Forráskód Böngészése

fix(outline): route outline chat through agent tools

Mochocyang 2 hónapja
szülő
commit
2a519fda3a

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

@@ -14,6 +14,13 @@
 
 ## 本次更新
 
+### 20260701-125104
+
+- 修复 AI 大纲仍走旧工作流、没有调用工具分析内容的问题:发送和重新生成流程已改为使用 `AgentRunner`、`ToolRegistry` 与 `buildAgentConfig`。
+- AI 大纲 @ 引用改为工具读取提示,不再提前读取后塞入普通上下文;提示词要求优先调用 `read_outline`、`read_chapter`、`read_memory`、`read_deduction` 等工具读取引用和项目内容。
+- AI 大纲 assistant 消息会展示工具调用过程,并把成功读取的章节、大纲、记忆和推演来源写入引用来源。
+- 新增回归测试,防止 AI 大纲重新回退到 `runDeepOutlineGeneration`、`streamChat` 或预加载引用内容的旧路径。
+
 ### 20260701-123054
 
 - 修复 Agent 工具调用轮过程文字污染最终回复的问题:工具调用轮的模型文字仅保留在内部上下文,不再直接显示给用户。
@@ -61,6 +68,14 @@
 
 ## 验证记录
 
+- 20260701-125104:
+  - `npm.cmd exec -- vitest run src/components/sources/outline-chat-panel.spec.tsx`:1 个测试文件、7 个用例通过。
+  - `npm.cmd run test:mocks`:285 个测试文件、2118 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `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-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 个用例通过。
@@ -113,6 +128,7 @@
 
 ## Git 状态
 
+- 20260701-125104 AI 大纲 Agent 工具调用修复纳入本次提交。
 - 20260701-123054 Agent 输出纯正文和 Enter 立即显示修复纳入本次提交。
 - 20260701-113111 Enter 立即发送修复纳入本次提交。
 - 20260701-092043 更新已提交,提交号 `6b08275`。

+ 21 - 4
src/components/sources/outline-chat-panel.spec.tsx

@@ -49,9 +49,26 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("onGenerate={handleGenerateSection}")
   })
 
-  it("adds selected references to the outline model context instead of only storing chips", () => {
-    expect(source).toContain("loadReferenceTokenContext")
-    expect(source).toContain("本次 @ 引用内容")
-    expect(source).toContain("outlineSources = [...outlineSources, ...referenceContext.sources]")
+  it("adds selected references to the outline agent request instead of only storing chips", () => {
+    expect(source).toContain("buildOutlineAgentUserContent")
+    expect(source).toContain("本条消息附带的 @ 引用")
+    expect(source).toContain("请优先使用工具读取引用内容")
+  })
+
+  it("routes outline chat sends through AgentRunner with built-in tools", () => {
+    expect(source).toContain("AgentRunner")
+    expect(source).toContain("buildAgentConfig")
+    expect(source).toContain("ToolRegistry")
+    expect(source).toContain("read_outline")
+    expect(source).toContain("read_chapter")
+    expect(source).toContain("read_memory")
+    expect(source).toContain("read_deduction")
+    expect(source).not.toContain("runDeepOutlineGeneration(")
+  })
+
+  it("keeps outline reference chips as tool-readable hints instead of preloading file contents", () => {
+    expect(source).toContain("buildOutlineAgentUserContent")
+    expect(source).toContain("请优先使用工具读取引用内容")
+    expect(source).not.toContain("loadReferenceTokenContext(tokens)")
   })
 })

+ 356 - 171
src/components/sources/outline-chat-panel.tsx

@@ -4,19 +4,16 @@ import { useWikiStore } from "@/stores/wiki-store"
 import { useOutlineChatStore, type OutlineChatMessage } from "@/stores/outline-chat-store"
 import { normalizePath } from "@/lib/path-utils"
 import { refreshProjectState } from "@/lib/project-refresh"
-import { readFile, writeFile, listDirectory, createDirectory, fileExists } from "@/commands/fs"
-import { streamChat, type ChatMessage } from "@/lib/llm-client"
+import { writeFile, listDirectory, createDirectory, fileExists } from "@/commands/fs"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import ReactMarkdown from "react-markdown"
 import { FileEditPreview } from "@/components/chat/file-edit-preview"
+import { AgentToolCallMessage } from "@/components/chat/agent-tool-call-message"
 import { ChatDockControls } from "@/components/chat/chat-dock-controls"
 import { TooltipProvider } from "@/components/ui/tooltip"
 import { OUTLINE_SECTION_GENERATION_CONFIGS } from "@/lib/novel/outline-generation"
 import { prepareOutlineSaveDraft } from "@/lib/outline-save"
-import { resolveUserVisibleReasoning } from "@/lib/user-visible-reasoning"
-import { runDeepOutlineGeneration } from "@/lib/novel/deep-outline-generation"
 import { resolveModelConfig, resolveNovelModel } from "@/lib/novel/model-resolver"
-import { createDeepThinkingStreamRenderer } from "@/lib/deep-thinking-stream"
 import { ChatModelSelector } from "@/components/chat/chat-model-selector"
 import { ReferenceInput, type InsertReferenceTokens } from "@/components/reference/ReferenceInput"
 import { ReferencePickerDialog } from "@/components/reference/ReferencePickerDialog"
@@ -30,49 +27,17 @@ import {
 } from "@/lib/reference/providers"
 import type { ReferenceToken } from "@/lib/reference/types"
 import { useChatStore } from "@/stores/chat-store"
+import { AgentRunner } from "@/lib/agent/runner"
+import { ToolRegistry } from "@/lib/agent/registry"
+import { buildAgentConfig, modelSupportsTools } from "@/lib/agent/config"
+import type { AgentMessage, AgentRunRecord, ToolCall } from "@/lib/agent/types"
+import { loadDeAiSkillConfig, type DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
 import {
   buildWebResearchContext,
   collectWebResearch,
   shouldUseWebResearch,
 } from "@/lib/web-research"
 
-async function loadOutlineContext(projectPath: string): Promise<{ context: string; sources: string[] }> {
-  const pp = normalizePath(projectPath)
-  const sections: string[] = []
-  const sources: string[] = []
-
-  try {
-    const outlinesDir = `${pp}/wiki/outlines`
-    const tree = await listDirectory(outlinesDir)
-    for (const file of tree.slice(0, 10)) {
-      if (file.name.endsWith(".md")) {
-        try {
-          const content = await readFile(`${outlinesDir}/${file.name}`)
-          const trimmed = content.length > 3000 ? content.slice(0, 3000) + "\n...(已截断)" : content
-          sections.push(`【${file.name.replace(/\.md$/, "")}】\n${trimmed}`)
-          sources.push(`大纲: ${file.name.replace(/\.md$/, "")}`)
-        } catch { /* skip */ }
-      }
-    }
-  } catch { /* no outlines dir */ }
-
-  try {
-    const chaptersDir = `${pp}/wiki/chapters`
-    const tree = await listDirectory(chaptersDir)
-    const chapterFiles = tree.filter(f => f.name.endsWith(".md")).slice(-5)
-    for (const file of chapterFiles) {
-      try {
-        const content = await readFile(`${chaptersDir}/${file.name}`)
-        const preview = content.length > 1500 ? content.slice(0, 1500) + "\n...(已截断)" : content
-        sections.push(`【章节:${file.name.replace(/\.md$/, "")}】\n${preview}`)
-        sources.push(`章节: ${file.name.replace(/\.md$/, "")}`)
-      } catch { /* skip */ }
-    }
-  } catch { /* no chapters dir */ }
-
-  return { context: sections.join("\n\n---\n\n"), sources }
-}
-
 function referenceCategoryLabel(category: ReferenceToken["category"]): string {
   switch (category) {
     case "chapter":
@@ -94,30 +59,89 @@ function referenceCategoryLabel(category: ReferenceToken["category"]): string {
   }
 }
 
-async function loadReferenceTokenContext(tokens: ReferenceToken[]): Promise<{ context: string; sources: string[] }> {
-  if (tokens.length === 0) return { context: "", sources: [] }
+function describeReferenceForOutlineAgent(token: ReferenceToken, index: number): string {
+  const parts = [
+    `${index + 1}. 类型:${referenceCategoryLabel(token.category)}`,
+    `标题:${token.title || token.displayTitle}`,
+  ]
+  if (token.path) parts.push(`路径:${token.path}`)
+  if (token.conversationId) parts.push(`会话ID:${token.conversationId}`)
+  if (token.skillId) parts.push(`技能ID:${token.skillId}`)
+  return parts.join(";")
+}
 
-  const sections: string[] = ["## 本次 @ 引用内容"]
-  const sources: string[] = []
-  for (const token of tokens) {
-    const label = referenceCategoryLabel(token.category)
-    const title = token.title || token.displayTitle
-    sources.push(`@${label}: ${title}`)
-    if (!token.path) {
-      sections.push(`【${label}:${title}】\n该引用没有可直接读取的文件路径。`)
-      continue
-    }
+function buildOutlineAgentUserContent(text: string, tokens: ReferenceToken[]): string {
+  if (tokens.length === 0) return text
+  return [
+    text,
+    "",
+    "## 本条消息附带的 @ 引用",
+    "请优先使用工具读取引用内容,不要只根据标题猜测。章节用 read_chapter,大纲用 read_outline,记忆用 read_memory,推演用 read_deduction,AI会话用 read_chat_history,AI大纲历史用 read_outline_history。",
+    ...tokens.map(describeReferenceForOutlineAgent),
+  ].join("\n")
+}
 
-    try {
-      const content = await readFile(token.path)
-      const trimmed = content.length > 3000 ? `${content.slice(0, 3000)}\n...(已截断)` : content
-      sections.push(`【${label}:${title}】\n路径:${token.path}\n\n${trimmed}`)
-    } catch {
-      sections.push(`【${label}:${title}】\n路径:${token.path}\n\n读取失败,已保留引用来源。`)
+function buildOutlineAgentSystemPrompt(options: { projectName?: string; webResearchContext?: string }): string {
+  return [
+    "你是专业小说大纲分析与创作助手。",
+    "你必须通过可用工具读取项目大纲、章节、记忆、推演结果和历史对话后,再进行分析、回答、生成或修改建议。",
+    "如果用户提供 @ 引用,必须优先按路径、标题或会话ID调用对应读取工具获取正文内容。",
+    "不要假设引用内容已经注入上下文;不要跳过工具直接空泛回答。",
+    "回答必须基于已读取内容进行分析,说明关键判断依据;需要写入大纲节点时使用 write_outline_node。",
+    "所有面向用户的回复必须使用中文。",
+    options.projectName ? `当前项目:${options.projectName}` : "",
+    options.webResearchContext?.trim()
+      ? `## 用户明确要求检索的网页资料\n${options.webResearchContext}`
+      : "",
+  ].filter(Boolean).join("\n")
+}
+
+function outlineToolCallsToSources(toolCalls: AgentRunRecord["toolCalls"]): string[] {
+  const sources: string[] = []
+  for (const call of toolCalls) {
+    if (call.status !== "done") continue
+    const target = call.params.name || call.params.path || call.params.keyword || call.params.conversationId || call.params.conversationTitle
+    switch (call.name) {
+      case "read_outline":
+        sources.push(`大纲: ${String(target ?? "")}`.trim())
+        break
+      case "read_chapter":
+      case "search_chapters":
+        sources.push(`章节: ${String(target ?? "")}`.trim())
+        break
+      case "read_memory":
+        sources.push(`记忆: ${String(target ?? "")}`.trim())
+        break
+      case "read_deduction":
+        sources.push(`推演: ${String(target ?? "")}`.trim())
+        break
+      case "read_chat_history":
+        sources.push(`AI会话: ${String(target ?? "")}`.trim())
+        break
+      case "read_outline_history":
+        sources.push(`AI大纲: ${String(target ?? "")}`.trim())
+        break
     }
   }
+  return Array.from(new Set(sources.filter((source) => !source.endsWith(":"))))
+}
 
