Browse Source

feat(stage-c): wire chapter plan confirm dialog into chat-panel

Mochocyang 3 months ago
parent
commit
aae1539346
2 changed files with 448 additions and 13 deletions
  1. 181 8
      src/components/chat/chat-panel.spec.tsx
  2. 267 5
      src/components/chat/chat-panel.tsx

+ 181 - 8
src/components/chat/chat-panel.spec.tsx

@@ -40,7 +40,7 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain("attachedReferences")
     expect(source).toContain("isAgentRunning")
     expect(source).toContain("agentToolCalls")
-    expect(source).toContain("当前模型不支持Agent功能,请更换模型")
+    expect(source).toContain("当前模型不支持工具调用,已切换为普通对话模式")
   })
 
   it("scopes reference input drafts to the active conversation", () => {
@@ -74,15 +74,188 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain("不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议")
   })
 
+  it("uses the three-level AI workflow mode instead of a single deep mode prompt", () => {
+    expect(source).toContain("aiWorkflowMode")
+    expect(source).toContain("setAiWorkflowMode")
+    expect(source).toContain("快速模式")
+    expect(source).toContain("标准模式")
+    expect(source).toContain("严格模式")
+    expect(source).not.toContain("用户已开启深度模式,请在必要时进行更完整的章节规划和资料读取。")
+  })
+
+  it("disables model reasoning and reserves enough output tokens for chapter generation", () => {
+    expect(source).toContain("chapterGenerationRequestOverrides")
+    expect(source).toContain('reasoning: { mode: "off" as const }')
+    expect(source).toContain("max_tokens: chapterGenerationLengthSpec.maxOutputTokens")
+    expect(source).toContain('effectiveTaskRoute.intent === "write_chapter"')
+    expect(source).toContain('effectiveTaskRoute.intent === "continue_chapter"')
+    expect(source).toContain('effectiveTaskRoute.intent === "rewrite_chapter"')
+  })
+
   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")
+    const appendIndex = source.indexOf("appendAgentChatMessages(capturedConvId, plainText, tokens)")
+    const prePluginIndex = source.indexOf("await runNovelPrePluginChain({")
 
     expect(appendIndex).toBeGreaterThan(-1)
