فهرست منبع

Merge pull request #52 from darknessomi/feat/strict-writing-entity-web-search

feat(ai): 接入 Codex 主 Agent 并完善写作上下文与缓存诊断
darknessomi 1 ماه پیش
والد
کامیت
3da98a5102
73فایلهای تغییر یافته به همراه6148 افزوده شده و 954 حذف شده
  1. 612 312
      src-tauri/src/commands/codex_cli.rs
  2. 4 3
      src-tauri/src/lib.rs
  3. 4 3
      src-tauri/src/main.rs
  4. 4 0
      src/components/chat/chat-panel.spec.tsx
  5. 23 8
      src/components/chat/chat-panel.tsx
  6. 122 1
      src/components/chat/context-trace-panel.spec.tsx
  7. 95 3
      src/components/common/context-hub-stats-summary.tsx
  8. 3 8
      src/components/settings/llm-presets.ts
  9. 12 1
      src/components/settings/llm-wiki-model-settings.spec.ts
  10. 6 8
      src/components/settings/preset-resolver.ts
  11. 6 0
      src/components/settings/sections/llm-provider-section.spec.ts
  12. 8 4
      src/components/settings/sections/llm-provider-section.tsx
  13. 40 6
      src/components/sources/outline-chat-panel.tsx
  14. 2 2
      src/i18n/en.json
  15. 2 2
      src/i18n/zh.json
  16. 16 0
      src/lib/agent/activity-trace.spec.ts
  17. 3 0
      src/lib/agent/activity-trace.ts
  18. 305 0
      src/lib/agent/codex-app-server-runner.spec.ts
  19. 417 0
      src/lib/agent/codex-app-server-runner.ts
  20. 4 1
      src/lib/agent/config.spec.ts
  21. 0 2
      src/lib/agent/config.ts
  22. 4 0
      src/lib/agent/pipeline.ts
  23. 4 0
      src/lib/agent/plugins/build-system-prompt-plugin.spec.ts
  24. 14 1
      src/lib/agent/plugins/build-system-prompt-plugin.ts
  25. 69 0
      src/lib/agent/runner.spec.ts
  26. 26 165
      src/lib/agent/runner.ts
  27. 19 0
      src/lib/agent/tool-evidence-ledger.spec.ts
  28. 181 0
      src/lib/agent/tool-executor.ts
  29. 32 1
      src/lib/agent/tool-result.spec.ts
  30. 12 1
      src/lib/agent/tool-result.ts
  31. 38 0
      src/lib/agent/tools/run-chapter-workflow.spec.ts
  32. 1 0
      src/lib/agent/tools/run-chapter-workflow.ts
  33. 9 0
      src/lib/agent/types.ts
  34. 160 0
      src/lib/codex-app-server-client.spec.ts
  35. 309 0
      src/lib/codex-app-server-client.ts
  36. 25 0
      src/lib/codex-cli-model.spec.ts
  37. 16 0
      src/lib/codex-cli-model.ts
  38. 24 0
      src/lib/codex-cli-timeout.spec.ts
  39. 18 0
      src/lib/codex-cli-timeout.ts
  40. 155 0
      src/lib/codex-cli-transport.spec.ts
  41. 219 209
      src/lib/codex-cli-transport.ts
  42. 29 0
      src/lib/context-hub/data-source-cache.spec.ts
  43. 12 2
      src/lib/context-hub/data-source-cache.ts
  44. 97 0
      src/lib/context-hub/normalize-stats.spec.ts
  45. 61 0
      src/lib/context-hub/provider-usage.spec.ts
  46. 35 4
      src/lib/context-hub/provider-usage.ts
  47. 60 2
      src/lib/context-hub/types.ts
  48. 152 6
      src/lib/llm-client.ts
  49. 72 2
      src/lib/llm-client.usage.spec.ts
  50. 131 0
      src/lib/llm-request-trace.spec.ts
  51. 248 0
      src/lib/llm-request-trace.ts
  52. 5 0
      src/lib/local-cli-config.ts
  53. 3 1
      src/lib/novel/chapter-execution-contract.ts
  54. 3 1
      src/lib/novel/chapter-execution-report.ts
  55. 5 1
      src/lib/novel/chapter-plan-compliance.ts
  56. 9 21
      src/lib/novel/context-data-sources.ts
  57. 17 16
      src/lib/novel/context-engine-outline-read.spec.ts
  58. 25 2
      src/lib/novel/context-engine.spec.ts
  59. 46 87
      src/lib/novel/context-engine.ts
  60. 156 3
      src/lib/novel/deep-chapter-generation.spec.ts
  61. 186 48
      src/lib/novel/deep-chapter-generation.ts
  62. 1 1
      src/lib/novel/mod.ts
  63. 187 0
      src/lib/novel/outline-context-index.spec.ts
  64. 679 0
      src/lib/novel/outline-context-index.ts
  65. 24 8
      src/lib/novel/previous-chapters-analysis.ts
  66. 2 0
      src/lib/novel/review-adapter.ts
  67. 37 4
      src/lib/novel/section-briefing.ts
  68. 244 0
      src/lib/novel/writing-entity-web-search.spec.ts
  69. 381 0
      src/lib/novel/writing-entity-web-search.ts
  70. 94 0
      src/lib/project-store.integration.test.ts
  71. 85 0
      src/lib/project-store.ts
  72. 25 3
      src/lib/settings-model-list.spec.ts
  73. 14 1
      src/lib/settings-model-list.ts

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 612 - 312
src-tauri/src/commands/codex_cli.rs


+ 4 - 3
src-tauri/src/lib.rs

@@ -58,7 +58,7 @@ pub fn run() {
                 eprintln!("[proxy] could not resolve app_data_dir");
             }
             app.manage(commands::claude_cli::ClaudeCliState::default());
-            app.manage(commands::codex_cli::CodexCliState::default());
+            app.manage(commands::codex_cli::CodexAppServerState::default());
             app.manage(commands::cursor_cli::CursorProxyState::default());
             app.manage(commands::file_sync::FileSyncState::default());
             app.manage(commands::mcp_stdio::McpStdioState::default());
@@ -103,8 +103,9 @@ pub fn run() {
             commands::claude_cli::claude_cli_spawn,
             commands::claude_cli::claude_cli_kill,
             commands::codex_cli::codex_cli_detect,
-            commands::codex_cli::codex_cli_spawn,
-            commands::codex_cli::codex_cli_kill,
+            commands::codex_cli::codex_app_server_start,
+            commands::codex_cli::codex_app_server_write,
+            commands::codex_cli::codex_app_server_stop,
             commands::cursor_cli::cursor_cli_detect,
             commands::cursor_cli::cursor_proxy_status,
             commands::cursor_cli::cursor_proxy_ensure,

+ 4 - 3
src-tauri/src/main.rs

@@ -60,7 +60,7 @@ fn main() {
                 eprintln!("[proxy] could not resolve app_data_dir");
             }
             app.manage(commands::claude_cli::ClaudeCliState::default());
-            app.manage(commands::codex_cli::CodexCliState::default());
+            app.manage(commands::codex_cli::CodexAppServerState::default());
             app.manage(commands::cursor_cli::CursorProxyState::default());
             app.manage(commands::file_sync::FileSyncState::default());
             app.manage(commands::mcp_stdio::McpStdioState::default());
@@ -105,8 +105,9 @@ fn main() {
             commands::claude_cli::claude_cli_spawn,
             commands::claude_cli::claude_cli_kill,
             commands::codex_cli::codex_cli_detect,
-            commands::codex_cli::codex_cli_spawn,
-            commands::codex_cli::codex_cli_kill,
+            commands::codex_cli::codex_app_server_start,
+            commands::codex_cli::codex_app_server_write,
+            commands::codex_cli::codex_app_server_stop,
             commands::cursor_cli::cursor_cli_detect,
             commands::cursor_cli::cursor_proxy_status,
             commands::cursor_cli::cursor_proxy_ensure,

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

@@ -100,6 +100,8 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain("description: \"完整质检")
     expect(source).toContain("快速模式像普通对话一样直接出结果")
     expect(source).toContain("读取上下文、生成任务书和正文初稿后直接完成")
+    expect(source).toContain("读取更完整上下文,执行审稿、返修、复审、去AI味和计划验收。会联网搜索。")
+    expect(source).not.toContain("前文与实体表")
     expect(source).toContain("workflowModeDropdownStyle.width")
     expect(source).toContain("routeDescription")
   })
@@ -273,8 +275,10 @@ describe("chat-panel agent reference integration", () => {
 
   it("requires external search requests to use web_search instead of pretending", () => {
     expect(source).toContain("web_search")
+    expect(source).toContain("会联网搜索")
     expect(source).toContain("不得声称已经搜索")
     expect(source).toContain("未使用联网资料")
+    expect(source).not.toContain("前文与实体表")
   })
 
   it("records web_search tool results into context trace", () => {

+ 23 - 8
src/components/chat/chat-panel.tsx

@@ -183,7 +183,7 @@ const aiWorkflowModeOptions: Array<{
     mode: "strict",
     label: "严格",
     description: "完整质检",
-    routeDescription: "读取更完整上下文,执行审稿、返修、复审、去AI味和计划验收。",
+    routeDescription: "读取更完整上下文,执行审稿、返修、复审、去AI味和计划验收。会联网搜索。",
   },
 ]
 const currentModelNotSupportMsg = "当前模型不支持工具调用,已切换为普通对话模式"
@@ -359,7 +359,7 @@ function buildChatAgentSystemPrompt(options: {
         lines.push("标准模式:读取上下文,生成任务书和正文初稿后直接完成,不做正文后审核。")
         break
       case "strict":
-        lines.push("严格模式:读取更完整上下文,执行更严格的审稿、返修和一致性检查。如果有外部搜索需求,必须使用 web_search 工具,不得声称已经搜索。未使用联网资料时,在回复末尾注明。")
+        lines.push("严格模式:读取更完整上下文,执行更严格的审稿、返修和一致性检查。会联网搜索。如果有外部搜索需求,必须使用 web_search 工具,不得声称已经搜索。未使用联网资料时,在回复末尾注明。")
         break
       }
     if (options.planExecuteEnabled && options.aiWorkflowMode !== "fast") {
@@ -1754,6 +1754,10 @@ export function ChatPanel() {
 
       const prePluginSystemPrompt = prePluginResult?.finalSystemPrompt?.trim()
       const prePluginSystemRulesPrompt = prePluginResult?.finalSystemRulesPrompt?.trim()
+      const stableSystemRulesPrompt = prePluginResult?.stableSystemRulesPrompt?.trim()
+      const dynamicSystemRulesPrompt = prePluginResult?.dynamicSystemRulesPrompt?.trim()
+      const hasSplitSystemRules = prePluginResult?.stableSystemRulesPrompt !== undefined
+        || prePluginResult?.dynamicSystemRulesPrompt !== undefined
       const baseSystemPrompt = [
         prePluginSystemPrompt || sessionAgentSystemPrompt,
         qmQuaiSystemPrompt ? `## QM-QUAI 技能\n${qmQuaiSystemPrompt}` : "",
@@ -1773,14 +1777,17 @@ export function ChatPanel() {
         : [
             baseSystemPrompt,
           ].filter(Boolean).join("\n")
-      const contextHubSoftwareRules = prePluginSystemRulesPrompt || sessionAgentSystemPrompt
+      const contextHubSoftwareRules = hasSplitSystemRules
+        ? (stableSystemRulesPrompt ?? "")
+        : (prePluginSystemRulesPrompt || sessionAgentSystemPrompt)
       const contextHubSystemContent = contextHubResult
         ? buildContextHubSystemContent(contextHubSoftwareRules, contextHubResult, [
+            dynamicSystemRulesPrompt ?? "",
             qmQuaiSystemPrompt ? `## QM-QUAI 技能\n${qmQuaiSystemPrompt}` : "",
-            prePluginSystemRulesPrompt ? "" : taskDirective,
+            prePluginSystemRulesPrompt || hasSplitSystemRules ? "" : taskDirective,
             goldenDirective,
-            prePluginSystemRulesPrompt ? "" : selectedSkillsPrompt,
-            !prePluginSystemRulesPrompt && prePluginResult?.selectedSkills?.length
+            prePluginSystemRulesPrompt || hasSplitSystemRules ? "" : selectedSkillsPrompt,
+            !prePluginSystemRulesPrompt && !hasSplitSystemRules && prePluginResult?.selectedSkills?.length
               ? `## 当前会话写作技能\n${buildSelectedSkillsPrompt(prePluginResult.selectedSkills)}`
               : "",
           ])
@@ -1873,7 +1880,9 @@ export function ChatPanel() {
           : sessionTools
         const usageSnapshotBase = buildContextUsageSnapshot({
           windowTokens: getEffectiveMaxContextSize(agentConfig.llmConfig),
-          softwareRules: contextHubResult ? contextHubSoftwareRules : systemPromptForConfig,
+          softwareRules: contextHubResult
+            ? [contextHubSoftwareRules, dynamicSystemRulesPrompt].filter(Boolean).join("\n\n")
+            : systemPromptForConfig,
           toolDefinitionsJson: JSON.stringify(toOpenAITools(advertisedTools)),
           stableTokens: contextHubResult?.stats.stableTokens,
           summaryTokens: contextHubResult?.stats.summaryTokens,
@@ -1973,7 +1982,7 @@ export function ChatPanel() {
             record.lastRequestUsage ?? record.usage,
           ),
         )
-        if (contextHubResult && record.usage) {
+        if (contextHubResult && (record.usage || record.requestTraces?.length)) {
           try {
             const contextHubSnapshot = await persistContextHubProviderUsage(
               getContextHub(pp),
@@ -1985,6 +1994,12 @@ export function ChatPanel() {
                 requestDiagnostics: buildLlmRequestDiagnostics(
                   record.usage,
                   Math.max(1, record.roundsUsed || 1),
+                  {
+                    requests: record.requestTraces,
+                    omittedRequestCount: record.omittedRequestTraceCount,
+                    requestCountAvailable: record.providerRequestCountAvailable,
+                    usageScope: record.usageAggregationScope,
+                  },
                 ),
               },
             )

+ 122 - 1
src/components/chat/context-trace-panel.spec.tsx

@@ -43,7 +43,8 @@ describe("ContextTracePanel selected skills", () => {
     expect(html).toContain("会话摘要 180 Token")
     expect(html).toContain("动态片段 420 Token")
     expect(html).toContain("上下文压缩预计减少 1,400 Token(44%)")
-    expect(html).toContain("已发送稳定前缀,是否命中以供应商返回为准")
+    expect(html).toContain("已发送本地稳定核心,是否命中以供应商返回为准")
+    expect(html).toContain("供应商前缀:不可判断")
     expect(html).toContain("实际用量不可用")
     expect(html).not.toContain("供应商已确认命中")
   })
@@ -88,6 +89,54 @@ describe("ContextTracePanel selected skills", () => {
     expect(html).toContain("供应商新写入缓存 256 Token")
   })
 
+  it("labels Codex thread totals and does not show the outer round as a request count", () => {
+    const trace: ContextTrace = {
+      id: "trace-codex-thread-total",
+      startedAt: 1,
+      status: "done",
+      toolCalls: [],
+      contextInfo: {
+        intent: "write_chapter",
+        confidence: 1,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+        contextHub: {
+          cacheHits: 1, reloaded: 0, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
+          stableTokens: 100,
+          summaryTokens: 0,
+          dynamicTokens: 20,
+          candidateTokens: 120,
+          estimatedSavedTokens: 0,
+          estimatedSavedPercent: 0,
+          expanded: false,
+          providerCacheEnabled: true,
+          providerUsageReported: true,
+          providerInputTokens: 125_732,
+          providerCachedTokens: 120_576,
+          requestDiagnostics: {
+            requestCount: 0,
+            requestCountAvailable: false,
+            usageScope: "provider_thread",
+            providerUsageAvailable: true,
+            inputTokens: 3_676_375,
+            outputTokens: 7_926,
+            cacheReadTokens: 3_533_312,
+            cacheWriteTokens: 0,
+          },
+        },
+      },
+    }
+
+    const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
+
+    expect(html).toContain("Codex 线程累计实际用量:内部请求数不可判断")
+    expect(html).toContain("输入 3,676,375")
+    expect(html).not.toContain("请求 1")
+  })
+
   it("uses the shared cache viewer when a persisted snapshot reference exists", () => {
     const trace: ContextTrace = {
       id: "trace-snapshot",
@@ -129,6 +178,78 @@ describe("ContextTracePanel selected skills", () => {
     expect(html).toContain("本轮数据源:命中 2,重载 1,无数据 0,fallback 0,失败 0")
   })
 
+  it("renders sanitized per-request prefix, timing and cache diagnostics", () => {
+    const trace: ContextTrace = {
+      id: "trace-request-cache",
+      startedAt: 1,
+      status: "done",
+      toolCalls: [],
+      contextInfo: {
+        intent: "write_chapter",
+        confidence: 1,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+        contextHub: {
+          cacheHits: 1, reloaded: 0, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
+          stableTokens: 100,
+          summaryTokens: 20,
+          dynamicTokens: 30,
+          candidateTokens: 200,
+          estimatedSavedTokens: 50,
+          estimatedSavedPercent: 25,
+          expanded: false,
+          providerCacheEnabled: true,
+          requestDiagnostics: {
+            requestCount: 2,
+            providerUsageAvailable: true,
+            requests: [
+              {
+                provider: "openai",
+                model: "gpt-test",
+                apiMode: "chat_completions",
+                prefixFingerprint: "abcdef0123456789",
+                startedAt: 1_000,
+                finishedAt: 1_400,
+                durationMs: 400,
+                firstResponseMs: 120,
+                cacheReadTokens: 0,
+                cacheWriteTokens: 500,
+                status: "success",
+              },
+              {
+                provider: "openai",
+                model: "gpt-test",
+                apiMode: "chat_completions",
+                prefixFingerprint: "abcdef0123456789",
+                startedAt: 2_000,
+                finishedAt: 2_300,
+                durationMs: 300,
+                firstResponseMs: 80,
+                startGapMs: 1_000,
+                idleGapMs: 600,
+                cacheReadTokens: 500,
+                cacheWriteTokens: 0,
+                status: "success",
+              },
+            ],
+            omittedRequestCount: 4,
+          },
+        },
+      },
+    }
+
+    const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
+
+    expect(html).toContain("供应商前缀:未变化")
+    expect(html).toContain("请求缓存与间隔(2,另省略 4)")
+    expect(html).toContain("开始间隔")
+    expect(html).toContain("TTFT")
+    expect(html).toContain("abcdef0123")
+  })
+
   it("renders web search trace entries in the overview", () => {
     const trace: ContextTrace = {
       id: "trace-web",

+ 95 - 3
src/components/common/context-hub-stats-summary.tsx

@@ -2,6 +2,7 @@ import type {
   ContextHubStats,
   StablePrefixStatus,
 } from "@/lib/context-hub/types"
+import type { LlmRequestCacheTrace } from "@/lib/llm-request-trace"
 
 const STABLE_PREFIX_LABELS: Record<StablePrefixStatus, string> = {
   unchanged: "未变化",
@@ -23,6 +24,35 @@ function formatTokens(tokens: number): string {
   return `${tokens.toLocaleString()} Token`
 }
 
+function formatDuration(milliseconds: number | undefined): string {
+  if (milliseconds === undefined) return "—"
+  if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms`
+  return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 2 : 1)} s`
+}
+
+function getProviderPrefixStatus(requests: LlmRequestCacheTrace[]): "未变化" | "已变化" | "不可判断" {
+  const fingerprints = requests
+    .map((request) => request.prefixFingerprint)
+    .filter((value): value is string => Boolean(value))
+  if (fingerprints.length < 2) return "不可判断"
+  return new Set(fingerprints).size === 1 ? "未变化" : "已变化"
+}
+
+function requestPrefixStatus(
+  request: LlmRequestCacheTrace,
+  previous: LlmRequestCacheTrace | undefined,
+): "未变化" | "已变化" | "不可判断" {
+  if (!request.prefixFingerprint || !previous?.prefixFingerprint) return "不可判断"
+  return request.prefixFingerprint === previous.prefixFingerprint ? "未变化" : "已变化"
+}
+
+const REQUEST_STATUS_LABELS: Record<LlmRequestCacheTrace["status"], string> = {
+  success: "成功",
+  error: "供应商错误",
+  cancelled: "已取消",
+  network_error: "网络错误",
+}
+
 export function ProviderCacheUsage({ stats }: { stats: ContextHubStats }) {
   const cachedTokens = stats.providerCachedTokens
   const inputTokens = stats.providerInputTokens
@@ -44,7 +74,7 @@ export function ProviderCacheUsage({ stats }: { stats: ContextHubStats }) {
       ) : stats.providerUsageReported ? (
         <div>供应商已返回 Token 用量,但未提供缓存命中明细</div>
       ) : stats.providerCacheEnabled ? (
-        <div>已发送稳定前缀,是否命中以供应商返回为准</div>
+        <div>已发送本地稳定核心,是否命中以供应商返回为准</div>
       ) : null}
       {(stats.providerCacheWriteTokens ?? 0) > 0 && (
         <div>供应商新写入缓存 {stats.providerCacheWriteTokens?.toLocaleString()} Token</div>
@@ -62,6 +92,9 @@ export function ContextHubStatsSummary({
 }: ContextHubStatsSummaryProps) {
   const failures = stats.readFailed + stats.writeFailed
   const diagnostics = stats.requestDiagnostics
+  const usageScopeLabel = diagnostics?.usageScope === "provider_thread"
+    ? "Codex 线程累计实际用量"
+    : "工作流累计实际用量"
 
   return (
     <div className={className}>
@@ -70,7 +103,7 @@ export function ContextHubStatsSummary({
       </div>
       {stats.stablePrefixStatus ? (
         <div className="mt-0.5 text-[11px] text-muted-foreground">
-          稳定前缀:{STABLE_PREFIX_LABELS[stats.stablePrefixStatus]}
+          本地稳定核心:{STABLE_PREFIX_LABELS[stats.stablePrefixStatus]}
         </div>
       ) : null}
       <div className="mt-0.5 text-[11px] text-muted-foreground">
@@ -97,7 +130,9 @@ export function ContextHubStatsSummary({
         {diagnostics ? (
           diagnostics.providerUsageAvailable ? (
             <div>
-              工作流累计实际用量:请求 {diagnostics.requestCount},
+              {usageScopeLabel}:{diagnostics.requestCountAvailable === false
+                ? "内部请求数不可判断"
+                : `请求 ${diagnostics.requestCount}`},
               输入 {(diagnostics.inputTokens ?? 0).toLocaleString()},
               输出 {(diagnostics.outputTokens ?? 0).toLocaleString()},
               缓存读 {(diagnostics.cacheReadTokens ?? 0).toLocaleString()},
@@ -116,6 +151,63 @@ export function ContextHubStatsSummary({
         ) : (
           <div>实际用量不可用</div>
         )}
+        <div>
+          供应商前缀:{getProviderPrefixStatus(diagnostics?.requests ?? [])}
+        </div>
+        {(diagnostics?.requests?.length ?? 0) > 0 ? (
+          <details className="mt-1">
+            <summary className="cursor-pointer select-none font-medium">
+              请求缓存与间隔({diagnostics?.requests?.length ?? 0}
+              {(diagnostics?.omittedRequestCount ?? 0) > 0
+                ? `,另省略 ${diagnostics?.omittedRequestCount}`
+                : ""})
+            </summary>
+            <div className="mt-1 overflow-x-auto">
+              <table className="w-full min-w-[720px] border-collapse text-left text-[10px]">
+                <thead>
+                  <tr className="border-b border-border/60">
+                    <th className="py-1 pr-2 font-medium">请求</th>
+                    <th className="py-1 pr-2 font-medium">供应商前缀</th>
+                    <th className="py-1 pr-2 font-medium">开始间隔</th>
+                    <th className="py-1 pr-2 font-medium">空闲间隔</th>
+                    <th className="py-1 pr-2 font-medium">耗时</th>
+                    <th className="py-1 pr-2 font-medium">TTFT</th>
+                    <th className="py-1 pr-2 font-medium">输入/输出</th>
+                    <th className="py-1 pr-2 font-medium">缓存读/写</th>
+                    <th className="py-1 font-medium">状态</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  {diagnostics?.requests?.map((request, index, requests) => (
+                    <tr
+                      key={`${request.startedAt}:${index}`}
+                      className="border-b border-border/30 last:border-b-0"
+                    >
+                      <td className="whitespace-nowrap py-1 pr-2">
+                        #{index + 1} {request.provider}/{request.model}
+                      </td>
+                      <td className="whitespace-nowrap py-1 pr-2">
+                        {requestPrefixStatus(request, requests[index - 1])}
+                        {request.prefixFingerprint ? ` · ${request.prefixFingerprint.slice(0, 10)}` : ""}
+                      </td>
+                      <td className="whitespace-nowrap py-1 pr-2">{formatDuration(request.startGapMs)}</td>
+                      <td className="whitespace-nowrap py-1 pr-2">{formatDuration(request.idleGapMs)}</td>
+                      <td className="whitespace-nowrap py-1 pr-2">{formatDuration(request.durationMs)}</td>
+                      <td className="whitespace-nowrap py-1 pr-2">{formatDuration(request.firstResponseMs)}</td>
+                      <td className="whitespace-nowrap py-1 pr-2">
+                        {request.inputTokens?.toLocaleString() ?? "—"}/{request.outputTokens?.toLocaleString() ?? "—"}
+                      </td>
+                      <td className="whitespace-nowrap py-1 pr-2">
+                        {request.cacheReadTokens?.toLocaleString() ?? "—"}/{request.cacheWriteTokens?.toLocaleString() ?? "—"}
+                      </td>
+                      <td className="whitespace-nowrap py-1">{REQUEST_STATUS_LABELS[request.status]}</td>
+                    </tr>
+                  ))}
+                </tbody>
+              </table>
+            </div>
+          </details>
+        ) : null}
       </div>
       {warnings.length > 0 ? (
         <div className="mt-1 space-y-0.5 text-[11px] text-amber-700 dark:text-amber-300">

+ 3 - 8
src/components/settings/llm-presets.ts

@@ -1,5 +1,6 @@
 import type { AzureModelFamily } from "@/stores/wiki-store"
 import { MIN_USER_LLM_CONTEXT_SIZE } from "@/lib/llm-context-size"
+import { CODEX_CLI_SUGGESTED_MODELS, DEFAULT_CODEX_CLI_MODEL } from "@/lib/codex-cli-model"
 
 /**
  * Curated LLM provider presets.
@@ -120,14 +121,8 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
     label: "Codex CLI (local)",
     hint: "Uses the local `codex` binary — no API key needed",
     provider: "codex-cli",
-    defaultModel: "gpt-5.4-mini",
-    suggestedModels: [
-      "gpt-5.4-mini",
-      "gpt-5.4",
-      "gpt-5.3-codex",
-      "gpt-5.3-codex-spark",
-      "gpt-5.2",
-    ],
+    defaultModel: DEFAULT_CODEX_CLI_MODEL,
+    suggestedModels: [...CODEX_CLI_SUGGESTED_MODELS],
     suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {

+ 12 - 1
src/components/settings/llm-wiki-model-settings.spec.ts

@@ -130,9 +130,20 @@ describe("QMAI model settings", () => {
     )
     expect(codex.provider).toBe("codex-cli")
     expect(codex.localCliIsolation).toBe(true)
-    expect(codex.model).toBe("")
+    expect(codex.model).toBe("gpt-5.6-terra")
     expect(codex.codexCliTimeoutMinutes).toBe(45)
 
+    const codexPreset = preset("codex-cli")
+    expect(codexPreset.defaultModel).toBe("gpt-5.6-terra")
+    expect(codexPreset.suggestedModels).toEqual([
+      "gpt-5.6-terra",
+      "gpt-5.6-sol",
+      "gpt-5.6-luna",
+    ])
+    const codexDefault = resolveConfig(preset("codex-cli"), {}, fallback)
+    expect(codexDefault.model).toBe("gpt-5.6-terra")
+    expect(codexDefault.codexCliTimeoutMinutes).toBe(40)
+
     const cursor = resolveConfig(
       preset("cursor-cli"),
       { baseUrl: "http://127.0.0.1:8765/v1" },

+ 6 - 8
src/components/settings/preset-resolver.ts

@@ -6,6 +6,7 @@ import {
   normalizeUserLlmContextSize,
   normalizeUserLlmMaxOutputTokens,
 } from "@/lib/llm-context-size"
+import { resolveCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
 
 /**
  * Build a full LlmConfig from a preset template + the user's saved
@@ -29,10 +30,7 @@ export function resolveConfig(
   const reasoning = ov.reasoning ?? { mode: "auto" as const }
   const localCliIsolation = ov.localCliIsolation === true
   const functionCallingEnabled = ov.functionCallingEnabled !== false
-  const codexCliTimeoutMinutes =
-    typeof ov.codexCliTimeoutMinutes === "number" && Number.isFinite(ov.codexCliTimeoutMinutes)
-      ? Math.max(1, Math.min(240, Math.floor(ov.codexCliTimeoutMinutes)))
-      : undefined
+  const codexCliTimeoutMinutes = resolveCodexCliTimeoutMinutes(ov.codexCliTimeoutMinutes)
 
   let config: LlmConfig
 
@@ -80,13 +78,13 @@ export function resolveConfig(
     }
   } else if (preset.provider === "claude-code" || preset.provider === "codex-cli") {
     // Subprocess transport — no apiKey, no endpoint URL. Model id is
-    // passed straight to the local CLI's model flag when the user
-    // explicitly sets one. Leaving it empty lets the local CLI use the
-    // machine's own configured default model.
+    // passed straight to the local CLI. Claude can inherit its machine
+    // default; Codex is pinned to QMAI's curated default unless the user
+    // explicitly selects another app-server model.
     config = {
       provider: preset.provider,
       apiKey: "",
-      model: ov.model?.trim() || "",
+      model: ov.model?.trim() || (preset.provider === "codex-cli" ? preset.defaultModel : "") || "",
       ollamaUrl: fallback.ollamaUrl,
       customEndpoint: fallback.customEndpoint,
       maxContextSize: rawMaxContextSize,

+ 6 - 0
src/components/settings/sections/llm-provider-section.spec.ts

@@ -31,4 +31,10 @@ describe("LLM provider model controls", () => {
     expect(source).toContain("functionCallingEnabled")
     expect(source).toContain('settings.sections.llm.functionCalling.label')
   })
+
+  it("keeps Codex CLI isolated and requires app-server dynamic tools", () => {
+    expect(source).toContain('const showLocalCliIsolation = preset.provider === "claude-code"')
+    expect(source).toContain("r.installed && r.appServerReady === true && r.dynamicToolsReady === true")
+    expect(source).toContain('invoke<DetectResult>("codex_cli_detect")')
+  })
 })

+ 8 - 4
src/components/settings/sections/llm-provider-section.tsx

@@ -25,6 +25,7 @@ import {
   normalizeUserLlmMaxOutputTokens,
 } from "@/lib/llm-context-size"
 import { thinkingMinMaxTokens } from "@/lib/llm-providers"
+import { resolveCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
 
 const MODEL_PARAM_DOCS_URL = "https://global.modelmesh.info/model"
 
@@ -215,8 +216,8 @@ function PresetRow({
   )
   const reasoning = ov.reasoning ?? { mode: "auto" as const }
   const localCliIsolation = ov.localCliIsolation === true
-  const codexCliTimeoutMinutes = Math.max(1, Math.min(240, ov.codexCliTimeoutMinutes ?? 10))
-  const isLocalCliProvider = preset.provider === "claude-code" || preset.provider === "codex-cli"
+  const codexCliTimeoutMinutes = resolveCodexCliTimeoutMinutes(ov.codexCliTimeoutMinutes)
+  const showLocalCliIsolation = preset.provider === "claude-code"
   const isCursorCliProvider = preset.provider === "cursor-cli"
   const [testState, setTestState] = useState<ProviderTestState>({ kind: "idle" })
   const [modelOptions, setModelOptions] = useState<string[]>([])
@@ -491,7 +492,7 @@ function PresetRow({
             </div>
           )}
 
-          {isLocalCliProvider && (
+          {showLocalCliIsolation && (
             <div className="space-y-2 rounded-md border p-3">
               <div className="flex items-start justify-between gap-3">
                 <div>
@@ -1087,6 +1088,9 @@ interface DetectResult {
   installed: boolean
   version: string | null
   path: string | null
+  appServerReady?: boolean
+  dynamicToolsReady?: boolean
+  models?: string[]
   error: string | null
 }
 
@@ -1216,7 +1220,7 @@ function CodexCliStatusPill() {
     try {
       const r = await invoke<DetectResult>("codex_cli_detect")
       setResult(r)
-      setState(r.installed ? "ok" : "err")
+      setState(r.installed && r.appServerReady === true && r.dynamicToolsReady === true ? "ok" : "err")
     } catch (e) {
       setResult({
         installed: false,

+ 40 - 6
src/components/sources/outline-chat-panel.tsx

@@ -203,6 +203,7 @@ import {
 } from "@/lib/context-usage";
 import { selectContextHistoryMessages } from "@/lib/context-hub/session-summary";
 import { addLlmUsage, type LlmUsage } from "@/lib/llm-usage";
+import { LlmRequestTraceCollector } from "@/lib/llm-request-trace";
 import { enqueueUserMemoryLearning } from "@/lib/user-memory/learning-service";
 import { recordLatestUserMemoryFeedback } from "@/lib/user-memory/feedback-service";
 import {
@@ -1978,6 +1979,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       let lastProviderUsage: LlmUsage | undefined;
       let memoryDecision: UserMemoryDecision | null | undefined;
       let llmRequestCount = 0;
+      let providerRequestCountAvailable = true;
+      const requestTraceCollector = new LlmRequestTraceCollector();
       let accumulatedReasoningContent = "";
       const missingSkillNames = new Set<string>();
       // 已生成的用户可见文本。streamingContents 只承载状态提示不存内容,
@@ -2251,6 +2254,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 }));
               },
               onDone: () => {},
+              onRequestTrace: requestTraceCollector.record,
               onError: (error) => {
                 agentErrorBox.current = error;
               },
@@ -2259,7 +2263,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           );
           providerUsage = addLlmUsage(providerUsage, record.usage);
           lastProviderUsage = record.lastRequestUsage ?? record.usage ?? lastProviderUsage;
-          llmRequestCount += Math.max(1, record.roundsUsed || 1);
+          if (record.providerRequestCountAvailable === false) {
+            providerRequestCountAvailable = false;
+          } else {
+            llmRequestCount += Math.max(1, record.roundsUsed || 1);
+          }
           if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
             memoryDecision = record.userMemoryDecision;
           }
@@ -2687,7 +2695,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           }
           return { started: true, sent: false };
         }
-        if (contextHubResult && providerUsage) {
+        if (contextHubResult && (providerUsage || requestTraceCollector.snapshot().requests.length > 0)) {
           try {
             const contextHubSnapshot = await persistContextHubProviderUsage(
               getContextHub(normalizePath(project.path)),
@@ -2699,6 +2707,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 requestDiagnostics: buildLlmRequestDiagnostics(
                   providerUsage,
                   Math.max(1, llmRequestCount || 1),
+                  {
+                    ...requestTraceCollector.snapshot(),
+                    requestCountAvailable: providerRequestCountAvailable,
+                  },
                 ),
               },
             );
@@ -3209,6 +3221,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         let lastProviderUsage: LlmUsage | undefined;
         let memoryDecision: UserMemoryDecision | null | undefined;
         let llmRequestCount = 0;
+        let providerRequestCountAvailable = true;
+        const requestTraceCollector = new LlmRequestTraceCollector();
         try {
           const contextHub = getContextHub(normalizePath(project.path));
           contextHubResult = await contextHub.prepare({
@@ -3342,11 +3356,16 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               onToolError: () => {},
               onToolEvent: () => {},
               onDone: () => {},
+              onRequestTrace: requestTraceCollector.record,
               onError: (error) => { agentError = error; },
             }, controller.signal);
             providerUsage = addLlmUsage(providerUsage, record.usage);
             lastProviderUsage = record.lastRequestUsage ?? record.usage ?? lastProviderUsage;
-            llmRequestCount += Math.max(1, record.roundsUsed || 1);
+            if (record.providerRequestCountAvailable === false) {
+              providerRequestCountAvailable = false;
+            } else {
+              llmRequestCount += Math.max(1, record.roundsUsed || 1);
+            }
             if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
               memoryDecision = record.userMemoryDecision;
             }
@@ -3381,11 +3400,16 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               onToolError: () => {},
               onToolEvent: () => {},
               onDone: () => {},
+              onRequestTrace: requestTraceCollector.record,
               onError: (error) => { mergeError = error; },
             }, controller.signal);
             providerUsage = addLlmUsage(providerUsage, record.usage);
             lastProviderUsage = record.lastRequestUsage ?? record.usage ?? lastProviderUsage;
-            llmRequestCount += Math.max(1, record.roundsUsed || 1);
+            if (record.providerRequestCountAvailable === false) {
+              providerRequestCountAvailable = false;
+            } else {
+              llmRequestCount += Math.max(1, record.roundsUsed || 1);
+            }
             if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
               memoryDecision = record.userMemoryDecision;
             }
@@ -3407,7 +3431,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
         if (!isCurrentRun()) return;
 
-        if (contextHubResult && providerUsage) {
+        if (contextHubResult && (providerUsage || requestTraceCollector.snapshot().requests.length > 0)) {
           try {
             const contextHubSnapshot = await persistContextHubProviderUsage(
               getContextHub(normalizePath(project.path)),
@@ -3419,6 +3443,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 requestDiagnostics: buildLlmRequestDiagnostics(
                   providerUsage,
                   Math.max(1, llmRequestCount || 1),
+                  {
+                    ...requestTraceCollector.snapshot(),
+                    requestCountAvailable: providerRequestCountAvailable,
+                  },
                 ),
               },
             );
@@ -3838,7 +3866,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         );
         if (agentError) throw agentError;
         if (!isCurrentRun()) return;
-        if (contextHubResult && record.usage) {
+        if (contextHubResult && (record.usage || record.requestTraces?.length)) {
           try {
             const updatedSnapshot = await persistContextHubProviderUsage(
               getContextHub(normalizePath(project.path)),
@@ -3850,6 +3878,12 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 requestDiagnostics: buildLlmRequestDiagnostics(
                   record.usage,
                   Math.max(1, record.roundsUsed || 1),
+                  {
+                    requests: record.requestTraces,
+                    omittedRequestCount: record.omittedRequestTraceCount,
+                    requestCountAvailable: record.providerRequestCountAvailable,
+                    usageScope: record.usageAggregationScope,
+                  },
                 ),
               },
             );

+ 2 - 2
src/i18n/en.json

@@ -875,12 +875,12 @@
         "azureModelFamilyGpt5": "GPT-5 / o-series",
         "azureModelFamilyHint": "Azure deployment names are arbitrary. Pick GPT-5 / o-series when that deployment needs strict completion parameters.",
         "localCliIsolation": "Isolate local CLI config",
-        "localCliIsolationHint": "When enabled, Claude/Codex CLI will try to ignore user rules and local config for more predictable tests and generation.",
+        "localCliIsolationHint": "When enabled, Claude CLI will try to ignore user rules and local config for more predictable tests and generation.",
         "localCliIsolationOn": "Isolation enabled: local rules and user config will be ignored where supported.",
         "localCliIsolationOff": "Isolation disabled: current CLI login and local config are used.",
         "codexCliTimeout": "Codex CLI timeout",
         "codexCliTimeoutUnit": "minutes",
-        "codexCliTimeoutHint": "Overall subprocess timeout for longer generations, from 1 to 240 minutes.",
+        "codexCliTimeoutHint": "Overall app-server timeout for longer generations. Defaults to 40 minutes; configurable from 1 to 240 minutes.",
         "cursorBridgeApiKey": "Proxy API key (optional)",
         "cursorBridgeApiKeyPlaceholder": "Only if the proxy was started with CURSOR_BRIDGE_API_KEY",
         "cursorBridgeApiKeyHint": "Leave blank by default. Fill in the same value if you started the proxy with CURSOR_BRIDGE_API_KEY.",

+ 2 - 2
src/i18n/zh.json

@@ -582,12 +582,12 @@
         "azureModelFamilyGpt5": "GPT-5 / o 系列",
         "azureModelFamilyHint": "Azure 部署名可自定义,必要时手动声明 GPT-5 或 o 系列行为。",
         "localCliIsolation": "隔离本地 CLI 配置",
-        "localCliIsolationHint": "为 Claude/Codex CLI 使用隔离配置目录,避免读写用户全局 CLI 配置。",
+        "localCliIsolationHint": "为 Claude CLI 使用隔离配置目录,避免读写用户全局 CLI 配置。",
         "localCliIsolationOn": "已隔离,不会使用全局 CLI 配置。",
         "localCliIsolationOff": "未隔离,将继承本机 CLI 登录状态。",
         "codexCliTimeout": "Codex CLI 超时",
         "codexCliTimeoutUnit": "分钟",
-        "codexCliTimeoutHint": "长文本生成的子进程总超时时间,可设置 1-240 分钟。",
+        "codexCliTimeoutHint": "长文本生成的 app-server 总超时时间,默认 40 分钟,可设置 1-240 分钟。",
         "cursorBridgeApiKey": "Proxy API Key(可选)",
         "cursorBridgeApiKeyPlaceholder": "仅当 proxy 设置了 CURSOR_BRIDGE_API_KEY 时填写",
         "cursorBridgeApiKeyHint": "默认无需填写。若手动启动 proxy 时设置了 CURSOR_BRIDGE_API_KEY,在此填入相同值。",

+ 16 - 0
src/lib/agent/activity-trace.spec.ts

@@ -269,10 +269,26 @@ describe("activity trace", () => {
   })
 
   it("resolves titles for post-draft strict stages", () => {
+    expect(resolveAgentStageTitle("final_polish")).toBe("去AI味")
     expect(resolveAgentStageTitle("execution_report")).toBe("执行报告")
     expect(resolveAgentStageTitle("execution_recheck")).toBe("执行复检")
     expect(resolveAgentStageTitle("plan_compliance")).toBe("计划履约")
     expect(resolveAgentStageTitle("plan_deviation_repair")).toBe("计划偏离返修")
     expect(resolveAgentStageTitle("plan_deviation_recheck")).toBe("计划偏离复检")
   })
+
+  it("places 去AI味 between 校验与修正 and 最终输出", () => {
+    const stages: AgentStageTrace[] = [
+      { id: "final_output", title: "最终输出", status: "done", summary: "完成", events: [], startedAt: 400 },
+      { id: "final_polish", title: "去AI味", status: "running", summary: "去AI味中", events: [], startedAt: 300 },
+      { id: "validate_revision", title: "校验与修正", status: "done", summary: "校验", events: [], startedAt: 200 },
+    ]
+
+    expect(prepareAgentStagesForDisplay(stages).map((stage) => stage.id)).toEqual([
+      "validate_revision",
+      "final_polish",
+      "final_output",
+    ])
+    expect(getDefaultOpenAgentStageId(stages)).toBe("final_polish")
+  })
 })

+ 3 - 0
src/lib/agent/activity-trace.ts

@@ -15,6 +15,7 @@ export const DETAILED_CHAPTER_STAGE_IDS = new Set([
   "plot_analysis",
   "generate_draft",
   "validate_revision",
+  "final_polish",
   "execution_report",
   "execution_recheck",
   "plan_compliance",
@@ -30,6 +31,7 @@ export const AGENT_STAGE_DISPLAY_ORDER = [
   "plot_analysis",
   "generate_draft",
   "validate_revision",
+  "final_polish",
   "execution_report",
   "execution_recheck",
   "plan_compliance",
@@ -296,6 +298,7 @@ export function resolveAgentStageTitle(stageId: string, fallbackTitle?: string):
     chapter_workflow: "多任务写作循环",
     generate_draft: "生成章节草稿",
     validate_revision: "校验与修正",
+    final_polish: "去AI味",
     execution_report: "执行报告",
     execution_recheck: "执行复检",
     plan_compliance: "计划履约",

+ 305 - 0
src/lib/agent/codex-app-server-runner.spec.ts

@@ -0,0 +1,305 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { ToolRegistry } from "./registry"
+import type { AgentConfig, AgentMessage, AgentRunCallbacks, Tool } from "./types"
+
+const appServerMock = vi.hoisted(() => ({
+  handler: null as null | {
+    onEnvelope?: (envelope: Record<string, unknown>) => void
+    onDynamicToolCall?: (request: Record<string, unknown>) => Promise<unknown>
+  },
+  turnNumber: 0,
+  onTurn: null as null | ((turnNumber: number) => void | Promise<void>),
+  call: vi.fn(),
+  interrupt: vi.fn(async () => undefined),
+}))
+
+vi.mock("@/lib/codex-app-server-client", () => ({
+  getCodexAppServerClient: () => ({
+    isolatedCwd: "/tmp/qmai-codex/workspace",
+    ensureStarted: vi.fn(async () => undefined),
+    call: appServerMock.call,
+    interrupt: appServerMock.interrupt,
+    registerThread: (_threadId: string, handler: typeof appServerMock.handler) => {
+      appServerMock.handler = handler
+      return () => {
+        appServerMock.handler = null
+      }
+    },
+  }),
+}))
+
+import { CodexAppServerRunner } from "./codex-app-server-runner"
+
+const llmConfig: LlmConfig = {
+  provider: "codex-cli",
+  apiKey: "",
+  model: "gpt-test",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 8192,
+  codexCliTimeoutMinutes: 10,
+}
+
+const messages: AgentMessage[] = [
+  { role: "system", content: "QMAI system" },
+  { role: "user", content: "读取大纲后回答" },
+]
+
+function callbacks(): AgentRunCallbacks {
+  return {
+    onText: vi.fn(),
+    onReasoningToken: vi.fn(),
+    onToolCall: vi.fn(),
+    onToolResult: vi.fn(),
+    onToolError: vi.fn(),
+    onToolEvent: vi.fn(),
+    onUsage: vi.fn(),
+    onDone: vi.fn(),
+    onError: vi.fn(),
+  }
+}
+
+function config(tools: Tool[], overrides: Partial<AgentConfig> = {}): AgentConfig {
+  return {
+    maxRounds: 3,
+    tools,
+    systemPrompt: "QMAI system",
+    llmConfig,
+    ...overrides,
+  }
+}
+
+function envelope(method: string, params: Record<string, unknown>): void {
+  appServerMock.handler?.onEnvelope?.({ method, params })
+}
+
+describe("CodexAppServerRunner", () => {
+  beforeEach(() => {
+    appServerMock.handler = null
+    appServerMock.turnNumber = 0
+    appServerMock.onTurn = null
+    appServerMock.call.mockReset()
+    appServerMock.interrupt.mockClear()
+    appServerMock.call.mockImplementation(async (method: string, params: Record<string, unknown>) => {
+      if (method === "thread/start") {
+        return { thread: { id: "thread-1" }, instructionSources: [] }
+      }
+      if (method === "turn/start") {
+        appServerMock.turnNumber += 1
+        const current = appServerMock.turnNumber
+        queueMicrotask(() => void appServerMock.onTurn?.(current))
+        return { turn: { id: `turn-${current}` } }
+      }
+      throw new Error(`unexpected method: ${method} ${JSON.stringify(params)}`)
+    })
+  })
+
+  it("publishes QMAI dynamic tools, executes a read tool, and returns the final record", async () => {
+    const tool: Tool = {
+      name: "read_outline",
+      description: "读取大纲",
+      category: "read",
+      parameters: { path: { type: "string", description: "路径", required: true } },
+      execute: vi.fn(async () => "大纲内容"),
+    }
+    const registry = new ToolRegistry()
+    registry.register(tool)
+    appServerMock.onTurn = async () => {
+      const response = await appServerMock.handler?.onDynamicToolCall?.({
+        threadId: "thread-1",
+        turnId: "turn-1",
+        callId: "call-1",
+        namespace: null,
+        tool: "read_outline",
+        arguments: { path: "QM/outlines/总纲.md" },
+      }) as { success: boolean; contentItems: Array<{ text: string }> }
+      expect(response.success).toBe(true)
+      expect(response.contentItems[0].text).toContain("大纲内容")
+      envelope("item/reasoning/summaryTextDelta", { threadId: "thread-1", delta: "思考" })
+      envelope("item/agentMessage/delta", { threadId: "thread-1", delta: "最终回答" })
+      envelope("thread/tokenUsage/updated", {
+        threadId: "thread-1",
+        tokenUsage: {
+          last: { inputTokens: 10, outputTokens: 2, totalTokens: 12 },
+          total: { inputTokens: 18, outputTokens: 4, totalTokens: 22 },
+        },
+      })
+      envelope("turn/completed", { threadId: "thread-1", turn: { status: "completed" } })
+    }
+    const cb = callbacks()
+
+    const record = await new CodexAppServerRunner().run(
+      config([tool]), registry, messages, cb,
+    )
+
+    const threadStart = appServerMock.call.mock.calls.find(([method]) => method === "thread/start")
+    expect(threadStart?.[1]).toEqual(expect.objectContaining({
+      approvalPolicy: "never",
+      sandbox: "read-only",
+      ephemeral: true,
+      dynamicTools: [expect.objectContaining({ name: "read_outline" })],
+    }))
+    expect(threadStart?.[1].baseInstructions).toContain("## 任务契约")
+    expect(tool.execute).toHaveBeenCalledWith(
+      { path: "QM/outlines/总纲.md" }, undefined, expect.any(Object),
+    )
+    expect(record.finalText).toBe("最终回答")
+    expect(record.toolCalls[0]).toEqual(expect.objectContaining({ name: "read_outline", status: "done" }))
+    expect(record.lastRequestUsage).toEqual(expect.objectContaining({ totalTokens: 12 }))
+    expect(record.usage).toEqual(expect.objectContaining({ totalTokens: 22 }))
+    expect(record.usageAggregationScope).toBe("provider_thread")
+    expect(record.providerRequestCountAvailable).toBe(false)
+    expect(cb.onText).toHaveBeenCalledWith("最终回答")
+    expect(cb.onDone).toHaveBeenCalledOnce()
+    expect(cb.onError).not.toHaveBeenCalled()
+  })
+
+  it("returns approval_required previews without executing confirmation tools", async () => {
+    const tool: Tool = {
+      name: "write_outline_node",
+      description: "修改大纲",
+      category: "write",
+      permission: "confirm",
+      parameters: { content: { type: "string", description: "内容", required: true } },
+      execute: vi.fn(async () => "不应执行"),
+      generatePreview: vi.fn(async () => "将写入:新内容"),
+    }
+    const registry = new ToolRegistry()
+    registry.register(tool)
+    appServerMock.onTurn = async () => {
+      const response = await appServerMock.handler?.onDynamicToolCall?.({
+        threadId: "thread-1",
+        turnId: "turn-1",
+        callId: "call-write",
+        namespace: null,
+        tool: "write_outline_node",
+        arguments: { content: "新内容" },
+      }) as { success: boolean; contentItems: Array<{ text: string }> }
+      expect(response.success).toBe(true)
+      expect(response.contentItems[0].text).toContain("尚未执行")
+      envelope("item/agentMessage/delta", { threadId: "thread-1", delta: "等待确认" })
+      envelope("turn/completed", { threadId: "thread-1", turn: { status: "completed" } })
+    }
+    const cb = callbacks()
+
+    const record = await new CodexAppServerRunner().run(config([tool]), registry, messages, cb)
+
+    expect(tool.generatePreview).toHaveBeenCalledOnce()
+    expect(tool.execute).not.toHaveBeenCalled()
+    expect(record.toolCalls[0]).toEqual(expect.objectContaining({
+      status: "approval_required",
+      preview: "将写入:新内容",
+    }))
+  })
+
+  it("continues in the same thread when requiredToolsOnce is missing", async () => {
+    const tool: Tool = {
+      name: "list_outlines",
+      description: "列出大纲",
+      category: "read",
+      parameters: {},
+      execute: vi.fn(async () => "总纲.md"),
+    }
+    const registry = new ToolRegistry()
+    registry.register(tool)
+    appServerMock.onTurn = async (turnNumber) => {
+      if (turnNumber === 1) {
+        envelope("item/agentMessage/delta", { threadId: "thread-1", delta: "未读取直接回答" })
+        envelope("turn/completed", { threadId: "thread-1", turn: { status: "completed" } })
+        return
+      }
+      await appServerMock.handler?.onDynamicToolCall?.({
+        threadId: "thread-1",
+        turnId: "turn-2",
+        callId: "call-list",
+        namespace: null,
+        tool: "list_outlines",
+        arguments: {},
+      })
+      envelope("item/agentMessage/delta", { threadId: "thread-1", delta: "读取后回答" })
+      envelope("turn/completed", { threadId: "thread-1", turn: { status: "completed" } })
+    }
+    const cb = callbacks()
+
+    const record = await new CodexAppServerRunner().run(
+      config([tool], { requiredToolsOnce: ["list_outlines"] }),
+      registry,
+      messages,
+      cb,
+    )
+
+    expect(appServerMock.turnNumber).toBe(2)
+    const turnStarts = appServerMock.call.mock.calls.filter(([method]) => method === "turn/start")
+    expect(turnStarts[1][1].input[0].text).toContain("list_outlines")
+    expect(record.finalText).toBe("读取后回答")
+    expect(cb.onText).not.toHaveBeenCalledWith("未读取直接回答")
+  })
+
+  it("hard-fails when Codex emits a native shell item", async () => {
+    appServerMock.onTurn = () => {
+      envelope("item/started", {
+        threadId: "thread-1",
+        item: { id: "native-1", type: "commandExecution", command: "pwd" },
+      })
+    }
+    const cb = callbacks()
+
+    await new CodexAppServerRunner().run(config([]), new ToolRegistry(), messages, cb)
+
+    expect(cb.onError).toHaveBeenCalledWith(expect.objectContaining({
+      message: expect.stringContaining("禁止 Codex 原生能力"),
+    }))
+    expect(appServerMock.interrupt).toHaveBeenCalledWith("thread-1", "turn-1")
+    expect(cb.onDone).not.toHaveBeenCalled()
+  })
+
+  it("rejects non-object dynamic tool arguments without executing the tool", async () => {
+    const tool: Tool = {
+      name: "read_outline",
+      description: "读取大纲",
+      category: "read",
+      parameters: {},
+      execute: vi.fn(async () => "不应执行"),
+    }
+    const registry = new ToolRegistry()
+    registry.register(tool)
+    appServerMock.onTurn = async () => {
+      const response = await appServerMock.handler?.onDynamicToolCall?.({
+        threadId: "thread-1",
+        turnId: "turn-1",
+        callId: "bad-args",
+        namespace: null,
+        tool: "read_outline",
+        arguments: "bad",
+      }) as { success: boolean }
+      expect(response.success).toBe(false)
+      envelope("item/agentMessage/delta", { threadId: "thread-1", delta: "参数失败" })
+      envelope("turn/completed", { threadId: "thread-1", turn: { status: "completed" } })
+    }
+
+    const record = await new CodexAppServerRunner().run(config([tool]), registry, messages, callbacks())
+
+    expect(tool.execute).not.toHaveBeenCalled()
+    expect(record.toolCalls[0].status).toBe("error")
+  })
+
+  it("maps AbortSignal cancellation to turn/interrupt", async () => {
+    const controller = new AbortController()
+    const cb = callbacks()
+    const run = new CodexAppServerRunner().run(
+      config([]),
+      new ToolRegistry(),
+      messages,
+      cb,
+      controller.signal,
+    )
+    await vi.waitFor(() => expect(appServerMock.turnNumber).toBe(1))
+    controller.abort()
+    await run
+
+    expect(appServerMock.interrupt).toHaveBeenCalledWith("thread-1", "turn-1")
+    expect(cb.onError).toHaveBeenCalledWith(expect.objectContaining({ message: "操作已取消" }))
+    expect(cb.onDone).not.toHaveBeenCalled()
+  })
+})

+ 417 - 0
src/lib/agent/codex-app-server-runner.ts

@@ -0,0 +1,417 @@
+import { getCodexAppServerClient, type CodexAppServerEnvelope } from "@/lib/codex-app-server-client"
+import {
+  buildCodexTurnInput,
+  codexNativeBoundaryError,
+  codexReasoningEffort,
+  restrictedCodexConfig,
+} from "@/lib/codex-cli-transport"
+import { trimChatMessagesToTokenBudget } from "@/lib/chat-request-budget"
+import { getEffectiveMaxContextSize } from "@/lib/llm-providers"
+import { resolveCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
+import type { LlmUsage } from "@/lib/llm-usage"
+import { applyGlobalUserMemoryToMessages } from "@/lib/user-memory/request-integration"
+import type { ToolRegistry } from "./registry"
+import {
+  RequiredToolsNotCalledError,
+  buildRequiredToolNudgeMessage,
+  missingRequiredToolsOnce,
+} from "./required-tools-gate"
+import { executeAgentTool } from "./tool-executor"
+import { ToolEvidenceLedger } from "./tool-evidence-ledger"
+import { DEFAULT_TOOL_RESULT_CONTEXT_LIMIT } from "./tool-result"
+import {
+  clearTaskBreakpoint,
+  createTaskBreakpoint,
+  saveTaskBreakpoint,
+  updateBreakpointStage,
+  type TaskBreakpoint,
+} from "./task-breakpoint"
+import type { AgentConfig, AgentMessage, AgentRunCallbacks, AgentRunRecord } from "./types"
+
+interface ThreadStartResponse {
+  thread: { id: string }
+  instructionSources?: string[]
+}
+
+interface TurnStartResponse {
+  turn: { id: string }
+}
+
+interface TurnCompletion {
+  status: string
+  error?: string
+}
+
+function messageContentText(content: AgentMessage["content"]): string {
+  if (typeof content === "string") return content
+  return content.filter((block) => block.type === "text").map((block) => block.text).join("")
+}
+
+function toDynamicTools(config: AgentConfig): Array<Record<string, unknown>> {
+  return config.tools.map((tool) => {
+    const properties: Record<string, unknown> = {}
+    const required: string[] = []
+    for (const [name, parameter] of Object.entries(tool.parameters)) {
+      properties[name] = {
+        type: parameter.type,
+        description: parameter.description,
+        ...(parameter.enum?.length ? { enum: parameter.enum } : {}),
+      }
+      if (parameter.required) required.push(name)
+    }
+    return {
+      type: "function",
+      name: tool.name,
+      description: tool.description,
+      inputSchema: {
+        type: "object",
+        properties,
+        required,
+        additionalProperties: false,
+      },
+    }
+  })
+}
+
+function toLlmUsage(value: Record<string, unknown> | undefined): LlmUsage | undefined {
+  if (!value) return undefined
+  return {
+    inputTokens: Number(value.inputTokens) || 0,
+    outputTokens: Number(value.outputTokens) || 0,
+    totalTokens: Number(value.totalTokens) || 0,
+    cachedInputTokens: Number(value.cachedInputTokens) || 0,
+    cacheWriteInputTokens: Number(value.cacheWriteInputTokens) || 0,
+  }
+}
+
+function usageFromEnvelope(envelope: CodexAppServerEnvelope): {
+  last?: LlmUsage
+  total?: LlmUsage
+} | undefined {
+  if (envelope.method !== "thread/tokenUsage/updated") return undefined
+  const tokenUsage = envelope.params?.tokenUsage as Record<string, unknown> | undefined
+  const last = tokenUsage?.last as Record<string, unknown> | undefined
+  return {
+    last: toLlmUsage(last),
+    total: toLlmUsage(tokenUsage?.total as Record<string, unknown> | undefined),
+  }
+}
+
+export class CodexAppServerRunner {
+  async run(
+    config: AgentConfig,
+    registry: ToolRegistry,
+    messages: AgentMessage[],
+    callbacks: AgentRunCallbacks,
+    signal?: AbortSignal,
+  ): Promise<AgentRunRecord> {
+    const record: AgentRunRecord = {
+      toolCalls: [],
+      roundsUsed: 0,
+      finalText: "",
+      usageAggregationScope: "provider_thread",
+      providerRequestCountAvailable: false,
+    }
+    const client = getCodexAppServerClient()
+    const evidenceLedger = new ToolEvidenceLedger(config.toolResultContextLimit ?? DEFAULT_TOOL_RESULT_CONTEXT_LIMIT)
+    let threadId = ""
+    let activeTurnId = ""
+    let turnText = ""
+    let turnUsage: LlmUsage | undefined
+    let cumulativeUsage: LlmUsage | undefined
+    let turnResolve: ((completion: TurnCompletion) => void) | null = null
+    let turnReject: ((error: Error) => void) | null = null
+    let unregister = () => {}
+    let terminalError: Error | null = null
+    const emittedItems = new Set<string>()
+    const agentMessagePhases = new Map<string, string | null>()
+    const projectPath = config.projectPath
+    const latestUserContent = [...messages].reverse().find((message) => message.role === "user")?.content
+    const taskGoalText = config.taskGoal || (latestUserContent ? messageContentText(latestUserContent) : "") || "未命名任务"
+    let taskBreakpoint: TaskBreakpoint | null = projectPath
+      ? createTaskBreakpoint({ taskGoal: taskGoalText, currentStage: "agent_round_1" })
+      : null
+    const persistTaskBreakpoint = async () => {
+      if (!projectPath || !taskBreakpoint) return
+      try {
+        await saveTaskBreakpoint(projectPath, taskBreakpoint)
+      } catch {
+        // 断点保存失败不应中断当前 AI 会话。
+      }
+    }
+    const clearPersistedBreakpoint = async () => {
+      if (!projectPath) return
+      try {
+        await clearTaskBreakpoint(projectPath)
+      } catch {
+        // 清理失败不改变本轮模型结果。
+      }
+    }
+
+    if (taskBreakpoint) await persistTaskBreakpoint()
+
+    const taskContract: AgentMessage = {
+      role: "system",
+      content: `## 任务契约\n初始任务目标:${taskGoalText.slice(0, 1800)}\n执行过程中不得因历史裁剪丢失该目标;当前用户新要求优先。`,
+    }
+    const messagesWithContract = [...messages]
+    const contractIndex = messagesWithContract.findIndex((message) => message.role !== "system")
+    messagesWithContract.splice(contractIndex < 0 ? messagesWithContract.length : contractIndex, 0, taskContract)
+
+    const { messages: memoryMessages, decision } = applyGlobalUserMemoryToMessages(
+      messagesWithContract,
+      config.requestOverrides,
+    )
+    record.userMemoryDecision = decision
+    callbacks.onUserMemoryDecision?.(decision)
+    const budget = Math.max(1, Math.floor(getEffectiveMaxContextSize(config.llmConfig) * 0.75))
+    let preparedMessages: AgentMessage[]
+    try {
+      preparedMessages = trimChatMessagesToTokenBudget(
+        memoryMessages,
+        budget,
+      ) as AgentMessage[]
+    } catch {
+      const error = new Error("模型上下文不足:当前对话即使压缩后仍放不下系统提示与最新请求。")
+      callbacks.onError(error)
+      return record
+    }
+
+    const completeActiveTurn = (completion: TurnCompletion) => {
+      turnResolve?.(completion)
+      turnResolve = null
+      turnReject = null
+    }
+    const failActiveTurn = (error: Error) => {
+      terminalError = error
+      turnReject?.(error)
+      turnResolve = null
+      turnReject = null
+      if (threadId && activeTurnId) void client.interrupt(threadId, activeTurnId)
+    }
+
+    const timeoutMinutes = resolveCodexCliTimeoutMinutes(config.llmConfig.codexCliTimeoutMinutes)
+    const timeout = setTimeout(() => {
+      failActiveTurn(new Error(`Codex app-server 超时(${timeoutMinutes} 分钟)`))
+    }, timeoutMinutes * 60_000)
+    const abort = () => failActiveTurn(new Error("操作已取消"))
+    signal?.addEventListener("abort", abort, { once: true })
+
+    try {
+      await client.ensureStarted()
+      const preparedSystemInstructions = preparedMessages
+        .filter((message) => message.role === "system")
+        .map((message) => messageContentText(message.content))
+        .filter(Boolean)
+        .join("\n\n")
+      const started = await client.call<ThreadStartResponse>("thread/start", {
+        model: config.modelId?.trim() || config.llmConfig.model.trim() || null,
+        cwd: client.isolatedCwd,
+        approvalPolicy: "never",
+        sandbox: "read-only",
+        ephemeral: true,
+        baseInstructions: preparedSystemInstructions || config.systemPrompt,
+        developerInstructions: [
+          "You are the QMAI main agent.",
+          "Use only client-provided dynamic tools.",
+          "Never use native shell, file changes, MCP, plugins, skills, apps, browser, web search, image generation, or subagents.",
+          "Project data is available only through QMAI tools. Do not guess file contents.",
+        ].join("\n"),
+        dynamicTools: toDynamicTools(config),
+        config: restrictedCodexConfig(),
+      })
+      if (started.instructionSources?.length) {
+        throw new Error(`QMAI 禁止 Codex 加载本机或项目规则:${started.instructionSources.join(", ")}`)
+      }
+      threadId = started.thread.id
+
+      unregister = client.registerThread(threadId, {
+        onDynamicToolCall: async (request) => {
+          if (!request.arguments || typeof request.arguments !== "object" || Array.isArray(request.arguments)) {
+            const message = `错误: 工具 ${request.tool} 的参数必须是 JSON 对象`
+            const now = Date.now()
+            callbacks.onToolCall({ id: request.callId, name: request.tool, arguments: {} })
+            callbacks.onToolEvent?.({
+              type: "call_started",
+              callId: request.callId,
+              name: request.tool,
+              params: {},
+              timestamp: now,
+            })
+            record.toolCalls.push({
+              id: request.callId,
+              name: request.tool,
+              params: {},
+              result: message,
+              status: "error",
+              startedAt: now,
+              finishedAt: now,
+            })
+            callbacks.onToolError(request.callId, message)
+            callbacks.onToolEvent?.({
+              type: "error",
+              callId: request.callId,
+              name: request.tool,
+              params: {},
+              result: message,
+              timestamp: now,
+            })
+            return {
+              contentItems: [{ type: "inputText", text: message }],
+              success: false,
+            }
+          }
+          const params = request.arguments as Record<string, unknown>
+          const executed = await executeAgentTool(
+            { id: request.callId, name: request.tool, arguments: params },
+            registry,
+            callbacks,
+            signal,
+          )
+          record.toolCalls.push(executed.record)
+          if (taskBreakpoint) {
+            const usedTools = taskBreakpoint.usedTools.includes(request.tool)
+              ? taskBreakpoint.usedTools
+              : [...taskBreakpoint.usedTools, request.tool]
+            taskBreakpoint = updateBreakpointStage(
+              { ...taskBreakpoint, usedTools },
+              `agent_round_${record.roundsUsed}`,
+              `tool:${request.tool}`,
+            )
+            await persistTaskBreakpoint()
+          }
+          return {
+            contentItems: [{
+              type: "inputText",
+              text: evidenceLedger.format(request.tool, params, executed.responseText),
+            }],
+            success: executed.success,
+          }
+        },
+        onEnvelope: (envelope) => {
+          const boundaryError = codexNativeBoundaryError(envelope, true)
+          if (boundaryError) {
+            failActiveTurn(boundaryError)
+            return
+          }
+          if (envelope.method === "item/started") {
+            const item = envelope.params?.item as Record<string, unknown> | undefined
+            if (item?.type === "agentMessage" && typeof item.id === "string") {
+              agentMessagePhases.set(item.id, typeof item.phase === "string" ? item.phase : null)
+            }
+          } else if (envelope.method === "item/agentMessage/delta") {
+            const delta = envelope.params?.delta
+            const itemId = typeof envelope.params?.itemId === "string" ? envelope.params.itemId : ""
+            if (typeof delta === "string" && agentMessagePhases.get(itemId) !== "commentary") {
+              turnText += delta
+            }
+          } else if (
+            envelope.method === "item/reasoning/summaryTextDelta" ||
+            envelope.method === "item/reasoning/textDelta"
+          ) {
+            const delta = envelope.params?.delta
+            if (typeof delta === "string" && delta) callbacks.onReasoningToken?.(delta)
+          } else if (envelope.method === "thread/tokenUsage/updated") {
+            const usage = usageFromEnvelope(envelope)
+            turnUsage = usage?.last
+            cumulativeUsage = usage?.total ?? cumulativeUsage
+            if (turnUsage) callbacks.onUsage?.(turnUsage)
+          } else if (envelope.method === "item/completed") {
+            const item = envelope.params?.item as Record<string, unknown> | undefined
+            const itemId = typeof item?.id === "string" ? item.id : ""
+            if (
+              item?.type === "agentMessage" &&
+              item.phase !== "commentary" &&
+              typeof item.text === "string" &&
+              !emittedItems.has(itemId)
+            ) {
+              if (!turnText) turnText = item.text
+              if (itemId) emittedItems.add(itemId)
+            }
+          } else if (envelope.method === "turn/completed") {
+            const turn = envelope.params?.turn as Record<string, unknown> | undefined
+            const error = turn?.error as Record<string, unknown> | undefined
+            completeActiveTurn({
+              status: String(turn?.status || "completed"),
+              error: typeof error?.message === "string" ? error.message : undefined,
+            })
+          } else if (envelope.method === "error") {
+            const error = envelope.params?.error as Record<string, unknown> | undefined
+            if (envelope.params?.willRetry !== true) {
+              failActiveTurn(new Error(String(error?.message || "Codex app-server 错误")))
+            }
+          }
+        },
+      })
+
+      let turnInput = buildCodexTurnInput(preparedMessages)
+      for (let round = 0; round < Math.max(1, config.maxRounds); round += 1) {
+        if (signal?.aborted) throw new Error("操作已取消")
+        record.roundsUsed = round + 1
+        turnText = ""
+        turnUsage = undefined
+        const completionPromise = new Promise<TurnCompletion>((resolve, reject) => {
+          turnResolve = resolve
+          turnReject = reject
+        })
+        const turn = await client.call<TurnStartResponse>("turn/start", {
+          threadId,
+          input: turnInput,
+          cwd: client.isolatedCwd,
+          approvalPolicy: "never",
+          sandboxPolicy: { type: "readOnly", networkAccess: false },
+          model: config.modelId?.trim() || config.llmConfig.model.trim() || null,
+          effort: codexReasoningEffort(config.llmConfig),
+        })
+        activeTurnId = turn.turn.id
+        if (terminalError) void client.interrupt(threadId, activeTurnId)
+        const completion = await completionPromise
+        activeTurnId = ""
+        if (completion.status === "failed") {
+          throw new Error(completion.error || "Codex app-server turn 失败")
+        }
+        if (completion.status === "interrupted") {
+          throw terminalError ?? new Error("操作已取消")
+        }
+        const completedUsage = turnUsage as LlmUsage | undefined
+        if (completedUsage) {
+          record.lastRequestUsage = { ...completedUsage }
+          record.usage = cumulativeUsage ? { ...cumulativeUsage } : { ...completedUsage }
+        }
+
+        const missing = missingRequiredToolsOnce({
+          requiredToolsOnce: config.requiredToolsOnce,
+          availableToolNames: config.tools.map((tool) => tool.name),
+          calledToolNames: record.toolCalls.map((call) => call.name),
+          toolsEnabled: config.tools.length > 0,
+        })
+        if (missing.length === 0) {
+          record.finalText = turnText
+          if (turnText) callbacks.onText(turnText)
+          await clearPersistedBreakpoint()
+          callbacks.onDone()
+          return record
+        }
+        if (round >= Math.max(1, config.maxRounds) - 1) {
+          await clearPersistedBreakpoint()
+          throw new RequiredToolsNotCalledError(missing)
+        }
+        turnInput = [{
+          type: "text",
+          text: buildRequiredToolNudgeMessage(missing),
+          text_elements: [],
+        }]
+      }
+
+      throw new Error(`Agent 已达到最大调用轮次(${config.maxRounds}),请尝试减少引用内容或拆分任务`)
+    } catch (error) {
+      const resolved = error instanceof Error ? error : new Error(String(error))
+      callbacks.onError(resolved)
+      return record
+    } finally {
+      clearTimeout(timeout)
+      signal?.removeEventListener("abort", abort)
+      unregister()
+    }
+  }
+}

+ 4 - 1
src/lib/agent/config.spec.ts

@@ -35,9 +35,12 @@ describe("function calling helpers", () => {
 
   it("blocks local CLI providers from agent tools", () => {
     expect(modelSupportsTools("opus", "claude-code")).toBe(false)
-    expect(modelSupportsTools("gpt-5", "codex-cli")).toBe(false)
     expect(modelSupportsTools("composer-2-fast", "cursor-cli")).toBe(false)
   })
+
+  it("allows Codex app-server dynamic tools", () => {
+    expect(modelSupportsTools("gpt-5", "codex-cli")).toBe(true)
+  })
 })
 
 describe("buildAgentConfig functionCallingEnabled", () => {

+ 0 - 2
src/lib/agent/config.ts

@@ -10,13 +10,11 @@ export const TOOL_UNSUPPORTED_MODEL_PREFIXES: string[] = [
   "o3-mini",
   "deepseek-reasoner",
   "claude-code",
-  "codex-cli",
   "cursor-cli",
 ]
 
 const TOOL_UNSUPPORTED_PROVIDERS = new Set<LlmConfig["provider"]>([
   "claude-code",
-  "codex-cli",
   "cursor-cli",
 ])
 

+ 4 - 0
src/lib/agent/pipeline.ts

@@ -26,6 +26,8 @@ export interface PrePluginInput {
   novelSystemPrompt?: string
   finalSystemPrompt?: string
   finalSystemRulesPrompt?: string
+  stableSystemRulesPrompt?: string
+  dynamicSystemRulesPrompt?: string
   shouldStop?: boolean
   stopReason?: string
   [key: string]: unknown
@@ -41,6 +43,8 @@ export interface PrePluginOutput {
   novelSystemPrompt?: string
   finalSystemPrompt?: string
   finalSystemRulesPrompt?: string
+  stableSystemRulesPrompt?: string
+  dynamicSystemRulesPrompt?: string
   shouldStop?: boolean
   stopReason?: string
   [key: string]: unknown

+ 4 - 0
src/lib/agent/plugins/build-system-prompt-plugin.spec.ts

@@ -40,6 +40,10 @@ describe("BuildSystemPromptPlugin selected skills", () => {
     expect(result.finalSystemRulesPrompt).toContain("本次启用 Skill")
     expect(result.finalSystemRulesPrompt).toContain("task directive")
     expect(result.finalSystemRulesPrompt).not.toContain("context prompt")
+    expect(result.stableSystemRulesPrompt).toBe("base prompt")
+    expect(result.dynamicSystemRulesPrompt).toContain("本次启用 Skill")
+    expect(result.dynamicSystemRulesPrompt).toContain("task directive")
+    expect(result.dynamicSystemRulesPrompt).not.toContain("base prompt")
   })
 
   it("does not inject chapter plan protocol from standard mode unless Plan Execute is enabled", async () => {

+ 14 - 1
src/lib/agent/plugins/build-system-prompt-plugin.ts

@@ -29,6 +29,8 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
 
         const parts: string[] = []
         const rulesParts: string[] = []
+        const stableRulesParts: string[] = []
+        const dynamicRulesParts: string[] = []
 
         // 去掉 base 里可能已有的找纲协议,统一由本 plugin 注入一次,避免重复。
         const rawBase = baseSystemPrompt || (input.agentConfig as any)?.systemPrompt || ""
@@ -36,6 +38,7 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
         if (base) {
           parts.push(base)
           rulesParts.push(base)
+          stableRulesParts.push(base)
         }
 
         if (input.novelSystemPrompt) {
@@ -46,6 +49,7 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
         if (selectedSkillsPrompt) {
           parts.push(selectedSkillsPrompt)
           rulesParts.push(selectedSkillsPrompt)
+          dynamicRulesParts.push(selectedSkillsPrompt)
         }
         const missingSkillNames = Array.isArray(input.missingSkillNames)
           ? input.missingSkillNames.filter((name): name is string => typeof name === "string")
@@ -54,6 +58,7 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
           const diagnostic = `## Skill 路由诊断\n以下确定性 Skill 缺失或已被用户禁用,禁止通过 apply_skill 强制启用:${missingSkillNames.join("、")}。请按已加载规则继续,并向用户保留该诊断。`
           parts.push(diagnostic)
           rulesParts.push(diagnostic)
+          dynamicRulesParts.push(diagnostic)
         }
 
         const routeForWriting = input.effectiveTaskRoute || input.taskRoute
@@ -62,6 +67,7 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
           const outlineProtocol = buildOutlineFindProtocol(routeForWriting?.chapterNumber)
           parts.push(outlineProtocol)
           rulesParts.push(outlineProtocol)
+          dynamicRulesParts.push(outlineProtocol)
         }
 
         if (input.planExecuteEnabled && input.aiWorkflowMode) {
@@ -72,6 +78,7 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
           const planProtocol = buildChapterPlanProtocol(input.aiWorkflowMode)
           parts.push(planProtocol)
           rulesParts.push(planProtocol)
+          dynamicRulesParts.push(planProtocol)
         }
 
         if (route) {
@@ -79,12 +86,18 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
           if (taskDirective) {
             parts.push(taskDirective)
             rulesParts.push(taskDirective)
+            dynamicRulesParts.push(taskDirective)
           }
         }
 
         const finalSystemPrompt = parts.join("\n\n")
         const finalSystemRulesPrompt = rulesParts.join("\n\n")
-        return { finalSystemPrompt, finalSystemRulesPrompt }
+        return {
+          finalSystemPrompt,
+          finalSystemRulesPrompt,
+          stableSystemRulesPrompt: stableRulesParts.join("\n\n"),
+          dynamicSystemRulesPrompt: dynamicRulesParts.join("\n\n"),
+        }
       } catch (error) {
         onError?.(error instanceof Error ? error : new Error(String(error)))
         return {}

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

@@ -69,6 +69,16 @@ describe("AgentRunner", () => {
 
   it("returns final text when LLM responds without tool calls", async () => {
     mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      cb.onRequestTrace?.({
+        provider: "openai",
+        model: "test",
+        apiMode: "chat_completions",
+        prefixFingerprint: "fingerprint",
+        startedAt: 100,
+        finishedAt: 200,
+        durationMs: 100,
+        status: "success",
+      })
       for (const char of "Hello user!") {
         cb.onToken(char)
       }
@@ -86,6 +96,7 @@ describe("AgentRunner", () => {
     const result = await runner.run(config, registry, [systemMsg, userMsg], callbacks, undefined)
     expect(result.finalText).toBe("Hello user!")
     expect(result.roundsUsed).toBe(1)
+    expect(result.requestTraces).toEqual([expect.objectContaining({ prefixFingerprint: "fingerprint" })])
     expect(callbacks.onDone).toHaveBeenCalledOnce()
     expect(callbacks.onError).not.toHaveBeenCalled()
   })
@@ -1024,6 +1035,64 @@ describe("AgentRunner", () => {
     expect(compressedToolMessage).toContain("结尾")
   })
 
+  it("sends the full run_chapter_workflow result to the model instead of compressing it", async () => {
+    const longResult = [
+      "章节工作流完成。",
+      "是否返修:是",
+      `任务书:${"开头承接".repeat(400)}`,
+      "",
+      "最终正文:",
+      `${"陈远的手还压在西线地图上。".repeat(80)}\n中间正文\n${"空袭窗口正在关闭。".repeat(80)}`,
+    ].join("\n")
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      permission: "auto",
+      executeTimeoutMs: 0,
+      parameters: {},
+      execute: vi.fn().mockResolvedValue(longResult),
+    }
+    registry.register(tool)
+
+    let injectedToolMessage = ""
+    let callCount = 0
+    mockStreamChat.mockImplementation(async (_config: unknown, messages: AgentMessage[], cb: StreamCallbacks) => {
+      callCount++
+      if (callCount === 1) {
+        cb.onToolCallDelta?.({ index: 0, id: "workflow_1", name: "run_chapter_workflow" })
+        cb.onToolCallDelta?.({ index: 0, arguments: "{}" })
+        cb.onDone()
+      } else {
+        injectedToolMessage = String(messages[messages.length - 1].content)
+        cb.onToken("已输出")
+        cb.onDone()
+      }
+    })
+
+    const config: AgentConfig = {
+      maxRounds: 3,
+      tools: [tool],
+      systemPrompt: "",
+      llmConfig: mockLlmConfig,
+      toolResultContextLimit: 1200,
+    }
+    const result = await runner.run(
+      config,
+      registry,
+      [systemMsg, userMsg],
+      { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
+      undefined,
+    )
+
+    expect(longResult.length).toBeGreaterThan(1200)
+    expect(result.toolCalls[0].result).toBe(longResult)
+    expect(injectedToolMessage).toContain(longResult)
+    expect(injectedToolMessage).toContain("陈远的手还压在西线地图上")
+    expect(injectedToolMessage).toContain("中间正文")
+    expect(injectedToolMessage).not.toContain("已压缩给模型使用")
+  })
+
   it("每轮模型请求保留任务契约并压缩内部工作消息", async () => {
     const untrimmed = [
       { role: "system" as const, content: "系统规则".repeat(120) },

+ 26 - 165
src/lib/agent/runner.ts

@@ -5,7 +5,7 @@ import { accumulateToolCalls, parseTextToolCalls } from "./tool-call-parser"
 import { toOpenAITools } from "./tools-schema"
 import type { ToolRegistry } from "./registry"
 import type { AgentConfig, AgentMessage, AgentRunCallbacks, AgentRunRecord, ToolCall, ToolCallDelta } from "./types"
-import { DEFAULT_MAX_ROUNDS, TOOL_EXECUTE_TIMEOUT_MS } from "./types"
+import { DEFAULT_MAX_ROUNDS } from "./types"
 import type { TaskBreakpoint } from "./task-breakpoint"
 import {
   clearTaskBreakpoint,
@@ -16,15 +16,18 @@ import {
 import { getEffectiveMaxContextSize, type ChatMessage } from "../llm-providers"
 import { isReasoningDisabled, isReasoningOnlyResponseError, withReasoningDisabled } from "../reasoning-retry"
 import { addLlmUsage, mergeLlmUsageSnapshot, type LlmUsage } from "../llm-usage"
+import { LlmRequestTraceCollector, type LlmRequestCacheTrace } from "../llm-request-trace"
 import { trimChatMessagesToTokenBudget } from "../chat-request-budget"
 import { logReasoningReplay } from "../reasoning-replay-debug"
 import { ToolEvidenceLedger } from "./tool-evidence-ledger"
+import { DEFAULT_TOOL_RESULT_CONTEXT_LIMIT } from "./tool-result"
 import {
   RequiredToolsNotCalledError,
   buildRequiredToolNudgeMessage,
   missingRequiredToolsOnce,
 } from "./required-tools-gate"
-import { isToolErrorResult } from "./tool-result"
+import { executeAgentTool } from "./tool-executor"
+import { CodexAppServerRunner } from "./codex-app-server-runner"
 
 export class ModelDoesNotSupportToolsError extends Error {
   constructor() {
@@ -40,17 +43,6 @@ function messageContentText(content: AgentMessage["content"]): string {
     .join("")
 }
 
-function withToolTimeout<T>(operation: Promise<T>, timeoutMs: number | undefined): Promise<T> {
-  const resolvedTimeoutMs = timeoutMs ?? TOOL_EXECUTE_TIMEOUT_MS
-  if (resolvedTimeoutMs <= 0) return operation
-  return Promise.race([
-    operation,
-    new Promise<never>((_, reject) =>
-      setTimeout(() => reject(new Error("工具执行超时")), resolvedTimeoutMs),
-    ),
-  ])
-}
-
 export class AgentRunner {
   async run(
     config: AgentConfig,
@@ -59,7 +51,18 @@ export class AgentRunner {
     callbacks: AgentRunCallbacks,
     signal?: AbortSignal,
   ): Promise<AgentRunRecord> {
+    if (config.llmConfig.provider === "codex-cli") {
+      return new CodexAppServerRunner().run(config, registry, messages, callbacks, signal)
+    }
     const record: AgentRunRecord = { toolCalls: [], roundsUsed: 0, finalText: "" }
+    const requestTraceCollector = new LlmRequestTraceCollector()
+    const onRequestTrace = (trace: LlmRequestCacheTrace) => {
+      requestTraceCollector.record(trace)
+      const snapshot = requestTraceCollector.snapshot()
+      record.requestTraces = snapshot.requests
+      record.omittedRequestTraceCount = snapshot.omittedRequestCount
+      callbacks.onRequestTrace?.(trace)
+    }
     const workingMessages = [...messages]
     let finalText = ""
     const maxRounds = config.maxRounds || DEFAULT_MAX_ROUNDS
@@ -74,7 +77,7 @@ export class AgentRunner {
       role: "system",
       content: taskContract,
     })
-    const evidenceLedger = new ToolEvidenceLedger(config.toolResultContextLimit ?? 6000)
+    const evidenceLedger = new ToolEvidenceLedger(config.toolResultContextLimit ?? DEFAULT_TOOL_RESULT_CONTEXT_LIMIT)
     let taskBreakpoint: TaskBreakpoint | null = projectPath
       ? createTaskBreakpoint({
           taskGoal,
@@ -146,6 +149,7 @@ export class AgentRunner {
           roundUsage = mergeLlmUsageSnapshot(roundUsage, usage)
           if (roundUsage) callbacks.onUsage?.(roundUsage)
         },
+        onRequestTrace,
         onUserMemoryDecision: (decision) => {
           if (record.userMemoryDecision === undefined) {
             record.userMemoryDecision = decision
@@ -359,7 +363,6 @@ export class AgentRunner {
       // Execute each tool call
       for (const tc of toolCalls) {
         const toolName = tc.function.name
-        const tool = registry.get(toolName)
 
         const saveToolProgress = async () => {
           if (!taskBreakpoint) return
@@ -378,159 +381,17 @@ export class AgentRunner {
           try { return JSON.parse(tc.function.arguments || "{}") }
           catch { return {} }
         })()
-
-        const toolCallRecord: AgentRunRecord["toolCalls"][number] = {
-          id: tc.id,
-          name: toolName,
-          params,
-          result: "",
-          status: "running",
-          startedAt: Date.now(),
-          finishedAt: Date.now(),
-        }
-
-        const callbackToolCall: ToolCall = { id: tc.id, name: toolName, arguments: params }
-        const executionContext = {
-          callId: tc.id,
-          toolName,
-          onToolEvent: callbacks.onToolEvent,
-          onActivityEvent: callbacks.onActivityEvent,
-        }
-        callbacks.onToolCall(callbackToolCall)
-        callbacks.onToolEvent?.({
-          type: "call_started",
-          callId: tc.id,
-          name: toolName,
-          params,
-          timestamp: toolCallRecord.startedAt,
-        })
-
-        if (!tool) {
-          const errorMsg = `错误: 未知工具 ${toolName}`
-          callbacks.onToolError(tc.id, errorMsg)
-          toolCallRecord.status = "error"
-          toolCallRecord.result = errorMsg
-          toolCallRecord.finishedAt = Date.now()
-          record.toolCalls.push(toolCallRecord)
-          callbacks.onToolEvent?.({
-            type: "error",
-            callId: tc.id,
-            name: toolName,
-            params,
-            result: errorMsg,
-            timestamp: toolCallRecord.finishedAt,
-          })
-          workingMessages.push({
-            role: "tool",
-            content: evidenceLedger.format(toolName, params, toolCallRecord.result),
-            tool_call_id: tc.id,
-            name: toolName,
-          })
-          await saveToolProgress()
-          continue
-        }
-
-        const permission = tool.permission ?? (tool.category === "write" ? "confirm" : "auto")
-        if (permission === "confirm") {
-          let preview = ""
-          try {
-            const previewFn = tool.generatePreview ?? tool.execute
-            preview = await withToolTimeout(previewFn(params, signal, executionContext), tool.executeTimeoutMs)
-          } catch (e) {
-            const errorMsg = `预览生成失败:${e instanceof Error ? e.message : String(e)}`
-            toolCallRecord.status = "error"
-            toolCallRecord.result = errorMsg
-            toolCallRecord.finishedAt = Date.now()
-            record.toolCalls.push(toolCallRecord)
-            callbacks.onToolError(tc.id, errorMsg)
-            callbacks.onToolEvent?.({
-              type: "error",
-              callId: tc.id,
-              name: toolName,
-              params,
-              result: errorMsg,
-              timestamp: toolCallRecord.finishedAt,
-            })
-            workingMessages.push({
-              role: "tool",
-              content: evidenceLedger.format(toolName, params, errorMsg),
-              tool_call_id: tc.id,
-              name: toolName,
-            })
-            await saveToolProgress()
-            continue
-          }
-          toolCallRecord.status = "approval_required"
-          ;(toolCallRecord as any).preview = preview
-          toolCallRecord.result = preview
-          toolCallRecord.finishedAt = Date.now()
-          record.toolCalls.push(toolCallRecord)
-          callbacks.onToolEvent?.({
-            type: "approval_required",
-            callId: tc.id,
-            name: toolName,
-            params,
-            result: preview,
-            preview,
-            timestamp: toolCallRecord.finishedAt,
-          })
-          workingMessages.push({
-            role: "tool",
-            content: evidenceLedger.format(toolName, params, preview),
-            tool_call_id: tc.id,
-            name: toolName,
-          })
-          await saveToolProgress()
-          continue
-        }
-
-        try {
-          const result = await withToolTimeout(tool.execute(params, signal, executionContext), tool.executeTimeoutMs)
-          toolCallRecord.result = result
-          toolCallRecord.finishedAt = Date.now()
-          if (isToolErrorResult(result)) {
-            toolCallRecord.status = "error"
-            callbacks.onToolError(tc.id, result)
-            callbacks.onToolEvent?.({
-              type: "error",
-              callId: tc.id,
-              name: toolName,
-              params,
-              result,
-              timestamp: toolCallRecord.finishedAt,
-            })
-          } else {
-            toolCallRecord.status = "done"
-            callbacks.onToolResult(tc.id, result)
-            callbacks.onToolEvent?.({
-              type: "result",
-              callId: tc.id,
-              name: toolName,
-              params,
-              result,
-              timestamp: toolCallRecord.finishedAt,
-            })
-          }
-        } catch (err) {
-          toolCallRecord.status = "error"
-          toolCallRecord.result = `错误: ${err instanceof Error ? err.message : String(err)}`
-          toolCallRecord.finishedAt = Date.now()
-          callbacks.onToolError(tc.id, toolCallRecord.result)
-          callbacks.onToolEvent?.({
-            type: "error",
-            callId: tc.id,
-            name: toolName,
-            params,
-            result: toolCallRecord.result,
-            timestamp: toolCallRecord.finishedAt,
-          })
-        }
-
-        record.toolCalls.push(toolCallRecord)
+        const executed = await executeAgentTool(
+          { id: tc.id, name: toolName, arguments: params } satisfies ToolCall,
+          registry,
+          { ...callbacks, onRequestTrace },
+          signal,
+        )
+        record.toolCalls.push(executed.record)
         await saveToolProgress()
         workingMessages.push({
           role: "tool",
-          content: evidenceLedger.format(toolName, params, toolCallRecord.result),
+          content: evidenceLedger.format(toolName, params, executed.responseText),
           tool_call_id: tc.id,
           name: toolName,
         })

+ 19 - 0
src/lib/agent/tool-evidence-ledger.spec.ts

@@ -18,4 +18,23 @@ describe("ToolEvidenceLedger", () => {
 
     expect(ledger.format("read_chapter", { chapter: 2 }, "第二章")).toContain("第二章")
   })
+
+  it("章节工作流终稿不按证据限额截断", () => {
+    const ledger = new ToolEvidenceLedger(300)
+    const result = [
+      "章节工作流完成。",
+      "是否返修:否",
+      `任务书:${"必须完成项".repeat(120)}`,
+      "",
+      "最终正文:",
+      "陈远的手还压在西线地图上。".repeat(80),
+    ].join("\n")
+
+    const formatted = ledger.format("run_chapter_workflow", { chapterNumber: 240 }, result)
+
+    expect(formatted).toContain("最终正文:")
+    expect(formatted).toContain("陈远的手还压在西线地图上")
+    expect(formatted).not.toContain("已压缩给模型使用")
+    expect(formatted).toContain(result)
+  })
 })

+ 181 - 0
src/lib/agent/tool-executor.ts

@@ -0,0 +1,181 @@
+import type { ToolRegistry } from "./registry"
+import type { AgentRunCallbacks, AgentRunRecord, ToolCall } from "./types"
+import { TOOL_EXECUTE_TIMEOUT_MS } from "./types"
+import { isToolErrorResult } from "./tool-result"
+
+export function withToolTimeout<T>(operation: Promise<T>, timeoutMs: number | undefined): Promise<T> {
+  const resolvedTimeoutMs = timeoutMs ?? TOOL_EXECUTE_TIMEOUT_MS
+  if (resolvedTimeoutMs <= 0) return operation
+  return new Promise<T>((resolve, reject) => {
+    const timer = setTimeout(() => reject(new Error("工具执行超时")), resolvedTimeoutMs)
+    operation.then(
+      (value) => {
+        clearTimeout(timer)
+        resolve(value)
+      },
+      (error) => {
+        clearTimeout(timer)
+        reject(error)
+      },
+    )
+  })
+}
+
+export interface ExecuteAgentToolResult {
+  record: AgentRunRecord["toolCalls"][number]
+  responseText: string
+  success: boolean
+}
+
+export async function executeAgentTool(
+  call: ToolCall,
+  registry: ToolRegistry,
+  callbacks: AgentRunCallbacks,
+  signal?: AbortSignal,
+): Promise<ExecuteAgentToolResult> {
+  const startedAt = Date.now()
+  const record: AgentRunRecord["toolCalls"][number] = {
+    id: call.id,
+    name: call.name,
+    params: call.arguments,
+    result: "",
+    status: "running",
+    startedAt,
+    finishedAt: startedAt,
+  }
+  callbacks.onToolCall(call)
+  callbacks.onToolEvent?.({
+    type: "call_started",
+    callId: call.id,
+    name: call.name,
+    params: call.arguments,
+    timestamp: startedAt,
+  })
+
+  const tool = registry.get(call.name)
+  if (!tool) {
+    const result = `错误: 未知工具 ${call.name}`
+    record.status = "error"
+    record.result = result
+    record.finishedAt = Date.now()
+    callbacks.onToolError(call.id, result)
+    callbacks.onToolEvent?.({
+      type: "error",
+      callId: call.id,
+      name: call.name,
+      params: call.arguments,
+      result,
+      timestamp: record.finishedAt,
+    })
+    return { record, responseText: result, success: false }
+  }
+
+  const executionContext = {
+    callId: call.id,
+    toolName: call.name,
+    onToolEvent: callbacks.onToolEvent,
+    onActivityEvent: callbacks.onActivityEvent,
+    onRequestTrace: callbacks.onRequestTrace,
+  }
+  const permission = tool.permission ?? (tool.category === "write" ? "confirm" : "auto")
+  if (permission === "confirm") {
+    try {
+      const previewFn = tool.generatePreview ?? tool.execute
+      const preview = await withToolTimeout(
+        previewFn(call.arguments, signal, executionContext),
+        tool.executeTimeoutMs,
+      )
+      record.status = "approval_required"
+      record.preview = preview
+      record.result = preview
+      record.finishedAt = Date.now()
+      callbacks.onToolEvent?.({
+        type: "approval_required",
+        callId: call.id,
+        name: call.name,
+        params: call.arguments,
+        result: preview,
+        preview,
+        timestamp: record.finishedAt,
+      })
+      return {
+        record,
+        responseText: `尚未执行:该操作需要用户确认。\n\n${preview}`,
+        success: true,
+      }
+    } catch (error) {
+      const result = `预览生成失败:${error instanceof Error ? error.message : String(error)}`
+      record.status = "error"
+      record.result = result
+      record.finishedAt = Date.now()
+      callbacks.onToolError(call.id, result)
+      callbacks.onToolEvent?.({
+        type: "error",
+        callId: call.id,
+        name: call.name,
+        params: call.arguments,
+        result,
+        timestamp: record.finishedAt,
+      })
+      return { record, responseText: result, success: false }
+    }
+  }
+
+  try {
+    const result = await withToolTimeout(
+      tool.execute(call.arguments, signal, executionContext),
+      tool.executeTimeoutMs,
+    )
+    record.result = result
+    record.finishedAt = Date.now()
+    if (isToolErrorResult(result)) {
+      record.status = "error"
+      callbacks.onToolError(call.id, result)
+      callbacks.onToolEvent?.({
+        type: "error",
+        callId: call.id,
+        name: call.name,
+        params: call.arguments,
+        result,
+        timestamp: record.finishedAt,
+      })
+      return { record, responseText: result, success: false }
+    }
+    record.status = "done"
+    callbacks.onToolResult(call.id, result)
+    callbacks.onToolEvent?.({
+      type: "result",
+      callId: call.id,
+      name: call.name,
+      params: call.arguments,
+      result,
+      timestamp: record.finishedAt,
+    })
+    return { record, responseText: result, success: true }
+  } catch (error) {
+    const result = `错误: ${error instanceof Error ? error.message : String(error)}`
+    record.status = signal?.aborted ? "cancelled" : "error"
+    record.result = result
+    record.finishedAt = Date.now()
+    if (record.status === "cancelled") {
+      callbacks.onToolEvent?.({
+        type: "cancelled",
+        callId: call.id,
+        name: call.name,
+        params: call.arguments,
+        timestamp: record.finishedAt,
+      })
+    } else {
+      callbacks.onToolError(call.id, result)
+      callbacks.onToolEvent?.({
+        type: "error",
+        callId: call.id,
+        name: call.name,
+        params: call.arguments,
+        result,
+        timestamp: record.finishedAt,
+      })
+    }
+    return { record, responseText: result, success: false }
+  }
+}

+ 32 - 1
src/lib/agent/tool-result.spec.ts

@@ -1,11 +1,24 @@
 import { describe, expect, it } from "vitest"
-import { formatToolResultForModel } from "./tool-result"
+import {
+  DEFAULT_TOOL_RESULT_CONTEXT_LIMIT,
+  formatToolResultForModel,
+  keepsFullToolResultForModel,
+} from "./tool-result"
 
 describe("formatToolResultForModel", () => {
   it("returns short tool results unchanged", () => {
     expect(formatToolResultForModel("read_chapter", "短内容", 100)).toBe("短内容")
   })
 
+  it("uses 10000 as the default evidence limit", () => {
+    expect(DEFAULT_TOOL_RESULT_CONTEXT_LIMIT).toBe(10000)
+    const under = "章".repeat(9000)
+    const over = "章".repeat(11000)
+    expect(formatToolResultForModel("read_chapter", under)).toBe(under)
+    expect(formatToolResultForModel("read_chapter", over)).toContain("已压缩给模型使用")
+    expect(formatToolResultForModel("read_chapter", over).length).toBeLessThan(over.length)
+  })
+
   it("compresses long results while preserving beginning and ending evidence", () => {
     const result = `${"开头内容".repeat(80)}\n${"中间内容".repeat(80)}\n${"结尾内容".repeat(80)}`
     const compressed = formatToolResultForModel("read_chapter", result, 300)
@@ -16,4 +29,22 @@ describe("formatToolResultForModel", () => {
     expect(compressed).toContain("开头内容")
     expect(compressed).toContain("结尾内容")
   })
+
+  it("does not truncate run_chapter_workflow deliverable even when over the limit", () => {
+    const chapterBody = "陈远的手还压在西线地图上。".repeat(80)
+    const result = [
+      "章节工作流完成。",
+      "是否返修:是",
+      `任务书:${"场景验收标准".repeat(800)}`,
+      "",
+      "最终正文:",
+      chapterBody,
+    ].join("\n")
+
+    expect(keepsFullToolResultForModel("run_chapter_workflow")).toBe(true)
+    expect(result.length).toBeGreaterThan(300)
+    expect(formatToolResultForModel("run_chapter_workflow", result, 300)).toBe(result)
+    expect(formatToolResultForModel("run_chapter_workflow", result, 300)).not.toContain("已压缩给模型使用")
+    expect(formatToolResultForModel("run_chapter_workflow", result, 300)).toContain("陈远的手还压在西线地图上")
+  })
 })

+ 12 - 1
src/lib/agent/tool-result.ts

@@ -1,16 +1,27 @@
 const TOOL_ERROR_PREFIX = /^\s*错误\s*[::]/
 
-export const DEFAULT_TOOL_RESULT_CONTEXT_LIMIT = 6000
+export const DEFAULT_TOOL_RESULT_CONTEXT_LIMIT = 10000
+
+/**
+ * 这些工具的返回值就是给用户的交付物(章节终稿),不是给模型当「证据摘录」的资料。
+ * 头尾截断会先切掉任务书、丢掉正文中段,外层模型只能按残片另写一章。
+ */
+const FULL_TOOL_RESULT_FOR_MODEL = new Set(["run_chapter_workflow"])
 
 export function isToolErrorResult(result: string): boolean {
   return TOOL_ERROR_PREFIX.test(result)
 }
 
+export function keepsFullToolResultForModel(toolName: string): boolean {
+  return FULL_TOOL_RESULT_FOR_MODEL.has(toolName)
+}
+
 export function formatToolResultForModel(
   toolName: string,
   result: string,
   limit = DEFAULT_TOOL_RESULT_CONTEXT_LIMIT,
 ): string {
+  if (keepsFullToolResultForModel(toolName)) return result
   if (result.length <= limit) return result
 
   const safeLimit = Math.max(200, limit)

+ 38 - 0
src/lib/agent/tools/run-chapter-workflow.spec.ts

@@ -184,6 +184,44 @@ describe("createRunChapterWorkflowTool", () => {
     }))
   })
 
+  it("forwards every nested chapter request trace into the parent Agent collector", async () => {
+    const onRequestTrace = vi.fn()
+    const trace = {
+      provider: "custom" as const,
+      model: "test-model",
+      apiMode: "chat_completions",
+      startedAt: 100,
+      finishedAt: 200,
+      durationMs: 100,
+      status: "success" as const,
+    }
+    const runDeepChapterGeneration = vi.fn(async (_input, callbacks) => {
+      callbacks.onRequestTrace?.(trace)
+      return {
+        finalContent: "正文",
+        taskBrief: "任务书",
+        draftContent: "草稿",
+        reviewResults: [],
+        revised: false,
+      }
+    })
+    const tool = createRunChapterWorkflowTool({
+      projectPath: "C:/Novel",
+      llmConfig,
+      aiWorkflowMode: "strict",
+      runDeepChapterGeneration,
+    })
+
+    await tool.execute(
+      { userRequest: "生成第14章" },
+      undefined,
+      { callId: "workflow-trace", toolName: "run_chapter_workflow", onRequestTrace },
+    )
+
+    expect(onRequestTrace).toHaveBeenCalledOnce()
+    expect(onRequestTrace).toHaveBeenCalledWith(trace)
+  })
+
   it("forwards the confirmed plan blueprint into deep chapter generation", async () => {
     const runDeepChapterGeneration = vi.fn(async (_input, callbacks) => {
       callbacks.onWorkflowEvent?.({

+ 1 - 0
src/lib/agent/tools/run-chapter-workflow.ts

@@ -145,6 +145,7 @@ export function createRunChapterWorkflowTool(options: RunChapterWorkflowToolOpti
               toolCallId: event.toolCallId ?? parentCallId,
             })
           },
+          onRequestTrace: context?.onRequestTrace,
         },
         undefined,
         signal,

+ 9 - 0
src/lib/agent/types.ts

@@ -1,6 +1,7 @@
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { ChatMessage, RequestOverrides } from "../llm-providers"
 import type { LlmUsage } from "../llm-usage"
+import type { LlmRequestCacheTrace } from "../llm-request-trace"
 
 export interface ToolParameter {
   type: "string" | "number" | "boolean" | "object" | "array" | "integer"
@@ -18,6 +19,7 @@ export interface ToolExecutionContext {
   toolName: string
   onToolEvent?: (event: AgentToolEvent) => void
   onActivityEvent?: (event: AgentActivityEvent) => void
+  onRequestTrace?: (trace: LlmRequestCacheTrace) => void
 }
 
 export interface Tool {
@@ -136,6 +138,7 @@ export interface AgentRunCallbacks {
   onActivityEvent?: (event: AgentActivityEvent) => void
   /** Usage for the current/latest provider request. */
   onUsage?: (usage: LlmUsage) => void
+  onRequestTrace?: (trace: LlmRequestCacheTrace) => void
   onUserMemoryDecision?: (decision: import("@/lib/user-memory/decision-trace").UserMemoryDecision | null) => void
   onDone: () => void
   onError: (error: Error) => void
@@ -166,8 +169,14 @@ export interface AgentRunRecord {
   finalText: string
   /** Cumulative provider usage across all requests in this agent run. */
   usage?: LlmUsage
+  /** Provider-managed thread totals cannot be assigned a reliable internal request count. */
+  usageAggregationScope?: "workflow" | "provider_thread"
+  providerRequestCountAvailable?: boolean
   /** Provider usage for the final request only; used for context-window UI. */
   lastRequestUsage?: LlmUsage
+  /** Sanitized request traces for this run, including nested workflow calls. */
+  requestTraces?: LlmRequestCacheTrace[]
+  omittedRequestTraceCount?: number
   /** Memory decision from the first LLM round that applied user memory. */
   userMemoryDecision?: import("@/lib/user-memory/decision-trace").UserMemoryDecision | null
 }

+ 160 - 0
src/lib/codex-app-server-client.spec.ts

@@ -0,0 +1,160 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const invokeMock = vi.hoisted(() => vi.fn())
+const listenMock = vi.hoisted(() => vi.fn())
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }))
+vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock }))
+
+import {
+  CodexAppServerClient,
+  type CodexAppServerEnvelope,
+} from "./codex-app-server-client"
+
+type EventHandler = (event: { payload: unknown }) => void
+
+describe("CodexAppServerClient", () => {
+  let handlers: Map<string, EventHandler>
+  let writes: CodexAppServerEnvelope[]
+  let generation: number
+
+  const emitLine = (envelope: CodexAppServerEnvelope) => {
+    handlers.get("codex-app-server:event")?.({
+      payload: { generation, line: JSON.stringify(envelope) },
+    })
+  }
+
+  beforeEach(() => {
+    handlers = new Map()
+    writes = []
+    generation = 1
+    listenMock.mockReset()
+    listenMock.mockImplementation(async (name: string, handler: EventHandler) => {
+      handlers.set(name, handler)
+      return () => handlers.delete(name)
+    })
+    invokeMock.mockReset()
+    invokeMock.mockImplementation(async (command: string, args?: Record<string, unknown>) => {
+      if (command === "codex_app_server_start") {
+        return { generation, cwd: "/tmp/qmai-codex/workspace" }
+      }
+      if (command === "codex_app_server_stop") return undefined
+      if (command === "codex_app_server_write") {
+        const envelope = JSON.parse(String(args?.data)) as CodexAppServerEnvelope
+        writes.push(envelope)
+        if (envelope.method === "initialize") {
+          queueMicrotask(() => emitLine({ id: envelope.id, result: { userAgent: "codex-test" } }))
+        } else if (
+          envelope.method === "thread/start" &&
+          Array.isArray((envelope.params as Record<string, unknown> | undefined)?.dynamicTools)
+        ) {
+          queueMicrotask(() => emitLine({
+            id: envelope.id,
+            result: { thread: { id: "probe-thread" }, instructionSources: [] },
+          }))
+        }
+        return undefined
+      }
+      throw new Error(`unexpected command: ${command}`)
+    })
+  })
+
+  it("initializes once and correlates concurrent JSON-RPC responses by id", async () => {
+    const client = new CodexAppServerClient()
+    await client.ensureStarted()
+
+    const alpha = client.call<string>("test/alpha")
+    const beta = client.call<string>("test/beta")
+    await Promise.resolve()
+    const alphaRequest = writes.find((item) => item.method === "test/alpha")!
+    const betaRequest = writes.find((item) => item.method === "test/beta")!
+    emitLine({ id: betaRequest.id, result: "B" })
+    emitLine({ id: alphaRequest.id, result: "A" })
+
+    await expect(alpha).resolves.toBe("A")
+    await expect(beta).resolves.toBe("B")
+    expect(writes.filter((item) => item.method === "initialize")).toHaveLength(1)
+    expect(writes.some((item) => item.method === "initialized")).toBe(true)
+  })
+
+  it("routes item/tool/call to the registered thread and writes DynamicToolCallResponse", async () => {
+    const client = new CodexAppServerClient()
+    await client.ensureStarted()
+    const onDynamicToolCall = vi.fn(async () => ({
+      contentItems: [{ type: "inputText" as const, text: "tool-result" }],
+      success: true,
+    }))
+    client.registerThread("thread-1", { onDynamicToolCall })
+
+    emitLine({
+      id: 99,
+      method: "item/tool/call",
+      params: {
+        threadId: "thread-1",
+        turnId: "turn-1",
+        callId: "call-1",
+        namespace: null,
+        tool: "read_outline",
+        arguments: { path: "QM/outlines/总纲.md" },
+      },
+    })
+    await vi.waitFor(() => {
+      expect(writes.some((item) => item.id === 99 && item.result)).toBe(true)
+    })
+
+    expect(onDynamicToolCall).toHaveBeenCalledWith(expect.objectContaining({
+      tool: "read_outline",
+      arguments: { path: "QM/outlines/总纲.md" },
+    }))
+    expect(writes.find((item) => item.id === 99)?.result).toEqual({
+      contentItems: [{ type: "inputText", text: "tool-result" }],
+      success: true,
+    })
+  })
+
+  it("rejects all pending requests on process exit and starts a new generation only later", async () => {
+    const client = new CodexAppServerClient()
+    await client.ensureStarted()
+    const pending = client.call("test/slow")
+    await Promise.resolve()
+
+    handlers.get("codex-app-server:exit")?.({ payload: { generation } })
+    await expect(pending).rejects.toThrow("本轮请求不会自动重放")
+
+    generation = 2
+    await client.ensureStarted()
+    expect(invokeMock.mock.calls.filter(([command]) => command === "codex_app_server_start")).toHaveLength(2)
+    expect(writes.filter((item) => item.method === "initialize")).toHaveLength(2)
+  })
+
+  it("rejects forbidden native server requests instead of dispatching them", async () => {
+    const client = new CodexAppServerClient()
+    await client.ensureStarted()
+    client.registerThread("thread-1", {})
+
+    emitLine({
+      id: 100,
+      method: "item/commandExecution/requestApproval",
+      params: { threadId: "thread-1" },
+    })
+    await vi.waitFor(() => {
+      expect(writes.some((item) => item.id === 100 && item.error)).toBe(true)
+    })
+    expect(writes.find((item) => item.id === 100)?.error?.message).toContain("禁止 Codex 原生能力")
+  })
+
+  it("times out an individual RPC without stopping or replaying the process", async () => {
+    const client = new CodexAppServerClient()
+    await client.ensureStarted()
+    vi.useFakeTimers()
+    try {
+      const pending = client.call("test/timeout", undefined, 25)
+      const assertion = expect(pending).rejects.toThrow("Codex app-server 调用超时:test/timeout")
+      await vi.advanceTimersByTimeAsync(25)
+      await assertion
+      expect(invokeMock.mock.calls.filter(([command]) => command === "codex_app_server_start")).toHaveLength(1)
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+})

+ 309 - 0
src/lib/codex-app-server-client.ts

@@ -0,0 +1,309 @@
+import { invoke } from "@tauri-apps/api/core"
+import { listen, type UnlistenFn } from "@tauri-apps/api/event"
+
+export type JsonRpcId = string | number
+
+export interface CodexAppServerEnvelope {
+  jsonrpc?: "2.0"
+  id?: JsonRpcId
+  method?: string
+  params?: Record<string, unknown>
+  result?: unknown
+  error?: { code?: number; message?: string; data?: unknown }
+}
+
+interface StartResult {
+  generation: number
+  cwd: string
+}
+
+interface EventPayload {
+  generation: number
+  line: string
+}
+
+interface ExitPayload {
+  generation: number
+}
+
+interface PendingRequest {
+  resolve: (value: unknown) => void
+  reject: (error: Error) => void
+  timer: ReturnType<typeof setTimeout>
+}
+
+export interface DynamicToolCallRequest {
+  threadId: string
+  turnId: string
+  callId: string
+  namespace: string | null
+  tool: string
+  arguments: unknown
+}
+
+export interface DynamicToolCallResponse {
+  contentItems: Array<{ type: "inputText"; text: string }>
+  success: boolean
+}
+
+export interface CodexThreadHandler {
+  onEnvelope?: (envelope: CodexAppServerEnvelope) => void
+  onDynamicToolCall?: (request: DynamicToolCallRequest) => Promise<DynamicToolCallResponse>
+}
+
+const DEFAULT_RPC_TIMEOUT_MS = 30_000
+const APP_SERVER_EXIT_MESSAGE = "Codex app-server 已退出;本轮请求不会自动重放"
+const APP_SERVER_UPGRADE_MESSAGE = "当前 Codex CLI 不支持 QMAI 主 Agent,请升级 Codex CLI"
+
+function rpcError(error: unknown, fallback: string): Error {
+  if (error instanceof Error) return error
+  if (typeof error === "string" && error.trim()) return new Error(error)
+  return new Error(fallback)
+}
+
+export class CodexAppServerClient {
+  private nextId = 1
+  private generation: number | null = null
+  private cwd = ""
+  private startPromise: Promise<void> | null = null
+  private initializedGeneration: number | null = null
+  private pending = new Map<JsonRpcId, PendingRequest>()
+  private handlers = new Map<string, CodexThreadHandler>()
+  private unlistenEvent: UnlistenFn | null = null
+  private unlistenExit: UnlistenFn | null = null
+  private listenersPromise: Promise<void> | null = null
+
+  get isolatedCwd(): string {
+    return this.cwd
+  }
+
+  async ensureStarted(): Promise<void> {
+    if (this.generation !== null && this.initializedGeneration === this.generation) return
+    if (this.startPromise) return this.startPromise
+    this.startPromise = this.startInternal().finally(() => {
+      this.startPromise = null
+    })
+    return this.startPromise
+  }
+
+  async call<T = unknown>(
+    method: string,
+    params?: Record<string, unknown>,
+    timeoutMs = DEFAULT_RPC_TIMEOUT_MS,
+  ): Promise<T> {
+    await this.ensureStarted()
+    return this.rawCall<T>(method, params, timeoutMs)
+  }
+
+  async notify(method: string, params?: Record<string, unknown>): Promise<void> {
+    await this.ensureStarted()
+    await this.send({ jsonrpc: "2.0", method, ...(params ? { params } : {}) })
+  }
+
+  registerThread(threadId: string, handler: CodexThreadHandler): () => void {
+    this.handlers.set(threadId, handler)
+    return () => {
+      if (this.handlers.get(threadId) === handler) this.handlers.delete(threadId)
+    }
+  }
+
+  async interrupt(threadId: string, turnId: string): Promise<void> {
+    await this.call("turn/interrupt", { threadId, turnId }).catch(() => undefined)
+  }
+
+  async stop(): Promise<void> {
+    this.rejectAll(new Error(APP_SERVER_EXIT_MESSAGE))
+    this.generation = null
+    this.initializedGeneration = null
+    this.cwd = ""
+    this.handlers.clear()
+    await invoke("codex_app_server_stop")
+  }
+
+  private async startInternal(): Promise<void> {
+    await this.ensureListeners()
+    const started = await invoke<StartResult>("codex_app_server_start")
+    const changed = this.generation !== started.generation
+    this.generation = started.generation
+    this.cwd = started.cwd
+    if (!changed && this.initializedGeneration === started.generation) return
+
+    try {
+      const initialized = await this.rawCall<{ userAgent?: string }>("initialize", {
+        clientInfo: { name: "QMaiWrite", title: "QMaiWrite", version: "3.1.8" },
+        capabilities: { experimentalApi: true, requestAttestation: false },
+      })
+      if (!initialized || typeof initialized !== "object") {
+        throw new Error("Codex app-server initialize 返回无效")
+      }
+      await this.send({ jsonrpc: "2.0", method: "initialized" })
+      let probe: { instructionSources?: string[] }
+      try {
+        probe = await this.rawCall("thread/start", {
+          cwd: this.cwd,
+          approvalPolicy: "never",
+          sandbox: "read-only",
+          ephemeral: true,
+          baseInstructions: "QMAI capability probe. Do not use native tools.",
+          developerInstructions: "Use only client-provided dynamic tools.",
+          dynamicTools: [{
+            type: "function",
+            name: "qmai_capability_probe",
+            description: "QMAI capability probe; never call it.",
+            inputSchema: { type: "object", properties: {}, additionalProperties: false },
+          }],
+        })
+      } catch (error) {
+        throw new Error(`${APP_SERVER_UPGRADE_MESSAGE}。${rpcError(error, APP_SERVER_UPGRADE_MESSAGE).message}`)
+      }
+      if (probe.instructionSources?.length) {
+        throw new Error(`QMAI 禁止 Codex 加载本机或项目规则:${probe.instructionSources.join(", ")}`)
+      }
+      this.initializedGeneration = started.generation
+    } catch (error) {
+      this.generation = null
+      this.initializedGeneration = null
+      this.cwd = ""
+      await invoke("codex_app_server_stop").catch(() => undefined)
+      throw error
+    }
+  }
+
+  private async ensureListeners(): Promise<void> {
+    if (this.unlistenEvent && this.unlistenExit) return
+    if (this.listenersPromise) return this.listenersPromise
+    this.listenersPromise = (async () => {
+      this.unlistenEvent = await listen<EventPayload>("codex-app-server:event", (event) => {
+        const payload = event.payload
+        if (!payload || payload.generation !== this.generation) return
+        this.handleLine(payload.line)
+      })
+      this.unlistenExit = await listen<ExitPayload>("codex-app-server:exit", (event) => {
+        if (event.payload?.generation !== this.generation) return
+        this.generation = null
+        this.initializedGeneration = null
+        this.cwd = ""
+        const error = new Error(APP_SERVER_EXIT_MESSAGE)
+        this.rejectAll(error)
+        for (const handler of this.handlers.values()) {
+          handler.onEnvelope?.({ method: "qmai/app-server-exit", params: { message: error.message } })
+        }
+        this.handlers.clear()
+      })
+    })().finally(() => {
+      this.listenersPromise = null
+    })
+    return this.listenersPromise
+  }
+
+  private rawCall<T>(
+    method: string,
+    params?: Record<string, unknown>,
+    timeoutMs = DEFAULT_RPC_TIMEOUT_MS,
+  ): Promise<T> {
+    const id = this.nextId++
+    return new Promise<T>((resolve, reject) => {
+      const timer = setTimeout(() => {
+        this.pending.delete(id)
+        reject(new Error(`Codex app-server 调用超时:${method}`))
+      }, timeoutMs)
+      this.pending.set(id, {
+        resolve: (value) => resolve(value as T),
+        reject,
+        timer,
+      })
+      void this.send({
+        jsonrpc: "2.0",
+        id,
+        method,
+        ...(params ? { params } : {}),
+      }).catch((error) => {
+        const pending = this.pending.get(id)
+        if (!pending) return
+        clearTimeout(pending.timer)
+        this.pending.delete(id)
+        pending.reject(rpcError(error, `Codex app-server 写入失败:${method}`))
+      })
+    })
+  }
+
+  private async send(envelope: CodexAppServerEnvelope): Promise<void> {
+    if (this.generation === null) throw new Error("Codex app-server 未启动")
+    await invoke("codex_app_server_write", {
+      generation: this.generation,
+      data: JSON.stringify(envelope),
+    })
+  }
+
+  private handleLine(line: string): void {
+    let envelope: CodexAppServerEnvelope
+    try {
+      envelope = JSON.parse(line) as CodexAppServerEnvelope
+    } catch {
+      return
+    }
+
+    if (envelope.id !== undefined && !envelope.method) {
+      const pending = this.pending.get(envelope.id)
+      if (!pending) return
+      clearTimeout(pending.timer)
+      this.pending.delete(envelope.id)
+      if (envelope.error) {
+        pending.reject(new Error(envelope.error.message || "Codex app-server 调用失败"))
+      } else {
+        pending.resolve(envelope.result)
+      }
+      return
+    }
+
+    const threadId = typeof envelope.params?.threadId === "string"
+      ? envelope.params.threadId
+      : null
+    const handler = threadId ? this.handlers.get(threadId) : undefined
+    handler?.onEnvelope?.(envelope)
+
+    if (envelope.id === undefined || !envelope.method) return
+    if (envelope.method === "item/tool/call" && handler?.onDynamicToolCall) {
+      void handler.onDynamicToolCall(envelope.params as unknown as DynamicToolCallRequest)
+        .then((result) => this.send({ jsonrpc: "2.0", id: envelope.id!, result }))
+        .catch((error) => this.send({
+          jsonrpc: "2.0",
+          id: envelope.id!,
+          result: {
+            contentItems: [{ type: "inputText", text: rpcError(error, "QMAI 工具执行失败").message }],
+            success: false,
+          },
+        }))
+        .catch(() => undefined)
+      return
+    }
+
+    void this.send({
+      jsonrpc: "2.0",
+      id: envelope.id,
+      error: {
+        code: -32601,
+        message: `QMAI 禁止 Codex 原生能力:${envelope.method}`,
+      },
+    }).catch(() => undefined)
+  }
+
+  private rejectAll(error: Error): void {
+    for (const pending of this.pending.values()) {
+      clearTimeout(pending.timer)
+      pending.reject(error)
+    }
+    this.pending.clear()
+  }
+}
+
+let sharedClient: CodexAppServerClient | null = null
+
+export function getCodexAppServerClient(): CodexAppServerClient {
+  if (!sharedClient) sharedClient = new CodexAppServerClient()
+  return sharedClient
+}
+
+export function resetCodexAppServerClientForTests(): void {
+  sharedClient = null
+}

+ 25 - 0
src/lib/codex-cli-model.spec.ts

@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest"
+import {
+  CODEX_CLI_SUGGESTED_MODELS,
+  DEFAULT_CODEX_CLI_MODEL,
+  migrateLegacyDefaultCodexCliModel,
+} from "./codex-cli-model"
+
+describe("Codex CLI model defaults", () => {
+  it("uses the current 5.6 family and maps the old mini role to Terra", () => {
+    expect(DEFAULT_CODEX_CLI_MODEL).toBe("gpt-5.6-terra")
+    expect(CODEX_CLI_SUGGESTED_MODELS).toEqual([
+      "gpt-5.6-terra",
+      "gpt-5.6-sol",
+      "gpt-5.6-luna",
+    ])
+  })
+
+  it("migrates only the missing or exact legacy default", () => {
+    expect(migrateLegacyDefaultCodexCliModel(undefined)).toBe("gpt-5.6-terra")
+    expect(migrateLegacyDefaultCodexCliModel("  ")).toBe("gpt-5.6-terra")
+    expect(migrateLegacyDefaultCodexCliModel("gpt-5.4-mini")).toBe("gpt-5.6-terra")
+    expect(migrateLegacyDefaultCodexCliModel("gpt-5.4")).toBe("gpt-5.4")
+    expect(migrateLegacyDefaultCodexCliModel(" gpt-5.6-sol ")).toBe("gpt-5.6-sol")
+  })
+})

+ 16 - 0
src/lib/codex-cli-model.ts

@@ -0,0 +1,16 @@
+export const DEFAULT_CODEX_CLI_MODEL = "gpt-5.6-terra"
+export const LEGACY_DEFAULT_CODEX_CLI_MODEL = "gpt-5.4-mini"
+
+export const CODEX_CLI_SUGGESTED_MODELS = [
+  DEFAULT_CODEX_CLI_MODEL,
+  "gpt-5.6-sol",
+  "gpt-5.6-luna",
+] as const
+
+export function migrateLegacyDefaultCodexCliModel(model: string | undefined): string {
+  const normalized = model?.trim() ?? ""
+  if (!normalized || normalized === LEGACY_DEFAULT_CODEX_CLI_MODEL) {
+    return DEFAULT_CODEX_CLI_MODEL
+  }
+  return normalized
+}

+ 24 - 0
src/lib/codex-cli-timeout.spec.ts

@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest"
+import {
+  DEFAULT_CODEX_CLI_TIMEOUT_MINUTES,
+  migrateLegacyCodexCliTimeoutMinutes,
+  resolveCodexCliTimeoutMinutes,
+} from "./codex-cli-timeout"
+
+describe("Codex CLI timeout", () => {
+  it("defaults missing values to 40 minutes", () => {
+    expect(DEFAULT_CODEX_CLI_TIMEOUT_MINUTES).toBe(40)
+    expect(resolveCodexCliTimeoutMinutes(undefined)).toBe(40)
+  })
+
+  it("keeps explicit runtime values within the supported range", () => {
+    expect(resolveCodexCliTimeoutMinutes(20)).toBe(20)
+    expect(resolveCodexCliTimeoutMinutes(300)).toBe(240)
+  })
+
+  it("lifts legacy values below 40 once without lowering larger values", () => {
+    expect(migrateLegacyCodexCliTimeoutMinutes(undefined)).toBe(40)
+    expect(migrateLegacyCodexCliTimeoutMinutes(20)).toBe(40)
+    expect(migrateLegacyCodexCliTimeoutMinutes(60)).toBe(60)
+  })
+})

+ 18 - 0
src/lib/codex-cli-timeout.ts

@@ -0,0 +1,18 @@
+export const DEFAULT_CODEX_CLI_TIMEOUT_MINUTES = 40
+export const MAX_CODEX_CLI_TIMEOUT_MINUTES = 240
+
+export function resolveCodexCliTimeoutMinutes(value: number | undefined): number {
+  if (!Number.isFinite(value)) return DEFAULT_CODEX_CLI_TIMEOUT_MINUTES
+  return Math.max(1, Math.min(MAX_CODEX_CLI_TIMEOUT_MINUTES, Math.floor(value as number)))
+}
+
+/**
+ * One-time persisted-config migration. Old releases defaulted to 10 minutes,
+ * and some existing users saved 20 minutes explicitly. Lift every legacy
+ * value below the new default once; after the migration marker is written,
+ * later deliberate user reductions remain untouched.
+ */
+export function migrateLegacyCodexCliTimeoutMinutes(value: number | undefined): number {
+  const resolved = resolveCodexCliTimeoutMinutes(value)
+  return Math.max(DEFAULT_CODEX_CLI_TIMEOUT_MINUTES, resolved)
+}

+ 155 - 0
src/lib/codex-cli-transport.spec.ts

@@ -0,0 +1,155 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+
+const clientMock = vi.hoisted(() => ({
+  call: vi.fn(),
+  handler: null as null | { onEnvelope?: (envelope: Record<string, unknown>) => void },
+  interrupt: vi.fn(async () => undefined),
+}))
+
+vi.mock("./codex-app-server-client", () => ({
+  getCodexAppServerClient: () => ({
+    isolatedCwd: "/tmp/qmai/workspace",
+    ensureStarted: vi.fn(async () => undefined),
+    call: clientMock.call,
+    interrupt: clientMock.interrupt,
+    registerThread: (_threadId: string, handler: typeof clientMock.handler) => {
+      clientMock.handler = handler
+      return () => {
+        clientMock.handler = null
+      }
+    },
+  }),
+}))
+
+import {
+  buildCodexTurnInput,
+  codexNativeBoundaryError,
+  restrictedCodexConfig,
+  streamCodexCli,
+} from "./codex-cli-transport"
+
+const config: LlmConfig = {
+  provider: "codex-cli",
+  apiKey: "",
+  model: "gpt-test",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 8192,
+  codexCliTimeoutMinutes: 10,
+}
+
+describe("codex app-server transport", () => {
+  beforeEach(() => {
+    clientMock.call.mockReset()
+    clientMock.handler = null
+    clientMock.interrupt.mockClear()
+  })
+
+  it("disables native extension, shell, web, image, and project-rule surfaces", () => {
+    const restricted = restrictedCodexConfig()
+    expect(restricted).toEqual(expect.objectContaining({
+      web_search: "disabled",
+      project_doc_max_bytes: 0,
+      project_root_markers: [],
+      tools: { view_image: false, web_search: false },
+      features: expect.objectContaining({
+        apps: false,
+        plugins: false,
+        shell_tool: false,
+        multi_agent: false,
+        skill_search: false,
+      }),
+    }))
+  })
+
+  it("serializes trimmed chat history and images into turn input", () => {
+    const input = buildCodexTurnInput([
+      { role: "system", content: "system" },
+      { role: "user", content: [
+        { type: "text", text: "看图" },
+        { type: "image", mediaType: "image/png", dataBase64: "YWJj" },
+      ] },
+      { role: "assistant", content: "收到" },
+    ])
+    expect(input[0].text).toContain("<USER>\n看图")
+    expect(input[0].text).toContain("<ASSISTANT>\n收到")
+    expect(input).toContainEqual({ type: "image", url: "data:image/png;base64,YWJj" })
+  })
+
+  it("allows only QMAI dynamic tool server requests and rejects native MCP notifications", () => {
+    const dynamic = { id: 1, method: "item/tool/call", params: {} }
+    expect(codexNativeBoundaryError(dynamic, true)).toBeNull()
+    expect(codexNativeBoundaryError(dynamic)).toEqual(expect.objectContaining({
+      message: expect.stringContaining("禁止 Codex 原生能力"),
+    }))
+    expect(codexNativeBoundaryError({
+      method: "mcpServer/startupStatus/updated",
+      params: {},
+    })).toEqual(expect.objectContaining({
+      message: expect.stringContaining("mcpServer/startupStatus/updated"),
+    }))
+    expect(codexNativeBoundaryError({
+      method: "hook/started",
+      params: {},
+    })).toEqual(expect.objectContaining({
+      message: expect.stringContaining("hook/started"),
+    }))
+  })
+
+  it("uses app-server for plain streamChat without exposing dynamic tools", async () => {
+    clientMock.call.mockImplementation(async (method: string, params: Record<string, unknown>) => {
+      if (method === "thread/start") {
+        expect(params).not.toHaveProperty("dynamicTools")
+        return { thread: { id: "thread-text" }, instructionSources: [] }
+      }
+      if (method === "turn/start") {
+        queueMicrotask(() => {
+          clientMock.handler?.onEnvelope?.({
+            method: "item/agentMessage/delta",
+            params: { threadId: "thread-text", delta: "纯文本结果" },
+          })
+          clientMock.handler?.onEnvelope?.({
+            method: "turn/completed",
+            params: { threadId: "thread-text", turn: { status: "completed" } },
+          })
+        })
+        return { turn: { id: "turn-text" } }
+      }
+      throw new Error(`unexpected method: ${method}`)
+    })
+    const callbacks = {
+      onToken: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+
+    await streamCodexCli(config, [
+      { role: "system", content: "只输出正文" },
+      { role: "user", content: "生成大纲" },
+    ], callbacks)
+
+    expect(callbacks.onToken).toHaveBeenCalledWith("纯文本结果")
+    expect(callbacks.onDone).toHaveBeenCalledOnce()
+    expect(callbacks.onError).not.toHaveBeenCalled()
+  })
+
+  it("fails closed when app-server reports inherited instruction sources", async () => {
+    clientMock.call.mockResolvedValueOnce({
+      thread: { id: "thread-bad" },
+      instructionSources: ["/Users/test/.codex/AGENTS.md"],
+    })
+    const callbacks = {
+      onToken: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+
+    await streamCodexCli(config, [{ role: "user", content: "测试" }], callbacks)
+
+    expect(callbacks.onError).toHaveBeenCalledWith(expect.objectContaining({
+      message: expect.stringContaining("禁止 Codex 加载本机或项目规则"),
+    }))
+    expect(callbacks.onDone).not.toHaveBeenCalled()
+  })
+})

+ 219 - 209
src/lib/codex-cli-transport.ts

@@ -1,95 +1,122 @@
-/**
- * Codex CLI subprocess transport.
- *
- * Rust-side counterpart: src-tauri/src/commands/codex_cli.rs. The Rust
- * command spawns `codex exec --json`, sends a single reconstructed prompt
- * over stdin, and emits each JSONL stdout line back as `codex-cli:{streamId}`.
- */
-
-import { invoke } from "@tauri-apps/api/core"
-import { listen, type UnlistenFn } from "@tauri-apps/api/event"
 import type { LlmConfig } from "@/stores/wiki-store"
+import { resolveCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
 import type { ChatMessage, ContentBlock, RequestOverrides } from "./llm-providers"
 import type { StreamCallbacks } from "./llm-client"
+import { getCodexAppServerClient, type CodexAppServerEnvelope } from "./codex-app-server-client"
 
-export function parseCodexCliLine(rawLine: string): string | null {
-  const line = rawLine.trim()
-  if (!line) return null
+interface ThreadStartResponse {
+  thread: { id: string }
+  instructionSources?: string[]
+}
 
-  let evt: unknown
-  try {
-    evt = JSON.parse(line)
-  } catch {
-    return null
-  }
+interface TurnStartResponse {
+  turn: { id: string }
+}
 
-  if (!evt || typeof evt !== "object") return null
-  const obj = evt as Record<string, unknown>
-  if (obj.type !== "item.completed") return null
+const NATIVE_ITEM_TYPES = new Set([
+  "commandExecution",
+  "fileChange",
+  "mcpToolCall",
+  "collabAgentToolCall",
+  "subAgentActivity",
+  "webSearch",
+  "imageGeneration",
+  "hookPrompt",
+])
 
-  const item = obj.item as Record<string, unknown> | undefined
-  if (item?.type !== "agent_message") return null
-  return typeof item.text === "string" && item.text.length > 0 ? item.text : null
-}
+const NATIVE_METHOD_PREFIXES = [
+  "app/",
+  "command/",
+  "environment/",
+  "fs/",
+  "hook/",
+  "mcpServer/",
+  "plugin/",
+  "process/",
+  "skills/",
+  "thread/backgroundTerminals/",
+]
 
-export function extractCodexCliError(rawOutput: string): string {
-  let lastError = ""
-  for (const line of rawOutput.split(/\r?\n/)) {
-    const trimmed = line.trim()
-    if (!trimmed) continue
-    try {
-      const parsed = JSON.parse(trimmed) as {
-        type?: string
-        message?: unknown
-        error?: { message?: unknown }
-      }
-      const message = typeof parsed.error?.message === "string"
-        ? parsed.error.message
-        : typeof parsed.message === "string"
-          ? parsed.message
-          : ""
-      if (parsed.type === "turn.failed" && message) return message
-      if (parsed.type === "error" && message && !/^Reconnecting\.\.\./i.test(message)) {
-        lastError = message
-      }
-    } catch {
-      // Keep the original output as fallback below.
-    }
+export function restrictedCodexConfig(): Record<string, unknown> {
+  return {
+    features: {
+      apps: false,
+      browser_use: false,
+      browser_use_external: false,
+      browser_use_full_cdp_access: false,
+      computer_use: false,
+      image_generation: false,
+      in_app_browser: false,
+      multi_agent: false,
+      multi_agent_v2: false,
+      plugins: false,
+      remote_plugin: false,
+      shell_snapshot: false,
+      shell_tool: false,
+      skill_mcp_dependency_install: false,
+      skill_search: false,
+    },
+    web_search: "disabled",
+    project_doc_max_bytes: 0,
+    project_doc_fallback_filenames: [],
+    project_root_markers: [],
+    tools: {
+      view_image: false,
+      web_search: false,
+    },
   }
-  return lastError || rawOutput.trim()
 }
 
-function contentToText(content: string | ContentBlock[]): string {
-  if (typeof content === "string") return content
-  return content
-    .map((block) => {
-      if (block.type === "text") return block.text
-      return `[Image omitted: ${block.mediaType}]`
-    })
-    .join("\n")
+export function codexReasoningEffort(config: LlmConfig): string | null {
+  const mode = config.reasoning?.mode
+  if (!mode || mode === "auto" || mode === "off" || mode === "custom") return null
+  return mode
 }
 
-function escapePromptContent(text: string): string {
-  return text.replace(/<\/?[A-Z_][A-Z0-9_]*>/gi, (tag) =>
-    tag.replace(/</g, "&lt;").replace(/>/g, "&gt;"),
-  )
+function contentText(content: string | ContentBlock[]): string {
+  if (typeof content === "string") return content
+  return content.filter((block) => block.type === "text").map((block) => block.text).join("\n")
 }
 
-export function buildPrompt(messages: ChatMessage[]): string {
-  return messages
-    .map((message) => {
-      const role = message.role.toUpperCase()
-      return `<${role}>\n${escapePromptContent(contentToText(message.content))}\n</${role}>`
-    })
+export function buildCodexTurnInput(messages: ChatMessage[]): Array<Record<string, unknown>> {
+  const nonSystem = messages.filter((message) => message.role !== "system")
+  const text = nonSystem
+    .map((message) => `<${message.role.toUpperCase()}>\n${contentText(message.content)}\n</${message.role.toUpperCase()}>`)
     .join("\n\n")
+  const input: Array<Record<string, unknown>> = [{ type: "text", text, text_elements: [] }]
+  for (const message of nonSystem) {
+    if (!Array.isArray(message.content)) continue
+    for (const block of message.content) {
+      if (block.type !== "image") continue
+      input.push({
+        type: "image",
+        url: `data:${block.mediaType};base64,${block.dataBase64}`,
+      })
+    }
+  }
+  return input
 }
 
-type SpawnPayload = Record<string, unknown> & {
-  streamId: string
-  model: string
-  prompt: string
-  isolateLocalConfig: boolean
-  timeoutMinutes?: number
+export function codexNativeBoundaryError(
+  envelope: CodexAppServerEnvelope,
+  allowDynamicTools = false,
+): Error | null {
+  if (envelope.method === "qmai/app-server-exit") {
+    return new Error(String(envelope.params?.message || "Codex app-server 已退出"))
+  }
+  if (envelope.method && NATIVE_METHOD_PREFIXES.some((prefix) => envelope.method!.startsWith(prefix))) {
+    return new Error(`QMAI 禁止 Codex 原生能力:${envelope.method}`)
+  }
+  if (envelope.id !== undefined && envelope.method && !(allowDynamicTools && envelope.method === "item/tool/call")) {
+    return new Error(`QMAI 禁止 Codex 原生能力:${envelope.method}`)
+  }
+  if (envelope.method !== "item/started" && envelope.method !== "item/completed") return null
+  const item = envelope.params?.item
+  if (!item || typeof item !== "object") return null
+  const type = (item as Record<string, unknown>).type
+  return typeof type === "string" && NATIVE_ITEM_TYPES.has(type)
+    ? new Error(`QMAI 禁止 Codex 原生能力:${type}`)
+    : null
 }
 
 export async function streamCodexCli(
@@ -97,157 +124,140 @@ export async function streamCodexCli(
   messages: ChatMessage[],
   callbacks: StreamCallbacks,
   signal?: AbortSignal,
-  overrides?: RequestOverrides,
+  _overrides?: RequestOverrides,
 ): Promise<void> {
-  const { onToken, onDone, onError } = callbacks
-
-  if (import.meta.env?.DEV && overrides) {
-    for (const key of ["temperature", "top_p", "top_k", "max_tokens", "stop"] as const) {
-      if (overrides[key] !== undefined) {
-        // eslint-disable-next-line no-console
-        console.warn(`[codex-cli] ignoring unsupported override "${key}": CLI has no equivalent flag`)
-      }
-    }
-  }
-
-  const streamId = crypto.randomUUID()
-  let unlistenData: UnlistenFn | (() => void) | undefined
-  let unlistenDone: UnlistenFn | (() => void) | undefined
+  const client = getCodexAppServerClient()
+  let threadId = ""
+  let turnId = ""
   let finished = false
-  let aborted = signal?.aborted ?? false
-  let emittedAgentMessage = false
-  let resolveCompletion: () => void = () => {}
-  const completion = new Promise<void>((resolve) => {
-    resolveCompletion = resolve
+  let emittedText = false
+  const agentMessagePhases = new Map<string, string | null>()
+  let unregister = () => {}
+  let resolveTurn = () => {}
+  let rejectTurn = (_error: Error) => {}
+  const turnDone = new Promise<void>((resolve, reject) => {
+    resolveTurn = resolve
+    rejectTurn = reject
   })
+  const timeoutMinutes = resolveCodexCliTimeoutMinutes(config.codexCliTimeoutMinutes)
+  const timeoutMs = timeoutMinutes * 60_000
+  const timeout = setTimeout(() => {
+    if (!finished) {
+      if (threadId && turnId) void client.interrupt(threadId, turnId)
+      fail(new Error(`Codex app-server 超时(${timeoutMinutes} 分钟)`))
+    }
+  }, timeoutMs)
 
-  const unparsedLines: string[] = []
-  let unparsedSize = 0
-  function captureUnparsed(line: string) {
-    if (unparsedSize >= 4096) return
-    const trimmed = line.trim()
-    if (!trimmed) return
-    unparsedLines.push(line)
-    unparsedSize += line.length + 1
-  }
-
-  const cleanup = () => {
-    unlistenData?.()
-    unlistenDone?.()
-  }
-
-  const finishWith = (cb: () => void) => {
+  const fail = (error: Error) => {
     if (finished) return
     finished = true
-    cleanup()
-    cb()
-    resolveCompletion()
-  }
-
-  const replayAgentMessagesFromStdout = (stdout: string | undefined) => {
-    if (!stdout) return
-
-    for (const line of stdout.split(/\r?\n/)) {
-      const token = parseCodexCliLine(line)
-      if (token !== null) {
-        emittedAgentMessage = true
-        onToken(token)
-      }
-    }
-  }
-
-  const abortListener = () => {
-    aborted = true
-    void invoke("codex_cli_kill", { streamId }).catch(() => {})
-    finishWith(onDone)
+    rejectTurn(error)
   }
-  if (aborted) {
-    finishWith(onDone)
-    return
-  }
-  signal?.addEventListener("abort", abortListener)
 
   try {
-    // ── Tauri mode: use Tauri listen + invoke ──
-    unlistenData = await listen<string>(`codex-cli:${streamId}`, (event) => {
-      const token = parseCodexCliLine(event.payload)
-      if (token !== null) {
-        emittedAgentMessage = true
-        onToken(token)
-      } else {
-        captureUnparsed(event.payload)
-      }
+    await client.ensureStarted()
+    const systemPrompt = messages
+      .filter((message) => message.role === "system")
+      .map((message) => contentText(message.content))
+      .join("\n\n")
+    const started = await client.call<ThreadStartResponse>("thread/start", {
+      model: config.model.trim() || null,
+      cwd: client.isolatedCwd,
+      approvalPolicy: "never",
+      sandbox: "read-only",
+      ephemeral: true,
+      baseInstructions: systemPrompt || "You are QMAI's text generation model.",
+      developerInstructions: "Only produce the requested text. Never use native tools, shell, file changes, MCP, plugins, skills, apps, browser, or subagents.",
+      config: restrictedCodexConfig(),
     })
-    if (aborted || finished) {
-      cleanup()
-      return
+    if (started.instructionSources?.length) {
+      throw new Error(`QMAI 禁止 Codex 加载本机或项目规则:${started.instructionSources.join(", ")}`)
     }
-
-    unlistenDone = await listen<{ code: number | null; stderr: string; stdout?: string }>(
-      `codex-cli:${streamId}:done`,
-      (event) => {
-        const code = event.payload?.code
-        const stderr = event.payload?.stderr?.trim() ?? ""
-        const stdout = event.payload?.stdout ?? ""
-        if (code !== null && code !== undefined && code !== 0) {
-          const details = stderr || extractCodexCliError(stdout) || extractCodexCliError(unparsedLines.join("\n"))
-          finishWith(() =>
-            onError(new Error(
-              details
-                ? `Codex CLI exited with code ${code}:\n${details}`
-                : `Codex CLI exited with code ${code}. Run \`codex\` in a terminal to inspect the problem.`,
-            )),
-          )
-        } else {
-          if (!emittedAgentMessage) replayAgentMessagesFromStdout(stdout)
-          if (!emittedAgentMessage) {
-            const details = stdout.trim() || unparsedLines.join("\n").trim()
-            finishWith(() =>
-              onError(new Error(
-                details
-                  ? `Codex CLI completed but did not emit an agent_message. Raw output:\n${details}`
-                  : "Codex CLI completed but did not emit an agent_message. Run `codex exec --json` in a terminal to inspect the provider output.",
-              )),
-            )
-          } else {
-            finishWith(onDone)
+    threadId = started.thread.id
+    unregister = client.registerThread(threadId, {
+      onEnvelope: (envelope) => {
+        const boundaryError = codexNativeBoundaryError(envelope)
+        if (boundaryError) {
+          fail(boundaryError)
+          if (turnId) void client.interrupt(threadId, turnId)
+          return
+        }
+        if (envelope.method === "item/started") {
+          const item = envelope.params?.item as Record<string, unknown> | undefined
+          if (item?.type === "agentMessage" && typeof item.id === "string") {
+            agentMessagePhases.set(item.id, typeof item.phase === "string" ? item.phase : null)
+          }
+        } else if (envelope.method === "item/agentMessage/delta") {
+          const delta = envelope.params?.delta
+          const itemId = typeof envelope.params?.itemId === "string" ? envelope.params.itemId : ""
+          if (typeof delta === "string" && delta && agentMessagePhases.get(itemId) !== "commentary") {
+            emittedText = true
+            callbacks.onToken(delta)
+          }
+        } else if (
+          envelope.method === "item/reasoning/summaryTextDelta" ||
+          envelope.method === "item/reasoning/textDelta"
+        ) {
+          const delta = envelope.params?.delta
+          if (typeof delta === "string" && delta) callbacks.onReasoningToken?.(delta)
+        } else if (envelope.method === "thread/tokenUsage/updated") {
+          const last = (envelope.params?.tokenUsage as Record<string, unknown> | undefined)?.last as Record<string, unknown> | undefined
+          if (last) {
+            callbacks.onUsage?.({
+              inputTokens: Number(last.inputTokens) || 0,
+              outputTokens: Number(last.outputTokens) || 0,
+              totalTokens: Number(last.totalTokens) || 0,
+              cachedInputTokens: Number(last.cachedInputTokens) || 0,
+              cacheWriteInputTokens: Number(last.cacheWriteInputTokens) || 0,
+            })
+          }
+        } else if (envelope.method === "item/completed" && !emittedText) {
+          const item = envelope.params?.item as Record<string, unknown> | undefined
+          if (item?.type === "agentMessage" && item.phase !== "commentary" && typeof item.text === "string") {
+            emittedText = true
+            callbacks.onToken(item.text)
+          }
+        } else if (envelope.method === "turn/completed") {
+          const turn = envelope.params?.turn as Record<string, unknown> | undefined
+          if (turn?.status === "failed") {
+            const error = turn.error as Record<string, unknown> | undefined
+            fail(new Error(String(error?.message || "Codex app-server turn 失败")))
+          } else if (turn?.status === "interrupted") {
+            fail(new Error("操作已取消"))
+          } else if (!finished) {
+            finished = true
+            resolveTurn()
           }
         }
       },
-    )
-    if (aborted || finished) {
-      cleanup()
-      return
-    }
-
-    const payload: SpawnPayload = {
-      streamId,
-      model: config.model,
-      prompt: buildPrompt(messages),
-      isolateLocalConfig: config.localCliIsolation === true,
-      timeoutMinutes: config.codexCliTimeoutMinutes,
+    })
+    const turn = await client.call<TurnStartResponse>("turn/start", {
+      threadId,
+      input: buildCodexTurnInput(messages),
+      cwd: client.isolatedCwd,
+      approvalPolicy: "never",
+      sandboxPolicy: { type: "readOnly", networkAccess: false },
+      model: config.model.trim() || null,
+      effort: codexReasoningEffort(config),
+    })
+    turnId = turn.turn.id
+    if (finished) void client.interrupt(threadId, turnId)
+    const abort = () => {
+      if (turnId) void client.interrupt(threadId, turnId)
+      fail(new Error("操作已取消"))
     }
-    await invoke("codex_cli_spawn", payload)
-
-    if (aborted || signal?.aborted) {
-      aborted = true
-      await invoke("codex_cli_kill", { streamId }).catch(() => {})
-      finishWith(onDone)
-      return
+    signal?.addEventListener("abort", abort, { once: true })
+    if (signal?.aborted) abort()
+    try {
+      await turnDone
+    } finally {
+      signal?.removeEventListener("abort", abort)
     }
-    await completion
-  } catch (err) {
-    finishWith(() => {
-      const message = err instanceof Error ? err.message : String(err)
-      if (/not found|No such file|executable file not found/i.test(message)) {
-        onError(new Error(
-          "Codex CLI not found. Install `codex` with `npm install -g @openai/codex` or pick a different provider.",
-        ))
-      } else {
-        onError(err instanceof Error ? err : new Error(message))
-      }
-    })
+    callbacks.onDone()
+  } catch (error) {
+    callbacks.onError(error instanceof Error ? error : new Error(String(error)))
   } finally {
-    signal?.removeEventListener("abort", abortListener)
+    clearTimeout(timeout)
+    unregister()
   }
 }

+ 29 - 0
src/lib/context-hub/data-source-cache.spec.ts

@@ -76,6 +76,35 @@ describe("DataSourceCacheAdapter", () => {
     ])
   })
 
+  it("uses a versioned key for chapter outlines so stale wrong-chapter artifacts are not reused", async () => {
+    const harness = createHarness()
+    const source: DataSource<string> = { name: "chapterOutline", priority: 1, load: async () => "" }
+
+    await harness.adapter.load(source, context, async () => "第2章章纲")
+
+    expect(harness.storage.readArtifact).toHaveBeenCalledWith(
+      expect.stringMatching(/^data-source:chapterOutline:v3:/),
+    )
+    expect(harness.storage.writeArtifact).toHaveBeenCalledWith(
+      expect.stringMatching(/^data-source:chapterOutline:v3:/),
+      expect.objectContaining({ sourceName: "chapterOutline", value: "第2章章纲" }),
+    )
+  })
+
+  it("includes task text in section briefing cache keys for on-demand character selection", async () => {
+    const harness = createHarness()
+    const source: DataSource<string> = { name: "sectionBriefing", priority: 1, load: async () => "" }
+    const directLoad = vi.fn(async () => "人物速记")
+
+    await harness.adapter.load(source, context, directLoad)
+    await harness.adapter.load(source, { ...context, task: "改由另一个人物处理" }, directLoad)
+
+    expect(directLoad).toHaveBeenCalledTimes(2)
+    expect(harness.storage.readArtifact.mock.calls[0]?.[0]).not.toBe(
+      harness.storage.readArtifact.mock.calls[1]?.[0],
+    )
+  })
+
   it("marks empty values as empty and does not count them as reloaded", async () => {
     const harness = createHarness()
     const source: DataSource<string> = { name: "outline", priority: 1, load: async () => "" }

+ 12 - 2
src/lib/context-hub/data-source-cache.ts

@@ -62,10 +62,18 @@ const CHAPTER_SCOPED_SOURCES = new Set([
   "fallbackTimeline",
   "revisionFeedback",
   "cognitionText",
-  "sectionBriefing",
   "retrieval",
 ])
 
+// Bump only the affected data source when its extraction semantics change.
+// This prevents a previously cached wrong-chapter outline from surviving the fix.
+const SOURCE_CACHE_VERSIONS: Partial<Record<string, number>> = {
+  outline: 2,
+  chapterOutline: 3,
+  volumeContext: 2,
+  sectionBriefing: 2,
+}
+
 function canonicalize(value: unknown): unknown {
   if (Array.isArray(value)) return value.map(canonicalize)
   if (!value || typeof value !== "object") return value
@@ -82,7 +90,9 @@ async function sourceRequestKey(sourceName: string, context: ContextLoadContext)
     : CHAPTER_SCOPED_SOURCES.has(sourceName)
       ? { chapterNumber: context.chapterNumber ?? null, config: context.config }
       : { task: context.task, chapterNumber: context.chapterNumber ?? null, config: context.config }
-  return `data-source:${sourceName}:${await sha256Text(JSON.stringify(canonicalize(scope)))}`
+  const version = SOURCE_CACHE_VERSIONS[sourceName]
+  const versionSuffix = version ? `:v${version}` : ""
+  return `data-source:${sourceName}${versionSuffix}:${await sha256Text(JSON.stringify(canonicalize(scope)))}`
 }
 
 function dependencyStampsMatch(cached: DependencyStamp, current: DependencyStamp): boolean {

+ 97 - 0
src/lib/context-hub/normalize-stats.spec.ts

@@ -101,6 +101,103 @@ describe("parseContextHubSnapshot", () => {
     expect(snapshot?.stats.cacheHits).toBe(1)
     expect(snapshot?.items[0]?.status).toBe("cache_hit")
   })
+
+  it("keeps valid request traces and drops damaged optional trace entries", () => {
+    const snapshot = parseContextHubSnapshot({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      id: "assistant:trace",
+      surface: "ai-chat",
+      createdAt: 10,
+      stats: {
+        ...currentStats,
+        requestDiagnostics: {
+          requestCount: 2,
+          providerUsageAvailable: true,
+          requests: [
+            {
+              provider: "openai",
+              model: "gpt-test",
+              apiMode: "chat_completions",
+              startedAt: 1,
+              finishedAt: 2,
+              durationMs: 1,
+              status: "success",
+              prompt: "不应保留",
+            },
+            {
+              provider: "openai",
+              model: "gpt-test",
+              apiMode: "chat_completions",
+              startedAt: 1,
+              finishedAt: 2,
+              durationMs: -1,
+              status: "success",
+              prompt: "不应保留",
+            },
+          ],
+          omittedRequestCount: 3,
+        },
+      },
+      items: [],
+      stableCore: "stable",
+      sessionSummary: "",
+      dynamicContext: "dynamic",
+    })
+
+    expect(snapshot?.stats.requestDiagnostics?.requests).toHaveLength(1)
+    expect(snapshot?.stats.requestDiagnostics?.omittedRequestCount).toBe(3)
+    expect(snapshot?.stats.requestDiagnostics?.requests?.[0]).not.toHaveProperty("prompt")
+  })
+
+  it("keeps valid aggregate scope metadata and drops invalid optional values", () => {
+    const valid = parseContextHubSnapshot({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      id: "assistant:codex-usage",
+      surface: "ai-chat",
+      createdAt: 10,
+      stats: {
+        ...currentStats,
+        requestDiagnostics: {
+          requestCount: 0,
+          requestCountAvailable: false,
+          usageScope: "provider_thread",
+          providerUsageAvailable: true,
+          inputTokens: 100,
+        },
+      },
+      items: [],
+      stableCore: "stable",
+      sessionSummary: "",
+      dynamicContext: "dynamic",
+    })
+    const damaged = parseContextHubSnapshot({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      id: "assistant:damaged-usage-scope",
+      surface: "ai-chat",
+      createdAt: 11,
+      stats: {
+        ...currentStats,
+        requestDiagnostics: {
+          requestCount: 1,
+          requestCountAvailable: "no",
+          usageScope: "single_request",
+          providerUsageAvailable: true,
+          inputTokens: 100,
+        },
+      },
+      items: [],
+      stableCore: "stable",
+      sessionSummary: "",
+      dynamicContext: "dynamic",
+    })
+
+    expect(valid?.stats.requestDiagnostics).toMatchObject({
+      requestCountAvailable: false,
+      usageScope: "provider_thread",
+    })
+    expect(damaged?.stats.requestDiagnostics).not.toHaveProperty("requestCountAvailable")
+    expect(damaged?.stats.requestDiagnostics).not.toHaveProperty("usageScope")
+  })
 })
 
 describe("parseContextHubSnapshotRef", () => {

+ 61 - 0
src/lib/context-hub/provider-usage.spec.ts

@@ -26,6 +26,67 @@ const baseStats: ContextHubStats = {
 }
 
 describe("context hub provider usage", () => {
+  it("marks provider-thread totals without inventing an internal request count", () => {
+    const diagnostics = buildLlmRequestDiagnostics(
+      {
+        inputTokens: 3_676_375,
+        outputTokens: 7_926,
+        cachedInputTokens: 3_533_312,
+      },
+      1,
+      {
+        requestCountAvailable: false,
+        usageScope: "provider_thread",
+      },
+    )
+
+    expect(diagnostics).toMatchObject({
+      requestCount: 0,
+      requestCountAvailable: false,
+      usageScope: "provider_thread",
+      providerUsageAvailable: true,
+      inputTokens: 3_676_375,
+      cacheReadTokens: 3_533_312,
+    })
+  })
+
+  it("uses actual traced attempts for request count and preserves traces while merging usage", () => {
+    const diagnostics = buildLlmRequestDiagnostics(
+      { inputTokens: 100 },
+      1,
+      {
+        requests: [{
+          provider: "openai",
+          model: "gpt-test",
+          apiMode: "chat_completions",
+          startedAt: 1,
+          finishedAt: 2,
+          durationMs: 1,
+          status: "error",
+        }, {
+          provider: "openai",
+          model: "gpt-test",
+          apiMode: "chat_completions",
+          startedAt: 3,
+          finishedAt: 4,
+          durationMs: 1,
+          status: "success",
+        }],
+        omittedRequestCount: 2,
+      },
+    )
+
+    expect(diagnostics.requestCount).toBe(4)
+    expect(applyProviderUsageToStats(
+      { ...baseStats, requestDiagnostics: diagnostics },
+      { inputTokens: 50 },
+    ).requestDiagnostics).toMatchObject({
+      requestCount: 5,
+      requests: diagnostics.requests,
+      omittedRequestCount: 2,
+    })
+  })
+
   it("stores confirmed cache usage without changing local cache counters", () => {
     const next = applyProviderUsageToStats(baseStats, {
       inputTokens: 1600,

+ 35 - 4
src/lib/context-hub/provider-usage.ts

@@ -1,4 +1,8 @@
 import type { LlmUsage } from "@/lib/llm-usage"
+import {
+  copyLlmRequestCacheTrace,
+  type LlmRequestCacheTrace,
+} from "@/lib/llm-request-trace"
 import type { UserMemoryDecision } from "@/lib/user-memory/decision-trace"
 import type {
   ContextHub,
@@ -16,7 +20,23 @@ export interface PersistContextHubProviderUsageOptions {
 export function buildLlmRequestDiagnostics(
   usage: LlmUsage | undefined,
   requestCount = 1,
+  traceOptions: {
+    requests?: LlmRequestCacheTrace[]
+    omittedRequestCount?: number
+    requestCountAvailable?: boolean
+    usageScope?: "workflow" | "provider_thread"
+  } = {},
 ): LlmRequestDiagnostics {
+  const tracedRequestCount = (traceOptions.requests?.length ?? 0)
+    + Math.max(0, traceOptions.omittedRequestCount ?? 0)
+  const requestCountAvailable = traceOptions.requestCountAvailable ?? true
+  const effectiveRequestCount = requestCountAvailable
+    ? (tracedRequestCount > 0 ? tracedRequestCount : requestCount)
+    : 0
+  const scopeFields = {
+    ...(traceOptions.requestCountAvailable !== undefined ? { requestCountAvailable } : {}),
+    ...(traceOptions.usageScope ? { usageScope: traceOptions.usageScope } : {}),
+  }
   const hasAny = Boolean(
     usage
     && (
@@ -28,17 +48,27 @@ export function buildLlmRequestDiagnostics(
   )
   if (!hasAny || !usage) {
     return {
-      requestCount: Math.max(0, requestCount),
+      requestCount: Math.max(0, effectiveRequestCount),
+      ...scopeFields,
       providerUsageAvailable: false,
+      ...(traceOptions.requests ? { requests: traceOptions.requests.map(copyLlmRequestCacheTrace) } : {}),
+      ...(traceOptions.omittedRequestCount !== undefined
+        ? { omittedRequestCount: Math.max(0, traceOptions.omittedRequestCount) }
+        : {}),
     }
   }
   return {
-    requestCount: Math.max(1, requestCount),
+    requestCount: requestCountAvailable ? Math.max(1, effectiveRequestCount) : 0,
+    ...scopeFields,
     providerUsageAvailable: true,
     inputTokens: usage.inputTokens,
     outputTokens: usage.outputTokens,
     cacheReadTokens: usage.cachedInputTokens,
     cacheWriteTokens: usage.cacheWriteInputTokens,
+    ...(traceOptions.requests ? { requests: traceOptions.requests.map(copyLlmRequestCacheTrace) } : {}),
+    ...(traceOptions.omittedRequestCount !== undefined
+      ? { omittedRequestCount: Math.max(0, traceOptions.omittedRequestCount) }
+      : {}),
   }
 }
 
@@ -58,12 +88,13 @@ export function mergeLlmRequestDiagnostics(
   if (!hasAny) {
     return {
       ...base,
-      requestCount: base.requestCount + 1,
+      requestCount: base.requestCountAvailable === false ? base.requestCount : base.requestCount + 1,
       providerUsageAvailable: base.providerUsageAvailable,
     }
   }
   return {
-    requestCount: base.requestCount + 1,
+    ...base,
+    requestCount: base.requestCountAvailable === false ? base.requestCount : base.requestCount + 1,
     providerUsageAvailable: true,
     inputTokens: (base.inputTokens ?? 0) + (usage.inputTokens ?? 0),
     outputTokens: (base.outputTokens ?? 0) + (usage.outputTokens ?? 0),

+ 60 - 2
src/lib/context-hub/types.ts

@@ -1,6 +1,11 @@
 import type { AgentMessage } from "@/lib/agent/types"
 import type { DataSourceCategory } from "@/lib/novel/classification"
 import type { ContextPack } from "@/lib/novel/context-engine"
+import {
+  copyLlmRequestCacheTrace,
+  isLlmRequestCacheTrace,
+  type LlmRequestCacheTrace,
+} from "@/lib/llm-request-trace"
 
 export const CONTEXT_CACHE_SCHEMA_VERSION = 2
 
@@ -94,11 +99,17 @@ export interface ContextFragmentTrace {
 
 export interface LlmRequestDiagnostics {
   requestCount: number
+  /** False when the provider only exposes aggregate usage without an internal request count. */
+  requestCountAvailable?: boolean
+  /** Distinguishes a normal workflow aggregate from a provider-managed thread total. */
+  usageScope?: "workflow" | "provider_thread"
   providerUsageAvailable: boolean
   inputTokens?: number
   outputTokens?: number
   cacheReadTokens?: number
   cacheWriteTokens?: number
+  requests?: LlmRequestCacheTrace[]
+  omittedRequestCount?: number
 }
 
 /** Generation-details stats: source traces + independent stablePrefixStatus; token fields are estimates. */
@@ -247,6 +258,53 @@ function isFiniteNumber(value: unknown): value is number {
   return typeof value === "number" && Number.isFinite(value)
 }
 
+function normalizeRequestDiagnostics(value: unknown): LlmRequestDiagnostics | undefined {
+  if (!value || typeof value !== "object") return undefined
+  const source = value as Record<string, unknown>
+  if (!isFiniteNumber(source.requestCount) || typeof source.providerUsageAvailable !== "boolean") {
+    return undefined
+  }
+  const optionalNumber = (key: string) => source[key] === undefined || isFiniteNumber(source[key])
+  if (
+    !optionalNumber("inputTokens")
+    || !optionalNumber("outputTokens")
+    || !optionalNumber("cacheReadTokens")
+    || !optionalNumber("cacheWriteTokens")
+  ) return undefined
+  const requests = Array.isArray(source.requests)
+    ? source.requests.filter(isLlmRequestCacheTrace).map(copyLlmRequestCacheTrace)
+    : undefined
+  const requestCountAvailable = typeof source.requestCountAvailable === "boolean"
+    ? source.requestCountAvailable
+    : undefined
+  const usageScope = source.usageScope === "workflow" || source.usageScope === "provider_thread"
+    ? source.usageScope
+    : undefined
+  return {
+    requestCount: Math.max(0, Math.floor(source.requestCount)),
+    ...(requestCountAvailable !== undefined ? { requestCountAvailable } : {}),
+    ...(usageScope ? { usageScope } : {}),
+    providerUsageAvailable: source.providerUsageAvailable,
+    ...(isFiniteNumber(source.inputTokens) ? { inputTokens: source.inputTokens } : {}),
+    ...(isFiniteNumber(source.outputTokens) ? { outputTokens: source.outputTokens } : {}),
+    ...(isFiniteNumber(source.cacheReadTokens) ? { cacheReadTokens: source.cacheReadTokens } : {}),
+    ...(isFiniteNumber(source.cacheWriteTokens) ? { cacheWriteTokens: source.cacheWriteTokens } : {}),
+    ...(requests ? { requests } : {}),
+    ...(isFiniteNumber(source.omittedRequestCount)
+      ? { omittedRequestCount: Math.max(0, Math.floor(source.omittedRequestCount)) }
+      : {}),
+  }
+}
+
+function normalizeContextHubStats(source: ContextHubStats): ContextHubStats {
+  const { requestDiagnostics: _requestDiagnostics, ...rest } = source
+  const requestDiagnostics = normalizeRequestDiagnostics(source.requestDiagnostics)
+  return {
+    ...rest,
+    ...(requestDiagnostics ? { requestDiagnostics } : {}),
+  }
+}
+
 /** True only for the current stats shape. Legacy hits/refreshed/failures payloads are rejected. */
 export function isCurrentContextHubStats(raw: unknown): raw is ContextHubStats {
   if (!raw || typeof raw !== "object") return false
@@ -315,7 +373,7 @@ export function parseContextHubSnapshot(raw: unknown): ContextHubSnapshot | null
     id: source.id,
     surface: source.surface,
     createdAt: source.createdAt,
-    stats: source.stats,
+    stats: normalizeContextHubStats(source.stats as ContextHubStats),
     items,
     stableCore: source.stableCore,
     sessionSummary: source.sessionSummary,
@@ -339,6 +397,6 @@ export function parseContextHubSnapshotRef(raw: unknown): ContextHubSnapshotRef
     id: source.id,
     surface: source.surface,
     createdAt: source.createdAt,
-    stats: source.stats,
+    stats: normalizeContextHubStats(source.stats as ContextHubStats),
   }
 }

+ 152 - 6
src/lib/llm-client.ts

@@ -27,6 +27,12 @@ import { RESPONSE_RESERVE_FRAC, planLlmRequestBudget } from "./context-budget"
 import { mergeLlmUsageSnapshot, type LlmUsage } from "./llm-usage"
 import { applyGlobalUserMemoryToMessages } from "./user-memory/request-integration"
 import type { UserMemoryDecision } from "./user-memory/decision-trace"
+import {
+  buildLlmRequestCacheTrace,
+  buildLlmRequestPrefixDescriptor,
+  type LlmRequestCacheTrace,
+  type LlmRequestTraceStatus,
+} from "./llm-request-trace"
 
 export type { ChatMessage, RequestOverrides } from "./llm-providers"
 export { isFetchNetworkError } from "./tauri-fetch"
@@ -38,6 +44,8 @@ export interface StreamCallbacks {
   /** 工具调用流式 delta,用于累积 tool_calls */
   onToolCallDelta?: (delta: { index: number; id?: string; name?: string; arguments?: string }) => void
   onUsage?: (usage: LlmUsage) => void
+  /** Sanitized request-level timing/cache trace; never contains prompt text or credentials. */
+  onRequestTrace?: (trace: LlmRequestCacheTrace) => void
   /** Decision produced while preparing this request's messages (request-scoped). */
   onUserMemoryDecision?: (decision: UserMemoryDecision | null) => void
   onDone: () => void
@@ -262,15 +270,100 @@ export async function streamChat(
   const { onToken, onDone, onError } = callbacks
   const decoder = new TextDecoder()
 
+  let prefixDescriptor = await buildLlmRequestPrefixDescriptor(
+    runtimeConfig,
+    budgetedMessages,
+    effectiveRequestOverrides,
+  )
+  interface ActiveRequestTrace {
+    startedAt: number
+    firstResponseAt?: number
+    finished: boolean
+  }
+  const startRequestTrace = (): ActiveRequestTrace => ({
+    startedAt: Date.now(),
+    finished: false,
+  })
+  const markFirstResponse = (trace: ActiveRequestTrace | null | undefined) => {
+    if (trace && trace.firstResponseAt === undefined) trace.firstResponseAt = Date.now()
+  }
+  const finishRequestTrace = (
+    trace: ActiveRequestTrace | null | undefined,
+    status: LlmRequestTraceStatus,
+    usage?: LlmUsage,
+  ) => {
+    if (!trace || trace.finished) return
+    trace.finished = true
+    try {
+      callbacks.onRequestTrace?.(buildLlmRequestCacheTrace({
+        config: runtimeConfig,
+        ...prefixDescriptor,
+        startedAt: trace.startedAt,
+        finishedAt: Date.now(),
+        firstResponseAt: trace.firstResponseAt,
+        usage,
+        status,
+      }))
+    } catch (error) {
+      console.warn("LLM 请求追踪回调失败,忽略观测错误:", error)
+    }
+  }
+
+  const streamLocalWithTrace = async (
+    run: (localCallbacks: StreamCallbacks) => Promise<void>,
+  ): Promise<void> => {
+    const trace = startRequestTrace()
+    let usage: LlmUsage | undefined
+    const tracedCallbacks: StreamCallbacks = {
+      ...callbacks,
+      onToken: (token) => {
+        markFirstResponse(trace)
+        callbacks.onToken(token)
+      },
+      onReasoningToken: (token) => {
+        markFirstResponse(trace)
+        callbacks.onReasoningToken?.(token)
+      },
+      onToolCallDelta: (delta) => {
+        markFirstResponse(trace)
+        callbacks.onToolCallDelta?.(delta)
+      },
+      onUsage: (nextUsage) => {
+        usage = mergeLlmUsageSnapshot(usage, nextUsage)
+        callbacks.onUsage?.(nextUsage)
+      },
+      onDone: () => {
+        finishRequestTrace(trace, signal?.aborted ? "cancelled" : "success", usage)
+        callbacks.onDone()
+      },
+      onError: (error) => {
+        finishRequestTrace(trace, signal?.aborted ? "cancelled" : "error", usage)
+        callbacks.onError(error)
+      },
+    }
+    try {
+      await run(tracedCallbacks)
+    } catch (error) {
+      finishRequestTrace(
+        trace,
+        signal?.aborted ? "cancelled" : isFetchNetworkError(error) ? "network_error" : "error",
+        usage,
+      )
+      throw error
+    }
+  }
+
   // Claude Code CLI uses a subprocess transport (stdin/stdout), not
   // HTTP. Dispatch before getProviderConfig — that function throws for
   // this provider because it has no URL/headers.
   if (runtimeConfig.provider === "claude-code") {
-    return streamViaClaudeCodeCli(runtimeConfig, budgetedMessages, callbacks, signal, effectiveRequestOverrides)
+    return streamLocalWithTrace((localCallbacks) =>
+      streamViaClaudeCodeCli(runtimeConfig, budgetedMessages, localCallbacks, signal, effectiveRequestOverrides))
   }
 
   if (runtimeConfig.provider === "codex-cli") {
-    return streamViaCodexCli(runtimeConfig, budgetedMessages, callbacks, signal, effectiveRequestOverrides)
+    return streamLocalWithTrace((localCallbacks) =>
+      streamViaCodexCli(runtimeConfig, budgetedMessages, localCallbacks, signal, effectiveRequestOverrides))
   }
 
   if (runtimeConfig.provider === "cursor-cli") {
@@ -310,6 +403,34 @@ export async function streamChat(
   }
 
   try {
+    let activeRequestTrace: ActiveRequestTrace | null = null
+    const tracedFetch = async (
+      fetcher: (url: string, init: RequestInit) => Promise<Response>,
+      url: string,
+      init: RequestInit,
+    ): Promise<Response> => {
+      const trace = startRequestTrace()
+      try {
+        const result = await fetcher(url, init)
+        if (result.ok) {
+          activeRequestTrace = trace
+        } else {
+          finishRequestTrace(trace, "error")
+        }
+        return result
+      } catch (error) {
+        finishRequestTrace(
+          trace,
+          signal?.aborted || (combinedSignal?.aborted && !timeoutFired)
+            ? "cancelled"
+            : isFetchNetworkError(error)
+              ? "network_error"
+              : "error",
+        )
+        throw error
+      }
+    }
+
     const buildRequestInit = (
       nextMessages: import("./llm-providers").ChatMessage[],
       overrides: RequestOverrides = effectiveRequestOverrides,
@@ -325,7 +446,7 @@ export async function streamChat(
       let attempt = 0
       while (true) {
         try {
-          return await httpFetch(providerConfig.url, requestInit)
+          return await tracedFetch(httpFetch, providerConfig.url, requestInit)
         } catch (err) {
           if (signal?.aborted || combinedSignal?.aborted) throw err
           if (!isFetchNetworkError(err)) throw err
@@ -432,6 +553,11 @@ export async function streamChat(
           onError(new Error(inputLengthLimitMessage(inputLimit)))
           return
         }
+        prefixDescriptor = await buildLlmRequestPrefixDescriptor(
+          runtimeConfig,
+          retryMessages,
+          effectiveRequestOverrides,
+        )
         const retryRequestInit = buildRequestInit(retryMessages)
         if (retryRequestInit.body === requestInit.body) {
           onError(new Error(inputLengthLimitMessage(inputLimit)))
@@ -505,7 +631,11 @@ export async function streamChat(
       }
       if (!httpRetrySucceeded && shouldRetryWithBrowserFetch(errorDetail) && typeof globalThis.fetch === "function") {
         try {
-          response = await globalThis.fetch(providerConfig.url, requestInit)
+          response = await tracedFetch(
+            (url, init) => globalThis.fetch(url, init),
+            providerConfig.url,
+            requestInit,
+          )
         } catch (err) {
           onError(err instanceof Error ? err : new Error(String(err)))
           return
@@ -529,6 +659,7 @@ export async function streamChat(
     }
 
     if (!response.body) {
+      finishRequestTrace(activeRequestTrace, "error")
       onError(new Error("Response body is null"))
       return
     }
@@ -569,6 +700,7 @@ export async function streamChat(
     const recordReasoning = (line: string) => {
       const reasoningParts = extractReasoningTextFromLine(line)
       for (const part of reasoningParts) {
+        if (part) markFirstResponse(activeRequestTrace)
         reasoningTokensForwarded += part.length
         callbacks.onReasoningToken?.(part)
       }
@@ -589,11 +721,15 @@ export async function streamChat(
             recordReasoning(trimmed)
             const toolDelta = parseToolCallDeltaFromLine(trimmed)
             if (toolDelta) {
+              markFirstResponse(activeRequestTrace)
               toolCallDeltaCount += 1
               callbacks.onToolCallDelta?.(toolDelta)
             } else {
               const token = providerConfig.parseStream(trimmed)
-              if (token !== null) recordToken(token)
+              if (token !== null) {
+                if (token) markFirstResponse(activeRequestTrace)
+                recordToken(token)
+              }
             }
           }
           break
@@ -613,12 +749,16 @@ export async function streamChat(
           recordReasoning(trimmed)
           const toolDelta = parseToolCallDeltaFromLine(trimmed)
           if (toolDelta) {
+            markFirstResponse(activeRequestTrace)
             toolCallDeltaCount += 1
             callbacks.onToolCallDelta?.(toolDelta)
             continue
           }
           const token = providerConfig.parseStream(trimmed)
-          if (token !== null) recordToken(token)
+          if (token !== null) {
+            if (token) markFirstResponse(activeRequestTrace)
+            recordToken(token)
+          }
         }
       }
 
@@ -645,6 +785,7 @@ export async function streamChat(
         contentCharsEmitted === 0 &&
         reasoningCharsObserved >= REASONING_DIAGNOSTIC_THRESHOLD
       ) {
+        finishRequestTrace(activeRequestTrace, "error", streamUsage)
         onError(
           new Error(
             `模型只输出了 ${reasoningCharsObserved.toLocaleString()} 字符的思考内容,但没有输出正文。` +
@@ -661,23 +802,28 @@ export async function streamChat(
       // a silently half-finished response.
       const finalFinishReason: string | null = finishReason
       if (finalFinishReason && isTruncationFinishReason(finalFinishReason)) {
+        finishRequestTrace(activeRequestTrace, "error", streamUsage)
         onError(buildOutputTruncatedError(finalFinishReason))
         return
       }
 
+      finishRequestTrace(activeRequestTrace, "success", streamUsage)
       onDone()
     } catch (err) {
       if (err instanceof Error && (err.name === "AbortError" || (signal?.aborted))) {
+        finishRequestTrace(activeRequestTrace, "cancelled", streamUsage)
         onDone()
         return
       }
       if (isFetchNetworkError(err)) {
+        finishRequestTrace(activeRequestTrace, "network_error", streamUsage)
         // Stream reader threw a network error mid-response (connection
         // dropped, server closed early, network blip). Same message
         // regardless of whether the webview is WebKit or Chromium.
         onError(new Error("流式响应读取中断,请检查网络、代理或接口稳定性后重试。"))
         return
       }
+      finishRequestTrace(activeRequestTrace, "error", streamUsage)
       onError(err instanceof Error ? err : new Error(String(err)))
     } finally {
       reader.releaseLock()

+ 72 - 2
src/lib/llm-client.usage.spec.ts

@@ -12,12 +12,13 @@ import { normalizeUserLlmMaxOutputTokens } from "./llm-context-size"
 
 const mocks = vi.hoisted(() => ({
   fetch: vi.fn(),
+  isFetchNetworkError: vi.fn(() => false),
   streamClaudeCodeCli: vi.fn(),
 }))
 
 vi.mock("./tauri-fetch", () => ({
   getHttpFetch: vi.fn(async () => mocks.fetch),
-  isFetchNetworkError: vi.fn(() => false),
+  isFetchNetworkError: (...args: unknown[]) => mocks.isFetchNetworkError(...args),
 }))
 
 vi.mock("./local-cli-config", () => ({
@@ -40,6 +41,8 @@ const config: LlmConfig = {
 describe("streamChat usage", () => {
   beforeEach(() => {
     mocks.fetch.mockReset()
+    mocks.isFetchNetworkError.mockReset()
+    mocks.isFetchNetworkError.mockReturnValue(false)
     mocks.streamClaudeCodeCli.mockReset()
   })
 
@@ -60,10 +63,22 @@ describe("streamChat usage", () => {
     const onUsage = vi.fn()
     const onDone = vi.fn()
     const onError = vi.fn()
+    const onRequestTrace = vi.fn()
 
-    await streamChat(config, [{ role: "user", content: "测试" }], {
+    await streamChat(config, [
+      {
+        role: "system",
+        content: [
+          { type: "text", text: "固定规则" },
+          { type: "text", text: "项目稳定核心", cacheControl: true },
+          { type: "text", text: "动态任务" },
+        ],
+      },
+      { role: "user", content: "测试" },
+    ], {
       onToken: vi.fn(),
       onUsage,
+      onRequestTrace,
       onDone,
       onError,
     })
@@ -82,6 +97,16 @@ describe("streamChat usage", () => {
     })
     expect(onDone).toHaveBeenCalledOnce()
     expect(onError).not.toHaveBeenCalled()
+    expect(onRequestTrace).toHaveBeenCalledOnce()
+    expect(onRequestTrace).toHaveBeenCalledWith(expect.objectContaining({
+      provider: "openai",
+      model: "gpt-test",
+      prefixFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
+      inputTokens: 1200,
+      outputTokens: 80,
+      cacheReadTokens: 1024,
+      status: "success",
+    }))
   })
 
   it("同行 tool_calls 仍触发 onReasoningToken", async () => {
@@ -334,9 +359,11 @@ describe("streamChat usage", () => {
       ].join("\n"), { status: 200 }))
 
     const onError = vi.fn()
+    const onRequestTrace = vi.fn()
     await streamChat(config, [{ role: "user", content: "写第一章" }], {
       onToken: vi.fn(),
       onDone: vi.fn(),
+      onRequestTrace,
       onError,
     })
 
@@ -344,6 +371,7 @@ describe("streamChat usage", () => {
     const retryBody = JSON.parse(String((mocks.fetch.mock.calls[1][1] as RequestInit).body))
     expect(retryBody.max_tokens).toBe(8_192)
     expect(onError).not.toHaveBeenCalled()
+    expect(onRequestTrace.mock.calls.map(([trace]) => trace.status)).toEqual(["error", "success"])
   })
 
   it("脏 SSE 行不会中断整轮流式响应", async () => {
@@ -376,4 +404,46 @@ describe("streamChat usage", () => {
     expect(onDone).toHaveBeenCalledOnce()
     expect(onError).not.toHaveBeenCalled()
   })
+
+  it("records a mid-stream network failure as network_error", async () => {
+    const body = new ReadableStream<Uint8Array>({
+      start(controller) {
+        controller.error(new Error("connection dropped"))
+      },
+    })
+    mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
+    mocks.isFetchNetworkError.mockReturnValue(true)
+    const onRequestTrace = vi.fn()
+    const onError = vi.fn()
+
+    await streamChat(config, [{ role: "user", content: "测试网络中断" }], {
+      onToken: vi.fn(),
+      onRequestTrace,
+      onDone: vi.fn(),
+      onError,
+    })
+
+    expect(onRequestTrace).toHaveBeenCalledWith(expect.objectContaining({ status: "network_error" }))
+    expect(onError).toHaveBeenCalledWith(expect.objectContaining({
+      message: expect.stringContaining("流式响应读取中断"),
+    }))
+  })
+
+  it("records an aborted supplier attempt as cancelled", async () => {
+    mocks.fetch.mockRejectedValue(new DOMException("aborted", "AbortError"))
+    const controller = new AbortController()
+    controller.abort()
+    const onRequestTrace = vi.fn()
+    const onDone = vi.fn()
+
+    await streamChat(config, [{ role: "user", content: "取消请求" }], {
+      onToken: vi.fn(),
+      onRequestTrace,
+      onDone,
+      onError: vi.fn(),
+    }, controller.signal)
+
+    expect(onRequestTrace).toHaveBeenCalledWith(expect.objectContaining({ status: "cancelled" }))
+    expect(onDone).toHaveBeenCalledOnce()
+  })
 })

+ 131 - 0
src/lib/llm-request-trace.spec.ts

@@ -0,0 +1,131 @@
+import { describe, expect, it } from "vitest"
+import type { ChatMessage, RequestOverrides } from "./llm-providers"
+import {
+  LlmRequestTraceCollector,
+  MAX_LLM_REQUEST_CACHE_TRACES,
+  buildLlmRequestPrefixDescriptor,
+  isLlmRequestCacheTrace,
+  type LlmRequestCacheTrace,
+} from "./llm-request-trace"
+import type { LlmConfig } from "@/stores/wiki-store"
+
+const config: LlmConfig = {
+  provider: "openai",
+  apiKey: "sk-must-not-be-persisted",
+  model: "gpt-test",
+  apiMode: "chat_completions",
+  ollamaUrl: "",
+  customEndpoint: "https://secret.example/v1",
+  maxContextSize: 204_800,
+  reasoning: { mode: "medium" },
+}
+
+function messages(dynamicRule: string, stableCore = "项目稳定核心"): ChatMessage[] {
+  return [
+    {
+      role: "system",
+      content: [
+        { type: "text", text: "固定基础规则\n" },
+        { type: "text", text: stableCore, cacheControl: true },
+        { type: "text", text: `\n动态规则:${dynamicRule}` },
+      ],
+    },
+    { role: "user", content: `任务:${dynamicRule}` },
+  ]
+}
+
+const tools: NonNullable<RequestOverrides["tools"]> = [{
+  type: "function",
+  function: {
+    name: "read_outline",
+    description: "读取大纲",
+    parameters: { type: "object", properties: {} },
+  },
+}]
+
+describe("LLM request prefix fingerprint", () => {
+  it("ignores task, chapter and Skill changes after the cache breakpoint", async () => {
+    const first = await buildLlmRequestPrefixDescriptor(config, messages("写第 11 章并启用 Skill A"), {
+      tools,
+      toolChoice: "auto",
+      reasoning: { mode: "medium" },
+    })
+    const second = await buildLlmRequestPrefixDescriptor(config, messages("分析第 229 章并启用 Skill B"), {
+      tools,
+      toolChoice: "auto",
+      reasoning: { mode: "medium" },
+    })
+
+    expect(first.prefixFingerprint).toMatch(/^[a-f0-9]{64}$/)
+    expect(second.prefixFingerprint).toBe(first.prefixFingerprint)
+    expect(first.prefixEstimatedTokens).toBeGreaterThan(0)
+  })
+
+  it("changes for stable text, model, tool schema and reasoning changes", async () => {
+    const base = await buildLlmRequestPrefixDescriptor(config, messages("动态"), {
+      tools,
+      toolChoice: "auto",
+      reasoning: { mode: "medium" },
+    })
+    const variants = await Promise.all([
+      buildLlmRequestPrefixDescriptor(config, messages("动态", "变化后的稳定核心"), { tools, toolChoice: "auto", reasoning: { mode: "medium" } }),
+      buildLlmRequestPrefixDescriptor({ ...config, model: "gpt-other" }, messages("动态"), { tools, toolChoice: "auto", reasoning: { mode: "medium" } }),
+      buildLlmRequestPrefixDescriptor(config, messages("动态"), { tools: [{ ...tools[0], function: { ...tools[0].function, description: "变化" } }], toolChoice: "auto", reasoning: { mode: "medium" } }),
+      buildLlmRequestPrefixDescriptor(config, messages("动态"), { tools, toolChoice: "auto", reasoning: { mode: "high" } }),
+    ])
+
+    for (const variant of variants) {
+      expect(variant.prefixFingerprint).not.toBe(base.prefixFingerprint)
+    }
+  })
+
+  it("returns no fingerprint when no virtual or real breakpoint exists", async () => {
+    await expect(buildLlmRequestPrefixDescriptor(config, [
+      { role: "system", content: "普通系统提示" },
+      { role: "user", content: "任务" },
+    ])).resolves.toEqual({})
+  })
+})
+
+function trace(index: number, fingerprint = "a".repeat(64)): LlmRequestCacheTrace {
+  return {
+    provider: "openai",
+    model: "gpt-test",
+    apiMode: "chat_completions",
+    prefixFingerprint: fingerprint,
+    startedAt: index * 1_000,
+    finishedAt: index * 1_000 + 400,
+    durationMs: 400,
+    firstResponseMs: 120,
+    inputTokens: 1_000,
+    outputTokens: 100,
+    cacheReadTokens: 800,
+    cacheWriteTokens: 0,
+    status: "success",
+  }
+}
+
+describe("LLM request trace collector", () => {
+  it("computes same-prefix start/idle gaps and caps snapshots at 32 requests", () => {
+    const collector = new LlmRequestTraceCollector()
+    for (let index = 0; index < MAX_LLM_REQUEST_CACHE_TRACES + 2; index += 1) {
+      collector.record(trace(index))
+    }
+
+    const snapshot = collector.snapshot()
+    expect(snapshot.requests).toHaveLength(MAX_LLM_REQUEST_CACHE_TRACES)
+    expect(snapshot.omittedRequestCount).toBe(2)
+    expect(snapshot.requests[0].startedAt).toBe(2_000)
+    expect(snapshot.requests[1]).toMatchObject({ startGapMs: 1_000, idleGapMs: 600 })
+  })
+
+  it("stores only sanitized diagnostics and strictly rejects damaged traces", () => {
+    const value = trace(1)
+    expect(isLlmRequestCacheTrace(value)).toBe(true)
+    expect(JSON.stringify(value)).not.toContain(config.apiKey)
+    expect(JSON.stringify(value)).not.toContain(config.customEndpoint)
+    expect(JSON.stringify(value)).not.toContain("项目稳定核心")
+    expect(isLlmRequestCacheTrace({ ...value, status: "timeout" })).toBe(false)
+    expect(isLlmRequestCacheTrace({ ...value, durationMs: -1 })).toBe(false)
+  })
+})

+ 248 - 0
src/lib/llm-request-trace.ts

@@ -0,0 +1,248 @@
+import { estimateChatMessagesTokens } from "@/lib/chat-request-budget"
+import { sha256Text } from "@/lib/context-hub/fingerprint"
+import type { ChatMessage, RequestOverrides } from "@/lib/llm-providers"
+import type { LlmUsage } from "@/lib/llm-usage"
+import type { LlmConfig } from "@/stores/wiki-store"
+
+export const MAX_LLM_REQUEST_CACHE_TRACES = 32
+const LLM_REQUEST_TRACE_PROVIDERS = new Set<LlmConfig["provider"]>([
+  "openai",
+  "anthropic",
+  "google",
+  "azure",
+  "ollama",
+  "custom",
+  "minimax",
+  "claude-code",
+  "codex-cli",
+  "cursor-cli",
+])
+
+export type LlmRequestTraceStatus = "success" | "error" | "cancelled" | "network_error"
+
+export interface LlmRequestCacheTrace {
+  provider: LlmConfig["provider"]
+  model: string
+  apiMode: string
+  prefixFingerprint?: string
+  prefixEstimatedTokens?: number
+  startedAt: number
+  finishedAt: number
+  durationMs: number
+  firstResponseMs?: number
+  startGapMs?: number
+  idleGapMs?: number
+  inputTokens?: number
+  outputTokens?: number
+  cacheReadTokens?: number
+  cacheWriteTokens?: number
+  status: LlmRequestTraceStatus
+}
+
+export function resolveLlmRequestApiMode(config: LlmConfig): string {
+  if (config.provider === "custom") return config.apiMode ?? "chat_completions"
+  if (config.provider === "anthropic" || config.provider === "minimax") return "anthropic_messages"
+  if (config.provider === "google") return "gemini_generate_content"
+  if (config.provider === "azure") return "azure_chat_completions"
+  if (config.provider === "claude-code") return "claude_code_cli"
+  if (config.provider === "codex-cli") return "codex_cli"
+  return "chat_completions"
+}
+
+export interface LlmRequestTraceSnapshot {
+  requests: LlmRequestCacheTrace[]
+  omittedRequestCount: number
+}
+
+function textBlocksThroughLastBreakpoint(messages: ChatMessage[]): ChatMessage[] | null {
+  let lastMessageIndex = -1
+  let lastBlockIndex = -1
+  for (const [messageIndex, message] of messages.entries()) {
+    if (!Array.isArray(message.content)) continue
+    for (const [blockIndex, block] of message.content.entries()) {
+      if (block.type === "text" && block.cacheControl) {
+        lastMessageIndex = messageIndex
+        lastBlockIndex = blockIndex
+      }
+    }
+  }
+  if (lastMessageIndex < 0) return null
+
+  return messages.slice(0, lastMessageIndex + 1).map((message, messageIndex) => {
+    if (messageIndex !== lastMessageIndex || !Array.isArray(message.content)) {
+      return message
+    }
+    return {
+      ...message,
+      content: message.content.slice(0, lastBlockIndex + 1),
+    }
+  })
+}
+
+function canonicalMessage(message: ChatMessage): unknown {
+  return {
+    role: message.role,
+    content: typeof message.content === "string"
+      ? message.content
+      : message.content.map((block) => block.type === "text"
+        ? { type: "text", text: block.text, cacheControl: Boolean(block.cacheControl) }
+        : { type: "image", mediaType: block.mediaType, dataBase64: block.dataBase64 }),
+    ...(message.tool_calls ? { tool_calls: message.tool_calls } : {}),
+    ...(message.tool_call_id ? { tool_call_id: message.tool_call_id } : {}),
+    ...(message.name ? { name: message.name } : {}),
+    ...(message.reasoning_content !== undefined ? { reasoning_content: message.reasoning_content } : {}),
+  }
+}
+
+export async function buildLlmRequestPrefixDescriptor(
+  config: LlmConfig,
+  messages: ChatMessage[],
+  overrides?: RequestOverrides,
+): Promise<{ prefixFingerprint?: string; prefixEstimatedTokens?: number }> {
+  const prefixMessages = textBlocksThroughLastBreakpoint(messages)
+  if (!prefixMessages) return {}
+
+  const canonical = JSON.stringify({
+    provider: config.provider,
+    model: config.model,
+    apiMode: resolveLlmRequestApiMode(config),
+    tools: overrides?.tools ?? [],
+    toolChoice: overrides?.toolChoice,
+    reasoning: overrides?.reasoning ?? config.reasoning ?? { mode: "auto" },
+    messages: prefixMessages.map(canonicalMessage),
+  })
+  return {
+    prefixFingerprint: await sha256Text(canonical),
+    prefixEstimatedTokens: estimateChatMessagesTokens(prefixMessages),
+  }
+}
+
+export function buildLlmRequestCacheTrace(input: {
+  config: LlmConfig
+  prefixFingerprint?: string
+  prefixEstimatedTokens?: number
+  startedAt: number
+  finishedAt: number
+  firstResponseAt?: number
+  usage?: LlmUsage
+  status: LlmRequestTraceStatus
+}): LlmRequestCacheTrace {
+  return {
+    provider: input.config.provider,
+    model: input.config.model,
+    apiMode: resolveLlmRequestApiMode(input.config),
+    ...(input.prefixFingerprint ? { prefixFingerprint: input.prefixFingerprint } : {}),
+    ...(input.prefixEstimatedTokens !== undefined
+      ? { prefixEstimatedTokens: input.prefixEstimatedTokens }
+      : {}),
+    startedAt: input.startedAt,
+    finishedAt: input.finishedAt,
+    durationMs: Math.max(0, input.finishedAt - input.startedAt),
+    ...(input.firstResponseAt !== undefined
+      ? { firstResponseMs: Math.max(0, input.firstResponseAt - input.startedAt) }
+      : {}),
+    ...(input.usage?.inputTokens !== undefined ? { inputTokens: input.usage.inputTokens } : {}),
+    ...(input.usage?.outputTokens !== undefined ? { outputTokens: input.usage.outputTokens } : {}),
+    ...(input.usage?.cachedInputTokens !== undefined
+      ? { cacheReadTokens: input.usage.cachedInputTokens }
+      : {}),
+    ...(input.usage?.cacheWriteInputTokens !== undefined
+      ? { cacheWriteTokens: input.usage.cacheWriteInputTokens }
+      : {}),
+    status: input.status,
+  }
+}
+
+function requestKey(trace: LlmRequestCacheTrace): string | null {
+  if (!trace.prefixFingerprint) return null
+  return [trace.provider, trace.model, trace.apiMode, trace.prefixFingerprint].join("\u0000")
+}
+
+export class LlmRequestTraceCollector {
+  private traces: LlmRequestCacheTrace[] = []
+  private omitted = 0
+
+  record = (trace: LlmRequestCacheTrace): void => {
+    this.traces.push(copyLlmRequestCacheTrace(trace))
+    this.traces.sort((left, right) => left.startedAt - right.startedAt)
+    if (this.traces.length > MAX_LLM_REQUEST_CACHE_TRACES) {
+      const overflow = this.traces.length - MAX_LLM_REQUEST_CACHE_TRACES
+      this.traces.splice(0, overflow)
+      this.omitted += overflow
+    }
+  }
+
+  snapshot(): LlmRequestTraceSnapshot {
+    const previousByKey = new Map<string, LlmRequestCacheTrace>()
+    const requests = this.traces.map((source) => {
+      const trace = { ...source }
+      const key = requestKey(trace)
+      if (key) {
+        const previous = previousByKey.get(key)
+        if (previous) {
+          trace.startGapMs = Math.max(0, trace.startedAt - previous.startedAt)
+          trace.idleGapMs = Math.max(0, trace.startedAt - previous.finishedAt)
+        }
+        previousByKey.set(key, trace)
+      }
+      return trace
+    })
+    return { requests, omittedRequestCount: this.omitted }
+  }
+}
+
+export function copyLlmRequestCacheTrace(trace: LlmRequestCacheTrace): LlmRequestCacheTrace {
+  return {
+    provider: trace.provider,
+    model: trace.model,
+    apiMode: trace.apiMode,
+    ...(trace.prefixFingerprint !== undefined ? { prefixFingerprint: trace.prefixFingerprint } : {}),
+    ...(trace.prefixEstimatedTokens !== undefined ? { prefixEstimatedTokens: trace.prefixEstimatedTokens } : {}),
+    startedAt: trace.startedAt,
+    finishedAt: trace.finishedAt,
+    durationMs: trace.durationMs,
+    ...(trace.firstResponseMs !== undefined ? { firstResponseMs: trace.firstResponseMs } : {}),
+    ...(trace.startGapMs !== undefined ? { startGapMs: trace.startGapMs } : {}),
+    ...(trace.idleGapMs !== undefined ? { idleGapMs: trace.idleGapMs } : {}),
+    ...(trace.inputTokens !== undefined ? { inputTokens: trace.inputTokens } : {}),
+    ...(trace.outputTokens !== undefined ? { outputTokens: trace.outputTokens } : {}),
+    ...(trace.cacheReadTokens !== undefined ? { cacheReadTokens: trace.cacheReadTokens } : {}),
+    ...(trace.cacheWriteTokens !== undefined ? { cacheWriteTokens: trace.cacheWriteTokens } : {}),
+    status: trace.status,
+  }
+}
+
+export function isLlmRequestCacheTrace(value: unknown): value is LlmRequestCacheTrace {
+  if (!value || typeof value !== "object") return false
+  const source = value as Record<string, unknown>
+  const optionalNumber = (key: string) => source[key] === undefined
+    || (typeof source[key] === "number" && Number.isFinite(source[key]) && source[key] >= 0)
+  return typeof source.provider === "string"
+    && LLM_REQUEST_TRACE_PROVIDERS.has(source.provider as LlmConfig["provider"])
+    && typeof source.model === "string"
+    && typeof source.apiMode === "string"
+    && source.apiMode.length > 0
+    && (source.prefixFingerprint === undefined
+      || (typeof source.prefixFingerprint === "string" && /^[a-f0-9]{64}$/.test(source.prefixFingerprint)))
+    && typeof source.startedAt === "number"
+    && Number.isFinite(source.startedAt)
+    && source.startedAt >= 0
+    && typeof source.finishedAt === "number"
+    && Number.isFinite(source.finishedAt)
+    && source.finishedAt >= source.startedAt
+    && typeof source.durationMs === "number"
+    && Number.isFinite(source.durationMs)
+    && source.durationMs >= 0
+    && optionalNumber("prefixEstimatedTokens")
+    && optionalNumber("firstResponseMs")
+    && optionalNumber("startGapMs")
+    && optionalNumber("idleGapMs")
+    && optionalNumber("inputTokens")
+    && optionalNumber("outputTokens")
+    && optionalNumber("cacheReadTokens")
+    && optionalNumber("cacheWriteTokens")
+    && (source.status === "success"
+      || source.status === "error"
+      || source.status === "cancelled"
+      || source.status === "network_error")
+}

+ 5 - 0
src/lib/local-cli-config.ts

@@ -7,6 +7,9 @@ export interface LocalCliDetectResult {
   version: string | null
   path: string | null
   model?: string | null
+  appServerReady?: boolean
+  dynamicToolsReady?: boolean
+  models?: string[]
   error: string | null
 }
 
@@ -30,6 +33,8 @@ export async function resolveRuntimeLocalCliConfig(config: LlmConfig): Promise<L
     return config
   }
 
+  if (config.model.trim()) return config
+
   try {
     const detected = await detectLocalCliConfig(config.provider)
     const detectedModel = detected?.model?.trim() ?? ""

+ 3 - 1
src/lib/novel/chapter-execution-contract.ts

@@ -1,4 +1,4 @@
-import { streamChat } from "@/lib/llm-client"
+import { streamChat, type StreamCallbacks } from "@/lib/llm-client"
 import type { LlmConfig } from "@/stores/wiki-store"
 
 export interface ChapterExecutionSceneStep {
@@ -99,6 +99,7 @@ export async function runChapterExecutionContractBuild(
   llmConfig: LlmConfig,
   planContent: string,
   signal?: AbortSignal,
+  onRequestTrace?: StreamCallbacks["onRequestTrace"],
 ): Promise<ChapterExecutionContract> {
   if (!planContent.trim()) return createEmptyContract()
 
@@ -116,6 +117,7 @@ export async function runChapterExecutionContractBuild(
       onError: (error: Error) => {
         streamError = error
       },
+      onRequestTrace,
     },
     signal,
   )

+ 3 - 1
src/lib/novel/chapter-execution-report.ts

@@ -1,4 +1,4 @@
-import { streamChat } from "@/lib/llm-client"
+import { streamChat, type StreamCallbacks } from "@/lib/llm-client"
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { ChapterExecutionContract } from "./chapter-execution-contract"
 import { contractToTaskBriefText } from "./chapter-execution-contract"
@@ -51,6 +51,7 @@ export async function runChapterExecutionReportCheck(
   contract: ChapterExecutionContract,
   finalContent: string,
   signal?: AbortSignal,
+  onRequestTrace?: StreamCallbacks["onRequestTrace"],
 ): Promise<ChapterExecutionReport> {
   let responseText = ""
   let streamError: Error | null = null
@@ -66,6 +67,7 @@ export async function runChapterExecutionReportCheck(
       onError: (error: Error) => {
         streamError = error
       },
+      onRequestTrace,
     },
     signal,
   )

+ 5 - 1
src/lib/novel/chapter-plan-compliance.ts

@@ -1,4 +1,4 @@
-import { streamChat } from "@/lib/llm-client"
+import { streamChat, type StreamCallbacks } from "@/lib/llm-client"
 import type { LlmConfig } from "@/stores/wiki-store"
 import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
 
@@ -61,6 +61,7 @@ export async function runChapterPlanComplianceCheck(
   planBlueprint: string,
   finalContent: string,
   signal?: AbortSignal,
+  onRequestTrace?: StreamCallbacks["onRequestTrace"],
 ): Promise<string> {
   if (!planBlueprint.trim()) return ""
   if (!finalContent.trim()) return ""
@@ -74,6 +75,7 @@ export async function runChapterPlanComplianceCheck(
       onToken: (token) => { result += token },
       onDone: () => {},
       onError: (error) => { streamError = error },
+      onRequestTrace,
     },
     signal,
   )
@@ -156,6 +158,7 @@ export async function runChapterPlanDeviationRepair(
   finalContent: string,
   complianceResult: ParsedChapterPlanComplianceResult | string,
   signal?: AbortSignal,
+  onRequestTrace?: StreamCallbacks["onRequestTrace"],
 ): Promise<string> {
   if (!planBlueprint.trim()) return finalContent.trim()
   if (!finalContent.trim()) return ""
@@ -169,6 +172,7 @@ export async function runChapterPlanDeviationRepair(
       onToken: (token) => { result += token },
       onDone: () => {},
       onError: (error) => { streamError = error },
+      onRequestTrace,
     },
     signal,
   )

+ 9 - 21
src/lib/novel/context-data-sources.ts

@@ -10,7 +10,6 @@ import { parseChapterMeta } from "./chapter-meta"
 import { listSnapshots, loadSnapshot, type ChapterSnapshot } from "./chapter-ingest"
 import { loadRevisionFeedbackForContext, createEmptyRevisionFeedback } from "./revision-feedback"
 import { loadCognitionState, cognitionToContextText } from "./character-cognition"
-import { getChapterVolumes } from "./volume"
 import { readSoulDoc } from "./soul-doc"
 import { buildWritingStyleContext } from "./writing-style-store"
 import { buildSectionBriefing } from "./section-briefing"
@@ -25,6 +24,7 @@ import type { DataSourceCategory } from "./classification"
 import {
   readOutlineContent,
   readChapterOutlineContent,
+  readVolumeContextContent,
   searchRelevantContentUnified,
   searchGraphRelevantContent,
   selectLookbackChapterNumbers,
@@ -89,7 +89,7 @@ export const outlineDataSource: DataSource<string> = {
   name: "outline",
   priority: 1,
   async load(context: ContextLoadContext): Promise<string> {
-    return await readOutlineContent(context.projectPath)
+    return await readOutlineContent(context.projectPath, context.chapterNumber)
   },
 }
 
@@ -112,23 +112,7 @@ export const volumeContextDataSource: DataSource<string> = {
   name: "volumeContext",
   priority: 3,
   async load(context: ContextLoadContext): Promise<string> {
-    if (!context.chapterNumber) return ""
-    try {
-      const volumes = await getChapterVolumes(context.projectPath, context.chapterNumber)
-      if (volumes.length === 0) return ""
-      return volumes
-        .map(v => {
-          const parts = [`第${v.volumeNumber}卷:${v.title}`]
-          if (v.summary) parts.push(`概要:${v.summary}`)
-          if (v.chapterRangeStart !== undefined && v.chapterRangeEnd !== undefined) {
-            parts.push(`章节范围:第${v.chapterRangeStart}章 - 第${v.chapterRangeEnd}章`)
-          }
-          return parts.join("\n")
-        })
-        .join("\n\n")
-    } catch {
-      return ""
-    }
+    return await readVolumeContextContent(context.projectPath, context.chapterNumber)
   },
 }
 
@@ -492,8 +476,12 @@ export const sectionBriefingDataSource: DataSource<string> = {
   async load(context: ContextLoadContext): Promise<string> {
     if (!context.chapterNumber) return ""
     const chapterOutlineContent = await readChapterOutlineContent(context.projectPath, context.chapterNumber)
-    if (!chapterOutlineContent.trim()) return ""
-    return buildSectionBriefing(context.projectPath, context.chapterNumber, chapterOutlineContent)
+    return buildSectionBriefing(
+      context.projectPath,
+      context.chapterNumber,
+      chapterOutlineContent,
+      context.task,
+    )
   },
 }
 

+ 17 - 16
src/lib/novel/context-engine-outline-read.spec.ts

@@ -10,17 +10,24 @@ vi.mock("@/lib/search", () => ({
 }))
 
 import { listDirectory, readFile } from "@/commands/fs"
-import { searchWiki } from "@/lib/search"
 import { readOutlineContent } from "./context-engine"
 
 describe("纯 Markdown 大纲上下文读取", () => {
   beforeEach(() => {
     vi.clearAllMocks()
-    vi.mocked(searchWiki).mockResolvedValue([])
   })
 
-  it("搜索索引没有 type:outline 时从大纲目录读取并剥离历史 YAML", async () => {
+  it("只加载总纲和设定,不把章纲混入 outline", async () => {
     vi.mocked(listDirectory).mockResolvedValue([{
+      name: "大纲",
+      path: "C:/book/wiki/outlines/大纲",
+      is_dir: true,
+      children: [{
+        name: "总纲.md",
+        path: "C:/book/wiki/outlines/大纲/总纲.md",
+        is_dir: false,
+      }],
+    }, {
       name: "章纲",
       path: "C:/book/wiki/outlines/章纲",
       is_dir: true,
@@ -30,23 +37,17 @@ describe("纯 Markdown 大纲上下文读取", () => {
         is_dir: false,
       }],
     }])
-    vi.mocked(readFile).mockResolvedValue([
-      "---",
-      "type: outline",
-      "outline_type: chapter-outline",
-      "---",
-      "",
-      "# 第001章章纲",
-      "",
-      "- 主角进入旧城",
-    ].join("\n"))
+    vi.mocked(readFile).mockImplementation(async (path) => path.includes("总纲")
+      ? "---\ntype: outline\n---\n# 总纲\n主线内容"
+      : "---\ntype: outline\noutline_type: chapter-outline\n---\n# 第001章章纲\n不应混入")
 
     const result = await readOutlineContent("C:/book")
 
     expect(listDirectory).toHaveBeenCalledWith("C:/book/wiki/outlines")
-    expect(result).toContain("# 第001章章纲")
-    expect(result).toContain("主角进入旧城")
+    expect(result).toContain("# 总纲")
+    expect(result).toContain("主线内容")
+    expect(result).not.toContain("第001章章纲")
+    expect(result).not.toContain("不应混入")
     expect(result).not.toContain("type: outline")
-    expect(result).not.toContain("outline_type:")
   })
 })

+ 25 - 2
src/lib/novel/context-engine.spec.ts

@@ -1,7 +1,13 @@
 import { afterEach, beforeEach, describe, expect, it } from "vitest"
 import i18n from "@/i18n"
 import { charsPerTokenForLanguage } from "@/lib/context-budget"
-import { annotateChapterOutlineStatus, contextPackToPrompt, trimContextPack, type ContextPack } from "./context-engine"
+import {
+  annotateChapterOutlineStatus,
+  contextPackToPrompt,
+  pickChapterOutlineByNumber,
+  trimContextPack,
+  type ContextPack,
+} from "./context-engine"
 
 const basePack: ContextPack = {
   task: "生成第2章正文",
@@ -49,12 +55,29 @@ describe("annotateChapterOutlineStatus", () => {
     expect(annotateChapterOutlineStatus(content)).toBe(content)
   })
 
-  it("草稿章纲添加普通 AI 会话风险提示", () => {
+  it("草稿章纲只添加资料状态,不把状态变成生成阻断指令", () => {
     const result = annotateChapterOutlineStatus("## 基础信息\n- 当前状态:草稿\n")
 
     expect(result).toContain("章纲状态提示")
     expect(result).toContain("当前状态为「草稿」")
+    expect(result).toContain("不构成暂停生成、要求确认或向用户追问的指令")
     expect(result).toContain("不得自行补写或改写章纲")
+    expect(result).not.toContain("生成正文前应提醒用户确认")
+  })
+})
+
+describe("pickChapterOutlineByNumber", () => {
+  it("只返回目标章节,不把相邻章节当作搜索兜底", () => {
+    const candidates = [
+      {
+        path: "wiki/outlines/第237章-银河号介入.md",
+        content: "# 第237章:银河号介入\n\n后续承接:奥斯陆断线细节留第239章。",
+      },
+      { path: "wiki/outlines/第239章-批准书上的血迹.md", content: "# 第239章:批准书上的血迹" },
+    ]
+
+    expect(pickChapterOutlineByNumber(candidates, 239)).toContain("第239章")
+    expect(pickChapterOutlineByNumber(candidates.slice(0, 1), 239)).toBe("")
   })
 })
 

+ 46 - 87
src/lib/novel/context-engine.ts

@@ -2,7 +2,7 @@ import {
   charsPerTokenForLanguage,
   resolveContextPackTokenBudget,
 } from "@/lib/context-budget"
-import { listDirectory, readFile } from "@/commands/fs"
+import { readFile } from "@/commands/fs"
 import i18n from "@/i18n"
 import { searchWiki, tokenizeQuery } from "@/lib/search"
 import { normalizePath } from "@/lib/path-utils"
@@ -13,10 +13,8 @@ import { listSnapshots, loadSnapshot, type ChapterSnapshot } from "./chapter-ing
 import { buildRevisionDirectives } from "./revision-feedback"
 import { extractChapterOutlineStatus } from "./outline-quality-check"
 import { loadCognitionState, cognitionToContextText } from "./character-cognition"
-import { getChapterVolumes } from "./volume"
 import { isAuthoritativeGenerationPath, isHistoricalProjectionSnippet, novelMixedSearch } from "./search-adapter"
 import { rerankCandidates } from "@/lib/rerank"
-import type { FileNode } from "@/types/wiki"
 import {
   DataSourceRegistry,
   type ContextLoadContext,
@@ -28,7 +26,13 @@ import {
   buildNovelVectorSnippet,
   selectRelevantNovelVectorResults,
 } from "./vector-relevance"
-import { stripOutlineFrontmatter } from "./outline-markdown"
+import {
+  buildOutlineContext,
+  buildVolumeContext,
+  capOutlineSourcesToBudget,
+  loadOutlineDocumentIndex,
+  resolveChapterOutline,
+} from "./outline-context-index"
 
 const FIELD_PRIORITY: Record<string, number> = {
   sectionBriefing: 0,
@@ -428,43 +432,12 @@ function emptyPack(task: string): ContextPack {
   }
 }
 
-export async function readOutlineContent(pp: string): Promise<string> {
-  try {
-    const results = await searchWiki(pp, "outline type:outline")
-    if (results.length > 0) {
-      const contents = await Promise.all(
-        results.map(async (result) => {
-          try {
-            return stripOutlineFrontmatter(await readFile(result.path))
-          } catch {
-            return ""
-          }
-        }),
-      )
-      return joinNonEmpty(contents, "\n\n")
-    }
-  } catch {}
+export async function readOutlineContent(pp: string, chapterNumber?: number): Promise<string> {
   try {
-    const tree = await listDirectory(`${pp}/wiki/outlines`)
-    const files = flattenOutlineMarkdownFiles(tree).slice(0, 80)
-    const contents = await Promise.all(
-      files.map(async (file) => stripOutlineFrontmatter(await readFile(file.path)).trim()),
-    )
-    return joinNonEmpty(contents, "\n\n")
-  } catch {}
-  return ""
-}
-
-function flattenOutlineMarkdownFiles(nodes: FileNode[]): FileNode[] {
-  const files: FileNode[] = []
-  for (const node of nodes) {
-    if (node.is_dir) {
-      if (node.children) files.push(...flattenOutlineMarkdownFiles(node.children))
-      continue
-    }
-    if (node.name.toLowerCase().endsWith(".md")) files.push(node)
+    return buildOutlineContext(await loadOutlineDocumentIndex(pp), chapterNumber)
+  } catch {
+    return ""
   }
-  return files
 }
 
 function readFrontmatterChapterNumber(content: string): number | undefined {
@@ -505,6 +478,22 @@ function includesChapterMarker(text: string, chapterNumber: number): boolean {
     new RegExp(`chapter\\s*${chapterNumber}\\b`, "i").test(text)
 }
 
+function hasChapterHeading(content: string, chapterNumber: number): boolean {
+  const labels = chapterLabels(chapterNumber)
+  return content.split(/\r?\n/).some((line) => {
+    const compact = line.trim().replace(/\s+/g, "")
+    const heading = compact.replace(/^#{1,6}/, "")
+    if (heading === compact) return false
+    return labels.some((label) =>
+      heading === label ||
+      heading.startsWith(`${label}:`) ||
+      heading.startsWith(`${label}:`) ||
+      heading.startsWith(`${label}-`) ||
+      heading.startsWith(`${label}—`),
+    )
+  })
+}
+
 export function pickChapterOutlineByNumber(
   candidates: Array<{ path: string; content: string }>,
   chapterNumber: number,
@@ -513,50 +502,33 @@ export function pickChapterOutlineByNumber(
   if (frontmatterMatch) return frontmatterMatch.content.slice(0, 4000)
 
   const headingMatch = candidates.find((candidate) =>
-    includesChapterMarker(candidate.content, chapterNumber) || includesChapterMarker(candidate.path, chapterNumber),
+    hasChapterHeading(candidate.content, chapterNumber) || includesChapterMarker(candidate.path, chapterNumber),
   )
   if (headingMatch) return headingMatch.content.slice(0, 4000)
 
   return ""
 }
 
-async function readChapterOutlineDirect(pp: string, chapterNumber: number): Promise<string> {
+export async function readChapterOutlineContent(pp: string, chapterNumber?: number): Promise<string> {
+  if (!chapterNumber) return ""
   try {
-    const tree = await listDirectory(`${pp}/wiki/outlines`)
-    const files = flattenOutlineMarkdownFiles(tree)
-    const candidates = await Promise.all(
-      files.slice(0, 80).map(async (file) => ({
-        path: file.path,
-        content: await readFile(file.path).catch(() => ""),
-      })),
-    )
-    return pickChapterOutlineByNumber(
-      candidates.filter((candidate) => candidate.content.trim()),
-      chapterNumber,
-    )
+    const resolution = resolveChapterOutline(await loadOutlineDocumentIndex(pp), chapterNumber)
+    if (!resolution.content.trim()) return ""
+    return resolution.sourceKind === "standalone"
+      ? annotateChapterOutlineStatus(resolution.content)
+      : resolution.content
   } catch {
     return ""
   }
 }
 
-export async function readChapterOutlineContent(pp: string, chapterNumber?: number): Promise<string> {
+export async function readVolumeContextContent(pp: string, chapterNumber?: number): Promise<string> {
   if (!chapterNumber) return ""
-  const direct = await readChapterOutlineDirect(pp, chapterNumber)
-  if (direct.trim()) return annotateChapterOutlineStatus(direct)
-  const queries = [
-    `第${chapterNumber}章细纲 outline`,
-    `chapter ${chapterNumber} outline`,
-    `chapter_number:${chapterNumber} outline_type:chapter-outline`,
-  ]
-  for (const query of queries) {
-    try {
-      const results = await searchWiki(pp, query)
-      if (results.length > 0) {
-        return annotateChapterOutlineStatus(await readFile(results[0].path)).slice(0, 3000)
-      }
-    } catch {}
+  try {
+    return buildVolumeContext(await loadOutlineDocumentIndex(pp), chapterNumber)
+  } catch {
+    return ""
   }
-  return ""
 }
 
 export function annotateChapterOutlineStatus(content: string): string {
@@ -564,7 +536,7 @@ export function annotateChapterOutlineStatus(content: string): string {
   if (status === "已确认") return content
   const label = status === "未知" ? "未标明当前状态" : `当前状态为「${status}」`
   return [
-    `【章纲状态提示】该章纲${label},普通 AI 会话生成正文前应提醒用户确认是否继续使用;不得自行补写或改写章纲。`,
+    `【章纲状态提示】该章纲${label}。本标记仅说明资料状态,不构成暂停生成、要求确认或向用户追问的指令;用户已明确要求生成目标章节时,应按章纲已有内容执行,不得自行补写或改写章纲。`,
     "",
     content,
   ].join("\n")
@@ -766,22 +738,7 @@ async function readVolumeContext(
   chapterNumber: number | undefined,
 ): Promise<string> {
   if (!chapterNumber) return ""
-  try {
-    const volumes = await getChapterVolumes(pp, chapterNumber)
-    if (volumes.length === 0) return ""
-    return volumes
-      .map(v => {
-        const parts = [`第${v.volumeNumber}卷:${v.title}`]
-        if (v.summary) parts.push(`概要:${v.summary}`)
-        if (v.chapterRangeStart !== undefined && v.chapterRangeEnd !== undefined) {
-          parts.push(`章节范围:第${v.chapterRangeStart}章 - 第${v.chapterRangeEnd}章`)
-        }
-        return parts.join("\n")
-      })
-      .join("\n\n")
-  } catch {
-    return ""
-  }
+  return readVolumeContextContent(pp, chapterNumber)
 }
 
 export async function searchRelevantContent(
@@ -1264,7 +1221,9 @@ export function trimContextPack(
     const originalFieldChars = nextField.charCount
     
     if (targetContentChars > minKeepChars && nextField.charCount > targetContentChars) {
-      const trimmedContent = trimFieldContent(nextField.content, targetContentChars)
+      const trimmedContent = nextField.fieldKey === "outline" && typeof nextField.content === "string"
+        ? capOutlineSourcesToBudget(nextField.content, targetContentChars)
+        : trimFieldContent(nextField.content, targetContentChars)
       const keptContentChars = Array.isArray(trimmedContent)
         ? trimmedContent.reduce((sum, item) => sum + item.length, 0)
         : trimmedContent.length

+ 156 - 3
src/lib/novel/deep-chapter-generation.spec.ts

@@ -156,6 +156,47 @@ function createLegacyPlanComplianceDeps(reviewResults: NovelReviewResult[] = [])
 }
 
 describe("runDeepChapterGeneration", () => {
+  it("forwards every internal model request trace to one workflow collector", async () => {
+    const deps = createDeps()
+    const onRequestTrace = vi.fn()
+    let index = 0
+    vi.mocked(deps.streamChat).mockImplementation(async (
+      _config: LlmConfig,
+      messages: ChatMessage[],
+      callbacks: StreamCallbacks,
+    ) => {
+      index += 1
+      callbacks.onRequestTrace?.({
+        provider: "openai",
+        model: "test-model",
+        apiMode: "chat_completions",
+        prefixFingerprint: "stable",
+        startedAt: index * 100,
+        finishedAt: index * 100 + 50,
+        durationMs: 50,
+        status: "success",
+      })
+      const prompt = messagesPromptText(messages)
+      callbacks.onToken(prompt.includes("正文") ? chapterText("正文") : "写作任务书")
+      callbacks.onDone()
+    })
+
+    await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第三章",
+        chapterNumber: 3,
+        llmConfig,
+        aiWorkflowMode: "standard",
+      },
+      { onRequestTrace },
+      deps,
+    )
+
+    expect(onRequestTrace).toHaveBeenCalledTimes(vi.mocked(deps.streamChat).mock.calls.length)
+    expect(onRequestTrace).toHaveBeenCalled()
+  })
+
   it("routes workflow, prose, and de-AI stages to their configured models", async () => {
     const previousState = useWikiStore.getState()
     useWikiStore.setState({
@@ -1178,6 +1219,46 @@ describe("runDeepChapterGeneration", () => {
     expect(activityEvents.some((event) => event.kind === "extract_goal" && event.content.includes("上一章结尾"))).toBe(true)
     expect(activityEvents.some((event) => event.kind === "extract_result" && event.content.includes("门缝里传来金属拖拽声"))).toBe(true)
     expect(activityEvents.some((event) => event.kind === "stage_output" && event.content.includes("任务书"))).toBe(true)
+    expect(activityEvents.some((event) => event.stageId === "final_polish")).toBe(false)
+  })
+
+  it("emits a dedicated 去AI味 stage between 校验与修正 and 最终输出", async () => {
+    const deps = createDeps()
+    const activityEvents: AgentActivityEvent[] = []
+
+    await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        aiWorkflowMode: "strict",
+      },
+      { onActivityEvent: (event) => activityEvents.push(event) },
+      deps,
+    )
+
+    const polishStarted = activityEvents.find(
+      (event) => event.stageId === "final_polish" && event.kind === "stage_started",
+    )
+    const polishOutput = activityEvents.find(
+      (event) => event.stageId === "final_polish" && event.kind === "stage_output",
+    )
+    const validateOutputIndex = activityEvents.findIndex(
+      (event) => event.stageId === "validate_revision" && event.kind === "stage_output",
+    )
+    const polishStartedIndex = activityEvents.findIndex(
+      (event) => event.stageId === "final_polish" && event.kind === "stage_started",
+    )
+    const finalOutputIndex = activityEvents.findIndex(
+      (event) => event.stageId === "final_output" && event.kind === "final_output",
+    )
+
+    expect(polishStarted?.content).toContain("去除复读、机械套话和 AI 味")
+    expect(polishOutput?.content).toContain("简单审查与去AI味完成")
+    expect(validateOutputIndex).toBeGreaterThanOrEqual(0)
+    expect(polishStartedIndex).toBeGreaterThan(validateOutputIndex)
+    expect(finalOutputIndex).toBeGreaterThan(polishStartedIndex)
   })
 
   it("injects the enabled writing style into the stage 3 draft prompt", async () => {
@@ -1360,7 +1441,8 @@ describe("runDeepChapterGeneration", () => {
   })
 
   it("uses fast, standard, and strict workflow routes", async () => {
-    const fastDeps = createDeps()
+    const skippedCollect = vi.fn(async () => ({ markdown: "", searchedNames: [], notes: [] }))
+    const fastDeps = { ...createDeps(), collectWritingEntityWebSearch: skippedCollect }
     await runDeepChapterGeneration(
       { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "fast" },
       {},
@@ -1368,8 +1450,9 @@ describe("runDeepChapterGeneration", () => {
     )
     expect(fastDeps.streamChat).toHaveBeenCalledTimes(2)
     expect(fastDeps.reviewChapter).not.toHaveBeenCalled()
+    expect(skippedCollect).not.toHaveBeenCalled()
 
-    const standardDeps = createDeps()
+    const standardDeps = { ...createDeps(), collectWritingEntityWebSearch: skippedCollect }
     const standardThinking: string[] = []
     await runDeepChapterGeneration(
       { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "standard" },
@@ -1380,8 +1463,14 @@ describe("runDeepChapterGeneration", () => {
     expect(standardDeps.reviewChapter).not.toHaveBeenCalled()
     expect(standardThinking.join("\n")).toContain("阶段4:标准完成")
     expect(standardThinking.join("\n")).not.toContain("快速模式")
+    expect(skippedCollect).not.toHaveBeenCalled()
 
-    const strictDeps = createDeps()
+    const collectWritingEntityWebSearch = vi.fn(async () => ({
+      markdown: "",
+      searchedNames: [] as string[],
+      notes: [] as string[],
+    }))
+    const strictDeps = { ...createDeps(), collectWritingEntityWebSearch }
     await runDeepChapterGeneration(
       { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "strict" },
       {},
@@ -1389,6 +1478,30 @@ describe("runDeepChapterGeneration", () => {
     )
     expect(strictDeps.streamChat).toHaveBeenCalledTimes(3)
     expect(strictDeps.reviewChapter).toHaveBeenCalled()
+    expect(collectWritingEntityWebSearch).toHaveBeenCalled()
+  })
+
+  it("injects strict-mode entity web search into the chapter context pack", async () => {
+    const research = "## 外部检索(仅补本地缺失实体)\n\n### 黄蓉\n- 资料 https://example.test/hr\n  公开摘要"
+    const seenPacks: ContextPack[] = []
+    const deps = createDeps()
+    vi.mocked(deps.contextPackToPrompt).mockImplementation((pack) => {
+      seenPacks.push(pack)
+      return pack.searchResults || "上下文包内容"
+    })
+    await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "strict" },
+      {},
+      {
+        ...deps,
+        collectWritingEntityWebSearch: vi.fn(async () => ({
+          markdown: research,
+          searchedNames: ["黄蓉"],
+          notes: [],
+        })),
+      },
+    )
+    expect(seenPacks.some((pack) => pack.searchResults.includes(research))).toBe(true)
   })
 
   it("emits visible workflow events for the chapter multi-task loop", async () => {
@@ -1641,6 +1754,46 @@ describe("runDeepChapterGeneration", () => {
     expect(thinking.join("\n")).toContain("阶段6:简单审查与去AI味")
   })
 
+  it("fails the workflow when expansion is still far below the minimum chapter length", async () => {
+    const responses = [
+      "写作任务书内容",
+      "请提供第239章章纲后再继续。",
+      "当前资料不足,无法扩写。",
+    ]
+    const deps: DeepChapterGenerationDeps = {
+      buildContextPack: vi.fn(async () => contextPack),
+      contextPackToPrompt: vi.fn(() => "上下文包内容"),
+      reviewChapter: vi.fn(async () => []),
+      streamChat: vi.fn(async (_config: LlmConfig, _messages: ChatMessage[], callbacks: StreamCallbacks) => {
+        callbacks.onToken(responses.shift() ?? "")
+        callbacks.onDone()
+      }),
+    }
+    const events: Array<{ type: string; name: string; result?: string }> = []
+
+    await expect(runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第239章", chapterNumber: 239, llmConfig },
+      { onWorkflowEvent: (event) => events.push(event) },
+      deps,
+    )).rejects.toThrow(/扩写后仅约 .*低于最低完成线/)
+
+    expect(deps.reviewChapter).not.toHaveBeenCalled()
+    expect(events).toContainEqual(expect.objectContaining({
+      type: "completed",
+      name: "chapter_draft",
+      result: expect.stringContaining("低于最低完成线"),
+    }))
+    expect(events).toContainEqual(expect.objectContaining({
+      type: "error",
+      name: "chapter_expansion",
+      result: expect.stringContaining("章节正文生成失败"),
+    }))
+    expect(events).not.toContainEqual(expect.objectContaining({
+      type: "completed",
+      name: "chapter_expansion",
+    }))
+  })
+
   it("does not force expansion after final polish even when the result is short", async () => {
     const draft = chapterText("初稿正文内容", 3000)
     const shortFinal = chapterText("最终润色后过短", 1800)

+ 186 - 48
src/lib/novel/deep-chapter-generation.ts

@@ -8,6 +8,7 @@ import {
 import { useWikiStore } from "@/stores/wiki-store";
 import type { AiWorkflowMode } from "@/lib/agent/workflow-mode";
 import type { AgentActivityEvent, AgentActivityKind } from "@/lib/agent/types";
+import type { LlmRequestCacheTrace } from "@/lib/llm-request-trace";
 import {
   isReasoningDisabled,
   isReasoningOnlyResponseError,
@@ -27,6 +28,11 @@ import {
   contextPackToPrompt,
   type ContextPack,
 } from "./context-engine";
+import {
+  collectWritingEntityWebSearch,
+  type CollectWritingEntityWebSearchInput,
+  type WritingEntityWebSearchResult,
+} from "./writing-entity-web-search";
 import { resolveDefaultModel, resolveNovelModel } from "./model-resolver";
 import { reviewChapter, type NovelReviewResult } from "./review-adapter";
 import type { TaskRouteResult } from "./task-router";
@@ -39,6 +45,7 @@ import {
   shouldRepairChapterPlanDeviation,
 } from "./chapter-plan-compliance";
 import { buildChapterPlanExecutionSummary } from "./chapter-plan-execution-summary";
+import { capOutlineSourcesToBudget } from "./outline-context-index";
 import {
   contractToTaskBriefText,
   fallbackParseChapterExecutionContract,
@@ -81,6 +88,7 @@ export interface DeepChapterGenerationCallbacks {
   onCheckpoint?: (checkpoint: DeepChapterGenerationResumeCheckpoint) => void;
   onWorkflowEvent?: (event: ChapterWorkflowEvent) => void;
   onActivityEvent?: (event: AgentActivityEvent) => void;
+  onRequestTrace?: (trace: LlmRequestCacheTrace) => void;
 }
 
 export interface DeepChapterGenerationResult {
@@ -141,6 +149,9 @@ export interface DeepChapterGenerationDeps {
     signal?: AbortSignal,
     requestOverrides?: RequestOverrides,
   ) => Promise<void>;
+  collectWritingEntityWebSearch?: (
+    input: CollectWritingEntityWebSearchInput,
+  ) => Promise<WritingEntityWebSearchResult>;
 }
 
 const defaultDeps: DeepChapterGenerationDeps = {
@@ -186,24 +197,6 @@ const DEEP_CHAPTER_OUTLINE_MAX_FRAC = 0.7;
  *  some room for memory/settings/search hits. */
 const DEEP_CHAPTER_REST_TOKEN_FLOOR = 2000;
 
-/**
- * Trim the (mandatory) outline to a character cap so it can never overflow the
- * context window on its own. Keeps the head — which carries the overall
- * structure — and drops the tail with an explicit truncation marker so the
- * model knows the outline was cut. Cuts on a line boundary when possible.
- */
-function capOutlineToBudget(outline: string, charCap: number): string {
-  const trimmed = outline.trim();
-  if (charCap <= 0 || trimmed.length <= charCap) return trimmed;
-
-  const marker = "\n\n【大纲过长,已按上下文窗口截断,仅保留前部】";
-  const room = Math.max(0, charCap - marker.length);
-  let head = trimmed.slice(0, room);
-  const lastBreak = head.lastIndexOf("\n");
-  if (lastBreak > room * 0.6) head = head.slice(0, lastBreak);
-  return `${head.trimEnd()}${marker}`;
-}
-
 export function shouldUseDeepChapterGeneration(
   _route: TaskRouteResult | null,
   enabled: boolean,
@@ -502,7 +495,9 @@ export async function runDeepChapterGeneration(
   if (!executionContract && workflowProfile.runExecutionContractBuild && planBlueprint) {
     try {
       const buildContract = deps.runChapterExecutionContractBuild || runChapterExecutionContractBuild;
-      executionContract = await buildContract(workflowConfig, planBlueprint, signal);
+      executionContract = callbacks.onRequestTrace
+        ? await buildContract(workflowConfig, planBlueprint, signal, callbacks.onRequestTrace)
+        : await buildContract(workflowConfig, planBlueprint, signal);
     } catch (error) {
       rethrowIfUserAbort(error, signal);
       console.warn("[Deep Chapter] 执行清单生成失败,使用本地兜底解析:", error);
@@ -573,7 +568,7 @@ export async function runDeepChapterGeneration(
   }
   throwIfAborted(signal);
 
-  const contextPack = await safeBuildChapterContextPack(
+  let contextPack = await safeBuildChapterContextPack(
     deps,
     input.projectPath,
     contextRequest,
@@ -582,6 +577,20 @@ export async function runDeepChapterGeneration(
   );
   assertNotAborted(signal);
 
+  if (workflowProfile.mode === "strict") {
+    contextPack = await maybeInjectWritingEntityWebSearch({
+      input,
+      deps,
+      contextPack,
+      previousChaptersAnalysis,
+      planBlueprint,
+      workflowConfig,
+      callbacks,
+      signal,
+    });
+    assertNotAborted(signal);
+  }
+
   if (!resumeCheckpoint) {
     emitDeepChapterStageStarted(
       callbacks,
@@ -711,7 +720,7 @@ export async function runDeepChapterGeneration(
   const outlineCharCap = Math.floor(
     totalContextCharBudget * DEEP_CHAPTER_OUTLINE_MAX_FRAC,
   );
-  const outlineText = capOutlineToBudget(
+  const outlineText = capOutlineSourcesToBudget(
     contextPack.outline ?? "",
     outlineCharCap,
   );
@@ -829,6 +838,7 @@ export async function runDeepChapterGeneration(
             ),
           analysisRequestOverrides,
           cachePrefix,
+          callbacks.onRequestTrace,
         ),
       (value) => `写作任务书完成,约 ${countChapterChars(value)} 字。`,
       (value) => ({ chars: countChapterChars(value) }),
@@ -903,8 +913,14 @@ export async function runDeepChapterGeneration(
             ),
           generationRequestOverrides,
           cachePrefix,
+          callbacks.onRequestTrace,
         ),
-      (value) => `正文初稿完成,约 ${countChapterChars(value)} 字。`,
+      (value) => {
+        const chars = countChapterChars(value);
+        return chars < lengthSpec.minChars
+          ? `正文初稿仅约 ${chars} 字,低于最低完成线 ${lengthSpec.minChars} 字,进入扩写补足。`
+          : `正文初稿完成,约 ${chars} 字。`;
+      },
       (value) => ({ chars: countChapterChars(value) }),
     );
     assertNotAborted(signal);
@@ -917,8 +933,8 @@ export async function runDeepChapterGeneration(
           detail: "初稿低于本章目标字数,补足场景和人物行动。",
           params: workflowBaseParams,
         },
-        () =>
-          collectModelText(
+        async () => {
+          const expanded = await collectModelText(
             writingConfig,
             [
               {
@@ -943,7 +959,16 @@ export async function runDeepChapterGeneration(
               ),
             generationRequestOverrides,
             cachePrefix,
-          ),
+            callbacks.onRequestTrace,
+          );
+          const expandedChars = countChapterChars(expanded);
+          if (expandedChars < lengthSpec.minChars) {
+            throw new Error(
+              `章节正文生成失败:扩写后仅约 ${expandedChars} 字,低于最低完成线 ${lengthSpec.minChars} 字。`,
+            );
+          }
+          return expanded;
+        },
         (value) => `正文扩写补足完成,约 ${countChapterChars(value)} 字。`,
         (value) => ({ chars: countChapterChars(value) }),
       );
@@ -1087,6 +1112,7 @@ export async function runDeepChapterGeneration(
                 contextPack,
                 planBlueprint: planExecutionSummary,
                 throwOnFailure: true,
+                onRequestTrace: callbacks.onRequestTrace,
               },
               signal,
             )
@@ -1099,6 +1125,7 @@ export async function runDeepChapterGeneration(
                 contextPack,
                 planBlueprint: planExecutionSummary,
                 throwOnFailure: true,
+                onRequestTrace: callbacks.onRequestTrace,
               },
             );
       } catch (err) {
@@ -1240,6 +1267,7 @@ export async function runDeepChapterGeneration(
             ),
           generationRequestOverrides,
           cachePrefix,
+          callbacks.onRequestTrace,
         ),
       (value) =>
         `检测到 ${blockingIssues.length} 个阻断问题,已自动返修一次。返修后正文约 ${countChapterChars(value)} 字。`,
@@ -1307,6 +1335,7 @@ export async function runDeepChapterGeneration(
               contextPack,
               characterOnly: true,
               throwOnFailure: true,
+              onRequestTrace: callbacks.onRequestTrace,
             },
             signal,
           )
@@ -1319,6 +1348,7 @@ export async function runDeepChapterGeneration(
               contextPack,
               characterOnly: true,
               throwOnFailure: true,
+              onRequestTrace: callbacks.onRequestTrace,
             },
           );
       const postBlockingIssues = (postRevisionResults || []).filter(
@@ -1371,6 +1401,14 @@ export async function runDeepChapterGeneration(
     detail: "做最后一遍简单审查,减少复读、机械套话和 AI 味。",
     params: workflowBaseParams,
   };
+  if (workflowProfile.runFinalPolish) {
+    emitDeepChapterStageStarted(
+      callbacks,
+      "final_polish",
+      "去AI味",
+      "正在做最后一遍简单审查,去除复读、机械套话和 AI 味。",
+    );
+  }
   let finalContent = workflowProfile.runFinalPolish
     ? await runChapterWorkflowStep(
         callbacks,
@@ -1403,6 +1441,14 @@ export async function runDeepChapterGeneration(
       "快速模式跳过最终去AI味,直接采用阶段3正文作为最终正文。",
       { skipped: true, chars: countChapterChars(finalContent) },
     );
+  } else {
+    emitDeepChapterActivity(callbacks, {
+      id: `deep_chapter:final_polish:output:${Date.now()}`,
+      stageId: "final_polish",
+      kind: "stage_output",
+      title: "去AI味",
+      content: `简单审查与去AI味完成,最终正文约 ${countChapterChars(finalContent)} 字。`,
+    });
   }
   callbacks.onThinking?.(
     formatStageThinking(
@@ -1436,7 +1482,9 @@ export async function runDeepChapterGeneration(
           detail: "按场景验收标准逐项检查最终正文。",
           params: workflowBaseParams,
         },
-        () => runReport(workflowConfig, executionContract!, finalContent, signal),
+        () => callbacks.onRequestTrace
+          ? runReport(workflowConfig, executionContract!, finalContent, signal, callbacks.onRequestTrace)
+          : runReport(workflowConfig, executionContract!, finalContent, signal),
         (value) => executionReportToToolSummary(value),
         (value) => ({
           status: value.status,
@@ -1463,13 +1511,22 @@ export async function runDeepChapterGeneration(
         startChapterWorkflowStep(callbacks, repairStep);
         let repairedContent = "";
         try {
-          repairedContent = await runRepair(
-            writingConfig,
-            contractToTaskBriefText(executionContract!),
-            finalContent,
-            repairItems.join("\n"),
-            signal,
-          );
+          repairedContent = callbacks.onRequestTrace
+            ? await runRepair(
+                writingConfig,
+                contractToTaskBriefText(executionContract!),
+                finalContent,
+                repairItems.join("\n"),
+                signal,
+                callbacks.onRequestTrace,
+              )
+            : await runRepair(
+                writingConfig,
+                contractToTaskBriefText(executionContract!),
+                finalContent,
+                repairItems.join("\n"),
+                signal,
+              );
         } catch (error) {
           errorChapterWorkflowStep(callbacks, repairStep, error);
           throw error;
@@ -1489,7 +1546,9 @@ export async function runDeepChapterGeneration(
                 detail: "返修后再次检查执行清单失败项是否消除。",
                 params: workflowBaseParams,
               },
-              () => runReport(workflowConfig, executionContract!, repairedCandidate, signal),
+              () => callbacks.onRequestTrace
+                ? runReport(workflowConfig, executionContract!, repairedCandidate, signal, callbacks.onRequestTrace)
+                : runReport(workflowConfig, executionContract!, repairedCandidate, signal),
               (value) => executionReportToToolSummary(value),
               (value) => ({
                 status: value.status,
@@ -1557,7 +1616,9 @@ export async function runDeepChapterGeneration(
       planCompliance = await runChapterWorkflowStep(
         callbacks,
         complianceStep,
-        () => runCompliance(workflowConfig, planExecutionSummary, finalContent, signal),
+        () => callbacks.onRequestTrace
+          ? runCompliance(workflowConfig, planExecutionSummary, finalContent, signal, callbacks.onRequestTrace)
+          : runCompliance(workflowConfig, planExecutionSummary, finalContent, signal),
         (value) => value ? "计划履约度检查完成。" : "计划履约度检查完成,未返回具体结果。",
         (value) => ({ hasComplianceResult: Boolean(value?.trim()) }),
       );
@@ -1589,13 +1650,22 @@ export async function runDeepChapterGeneration(
           const complianceBeforeRepair = planCompliance;
           let repairedContent = "";
           try {
-            repairedContent = await runRepair(
-              writingConfig,
-              planExecutionSummary,
-              finalContent,
-              complianceBeforeRepair,
-              signal,
-            );
+            repairedContent = callbacks.onRequestTrace
+              ? await runRepair(
+                  writingConfig,
+                  planExecutionSummary,
+                  finalContent,
+                  complianceBeforeRepair,
+                  signal,
+                  callbacks.onRequestTrace,
+                )
+              : await runRepair(
+                  writingConfig,
+                  planExecutionSummary,
+                  finalContent,
+                  complianceBeforeRepair,
+                  signal,
+                );
           } catch (error) {
             errorChapterWorkflowStep(callbacks, repairStep, error);
             throw error;
@@ -1615,12 +1685,20 @@ export async function runDeepChapterGeneration(
               recheckResult = await runChapterWorkflowStep(
                 callbacks,
                 recheckStep,
-                () => runCompliance(
-                  workflowConfig,
-                  planExecutionSummary,
-                  repairedCandidate,
-                  signal,
-                ),
+                () => callbacks.onRequestTrace
+                  ? runCompliance(
+                      workflowConfig,
+                      planExecutionSummary,
+                      repairedCandidate,
+                      signal,
+                      callbacks.onRequestTrace,
+                    )
+                  : runCompliance(
+                      workflowConfig,
+                      planExecutionSummary,
+                      repairedCandidate,
+                      signal,
+                    ),
                 (value) => value ? "计划返修复检完成。" : "计划返修复检未返回具体结果。",
                 (value) => ({ hasComplianceResult: Boolean(value?.trim()) }),
               );
@@ -1772,6 +1850,7 @@ async function finalPolishChapter(
       ),
     requestOverrides,
     cachePrefix,
+    callbacks.onRequestTrace,
   );
   assertNotAborted(signal);
   return polished.trim() ? polished : currentContent;
@@ -1827,6 +1906,7 @@ async function collectModelText(
   onUpdate?: (content: string) => void,
   requestOverrides?: RequestOverrides,
   cachePrefix?: string,
+  onRequestTrace?: StreamCallbacks["onRequestTrace"],
 ): Promise<string> {
   let content = "";
   let reasoningBuffer = "";
@@ -1875,6 +1955,7 @@ async function collectModelText(
     onError: (error) => {
       streamError = error;
     },
+    onRequestTrace,
   };
 
   const streamOnce = async (effectiveOverrides?: RequestOverrides) => {
@@ -2253,6 +2334,63 @@ function resolveGoldenThreeThinkingHints(
   ];
 }
 
+async function maybeInjectWritingEntityWebSearch(args: {
+  input: DeepChapterGenerationInput;
+  deps: DeepChapterGenerationDeps;
+  contextPack: ContextPack;
+  previousChaptersAnalysis: string;
+  planBlueprint?: string;
+  workflowConfig: LlmConfig;
+  callbacks: DeepChapterGenerationCallbacks;
+  signal?: AbortSignal;
+}): Promise<ContextPack> {
+  const collect = args.deps.collectWritingEntityWebSearch ?? collectWritingEntityWebSearch;
+  try {
+    args.callbacks.onThinking?.(
+      formatStageThinking("联网搜索", "正在核对本库实体,必要时联网补搜..."),
+    );
+    const result = await collect({
+      projectPath: args.input.projectPath,
+      userRequest: args.input.userRequest,
+      outline: args.contextPack.outline,
+      planBlueprint: args.planBlueprint,
+      contextPack: args.contextPack,
+      chapterNumber: args.input.chapterNumber,
+      previousChaptersAnalysis: args.previousChaptersAnalysis,
+      streamChat: args.deps.streamChat,
+      llmConfig: args.workflowConfig,
+      searchApiConfig: useWikiStore.getState().searchApiConfig,
+      signal: args.signal,
+      onRequestTrace: args.callbacks.onRequestTrace,
+    });
+    if (result.searchedNames.length > 0 || result.markdown.trim()) {
+      emitDeepChapterActivity(args.callbacks, {
+        id: `deep_chapter:entity_web_search:${Date.now()}`,
+        stageId: "read_context",
+        kind: "web_search",
+        title: "联网搜索",
+        content: [
+          result.searchedNames.length > 0
+            ? `已搜索:${result.searchedNames.join("、")}`
+            : "未发起联网搜索",
+          ...result.notes,
+        ].filter(Boolean).join("\n"),
+      });
+    }
+    if (!result.markdown.trim()) return args.contextPack;
+    return {
+      ...args.contextPack,
+      searchResults: [args.contextPack.searchResults?.trim(), result.markdown.trim()]
+        .filter(Boolean)
+        .join("\n\n"),
+    };
+  } catch (error) {
+    rethrowIfUserAbort(error, args.signal);
+    console.error("[deep-chapter-generation] 实体联网补搜失败:", error);
+    return args.contextPack;
+  }
+}
+
 async function safeBuildChapterContextPack(
   deps: DeepChapterGenerationDeps,
   projectPath: string,

+ 1 - 1
src/lib/novel/mod.ts

@@ -33,7 +33,7 @@ export { exportProject, type ExportOptions, type ExportResult } from "./export"
 export { routeTask, buildTaskDirective, type NovelTaskIntent, type TaskRouteResult } from "./task-router"
 export { createDefaultNovelProjectMeta, saveNovelProjectMeta, loadNovelProjectMeta, updateNovelProjectStats, type NovelProjectMeta } from "./project-meta"
 export { buildDeAiSystemPrompt, buildDeAiRewriteMessages, injectDeAiDirective, loadCustomDeAiSkill } from "./de-ai-adapter"
-export { analyzePreviousChapters, type PreviousChapterAnalysis } from "./previous-chapters-analysis"
+export { analyzePreviousChapters, readPreviousChapterBodies, type PreviousChapterAnalysis } from "./previous-chapters-analysis"
 export { rebuildAllSnapshots, rebuildVectorIndex, type RebuildProgress, type RebuildProgressCallback } from "./rebuild"
 export { runFactCheck, verifyFactCheckLlm, type FactCheckResult, type FactCheckReport, type FactCheckOptions } from "./fact-snapshot"
 export { scoreReviewResults, CALIBRATED_DIMENSION_WEIGHTS, CALIBRATED_SEVERITY_DEDUCTION, type DimensionScore, type ReviewScoreReport, type ReviewScoringOptions } from "./review-scoring"

+ 187 - 0
src/lib/novel/outline-context-index.spec.ts

@@ -0,0 +1,187 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+vi.mock("@/commands/fs", () => ({
+  listDirectory: vi.fn(),
+  readFile: vi.fn(),
+}))
+
+import { listDirectory, readFile } from "@/commands/fs"
+import {
+  buildOutlineContext,
+  buildRelevantCharacterBriefs,
+  buildRelevantForeshadowing,
+  capOutlineSourcesToBudget,
+  loadOutlineDocumentIndex,
+  resolveChapterOutline,
+} from "./outline-context-index"
+
+function file(path: string) {
+  return { name: path.split("/").pop()!, path, is_dir: false }
+}
+
+function directory(path: string, children: ReturnType<typeof file>[]) {
+  return { name: path.split("/").pop()!, path, is_dir: true, children }
+}
+
+const root = "/book/wiki/outlines"
+
+describe("outline context index", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it("outline 只包含总纲、目标卷和全部设定", async () => {
+    const paths = {
+      master: `${root}/大纲/总纲.md`,
+      volume1: `${root}/卷纲/第一卷.md`,
+      volume4: `${root}/卷纲/第四卷.md`,
+      chapter: `${root}/章纲/第237章.md`,
+      setting: `${root}/设定/世界观.md`,
+      character: `${root}/人物小传/阿明.md`,
+      foreshadowing: `${root}/伏笔/暗线.md`,
+    }
+    vi.mocked(listDirectory).mockResolvedValue([
+      directory(`${root}/大纲`, [file(paths.master)]),
+      directory(`${root}/卷纲`, [file(paths.volume1), file(paths.volume4)]),
+      directory(`${root}/章纲`, [file(paths.chapter)]),
+      directory(`${root}/设定`, [file(paths.setting)]),
+      directory(`${root}/人物小传`, [file(paths.character)]),
+      directory(`${root}/伏笔`, [file(paths.foreshadowing)]),
+    ])
+    const contents: Record<string, string> = {
+      [paths.master]: "# 总纲\n全书主线",
+      [paths.volume1]: "# 第一卷\n第一卷概述\n\n| 章节 | 事件 |\n| --- | --- |\n| 第1章 | 开端 |",
+      [paths.volume4]: "# 第四卷\n第四卷概述\n\n## 章序表\n| 实际章号 | 事件ID | 事件 |\n| --- | --- | --- |\n| 239 | V4-050 | 钢铁来潮 |",
+      [paths.chapter]: "# 第237章:旧账\n正文说明:第239章再回收,不能据此冒充第239章章纲。",
+      [paths.setting]: "# 世界观设定\n世界规则",
+      [paths.character]: "# 阿明\n人物秘密",
+      [paths.foreshadowing]: "# 伏笔表\n暗线内容",
+    }
+    vi.mocked(readFile).mockImplementation(async (path) => contents[String(path)] ?? "")
+
+    const index = await loadOutlineDocumentIndex("/book")
+    const result = buildOutlineContext(index, 239)
+
+    expect(result).toContain("全书主线")
+    expect(result).toContain("第四卷概述")
+    expect(result).toContain("V4-050")
+    expect(result).toContain("世界规则")
+    expect(result).not.toContain("第一卷概述")
+    expect(result).not.toContain("第237章:旧账")
+    expect(result).not.toContain("人物秘密")
+    expect(result).not.toContain("暗线内容")
+    expect(result).not.toContain("| 第1章 | 开端 |")
+
+    const capped = capOutlineSourcesToBudget(result, 900)
+    expect(capped).toContain("大纲/总纲.md")
+    expect(capped).toContain("卷纲/第四卷.md")
+    expect(capped).toContain("设定/世界观.md")
+
+    const allVolumes = buildOutlineContext(index)
+    expect(allVolumes).toContain("第一卷概述")
+    expect(allVolumes).toContain("第四卷概述")
+  })
+
+  it("第237章章纲正文提到第239章时不误选,并从目标卷纲精确兜底", async () => {
+    const chapterPath = `${root}/章纲/第237章.md`
+    const volumePath = `${root}/卷纲/第四卷.md`
+    vi.mocked(listDirectory).mockResolvedValue([
+      directory(`${root}/章纲`, [file(chapterPath)]),
+      directory(`${root}/卷纲`, [file(volumePath)]),
+    ])
+    vi.mocked(readFile).mockImplementation(async (path) => String(path) === chapterPath
+      ? "# 第237章:旧账\n本章埋下线索,到第239章回收。\n\n## 第239章\n这里只是后续提示。"
+      : [
+          "# 第四卷",
+          "## 章序表",
+          "| 实际章号 | 事件ID | 事件名 |",
+          "| --- | --- | --- |",
+          "| 237 | V4-048 | 旧账 |",
+          "| 239 | V4-050 | 钢铁来潮 |",
+          "## V4-050 钢铁来潮",
+          "第239章执行目标:完成接收链。",
+        ].join("\n"))
+
+    const result = resolveChapterOutline(await loadOutlineDocumentIndex("/book"), 239)
+
+    expect(result.sourceKind).toBe("volume")
+    expect(result.content).toContain("卷纲兜底")
+    expect(result.content).toContain("V4-050")
+    expect(result.content).toContain("完成接收链")
+    expect(result.content).not.toContain("本章埋下线索")
+  })
+
+  it("独立章纲精确命中后保留整份文档的子标题内容", async () => {
+    const chapterPath = `${root}/章纲/第239章.md`
+    vi.mocked(listDirectory).mockResolvedValue([
+      directory(`${root}/章纲`, [file(chapterPath)]),
+    ])
+    vi.mocked(readFile).mockResolvedValue("# 第239章:钢铁来潮\n\n## 场景一\n完整场景要求\n\n## 伏笔\n回收暗线")
+
+    const result = resolveChapterOutline(await loadOutlineDocumentIndex("/book"), 239)
+
+    expect(result.sourceKind).toBe("standalone")
+    expect(result.content).toContain("完整场景要求")
+    expect(result.content).toContain("回收暗线")
+  })
+
+  it("旧版根目录 type:outline 文件仍以明确的补零章标题分类", async () => {
+    const chapterPath = `${root}/legacy-outline.md`
+    vi.mocked(listDirectory).mockResolvedValue([file(chapterPath)])
+    vi.mocked(readFile).mockResolvedValue([
+      "---",
+      "type: outline",
+      "---",
+      "# 第0239章章纲",
+      "旧版章纲完整内容",
+    ].join("\n"))
+
+    const result = resolveChapterOutline(await loadOutlineDocumentIndex("/book"), 239)
+
+    expect(result.sourceKind).toBe("standalone")
+    expect(result.content).toContain("旧版章纲完整内容")
+  })
+
+  it("完整新书规划按语义拆分,人物小传仍只按命中人物加载", async () => {
+    const planningPath = `${root}/大纲/完整新书规划.md`
+    vi.mocked(listDirectory).mockResolvedValue([
+      directory(`${root}/大纲`, [file(planningPath)]),
+    ])
+    vi.mocked(readFile).mockResolvedValue([
+      "# 完整新书规划",
+      "## 总纲",
+      "总主线",
+      "## 第一卷",
+      "卷目标",
+      "### 章节规划表",
+      "| 章节 | 事件 |",
+      "| --- | --- |",
+      "| 第8章 | 入城 |",
+      "## 世界观/设定",
+      "法术规则",
+      "## 人物小传",
+      "### 林岚",
+      "林岚人物详情",
+      "### 周野",
+      "周野人物详情",
+      "## 伏笔表",
+      "| 回收章节 | 内容 |",
+      "| --- | --- |",
+      "| 第8章 | 古钥匙 |",
+    ].join("\n"))
+
+    const index = await loadOutlineDocumentIndex("/book")
+    const outline = buildOutlineContext(index, 8)
+    const characters = buildRelevantCharacterBriefs(index, "本章由林岚入城")
+    const foreshadowing = buildRelevantForeshadowing(index, 8, "处理古钥匙伏笔")
+
+    expect(outline).toContain("总主线")
+    expect(outline).toContain("卷目标")
+    expect(outline).toContain("法术规则")
+    expect(outline).not.toContain("林岚人物详情")
+    expect(outline).not.toContain("古钥匙")
+    expect(characters).toContain("林岚人物详情")
+    expect(characters).not.toContain("周野人物详情")
+    expect(foreshadowing).toContain("古钥匙")
+  })
+})

+ 679 - 0
src/lib/novel/outline-context-index.ts

@@ -0,0 +1,679 @@
+import { listDirectory, readFile } from "@/commands/fs"
+import { mapWithConcurrency } from "@/lib/async-pool"
+import { parseFrontmatter } from "@/lib/frontmatter"
+import { normalizePath } from "@/lib/path-utils"
+import type { FileNode } from "@/types/wiki"
+
+export type OutlineSegmentKind =
+  | "master"
+  | "volume"
+  | "chapter-plan"
+  | "chapter"
+  | "character"
+  | "setting"
+  | "foreshadowing"
+  | "unknown"
+
+export interface OutlineDocument {
+  path: string
+  relativePath: string
+  folder?: string
+  kind: OutlineSegmentKind
+  content: string
+  frontmatter: Record<string, unknown> | null
+}
+
+export interface OutlineSegment {
+  path: string
+  relativePath: string
+  folder?: string
+  kind: OutlineSegmentKind
+  content: string
+  volumeScopeId?: string
+  frontmatter: Record<string, unknown> | null
+}
+
+export interface OutlineDocumentIndex {
+  projectPath: string
+  documents: OutlineDocument[]
+  segments: OutlineSegment[]
+}
+
+export interface ChapterOutlineResolution {
+  content: string
+  sourceKind: "standalone" | "volume" | "chapter-plan" | "master" | "none"
+  sourcePaths: string[]
+}
+
+export interface ResolvedVolume {
+  scopeId: string
+  title: string
+  sourcePaths: string[]
+  content: string
+  score: number
+}
+
+const STANDARD_FOLDER_KINDS: Record<string, OutlineSegmentKind> = {
+  大纲: "master",
+  总大纲: "master",
+  完整新书规划: "master",
+  卷纲: "volume",
+  章纲: "chapter",
+  章节细纲: "chapter",
+  章节规划表: "chapter-plan",
+  章节计划表: "chapter-plan",
+  人物小传: "character",
+  设定: "setting",
+  世界观: "setting",
+  地点设定: "setting",
+  势力设定: "setting",
+  力量体系: "setting",
+  金手指设定: "setting",
+  背景设定: "setting",
+  地理设定: "setting",
+  组织: "setting",
+  伏笔: "foreshadowing",
+  伏笔表: "foreshadowing",
+  伏笔计划: "foreshadowing",
+}
+
+const SOURCE_START = "<!-- qmai-outline-source:start -->"
+const SOURCE_END = "<!-- qmai-outline-source:end -->"
+const INDEX_READ_CONCURRENCY = 16
+const MAX_OUTLINE_DOCUMENT_CHARS = 18_000
+const MAX_CHAPTER_CONTEXT_CHARS = 14_000
+const indexRequests = new Map<string, Promise<OutlineDocumentIndex>>()
+
+function flattenMarkdownFiles(nodes: FileNode[]): FileNode[] {
+  const files: FileNode[] = []
+  for (const node of nodes) {
+    if (node.is_dir) {
+      if (node.children) files.push(...flattenMarkdownFiles(node.children))
+      continue
+    }
+    if (node.name.toLowerCase().endsWith(".md")) files.push(node)
+  }
+  return files
+}
+
+function relativeOutlinePath(root: string, path: string): string {
+  const normalizedRoot = normalizePath(root).replace(/\/$/, "")
+  const normalizedPath = normalizePath(path)
+  return normalizedPath.startsWith(`${normalizedRoot}/`)
+    ? normalizedPath.slice(normalizedRoot.length + 1)
+    : normalizedPath.split("/").pop() ?? normalizedPath
+}
+
+function scalarText(value: unknown): string {
+  if (typeof value === "string") return value.trim()
+  if (typeof value === "number" && Number.isFinite(value)) return String(value)
+  return ""
+}
+
+function kindFromFrontmatter(frontmatter: Record<string, unknown> | null): OutlineSegmentKind {
+  if (!frontmatter) return "unknown"
+  const type = scalarText(frontmatter.type).toLowerCase()
+  const outlineType = scalarText(frontmatter.outline_type).toLowerCase()
+  if (outlineType === "chapter-outline" || scalarText(frontmatter.chapter_number)) return "chapter"
+  if (outlineType === "volume-outline" || type === "volume" || scalarText(frontmatter.volume_number)) return "volume"
+  if (outlineType === "story-outline" || outlineType === "master-outline" || type === "overview") return "master"
+  if (outlineType === "setting-outline" || type === "concept" || type === "setting") return "setting"
+  if (type === "character") return "character"
+  if (type === "foreshadowing") return "foreshadowing"
+  if (type === "outline") return "master"
+  return "unknown"
+}
+
+function kindFromHeading(title: string): OutlineSegmentKind | null {
+  const compact = title.replace(/\s+/g, "")
+  if (/^第(?:\d+|[一二三四五六七八九十百千万]+)章(?:章纲|细纲|[::、\-—]|$)/i.test(compact) || /^chapter\d+\b/i.test(compact)) {
+    return "chapter"
+  }
+  if (/章节规划表|章节计划表|章节节拍表|章序表|卷节拍表|章节列表|章节安排/.test(compact)) return "chapter-plan"
+  if (/^第(?:\d+|[一二三四五六七八九十百千万]+)卷(?:[::、\-—]|$)|卷纲|分卷大纲/.test(compact)) return "volume"
+  if (/人物小传|人物设定|角色设定|主要人物|核心主角|核心配角|人物状态变化/.test(compact)) return "character"
+  if (/伏笔表|伏笔计划|伏笔清单|伏笔设计/.test(compact)) return "foreshadowing"
+  if (/世界观|背景设定|核心设定|规则设定|力量体系|能力体系|金手指|地理设定|地点设定|组织势力|势力设定/.test(compact)) return "setting"
+  if (/完整新书规划|总纲|故事大纲|全书大纲|总体规划/.test(compact)) return "master"
+  return null
+}
+
+function classifyDocument(relativePath: string, body: string, frontmatter: Record<string, unknown> | null): OutlineSegmentKind {
+  const firstPart = relativePath.split("/").filter(Boolean)[0] ?? ""
+  const folderKind = STANDARD_FOLDER_KINDS[firstPart]
+  if (folderKind) return folderKind
+  const fmKind = kindFromFrontmatter(frontmatter)
+  const firstHeading = body.match(/^#{1,6}\s+(.+)$/m)?.[1] ?? ""
+  const headingKind = kindFromHeading(firstHeading)
+  // 旧文件常把所有大纲统一写成 type:outline;此值不应盖过明确的卷/章/设定标题。
+  if (fmKind !== "unknown" && fmKind !== "master") return fmKind
+  return headingKind ?? fmKind
+}
+
+interface HeadingState {
+  level: number
+  kind: OutlineSegmentKind
+  volumeScopeId?: string
+}
+
+function splitSemanticSegments(document: OutlineDocument): OutlineSegment[] {
+  const lines = document.content.split(/\r?\n/)
+  const segments: OutlineSegment[] = []
+  const stack: HeadingState[] = []
+  let volumeCounter = 0
+  let currentKind = document.kind
+  let currentVolumeScope = document.kind === "volume" ? `${document.path}#document` : undefined
+  let currentLines: string[] = []
+
+  const flush = () => {
+    const content = currentLines.join("\n").trim()
+    if (content) {
+      segments.push({
+        path: document.path,
+        relativePath: document.relativePath,
+        folder: document.folder,
+        kind: currentKind,
+        content,
+        volumeScopeId: currentVolumeScope,
+        frontmatter: document.frontmatter,
+      })
+    }
+    currentLines = []
+  }
+
+  for (const line of lines) {
+    const heading = line.match(/^(#{1,6})\s+(.+)$/)
+    if (!heading) {
+      currentLines.push(line)
+      continue
+    }
+
+    const level = heading[1].length
+    const explicitKind = kindFromHeading(heading[2])
+    while (stack.length > 0 && stack[stack.length - 1].level >= level) stack.pop()
+    const parent = stack[stack.length - 1]
+    const nextKind = explicitKind ?? parent?.kind ?? document.kind
+    let nextVolumeScope = parent?.volumeScopeId ?? (document.kind === "volume" ? `${document.path}#document` : undefined)
+    if (explicitKind === "volume") {
+      volumeCounter += 1
+      nextVolumeScope = `${document.path}#volume-${volumeCounter}`
+    }
+
+    // 每个标题独立成段,既能识别“完整新书规划”中的混合目标,也能按人物名精确取小传。
+    if (currentLines.length > 0) flush()
+    currentKind = nextKind
+    currentVolumeScope = nextVolumeScope
+    currentLines.push(line)
+    stack.push({ level, kind: nextKind, volumeScopeId: nextVolumeScope })
+  }
+  flush()
+  return segments
+}
+
+async function buildIndex(projectPath: string): Promise<OutlineDocumentIndex> {
+  const pp = normalizePath(projectPath)
+  const outlinesRoot = `${pp}/wiki/outlines`
+  const tree = await listDirectory(outlinesRoot)
+  const files = flattenMarkdownFiles(tree).sort((left, right) =>
+    left.path.localeCompare(right.path, "zh-Hans-CN", { numeric: true }),
+  )
+  const loadedFiles = await mapWithConcurrency(files, INDEX_READ_CONCURRENCY, async (file) => ({
+    path: file.path,
+    content: await readFile(file.path).catch(() => ""),
+  }))
+  return createOutlineDocumentIndex(pp, loadedFiles)
+}
+
+export function createOutlineDocumentIndex(
+  projectPath: string,
+  files: Array<{ path: string; content: string }>,
+): OutlineDocumentIndex {
+  const pp = normalizePath(projectPath)
+  const outlinesRoot = `${pp}/wiki/outlines`
+  const loadedDocuments = files.map((file) => {
+    const raw = file.content
+    if (!raw.trim()) return null
+    const parsed = parseFrontmatter(raw)
+    const relativePath = relativeOutlinePath(outlinesRoot, file.path)
+    const folder = relativePath.includes("/") ? relativePath.split("/")[0] : undefined
+    const content = parsed.body.trim()
+    return {
+      path: file.path,
+      relativePath,
+      folder,
+      kind: classifyDocument(relativePath, content, parsed.frontmatter),
+      content,
+      frontmatter: parsed.frontmatter as Record<string, unknown> | null,
+    } satisfies OutlineDocument
+  })
+  const documents: OutlineDocument[] = loadedDocuments.filter(
+    (document): document is Exclude<typeof document, null> => document !== null,
+  )
+
+  return {
+    projectPath: pp,
+    documents,
+    segments: documents.flatMap(splitSemanticSegments),
+  }
+}
+
+export async function loadOutlineDocumentIndex(projectPath: string): Promise<OutlineDocumentIndex> {
+  const key = normalizePath(projectPath)
+  const pending = indexRequests.get(key)
+  if (pending) return pending
+  const request = buildIndex(key).finally(() => indexRequests.delete(key))
+  indexRequests.set(key, request)
+  return request
+}
+
+function numberToChinese(value: number): string {
+  const digits = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"]
+  if (value < 10) return digits[value] ?? String(value)
+  if (value === 10) return "十"
+  if (value < 20) return `十${digits[value - 10]}`
+  if (value < 100) return `${digits[Math.floor(value / 10)]}十${value % 10 ? digits[value % 10] : ""}`
+  if (value < 1000) {
+    const hundreds = Math.floor(value / 100)
+    const rest = value % 100
+    if (rest === 0) return `${digits[hundreds]}百`
+    return `${digits[hundreds]}百${rest < 10 ? "零" : ""}${numberToChinese(rest)}`
+  }
+  return String(value)
+}
+
+function chapterLabels(chapterNumber: number): string[] {
+  return [`第${chapterNumber}章`, `第${numberToChinese(chapterNumber)}章`, `Chapter ${chapterNumber}`, `chapter ${chapterNumber}`]
+}
+
+function exactChapterHeading(content: string, chapterNumber: number): boolean {
+  const labels = chapterLabels(chapterNumber).map((label) => label.replace(/\s+/g, "").toLowerCase())
+  return content.split(/\r?\n/).some((line) => {
+    const heading = line.match(/^#{1,6}\s*(.+)$/)?.[1]?.replace(/\s+/g, "").toLowerCase()
+    if (!heading) return false
+    const arabic = heading.match(/^第0*(\d+)章(?:章纲|细纲|[::、\-—]|$)/)
+    if (arabic && Number(arabic[1]) === chapterNumber) return true
+    return labels.some((label) =>
+      heading === label || heading === `${label}章纲` || heading === `${label}细纲` ||
+      heading.startsWith(`${label}:`) || heading.startsWith(`${label}:`) || heading.startsWith(`${label}-`) || heading.startsWith(`${label}—`),
+    )
+  })
+}
+
+function pathMatchesChapter(path: string, chapterNumber: number): boolean {
+  const compact = path.replace(/\s+/g, "")
+  return new RegExp(`第0*${chapterNumber}章`).test(compact) ||
+    compact.includes(`第${numberToChinese(chapterNumber)}章`) ||
+    new RegExp(`(?:chapter|ch)[-_ ]*0*${chapterNumber}(?:\\D|$)`, "i").test(path)
+}
+
+function frontmatterChapterNumber(frontmatter: Record<string, unknown> | null): number | undefined {
+  const value = Number(scalarText(frontmatter?.chapter_number))
+  return Number.isFinite(value) && value > 0 ? value : undefined
+}
+
+function parseMarkdownTableRows(content: string): Array<{ headers: string[]; cells: string[]; raw: string }> {
+  const rows: Array<{ headers: string[]; cells: string[]; raw: string }> = []
+  const lines = content.split(/\r?\n/)
+  for (let index = 0; index < lines.length - 2; index += 1) {
+    const headerLine = lines[index].trim()
+    const divider = lines[index + 1].trim()
+    if (!headerLine.includes("|") || !/^\|?\s*:?-{3,}/.test(divider)) continue
+    const headers = headerLine.replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim())
+    let rowIndex = index + 2
+    while (rowIndex < lines.length && lines[rowIndex].includes("|")) {
+      const raw = lines[rowIndex].trim()
+      const cells = raw.replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim())
+      if (cells.some(Boolean)) rows.push({ headers, cells, raw })
+      rowIndex += 1
+    }
+    index = rowIndex - 1
+  }
+  return rows
+}
+
+function chapterCellMatches(cell: string, chapterNumber: number): boolean {
+  const compact = cell.replace(/[*_`\s]/g, "")
+  const single = compact.match(/^第?0*(\d+)章?$/)
+  if (single && Number(single[1]) === chapterNumber) return true
+  if (compact === `第${numberToChinese(chapterNumber)}章`) return true
+  const range = compact.match(/^第?(\d+)章?[-—–~至](?:第)?(\d+)章?$/)
+  if (!range) return false
+  return chapterNumber >= Number(range[1]) && chapterNumber <= Number(range[2])
+}
+
+function chapterTableRows(content: string, chapterNumber: number): string[] {
+  const chapterHeader = /章节|章号|实际章号|chapter/i
+  return parseMarkdownTableRows(content)
+    .filter((row) => row.headers.some((header) => chapterHeader.test(header)))
+    .filter((row) => row.headers.some((header, index) => chapterHeader.test(header) && chapterCellMatches(row.cells[index] ?? "", chapterNumber)))
+    .map((row) => row.raw)
+}
+
+function explicitChapterLines(content: string, chapterNumber: number): string[] {
+  const exact = new RegExp(`^(?:[-*+]\\s+|\\d+[.)]\\s+)?(?:#{1,6}\\s*)?第\\s*0*${chapterNumber}\\s*章(?:[::、\\s\\-—]|$)`, "i")
+  return content.split(/\r?\n/).filter((line) => exact.test(line.trim()))
+}
+
+function bodyRangeContains(content: string, chapterNumber: number): boolean {
+  const patterns = [
+    /章节范围[^\d]{0,12}第?\s*(\d+)\s*章?\s*[-—–~至]\s*第?\s*(\d+)\s*章?/gi,
+    /章号[^\d]{0,12}(\d+)\s*[-—–~至]\s*(\d+)/gi,
+  ]
+  return patterns.some((pattern) => {
+    for (const match of content.matchAll(pattern)) {
+      if (chapterNumber >= Number(match[1]) && chapterNumber <= Number(match[2])) return true
+    }
+    return false
+  })
+}
+
+function frontmatterRangeContains(frontmatter: Record<string, unknown> | null, chapterNumber: number): boolean {
+  const start = Number(scalarText(frontmatter?.chapter_range_start))
+  const end = Number(scalarText(frontmatter?.chapter_range_end))
+  return Number.isFinite(start) && Number.isFinite(end) && chapterNumber >= start && chapterNumber <= end
+}
+
+function volumeMatchScore(segments: OutlineSegment[], chapterNumber: number): number {
+  const content = segments.map((segment) => segment.content).join("\n\n")
+  if (chapterTableRows(content, chapterNumber).length > 0) return 100
+  if (exactChapterHeading(content, chapterNumber) || explicitChapterLines(content, chapterNumber).length > 0) return 90
+  if (segments.some((segment) => frontmatterRangeContains(segment.frontmatter, chapterNumber))) return 70
+  if (bodyRangeContains(content, chapterNumber)) return 60
+  return 0
+}
+
+function firstHeading(content: string): string {
+  return content.match(/^#{1,6}\s+(.+)$/m)?.[1]?.trim() ?? "卷纲"
+}
+
+export function resolveTargetVolumes(index: OutlineDocumentIndex, chapterNumber: number): ResolvedVolume[] {
+  const grouped = new Map<string, OutlineSegment[]>()
+  for (const segment of index.segments) {
+    if (!segment.volumeScopeId) continue
+    const list = grouped.get(segment.volumeScopeId) ?? []
+    list.push(segment)
+    grouped.set(segment.volumeScopeId, list)
+  }
+  const candidates = Array.from(grouped.entries()).map(([scopeId, segments]) => {
+    // 卷匹配和章纲兜底需要看到卷内的章节段;outline 主上下文会在 buildOutlineContext 中另行排除这些段。
+    const content = segments.map((segment) => segment.content).join("\n\n")
+    return {
+      scopeId,
+      title: firstHeading(content),
+      sourcePaths: Array.from(new Set(segments.map((segment) => segment.relativePath))),
+      content,
+      score: volumeMatchScore(segments, chapterNumber),
+    }
+  }).filter((candidate) => candidate.score > 0)
+  const bestScore = Math.max(0, ...candidates.map((candidate) => candidate.score))
+  return candidates.filter((candidate) => candidate.score === bestScore)
+}
+
+function excerptHeadTail(content: string, maxChars: number): string {
+  const trimmed = content.trim()
+  if (trimmed.length <= maxChars) return trimmed
+  const marker = "\n\n【本资料过长,中段已按上下文预算省略】\n\n"
+  const room = Math.max(0, maxChars - marker.length)
+  const headChars = Math.floor(room * 0.58)
+  return `${trimmed.slice(0, headChars).trimEnd()}${marker}${trimmed.slice(-(room - headChars)).trimStart()}`
+}
+
+function sourceBlock(kind: OutlineSegmentKind, relativePath: string, content: string): string {
+  return [
+    SOURCE_START,
+    `## 大纲来源:${relativePath}(${kind})`,
+    "",
+    excerptHeadTail(content, MAX_OUTLINE_DOCUMENT_CHARS),
+    SOURCE_END,
+  ].join("\n")
+}
+
+function uniqueSourceBlocks(sources: Array<{ kind: OutlineSegmentKind; relativePath: string; content: string }>): string[] {
+  const seen = new Set<string>()
+  const blocks: string[] = []
+  for (const source of sources) {
+    const normalized = source.content.trim()
+    if (!normalized) continue
+    const key = `${source.kind}\u0000${source.relativePath}\u0000${normalized}`
+    if (seen.has(key)) continue
+    seen.add(key)
+    blocks.push(sourceBlock(source.kind, source.relativePath, normalized))
+  }
+  return blocks
+}
+
+function groupSourcesByDocument(
+  sources: Array<{ kind: OutlineSegmentKind; relativePath: string; content: string }>,
+): Array<{ kind: OutlineSegmentKind; relativePath: string; content: string }> {
+  const grouped = new Map<string, { kind: OutlineSegmentKind; relativePath: string; contents: string[] }>()
+  for (const source of sources) {
+    const key = `${source.kind}\u0000${source.relativePath}`
+    const existing = grouped.get(key)
+    if (existing) {
+      existing.contents.push(source.content)
+    } else {
+      grouped.set(key, {
+        kind: source.kind,
+        relativePath: source.relativePath,
+        contents: [source.content],
+      })
+    }
+  }
+  return Array.from(grouped.values()).map((source) => ({
+    kind: source.kind,
+    relativePath: source.relativePath,
+    content: source.contents.map((content) => content.trim()).filter(Boolean).join("\n\n"),
+  }))
+}
+
+export function buildOutlineContext(index: OutlineDocumentIndex, chapterNumber?: number): string {
+  const master = index.segments.filter((segment) => segment.kind === "master")
+  const settings = index.segments.filter((segment) => segment.kind === "setting")
+  let volumes: OutlineSegment[] = []
+  let chapterPlans: OutlineSegment[] = []
+  if (chapterNumber) {
+    const scopeIds = new Set(resolveTargetVolumes(index, chapterNumber).map((volume) => volume.scopeId))
+    volumes = index.segments.filter((segment) => segment.volumeScopeId && scopeIds.has(segment.volumeScopeId) && segment.kind === "volume")
+    chapterPlans = index.segments.filter((segment) =>
+      segment.kind === "chapter-plan" && (!segment.volumeScopeId || scopeIds.has(segment.volumeScopeId)),
+    )
+  } else {
+    volumes = index.segments.filter((segment) => segment.kind === "volume")
+    chapterPlans = index.segments.filter((segment) => segment.kind === "chapter-plan")
+  }
+  return uniqueSourceBlocks(groupSourcesByDocument([...master, ...volumes, ...chapterPlans, ...settings])).join("\n\n")
+}
+
+function extractHeadingSection(content: string, chapterNumber: number): string {
+  const lines = content.split(/\r?\n/)
+  for (let index = 0; index < lines.length; index += 1) {
+    const heading = lines[index].match(/^(#{1,6})\s+(.+)$/)
+    if (!heading || !exactChapterHeading(lines[index], chapterNumber)) continue
+    const level = heading[1].length
+    let end = index + 1
+    while (end < lines.length) {
+      const next = lines[end].match(/^(#{1,6})\s+/)
+      if (next && next[1].length <= level) break
+      end += 1
+    }
+    return lines.slice(index, end).join("\n").trim()
+  }
+  return ""
+}
+
+function stableIdentifiers(lines: string[]): string[] {
+  const ids = new Set<string>()
+  for (const line of lines) {
+    for (const match of line.matchAll(/\b[A-Z][A-Z0-9]*-\d{2,}\b/g)) ids.add(match[0])
+  }
+  return Array.from(ids)
+}
+
+function extractIdentifierSections(content: string, identifiers: string[]): string[] {
+  if (identifiers.length === 0) return []
+  const lines = content.split(/\r?\n/)
+  const sections: string[] = []
+  for (let index = 0; index < lines.length; index += 1) {
+    const heading = lines[index].match(/^(#{1,6})\s+(.+)$/)
+    if (!heading || !identifiers.some((identifier) => heading[2].includes(identifier))) continue
+    const level = heading[1].length
+    let end = index + 1
+    while (end < lines.length) {
+      const next = lines[end].match(/^(#{1,6})\s+/)
+      if (next && next[1].length <= level) break
+      end += 1
+    }
+    sections.push(lines.slice(index, end).join("\n").trim())
+    index = end - 1
+  }
+  return sections
+}
+
+function extractStructuredChapterContent(content: string, chapterNumber: number): string {
+  const headingSection = extractHeadingSection(content, chapterNumber)
+  const rows = chapterTableRows(content, chapterNumber)
+  const lines = explicitChapterLines(content, chapterNumber)
+  const base = [headingSection, ...rows, ...lines].filter(Boolean)
+  const ids = stableIdentifiers(base)
+  const identifierSections = extractIdentifierSections(content, ids)
+  const relatedLines = ids.length > 0
+    ? content.split(/\r?\n/).filter((line) => ids.some((id) => line.includes(id)))
+    : []
+  return Array.from(new Set([...base, ...identifierSections, ...relatedLines])).join("\n\n").trim()
+}
+
+function standaloneChapterMatches(document: OutlineDocument, chapterNumber: number): boolean {
+  const titleLine = document.content.match(/^#{1,6}\s+.+$/m)?.[0] ?? ""
+  return frontmatterChapterNumber(document.frontmatter) === chapterNumber ||
+    pathMatchesChapter(document.relativePath, chapterNumber) ||
+    exactChapterHeading(titleLine, chapterNumber)
+}
+
+export function resolveChapterOutline(index: OutlineDocumentIndex, chapterNumber: number): ChapterOutlineResolution {
+  const standalone = index.documents.find((document) =>
+    document.kind === "chapter" && standaloneChapterMatches(document, chapterNumber),
+  )
+  if (standalone) {
+    return {
+      content: excerptHeadTail(standalone.content, MAX_CHAPTER_CONTEXT_CHARS),
+      sourceKind: "standalone",
+      sourcePaths: [standalone.relativePath],
+    }
+  }
+
+  const volumes = resolveTargetVolumes(index, chapterNumber)
+  const volumeExtracts = volumes.map((volume) => extractStructuredChapterContent(volume.content, chapterNumber)).filter(Boolean)
+  if (volumeExtracts.length > 0) {
+    const sourcePaths = Array.from(new Set(volumes.flatMap((volume) => volume.sourcePaths)))
+    return {
+      content: [
+        `【章纲来源:卷纲兜底】未找到第${chapterNumber}章独立章纲,以下内容从目标卷纲按章节结构精确摘录。`,
+        ...volumeExtracts,
+      ].join("\n\n").slice(0, MAX_CHAPTER_CONTEXT_CHARS),
+      sourceKind: "volume",
+      sourcePaths,
+    }
+  }
+
+  for (const kind of ["chapter-plan", "master"] as const) {
+    const matches = index.segments
+      .filter((segment) => segment.kind === kind)
+      .map((segment) => ({ segment, content: extractStructuredChapterContent(segment.content, chapterNumber) }))
+      .filter((item) => item.content)
+    if (matches.length > 0) {
+      return {
+        content: [
+          `【章纲来源:${kind === "chapter-plan" ? "章节规划表" : "总纲"}兜底】未找到第${chapterNumber}章独立章纲,以下为结构化目标摘录。`,
+          ...matches.map((item) => item.content),
+        ].join("\n\n").slice(0, MAX_CHAPTER_CONTEXT_CHARS),
+        sourceKind: kind,
+        sourcePaths: Array.from(new Set(matches.map((item) => item.segment.relativePath))),
+      }
+    }
+  }
+  return { content: "", sourceKind: "none", sourcePaths: [] }
+}
+
+export function buildVolumeContext(index: OutlineDocumentIndex, chapterNumber: number): string {
+  return resolveTargetVolumes(index, chapterNumber).map((volume) => [
+    `所属卷纲:${volume.title}`,
+    `来源:${volume.sourcePaths.join("、")}`,
+    ...chapterTableRows(volume.content, chapterNumber),
+  ].filter(Boolean).join("\n")).join("\n\n")
+}
+
+function parseMarkedSourceBlocks(outline: string): string[] {
+  const escapedStart = SOURCE_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+  const escapedEnd = SOURCE_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+  const regex = new RegExp(`${escapedStart}([\\s\\S]*?)${escapedEnd}`, "g")
+  return Array.from(outline.matchAll(regex), (match) => `${SOURCE_START}${match[1]}${SOURCE_END}`.trim())
+}
+
+export function capOutlineSourcesToBudget(outline: string, charCap: number): string {
+  const trimmed = outline.trim()
+  if (charCap <= 0 || trimmed.length <= charCap) return trimmed
+  const blocks = parseMarkedSourceBlocks(trimmed)
+  if (blocks.length === 0) return excerptHeadTail(trimmed, charCap)
+
+  const kept = new Array<string>(blocks.length)
+  const pending = new Set(blocks.map((_, index) => index))
+  let remaining = charCap - Math.max(0, blocks.length - 1) * 2
+  while (pending.size > 0 && remaining > 0) {
+    const share = Math.floor(remaining / pending.size)
+    let consumedSmallBlock = false
+    for (const index of Array.from(pending)) {
+      if (blocks[index].length <= share) {
+        kept[index] = blocks[index]
+        remaining -= blocks[index].length
+        pending.delete(index)
+        consumedSmallBlock = true
+      }
+    }
+    if (!consumedSmallBlock) {
+      for (const index of pending) kept[index] = excerptHeadTail(blocks[index], share)
+      remaining = 0
+    }
+  }
+  return kept.filter(Boolean).join("\n\n").slice(0, charCap)
+}
+
+function characterAliases(segment: OutlineSegment): string[] {
+  const aliases = new Set<string>()
+  const stem = segment.relativePath.split("/").pop()?.replace(/\.md$/i, "").replace(/^(?:角色|人物小传)[-_::]*/, "").trim()
+  if (stem) aliases.add(stem)
+  const title = scalarText(segment.frontmatter?.title)
+  const name = scalarText(segment.frontmatter?.name)
+  if (title) aliases.add(title.replace(/^(?:角色|人物小传)[-_::]*/, "").trim())
+  if (name) aliases.add(name)
+  const heading = firstHeading(segment.content).replace(/^(?:人物小传|人物设定|角色设定)[-_::]*/, "").trim()
+  if (heading && heading.length <= 24) aliases.add(heading)
+  return Array.from(aliases).filter((alias) => alias.length >= 2)
+}
+
+export function buildRelevantCharacterBriefs(index: OutlineDocumentIndex, matchingText: string): string {
+  const matches = index.segments.filter((segment) =>
+    segment.kind === "character" && characterAliases(segment).some((alias) => matchingText.includes(alias)),
+  )
+  return uniqueSourceBlocks(groupSourcesByDocument(matches)).join("\n\n")
+}
+
+export function buildRelevantForeshadowing(index: OutlineDocumentIndex, chapterNumber: number, matchingText: string): string {
+  const hintMatches = Array.from(matchingText.matchAll(/(?:伏笔|铺垫|悬念)[::]\s*([^\n]+)/gi), (match) => match[1].trim()).filter(Boolean)
+  const results: Array<{ kind: OutlineSegmentKind; relativePath: string; content: string }> = []
+  for (const segment of index.segments.filter((item) => item.kind === "foreshadowing")) {
+    const rows = parseMarkdownTableRows(segment.content).filter((row) => {
+      const chapterColumnsMatch = row.headers.some((header, index) =>
+        /章节|埋设|推进|回收|resolve|chapter/i.test(header) && chapterCellMatches(row.cells[index] ?? "", chapterNumber),
+      )
+      const hintMatch = hintMatches.some((hint) => row.raw.includes(hint))
+      return chapterColumnsMatch || hintMatch
+    }).map((row) => row.raw)
+    const lines = segment.content.split(/\r?\n/).filter((line) =>
+      (/伏笔|埋设|推进|回收/.test(line) && line.replace(/\s+/g, "").includes(`第${chapterNumber}章`)) ||
+      hintMatches.some((hint) => line.includes(hint)),
+    )
+    const content = Array.from(new Set([...rows, ...lines])).join("\n")
+    if (content.trim()) results.push({ kind: "foreshadowing", relativePath: segment.relativePath, content })
+  }
+  return uniqueSourceBlocks(results).join("\n\n")
+}

+ 24 - 8
src/lib/novel/previous-chapters-analysis.ts

@@ -33,20 +33,17 @@ export interface PreviousChapterAnalysis {
 }
 
 /**
- * 读取并分析前几章的完整内容
+ * 读取前 N 章正文(去 frontmatter),不做 LLM 分析。读取失败的章节跳过。
  */
-export async function analyzePreviousChapters(
+export async function readPreviousChapterBodies(
   projectPath: string,
   currentChapterNumber: number,
-  llmConfig: LlmConfig,
   analysisCount: number = 3,
   signal?: AbortSignal,
-): Promise<string> {
-  if (currentChapterNumber <= 1) return ""
+): Promise<Array<{ number: number; content: string }>> {
+  if (currentChapterNumber <= 1) return []
 
   const previousChapters: Array<{ number: number; content: string }> = []
-
-  // 读取前N章的完整内容
   for (let i = Math.max(1, currentChapterNumber - analysisCount); i < currentChapterNumber; i++) {
     if (signal?.aborted) throw new Error("已停止生成")
     try {
@@ -55,12 +52,31 @@ export async function analyzePreviousChapters(
         const content = await readFile(results[0].path)
         const bodyStart = content.indexOf("---", 4)
         const body = bodyStart >= 0 ? content.slice(bodyStart + 3).trim() : content
-        previousChapters.push({ number: i, content: body })
+        if (body) previousChapters.push({ number: i, content: body })
       }
     } catch {
       // 忽略读取失败的章节
     }
   }
+  return previousChapters
+}
+
+/**
+ * 读取并分析前几章的完整内容
+ */
+export async function analyzePreviousChapters(
+  projectPath: string,
+  currentChapterNumber: number,
+  llmConfig: LlmConfig,
+  analysisCount: number = 3,
+  signal?: AbortSignal,
+): Promise<string> {
+  const previousChapters = await readPreviousChapterBodies(
+    projectPath,
+    currentChapterNumber,
+    analysisCount,
+    signal,
+  )
 
   if (previousChapters.length === 0) return ""
 

+ 2 - 0
src/lib/novel/review-adapter.ts

@@ -20,6 +20,7 @@ export interface NovelReviewResult {
 
 export interface NovelReviewCallbacks {
   onThinking?: (content: string) => void
+  onRequestTrace?: StreamCallbacks["onRequestTrace"]
 }
 
 export interface ReviewChapterOptions extends NovelReviewCallbacks {
@@ -418,6 +419,7 @@ async function runReviewStage(
     onError: (error: Error) => {
       console.error("[Novel Review] Stream error:", error)
     },
+    onRequestTrace: callbacks.onRequestTrace,
   }
 
   const timeoutController = new AbortController()

+ 37 - 4
src/lib/novel/section-briefing.ts

@@ -12,6 +12,15 @@ import {
   loadForeshadowingTracker,
   createEmptyForeshadowingStore,
 } from "./foreshadowing-tracker"
+import {
+  buildRelevantCharacterBriefs,
+  buildRelevantForeshadowing,
+  capOutlineSourcesToBudget,
+  loadOutlineDocumentIndex,
+} from "./outline-context-index"
+
+const RELEVANT_CHARACTER_BRIEFS_MAX_CHARS = 8_000
+const RELEVANT_FORESHADOWING_MAX_CHARS = 6_000
 
 /**
  * 从细纲文本中提取出场角色名
@@ -73,13 +82,14 @@ export async function buildSectionBriefing(
   projectPath: string,
   chapterNumber: number,
   chapterOutlineContent: string,
+  task = "",
 ): Promise<string> {
   const sections: string[] = []
   const trimmedOutline = chapterOutlineContent.trim()
-  if (!trimmedOutline) return ""
+  const matchingText = [task, trimmedOutline].filter(Boolean).join("\n")
 
   // ── 1. 提取出场角色并筛选角色状态 ──────────────────
-  const characterNames = extractCharacterNames(trimmedOutline)
+  const characterNames = extractCharacterNames(matchingText)
 
   if (characterNames.length > 0) {
     const charStore = await readCharacterStateMd(projectPath)
@@ -111,6 +121,29 @@ export async function buildSectionBriefing(
     }
   }
 
+  try {
+    const outlineIndex = await loadOutlineDocumentIndex(projectPath)
+    const characterBriefs = capOutlineSourcesToBudget(
+      buildRelevantCharacterBriefs(outlineIndex, matchingText),
+      RELEVANT_CHARACTER_BRIEFS_MAX_CHARS,
+    )
+    if (characterBriefs) {
+      sections.push("### 相关人物小传")
+      sections.push(characterBriefs)
+      sections.push("")
+    }
+
+    const outlineForeshadowing = capOutlineSourcesToBudget(
+      buildRelevantForeshadowing(outlineIndex, chapterNumber, matchingText),
+      RELEVANT_FORESHADOWING_MAX_CHARS,
+    )
+    if (outlineForeshadowing) {
+      sections.push("### 相关伏笔规划")
+      sections.push(outlineForeshadowing)
+      sections.push("")
+    }
+  } catch {}
+
   // ── 2. 筛选相关伏笔 ────────────────────────────────
   // 先尝试读取新版 tracking 数据
   let fStore = await loadForeshadowingTracker(projectPath).catch(() => null)
@@ -120,7 +153,7 @@ export async function buildSectionBriefing(
     fStore = mdResult.store
   }
 
-  const foreshadowingHints = extractForeshadowingHints(trimmedOutline)
+  const foreshadowingHints = extractForeshadowingHints(matchingText)
 
   const relevantForeshadowing = fStore.items.filter((f) => {
     if (f.status === "abandoned") return false
@@ -194,4 +227,4 @@ export async function buildSectionBriefing(
     "",
     ...sections,
   ].join("\n")
-}
+}

+ 244 - 0
src/lib/novel/writing-entity-web-search.spec.ts

@@ -0,0 +1,244 @@
+import { describe, expect, it, vi } from "vitest"
+import type { LlmConfig, SearchApiConfig } from "@/stores/wiki-store"
+import type { ChatMessage, StreamCallbacks } from "@/lib/llm-client"
+import type { ContextPack } from "./context-engine"
+import {
+  buildLocalWritingCorpus,
+  collectWritingEntityWebSearch,
+  formatWritingEntitySearchMarkdown,
+  isLocallyResolvedEntity,
+  isWebSearchConfigured,
+  parseExtractedEntityNames,
+  parseNeedExternalNames,
+  selectUnresolvedEntities,
+  WRITING_ENTITY_SEARCH_HEADING,
+} from "./writing-entity-web-search"
+
+const llmConfig = {
+  provider: "custom",
+  apiKey: "test-key",
+  model: "test-model",
+  ollamaUrl: "",
+  customEndpoint: "https://example.test/v1",
+  maxContextSize: 120000,
+} satisfies LlmConfig
+
+const configuredSearch: SearchApiConfig = {
+  provider: "bocha",
+  apiKey: "search-key",
+  serpApiEngine: "google",
+  searXngUrl: "",
+  searXngCategories: ["general"],
+  providerConfigs: {},
+}
+
+const pack: ContextPack = {
+  task: "写第三章,黄蓉出场",
+  chapterGoal: "黄蓉与郭靖会合",
+  outline: "第3章:郭靖在客栈等候。",
+  recentSummaries: ["第1章:郭靖离乡。"],
+  previousChapterEnding: "客栈门帘掀开。",
+  characterStates: "郭靖刚到中原。",
+  soulDoc: "",
+  characterAuras: "",
+  storyFrameworkBinding: "",
+  cognitionStates: "",
+  foreshadowingStates: "",
+  timeline: "",
+  relatedSettings: "",
+  canonRules: "",
+  writingStyle: "",
+  searchResults: "",
+  graphSearchResults: "",
+  mustDo: "",
+  mustAvoid: "",
+  nextChapterAdvice: "",
+  revisionDirectives: "",
+}
+
+function streamChatReturning(responses: string[]) {
+  let index = 0
+  return vi.fn(async (_config: LlmConfig, _messages: ChatMessage[], callbacks: StreamCallbacks) => {
+    callbacks.onToken(responses[Math.min(index, responses.length - 1)] ?? "")
+    index += 1
+    callbacks.onDone()
+  })
+}
+
+describe("writing entity local lookup", () => {
+  it("treats entity-table or previous-text hits as resolved", () => {
+    const corpus = buildLocalWritingCorpus(pack, ["前文里出现过穆念慈。"])
+    expect(isLocallyResolvedEntity("郭靖", corpus, ["黄蓉"])).toBe(true)
+    expect(isLocallyResolvedEntity("穆念慈", corpus, [])).toBe(true)
+    expect(isLocallyResolvedEntity("黄蓉", "无关正文", ["黄蓉"])).toBe(true)
+    expect(isLocallyResolvedEntity("降龙十八掌", corpus, ["黄蓉"])).toBe(false)
+  })
+
+  it("does not treat names that only appear in the chapter outline as resolved", () => {
+    const corpus = buildLocalWritingCorpus({
+      ...pack,
+      outline: "第3章:李鸿章在总理衙门与赫德会面。",
+      chapterGoal: "李鸿章与赫德谈判",
+    })
+    expect(corpus).not.toContain("李鸿章")
+    expect(corpus).not.toContain("赫德")
+    expect(isLocallyResolvedEntity("李鸿章", corpus, [])).toBe(false)
+    expect(isLocallyResolvedEntity("赫德", corpus, [])).toBe(false)
+    expect(isLocallyResolvedEntity("郭靖", corpus, [])).toBe(true)
+  })
+
+  it("selects only names missing from both corpus and entity table", () => {
+    const corpus = buildLocalWritingCorpus(pack)
+    expect(selectUnresolvedEntities(["郭靖", "黄蓉", "降龙十八掌"], corpus, ["黄蓉"])).toEqual([
+      "降龙十八掌",
+    ])
+  })
+})
+
+describe("writing entity parse helpers", () => {
+  it("parses extracted entity names from JSON", () => {
+    expect(parseExtractedEntityNames('{"entities":["黄蓉","降龙十八掌"]}')).toEqual(["黄蓉", "降龙十八掌"])
+    expect(parseExtractedEntityNames("```json\n[\"郭靖\"]\n```")).toEqual(["郭靖"])
+  })
+
+  it("parses needExternal names against candidates", () => {
+    expect(parseNeedExternalNames('{"needExternal":["黄蓉","原创甲"]}', ["黄蓉", "降龙十八掌"])).toEqual(["黄蓉"])
+    expect(parseNeedExternalNames('{"entities":[{"name":"黄蓉","needExternal":true},{"name":"林烬","needExternal":false}]}', ["黄蓉", "林烬"])).toEqual(["黄蓉"])
+  })
+})
+
+describe("isWebSearchConfigured", () => {
+  it("rejects missing provider or api key", () => {
+    expect(isWebSearchConfigured(null)).toBe(false)
+    expect(isWebSearchConfigured({
+      provider: "none",
+      apiKey: "",
+      searXngUrl: "",
+      searXngCategories: ["general"],
+    })).toBe(false)
+    expect(isWebSearchConfigured(configuredSearch)).toBe(true)
+  })
+})
+
+describe("collectWritingEntityWebSearch", () => {
+  it("skips search when the provider is not configured", async () => {
+    const search = vi.fn()
+    const result = await collectWritingEntityWebSearch({
+      projectPath: "/project",
+      userRequest: "写一章黄蓉出场",
+      contextPack: pack,
+      streamChat: streamChatReturning(['{"entities":["黄蓉"]}']),
+      llmConfig,
+      searchApiConfig: { provider: "none", apiKey: "", searXngUrl: "", searXngCategories: ["general"] },
+      search,
+    })
+    expect(search).not.toHaveBeenCalled()
+    expect(result.markdown).toBe("")
+    expect(result.notes).toContain("未配置外部搜索")
+  })
+
+  it("does not search names found in previous text or the entity table", async () => {
+    const search = vi.fn()
+    const result = await collectWritingEntityWebSearch({
+      projectPath: "/project",
+      userRequest: "写一章郭靖和黄蓉出场",
+      contextPack: pack,
+      streamChat: streamChatReturning([
+        '{"entities":["郭靖","黄蓉"]}',
+        '{"needExternal":["郭靖","黄蓉"]}',
+      ]),
+      llmConfig,
+      searchApiConfig: configuredSearch,
+      listEntityNames: async () => ["黄蓉"],
+      readPreviousBodies: async () => [],
+      search,
+    })
+    expect(search).not.toHaveBeenCalled()
+    expect(result.searchedNames).toEqual([])
+    expect(result.markdown).toBe("")
+  })
+
+  it("searches outline-only names the model marks as needExternal", async () => {
+    const search = vi.fn(async (query: string) => [{
+      title: `${query} 资料`,
+      url: `https://example.test/${encodeURIComponent(query)}`,
+      snippet: "公开资料摘要",
+      source: "example.test",
+    }])
+    const result = await collectWritingEntityWebSearch({
+      projectPath: "/project",
+      userRequest: "写一章李鸿章出场",
+      outline: "第3章:李鸿章在总理衙门。",
+      contextPack: {
+        ...pack,
+        outline: "第3章:李鸿章在总理衙门。",
+        chapterGoal: "李鸿章与赫德谈判",
+      },
+      streamChat: streamChatReturning([
+        '{"entities":["李鸿章"]}',
+        '{"needExternal":["李鸿章"]}',
+      ]),
+      llmConfig,
+      searchApiConfig: configuredSearch,
+      listEntityNames: async () => ["黄蓉"],
+      readPreviousBodies: async () => [],
+      search,
+    })
+    expect(search).toHaveBeenCalledWith("李鸿章", configuredSearch, 4)
+    expect(result.searchedNames).toEqual(["李鸿章"])
+  })
+
+  it("searches unresolved names the model marks as needExternal", async () => {
+    const search = vi.fn(async (query: string) => [{
+      title: `${query} 资料`,
+      url: `https://example.test/${encodeURIComponent(query)}`,
+      snippet: "公开资料摘要",
+      source: "example.test",
+    }])
+    const result = await collectWritingEntityWebSearch({
+      projectPath: "/project",
+      userRequest: "写一章降龙十八掌对决",
+      contextPack: pack,
+      streamChat: streamChatReturning([
+        '{"entities":["降龙十八掌"]}',
+        '{"needExternal":["降龙十八掌"]}',
+      ]),
+      llmConfig,
+      searchApiConfig: configuredSearch,
+      listEntityNames: async () => ["黄蓉"],
+      readPreviousBodies: async () => [],
+      search,
+    })
+    expect(search).toHaveBeenCalledWith("降龙十八掌", configuredSearch, 4)
+    expect(result.searchedNames).toEqual(["降龙十八掌"])
+    expect(result.markdown).toContain(WRITING_ENTITY_SEARCH_HEADING)
+    expect(result.markdown).toContain("降龙十八掌")
+    expect(result.markdown).toContain("公开资料摘要")
+  })
+
+  it("does not search original names the model can invent", async () => {
+    const search = vi.fn()
+    const result = await collectWritingEntityWebSearch({
+      projectPath: "/project",
+      userRequest: "写一章林烬出场",
+      contextPack: pack,
+      streamChat: streamChatReturning([
+        '{"entities":["林烬"]}',
+        '{"needExternal":[]}',
+      ]),
+      llmConfig,
+      searchApiConfig: configuredSearch,
+      listEntityNames: async () => ["黄蓉"],
+      readPreviousBodies: async () => [],
+      search,
+    })
+    expect(search).not.toHaveBeenCalled()
+    expect(result.searchedNames).toEqual([])
+  })
+})
+
+describe("formatWritingEntitySearchMarkdown", () => {
+  it("returns empty string without results", () => {
+    expect(formatWritingEntitySearchMarkdown([])).toBe("")
+  })
+})

+ 381 - 0
src/lib/novel/writing-entity-web-search.ts

@@ -0,0 +1,381 @@
+import type { LlmConfig, SearchApiConfig } from "@/stores/wiki-store"
+import type { ChatMessage, RequestOverrides, StreamCallbacks } from "@/lib/llm-client"
+import { providerRequiresApiKey, resolveSearchConfig, webSearch, type WebSearchResult } from "@/lib/web-search"
+import { rethrowIfUserAbort, throwIfAborted } from "@/lib/user-abort"
+import { listLocalEntityNames } from "./local-entity-names"
+import { readPreviousChapterBodies } from "./previous-chapters-analysis"
+import type { ContextPack } from "./context-engine"
+
+export const WRITING_ENTITY_SEARCH_HEADING = "外部检索(仅补本地缺失实体)"
+const MIN_NAME_LENGTH = 2
+const MAX_EXTRACTED_ENTITIES = 12
+const MAX_SEARCH_QUERIES = 3
+const SOURCE_TEXT_CHAR_CAP = 8000
+
+export interface WritingEntityWebSearchResult {
+  markdown: string
+  searchedNames: string[]
+  notes: string[]
+}
+
+export interface CollectWritingEntityWebSearchInput {
+  projectPath: string
+  userRequest: string
+  outline?: string
+  planBlueprint?: string
+  contextPack: ContextPack
+  chapterNumber?: number
+  previousChaptersAnalysis?: string
+  streamChat: (
+    config: LlmConfig,
+    messages: ChatMessage[],
+    callbacks: StreamCallbacks,
+    signal?: AbortSignal,
+    requestOverrides?: RequestOverrides,
+  ) => Promise<void>
+  llmConfig: LlmConfig
+  searchApiConfig?: SearchApiConfig | null
+  signal?: AbortSignal
+  onRequestTrace?: StreamCallbacks["onRequestTrace"]
+  listEntityNames?: typeof listLocalEntityNames
+  readPreviousBodies?: typeof readPreviousChapterBodies
+  search?: typeof webSearch
+}
+
+export function isWebSearchConfigured(
+  config: SearchApiConfig | null | undefined,
+): config is SearchApiConfig {
+  if (!config) return false
+  const resolved = resolveSearchConfig(config)
+  if (resolved.provider === "none") return false
+  if (providerRequiresApiKey(resolved.provider) && !resolved.apiKey?.trim()) return false
+  if (resolved.provider === "searxng" && !resolved.searXngUrl?.trim()) return false
+  return true
+}
+
+export function buildLocalWritingCorpus(
+  pack: Pick<
+    ContextPack,
+    | "characterStates"
+    | "characterAuras"
+    | "relatedSettings"
+    | "canonRules"
+    | "cognitionStates"
+    | "foreshadowingStates"
+    | "previousChapterEnding"
+    | "recentSummaries"
+    | "searchResults"
+    | "soulDoc"
+  >,
+  extraTexts: readonly string[] = [],
+): string {
+  // 故意不纳入 outline / chapterGoal:实体正是从本章大纲抽出的,
+  // 再拿同一份大纲当「本地已有」会把几乎所有名字短路掉。
+  return [
+    pack.characterStates,
+    pack.characterAuras,
+    pack.relatedSettings,
+    pack.canonRules,
+    pack.cognitionStates,
+    pack.foreshadowingStates,
+    pack.previousChapterEnding,
+    pack.searchResults,
+    pack.soulDoc,
+    ...(pack.recentSummaries ?? []),
+    ...extraTexts,
+  ]
+    .filter((item): item is string => typeof item === "string" && item.trim().length > 0)
+    .join("\n")
+}
+
+export function isLocallyResolvedEntity(
+  name: string,
+  corpus: string,
+  entityNames: readonly string[],
+): boolean {
+  const trimmed = name.trim()
+  if (trimmed.length < MIN_NAME_LENGTH) return true
+  if (corpus.includes(trimmed)) return true
+  return entityNames.some((entityName) => (
+    entityName.length >= MIN_NAME_LENGTH
+    && (trimmed.includes(entityName) || entityName.includes(trimmed))
+  ))
+}
+
+export function selectUnresolvedEntities(
+  names: readonly string[],
+  corpus: string,
+  entityNames: readonly string[],
+): string[] {
+  const unique: string[] = []
+  for (const raw of names) {
+    const name = raw.trim()
+    if (name.length < MIN_NAME_LENGTH) continue
+    if (unique.some((item) => item === name)) continue
+    if (isLocallyResolvedEntity(name, corpus, entityNames)) continue
+    unique.push(name)
+    if (unique.length >= MAX_EXTRACTED_ENTITIES) break
+  }
+  return unique
+}
+
+export function parseExtractedEntityNames(text: string): string[] {
+  const parsed = parseJsonPayload(text)
+  const names = collectNameStrings(parsed)
+  return uniqueNames(names).slice(0, MAX_EXTRACTED_ENTITIES)
+}
+
+export function parseNeedExternalNames(text: string, candidates: readonly string[]): string[] {
+  const allowed = new Set(candidates.map((name) => name.trim()).filter(Boolean))
+  const parsed = parseJsonPayload(text)
+  if (!parsed) return []
+
+  const selected: string[] = []
+  const add = (value: unknown) => {
+    const name = String(value ?? "").trim()
+    if (!name || !allowed.has(name) || selected.includes(name)) return
+    selected.push(name)
+  }
+
+  if (Array.isArray(parsed)) {
+    for (const item of parsed) {
+      if (typeof item === "string") add(item)
+      else if (item && typeof item === "object") {
+        const record = item as Record<string, unknown>
+        if (record.needExternal === false) continue
+        if (record.needExternal === true || record.search === true) add(record.name)
+      }
+    }
+    return selected
+  }
+
+  if (typeof parsed !== "object") return []
+  const record = parsed as Record<string, unknown>
+  const needExternal = record.needExternal ?? record.search ?? record.names
+  if (Array.isArray(needExternal)) {
+    for (const item of needExternal) {
+      if (typeof item === "string") add(item)
+      else if (item && typeof item === "object") {
+        const entry = item as Record<string, unknown>
+        if (entry.needExternal === false) continue
+        add(entry.name)
+      }
+    }
+  }
+  if (Array.isArray(record.entities)) {
+    for (const item of record.entities) {
+      if (!item || typeof item !== "object") continue
+      const entry = item as Record<string, unknown>
+      if (entry.needExternal === true || entry.search === true) add(entry.name)
+    }
+  }
+  return selected
+}
+
+export function formatWritingEntitySearchMarkdown(
+  items: Array<{ name: string; results: WebSearchResult[] }>,
+): string {
+  if (items.length === 0) return ""
+  const sections = items.map((item) => {
+    const lines = item.results.length > 0
+      ? item.results.map((result) => {
+        const title = result.title.trim() || result.url.trim() || result.source.trim() || "未命名来源"
+        const url = result.url.trim()
+        const snippet = result.snippet.trim()
+        return [`- ${title}${url ? ` ${url}` : ""}`, snippet ? `  ${snippet}` : ""].filter(Boolean).join("\n")
+      })
+      : ["- 无可用结果"]
+    return `### ${item.name}\n${lines.join("\n")}`
+  })
+  return [`## ${WRITING_ENTITY_SEARCH_HEADING}`, ...sections].join("\n\n")
+}
+
+export async function collectWritingEntityWebSearch(
+  input: CollectWritingEntityWebSearchInput,
+): Promise<WritingEntityWebSearchResult> {
+  const notes: string[] = []
+  if (!isWebSearchConfigured(input.searchApiConfig)) {
+    return { markdown: "", searchedNames: [], notes: ["未配置外部搜索"] }
+  }
+
+  throwIfAborted(input.signal)
+
+  try {
+    const listEntityNames = input.listEntityNames ?? listLocalEntityNames
+    const readPreviousBodies = input.readPreviousBodies ?? readPreviousChapterBodies
+    const search = input.search ?? webSearch
+
+    const [entityNames, previousBodies] = await Promise.all([
+      listEntityNames(input.projectPath),
+      input.chapterNumber && input.chapterNumber > 1
+        ? readPreviousBodies(input.projectPath, input.chapterNumber, 3, input.signal)
+        : Promise.resolve([]),
+    ])
+    throwIfAborted(input.signal)
+
+    const corpus = buildLocalWritingCorpus(input.contextPack, [
+      input.previousChaptersAnalysis ?? "",
+      ...previousBodies.map((chapter) => chapter.content),
+    ])
+
+    const extracted = await extractEntityNames(input)
+    const unresolved = selectUnresolvedEntities(extracted, corpus, entityNames)
+    if (unresolved.length === 0) {
+      return { markdown: "", searchedNames: [], notes }
+    }
+
+    const needExternal = await judgeNeedExternal(input, unresolved)
+    const queries = needExternal.slice(0, MAX_SEARCH_QUERIES)
+    if (queries.length === 0) {
+      return { markdown: "", searchedNames: [], notes }
+    }
+
+    const items: Array<{ name: string; results: WebSearchResult[] }> = []
+    for (const name of queries) {
+      throwIfAborted(input.signal)
+      try {
+        const results = await search(name, input.searchApiConfig, 4)
+        items.push({ name, results })
+      } catch (error) {
+        rethrowIfUserAbort(error, input.signal)
+        notes.push(`搜索「${name}」失败:${error instanceof Error ? error.message : String(error)}`)
+      }
+    }
+
+    return {
+      markdown: formatWritingEntitySearchMarkdown(items),
+      searchedNames: items.map((item) => item.name),
+      notes,
+    }
+  } catch (error) {
+    rethrowIfUserAbort(error, input.signal)
+    notes.push(`实体补搜失败:${error instanceof Error ? error.message : String(error)}`)
+    return { markdown: "", searchedNames: [], notes }
+  }
+}
+
+async function extractEntityNames(input: CollectWritingEntityWebSearchInput): Promise<string[]> {
+  const source = [
+    input.userRequest.trim(),
+    input.planBlueprint?.trim() ?? "",
+    input.outline?.trim() || input.contextPack.outline?.trim() || "",
+  ].filter(Boolean).join("\n\n").slice(0, SOURCE_TEXT_CHAR_CAP)
+
+  const raw = await completeText(input, [
+    {
+      role: "system",
+      content: "你提取小说写作请求里的人物名、势力名、地点名、功法或公开 IP 名。只输出 JSON。",
+    },
+    {
+      role: "user",
+      content: [
+        "从以下文本提取需要核实的专有名称,最多 12 个。",
+        "不要提取章节号、普通动词、纯原创占位词如「主角」。",
+        '只输出 JSON:{"entities":["名称"]}',
+        "",
+        source || "(无文本)",
+      ].join("\n"),
+    },
+  ])
+  return parseExtractedEntityNames(raw)
+}
+
+async function judgeNeedExternal(
+  input: CollectWritingEntityWebSearchInput,
+  unresolved: readonly string[],
+): Promise<string[]> {
+  const raw = await completeText(input, [
+    {
+      role: "system",
+      content: "你判断这些本地找不到的名字是否需要联网查公开资料。只输出 JSON。",
+    },
+    {
+      role: "user",
+      content: [
+        "下列名称在本库前文和实体表都未找到。",
+        "只把「公开 IP / 真实历史或现实设定 / 你明确理解不了或本地解释对不上」的名字放入 needExternal。",
+        "原创角色、可按大纲自编的名字不要放入。",
+        '只输出 JSON:{"needExternal":["名称"]}',
+        "",
+        unresolved.join("\n"),
+      ].join("\n"),
+    },
+  ])
+  return parseNeedExternalNames(raw, unresolved)
+}
+
+async function completeText(
+  input: CollectWritingEntityWebSearchInput,
+  messages: ChatMessage[],
+): Promise<string> {
+  let result = ""
+  await input.streamChat(
+    input.llmConfig,
+    messages,
+    {
+      onToken: (token) => { result += token },
+      onDone: () => {},
+      onError: () => {},
+      onRequestTrace: input.onRequestTrace,
+    },
+    input.signal,
+  )
+  return result.trim()
+}
+
+function parseJsonPayload(text: string): unknown | null {
+  const trimmed = text.trim()
+  if (!trimmed) return null
+  const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)
+  const candidates = [fenced?.[1]?.trim(), trimmed].filter((item): item is string => Boolean(item))
+  for (const candidate of candidates) {
+    try {
+      return JSON.parse(candidate)
+    } catch {
+      const objectMatch = candidate.match(/\{[\s\S]*\}/)
+      if (objectMatch) {
+        try {
+          return JSON.parse(objectMatch[0])
+        } catch {
+          // continue
+        }
+      }
+      const arrayMatch = candidate.match(/\[[\s\S]*\]/)
+      if (arrayMatch) {
+        try {
+          return JSON.parse(arrayMatch[0])
+        } catch {
+          // continue
+        }
+      }
+    }
+  }
+  return null
+}
+
+function collectNameStrings(parsed: unknown): string[] {
+  if (!parsed) return []
+  if (Array.isArray(parsed)) {
+    return parsed.flatMap((item) => {
+      if (typeof item === "string") return [item]
+      if (item && typeof item === "object" && "name" in item) {
+        return [String((item as { name?: unknown }).name ?? "")]
+      }
+      return []
+    })
+  }
+  if (typeof parsed !== "object") return []
+  const record = parsed as Record<string, unknown>
+  const list = record.entities ?? record.names ?? record.needExternal
+  return collectNameStrings(Array.isArray(list) ? list : [])
+}
+
+function uniqueNames(names: readonly string[]): string[] {
+  const output: string[] = []
+  for (const raw of names) {
+    const name = raw.trim()
+    if (name.length < MIN_NAME_LENGTH || output.includes(name)) continue
+    output.push(name)
+  }
+  return output
+}

+ 94 - 0
src/lib/project-store.integration.test.ts

@@ -126,6 +126,100 @@ describe("DeepSeek window migration", () => {
   })
 })
 
+describe("Codex CLI timeout migration", () => {
+  it("rewrites old active and provider timeouts to 40 minutes once", async () => {
+    inMemoryStore.set("llmConfig", {
+      provider: "codex-cli",
+      apiKey: "",
+      model: "gpt-5.4-mini",
+      customEndpoint: "",
+      ollamaUrl: "",
+      maxContextSize: 204_800,
+      codexCliTimeoutMinutes: 20,
+    } satisfies LlmConfig)
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "gpt-5.4-mini", codexCliTimeoutMinutes: 20 },
+    } satisfies ProviderConfigs)
+
+    expect((await loadLlmConfig())?.codexCliTimeoutMinutes).toBe(40)
+    expect((await loadProviderConfigs())?.["codex-cli"]?.codexCliTimeoutMinutes).toBe(40)
+    expect((inMemoryStore.get("llmConfig") as LlmConfig).codexCliTimeoutMinutes).toBe(40)
+    expect((inMemoryStore.get("providerConfigs") as ProviderConfigs)["codex-cli"]?.codexCliTimeoutMinutes).toBe(40)
+
+    // Migration markers are now set. A later deliberate user reduction must stick.
+    inMemoryStore.set("llmConfig", {
+      ...(inMemoryStore.get("llmConfig") as LlmConfig),
+      codexCliTimeoutMinutes: 20,
+    })
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "gpt-5.4-mini", codexCliTimeoutMinutes: 20 },
+    } satisfies ProviderConfigs)
+
+    expect((await loadLlmConfig())?.codexCliTimeoutMinutes).toBe(20)
+    expect((await loadProviderConfigs())?.["codex-cli"]?.codexCliTimeoutMinutes).toBe(20)
+  })
+
+  it("fills a missing saved Codex provider timeout without changing larger values", async () => {
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "gpt-5.4-mini" },
+    } satisfies ProviderConfigs)
+    expect((await loadProviderConfigs())?.["codex-cli"]?.codexCliTimeoutMinutes).toBe(40)
+
+    inMemoryStore.clear()
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "gpt-5.4-mini", codexCliTimeoutMinutes: 60 },
+    } satisfies ProviderConfigs)
+    expect((await loadProviderConfigs())?.["codex-cli"]?.codexCliTimeoutMinutes).toBe(60)
+  })
+})
+
+describe("Codex CLI model migration", () => {
+  it("rewrites the old mini default to Terra once, then leaves the user in control", async () => {
+    inMemoryStore.set("llmConfig", {
+      provider: "codex-cli",
+      apiKey: "",
+      model: "gpt-5.4-mini",
+      customEndpoint: "",
+      ollamaUrl: "",
+      maxContextSize: 204_800,
+      codexCliTimeoutMinutes: 40,
+    } satisfies LlmConfig)
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "gpt-5.4-mini", codexCliTimeoutMinutes: 40 },
+    } satisfies ProviderConfigs)
+
+    expect((await loadLlmConfig())?.model).toBe("gpt-5.6-terra")
+    expect((await loadProviderConfigs())?.["codex-cli"]?.model).toBe("gpt-5.6-terra")
+    expect((inMemoryStore.get("llmConfig") as LlmConfig).model).toBe("gpt-5.6-terra")
+    expect((inMemoryStore.get("providerConfigs") as ProviderConfigs)["codex-cli"]?.model).toBe("gpt-5.6-terra")
+
+    // Migration markers are now set. A later deliberate legacy selection must stick.
+    inMemoryStore.set("llmConfig", {
+      ...(inMemoryStore.get("llmConfig") as LlmConfig),
+      model: "gpt-5.4-mini",
+    })
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "gpt-5.4-mini", codexCliTimeoutMinutes: 40 },
+    } satisfies ProviderConfigs)
+
+    expect((await loadLlmConfig())?.model).toBe("gpt-5.4-mini")
+    expect((await loadProviderConfigs())?.["codex-cli"]?.model).toBe("gpt-5.4-mini")
+  })
+
+  it("fills a missing Codex model but preserves an explicit non-default model", async () => {
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "", codexCliTimeoutMinutes: 40 },
+    } satisfies ProviderConfigs)
+    expect((await loadProviderConfigs())?.["codex-cli"]?.model).toBe("gpt-5.6-terra")
+
+    inMemoryStore.clear()
+    inMemoryStore.set("providerConfigs", {
+      "codex-cli": { model: "gpt-5.4", codexCliTimeoutMinutes: 40 },
+    } satisfies ProviderConfigs)
+    expect((await loadProviderConfigs())?.["codex-cli"]?.model).toBe("gpt-5.4")
+  })
+})
+
 function makeNovelConfig(overrides: Partial<NovelConfig> = {}): NovelConfig {
   return {
     contextTokenBudget: 200000,

+ 85 - 0
src/lib/project-store.ts

@@ -18,6 +18,8 @@ import {
   normalizeProviderConfigs,
   normalizeUserLlmConfig,
 } from "@/lib/llm-context-size"
+import { migrateLegacyCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
+import { migrateLegacyDefaultCodexCliModel } from "@/lib/codex-cli-model"
 import { CHAPTER_TARGET_CHARS_MAX, CHAPTER_TARGET_CHARS_MIN } from "@/lib/novel/deep-chapter-prompts"
 
 const RECENT_PROJECTS_KEY = "recentProjects"
@@ -59,10 +61,19 @@ const DEEPSEEK_WINDOW_MIGRATION_KEYS = {
   llmConfig: "deepseekWindowMigratedV1.llmConfig",
   providerConfigs: "deepseekWindowMigratedV1.providerConfigs",
 } as const
+const CODEX_TIMEOUT_MIGRATION_KEYS = {
+  llmConfig: "codexCliTimeoutMigratedV1.llmConfig",
+  providerConfigs: "codexCliTimeoutMigratedV1.providerConfigs",
+} as const
+const CODEX_MODEL_MIGRATION_KEYS = {
+  llmConfig: "codexCliModelMigratedV1.llmConfig",
+  providerConfigs: "codexCliModelMigratedV1.providerConfigs",
+} as const
 /** DeepSeek's official published context window. */
 const DEEPSEEK_OFFICIAL_CONTEXT_SIZE = 1_000_000
 /** Preset id whose configuration is known to target api.deepseek.com. */
 const DEEPSEEK_PRESET_ID = "deepseek"
+const CODEX_CLI_PRESET_ID = "codex-cli"
 
 function isDeepSeekOfficialEndpoint(endpoint: string | undefined): boolean {
   return typeof endpoint === "string" && /api\.deepseek\.com/i.test(endpoint)
@@ -95,6 +106,34 @@ async function markDeepSeekWindowMigrationDone(
   const store = await getStore()
   await store.set(DEEPSEEK_WINDOW_MIGRATION_KEYS[slot], true)
 }
+
+async function hasRunCodexTimeoutMigration(
+  slot: keyof typeof CODEX_TIMEOUT_MIGRATION_KEYS,
+): Promise<boolean> {
+  const store = await getStore()
+  return (await store.get<boolean>(CODEX_TIMEOUT_MIGRATION_KEYS[slot])) === true
+}
+
+async function markCodexTimeoutMigrationDone(
+  slot: keyof typeof CODEX_TIMEOUT_MIGRATION_KEYS,
+): Promise<void> {
+  const store = await getStore()
+  await store.set(CODEX_TIMEOUT_MIGRATION_KEYS[slot], true)
+}
+
+async function hasRunCodexModelMigration(
+  slot: keyof typeof CODEX_MODEL_MIGRATION_KEYS,
+): Promise<boolean> {
+  const store = await getStore()
+  return (await store.get<boolean>(CODEX_MODEL_MIGRATION_KEYS[slot])) === true
+}
+
+async function markCodexModelMigrationDone(
+  slot: keyof typeof CODEX_MODEL_MIGRATION_KEYS,
+): Promise<void> {
+  const store = await getStore()
+  await store.set(CODEX_MODEL_MIGRATION_KEYS[slot], true)
+}
 const AI_CHAT_MODEL_KEY = "aiChatModel"
 const AI_OUTLINE_MODEL_KEY = "aiOutlineModel"
 let aiOutlineModelSaveRevision = 0
@@ -122,6 +161,24 @@ export async function loadLlmConfig(): Promise<LlmConfig | null> {
     }
     await markDeepSeekWindowMigrationDone("llmConfig")
   }
+  if (!(await hasRunCodexTimeoutMigration("llmConfig"))) {
+    if (normalized.provider === "codex-cli") {
+      const codexCliTimeoutMinutes = migrateLegacyCodexCliTimeoutMinutes(
+        normalized.codexCliTimeoutMinutes,
+      )
+      if (codexCliTimeoutMinutes !== normalized.codexCliTimeoutMinutes) {
+        normalized = { ...normalized, codexCliTimeoutMinutes }
+      }
+    }
+    await markCodexTimeoutMigrationDone("llmConfig")
+  }
+  if (!(await hasRunCodexModelMigration("llmConfig"))) {
+    if (normalized.provider === "codex-cli") {
+      const model = migrateLegacyDefaultCodexCliModel(normalized.model)
+      if (model !== normalized.model) normalized = { ...normalized, model }
+    }
+    await markCodexModelMigrationDone("llmConfig")
+  }
   if (normalized !== saved) await store.set(LLM_CONFIG_KEY, normalized)
   return normalized
 }
@@ -188,6 +245,34 @@ export async function loadProviderConfigs(): Promise<ProviderConfigs | null> {
     }
     await markDeepSeekWindowMigrationDone("providerConfigs")
   }
+  if (!(await hasRunCodexTimeoutMigration("providerConfigs"))) {
+    const codex = normalized[CODEX_CLI_PRESET_ID]
+    if (codex) {
+      const codexCliTimeoutMinutes = migrateLegacyCodexCliTimeoutMinutes(
+        codex.codexCliTimeoutMinutes,
+      )
+      if (codexCliTimeoutMinutes !== codex.codexCliTimeoutMinutes) {
+        normalized = {
+          ...normalized,
+          [CODEX_CLI_PRESET_ID]: { ...codex, codexCliTimeoutMinutes },
+        }
+      }
+    }
+    await markCodexTimeoutMigrationDone("providerConfigs")
+  }
+  if (!(await hasRunCodexModelMigration("providerConfigs"))) {
+    const codex = normalized[CODEX_CLI_PRESET_ID]
+    if (codex) {
+      const model = migrateLegacyDefaultCodexCliModel(codex.model)
+      if (model !== codex.model) {
+        normalized = {
+          ...normalized,
+          [CODEX_CLI_PRESET_ID]: { ...codex, model },
+        }
+      }
+    }
+    await markCodexModelMigrationDone("providerConfigs")
+  }
   if (normalized !== saved) await store.set(PROVIDER_CONFIGS_KEY, normalized)
   return normalized
 }

+ 25 - 3
src/lib/settings-model-list.spec.ts

@@ -120,9 +120,12 @@ describe("settings model list", () => {
   it("reads the configured local Codex CLI model from Tauri detection", async () => {
     vi.mocked(invoke).mockResolvedValueOnce({
       installed: true,
-      version: "codex-cli 0.137.0",
+      version: "codex-cli 0.146.1",
       path: "C:/Users/Administrator/AppData/Roaming/npm/codex.cmd",
-      model: "gpt-5.4",
+      model: "gpt-5.6-terra",
+      appServerReady: true,
+      dynamicToolsReady: true,
+      models: ["gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.6-luna"],
       error: null,
     })
 
@@ -134,6 +137,25 @@ describe("settings model list", () => {
     }))
 
     expect(invoke).toHaveBeenCalledWith("codex_cli_detect")
-    expect(result.models).toEqual(["gpt-5.4"])
+    expect(result.models).toEqual(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"])
+  })
+
+  it("rejects a Codex CLI without app-server dynamic tools", async () => {
+    vi.mocked(invoke).mockResolvedValueOnce({
+      installed: true,
+      version: "codex-cli 0.120.0",
+      path: "/usr/local/bin/codex",
+      appServerReady: false,
+      dynamicToolsReady: false,
+      models: [],
+      error: "当前 Codex CLI 不支持 QMAI 主 Agent,请升级 Codex CLI。",
+    })
+
+    const { fetchLlmModelList } = await import("./settings-model-list")
+    await expect(fetchLlmModelList(customConfig({
+      provider: "codex-cli",
+      apiKey: "",
+      model: "",
+    }))).rejects.toThrow("请升级 Codex CLI")
   })
 })

+ 14 - 1
src/lib/settings-model-list.ts

@@ -168,9 +168,22 @@ async function fetchModelList(url: string, headers: Record<string, string>, _cur
 
 async function fetchLocalCliModel(config: LlmConfig): Promise<LlmModelListResult> {
   const explicitModel = config.model.trim()
-  if (explicitModel) return { models: [explicitModel] }
+  if (explicitModel && config.provider !== "codex-cli") return { models: [explicitModel] }
 
   const detect = await detectLocalCliConfig(config.provider)
+  if (config.provider === "codex-cli") {
+    if (!detect?.appServerReady || !detect.dynamicToolsReady) {
+      throw new Error(detect?.error || "当前 Codex CLI 不支持 QMAI 主 Agent,请升级 Codex CLI。")
+    }
+    const models = Array.from(new Set([
+      ...(explicitModel ? [explicitModel] : []),
+      ...(detect.models ?? []),
+    ])).filter(Boolean)
+    if (models.length === 0) {
+      throw new Error("Codex app-server 未返回可用模型,请检查本机 Codex 登录状态。")
+    }
+    return toModelListResult(models)
+  }
   const localModel = detect?.model?.trim() ?? ""
   if (!localModel) {
     throw new Error("当前本地 CLI 未配置默认模型,请先在本地 CLI 中设置模型,或在软件里手动填写模型。")

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است