Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions electron/main/ai/agent/codex-tool-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
21 changes: 20 additions & 1 deletion electron/main/ai/agent/run-agent-codex-cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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: ''
})
})
13 changes: 11 additions & 2 deletions electron/main/ai/agent/tools/chapter-data-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { commitChapterEditInDb } from './chapter-commit'
import {
insertInHtml,
joinChapterBlocks,
replaceAllInHtml,
replaceInHtml,
stripHtmlTags,
textToHtmlParagraphs
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand All @@ -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
}
}
Expand Down
10 changes: 9 additions & 1 deletion electron/main/ai/agent/tools/chapter-html-edit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import test from 'node:test'
import {
insertInHtml,
joinChapterBlocks,
replaceInHtml
replaceInHtml,
replaceAllInHtml
} from './chapter-html-edit.ts'

test('段内替换不会生成嵌套 p 或额外空段落', () => {
Expand Down Expand Up @@ -32,3 +33,10 @@ test('锚点插入保持合法段落结构', () => {
test('空编辑器追加正文时替换占位空段落', () => {
assert.equal(joinChapterBlocks('<p></p>', '<p>正文</p>', 'end'), '<p>正文</p>')
})

test('整章替换不需要定位原文,并重新生成合法段落', () => {
assert.equal(
replaceAllInHtml('<p>旧正文</p>', '新正文第一段\n\n新正文第二段'),
'<p>新正文第一段</p><p>新正文第二段</p>'
)
})
5 changes: 5 additions & 0 deletions electron/main/ai/agent/tools/chapter-html-edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions electron/main/ai/agent/tools/chapter-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions electron/main/ai/runtime-v2/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 字段。
Expand Down
28 changes: 28 additions & 0 deletions electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' })

Expand Down
8 changes: 4 additions & 4 deletions electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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 可选:锚点文本。' },
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion renderer/src/components/GlobalAssistantPage.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import {
ArrowUp,
BookMarked,
Expand Down Expand Up @@ -58,6 +58,10 @@ function scrollToBottom(smooth = true): void {
conversationRef.value.scrollTo({ top: conversationRef.value.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
}

function restoreConversationPosition(): void {
nextTick(() => scrollToBottom(false))
}

function groupKey(messageId: string, group: ToolGroup): string {
return `${messageId}:${group.key}:${group.items[0]?.toolUseId ?? 'empty'}`
}
Expand Down Expand Up @@ -171,6 +175,9 @@ onMounted(() => {
nextTick(() => autoResize())
})

onMounted(restoreConversationPosition)
onActivated(restoreConversationPosition)

onBeforeUnmount(() => {
stopRailResize?.()
})
Expand Down
9 changes: 8 additions & 1 deletion renderer/src/components/GlobalAssistantPanel.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
ChevronRight,
GripHorizontal,
Expand Down Expand Up @@ -170,6 +170,10 @@ function scrollToBottom(smooth = true): void {
})
}

function restoreConversationPosition(): void {
nextTick(() => scrollToBottom(false))
}

function closePanel(): void {
emit('close')
}
Expand Down Expand Up @@ -204,6 +208,9 @@ onMounted(() => {
window.addEventListener('resize', syncInputHeightBounds)
})

onMounted(restoreConversationPosition)
onActivated(restoreConversationPosition)

onBeforeUnmount(() => {
window.removeEventListener('resize', syncInputHeightBounds)
})
Expand Down
Loading
Loading