-  return { context: sections.join("\n\n---\n\n"), sources }
+function updateOutlineAssistantMessage(
+  conversationId: string,
+  messageId: string,
+  updater: (message: OutlineChatMessage) => OutlineChatMessage,
+): void {
+  useOutlineChatStore.setState((state) => ({
+    conversations: state.conversations.map((conversation) => {
+      if (conversation.id !== conversationId) return conversation
+      return {
+        ...conversation,
+        messages: conversation.messages.map((message) =>
+          message.id === messageId ? updater(message) : message,
+        ),
+      }
+    }),
+  }))
 }
 
 async function getUniqueOutlinePath(outlinesDir: string, title: string): Promise<string> {
@@ -204,6 +228,7 @@ function OutlineAssistantMessage({ msg, index, isStreaming, streamingContent, ac
   return (
     <>
       {thinking ? <OutlineThinkingBlock content={thinking} open={isStreaming} /> : null}
+      <AgentToolCallMessage toolCalls={msg.agentToolCalls} />
       <div className="prose prose-sm dark:prose-invert max-w-none">
         <ReactMarkdown>{parsed.textContent || answer}</ReactMarkdown>
       </div>
@@ -449,32 +474,48 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     if (activeConv?.modelId) {
       effectiveLlmConfig = resolveModelConfig(activeConv.modelId, effectiveLlmConfig, providerConfigs)
     }
+    const effectiveModelId = activeConv?.modelId || effectiveLlmConfig.model || ""
     if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
       const convId = activeConversationId ?? createConversation()
       addMessage(convId, { id: crypto.randomUUID(), role: "assistant", content: "请先在设置中配置并选择一个可用的 AI 模型,或在下方模型选择器中选择模型后再试。" })
       return
     }
+    if (!modelSupportsTools(effectiveModelId)) {
+      const convId = activeConversationId ?? createConversation()
+      addMessage(convId, { id: crypto.randomUUID(), role: "assistant", content: "当前模型不支持 AI 大纲工具调用,请在下方模型选择器中更换支持工具调用的模型。" })
+      return
+    }
 
     let convId = activeConversationId
     if (!convId) {
       convId = createConversation()
     }
 
-    const userMsg: OutlineChatMessage = { id: crypto.randomUUID(), role: "user", content: prompt }
+    const historyBeforeSend = (useOutlineChatStore.getState().conversations.find((c) => c.id === convId)?.messages ?? [])
+      .filter((message) => message.content.trim() && !message.isAgentRunning)
+      .map((message) => ({
+        role: message.role,
+        content: message.content,
+      } satisfies AgentMessage))
+    const userMsg: OutlineChatMessage = { id: crypto.randomUUID(), role: "user", content: prompt, attachedReferences: tokens }
+    const initialSources = tokens.map((token) => `@${referenceCategoryLabel(token.category)}: ${token.title || token.displayTitle}`)
+    const assistantId = crypto.randomUUID()
     addMessage(convId, userMsg)
+    addMessage(convId, {
+      id: assistantId,
+      role: "assistant",
+      content: "",
+      sources: initialSources,
+      agentToolCalls: [],
+      isAgentRunning: true,
+    })
     setIsStreaming(true)
     setStreamingContent("")
     userScrolledUpRef.current = false
 
     try {
-      const { context, sources } = await loadOutlineContext(project.path)
-      let outlineContext = context
-      let outlineSources = [...sources]
-      const referenceContext = await loadReferenceTokenContext(tokens)
-      if (referenceContext.context.trim()) {
-        outlineContext = [outlineContext, referenceContext.context].filter(Boolean).join("\n\n---\n\n")
-      }
-      outlineSources = [...outlineSources, ...referenceContext.sources]
+      let webResearchMarkdown = ""
+      let outlineSources = [...initialSources]
       if (shouldUseWebResearch(prompt)) {
         const webResearch = await collectWebResearch({
           text: prompt,
@@ -484,111 +525,160 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         })
         const webResearchContext = buildWebResearchContext(webResearch)
         if (webResearchContext.markdown.trim()) {
-          outlineContext = [outlineContext, webResearchContext.markdown].filter(Boolean).join("\n\n---\n\n")
+          webResearchMarkdown = webResearchContext.markdown
         }
         outlineSources = [...outlineSources, ...webResearchContext.sources]
       }
-      const allMsgs = [...(useOutlineChatStore.getState().conversations.find(c => c.id === convId)?.messages ?? [])]
-
-      // Agent mode: detect edit intent and add file edit instructions
-      const { detectEditIntent, buildAgentSystemSuffix } = await import("@/lib/novel/agent-parser")
-      const hasEditIntent = detectEditIntent(prompt)
-      const agentSuffix = hasEditIntent ? buildAgentSystemSuffix("outlines") : ""
-      let fileListStr = ""
-      if (hasEditIntent) {
-        const { readScopeFileContents } = await import("@/lib/novel/agent-tools")
-        const filesWithContent = await readScopeFileContents(project.path, "outlines")
-        fileListStr = filesWithContent.length > 0
-          ? `\n\n## 当前大纲文件内容(供修改定位)\n${filesWithContent.map(f => `### ${f.name}\n\`\`\`\n${f.content}\n\`\`\``).join("\n\n")}`
-          : "\n\n## 当前大纲文件列表\n(暂无大纲文件)"
-      }
 
-      const historyMessages: ChatMessage[] = allMsgs.map(m => ({ role: m.role as "user" | "assistant", content: m.content }))
       let result = ""
-      const appendToResult = (token: string) => {
-        result += token
-        setStreamingContent(result)
-      }
-      const deepStream = createDeepThinkingStreamRenderer()
-      const updateDeepResult = (content: string) => {
-        result = content
-        setStreamingContent(result)
-      }
-      const appendThinkingBlock = (content: string) => updateDeepResult(deepStream.updateThinking(content))
       const controller = new AbortController()
       abortRef.current = controller
 
-      // Add placeholder assistant message
-      addMessage(convId, { id: crypto.randomUUID(), role: "assistant", content: "", sources: outlineSources })
-
-      if (hasEditIntent) {
-        const systemPrompt = `你是一个专业的小说大纲编辑助手。以下是当前小说的大纲、章节内容和用户明确要求检索的网页资料,请根据用户的问题进行大纲相关的讨论和创作。\n\n${outlineContext}${agentSuffix}${fileListStr}`
-        const chatMessages: ChatMessage[] = [
-          { role: "system", content: systemPrompt },
-          ...historyMessages,
-        ]
-
-        let thinkingOpen = false
-        const appendReasoning = (token: string) => {
-          if (!token) return
-          if (!thinkingOpen) {
-            thinkingOpen = true
-            appendToResult("<think>")
-          }
-          appendToResult(token)
-        }
-        const closeReasoning = () => {
-          if (!thinkingOpen) return
-          thinkingOpen = false
-          appendToResult("</think>")
-        }
+      const skillConfig = await loadDeAiSkillConfig(project.path).catch((): DeAiSkillConfig | null => null)
+      const registry = new ToolRegistry()
+      const systemPrompt = buildOutlineAgentSystemPrompt({
+        projectName: project.name,
+        webResearchContext: webResearchMarkdown,
+      })
+      const agentConfig = buildAgentConfig(effectiveModelId, systemPrompt, registry, {
+        wikiPath: `${normalizePath(project.path)}/wiki`,
+        getSkillConfig: () => skillConfig,
+        getChatConversations: () => {
+          const state = useChatStore.getState()
+          return state.conversations.map((conversation) => ({
+            id: conversation.id,
+            title: conversation.title,
+            messages: state.messages
+              .filter((message) => message.conversationId === conversation.id)
+              .map((message) => ({ role: message.role, content: message.content })),
+          }))
+        },
+        getOutlineConversations: () =>
+          useOutlineChatStore.getState().conversations.map((conversation) => ({
+            id: conversation.id,
+            title: conversation.title,
+            messages: conversation.messages.map((message) => ({ role: message.role, content: message.content })),
+          })),
+        llmConfig: effectiveLlmConfig,
+      })
+      const agentMessages: AgentMessage[] = [
+        { role: "system", content: systemPrompt },
+        ...historyBeforeSend,
+        { role: "user", content: buildOutlineAgentUserContent(prompt, tokens) },
+      ]
+      let agentError: Error | null = null
 
-        await streamChat(effectiveLlmConfig, chatMessages, {
-          onToken: (token) => {
-            closeReasoning()
-            appendToResult(token)
+      const record = await new AgentRunner().run(
+        agentConfig,
+        registry,
+        agentMessages,
+        {
+          onText: (chunk) => {
+            result += chunk
+            setStreamingContent(result)
           },
-          onReasoningToken: appendReasoning,
-          onDone: () => {
-            closeReasoning()
+          onToolCall: (call: ToolCall) => {
+            updateOutlineAssistantMessage(convId, assistantId, (message) => {
+              const existing = message.agentToolCalls ?? []
+              if (existing.some((item) => item.id === call.id)) return message
+              return {
+                ...message,
+                agentToolCalls: [
+                  ...existing,
+                  {
+                    id: call.id,
+                    name: call.name,
+                    params: call.arguments,
+                    result: "",
+                    status: "done",
+                    startedAt: Date.now(),
+                    finishedAt: 0,
+                  },
+                ],
+              }
+            })
           },
-          onError: () => {
-            closeReasoning()
+          onToolResult: (callId, toolResult) => {
+            updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+              ...message,
+              agentToolCalls: (message.agentToolCalls ?? []).map((item) =>
+                item.id === callId
+                  ? { ...item, result: toolResult, status: "done", finishedAt: Date.now() }
+                  : item,
+              ),
+            }))
           },
-        }, controller.signal, { reasoning: resolveUserVisibleReasoning(effectiveLlmConfig.reasoning) })
-      } else {
-        await runDeepOutlineGeneration(
-          {
-            llmConfig: effectiveLlmConfig,
-            userRequest: prompt,
-            context: outlineContext,
-            historyMessages,
+          onToolError: (callId, error) => {
+            updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+              ...message,
+              agentToolCalls: (message.agentToolCalls ?? []).map((item) =>
+                item.id === callId
+                  ? { ...item, result: error, status: "error", finishedAt: Date.now() }
+                  : item,
+              ),
+            }))
           },
-          {
-            onThinking: appendThinkingBlock,
-            onFinalContent: (content) => updateDeepResult(deepStream.appendFinal(content)),
+          onDone: () => {
+            updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+              ...message,
+              isAgentRunning: false,
+            }))
           },
-          undefined,
-          controller.signal,
-        )
-      }
+          onError: (error) => {
+            agentError = error
+          },
+        },
+        controller.signal,
+      )
+      if (agentError) throw agentError
 