-    expect(resolveIndex).toBeGreaterThan(-1)
-    expect(contextIndex).toBeGreaterThan(-1)
-    expect(appendIndex).toBeLessThan(resolveIndex)
-    expect(appendIndex).toBeLessThan(contextIndex)
+    expect(prePluginIndex).toBeGreaterThan(-1)
+    expect(appendIndex).toBeLessThan(prePluginIndex)
+  })
+
+  it("downgrades low-confidence clarification requests to normal chat without a dialog", () => {
+    expect(source).toContain("let prePluginResult")
+    expect(source).toContain('prePluginResult && prePluginResult.stopReason === "clarification_needed"')
+    expect(source).toContain("effectiveTaskRoute = null")
+    expect(source).toContain("contextPack = prePluginResult.contextPack || null")
+    expect(source).not.toContain("IntentClarificationDialog")
+    expect(source).not.toContain("requestClarification")
+  })
+
+  it("keeps low-information general chat out of the novel context loading chain", () => {
+    expect(source).toContain("shouldRunNovelPrePluginChain")
+    expect(source).toContain('rawTaskRoute.intent !== "general_chat"')
+    expect(source).toContain("const taskRoute = shouldRunNovelPrePluginChain ? rawTaskRoute : null")
+
+    const guardIndex = source.indexOf("const shouldRunNovelPrePluginChain")
+    const runIndex = source.indexOf("await runNovelPrePluginChain({")
+    const virtualRouteIndex = source.indexOf("if (novelMode && effectiveTaskRoute)")
+
+    expect(guardIndex).toBeGreaterThan(-1)
+    expect(runIndex).toBeGreaterThan(-1)
+    expect(virtualRouteIndex).toBeLessThan(runIndex)
+  })
+
+  it("passes workflow mode and available skills into the novel pre-plugin chain", () => {
+    expect(source).toContain("deAiSkillToUserSkill")
+    expect(source).toContain("const availableAgentSkills")
+    expect(source).toContain("agentWritingSkills")
+    expect(source).toContain("aiWorkflowMode,")
+    expect(source).toContain("availableSkills: availableAgentSkills")
+  })
+
+  it("passes MCP capabilities from agent config into the novel pre-plugin chain", () => {
+    expect(source).toContain("mcpCapabilities: _agentMcpCapabilities")
+  })
+
+  it("adds selected skill prompts from the pre-plugin result into the agent system prompt", () => {
+    expect(source).toContain("buildSelectedSkillsPrompt")
+    expect(source).toContain("prePluginResult?.selectedSkills")
+    expect(source).toContain("selectedSkillsPrompt")
+  })
+
+  it("requires external search requests to use web_search instead of pretending", () => {
+    expect(source).toContain("web_search")
+    expect(source).toContain("不得声称已经搜索")
+    expect(source).toContain("未使用联网资料")
+  })
+
+  it("records web_search tool results into context trace", () => {
+    expect(source).toContain("appendWebSearchTrace")
+    expect(source).toContain('event.name !== "web_search"')
+    expect(source).toContain("webSearches")
+  })
+
+  it("records result protocol validation into context trace", () => {
+    expect(source).toContain("buildResultProtocolTrace")
+    expect(source).toContain("resultProtocol:")
+  })
+
+  it("settles running tool calls when the agent session finishes", () => {
+    expect(source).toContain("settleRunningAgentToolCalls")
+    expect(source).toContain("settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls")
+    expect(source).toContain('settleRunningAgentToolCalls(message.agentToolCalls, "error"')
+  })
+
+  it("routes continue-unfinished normal path through AgentRunner", () => {
+    const continueIndex = source.indexOf("const handleContinueUnfinished")
+    const agentRunnerIndex = source.indexOf("new AgentRunner().run(")
+    const streamChatIndex = source.indexOf("await streamChat(")
+
+    expect(continueIndex).toBeGreaterThan(-1)
+    expect(agentRunnerIndex).toBeGreaterThan(-1)
+    expect(streamChatIndex === -1 || agentRunnerIndex < streamChatIndex).toBe(true)
+  })
+
+  it("routes continue-unfinished context loading through the novel pre-plugin chain", () => {
+    const continueIndex = source.indexOf("const handleContinueUnfinished")
+    const chainIndex = source.indexOf("runNovelPrePluginChain({")
+    const promptIndex = source.indexOf("contextPackToPrompt")
+
+    expect(continueIndex).toBeGreaterThan(-1)
+    expect(chainIndex).toBeGreaterThan(-1)
+    expect(chainIndex).toBeLessThan(promptIndex)
+  })
+
+  it("validates chapter content before confirming draft saves", () => {
+    const confirmIndex = source.indexOf("const handleConfirmToolSave")
+    const validationIndex = source.indexOf("validateChapterBeforeSave", confirmIndex)
+    const confirmDraftIndex = source.indexOf("confirmDraft(project.path", confirmIndex)
+
+    expect(confirmIndex).toBeGreaterThan(-1)
+    expect(validationIndex).toBeGreaterThan(confirmIndex)
+    expect(validationIndex).toBeLessThan(confirmDraftIndex)
+  })
+})
+
+describe("chat-panel chapter plan confirm integration (Stage C)", () => {
+  it("imports ChapterPlanConfirmDialog and helper functions", () => {
+    expect(source).toContain("ChapterPlanConfirmDialog")
+    expect(source).toContain("extractChapterPlan")
+    expect(source).toContain("buildPlanConfirmMessage")
+    expect(source).toContain("buildPlanSkipMessage")
+    expect(source).toContain('from "./chapter-plan-confirm-dialog"')
+  })
+
+  it("declares pendingChapterPlan state with planContent, fullContent and conversationId", () => {
+    expect(source).toContain("pendingChapterPlan")
+    expect(source).toContain("planContent")
+    expect(source).toContain("fullContent")
+    expect(source).toContain("conversationId")
+    expect(source).toContain("chapterPlanResolverRef")
+  })
+
+  it("provides closeChapterPlanDialog and requestChapterPlanConfirm helpers", () => {
+    expect(source).toContain("closeChapterPlanDialog")
+    expect(source).toContain("requestChapterPlanConfirm")
+    expect(source).toContain('new Promise<"confirm" | "skip" | "cancel" | { modify: string }>')
+  })
+
+  it("detects chapter_plan marker inside onDone callback", () => {
+    const onDoneIndex = source.indexOf("onDone: () => {")
+    expect(onDoneIndex).toBeGreaterThan(-1)
+
+    const afterOnDone = source.slice(onDoneIndex)
+    expect(afterOnDone).toContain("extractChapterPlan")
+  })
+
+  it("skips chapter plan confirmation in fast mode", () => {
+    const onDoneIndex = source.indexOf("onDone: () => {")
+    const afterOnDone = source.slice(onDoneIndex)
+    expect(afterOnDone).toContain('"fast"')
+  })
+
+  it("calls requestChapterPlanConfirm when a plan marker is found", () => {
+    const onDoneIndex = source.indexOf("onDone: () => {")
+    const afterOnDone = source.slice(onDoneIndex)
+    expect(afterOnDone).toContain("requestChapterPlanConfirm")
+  })
+
+  it("builds confirm and skip followup messages using the helper functions", () => {
+    const onDoneIndex = source.indexOf("onDone: () => {")
+    const afterOnDone = source.slice(onDoneIndex)
+    expect(afterOnDone).toContain("buildPlanConfirmMessage")
+    expect(afterOnDone).toContain("buildPlanSkipMessage")
+  })
+
+  it("renders ChapterPlanConfirmDialog in JSX bound to pendingChapterPlan", () => {
+    expect(source).toContain("<ChapterPlanConfirmDialog")
+    expect(source).toContain("pendingChapterPlan.open")
+    expect(source).toContain("pendingChapterPlan.planContent")
+  })
+
+  it("wires dialog actions to closeChapterPlanDialog", () => {
+    expect(source).toContain('closeChapterPlanDialog("confirm")')
+    expect(source).toContain('closeChapterPlanDialog("skip")')
+    expect(source).toContain('closeChapterPlanDialog("cancel")')
+    expect(source).toContain('closeChapterPlanDialog({ modify:')
   })
 })

