# StoryMoss Agent 指南 > 本文件包含 AI 助手需要了解的项目背景、编码风格、工具配置与强制构建规则。 ## 项目背景 **StoryMoss (草苔)** — AI 辅助小说创作桌面应用 - **项目根目录**: `/Users/yuzaimu/projects/StoryMoss` - **版本**: v0.30.45 - **GitHub**: https://github.com/91zgaoge/StoryMoss - **技术栈**: Tauri 2.4 + Rust 1.95.0 + React 18 + TypeScript 5.8 + Vite 6 + SQLite + LanceDB - **双界面**: 幕前 `/frontstage.html`(沉浸式写作),幕后 `/index.html`(工作室管理) ## 编码风格 - **Rust**: `snake_case`,`Result`,异步 `async/await`,数据库 `rusqlite` + `r2d2`。 - **TypeScript**: `camelCase`,函数组件 + Hooks,Zustand 状态管理,TanStack Query 调用后端。 ## 开发命令 ```bash # 前端开发服务器 cd src-frontend && npm run dev # 启动 Tauri 桌面应用 cd src-tauri && cargo tauri dev # 构建生产版本 cd src-tauri && cargo tauri build # 测试与检查 cd src-tauri && cargo test --lib cd src-frontend && npx tsc --noEmit npx vitest run npm test # Playwright E2E node scripts/cdp-inspect.js # CDP 截图 ``` ## Pre-commit 格式守卫 仓库内置 `.githooks/pre-commit`:提交前自动检查本次 staged 的 Rust(`cargo +nightly fmt -- --check`)与前端(`prettier --check`)代码是否已格式化,未格式化则拒绝提交,对齐 CI 的 fmt 检查。 - **首次克隆后启用**:`git config core.hooksPath .githooks` - **行为**:仅检查本次 `git add` 进来的 `.rs` / `.ts` / `.tsx` / `.css` / `.json` 代码文件,纯文档/配置提交不受影响;失败时打印 diff 并给出修复命令。 - **修复**:按提示执行 `(cd src-tauri && cargo +nightly fmt)` 或 `(cd src-frontend && npm run format)`,再 `git add -u && git commit`。 - **紧急绕过**:`git commit --no-verify`(仅限紧急情况,CI 仍会兜底检查)。 ## 强制构建规则(用户级) 1. **每次修改代码后**:先推送到 GitHub,触发 GitHub Actions 全平台构建。 2. **本地构建仅在用户明确要求时执行**:推送后由 GitHub Actions 负责全平台构建(macOS `.dmg` / Windows `.exe`+`.msi` / Linux `.AppImage`+`.deb`)。**除非用户明确要求「构建」/「打包」/「生成本地安装包」,否则不要在本地执行 `cargo tauri build`**——本地 `cargo test --lib` / `cargo check` / `tsc` / `vitest` 等验证命令照常运行,仅省略耗时的打包构建。此规则为用户级永久指令,优先级高于本节其它条目。 3. **版本号统一**:`Git tag`、`Cargo.toml`、`src-tauri/tauri.conf.json`、`src-frontend/package.json` 必须一致。 4. **每次推送必须更新** `README.md` 与以下文档:`CHANGELOG.md`、`AGENTS.md`、`PROJECT_STATUS.md`、`ROADMAP.md`、`ARCHITECTURE.md`、`TESTING.md`、`docs/USER_GUIDE.md`。 5. **版本标签**:每次推送使用新 tag,禁止 force push 覆盖已有 tag。 ```bash git tag -a vX.Y.Z -m "..." && git push origin vX.Y.Z ``` 6. **网站 Release 保留策略**:`.github/scripts/upload-releases-ftp.mjs` 每次上传后会自动清理 `/releases` 目录,仅保留最近 5 个版本的安装包(`RELEASE_RETENTION_COUNT=5`,可通过环境变量覆盖),防止服务器空间不足。禁止删除 `latest.json` 与无版本号文件(如 `StoryMoss_aarch64.app.tar.gz`)。 7. **网站下载页内容及时同步**(用户级永久指令):落地页(`landing/`)下载区**运行时**从 `https://storymoss.top/releases/latest.json` 拉取版本号并拼出下载链接(`landing/src/hooks/useLatestRelease.ts`),因此每次发版后下载页版本号与链接**自动跟随最新 release,无需重新部署落地页**。注意:发版(tag push)**不**触发 `deploy-landing.yml`(它只在 `landing/**` 变更时构建部署),运行时 fetch 才是保持下载页新鲜的机制。两条强制维护义务: - **兜底版本必须随发版 bump**:`useLatestRelease.ts` 的 `FALLBACK_VERSION` 必须与 `Cargo.toml` / `src-frontend/package.json` 的版本号同步更新--这是 fetch 失败(离线/服务器故障)时下载链接仍指向有效版本的最后一道防线,否则兜底链接会指向已被保留策略删除的旧版本而 404。 - **文件名规律变更时必须校验**:`buildReleaseUrls` 内的 bundle 命名(`StoryMoss_{version}_aarch64.dmg` / `_x64_zh-CN.msi` / `_amd64.AppImage`)在 Tauri bundle 命名或语言包变更时需重新对照线上 `latest.json` 核对。 ## 提交信息格式 ``` : type: feat / fix / docs / style / refactor / test / chore ``` ## 重要文档 - [README.md](./README.md) - [docs/USER_GUIDE.md](./docs/USER_GUIDE.md) - [ARCHITECTURE.md](./ARCHITECTURE.md) - [TESTING.md](./TESTING.md) - [CHANGELOG.md](./CHANGELOG.md) - [ROADMAP.md](./ROADMAP.md) - [docs/archive/AGENTS_HISTORY.md](./docs/archive/AGENTS_HISTORY.md) — 完整历史版本记录 - [docs/archive/LESSONS_LEARNED.md](./docs/archive/LESSONS_LEARNED.md) — 项目修复过程中积累的经验教训与反模式 ## 当前编译状态 - `cargo check` ✅ 零错误 - `cargo test -p storymoss` ✅ 1091 passed - `npx tsc --noEmit` ✅ - `npx vitest run` ✅ 352 passed / 3 skipped - `npx playwright test` ✅ 本版未重跑 E2E - `cargo +nightly fmt` ✅ - `cargo clippy --lib` ✅ 539(零新增) - `npm run format:check` ✅ - `python3 scripts/architecture_guard.py` ✅ ## 最近完成的功能 ### v0.30.45 - 修复文思活跃模式续写提示词泄露(LLM 思维链泄露到正文) 用户报告"开启了文思活跃模式后,出现提示词泄露问题"--续写返回的不是小说正文,而是 LLM 的思维链(CoT):"这是一个小说续写任务,需要我以专业作者身份..."。四层防线全部失守导致 deepseek-v4 推理模型的 CoT 被当作正文返回。 - **根因 1·`resolve_content` 错误回退(`llm/openai.rs`)**:v0.30.25 假设推理模型可能把实际内容放在 reasoning_content,content 为空时回退。但 reasoning_content 是思维链不是正文。现移除回退,content 为空返回空 + warn。 - **根因 2·`max_tokens: 2048` 太小(`agents/orchestrator.rs`)**:推理模型 CoT 消耗 1500-2500 token,2048 留给正文预算为 0 -> content 空 -> 触发回退。三处 `Some(2048)` -> `Some(4096)`。 - **根因 3·裸 CoT 检测(`agents/orchestrator.rs` `sanitize_novel_output`)**:新增 `detect_and_strip_bare_cot` 纯函数--扫描前 2000 字符非空行,≥3 行命中 CoT 信号词(40+ 个)判定泄露,尝试提取正文起点,找不到返回空。作为 step 0e 插入。 - **根因 4·prompt 禁止输出思考过程(`resources/prompts/writer/`)**:`writer_system.md` + `orchestrator_timesliced_writer.md` 新增"不要输出思考过程/分析/规划"+"禁止以分析性语句开头"。 - **验证**:`cargo test --lib` 1091 passed / 2 ignored(+4);`npx vitest run` 352 passed / 3 skipped;`cargo +nightly fmt` / `cargo clippy --lib`(539 零新增)/ `architecture_guard` / `npm run format:check` 全绿。 ### v0.30.44 - 修复文思活跃模式续写报"生成过程异常结束,未收到有效内容" 用户报告"开启了文思活跃模式后,出现了报错的诊断信息"。诊断数据显示 LLM(deepseek-v4)成功返回 2460 字符,但前端 `generatedText` 仅剩 3 字符("正文续"),打字机动画显示 18 字符增长(12->15->18)后被中断,最终弹出"生成过程异常结束,未收到有效内容"。根因:`smartExecuteInFlightRef.current = false` 在 smartExecute resolve 后、内容处理前被提前清除--后台活动同步回调(100ms 防抖)在内容处理期间把 `isGenerating` 置 false,触发安全网 effect(`!isGenerating && smartExecuteNeedDiagnosticRef.current`)误报。`handleRequestGeneration` 的活跃模式分支还错误地走了打字机幽灵文本(3 字符/帧),而非直接 `appendAiContent` 追加到编辑器正文。 - **主修复·`handleRequestGeneration` 提前清除 flight 标志(`FrontstageApp.tsx`)**:移除 smartExecute resolve 后的 `smartExecuteInFlightRef.current = false`。改为在各退出路径统一清除:打字机完成时、displayText 空 bail、background bootstrap、genesis 首章、aborted、active mode 追加后。确保内容处理期间 `isGenerating` 不被后台活动同步干扰。 - **主修复·`handleSmartGeneration` 同类根因(`FrontstageApp.tsx`)**:移除 smartExecute resolve 后的 `smartExecuteInFlightRef.current = false`,与 `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` 活跃模式行为一致)。 - **回归测试(`FrontstageApp.wensi-active.test.tsx`)**:+2 测试。①活跃模式续写内容直接追加到编辑器正文,不走打字机幽灵文本;②`smartExecuteNeedDiagnosticRef` 被清除,不触发"生成过程异常结束"诊断。测试 mock 修复:RichTextEditor mock 的 `getHTML()` 此前返回 stale `props.content`,改为用 mutable ref 跟踪编辑器内部 HTML(对齐真实 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 变更。 ### v0.30.43 - 修复续写内容丢失根因: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 - 修复世界观生成失败(LLM 返回 markdown 代码块包裹的 JSON + 未转义引号 + 静默失败 + prompt 字段名不匹配) issue #14 用户报告"世界观生成失败,请重试",但日志显示 LLM API 调用成功返回内容(7636 字符),失败发生在下游 JSON 解析且完全无错误日志。根因三层:①模型将 JSON 包裹在 ` ```json ... ``` ` 代码块中、或在字符串值内直接换行/使用裸双引号,`serde_json::from_str` 静默失败;②`novel_creation.rs` 严格解析全量响应(含围栏)直接失败,`agency/coordinator.rs::parse_lenient` 用 `rfind('}')` 会被尾部杂散 `}` 误导且无法修复字符串内裸换行;③`novel_creation_world_options.md` prompt 要求"concepts 数组"但代码读 `parsed["world_buildings"]`,即使解析成功也找不到数组;prompt 缺少格式约束。 - **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 外输出任何文字。 - **验证**:`cargo test --lib` 1087 passed / 2 ignored(+5:parse_lenient 剥围栏/修复裸换行 +2,novel_creation 解析 +3);`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 - 修复续写内容被假阳性去重静默丢弃(模型回显指令 + 短文本假阳性 + 内容丢失) 用户诊断报告显示续写生成时 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 测试(短文本假阳性守卫 + 长文本真阳性);`textCleanup.test.ts` +7 测试(`stripInstructionEcho` 7 场景);更新 2 既有测试(前缀检测改用 ≥40 字符 + `isTextDuplicate` 用 ≥30 字符)。 - **验证**:`npx tsc --noEmit` ✅;`npx vitest run` 349 passed / 3 skipped(+13);`npm run format:check` ✅;`architecture_guard` ✅。纯前端修复,无 Rust 变更(cargo 基线不变)。 ### v0.30.40 - 修复代理工作室不显示活动记录数据(activeRunId 仅从事件捕获 + 无 list_runs 命令) 用户报告"前端后台的代理工作室,没有显示代理活动的记录数据"。根因:`AgencyStudio.tsx` 的 `activeRunId` **仅从实时事件捕获**(`agency-agent-activity` / `agency-run-progress` / `agency-board-changed` 三个 `listen`),IPC 查询 `getRun`/`listBoard` 的 `enabled: !!activeRunId`--如果用户在 run 启动后或完成后才打开代理工作室,没有事件到达,`activeRunId` 恒为 `null`,页面永远显示"暂无活动"。此外无 `agency_list_runs` 命令发现已有 run,activity/progress 事件 fire-and-forget 不持久化(时间线数据页面卸载即丢失)。 - **后端·新增 `agency_list_runs` 命令(`agency/repository.rs` + `agency/commands.rs` + `handlers.rs`)**:`AgencyRepository::list_runs_for_story(story_id, limit)` 按 `created_at DESC` 列出某 story 的全部 run(利用已有 `idx_agency_runs_story` 索引);`agency_list_runs` Tauri 命令(limit=20)注册到 `handlers.rs`。前端可通过 IPC 发现已有 run,不依赖实时事件。 - **前端·activeRunId 水合(`AgencyStudio.tsx`)**:新增 `useQuery(['agency-runs', currentStory?.id], () => listRuns(currentStory.id))` 查询(10s 轮询);`useEffect` 在 `runs` 数据到达且 `!activeRunId` 时取 `runs[0].id`(最新 run)水合。实时事件仍可覆盖(新 run 启动时事件到达,切到新 run)。 - **前端·历史时间线重建(`AgencyStudio.tsx`)**:时间线从仅 live 事件改为三源合并--①Live 事件(activities + progress);②历史重建(board items 的 `created_at` + `producer` + `zone` + `key` + `summary` 生成时间线条目,如"管理 创建 资产:世界观 - 双星系统");③Run 生命周期(`created_at` 启动 + `updated_at` 终态)。合并后按 `(at, text)` 去重、时间倒序、截断 100 条。无需新表/迁移,从已持久化的 `agency_board_items` 重建。 - **前端·Run 选择器(`AgencyStudio.tsx`)**:标题栏右侧新增 `