1
0
Эх сурвалжийг харах

fix(agent): keep chapter replies clean and send immediately

Mochocyang 2 сар өмнө
parent
commit
822c0bc04c

+ 17 - 0
agent-duihua-分支说明.md

@@ -14,6 +14,12 @@
 
 ## 本次更新
 
+### 20260701-123054
+
+- 修复 Agent 工具调用轮过程文字污染最终回复的问题:工具调用轮的模型文字仅保留在内部上下文,不再直接显示给用户。
+- 收紧 AI 会话章节生成输出约束:生成、续写、改写章节时最终回复只允许章节正文,禁止输出读取说明、执行总结、完成目标表格、章节结构和后续建议。
+- 优化 Enter 发送链路:先写入用户消息和 assistant 运行占位,再异步解析章节号与构建小说上下文,减少按 Enter 后等待的体感延迟。
+
 ### 20260701-113111
 
 - 修复 AI 会话按 Enter 发送存在延迟的问题:提交时优先读取 textarea 当前 DOM 值,避免刚输入后 React state 尚未同步导致发送不及时。
@@ -55,6 +61,16 @@
 
 ## 验证记录
 
+- 20260701-123054:
+  - `npm.cmd exec -- vitest run src/lib/agent/runner.spec.ts src/components/chat/chat-panel.spec.tsx`:2 个测试文件、16 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/reference/ReferenceInput.spec.tsx src/lib/agent/runner.spec.ts src/components/chat/chat-panel.spec.tsx src/components/chat/agent-message-metadata.spec.ts src/components/chat/chat-message.spec.tsx src/components/layout/chat-layout.test.ts src/lib/reference/resolve.spec.ts`:7 个测试文件、53 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/reference/ReferenceInput.spec.tsx src/components/reference/ReferencePickerDialog.spec.tsx src/lib/reference/providers.spec.ts src/components/chat/chat-panel.spec.tsx src/components/sources/outline-chat-panel.spec.tsx src/lib/agent/tools/read-tools.spec.ts src/lib/agent/tools/write-tools.spec.ts`:7 个测试文件、53 个用例通过。
+  - `git diff --check`:通过,仅有 Git 换行提示。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:285 个测试文件、2116 个用例通过。
+  - `npm.cmd run build`:通过,存在既有 Vite chunk/dynamic import 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 通过 Job 验证,Vite ready 后已停止。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `version-info.json`;Rust 构建存在既有 `file_sync.rs` dead-code 警告。
 - 20260701-113111:
   - `npm.cmd exec -- vitest run src/components/reference/ReferenceInput.spec.tsx`:1 个测试文件、10 个用例通过。
   - `npm.cmd exec -- vitest run src/components/reference/ReferenceInput.spec.tsx src/components/reference/ReferencePickerDialog.spec.tsx src/lib/reference/providers.spec.ts src/components/chat/chat-panel.spec.tsx src/components/sources/outline-chat-panel.spec.tsx`:5 个测试文件、36 个用例通过。
@@ -97,6 +113,7 @@
 
 ## Git 状态
 
+- 20260701-123054 Agent 输出纯正文和 Enter 立即显示修复纳入本次提交。
 - 20260701-113111 Enter 立即发送修复纳入本次提交。
 - 20260701-092043 更新已提交,提交号 `6b08275`。
 - 20260701-095256 优化纳入本次提交。

+ 17 - 0
src/components/chat/chat-panel.spec.tsx

@@ -68,4 +68,21 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain("consumePendingReferenceTokens")
     expect(source).toContain("setReferenceTokensForConversation(drafts, targetConversationId")
   })
+
+  it("keeps chapter-generation replies limited to chapter body", () => {
+    expect(source).toContain("章节生成、续写或改写任务的最终回复必须只包含章节正文")
+    expect(source).toContain("不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议")
+  })
+
+  it("shows the sent chat message before asynchronous chapter context building", () => {
+    const appendIndex = source.indexOf("const { assistantMessage } = appendAgentChatMessages")
+    const resolveIndex = source.indexOf("await resolveTargetChapterNumberForChat")
+    const contextIndex = source.indexOf("await buildContextPack")
+
+    expect(appendIndex).toBeGreaterThan(-1)
+    expect(resolveIndex).toBeGreaterThan(-1)
+    expect(contextIndex).toBeGreaterThan(-1)
+    expect(appendIndex).toBeLessThan(resolveIndex)
+    expect(appendIndex).toBeLessThan(contextIndex)
+  })
 })

+ 68 - 61
src/components/chat/chat-panel.tsx

