diff --git a/electron/main/ai/agent/streaming-orchestrator.ts b/electron/main/ai/agent/streaming-orchestrator.ts index 2137a316..a7bc2f0a 100644 --- a/electron/main/ai/agent/streaming-orchestrator.ts +++ b/electron/main/ai/agent/streaming-orchestrator.ts @@ -1,8 +1,7 @@ import type { AiAgentStreamHandlers, AiKnowledgeDocumentDraft, AiTaskKnowledgeContext, AiTaskPayload, AiTaskResponse } from '../shared-types' import { normalizeSettings, validateSettings, resolveMaxTokens, shouldOmitMaxTokens } from '../settings' import { getTaskHandler } from '../tasks' -import { resolveTaskSkills, getSkillById, getAllSkills } from '../skills' -import { isSkillEnabledForTask } from '../skills/task-selection' +import { resolveTaskSkills, resolveSkillEnabledOverrides, getSkillById, getAllSkills } from '../skills' import { buildPromptInput } from '../runtime/context-builder' import { enrichTaskContextForGeneration } from '../runtime/task-context' import { buildRunMeta, buildResponsePreview } from '../runtime/run-meta' @@ -68,7 +67,7 @@ export async function runStreamingAgentTask( const chapterId = String(task.context.chapterId ?? '').trim() || undefined const handler = getTaskHandler(task.task) - const { projectId, skills: candidateSkills, usedSkillIds } = await resolveTaskSkills(task) + const { projectId, skills: candidateSkills, usedSkillIds, policy: skillPolicy } = await resolveTaskSkills(task) logSelection(task.task, candidateSkills, knowledgeContext?.usedKnowledge ?? []) await enrichTaskContextForGeneration(task, settings) @@ -91,12 +90,18 @@ export async function runStreamingAgentTask( .map((sel) => getSkillById(sel.id, projectId || undefined)) .filter((s): s is NonNullable => Boolean(s)) - const requiredSkillDefs = candidateSkillDefs.filter((s) => s.manifest.required) - const optionalSkillDefs = candidateSkillDefs.filter((s) => !s.manifest.required) + const requiredSkillDefs = skillPolicy.mode === 'only' + ? candidateSkillDefs + : candidateSkillDefs.filter((s) => s.manifest.required) + const optionalSkillDefs = skillPolicy.mode === 'only' + ? [] + : candidateSkillDefs.filter((s) => !s.manifest.required) + const allowedSkillIds = new Set(candidateSkillDefs.map((skill) => skill.id)) + const skillEnabledOverrides = resolveSkillEnabledOverrides(task, projectId) const maxSteps = resolveStreamingAgentMaxSteps(task.task) const requiredSkillBlock = requiredSkillDefs.length - ? `\n\n## 强制生效的 SKILLS\n\n${requiredSkillDefs.map((s) => { + ? `\n\n## ${skillPolicy.mode === 'only' ? '本轮仅使用的 SKILLS(不得混用其他 Skill)' : '强制生效的 SKILLS'}\n\n${requiredSkillDefs.map((s) => { const body = stripSkillFrontmatter(s.content).trim().slice(0, 2000) return `### ${s.name}\n${body}` }).join('\n\n')}` @@ -254,10 +259,11 @@ export async function runStreamingAgentTask( const skillIndexBlock = task.task === 'chapter-first-draft' ? '' : buildSkillIndex(optionalSkillDefs) const systemPrompt = `${prompt.system}${requiredSkillBlock}${preloadedSkillRefsBlock}${chapterToolsBlock}${contextModulesBlock}\n${skillIndexBlock}\n${buildAgentBehaviorRules()}${globalAssistantRules}${settingProposalRules}${chapterDraftRules}${skillUsageHints}` - const skillTools = createSkillTools({ + const skillTools = skillPolicy.mode === 'off' ? [] : createSkillTools({ resolveSkill: (id) => getSkillById(id, projectId || undefined), listSkills: () => getAllSkills(projectId || undefined), - resolveSkillEnabled: (skill) => isSkillEnabledForTask(task, skill.id, projectId), + resolveSkillEnabled: (skill) => skillEnabledOverrides?.get(skill.id) ?? skill.enabled, + allowSkillUse: (skill) => allowedSkillIds.has(skill.id), allowScriptExecution: (skill) => skill.scope === 'builtin' }) diff --git a/electron/main/ai/agent/tools/skill-tools.ts b/electron/main/ai/agent/tools/skill-tools.ts index ecd9fc9b..1399090b 100644 --- a/electron/main/ai/agent/tools/skill-tools.ts +++ b/electron/main/ai/agent/tools/skill-tools.ts @@ -13,6 +13,8 @@ export type SkillToolFactoryOptions = { listSkills?: () => SkillDefinition[] /** 解析当前项目下 skill 是否启用;未提供时使用 skill 自身默认 enabled。 */ resolveSkillEnabled?: (skill: SkillDefinition) => boolean + /** 当前执行计划是否授权使用该 skill;与项目启用状态分离,避免 skill_list 状态失真。 */ + allowSkillUse?: (skill: SkillDefinition) => boolean /** 是否允许 skill_run_script。builtin skill 默认开;project skill 应受 settings 控制。 */ allowScriptExecution?: (skill: SkillDefinition) => boolean /** 单文件读取上限。0 表示不截断。默认不截断。 */ @@ -92,6 +94,9 @@ export function createSkillTools(opts: SkillToolFactoryOptions): Tool[] { function resolveSkillOrError(skillId: string): SkillDefinition | { error: string } { const skill = opts.resolveSkill(skillId) if (!skill) return { error: `skill 未找到:${skillId}` } + const enabled = opts.resolveSkillEnabled?.(skill) ?? skill.enabled + const allowed = opts.allowSkillUse?.(skill) ?? enabled + if (!enabled || !allowed) return { error: `当前执行策略不允许使用 skill:${skillId}` } return skill } diff --git a/electron/main/ai/runtime-v2/execution-plan.ts b/electron/main/ai/runtime-v2/execution-plan.ts index c5fb8fd0..aa4b7a3a 100644 --- a/electron/main/ai/runtime-v2/execution-plan.ts +++ b/electron/main/ai/runtime-v2/execution-plan.ts @@ -19,7 +19,7 @@ import { createChapterTools } from '../agent/tools/chapter-tools' import { createProjectDataTools } from '../agent/tools/project-data-tools' import { createKnowledgeTools } from '../agent/tools/knowledge-tools' import { createSkillTools } from '../agent/tools/skill-tools' -import { getAllSkills, getSkillById, resolveTaskSkills, isSkillEnabledForTask } from '../skills' +import { getAllSkills, getSkillById, resolveSkillEnabledOverrides, resolveTaskSkills } from '../skills' import type { SkillDefinition, SkillStageId } from '../skills' import { normalizeSettings, validateSettings } from '../settings' import { assembleContextBlock, contextBuilder, estimateTokens } from './context-builder' @@ -101,11 +101,13 @@ export function createExecutionPlanner( runtimePlan, activeScopeRef }) - const { projectId, skills: selectedSkills } = await resolveTaskSkills(skillTask) + const { projectId, skills: selectedSkills, policy: skillPolicy } = await resolveTaskSkills(skillTask) const matchedSkillDefs = selectedSkills .map((sel) => getSkillById(sel.id, projectId || undefined)) .filter((skill): skill is SkillDefinition => Boolean(skill)) - const skillPromptBlock = buildV2SkillPromptBlock(matchedSkillDefs, surface) + const skillPromptBlock = buildV2SkillPromptBlock(matchedSkillDefs, surface, skillPolicy.mode === 'only') + const allowedSkillIds = new Set(selectedSkills.map((skill) => skill.id)) + const skillEnabledOverrides = resolveSkillEnabledOverrides(skillTask, projectId) // 2. 拼 system prompt const systemPrompt = buildAssistantSystemPrompt({ @@ -142,10 +144,12 @@ export function createExecutionPlanner( defaultSourceLabel: 'assistant-v2' }) - const skillTools = createSkillTools({ + const skillTools = skillPolicy.mode === 'off' ? [] : createSkillTools({ resolveSkill: (id) => getSkillById(id, session.projectId || undefined), listSkills: () => getAllSkills(session.projectId || undefined), - resolveSkillEnabled: (skill) => isSkillEnabledForTask(skillTask, skill.id, projectId), + resolveSkillEnabled: (skill) => skillEnabledOverrides?.get(skill.id) ?? skill.enabled, + // 执行计划是唯一授权来源:不在本轮 selected 中的 Skill 无法再被模型按 id 绕过加载。 + allowSkillUse: (skill) => allowedSkillIds.has(skill.id), // 只允许 builtin skill 跑脚本,跟旧路径一致 allowScriptExecution: (skill) => skill.scope === 'builtin' }) @@ -225,7 +229,15 @@ export function createExecutionPlanner( settings, maxOutputTokens, runtimePlan, - evidenceLedger + evidenceLedger, + skillPlan: { + mode: skillPolicy.mode, + items: matchedSkillDefs.map((skill) => ({ + id: skill.id, + name: skill.name, + state: (skillPolicy.mode === 'only' || skill.manifest.required) ? 'injected' as const : 'candidate' as const + })) + } } } } @@ -249,17 +261,20 @@ function hasSkillToolAccess(surface: SurfaceDefinition): boolean { function buildV2SkillPromptBlock( skills: SkillDefinition[], - surface: SurfaceDefinition + surface: SurfaceDefinition, + forceInject: boolean ): string { if (!skills.length) return '' - const requiredSkills = skills.filter((skill) => skill.manifest.required) - const optionalSkills = skills.filter((skill) => !skill.manifest.required) + const requiredSkills = forceInject ? skills : skills.filter((skill) => skill.manifest.required) + const optionalSkills = forceInject ? [] : skills.filter((skill) => !skill.manifest.required) const sections: string[] = [] if (requiredSkills.length > 0) { sections.push([ - '## 强制生效的 SKILLS(已直接注入,无需调用 skill_load)', + forceInject + ? '## 本轮仅使用的 SKILLS(已直接注入,不得加载或混用其他 Skill)' + : '## 强制生效的 SKILLS(已直接注入,无需调用 skill_load)', '', ...requiredSkills.map((skill) => { const body = stripSkillFrontmatter(skill.content).trim().slice(0, 2000) @@ -304,6 +319,7 @@ function createV2SkillTask(params: { projectGenre: project?.genre ?? '', projectNovelLength: project?.novelLength ?? '', projectSkills: project?.projectSkills ?? [], + skillPolicy: params.request.skillPolicy ?? project?.skillPolicy, userPrompt: params.request.userMessage, originalUserPrompt: params.request.userMessage, quickAction: params.request.intentHint ?? '', diff --git a/electron/main/ai/runtime-v2/ipc.ts b/electron/main/ai/runtime-v2/ipc.ts index 1875bead..f9c4a6e1 100644 --- a/electron/main/ai/runtime-v2/ipc.ts +++ b/electron/main/ai/runtime-v2/ipc.ts @@ -20,6 +20,8 @@ import { type StageBindTargetRequest, type StageCommitRequest, type StageRejectRequest, + type SkillExecutionReceiptItem, + type SkillUseMode, type SurfaceDefinition, type TurnEvent, type TurnCancelRequest, @@ -54,6 +56,7 @@ export type ResolveTurnExecutionPlan = (params: { maxOutputTokens?: number runtimePlan: AssistantRuntimePlan evidenceLedger: EvidenceLedger + skillPlan: { mode: SkillUseMode; items: SkillExecutionReceiptItem[] } }> /** 外部依赖注入。 */ @@ -355,7 +358,15 @@ function registerTurnHandlers(): void { signal: controller.signal, maxSteps: payload.surface.maxSteps, maxOutputTokens: plan.maxOutputTokens, - onTurnCreated: registerActiveKey + onTurnCreated: (turnId) => { + registerActiveKey(turnId) + appendRuntimeEvent(cm, emitter, session.id, turnId, { + kind: 'skill_plan', + seq: 0, + mode: plan.skillPlan.mode, + items: plan.skillPlan.items + }) + } }) const ledgerSnapshot = plan.evidenceLedger.snapshot() const resumable = shouldOfferContinuation(plan.runtimePlan, ledgerSnapshot, result) diff --git a/electron/main/ai/skills/index.ts b/electron/main/ai/skills/index.ts index 4b347981..72dfe34c 100644 --- a/electron/main/ai/skills/index.ts +++ b/electron/main/ai/skills/index.ts @@ -5,10 +5,11 @@ export { getAllSkills, getSkillById, getEnabledSkills, + resolveCanonicalSkillIds, toScanEntries, toContextEntries } from './registry' export { pickSkillsFor } from './matcher' -export { isSkillEnabledForTask, resolveSkillEnabledOverrides, resolveTaskSkills } from './task-selection' +export { isSkillEnabledForTask, resolveSkillEnabledOverrides, resolveSkillStageOverrides, resolveTaskSkills } from './task-selection' export type { ResolvedTaskSkills } from './task-selection' export { getProjectSkillsDirPath, scanSkillsFromDisk } from './discovery' diff --git a/electron/main/ai/skills/matcher.ts b/electron/main/ai/skills/matcher.ts index cc0794a0..eb12f27c 100644 --- a/electron/main/ai/skills/matcher.ts +++ b/electron/main/ai/skills/matcher.ts @@ -4,10 +4,18 @@ import { getAllSkills, getEnabledSkills } from './registry' import { loadSkillReferences } from './loader' import { getTaskHandler } from '../tasks' import { matchNarrativeFunction } from './narrative-function-map' +import { isSkillExplicitlyMentioned, selectSkillCandidates } from './selection-priority' /** 默认最大匹配 skill 数量 */ const DEFAULT_MAX_SKILLS = 4 +export type SkillMatchOptions = { + enabledOverrides?: Map + stageOverrides?: Map + /** 存在时进入严格手动模式:只选择这些 id,忽略自动评分与 required。 */ + onlySkillIds?: string[] +} + /** 评分分项明细,用于日志排查与调参 */ export type ScoreBreakdown = { total: number @@ -27,7 +35,7 @@ export type ScoreBreakdown = { */ export async function pickSkillsFor( task: AiTaskPayload, - enabledOverrides?: Map + options: SkillMatchOptions = {} ): Promise { let handler: ReturnType | undefined try { @@ -40,32 +48,54 @@ export async function pickSkillsFor( } const projectId = String(task.context.projectId ?? '').trim() - const skills = enabledOverrides - ? getAllSkills(projectId).filter((skill) => enabledOverrides.get(skill.id) === true) + const skills = options.enabledOverrides + ? getAllSkills(projectId).filter((skill) => options.enabledOverrides?.get(skill.id) === true) : getEnabledSkills(projectId) const context = task.context ?? {} + if (options.onlySkillIds) { + const byId = new Map(skills.map((skill) => [skill.id, skill])) + const selected = options.onlySkillIds + .map((id) => byId.get(id)) + .filter((skill): skill is SkillDefinition => Boolean(skill && skill.compatibility !== 'external-only')) + return loadSelections(selected.map((skill) => ({ + skill, + breakdown: { + total: 100, + task: 0, + stage: 0, + trigger: 0, + narrative: 0, + length: 0, + priority: 0 + } + })), task) + } + // 从 TaskHandler 读取 maxSkills,允许复杂任务使用更多 skill let maxSkills = DEFAULT_MAX_SKILLS if (handler?.maxSkills && handler.maxSkills > 0) { maxSkills = handler.maxSkills } + const stageId = String(context.stageId ?? '').trim() + const userPrompt = String(context.userPrompt ?? '') const candidates = skills + .filter((skill) => { + if (isSkillExplicitlyMentioned(skill, userPrompt)) return true + if (!stageId || !options.stageOverrides?.has(skill.id)) return true + return options.stageOverrides.get(skill.id)?.includes(stageId as SkillDefinition['manifest']['stages'][number]) === true + }) .map((skill) => ({ skill, breakdown: computeScore(skill, task, context) })) - .filter((entry) => entry.breakdown.total > 0 || entry.skill.manifest.required) + const selected = selectSkillCandidates(candidates, userPrompt, maxSkills, compareCandidates) - // required skill 无条件保留(即使被高分 optional 挤出 slice), - // 兑现 SkillManifest.required 的语义:"用户明确要求必须生效"。 - // optional 仅填补 required 之外的剩余名额。 - const required = candidates.filter((entry) => entry.skill.manifest.required) - const optional = candidates - .filter((entry) => !entry.skill.manifest.required) - .sort(compareCandidates) - - const remainingSlots = Math.max(0, maxSkills - required.length) - const selected = [...required.sort(compareCandidates), ...optional.slice(0, remainingSlots)] + return loadSelections(selected, task) +} +async function loadSelections( + selected: Array<{ skill: SkillDefinition; breakdown: ScoreBreakdown }>, + task: AiTaskPayload +): Promise { const results: SkillSelection[] = [] for (const { skill, breakdown } of selected) { const referenceContents = await loadSkillReferences(skill, task) diff --git a/electron/main/ai/skills/policy.test.mjs b/electron/main/ai/skills/policy.test.mjs new file mode 100644 index 00000000..ceab0599 --- /dev/null +++ b/electron/main/ai/skills/policy.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { normalizeSkillUsePolicy } from '../../../shared/assistant-runtime.ts' + +test('Skill 策略规范化支持自动、仅使用和不使用三态', () => { + assert.deepEqual(normalizeSkillUsePolicy(undefined), { mode: 'auto', skillIds: [] }) + assert.deepEqual(normalizeSkillUsePolicy({ mode: 'only', skillIds: ['a', 'a', '', ' b '] }), { + mode: 'only', + skillIds: ['a', 'b'] + }) + assert.deepEqual(normalizeSkillUsePolicy({ mode: 'off', skillIds: ['a'] }), { + mode: 'off', + skillIds: ['a'] + }) +}) + +test('Skill 工具统一在解析入口校验本轮执行授权', () => { + const source = readFileSync(new URL('../agent/tools/skill-tools.ts', import.meta.url), 'utf8') + assert.match(source, /opts\.allowSkillUse\?\.\(skill\) \?\? enabled/) + assert.match(source, /当前执行策略不允许使用 skill/) +}) diff --git a/electron/main/ai/skills/registry-merge.test.mjs b/electron/main/ai/skills/registry-merge.test.mjs new file mode 100644 index 00000000..1161bb8c --- /dev/null +++ b/electron/main/ai/skills/registry-merge.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { mergeSkillDefinitions } from './registry-merge.ts' + +function skill({ id, name, content, scope = 'project' }) { + return { + id, + name, + version: '', + path: `project-skills/${id}`, + scope, + rootDir: `/tmp/${id}`, + description: '同一份技能说明', + source: '', + manifest: { category: 'writing', tasks: [], stages: ['draft'], triggers: [], priority: 50, references: [] }, + compatibility: 'native', + compatibilityNote: '', + enabled: true, + referencesCount: 0, + referenceFiles: [], + content + } +} + +test('全局中文版 Skill 优先,并合并仅 name 不同的历史项目副本', () => { + const body = '\n正文规则完全相同。\n' + const shared = [skill({ + id: '写作润色', + name: '我吃西红柿笔锋', + content: `---\nname: 我吃西红柿笔锋\ndescription: 同一份技能说明\n---\n${body}` + })] + const project = [ + skill({ + id: 'fiction-human-texture', + name: 'fiction-human-texture', + content: `---\nname: fiction-human-texture\ndescription: 同一份技能说明\n---\n${body}` + }), + skill({ + id: '写作润色', + name: 'fiction-human-texture', + content: `---\nname: fiction-human-texture\ndescription: 同一份技能说明\n---\n${body}` + }) + ] + + const merged = mergeSkillDefinitions(shared, project) + assert.equal(merged.length, 1) + assert.equal(merged[0].id, '写作润色') + assert.equal(merged[0].name, '我吃西红柿笔锋') +}) + +test('内容不同的项目 Skill 仍会保留', () => { + const shared = [skill({ id: '全局技能', name: '全局技能', content: '全局正文' })] + const project = [skill({ id: '项目技能', name: '项目技能', content: '不同正文' })] + assert.deepEqual(mergeSkillDefinitions(shared, project).map((item) => item.id), ['全局技能', '项目技能']) +}) diff --git a/electron/main/ai/skills/registry-merge.ts b/electron/main/ai/skills/registry-merge.ts new file mode 100644 index 00000000..f1777545 --- /dev/null +++ b/electron/main/ai/skills/registry-merge.ts @@ -0,0 +1,57 @@ +import type { SkillDefinition } from './types' + +/** + * 生成不受展示名称影响的 Skill 内容标识。 + * 旧版导入可能只改了 frontmatter 的 name 或目录名,正文规则实际完全相同。 + */ +export function resolveSkillContentIdentity(skill: SkillDefinition): string { + const lines = String(skill.content ?? '').replace(/\r\n?/g, '\n').split('\n') + if (lines[0]?.trim() === '---') { + const closingIndex = lines.slice(1).findIndex((line) => line.trim() === '---') + if (closingIndex >= 0) { + const frontmatterEnd = closingIndex + 1 + for (let index = 1; index < frontmatterEnd; index += 1) { + if (/^\s*name\s*:/i.test(lines[index] ?? '')) lines[index] = '' + } + } + } + return lines.map((line) => line.trimEnd()).join('\n').trim() +} + +/** + * 合并全局与项目注册表。全局条目优先;历史项目副本若仅目录名或展示名不同, + * 会按内容标识折叠,避免在项目设置中出现英文旧名和重复项。 + */ +export function mergeSkillDefinitions( + sharedSkills: Iterable, + projectSkills: Iterable +): SkillDefinition[] { + const result: SkillDefinition[] = [] + const seenIds = new Set() + const seenContent = new Set() + + const append = (skill: SkillDefinition): void => { + const contentIdentity = resolveSkillContentIdentity(skill) + if (seenIds.has(skill.id) || (contentIdentity && seenContent.has(contentIdentity))) return + seenIds.add(skill.id) + if (contentIdentity) seenContent.add(contentIdentity) + result.push(skill) + } + + for (const skill of sharedSkills) append(skill) + for (const skill of projectSkills) append(skill) + return result +} + +/** 按规范 ID 查找,找不到时兼容唯一的显示名称。 */ +export function findSkillDefinitionByIdOrName( + skills: Iterable, + idOrName: string +): T | undefined { + const entries = Array.from(skills) + const key = idOrName.normalize('NFKC').trim().toLocaleLowerCase('zh-CN') + const exactId = entries.find((skill) => skill.id.normalize('NFKC').trim().toLocaleLowerCase('zh-CN') === key) + if (exactId) return exactId + const nameMatches = entries.filter((skill) => skill.name.normalize('NFKC').trim().toLocaleLowerCase('zh-CN') === key) + return nameMatches.length === 1 ? nameMatches[0] : undefined +} diff --git a/electron/main/ai/skills/registry.ts b/electron/main/ai/skills/registry.ts index 60cc960a..6469f9a8 100644 --- a/electron/main/ai/skills/registry.ts +++ b/electron/main/ai/skills/registry.ts @@ -1,5 +1,6 @@ import type { SkillDefinition, SkillScanEntry } from './types' import { scanSkillsFromDisk } from './discovery' +import { findSkillDefinitionByIdOrName, mergeSkillDefinitions, resolveSkillContentIdentity } from './registry-merge' /** 按项目 ID 存储的 skill 注册表,每个项目有独立的 skill Map */ const skillMaps = new Map>() @@ -17,8 +18,13 @@ function resolveRegistryKey(projectId?: string): string { * @param projectId - 项目标识 */ export async function initRegistry(projectId?: string): Promise { + const key = resolveRegistryKey(projectId) + if (key !== '_shared') { + const sharedSkills = await scanSkillsFromDisk() + skillMaps.set('_shared', new Map(sharedSkills.map((s) => [s.id, s]))) + } const skills = await scanSkillsFromDisk(projectId) - skillMaps.set(resolveRegistryKey(projectId), new Map(skills.map((s) => [s.id, s]))) + skillMaps.set(key, new Map(skills.map((s) => [s.id, s]))) initialized = true } @@ -27,8 +33,13 @@ export async function initRegistry(projectId?: string): Promise { * @param projectId - 项目标识 */ export async function refreshRegistry(projectId?: string): Promise { + const key = resolveRegistryKey(projectId) + if (key !== '_shared') { + const sharedSkills = await scanSkillsFromDisk() + skillMaps.set('_shared', new Map(sharedSkills.map((s) => [s.id, s]))) + } const skills = await scanSkillsFromDisk(projectId) - skillMaps.set(resolveRegistryKey(projectId), new Map(skills.map((s) => [s.id, s]))) + skillMaps.set(key, new Map(skills.map((s) => [s.id, s]))) initialized = true } @@ -46,11 +57,30 @@ export function getAllSkills(projectId?: string): SkillDefinition[] { const key = resolveRegistryKey(projectId) const projectMap = skillMaps.get(key) const sharedMap = key !== '_shared' ? skillMaps.get('_shared') : undefined - if (!sharedMap) return Array.from(projectMap?.values() ?? []) - if (!projectMap) return Array.from(sharedMap.values()) - const merged = new Map(sharedMap) - for (const [k, v] of projectMap) merged.set(k, v) - return Array.from(merged.values()) + if (!sharedMap) return mergeSkillDefinitions([], projectMap?.values() ?? []) + if (!projectMap) return mergeSkillDefinitions(sharedMap.values(), []) + return mergeSkillDefinitions(sharedMap.values(), projectMap.values()) +} + +/** 将旧项目 Skill ID 解析为当前全局优先合并后的规范 ID。 */ +export function resolveCanonicalSkillIds(projectId: string, skillIds: string[]): string[] { + const canonicalSkills = getAllSkills(projectId || undefined) + const canonicalById = new Map(canonicalSkills.map((skill) => [skill.id, skill])) + const canonicalByContent = new Map( + canonicalSkills.map((skill) => [resolveSkillContentIdentity(skill), skill]) + ) + const rawProjectMap = skillMaps.get(resolveRegistryKey(projectId)) + const resolved: string[] = [] + + for (const id of skillIds) { + const exact = canonicalById.get(id) + const legacy = rawProjectMap?.get(id) + const canonical = exact ?? (legacy + ? canonicalByContent.get(resolveSkillContentIdentity(legacy)) + : undefined) + if (canonical && !resolved.includes(canonical.id)) resolved.push(canonical.id) + } + return resolved } /** @@ -61,8 +91,14 @@ export function getAllSkills(projectId?: string): SkillDefinition[] { */ export function getSkillById(id: string, projectId?: string): SkillDefinition | undefined { const key = resolveRegistryKey(projectId) - return skillMaps.get(key)?.get(id) - ?? (key !== '_shared' ? skillMaps.get('_shared')?.get(id) : undefined) + const canonicalSkills = getAllSkills(projectId) + const exact = findSkillDefinitionByIdOrName(canonicalSkills, id) + if (exact) return exact + + const legacy = skillMaps.get(key)?.get(id) + if (!legacy) return undefined + const legacyIdentity = resolveSkillContentIdentity(legacy) + return canonicalSkills.find((skill) => resolveSkillContentIdentity(skill) === legacyIdentity) } /** diff --git a/electron/main/ai/skills/selection-priority.test.mjs b/electron/main/ai/skills/selection-priority.test.mjs new file mode 100644 index 00000000..2c592729 --- /dev/null +++ b/electron/main/ai/skills/selection-priority.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { selectSkillCandidates } from './selection-priority.ts' +import { findSkillDefinitionByIdOrName } from './registry-merge.ts' + +function entry(id, name, total, required = false) { + return { + skill: { + id, + name, + manifest: { required } + }, + breakdown: { total } + } +} + +const compare = (a, b) => b.breakdown.total - a.breakdown.total + +test('自动模式中用户点名的 Skill 不会被高分候选和数量上限挤掉', () => { + const entries = [ + entry('humanizer-zh', 'humanizer-zh', 20, true), + entry('novel-pacing', 'novel-pacing', 15), + entry('novel-emotion', 'novel-emotion', 14), + entry('novel-language-style', 'novel-language-style', 13), + entry('novel-polishing', 'novel-polishing', 12), + entry('novel-pleasure-points', 'novel-pleasure-points', 11), + entry('我吃西红柿笔锋', '我吃西红柿笔锋', 0) + ] + + const selected = selectSkillCandidates( + entries, + '继续创作章节,写作优先使用我吃西红柿笔锋', + 6, + compare + ) + + assert.equal(selected.length, 6) + assert.ok(selected.some((item) => item.skill.id === '我吃西红柿笔锋')) + assert.ok(selected.some((item) => item.skill.id === 'humanizer-zh')) +}) + +test('显示名称与目录 ID 不同时,点名显示名称也能优先并被工具解析', () => { + const named = entry('fiction-human-texture', '我吃西红柿笔锋', 0) + const selected = selectSkillCandidates([named], '请优先使用我吃西红柿笔锋', 4, compare) + assert.equal(selected[0]?.skill.id, 'fiction-human-texture') + assert.equal( + findSkillDefinitionByIdOrName([named.skill], '我吃西红柿笔锋')?.id, + 'fiction-human-texture' + ) +}) diff --git a/electron/main/ai/skills/selection-priority.ts b/electron/main/ai/skills/selection-priority.ts new file mode 100644 index 00000000..049599ef --- /dev/null +++ b/electron/main/ai/skills/selection-priority.ts @@ -0,0 +1,44 @@ +type CandidateSkill = { + id: string + name: string + manifest: { required?: boolean } +} + +export type PrioritizedSkillCandidate = { + skill: CandidateSkill + breakdown: { total: number } +} + +function normalizeMention(value: string): string { + return value.normalize('NFKC').toLocaleLowerCase('zh-CN').replace(/\s+/g, '') +} + +/** 用户在自然语言中明确写出 Skill ID 或显示名称时,视为主动点名。 */ +export function isSkillExplicitlyMentioned(skill: CandidateSkill, userPrompt: string): boolean { + const prompt = normalizeMention(userPrompt) + if (!prompt) return false + return [skill.id, skill.name] + .map(normalizeMention) + .filter((value) => value.length >= 2) + .some((value) => prompt.includes(value)) +} + +/** + * 自动模式候选选择:required 与用户点名项优先保留,剩余名额再按评分填充。 + * 点名项不受普通候选数量上限挤压;若点名/必选项本身超过上限,则全部保留。 + */ +export function selectSkillCandidates( + entries: T[], + userPrompt: string, + maxSkills: number, + compare: (a: T, b: T) => number +): T[] { + const decorated = entries.map((entry) => ({ + entry, + pinned: entry.skill.manifest.required === true || isSkillExplicitlyMentioned(entry.skill, userPrompt) + })) + const eligible = decorated.filter(({ entry, pinned }) => entry.breakdown.total > 0 || pinned) + const pinned = eligible.filter((item) => item.pinned).map((item) => item.entry).sort(compare) + const optional = eligible.filter((item) => !item.pinned).map((item) => item.entry).sort(compare) + return [...pinned, ...optional.slice(0, Math.max(0, maxSkills - pinned.length))] +} diff --git a/electron/main/ai/skills/task-selection.ts b/electron/main/ai/skills/task-selection.ts index abf7154e..eed2bf0e 100644 --- a/electron/main/ai/skills/task-selection.ts +++ b/electron/main/ai/skills/task-selection.ts @@ -1,24 +1,54 @@ import type { AiTaskPayload } from '../shared-types' -import { getAllSkills, getEnabledSkills, refreshRegistry } from './registry' +import { normalizeSkillUsePolicy, type SkillUsePolicy } from '../../../shared/assistant-runtime' +import { getAllSkills, getEnabledSkills, refreshRegistry, resolveCanonicalSkillIds } from './registry' import { pickSkillsFor } from './matcher' -import type { SkillSelection } from './types' +import type { SkillSelection, SkillStageId } from './types' export type ResolvedTaskSkills = { projectId: string skills: SkillSelection[] usedSkillIds: string[] + policy: SkillUsePolicy } export async function resolveTaskSkills(task: AiTaskPayload): Promise { const projectId = String(task.context.projectId ?? '').trim() await refreshRegistry(projectId || undefined).catch(() => {}) - const skills = await pickSkillsFor(task, resolveSkillEnabledOverrides(task, projectId)) + const rawPolicy = normalizeSkillUsePolicy(task.context.skillPolicy) + const policy = rawPolicy.mode === 'only' + ? { ...rawPolicy, skillIds: resolveCanonicalSkillIds(projectId, rawPolicy.skillIds) } + : rawPolicy + if (policy.mode === 'off' || (policy.mode === 'only' && policy.skillIds.length === 0)) { + return { projectId, skills: [], usedSkillIds: [], policy } + } + const skills = await pickSkillsFor(task, { + enabledOverrides: resolveSkillEnabledOverrides(task, projectId), + stageOverrides: resolveSkillStageOverrides(task, projectId), + ...(policy.mode === 'only' ? { onlySkillIds: policy.skillIds } : {}) + }) return { projectId, skills, - usedSkillIds: skills.map((skill) => skill.id) + usedSkillIds: skills.map((skill) => skill.id), + policy + } +} + +export function resolveSkillStageOverrides(task: AiTaskPayload, projectId = ''): Map | undefined { + if (!Array.isArray(task.context.projectSkills) || task.context.projectSkills.length === 0) return undefined + const result = new Map() + for (const rawSkill of task.context.projectSkills) { + if (!rawSkill || typeof rawSkill !== 'object') continue + const skill = rawSkill as { id?: unknown; stageIds?: unknown } + const id = String(skill.id ?? '').trim() + if (!id || !Array.isArray(skill.stageIds)) continue + const canonicalId = resolveCanonicalSkillIds(projectId, [id])[0] ?? id + result.set(canonicalId, skill.stageIds.filter((stage): stage is SkillStageId => ( + stage === 'reference' || stage === 'premise' || stage === 'setting' || stage === 'outline' || stage === 'draft' + ))) } + return result.size ? result : undefined } export function resolveSkillEnabledOverrides( @@ -42,10 +72,12 @@ export function resolveSkillEnabledOverrides( } if (typeof skill.enabled === 'boolean') { hasExplicitEnabledState = true - overrideById.set(id, skill.enabled) + const canonicalId = resolveCanonicalSkillIds(projectId, [id])[0] ?? id + overrideById.set(canonicalId, skill.enabled) } else { // 兼容旧调用方:它们只传"已启用 skill"列表,条目里没有 enabled 字段。 - overrideById.set(id, true) + const canonicalId = resolveCanonicalSkillIds(projectId, [id])[0] ?? id + overrideById.set(canonicalId, true) } } @@ -56,7 +88,7 @@ export function resolveSkillEnabledOverrides( return new Map(allSkills.map((skill) => [ skill.id, - overrideById.get(skill.id) ?? (hasExplicitEnabledState ? skill.enabled : false) + overrideById.get(skill.id) ?? (hasExplicitEnabledState ? false : skill.enabled) ])) } @@ -65,9 +97,16 @@ export function isSkillEnabledForTask( skillId: string, projectId: string ): boolean { + const policy = normalizeSkillUsePolicy(task.context.skillPolicy) + if (policy.mode === 'off') return false + const canonicalSkillId = resolveCanonicalSkillIds(projectId, [skillId])[0] ?? skillId + const canonicalPolicyIds = policy.mode === 'only' + ? resolveCanonicalSkillIds(projectId, policy.skillIds) + : [] + if (policy.mode === 'only' && !canonicalPolicyIds.includes(canonicalSkillId)) return false const overrides = resolveSkillEnabledOverrides(task, projectId) - if (overrides?.has(skillId)) { - return overrides.get(skillId) === true + if (overrides?.has(canonicalSkillId)) { + return overrides.get(canonicalSkillId) === true } - return getEnabledSkills(projectId || undefined).some((skill) => skill.id === skillId) + return getEnabledSkills(projectId || undefined).some((skill) => skill.id === canonicalSkillId) } diff --git a/electron/main/register-main-ipc.ts b/electron/main/register-main-ipc.ts index 4d3381b1..a7b6a2a0 100644 --- a/electron/main/register-main-ipc.ts +++ b/electron/main/register-main-ipc.ts @@ -1,6 +1,6 @@ import { BrowserWindow, dialog, ipcMain, nativeTheme, shell } from 'electron' import { existsSync } from 'node:fs' -import { cp, mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises' +import { cp, mkdir, readFile, readdir, rm, stat, unlink, writeFile } from 'node:fs/promises' import { basename, join } from 'node:path' import type { DatabaseSync } from 'node:sqlite' import * as XLSX from 'xlsx' @@ -1468,6 +1468,27 @@ export function registerMainIpcHandlers(deps: RegisterMainIpcHandlersDeps): void } }) + ipcMain.handle('characterarc:global-skill-delete', async (_event, skillId: unknown) => { + try { + const normalizedSkillId = String(skillId ?? '').trim() + if (!normalizedSkillId || basename(normalizedSkillId) !== normalizedSkillId || normalizedSkillId === '.' || normalizedSkillId === '..') { + return { success: false, error: 'Skill 标识无效。' } + } + + const sharedRoot = getSkillsDirPath() + const targetDir = join(sharedRoot, normalizedSkillId) + if (!existsSync(join(targetDir, 'SKILL.md'))) { + return { success: false, error: '只能删除用户导入的全局 Skill,内置 Skill 不会被删除。' } + } + + await rm(targetDir, { recursive: true, force: false }) + await refreshSkillRegistry() + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : '删除全局 Skill 失败' } + } + }) + ipcMain.handle('characterarc:project-skills-context', async (_event, projectId: unknown) => { try { const resolvedProjectId = String(projectId ?? '').trim() diff --git a/electron/main/workspace-store.ts b/electron/main/workspace-store.ts index 08c960c3..b0d2cfa9 100644 --- a/electron/main/workspace-store.ts +++ b/electron/main/workspace-store.ts @@ -75,6 +75,7 @@ export async function ensureWorkspaceDb(): Promise { writing_style_prompt TEXT NOT NULL DEFAULT '', novel_workflow_stages_json TEXT NOT NULL DEFAULT '[]', project_skills_json TEXT NOT NULL DEFAULT '[]', + skill_policy_json TEXT NOT NULL DEFAULT '{"mode":"auto","skillIds":[]}', chapter_assistant_templates_json TEXT NOT NULL DEFAULT '[]' ) STRICT; @@ -708,6 +709,10 @@ function ensureProjectColumns(db: DatabaseSync): void { db.exec(`ALTER TABLE projects ADD COLUMN project_skills_json TEXT NOT NULL DEFAULT '[]';`) } + if (!columnNames.has('skill_policy_json')) { + db.exec(`ALTER TABLE projects ADD COLUMN skill_policy_json TEXT NOT NULL DEFAULT '{"mode":"auto","skillIds":[]}';`) + } + if (!columnNames.has('target_platform')) { db.exec(`ALTER TABLE projects ADD COLUMN target_platform TEXT NOT NULL DEFAULT '';`) } @@ -747,16 +752,18 @@ export function readWorkspaceSnapshot(db: DatabaseSync): WorkspacePayload | null reference_works_json AS referenceWorksJson, novel_workflow_stages_json AS novelWorkflowStagesJson, project_skills_json AS projectSkillsJson, + skill_policy_json AS skillPolicyJson, chapter_assistant_templates_json AS chapterAssistantTemplatesJson FROM projects ORDER BY rowid ASC `).all() as Array< - Omit & { + Omit & { coverHistoryJson?: string referenceWorksJson?: string chapterAssistantTemplatesJson?: string novelWorkflowStagesJson?: string projectSkillsJson?: string + skillPolicyJson?: string } > @@ -767,6 +774,7 @@ export function readWorkspaceSnapshot(db: DatabaseSync): WorkspacePayload | null selectedReferenceWorkIds: parseJson(project.referenceWorksJson, [] as string[]), novelWorkflowStages: parseJson(project.novelWorkflowStagesJson, []), projectSkills: parseJson(project.projectSkillsJson, []), + skillPolicy: parseJson(project.skillPolicyJson, { mode: 'auto', skillIds: [] }), chapterAssistantTemplates: parseJson(project.chapterAssistantTemplatesJson, []) }) ) @@ -1394,8 +1402,8 @@ export function writeWorkspaceSnapshot(db: DatabaseSync, payload: WorkspacePaylo } const insertProject = db.prepare(` - INSERT INTO projects (id, title, genre, novel_length, word_count, last_edited, cover, target_platform, cover_history_json, reference_works_json, writing_style_preset_id, writing_style_prompt, novel_workflow_stages_json, project_skills_json, chapter_assistant_templates_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO projects (id, title, genre, novel_length, word_count, last_edited, cover, target_platform, cover_history_json, reference_works_json, writing_style_preset_id, writing_style_prompt, novel_workflow_stages_json, project_skills_json, skill_policy_json, chapter_assistant_templates_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, genre = excluded.genre, @@ -1410,6 +1418,7 @@ export function writeWorkspaceSnapshot(db: DatabaseSync, payload: WorkspacePaylo writing_style_prompt = excluded.writing_style_prompt, novel_workflow_stages_json = excluded.novel_workflow_stages_json, project_skills_json = excluded.project_skills_json, + skill_policy_json = excluded.skill_policy_json, chapter_assistant_templates_json = excluded.chapter_assistant_templates_json `) for (const project of payload.projects) { @@ -1429,6 +1438,7 @@ export function writeWorkspaceSnapshot(db: DatabaseSync, payload: WorkspacePaylo project.writingStylePrompt, JSON.stringify(project.novelWorkflowStages ?? []), JSON.stringify(project.projectSkills ?? []), + JSON.stringify(project.skillPolicy ?? { mode: 'auto', skillIds: [] }), JSON.stringify(project.chapterAssistantTemplates ?? []) ) } diff --git a/electron/main/workspace-types.ts b/electron/main/workspace-types.ts index df5df766..d7798649 100644 --- a/electron/main/workspace-types.ts +++ b/electron/main/workspace-types.ts @@ -1,4 +1,5 @@ import type { ReferenceStyleMetric } from './referenceAnalysis' +import type { SkillUsePolicy } from '../shared/assistant-runtime' export type KnowledgeDocumentSourceType = | 'reference-summary' @@ -167,6 +168,7 @@ export type WorkspacePayload = { enabled: boolean stageIds: Array<'reference' | 'premise' | 'setting' | 'outline' | 'draft'> }> + skillPolicy: SkillUsePolicy chapterAssistantTemplates: Array<{ id: string label: string @@ -637,6 +639,7 @@ export function normalizeProjectRecord( writingStylePrompt: project.writingStylePrompt || '', novelWorkflowStages: Array.isArray(project.novelWorkflowStages) ? project.novelWorkflowStages : [], projectSkills: Array.isArray(project.projectSkills) ? project.projectSkills : [], + skillPolicy: normalizeWorkspaceSkillPolicy(project.skillPolicy), chapterAssistantTemplates: Array.isArray(project.chapterAssistantTemplates) ? project.chapterAssistantTemplates : [], selectedReferenceWorkIds: Array.isArray(project.selectedReferenceWorkIds) ? project.selectedReferenceWorkIds.map((id) => String(id).trim()).filter(Boolean) @@ -644,6 +647,16 @@ export function normalizeProjectRecord( } } +function normalizeWorkspaceSkillPolicy(value: unknown): SkillUsePolicy { + if (!value || typeof value !== 'object') return { mode: 'auto', skillIds: [] } + const raw = value as { mode?: unknown; skillIds?: unknown } + const mode = raw.mode === 'only' || raw.mode === 'off' ? raw.mode : 'auto' + const skillIds = Array.isArray(raw.skillIds) + ? [...new Set(raw.skillIds.map((id) => String(id ?? '').trim()).filter(Boolean))] + : [] + return { mode, skillIds } +} + export function normalizeCoverWorkbenchHistory( items?: unknown ): WorkspacePayload['coverWorkbenchHistory'] { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 34263a77..fca82ba0 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -67,6 +67,8 @@ contextBridge.exposeInMainWorld('characterArc', { scanProjectSkills: (projectId: string) => ipcRenderer.invoke('characterarc:project-skills-scan', projectId), /** 从本地目录导入一组项目扩展 skills 到应用数据目录 */ importProjectSkillsPackage: (projectId: string) => ipcRenderer.invoke('characterarc:project-skills-import', projectId), + /** 删除一个用户导入的全局 Skill;内置 Skill 不允许删除 */ + deleteGlobalSkill: (skillId: string) => ipcRenderer.invoke('characterarc:global-skill-delete', skillId), /** 读取当前项目可用 skills 的正文内容(供 AI 内部使用) */ getProjectSkillsContext: (projectId: string) => ipcRenderer.invoke('characterarc:project-skills-context', projectId), diff --git a/electron/shared/assistant-runtime.ts b/electron/shared/assistant-runtime.ts index d2a67b7a..96c7a9b2 100644 --- a/electron/shared/assistant-runtime.ts +++ b/electron/shared/assistant-runtime.ts @@ -23,6 +23,36 @@ export type SurfaceId = /** Surface 上下文的绑定范围。 */ export type SurfaceScope = 'project' | 'chapter' | 'selection' +/** Skill 的使用策略。only 表示严格只允许所列 Skill,off 表示本轮完全禁用。 */ +export type SkillUseMode = 'auto' | 'only' | 'off' + +export interface SkillUsePolicy { + mode: SkillUseMode + skillIds: string[] +} + +export interface SkillExecutionReceiptItem { + id: string + name: string + state: 'candidate' | 'injected' | 'loaded' +} + +export const DEFAULT_SKILL_USE_POLICY: SkillUsePolicy = { + mode: 'auto', + skillIds: [] +} + +/** 兼容旧项目与不可信 IPC 输入,统一收敛为稳定的 Skill 策略。 */ +export function normalizeSkillUsePolicy(value: unknown): SkillUsePolicy { + if (!value || typeof value !== 'object') return { ...DEFAULT_SKILL_USE_POLICY } + const raw = value as { mode?: unknown; skillIds?: unknown } + const mode: SkillUseMode = raw.mode === 'only' || raw.mode === 'off' ? raw.mode : 'auto' + const skillIds = Array.isArray(raw.skillIds) + ? [...new Set(raw.skillIds.map((id) => String(id ?? '').trim()).filter(Boolean))] + : [] + return { mode, skillIds } +} + /** * Surface 声明:由前端组件在挂载时提供,Runtime 据此配置 tools/context/auto-commit。 * 这里只声明"能力诉求",不放具体运行时数据。 @@ -86,6 +116,7 @@ export type TurnEvent = | { kind: 'staged_change'; seq: number; changeId: string; toolUseId?: string } | { kind: 'staged_change_updated'; seq: number; changeId: string; status: StagedChangeStatus } | { kind: 'agent_status'; seq: number; message: string } + | { kind: 'skill_plan'; seq: number; mode: SkillUseMode; items: SkillExecutionReceiptItem[] } | { kind: 'resumable'; seq: number; label: string; prompt: string; reason?: string } | { kind: 'done'; seq: number; content: string } | { kind: 'canceled'; seq: number; content?: string } @@ -331,6 +362,8 @@ export interface TurnSendRequest { intentHint?: string /** 继续某个未完成批次时传入原 turnId,便于后端后续关联 phase state。 */ resumeOfTurnId?: string + /** 当前对话/本轮覆盖后的 Skill 使用策略。缺省时采用项目默认值。 */ + skillPolicy?: SkillUsePolicy } export interface TurnAttachment { diff --git a/package.json b/package.json index 23e18b22..0b18f04f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev": "electron-vite dev", "test": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/shared/continuation-import.test.mjs electron/main/workspace-types.test.mjs renderer/src/features/workspace/outlineReorder.test.mjs renderer/src/features/workspace/volumeCollapseState.test.mjs renderer/src/features/workspace/workbenchMenu.test.mjs renderer/src/features/chapters/chapterFormatting.test.mjs renderer/src/features/chapters/chapterReference.test.mjs renderer/src/features/ai/taskRegistry.test.mjs renderer/src/features/ai/outlineReferences.test.mjs renderer/src/features/tutorials/tutorials.test.mjs renderer/src/features/knowledge/knowledgeCenter.test.mjs electron/main/story-state-store.test.mjs electron/main/knowledge-document-schema.test.mjs electron/main/ai/settings.test.mjs electron/main/ai/codex-cli.test.mjs electron/main/ai/generate.test.mjs electron/main/ai/provider.test.mjs electron/main/ai/protocol-adapter.test.mjs electron/main/ai/proxy-fetch.test.mjs electron/main/ai/knowledge-retrieval.test.mjs electron/main/ai/state-backfill-store.test.mjs electron/main/ai/state-backfill-task-controller.test.mjs electron/main/ai/runtime/background-task-coordinator.test.mjs electron/main/ai/runtime/chapter-processing-store.test.mjs electron/main/ai/tasks/chapter-session-note.test.mjs electron/main/ai/tasks/outline-context.test.mjs electron/main/ai/tasks/story-state-generation.test.mjs electron/main/ai/tasks/worldview-type.test.mjs electron/main/ai/tasks/ranking-analysis.test.mjs electron/main/ai/tasks/qimao-ranking.test.mjs electron/main/ai/tasks/zongheng-ranking.test.mjs electron/main/ai/runtime-v2/conversation-manager.test.mjs electron/main/ai/runtime-v2/staged-changes-store.test.mjs electron/main/ai/runtime-v2/commit-result.test.mjs electron/main/ai/runtime-v2/context-builder.test.mjs electron/main/ai/runtime-v2/agent-loop.test.mjs electron/main/ai/agent/run-agent-codex-cli.test.mjs electron/main/ai/agent/tools/text-window.test.mjs electron/main/ai/agent/tools/knowledge-tools.test.mjs electron/main/ai/agent/tools/chapter-data-access.test.mjs electron/main/ai/agent/tools/chapter-html-edit.test.mjs electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs", "test:conversation-scroll": "node --test renderer/src/features/assistant/conversationScrollLifecycle.test.mjs", + "test:skill-policy": "node --test electron/main/ai/skills/policy.test.mjs electron/main/ai/skills/registry-merge.test.mjs electron/main/ai/skills/selection-priority.test.mjs renderer/src/features/assistant/assistantIpcPayload.test.mjs", "test:ranking": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/main/ai/tasks/ranking-analysis.test.mjs", "typecheck": "vue-tsc --noEmit", "build": "pnpm run typecheck && electron-vite build", diff --git a/renderer/src/components/ProjectSkillsPanel.vue b/renderer/src/components/ProjectSkillsPanel.vue index faa643f2..79fc828c 100644 --- a/renderer/src/components/ProjectSkillsPanel.vue +++ b/renderer/src/components/ProjectSkillsPanel.vue @@ -1,21 +1,37 @@