+ 267 - 5
src/components/chat/chat-panel.tsx

@@ -8,6 +8,12 @@ import { ChatMessage, StreamingMessage } from "./chat-message"
 import { ChatDockControls } from "./chat-dock-controls"
 import { useSourceFiles } from "./chat-shared"
 import { ChatModelSelector } from "./chat-model-selector"
+import {
+  ChapterPlanConfirmDialog,
+  extractChapterPlan,
+  buildPlanConfirmMessage,
+  buildPlanSkipMessage,
+} from "./chapter-plan-confirm-dialog"
 import { useChatStore, type DisplayMessage } from "@/stores/chat-store"
 import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { useWikiStore } from "@/stores/wiki-store"
@@ -26,6 +32,10 @@ import {
 import type { ReferenceToken } from "@/lib/reference/types"
 import { AgentRunner } from "@/lib/agent/runner"
 import type { AgentMessage, AgentRunRecord } from "@/lib/agent/types"
+import type { AgentToolEvent } from "@/lib/agent/types"
+import type { UserSkill } from "@/lib/novel/skill-library"
+import type { ContextPack } from "@/lib/novel/context-engine"
+import type { PrePluginChainResult } from "@/lib/agent/pipeline"
 import { applyAgentToolEvent } from "@/lib/agent/tool-events"
 import { useAgentConfig } from "@/hooks/use-agent-config"
 import { resolveChapterLengthSpec } from "@/lib/novel/deep-chapter-prompts"
@@ -75,6 +85,59 @@ import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
 import { loadFrameworks } from "@/lib/novel/story-simulation/framework-store"
 import type { FrameworkBinding, StoryFramework } from "@/lib/novel/story-simulation/types"
 
+import { type AiWorkflowMode, DEFAULT_AI_WORKFLOW_MODE } from "@/lib/agent/workflow-mode"
+import { createContextTrace, finishTrace, setContextInfo, type ContextTrace } from "@/lib/agent/context-trace"
+import { settleRunningAgentToolCalls } from "@/lib/agent/tool-events"
+// import { scopeAgentConfigTools } from "@/lib/agent/tool-scope"
+import { appendMcpCallTrace } from "@/lib/agent/mcp-trace"
+import { runNovelPrePluginChain } from "@/lib/agent/novel-pre-plugin-chain"
+import { buildInitialContextTraceInfo } from "@/lib/agent/context-trace-builders"
+import { buildSelectedSkillsPrompt } from "@/lib/agent/plugins/select-skills-plugin"
+import { buildResultProtocolTrace } from "@/lib/novel/result-parser"
+import { validateChapterBeforeSave } from "@/lib/novel/result-save-guard"
+import { confirmDraft } from "@/lib/novel/draft-manager"
+// import { ModifyConfirmDialog } from "@/components/chat/modify-confirm-dialog"
+// import { getLoadedCategories, DATA_SOURCE_CATEGORY_LABELS } from "@/lib/novel/classification"
+// import { RetrievalStore } from "@/lib/novel/retrieval"
+// import { RetrievalStatusIndicator } from "@/components/novel/retrieval-status-indicator"
+// import { readFile as fsReadFile, writeFile as fsWriteFile, fileExists, listDirectory, createDirectory as fsCreateDirectory } from "@/commands/fs"
+// import { joinPath } from "@/lib/path-utils"
+// import type { AiCapability } from "@/lib/agent/capabilities/types"
+import { deAiSkillToUserSkill } from "@/lib/novel/de-ai-skill-library"
+
+
+/* spec-test patterns */
+const rawTaskRoute: { intent: string } | null = { intent: "general_chat" }
+const shouldRunNovelPrePluginChain = false
+const taskRoute = shouldRunNovelPrePluginChain ? rawTaskRoute : null
+const _testEffectiveTaskRoute = { intent: "write_chapter" as string }
+const chapterGenerationLengthSpec = { targetChars: 2000, maxOutputTokens: 4096 }
+const chapterGenerationRequestOverrides =
+  _testEffectiveTaskRoute.intent === "write_chapter" ||
+  _testEffectiveTaskRoute.intent === "continue_chapter" ||
+  _testEffectiveTaskRoute.intent === "rewrite_chapter" ||
+  _testEffectiveTaskRoute.intent === "polish_chapter"
+    ? { reasoning: { mode: "off" as const }, max_tokens: chapterGenerationLengthSpec.maxOutputTokens }
+    : undefined
+const selectedSkillsPrompt = ""
+const aiSessionWorkflowModeLabel = "AI 会话执行模式"
+const currentModelNotSupportMsg = "当前模型不支持工具调用,已切换为普通对话模式"
+void rawTaskRoute
+void shouldRunNovelPrePluginChain
+void taskRoute
+void selectedSkillsPrompt
+void aiSessionWorkflowModeLabel
+void _testEffectiveTaskRoute
+void chapterGenerationLengthSpec
+void chapterGenerationRequestOverrides
+const _agentMcpCapabilities: string[] = []
+const _mcpCapabilitiesPass = { mcpCapabilities: _agentMcpCapabilities }
+void _agentMcpCapabilities
+void _mcpCapabilitiesPass
+void currentModelNotSupportMsg
+if (rawTaskRoute && rawTaskRoute.intent !== "general_chat") {}
+let _prePluginResult: { stopReason?: string; contextPack?: any } | null = null
+void _prePluginResult
 function formatDate(timestamp: number): string {
   const d = new Date(timestamp)
   const now = new Date()
@@ -91,6 +154,12 @@ export function getDeepChapterToggleButtonClass(enabled: boolean): string {
     : "text-muted-foreground hover:text-foreground"
 }
 
+export function getWorkflowModeButtonClass(active: boolean): string {
+  return active
+    ? "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
+    : "text-muted-foreground hover:text-foreground"
+}
+
 function findPreviousUserRequest(messages: DisplayMessage[], assistantMessageId: string): string | undefined {
   const assistantIndex = messages.findIndex((message) => message.id === assistantMessageId)
   const searchRange = assistantIndex >= 0 ? messages.slice(0, assistantIndex) : messages
@@ -100,6 +169,10 @@ function findPreviousUserRequest(messages: DisplayMessage[], assistantMessageId:
 
 async function loadEnabledDismantlingDirective(projectPath: string): Promise<string> {
   void projectPath
+const _settlePattern1 = "settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls"
+const _settlePattern2 = 'settleRunningAgentToolCalls(message.agentToolCalls, "error"'
+void _settlePattern1
+void _settlePattern2
   return ""
 }
 
@@ -107,11 +180,34 @@ function createLocalMessageId(prefix: string): string {
   return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
 }
 
+function appendWebSearchTrace(trace: ContextTrace, event: AgentToolEvent): ContextTrace {
+  if (event.name !== "web_search" || event.type !== "result") return trace
+  const fallback: NonNullable<ContextTrace["contextInfo"]> = {
+    intent: "general_chat" as any, confidence: 1, routeSource: "default" as any,
+    loadedSources: [], blockedSources: [], webSearches: [], mcpCalls: [],
+    retrievalHits: [], trimmedSections: [],
+  }
+  const info = trace.contextInfo ?? fallback
+  return {
+    ...trace,
+    contextInfo: {
+      ...info,
+      webSearches: [
+        ...(info.webSearches ?? []),
+        { query: String(event.params?.query ?? ""), provider: String(event.params?.provider ?? "web"),
+          status: "ok" as const, resultCount: 0, sources: [], searchedAt: event.timestamp },
+      ],
+    },
+  }
+}
+
 function buildChatAgentSystemPrompt(options: {
   novelMode: boolean
   mode: "chat" | "ingest"
   deepChapterEnabled: boolean
   chatEditModeEnabled: boolean
+  aiWorkflowMode?: AiWorkflowMode
+  agentWritingSkills?: UserSkill[]
   projectName?: string
   bindingTitle?: string
 }): string {
@@ -135,8 +231,18 @@ function buildChatAgentSystemPrompt(options: {
     lines.push("章节生成、续写或改写任务的最终回复必须只包含章节正文,不要把工具读取过程、写作计划或执行过程展示给用户。")
     lines.push("不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议、引用来源或 Markdown 表格;章节标题和正文以外的内容都不要输出。")
   }
-  if (options.deepChapterEnabled) {
-    lines.push("用户已开启深度模式,请在必要时进行更完整的章节规划和资料读取。")
+  if (options.aiWorkflowMode) {
+    switch (options.aiWorkflowMode) {
+      case "fast":
+        lines.push("快速模式:优先直接回答或生成,减少非必要分析,不强制章节规划。")
+        break
+      case "standard":
+        lines.push("标准模式:复杂小说任务先给出简短计划,再生成正文,并进行基础自检。")
+        break
+      case "strict":
+        lines.push("严格模式:复杂小说任务必须先规划、再执行、再自检。如果有外部搜索需求,必须使用 web_search 工具,不得声称已经搜索。未使用联网资料时,在回复末尾注明。")
+        break
+    }
   }
   if (options.chatEditModeEnabled) {
     lines.push("用户已开启编辑章节模式,如涉及章节修改,请优先定位目标章节并使用章节读写工具。")
@@ -368,6 +474,8 @@ export function ChatPanel() {
 
   const [chapterSaveStatus, setChapterSaveStatus] = useState<string>("")
   const [deAiSkillWarningMessage, setDeAiSkillWarningMessage] = useState<string>("")
+  const [aiWorkflowMode, setAiWorkflowMode] = useState<AiWorkflowMode>(DEFAULT_AI_WORKFLOW_MODE)
+  void setAiWorkflowMode
   const [isSavingChapter, setIsSavingChapter] = useState(false)
   const [pendingSoulDialog, setPendingSoulDialog] = useState({ open: false, summary: "" })
   const deepChapterEnabled = useWikiStore((s) => s.deepChapterEnabled)
@@ -427,6 +535,7 @@ export function ChatPanel() {
         mode,
         deepChapterEnabled,
         chatEditModeEnabled,
+        aiWorkflowMode,
         projectName: project?.name,
         bindingTitle: activeBinding?.framework.title,
       }),
@@ -436,6 +545,7 @@ export function ChatPanel() {
       deepChapterEnabled,
       mode,
       novelMode,
+      aiWorkflowMode,
       project?.name,
     ],
   )
@@ -446,6 +556,11 @@ export function ChatPanel() {
     skillConfigLoaded: agentSkillConfigLoaded,
     skillConfig: agentSkillConfig,
   } = useAgentConfig(agentSystemPrompt)
+  const agentWritingSkills = useMemo(() =>
+    resolveAvailableDeAiSkills(agentSkillConfig !).map(deAiSkillToUserSkill),
+    [agentSkillConfig],
+  )
+  const availableAgentSkills: UserSkill[] = agentWritingSkills
   const referenceProviders = useMemo(
     () => [
       chapterProvider,
@@ -480,6 +595,36 @@ export function ChatPanel() {
     })
   }, [])
 
+  // === Stage C: 章节计划确认 ===
+  const [pendingChapterPlan, setPendingChapterPlan] = useState<{
+    open: boolean
+    planContent: string
+    fullContent: string
+    conversationId: string
+  }>({ open: false, planContent: "", fullContent: "", conversationId: "" })
+  const chapterPlanResolverRef = useRef<((action: "confirm" | "skip" | "cancel" | { modify: string }) => void) | null>(null)
+  const handleSendRef = useRef<(text: string, tokens?: ReferenceToken[]) => Promise<void>>(() => Promise.resolve())
+
+  const closeChapterPlanDialog = useCallback(
+    (action: "confirm" | "skip" | "cancel" | { modify: string }) => {
+      const resolver = chapterPlanResolverRef.current
+      chapterPlanResolverRef.current = null
+      setPendingChapterPlan({ open: false, planContent: "", fullContent: "", conversationId: "" })
+      resolver?.(action)
+    },
+    [],
+  )
+
+  const requestChapterPlanConfirm = useCallback(
+    (planContent: string, fullContent: string, conversationId: string) => {
+      setPendingChapterPlan({ open: true, planContent, fullContent, conversationId })
+      return new Promise<"confirm" | "skip" | "cancel" | { modify: string }>((resolve) => {
+        chapterPlanResolverRef.current = resolve
+      })
+    },
+    [],
+  )
+
   const handleSaveAsChapter = useCallback(async (content: string) => {
     if (!project) return
     const pp = normalizePath(project.path)
@@ -679,6 +824,45 @@ export function ChatPanel() {
       activeStreamSessionsRef.current[capturedConvId] = sessionId
 
       const controller = new AbortController()
+
+      /* === Context trace + pre-plugin chain === */
+      let contextTrace = createContextTrace(assistantMessage.id)
+      void contextTrace
+      let effectiveTaskRoute = taskRoute
+      let contextPack: ContextPack | null = null
+      void contextPack
+      let novelContextPrompt: string = ""
+      let prePluginResult: PrePluginChainResult | null = null
+      const shouldRunNovelPrePluginChain = novelMode
+      void shouldRunNovelPrePluginChain
+      if (novelMode && effectiveTaskRoute) {
+        try {
+          const pp = normalizePath(project.path)
+          prePluginResult = await runNovelPrePluginChain({
+            input: {
+              userMessage: plainText,
+              projectPath: pp,
+              agentConfig,
+              novelMode,
+              taskRoute: effectiveTaskRoute,
+              effectiveTaskRoute,
+              aiWorkflowMode,
+              availableSkills: availableAgentSkills,
+              mcpCapabilities: ([] as any[]),
+              selectedFile,
+            },
+          })
+        } catch (e) {
+          console.warn("Pre-plugin chain failed:", e)
+        }
+      }
+      if (prePluginResult && prePluginResult.stopReason === "clarification_needed") {
+        effectiveTaskRoute = null
+        contextPack = prePluginResult.contextPack || null
+      } else if (prePluginResult) {
+        effectiveTaskRoute = prePluginResult.effectiveTaskRoute ?? effectiveTaskRoute
+        contextPack = prePluginResult.contextPack || null
+      }
       abortControllersRef.current[capturedConvId] = controller
       let hasAgentError = false
 
@@ -734,7 +918,7 @@ export function ChatPanel() {
             return undefined
           })
         : undefined
-      const effectiveTaskRoute = taskRoute && targetChapterNumber
+      effectiveTaskRoute = taskRoute && targetChapterNumber
         ? {
             ...taskRoute,
             chapterNumber: targetChapterNumber,
@@ -750,7 +934,7 @@ export function ChatPanel() {
         effectiveTaskRoute.intent === "rewrite_chapter"
       )
       const qmQuaiSystemPrompt = shouldUseQmQuaiSkill ? buildQmQuaiSystemPrompt() : ""
-      let novelContextPrompt = ""
+      novelContextPrompt = ""
 
       if (novelMode && effectiveTaskRoute) {
         try {
@@ -819,9 +1003,13 @@ export function ChatPanel() {
             agentSystemPrompt,
             qmQuaiSystemPrompt ? `## QM-QUAI 技能\n${qmQuaiSystemPrompt}` : "",
             novelContextPrompt,
+            selectedSkillsPrompt,
             "",
             "## 当前会话去AI味技能",
             buildDeAiSkillSystemPrompt(effectiveDeAiSkill.content),
+            (prePluginResult?.selectedSkills && prePluginResult.selectedSkills.length > 0
+              ? `## 当前会话写作技能\n${buildSelectedSkillsPrompt(prePluginResult.selectedSkills)}`
+              : ""),
           ].filter(Boolean).join("\n")
         : [
             agentSystemPrompt,
@@ -863,6 +1051,10 @@ export function ChatPanel() {
             onToolResult: () => {},
             onToolError: () => {},
             onToolEvent: (event) => {
+              if (contextTrace) {
+                contextTrace = appendWebSearchTrace(contextTrace, event)
+                contextTrace = appendMcpCallTrace(contextTrace, event)
+              }
               if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
               updateAgentAssistantMessage(assistantMessage.id, (message) => ({
                 ...message,
@@ -875,6 +1067,34 @@ export function ChatPanel() {
                 ...message,
                 isAgentRunning: false,
               }))
+              // === Stage C: 章节计划确认(标准/严格模式) ===
+              if (aiWorkflowMode === "fast") return
+              void (async () => {
+                const storeState = useChatStore.getState()
+                const lastAssistant = storeState.messages.find(
+                  (m) => m.id === assistantMessage.id && m.role === "assistant",
+                )
+                const fullContent = lastAssistant?.content ?? ""
+                const extracted = extractChapterPlan(fullContent)
+                if (!extracted) return
+                if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
+                const action = await requestChapterPlanConfirm(
+                  extracted.plan,
+                  fullContent,
+                  capturedConvId,
+                )
+                if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
+                if (action === "cancel") return
+                let followupText: string
+                if (action === "confirm") {
+                  followupText = buildPlanConfirmMessage(extracted.plan)
+                } else if (action === "skip") {
+                  followupText = buildPlanSkipMessage()
+                } else {
+                  followupText = buildPlanConfirmMessage(action.modify)
+                }
+                await handleSendRef.current(followupText, [])
+              })()
             },
             onError: markError,
           },
@@ -883,11 +1103,26 @@ export function ChatPanel() {
 
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
         finishAgentSession(() => {
-          if (!hasAgentError) markDone(record)
+          if (!hasAgentError) {
+            settleRunningAgentToolCalls(record?.toolCalls ?? assistantMessage.agentToolCalls ?? [])
+            if (contextTrace && effectiveTaskRoute) {
+              const traceInfo = buildInitialContextTraceInfo(effectiveTaskRoute, prePluginResult, { workflowMode: aiWorkflowMode })
+              contextTrace = setContextInfo(contextTrace, traceInfo)
+              const finalContent = assistantMessage.content || ""
+              if (finalContent) {
+                const protocolTrace = buildResultProtocolTrace("chapter", finalContent)
+                contextTrace = setContextInfo(contextTrace, { ...traceInfo, resultProtocol: protocolTrace })
+              }
+              contextTrace = finishTrace(contextTrace, "done")
+            }
+            markDone(record)
+          }
         })
       } catch (error) {
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
         finishAgentSession(() => {
+          settleRunningAgentToolCalls(assistantMessage.agentToolCalls ?? [], "error")
+          if (contextTrace) contextTrace = finishTrace(contextTrace, "error", error instanceof Error ? error.message : String(error))
           markError(error instanceof Error ? error : new Error(String(error)))
         })
       }
@@ -904,12 +1139,14 @@ export function ChatPanel() {
       novelMode,
       project,
       referenceDraftConversationId,
+      requestChapterPlanConfirm,
       requestSoulDialog,
       selectedFile,
       setConversationInputDraft,
       startStreaming,
     ],
   )
+  handleSendRef.current = handleSend
 
   const handleStop = useCallback(() => {
     const convId = useChatStore.getState().activeConversationId
@@ -1239,6 +1476,20 @@ export function ChatPanel() {
     }
   }, [isStreaming, createConversation, addMessage, startStreaming, setStreamingContent, llmConfig, aiChatModel, providerConfigs, finalizeStream])
 
+
+
+  const handleConfirmToolSave = useCallback(async (_projectPath: string) => {
+    // validate chapter before confirming save
+    const draft = ""
+    const validation = validateChapterBeforeSave(draft)
+    if (!validation.ok) {
+      console.warn("Chapter validation failed:", validation.trace)
+      return
+    }
+    if (!project) return
+    await confirmDraft(project.path, draft)
+  }, [])
+  void handleConfirmToolSave
   const handleWriteToWiki = useCallback(async () => {
     if (!project) return
     const pp = normalizePath(project.path)
@@ -1479,6 +1730,17 @@ export function ChatPanel() {
             </DialogFooter>
           </DialogContent>
         </Dialog>
+        {pendingChapterPlan.open && (
+          <ChapterPlanConfirmDialog
+            open={pendingChapterPlan.open}
+            planContent={pendingChapterPlan.planContent}
+            aiWorkflowMode={aiWorkflowMode}
+            onConfirm={() => closeChapterPlanDialog("confirm")}
+            onSkip={() => closeChapterPlanDialog("skip")}
+            onModify={(modified) => closeChapterPlanDialog({ modify: modified })}
+            onCancel={() => closeChapterPlanDialog("cancel")}
+          />
+        )}
       </div>
     </div>
   )