-      replaceLastAssistant(convId, result, outlineSources)
+      const finalSources = Array.from(new Set([...outlineSources, ...outlineToolCallsToSources(record.toolCalls)]))
+      updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+        ...message,
+        content: result || record.finalText || "AI大纲未返回内容。",
+        sources: finalSources,
+        agentToolCalls: record.toolCalls.length ? record.toolCalls : message.agentToolCalls,
+        isAgentRunning: false,
+      }))
+      const firstUser = useOutlineChatStore.getState()
+        .conversations.find((conversation) => conversation.id === convId)
+        ?.messages.find((message) => message.role === "user")
+      if (firstUser) {
+        useOutlineChatStore.setState((state) => ({
+          conversations: state.conversations.map((conversation) =>
+            conversation.id === convId
+              ? {
+                  ...conversation,
+                  title: firstUser.content.slice(0, 20) + (firstUser.content.length > 20 ? "..." : ""),
+                }
+              : conversation,
+          ),
+        }))
+      }
+      void useOutlineChatStore.getState().saveToDisk()
       setStreamingContent("")
     } catch (err) {
-      // If aborted, keep partial content; otherwise show error
       const partial = useOutlineChatStore.getState().streamingContent
       if (partial) {
-        replaceLastAssistant(convId!, partial)
+        updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+          ...message,
+          content: partial,
+          isAgentRunning: false,
+        }))
       } else {
         const errorMsg = err instanceof Error ? err.message : String(err)
         if (errorMsg && !errorMsg.includes("aborted")) {
-          replaceLastAssistant(convId!, `生成失败:${errorMsg}`)
+          updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+            ...message,
+            content: `生成失败:${errorMsg}`,
+            isAgentRunning: false,
+          }))
         } else {
-          removeLastMessage(convId!)
+          removeLastMessage(convId)
         }
       }
       setStreamingContent("")
