2026-07-10-ai-outline-intent-optimization.html 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>AI大纲交互重构优化 — 实施计划</title>
  7. <style>
  8. :root {
  9. --bg: #0f1117;
  10. --surface: #1a1d27;
  11. --surface-2: #232733;
  12. --border: #2d3142;
  13. --text: #e6e8ed;
  14. --text-muted: #9aa0ae;
  15. --accent: #6cb6ff;
  16. --accent-2: #8b7bf0;
  17. --success: #4ade80;
  18. --warning: #fbbf24;
  19. --danger: #f87171;
  20. --amber: #f59e0b;
  21. }
  22. * { box-sizing: border-box; margin: 0; padding: 0; }
  23. body {
  24. background: var(--bg);
  25. color: var(--text);
  26. font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
  27. line-height: 1.7;
  28. padding: 2rem;
  29. max-width: 1080px;
  30. margin: 0 auto;
  31. }
  32. h1 { font-size: 1.8rem; margin-bottom: 0.5rem; }
  33. h2 { font-size: 1.4rem; margin-top: 2.5rem; margin-bottom: 1rem; color: var(--accent); border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
  34. h3 { font-size: 1.15rem; margin-top: 1.8rem; margin-bottom: 0.8rem; color: var(--accent-2); }
  35. h4 { font-size: 1rem; margin-top: 1.2rem; margin-bottom: 0.5rem; color: var(--text); }
  36. p { margin-bottom: 0.8rem; }
  37. ul, ol { margin-left: 1.5rem; margin-bottom: 0.8rem; }
  38. li { margin-bottom: 0.3rem; }
  39. code {
  40. background: var(--surface-2);
  41. padding: 0.15rem 0.4rem;
  42. border-radius: 4px;
  43. font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
  44. font-size: 0.88em;
  45. color: var(--amber);
  46. }
  47. pre {
  48. background: var(--surface-2);
  49. border: 1px solid var(--border);
  50. border-radius: 8px;
  51. padding: 1rem 1.2rem;
  52. overflow-x: auto;
  53. margin: 1rem 0;
  54. font-size: 0.85rem;
  55. line-height: 1.6;
  56. }
  57. pre code { background: none; padding: 0; color: var(--text); }
  58. table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
  59. th, td { border: 1px solid var(--border); padding: 0.6rem 0.8rem; text-align: left; }
  60. th { background: var(--surface-2); color: var(--accent); font-weight: 600; }
  61. tr:nth-child(even) { background: var(--surface); }
  62. .meta { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 1.5rem; }
  63. .callout { background: var(--surface); border-left: 3px solid var(--accent); padding: 0.8rem 1rem; border-radius: 0 6px 6px 0; margin: 1rem 0; }
  64. .callout-warn { border-left-color: var(--warning); }
  65. .callout-danger { border-left-color: var(--danger); }
  66. .callout-success { border-left-color: var(--success); }
  67. .phase-tag { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.8rem; font-weight: 600; margin-right: 0.5rem; }
  68. .phase-1 { background: rgba(248,113,113,0.15); color: var(--danger); }
  69. .phase-2 { background: rgba(251,191,36,0.15); color: var(--warning); }
  70. .phase-3 { background: rgba(107,182,255,0.15); color: var(--accent); }
  71. .phase-4 { background: rgba(74,222,128,0.15); color: var(--success); }
  72. .task-block { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 1.2rem 1.5rem; margin: 1.5rem 0; }
  73. .step-line { padding: 0.3rem 0; }
  74. </style>
  75. </head>
  76. <body>
  77. <h1>AI大纲交互重构优化 — 实施计划</h1>
  78. <blockquote><strong>For agentic workers:</strong> REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox-style tracking.</blockquote>
  79. <p><strong>Goal:</strong> 修复AI大纲会话窗口交互重构中的3个致命问题、4个高优先级问题及11个中等/低优先级优化项,使两阶段流程真正可运行并达到设计规格要求的完成度。</p>
  80. <p><strong>Architecture:</strong> 通过 props 传递回调解决推荐点击断裂;使用 useRef 替代 useState 消除闭包陷阱;提取独立组件降低面板复杂度;增强 buildOutlineStages 填充运行时摘要数据。</p>
  81. <p><strong>Tech Stack:</strong> React 18, TypeScript, Zustand, Vitest, Tailwind CSS</p>
  82. <hr>
  83. <h2>文件结构</h2>
  84. <table>
  85. <tr><th>文件</th><th>操作</th><th>职责</th></tr>
  86. <tr><td><code>src/lib/novel/outline-intent-clarity.ts</code></td><td>修改</td><td>新增 stripStructuredMarkers 工具函数</td></tr>
  87. <tr><td><code>src/lib/novel/outline-intent-clarity.spec.ts</code></td><td>修改</td><td>新增 stripStructuredMarkers 测试</td></tr>
  88. <tr><td><code>src/components/sources/outline-intent-options-card.tsx</code></td><td>创建</td><td>意图推荐选项卡片组件(从面板提取)</td></tr>
  89. <tr><td><code>src/components/sources/outline-next-step-card.tsx</code></td><td>创建</td><td>下一步推荐卡片组件(从面板提取)</td></tr>
  90. <tr><td><code>src/components/sources/outline-chat-panel.tsx</code></td><td>修改</td><td>核心改造:props传递、闭包修复、内容过滤、阶段摘要</td></tr>
  91. <tr><td><code>src/lib/novel/outline-stage-trace.ts</code></td><td>修改</td><td>填充运行时摘要数据</td></tr>
  92. <tr><td><code>src/lib/novel/outline-stage-trace.spec.ts</code></td><td>修改</td><td>新增摘要填充测试</td></tr>
  93. <tr><td><code>src/lib/novel/outline-next-step.ts</code></td><td>修改</td><td>优化 isRecommendationForbidden 精确度</td></tr>
  94. <tr><td><code>src/lib/novel/outline-next-step.spec.ts</code></td><td>修改</td><td>新增误杀测试</td></tr>
  95. <tr><td><code>src/lib/novel/outline-section-configs.ts</code></td><td>修改</td><td>buildGenerationPrompt 使用 outputMode</td></tr>
  96. <tr><td><code>src/lib/novel/outline-workflow-state.ts</code></td><td>修改</td><td>面板集成状态机</td></tr>
  97. </table>
  98. <hr>
  99. <h2><span class="phase-tag phase-1">第一阶段</span>修复致命问题(P0)</h2>
  100. <div class="callout callout-danger">
  101. <strong>目标</strong>:让两阶段流程真正可运行,推荐点击能触发生成,显示内容干净。预估 4.5h。
  102. </div>
  103. <div class="task-block">
  104. <h3>Task 1: 新增 stripStructuredMarkers 工具函数</h3>
  105. <p><strong>问题 U1</strong>:intent_clarity / next_step JSON 块在正文显示中未过滤,用户看到原始 JSON。</p>
  106. **Files:**
  107. - Modify: `src/lib/novel/outline-intent-clarity.ts`
  108. - Modify: `src/lib/novel/outline-intent-clarity.spec.ts`
  109. - [ ] **Step 1: 在 outline-intent-clarity.spec.ts 新增测试**
  110. ```typescript
  111. import { stripStructuredMarkers } from "./outline-intent-clarity"
  112. describe("stripStructuredMarkers", () => {
  113. it("移除 intent_clarity 标记块", () => {
  114. const text = `分析结果如下:\n<!-- intent_clarity -->\n{"clarity":"clear"}\n<!-- /intent_clarity -->\n\n这是正文内容。`
  115. expect(stripStructuredMarkers(text)).toBe("分析结果如下:\n\n这是正文内容。")
  116. })
  117. it("移除 next_step 标记块", () => {
  118. const text = `正文内容\n<!-- next_step -->\n{"recommendations":[]}\n<!-- /next_step -->`
  119. expect(stripStructuredMarkers(text).trim()).toBe("正文内容")
  120. })
  121. it("同时移除两种标记块", () => {
  122. const text = `<!-- intent_clarity -->\n{"clarity":"clear"}\n<!-- /intent_clarity -->\n正文\n<!-- next_step -->\n{"recommendations":[]}\n<!-- /next_step -->`
  123. expect(stripStructuredMarkers(text).trim()).toBe("正文")
  124. })
  125. it("无标记块时原样返回", () => {
  126. const text = "纯文本内容"
  127. expect(stripStructuredMarkers(text)).toBe("纯文本内容")
  128. })
  129. })
  130. ```
  131. - [ ] **Step 2: 运行测试验证失败**
  132. Run: `npx vitest run src/lib/novel/outline-intent-clarity.spec.ts`
  133. Expected: stripStructuredMarkers 相关测试 FAIL
  134. - [ ] **Step 3: 在 outline-intent-clarity.ts 新增 stripStructuredMarkers**
  135. 在文件末尾(`buildIntentAnalysisPrompt` 函数之后)添加:
  136. ```typescript
  137. export function stripStructuredMarkers(text: string): string {
  138. return text
  139. .replace(/<!--\s*intent_clarity\s*-->[\s\S]*?<!--\s*\/intent_clarity\s*-->/gi, "")
  140. .replace(/<!--\s*next_step\s*-->[\s\S]*?<!--\s*\/next_step\s*-->/gi, "")
  141. .replace(/\n{3,}/g, "\n\n")
  142. .trim()
  143. }
  144. ```
  145. - [ ] **Step 4: 运行测试验证通过**
  146. Run: `npx vitest run src/lib/novel/outline-intent-clarity.spec.ts`
  147. Expected: 全部 PASS
  148. - [ ] **Step 5: 在 outline-chat-panel.tsx 的 displayContent 中使用 stripStructuredMarkers**
  149. 在 `OutlineAssistantMessage` 函数中(约 L732-L736),当前 `renderedMarkdownContent` 的 useMemo:
  150. ```typescript
  151. const renderedMarkdownContent = useMemo(() => {
  152. const rawContent = parsed.textContent || answer;
  153. const bodyContent = extractBodyContent(rawContent);
  154. return normalizeOutlineMarkdown(bodyContent || rawContent);
  155. }, [answer, parsed.textContent]);
  156. ```
  157. 修改为:
  158. ```typescript
  159. const renderedMarkdownContent = useMemo(() => {
  160. const rawContent = parsed.textContent || answer;
  161. const stripped = stripStructuredMarkers(rawContent);
  162. const bodyContent = extractBodyContent(stripped);
  163. return normalizeOutlineMarkdown(bodyContent || stripped);
  164. }, [answer, parsed.textContent]);
  165. ```
  166. 同时在文件顶部新增 import:
  167. ```typescript
  168. import { stripStructuredMarkers } from "@/lib/novel/outline-intent-clarity"
  169. ```
  170. 注意:`stripStructuredMarkers` 需要添加到已有的 `outline-intent-clarity` import 语句中(约 L157)。
  171. - [ ] **Step 6: 运行 typecheck**
  172. Run: `npx tsc --noEmit`
  173. Expected: 无错误
  174. - [ ] **Step 7: 提交**
  175. ```
  176. git add src/lib/novel/outline-intent-clarity.ts src/lib/novel/outline-intent-clarity.spec.ts src/components/sources/outline-chat-panel.tsx
  177. git commit -m "新增stripStructuredMarkers过滤intent_clarity/next_step显示"
  178. ```
  179. </div>
  180. <div class="task-block">
  181. <h3>Task 2: 修复推荐选项点击回调(F1+F3+Q1)</h3>
  182. <p><strong>问题 F1</strong>:推荐选项点击回调未连接 handleGenerateSection。<br>
  183. <strong>问题 F3</strong>:needs_input 用户选择后未跳过意图分析。<br>
  184. <strong>问题 Q1</strong>:推荐点击回调直接操作 store,绕过面板流程控制。</p>
  185. **Files:**
  186. - Modify: `src/components/sources/outline-chat-panel.tsx`
  187. - [ ] **Step 1: 在 OutlineAssistantMessage props 新增回调**
  188. 在 `OutlineAssistantMessage` 函数签名(约 L682-L708)中,在 `onRejectTool` 之后添加三个 props:
  189. ```typescript
  190. function OutlineAssistantMessage({
  191. msg,
  192. index,
  193. isStreaming,
  194. streamingContent,
  195. activeMessagesLength,
  196. copied,
  197. projectPath,
  198. onSaveAsOutline,
  199. onCopy,
  200. onRegenerate,
  201. onConfirmToolSave,
  202. onRejectTool,
  203. onGenerateSection,
  204. onSendMessage,
  205. onFocusInput,
  206. }: {
  207. msg: import("@/stores/outline-chat-store").OutlineChatMessage;
  208. index: number;
  209. isStreaming: boolean;
  210. streamingContent: string;
  211. activeMessagesLength: number;
  212. copied: string | null;
  213. projectPath: string | null;
  214. onSaveAsOutline: (content: string) => Promise<void>;
  215. onCopy: (content: string, id: string) => void;
  216. onRegenerate: (index: number) => Promise<void>;
  217. onConfirmToolSave: (call: ToolCallRecord & { preview?: string }) => void;
  218. onRejectTool: (call: ToolCallRecord & { preview?: string }) => void;
  219. onGenerateSection: (title: string, requestHint: string) => void;
  220. onSendMessage: (text: string, options?: { intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"; scope?: string }) => void;
  221. onFocusInput: () => void;
  222. }) {
  223. ```
  224. - [ ] **Step 2: 替换意图推荐选项的点击回调**
  225. 在 `OutlineAssistantMessage` 中(约 L910-L943),替换 needs_input 推荐选项的 onClick:
  226. 将:
  227. ```tsx
  228. onClick={() => {
  229. if (option.id === "D") {
  230. // 自定义:聚焦输入框(暂不实现,后续 Task 补充)
  231. } else {
  232. const outlineChatStore = useOutlineChatStore.getState();
  233. const convId = outlineChatStore.activeConversationId;
  234. if (!convId) return;
  235. useOutlineChatStore.getState().addMessage(convId, {
  236. id: crypto.randomUUID(),
  237. role: "user",
  238. content: option.label + (option.description ? `:${option.description}` : ""),
  239. });
  240. }
  241. }}
  242. ```
  243. 替换为:
  244. ```tsx
  245. onClick={() => {
  246. if (option.id === "D") {
  247. onFocusInput();
  248. } else {
  249. const scope = option.label + (option.description ? `:${option.description}` : "");
  250. onSendMessage(scope, { intentPhase: "generation", scope });
  251. }
  252. }}
  253. ```
  254. - [ ] **Step 3: 替换下一步推荐的点击回调**
  255. 在 `OutlineAssistantMessage` 中(约 L944-L980),替换 next_step 推荐的 onClick:
  256. 将:
  257. ```tsx
  258. onClick={() => {
  259. if (rec.id === "D") {
  260. // 自定义:聚焦输入框
  261. } else {
  262. const config = OUTLINE_SECTION_GENERATION_CONFIGS.find(c => c.title === rec.label);
  263. if (config) {
  264. const outlineChatStore = useOutlineChatStore.getState();
  265. const convId = outlineChatStore.activeConversationId;
  266. if (!convId) return;
  267. useOutlineChatStore.getState().addMessage(convId, {
  268. id: crypto.randomUUID(),
  269. role: "user",
  270. content: `生成${rec.label}`,
  271. });
  272. }
  273. }
  274. }}
  275. ```
  276. 替换为:
  277. ```tsx
  278. onClick={() => {
  279. if (rec.id === "D") {
  280. onFocusInput();
  281. } else {
  282. const config = OUTLINE_SECTION_GENERATION_CONFIGS.find(c => c.title === rec.label);
  283. if (config) {
  284. onGenerateSection(config.title, config.requestHint);
  285. }
  286. }
  287. }}
  288. ```
  289. - [ ] **Step 4: 在渲染 OutlineAssistantMessage 的位置传入新 props**
  290. 在面板主组件中(约 L2860-L2873),添加三个 props:
  291. ```tsx
  292. <OutlineAssistantMessage
  293. msg={msg}
  294. index={i}
  295. isStreaming={isStreaming}
  296. streamingContent={streamingContent}
  297. activeMessagesLength={activeMessages.length}
  298. copied={copied}
  299. projectPath={project?.path ?? null}
  300. onSaveAsOutline={handleSaveAsOutline}
  301. onCopy={handleCopy}
  302. onRegenerate={handleRegenerate}
  303. onConfirmToolSave={handleConfirmToolSave}
  304. onRejectTool={handleRejectTool}
  305. onGenerateSection={handleGenerateSection}
  306. onSendMessage={(text, options) => {
  307. void handleSend(
  308. buildGenerationPrompt(lastIntentTitle, lastIntentHint, options?.scope),
  309. [],
  310. { intentPhase: options?.intentPhase ?? "generation" },
  311. );
  312. }}
  313. onFocusInput={() => {
  314. insertReferenceTokensRef.current?.focus();
  315. }}
  316. />
  317. ```
  318. - [ ] **Step 5: 在 handleSend 中处理 scope 参数,确保防无限循环**
  319. 在 handleSend 中 intentPhase 为 "generation" 时,不需要再做意图分析。当前 handleSend 的 options 已有 intentPhase 字段,流结束回调中已有逻辑只在 intentPhase 为 "intent_analysis" 时解析 intent_clarity。
  320. 修改流结束回调中的意图解析逻辑(约 L2090-L2107),增加 intentPhase 条件检查:
  321. 将:
  322. ```typescript
  323. // 解析意图清晰度结果
  324. const intentResult = parseIntentClarity(finalContent);
  325. ```
  326. 替换为:
  327. ```typescript
  328. // 解析意图清晰度结果(仅意图分析阶段触发)
  329. const currentIntentPhase = useOutlineChatStore.getState()
  330. .conversations.find(c => c.id === convId)
  331. ?.messages.find(m => m.id === assistantId)?.intentPhase;
  332. if (currentIntentPhase === "intent_analysis") {
  333. const intentResult = parseIntentClarity(finalContent);
  334. ```
  335. 并在该 if 块末尾(约 L2107)添加对应的闭合大括号 `}`。
  336. - [ ] **Step 6: 运行 typecheck**
  337. Run: `npx tsc --noEmit`
  338. Expected: 无错误
  339. - [ ] **Step 7: 运行大纲面板测试**
  340. Run: `npx vitest run src/components/sources/outline-chat-panel.spec.tsx`
  341. Expected: 全部 PASS
  342. - [ ] **Step 8: 提交**
  343. ```
  344. git add src/components/sources/outline-chat-panel.tsx
  345. git commit -m "修复推荐选项点击回调连接handleGenerateSection,防无限循环"
  346. ```
  347. </div>
  348. <h2><span class="phase-tag phase-2">第二阶段</span>补全交互细节(P1)</h2>
  349. <div class="callout callout-warn">
  350. <strong>目标</strong>:4类选项完整可用,阶段信息可读,用户体验可感知。预估 6h。
  351. </div>
  352. <div class="task-block">
  353. <h3>Task 3: 提取推荐选项和下一步推荐为独立组件</h3>
  354. <p><strong>问题 M1</strong>:outline-chat-panel.tsx 过大,推荐选项和下一步推荐应提取为独立组件。</p>
  355. **Files:**
  356. - Create: `src/components/sources/outline-intent-options-card.tsx`
  357. - Create: `src/components/sources/outline-next-step-card.tsx`
  358. - Modify: `src/components/sources/outline-chat-panel.tsx`
  359. - [ ] **Step 1: 创建 outline-intent-options-card.tsx**
  360. ```tsx
  361. import React from "react"
  362. import type { IntentClarityResult } from "@/lib/novel/outline-intent-clarity"
  363. interface IntentOptionsCardProps {
  364. result: IntentClarityResult
  365. onSelectOption: (optionId: string, label: string, description: string) => void
  366. }
  367. export function IntentOptionsCard({ result, onSelectOption }: IntentOptionsCardProps) {
  368. return (
  369. <div className="mt-3 rounded-md border border-amber-500/30 bg-amber-50/30 p-3 dark:bg-amber-950/10">
  370. {result.analysis ? (
  371. <div className="mb-2 text-xs text-muted-foreground">{result.analysis}</div>
  372. ) : null}
  373. <div className="text-sm font-medium mb-2">{result.question || "请选择生成范围:"}</div>
  374. <div className="space-y-2">
  375. {result.options.map((option) => (
  376. <button
  377. key={option.id}
  378. className="w-full rounded-md border px-3 py-2 text-left text-sm hover:bg-accent transition-colors"
  379. onClick={() => onSelectOption(option.id, option.label, option.description)}
  380. >
  381. <div className="font-medium">{option.label}</div>
  382. {option.description ? (
  383. <div className="text-xs text-muted-foreground">{option.description}</div>
  384. ) : null}
  385. </button>
  386. ))}
  387. </div>
  388. </div>
  389. )
  390. }
  391. ```
  392. - [ ] **Step 2: 创建 outline-next-step-card.tsx**
  393. ```tsx
  394. import React from "react"
  395. import type { NextStepRecommendation } from "@/lib/novel/outline-next-step"
  396. interface NextStepCardProps {
  397. recommendation: NextStepRecommendation
  398. onSelectRecommendation: (recId: string, label: string) => void
  399. }
  400. export function NextStepCard({ recommendation, onSelectRecommendation }: NextStepCardProps) {
  401. return (
  402. <div className="mt-3 rounded-md border border-sky-500/30 bg-sky-50/30 p-3 dark:bg-sky-950/10">
  403. <div className="text-xs text-muted-foreground mb-1">
  404. 已完成:{recommendation.completedModule}
  405. {recommendation.completedScope ? `(${recommendation.completedScope})` : ""}
  406. </div>
  407. <div className="text-sm font-medium mb-2">接下来想做什么?</div>
  408. <div className="space-y-2">
  409. {recommendation.recommendations.map((rec) => (
  410. <button
  411. key={rec.id}
  412. className="w-full rounded-md border px-3 py-2 text-left text-sm hover:bg-accent transition-colors"
  413. onClick={() => onSelectRecommendation(rec.id, rec.label)}
  414. >
  415. <div className="font-medium">{rec.label}</div>
  416. {rec.reason ? (
  417. <div className="text-xs text-muted-foreground">{rec.reason}</div>
  418. ) : null}
  419. </button>
  420. ))}
  421. </div>
  422. </div>
  423. )
  424. }
  425. ```
  426. - [ ] **Step 3: 在 outline-chat-panel.tsx 中替换内联渲染为组件调用**
  427. 在文件顶部添加 import:
  428. ```typescript
  429. import { IntentOptionsCard } from "@/components/sources/outline-intent-options-card"
  430. import { NextStepCard } from "@/components/sources/outline-next-step-card"
  431. ```
  432. 在 `OutlineAssistantMessage` 中,替换 needs_input 推荐选项区域(约 L910-L943):
  433. ```tsx
  434. {msg.intentClarityResult?.clarity === "needs_input" && !isStreaming ? (
  435. <IntentOptionsCard
  436. result={msg.intentClarityResult}
  437. onSelectOption={(optionId, label, description) => {
  438. if (optionId === "D") {
  439. onFocusInput();
  440. } else {
  441. const scope = label + (description ? `:${description}` : "");
  442. onSendMessage(scope, { intentPhase: "generation", scope });
  443. }
  444. }}
  445. />
  446. ) : null}
  447. ```
  448. 替换下一步推荐区域(约 L944-L980):
  449. ```tsx
  450. {msg.nextStepRecommendation && msg.nextStepRecommendation.recommendations.length > 0 && !isStreaming ? (
  451. <NextStepCard
  452. recommendation={msg.nextStepRecommendation}
  453. onSelectRecommendation={(recId, label) => {
  454. if (recId === "D") {
  455. onFocusInput();
  456. } else {
  457. const config = OUTLINE_SECTION_GENERATION_CONFIGS.find(c => c.title === label);
  458. if (config) {
  459. onGenerateSection(config.title, config.requestHint);
  460. }
  461. }
  462. }}
  463. />
  464. ) : null}
  465. ```
  466. - [ ] **Step 4: 运行 typecheck**
  467. Run: `npx tsc --noEmit`
  468. Expected: 无错误
  469. - [ ] **Step 5: 提交**
  470. ```
  471. git add src/components/sources/outline-intent-options-card.tsx src/components/sources/outline-next-step-card.tsx src/components/sources/outline-chat-panel.tsx
  472. git commit -m "提取IntentOptionsCard和NextStepCard独立组件"
  473. ```
  474. </div>
  475. <div class="task-block">
  476. <h3>Task 4: 填充阶段摘要运行时数据</h3>
  477. <p><strong>问题 U2</strong>:阶段摘要信息为空,用户只看到"✓ 任务理解"而非"✓ 任务理解 · 识别为生成章节细纲 · 1.2s"。</p>
  478. **Files:**
  479. - Modify: `src/lib/novel/outline-stage-trace.ts`
  480. - Modify: `src/lib/novel/outline-stage-trace.spec.ts`
  481. - [ ] **Step 1: 在 outline-stage-trace.spec.ts 新增摘要测试**
  482. ```typescript
  483. it("任务理解阶段摘要包含工具信息", () => {
  484. const input: OutlineStageInput = {
  485. toolCalls: [makeToolCall("route_task", "done")],
  486. content: "",
  487. isStreaming: false,
  488. }
  489. const stages = buildOutlineStages(input)
  490. const intentStage = stages.find((s) => s.kind === "intent")
  491. expect(intentStage?.summary).toBeTruthy()
  492. expect(intentStage?.summary).toContain("route_task")
  493. })
  494. it("范围分析阶段摘要包含模块名", () => {
  495. const content = `<!-- intent_clarity -->
  496. {"clarity":"clear","module":"章节细纲","analysis":"3章缺细纲","detectedScope":"第1-3章","missingItems":[],"options":[],"question":""}
  497. <!-- /intent_clarity -->`
  498. const input: OutlineStageInput = {
  499. toolCalls: [makeToolCall("route_task", "done")],
  500. content,
  501. isStreaming: false,
  502. }
  503. const stages = buildOutlineStages(input)
  504. const scopeStage = stages.find((s) => s.kind === "scope")
  505. expect(scopeStage?.summary).toContain("章节细纲")
  506. })
  507. it("上下文准备阶段摘要包含读取文件数", () => {
  508. const input: OutlineStageInput = {
  509. toolCalls: [
  510. makeToolCall("route_task", "done"),
  511. makeToolCall("read_outline", "done"),
  512. makeToolCall("read_chapter", "done"),
  513. makeToolCall("list_outlines", "done"),
  514. ],
  515. content: "",
  516. isStreaming: false,
  517. }
  518. const stages = buildOutlineStages(input)
  519. const ctxStage = stages.find((s) => s.kind === "context")
  520. expect(ctxStage?.summary).toContain("3")
  521. })
  522. ```
  523. - [ ] **Step 2: 运行测试验证失败**
  524. Run: `npx vitest run src/lib/novel/outline-stage-trace.spec.ts`
  525. Expected: 新增测试 FAIL(summary 为空)
  526. - [ ] **Step 3: 修改 buildOutlineStages 填充摘要数据**
  527. 修改 `buildOutlineStages` 函数,在 stageDefs 构建后、返回前,根据激活条件填充 summary:
  528. 将 `buildOutlineStages` 中 `stageDefs` 定义后的逻辑改为:
  529. ```typescript
  530. export function buildOutlineStages(input: OutlineStageInput): OutlineStage[] {
  531. const { toolCalls, content, isStreaming } = input
  532. const routeCalls = toolCalls.filter((c) => ROUTE_TOOLS.has(c.name))
  533. const skillCalls = toolCalls.filter((c) => SKILL_TOOLS.has(c.name))
  534. const readCalls = toolCalls.filter((c) => LIST_TOOLS.has(c.name) || READ_TOOLS.has(c.name))
  535. const writeCalls = toolCalls.filter((c) => WRITE_TOOLS.has(c.name))
  536. const hasRoute = routeCalls.length > 0
  537. const hasSkill = skillCalls.length > 0
  538. const hasRead = readCalls.length > 0
  539. const hasWrite = writeCalls.length > 0
  540. const hasClarity = hasIntentClarity(content)
  541. const hasThinkingBlock = hasThinking(content)
  542. const hasOutput = hasGenerationStarted(content)
  543. const activations = [hasRoute, hasClarity, hasSkill, hasRead, hasWrite, hasThinkingBlock, hasOutput]
  544. // 提取意图分析摘要
  545. let clarityModule = ""
  546. let clarityAnalysis = ""
  547. const clarityMatch = content.match(INTENT_CLARITY_PATTERN)
  548. if (clarityMatch) {
  549. try {
  550. const payload = JSON.parse(clarityMatch[1].trim())
  551. clarityModule = String(payload.module ?? "")
  552. clarityAnalysis = String(payload.analysis ?? "")
  553. } catch {}
  554. }
  555. // 构建摘要
  556. const summaries: string[] = [
  557. hasRoute ? `路由到${routeCalls.map(c => c.name).join("、")}` : "",
  558. hasClarity ? (clarityModule ? `${clarityModule}:${clarityAnalysis || "范围已明确"}` : "范围分析完成") : "",
  559. hasSkill ? `已加载${skillCalls.map(c => c.name).join("、")}` : "",
  560. hasRead ? `读取了${readCalls.length}个资料` : "",
  561. hasWrite ? `调用${writeCalls.length}个写入工具` : "",
  562. hasThinkingBlock ? "角色与情节推理中" : "",
  563. hasOutput ? "内容生成完成" : "",
  564. ]
  565. const detailsList: string[][] = [
  566. hasRoute ? routeCalls.map((c) => `${c.name}(${c.status})`) : [],
  567. hasClarity ? [clarityModule, clarityAnalysis].filter(Boolean) : [],
  568. hasSkill ? skillCalls.map((c) => `${c.name}(${c.status})`) : [],
  569. hasRead ? readCalls.map((c) => `${c.name}(${c.status})`) : [],
  570. hasWrite ? writeCalls.map((c) => `${c.name}(${c.status})`) : [],
  571. [],
  572. [],
  573. ]
  574. const stageDefs: Array<Omit<OutlineStage, "status">> = [
  575. { kind: "intent", title: "任务理解", summary: summaries[0], details: detailsList[0] },
  576. { kind: "scope", title: "范围分析", summary: summaries[1], details: detailsList[1] },
  577. { kind: "skill", title: "技能选择", summary: summaries[2], details: detailsList[2] },
  578. { kind: "context", title: "上下文准备", summary: summaries[3], details: detailsList[3] },
  579. { kind: "tool", title: "工具调用", summary: summaries[4], details: detailsList[4] },
  580. { kind: "thinking", title: "思考与角色", summary: summaries[5], details: detailsList[5] },
  581. { kind: "generation", title: "生成与校验", summary: summaries[6], details: detailsList[6] },
  582. ]
  583. let lastActiveIndex = -1
  584. for (let i = activations.length - 1; i >= 0; i--) {
  585. if (activations[i]) {
  586. lastActiveIndex = i
  587. break
  588. }
  589. }
  590. return stageDefs.map((def, index) => {
  591. const activated = activations[index]
  592. let status: OutlineStageStatus = "hidden"
  593. if (activated) {
  594. if (!isStreaming || index < lastActiveIndex) {
  595. status = "done"
  596. } else {
  597. status = "active"
  598. }
  599. }
  600. if (activated && !isStreaming) {
  601. status = "done"
  602. }
  603. return { ...def, status }
  604. })
  605. }
  606. ```
  607. - [ ] **Step 4: 运行测试验证通过**
  608. Run: `npx vitest run src/lib/novel/outline-stage-trace.spec.ts`
  609. Expected: 全部 PASS
  610. - [ ] **Step 5: 提交**
  611. ```
  612. git add src/lib/novel/outline-stage-trace.ts src/lib/novel/outline-stage-trace.spec.ts
  613. git commit -m "填充阶段摘要运行时数据"
  614. ```
  615. </div>
  616. <div class="task-block">
  617. <h3>Task 5: 添加过渡提示和分析摘要</h3>
  618. <p><strong>问题 U3</strong>:clear 自动注入时无过渡提示。<br>
  619. <strong>问题 U4</strong>:推荐选项区域缺少分析摘要。</p>
  620. **Files:**
  621. - Modify: `src/components/sources/outline-chat-panel.tsx`
  622. - [ ] **Step 1: 在 clear 自动注入前添加系统消息**
  623. 在 handleSend 流结束回调中(约 L2098-L2106),clear 自动注入逻辑前添加系统消息:
  624. 将:
  625. ```typescript
  626. if (intentResult.clarity === "clear") {
  627. // 自动注入生成 prompt
  628. const scope = intentResult.detectedScope;
  629. void handleSend(
  630. buildGenerationPrompt(lastIntentTitle, lastIntentHint, scope),
  631. [],
  632. { intentPhase: "generation" },
  633. );
  634. }
  635. ```
  636. 替换为:
  637. ```typescript
  638. if (intentResult.clarity === "clear") {
  639. // 插入过渡提示
  640. addMessage(convId, {
  641. id: crypto.randomUUID(),
  642. role: "user",
  643. content: `✓ 意图明确(${intentResult.module}${intentResult.detectedScope ? `:${intentResult.detectedScope}` : ""}),开始生成...`,
  644. });
  645. // 自动注入生成 prompt
  646. const scope = intentResult.detectedScope;
  647. void handleSend(
  648. buildGenerationPrompt(lastIntentTitle, lastIntentHint, scope),
  649. [],
  650. { intentPhase: "generation" },
  651. );
  652. }
  653. ```
  654. 注意:U4(分析摘要)已在 Task 3 的 IntentOptionsCard 中通过 `result.analysis` 渲染实现。
  655. - [ ] **Step 2: 运行 typecheck**
  656. Run: `npx tsc --noEmit`
  657. Expected: 无错误
  658. - [ ] **Step 3: 提交**
  659. ```
  660. git add src/components/sources/outline-chat-panel.tsx
  661. git commit -m "添加clear自动注入过渡提示"
  662. ```
  663. </div>
  664. <div class="task-block">
  665. <h3>Task 6: 修复闭包陷阱,使用 useRef 保存意图上下文</h3>
  666. <p><strong>问题 Q2</strong>:lastIntentTitle/lastIntentHint 在 handleSend 闭包中可能过期。</p>
  667. **Files:**
  668. - Modify: `src/components/sources/outline-chat-panel.tsx`
  669. - [ ] **Step 1: 将 lastIntentTitle/lastIntentHint 改为 useRef**
  670. 将(约 L1168-L1170):
  671. ```typescript
  672. const [lastIntentTitle, setLastIntentTitle] = useState("");
  673. const [lastIntentHint, setLastIntentHint] = useState("");
  674. ```
  675. 替换为:
  676. ```typescript
  677. const lastIntentTitleRef = useRef("");
  678. const lastIntentHintRef = useRef("");
  679. ```
  680. - [ ] **Step 2: 更新 handleGenerateSection 中的赋值**
  681. 将(约 L2217-L2224):
  682. ```typescript
  683. const handleGenerateSection = useCallback(
  684. (title: string, requestHint: string) => {
  685. setLastIntentTitle(title);
  686. setLastIntentHint(requestHint);
  687. const intentPrompt = buildIntentAnalysisPrompt(title, requestHint);
  688. void handleSend(intentPrompt, [], { intentPhase: "intent_analysis" });
  689. },
  690. [handleSend],
  691. );
  692. ```
  693. 替换为:
  694. ```typescript
  695. const handleGenerateSection = useCallback(
  696. (title: string, requestHint: string) => {
  697. lastIntentTitleRef.current = title;
  698. lastIntentHintRef.current = requestHint;
  699. const intentPrompt = buildIntentAnalysisPrompt(title, requestHint);
  700. void handleSend(intentPrompt, [], { intentPhase: "intent_analysis" });
  701. },
  702. [handleSend],
  703. );
  704. ```
  705. - [ ] **Step 3: 更新 handleSend 中引用 lastIntentTitle/lastIntentHint 的位置**
  706. 在流结束回调中(约 L2101-L2102),将:
  707. ```typescript
  708. buildGenerationPrompt(lastIntentTitle, lastIntentHint, scope),
  709. ```
  710. 替换为:
  711. ```typescript
  712. buildGenerationPrompt(lastIntentTitleRef.current, lastIntentHintRef.current, scope),
  713. ```
  714. 在 onSendMessage 的渲染位置(约 L2870),将:
  715. ```tsx
  716. onSendMessage={(text, options) => {
  717. void handleSend(
  718. buildGenerationPrompt(lastIntentTitle, lastIntentHint, options?.scope),
  719. ```
  720. 替换为:
  721. ```tsx
  722. onSendMessage={(text, options) => {
  723. void handleSend(
  724. buildGenerationPrompt(lastIntentTitleRef.current, lastIntentHintRef.current, options?.scope),
  725. ```
  726. - [ ] **Step 4: 更新 handleSend 依赖数组**
  727. 从 handleSend 的依赖数组中移除 `lastIntentTitle`、`lastIntentHint`,添加 `lastIntentTitleRef`、`lastIntentHintRef`。
  728. 实际上 useRef 不需要在依赖数组中,所以直接移除 `lastIntentTitle` 和 `lastIntentHint` 即可。
  729. 同时移除 `setLastIntentResult`(因为 lastIntentResult 也可以改用 ref)。
  730. 将(约 L2211-L2213):
  731. ```typescript
  732. lastIntentTitle,
  733. lastIntentHint,
  734. setLastIntentResult,
  735. ```
  736. 替换为空(直接删除这三行)。
  737. - [ ] **Step 5: 将 lastIntentResult 也改为 useRef**
  738. 将(约 L1168):
  739. ```typescript
  740. const [lastIntentResult, setLastIntentResult] = useState<IntentClarityResult | null>(null);
  741. ```
  742. 替换为:
  743. ```typescript
  744. const lastIntentResultRef = useRef<IntentClarityResult | null>(null);
  745. ```
  746. 在流结束回调中,将:
  747. ```typescript
  748. setLastIntentResult(intentResult);
  749. ```
  750. 替换为:
  751. ```typescript
  752. lastIntentResultRef.current = intentResult;
  753. ```
  754. - [ ] **Step 6: 运行 typecheck + 大纲面板测试**
  755. Run: `npx tsc --noEmit`
  756. Run: `npx vitest run src/components/sources/outline-chat-panel.spec.tsx`
  757. Expected: 无错误,全部 PASS
  758. - [ ] **Step 7: 提交**
  759. ```
  760. git add src/components/sources/outline-chat-panel.tsx
  761. git commit -m "修复闭包陷阱:lastIntent改用useRef"
  762. ```
  763. </div>
  764. <h2><span class="phase-tag phase-3">第三阶段</span>架构完善与质量提升(P2)</h2>
  765. <div class="callout">
  766. <strong>目标</strong>:outputMode 真正生效,状态机驱动流程,推荐过滤优化,测试覆盖完整。预估 9h。
  767. </div>
  768. <div class="task-block">
  769. <h3>Task 7: outputMode 在生成流程中生效</h3>
  770. <p><strong>问题 F4</strong>:outputMode 字段已定义但生成流程未使用。</p>
  771. **Files:**
  772. - Modify: `src/lib/novel/outline-section-configs.ts`
  773. - Modify: `src/components/sources/outline-chat-panel.tsx`
  774. - [ ] **Step 1: 在 buildGenerationPrompt 中使用 outputMode**
  775. 当前 `buildGenerationPrompt`(约 L376-L399)不接收 outputMode 参数。修改为接收并使用:
  776. 将:
  777. ```typescript
  778. function buildGenerationPrompt(
  779. title: string,
  780. requestHint: string,
  781. scope?: string,
  782. ): string {
  783. ```
  784. 替换为:
  785. ```typescript
  786. function buildGenerationPrompt(
  787. title: string,
  788. requestHint: string,
  789. scope?: string,
  790. outputMode?: "per_chapter" | "per_item" | "single",
  791. ): string {
  792. ```
  793. 在 prompt 中添加 outputMode 相关指导:
  794. 在 `buildGenerationPrompt` 的返回数组中,在 `"## 文件输出要求"` 之后添加:
  795. ```typescript
  796. outputMode === "per_chapter"
  797. ? "每个章节必须输出独立的 outlineSaveRequest,每个对应一个独立 .md 文件,文件名格式:第N章-章节标题.md。禁止将多个章节写入同一文件。"
  798. : outputMode === "per_item"
  799. ? "每个角色/势力/体系必须输出独立的 outlineSaveRequest,每个对应一个独立 .md 文件,文件名格式:名称.md。禁止将多个角色/势力写入同一文件。"
  800. : "按可保存的大纲正文输出:标题清楚、条目完整、能直接指导后续小说写作。",
  801. ```
  802. - [ ] **Step 2: 在 handleGenerateSection 中传递 outputMode**
  803. 修改 `handleGenerateSection`(约 L2217-L2224):
  804. ```typescript
  805. const handleGenerateSection = useCallback(
  806. (title: string, requestHint: string) => {
  807. lastIntentTitleRef.current = title;
  808. lastIntentHintRef.current = requestHint;
  809. const config = OUTLINE_SECTION_GENERATION_CONFIGS.find(c => c.title === title);
  810. lastIntentOutputModeRef.current = config?.outputMode;
  811. const intentPrompt = buildIntentAnalysisPrompt(title, requestHint);
  812. void handleSend(intentPrompt, [], { intentPhase: "intent_analysis" });
  813. },
  814. [handleSend],
  815. );
  816. ```
  817. 添加 `lastIntentOutputModeRef`:
  818. ```typescript
  819. const lastIntentOutputModeRef = useRef<"per_chapter" | "per_item" | "single" | undefined>(undefined);
  820. ```
  821. - [ ] **Step 3: 在 clear 自动注入生成时传递 outputMode**
  822. 在流结束回调中,修改 clear 分支:
  823. ```typescript
  824. buildGenerationPrompt(
  825. lastIntentTitleRef.current,
  826. lastIntentHintRef.current,
  827. scope,
  828. lastIntentOutputModeRef.current,
  829. ),
  830. ```
  831. 同样修改 onSendMessage 中的调用:
  832. ```tsx
  833. onSendMessage={(text, options) => {
  834. void handleSend(
  835. buildGenerationPrompt(
  836. lastIntentTitleRef.current,
  837. lastIntentHintRef.current,
  838. options?.scope,
  839. lastIntentOutputModeRef.current,
  840. ),
  841. [],
  842. { intentPhase: options?.intentPhase ?? "generation" },
  843. );
  844. }}
  845. ```
  846. - [ ] **Step 4: 运行 typecheck + 大纲面板测试**
  847. Run: `npx tsc --noEmit`
  848. Run: `npx vitest run src/components/sources/outline-chat-panel.spec.tsx`
  849. Expected: 无错误,全部 PASS
  850. - [ ] **Step 5: 提交**
  851. ```
  852. git add src/lib/novel/outline-section-configs.ts src/components/sources/outline-chat-panel.tsx
  853. git commit -m "outputMode在生成流程中生效"
  854. ```
  855. </div>
  856. <div class="task-block">
  857. <h3>Task 8: 优化 isRecommendationForbidden 精确度</h3>
  858. <p><strong>问题 Q4</strong>:isRecommendationForbidden 可能误杀合法推荐。</p>
  859. **Files:**
  860. - Modify: `src/lib/novel/outline-next-step.ts`
  861. - Modify: `src/lib/novel/outline-next-step.spec.ts`
  862. - [ ] **Step 1: 新增误杀测试**
  863. ```typescript
  864. it("不误杀包含'正文'但非正文生成的推荐", () => {
  865. expect(isRecommendationForbidden("生成组织势力正文说明")).toBe(false)
  866. expect(isRecommendationForbidden("完善伏笔正文分析")).toBe(false)
  867. expect(isRecommendationForbidden("生成角色设定文档")).toBe(false)
  868. })
  869. it("精确禁止章节正文生成", () => {
  870. expect(isRecommendationForbidden("生成章节正文")).toBe(true)
  871. expect(isRecommendationForbidden("生成对应正文")).toBe(true)
  872. expect(isRecommendationForbidden("写章节正文")).toBe(true)
  873. expect(isRecommendationForbidden("开始写正文")).toBe(true)
  874. })
  875. ```
  876. - [ ] **Step 2: 优化 isRecommendationForbidden 正则**
  877. 将:
  878. ```typescript
  879. const FORBIDDEN_PATTERNS = [
  880. /生成.*正文/,
  881. /写.*正文/,
  882. /生成.*章节内容/,
  883. /生成.*正文章节/,
  884. /章节正文/,
  885. /写正文/,
  886. /生成正文/,
  887. ]
  888. ```
  889. 替换为:
  890. ```typescript
  891. const FORBIDDEN_PATTERNS = [
  892. /生成.*(章节?|对应|相关).?正文/,
  893. /写.*(章节?|对应|相关).?正文/,
  894. /生成正文/,
  895. /写正文/,
  896. /开始写正文/,
  897. /生成章节内容/,
  898. ]
  899. ```
  900. - [ ] **Step 3: 运行测试**
  901. Run: `npx vitest run src/lib/novel/outline-next-step.spec.ts`
  902. Expected: 全部 PASS
  903. - [ ] **Step 4: 提交**
  904. ```
  905. git add src/lib/novel/outline-next-step.ts src/lib/novel/outline-next-step.spec.ts
  906. git commit -m "优化isRecommendationForbidden精确度避免误杀"
  907. ```
  908. </div>
  909. <div class="task-block">
  910. <h3>Task 9: 面板集成状态机</h3>
  911. <p><strong>问题 F5</strong>:状态机定义了 intent_analysis/waiting_user_input 但面板未使用。</p>
  912. **Files:**
  913. - Modify: `src/components/sources/outline-chat-panel.tsx`
  914. - [ ] **Step 1: 引入状态机状态**
  915. 在面板主组件中(约 L1168 附近),添加:
  916. ```typescript
  917. const [outlineWorkflowStage, setOutlineWorkflowStage] = useState<OutlineWorkflowStage>("idle");
  918. ```
  919. 添加 import:
  920. ```typescript
  921. import {
  922. type OutlineWorkflowStage,
  923. createInitialOutlineWorkflowState,
  924. transitionOutlineWorkflow,
  925. canTransitionOutlineWorkflow,
  926. } from "@/lib/novel/outline-workflow-state"
  927. ```
  928. - [ ] **Step 2: 在关键流程节点转换状态**
  929. 在 `handleGenerateSection` 中,发送意图分析前:
  930. ```typescript
  931. if (canTransitionOutlineWorkflow(outlineWorkflowStage, "intent_analysis")) {
  932. setOutlineWorkflowStage("intent_analysis");
  933. }
  934. ```
  935. 在流结束回调中,clear 分支自动注入生成前:
  936. ```typescript
  937. if (canTransitionOutlineWorkflow(outlineWorkflowStage, "sufficiency_check")) {
  938. setOutlineWorkflowStage("sufficiency_check");
  939. }
  940. ```
  941. 在 needs_input 分支:
  942. ```typescript
  943. if (canTransitionOutlineWorkflow(outlineWorkflowStage, "waiting_user_input")) {
  944. setOutlineWorkflowStage("waiting_user_input");
  945. }
  946. ```
  947. 在用户选择推荐后(onSendMessage 回调中):
  948. ```typescript
  949. if (canTransitionOutlineWorkflow(outlineWorkflowStage, "sufficiency_check")) {
  950. setOutlineWorkflowStage("sufficiency_check");
  951. }
  952. ```
  953. 在生成完成(流结束且 intentPhase 为 generation)时:
  954. ```typescript
  955. if (canTransitionOutlineWorkflow(outlineWorkflowStage, "saved")) {
  956. setOutlineWorkflowStage("saved");
  957. }
  958. ```
  959. - [ ] **Step 3: 在面板标题区域显示当前状态**
  960. 在面板顶栏(约 L2800 附近),在现有标题旁添加状态标签:
  961. ```tsx
  962. {outlineWorkflowStage !== "idle" && outlineWorkflowStage !== "saved" ? (
  963. <span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
  964. {outlineWorkflowStage === "intent_analysis" ? "意图分析中" :
  965. outlineWorkflowStage === "waiting_user_input" ? "等待选择" :
  966. outlineWorkflowStage === "sufficiency_check" ? "生成中" :
  967. "处理中"}
  968. </span>
  969. ) : null}
  970. ```
  971. - [ ] **Step 4: 运行 typecheck**
  972. Run: `npx tsc --noEmit`
  973. Expected: 无错误
  974. - [ ] **Step 5: 提交**
  975. ```
  976. git add src/components/sources/outline-chat-panel.tsx
  977. git commit -m "面板集成状态机驱动流程"
  978. ```
  979. </div>
  980. <div class="task-block">
  981. <h3>Task 10: 新增面板集成测试</h3>
  982. <p><strong>问题 M3</strong>:缺少面板集成测试。</p>
  983. **Files:**
  984. - Modify: `src/components/sources/outline-chat-panel.spec.tsx`
  985. - [ ] **Step 1: 在现有测试文件中新增集成测试**
  986. 在 `src/components/sources/outline-chat-panel.spec.tsx` 末尾添加:
  987. ```typescript
  988. it("handleGenerateSection 发送意图分析 prompt 并设置 intentPhase", () => {
  989. renderOutlineChatPanel();
  990. const generateButtons = screen.getAllByRole("button", { name: /章节细纲|人物小传|组织势力/ });
  991. // 点击第一个生成按钮
  992. if (generateButtons.length > 0) {
  993. fireEvent.click(generateButtons[0]);
  994. // 验证 handleSend 被调用且 intentPhase 为 intent_analysis
  995. const lastCall = mockHandleSend.mock.calls[mockHandleSend.mock.calls.length - 1];
  996. expect(lastCall?.[2]?.intentPhase).toBe("intent_analysis");
  997. }
  998. });
  999. it("intent_clarity 解析后 clear 自动注入生成 prompt", () => {
  1000. // 验证 stripStructuredMarkers 被正确调用
  1001. // 验证显示内容中不含 intent_clarity JSON 块
  1002. renderOutlineChatPanel();
  1003. // 模拟一条包含 intent_clarity 的 assistant 消息
  1004. const store = useOutlineChatStore.getState();
  1005. const convId = store.activeConversationId ?? store.createConversation();
  1006. store.addMessage(convId, {
  1007. id: "test-msg-1",
  1008. role: "assistant",
  1009. content: `<!-- intent_clarity -->\n{"clarity":"clear","module":"章节细纲","analysis":"3章缺细纲","detectedScope":"第1-3章","missingItems":[],"options":[],"question":""}\n<!-- /intent_clarity -->`,
  1010. intentPhase: "intent_analysis",
  1011. intentClarityResult: {
  1012. clarity: "clear",
  1013. module: "章节细纲",
  1014. analysis: "3章缺细纲",
  1015. detectedScope: "第1-3章",
  1016. missingItems: [],
  1017. options: [],
  1018. question: "",
  1019. },
  1020. });
  1021. // 重新渲染
  1022. rerenderOutlineChatPanel();
  1023. // 验证显示内容中不包含 intent_clarity 标记
  1024. expect(screen.queryByText(/intent_clarity/)).toBeNull();
  1025. });
  1026. ```
  1027. 注意:需要根据现有测试的 mock 结构调整。如果测试使用的是真实组件渲染,需确保 mock 了 handleSend。
  1028. - [ ] **Step 2: 运行测试**
  1029. Run: `npx vitest run src/components/sources/outline-chat-panel.spec.tsx`
  1030. Expected: 全部 PASS
  1031. - [ ] **Step 3: 提交**
  1032. ```
  1033. git add src/components/sources/outline-chat-panel.spec.tsx
  1034. git commit -m "新增面板集成测试"
  1035. ```
  1036. </div>
  1037. <h2><span class="phase-tag phase-4">第四阶段</span>性能与兼容性打磨(P3)</h2>
  1038. <div class="callout callout-success">
  1039. <strong>目标</strong>:性能优化、兼容性保障、边界场景处理。预估 4.5h。
  1040. </div>
  1041. <div class="task-block">
  1042. <h3>Task 11: 性能优化</h3>
  1043. <p><strong>问题 P1-P3</strong>:buildOutlineStages 频繁重计算、组件未 memo、正则替换效率低。</p>
  1044. **Files:**
  1045. - Modify: `src/components/sources/outline-workflow-stages.tsx`
  1046. - Modify: `src/lib/novel/outline-stage-trace.ts`
  1047. - [ ] **Step 1: 对 OutlineWorkflowStages 添加 React.memo**
  1048. 将 `OutlineWorkflowStages` 导出改为:
  1049. ```typescript
  1050. export const OutlineWorkflowStages = React.memo(function OutlineWorkflowStages(props: OutlineStageInput) {
  1051. const stages = buildOutlineStages(props)
  1052. const visibleStages = stages.filter((s) => s.status !== "hidden")
  1053. const defaultOpenId = stages.find((s) => s.status === "active")?.kind ?? null
  1054. const [openMap, setOpenMap] = useState<Record<string, boolean>>({})
  1055. if (visibleStages.length === 0) return null
  1056. return (
  1057. <div className="mb-2 space-y-1">
  1058. <div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground mb-1">
  1059. <span>思考过程</span>
  1060. </div>
  1061. {visibleStages.map((stage) => {
  1062. const isOpen = openMap[stage.kind] ?? (stage.status === "active")
  1063. return (
  1064. <StageRow
  1065. key={stage.kind}
  1066. stage={stage}
  1067. open={isOpen}
  1068. onToggle={() => setOpenMap((prev) => ({ ...prev, [stage.kind]: !isOpen }))}
  1069. />
  1070. )
  1071. })}
  1072. </div>
  1073. )
  1074. })
  1075. ```
  1076. - [ ] **Step 2: 优化 hasGenerationStarted 避免全文正则替换**
  1077. 在 `outline-stage-trace.ts` 中,将:
  1078. ```typescript
  1079. function hasGenerationStarted(content: string): boolean {
  1080. const withoutThinking = content.replace(THINKING_PATTERN, "")
  1081. const withoutClarity = withoutThinking.replace(INTENT_CLARITY_PATTERN, "")
  1082. return withoutClarity.trim().length > 0
  1083. }
  1084. ```
  1085. 替换为:
  1086. ```typescript
  1087. function hasGenerationStarted(content: string): boolean {
  1088. if (!content) return false
  1089. let idx = 0
  1090. while (idx < content.length) {
  1091. const thinkStart = content.indexOf("<think", idx)
  1092. const clarityStart = content.indexOf("<!-- intent_clarity", idx)
  1093. if (thinkStart === -1 && clarityStart === -1) {
  1094. return content.slice(idx).trim().length > 0
  1095. }
  1096. const nextMark = Math.min(
  1097. thinkStart === -1 ? Infinity : thinkStart,
  1098. clarityStart === -1 ? Infinity : clarityStart,
  1099. )
  1100. if (nextMark > idx && content.slice(idx, nextMark).trim().length > 0) {
  1101. return true
  1102. }
  1103. const thinkEnd = content.indexOf("</think>", thinkStart)
  1104. const clarityEnd = content.indexOf("<!-- /intent_clarity -->", clarityStart)
  1105. idx = Math.max(
  1106. thinkStart >= 0 && thinkEnd >= 0 ? thinkEnd + 8 : 0,
  1107. clarityStart >= 0 && clarityEnd >= 0 ? clarityEnd + 24 : 0,
  1108. nextMark + 1,
  1109. )
  1110. }
  1111. return false
  1112. }
  1113. ```
  1114. - [ ] **Step 3: 运行测试**
  1115. Run: `npx vitest run src/lib/novel/outline-stage-trace.spec.ts`
  1116. Expected: 全部 PASS
  1117. - [ ] **Step 4: 提交**
  1118. ```
  1119. git add src/components/sources/outline-workflow-stages.tsx src/lib/novel/outline-stage-trace.ts
  1120. git commit -m "性能优化:React.memo和indexOf替代正则替换"
  1121. ```
  1122. </div>
  1123. <div class="task-block">
  1124. <h3>Task 12: 兼容性保障</h3>
  1125. <p><strong>问题 C1-C3</strong>:旧数据兼容、AI格式遵从度fallback、弱模型降级。</p>
  1126. **Files:**
  1127. - Modify: `src/components/sources/outline-chat-panel.tsx`
  1128. - Modify: `src/components/sources/outline-workflow-stages.tsx`
  1129. - [ ] **Step 1: 弱模型降级 — 无工具调用时显示简化状态**
  1130. 在 `OutlineWorkflowStages` 组件中,当 `visibleStages.length === 0` 但 `isStreaming` 为 true 时,显示简化状态:
  1131. 将:
  1132. ```typescript
  1133. if (visibleStages.length === 0) return null
  1134. ```
  1135. 替换为:
  1136. ```typescript
  1137. if (visibleStages.length === 0) {
  1138. if (isStreaming) {
  1139. return (
  1140. <div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
  1141. <Loader2 className="h-3 w-3 animate-spin" />
  1142. <span>生成中...</span>
  1143. </div>
  1144. )
  1145. }
  1146. return null
  1147. }
  1148. ```
  1149. - [ ] **Step 2: 验证旧数据兼容 — 检查可选字段安全访问**
  1150. 在 `OutlineAssistantMessage` 中,所有新增字段访问均使用可选链 `?.`:
  1151. - `msg.intentClarityResult?.clarity` — 安全
  1152. - `msg.nextStepRecommendation?.recommendations` — 安全
  1153. - `msg.intentPhase` — 安全(undefined 时不会触发任何分支)
  1154. 确认无需额外修改。
  1155. - [ ] **Step 3: 运行 typecheck + 全量测试**
  1156. Run: `npx tsc --noEmit`
  1157. Run: `npx vitest run src/lib/novel/outline-intent-clarity.spec.ts src/lib/novel/outline-stage-trace.spec.ts src/lib/novel/outline-next-step.spec.ts src/components/sources/outline-chat-panel.spec.tsx`
  1158. Expected: 无错误,全部 PASS
  1159. - [ ] **Step 4: 提交**
  1160. ```
  1161. git add src/components/sources/outline-chat-panel.tsx src/components/sources/outline-workflow-stages.tsx
  1162. git commit -m "兼容性保障:弱模型降级显示、旧数据安全访问"
  1163. ```
  1164. </div>
  1165. <div class="task-block">
  1166. <h3>Task 13: 最终回归验证</h3>
  1167. **Files:** 无新修改
  1168. - [ ] **Step 1: 运行全部新增模块测试**
  1169. Run: `npx vitest run src/lib/novel/outline-intent-clarity.spec.ts src/lib/novel/outline-stage-trace.spec.ts src/lib/novel/outline-next-step.spec.ts`
  1170. Expected: 全部 PASS
  1171. - [ ] **Step 2: 运行大纲面板测试**
  1172. Run: `npx vitest run src/components/sources/outline-chat-panel.spec.tsx`
  1173. Expected: 全部 PASS
  1174. - [ ] **Step 3: 运行 AI 会话面板测试(回归验证)**
  1175. Run: `npx vitest run src/components/chat/agent-tool-call-message.spec.tsx`
  1176. Expected: 全部 PASS
  1177. - [ ] **Step 4: 运行 TypeScript 全量编译检查**
  1178. Run: `npx tsc --noEmit`
  1179. Expected: 无错误
  1180. - [ ] **Step 5: 确认 git 状态**
  1181. Run: `git log --oneline -15`
  1182. 确认所有 13 个任务的 commit 都在 dagangshengji 分支上
  1183. - [ ] **Step 6: 标记任务完成**
  1184. 所有优化任务完成,更新 todo 列表。
  1185. </div>
  1186. </body>
  1187. </html>