# Changelog All notable changes to StoryMoss (草苔) project will be documented in this file. ## v0.30.45(2026-07-31) ### 修复文思活跃模式续写提示词泄露(LLM 思维链泄露到正文) 用户报告"开启了文思活跃模式后,出现提示词泄露问题"--续写返回的不是小说正文,而是 LLM 的思维链(CoT):"这是一个小说续写任务,需要我以专业作者身份,根据给定的设定和指令,续写一部小说正文。让我从设定中提取关键信息..."。诊断显示 LLM(deepseek-v4)成功返回 2460 字符,但全部是分析性规划文本而非小说正文。四层防线全部失守: - **根因 1·`resolve_content` 错误回退(`llm/openai.rs`)**:v0.30.25 假设"推理模型可能把实际内容放在 reasoning_content",当 `content` 为空时回退到 `reasoning_content`。但 DeepSeek 的 `reasoning_content` 是**思维链**(CoT),不是正文。回退直接把 CoT 当正文返回。现移除回退:`content` 为空时返回空字符串 + `log::warn!`,让调用方处理(重试/报错)。流式版本同理移除 `reasoning_content` fallback。 - **根因 2·`max_tokens: 2048` 太小(`agents/orchestrator.rs`)**:推理模型 CoT 消耗 1500-2500 token,2048 留给正文的预算为 0 -> `content` 返回空 -> 触发回退。三处 `Some(2048)` 改为 `Some(4096)`(TimeSliced line 1064 / TriShot line 1775 / TriShot retry line 1891),给 CoT ~2500 token + 正文 ~1500 token。 - **根因 3·裸 CoT 检测(`agents/orchestrator.rs` `sanitize_novel_output`)**:新增 `detect_and_strip_bare_cot` 纯函数--扫描前 2000 字符内的非空行,统计命中 CoT 信号词("这是一个小说续写任务"/"让我从"/"我需要落实"/"根据要求"/"叙事四元组"/"剧情引擎"/"桥段卡" 等 40+ 个)的行数;≥3 行命中判定为 CoT 泄露,尝试提取正文起点(第一个不含信号词且 >20 字符的行),找不到则返回空。作为 `sanitize_novel_output` step 0e 插入(step 0d markdown 剥离之后、step 1 demd 之前)。设计原则:保守检测(≥3 行阈值),宁可漏检也不误删正文。 - **根因 4·prompt 禁止输出思考过程(`resources/prompts/writer/`)**:`writer_system.md`(version 0.26.44 -> 0.30.45)输出要求段新增"不要输出你的思考过程、分析、规划或元评论--直接从小说正文第一句开始"+"禁止以'这是一个...'、'让我...'、'我需要...'等分析性语句开头";`orchestrator_timesliced_writer.md`(version 0.30.32 -> 0.30.45)要求段新增"6. 直接输出正文,不要输出思考过程、分析或规划"。 - **测试**:`openai.rs` 更新 1 测试(`resolve_content_does_not_fall_back_to_reasoning` 断言空 content + 有 reasoning_content 时返回空而非 CoT);`orchestrator.rs` +3 测试(`detect_and_strip_bare_cot` 正向剥离 / 全 CoT 返回空 / 正常正文不误删 / 不足阈值不剥离)。 - **验证**:`cargo test --lib` 1091 passed / 2 ignored(+4);`npx tsc --noEmit` ✅;`npx vitest run` 352 passed / 3 skipped;`cargo +nightly fmt` / `cargo clippy --lib`(539 零新增)/ `architecture_guard` / `npm run format:check` 全绿。 ## v0.30.44(2026-07-29) ### 修复文思活跃模式续写报"生成过程异常结束,未收到有效内容" 用户报告"开启了文思活跃模式后,出现了报错的诊断信息"。诊断数据显示 LLM(deepseek-v4)成功返回 2460 字符,但前端 `generatedText` 仅剩 3 字符("正文续"),打字机动画显示 18 字符增长(12->15->18)后被中断,最终弹出"生成过程异常结束,未收到有效内容"。根因:`smartExecuteInFlightRef.current = false` 在 smartExecute resolve 后、内容处理前被提前清除--后台活动同步回调(100ms 防抖)在内容处理期间(isAlreadyPresent 检查 / active mode 追加 / appendAiContent 同步步骤)把 `isGenerating` 置 false,触发安全网 effect(`!isGenerating && smartExecuteNeedDiagnosticRef.current`)误报。`handleRequestGeneration` 的活跃模式分支还错误地走了打字机幽灵文本(3 字符/帧),而非直接 `appendAiContent` 追加到编辑器正文。 - **主修复·`handleRequestGeneration` 提前清除 flight 标志(`FrontstageApp.tsx`)**:移除 smartExecute resolve 后的 `smartExecuteInFlightRef.current = false`(line ~3231)。改为在各退出路径统一清除:打字机完成时、displayText 空 bail、background bootstrap、genesis 首章、aborted、active mode 追加后。确保内容处理期间 `isGenerating` 不被后台活动同步干扰。 - **主修复·`handleSmartGeneration` 同类根因(`FrontstageApp.tsx`)**:移除 smartExecute resolve 后的 `smartExecuteInFlightRef.current = false`(line ~4194),与 `handleRequestGeneration` 同理。在各内容交付路径(aborted / isAlreadyPresent / isBootstrapCompleted&&delivered / active mode append / isFirstChapterReady / ghost text)统一清除 `smartExecuteInFlightRef` + `smartExecuteNeedDiagnosticRef`;`finally` 块在 `setIsGenerating(false)` 之后兜底清除 flight 标志防泄漏。 - **活跃模式直追(`FrontstageApp.tsx` `handleRequestGeneration`)**:在打字机之前新增活跃模式分支--`wensiModeRef.current === 'active'` 时直接 `appendAiContent(displayText, 'auto')` + 清除两标志 + `setIsGenerating(false)`,绕过打字机(与 `handleSmartGeneration` 活跃模式行为一致)。打字机 3 字符/帧的逐帧动画在活跃模式下无意义且引入安全网误报窗口。 - **回归测试(`FrontstageApp.wensi-active.test.tsx`)**:+2 测试。①活跃模式续写内容直接追加到编辑器正文,不走打字机幽灵文本(断言 `captured.content` 含续写文本 + `captured.generatedText` 不含);②`smartExecuteNeedDiagnosticRef` 被清除,不触发"生成过程异常结束"诊断。测试 mock 修复:RichTextEditor mock 的 `getHTML()` 此前返回 stale `props.content`(appendText 后未更新),导致 `appendAiContent` 的 `getHTML -> setContent` 覆写回旧值;改为用 mutable ref 跟踪编辑器内部 HTML,`getHTML`/`getText`/`appendText`/`setContent` 统一读写该 ref(对齐真实 TipTap `getHTML` 返回实时 DOM 行为)。 - **验证**:`npx tsc --noEmit` ✅;`npx vitest run` 352 passed / 3 skipped(+2);`cargo +nightly fmt` / `cargo clippy --lib`(538 零新增)/ `architecture_guard` / `npm run format:check` 全绿。纯前端修复,无 Rust 变更(cargo 基线 1087 不变)。 ## v0.30.43(2026-07-30) ### 修复续写内容丢失根因:flushSceneSave 读取滞后的 latestContentRef + onChapterUpdated 覆写未保存内容 v0.30.33/v0.30.34 的关闭前 flush + 序列化持久化仍未能完全解决续写内容丢失。深入诊断定位两个根因:①`flushSceneSave` 读取 `latestContentRef.current` 而非编辑器实际 HTML--RichTextEditor 的 `onChange` 有 200ms 防抖(`htmlDebounceRef`),`latestContentRef` 可能比编辑器实际内容滞后 200ms,关闭应用/切换章节时若读 `latestContentRef`,最后 200ms 内的输入会丢失;②`onChapterUpdated`(后台 auto_commit 触发)用 DB 旧内容 `setContent` 覆写编辑器但不更新 `latestContentRef`,若用户有尚未落库的输入(防抖窗口内),编辑器被 DB 旧内容覆写后用户再输入,旧输入从编辑器消失且 `latestContentRef` 被新输入覆盖,造成不可逆丢失。 - **主修复·flushSceneSave 直接读编辑器(`FrontstageApp.tsx`)**:`flushSceneSave` 从 `editorRef.current?.getHTML()` 读取编辑器实际 HTML,`editorRef` 不可用时回退 `latestContentRef.current`;读后回写 `latestContentRef.current = content` 保持一致。覆盖关闭前 flush(`frontstage-flush-requested` 事件)、章节切换(`selectChapter`)、AI 追加(`appendAiContent`)、修稿(`handlePipelineRefine`/`onReviseResult`)全部 flush 路径。消除 200ms HTML 防抖窗口导致的内容丢失。 - **Root Cause #2·onChapterUpdated 保护未保存内容 + 同步 latestContentRef(`FrontstageApp.tsx`)**:`onChapterUpdated` 在 `setContent(formatted)` 前新增守卫--若 `latestContentRef`(会被 flush 保存的内容)非空且与 DB 内容不同,说明用户有尚未落库的输入(200ms HTML 防抖窗口内或 2000ms 自动保存防抖未出火),此时绝不用 DB 旧内容覆写编辑器,直接 `return` 跳过;`setContent` 后补 `latestContentRef.current = formatted` 同步刷新后的内容,使后续 flush 保存 onChapterUpdated 刚加载的 DB 内容而非旧值。 - **附带·setContent('') 清空 latestContentRef(`FrontstageApp.tsx`)**:无章节时 `setContent('')` 后补 `latestContentRef.current = ''`,避免 flushSceneSave 保存已清空的旧内容。 - **验证**:`cargo test --lib` 1087 passed(无 Rust 变更);`npx tsc --noEmit` ✅;`npx vitest run` 350 passed / 3 skipped(+1:close-flush 保存编辑器实际内容而非滞后 latestContentRef 回归测试);`cargo +nightly fmt` / `cargo clippy --lib`(538 零新增)/ `architecture_guard` / `npm run format:check` 全绿。 ## v0.30.42(2026-07-30) ### 修复世界观生成失败(LLM 返回 markdown 代码块包裹的 JSON + 未转义引号 + 静默失败 + prompt 字段名不匹配) issue #14 用户报告"世界观生成失败,请重试",但日志显示 LLM API 调用成功返回内容(7636 字符),失败发生在下游 JSON 解析且完全无错误日志。根因三层:①模型将 JSON 包裹在 ` ```json ... ``` ` 代码块中、或在字符串值内直接换行/使用裸双引号,下游 `serde_json::from_str` 静默失败;②`novel_creation.rs` 用严格 `serde_json::from_str` 解析全量响应(含围栏)直接失败,`agency/coordinator.rs::parse_lenient` 用 `rfind('}')` 会被尾部杂散 `}` 误导且无法修复字符串内裸换行;③`novel_creation_world_options.md` prompt 要求"concepts 数组"但代码读 `parsed["world_buildings"]`,即使解析成功也找不到数组;prompt 缺少格式约束(未禁止 markdown 围栏 / 未要求转义引号)。 - **Fix 1·`parse_lenient` 复用健壮提取器(`agency/coordinator.rs`)**:`parse_lenient` 改为先调 `crate::narrative::extract_and_sanitize_json`(剥离 markdown 围栏 / 推理链、括号深度匹配跳过尾部杂散 `}`、修复字符串内未转义换行、移除 BOM / 注释 / 尾随逗号),失败再回退旧的首尾花括号截取。覆盖 agency 全部 JSON 解析路径(concept_pack / producer_depth_assets 世界观 / editor 裁决 / retrieval plan)。`extract_and_sanitize_json` 已存在于 `narrative` 且被 memory/analysis 等模块使用,`agents` 已有 `crate::narrative::strip_reasoning_blocks` 先例,无新跨层依赖。 - **Fix 2·`novel_creation.rs` 世界观选项解析健壮化**:提取 `parse_world_options_response` 纯函数(便于单测,无需 mock LlmService),先 `extract_and_sanitize_json` 剥离围栏再 `serde_json::from_str`;解析失败时 `log::warn!` 记录错误 + raw 长度 + 200 字片段(此前完全静默);`world_buildings` 缺失时错误信息明确指出"缺少 world_buildings 数组";元素反序列化 `unwrap` 改 `map_err` 不再 panic。 - **Fix 3·prompt 字段名修正 + 格式约束(`novel_creation_world_options.md` + `narrative_world_building_generate.md`)**:`novel_creation_world_options.md` "concepts 数组" -> `world_buildings`(与代码一致)并补全完整 schema 示例;两份 prompt 新增格式约束--禁止 markdown 代码块包裹、字符串值内引用用中文引号「」或转义 `\"`、禁止 JSON 外输出任何文字。prompt version 标记 0.30.42 / 0.30.46。 - **测试**:`agency/tests.rs` +2(parse_lenient 剥离围栏 + 尾部杂散 `}` / 修复字符串内裸换行);`novel_creation.rs` +3(干净 JSON / markdown 围栏包裹 / 缺 world_buildings 键报错守卫)。 - **验证**:`cargo test --lib` 1087 passed / 2 ignored(+5);`npx tsc --noEmit` ✅;`npx vitest run` 349 passed / 3 skipped;`cargo +nightly fmt` / `cargo clippy --lib`(538,零新增)/ `architecture_guard` / `npm run format:check` 全绿。 ## v0.30.41(2026-07-30) ### 修复续写内容被假阳性去重静默丢弃(模型回显指令 + 短文本假阳性 + 内容丢失) 用户诊断报告显示续写生成时 LLM(deepseek-v4)成功返回 2511 字符,但前端仅显示 6 字符("续写\n黑暗。"),随后报"生成过程异常结束,未收到有效内容"。根因链:①模型在生成内容开头回显用户指令"续写"(非正文);②打字机动画首帧仅 3 字符("续写\n"),归一化后 2 字符"续写"几乎必然出现在 9656 字已有正文中;③`isTextDuplicate` 假阳性返回 true,`setGeneratedText` 跳过赋值并 `markAccepted` 存入 2 字符指纹;④生成内容被静默丢弃。两层修复: - **Fix 1·`isTextDuplicate` 最小长度守卫(`textCleanup.ts`)**:归一化后 < 30 字符的生成文本直接返回 false,不进行去重检查。打字机首帧(3 字符)、短回显前缀(2 字符)等短文本在长篇正文中几乎必然命中 `includes()` 造成假阳性;只有生成文本足够长(≥30 归一化字符)时才检查是否为已有内容的子串。全量内容(2511 字符)仍正常评估去重。 - **Fix 2·`stripInstructionEcho` 指令回显剥离(`textCleanup.ts` + `FrontstageApp.tsx`)**:新增 `stripInstructionEcho(generated, userInput)` --归一化比较生成文本开头与用户指令,若开头匹配则裁掉原始文本中对应前缀及紧随的分隔符(换行/冒号/逗号等),剩余内容过短(<10 字符)则保留原文防误剥。在 `handleRequestGeneration` 和 `handleSmartGeneration` 的 `sanitizeContinuationOutput` 后调用,覆盖打字机路径与 smart_execute 直接路径。 - **测试**:`isTextDuplicate.test.ts` 新增 2 测试(短文本假阳性守卫 + 长文本真阳性验证),更新 1 测试(前缀检测改用 ≥40 字符);`textCleanup.test.ts` 新增 7 测试(`stripInstructionEcho` 正常剥离/冒号分隔/不匹配不剥/短输入不剥/空输入不剥/剩余过短保留/长指令剥离),更新 1 测试(`isTextDuplicate` 用 ≥30 字符长文本)。 - **验证**:`npx tsc --noEmit` ✅;`npx vitest run` 349 passed / 3 skipped(+13);`npm run format:check` ✅;`architecture_guard` ✅。纯前端修复,无 Rust 变更(cargo 基线不变)。 ## v0.30.40(2026-07-29) ### 修复代理工作室不显示活动记录数据(activeRunId 仅从事件捕获 + 无 list_runs 命令) 用户报告"前端后台的代理工作室,没有显示代理活动的记录数据"。根因:`activeRunId` 仅从实时事件捕获,页面后开时恒 null;无 `list_runs` 命令发现已有 run。 - **根因(`AgencyStudio.tsx`)**:`activeRunId` 仅从 `agency-agent-activity`/`agency-run-progress`/`agency-board-changed` 三个 `listen` 设置,IPC 查询 `getRun`/`listBoard` 的 `enabled: !!activeRunId`--用户在 run 启动后或完成后打开页面时无事件到达,`activeRunId` 恒 null,页面永远显示"暂无活动"。activity/progress 事件 fire-and-forget 不持久化,时间线数据页面卸载即丢失。 - **后端·新增 `agency_list_runs` 命令(`agency/repository.rs` + `agency/commands.rs` + `handlers.rs`)**:`list_runs_for_story(story_id, limit)` 按 `created_at DESC` 列出 story 的全部 run(limit=20),前端可发现已有 run。 - **前端·activeRunId 水合(`AgencyStudio.tsx`)**:新增 `useQuery(['agency-runs', ...], listRuns)` + `useEffect` 在 runs 到达且 `!activeRunId` 时取 `runs[0].id` 水合。 - **前端·历史时间线重建(`AgencyStudio.tsx`)**:时间线从仅 live 事件改为三源合并(live 事件 + board items 重建 + run 生命周期),去重排序截断 100 条。无需新表/迁移。 - **前端·Run 选择器(`AgencyStudio.tsx`)**:标题栏新增 `