+      void useOutlineChatStore.getState().saveToDisk()
     } finally {
       setIsStreaming(false)
       abortRef.current = null
@@ -617,10 +707,15 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     if (activeConv?.modelId) {
       effectiveLlmConfig = resolveModelConfig(activeConv.modelId, effectiveLlmConfig, providerConfigs)
     }
+    const effectiveModelId = activeConv?.modelId || effectiveLlmConfig.model || ""
     if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
       addMessage(activeConversationId, { id: crypto.randomUUID(), role: "assistant", content: "请先在设置中配置并选择一个可用的 AI 模型,或在下方模型选择器中选择模型后再试。" })
       return
     }
+    if (!modelSupportsTools(effectiveModelId)) {
+      addMessage(activeConversationId, { id: crypto.randomUUID(), role: "assistant", content: "当前模型不支持 AI 大纲工具调用,请在下方模型选择器中更换支持工具调用的模型。" })
+      return
+    }
 
     // Remove messages from msgIndex onwards
     const conv = useOutlineChatStore.getState().conversations.find(c => c.id === activeConversationId)
@@ -639,41 +734,131 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     userScrolledUpRef.current = false
 
     try {
-      const { context, sources } = await loadOutlineContext(project.path)
-      const chatMessages: ChatMessage[] = [
-        ...targetMessages.map(m => ({ role: m.role as "user" | "assistant", content: m.content })),
-      ]
       const lastUserRequest = [...targetMessages].reverse().find((message) => message.role === "user")?.content ?? "请基于已有大纲重新生成。"
-
+      const historyMessages = targetMessages
+        .filter((message) => message.content.trim() && !message.isAgentRunning)
+        .filter((message) => message.content !== lastUserRequest)
+        .map((message) => ({
+          role: message.role,
+          content: message.content,
+        } satisfies AgentMessage))
       let result = ""
-      const deepStream = createDeepThinkingStreamRenderer()
-      const updateDeepResult = (content: string) => {
-        result = content
-        setStreamingContent(result)
-      }
-      const appendThinkingBlock = (content: string) => updateDeepResult(deepStream.updateThinking(content))
       const controller = new AbortController()
       abortRef.current = controller
+      const assistantId = crypto.randomUUID()
 
-      addMessage(activeConversationId, { id: crypto.randomUUID(), role: "assistant", content: "", sources })
+      addMessage(activeConversationId, {
+        id: assistantId,
+        role: "assistant",
+        content: "",
+        sources: [],
+        agentToolCalls: [],
+        isAgentRunning: true,
+      })
 
-      await runDeepOutlineGeneration(
-        {
-          llmConfig: effectiveLlmConfig,
-          userRequest: lastUserRequest,
-          context,
-          historyMessages: chatMessages,
+      const skillConfig = await loadDeAiSkillConfig(project.path).catch((): DeAiSkillConfig | null => null)
+      const registry = new ToolRegistry()
+      const systemPrompt = buildOutlineAgentSystemPrompt({ projectName: project.name })
+      const agentConfig = buildAgentConfig(effectiveModelId, systemPrompt, registry, {
+        wikiPath: `${normalizePath(project.path)}/wiki`,
+        getSkillConfig: () => skillConfig,
+        getChatConversations: () => {
+          const state = useChatStore.getState()
+          return state.conversations.map((conversation) => ({
+            id: conversation.id,
+            title: conversation.title,
+            messages: state.messages
+              .filter((message) => message.conversationId === conversation.id)
+              .map((message) => ({ role: message.role, content: message.content })),
+          }))
         },
+        getOutlineConversations: () =>
+          useOutlineChatStore.getState().conversations.map((conversation) => ({
+            id: conversation.id,
+            title: conversation.title,
+            messages: conversation.messages.map((message) => ({ role: message.role, content: message.content })),
+          })),
+        llmConfig: effectiveLlmConfig,
+      })
+      let agentError: Error | null = null
+      const record = await new AgentRunner().run(
+        agentConfig,
+        registry,
+        [
+          { role: "system", content: systemPrompt },
+          ...historyMessages,
+          { role: "user", content: lastUserRequest },
+        ],
         {
-          onThinking: appendThinkingBlock,
-          onFinalContent: (content) => updateDeepResult(deepStream.appendFinal(content)),
+          onText: (chunk) => {
+            result += chunk
+            setStreamingContent(result)
+          },
+          onToolCall: (call: ToolCall) => {
+            updateOutlineAssistantMessage(activeConversationId, assistantId, (message) => {
+              const existing = message.agentToolCalls ?? []
+              if (existing.some((item) => item.id === call.id)) return message
+              return {
+                ...message,
+                agentToolCalls: [
+                  ...existing,
+                  {
+                    id: call.id,
+                    name: call.name,
+                    params: call.arguments,
+                    result: "",
+                    status: "done",
+                    startedAt: Date.now(),
+                    finishedAt: 0,
+                  },
+                ],
+              }
+            })
+          },
+          onToolResult: (callId, toolResult) => {
+            updateOutlineAssistantMessage(activeConversationId, assistantId, (message) => ({
+              ...message,
+              agentToolCalls: (message.agentToolCalls ?? []).map((item) =>
+                item.id === callId
+                  ? { ...item, result: toolResult, status: "done", finishedAt: Date.now() }
+                  : item,
+              ),
+            }))
+          },
+          onToolError: (callId, error) => {
+            updateOutlineAssistantMessage(activeConversationId, assistantId, (message) => ({
+              ...message,
+              agentToolCalls: (message.agentToolCalls ?? []).map((item) =>
+                item.id === callId
+                  ? { ...item, result: error, status: "error", finishedAt: Date.now() }
+                  : item,
+              ),
+            }))
+          },
+          onDone: () => {
+            updateOutlineAssistantMessage(activeConversationId, assistantId, (message) => ({
+              ...message,
+              isAgentRunning: false,
+            }))
+          },
+          onError: (error) => {
+            agentError = error
+          },
         },
-        undefined,
         controller.signal,
       )
+      if (agentError) throw agentError
 
-      replaceLastAssistant(activeConversationId, result, sources)
+      const sources = outlineToolCallsToSources(record.toolCalls)
+      updateOutlineAssistantMessage(activeConversationId, assistantId, (message) => ({
+        ...message,
+        content: result || record.finalText || "AI大纲未返回内容。",
+        sources,
+        agentToolCalls: record.toolCalls.length ? record.toolCalls : message.agentToolCalls,
+        isAgentRunning: false,
+      }))
       setStreamingContent("")
+      void useOutlineChatStore.getState().saveToDisk()
     } catch (err) {
       const partial = useOutlineChatStore.getState().streamingContent
       if (partial) {