diff --git a/electron/main/ai/agent/codex-tool-bridge.ts b/electron/main/ai/agent/codex-tool-bridge.ts index e730f42d..f2d33c05 100644 --- a/electron/main/ai/agent/codex-tool-bridge.ts +++ b/electron/main/ai/agent/codex-tool-bridge.ts @@ -60,11 +60,48 @@ function mergeUsage(a: AiRunUsage | undefined, b: AiRunUsage | undefined): AiRun } } +/** 从混合文本中提取彼此相邻的顶层 JSON 对象,正确跳过字符串里的花括号。 */ +function extractTopLevelJsonObjects(text: string): string[] { + const objects: string[] = [] + let start = -1 + let depth = 0 + let inString = false + let escaped = false + + for (let index = 0; index < text.length; index += 1) { + const char = text[index] + if (inString) { + if (escaped) { + escaped = false + } else if (char === '\\') { + escaped = true + } else if (char === '"') { + inString = false + } + continue + } + if (char === '"' && depth > 0) { + inString = true + } else if (char === '{') { + if (depth === 0) start = index + depth += 1 + } else if (char === '}' && depth > 0) { + depth -= 1 + if (depth === 0 && start >= 0) { + objects.push(text.slice(start, index + 1)) + start = -1 + } + } + } + return objects +} + function jsonCandidates(text: string): string[] { const trimmed = stripReasoningMarkup(text).trim() const candidates = [trimmed] const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim() if (fenced) candidates.push(fenced) + candidates.push(...extractTopLevelJsonObjects(trimmed)) const firstBrace = trimmed.indexOf('{') const lastBrace = trimmed.lastIndexOf('}') if (firstBrace >= 0 && lastBrace > firstBrace) { @@ -75,6 +112,7 @@ function jsonCandidates(text: string): string[] { /** 解析 Codex 返回的宿主工具协议;普通聊天文本返回 null,由调用方直接展示。 */ export function parseCodexToolEnvelope(text: string): CodexToolEnvelope | null { + let latest: CodexToolEnvelope | null = null for (const candidate of jsonCandidates(text)) { let value: unknown try { @@ -99,9 +137,9 @@ export function parseCodexToolEnvelope(text: string): CodexToolEnvelope | null { } toolCalls.push({ name, arguments: rawCall.arguments }) } - if (valid) return { toolCalls, finalText: value.finalText.trim() } + if (valid) latest = { toolCalls, finalText: value.finalText.trim() } } - return null + return latest } function buildToolProtocolPrompt(tools: Tool[]): string { diff --git a/electron/main/ai/agent/run-agent-codex-cli.test.mjs b/electron/main/ai/agent/run-agent-codex-cli.test.mjs index e51b5d81..7cb9ea4e 100644 --- a/electron/main/ai/agent/run-agent-codex-cli.test.mjs +++ b/electron/main/ai/agent/run-agent-codex-cli.test.mjs @@ -57,7 +57,9 @@ test('Codex CLI 可通过宿主工具生成暂存变更,重复调用不会重 ` const text = !bridgeEnabled`, ` ? JSON.stringify({ toolCalls: [], finalText: '宿主工具桥未启用。' })`, ` : prompt.includes('change_id=change-1') ? ${JSON.stringify(finalResponse)} : ${JSON.stringify(toolResponse)}`, - ` console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text } }))`, + ` const event = JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text } })`, + ` console.log(event)`, + ` console.log(event)`, ` console.log(JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 12, output_tokens: 8 } }))`, '})' ].join('\n') @@ -122,3 +124,20 @@ test('Codex 宿主工具协议兼容 JSON 代码块,普通聊天文本不会 assert.equal(parseCodexToolEnvelope('这是普通聊天回复。'), null) assert.equal(parseCodexToolEnvelope('{"title":"用户要求的 JSON 内容"}'), null) }) + +test('Codex CLI 重复返回相同协议 JSON 时仍能解析工具调用', () => { + const response = '{"toolCalls":[{"name":"read_chapter","arguments":{"chapter_id":"chapter-1"}}],"finalText":""}' + assert.deepEqual(parseCodexToolEnvelope(response + response), { + toolCalls: [{ name: 'read_chapter', arguments: { chapter_id: 'chapter-1' } }], + finalText: '' + }) +}) + +test('Codex CLI 连续返回多个有效协议 JSON 时采用最后一个结果', () => { + const first = '{"toolCalls":[{"name":"read_chapter","arguments":{"chapter_id":"chapter-1"}}],"finalText":""}' + const latest = '{"toolCalls":[{"name":"read_chapter","arguments":{"chapter_id":"chapter-2"}}],"finalText":""}' + assert.deepEqual(parseCodexToolEnvelope(first + latest), { + toolCalls: [{ name: 'read_chapter', arguments: { chapter_id: 'chapter-2' } }], + finalText: '' + }) +}) diff --git a/electron/main/ai/agent/tools/chapter-data-access.ts b/electron/main/ai/agent/tools/chapter-data-access.ts index 7e69c2d9..dbaece60 100644 --- a/electron/main/ai/agent/tools/chapter-data-access.ts +++ b/electron/main/ai/agent/tools/chapter-data-access.ts @@ -4,6 +4,7 @@ import { commitChapterEditInDb } from './chapter-commit' import { insertInHtml, joinChapterBlocks, + replaceAllInHtml, replaceInHtml, stripHtmlTags, textToHtmlParagraphs @@ -32,7 +33,7 @@ export type ChapterSummaryItem = { } export type ChapterEdit = { - operation: 'replace' | 'insert' | 'append' + operation: 'replace' | 'replace_all' | 'insert' | 'append' search?: string content: string position?: 'before' | 'after' | 'start' | 'end' @@ -226,6 +227,9 @@ export async function applyChapterEdit( } newContent = replaceInHtml(oldContent, searchText, edit.content) preview = `Replaced "${searchText.slice(0, 30)}..." -> "${edit.content.slice(0, 30)}..."` + } else if (edit.operation === 'replace_all') { + newContent = replaceAllInHtml(oldContent, edit.content) + preview = `Replaced entire chapter with ${edit.content.length} chars` } else if (edit.operation === 'insert') { if (!edit.search && edit.position !== 'start' && edit.position !== 'end') { throw new Error('insert requires search or start/end position') @@ -284,6 +288,9 @@ export async function computeChapterEdit( } newContent = replaceInHtml(oldContent, searchText, edit.content) preview = `Replaced "${searchText.slice(0, 30)}..." -> "${edit.content.slice(0, 30)}..."` + } else if (edit.operation === 'replace_all') { + newContent = replaceAllInHtml(oldContent, edit.content) + preview = `Replaced entire chapter with ${edit.content.length} chars` } else if (edit.operation === 'insert') { if (!edit.search && edit.position !== 'start' && edit.position !== 'end') { throw new Error('insert requires search or start/end position') @@ -306,7 +313,9 @@ export async function computeChapterEdit( preview, chapterTitle, // 只包含变更片段,用于 diff 展示(不是整章) - beforeFragment: edit.operation === 'replace' ? (edit.search?.trim() ?? '') : '', + beforeFragment: edit.operation === 'replace_all' + ? stripHtmlTags(oldContent) + : edit.operation === 'replace' ? (edit.search?.trim() ?? '') : '', afterFragment: edit.content } } diff --git a/electron/main/ai/agent/tools/chapter-html-edit.test.mjs b/electron/main/ai/agent/tools/chapter-html-edit.test.mjs index 0f6abaaa..8980516c 100644 --- a/electron/main/ai/agent/tools/chapter-html-edit.test.mjs +++ b/electron/main/ai/agent/tools/chapter-html-edit.test.mjs @@ -4,7 +4,8 @@ import test from 'node:test' import { insertInHtml, joinChapterBlocks, - replaceInHtml + replaceInHtml, + replaceAllInHtml } from './chapter-html-edit.ts' test('段内替换不会生成嵌套 p 或额外空段落', () => { @@ -32,3 +33,10 @@ test('锚点插入保持合法段落结构', () => { test('空编辑器追加正文时替换占位空段落', () => { assert.equal(joinChapterBlocks('
', '正文
', 'end'), '正文
') }) + +test('整章替换不需要定位原文,并重新生成合法段落', () => { + assert.equal( + replaceAllInHtml('旧正文
', '新正文第一段\n\n新正文第二段'), + '新正文第一段
新正文第二段
' + ) +}) diff --git a/electron/main/ai/agent/tools/chapter-html-edit.ts b/electron/main/ai/agent/tools/chapter-html-edit.ts index cd65ca26..b52c6fec 100644 --- a/electron/main/ai/agent/tools/chapter-html-edit.ts +++ b/electron/main/ai/agent/tools/chapter-html-edit.ts @@ -103,6 +103,11 @@ export function replaceInHtml(html: string, search: string, replacement: string) return html.slice(0, htmlStart) + textToInlineHtml(replacement) + html.slice(htmlEnd) } +/** 整章替换:显式操作才会调用,不依赖原文定位,输出标准段落 HTML。 */ +export function replaceAllInHtml(_html: string, replacement: string): string { + return textToHtmlParagraphs(replacement) +} + export function insertInHtml( html: string, search: string, diff --git a/electron/main/ai/agent/tools/chapter-tools.ts b/electron/main/ai/agent/tools/chapter-tools.ts index 935d717d..18e82ce3 100644 --- a/electron/main/ai/agent/tools/chapter-tools.ts +++ b/electron/main/ai/agent/tools/chapter-tools.ts @@ -174,8 +174,8 @@ export function createChapterTools(callbacks: ChapterToolCallbacks): Tool[] { properties: { operation: { type: 'string', - enum: ['replace', 'insert', 'append'], - description: 'Edit operation.' + enum: ['replace', 'replace_all', 'insert', 'append'], + description: 'replace performs a local search-based replacement; replace_all explicitly replaces the whole chapter; insert/append add content.' }, chapter_id: { type: 'string', @@ -217,7 +217,7 @@ export function createChapterTools(callbacks: ChapterToolCallbacks): Tool[] { return { content: error instanceof Error ? error.message : String(error), isError: true } } - const operation = String(input.operation) as 'replace' | 'insert' | 'append' + const operation = String(input.operation) as 'replace' | 'replace_all' | 'insert' | 'append' const content = String(input.content || '') const search = input.search ? String(input.search) : undefined const position = input.position ? String(input.position) as 'before' | 'after' | 'start' | 'end' : undefined diff --git a/electron/main/ai/runtime-v2/system-prompt.ts b/electron/main/ai/runtime-v2/system-prompt.ts index 03ce2ef4..8c48b0af 100644 --- a/electron/main/ai/runtime-v2/system-prompt.ts +++ b/electron/main/ai/runtime-v2/system-prompt.ts @@ -33,6 +33,7 @@ const CORE_SYSTEM = `你是一位小说创作项目的资深创作助手。你 - 每批资料读取完成后,必须在可见回复区输出阶段性分析,不要只把判断放在思考过程里。阶段性分析应包含:已确认事实、证据来源、仍缺资料、下一步读取/处理计划。若还要继续读,先说明为什么继续读。 - 只改用户指向的对象。用户说要改章节正文,就聚焦章节正文(stage_chapter_edit);不要顺手去改人物卡、大纲、创作记忆等用户没提到的数据。每次动手前先自问:"这个改动是用户这次要的吗?"不是就别做。 - 需要修改章节正文、人物卡、大纲等实际数据时,调用对应的 stage_* 工具产出**暂存变更**,不要在回复正文里"贴出修改结果"。 +- 章节局部替换使用 stage_chapter_edit(operation=replace) 并提供逐字来自正文的 search;用户明确要求重写、压缩或整体替换整章时,使用 operation=replace_all,无需 search。不要用缺少 search 的 replace,也不要因为整章替换缺少定位片段而停止任务。 - stage_workflow_document(创作记忆:当前状态、创作计划、写作进度、伏笔悬念、素材清单、人物关系梳理)只在用户明确要求整理/沉淀创作记忆时才用,或它确实是本次任务不可或缺的产物。绝不把它当成每次回复的默认副产品——用户只是要改正文或讨论问题时,不要附带生成创作记忆变更。 - 暂存变更不是最终写入。用户会在暂存区逐条审阅确认。禁止把 stage_* 的调用描述为"已完成修改"、"已写入"、"已修复"。可以说"已生成待审阅的修改"。 - 用户设定优先。已有资料哪怕不完美,也不擅自颠覆。修改要有明确理由,写进 stage_* 的 reason 字段。 diff --git a/electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs b/electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs index 24f3f2f1..bc12d16e 100644 --- a/electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs +++ b/electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs @@ -87,6 +87,34 @@ test('章节面板禁止暂存当前章节之外的修改', async () => { assert.equal(stagedStore.list({}, 'session-1').length, 0) }) +test('整章重写使用 replace_all,无需提供 search', async () => { + const { tool, stagedStore } = makeTool({ currentChapterId: 'chapter-1' }) + const operationSchema = tool.definition.inputSchema.properties.operation + + assert.ok(operationSchema.enum.includes('replace_all')) + const result = await runTool(tool, { + operation: 'replace_all', + content: '整章新正文', + reason: '按拆章方案重写本章' + }) + + assert.equal(result.isError, undefined) + assert.equal(stagedStore.list({}, 'session-1').length, 1) +}) + +test('局部 replace 缺少 search 时仍拒绝,避免误覆盖整章', async () => { + const { tool, stagedStore } = makeTool({ currentChapterId: 'chapter-1' }) + const result = await runTool(tool, { + operation: 'replace', + content: '不能被当成整章正文', + reason: '参数遗漏测试' + }) + + assert.equal(result.isError, true) + assert.equal(result.content, 'replace 需要提供 search。') + assert.equal(stagedStore.list({}, 'session-1').length, 0) +}) + test('同一轮多次暂存会基于上一条暂存后的章节正文继续计算', async () => { const { tool, stagedStore, calls } = makeTool({ currentChapterId: 'chapter-1' }) diff --git a/electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.ts b/electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.ts index 433f860d..8fb063a4 100644 --- a/electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.ts +++ b/electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.ts @@ -112,7 +112,7 @@ export function makeStageChapterEditToolCore(deps: StageChapterEditToolDeps): To definition: { name: 'stage_chapter_edit', description: - '暂存对章节正文的修改,不直接写库。变更进入待审阅暂存区,用户在 UI 中确认后才写回。参数:chapter_id(可选,缺省用当前章节)、operation(replace/insert/append)、content(新内容)、search(replace 定位文本 / insert 锚点)、position(insert 前后 / 起首 / 末尾)、reason(写给用户看的一句话理由)。', + '暂存对章节正文的修改,不直接写库。变更进入待审阅暂存区,用户在 UI 中确认后才写回。局部替换用 replace 并提供 search;整章重写必须显式用 replace_all,无需 search;另支持 insert/append。', inputSchema: { type: 'object', properties: { @@ -122,8 +122,8 @@ export function makeStageChapterEditToolCore(deps: StageChapterEditToolDeps): To }, operation: { type: 'string', - enum: ['replace', 'insert', 'append'], - description: 'replace=按 search 定位后替换;insert=按 search 或 position 插入;append=末尾追加。' + enum: ['replace', 'replace_all', 'insert', 'append'], + description: 'replace=按 search 定位后局部替换;replace_all=整章替换且无需 search;insert=按 search 或 position 插入;append=末尾追加。' }, content: { type: 'string', description: '要写入的新文本(纯文本,工具会转成段落)。' }, search: { type: 'string', description: 'replace 必填:目标文本;insert 可选:锚点文本。' }, @@ -161,7 +161,7 @@ export function makeStageChapterEditToolCore(deps: StageChapterEditToolDeps): To } } - const operation = String(input.operation) as 'replace' | 'insert' | 'append' + const operation = String(input.operation) as 'replace' | 'replace_all' | 'insert' | 'append' const content = String(input.content || '') const search = input.search ? String(input.search) : undefined const position = input.position diff --git a/package.json b/package.json index b836264a..d3b57e57 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "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/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: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/GlobalAssistantPage.vue b/renderer/src/components/GlobalAssistantPage.vue index f426a37f..4609ac4c 100644 --- a/renderer/src/components/GlobalAssistantPage.vue +++ b/renderer/src/components/GlobalAssistantPage.vue @@ -1,5 +1,5 @@