@@ -130,7 +130,9 @@ function buildChatAgentSystemPrompt(options: {
     lines.push("当前处于资料写入模式,用户可能希望把对话内容整理写入资料库。")
   }
   if (options.novelMode) {
-    lines.push("小说模式下,如果用户要求生成、续写或改写章节,只输出可直接放入章节库的正文或明确的执行结果。")
+    lines.push("小说模式下,如果用户要求生成、续写或改写章节,只输出可直接放入章节库的正文。")
+    lines.push("章节生成、续写或改写任务的最终回复必须只包含章节正文,不要把工具读取过程、写作计划或执行过程展示给用户。")
+    lines.push("不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议、引用来源或 Markdown 表格;章节标题和正文以外的内容都不要输出。")
   }
   if (options.deepChapterEnabled) {
     lines.push("用户已开启深度模式,请在必要时进行更完整的章节规划和资料读取。")
@@ -663,6 +665,61 @@ export function ChatPanel() {
               .map((message) => message.content),
           )
         : undefined
+
+      const { assistantMessage } = appendAgentChatMessages(capturedConvId, plainText, tokens)
+      setConversationInputDraft(capturedConvId, "")
+      setFallbackReferenceText("")
+      setReferenceTokensByConversation((drafts) => {
+        const withoutCaptured = setReferenceTokensForConversation(drafts, capturedConvId, [])
+        return setReferenceTokensForConversation(withoutCaptured, referenceDraftConversationId, [])
+      })
+      startStreaming(capturedConvId)
+      const sessionId = streamSessionGuardRef.current.start(capturedConvId)
+      activeStreamSessionsRef.current[capturedConvId] = sessionId
+
+      const controller = new AbortController()
+      abortControllersRef.current[capturedConvId] = controller
+      let hasAgentError = false
+
+      const markDone = (record?: AgentRunRecord) => {
+        updateAgentAssistantMessage(assistantMessage.id, (message) => ({
+          ...message,
+          content: message.content || record?.finalText || "Agent未返回内容。",
+          agentToolCalls: record?.toolCalls.length ? record.toolCalls : message.agentToolCalls,
+          references: (() => {
+            const existingReferences = message.references ?? []
+            const existingPaths = new Set(existingReferences.map((reference) => reference.path))
+            const agentReferences = agentToolCallsToMessageReferences(
+              record?.toolCalls.length ? record.toolCalls : message.agentToolCalls,
+            ).filter((reference) => !existingPaths.has(reference.path))
+            return agentReferences.length > 0
+              ? [...existingReferences, ...agentReferences]
+              : message.references
+          })(),
+          isAgentRunning: false,
+        }))
+      }
+
+      const markError = (error: Error) => {
+        hasAgentError = true
+        updateAgentAssistantMessage(assistantMessage.id, (message) => ({
+          ...message,
+          content: message.content
+            ? `${message.content}\n\n出错:${error.message}`
+            : `出错:${error.message}`,
+          isAgentRunning: false,
+        }))
+      }
+
+      const finishAgentSession = (callback?: () => void) => {
+        streamSessionGuardRef.current.finish(capturedConvId, sessionId, () => {
+          callback?.()
+          clearStreaming(capturedConvId)
+          delete activeStreamSessionsRef.current[capturedConvId]
+          delete abortControllersRef.current[capturedConvId]
+        })
+      }
+
       const targetChapterNumber = novelMode && taskRoute
         ? await resolveTargetChapterNumberForChat({
             projectPath: pp,
@@ -671,6 +728,9 @@ export function ChatPanel() {
             routeChapterNumber: taskRoute.chapterNumber,
             selectedFile,
             lastGeneratedChapterNumber,
+          }).catch((error) => {
+            console.warn("解析目标章节失败:", error)
+            return undefined
           })
         : undefined
       const effectiveTaskRoute = taskRoute && targetChapterNumber
@@ -722,12 +782,13 @@ export function ChatPanel() {
           if (contextPack.characterAuras.trim()) {
             const confirmed = await requestSoulDialog(contextPack.characterAuras)
             if (!confirmed) {
-              const { assistantMessage } = appendAgentChatMessages(capturedConvId, plainText, tokens)
-              updateAgentAssistantMessage(assistantMessage.id, (message) => ({
-                ...message,
-                content: "已取消本次生成,角色灵魂上下文未发送给模型。",
-                isAgentRunning: false,
-              }))
+              finishAgentSession(() => {
+                updateAgentAssistantMessage(assistantMessage.id, (message) => ({
+                  ...message,
+                  content: "已取消本次生成,角色灵魂上下文未发送给模型。",
+                  isAgentRunning: false,
+                }))
+              })
               return
             }
           }
@@ -781,60 +842,6 @@ export function ChatPanel() {
         { role: "user", content: userContent },
       ]
 
-      const { assistantMessage } = appendAgentChatMessages(capturedConvId, plainText, tokens)
-      setConversationInputDraft(capturedConvId, "")
-      setFallbackReferenceText("")
-      setReferenceTokensByConversation((drafts) => {
-        const withoutCaptured = setReferenceTokensForConversation(drafts, capturedConvId, [])
-        return setReferenceTokensForConversation(withoutCaptured, referenceDraftConversationId, [])
-      })
-      startStreaming(capturedConvId)
-      const sessionId = streamSessionGuardRef.current.start(capturedConvId)
-      activeStreamSessionsRef.current[capturedConvId] = sessionId
-
-      const controller = new AbortController()
-      abortControllersRef.current[capturedConvId] = controller
-      let hasAgentError = false
-
-      const markDone = (record?: AgentRunRecord) => {
-        updateAgentAssistantMessage(assistantMessage.id, (message) => ({
-          ...message,
-          content: message.content || record?.finalText || "Agent未返回内容。",
-          agentToolCalls: record?.toolCalls.length ? record.toolCalls : message.agentToolCalls,
-          references: (() => {
-            const existingReferences = message.references ?? []
-            const existingPaths = new Set(existingReferences.map((reference) => reference.path))
-            const agentReferences = agentToolCallsToMessageReferences(
-              record?.toolCalls.length ? record.toolCalls : message.agentToolCalls,
-            ).filter((reference) => !existingPaths.has(reference.path))
-            return agentReferences.length > 0
-              ? [...existingReferences, ...agentReferences]
-              : message.references
-          })(),
-          isAgentRunning: false,
-        }))
-      }
-
-      const markError = (error: Error) => {
-        hasAgentError = true
-        updateAgentAssistantMessage(assistantMessage.id, (message) => ({
-          ...message,
-          content: message.content
-            ? `${message.content}\n\n出错:${error.message}`
-            : `出错:${error.message}`,
-          isAgentRunning: false,
-        }))
-      }
-
-      const finishAgentSession = (callback?: () => void) => {
-        streamSessionGuardRef.current.finish(capturedConvId, sessionId, () => {
-          callback?.()
-          clearStreaming(capturedConvId)
-          delete activeStreamSessionsRef.current[capturedConvId]
-          delete abortControllersRef.current[capturedConvId]
-        })
-      }
-
       try {
         const record = await new AgentRunner().run(
           {

+ 41 - 0
src/lib/agent/runner.spec.ts

@@ -102,6 +102,47 @@ describe("AgentRunner", () => {
     expect(result.roundsUsed).toBe(2)
   })
 
+  it("does not stream assistant narration from tool-call rounds", async () => {
+    const tool: Tool = {
+      name: "read_chapter",
+      description: "read",
+      category: "read",
+      parameters: { name: { type: "string", description: "name" } },
+      execute: vi.fn().mockResolvedValue("Chapter content"),
+    }
+    registry.register(tool)
+
+    let callCount = 0
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      callCount++
+      if (callCount === 1) {
+        cb.onToken("我先读取上一章。")
+        cb.onToolCallDelta?.({ index: 0, id: "call_1", name: "read_chapter" })
+        cb.onToolCallDelta?.({ index: 0, arguments: '{"name":"ch1"}' })
+        cb.onDone()
+      } else {
+        cb.onToken("章节正文")
+        cb.onDone()
+      }
+    })
+
+    const callbacks = {
+      onText: vi.fn(),
+      onToolCall: vi.fn(),
+      onToolResult: vi.fn(),
+      onToolError: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+
+    const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "You are helpful", llmConfig: mockLlmConfig }
+    const result = await runner.run(config, registry, [systemMsg, userMsg], callbacks, undefined)
+
+    expect(callbacks.onText).toHaveBeenCalledTimes(1)
+    expect(callbacks.onText).toHaveBeenCalledWith("章节正文")
+    expect(result.finalText).toBe("章节正文")
+  })
+
   it("stops after maxRounds exceeded", async () => {
     // Always return tool calls
     mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {

+ 1 - 1
src/lib/agent/runner.ts

@@ -35,7 +35,6 @@ export class AgentRunner {
       const streamCallbacks: StreamCallbacks = {
         onToken: (t: string) => {
           roundText += t
-          callbacks.onText(t)
         },
         onToolCallDelta: (delta: ToolCallDelta) => {
           toolCallDeltas.push(delta)
@@ -73,6 +72,7 @@ export class AgentRunner {
       if (toolCalls.length === 0) {
         finalText = roundText
         record.finalText = finalText
+        if (roundText) callbacks.onText(roundText)
         callbacks.onDone()
         return record
       }