diff --git a/.gitignore b/.gitignore index 4beb42a..165f54b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ experiments/ /QUICKSTART.docx # Storage safety files and backups +*.usage/ *.bak *.corrupt.*.json *.tmp diff --git a/AGENTS.md b/AGENTS.md index 26295c0..25e1873 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,8 @@ npm.cmd run agent:setup:codex ## 标准流程 +在本机调用模型 API 前读取用户目录的 `.config/ai/api-registry.json`。按服务条目的 Base URL 和 `keyEnv` 取用凭据,密钥值仅由发请求的进程读取。主密钥缺失或收到 401 时,可以使用同一条目的 `backupKeyEnv`。桌面版和 MCP 写作流程会按每个工作环节的实际地址选择密钥。 + 1. 用 `agent.listProjects` 定位项目,再用 `agent.getProjectDigest` 和 `agent.getNextChapterTarget` 读取当前权威状态。 2. 按章完成生成、审阅、决定和提交。提交后重新读取最新状态,再推进下一章;不能把旧 Prompt、旧草稿或本地镜像当作当前正史。 3. 用 `agent.getChapterProductionState`、`agent.getRunDiagnostics` 和 `agent.getAcceptanceRecommendation` 形成审稿判断。日常读取保持 compact;只有正文、Prompt、完整诊断或逐句证据确有需要时才传 `detail: "full"`。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 09f8076..8785b7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## 简体中文 +### 0.1.6-preview.20 - 长篇完稿核查与收尾 + +- 超过字数上限的可读草稿会保留;有明确证据的截断片段不能采纳。作者可对成稿核查禁词、角色动作,并决定哪些确证违规必须先修。 +- 工作台新增全书进度、缺章与跨章核对;角色卡可按章核对已确认余额,不用旧卡上的钱数覆盖新账。 +- 可按书查看模型用量、设置价目并在请求前预估费用。剧情导向支持 1–30 章,批量改名先预览、不改正文。详见[长篇修复说明](./docs/LONG_NOVEL_COMPLETION_REPAIR_PLAN.md)。 + +### 0.1.6-preview.19 - 重复计算与修订指令精简 + +- Novelty 审计在单次调用内复用词边界和原文偏移;Prompt 构建复用渲染结果,减少追踪与 token 估算中的重复处理,保持审计及正文 Prompt 输出一致。 +- 修订指令按所选任务加入,减少无关的衔接、压缩和润色要求;保留事实、局部范围、伏笔和去 AI 味约束。 +- 纳入当前已有的界面调整,精简 README 与 ROADMAP。性能方法和结果见 [重复计算与指令精简](./docs/REDUNDANCY_PERFORMANCE.md),本地打包状态以 `release/BUILD_INFO.json` 为准。 + ### 0.1.6-preview.18 - 界面更新打包 - 将本轮已有的 Ink Desk 界面改动纳入安装包和 `win-unpacked`:深色导航、雾蓝操作色、正文纸面,以及页面、弹窗、进度反馈的统一样式和动效。 @@ -201,6 +213,18 @@ ## English +### 0.1.6-preview.20 - Long-Manuscript Checks and Finishing Tools + +- Keep readable overlength drafts; explicitly truncated fragments cannot be accepted. Authors can check manuscripts against selected hard rules and block confirmed violations. +- See book progress, missing chapters and cross-chapter evidence. Character card checks use confirmed balances as of the selected chapter. +- Review per-book model usage, edit prices and preview request costs. Story directions cover 1–30 chapters; bulk title changes have a preview and leave the manuscript unchanged. + +### 0.1.6-preview.19 - Less Duplicate Work and Focused Revision Instructions + +- Reuse prepared text within a Novelty audit and rendered Prompt blocks during trace construction, preserving audit and writing-prompt outputs. +- Apply revision instructions by task type while retaining fact, local-scope, foreshadowing and less-AI-tone constraints. +- Include the current UI changes and simplify README/ROADMAP. See the [measurement report](./docs/REDUNDANCY_PERFORMANCE.md) and `release/BUILD_INFO.json` for local package verification. + ### 0.1.6-preview.18 - Packaged UI Refresh - Package the existing Ink Desk UI changes into the installer and `win-unpacked`: dark navigation, blue-grey controls, manuscript surfaces, and coordinated page, dialog and progress styling and motion. diff --git a/QUICKSTART.md b/QUICKSTART.md index 92fcb8a..db10e0e 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -46,6 +46,14 @@ > 只出现任务模板、没有真实正文,通常不是模型文风差,而是 API 没有真正调用成功。先看步骤错误和 Run Trace,再改 Prompt 或设定。 +### 查这本书用了多少,下一次大概花多少 + +打开 **工作台 → 全书模型用量**。可以按写作、审稿、修订等环节查看;未采纳的修订和实际发出的重试也在内。“未知”不是零。需要看钱,点 **编辑价目**,按你的服务地址、模型填每百万输入、输出和可选的缓存输入价格。人民币和美元分别显示,不自动换汇。这里按**当前价目**折算已知用量,不是服务商账单,也查不到你的账户余额。 + +桌面启动流水线或生成修订前可以看单次请求估算。还没组好的请求、后续审稿、重试和全书总额不会被算成已知;取消不发送这次请求。订阅 CLI 不按 API 价目折算。 + +**换电脑或移动书库:**关闭程序,将数据文件和旁边的同名 `.usage` 目录一起备份。例如,`novel-director-data.sqlite` 对应 `novel-director-data.sqlite.usage`。普通作品 JSON 导出不含这份逐次用量记录。工作台以外的调用无法补记,旧书只能补回原生成记录中确实留存的 token。 + --- ## 0. 先理解它的工作方式 @@ -507,7 +515,27 @@ HardCanon:不得临时新增无铺垫救命规则。 | 伏笔账本 | 伏笔标题、状态、权重、treatmentMode、回收方向 | 泛泛灵感池 | 按权重最多选择 10 条相关伏笔 | | 时间线 | 不可改变的事件顺序和因果锚点 | 普通剧情复述 | 本章相关时作为时间锚点进入 prompt | | 阶段摘要 | 远期旧章节剧情压缩 | 角色关系、伏笔、下一阶段规划 | 只作远期背景,低于硬状态和伏笔 | -| 剧情导向 | 未来 5/10 章软性方向 | 已发生事实、硬设定、正式回收结论 | 作为软指导进入 prompt,不覆盖硬事实 | +| 剧情导向 | 指定起止章的软性方向,例如第 91–96 章 | 已发生事实、硬设定、正式回收结论 | 作为软指导进入 prompt,不覆盖硬事实 | + +**想让成稿也受检查?** 打开“硬设定包”,编辑条目的“检查方式”。例如要控制“半步”的重复,选“字面用语与次数”,填入“半步”和你决定的本章或全书上限。要检查某个角色不能用右手端杯,选“角色动作”,填写角色、动作“端”、侧别“右”和物件“杯”。先用“仅提示作者复核”试读;需要拦住确证的违反,再选“确证违规时阻断采纳”。旧条目默认只作写作提示。 + +在“生产流水线”看命中原句;要核对已采纳的正文,打开“章节 → 版本历史 → 核对硬设定”。两处都按当前规则本地核对,不消耗模型额度,也不自动改正文。 + +### 看全书还缺哪一章 + +回到**工作台 → 全书进度与缺章**。点铅笔图标可填全书字数下限或上限,两项都可以留空。正文累计只算未归档的当前正文:忽略空白,含标点,一个扩展汉字也算一个字。修订或恢复旧稿会替换该章字数,不把版本相加。 + +展开清单,勾选**仅看问题**,或输入起止章号查看一卷。比如进度显示“从第 1 章连续写至第 65 章”,下一可分配章号却是 97,就先查看中间缺少哪些正文。未带提交记录的手写、导入旧章显示“历史正文”,照常计数。若第 92 章修订写回失败,旧正文仍计数,新修订不计;从该行回到对应稿件,重新核对后再提交。只有实际记录过的写回失败才会标红。 + +Agent 的只读工具 `agent.getBookProgress` 返回同一清单。连写任务核对前一个目标的采纳回执和正式版本后才继续;有意跳写时可以另开单章任务。查询进度不调用模型。 + +### 角色卡还是旧余额时怎么办 + +假设第 10 章的小葵只有十文,第 12 章又挣了二十文。先在**角色 → 动态状态账本**确认同一笔随身现金的余额、单位和来源章。仅有自然语言日志或待确认候选,不会让余额从十文变成三十文。重新构建第 13 章 Prompt 时,角色切片用三十文;重建第 11 章时,只用当时有依据的十文。船队的公款、欠款和不同币种不会自动并入她的钱袋。 + +在**角色 → 角色卡核对**选择一章,展开证据。卡上会分开显示最后变化章、最后人工核对章和最近已确认事实章。卡片没必要改,可以点“保留原卡,核对到第 N 章”;需要改文字时,选“编辑卡片后核对”。这些操作都不会替你采纳一条账本候选。旧 Prompt 快照仍是当时的原文,需要新余额时请重新构建。 + +打开**工作台 → 全书核对**,按章段和类别查钱款、人数、物件、人物下落、承诺、季节或倒计时。**冲突**表示证据能比、结果对不上;**待核**表示缺少起始余额、计时依据、归属或可定位原句;**未结**表示尚待交代的事项,不等于写错。点章号可回到正式正文。核对不收费,也不会自动改稿。 ### 一条信息到底应该放哪里 @@ -537,7 +565,7 @@ HardCanon:不得临时新增无铺垫救命规则。 | 近期伏笔 | 2-5 条 | 只录近期会暗示、推进或禁止揭示的线索;单个 prompt 最多调度 10 条 | | 时间线 | 只录不可逆节点 | 死亡、身份公开、关键物品易手、重大因果发生后再记 | | 阶段摘要 | 每 5-15 章按需压缩 | 只写旧剧情压缩、不可逆变化和结尾承接状态 | -| 剧情导向 | 未来 5 或 10 章 | 写方向和每章 beat,不把候选新设定写成 canon | +| 剧情导向 | 填起始章节和结束章节;末尾只剩一章也可以 | 起止均计入;第 91–96 章对应六个节拍,不把候选新设定写成 canon | 如果你发现自己在“设定”里写了几千字但还没写下一章任务,先停下来。对生成质量最直接的通常是上一章结尾、本章任务、当前角色硬状态和本章伏笔规则。 @@ -1112,6 +1140,8 @@ This quickstart helps a first-time author complete the practical Novel Director All examples are synthetic demo data. Do not publish real API keys, private manuscripts, local data files, screenshots, or exported real project JSON. +**Usage and finishing a book.** On the dashboard, expand **全书模型用量** (book usage) and edit the rate card to estimate costs. Only reported usage is counted; unaccepted revisions and actual retries count. Amounts use the current rate in its own currency, not the provider's invoice or balance. The preflight quote covers one prepared request, not the entire run. Back up the data file and its adjacent `.usage` directory together; ordinary JSON project exports omit the per-request ledger. For a six-chapter finale, enter 91 and 96 in Story Direction. In Chapters, preview official title changes before committing; title history is separate from prose revisions. + ### 1. Configure the AI Provider Open Settings and configure an OpenAI-compatible provider: @@ -1136,6 +1166,12 @@ Write only the facts that prevent continuity mistakes: - Foreshadowing: title, status, weight, treatment mode, payoff direction. - Timeline: irreversible event order and causal anchors. +HardCanon entries are writing hints unless you explicitly configure a check. In the HardCanon editor, choose a term limit or an actor/action condition, then choose advisory or blocking. The pipeline highlights matching passages; chapter version history can recheck older text against today's rules. Neither check calls a model or changes the manuscript. + +On the dashboard, expand **全书进度与缺章** (Book Progress). Set an optional word-count range, filter missing chapters or unsuccessful writebacks, and reopen the relevant manuscript or draft. Only current, non-archived manuscript text contributes to the total, including imported and handwritten chapters. The count excludes whitespace and includes punctuation, measured in Unicode code points. A failed revision does not erase the previous manuscript. The read-only `agent.getBookProgress` tool exposes the same report without a model call. + +For a stale balance, confirm the dated change to the same personal account and unit in the character ledger. A pending suggestion is not a balance. Rebuilding a later writing prompt uses the confirmed balance; reconstructing an earlier chapter uses only evidence available by then. Use **角色卡核对** on the character page to inspect the evidence and keep or edit the card. Saved prompt snapshots remain unchanged. **全书核对** on the dashboard compares chapter evidence, distinguishing contradictions from missing evidence and open threads. Neither reading a report nor reviewing a card accepts a pending change. + Avoid huge encyclopedias, unused side-character biographies, far-future reveals, vague style slogans, and `TBD` placeholders. ### 3. Chapter Task diff --git a/README.md b/README.md index b109aaf..5af567a 100644 --- a/README.md +++ b/README.md @@ -4,33 +4,11 @@ Novel Director 是一个实验性的、本地优先的 AI 长篇小说工作台。它是面向长篇创作的“小说导演台”:管理项目圣经、章节连续性、角色状态账本、伏笔调度、Prompt 上下文、生成追踪、质量门禁和修订流程。 -当前仓库是早期 `0.1.6-preview.18` 本地试用候选,适合本地试用和开发验证,不表示已发布 GitHub Release。数据格式和交互体验仍在演进中,请在真实稿件上使用前做好备份。 +当前版本为 `0.1.6-preview.20`,仍在试用阶段。使用真实稿件前请备份。源码更新不代表安装包已更新,包的版本和验证记录以 `release/BUILD_INFO.json` 为准。 -Preview.18 将当前 Ink Desk 界面更新纳入安装包与 `win-unpacked`,统一导航、页面、正文区域和操作反馈。发行路径、验证和旧包清理范围见 [发行产物说明](./docs/RELEASE_ARTIFACTS.md);不迁移或删除作者的小说数据。 +第一次使用请看 [新手教程](./docs/BEGINNER_TUTORIAL.md) 或 [快速开始](./QUICKSTART.md)。Codex 接入和项目权限见 [Agent 授权指南](./docs/AGENT_PROJECT_AUTHORIZATION.md)。 -Preview.17 针对后端实测热点减少每日备份检查的全库读取、旧任务恢复和 Novelty 审计的重复计算,修补 SQLite 首次打开及迁移失败路径,并保留现有事务、JSON fallback 和生成行为。范围、前后实测与复现命令见 [后端性能复核](./docs/BACKEND_PERFORMANCE_REVIEW.md)。 - -Preview.16 修复角色之间的新建状态输入串用,以及日志/状态保存回执清掉新输入的问题。角色切换保留各自页内草稿,保存中防重复点击,失败可继续编辑和重试;不自动入账或接受候选。见 [角色编辑反馈验收](./docs/UI_HIG_CHARACTER_DRAFTS.md)。 - -Preview.15 补齐官方 DeepSeek V4.1 Flash 的 `deepseek-flash` 名称兼容,保持与旧 Flash 名称一致的非思考请求参数,不改作者保存的模型和第三方端点。真实模型对照本轮按作者要求跳过,未产生费用;见 [检查与修复记录](./docs/DEEPSEEK_FLASH_COMPATIBILITY.md)。 - -Preview.14 将角色页整理为角色资料、动态账本和状态日志,加入角色搜索、键盘页签和按需展开的新建状态表单。账本先呈现已生效状态,不改变候选确认和保存逻辑。见 [角色工作区验收](./docs/UI_HIG_CHARACTER_WORKSPACE.md)。 - -Preview.13 延续 HIG 工作区优化:项目列表优先展示,Prompt 分为任务、编辑、上下文和历史,保留手动编辑及准确的快照来源;修订保存不覆盖新输入。页面变化、验证范围与实际包状态见 [作者工作区验收](./docs/UI_HIG_AUTHOR_WORKSPACE.md)。 - -Preview.12 统一明暗主题、按钮和文字层级,顶部可收起侧栏,窄窗口保留章节列表与正文并排,修复深色角色页的可读性。借鉴 Apple HIG,保留 Windows 使用习惯;详见 [界面验收记录](./docs/UI_HIG_WORKSPACE.md)。 - -Preview.11 修复采纳草稿时因默认字段差异误报生成记录不可变的问题;无需重新生成正文。修复边界与验证见 [采纳误冲突记录](./docs/FIX_DRAFT_ACCEPTANCE_TRACE.md)。 - -Agent 四个项目概览工具已改为定向读取,500 章摘要中位耗时约从 397ms 降到 32ms,信息不减少。见 [Agent 概览实测](./docs/OPTIMIZATION_AGENT_OVERVIEW_READ.md)。需重新连接常驻的仓库 MCP 服务;本批不改变桌面全量刷新,也不宣称减少模型 token。 - -本批针对实测瓶颈改善长篇保存:编辑一章不再重写全部历史,生成步骤校验不再加载整本小说。新增 10/100/500 章真实存储与 Electron 基准、正式采纳链成本归集;全量读取和真实模型质量/费用仍有待改进或对照,不夸大收益。数据与复现命令见 [W09/W10 实测记录](./docs/OPTIMIZATION_W09_W10_MEASUREMENT.md)。 - -本批补齐 Agent 的任务、世界资料编辑和可撤回项目授权。作者在 Agent 页授予范围,Codex 可在范围内逐章生成、审阅、决定和提交,作者通过“读取最新结果”接手同一份数据。授权不随小说备份导入。操作见 [项目授权指南](./docs/AGENT_PROJECT_AUTHORIZATION.md);是否已打包以同版本的 `release/BUILD_INFO.json` 为准。 - -此前正文优先布局、配置与报告折叠见 [W07 试用记录](./docs/OPTIMIZATION_W07_TRIAL.md),九字段编辑及上下文复用见 [任务编辑试用记录](./docs/OPTIMIZATION_W06_TRIAL.md)。 - -此前已支持候选处理历史、部分选择、伏笔/时间线筛选,以及可预览、保留历史的撤销;Agent 共享同一事务命令。操作、边界和验收见 [第五批 W03 试用记录](./docs/OPTIMIZATION_W03_TRIAL.md)。跨页重写暂存、调用状态和 Agent 修订见 [第四批试用记录](./docs/OPTIMIZATION_W04_W05_W08_TRIAL.md)。 +版本变化见 [CHANGELOG](./CHANGELOG.md),安装包位置见 [发行产物说明](./docs/RELEASE_ARTIFACTS.md),后续工作见 [路线图](./ROADMAP.md)。2026-10-31 的重复计算与修订指令精简见 [性能记录](./docs/REDUNDANCY_PERFORMANCE.md)。 ### 项目状态 @@ -80,14 +58,6 @@ npm.cmd test npm.cmd run build ``` -Runtime performance and Chinese Novelty Audit baselines: - -```bash -npm.cmd run perf:novelty -npm.cmd run native:electron -npm.cmd run perf:ui -npm.cmd run native:node -``` 真实性能与中文 Novelty Audit 基线: @@ -107,7 +77,7 @@ npm.cmd run dist:win 生成产物位于: -- `release/Novel Director Setup 0.1.6-preview.18.exe` +- `release/Novel Director Setup 0.1.6-preview.20.exe` - `release/win-unpacked/Novel Director.exe` `release/` 始终只保留当前可分发版本;历史安装包会集中归档到 `releases/archive/`。每次成功打包还会生成 `BUILD_INFO.json` 和 `SHA256SUMS.txt`,用于确认版本、构建时间与安装包校验值。详见 [发行产物说明](docs/RELEASE_ARTIFACTS.md)。 @@ -164,6 +134,12 @@ npm.cmd run smoke:packaged CLI 模式由 Electron 主进程在空临时目录中调用 `codex exec`,使用只读沙箱和一次性会话。工作台不读取或保存 Codex 登录令牌,但发送给模型的章节上下文仍会交给 OpenAI;可用计划、额度和速率限制以当前 ChatGPT/Codex 账号为准。详见 [Codex CLI Provider](./docs/CODEX_CLI_PROVIDER.md)。 +桌面版和外部写作流程都可以使用个人 API 清单 `~/.config/ai/api-registry.json`。在“AI 模型接入”或各工作环节中填写清单里的 Base URL 和 Model Name。每次请求只取该地址对应的环境变量,因此起草和审稿可以使用不同服务。改动清单对下一次桌面请求或下一次外部任务生效。新增系统环境变量后,重新启动相关客户端。 + +清单的 `version` 为 `1`,`apis` 下每个服务指定 `baseUrl` 和 `keyEnv`。例如 DeepSeek 使用 `https://api.deepseek.com` 与 `DEEPSEEK_API_KEY`。同一来源的兼容路径可列在 `aliases`。可选的 `desktopSavedKeyFor` 指定哪家服务能沿用旧桌面版留下的无地址密钥,外部流程仍使用对应环境变量。清单不存密钥值。 + +同一服务可以用 `backupKeyEnv` 指定备用密钥。主密钥未设置时直接使用备用密钥。主密钥收到 HTTP 401 时,工作台在原请求时限内用同一服务的备用密钥尝试一次。限流和额度错误不会触发密钥切换。 + API Key 不应持久化到主 AppData JSON。桌面应用通过 Electron 安全存储保存密钥,renderer 进程只应知道“是否已保存密钥”,不应读取完整明文 key。 启用远程 AI 后,被选中的项目上下文和 prompt 内容会发送给你配置的供应商。请在使用真实稿件前阅读供应商的条款和隐私政策。 @@ -235,11 +211,9 @@ Novel Director 使用 [MIT License](./LICENSE) 发布。 Novel Director is an experimental, local-first desktop workbench for managing AI-assisted long-form fiction projects. It is designed for authors who need more than a text editor: project bibles, chapter continuity, character state, foreshadowing schedules, prompt context control, generation traces, quality gates, and revision workflows. -This repository is an early `0.1.6-preview.18` local trial candidate, not a claim of a published GitHub release. Preview.18 packages the current Ink Desk UI updates for navigation, pages, manuscript surfaces and interaction feedback; see [release artifacts](./docs/RELEASE_ARTIFACTS.md) for paths, verification and cleanup scope. Author data is not migrated or deleted. Preview.17 reduces measured backend overhead in backup checks, legacy normalization and Novelty matching, and fixes SQLite initialization failure paths without changing generation or persistence contracts; see the [backend review](./docs/BACKEND_PERFORMANCE_REVIEW.md). Preview.16 isolates page-local character drafts and protects newer input from late save receipts; see the [editing record](./docs/UI_HIG_CHARACTER_DRAFTS.md). Preview.15 keeps the official `deepseek-flash` name and legacy aliases on the same non-thinking request path; see the [compatibility record](./docs/DEEPSEEK_FLASH_COMPATIBILITY.md). No paid model comparison was run this round. Preview.14 adds searchable characters and separate profile, ledger and log tabs; see the [character workspace checks](./docs/UI_HIG_CHARACTER_WORKSPACE.md). It builds on the project-first home, tabbed Prompt editor and safer editing feedback from [Preview.13](./docs/UI_HIG_AUTHOR_WORKSPACE.md), the shared [HIG workspaces](./docs/UI_HIG_WORKSPACE.md), and the [draft-acceptance fix](./docs/FIX_DRAFT_ACCEPTANCE_TRACE.md). - -Four compact Agent entry tools use scoped reads; the measured 500-chapter digest median falls from 397ms to 32ms with identical output. Reconnect an existing repository MCP process to load this change. Desktop refresh and full-detail reads are unchanged. See the [Agent overview measurement](./docs/OPTIMIZATION_AGENT_OVERVIEW_READ.md) and prior [storage/UI measurements](./docs/OPTIMIZATION_W09_W10_MEASUREMENT.md). Synthetic tests do not prove literary quality or provider-cost savings. Agent task/world editing and revocable project grants remain available; see the [authorization guide](./docs/AGENT_PROJECT_AUTHORIZATION.md). Packaging status is recorded separately in the matching `release/BUILD_INFO.json`. +The current version is `0.1.6-preview.20`, an early trial. Back up real manuscripts before use. Source updates do not automatically update installed binaries; check `release/BUILD_INFO.json` for the packaged version. -The [W07 trial record](./docs/OPTIMIZATION_W07_TRIAL.md) covers prose-first workspaces; the [task-editing trial record](./docs/OPTIMIZATION_W06_TRIAL.md) covers scoped context reuse. Candidate history and transaction-backed undo remain in the [fifth trial record](./docs/OPTIMIZATION_W03_TRIAL.md), and quick-rewrite candidates and Agent revision in the [fourth trial record](./docs/OPTIMIZATION_W04_W05_W08_TRIAL.md). +Start with the [beginner tutorial](./docs/BEGINNER_TUTORIAL.md) or [quickstart](./QUICKSTART.md). Agent setup and project grants are in the [authorization guide](./docs/AGENT_PROJECT_AUTHORIZATION.md). See [CHANGELOG](./CHANGELOG.md), [release artifacts](./docs/RELEASE_ARTIFACTS.md) and [ROADMAP](./ROADMAP.md) for changes, binaries and next steps. The [2026-10-31 performance record](./docs/REDUNDANCY_PERFORMANCE.md) covers redundant computation and revision instructions. ### Status @@ -297,7 +271,7 @@ npm.cmd run dist:win Generated artifacts are written to: -- `release/Novel Director Setup 0.1.6-preview.18.exe` +- `release/Novel Director Setup 0.1.6-preview.20.exe` - `release/win-unpacked/Novel Director.exe` `release/` contains only the current distributable build. Historical artifacts are kept under `releases/archive/`. Successful packaging also writes `BUILD_INFO.json` and `SHA256SUMS.txt` for version and integrity checks. See [Release Artifacts](docs/RELEASE_ARTIFACTS.md). diff --git a/ROADMAP.md b/ROADMAP.md index 905aff5..3e8155b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,589 +1,63 @@ # 路线图 / Roadmap -计划复核日期:2026-09-28。最近验证本地包:`0.1.6-preview.17`。新增后端维护批次已完成:每日备份检查不再先读全库,SQLite 初始化与失败迁移可重试,旧任务恢复和 Novelty 审计去除重复计算,进度/日志/并发限流完成小修。149/149 验证脚本、类型检查、生产构建、Windows 打包及包内 SQLite smoke 通过,见 [后端性能复核](./docs/BACKEND_PERFORMANCE_REVIEW.md)。500 章主进程步骤保存 p50 为 599ms -> 1.44ms,完整保存为 1076ms -> 253ms;不把后端耗时当作 UI 或模型生成时间。下一步只按实际慢入口补定向读取与同步诊断测量,不恢复全库 CRUD/线程池等大改。 - -上一批收尾(2026-09-22,Preview.16):四项收敛主线的交付范围、证据和保留边界见 [优化验收收尾](./docs/OPTIMIZATION_CLOSEOUT.md)。角色页跨角色输入与迟到保存回执误清草稿已修复,当时 144/144 验证脚本、类型检查、构建、包内 SQLite smoke 通过;实际 EXE 七页、双主题与窄窗口验收共 40 张截图。本轮后端维护没有重做该 UI 视觉验收。作者指定的 DeepSeek V4.1 Flash、上限 5 元对照,因隔离执行器无凭据按要求跳过,费用 0 元,不宣称文学质量或模型费用收益。模型名称兼容修复见 [检查记录](./docs/DEEPSEEK_FLASH_COMPATIBILITY.md)。 - -最新性能证据:Preview.14 的 10/100/500 章真实 EXE 操作全部通过,500 章冷启动/打开项目/编辑至已保存的 p50 为 1.54/1.58/1.61 秒。见 [长篇界面复测](./docs/PREVIEW14_LONG_NOVEL_PERFORMANCE.md);不把历史数值差异当成本轮 UI 改造的因果收益,也不恢复已暂缓的全库重构。 - -完整工作包、代码边界、任务影响表和验收要求见 [全盘优化施工计划](./docs/PRODUCT_OPTIMIZATION_MASTER_PLAN.md)。该文档第三节只保留四项优先事项,第六节是当前收尾施工卡;W01-W10 仅作范围索引,不再把全部工作包列为必做,也不并列使用旧计划的 P0-P7、批次 A-F 等顺序。 +更新日期:2026-11-11。这里只保留当前方向和下一步,不再重复各版本的施工日志、源码行数和已完成待办。历史变更见 [CHANGELOG](./CHANGELOG.md),包的实际状态见 [发行产物说明](./docs/RELEASE_ARTIFACTS.md)。 ## 当前方向 -让作者和 Codex 顺畅完成“任务 -> 生成 -> 阅读 -> 修改 -> 采纳 -> 下一章”。保留已有 Prompt、事务保存、版本链和 Agent Runtime,以减少返工和重复操作为目标,不以增加门禁、页面或减少源码行数为目标。 - -| 优先顺序 | 范围 | 必须完成的结果 | -| --- | --- | --- | -| 1. 任务编辑与保存 | W06 + W01 接线 | 已完成并交付 Preview.6:九字段编辑、按影响更新上下文、通用任务校验与专用事务验证 | -| 2. 正文优先界面 | W07,回归 W02/W04 | 已完成并交付 Preview.7:三页正文优先,配置/报告折叠,双主题与窄窗口、阅读位置实测 | -| 3. Agent 创作能力 | W08,按需补 W03 | 已完成并交付 Preview.8:任务和六类世界资料管理、可撤回项目授权、逐章审阅提交、主动读取同一数据 | -| 4. 质量、费用与长篇实测 | W05/W10 + W09 | 长篇保存及 Agent 概览读取已局部优化,成本归集已验证。本轮付费对照按授权跳过,费用 0 元;只补有证据的兼容问题,不扩大为全库读取重构 | - -本轮按上述收敛范围交付试用包;后续按实际试用反馈另开具体问题,不继续扩大待办。付费质量对照按作者许可跳过,不阻塞收尾。候选历史、采纳、版本恢复等已完成能力以回归为主,不重复开发。 - -### 降级或暂缓 - -- 自动时间线提取、全部旧候选入口统一:不再独立排主线,只补阻塞当前采纳或 Agent 操作的入口。 -- 全站视觉改版、首用向导重做、资料/设置页美化:先让三个高频页面好用。 -- 新多模型路由、默认多轮会审、全面扩充 Novelty/评分规则:实测或可复现问题证明有必要再做。 -- 流式传输重写、全文/向量检索、图谱、全库 CRUD、项目级 store、为行数或 chunk 拆模块:不纳入本轮。 -- 复杂权限平台、插件平台、云同步、新看板:不是 Agent 项目授权的前置条件。 - -事务保存、版本链、JSON 导入导出、旧数据兼容、密钥保护和发布检查不降级。新工作必须能说明解决哪个实际痛点;没有证据的不自动加入施工范围。 - -候选决定历史、部分选择和补偿撤销已在第五批本地试用中交付;具体范围见 [W03 试用记录](./docs/OPTIMIZATION_W03_TRIAL.md)。旧直接保存入口、项目授权、任务编辑和真实模型效果对照不能被该批验收替代。 - -### 当前施工状态 - -最新界面收尾:Preview.16 已同步安装包和 unpacked;角色页按项目和角色保留页内事实、日志、转换草稿,保存回执只清理本次实际提交且未再修改的输入。12 项行为回归、实际包 40 张截图通过,主题通过真实设置页保存后验证,见 [角色输入验收](./docs/UI_HIG_CHARACTER_DRAFTS.md)。Preview.14 的角色搜索与页签见 [角色工作区验收](./docs/UI_HIG_CHARACTER_WORKSPACE.md);Preview.13 的项目入口、Prompt 页签等见 [作者工作区验收](./docs/UI_HIG_AUTHOR_WORKSPACE.md)。下面的旧版本数字只代表各批当时的结果,不是当前缺项。 - -后续 Agent 概览优化:四个项目入口仅读取所需集合,500 章项目摘要 p50 从 397ms 降到 32ms,输出逐轮一致。完整读取和界面刷新未改,不宣称同步变快;135/135 脚本、类型检查、生产构建、Preview.10 打包与 SQLite smoke 通过。原始数据、旧记录兼容和 MCP 重连说明见 [Agent 概览实测](./docs/OPTIMIZATION_AGENT_OVERVIEW_READ.md)。 - -第四项新进展:500 章样本的步骤保存 p50 从 393ms 降到 1ms,编辑保存从 826ms 降到 309ms,保留完整 API、事务与历史。匹配驱动的实际 Preview.9 包内对照:编辑到已保存从 2.10s 降到 1.56s,返回项目从 1.57s 降到 1.01s;启动和 Agent 刷新未明显改善,全量读取仍约 400ms。成本归集沿正式版本链去重,缺失遥测明确标记未知。134/134 脚本、类型检查、构建、打包与 SQLite smoke 通过。详见 [W09/W10 实测记录](./docs/OPTIMIZATION_W09_W10_MEASUREMENT.md)。 - -第一项的代码和生产 Electron 操作验收已完成。作者可以直接修改本章九项任务,保存为新运行而不调用模型;表达/篇幅小改按条件复用计划和事实切片,情节变化重新准备上下文。旧草稿、报告和手动快照保留;保存队列校验来源,避免新上下文基于已变化的资料落盘。具体边界见 [W06 试用记录](./docs/OPTIMIZATION_W06_TRIAL.md)。 - -第二项三页正文优先布局已交付 Preview.7。新布局、目录坐标和阅读位置的生产/包内验收范围见 [W07 试用记录](./docs/OPTIMIZATION_W07_TRIAL.md)。 - -2026-09-07 新结果:第三项已交付 Preview.8,类型检查、131/131 验证脚本、生产构建、Windows 打包和 SQLite smoke 通过。生产 Electron 与实际 unpacked 授权页各通过 60 项检查、22 张截图;五章顺序旅程含暂停恢复和作者中途更新,使用 21 次本地模拟响应。重复提交、撤回和 HardCanon 来源去重已回归。见 [W08 试用记录](./docs/OPTIMIZATION_W08_TRIAL.md)。下一项仅测最终采纳质量/成本与 10/100/500 章性能;不把 mock 创作当成真实文学质量结论。 - -## English Current Direction - -Current verified local package: Preview.17 (2026-09-28), with measured backend optimization, safer backup publication and SQLite initialization, all 149 scripts, typecheck, build, Windows packaging and packaged SQLite smoke passing. See the [backend review](./docs/BACKEND_PERFORMANCE_REVIEW.md). Targeted reads and synchronous diagnostic hotspots remain follow-up work, not a broad CRUD or worker-pool rewrite. - -The previous Preview.16 UI batch (2026-09-22) passed 144 scripts and actual executable QA across seven routes, both persisted themes and narrow windows in 40 screenshots; see the [character draft record](./docs/UI_HIG_CHARACTER_DRAFTS.md). Those screenshots were not rerun for this backend-only round. The narrowed round is delivered within the limits in the [closeout matrix](./docs/OPTIMIZATION_CLOSEOUT.md). The author specified DeepSeek V4.1 Flash and a CNY 5 cap; paid comparison was skipped as permitted because the isolated runner has no credential. Historical milestones below remain evidence, not unfinished tasks. - -The fourth priority now has real 10/100/500-chapter storage/UI measurements, differential snapshot writes, job-scoped checkpoint validation and accepted-chapter accounting. A follow-up scopes four Agent overview reads: 500-chapter digest median 397ms -> 32ms, with identical responses and 135/135 scripts passing. Full-detail reads and desktop refresh remain unchanged. See the [overview measurement](./docs/OPTIMIZATION_AGENT_OVERVIEW_READ.md); Preview.9's actual packaged long-novel workflows remain in the [earlier record](./docs/OPTIMIZATION_W09_W10_MEASUREMENT.md). No paid quality/cost conclusion is claimed this round. - -See the master plan for four priorities: task editing and bundle-save integration; prose-first pipeline, revision and reading UI; Agent world-management and project grants; measured quality/cost and long-novel performance. These are delivered within the closeout scope, with paid comparison explicitly skipped rather than claimed. Preview.8 authorization QA and its five-chapter local-mock journey remain earlier evidence; subsequent releases add measured persistence improvements. Broader full-read work remains deferred, not a new completion requirement. - -Automatic timeline extraction, exhaustive legacy cleanup, site-wide redesign, new routing, streaming, search/graphs and broad storage refactors are deferred, not hidden release requirements. Fix demonstrated blockers, reuse completed features, and retain persistence, version-history, credential and compatibility checks. - -Reuse completed capabilities. Creative disagreements remain director decisions; failed persistence and stale writes remain technical errors. The linked W06 record distinguishes source, production-preview and packaged evidence; historical counts below are not current results. - -## 历史实施与检测记录 - -以下保留既有批次和检测原文,便于核对当时的范围。旧记录中的“下一批”“尚未实现”和数字仅适用于其标注日期;当前待办以本页上方和全盘计划为准,不按历史顺序重复施工。 - -
-展开历史施工与验证记录(不作为当前待办) - -### 首批施工进展(2026-09-06) - -- 共享候选预览与真正入账目标/结果保持一致,来源不明的旧报告不会冒充当前通过结果。 -- 修订页候选正文和接受按钮前置,首次修订前正文可在版本历史中恢复。 -- 直接 AI 重写新增“取消本次”,按章节/修订对象隔离迟到结果;真实 Electron 的本地请求取消与再次重写已通过。 -- 设置页新增可折叠运行信息,区分程序版本、构建时间和运行位置。 -- 下一批优先补显式未审稿采纳、候选编辑后接受、阅读位置恢复;完整 M1-M3 与 W01-W10 范围不变。 - -English: The first integration batch adds accurate candidate previews, candidate-first revision layout, restorable pre-revision snapshots, per-call rewrite cancellation and runtime identity. The full roadmap remains active; see the linked trial record for current verification and artifact status. A local trial is not a published GitHub release. - -### 第二批施工进展(2026-09-06) +让作者和 Codex 顺畅完成“任务 -> 生成 -> 阅读 -> 修改 -> 采纳 -> 下一章”。优先解决实测慢入口、重复操作和错误指令,不为缩小文件或增加保护层而重构。 -- 未完成审稿时可由作者明确采纳,提交和历史显示真实状态;失败审稿步骤不再挡住已采纳章节的下一章入口。 -- 收件箱支持类型化编辑后接受,取消不落盘,保留原建议与实际采用值。修复余额重复扣减、空数字归零等问题。 -- Agent 对应预览/接受共用现有事务命令;可执行修改不因 compact 截断,敏感返回脱敏并标记。高风险项目授权仍未完成。 -- 首份当前版本正确计入已保存历史,紧凑窗口的版本标题与状态标签不再互相挤占。 -- `0.1.6-preview.2` 已本地打包;108/108 验证脚本、127/127 采纳检查和 22 项 Agent 候选检查通过。生产 Electron 与实际 unpacked 各通过 16 项旅程检查,使用隔离数据。 -- 下一批进入 W04 阅读位置返回、旧要求重新定位、快捷改写结果保留;专项候选、授权、全站界面和长篇性能工作继续推进。 +## 已有基础 -English: Preview.2 is locally packaged and verified, adding explicit unreviewed acceptance, edited candidate decisions and reliable continuation after acceptance. Full scope remains active. See the second trial record for exact evidence; installer lifecycle, signing and GitHub publication are not claimed. +- 本地 SQLite、JSON 导入导出、备份和三类事务提交已接入。章节版本可查看、比较和恢复。 +- Context Need Planner、预算选择、角色硬状态、伏笔操作规则和 HardCanon 共用最终 Prompt 与 Run Trace。 +- 桌面和 Agent 共用生成、审稿、修订、候选决定及正式提交流程;项目权限可以撤回。 +- 章节阅读、局部重写、任务编辑、候选历史和作者诊断摘要已有入口。 ---- +2026-10-31:复用单次 Novelty 审计的分词结果,去掉 Prompt 追踪的重复渲染,简化 token 扫描;修订指令按类型输出。范围、实测和限制见 [本轮性能记录](./docs/REDUNDANCY_PERFORMANCE.md)。已纳入本地 Preview.19 安装包和 `release/win-unpacked`,尚未上传 GitHub。 -## 简体中文 +## 下一步优先事项 -### 历史检测基线(2026-09-05) +按[长篇完稿后的修复计划](./docs/LONG_NOVEL_COMPLETION_REPAIR_PLAN.md),源码已支持保留超限稿、成稿硬规则核查、全书进度与连写顺序、按章取余额和跨章事实核对。P2 增加按书统计模型用量、调用前估算、连接诊断、按起止章编排导向,以及可预览和追溯的批量改名。尚不能从用量反推供应商余额或旧书缺失的账单。下表保留原有维护事项;安装包要单独更新。 -本轮重新检查了: - -- `package.json` 与 `scripts/run-tests.mjs` -- 核心存储、IPC、preload、AI 调用、安全与发布脚本 -- 生成流水线、PromptBuilder、Context Need Planner、ContextBudgetManager、NoveltyDetector、QualityGate -- 公开文档与最大源码文件体量 - -当时记录的验证基线(不是本次规划重跑结果): - -- `npm.cmd run typecheck`:通过 -- `npm.cmd test`:通过,当前 72 个验证脚本 -- `npm.cmd run build`:通过 -- `validate-no-mojibake.mjs`:通过 -- `validate-release-p0-readiness.mjs`:通过 -- `validate-electron-security-p0.mjs`:通过 -- `validate-sqlite-storage.mjs`:通过 -- `validate-novelty-guardrails.mjs`:通过 - -当前较大的源码文件约为: - -- `src/renderer/src/views/ChaptersView.tsx`:约 453 行(正文状态机与 AI 协调已拆分) -- `src/renderer/src/views/revision/revisionStudioActionHandlers.ts`:约 438 行 -- `src/renderer/src/views/PromptBuilderView.tsx`:约 418 行 -- `src/renderer/src/views/generation/pipelineSteps/chapterGeneration.ts`:约 383 行 -- `src/services/PromptBuilderService.ts`:约 381 行(格式化职责已拆分) -- `src/renderer/src/views/CharactersView.tsx`:约 373 行 - -结构性进展: - -- `src/shared/types.ts` 已拆成 `src/shared/types/*`,原文件保留兼容导出。 -- `src/shared/defaults.ts` 已拆成 `src/shared/defaults/index.ts` 与 `src/shared/normalizers/*`,原文件保留兼容导出。 -- 上下文规范化已进一步拆成预算、需求计划、基础枚举兼容和选择/trace 四个纯模块;原 `normalizers/context.ts` 从约 459 行降为 6 行兼容门面,并由模块化行数门禁保护。 -- `PromptBuilderService.ts` 已拆出 `src/services/promptFormatters/*`。 -- `ContextBudgetManager.ts` 已拆出评分、选择、预算收尾和 trace 模块;主协调器约 257 行,结构化省略与预算裁剪集中在 `selectionFinalizer.ts`。 -- 生成流水线执行器已拆成 `pipelineRunnerEngine.ts`、`pipelineRunnerTypes.ts` 和 `pipelineSteps/*`,`usePipelineRunnerCore.ts` 已降到可维护体量。 -- 生产流水线的执行引擎、修订动作、生成记录聚合、草稿提交、记忆候选动作和作者诊断摘要已改为按需加载;`GenerationPipelineView` 生产 chunk 从 179.61 kB 降至 82.98 kB,减少约 53.8%,且生成、重试、修订与事务保存仍由完整回归测试覆盖。 -- 章节创作台的 AI 草稿、复盘、版本历史、版本恢复和角色/伏笔候选写入已按使用时机拆分;正文缓冲保存与 AI 草稿协调也已移入独立 Hook。加入外部正文冲突状态机后,`ChaptersView` 生产 chunk 为 50.03 kB,仍比最初 83.35 kB 低约 40%。 -- 连贯阅读页已从约 474 行降至约 290 行;滚动定位、章节横栏、正文分段偏移、AI 重写菜单分别由独立 Hook/组件/纯工具维护。初始章节只定位一次,正文更新不再把读者拉回入口章节;横栏显示真实章序,未保存的行内编辑离开前会确认,直接 AI 重写支持一次安全撤销。 -- 角色页已从约 469 行降至约 395 行,状态日志与转换表单由独立面板维护。切换角色会清理上一角色的日志草稿并恢复“仅记录日志”安全默认;状态事实输入使用去除显示单位的缓冲值,避免逐键完整保存及资源数值被误解析为 `0`。 -- 章节 AI 操作现在严格使用当前 `bodyDraft`;作者主动清空正文后立即复盘或提取时,不会再因 truthy fallback 误用保存前的旧正文。 -- 当前章节被流水线采纳、历史恢复或其他入口更新时,编辑器会区分干净同步、保存确认和真实分叉;发生分叉会暂停自动保存并让作者选择最新正文或明确覆盖。切换章节后的迟到 AI 响应也不会再写入新章节面板。 -- 修订工作台已拆成约 284 行协调器、来源侧栏和版本对比面板;动作层进一步分成共享类型、AI 生成动作与正式版本动作。生产构建中主 chunk 为 35.52 kB,生成/版本动作分别为 9.51/10.39 kB;生成候选不再加载 `RevisionCommitBundleService`,复制、拒绝和正式接受也不再加载 AI 生成实现。编辑正文仍会在接受、切换版本或切换来源前先落盘。 -- 数据合并核心已拆出集合清单、实体判等/冲突工具、引用重映射和数据文件备份层;`DataMergeService.ts` 从 655 行降至 346 行。合并写入后会返回同一次读取的完整 data/revision 快照,避免并发写入窗口产生不一致快照。 -- SQLite 后端已拆出 schema、实体索引映射和三类 bundle entry 规划模块;`SqliteStorageService.ts` 从 675 行降至 390 行。bundle 的 revision 校验、当前数据读取、引用校验与 upsert 现在位于同一个短事务内,且成功加载后的 `better-sqlite3` 构造器会复用,减少重复 native 探测。 -- Agent 可读摘要已拆成 160 行稳定门面,以及项目、章节诊断、版本链和长文本读取模块;原服务从 651 行降至 160 行。Agent 现在复用 UI 的正式版本链语义,可显式读取归档章节,并按 project/job/chapter 严格限定诊断报告,避免跨项目结果污染。 -- `PromptBuilderView.tsx` 已从约 566 行降至 446 行。上下文需求、衔接、角色选择和历史动作已拆为独立组件/Hook;载入上下文快照会恢复任务书、模式、模块、预算、选择集和衔接设置,需求计划移除角色或禁止伏笔时也会同步显式选择,避免被预算选择器重新强制纳入。 -- `ContextNeedPlannerService.ts` 已把角色点名窗口分析和角色需求切片拆到两个纯模块,主服务从约 424 行降至 343 行。需求计划现在区分“仅被提及 / 预计在场 / 必须行动”和“是否必须核对状态”;Story Direction 或伏笔弱关联产生的 uncertain 角色只参与评分,不再被预算器强制纳入。 -- `SettingsView.tsx` 已从约 549 行降至 73 行协调器。凭据、数据路径/导入导出、备份/日志分别由独立控制器管理;高风险操作有 busy 防重复、可读错误反馈和保存失败边界,API Key 安全存储成功但元数据同步失败时不会再伪报完整成功。 -- `AIResponseNormalizer.ts` 已从约 517 行降为 4 行兼容门面,章节/伏笔、角色状态、质量/修订和基础值规范化拆成 4 个纯模块,最大领域实现约 194 行;现有调用 import 保持不变。 -- NoveltyDetector 已拆出策略、命名实体、关键词、逐次扫描、语义关联和 finding 构建模块,主编排器降至约 232 行;任务书许可、已有上下文/HardCanon、同义规则、来源媒介、章节模式、数量上限、关联代价和危机解除方式共同决定风险等级。 -- `CharacterStateService.ts` 已降为约 68 行兼容门面,日志推断、状态选择、规则校验和值/事务变更分开维护。候选写入会校验 project/character 归属,多条物品事实会聚合,知识泄露判断不再把“已知事实”反向误报。 -- `QualityGateService.ts` 已降至约 143 行编排器。结构化角色状态、本地连续性/伏笔/长度/冗余硬规则现在无论 AI 报告是否存在都会执行,并复用流水线同一份 NoveltyAuditResult,避免门禁与 Run Trace 结论分叉。 -- `AgentToolService.ts` 已降为约 50 行稳定门面,工具定义、严格参数校验、只读查询和事务写入分别维护。Agent 显式请求 `full` 时会获得脱敏后的完整正文/Prompt,只有显式 `maxChars` 才截断;默认读取仍保持摘要模式。 -- `RunTraceAuthorSummaryService.ts` 已拆出来源查找、通用诊断和上下文诊断模块,主编排器约 315 行。报告关联必须同时匹配 project/job;任务书已授权的 info 级新信息只保留 trace,手动排除和快照固定也不会被误报成系统漏选。 -- 质量门禁、Novelty Audit 和冗余诊断已收敛到 main process diagnostics IPC;renderer 侧不再保留重型服务动态 fallback。 -- 写作 Prompt Guard 已升级为运行时防护:清理占位符、重复禁令和审稿口吻,并把有价值的风险提示转译成当前写作限制。 -- 数据迁移合并已增加仅作用于源记录的类型化引用重映射,覆盖嵌套/前向 ID、以 ID 为键的字典和旧数据跨集合 ID 碰撞,同时不改写目标库原有记录。 -- UI、preload、SQLite/JSON 存储与 Agent Runtime 已共享乐观 revision 保护;旧快照的 full save 或 bundle save 会被拒绝,不再覆盖另一进程刚写入的数据。 -- revision 冲突会在项目页和项目列表显示全局恢复提示;作者确认后通过同一保存队列重新加载最新数据,不会自动把旧内存写回磁盘。 -- GenerationRun / ChapterCommit / RevisionCommit 的 IPC 写入失败不再被 renderer 静默降级成完整 AppData 保存;只有旧 preload 确实缺少对应 API 时才允许兼容回退,事务校验与 revision 冲突不能再被绕过。 -- ChapterCommit 现在同时保存被替换的提交前快照和正式采纳版本;旧提交仍可在版本链中查看和恢复,正式提交版本受到删除保护。 -- RevisionCommit 与 ChapterCommit 增加不可变性、引用一致性和 stale baseline 校验,重复接受保持幂等,旧正文不能覆盖已更新章节。 -- ChapterCommit 与 RevisionCommit 现已复用同一组提交不变量:稳定幂等 upsert、完整提交不可变校验、项目归属和跨项目同 ID 防覆盖。构建器按 project/job/chapter 限定报告、草稿、trace 和版本来源;AI 辅助修订会正确记录为 `user_with_ai_revision`,不会再误标为纯 AI 修订。 -- ChapterCommit 与 RevisionCommit 的纯校验已进一步拆到 `commitBundles/*Validation.ts`;公开服务分别降至约 266/238 行,只负责构建、委派校验和内存应用。模块化门禁锁定了主服务与校验器体量,SQLite/JSON 事务入口和 renderer API 保持不变。 -- 章节正文的 debounce 保存现在会跟踪待写内容;章节切换、定时保存失败和组件卸载均不会静默丢弃最后一次编辑。 -- 最终 `promptBlockOrder` 会按运行时 Prompt Guard 清理后的正文重新计算 included/tokenEstimate;冲突优先级声明已显式纳入 HardCanon 与 Story Direction。 -- 章节删除已替换为可逆归档:正文、版本链、提交和诊断记录继续保留;阅读、Prompt、预算选择、生成目标和 Agent 常规读取只使用活跃章节。恢复时若原章序已被占用,会安全移动到末尾。 -- UI 与 Agent Runtime 共用 `ChapterLifecycleService`;Agent 可查询、归档和恢复章节,高风险动作要求显式确认,下一章序不会复用归档章序。 - -### 当前产品状态 - -Novel Director 是 `0.1.x` 实验性预览阶段的本地优先 AI 长篇小说工作台。 - -当前已具备: - -- Electron + React + TypeScript 桌面应用,Windows 为主要开发和验证平台。 -- SQLite P0 后端,renderer 仍通过 `load()/save(AppData)` 兼容 API 工作。 -- JsonStorageService fallback、JSON 导入导出、旧 JSON 首次迁移、数据路径迁移和合并预览。 -- API Key 使用 Electron `safeStorage`,不应写入 AppData、SQLite、JSON 导出或日志。 -- main process sandbox、context isolation、CSP、导航限制、单实例锁和受控 preload API。 -- AI HTTP 调用在 main process 执行,支持限流、指数退避重试、`response_format` 降级和错误脱敏。 -- renderer 保存队列和函数式保存,降低旧闭包覆盖新数据的风险。 -- 关键写入聚合或事务化: - - `GenerationRunBundle` 记录 AI 生成过程。 - - `ChapterCommitBundle` 记录用户首次采纳草稿。 - - `RevisionCommitBundle` 记录正式修订提交。 - - 版本链、历史版本查看、Diff 和恢复 UI 已有基础实现。 -- Context Need Planner、ContextBudgetManager、计划后二次上下文补全、Prompt Priority Stack、上下文压缩替换、伏笔 treatmentMode 操作表、HardCanonPack、Story Direction Guide、Novelty Guardrails、Quality Gate 和 Run Trace。 -- Character State Ledger 覆盖位置、伤势、资源、物品、知识、承诺、能力限制等硬状态,并支持从日志转入账本或候选。 -- 记忆候选结构化,支持批量确认,仍以用户确认为长期记忆写入边界。 -- 质量门禁通过分数为 50,需要人工确认审核的门槛为 80。 -- 最终写作 Prompt 中可推进伏笔数量限制为 10,并按状态、权重和 treatmentMode 排序。 -- 公开发布材料已具备:MIT License、README、SECURITY、CONTRIBUTING、CHANGELOG、THIRD_PARTY_NOTICES、GitHub Actions 与发布检查脚本。 -- Windows 打包流程包含本地 Electron 缓存目录、`better-sqlite3` native 依赖说明和 packaged smoke test。 -- 首页空项目状态提供旧数据 JSON 导入口。 -- JSON 导入已区分安全合并与确认覆盖:非空工作台默认保留当前数据,无法自动解决的冲突在写入前停止;覆盖前创建恢复备份,外部文件中的旧 API Key 一律忽略。导入、完整保存和事务 bundle 共用 renderer 串行队列,不再把主进程已持久化的导入结果重复完整保存一次。 -- 备份恢复、数据路径迁移、恢复默认路径和合并迁移也已接入同一 renderer 操作队列;主进程成功落盘后只采纳返回快照,不再通过 `replaceData` 做第二次完整保存。 -- 章节页支持归档与恢复,不再通过硬删除制造不可达版本历史。 - -### P0:发布与数据安全回归 +| 优先级 | 工作 | 完成条件 | +| --- | --- | --- | +| 1 | 处理已报告的依赖安全告警 | 核对受影响调用,按兼容范围升级;完整回归和包内 SQLite 启动通过后再发包 | +| 2 | 继续压缩实际慢入口 | 复现具体操作,测量全量数据读取、跨进程传输或诊断耗时;只有可重复收益才新增定向查询或后台计算 | +| 3 | 用真实修订案例检查提示词 | 对比指定问题是否修好、无关段落是否保留;记录 Novelty 误报/漏报,避免继续堆全局禁令 | +| 4 | 修补高频操作中的阻碍 | 优先未保存输入恢复、采纳失败恢复、阅读和修订切换;按用户反馈选具体入口,不做全站重设计 | -状态:P0 基线已完成,后续作为每次发布前的硬门禁维护。 +真实模型对照需要另行确认供应商、模型和费用。本地性能基准不消耗模型额度,不能替代成文质量评估。 -继续保持: +## 保留的必要校验 -1. 文档和用户可见中文文案保持 UTF-8,无 mojibake。 -2. Electron sandbox、CSP、导航限制、preload API 收敛和单实例锁保持开启。 -3. API Key 不落盘回归持续覆盖保存、导出、导入、迁移、SQLite、日志和错误路径。 -4. SQLite、JSON fallback、导入导出、旧 JSON 迁移、事务写入保持验证。 -5. UI 与 Agent 并发写入持续验证 revision 冲突会阻止旧 AppData 或旧 bundle 覆盖新数据。 -6. 数据合并持续验证项目 ID 冲突、嵌套提交对象、Prompt 快照和版本链引用不会跨项目串联。 -7. Windows packaged smoke test 持续验证图标、preload API、SQLite 默认路径、导入导出和无 API Key 状态。 -8. 导入安全回归持续验证合并不覆盖现有项目、冲突不写入、覆盖需确认、导入凭据不进入 safeStorage,以及导入、备份恢复、路径迁移与其他写入严格串行。 +正式写入仍检查项目归属、当前正文与报告是否匹配、版本冲突和重复提交。事务回滚、备份、权限撤回及密钥脱敏继续保留。AI 候选只有经用户或授权 Agent 确认后才能成为正式数据。 -每次发布前必须运行: +改代码后运行: -```bash +```powershell npm.cmd run typecheck npm.cmd test npm.cmd run build -npm.cmd run smoke:packaged ``` -公开发布前还应运行第三方 secret scanner,例如 gitleaks 或 trufflehog,并确认 GitHub secret scanning、Dependabot alerts、branch protection 和 required checks 已启用。 - -### P1:核心可维护性 - -目标:继续降低最大文件复杂度,让贡献者能安全修改高频页面和关键服务。 - -优先事项: - -1. 已完成 `RevisionStudioView.tsx` 的协调器、侧栏、版本对比及生成/版本双 lazy boundary 拆分;架构门禁明确禁止 AI 生成路径重新依赖正式提交服务,也禁止版本动作重新承载 AI 生成行为。继续用回归测试锁定编辑内容先落盘、局部修订安全合并和 `RevisionCommitBundle` 正式提交语义。 -2. 已完成 `DataMergeService.ts` 的集合元数据、实体冲突、引用重映射和文件备份边界拆分;继续用完整集合覆盖、SQLite/JSON 合并和 revision 冲突测试保护导入兼容性。 -3. 已完成 `SqliteStorageService.ts` 的 schema、实体索引序列化和三类 bundle entry 规划拆分;继续用 rollback、旧 JSON 迁移、密钥不落盘和并发 revision 测试保护 main-process-only 事务边界。 -4. 已完成 `AgentReadableSummaryService.ts` 的项目摘要、章节诊断、版本链和长文本读取拆分;继续保持 CLI/MCP 门面稳定,并用归档章节读取、跨项目诊断隔离和敏感文本脱敏测试保护代理信息权。 -5. 已完成 `PromptBuilderView.tsx` 的上下文需求、衔接、角色选择和历史动作拆分;继续用快照完整恢复、显式选择同步和 Prompt Guard 回归保护其状态语义。 -6. 已完成 `NoveltyDetector`、`CharacterStateService`、`QualityGateService`、`RunTraceAuthorSummaryService` 与 `AgentToolService` 的领域边界拆分,并将最大行数和委派关系加入 `validate-core-modularization.mjs`,防止重新合并成巨型服务。 -7. 已完成 `ReadingView.tsx`、`CharactersView.tsx` 与 `ChaptersView.tsx` 的首轮状态/协调拆分;章节正文冲突保护、AI 响应代际隔离和章序交换规则已有独立回归。后续如继续收窄 473 行章节协调器,应优先抽离纯展示装配,而不是拆散已经清晰的状态边界。 -8. 控制 `ChaptersView` 当前约 50.03 kB 的编辑首屏边界;AI 草稿、复盘、版本历史和候选写入不得重新静态并入正文编辑路径,并优先通过交互性能测量判断是否值得继续拆包。 -9. `GenerationPipelineView` 当前约 83.11 kB,仍是最大的业务页面 chunk。依赖图复核显示 `promptContext.ts` 本体仅约 150 行,其 113.60 kB 共享 chunk 主要来自同阶段确实需要的 `PromptBuilderService` 与 `ContextBudgetManager`;继续优化前应先测量控制台首开和步骤切换延迟,不为 chunk 数字机械碎片化共同执行路径。 -10. 已完成 `ChapterCommitBundleService.ts` 与 `RevisionCommitBundleService.ts` 的共享不变量和纯校验器拆分;继续用项目隔离、不可变提交、stale baseline、SQLite rollback 和版本链恢复回归保护正式写入语义。 -11. 如果后续需要严格审计章节生命周期,再引入轻量 `ChapterLifecycleCommit`;当前归档已具备原子保存、revision 冲突保护和恢复能力,不应为尚未出现的审计需求提前扩张 schema。 - -### P2:上下文与 Prompt 质量 - -目标:让模型更少吃书、更少临时发明规则、更稳定承接上一章。 - -已完成基础: - -- Context Need Planner 能识别角色硬状态、伏笔、时间线、HardCanon、Story Direction 需求,并能给出 reason 与 priority。 -- 角色需求已显式区分提及、在场、必须行动和状态核对;uncertain 候选会保留在 selection trace,但不会被升级成确定缺口或强制挤入 prompt。 -- ContextBudgetManager 有评分、选择、trace 和压缩模块。 -- Prompt Priority Stack 已将上一章衔接、本章任务、角色硬状态、伏笔规则和 HardCanon 前置。 -- StageSummary 职责已收窄为远期剧情压缩背景。 -- 写作 Prompt 已去除空字段、占位符、重复英文 guardrail 和风险审稿口吻。 -- 运行时 Prompt Guard 会在最终正文 prompt 生成后执行 lint,阻止诊断文字回流,并在 Run Trace 中记录 prompt lint 结果。 -- 伏笔按 treatmentMode 分组,可推进数量限制为 10。 -- Context selection 已使用结构化 reason code 区分预算不足、低相关、压缩替代、规则禁止、用户排除、数量上限、配置过滤、状态不适用、快照固定和来源缺失。 -- 最终 Run Trace 会按 Prompt Guard 后的真实 prompt 复核角色状态事实与 HardCanon 条目;只存在于当前账本、但没有进入冻结快照或最终 prompt 的内容不再被误记为已纳入。 -- 计划后二次补选不再把 uncertain 角色或计划要求伏笔伪装成手动强选;确认在场的角色仍受保护,纯提及候选只参与评分。 -- Run Trace Inspector 已加入 Prompt block 预览与运行时契约回放:按最终顺序展示 token、来源、权威级、forced、compressed、遗漏原因和高优先级未满足需求,不复制完整 Prompt 或正文。 -- 快照或旧 Prompt 缺失 Bridge 时,流水线会在本章任务契约之前补入;优先级声明中的“上一章结尾衔接”文字不再被误判为真实 Bridge 区块。 -- 已移除无入口的旧 Run Trace 面板,作者摘要、Novelty 审计、压缩记录、forced context 和高级 JSON 统一收敛到实际使用的 Inspector。 -- NoveltyDetector 已能区分: - - 任务书许可的新信息; - - 已在上下文或伏笔中出现的规则复用; - - 通过系统提示、广播、票据、公告、环境线索等合理媒介出现的新规则; - - 新副本开场、揭露章和高潮解法章的不同风险阈值; - - 带明确代价/限制的新规则; - - 未授权、无铺垫、无代价且刚好解围的机械降神式规则补丁; - - 编号管理员/组织层级与普通命名角色。 -- Novelty 语义层会扫描同一关键词的每次出现,识别同义规则、作用/受益对象、危机线索、解除动作、来源媒介和与规则直接关联的代价;普通系统面板、物理距离、感官“源头”和日常汇报不再被默认当成新机制。 -- Novelty 中文匹配已改为 NFKC offset 映射、词典 Trie 单次扫描和 `Intl.Segmenter` 中文边界;`管理员工位`、`总部署`、`回收站台`、否定句、被否认引用及专名不再因裸子串误报。160,000 字 synthetic 样本 p50 从约 4.47s 降到约 0.21s,完整原始基线见 `docs/PERFORMANCE.md`。 -- 生产性能测量已覆盖 Electron 进程冷启动、项目工作台、生产流水线首次/缓存进入、Prompt 构建器重型边界以及 Novelty 三档语料。UI 时延作为观测基线,算法级长文本预算作为稳定门禁,不再用源码行数或 chunk 大小替代真实性能。 -- Quality Gate 与 Run Trace 会保留结构化 Novelty 证据;附近无关的“代价”“档案”不能再替临时救命规则降级。 -- 同一危机补丁只生成一个主机械降神 finding;质量门禁、记忆候选风险和 Run Trace 共用复核汇总策略,不再为同一事件重复堆叠普通规则告警。 -- 章节号/范围解析已提升为 shared 纯工具;伏笔页改用 3.81 kB 的轻量推荐模块,不再因“预计回收章节”筛选拉入 PromptBuilder 与 ContextBudgetManager。`promptContext` 共享 chunk 从约 116.69 kB 降至 113.60 kB。 - -下一步: - -1. 用可公开的 synthetic 章节语料持续校准 NoveltyDetector,并把题材特有词汇做成可维护配置;避免为单部小说不断扩张全局关键词表。 -2. 为作者确认过的误报/漏报设计轻量反馈记录,但不要直接自动改写 HardCanon 或长期记忆。 - -### P3:作者体验 - -目标:降低复杂 AI 工作流的认知负担,让真实写作更顺。 - -优先方向: - -- 提供干净的 synthetic demo project,避免首次打开完全空白。 -- 安全合并/确认覆盖的旧 JSON 导入基础已完成;下一步做恢复向导,自动发现旧 JSON、旧 SQLite、备份文件和常见数据目录,并为无法自动合并的冲突提供只读详情。 -- 优化 Version Chain 恢复说明,让作者明确“恢复会创建新版本,不会删除当前正文”。 -- 优化 Revision Workbench 的 issue 来源、diff 范围、接受风险和回滚入口。 -- 让 Run Trace 作者摘要成为默认阅读入口,原始 JSON 作为高级详情。 -- 继续改善长文本编辑体验:连续阅读定位、未保存离开保护、快速重写撤销和章节正文保存状态已完成;下一步补章节内搜索和键盘快捷键。 -- 继续增强冲突恢复:在重新加载前列出各页面尚未提交的字段,并支持逐项复制或重新应用。 -- 增强无障碍:焦点样式、按钮可读标签、键盘导航和对比度。 - -### P4:存储、查询与搜索 - -目标:在保持 AppData 兼容的前提下,让 SQLite 后端逐步发挥查询价值。 - -建议方向: +更新安装包时另跑 `npm.cmd run smoke:packaged`;公开发布前做密钥扫描。每项新优化必须有行为回归或实测依据,不新增只检查文案和文件行数的门槛。 -- 增加 main-process 只读查询 API,不让 renderer 直接访问 SQLite。 -- 为章节、角色、伏笔、时间线、角色状态事实增加常用查询索引。 -- 增加可选 SQLite FTS,用于章节正文、阶段摘要、角色状态、伏笔和 HardCanon 搜索。 -- 提供只读搜索和引用跳转,不直接改写业务数据。 -- 保留 JSON 导入导出和备份能力。 +## 暂缓 -暂不建议: +全库 CRUD/关系型重构、FTS、图谱、云同步、通用插件系统、多模型路由矩阵和全站视觉重做不作为本轮必做项。签名与自动发布在实际分发需要时推进。已有可用流程不因旧路线图中的长期设想重复施工。 -- 立即把所有 AppData 拆成完整关系型模型。 -- 移除 JSON fallback。 -- 让 renderer 直接读写数据库。 +## 参考 -### P5:扩展能力 - -长期方向: - -- 多 provider adapter 和 provider capability matrix。 -- 可选流式输出、步骤内更细粒度进度和更细粒度重试策略。 -- Prompt 模板可视化编辑器和调试器。 -- 时间线图、伏笔网络、角色关系图和角色状态图谱。 -- 项目打包格式和更完整的导入导出协议。 -- 正式代码签名、release checksums 和自动发布流水线。 -- Agent Runtime / CLI / MCP 工具入口,让 Codex 不读取 UI 也能调度同一套生产流水线、诊断、提交和版本链。 -- Agent 单章流水线已支持复用原 AgentRun/Job,从首个失败或未完成步骤恢复;已完成 Job 拒绝重复重试,避免重复草稿与诊断记录。 -- Agent 长请求已支持结构化进度查询与跨工具取消;取消使用独立控制通道终止当前 AI 请求,不与完整 AppData 保存竞争,并保留可恢复步骤。 -- 可选插件或脚本扩展点。 - -参考:[AI 代理运行层施工计划](./docs/AGENT_RUNTIME_PLAN.md)。 - -### 暂不计划 - -近期不做: - -- 云同步、账号系统、多设备协作。 -- 自动把 AI 输出直接写入长期记忆或 HardCanon。 -- 远程托管用户稿件。 -- 一次性重写 PromptBuilder、生成流水线或整个 UI。 -- 用大型依赖替代现有轻量规则系统。 - -### 工程守则 - -- 本地优先,用户数据默认保留在本机。 -- 密钥只由 main process 和安全存储处理,不写入普通数据文件。 -- 高风险写入必须可追踪、可回滚、幂等。 -- AI 输出只能成为候选,进入长期记忆或硬设定前需要用户确认。 -- Prompt 优先级必须保护上一章衔接、本章任务、角色硬状态、伏笔 treatmentMode 和 HardCanon。 -- renderer 不直接访问文件系统、SQLite 或 API Key。 -- 每轮小版本都应通过 `npm.cmd run typecheck`、`npm.cmd test`、`npm.cmd run build`。 - ---- +- [新手教程](./docs/BEGINNER_TUTORIAL.md):如何使用现有能力。 +- [Agent 授权指南](./docs/AGENT_PROJECT_AUTHORIZATION.md):Codex 与桌面共享数据和权限。 +- [后端性能复核](./docs/BACKEND_PERFORMANCE_REVIEW.md):备份、保存、初始化的历史测量。 +- [长篇界面实测](./docs/PREVIEW14_LONG_NOVEL_PERFORMANCE.md):10/100/500 章真实应用基线。 +- [原施工计划](./docs/PRODUCT_OPTIMIZATION_MASTER_PLAN.md):已完成批次的背景,不作为新的待办清单。 ## English -Last reviewed: 2026-07-15. - -This roadmap reflects the current repository, validation scripts, public documentation, and this review pass. It is public-facing and avoids local paths, private manuscripts, real credentials, and internal sensitive details. - -### Review Summary - -This pass reviewed: - -- `package.json` and `scripts/run-tests.mjs` -- Storage, IPC, preload, AI transport, security, and release scripts -- Generation Pipeline, PromptBuilder, Context Need Planner, ContextBudgetManager, NoveltyDetector, and QualityGate -- Public documentation and largest source files - -Current validation baseline: - -- `npm.cmd run typecheck`: passing -- `npm.cmd test`: passing, currently 66 validation scripts -- `npm.cmd run build`: passing -- `validate-no-mojibake.mjs`: passing -- `validate-release-p0-readiness.mjs`: passing -- `validate-electron-security-p0.mjs`: passing -- `validate-sqlite-storage.mjs`: passing -- `validate-novelty-guardrails.mjs`: passing - -Largest remaining source files include: - -- `ChaptersView.tsx`: about 453 lines, with prose state and AI coordination delegated -- `revisionStudioActionHandlers.ts`: about 438 lines -- `PromptBuilderView.tsx`: about 418 lines -- `pipelineSteps/chapterGeneration.ts`: about 383 lines -- `PromptBuilderService.ts`: about 381 lines, with formatting delegated - -Structural progress: - -- Shared types and defaults have been split into domain modules with compatibility exports. -- Prompt formatting and context budget logic have been split into focused modules. `ContextBudgetManager.ts` is now a roughly 257-line coordinator over scoring, selection, finalization, and trace construction. -- The generation pipeline runner has been split into an engine, shared runner types, and phase-specific step handlers. -- The pipeline engine, revision actions, run-bundle assembly, draft commits, memory-candidate actions, and author diagnostics now load on demand. The production `GenerationPipelineView` chunk fell from 179.61 kB to 82.98 kB, a reduction of about 53.8%, while generation, retry, revision, and transactional persistence remain covered by the full regression suite. -- Chapter AI drafts, review fields, version history, restore actions, and character / foreshadowing candidate writes load only when used; buffered prose persistence and AI draft coordination now have focused hooks. With the external-update conflict state machine included, the production `ChaptersView` chunk is 50.03 kB, still about 40% below its original 83.35 kB. -- The continuous reader fell from about 474 to 290 lines. Scroll tracking, chapter rails, prose offsets, and the shared AI rewrite menu now have focused hooks/components/helpers. Initial navigation runs once, chapter prose updates no longer reset reading position, rail labels use real chapter order, unsaved inline edits are guarded, and direct AI rewrites offer a safe one-step undo. -- The character view fell from about 469 to 395 lines by delegating state logs and conversion forms. Character switches clear stale log drafts and restore log-only mode; state-fact editors debounce raw values without display units, preventing per-keystroke full saves and accidental numeric coercion to zero. -- Chapter AI actions now use the current `bodyDraft` even when it is intentionally empty, so clearing prose can no longer fall back to stale saved text during review or extraction. -- When pipeline acceptance, version restore, or another path updates the active chapter, the editor distinguishes clean synchronization, save acknowledgement, and true divergence. Divergence pauses autosave and asks the author which body to keep; late AI responses after chapter navigation are discarded. -- The revision workbench now uses a roughly 284-line coordinator plus source and comparison panels, while its actions are split into shared types, AI generation, and formal version operations. The production main chunk is 35.52 kB and the generation/version action chunks are 9.51/10.39 kB. Candidate generation no longer loads `RevisionCommitBundleService`, and copy/reject/formal acceptance no longer load AI-generation behavior; edits still flush before acceptance or navigation. -- The data-merge core now separates collection metadata, entity equality/conflict helpers, reference remapping, and data-file backup/storage. `DataMergeService.ts` fell from 655 to 346 lines, and post-merge reads now return a coherent data/revision snapshot from the same load. -- The SQLite backend now separates schema setup, indexed entity mapping, and bundle-entry planning. `SqliteStorageService.ts` fell from 675 to 390 lines. Bundle revision checks, current-data reads, reference validation, and upserts now share one short transaction, while a successfully loaded `better-sqlite3` constructor is cached to avoid repeated native probes. -- Agent-readable summaries now use a 160-line stable facade plus project, chapter-diagnostics, version-chain, and long-text reader modules. The service fell from 651 to 160 lines. Agent reads now reuse the UI's formal version-chain semantics, support explicit archived-chapter reads, and strictly scope diagnostics by project/job/chapter to prevent cross-project contamination. -- `PromptBuilderView.tsx` fell from about 566 to 446 lines. Context needs, continuity, character selection, and history actions now have focused components/hooks. Loading a context snapshot restores task, modes, modules, budget, selections, and continuity settings, while need-plan removals stay aligned with explicit selection. -- `ContextNeedPlannerService.ts` now delegates character mention-window inference and character need-slice assembly to two pure modules, reducing the coordinator from about 424 to 343 lines. Needs distinguish mention-only, expected presence, required action, and state checks; uncertain Story Direction or foreshadowing candidates are scored without becoming forced context. -- `SettingsView.tsx` fell from about 549 lines to a 73-line coordinator. Credentials, storage/import-export, backup, and logs use focused controllers with busy guards, readable error states, and explicit persistence-failure handling. Credential metadata sync can no longer be reported as fully successful when its AppData save fails. -- `AIResponseNormalizer.ts` fell from about 517 lines to a four-line compatibility facade. Primitive, chapter/foreshadowing, character-state, and quality/revision normalization now live in four pure domain modules, the largest of which is about 194 lines, without changing existing import paths. -- Novelty detection now uses focused policy, name, keyword, occurrence scanning, semantic-linking, and finding modules behind a roughly 232-line orchestrator. Chapter permission, known context/HardCanon, rule synonyms, source media, chapter mode, quotas, linked costs, and crisis resolution all affect severity. -- `CharacterStateService.ts` is now a roughly 68-line compatibility facade over log inference, prompt selection, validation, value handling, and mutations. Candidate ownership is checked, inventory facts aggregate correctly, and known information no longer causes reversed knowledge-leak findings. -- `QualityGateService.ts` is now a roughly 143-line orchestrator. Deterministic state, continuity, foreshadowing, length, and redundancy rules always run even when an AI evaluation exists, and the pipeline's NoveltyAuditResult is reused instead of recomputed independently. -- `AgentToolService.ts` is now a roughly 50-line stable facade over definitions, strict argument parsing, reads, and transaction-backed writes. Explicit full reads return complete redacted prose/prompts unless the caller supplies `maxChars`; compact defaults remain unchanged. -- `RunTraceAuthorSummaryService.ts` now delegates source lookup, shared diagnostics, and context diagnostics; its coordinator is roughly 315 lines. Linked reports must match project/job, task-authorized info novelty stays traceable without becoming a false risk, and manual exclusions or frozen snapshots are no longer mislabeled as automatic selection failures. -- Quality Gate, Novelty Audit, and redundancy diagnostics now go through main-process diagnostics IPC; renderer-side heavy-service fallback has been removed. -- Runtime Prompt Guard now lints final writing prompts, removes placeholders / duplicate guardrails / review tone, and rewrites useful risks into current writing constraints. -- Migration merge now performs a source-only typed reference pass for nested / forward IDs, ID-keyed records, and legacy cross-collection ID collisions without rewriting existing target records. -- UI, preload, SQLite/JSON storage, and Agent Runtime now share optimistic revision checks; stale full-data or bundle writes are rejected instead of overwriting another process's newer data. -- Revision conflicts now surface a global recovery notice on project and home screens; confirmed reloads run through the same queue and never write stale memory back automatically. -- GenerationRun / ChapterCommit / RevisionCommit IPC failures can no longer be silently bypassed by a full AppData save. Compatibility fallback is allowed only when an older preload genuinely lacks the bundle API. -- Chapter commits now retain both the pre-commit snapshot and the formally accepted version. Legacy commits remain readable and restorable, while formal commit versions are protected from deletion. -- Chapter and revision commits now enforce immutable IDs, reference consistency, stale-baseline protection, and idempotent repeated acceptance. -- Chapter and revision commits now share stable idempotent upserts, full immutable-commit checks, project ownership checks, and cross-project ID-collision guards. Builders scope reports, drafts, traces, and version ancestry by project/job/chapter, and AI-assisted revisions are recorded as `user_with_ai_revision` rather than pure AI revisions. -- Pure chapter/revision commit validation now lives in `commitBundles/*Validation.ts`. The public services fell to roughly 266 and 238 lines and only build bundles, delegate validation, and apply them in memory; SQLite/JSON transaction entry points and renderer APIs remain unchanged. -- Debounced chapter editing now tracks pending text through chapter switches, save failures, and component unmounts instead of silently dropping the latest edit. -- `promptBlockOrder` is reconciled against the post-lint writing prompt, and the conflict-priority declaration now explicitly includes HardCanon and Story Direction. -- Destructive chapter deletion has been replaced with reversible archive semantics. Prose, versions, commits, and diagnostics are retained, while reading, prompt selection, budgeting, generation targets, and normal Agent reads use active chapters only. Restore safely moves a chapter when its former order is occupied. -- The desktop UI and Agent Runtime share `ChapterLifecycleService`. Agent tools can inspect, archive, and restore chapters behind explicit confirmation, and next-order calculation never reuses archived orders. -- AppData collection ownership now has one shared metadata source for import/merge, SQLite serialization, renderer project scoping, and project deletion. Project removal is a tested pure lifecycle operation, follows generation/revision dependencies, preserves other projects, and shows authors a concrete deletion-impact summary before the irreversible save. - -### Current State - -Novel Director is an experimental `0.1.x` local-first AI workbench for long-form fiction. - -It currently includes: - -- Electron + React + TypeScript desktop app, primarily validated on Windows. -- P0 SQLite backend with renderer-facing `load()/save(AppData)` compatibility. -- JSON fallback, JSON import/export, legacy JSON migration, storage migration, and merge preview. -- API keys stored through Electron `safeStorage`; keys should not persist into AppData, SQLite, JSON exports, or logs. -- Main-process sandboxing, context isolation, CSP, navigation restrictions, single-instance lock, and controlled preload API. -- Main-process AI transport with rate limiting, exponential backoff retry, `response_format` fallback, and error redaction. -- Renderer save queue and functional save updates. -- Transactional or bundled critical writes: generation runs, accepted drafts, accepted revisions, version chain, diff, and restore. -- Context Need Planner, ContextBudgetManager, post-plan context gap closure, Prompt Priority Stack, compression, foreshadowing treatment tables, HardCanonPack, Story Direction Guide, Novelty Guardrails, Quality Gate, and Run Trace. -- Character State Ledger for location, injury, resources, inventory, knowledge, promises, and ability limits. -- Structured memory candidates that require user confirmation. -- Quality Gate pass threshold at 50 and human review threshold at 80. -- Final writing prompt foreshadowing progression limit of 10 items. -- Reversible chapter archive and restore without losing version history. -- Public release materials: MIT license, README, SECURITY, CONTRIBUTING, CHANGELOG, THIRD_PARTY_NOTICES, GitHub Actions, and release checks. - -### P0: Release And Data Safety - -Status: the P0 baseline is complete and should remain a hard release gate. - -Keep maintaining: - -1. UTF-8 / mojibake checks for docs and user-visible copy. -2. Electron sandbox, CSP, navigation restrictions, narrowed preload API, and single-instance lock. -3. Credential persistence regression checks across save, export, import, migration, SQLite, logs, and errors. -4. SQLite, JSON fallback, import/export, legacy JSON migration, and transactional-write checks. -5. UI/Agent concurrent-write regressions that reject stale AppData and bundle snapshots. -6. Merge regressions for conflicting project IDs, nested commit payloads, prompt snapshots, and version-chain references. -7. Packaged app smoke checks for icon, preload API, SQLite default path, import/export, and no-key state. - -Before every release: - -```bash -npm.cmd run typecheck -npm.cmd test -npm.cmd run build -npm.cmd run smoke:packaged -``` - -Before public release, also run a third-party secret scanner and enable GitHub secret scanning, Dependabot alerts, branch protection, and required checks. - -### P1: Maintainability - -Goal: reduce the remaining large files so contributors can safely work on high-frequency pages and core services. - -Priorities: - -1. Completed the `RevisionStudioView.tsx` coordinator/sidebar/comparison split and separate generation/version lazy boundaries. Architecture checks now forbid AI generation from importing formal commit persistence and forbid version actions from absorbing AI generation again. Keep regression coverage around edit flushing, safe local merge, and formal `RevisionCommitBundle` semantics. -2. Completed the `DataMergeService.ts` collection metadata, entity conflict, reference-remapping, and file-storage split. Keep full collection coverage, SQLite/JSON merge, and revision-conflict tests as the compatibility boundary. -3. Completed the `SqliteStorageService.ts` schema, indexed entity serialization, and bundle-entry planning split. Keep rollback, legacy JSON migration, credential redaction, and concurrent-revision tests as the main-process-only transaction boundary. -4. Completed the `AgentReadableSummaryService.ts` project-summary, chapter-diagnostics, version-chain, and long-text reader split. Keep the CLI/MCP facade stable and protect Agent information parity with archived-read, cross-project diagnostic isolation, and sensitive-text redaction tests. -5. Completed the `PromptBuilderView.tsx` context-needs, continuity, character-selection, and history-action split. Keep full snapshot restore, explicit-selection alignment, and Prompt Guard regressions as its state boundary. -6. Completed the domain splits for `NoveltyDetector`, `CharacterStateService`, `QualityGateService`, `RunTraceAuthorSummaryService`, and `AgentToolService`; `validate-core-modularization.mjs` now guards their line limits and delegation boundaries. -7. Completed the first state/coordination split for `ReadingView.tsx`, `CharactersView.tsx`, and `ChaptersView.tsx`. Prose conflicts, stale AI-response isolation, and safe chapter-order swaps now have focused regressions. Any further reduction of the 473-line chapter coordinator should extract presentation assembly without breaking these state boundaries. -8. Keep the current roughly 50.03 kB `ChaptersView` editing boundary under observation. AI drafts, review fields, version history, and candidate mutations must not drift back into the static prose-editing path; profile interaction latency before pursuing another bundle split. -9. At 83.11 kB, `GenerationPipelineView` remains the largest business-view chunk. Dependency review shows that `promptContext.ts` itself is only about 150 lines; its 113.60 kB shared chunk is dominated by `PromptBuilderService` and `ContextBudgetManager`, which are genuinely used together in the same execution phases. Profile console-open and step-transition latency before splitting this path for bundle-size optics. -10. Completed the shared-invariant and pure-validator split for chapter and revision commit services. Keep project isolation, immutable commits, stale baselines, SQLite rollback, and version restore as the regression boundary for formal writes. -11. Add a lightweight `ChapterLifecycleCommit` only if formal archive auditing becomes a real requirement. The current implementation already has atomic persistence, optimistic revision protection, and reversible restore, so the schema should not expand speculatively. -12. Keep `appDataCollections.ts` as the only collection-ownership registry. New AppData arrays must declare their project/dependency scope and user-facing label there; source-regex copies in views or storage modules are not acceptable. - -### P2: Context And Prompt Quality - -Goal: reduce continuity drift, unhelpful context bloat, and unearned new rules. - -Completed baseline: - -- Context Need Planner can identify character hard-state, foreshadowing, timeline, HardCanon, and Story Direction needs with reasons and priorities. -- Character needs explicitly distinguish mention, presence, required action, and state checks. Uncertain candidates remain visible in selection trace without being mislabeled as confirmed gaps or forced into the prompt. -- ContextBudgetManager has scoring, selection, trace, and compression modules. -- Prompt Priority Stack protects previous-chapter bridge, chapter task, character hard state, foreshadowing rules, and HardCanon. -- StageSummary has been narrowed to long-range plot compression. -- Writing prompts remove empty placeholders, duplicate English guardrails, and review-tone risk text. -- Runtime Prompt Guard runs after final writing prompt assembly and records prompt lint results in Run Trace. -- Foreshadowing is grouped by treatmentMode and progression is capped at 10 items. -- Context selection now uses structured reason codes for budget pressure, low relevance, compressed replacement, forbidden content, manual exclusion, item caps, profile filtering, ineligible status, frozen snapshots, and missing sources. -- Final Run Trace enrichment verifies character-state facts and HardCanon items against the actual post-guard prompt. Ledger facts that exist in current data but are absent from a frozen snapshot or final prompt are no longer reported as included. -- Post-plan delta selection no longer disguises uncertain characters or plan-required foreshadowings as manual selections; confirmed present characters remain protected while mention-only candidates remain advisory. -- The active Run Trace Inspector now includes prompt-block preview and runtime contract replay with final order, token estimates, source, authority priority, forced/compressed state, omission reasons, and confirmed unmet needs without copying the full prompt or draft. -- When a snapshot or legacy prompt lacks a real continuity section, the pipeline inserts the Bridge before the chapter-task contract; text inside the priority declaration can no longer masquerade as the actual Bridge block. -- The unused duplicate Run Trace panel has been removed. Author diagnosis, Novelty audit, compression, forced context, and advanced JSON now live in the active Inspector. -- NoveltyDetector now separates task-authorized novelty, synonymous traced-rule reuse, source-media-supported rules, chapter-mode allowances, linked-cost novelty, mundane wording, and unearned deus-ex patches. Quality Gate and Run Trace retain structured beneficiary, resolution, source-medium, and cost evidence. -- One rescue-rule incident now produces one primary deus-ex finding. Quality Gate, memory-candidate warnings, and Run Trace share one author-review consolidation policy instead of repeating ordinary rule findings for the same event. -- Chapter-number/range parsing now lives in a shared pure utility. ForeshadowingView uses a 3.81 kB recommendation helper instead of pulling PromptBuilder and ContextBudgetManager through `promptContext`; the shared chunk fell from about 116.69 kB to 113.60 kB. - -Next: - -1. Calibrate NoveltyDetector against public synthetic chapter fixtures and move genre-specific vocabulary into maintainable configuration instead of growing one global keyword list. -2. Add lightweight author feedback records for confirmed false positives / negatives without automatically mutating HardCanon or long-term memory. - -### P3: Author Experience - -Goal: make the complex AI workflow easier to understand and safer to use. - -Priorities: - -- Add a clean synthetic demo project. -- Add an old-data import wizard. -- Improve Version Chain restore copy and linked report entry points. -- Improve Revision Workbench issue provenance and rollback guidance. -- Make Run Trace author summary the default surface. -- Continuous-reader navigation, unsaved-exit protection, quick-rewrite undo, and chapter save-state feedback are complete; next add in-chapter search and keyboard shortcuts. -- Continue conflict recovery by listing dirty fields before reload and allowing selective copy or re-apply. -- Improve accessibility: focus styles, readable button labels, keyboard navigation, and contrast. -- Project deletion now explains its chapter, character, foreshadowing, draft, version, and trace impact and recommends exporting a backup. A future reversible project archive should be considered only together with a clear storage-retention policy. - -### P4: Storage And Search - -Goal: let the SQLite backend provide more value while keeping AppData compatibility. - -Future work: - -- Add main-process read APIs for focused queries. -- Add SQLite indexes for common entity lookups. -- Add optional SQLite FTS for chapter text, summaries, character state, foreshadowing, and HardCanon. -- Keep renderer away from direct SQLite access. -- Keep JSON import/export and backups. - -Not recommended yet: - -- Fully relational rewrite of all AppData collections. -- Removing JSON fallback. -- Direct renderer database access. - -### P5: Expansion - -Long-term: - -- Provider adapter matrix. -- Optional streaming and cancellation. -- Visual prompt template editor. -- Timeline, foreshadowing, relationship, and character-state graph views. -- Project package format and richer import/export. -- Code signing, release checksums, and automated release workflow. -- Agent Runtime / CLI / MCP tool entry points so Codex can orchestrate the same generation pipeline, diagnostics, commits, and version chain without reading the UI. -- Optional plugin or scripting extension points. - -See [Agent Runtime implementation plan](./docs/AGENT_RUNTIME_PLAN.md). - -### Not Planned Short-Term - -- Cloud sync or accounts. -- Automatic AI writes into long-term memory or HardCanon. -- Remote manuscript hosting. -- Full rewrite of PromptBuilder, the generation pipeline, or the full UI. -- Replacing the lightweight rule system with large dependencies. - -### Engineering Guardrails +Updated 2026-11-11. The [long-novel repair plan](./docs/LONG_NOVEL_COMPLETION_REPAIR_PLAN.md) now includes project-level usage, single-request cost estimates, connection diagnoses, custom story-direction ranges and auditable chapter renames, in addition to draft retention, canon checks and cross-chapter continuity. Provider balances and unrecorded historical usage remain unknown. This describes the working tree, not a new installer. -- Local-first by default. -- Credentials stay in secure main-process storage. -- High-risk writes must be traceable, recoverable, and idempotent. -- AI output remains a candidate until the user confirms it. -- Prompt priority protects previous-chapter continuity, chapter task, character hard state, foreshadowing treatment mode, and HardCanon. -- Renderer must not directly access filesystem, SQLite, or API keys. -- Every small release should pass `npm.cmd run typecheck`, `npm.cmd test`, and `npm.cmd run build`. +Next: verify dependency advisories; profile concrete slow operations before redesigning storage or threading; evaluate task-specific revision instructions with real cases; repair reported editing and acceptance friction. The [current performance record](./docs/REDUNDANCY_PERFORMANCE.md) covers the optimizations now packaged locally as Preview.19, not a claim about model quality or a GitHub publication. -
+Keep project isolation, version conflict checks, idempotent commits, report provenance, rollback, backups, authorization and credential redaction. Full CRUD rewrites, FTS, graphs, cloud sync and generic extension systems remain deferred. Historical plans and measurements are references, not additional mandatory work. diff --git a/docs/AGENT_TOOL_API.md b/docs/AGENT_TOOL_API.md index f3e9363..3ef5d78 100644 --- a/docs/AGENT_TOOL_API.md +++ b/docs/AGENT_TOOL_API.md @@ -42,7 +42,11 @@ npm.cmd run agent:tools - `agent.listProjects` - `agent.getProjectDigest` - `agent.getNextChapterTarget` +- `agent.getProjectUsage`:按项目和工作环节读取实际请求、已知 token、已知费用和未覆盖范围。不含正文,不调用模型。未上报的用量和账户余额都是未知。 +- `agent.estimateProjectUsage`:预估单次请求;传 `projectId`、`provider`、`endpoint`(订阅 CLI 无需)、`model`、可选 `maxOutputTokens`。输入用 `inputTokens` 或本项目的 `snapshotId`,二选一;不提供则输入未知。快照只代表该段文字,不是整条流水线。只有配置了匹配的价目才能估算金额。 +- `agent.getBookProgress`:核对全书正式字数、距作者目标的差额、缺章和写回情况。传 `projectId` 或 `project`;可选 `fromChapter` / `toChapter`(含两端)、`issuesOnly`、`offset`、`limit`(默认 50,最多 200)。`totalWords` 是全书字数,`range.totalWords` 是所选章段字数,均不随分页变化。它不返回整段正文。`nextAssignableOrder` 是可分配的新章号;`completeThrough` 才是从第 1 章起连续有正文的末章。 - `agent.getArchivedChapters` +- `agent.getBookContinuityReport`:只读核对本项目的当前正式正文和已确认记录。传 `projectId` 或 `project`,可选 `from`、`to`、`kind`、`status`、`offset` 和 `limit`(默认 10、最多 50)。例:`{"projectId":"你的项目ID","from":80,"to":96,"kind":"money","status":"conflict"}`。可保留第 80 章之前的比较依据,但不会使用第 96 章之后的事件。结果包含章 ID、正文指纹、可核实的版本 ID 和原句节选,不返回整章或写入正史。`needs_evidence` 是待核线索,`open` 是未结事项,不是采纳否决。 - `agent.getChapterProductionState` - `agent.getChapterVersionChain` - `agent.getChapterText` @@ -62,6 +66,8 @@ npm.cmd run agent:tools 写入/预览: +正式改章名:先用 `agent.previewChapterTitleRename`,传 `projectId`、唯一的 `operationId`、`prefixMode`(`manual`、`keep` 或 `strip`)和 `edits`(稳定章节 ID、拟用标题)。核对返回的章号、旧名和新名后,用 `agent.applyChapterTitleRename` 发同一命令,附预览的版本与指纹(`expectedRevision`、`expectedPreviewHash`)。必须有覆盖全部所选章节的有效 `manage_chapters` 授权。重试同一提交请复用操作 ID。读 `agent.getChapterTitleHistory` 可按项目或章节查旧名,不需读取正文。 + - `agent.startAgentRun` - `agent.runChapterPipeline` - `agent.retryChapterPipeline` @@ -162,7 +168,7 @@ directory, never the production novel store. 世界资料工具只允许读取和经作者项目级 `edit_world` 授权后的明确导演动作;不提供删除、任意集合写入或由检测结果自动生成 canon 的入口。 -1. `agent.getWorldRecords` 与 `agent.getWorldRecord`:读取角色、角色状态、伏笔、时间线、HardCanon 或剧情导向。默认 compact,`detail: "full"` 才返回未截断的许可字段。 +1. `agent.getWorldRecords` 与 `agent.getWorldRecord`:读取角色、角色状态、伏笔、时间线、HardCanon 或剧情导向。单读角色时,`cardReview` 区分最后变化章、核对章、最近确认事实章,`detail: "full"` 另给短引文和来源。可用 `asOfChapterOrder` 限定核对截止章;这不会把可编辑原卡还原到当时,也不会将卡标为已核对。 2. `agent.getWorldActionReceipt`:按 `operationId` 读取一条已落盘的世界资料操作记录,含脱敏后的 `before`、`after`、command/preview 指纹、导演来源和实际授权 grant ID。 3. `agent.getWorldActionHistory`:按项目分页读取上述 receipt;`limit` 默认 20、最大 100,返回 `total`、`offset`、`nextOffset` 和 `receipts`。 4. `agent.previewWorldAction` 与 `agent.applyWorldAction`:支持 create、update、set_status、archive。apply 必须原样携带 preview 的 `expectedFingerprint` 与稳定 `operationId`,并在当前落盘快照上重新校验后保存。 @@ -173,7 +179,7 @@ compact 输出会保留 ID、枚举、operationId 和全部指纹,不截断; `previewWorldAction` 和 `applyWorldAction` 返回的 `before`/`after` 也使用同一脱敏规则;保存的 command、receipt 和 fingerprint 仍基于原始内容,不会因展示脱敏而改变。两个写工具的 `patch` schema 提供 `x-worldEditableFieldsByEntity`:它列出每种实体允许的字段、角色九项卡字段以及状态/伏笔/HardCanon/剧情导向的枚举值,实际写入仍由共享服务按实体严格校验。 -角色和时间线没有已注册的归档生命周期,归档/状态动作会明确拒绝。角色状态的 `sourceChapterId`(及同时提供的 `sourceChapterOrder`)必须定位到同项目同一章节。激活剧情导向时,`endChapterOrder` 必须等于 `startChapterOrder + horizonChapters - 1`,每个节拍必须位于该范围并与自身 offset 一致。 +角色和时间线没有已注册的归档生命周期,归档/状态动作会明确拒绝。角色状态的 `sourceChapterId`(及同时提供的 `sourceChapterOrder`)必须定位到同项目同一章节。剧情导向以起止章为准,含两端,一条导向可覆盖 1–30 章;5 和 10 只是快捷选项。第 91–96 章的长度是 6,第 96–96 章的长度是 1。节拍的章号和 offset 须与区间一致。 ### Shared-Kernel Review Handoff (2026-09-06) @@ -257,6 +263,14 @@ known shared defects to remain present. Novel Director now exposes a structured Agent Tool API for Codex/Agent use. It shares the desktop app's SQLite/JSON data, Agent Runtime, and transaction-backed commit pipeline without requiring Computer Use to read the UI. +Use `agent.getProjectUsage` for the book's recorded usage and `agent.estimateProjectUsage` for an offline, single-request estimate. Neither can read a provider balance. Story direction accepts inclusive ranges of 1–30 chapters. For official chapter titles use `agent.previewChapterTitleRename`, then `agent.applyChapterTitleRename` with the returned revision and fingerprint and a current `manage_chapters` grant. Read prior titles with `agent.getChapterTitleHistory`; renames do not create prose revisions. + +For manuscript coverage, use `agent.getBookProgress` with a project, optional chapter range and `issuesOnly: true`. A nonblank historical manuscript counts even without a formal receipt; an old manuscript does not prove that a failed revision was applied. `agent.getNextChapterTarget` returns an available order, not proof that the manuscript is complete through that point. + +For cross-chapter consistency, call `agent.getBookContinuityReport` with an optional inclusive `from` / `to`, category and status. Earlier evidence can support an in-range finding; later chapters cannot. Results reference current manuscript evidence and distinguish conflicts, missing evidence and open questions. This local read does not change the acceptance decision. + +For a single character, `agent.getWorldRecord` includes a compact `cardReview`: when the card changed, when an author last checked it, and when a confirmed fact last changed. Use `detail: "full"` for short evidence excerpts. The optional `asOfChapterOrder` limits this review, not the editable profile; reading never marks it reviewed. + ### Start ```powershell diff --git a/docs/BEGINNER_TUTORIAL.md b/docs/BEGINNER_TUTORIAL.md index 47b8eb4..cb84db7 100644 --- a/docs/BEGINNER_TUTORIAL.md +++ b/docs/BEGINNER_TUTORIAL.md @@ -72,7 +72,7 @@ Max Tokens 建议: - 2-4 个核心角色的 9 项角色卡。 - 3-8 条真正会影响近期章节的伏笔。 - 关键时间线事件。 -- 未来 5/10 章的剧情导向。 +- 下一段的剧情导向。按实际起止章节填写:结尾还剩六章就写第 91–96 章,不必凑五章或十章。 ### 先别写 diff --git a/docs/LONG_NOVEL_COMPLETION_REPAIR_PLAN.md b/docs/LONG_NOVEL_COMPLETION_REPAIR_PLAN.md new file mode 100644 index 0000000..15af864 --- /dev/null +++ b/docs/LONG_NOVEL_COMPLETION_REPAIR_PLAN.md @@ -0,0 +1,157 @@ +# 长篇完稿后的修复计划 + +核对日期:2026-11-11。范围是当前工作树,不是已发布安装包。本文是施工计划,不是功能发布说明。依据为作者的完稿反馈、代码检查和隔离的虚构样例;没有读取、回写原书或调用付费模型。以下示例不引用原书正文。 + +**最值得做的三件事:成稿对照硬规则、保住已生成的可读草稿、让作者在工作台里对全书的账。** 不再给每章 Prompt 加一页禁令,不让模型每次生成后重读整本书,也不让程序替作者改正文或确认设定。 + +## 施工进度 + +**发行状态(2026-11-11):**下面各批次的“未替换安装包”是该批源码验收时的记录。P0–P2 已合并为本地 Windows `0.1.6-preview.20`;成品、校验值和验收范围见[发行产物说明](./RELEASE_ARTIFACTS.md)。 + +**第一批已进入源码(#6、#7、#13),不代表安装包已更新。** 遇到明确字数上限,可读稿先存为候选,超限本身不触发付费重写。审稿出错后,从该次生成保存的草稿继续。以省略号、破折号或无标点结尾不再单独判截断;若服务商明确报告截断,能安全提取的片段仅供查看,不进入可采纳草稿。已有缺配置测试改用自己的假清单与假密钥。 + +隔离验收:在临时 SQLite 书库中,先让本地审稿在正文保存后失败,再让标记步骤完成的写入失败。两次重开重试后,写作模型替身总共只调用一次,书库中仍是同一条草稿。另一组在失败后先手改正文,重试只审修改后的版本,篇幅提示也重新计算;手改稿明显过短时停在本地,不发起付费复盘。供应商明确报告截断但正文片段以句号收尾,仍不能采纳。可运行 `node scripts/validate-agent-pipeline-execution.mjs --sqlite-retention` 复核。 + +Agent 从章节计划重跑,如果新计划失败,旧的不完整片段仍可查看。复用已存草稿只做本地审稿时不要求写作密钥;确实需要重写时仍会检查。可运行 `node scripts/validate-agent-draft-recovery.mjs` 复核。 + +其余回归见[保稿与正式篇幅检查](../scripts/validate-chapter-task-contract.mjs)、[桌面展示与采纳](../scripts/validate-unreviewed-draft-acceptance.mjs)、[不完整片段提取](../scripts/validate-draft-fragments.mjs)和[修订工具测试](../scripts/validate-agent-revision-tools.mjs)。 + +**第二批已进入源码(P0-B,#1、#11)。** 它处理的是一条明确规则与当前正文是否冲突,不是对自由文本设定的自动理解,也不自动修改正文。 + +1. 打开**硬设定包**,新增或编辑一条规则。旧设定仍为“只作写作提示”。在“检查方式”里选择用语或角色动作,填入要查的词、动作、章段和例外。“仅提示作者复核”不会阻止采纳;确实不能违反的规则,选“确证违规时阻断采纳”。 +2. 在**生产流水线**查看核查结果。例如,给“半步”设置全书上限后,能同时看到本章次数、已采纳正文合计、替换此章后的合计和差值。不会把已有的本章再加一次。对白、受保护的专名和设置的例外不计入用语次数,因此这不是全文搜索的命中数。角色动作分别核对主体、侧别、物件和适用状态;引语、回忆或身份不明仅供复核。只对显式条件负责,不承诺识别所有同义写法或做整篇繁简转换。 +3. 核查不受 Prompt 预算限制,也不调用模型。有确证违反时,正常采纳、未审稿采纳、正式修订和恢复都不能绕过;改掉原句或调整规则即可重新核查。**章节 → 版本历史 → 核对硬设定**用现行规则核对所选正文,不改写原审稿结论。Agent 的草稿采纳建议和版本详情(`detail: "full"`)也能读到核查证据。 +4. 桌面或 MCP 重复新增同项目、同条件且标题和内容完全相同的规则,复用原条目,不额外启用、覆盖或合并。编号变化或近似表述只给比较提示,由作者决定。不自动清理旧书里的重复项。 + +复核命令:`node scripts/run-tests.mjs hard-canon`。它覆盖[中文正反例与计数](../scripts/validate-hard-canon-mechanical.mjs)、[桌面和 MCP 录入](../scripts/validate-hard-canon-authoring.mjs)、[质量报告与原句定位](../scripts/validate-hard-canon-quality.mjs)、[SQLite / JSON 采纳、修订与恢复](../scripts/validate-hard-canon-commit-preflight.mjs)。已构建桌面程序且本机有匹配的 Electron 运行时和发布绑定时,可运行 `node scripts/qa-hard-canon-audit.mjs`,在临时书库中操作真实界面。 + +本批验收:类型检查、全量 157 个验证脚本和生产构建通过,包含 95 个机械检查用例。隔离桌面实测从表单新增禁词,在草稿中看到了命中词和所在原句;违规时“未完成审稿,直接采纳”不可用,停用该规则后恢复可用。没有读取真实作品、调用付费模型或替换分发包。 + +**第三批:P1-A,全书进度、缺章和写回状态(#3、#10)。** 桌面入口为“工作台 → 全书进度与缺章”。Agent 用 `agent.getBookProgress`(命令行 `book-progress`)查询同一份结果。可选全书目标、按章号范围筛选、只看问题;完整章段按现存正式正文计算,不再用最大章号替代进度。归档章不计数,也不冒充意外漏章。手写和导入的旧正文没有回执也照常计数。 + +连写任务在推进下一目标前核对本批前置任务的采纳回执、独立存储的版本以及与当前正文的关系。旧版草稿、其他任务和单纯“步骤完成”不能替代目标写回。后续正式修订或恢复可以沿版本链核对。单章独立创作和有意跳写仍可用。写回失败与正文状态分别展示:旧文仍在就继续计数;没有记录过的失败(包括历史版本)不反推为失败。若存储完全不可写,失败记录本身也可能无法保存,缺少正式写回证据仍不能推进连写。 + +隔离样本覆盖 96 章:第 66 章缺失、第 74 章仅有草稿、第 80 章为空、第 88 章归档、第 92 章有旧正文但新修订未写回。276 个正式字距下限还差 15,000 字。真实桌面验收中人为让第 74 章正式写入失败,确认没有半章留下;从提示重新加载,回到同一草稿再次采纳,只产生一笔正式提交。预览拒绝、未授权、版本过期等在开始写入之前拦下的操作不记作存储失败。复核见[全书服务与桌面/MCP 数据口径](../scripts/validate-book-progress.mjs)、[连写顺序](../scripts/validate-batch-writeback-order.mjs)、[失败凭据](../scripts/validate-chapter-writeback-failures.mjs)和[桌面实测](../scripts/qa-book-progress.mjs)。下一批是 **P1-B:角色资料滞后和跨章事实核对**。 + +本批源码验收:类型检查、全量 161 个验证脚本、生产构建和隔离桌面写回重试均通过。未替换安装包或 `release/win-unpacked`。 + +**第四批:P1-B,角色卡核对与全书事实核对(#2、#3、#12)。** 写作提示词和预算选择都先读角色卡九项专用字段,再按目标章取已确认的状态。同一人的同一笔、同一单位的余额只写一次;不改原卡,不用未确认收入或未来交易倒推。个人现金、替团队保管的钱、欠款和其他币种不互相抵销。无法判断是否同一笔时保留原句,提醒作者核对。 + +- **角色 → 角色卡核对**:分开显示卡片最后变化、作者核对到哪章和最近确认事实的来源。作者可以保留原卡或编辑后核对。核对只是留下核对记录,不会确认候选,也不会补造历史余额。 +- **工作台 → 全书核对**:按全书或章号范围查看钱款、人数、物件归属、季节与倒计时、人物下落和未了的承诺。能对上的数字给出差值;缺基数、缺单位或只有提及时写明缺哪项依据,不把它们判作确定矛盾。点证据可回原章;修订、恢复、归档和换序后以当前正式正文重查。Agent 的 `agent.getBookContinuityReport` 返回同一份只读结果。 +- **隔离样书复现**:小葵第 10 章有十文,第 12 章收到二十文。收入待确认时,第 13 章仍为十文;确认后为三十文,回看第 11 章仍为十文。另一份 96 章样书在第 2 章记十文,第 5 章支出三文却记剩十一文,指出应剩七文、相差四文。若修订移除了原句,旧证据不能继续证明这项矛盾。 + +本批源码验收:类型检查、168 个验证脚本、生产构建通过。两次隔离桌面验收分别验证了完整退出再启动后核对记录仍在、不同章取到各自的余额,以及从一条跨章冲突跳回两端原文。可用 `node scripts/validate-card-balance-prompt.mjs`、`node scripts/run-tests.mjs book-continuity` 复核;桌面操作见[角色卡与提示词](../scripts/qa-character-card-balance.mjs)和[全书核对](../scripts/qa-book-continuity.mjs)。没有改写用户书库或替换分发包。 + +**第五批:P2-A、P2-B,费用与收尾(#5、#8、#9、#14)。** 以下入口以当前源码为准。 + +- **工作台 → 全书模型用量**:统计本书实际发出的请求,按规划、写作、审稿、修订、提取等环节展示。失败和未采纳并不等于免费。作者可录入服务地址、模型、币种和每百万 token 价目;按当前价目计算已知部分,不换汇。不知道的费用、订阅剩余额度、工作台外调用不估造。费用预览只估下一次可准备的请求,不把它冒充整本书预算。 +- **连接失败**:区分余额不足、暂时限流、认证、域名解析和连接/读取超时。桌面使用 Electron 网络设置;只有使用支持环境代理的独立 Node 并符合条件时才提示检查相应开关。无需为查额度再发一次正文。 +- **剧情导向 → 起始章节、结束章节**:例如第 91–96 章一共六章,第 96–96 章只有一章。单条支持 1–30 章,5/10 为快捷项;长于 30 章可分段。区间不合理或生成节拍对不上时说明原因,不悄悄裁掉或补造节拍。 +- **章节 → 批量更改章名**(单章可用标题旁的改名按钮):选章、核对旧名与新名、确认提交。可以手工命名或处理章号前缀;不会改章号、正文、正文版本数。版本历史另列章名记录。Agent 同样先预览,再在授权范围内提交;重试相同操作不会再写一份。 + +**备份边界:**逐次用量收据单独保存在当前数据文件旁的同名 `.usage` 目录,不使正文写入失效。作品 JSON 导出不含该目录。整库搬迁请一起带走;旧版本未记录、服务商未返回或进程在回执落盘前中断的消耗,不能据此还原。价格调整会按新价目重新折算,不更改原始 token。 + +复核命令:`node scripts/run-tests.mjs project-usage ai-connection-diagnostics story-direction-range chapter-title-batch`。六个专项脚本覆盖逐次发送与重复回执、旧用量覆盖范围、分币种定价、Node/Electron 本地代理、订阅 CLI 替身、按章段生成和事务改名。额外的真实桌面验收分别运行[用量和取消](../scripts/qa-project-usage-pricing.mjs)、[六章和单章导向](../scripts/qa-story-direction-range.mjs),以及 `node scripts/validate-chapter-title-batch.mjs --ui`。均使用临时书库;本轮未替换安装包。 + +本批验收:类型检查、全量 174 个验证脚本和生产构建通过。隔离桌面实测确认:修改人民币价目只重算对应金额;取消预估不发请求;第 91–96 章与单独第 96 章的导向重启后仍有效;第 1–32 章改名只产生一次回执,并发修改不覆盖。未调用付费模型。 + +## 修复前核实的现状和缺口 + +| 原反馈 | 工作树中核实的情况 | 排期 | +| --- | --- | --- | +| 1. 硬规则反复被违反 | HardCanon 有启停、压缩和入 Prompt 记录,门禁也已有通用检查;“条目进过 Prompt”不等于“成稿实际遵守了条目”。缺少逐条、可定位、绑定正文版本的确定性核查。 | P0-B | +| 2. 人物卡停在旧章 | 已确认的候选能更新部分字段或账本,不是整张卡都不更新。但写作切片和预算估算均把“能力与资源”读成旧的“已知信息”,也把“角色定位”读成旧角色字段。“最近变化”不等于“最近核对”。 | P1-B | +| 3. 缺全书账 | 已有时间线、角色状态流水、伏笔和工作台“正文累计”。缺的是按正式章版本对账、比较目标、指出缺证或矛盾,并能跳回原章。 | P1-A/B | +| 4. 修订绕路 | 当前分支已有免模型手工修订提交 `0d73e20`;不据此推定安装包已更新。 | 已做;仅回归 | +| 5. 花费不透明 | 已有供应商实际返回的用量,以及“已采纳单章”的统计服务。它们不是全书账单,也不包含供应商账户余额。 | P2-A | +| 6. 超字数没留下草稿 | 正文候选在校验之后入账;显式范围的上限能触发自动重写,仍不合格会先抛错。单个目标数字本身并不产生上限。 | P0-A | +| 7. 自然结尾被判截断 | 当前本地判断看最后一个字符,不是屏蔽“开口前”。足够长的同一正文以“他停在开口前。”结尾通过;无句号或以“……”收尾均被判疑似截断。未拿原运行反推其唯一成因。 | P0-A | +| 8. 六章导向录不进 | 共享类型、编辑、Agent 校验和提示都限制 5/10。输入起止 91–96、长度 6,规范化输出为起止 91–96、长度 5。 | P2-B | +| 9. MCP 不能改正式章名 | 草稿采纳可设标题;修订候选的标题不是正式章名。现有 Agent 工具没有专门的正式章更名入口。 | P2-B | +| 10. 前章未写回也能往下跑 | 章号分配取最高章号加一;已有 65 和 67 时返回 68。部分批次进度依据 job 完成,不能据此证明前章正式正文已落盘。 | P1-A;先交付缺章检查 | +| 11. 同一硬规则可重复录入 | 已有带来源的部分精确去重;手工条目未必有来源 ID,不同编号和入口也不能靠这一条件挡住。 | P0-B | +| 12. 单章建议不能替代全书审稿 | 虚构样本中只改前章正史,当前稿的采纳建议仍为 accept;改本稿才使旧裁定失效。这符合单稿工具的边界,但跨章报告必须能发现前章被改。 | P1-B | +| 13. 缺配置测试不稳定 | 同一测试在虚构空清单下通过;登记服务、不给密钥时中文报错不匹配;给虚构密钥时进入模拟传输,未按预期拒绝。该脚本禁止网络,不能从测试失败推断实际产生了费用。 | P0 前置小修 | +| 14. 代理问题只看到 fetch failed | 当前 Agent/MCP 启动脚本已在未设置时补 Node 代理开关;桌面走 Electron 网络栈,Codex CLI 走子进程。不能只凭 fetch failed 断定三者都是代理故障。原机器当时的路由未复现。 | P2-A | + +**本地复核口径:**同一份 1,985 个可见字符的虚构正文,给定明确的 1,200–1,300 字范围时触发上限;移除该权威任务限制后通过。单独填写 1,200 时现行策略是至少 960、没有推算上限。配置测试使用隔离库、模拟传输和虚构清单;空清单运行通过 58 项(含 3 项真实临时 SQLite 检查),另两种清单复现同一“缺配置”断言的两种失败。以上均不是对整部作品或全部测试的结论。 + +## P0:先保住正文,再拦有证据的硬伤 + +前置小修(#13):整套测试入口已覆盖清单路径,直接跑子脚本却不等价。让修订测试自身显式使用独立测试清单和测试凭据来源,不继承开发机清单、主密钥或备用密钥。缺密钥断言检查“错误种类、模型调用 0 次、版本写入 0 次”;另测虚构有效密钥会走模拟传输。不要放宽断言到“随便报错就过”。不改免模型修订路线。 + +### A. 保留超限稿,区分有意留白和真实截断(#6、#7) + +1. 正文解析后先区分:**无可读正文**(空、只有大纲、坏结构)、**可读完整候选**、**有可读片段但服务商明确中断**。对可读候选先用现有生成记录保存,再做篇幅判断和后续诊断;不把半个 JSON 当成完整正文。调用失败与“稿子已生成、待处理”应有不同结果。 +2. 超出明确上限时显示“目标 1,200–1,300,实际 1,985”,保留原稿,不因字数单项自动发起第二次付费生成。作者可手修、明确要求压缩或按现有权限确认例外;作者设为硬性上限时,默认不放行正式采纳。保留现有任务契约中与篇幅无关的阻断项。 +3. 供应商明确返回长度截断时,无论最后有没有句号都标记“不完整”,能安全提取的片段单独保留,不直接采纳;无结束信号时,单凭省略号、破折号、引号或无句号不足以判失败。有结构残缺等独立证据再提示检查。不要新增结尾词黑名单。 +4. 复用现有 job、step、草稿 ID 与正文指纹。审计失败或重试时能从已保存候选继续,不再次调用写作模型,不重复插入草稿。桌面结果页、Agent 建议、正式提交沿用同一判定,不能一边显示“可处理”一边丢掉原稿。 + +**验收:**模拟模型只返回一次超长好稿,重开仍看得到原文、字数和警告;后续审稿失败再重试,写作调用数仍为 1。以 `stop`、`length`、无结束信号分别测试句号、留白、损坏 JSON、空结果;保存候选不等于采纳章节。 + +### B. 成稿逐条核对硬规则(#1、#11) + +1. 在现有 HardCanon 编辑器为条目加**可选检查条件**;第一批只做两种:字面用语/次数,以及“谁在什么条件下能做什么动作”(角色、别名、左右侧、物件、状态、适用章段)。自由文本仍可照常使用;明确标出“只作写作提示 / 可机械核查”。不自动把自然语言或 AI 候选变成阻断规则。 +2. 核查输入是**当前正文版本**与**本章适用的已启用规则**,不局限于因为预算而进了 Prompt 的子集。区别“角色甲右手持刀”“甲没有用右手持刀”“甲看到乙右手持刀”。伤势归属、持械限制、昏迷状态同理。引语、转述、指代不明、闪回或规则自身冲突,转作者复核;不能仅靠两词同段阻断。 +3. 繁简与外文、重复词是可单独启用的规则。只对明确字形/词、作者给定的阈值和例外作确定判断,不借此禁止人名、型号或对白。重复用语同时显示本章次数、已采纳全书次数和采纳此稿后的差值;不把历史版本、多个草稿或示例数字当作正式计数或默认限额。 +4. 接入原有质量门禁、修订后重审和正式采纳预检。结果带规则 ID、规则版本、正文指纹、原句位置及理由;确证违反作者的硬性规则才拦,弱证据只提示。旧报告正文或规则已改就重算本地检查,不额外调用模型,不冒充旧报告已经验证了新规则。 +5. 桌面与 MCP 的 HardCanon 新增共用重复判断:在同项目、同作用范围内,完全相同的规则提示复用现有条目;不同编号、近义写法只提示可能重复,让作者比较。不跨项目合并,不自动停用旧规则,不把条件相反的两条合并。 + +**验收:**每个可执行条件至少一对正反例;同名角色、换持刀人、否定、明确左/右侧、允许外文专名均有覆盖。删改违规句后旧证据失效;恢复历史版本可重算。两处入口录入无来源的同一规则不再制造第二个有效条目。 + +## P1:全书对账,不再靠外部脚本通读 + +### A. 先把章和字数对上(#3、#10) + +- 以本项目**未归档的当前正式正文**为计数基础,每章只计一次。已有非空手写或导入正文可以入账,旧章没有提交回执就标“历史正文”,不伪造回执。空壳、仅有草稿、写回失败、已正式写回、归档分开展示;完整区间与“可分配的新章号”分别返回。 +- 连写模式在下一章调用模型前验证前章预期提交及其版本已落盘。若第 66 章写回失败,停在 66,给出“回到该稿 / 重试提交”;第 92 章漏写而已到 96,也能直接定位。后续手工修订写回失败与“该章有旧正文”不可混作成功。保留有意跳写、补章、归档和导入;单独开章不强行按顺序,连续任务不限 2–3 章。 +- 在已有“正文累计”旁加作者可选的全书目标区间、距下限还差多少;统一首页、Agent 和章节任务显示的字数:沿用现有章节任务的去空白、含标点、按 Unicode 码点计数。虚构样本(甲、空格、换行、扩展汉字、句号)在三个入口目前分别可得 4、6、3;选区偏移量仍用原始索引,不与字数混用。可选按卷/章号范围查看,目标未知不报不足。修订、归档、恢复要替换原贡献,不把版本累加。 +- 首个交付是一张“全书进度与缺章”清单,并给 Agent 返回同一结果。不要用 `max(order)+1`、job 已完成或有候选替代正式写回证明。 + +### B. 角色核对日期、跨章事实和全书审稿(#2、#3、#12) + +| 先做什么 | 依据和处理方式 | 作者能看到什么 | +| --- | --- | --- | +| 卡片新旧 | 先让写作切片和预算估算共用同一取值:优先用九项专用字段,仅字段为空才回退到旧字段。区分“最后变化章 / 最后核对章 / 最近已确认事实章”。按本章时间点,已确认的同一状态键优先于旧卡描述;无法确认同一键时标冲突,不整段删卡。没有记录显示“未核对”,不靠更新时间猜章号;已知最后核对章就显示距当前几章,并区分期间有无已确认的相关变化。 | 卡上“核对到第 10 章,此后有一条余额变化”,可跳到证据、确认更新或保留;没变化的角色不必每章重新填卡。 | +| 钱、人数、物件 | 复用已接受的状态事实和交易,按角色、队伍、物件与单位核对;区分个人/队伍、转出/转入、明确人数/仅提到的人名。无初始金额、单位不明或少一端转移就报缺依据,不猜余额、汇率或人数。 | 数量或归属冲突给出前后两章、角色、原值和变化;草稿、被拒候选和修订前的旧版本不混进正式余额。 | +| 季节和倒计时 | 在现有时间线上按需补稳定章 ID、地点、故事时间锚点、经过时长或截止点。当前事件靠章号关联,换序要随稳定 ID 定位;迁移时有歧义就标待核。只有同一时间线、参考点可比且无跳时解释才报矛盾;回忆、异地或明确跨季的写法不误拦。 | 分开显示“第 A、B 章互相矛盾”和“两个日期之间缺时间依据”;作者可定位两处原文。 | +| 人物下落与承诺 | 分开记录最后一次**实际出场**、仅被提及、已退场/死亡、还有哪些承诺未了。未出场很久本身不是错误;只有标注待交代、承诺未结或作者选择收尾核对才提醒。 | “最后出场第 N 章”“承诺仍待处理”,可跳到来源,标明暂缓、已有交代或作者有意留白;不把所有配角建成伏笔。 | + +在现有工作台提供只读的**全书核对**入口。单章采纳建议回答“这份草稿能不能采纳”;全书核对回答“哪两处正式事实冲突,还缺哪一项证据”。桌面和 Agent 查询同一结果,可按全书、卷或章号范围查看。没有卷定义时先用章号范围。 + +老书不要求作者先重录几十章。先列已有确认事实,再从当前正式正文中找能定位的金额、期限、承诺等线索;没有账本依据就列为“待核”,不直接计入余额或承诺。查询第 N 章时先限定事实生效范围,再选事实;当前选择器在无计划等分支能选中未来状态。缺少历史流水时不能用今天的余额倒推昨天。 + +跨章结论引用两端的章节 ID、版本与原句。初次按需扫描,以后章节被修订、恢复、归档或换序时,使相关结果失效;修改前章也不能继续显示旧结论为有效。不在每章写完后调用模型通读全书。 + +**P1 验收:**隔离的 96 章样本中放入跳号、空壳、失败写回、同一章的多个版本以及接近字数下限的情形;再放入跨季、倒数天数、物件两主、人数有出入、承诺未结及它们的合理反例。报告分别给出正确章号、差值或“资料不足”。端到端验证旧卡为十文、后来余额已确认变化时,后章 Prompt 不再并列两个“当前余额”,但回看早章不带入未来值。运行全书核对不写入正史、不产生模型费用。 + +## P2:费用可解释,收尾操作不用绕行 + +**A. 用量与连接(#5、#14)** + +- **归集实耗:**通用请求还没有项目 ID,独立修订和 CLI 用量也未完整归档。新增调用记录项目、阶段、实际请求 ID;复用现有单章统计,按项目汇总写作、审稿、修订,含未采纳和重试。格式降级发出的第二个请求也应计入,同一用量上报不能重复累计。旧项目标出覆盖范围;未知不是零。 +- **调用前和调用后分开:**调用前按输入、输出范围及模型价目提示预计 token 与费用;调用后显示供应商上报用量和按价目折算的金额。币种、缓存费率和缺项写清楚。无价目不报精确金额,无供应商余额接口就显示“额度未知”。订阅 CLI 不等于按 token 扣款,工作台外的通读也不能冒充已统计。 +- **连接诊断:**区分余额耗尽、限流、DNS 和连接超时,说明是桌面、独立 Node 还是 CLI 子进程。仅在适用的 Node 版本和代理环境下提示检查 `NODE_USE_ENV_PROXY=1`。不输出密钥、代理凭据或整份环境变量,不为查余额试发付费正文。 +- **验收:**用两个虚构项目、一次未采纳修订、重试、格式降级和缺用量的 CLI 检查不串账、不错算。用回环假服务/代理测试桌面、Node 代理启用/禁用/绕过和 CLI 替身;401、429 不误报为代理故障。 + +**B. 任意合理区间与正式章名(#8、#9)** + +- 导向以起止章号为准,长度为正整数,5/10 留作快捷选项。类型、规范化、AI 节拍校验、Agent 世界资料接口与 Trace 一起改。旧 5/10 数据不变;起止与旧长度冲突时在预览中说明。验收第 91–96 章正好生成六个节拍,以及最后只剩一章的情况。 +- 正式章名支持单章和批量操作,预览列出章号、旧名、新名,经授权和版本校验后写回。作者决定是否保留“第七章”等前缀;不改章号、不调用模型,不用空的正文修订来伪装改名。旧名可追溯。测试第 1–32 章预览不写库、重复提交不产生重复历史、并发修改不覆盖新标题。 + +## 交付顺序与验证 + +| 批次 | 最小可交付结果 | 如何决定可以进入下一步 | +| --- | --- | --- | +| 1(P0 前置 + A) | 隔离修订测试;作者能捞回超限稿,不因标点多付一次费 | 用模拟供应商计数;用隔离库覆盖生成、重开、审稿重试、采纳限制 | +| 2(P0-B) | 作者能给一条硬设定配检查条件,桌面和 Agent 能看到同一原句与结论 | 正反例、规则更新、修订后重审和跨入口重复录入通过 | +| 3(P1-A) | 缺章、写回状态、目标字数同屏;自动连写不会越过失败的前章 | 导入旧书、失败提交、授权跳写、批次恢复和修订不重复计数通过 | +| 4(P1-B) | 作者在书内核对时间、人数钱物、人物承诺;已确认新余额不再被旧卡抢回 | 跨章矛盾和合理留白均有对照案例;输出有两端证据或明确说缺哪项 | +| 5(P2) | 费用和代理原因有据可查;能录任意收尾章段、能改正式章名 | 桌面/MCP 同口径;历史用量缺项、价格未知、六章导向、正式改名分别有回归 | + +实施时优先扩展 [章节任务契约验证](../scripts/validate-chapter-task-contract.mjs)、[Agent 流水线验证](../scripts/validate-agent-pipeline-execution.mjs)、[HardCanon 验证](../scripts/validate-hard-canon-pack.mjs)、[导向验证](../scripts/validate-story-direction-board.mjs) 和 [修订工具验证](../scripts/validate-agent-revision-tools.mjs)。新增一份脱敏的长篇合成样本,供全书核对与桌面/MCP 对照复用;不把作者未发表的正文放进仓库。每个改代码的批次跑定向测试、`npm.cmd run typecheck`、`npm.cmd test`、`npm.cmd run build`;只有更新分发包时才做包内启动验收。自动检查不要依赖真实供应商有无密钥、代理或余额。 + +**主要落点:**[正文生成](../src/renderer/src/views/generation/pipelineSteps/chapterGeneration.ts) 和 [草稿校验](../src/renderer/src/views/generation/pipelineUtils.ts);[HardCanon](../src/services/HardCanonPackService.ts)、[本地质量规则](../src/services/qualityGate/localQualityRules.ts) 与 [世界资料编辑](../src/services/WorldManagementService.ts);[角色字段取值](../src/services/promptFormatters/characterFormatters.ts)、[预算取值](../src/services/contextBudget/scoringEngine.ts)、[角色事实选择](../src/services/characterState/stateSelection.ts)、[现有进度统计](../src/services/DirectorNextActionService.ts)、[章号分配](../src/services/ChapterLifecycleService.ts)、[Agent 批次进度](../src/agent/AgentDecisionService.ts);[单章成本](../src/services/AcceptedChapterCostService.ts)、[导向规范化](../src/shared/normalizers/storyDirection.ts) 与 [正式修订版本](../src/services/RevisionCommitBundleService.ts)。 + +本轮不建设通用知识图谱、不迁移 SQLite 表结构、不提高模型调用次数来掩盖数据缺失,也不把免模型修订重新排期。优先看作者能否定位错句、保住稿子、对得上整本书,而不是又增加多少字段。 diff --git a/docs/REDUNDANCY_PERFORMANCE.md b/docs/REDUNDANCY_PERFORMANCE.md new file mode 100644 index 0000000..3b9d118 --- /dev/null +++ b/docs/REDUNDANCY_PERFORMANCE.md @@ -0,0 +1,61 @@ +# 重复计算与指令精简 / Redundant Work + +日期:2026-10-31。性能优化完成后,按作者要求另行打包为本地 Preview.19,更新安装包及 `release/win-unpacked`;见 [发行产物说明](./RELEASE_ARTIFACTS.md)。没有调用模型或读取作者项目。 + +## 修改 + +1. **Novelty 审计**:旧实现只缓存同一词的匹配结果,不同词仍重复规范化和分词。现在同一次同步审计对同一原文只准备一次词边界、原文偏移和句子范围;结束或抛错后释放,嵌套审计互不共享。中文识别、证据和严重程度不变。 +2. **Prompt 追踪**:复用第一次渲染结果,只对最终 Prompt 中的区块计数。删除无调用方的旧 HardCanon 格式化函数、重复伏笔排序常量和废弃类型导入。实际 HardCanon 服务和伏笔数量限制不变。 +3. **token 估算**:用字符编码区间代替逐字符正则,保留原有中文、ASCII、其他字符权重和取整方式。 +4. **修订指令**:公共事实约束只在 system 中出现;衔接、压缩、表达提示按所选类型加入,不再要求所有修订同时做所有事。全文修订不发送无用的 `targetRange: `。去 AI 味合同、缺少输入处理、局部合并与正式提交未放宽。 +5. **文档**:README 不再展示逐版本施工流水账,ROADMAP 不再把已完成能力和长期设想重复列成必做事项。原变更记录和验收文档仍可查。 + +事务、项目隔离、revision 冲突、正文与报告绑定、权限和密钥脱敏是有实际作用的校验,本轮没有删除。 + +## 实测 + +Windows x64,Ryzen 7 7800X3D,Node 24.14.0。基线是提交 `04e0a01` 中六个被修改模块;新旧实现使用同一套其余依赖。每项热身 3 轮、测量 15 轮,交替运行先后顺序。每轮深比较完整审计结果或最终 Prompt、区块记录、token 和 warnings。 + +单位 ms,单元格为 p50 / p95。这里测真实服务计算,不是页面交互或远程模型响应时间。 + +| 场景 | 修改前 | 修改后 | +| --- | --- | --- | +| 2,000 字正文 + 1,000 字上下文审计 | 15.621 / 22.111 | 3.234 / 4.424 | +| 2,000 字正文 + 8,000 字上下文审计 | 109.519 / 172.237 | 10.895 / 14.459 | +| 2,000 字正文 + 24,000 字上下文审计 | 288.227 / 542.124 | 25.513 / 42.600 | +| 24,000 字正文、短上下文审计 | 44.456 / 90.076 | 38.247 / 63.477 | +| 160,000 字正文、短上下文审计 | 107.146 / 200.670 | 107.196 / 191.921 | +| 10 章样本 Prompt 构建 | 1.919 / 3.498 | 1.172 / 1.410 | +| 100 章样本 Prompt 构建 | 3.716 / 8.004 | 2.201 / 4.053 | +| 500 章样本 Prompt 构建 | 19.099 / 21.912 | 11.081 / 15.487 | +| 24,000 字 token 估算 | 1.028 / 2.213 | 0.253 / 0.823 | + +长上下文审计中位耗时约减少 91%,500 章 Prompt 构建约减少 42%。超长正文的中位耗时没有改善,不把尾部波动算作收益。 + +短合成修订请求中,system + user 的估算 token:润色 1,044 -> 649;认知修复 1,052 -> 604;去 AI 味 2,722 -> 2,274;衔接 1,048 -> 655;删冗余 1,045 -> 658。固定指令约减少 16%–43%,长正文中的占比会更低;不是模型账单或成文质量结论。 + +复现: + +```powershell +npm.cmd run perf:redundancy +node scripts/validate-redundant-work.mjs +node scripts/validate-novelty-audit-memo.mjs +``` + +基准需要本地 Git 历史包含 `04e0a01`。原始样本、源码 hash 和环境见 [JSON](./performance/redundant-work.json)。自动化门槛检查语义等价与分词/渲染次数,不用毫秒数阻断不同机器上的 CI。 + +## 验证与限制 + +新增 5 组回归覆盖跨词分词复用、Unicode token 等价、Prompt 区块遗漏原因和按修订类型筛选指令。原有 Novelty 90 组对照及嵌套/异常清理检查通过;修订合同的 11 项检查通过。`npm.cmd test` 的 150 项验证脚本、`npm.cmd run typecheck` 和 `npm.cmd run build` 均通过,完整套件包含架构与乱码检查。额外只读代码审阅未发现本轮修改引入的回归。 + +额外使用新生产构建和隔离虚构数据启动 Electron。第二次检查完成启动、打开章节、编辑及 SQLite 保存回读,但项目切换后的按钮命中检查遇到遮挡;第一次则在自动化等待时超时。两次均无 renderer 异常、无 AI 请求,退出后没有遗留测试进程。这不是完整 UI 验收,也不是 UI 性能对照,不能用这些单次数据声明界面整体提速。记录位于 `tmp/performance/long-novel-ui/run-iIRAnL/report.json` 和 `tmp/performance/long-novel-ui/run-76e8J0/report.json`。 + +首次源码检查时,根目录 Electron 为 39.8.10,旧安装包及其 SQLite 绑定来自 39.8.9;当时使用隔离的 39.8.9 运行时。后续 Preview.19 使用 39.8.10 打包,SQLite 绑定先通过目标运行时查询,再通过成品程序的隔离启动检查。根目录 Node 绑定未替换。上述两次源码 UI 检查不冒充新版安装包验收。 + +缓存只在一次审计内,仍需扫描原文匹配不同词;超长正文和全量 AppData 读取的成本仍存在。本轮未改 UI 样式、存储协议或数据格式,未评估真实模型的文学质量。构建产物与安装包分开管理,使用旧 EXE 不会自动加载本轮源码。 + +## English + +This source-only pass reuses prepared text within each synchronous Novelty audit, removes duplicate Prompt rendering for trace metadata, replaces per-character regular expressions in token estimation, and selects revision instructions by task type. Public documentation now separates current work from historical release logs. + +Alternating before/after measurements use three warmups and fifteen iterations, with equivalent audit and writing-prompt outputs checked each time. Long-context audit median drops from 288.227ms to 25.513ms; the 500-chapter Prompt fixture drops from 19.099ms to 11.081ms. Very long body scans do not improve. Revision instruction estimates shrink 16%–43% on short synthetic requests, not on full production manuscripts. A subsequent author-requested local Preview.19 package includes these changes; see [release verification](./RELEASE_ARTIFACTS.md). No paid model calls, author data changes or GitHub publication occurred. diff --git a/docs/RELEASE_ARTIFACTS.md b/docs/RELEASE_ARTIFACTS.md index ef78e15..54b6d13 100644 --- a/docs/RELEASE_ARTIFACTS.md +++ b/docs/RELEASE_ARTIFACTS.md @@ -19,7 +19,31 @@ Novel Director 的本地目录按用途分为三层: 小说项目和 SQLite 用户数据库不存放在这些目录中,整理发行产物不会移动或删除用户数据。 -### 当前本地发行与清理(2026-10-01) +### 当前本地发行(2026-11-11) + +`0.1.6-preview.20` 合并了[长篇完稿修复 P0–P2](./LONG_NOVEL_COMPLETION_REPAIR_PLAN.md)。安装请用 `release/Novel Director Setup 0.1.6-preview.20.exe`;不安装可直接打开 `release/win-unpacked/Novel Director.exe`。打开项目,在**设置 → 运行信息**中核对应用版本、运行方式和程序位置。已安装的旧版不会因更新仓库目录而自动升级。 + +本次使用隔离依赖打包,保留命令行用的 Node SQLite 绑定。临时与正式路径的 Windows 成品均通过启动与 SQLite 检查;包内 133 个构建文件与本次生产构建逐项相符。另将 NSIS 安装包释放到临时目录,128 个应用文件与对应的 `win-unpacked` 文件逐一比较,文件名和 SHA-256 均相同。可运行 `node scripts/verify-win-installer-payload.mjs` 复核,结果见 `release/INSTALLER_PAYLOAD_CHECK.json`。安装包未签名,本次验收没有覆盖本机已安装的应用。公开分发使用 [v0.1.6-preview.20 预发布](https://github.com/SHIN09-git/novel/releases/tag/v0.1.6-preview.20);下载 Windows 安装包和同页的 `SHA256SUMS.txt` 配套校验。该标签保留安装包使用的 Electron 39.8.10 和 better-sqlite3 12.9.0,开发主线可使用更新的依赖。 + +类型检查、生产构建、174 项回归通过。正式程序的 11 项功能记录见 `release/FUNCTIONAL_ACCEPTANCE.json`:包括 946 字草稿在 800 字上限下完整保留、审稿失败重启后不重写、硬规则阻断与去重、跨章余额与写回、费用、单章和六章导向、批量改名。使用临时书库和本机模型替身,未调用付费模型。桌面验收脚本应逐个运行:多套用例同时操作窗口时出现过读取界面超时,单独重跑保稿套件 7 项均通过。 + +上一版完整保留在 `releases/archive/v0.1.6-preview.19-2026-11-11T10-41-50-069Z/`。安装包 SHA-256:`AA38B6DBCBA629591113D928617F4EA82EE9C9DCFDFD199E67A7A9CE4C0D50CE`。 + +### 历史发行(2026-10-31) + +- 当前版本:`0.1.6-preview.19`,包括重复计算优化、按类型精简的修订指令和当前已有的 UI 调整。 +- 安装包:`release/Novel Director Setup 0.1.6-preview.19.exe`;直接运行:`release/win-unpacked/Novel Director.exe`。 +- 上一版完整保留在 `releases/archive/v0.1.6-preview.18-2026-10-31T05-21-28-601Z/`。本轮没有清理其他历史归档或用户数据。 +- 使用隔离依赖目录打包,Electron 39.8.10 / SQLite 12.9.0。复用的原生绑定先通过目标 Electron 实际查询,再通过成品启动检查;根目录 Node 绑定未改动。 +- Windows NSIS 打包、临时成品与正式发行路径的启动/SQLite smoke 通过,含 preload API 与中文 Novelty 正反例。包内 110 个 `out/` 文件与本次构建逐项 SHA-256 一致。 + +类型检查、生产构建及 150/150 验证脚本通过。实际 `win-unpacked` 的七个主要页面在明暗主题下均能打开,保存 28 张截图;另完成 Prompt 构建、章节编辑自动保存、SQLite 回读及页面重载后正文一致性检查。没有 renderer 异常、AI 请求或遗留验收进程。记录:`tmp/visual-qa/hig-workspace/before-zO77gR/report.json`(页面加载与截图,不启用布局门槛)和 `tmp/preview19-functional-aMvx39/report.json`(功能复查)。 + +限制:严格布局检查在 1024px 工作台视口发现横向溢出,记录于 `tmp/visual-qa/hig-workspace/after-cyEvRw/report.json`,因此完整视觉验收没有通过。本轮保留现有 UI,没有为打包重做布局;建议使用常规桌面窗口。没有执行真实模型生成,也没有覆盖安装或卸载测试。 + +安装包 SHA-256:`86F760BE5AAE53FE2724556D40C7BC03B85A6B4D2382660DE58085A4FB77FFFB`。包仍未代码签名,也未上传 GitHub;没有覆盖系统中已有的安装目录。 + +### 历史发行与清理(2026-10-01) - 当前版本:`0.1.6-preview.18`,包含 10 月 1 日已有的 Ink Desk 界面更新。本轮保留这些设计改动,没有重做界面或修改业务规则。 - 安装包:`release/Novel Director Setup 0.1.6-preview.18.exe`。 @@ -73,7 +97,27 @@ Rebuilding the same version replaces generated output. Building a newer version Novel projects and the SQLite user database are stored elsewhere. Organizing release artifacts does not move or delete user data. -### Current Local Release and Cleanup (2026-10-01) +### Current Local Release (2026-11-11) + +Preview.20 ships the [P0–P2 long-manuscript repairs](./LONG_NOVEL_COMPLETION_REPAIR_PLAN.md). Install `release/Novel Director Setup 0.1.6-preview.20.exe` or start `release/win-unpacked/Novel Director.exe`. In an open project, Settings → Runtime information shows the application version, packaged mode and executable path. An installed older copy is not automatically updated by a new repository build. + +Both staged and final executables passed isolated startup and SQLite checks. All 133 production build files matched the application archive; all 128 files extracted from the NSIS application payload matched the unpacked release by name and SHA-256. Recheck with `node scripts/verify-win-installer-payload.mjs`; see `release/INSTALLER_PAYLOAD_CHECK.json`. The unsigned installer was not installed over an existing copy during testing. Download the Windows installer and matching `SHA256SUMS.txt` from the [v0.1.6-preview.20 prerelease](https://github.com/SHIN09-git/novel/releases/tag/v0.1.6-preview.20). The tag retains the binary's Electron 39.8.10 and better-sqlite3 12.9.0; the development branch may use newer dependencies. The complete Preview.19 rollback is at `releases/archive/v0.1.6-preview.19-2026-11-11T10-41-50-069Z/`. + +Type checking, the production build and 174 regression scripts passed. `release/FUNCTIONAL_ACCEPTANCE.json` records 11 feature checks against the packaged runtime, including draft retention across a failed review and application restart. The tests used disposable books and loopback model responses, not a live provider. Run desktop suites sequentially: two UI reads timed out during overlapping runs; the standalone retention suite passed all seven cases. + +### Historical Release (2026-10-31) + +Preview.19 includes the duplicate-work optimizations, task-specific revision instructions and existing UI changes. Use `release/Novel Director Setup 0.1.6-preview.19.exe` or `release/win-unpacked/Novel Director.exe`. Preview.18 is preserved intact at `releases/archive/v0.1.6-preview.18-2026-10-31T05-21-28-601Z/`; no author data or other archives were removed. + +An isolated dependency tree packages Electron 39.8.10 with SQLite 12.9.0. The reused binding passes a real target-runtime query and packaged SQLite startup; the repository's Node binding is unchanged. NSIS packaging and both staged/published startup checks pass, including preload APIs and Chinese Novelty positive/negative cases. All 110 bundled output files match the current build by SHA-256. + +Typecheck, production build and all 150 validation scripts pass. Seven main routes open in both themes with 28 screenshots; separate packaged checks build a Prompt, edit and persist a chapter, verify SQLite readback and reload the saved text. No renderer exceptions, AI calls or surviving test processes. Evidence: `tmp/visual-qa/hig-workspace/before-zO77gR/report.json` (page loads/screenshots with layout gates disabled) and `tmp/preview19-functional-aMvx39/report.json`. + +The strict 1024px dashboard layout check still reports horizontal overflow (`tmp/visual-qa/hig-workspace/after-cyEvRw/report.json`), so this is not a full visual acceptance pass. Existing UI is preserved; use a normal desktop window. Paid-model generation and installer upgrade/uninstall lifecycle were not tested. + +Installer SHA-256: `86F760BE5AAE53FE2724556D40C7BC03B85A6B4D2382660DE58085A4FB77FFFB`. Still unsigned and local only; no GitHub upload or overwrite of an existing system installation. + +### Historical Release and Cleanup (2026-10-01) Preview.18 packages the existing October 1 Ink Desk UI updates without replacing the design or changing business rules. The current installer is `release/Novel Director Setup 0.1.6-preview.18.exe`; the executable is `release/win-unpacked/Novel Director.exe`. The complete Preview.17 rollback package remains in `releases/archive/v0.1.6-preview.17-2026-10-01T08-15-54-270Z/`. diff --git a/docs/performance/redundant-work.json b/docs/performance/redundant-work.json new file mode 100644 index 0000000..abfde63 --- /dev/null +++ b/docs/performance/redundant-work.json @@ -0,0 +1,561 @@ +{ + "benchmark": "redundant-work", + "measuredAt": "2026-10-31T05:05:27.444Z", + "baseline": "04e0a01", + "environment": { + "node": "v24.14.0", + "platform": "win32", + "arch": "x64", + "cpu": "AMD Ryzen 7 7800X3D 8-Core Processor " + }, + "method": { + "warmups": 3, + "iterations": 15, + "clock": "performance.now", + "order": "alternating before/after", + "scope": "Real services under Node, not UI latency. Baseline replaces only the six listed modules with committed source.", + "data": "Synthetic fixtures only; no user data, models or network. Outputs compared on every iteration except intentional revision prompt changes.", + "regression": "Semantic parity and operation counts are gates; timings are observational." + }, + "sources": { + "src/services/novelty/noveltyMatcher.ts": { + "beforeSha256": "2bfadfef8d1b0789d6f04d84d5596762c6223613be69f899f6903322381a4336", + "afterSha256": "497fe7f954c3f9d0e7a4ae77cfad5cafde030ed4e0f63b93a6478ba282f19c1e" + }, + "src/services/novelty/noveltyText.ts": { + "beforeSha256": "a0a17acec5409f0e67499b15201a1dc344c641eec88f44dac29ba10851286310", + "afterSha256": "79d834f84450abe4bc36c6bdf716f302bac49792e07e8fca92a3f7666b22f4dd" + }, + "src/services/TokenEstimator.ts": { + "beforeSha256": "17f0c4a7b8035fcf7f9016e773fb1e13e3d9905fed6d26ef80431fb60cda17db", + "afterSha256": "73b702db2e135c61ecee9d95d84dae547828d571a41546c20106498cd544193e" + }, + "src/services/PromptBuilderService.ts": { + "beforeSha256": "c2858e1d6e221de8ddd3b6651e5f3bbfea72450dac670e8bbbc39277a7ae29d2", + "afterSha256": "6496cb634a9ade429d095b4d5e061b79ff1273e4e92fe0c6d3cdda67b4cf43ba" + }, + "src/services/promptFormatters/promptUtils.ts": { + "beforeSha256": "33b0780b4f16bbad473e2c75c6c6f55c1a3af4e75ca74046a92aec1f95e4bb58", + "afterSha256": "f5f29459a5ffca79f16c99f098ef3f4e555921b7330809da749d3dc243289187" + }, + "src/services/ai/AIPromptTemplates.ts": { + "beforeSha256": "c56d4b80692810dc0bd8b05bab747dfee06fa817fcde91890d5a72a73fbb638e", + "afterSha256": "e8c8ebafdaf3bc0ce12a96be10ba52a2ba55f8e5ed86d8f381f59ced02933307" + } + }, + "results": [ + { + "name": "novelty-context-1000", + "bodyCharacters": 2000, + "contextCharacters": 1000, + "before": { + "p50Ms": 15.621, + "p95Ms": 22.111, + "samplesMs": [ + 22.111, + 18.808, + 20.792, + 16.36, + 16.274, + 16.359, + 14.241, + 11.496, + 14.742, + 15.621, + 13.851, + 12.915, + 16.444, + 14.679, + 12.774 + ] + }, + "after": { + "p50Ms": 3.234, + "p95Ms": 4.424, + "samplesMs": [ + 4.424, + 4.146, + 4.197, + 3.234, + 4.324, + 2.366, + 2.408, + 3.888, + 2.382, + 2.628, + 2.257, + 3.179, + 3.682, + 2.14, + 3.509 + ] + }, + "outputHash": "754cad4ab761a3357d6644006e11748825b7bfe0a03dee28358bf137b47e5b24" + }, + { + "name": "novelty-context-8000", + "bodyCharacters": 2000, + "contextCharacters": 8000, + "before": { + "p50Ms": 109.519, + "p95Ms": 172.237, + "samplesMs": [ + 128.411, + 131.531, + 172.237, + 159.715, + 162.221, + 114.931, + 114.875, + 109.519, + 104.312, + 101.321, + 92.512, + 91.615, + 93.187, + 105.516, + 85.732 + ] + }, + "after": { + "p50Ms": 10.895, + "p95Ms": 14.459, + "samplesMs": [ + 11.509, + 14.459, + 13.021, + 11.372, + 13.456, + 10.747, + 10.895, + 12.008, + 8.42, + 10.193, + 9.114, + 8.55, + 11.413, + 8.916, + 8.863 + ] + }, + "outputHash": "754cad4ab761a3357d6644006e11748825b7bfe0a03dee28358bf137b47e5b24" + }, + { + "name": "novelty-context-24000", + "bodyCharacters": 2000, + "contextCharacters": 24000, + "before": { + "p50Ms": 288.227, + "p95Ms": 542.124, + "samplesMs": [ + 542.124, + 487.992, + 452.371, + 453.884, + 310.154, + 288.426, + 288.47, + 286.242, + 275.733, + 288.227, + 269.822, + 264.83, + 259.519, + 271.238, + 280.37 + ] + }, + "after": { + "p50Ms": 25.513, + "p95Ms": 42.6, + "samplesMs": [ + 42.6, + 37.235, + 34.446, + 33.149, + 25.184, + 25.072, + 29.014, + 25.352, + 26.49, + 27.788, + 25.513, + 22.587, + 23.078, + 24.446, + 24.69 + ] + }, + "outputHash": "754cad4ab761a3357d6644006e11748825b7bfe0a03dee28358bf137b47e5b24" + }, + { + "name": "novelty-body-2000", + "bodyCharacters": 2000, + "contextCharacters": 39, + "before": { + "p50Ms": 2.302, + "p95Ms": 3.196, + "samplesMs": [ + 3.167, + 2.974, + 3.196, + 2.163, + 2.436, + 2.094, + 1.935, + 2.678, + 3.07, + 2.193, + 2.083, + 2.502, + 2.302, + 1.742, + 2.187 + ] + }, + "after": { + "p50Ms": 1.636, + "p95Ms": 3.658, + "samplesMs": [ + 1.827, + 2.254, + 1.296, + 2.556, + 2.197, + 1.37, + 1.51, + 1.769, + 1.633, + 1.636, + 1.602, + 2.009, + 1.443, + 1.438, + 3.658 + ] + }, + "outputHash": "754cad4ab761a3357d6644006e11748825b7bfe0a03dee28358bf137b47e5b24" + }, + { + "name": "novelty-body-24000", + "bodyCharacters": 24000, + "contextCharacters": 39, + "before": { + "p50Ms": 44.456, + "p95Ms": 90.076, + "samplesMs": [ + 15.829, + 60.828, + 55.939, + 35.339, + 73.213, + 39.23, + 44.456, + 48.885, + 90.076, + 54.74, + 40.869, + 70.566, + 38.54, + 35.324, + 36.841 + ] + }, + "after": { + "p50Ms": 38.247, + "p95Ms": 63.477, + "samplesMs": [ + 14.453, + 23.421, + 30.611, + 26.974, + 45.203, + 42.767, + 45.609, + 58.546, + 63.477, + 42.129, + 35.18, + 38.247, + 52.479, + 30.934, + 33.775 + ] + }, + "outputHash": "754cad4ab761a3357d6644006e11748825b7bfe0a03dee28358bf137b47e5b24" + }, + { + "name": "novelty-body-160000", + "bodyCharacters": 160000, + "contextCharacters": 39, + "before": { + "p50Ms": 107.146, + "p95Ms": 200.67, + "samplesMs": [ + 200.67, + 200.644, + 178.671, + 118.601, + 107.334, + 140.946, + 105.604, + 96.116, + 100.088, + 97.794, + 98.248, + 121.072, + 82.411, + 86.67, + 107.146 + ] + }, + "after": { + "p50Ms": 107.196, + "p95Ms": 191.921, + "samplesMs": [ + 191.921, + 176, + 178.498, + 149.746, + 107.725, + 128.874, + 97.15, + 95.016, + 100.034, + 101.563, + 108.531, + 101.093, + 84.683, + 95.872, + 107.196 + ] + }, + "outputHash": "754cad4ab761a3357d6644006e11748825b7bfe0a03dee28358bf137b47e5b24" + }, + { + "name": "prompt-10-chapters", + "chapterCount": 10, + "before": { + "p50Ms": 1.919, + "p95Ms": 3.498, + "samplesMs": [ + 2.46, + 1.96, + 1.906, + 1.919, + 1.964, + 1.997, + 1.874, + 1.838, + 1.95, + 1.69, + 1.716, + 3.498, + 3.353, + 1.71, + 1.79 + ] + }, + "after": { + "p50Ms": 1.172, + "p95Ms": 1.41, + "samplesMs": [ + 1.377, + 1.258, + 1.209, + 1.327, + 1.284, + 1.235, + 1.172, + 1.152, + 1.084, + 1.06, + 1.051, + 1.41, + 1.111, + 1.041, + 1.098 + ] + }, + "outputHash": "828c638052adffeb716f036d1a95a830e23b4127ce5aa1d040259d742c3937fc" + }, + { + "name": "prompt-100-chapters", + "chapterCount": 100, + "before": { + "p50Ms": 3.716, + "p95Ms": 8.004, + "samplesMs": [ + 3.433, + 5.755, + 4.037, + 8.004, + 4.657, + 4.235, + 3.33, + 3.255, + 3.531, + 3.186, + 4.114, + 3.306, + 3.716, + 3.846, + 3.171 + ] + }, + "after": { + "p50Ms": 2.201, + "p95Ms": 4.053, + "samplesMs": [ + 2.201, + 2.261, + 2.461, + 2.518, + 2.274, + 2.232, + 2.013, + 1.878, + 1.84, + 1.925, + 1.856, + 1.807, + 3.089, + 4.053, + 1.814 + ] + }, + "outputHash": "5eeaa534d30861ce92471a510955a93429642bca9e9a10c816fc11a48aeff14b" + }, + { + "name": "prompt-500-chapters", + "chapterCount": 500, + "before": { + "p50Ms": 19.099, + "p95Ms": 21.912, + "samplesMs": [ + 13.731, + 19.55, + 19.993, + 17.241, + 18.514, + 21.015, + 18.73, + 19.876, + 14.908, + 17.172, + 21.912, + 21.624, + 19.099, + 16.34, + 21.202 + ] + }, + "after": { + "p50Ms": 11.081, + "p95Ms": 15.487, + "samplesMs": [ + 11.392, + 6.105, + 13.198, + 9.735, + 11.198, + 10.601, + 9.832, + 11.205, + 9.207, + 8.326, + 11.081, + 10.65, + 15.487, + 11.316, + 13.731 + ] + }, + "outputHash": "cbf5b55b770f75444626bcb2882382f14aec44d9daadfa437ff5353cc5a7655a" + }, + { + "name": "token-estimate-24000", + "characters": 24000, + "before": { + "p50Ms": 1.028, + "p95Ms": 2.213, + "samplesMs": [ + 0.604, + 2.213, + 1.111, + 1.028, + 0.98, + 1.054, + 1.27, + 1.174, + 0.96, + 0.96, + 0.93, + 0.967, + 1.664, + 1.069, + 0.969 + ] + }, + "after": { + "p50Ms": 0.253, + "p95Ms": 0.823, + "samplesMs": [ + 0.233, + 0.245, + 0.294, + 0.285, + 0.275, + 0.823, + 0.264, + 0.252, + 0.222, + 0.232, + 0.231, + 0.239, + 0.253, + 0.259, + 0.464 + ] + }, + "outputHash": "e0bcfbc4c42f4580511edf2da369b56b68566c19c524d9edc809eb8dad8b8193" + } + ], + "revisionPrompts": [ + { + "type": "polish_style", + "beforeCharacters": 980, + "afterCharacters": 674, + "beforeTokens": 1044, + "afterTokens": 649 + }, + { + "type": "fix_character_knowledge", + "beforeCharacters": 995, + "afterCharacters": 650, + "beforeTokens": 1052, + "afterTokens": 604 + }, + { + "type": "reduce_ai_tone", + "beforeCharacters": 2369, + "afterCharacters": 2024, + "beforeTokens": 2722, + "afterTokens": 2274 + }, + { + "type": "improve_continuity", + "beforeCharacters": 988, + "afterCharacters": 683, + "beforeTokens": 1048, + "afterTokens": 655 + }, + { + "type": "reduce_redundancy", + "beforeCharacters": 985, + "afterCharacters": 685, + "beforeTokens": 1045, + "afterTokens": 658 + } + ] +} diff --git a/package-lock.json b/package-lock.json index 62e3b31..eeca0fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "novel-director", - "version": "0.1.6-preview.18", + "version": "0.1.6-preview.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "novel-director", - "version": "0.1.6-preview.18", + "version": "0.1.6-preview.20", "license": "MIT", "dependencies": { "@vitejs/plugin-react": "^5.1.1", diff --git a/package.json b/package.json index 162b561..e574ca4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "novel-director", - "version": "0.1.6-preview.18", + "version": "0.1.6-preview.20", "productName": "Novel Director", "description": "AI long-form novel creation management desktop MVP", "license": "MIT", @@ -32,6 +32,7 @@ "perf:novelty": "node scripts/benchmark-novelty-audit.mjs --output tmp/performance/novelty-audit.json", "perf:ui": "node scripts/benchmark-production-ui.mjs --output tmp/performance/production-ui.json", "perf:prompt": "node scripts/benchmark-prompt-composition.mjs --output tmp/performance/prompt-composition.json", + "perf:redundancy": "node scripts/benchmark-redundant-work.mjs --baseline-ref 04e0a01 --output tmp/performance/redundant-work.json", "perf:post-draft": "node scripts/benchmark-post-draft-analysis.mjs --output tmp/performance/post-draft-analysis.json", "perf:storage": "node scripts/benchmark-long-novel-storage.mjs", "perf:backend": "node scripts/benchmark-backend-checkpoints.mjs", diff --git a/scripts/benchmark-redundant-work.mjs b/scripts/benchmark-redundant-work.mjs new file mode 100644 index 0000000..c0a42e1 --- /dev/null +++ b/scripts/benchmark-redundant-work.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { performance } from 'node:perf_hooks' +import { dirname, join, relative, resolve } from 'node:path' +import os from 'node:os' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' +import { createLongNovelFixture } from './fixtures/long-novel-performance.mjs' + +const argument = (name, fallback) => { + const index = process.argv.indexOf(name) + return index < 0 ? fallback : process.argv[index + 1] +} +const baseline = argument('--baseline-ref', execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim()) +assert(/^[a-f\d]{7,40}$/.test(baseline), 'Use a commit hash as --baseline-ref') +const output = resolve(repoRoot, argument('--output', 'tmp/performance/redundant-work.json')) +const paths = [ + 'src/services/novelty/noveltyMatcher.ts', 'src/services/novelty/noveltyText.ts', + 'src/services/TokenEstimator.ts', 'src/services/PromptBuilderService.ts', + 'src/services/promptFormatters/promptUtils.ts', 'src/services/ai/AIPromptTemplates.ts' +] +const referenceSources = new Map(paths.map((path) => [path, + execFileSync('git', ['show', `${baseline}:${path}`], { cwd: repoRoot, encoding: 'utf8' })])) +async function load(reference) { + const result = await build({ + stdin: { resolveDir: repoRoot, loader: 'ts', contents: ` + export { NoveltyDetector } from './src/services/NoveltyDetector'; + export { TokenEstimator } from './src/services/TokenEstimator'; + export { PromptBuilderService } from './src/services/PromptBuilderService'; + export { REVISION_SYSTEM_PROMPT, buildRevisionUserPrompt } from './src/services/ai/AIPromptTemplates'; + export { normalizeAppData, createEmptyBible } from './src/shared/defaults'; + ` }, bundle: true, write: false, format: 'esm', platform: 'node', target: 'node24', logLevel: 'silent', + plugins: reference ? [{ name: 'baseline', setup(builder) { + builder.onLoad({ filter: /\.ts$/ }, ({ path }) => { + const contents = referenceSources.get(relative(repoRoot, path).replaceAll('\\', '/')) + return contents === undefined ? undefined : { contents, loader: 'ts' } + }) + } }] : [] + }) + return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`) +} +const before = await load(true), after = await load(false) +const warmups = 3, iterations = 15 +function stats(values) { + const sorted = [...values].sort((a, b) => a - b) + const round = (n) => Number(n.toFixed(3)) + return { p50Ms: round(sorted[Math.ceil(sorted.length * 0.5) - 1]), + p95Ms: round(sorted[Math.ceil(sorted.length * 0.95) - 1]), samplesMs: values.map(round) } +} +function measure(name, run, dimensions) { + const durations = { before: [], after: [] } + let last + for (let i = -warmups; i < iterations; i++) { + const result = {} + for (const label of i % 2 === 0 ? ['before', 'after'] : ['after', 'before']) { + const start = performance.now() + result[label] = run(label === 'before' ? before : after) + if (i >= 0) durations[label].push(performance.now() - start) + } + assert.deepEqual(result.after, result.before, `${name}: observable output must not change`) + last = result.after + } + const row = { name, ...dimensions, before: stats(durations.before), after: stats(durations.after), + outputHash: createHash('sha256').update(JSON.stringify(last)).digest('hex') } + console.log(`${name}: p50 ${row.before.p50Ms} -> ${row.after.p50Ms} ms`) + return row +} +const repeat = (text, length) => text.repeat(Math.ceil(length / text.length)).slice(0, length) +const body = '门禁倒计时只剩三秒,系统面板突然弹出附加条款:主角获得临时权限,可以立即强制放行。区域管理员-03宣布核心单元可携带三名协同单元,共享身份后直接脱困。档案显示意识剥离后的记忆会进入冗余池。' +const context = '站台记录了雨声和脚步。值班记录写明门口的灯已经修好,旅客仍在等待下一班列车。\n' +const results = [] +for (const length of [1000, 8000, 24000]) { + const input = { generatedText: repeat(body, 2000), context: repeat(context, length), chapterPlan: null } + results.push(measure(`novelty-context-${length}`, (api) => api.NoveltyDetector.audit(input), + { bodyCharacters: input.generatedText.length, contextCharacters: length })) +} +for (const length of [2000, 24000, 160000]) { + const input = { generatedText: repeat(body, length), context, chapterPlan: null } + results.push(measure(`novelty-body-${length}`, (api) => api.NoveltyDetector.audit(input), + { bodyCharacters: length, contextCharacters: context.length })) +} +for (const size of [10, 100, 500]) { + const data = after.normalizeAppData(createLongNovelFixture(size)) + const project = data.projects[0] + const input = { ...data, project, bible: after.createEmptyBible(project.id), + config: { projectId: project.id, targetChapterOrder: size + 1, mode: 'standard', + selectedCharacterIds: data.characters.slice(0, 2).map((item) => item.id), + selectedForeshadowingIds: data.foreshadowings.slice(-10).map((item) => item.id), + modules: Object.fromEntries(['bible', 'progress', 'recentChapters', 'characters', 'foreshadowing', + 'stageSummaries', 'timeline', 'chapterTask', 'forbidden', 'outputFormat'].map((key) => [key, true])), + task: { goal: '林砚与苏宁核对车票的来源', conflict: '日期与记录不符', suspenseToKeep: '报时声的原因', + allowedPayoffs: '', forbiddenPayoffs: '', endingHook: '门外传来敲门声', readerEmotion: '紧张', + targetWordCount: '3000', styleRequirement: '简洁' } + } + } + results.push(measure(`prompt-${size}-chapters`, (api) => { + const result = api.PromptBuilderService.buildResult(input) + return { finalPrompt: result.finalPrompt, promptBlockOrder: result.promptBlockOrder, + estimatedTokens: result.estimatedTokens, warnings: result.warnings } + }, { chapterCount: size })) +} +results.push(measure('token-estimate-24000', (api) => api.TokenEstimator.estimate(repeat(body, 24000)), { characters: 24000 })) +const revisionPrompts = ['polish_style', 'fix_character_knowledge', 'reduce_ai_tone', 'improve_continuity', 'reduce_redundancy'] + .map((type) => { + const request = { type, instruction: '按所选类型修改', revisionScope: 'local', + fullChapterText: '开头。目标句。结尾。', targetRange: '目标句。' } + const prompt = (api) => `${api.REVISION_SYSTEM_PROMPT}\n${api.buildRevisionUserPrompt(request, '已知记录')}` + const oldText = prompt(before), newText = prompt(after) + return { type, beforeCharacters: oldText.length, afterCharacters: newText.length, + beforeTokens: after.TokenEstimator.estimate(oldText), afterTokens: after.TokenEstimator.estimate(newText) } + }) +const report = { benchmark: 'redundant-work', measuredAt: new Date().toISOString(), baseline, + environment: { node: process.version, platform: process.platform, arch: process.arch, cpu: os.cpus()[0]?.model }, + method: { warmups, iterations, clock: 'performance.now', order: 'alternating before/after', + scope: 'Real services under Node, not UI latency. Baseline replaces only the six listed modules with committed source.', + data: 'Synthetic fixtures only; no user data, models or network. Outputs compared on every iteration except intentional revision prompt changes.', + regression: 'Semantic parity and operation counts are gates; timings are observational.' }, + sources: {}, results, revisionPrompts } +for (const path of paths) report.sources[path] = { + beforeSha256: createHash('sha256').update(referenceSources.get(path)).digest('hex'), + afterSha256: createHash('sha256').update(await readFile(join(repoRoot, path))).digest('hex') +} +await mkdir(dirname(output), { recursive: true }) +await writeFile(output, JSON.stringify(report, null, 2) + '\n') +console.log(`Raw results: ${output}`) diff --git a/scripts/fixtures/book-continuity-surfaces.mjs b/scripts/fixtures/book-continuity-surfaces.mjs new file mode 100644 index 0000000..1f719a6 --- /dev/null +++ b/scripts/fixtures/book-continuity-surfaces.mjs @@ -0,0 +1,45 @@ +// Synthetic books with overlapping chapter numbers. No text, IDs or credentials from a real library. +export const auditBook = 'audit-a', oldBook = 'audit-b' +export const firstQuote = '顾宁数清袋里的余额,一共10文钱。' +export const laterQuote = '顾宁支出3文钱,账本上却记着余额11文钱。' +export const promiseQuote = '顾宁答应明日归还铜钥匙。' +export const oldQuote = '另一册的账上只剩27文钱。' +export const oldCountdown = '渡口的期限还剩137天。' +const at = '2026-10-01T00:00:00.000Z' +const dates = { createdAt: at, updatedAt: at } +const chapter = (projectId, order, body) => ({ id: projectId + '-' + order, projectId, order, + title: '渡口手记 ' + order, body, ...dates }) + +export function continuitySurfaceFixture() { + const projectId = auditBook, characterId = 'keeper' + const chapters = Array.from({ length: 96 }, (_, i) => chapter(projectId, i + 1, '渡口的灯照常亮起。')) + chapters[1].body = firstQuote + '\n木桌上压着一页封存附记。' + chapters[4].body = laterQuote + '\n' + promiseQuote + '\n门外有人叩了三声。' + const fact = (id, n, evidence, patch) => ({ id, projectId, characterId, key: id, label: '现金', + category: 'resource', valueType: 'number', value: 10, unit: '文', status: 'active', + sourceChapterId: chapters[n - 1].id, sourceChapterOrder: n, evidence, ...dates, ...patch }) + const tx = (id, factId, n, evidence, patch) => ({ id, projectId, characterId, factId, chapterId: chapters[n - 1].id, + chapterOrder: n, transactionType: 'decrement', beforeValue: 10, afterValue: 11, delta: -3, evidence, + status: 'accepted', ...dates, ...patch }) + const characterStateFacts = [fact('cash', 2, firstQuote), + fact('return-key', 5, promiseQuote, { category: 'promise', label: '归还铜钥匙', valueType: 'text', value: '尚未归还', unit: '' })] + const characterStateTransactions = [tx('spent', 'cash', 5, laterQuote)] + for (let n = 10; n <= 24; n++) { + const quote = `第${n}袋钱支出2文钱。` + chapters[n - 1].body = quote + '账页的背面是空的。' + characterStateFacts.push(fact('cash-' + n, n, quote, { value: 8, label: '钱袋 ' + n })) + characterStateTransactions.push(tx('spent-' + n, 'cash-' + n, n, quote, { afterValue: 8, delta: -2 })) + } + chapters[25].body = '归档陷阱剩下99文钱。'; chapters[25].archivedAt = at + chapters.push(chapter(oldBook, 2, oldQuote), chapter(oldBook, 5, oldCountdown)) + const version = { id: 'current-v5', projectId, chapterId: chapters[4].id, title: chapters[4].title, + body: chapters[4].body, source: 'accept_draft', createdAt: at, note: '' } + return { + projects: [{ id: auditBook, name: '渡口核对样书', ...dates }, { id: oldBook, name: '无账本旧书', ...dates }], + chapters, characters: [{ id: characterId, projectId, name: '顾宁', ...dates }], + characterStateFacts, characterStateTransactions, + chapterVersions: [version, { ...version, id: 'superseded-v5', body: '旧版陷阱余额999文钱。' }], + generatedChapterDrafts: [{ id: 'draft-trap', projectId, jobId: 'spare', chapterId: chapters[4].id, title: '候选', + body: '草稿陷阱余额555文钱。', status: 'draft', ...dates }] + } +} diff --git a/scripts/fixtures/book-progress.mjs b/scripts/fixtures/book-progress.mjs new file mode 100644 index 0000000..1c342e7 --- /dev/null +++ b/scripts/fixtures/book-progress.mjs @@ -0,0 +1,25 @@ +// Synthetic only. Order 66 is missing; a failed revision of 92 leaves its old text intact. +export const progressBookId = 'progress-book' +export const fixtureTime = '2026-10-01T10:00:00.000Z' +export const wideText = '甲 \n𠮷。' +export function bookProgressFixture() { + const p = progressBookId, t = fixtureTime + const chapter = order => ({ id: 'c' + order, projectId: p, order, title: '样章 ' + order, body: wideText, + summary: '', newInformation: '', characterChanges: '', newForeshadowing: '', resolvedForeshadowing: '', + endingHook: '', riskWarnings: '', includedInStageSummary: false, createdAt: t, updatedAt: t }) + const job = order => ({ id: 'j' + order, projectId: p, targetChapterOrder: order, status: 'completed', + currentStep: 'await_user_confirmation', createdAt: t, updatedAt: t }) + const draft = order => ({ id: 'd' + order, projectId: p, chapterId: null, jobId: 'j' + order, title: '草稿 ' + order, + body: '这是只存在草稿里的内容。', status: 'draft', createdAt: t, updatedAt: t }) + const chapters = Array.from({ length: 96 }, (_, i) => i + 1).filter(n => n !== 66 && n !== 74).map(chapter) + Object.assign(chapters.find(c => c.order === 80), { body: ' \n ' }) + Object.assign(chapters.find(c => c.order === 88), { archivedAt: t }) + return { projects: [{ id: p, name: '全书进度样书', bookWordTarget: { min: 15276, max: 20000 }, createdAt: t, updatedAt: t }, + { id: 'other-book', name: '另一本样书', createdAt: t, updatedAt: t }], + chapters: [...chapters, { ...chapter(66), id: 'other-c66', projectId: 'other-book', body: '不能串书。' }], + chapterGenerationJobs: [job(66), job(74), job(92)], + generatedChapterDrafts: [draft(74), { ...draft(92), body: '重写。' }, { ...draft(66), status: 'rejected' }], + chapterWritebackFailures: [{ id: 'sample-failure', projectId: p, chapterId: 'c92', chapterOrder: 92, + kind: 'revision_commit', commitId: 'failed-r92', versionId: 'failed-v92', revisionSessionId: 's92', revisionVersionId: 'rv92', + attemptedAt: t, reason: '正式修订未写入。' }] } +} diff --git a/scripts/fixtures/card-balance.mjs b/scripts/fixtures/card-balance.mjs new file mode 100644 index 0000000..89a2445 --- /dev/null +++ b/scripts/fixtures/card-balance.mjs @@ -0,0 +1,23 @@ +// Two official chapter observations in a synthetic book. Both desktop QA and contract tests use the actual acceptance operation. +export function cardBalanceFixture(normalizeAppData, CharacterStateService) { + const p = 'book', c = 'kui', stamp = '2026-01-01T00:00:00Z' + const chapter = (order, body) => ({ id: 'c' + order, projectId: p, order, title: '样章', body, + summary: '', newInformation: '', characterChanges: '', newForeshadowing: '', resolvedForeshadowing: '', + endingHook: '', riskWarnings: '', createdAt: stamp, updatedAt: stamp }) + const log = { id: 'initial', projectId: p, characterId: c, chapterId: 'c10', chapterOrder: 10, note: '小葵的钱只剩十文。', createdAt: stamp } + const initial = normalizeAppData({ projects: [{ id: p, name: '试算样书' }], + chapters: [chapter(10, log.note), chapter(12, '她挣了二十文,钱袋里有三十文。')], + characters: [{ id: c, projectId: p, name: '小葵', isMain: true, lastChangedChapter: 10, role: '过期角色定位', + roleFunction: '船上账房', abilitiesAndResources: '钱只剩十文;擅长辨银;替船队保管五十文。', knownInformation: '知道过桥暗号。', + relationshipTension: '信任掌柜', protagonistRelationship: '旧关系' }], + characterStateLogs: [log] }) + const before = CharacterStateService.createFactFromLog(log, { id: 'cash', projectId: p, characterId: c, + key: 'cash', label: '随身现金', category: 'resource', valueType: 'number', value: 10, unit: '文', + trackingLevel: 'hard', promptPolicy: 'always' }, initial) + const candidate = { id: 'earned', projectId: p, characterId: c, chapterId: 'c12', chapterOrder: 12, candidateType: 'transaction', + targetFactId: 'cash', proposedFact: null, proposedTransaction: null, beforeValue: 10, afterValue: 30, + evidence: before.chapters[1].body, confidence: 1, riskLevel: 'low', status: 'pending', createdAt: stamp, updatedAt: stamp } + const pending = { ...before, characterStateChangeCandidates: [candidate] } + const data = CharacterStateService.applyStateChangeCandidate(candidate.id, pending) + return { data, pending, p, c, log } +} diff --git a/scripts/qa-book-continuity.mjs b/scripts/qa-book-continuity.mjs new file mode 100644 index 0000000..c58e895 --- /dev/null +++ b/scripts/qa-book-continuity.mjs @@ -0,0 +1,167 @@ +// Only preview builds into a disposable directory. --packaged uses the existing release as-is. +import assert from 'node:assert/strict' +import { mkdir, mkdtemp } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join, relative } from 'node:path' +import { repoRoot as root } from './utils/repo-root.mjs' +import { qaRuntimeTarget, targetIdentity, prepareTarget, writeOfflineQaProfile, connectQaSession, verifyQaOffline, cleanupQaRun } from './utils/long-novel-ui-runtime.mjs' +import { launch, evaluate, click, button, key, ready } from './utils/long-novel-ui-driver.mjs' +import { auditBook, oldBook, continuitySurfaceFixture, firstQuote, laterQuote, oldQuote } from './fixtures/book-continuity-surfaces.mjs' + +const Database = createRequire(import.meta.url)('better-sqlite3') +const base = join(root, 'tmp') +await mkdir(base, { recursive: true }) +const work = await mkdtemp(join(base, 'continuity-electron-')), profile = join(work, 'profile') +const target = qaRuntimeTarget(root) +let session, db, sink +try { + if (target.mode === 'isolated-out') { + const out = join(work, 'built'), { build } = await import('electron-vite') + target.required = target.required.map(path => join(out, relative(target.out, path))) + target.out = out + await build({ root, build: { outDir: out }, logLevel: 'warn' }) + } + await targetIdentity(root, target) + await prepareTarget(root, target, work) + const fixture = continuitySurfaceFixture() + sink = await writeOfflineQaProfile(profile, fixture) + session = await launch(target, profile) + await connectQaSession(target, session, profile) + const c = session.client, evalUI = js => evaluate(c, js), wait = js => ready(c, js, 12000) + const el = s => `document.querySelector(${JSON.stringify(s)})` + const toggle = button('全书核对'), panel = el('.book-continuity-panel'), findings = el('.book-audit-findings') + const field = selector => el('.book-continuity-panel ' + selector) + const resultText = field('.book-progress-results') + '?.innerText' + const input = async (selector, value) => { + await evalUI(`(() => {const e=${field(selector)}; const prop=Object.getOwnPropertyDescriptor(Object.getPrototypeOf(e),'value'); + prop.set.call(e,${JSON.stringify(value)});e.dispatchEvent(new Event(e.tagName==='SELECT'?'change':'input',{bubbles:true}));})()`) + } + const focus = expr => evalUI(`(${expr}).focus()`) + const enter = async expr => { + await focus(expr) + const event = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 } + await c.send('Input.dispatchKeyEvent', { type: 'keyDown', ...event, text: '\r', unmodifiedText: '\r' }) + await c.send('Input.dispatchKeyEvent', { type: 'keyUp', ...event }) + } + const open = async () => { await enter(toggle); await wait(panel + ' && ' + resultText) } + const enterBook = async name => { + const row = `[...document.querySelectorAll('.project-row')].find(e=>e.textContent.includes(${JSON.stringify(name)}))` + await wait(row); await click(c, button('进入', row)); await wait(toggle) + } + const dashboard = async () => { + await click(c, button('工作台', el('.nav-list'))); await wait(toggle); await open() + } + const verifyChapter = async (projectId, order) => { + const ch = fixture.chapters.find(c => c.projectId === projectId && c.order === order) + const selected = el('.chapters-view .list-pane .list-item.active') + await wait(`${selected}?.querySelector('strong').textContent === ${JSON.stringify('第 ' + order + ' 章')} && + ${selected}.querySelector('span').textContent === ${JSON.stringify(ch.title)} && + ${el('.chapters-view .manuscript-textarea')}?.value === ${JSON.stringify(ch.body)}`) + } + const scansSinceLastCheck = async () => (await c.send('Profiler.takePreciseCoverage')).result + .flatMap(script => script.functions).filter(f => f.functionName === 'createBookContinuityReader') + .reduce((sum, f) => sum + f.ranges[0].count, 0) + const currentBookState = () => db.transaction(() => ({ + revision: db.prepare("SELECT value FROM meta WHERE key='revision'").get().value, + rows: db.prepare('SELECT collection,id,json FROM entities ORDER BY collection,id').all() + }))() + + await c.send('Profiler.enable') + await c.send('Profiler.startPreciseCoverage', { callCount: true, detailed: false }) + await enterBook('渡口核对样书') + db = new Database(join(profile, 'novel-director-data.sqlite'), { readonly: true, fileMustExist: true }) + const before = currentBookState() + assert.equal(await evalUI('!!' + panel), false) + assert.equal(await scansSinceLastCheck(), 0, 'A closed audit must not scan') + await open() + assert.equal(await scansSinceLastCheck(), 1, 'Opening builds one book reader') + assert.equal(await evalUI(findings + '.children.length'), 10) + for (const word of ['冲突', '待核', '未结']) assert((await evalUI(resultText)).includes(word)) + const page1 = await evalUI(findings + '.innerText') + await enter(field('[aria-label="核对下一页"]')); await wait(field('nav') + '.innerText.includes("第 2 /")') + assert.notEqual(await evalUI(findings + '.innerText'), page1) + await enter(field('[aria-label="核对上一页"]')); await wait(findings + '.innerText === ' + JSON.stringify(page1)) + await input('label:nth-of-type(3) select', 'promise') + await input('label:nth-of-type(4) select', 'open') + await wait(findings + '?.innerText.includes("归还铜钥匙")') + assert(!(await evalUI(findings + '.innerText')).includes(firstQuote)) + await input('label:nth-of-type(3) select', 'money') + await input('label:nth-of-type(4) select', 'conflict') + await wait(findings + '?.innerText.includes(' + JSON.stringify(firstQuote) + ')') + assert.equal(await scansSinceLastCheck(), 0, 'Paging and category/status filters must reuse the scan') + + await input('label:nth-of-type(1) input', '6') + await input('label:nth-of-type(2) input', '5') + await enter(field('button[type=submit]')); await wait(field('[role=alert]')) + assert.equal(await evalUI('!!' + findings), false, 'Invalid or unapplied chapter range must not show stale evidence') + await input('label:nth-of-type(1) input', '5') + await enter(field('button[type=submit]')); await wait(resultText + '?.includes("筛选结果 1 条")') + const evidence = field('.book-audit-evidence') + assert.deepEqual(await evalUI(`[...${evidence}.querySelectorAll('blockquote')].map(e=>e.textContent)`), [firstQuote, laterQuote]) + assert.equal(await evalUI('!!' + field('.book-audit-version[open]')), false) + assert.doesNotMatch(await evalUI(evidence + '.innerText'), /正文指纹|current-v5|无版本记录/) + await enter(field('summary[aria-label="第 2 章版本信息"]')) + await wait(field('.book-audit-version[open]')) + assert.match(await evalUI(evidence + '.innerText'), /当前正文无版本记录/) + await key(c, 'Escape', 'Escape', 27) + await wait(panel + ' && !' + field('.book-audit-version[open]')) + await enter(field('summary[aria-label="第 5 章版本信息"]')) + await wait(evidence + '.innerText.includes("current-v5")') + assert.match(await evalUI(evidence + '.innerText'), /正文指纹/) + assert.equal(await scansSinceLastCheck(), 1, 'Only applying the new cutoff may rebuild the reader') + await input('label:nth-of-type(1) input', '2'); await enter(field('button[type=submit]')) + await wait(resultText + '?.includes("第 2 至 5 章")') + assert.equal(await scansSinceLastCheck(), 0, 'Changing only the lower bound must reuse the scan') + assert.equal(await evalUI('!!' + field('.book-audit-version[open]')), false, 'A different range must not inherit expanded version information') + await input('label:nth-of-type(2) input', '2') + assert.equal(await evalUI('!!' + findings), false) + await enter(field('button[type=submit]')); await wait(resultText + '?.includes("筛选结果 0 条")') + assert.equal(await evalUI('!!' + evidence), false) + assert.doesNotMatch(await evalUI(panel + '.textContent'), /current-v5|封存附记|11文钱/) + await input('label:nth-of-type(2) input', '5') + await enter(field('button[type=submit]')); await wait(evidence + '?.innerText.includes(' + JSON.stringify(laterQuote) + ')') + await enter(field('summary[aria-label="第 5 章版本信息"]')) + for (const width of [790, 390, 1280]) { + await c.send('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: false }) + await wait(panel + '.clientWidth > 0') + assert(await evalUI(`(() => {const p=${panel}, r=p.getBoundingClientRect();return p.scrollWidth<=p.clientWidth+1 && + [...p.querySelectorAll('input,select,button,blockquote,summary,details[open] dd')].every(e=>e.getBoundingClientRect().right<=r.right+1)})()`), 'Audit overflows at ' + width) + } + await focus(field('input')); await key(c, 'Tab', 'Tab', 9) + assert.equal(await evalUI('document.activeElement === ' + field('label:nth-of-type(2) input')), true) + await key(c, 'Escape', 'Escape', 27) + await wait('!' + panel + ' && document.activeElement === ' + toggle) + await open() + for (const order of [2, 5]) { + await enter(field(`[aria-label="查看第 ${order} 章正式正文"]`)) + await verifyChapter(auditBook, order) + await dashboard() + } + assert.deepEqual(currentBookState(), before, 'Filtering and opening either chapter must not save anything') + await click(c, button('返回项目列表')) + await enterBook('无账本旧书') + const oldState = currentBookState() + assert.equal(await evalUI('!!' + panel), false) + await open(); await wait(findings + '?.innerText.includes("27文钱")') + const oldEvidence = field('[aria-label="查看第 2 章正式正文"]') + ".closest('li')" + const oldExcerpt = await evalUI(oldEvidence + ".querySelector('blockquote').textContent") + assert(oldExcerpt && oldQuote.includes(oldExcerpt), 'The legacy quote must come from the current manuscript') + const oldText = await evalUI(panel + '.innerText') + assert.match(oldText, /待核/); assert(!oldText.includes('正文指纹')) + assert(!oldText.includes(laterQuote) && !oldText.includes(firstQuote) && !oldText.includes('current-v5')) + await enter(field('summary[aria-label="第 2 章版本信息"]')); await wait(oldEvidence + '.innerText.includes("当前正文无版本记录")') + await enter(field('[aria-label="查看第 2 章正式正文"]')) + await verifyChapter(oldBook, 2) + assert.deepEqual(currentBookState(), oldState) + await verifyQaOffline(session, sink) +} catch (error) { + if (session?.client) { + console.error(await evaluate(session.client, "({focused: document.activeElement?.outerHTML.slice(0,400), surface: document.querySelector('.book-progress')?.innerText ?? document.body.innerText.slice(-900)})").catch(() => null)) + for (const e of session.exceptions) console.error(e.exception?.description ?? e.text) + } + throw error +} finally { + db?.close() + await cleanupQaRun(root, work, session, sink) +} +console.log(`PASS ${target.mode}: lazy scan counts, keyboard, paging, cutoff, two current chapter links, old book, narrow layout, unchanged SQLite and no network requests.`) diff --git a/scripts/qa-book-progress.mjs b/scripts/qa-book-progress.mjs new file mode 100644 index 0000000..ece7674 --- /dev/null +++ b/scripts/qa-book-progress.mjs @@ -0,0 +1,106 @@ +// Only the disposable Electron profile and synthetic manuscript are opened. +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { mkdir, mkdtemp } from 'node:fs/promises' +import { repoRoot as root } from './utils/repo-root.mjs' +import { bookProgressFixture } from './fixtures/book-progress.mjs' +import { qaRuntimeTarget, targetIdentity, prepareTarget, writeOfflineQaProfile, connectQaSession, verifyQaOffline, cleanupQaRun } from './utils/long-novel-ui-runtime.mjs' +import { launch, evaluate, click, button, ready } from './utils/long-novel-ui-driver.mjs' + +await mkdir(join(root, 'tmp'), { recursive: true }) +const work = await mkdtemp(join(root, 'tmp', 'book-progress-ui-')), target = qaRuntimeTarget(root) +const profile = join(work, 'profile') +const Database = createRequire(import.meta.url)('better-sqlite3') +let session, sink +try { + await targetIdentity(root, target) + await prepareTarget(root, target, work) + sink = await writeOfflineQaProfile(profile, bookProgressFixture()) + session = await launch(target, profile) + await connectQaSession(target, session, profile) + const c = session.client, table = 'document.querySelector(".book-progress-table")' + const enterBook = async () => { + const project = '[...document.querySelectorAll(".project-row")].find(e => e.textContent.includes("全书进度样书"))' + await ready(c, project) + await click(c, button('进入', project)) + await ready(c, 'document.querySelector(".book-progress")') + } + await enterBook() + assert.match(await evaluate(c, 'document.querySelector(".book-progress-goal").innerText'), /15,?000/) + await click(c, button('全书进度与缺章')) + await ready(c, table) + await click(c, 'document.querySelector(".book-progress-check input")') + await ready(c, table + '?.tBodies[0]?.rows.length === 4') + const rows = await evaluate(c, '[...document.querySelectorAll(".book-progress-table tbody tr")].map(e=>e.innerText)') + assert.match(rows[0], /66.*缺章/s) + assert.match(rows[1], /74.*仅有草稿/s) + assert.match(rows[2], /80.*空章/s) + assert.match(rows[3], /92.*写回失败.*历史正文/s) + await click(c, 'document.querySelector("[aria-label=编辑全书目标]")') + await ready(c, 'document.querySelector(".book-target-editor input")') + // Use the native value setter to drive React's controlled numeric input. + await evaluate(c, '(()=>{const el=document.querySelector(".book-target-editor input");Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,"value").set.call(el,"1000");el.dispatchEvent(new Event("input",{bubbles:true}));})()') + await click(c, button('保存目标')) + await ready(c, 'document.querySelector(".book-progress-goal")?.innerText.includes("724")') + await c.send('Page.reload') + await ready(c, 'document.querySelector(".project-row") || document.querySelector(".dashboard-view")') + if (!await evaluate(c, '!!document.querySelector(".dashboard-view")')) await enterBook() + await ready(c, 'document.querySelector(".book-progress-goal")?.innerText.includes("724")') + const goDashboard = async () => { + await click(c, button('工作台', 'document.querySelector(".nav-list")')) + await ready(c, 'document.querySelector(".book-progress")') + } + const selectProblems = async () => { + await click(c, button('全书进度与缺章')) + await ready(c, table) + await click(c, 'document.querySelector(".book-progress-check input")') + } + const row74 = '[...document.querySelectorAll(".book-progress-table tr")].find(e=>e.textContent.includes("第 74 章"))' + const submit74 = async () => { + await ready(c, row74) + await click(c, button('回到草稿', row74)) + await ready(c, button('未完成审稿,直接采纳')) + await click(c, button('未完成审稿,直接采纳')) + const confirm = button('确认未审稿采纳', 'document.querySelector(".confirm-dialog")') + await ready(c, confirm) + await click(c, confirm) + } + // The normal QA wait rejects storage warnings, but this deliberate failure must surface one. + const waitWithNotice = async expression => { + const end = Date.now() + 10000 + while (!await evaluate(c, 'Boolean(' + expression + ')')) { + assert(Date.now() < end, 'Expected writeback warning/confirmation did not appear') + await new Promise(r => setTimeout(r, 30)) + } + } + const db = new Database(join(profile, 'novel-director-data.sqlite'), { fileMustExist: true }) + try { + db.exec("CREATE TRIGGER qa_book_progress BEFORE INSERT ON entities WHEN NEW.collection='chapterVersions' BEGIN SELECT RAISE(ABORT, 'synthetic failed write'); END") + await selectProblems() + await submit74() + await waitWithNotice('document.querySelector(".storage-conflict-notice")?.textContent.includes("本次正式写回未确认")') + assert.equal(db.prepare("SELECT count(*) AS n FROM entities WHERE collection='chapters' AND project_id=? AND chapter_order=74").get('progress-book').n, 0) + assert.equal(db.prepare("SELECT count(*) AS n FROM entities WHERE collection='chapterWritebackFailures' AND project_id=? AND chapter_order=74").get('progress-book').n, 1) + db.exec('DROP TRIGGER qa_book_progress') + await click(c, button('重新加载最新数据', 'document.querySelector(".storage-conflict-notice")')) + const confirm = button('重新加载', 'document.querySelector(".confirm-dialog")') + await waitWithNotice(confirm) + await click(c, confirm) + await waitWithNotice('!document.querySelector(".storage-conflict-notice")') + await goDashboard() + await selectProblems() + await ready(c, row74 + '?.textContent.includes("写回失败")') + await submit74() + await ready(c, 'document.body.innerText.includes("第 74 章已接受并写入版本链")') + await goDashboard() + await selectProblems() + await ready(c, table + '?.tBodies[0]?.rows.length === 3') + assert.equal(await evaluate(c, 'Boolean(' + row74 + ')'), false) + assert.equal(db.prepare("SELECT count(*) AS n FROM entities WHERE collection='chapterCommitBundles' AND project_id=? AND job_id=?").get('progress-book', 'j74').n, 1) + } finally { db.close() } + await verifyQaOffline(session, sink) +} finally { + await cleanupQaRun(root, work, session, sink) +} +console.log(`PASS ${target.mode}: 96-chapter progress and goal, failed SQLite commit rolled back, explicit reload showed the failure, retry from the draft committed once.`) diff --git a/scripts/qa-character-card-balance.mjs b/scripts/qa-character-card-balance.mjs new file mode 100644 index 0000000..65d8bee --- /dev/null +++ b/scripts/qa-character-card-balance.mjs @@ -0,0 +1,80 @@ +// Production renderer and SQLite IPC; only a disposable userData directory with synthetic text. +import assert from 'node:assert/strict' +import { mkdir, mkdtemp } from 'node:fs/promises' +import { join } from 'node:path' +import { repoRoot as root } from './utils/repo-root.mjs' +import { loadDomainModules } from './utils/load-domain-modules.mjs' +import { cardBalanceFixture } from './fixtures/card-balance.mjs' +import { qaRuntimeTarget, targetIdentity, prepareTarget, writeOfflineQaProfile, connectQaSession, verifyQaOffline, cleanupQaRun } from './utils/long-novel-ui-runtime.mjs' +import { launch, cleanup, evaluate, click, button, ready } from './utils/long-novel-ui-driver.mjs' + +const { normalizeAppData, CharacterStateService } = await loadDomainModules('shared/defaults', 'services/CharacterStateService') +const { data } = cardBalanceFixture(normalizeAppData, CharacterStateService) +await mkdir(join(root, 'tmp'), { recursive: true }) +const work = await mkdtemp(join(root, 'tmp', 'card-balance-ui-')), profile = join(work, 'profile') +const target = qaRuntimeTarget(root) +let session, sink +try { + await targetIdentity(root, target) + await prepareTarget(root, target, work) + sink = await writeOfflineQaProfile(profile, data) + session = await launch(target, profile) + await connectQaSession(target, session, profile) + let c = session.client + const nav = 'document.querySelector(".nav-list")' + async function enter() { + const row = '[...document.querySelectorAll(".project-row")].find(e=>e.textContent.includes("试算样书"))' + await ready(c, row) + await click(c, button('进入', row)) + await ready(c, nav) + } + async function change(selector, value, isSelect = false) { + await evaluate(c, '(()=>{const el=document.querySelector(' + JSON.stringify(selector) + ');' + + 'Object.getOwnPropertyDescriptor(' + (isSelect ? 'HTMLSelectElement' : 'HTMLInputElement') + + '.prototype,"value").set.call(el,' + JSON.stringify(value) + ');el.dispatchEvent(new Event("' + + (isSelect ? 'change' : 'input') + '",{bubbles:true}))})()') + } + await enter() + await click(c, button('角色', nav)) + const panel = 'document.querySelector(".character-card-review")' + await ready(c, panel) + assert.match(await evaluate(c, panel + '.innerText'), /未核对/) + await change('.character-card-review select', 'c10', true) + await ready(c, button('保留原卡,核对到第 10 章', panel)) + await click(c, button('保留原卡,核对到第 10 章', panel)) + await ready(c, panel + '?.querySelector("[role=status]")?.textContent.includes("核对到第 10 章")') + await change('.character-card-review select', 'c12', true) + await ready(c, panel + '?.innerText.includes("相差 2 章")') + await click(c, 'document.querySelector(".character-review-evidence summary")') + await ready(c, panel + '?.innerText.includes("30文")') + await click(c, button('Prompt 构建器', nav)) + const editor = 'document.querySelector("textarea.prompt-editor")' + const amount = async (order, expected) => { + await ready(c, 'document.querySelector(".prompt-controls input[type=number]")') + await change('.prompt-controls input[type=number]', String(order)) + await click(c, button('构建 Prompt')) + await ready(c, editor + '?.value.includes("第 ' + order + ' 章写作 Prompt")') + const prompt = await evaluate(c, editor + '.value') + const section = prompt.match(/【角色状态账本切片】([\s\S]*?)(?=\n## |$)/u)?.[1] ?? '' + assert(section.includes('随身现金:' + expected + ' 文'), 'Missing confirmed balance for chapter ' + order) + assert(!section.includes('钱只剩十文') && section.includes('替船队保管五十文')) + return section + } + await amount(13, 30) + assert.doesNotMatch(await amount(11, 10), /30 文|三十文/) + await verifyQaOffline(session, sink) + const closed = await cleanup(session) + assert.deepEqual(closed.remainingProcessIds, []) + console.log('QA_RESTART', JSON.stringify({ pid: session.child.pid, remainingProcessIds: closed.remainingProcessIds })) + session = await launch(target, profile) + await connectQaSession(target, session, profile) + c = session.client + await ready(c, 'document.querySelector(".project-row") || document.querySelector(".nav-list")') + if (!await evaluate(c, '!!document.querySelector(".nav-list")')) await enter() + await click(c, button('角色', nav)) + await ready(c, panel + '?.innerText.includes("相差 2 章")') + await verifyQaOffline(session, sink) +} finally { + await cleanupQaRun(root, work, session, sink) +} +console.log(`PASS ${target.mode}: retained card at chapter 10, confirmed marker after a full restart, chapter 13 gets 30 wen, chapter 11 gets 10 wen.`) diff --git a/scripts/qa-hard-canon-audit.mjs b/scripts/qa-hard-canon-audit.mjs new file mode 100644 index 0000000..3f41740 --- /dev/null +++ b/scripts/qa-hard-canon-audit.mjs @@ -0,0 +1,89 @@ +// Use the isolated preview by default, or the release executable with --packaged. +import assert from 'node:assert/strict' +import { join } from 'node:path' +import { mkdir, mkdtemp } from 'node:fs/promises' +import { repoRoot as root } from './utils/repo-root.mjs' +import { loadProseFixtureDomain } from './utils/qa-prose-first-fixture.mjs' +import { qaRuntimeTarget, targetIdentity, prepareTarget, writeOfflineQaProfile, connectQaSession, verifyQaOffline, cleanupQaRun } from './utils/long-novel-ui-runtime.mjs' +import { launch, evaluate, click, button, ready } from './utils/long-novel-ui-driver.mjs' + +const base = join(root, 'tmp') +await mkdir(base, { recursive: true }) +const work = await mkdtemp(join(base, 'canon-desktop-')) +const target = qaRuntimeTarget(root) +const t = '2026-10-01T00:00:00.000Z' +let session, sink +try { + await targetIdentity(root, target) + await prepareTarget(root, target, work) + const { normalizeAppData } = await loadProseFixtureDomain() + const data = normalizeAppData({ + projects: [{ id: 'qa-p', name: '核查样书', createdAt: t, updatedAt: t }], + chapterGenerationJobs: [{ id: 'qa-j', projectId: 'qa-p', targetChapterOrder: 1, status: 'completed', + currentStep: 'await_user_confirmation', createdAt: t, updatedAt: t }], + generatedChapterDrafts: [{ id: 'qa-d', projectId: 'qa-p', jobId: 'qa-j', title: '铜扣', body: '扣上还剩一道雾印。', + status: 'draft', createdAt: t, updatedAt: t }] + }) + const profile = join(work, 'profile') + sink = await writeOfflineQaProfile(profile, data) + session = await launch(target, profile) + await connectQaSession(target, session, profile) + const c = session.client, nav = name => button(name, 'document.querySelector(".nav-list")') + const field = label => `([...document.querySelectorAll('.hard-canon-draft .field')].find(e=>e.querySelector('.field-label')?.textContent===${JSON.stringify(label)})?.querySelector('input,select,textarea'))` + const select = (label, value) => evaluate(c, `(() => {const el=${field(label)}; el.value=${JSON.stringify(value)}; el.dispatchEvent(new Event('change',{bubbles:true})); return el.value})()`) + const fill = async (label, value) => { await click(c, field(label)); await c.send('Input.insertText', { text: value }) } + const row = '[...document.querySelectorAll(".project-row")].find(e=>e.textContent.includes("核查样书"))' + await ready(c, row) + await click(c, button('进入', row)) + await ready(c, 'document.querySelector(".dashboard-view")') + await click(c, nav('硬设定包')) + await ready(c, field('标题')) + const enterRule = async () => { + await fill('标题', '禁写雾印') + await fill('内容', '不使用雾印。') + await select('检查方式', 'terms') + await ready(c, field('检查用语(每行一项)')) + await fill('检查用语(每行一项)', '雾印') + await select('核查结果', 'block') + } + await enterRule() + await click(c, button('新增条目', 'document.querySelector(".hard-canon-draft")')) + await ready(c, 'document.querySelector(".hard-canon-item")?.textContent.includes("可机械核查")') + assert.match(await evaluate(c, 'document.querySelector(".hard-canon-condition").textContent'), /雾印.*确证违规时阻断/) + const openDraft = async () => { + await click(c, nav('生产流水线')) + await ready(c, 'document.querySelector(".pipeline-artifact-tabs")') + await click(c, button('草稿', 'document.querySelector(".pipeline-artifact-tabs")')) + } + await openDraft() + const accept = button('未完成审稿,直接采纳') + await ready(c, 'document.querySelector(".pipeline-draft-body") && document.querySelector(".pipeline-diagnostics-summary")', 12000) + assert.equal(await evaluate(c, 'document.querySelector(".hard-canon-evidence blockquote")?.textContent'), '扣上还剩一道雾印。') + assert.equal(await evaluate(c, 'document.querySelector(".hard-canon-evidence mark")?.textContent'), '雾印') + assert.equal(await evaluate(c, `${accept}?.disabled`), true) + await click(c, nav('硬设定包')) + await ready(c, 'document.querySelector(".hard-canon-item")') + await click(c, button('停用', 'document.querySelector(".hard-canon-item")')) + await ready(c, button('启用', 'document.querySelector(".hard-canon-item")')) + const storedRules = () => evaluate(c, `window.novelDirector.data.load().then(({data}) => + data.hardCanonPacks.find(p => p.projectId === "qa-p").items.map(({id,status}) => ({id,status})))`) + const before = await storedRules() + assert.equal(before.length, 1) + assert.equal(before[0].status, 'inactive') + await enterRule() + const reuse = button('复用已有条目', 'document.querySelector(".hard-canon-draft")') + await ready(c, reuse) + await click(c, reuse) + await ready(c, 'document.querySelector(".hard-canon-message")?.innerText.includes("已复用")') + assert.deepEqual(await storedRules(), before, 'Duplicate entry must neither create nor reactivate a rule') + await openDraft() + await ready(c, `${accept} && !${accept}.disabled && !document.querySelector('.hard-canon-evidence')`) + await verifyQaOffline(session, sink) +} catch (error) { + if (session?.client) console.error(await evaluate(session.client, + '({ notice: document.querySelector(".notice")?.textContent, artifact: document.querySelector(".pipeline-current-artifact")?.innerText?.slice(0,350), diagnostics: document.querySelector(".pipeline-diagnostics-summary")?.innerText?.slice(0,350) })').catch(() => null)) + throw error +} finally { + await cleanupQaRun(root, work, session, sink) +} +console.log(`PASS ${target.mode}: authored a blocking rule, saw exact draft evidence, re-entered the disabled rule without duplicating or reactivating it, verified acceptance unlocked.`) diff --git a/scripts/qa-p2-packaged-main.mjs b/scripts/qa-p2-packaged-main.mjs new file mode 100644 index 0000000..8b331f1 --- /dev/null +++ b/scripts/qa-p2-packaged-main.mjs @@ -0,0 +1,210 @@ +// A black-box test of the shipped main process. Never import a source AI client or seed a receipt. +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { once } from 'node:events' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { dirname, join } from 'node:path' +import { repoRoot as root } from './utils/repo-root.mjs' +import { freePort } from './utils/qa-agent-authorization-driver.mjs' +import { cleanup, evaluate } from './utils/long-novel-ui-driver.mjs' +import { connectQaSession, prepareTarget, qaRuntimeTarget, targetIdentity } from './utils/long-novel-ui-runtime.mjs' + +const target = qaRuntimeTarget(root) +assert.equal(target.mode, 'packaged', 'Use --packaged: this test accepts only the shipped desktop.') +const tmp = join(root, 'tmp') +await mkdir(tmp, { recursive: true }) +const work = await mkdtemp(join(tmp, 'p2-packaged-main-')), profile = join(work, 'profile') +const A = 'p2-main-a', B = 'p2-main-b', model = 'p2-local', runId = 'p2-run' +const privateMarker = 'p2-private-fixture', stages = new Map(), requests = [], diagnoses = {}, launches = [] +const responses = { + format: [400, 200], retry: [503, 200], limit: [429], quota: [429], auth: [401], cut: ['cut-body'] +} +let origin, blocked = 0, session +const mock = createServer(async (req, res) => { + try { + const route = /^\/(\w+)\/v1\/chat\/completions$/.exec(req.url)?.[1] + assert.equal(req.socket.localAddress, '127.0.0.1') + assert.equal(req.socket.remoteAddress, '127.0.0.1') + assert.equal(req.headers.host, new URL(origin).host) + assert.equal(req.method, 'POST') + assert(!('authorization' in req.headers) && !('proxy-authorization' in req.headers)) + assert(Object.hasOwn(responses, route)) + let raw = '' + for await (const chunk of req) { raw += chunk; assert(raw.length < 4096) } + const body = JSON.parse(raw) + assert.equal(body.model, model) + assert.deepEqual(body.messages, [{ role: 'user', content: privateMarker + '-prompt' }]) + const i = stages.get(route) ?? 0, status = responses[route][i] + assert(status, 'Unexpected extra dispatch') + stages.set(route, i + 1) + const requestId = 'p2-' + route + '-' + (i + 1) + requests.push({ route, status, requestId, jsonMode: body.response_format?.type === 'json_object' }) + const headers = { 'Content-Type': 'application/json', 'X-Content-Type-Options': 'nosniff', + 'X-Request-ID': requestId, Connection: 'close' } + if (status === 'cut-body') { + res.writeHead(200, { ...headers, 'Content-Length': '65536' }) + res.write('["' + privateMarker + ' '.repeat(8192)) + setTimeout(() => res.destroy(), 150) + } else if (status === 200) { + const [input, output] = route === 'format' ? [17, 5] : [7, 2] + res.writeHead(status, headers).end(JSON.stringify({ choices: [{ message: { content: 'local-ok' } }], + usage: { prompt_tokens: input, completion_tokens: output, total_tokens: input + output } })) + } else { + const code = { format: 'unsupported_response_format', retry: 'temporarily_unavailable', + limit: 'rate_limit_exceeded', quota: 'insufficient_quota', auth: 'unauthorized' }[route] + res.writeHead(status, headers).end(JSON.stringify({ error: { code, message: privateMarker + '-response' } })) + } + } catch { + blocked++ + res.writeHead(403, { Connection: 'close' }).end() + } +}) +// Never forward HTTP proxy requests, HTTPS tunnels or WebSockets, including to a literal external IP. +for (const event of ['connect', 'upgrade']) mock.on(event, (_req, socket) => { blocked++; socket.destroy() }) + +async function launchRestricted() { + const port = await freePort(), host = new URL(origin).host + const env = {} + for (const k of ['SystemRoot', 'WINDIR', 'ComSpec', 'SYSTEMDRIVE', 'PATHEXT', 'NUMBER_OF_PROCESSORS', 'PROCESSOR_ARCHITECTURE']) { + if (process.env[k]) env[k] = process.env[k] + } + Object.assign(env, { NOVEL_DIRECTOR_SMOKE_USER_DATA: profile, HOME: join(profile, 'home'), USERPROFILE: join(profile, 'home'), + APPDATA: join(profile, 'roaming'), LOCALAPPDATA: join(profile, 'local'), TEMP: join(profile, 'temp'), TMP: join(profile, 'temp'), + CODEX_HOME: join(profile, 'codex'), NOVEL_DIRECTOR_API_REGISTRY: join(profile, 'empty-registry.json'), + PATH: join(env.SystemRoot, 'System32') }) + const args = ['--remote-debugging-address=127.0.0.1', '--remote-debugging-port=' + port, '--user-data-dir=' + profile, + '--disable-background-networking', '--disable-component-update', '--disable-quic', '--no-first-run', + '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1', + '--proxy-server=' + origin, '--proxy-bypass-list=<-loopback>;' + host] + // Only the mock's exact host:port is direct. All other Chromium traffic goes to the non-forwarding mock. + const child = spawn(target.executable, args, { cwd: target.runtimeRoot, env, windowsHide: true, stdio: 'ignore' }) + await once(child, 'spawn') + session = { child, port, args, processIds: [], logs: [], exceptions: [], networkRequests: [] } + await connectQaSession(target, session, profile) + launches.push({ pid: child.pid, port, profile, mock: origin }) +} +async function stop() { + if (!session) return + const closed = await cleanup(session) + assert.deepEqual(closed.remainingProcessIds, []) + assert.deepEqual(session.exceptions, []) + assert.deepEqual(session.networkRequests, []) + console.log('CLOSED', JSON.stringify({ pid: session.child.pid, remainingProcessIds: [], rendererRequests: 0 })) + session = null +} + +const settings = { apiProvider: 'local', modelName: model, temperature: 0, maxTokens: 32, requestTimeoutMs: 10000 } +const phases = { format: 'writing', retry: 'review', limit: 'review', quota: 'revision', auth: 'other', cut: 'other' } +const book = name => ['format', 'retry', 'limit'].includes(name) ? A : B +const ui = expr => evaluate(session.client, expr) +const report = id => ui('window.novelDirector.data.getProjectUsage(' + JSON.stringify(id) + ')') +const revision = () => ui('window.novelDirector.data.load().then(r=>r.revision)') +async function chat(name, overrides = {}) { + const retry = ['retry', 'quota', 'auth'].includes(name) + const req = { settings: { ...settings, baseUrl: origin + '/' + name + '/v1', retryEnabled: retry, maxRetries: retry ? 1 : 0 }, + messages: [{ role: 'user', content: privateMarker + '-prompt' }], usageContext: { projectId: book(name), phase: phases[name] }, + runId, clientCallId: 'logical-' + name, ...overrides } + return ui('window.novelDirector.ai.chatCompletion(' + JSON.stringify(req) + ')') +} +async function receipts(id) { + const storage = join(profile, 'novel-director-data.sqlite') + const file = join(storage + '.usage', createHash('sha256').update(id).digest('hex') + '.jsonl') + assert.equal(dirname(await realpath(file)), await realpath(storage + '.usage')) + const raw = await readFile(file, 'utf8') + assert(!raw.includes(privateMarker), 'Private fixture content appeared in the on-disk ledger') + return raw.trim().split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)) +} + +try { + await targetIdentity(root, target) + await prepareTarget(root, target, work) + await new Promise((ok, fail) => mock.once('error', fail).listen(0, '127.0.0.1', ok)) + assert.equal(mock.address().address, '127.0.0.1') + origin = 'http://127.0.0.1:' + mock.address().port + for (const name of ['home', 'roaming', 'local', 'temp', 'codex']) await mkdir(join(profile, name), { recursive: true }) + await writeFile(join(profile, 'empty-registry.json'), JSON.stringify({ version: 1, apis: {} }), { flag: 'wx' }) + await writeFile(join(profile, 'novel-director-data.json'), JSON.stringify({ settings: { ...settings, baseUrl: origin + '/v1' }, + projects: [{ id: A, name: A, modelRates: [{ provider: 'local', endpoint: origin, model, currency: 'CNY', + inputPerMillionTokens: 2, outputPerMillionTokens: 3 }] }, { id: B, name: B }] }), { flag: 'wx' }) + await launchRestricted() + const before = await revision() + assert.equal((await chat('format', { usageContext: { projectId: A, phase: 'invalid' } })).ok, false) + assert.equal(requests.length, 0) + for (const id of [A, B]) assert.equal((await report(id)).totals.requests, 0) + const errors = { limit: /HTTP 429.*限流/, quota: /HTTP 429.*额度不足/, auth: /HTTP 401.*身份验证/, cut: /已收到响应头.*读完响应正文/ } + for (const [name, statuses] of Object.entries(responses)) { + const start = requests.length, result = await chat(name) + assert.deepEqual(requests.slice(start).map(r => r.status), statuses, name + ': wire status sequence') + assert.deepEqual(requests.slice(start).map(r => r.jsonMode), statuses.map((_, i) => name !== 'format' || i === 0)) + assert.equal(result.telemetry?.attempts, statuses.length) + assert(!result.telemetry.usageJournalIncomplete) + assert.equal(result.ok, !errors[name]) + if (result.ok) { + assert.equal(result.content, 'local-ok') + assert.equal(result.telemetry.responseFormatFallback, name === 'format') + assert.deepEqual(result.telemetry.usage, name === 'format' ? { promptTokens: 17, completionTokens: 5, totalTokens: 22 } + : { promptTokens: 7, completionTokens: 2, totalTokens: 9 }) + } else { + assert.match(result.error, errors[name]) + assert(result.error.includes('桌面 Electron ' + target.observedRuntime.electronVersion + '(Chromium net.fetch)')) + assert.match(result.error, /额度未知/) + assert(!result.error.includes(privateMarker)) + diagnoses[name] = result.error + } + console.log('MAIN_CASE', JSON.stringify({ name, attempts: result.telemetry.attempts, statuses, diagnosis: diagnoses[name] })) + } + const projects = {} + for (const id of [A, B]) { + const rows = await receipts(id), hits = requests.filter(r => book(r.route) === id) + assert.deepEqual(rows.map(r => [r.projectId, r.runId, r.callId, r.phase, r.provider, r.model, r.endpoint, r.providerRequestId, r.outcome]), + hits.map(r => [id, runId, 'logical-' + r.route, phases[r.route], 'local', model, origin, r.requestId, r.status === 200 ? 'success' : 'failed'])) + assert.deepEqual(rows.map(r => r.usage), hits.map(r => r.status !== 200 ? undefined : r.route === 'format' + ? { promptTokens: 17, completionTokens: 5, totalTokens: 22 } : { promptTokens: 7, completionTokens: 2, totalTokens: 9 })) + const usage = await report(id), t = usage.totals, failures = hits.filter(r => r.status !== 200).length + assert.deepEqual([t.requests, t.failedRequests, t.unknownUsageRequests], [hits.length, failures, failures]) + assert.deepEqual(t.costs, id === A ? [{ currency: 'CNY', amount: 0.000069, pricedRequests: 2 }] : []) + assert.deepEqual([t.tokens.promptTokens, t.tokens.completionTokens, t.tokens.totalTokens], id === A ? [24, 7, 31] : [null, null, null]) + for (const phase of usage.byPhase) assert.equal(phase.requests, hits.filter(r => phases[r.route] === phase.phase).length) + assert.equal(usage.coverage.invalidRecords, 0) + projects[id] = { rows, usage } + } + assert.equal(new Set(Object.values(projects).flatMap(p => p.rows.map(r => r.id))).size, requests.length) + assert.equal(await revision(), before, 'Usage receipts must not advance the manuscript revision') + const n = requests.length + await stop() + await launchRestricted() + for (const id of [A, B]) { + assert.deepEqual(await report(id), projects[id].usage) + assert.deepEqual(await receipts(id), projects[id].rows) + } + assert.equal(await revision(), before) + await stop() + assert.equal(requests.length, n) + assert.equal(blocked, 0, 'Unexpected request, tunnel, credentials or malformed mock payload') + const mainLog = await readFile(join(profile, 'logs', 'main.log'), 'utf8') + assert(!mainLog.includes(privateMarker)) + await writeFile(join(work, 'main.log'), mainLog, { flag: 'wx' }) + const evidence = join(work, 'evidence.json') + await writeFile(evidence, JSON.stringify({ runtime: target.observedRuntime, fingerprints: target.fingerprints, launches, + requests, diagnoses, projects, blocked, revisionUnchanged: true, restartMatched: true }, null, 2), { flag: 'wx' }) + console.log('PASS packaged main: zero pre-dispatch requests; 8 local HTTP dispatches = 8 distinct durable receipts;', + '6 failed, 2 successful, 24 input + 7 output tokens, CNY 0.000069. No credentials, forwarding or renderer requests.') + console.log('EVIDENCE', evidence) +} finally { + try { + await stop() + } finally { + if (mock.listening) { + const closed = new Promise(ok => mock.close(ok)) + mock.closeAllConnections() + await closed + } + assert.equal(dirname(await realpath(work)), await realpath(tmp)) + if (await realpath(profile).catch(e => { if (e.code !== 'ENOENT') throw e })) { + assert.equal(dirname(await realpath(profile)), await realpath(work)) + await rm(profile, { recursive: true, force: true, maxRetries: 4 }) + } + } +} diff --git a/scripts/qa-packaged-draft-retention.mjs b/scripts/qa-packaged-draft-retention.mjs new file mode 100644 index 0000000..2417785 --- /dev/null +++ b/scripts/qa-packaged-draft-retention.mjs @@ -0,0 +1,389 @@ +#!/usr/bin/env node +// Only the pinned Windows release is eligible. No source build, alternate runtime or external model. +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, open, readFile, realpath, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { setTimeout as delay } from 'node:timers/promises' +import { repoRoot as root } from './utils/repo-root.mjs' +import { qaRuntimeTarget, prepareTarget, targetIdentity, verifyRuntimeIdentity } from './utils/long-novel-ui-runtime.mjs' +import { click, connect, cleanup, evaluate, launch, ready, sha256, within } from './utils/long-novel-ui-driver.mjs' +import { fill } from './utils/qa-agent-authorization-driver.mjs' +import { cases, fixture, hash, roles, startStub, visible } from './utils/qa-draft-retention-fixture.mjs' + +const VERSION = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')).version +// This receipt compares the installer payload with the unpacked binary, not just its version label. +const payloadProof = JSON.parse(await readFile(join(root, 'release', 'INSTALLER_PAYLOAD_CHECK.json'), 'utf8')) +assert.equal(payloadProof.version, VERSION, 'Run verify-win-installer-payload.mjs for the current release first.') +assert.equal(payloadProof.status, 'identical') +const ASAR = String(payloadProof.appAsarSha256).toLowerCase() +assert.match(ASAR, /^[0-9a-f]{64}$/) +const target = qaRuntimeTarget(root, true) +const j = JSON.stringify, now = () => new Date().toISOString() +const el = selector => 'document.querySelector(' + j(selector) + ')' +const btn = (label, scope = '') => '[...document.querySelectorAll(' + j((scope ? scope + ' ' : '') + 'button') + + ')].find(b=>b.textContent.trim()===' + j(label) + ')' +const mainButton = '.pipeline-top-status-inner' +const tabs = '.pipeline-artifact-tabs' +const saveJson = (path, value) => writeFile(path, j(value, null, 2) + '\n', 'utf8') +const args = process.argv.slice(2).filter(a => a !== '--packaged') +const selected = args.length === 0 ? cases : cases.filter(c => args.length === 1 && args[0] === '--case=' + c.id) +assert(selected.length, 'Usage: node scripts/qa-packaged-draft-retention.mjs [--case=]') + +async function releaseGate() { + assert.equal(process.platform, 'win32') + assert.equal(target.mode, 'packaged') + assert.equal(target.out, null) + const manifest = JSON.parse(await readFile(target.releaseInfoPath, 'utf8')) + assert.equal(manifest.version, VERSION, 'REFUSED before launch: wrong release') + assert.equal(manifest.packagedSmokeTest, 'passed', 'REFUSED before launch: missing package-completion evidence') + assert.equal(manifest.unpackedExecutable.replaceAll('\\', '/'), 'win-unpacked/Novel Director.exe') + assert.equal(resolve(target.executable), join(root, 'release', 'win-unpacked', 'Novel Director.exe')) + assert.equal(await realpath(target.executable), resolve(target.executable)) + assert.equal(await sha256(join(target.packagedDir, 'resources', 'app.asar')), ASAR, 'REFUSED: different app.asar') + return manifest +} + +function readState(db) { + // A single read transaction cannot mix a previous draft with a newer job status. + db.exec('BEGIN') + try { + const query = db.prepare('SELECT json FROM entities WHERE collection=?') + const rows = key => query.all(key).map(row => JSON.parse(row.json)) + const jobs = rows('chapterGenerationJobs'), steps = rows('chapterGenerationSteps') + return { jobs, job: jobs[0], drafts: rows('generatedChapterDrafts'), steps, + draftStep: steps.find(s => s.type === 'generate_chapter_draft'), + consistency: rows('consistencyReviewReports'), quality: rows('qualityGateReports'), + canonical: Object.fromEntries(['chapters', 'chapterVersions', 'chapterCommitBundles'].map(k => [k, rows(k).length])), + revision: db.prepare("SELECT value FROM meta WHERE key='revision'").get()?.value } + } finally { db.exec('ROLLBACK') } +} + +function compact(s) { + return { revision: s.revision, jobs: s.jobs.length, jobId: s.job?.id, status: s.job?.status, + currentStep: s.job?.currentStep, error: s.job?.errorMessage, canonical: s.canonical, + drafts: s.drafts.map(d => ({ id: d.id, jobId: d.jobId, status: d.status, title: d.title, chapterId: d.chapterId, + visibleChars: visible(d.body), sha256: hash(d.body), createdAt: d.createdAt, updatedAt: d.updatedAt })), + failedSteps: s.steps.filter(t => t.status === 'failed').map(t => ({ type: t.type, error: t.errorMessage })), + consistencyReportIds: s.consistency.map(r => r.id), qualityReportIds: s.quality.map(r => r.id) } +} + +const report = { status: 'not-run', startedAt: now(), targetVersion: VERSION, expectedAsar: ASAR, cases: [], + boundary: 'Packaged desktop UI, production preload, real loopback HTTP, independent read-only SQLite. No installed-app, live-provider, CLI/MCP, or injected in-process diagnostic tests.' } +let work + +async function runCase(spec) { + const dir = join(work, spec.id), profile = join(dir, 'profile'), database = join(profile, 'novel-director-data.sqlite') + await mkdir(dir) + await mkdir(profile) + assert.equal(await realpath(profile), profile) + const stub = await startStub(spec) + const entry = { id: spec.id, status: 'running', directory: dir, profile, database, stub: stub.baseUrl, + expected: { target: spec.target, title: spec.title, visibleChars: visible(spec.body), bodySha256: hash(spec.body), + ending: spec.body.slice(-12), finishReason: spec.finish ?? null, rejectedAs: spec.negative ?? null }, + sessions: [], evidence: [], calls: stub.calls } + report.cases.push(entry) + let session, db, client, jobId + const ev = expression => evaluate(client, expression) + const wait = expression => ready(client, expression, 15000) + const press = async expression => { await wait('(' + expression + ')?.disabled === false'); await click(client, expression) } + const snap = () => readState(db) + const note = (name, data) => entry.evidence.push({ name, at: now(), ...data }) + const proseCalls = () => stub.calls.filter(c => c.kind === 'prose') + const until = async (label, predicate) => { + const deadline = Date.now() + 35000 + do { + const s = snap() + if (predicate(s)) return s + await delay(80) + } while (Date.now() < deadline) + throw Error('Timed out: ' + label + '; last SQLite state: ' + j(compact(snap()))) + } + const screenshot = async name => { + const { data } = await client.send('Page.captureScreenshot', { format: 'png', fromSurface: true }) + await writeFile(join(dir, name + '.png'), Buffer.from(data, 'base64')) + } + const terminal = s => s.job?.id === jobId && ['failed', 'completed'].includes(s.job.status) + const noCanon = s => assert.deepEqual(s.canonical, { chapters: 0, chapterVersions: 0, chapterCommitBundles: 0 }) + const exactDraft = s => { + assert.equal(s.jobs.length, 1) + assert.equal(s.job.id, jobId) + assert.equal(s.drafts.length, 1, 'Expected one and only one persisted draft') + const [d] = s.drafts + assert.equal(d.jobId, jobId) + assert.equal(d.projectId, spec.projectId) + assert.equal(d.status, 'draft') + assert.equal(d.chapterId, null) + assert.equal(d.title, spec.title) + assert.equal(d.body, spec.body, 'Saved body differs from the only generated body') + assert.equal(s.draftStep.status, 'completed') + const output = JSON.parse(s.draftStep.output) + assert.equal(output.checkpoint?.draftId, d.id) + assert.equal(output.checkpoint.kind, 'chapter_draft_checkpoint') + assert.equal(output.body, spec.body) + assert.equal(proseCalls().length, 1, 'An extra writing request was dispatched') + noCanon(s) + return d + } + + async function openApp() { + await releaseGate() + session = await launch(target, profile) + const identity = { pid: session.child.pid, cdpPort: session.port, profile, startedAt: now() } + entry.sessions.push(identity) + await connect(session) + client = session.client + await wait('window.novelDirector?.app && document.querySelector(".project-row")') + identity.runtime = await ev('window.novelDirector.app.getRuntimeInfo()') + identity.renderer = await ev('location.href') + verifyRuntimeIdentity(target, identity.runtime, identity.renderer) + const paths = await ev('window.novelDirector.app.getStoragePath()') + for (const p of [paths.storagePath, paths.defaultStoragePath]) assert.equal(resolve(p), database) + assert.equal(await realpath(database), database) + const file = await open(database, 'r') + try { + const { buffer } = await file.read(Buffer.alloc(16), 0, 16, 0) + assert.equal(buffer.toString(), 'SQLite format 3\0') + } finally { await file.close() } + db = new DatabaseSync(database, { readOnly: true }) + const loaded = await ev('window.novelDirector.data.load().then(({data,storagePath})=>({storagePath,ids:data.projects.map(p=>p.id),settings:data.settings}))') + assert.equal(loaded.storagePath, database) + assert.deepEqual(loaded.ids, [spec.projectId]) + assert(!loaded.settings.hasApiKey && !loaded.settings.apiKey) + for (const settings of [loaded.settings, ...roles.map(r => loaded.settings.pipelineModelRoles[r])]) { + assert.equal(settings.apiProvider, 'local') + assert.equal(settings.baseUrl, stub.baseUrl) + } + const row = '[...document.querySelectorAll(".project-row")].find(r=>r.textContent.includes(' + j(spec.name) + '))' + await wait(row) + await press('[...(' + row + ').querySelectorAll("button")].find(b=>b.textContent.trim()==="进入")') + await wait(el('.dashboard-view')) + await press(btn('生产流水线', '.sidebar')) + await wait(el('.pipeline-task-editor')) + } + + async function closeApp() { + if (db) { db.close(); db = undefined } + if (!session) return + const identity = entry.sessions.at(-1) + try { + identity.cleanup = await cleanup(session) + assert.deepEqual(identity.cleanup.remainingProcessIds, []) + identity.rendererNetwork = session.networkRequests + identity.rendererExceptions = session.exceptions.map(e => e.exception?.description ?? e.text) + assert.equal(identity.rendererNetwork.length, 0, 'Unexpected network request from renderer') + assert.deepEqual(identity.rendererExceptions, []) + } finally { + await writeFile(join(dir, 'electron-' + entry.sessions.length + '.log'), session.logs.join('')) + session = client = undefined + } + } + + async function generate() { + const initial = snap() + assert.equal(initial.jobs.length + initial.drafts.length, 0, 'A job or draft was pre-seeded') + noCanon(initial) + if (spec.overflow) { + const config = '.pipeline-config-tools' + await click(client, el(config + ' > summary')) + const select = '[...document.querySelectorAll(".pipeline-config-tools select")].find(e=>e.querySelector("option[value=conservative]"))' + await ev('(()=>{const s=' + select + ';s.value="conservative";s.dispatchEvent(new Event("change",{bubbles:true}));})()') + await wait('(' + select + ').value === "conservative"') + await click(client, el(config + ' > summary')) + } + await fill(client, el('.pipeline-task-editor [name=goal]'), '林禾在车站寄存窗找回箱子里的地图。') + await fill(client, el('.pipeline-task-editor [name=targetWordCount]'), spec.target) + await press(btn('保存任务', '.pipeline-task-editor')) + const saved = await until('new idle task', s => s.job?.status === 'idle') + jobId = saved.job.id + assert.equal(saved.jobs.length, 1) + assert.equal(saved.job.targetChapterOrder, 1) + assert.equal(saved.job.chapterTaskSnapshot.targetWordCount, spec.target) + if (spec.overflow) assert.equal(saved.job.pipelineRecipeId, 'strict') + for (const role of roles) { + const config = saved.job.aiRunConfig.roles[role] + assert.equal(config.apiProvider, 'local') + assert.equal(config.baseUrl, stub.baseUrl) + assert.equal(config.modelName, 'retention-' + role) + } + assert.equal(saved.drafts.length, 0) + assert.equal(stub.calls.length, 0) + note('saved-task-before-model', compact(saved)) + await press(btn('按此任务生成', '.pipeline-task-editor')) + await press(btn('启动流水线', '.confirm-dialog')) + } + + async function showDraft(label) { + await press(btn('草稿', tabs)) + await wait(el('.pipeline-draft-body') + '?.readOnly') + assert.equal(await ev(el('.pipeline-draft-body') + '.value'), spec.body) + const notice = await ev(el('.pipeline-draft-preview > p') + '.innerText') + assert(notice.includes(String(visible(spec.body))), notice) + if (spec.overflow) { + assert.match(notice, /600.{1,3}800/) + assert(notice.includes('超出上限 ' + (visible(spec.body) - 800) + ' 字'), notice) + assert(notice.includes('明确确认'), notice) + } else assert(!notice.includes('超出上限'), notice) + await ev(el('.pipeline-draft-preview > p') + '.scrollIntoView({block:"center"})') + await screenshot(label) + note(label, { notice, ...compact(snap()) }) + } + + async function reviewRetry() { + const reached = await until('first consistency request or terminal failure', s => + stub.calls.some(c => c.kind === 'consistency') || terminal(s)) + try { + const c = stub.calls.find(c => c.kind === 'consistency') + assert(c && c.status === undefined && c.containsExactBody, 'Did not reach held reviewer with the original draft: ' + j(compact(reached))) + const original = exactDraft(reached) + entry.savedDraftId = original.id + entry.checkpoint = JSON.parse(reached.draftStep.output).checkpoint + note('draft-on-disk-before-review-reply', compact(reached)) + } finally { stub.releaseFailure() } + const failed = await until('injected review failure', terminal) + assert.equal(failed.job.status, 'failed') + assert.equal(failed.job.currentStep, 'consistency_review') + assert.equal(failed.steps.find(s => s.type === 'consistency_review')?.status, 'failed') + assert.equal(stub.calls.find(c => c.kind === 'consistency')?.status, 503) + assert.equal(exactDraft(failed).id, entry.savedDraftId) + note('consistency-review-failed', compact(failed)) + const beforeRestart = stub.calls.length + await closeApp() + await openApp() + const reopened = snap() + assert.equal(reopened.job.status, 'failed') + assert.deepEqual(exactDraft(reopened), failed.drafts[0], 'Cold restart changed the persisted candidate') + assert.notEqual(entry.sessions[0].pid, entry.sessions[1].pid) + await showDraft('reopened-original') + await press(btn('未完成审稿,直接采纳', '.pipeline-draft-actions')) + await wait(el('.confirm-dialog-message')) + entry.cancelledAcceptance = await ev(el('.confirm-dialog-message') + '.innerText') + assert(entry.cancelledAcceptance.includes('超限原文') && entry.cancelledAcceptance.includes(String(visible(spec.body)))) + await screenshot('cancelled-acceptance') + await press(btn('取消', '.confirm-dialog')) + await wait('!document.querySelector(".confirm-dialog")') + noCanon(snap()) + assert.equal(stub.calls.length, beforeRestart, 'Reopening or inspecting the candidate invoked a model') + await press(btn('重试失败步骤', mainButton)) + const retried = await until('retry completion', s => terminal(s) && s.job.updatedAt !== failed.job.updatedAt && stub.calls.length > beforeRestart) + assert.equal(retried.job.status, 'completed', retried.job.errorMessage) + assert.equal(retried.job.currentStep, 'await_user_confirmation') + assert.deepEqual(exactDraft(retried), failed.drafts[0], 'Retry changed the original candidate') + assert.deepEqual(retried.draftStep, failed.draftStep, 'Retry repeated/modified the completed writing step') + const reviews = stub.calls.filter(c => c.kind === 'consistency') + assert.deepEqual(reviews.map(c => c.status), [503, 200]) + assert(reviews.every(c => c.containsExactBody), 'A review did not receive the original body') + assert.equal(reviews[0].promptSha256, reviews[1].promptSha256) + assert(retried.consistency.some(r => r.jobId === jobId && r.draftId === entry.savedDraftId)) + assert(retried.quality.some(r => r.jobId === jobId && r.draftId === entry.savedDraftId)) + note('review-retry-same-job-step-and-draft', { ...compact(retried), retryCalls: stub.calls.slice(beforeRestart).map(c => c.kind) }) + await showDraft('after-review-retry') + } + + async function negative(s) { + assert.equal(s.job.status, 'failed') + assert.equal(s.job.currentStep, 'generate_chapter_draft') + assert.equal(s.draftStep.status, 'failed') + assert.equal(s.drafts.length, 0) + noCanon(s) + assert.equal(s.consistency.length + s.quality.length, 0) + assert(stub.calls.every(c => c.kind === 'prose')) + const output = s.draftStep.output, reason = s.draftStep.errorMessage + assert(await ev('(' + btn('草稿', tabs) + ')?.disabled === true'), 'Fragment/rejected response exposed an active draft tab') + await press(btn('步骤输出', tabs)) + await wait(el('.pipeline-step-output.failed[open]')) + const controls = await ev('[...document.querySelectorAll("button")].filter(b=>b.getClientRects().length && !b.disabled).map(b=>b.textContent.trim())') + assert(!controls.some(label => /接受章节草稿|审核后接受草稿|直接采纳|确认采纳/.test(label)), 'Rejected response exposed an acceptance action') + if (spec.negative === 'fragment') { + const part = JSON.parse(output) + assert.equal(part.kind, 'incomplete_chapter_fragment') + assert.equal(part.finishReason, spec.finish) + assert.equal(part.title, spec.title) + assert.equal(part.body, spec.body) + assert.equal(await ev(el('.pipeline-step-output.failed pre') + '.textContent'), part.body) + const notice = await ev(el('.pipeline-step-output.failed .notice') + '.innerText') + assert(notice.includes('不能采纳') && notice.includes(spec.finish)) + note('read-only-fragment', { notice, sha256: hash(part.body), reason }) + } else { + assert(!output.includes('incomplete_chapter_fragment')) + assert(!await ev('Boolean(' + el('.pipeline-step-output.failed .notice') + ')')) + assert.match(reason, spec.negative === 'broken' ? /JSON|解析/ : /空|empty|没有.*(正文|内容)|未返回/i) + } + await ev(el('.pipeline-step-output.failed') + '.scrollIntoView({block:"start"})') + await screenshot('rejected-output') + note('no-candidate-or-acceptance', { reason, ...compact(s) }) + } + + console.log('START ' + spec.id + ' ' + dir) + try { + await writeFile(join(profile, 'novel-director-data.json'), j(fixture(spec, stub.baseUrl)), { flag: 'wx' }) + await writeFile(join(dir, 'fixture-body.txt'), spec.body, { flag: 'wx' }) + await openApp() + await generate() + if (spec.overflow) await reviewRetry() + else { + const s = await until('job terminal', terminal) + if (spec.negative) await negative(s) + else { + exactDraft(s) + assert.equal(s.job.status, 'completed', s.job.errorMessage) + await showDraft('normal-ending') + } + } + assert.deepEqual(stub.calls.filter(c => c.unexpected), []) + assert.equal(proseCalls().length, spec.negative === 'empty' ? 2 : 1) + assert(proseCalls().every(c => c.hasTarget && c.status === 200)) + entry.writingUsage = (await ev('window.novelDirector.data.getProjectUsage(' + j(spec.projectId) + ')')).byPhase.find(p => p.phase === 'writing') + entry.status = 'passed' + } catch (err) { + entry.status = 'failed' + entry.error = err.stack + if (db) note('failure-sqlite', compact(snap())) + if (client) { + try { + await writeFile(join(dir, 'failure-ui.txt'), await ev('document.body.innerText')) + await screenshot('failure') + } catch { /* A startup or shutdown failure may leave no renderer. */ } + } + } finally { + stub.releaseFailure() + try { await closeApp() } catch (err) { entry.cleanupError = err.stack; entry.status = 'failed' } + await stub.close() + await saveJson(join(dir, 'requests.json'), stub.calls) + await saveJson(join(dir, 'result.json'), entry) + console.log(entry.status.toUpperCase() + ' ' + spec.id + (entry.error ? ' ' + entry.error.split('\n')[0] : '')) + } +} + +try { + report.release = await releaseGate() + await targetIdentity(root, target) + assert.equal(target.expectedAppVersion, VERSION) + const tmp = join(root, 'tmp') + await mkdir(tmp, { recursive: true }) + assert.equal(await realpath(tmp), tmp) + work = await mkdtemp(join(tmp, 'packaged-draft-retention-')) + assert(within(tmp, await realpath(work))) + await prepareTarget(root, target, work) + report.fingerprints = target.fingerprints + assert.equal(report.fingerprints.find(f => f.path === target.archive)?.sha256, ASAR) + report.status = 'running' + console.log('Packaged P0-A evidence: ' + work) + for (const spec of selected) { + await runCase(spec) + await saveJson(join(work, 'report.json'), report) + assert(!report.cases.at(-1).cleanupError, 'Previous session did not close cleanly') + } + await releaseGate() + for (const file of target.fingerprints) assert.equal(await sha256(file.path), file.sha256, 'Packaged file changed: ' + file.path) + report.status = report.cases.every(c => c.status === 'passed') ? 'passed' : 'failed' +} catch (err) { + report.error = err.stack + if (report.status !== 'not-run') report.status = 'failed' + console.error(err.message) +} finally { + report.finishedAt = now() + if (work) await saveJson(join(work, 'report.json'), report) + console.log(j({ status: report.status, cases: report.cases.map(c => [c.id, c.status]), report: work && join(work, 'report.json') })) + if (report.status !== 'passed') process.exitCode = 1 +} diff --git a/scripts/qa-project-usage-pricing.mjs b/scripts/qa-project-usage-pricing.mjs new file mode 100644 index 0000000..92b2efb --- /dev/null +++ b/scripts/qa-project-usage-pricing.mjs @@ -0,0 +1,171 @@ +// Real desktop, preload, SQLite and receipt journal; the only configured endpoint is a loopback tripwire. +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { createServer } from 'node:http' +import { appendFile, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { dirname, join, relative, resolve } from 'node:path' +import { repoRoot as root } from './utils/repo-root.mjs' +import { qaRuntimeTarget, prepareTarget, targetIdentity, verifyRuntimeIdentity } from './utils/long-novel-ui-runtime.mjs' +import { launch, connect, cleanup, evaluate, click, button, ready } from './utils/long-novel-ui-driver.mjs' + +const tmp = join(root, 'tmp') +await mkdir(tmp, { recursive: true }) +const work = await mkdtemp(join(tmp, 'project-usage-ui-')), profile = join(work, 'profile') +const target = qaRuntimeTarget(root) +let calls = 0, session +const tripwire = createServer((_req, res) => { calls++; res.writeHead(410); res.end() }) +// Synthetic historical receipts only. Current physical dispatches are tested in qa-p2-packaged-main. +const ledger = join(profile, 'novel-director-data.sqlite.usage') +const journalFor = id => join(ledger, createHash('sha256').update(id).digest('hex') + '.jsonl') +async function seedReceipts(rows) { + await mkdir(ledger, { recursive: true }) + for (const row of rows) await appendFile(journalFor(row.projectId), JSON.stringify(row) + '\n') +} +function checkQuote(text, currency, inputPrice, outputPrice, maxTokens) { + const numbers = text.replaceAll(',', '') + const inputs = numbers.match(/预计输入:(\d+) 至 (\d+) token/) + const amount = numbers.match(/按本书当前价目:([A-Z]{3}) ([\d.]+) 至 ([\d.]+)/) + assert(inputs && amount && numbers.includes(`预计输出:0 至 ${maxTokens} token`)) + assert.equal(amount[1], currency) + const expected = [Number(inputs[1]) * inputPrice / 1e6, (Number(inputs[2]) * inputPrice + maxTokens * outputPrice) / 1e6] + const display = new Intl.NumberFormat('zh-CN', { maximumSignificantDigits: 7, useGrouping: false }) + assert.deepEqual(amount.slice(2), expected.map(n => display.format(n))) +} +try { + await new Promise((done, fail) => tripwire.once('error', fail).listen(0, '127.0.0.1', done)) + const endpoint = 'http://127.0.0.1:' + tripwire.address().port, provider = 'local' + const at = '2026-10-01T00:00:00.000Z', A = 'pricing-qa-a', B = 'pricing-qa-b' + const modelA = 'fixture-a', modelB = 'fixture-b', legacyJob = 'pricing-qa-legacy' + const rates = [{ provider, endpoint, model: modelA, currency: 'CNY', inputPerMillionTokens: 1, outputPerMillionTokens: 2 }, + { provider, endpoint, model: modelB, currency: 'USD', inputPerMillionTokens: 4, outputPerMillionTokens: 0 }] + const data = { + settings: { apiProvider: provider, baseUrl: endpoint + '/v1', modelName: modelA, hasApiKey: false, retryEnabled: false, + pipelineModelRoles: { planner: { modelName: modelB, maxTokens: 321 }, revision: { modelName: modelA, maxTokens: 654 } } }, + projects: [{ id: A, name: '价格甲样书', modelRates: rates }, { id: B, name: '价格乙样书' }], + chapters: [{ projectId: A, id: 'pricing-qa-chapter', order: 1, title: '测试章节', body: '用于核对修订输入的临时文本。'.repeat(30) }], + chapterGenerationJobs: [{ id: legacyJob, projectId: A, targetChapterOrder: 1, status: 'completed', + currentStep: 'await_user_confirmation', createdAt: at, updatedAt: at }], + generationRunTraces: [{ id: 'pricing-qa-trace', projectId: A, jobId: legacyJob, aiCalls: [{ + callId: 'old-call', role: 'reviewer', stepType: 'quality_gate', model: modelA, provider, attempts: 3, + usage: { promptTokens: 4, completionTokens: 5 }, outcome: 'success', createdAt: at }] }] + } + if (target.mode !== 'packaged') { + const out = join(work, 'built') + target.required = target.required.map(p => join(out, relative(target.out, p))) + target.out = out + await (await import('electron-vite')).build({ root, build: { outDir: out }, logLevel: 'warn' }) + } + await targetIdentity(root, target) + await prepareTarget(root, target, work) + await mkdir(profile) + await writeFile(join(profile, 'novel-director-data.json'), JSON.stringify(data), { flag: 'wx' }) + session = await launch(target, profile) + await connect(session) + const c = session.client, ui = s => evaluate(c, s), wait = s => ready(c, s, 14000) + const el = s => `document.querySelector(${JSON.stringify(s)})` + const press = expr => click(c, expr) + const table = el('.usage-table'), panel = el('.project-usage'), nav = label => button(label, el('.nav-list')) + const toggle = button('全书模型用量', panel), dialog = el('.confirm-dialog:not([inert])') + const openTable = async () => { await press(toggle); await wait(table) } + const enter = async name => { + const row = `[...document.querySelectorAll('.project-row')].find(e=>e.textContent.includes(${JSON.stringify(name)}))` + await wait(row); await press(button('进入', row)); await wait(panel) + } + const snapshot = () => ui('window.novelDirector.data.load().then(s=>({revision:s.revision,jobs:s.data.chapterGenerationJobs.length,versions:s.data.revisionVersions.length}))') + const row = label => `[...${table}.tBodies[0].rows].find(r=>r.cells[0].textContent===${JSON.stringify(label)})` + const totals = row('全书') + const totalText = () => ui(totals + '.innerText') + const currency = (name, val) => `[...${row('全书')}.querySelectorAll(".usage-currency strong")].some(x=>x.textContent===${JSON.stringify(name + ' ' + val)})` + const waitCurrency = async (name, val) => wait(`${table} && ${currency(name, val)}`) + const fill = async (expr, v) => ui(`(()=>{const e=${expr}; Object.getOwnPropertyDescriptor(Object.getPrototypeOf(e),'value').set.call(e,${JSON.stringify(v)}); + e.dispatchEvent(new Event(e.tagName==='SELECT'?'change':'input',{bubbles:true}))})()`) + + await wait(el('.project-row')) + const rendererUrl = await ui('location.href') + verifyRuntimeIdentity(target, await ui('window.novelDirector.app.getRuntimeInfo()'), rendererUrl) + const path = await ui('window.novelDirector.app.getStoragePath().then(r=>r.storagePath)') + assert.equal(await realpath(path), await realpath(join(profile, 'novel-director-data.sqlite'))) + console.log('DESKTOP', JSON.stringify({ ...target.observedRuntime, rendererUrl, storagePath: path, pid: session.child.pid, + port: session.port, tripwire: endpoint })) + await enter('价格甲样书') + const receipt = (id, phase, model, usage, patch = {}) => ({ id, callId: id, projectId: A, provider, endpoint, + model, phase, usage, outcome: 'success', startedAt: at, ...patch }) + await seedReceipts([ + receipt('a1', 'writing', modelA, { promptTokens: 1_000_000, completionTokens: 1_000_000 }), + receipt('a2', 'planning', modelB, { promptTokens: 1_000_000, completionTokens: 0 }), + receipt('a3', 'review', modelA, undefined, { outcome: 'failed' }), + receipt('a4', 'extraction', modelA, { totalTokens: 3 }), + receipt('a5', 'revision', 'fixture-cli', { totalTokens: 2 }, { provider: 'codex_cli', endpoint: '' }), + receipt('b1', 'other', modelA, undefined, { projectId: B }) + ]) + const beforeRead = await snapshot() + await openTable() + await waitCurrency('CNY', 3); await waitCurrency('USD', 4) + assert.match(await ui(row('全书') + '.cells[1].textContent'), /^5 次失败 1$/) + assert.match(await ui(row('审稿') + '.cells[4].textContent'), /未知 1/) + assert.match(await ui(row('提取') + '.cells[2].textContent'), /输入 未知.*输出 未知/) + assert.match(await ui(row('修订') + '.cells[5].textContent'), /订阅 1/) + assert.match(await ui(panel + '.innerText'), /旧重试中有 2 次未报告实耗/) + await press(el('.usage-coverage summary')) + assert.match(await ui(panel + '.innerText'), /旧逻辑调用 1 次,不计入实际请求数/) + assert.deepEqual(await snapshot(), beforeRead) + await press(button('编辑价目', panel)) + const editor = el('.usage-rate-editor'), firstInput = el('.usage-rate-editor input[type=number]') + await fill(firstInput, '6') + await press(button('保存价目', editor)) + await waitCurrency('CNY', 8); await waitCurrency('USD', 4) + assert.equal(await ui('window.novelDirector.data.load().then(r=>r.data.projects.find(p=>p.id==="pricing-qa-a").modelRates[0].inputPerMillionTokens)'), 6) + // Add a genuinely new author rate; the URL suffix is not part of the saved price. + await press(button('编辑价目', panel)); await press(button('添加价目', editor)) + const field = selector => el('.usage-rate-editor fieldset:last-of-type ' + selector) + await fill(field('select'), 'local'); await fill(field('input[type=url]'), endpoint + '/v1?token=discarded') + await fill(field('input[maxlength="128"]'), 'fixture-c'); await fill(field('input[maxlength="3"]'), 'eur') + await fill(field('label:nth-child(5) input'), '0'); await fill(field('label:nth-child(6) input'), '1') + await press(button('保存价目', editor)) + await wait(panel + '?.innerText.includes("本书价目 3 条") && !' + editor) + const persisted = await ui('window.novelDirector.data.load().then(r=>r.data.projects.find(p=>p.id==="pricing-qa-a").modelRates)') + assert.equal(persisted[2].endpoint, endpoint); assert.equal(persisted[2].currency, 'EUR') + + await press(toggle); await wait('!' + table) + await seedReceipts([receipt('a6', 'other', 'fixture-c', { promptTokens: 0, completionTokens: 1_000_000 })]) + await openTable() + await waitCurrency('EUR', 1) + assert.match(await totalText(), /6 次/) + const beforeCancel = await snapshot(), journal = journalFor(A), receipts = await readFile(journal, 'utf8') + await press(nav('生产流水线')); await wait(el('.pipeline-config-tools summary')) + await press(el('.pipeline-config-tools summary')); await press(el('.pipeline-config-start')) + await wait(dialog) + const message = () => ui(dialog + '.innerText') + assert.match(await message(), /流水线启动前估算[\s\S]*预计输入:[\d,]+ 至 [\d,]+ token[\s\S]*预计输出:0 至 321 token[\s\S]*USD [\d.]+ 至 [\d.]+[\s\S]*全书预算未知/u) + checkQuote(await message(), 'USD', 4, 0, 321) + await press(button('取消', dialog)); await wait('!' + el('.confirm-overlay')) + assert.deepEqual(await snapshot(), beforeCancel) + await press(nav('修订工作台')); await wait(button('生成修订版本')) + await press(button('生成修订版本')); await wait(dialog) + assert.match(await message(), /修订发起前估算[\s\S]*预计输入:[\d,]+ 至 [\d,]+ token[\s\S]*预计输出:0 至 654 token[\s\S]*CNY [\d.]+ 至 [\d.]+/u) + checkQuote(await message(), 'CNY', 6, 2, 654) + await press(button('取消', dialog)); await wait('!' + el('.confirm-overlay')) + assert.deepEqual(await snapshot(), beforeCancel) + assert.equal(await readFile(journal, 'utf8'), receipts) + await press(button('返回项目列表')); await enter('价格乙样书'); await openTable() + assert.match(await ui(totals + '.cells[1].textContent'), /^1 次失败 0$/) + assert(!/CNY|USD|EUR/.test(await totalText())) + await press(button('返回项目列表')); await enter('价格甲样书'); await openTable(); await waitCurrency('CNY', 8) + assert.equal(calls, 0, 'A model request reached the loopback tripwire') + assert.deepEqual(session.exceptions, []); assert.deepEqual(session.networkRequests, []) + console.log('PASS isolated Electron + SQLite: per-phase physical/legacy/unknown, CNY 3 -> 8, USD 4 unchanged, EUR on reopen, book isolation, both preflights cancelled without writes or requests.') +} finally { + try { + const closed = await cleanup(session) + if (closed) { + assert.deepEqual(closed.remainingProcessIds, []) + console.log('CLOSED', JSON.stringify({ pid: session.child.pid, remainingProcessIds: [] })) + } + } finally { + if (tripwire.listening) await new Promise(done => tripwire.close(done)) + assert.equal(dirname(await realpath(work)), await realpath(tmp)) + assert.equal(resolve(profile), join(work, 'profile')) + await rm(work, { recursive: true, force: true, maxRetries: 4 }) + assert.equal(calls, 0, 'Unexpected loopback request during shutdown') + } +} diff --git a/scripts/qa-story-direction-range.mjs b/scripts/qa-story-direction-range.mjs new file mode 100644 index 0000000..d5ffd7c --- /dev/null +++ b/scripts/qa-story-direction-range.mjs @@ -0,0 +1,173 @@ +// --packaged runs the release executable; the default uses the existing preview output. +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { repoRoot as root } from './utils/repo-root.mjs' +import { qaRuntimeTarget, targetIdentity, prepareTarget, connectQaSession, verifyQaOffline, cleanupQaRun } from './utils/long-novel-ui-runtime.mjs' +import { launch, cleanup, evaluate, click, button, ready } from './utils/long-novel-ui-driver.mjs' +import { startProseRejectSink } from './utils/qa-prose-first-fixture.mjs' + +const books = [91, 96].map(start => ({ start, id: 'book-' + start, name: '收尾验收 ' + start, goal: '第96章核对回执' + start })) +const data = { projects: books.map(({ id, name }) => ({ id, name })), + chapters: books.flatMap(({ id: projectId, start }) => Array.from({ length: start - 1 }, (_, i) => ({ + id: projectId + '-' + i, projectId, order: i + 1, title: '样章' + (i + 1), body: '一页虚构记录。', summary: '整理记录。' + }))), + settings: { apiProvider: 'openai', apiKey: '', hasApiKey: false, modelName: 'offline-fixture', retryEnabled: false }, + // Import the inconsistent legacy row raw so the desktop itself derives its warning. + storyDirectionGuides: [{ id: 'legacy-six', projectId: books[0].id, status: 'draft', title: '旧长度', + startChapterOrder: 91, endChapterOrder: 96, horizonChapters: 5, chapterBeats: [] }] } +const tmp = join(root, 'tmp') +await mkdir(tmp, { recursive: true }) +const work = await mkdtemp(join(tmp, 'story-direction-ui-')), profile = join(work, 'profile') +const target = qaRuntimeTarget(root) +let session, c, sink +const nav = 'document.querySelector(".nav-list")', view = 'document.querySelector(".story-direction-view")' +const status = view + '?.querySelector("[role=status]")?.textContent' +const first = '.story-direction-controls input[type=number]:first-of-type' +const last = '.story-direction-controls label:nth-child(2) input[type=number]' +const headings = '[...document.querySelectorAll(".story-direction-editor article h4")].map(e=>e.textContent.trim())' +const read = expr => evaluate(c, expr), wait = expr => ready(c, expr, 15000) +async function press(label, scope) { + const target = button(label, scope) + try { await click(c, target) } catch (error) { + const hit = await read(`(() => {const rect = (${target})?.getBoundingClientRect(); if (!rect) return 'absent'; + const el = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2); + return { box: rect.toJSON(), hit: el?.outerHTML.slice(0, 250), dialog: document.querySelector('[role=dialog]')?.innerText }; })()`) + throw new Error(label + ': ' + JSON.stringify(hit), { cause: error }) + } +} + +async function startDesktop() { + session = await launch(target, profile) + await connectQaSession(target, session, profile) + c = session.client + await c.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }) +} +async function stopDesktop() { + await verifyQaOffline(session, sink) + assert.deepEqual((await cleanup(session)).remainingProcessIds, []) + console.log('CLOSED', JSON.stringify({ pid: session.child.pid, rendererRequests: 0, remainingProcessIds: [] })) + session = null +} +async function enterBook(book) { + if (await read('!!' + nav)) await press('返回项目列表') + const row = '[...document.querySelectorAll(".project-row")].find(e=>e.textContent.includes(' + JSON.stringify(book.name) + '))' + await wait(row) + await press('进入', row) + await wait(nav) + await wait(nav + '.getBoundingClientRect().left >= 0 && ' + button('剧情导向', nav)) + await press('剧情导向', nav) + await wait(view) +} +async function change(selector, value) { + await read(`(() => { + const el = document.querySelector(${JSON.stringify(selector)}); + if (!el) throw new Error('Missing fixture control'); + el.focus(); + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set.call(el, ${JSON.stringify(String(value))}); + el.dispatchEvent(new Event(el.tagName === 'SELECT' ? 'change' : 'input', {bubbles:true})); + el.blur(); + })()`) +} +async function stored(book, status) { + return read(`window.novelDirector.data.load().then(r => r.data.storyDirectionGuides.filter(g => + g.projectId === ${JSON.stringify(book.id)} && g.status === ${JSON.stringify(status)} && g.id !== 'legacy-six'))`) +} +function checkGuide(g, book) { + const expected = Array.from({ length: 97 - book.start }, (_, i) => [i + 1, book.start + i]) + assert.deepEqual([g.startChapterOrder, g.endChapterOrder, g.horizonChapters], [book.start, 96, expected.length]) + assert.deepEqual(g.chapterBeats.map(b => [b.chapterOffset, b.chapterOrder]), expected) + assert.equal(new Set(g.chapterBeats.map(b => b.id)).size, expected.length) +} +async function checkEditor(book) { + const expected = Array.from({ length: 97 - book.start }, (_, i) => '第 ' + (book.start + i) + ' 章') + await wait('(' + headings + ').length === ' + expected.length) + assert.deepEqual(await read(headings), expected) +} +try { + await targetIdentity(root, target) + await prepareTarget(root, target, work) + sink = await startProseRejectSink() + data.settings.baseUrl = sink.baseUrl + '/v1' + await mkdir(profile) + await writeFile(join(profile, 'novel-director-data.json'), JSON.stringify(data), { flag: 'wx' }) + await startDesktop() + const saved = new Map() + for (const book of books) { + await enterBook(book) + assert.equal(await read('document.querySelector(' + JSON.stringify(first) + ').valueAsNumber'), book.start) + if (book.start === 91) { + await change('.story-direction-controls select', 'legacy-six') + await wait(view + '.innerText.includes("记录长度为 5 章")') + assert.match(await read(view + '.innerText'), /91-96 章实际为 6 章/) + } + await press('未来 10 章', view) + await wait('document.querySelector(' + JSON.stringify(last) + ').valueAsNumber === ' + (book.start + 9)) + await change(last, book.start + 30) // Inclusive 31-chapter range exceeds the 30-chapter limit. + await wait(view + '.querySelector("[role=alert]")') + assert.match(await read(view + '.querySelector("[role=alert]").textContent'), /最多/) + assert.equal(await read(button('生成 AI 剧情指导', view) + '.disabled'), true) + await change(last, 96) + await press('新建空白导向', view) + await checkEditor(book) + await press('保存草稿', view) + await wait(status + '?.includes("已保存剧情导向草稿")') + const drafts = await stored(book, 'draft') + assert.equal(drafts.length, 1) + checkGuide(drafts[0], book) + + await press('生成 AI 剧情指导', view) + await wait(status + '?.includes("本地保守剧情指导模板")') + await checkEditor(book) + await change('.story-direction-editor article:last-child textarea', book.goal) + await press('设为当前导向', view) + await wait(status + '?.includes("已设为当前剧情导向")') + const active = await stored(book, 'active') + assert.equal(active.length, 1) + checkGuide(active[0], book) + assert.notEqual(drafts[0].id, active[0].id) + assert.equal(active[0].chapterBeats.at(-1).goal, book.goal) + saved.set(book.id, active[0]) + } + await stopDesktop() + await startDesktop() + for (const book of books) { + await enterBook(book) + await checkEditor(book) + assert.deepEqual(await stored(book, 'active'), [saved.get(book.id)]) + const drafts = await stored(book, 'draft') + assert.equal(drafts.length, 1) + if (book.start === 91) { + await change('.story-direction-controls select', drafts[0].id) + await checkEditor(book) + await change('.story-direction-editor article:last-child textarea', '保留最后一拍') + await change(first, 96) + await wait(view + '.innerText.includes("移除 5 个范围外的节拍")') + assert.equal(await read(button('保存草稿', view) + '.disabled'), true) + await press('应用章段到草稿', view) + await checkEditor({ start: 96 }) + checkGuide((await stored(book, 'draft'))[0], book) + await press('保存草稿', view) + await wait(status + '?.includes("已保存剧情导向草稿")') + const [trimmed] = await stored(book, 'draft') + checkGuide(trimmed, { start: 96 }) + assert.equal(trimmed.id, drafts[0].id) + assert.equal(trimmed.chapterBeats[0].id, drafts[0].chapterBeats[5].id) + assert.equal(trimmed.chapterBeats[0].goal, '保留最后一拍') + assert.deepEqual(await stored(book, 'active'), [saved.get(book.id)]) + } + await press('Prompt 构建器', nav) + const control = '.prompt-controls input[type=number]', editor = 'document.querySelector("textarea.prompt-editor")' + for (const order of [96, 97]) { + await wait('document.querySelector(' + JSON.stringify(control) + ')') + await change(control, order) + await press('构建 Prompt') + await wait(editor + '?.value.includes("第 ' + order + ' 章写作 Prompt")') + assert.equal((await read(editor + '.value')).includes(book.goal), order === 96) + } + console.log(`PASS desktop ${book.start}-96: create, save, local generation, activate, SQLite restart and Prompt boundary.`) + } + await stopDesktop() +} finally { + await cleanupQaRun(root, work, session, sink) +} diff --git a/scripts/run-agent-tools.mjs b/scripts/run-agent-tools.mjs index 150e788..e33c071 100644 --- a/scripts/run-agent-tools.mjs +++ b/scripts/run-agent-tools.mjs @@ -24,6 +24,7 @@ await build({ const child = spawn(process.execPath, [outfile, ...process.argv.slice(2)], { cwd: root, + env: { ...process.env, NODE_USE_ENV_PROXY: process.env.NODE_USE_ENV_PROXY ?? '1' }, stdio: 'inherit', shell: false }) diff --git a/scripts/run-agent.mjs b/scripts/run-agent.mjs index 1f7cc42..5e6a2ca 100644 --- a/scripts/run-agent.mjs +++ b/scripts/run-agent.mjs @@ -24,6 +24,7 @@ await build({ const child = spawn(process.execPath, [outfile, ...process.argv.slice(2)], { cwd: root, + env: { ...process.env, NODE_USE_ENV_PROXY: process.env.NODE_USE_ENV_PROXY ?? '1' }, stdio: 'inherit', shell: false }) diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 96a57c7..f982c19 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -7,6 +7,7 @@ const scriptsDir = dirname(fileURLToPath(import.meta.url)) const root = dirname(scriptsDir) const tests = [ + ['validate-redundant-work.mjs'], ['validate-hig-workspace.mjs'], ['validate-character-workspace-drafts.mjs'], ['validate-home-workspace.mjs'], @@ -17,10 +18,12 @@ const tests = [ ['validate-ai-modules.mjs'], ['validate-unified-post-draft-analysis.mjs'], ['validate-codex-cli-provider.mjs'], + ['validate-api-registry.mjs'], ['validate-ai-workflow-prompt-boundaries.mjs'], ['validate-main-diagnostics-boundary.mjs'], ['validate-ai-schema-validator.mjs'], ['validate-ai-chapter-draft-recovery.mjs'], + ['validate-draft-fragments.mjs'], ['validate-ai-retry-backoff.mjs'], ['validate-ai-empty-response-recovery.mjs'], ['validate-ai-http-cancellation.mjs'], @@ -43,6 +46,14 @@ const tests = [ ['validate-reading-position.mjs'], ['validate-chapter-editor-state.mjs'], ['validate-chapter-archive.mjs'], + ['validate-book-progress.mjs'], + ['validate-book-progress-ui.mjs'], + ['validate-book-continuity-ledger.mjs'], + ['validate-book-continuity-timeline.mjs'], + ['validate-book-continuity-versions.mjs'], + ['validate-book-continuity-surfaces.mjs'], + ['validate-batch-writeback-order.mjs'], + ['validate-chapter-writeback-failures.mjs'], ['validate-project-list-recent-open-order.mjs'], ['validate-appdata-project-boundaries.mjs'], ['validate-p0-stability.mjs'], @@ -61,6 +72,12 @@ const tests = [ ['validate-generation-run-bundle.mjs'], ['validate-generation-bundle-scoped-read.mjs'], ['validate-accepted-chapter-cost.mjs'], + ['validate-project-usage.mjs'], + ['validate-project-usage-pricing.mjs'], + ['validate-project-usage-transport.mjs'], + ['validate-ai-connection-diagnostics.mjs'], + ['validate-story-direction-range.mjs'], + ['validate-chapter-title-batch.mjs'], ['validate-model-comparison-preparation.mjs'], ['validate-pipeline-bundle-handoff.mjs', '--require-native'], ['validate-chapter-task-editing.mjs'], @@ -83,8 +100,15 @@ const tests = [ ['validate-editorial-verdict-workflow.mjs'], ['validate-editorial-revision-entry.mjs'], ['validate-character-state-ledger.mjs'], + ['validate-character-state-as-of.mjs'], + ['validate-character-card-review.mjs'], + ['validate-card-balance-prompt.mjs'], ['validate-story-direction-board.mjs'], ['validate-hard-canon-pack.mjs'], + ['validate-hard-canon-mechanical.mjs'], + ['validate-hard-canon-authoring.mjs'], + ['validate-hard-canon-quality.mjs'], + ['validate-hard-canon-commit-preflight.mjs'], ['validate-pipeline-context-trace-alignment.mjs'], ['validate-revision-candidate-context-source.mjs'], ['validate-secure-credentials.mjs'], @@ -149,7 +173,8 @@ const tests = [ ['validate-agent-runtime-p1.mjs'], ['validate-agent-runtime-p2.mjs'], ['validate-agent-runtime-p3.mjs'], - ['validate-agent-pipeline-execution.mjs'], + ['validate-agent-pipeline-execution.mjs', '--sqlite-retention'], + ['validate-agent-draft-recovery.mjs'], ['validate-agent-runtime-p5.mjs'], ['validate-agent-runtime-p6.mjs'], ['validate-core-modularization.mjs'], @@ -176,6 +201,7 @@ async function runTest(test, index, total) { await new Promise((resolve, reject) => { const proc = spawn(process.execPath, [scriptPath, ...args], { cwd: root, + env: { ...process.env, NOVEL_DIRECTOR_API_REGISTRY: join(root, 'tmp', 'unused-test-api-registry.json') }, stdio: 'inherit', shell: false }) diff --git a/scripts/utils/hard-canon-editor-fixture.tsx b/scripts/utils/hard-canon-editor-fixture.tsx new file mode 100644 index 0000000..f609174 --- /dev/null +++ b/scripts/utils/hard-canon-editor-fixture.tsx @@ -0,0 +1,102 @@ +import { createRoot } from 'react-dom/client' +import { flushSync } from 'react-dom' +import { ConfirmProvider } from '../../src/renderer/src/components/ConfirmDialog' +import { HardCanonView } from '../../src/renderer/src/views/HardCanonView' +import type { SaveDataHandler } from '../../src/renderer/src/utils/saveDataState' +import type { AppData } from '../../src/shared/types' + +// This renderer only has the two loopback endpoints of the temporary book. +export async function verifyDesktop() { + const load = async (): Promise => (await fetch('/book')).json() + let data = await load(), writes = 0, pending: Promise = Promise.resolve() + const root = createRoot(document.getElementById('root')!) + const render = () => flushSync(() => root.render( + + )) + const save: SaveDataHandler = (input) => { + const operation = (async () => { + const next = typeof input === 'function' ? input(data) : input + const response = await fetch('/book', { method: 'POST', body: JSON.stringify(next) }) + if (!response.ok) throw new Error(await response.text()) + data = await load() + writes++ + render() + return { ok: true as const } + })() + pending = operation + return operation + } + const editor = () => document.querySelector('.hard-canon-draft')! + const article = (title: string) => [...document.querySelectorAll('article')].find((el) => el.querySelector('strong')?.textContent === title)! + const expect = (ok: unknown, label: string) => { if (!ok) throw new Error('Desktop: ' + label) } + const field = (name: string) => { + const label = [...editor().querySelectorAll('label')].find((node) => node.querySelector('.field-label')?.textContent === name) + if (!label) throw new Error('Missing field: ' + name) + return label.querySelector('input,textarea,select') as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement + } + function fill(name: string, value: string) { + const el = field(name) + const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value')!.set! + setter.call(el, value) + flushSync(() => el.dispatchEvent(new Event(el.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }))) + } + async function click(name: string, container: Element = editor()) { + const btn = [...container.querySelectorAll('button')].find((b) => b.textContent === name) + if (!btn) throw new Error('Missing button: ' + name) + flushSync(() => btn.click()) + await pending + await new Promise((done) => setTimeout(done, 16)) + } + const notice = () => editor().querySelector('[role=status]')?.textContent ?? '' + render() + expect(article('旧纸条')?.textContent?.includes('只作写作提示'), 'legacy prose gained a check') + expect(article('坏条件')?.textContent?.includes('只作写作提示'), 'malformed data gained a check') + fill('标题', '封笺提示'); fill('内容', '封笺不得提前拆开。') + await click('复用已有条目') + expect(writes === 0 && notice().includes('复用') && notice().includes('停用'), 'MCP record changed during desktop reuse') + + function literal(title: string) { + fill('标题', title); fill('内容', '印笺上少用暮纹。'); fill('检查方式', 'terms') + fill('检查用语(每行一项)', '暮纹\n暮印'); fill('例外用语(每行一项)', '暮纹阁') + fill('核查结果', 'block') + } + literal('01. 暮纹') + fill('全书次数上限', '三次') + await click('新增条目') + expect(writes === 0 && notice().includes('check.maxPerBook'), 'invalid cap became a chapter ban') + fill('全书次数上限', '2'); fill('起始章(含)', '4'); fill('结束章(含)', '2') + await click('新增条目') + expect(writes === 0 && notice().includes('check.toChapter'), 'reversed range was accepted') + fill('起始章(含)', ''); fill('结束章(含)', '') + await click('新增条目') + const stored = article('01. 暮纹') + expect(stored?.textContent?.includes('全书最多 2 次') && !stored.textContent.includes('本章禁止'), 'book-only cap display') + await click('编辑', stored) + expect(field('本章次数上限').value === '' && field('全书次数上限').value === '2', 'reopened caps') + fill('全书次数上限', '3') + await click('保存修改') + literal('02. 暮纹'); fill('全书次数上限', '3') + expect(editor().querySelector('.hard-canon-comparison')?.textContent?.includes('01. 暮纹'), 'unnumbered comparison result missing') + await click('另存为独立条目') + + await click('编辑', article('袖扣规则')) + expect(field('绑定角色卡(可选)').value === 'c1' && field('侧别').value === '右' && field('动作(每行一项)').value === '扣紧', 'MCP role condition failed to refill') + fill('角色别名(每行一项)', '阿迟\n南窗'); fill('适用状态(可选)', '手腕固定') + fill('动作要求', 'allow'); fill('侧别', '左'); fill('物件(每行一项)', '袖扣\n表带') + fill('结束章(含)', '9') + await click('保存修改') + + fill('标题', '外文字形'); fill('内容', '正文避免外文字形。'); fill('检查方式', 'latin') + fill('例外用语(每行一项)', 'AQUA-9') + await click('新增条目') + expect(article('外文字形')?.textContent?.includes('本章禁止出现') && article('外文字形').textContent?.includes('仅提示复核'), 'default literal limit or enforcement') + await click('编辑', article('坏条件')) + fill('检查方式', 'latin'); fill('指定外文用语(每行一项,可留空)', 'ember\nmast') + await click('保存修改') + await click('编辑', article('坏条件')) + expect(field('指定外文用语(每行一项,可留空)').value === 'ember\nmast', 'Latin filter did not survive saving') + fill('检查方式', 'hint') + await click('保存修改') + expect(article('坏条件').textContent?.includes('只作写作提示'), 'explicit removal from desktop retained the check') + return { writes } +} diff --git a/scripts/utils/load-domain-modules.mjs b/scripts/utils/load-domain-modules.mjs new file mode 100644 index 0000000..af90a8f --- /dev/null +++ b/scripts/utils/load-domain-modules.mjs @@ -0,0 +1,11 @@ +import { build } from 'esbuild' +import { repoRoot } from './repo-root.mjs' + +// Compile the production module graph rather than a hand-maintained list of transitive imports. +export async function loadDomainModules(...entries) { + const { outputFiles } = await build({ + stdin: { resolveDir: repoRoot, contents: entries.map(e => 'export * from ' + JSON.stringify('./src/' + e)).join('\n') }, + bundle: true, write: false, platform: 'node', format: 'esm', target: 'node22' + }) + return import('data:text/javascript;base64,' + Buffer.from(outputFiles[0].contents).toString('base64')) +} diff --git a/scripts/utils/long-novel-ui-runtime.mjs b/scripts/utils/long-novel-ui-runtime.mjs index 0a23040..007506a 100644 --- a/scripts/utils/long-novel-ui-runtime.mjs +++ b/scripts/utils/long-novel-ui-runtime.mjs @@ -1,16 +1,32 @@ import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { createRequire } from 'node:module' -import { copyFile, cp, mkdir, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises' +import { copyFile, cp, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { checkedPath, sha256, within } from './long-novel-ui-driver.mjs' +import { checkedPath, cleanup, connect, evaluate, ready, sha256, within } from './long-novel-ui-driver.mjs' const require = createRequire(import.meta.url) const asar = require('@electron/asar') const digest = (content) => createHash('sha256').update(content).digest('hex') const bindingRelative = join('node_modules', 'better-sqlite3', 'build', 'Release', 'better_sqlite3.node') +export function isolatedPreviewTarget(root) { + const packagedDir = join(root, 'release', 'win-unpacked'), out = join(root, 'out') + return { mode: 'isolated-out', packagedDir, out, + executable: join(root, 'node_modules', 'electron', 'dist', 'electron.exe'), + binding: join(packagedDir, 'resources', 'app.asar.unpacked', bindingRelative), + required: ['main/index.js', 'preload/index.cjs', 'renderer/index.html'].map(file => join(out, file)) } +} + +export function qaRuntimeTarget(root, packaged = process.argv.includes('--packaged')) { + if (!packaged) return isolatedPreviewTarget(root) + const packagedDir = join(root, 'release', 'win-unpacked') + return { mode: 'packaged', packagedDir, out: null, executable: join(packagedDir, 'Novel Director.exe'), + binding: join(packagedDir, 'resources', 'app.asar.unpacked', bindingRelative), + releaseInfoPath: join(root, 'release', 'BUILD_INFO.json'), required: [join(packagedDir, 'resources', 'icon.png')] } +} + export async function treeManifest(directory) { const files = [] async function visit(relativePath) { @@ -46,6 +62,17 @@ export async function targetIdentity(root, target) { assert.equal(manifest.type, 'module', 'Production main requires the ESM package contract') target.expectedAppVersion = manifest.version target.expectedMode = target.out ? 'preview' : 'packaged' + if (target.releaseInfoPath) { + const release = JSON.parse(await readFile(target.releaseInfoPath, 'utf8')) + assert.equal(release.version, packagedManifest.version, 'BUILD_INFO and app.asar versions differ') + assert.equal(resolve(dirname(target.releaseInfoPath), release.unpackedExecutable), resolve(target.executable), + 'BUILD_INFO points to a different desktop executable') + assert(release.electronVersion, 'Missing Electron version in BUILD_INFO') + target.expectedElectronVersion = release.electronVersion + for (const entry of ['out/main/index.js', 'out/preload/index.cjs', 'out/renderer/index.html']) { + assert(asar.statFile(target.archive, join(...entry.split('/')), false).size > 0, 'Missing packaged entry: ' + entry) + } + } if (target.out) { // A binding from another Electron version is not silently accepted or rebuilt. const releasePath = await checkedPath(root, join(dirname(target.packagedDir), 'BUILD_INFO.json')) @@ -138,3 +165,54 @@ export function verifyRuntimeIdentity(target, info, rendererUrl) { if (target.observedRuntime) assert.deepEqual(info, target.observedRuntime, 'Build identity changed between samples') else target.observedRuntime = info } + +export async function writeOfflineQaProfile(profile, fixture) { + const { startProseRejectSink } = await import('./qa-prose-first-fixture.mjs') + const sink = await startProseRejectSink() + try { + const settings = { ...fixture.settings, apiProvider: 'local', baseUrl: sink.baseUrl, modelName: 'qa-offline', + apiKey: '', hasApiKey: false, pipelineModelRoles: {}, retryEnabled: false, maxRetries: 0 } + await mkdir(profile) + await writeFile(join(profile, 'novel-director-data.json'), JSON.stringify({ ...fixture, settings }), { flag: 'wx' }) + return sink + } catch (error) { + await sink.close() + throw error + } +} + +export async function connectQaSession(target, session, profile) { + await connect(session) + const c = session.client + await ready(c, 'document.querySelector(".project-row,.nav-list")') + const info = await evaluate(c, 'window.novelDirector.app.getRuntimeInfo()'), rendererUrl = await evaluate(c, 'location.href') + verifyRuntimeIdentity(target, info, rendererUrl) + const storagePath = await evaluate(c, 'window.novelDirector.app.getStoragePath().then(result => result.storagePath)') + assert.equal(await realpath(storagePath), await realpath(join(profile, 'novel-director-data.sqlite')), + 'Desktop is not using the disposable SQLite database') + console.log('QA_RUNTIME', JSON.stringify({ ...info, rendererUrl, storagePath, pid: session.child.pid, debugPort: session.port })) +} + +export async function verifyQaOffline(session, sink) { + assert.deepEqual(session.exceptions, []) + assert.deepEqual(session.networkRequests, []) + assert.equal(await evaluate(session.client, 'window.novelDirector.ai.getCallProgress({})'), null, 'Unexpected model call') + assert.equal(sink.requests.length, 0, 'Model request reached the loopback-only QA endpoint') + console.log('QA_OFFLINE', JSON.stringify({ rendererRequests: session.networkRequests.length, modelProgress: null, + loopbackEndpoint: sink.baseUrl, loopbackRequests: sink.requests.length })) +} + +export async function cleanupQaRun(root, work, session, sink) { + try { + const closed = await cleanup(session) + if (closed) { + console.log('QA_CLEANUP', JSON.stringify({ pid: session.child.pid, remainingProcessIds: closed.remainingProcessIds })) + assert.deepEqual(closed.remainingProcessIds, []) + } + } finally { + await sink?.close() + } + assert.equal(dirname(await realpath(work)), await realpath(join(root, 'tmp')), 'Unexpected QA cleanup directory') + await rm(work, { recursive: true, force: true, maxRetries: 4 }) + if (sink) assert.equal(sink.requests.length, 0, 'Model request during QA shutdown') +} diff --git a/scripts/utils/pipeline-bundle-handoff-fixture.mjs b/scripts/utils/pipeline-bundle-handoff-fixture.mjs index bad674d..6a517c6 100644 --- a/scripts/utils/pipeline-bundle-handoff-fixture.mjs +++ b/scripts/utils/pipeline-bundle-handoff-fixture.mjs @@ -102,7 +102,7 @@ export function handoffPlugin(repoRoot) { usePipelineConfigState: `export const usePipelineConfigState = () => ({nextChapter:2,targetChapterOrder:2,pipelineMode:'standard', estimatedWordCount:'1000',readerEmotionTarget:'Fixture',budgetMode:'balanced',budgetMaxTokens:3456,contextSource:'auto',selectedSnapshot:null, setSelectedJobId:id=>{globalThis.__pipelineHandoff.selectedJobId=id},rememberCurrentReaderEmotionTarget:()=>{}});`, - useSelectedPipelineJob: `export const useSelectedPipelineJob = () => ({selectedJob:null,latestDraft:null,selectedSteps:[],jobs:[], + useSelectedPipelineJob: `export const useSelectedPipelineJob = () => ({selectedJob:null,latestDraft:null,selectedDrafts:[],selectedSteps:[],jobs:[], selectedCandidates:[],selectedReports:[],selectedRevisionCandidates:[],consistencyIssueById:new Map()});`, usePipelineRunner: `import {usePipelineRunner as run} from 'real-handoff-runner'; export {canSkipPipelineStep,PIPELINE_STEP_LABELS} from 'real-handoff-steps'; diff --git a/scripts/utils/pipeline-task-editor-view-fixture.mjs b/scripts/utils/pipeline-task-editor-view-fixture.mjs index 036e9e4..536ff5a 100644 --- a/scripts/utils/pipeline-task-editor-view-fixture.mjs +++ b/scripts/utils/pipeline-task-editor-view-fixture.mjs @@ -39,6 +39,7 @@ function createHookHost() { function viewFixturePlugin() { const modules = { react: `export const useState = initial => globalThis.__pipelineTaskEditorView.hooks.state(initial); + export const useMemo = read => read(); export const useEffect = (callback, dependencies) => globalThis.__pipelineTaskEditorView.hooks.effect(callback, dependencies);`, 'react/jsx-runtime': `export const jsx = (type, props, key) => typeof type === 'function' ? type({...props, key}) : {type, props, key}; export const jsxs = jsx; export const Fragment = 'fragment';`, @@ -78,7 +79,7 @@ function viewFixturePlugin() { env.selectionArgs = {selectedJobId, preparingNewTask}; const selectedJob = preparingNewTask ? null : env.jobs.find(job => job.id === selectedJobId) ?? null; const selectedSteps = selectedJob ? env.steps.filter(step => step.jobId === selectedJob.id) : []; - return { jobs: env.jobs, selectedJob, selectedSteps, selectedCandidates: [], selectedReports: [], selectedRevisionCandidates: [], + return { jobs: env.jobs, selectedJob, selectedSteps, selectedDrafts: [], selectedCandidates: [], selectedReports: [], selectedRevisionCandidates: [], selectedTrace: null, selectedAuthorSummary: null, selectedTraceSnapshot: null, consistencyIssueById: new Map(), latestDraft: null, latestQualityReport: null, traceConsistencyReport: null, traceQualityReport: null, traceContinuityBridge: null, traceRedundancyReport: null, selectedStepsKey: selectedSteps.map(step => step.id + ':' + step.status).join('|') }; diff --git a/scripts/utils/qa-draft-retention-fixture.mjs b/scripts/utils/qa-draft-retention-fixture.mjs new file mode 100644 index 0000000..760d7b7 --- /dev/null +++ b/scripts/utils/qa-draft-retention-fixture.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { createServer } from 'node:http' + +export const hash = (text) => createHash('sha256').update(text, 'utf8').digest('hex') +export const visible = (text) => [...text.replace(/\s/gu, '')].length +const title = '寄存窗' +const prose = [ + '雨停在五点一刻。林禾把一只旧木箱搬上寄存处的柜台,先垫好一张报纸,再把搭扣翻开。柜员伸过手来要看票,她把口袋翻给他看,里面只有一枚顶针。顶针压出的一圈红印还在掌心,票却不知落在哪一站了。', + '柜员拿尺子量了箱子的长宽,把数字一笔一画填进登记簿。轮到填颜色,他停下来看她。漆已经脱尽了,四个角被人摸得发亮,只有提手底下剩一点蓝。林禾把提手抬起来。柜员低下头去,终于在那格挤进一个很小的蓝字。', + '屋檐上的水顺着铁链滴进搪瓷盆。有人来取一卷凉席,也有人扛着缝纫机进门,手腕上勒了一道白痕。林禾往旁边挪了半步,替那人抵住门。等她转过身,柜员已经在木箱边放好两只杯子,其中一只杯沿缺了口。', + '她没有喝茶。箱子是父亲留下的,最后一次合上盖子的也是他。她只记得里面有一卷麻线,麻线下面压着一本车票册。柜员听她说完,把登记簿转了半圈,指给她看前一页的字。那行字跟她刚写的一模一样,连寄存二字之间留的小空当都一样。', + '林禾借来铅笔,在报纸空白处又写了一遍。小时候父亲教她写寄字,总要把上面一点单独描深。纸薄,铅笔的尖把那一点戳出了一个窟窿。她把纸举到窗前,远处月台的灯就在窟窿里亮着,列车经过时短短地暗了一下。', + '柜员从柜台下面抽出绳子,说钥匙可以暂存在这里,明天再回来领。他把绳子打了个松结,两根绳头留得很长。林禾没有把钥匙交给他。她拎起木箱试了试重量,又放回原处,轻得像是底板上只躺着那卷线。', + '她解开松结,拨开搭扣。麻线和票册都还在,另有一块方手绢,洗得已经看不出原来的颜色。手绢裹着一粒小纽扣。有人用红线在纽扣眼里绕了两圈,末端埋进布角,不拆开便看不出它是后来才缝上去的。', + '登记簿翻到最后,夹页里露出另外半张报纸。柜员把两张报纸沿着撕开的边一拼,正好合成一则寻物启事。启事上没有姓名,只有车次和座号。林禾慢慢用指腹按平裂口,感觉到背面有一道早已干透的胶水。', + '她把启事翻过来。背面是一幅铅笔画成的小地图,只画了站前的邮筒和一排旧信箱。最末一格有个细细的叉。柜员从抽屉最深处拿出一把铜钥匙,放在地图的叉上。她认得钥匙上的缺口,以前那是挂在父亲腰带上的。', + '远处传来末班车进站的铃声。柜员把窗扇慢慢推回去,只留能递出一封信的宽度。林禾把地图放回手绢,摸到纽扣上那两圈红线。她张开手,又重新合住。隔着柜台,柜员也停下了手里的铅笔。', + '他停在开口前' +].join('\n\n') + +export const cases = [ + { id: 'overflow-retry', end: '。', finish: 'stop', overflow: true }, + { id: 'length-fragment', end: '。', finish: 'length', negative: 'fragment' }, + { id: 'ellipsis-stop', end: '……', finish: 'stop' }, + { id: 'pause-no-signal', end: '' }, + { id: 'dash-stop', end: '——', finish: 'stop' }, + { id: 'broken-stop', end: '。', finish: 'stop', negative: 'broken' }, + { id: 'empty-no-signal', end: '', negative: 'empty' } +].map(spec => ({ + ...spec, title, body: prose + spec.end, projectId: 'qa-retention-' + spec.id, + name: '寄存窗 / ' + spec.id, target: spec.overflow ? '600-800' : '600-1600' +})) + +const fillFields = (names, value) => Object.fromEntries(names.split(' ').map(name => [name, value])) +const extraction = { + chapterReview: { ...fillFields('newInformation characterChanges newForeshadowing resolvedForeshadowing endingHook riskWarnings', ''), + summary: '林禾在登记簿中看见旧笔迹。', characterStateChangeSuggestions: [], + continuityBridgeSuggestion: fillFields('lastSceneLocation lastPhysicalState lastEmotionalState lastUnresolvedAction lastDialogueOrThought immediateNextBeat mustContinueFrom mustNotReset openMicroTensions', '') }, + characterSuggestions: [], foreshadowingExtraction: fillFields('newForeshadowingCandidates advancedForeshadowingIds resolvedForeshadowingIds abandonedForeshadowingCandidates statusChanges', []) +} +const consistency = { ...fillFields('timelineProblems settingConflicts characterOOC foreshadowingMisuse pacingProblems emotionPayoffProblems suggestions issues', []), severitySummary: 'low' } +const quality = { overallScore: 91, pass: true, issues: [], requiredFixes: [], optionalSuggestions: [], + dimensions: fillFields('plotCoherence characterConsistency characterStateConsistency foreshadowingControl chapterContinuity redundancyControl styleMatch pacing emotionalPayoff originality promptCompliance contextRelevanceCompliance', 91) } + +export const roles = ['planner', 'prose', 'extraction', 'reviewer', 'revision'] +export function fixture(spec, baseUrl) { + const at = '2026-01-02T03:04:05.000Z' + assert.match(baseUrl, /^http:\/\/127\.0\.0\.1:\d+\/v1$/) + const model = { apiProvider: 'local', baseUrl, modelName: 'retention-unused', temperature: 0, maxTokens: 4096, + retryEnabled: false, maxRetries: 0, requestTimeoutMs: 30000, codexCliPath: '', codexCliModel: '' } + return { schemaVersion: 3, projects: [{ id: spec.projectId, name: spec.name, genre: '小说', description: '', + targetReaders: '', coreAppeal: '', style: '', createdAt: at, updatedAt: at }], chapters: [], + settings: { ...model, apiKey: '', hasApiKey: false, enableAutoSummary: false, enableChapterDiagnostics: false, + pipelineModelRoles: Object.fromEntries(roles.map(role => [role, { ...model, modelName: 'retention-' + role }])) } } +} + +// Count physical HTTP requests, not logical pipeline steps. Never proxy or look up credentials. +export async function startStub(spec) { + const calls = [] + let releaseFailure + const failureBarrier = new Promise(resolve => { releaseFailure = resolve }) + const server = createServer(async (req, res) => { + const call = { n: calls.length + 1, at: new Date().toISOString(), method: req.method, path: req.url } + calls.push(call) + const reply = (status, payload) => { + call.status = status + res.writeHead(status, { 'Content-Type': 'application/json', Connection: 'close' }) + res.end(JSON.stringify(payload)) + } + try { + assert.equal(req.socket.remoteAddress, '127.0.0.1') + assert.equal(req.headers.host, '127.0.0.1:' + server.address().port) + assert.equal(req.method, 'POST') + assert.equal(req.url, '/v1/chat/completions') + assert(!('authorization' in req.headers), 'Local provider unexpectedly sent credentials') + const chunks = [] + let bytes = 0 + for await (const chunk of req) { + bytes += chunk.length + assert(bytes < 1024 * 1024, 'Unexpectedly large fixture request') + chunks.push(chunk) + } + const { model, messages, response_format } = JSON.parse(Buffer.concat(chunks).toString('utf8')) + assert(Array.isArray(messages)) + const prompt = messages.map(m => m.content).join('\n') + Object.assign(call, { model, responseFormat: response_format?.type ?? null, promptSha256: hash(prompt), + containsExactBody: prompt.includes(spec.body), hasTarget: prompt.includes(spec.target) }) + let content, finish = 'stop' + if (model === 'retention-prose') { + call.kind = 'prose' + const dispatch = calls.filter(c => c.kind === 'prose').length + assert(dispatch === 1 || (spec.negative === 'empty' && dispatch === 2 && !response_format), + 'Unexpected additional writing request') + content = JSON.stringify({ title, body: spec.body }) + if (spec.negative === 'broken') content = '[' + content + ',' // Intact inner object in a broken outer envelope. + if (spec.negative === 'empty') content = '' + finish = spec.finish + } else if (model === 'retention-extraction' && prompt.includes('foreshadowingExtraction')) { + call.kind = 'extraction' + content = JSON.stringify(extraction) + } else if (model === 'retention-reviewer' && prompt.includes('You are a continuity editor')) { + call.kind = 'consistency' + if (spec.overflow && calls.filter(c => c.kind === 'consistency').length === 1) { + await failureBarrier // The independent reader inspects committed SQLite before this 503 is sent. + return reply(503, { error: { message: 'P0A_QA_REVIEW_OFFLINE' } }) + } + content = JSON.stringify(consistency) + } else if (model === 'retention-reviewer' && prompt.includes('overallScore')) { + call.kind = 'quality' + content = JSON.stringify(quality) + } else { + throw Error('Unexpected model or request type: ' + model) + } + Object.assign(call, { finishReason: finish ?? null, contentBytes: Buffer.byteLength(content), contentSha256: hash(content) }) + return reply(200, { id: spec.id + '-' + call.n, choices: [{ index: 0, + message: { role: 'assistant', content }, ...(finish === undefined ? {} : { finish_reason: finish }) }], + usage: { prompt_tokens: 71, completion_tokens: content ? 37 : 0, total_tokens: content ? 108 : 71 } }) + } catch (err) { + call.unexpected = err.message + if (!res.headersSent && !res.destroyed) reply(409, { error: { message: 'Unexpected QA request' } }) + } + }) + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + return { baseUrl: 'http://127.0.0.1:' + server.address().port + '/v1', calls, releaseFailure, + async close() { + releaseFailure() + const closed = new Promise((resolve, reject) => server.close(e => e ? reject(e) : resolve())) + server.closeAllConnections() + await closed + } } +} diff --git a/scripts/validate-agent-candidate-undo.mjs b/scripts/validate-agent-candidate-undo.mjs index b16ab03..e660f09 100644 --- a/scripts/validate-agent-candidate-undo.mjs +++ b/scripts/validate-agent-candidate-undo.mjs @@ -62,6 +62,8 @@ try { import { SqliteStorageService, loadSqliteSnapshotReadonly as readNative, loadSqliteCollectionsReadonly as readNativeCollections } from 'real-undo-sqlite'; export { SqliteStorageService }; + export const loadChapterTitlesReadonly = () => { throw new Error('Unexpected title query in undo test'); }; + export const loadSqliteUsageHistoryReadonly = () => { throw new Error('Unexpected usage query in undo test'); }; function check(path) { if (!globalThis.__candidateUndoNativePaths.has(path)) throw new Error('Unregistered SQLite path forbidden: ' + path); } export const createStorageService = (path) => { check(path); diff --git a/scripts/validate-agent-draft-recovery.mjs b/scripts/validate-agent-draft-recovery.mjs new file mode 100644 index 0000000..c967ff9 --- /dev/null +++ b/scripts/validate-agent-draft-recovery.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' +import { mock } from 'node:test' +import { pathToFileURL } from 'node:url' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +const temp = resolve(tmpdir()) +const dir = await mkdtemp(join(temp, 'agent-draft-recovery-')) +const paths = { storagePath: join(dir, 'fixture.json'), userDataPath: dir } +const keyEnv = 'NOVEL_DRAFT_RECOVERY_TEST_API_KEY' +const baseUrl = 'https://draft-recovery.invalid/v1' +const missingKey = `工作环节 prose 缺少对应的 API Key(${keyEnv})。外部流程按共享清单取密钥。` +let networkAttempts = 0 +function noNetwork() { networkAttempts++; throw new Error('Network forbidden in draft recovery tests') } + +try { + process.env.NOVEL_DIRECTOR_API_REGISTRY = join(dir, 'registry.json') + delete process.env.NOVEL_DIRECTOR_API_KEY + delete process.env[keyEnv] + await writeFile(process.env.NOVEL_DIRECTOR_API_REGISTRY, JSON.stringify({ version: 1, apis: { prose: { baseUrl, keyEnv } } })) + mock.method(globalThis, 'fetch', async () => noNetwork()) + mock.method(Socket.prototype, 'connect', noNetwork) + const outfile = join(dir, 'api.mjs') + await build({ stdin: { resolveDir: repoRoot, contents: [ + "export { executeAgentChapterPipeline } from './src/agent/AgentPipelineExecutor'", + "export { AgentRunService } from './src/agent/AgentRunService'", + "export { loadAgentRuntimeData } from './src/agent/AgentRuntime'", + "export { normalizeAppData } from './src/shared/defaults'", + "export { readChapterStepFragment } from './src/shared/chapterStepFragment'", + "export { JsonStorageService } from './src/storage/JsonStorageService'", + "export { AIService as MainAIService } from './src/main/services/AIService'", + "export { ensureChapterPlan } from './src/services/ai/responseNormalizers/chapter'" + ].join('\n') }, outfile, bundle: true, platform: 'node', format: 'esm', target: 'node22', + external: ['better-sqlite3', 'electron'], logLevel: 'silent' }) + const api = await import(pathToFileURL(outfile).href) + const prepared = api.AgentRunService.prepareSingleChapterRun({ + appData: api.normalizeAppData({ projects: [{ id: 'p', name: 'Fixture' }], settings: { + apiProvider: 'local', modelName: 'local-fixture', pipelineModelRoles: { + planner: { modelName: 'planner-fixture' }, prose: { apiProvider: 'compatible', baseUrl, modelName: 'prose-fixture' } + } + } }), projectId: 'p', targetChapterOrder: 2 + }) + const reopen = () => api.loadAgentRuntimeData(paths) + const reset = (data) => writeFile(paths.storagePath, JSON.stringify(data)) + const draftStep = (data) => data.chapterGenerationSteps.find(s => s.jobId === prepared.job.id && s.type === 'generate_chapter_draft') + const run = async (options = {}, dependencies = {}) => { + const runtime = await reopen() + return api.executeAgentChapterPipeline({ appData: runtime.data, runtime, agentRunId: prepared.agentRun.id, + jobId: prepared.job.id, ...options }, dependencies) + } + const calls = [] + const proseReplies = [] + let failPlan = false + const plan = api.ensureChapterPlan({ chapterTitle: 'Paper', chapterGoal: 'Find the missing page.' }) + const body = '钟响过三次,她把蓝封皮的清单放进抽屉。'.repeat(150) + const response = (text) => ({ ok: true, content: JSON.stringify({ title: 'Paper', body: text }), finishReason: 'stop' }) + // Replace only the last transport boundary; the production role/key checks and local diagnostics still run. + mock.method(api.MainAIService.prototype, 'chatCompletion', async ({ settings }) => { + calls.push(settings.modelName) + if (settings.modelName === 'planner-fixture') return failPlan ? { ok: false, error: 'fixture-plan-fault' } : { ok: true, content: JSON.stringify(plan) } + if (settings.modelName === 'prose-fixture') { + assert.ok(proseReplies.length, 'Unexpected second prose call') + return proseReplies.shift() + } + return { ok: false, error: 'fixture stops after draft diagnostics' } + }) + + await reset(prepared.appData) + assert.equal((await run()).failedStep?.errorMessage, missingKey) + assert.deepEqual(calls, [], 'A fresh run must fail preflight before even the planner is called') + console.log('PASS fresh run: missing prose key, zero model calls') + + await reset(prepared.appData) + process.env[keyEnv] = 'fixture-only' + proseReplies.push(response(body)) + const interrupted = await run({}, { diagnostics: { async auditNovelty() { throw Error('fixture-audit-fault') } } }) + assert.equal(interrupted.failedStep?.errorMessage, 'fixture-audit-fault') + assert.equal(calls.filter(model => model === 'prose-fixture').length, 1) + const top = (await reopen()).data + const checkpoint = JSON.parse(draftStep(top).output) + assert.equal(checkpoint.kind, 'chapter_draft_checkpoint') + delete process.env[keyEnv] + calls.length = 0 + + const originalSave = api.JsonStorageService.prototype.saveIfCurrent + const saveFault = mock.method(api.JsonStorageService.prototype, 'saveIfCurrent', function (data, revision) { + if (draftStep(data).status === 'completed') throw Error('fixture-completion-save-fault') + return originalSave.call(this, data, revision) + }) + try { assert.equal((await run()).failedStep?.errorMessage, 'fixture-completion-save-fault') } + finally { saveFault.mock.restore() } + const wrapped = (await reopen()).data + assert.deepEqual(JSON.parse(draftStep(wrapped).output).checkpoint, checkpoint) + assert.deepEqual(calls, [], 'Top-level checkpoint must reach the local diagnostics without a model') + console.log('PASS top-level checkpoint: local diagnostics complete without a prose key') + + await run() + const recovered = (await reopen()).data + assert.equal(draftStep(recovered).status, 'completed') + assert.deepEqual(recovered.generatedChapterDrafts.map(d => [d.id, d.body]), [[checkpoint.draftId, body]]) + assert.equal(recovered.generationRunTraces.find(t => t.jobId === prepared.job.id).noveltyAuditResult.sourceDraftId, checkpoint.draftId) + assert.ok(recovered.redundancyReports.some(r => r.draftId === checkpoint.draftId)) + assert.ok(!calls.includes('prose-fixture')) + console.log('PASS wrapped checkpoint: same draft and local reports, no new prose call') + + for (const data of [top, wrapped]) { + await reset(data) + calls.length = 0 + const denied = await run({ readerEmotionTarget: 'A different writing instruction.' }) + assert.equal(denied.failedStep?.type, 'generate_chapter_draft') + assert.equal(denied.failedStep.errorMessage, missingKey) + assert.deepEqual(calls, []) + assert.deepEqual((await reopen()).data.generatedChapterDrafts, data.generatedChapterDrafts) + } + console.log('PASS changed inputs: both checkpoint formats require the prose key, zero model calls') + + await reset(prepared.appData) + process.env[keyEnv] = 'fixture-only' + const partial = 'The door stood open.\nThe light had gone' + proseReplies.push(response('太短。'), { ok: true, content: partial, finishReason: 'length' }) + const interruptedRepair = await run() + assert.equal(interruptedRepair.failedStep?.type, 'generate_chapter_draft') + const beforeRestart = draftStep((await reopen()).data) + assert.ok(JSON.parse(beforeRestart.output).repairSource, 'Fixture includes a prior correction tied to the old inputs') + const fragment = api.readChapterStepFragment(beforeRestart) + assert.equal(fragment.body, partial) + failPlan = true + calls.length = 0 + const upstreamFault = await run({ fromStep: 'generate_chapter_plan' }) + assert.match(upstreamFault.failedStep?.errorMessage ?? '', /fixture-plan-fault/) + assert.deepEqual(calls, ['planner-fixture']) + const pending = (await reopen()).data + assert.deepEqual(api.readChapterStepFragment(draftStep(pending)), fragment) + assert.equal(JSON.parse(draftStep(pending).output).repairSource, undefined) + assert.equal(pending.generatedChapterDrafts.length, 0, 'A retained fragment is never an acceptable draft') + failPlan = false + proseReplies.push(response(body)) + await run() + const replaced = (await reopen()).data + assert.equal(draftStep(replaced).status, 'completed') + assert.equal(api.readChapterStepFragment(draftStep(replaced)), null) + assert.deepEqual(replaced.generatedChapterDrafts.map(d => d.body), [body]) + assert.equal(proseReplies.length, 0) + console.log('PASS upstream restart: fragment survives plan failure and is replaced only by new output') + assert.equal(networkAttempts, 0) + console.log('Agent draft recovery passed; isolated JSON, 0 network attempts.') +} finally { + mock.restoreAll() + if (dirname(dir) === temp && basename(dir).startsWith('agent-draft-recovery-')) await rm(dir, { recursive: true, force: true }) +} diff --git a/scripts/validate-agent-pipeline-execution.mjs b/scripts/validate-agent-pipeline-execution.mjs index ff275ae..4497a37 100644 --- a/scripts/validate-agent-pipeline-execution.mjs +++ b/scripts/validate-agent-pipeline-execution.mjs @@ -41,6 +41,7 @@ async function loadHarness() { "export { AgentRunService, AGENT_PIPELINE_STEP_ORDER } from '../../src/agent/AgentRunService'", "export { AgentExecutionControlService } from '../../src/agent/AgentExecutionControlService'", "export { loadAgentRuntimeData } from '../../src/agent/AgentRuntime'", + "export { SqliteStorageService } from '../../src/storage/SqliteStorageService'", "export { normalizeAppData } from '../../src/shared/normalizers/appData'", "export { assertChapterTaskPresentInPrompt } from '../../src/renderer/src/views/generation/pipelineUtils'", "export { GenerationPipelineAI } from '../../src/services/ai/GenerationPipelineAI'", @@ -518,7 +519,7 @@ function phrasePositionRetryWorkflowAI() { function structurallyInvalidRetryWorkflowAI() { const stable = fakeWorkflowAI() - const earlierBody = `我${'继续写正文'.repeat(220)}` + const earlierBody = '{"title":"损坏返回","body":"我' + '继续写正文'.repeat(220) const captures = { draftCalls: 0, previousDrafts: [] } return { captures, @@ -1209,6 +1210,123 @@ async function main() { }) const reloaded = await h.loadAgentRuntimeData({ storagePath, userDataPath: outDir }) + // Prose is already paid for when local diagnostics begin. Reopen the actual isolated file inside the fault. + const recoveryRun = h.AgentRunService.prepareSingleChapterRun({ appData: fixture(h), projectId: prepared.job.projectId, + targetChapterOrder: 2, goal: '验证超限草稿在审稿失败时仍可找回。', chapterTaskSnapshot: { ...primaryTask, targetWordCount: '1200-1300' } }) + const sqliteRetention = process.argv.includes('--sqlite-retention') + const recoveryPath = join(outDir, sqliteRetention ? 'retained-overflow.sqlite' : 'retained-overflow.json') + if (sqliteRetention) { + const storage = new h.SqliteStorageService(recoveryPath, { legacyJsonPath: join(outDir, 'absent-retention-legacy.json') }) + try { await storage.save(recoveryRun.appData) } finally { storage.close() } + } else await writeFile(recoveryPath, JSON.stringify(recoveryRun.appData), 'utf8') + const reloadRecovery = () => h.loadAgentRuntimeData({ storagePath: recoveryPath, userDataPath: outDir }) + let recoveryRuntime = await reloadRecovery() + const runRecovery = (workflowAI, diagnostics, overrides = {}) => h.executeAgentChapterPipeline({ + appData: recoveryRuntime.data, runtime: recoveryRuntime, agentRunId: recoveryRun.agentRun.id, jobId: recoveryRun.job.id, ...overrides + }, { workflowAI, diagnostics }) + const retainedBody = '钟楼落雨,灯未熄。'.repeat(220) + '他停在开口前……' + let paidDraftCalls = 0 + const retainedAI = { ...fakeWorkflowAI().service, async generateChapterDraft() { + paidDraftCalls++ + return { ok: true, usedAI: true, finishReason: 'stop', data: { title: '雨夜', body: retainedBody } } + } } + const retentionAudits = { noveltyBodies: [], qualityModels: [] } + const retainedDiagnostics = fakeDiagnostics(h, retentionAudits) + let beforeAuditDraftId + const auditFault = await runRecovery(retainedAI, { ...retainedDiagnostics, async auditNovelty() { + const onDisk = (await reloadRecovery()).data + beforeAuditDraftId = onDisk.generatedChapterDrafts.find((item) => item.jobId === recoveryRun.job.id && item.body === retainedBody)?.id + throw new Error('ISOLATED_AUDIT_FAILURE') + } }, { estimatedWordCount: '1200-1300' }) + assert(beforeAuditDraftId && auditFault.failedStep?.errorMessage === 'ISOLATED_AUDIT_FAILURE', + 'The original candidate and its ID must be durable before the diagnostic fault.') + recoveryRuntime = await reloadRecovery() + const interruptedSnapshot = structuredClone(recoveryRuntime.data) + if (sqliteRetention) { + const Database = (await import('better-sqlite3')).default + const db = new Database(recoveryPath) + try { + db.exec(`CREATE TRIGGER fail_draft_completion BEFORE INSERT ON entities + WHEN NEW.collection = 'chapterGenerationSteps' AND json_extract(NEW.json, '$.type') = 'generate_chapter_draft' + AND json_extract(NEW.json, '$.status') = 'completed' + BEGIN SELECT RAISE(ABORT, 'FIXTURE_DRAFT_COMPLETION_SAVE'); END`) + const failedSave = await runRecovery(retainedAI, retainedDiagnostics) + const saved = (await reloadRecovery()).data.chapterGenerationSteps.find((item) => item.id === failedSave.failedStep?.id) + assert(saved?.status === 'failed' && saved.errorMessage.includes('FIXTURE_DRAFT_COMPLETION_SAVE') && + JSON.parse(saved.output).checkpoint.draftId === beforeAuditDraftId, 'A failed completion save must retain a resumable draft reference.') + db.exec('DROP TRIGGER fail_draft_completion') + } finally { db.close() } + recoveryRuntime = await reloadRecovery() + } + const resumedAudit = await runRecovery(retainedAI, retainedDiagnostics) + const retainedRows = (await reloadRecovery()).data.generatedChapterDrafts.filter((item) => item.jobId === recoveryRun.job.id) + const completedDraftStep = resumedAudit.appData.chapterGenerationSteps.find((item) => item.jobId === recoveryRun.job.id && item.type === 'generate_chapter_draft') + assert(!resumedAudit.failedStep && paidDraftCalls === 1 && retentionAudits.noveltyBodies.length === 1 && + resumedAudit.appData.redundancyReports.filter((row) => row.draftId === beforeAuditDraftId).length === 1 && + retainedRows.length === 1 && retainedRows[0].id === beforeAuditDraftId && + retainedRows[0].body === retainedBody && JSON.parse(completedDraftStep.output).lengthNotice.includes('1300'), + 'Retry must finish diagnostics on the saved draft without rewriting, duplicating or losing the overlength warning.', resumedAudit.failedStep) + + const editedPath = join(outDir, 'retained-author-edit.json') + const editedBody = '钟楼的门已经关上。'.repeat(138) + '他停在开口前' + interruptedSnapshot.generatedChapterDrafts.find((row) => row.id === beforeAuditDraftId).body = editedBody + await writeFile(editedPath, JSON.stringify(interruptedSnapshot), 'utf8') + const editedRuntime = await h.loadAgentRuntimeData({ storagePath: editedPath, userDataPath: outDir }) + const editedCaptures = { noveltyBodies: [], qualityModels: [] } + const editedRun = await h.executeAgentChapterPipeline({ appData: editedRuntime.data, runtime: editedRuntime, + agentRunId: recoveryRun.agentRun.id, jobId: recoveryRun.job.id }, { workflowAI: retainedAI, diagnostics: fakeDiagnostics(h, editedCaptures) }) + const editedStep = editedRun.appData.chapterGenerationSteps.find((step) => step.jobId === recoveryRun.job.id && step.type === 'generate_chapter_draft') + assert(!editedRun.failedStep && paidDraftCalls === 1 && editedCaptures.noveltyBodies.includes(editedBody) && + !editedCaptures.noveltyBodies.includes(retainedBody) && JSON.parse(editedStep.output).body === editedBody && + !JSON.parse(editedStep.output).lengthNotice, 'Resumed diagnostics and length notice must use the author edit, not the model checkpoint.') + + for (const [kind, change, reason] of [ + ['too-short-edit', (data) => { data.generatedChapterDrafts.find((row) => row.id === beforeAuditDraftId).body = '他走了。' }, /已保留手改草稿/], + ['broken-checkpoint', (data) => { data.chapterGenerationSteps.find((step) => step.id === auditFault.failedStep.id).output = '{"kind":"chapter_draft_check' }, /恢复记录不完整/] + ]) { + const damaged = structuredClone(interruptedSnapshot), path = join(outDir, kind + '.json') + change(damaged) + await writeFile(path, JSON.stringify(damaged), 'utf8') + const runtime = await h.loadAgentRuntimeData({ storagePath: path, userDataPath: outDir }) + const captures = { noveltyBodies: [], qualityModels: [] } + const stopped = await h.executeAgentChapterPipeline({ appData: runtime.data, runtime, + agentRunId: recoveryRun.agentRun.id, jobId: recoveryRun.job.id }, { workflowAI: retainedAI, diagnostics: fakeDiagnostics(h, captures) }) + assert(stopped.failedStep?.type === 'generate_chapter_draft' && reason.test(stopped.failedStep.errorMessage) && + paidDraftCalls === 1 && !captures.noveltyBodies.length && stopped.appData.generatedChapterDrafts.filter(row => row.jobId === recoveryRun.job.id).length === 1, + 'Invalid author edits or broken recovery markers must stop locally, before a paid rewrite or review.', stopped.failedStep) + } + + const partialRun = h.AgentRunService.prepareSingleChapterRun({ appData: fixture(h), projectId: prepared.job.projectId, + targetChapterOrder: 2, goal: '验证服务商确报截断只保留片段。', chapterTaskSnapshot: primaryTask }) + const partialPath = join(outDir, 'unfinished-fragment.json') + await writeFile(partialPath, JSON.stringify(partialRun.appData), 'utf8') + const partialRuntime = await h.loadAgentRuntimeData({ storagePath: partialPath, userDataPath: outDir }) + const interrupted = await h.executeAgentChapterPipeline({ appData: partialRuntime.data, runtime: partialRuntime, + agentRunId: partialRun.agentRun.id, jobId: partialRun.job.id }, { diagnostics: retainedDiagnostics, + workflowAI: { ...retainedAI, async generateChapterDraft() { + return { ok: false, usedAI: true, data: null, finishReason: 'length', rawText: retainedBody + '。' } + } } }) + const afterInterrupt = (await h.loadAgentRuntimeData({ storagePath: partialPath, userDataPath: outDir })).data + const fragment = JSON.parse(afterInterrupt.chapterGenerationSteps.find((step) => step.id === interrupted.failedStep?.id).output) + assert(fragment.kind === 'incomplete_chapter_fragment' && fragment.body === retainedBody + '。' && + !afterInterrupt.generatedChapterDrafts.some((row) => row.jobId === partialRun.job.id), + 'A confirmed interrupted response stays recoverable but never becomes an acceptable draft, even with a final period.') + + // A compatibility adapter may have salvaged an inner object from an unfinished outer array. + const envelopePath = join(outDir, 'unfinished-outer-array.json') + await writeFile(envelopePath, JSON.stringify(partialRun.appData), 'utf8') + const envelopeRuntime = await h.loadAgentRuntimeData({ storagePath: envelopePath, userDataPath: outDir }) + const inner = { title: '雨夜', body: retainedBody } + let envelopeCalls = 0 + const envelopeRun = await h.executeAgentChapterPipeline({ appData: envelopeRuntime.data, runtime: envelopeRuntime, + agentRunId: partialRun.agentRun.id, jobId: partialRun.job.id }, { diagnostics: retainedDiagnostics, + workflowAI: { ...retainedAI, async generateChapterDraft() { + envelopeCalls++ + return { ok: true, usedAI: true, data: inner, finishReason: 'stop', rawText: '[' + JSON.stringify(inner) + ',' } + } } }) + assert(envelopeRun.failedStep?.type === 'generate_chapter_draft' && envelopeCalls === 1 && + !envelopeRun.appData.generatedChapterDrafts.some(row => row.jobId === partialRun.job.id), + 'A malformed wire envelope must not become an acceptable candidate, even when the adapter recovered an apparent body.') const persistedSteps = reloaded.data.chapterGenerationSteps.filter((step) => step.jobId === result.job.id) assert(persistedSteps.every((step) => step.status === 'completed'), 'Every completed step must be persisted for recovery.', persistedSteps) assert(reloaded.data.generatedChapterDrafts.some((item) => item.id === draft.id), 'Generated draft must survive a storage reload.') @@ -1542,6 +1660,8 @@ async function main() { ok: true, completedStepCount: result.completedStepCount, draftId: draft.id, + retainedDraft: { storage: sqliteRetention ? 'sqlite' : 'json', proseCallsAfterAuditRetry: paidDraftCalls, + storedDrafts: retainedRows.length, incompleteFragmentStayedOutOfDrafts: true }, qualityReportId: report.id, modelSnapshot: captures.qualityModels[0], persistedStepCount: persistedSteps.length, diff --git a/scripts/validate-agent-project-grant-workflow.mjs b/scripts/validate-agent-project-grant-workflow.mjs index 65fc23d..9afb0b2 100644 --- a/scripts/validate-agent-project-grant-workflow.mjs +++ b/scripts/validate-agent-project-grant-workflow.mjs @@ -77,6 +77,72 @@ try { } for (const sqlite of [false, true]) { const label = sqlite ? 'SQLite' : 'JSON' + await check(`${label}: a current approved verdict cannot auto-commit an over-limit draft; a scoped explicit decision can`, async () => { + const h = await harness(sqlite, true) + try { + await h.edit(d => { + d.agentRuns[0].goal = '审阅当前正文' + Object.assign(d.qualityGateReports[0], { pass: true, overallScore: 98, requiredFixes: [] }) + d.editorialVerdicts = [{ id: 'v', projectId: 'p', jobId: 'job', draftId: 'draft', status: 'approved', + draftContentHash: api.draftContentHash(body), draftRevision: time, summary: '审稿通过', + sourceRefs: { qualityGateReportId: 'quality' }, createdAt: time, updatedAt: time }] + }) + const decide = () => h.call('recordAcceptanceDecision', { agentRunId: 'run', jobId: 'job' }) + const ordinary = (await decide()).preview + assert.equal(ordinary.requiresHumanApproval, false) + assert.equal(ordinary.recommendation, 'accept') + await h.edit(d => { d.chapterGenerationJobs[0].chapterTaskSnapshot = { targetWordCount: '最多10字', styleRequirement: '禁用“主角”' } }) + const risk = await decide() + assert.equal(risk.recommendation.recommendation, 'revise') + assert.equal(risk.recommendation.riskLevel, 'high') + assert.match(risk.recommendation.reasons.join(' '), /最多 10 字,实际/) + await assert.rejects(() => h.call('applyApprovedChapterCommit', { previewId: ordinary.id, confirm: true }), /超出上限/) + const p = await h.preview() + assert.match(p.review.lengthOverflowNotice, /最多 10 字/) + await assert.rejects(() => h.accept(p), /grant/i) + assert.equal((await h.data()).chapterCommitBundles.length, 0) + const grant = await h.grant(['accept_draft'], { chapterStart: 2, chapterEnd: 2 }) + await h.edit(d => { d.chapterGenerationJobs[0].chapterTaskSnapshot.targetWordCount = '最多12字' }) + await assert.rejects(() => h.accept(p), /变化/) + const fresh = await h.preview({ operationId: 'keep-long', reason: '明确保留超限原文。' }) + const saved = await h.accept(fresh) + assert.equal(saved.authorizationGrantId, grant.id) + assert.equal(saved.review.lengthOverflowNotice, fresh.review.lengthOverflowNotice) + assert.equal((await h.data()).chapters[0].body, body) + } finally { h.storage.close?.() } + }) + await check(`${label}: compact diagnostics omit fragment prose, full returns the exact step body, never an acceptable draft`, async () => { + const h = await harness(sqlite, true) + const fragment = { kind: 'incomplete_chapter_fragment', title: '保留片段', body: '仅在全文中可见\n' + '正文'.repeat(1500) + '\n最后一行', finishReason: 'length' } + try { + await h.edit(d => { + Object.assign(d.chapterGenerationJobs[0], { status: 'failed', currentStep: 'generate_chapter_draft' }) + d.chapterGenerationSteps = ['generate_chapter_draft', 'build_context'].map(type => ({ + id: type, type, jobId: 'job', status: 'failed', inputSnapshot: '', errorMessage: '', + output: JSON.stringify(fragment), createdAt: time, updatedAt: time })) + }) + const proseStep = result => result.steps.find(step => step.type === 'generate_chapter_draft') + for (const tool of ['getRunDiagnostics', 'inspectPipelineJob']) { + for (const detail of [undefined, 'summary', 'compact']) { + const result = await h.call(tool, { jobId: 'job', detail, maxChars: 99999 }) + assert.equal(proseStep(result).fragment.kind, fragment.kind) + assert.equal(proseStep(result).fragment.body, undefined) + assert.equal(result.steps.find(step => step.type === 'build_context').fragment, undefined) + assert.doesNotMatch(JSON.stringify(result), /仅在全文中可见|最后一行/) + } + assert.equal(proseStep(await h.call(tool, { jobId: 'job', detail: 'full' })).fragment.body, fragment.body) + assert.equal(proseStep(await h.call(tool, { jobId: 'job', detail: 'full', maxChars: 7 })).fragment.body, fragment.body.slice(0, 7)) + } + const state = await h.call('getChapterProductionState', { chapterOrder: 2 }) + assert.equal(state.latestAttemptHasOnlyFragment, true) + assert.equal(state.diagnostics.qualityGate, null) + assert.equal((await h.call('getAcceptanceRecommendation', { jobId: 'job' })).draftId, null) + await assert.rejects(() => h.call('getDraftText', { draftId: 'generate_chapter_draft' }), /Draft not found/) + await h.grant(['accept_draft', 'accept_unreviewed_draft']) + await assert.rejects(() => h.preview({ draftId: 'generate_chapter_draft' }), /草稿不存在/) + assert.equal((await h.data()).chapterCommitBundles.length, 0) + } finally { h.storage.close?.() } + }) await check(`${label}: high-risk candidate preview and transactional apply use real author grant; revoke blocks the next operation`, async () => { const h = await harness(sqlite) try { diff --git a/scripts/validate-agent-revision-tools.mjs b/scripts/validate-agent-revision-tools.mjs index 98a9530..08395ed 100644 --- a/scripts/validate-agent-revision-tools.mjs +++ b/scripts/validate-agent-revision-tools.mjs @@ -1,8 +1,10 @@ #!/usr/bin/env node import assert from 'node:assert/strict' -import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' +import { Socket } from 'node:net' import { isAbsolute, join, relative } from 'node:path' +import { mock } from 'node:test' import { pathToFileURL } from 'node:url' import { build } from 'esbuild' import { repoRoot } from './utils/repo-root.mjs' @@ -10,13 +12,18 @@ import { repoRoot } from './utils/repo-root.mjs' const tempRoot = join(repoRoot, 'tmp') await mkdir(tempRoot, { recursive: true }) const outDir = await mkdtemp(join(tempRoot, 'agent-revision-tools-')) +const emptyRegistry = join(outDir, 'empty-api-registry.json') +const routedRegistry = join(outDir, 'routed-api-registry.json') +const revisionRoute = { baseUrl: 'https://revision.invalid/v1', keyEnv: 'NOVEL_REVISION_TEST_API_KEY', + backupKeyEnv: 'NOVEL_REVISION_TEST_BACKUP_API_KEY' } const databases = new Map() const nativePaths = new Set() const nativeHandles = new Set() globalThis.__agentRevisionDatabases = databases globalThis.__agentRevisionNativePaths = nativePaths globalThis.__agentRevisionNativeHandles = nativeHandles -globalThis.fetch = async () => { throw new Error('Network is forbidden in Agent revision fixtures.') } +let networkAttempts = 0 +function forbidNetwork() { networkAttempts++; throw new Error('Network is forbidden in Agent revision fixtures.') } let checks = 0 let fixtureId = 0 let NativeDatabase @@ -31,6 +38,15 @@ async function test(label, run) { await run(); checks++; console.log(`PASS ${lab try { if (process.versions.electron) throw new Error('Run Agent revision fixtures with Node, not Electron.') + // This validator owns its Node process. Drop registry-compatible key names without reading their values. + process.env.NOVEL_DIRECTOR_API_REGISTRY = emptyRegistry + for (const name of Object.keys(process.env)) { + if (/_API_KEY$/i.test(name)) delete process.env[name] + } + await writeFile(emptyRegistry, JSON.stringify({ version: 1, apis: {} })) + await writeFile(routedRegistry, JSON.stringify({ version: 1, apis: { 'revision-fixture': revisionRoute } })) + mock.method(globalThis, 'fetch', async () => forbidNetwork()) + mock.method(Socket.prototype, 'connect', forbidNetwork) try { const Database = createRequire(import.meta.url)('better-sqlite3') const probe = new Database(':memory:') @@ -59,6 +75,9 @@ try { outfile, bundle: true, platform: 'node', format: 'esm', target: 'node22', external: ['better-sqlite3', 'electron'], logLevel: 'silent', plugins: [{ name: 'isolated-agent-revision-boundaries', setup(builder) { + builder.onResolve({ filter: /^(node:)?(http|https|http2|net|tls|dgram|dns(\/promises)?|child_process|electron)$/ }, ({ path }) => { + throw new Error(`Network and process transports are forbidden in the revision fixture bundle: ${path}`) + }) builder.onResolve({ filter: /AgentPipelineExecutor$/ }, () => ({ path: 'ai', namespace: 'revision-fixture' })) builder.onResolve({ filter: /SqliteStorageService$/ }, () => ({ path: 'sqlite', namespace: 'revision-fixture' })) builder.onResolve({ filter: /^real-revision-sqlite$/ }, () => ({ path: join(repoRoot, 'src/storage/SqliteStorageService.ts') })) @@ -69,6 +88,8 @@ try { import { SqliteStorageService, loadSqliteSnapshotReadonly as readNative, loadSqliteCollectionsReadonly as readNativeCollections } from 'real-revision-sqlite'; export { SqliteStorageService }; + export const loadChapterTitlesReadonly = () => { throw new Error('Unexpected title query in revision test'); }; + export const loadSqliteUsageHistoryReadonly = () => { throw new Error('Unexpected usage query in revision test'); }; export const createStorageService = (path) => { if (globalThis.__agentRevisionNativePaths.has(path)) { // No JSON fallback: this branch must construct and use the real native service. @@ -184,7 +205,6 @@ try { boundary = sqliteBoundary(initial, storagePath) storage = boundary.storage databases.set(storagePath, storage) - const { writeFile } = await import('node:fs/promises') await writeFile(storagePath, 'isolated simulated SQLite boundary; not a database') } const paths = { storagePath, userDataPath: dir } @@ -346,8 +366,8 @@ try { assert.deepEqual(await h.snapshot(), before) }) - await test('seven strict descriptors are registered', () => { - const names = ['getRevisionSessions', 'getRevisionVersion', 'createRevisionRequest', 'generateRevisionVersion', 'editRevisionVersion', 'previewRevisionCommit', 'applyApprovedRevisionCommit'] + await test('eight strict descriptors are registered', () => { + const names = ['getRevisionSessions', 'getRevisionVersion', 'createRevisionRequest', 'generateRevisionVersion', 'createManualRevisionVersion', 'editRevisionVersion', 'previewRevisionCommit', 'applyApprovedRevisionCommit'] for (const name of names) { const definition = api.AgentToolService.listTools().find((tool) => tool.name === `agent.${name}`) assert.ok(definition); assert.equal(definition.inputSchema.additionalProperties, false) @@ -483,6 +503,28 @@ try { await h.apply(await h.preview(edited, { operationId: 'edited-preview' })) assert.equal((await h.snapshot()).data.chapters[0].body, ' Explicit Agent edit.\n') }) + await test(`${kind}: manual candidates skip the model and still go through preview and commit`, async () => { + const h = await harness(kind) + const req = await h.request() + const manual = (extra = {}) => h.call('createManualRevisionVersion', { ...h.write, operationId: 'manual', requestId: req.requestId, + expectedRequestHash: req.requestHash, ...h.sourceArgs(req.source), body: ' Written by hand.\n', ...extra }) + const before = await h.snapshot() + await assert.rejects(manual({ expectedRequestHash: 'stale' }), /request changed/) + await assert.rejects(manual({ expectedSourceHash: 'stale' }), /[Ss]ource/) + await assert.rejects(manual({ body: before.data.chapters[0].body }), /matches the source/) + assert.deepEqual(await h.snapshot(), before) + const v = await manual({ changedSummary: 'Hand fix.' }) + assert.equal(v.version.status, 'draft') + assert.equal(h.calls.length, 0) + assert.equal((await h.snapshot()).data.chapters[0].body, before.data.chapters[0].body) + assert.equal((await manual({ changedSummary: 'Hand fix.' })).replayed, true) + await h.apply(await h.preview(v)) + assert.equal((await h.snapshot()).data.chapters[0].body, ' Written by hand.\n') + const local = await harness(kind) + const localReq = await local.request({ targetRange: target }) + await assert.rejects(local.call('createManualRevisionVersion', { ...local.write, operationId: 'manual-local', requestId: localReq.requestId, + expectedRequestHash: localReq.requestHash, ...local.sourceArgs(localReq.source), body: 'Whole text.' }), /full text/) + }) await test(`${kind}: failed AI returns no fabricated version; local broader response requires acknowledgement`, async () => { const h = await harness(kind) const req = await h.request({ targetRange: target }) @@ -595,17 +637,53 @@ try { assert.equal(data.agentActionPreviews[0].status, 'applied') assert.equal(data.revisionCommitBundles.length, 1) }) - await test('json: no API configuration cannot save the local fallback as generated prose', async () => { - const data = fixture(); data.settings.apiProvider = 'openai'; data.settings.hasApiKey = false - const previous = process.env.NOVEL_DIRECTOR_API_KEY - delete process.env.NOVEL_DIRECTOR_API_KEY - try { + for (const { label, registry, keyEnv, error } of [ + { label: 'no route or key rejects fallback prose', registry: emptyRegistry, + error: 'AI did not generate a revision; no fallback prose was saved.' }, + { label: 'registered route without either key names the missing credential', registry: routedRegistry, + error: `修订使用的服务 revision-fixture 缺少 ${revisionRoute.keyEnv}。` }, + { label: 'fake primary key alone generates one pending candidate', registry: routedRegistry, keyEnv: revisionRoute.keyEnv }, + { label: 'fake backup key alone generates one pending candidate', registry: routedRegistry, keyEnv: revisionRoute.backupKeyEnv } + ]) { + await test(`json: revision API configuration: ${label}`, async () => { + const data = fixture() + Object.assign(data.settings, { apiProvider: 'openai', baseUrl: revisionRoute.baseUrl, hasApiKey: false }) const h = await harness('json', data) - await assert.rejects(h.generate(await h.request()), /fallback|did not generate/) - assert.equal(h.calls.length, 0) - assert.equal((await h.snapshot()).data.revisionVersions.length, 0) - } finally { if (previous === undefined) delete process.env.NOVEL_DIRECTOR_API_KEY; else process.env.NOVEL_DIRECTOR_API_KEY = previous } - }) + const req = await h.request() + const before = await h.snapshot() + const save = mock.method(api.JsonStorageService.prototype, 'saveIfCurrent') + process.env.NOVEL_DIRECTOR_API_REGISTRY = registry + if (keyEnv) process.env[keyEnv] = 'revision-fixture-only' + try { + if (error) { + await assert.rejects(h.generate(req), { name: 'Error', message: error }) + assert.equal(h.calls.length, 0) + assert.equal(save.mock.callCount(), 0, 'Rejected generation must not attempt a save') + const after = await h.snapshot() + assert.equal(after.data.revisionVersions.length, 0) + assert.deepEqual(after, before, 'Rejected generation must preserve all data and the storage revision') + } else { + const result = await h.generate(req) + assert.equal(h.calls.length, 1) + assert.equal(h.calls[0].settings.baseUrl, revisionRoute.baseUrl) + assert.equal(h.calls[0].settings.hasApiKey, true) + assert.equal(save.mock.callCount(), 1) + const after = await h.snapshot() + assert.equal(after.data.revisionVersions.length, 1) + const [version] = after.data.revisionVersions + assert.equal(version.id, result.version.id) + assert.equal(version.requestId, req.requestId) + assert.equal(version.status, 'pending') + assert.equal(version.body, body.replace(target, replacement)) + assert.deepEqual(after.data.chapters, before.data.chapters) + } + } finally { + save.mock.restore() + if (keyEnv) delete process.env[keyEnv] + process.env.NOVEL_DIRECTOR_API_REGISTRY = emptyRegistry + } + }) + } await test('sqlite-boundary: injected entity failure rolls back manuscript, versions, metadata and revision', async () => { const h = await harness('sqlite-boundary') @@ -650,9 +728,11 @@ try { assert.equal((await h.snapshot()).data.revisionCommitBundles.length, 1) }) } - console.log(`Agent revision tools passed (${checks} focused checks; ${nativeChecks} real native SQLite check). JSON: real isolated files. SQLite fault injection: simulated I/O/rollback, separate from native. AI: real AIService with stub transport. No UI QA.`) + assert.equal(networkAttempts, 0, 'Even caught network attempts must fail the suite') + console.log(`Agent revision tools passed (${checks} focused checks; ${nativeChecks} real native SQLite check). JSON: real isolated files. SQLite fault injection: simulated I/O/rollback, separate from native. AI: real AIService with stub transport; ${networkAttempts} network attempts. No UI QA.`) console.log(nativeChecks ? `Native SQLite: Node ${process.version} ABI ${process.versions.modules}; real isolated file, reopened reads, direct SQL integrity check; all handles closed.` : `Native SQLite: SKIP (${nativeSkipReason}).`) } finally { + mock.restoreAll() for (const handle of nativeHandles) { try { handle.close() } finally { nativeHandles.delete(handle) } } delete globalThis.__agentRevisionDatabases delete globalThis.__agentRevisionNativePaths diff --git a/scripts/validate-agent-world-tools.mjs b/scripts/validate-agent-world-tools.mjs index 73b54c5..cfa67ba 100644 --- a/scripts/validate-agent-world-tools.mjs +++ b/scripts/validate-agent-world-tools.mjs @@ -251,17 +251,47 @@ try { await assert.rejects(h.preview({ ...state, operationId: 'state-with-foreign-source', patch: { ...state.patch, sourceChapterId: 'chapter-p2' } }), /missing or foreign chapter/) + const conflict = await h.preview({ + entity: 'story_direction', action: 'create', operationId: 'legacy-length-conflict', + patch: { title: '旧长度冲突', status: 'active', startChapterOrder: 91, horizonChapters: 5, endChapterOrder: 96 }, source + }) + assert.equal(conflict.after.horizonChapters, 6) + assert.match(conflict.after.warnings.join(' '), /长度冲突.*5 章.*91-96.*6 章/) + assert.ok(!(await h.runtime()).data.storyDirectionGuides.some(guide => guide.id === conflict.id)) await assert.rejects(h.preview({ entity: 'story_direction', action: 'create', operationId: 'bad-active-range', - patch: { title: '错误范围', status: 'active', startChapterOrder: 5, horizonChapters: 5, endChapterOrder: 8 }, source - }), /must cover exactly/) + patch: { title: '倒序章段', status: 'active', startChapterOrder: 91, endChapterOrder: 90 }, source + }), /结束章节.*不能早于/) await assert.rejects(h.preview({ entity: 'story_direction', action: 'create', operationId: 'bad-active-beat', patch: { title: '错误节拍', status: 'active', startChapterOrder: 5, horizonChapters: 5, endChapterOrder: 9, chapterBeats: [{ id: 'beat-outside', chapterOffset: 1, chapterOrder: 7 }] }, source - }), /beats must stay within/) + }), /第 7 章的节拍序号应为 3/) + }) + + await test(`${kind}: six-chapter and single-chapter guides persist and activate under the edit_world grant`, async () => { + const h = await harness(kind) + const source = { kind: 'agent_director', reason: '导向章段验收', requestedAt: timestamp } + for (const start of [91, 96]) { + const orders = Array.from({ length: 97 - start }, (_, i) => start + i) + const create = { entity: 'story_direction', action: 'create', operationId: 'range-' + start, source, + patch: { title: '收尾章段', startChapterOrder: start, endChapterOrder: 96, + chapterBeats: orders.map((chapterOrder, i) => ({ chapterOrder, chapterOffset: i + 1, goal: '节拍' + chapterOrder })) } } + const preview = await h.preview(create) + const saved = await h.apply({ ...create, expectedFingerprint: preview.fingerprint }) + assert.deepEqual(saved.after, preview.after) + const activate = { entity: create.entity, id: saved.id, action: 'set_status', operationId: 'activate-' + start, + patch: { status: 'active' }, source } + const active = await h.preview(activate) + await h.apply({ ...activate, expectedFingerprint: active.fingerprint }) + const reloaded = (await h.read('getWorldRecord', { entity: create.entity, id: saved.id, detail: 'full' })).record + assert.equal(reloaded.status, 'active') + assert.equal(reloaded.horizonChapters, orders.length) + assert.deepEqual(reloaded.chapterBeats.map(beat => beat.chapterOrder), orders) + assert.equal((await h.runtime()).data.storyDirectionGuides.filter(guide => guide.status === 'active').length, 1) + } }) await test(`${kind}: revision conflicts, cross-project references, untrusted apply and repeated operations are controlled`, async () => { @@ -330,7 +360,7 @@ try { assert.equal(retry.replayed, true) }) - await test(`${kind}: HardCanon sourced duplicates require an explicit update and never change the receipt target`, async () => { + await test(`${kind}: HardCanon sourced duplicates reuse unchanged records and never retarget an edit`, async () => { const h = await harness(kind) const source = { kind: 'agent_director', reason: '导演明确确认来源硬设定。', requestedAt: timestamp } const create = async (operationId, patch) => { @@ -353,18 +383,24 @@ try { assert.equal(firstReceipt.after.updatedAt, savedFirst.updatedAt) const duplicate = { entity: 'hard_canon', action: 'create', operationId: 'canon-source-duplicate', - patch: { title: '重复来源规则', content: '同一来源的规则。', category: 'world_rule', sourceId: 'chapter-source-1' }, source + patch: { title: '来源规则', content: '同一来源的规则。', category: 'world_rule', sourceId: 'chapter-source-1' }, source } - await assert.rejects(h.preview(duplicate), new RegExp(`already exists.*${first.id}.*update`)) + const reuse = await h.preview(duplicate) + assert.equal(reuse.changed, false) + assert.equal(reuse.id, first.id) + assert.deepEqual(reuse.before, reuse.after) assert.equal((await h.runtime()).data.worldManagementReceipts.some((item) => item.operationId === duplicate.operationId), false) + const reused = await h.apply({ ...duplicate, expectedFingerprint: reuse.fingerprint }) + assert.equal(reused.hardCanonDuplicates.exact.id, first.id) + assert.deepEqual(reused.after, savedFirst) const other = await create('canon-source-other', { title: '另一条规则', content: '另一条内容。', category: 'world_rule', sourceId: 'chapter-source-2' }) await assert.rejects(h.preview({ entity: 'hard_canon', action: 'update', operationId: 'canon-source-update-duplicate', id: other.id, - patch: { content: '同一来源的规则。', sourceId: 'chapter-source-1' }, source - }), new RegExp(`already exists.*${first.id}.*update`)) + patch: { title: '来源规则', content: '同一来源的规则。', sourceId: 'chapter-source-1' }, source + }), new RegExp(`相同规则已存在.*${first.id}`)) assert.equal((await h.runtime()).data.hardCanonPacks.flatMap((pack) => pack.items).find((item) => item.id === other.id)?.content, '另一条内容。') }) diff --git a/scripts/validate-ai-call-progress.mjs b/scripts/validate-ai-call-progress.mjs index 0f06e65..2a4e003 100644 --- a/scripts/validate-ai-call-progress.mjs +++ b/scripts/validate-ai-call-progress.mjs @@ -88,6 +88,38 @@ try { }) const { AIHttpClient, AIService, AiCallProgressStore, MainAIJsonClient } = await import(pathToFileURL(outfile).href) + await test('official OpenAI reasoning models use the completion-token limit on both JSON attempts', async () => { + const cases = [ + ['https://api.openai.com/v1', 'gpt-6-luna', true], + ['https://api.openai.com/v1/', 'gpt-6.1-sol', true], + ['https://api.openai.com/v1', 'gpt-5.4-mini', true], + ['https://api.openai.com/v1', 'o3', true], + ['https://api.openai.com/v1', 'gpt-4.1-mini', false], + ['https://api.openai.com.example.com/v1', 'gpt-6-luna', false], + ['https://opencode.ai/inference/openai/v1', 'gpt-6-luna', false] + ] + for (const [baseUrl, modelName, modern] of cases) { + const service = serviceWith(AIService, { + postWithFallback: async (url, body, _headers, fallback, options) => { + assert.equal(url, baseUrl.replace(/\/+$/, '') + '/chat/completions') + for (const payload of [body, fallback]) { + assert.equal(payload.max_completion_tokens, modern ? 800 : undefined) + assert.equal(payload.max_tokens, modern ? undefined : 800) + assert.equal(payload.temperature, modern ? undefined : 0.3) + } + assert.deepEqual(body.response_format, { type: 'json_object' }) + assert.equal(fallback.response_format, undefined) + options.onResponseStarted() + return response() + } + }) + const result = await service.chatCompletion(request('openai-compatibility', 'call', { + settings: { apiProvider: 'openai', baseUrl, modelName, temperature: 0.3, maxTokens: 800 } + })) + assert.equal(result.ok, true, result.error) + } + }) + await test('disables thinking only for official DeepSeek Flash names and retained V4 aliases', async () => { const cases = [ { label: 'current Flash name', settings: { apiProvider: 'compatible', baseUrl: 'https://api.deepseek.com/v1', modelName: 'deepseek-flash' }, disabled: true }, diff --git a/scripts/validate-ai-connection-diagnostics.mjs b/scripts/validate-ai-connection-diagnostics.mjs new file mode 100644 index 0000000..89fa828 --- /dev/null +++ b/scripts/validate-ai-connection-diagnostics.mjs @@ -0,0 +1,373 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { createServer, request } from 'node:http' +import { connect, createServer as createTcpServer } from 'node:net' +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { delimiter, dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { repoRoot } from './utils/repo-root.mjs' + +const require = createRequire(import.meta.url) +const script = fileURLToPath(import.meta.url) +const run = promisify(execFile) +const marker = 'fixture-private-' +const privateUrl = 'https://fixture-private-user:fixture-private-pass@example.invalid/private?token=fixture-private-query' +const settings = { apiProvider: 'local', modelName: 'fixture', temperature: 0, maxTokens: 16, + requestTimeoutMs: 5_000, retryEnabled: false, maxRetries: 0 } +const messages = [{ role: 'user', content: marker + 'prompt' }] + +function safe(value) { + assert.ok(!JSON.stringify(value).includes(marker), 'private fixture data reached diagnostic output') +} + +async function compile(dir) { + const { build } = await import('esbuild') + await build({ + stdin: { resolveDir: repoRoot, contents: [ + 'main/utils/aiErrors', 'main/utils/connectionDiagnosis', 'main/services/AIHttpClient', + 'main/services/AIService', 'main/services/CodexCliService', 'shared/errorUtils' + ].map(name => `export * from './src/${name}'`).join('\n') }, + bundle: true, platform: 'node', format: 'esm', target: 'node22', logLevel: 'silent', outfile: join(dir, 'client.mjs') + }) +} + +async function checkWiring() { + const source = path => readFile(join(repoRoot, path), 'utf8') + const desktop = await source('src/main/index.ts') + assert.match(desktop, /new AIHttpClient\(\{[^}]*net\.fetch\([^}]*transport: 'electron-net'/) + assert.match(await source('src/agent/AgentPipelineExecutor.ts'), /new AIHttpClient\(\{[^}]*transport: 'node-fetch'/) + for (const file of ['scripts/run-agent.mjs', 'scripts/run-agent-tools.mjs']) { + assert.match(await source(file), /NODE_USE_ENV_PROXY: process\.env\.NODE_USE_ENV_PROXY \?\? '1'/) + } +} + +function unit(api) { + const context = { transport: 'custom-fetch', baseUrl: privateUrl } + const diagnosis = error => { const result = api.diagnoseAiConnection(error, context); safe(result); return result } + for (const [code, syscall, kind] of [ + ['ENOTFOUND', 'getaddrinfo', 'dns'], ['EAI_AGAIN', 'getaddrinfo', 'dns'], + ['UND_ERR_CONNECT_TIMEOUT', '', 'connect_timeout'], ['ETIMEDOUT', 'connect', 'connect_timeout'], + ['UND_ERR_HEADERS_TIMEOUT', '', 'read_timeout'], ['UND_ERR_BODY_TIMEOUT', '', 'read_timeout'], + ['ETIMEDOUT', 'read', 'read_timeout'], ['ETIMEDOUT', '', 'network'], ['ECONNRESET', '', 'connection'] + ]) { + const cause = Object.assign(new Error(privateUrl), { code, syscall, hostname: privateUrl }) + const error = new TypeError('fetch failed', { cause: new AggregateError([cause]) }) + assert.equal(diagnosis(error).kind, kind) + safe(api.describeAiRetryError(error)) + } + const nested = new Error('fetch failed') + nested.cause = nested + assert.equal(diagnosis(nested).kind, 'network') + assert.match(diagnosis(nested).message, /自定义 fetch/) + assert.doesNotMatch(diagnosis(nested).message, /NODE_USE_ENV_PROXY|桌面 Electron/) + const mockDesktop = api.diagnoseAiConnection(nested, { transport: 'electron-net', baseUrl: privateUrl }) + assert.match(mockDesktop.message, /非 Electron 运行时/) + assert.doesNotMatch(mockDesktop.message, /NODE_USE_ENV_PROXY/) + assert.equal(diagnosis(new api.AiRequestCancelledError()).kind, 'cancelled') + assert.equal(diagnosis(new api.AiRequestTimeoutError(300_000, 'reading')).kind, 'read_timeout') + assert.match(diagnosis(new api.AiRequestTimeoutError(300_000)).message, /300 秒硬超时/) + assert.equal(api.diagnoseAiConnection(new Error('本地参数缺失'), context), undefined) + for (const [status, payload, kind] of [ + [402, 'oops', 'quota_exhausted'], + [429, { error: { type: 'insufficient_quota', code: null } }, 'quota_exhausted'], + [429, { code: 'InsufficientBalance' }, 'quota_exhausted'], + [429, { error: { code: 'rate_limit_exceeded', message: 'insufficient_quota' } }, 'rate_limited'], + [429, { error: { code: 'RESOURCE_EXHAUSTED' } }, 'rate_limited'], + [429, 'insufficient_quota', 'rate_limited'], [401, { code: 'insufficient_quota' }, 'authentication'], + [403, { code: 'insufficient_quota' }, 'http'] + ]) { + const error = new api.AiHttpError(status, typeof payload === 'string' ? payload : JSON.stringify({ ...payload, prompt: marker })) + assert.equal(diagnosis(error).kind, kind) + assert.equal(api.isRetryableAiError(error), kind === 'rate_limited') + safe([error, error.message, error.stack, api.describeAiRetryError(error)]) + } + safe(api.describeNetworkError(new Error('Bad endpoint: ' + privateUrl, { cause: { message: marker } }))) + safe(api.redactSensitiveText('http://fixture-private-user:fixture-private-pass@localhost/?token=fixture-private-query')) + assert.ok(!api.redactSensitiveText('Proxy-Authorization: Basic Zml4dHVyZQ==').includes('Zml4dHVyZQ')) + for (const [v, supported] of [['20.19.0', false], ['22.20.0', false], ['22.21.0', true], ['23.11.1', false], ['24.0.0', true]]) { + assert.equal(api.supportsNodeFetchEnvProxy(v), supported) + } + const hint = (env, flags = [], version = '24.14.0') => api.describeNodeProxy(privateUrl, version, env, flags) + const on = { HTTPS_PROXY: privateUrl, NODE_USE_ENV_PROXY: '1' } + assert.match(hint(on, [], '22.20.0'), /不支持/) + assert.doesNotMatch(hint(on, [], '22.20.0'), /NODE_USE_ENV_PROXY=1/) + assert.match(hint({}), /未配置/) + assert.match(hint({ ...on, https_proxy: '' }), /未配置/) + assert.match(hint({ ...on, NO_PROXY: 'example.invalid:443' }), /绕过/) + assert.match(hint({ ...on, NO_PROXY: 'example.invalid:444' }), /使用代理/) + assert.match(hint({ ...on, NO_PROXY: '*.invalid' }), /绕过/) + assert.match(hint({ ...on, NO_PROXY: 'ample.invalid' }), /使用代理/) + assert.match(hint({ ...on, NODE_OPTIONS: '--use-env-proxy' }, ['--no-use-env-proxy']), /--no-use-env-proxy 禁用/) + assert.match(hint({ HTTPS_PROXY: privateUrl, NODE_USE_ENV_PROXY: '0' }, ['--use-env-proxy']), /已启用/) + safe(hint(on)) +} + +async function listen(server, protocol = 'http:') { + await new Promise((resolve, reject) => server.once('error', reject).listen(0, '127.0.0.1', resolve)) + return protocol + '//127.0.0.1:' + server.address().port +} + +async function loopbacks() { + const hits = new Map() + let proxyHits = 0 + const sockets = new Set() + const origin = createServer((req, res) => { + const [, mode, name] = new URL(req.url, 'http://127.0.0.1').pathname.split('/') + const key = mode + '/' + name + hits.set(key, 1 + (hits.get(key) ?? 0)) + req.resume() + if (name === 'drop') return req.socket.destroy() + if (name === 'stall') return + if (name === 'slow-body') { + res.writeHead(200, { 'Content-Type': 'application/json', 'X-Content-Type-Options': 'nosniff' }) + res.write('[' + ' '.repeat(8_192)) + return + } + if (name === 'cut-body') { + res.writeHead(200, { 'Content-Type': 'application/json', 'X-Content-Type-Options': 'nosniff', 'Content-Length': '65536' }) + res.write('["' + marker + ' '.repeat(8_192)) + setTimeout(() => req.socket.destroy(), 150) + return + } + const status = { quota: 429, rate: 429, pay: 402, auth: 401 }[name] ?? 200 + res.writeHead(status, { 'Content-Type': 'application/json', Connection: 'close' }) + res.end(JSON.stringify(status === 200 ? { choices: [{ message: { content: 'fixture-ok' } }] } : + { error: { code: name === 'quota' ? 'insufficient_quota' : 'rate_limit_exceeded', + message: 'unsupported response_format insufficient_quota ' + privateUrl }, extra: marker })) + }) + const originUrl = await listen(origin) + const target = new URL(originUrl) + // Only this fixture origin is reachable, even if a client sends some other destination. + const proxy = createServer((req, res) => { + const dest = URL.canParse(req.url) && new URL(req.url) + if (!dest || dest.origin !== originUrl) { res.writeHead(403).end(); return } + proxyHits++ + const upstream = request(originUrl + dest.pathname + dest.search, { method: req.method, + headers: { 'content-type': 'application/json' }, agent: false }, reply => { + res.writeHead(reply.statusCode, reply.headers); reply.pipe(res) + }) + upstream.on('error', () => res.destroy()) + req.pipe(upstream) + }) + proxy.on('connect', (req, client, head) => { + if (req.url !== target.host) return client.destroy() + proxyHits++ + const upstream = connect(Number(target.port), '127.0.0.1', () => { + client.write('HTTP/1.1 200 Connection Established\r\n\r\n') + if (head.length) upstream.write(head) + upstream.pipe(client); client.pipe(upstream) + }) + client.on('error', () => upstream.destroy()).on('close', () => upstream.destroy()) + upstream.on('error', () => client.destroy()).on('close', () => client.destroy()) + }) + const tlsStall = createTcpServer(socket => socket.resume()) // Accept TCP, never answer the TLS ClientHello. + const servers = [origin, proxy, tlsStall] + for (const server of servers) server.on('connection', socket => { + sockets.add(socket); socket.once('close', () => sockets.delete(socket)) + }) + return { originUrl, proxyUrl: await listen(proxy), tlsUrl: await listen(tlsStall, 'https:'), hits, proxyHits: () => proxyHits, async close() { + for (const socket of sockets) socket.destroy() + await Promise.all(servers.map(s => new Promise(resolve => s.close(resolve)))) + } } +} + +async function fakeCli(dir) { + const js = join(dir, 'stand-in.cjs') + await writeFile(js, ` + const { appendFileSync } = require('node:fs') + const args = process.argv.slice(2) + appendFileSync(process.env.FIXTURE_EVENTS, args[0] + '\\n') + if (args[0] === '--version') console.log('codex-cli 9.9.9-fixture\\n' + ${JSON.stringify(privateUrl)}) + else if (args[0] === 'login') console.log('Logged in using ChatGPT\\n' + ${JSON.stringify(marker)}) + else if (args.includes('hang')) setTimeout(() => process.exit(0), 30000) + else { process.stderr.write(args.includes('auth') ? 'not authenticated' : 'failed at ' + ${JSON.stringify(privateUrl)}); process.exitCode = 23 } + `) + const win = process.platform === 'win32' + const path = join(dir, win ? 'codex.cmd' : 'codex') + await writeFile(path, win ? `@echo off\r\n"${process.execPath}" "${js}" %*\r\n` : `#!/bin/sh\nexec "${process.execPath}" "${js}" "$@"\n`) + if (!win) await chmod(path, 0o755) + return path +} + +function cleanEnv(dir) { + const env = {} + for (const key of ['SystemRoot', 'SystemDrive', 'WINDIR', 'ComSpec', 'PATHEXT', 'DISPLAY', 'WAYLAND_DISPLAY', 'XDG_RUNTIME_DIR']) { + if (process.env[key]) env[key] = process.env[key] + } + env.PATH = [dirname(process.execPath), ...(process.platform === 'win32' ? [join(env.SystemRoot, 'System32')] : ['/usr/bin', '/bin'])].join(delimiter) + for (const key of ['HOME', 'USERPROFILE', 'LOCALAPPDATA', 'APPDATA', 'TMP', 'TEMP', 'TMPDIR', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'CODEX_HOME']) env[key] = dir + return env +} + +async function probe(mode, dir, origin, proxy, electron) { + const api = await import(pathToFileURL(join(dir, 'client.mjs')).href) + const logs = [] + const logger = { info: line => logs.push(line), warn: line => logs.push(line) } + let client = new api.AIHttpClient() + if (electron) { + assert.ok(process.versions.electron && process.versions.chrome && process.type === 'browser') + await electron.app.whenReady() + const session = electron.session.defaultSession + session.webRequest.onBeforeRequest({ urls: ['http://*/*', 'https://*/*'] }, (req, done) => { + const url = new URL(req.url) + done({ cancel: url.origin !== origin && url.hostname !== 'no-record.fixture.invalid' }) + }) + await session.setProxy(mode === 'electron-direct' ? { mode: 'direct' } : + { mode: 'fixed_servers', proxyRules: proxy, proxyBypassRules: '<-loopback>' }) + client = new api.AIHttpClient({ fetch: (url, init) => electron.net.fetch(url, init), transport: 'electron-net' }) + } + const service = new api.AIService({ getApiKey: async () => { throw Error('unexpected credential read') } }, {}, { httpClient: client, logger }) + const call = (name, extra = {}) => service.chatCompletion({ messages, settings: { + ...settings, baseUrl: origin + '/' + mode + '/' + name, ...extra + } }) + if (mode === 'cli') { + const path = await fakeCli(dir) + const status = await service.getCodexCliStatus(path) + assert.ok(status.available && status.authenticated) + safe(status) + assert.match(status.message, /未查询|未知/) + assert.equal(status.version, 'codex-cli 9.9.9-fixture') + assert.equal((await readFile(process.env.FIXTURE_EVENTS, 'utf8')).trim(), '--version\nlogin') + const cli = { apiProvider: 'codex_cli', codexCliPath: path } + for (const [model, expected] of [['exit', /退出码 23/], ['auth', /身份验证/]]) { + const failed = await call('never', { ...cli, codexCliModel: model }) + safe(failed); assert.match(failed.error, expected); assert.match(failed.error, /CLI 子进程/) + assert.doesNotMatch(failed.error, /NODE_USE_ENV_PROXY/) + } + const cliService = new api.CodexCliService(logger) + const missing = await call('never', { ...cli, codexCliPath: join(dir, 'missing', process.platform === 'win32' ? 'codex.exe' : 'codex') }) + safe(missing); assert.match(missing.error, /无法启动.*CLI 子进程/) + await assert.rejects(cliService.chatCompletion({ command: path, model: 'hang', messages, maxTokens: 16, + signal: new AbortController().signal, timeoutMs: 400 }), e => api.diagnoseAiConnection(e, { transport: 'codex-cli' }).kind === 'cli_timeout') + safe(logs) + return + } + assert.equal((await call('ok')).content, 'fixture-ok') + for (const [name, expected] of [['auth', /HTTP 401.*身份验证/], ['quota', /HTTP 429.*额度不足/], + ['rate', /HTTP 429.*限流/], ['pay', /HTTP 402.*付款/]]) { + const failed = await call(name, { retryEnabled: mode === 'node-clean', maxRetries: 1 }) + safe(failed); assert.equal(failed.ok, false); assert.match(failed.error, expected) + assert.match(failed.error, /额度未知/); assert.doesNotMatch(failed.error, /代理|NODE_USE_ENV_PROXY/) + assert.match(failed.error, electron ? /桌面 Electron/ : /独立 Node\.js/) + } + const dropped = await call('drop') + safe(dropped) + const route = { 'node-clean': /未配置.*直连/, 'node-on': /按配置使用代理/, + 'node-off': /未启用.*NODE_USE_ENV_PROXY=1/, 'node-bypass': /NO_PROXY.*绕过代理直连/ } + if (electron) { + assert.match(dropped.error, /桌面 Electron.*Chromium net.fetch/) + assert.doesNotMatch(dropped.error, /NODE_USE_ENV_PROXY|独立 Node/) + } else { + assert.match(dropped.error, mode === 'node-clean' || api.supportsNodeFetchEnvProxy(process.versions.node) ? route[mode] : /不支持环境代理/) + assert.ok(dropped.error.includes(process.versions.node)) + } + if (mode === 'node-clean' || mode === 'electron-direct') { + const cut = await call('cut-body') + safe(cut); assert.match(cut.error, /已收到响应头.*读完响应正文/) + assert.doesNotMatch(cut.error, /建立连接超时|读取响应超时|代理/) + for (const [name, kind] of [['slow-body', 'read_timeout'], ['stall', 'total_timeout']]) { + await assert.rejects(client.post(origin + '/' + mode + '/' + name, {}, {}, { timeoutMs: 1_000 }), e => { + const actual = api.diagnoseAiConnection(e, { transport: client.transport })?.kind + assert.equal(actual, kind, name + ': expected ' + kind + ', got ' + actual) + return true + }) + } + const host = 'no-record.fixture.invalid' + const dns = require('node:dns') + const lookup = dns.lookup + let lookups = 0 + // Chromium uses its host resolver rules. Node uses a lookup stand-in, so neither consults public DNS. + if (!electron) dns.lookup = (name, _options, callback) => { + assert.equal(name, host) + lookups++ + process.nextTick(callback, Object.assign(new Error('fixture resolver'), { code: 'ENOTFOUND', syscall: 'getaddrinfo' })) + } + try { + const failed = await call('dns', { baseUrl: origin.replace('127.0.0.1', host) }) + safe(failed); assert.match(failed.error, /DNS 域名解析失败/) + if (!electron) assert.ok(lookups > 0) + } finally { if (!electron) dns.lookup = lookup } + } + if (mode === 'node-clean') { + const injected = new api.AIHttpClient({ fetch: async () => { throw new Error('opaque exception ' + marker + 'environment ' + privateUrl) } }) + const mock = new api.AIService({ getApiKey: async () => '' }, {}, { httpClient: injected, logger }) + const failed = await mock.chatCompletion({ messages, settings: { ...settings, baseUrl: origin } }) + safe(failed); assert.match(failed.error, /自定义 fetch/); assert.doesNotMatch(failed.error, /Electron|NODE_USE_ENV_PROXY/) + const handshake = await call('never', { baseUrl: process.env.FIXTURE_TLS_STALL, requestTimeoutMs: 18_000 }) + safe(handshake); assert.match(handshake.error, /建立连接超时.*UND_ERR_CONNECT_TIMEOUT/) + } + safe(logs) +} + +async function main() { + const dir = await mkdtemp(join(tmpdir(), 'novel-connection-diag-')) + let net + try { + await checkWiring() + await compile(dir) + const api = await import(pathToFileURL(join(dir, 'client.mjs')).href) + unit(api) + console.log('PASS 错误分类、运行时版本边界、模拟原因链和脱敏') + const nativeProxy = api.supportsNodeFetchEnvProxy(process.versions.node) + if (!nativeProxy) console.log('此 Node.js 不支持内置 fetch 环境代理;验证开关不生效,不计为已验证代理转发。') + net = await loopbacks() + const base = cleanEnv(dir) + const proxy = net.proxyUrl.replace('://', '://fixture-private-user:fixture-private-pass@') + const modes = [ + ['node-clean', {}, false], ['node-on', { HTTP_PROXY: proxy, NODE_USE_ENV_PROXY: '1' }, nativeProxy], + ['node-off', { HTTP_PROXY: proxy, NODE_USE_ENV_PROXY: '0' }, false], + ['node-bypass', { HTTP_PROXY: proxy, NODE_USE_ENV_PROXY: '1', NO_PROXY: new URL(net.originUrl).host }, false], + ['electron-direct', { HTTP_PROXY: proxy, NODE_USE_ENV_PROXY: '1' }, false], + ['electron-proxy', { NODE_USE_ENV_PROXY: '0', NO_PROXY: '*' }, true], ['cli', { FIXTURE_EVENTS: join(dir, 'events') }, false] + ] + const desktopEntry = join(dir, 'electron-main.cjs') + await writeFile(desktopEntry, `const { app } = require('electron') + app.disableHardwareAcceleration() + app.commandLine.appendSwitch('host-resolver-rules', 'MAP * ~NOTFOUND, EXCLUDE 127.0.0.1') + app.whenReady().then(() => import(${JSON.stringify(import.meta.url)})).catch(() => app.exit(1))`) + for (const [mode, config, proxied] of modes) { + const electron = mode.startsWith('electron-') + const profile = join(dir, mode) + await mkdir(profile) + const args = [electron ? desktopEntry : script, '--probe', mode, dir] + if (electron) args.push('--disable-background-networking', '--no-first-run', '--disable-component-update', '--user-data-dir=' + profile) + const before = net.proxyHits() + try { + await run(electron ? require('electron') : process.execPath, args, { + cwd: dir, env: { ...base, ...config, FIXTURE_ORIGIN: net.originUrl, FIXTURE_PROXY: net.proxyUrl, FIXTURE_TLS_STALL: net.tlsUrl }, + windowsHide: true, timeout: 35_000 + }) + } catch (e) { + // Never dump subprocess stderr, environment or a whole request on failure. + const output = [e.stderr, e.stdout].filter(Boolean).join('\n') + const where = output.split(/\r?\n/).map(s => s.trim()) + .find(s => s && !s.includes(marker) && !s.includes('://') && !/Warning:|trace-warnings/.test(s)) + throw Error(mode + ' 验证失败:' + (e.code ?? 'unknown') + (where ? ' (' + where.slice(0, 160) + ')' : '')) + } + assert.equal(net.proxyHits() > before, proxied, mode + ' 代理实际接收次数不符') + if (mode !== 'cli') { + for (const key of ['ok', 'quota', 'pay', 'auth']) assert.equal(net.hits.get(mode + '/' + key), 1, mode + '/' + key) + assert.equal(net.hits.get(mode + '/rate'), mode === 'node-clean' ? 2 : 1) + } else assert.equal(net.hits.get('cli/never'), undefined) + console.log(`PASS ${mode}(${electron ? '真实 Electron ' + require('electron/package.json').version + ' net.fetch' : + mode === 'cli' ? '本地 CLI 替身' : '真实 Node.js ' + process.versions.node + ' fetch'};代理命中 ${net.proxyHits() - before})`) + } + } finally { + await net?.close() + await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +} + +const probeIndex = process.argv.indexOf('--probe') +const electron = process.versions.electron ? require('electron') : undefined +const validation = probeIndex >= 0 ? probe(process.argv[probeIndex + 1], process.argv[probeIndex + 2], + process.env.FIXTURE_ORIGIN, process.env.FIXTURE_PROXY, electron) : main() +validation.then(() => electron?.app.exit(0)).catch(error => { + console.error(error instanceof Error ? error.message : '验证失败') + process.exitCode = 1 + electron?.app.exit(1) +}) diff --git a/scripts/validate-ai-retry-backoff.mjs b/scripts/validate-ai-retry-backoff.mjs index b48e19e..d7c4220 100644 --- a/scripts/validate-ai-retry-backoff.mjs +++ b/scripts/validate-ai-retry-backoff.mjs @@ -68,7 +68,7 @@ check('AI chat request validation is split out of registerIpcHandlers', /from '\ check('AI chat validation helper owns provider and message validation', /const apiProviders = new Set/.test(aiChatValidation) && /const chatMessageRoles = new Set/.test(aiChatValidation) && /export function validateChatCompletionRequest/.test(aiChatValidation)) check( 'AIService wraps chat completion HTTP call with retryWithBackoff', - /retryWithBackoff\([\s\S]*?this\.performChatCompletion\(/.test(aiService) + /const send = async \(\) =>[\s\S]*?this\.performChatCompletion\([\s\S]*?await retryWithBackoff\([\s\S]*?return await send\(\)/.test(aiService) ) check('AIHttpClient preserves response_format fallback', /response_format\|json_object\|unsupported/.test(aiHttpClient) && /fallbackBody/.test(aiHttpClient)) check('AI retry logs sanitized retry summaries', /AI request retry scheduled/.test(aiService) && /sanitizeAiErrorText\(describeAiRetryError/.test(aiService)) diff --git a/scripts/validate-api-registry.mjs b/scripts/validate-api-registry.mjs new file mode 100644 index 0000000..87c29be --- /dev/null +++ b/scripts/validate-api-registry.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +const root = await mkdtemp(join(tmpdir(), 'novel-api-registry-')) +const registryFile = join(root, 'registry.json') +process.env.NOVEL_DIRECTOR_API_REGISTRY = registryFile +const servers = [] + +async function endpoint(expectedKey, model) { + const server = createServer(async (req, res) => { + let body = '' + for await (const chunk of req) body += chunk + const requested = JSON.parse(body).model + const valid = req.headers.authorization === 'Bearer ' + expectedKey && requested === model + const status = { forbidden: 403, limited: 429 }[requested] ?? (valid ? 200 : 401) + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(valid ? { choices: [{ message: { content: model } }] } : + { error: 'test rejected fixture-expired ' + req.headers.authorization })) + }) + servers.push(server) + await new Promise((ok) => server.listen(0, '127.0.0.1', ok)) + return 'http://127.0.0.1:' + server.address().port +} + +try { + const compiled = await build({ + stdin: { resolveDir: repoRoot, contents: [ + "export * from './src/main/services/ApiRegistry'", + "export * from './src/main/SecureCredentialService'", + "export * from './src/main/services/AIService'", + "export * from './src/main/services/MainAIJsonClient'", + "export * from './src/services/ai/AIClient'" + ].join('\n') }, + platform: 'node', format: 'esm', bundle: true, write: false, + plugins: [{ name: 'test-credential-storage', setup(plugin) { + plugin.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'fixture' })) + plugin.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ + contents: 'export const safeStorage={isEncryptionAvailable:()=>true, encryptString:s=>Buffer.from(s), decryptString:b=>b.toString()}' + })) + } }] + }) + const { AIService, SecureCredentialService, MainAIJsonClient, AIClient, readApiRegistry, agentApiKey, backupKeyAfterRejection } = + await import('data:text/javascript;base64,' + Buffer.from(compiled.outputFiles[0].contents).toString('base64')) + const a = await endpoint('fixture-a', 'model-a'), b = await endpoint('fixture-b', 'model-b') + const config = { version: 1, desktopSavedKeyFor: 'a', apis: { + a: { baseUrl: a, aliases: [a + '/v1'], keyEnv: 'NOVEL_TEST_A_API_KEY' }, + b: { baseUrl: b + '/openai', keyEnv: 'NOVEL_TEST_B_API_KEY', backupKeyEnv: 'NOVEL_TEST_B_BACKUP_API_KEY' } + } } + await writeFile(registryFile, JSON.stringify(config)) + process.env.NOVEL_TEST_A_API_KEY = 'fixture-a' + process.env.NOVEL_TEST_B_API_KEY = 'fixture-b' + process.env.NOVEL_DIRECTOR_API_KEY = 'generic-fixture' + const registry = await readApiRegistry() + const desktop = new SecureCredentialService(root) + await writeFile(desktop.getCredentialPath(), JSON.stringify({ apiKey: Buffer.from('old-desktop').toString('base64') })) + const logs = [] + const options = { logger: { info: (m) => logs.push(m), warn: (m) => logs.push(m) } } + const request = (baseUrl, modelName) => ({ + settings: { apiProvider: 'compatible', baseUrl, modelName, temperature: 0.7, maxTokens: 256, retryEnabled: false, + maxRetries: 0, requestTimeoutMs: 10000 }, messages: [{ role: 'user', content: 'test' }] + }) + delete process.env.NOVEL_TEST_B_BACKUP_API_KEY + const external = { + getApiKey: async (url) => agentApiKey(registry, url, a), + getBackupApiKey: async (url, rejected) => backupKeyAfterRejection(registry, url, rejected) + } + for (const reader of [desktop, external]) { + const service = new AIService(reader, {}, options) + const results = await Promise.all([service.chatCompletion(request(a + '/v1', 'model-a')), + service.chatCompletion(request(b + '/openai', 'model-b'))]) + assert.deepEqual(results.map(r => r.content), ['model-a', 'model-b']) + } + delete process.env.NOVEL_TEST_A_API_KEY + assert.equal(await desktop.getApiKey(a + '/v1/'), 'old-desktop') + assert.equal(await external.getApiKey(a), '') + assert.equal(await desktop.getApiKey(a + '/unlisted'), '') + for (const reader of [desktop, external]) { + const service = new AIService(reader, {}, options) + const target = b + '/openai' + const call = (model = 'model-b') => service.chatCompletion(request(target, model)) + process.env.NOVEL_TEST_B_API_KEY = 'fixture-b' + process.env.NOVEL_TEST_B_BACKUP_API_KEY = 'fixture-unused' + assert.equal((await call()).telemetry.attempts, 1) + process.env.NOVEL_TEST_B_API_KEY = 'fixture-expired' + process.env.NOVEL_TEST_B_BACKUP_API_KEY = 'fixture-b' + const switched = await call() + assert.equal(switched.content, 'model-b') + assert.equal(switched.telemetry.attempts, 2) + assert.equal(await reader.getBackupApiKey(a, 'fixture-expired'), '') + for (const model of ['forbidden', 'limited']) { + const denied = await call(model) + assert.equal(denied.ok, false) + assert.equal(denied.telemetry.attempts, 1) + } + delete process.env.NOVEL_TEST_B_API_KEY + const onlyBackup = await call() + assert.equal(onlyBackup.content, 'model-b') + assert.equal(onlyBackup.telemetry.attempts, 1) + process.env.NOVEL_TEST_B_API_KEY = 'fixture-expired' + process.env.NOVEL_TEST_B_BACKUP_API_KEY = 'fixture-also-expired' + const rejected = await call() + assert.equal(rejected.ok, false) + assert.equal(rejected.telemetry.attempts, 2) + assert.ok(!rejected.error.includes('fixture-')) + } + delete process.env.NOVEL_TEST_B_BACKUP_API_KEY + delete process.env.NOVEL_TEST_B_API_KEY + await assert.rejects(desktop.getApiKey(b + '/openai'), /NOVEL_TEST_B_API_KEY/) + const offlineSettings = { ...request(b + '/openai', 'model-b').settings, hasApiKey: false } + const onlineService = new AIService(desktop, {}, options) + for (const Client of [AIClient, MainAIJsonClient]) { + const result = await new Client(offlineSettings, onlineService).requestJson('system', 'user', v => v, {}) + assert.equal(result.usedAI, true) + assert.match(result.error, /NOVEL_TEST_B_API_KEY/) + } + assert.equal((await desktop.getState(a)).storedForEndpoint, true) + await desktop.setApiKey('new-desktop', b + '/openai') + await assert.rejects(desktop.getApiKey(a), /NOVEL_TEST_A_API_KEY/) + assert.equal(await desktop.getApiKey(b + '/openai'), 'new-desktop') + assert.equal(await external.getApiKey(b + '/other'), '') + assert.ok(!logs.join(' ').includes('fixture-')) + const state = await desktop.getState(b + '/openai') + assert.ok(!JSON.stringify(state).includes('new-desktop')) + await writeFile(registryFile, '{') + await assert.rejects(desktop.getApiKey(a), /JSON/) + config.apis.a.aliases = [b] + await writeFile(registryFile, JSON.stringify(config)) + await assert.rejects(readApiRegistry(), /别名/) + await rm(registryFile) + assert.equal(await desktop.getApiKey(a), '') + assert.equal(await desktop.getApiKey(b + '/openai'), 'new-desktop') + assert.equal(agentApiKey(await readApiRegistry(), a, a), 'generic-fixture') + assert.ok((await readFile(desktop.getCredentialPath(), 'utf8')).includes('baseUrl')) + console.log('API registry: desktop and external routing, backup selection, aliases, missing keys, legacy binding, error and log checks passed.') +} finally { + for (const server of servers) { server.closeAllConnections(); await new Promise((ok) => server.close(ok)) } + if (dirname(resolve(root)) === resolve(tmpdir()) && basename(root).startsWith('novel-api-registry-')) { + await rm(root, { recursive: true, force: true }) + } +} diff --git a/scripts/validate-app-config-safety.mjs b/scripts/validate-app-config-safety.mjs index 1ea80ab..0d55956 100644 --- a/scripts/validate-app-config-safety.mjs +++ b/scripts/validate-app-config-safety.mjs @@ -1,9 +1,8 @@ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import ts from 'typescript' +import { join } from 'node:path' import { repoRoot } from './utils/repo-root.mjs' +import { loadDomainModules } from './utils/load-domain-modules.mjs' const root = repoRoot const outDir = join(root, 'tmp', 'app-config-safety-test') @@ -13,28 +12,6 @@ function assert(condition, message, details = {}) { return condition ? { ok: true, message } : { ok: false, message, details } } -async function compileTsModulePath(relativePath, replacements = []) { - const sourcePath = join(root, relativePath) - let source = await readFile(sourcePath, 'utf-8') - for (const [from, to] of replacements) source = source.replace(from, to) - const compiled = ts.transpileModule(source, { - compilerOptions: { - module: ts.ModuleKind.ES2022, - target: ts.ScriptTarget.ES2022, - useDefineForClassFields: true - } - }) - await mkdir(outDir, { recursive: true }) - const outPath = join(outDir, `${relativePath.replace(/[\\/.:]/g, '-')}.mjs`) - await writeFile(outPath, compiled.outputText, 'utf-8') - return outPath -} - -async function compileTsModule(relativePath, replacements = []) { - const outPath = await compileTsModulePath(relativePath, replacements) - return import(`${pathToFileURL(outPath).href}?t=${Date.now()}`) -} - async function readJson(path) { return JSON.parse(await readFile(path, 'utf-8')) } @@ -44,10 +21,7 @@ async function main() { await mkdir(userDataDir, { recursive: true }) const checks = [] - const storageServicePath = await compileTsModulePath('src/storage/StorageService.ts') - const { AppConfigService } = await compileTsModule('src/main/AppConfigService.ts', [ - ["from '../storage/StorageService'", `from '${pathToFileURL(storageServicePath).href}'`] - ]) + const { AppConfigService } = await loadDomainModules('main/AppConfigService') const service = new AppConfigService(userDataDir) const configPath = await service.getConfigPath() const defaultStoragePath = service.getDefaultStoragePath() diff --git a/scripts/validate-architecture-p2.mjs b/scripts/validate-architecture-p2.mjs index 0e40678..8ceb86a 100644 --- a/scripts/validate-architecture-p2.mjs +++ b/scripts/validate-architecture-p2.mjs @@ -41,7 +41,7 @@ contains(mainIndex, "import { AIService } from './services/AIService'", 'main pr contains(mainIndex, 'aiRateLimiters', 'main process must create AI rate limiters.') contains(mainIndex, /openai:\s*new TokenBucketRateLimiter/, 'OpenAI provider must have a limiter.') contains(mainIndex, /compatible:\s*new TokenBucketRateLimiter/, 'Compatible provider must have a limiter.') -contains(mainIndex, 'new AIService(credentialService, aiRateLimiters)', 'main process must instantiate AIService with credentials and rate limiters.') +contains(mainIndex, 'new AIService(credentialService, aiRateLimiters,', 'main process must instantiate AIService with credentials and rate limiters.') contains(mainIndex, 'registerIpcHandlers({', 'main process must register IPC handlers.') contains(mainIndex, 'aiService', 'IPC context must receive AIService.') @@ -395,9 +395,9 @@ contains(promptManualForeshadowingPanel, 'effectiveTreatmentMode', 'PromptManual assert(!promptBuilderView.includes('function renderManualForeshadowingPanel'), 'PromptBuilderView should not inline the manual foreshadowing panel.') assert(!promptBuilderView.includes('new Set(contextNeedPlan.requiredForeshadowingIds)'), 'PromptBuilderView should not directly mutate required/forbidden foreshadowing sets.') assert(!promptBuilderView.includes('requiredCharacterCardFields: Object.fromEntries'), 'PromptBuilderView should not directly rebuild need-plan character field maps.') -contains(storyDirectionView, 'previousProjectIdRef', 'StoryDirectionView must detect project switches.') -contains(storyDirectionView, 'setStartChapterOrder(nextStartChapterOrder)', 'StoryDirectionView must reset start chapter when switching projects.') -contains(storyDirectionView, 'setDraftGuide(StoryDirectionService.getActiveGuideForChapter', 'StoryDirectionView must load the active guide for the new project.') +contains(storyDirectionView, ' { const timers = new Set() const caller = new AbortController() + const { diagnoseAiConnection } = await load('src/main/utils/connectionDiagnosis.ts') + const privateMarker = 'FIXTURE_PRIVATE_BODY' + let receivedHeaders = 0 let failBody = false + let reset = false const { AIHttpClient } = await load('src/main/services/AIHttpClient.ts', {}, { setTimeout: (callback) => { timers.add(callback); return callback }, clearTimeout: (callback) => timers.delete(callback), fetch: async () => new Response(new ReadableStream({ start(controller) { - if (failBody) controller.error(new Error('fixture body error')) + if (failBody) controller.error(Object.assign(new Error('fixture body error: ' + privateMarker), { + code: reset ? 'ECONNRESET' : privateMarker + })) else { controller.enqueue(new TextEncoder().encode('{}')); controller.close() } } })) }) for (let index = 0; index < 30; index += 1) { failBody = index % 2 !== 0 - const pending = new AIHttpClient().post('https://fixture.invalid', {}, {}, { signal: caller.signal }) - if (failBody) await assert.rejects(pending, /fixture body error/) + reset = index % 4 === 3 + const pending = new AIHttpClient().post('https://fixture.invalid', {}, {}, { + signal: caller.signal, onResponseStarted: () => { receivedHeaders++ } + }) + if (failBody) await assert.rejects(pending, error => { + const diagnosis = diagnoseAiConnection(error, { transport: 'custom-fetch' }) + assert.equal(error.phase, 'reading') + assert.equal(diagnosis?.kind, 'read_failure') + assert.match(diagnosis.message, /已收到响应头.*响应正文/) + assert.equal(error.cause?.code, reset ? 'ECONNRESET' : undefined) + assert.ok(![inspect(error), diagnosis.message].some(text => text.includes(privateMarker)), 'body error leaked private data') + return true + }) else assert.deepEqual(await (await pending).json(), {}) + assert.equal(receivedHeaders, index + 1) assert.equal(getEventListeners(caller.signal, 'abort').length, 0) assert.equal(timers.size, 0) } diff --git a/scripts/validate-batch-writeback-order.mjs b/scripts/validate-batch-writeback-order.mjs new file mode 100644 index 0000000..f494749 --- /dev/null +++ b/scripts/validate-batch-writeback-order.mjs @@ -0,0 +1,317 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { isAbsolute, join, relative } from 'node:path' +import { pathToFileURL } from 'node:url' +import { mock } from 'node:test' +import Database from 'better-sqlite3' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +const root = join(repoRoot, 'tmp') +await mkdir(root, { recursive: true }) +const dir = await mkdtemp(join(root, 'batch-writeback-')) +const originalFetch = globalThis.fetch +const originalRegistry = process.env.NOVEL_DIRECTOR_API_REGISTRY +const originalKey = process.env.NOVEL_DIRECTOR_API_KEY +const projectId = 'batch-fixture' +const agentRunId = 'batch-65-96' +const endpoint = 'http://127.0.0.1:9/v1' +const at = (second) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString() +let send = null +let requests = 0 +let plannerCalls = 0 +globalThis.fetch = async (url, options) => { + requests++ + assert.equal(String(url), endpoint + '/chat/completions') + assert.ok(send, 'Blocked chapter work must not invoke even the model double.') + return send(options) +} + +function draft(job, suffix) { + return { id: 'draft-' + suffix, projectId, jobId: job.id, chapterId: null, title: '稿件 ' + suffix, + body: '本次虚构正文:' + suffix + '。', summary: '', status: 'draft', tokenEstimate: 10, createdAt: at(0), updatedAt: at(0) } +} + +function accept(api, data, order, suffix = String(order), time = 10) { + return api.buildAcceptedDraftCommitBundle({ appData: data, projectId, targetChapterOrder: order, draftId: 'draft-' + suffix, + commitId: 'accept-' + suffix, chapterId: 'chapter-' + order, acceptedAt: at(time), acceptanceMode: 'unreviewed' }) +} + +function addManualCandidate(data, suffix) { + const session = { id: 'session-' + suffix, projectId, chapterId: 'chapter-66', sourceDraftId: null, + status: 'active', createdAt: at(60), updatedAt: at(60) } + const request = { id: 'request-' + suffix, sessionId: session.id, type: 'custom', targetRange: '', + instruction: '作者手工修订。', createdAt: at(60) } + const version = { id: 'candidate-' + suffix, sessionId: session.id, requestId: request.id, title: '手工修订', + body: '候选正文:' + suffix, status: 'pending', changedSummary: '', risks: '', preservedFacts: '', createdAt: at(60), updatedAt: at(60) } + return { ...data, revisionSessions: [...data.revisionSessions, session], revisionRequests: [...data.revisionRequests, request], + revisionVersions: [...data.revisionVersions, version] } +} + +function fixture(api) { + const base = api.normalizeAppData({ projects: [{ id: projectId, name: '连写测试' }], + chapters: [{ id: 'chapter-92', projectId, order: 92, title: '导入', body: '导入旧书的正文。', createdAt: at(0), updatedAt: at(0) }], + settings: { apiProvider: 'local', baseUrl: endpoint, modelName: 'fixture-only', retryEnabled: false, maxRetries: 0 } }) + const prepare = (count) => api.AgentRunService.prepareSequentialChapterRun({ + appData: base, projectId, targetChapterOrder: 65, chapterCount: count, agentRunId + }) + for (const count of [5, 10, 40]) { + const registered = prepare(count) + assert.equal(registered.agentRun.targetChapterOrders.length, count) + assert.equal(registered.agentRun.createdJobIds.length, 1) + } + const prepared = prepare(32) + const orders = prepared.agentRun.targetChapterOrders + // An older client already ran later chapters. A job for chapter 95 is not evidence for 66 or 92. + const jobs = orders.map(order => ({ ...prepared.job, id: 'job-' + order, targetChapterOrder: order, + status: order === 96 ? 'failed' : 'completed', currentStep: order === 96 ? 'generate_chapter_plan' : 'await_user_confirmation' })) + const outside = { ...jobs[1], id: 'outside-batch-66' } + let data = { ...base, chapterGenerationJobs: [outside, ...jobs], + agentRuns: [{ ...prepared.agentRun, createdJobIds: jobs.map(job => job.id), createdDraftIds: [], createdCommitIds: [] }], + chapterGenerationSteps: [{ ...prepared.steps.find(step => step.type === 'generate_chapter_plan'), jobId: 'job-96', status: 'failed' }], + generatedChapterDrafts: [draft(outside, 'outside'), ...jobs.map(j => draft(j, j.targetChapterOrder))] } + data = api.applyChapterCommitBundleToAppData(data, accept(api, data, 66, 'outside', 5)) + for (const order of orders.filter(n => ![66, 92, 96].includes(n))) { + data = api.applyChapterCommitBundleToAppData(data, accept(api, data, order)) + } + return data +} + +async function checkBackend(api, kind) { + const folder = join(dir, kind) + const userDataPath = join(folder, 'profile') + await mkdir(userDataPath, { recursive: true }) + const storagePath = join(folder, 'book.' + kind) + const opts = { storagePath, userDataPath } + const storage = kind === 'json' ? new api.JsonStorageService(storagePath) : new api.SqliteStorageService(storagePath) + const open = () => api.loadAgentRuntimeData(opts) + const call = async (name, args = {}) => (await api.AgentToolService.callTool({ name: 'agent.' + name, + arguments: { ...opts, ...args } })).data + const summary = () => call('getAgentRunSummary', { agentRunId }) + const retry = (runtime) => api.executeAgentChapterPipeline({ appData: runtime.data, runtime, agentRunId, jobId: 'job-96' }) + const mutate = async (changes) => { + const { data, revision } = await open() + await storage.saveIfCurrent(changes(data), revision) + } + const failedWriter = api.observeFormalChapterWrites({ + async saveRevisionCommitBundle(_bundle, _revision, writeStarted) { + writeStarted() + throw new Error('fixture write failed') + }, + recordChapterWritebackFailureIfUncommitted: failure => storage.recordChapterWritebackFailureIfUncommitted(failure) + }) + const manual = (data, suffix, time) => api.buildRevisionCommitBundle({ appData: data, projectId, chapterId: 'chapter-66', + revisionCommitId: 'commit-' + suffix, newChapterVersionId: 'version-' + suffix, revisionVersionId: 'candidate-' + suffix, + revisionSessionId: 'session-' + suffix, revisedBy: 'user', revisedAt: at(time) }) + const edit = (chapterOrder) => ({ projectId, agentRunId, chapterOrder, reason: '准备本批下一章。', + operationId: 'prepare-' + chapterOrder, sourceJobId: null, sourceSnapshotId: null, + task: { ...api.createEmptyChapterTask(), goal: '继续核对日志。', targetWordCount: '1000' }, + pipelineMode: 'standard', budgetMode: 'standard', budgetMaxTokens: 8000 }) + const assertBlocked = async (order, expectedDraft, action) => { + const rev = (await open()).revision + const calls = requests + await assert.rejects(action, error => error.message.includes('第 ' + order + ' 章') && error.message.includes(expectedDraft)) + assert.equal((await open()).revision, rev, 'A refused entry must not reset steps or write a new job.') + assert.equal(requests, calls, 'No new provider request is allowed on rejection.') + } + try { + await storage.save(fixture(api)) + const start = await open() + assert.equal(start.source, kind) + const first = await summary() + assert.equal(first.nextChapterOrder, 66) + assert.equal(first.nextTarget.jobId, 'job-66') + assert.equal(first.nextTarget.draftId, 'draft-66') + assert.notEqual(first.nextTarget.draftId, 'draft-outside') + assert.throws(() => api.AgentRunService.prepareSingleChapterRun({ appData: start.data, projectId, agentRunId, targetChapterOrder: 96 }), /第 66 章/) + assert.throws(() => api.AgentRunService.prepareSingleChapterRun({ appData: start.data, projectId, targetChapterOrder: 96, + targetChapterOrders: [92, 96] }), /第 92 章/) + await assertBlocked(66, 'draft-66', () => call('previewChapterTaskEdit', edit(96))) + await assertBlocked(66, 'draft-66', () => call('applyChapterTaskEdit', { + ...edit(96), expectedRevision: start.revision, expectedPreviewHash: 'stale-preview' })) + await assertBlocked(66, 'draft-66', () => call('retryChapterPipeline', { agentRunId, jobId: 'job-96' })) + await assertBlocked(66, 'draft-66', () => call('retryChapterPipeline', { agentRunId, jobId: 'job-66' })) + await assertBlocked(66, 'draft-66', () => retry(start)) + assert.deepEqual((await call('getProjectAuthorization', { projectId })).grants, []) + + const standalone = await call('startAgentRun', { projectId, chapterOrder: 106 }) + assert.deepEqual(standalone.agentRun.targetChapterOrders, [106], 'A new, intentionally nonsequential run remains independent.') + await assertBlocked(106, standalone.job.id, () => call('previewChapterTaskEdit', { ...edit(107), agentRunId: standalone.agentRun.id })) + const another = await call('continueAgentRun', { projectId, startChapterOrder: 110, chapterCount: 10 }) + assert.equal(another.createdJobCount, 1) + assert.notEqual(another.agentRun.id, agentRunId) + const before66 = await open() + const receipt66 = accept(api, before66.data, 66, '66', 20) + await storage.saveChapterCommitBundle(receipt66, before66.revision) + assert.equal((await summary()).nextChapterOrder, 92, 'Chapter 91 and chapters 93-95 have receipts, but 92 only has imported prose.') + await assertBlocked(92, 'draft-92', () => retry(before66)) + await mutate(data => ({ ...data, chapterVersions: data.chapterVersions.filter(v => v.id !== receipt66.chapterVersion.id) })) + assert.equal((await summary()).nextChapterOrder, 66, 'An embedded version in the receipt cannot replace the persisted version.') + await mutate(data => ({ ...data, chapterVersions: [...data.chapterVersions, receipt66.chapterVersion] })) + await mutate(data => ({ ...data, agentRuns: data.agentRuns.map(run => run.id === agentRunId + ? { ...run, createdJobIds: run.createdJobIds.filter(id => id !== 'job-92') } : run) })) + assert.equal((await summary()).nextTarget.jobId, null, 'A job not registered in this batch cannot satisfy a missing target.') + await mutate(data => ({ ...data, agentRuns: data.agentRuns.map(run => run.id === agentRunId + ? { ...run, createdJobIds: [...run.createdJobIds, 'job-92'] } : run) })) + + const before92 = await open() + await storage.saveChapterCommitBundle(accept(api, before92.data, 92, '92', 21), before92.revision) + const beforeRevision = await open() + const revision = api.buildRevisionCommitBundle({ appData: beforeRevision.data, projectId, chapterId: 'chapter-66', + revisionCommitId: 'previous-manual-66', newChapterVersionId: 'previous-version-66', afterText: '经确认的后续修订。', revisedAt: at(30) }) + await storage.saveRevisionCommitBundle(revision, beforeRevision.revision) + assert.equal((await summary()).nextChapterOrder, 96, 'A legitimate later revision does not undo the original job acceptance.') + const beforeRestore = await open() + const restore = api.buildRestoreRevisionCommitBundle({ appData: beforeRestore.data, projectId, chapterId: 'chapter-66', + revisionCommitId: 'restore-66', newChapterVersionId: 'version-restore-66', sourceVersionId: receipt66.chapterVersion.id, restoredAt: at(40) }) + await storage.saveRevisionCommitBundle(restore, beforeRestore.revision) + assert.equal((await summary()).nextChapterOrder, 96) + await mutate(data => ({ ...data, generatedChapterDrafts: [...data.generatedChapterDrafts, + { ...draft(data.chapterGenerationJobs.find(job => job.id === 'job-92'), 'regenerated-92'), createdAt: at(41), updatedAt: at(41) }] })) + await assertBlocked(92, 'draft-regenerated-92', () => call('retryChapterPipeline', { agentRunId, jobId: 'job-96' })) + const regenerated = await open() + await storage.saveChapterCommitBundle(accept(api, regenerated.data, 92, 'regenerated-92', 45), regenerated.revision) + const confirmed = await open() + const preview = await call('previewChapterTaskEdit', edit(96)) + assert.equal(preview.invokesAI, false) + const applyArgs = { ...edit(96), expectedRevision: confirmed.revision, expectedPreviewHash: preview.previewHash } + await assert.rejects(() => call('applyChapterTaskEdit', applyArgs), { code: 'AGENT_AUTHORIZATION_REQUIRED' }) + const nextJob = { ...confirmed.data.chapterGenerationJobs.find(j => j.id === 'job-66'), id: 'replacement-66', status: 'failed' } + await mutate(data => ({ ...data, chapterGenerationJobs: [nextJob, ...data.chapterGenerationJobs], + generatedChapterDrafts: [draft(nextJob, 'replacement'), ...data.generatedChapterDrafts], + agentRuns: data.agentRuns.map(r => r.id === agentRunId ? { ...r, createdJobIds: [...r.createdJobIds, nextJob.id] } : r) })) + assert.equal((await summary()).nextTarget.jobId, 'replacement-66') + await assertBlocked(66, 'draft-replacement', () => call('applyChapterTaskEdit', applyArgs)) + await assertBlocked(66, 'draft-replacement', () => retry(confirmed)) + const beforeRepair = await open() + await storage.saveChapterCommitBundle(accept(api, beforeRepair.data, 66, 'replacement', 50), beforeRepair.revision) + assert.equal((await summary()).nextChapterOrder, 96) + + await mutate(data => addManualCandidate(addManualCandidate(data, 'manual-66'), 'retry-66')) + const beforeFailure = await open() + const firstManual = manual(beforeFailure.data, 'manual-66', 70) + await assert.rejects(() => failedWriter.saveRevisionCommitBundle(firstManual, beforeFailure.revision), /fixture write failed/) + const failed = await summary() + assert.equal(failed.nextChapterOrder, 66, 'A later unresolved manual revision failure overrides older success.') + assert.ok(failed.summary.includes('candidate-manual-66'), 'Recovery must identify the failed revision candidate, not just the old accepted draft.') + assert.equal(failed.nextTarget.writebackFailure.revisionVersionId, 'candidate-manual-66') + assert.ok(!failed.summary.includes('draft-replacement')) + await assertBlocked(66, 'candidate-manual-66', () => retry(beforeFailure)) + const staleFailure = await open() + await storage.saveRevisionCommitBundle(firstManual, staleFailure.revision) + assert.equal((await summary()).nextChapterOrder, 96, 'Only a matching repair receipt and version resolve the failed write.') + + const pinned = await open() + const beforeRequest = api.batchModelRequestGuard(pinned, pinned.data.agentRuns.find(r => r.id === agentRunId), 'job-96') + const reads = [] + const prepare = Database.prototype.prepare + const spy = kind === 'sqlite' && mock.method(Database.prototype, 'prepare', function (sql, ...rest) { + reads.push(sql) + return prepare.call(this, sql, ...rest) + }) + try { + await beforeRequest() + if (spy) { + assert.ok(reads.some(sql => sql.includes('FROM meta'))) + assert.ok(!reads.some(sql => sql.includes('FROM entities')), 'Unchanged SQLite revision must not load any book rows.') + } + await mutate(data => ({ ...data, projects: data.projects.map(p => ({ ...p, name: '新书名' })) })) + reads.length = 0 + await assert.rejects(beforeRequest, { code: 'STORAGE_REVISION_CONFLICT' }) + if (spy) assert.ok(reads.some(sql => sql.includes('FROM entities') && sql.includes('project_id = ?'))) + } finally { if (spy) spy.mock.restore() } + + const priorCalls = requests + let secondManual + send = async () => { + const current = await open() + assert.equal(current.data.chapterGenerationJobs.find(j => j.id === 'job-96').status, 'running') + await assert.rejects(() => storage.saveChapterCommitBundle(accept(api, current.data, 96), current.revision), /生成仍在运行/) + secondManual = manual(current.data, 'retry-66', 80) + await assert.rejects(() => failedWriter.saveRevisionCommitBundle(secondManual, current.revision), /fixture write failed/) + // A caller mutating its public runtime cannot advance the executor's privately checked revision. + Object.assign(staleFailure, await open()) + return new Response('unsupported response_format', { status: 400 }) + } + await assert.rejects(() => retry(staleFailure), error => error.message.includes('第 66 章') && error.message.includes('candidate-retry-66')) + assert.equal(requests, priorCalls + 1, 'The first admitted request may finish; its automatic format retry must not reach the provider.') + send = null + assert.equal((await summary()).nextChapterOrder, 66) + const progress = await call('getPipelineProgress', { jobId: 'job-96' }) + assert.equal(progress.jobStatus, 'paused') + assert.equal(progress.agentRunStatus, 'paused') + assert.equal(progress.currentStepStatus, 'failed') + assert.equal(progress.recoverableFromStep, 'generate_chapter_plan') + assert.equal(progress.cancellationRequested, false) + const paused = await open() + assert.ok(paused.data.chapterGenerationJobs.find(j => j.id === 'job-96').errorMessage.includes('candidate-retry-66')) + assert.equal(paused.data.chapterWritebackFailures.length, 2, 'The interrupted executor must not overwrite the concurrent failure.') + assert.deepEqual(paused.data.generatedChapterDrafts.filter(d => d.jobId === 'job-96'), start.data.generatedChapterDrafts.filter(d => d.jobId === 'job-96')) + await assertBlocked(66, 'candidate-retry-66', () => call('retryChapterPipeline', { agentRunId, jobId: 'job-96' })) + await storage.saveRevisionCommitBundle(secondManual, (await open()).revision) + assert.equal((await summary()).nextChapterOrder, 96) + const startPlanner = async () => { + const started = Promise.withResolvers() + const release = Promise.withResolvers() + const pending = api.executeAgentChapterPipeline({ runtime: await open(), agentRunId, jobId: 'job-96' }, { + workflowAI: { async generateChapterPlan() { + plannerCalls++ + started.resolve() + await release.promise + throw new Error('fixture plan stopped') + } }, diagnostics: {} + }) + await Promise.race([started.promise, pending.then(() => { throw new Error('Plan did not start.') })]) + return { release: release.resolve, pending } + } + const older = await startPlanner() + try { + const newer = await startPlanner() + try { + const owned = await open() + older.release() + await assert.rejects(older.pending, { code: 'STORAGE_REVISION_CONFLICT' }) + assert.equal((await open()).revision, owned.revision, 'An interrupted owner must not pause the executor that took over.') + assert.equal((await call('getPipelineProgress', { jobId: 'job-96' })).jobStatus, 'running') + } finally { newer.release(); await newer.pending } + } finally { older.release(); await older.pending.catch(() => {}) } + const finish = await open() + assert.equal(finish.data.chapterGenerationJobs.find(j => j.id === 'job-96').status, 'failed') + // The existing candidate can now go through normal acceptance, without forging a completed job. + await storage.saveChapterCommitBundle(accept(api, finish.data, 96), finish.revision) + assert.equal((await summary()).nextTarget, null) + console.log(kind + ': 66/92, exact job and persisted version, revision/restore, auth, stale resume and transport retry passed.') + } finally { + send = null + storage.close?.() + } +} + +try { + process.env.NOVEL_DIRECTOR_API_REGISTRY = join(dir, 'registry.json') + process.env.NOVEL_DIRECTOR_API_KEY = '' + await writeFile(process.env.NOVEL_DIRECTOR_API_REGISTRY, JSON.stringify({ version: 1, apis: {} })) + const outfile = join(dir, 'fixture.mjs') + await build({ stdin: { resolveDir: repoRoot, loader: 'ts', contents: [ + 'agent/tools/AgentToolService', 'agent/AgentRunService', 'agent/AgentRuntime', 'agent/AgentPipelineExecutor', + 'agent/agentPipelineWritebackGuard', 'services/ChapterWritebackFailureService', 'services/ChapterCommitBundleService', + 'services/RevisionCommitBundleService', 'services/ChapterVersionChainService', + 'storage/JsonStorageService', 'storage/SqliteStorageService', 'shared/defaults' + ].map(path => "export * from './src/" + path + "'").join('\n') }, + outfile, bundle: true, platform: 'node', format: 'esm', target: 'node22', external: ['better-sqlite3', 'electron'], logLevel: 'silent' }) + const api = await import(pathToFileURL(outfile).href) + for (const kind of ['json', 'sqlite']) await checkBackend(api, kind) + assert.equal(requests, 2) + assert.equal(plannerCalls, 4) + console.log('2 admitted mock HTTP requests and 4 overlapping mock planner calls; 0 blocked or external requests.') +} finally { + globalThis.fetch = originalFetch + if (originalRegistry === undefined) delete process.env.NOVEL_DIRECTOR_API_REGISTRY + else process.env.NOVEL_DIRECTOR_API_REGISTRY = originalRegistry + if (originalKey === undefined) delete process.env.NOVEL_DIRECTOR_API_KEY + else process.env.NOVEL_DIRECTOR_API_KEY = originalKey + const path = relative(root, dir) + if (!path || path.startsWith('..') || isAbsolute(path)) throw new Error('Refusing to clean outside the fixture root.') + await rm(dir, { recursive: true, force: true }) +} diff --git a/scripts/validate-book-continuity-ledger.mjs b/scripts/validate-book-continuity-ledger.mjs new file mode 100644 index 0000000..834d601 --- /dev/null +++ b/scripts/validate-book-continuity-ledger.mjs @@ -0,0 +1,378 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +// Only pure services and invented, in-memory records. Nothing opens an application database. +const { outputFiles } = await build({ + stdin: { resolveDir: repoRoot, contents: "export * from './src/services/bookContinuity/ledgerChecks'; export { applyStateChangeCandidate } from './src/services/characterState/stateMutations'; export { draftContentHash } from './src/services/DraftDiagnosticBindingService'" }, + write: false, bundle: true, platform: 'node', format: 'esm', target: 'node22' +}) +const { auditBookLedger, applyStateChangeCandidate, draftContentHash } = await import('data:text/javascript;base64,' + Buffer.from(outputFiles[0].contents).toString('base64')) +const p = 'invented-book' +const ch = (n, body, extra) => ({ id: 'c' + n, order: n, projectId: p, body, ...extra }) +const data = (...texts) => ({ + chapters: texts.map((body, i) => ch(i + 1, body)), chapterVersions: [], + characters: [['a', '林岫'], ['b', '赵久'], ['c', '周未']].map(([id, name]) => ({ projectId: p, id, name })), + characterStateFacts: [], characterStateTransactions: [], characterStateChangeCandidates: [] +}) +function fact(d, id, n, evidence, extra = {}) { + const f = { id, projectId: p, characterId: 'a', category: 'resource', key: 'cash', label: '个人现金', + unit: '文', status: 'active', valueType: 'number', value: 10, sourceChapterId: 'c' + n, sourceChapterOrder: n, evidence, + ...extra } + d.characterStateFacts.push(f) + return f +} +function tx(d, f, n, evidence, type, before, after, delta = null, extra = {}) { + const t = { id: 'tx-' + d.characterStateTransactions.length, projectId: p, characterId: f.characterId, factId: f.id, + chapterId: 'c' + n, chapterOrder: n, transactionType: type, beforeValue: before, afterValue: after, delta, + evidence, source: 'manual', reason: '', status: 'accepted', ...extra } + d.characterStateTransactions.push(t) + return t +} +function audit(d, to = 999, from = 1) { + const chapters = d.chapters.filter(c => c.projectId === p && !c.archivedAt && c.order <= to && c.body.trim()).sort((a, b) => a.order - b.order) + const before = structuredClone(d) + const result = auditBookLedger({ data: d, projectId: p, chapters, from, to, + chapterFor(id, n) { + if (id) return chapters.find(c => c.id === id) + const at = chapters.filter(c => c.order === n) + return at.length === 1 ? at[0] : undefined + }, + evidence(c, excerpt) { + assert(chapters.includes(c) && excerpt && c.body.includes(excerpt), 'evidence must quote the current manuscript') + return { chapterId: c.id, chapterOrder: c.order, excerpt: Array.from(excerpt).slice(0, 160).join(''), + versionId: d.chapterVersions.findLast(v => v.chapterId === c.id && v.body === c.body && v.projectId === p)?.id ?? null, + contentHash: draftContentHash(c.body) } + } }) + assert.deepEqual(d, before, 'read-only audit') + for (const f of result) { + assert(f.evidence.length || (f.status === 'needs_evidence' && chapters.some(c => c.order === f.atChapterOrder))) + assert(f.evidence.every(e => chapters.some(c => c.id === e.chapterId && c.body.includes(e.excerpt) && e.excerpt !== c.body))) + } + return result +} +const select = (result, kind, status) => result.filter(f => f.kind === kind && f.status === status) +const conflicts = r => r.filter(f => f.status === 'conflict') +const orders = finding => finding.evidence.map(e => e.chapterOrder) +const version = (c, id) => ({ id, chapterId: c.id, projectId: p, body: c.body }) +const inv = (d, id, n, owner, evidence, value, extra) => fact(d, id, n, evidence, + { category: 'inventory', key: 'pack', label: '随身物件', unit: '', characterId: owner, valueType: 'list', value, ...extra }) +const state = (d, id, n, key, value, extra) => fact(d, id, n, d.chapters[n - 1].body, + { category: 'status', key, label: key, valueType: 'string', value, unit: '', ...extra }) + +test('历史投影不重复入账;当前版本、换序、归档、拒绝项和跨书隔离', () => { + const d = data('林岫数过现金,只有十文。', '风吹过空院。', '林岫花了三文,账面却写余八文。') + const [first, middle, last] = d.chapters + const wallet = fact(d, 'wallet', 3, last.body, { value: 8 }) + tx(d, wallet, 1, first.body, 'create', null, 10) + tx(d, wallet, 3, last.body, 'decrement', 10, 8, 3) + for (const status of ['pending', 'rejected']) tx(d, wallet, 2, middle.body, 'increment', 700, 999, 299, { status }) + tx(d, wallet, 2, middle.body, 'increment', 700, 999, 299, { projectId: 'foreign' }) + tx(d, wallet, 2, middle.body, 'increment', 700, 999, 299, { characterId: 'b' }) + d.generatedChapterDrafts = [{ projectId: p, body: '草稿承诺转出三百文。' }] + d.characterStateChangeCandidates.push({ status: 'rejected', proposedFact: { ...wallet, value: 9000 } }) + d.chapters.push(ch(2, '归档内容余额四百文。', { id: 'archived', archivedAt: '2000-01-01' }), ch(2, '外书许诺付钱。', { projectId: 'foreign', id: 'other' })) + tx(d, wallet, 2, '归档内容余额四百文。', 'increment', 1, 400, 399, { chapterId: 'archived' }) + d.chapterVersions.push(version(first, 'base'), version(last, 'v3'), { ...version(last, 'obsolete'), body: '旧版付出九百文。' }) + const report = audit(d, 3, 3) + assert.equal(report.length, 1) + assert.deepEqual([report[0].kind, report[0].status, report[0].characterIds], ['money', 'conflict', ['a']]) + assert.deepEqual(report[0].evidence.map(e => [e.chapterId, e.versionId, e.excerpt]), [['c1', 'base', first.body.slice(0, -1)], ['c3', 'v3', last.body.slice(0, -1)]]) + assert.match(report[0].reason, /应为 7文.*相差 1文/u) + const original = first.body + first.body = '林岫说,账本从未打开。' + assert.equal(conflicts(audit(d)).length, 0) + assert(select(audit(d), 'money', 'needs_evidence').some(f => /基线/u.test(f.reason))) + first.body = original + first.order = 2 + d.chapterVersions.push(version(first, 'restored')) + assert.deepEqual(orders(conflicts(audit(d))[0]), [2, 3]) + assert.equal(conflicts(audit(d))[0].evidence[0].versionId, 'restored') + first.archivedAt = '2001-02-03' + assert.equal(conflicts(audit(d)).length, 0) + assert.deepEqual(audit(d, 2), []) +}) + +test('缺基线、未知或不同单位、不同角色及个人与各队账均不产生冲突', () => { + const d = data('林岫有十文。', '林岫支出八文,余二十文。', '林岫手头又有十两银子。', '账本没有填写单位。') + const wallet = fact(d, 'wallet', 1, d.chapters[0].body) + for (const [i, extra] of [ + { label: '白帆队现金' }, { label: '青帆队现金' }, { characterId: 'b' } + ].entries()) { + const other = fact(d, 'other-' + i, 2, d.chapters[1].body, { value: 20, ...extra }) + tx(d, other, 2, d.chapters[1].body, 'decrement', null, 20, 8) + } + fact(d, 'taels', 3, d.chapters[2].body, { unit: '两', value: 10 }) + fact(d, 'unknown', 4, d.chapters[3].body, { unit: '', value: 100 }) + const r = audit(d) + assert.equal(conflicts(r).length, 0) + assert.equal(r.filter(f => /基线/u.test(f.reason)).length, 3) + assert(r.some(f => /单位不同/u.test(f.reason) && orders(f).join() === '1,3')) + assert(r.some(f => /单位/u.test(f.reason) && f.evidence.at(-1).chapterOrder === 4)) + wallet.unit = '元' + assert(select(audit(d, 1), 'money', 'needs_evidence').some(f => /单位/u.test(f.reason))) +}) + +test('真实采纳保留下来的期初事实可用;删除历史流水的旧书不借未来值', () => { + const d = data('林岫的账上有十文。', '林岫花去七文。') + const wallet = fact(d, 'wallet', 1, d.chapters[0].body, { linkedCardFields: [] }) + d.characterStateChangeCandidates.push({ id: 'candidate', projectId: p, characterId: 'a', targetFactId: wallet.id, + chapterId: 'c2', chapterOrder: 2, status: 'pending', candidateType: 'transaction', afterValue: 3, + proposedTransaction: { transactionType: 'decrement', delta: 7 }, evidence: d.chapters[1].body }) + const accepted = applyStateChangeCandidate('candidate', d) + assert.equal(accepted.characterStateFacts[0].value, 3) + assert.deepEqual(audit(accepted), []) + const legacy = { ...accepted, characterStateTransactions: accepted.characterStateTransactions.filter(t => !t.preservedPriorFact) } + assert.equal(conflicts(audit(legacy)).length, 0) + assert(select(audit(legacy), 'money', 'needs_evidence').some(f => /基线/u.test(f.reason))) + assert.deepEqual(select(audit(legacy, 1), 'money', 'needs_evidence').map(f => orders(f)), [[1]]) +}) + +test('双端转账同单位同金额才可对账;相同金额或单边转出不是配对依据', () => { + const d = data('林岫手里有十文。赵久有一文。', '林岫给赵久转账四文。', '林岫手头少了四文。') + const a = fact(d, 'a-wallet', 1, '林岫手里有十文。') + const b = fact(d, 'b-wallet', 1, '赵久有一文。', { characterId: 'b', value: 1 }) + tx(d, a, 2, d.chapters[1].body, 'decrement', 10, 6, 4) + assert.match(select(audit(d), 'money', 'needs_evidence')[0].reason, /转入/u) + const credit = tx(d, b, 2, d.chapters[1].body, 'increment', 1, 5, 4) + assert.equal(select(audit(d, 2), 'money', 'needs_evidence').length, 0) + credit.delta = 3 + assert.equal(select(audit(d, 2), 'money', 'needs_evidence').length, 2) + assert.equal(conflicts(audit(d, 2)).length, 0) + credit.delta = 4 + credit.status = 'rejected' + assert.equal(select(audit(d, 2), 'money', 'needs_evidence').length, 1) + credit.status = 'accepted' + credit.chapterId = 'c3' + credit.evidence = d.chapters[2].body = '一张收据记着转入四文。' + assert.equal(select(audit(d), 'money', 'needs_evidence').length, 2) + credit.evidence = d.chapters[2].body = '赵久的账上记明林岫转入四文。' + assert.deepEqual(audit(d), []) +}) + +test('仅核同队明确人数:差值、合法减员、断档、无流水的两次点名', () => { + const d = data('白帆队点名共五人。', '白帆队走了两人,清点却有四人。', '全队始终是六人。', '林岫与赵久并肩走着。') + const crew = fact(d, 'crew', 1, d.chapters[0].body, { key: 'crew_size', label: '白帆队人数', unit: '人', value: 5 }) + const change = tx(d, crew, 2, d.chapters[1].body, 'decrement', 5, 4, -2) + let r = audit(d, 2) + assert.deepEqual(orders(select(r, 'headcount', 'conflict')[0]), [1, 2]) + assert.match(conflicts(r)[0].reason, /应为 3人.*相差 1人/u) + change.afterValue = 3 + change.evidence = d.chapters[1].body = '白帆队走了两人,只剩三人。' + assert.deepEqual(audit(d, 2), []) + fact(d, 'crew-again', 3, d.chapters[2].body, { key: 'crew_size', label: '白帆队人数', unit: '人', value: 6 }) + assert.match(select(audit(d), 'headcount', 'conflict')[0].reason, /明确说数量未变/u) + d.characterStateFacts[1].evidence = d.chapters[2].body = '白帆队又点名,有六人。' + r = audit(d) + assert.equal(conflicts(r).length, 0) + assert(select(r, 'headcount', 'needs_evidence').some(f => /增减依据/u.test(f.reason))) + change.evidence = '修订前写的是全队消失。' + assert.equal(conflicts(audit(d)).length, 0) + assert(!audit(d).some(f => f.evidence.some(e => e.chapterOrder === 4))) +}) + +test('唯一物件两主有双端证据;普通同名物品、不同编号不等于两主', () => { + const d = data('林岫持有唯一一枚霜印,行囊中还有一支笔。', '赵久的物件清单中也有霜印和笔。') + inv(d, 'a-pack', 1, 'a', d.chapters[0].body, ['霜印', '笔']) + inv(d, 'b-pack', 2, 'b', d.chapters[1].body, ['霜印', '笔']) + let r = audit(d) + assert.equal(conflicts(r).length, 1) + assert.deepEqual([conflicts(r)[0].title, orders(conflicts(r)[0]), conflicts(r)[0].characterIds], ['霜印', [1, 2], ['a', 'b']]) + assert.equal(select(r, 'inventory', 'needs_evidence')[0].title, '笔') + for (const [i, f] of d.characterStateFacts.entries()) Object.assign(f, { key: 'item:' + i, label: '霜印', value: true }) + assert.deepEqual(audit(d), []) + d.characterStateFacts[1].key = 'item:0' + r = audit(d) + assert.equal(conflicts(r).length, 1) + d.characterStateFacts[1].status = 'retconned' + assert.equal(conflicts(audit(d)).length, 0) +}) + +test('物件列表交易使用前后差集;正常交接不出现短暂两主,缺任一端只待核', () => { + const d = data('林岫独自保管唯一的霜印和灯。赵久随身带笔。', '林岫把霜印交给赵久。') + const a = inv(d, 'a-pack', 1, 'a', '林岫独自保管唯一的霜印和灯。', ['霜印', '灯']) + const b = inv(d, 'b-pack', 1, 'b', '赵久随身带笔。', ['笔']) + const into = tx(d, b, 2, d.chapters[1].body, 'add_item', ['笔'], ['笔', '霜印']) + assert.match(select(audit(d), 'inventory', 'needs_evidence')[0].reason, /缺少.*转出/u) + assert.equal(conflicts(audit(d)).length, 0) + tx(d, a, 2, d.chapters[1].body, 'remove_item', ['灯', '霜印'], ['灯']) + assert.deepEqual(audit(d), []) + into.status = 'pending' + assert.match(select(audit(d), 'inventory', 'needs_evidence')[0].reason, /缺少.*转入/u) +}) + +test('实际出场与提及分开;暂缓、留白、兑现分项处理,死亡不抹去别的承诺', () => { + const d = data('林岫进门,把湿衣搭在椅背上。', '林岫答应替人还印。', '旧信里只提到林岫。', '此后林岫的下落待交代。', + '印信的事暂缓。', '林岫答应把名册带回来。', '信上确认了林岫的死讯。', '名册后事有意留白。') + const appearance = state(d, 'appearance', 7, 'presence', '死亡') + tx(d, appearance, 1, d.chapters[0].body, 'create', null, 'onstage') + tx(d, appearance, 3, d.chapters[2].body, 'update', 'onstage', 'mentioned') + tx(d, appearance, 7, d.chapters[6].body, 'update', 'mentioned', '死亡') + const promise = state(d, 'promise-1', 5, 'return-seal', '暂缓', { category: 'promise', label: '还印' }) + tx(d, promise, 2, d.chapters[1].body, 'create', null, '答应还印') + state(d, 'where', 4, 'whereabouts', '待交代') + const second = state(d, 'promise-2', 8, 'return-list', '有意留白', { category: 'promise', label: '送回名册', status: 'resolved' }) + tx(d, second, 6, d.chapters[5].body, 'create', null, '送回名册') + let r = audit(d, 3) + assert.equal(select(r, 'promise', 'open').length, 1) + assert.match(r[0].reason, /实际出场在第 1 章.*第 3 章有提及/u) + assert.deepEqual(orders(r[0]), [1, 2, 3]) + assert.equal(select(audit(d, 4), 'whereabouts', 'open').length, 1) + assert.equal(select(audit(d, 5), 'promise', 'open').length, 0) + r = audit(d, 7) + assert.equal(select(r, 'whereabouts', 'open').length, 0) + assert.equal(select(r, 'promise', 'open').length, 1) + assert.match(select(r, 'promise', 'open')[0].reason, /送回名册.*实际出场在第 1 章.*死亡/u) + assert.deepEqual(audit(d), []) + // None of the 88 silent chapters confers an obligation on a supporting character. + d.chapters.push(...Array.from({ length: 88 }, (_, i) => ch(9 + i, '远处的钟响了一声。'))) + assert.deepEqual(audit(d, 96), []) +}) + +test('旧书明确原句只作待核,姓名清单、计划出场、未答应、空壳、旧版本均不入账', () => { + const d = data('箱底还有二十文。全队只剩五人。', '林岫答应送回名册。那把刀归赵久保管。', '林岫、赵久和周未的名字在纸上。', '林岫从未答应此事。', '') + d.chapters[4].summary = '林岫发誓付出五百文。' + d.chapterVersions.push({ ...version(d.chapters[4], 'old'), body: '林岫有一千文。' }) + d.contextNeedPlans = [{ projectId: p, targetChapterOrder: 4, expectedCharacters: [{ characterId: 'b', expectedPresence: 'onstage' }] }] + const r = audit(d) + assert.deepEqual(r.map(f => f.kind).sort(), ['headcount', 'inventory', 'money', 'promise']) + assert(r.every(f => f.status === 'needs_evidence' && f.evidence[0].versionId === null)) + state(d, 'where', 3, 'whereabouts', '待交代', { characterId: 'b' }) + const w = select(audit(d), 'whereabouts', 'open')[0] + assert.match(w.reason, /没有可定位的已确认实际出场/u) + assert.match(w.reason, /第 3 章有提及/u) +}) + +test('原句仍在但来源指纹已过期,交易及其改过说明的事实镜像均不能作证', () => { + const d = data('林岫现有十文。', '林岫付了三文,却记为余八文。另一人关上门。', '林岫又付三文,余五文。') + const short = '付了三文' + const wallet = fact(d, 'cash', 2, '却记为余八文', { value: 8 }) + tx(d, wallet, 1, d.chapters[0].body, 'create', null, 10) + const t = tx(d, wallet, 2, short, 'decrement', 10, 8, -3, { source: 'pipeline', sourceBodyHash: draftContentHash(d.chapters[1].body) }) + assert.equal(conflicts(audit(d, 2)).length, 1) + d.chapters[1].body = d.chapters[1].body.replace('另一人关上门', '这是另一份账簿上的旧话') + const r = audit(d, 2, 2) + assert.equal(conflicts(r).length, 0) + assert(r.some(f => f.atChapterOrder === 2 && !f.evidence.length && /指纹/u.test(f.reason))) + tx(d, wallet, 3, d.chapters[2].body, 'decrement', 8, 5, -3) + assert(select(audit(d), 'money', 'needs_evidence').some(f => /基线/u.test(f.reason) && orders(f).join() === '3')) + t.evidence = '旧版本并无此句' + assert.equal(conflicts(audit(d)).length, 0) +}) + +test('撤销和被撤销的交易及其镜像不参与对账;旧式撤销不能猜被撤销的是哪一笔', () => { + const d = data('林岫的钱袋里有十文。', '林岫花掉三文,账上却记八文。', '林岫回来核账。') + const wallet = fact(d, 'cash', 2, d.chapters[1].body, { value: 8 }) + tx(d, wallet, 1, d.chapters[0].body, 'create', null, 10) + const removed = tx(d, wallet, 2, d.chapters[1].body, 'decrement', 10, 8, 3) + const undo = tx(d, wallet, 3, d.chapters[2].body, 'update', 900, 500, null, { reversesTransactionIds: [removed.id] }) + assert.equal(conflicts(audit(d)).length, 0) + assert.equal(conflicts(audit(d, 2)).length, 0, 'editorial undo is not a future story event') + undo.status = 'rejected' + assert.equal(conflicts(audit(d)).length, 1) + undo.status = 'accepted' + delete undo.reversesTransactionIds + undo.reason = '撤销候选决定 R1;补偿记录 C1' + assert.equal(conflicts(audit(d)).length, 0) +}) + +test('流水使用生效时的名称、类别和单位;后来改名、改用两银不改写早期的文钱', () => { + const d = data('白帆队的经费有十文。', '白帆队花了三文,仍记余八文。', '新账改用银两,记了四两银子。') + const current = fact(d, 'fund', 3, d.chapters[2].body, { key: 'taels', label: '新银两账', unit: '两', value: 4 }) + const def = { category: 'resource', key: 'crew_funds', label: '白帆队经费', unit: '文', status: 'active' } + tx(d, current, 1, d.chapters[0].body, 'create', null, 10, null, { factDefinition: def }) + const change = tx(d, current, 2, d.chapters[1].body, 'decrement', 10, 8, 3, { factDefinition: def }) + let r = audit(d, 2) + assert.equal(conflicts(r).length, 1) + assert.match(conflicts(r)[0].title, /白帆队经费/u) + assert.match(conflicts(r)[0].reason, /7文/u) + delete change.factDefinition + r = audit(d) + assert.equal(conflicts(r).length, 0) + assert(r.some(f => /当时的账户定义/u.test(f.reason) && f.atChapterOrder === 2)) +}) + +test('长句中的金额、专物、承诺留在有界原文引文内,按 Unicode 码点裁剪', () => { + const prefix = '𠮷字'.repeat(130) + const d = data(prefix + ',林岫持有唯一的霜印。', prefix + ',赵久也持有霜印。', prefix + ',林岫花了三十文。', + prefix + ',林岫答应归还灯芯。', prefix + ',那封信归林岫保管。') + inv(d, 'a-pack', 1, 'a', d.chapters[0].body, ['霜印']) + inv(d, 'b-pack', 2, 'b', d.chapters[1].body, ['霜印']) + const r = audit(d) + assert(conflicts(r)[0].evidence.every(e => e.excerpt.includes('霜印'))) + for (const [kind, text] of [['money', '三十文'], ['promise', '答应归还灯芯'], ['inventory', '归林岫保管']]) { + const e = select(r, kind, 'needs_evidence')[0].evidence[0] + assert(e.excerpt.includes(text)) + assert(Array.from(e.excerpt).length <= 160 && !e.excerpt.includes('\ufffd')) + } +}) + +test('姓名清单不证明人数,无金额的泛引文也不证明余额', () => { + const d = data('白帆队有五人。林岫有十文钱。', '林岫、赵久在名册上。掌柜翻开账簿。') + const crew = fact(d, 'crew', 1, '白帆队有五人。', { key: 'headcount', label: '白帆队人数', unit: '人', value: 5 }) + const cash = fact(d, 'cash', 1, '林岫有十文钱。') + tx(d, crew, 2, '林岫、赵久在名册上。', 'decrement', 5, 1, 2) + tx(d, cash, 2, '掌柜翻开账簿。', 'decrement', 10, 80, 10) + const r = audit(d) + assert.equal(conflicts(r).length, 0) + assert(select(r, 'headcount', 'needs_evidence').some(f => /没有明确人数/u.test(f.reason))) + assert(select(r, 'money', 'needs_evidence').some(f => /没有可定位的金额/u.test(f.reason))) +}) + +test('转出点明了对方但缺收款记录,不拿其旧余额跟后账制造第二个冲突', () => { + const d = data('林岫有十文。赵久有一文。', '林岫转给赵久四文。', '赵久付出三文,余二文。') + const a = fact(d, 'src', 1, '林岫有十文。') + const b = fact(d, 'dst', 1, '赵久有一文。', { characterId: 'b', value: 1 }) + tx(d, a, 2, d.chapters[1].body, 'decrement', 10, 6, 4) + tx(d, b, 3, d.chapters[2].body, 'decrement', 5, 2, 3) + const r = audit(d) + assert.equal(conflicts(r).length, 0) + assert(select(r, 'money', 'needs_evidence').some(f => /转入/u.test(f.reason))) + assert(select(r, 'money', 'needs_evidence').some(f => /基线/u.test(f.reason) && orders(f).join() === '3')) +}) + +test('期初数值必须有对应原句;账目含另一笔金额时长引文仍聚焦实际数值', () => { + const d = data('林岫花了两文。', '林岫另花三文,账页写余八文。') + const wallet = fact(d, 'cash', 1, d.chapters[0].body) + const t = tx(d, wallet, 2, d.chapters[1].body, 'decrement', 10, 8, 3) + assert.equal(conflicts(audit(d)).length, 0) + assert(select(audit(d), 'money', 'needs_evidence').some(f => /基线/u.test(f.reason))) + d.chapters[0].body = wallet.evidence = '林岫钱包里有十文。' + d.chapters[1].body = t.evidence = '林岫花去三文,账上记为余八文,' + '𠮷'.repeat(220) + ',别家收银九十九文。' + const issue = conflicts(audit(d))[0] + assert(issue.evidence[1].excerpt.includes('余八文')) + assert(!issue.evidence[1].excerpt.includes('九十九文')) +}) + +test('明确的作者暂缓无需虚构正文,不能据此证明已经履约或死亡', () => { + const d = data('林岫许诺送回石灯。', '茶水放凉了。') + const obligation = state(d, 'lamp', 1, 'return-lamp', '送回石灯', { category: 'promise', label: '送回石灯' }) + const hold = tx(d, obligation, 2, '作者决定在番外处理。', 'update', '送回石灯', '暂缓') + assert.equal(select(audit(d, 1), 'promise', 'open').length, 1) + assert.deepEqual(audit(d), []) + hold.afterValue = '有意留白' + assert.deepEqual(audit(d), []) + hold.afterValue = '已履行' + const unproven = audit(d) + assert.equal(select(unproven, 'promise', 'open').length, 1) + assert(unproven.some(f => f.atChapterOrder === 2 && f.evidence.length === 0)) + hold.afterValue = '暂缓' + hold.sourceBodyHash = draftContentHash('旧正文。') + assert.equal(select(audit(d), 'promise', 'open').length, 1) +}) + +test('正数不能给负余额作证,旧短引文不能取自新数词的内部', () => { + const d = data('林岫的余额为十文。', '林岫花去三文,仍有八文。') + const wallet = fact(d, 'cash', 1, d.chapters[0].body, { value: -10 }) + const outgoing = tx(d, wallet, 2, d.chapters[1].body, 'decrement', -10, 8, 3) + assert.equal(conflicts(audit(d)).length, 0) + wallet.value = 10 + d.chapters[1].body = '林岫买画花去十二文,却记余八文。' + outgoing.evidence = '二文' + outgoing.beforeValue = 10 + outgoing.delta = 2 + const r = audit(d) + assert.equal(conflicts(r).length, 0) + assert(r.some(f => f.atChapterOrder === 2 && /原句/u.test(f.reason))) +}) diff --git a/scripts/validate-book-continuity-surfaces.mjs b/scripts/validate-book-continuity-surfaces.mjs new file mode 100644 index 0000000..d3f9ff2 --- /dev/null +++ b/scripts/validate-book-continuity-surfaces.mjs @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' +import { auditBook, oldBook, continuitySurfaceFixture, firstQuote, laterQuote, oldQuote, oldCountdown } from './fixtures/book-continuity-surfaces.mjs' + +const Database = createRequire(import.meta.url)('better-sqlite3') +const tool = 'agent.getBookContinuityReport' +const base = join(repoRoot, 'tmp') +await mkdir(base, { recursive: true }) +const work = await mkdtemp(join(base, 'continuity-surfaces-')) +const buildOptions = { bundle: true, platform: 'node', format: 'esm', target: 'node22', external: ['electron', 'better-sqlite3'] } +const networkGuard = "globalThis.fetch = () => { throw new Error('Network disabled for continuity tests') }" +const modules = ['shared/defaults', 'services/BookContinuityService', 'services/DraftDiagnosticBindingService', + 'shared/bookCollectionSelection', 'agent/tools/AgentToolService', + 'storage/SqliteStorageService', 'storage/JsonStorageService'] +const projections = [] + +async function fingerprints(dir) { + const names = (await readdir(dir, { withFileTypes: true })).filter(e => e.isFile()).map(e => e.name).sort() + const files = {} + for (const name of names) files[name] = createHash('sha256').update(await readFile(join(dir, name))).digest('hex') + return files +} + +function invoke(entry, args, input = '') { + const env = { SystemRoot: process.env.SystemRoot, PATH: process.env.PATH, HOME: work, USERPROFILE: work, APPDATA: work, LOCALAPPDATA: work } + const result = spawnSync(process.execPath, [join(work, entry + '.mjs'), ...args], { + cwd: work, env, input, encoding: 'utf8', windowsHide: true, timeout: 20000, maxBuffer: 2 ** 21 + }) + assert.ifError(result.error) + assert.equal(result.status, 0, result.stderr || result.stdout) + return result.stdout.trim() +} + +const originalFetch = globalThis.fetch +let networkAttempts = 0 +try { + globalThis.fetch = () => { networkAttempts++; throw new Error('Network disabled for continuity tests') } + globalThis.auditProjectionCalls = projections + await build({ ...buildOptions, stdin: { resolveDir: repoRoot, contents: modules.map(m => `export * from './src/${m}'`).join('\n') }, + outfile: join(work, 'api.mjs'), plugins: [{ name: 'observe-real-projections', setup(b) { + b.onResolve({ filter: /SqliteStorageService$/ }, a => a.importer.endsWith('agentBookProjection.ts') + ? { path: 'scoped-sqlite', namespace: 'audit-test' } : undefined) + b.onLoad({ filter: /.*/, namespace: 'audit-test' }, () => ({ resolveDir: repoRoot, contents: + "import {loadSqliteCollectionsReadonly as read} from './src/storage/SqliteStorageService';" + + 'export function loadSqliteCollectionsReadonly(path,keys,selector) { globalThis.auditProjectionCalls.push({keys,selector}); return read(path,keys,selector) }' + })) + } }] }) + await build({ ...buildOptions, entryPoints: { cli: join(repoRoot, 'src/agent/cli.ts'), mcp: join(repoRoot, 'src/agent/mcp/server.ts') }, + outdir: work, outExtension: { '.js': '.mjs' }, banner: { js: networkGuard } }) + const api = await import(pathToFileURL(join(work, 'api.mjs')).href) + const seed = api.normalizeAppData(continuitySurfaceFixture()) + const definition = api.AgentToolService.listTools().find(t => t.name === tool) + assert.equal(definition.riskLevel, 'read') + assert.equal(definition.inputSchema.additionalProperties, false) + assert.equal(definition.inputSchema.properties.kind.enum.length, 7) + assert.deepEqual(definition.inputSchema.properties.status.enum, ['conflict', 'needs_evidence', 'open']) + const run = args => api.AgentToolService.callTool({ name: tool, arguments: args }) + const expected = options => api.buildBookContinuityReport(seed, auditBook, options) + const onlyConflict = { from: 5, to: 5, kind: 'money', status: 'conflict', limit: 1 } + const dispute = expected(onlyConflict) + assert.equal(dispute.total, 1) + assert.deepEqual(dispute.findings[0].evidence.map(e => [e.chapterId, e.chapterOrder, e.versionId, e.excerpt]), [ + [auditBook + '-2', 2, null, firstQuote], [auditBook + '-5', 5, 'current-v5', laterQuote] + ]) + for (const e of dispute.findings[0].evidence) { + assert.equal(e.contentHash, api.draftContentHash(seed.chapters.find(c => c.id === e.chapterId).body)) + } + const all = expected({ limit: 50 }) + assert(all.counts.conflict && all.counts.open && all.counts.needs_evidence && all.total > 10) + const select = api.createBookCollectionSelection(auditBook, api.BOOK_CONTINUITY_COLLECTIONS), scoped = select(seed) + assert.deepEqual(api.buildBookContinuityReport(scoped, auditBook, onlyConflict), dispute) + assert(scoped.chapters.every(c => c.projectId === auditBook)) + const otherEdit = { ...seed, settings: { ...seed.settings }, chapters: seed.chapters.map(c => + c.projectId === oldBook ? { ...c, body: '别书改写。' } : c) } + assert.equal(select(otherEdit), scoped) + assert.notEqual(select({ ...otherEdit, chapterVersions: [] }), scoped) + console.log('PASS project selection, current version and versionless evidence, cross-range chapter IDs and hashes') + + for (const [source, Store] of [['json', api.JsonStorageService], ['sqlite', api.SqliteStorageService]]) { + const dir = join(work, source), path = join(dir, 'novel-director-data.' + source) + await mkdir(dir) + const writeFixture = async data => { + const store = new Store(path) + try { await store.save(data) } finally { store.close?.() } + } + await writeFixture(seed) + if (source === 'sqlite') { + const db = new Database(path, { fileMustExist: true }) + try { db.pragma('journal_mode = DELETE') } finally { db.close() } + } + const before = await fingerprints(dir) + const args = { project: '渡口核对样书', storagePath: path, userDataPath: dir } + projections.length = 0 + const result = await run({ ...args, ...onlyConflict }) + assert.equal(result.storage.source, source) + assert.deepEqual(result.data, dispute) + if (source === 'sqlite') { + assert.deepEqual(projections.filter(p => p.keys.length > 1), [{ keys: api.BOOK_CONTINUITY_COLLECTIONS, selector: args.project }]) + assert(projections.every(p => p.selector === args.project)) + projections.length = 0 + } + const sameCutoff = { ...onlyConflict, status: 'open', kind: 'promise' } + assert.deepEqual((await run({ ...args, ...sameCutoff })).data, expected(sameCutoff)) + if (source === 'sqlite') { + assert.deepEqual(projections, [{ keys: ['projects'], selector: args.project }]) + } + const firstPage = (await run(args)).data + assert.equal(firstPage.limit, 10) + const secondPage = (await run({ ...args, offset: 10, limit: 10 })).data + assert.deepEqual([...firstPage.findings, ...secondPage.findings], all.findings.slice(0, 20)) + assert.equal((await run({ ...args, to: 2, status: 'conflict' })).data.total, 0) + assert.deepEqual((await run({ ...args, offset: 999 })).data.findings, []) + for (const finding of firstPage.findings) for (const e of finding.evidence) { + const chapter = seed.chapters.find(c => c.id === e.chapterId && c.projectId === auditBook && !c.archivedAt) + assert(chapter?.body.includes(e.excerpt)); assert.notEqual(e.excerpt, chapter.body.trim()) + assert.deepEqual(Object.keys(e).sort(), ['chapterId', 'chapterOrder', 'contentHash', 'excerpt', 'versionId']) + } + assert.doesNotMatch(JSON.stringify([firstPage, secondPage]), /另一册|归档陷阱|草稿陷阱|旧版陷阱|封存附记|superseded-v5/) + const old = (await run({ ...args, project: oldBook, kind: 'money', status: 'needs_evidence' })).data + assert.equal(old.total, 1) + const evidence = old.findings[0].evidence[0] + assert.equal(evidence.chapterId, oldBook + '-2'); assert.equal(evidence.versionId, null) + assert(evidence.excerpt.includes('27文钱') && oldQuote.includes(evidence.excerpt)) + assert(!JSON.stringify(old).includes(oldQuote), 'Even a one-line chapter must not be returned in full') + const countdown = (await run({ ...args, project: oldBook, kind: 'countdown' })).data + const timeQuote = countdown.findings[0].evidence[0].excerpt + assert(timeQuote.includes('137天') && oldCountdown.includes(timeQuote)) + assert(!JSON.stringify(countdown).includes(oldCountdown)) + for (const bad of [{ from: 0 }, { from: 9, to: 5 }, { limit: 51 }, { limit: 1.5 }, { offset: -1 }, + { offset: '0' }, { kind: 'else' }, { status: 'all' }, { includeProse: true }, { project: 'nonexistent' }, { storagePath: 1 }]) { + await assert.rejects(run({ ...args, ...bad })) + } + await assert.rejects(run({ storagePath: path, userDataPath: dir }), /project/i) + const cli = JSON.parse(invoke('cli', ['book-continuity', '--project-id', auditBook, '--storage', path, '--user-data', dir, + '--from', '5', '--to', '5', '--kind', 'money', '--status', 'conflict', '--limit', '1'])) + assert.deepEqual(cli.data, dispute) + const requests = [{ id: 1, method: 'tools/list' }, { id: 2, method: 'tools/call', params: { name: tool, arguments: { ...args, ...onlyConflict } } }] + const responses = invoke('mcp', [], requests.map(r => JSON.stringify({ jsonrpc: '2.0', ...r })).join('\n') + '\n') + .split(/\r?\n/).map(s => JSON.parse(s)) + const advertised = responses.find(r => r.id === 1).result.tools.find(t => t.name === tool) + assert.deepEqual(advertised.inputSchema, definition.inputSchema) + assert.equal(advertised.annotations.readOnlyHint, true) + const mcp = responses.find(r => r.id === 2).result + assert.equal(mcp.isError, false); assert.deepEqual(mcp.structuredContent, result) + assert.deepEqual(JSON.parse(mcp.content[0].text), result) + assert.deepEqual(await fingerprints(dir), before, 'Read-only entrypoints must leave files byte-identical') + + await run({ ...args, ...onlyConflict }) + await writeFixture({ ...seed, chapters: seed.chapters.map(c => c.id === auditBook + '-5' ? { ...c, body: '重新核对后删去了该笔记录。' } : c) }) + const revised = await run({ ...args, ...onlyConflict }) + assert.notEqual(revised.storage.revision, result.storage.revision) + assert.equal(revised.data.total, 0, 'An earlier snapshot must not survive a revision change') + console.log(`PASS ${source}: actual CLI/MCP, paging and filters, isolated projects, short legacy chapter, revision invalidation, unchanged storage`) + } + const nonexistent = join(work, 'empty') + await mkdir(nonexistent) + await assert.rejects(run({ storagePath: join(nonexistent, 'missing.sqlite'), userDataPath: nonexistent, projectId: auditBook }), /project/i) + assert.deepEqual(await readdir(nonexistent), []) + assert.equal(networkAttempts, 0) +} finally { + globalThis.fetch = originalFetch + delete globalThis.auditProjectionCalls + assert.equal(dirname(work), base) + await rm(work, { recursive: true, force: true, maxRetries: 4 }) +} diff --git a/scripts/validate-book-continuity-timeline.mjs b/scripts/validate-book-continuity-timeline.mjs new file mode 100644 index 0000000..07b6038 --- /dev/null +++ b/scripts/validate-book-continuity-timeline.mjs @@ -0,0 +1,346 @@ +import assert from 'node:assert/strict' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +// Only the pure merge planner is under test. No storage adapter, desktop profile or model is opened. +const { outputFiles } = await build({ stdin: { resolveDir: repoRoot, contents: [ + "export { buildBookContinuityReport } from './src/services/BookContinuityService'", + "export { normalizeAppData } from './src/shared/normalizers/appData'", + "export { editTimelineEvent } from './src/shared/normalizers/timeline'", + "export { normalizeCandidateMemoryPatchAmendment } from './src/shared/normalizers/candidateDecision'", + "export { WorldManagementService } from './src/services/WorldManagementService'", + "export { applyMemoryCandidatePatchToData } from './src/services/MemoryCandidateService'", + "export { reorderChapterInAppData, archiveChapterInAppData, restoreArchivedChapterInAppData } from './src/services/ChapterLifecycleService'", + "export { draftContentHash } from './src/services/DraftDiagnosticBindingService'", + "export { mergeAppData } from './src/main/DataMergeService'" +].join('\n') }, bundle: true, write: false, platform: 'node', format: 'esm', target: 'node22', + plugins: [{ name: 'no-storage', setup(builder) { + builder.onResolve({ filter: /\/dataMerge\/dataFileStorage$/ }, () => ({ path: 'disabled', namespace: 'fixture' })) + builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: + "const fail = () => { throw new Error('No file storage in this test') }; export { fail as backupBeforeMerge, fail as backupFileForOverwrite, fail as loadDataFileSnapshot, fail as saveDataFile }" })) + } }] }) +const api = await import('data:text/javascript;base64,' + Buffer.from(outputFiles[0].contents).toString('base64')) +const projectId = 'clock-book', at = '2025-01-01T00:00:00Z', later = '2025-01-02T00:00:00Z' +const project = { id: projectId, name: '虚构的时序样书', createdAt: at, updatedAt: at } +const chapter = (id, order, body, patch = {}) => ({ id, projectId, order, title: id, body, createdAt: at, updatedAt: at, ...patch }) +const quoted = (order, quote) => chapter('c' + order, order, '窗纸动了一下。\r\n' + quote + '\n他合上书。') +const version = c => ({ id: 'v-' + c.id, projectId, chapterId: c.id, body: c.body, source: 'accept_draft', createdAt: at }) +const event = (order, sourceExcerpt, patch = {}) => ({ + id: 'e' + order, projectId, chapterOrder: order, title: '门楼记录' + order, storyTime: '', narrativeOrder: order, + participantCharacterIds: [], result: '', downstreamImpact: '', createdAt: at, updatedAt: at, + timelineKey: '本城', location: '北门', timeAnchor: '封城', elapsedDays: 0, sourceExcerpt, ...patch +}) +const book = (chapters, timelineEvents = [], rest = {}) => api.normalizeAppData({ + projects: [project], chapters, chapterVersions: chapters.map(version), timelineEvents, ...rest +}) +const spring = '北门正值暮春。', winter = '北门已是严冬。' +const five = '距离启闸还剩五天。', four = '距离启闸只剩四天。', three = '距离启闸只剩三天。' +function seasons(second = {}, first = {}, q = winter) { + return book([quoted(1, spring), quoted(96, q)], [event(1, spring, { season: 'spring', ...first }), + event(96, q, { season: 'winter', ...second })]) +} +function clock(second = {}, first = {}, q = four) { + return book([quoted(1, five), quoted(96, q)], [event(1, five, { deadlineKey: '本次启闸', remainingDays: 5, ...first }), + event(96, q, { deadlineKey: '本次启闸', elapsedDays: 2, remainingDays: 4, ...second })]) +} +function read(data, kind, options = {}) { + const report = api.buildBookContinuityReport(data, projectId, { kind, limit: 200, ...options }) + for (const f of report.findings) { + for (const e of f.evidence) { + const c = data.chapters.find(c => c.id === e.chapterId && c.projectId === projectId && !c.archivedAt) + assert.ok(c && c.body.trim() && e.excerpt && c.body.includes(e.excerpt), 'not a quotation of the live text') + assert.equal(e.contentHash, api.draftContentHash(c.body)) + assert.equal(e.chapterOrder, c.order) + if (e.versionId !== null) assert.ok(data.chapterVersions.some(v => v.id === e.versionId && v.chapterId === c.id && v.body === c.body)) + } + if (f.status === 'conflict') { + assert.equal(new Set(f.evidence.map(e => e.chapterId)).size, 2) + assert.equal(f.atChapterOrder, Math.max(...f.evidence.map(e => e.chapterOrder))) + } + } + return report.findings +} +let passed = 0 +function test(label, fn) { fn(); passed++; console.log('PASS ' + label) } + +test('同线同地同一计时日的两个季节:核对到第96章,能分别定位真实原句和正式版本', () => { + const data = seasons(), before = structuredClone(data) + const [f] = read(data, 'season', { from: 96, to: 96 }) + assert.equal(f.status, 'conflict'); assert.equal(f.atChapterOrder, 96) + assert.deepEqual(f.evidence.map(e => [e.chapterId, e.versionId, e.excerpt]), [['c1', 'v-c1', spring], ['c96', 'v-c96', winter]]) + assert.deepEqual(read(data, 'season', { to: 95 }), []) + assert.deepEqual(data, before) +}) +test('固定截止按共同起点的天数差核对;相符、不同期限和过期后的零均不报', () => { + const [f] = read(clock(), 'countdown') + assert.equal(f.status, 'conflict'); assert.match(f.reason, /相隔 2 天.*应剩 3 天.*4 天/) + assert.deepEqual(f.evidence.map(e => e.excerpt), [five, four]) + assert.deepEqual(read(clock({ remainingDays: 3 }, {}, three), 'countdown'), []) + assert.deepEqual(read(clock({ deadlineKey: '另一次启闸' }), 'countdown'), []) + const zero = '距钟响还剩零天。' + assert.deepEqual(read(book([quoted(1, zero), quoted(96, zero)], [event(1, zero, { deadlineKey: '钟响', remainingDays: 0 }), + event(96, zero, { deadlineKey: '钟响', elapsedDays: 3, remainingDays: 0 })]), 'countdown'), []) +}) +test('异地、支线、显式闪回、跨季以及中间章说明的跳时或改期,不报两章矛盾', () => { + for (const patch of [{ location: '南港' }, { timelineKey: '另一线' }, { timeShift: 'flashback' }, + { timeShift: 'time_skip' }, { storyTime: '十年前的回忆' }]) { + assert.deepEqual(read(seasons(patch), 'season'), []) + assert.deepEqual(read(clock(patch), 'countdown'), []) + } + assert.deepEqual(read(seasons({ elapsedDays: 240 }), 'season'), []) + const turn = seasons() + turn.chapters[1].body = '今日立冬,' + winter + assert.deepEqual(read(turn, 'season'), []) + turn.chapters[1].body = '今日立冬。' + winter + assert.deepEqual(read(turn, 'season').map(f => [f.status, f.evidence[0].excerpt]), [['needs_evidence', '今日立冬。']]) + turn.chapters[1].body = '他想起十年前。' + winter + assert.deepEqual(read(turn, 'season'), []) + for (const [data, kind, transition] of [[seasons(), 'season', '半年后,队伍才回到北门。'], + [clock(), 'countdown', '启闸日期已经延期。']]) { + data.chapters.push(quoted(18, transition)) + assert.deepEqual(read(data, kind), []) + } +}) +test('缺共同起点、天数、地点、时间线或截止事项均待核;0天不等于未填', () => { + for (const [patch, why] of [[{ timeAnchor: '出海' }, /两个起点/], [{ timeAnchor: '' }, /起点/], + [{ elapsedDays: undefined }, /已过天数/], [{ location: '' }, /地点/], + [{ timelineKey: '' }, /时间线/], [{ deadlineKey: '' }, /截止事项/]]) { + const rows = read(clock(patch), 'countdown', { from: 96 }) + assert.equal(rows.length, 1); assert.equal(rows[0].status, 'needs_evidence'); assert.match(rows[0].reason, why) + assert.equal(rows[0].atChapterOrder, 96); assert.ok(rows[0].evidence.some(e => e.excerpt === four)) + } +}) +test('第96章引文删掉后只附前章证据;两端都失效时没有证据,仍在96章范围内', () => { + const d = clock() + d.chapters[1].body = '这一版没有写期限。' + for (const quotes of [[five], []]) { + const [f] = read(d, 'countdown', { from: 96, to: 96 }) + assert.equal(f.status, 'needs_evidence'); assert.equal(f.atChapterOrder, 96) + assert.deepEqual(f.evidence.map(e => e.excerpt), quotes); assert.match(f.reason, /原句/) + d.chapters[0].body = '以前的时限删掉了。' + } +}) +test('换序按ID;修订后采用当前哈希和版本;同号替身、归档、草稿及旧版本不能补证', () => { + const d = seasons() + delete d.timelineEvents[0].chapterId // Old number-only row in an already-open editor. + const swapped = api.reorderChapterInAppData(d, 'c1', 96, later) + assert.deepEqual(swapped.timelineEvents.map(e => [e.chapterId, e.chapterOrder]), [['c1', 96], ['c96', 1]]) + const proof = read(swapped, 'season')[0].evidence + assert.deepEqual(proof.map(e => [e.chapterId, e.chapterOrder, e.excerpt]), [['c96', 1, winter], ['c1', 96, spring]]) + const oldHash = proof[1].contentHash, c = swapped.chapters.find(c => c.id === 'c1') + c.body += '新添的门牌。' + assert.equal(read(swapped, 'season')[0].evidence[1].versionId, null) + swapped.chapterVersions.push({ ...version(c), id: 'v-revision', source: 'manual_revision', createdAt: later }) + const fresh = read(swapped, 'season')[0].evidence[1] + assert.notEqual(fresh.contentHash, oldHash); assert.equal(fresh.versionId, 'v-revision') + const gone = api.archiveChapterInAppData(swapped, c.id, later) + gone.chapters.push(chapter('replacement', 96, '完全不同的正文。')) + gone.generatedChapterDrafts.push({ chapterId: 'replacement', body: spring }) + assert.deepEqual(read(gone, 'season'), []) + const restored = api.restoreArchivedChapterInAppData(gone, c.id, later) + assert.equal(restored.chapter.order, 97) + assert.equal(restored.data.timelineEvents.find(e => e.chapterId === c.id).chapterOrder, 97) + assert.equal(read(restored.data, 'season')[0].atChapterOrder, 97) +}) +test('旧书原句只列线索;不拿注释、草稿、历史版、别书或同句重复充当证据', () => { + const d = book([quoted(1, winter), quoted(96, five), chapter('shell', 12, '', { summary: spring }), + chapter('retired', 3, winter, { archivedAt: at }), chapter('other', 4, four, { projectId: 'elsewhere' })]) + d.generatedChapterDrafts.push({ body: three }) + const f = [...read(d, 'season'), ...read(d, 'countdown')] + assert.deepEqual(f.map(x => [x.status, x.atChapterOrder, x.evidence[0].excerpt]), [ + ['needs_evidence', 1, winter], ['needs_evidence', 96, five]]) + const repeated = clock() + repeated.chapters[1].body += four + assert.ok(read(repeated, 'countdown').every(f => f.status === 'needs_evidence')) +}) +test('对白、回忆、否定、不确定数量和一行多个期限不作为可计算事实', () => { + for (const q of ['“距离启闸只剩四天。”', '十年前,距离启闸只剩四天。', '并非距离启闸只剩四天。', + '距离启闸只剩四天左右。', '距离启闸只剩两三天。', '距离启闸只剩四天,距离封桥还剩五天。']) { + assert.ok(read(clock({}, {}, q), 'countdown').every(f => f.status !== 'conflict'), q) + } +}) +test('长句的可见摘录包含时间词本身,不用章节开头代替命中', () => { + const q = '灯下'.repeat(150) + four + const [f] = read(clock({}, {}, q), 'countdown') + assert.equal(f.status, 'conflict'); assert.ok(f.evidence[1].excerpt.includes('只剩四天')) +}) +test('跨句摘录精确定位对应句,其他支线插入章不吞掉原线比较', () => { + const d = clock({ sourceExcerpt: '窗纸动了一下。\r\n' + four }) + d.chapters.push(quoted(60, three)) + d.timelineEvents.push(event(60, three, { deadlineKey: '本次启闸', remainingDays: 3, timelineKey: '支线' })) + const [f] = read(d, 'countdown') + assert.equal(f.status, 'conflict'); assert.deepEqual(f.evidence.map(e => e.excerpt), [five, four]) +}) +test('旧章号只按项目内唯一映射;归档重号留待核,即使随后移走重号也不猜', () => { + const d = book([quoted(1, spring), quoted(96, winter), chapter('old96', 96, '旧卷。', { archivedAt: at }), + chapter('foreign', 1, '别书。', { projectId: 'elsewhere' })], [event(1, spring, { season: 'spring' }), event(96, winter, { season: 'winter' })]) + assert.deepEqual(d.timelineEvents.map(e => e.chapterId), ['c1', null]) + let [f] = read(d, 'season', { from: 96 }).filter(f => f.title.includes('待绑定')) + assert.equal(f.atChapterOrder, 96); assert.deepEqual(f.evidence, []) + d.chapters.find(c => c.id === 'old96').order = 99 + assert.equal(api.normalizeAppData(d).timelineEvents[1].chapterId, null) + const edited = api.editTimelineEvent(d.timelineEvents[1], { chapterId: 'c96' }, d.chapters) + assert.equal(edited.chapterId, 'c96'); assert.equal(edited.chapterOrder, 96) + for (const badId of ['missing', 'foreign', 'old96']) assert.throws(() => api.editTimelineEvent(edited, { chapterId: badId }, d.chapters)) + d.timelineEvents[1] = { ...edited, chapterId: 'missing' } + ;[f] = read(d, 'season', { from: 96 }).filter(f => f.title.includes('待绑定')) + assert.equal(f.atChapterOrder, 96); assert.deepEqual(f.evidence, []) +}) +test('旧形状及未来预留章号仍可编辑;坏校验字段不强制转成0、默认季节或别章', () => { + const legacy = { id: 'e', projectId, title: '候选', chapterOrder: 37, storyTime: '天光初亮' } + const partial = api.normalizeAppData({ timelineEvents: [legacy] }).timelineEvents[0] + assert.equal(partial.chapterId, undefined); assert.deepEqual(partial.participantCharacterIds, []) + const d = book([quoted(1, spring)], [{ ...legacy, chapterId: 123, chapterOrder: 1, season: 'springtime', + elapsedDays: '0', remainingDays: -2, sourceExcerpt: ['wrong'], timeShift: 7, timelineKey: ' 主线 ' }]) + const [e] = d.timelineEvents + assert.equal(e.chapterId, null); assert.equal(e.timelineKey, '主线') + for (const k of ['season', 'elapsedDays', 'remainingDays', 'sourceExcerpt', 'timeShift']) assert.ok(!Object.hasOwn(e, k)) + const bound = api.editTimelineEvent(e, { chapterOrder: 1 }, d.chapters) + assert.equal(bound.chapterId, 'c1') + const future = api.editTimelineEvent(bound, { chapterOrder: 37 }, d.chapters) + assert.equal(future.chapterId, null); assert.equal(future.chapterOrder, 37); assert.equal(future.storyTime, legacy.storyTime) + assert.deepEqual(api.normalizeAppData(d).timelineEvents, d.timelineEvents) +}) + +test('Agent时间线预览/写入/编辑用同一组字段;换序后的旧预览不能把事件绑到替身', () => { + const d = book([quoted(1, five), quoted(96, four), chapter('foreign', 3, '别书。', { projectId: 'other' })]) + const command = (action, operationId, patch, id) => ({ projectId, action, operationId, patch, id, entity: 'timeline_event', + source: { kind: 'agent_director', requestedAt: at, reason: '隔离样本' } }) + const fields = { title: '闸门计时', chapterOrder: 1, storyTime: '黎明', timelineKey: '主线', location: '南岸', + timeAnchor: '出发', elapsedDays: 0, deadlineKey: '启闸', remainingDays: 5, sourceExcerpt: five, season: 'spring', timeShift: 'flashback' } + const world = api.WorldManagementService, create = command('create', 'new-clock', fields) + const preview = world.preview(d, create) + const apply = (data, cmd, p = world.preview(data, cmd)) => world.apply(data, + { ...cmd, expectedFingerprint: p.fingerprint }, { authorizationGrantId: 'isolated-test-grant' }) + assert.equal(preview.after.chapterId, 'c1'); assert.equal(d.timelineEvents.length, 0) + const swapped = api.reorderChapterInAppData(d, 'c1', 96, later) + assert.throws(() => apply(swapped, create, preview), /preview/i) + const saved = apply(d, create), id = saved.result.id + assert.deepEqual(saved.data.timelineEvents[0], preview.after) + const edited = apply(saved.data, command('update', 'update-clock', { chapterId: 'c96', elapsedDays: 2, + remainingDays: 4, timeShift: null, sourceExcerpt: four }, id)) + const e = world.get(edited.data, projectId, 'timeline_event', id) + assert.equal(e.chapterOrder, 96); assert.equal(e.chapterId, 'c96'); assert.equal(e.sourceExcerpt, four) + assert.equal(e.timeShift, undefined); assert.equal(e.season, 'spring'); assert.equal(e.elapsedDays, 2) + for (const patch of [{ chapterId: 'foreign' }, { chapterId: 'gone' }, { remainingDays: '2' }, { elapsedDays: -1 }, + { elapsedDays: 0.5 }, { season: 'wet' }, { timeShift: 'back' }, { location: [] }]) { + assert.throws(() => world.preview(edited.data, command('update', 'invalid', patch, id))) + } + const cleared = apply(edited.data, command('update', 'clear-clock', { chapterId: null }, id)).result.after + assert.equal(cleared.chapterId, null); assert.equal(cleared.chapterOrder, null) +}) +test('接受时间线记忆候选保留标注并绑定稳定章ID;候选编辑不能顺带改来源章节', () => { + const d = book([quoted(1, five), quoted(96, four)]) + d.chapterGenerationJobs.push({ id: 'job', projectId, targetChapterOrder: 96 }) + const fields = { timeAnchor: '封城', elapsedDays: 2, remainingDays: 4, deadlineKey: '启闸', sourceExcerpt: four, location: '北门', timelineKey: '主线' } + const amendment = { kind: 'timeline_event_create', event: fields } + assert.deepEqual(api.normalizeCandidateMemoryPatchAmendment(amendment), amendment) + for (const bad of [{ chapterId: 'c1' }, { chapterOrder: 1 }, { remainingDays: 0.2 }]) { + assert.equal(api.normalizeCandidateMemoryPatchAmendment({ ...amendment, event: bad }), undefined) + } + const patch = { ...amendment, event: { ...fields, chapterId: 'c96', chapterOrder: 1, title: '记时' }, schemaVersion: 1, warnings: [], summary: '启闸' } + const candidate = { id: 'candidate', projectId, jobId: 'job', type: 'timeline_event', targetId: null, status: 'pending', proposedPatch: patch } + d.memoryUpdateCandidates.push(candidate) + const args = { current: d, projectId, candidate, patch, timestamp: at, targetChapterOrder: 96 } + const [saved] = api.applyMemoryCandidatePatchToData(args).timelineEvents + assert.equal(saved.chapterId, 'c96'); assert.equal(saved.chapterOrder, 96) + for (const [key, value] of Object.entries(fields)) assert.equal(saved[key], value) + patch.event.chapterId = 'missing'; assert.throws(() => api.applyMemoryCandidatePatchToData(args), /chapterId/) + assert.deepEqual(d.timelineEvents, []) +}) +test('实际导入规划重映章节和参与角色的同名ID,不改作者的时间线、地点和截止标记', () => { + const source = book([quoted(1, spring)], [event(1, spring, { participantCharacterIds: ['c1'], + season: 'spring', timeAnchor: 'c1', deadlineKey: 'c1', timelineKey: 'c1' })], + { characters: [{ id: 'c1', projectId, name: '校时员', createdAt: at, updatedAt: at }] }) + const target = book([chapter('c1', 1, '目标原章。')], [], { projects: [{ ...project, name: '保留的目标书' }] }) + const { mergedData: merged, preview } = api.mergeAppData(source, target, { sourcePath: 'memory-source', targetPath: 'memory-target' }) + assert.ok(preview.canAutoMerge) + const imported = merged.projects.find(p => p.id !== projectId) + const [e] = merged.timelineEvents, c = merged.chapters.find(c => c.projectId === imported.id) + assert.notEqual(c.id, 'c1'); assert.equal(e.chapterId, c.id); assert.equal(e.projectId, imported.id) + assert.deepEqual(e.participantCharacterIds, [merged.characters.find(ch => ch.projectId === imported.id).id]) + assert.notEqual(e.participantCharacterIds[0], c.id) + assert.deepEqual([e.timeAnchor, e.deadlineKey, e.timelineKey, e.sourceExcerpt], ['c1', 'c1', 'c1', spring]) +}) + +if (process.argv.includes('--desktop')) { + const { mkdtemp, rm, writeFile } = await import('node:fs/promises') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + const { createRequire } = await import('node:module') + const { execFile } = await import('node:child_process') + const { promisify } = await import('node:util') + const work = await mkdtemp(join(tmpdir(), 'timeline-renderer-')) + try { + const seed = book([quoted(1, spring), quoted(96, four), chapter('duplicate', 96, '同号另一章。')]) + const fixture = ` + import { createRoot } from 'react-dom/client' + import { ConfirmProvider } from './src/renderer/src/components/ConfirmDialog' + import { TimelineView } from './src/renderer/src/views/TimelineView' + import { normalizeAppData } from './src/shared/normalizers/appData' + let data = ${JSON.stringify(seed)} + const root = createRoot(document.getElementById('root')) + function render() { root.render( { data = change(data); render(); return { ok: true } }} />) } + window.readEvent = () => data.timelineEvents[0] + window.reloadFixture = () => { data = normalizeAppData(JSON.parse(JSON.stringify(data))); render() } + render()` + await build({ stdin: { resolveDir: repoRoot, contents: fixture, loader: 'tsx' }, outfile: join(work, 'view.js'), + platform: 'browser', bundle: true, jsx: 'automatic', loader: { '.png': 'dataurl' }, define: { 'process.env.NODE_ENV': '"production"' } }) + await writeFile(join(work, 'index.html'), '
') + async function operate() { + const until = async predicate => { + for (let i = 0; i < 200; i++) { if (predicate()) return; await new Promise(r => setTimeout(r, 15)) } + throw Error('Timeout: ' + predicate) + } + const field = name => [...document.querySelectorAll('label.field')].find(el => el.querySelector('span')?.textContent === name)?.querySelector('input,select,textarea') + async function set(name, value) { + const el = field(name); if (!el) throw Error('Missing field: ' + name) + el.focus(); Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set.call(el, value) + el.dispatchEvent(new Event(el.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true })); el.blur() + } + await until(() => document.querySelector('button.primary-button')) + document.querySelector('button.primary-button').click() + await until(() => field('预留章号')) + await set('预留章号', '96') + await until(() => window.readEvent().chapterOrder === 96 && window.readEvent().chapterId === null) + await set('所属章节', 'c96') + await until(() => window.readEvent().chapterId === 'c96') + document.querySelector('.timeline-item details summary').click() + const entries = [ + ['事件标题', '校时', 'title'], ['故事内时间', '傍晚', 'storyTime'], ['时间线', '北线', 'timelineKey'], + ['地点', '北门', 'location'], ['共同计时起点', '封城', 'timeAnchor'], ['距起点已过天数', '0', 'elapsedDays'], + ['季节', 'winter', 'season'], ['截止事项', '启闸', 'deadlineKey'], ['距截止剩余天数', '4', 'remainingDays'], + ['叙事跳时', 'flashback', 'timeShift'], ['本章正文原句', '距离启闸只剩四天。', 'sourceExcerpt'] + ] + for (const [name, value, key] of entries) { + await set(name, value) + await until(() => String(window.readEvent()[key]) === value) + } + window.reloadFixture() + for (const [name, value] of entries) await until(() => field(name).value === value) + await set('距起点已过天数', '') + await until(() => !Object.hasOwn(window.readEvent(), 'elapsedDays')) + await set('所属章节', 'c1') + await until(() => window.readEvent().chapterId === 'c1' && window.readEvent().chapterOrder === 1) + await set('所属章节', '') + await until(() => window.readEvent().chapterId === null && window.readEvent().chapterOrder === null) + return 'PASS desktop timeline' + } + await writeFile(join(work, 'main.cjs'), [ + "const { app, BrowserWindow, session } = require('electron'); app.disableHardwareAcceleration();", + "app.whenReady().then(async () => { session.defaultSession.webRequest.onBeforeRequest({ urls:['http://*/*','https://*/*'] }, (_, done) => done({ cancel:true }));", + "const w = new BrowserWindow({ show:false, webPreferences:{ sandbox:true, backgroundThrottling:false } });", + "await w.loadFile(require('node:path').join(__dirname,'index.html'));", + 'console.log(await w.webContents.executeJavaScript(' + JSON.stringify('(' + operate.toString() + ')()') + '));', + "app.exit(0); }).catch(e => { console.error(e); app.exit(1) });" + ].join('\n')) + const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE + const { stdout } = await promisify(execFile)(createRequire(import.meta.url)('electron'), [ + '--user-data-dir=' + join(work, 'profile'), '--disable-background-networking', join(work, 'main.cjs') + ], { env, windowsHide: true, timeout: 40000 }) + assert.match(stdout, /PASS desktop timeline/); passed++ + console.log('PASS 真实桌面时间线:新建、重号选章、编辑、重新载入和清空字段(临时配置,禁网)') + } finally { await rm(work, { recursive: true, force: true, maxRetries: 4, retryDelay: 100 }) } +} +console.log(`通过 ${passed} 组时间线隔离检查。`) diff --git a/scripts/validate-book-continuity-versions.mjs b/scripts/validate-book-continuity-versions.mjs new file mode 100644 index 0000000..49f63c9 --- /dev/null +++ b/scripts/validate-book-continuity-versions.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' +import { loadDomainModules } from './utils/load-domain-modules.mjs' +import { auditBook, continuitySurfaceFixture, laterQuote } from './fixtures/book-continuity-surfaces.mjs' + +const { normalizeAppData, buildBookContinuityReport, buildRevisionCommitBundle, applyRevisionCommitBundleToAppData, + buildRestoreRevisionCommitBundle, draftContentHash } = await loadDomainModules('shared/defaults', 'services/BookContinuityService', + 'services/RevisionCommitBundleService', 'services/ChapterVersionChainService', 'services/DraftDiagnosticBindingService') +const original = normalizeAppData(continuitySurfaceFixture()) +const chapterId = auditBook + '-5', args = { from: 5, to: 5, kind: 'money', status: 'conflict' } +const report = (data, options = args) => buildBookContinuityReport(data, auditBook, options) +const old = report(original) +assert.equal(old.total, 1) +const ends = old.findings[0].evidence +assert.deepEqual(ends.map(e => e.chapterOrder), [2, 5], 'chapter 5 may need a baseline outside the query range') +assert.equal(ends[0].versionId, null, 'imported chapter without a recorded version') +assert.equal(ends[1].versionId, 'current-v5') +assert.equal(report(original, { ...args, from: 1, to: 4 }).total, 0, 'earlier chapters cannot borrow a later conflict') +const edited = applyRevisionCommitBundleToAppData(original, buildRevisionCommitBundle({ appData: original, + chapterId, projectId: auditBook, afterText: '顾宁合上账本,没有开支。', revisionCommitId: 'edit', newChapterVersionId: 'v-edit', + revisedAt: '2026-10-02T00:00:00Z', revisedBy: 'user' })) +assert.equal(report(edited).total, 0, 'no stale conflict after a real revision') +assert(!JSON.stringify(report(edited, { to: 96 })).includes(laterQuote)) +const before = original.chapters.find(c => c.id === chapterId) +const pasted = { ...edited, chapters: edited.chapters.map(c => c.id === chapterId ? { ...c, body: before.body } : c) } +assert.equal(report(pasted).findings[0].evidence[1].versionId, null, 'manually pasted text cannot claim a superseded version') +const restored = applyRevisionCommitBundleToAppData(edited, buildRestoreRevisionCommitBundle({ appData: edited, projectId: auditBook, + chapterId, sourceVersionId: 'current-v5', revisionCommitId: 'restore', newChapterVersionId: 'v-restored', + restoredAt: '2026-10-03T00:00:00Z' })) +const restoredEnd = report(restored).findings[0].evidence[1] +assert.equal(restoredEnd.versionId, 'v-restored') +assert.equal(restoredEnd.contentHash, draftContentHash(before.body)) +const reorder = { ...restored, chapters: restored.chapters.map(c => c.id === chapterId ? { ...c, order: 97 } : c) } +assert.equal(report(reorder).total, 0) +assert.deepEqual(report(reorder, { ...args, from: 97, to: 97 }).findings[0].evidence.map(e => e.chapterOrder), [2, 97]) +const archived = { ...restored, chapters: restored.chapters.map(c => c.id === chapterId ? { ...c, archivedAt: '2026-10-04' } : c) } +assert.equal(report(archived).total, 0) +console.log('PASS 96-chapter evidence: both ends, cutoff, revision, repasted text, restore commit, reordering and archive.') diff --git a/scripts/validate-book-progress-ui.mjs b/scripts/validate-book-progress-ui.mjs new file mode 100644 index 0000000..5e2a6d1 --- /dev/null +++ b/scripts/validate-book-progress-ui.mjs @@ -0,0 +1,234 @@ +// Actual renderer, isolated browser profile, synthetic books and an in-memory desktop bridge. +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +const require = createRequire(import.meta.url) +const at = '2026-11-01T00:00:00.000Z' +const later = '2026-11-02T00:00:00.000Z' + +function fixture(normalize) { + const projectId = 'progress-fixture' + const chapter = (order, title, body, patch) => ({ id: 'c' + order, projectId, order, title, body, createdAt: at, updatedAt: at, ...patch }) + const job = (order) => ({ id: 'j' + order, projectId, targetChapterOrder: order, status: 'paused', + currentStep: 'await_user_confirmation', updatedAt: at, createdAt: at }) + const draft = (order) => ({ id: 'd' + order, projectId, jobId: 'j' + order, chapterId: null, title: '第' + order + '章的候选', + body: '保留的第' + order + '章草稿。', status: 'draft', tokenEstimate: 13, createdAt: at, updatedAt: at }) + const failure = (order, kind, refs) => ({ id: 'f' + order, projectId, chapterOrder: order, chapterId: 'c' + order, + kind, attemptedAt: later, commitId: 'failed-commit-' + order, versionId: 'failed-version-' + order, reason: '合成写回错误。', ...refs }) + const first = chapter(1, '第一份收据', '甲 \n𠀀。', { updatedAt: later }) + const version = { id: 'accepted-v1', projectId, chapterId: first.id, title: first.title, body: first.body, + source: 'accept_draft', createdAt: at, note: '', linkedChapterCommitId: 'accepted' } + const candidate = (id, body, date) => ({ id, sessionId: 'session-5', requestId: 'request-5', title: '修订五', + body, changedSummary: body, risks: '', preservedFacts: '', status: 'pending', createdAt: date, updatedAt: date }) + return normalize({ + projects: [{ id: projectId, name: '合成进度样书', createdAt: at, updatedAt: at }, + { id: 'foreign', name: '另一部样书', bookWordTarget: { min: 999 }, createdAt: at, updatedAt: at }], + chapters: [first, chapter(2, '空页', ' \n'), chapter(5, '旧章留存', '旧 稿。'), chapter(6, '归档页', '不入账。', { archivedAt: at }), + chapter(7, '无回执章', '旧。'), chapter(28, '末页', '末。'), chapter(31, '别书', '不入账。', { projectId: 'foreign' })], + chapterGenerationJobs: [job(4), job(8), job(9)], generatedChapterDrafts: [draft(4), draft(8), draft(9), + { ...draft(9), id: 'new-d9', body: '同一任务的另一稿。', updatedAt: later }], + chapterVersions: [version], + chapterCommitBundles: [{ id: 'accepted', commitId: 'accepted', projectId, chapterId: first.id, + acceptedAt: at, chapter: first, chapterVersion: version }], + chapterWritebackFailures: [failure(5, 'revision_commit', { revisionSessionId: 'session-5', revisionVersionId: 'tried' }), + failure(8, 'chapter_commit', { jobId: 'j8', draftId: 'd8' }), + failure(9, 'chapter_commit', { jobId: 'j9', draftId: 'd9' }), + failure(10, 'chapter_commit', { jobId: 'j10', draftId: 'gone' })], + revisionSessions: [{ id: 'session-5', projectId, chapterId: 'c5', sourceDraftId: null, status: 'active', createdAt: at, updatedAt: at }], + revisionRequests: [{ id: 'request-5', sessionId: 'session-5', type: 'custom', targetRange: '', instruction: '校对旧章', createdAt: at }], + revisionVersions: [candidate('tried', '这是写回失败的修订。', at), candidate('newer', '这是另一份候选。', later)] + }) +} + +async function verifyStores(api, work) { + for (const input of [null, {}, [], '120', { min: 4, max: 1 }, { min: -1 }, { max: 0 }, + { min: '9' }, { min: 1.5 }, { max: Number.MAX_SAFE_INTEGER + 1 }]) { + assert.equal(api.normalizeBookWordTarget(input), undefined) + assert.equal(Object.hasOwn(api.normalizeAppData({ projects: [{ id: 'old', bookWordTarget: input }] }).projects[0], 'bookWordTarget'), false) + } + assert.deepEqual(api.normalizeBookWordTarget({ min: null, max: 12 }), { max: 12 }) + for (const [name, Store] of [['json', api.JsonStorageService], ['sqlite', api.SqliteStorageService]]) { + const path = join(work, 'targets.' + name) + const projects = [undefined, { min: 3 }, { max: 4 }, { min: 15, max: 20 }].map((goal, i) => ({ + id: 'p' + i, name: '样书' + i, createdAt: at, updatedAt: at, ...(goal && { bookWordTarget: goal }) + })) + const seed = api.normalizeAppData({ projects }) + const store = new Store(path) + try { await store.save(seed) } finally { store.close?.() } + const reopened = new Store(path) + try { + const byId = (await reopened.load()).projects + for (const p of seed.projects) assert.deepEqual(byId.find(v => v.id === p.id), p) + assert.ok(!Object.hasOwn(byId.find(v => v.id === 'p0'), 'bookWordTarget')) + } finally { reopened.close?.() } + console.log('PASS ' + name + ' target round trip, including legacy absence and one-sided goals') + } +} + +function installBridge(seed) { + window.progressQA = { data: seed, writes: 0, fail: false, readers: 0, aiCalls: 0 } + const qa = window.progressQA + window.novelDirector = { + data: { load: async () => ({ data: qa.data, storagePath: 'synthetic-progress' }), + save: async (data) => { + qa.writes++ + if (qa.fail) throw new Error('合成保存失败') + qa.data = structuredClone(data) + return { storagePath: 'synthetic-progress' } + } }, + ai: { chatCompletion: () => { qa.aiCalls++; throw new Error('本测试禁止调用模型。') } } + } +} + +async function verifyUI() { + const qa = window.progressQA + const $ = (selector, root = document) => root.querySelector(selector) + const all = (s, root = document) => [...root.querySelectorAll(s)] + const btn = (name, root = document) => all('button', root).find(el => (el.getAttribute('aria-label') || el.textContent.trim()) === name) + const check = (condition, message) => { if (!condition) throw new Error(message) } + async function until(fn, message) { + for (let i = 0; i < 150; i++) { if (fn()) return; await new Promise(resolve => setTimeout(resolve, 25)) } + throw new Error(message) + } + async function click(name, root) { + const el = btn(name, root) + check(el && !el.disabled, '不能操作按钮:' + name); el.click() + await new Promise(resolve => setTimeout(resolve, 0)) + } + const input = async (label, value) => { + const el = all('label').find(l => l.textContent.trim() === label)?.querySelector('input') + check(el, '找不到输入框:' + label) + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(el, value) + el.dispatchEvent(new Event('input', { bubbles: true })) + await new Promise(resolve => setTimeout(resolve, 0)) + } + const goal = () => $('.book-progress-goal').textContent + const target = () => qa.data.projects.find(p => p.id === 'progress-fixture') + const row = n => all('.book-progress-table tbody tr').find(tr => $('th span', tr).textContent === '第 ' + n + ' 章') + async function openList() { await click('全书进度与缺章'); await until(() => row(1), '未显示章节清单') } + async function back() { await click('工作台', $('.nav-list')); await until(() => $('.book-progress'), '未回到工作台'); await openList() } + + await until(() => $('.project-row'), '未加载隔离项目') + await click('进入', all('.project-row').find(el => el.textContent.includes('合成进度样书'))) + await until(() => $('.book-progress'), '未加载进度入口') + check(!$('.book-progress-list') && qa.readers === 0, '初始不应构建全书清单') + check(goal().includes('未设置全书目标') && !goal().includes('差'), '未设目标不能显示不足') + check($('.director-stats dd').textContent === '10字' && $('.director-chapter-list button').textContent.includes('3 字'), '首页累计或最近章节字数有误') + const initialWrites = qa.writes + await click('编辑全书目标'); await input('目标下限(字)', '20'); await input('目标上限(字)', '12'); await click('保存目标') + await until(() => $('.book-target-editor [role=alert]'), '倒置范围未报错') + check(qa.writes === initialWrites, '无效目标不应写库') + await input('目标上限(字)', ''); qa.fail = true; await click('保存目标') + await until(() => $('.book-target-editor [role=alert]')?.textContent.includes('合成保存失败'), '未保留失败后的编辑表单') + check(goal().includes('未设置') && !Object.hasOwn(target(), 'bookWordTarget'), '保存失败时不能改显示或数据') + qa.fail = false; await click('保存目标') + await until(() => goal().includes('距下限还差 10 字'), '下限距离不对') + check(target().bookWordTarget.min === 20 && !Object.hasOwn(target().bookWordTarget, 'max'), '只设下限被改变') + await click('编辑全书目标'); await input('目标下限(字)', '6'); await input('目标上限(字)', '18'); await click('保存目标') + await until(() => goal().includes('已达下限') && goal().includes('6 至 18'), '区间内进度不对') + await click('编辑全书目标'); await input('目标下限(字)', ''); await input('目标上限(字)', '8'); await click('保存目标') + await until(() => goal().includes('超出上限 2 字'), '单设上限时的超额不对') + check(qa.readers === 0, '仅编辑目标不应构建章节清单') + + const beforeList = qa.writes + await openList() + check(qa.readers === 1 && all('.book-progress-table tbody tr').length === 25, '清单首屏未分页') + check($('.book-progress-summary').textContent.includes('从第 1 章连续写至第 1 章') && + $('.book-progress-summary').textContent.includes('可分配的新章号第 29 章'), '章号分配误作完整进度') + for (const [n, status] of [[1,'已正式写回'], [2,'空章'], [3,'缺章'], [4,'仅有草稿'], [5,'写回失败'], [6,'已归档'], [7,'历史正文'], [8,'写回失败']]) { + check($('.status-badge', row(n))?.textContent === status, '第 ' + n + ' 章状态有误') + } + check(row(5).textContent.includes('书中保留:历史正文') && $('.book-progress-count', row(5)).textContent === '3', '旧正文与失败修订未区分') + $('summary', row(5)).click() + const details = $('details', row(5)) + check(details.open && details.innerText.includes('未发现本次提交回执或目标版本') && !details.innerText.includes('合成写回错误'), + '应显示安全的写回原因,不显示原始错误') + await click('下一页'); await until(() => row(28), '没有翻到末页'); check(!row(1), '分页重复') + await click('上一页'); await until(() => row(1), '没有回到首页') + await input('起始章', '7'); await input('结束章', '4'); await click('查看范围') + await until(() => $('.book-progress-filters [role=alert]'), '无效章段未报错') + await input('起始章', '4'); await input('结束章', '7'); await click('查看范围') + await until(() => all('.book-progress-table tbody tr').length === 4, '章段未生效') + $('.book-progress-check input').click() + await until(() => all('.book-progress-table tbody tr').length === 2 && row(4) && row(5), '仅看问题分类不对') + check($('.book-progress-results').textContent.includes('范围正文 5 字') && goal().includes('超出上限 2 字'), '筛选改变全书目标或范围字数') + await click('重置筛选'); await until(() => row(1), '未恢复全书') + check(qa.readers === 1 && qa.writes === beforeList, '翻页或筛选不应重建全书清单或写库') + + await click('查看正文', row(5)) + await until(() => { + const title = $('.chapter-editor-main input[aria-label="章节标题"]') + return title?.readOnly && title.value === '旧章留存' && $('.chapter-editor-main .manuscript-textarea')?.value === '旧 稿。' + }, '未定位到第 5 章的只读正式标题与当前正文') + await back(); await click('回到草稿', row(8)) + await until(() => $('.pipeline-draft-body')?.value === '保留的第8章草稿。', '未定位到失败草稿,而是打开了其他任务') + await back(); await click('回到草稿', row(9)) + await until(() => $('.revision-textarea.original')?.value === '保留的第9章草稿。', '同任务的更新稿遮住了指定草稿') + await back(); await click('回到草稿', row(10)) + await until(() => row(10) && $('[role=alert]', row(10))?.textContent.includes('找不到'), '缺失稿件不应打开无关任务') + await click('回到修订稿', row(5)) + await until(() => $('.revision-version-meta')?.textContent.includes('这是写回失败的修订。'), '没有选中失败修订版本') + check(!$('.revision-version-meta').textContent.includes('这是另一份候选。'), '恢复定位到了较新的无关候选') + await back(); check(qa.writes === beforeList && qa.aiCalls === 0, '打开稿件不应提交或调用模型') + await click('编辑全书目标'); await click('清空目标'); await click('取消') + check(goal().includes('超出上限 2 字') && qa.writes === beforeList, '取消编辑不应清除目标') + await click('编辑全书目标'); await click('清空目标'); await click('保存目标') + await until(() => !Object.hasOwn(target(), 'bookWordTarget') && goal().includes('未设置'), '清空时没有移除项目字段') + check(qa.data.projects.find(p => p.id === 'foreign').bookWordTarget.min === 999, '修改了另一部书') + return 'PASS renderer: goal editing, failure handling, pagination, filters, chapter / draft / revision links' +} + +function checkLayout() { + const section = document.querySelector('.book-progress') + const bounds = section.getBoundingClientRect() + return section.scrollWidth <= section.clientWidth + 1 && bounds.left >= 0 && bounds.right <= innerWidth + 1 && + [...section.querySelectorAll('button, input, table')].every(el => el.getBoundingClientRect().right <= bounds.right + 1) +} + +const tmp = join(repoRoot, 'tmp') +await mkdir(tmp, { recursive: true }) +const work = await mkdtemp(join(tmp, 'book-progress-ui-')) +try { + await build({ stdin: { resolveDir: repoRoot, contents: [ + "export { normalizeAppData } from './src/shared/defaults'", + "export { normalizeBookWordTarget } from './src/shared/normalizers/bookWordTarget'", + "export { JsonStorageService } from './src/storage/JsonStorageService'", + "export { SqliteStorageService } from './src/storage/SqliteStorageService'" + ].join('\n') }, bundle: true, platform: 'node', format: 'esm', external: ['better-sqlite3', 'electron'], outfile: join(work, 'domain.mjs') }) + const api = await import(pathToFileURL(join(work, 'domain.mjs')).href) + await verifyStores(api, work) + await build({ entryPoints: [join(repoRoot, 'src/renderer/src/main.tsx')], outfile: join(work, 'ui.js'), bundle: true, + format: 'iife', jsx: 'automatic', loader: { '.png': 'dataurl' }, define: { 'process.env.NODE_ENV': '"production"' }, + plugins: [{ name: 'count-real-readers', setup(builder) { + builder.onResolve({ filter: /BookProgressService$/ }, args => args.importer.endsWith('BookProgressList.tsx') + ? { path: 'instrumented', namespace: 'book-reader' } : undefined) + builder.onLoad({ filter: /.*/, namespace: 'book-reader' }, () => ({ resolveDir: repoRoot, contents: + "import {createBookProgressReader as real} from './src/services/BookProgressService'; export const createBookProgressReader = (...args) => { window.progressQA.readers++; return real(...args) }" })) + } }] }) + await writeFile(join(work, 'fixture.js'), '(' + installBridge.toString() + ')(' + JSON.stringify(fixture(api.normalizeAppData)) + ')') + await writeFile(join(work, 'index.html'), '
') + await writeFile(join(work, 'verify.cjs'), [ + "const { app, BrowserWindow, session } = require('electron'); app.disableHardwareAcceleration();", + "app.whenReady().then(async () => { session.defaultSession.webRequest.onBeforeRequest({urls:['http://*/*','https://*/*']}, (_, done) => done({cancel:true}));", + "const w = new BrowserWindow({show:false, width:1100, height:850, webPreferences:{sandbox:true, backgroundThrottling:false}});", + "await w.loadFile(require('node:path').join(__dirname,'index.html'));", + 'console.log(await w.webContents.executeJavaScript(' + JSON.stringify('(' + verifyUI.toString() + ')()') + '));', + "for (const width of [1100, 790, 390]) { w.setContentSize(width, 850); await new Promise(r => setTimeout(r, 100));", + 'if (!await w.webContents.executeJavaScript(' + JSON.stringify('(' + checkLayout.toString() + ')()') + ")) throw new Error('清单横向溢出:' + width) }", + "console.log('PASS renderer layout: 1100, 790, 390 px');", + "app.exit(0); }).catch(error => {console.error(error); app.exit(1)})" + ].join('\n')) + const env = { ...process.env } + delete env.ELECTRON_RUN_AS_NODE + const { stdout } = await promisify(execFile)(require('electron'), [join(work, 'verify.cjs'), + '--user-data-dir=' + join(work, 'browser'), '--disable-background-networking'], { env, windowsHide: true, timeout: 55000 }) + assert.match(stdout, /PASS renderer/) + console.log(stdout.trim()) +} finally { await rm(work, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) } diff --git a/scripts/validate-book-progress.mjs b/scripts/validate-book-progress.mjs new file mode 100644 index 0000000..497a581 --- /dev/null +++ b/scripts/validate-book-progress.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, rm, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' +import { bookProgressFixture, fixtureTime as t, progressBookId as p, wideText } from './fixtures/book-progress.mjs' + +const base = join(repoRoot, 'tmp') +await mkdir(base, { recursive: true }) +const tmp = await mkdtemp(join(base, 'book-progress-')) +try { + const file = join(tmp, 'api.mjs') + const modules = ['shared/defaults', 'services/BookProgressService', 'services/BookWritebackService', 'services/ChapterCommitBundleService', + 'services/RevisionCommitBundleService', 'services/ChapterVersionChainService', 'services/ChapterLifecycleService', + 'services/DirectorNextActionService', 'services/chapterTaskContractText', 'agent/tools/AgentToolService', + 'agent/AgentReadableSummaryService', 'storage/JsonStorageService', 'storage/SqliteStorageService'] + await build({ stdin: { contents: modules.map(path => `export * from './src/${path}'`).join('\n'), resolveDir: repoRoot }, + bundle: true, platform: 'node', format: 'esm', target: 'node22', outfile: file, external: ['electron', 'better-sqlite3'] }) + const api = await import(pathToFileURL(file).href) + const data = api.normalizeAppData(bookProgressFixture()) + const get = (d, opt) => api.buildBookProgress(d, p, opt) + const proof = d => api.createBookWritebackIndex(d, p).verifyJob('j92') + const row = (d, n) => get(d, { fromChapter: n, toChapter: n }).rows[0] + const overview = get(data, { limit: 200 }) + assert.equal(overview.totalWords, 92 * 3) + assert.equal(overview.chapterCount, 92) + assert.equal(overview.goal.remainingToMin, 15000) + assert.equal(overview.completeThrough, 65) + assert.equal(overview.nextAssignableOrder, 97) + const target = api.AgentReadableSummaryService.getNextChapterTarget(data, p) + assert.equal(target.nextChapterOrder, target.nextAssignableOrder) + assert.equal(target.progressTool, 'agent.getBookProgress') + assert.deepEqual(overview.missingRanges, [66, 74, 80].map(n => ({ from: n, to: n }))) + assert.deepEqual(overview.completeRanges, [[1, 65], [67, 73], [75, 79], [81, 87], [89, 96]].map(([from, to]) => ({ from, to }))) + assert.deepEqual(get(data, { issuesOnly: true }).rows.map(r => [r.order, r.status]), + [[66, 'missing'], [74, 'draft_only'], [80, 'empty'], [92, 'writeback_failed']]) + assert.deepEqual([row(data, 92).manuscriptStatus, row(data, 92).wordCount], ['historical', 3]) + assert.equal(row(data, 88).status, 'archived') + assert.equal(row(data, 74).draftId, 'd74') + assert.equal(row(data, 66).draftId, null) + const orphan = { ...data, generatedChapterDrafts: data.generatedChapterDrafts.map(d => + d.id === 'd66' ? { ...d, chapterId: 'unavailable-import', status: 'accepted' } : d) } + assert.equal(row(orphan, 66).status, 'draft_only') + assert.equal(get(orphan).totalWords, overview.totalWords) + assert(!api.createBookWritebackIndex(orphan, p).verifyJob('j66').confirmed) + assert.equal(get(data, { fromChapter: 90, toChapter: 96 }).range.totalWords, 21) + assert.equal(api.visibleCharacterCount(wideText), 3) + assert.equal(api.AgentReadableSummaryService.getProjectDigest(data, p).manuscript.totalWords, overview.totalWords) + assert.equal(api.AgentReadableSummaryService.getChapterProductionState(data, p, 92).chapter.wordCount, 3) + assert.equal(api.AgentReadableSummaryService.getChapterText(data, p, 92, {}).wordCount, 3) + assert.equal(api.buildBookProgress(data, 'other-book').goal.remainingToMin, null) + assert.equal(get({ ...data, chapters: [...data.chapters, { ...data.chapters[0], id: 'duplicate-order' }] }).totalWords, overview.totalWords) + assert.equal(get(data, { fromChapter: 1, toChapter: 1000000000, offset: 999999999, limit: 5 }).rows[0].order, 1000000000) + assert.throws(() => get(data, { fromChapter: 7, toChapter: 4 })) + assert(!proof(data).confirmed) + // Use actual commit and restore builders; neither the draft nor any previous version is additive. + const clean = { ...data, chapterWritebackFailures: [] } + const accepted = api.buildAcceptedDraftCommitBundle({ appData: clean, projectId: p, chapterId: 'c92', draftId: 'd92', + targetChapterOrder: 92, commitId: 'commit92', chapterVersionId: 'first92', acceptedAt: t, acceptanceMode: 'unreviewed' }) + const filed = api.applyChapterCommitBundleToAppData(clean, accepted) + assert.equal(row(filed, 92).status, 'committed') + assert(proof(filed).confirmed) + assert(!proof({ ...filed, chapterVersions: filed.chapterVersions.filter(v => v.id !== 'first92') }).confirmed) + assert(!proof({ ...filed, chapterCommitBundles: filed.chapterCommitBundles.map(c => ({ ...c, jobId: 'unrelated' })) }).confirmed) + const newAttempt = { ...filed.generatedChapterDrafts.find(d => d.id === 'd92'), id: 'd92-second-attempt', status: 'draft', + createdAt: '2026-10-02T00:00:00.000Z' } + assert(!proof({ ...filed, generatedChapterDrafts: [...filed.generatedChapterDrafts, newAttempt] }).confirmed) + const revision = api.buildRevisionCommitBundle({ appData: filed, projectId: p, chapterId: 'c92', revisionCommitId: 'rev92', + newChapterVersionId: 'second92', revisedAt: '2026-10-03T00:00:00.000Z', afterText: '长一段。' }) + const revised = api.applyRevisionCommitBundleToAppData(filed, revision) + assert.equal(get(revised).totalWords, 277) + assert.equal(proof(revised).versionId, 'second92') + const archived = api.archiveChapterInAppData(revised, 'c92', '2026-10-04T00:00:00Z') + assert.equal(get(archived).totalWords, 273) + assert(!proof(archived).confirmed) + const restore = api.buildRestoreRevisionCommitBundle({ appData: revised, projectId: p, chapterId: 'c92', sourceVersionId: 'first92', + newChapterVersionId: 'third92', revisionCommitId: 'restore92', restoredAt: '2026-10-05T00:00:00.000Z' }) + const restored = api.applyRevisionCommitBundleToAppData(revised, restore) + assert.equal(get(restored).totalWords, 276) + assert.equal(proof(restored).versionId, 'third92') + assert(!proof({ ...restored, chapterVersions: restored.chapterVersions.filter(v => v.id !== 'third92') }).confirmed, + 'a newer restore missing its version cannot borrow an older receipt for the same text') + assert(!proof({ ...restored, chapters: restored.chapters.map(c => c.id === 'c92' ? { ...c, body: '未形成版本的修改。' } : c) }).confirmed) + const broken = { ...restored, chapterVersions: restored.chapterVersions.map(v => v.id === 'third92' ? { ...v, baseChapterVersionId: v.id } : v) } + assert(!proof(broken).confirmed) + for (const [kind, Storage] of [['json', api.JsonStorageService], ['sqlite', api.SqliteStorageService]]) { + const space = join(tmp, kind) + await mkdir(space) + const storagePath = join(space, 'novel-director-data.' + kind) + const store = new Storage(storagePath) + try { + await store.save(data) + const before = await store.loadSnapshot() + const args = { storagePath, userDataPath: space, project: '全书进度样书', issuesOnly: true, offset: 1, limit: 3 } + const result = await api.AgentToolService.callTool({ name: 'agent.getBookProgress', arguments: args }) + assert.equal(result.storage.source, kind) + assert.deepEqual(result.data, get(before.data, args)) + assert.deepEqual(await store.loadSnapshot(), before, 'a progress query must not modify a book') + assert(!JSON.stringify(result.data).includes('只存在草稿') && !JSON.stringify(result.data).includes('不能串书')) + assert(!result.data.rows.some(r => 'body' in r || 'chapterVersion' in r || 'chapter' in r)) + if (kind === 'json') assert.deepEqual(get(api.normalizeAppData(JSON.parse(await readFile(storagePath, 'utf8')))), get(data)) + await assert.rejects(api.AgentToolService.callTool({ name: 'agent.getBookProgress', arguments: { ...args, limit: -1 } })) + console.log(`PASS ${kind}: 96-chapter book, read-only MCP identical to desktop, chapter range, pagination and goal`) + } finally { store.close?.() } + } + console.log('PASS manuscript count, failure over old body, writeback lineage, archive, revisions and restore') +} finally { await rm(tmp, { recursive: true, force: true, maxRetries: 3 }) } diff --git a/scripts/validate-card-balance-prompt.mjs b/scripts/validate-card-balance-prompt.mjs new file mode 100644 index 0000000..1eebad5 --- /dev/null +++ b/scripts/validate-card-balance-prompt.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import { cardBalanceFixture } from './fixtures/card-balance.mjs' +import { loadDomainModules } from './utils/load-domain-modules.mjs' + +const { normalizeAppData, defaultModulesForMode, createEmptyChapterTask, CharacterStateService, PromptBuilderService, + ContextNeedPlannerService, selectBudgetContext, createContextBudgetProfile, buildPipelineContextResultFromSelection, + isolateOpeningCharacterCards, stringifyCharacterForPlan, characterFieldValue, writingCardAtChapter } = await loadDomainModules( + 'shared/defaults', 'services/CharacterStateService', 'services/PromptBuilderService', 'services/ContextNeedPlannerService', + 'renderer/src/utils/promptContext', 'services/OpeningChapterContextPolicy', 'services/contextBudget/scoringEngine', + 'services/characterState/writingCard') +const { data, pending, p, c, log } = cardBalanceFixture(normalizeAppData, CharacterStateService) +const project = data.projects[0], person = data.characters[0] +const task = { ...createEmptyChapterTask(), goal: '小葵照账付钱' }, budget = createContextBudgetProfile(p, 'standard', 7000) +const makePlan = (order, d = data) => { + const plan = ContextNeedPlannerService.buildFromChapterIntent({ project, storyBible: null, targetChapterOrder: order, chapterTaskDraft: task, + previousChapter: null, continuityBridge: null, characters: d.characters, characterStateFacts: d.characterStateFacts, + foreshadowing: [], timelineEvents: [], stageSummaries: [] }) + return { ...plan, requiredCharacterCardFields: { [c]: ['roleFunction', 'abilitiesAndResources', 'relationshipTension'] } } +} +const raw = (order, d = data, plan = null) => PromptBuilderService.buildResult({ project, chapters: d.chapters, characters: d.characters, + characterStateLogs: d.characterStateLogs, characterStateFacts: d.characterStateFacts, characterStateTransactions: d.characterStateTransactions, + stateSourceChapters: d.chapters, contextNeedPlan: plan, timelineEvents: [], stageSummaries: [], foreshadowings: [], bible: null, + config: { projectId: p, targetChapterOrder: order, mode: 'standard', modules: defaultModulesForMode('standard'), task, + useContinuityBridge: false, selectedCharacterIds: [c], selectedForeshadowingIds: [] } }) +const currentSection = text => text.match(/【角色状态账本切片】([\s\S]*?)(?=\n## |$)/u)?.[1] || '' +const beforeSnapshot = JSON.stringify(data) +for (const plan of [false, true]) { + const at = (order, d = data) => currentSection(raw(order, d, plan ? makePlan(order, d) : null).finalPrompt) + assert.match(at(11), /随身现金:10 文/) + assert.doesNotMatch(at(11), /30 文|三十文/) + assert.match(at(13, pending), /随身现金:10 文/, 'unaccepted income is not canon') + assert.match(at(13), /随身现金:30 文/) + assert.doesNotMatch(at(13), /钱只剩十文|过期角色定位|旧关系/) + assert.match(at(13), /擅长辨银/) + assert.match(at(13), /替船队保管五十文/) +} +const plan = makePlan(13) +const selection = selectBudgetContext(project, data, 13, budget, { characterIds: [c], contextNeedPlan: plan, chapterTask: task }) +assert.deepEqual(selection.selectedCharacterIds, [c]) +const production = buildPipelineContextResultFromSelection(project, data, 13, '', '', budget, selection, plan, null, task) +assert.match(currentSection(production.finalPrompt), /随身现金:30 文/) +assert.doesNotMatch(currentSection(production.finalPrompt), /钱只剩十文/) +assert.equal(characterFieldValue(person, 'roleFunction'), '船上账房') +assert.match(stringifyCharacterForPlan(person, plan), /船上账房/) +assert.doesNotMatch(stringifyCharacterForPlan(person, plan), /过期角色定位|知道过桥暗号|旧关系/) +assert.equal(characterFieldValue({ ...person, abilitiesAndResources: ' ' }, 'abilitiesAndResources'), '知道过桥暗号。') +assert.equal(isolateOpeningCharacterCards([person])[0].abilitiesAndResources, '') +const cash = data.characterStateFacts[0] +const evidence = '小葵替船队保管五十文公款。' +const crew = { ...data.characterStateChangeCandidates[0], id: 'crew-update', targetFactId: null, + chapterId: 'c13', chapterOrder: 13, candidateType: 'create_fact', status: 'pending', + proposedFact: { ...cash, id: 'crew-fund', label: '船队公款', key: 'cash', value: 50 }, + proposedTransaction: null, beforeValue: null, afterValue: 50, evidence } +const bothAccounts = CharacterStateService.applyStateChangeCandidate(crew.id, { ...data, + chapters: [...data.chapters, { ...data.chapters[1], id: 'c13', order: 13, body: evidence }], + characterStateChangeCandidates: [crew, ...data.characterStateChangeCandidates] }) +assert.equal(bothAccounts.characterStateFacts.find(f => f.id === 'cash').value, 30) +assert.equal(bothAccounts.characterStateFacts.find(f => f.id === 'crew-fund').value, 50) +const twoPurses = currentSection(raw(14, bothAccounts).finalPrompt) +assert.match(twoPurses, /随身现金:30 文/) +assert.match(twoPurses, /船队公款:50 文/) +assert.doesNotMatch(twoPurses, /钱只剩十文/) +const cardWithBalance = { ...person, abilitiesAndResources: '钱只剩十文;擅长辨银' } +const renderedCash = (overrides) => writingCardAtChapter(cardWithBalance, [{ ...cash, ...overrides }], 13) +assert.equal(renderedCash({ key: '资源/余额', label: '资源/余额', value: '钱袋里还剩三十文。', unit: '' }).card.abilitiesAndResources, '擅长辨银') +for (const overrides of [ + { key: 'cash', label: '船队盘缠' }, { key: 'cash_drawer', label: '现金余额' }, + { key: 'cash', label: '现金', value: '支付二十文买药' }, { key: 'cash', unit: '美元' } +]) assert.equal(renderedCash(overrides).card.abilitiesAndResources, cardWithBalance.abilitiesAndResources) +assert(renderedCash({ key: 'cash', value: '支付二十文买药' }).warnings.size > 0) +for (const sentence of ['队里只剩十文', '她欠店里十文', '盘缠只剩十两银子', '十文是过桥费', '掌柜的钱只剩十文', '以前钱只剩十文', + '她说:“钱只剩十文。”', '她说:“还记得吗?钱只剩十文。那是三年前。”', '队长清点公账,钱只剩十文', '回忆当初,钱只剩十文']) { + assert.equal(writingCardAtChapter({ ...person, abilitiesAndResources: sentence }, [cash], 13).card.abilitiesAndResources, sentence) +} +const unknown = raw(13, { ...data, characterStateFacts: [{ ...cash, unit: '' }], characterStateTransactions: [] }) +assert(unknown.warnings.some(w => w.includes('单位')), 'ambiguous balances are reported outside the writing prompt') +assert.match(currentSection(unknown.finalPrompt), /钱只剩十文/) +assert.doesNotMatch(unknown.finalPrompt, /账户或单位不能唯一对应/) +const undated = { ...data, characterStateTransactions: [], characters: [{ ...person, abilitiesAndResources: '钱只剩三十文;擅长辨银', lastChangedChapter: null }] } +assert.doesNotMatch(currentSection(raw(11, undated).finalPrompt), /三十文|30 文/) +assert.match(currentSection(raw(11, undated).finalPrompt), /擅长辨银/) +const noAccount = { ...undated, characterStateFacts: [], characterStateLogs: [{ ...log, chapterId: null, chapterOrder: null, note: '未注明何时开仓' }] } +assert.doesNotMatch(currentSection(raw(12, noAccount).finalPrompt), /三十文|未注明何时开仓/) +assert.match(currentSection(raw(13, noAccount).finalPrompt), /三十文/) +const discretionary = { ...data, characterStateTransactions: [], characterStateFacts: [{ ...cash, trackingLevel: 'soft', promptPolicy: 'when_relevant' }] } +const unrelated = { ...makePlan(13), mustCheckContinuity: [], requiredStateFactCategories: { [c]: ['physical'] } } +assert.doesNotMatch(currentSection(raw(13, discretionary, unrelated).finalPrompt), /钱只剩十文|随身现金:30 文/) +const foreignLog = { ...log, id: 'later', chapterId: 'c12', chapterOrder: 5, note: '迟来的定局' } +assert.doesNotMatch(raw(11, { ...data, characterStateLogs: [foreignLog] }).finalPrompt, /迟来的定局/) +assert.equal(JSON.stringify(data), beforeSnapshot, 'reading the card and building context never rewrites the manuscript or card') +console.log('PASS historical and current cash in real writing contexts, dedicated card fields, separate accounts, unchanged source cards') diff --git a/scripts/validate-chapter-task-contract.mjs b/scripts/validate-chapter-task-contract.mjs index 10e55a2..17403bc 100644 --- a/scripts/validate-chapter-task-contract.mjs +++ b/scripts/validate-chapter-task-contract.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { mkdir, readFile, rm } from 'node:fs/promises' +import { mkdir, rm } from 'node:fs/promises' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { build } from 'esbuild' @@ -81,7 +81,7 @@ const { ChapterTaskContractService } = await bundle('src/services/ChapterTaskCon const { QualityGateService } = await bundle('src/services/QualityGateService.ts', 'quality-gate-service.mjs') const { shouldQualityGateRequireHumanReview } = await bundle('src/shared/qualityGatePolicy.ts', 'quality-policy.mjs') const { buildAuthoritativeOpeningDraftPrompt } = await bundle('src/services/ai/GenerationPipelineAI.ts', 'generation-prompt.mjs') -const { validateAuthoritativeChapterTaskDraft } = await bundle('src/renderer/src/views/generation/pipelineUtils.ts', 'pipeline-utils.mjs') +const { validateAuthoritativeChapterTaskDraft, validateGeneratedChapterDraft } = await bundle('src/renderer/src/views/generation/pipelineUtils.ts', 'pipeline-utils.mjs') const shortBody = `我${'做饭'.repeat(1144)}呀。` assert([...shortBody.replace(/\s/gu, '')].length === 2291, 'Short-body regression fixture must remain exactly 2291 visible characters.') @@ -529,7 +529,34 @@ for (const dimension of ['promptCompliance', 'pacing', 'styleMatch']) { assert(shouldQualityGateRequireHumanReview(report), `${dimension}=65 的存量报告必须进入人工复核。`) } -const chapterGeneration = await readFile(join(root, 'src/renderer/src/views/generation/pipelineSteps/chapterGeneration.ts'), 'utf8') -assert((chapterGeneration.match(/validateAuthoritativeChapterTaskDraft/g) ?? []).length === 3, '权威任务合约必须在首次草稿和择优后的最终草稿上都校验。') +const overflowTask = task({ targetWordCount: '1200-1300' }) +const overflowBody = '钟楼落雨,灯未熄。'.repeat(220) + '灯还亮着。' +const retainOverflow = { allowReviewableLengthOverflow: true } +assert(ChapterTaskContractService.lengthOverflowNotice({ body: overflowBody, chapterTask: overflowTask }) === + '目标 1200–1300 字,实际 1985 字,超出上限 685 字。', '作者应能看到真实目标、字数和差值。') +assert(ChapterTaskContractService.lengthOverflowNotice({ body: overflowBody, chapterTask: task({ targetWordCount: '1200' }) }) === null, + '单一目标不能被推断成作者的硬上限。') +assert(validateAuthoritativeChapterTaskDraft(overflowBody, '', '1200-1300', true, overflowTask, retainOverflow) === null, + '仅有明确超限不应使可读正文丢失。') +assert(validateAuthoritativeChapterTaskDraft(overflowBody, '', '1200-1300', true, overflowTask) !== null, + '保留为候选不等于通过正式任务契约。') +assert(validateAuthoritativeChapterTaskDraft(overflowBody, '', '1200-1300', true, + { ...overflowTask, styleRequirement: '禁止使用“钟楼”。' }, retainOverflow) !== null, + '同时违反明确禁词时仍需处理,不能被超限例外覆盖。') +for (const ending of ['他停在开口前。', '他停在开口前……', '“他停在开口前”', '他停在开口前——', '他停在开口前']) { + assert(validateGeneratedChapterDraft(overflowBody + ending, '1200-1300', true) === null, '不能仅凭末尾标点判定截断。', ending) +} +for (const unusable of ['', '{"title":"未完', '[{"body":"未完', '【本章目标】留下悬念。']) { + assert(validateGeneratedChapterDraft(unusable, '', false) !== null, '损坏结构、空结果和任务书不应变成正文。', unusable) +} +const overflowReport = await QualityGateService.evaluateChapterDraft({ + projectId: 'project-1', jobId: 'overflow', chapterId: null, draftId: 'retained', chapterDraft: { title: '雨夜', body: overflowBody }, + context: '', chapterPlan: null, chapterTask: overflowTask, noveltyAuditResult: passNoveltyAudit(), + aiService: { async generateQualityGateReport() { return { + ok: true, usedAI: true, data: { overallScore: 99, pass: true, dimensions: strongDimensions(99), issues: [], requiredFixes: [], optionalSuggestions: [] } + } } } +}) +assert(!overflowReport.pass && overflowReport.issues.some((item) => item.type === 'chapter_task_length'), + '超限保稿不能让高模型评分绕过正式审稿。') console.log('ChapterTask contract validation passed.') diff --git a/scripts/validate-chapter-title-batch.mjs b/scripts/validate-chapter-title-batch.mjs new file mode 100644 index 0000000..4c34594 --- /dev/null +++ b/scripts/validate-chapter-title-batch.mjs @@ -0,0 +1,342 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { repoRoot } from './utils/repo-root.mjs' + +const packaged = process.argv.includes('--packaged') +const tmp = join(repoRoot, 'tmp') +await mkdir(tmp, { recursive: true }) +const dir = await mkdtemp(join(tmp, 'chapter-titles-')), require = createRequire(import.meta.url) +const projectId = 'book-a', at = '2026-01-10T12:00:00.000Z' +const previewTool = 'agent.previewChapterTitleRename', applyTool = 'agent.applyChapterTitleRename', historyTool = 'agent.getChapterTitleHistory' +const ids = Array.from({ length: 32 }, (_, i) => 'c' + (i + 1)) +const stem = n => n === 9 ? '说起第七章' : '来信' + n +const prefix = n => n === 7 ? '第七章: ' : n === 9 ? '' : '第 ' + n + ' 章 ' +const single = (op, id, title, mode = 'manual') => ({ projectId, operationId: op, prefixMode: mode, edits: [{ chapterId: id, title }] }) +const batch = { projectId, operationId: 'first-32', prefixMode: 'strip', edits: ids.map(chapterId => ({ chapterId })).reverse() } +const commitArgs = (cmd, p) => ({ ...cmd, expectedRevision: p.expectedRevision, expectedPreviewHash: p.previewHash }) +const testBuild = { bundle: true, platform: 'node', target: 'node22', format: 'esm', external: ['electron', 'better-sqlite3'] } +const mcp = join(dir, 'mcp.mjs') + +function fixture() { + const chapters = ids.map((id, i) => ({ id, projectId, order: i + 1, title: prefix(i + 1) + stem(i + 1), + body: '虚构正文,不应出现在改名预览中。' + id, summary: '保留字段', createdAt: at, updatedAt: at })) + return { projects: [projectId, 'book-b'].map(id => ({ id, name: id, createdAt: at, updatedAt: at })), + chapters: [...chapters, { ...chapters[0], id: 'archived', order: 33, archivedAt: at }, + { ...chapters[0], id: 'foreign', projectId: 'book-b', title: '另一项目的标题' }], + chapterVersions: [...chapters.map(c => ({ id: 'v-' + c.id, chapterId: c.id, projectId, body: c.body, title: c.title, + source: 'imported', createdAt: c.order === 7 ? '2098-01-01T00:00:00Z' : at })), + { id: 'same-body-other-title', chapterId: 'c7', projectId, body: chapters[6].body, title: stem(7), source: 'imported', + createdAt: '2099-01-01T00:00:00Z' }], generatedChapterDrafts: [{ id: 'draft', projectId, body: '虚构候选正文', title: '不是正式章节' }] } +} + +function rpc(method, params) { + const res = spawnSync(process.execPath, [mcp], { input: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) + '\n', + cwd: dir, timeout: 15000, windowsHide: true, encoding: 'utf8' }) + assert.equal(res.status, 0, res.stderr) + const reply = JSON.parse(res.stdout.trim()) + assert.ok(!reply.error, JSON.stringify(reply.error)) + return reply.result +} + +async function verifyBackend(api, data, format) { + const home = join(dir, format) + await mkdir(home) + const path = join(home, 'novel-director-data.' + format) + const store = format === 'json' ? new api.JsonStorageService(path) : new api.SqliteStorageService(path) + const scope = { storagePath: path, projectId }, options = { ...scope, userDataPath: home } + const call = async (name, args = {}) => (await api.AgentToolService.callTool({ name, arguments: { ...options, ...args } })).data + const authority = new api.AgentAuthorizationService(home) + const grant = async (start, end) => authority.grant({ ...scope, chapterStart: start, chapterEnd: end, actions: ['manage_chapters'] }) + const peek = cmd => call(previewTool, cmd), apply = (cmd, p) => call(applyTool, commitArgs(cmd, p)) + try { + await store.save(data) + const initial = await store.loadSnapshot(), untouched = await readFile(path) + const denied = await peek(batch) + assert.equal(denied.authorization.authorized, false) + assert.equal(denied.previewHash, null) + assert.equal(denied.changedCount, 31) + assert.deepEqual(denied.rows.map(r => [r.chapterOrder, r.oldTitle, r.newTitle]), ids.map((_, i) => [i + 1, prefix(i + 1) + stem(i + 1), stem(i + 1)])) + assert.doesNotMatch(JSON.stringify(denied), /虚构正文|候选正文|另一项目/) + await assert.rejects(call(applyTool, { ...commitArgs(batch, denied), expectedPreviewHash: 'fake' }), /grant/) + await assert.rejects(call(applyTool, { ...commitArgs(batch, denied), confirm: true }), /字段/) + const g1 = await grant(1, 16), g2 = await grant(17, 32) + assert.equal((await peek(batch)).authorization.authorized, false, 'two partial grants do not cover one atomic action') + await authority.revoke(g1.id, path, projectId) + await authority.revoke(g2.id, path, projectId) + const g3 = await grant(1, 32) + const beforeRevoke = await peek(batch) + await authority.revoke(g3.id, path, projectId) + await assert.rejects(apply(batch, beforeRevoke), /grant/) + const active = await grant(1, 32) + await assert.rejects(apply(batch, beforeRevoke), /预览/) + const p = await peek(batch) + assert.equal(p.authorization.actor.authorizationGrantId, active.id) + assert.equal(p.expectedRevision, initial.revision) + assert.deepEqual(await readFile(path), untouched, 'read-only preview and denied actions did not change the database file') + if (format === 'json') assert.ok(!(await readdir(home)).includes('novel-director-data.json.bak')) + assert.deepEqual(await store.loadSnapshot(), initial) + for (const cmd of [single('blank', 'c1', ' '), single('empty-stem', 'c1', '第三章', 'strip'), + single('line', 'c1', '两个\n标题'), single('foreign', 'foreign', '新'), single('archived', 'archived', '新'), + single('draft', 'draft', '新'), { ...batch, edits: [] }, { ...batch, edits: [batch.edits[0], batch.edits[0]] }, + { ...batch, edits: [{ chapterId: 'c3', body: '越权写正文' }] }]) await assert.rejects(peek(cmd)) + await assert.rejects(call(applyTool, batch), /预览/) + await assert.rejects(apply({ ...batch, prefixMode: 'keep' }, p), /预览/) + + // Inject a failure after the SQLite writes have begun / before JSON's atomic replacement. + let native + if (format === 'sqlite') { + native = new (require('better-sqlite3'))(path) + native.exec("CREATE TRIGGER reject_receipt BEFORE INSERT ON entities WHEN NEW.collection = 'chapterTitleReceipts' BEGIN SELECT RAISE(ABORT,'injected failure'); END") + } else await mkdir(path + '.tmp') + try { + await assert.rejects(apply(batch, p), format === 'sqlite' ? /injected failure/ : /EISDIR|EPERM|EACCES/) + assert.deepEqual(await store.loadSnapshot(), initial, 'no partial titles, receipt or revision') + } finally { + if (native) { native.exec('DROP TRIGGER reject_receipt'); native.close() } + else await rm(path + '.tmp', { recursive: true }) + } + + const stdio = rpc('tools/call', { name: previewTool, arguments: { ...options, ...batch } }).structuredContent.data + assert.equal(stdio.previewHash, p.previewHash, 'the preview is valid across independent MCP processes') + const saved = rpc('tools/call', { name: applyTool, arguments: { ...options, ...commitArgs(batch, stdio) } }).structuredContent.data + assert.equal(saved.replayed, false) + assert.equal(saved.receipt.rows.length, 32) + assert.deepEqual(saved.savedCollections, ['chapters', 'chapterTitleReceipts']) + const after = await store.loadSnapshot() + assert.deepEqual(after.data.chapterTitleReceipts, [saved.receipt]) + // Compare against the original fixture independently of the implementation's patch helper. + assert.deepEqual(after.data, { ...initial.data, chapters: initial.data.chapters.map(c => + ids.includes(c.id) && c.order !== 9 ? { ...c, title: stem(c.order), updatedAt: saved.receipt.createdAt } : c), chapterTitleReceipts: [saved.receipt] }) + for (const id of ids) { + const chain = d => api.getChapterVersionChain(d, id).map(e => [e.id, e.title, e.body, e.isCurrent]) + assert.deepEqual(chain(after.data), chain(initial.data), 'rename must retain the same current version and historical title') + } + const replay = await apply(batch, p) + assert.deepEqual([replay.replayed, replay.revision, replay.receipt], [true, after.revision, saved.receipt]) + await assert.rejects(apply({ ...batch, edits: batch.edits.slice(1) }, p), /操作标识/) + await assert.rejects(apply(batch, { ...p, expectedRevision: after.revision }), /操作标识/) + const cmdA = single('single-A', 'c7', '第七章: 归帆'), cmdB = single('single-B', 'c7', '第七章: 分水') + const pA = await peek(cmdA), pB = await peek(cmdB) + const pendingBatch = { ...batch, operationId: 'competing-batch', prefixMode: 'manual', + edits: ids.map(chapterId => ({ chapterId, title: '另一个批次的' + chapterId })) } + const stale = await peek(pendingBatch) + const race = await Promise.allSettled([apply(cmdA, pA), apply(cmdB, pB)]) + assert.equal(race.filter(r => r.status === 'fulfilled').length, 1) + assert.ok(race.some(r => r.status === 'rejected' && r.reason.code === 'STORAGE_REVISION_CONFLICT')) + const winner = race.find(r => r.status === 'fulfilled').value.receipt.rows[0].newTitle + const fresh = await store.loadSnapshot() + await assert.rejects(apply(pendingBatch, stale), { code: 'STORAGE_REVISION_CONFLICT' }) + await assert.rejects(apply(pendingBatch, { ...stale, expectedRevision: fresh.revision }), /预览/) + assert.deepEqual(await store.loadSnapshot(), fresh) + assert.equal((await store.load()).chapters.find(c => c.id === 'c7').title, winner) + const cmd = single('keep', 'c7', '第32章 新的渡口', 'keep'), keep = await peek(cmd) + assert.deepEqual(keep.rows, [{ chapterId: 'c7', chapterOrder: 7, oldTitle: winner, newTitle: '第七章: 新的渡口' }]) + const retries = await Promise.all([apply(cmd, keep), apply(cmd, keep)]) + assert.deepEqual(retries.map(r => r.replayed).sort(), [false, true]) + const latest = await store.loadSnapshot() + assert.equal((await apply(batch, p)).replayed, true) + assert.deepEqual(await store.loadSnapshot(), latest, 'late replay did not restore an obsolete title') + const hist = await call(historyTool, { chapterId: 'c7' }) + assert.equal(hist.total, 3) + assert.deepEqual(hist.receipts.map(r => r.rows[0].newTitle), ['第七章: 新的渡口', winner, stem(7)]) + assert.ok(hist.receipts.every(r => r.rows.length === 1)) + assert.equal((await call(historyTool, { projectId: 'book-b' })).total, 0) + const noop = single('unchanged', 'c7', '第七章: 新的渡口') + assert.equal((await peek(noop)).changedCount, 0) + await assert.rejects(apply(noop, await peek(noop)), /没有变化/) + await authority.revoke(active.id, path, projectId) + await assert.rejects(apply(batch, p), /grant/) + assert.deepEqual(await store.loadSnapshot(), latest) + console.log('PASS ' + format + ': 32-row read-only preview, atomic rollback, scoped grant/revocation, single-title CAS race, exact replay and immutable body versions') + } finally { store.close?.() } +} + +async function verifyIsolation(api, data) { + const home = join(dir, 'siblings') + await mkdir(home) + const json = join(home, 'novel-director-data.json'), sqlite = join(home, 'novel-director-data.sqlite') + const database = new api.SqliteStorageService(sqlite) + await database.save(data); database.close() + const other = structuredClone(data) + other.chapters[0].title = '同目录另一份书' + const local = new api.JsonStorageService(json) + await local.save(other) + const args = single('siblings', 'c1', '新名') + const peek = async path => (await api.AgentToolService.callTool({ name: previewTool, + arguments: { ...args, storagePath: path, userDataPath: home } })).data + assert.equal((await peek(json)).rows[0].oldTitle, '同目录另一份书') + assert.equal((await peek(sqlite)).rows[0].oldTitle, data.chapters[0].title) + const before = await readdir(home) + for (const ext of ['sqlite', 'json']) await assert.rejects(peek(join(home, 'missing.' + ext))) + assert.deepEqual(await readdir(home), before, 'read-only preview must not initialize a missing store') + await local.save({ ...other, chapters: [...other.chapters, { ...other.chapters[0], order: 999, archivedAt: at }] }) + await assert.rejects(peek(json), /唯一定位/, 'ambiguous imported chapter IDs cannot broaden a bounded grant') + console.log('PASS explicitly named sibling stores, absent files and ambiguous imported IDs') +} + +async function run(api) { + const data = api.normalizeAppData(fixture()) + const listed = rpc('tools/list', {}).tools + assert.equal(listed.find(t => t.name === previewTool).annotations.readOnlyHint, true) + assert.ok(listed.some(t => t.name === applyTool) && listed.some(t => t.name === historyTool)) + for (const format of ['json', 'sqlite']) await verifyBackend(api, data, format) + await verifyIsolation(api, data) + if (process.argv.includes('--ui')) await verifyUI(data, api) +} + +async function verifyUI(data, api) { + const { qaRuntimeTarget, prepareTarget, targetIdentity, connectQaSession, writeOfflineQaProfile, verifyQaOffline } = + await import('./utils/long-novel-ui-runtime.mjs') + const { launch, cleanup, evaluate, click, button, ready } = await import('./utils/long-novel-ui-driver.mjs') + const { fill, waitFor } = await import('./utils/qa-agent-authorization-driver.mjs') + const profile = join(dir, 'desktop-profile'), target = qaRuntimeTarget(repoRoot) + if (!packaged) { + const out = join(dir, 'desktop-build') + await (await import('electron-vite')).build({ root: repoRoot, logLevel: 'error', build: { outDir: out } }) + target.out = out + target.required = ['main/index.js', 'preload/index.cjs', 'renderer/index.html'].map(f => join(out, f)) + } + await targetIdentity(repoRoot, target) + await prepareTarget(repoRoot, target, dir) + const path = join(profile, 'novel-director-data.sqlite') + let session, peer, sink + // Read via the peer so a check cannot refresh the editing window's optimistic revision. + const snapshot = () => peer ? evaluate(peer.client, 'window.novelDirector.data.load()') : api.loadSqliteSnapshotReadonly(path) + const d = 'document.querySelector(".chapter-title-dialog")', q = sel => 'document.querySelector(' + JSON.stringify(sel) + ')' + const edit = q('button[aria-label="更改本章标题"]'), title = q('input[aria-label="第 32 章拟用标题"]') + const submit = button('确认提交改名', d), view = button('预览改名', d), batchButton = button('批量更改章名') + const author = api && new api.AgentAuthorizationService(profile) + const external = async (op, id, newTitle) => { + if (peer) { + const command = single(op, id, newTitle) + await snapshot() + const preview = await evaluate(peer.client, 'window.novelDirector.data.previewChapterTitleRename(' + JSON.stringify(command) + ')') + assert.equal(preview.changedCount, 1) + const commit = { command, expectedRevision: preview.expectedRevision, expectedPreviewHash: preview.previewHash } + const result = await evaluate(peer.client, 'window.novelDirector.data.applyChapterTitleRename(' + JSON.stringify(commit) + ')') + assert.equal(result.replayed, false) + return + } + const args = { ...single(op, id, newTitle), userDataPath: profile, storagePath: path } + const p = (await api.AgentToolService.callTool({ name: previewTool, arguments: args })).data + await api.AgentToolService.callTool({ name: applyTool, arguments: commitArgs(args, p) }) + } + try { + sink = await writeOfflineQaProfile(profile, data) + session = await launch(target, profile) + await connectQaSession(target, session, profile) + const c = session.client, nav = q('.nav-list') + if (packaged) { + const peerProfile = join(dir, 'competing-desktop-profile') + await mkdir(peerProfile) + await writeFile(join(peerProfile, 'app-config.json'), JSON.stringify({ storagePath: path }), { flag: 'wx' }) + peer = await launch(target, peerProfile) + await connectQaSession(target, peer, profile) // Same disposable book, independently launched app/profile. + assert.equal(await evaluate(peer.client, 'window.novelDirector.app.getStoragePath().then(r=>r.defaultStoragePath)'), + join(peerProfile, 'novel-director-data.sqlite')) + assert.notEqual(peer.child.pid, session.child.pid) + assert.notEqual(peer.port, session.port) + console.log('TITLE_PEERS', JSON.stringify({ profile, port: session.port, peerProfile, peerPort: peer.port })) + await c.send('Page.bringToFront') + } + const project = '[...document.querySelectorAll(".project-row")].find(e => e.textContent.includes("book-a"))' + await ready(c, project); await click(c, button('进入', project)) + await ready(c, nav) + await click(c, button('章节', nav)); await ready(c, edit) + const body = q('textarea.manuscript-textarea'), bodyText = '作者刚输入的独立正文,改名不能替它保存。' + await fill(c, body, bodyText) + assert.equal(await evaluate(c, edit + '.disabled && ' + batchButton + '.disabled'), true) + await ready(c, '!' + edit + '.disabled') + const initial = await snapshot() + assert.equal(initial.data.chapters.find(ch => ch.id === 'c32').body, bodyText) + await click(c, batchButton); await ready(c, d) + assert.ok(await evaluate(c, submit + '.disabled')) + await click(c, view); await ready(c, q('.chapter-title-preview tbody')) + const expectedRows = ids.map((_, i) => [String(i + 1), prefix(i + 1) + stem(i + 1), stem(i + 1)]) + assert.deepEqual(await evaluate(c, + '[...document.querySelectorAll(".chapter-title-preview tbody tr")].map(r=>[...r.cells].map(cell=>cell.textContent))'), expectedRows) + assert.match(await evaluate(c, q('.chapter-title-preview') + '.textContent'), /实际改动 31 章/) + await click(c, q('.chapter-title-preview summary')) + assert.ok(await evaluate(c, q('.chapter-title-preview details') + '.innerText.includes("允许提交本批改名")')) + assert.equal(await evaluate(c, q('.chapter-title-preview code') + '.textContent'), initial.revision) + assert.deepEqual(await snapshot(), initial, 'author preview does not persist anything') + await click(c, submit); await ready(c, '!' + d) + const applied = await snapshot() + assert.equal(applied.data.chapterTitleReceipts.length, 1) + const [receipt] = applied.data.chapterTitleReceipts + assert.equal(receipt.actor.kind, 'user') + assert.deepEqual(receipt.rows.map(r => [String(r.chapterOrder), r.oldTitle, r.newTitle]), expectedRows) + assert.deepEqual(applied.data.chapters, initial.data.chapters.map(ch => ids.includes(ch.id) && ch.order !== 9 + ? { ...ch, title: stem(ch.order), updatedAt: receipt.createdAt } : ch)) + assert.deepEqual(applied.data.generatedChapterDrafts, initial.data.generatedChapterDrafts) + await click(c, edit); await ready(c, title) + await fill(c, title, '不提交的旧输入'); await click(c, view); await ready(c, q('.chapter-title-preview')) + await fill(c, title, '作者指定的新章名') + assert.ok(await evaluate(c, submit + '.disabled')) + await click(c, view); await ready(c, '!' + submit + '.disabled') + await click(c, submit); await ready(c, '!' + d) + await click(c, button('版本历史')); await ready(c, q('.chapter-title-history')) + await click(c, q('.chapter-title-history summary')) + assert.match(await evaluate(c, q('.chapter-title-history') + '.innerText'), /来信32.*作者指定的新章名/) + const saved = await snapshot() + assert.deepEqual(saved.data.chapterVersions, initial.data.chapterVersions) + assert.equal(saved.data.chapters.find(ch => ch.id === 'c32').body, bodyText) + await click(c, edit); await ready(c, title) + await fill(c, title, '桌面过期改名'); await click(c, view); await ready(c, q('.chapter-title-preview')) + if (author) await author.grant({ projectId, storagePath: path, actions: ['manage_chapters'], chapterStart: 31, chapterEnd: 32 }) + await external('ui-race', 'c32', '并发保留名') + await click(c, submit); await waitFor(c, '!!' + q('.chapter-title-dialog [role=alert]')) + assert.ok(await evaluate(c, 'Boolean(' + q('.storage-conflict-notice') + ')'), + 'Missing reload prompt after conflict: ' + await evaluate(c, q('.chapter-title-dialog [role=alert]') + '.textContent')) + assert.equal((await snapshot()).data.chapterTitleReceipts.length, 3) + assert.equal((await snapshot()).data.chapters.find(ch => ch.id === 'c32').title, '并发保留名') + await click(c, button('取消', d)) + await click(c, button('重新加载最新数据')); await click(c, button('重新加载', q('.confirm-dialog'))) + await waitFor(c, '!' + q('.storage-conflict-notice')) + await ready(c, '!' + edit + '.disabled') + const reviewButton = '[...document.querySelectorAll("button")].find(b => b.textContent.includes("本章复盘 ("))' + await click(c, reviewButton) + const review = q('.chapter-review-panel textarea') + await ready(c, review) + await external('ui-form-race', 'c31', '另一章的新标题') + await fill(c, review, '留在表单里的未保存复盘') + await waitFor(c, '!!' + q('.storage-conflict-notice')) + assert.ok(await evaluate(c, edit + '.disabled && ' + batchButton + '.disabled')) + assert.equal(await evaluate(c, review + '.value'), '留在表单里的未保存复盘') + assert.equal((await snapshot()).data.chapters.find(ch => ch.id === 'c32').summary, '保留字段') + for (const running of [session, peer].filter(Boolean)) await verifyQaOffline(running, sink) + console.log('PASS desktop + SQLite: dirty body guard, 32 exact preview/receipt rows, 31 renames, single rename/history, ' + + (peer ? 'competing packaged desktop' : 'competing MCP') + ' title preserved, unsaved review retained; no model calls.') + } finally { + try { + const closed = await Promise.all([cleanup(peer), cleanup(session)]) + for (const result of closed.filter(Boolean)) assert.deepEqual(result.remainingProcessIds, []) + console.log('CLOSED', JSON.stringify(closed.filter(Boolean).map(r => r.remainingProcessIds))) + } finally { + await sink?.close() + if (sink) assert.equal(sink.requests.length, 0) + } + } +} + +async function runSourceChecks() { + const { build } = await import('esbuild') + const file = join(dir, 'domain.mjs') + const entries = ['shared/defaults', 'storage/JsonStorageService', 'storage/SqliteStorageService', 'agent/tools/AgentToolService', + 'main/services/AgentAuthorizationService', 'services/ChapterVersionChainService'] + await build({ ...testBuild, outfile: file, stdin: { resolveDir: repoRoot, contents: entries.map(m => `export * from './src/${m}'`).join('\n') } }) + await build({ ...testBuild, outfile: mcp, entryPoints: [join(repoRoot, 'src/agent/mcp/server.ts')] }) + await run(await import(pathToFileURL(file))) +} + +try { + if (packaged) await verifyUI(fixture()) + else await runSourceChecks() +} finally { + assert.equal(dirname(await realpath(dir)), await realpath(tmp)) + await rm(dir, { recursive: true, force: true, maxRetries: 4, retryDelay: 120 }) +} diff --git a/scripts/validate-chapter-writeback-failures.mjs b/scripts/validate-chapter-writeback-failures.mjs new file mode 100644 index 0000000..2a68490 --- /dev/null +++ b/scripts/validate-chapter-writeback-failures.mjs @@ -0,0 +1,352 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, rmdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import Database from 'better-sqlite3' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +const parent = join(repoRoot, 'tmp') +await mkdir(parent, { recursive: true }) +const tmp = await mkdtemp(join(parent, 'chapter-writeback-failures-')) +assert.equal(dirname(tmp), parent) +const early = '2024-01-01T00:00:00.000Z' +const old = 'Synthetic old manuscript.', draftText = 'Synthetic proposed manuscript.', edited = 'Synthetic revised manuscript.' +const sensitive = ['sk', 'test', 'fixture12345678'].join('-') +let api + +async function loadApi() { + const paths = ['shared/defaults', 'services/ChapterWritebackFailureService', 'services/ChapterCommitBundleService', + 'services/RevisionCommitBundleService', 'services/ChapterVersionChainService', 'storage/JsonStorageService', + 'storage/SqliteStorageService', 'agent/AgentRuntime', 'agent/tools/agentChapterAcceptanceTools', + 'main/ipc/dataIpcHandlers', 'shared/ipc/ipcChannels', 'main/DataMergeService', 'main/dataMerge/referenceRemapping', + 'main/services/AgentAuthorizationService'] + const file = join(tmp, 'test-api.mjs') + await build({ stdin: { contents: paths.map((p) => "export * from './src/" + p + "'").join('\n') + + "\nexport { handlers } from 'electron'", resolveDir: repoRoot }, outfile: file, + platform: 'node', format: 'esm', target: 'node22', bundle: true, external: ['better-sqlite3'], logLevel: 'silent', + plugins: [{ name: 'isolated-desktop', setup(b) { + b.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'fixture' })) + b.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: + 'export const handlers = new Map(), ipcMain = { handle: (k,f) => handlers.set(k,f) }, ' + + 'app = { isReady: () => false, isPackaged: true }, dialog = {}, shell = {}, safeStorage = {}' })) + } }] }) + return import(pathToFileURL(file).href) +} + +function fixture(mode) { + const chapter = { id: 'ch', projectId: 'p', order: 2, title: 'Old title', body: old, createdAt: early, updatedAt: early } + const base = { id: 'base', projectId: 'p', chapterId: 'ch', title: chapter.title, body: old, source: 'manual_revision', createdAt: early } + return api.normalizeAppData({ + projects: [{ id: 'p', name: 'Synthetic book' }, { id: 'other', name: 'Independent book' }], + chapters: mode === 'new' ? [] : [chapter], + chapterVersions: mode === 'new' || mode === 'replace' ? [] : [base, + ...(mode === 'restore' ? [{ ...base, id: 'historical', title: 'Historical title', body: edited, createdAt: '2023-01-01T00:00:00.000Z' }] : [])], + chapterGenerationJobs: [{ id: 'j', projectId: 'p', targetChapterOrder: 2, status: 'completed', currentStep: 'await_user_confirmation' }], + generatedChapterDrafts: [{ id: 'd', projectId: 'p', jobId: 'j', chapterId: null, status: 'draft', title: 'Proposed title', + body: draftText, summary: '', createdAt: early, updatedAt: early }], + agentRuns: [{ id: 'run', projectId: 'p', createdJobIds: ['j'] }], + revisionSessions: [{ id: 's', projectId: 'p', chapterId: 'ch', sourceDraftId: 'd', status: 'active' }], + revisionVersions: [{ id: 'rv', sessionId: 's', status: 'pending', title: 'Candidate', body: edited }] + }) +} + +function makeBundle(mode, data, id, at) { + if (mode === 'new' || mode === 'replace') return api.buildAcceptedDraftCommitBundle({ + appData: data, projectId: 'p', targetChapterOrder: 2, chapterId: 'new-' + id, draftId: 'd', + acceptedAt: at, acceptanceMode: 'unreviewed', commitId: id, chapterVersionId: id + '-v' + }) + if (mode === 'restore') return api.buildRestoreRevisionCommitBundle({ appData: data, projectId: 'p', chapterId: 'ch', + sourceVersionId: 'historical', revisionCommitId: id, newChapterVersionId: id + '-v', restoredAt: at }) + return api.buildRevisionCommitBundle({ appData: data, projectId: 'p', chapterId: 'ch', + revisionCommitId: id, newChapterVersionId: id + '-v', revisedAt: at, + ...(mode === 'candidate' ? { revisionSessionId: 's', revisionVersionId: 'rv' } : { afterText: edited, revisionReason: 'Manual correction' }) }) +} + +async function openBook(name, backend, mode) { + const dir = await mkdtemp(join(tmp, name + '-')) + const storagePath = join(dir, 'book.' + backend), options = { storagePath, userDataPath: dir } + const Type = backend === 'json' ? api.JsonStorageService : api.SqliteStorageService + const store = new Type(storagePath) + await store.save(fixture(mode)) + const read = () => api.loadAgentRuntimeData(options) + api.registerDataIpcHandlers({ getStorage: () => store, backupService: { maybeCreateAutomaticBackup: async () => null } }) + const commit = async (bundle, path, expectedRevision) => { + const isRevision = 'revisionCommitId' in bundle + const runtime = await read() + if (path === 'agent') return (isRevision ? api.saveAgentRevisionCommitBundle : api.saveAgentChapterCommitBundle)( + bundle, { ...runtime, revision: expectedRevision ?? runtime.revision }) + const channel = isRevision ? api.IPC_CHANNELS.DATA_SAVE_REVISION_COMMIT_BUNDLE : api.IPC_CHANNELS.DATA_SAVE_CHAPTER_COMMIT_BUNDLE + const result = await api.handlers.get(channel)(null, { bundle, expectedRevision: expectedRevision ?? runtime.revision }) + if (!result.ok) throw Object.assign(new Error(result.error), { code: result.code }) + return result + } + return { backend, store, storagePath, Type, read, commit } +} + +function sql(file, statement) { + const db = new Database(file, { fileMustExist: true }) + try { db.exec(statement) } finally { db.close() } +} + +// An actual filesystem/SQLite write fails. The optional hook runs only AFTER the failed write releases its lock. +async function interrupt(book, run, between, { blockEvidence = false } = {}) { + const { backend, storagePath, Type } = book + const tempFile = storagePath + '.tmp' + if (backend === 'json') await mkdir(tempFile) + else sql(storagePath, "CREATE TRIGGER interrupt_version BEFORE INSERT ON entities WHEN NEW.collection='chapterVersions' " + + "BEGIN SELECT RAISE(ABORT, 'synthetic formal write refused'); END;" + (blockEvidence + ? "CREATE TRIGGER interrupt_evidence BEFORE INSERT ON entities WHEN NEW.collection='chapterWritebackFailures' " + + "BEGIN SELECT RAISE(ABORT, 'secondary failure'); END;" : '')) + const record = Type.prototype.recordChapterWritebackFailureIfUncommitted + let observed = 0 + Type.prototype.recordChapterWritebackFailureIfUncommitted = async function (failure) { + assert.equal(this.getStoragePath(), storagePath) + observed++ + if (backend === 'json' && !blockEvidence) await rmdir(tempFile) + await between?.(failure) + return record.call(this, failure) + } + try { + await run() + return observed + } finally { + Type.prototype.recordChapterWritebackFailureIfUncommitted = record + if (backend === 'json') await rmdir(tempFile).catch((e) => { if (e.code !== 'ENOENT') throw e }) + else sql(storagePath, 'DROP TRIGGER IF EXISTS interrupt_version; DROP TRIGGER IF EXISTS interrupt_evidence;') + } +} + +const openFailures = (data) => api.unresolvedChapterWritebackFailures(data, 'p') +const writeError = /synthetic formal write refused|EISDIR|EPERM|EACCES/ +function verifyAlternatives(data, retry, mode, failure) { + const revision = 'revisionCommitId' in retry + const collection = revision ? 'revisionCommitBundles' : 'chapterCommitBundles' + function check(edit, message) { + const receipt = structuredClone(retry) + const snapshot = { ...data, [collection]: [receipt], chapterVersions: [...data.chapterVersions, structuredClone(receipt.chapterVersion)] } + edit(snapshot, receipt, snapshot.chapterVersions.at(-1)) + assert.equal(openFailures(snapshot).length, 1, message) + } + check((s) => { s[collection] = [] }, 'a version alone is not a receipt') + check((s) => { s.chapterVersions = data.chapterVersions }, 'the embedded version is not an independent stored version') + check((_, r) => { r[revision ? 'revisedAt' : 'acceptedAt'] = failure.attemptedAt }, 'retry must be later, not simultaneous or older') + check((_, _r, v) => { v.createdAt = early }, 'a recent receipt cannot borrow an older target version') + check((_, _r, v) => { delete v[revision ? 'linkedRevisionCommitId' : 'linkedChapterCommitId'] }, 'replacement IDs need a linked version') + check((_, r) => { r.projectId = 'other' }, 'same IDs in another project') + check((_, r, v) => { v.body = r.chapter.body = r.chapterVersion.body = r.afterText = 'Different target' }, 'same source IDs but different target text') + check((_, _r, v) => { v.body = old }, 'receipt does not match the stored version') + check((_, _r, v) => { v.baseChapterVersionId = 'unrelated-base' }, 'stored ancestry must agree with the receipt') + check((_, _r, v) => { v.chapterId = 'different-chapter' }, 'version belongs to another chapter') + if (revision) { + check((_, r) => { r.beforeText = 'Another baseline' }, 'different source text') + check((_, r) => { r.baseChapterVersionId = 'another-base' }, 'same baseline text but different base version') + if (mode === 'candidate') { + check((_, r) => { r.revisionVersionId = 'other-candidate' }, 'same session, different candidate') + check((_, r) => { r.revisionSessionId = 'other-session' }, 'same candidate ID, different session') + } else check((_, r) => { r.revisionReason = 'Unrelated operation' }, 'manual edit and restore are not interchangeable') + } else { + check((_, r) => { r.generatedDraftId = 'other-draft' }, 'same job but different draft') + check((_, r) => { r.jobId = 'other-job' }, 'same draft ID but different job') + if (mode === 'replace') check((_, r) => { r.previousChapterVersion.body = 'Other prior chapter' }, 'automatic before-image must match') + } +} + +async function exerciseRetry(backend, mode) { + const book = await openBook(mode, backend, mode) + try { + const before = await book.read(), failed = makeBundle(mode, before.data, 'first', early) + const path = (mode === 'candidate' || mode === 'new') === (backend === 'sqlite') ? 'agent' : 'desktop' + const count = await interrupt(book, async () => { + await assert.rejects(book.commit(failed, path), writeError) + }, async () => { + assert.deepEqual((await book.read()).data, before.data, 'the failed transaction must finish rolling back before observation') + }) + assert.equal(count, 1) + const reopened = await book.read(), [failure] = openFailures(reopened.data) + assert.deepEqual({ ...reopened.data, chapterWritebackFailures: [] }, before.data, 'only new evidence may differ') + assert.notEqual(reopened.revision, before.revision) + assert.equal(reopened.data.chapterWritebackFailures.length, 1) + assert.equal(failure.commitId, 'first') + assert.equal(failure.versionId, 'first-v') + assert.equal(failure.chapterOrder, 2) + assert.equal(failure.chapterId, failed.chapterId) + const isChapter = mode === 'new' || mode === 'replace' + assert.equal(failure.kind, isChapter ? 'chapter_commit' : 'revision_commit') + if (isChapter || mode === 'candidate') assert.deepEqual([failure.jobId, failure.draftId], ['j', 'd']) + if (mode === 'candidate') assert.deepEqual([failure.revisionSessionId, failure.revisionVersionId], ['s', 'rv']) + for (const marker of [old, edited, draftText, book.storagePath, 'synthetic formal write refused', sensitive]) { + assert.ok(!JSON.stringify(failure).includes(marker)) + } + const retry = makeBundle(mode, reopened.data, 'retry', new Date(Date.parse(failure.attemptedAt) + 1000).toISOString()) + verifyAlternatives(reopened.data, retry, mode, failure) + await book.commit(retry, path === 'desktop' ? 'agent' : 'desktop') + const done = (await book.read()).data + assert.equal(done.chapters[0].body, isChapter ? draftText : edited) + assert.deepEqual(openFailures(done), [], 'a later real retry may allocate new commit and version IDs') + assert.equal(done.chapterWritebackFailures.length, 1, 'history is retained, not bulk-cleared') + const unrelated = { ...failure, id: 'separate', commitId: 'different', versionId: 'different-version', + intentFingerprint: 'writeback-v1:draft-v1-0000000000000000-0' } + assert.deepEqual(openFailures({ ...done, chapterWritebackFailures: [failure, unrelated] }).map((v) => v.id), ['separate']) + if (mode === 'manual' || mode === 'restore') { + const exported = JSON.parse(JSON.stringify(api.sanitizeAppDataForPersistence(done))) + assert.deepEqual(api.normalizeAppData(exported).chapterWritebackFailures, done.chapterWritebackFailures) + const merged = api.mergeAppData(done, api.normalizeAppData({ projects: [{ id: 'p', name: 'Different target book' }] }), + { sourcePath: 'source', targetPath: 'target' }) + assert.equal(merged.preview.canAutoMerge, true) + const imported = merged.mergedData.chapterWritebackFailures[0] + assert.notEqual(imported.projectId, 'p') + assert.notEqual(imported.baseChapterVersionId, 'base') + assert.deepEqual(api.unresolvedChapterWritebackFailures(merged.mergedData, imported.projectId), []) + if (mode === 'restore') { + const pending = api.mergeAppData(reopened.data, api.normalizeAppData({ projects: [{ id: 'p', name: 'Another book' }] }), + { sourcePath: 'source', targetPath: 'target' }).mergedData + const [f] = pending.chapterWritebackFailures + const source = pending.chapterVersions.find((v) => v.projectId === f.projectId && v.body === edited) + const restored = api.buildRestoreRevisionCommitBundle({ appData: pending, projectId: f.projectId, chapterId: f.chapterId, + sourceVersionId: source.id, revisionCommitId: 'after-import', newChapterVersionId: 'v-after-import', restoredAt: retry.revisedAt }) + assert.deepEqual(api.unresolvedChapterWritebackFailures(api.applyRevisionCommitBundleToAppData(pending, restored), f.projectId), []) + } + } + console.log('PASS', backend, path, mode, 'rollback, independent reopen, binding checks and new-ID retry') + } finally { book.store.close?.() } +} + +async function competingWrites(backend) { + const book = await openBook('competing', backend, 'manual') + try { + const before = await book.read(), failed = makeBundle('manual', before.data, 'retry-me', early) + await interrupt(book, () => assert.rejects(book.commit(failed, 'agent'), writeError), async () => { + if (backend === 'sqlite') sql(book.storagePath, 'DROP TRIGGER interrupt_version') + const peer = new book.Type(book.storagePath) + try { + const snap = await peer.loadSnapshot() + await peer.saveIfCurrent({ ...snap.data, projects: snap.data.projects.map((p) => p.id === 'other' + ? { ...p, name: 'Saved between rollback and evidence' } : p) }, snap.revision) + } finally { peer.close?.() } + }) + const observed = await book.read() + assert.equal(observed.data.projects.find((p) => p.id === 'other').name, 'Saved between rollback and evidence') + assert.equal(openFailures(observed.data).length, 1) + await assert.rejects(book.store.saveIfCurrent(before.data, before.revision), (e) => e.code === 'STORAGE_REVISION_CONFLICT') + await book.commit(failed, 'agent') + assert.deepEqual(openFailures((await book.read()).data), [], 'exact idempotent retry even when the preview timestamp predates the failure') + const next = makeBundle('manual', (await book.read()).data, 'ack-lost', early) + const save = book.store.saveRevisionCommitBundle.bind(book.store) + const original = new Error('synthetic lost acknowledgement') + book.store.saveRevisionCommitBundle = async (...args) => { await save(...args); throw original } + await assert.rejects(api.observeFormalChapterWrites(book.store).saveRevisionCommitBundle(next), (e) => e === original) + const afterAck = await book.read() + assert.equal(afterAck.data.revisionCommitBundles.length, 2) + assert.equal(afterAck.data.chapterWritebackFailures.length, 1, 'never label a committed write as failed') + console.log('PASS', backend, 'competing save, exact replay and lost acknowledgement') + } finally { book.store.close?.() } +} + +async function exclusions(backend) { + const book = await openBook('exclusions', backend, 'manual') + try { + const before = await book.read(), bundle = makeBundle('manual', before.data, 'not-written', early) + assert.equal(await interrupt(book, () => assert.rejects(book.store.saveRevisionCommitBundle(bundle), writeError)), 0) + assert.deepEqual(await book.read(), before, 'direct storage API still rolls back without writing any evidence') + const legacy = makeBundle('replace', before.data, 'legacy', early) + legacy.chapterVersion = { ...legacy.chapterVersion, source: 'before_accept_draft', body: old, linkedChapterCommitId: null } + assert.equal(await interrupt(book, () => assert.rejects(book.commit(legacy, 'agent'), writeError)), 0, + 'a before-image alone cannot identify a target version for the failed write') + await assert.rejects(book.commit(bundle, 'agent', 'stale-revision'), (e) => e.code === 'STORAGE_REVISION_CONFLICT') + await assert.rejects(book.commit({ ...bundle, beforeText: 'wrong base' }, 'agent'), /stale/) + assert.deepEqual(await book.read(), before) + const preview = await api.previewAgentChapterAcceptance(before.data, { projectId: 'p', agentRunId: 'run', jobId: 'j', draftId: 'd', + reason: 'Fixture preview', mode: 'unreviewed', operationId: 'test' }, before) + const state = await book.read() + const args = { projectId: 'p', previewId: preview.preview.id, expectedPreviewHash: preview.previewHash } + await assert.rejects(api.applyAgentChapterAcceptance(state.data, { ...args, expectedPreviewHash: 'wrong' }, state), /预览/) + await assert.rejects(api.applyAgentChapterAcceptance(state.data, args, state), (e) => e.code === 'AGENT_AUTHORIZATION_REQUIRED') + assert.equal((await book.read()).data.chapterWritebackFailures.length, 0) + await interrupt(book, async () => { + await assert.rejects(book.commit(bundle, 'agent'), (e) => writeError.test(e.message) && !/secondary/.test(e.message)) + }, undefined, { blockEvidence: true }) + assert.deepEqual(await book.read(), state, 'an unwritable journal must not mask the original failure or change storage') + await new api.AgentAuthorizationService(state.userDataPath).grant({ storagePath: book.storagePath, projectId: 'p', + chapterStart: 2, chapterEnd: 2, actions: ['accept_draft', 'accept_unreviewed_draft'] }) + await interrupt(book, () => assert.rejects(api.applyAgentChapterAcceptance(state.data, args, state), writeError)) + const failed = await book.read() + assert.equal(openFailures(failed.data).length, 1) + assert.equal(failed.data.agentActionPreviews[0].status, 'pending') + const save = book.Type.prototype.saveIfCurrent, metadataError = new Error('synthetic metadata failure') + book.Type.prototype.saveIfCurrent = function (data, ...rest) { + if (data.agentActionPreviews.some((row) => row.status === 'applied')) throw metadataError + return save.call(this, data, ...rest) + } + try { await assert.rejects(api.applyAgentChapterAcceptance(failed.data, args, failed), (e) => e === metadataError) } + finally { book.Type.prototype.saveIfCurrent = save } + const applied = await book.read() + assert.equal(applied.data.chapterCommitBundles.length, 1) + assert.equal(applied.data.chapterWritebackFailures.length, 1, 'a metadata failure after commit is not a second writeback failure') + assert.deepEqual(openFailures(applied.data), []) + assert.equal((await api.applyAgentChapterAcceptance(applied.data, args, applied)).replayed, true) + console.log('PASS', backend, 'direct rollback, preview/grant/validation refusals, unwritable evidence and authorized MCP recovery') + } finally { book.store.close?.() } +} + +async function ambiguousReadback(backend) { + const scenarios = ['receipt-only', 'version-only', 'project-removed', ...(backend === 'json' ? ['unreadable'] : [])] + for (const state of scenarios) { + const book = await openBook(state, backend, 'manual') + try { + const before = await book.read(), bundle = makeBundle('manual', before.data, 'interrupted', early) + await interrupt(book, () => assert.rejects(book.commit(bundle, 'agent'), writeError), async () => { + if (state === 'unreadable') return writeFile(book.storagePath, '{unreadable', 'utf8') + if (backend === 'sqlite') sql(book.storagePath, 'DROP TRIGGER interrupt_version') + const snap = await book.store.loadSnapshot() + const data = { ...snap.data } + if (state === 'receipt-only') data.revisionCommitBundles = [bundle] + if (state === 'version-only') data.chapterVersions = [...data.chapterVersions, bundle.chapterVersion] + if (state === 'project-removed') data.projects = data.projects.filter((row) => row.id !== 'p') + await book.store.saveIfCurrent(data, snap.revision) + }) + if (state === 'unreadable') assert.equal(await readFile(book.storagePath, 'utf8'), '{unreadable') + else assert.equal((await book.read()).data.chapterWritebackFailures.length, 0, state) + } finally { book.store.close?.() } + } + console.log('PASS', backend, 'partial evidence and removed/unreadable targets are not proof of a failed commit') +} + +function checkImport() { + const dirty = { id: 'f', projectId: 'p', chapterId: 'ch', chapterOrder: 2, kind: 'revision_commit', + commitId: 'shared', versionId: 'shared', attemptedAt: early, reason: sensitive, body: old, error: { token: sensitive }, path: tmp } + const data = api.normalizeAppData({ projects: [{ id: 'p' }], settings: { apiKey: 'private-fixture-key' }, + chapterWritebackFailures: [null, [], dirty, ...[{ chapterOrder: 0 }, { chapterOrder: 1.5 }, { kind: 'preview' }, { projectId: 'missing' }, + { versionId: sensitive }, { commitId: 'private-fixture-key' }, { attemptedAt: 'yesterday' }, { jobId: tmp }].map((fields) => ({ ...dirty, ...fields }))] }) + assert.equal(data.chapterWritebackFailures.length, 1) + const [row] = data.chapterWritebackFailures + for (const marker of [sensitive, old, tmp, 'private-fixture-key']) assert.ok(!JSON.stringify(row).includes(marker)) + assert.deepEqual(openFailures({ ...data, chapterWritebackFailures: [null, dirty] }), [row], 'sanitize raw projections too') + const remaps = api.createIdRemaps() + for (const [coll, id] of [['chapterVersions', 'version'], ['chapterCommitBundles', 'chapter'], ['revisionCommitBundles', 'revision']]) { + api.rememberId(remaps, coll, 'shared', id) + } + for (const [kind, expected] of [['chapter_commit', 'chapter'], ['revision_commit', 'revision']]) { + const mapped = api.remapReferencesDeep({ ...row, kind }, remaps) + assert.deepEqual([mapped.commitId, mapped.versionId], [expected, 'version']) + } + assert.deepEqual(api.normalizeAppData({}).chapterWritebackFailures, []) + console.log('PASS legacy/defaults, allowlisted import/projection and kind-specific remaps') +} + +try { + api = await loadApi() + const browser = await build({ entryPoints: [join(repoRoot, 'src/services/ChapterWritebackFailureService.ts')], bundle: true, + platform: 'browser', format: 'esm', write: false, metafile: true, logLevel: 'silent' }) + assert.ok(!Object.keys(browser.metafile.inputs).some((p) => /(?:^|[\\/])storage[\\/]|sqlite|node_modules/.test(p))) + checkImport() + for (const backend of ['sqlite', 'json']) { + for (const mode of ['new', 'replace', 'candidate', 'manual', 'restore']) await exerciseRetry(backend, mode) + await competingWrites(backend) + await ambiguousReadback(backend) + await exclusions(backend) + } + console.log('PASS browser import contains no Node/SQLite runtime; all fixtures isolated') +} finally { + await rm(tmp, { recursive: true, force: true }) +} diff --git a/scripts/validate-character-card-review.mjs b/scripts/validate-character-card-review.mjs new file mode 100644 index 0000000..a435bb0 --- /dev/null +++ b/scripts/validate-character-card-review.mjs @@ -0,0 +1,296 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +const base = join(repoRoot, 'tmp') +await mkdir(base, { recursive: true }) +const dir = await mkdtemp(join(base, 'card-review-')) +const at = '2026-03-10T12:00:00Z', later = '2026-03-12T12:00:00Z' +const p = 'book-a', who = 'hero' +const oldQuote = '她清点了十文钱。', newQuote = '她只剩下七文钱。' +const chapter = (id, order, body, projectId = p) => ({ id, projectId, order, title: id, body, createdAt: at, updatedAt: at }) +const tx = (id, factId, chapterId, chapterOrder, evidence, afterValue, more = {}) => ({ + id, factId, projectId: p, characterId: who, chapterId, chapterOrder, evidence, afterValue, beforeValue: null, + status: 'accepted', transactionType: 'update', source: 'manual', reason: '', createdAt: at, updatedAt: at, ...more +}) +function fixture(api) { + const chapters = [chapter('a14', 14, '她关上房门。'), chapter('b99', 99, '别部书保密正文。', 'book-b'), + chapter('a12', 12, '雨停了。\r\n\r\n' + newQuote + '\n她收起钱袋。'), chapter('a10', 10, oldQuote), + chapter('empty', 15, ' '), { ...chapter('archived', 16, '归档。'), archivedAt: at }] + const cash = { id: 'cash', projectId: p, characterId: who, category: 'resource', key: 'cash', label: '余额', + value: 7, valueType: 'number', unit: '文', evidence: newQuote, sourceChapterId: 'a12', sourceChapterOrder: 12, + status: 'active', createdAt: at, updatedAt: at } + return api.normalizeAppData({ + projects: [p, 'book-b'].map(id => ({ id, name: id, createdAt: at, updatedAt: at })), chapters, + characters: [{ id: who, projectId: p, name: '沈宁', abilitiesAndResources: '钱袋里有十文钱。', isMain: true, + lastChangedChapter: 3, createdAt: at, updatedAt: at }, + { id: 'other', projectId: 'book-b', name: '另一角色', createdAt: at, updatedAt: at }], + chapterVersions: [{ id: 'v12', projectId: p, chapterId: 'a12', title: 'a12', source: 'manual_revision', + body: chapters[2].body, createdAt: later }], + characterStateFacts: [cash, { ...cash, id: 'draft-only', key: 'draft', evidence: '草稿私句。', sourceChapterId: 'empty' }], + characterStateTransactions: [ + tx('t12', 'cash', 'a12', 12, newQuote, 7, { beforeValue: 10, source: 'chapter_review', sourceBodyHash: api.draftContentHash(chapters[2].body) }), + tx('t10', 'cash', 'a10', 10, oldQuote, 10), + tx('pending', 'cash', 'a14', 14, '她关上房门。', 400, { status: 'pending' }), + tx('rejected', 'cash', 'a14', 14, '她关上房门。', 500, { status: 'rejected' }), + tx('foreign', 'cash', 'a12', 12, newQuote, 600, { projectId: 'book-b' }), + tx('wrong-owner', 'cash', 'a12', 12, newQuote, 700, { characterId: 'other' })], + generatedChapterDrafts: [{ id: 'draft', projectId: p, chapterId: 'empty', jobId: 'j', body: '草稿私句。', status: 'draft' }], + characterStateChangeCandidates: ['pending', 'rejected'].map(status => ({ id: status, projectId: p, characterId: who, + status, chapterId: 'a14', chapterOrder: 14, proposedFact: { ...cash, id: status, value: 80 }, evidence: '未采纳的提议。' })) + }) +} +const withCard = (data, card) => ({ ...data, characters: data.characters.map(c => c.projectId === card.projectId && c.id === card.id ? card : c) }) +const read = (api, data, n) => api.getCharacterCardReview(data, p, who, n) +function command(api, data, n, patch) { + const r = read(api, data, n) + return { projectId: p, characterId: who, chapterId: r.currentChapter.chapterId, chapterOrder: n, + expectedFingerprint: r.previewFingerprint, ...(patch ? { decision: 'update', patch } : { decision: 'keep' }) } +} + +function verifyDomain(api, data) { + const untouched = structuredClone(data) + assert.deepEqual(api.createCharacterCardReviewReader(data, p).chapters.map(c => c.order), [10, 12, 14]) + assert.equal(read(api, data).status, 'unreviewed') + assert.equal(read(api, data).chaptersSinceReview, null) + assert.equal(read(api, data, 10).latestConfirmedFactChapter.chapterId, 'a10') + assert.deepEqual(read(api, data, 10).evidence.map(r => r.after), [10]) + assert.deepEqual(read(api, data).evidence.map(r => r.transactionId), ['t10', 't12']) + const keep = command(api, data, 10) + const kept = api.applyCharacterCardReview(data, keep, later), checked = withCard(data, kept) + const { lastCardReview, ...rest } = kept + assert.deepEqual(rest, data.characters[0], 'keep does not change the content or last-change metadata') + assert.equal(lastCardReview.chapterId, 'a10') + const r = read(api, checked) + assert.equal(read(api, withCard(checked, { ...kept, lastChangedChapter: 12 })).status, 'reviewed', 'a change-log date alone is not a card edit') + assert.deepEqual([r.status, r.chaptersSinceReview, r.lastChangedChapter, r.latestConfirmedFactChapter.chapterOrder], ['reviewed', 4, 3, 12]) + assert.deepEqual(r.evidence.map(v => [v.transactionId, v.before, v.after, v.chapter.chapterVersionId]), [['t12', 10, 7, 'v12']]) + const loc = r.evidence[0].location, body = data.chapters.find(c => c.id === 'a12').body + assert.equal(loc.paragraph, 2); assert.equal(body.slice(loc.start, loc.end), newQuote) + assert.equal(read(api, checked, 10).evidence.length, 0) + const separate = read(api, { ...checked, characterStateTransactions: checked.characterStateTransactions.filter(t => t.id !== 't12') }) + assert.deepEqual(separate.evidence.map(e => [e.factId, e.transactionId, e.after]), [['cash', null, 7]]) + const changed = api.applyCharacterCardReview(checked, command(api, checked, 14, { abilitiesAndResources: '七文' }), later) + const updated = withCard(checked, changed) + assert.equal(changed.abilitiesAndResources, '七文'); assert.equal(changed.lastChangedChapter, 3) + assert.equal(read(api, updated).status, 'reviewed') + assert.equal(read(api, updated, 10).lastCardReview, null) + assert.throws(() => api.applyCharacterCardReview(updated, command(api, updated, 12), at), /更早/) + assert.throws(() => api.applyCharacterCardReview(checked, keep, at), /已变化/) + for (const bad of [{ projectId: 'book-b' }, { characterId: 'other' }, { chapterId: 'b99' }, { chapterId: 'empty', chapterOrder: 15 }, + { chapterId: 'draft' }, { chapterId: 'archived', chapterOrder: 16 }, { chapterOrder: 0 }, { chapterOrder: 11 }, + { patch: { name: '伪改' } }, { decision: 'update', patch: { projectId: 'book-b' } }, + { decision: 'update', patch: { lastCardReview } }, { decision: 'update', patch: {} }]) { + assert.throws(() => api.applyCharacterCardReview(data, { ...keep, ...bad }, at)) + } + const moved = { ...checked, chapters: checked.chapters.map(c => c.id === 'a10' ? { ...c, order: 11 } : c) } + assert.deepEqual([read(api, moved).lastReviewedChapter.chapterId, read(api, moved).lastReviewedChapter.chapterOrder, + read(api, moved).status, read(api, moved).chaptersSinceReview], ['a10', 11, 'stale', 3]) + assert.throws(() => api.applyCharacterCardReview(moved, keep, at)) + const duplicate = { ...data, chapters: [...data.chapters, chapter('duplicate', 10, oldQuote)] } + assert.throws(() => api.applyCharacterCardReview(duplicate, keep, later)) + const replaced = { ...checked, chapters: checked.chapters.map(c => c.id === 'a10' ? { ...c, id: 'substitute' } : c) } + assert.equal(read(api, replaced).status, 'stale'); assert.equal(read(api, replaced).lastReviewedChapter, null) + const revised = { ...updated, chapters: updated.chapters.map(c => c.id === 'a12' ? { ...c, body: '手改开头。' + c.body } : c) } + assert.equal(read(api, revised).status, 'stale') + assert.ok(!read(api, revised).evidence.some(row => row.transactionId === 't12'), 'superseded extraction cannot borrow the same quote') + const pasted = { ...data, chapterVersions: [...data.chapterVersions, { ...data.chapterVersions[0], + id: 'v12-newer', createdAt: '2026-08-01T00:00:00Z', body: '较晚的正式版本。' }] } + assert.equal(read(api, pasted, 12).currentChapter.chapterVersionId, null, 'pasting old prose is not restoring the old version id') + const retro = { ...updated, characterStateTransactions: [...updated.characterStateTransactions, + tx('late-proof', 'cash', 'a14', 14, '她关上房门。', 4)] } + assert.equal(read(api, retro).status, 'stale'); assert.ok(read(api, retro).evidence.some(e => e.transactionId === 'late-proof')) + const undone = { ...checked, characterStateTransactions: [...checked.characterStateTransactions, + tx('undo', 'cash', 'a12', 12, newQuote, 10, { reversesTransactionIds: ['t12'] })] } + assert.equal(read(api, undone).latestConfirmedFactChapter.chapterOrder, 10) + const withoutId = { ...data, characterStateTransactions: data.characterStateTransactions.map(t => t.id === 't12' ? { ...t, chapterId: null } : t) } + assert.equal(read(api, withoutId).latestConfirmedFactChapter.chapterOrder, 12) + const foreignId = { ...data, characterStateTransactions: data.characterStateTransactions.map(t => t.id === 't12' ? { ...t, chapterId: 'b99' } : t) } + assert.equal(read(api, foreignId).latestConfirmedFactChapter.chapterOrder, 10) + const draftOrigin = { ...data, characterStateChangeCandidates: [...data.characterStateChangeCandidates, + { ...data.characterStateChangeCandidates[0], id: 'draft-origin', targetFactId: 'cash', jobId: 'j', chapterId: 'a12', + chapterOrder: 12, afterValue: 7, status: 'accepted', evidence: newQuote }] } + assert.equal(read(api, draftOrigin).latestConfirmedFactChapter.chapterOrder, 10, 'an accepted proposal from an uncommitted job is not formal evidence') + const posted = { ...draftOrigin, chapterCommitBundles: [{ id: 'receipt', projectId: p, jobId: 'j', chapterId: 'a12', + chapter: data.chapters.find(c => c.id === 'a12'), chapterVersion: data.chapterVersions[0], acceptedAt: later }] } + assert.equal(read(api, posted).latestConfirmedFactChapter.chapterOrder, 12) + const otherDraft = { ...posted, characterStateTransactions: posted.characterStateTransactions.map(t => + t.id === 't12' ? { ...t, sourceBodyHash: undefined } : t) } + assert.equal(read(api, otherDraft).latestConfirmedFactChapter.chapterOrder, 10, 'job identity cannot prove which draft was accepted') + const locked12 = withCard(data, api.applyCharacterCardReview(data, command(api, data, 12), later)) + const restored = { ...locked12, chapterVersions: [...locked12.chapterVersions, { ...locked12.chapterVersions[0], + id: 'restored', baseChapterVersionId: 'v12', createdAt: '2026-05-01T00:00:00Z' }] } + assert.equal(read(api, restored).status, 'stale', 'a same-text restore must not inherit the superseded version review') + const shadow = { ...data, characters: [...data.characters, { ...data.characters[0], projectId: 'book-b', name: '同号外书' }] } + assert.deepEqual(api.applyCharacterCardReview(shadow, keep, later), kept) + const untouchedOther = withCard(shadow, kept).characters.at(-1) + assert.ok(!untouchedOther.lastCardReview && untouchedOther.name === '同号外书') + const secret = ['sk', 'proj', 'fixtureCardReview1234567890'].join('-') + const classified = { ...data, characters: [{ ...data.characters[0], costUsd: 'fixture-cost-private' }], + characterStateFacts: data.characterStateFacts.map(f => f.id === 'cash' ? { ...f, label: secret } : f) } + const publicReply = JSON.stringify(api.getAgentWorldRecord(classified, { entity: 'character', id: who, projectId: p, detail: 'full' })) + assert.ok(publicReply.includes('[REDACTED]') && !publicReply.includes(secret) && !publicReply.includes('fixture-cost-private')) + assert.deepEqual(data, untouched, 'all projections and rejected commands are pure') + assert.equal(api.normalizeAppData(withCard(data, { ...kept, lastCardReview: { chapterOrder: 10 } })).characters[0].lastCardReview, undefined) + console.log('PASS scoped chapters, located facts, explicit decisions, old versions, undone and late facts, reorder and stale previews') +} + +async function verifyStorage(api, data, format, Store) { + const home = join(dir, format) + await mkdir(home) + const path = join(home, 'novel-director-data.' + format) + const store = new Store(path) + try { + await store.save(data) + const initial = await store.loadSnapshot() + store.saveIfCurrent = () => { throw new Error('Bare full save is forbidden in card review') } + const keep = command(api, initial.data, 10) + const result = await store.reviewCharacterCard(keep, initial.revision) + assert.deepEqual(result.savedCollections, ['characters']) + const current = await store.loadSnapshot() + assert.deepEqual(current.data, withCard(initial.data, result.character)) + await assert.rejects(store.reviewCharacterCard(keep, initial.revision), e => e.code === 'STORAGE_REVISION_CONFLICT') + await assert.rejects(store.reviewCharacterCard(keep, undefined), e => e.code === 'STORAGE_REVISION_CONFLICT') + await assert.rejects(store.reviewCharacterCard(keep, current.revision), /已变化/) + const args = { storagePath: path, userDataPath: home, projectId: p, entity: 'character', id: who } + const world = async (extra = {}) => (await api.AgentToolService.callTool({ + name: 'agent.getWorldRecord', arguments: { ...args, ...extra } + })).data + const compact = await world() + const full = await world({ detail: 'full' }) + assert.deepEqual([compact.cardReview.status, compact.cardReview.lastReviewedChapterOrder, compact.cardReview.lastChangedChapter, + compact.cardReview.latestConfirmedFactChapterOrder, compact.cardReview.chaptersSinceReview, compact.cardReview.evidenceCount], + ['reviewed', 10, 3, 12, 4, 1]) + assert.ok(!('evidence' in compact.cardReview)); assert.ok(!JSON.stringify(compact).includes(newQuote)) + assert.deepEqual(full.cardReview.evidence[0].location, read(api, current.data).evidence[0].location) + assert.equal(full.cardReview.evidence[0].excerpt, newQuote) + const short = await world({ detail: 'full', asOfChapterOrder: 10 }) + assert.equal(short.cardReview.latestConfirmedFact.location.start, 0) + assert.ok(!JSON.stringify(short).includes(oldQuote), 'a one-sentence official chapter is never returned as an excerpt in full') + assert.ok(!JSON.stringify(full).includes('草稿私句') && !JSON.stringify(full).includes('别部书保密正文')) + assert.doesNotMatch(JSON.stringify(full), /"(?:usage|cost|costUsd|promptContext|chapterCommitBundles|apiKey|previewFingerprint|basisFingerprint)":/i) + await assert.rejects(world({ projectId: 'book-b' }), /not found/) + await assert.rejects(world({ asOfChapterOrder: -1 })) + assert.equal((await store.loadSnapshot()).revision, current.revision, 'all world reads and failures are read-only') + const edited = await store.reviewCharacterCard(command(api, current.data, 14, { abilitiesAndResources: '只剩七文。' }), current.revision) + assert.equal(edited.character.lastCardReview.decision, 'update') + assert.equal((await world()).cardReview.evidenceCount, 0) + assert.equal((await world({ detail: 'full' })).record.abilitiesAndResources, '只剩七文。') + console.log('PASS ' + format + ': atomic keep/update, conflict rollback, read-only compact/full Agent parity and chapter locators') + } finally { store.close?.() } +} + +async function verifyUI() { + const qa = window.qa + const find = text => [...document.querySelectorAll('button')].find(b => b.textContent.includes(text)) + async function until(fn, msg) { + for (let i = 0; i < 120; i++) { if (fn()) return; await new Promise(resolve => setTimeout(resolve, 20)) } + throw new Error(msg) + } + async function click(text) { + await until(() => find(text) && !find(text).disabled, '按钮不可用:' + text) + find(text).click() + await new Promise(resolve => setTimeout(resolve, 0)) + } + function check(value, msg) { if (!value) throw new Error(msg) } + await until(() => document.querySelector('.character-card-review'), '角色页未加载') + const choose = (id) => { + const el = document.querySelector('.character-card-review select') + el.value = id; el.dispatchEvent(new Event('change', { bubbles: true })) + } + const text = () => document.querySelector('.character-card-review').textContent + check(text().includes('未核对') && !qa.data.characters[0].lastCardReview, '打开页面不应标记核对') + choose('a10'); await click('保留原卡,核对到第 10 章') + await until(() => qa.data.characters[0].lastCardReview?.chapterId === 'a10', '保留原卡未保存') + choose('a14'); await until(() => text().includes('相差 4 章'), '未显示距当前章数') + document.querySelector('.character-review-evidence summary').click() + await click('打开第 12 章') + check(qa.opened === 'a12' && text().includes('第 2 段') && text().includes('她只剩下七文钱。'), '证据定位不正确') + await click('编辑卡片后核对') + async function typeValue(value) { + const areaForResources = () => [...document.querySelectorAll('.character-review-form label')] + .find(l => l.querySelector('.field-label')?.textContent === '能力与资源')?.querySelector('textarea') + await until(areaForResources, '未找到能力与资源编辑框') + const area = areaForResources() + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(area, value) + area.dispatchEvent(new Event('input', { bubbles: true })) + await new Promise(resolve => setTimeout(resolve, 10)) + } + await typeValue('本次取消的修改'); await click('取消本次编辑') + check(!JSON.stringify(qa.data).includes('本次取消的修改'), '取消编辑不能写入') + await click('编辑卡片后核对'); await typeValue('手工确认七文') + await click('确认更新并核对到第 14 章') + await until(() => text().includes('合成写入失败'), '没有显示保存失败') + check(qa.data.characters[0].lastCardReview.chapterId === 'a10', '写入失败仍更新了核对') + await click('确认更新并核对到第 14 章') + await until(() => qa.data.characters[0].lastCardReview.chapterId === 'a14', '重试没有更新') + check(qa.data.characters[0].abilitiesAndResources === '手工确认七文', '表单与核对未原子保存') + check(text().includes('上次核对范围内的最近一条') && text().includes('她只剩下七文钱。'), '完成核对后仍应能查看最近证据') + choose('a10'); await until(() => text().includes('不会回退记录'), '早章不能回退核对') + check(find('保留原卡').disabled, '早章误开放写入') + return 'PASS renderer + IPC: evidence navigation, explicit keep, dedicated fields, cancel, failed save and retry without full save' +} + +async function runUI(data) { + const fixturePath = join(dir, 'index.html') + await build({ stdin: { resolveDir: repoRoot, loader: 'tsx', contents: ` + import {createRoot} from 'react-dom/client'; + import {ConfirmProvider} from './src/renderer/src/components/ConfirmDialog'; + import {CharactersView} from './src/renderer/src/views/CharactersView'; + import {useAppData} from './src/renderer/src/hooks/useAppData'; + const qa = window.qa = {data:null,opened:null}; + function Page(){const {data,saveData,runPersistedStorageOperation}=useAppData(); qa.data=data; + return data && {qa.opened=id}} runPersistedStorageOperation={runPersistedStorageOperation}/>} + createRoot(document.querySelector('main')).render(); + ` }, jsx: 'automatic', bundle: true, format: 'iife', platform: 'browser', loader: { '.png': 'dataurl' }, + define: { 'process.env.NODE_ENV': '"production"' }, outfile: join(dir, 'ui.js') }) + await writeFile(fixturePath, '
') + await build({ entryPoints: [join(repoRoot, 'src/preload/index.ts')], bundle: true, platform: 'node', format: 'cjs', + external: ['electron'], outfile: join(dir, 'preload.cjs') }) + await build({ stdin: { resolveDir: repoRoot, contents: ` + export {registerDataIpcHandlers} from './src/main/ipc/dataIpcHandlers'; + export {JsonStorageService} from './src/storage/JsonStorageService'; + ` }, bundle: true, platform: 'node', format: 'esm', external: ['electron', 'better-sqlite3'], outfile: join(dir, 'ipc.mjs') }) + await writeFile(join(dir, 'ui-book.json'), JSON.stringify(data)) + await writeFile(join(dir, 'verify.cjs'), `const {app,BrowserWindow}=require('electron'); app.disableHardwareAcceleration(); + app.whenReady().then(async()=>{const {JsonStorageService,registerDataIpcHandlers}=await import('./ipc.mjs'); + const storage=new JsonStorageService(require('node:path').join(__dirname,'ui-book.json')); + let writes=0,raw=0,fail=true;const persist=storage.reviewCharacterCard.bind(storage); + storage.saveIfCurrent=()=>{raw++;throw new Error('Bare full save is forbidden')}; + storage.reviewCharacterCard=async(c,r)=>{if(c.decision==='update'&&fail){fail=false;throw new Error('合成写入失败')} + const saved=await persist(c,r);writes++;return saved}; + registerDataIpcHandlers({getStorage:()=>storage,backupService:{maybeCreateAutomaticBackup:async()=>null}, + credentialService:{hasApiKey:async()=>false}}); + const w=new BrowserWindow({show:false,webPreferences:{sandbox:true,backgroundThrottling:false,preload:require('node:path').join(__dirname,'preload.cjs')}}); + await w.loadFile(${JSON.stringify(fixturePath)});console.log(await w.webContents.executeJavaScript(${JSON.stringify('(' + verifyUI.toString() + ')()')})); + require('node:assert/strict').deepEqual([writes,raw],[2,0]);app.exit(0)}).catch(e=>{console.error(e);app.exit(1)})`) + const env = { ...process.env } + delete env.ELECTRON_RUN_AS_NODE + const { stdout } = await promisify(execFile)(createRequire(import.meta.url)('electron'), [join(dir, 'verify.cjs'), + '--user-data-dir=' + join(dir, 'browser'), '--disable-background-networking'], { env, windowsHide: true, timeout: 35000 }) + assert.match(stdout, /PASS renderer/); console.log(stdout.trim()) +} + +try { + const outfile = join(dir, 'domain.mjs') + const modules = ['shared/defaults', 'services/DraftDiagnosticBindingService', 'services/characterState/cardReview', + 'storage/SqliteStorageService', 'storage/JsonStorageService', 'agent/tools/AgentToolService', 'agent/tools/agentWorldTools'] + await build({ stdin: { contents: modules.map(m => `export * from './src/${m}'`).join('\n'), resolveDir: repoRoot }, + bundle: true, platform: 'node', format: 'esm', target: 'node22', external: ['better-sqlite3', 'electron'], outfile }) + const api = await import(pathToFileURL(outfile)) + const data = fixture(api) + verifyDomain(api, data) + await verifyStorage(api, data, 'json', api.JsonStorageService) + await verifyStorage(api, data, 'sqlite', api.SqliteStorageService) + await runUI(data) +} finally { + assert.equal(dirname(resolve(dir)), resolve(base)) + await rm(dir, { recursive: true, force: true, maxRetries: 4, retryDelay: 120 }) +} diff --git a/scripts/validate-character-state-as-of.mjs b/scripts/validate-character-state-as-of.mjs new file mode 100644 index 0000000..757d22a --- /dev/null +++ b/scripts/validate-character-state-as-of.mjs @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +// Synthetic fixtures; the shared desktop/Agent steps run in memory, without a provider or an author library. +globalThis.fetch = () => { throw new Error('Network is disabled in this regression') } +const compiled = await build({ stdin: { resolveDir: repoRoot, contents: ` + export * from './src/services/characterState/stateAsOf' + export * from './src/services/characterState/stateSelection' + export * from './src/services/CharacterStateService' + export * from './src/services/CandidateDecisionService' + export { previewCandidateDecisionUndo } from './src/services/CandidateDecisionUndoService' + export * from './src/services/DraftDiagnosticBindingService' + export * from './src/services/HardCanonAuditService' + export * from './src/shared/defaults' + export * from './src/renderer/src/utils/promptContext' + export * from './src/renderer/src/utils/projectData' + export * from './src/renderer/src/views/generation/pipelineSteps/contextPlanning' + export { runRebuildContextWithPlanStep } from './src/renderer/src/views/generation/pipelineSteps/chapterGeneration' + export { runQualityGateStep } from './src/renderer/src/views/generation/pipelineSteps/qualityCheck' + export { buildChapterAiContext } from './src/renderer/src/views/chapters/chapterAiContext' + export { characterStateFactsPresentInPrompt } from './src/renderer/src/views/generation/contextSelectionTraceRuntime' +` }, write: false, bundle: true, platform: 'node', format: 'esm', target: 'node22' }) +const m = await import('data:text/javascript;base64,' + Buffer.from(compiled.outputFiles[0].contents).toString('base64')) +const project = 'sample', char = 'clerk', day = n => '2022-01-' + String(n).padStart(2, '0') + 'T00:00:00.000Z' +const ch = (order, patch = {}) => ({ id: 'c' + order, projectId: project, order, body: '抄表员核对了表格。', updatedAt: day(1), ...patch }) +const book = [ch(1, { body: '清点之后,账面留有四十文。' }), ch(4), ch(8)] +const fact = (order, value, patch = {}) => ({ id: 'cash', projectId: project, characterId: char, category: 'resource', key: 'cash', + label: '现金余额', unit: '文', valueType: 'number', value, status: 'active', trackingLevel: 'hard', promptPolicy: 'always', + linkedCardFields: ['abilitiesAndResources'], confidence: 1, sourceChapterId: order ? 'c' + order : null, + sourceChapterOrder: order, evidence: '', createdAt: day(1), updatedAt: day(order || 1), ...patch }) +const tx = (order, value, patch = {}) => ({ id: 'tx' + order, projectId: project, characterId: char, factId: 'cash', + chapterId: order ? 'c' + order : null, chapterOrder: order, status: 'accepted', source: 'chapter_review', transactionType: 'update', + beforeValue: null, afterValue: value, delta: null, reason: '', evidence: '账面留有四十文', createdAt: day(order || 1), updatedAt: day(order || 1), ...patch }) +const current = fact(8, 980, { evidence: '仅在第八章变更的说明' }), first = tx(1, 40) +const at = m.projectCharacterStatesAtChapter +const values = (...args) => at(...args).map(f => f.value) +const select = (facts, order, plan = null) => m.getRelevantCharacterStatesForPrompt([char], plan, order, facts) +let checks = 0 +async function test(label, fn) { await fn(); console.log('ok ' + ++checks + ' - ' + label) } + +await test('only matching accepted after-values; future before-values, deltas and proposals cannot establish a balance', () => { + const rows = [tx(8, 980, { beforeValue: 777 }), tx(1, 81, { status: 'pending' }), tx(1, 82, { status: 'rejected' }), + tx(1, null, { beforeValue: 300, delta: -9 }), tx(1, 84, { characterId: 'else' }), tx(1, 85, { projectId: 'else' })] + assert.deepEqual(values([current], rows, 3, book), []) + const before = structuredClone([current, first]) + const [old] = at([current], [...rows, first], 3, book) + assert.deepEqual([old.value, old.evidence, old.sourceChapterOrder, old.sourceChapterId], [40, first.evidence, 1, 'c1']) + assert.deepEqual([current, first], before) + assert.deepEqual(values([current], [tx(1, 0)], 3, book), [0]) +}) +await test('scope includes project, character, category, stable key and unit, not the display name or array order', () => { + const rows = [fact(1, 10), fact(4, 20, { id: 'different-id', label: '改过的名称' }), fact(8, 30), + fact(1, 1, { id: 'silver', unit: '两' }), fact(1, 2, { projectId: 'b' }), fact(1, 3, { key: 'team.cash' }), + fact(1, 4, { category: 'custom' }), fact(1, 5, { characterId: 'someone' })] + assert.deepEqual(values(rows, [], 5).sort((a, b) => a - b), [1, 2, 3, 4, 5, 20]) + assert.deepEqual(at(rows, [], 5), at([...rows].reverse(), [], 5)) + assert.deepEqual(values([fact(1, 10), fact(1, 20, { id: 'disputes' })], [], 5, book), []) +}) +await test('complete, partial and absent chapter directories; known archived and foreign IDs never pass', () => { + assert.deepEqual(values([current], [first], 3, [book[1]]), []) + assert.deepEqual(values([current], [first], 3, [book[1]], 'partial'), [40]) + assert.deepEqual(values([current], [first], 3), [40]) + for (const coverage of ['complete', 'partial']) { + assert.deepEqual(values([current], [first], 3, [{ ...book[0], archivedAt: day(2) }], coverage), []) + assert.deepEqual(values([current], [first], 3, [{ ...book[0], projectId: 'foreign' }], coverage), []) + } + assert.deepEqual(values([current], [tx(1, 40, { chapterId: null })], 3, [book[0], ch(1, { id: 'archive', archivedAt: day(2) })]), []) + assert.deepEqual(values([fact(null, 40, { sourceChapterId: 'c1' })], [], 3), []) +}) +await test('stable chapter ID moves forward and backward, including an order previously beyond the cutoff', () => { + const moved = [ch(2, { id: 'c8', body: book[0].body }), ch(8, { id: 'c1', body: book[0].body })] + assert.deepEqual(at([current], [], 3, moved).map(f => [f.value, f.sourceChapterOrder]), [[980, 2]]) + assert.deepEqual(values([fact(1, 40)], [], 3, moved), []) + const later = fact(9, 111) + assert.deepEqual(values([later], [tx(8, 20), tx(1, 40)], 3, moved), [20]) + assert.deepEqual(values([later], [tx(8, 20)], 3, [ch(2, { id: 'c8', body: '改后没有旧证据。' })]), []) +}) +await test('old prose cannot reappear through the mutable fact; explicitly dated manual settings need not quote a chapter', () => { + const mirror = fact(1, 40, { evidence: first.evidence }) + const revised = [ch(1, { body: '账面已经清空。' })] + assert.deepEqual(values([mirror], [first], 3, revised), []) + assert.deepEqual(values([mirror], [first], 3, [ch(1, { body: '并非账面留有四十文。' })]), []) + assert.deepEqual(values([mirror], [tx(1, 40, { evidence: '现有40' })], 3, [ch(1, { body: '现有400文。' })]), []) + const bound = tx(1, 40, { sourceBodyHash: m.draftContentHash(book[0].body), evidence: '基于该版本的概述' }) + assert.deepEqual(values([mirror], [bound], 3, book), [40]) + assert.deepEqual(values([mirror], [bound], 3, revised), []) + assert.deepEqual(values([mirror], [bound], 3, [book[0], ch(1, { body: '修订稿。', updatedAt: day(2) })]), []) + assert.deepEqual(values([mirror], [{ ...first, source: 'manual', evidence: '作者设定,并非引文。' }], 3, revised), [40]) + assert.deepEqual(values([mirror], [], 3, revised), [40]) + assert.deepEqual(values([current], [tx(1, 99, { chapterId: null, evidence: '' })], 3, []), []) +}) +await test('undated hand settings only for a genuinely new chapter in a complete project directory', () => { + const setting = fact(null, 17), foreign = ch(30, { projectId: 'other' }), archived = ch(31, { archivedAt: day(2) }) + const all = [...book, foreign, archived, ch(10, { body: ' ' })] + for (const target of [1, 3, 8]) assert.deepEqual(values([setting], [], target, all), []) + for (const chapters of [undefined, []]) assert.deepEqual(values([setting], [], 9, chapters, 'partial'), []) + const [now] = at([setting], [], 9, all) + assert.deepEqual([now.value, now.sourceChapterOrder, now.currentAsOfChapter], [17, null, 9]) + assert.equal(select([now], 9).length, 1); assert.equal(select([now], 7).length, 0) + assert.equal(select([{ ...now, promptPolicy: 'manual_only' }], 9).length, 0) + for (const bad of [{ sourceChapterId: 'deleted' }, { status: 'retconned' }, { status: 'inactive' }]) { + assert.deepEqual(values([{ ...setting, ...bad }], [], 9, all), []) + } + assert.deepEqual(values([setting], [tx(12, 17, { source: 'manual' })], 9, all), []) + assert.deepEqual(values([setting], [tx(1, 17, { evidence: '已被删掉的句子' })], 9, all), []) + assert.equal(m.normalizeAppData({ ...m.EMPTY_APP_DATA, characterStateFacts: [now] }).characterStateFacts[0].currentAsOfChapter, undefined) +}) +await test('all selector branches reject future facts before considering priority or relevance', () => { + for (const policy of ['always', 'when_relevant', 'manual_only']) { + for (const plan of [null, { requiredStateFactCategories: { [char]: ['resource'] }, mustCheckContinuity: [] }, + { requiredStateFactCategories: {}, mustCheckContinuity: ['money'] }]) { + assert.deepEqual(select([fact(8, 2, { promptPolicy: policy })], 3, plan), []) + } + } +}) +const library = (extra = {}) => m.normalizeAppData({ ...structuredClone(m.EMPTY_APP_DATA), + projects: [{ id: project, name: '虚构账簿' }, { id: 'other', name: '隔离项目' }], + characters: [{ id: char, projectId: project, name: '小简', isMain: true }], + chapters: book, ...extra }) +await test('manual and candidate updates preserve actual dated rows only; no history on a metadata-only edit or a read', () => { + const svc = m.CharacterStateService, base = library({ characterStateFacts: [fact(1, 40)] }) + const edited = svc.createOrUpdateFact(fact(4, 80, { label: '新余额' }), base) + assert.deepEqual(edited.characterStateTransactions.map(t => t.afterValue), [80, 40]) + assert.deepEqual(at(edited.characterStateFacts, edited.characterStateTransactions, 2, book).map(f => [f.value, f.label]), [[40, '现金余额']]) + const metadata = svc.createOrUpdateFact({ ...edited.characterStateFacts[0], linkedCardFields: ['surfaceGoal'] }, edited) + assert.deepEqual(metadata.characterStateTransactions, edited.characterStateTransactions) + const changedUnit = svc.createOrUpdateFact(fact(8, 3, { unit: '两' }), metadata) + const roundTrip = m.normalizeAppData(JSON.parse(JSON.stringify(changedUnit))) + assert.deepEqual(at(roundTrip.characterStateFacts, roundTrip.characterStateTransactions, 5, book).map(f => [f.value, f.unit]), [[80, '文']]) + assert.deepEqual(at(roundTrip.characterStateFacts, roundTrip.characterStateTransactions, 8, book).map(f => [f.value, f.unit]), [[3, '两']]) + assert.deepEqual(base.characterStateTransactions, []) + const candidate = { id: 'spend', characterId: char, projectId: project, targetFactId: 'cash', candidateType: 'transaction', + chapterId: 'c4', chapterOrder: 4, beforeValue: 40, afterValue: 21, status: 'pending', riskLevel: 'medium', + evidence: book[1].body, confidence: 1, proposedTransaction: tx(4, 21, { sourceBodyHash: m.draftContentHash(book[1].body) }) } + const withCandidate = library({ characterStateFacts: [fact(1, 40)], characterStateChangeCandidates: [candidate] }) + const decisions = m.previewCandidateDecisions(withCandidate, { projectId: project, decisions: [ + { kind: 'character_state', candidateId: 'spend', decision: 'accept' }] }).items + const command = { id: 'decision', projectId: project, decisions, actor: { kind: 'user' }, confirmedHighRisk: true, + reason: '虚构核对', schemaVersion: 1, decidedAt: day(4) } + const accepted = m.applyCandidateDecisionCommand(withCandidate, command).data + assert.deepEqual(m.characterStatesForProjectAtChapter(accepted, project, 3).map(f => f.value), [40]) + assert.deepEqual(m.characterStatesForProjectAtChapter(accepted, project, 5).map(f => f.value), [21]) + const undo = m.previewCandidateDecisionUndo(accepted, { projectId: project, receiptId: command.id }) + const restored = m.applyCandidateDecisionCommand(accepted, { ...command, id: 'undo', decisions: [], + undo: { receiptId: command.id, expectedFingerprint: undo.expectedFingerprint } }).data + assert.deepEqual(m.characterStatesForProjectAtChapter(m.normalizeAppData(restored), project, 5).map(f => f.value), [40]) + assert.ok(restored.characterStateTransactions.some(t => t.afterValue === 21 && t.status === 'accepted')) +}) +await test('resolve ends the interval; invalidated, archived or retired records do not authorize historical resurrection', () => { + const closed = fact(4, 40, { status: 'resolved' }) + assert.deepEqual(values([closed], [first, tx(4, null, { source: 'manual', transactionType: 'resolve' })], 2, book), [40]) + assert.deepEqual(values([closed], [first], 4, book), []) + assert.deepEqual(values([closed], [first, tx(4, 40, { source: 'manual', transactionType: 'update' })], 4, book), []) + for (const status of ['retconned', 'inactive']) assert.deepEqual(values([{ ...current, status }], [first], 3, book), []) + assert.deepEqual(values([current], [first, tx(1, null, { source: 'manual', reversesTransactionIds: [first.id] })], 3, book), []) + assert.deepEqual(values([current], [first, tx(1, null, { source: 'manual', reason: '撤销候选决定 old;补偿记录 undo' })], 3, book), []) + assert.deepEqual(values([fact(1, 40)], [first, tx(4, 82, { source: 'manual', reason: '撤销候选决定 old;补偿记录 undo' })], 5, book), []) +}) +await test('budget replays the last official chapter, not an empty shell; unselected facts can only suppress stale card text', () => { + const p = library().projects[0], target = 4, profile = m.createContextBudgetProfile(project, 'balanced', 8000) + const cost = (chapters, card, facts = []) => { + const data = library({ chapters, characterStateFacts: facts, + characters: [{ id: char, name: '小简', projectId: project, isMain: true, abilitiesAndResources: card }] }) + return m.selectBudgetContext(p, data, target, profile, { characterIds: [char] }).estimatedTokens + } + const cashCard = '余额999文。'.repeat(35) + for (const chapters of [[ch(1), ch(4)], [ch(1), ch(8)]]) { + assert.equal(cost(chapters, cashCard), cost(chapters, '')) + } + const next = [ch(1), ch(4, { body: ' ' }), ch(5, { archivedAt: day(1) }), ch(6, { projectId: 'other' })] + assert.ok(cost(next, cashCard) > cost(next, '')) + const soft = fact(1, 43, { trackingLevel: 'soft', promptPolicy: 'when_relevant' }) + assert.equal(cost(next, cashCard, [soft]), cost(next, ''), 'strip the stale claim but do not add the unselected ledger row') + assert.ok(cost(next, cashCard, [{ ...soft, promptPolicy: 'always' }]) > cost(next, '')) +}) +await test('conditional HardCanon gates use an independent earlier observation, not a future or revised quotation', () => { + const d = library() + const rule = { id: 'silent', projectId: project, title: '昏迷时不能开口', content: '', status: 'active', + check: { kind: 'actor_action', actor: '小简', characterId: char, state: '昏迷', actions: ['开口'], effect: 'forbid', enforcement: 'block' } } + d.hardCanonPacks = [{ id: 'rules', projectId: project, items: [rule] }] + d.characterStateFacts = [fact(8, '清醒', { category: 'physical', valueType: 'text', label: '意识', key: 'alert', unit: '' })] + d.chapters[0].body = '小简陷入昏迷。' + d.characterStateTransactions = [tx(1, '昏迷', { evidence: d.chapters[0].body })] + const audit = () => m.auditHardCanon(d, { projectId: project, chapterOrder: 3, body: '小简开口。' }).findings.map(f => f.severity) + assert.deepEqual(audit(), ['block']) + d.chapters[0].body = '小简正在抄表。'; assert.deepEqual(audit(), ['review']) +}) +await test('desktop and Agent shared pipeline: automatic and rebuilt prompt, final Trace and quality input agree', async () => { + const data = library({ characterStateFacts: [current, fact(1, 721, { projectId: 'other', id: 'foreign', label: '异书账户' })], + characterStateTransactions: [first, tx(2, 111, { chapterId: 'missing', source: 'manual' })] }) + const p = data.projects[0], scoped = m.projectData(data, p.id), target = 3 + const job = { id: 'job', projectId: p.id, targetChapterOrder: target, contextSource: 'auto', pipelineMode: 'standard' } + const options = { targetChapterOrder: target, estimatedWordCount: '900', readerEmotionTarget: '好奇', budgetMode: 'balanced', budgetMaxTokens: 6000 } + const state = { working: data, context: '', contextNeedPlan: null, contextNeedPlanFromPlan: null, plan: null } + const ctx = { job, options, state, step: { id: 'step', jobId: job.id, type: 'build_context' }, snapshot: null, + activeStoryDirectionGuide: null, storyDirectionTracePatch: {}, env: { + project: p, scoped, updateStepInData: d => d, getAiSettings: () => data.settings, runId: 'dry-run' + } } + m.runContextNeedPlanningStep(ctx) + m.runContextBudgetSelectionStep(ctx) + const direct = m.buildPipelineContext(p, data, target, '好奇', '900') + assert.match(direct, /现金余额:40 文/); assert.doesNotMatch(direct, /异书账户|980 文|111 文/) + const chapterAI = m.buildChapterAiContext({ ...scoped, project: p, selectedChapter: ch(target), + characterStateFacts: m.characterStatesForProjectAtChapter(data, project, target) }) + assert.match(chapterAI, /现金余额:40 文/); assert.doesNotMatch(chapterAI, /异书账户|980 文|111 文/) + for (const buildPrompt of [m.runBuildContextStep, m.runRebuildContextWithPlanStep]) { + buildPrompt(ctx) + assert.match(state.context, /现金余额:40 文/); assert.doesNotMatch(state.context, /异书账户|980 文|111 文/) + assert.deepEqual(state.working.generationRunTraces[0].includedCharacterStateFactIds, ['cash']) + const wrong = state.context.replace('现金余额:40 文', '现金余额:400 文') + assert.deepEqual(m.characterStateFactsPresentInPrompt(wrong, at([current], [first], target, book), scoped.characters), []) + } + state.draftResult = { title: '核表', body: '小简花费 60 文。' } + const stop = new Error('fixture received gate input') + ctx.env.diagnostics = { auditNovelty: async () => ({}), evaluateQualityGate: async request => { + assert.deepEqual(request.characterStateFacts.map(f => f.value), [40]) + assert.ok(m.CharacterStateService.validateCharacterStateInText(request.chapterDraft.body, request.characterStateFacts, scoped.characters) + .some(i => i.type === 'resource_underflow')); throw stop + } } + await assert.rejects(m.runQualityGateStep(ctx), error => error === stop) +}) +console.log('Character state as-of: ' + checks + ' groups passed (synthetic, offline).') diff --git a/scripts/validate-character-state-ledger.mjs b/scripts/validate-character-state-ledger.mjs index 67790da..6645b54 100644 --- a/scripts/validate-character-state-ledger.mjs +++ b/scripts/validate-character-state-ledger.mjs @@ -1,79 +1,20 @@ -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import ts from 'typescript' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { build } from 'esbuild' import { repoRoot } from './utils/repo-root.mjs' const root = repoRoot -const outDir = join(root, 'tmp', 'character-state-ledger-test') const timestamp = '2026-01-01T00:00:00.000Z' function assert(condition, message, details = {}) { return condition ? { ok: true, message } : { ok: false, message, details } } -function rewriteRelativeImports(source) { - return source.replace(/(from\s+['"])(\.{1,2}\/[^'"]+?)(['"])/g, (_match, prefix, specifier, suffix) => { - if (/\.(mjs|js|json)$/.test(specifier)) return `${prefix}${specifier}${suffix}` - if (specifier === './normalizers' || specifier.endsWith('/normalizers')) return `${prefix}${specifier}/index.mjs${suffix}` - return `${prefix}${specifier}.mjs${suffix}` - }) -} - -async function compileTsTree(files) { - await rm(outDir, { recursive: true, force: true }) - for (const relativePath of files) { - const source = await readFile(join(root, relativePath), 'utf-8') - const compiled = ts.transpileModule(source, { - compilerOptions: { - module: ts.ModuleKind.ES2022, - target: ts.ScriptTarget.ES2022, - useDefineForClassFields: true - } - }) - const outPath = join(outDir, relativePath).replace(/\.tsx?$/, '.mjs') - await mkdir(dirname(outPath), { recursive: true }) - await writeFile(outPath, rewriteRelativeImports(compiled.outputText), 'utf-8') - } -} - async function loadModules() { - await compileTsTree([ - 'src/services/CharacterStateService.ts', - 'src/services/characterState/logInference.ts', - 'src/services/characterState/stateMutations.ts', - 'src/services/characterState/stateSelection.ts', - 'src/services/characterState/stateValidation.ts', - 'src/services/characterState/stateValue.ts', - 'src/shared/defaults.ts', - 'src/shared/defaults/index.ts', - 'src/shared/normalizers/index.ts', - 'src/shared/normalizers/common.ts', - 'src/shared/normalizers/agent.ts', - 'src/shared/normalizers/appData.ts', - 'src/shared/normalizers/candidateDecision.ts', - 'src/shared/normalizers/candidateDecisionHistory.ts', - 'src/shared/normalizers/worldManagement.ts', - 'src/shared/normalizers/quickRewrite.ts', - 'src/shared/normalizers/characterState.ts', - 'src/shared/normalizers/context.ts', - 'src/shared/normalizers/contextBudget.ts', - 'src/shared/normalizers/contextNeedPlan.ts', - 'src/shared/normalizers/contextPrimitives.ts', - 'src/shared/normalizers/contextSelection.ts', - 'src/shared/normalizers/pipelineRunConfig.ts', - 'src/shared/normalizers/continuity.ts', - 'src/shared/normalizers/foreshadowing.ts', - 'src/shared/normalizers/generation.ts', - 'src/shared/normalizers/memoryUpdate.ts', - 'src/shared/normalizers/reports.ts', - 'src/shared/normalizers/runTrace.ts', - 'src/shared/normalizers/storyDirection.ts', - 'src/shared/foreshadowingTreatment.ts' - ]) - const service = await import(`${pathToFileURL(join(outDir, 'src/services/CharacterStateService.mjs')).href}?t=${Date.now()}`) - const defaults = await import(`${pathToFileURL(join(outDir, 'src/shared/defaults.mjs')).href}?t=${Date.now()}`) - return { ...service, ...defaults } + const { outputFiles } = await build({ stdin: { resolveDir: root, contents: + "export * from './src/services/CharacterStateService'; export * from './src/shared/defaults';" }, + bundle: true, write: false, format: 'esm', platform: 'node', target: 'node22' }) + return import('data:text/javascript;base64,' + Buffer.from(outputFiles[0].contents).toString('base64')) } function baseData(overrides = {}) { diff --git a/scripts/validate-context-need-planner.mjs b/scripts/validate-context-need-planner.mjs index 3a47f47..ef1f927 100644 --- a/scripts/validate-context-need-planner.mjs +++ b/scripts/validate-context-need-planner.mjs @@ -1,64 +1,17 @@ -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import ts from 'typescript' +import { loadDomainModules } from './utils/load-domain-modules.mjs' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' import { repoRoot } from './utils/repo-root.mjs' const root = repoRoot -const outDir = join(root, 'tmp', 'context-need-planner-test') const timestamp = '2026-01-01T00:00:00.000Z' function assert(condition, message, details = {}) { return condition ? { ok: true, message } : { ok: false, message, details } } -function rewriteRelativeImports(source) { - return source.replace(/(from\s+['"])(\.{1,2}\/[^'"]+?)(['"])/g, (_match, prefix, specifier, suffix) => { - if (/\.(mjs|js|json)$/.test(specifier)) return `${prefix}${specifier}${suffix}` - return `${prefix}${specifier}.mjs${suffix}` - }) -} - -async function compileTsTree(files) { - await rm(outDir, { recursive: true, force: true }) - for (const relativePath of files) { - const source = await readFile(join(root, relativePath), 'utf-8') - const compiled = ts.transpileModule(source, { - compilerOptions: { - module: ts.ModuleKind.ES2022, - target: ts.ScriptTarget.ES2022, - useDefineForClassFields: true - } - }) - const outPath = join(outDir, relativePath).replace(/\.tsx?$/, '.mjs') - await mkdir(dirname(outPath), { recursive: true }) - await writeFile(outPath, rewriteRelativeImports(compiled.outputText), 'utf-8') - } -} - -async function loadPlannerModules() { - await compileTsTree([ - 'src/shared/chapterText.ts', - 'src/shared/foreshadowingTreatment.ts', - 'src/shared/defaults/index.ts', - 'src/services/StageSummaryService.ts', - 'src/services/StoryDirectionService.ts', - 'src/services/contextNeedPlanner/rules.ts', - 'src/services/contextNeedPlanner/characterInference.ts', - 'src/services/contextNeedPlanner/characterNeeds.ts', - 'src/services/ContextNeedPlannerService.ts', - 'src/renderer/src/utils/format.ts', - 'src/renderer/src/views/promptBuilder/promptBuilderNeedPlan.ts', - 'src/renderer/src/views/promptBuilder/promptBuilderHistoryState.ts', - 'src/renderer/src/views/promptBuilder/promptBuilderSnapshotConsistency.ts', - 'src/renderer/src/utils/foreshadowingRecommendations.ts' - ]) - const cacheKey = Date.now() - const planner = await import(`${pathToFileURL(join(outDir, 'src/services/ContextNeedPlannerService.mjs')).href}?t=${cacheKey}`) - const editor = await import(`${pathToFileURL(join(outDir, 'src/renderer/src/views/promptBuilder/promptBuilderNeedPlan.mjs')).href}?t=${cacheKey}`) - const history = await import(`${pathToFileURL(join(outDir, 'src/renderer/src/views/promptBuilder/promptBuilderHistoryState.mjs')).href}?t=${cacheKey}`) - const recommendations = await import(`${pathToFileURL(join(outDir, 'src/renderer/src/utils/foreshadowingRecommendations.mjs')).href}?t=${cacheKey}`) - return { ...planner, ...editor, ...history, ...recommendations } +function loadPlannerModules() { + return loadDomainModules('services/ContextNeedPlannerService', 'renderer/src/views/promptBuilder/promptBuilderNeedPlan', 'renderer/src/views/promptBuilder/promptBuilderHistoryState', 'renderer/src/utils/foreshadowingRecommendations') } function character(id, name, overrides = {}) { diff --git a/scripts/validate-context-relevance-scoring.mjs b/scripts/validate-context-relevance-scoring.mjs index 65f761e..07a2e91 100644 --- a/scripts/validate-context-relevance-scoring.mjs +++ b/scripts/validate-context-relevance-scoring.mjs @@ -1,56 +1,15 @@ -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import ts from 'typescript' +import { loadDomainModules } from './utils/load-domain-modules.mjs' import { repoRoot } from './utils/repo-root.mjs' const root = repoRoot -const outDir = join(root, 'tmp', 'context-relevance-scoring-test') const timestamp = '2026-01-01T00:00:00.000Z' function assert(condition, message, details = {}) { return condition ? { ok: true, message } : { ok: false, message, details } } -function rewriteRelativeImports(source) { - return source.replace(/(from\s+['"])(\.{1,2}\/[^'"]+?)(['"])/g, (_match, prefix, specifier, suffix) => { - if (/\.(mjs|js|json)$/.test(specifier)) return `${prefix}${specifier}${suffix}` - return `${prefix}${specifier}.mjs${suffix}` - }) -} - -async function compileTsTree(files) { - await rm(outDir, { recursive: true, force: true }) - for (const relativePath of files) { - const source = await readFile(join(root, relativePath), 'utf-8') - const compiled = ts.transpileModule(source, { - compilerOptions: { - module: ts.ModuleKind.ES2022, - target: ts.ScriptTarget.ES2022, - useDefineForClassFields: true - } - }) - const outPath = join(outDir, relativePath).replace(/\.tsx?$/, '.mjs') - await mkdir(dirname(outPath), { recursive: true }) - await writeFile(outPath, rewriteRelativeImports(compiled.outputText), 'utf-8') - } -} - -async function loadModules() { - await compileTsTree([ - 'src/shared/foreshadowingTreatment.ts', - 'src/services/TokenEstimator.ts', - 'src/services/StageSummaryService.ts', - 'src/services/ContextCompressionService.ts', - 'src/services/contextBudget/types.ts', - 'src/services/contextBudget/scoringEngine.ts', - 'src/services/contextBudget/selectionEngine.ts', - 'src/services/contextBudget/selectionFinalizer.ts', - 'src/services/contextBudget/traceBuilder.ts', - 'src/services/ChapterLifecycleService.ts', - 'src/services/ContextBudgetManager.ts' - ]) - return import(`${pathToFileURL(join(outDir, 'src/services/ContextBudgetManager.mjs')).href}?t=${Date.now()}`) +function loadModules() { + return loadDomainModules('services/ContextBudgetManager') } function makeProject() { diff --git a/scripts/validate-draft-fragments.mjs b/scripts/validate-draft-fragments.mjs new file mode 100644 index 0000000..9d1deb7 --- /dev/null +++ b/scripts/validate-draft-fragments.mjs @@ -0,0 +1,229 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +const bundle = await build({ + stdin: { + contents: [ + "export { getTruncatedDraftFragment } from './src/services/ChapterDraftFragmentService'", + "export { isIncompleteChapterEnvelope, isTruncatedFinishReason, ensureChapterDraft, rawTextAsChapterDraft } from './src/services/ai/responseNormalizers/chapter'", + "export { getChapterDraftEnvelopeError } from './src/services/ChapterDraftEnvelopeService'", + "export { validateChapterDraftSchema } from './src/services/ai/AISchemaValidator'", + "export { parseAiResponsePayload } from './src/main/services/AIResponseParser'", + "export { MainAIJsonClient } from './src/main/services/MainAIJsonClient'", + "export { AIClient } from './src/services/ai/AIClient'" + ].join('\n'), + resolveDir: repoRoot, loader: 'ts' + }, + bundle: true, write: false, platform: 'node', format: 'esm', target: 'node22', logLevel: 'silent' +}) +const { getTruncatedDraftFragment, isTruncatedFinishReason, isIncompleteChapterEnvelope, getChapterDraftEnvelopeError, + ensureChapterDraft, rawTextAsChapterDraft, validateChapterDraftSchema, parseAiResponsePayload, MainAIJsonClient, AIClient +} = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString('base64')}`) + +const title = '雨棚' +const fallbackTitle = '第七章' +const prose = '  她推开木窗。\r\n\r\n“还在下吗?”' +const numberedProse = ['广播显示 [07:30],门仍关着。', '[7号站台] 他停了下来', '[7], he read, and lowered the page.'] +const raw = JSON.stringify({ title, body: prose }) +const limited = ['length', 'max_tokens', 'max-tokens', 'maxTokens', 'token_limit', 'max_output_tokens', + 'MAX_COMPLETION_TOKENS', 'model_length', 'token-limit-reached'] +const other = [undefined, '', 'stop', 'end_turn', 'content_filter', 'refusal', 'timeout', 'cancelled', + 'context_length_exceeded', 'not_max_tokens', 'length/content_filter', 'stop: max_tokens', 'lengthy'] +const response = (rawText, finishReason, extra) => Object.freeze({ ok: false, usedAI: true, data: null, rawText, finishReason, ...extra }) +const expected = (body, finishReason = 'length', name = fallbackTitle) => + ({ kind: 'incomplete_chapter_fragment', title: name, body, finishReason }) +const extract = (rawText) => getTruncatedDraftFragment(response(rawText, 'length'), fallbackTitle) +const payload = (finish_reason, message) => parseAiResponsePayload({ choices: [{ finish_reason, message }] }) + +test('only explicit provider limit codes qualify, independently of punctuation', () => { + for (const reason of limited) assert.equal(isTruncatedFinishReason(` ${reason} `), true, reason) + for (const reason of other) assert.equal(isTruncatedFinishReason(reason), false, String(reason)) + for (const ending of ['。', '……', '——', '”', '!', '']) { + const body = '他停在开口前' + ending + for (const reason of [...limited, ...other]) { + assert.deepEqual(getTruncatedDraftFragment(response(body, reason), fallbackTitle), + limited.includes(reason) ? expected(body, reason) : null, `finish_reason=${reason}; ending=${ending}`) + } + } +}) + +test('only rawText and uncontradicted provider metadata are evidence', () => { + const telemetry = Object.freeze({ finishReason: 'max_output_tokens', terminationCategory: 'none' }) + assert.deepEqual(getTruncatedDraftFragment(response(raw, undefined, { telemetry }), fallbackTitle), + expected(prose, 'max_output_tokens', title)) + for (const input of [ + response(raw, 'stop', { telemetry }), response(raw, 'content_filter', { telemetry }), + response(raw, 'length', { telemetry: { finishReason: 'content_filter' } }), + response(raw, 'length', { telemetry: { terminationCategory: 'cancelled' } }), + response(raw, 'length', { telemetry: { terminationCategory: 'timeout' } }), + response(raw, undefined, { error: 'finish_reason=length', parseError: 'max_tokens' }), + response(raw, 'length', { usedAI: false }), + response(undefined, 'length', { data: { title, body: prose }, error: prose }), + response({ body: prose }, 'length'), response('', 'length') + ]) assert.equal(getTruncatedDraftFragment(input, fallbackTitle), null) +}) + +test('recover only a whole JSON value or a closed body string; preserve its exact contents', () => { + const body = '她写下 "A"、{B} 和 C:\\书页。\n  雨停了。' + const json = JSON.stringify({ title, body }) + for (const sample of [json, json.slice(0, -1), json.slice(0, -1) + ' ,', `\`\`\`json\n${json}\n\`\`\``]) { + assert.deepEqual(extract(sample), expected(body, 'length', title)) + } + assert.deepEqual(extract('{"body":"第一行\n第二行"}'), expected('第一行\n第二行')) + assert.deepEqual(extract(JSON.stringify({ chapterTitle: title, chapterBody: body })), expected(body, 'length', title)) + assert.deepEqual(extract(JSON.stringify(body)), expected(body)) + assert.deepEqual(extract(JSON.stringify({ body, title: '思考' })), expected(body, 'length', '思考')) + assert.deepEqual(getTruncatedDraftFragment(response('{"body":"雨停了。"}', 'length'), ' '), + expected('雨停了。', 'length', '未命名章节')) +}) + +test('plain prose, dialogue and in-story lists are not rewritten or classified by vocabulary', () => { + for (const body of [ + prose, ...numberedProse, '林舟把写着“本章目标”的纸揉成一团。', '“推理过程?”她问。', + '"Wait," she said. "Leave the light on."', + 'Analysis was never his forte. He put the outline in the drawer.', + '他在便条上列了两行:\n1. 旧钥匙\n2. 蓝手套', + '- Bread\n- Milk\nAt the foot of the list he wrote her name.', + '[Tuesday]\nShe had come back for the notebook.' + ]) assert.deepEqual(extract(body), expected(body)) + assert.deepEqual(extract(`\`\`\`text\n${prose}\n\`\`\``), expected(prose)) +}) + +test('reject half strings, bad escapes, arrays, conflicting fields and JSON debris', () => { + for (const sample of [ + '', ' ', '……', 'null', 'true', '1e5', '{', '{"tit', '{"title":"只有标题"}', + '{"title":"雨棚","body":', '{"body":"木窗开了', '{"body":"木窗\\', '{"body":"木窗\\u4e', + '{"body":"她说:"等等"。"}', '{"body":"雨停了。",}', '{"body":"雨停了。"} trailing', + raw + raw, '提示:' + raw, '{"plan":' + raw, JSON.stringify([JSON.parse(raw)]), + '{"body":null}', '{"body":23}', '{"body":["雨停了。"]}', '{"body":{"text":"雨停了。"}}', + '{"body":"雨停了。","chapterText":"另一段。"}', '{"type":"reasoning","text":"雨停了。"}', + '{"body":"[\\"field\\"]"}', 'title', 'chapterBody', '"body"', '"body":"不是纯正文"', + 'title, body', '她推开木窗。\n"chapterGoal": "写到这里"', + '\x60\x60\x60json\n' + raw, '\x60\x60\x60json\n雨停了。\n\x60\x60\x60' + ]) assert.equal(extract(sample), null, sample) +}) + +test('task, outline, reasoning and refusal envelopes never become prose, even inside body', () => { + for (const text of [ + '【本章目标】\n找到钥匙。', '## 章节大纲\n1. 找到钥匙', '- 角色节拍:推开窗', + 'chapterGoal: find the key', '推理过程:先安排一次停电。', '泄漏的内部推理\n雨停了。', + '<|channel|>analysis<|message|>not final prose', '[analysis]\nThe key is missing.', + 'reasoning_content: arrange a delay', 'Content filter: blocked' + ]) { + assert.equal(extract(text), null, text) + assert.equal(extract(JSON.stringify({ title, body: text })), null, text) + } + assert.equal(extract(JSON.stringify({ refusal: prose })), null) +}) + +test('provider content filtering and typed private parts cannot be recovered as public text', () => { + for (const reply of [ + payload('content_filter', { content: raw }), payload('length', { content: raw, refusal: 'refused' }), + payload('length', { content: [{ type: 'text', text: raw }, { type: 'refusal', text: 'refused' }] }), + payload('length', { content: [{ type: 'reasoning', text: prose }] }) + ]) { + assert.equal(reply.result.ok, false) + assert.equal(reply.result.content, undefined) + assert.equal(reply.canRetryWithoutResponseFormat, false) + } + const mixed = payload('max_output_tokens', { content: [ + { type: 'thinking', thinking: 'private note' }, { type: 'reasoning', text: 'private note' }, + { type: 'text', text: raw.slice(0, 6) }, { type: 'output_text', text: raw.slice(6) } + ] }) + assert.equal(mixed.result.content, raw) + assert.equal(mixed.diagnostics.reasoningCharacters, 2 * 'private note'.length) + assert.equal(payload('stop', { content: '' }).canRetryWithoutResponseFormat, true) +}) + +const brokenEnvelopes = [ + '[' + raw + ',', '[' + raw, '[' + raw + '}', '[' + raw + ',]', '{"items":[' + raw + '],', + '章节如下:\n\x60\x60\x60json\n[' + raw + ',\n\x60\x60\x60\n以上。', '\x60\x60\x60json\n' + raw, + raw + ']', raw + '\n[', '{"body":"雨停了。"', '{"body":"雨停了,\\' +] +const wrappedBody = '她把 ]、}、" 和 \\ 写在纸上。\n屋里的人念了三遍 \x60\x60\x60。' +const wrappedRaw = JSON.stringify({ title, body: wrappedBody }) +const fullEnvelopes = [ + wrappedRaw, '以下是章节:\n' + wrappedRaw + '\n以上。', + '以下是章节:\n\x60\x60\x60json\n' + wrappedRaw + '\n\x60\x60\x60\n以上。', + '结果:[' + wrappedRaw + ']\n以上。', wrappedRaw.replace('\\n', '\n') +] + +test('an injected ok + data cannot establish that the raw chapter envelope is complete', () => { + for (const rawText of brokenEnvelopes) { + const injected = { ok: true, usedAI: true, data: { title, body: prose }, rawText } + assert.equal(isIncompleteChapterEnvelope(injected.rawText), true, rawText) + assert.match(getChapterDraftEnvelopeError(injected.rawText), /JSON/) + } + for (const text of [...fullEnvelopes, '[' + raw + ']', prose, ...numberedProse, '[Tuesday]\nA letter arrived.']) { + assert.equal(isIncompleteChapterEnvelope(text), false, text) + assert.equal(getChapterDraftEnvelopeError(text), null) + } + assert.equal(getChapterDraftEnvelopeError(undefined), null) +}) + +for (const Client of [AIClient, MainAIJsonClient]) { + test(`${Client.name}: stop or absent reason cannot promote a sliced inner chapter`, async () => { + let wire + const client = new Client({ apiProvider: 'local' }, { chatCompletion: async () => wire }) + const asProse = (text) => rawTextAsChapterDraft(text, fallbackTitle) + for (const finishReason of ['stop', undefined]) { + for (const content of brokenEnvelopes) { + wire = { ok: true, content, finishReason } + const result = await client.requestJson('', '', ensureChapterDraft, null, asProse, validateChapterDraftSchema) + assert.equal(result.ok, false, content) + assert.equal(result.data, null) + assert.equal(result.rawText, content) + assert.equal(result.finishReason, finishReason) + assert.equal(result.parseError, getChapterDraftEnvelopeError(content)) + assert.equal(getTruncatedDraftFragment(result, fallbackTitle), null) + } + } + wire = { ok: true, content: '[' + raw + ',', finishReason: 'stop' } + let fallbackCalls = 0 + const fallback = () => { fallbackCalls++; return { title, body: prose } } + assert.equal((await client.requestJson('', '', ensureChapterDraft, null, fallback)).ok, false) + assert.equal((await client.requestJson('', '', (v) => v, null, fallback, validateChapterDraftSchema)).ok, false) + assert.equal(fallbackCalls, 0) + for (const content of fullEnvelopes) { + wire = { ok: true, content, finishReason: 'stop' } + const result = await client.requestJson('', '', ensureChapterDraft, null, asProse, validateChapterDraftSchema) + assert.equal(result.ok, true, content) + assert.deepEqual(result.data, { title, body: wrappedBody }) + } + for (const finishReason of ['stop', undefined]) { + for (const content of [prose, ...numberedProse]) { + wire = { ok: true, content, finishReason } + const result = await client.requestJson('', '', ensureChapterDraft, null, asProse, validateChapterDraftSchema) + assert.equal(result.ok, true, content) + assert.equal(result.data.body, content.trim()) + } + } + wire = { ok: true, content: '[{"summary":"雨停了。"},', finishReason: 'stop' } + assert.deepEqual((await client.requestJson('', '', (v) => v, null)).data, { summary: '雨停了。' }) + }) + + test(`${Client.name}: provider limit survives the parser and client as failed, never accepted`, async () => { + let reply + const client = new Client({ apiProvider: 'local' }, { chatCompletion: async () => reply.result }) + for (const reason of limited) { + reply = payload(reason, { content: [{ type: 'text', text: raw }], reasoning_content: 'not output' }) + const result = await client.requestJson('', '', assert.fail, null, assert.fail) + assert.equal(result.ok, false) + assert.equal(result.data, null) + assert.equal(result.rawText, raw) + assert.equal(result.finishReason, reason) + assert.deepEqual(getTruncatedDraftFragment(Object.freeze(result), fallbackTitle), expected(prose, reason, title)) + reply = payload(reason, { content: '', reasoning_content: 'not output' }) + assert.equal(reply.canRetryWithoutResponseFormat, false) + assert.match(reply.result.error, /Max Tokens/) + assert.equal(getTruncatedDraftFragment(await client.requestJson('', '', assert.fail, null), fallbackTitle), null) + } + reply = payload('stop', { content: raw }) + const complete = await client.requestJson('', '', (value) => value, null) + assert.equal(complete.ok, true) + assert.deepEqual(complete.data, { title, body: prose }) + assert.equal(getTruncatedDraftFragment(complete, fallbackTitle), null) + }) +} diff --git a/scripts/validate-hard-canon-authoring.mjs b/scripts/validate-hard-canon-authoring.mjs new file mode 100644 index 0000000..cb28017 --- /dev/null +++ b/scripts/validate-hard-canon-authoring.mjs @@ -0,0 +1,271 @@ +import assert from 'node:assert/strict' +import { execFile, spawnSync } from 'node:child_process' +import { createServer } from 'node:http' +import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +const tmp = join(repoRoot, 'tmp') +await mkdir(tmp, { recursive: true }) +const work = await mkdtemp(join(tmp, 'hard-canon-authoring-')) +const require = createRequire(import.meta.url) +const at = '2026-11-01T09:00:00.000Z' +const literal = { kind: 'literal', mode: 'terms', terms: ['暮纹'], enforcement: 'block' } +const actor = { kind: 'actor_action', actor: '顾迟', characterId: 'c1', aliases: ['阿迟'], actions: ['扣紧'], side: '右', + objects: ['袖扣'], state: '受伤', effect: 'forbid', enforcement: 'block', fromChapter: 2, toChapter: 5 } +const source = { kind: 'agent_director', reason: '虚构的作者录入验收', requestedAt: at } +let checks = 0 +const passed = (name) => { checks++; console.log('PASS ' + name) } + +async function bundles() { + const options = { bundle: true, platform: 'node', format: 'esm', target: 'node22', + external: ['better-sqlite3', 'electron'], logLevel: 'silent' } + await build({ ...options, outfile: join(work, 'api.mjs'), stdin: { resolveDir: repoRoot, contents: ` + export { normalizeAppData } from './src/shared/defaults' + export { parseHardCanonCheck, normalizeHardCanonCheck } from './src/shared/normalizers/hardCanonCheck' + export { HardCanonPackService } from './src/services/HardCanonPackService' + export { JsonStorageService } from './src/storage/JsonStorageService' + export { SqliteStorageService } from './src/storage/SqliteStorageService' + export { AgentAuthorizationService } from './src/main/services/AgentAuthorizationService'` } }) + await build({ ...options, entryPoints: [join(repoRoot, 'src/agent/mcp/server.ts')], outfile: join(work, 'mcp.mjs'), + banner: { js: 'globalThis.fetch = () => { throw new Error("Model/network calls forbidden in fixture") }' }, + plugins: [{ name: 'no-model', setup(builder) { + builder.onResolve({ filter: /AgentPipelineExecutor$/ }, () => ({ path: 'disabled', namespace: 'no-model' })) + builder.onLoad({ filter: /.*/, namespace: 'no-model' }, () => ({ contents: ` + export function executeAgentChapterPipeline() { throw new Error('Model disabled') } + export const createHeadlessTransport = executeAgentChapterPipeline;` })) + } }] }) + await build({ entryPoints: [join(repoRoot, 'scripts/utils/hard-canon-editor-fixture.tsx')], outfile: join(work, 'ui.js'), + bundle: true, format: 'iife', globalName: 'authorFixture', jsx: 'automatic', loader: { '.png': 'dataurl' }, + define: { 'process.env.NODE_ENV': '"production"' } }) + await build({ ...options, format: 'cjs', outfile: join(work, 'window.cjs'), stdin: { contents: ` + const {app, BrowserWindow, session} = require('electron'); + app.disableHardwareAcceleration(); + app.whenReady().then(async () => { + const url = process.argv.find(arg => arg.startsWith('http://127.0.0.1:')); + session.defaultSession.webRequest.onBeforeRequest((req, done) => done({ cancel: !req.url.startsWith(url + '/') })); + const window = new BrowserWindow({ show: false, webPreferences: { sandbox: true, backgroundThrottling: false } }); + await window.loadURL(url + '/'); + const result = await window.webContents.executeJavaScript('authorFixture.verifyDesktop()'); + console.log('DESKTOP_WRITES=' + result.writes); + app.exit(0); + }).catch(error => { console.error(error); app.exit(1); });` } }) + return import(pathToFileURL(join(work, 'api.mjs')).href) +} + +function item(id, title, overrides = {}) { + return { id, projectId: 'p1', title, content: '旧版的文字说明。', category: 'world_rule', priority: 'must', + status: 'active', sourceType: 'manual', sourceId: null, createdAt: at, updatedAt: at, ...overrides } +} +function fixture(api) { + return api.normalizeAppData({ + projects: ['p1', 'p2'].map((id) => ({ id, name: '合成样书' + id, createdAt: at, updatedAt: at })), + characters: [{ id: 'c1', projectId: 'p1', name: '顾迟' }, { id: 'c2', projectId: 'p2', name: '顾迟' }], + hardCanonPacks: [{ id: 'pack-1', projectId: 'p1', items: [ + item('legacy', '旧纸条'), item('bad', '坏条件', { check: { ...literal, maxPerBook: -1 } }) + ] }] + }) +} + +function rpc(method, params) { + const child = spawnSync(process.execPath, [join(work, 'mcp.mjs')], { input: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) + '\n', + windowsHide: true, encoding: 'utf8', timeout: 20000, maxBuffer: 4 * 1024 * 1024 }) + assert.ifError(child.error) + assert.equal(child.status, 0, child.stderr) + const reply = JSON.parse(child.stdout) + if (reply.error) throw new Error(reply.error.message) + return reply.result +} + +async function runDesktop(storage, dir) { + const server = createServer(async (req, res) => { + try { + if (req.url === '/book') { + res.setHeader('Cache-Control', 'no-store') + if (req.method === 'POST') { + const body = [] + for await (const chunk of req) body.push(chunk) + await storage.save(JSON.parse(Buffer.concat(body).toString('utf8'))) + res.end('ok') + } else res.end(JSON.stringify(await storage.load())) + } else if (req.url === '/ui.js') { + res.setHeader('Content-Type', 'text/javascript') + res.end(await readFile(join(work, 'ui.js'))) + } else if (req.url === '/') { + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.setHeader('Content-Security-Policy', "default-src 'self'; connect-src 'self'") + res.end('
') + } else { res.writeHead(404); res.end() } + } catch (error) { res.writeHead(500); res.end(String(error)) } + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + try { + const env = { ...process.env } + delete env.ELECTRON_RUN_AS_NODE + const { stdout } = await promisify(execFile)(require('electron'), [join(work, 'window.cjs'), + '--user-data-dir=' + join(dir, 'ui-profile'), '--disable-background-networking', 'http://127.0.0.1:' + server.address().port], + { windowsHide: true, timeout: 45000, env }) + assert.match(stdout, /DESKTOP_WRITES=7/) + } finally { server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)) } +} + +function validation(api) { + const bad = [null, '禁止', [], {}, { ...literal, mode: 'wildcard' }, { ...literal, terms: [] }, { ...literal, terms: ['词', ' '] }, + { ...literal, terms: '词' }, { ...literal, enforcement: undefined }, { ...literal, enforcement: 'hard' }, { ...literal, exceptons: ['专名'] }, + { ...literal, exceptions: [1] }, { ...literal, maxPerBok: 3 }, { ...literal, maxPerBook: '1' }, { ...literal, maxPerBook: -1 }, + { ...literal, maxPerChapter: 1.5 }, { ...literal, maxPerChapter: null }, { ...literal, toChapter: 0 }, + { ...literal, fromChapter: 8, toChapter: 2 }, { ...actor, side: '双' }, { ...actor, actor: ' ' }, { ...actor, actions: [] }, + { ...actor, effect: 'deny' }, { ...actor, aliases: [5] }, { ...actor, state: '' }, { ...actor, objects: null }] + const data = fixture(api) + for (const check of bad) { + assert.throws(() => api.parseHardCanonCheck(check), /check/) + assert.equal(api.normalizeHardCanonCheck(check), undefined) + assert.throws(() => api.HardCanonPackService.upsertHardCanonItem(data, item('', '新设定', { check })), /check/) + } + const read = api.normalizeAppData({ ...data, hardCanonPacks: [{ ...data.hardCanonPacks[0], items: [ + ...bad.map((check, i) => item('bad-' + i, '原文' + i, { check })), item('status', '坏状态', { check: literal, status: 'unknown' }), + item('foreign', '错绑角色', { check: { ...actor, characterId: 'c2' } }) + ] }] }).hardCanonPacks[0].items + assert.ok(read.every((entry) => !Object.hasOwn(entry, 'check') && entry.content === '旧版的文字说明。')) + assert.deepEqual(api.parseHardCanonCheck({ ...literal, terms: [' 银灯 ', '银灯', '银 灯'] }).terms, ['银灯', '银 灯']) + assert.deepEqual(api.parseHardCanonCheck({ ...literal, maxPerBook: 2 }), { ...literal, maxPerBook: 2 }) + passed('invalid inputs reject the entire check; old records retain their prose without an executable default') +} + +function compare(api) { + const save = (data, entry) => api.HardCanonPackService.saveAuthoredItem(data, entry) + const first = save(fixture(api), item('stable', 'HC-01 袖扣', { check: actor, status: 'deprecated', relatedCharacterIds: ['c1'] })) + const original = structuredClone(first.item) + const another = { ...original, id: '', priority: 'high', status: 'active', sourceType: 'imported', sourceId: 'new-source' } + const exact = save(first.data, another) + assert.equal(exact.reused, true); assert.equal(exact.data, first.data); assert.deepEqual(exact.item, original) + const variant = save(first.data, { ...another, title: 'HC-02 袖扣' }) + assert.equal(variant.reused, false); assert.deepEqual(variant.duplicates.possible.map((v) => v.id), ['stable']) + assert.throws(() => save(variant.data, { ...variant.item, title: original.title }), /相同规则已存在.*stable/) + for (const patch of [{ check: { ...actor, effect: 'allow' } }, { check: { ...actor, side: '左' } }, + { check: { ...actor, state: '清醒' } }, { check: { ...actor, toChapter: 6 } }, { check: { ...actor, enforcement: 'review' } }, + { relatedCharacterIds: [] }, { relatedTimelineEventIds: ['event-1'] }, { check: undefined }]) { + const distinct = save(first.data, { ...another, ...patch }) + assert.equal(distinct.reused, false); assert.deepEqual(distinct.duplicates, { exact: null, possible: [] }) + } + const free = save(first.data, item('', '门禁', { content: '铜门不得打开。' })) + const similar = save(free.data, item('', '铜门', { content: '铜门不可以打开。' })) + assert.deepEqual(similar.duplicates.possible.map((v) => v.id), [free.item.id]); assert.equal(similar.reused, false) + const reverse = save(free.data, item('', '门禁', { content: '铜门允许打开。' })) + assert.deepEqual(reverse.duplicates, { exact: null, possible: [] }) + const foreign = save(free.data, { ...free.item, id: '', projectId: 'p2' }) + assert.equal(foreign.reused, false); assert.equal(foreign.duplicates.possible.length, 0) + const colliding = { ...free.data, hardCanonPacks: [free.data.hardCanonPacks[0], { + ...free.data.hardCanonPacks[0], projectId: 'p2', items: [{ ...free.item, projectId: 'p2' }] + }] } + for (const scoped of [api.HardCanonPackService.setHardCanonItemStatus(colliding, free.item.id, 'inactive', 'p1'), + api.HardCanonPackService.removeHardCanonItem(colliding, free.item.id, 'p1')]) { + assert.deepEqual(scoped.hardCanonPacks[1], colliding.hardCanonPacks[1]) + } + const limit = save(first.data, item('', '字面规则', { check: { ...literal, terms: ['甲','乙'] } })) + assert.equal(save(limit.data, { ...limit.item, id: '', check: { ...literal, terms: ['乙','甲','乙'], maxPerChapter: 0 } }).reused, true) + assert.equal(save(limit.data, { ...limit.item, id: '', check: { ...limit.item.check, maxPerBook: 0 } }).reused, false) + passed('same rule reuses untouched ID/status/priority/source; scopes, reverse rules and suggestions remain independent') +} + +async function roundTrip(api, kind) { + const dir = await mkdtemp(join(work, kind + '-')) + const storagePath = join(dir, 'book.' + kind) + const Store = kind === 'json' ? api.JsonStorageService : api.SqliteStorageService + const store = new Store(storagePath) + const paths = { storagePath, userDataPath: dir, projectId: 'p1' } + const call = (name, args) => { + const result = rpc('tools/call', { name: 'agent.' + name, arguments: { ...paths, entity: 'hard_canon', ...args } }).structuredContent + assert.equal(result.storage.source, kind) + return result.data + } + const create = (operationId, patch) => ({ action: 'create', operationId, patch, source }) + const preview = (command) => call('previewWorldAction', command) + const apply = (command, fp = preview(command).fingerprint) => call('applyWorldAction', { ...command, expectedFingerprint: fp }) + const get = (id) => call('getWorldRecord', { id, detail: 'full' }).record + const list = () => call('getWorldRecords', { detail: 'full', includeArchived: true }).records + try { + const seed = fixture(api) + seed.hardCanonPacks[0].items.find((entry) => entry.id === 'bad').check = { ...literal, maxPerBook: -1 } + await store.save(seed) + const old = list() + assert.ok(old.every((entry) => !Object.hasOwn(entry, 'check'))) + const text = create('hint', { title: '封笺提示', content: '封笺不得提前拆开。', category: 'world_rule', status: 'inactive' }) + const p = preview(text) + assert.deepEqual(list(), old) + assert.throws(() => apply(text, p.fingerprint), /active user grant/) + const authority = new api.AgentAuthorizationService(dir) + await authority.grant({ storagePath, projectId: 'p2', actions: ['edit_world'], chapterStart: null, chapterEnd: null }) + assert.throws(() => apply(text, p.fingerprint), /active user grant/) + const grant = await authority.grant({ storagePath, projectId: 'p1', actions: ['edit_world'], chapterStart: null, chapterEnd: null }) + const hint = apply(text, p.fingerprint) + assert.equal(hint.audit.authorizationGrantId, grant.id) + const action = create('actor', { title: '袖扣规则', content: '顾迟的袖扣不可随意改写。', category: 'world_rule', check: actor }) + assert.deepEqual(preview(action).after.check, actor) + const created = apply(action) + assert.deepEqual(get(created.id).check, actor) + for (const check of [{ ...literal, maxPerBook: '2' }, { ...actor, effect: 'other' }, { ...actor, characterId: 'c2' }]) { + assert.throws(() => preview(create('bad', { ...action.patch, check })), /check\./) + } + assert.throws(() => preview({ ...action, source: { ...source, kind: 'ai_detection' } }), /agent_director/) + passed(kind + ': real MCP wire preserves patch.check, refuses invalid fields and enforces the project grant') + + await runDesktop(store, dir) + const records = list(), byTitle = (title) => records.find((entry) => entry.title === title) + const desktop = byTitle('01. 暮纹') + assert.deepEqual(desktop.check, { ...literal, terms: ['暮纹','暮印'], exceptions: ['暮纹阁'], maxPerBook: 3 }) + assert.deepEqual(byTitle('外文字形').check, { kind: 'literal', mode: 'latin', terms: [], exceptions: ['AQUA-9'], enforcement: 'review' }) + assert.deepEqual(get(created.id).check, { ...actor, aliases: ['阿迟','南窗'], state: '手腕固定', objects: ['袖扣','表带'], effect: 'allow', side: '左', toChapter: 9 }) + assert.deepEqual(get(hint.id), hint.after) + assert.ok(!Object.hasOwn(get('bad'), 'check')) + assert.ok(byTitle('02. 暮纹').id !== desktop.id) + passed(kind + ': actual desktop form saves and edits conditions; independent MCP reload returns every field') + + const patch = { title: desktop.title, content: desktop.content, category: desktop.category, priority: 'medium', check: desktop.check } + const duplicate = create('reuse-desktop', patch) + const reused = preview(duplicate) + assert.equal(reused.changed, false); assert.equal(reused.hardCanonDuplicates.exact.id, desktop.id) + assert.deepEqual(reused.before, reused.after) + const applied = apply(duplicate, reused.fingerprint) + assert.equal(applied.id, desktop.id); assert.equal(applied.changed, false); assert.equal(applied.audit.authorizationGrantId, grant.id) + assert.deepEqual(get(desktop.id), desktop); assert.deepEqual(list(), records) + assert.equal(apply(duplicate, reused.fingerprint).replayed, true) + const nearby = create('different-number', { ...patch, title: '03. 暮纹' }) + assert.deepEqual(new Set(preview(nearby).hardCanonDuplicates.possible.map((v) => v.id)), new Set([desktop.id, byTitle('02. 暮纹').id])) + const stale = create('race', { ...patch, content: '正文另外核对。', title: '并发录入' }) + const fresh = preview(stale) + apply({ ...stale, operationId: 'race-winner' }) + assert.throws(() => apply(stale, fresh.fingerprint), /changed since preview/) + assert.equal(preview(stale).changed, false) + passed(kind + ': cross-entry reuse is explicit, audited and immutable; changed comparison requires a fresh preview') + + const update = (operationId, patch) => ({ action: 'update', id: desktop.id, operationId, patch, source }) + apply(update('keep-check', { priority: 'high' })) + assert.deepEqual(get(desktop.id).check, desktop.check) + apply(update('remove-check', { check: null })) + assert.equal(Object.hasOwn(get(desktop.id), 'check'), false) + assert.deepEqual(get(byTitle('02. 暮纹').id).check, desktop.check) + assert.throws(() => preview(create('bad-null', { ...patch, check: null })), /check/) + await authority.revoke(grant.id, storagePath, 'p1') + assert.throws(() => apply(duplicate, reused.fingerprint), /active user grant/) + passed(kind + ': explicit removal affects only the selected ID; replay still needs an active grant') + } finally { store.close?.() } +} + +try { + const api = await bundles() + validation(api); compare(api) + const defs = rpc('tools/list', {}).tools + for (const name of ['agent.previewWorldAction', 'agent.applyWorldAction']) { + const patch = defs.find((d) => d.name === name).inputSchema.properties.patch + assert.ok(patch['x-worldEditableFieldsByEntity'].hard_canon.fields.includes('check')) + assert.deepEqual(patch.properties.check.oneOf.slice(0, 2).map((s) => s.properties.kind.const), ['literal','actor_action']) + } + await roundTrip(api, 'json') + await roundTrip(api, 'sqlite') + console.log('HardCanon authoring passed (' + checks + ' groups; temporary books only).') +} finally { await rm(work, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) } diff --git a/scripts/validate-hard-canon-commit-preflight.mjs b/scripts/validate-hard-canon-commit-preflight.mjs new file mode 100644 index 0000000..ea6a2bd --- /dev/null +++ b/scripts/validate-hard-canon-commit-preflight.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +const base = join(repoRoot, 'tmp') +await mkdir(base, { recursive: true }) +const tmp = await mkdtemp(join(base, 'canon-commit-')) +const t = '2026-10-01T00:00:00.000Z' +const later = '2026-10-02T00:00:00.000Z' +const original = '柜门边留着一道雾印。' +const edited = '柜门的铜扣已经松了。' +const rule = (enforcement = 'block') => ({ + id: 'word-rule', projectId: 'p', title: '禁写雾印', content: '正文不得出现雾印。', + category: 'prohibition', priority: 'medium', status: 'active', createdAt: t, updatedAt: t, + check: { kind: 'literal', mode: 'terms', terms: ['雾印'], enforcement, maxPerChapter: 0 } +}) +const attach = (data, items) => ({ ...data, hardCanonPacks: [ + { ...data.hardCanonPacks.find(pack => pack.projectId === 'p'), items } +] }) + +try { + const file = join(tmp, 'api.mjs') + await build({ stdin: { contents: [ + 'shared/defaults', 'services/ChapterCommitBundleService', 'services/RevisionCommitBundleService', + 'services/ChapterVersionChainService', 'services/HardCanonPreflight', 'services/DirectorNextActionService', + 'agent/AgentDecisionService', 'agent/agentVersionReaders', 'storage/JsonStorageService', 'storage/SqliteStorageService' + ].map(path => `export * from './src/${path}'`).join('\n'), resolveDir: repoRoot, loader: 'ts' }, + outfile: file, format: 'esm', platform: 'node', target: 'node22', external: ['electron', 'better-sqlite3'], + bundle: true, logLevel: 'silent' }) + const api = await import(pathToFileURL(file).href) + const fixture = api.normalizeAppData({ + projects: [{ id: 'p', name: '虚构核查样书', createdAt: t, updatedAt: t }], + chapterGenerationJobs: [{ id: 'job', projectId: 'p', targetChapterOrder: 2, status: 'completed', + currentStep: 'await_user_confirmation', createdAt: t, updatedAt: t }], + generatedChapterDrafts: [{ id: 'draft', jobId: 'job', projectId: 'p', chapterId: null, + title: '橱柜', body: original, summary: '', status: 'draft', createdAt: t, updatedAt: t }] + }) + const buildAcceptance = (appData, mode) => api.buildAcceptedDraftCommitBundle({ + appData, projectId: 'p', chapterId: 'c2', draftId: 'draft', targetChapterOrder: 2, + commitId: 'adopt', chapterVersionId: 'first', acceptedAt: t, acceptanceMode: mode + }) + const restore = appData => api.buildRestoreRevisionCommitBundle({ appData, projectId: 'p', + chapterId: 'c2', sourceVersionId: 'first', revisionCommitId: 'restore', newChapterVersionId: 'third', + restoredAt: '2026-10-03T00:00:00.000Z' }) + for (const [name, Storage] of [['json', api.JsonStorageService], ['sqlite', api.SqliteStorageService]]) { + const storage = new Storage(join(tmp, 'book.' + name)) + try { + await storage.save(fixture) + const plain = await storage.load() + const beforeRule = buildAcceptance(plain, 'unreviewed') + const legacy = buildAcceptance(plain, undefined) + await storage.save(attach(plain, [rule()])) + const blocked = await storage.loadSnapshot() + assert.equal(api.AgentDecisionService.buildAcceptanceRecommendation(blocked.data, 'job').recommendation, 'revise') + assert.match(api.getDirectorDashboard(blocked.data, 'p').actions[0].reason, /雾印/) + const agent = api.AgentDecisionService.buildAcceptanceRecommendation(blocked.data, 'job').hardCanon + assert.equal(agent.findings[0].ruleId, 'word-rule') + assert.equal(original.slice(agent.findings[0].start, agent.findings[0].end), agent.findings[0].excerpt) + assert.equal(agent.findings[0].sentence, original) + const repeated = { ...blocked.data, generatedChapterDrafts: [{ ...blocked.data.generatedChapterDrafts[0], body: original.repeat(4) }] } + assert.equal(api.AgentDecisionService.buildAcceptanceRecommendation(repeated, 'job').hardCanon.findings.length, 3) + assert.equal(api.AgentDecisionService.buildAcceptanceRecommendation(repeated, 'job', undefined, 'full').hardCanon.findings.length, 4) + for (const mode of ['unreviewed', 'reviewed', undefined]) assert.throws(() => buildAcceptance(blocked.data, mode), /硬设定核查未通过.*雾印/) + for (const preview of [legacy, beforeRule]) { + await assert.rejects(storage.saveChapterCommitBundle(preview, blocked.revision), /硬设定核查未通过.*雾印/) + assert.deepEqual(await storage.loadSnapshot(), blocked) + } + // Even a nonblocking change must not silently accept a preview that said there were no checks. + await storage.save(attach(blocked.data, [rule('review')])) + const advisory = await storage.loadSnapshot() + await assert.rejects(storage.saveChapterCommitBundle(beforeRule, advisory.revision), /硬设定或全书计数已变化/) + const accepted = buildAcceptance(advisory.data, 'unreviewed') + assert.equal(accepted.acceptanceReview.hardCanonCheck.reviewCount, 1) + await storage.saveChapterCommitBundle(accepted, advisory.revision) + const persisted = await storage.loadSnapshot() + assert.deepEqual(persisted.data.chapterCommitBundles[0].acceptanceReview.hardCanonCheck, accepted.acceptanceReview.hardCanonCheck) + const revision = api.buildRevisionCommitBundle({ appData: persisted.data, projectId: 'p', chapterId: 'c2', + revisionCommitId: 'rewrite', newChapterVersionId: 'second', revisedAt: later, afterText: edited }) + await storage.saveRevisionCommitBundle(revision, persisted.revision) + const revised = await storage.loadSnapshot() + assert.equal(revised.data.chapters[0].body, edited) + const oldRestore = restore(revised.data) + await storage.save(attach(revised.data, [rule()])) + const today = await storage.loadSnapshot() + assert.deepEqual((await storage.saveChapterCommitBundle(accepted, today.revision)).savedCollections, []) + assert.deepEqual((await storage.saveRevisionCommitBundle(revision, today.revision)).savedCollections, []) + assert.throws(() => restore(today.data), /硬设定核查未通过.*雾印/) + await assert.rejects(storage.saveRevisionCommitBundle(oldRestore, today.revision), /硬设定核查未通过.*雾印/) + assert.deepEqual(await storage.loadSnapshot(), today) + const oldVersion = api.getVersionDetail(today.data, 'p', 2, 'first', { detail: 'full' }) + assert.equal(oldVersion.metadata.currentHardCanon.findings[0].severity, 'block') + assert.ok(!api.getVersionDetail(today.data, 'p', 2, 'second', { detail: 'full' }).metadata.currentHardCanon.findings.length) + assert.equal(api.getVersionDetail(today.data, 'p', 2, 'first').metadata.currentHardCanon, undefined) + await storage.save(attach(today.data, [])) + await storage.saveRevisionCommitBundle(restore(await storage.load())) + const final = await storage.load() + assert.equal(final.chapters[0].body, original) + assert.deepEqual(new Set(final.chapterVersions.map(v => v.id)), new Set(['first', 'second', 'third'])) + console.log(`PASS ${name}: preview, advisory, stale receipts, transactional acceptance/revision, restore and historical replay`) + } finally { storage.close?.() } + } +} finally { + await rm(tmp, { recursive: true, force: true }) +} diff --git a/scripts/validate-hard-canon-mechanical.mjs b/scripts/validate-hard-canon-mechanical.mjs new file mode 100644 index 0000000..53a5e66 --- /dev/null +++ b/scripts/validate-hard-canon-mechanical.mjs @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict' +import { performance } from 'node:perf_hooks' +import { join } from 'node:path' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +// Compile in memory. No defaults, project storage, API clients or credentials are loaded. +const { outputFiles } = await build({ entryPoints: [join(repoRoot, 'src/services/HardCanonAuditService.ts')], + bundle: true, write: false, platform: 'node', format: 'esm', target: 'node22', logLevel: 'silent' }) +const { auditHardCanon } = await import('data:text/javascript;base64,' + Buffer.from(outputFiles[0].contents).toString('base64')) +const projectId = 'paper-lantern', other = 'second-paper-book', timestamp = '2020-01-01T00:00:00Z' +const person = (id, name, project = projectId) => ({ projectId: project, id, name }) +const cast = [person('a', '阿满'), person('b', '云舟'), person('c', 'Nora'), person('d', '霁記')] +const chapter = (id, order, body, rest = {}) => ({ projectId, id, order, body, updatedAt: timestamp, ...rest }) +const fact = (rest = {}) => ({ projectId, characterId: 'a', id: 'fact', key: 'consciousness', label: '意识', + value: '昏迷', category: 'physical', trackingLevel: 'hard', status: 'active', + sourceChapterOrder: 2, sourceChapterId: null, ...rest }) +const item = (id, check, rest = {}) => ({ id, projectId, title: '虚构条件 ' + id, content: '仅为说明,不参与推理。', + status: 'active', updatedAt: timestamp, check, ...rest }) +const pack = (items, id = 'pack', project = projectId) => ({ projectId: project, id, items, maxPromptTokens: 0 }) +const literal = (terms, rest = {}) => ({ kind: 'literal', mode: 'terms', terms, enforcement: 'block', ...rest }) +const actor = (actions, rest = {}) => ({ kind: 'actor_action', actor: '阿满', characterId: 'a', aliases: ['小满'], + actions, effect: 'forbid', enforcement: 'block', ...rest }) +const hold = actor(['持', '握住', '握', '提起'], { side: '右', objects: ['灯', '短刀'] }) +const wake = actor(['醒来', '苏醒'], { state: '昏迷' }) +const speak = actor(['开口', '说话'], { state: '昏迷' }) +const data = (items, extra = {}) => ({ hardCanonPacks: [pack(items)], characters: cast, characterStateFacts: [], chapters: [], ...extra }) +const run = (d, body, extra = {}) => auditHardCanon(d, { projectId, chapterOrder: 3, body, ...extra }) +const one = (check, body, extra = {}) => run(data([item('r', check)], extra), body) +let passed = 0, failed = 0 +function test(label, fn) { + try { fn(); passed++ } catch (err) { failed++; console.error('FAIL:', label, '\n', err.message) } +} +function expect(result, severity, body) { + for (const f of result.findings) { + assert.ok(f.start >= 0 && f.end > f.start && f.end <= body.length) + assert.equal(body.slice(f.start, f.end), f.excerpt) + assert.ok(f.ruleId && f.ruleTitle && f.ruleVersion && f.reason) + } + if (severity === 'none') assert.deepEqual(result.findings, []) + else { + assert.ok(result.findings.length > 0, '缺少 ' + severity + ' 证据') + assert.deepEqual([...new Set(result.findings.map((f) => f.severity))], [severity]) + } +} +function cases(check, expected, bodies, extra) { + for (const body of bodies) test(expected + ' / ' + body, () => expect(one(check, body, extra), expected, body)) +} + +cases(hold, 'block', ['阿满右手持灯。', '小满用右手握住了那把短刀。', '阿满把那柄短刀握在右手里。', '阿满用右手提起一盏灯。']) +cases(hold, 'none', ['阿满没有用右手持灯。', '阿满右手没能握住灯。', '阿满不再用右手提起灯。', '阿满差点用右手持灯。', + '阿满左手持灯。', '阿满右手持伞。', '阿满右手碰了灯。', '阿满看见云舟右手持灯。', '阿满看见陌生人右手持灯。', + '阿满让学徒右手持灯。', '云舟右手持灯走向阿满。', '阿满的妹妹右手持灯。', '阿满的右手在灯旁。', '阿满扶住云舟的右手。']) +cases(hold, 'review', ['他右手持灯。', '阿满持灯。', '阿满走到门前,右手持灯。', '“阿满右手持灯。”', '「我记得『阿满右手持灯』。」', + '云舟转述:阿满右手持灯。', '云舟说,阿满右手持灯。', '如果阿满右手持灯,会被认出来。', '阿满右手持灯吗?', + '阿满右手持灯,那只是传闻。', '云舟回忆起旧事。\n阿满右手持灯。', '十年前,阿满右手持灯。', '云舟转述:\r\n阿满右手持灯。']) +cases(hold, 'block', ['云舟回忆起旧事。\n回到现实,阿满右手持灯。']) +cases(hold, 'review', ['阿满右手持灯。'], { characters: [...cast, person('e', '阿满')] }) +cases(hold, 'block', ['阿满右手持灯。'], { characters: [...cast, person('x', '阿满', other)] }) +cases({ ...hold, aliases: ['他'] }, 'review', ['他右手持灯。']) +cases(actor(['持刀'], { side: '右', objects: ['刀'] }), 'block', ['阿满的右手持刀。']) +cases({ ...hold, enforcement: 'review' }, 'review', ['阿满右手持灯。']) +cases(actor(['按住'], { objects: ['右手'] }), 'none', ['阿满用右手按住了门板。']) +cases(actor(['受伤', '骨折'], { side: '右', objects: ['手', '臂'] }), 'block', ['阿满的右手受伤了。', '阿满右臂骨折。']) +cases(actor(['受伤', '骨折'], { side: '右', objects: ['手', '臂'] }), 'none', ['阿满的左手受伤了。', '阿满看见云舟右臂骨折。', + '云舟扶着阿满,云舟的右臂骨折了。', '阿满的右手未曾受伤。']) +cases(actor(['抬起'], { side: '右', objects: ['手'] }), 'block', ['阿满抬起右手。']) +cases(actor(['抬起'], { side: '右', objects: ['手'] }), 'none', ['云舟抬起阿满的右手。', '阿满抬起左手。']) + +const asleep = { characterStateFacts: [fact()] } +cases(wake, 'block', ['阿满醒来。', '阿满苏醒。', '阿满终于醒来了。'], asleep) +cases(wake, 'none', ['阿满没有醒来。', '阿满未曾苏醒。', '云舟苏醒。', '阿满仍在昏迷。'], asleep) +cases(wake, 'review', ['阿满醒来。']) +cases(wake, 'review', ['云舟说,阿满醒来。', '“阿满苏醒。”'], asleep) +cases(wake, 'review', ['阿满已经清醒。阿满苏醒。'], asleep) +for (const first of ['阿满没有醒来。', '阿满望见云舟醒来。', '“阿满已经醒来了。”', '阿满可能已经醒来了。']) { + test('只有先前确属该角色的实然证据才能削弱下一次裁定 / ' + first, () => { + const body = first + '阿满苏醒。' + const findings = one(wake, body, asleep).findings.filter((f) => f.start >= first.length) + assert.deepEqual(findings.map((f) => f.severity), ['block']) + }) +} +cases(speak, 'block', ['阿满开口。'], asleep) +cases(speak, 'review', ['阿满先醒来。阿满开口。'], asleep) +cases(speak, 'none', ['阿满始终没有开口。', '阿满听到云舟说话。', '阿满躺着。']) +for (const [label, f] of Object.entries({ '未来章': fact({ sourceChapterOrder: 4 }), '无章号': fact({ sourceChapterOrder: null }), + '其他项目': fact({ projectId: other }), '他人状态': fact({ characterId: 'b' }), '已撤销': fact({ status: 'retconned' }), + '软注记': fact({ trackingLevel: 'note' }), '否定不是肯定': fact({ value: '无法判断是否昏迷' }), + '仅备注提及': fact({ value: '未知', evidence: '听说阿满昏迷' }) })) { + test(label + '不能证实昏迷', () => expect(one(wake, '阿满醒来。', { characterStateFacts: [f] }), 'review', '阿满醒来。')) +} +cases(speak, 'none', ['阿满开口。'], { characterStateFacts: [fact(), fact({ sourceChapterOrder: 3, value: '清醒' })] }) +cases(speak, 'block', ['阿满开口。'], { characterStateFacts: [fact(), fact({ sourceChapterOrder: 4, value: '清醒' })] }) +cases(speak, 'review', ['阿满开口。'], { characterStateFacts: [fact(), fact({ value: '清醒' })] }) +cases(wake, 'block', ['阿满苏醒。'], { characterStateFacts: [fact(), fact({ sourceChapterOrder: null })] }) +cases(wake, 'review', ['阿满苏醒。'], { characterStateFacts: [fact(), fact({ sourceChapterOrder: null, value: '清醒' })] }) +cases(speak, 'block', ['阿满开口。'], { characterStateFacts: [fact({ key: '昏迷', label: '昏迷', value: true, sourceChapterOrder: 3 })] }) +cases(speak, 'none', ['阿满开口。'], { characterStateFacts: [fact({ key: '昏迷', label: '昏迷', value: false })] }) +cases(speak, 'review', ['阿满开口。'], { ...asleep, characters: [person('b', '云舟')] }) +cases(speak, 'block', ['阿满开口。'], { chapters: [chapter('origin', 2, '旧钟报时。')], + characterStateFacts: [fact({ sourceChapterOrder: null, sourceChapterId: 'origin' })] }) +cases(speak, 'review', ['阿满开口。'], { chapters: [chapter('origin', 2, '旧钟报时。', { archivedAt: timestamp })], + characterStateFacts: [fact({ sourceChapterOrder: 2, sourceChapterId: 'origin' })] }) +cases(speak, 'review', ['阿满开口。'], { chapters: [chapter('foreign', 2, '他书。', { projectId: other })], + characterStateFacts: [fact({ sourceChapterOrder: 2, sourceChapterId: 'foreign' })] }) + +test('允许并非强制;只把同一动作的相反条件转为复核', () => { + const allow = { ...hold, actions: ['持灯'], effect: 'allow', objects: ['灯'] } + const body = '阿满右手持灯。阿满右手持短刀。' + expect(one(allow, body), 'none', body) + const result = run(data([item('forbid', hold), item('allow', allow)]), body) + assert.deepEqual(result.findings.map((f) => [f.ruleId, f.severity]), [['forbid', 'review'], ['forbid', 'block']]) + assert.match(result.findings[0].reason, /allow/) + for (const change of [{ side: '左' }, { fromChapter: 4 }, { actor: '云舟', characterId: 'b', aliases: [] }]) { + expect(run(data([item('f', hold), item('a', { ...allow, ...change })]), body), 'block', body) + } +}) + +test('原字形、受保护名字/标题/对白、精确例外与 UTF-16 位置', () => { + const body = '🧭序。\r\n霁記读《灯記》说:“記。”\n門牌記号。记。記。' + const result = one(literal(['記'], { exceptions: ['門牌記号'] }), body) + expect(result, 'block', body) + assert.deepEqual(result.findings.map((f) => [f.start, f.end, f.excerpt]), [[body.lastIndexOf('記'), body.lastIndexOf('記') + 1, '記']]) +}) +test('Latin:普通词计数;姓名型号、作者例外、带引号和无引号对白不计', () => { + const body = 'Nora看着Nova和ZX-19型。型号为abc。她说:quiet。例外 spare。笺上留着quiet。' + const r = one(literal([], { mode: 'latin', exceptions: ['spare'] }), body) + assert.deepEqual(r.findings.map((f) => [f.excerpt, f.start]), [['quiet', body.lastIndexOf('quiet')]]) +}) +test('选定外文词不匹配其他单词、型号内的子串或英文缩写中的句点', () => { + const body = 'badger遇到X-bad-9。bad。beta。' + assert.deepEqual(one(literal(['bad'], { mode: 'latin' }), body).findings.map((f) => f.excerpt), ['bad']) +}) +test('逐字匹配不是正则;最长的不重叠命中计一次;交叠例外取并集', () => { + const body = '甲乙丙。浮灯。灯。[+]。' + const r = one(literal(['丙', '浮灯', '灯', '[+]'], { exceptions: ['甲乙', '乙丙'] }), body) + assert.deepEqual(r.findings.map((f) => f.excerpt), ['浮灯', '灯', '[+]']) +}) +const ledger = [chapter('p1', 1, '潮音。潮音。'), chapter('p3', 3, '潮音。潮音。'), chapter('p5', 5, '潮音。'), + chapter('archived', 2, '潮音。'.repeat(20), { archivedAt: timestamp }), chapter('foreign', 6, '潮音。'.repeat(8), { projectId: other }), + chapter('shell', 7, ''), chapter('p3', 3, '潮音。'.repeat(9), { updatedAt: '2019-01-01' })] +const book = (check, body, extra) => run(data([item('word', check)], { chapters: ledger, + generatedChapterDrafts: [{ projectId, id: 'draft', chapterId: 'p3', status: 'draft', body: '潮音。'.repeat(9) }], + chapterVersions: [{ projectId, id: 'history', chapterId: 'p3', body: '潮音。'.repeat(8) }], + revisionVersions: [{ id: 'candidate', sessionId: 'edit', status: 'candidate', body: '潮音。'.repeat(7) }] }), body, extra) +const count = (r) => [r.counts[0].chapterCount, r.counts[0].bookCount, r.counts[0].projectedBookCount, r.counts[0].delta] +test('单设全书限额;只替换当前正文章,计入范围外和后章的正式正文', () => { + const c = literal(['潮音'], { maxPerBook: 5, fromChapter: 3, toChapter: 3 }) + for (const opts of [{ chapterId: 'p3' }, { chapterId: null }]) { + const r = book(c, '潮音。', opts) + assert.deepEqual(count(r), [1, 5, 4, -1]); assert.deepEqual(r.findings, []) + } + const excess = book(c, '潮音。'.repeat(3)) + assert.deepEqual(count(excess), [3, 5, 6, 1]); assert.deepEqual(excess.findings.map((f) => f.start), [6]) +}) +test('新增、减少、已超全书限额但本稿无证据;两种阈值只标实际超额处', () => { + const c = literal(['潮音'], { maxPerBook: 5, maxPerChapter: 1 }) + assert.deepEqual(count(book(c, '潮音。', { chapterOrder: 9 })), [1, 5, 6, 1]) + assert.deepEqual(count(book(c, '潮音。', { chapterId: 'archived' })), [1, 5, 6, 1]) + assert.deepEqual(book(c, '潮音。'.repeat(3)).findings.map((f) => f.start), [3, 6]) + const empty = book(literal(['潮音'], { maxPerBook: 0 }), '翻页。') + assert.deepEqual(count(empty), [0, 5, 3, -2]); assert.deepEqual(empty.findings, []) +}) +test('只执行本章启用的结构化条件,包括零 Prompt 预算的第二个包', () => { + const c = literal(['潮音']) + const d = data([], { hardCanonPacks: [pack([ + item('off', c, { status: 'inactive' }), item('old', c, { status: 'deprecated' }), item('other', c, { projectId: other }), + item('next', { ...c, fromChapter: 4 }), item('past', { ...c, toChapter: 2 }), item('free', undefined, { content: '绝不能写潮音' }), + item('invalid', { ...c, maxPerBook: -1 }), item('typo', { ...c, maxPerBok: 1 }), item('a', { ...c, fromChapter: 3, toChapter: 3 }) + ]), pack([item('b', { ...c, enforcement: 'review' })], 'second'), pack([item('x', c)], 'foreign', other)] }) + const r = run(d, '潮音。') + assert.deepEqual(r.checkedRuleIds, ['a', 'b']); assert.deepEqual(r.findings.map((f) => f.severity), ['block', 'review']) +}) +test('正文、规则版本及依赖变更失效;恢复原文复算;规则、集合、章和角色排序不影响指纹', () => { + const c = literal(['潮音', '舊'], { exceptions: ['手記', '雨記'] }) + const rows = [item('w', c), item('a', hold)] + const d = data(rows, { chapters: ledger }) + const body = '🧭序。\r\n阿满右手持灯。潮音。' + const r = run(d, body) + const reordered = data([rows[1], item('w', { ...c, terms: [...c.terms].reverse(), exceptions: [...c.exceptions].reverse() })], + { characters: [...cast].reverse(), chapters: [...ledger].reverse() }) + assert.deepEqual(run(reordered, body), r) + const fixed = run(d, '🧭序。\r\n阿满左手持灯。') + assert.notEqual(r.draftContentHash, fixed.draftContentHash); assert.deepEqual(fixed.findings, []) + assert.deepEqual(run(d, body), r) + const lf = run(d, body.replace('\r\n', '\n')) + assert.equal(r.draftContentHash, lf.draftContentHash); assert.notEqual(r.rawBodyFingerprint, lf.rawBodyFingerprint) + assert.deepEqual(r.findings.map((f) => f.start - 1), lf.findings.map((f) => f.start)) + const changed = run(data([item('w', { ...c, maxPerBook: 2 }), rows[1]], { chapters: ledger }), body) + assert.notEqual(r.rulesFingerprint, changed.rulesFingerprint) + assert.notEqual(r.findings.at(-1).ruleVersion, changed.findings.at(-1).ruleVersion) + assert.notEqual(r.rulesFingerprint, run({ ...d, chapters: [chapter('new', 8, '潮音。'), ...ledger] }, body).rulesFingerprint) + assert.notEqual(one(wake, body).rulesFingerprint, one(wake, body, asleep).rulesFingerprint) +}) + +if (!failed) { + const body = '雨幕里,值夜人给灯罩擦去一圈水痕。长廊尽头又响起潮音。\n'.repeat(66) + '阿满右手持灯。' + const fixture = data([item('a', hold), item('b', speak), item('w1', literal(['潮音'], { maxPerBook: 10000 })), + item('w2', literal(['舊'])), item('w3', literal([], { mode: 'latin' }))], + { chapters: Array.from({ length: 100 }, (_, i) => chapter('bench-' + i, i + 1, body)), ...asleep }) + const sample = () => { const at = performance.now(); const r = run(fixture, body); assert.equal(r.checkedRuleIds.length, 5); return performance.now() - at } + sample() + const times = Array.from({ length: 9 }, sample).sort((a, b) => a - b) + console.log(`Warm audit: 100 chapters x ${body.length} UTF-16 units, 5 rules; 9 runs, median ${times[4].toFixed(1)} ms, max ${times.at(-1).toFixed(1)} ms (no process startup).`) +} +console.log(`Mechanical HardCanon: ${passed} passed, ${failed} failed.`) +if (failed) process.exitCode = 1 diff --git a/scripts/validate-hard-canon-quality.mjs b/scripts/validate-hard-canon-quality.mjs new file mode 100644 index 0000000..6b58bfb --- /dev/null +++ b/scripts/validate-hard-canon-quality.mjs @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { build } from 'esbuild' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { repoRoot } from './utils/repo-root.mjs' + +// Exercise the actual desktop diagnostic handler and the shared pipeline step, without Electron, storage or a model. +const handlers = new Map() +const electron = { ipcMain: { handle: (key, fn) => handlers.set(key, fn) }, app: { isReady: () => false, isPackaged: true } } +const compiled = await build({ + stdin: { resolveDir: repoRoot, contents: ` + export { auditHardCanon } from './src/services/HardCanonAuditService' + export * from './src/services/qualityGate/hardCanonQuality' + export { normalizeQualityGateReport } from './src/shared/normalizers/reports' + export { draftContentHash, invalidateDraftDiagnosticsAfterChange, latestQualityReportForDraft } from './src/services/DraftDiagnosticBindingService' + export { AuthorDecisionPolicyService } from './src/services/AuthorDecisionPolicyService' + export { DiagnosticsService } from './src/main/services/DiagnosticsService' + export { registerDiagnosticsIpcHandlers } from './src/main/ipc/diagnosticsIpcHandlers' + export { IPC_CHANNELS } from './src/shared/ipc/ipcChannels' + export { runQualityGateStep } from './src/renderer/src/views/generation/pipelineSteps/qualityCheck' + export { restorePipelineStateFromCompletedSteps } from './src/renderer/src/views/generation/pipelineStateRestore' + export { HardCanonAuditPanel } from './src/renderer/src/components/HardCanonAuditPanel' + export { PipelineDiagnosticsPanel } from './src/renderer/src/components/pipeline/PipelineDiagnosticsPanel' + ` }, + platform: 'node', format: 'cjs', target: 'node22', packages: 'external', loader: { '.css': 'empty' }, bundle: true, write: false +}) +const require = createRequire(import.meta.url) +const mod = { exports: {} } +new Function('require', 'module', 'exports', compiled.outputFiles[0].text)( + (id) => id === 'electron' ? electron : require(id), mod, mod.exports +) +const { + auditHardCanon, normalizeQualityGateReport, withCurrentHardCanonAudit, qualityReportWithoutHardCanon, + draftContentHash, invalidateDraftDiagnosticsAfterChange, latestQualityReportForDraft, AuthorDecisionPolicyService, + runQualityGateStep, restorePipelineStateFromCompletedSteps, HardCanonAuditPanel, PipelineDiagnosticsPanel +} = mod.exports +const render = (component, props) => renderToStaticMarkup(createElement(component, props)) +const projectId = 'quality-fixture' +const date = '2026-01-10T08:00:00.000Z' +const bookOnly = '只有虚构前章存有的参照句' +const ruleOnly = '未进入模型上下文的规则标题' +const body = '架上放着灰铃。桌角压着晓签。\r\n' + Array.from({ length: 100 }, + (_, i) => `第${i + 1}张登记纸叠在窗台上。她腾出一只手,把编号写进空着的方格里。`).join('\n') +const draft = { id: 'd', jobId: 'j', chapterId: 'c2', projectId, title: '登记册', body, status: 'draft', updatedAt: date, createdAt: date } +const job = { id: draft.jobId, projectId, targetChapterOrder: 2, pipelineRecipeId: 'standard', pipelineMode: 'standard' } +const target = (text) => ({ projectId, chapterOrder: job.targetChapterOrder, chapterId: draft.chapterId, body: text }) +const rule = (id, term, enforcement) => ({ + id, projectId, title: ruleOnly + term, content: '合成规则正文', category: 'prohibition', priority: 'must', + status: 'active', createdAt: date, updatedAt: date, check: { kind: 'literal', mode: 'terms', terms: [term], enforcement, maxPerChapter: 0 } +}) +const ruleA = rule('rule-A', '灰铃', 'block'), ruleB = rule('rule-B', '晓签', 'review') +const pack = { id: 'pack', projectId, items: [ruleA, ruleB], createdAt: date, updatedAt: date, schemaVersion: 1 } +const step = { id: 'gate', jobId: job.id, type: 'quality_gate', status: 'pending', updatedAt: date, output: '' } +const data = { + hardCanonPacks: [pack], characters: [], characterStateFacts: [], foreshadowings: [], generatedChapterDrafts: [draft], + chapters: [ + { id: 'c1', projectId, order: 1, body: bookOnly + '。灰铃。' }, + { id: 'c2', projectId, order: 2, body: '灰铃。架上也是灰铃。' } + ], + chapterGenerationSteps: [ + { ...step, id: 'prose', type: 'generate_chapter_draft', status: 'completed', output: JSON.stringify({ title: draft.title, body }) }, step + ], + chapterGenerationJobs: [job], consistencyReviewReports: [], qualityGateReports: [], redundancyReports: [], + editorialVerdicts: [], generationRunTraces: [], runTraceAuthorSummaries: [] +} +const dimensions = Object.fromEntries(Object.keys(normalizeQualityGateReport({}).dimensions).map(key => [key, 92])) +const semantic = { pass: true, overallScore: 92, dimensions, issues: [], requiredFixes: [], optionalSuggestions: [] } +const passNovelty = { + severity: 'pass', summary: '', newNamedCharacters: [], newWorldRules: [], newSystemMechanics: [], newOrganizationsOrRanks: [], + majorLoreReveals: [], suspiciousDeusExRules: [], untracedNames: [] +} +const modelCalls = [], diagnosticRequests = [] +const diagnosticsService = new mod.exports.DiagnosticsService({ + chatCompletion: async request => { + modelCalls.push(request) + return { ok: true, content: JSON.stringify(semantic), finishReason: 'stop' } + } +}) +mod.exports.registerDiagnosticsIpcHandlers(diagnosticsService) +async function runGate(library) { + const current = library.generatedChapterDrafts[0] + const redundancy = { projectId, id: 'redundancy', chapterId: current.chapterId, draftId: current.id, jobId: job.id, + draftContentHash: draftContentHash(current.body), overallRedundancyScore: 0, compressionSuggestions: [], createdAt: date } + const state = { working: { ...library, redundancyReports: [redundancy] }, context: '仅本章的场景提纲', plan: null } + restorePipelineStateFromCompletedSteps(state, library.chapterGenerationSteps, false, false) + await runQualityGateStep({ state, job, step, options: { pipelineMode: 'standard' }, env: { + project: { id: projectId }, scoped: { characters: [] }, runId: 'fixture-run', + getAiSettings: () => ({ apiProvider: 'local', baseUrl: 'http://localhost:1', modelName: 'fixture', temperature: 0, maxTokens: 1000 }), + updateStepInData: (working, id, patch) => ({ + ...working, chapterGenerationSteps: working.chapterGenerationSteps.map(item => item.id === id ? { ...item, ...patch } : item) + }), + diagnostics: { + auditNovelty: async () => passNovelty, + evaluateQualityGate: async request => { + diagnosticRequests.push(request) + const result = await handlers.get(mod.exports.IPC_CHANNELS.DIAGNOSTICS_EVALUATE_QUALITY_GATE)({}, request) + assert.ok(result.id, result.error) + return result + } + } + } }) + return state.working +} + +const first = await runGate(data) +const report = first.qualityGateReports[0], audit = report.hardCanonAudit +const base = qualityReportWithoutHardCanon(report) +assert.deepEqual(audit, auditHardCanon(data, target(body))) +assert.ok(audit.findings.some(f => f.ruleId === ruleA.id && f.severity === 'block')) +assert.ok(audit.findings.some(f => f.ruleId === ruleB.id && f.severity === 'review')) +assert.deepEqual(audit.counts.find(count => count.ruleId === ruleA.id), { + ruleId: ruleA.id, chapterCount: 1, bookCount: 3, projectedBookCount: 2, delta: -1 +}) +assert.equal(base.pass, true) +assert.equal(report.pass, false) +assert.deepEqual(report.issues, []) +assert.deepEqual(report.requiredFixes, []) +assert.deepEqual(report.dimensions, dimensions) +assert.equal(report.overallScore, semantic.overallScore) +assert.equal(report.hardCanonBasePass, base.pass) +assert.deepEqual(first.consistencyReviewReports[0].issues, []) +assert.deepEqual(first.editorialVerdicts[0].blockers, []) +assert.equal(first.editorialVerdicts[0].status, 'approved') +assert.equal(first.generationRunTraces[0].qualityGateReportId, report.id) +assert.deepEqual(JSON.parse(first.chapterGenerationSteps.find(item => item.id === step.id).output).hardCanonAudit, audit) +assert.equal(modelCalls.length, 1) + +const persisted = normalizeQualityGateReport(JSON.parse(JSON.stringify(report))) +assert.deepEqual(persisted.hardCanonAudit, audit) +assert.equal(qualityReportWithoutHardCanon(persisted).pass, true) +assert.deepEqual(withCurrentHardCanonAudit(persisted, audit), persisted) +assert.deepEqual(withCurrentHardCanonAudit(persisted, null), qualityReportWithoutHardCanon(persisted)) +assert.equal(withCurrentHardCanonAudit(base, null), base) +const legacy = normalizeQualityGateReport({ ...base, pass: false }) +assert.ok(!('hardCanonAudit' in legacy)) +assert.equal(withCurrentHardCanonAudit(legacy, null), legacy) +assert.equal(withCurrentHardCanonAudit(legacy, null).pass, false) +for (const bad of [{ ...audit, projectId: 'other' }, { ...audit, draftContentHash: 'other' }]) { + assert.throws(() => withCurrentHardCanonAudit(base, bad), /不匹配/) +} +const corrupt = normalizeQualityGateReport({ ...report, hardCanonAudit: { ...audit, findings: [{ severity: 'unknown' }] } }) +assert.ok(!('hardCanonAudit' in corrupt) && !('hardCanonBasePass' in corrupt)) +assert.equal(corrupt.pass, false) + +const weakPack = { ...pack, items: [rule('rule-A', '灰铃', 'review'), ruleB] } +const weakAudit = auditHardCanon({ ...data, hardCanonPacks: [weakPack] }, target(body)) +assert.notEqual(weakAudit.rulesFingerprint, audit.rulesFingerprint) +const weakReport = withCurrentHardCanonAudit(persisted, weakAudit) +assert.equal(weakReport.pass, true) +assert.deepEqual(weakReport.issues, base.issues) +assert.deepEqual(weakReport.requiredFixes, base.requiredFixes) +assert.deepEqual(weakReport.optionalSuggestions, base.optionalSuggestions) +assert.deepEqual(weakReport.dimensions, base.dimensions) +assert.equal(weakReport.overallScore, base.overallScore) +assert.equal(AuthorDecisionPolicyService.assessQualityGate(weakReport).status, 'passed') +const nearThreshold = { ...base, overallScore: 50 } +assert.equal(withCurrentHardCanonAudit(nearThreshold, weakAudit).pass, true) +const emptyAudit = auditHardCanon({ ...data, hardCanonPacks: [] }, target(body)) +assert.deepEqual(withCurrentHardCanonAudit(report, emptyAudit), base) +const otherFailure = { ...base, pass: false, requiredFixes: ['普通审稿仍有必修项'] } +assert.deepEqual(withCurrentHardCanonAudit(withCurrentHardCanonAudit(otherFailure, audit), null), otherFailure) +assert.equal(withCurrentHardCanonAudit(otherFailure, weakAudit).pass, false) +assert.equal(report.pass, false, 'replacing the live check must not mutate the historical report') +const lfBody = body.replaceAll('\r\n', '\n') +assert.equal(draftContentHash(lfBody), draftContentHash(body)) +await assert.rejects(diagnosticsService.evaluateQualityGate({ + ...diagnosticRequests[0], chapterDraft: { ...draft, body: lfBody }, hardCanonAudit: audit +}), /不匹配/) +await assert.rejects(diagnosticsService.evaluateQualityGate({ + ...diagnosticRequests[0], targetChapterOrder: job.targetChapterOrder + 1, hardCanonAudit: audit +}), /不匹配/) +assert.equal(modelCalls.length, 1, 'a rules-only change is checked locally') + +const revisedBody = body.replace('灰铃', '木盒') +const edited = invalidateDraftDiagnosticsAfterChange(first, draft.id, revisedBody, '2026-01-10T09:00:00.000Z') +assert.equal(latestQualityReportForDraft(edited.qualityGateReports, edited.generatedChapterDrafts[0]), null) +const second = await runGate(edited) +const fresh = second.qualityGateReports[0] +assert.equal(modelCalls.length, 2, 'only the two ordinary semantic reviews may call the transport') +for (const request of modelCalls) { + assert.ok(!JSON.stringify(request.messages).includes(bookOnly)) + assert.ok(!JSON.stringify(request.messages).includes(ruleOnly)) +} +for (const request of diagnosticRequests) { + assert.ok(!JSON.stringify(request).includes(bookOnly)) + assert.ok(!('chapters' in request) && !('hardCanonPacks' in request)) +} +assert.equal(diagnosticRequests[1].chapterDraft.body, revisedBody) +assert.equal(fresh.draftContentHash, draftContentHash(revisedBody)) +assert.equal(fresh.hardCanonAudit.draftContentHash, fresh.draftContentHash) +assert.notEqual(fresh.hardCanonAudit.rawBodyFingerprint, audit.rawBodyFingerprint) +assert.ok(!fresh.hardCanonAudit.findings.some(f => f.severity === 'block')) +assert.equal(fresh.pass, true) +assert.deepEqual(second.qualityGateReports.find(item => item.id === report.id), report) + +const viewProps = { editorialVerdict: first.editorialVerdicts[0], qualityReport: report, latestDraft: draft, + chapterCommitBundles: [], revisionCandidates: [], consistencyReports: [], linkedConsistencyIssueTitle: () => null } +const headline = (props) => render(PipelineDiagnosticsPanel, { ...viewProps, ...props }).split('
')[0] +assert.match(headline({ hardCanonAudit: audit }), /硬设定未通过/) +assert.doesNotMatch(headline({ hardCanonAudit: audit }), /可以采纳|接受草稿|没有必须先修订的问题/) +assert.match(headline({ hardCanonAudit: weakAudit }), /可采纳,有硬设定提示/) +assert.match(headline({ hardCanonAudit: null }), /没有适用于本章的可执行硬设定/) +assert.doesNotMatch(headline({ hardCanonAudit: null }), /硬设定未通过|确证违反/) +assert.match(headline({}), /硬设定待核对/) +assert.doesNotMatch(headline({ hardCanonAudit: fresh.hardCanonAudit }), /可采纳,有硬设定提示/) +assert.match(headline({ hardCanonAudit: audit, latestDraft: { ...draft, body: lfBody } }), /硬设定待核对/) +const snapshotHtml = render(HardCanonAuditPanel, { audit, scope: 'report_snapshot' }) +const finding = audit.findings.find(f => f.severity === 'block') +for (const value of [finding.excerpt, finding.reason, finding.ruleVersion, finding.ruleId, audit.rulesFingerprint, + `正文第 ${finding.start + 1} 至 ${finding.end} 位`, '本章 1 次', '已采纳全书 3 次', '采纳此稿后 2 次', '变化 -1 次']) { + assert.ok(snapshotHtml.includes(value), `missing readable evidence: ${value}`) +} +assert.match(snapshotHtml, /审稿当时的硬设定核查/) +assert.match(render(HardCanonAuditPanel, { audit, body }), /架上放着灰铃<\/mark>。/) +assert.match(render(HardCanonAuditPanel, { audit, body: lfBody }), /正文已变化/) +assert.doesNotMatch(render(HardCanonAuditPanel, { audit, body: lfBody, scope: 'report_snapshot' }), //) +assert.match(render(HardCanonAuditPanel, { audit, scope: 'historical_version' }), /历史正文 · 按现行规则核对/) +assert.match(render(HardCanonAuditPanel, { audit: null, scope: 'report_snapshot' }), /没有保存/) +const hints = Array.from({ length: 13 }, (_, i) => ({ ...finding, severity: 'review', reason: `复核第${i + 1}处` })) +const longList = render(HardCanonAuditPanel, { audit: { ...audit, findings: [...hints, finding] } }) +assert.ok(longList.includes(finding.reason) && !longList.includes('复核第13处')) +assert.match(longList, /查看更多证据/) +console.log('validate-hard-canon-quality: pipeline, revised text, report provenance, local replacement and author evidence passed') diff --git a/scripts/validate-pipeline-stage-ui.mjs b/scripts/validate-pipeline-stage-ui.mjs index 8182765..6400bc1 100644 --- a/scripts/validate-pipeline-stage-ui.mjs +++ b/scripts/validate-pipeline-stage-ui.mjs @@ -55,7 +55,7 @@ if (!consoleView.includes(' item\.id === initialJobId\)/.test(generationPipelineView) && /selectedJob\s*\?\s*scoped\.chapterGenerationSteps/.test(selectedPipelineJobHook), message: 'GenerationPipelineView delegates selected step lookup through scoped project data' }, diff --git a/scripts/validate-project-usage-pricing.mjs b/scripts/validate-project-usage-pricing.mjs new file mode 100644 index 0000000..a38a996 --- /dev/null +++ b/scripts/validate-project-usage-pricing.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict' +import { loadDomainModules } from './utils/load-domain-modules.mjs' + +const { estimateProjectUsage: estimate, findProjectRate: find, normalizeProjectModelRates: normalize, priceUsageAttempt: price, + normalizeAppData, pipelineUsagePreview, revisionMessages, chapterPlanMessages, chapterDraftMessages, RevisionAI, GenerationPipelineAI, + buildAuthoritativeOpeningPlan } = await loadDomainModules('services/ProjectUsagePricing.ts', 'shared/normalizers/appData.ts', + 'services/ai/RevisionAI.ts', 'services/ai/GenerationPipelineAI.ts', 'services/OpeningChapterPlanGuardService.ts', + 'renderer/src/views/generation/pipelineUsagePreview.ts') +const endpoint = 'https://fixture.example', model = 'team/fixture-1', provider = 'compatible' +const base = { provider, endpoint, model, currency: 'CNY', inputPerMillionTokens: 4, outputPerMillionTokens: 7 } +const discount = { ...base, cachedInputPerMillionTokens: 1 } + +const [clean] = normalize([{ ...base, currency: ' cny ', endpoint: 'https://FIXTURE.example:443/v1?token=fixture-secret', + apiKey: 'fixture-secret', headers: { Authorization: 'private' } }]) +assert.deepEqual(clean, base) +assert.deepEqual(normalize([discount, base]), [base]) +assert.equal(find([clean], provider, endpoint + '/v2/custom', model), clean) +for (const args of [['openai', endpoint, model], [provider, endpoint + ':444', model], [provider, 'http://fixture.example', model], + [provider, endpoint, model.toUpperCase()], [provider, '', model], [provider, endpoint, '']]) { + assert.equal(find([clean], ...args), undefined) +} +for (const bad of [null, {}, { ...base, endpoint: 'file:///C:/fixture' }, { ...base, endpoint: 'https://name:password@fixture.example' }, + { ...base, endpoint: 'https://sk-x.example' }, { ...base, model: 'sk-y' }, + { ...base, model: 'abc/ghp_fixture' }, { ...base, model: 'x'.repeat(300) }, { ...base, model: 'name?key=fixture' }, + { ...base, provider: 'guess' }, { ...base, currency: '¥' }, { ...base, inputPerMillionTokens: '' }, + { ...base, outputPerMillionTokens: -1 }, { ...base, cachedInputPerMillionTokens: NaN }]) { + assert.deepEqual(normalize([bad]), []) +} +assert.deepEqual(normalize(undefined), []) +const projects = normalizeAppData({ projects: [{ id: 'a', name: 'A', modelRates: [base] }, { id: 'b', name: 'B' }, + { id: 'c', name: 'C', modelRates: { apiKey: 'fixture' } }] }).projects +assert.deepEqual(projects[0].modelRates, [base]) +assert(!('modelRates' in projects[1]) && !('modelRates' in projects[2])) +console.log('PASS origin, provider and case-sensitive model matching; project price normalization and credential rejection') + +const receipt = { promptTokens: 1_000_000, completionTokens: 2_000_000, cachedPromptTokens: 250_000, reasoningTokens: 100_000 } +assert.equal(price(receipt, base), 18) +assert.equal(price(receipt, discount), 17.25) +assert.equal(price({ ...receipt, cachedPromptTokens: undefined }, discount), null) +assert.equal(price({ ...receipt, cachedPromptTokens: undefined }, { ...base, cachedInputPerMillionTokens: 4 }), 18) +assert.equal(price({ promptTokens: 0, completionTokens: 1_000_000 }, discount), 7) +assert.equal(price({ promptTokens: 0, completionTokens: 0 }, discount), 0) +assert.equal(price({ ...receipt, cachedPromptTokens: 0 }, discount), 18) +assert.equal(price(receipt, { ...base, inputPerMillionTokens: 0, outputPerMillionTokens: 0 }), 0) +for (const invalid of [undefined, { totalTokens: 8 }, { promptTokens: 2 }, { completionTokens: 0 }, + { ...receipt, promptTokens: -1 }, { ...receipt, completionTokens: 0.5 }, { ...receipt, cachedPromptTokens: 1_000_001 }]) { + assert.equal(price(invalid, base), null) +} +assert.equal(price(receipt, undefined), null) +assert.equal(price(receipt, { ...base, provider: 'codex_cli' }), null) +console.log('PASS physical receipt pricing, unknown versus zero, disjoint cached/uncached inputs and included reasoning') + +const request = { model, provider, endpoint, rates: [discount], maxTokens: 20 } +const counted = estimate({ ...request, countedInputTokens: 10 }) +assert.deepEqual(counted.inputTokens, { min: 10, max: 10 }) +assert.deepEqual(counted.outputTokens, { min: 0, max: 20 }) +assert.deepEqual(counted.cost, { currency: 'CNY', min: 10 / 1e6, max: 180 / 1e6 }) +assert.equal(counted.scope, 'single_request') +assert.equal(counted.pipelineBudget, null) +assert.equal(counted.bookBudget, null) +assert.deepEqual(estimate({ ...request, countedInputTokens: 0 }).inputTokens, { min: 0, max: 0 }) +assert.deepEqual(estimate({ ...request, rates: [{ ...base, cachedInputPerMillionTokens: 8 }], countedInputTokens: 10 }).cost, + { currency: 'CNY', min: 40 / 1e6, max: 220 / 1e6 }) +for (const unknown of [{}, { messages: [] }, { messages: [{ content: '' }] }, { countedInputTokens: -1 }, { countedInputTokens: null }, + { countedInputTokens: 5.5 }, { countedInputTokens: Number.MAX_SAFE_INTEGER + 1 }, { countedInputTokens: 4, messages: [{ content: 'both' }] }]) { + const e = estimate({ ...request, ...unknown }) + assert.equal(e.inputTokens, null) + assert.equal(e.cost, null) +} +const messages = [{ role: 'system', content: '系统' }, { role: 'user', content: 'abcde' }] +assert.deepEqual(estimate({ ...request, messages }).inputTokens, { min: 3, max: 6 }) +assert.equal(estimate({ ...request, messages, rates: [] }).pricing, 'no_rate') +assert.equal(estimate({ ...request, messages, maxTokens: null }).cost, null) +const sub = estimate({ ...request, messages, provider: 'codex_cli' }) +assert.equal(sub.pricing, 'subscription'); assert.equal(sub.outputTokens, null); assert.equal(sub.cost, null) +assert(!/abcde|fixture|系统/.test(JSON.stringify(estimate({ ...request, messages })))) +console.log('PASS counted or text input; cache extremes; CLI and non-single budgets stay unknown; no message echo') + +// Record the two strings sent to a local dummy client. No bridge, network or model is involved. +let captured +const dummy = { requestJson: async (sys, user, _normalize, fallback) => { + captured = [{ role: 'system', content: sys }, { role: 'user', content: user }] + return { ok: true, data: fallback, usedAI: false } +} } +const local = { type: 'custom', revisionScope: 'local', fullChapterText: '样例全文只用于测试。', targetRange: '只用于测试', instruction: '' } +await new RevisionAI(dummy).generateRevision(local, '样例上下文') +assert.deepEqual(revisionMessages(local, '样例上下文'), captured) +assert(captured[1].content.includes(local.fullChapterText)) +const task = { goal: '合成任务', conflict: '', suspenseToKeep: '', allowedPayoffs: '', forbiddenPayoffs: '', + endingHook: '', readerEmotion: '', targetWordCount: '300', styleRequirement: '' } +const opts = { mode: 'standard', targetChapterOrder: 2, readerEmotionTarget: '', estimatedWordCount: '300', chapterTask: task } +const gen = new GenerationPipelineAI(dummy) +const plan = (await gen.generateChapterPlan('样例上下文', opts)).data +assert.deepEqual(chapterPlanMessages('样例上下文', opts), captured) +await gen.generateChapterDraft(plan, '正文上下文', opts) +assert.deepEqual(chapterDraftMessages(plan, '正文上下文', opts), captured) +const data = normalizeAppData({ projects: [{ id: 'a', name: '临时样例', modelRates: [discount] }], settings: { + apiProvider: provider, baseUrl: endpoint, modelName: model, pipelineModelRoles: { planner: { maxTokens: 313 }, prose: { maxTokens: 417 } } } }) +const job = { projectId: 'a', id: 'qa-job', targetChapterOrder: 2, contextSource: 'auto' } +const options = { targetChapterOrder: 2, pipelineMode: 'standard', estimatedWordCount: '300', readerEmotionTarget: '', budgetMode: 'standard', budgetMaxTokens: 9000 } +const source = JSON.stringify(data) +const preview = pipelineUsagePreview(data, data.projects[0], job, 'context_need_planning', options) +assert.match(preview, /章节规划模型.*\n预计输入:[\d,]+ 至 [\d,]+ token\n预计输出:0 至 313 token\n按本书当前价目:CNY/u) +assert.equal(JSON.stringify(data), source, 'preview mutated the book') +const openingOpts = { ...opts, targetChapterOrder: 1, authoritativeChapterTask: true } +const ranges = estimate({ ...request, messages: chapterDraftMessages(buildAuthoritativeOpeningPlan(task), '', openingOpts) }).inputTokens +const savedOpening = pipelineUsagePreview(data, data.projects[0], { ...job, targetChapterOrder: 1, chapterTaskSnapshot: task, + aiRunConfig: { ...data.settings, roles: { prose: { maxTokens: 197 } } } }, 'generate_chapter_plan', { ...options, targetChapterOrder: 1 }) +assert(savedOpening.includes(`预计输入:${ranges.min.toLocaleString('zh-CN')} 至 ${ranges.max.toLocaleString('zh-CN')} token`)) +assert.match(savedOpening, /正文生成模型[\s\S]*预计输出:0 至 197 token/) +assert.throws(() => pipelineUsagePreview(data, data.projects[0], { ...job, projectId: 'foreign' }, 'generate_chapter_plan', options)) +assert.throws(() => pipelineUsagePreview(data, data.projects[0], { ...job, contextSource: 'prompt_snapshot', promptContextSnapshotId: 'missing' }, 'generate_chapter_plan', options)) +console.log('PASS real message builders, local revision includes full text, in-memory context prep and frozen opening-draft settings') diff --git a/scripts/validate-project-usage-transport.mjs b/scripts/validate-project-usage-transport.mjs new file mode 100644 index 0000000..1f2d21c --- /dev/null +++ b/scripts/validate-project-usage-transport.mjs @@ -0,0 +1,377 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +const root = await mkdtemp(join(tmpdir(), 'novel-usage-transport-')) +const originalEnv = process.env +const originalFetch = globalThis.fetch +const noFetch = () => { throw new Error('Unexpected fetch outside a fixture') } +const credentials = { getApiKey: async () => 'private-primary', getBackupApiKey: async () => 'private-backup' } +const settings = { apiProvider: 'compatible', hasApiKey: true, modelName: 'test-model', + baseUrl: 'https://fixture.invalid:8443/private-route', temperature: 0.5, maxTokens: 200, + maxRetries: 1, retryEnabled: false, requestTimeoutMs: 5000 } +const context = { projectId: 'book-a', phase: 'writing' } +const request = (changes = {}) => ({ settings: { ...settings }, runId: 'run-a', clientCallId: 'reused-call', + messages: [{ role: 'user', content: 'private-manuscript' }], usageContext: { ...context }, ...changes }) +const json = (value, status = 200, id) => new Response(JSON.stringify(value), { + status, headers: id ? { 'x-request-id': id } : {} +}) +const payload = (content = '{"ok":true}', usage) => ({ choices: [{ message: { content }, finish_reason: 'stop' }], usage }) +const gate = () => { let open; const wait = new Promise(resolve => { open = resolve }); return { wait, open } } +const checkSafe = value => assert.ok(!/private-(?:primary|backup|route|manuscript|response|journal|stderr)|Bearer /i.test(JSON.stringify(value)), + 'Unexpected private data in public output') +let checks = 0 +async function test(label, fn) { await fn(); checks++; console.log('PASS ' + label) } + +try { + // No real home, credentials, provider executable, database or network in this harness. + process.env = { PATH: originalEnv.PATH, SystemRoot: originalEnv.SystemRoot, ComSpec: originalEnv.ComSpec, + HOME: root, USERPROFILE: root, APPDATA: root, CODEX_HOME: root, TMPDIR: root, TMP: root, TEMP: root } + globalThis.fetch = noFetch + const outfile = join(root, 'transport.mjs') + await build({ stdin: { resolveDir: repoRoot, contents: [ + "export { AIService } from './src/main/services/AIService'", + "export { AIHttpClient } from './src/main/services/AIHttpClient'", + "export { AiUsageReceipts } from './src/main/services/AiUsageReceipts'", + "export { DiagnosticsService } from './src/main/services/DiagnosticsService'", + "export { validateChatCompletionRequest } from './src/main/ipc/aiChatValidation'", + "export { AIWorkflowService } from './src/services/AIService'", + "export { createHeadlessTransport } from './src/agent/AgentPipelineExecutor'", + "export { readProjectUsageAttempts } from './src/storage/projectUsageJournal'" + ].join('\n') }, outfile, bundle: true, platform: 'node', format: 'esm', target: 'node22', + external: ['better-sqlite3', 'electron'], logLevel: 'silent' }) + const { AIService, AIHttpClient, AiUsageReceipts, AIWorkflowService, DiagnosticsService, validateChatCompletionRequest, + createHeadlessTransport, readProjectUsageAttempts } = await import(pathToFileURL(outfile).href) + + function fixture(send = noFetch, options = {}) { + const batches = [], logs = [] + let sends = 0 + const service = new AIService(options.credentials ?? credentials, options.limiters ?? {}, { + logger: { info: msg => logs.push(msg), warn: msg => logs.push(msg) }, + httpClient: new AIHttpClient({ beforeRequest: options.guard, fetch: (url, init) => send(++sends, url, init) }), + usageJournal: { getStoragePath: options.path ?? (() => 'library-a'), + append: async (path, rows) => { batches.push({ path, rows }); await options.persist?.(path, rows) } } + }) + return { service, batches, logs, sends: () => sends, rows: () => batches.flatMap(b => b.rows) } + } + + await test('401 + backup + format fallback + 503 retry + silent response: five dispatches, one flush', async () => { + const keys = [], formats = [] + const f = fixture((n, _url, init) => { + keys.push(new Headers(init.headers).get('authorization')) + formats.push(Boolean(JSON.parse(init.body).response_format)) + switch (n) { + case 1: return json({ error: 'private-response' }, 401, 'req-1') + case 2: return json({ error: 'unsupported response_format', usage: { input_tokens: 0 } }, 400, 'req-2') + case 3: return json({ request_id: 'req-3', usage: { output_tokens: 2 } }, 503) + case 4: return json({ ...payload('', { input_tokens: 11, output_tokens: 3 }), + choices: [{ message: { content: '' }, finish_reason: 'private-response' }] }, 200, 'req-4') + case 5: return json(payload('private-response', { input_tokens: 17, output_tokens: 5, total_tokens: 22 }), 200, 'req-5') + default: assert.fail('Unexpected extra model request') + } + }) + const r = await f.service.chatCompletion(request({ settings: { ...settings, retryEnabled: true } })) + assert.equal(r.content, 'private-response') + assert.equal(r.telemetry.attempts, f.sends()) + assert.equal(f.batches.length, 1) + assert.deepEqual(keys, ['private-primary', ...Array(4).fill('private-backup')].map(key => 'Bearer ' + key)) + assert.deepEqual(formats, [true, true, false, true, false]) + const rows = f.rows() + assert.equal(rows.length, 5) + assert.deepEqual(rows.map(r => r.outcome), ['failed', 'failed', 'failed', 'failed', 'success']) + assert.deepEqual(rows.map(r => r.providerRequestId), ['req-1', 'req-2', 'req-3', 'req-4', 'req-5']) + assert.deepEqual(rows.map(r => r.usage), [undefined, { promptTokens: 0 }, { completionTokens: 2 }, + { promptTokens: 11, completionTokens: 3 }, { promptTokens: 17, completionTokens: 5, totalTokens: 22 }]) + assert.equal(new Set(rows.map(r => r.id)).size, f.sends()) + for (const row of rows) { + assert.equal(row.callId, 'reused-call') + assert.notEqual(row.id, row.callId) + assert.equal(row.runId, 'run-a') + assert.equal(row.projectId, context.projectId) + assert.equal(row.phase, context.phase) + assert.equal(row.endpoint, 'https://fixture.invalid:8443') + assert.equal(row.provider, 'compatible') + assert.equal(row.model, settings.modelName) + assert.ok(Number.isFinite(Date.parse(row.startedAt))) + } + checkSafe([f.batches, f.logs]) + assert.doesNotMatch(f.logs.join('\n'), /req-[1-5]|reused-call|providerRequestId|prompt_tokens/) + }) + + await test('trace merges replayed upstream usage by field even when the journal fails', async () => { + for (const failJournal of [false, true]) { + const f = fixture(n => { + if (n === 1) return json({ usage: { input_tokens: 10, output_tokens: 3 } }, 401, 'same-upstream') + if (n === 2) return json({ error: 'unsupported response_format', + usage: { input_tokens: 10, output_tokens: 4, cached_input_tokens: 0 } }, 400, 'same-upstream') + return json(payload('ok', { input_tokens: 10, output_tokens: 3, total_tokens: 13 }), 200, 'same-upstream') + }, { persist: async () => { if (failJournal) throw new Error('private-journal') } }) + const r = await f.service.chatCompletion(request()) + assert.equal(r.ok, true) + assert.equal(r.telemetry.usageJournalIncomplete, failJournal || undefined) + assert.deepEqual(r.telemetry.usage, { promptTokens: 10, cachedPromptTokens: 0, totalTokens: 13 }) + assert.equal(r.telemetry.attempts, 3) + assert.equal(new Set(f.rows().map(r => r.id)).size, 3) + assert.deepEqual(f.rows().map(r => r.usage.completionTokens), [3, 4, 3]) + assert.equal(f.batches.length, 1) + } + const separate = new AiUsageReceipts(() => true) + for (const [origin, id] of [['https://a.invalid', 'same'], ['https://b.invalid', 'same'], + ['https://a.invalid', undefined], ['https://a.invalid', undefined]]) { + separate.start(origin) + separate.observe({ providerRequestId: id, usage: { promptTokens: 2 } }, 'success') + } + assert.deepEqual(separate.totalKnownUsage(), { promptTokens: 8 }) + }) + + await test('read failure, invalid JSON, unsafe IDs and absent usage retain separate safe receipts', async () => { + const f = fixture(n => { + if (n === 1) return new Response(new ReadableStream({ start(c) { c.error(new Error('private-response')) } }), + { headers: { 'request-id': 'read-failed' } }) + if (n === 2) return new Response('private-response', { headers: { 'x-request-id': 'private-primary' } }) + return json({ ...payload('ok', { input_tokens: -2, output_tokens: '0', total_tokens: null }), id: 'not-a-request-id' }, + 200, 'Bearer private-backup') + }) + for (const expected of [false, false, true]) { + const r = await f.service.chatCompletion(request()) + assert.equal(r.ok, expected) + assert.equal(r.telemetry.attempts, 1) + checkSafe(r.error ?? '') + } + assert.deepEqual(f.rows().map(r => [r.outcome, r.providerRequestId, r.usage]), [ + ['failed', 'read-failed', undefined], ['failed', undefined, undefined], ['success', undefined, undefined] + ]) + assert.equal(new Set(f.rows().map(r => r.id)).size, 3, 'external call ID was reused across logical calls') + checkSafe([f.logs, f.rows()]) + }) + + await test('project, phase and library are pinned before credentials, including concurrent calls', async () => { + const g = gate() + let path = 'library-a' + const dispatched = [] + const f = fixture((_n, url, init) => { + const body = JSON.parse(init.body) + dispatched.push([url, body.model, body.messages[0].content]) + return json(payload()) + }, { path: () => path, credentials: { getApiKey: () => g.wait } }) + const a = request() + const waiting = f.service.chatCompletion(a) + path = 'library-b' + a.usageContext.projectId = 'changed' + a.usageContext.phase = 'other' + a.settings.modelName = 'changed-model' + a.settings.baseUrl = 'https://other.invalid' + a.messages[0].content = 'changed' + const b = request({ runId: 'run-b', usageContext: { projectId: 'book-b', phase: 'revision' } }) + const next = f.service.chatCompletion(b) + g.open('fake-key') + await Promise.all([waiting, next]) + assert.ok(dispatched.length === 2 && dispatched.every(([url, model, content]) => + url === settings.baseUrl + '/chat/completions' && model === settings.modelName && content === 'private-manuscript')) + assert.deepEqual(f.batches.map(b => [b.path, ...b.rows.map(r => [r.projectId, r.phase])]).sort(), [ + ['library-a', ['book-a', 'writing']], ['library-b', ['book-b', 'revision']] + ]) + assert.equal(new Set(f.rows().map(r => r.id)).size, 2) + }) + + await test('failed receipt persistence is awaited; it never retries or hides the original result', async () => { + for (const status of [200, 403]) { + const started = gate(), release = gate() + const f = fixture(() => json(payload('private-response'), status), { + persist: async () => { started.open(); await release.wait; throw new Error('private-journal') } + }) + let settled = false + const pending = f.service.chatCompletion(request({ settings: { ...settings, retryEnabled: true } })) + .then(r => { settled = true; return r }) + await started.wait + assert.equal(settled, false) + release.open() + const result = await pending + assert.equal(result.ok, status === 200) + if (status === 200) assert.equal(result.content, 'private-response') + else assert.match(result.error, /403/) + assert.equal(result.telemetry.usageJournalIncomplete, true) + assert.equal(f.sends(), 1) + assert.equal(f.batches.length, 1) + checkSafe(f.logs) + } + }) + + await test('local validation, credential/guard failures and pre-send cancellation create no receipts', async () => { + for (const [opts, overrides] of [ + [{}, { maxTokens: 0 }], [{}, { baseUrl: 'not-a-url' }], [{}, { baseUrl: 'https://user:private-primary@fixture.invalid' }], + [{ credentials: { getApiKey: async () => '' } }, {}], + [{ guard: async () => { throw new Error('local permission changed') } }, {}] + ]) { + const f = fixture(noFetch, opts) + const r = await f.service.chatCompletion(request({ settings: { ...settings, ...overrides } })) + assert.equal(r.ok, false) + assert.equal(r.telemetry.attempts, 0) + assert.equal(f.sends(), 0) + assert.equal(f.batches.length, 0) + } + const entered = gate(), release = gate() + const f = fixture(noFetch, { limiters: { compatible: { acquire: async () => { entered.open(); await release.wait } } } }) + const pending = f.service.chatCompletion(request()) + await entered.wait + assert.equal(f.service.cancelCall('run-a', 'reused-call'), true) + release.open() + assert.equal((await pending).telemetry.terminationCategory, 'cancelled') + assert.equal(f.sends(), 0) + assert.equal(f.batches.length, 0) + }) + + await test('cancel while reading preserves the header ID and counts exactly the dispatched request', async () => { + const entered = gate() + const f = fixture((_n, _url, init) => new Response(new ReadableStream({ start(c) { + init.signal.addEventListener('abort', () => c.error(new Error('aborted')), { once: true }) + entered.open() + } }), { headers: { 'x-request-id': 'cancelled-id' } })) + const pending = f.service.chatCompletion(request()) + await entered.wait + f.service.cancelRun('run-a') + const r = await pending + assert.equal(r.telemetry.terminationCategory, 'cancelled') + assert.deepEqual(f.rows().map(r => [r.outcome, r.providerRequestId, r.usage]), [['cancelled', 'cancelled-id', undefined]]) + }) + + await test('chat IPC validates attribution; renderer business methods pass the actual purpose', async () => { + for (const invalid of [null, [], {}, { phase: 'writing', projectId: ' ' }, { phase: 'fake', projectId: 'a' }, + { ...context, storagePath: 'wrong' }, { ...context, projectId: 'a\nb' }]) { + assert.throws(() => validateChatCompletionRequest(request({ usageContext: invalid }))) + } + const seen = [] + const chatCompletion = async req => { + const checked = validateChatCompletionRequest(req) + assert.equal(checked.runId, 'desktop-run') + assert.equal(checked.usageContext.projectId, 'desktop-book') + seen.push(checked.usageContext.phase) + return { ok: false, error: 'offline fixture' } + } + globalThis.window = { novelDirector: { ai: { chatCompletion } } } + const ai = new AIWorkflowService(settings, { projectId: 'desktop-book', runId: 'desktop-run' }) + const opts = { chapterTask: { goal: 'fixture' }, targetChapterOrder: 2, mode: 'standard' } + const draft = { title: 'fixture', body: 'fixture body' } + await ai.generateChapterPlan('context', opts) + await ai.generateChapterDraft({}, 'context', opts) + await ai.generateChapterReview(draft.body, 'context') + await ai.generatePostDraftAnalysis(draft.body, 'context', [], []) + await ai.updateCharacterStates(draft.body, [], 'context') + await ai.extractForeshadowing(draft.body, [], 'context') + await ai.generateNextChapterSuggestions(draft, 'context') + await ai.generateConsistencyReview(draft, 'context') + await ai.generateQualityGateReport(draft, 'context', null) + await ai.generateRevisionCandidate(draft, {}, 'context') + await ai.generateRevision({ type: 'improve_dialogue', instruction: 'edit', fullChapterText: draft.body }, 'context') + await ai.polishStoryDirectionIdea({ project: { name: 'fixture' }, recentStageSummaries: [], userRawIdea: 'idea' }) + await ai.generateStoryDirectionGuide({ project: { name: 'fixture' }, userRawIdea: 'idea', userPolishedIdea: '', + startChapterOrder: 1, endChapterOrder: 3, stageSummaries: [], recentChapters: [], activeForeshadowings: [], + characters: [], timelineEvents: [], storyBible: null }) + assert.deepEqual(seen, ['planning', 'writing', 'review', 'extraction', 'extraction', 'extraction', 'planning', + 'review', 'review', 'revision', 'revision', 'planning', 'planning']) + }) + + const qualityRequest = () => ({ settings: { ...settings, apiProvider: 'local' }, runId: 'diagnostic-run', + projectId: 'diagnostic-book', jobId: 'diagnostic-job', chapterId: null, draftId: null, + chapterDraft: { title: 'fixture', body: 'private-manuscript' }, context: 'fixture', chapterPlan: null }) + + await test('main diagnostics pin library and project before lazy loading', async () => { + let path = 'diagnostic-library' + const f = fixture(() => json(payload()), { path: () => path }) + const q = qualityRequest() + const pending = new DiagnosticsService(f.service).evaluateQualityGate(q) + path = 'other-library' + q.projectId = 'other-book' + const report = await pending + assert.equal(report.projectId, 'diagnostic-book') + assert.equal(report.aiTelemetry.attempts, 1) + assert.equal(f.batches[0].path, 'diagnostic-library') + assert.deepEqual(f.rows().map(r => [r.projectId, r.phase, r.runId]), [['diagnostic-book', 'review', 'diagnostic-run']]) + }) + + await test('headless workflow and diagnostics use the same temporary library and check before each dispatch', async () => { + const path = join(root, 'fake-library.json') + let sends = 0, guards = 0, refuse = false + globalThis.fetch = async () => ++sends === 1 ? json({ error: 'unsupported response_format' }, 400) : json(payload()) + try { + const q = qualityRequest() + const h = createHeadlessTransport(q.settings, new Map(), path, async () => { + guards++ + if (refuse) throw new Error('blocked by local guard') + }) + const ai = new AIWorkflowService(q.settings, { transport: h.transport, projectId: q.projectId, runId: q.runId }) + await ai.reduceAITone('private-manuscript', 'context') + await h.diagnostics.evaluateQualityGate(q) + refuse = true + await ai.reduceAITone('private-manuscript', 'context') + const journal = await readProjectUsageAttempts(path, q.projectId) + assert.deepEqual(journal.rows.map(r => [r.phase, r.outcome]), [ + ['revision', 'failed'], ['revision', 'success'], ['review', 'success'] + ]) + assert.ok(journal.rows.every(r => r.runId === q.runId && r.projectId === q.projectId && !r.usage)) + assert.equal(journal.rows.length, sends) + assert.equal(guards, sends + 1) + assert.equal(journal.invalidRecords, 0) + checkSafe(journal) + } finally { globalThis.fetch = noFetch } + }) + + await test('real fake CLI exec: reported, missing and failed usage; status and pre-spawn failures are not dispatches', async () => { + const executable = join(root, process.platform === 'win32' ? 'codex.cmd' : 'codex') + const runner = join(root, 'fixture.cjs'), observed = join(root, 'exec-events.txt') + await writeFile(runner, [ + "const fs = require('node:fs'), a = process.argv.slice(2)", + "if (a[0] === '--version') { console.log('codex-cli 1.2.3'); return }", + "if (a[0] === 'login') { console.log('ChatGPT'); return }", + "if (a[0] !== 'exec' || !a.includes('--json')) process.exit(9)", + "const m = a[a.indexOf('--model')+1]", + "fs.appendFileSync(" + JSON.stringify(observed) + ", m+'\\n')", + "const emit = e => console.log(JSON.stringify(e))", + "emit({type:'item.completed',item:{type:'reasoning',text:'private-response-internal'}})", + "if (m !== 'failed') emit({type:'item.completed',item:{type:'agent_message',text:'private-response'}})", + "if (m !== 'unknown') emit({type:m==='failed'?'turn.failed':'turn.completed',request_id:'cli-'+m,", + " usage:{input_tokens:21,cached_input_tokens:3,output_tokens:5,reasoning_output_tokens:0}})", + "if (m === 'failed') { console.error('private-stderr'); process.exitCode=4 }" + ].join('\n')) + await writeFile(executable, process.platform === 'win32' + ? '@echo off\r\n"' + process.execPath + '" "' + runner + '" %*\r\n' + : '#!/bin/sh\nexec "' + process.execPath + '" "' + runner + '" "$@"\n', { mode: 0o700 }) + const f = fixture() + const cli = (model, command = executable) => f.service.chatCompletion(request({ settings: { + ...settings, apiProvider: 'codex_cli', codexCliPath: command, codexCliModel: model } })) + assert.equal((await f.service.getCodexCliStatus(executable)).authenticated, true) + assert.equal((await cli('bad model')).telemetry.attempts, 0) + assert.equal((await cli('known', join(root, 'missing', 'codex'))).telemetry.attempts, 0) + assert.equal(f.batches.length, 0) + for (const model of ['known', 'unknown', 'failed']) { + const r = await cli(model) + assert.equal(r.ok, model !== 'failed') + assert.equal(r.telemetry.attempts, 1) + if (r.ok) assert.equal(r.content, 'private-response') + else { assert.match(r.error, /4/); checkSafe(r.error) } + } + assert.deepEqual((await readFile(observed, 'utf8')).trim().split('\n'), ['known', 'unknown', 'failed']) + const counts = { promptTokens: 21, cachedPromptTokens: 3, completionTokens: 5, reasoningTokens: 0 } + const rows = f.rows() + assert.deepEqual(rows.map(r => [r.model, r.outcome, r.usage, r.providerRequestId]), [ + ['known', 'success', counts, 'cli-known'], ['unknown', 'success', undefined, undefined], + ['failed', 'failed', counts, 'cli-failed'] + ]) + assert.ok(rows.every(r => r.endpoint === '' && r.provider === 'codex_cli')) + assert.equal(new Set(rows.map(r => r.id)).size, 3) + assert.equal(f.sends(), 0) + checkSafe([rows, f.logs]) + assert.doesNotMatch(f.logs.join('\n'), /cli-known|cli-failed|turn\.completed|input_tokens|providerRequestId/) + }) + console.log('validate-project-usage-transport: ' + checks + ' scenarios passed') +} finally { + globalThis.fetch = originalFetch + delete globalThis.window + process.env = originalEnv + await rm(root, { recursive: true, force: true }) +} diff --git a/scripts/validate-project-usage.mjs b/scripts/validate-project-usage.mjs new file mode 100644 index 0000000..67c8004 --- /dev/null +++ b/scripts/validate-project-usage.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict' +import { appendFile, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'esbuild' +import { createLongNovelFixture, LONG_NOVEL_PROJECT_ID as book, LONG_NOVEL_SECOND_PROJECT_ID as other } from './fixtures/long-novel-performance.mjs' +import { repoRoot } from './utils/repo-root.mjs' + +const tmp = join(repoRoot, 'tmp') +await mkdir(tmp, { recursive:true }) +const work = await mkdtemp(join(tmp, 'book-usage-test-')) +try { + const outfile = join(work, 'tests.mjs') + const entries = ['shared/defaults','services/projectUsageHistory','services/ProjectUsageService','storage/projectUsageJournal', + 'storage/readProjectUsage','storage/JsonStorageService','storage/SqliteStorageService','agent/tools/AgentToolService'] + await build({ stdin:{ resolveDir:repoRoot, contents:entries.map(m => 'export * from "./src/' + m + '"').join('\n') }, + outfile, bundle:true, format:'esm',platform:'node',target:'node22',external:['electron','better-sqlite3'],logLevel:'silent' }) + const api = await import(pathToFileURL(outfile)) + const base = createLongNovelFixture(1), job = base.chapterGenerationJobs[0].id + const trace = base.generationRunTraces[0], tpl = trace.aiCalls[1] + const origin = 'https://meter.invalid', second = 'https://second.invalid', model = 'synthetic' + const old = (id, input, output, attempts = 1) => ({ ...tpl, id, callId:id, provider:'compatible',model, + usage:{promptTokens:input,completionTokens:output,totalTokens:input+output}, attempts }) + trace.aiCalls = [old('live',900,100), old('archive',8,2,2)] + base.qualityGateReports[0].aiTelemetry = trace.aiCalls[1] + trace.aiCalls[1].usageJournalIncomplete = true + base.chapterCommitBundles[0].generationRunTrace = { ...trace, aiCalls:[...trace.aiCalls,old('commit-only',1,2)] } + base.chapterGenerationJobs.push({ ...base.chapterGenerationJobs[0],id:'silent',status:'failed' }) + base.projects.find(p => p.id === book).modelRates = [ + {provider:'compatible',endpoint:origin,model,currency:'CNY',inputPerMillionTokens:1,outputPerMillionTokens:2,cachedInputPerMillionTokens:0.25}, + {provider:'compatible',endpoint:second,model,currency:'USD',inputPerMillionTokens:2,outputPerMillionTokens:4} + ] + const data = api.normalizeAppData(base) + const at = (id,callId,phase,usage,extra = {}) => ({ id,callId,phase,usage,startedAt:'2026-11-11T02:00:00Z', + provider:'compatible',model,runId:job,projectId:book,endpoint:origin,outcome:'success',...extra }) + const records = [at('a1','live','writing',undefined,{outcome:'failed'}), + at('a2','live','writing',{promptTokens:100,completionTokens:20,cachedPromptTokens:25}, + {endpoint:'https://private:DO_NOT_STORE@meter.invalid/v1?token=DO_NOT_STORE',providerRequestId:'remote-2', prompt:'NEVER_STORE_PROSE'}), + at('r','unaccepted','revision',{promptTokens:10,completionTokens:5},{endpoint:second,runId:'independent-revision'}), + at('cli','subscription','other',undefined,{provider:'codex_cli',endpoint:''}), + at('q1','format','review',{promptTokens:40,completionTokens:4,cachedPromptTokens:0}), + at('q2','format','review',{promptTokens:40,completionTokens:8,cachedPromptTokens:40}), + at('partial','unpriced','planning',{totalTokens:7},{model:'no-rate'})] + for (const [ext,Service] of [['json',api.JsonStorageService],['sqlite',api.SqliteStorageService]]) { + const dir = join(work,ext); await mkdir(dir) + const path = join(dir,'novel-director-data.'+ext), storage = new Service(path) + try { + await storage.save(data) + const rev = (await storage.loadSnapshot()).revision + const journal = api.projectUsagePath(path,book) + const empty = await api.readProjectUsage(storage,other) + assert.equal(empty.totals.tokens.totalTokens,null) + assert.equal(empty.totals.requests,0) + await assert.rejects(readdir(dirname(journal)), { code:'ENOENT' }) + await Promise.all([api.appendProjectUsageAttempts(path, records.slice(0,3)),api.appendProjectUsageAttempts(path,records.slice(3))]) + await api.appendProjectUsageAttempts(path,[records[1],at('other','live','writing',{totalTokens:9999},{projectId:other})]) + await appendFile(journal,'\n{"interrupted":', 'utf8') + await api.appendProjectUsageAttempts(path,[records[0]]) + const report = await api.readProjectUsage(storage,book) + const mcp = await api.AgentToolService.callTool({ name:'agent.getProjectUsage',arguments:{projectId:book,storagePath:path,userDataPath:dir} }) + assert.deepEqual(mcp.data,report) + assert.equal(report.totals.requests,7); assert.equal(report.totals.failedRequests,1) + assert.equal(report.totals.tokens.totalTokens,247) + assert.equal(report.totals.tokens.reasoningTokens,null) + assert.equal(report.totals.unknownUsageRequests,2) + assert.equal(report.totals.partialUsageRequests,1); assert.equal(report.totals.subscriptionRequests,1) + assert.equal(report.totals.unpricedRequests,2) + assert.deepEqual(report.totals.costs,[{currency:'CNY',amount:0.00019525,pricedRequests:3},{currency:'USD',amount:0.00004,pricedRequests:1}]) + assert.equal(report.byPhase.find(p=>p.phase==='revision').tokens.totalTokens,15) + assert.equal(report.coverage.legacyCalls,2) + assert.equal(report.coverage.unreportedRequests,1) + assert(report.warnings.some(w=>w.includes('1 次调用曾报告用量记录未完整保存'))) + assert.equal(report.coverage.legacyJobsWithoutUsage,1);assert.equal(report.coverage.invalidRecords,1) + assert.equal(report.balance,null) + assert.equal((await api.readProjectUsage(storage,other)).totals.tokens.totalTokens,9999) + const estimate = await api.AgentToolService.callTool({name:'agent.estimateProjectUsage', + arguments:{projectId:book,storagePath:path,userDataPath:dir,provider:'compatible',endpoint:origin,model,inputTokens:120,maxOutputTokens:64}}) + assert.deepEqual(estimate.data.inputTokens,{min:120,max:120}) + assert.deepEqual(estimate.data.outputTokens,{min:0,max:64}); assert.equal(estimate.data.cost.currency,'CNY') + await assert.rejects(api.AgentToolService.callTool({name:'agent.estimateProjectUsage',arguments:{projectId:other,storagePath:path,userDataPath:dir, + provider:'compatible',endpoint:origin,model,snapshotId:'perf-snapshot-1',maxOutputTokens:64}}),/找不到/) + const fromSnap = await api.AgentToolService.callTool({name:'agent.estimateProjectUsage',arguments:{projectId:book,storagePath:path,userDataPath:dir, + provider:'compatible',endpoint:origin,model,snapshotId:'perf-snapshot-1',maxOutputTokens:64}}) + assert.equal(fromSnap.data.inputBasis,'snapshot_only'); assert(fromSnap.data.inputTokens.min>100) + const raw = await readFile(journal,'utf8') + assert.doesNotMatch(raw,/DO_NOT_STORE|NEVER_STORE_PROSE/) + assert.doesNotMatch(JSON.stringify([report,fromSnap]),/NEVER_STORE_PROSE|核对第1条线索/) + assert.equal((await storage.loadSnapshot()).revision,rev, 'usage writes do not make the current manuscript stale') + await storage.saveIfCurrent(data,rev) + assert.deepEqual(await api.readProjectUsage(storage,book),report,'a full manuscript save cannot erase receipts') + if (ext === 'json') { + const sibling = new api.SqliteStorageService(join(dir, 'novel-director-data.sqlite')) + try { + await sibling.save(data) + const siblingReport = await api.readProjectUsage(sibling, book) + assert.equal(siblingReport.totals.requests, 0) + assert.deepEqual((await api.AgentToolService.callTool({name:'agent.getProjectUsage', + arguments:{projectId:book,storagePath:path,userDataPath:dir}})).data, report) + const missing = join(dir, 'not-created.json') + await assert.rejects(api.readProjectUsage(new api.JsonStorageService(missing), book), {code:'ENOENT'}) + await assert.rejects(readFile(missing), {code:'ENOENT'}) + } finally { sibling.close() } + } + console.log('PASS '+ext+': two projects, unaccepted work, real sends, deduplication, old/missing counts, two currencies, crash tail, MCP quotes, privacy and independent saves') + } finally { storage.close?.() } + } + const history = { projectId:book,rates:data.projects[0].modelRates,finishedJobs:[],calls:[] } + const same = {...records[1],endpoint:origin}, repeated = api.buildProjectUsageReport(history,[same,{...same,id:'other-send'}]) + assert.equal(repeated.totals.requests,2) + assert.equal(repeated.totals.tokens.totalTokens,120) + const ambiguous = api.buildProjectUsageReport(history,[same,{...same,usage:{promptTokens:901}}]) + assert.equal(ambiguous.totals.tokens.totalTokens,null); assert.equal(ambiguous.coverage.invalidRecords,1) + const lateLink = api.buildProjectUsageReport({...history, finishedJobs:[job], calls:[{jobId:job, phase:'writing', + telemetry:old('live',100,20)}]}, [{...same,runId:undefined}]) + assert.equal(lateLink.totals.tokens.totalTokens,120) + assert.equal(lateLink.coverage.legacyJobsWithoutUsage,0) + console.log('PASS duplicate provider receipts and conflicting counts are not billed twice') +} finally { await rm(work,{recursive:true,force:true,maxRetries:3}) } diff --git a/scripts/validate-prompt-compression-replacement.mjs b/scripts/validate-prompt-compression-replacement.mjs index 4209e86..9eff9fc 100644 --- a/scripts/validate-prompt-compression-replacement.mjs +++ b/scripts/validate-prompt-compression-replacement.mjs @@ -1,75 +1,16 @@ -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import ts from 'typescript' +import { loadDomainModules } from './utils/load-domain-modules.mjs' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' import { repoRoot } from './utils/repo-root.mjs' const root = repoRoot -const outDir = join(root, 'tmp', 'prompt-compression-replacement-test') function assert(condition, message, details = {}) { return condition ? { ok: true, message } : { ok: false, message, details } } -function rewriteRelativeImports(source) { - return source.replace(/(from\s+['"])(\.{1,2}\/[^'"]+?)(['"])/g, (_match, prefix, specifier, suffix) => { - if (/\.(mjs|js|json)$/.test(specifier)) return `${prefix}${specifier}${suffix}` - return `${prefix}${specifier}.mjs${suffix}` - }) -} - -async function compileTsTree(files) { - await rm(outDir, { recursive: true, force: true }) - for (const relativePath of files) { - const source = await readFile(join(root, relativePath), 'utf-8') - const compiled = ts.transpileModule(source, { - compilerOptions: { - module: ts.ModuleKind.ES2022, - target: ts.ScriptTarget.ES2022, - jsx: ts.JsxEmit.ReactJSX, - useDefineForClassFields: true - } - }) - const outPath = join(outDir, relativePath).replace(/\.tsx?$/, '.mjs') - await mkdir(dirname(outPath), { recursive: true }) - await writeFile(outPath, rewriteRelativeImports(compiled.outputText), 'utf-8') - } -} - -async function loadModules() { - await compileTsTree([ - 'src/shared/chapterText.ts', - 'src/shared/foreshadowingTreatment.ts', - 'src/services/TokenEstimator.ts', - 'src/services/ContinuityService.ts', - 'src/services/CharacterStateService.ts', - 'src/services/characterState/logInference.ts', - 'src/services/characterState/stateMutations.ts', - 'src/services/characterState/stateSelection.ts', - 'src/services/characterState/stateValidation.ts', - 'src/services/characterState/stateValue.ts', - 'src/services/StageSummaryService.ts', - 'src/services/ContextCompressionService.ts', - 'src/services/contextBudget/types.ts', - 'src/services/contextBudget/scoringEngine.ts', - 'src/services/contextBudget/selectionEngine.ts', - 'src/services/contextBudget/selectionFinalizer.ts', - 'src/services/contextBudget/traceBuilder.ts', - 'src/services/ChapterLifecycleService.ts', - 'src/services/ContextBudgetManager.ts', - 'src/services/StoryDirectionService.ts', - 'src/services/HardCanonPackService.ts', - 'src/services/PromptLintService.ts', - 'src/services/promptFormatters/chapterFormatters.ts', - 'src/services/promptFormatters/characterFormatters.ts', - 'src/services/promptFormatters/foreshadowingFormatters.ts', - 'src/services/promptFormatters/promptUtils.ts', - 'src/services/PromptBuilderService.ts' - ]) - const contextBudget = await import(`${pathToFileURL(join(outDir, 'src/services/ContextBudgetManager.mjs')).href}?t=${Date.now()}`) - const promptBuilder = await import(`${pathToFileURL(join(outDir, 'src/services/PromptBuilderService.mjs')).href}?t=${Date.now()}`) - const compression = await import(`${pathToFileURL(join(outDir, 'src/services/ContextCompressionService.mjs')).href}?t=${Date.now()}`) - return { ...contextBudget, ...promptBuilder, ...compression } +function loadModules() { + return loadDomainModules('services/ContextBudgetManager', 'services/PromptBuilderService', 'services/ContextCompressionService') } const timestamp = '2026-01-01T00:00:00.000Z' diff --git a/scripts/validate-prompt-priority-stack.mjs b/scripts/validate-prompt-priority-stack.mjs index 299f4f2..ac58be7 100644 --- a/scripts/validate-prompt-priority-stack.mjs +++ b/scripts/validate-prompt-priority-stack.mjs @@ -1,72 +1,15 @@ -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import ts from 'typescript' +import { loadDomainModules } from './utils/load-domain-modules.mjs' +import { join } from 'node:path' import { repoRoot } from './utils/repo-root.mjs' const root = repoRoot -const outDir = join(root, 'tmp', 'prompt-priority-stack-test') function assert(condition, message, details = {}) { return condition ? { ok: true, message } : { ok: false, message, details } } -function rewriteRelativeImports(source) { - return source.replace(/(from\s+['"])(\.{1,2}\/[^'"]+?)(['"])/g, (_match, prefix, specifier, suffix) => { - if (/\.(mjs|js|json)$/.test(specifier)) return `${prefix}${specifier}${suffix}` - return `${prefix}${specifier}.mjs${suffix}` - }) -} - -async function compileTsTree(files) { - await rm(outDir, { recursive: true, force: true }) - for (const relativePath of files) { - const source = await readFile(join(root, relativePath), 'utf-8') - const compiled = ts.transpileModule(source, { - compilerOptions: { - module: ts.ModuleKind.ES2022, - target: ts.ScriptTarget.ES2022, - jsx: ts.JsxEmit.ReactJSX, - useDefineForClassFields: true - } - }) - const outPath = join(outDir, relativePath).replace(/\.tsx?$/, '.mjs') - await mkdir(dirname(outPath), { recursive: true }) - await writeFile(outPath, rewriteRelativeImports(compiled.outputText), 'utf-8') - } -} - -async function loadPromptBuilder() { - await compileTsTree([ - 'src/shared/chapterText.ts', - 'src/shared/foreshadowingTreatment.ts', - 'src/services/TokenEstimator.ts', - 'src/services/ContinuityService.ts', - 'src/services/CharacterStateService.ts', - 'src/services/characterState/logInference.ts', - 'src/services/characterState/stateMutations.ts', - 'src/services/characterState/stateSelection.ts', - 'src/services/characterState/stateValidation.ts', - 'src/services/characterState/stateValue.ts', - 'src/services/StageSummaryService.ts', - 'src/services/ContextCompressionService.ts', - 'src/services/contextBudget/types.ts', - 'src/services/contextBudget/scoringEngine.ts', - 'src/services/contextBudget/selectionEngine.ts', - 'src/services/contextBudget/selectionFinalizer.ts', - 'src/services/contextBudget/traceBuilder.ts', - 'src/services/ChapterLifecycleService.ts', - 'src/services/ContextBudgetManager.ts', - 'src/services/StoryDirectionService.ts', - 'src/services/HardCanonPackService.ts', - 'src/services/PromptLintService.ts', - 'src/services/promptFormatters/chapterFormatters.ts', - 'src/services/promptFormatters/characterFormatters.ts', - 'src/services/promptFormatters/foreshadowingFormatters.ts', - 'src/services/promptFormatters/promptUtils.ts', - 'src/services/PromptBuilderService.ts' - ]) - return import(`${pathToFileURL(join(outDir, 'src/services/PromptBuilderService.mjs')).href}?t=${Date.now()}`) +function loadPromptBuilder() { + return loadDomainModules('services/PromptBuilderService') } const timestamp = '2026-01-01T00:00:00.000Z' diff --git a/scripts/validate-redundant-work.mjs b/scripts/validate-redundant-work.mjs new file mode 100644 index 0000000..f9d58ee --- /dev/null +++ b/scripts/validate-redundant-work.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict' +import { build } from 'esbuild' +import { repoRoot } from './utils/repo-root.mjs' + +const bundle = await build({ + stdin: { resolveDir: repoRoot, loader: 'ts', contents: ` + export * from './src/services/novelty/noveltyText'; + export { createKeywordMatcher } from './src/services/novelty/noveltyMatcher'; + export { TokenEstimator } from './src/services/TokenEstimator'; + export { PromptLintService } from './src/services/PromptLintService'; + export { renderPromptBlock, promptBlockOrderFromFinalPrompt } from './src/services/promptFormatters/promptUtils'; + export { REVISION_SYSTEM_PROMPT, buildRevisionUserPrompt } from './src/services/ai/AIPromptTemplates'; + ` }, bundle: true, write: false, platform: 'node', format: 'esm', target: 'node24', logLevel: 'silent' +}) +const api = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString('base64')}`) +let checks = 0 +const check = (name, run) => { run(); checks++; console.log(`PASS ${name}`) } + +check('same source is segmented once across different lookup terms, without cross-audit caching', () => { + const source = '档案写明临时权限需要支付代价,顾临川已知此事。API-X 不是 APIX。' + const prototype = Intl.Segmenter.prototype, original = prototype.segment + let segments = 0 + prototype.segment = function (text) { + if (text === source) segments++ + return original.call(this, text) + } + try { + const run = () => api.withNoveltyAuditMemo(() => { + for (const [term, expected] of [['临时权限', true], ['冗余池', false], ['顾临川', true], ['API', true], ['API-X', true]]) { + assert.equal(api.textMatches(source, term), expected) + assert.equal(api.textMatches(source, term), expected) + } + }) + run() + assert.equal(segments, 1) + run() + assert.equal(segments, 2) + } finally { prototype.segment = original } +}) + +check('prepared dictionary matching preserves source offsets and independent query results', () => { + const text = '\u{20000}前文。API 权限:【附加条款】立即生效。\n 临时权限需要支付代价。' + const match = api.createKeywordMatcher(text) + for (const terms of [['API'], ['附加条款', '条款'], ['临时权限'], ['APIX'], []]) { + for (const radius of [0, 15, 130]) { + const actual = match(terms, radius) + assert.deepEqual(actual, api.keywordOccurrences(text, terms, radius)) + for (const item of actual) { + assert.equal(text.slice(item.sentenceStart, item.sentenceEnd), item.sentence) + assert.equal(text.slice(item.index, item.matchEnd).normalize('NFKC'), item.keyword) + } + } + } + const first = match(['API']) + first[0].sentence = 'caller edited result' + assert.notEqual(match(['API'])[0].sentence, first[0].sentence) +}) + +check('token estimates retain the prior Unicode and rounding semantics', () => { + const reference = (text) => { + if (!text?.trim()) return 0 + let cjk = 0, ascii = 0, other = 0 + for (const char of text) { + if (/[\u4e00-\u9fff]/.test(char)) cjk++ + else if (/[\x00-\x7F]/.test(char)) ascii++ + else other++ + } + return Math.ceil(cjk * 1.5 + ascii / 4 + other) + } + const texts = [null, undefined, '', ' \r\n\t', '中文 API 123', '\u{20000}\u{1f600}Aé\ud800'] + for (let start = 0; start <= 0xffff; start += 257) { + texts.push(Array.from({ length: 257 }, (_, offset) => String.fromCharCode(start + offset)).join('')) + } + for (const text of texts) assert.equal(api.TokenEstimator.estimate(text), reference(text)) +}) + +check('prompt trace uses final rendered sections without rendering draft bodies again', () => { + let reads = 0 + const make = (id, text, extra = {}) => ({ id, title: id, kind: 'test', priority: 1, source: 'fixture', + sourceIds: ['source'], reason: 'fixture', enabled: true, get body() { reads++; return text }, ...extra }) + const blocks = [make('present', '正文资料\n不得改写事实。'), + make('disabled', '不可读', { enabled: false, omittedReason: '用户关闭' }), + make('empty', '待补充'), make('removed', 'Novelty guardrail: Do not introduce new rules.')] + const rendered = blocks.map(api.renderPromptBlock) + const prompt = api.PromptLintService.guardWritingPrompt(rendered.join('\n')).guardedPrompt + const before = reads + const trace = api.promptBlockOrderFromFinalPrompt(blocks, prompt, rendered) + assert.equal(reads, before) + assert.deepEqual(trace.map((item) => item.included), [true, false, false, false]) + assert.equal(trace[0].tokenEstimate, api.TokenEstimator.estimate(prompt)) + assert.deepEqual(trace[0].sourceIds, ['source']) + assert.equal(trace[1].omittedReason, '用户关闭') + assert.equal(trace[2].omittedReason, '模块关闭或无可用内容。') + assert.equal(trace[3].omittedReason, '运行时 Prompt guard 已移除该区块。') + assert.deepEqual(api.promptBlockOrderFromFinalPrompt(blocks, prompt), trace) +}) + +check('revision instructions include only relevant operations and preserve local/full contracts', () => { + const request = { type: 'fix_character_knowledge', instruction: '只修正认知越界', revisionScope: 'local', + fullChapterText: '开头。目标句。结尾。', targetRange: '目标句。' } + const local = api.buildRevisionUserPrompt(request, '只使用已知记录') + assert(local.includes('revisedText 必须只包含 targetRange')) + assert(local.includes(request.fullChapterText) && local.includes(request.targetRange)) + assert(local.includes(request.instruction) && local.includes('treatmentMode')) + assert(!local.includes('开头必须接住') && !local.includes('优先删减') && !local.includes('改善指定文本')) + for (const type of ['fix_continuity', 'improve_continuity', 'strengthen_chapter_transition']) { + assert(api.buildRevisionUserPrompt({ ...request, type }, '').includes('开头必须接住')) + } + for (const type of ['compress_pacing', 'reduce_redundancy', 'compress_description', 'remove_repeated_explanation']) { + assert(api.buildRevisionUserPrompt({ ...request, type }, '').includes('优先删减')) + } + const full = api.buildRevisionUserPrompt({ ...request, revisionScope: 'full', type: 'reduce_ai_tone' }, '') + assert(full.includes('revisedText 必须返回完整章节正文')) + assert(!full.includes('targetRange') && !full.includes('')) + assert(full.includes('未命中规则的文字必须逐字保留')) + assert(!full.includes('优先删减') && !full.includes('开头必须接住')) + assert(api.REVISION_SYSTEM_PROMPT.includes('不改变剧情事实')) + assert(api.REVISION_SYSTEM_PROMPT.includes('不新增重大设定')) + assert(api.REVISION_SYSTEM_PROMPT.includes('严格 JSON')) +}) + +console.log(`Redundant work validation passed (${checks} groups).`) diff --git a/scripts/validate-revision-candidate-context-source.mjs b/scripts/validate-revision-candidate-context-source.mjs index b272aaf..a532feb 100644 --- a/scripts/validate-revision-candidate-context-source.mjs +++ b/scripts/validate-revision-candidate-context-source.mjs @@ -99,7 +99,7 @@ async function main() { checks.push( assert( revisionActionsSource.includes("resolvePipelineRoleSettings(sourceJob?.aiRunConfig, data.settings, 'revision')") && - revisionActionsSource.includes("new AIService(runSettings, { runId: sourceJob?.id ?? draft.jobId })"), + revisionActionsSource.includes("new AIService(runSettings, { projectId: draft.projectId, runId: sourceJob?.id ?? draft.jobId })"), 'revision candidate generation inherits the frozen source-run revision role and run identity' ) ) diff --git a/scripts/validate-revision-contract.mjs b/scripts/validate-revision-contract.mjs index f6c017f..b714a01 100644 --- a/scripts/validate-revision-contract.mjs +++ b/scripts/validate-revision-contract.mjs @@ -140,8 +140,8 @@ async function main() { fullPrompt.includes('revisionScope') && fullPrompt.includes('full') && fullPrompt.includes('fullChapterText') && - fullPrompt.includes('targetRange') && - fullPrompt.includes('') && + !fullPrompt.includes('targetRange') && + !fullPrompt.includes('') && fullPrompt.includes('revisedText') && fullPrompt.includes('lieflat-less-ai-tone 白名单式改写合同') && fullPrompt.includes('未命中规则的文字必须逐字保留') && diff --git a/scripts/validate-secure-credentials.mjs b/scripts/validate-secure-credentials.mjs index dbba33b..76e2475 100644 --- a/scripts/validate-secure-credentials.mjs +++ b/scripts/validate-secure-credentials.mjs @@ -94,7 +94,7 @@ async function main() { assert( dataIpc.includes('secureAndSanitizeAppData') && credentialSanitizer.includes('migrateLegacyApiKey') && - mainAiService.includes('credentialService.getApiKey()') && + mainAiService.includes('credentialService.getApiKey(settings.baseUrl)') && /sanitizeAiErrorText\(\s*message\s*,\s*apiKey(?:\s*,[^)]*)?\)/.test(mainAiService) && !aiMainSource.includes('headers.Authorization = `Bearer ${settings.apiKey'), 'main process migrates legacy keys, injects secure key for AI calls, and redacts AI errors' @@ -170,7 +170,7 @@ async function main() { checks.push( assert( credentialSanitizer.includes('const legacyApiKey = normalized.settings.apiKey.trim()') && - credentialSanitizer.includes('await context.credentialService.migrateLegacyApiKey(legacyApiKey)') && + credentialSanitizer.includes('await context.credentialService.migrateLegacyApiKey(legacyApiKey, normalized.settings.baseUrl)') && credentialSanitizer.includes('apiKey: \'\',') && dataIpc.includes('storage.saveIfCurrent(secured.data'), 'legacy AppData apiKey is migrated to secure storage and removed before persistence' diff --git a/scripts/validate-story-direction-range.mjs b/scripts/validate-story-direction-range.mjs new file mode 100644 index 0000000..11ef028 --- /dev/null +++ b/scripts/validate-story-direction-range.mjs @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { loadDomainModules } from './utils/load-domain-modules.mjs' + +const { normalizeAppData, normalizeStoryDirectionGuide: normalize, normalizeGenerationRunTrace, StoryDirectionService, + inspectStoryDirectionRange, MAX_STORY_DIRECTION_CHAPTERS: cap, StoryDirectionAI, AIClient, WorldManagementService: world, + projectData, runPipelineFromStepEngine, createEmptyChapterTask } = await loadDomainModules( + 'shared/defaults', 'shared/storyDirectionRange', 'shared/normalizers/storyDirection', 'shared/normalizers/runTrace', + 'services/StoryDirectionService', 'services/ai/StoryDirectionAI', 'services/ai/AIClient', 'services/WorldManagementService', + 'renderer/src/utils/projectData', 'renderer/src/views/generation/pipelineRunnerEngine') + +const at = '2026-01-01T00:00:00.000Z' +const projectId = 'range-test', project = { id: projectId, name: '章段试写' } +const positions = (start, count) => Array.from({ length: count }, (_, i) => ({ + chapterOffset: i + 1, chapterOrder: start + i, goal: '核对清单-' + (start + i) +})) +const guide = (start, end, extra = {}) => normalize({ id: 'guide-' + start, projectId, title: '清单', status: 'active', + startChapterOrder: start, endChapterOrder: end, chapterBeats: positions(start, end - start + 1), ...extra }) +const seed = () => normalizeAppData({ projects: [project] }) +const input = (range) => ({ project, userRawIdea: '', userPolishedIdea: '', stageSummaries: [], recentChapters: [], + activeForeshadowings: [], characters: [], timelineEvents: [], storyBible: null, ...range }) +const source = { kind: 'agent_director', reason: '合成章段验收', requestedAt: at } +const command = patch => ({ projectId, entity: 'story_direction', action: 'create', operationId: 'new', source, patch: { title: '章段', ...patch } }) + +test('旧 5/10 原样往返;没有有效结束章时,不把默认值或节拍当作冲突证据', () => { + for (const count of [5, 10]) { + const old = guide(91, 90 + count) + assert.deepEqual(normalize(old), old) + for (const endChapterOrder of [undefined, null, 0, 90, 96.5, '96', Infinity]) { + const read = normalize({ ...old, endChapterOrder, chapterBeats: positions(91, 6) }) + assert.deepEqual([read.startChapterOrder, read.endChapterOrder, read.horizonChapters], [91, 90 + count, count]) + assert.deepEqual(read.warnings, []) + assert.equal(read.chapterBeats.length, 6, 'normalization must not discard beats') + } + } + for (const count of [1, 6, cap]) { + assert.deepEqual(normalize({ startChapterOrder: 91, endChapterOrder: 90 + count }).warnings, []) + } + const old = { ...guide(91, 96), horizonChapters: 5, warnings: ['作者备注'] } + const read = normalize(old) + assert.equal(read.horizonChapters, 6) + assert.deepEqual(old.warnings, ['作者备注']) + assert.match(read.warnings[1], /冲突.*5 章.*91-96.*6 章/) + assert.deepEqual(normalize(read), read) + const oversized = normalize({ ...old, endChapterOrder: 91 + cap }) + assert.equal(oversized.endChapterOrder, 91 + cap) + assert.equal(oversized.horizonChapters, cap + 1) + assert.match(oversized.warnings.join(' '), /最多/) + assert.equal(StoryDirectionService.getActiveGuideForChapter([oversized], projectId, 91), null) +}) + +test('新输入不取整、不钳位;区间上限适用于跨度,不限制全书章号', async () => { + const noRequest = new StoryDirectionAI({ requestJson: () => assert.fail('invalid input reached the model') }) + for (const [startChapterOrder, endChapterOrder, horizonChapters, reason] of [ + [0, 96, undefined, /起始/], [91.5, 96, undefined, /起始/], ['91', 96, undefined, /起始/], + [91, null, 5, /结束/], [91, 0, 10, /结束/], [91, 90, undefined, /不能早于/], [91, 96.1, undefined, /结束/], + [91, '96', undefined, /结束/], [91, 91 + cap, undefined, /最多/], [91, undefined, cap + 1, /horizonChapters/], + [91, 96, -1, /horizonChapters/], [91, undefined, 1.5, /horizonChapters/], + [Number.MAX_SAFE_INTEGER, undefined, 5, /结束/], [1, Number.MAX_SAFE_INTEGER + 1, undefined, /结束/] + ]) { + const range = { startChapterOrder, endChapterOrder, horizonChapters } + assert.throws(() => world.preview(seed(), command(range)), reason) + const failed = await noRequest.generateStoryDirectionGuide(input(range)) + assert.equal(failed.data, null) + assert.equal(failed.usedAI, false) + assert.match(failed.error, reason) + } + assert.equal(inspectStoryDirectionRange(Number.MAX_SAFE_INTEGER, undefined, 1).range.endChapterOrder, Number.MAX_SAFE_INTEGER) + assert.equal(inspectStoryDirectionRange(9001, 9000 + cap).range.horizonChapters, cap) +}) + +test('AI 只接受逐章匹配的完整节拍;本地模板和旧长度调用使用同一范围', async () => { + const requests = [] + let reply + const ai = new StoryDirectionAI(new AIClient({ apiProvider: 'local' }, { chatCompletion: async request => { + requests.push(request.messages.map(message => message.content).join('\n')) + return { ok: true, finishReason: 'stop', content: JSON.stringify(reply) } + } })) + const local = new StoryDirectionAI(new AIClient(undefined, { chatCompletion: () => assert.fail('local template contacted a model') })) + for (const [start, count] of [[91, 6], [96, 1], [1, 1], [200, cap]]) { + const end = start + count - 1, expected = positions(start, count) + reply = { chapterBeats: [...expected].reverse() } + const range = { startChapterOrder: start, endChapterOrder: end } + const result = await ai.generateStoryDirectionGuide(input(range)) + assert.equal(result.ok, true) + assert.equal(result.usedAI, true) + assert.match(requests.at(-1), new RegExp('第 ' + start + '-' + end + ' 章.*恰好有 ' + count + ' 个节拍')) + const fallback = await local.generateStoryDirectionGuide(input(range)) + assert.equal(fallback.usedAI, false) + for (const r of [result, fallback]) { + assert.deepEqual(r.data.chapterBeats.map(b => [b.chapterOffset, b.chapterOrder]), expected.map(b => [b.chapterOffset, b.chapterOrder])) + } + } + for (const count of [5, 10]) { + const result = await local.generateStoryDirectionGuide(input({ startChapterOrder: 91, horizonChapters: count })) + assert.equal(result.data.chapterBeats.at(-1).chapterOrder, 90 + count) + } + const six = positions(91, 6), range = input({ startChapterOrder: 91, endChapterOrder: 96, horizonChapters: 5 }) + reply = { chapterBeats: six } + assert.match((await ai.generateStoryDirectionGuide(range)).data.warnings.join(' '), /冲突.*6 章/) + for (const [beats, reason] of [ + [undefined, /数组/], [six.slice(1), /缺少章号:91/], [[...six, six[0]], /实际返回 7/], + [[...six.slice(0, 5), six[3]], /重复/], [[...six.slice(0, 5), { chapterOrder: 97, chapterOffset: 7 }], /越界/], + [[...six.slice(0, 5), { chapterOrder: 96, chapterOffset: 5 }], /序号应为 6/], + ...[{ chapterOffset: 6 }, { chapterOrder: 96 }, { chapterOrder: 96, chapterOffset: 1.5 }, + { chapterOrder: '96', chapterOffset: 6 }, null].map(beat => [[...six.slice(0, 5), beat], /正整数/]) + ]) { + reply = { chapterBeats: beats } + const calls = requests.length, result = await ai.generateStoryDirectionGuide(range) + assert.equal(requests.length, calls + 1) + assert.equal(result.ok, false) + assert.equal(result.usedAI, true) + assert.equal(result.data, null) + assert.match(result.error, reason) + } +}) + +test('Agent 区间编辑只改指定端点,只有实际提供的冲突长度才预警;旧预览不能覆盖新版本', () => { + const data = seed(), create = command({ startChapterOrder: 91, horizonChapters: 5 }) + const created = world.preview(data, create) + assert.equal(created.after.endChapterOrder, 95) + const apply = (state, cmd, preview) => world.apply(state, { ...cmd, expectedFingerprint: preview.fingerprint }, { authorizationGrantId: 'fixture' }) + const saved = apply(data, create, created).data + const update = { ...create, action: 'update', id: created.id, operationId: 'update', patch: { endChapterOrder: 96, chapterBeats: positions(91, 6) } } + const updated = world.preview(saved, update) + assert.equal(updated.after.horizonChapters, 6) + assert.deepEqual(updated.after.warnings, []) + const conflict = world.preview(saved, { ...update, patch: { ...update.patch, horizonChapters: 5 } }) + assert.match(conflict.after.warnings.join(' '), /冲突.*91-96.*6/) + const activate = { ...update, operationId: 'activate', action: 'set_status', patch: { status: 'active' } } + const oldPreview = world.preview(saved, activate) + const next = apply(saved, update, updated).data + assert.deepEqual(next.storyDirectionGuides[0].chapterBeats, updated.after.chapterBeats) + assert.throws(() => apply(next, activate, oldPreview), /changed since preview/) + for (const [beat, message] of [[{ chapterOrder: 97 }, /越界/], [{ chapterOffset: 0 }, /正整数/], + [{ chapterOrder: 91, chapterOffset: 2 }, /序号/]]) { + assert.throws(() => world.preview(next, { ...update, operationId: 'bad', patch: { chapterBeats: [beat] } }), message) + } + assert.throws(() => world.preview(next, { ...update, operationId: 'duplicate', patch: { chapterBeats: [positions(91, 1)[0], positions(91, 1)[0]] } }), /重复/) +}) + +async function buildContext(live, target, snapshot = null, task = null) { + const job = { id: 'job', projectId, targetChapterOrder: target, contextSource: snapshot ? 'prompt_snapshot' : 'auto', + promptContextSnapshotId: snapshot?.id, chapterTaskSnapshot: task, pipelineMode: 'standard', createdAt: at, updatedAt: at } + const steps = ['context_need_planning', 'context_budget_selection', 'build_context'].map(type => ({ + id: type, type, jobId: job.id, projectId, status: 'pending', output: '', createdAt: at, updatedAt: at + })) + const data = normalizeAppData({ projects: [project], storyDirectionGuides: [live], chapterGenerationJobs: [job], + chapterGenerationSteps: steps, promptContextSnapshots: snapshot ? [snapshot] : [] }) + const options = { targetChapterOrder: target, pipelineMode: 'standard', estimatedWordCount: '2000', + readerEmotionTarget: '', budgetMode: 'standard', budgetMaxTokens: 16000 } + const out = await runPipelineFromStepEngine(data, job.id, steps[0].type, options, { + ...options, data, scoped: projectData(data, projectId), project: data.projects[0], aiSettings: data.settings, runId: job.id, + getAiService: () => assert.fail('building context must not call AI'), persistWorking: async value => value, + updateStepInData: (value, id, patch) => ({ ...value, + chapterGenerationSteps: value.chapterGenerationSteps.map(step => step.id === id ? { ...step, ...patch } : step) }) + }) + const built = out.chapterGenerationSteps.find(step => step.type === 'build_context') + assert.equal(built.status, 'completed', out.chapterGenerationSteps.map(step => step.errorMessage).join(' ')) + return { prompt: built.output, trace: out.generationRunTraces[0], plan: out.contextNeedPlans[0] } +} + +test('实际需求规划、预算、Prompt、Trace 和快照回放只记录本章节拍', async () => { + const live = guide(91, 96), single = guide(96, 96) + for (const [g, order] of [[live, 91], [live, 96], [single, 96], [guide(1, 1), 1]]) { + const { prompt, trace, plan } = await buildContext(g, order) + assert.ok(prompt.includes('覆盖章节:第 ' + g.startChapterOrder + '-' + g.endChapterOrder + ' 章')) + assert.ok(prompt.includes('核对清单-' + order)) + assert.ok(g.chapterBeats.every(b => b.chapterOrder === order || !prompt.includes(b.goal))) + assert.ok(plan.contextNeeds.some(need => need.sourceHint === 'storyDirection' && need.sourceId === g.id)) + assert.equal(trace.storyDirectionBeatId, g.chapterBeats.find(b => b.chapterOrder === order).id) + assert.equal(trace.storyDirectionGuideHorizon, g.horizonChapters) + assert.equal(trace.storyDirectionAppliedToChapterTask, true) + assert.ok(trace.promptBlockOrder.some(b => b.kind === 'story_direction' && b.included && b.tokenEstimate > 0)) + assert.equal(normalizeGenerationRunTrace(trace).storyDirectionGuideHorizon, g.horizonChapters) + } + for (const [order, task] of [[90, null], [97, null], [96, createEmptyChapterTask()]]) { + const { prompt, trace } = await buildContext(live, order, null, task) + assert.ok(!prompt.includes('核对清单-')) + assert.equal(trace.storyDirectionGuideHorizon, null) + } + const stored = { id: 'snap', projectId, targetChapterOrder: 96, storyDirectionGuide: { ...live, horizonChapters: 5 }, + finalPrompt: StoryDirectionService.formatForPrompt(live, 96) } + const snap = normalizeAppData({ promptContextSnapshots: [stored] }).promptContextSnapshots[0] + assert.equal(snap.storyDirectionGuide.horizonChapters, 6) + assert.match(snap.storyDirectionGuide.warnings.join(' '), /冲突/) + const { trace } = await buildContext(single, 96, snap) + assert.equal(trace.storyDirectionGuideId, live.id) + assert.equal(trace.storyDirectionGuideHorizon, 6) + assert.equal(trace.storyDirectionBeatId, live.chapterBeats[5].id) + assert.equal(trace.storyDirectionAppliedToChapterTask, false) + const absent = await buildContext(single, 96, { ...snap, finalPrompt: '本次不使用中期剧情导向。' }) + assert.equal(absent.trace.storyDirectionGuideId, null) + assert.equal(normalizeGenerationRunTrace({ storyDirectionGuideHorizon: 5, storyDirectionGuideStartChapterOrder: 91, + storyDirectionGuideEndChapterOrder: 96 }).storyDirectionGuideHorizon, 6) + assert.equal(normalizeGenerationRunTrace({ storyDirectionGuideHorizon: 10 }).storyDirectionGuideHorizon, 10) +}) diff --git a/scripts/validate-unreviewed-draft-acceptance.mjs b/scripts/validate-unreviewed-draft-acceptance.mjs index 082df35..eefe323 100644 --- a/scripts/validate-unreviewed-draft-acceptance.mjs +++ b/scripts/validate-unreviewed-draft-acceptance.mjs @@ -467,6 +467,72 @@ function uiHarness(data, { confirmed = true, mutate, transactional = true } = {} } function registerUiTests() { + test('UI: an approved over-limit draft requires one informed confirmation, including for a new chapter', async () => { + const data = fixture({ quality: 'passed', editorial: 'approved', existingChapter: false }) + data.chapterGenerationJobs[0].chapterTaskSnapshot = { targetWordCount: '2-4字', styleRequirement: '禁用“尾”' } + draftOf(data).body = '甲 乙\n𠮷丙尾' + data.qualityGateReports = [qualityReport(data, 'passed')] + data.editorialVerdicts = [verdict(data, 'approved')] + const h = uiHarness(data, { confirmed: false }) + await h.hook.acceptDraft(h.draft) + assert.equal(h.calls.saves, 0) + assert.equal(h.calls.confirmations.length, 1) + assert.match(h.calls.confirmations[0].message, /目标 2–4 字,实际 5 字/) + const keep = uiHarness(data) + await keep.hook.acceptDraft(keep.draft) + assert.equal(keep.calls.saves, 1) + assert.equal(keep.current().chapters[0].body, draftOf(data).body) + assert.match(keep.calls.bundles[0].acceptanceReview.lengthOverflowNotice, /超出上限 1 字/) + const changed = uiHarness(data, { mutate: d => { d.chapterGenerationJobs[0].chapterTaskSnapshot.targetWordCount = '最多3字' } }) + await changed.hook.acceptDraft(changed.draft) + assert.equal(changed.calls.saves, 0) + }) + test('view: current body controls the warning and shortened text cannot inherit a pass', () => { + const data = fixture({ quality: 'passed', editorial: 'approved', existingChapter: false }) + const job = data.chapterGenerationJobs[0] + job.chapterTaskSnapshot = { targetWordCount: '最多4字' } + data.editorialVerdicts[0].actions = [{ actionType: 'accept_draft', label: '直接接受旧稿', reason: '', priority: 1 }] + const render = (component, props) => require('react-dom/server').renderToStaticMarkup(require('react').createElement(component, props)) + const panels = () => renderViewProbe(data).props + const over = panels() + assert.match(render(modules.artifact.PipelineCurrentArtifactPanel, { ...over.currentArtifactPanel, activeTab: 'draft' }), /实际 17 字.*请先改短/) + const diagnostics = render(modules.diagnostics.PipelineDiagnosticsPanel, over.diagnosticsPanel) + assert.match(diagnostics, /篇幅超限,待处理/) + assert.doesNotMatch(diagnostics, /可以采纳|直接接受旧稿/) + assert.equal(modules.director.getDirectorDashboard(data, 'p1').actions[0].label, '审阅超限草稿') + draftOf(data).body = '甲 乙\n𠮷丙' + const shortened = panels() + assert.equal(shortened.topStatusBar.qualityReport, null) + assert.equal(shortened.diagnosticsPanel.editorialVerdict, null) + const html = render(modules.artifact.PipelineCurrentArtifactPanel, { ...shortened.currentArtifactPanel, activeTab: 'draft' }) + assert.match(html, /最多4字.*实际 4 个可见字符/) + assert.doesNotMatch(html, /超出上限|先改短/) + assert.equal(modules.director.getDirectorDashboard(data, 'p1').lengthOverflowNotice, null) + for (const task of [null, { targetWordCount: '约2字' }]) { + job.chapterTaskSnapshot = task + assert.equal(panels().currentArtifactPanel.lengthOverflowNotice, null) + } + }) + test('view: interrupted retry shows the full step fragment without borrowing the previous draft verdict', () => { + const data = fixture({ quality: 'passed', editorial: 'approved' }) + const body = '开头\n' + '正文'.repeat(1000) + '\n片段末尾' + Object.assign(data.chapterGenerationJobs[0], { status: 'failed', currentStep: 'generate_chapter_draft' }) + data.chapterGenerationSteps = [{ id: 'fragment-step', jobId: 'j1', type: 'generate_chapter_draft', status: 'failed', + output: JSON.stringify({ kind: 'incomplete_chapter_fragment', title: '中断正文', body, finishReason: 'length' }), updatedAt: later }] + const props = renderViewProbe(data).props + assert.equal(props.topStatusBar.draft, null) + assert.equal(props.topStatusBar.qualityReport, null) + assert.equal(props.diagnosticsPanel.editorialVerdict, null) + const dashboard = modules.director.getDirectorDashboard(data, 'p1') + assert.equal(dashboard.actions[0].kind, 'resume_pipeline') + assert.equal(dashboard.qualityReport, null) + const html = require('react-dom/server').renderToStaticMarkup(require('react').createElement(modules.artifact.PipelineCurrentArtifactPanel, + { ...props.currentArtifactPanel, activeTab: 'steps' })) + assert.ok(html.includes(body)) + assert.match(html, /不能采纳/) + assert.match(html, /此前的完整草稿/) + assert.doesNotMatch(html, /接受章节草稿|未完成审稿,直接采纳/) + }) test('UI: default reviewed does not silently switch to unreviewed', async () => { const h = uiHarness(fixture()) await h.hook.acceptDraft(h.draft) @@ -618,6 +684,20 @@ async function storageFor(kind, data) { function registerStorageTests() { for (const kind of ['json', 'sqlite-boundary', 'sqlite-native']) { + test(`${kind}: a new upper bound rejects a previously clean receipt and a legacy preview inside the transaction`, async () => { + const { storage } = await storageFor(kind, fixture({ quality: 'passed', editorial: 'approved' })) + const initial = await storage.loadSnapshot() + const reviewed = commit(initial.data, 'reviewed') + const legacy = legacyRecord(initial.data) + initial.data.chapterGenerationJobs[0].chapterTaskSnapshot = { targetWordCount: '最多4字' } + await storage.saveIfCurrent(initial.data, initial.revision) + const before = await storage.loadSnapshot() + assert.throws(() => commit(before.data, 'reviewed'), /超出上限/) + for (const result of [reviewed, legacy]) { + await assert.rejects(() => storage.saveChapterCommitBundle(result, before.revision)) + assert.deepEqual(await storage.loadSnapshot(), before) + } + }) test(`${kind}: snapshot round-trip and exact replay preserve revision`, async () => { const { storage } = await storageFor(kind, fixture()) const initial = await storage.loadSnapshot() @@ -1021,6 +1101,9 @@ async function main() { if (!process.argv.includes('--native-only')) { modules.view = await bundleViewProbe() modules.panel = await bundle('src/renderer/src/components/pipeline/PipelineDraftPanel.tsx', 'panel') + modules.artifact = await bundle('src/renderer/src/components/pipeline/PipelineCurrentArtifactPanel.tsx', 'artifact') + modules.diagnostics = await bundle('src/renderer/src/components/pipeline/PipelineDiagnosticsPanel.tsx', 'diagnostics') + modules.director = await bundle('src/services/DirectorNextActionService.ts', 'director') modules.history = await bundle('src/renderer/src/views/chapters/ChapterVersionHistoryPanel.tsx', 'history') } modules.review = await findReviewHelper() diff --git a/scripts/validate-writing-prompt-hygiene.mjs b/scripts/validate-writing-prompt-hygiene.mjs index a91759c..ebd90df 100644 --- a/scripts/validate-writing-prompt-hygiene.mjs +++ b/scripts/validate-writing-prompt-hygiene.mjs @@ -1,74 +1,16 @@ -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import ts from 'typescript' +import { loadDomainModules } from './utils/load-domain-modules.mjs' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' import { repoRoot } from './utils/repo-root.mjs' const root = repoRoot -const outDir = join(root, 'tmp', 'writing-prompt-hygiene-test') function assert(condition, message, details = {}) { return condition ? { ok: true, message } : { ok: false, message, details } } -function rewriteRelativeImports(source) { - return source.replace(/(from\s+['"])(\.{1,2}\/[^'"]+?)(['"])/g, (_match, prefix, specifier, suffix) => { - if (/\.(mjs|js|json)$/.test(specifier)) return `${prefix}${specifier}${suffix}` - return `${prefix}${specifier}.mjs${suffix}` - }) -} - -async function compileTsTree(files) { - await rm(outDir, { recursive: true, force: true }) - for (const relativePath of files) { - const source = await readFile(join(root, relativePath), 'utf-8') - const compiled = ts.transpileModule(source, { - compilerOptions: { - module: ts.ModuleKind.ES2022, - target: ts.ScriptTarget.ES2022, - jsx: ts.JsxEmit.ReactJSX, - useDefineForClassFields: true - } - }) - const outPath = join(outDir, relativePath).replace(/\.tsx?$/, '.mjs') - await mkdir(dirname(outPath), { recursive: true }) - await writeFile(outPath, rewriteRelativeImports(compiled.outputText), 'utf-8') - } -} - -async function loadPromptBuilder() { - await compileTsTree([ - 'src/shared/chapterText.ts', - 'src/shared/foreshadowingTreatment.ts', - 'src/services/TokenEstimator.ts', - 'src/services/ContinuityService.ts', - 'src/services/CharacterStateService.ts', - 'src/services/characterState/logInference.ts', - 'src/services/characterState/stateMutations.ts', - 'src/services/characterState/stateSelection.ts', - 'src/services/characterState/stateValidation.ts', - 'src/services/characterState/stateValue.ts', - 'src/services/StageSummaryService.ts', - 'src/services/ContextCompressionService.ts', - 'src/services/contextBudget/types.ts', - 'src/services/contextBudget/scoringEngine.ts', - 'src/services/contextBudget/selectionEngine.ts', - 'src/services/contextBudget/selectionFinalizer.ts', - 'src/services/contextBudget/traceBuilder.ts', - 'src/services/ChapterLifecycleService.ts', - 'src/services/ContextBudgetManager.ts', - 'src/services/StoryDirectionService.ts', - 'src/services/HardCanonPackService.ts', - 'src/services/PromptLintService.ts', - 'src/services/promptFormatters/chapterFormatters.ts', - 'src/services/promptFormatters/characterFormatters.ts', - 'src/services/promptFormatters/foreshadowingFormatters.ts', - 'src/services/promptFormatters/promptUtils.ts', - 'src/services/PromptBuilderService.ts' - ]) - const promptBuilderModule = await import(`${pathToFileURL(join(outDir, 'src/services/PromptBuilderService.mjs')).href}?t=${Date.now()}`) - const continuityModule = await import(`${pathToFileURL(join(outDir, 'src/services/ContinuityService.mjs')).href}?t=${Date.now()}`) - return { ...promptBuilderModule, ...continuityModule } +function loadPromptBuilder() { + return loadDomainModules('services/PromptBuilderService', 'services/ContinuityService') } const now = '2026-01-01T00:00:00.000Z' diff --git a/scripts/verify-win-installer-payload.mjs b/scripts/verify-win-installer-payload.mjs new file mode 100644 index 0000000..cc409d4 --- /dev/null +++ b/scripts/verify-win-installer-payload.mjs @@ -0,0 +1,75 @@ +// Compare the NSIS application payload with the tested unpacked tree without installing it. +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { createReadStream, existsSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { basename, dirname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = dirname(dirname(fileURLToPath(import.meta.url))) +const release = join(root, 'release') +await mkdir(join(root, 'tmp'), { recursive: true }) +const tmp = await realpath(join(root, 'tmp')) +const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) +const info = JSON.parse(await readFile(join(release, 'BUILD_INFO.json'), 'utf8')) +assert.equal(info.version, pkg.version) +assert.equal(info.installer, `Novel Director Setup ${pkg.version}.exe`) +const installer = join(release, info.installer) +const sevenZip = [ + process.env.ProgramFiles && join(process.env.ProgramFiles, '7-Zip', '7z.exe'), + join(root, 'node_modules/electron-winstaller/vendor/7z.exe') +].find(path => path && existsSync(path)) +assert(sevenZip, 'Install 7-Zip to verify the NSIS payload.') + +function extract(archive, to, members = []) { + execFileSync(sevenZip, ['x', '-y', '-bd', `-o${to}`, archive, ...members], { stdio: 'pipe', windowsHide: true }) +} + +async function hash(path) { + const h = createHash('sha256') + for await (const chunk of createReadStream(path)) h.update(chunk) + return h.digest('hex').toUpperCase() +} + +async function files(base, dir = base) { + const paths = [] + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) paths.push(...await files(base, path)) + else { + assert(entry.isFile(), `Unexpected non-file in application tree: ${path}`) + paths.push(relative(base, path)) + } + } + return paths.sort() +} + +const work = await mkdtemp(join(tmp, 'installer-equivalence-')) +try { + const publishedHash = await hash(installer) + assert.equal(publishedHash, info.installerSha256.toUpperCase(), 'Installer does not match the release manifest') + const payload = join(work, 'app') + await mkdir(payload) + extract(installer, work, ['$PLUGINSDIR\\app-64.7z']) + extract(join(work, '$PLUGINSDIR', 'app-64.7z'), payload) + const unpacked = join(release, 'win-unpacked') + const members = await files(unpacked) + assert.deepEqual(await files(payload), members, 'Installer and unpacked program have different file lists') + assert(members.includes('Novel Director.exe')) + const native = members.find(path => basename(path) === 'better_sqlite3.node') + assert(native, 'SQLite native binding missing') + for (const member of members) { + assert.equal(await hash(join(payload, member)), await hash(join(unpacked, member)), `File differs: ${member}`) + } + const result = { version: info.version, checkedAt: new Date().toISOString(), installer: info.installer, + installerSha256: publishedHash, appAsarSha256: await hash(join(unpacked, 'resources', 'app.asar')), + sqliteBindingSha256: await hash(join(unpacked, native)), comparedFileCount: members.length, + status: 'identical', installedOnHost: false } + await writeFile(join(release, 'INSTALLER_PAYLOAD_CHECK.json'), JSON.stringify(result, null, 2) + '\n') + console.log(JSON.stringify(result, null, 2)) +} finally { + assert.equal(dirname(await realpath(work)), tmp) + assert(basename(work).startsWith('installer-equivalence-')) + await rm(work, { recursive: true, force: true, maxRetries: 4 }) +} diff --git a/src/agent/AgentDecisionService.ts b/src/agent/AgentDecisionService.ts index f322b28..11f63ee 100644 --- a/src/agent/AgentDecisionService.ts +++ b/src/agent/AgentDecisionService.ts @@ -1,7 +1,13 @@ import { randomUUID } from 'node:crypto' import { AgentRunService } from './AgentRunService' +import { describePendingRunTarget, firstPendingRunTarget, isMultiChapterRun, type PendingRunTarget } from './agentBatchWriteback' import { AuthorDecisionPolicyService } from '../services/AuthorDecisionPolicyService' import { ChapterTaskContractService } from '../services/ChapterTaskContractService' +import { currentHardCanonAudit, hardCanonNotice } from '../services/HardCanonPreflight' +import { agentHardCanonEvidence } from './agentHardCanonEvidence' +import { redactSensitiveText } from './agentReadableText' +import { withCurrentHardCanonAudit } from '../services/qualityGate/hardCanonQuality' +import { hasCurrentChapterFragment } from '../shared/chapterStepFragment' import type { AgentActionPreview, AgentDecision, @@ -12,6 +18,7 @@ import type { ConsistencyReviewReport, EditorialVerdict, GeneratedChapterDraft, + HardCanonAuditResult, ID, QualityGateReport, RedundancyReport, @@ -27,6 +34,7 @@ import { export type AgentAcceptanceRecommendationType = 'accept' | 'revise' | 'reject' | 'wait' | 'pause' export interface AgentAcceptanceRecommendation { + hardCanon?: ReturnType jobId: ID recommendation: AgentAcceptanceRecommendationType confidence: number @@ -205,12 +213,45 @@ function recommendationFromEditorialVerdict( } export class AgentDecisionService { - static buildAcceptanceRecommendation(data: AppData, jobId: ID): AgentAcceptanceRecommendation { + static buildAcceptanceRecommendation(data: AppData, jobId: ID, draftId?: ID, detail?: 'summary' | 'compact' | 'full'): AgentAcceptanceRecommendation { const job: ChapterGenerationJob | null = data.chapterGenerationJobs.find((item) => item.id === jobId) ?? null if (!job) throw new Error(`Job not found: ${jobId}`) - const draft = latestDraftForJob(data, jobId) - const quality = latestQualityForJob(data, jobId, draft) + const draft = draftId + ? data.generatedChapterDrafts.find((item) => item.id === draftId && item.jobId === jobId && item.projectId === job.projectId) ?? null + : latestDraftForJob(data, jobId) + if (!draftId && hasCurrentChapterFragment(data.chapterGenerationSteps, jobId, draft)) { + return { jobId, recommendation: 'wait', confidence: 1, riskLevel: 'high', draftId: null, requiresHumanApproval: true, + reasons: ['本次生成只留下不完整片段,没有新的可采纳草稿。'], + nextActions: ['用 agent.getRunDiagnostics(detail=full)查看保留的片段;重试生成后再审阅新稿。'] } + } + const canon = draft ? currentHardCanonAudit(data, { projectId: job.projectId, chapterOrder: job.targetChapterOrder, + chapterId: draft.chapterId, body: draft.body }) : null + const blocker = hardCanonNotice(canon, 'block') + if (blocker && draft) return { jobId, draftId: draft.id, recommendation: 'revise', confidence: 1, riskLevel: 'high', + requiresHumanApproval: true, reasons: [redactSensitiveText(blocker)], hardCanon: agentHardCanonEvidence(canon, draft.body, detail === 'full'), + nextActions: ['按原句修订并重新核查;如规则填写有误,先在硬设定包中修正。'] } + const recommendation = this.assessDraft(data, job, draft, canon) + if (canon && draft) recommendation.hardCanon = agentHardCanonEvidence(canon, draft.body, detail === 'full') + const advisory = hardCanonNotice(canon, 'review') + if (advisory) recommendation.reasons = [`硬设定待核,不阻断采纳。${redactSensitiveText(advisory)}`, ...recommendation.reasons] + const notice = draft && ChapterTaskContractService.lengthOverflowNotice({ body: draft.body, chapterTask: job.chapterTaskSnapshot }) + if (!notice) return recommendation + return { + ...recommendation, + recommendation: recommendation.recommendation === 'accept' ? 'revise' : recommendation.recommendation, + riskLevel: 'high', + requiresHumanApproval: true, + reasons: [notice, ...recommendation.reasons], + nextActions: ['先改短正文并重新审稿;如决定保留原文,请说明理由,按已有项目授权预览并提交。', + ...(recommendation.recommendation === 'accept' ? [] : recommendation.nextActions)] + } + } + + private static assessDraft(data: AppData, job: ChapterGenerationJob, draft: GeneratedChapterDraft | null, canon: HardCanonAuditResult | null): AgentAcceptanceRecommendation { + const jobId = job.id + const storedQuality = latestQualityForJob(data, jobId, draft) + const quality = storedQuality && withCurrentHardCanonAudit(storedQuality, canon) const consistency = latestConsistencyForJob(data, jobId, draft) const redundancy = latestRedundancyForJob(data, jobId, draft) const summary = latestAuthorSummaryForJob(data, jobId, draft, quality, consistency, redundancy) @@ -474,20 +515,14 @@ export class AgentDecisionService { pendingPreviewCount: number latestDecision: AgentDecision | null nextChapterOrder: number | null + nextTarget: PendingRunTarget | null summary: string warnings: string[] } { const run = data.agentRuns.find((item) => item.id === agentRunId) if (!run) throw new Error(`AgentRun not found: ${agentRunId}`) const latestDecision = [...run.decisions].sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0] ?? null - const completedOrders = new Set( - run.createdJobIds - .map((jobId) => data.chapterGenerationJobs.find((job) => job.id === jobId)) - .filter((job): job is ChapterGenerationJob => Boolean(job)) - .filter((job) => job.status === 'completed') - .map((job) => job.targetChapterOrder) - ) - const nextChapterOrder = run.targetChapterOrders.find((order) => !completedOrders.has(order)) ?? null + const nextTarget = firstPendingRunTarget(data, run) return { agentRunId: run.id, status: run.status, @@ -496,8 +531,11 @@ export class AgentDecisionService { createdDraftIds: run.createdDraftIds, pendingPreviewCount: data.agentActionPreviews.filter((preview) => preview.agentRunId === run.id && preview.status === 'pending').length, latestDecision, - nextChapterOrder, - summary: run.summary, + nextChapterOrder: nextTarget?.chapterOrder ?? null, + nextTarget, + summary: isMultiChapterRun(run) + ? nextTarget ? describePendingRunTarget(nextTarget) : '本批所有目标章节均已确认正式写回。' + : run.summary, warnings: run.warnings } } diff --git a/src/agent/AgentPipelineExecutor.ts b/src/agent/AgentPipelineExecutor.ts index 0a32b77..6f1a91a 100644 --- a/src/agent/AgentPipelineExecutor.ts +++ b/src/agent/AgentPipelineExecutor.ts @@ -13,22 +13,32 @@ import type { } from '../shared/types' import { AIService as WorkflowAIService } from '../services/AIService' import type { AIChatCompletionTransport } from '../services/ai/AITransport' -import { resolvePipelineRoleSettings, resolvePipelineRunSettings } from '../services/PipelineRunContextService' +import { readChapterStepFragment } from '../shared/chapterStepFragment' +import { getPipelineRoleForStep, resolvePipelineRoleSettings, resolvePipelineRunSettings } from '../services/PipelineRunContextService' import { PIPELINE_AI_ROLES } from '../shared/normalizers/pipelineRunConfig' import { PipelineRecipeService } from '../services/PipelineRecipeService' import { OPENING_READER_EMOTION_FALLBACK } from '../services/OpeningChapterContextPolicy' import { AIService as MainAITransportService } from '../main/services/AIService' +import { AIHttpClient } from '../main/services/AIHttpClient' +import { appendProjectUsageAttempts } from '../storage/projectUsageJournal' +import { CodexCliService } from '../main/services/CodexCliService' +import { agentApiKey, apiEndpoint, backupKeyAfterRejection, readApiRegistry, type ApiRegistry } from '../main/services/ApiRegistry' import { DiagnosticsService } from '../main/services/DiagnosticsService' import { TokenBucketRateLimiter } from '../main/RateLimiter' import { projectData } from '../renderer/src/utils/projectData' import { runPipelineFromStepEngine } from '../renderer/src/views/generation/pipelineRunnerEngine' +import { parseOutput, type PipelineRuntimeOptions } from '../renderer/src/views/generation/pipelineRuntimeBasics' import type { PipelineDiagnosticsAdapter } from '../renderer/src/views/generation/pipelineRunnerTypes' import { saveAgentRuntimeData, type AgentRuntimeData } from './AgentRuntime' import { AGENT_PIPELINE_STEP_ORDER, AgentRunService } from './AgentRunService' import { AgentExecutionControlService } from './AgentExecutionControlService' +import { checkPipelineRunTarget, describePendingRunTarget, firstPendingRunTarget, isMultiChapterRun } from './agentBatchWriteback' +import { BATCH_EXECUTION_PREFIX, batchModelRequestGuard, guardModelCalls, pauseInterruptedBatchAttempt, reopenPipelineRuntime } from './agentPipelineWritebackGuard' +import { StorageRevisionConflictError } from '../storage/StorageService' export interface ExecuteAgentChapterPipelineInput { - appData: AppData + /** Older callers pass a snapshot here. Execution always reopens the persisted run. */ + appData?: AppData runtime: AgentRuntimeData agentRunId: ID jobId: ID @@ -99,11 +109,23 @@ export function resolveAgentPipelineResumeStep( return null } +function outputForRestart(step: ChapterGenerationStep, fromStep: ChapterGenerationStepType): string { + if (step.type !== 'generate_chapter_draft') return '' + if (fromStep === step.type && (step.status === 'running' || step.status === 'failed' || step.status === 'pending')) { + return step.output + } + // Restarting upstream invalidates reuse, but keep the readable fragment until there is a replacement. + // The read-only projection deliberately excludes any repair source tied to the old inputs. + const fragment = readChapterStepFragment(step) + return fragment ? JSON.stringify(fragment) : '' +} + function preparePipelineExecutionState( data: AppData, agentRunId: ID, jobId: ID, - fromStep: ChapterGenerationStepType + fromStep: ChapterGenerationStepType, + executionId?: ID ): AppData { const timestamp = now() const startIndex = AGENT_PIPELINE_STEP_ORDER.indexOf(fromStep) @@ -118,9 +140,9 @@ function preparePipelineExecutionState( job.status === 'failed' || jobSteps.some((step) => step.status === 'failed' || step.status === 'running') || jobSteps.some((step) => step.status === 'completed') - const resumeDecision: AgentDecision | null = isResume + const startDecision: AgentDecision | null = isResume || executionId ? { - id: newId('agent-decision'), + id: executionId ?? newId('agent-decision'), agentRunId, projectId: run.projectId, chapterId: @@ -129,8 +151,9 @@ function preparePipelineExecutionState( )?.id ?? null, jobId, step: fromStep, - action: 'retry_step', - reason: `Resume the persisted chapter pipeline from ${fromStep}; completed earlier steps remain authoritative.`, + action: isResume ? 'retry_step' : 'run_pipeline', + reason: isResume ? `Resume the persisted chapter pipeline from ${fromStep}; completed earlier steps remain authoritative.` + : `Start the registered chapter pipeline from ${fromStep}.`, evidence: [`jobId=${jobId}`, `fromStep=${fromStep}`], riskLevel: 'low', result: 'applied', @@ -147,7 +170,7 @@ function preparePipelineExecutionState( return { ...step, status: 'pending', - output: '', + output: outputForRestart(step, fromStep), errorMessage: '', updatedAt: timestamp } @@ -168,7 +191,7 @@ function preparePipelineExecutionState( ? { ...item, status: 'running', - decisions: resumeDecision ? [...item.decisions, resumeDecision] : item.decisions, + decisions: startDecision ? [...item.decisions, startDecision] : item.decisions, summary: `Chapter ${job.targetChapterOrder} pipeline is running from ${fromStep}.`, updatedAt: timestamp, completedAt: null @@ -206,14 +229,16 @@ function pipelineModeForRun(run: AgentRun): PipelineMode { export function resolveAgentPipelineAIProfile( config: ChapterGenerationJob['aiRunConfig'], fallback: AppData['settings'], - environmentHasApiKey: boolean + environmentHasApiKey: boolean | ((baseUrl: string) => boolean) ) { const needsKey = (settings: AppData['settings']) => settings.apiProvider === 'openai' || settings.apiProvider === 'compatible' const withAgentCredentials = (settings: AppData['settings']): AppData['settings'] => ({ ...settings, apiKey: '', - hasApiKey: needsKey(settings) ? environmentHasApiKey : settings.hasApiKey + hasApiKey: needsKey(settings) + ? (typeof environmentHasApiKey === 'boolean' ? environmentHasApiKey : environmentHasApiKey(settings.baseUrl)) + : settings.hasApiKey }) const aiSettings = withAgentCredentials(resolvePipelineRunSettings(config, fallback)) const roleSettings = {} as Record @@ -227,28 +252,39 @@ export function resolveAgentPipelineAIProfile( } } -export function createHeadlessTransport(settings: AppData['settings']): { +export function createHeadlessTransport(settings: AppData['settings'], registry: ApiRegistry, storagePath: string | null, beforeRequest?: () => Promise): { transport: AIChatCompletionTransport diagnostics: PipelineDiagnosticsAdapter cancelRun: (runId: ID) => boolean } { - const environmentApiKey = String(process.env.NOVEL_DIRECTOR_API_KEY ?? '').trim() + const logger = { + info: (message: string) => console.error(`[agent-ai] ${message}`), + warn: (message: string) => console.error(`[agent-ai:warn] ${message}`) + } const transportService = new MainAITransportService( - { getApiKey: async () => environmentApiKey }, + { getApiKey: async (baseUrl) => agentApiKey(registry, baseUrl, settings.baseUrl), + getBackupApiKey: async (baseUrl, rejected) => backupKeyAfterRejection(registry, baseUrl, rejected), + isManagedEndpoint: async (baseUrl) => registry.has(apiEndpoint(baseUrl)) }, { openai: new TokenBucketRateLimiter(60, 1), compatible: new TokenBucketRateLimiter(30, 0.5) }, { - logger: { - info: (message) => console.error(`[agent-ai] ${message}`), - warn: (message) => console.error(`[agent-ai:warn] ${message}`) - } + logger, + usageJournal: { getStoragePath: () => storagePath, append: appendProjectUsageAttempts }, + ...(beforeRequest ? { + // Includes retries with another format or key, and diagnostics sent through the same main transport. + httpClient: new AIHttpClient({ beforeRequest, transport: 'node-fetch' }), + codexCliService: guardModelCalls(new CodexCliService(logger), beforeRequest, ['chatCompletion']) + } : {}) } ) const diagnosticsService = new DiagnosticsService(transportService) return { - transport: { chatCompletion: (request) => transportService.chatCompletion(request) }, + transport: { + chatCompletion: (request) => transportService.chatCompletion(request), + isManagedEndpoint: (baseUrl) => transportService.isManagedEndpoint(baseUrl) + }, diagnostics: { analyzeRedundancy: (request) => diagnosticsService.analyzeRedundancy(request), auditNovelty: (request) => diagnosticsService.auditNovelty(request), @@ -316,59 +352,111 @@ export async function executeAgentChapterPipeline( input: ExecuteAgentChapterPipelineInput, dependencies: AgentPipelineExecutorDependencies = {} ): Promise { - const initialRun = input.appData.agentRuns.find((item) => item.id === input.agentRunId) - if (!initialRun) throw new Error(`AgentRun not found: ${input.agentRunId}`) - const initialJob = input.appData.chapterGenerationJobs.find((item) => item.id === input.jobId) - if (!initialJob) throw new Error(`Job not found: ${input.jobId}`) - if (initialRun.projectId !== initialJob.projectId) throw new Error('AgentRun and pipeline job belong to different projects.') - const project = input.appData.projects.find((item) => item.id === initialJob.projectId) + const runtime = await reopenPipelineRuntime(input.runtime) + const { run, job } = checkPipelineRunTarget(runtime.data, input.agentRunId, input.jobId) + const executionId = isMultiChapterRun(run) ? BATCH_EXECUTION_PREFIX + randomUUID() : undefined + try { + const result = await executeLoadedPipeline(input, dependencies, runtime, run, job, executionId) + Object.assign(input.runtime, runtime) + return result + } catch (error) { + if (executionId) { + const reason = error instanceof Error ? error.message : String(error) + try { + await pauseInterruptedBatchAttempt(runtime, run.id, job.id, executionId, reason) + } catch (pauseError) { + throw new Error(`${reason} 本次任务的暂停状态未能保存:${pauseError instanceof Error ? pauseError.message : String(pauseError)}`, { cause: error }) + } + } + throw error + } +} + +async function executeLoadedPipeline( + input: ExecuteAgentChapterPipelineInput, dependencies: AgentPipelineExecutorDependencies, + runtime: AgentRuntimeData, initialRun: AgentRun, initialJob: ChapterGenerationJob, executionId?: ID +): Promise { + const data = runtime.data + const beforeRequest = batchModelRequestGuard(runtime, initialRun, initialJob.id) + const project = data.projects.find((item) => item.id === initialJob.projectId) if (!project) throw new Error(`Project not found: ${initialJob.projectId}`) - const fromStep = resolveAgentPipelineResumeStep(input.appData, initialJob.id, input.fromStep, input.pipelineMode) + const fromStep = resolveAgentPipelineResumeStep(data, initialJob.id, input.fromStep, input.pipelineMode) if (!fromStep) { + const pending = isMultiChapterRun(initialRun) && firstPendingRunTarget(data, initialRun, initialJob.targetChapterOrder + 1) + if (pending) throw new Error(`本章没有待执行的生成步骤。${describePendingRunTarget(pending)}`) throw new Error(`Pipeline job ${initialJob.id} has no failed or unfinished step to run.`) } - await AgentExecutionControlService.clearCancellation(input.runtime, initialJob.id) + await AgentExecutionControlService.clearCancellation(runtime, initialJob.id) - const environmentHasApiKey = Boolean(String(process.env.NOVEL_DIRECTOR_API_KEY ?? '').trim()) - const { aiSettings, roleSettings, requiresEnvironmentApiKey } = resolveAgentPipelineAIProfile( - initialJob.aiRunConfig, input.appData.settings, environmentHasApiKey - ) - let latestWorking = preparePipelineExecutionState(input.appData, initialRun.id, initialJob.id, fromStep) + let latestWorking = preparePipelineExecutionState(data, initialRun.id, initialJob.id, fromStep, executionId) let cancellationObserved = false let cancellationPollBusy = false let cancellationTimer: ReturnType | null = null const persistWorking = async (next: AppData): Promise => { + try { + await saveAgentRuntimeData(next, runtime) + } catch (error) { + // Report the earlier chapter if a writeback failure arrived while this step was running. + if (error instanceof StorageRevisionConflictError) await beforeRequest?.() + throw error + } latestWorking = next - await saveAgentRuntimeData(next, input.runtime) return next } await persistWorking(latestWorking) try { - if (requiresEnvironmentApiKey && !environmentHasApiKey && !dependencies.workflowAI && !dependencies.transport) { - throw new Error( - 'Agent Runtime 无法读取桌面安全存储中的 API Key。请设置 NOVEL_DIRECTOR_API_KEY,或在项目设置中使用 Codex CLI / 本地模型后重试。' - ) + const defaultSettings = resolvePipelineRunSettings(initialJob.aiRunConfig, data.settings) + const usesRemoteRoles = resolveAgentPipelineAIProfile(initialJob.aiRunConfig, data.settings, false).requiresEnvironmentApiKey + const registry = usesRemoteRoles ? await readApiRegistry() : new Map() + const { aiSettings, roleSettings } = resolveAgentPipelineAIProfile(initialJob.aiRunConfig, data.settings, + (baseUrl) => Boolean(agentApiKey(registry, baseUrl, defaultSettings.baseUrl))) + const { pipelineMode, pipelineRecipe } = PipelineRecipeService.resolveRunConfiguration({ + recipe: initialJob.pipelineRecipe, recipeId: initialJob.pipelineRecipeId, storedMode: initialJob.pipelineMode, + requestedMode: input.pipelineMode, fallbackMode: pipelineModeForRun(initialRun) + }) + const requireRoleKey = (role: PipelineAIRole) => { + const settings = roleSettings[role] + if ((settings.apiProvider === 'compatible' || settings.apiProvider === 'openai') && !settings.hasApiKey) { + const variable = registry.get(apiEndpoint(settings.baseUrl))?.keyEnv ?? 'NOVEL_DIRECTOR_API_KEY' + throw new Error(`工作环节 ${role} 缺少对应的 API Key(${variable})。外部流程按共享清单取密钥。`) + } + } + if (!dependencies.workflowAI && !dependencies.transport) { + const start = AGENT_PIPELINE_STEP_ORDER.indexOf(fromStep) + for (const step of PipelineRecipeService.resolveExecutionPlan(pipelineRecipe, pipelineMode).steps) { + const role = getPipelineRoleForStep(step.type) + if (!step.run || !role || AGENT_PIPELINE_STEP_ORDER.indexOf(step.type) < start) continue + // The draft handler checks the persisted checkpoint before it asks for the prose service. + if (step.type === 'generate_chapter_draft' && fromStep === step.type) continue + requireRoleKey(role) + } } const needsDefaultHeadlessServices = !dependencies.workflowAI || !dependencies.diagnostics - const headless = needsDefaultHeadlessServices ? createHeadlessTransport(aiSettings) : null + const headless = needsDefaultHeadlessServices ? createHeadlessTransport(aiSettings, registry, runtime.storagePath, beforeRequest) : null + const workflowAI = dependencies.workflowAI && guardModelCalls(dependencies.workflowAI, beforeRequest) + const transport = dependencies.transport + ? guardModelCalls(dependencies.transport, beforeRequest, ['chatCompletion']) : headless?.transport const workflowAIByRole = new Map() const getRoleSettings = (role: PipelineAIRole) => roleSettings[role] const getWorkflowAI = async (role: PipelineAIRole) => { - if (dependencies.workflowAI) return dependencies.workflowAI + if (workflowAI) return workflowAI + if (role === 'prose' && !dependencies.transport) requireRoleKey(role) const existing = workflowAIByRole.get(role) if (existing) return existing const service = new WorkflowAIService(getRoleSettings(role), { + projectId: initialJob.projectId, runId: initialJob.id, - transport: dependencies.transport ?? headless?.transport + transport }) workflowAIByRole.set(role, service) return service } - const diagnostics = dependencies.diagnostics ?? headless?.diagnostics + const diagnostics = dependencies.diagnostics + ? guardModelCalls(dependencies.diagnostics, beforeRequest, ['evaluateQualityGate']) : headless?.diagnostics if (!diagnostics) throw new Error('Agent pipeline diagnostics adapter is unavailable.') const cancelActiveRun = dependencies.cancelActiveRun ?? headless?.cancelRun ?? (() => false) const checkCancellation = async (): Promise => { - const requested = await AgentExecutionControlService.isCancellationRequested(input.runtime, initialJob.id) + const requested = await AgentExecutionControlService.isCancellationRequested(runtime, initialJob.id) if (requested) { cancellationObserved = true cancelActiveRun(initialJob.id) @@ -382,14 +470,6 @@ export async function executeAgentChapterPipeline( cancellationPollBusy = false }) }, Math.max(50, dependencies.cancellationPollIntervalMs ?? 250)) - const runConfiguration = PipelineRecipeService.resolveRunConfiguration({ - recipe: initialJob.pipelineRecipe, - recipeId: initialJob.pipelineRecipeId, - storedMode: initialJob.pipelineMode, - requestedMode: input.pipelineMode, - fallbackMode: pipelineModeForRun(initialRun) - }) - const { pipelineMode, pipelineRecipe } = runConfiguration latestWorking = { ...latestWorking, chapterGenerationJobs: latestWorking.chapterGenerationJobs.map((job) => @@ -406,17 +486,19 @@ export async function executeAgentChapterPipeline( } await persistWorking(latestWorking) const scoped = projectData(latestWorking, project.id) + const previousStep = data.chapterGenerationSteps.find((step) => step.jobId === initialJob.id && step.type === fromStep) + const savedOptions = parseOutput | null>(previousStep?.inputSnapshot ?? '', null) const options = { targetChapterOrder: initialJob.targetChapterOrder, pipelineMode, - estimatedWordCount: input.estimatedWordCount?.trim() || '3000-5000', + estimatedWordCount: input.estimatedWordCount?.trim() || savedOptions?.estimatedWordCount || '3000-5000', readerEmotionTarget: - input.readerEmotionTarget?.trim() || + input.readerEmotionTarget?.trim() || savedOptions?.readerEmotionTarget || (initialJob.targetChapterOrder === 1 ? OPENING_READER_EMOTION_FALLBACK : '承接上一章情绪,并逐步提升本章核心压力'), - budgetMode: input.budgetMode ?? input.appData.settings.defaultPromptMode, - budgetMaxTokens: input.budgetMaxTokens ?? input.appData.settings.defaultTokenBudget + budgetMode: input.budgetMode ?? savedOptions?.budgetMode ?? data.settings.defaultPromptMode, + budgetMaxTokens: input.budgetMaxTokens ?? savedOptions?.budgetMaxTokens ?? data.settings.defaultTokenBudget } latestWorking = await runPipelineFromStepEngine( latestWorking, @@ -439,6 +521,7 @@ export async function executeAgentChapterPipeline( } ) } catch (error) { + if (beforeRequest) await beforeRequest() const message = error instanceof Error ? error.message : String(error) const failedStep = latestWorking.chapterGenerationSteps.find( (step) => step.jobId === initialJob.id && (step.status === 'running' || step.status === 'pending') @@ -456,7 +539,7 @@ export async function executeAgentChapterPipeline( job.id === initialJob.id ? { ...job, status: 'failed', errorMessage: message, updatedAt: now() } : job ) } - await saveAgentRuntimeData(latestWorking, input.runtime) + await persistWorking(latestWorking) } finally { if (cancellationTimer) clearInterval(cancellationTimer) } @@ -464,7 +547,7 @@ export async function executeAgentChapterPipeline( let job = latestWorking.chapterGenerationJobs.find((item) => item.id === initialJob.id) ?? initialJob const steps = latestWorking.chapterGenerationSteps.filter((step) => step.jobId === job.id) const failedStep = steps.find((step) => step.status === 'failed') ?? null - const cancellationRequest = await AgentExecutionControlService.getCancellationRequest(input.runtime, initialJob.id) + const cancellationRequest = await AgentExecutionControlService.getCancellationRequest(runtime, initialJob.id) const cancelled = Boolean(failedStep && (cancellationObserved || cancellationRequest)) if (cancelled) { latestWorking = { @@ -479,8 +562,8 @@ export async function executeAgentChapterPipeline( } const draftIds = latestWorking.generatedChapterDrafts.filter((draft) => draft.jobId === job.id).map((draft) => draft.id) latestWorking = finishAgentRun(latestWorking, input.agentRunId, job, draftIds, failedStep, cancelled) - await saveAgentRuntimeData(latestWorking, input.runtime) - await AgentExecutionControlService.clearCancellation(input.runtime, initialJob.id) + await persistWorking(latestWorking) + await AgentExecutionControlService.clearCancellation(runtime, initialJob.id) const agentRun = latestWorking.agentRuns.find((item) => item.id === input.agentRunId) if (!agentRun) throw new Error(`AgentRun not found after execution: ${input.agentRunId}`) return { diff --git a/src/agent/AgentReadableSummaryService.ts b/src/agent/AgentReadableSummaryService.ts index eff47a5..d36dd77 100644 --- a/src/agent/AgentReadableSummaryService.ts +++ b/src/agent/AgentReadableSummaryService.ts @@ -1,5 +1,5 @@ import type { AppData, ID } from '../shared/types' -import { AgentDecisionService } from './AgentDecisionService' +import { AgentDecisionService, type AgentAcceptanceRecommendation } from './AgentDecisionService' import type { AgentProjectOverviewData } from './agentProjectOverviewData' import { getCandidateDetail, @@ -53,7 +53,7 @@ export class AgentReadableSummaryService { static getNextChapterTarget( data: AgentProjectOverviewData, projectId: ID - ): { projectId: ID; nextChapterOrder: number; reason: string } { + ): ReturnType { return getNextChapterTarget(data, projectId) } @@ -69,8 +69,8 @@ export class AgentReadableSummaryService { return getChapterProductionState(data, projectId, chapterOrder) } - static getRunDiagnostics(data: AppData, jobId: ID): AgentRunDiagnostics { - return getRunDiagnostics(data, jobId) + static getRunDiagnostics(data: AppData, jobId: ID, options: AgentReadOptions = {}): AgentRunDiagnostics { + return getRunDiagnostics(data, jobId, options) } static getChapterVersionChain( @@ -146,16 +146,7 @@ export class AgentReadableSummaryService { static getAcceptanceRecommendation( data: AppData, jobId: ID - ): { - jobId: ID - recommendation: 'accept' | 'revise' | 'reject' | 'wait' | 'pause' - confidence: number - riskLevel: string - reasons: string[] - draftId: ID | null - nextActions: string[] - requiresHumanApproval: boolean - } { + ): AgentAcceptanceRecommendation { return AgentDecisionService.buildAcceptanceRecommendation(data, jobId) } } diff --git a/src/agent/AgentRunService.ts b/src/agent/AgentRunService.ts index 17183eb..2ed6e4b 100644 --- a/src/agent/AgentRunService.ts +++ b/src/agent/AgentRunService.ts @@ -15,6 +15,7 @@ import type { import { isChapterArchived } from '../services/ChapterLifecycleService' import { createPipelineAIRunConfig } from '../services/PipelineRunContextService' import { PipelineRecipeService } from '../services/PipelineRecipeService' +import { assertEarlierRunTargetsWritten } from './agentBatchWriteback' export const AGENT_PIPELINE_STEP_ORDER: ChapterGenerationStepType[] = [ 'context_need_planning', @@ -150,6 +151,13 @@ export class AgentRunService { } const targetChapterOrders = targetOrdersFromInput(input) + const priorRun = input.agentRunId && appData.agentRuns.find((run) => run.id === input.agentRunId) + if (priorRun) { + if (priorRun.projectId !== projectId) throw new Error('AgentRun belongs to another project.') + assertEarlierRunTargetsWritten(appData, priorRun, targetChapterOrder) + throw new Error('该批次已登记。请在原批次预览并应用章节任务编辑,不要覆盖已有任务。') + } + assertEarlierRunTargetsWritten(appData, { projectId, targetChapterOrders, createdJobIds: [] }, targetChapterOrder) const mode: AgentRunMode = targetChapterOrders.length > 1 ? 'multi_chapter' : 'single_chapter' const existingChapter = appData.chapters.find((chapter) => chapter.projectId === projectId && chapter.order === targetChapterOrder) ?? null if (existingChapter && isChapterArchived(existingChapter)) { diff --git a/src/agent/AgentRuntime.ts b/src/agent/AgentRuntime.ts index ec2d8bc..1af28d1 100644 --- a/src/agent/AgentRuntime.ts +++ b/src/agent/AgentRuntime.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto' +import { loadJsonSnapshotReadonly } from '../storage/jsonReadonly' import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' @@ -8,6 +8,8 @@ import type { StorageWriteResult } from '../shared/ipc/ipcTypes' import type { CandidateDecisionCommand } from '../shared/types/candidateDecision' import { applyCandidateDecisionChanges, CandidateDecisionError, previewCandidateDecisions } from '../services/CandidateDecisionService' import { previewCandidateDecisionUndo } from '../services/CandidateDecisionUndoService' +import { observeFormalChapterWrites } from '../services/ChapterWritebackFailureService' +import type { FormalChapterCommitWriter, StorageService } from '../storage/StorageService' import { normalizeAppData } from '../shared/defaults' import { JsonStorageService } from '../storage/JsonStorageService' import { createStorageService, loadSqliteSnapshotReadonly } from '../storage/SqliteStorageService' @@ -97,11 +99,7 @@ export async function loadAgentRuntimeData(options: AgentRuntimeLoadOptions = {} const siblingJsonPath = existsSync(jsonPath) ? jsonPath : join(dirname(sqlitePath), JSON_DATA_FILE_NAME) if (existsSync(siblingJsonPath)) { - const raw = await readFile(siblingJsonPath, 'utf-8') - const snapshot = { - data: normalizeAppData(JSON.parse(raw) as Partial), - revision: createHash('sha256').update(raw).digest('hex') - } + const snapshot = await loadJsonSnapshotReadonly(siblingJsonPath) return { data: snapshot.data, storagePath: siblingJsonPath, @@ -184,26 +182,17 @@ async function resolveAgentRuntimeSaveTarget(runtime: AgentRuntimeData | AgentRu } } -export async function saveAgentChapterCommitBundle( - bundle: ChapterCommitBundle, - runtime: AgentRuntimeData | AgentRuntimeLoadOptions +async function saveAgentFormalCommit( + runtime: AgentRuntimeData | AgentRuntimeLoadOptions, + write: (storage: FormalChapterCommitWriter, revision?: string) => Promise ): Promise { const resolved = await resolveAgentRuntimeSaveTarget(runtime) const isJson = resolved.source === 'json' || resolved.storagePath.toLowerCase().endsWith('.json') - if (isJson) { - const result = await new JsonStorageService(resolveJsonStoragePath(resolved.storagePath)).saveChapterCommitBundle( - bundle, - isLoadedAgentRuntime(runtime) ? runtime.revision : undefined - ) - adoptRuntimeWriteResult(runtime, result) - return result - } - const storage = createStorageService(resolveSqliteStoragePath(resolved.storagePath)) + const storage: StorageService = isJson + ? new JsonStorageService(resolveJsonStoragePath(resolved.storagePath)) + : createStorageService(resolveSqliteStoragePath(resolved.storagePath)) try { - const result = await storage.saveChapterCommitBundle( - bundle, - isLoadedAgentRuntime(runtime) ? runtime.revision : undefined - ) + const result = await write(observeFormalChapterWrites(storage), isLoadedAgentRuntime(runtime) ? runtime.revision : undefined) adoptRuntimeWriteResult(runtime, result) return result } finally { @@ -211,31 +200,18 @@ export async function saveAgentChapterCommitBundle( } } +export async function saveAgentChapterCommitBundle( + bundle: ChapterCommitBundle, + runtime: AgentRuntimeData | AgentRuntimeLoadOptions +): Promise { + return saveAgentFormalCommit(runtime, (storage, revision) => storage.saveChapterCommitBundle(bundle, revision)) +} + export async function saveAgentRevisionCommitBundle( bundle: RevisionCommitBundle, runtime: AgentRuntimeData | AgentRuntimeLoadOptions ): Promise { - const resolved = await resolveAgentRuntimeSaveTarget(runtime) - const isJson = resolved.source === 'json' || resolved.storagePath.toLowerCase().endsWith('.json') - if (isJson) { - const result = await new JsonStorageService(resolveJsonStoragePath(resolved.storagePath)).saveRevisionCommitBundle( - bundle, - isLoadedAgentRuntime(runtime) ? runtime.revision : undefined - ) - adoptRuntimeWriteResult(runtime, result) - return result - } - const storage = createStorageService(resolveSqliteStoragePath(resolved.storagePath)) - try { - const result = await storage.saveRevisionCommitBundle( - bundle, - isLoadedAgentRuntime(runtime) ? runtime.revision : undefined - ) - adoptRuntimeWriteResult(runtime, result) - return result - } finally { - storage.close?.() - } + return saveAgentFormalCommit(runtime, (storage, revision) => storage.saveRevisionCommitBundle(bundle, revision)) } export async function saveAgentCandidateDecision( diff --git a/src/agent/agentBatchWriteback.ts b/src/agent/agentBatchWriteback.ts new file mode 100644 index 0000000..930de68 --- /dev/null +++ b/src/agent/agentBatchWriteback.ts @@ -0,0 +1,72 @@ +import type { AgentRun, AppData, ID } from '../shared/types' +import { createBookWritebackIndex } from '../services/BookWritebackService' + +type RunTargets = Pick + +export interface PendingRunTarget { + chapterOrder: number + jobId: ID | null + chapterId: ID | null + draftId: ID | null + writebackFailure?: AppData['chapterWritebackFailures'][number] + reason: string +} + +export function isMultiChapterRun(run: AgentRun): boolean { + return run.mode === 'multi_chapter' || run.targetChapterOrders.length > 1 +} + +export function firstPendingRunTarget(data: AppData, run: RunTargets, beforeOrder = Infinity): PendingRunTarget | null { + const orders = run.targetChapterOrders.filter((order) => order < beforeOrder).sort((a, b) => a - b) + if (!orders.length) return null + const jobs = new Map(data.chapterGenerationJobs.filter((job) => job.projectId === run.projectId).map((job) => [job.id, job])) + const jobByOrder = new Map() + // A task edit appends a replacement job. Editing or replaying an older job must not make it current again. + for (const id of run.createdJobIds) { + const job = jobs.get(id) + if (job) jobByOrder.set(job.targetChapterOrder, id) + } + const book = createBookWritebackIndex(data, run.projectId) + for (const chapterOrder of orders) { + const jobId = jobByOrder.get(chapterOrder) + if (!jobId) return { chapterOrder, chapterId: null, jobId: null, draftId: null, reason: '本批没有该章的任务记录,请先准备该章任务。' } + const { confirmed, chapterId, draftId, reason, writebackFailure } = book.verifyJob(jobId) + if (!confirmed) { + const draft = draftId || writebackFailure?.kind === 'revision_commit' ? null : data.generatedChapterDrafts + .filter((item) => item.jobId === jobId && item.projectId === run.projectId) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0] + return { chapterOrder, chapterId: chapterId ?? draft?.chapterId ?? null, jobId, draftId: draftId ?? draft?.id ?? null, + reason, ...(writebackFailure ? { writebackFailure } : {}) } + } + } + return null +} + +export function describePendingRunTarget(target: PendingRunTarget): string { + const job = target.jobId ? `,任务 ${target.jobId}` : '' + const failure = target.writebackFailure + if (failure?.kind === 'revision_commit') { + const revision = failure.revisionVersionId ? `修订候选 ${failure.revisionVersionId}` + : failure.revisionSessionId ? `修订会话 ${failure.revisionSessionId}` : `修订提交 ${failure.commitId}` + return `请回到第 ${target.chapterOrder} 章的${revision}。${target.reason}` + } + const draft = target.draftId ? `,草稿 ${target.draftId}` : '' + return `请回到第 ${target.chapterOrder} 章${job}${draft}。${target.reason}` +} + +export function assertEarlierRunTargetsWritten(data: AppData, run: RunTargets, targetOrder: number): void { + const pending = firstPendingRunTarget(data, run, targetOrder) + if (pending) throw new Error(`本批不能推进到第 ${targetOrder} 章。${describePendingRunTarget(pending)}`) +} + +export function checkPipelineRunTarget(data: AppData, agentRunId: ID, jobId: ID) { + const run = data.agentRuns.find((item) => item.id === agentRunId) + if (!run) throw new Error(`AgentRun not found: ${agentRunId}`) + const job = data.chapterGenerationJobs.find((item) => item.id === jobId) + if (!job) throw new Error(`Job not found: ${jobId}`) + if (run.projectId !== job.projectId || !run.createdJobIds.includes(job.id) || !run.targetChapterOrders.includes(job.targetChapterOrder)) { + throw new Error('AgentRun and pipeline job are not part of the same production run.') + } + if (isMultiChapterRun(run)) assertEarlierRunTargetsWritten(data, run, job.targetChapterOrder) + return { run, job } +} diff --git a/src/agent/agentChapterSummaries.ts b/src/agent/agentChapterSummaries.ts index d4d38fa..3520691 100644 --- a/src/agent/agentChapterSummaries.ts +++ b/src/agent/agentChapterSummaries.ts @@ -1,4 +1,6 @@ import type { AppData, ID } from '../shared/types' +import { visibleCharacterCount } from '../services/chapterTaskContractText' +import { hasCurrentChapterFragment, readChapterStepFragment } from '../shared/chapterStepFragment' import { findActiveChapterByOrder } from '../services/ChapterLifecycleService' import { latestConsistencyReportsForDraft, @@ -18,7 +20,7 @@ import { redundancySummary, summarizeJob } from './agentSummaryHelpers' -import { compact } from './agentReadableText' +import { compact, redactSensitiveText, type AgentReadOptions } from './agentReadableText' import type { AgentChapterState, AgentRunDiagnostics } from './agentReadableSummaryTypes' interface ScopedRecord { @@ -55,15 +57,16 @@ export function getChapterProductionState( return Boolean(chapter && draft.chapterId === chapter.id) }) .sort(byUpdatedAtDesc) - const latestDraft = drafts[0] ?? null + const fragmentOnly = latestJob && hasCurrentChapterFragment(data.chapterGenerationSteps, latestJob.id, drafts[0]) + const latestDraft = fragmentOnly ? null : drafts[0] ?? null const quality = latestDraft ? latestQualityReportForDraft( data.qualityGateReports.filter((report) => report.projectId === projectId && report.jobId === latestDraft.jobId), latestDraft ) - : latestReport( + : latestJob ? null : latestReport( data.qualityGateReports.filter((report) => - belongsToRunOrChapter(report, projectId, latestJob?.id ?? null, chapter?.id ?? null) + belongsToRunOrChapter(report, projectId, null, chapter?.id ?? null) ) ) const consistency = latestDraft @@ -71,9 +74,9 @@ export function getChapterProductionState( data.consistencyReviewReports.filter((report) => report.projectId === projectId && report.jobId === latestDraft.jobId), latestDraft )[0] ?? null - : latestReport( + : latestJob ? null : latestReport( data.consistencyReviewReports.filter((report) => - belongsToRunOrChapter(report, projectId, latestJob?.id ?? null, chapter?.id ?? null) + belongsToRunOrChapter(report, projectId, null, chapter?.id ?? null) ) ) const redundancy = latestDraft @@ -81,15 +84,15 @@ export function getChapterProductionState( data.redundancyReports.filter((report) => report.projectId === projectId && report.jobId === latestDraft.jobId), latestDraft ) - : latestReport( + : latestJob ? null : latestReport( data.redundancyReports.filter((report) => - belongsToRunOrChapter(report, projectId, latestJob?.id ?? null, chapter?.id ?? null) + belongsToRunOrChapter(report, projectId, null, chapter?.id ?? null) ) ) const summary = latestReport( data.runTraceAuthorSummaries.filter((item) => { if (!belongsToRunOrChapter(item, projectId, latestJob?.id ?? null, chapter?.id ?? null)) return false - if (!latestDraft) return true + if (!latestDraft) return !latestJob if (item.generatedDraftId !== latestDraft.id || item.createdAt < latestDraft.updatedAt) return false if (item.sourceRefs.qualityGateReportId && item.sourceRefs.qualityGateReportId !== quality?.id) return false if (item.sourceRefs.consistencyReviewReportId && item.sourceRefs.consistencyReviewReportId !== consistency?.id) return false @@ -111,17 +114,20 @@ export function getChapterProductionState( title: chapter.title, updatedAt: chapter.updatedAt, bodyCharCount: chapter.body.length, + wordCount: visibleCharacterCount(chapter.body), summary: compact(chapter.summary), endingHook: compact(chapter.endingHook) } : null, latestJob: summarizeJob(latestJob), + ...(fragmentOnly ? { latestAttemptHasOnlyFragment: true as const } : {}), drafts: drafts.slice(0, 5).map((draft) => ({ id: draft.id, jobId: draft.jobId, title: draft.title, status: draft.status, bodyCharCount: draft.body.length, + wordCount: visibleCharacterCount(draft.body), tokenEstimate: draft.tokenEstimate, updatedAt: draft.updatedAt })), @@ -147,12 +153,13 @@ export function getChapterProductionState( } } -export function getRunDiagnostics(data: AppData, jobId: ID): AgentRunDiagnostics { +export function getRunDiagnostics(data: AppData, jobId: ID, options: AgentReadOptions = {}): AgentRunDiagnostics { const job = data.chapterGenerationJobs.find((item) => item.id === jobId) ?? null const trace = data.generationRunTraces.find((item) => item.jobId === jobId) ?? null - const draft = [...data.generatedChapterDrafts] + const latestDraft = [...data.generatedChapterDrafts] .filter((item) => item.jobId === jobId) .sort(byUpdatedAtDesc)[0] ?? null + const draft = hasCurrentChapterFragment(data.chapterGenerationSteps, jobId, latestDraft) ? null : latestDraft const quality = draft ? latestQualityReportForDraft(data.qualityGateReports.filter((report) => report.jobId === jobId), draft) : null @@ -162,7 +169,7 @@ export function getRunDiagnostics(data: AppData, jobId: ID): AgentRunDiagnostics const summary = latestReport( data.runTraceAuthorSummaries.filter((item) => { if (item.jobId !== jobId && item.traceId !== trace?.id) return false - if (!draft) return true + if (!draft) return false if (item.generatedDraftId !== draft.id || item.createdAt < draft.updatedAt) return false if (item.sourceRefs.qualityGateReportId && item.sourceRefs.qualityGateReportId !== quality?.id) return false if (item.sourceRefs.consistencyReviewReportId && item.sourceRefs.consistencyReviewReportId !== consistency?.id) return false @@ -174,14 +181,24 @@ export function getRunDiagnostics(data: AppData, jobId: ID): AgentRunDiagnostics steps: data.chapterGenerationSteps .filter((step) => step.jobId === jobId) .sort((a, b) => a.createdAt.localeCompare(b.createdAt)) - .map((step) => ({ - id: step.id, - type: step.type, - status: step.status, - errorMessage: compact(step.errorMessage, 300), - outputCharCount: step.output.length, - updatedAt: step.updatedAt - })), + .map((step) => { + const fragment = readChapterStepFragment(step) + return { + id: step.id, + type: step.type, + status: step.status, + errorMessage: compact(step.errorMessage, 300), + outputCharCount: step.output.length, + ...(fragment ? { fragment: { + kind: fragment.kind, + title: compact(redactSensitiveText(fragment.title), 160), + finishReason: fragment.finishReason ? compact(redactSensitiveText(fragment.finishReason), 120) : null, + bodyCharCount: fragment.body.length, + ...(options.detail === 'full' ? { body: redactSensitiveText(fragment.body).slice(0, options.maxChars) } : {}) + } } : {}), + updatedAt: step.updatedAt + } + }), trace: trace ? { id: trace.id, diff --git a/src/agent/agentCharacterCardReview.ts b/src/agent/agentCharacterCardReview.ts new file mode 100644 index 0000000..8696d0a --- /dev/null +++ b/src/agent/agentCharacterCardReview.ts @@ -0,0 +1,43 @@ +import type { AppData, ID } from '../shared/types' +import { redactSensitiveText } from '../shared/errorUtils' +import { getCharacterCardReview, type CardReviewEvidence } from '../services/characterState/cardReview' + +export function readAgentCharacterCardReview(data: AppData, projectId: ID, characterId: ID, + full: boolean, asOfChapterOrder?: number, maxChars = 160) { + const review = getCharacterCardReview(data, projectId, characterId, asOfChapterOrder) + const compact = { + status: review.status, currentChapterOrder: review.currentChapter?.chapterOrder ?? null, + lastChangedChapter: review.lastChangedChapter, + lastReviewedChapterId: review.lastCardReview?.chapterId ?? null, + lastReviewedChapterOrder: review.lastReviewedChapter?.chapterOrder ?? review.lastCardReview?.chapterOrder ?? null, + latestConfirmedFactChapterOrder: review.latestConfirmedFactChapter?.chapterOrder ?? null, + chaptersSinceReview: review.chaptersSinceReview, changesSinceReview: review.changesSinceReview, + evidenceCount: review.evidence.length, unlocatedCount: review.unlocatedCount + } + if (!full) return compact + const bodies = new Map(data.chapters.filter(c => c.projectId === projectId).map(c => [c.id, c.body.trim()])) + function evidence(row: CardReviewEvidence) { + const chapter = row.chapter + const secret = [data.settings.apiKey] + const body = bodies.get(chapter.chapterId) + const safeBody = body && redactSensitiveText(body, secret) + function excerpt(raw: string) { + let safe = redactSensitiveText(raw, secret) + // A one-line chapter can be an entire ledger quote. Return a locator and a fragment, not the manuscript. + if (safeBody && safe.includes(safeBody)) { + const chars = Array.from(safeBody) + safe = safe.replaceAll(safeBody, chars.slice(0, Math.min(80, Math.floor(chars.length / 2))).join('') + '…') + } + const chars = Array.from(safe), limit = Math.min(maxChars, 160) + return chars.length > limit ? chars.slice(0, limit).join('') + '…' : safe + } + const value = (v: CardReviewEvidence['after']) => typeof v === 'string' || Array.isArray(v) + ? excerpt(Array.isArray(v) ? v.join('、') : v) : v + return { chapterId: chapter.chapterId, chapterOrder: chapter.chapterOrder, chapterVersionId: chapter.chapterVersionId, + bodyFingerprint: chapter.bodyFingerprint, factId: row.factId, transactionId: row.transactionId, label: excerpt(row.label), + transactionType: row.transactionType, stateKey: excerpt(row.stateKey), before: value(row.before), after: value(row.after), + unit: excerpt(row.unit), evidenceKind: row.location ? 'current_text' : 'ledger_note', excerpt: excerpt(row.evidence), location: row.location } + } + return { ...compact, evidence: review.evidence.map(evidence), + ...(review.latestConfirmedFact && !review.evidence.length ? { latestConfirmedFact: evidence(review.latestConfirmedFact) } : {}) } +} diff --git a/src/agent/agentHardCanonEvidence.ts b/src/agent/agentHardCanonEvidence.ts new file mode 100644 index 0000000..e965e07 --- /dev/null +++ b/src/agent/agentHardCanonEvidence.ts @@ -0,0 +1,22 @@ +import type { HardCanonAuditResult } from '../shared/types' +import { hardCanonEvidenceContext } from '../shared/hardCanonEvidence' +import { redactSensitiveText } from './agentReadableText' + +export function agentHardCanonEvidence(audit: HardCanonAuditResult | null, body: string, full = false) { + if (!audit) return undefined + const limit = full ? Infinity : 3 + const selected = !full && audit.findings.length > limit + ? [...audit.findings.filter(item => item.severity === 'block'), ...audit.findings.filter(item => item.severity === 'review')] + : audit.findings + return { + draftContentHash: audit.draftContentHash, rawBodyFingerprint: audit.rawBodyFingerprint, rulesFingerprint: audit.rulesFingerprint, + checkedRuleCount: audit.checkedRuleIds.length, findingCount: audit.findings.length, + findings: selected.slice(0, limit).map(finding => { + const context = hardCanonEvidenceContext(finding, body) + return { ...finding, ruleTitle: redactSensitiveText(finding.ruleTitle), reason: redactSensitiveText(finding.reason), + excerpt: redactSensitiveText(finding.excerpt), + ...(context ? { sentence: redactSensitiveText(context.before + finding.excerpt + context.after) } : {}) } + }), + countRuleCount: audit.counts.length, counts: audit.counts.slice(0, limit) + } +} diff --git a/src/agent/agentPipelineWritebackGuard.ts b/src/agent/agentPipelineWritebackGuard.ts new file mode 100644 index 0000000..bf9045e --- /dev/null +++ b/src/agent/agentPipelineWritebackGuard.ts @@ -0,0 +1,84 @@ +import type { AgentRun, ID } from '../shared/types' +import { normalizeAppData } from '../shared/defaults' +import { BOOK_PROGRESS_COLLECTIONS } from '../services/BookProgressService' +import { loadSqliteCollectionsReadonly } from '../storage/SqliteStorageService' +import { StorageRevisionConflictError } from '../storage/StorageService' +import { loadAgentRuntimeData, saveAgentRuntimeData, type AgentRuntimeData } from './AgentRuntime' +import { checkPipelineRunTarget, isMultiChapterRun } from './agentBatchWriteback' + +export async function reopenPipelineRuntime(runtime: AgentRuntimeData) { + if (!runtime.storagePath) throw new Error('没有可重读的书库,无法核对章节任务。') + const fresh = await loadAgentRuntimeData({ storagePath: runtime.storagePath, userDataPath: runtime.userDataPath }) + if (fresh.source === 'empty' || fresh.storagePath !== runtime.storagePath) throw new Error('原书库已不存在或位置已变化,请重新打开任务。') + return fresh +} + +export function batchModelRequestGuard(runtime: AgentRuntimeData, run: AgentRun, jobId: ID) { + if (!isMultiChapterRun(run)) return undefined + return async () => { + if (runtime.source === 'sqlite' && runtime.storagePath) { + // Only this executor advances its private revision, by CAS-saving steps for the current job. + const head = await loadSqliteCollectionsReadonly(runtime.storagePath, []) + if (head.revision === runtime.revision) return + const fresh = await loadSqliteCollectionsReadonly(runtime.storagePath, [...BOOK_PROGRESS_COLLECTIONS, 'agentRuns'], run.projectId) + checkPipelineRunTarget(normalizeAppData(fresh.data), run.id, jobId) + throw new StorageRevisionConflictError() + } + const fresh = await reopenPipelineRuntime(runtime) + checkPipelineRunTarget(fresh.data, run.id, jobId) + // Even a legitimate concurrent edit invalidates the already prepared prompt and the executor's next save. + if (fresh.revision !== runtime.revision) throw new StorageRevisionConflictError() + } +} + +export const BATCH_EXECUTION_PREFIX = 'agent-batch-execution-' + +// Stop only the attempt that lost its checkpoint. Never adopt this fresh revision in the stale executor. +export async function pauseInterruptedBatchAttempt(runtime: AgentRuntimeData, runId: ID, jobId: ID, executionId: ID, reason: string) { + const lastJob = runtime.data.chapterGenerationJobs.find(job => job.id === jobId) + if (lastJob?.status !== 'running' || !runtime.data.agentRuns.find(run => run.id === runId)?.decisions.some(d => d.id === executionId)) return + for (let attempt = 0; ; attempt++) { + const fresh = await reopenPipelineRuntime(runtime) + const { data } = fresh + const run = data.agentRuns.find(r => r.id === runId) + const job = data.chapterGenerationJobs.find(j => j.id === jobId) + if (!run || !job || job.status !== 'running' || job.currentStep !== lastJob.currentStep || job.updatedAt !== lastJob.updatedAt) return + const starts = run.decisions.filter(d => d.id.startsWith(BATCH_EXECUTION_PREFIX)) + if (starts.filter(d => d.jobId === jobId).at(-1)?.id !== executionId) return + const step = data.chapterGenerationSteps.find(s => s.jobId === jobId && s.type === job.currentStep) + const lastStep = runtime.data.chapterGenerationSteps.find(s => s.jobId === jobId && s.type === lastJob.currentStep) + if (step?.id !== lastStep?.id || step?.status !== lastStep?.status || step?.updatedAt !== lastStep?.updatedAt) return + const updatedAt = new Date().toISOString() + const next = { ...data, + chapterGenerationJobs: data.chapterGenerationJobs.map(j => j.id === jobId + ? { ...j, status: 'paused' as const, errorMessage: reason, updatedAt } : j), + chapterGenerationSteps: step?.status !== 'running' ? data.chapterGenerationSteps : data.chapterGenerationSteps.map(s => + s.id === step.id ? { ...s, status: 'failed' as const, errorMessage: reason, updatedAt } : s), + agentRuns: starts.at(-1)?.id !== executionId || run.status !== 'running' ? data.agentRuns : data.agentRuns.map(r => + r.id === runId ? { ...r, status: 'paused' as const, summary: reason, updatedAt } : r) + } + try { + await saveAgentRuntimeData(next, fresh) + return + } catch (error) { + if (attempt !== 0 || !(error instanceof StorageRevisionConflictError)) throw error + } + } +} + +// Preserve optional methods on injected providers (their presence selects the pipeline's fallback path). +export function guardModelCalls( + service: T, check?: () => Promise, methods?: readonly (keyof T)[] +): T { + if (!check) return service + return new Proxy(service, { + get(target, key) { + const value = Reflect.get(target, key) + if (typeof value !== 'function' || (methods && !methods.includes(key as keyof T))) return value + return async (...args: unknown[]) => { + await check() + return Reflect.apply(value, target, args) + } + } + }) +} diff --git a/src/agent/agentProjectSummaries.ts b/src/agent/agentProjectSummaries.ts index 4ff6bf7..a1a8ef8 100644 --- a/src/agent/agentProjectSummaries.ts +++ b/src/agent/agentProjectSummaries.ts @@ -1,5 +1,7 @@ import type { AppData, ID } from '../shared/types' import { activeChapters, archivedChapters, nextChapterOrder } from '../services/ChapterLifecycleService' +import { summarizeBookManuscript } from '../services/bookProgress/manuscript' +import { visibleCharacterCount } from '../services/chapterTaskContractText' import type { AgentProjectOverviewData } from './agentProjectOverviewData' import { byChapterOrderDesc, @@ -49,6 +51,7 @@ export function getProjectDigest(data: AgentProjectOverviewData, projectId: ID): if (!project) throw new Error(`Project not found: ${projectId}`) const projectChapters = data.chapters.filter((chapter) => chapter.projectId === projectId) const chapters = activeChapters(projectChapters).sort(byChapterOrderDesc) + const manuscript = summarizeBookManuscript(projectChapters, projectId) const latestGenerationJob = latestJobForProject(data, projectId) const hardCanonPack = data.hardCanonPacks.find((pack) => pack.projectId === projectId) ?? null const activeStoryDirection = [...data.storyDirectionGuides] @@ -63,7 +66,8 @@ export function getProjectDigest(data: AgentProjectOverviewData, projectId: ID): description: compact(project.description, 300), updatedAt: project.updatedAt }, - nextChapterOrder: nextChapterOrder(data.chapters, projectId), + nextChapterOrder: manuscript.nextAssignableOrder, + manuscript: { totalWords: manuscript.totalWords, chapterCount: manuscript.chapterCount, completeThrough: manuscript.completeThrough }, counts: { chapters: chapters.length, archivedChapters: archivedChapters(projectChapters).length, @@ -85,6 +89,7 @@ export function getProjectDigest(data: AgentProjectOverviewData, projectId: ID): title: chapter.title, updatedAt: chapter.updatedAt, bodyCharCount: chapter.body.length, + wordCount: visibleCharacterCount(chapter.body), summary: compact(chapter.summary), endingHook: compact(chapter.endingHook) })), @@ -104,16 +109,18 @@ export function getProjectDigest(data: AgentProjectOverviewData, projectId: ID): export function getNextChapterTarget( data: AgentProjectOverviewData, projectId: ID -): { projectId: ID; nextChapterOrder: number; reason: string } { +) { const project = findProject(data, projectId) if (!project) throw new Error(`Project not found: ${projectId}`) const nextOrder = nextChapterOrder(data.chapters, projectId) return { projectId, nextChapterOrder: nextOrder, + nextAssignableOrder: nextOrder, + progressTool: 'agent.getBookProgress' as const, reason: nextOrder > 1 - ? `Highest allocated chapter order is ${nextOrder - 1}; archived orders are not reused.` + ? `Highest allocated chapter order is ${nextOrder - 1}; archived orders are not reused. This is not proof that every prior chapter was written back.` : 'No chapter orders have been allocated.' } } diff --git a/src/agent/agentReadableSummaryTypes.ts b/src/agent/agentReadableSummaryTypes.ts index 06acb41..35259fa 100644 --- a/src/agent/agentReadableSummaryTypes.ts +++ b/src/agent/agentReadableSummaryTypes.ts @@ -32,6 +32,7 @@ export interface AgentProjectDigest { updatedAt: string } nextChapterOrder: number + manuscript: { totalWords: number; chapterCount: number; completeThrough: number } counts: { chapters: number archivedChapters: number @@ -49,6 +50,7 @@ export interface AgentProjectDigest { title: string updatedAt: string bodyCharCount: number + wordCount: number summary: string endingHook: string }> @@ -70,16 +72,19 @@ export interface AgentChapterState { title: string updatedAt: string bodyCharCount: number + wordCount: number summary: string endingHook: string } | null latestJob: AgentJobSummary | null + latestAttemptHasOnlyFragment?: true drafts: Array<{ id: ID jobId: ID title: string status: string bodyCharCount: number + wordCount: number tokenEstimate: number updatedAt: string }> @@ -110,6 +115,13 @@ export interface AgentRunDiagnostics { status: string errorMessage: string outputCharCount: number + fragment?: { + kind: 'incomplete_chapter_fragment' + title: string + finishReason: string | null + bodyCharCount: number + body?: string + } updatedAt: string }> trace: { diff --git a/src/agent/agentReadableText.ts b/src/agent/agentReadableText.ts index 4064cad..3500f30 100644 --- a/src/agent/agentReadableText.ts +++ b/src/agent/agentReadableText.ts @@ -1,4 +1,5 @@ import type { AppData, ChapterGenerationJob, ID } from '../shared/types' +import { visibleCharacterCount } from '../services/chapterTaskContractText' export type AgentReadDetail = 'summary' | 'compact' | 'full' @@ -15,6 +16,7 @@ export interface AgentTextReadResult { kind: 'chapter' | 'draft' | 'version' | 'prompt' | 'trace' | 'candidate' title: string charCount: number + wordCount?: number excerpt: string text?: string metadata: Record @@ -74,6 +76,7 @@ export function textPayload( kind, title, charCount: safeText.length, + ...(['chapter', 'draft', 'version'].includes(kind) ? { wordCount: visibleCharacterCount(text) } : {}), excerpt: compact(safeText, excerptLimit), text: include ? (explicitTextLimit ? safeText.slice(0, explicitTextLimit) : safeText) : undefined, metadata diff --git a/src/agent/agentVersionReaders.ts b/src/agent/agentVersionReaders.ts index d9658fe..d878fa2 100644 --- a/src/agent/agentVersionReaders.ts +++ b/src/agent/agentVersionReaders.ts @@ -4,6 +4,8 @@ import { isChapterArchived } from '../services/ChapterLifecycleService' import { getChapterVersionChain as buildChapterVersionChain } from '../services/ChapterVersionChainService' +import { currentHardCanonAudit } from '../services/HardCanonPreflight' +import { agentHardCanonEvidence } from './agentHardCanonEvidence' import { commonPrefixLength, commonSuffixLength, @@ -75,6 +77,9 @@ export function getVersionDetail( const chapter = requireReadableChapter(data, projectId, chapterOrder) const entry = buildChapterVersionChain(data, chapter.id).find((item) => item.id === versionId) ?? null if (!entry) throw new Error(`Version not found: ${versionId}`) + const canon = options.detail === 'full' || options.includeDiagnostics ? currentHardCanonAudit(data, { + projectId, chapterId: chapter.id, chapterOrder, body: entry.body + }) : null return textPayload(entry.id, 'version', entry.title, entry.body, options, { projectId, chapterId: chapter.id, @@ -82,6 +87,7 @@ export function getVersionDetail( chapterArchived: isChapterArchived(chapter), source: entry.source, sourceKind: entry.sourceKind, + ...(canon ? { currentHardCanon: agentHardCanonEvidence(canon, entry.body, true) } : {}), createdAt: entry.createdAt, linkedChapterCommitId: entry.linkedChapterCommitId, linkedRevisionCommitId: entry.linkedRevisionCommitId, diff --git a/src/agent/cli.ts b/src/agent/cli.ts index da86b5f..1bdf832 100644 --- a/src/agent/cli.ts +++ b/src/agent/cli.ts @@ -118,6 +118,9 @@ Commands: list-projects project-digest --project-id | --project next-chapter-target --project-id | --project + book-progress --project-id | --project [--from ] [--to ] [--issues-only] [--offset ] [--limit ] + project-usage --project-id | --project + book-continuity --project-id | --project [--from ] [--to ] [--kind ] [--status conflict|needs_evidence|open] [--offset ] [--limit ] chapter-state --project-id | --project --chapter chapter-text --project-id | --project --chapter [--detail full] [--include-prose] [--max-chars ] draft-text --draft-id [--detail full] [--include-prose] [--max-chars ] @@ -126,8 +129,8 @@ Commands: version-detail --project-id | --project --chapter --version-id [--detail full] version-diff --project-id | --project --chapter --from-version-id --to-version-id generation-prompt --job-id [--detail full] [--include-prompt] [--max-chars ] - run-diagnostics --job-id - inspect-job --job-id + run-diagnostics --job-id [--detail full] [--max-chars ] + inspect-job --job-id [--detail full] [--max-chars ] watch-job --job-id [--timeout-ms ] [--interval-ms ] full-run-trace --trace-id | --job-id [--detail full] [--include-diagnostics] candidate-detail --candidate-id [--detail full] [--include-diagnostics] @@ -165,6 +168,19 @@ function buildToolCall(parsed: ParsedArgs): AgentToolCallInput { return { name: 'agent.getProjectDigest', arguments: { ...storage, ...projectArgs(flags) } } case 'next-chapter-target': return { name: 'agent.getNextChapterTarget', arguments: { ...storage, ...projectArgs(flags) } } + case 'book-progress': + return { name: 'agent.getBookProgress', arguments: { ...storage, ...projectArgs(flags), fromChapter: numberFlag(flags, 'from'), + toChapter: numberFlag(flags, 'to'), issuesOnly: flags['issues-only'] === true, offset: numberFlag(flags, 'offset'), limit: numberFlag(flags, 'limit') } } + case 'project-usage': + return { name:'agent.getProjectUsage', arguments:{ ...storage, ...projectArgs(flags) } } + case 'book-continuity': { + const options = ['storage', 'user-data', 'project-id', 'project', 'from', 'to', 'kind', 'status', 'offset', 'limit'] + for (const [key, value] of Object.entries(flags)) { + if (!options.includes(key) || typeof value !== 'string' || !value.trim()) throw new Error(`Invalid --${key}.`) + } + return { name: 'agent.getBookContinuityReport', arguments: { ...storage, ...projectArgs(flags), from: numberFlag(flags, 'from'), + to: numberFlag(flags, 'to'), kind: flag(flags, 'kind'), status: flag(flags, 'status'), offset: numberFlag(flags, 'offset'), limit: numberFlag(flags, 'limit') } } + } case 'chapter-state': return { name: 'agent.getChapterProductionState', arguments: { ...storage, ...projectArgs(flags), chapterOrder: intFlag(flags, 'chapter') } } case 'chapter-text': @@ -192,15 +208,17 @@ function buildToolCall(parsed: ParsedArgs): AgentToolCallInput { case 'generation-prompt': return { name: 'agent.getGenerationPrompt', arguments: { ...storage, jobId: flag(flags, 'job-id'), ...readArgs(flags) } } case 'run-diagnostics': - return { name: 'agent.getRunDiagnostics', arguments: { ...storage, jobId: flag(flags, 'job-id') } } - case 'inspect-job': - return { name: 'agent.inspectPipelineJob', arguments: { ...storage, jobId: flag(flags, 'job-id') } } + case 'inspect-job': { + const { detail, maxChars } = readArgs(flags) + return { name: parsed.command === 'inspect-job' ? 'agent.inspectPipelineJob' : 'agent.getRunDiagnostics', + arguments: { ...storage, jobId: flag(flags, 'job-id'), detail, maxChars } } + } case 'full-run-trace': return { name: 'agent.getFullRunTrace', arguments: { ...storage, traceId: flag(flags, 'trace-id'), jobId: flag(flags, 'job-id'), ...readArgs(flags) } } case 'candidate-detail': return { name: 'agent.getCandidateDetail', arguments: { ...storage, candidateId: flag(flags, 'candidate-id'), ...readArgs(flags) } } case 'acceptance-recommendation': - return { name: 'agent.getAcceptanceRecommendation', arguments: { ...storage, jobId: flag(flags, 'job-id') } } + return { name: 'agent.getAcceptanceRecommendation', arguments: { ...storage, jobId: flag(flags, 'job-id'), detail: flag(flags, 'detail') } } case 'record-acceptance-decision': return { name: 'agent.recordAcceptanceDecision', arguments: { ...storage, agentRunId: flag(flags, 'agent-run-id'), jobId: flag(flags, 'job-id') } } case 'action-previews': diff --git a/src/agent/mcp/server.ts b/src/agent/mcp/server.ts index f60e4e1..6d439a3 100644 --- a/src/agent/mcp/server.ts +++ b/src/agent/mcp/server.ts @@ -65,7 +65,8 @@ async function handleRequest(request: JsonRpcRequest): Promise ({ name: tool.name, description: tool.description, - inputSchema: tool.inputSchema + inputSchema: tool.inputSchema, + ...(tool.annotations && { annotations: tool.annotations }) })) } } diff --git a/src/agent/revision/agentRevisionCandidates.ts b/src/agent/revision/agentRevisionCandidates.ts index 0683f73..177e4db 100644 --- a/src/agent/revision/agentRevisionCandidates.ts +++ b/src/agent/revision/agentRevisionCandidates.ts @@ -55,6 +55,45 @@ export async function createAgentRevisionRequest(data: AppData, args: Record, runtime: AgentRuntimeData) { + checkRevisionFields(args, [...revisionWriteFields, ...revisionHashFields, 'requestId', 'expectedRequestHash', 'body', 'changedSummary']) + const projectId = findProjectId(data, args) + const identity = commandIdentity(args, 'create_manual_revision_version', projectId) + const prior = replayRevisionOperation(data, projectId, identity) + if (prior?.versionId) return { ...readRevisionVersion(data, projectId, prior.versionId), replayed: true } + const request = data.revisionRequests.find((item) => item.id === requiredString(args, 'requestId')) + if (!request) throw new Error('RevisionRequest not found.') + const session = revisionSession(data, projectId, request.sessionId) + if (session.status !== 'active') throw new Error('RevisionSession is no longer active.') + if (revisionFingerprint(request) !== requiredString(args, 'expectedRequestHash')) throw new Error('Revision request changed.') + const resolved = resolveRevisionSource(data, projectId, session.chapterId, session.sourceDraftId) + assertRevisionWritable(resolved) + checkSourceHashes(resolved.source, args) + checkKnownAgentSource(data, resolved.source, 'request', request.id, revisionFingerprint(request)) + if (request.sourceDraftContentHash && request.sourceDraftContentHash !== resolved.source.sourceContentHash) throw new Error('Revision request belongs to an older source.') + if (request.targetRange) throw new Error('Manual revision versions replace the full text; create a request without targetRange.') + const body = rawRevisionText(args, 'body') + if (body === resolved.body) throw new Error('Manual revision body matches the source.') + const timestamp = new Date().toISOString() + const version: RevisionVersion = { + id: `${identity.id}-version`, sessionId: session.id, requestId: request.id, + title: `${resolved.draft?.title ?? resolved.chapter?.title ?? 'Revision'} - ${request.type}`, + body, changedSummary: args.changedSummary === undefined ? '' : rawRevisionText(args, 'changedSummary'), risks: '', preservedFacts: '', + sourceContentHash: resolved.source.sourceContentHash, + ...(resolved.source.sourceChapterContentHash !== null ? { sourceChapterContentHash: resolved.source.sourceChapterContentHash } : {}), + responseScope: 'as_requested', status: 'draft', createdAt: timestamp, updatedAt: timestamp + } + if (data.revisionVersions.some((item) => item.id === version.id)) throw new Error('Revision version ID collision.') + const audit: RevisionAudit = { + schemaVersion: 1, actor: { kind: 'agent', agentRunId: identity.agentRunId }, operation: 'create_manual_revision_version', + commandHash: identity.hash, source: resolved.source, sessionId: session.id, requestId: request.id, + requestHash: revisionFingerprint(request), versionId: version.id, versionHash: revisionFingerprint(version) + } + const next = recordRevisionOperation({ ...data, revisionVersions: [...data.revisionVersions, version] }, identity.id, audit, requiredString(args, 'reason')) + const saved = await saveAgentRuntimeData(next, runtime) + return { ...readRevisionVersion(next, projectId, version.id), saved, replayed: false } +} + export async function editAgentRevisionVersion(data: AppData, args: Record, runtime: AgentRuntimeData) { checkRevisionFields(args, [...revisionWriteFields, ...revisionHashFields, 'versionId', 'expectedVersionHash', 'body']) const projectId = findProjectId(data, args) diff --git a/src/agent/revision/agentRevisionGeneration.ts b/src/agent/revision/agentRevisionGeneration.ts index 082418b..95da1e1 100644 --- a/src/agent/revision/agentRevisionGeneration.ts +++ b/src/agent/revision/agentRevisionGeneration.ts @@ -1,12 +1,13 @@ import type { AppData, RevisionVersion } from '../../shared/types' import { AIService } from '../../services/AIService' -import { resolvePipelineRoleSettings } from '../../services/PipelineRunContextService' +import { resolvePipelineRoleSettings, resolvePipelineRunSettings } from '../../services/PipelineRunContextService' import { normalizePipelineAIModelOverride } from '../../shared/normalizers/pipelineRunConfig' import { projectData } from '../../renderer/src/utils/projectData' import { buildRevisionAiContext } from '../../renderer/src/views/revision/revisionAiContext' import { findRewriteRange } from '../../renderer/src/components/aiRewriteResultModel' import { looksLikeFullChapterRevision } from '../../renderer/src/utils/revisionMerge' import { createHeadlessTransport } from '../AgentPipelineExecutor' +import { agentApiKey, apiEndpoint, readApiRegistry } from '../../main/services/ApiRegistry' import { loadAgentRuntimeData, saveAgentRuntimeData, type AgentRuntimeData } from '../AgentRuntime' import { findProjectId, requiredString } from '../tools/agentToolArguments' import { readRevisionVersion } from './agentRevisionReads' @@ -44,9 +45,18 @@ export async function generateAgentRevisionVersion( const context = buildRevisionAiContext({ ...scoped, project, selectedChapter: resolved.chapter ?? null, selectedDraft: resolved.draft ?? null }) const sourceRun = resolved.draft ? scoped.chapterGenerationJobs.find((job) => job.id === resolved.draft!.jobId) : undefined const roleSettings = resolvePipelineRoleSettings(sourceRun?.aiRunConfig, data.settings, 'revision') - const settings = { ...roleSettings, hasApiKey: roleSettings.hasApiKey || Boolean(process.env.NOVEL_DIRECTOR_API_KEY?.trim()) } + const base = resolvePipelineRunSettings(sourceRun?.aiRunConfig, data.settings) + const remote = roleSettings.apiProvider === 'compatible' || roleSettings.apiProvider === 'openai' + const registry = remote ? await readApiRegistry() : new Map() + const settings = { ...roleSettings, hasApiKey: remote && Boolean(agentApiKey(registry, roleSettings.baseUrl, base.baseUrl)) } + const route = registry.get(apiEndpoint(settings.baseUrl)) + if (!dependencies.ai && route && !settings.hasApiKey) { + throw new Error(`修订使用的服务 ${route.id} 缺少 ${route.keyEnv}。`) + } const aiRunId = sourceRun?.id ?? identity.agentRunId - const ai = dependencies.ai ?? new AIService(settings, { runId: aiRunId, transport: createHeadlessTransport(settings).transport }) + const ai = dependencies.ai ?? new AIService(settings, { + projectId, runId: aiRunId, transport: createHeadlessTransport(base, registry, runtime.storagePath).transport + }) const result = await ai.generateRevision({ type: request.type, instruction: request.instruction, fullChapterText: resolved.body, revisionScope: range ? 'local' : 'full', ...(range ? { targetRange: range.text } : {}) diff --git a/src/agent/revision/agentRevisionModel.ts b/src/agent/revision/agentRevisionModel.ts index 8e4374e..df666a0 100644 --- a/src/agent/revision/agentRevisionModel.ts +++ b/src/agent/revision/agentRevisionModel.ts @@ -131,7 +131,7 @@ export function checkVersionSource(version: RevisionVersion, source: ReturnType< } export function checkKnownAgentSource(data: AppData, source: RevisionSource, kind: 'request' | 'version', id: string, hash: string): void { - const operations = kind === 'request' ? ['create_revision_request'] : ['generate_revision_version', 'edit_revision_version'] + const operations = kind === 'request' ? ['create_revision_request'] : ['generate_revision_version', 'create_manual_revision_version', 'edit_revision_version'] for (const run of data.agentRuns.filter((item) => item.projectId === source.projectId)) { for (const decision of run.decisions.filter((item) => operations.includes(item.step))) { const audit = revisionAudit(decision.evidence) diff --git a/src/agent/revision/agentRevisionTools.ts b/src/agent/revision/agentRevisionTools.ts index a053857..eebacfc 100644 --- a/src/agent/revision/agentRevisionTools.ts +++ b/src/agent/revision/agentRevisionTools.ts @@ -1,7 +1,7 @@ import type { AppData } from '../../shared/types' import type { AgentRuntimeData } from '../AgentRuntime' import type { AgentToolHandlerResult } from '../tools/agentToolTypes' -import { createAgentRevisionRequest, editAgentRevisionVersion } from './agentRevisionCandidates' +import { createAgentRevisionRequest, createManualAgentRevisionVersion, editAgentRevisionVersion } from './agentRevisionCandidates' import { generateAgentRevisionVersion } from './agentRevisionGeneration' import { applyApprovedAgentRevisionCommit, previewAgentRevisionCommit } from './agentRevisionCommits' @@ -9,6 +9,7 @@ export async function handleRevisionWrite(name: string, args: Record { const args = input.arguments ?? {} + if (isChapterTitleTool(input.name)) return runChapterTitleTool(input.name, args) const overview = await handleAgentOverviewReadTool(input.name, args) if (overview) return overview const runtime = await loadAgentRuntimeData({ diff --git a/src/agent/tools/agentBookContinuityRead.ts b/src/agent/tools/agentBookContinuityRead.ts new file mode 100644 index 0000000..a8177c6 --- /dev/null +++ b/src/agent/tools/agentBookContinuityRead.ts @@ -0,0 +1,90 @@ +import { BOOK_CONTINUITY_COLLECTIONS, createBookContinuityReader } from '../../services/BookContinuityService' +import type { BookAuditKind, BookAuditOptions, BookAuditStatus } from '../../services/bookContinuity/types' +import { createBookCollectionSelection } from '../../shared/bookCollectionSelection' +import { loadAgentBookProjection } from './agentBookProjection' +import type { AgentToolCallResult } from './agentToolTypes' + +const kinds = ['money', 'headcount', 'inventory', 'promise', 'whereabouts', 'season', 'countdown'] as const satisfies readonly BookAuditKind[] +const statuses = ['conflict', 'needs_evidence', 'open'] as const satisfies readonly BookAuditStatus[] +const maxPageSize = 50 +export const bookContinuityProperties = { + projectId: { type: 'string' }, project: { type: 'string' }, + from: { type: 'integer', minimum: 1, description: 'First chapter with a finding. Earlier comparison evidence is retained.' }, + to: { type: 'integer', minimum: 1, description: 'Last chapter visible to the audit, inclusive.' }, + kind: { type: 'string', enum: kinds }, status: { type: 'string', enum: statuses }, + offset: { type: 'integer', minimum: 0 }, limit: { type: 'integer', minimum: 1, maximum: maxPageSize, default: 10 } +} + +function parseOptions(args: Record): BookAuditOptions { + for (const key of Object.keys(args)) { + if (!Object.hasOwn(bookContinuityProperties, key) && key !== 'storagePath' && key !== 'userDataPath') { + throw new Error(`Unsupported book continuity argument: ${key}.`) + } + if (['projectId', 'project', 'storagePath', 'userDataPath'].includes(key) && args[key] !== undefined && + (typeof args[key] !== 'string' || !args[key].trim())) throw new Error(`Invalid ${key}.`) + } + const integer = (key: string, min: number, max = Number.MAX_SAFE_INTEGER) => { + const value = args[key] + if (value === undefined) return undefined + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min || value > max) throw new Error(`Invalid ${key}.`) + return value + } + const choice = (key: string, choices: readonly T[]) => { + if (args[key] === undefined) return undefined + const chosen = choices.find(value => value === args[key]) + if (!chosen) throw new Error(`Invalid ${key}.`) + return chosen + } + const from = integer('from', 1), to = integer('to', 1) + if (from !== undefined && to !== undefined && from > to) throw new Error('from must not exceed to.') + return { from, to, kind: choice('kind', kinds), status: choice('status', statuses), + offset: integer('offset', 0), limit: integer('limit', 1, maxPageSize) ?? 10 } +} + +type BookSnapshot = Awaited> + +function partialQuote(text: string): string { + const clause = text.replace(/[。!?.!?;;…”’」』]+$/u, '').trimEnd() + if (clause.length < text.length) return clause + // Do not split a number, unit or negation to shorten a one-line chapter. + const parts = text.split(/[,,;;\n]/u).map(s => s.trim()).filter(Boolean) + return parts.length > 1 ? parts.reduce((a, b) => a.length >= b.length ? a : b) : '' +} + +function openReader({ data: snapshot, projectId, storage }: BookSnapshot, to?: number) { + const data = storage.source === 'sqlite' ? snapshot : createBookCollectionSelection(projectId, BOOK_CONTINUITY_COLLECTIONS)(snapshot) + const reader = createBookContinuityReader(data, projectId, { to }) + const shortBodies = new Map() + for (const chapter of data.chapters) { + if (chapter.projectId !== projectId || chapter.archivedAt || (to !== undefined && chapter.order > to)) continue + // A verbatim clue can be an entire one-line legacy chapter. Do not return the whole chapter over MCP. + const body = chapter.body.trim() + if (body && body.length <= 320) shortBodies.set(chapter.id, body) + } + return { projectId, storage, to, read(options: BookAuditOptions) { + const report = reader.read(options) + return { ...report, findings: report.findings.map(f => ({ ...f, evidence: f.evidence.map(e => { + if (!e.excerpt || e.excerpt !== shortBodies.get(e.chapterId)) return e + return { ...e, excerpt: partialQuote(e.excerpt) } + }) })) } + } } +} + +// One immutable snapshot per server. A cheap project/revision read invalidates it before any page is served. +let lastReader: ReturnType | undefined + +export async function readAgentBookContinuity(args: Record): Promise { + const options = parseOptions(args) + const previous = lastReader + const header = previous && previous.to === options.to ? await loadAgentBookProjection(args, ['projects']) : undefined + const tool = 'agent.getBookContinuityReport' + if (previous && header && previous.projectId === header.projectId && + previous.storage.source === header.storage.source && previous.storage.storagePath === header.storage.storagePath && + header.storage.revision !== '0' && previous.storage.revision === header.storage.revision) { + return { tool, ok: true, storage: header.storage, data: previous.read(options) } + } + const book = header && header.storage.source !== 'sqlite' ? header : await loadAgentBookProjection(args, BOOK_CONTINUITY_COLLECTIONS) + const reader = openReader(book, options.to) + lastReader = reader + return { tool, ok: true, storage: book.storage, data: reader.read(options) } +} diff --git a/src/agent/tools/agentBookProgressRead.ts b/src/agent/tools/agentBookProgressRead.ts new file mode 100644 index 0000000..518ef2b --- /dev/null +++ b/src/agent/tools/agentBookProgressRead.ts @@ -0,0 +1,19 @@ +import { BOOK_PROGRESS_COLLECTIONS, buildBookProgress } from '../../services/BookProgressService' +import { loadAgentBookProjection } from './agentBookProjection' +import type { AgentToolCallResult } from './agentToolTypes' + +export async function readAgentBookProgress(args: Record): Promise { + const numericOption = (key: string) => { + const v = args[key] + if (v === undefined) return undefined + if (typeof v !== 'number' || !Number.isSafeInteger(v) || v < (key === 'offset' ? 0 : 1) || (key === 'limit' && v > 200)) { + throw new Error(`Invalid ${key}.`) + } + return v + } + if (args.issuesOnly !== undefined && typeof args.issuesOnly !== 'boolean') throw new Error('Invalid issuesOnly.') + const options = { fromChapter: numericOption('fromChapter'), toChapter: numericOption('toChapter'), + offset: numericOption('offset'), limit: numericOption('limit'), issuesOnly: args.issuesOnly } + const { data, projectId, storage } = await loadAgentBookProjection(args, BOOK_PROGRESS_COLLECTIONS) + return { tool: 'agent.getBookProgress', ok: true, storage, data: buildBookProgress(data, projectId, options) } +} diff --git a/src/agent/tools/agentBookProjection.ts b/src/agent/tools/agentBookProjection.ts new file mode 100644 index 0000000..7753d9b --- /dev/null +++ b/src/agent/tools/agentBookProjection.ts @@ -0,0 +1,31 @@ +import { existsSync } from 'node:fs' +import type { AppDataArrayKey } from '../../shared/appDataCollections' +import { normalizeAppData } from '../../shared/defaults' +import { loadSqliteCollectionsReadonly } from '../../storage/SqliteStorageService' +import { loadJsonSnapshotReadonly } from '../../storage/jsonReadonly' +import { resolveSqliteStoragePath } from '../../storage/StorageService' +import { loadAgentRuntimeData, resolveAgentRuntimeStoragePath } from '../AgentRuntime' +import { findProjectId, stringArg } from './agentToolArguments' + +export async function loadAgentBookProjection(args: Record, collections: readonly AppDataArrayKey[]) { + const selector = stringArg(args, 'projectId') ?? stringArg(args, 'project') + if (!selector) throw new Error('Missing projectId or project.') + const explicit = stringArg(args, 'storagePath') + const paths = await resolveAgentRuntimeStoragePath({ storagePath: explicit, userDataPath: stringArg(args, 'userDataPath') }) + if (explicit && /\.(?:json|db|sqlite)$/i.test(explicit) && !existsSync(paths.storagePath)) { + throw new Error('Project database not found. 请检查指定的书库路径。') + } + if (paths.storagePath.toLowerCase().endsWith('.json') && (explicit || existsSync(paths.storagePath))) { + const { data, revision } = await loadJsonSnapshotReadonly(paths.storagePath) + return { data, projectId: findProjectId(data, args), storage: { source: 'json' as const, ...paths, revision } } + } + const sqlitePath = resolveSqliteStoragePath(paths.storagePath) + if (existsSync(sqlitePath)) { + const snapshot = await loadSqliteCollectionsReadonly(sqlitePath, collections, selector) + const data = normalizeAppData(snapshot.data) + return { data, projectId: findProjectId(data, args), storage: { source: 'sqlite' as const, storagePath: sqlitePath, + userDataPath: paths.userDataPath, revision: snapshot.revision } } + } + const { data, ...storage } = await loadAgentRuntimeData(paths) + return { data, projectId: findProjectId(data, args), storage } +} diff --git a/src/agent/tools/agentChapterAcceptanceTools.ts b/src/agent/tools/agentChapterAcceptanceTools.ts index 1aa6793..ad15f65 100644 --- a/src/agent/tools/agentChapterAcceptanceTools.ts +++ b/src/agent/tools/agentChapterAcceptanceTools.ts @@ -74,7 +74,7 @@ export async function previewAgentChapterAcceptance(data: AppData, args: Record< if (decisionFingerprint(bindingFor(prior)) !== decisionFingerprint(binding)) throw new Error('同一操作编号不能预览不同正文或采纳决定。') return { ...output(prior, binding), replayed: true } } - const recommendation = AgentDecisionService.buildAcceptanceRecommendation(data, binding.jobId) + const recommendation = AgentDecisionService.buildAcceptanceRecommendation(data, binding.jobId, binding.draftId) const timestamp = new Date().toISOString() const preview: AgentActionPreview = { id, projectId: binding.projectId, agentRunId: binding.agentRunId, jobId: binding.jobId, chapterId: data.generatedChapterDrafts.find((item) => item.id === binding.draftId)?.chapterId ?? null, @@ -132,7 +132,7 @@ export async function applyAgentChapterAcceptance(data: AppData, args: Record { const bundle = buildAcceptedDraftCommitBundle({ appData: data, projectId, draftId: binding.draftId, targetChapterOrder: binding.chapterOrder, commitId, chapterId: `${preview.id}:chapter`, - acceptedAt: new Date().toISOString(), acceptanceMode: binding.review.mode, + acceptedAt: new Date().toISOString(), acceptanceMode: binding.review.mode, reviewedLengthOverflowNotice: binding.review.lengthOverflowNotice, commitNote: `Agent director: ${preview.reason}; grant ${grant.id}.` }) bundle.acceptedBy = 'agent' bundle.actor = { kind: 'agent', agentRunId: binding.agentRunId, actionPreviewId: preview.id, authorizationGrantId: grant.id } diff --git a/src/agent/tools/agentChapterTaskTools.ts b/src/agent/tools/agentChapterTaskTools.ts index c3a93e4..8721e27 100644 --- a/src/agent/tools/agentChapterTaskTools.ts +++ b/src/agent/tools/agentChapterTaskTools.ts @@ -15,6 +15,7 @@ import { getChapterTaskForJob, prepareChapterTaskEdit, TASK_SCOPE_LABELS } from import { stableDecisionJson } from '../../services/candidateDecisionPrimitives' import type { AgentRuntimeData } from '../AgentRuntime' import { saveAgentRuntimeData } from '../AgentRuntime' +import { assertEarlierRunTargetsWritten } from '../agentBatchWriteback' import { booleanArg, findProjectId, requiredPositiveInteger, requiredString } from './agentToolArguments' import type { AgentToolHandlerResult } from './agentToolTypes' @@ -148,6 +149,7 @@ function resolveCommand(data: AppData, args: Record): ChapterTa const agentRunId = requiredString(args, 'agentRunId') const agentRun = data.agentRuns.find((run) => run.id === agentRunId && run.projectId === source.projectId) if (!agentRun) throw new Error(`AgentRun does not belong to project: ${agentRunId}`) + assertEarlierRunTargetsWritten(data, agentRun, source.targetChapterOrder) const operationId = requiredString(args, 'operationId') return { ...source, @@ -358,10 +360,12 @@ function attachJobToAgentRun( result: 'applied', createdAt: timestamp } + const targetChapterOrders = [...new Set([...command.agentRun.targetChapterOrders, command.targetChapterOrder])].sort((a, b) => a - b) const run: AgentRun = { ...command.agentRun, status: command.agentRun.status === 'running' ? 'running' : 'paused', - targetChapterOrders: [...new Set([...command.agentRun.targetChapterOrders, command.targetChapterOrder])].sort((a, b) => a - b), + targetChapterOrders, + mode: targetChapterOrders.length > 1 && command.agentRun.mode === 'single_chapter' ? 'multi_chapter' : command.agentRun.mode, createdJobIds: [...new Set([...command.agentRun.createdJobIds, job.id])], decisions: [...command.agentRun.decisions.filter((item) => item.id !== decision.id), decision], summary: `Chapter ${command.targetChapterOrder} task edit is saved as job ${job.id}; pipeline execution has not started.`, diff --git a/src/agent/tools/agentChapterTitleTools.ts b/src/agent/tools/agentChapterTitleTools.ts new file mode 100644 index 0000000..1d4492f --- /dev/null +++ b/src/agent/tools/agentChapterTitleTools.ts @@ -0,0 +1,73 @@ +import { existsSync } from 'node:fs' +import { AgentAuthorizationService } from '../../main/services/AgentAuthorizationService' +import { chapterTitleRows, parseChapterTitleRename, previewChapterTitleRename } from '../../services/ChapterTitleRenameService' +import { JsonStorageService } from '../../storage/JsonStorageService' +import { loadChapterTitlesReadonly, SqliteStorageService } from '../../storage/SqliteStorageService' +import { resolveJsonStoragePath, resolveSqliteStoragePath, StorageRevisionConflictError } from '../../storage/StorageService' +import { resolveAgentRuntimeStoragePath } from '../AgentRuntime' +import { requiredString, stringArg } from './agentToolArguments' +import type { AgentToolCallResult, AgentToolDefinition, JsonSchema } from './agentToolTypes' + +const preview = 'agent.previewChapterTitleRename', apply = 'agent.applyChapterTitleRename', history = 'agent.getChapterTitleHistory' +export const isChapterTitleTool = (name: string) => name === preview || name === apply || name === history + +export function chapterTitleDefinitions(schema: (properties: JsonSchema, required: string[]) => JsonSchema): AgentToolDefinition[] { + const str = { type: 'string', minLength: 1 } + const action = { projectId: str, operationId: str, prefixMode: { enum: ['manual', 'keep', 'strip'] }, + edits: { type: 'array', minItems: 1, items: { type: 'object', additionalProperties: false, required: ['chapterId'], + properties: { chapterId: str, title: { ...str, description: 'Required in manual mode; otherwise omit to transform the existing title.' } } } } } + return [ + { name: preview, riskLevel: 'write_preview', annotations: { readOnlyHint: true }, inputSchema: schema(action, Object.keys(action)), + description: 'Read-only preview of one or more official chapter titles. manual uses the entered title, keep preserves the original chapter-number prefix, strip removes it. Returns every chapter order, old/new title, current grant and storage revision. Never calls AI.' }, + { name: apply, riskLevel: 'write_commit', inputSchema: schema({ ...action, expectedRevision: str, expectedPreviewHash: str }, + [...Object.keys(action), 'expectedRevision', 'expectedPreviewHash']), + description: 'Commit the exact preview (previewHash as expectedPreviewHash) under one current manage_chapters grant covering every chapter. Reuse the operationId and original preview on retry. Title changes and receipt are atomic.' }, + { name: history, riskLevel: 'read', annotations: { readOnlyHint: true }, + inputSchema: schema({ projectId: str, chapterId: str, offset: { type: 'integer', minimum: 0 }, limit: { type: 'integer', minimum: 1, maximum: 100 } }, ['projectId']), + description: 'Read official title rename receipts, newest first. Filter by chapterId to see just its old and new titles, without reading prose.' } + ] +} + +export async function runChapterTitleTool(name: string, args: Record): Promise { + const projectId = requiredString(args, 'projectId'), explicit = stringArg(args, 'storagePath') + const paths = await resolveAgentRuntimeStoragePath({ storagePath: explicit, userDataPath: stringArg(args, 'userDataPath') }) + const sqlite = resolveSqliteStoragePath(paths.storagePath) + // Never switch an explicitly named file to a sibling book or silently fall back at commit. + const json = paths.storagePath.toLowerCase().endsWith('.json') || (!explicit && !existsSync(sqlite)) + const storagePath = json ? resolveJsonStoragePath(paths.storagePath) : sqlite + const { state, revision } = await loadChapterTitlesReadonly(storagePath, projectId) + const storage = { source: json ? 'json' as const : 'sqlite' as const, storagePath, userDataPath: paths.userDataPath, revision } + const { storagePath: _path, userDataPath: _profile, ...input } = args + if (name === history) { + if (Object.keys(input).some(k => !['projectId', 'chapterId', 'limit', 'offset'].includes(k))) throw new Error('不支持的改名历史查询参数。') + const chapterId = args.chapterId === undefined ? null : requiredString(args, 'chapterId') + const limit = args.limit ?? 20, offset = args.offset ?? 0 + if (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < 1 || limit > 100 || + typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0) throw new Error('无效的改名历史分页参数。') + const matches = state.receipts.filter(r => !chapterId || r.rows.some(row => row.chapterId === chapterId && row.oldTitle !== row.newTitle)) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)) + return { tool: name, ok: true, storage, data: { projectId, total: matches.length, offset, receipts: matches.slice(offset, offset + limit) + .map(r => chapterId ? { ...r, rows: r.rows.filter(row => row.chapterId === chapterId) } : r) } } + } + const { expectedRevision, expectedPreviewHash, ...fields } = input + const command = parseChapterTitleRename(name === preview ? input : fields) + const previous = state.receipts.find(r => r.operationId === command.operationId) + const authority = new AgentAuthorizationService(paths.userDataPath) + const scope = () => ({ storagePath, projectId, actions: ['manage_chapters'] as const, + chapterOrders: (previous?.rows ?? chapterTitleRows(state, command)).map(row => row.chapterOrder) }) + if (name === preview) { + const grant = await authority.findGrant(scope()) + return { tool: name, ok: true, storage, data: previewChapterTitleRename(state, command, storagePath, revision, + { kind: 'agent', authorizationGrantId: grant?.id ?? null }) } + } + if (typeof expectedRevision !== 'string' || !expectedRevision || typeof expectedPreviewHash !== 'string' || !expectedPreviewHash) + throw new Error('请提供上次改名预览的 expectedRevision 和 expectedPreviewHash。') + if (!previous && revision !== expectedRevision) throw new StorageRevisionConflictError() + const saved = await authority.withAuthorization(scope(), async grant => { + const store = json ? new JsonStorageService(storagePath) : new SqliteStorageService(storagePath) + try { + return await store.renameChapterTitles({ command, expectedRevision, expectedPreviewHash }, { kind: 'agent', authorizationGrantId: grant.id }) + } finally { if ('close' in store) store.close() } + }) + return { tool: name, ok: true, storage: { ...storage, revision: saved.revision }, data: saved } +} diff --git a/src/agent/tools/agentOverviewReadHandlers.ts b/src/agent/tools/agentOverviewReadHandlers.ts index f1586c1..eedae55 100644 --- a/src/agent/tools/agentOverviewReadHandlers.ts +++ b/src/agent/tools/agentOverviewReadHandlers.ts @@ -1,6 +1,9 @@ import { loadAgentProjectOverview } from '../AgentProjectOverviewRuntime' import { getNextChapterTarget, getProjectDigest, listProjects } from '../agentProjectSummaries' import { findProjectId, stringArg } from './agentToolArguments' +import { readAgentBookProgress } from './agentBookProgressRead' +import { readAgentBookContinuity } from './agentBookContinuityRead' +import { estimateAgentProjectUsage, readAgentProjectUsage } from './agentProjectUsageRead' import type { AgentToolCallResult } from './agentToolTypes' const OVERVIEW_TOOLS = new Set([ @@ -11,6 +14,12 @@ const OVERVIEW_TOOLS = new Set([ export async function handleAgentOverviewReadTool( name: string, args: Record ): Promise { + switch (name) { + case 'agent.getProjectUsage': return readAgentProjectUsage(args) + case 'agent.estimateProjectUsage': return estimateAgentProjectUsage(args) + case 'agent.getBookProgress': return readAgentBookProgress(args) + case 'agent.getBookContinuityReport': return readAgentBookContinuity(args) + } if (!OVERVIEW_TOOLS.has(name)) return null const runtime = await loadAgentProjectOverview({ storagePath: stringArg(args, 'storagePath'), userDataPath: stringArg(args, 'userDataPath') diff --git a/src/agent/tools/agentProjectUsageRead.ts b/src/agent/tools/agentProjectUsageRead.ts new file mode 100644 index 0000000..3709107 --- /dev/null +++ b/src/agent/tools/agentProjectUsageRead.ts @@ -0,0 +1,36 @@ +import { loadAgentBookProjection } from './agentBookProjection' +import { loadSqliteUsageHistoryReadonly } from '../../storage/SqliteStorageService' +import { reportFromHistory } from '../../storage/readProjectUsage' +import { projectUsageHistory } from '../../services/projectUsageHistory' +import type { AgentToolCallResult } from './agentToolTypes' +import type { ApiProvider } from '../../shared/types/base' +import { modelRateOrigin } from '../../shared/projectUsagePricing' +import { estimateProjectUsage } from '../../services/ProjectUsagePricing' + +export async function readAgentProjectUsage(args: Record): Promise { + const { data, projectId, storage } = await loadAgentBookProjection(args, ['projects']) + if (!storage.storagePath) throw new Error('未找到项目书库。') + const h = storage.source === 'sqlite' ? loadSqliteUsageHistoryReadonly(storage.storagePath, projectId) : + { history: projectUsageHistory(data, projectId), revision:storage.revision } + return { tool:'agent.getProjectUsage', ok:true, storage:{ ...storage, revision:h.revision }, + data:await reportFromHistory(storage.storagePath, h.history) } +} + +export async function estimateAgentProjectUsage(args: Record): Promise { + const { provider, model, endpoint, inputTokens, maxOutputTokens, snapshotId } = args + if (typeof provider !== 'string' || !['openai','compatible','local','codex_cli'].includes(provider) || typeof model !== 'string' || !model.trim() || + (provider !== 'codex_cli' && !modelRateOrigin(endpoint))) throw new Error('请指定供应商、模型和有效的服务地址。') + if ((inputTokens !== undefined && (typeof inputTokens !== 'number' || !Number.isSafeInteger(inputTokens) || inputTokens < 0)) || + (maxOutputTokens !== undefined && (typeof maxOutputTokens !== 'number' || !Number.isSafeInteger(maxOutputTokens) || maxOutputTokens < 1)) || + (snapshotId !== undefined && (typeof snapshotId !== 'string' || !snapshotId.trim() || inputTokens !== undefined))) { + throw new Error('请给出有效的 token 数;输入计数和快照只能选一个。') + } + const { data, projectId, storage } = await loadAgentBookProjection(args, snapshotId ? ['projects','promptContextSnapshots'] : ['projects']) + const snap = snapshotId && data.promptContextSnapshots.find(s => s.id === snapshotId && s.projectId === projectId) + if (snapshotId && !snap) throw new Error('本项目中找不到指定的 Prompt 快照。') + const result = estimateProjectUsage({ provider:provider as ApiProvider, endpoint:typeof endpoint === 'string' ? endpoint : '', model, + messages:snap ? [{ content:snap.finalPrompt }] : null, countedInputTokens:inputTokens, maxTokens:maxOutputTokens ?? null, + rates:data.projects.find(p => p.id === projectId)?.modelRates }) + return { tool:'agent.estimateProjectUsage', ok:true, storage, data:{ projectId, ...result, + inputBasis:snap ? 'snapshot_only' : inputTokens !== undefined ? 'caller_count' : 'unknown' } } +} diff --git a/src/agent/tools/agentRevisionToolDefinitions.ts b/src/agent/tools/agentRevisionToolDefinitions.ts index 724afee..56970c7 100644 --- a/src/agent/tools/agentRevisionToolDefinitions.ts +++ b/src/agent/tools/agentRevisionToolDefinitions.ts @@ -35,6 +35,12 @@ export const AGENT_REVISION_TOOL_DEFINITIONS: AgentToolDefinition[] = [ description: 'Generate a pending revision through AIService using the persisted request, rebuilt project context and the same revision-role settings as UI (frozen source job config for drafts, current global role otherwise). Never saves fallback prose or commits a chapter.', inputSchema: schema({ ...write, ...hashes, requestId: string, expectedRequestHash: string }, [...writeRequired, ...hashRequired, 'requestId', 'expectedRequestHash']) }, + { + name: 'agent.createManualRevisionVersion', riskLevel: 'write_preview', + description: 'Save caller-written full text as a draft candidate for a full-text revision request, with the same source and request checks as generation. Makes no model call and does not commit a chapter.', + inputSchema: schema({ ...write, ...hashes, requestId: string, expectedRequestHash: string, body: string, changedSummary: string }, + [...writeRequired, ...hashRequired, 'requestId', 'expectedRequestHash', 'body']) + }, { name: 'agent.editRevisionVersion', riskLevel: 'write_preview', description: 'Explicitly fork-edit a pending/draft candidate as an Agent; preserves the previous text as superseded history. Requires current candidate and source hashes. Not formal acceptance.', diff --git a/src/agent/tools/agentToolDefinitions.ts b/src/agent/tools/agentToolDefinitions.ts index 313a2ef..21b0564 100644 --- a/src/agent/tools/agentToolDefinitions.ts +++ b/src/agent/tools/agentToolDefinitions.ts @@ -3,7 +3,10 @@ import { AGENT_REVISION_TOOL_DEFINITIONS } from './agentRevisionToolDefinitions' import { AGENT_CANDIDATE_UNDO_DEFINITIONS } from './agentCandidateUndoDefinitions' import { AGENT_CHAPTER_ACCEPTANCE_DEFINITIONS } from './agentChapterAcceptanceTools' import { AGENT_CHAPTER_TASK_TOOL_DEFINITIONS } from './agentChapterTaskDefinitions' +import { chapterTitleDefinitions } from './agentChapterTitleTools' import { AGENT_WORLD_TOOL_DEFINITIONS } from './agentWorldDefinitions' +import { timelineSeasons, timelineShifts } from '../../shared/normalizers/timeline' +import { bookContinuityProperties } from './agentBookContinuityRead' const storageProperties = { storagePath: { type: 'string', description: 'Optional SQLite or JSON storage path.' }, @@ -137,6 +140,9 @@ function candidateDecisionsSchema(apply: boolean): JsonSchema { type: 'object', additionalProperties: false, properties: { title: { type: 'string' }, storyTime: { type: 'string' }, + ...amendmentStringProperties(['timelineKey', 'location', 'timeAnchor', 'deadlineKey', 'sourceExcerpt']), + season: { type: 'string', enum: timelineSeasons }, timeShift: { type: 'string', enum: timelineShifts }, + elapsedDays: { type: 'integer', minimum: 0 }, remainingDays: { type: 'integer', minimum: 0 }, participantCharacterIds: { type: 'array', items: { type: 'string' } }, result: { type: 'string' }, downstreamImpact: { type: 'string' } }, required: [] @@ -193,9 +199,16 @@ const candidateReadProperties = { maxChars: { type: 'integer', minimum: 1 } } +const runDiagnosticsSchema = schema({ + jobId: { type: 'string' }, + detail: { type: 'string', enum: ['summary', 'compact', 'full'], default: 'compact' }, + maxChars: { type: 'integer', minimum: 1 } +}, ['jobId']) + export const AGENT_TOOL_DEFINITIONS: AgentToolDefinition[] = [ ...AGENT_CHAPTER_ACCEPTANCE_DEFINITIONS, ...AGENT_CHAPTER_TASK_TOOL_DEFINITIONS, + ...chapterTitleDefinitions(schema), ...AGENT_WORLD_TOOL_DEFINITIONS, { name: 'agent.getProjectAuthorization', riskLevel: 'read', @@ -252,10 +265,38 @@ export const AGENT_TOOL_DEFINITIONS: AgentToolDefinition[] = [ }, { name: 'agent.getNextChapterTarget', - description: 'Find the next chapter order for a project.', + description: 'Find the next assignable chapter order, not evidence that earlier chapters were written back. Use agent.getBookProgress for manuscript coverage and gaps.', riskLevel: 'read', inputSchema: schema({ projectId: { type: 'string' }, project: { type: 'string' } }) }, + { + name: 'agent.getProjectUsage', + description: 'Read known project-wide provider token counts, dispatches, gaps and user-priced estimates by phase and currency. Includes unaccepted work. No balance lookup or model call.', + riskLevel: 'read', + inputSchema: schema({ projectId: { type:'string' }, project: { type:'string' } }) + }, + { + name: 'agent.estimateProjectUsage', + description: 'Estimate one planned request locally against the project rate card. Use a counted input or a saved snapshot, not both. A snapshot excludes later prompt wrapping. Unknowns and subscription costs stay unknown.', + riskLevel: 'read', + inputSchema: schema({ projectId:{type:'string'}, project:{type:'string'}, provider:{type:'string',enum:['openai','compatible','local','codex_cli']}, + endpoint:{type:'string'}, model:{type:'string'}, inputTokens:{type:'integer',minimum:0}, snapshotId:{type:'string'}, + maxOutputTokens:{type:'integer',minimum:1} }, ['provider','model']) + }, + { + name: 'agent.getBookProgress', + description: 'Read the current manuscript word count, goal, missing chapter ranges and paged writeback evidence. Historical text counts; drafts, archives and previous versions do not. No prose or model call.', + riskLevel: 'read', + inputSchema: schema({ projectId: { type: 'string' }, project: { type: 'string' }, + fromChapter: { type: 'integer', minimum: 1 }, toChapter: { type: 'integer', minimum: 1 }, + issuesOnly: { type: 'boolean' }, offset: { type: 'integer', minimum: 0 }, limit: { type: 'integer', minimum: 1, maximum: 200 } }) + }, + { + name: 'agent.getBookContinuityReport', + description: 'Read project-scoped continuity findings in the current manuscript: conflicts, evidence to verify, and open threads. Returns counts and a page of chapter IDs, current versions, content hashes and short quotes. No writes, full chapters, or model calls.', + riskLevel: 'read', annotations: { readOnlyHint: true }, + inputSchema: { ...schema(bookContinuityProperties), anyOf: [{ required: ['projectId'] }, { required: ['project'] }] } + }, { name: 'agent.getArchivedChapters', description: 'List archived chapters and retained version counts for a project.', @@ -312,15 +353,15 @@ export const AGENT_TOOL_DEFINITIONS: AgentToolDefinition[] = [ }, { name: 'agent.getRunDiagnostics', - description: 'Read job steps, trace counters, and author summary.', + description: 'Read job steps, trace counters, and author summary. Incomplete prose is only a step fragment, not a draft. Use detail=full to read its body.', riskLevel: 'read', - inputSchema: schema({ jobId: { type: 'string' } }, ['jobId']) + inputSchema: runDiagnosticsSchema }, { name: 'agent.inspectPipelineJob', description: 'Alias of agent.getRunDiagnostics for CLI/MCP parity.', riskLevel: 'read', - inputSchema: schema({ jobId: { type: 'string' } }, ['jobId']) + inputSchema: runDiagnosticsSchema }, { name: 'agent.getFullRunTrace', @@ -342,9 +383,9 @@ export const AGENT_TOOL_DEFINITIONS: AgentToolDefinition[] = [ }, { name: 'agent.getAcceptanceRecommendation', - description: 'Build an acceptance/revise/reject recommendation for a completed job.', + description: 'Assess the current draft against its reports and active HardCanon rules. Use detail=full for every matched passage and count.', riskLevel: 'read', - inputSchema: schema({ jobId: { type: 'string' } }, ['jobId']) + inputSchema: schema({ jobId: { type: 'string' }, detail: { type: 'string', enum: ['summary', 'compact', 'full'] } }, ['jobId']) }, { name: 'agent.startAgentRun', diff --git a/src/agent/tools/agentToolReadHandlers.ts b/src/agent/tools/agentToolReadHandlers.ts index b497b52..ff93b39 100644 --- a/src/agent/tools/agentToolReadHandlers.ts +++ b/src/agent/tools/agentToolReadHandlers.ts @@ -95,7 +95,7 @@ export function handleAgentReadTool( return { handled: true, payload: AgentReadableSummaryService.getVersionDiff(data, findProjectId(data, args), requiredPositiveInteger(args, 'chapterOrder'), requiredString(args, 'fromVersionId'), requiredString(args, 'toVersionId'), readOptions(args)) } case 'agent.getRunDiagnostics': case 'agent.inspectPipelineJob': - return { handled: true, payload: AgentReadableSummaryService.getRunDiagnostics(data, requiredString(args, 'jobId')) } + return { handled: true, payload: AgentReadableSummaryService.getRunDiagnostics(data, requiredString(args, 'jobId'), readOptions(args)) } case 'agent.getFullRunTrace': return { handled: true, payload: AgentReadableSummaryService.getFullRunTrace(data, stringArg(args, 'traceId') ?? requiredString(args, 'jobId'), readOptions(args)) } case 'agent.getCandidateDetail': @@ -103,7 +103,7 @@ export function handleAgentReadTool( case 'agent.getPendingHumanReviewItems': return { handled: true, payload: AgentReadableSummaryService.getProjectDigest(data, findProjectId(data, args)).pendingReview } case 'agent.getAcceptanceRecommendation': - return { handled: true, payload: AgentDecisionService.buildAcceptanceRecommendation(data, requiredString(args, 'jobId')) } + return { handled: true, payload: AgentDecisionService.buildAcceptanceRecommendation(data, requiredString(args, 'jobId'), undefined, readOptions(args).detail) } case 'agent.getActionPreviews': return { handled: true, payload: data.agentActionPreviews.filter((preview) => preview.agentRunId === requiredString(args, 'agentRunId')) } case 'agent.getAgentRunSummary': diff --git a/src/agent/tools/agentToolTypes.ts b/src/agent/tools/agentToolTypes.ts index b384f11..5208690 100644 --- a/src/agent/tools/agentToolTypes.ts +++ b/src/agent/tools/agentToolTypes.ts @@ -5,6 +5,7 @@ export interface AgentToolDefinition { description: string inputSchema: JsonSchema riskLevel: 'read' | 'write_preview' | 'write_commit' + annotations?: { readOnlyHint: true } } export interface AgentToolCallInput { diff --git a/src/agent/tools/agentToolWriteHandlers.ts b/src/agent/tools/agentToolWriteHandlers.ts index c54822a..69b0cb1 100644 --- a/src/agent/tools/agentToolWriteHandlers.ts +++ b/src/agent/tools/agentToolWriteHandlers.ts @@ -91,7 +91,6 @@ export async function handleAgentWriteTool( }) await saveAgentRuntimeData(prepared.appData, runtime) const executed = await executeAgentChapterPipeline({ - appData: prepared.appData, runtime, agentRunId: prepared.agentRun.id, jobId: prepared.job.id, @@ -117,15 +116,7 @@ export async function handleAgentWriteTool( case 'agent.retryChapterPipeline': { const agentRunId = requiredString(args, 'agentRunId') const jobId = requiredString(args, 'jobId') - const run = data.agentRuns.find((item) => item.id === agentRunId) - const job = data.chapterGenerationJobs.find((item) => item.id === jobId) - if (!run) throw new Error(`AgentRun not found: ${agentRunId}`) - if (!job) throw new Error(`Pipeline job not found: ${jobId}`) - if (run.projectId !== job.projectId || !run.createdJobIds.includes(job.id)) { - throw new Error('AgentRun and pipeline job are not part of the same production run.') - } const executed = await executeAgentChapterPipeline({ - appData: data, runtime, agentRunId, jobId, diff --git a/src/agent/tools/agentWorldDefinitions.ts b/src/agent/tools/agentWorldDefinitions.ts index a556501..230ff01 100644 --- a/src/agent/tools/agentWorldDefinitions.ts +++ b/src/agent/tools/agentWorldDefinitions.ts @@ -15,11 +15,43 @@ const source = { properties: { kind: { type: 'string', enum: ['agent_director'] }, reason: string, agentRunId: string, requestedAt: string }, required: ['kind', 'reason'] } +const words = { type: 'array', items: string } +const checkScope = { + enforcement: { type: 'string', enum: ['block', 'review'] }, + fromChapter: { type: 'integer', minimum: 1 }, toChapter: { type: 'integer', minimum: 1 } +} +const limit = { type: 'integer', minimum: 0 } +const hardCanonCheck = { + description: 'Explicit author condition for hard_canon only. Omit on create for a writing hint; omit on update to preserve it. Send null on update to remove it. A literal with neither limit forbids occurrences in the chapter; a book-only limit sets no chapter cap.', + oneOf: [ + { + type: 'object', additionalProperties: false, + properties: { kind: { const: 'literal' }, mode: { enum: ['terms', 'latin'] }, terms: words, exceptions: words, + maxPerChapter: limit, maxPerBook: limit, ...checkScope }, + required: ['kind', 'mode', 'terms', 'enforcement'], + allOf: [{ if: { properties: { mode: { const: 'terms' } } }, then: { properties: { terms: { minItems: 1 } } } }] + }, + { + type: 'object', additionalProperties: false, + properties: { kind: { const: 'actor_action' }, actor: string, characterId: string, aliases: words, + actions: { ...words, minItems: 1 }, side: { enum: ['左', '右'] }, objects: words, state: string, + effect: { enum: ['forbid', 'allow'] }, ...checkScope }, + required: ['kind', 'actor', 'actions', 'effect', 'enforcement'] + }, + { type: 'null' } + ] +} const worldAction = { entity, action, operationId: string, id: string, patch: { type: 'object', - description: 'Use only the selected entity fields in x-worldEditableFieldsByEntity; the service rejects cross-entity fields.', + description: 'Use only the selected entity fields in x-worldEditableFieldsByEntity. For hard_canon use title, content and optional check. Exact create duplicates reuse the original unchanged; hardCanonDuplicates.possible is advisory.', + additionalProperties: true, + properties: { title: string, content: string, check: hardCanonCheck, + startChapterOrder: { type: 'integer', minimum: 1, description: 'story_direction: first covered chapter (inclusive).' }, + endChapterOrder: { type: 'integer', minimum: 1, description: 'story_direction: last covered chapter (inclusive); determines length together with startChapterOrder.' }, + horizonChapters: { type: 'integer', minimum: 1, maximum: WORLD_MANAGEMENT_FIELD_HELP.story_direction.range.maxChapters, + description: 'story_direction: optional legacy length. Prefer startChapterOrder and endChapterOrder.' } }, 'x-worldEditableFieldsByEntity': WORLD_MANAGEMENT_FIELD_HELP }, source, @@ -37,8 +69,9 @@ export const AGENT_WORLD_TOOL_DEFINITIONS: AgentToolDefinition[] = [ }, { name: 'agent.getWorldRecord', riskLevel: 'read', - description: 'Read one project-scoped role, state, foreshadowing, timeline event, HardCanon item, or story direction. Compact is the default; request detail=full only when needed.', - inputSchema: schema({ ...read, entity, id: string }, ['entity', 'id']) + description: 'Read one project-scoped world record. For a character, cardReview summarizes human review and confirmed facts. detail=full adds short evidence excerpts and chapter locators. Reads do not mark cards reviewed.', + inputSchema: schema({ ...read, entity, id: string, asOfChapterOrder: { type: 'integer', minimum: 1, + description: 'Character only: inclusive cutoff for cardReview, not a historical reconstruction of the editable profile.' } }, ['entity', 'id']) }, { name: 'agent.getWorldActionReceipt', riskLevel: 'read', diff --git a/src/agent/tools/agentWorldTools.ts b/src/agent/tools/agentWorldTools.ts index 6942a22..bde6649 100644 --- a/src/agent/tools/agentWorldTools.ts +++ b/src/agent/tools/agentWorldTools.ts @@ -1,5 +1,6 @@ import type { AppData, ID } from '../../shared/types' -import { WorldManagementService, type WorldManagedEntity, type WorldManagementCommand, type WorldManagementResult } from '../../services/WorldManagementService' +import { WORLD_MANAGEMENT_FIELD_HELP, WorldManagementService, type WorldManagedEntity, type WorldManagementCommand, type WorldManagementResult } from '../../services/WorldManagementService' +import { readAgentCharacterCardReview } from '../agentCharacterCardReview' import { AgentAuthorizationService } from '../../main/services/AgentAuthorizationService' import { redactSensitiveText } from '../../shared/errorUtils' import type { AgentRuntimeData } from '../AgentRuntime' @@ -34,7 +35,8 @@ function pagination(args: Record): { limit: number; offset: num const stableTextKeys = new Set([ 'id', 'projectId', 'operationId', 'agentRunId', 'authorizationGrantId', 'fingerprint', 'expectedFingerprint', 'commandFingerprint', 'previewFingerprint', 'entity', 'action', 'status', 'kind', 'sourceType', 'category', - 'priority', 'valueType', 'trackingLevel', 'promptPolicy', 'schemaVersion', 'createdAt', 'updatedAt', 'recordedAt' + 'priority', 'valueType', 'trackingLevel', 'promptPolicy', 'schemaVersion', 'createdAt', 'updatedAt', 'recordedAt', + 'mode', 'effect', 'side', 'enforcement' ]) function isStableText(key: string): boolean { @@ -75,11 +77,14 @@ function worldOutput( } } -function presentWorldActionResult(result: WorldManagementResult['result'], data: AppData): WorldManagementResult['result'] { +function presentWorldActionResult(result: WorldManagementResult['result'], data: AppData) { // Keep the persisted receipt and its fingerprints raw; only redact display snapshots. const before = result.before === null ? null : worldOutput(result.before, data, Number.MAX_SAFE_INTEGER, 'before').value const after = worldOutput(result.after, data, Number.MAX_SAFE_INTEGER, 'after').value - return { ...result, before, after } + const duplicates = result.hardCanonDuplicates + return { ...result, before, after, ...(duplicates && { + hardCanonDuplicates: worldOutput(duplicates, data, Number.MAX_SAFE_INTEGER, 'hardCanonDuplicates').value + }) } } function command(data: AppData, args: Record, requireFingerprint: boolean): WorldManagementCommand { @@ -119,13 +124,25 @@ export function getAgentWorldRecords(data: AppData, args: Record): unknown { - fields(args, [...common, 'entity', 'id', 'detail', 'maxChars']) + const kind = entity(args) + fields(args, [...common, 'entity', 'id', 'detail', 'maxChars', ...(kind === 'character' ? ['asOfChapterOrder'] : [])]) const options = readOptions(args) - const item = WorldManagementService.get(data, findProjectId(data, args), entity(args), requiredString(args, 'id')) - const output = worldOutput(item, data, options.maxChars ?? (options.detail === 'full' ? Number.MAX_SAFE_INTEGER : 320)) + const projectId = findProjectId(data, args), id = requiredString(args, 'id') + const item = WorldManagementService.get(data, projectId, kind, id) + const through = args.asOfChapterOrder + if (through !== undefined && (typeof through !== 'number' || !Number.isSafeInteger(through) || through < 1)) { + throw new Error('asOfChapterOrder must be a positive integer.') + } + const full = options.detail === 'full', maxChars = options.maxChars ?? (full ? Number.MAX_SAFE_INTEGER : 320) + const record = kind === 'character' ? Object.fromEntries(['id', 'projectId', 'createdAt', 'updatedAt', + ...WORLD_MANAGEMENT_FIELD_HELP.character.fields].map(key => [key, (item as Record)[key]])) : item + const output = worldOutput(record, data, maxChars) + const review = kind === 'character' ? worldOutput(readAgentCharacterCardReview(data, projectId, id, full, through, options.maxChars), + data, maxChars, 'cardReview') : null + const truncatedFields = [...output.truncatedFields, ...(review?.truncatedFields ?? [])] return { - projectId: findProjectId(data, args), entity: entity(args), record: output.value, - truncated: output.truncatedFields.length > 0, truncatedFields: output.truncatedFields, + projectId, entity: kind, record: output.value, ...(review ? { cardReview: review.value } : {}), + truncated: truncatedFields.length > 0, truncatedFields, ...(options.detail === 'full' ? {} : { fullRead: { tool: 'agent.getWorldRecord', detail: 'full', requiredArguments: ['projectId', 'entity', 'id'] } }) } } diff --git a/src/main/SecureCredentialService.ts b/src/main/SecureCredentialService.ts index 40f5b48..945897f 100644 --- a/src/main/SecureCredentialService.ts +++ b/src/main/SecureCredentialService.ts @@ -1,14 +1,22 @@ import { safeStorage } from 'electron' -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' +import { apiEndpoint, backupKeyAfterRejection, readApiRegistry, routeKey, sameApiEndpoint, type ApiRegistry } from './services/ApiRegistry' interface CredentialFile { apiKey?: string + baseUrl?: string updatedAt?: string } +function storedForEndpoint(file: CredentialFile, registry: ApiRegistry, baseUrl: string): boolean { + return Boolean(file.apiKey && (file.baseUrl ? sameApiEndpoint(registry, file.baseUrl, baseUrl) : !registry.size)) +} + export class SecureCredentialService { private readonly credentialPath: string + private pendingWrite = Promise.resolve() constructor(userDataPath: string) { this.credentialPath = join(userDataPath, 'secure-credentials.json') @@ -25,13 +33,43 @@ export class SecureCredentialService { } async hasApiKey(): Promise { - const file = await this.readCredentialFile() - return Boolean(file.apiKey) + return (await this.getState()).anyAvailable } - async getApiKey(): Promise { - const file = await this.readCredentialFile() - if (!file.apiKey) return '' + async isManagedEndpoint(baseUrl: string): Promise { + return (await readApiRegistry()).has(apiEndpoint(baseUrl)) + } + + async getState(baseUrl = '') { + const registry = await readApiRegistry() + const file = await this.readScopedCredential(registry) + const route = registry.get(apiEndpoint(baseUrl)) + return { + ok: true as const, + hasApiKey: Boolean(file.apiKey), + anyAvailable: Boolean(file.apiKey) || [...registry.values()].some((item) => Boolean(routeKey(item))), + keyEnv: route?.keyEnv, + envAvailable: Boolean(route && process.env[route.keyEnv]?.trim()), + backupKeyEnv: route?.backupKeyEnv, + backupAvailable: Boolean(route?.backupKeyEnv && process.env[route.backupKeyEnv]?.trim()), + storedForEndpoint: storedForEndpoint(file, registry, baseUrl) + } + } + + async getBackupApiKey(baseUrl: string, rejected: string): Promise { + return backupKeyAfterRejection(await readApiRegistry(), baseUrl, rejected) + } + + async getApiKey(baseUrl: string): Promise { + const registry = await readApiRegistry() + const route = registry.get(apiEndpoint(baseUrl)) + const managedKey = route && routeKey(route) + if (managedKey) return managedKey + const file = await this.readScopedCredential(registry) + if (!file.apiKey || !storedForEndpoint(file, registry, baseUrl)) { + if (route) throw new Error(`服务 ${route.id} 缺少 ${route.keyEnv}。请配置后重新启动工作台。`) + return '' + } this.assertEncryptionAvailable() try { return safeStorage.decryptString(Buffer.from(file.apiKey, 'base64')) @@ -40,34 +78,53 @@ export class SecureCredentialService { } } - async setApiKey(apiKey: string): Promise { + private updateFile(write: () => Promise): Promise { + const operation = this.pendingWrite.then(write) + this.pendingWrite = operation.catch(() => {}) + return operation + } + + private async readScopedCredential(registry: ApiRegistry): Promise { + const file = await this.readCredentialFile() + if (!file.apiKey || file.baseUrl) return file + const approved = [...registry].find(([, route]) => route.legacyDesktopKey) + if (!approved) return file + await this.updateFile(async () => { + const current = await this.readCredentialFile() + if (current.apiKey && !current.baseUrl) await this.writeCredentialFile({ ...current, baseUrl: approved[0] }) + }) + return this.readCredentialFile() + } + + private async writeCredentialFile(file: CredentialFile): Promise { + await mkdir(dirname(this.credentialPath), { recursive: true }) + const temp = this.credentialPath + '.' + randomUUID() + '.tmp' + try { + await writeFile(temp, JSON.stringify(file, null, 2), { encoding: 'utf8', mode: 0o600 }) + await rename(temp, this.credentialPath) + } finally { + await rm(temp, { force: true }) + } + } + + async setApiKey(apiKey: string, baseUrl = ''): Promise { const trimmed = apiKey.trim() if (!trimmed) throw new Error('API Key 不能为空。') this.assertEncryptionAvailable() const encrypted = safeStorage.encryptString(trimmed).toString('base64') - await mkdir(dirname(this.credentialPath), { recursive: true }) - await writeFile( - this.credentialPath, - JSON.stringify( - { - apiKey: encrypted, - updatedAt: new Date().toISOString() - } satisfies CredentialFile, - null, - 2 - ), - 'utf-8' - ) + await this.updateFile(() => this.writeCredentialFile({ + apiKey: encrypted, baseUrl: apiEndpoint(baseUrl) || undefined, updatedAt: new Date().toISOString() + })) } async deleteApiKey(): Promise { - await rm(this.credentialPath, { force: true }) + await this.updateFile(() => rm(this.credentialPath, { force: true })) } - async migrateLegacyApiKey(apiKey: string): Promise { + async migrateLegacyApiKey(apiKey: string, baseUrl = ''): Promise { const trimmed = apiKey.trim() if (!trimmed) return this.hasApiKey() - await this.setApiKey(trimmed) + await this.setApiKey(trimmed, baseUrl) return true } diff --git a/src/main/dataMerge/referenceRemapping.ts b/src/main/dataMerge/referenceRemapping.ts index 6150592..a39daeb 100644 --- a/src/main/dataMerge/referenceRemapping.ts +++ b/src/main/dataMerge/referenceRemapping.ts @@ -31,6 +31,7 @@ const REFERENCE_COLLECTION_BY_KEY: Partial> = { relatedChapterIds: 'chapters', newlyRequiredChapterIds: 'chapters', characterId: 'characters', + participantCharacterIds: 'characters', selectedCharacterIds: 'characters', relatedCharacterIds: 'characters', newlyRequiredCharacterIds: 'characters', @@ -83,6 +84,7 @@ const REFERENCE_COLLECTION_BY_KEY: Partial> = { revisionCommitId: 'revisionCommitBundles', linkedRevisionCommitId: 'revisionCommitBundles', factId: 'characterStateFacts', + reversesTransactionIds: 'characterStateTransactions', targetFactId: 'characterStateFacts', linkedFactId: 'characterStateFacts', includedCharacterStateFactIds: 'characterStateFacts', @@ -120,6 +122,7 @@ const EMBEDDED_ENTITY_COLLECTION_BY_KEY: Partial appliedCharacterStateTransactions: 'characterStateTransactions', appliedForeshadowingUpdates: 'foreshadowings', appliedTimelineEvents: 'timelineEvents', + event: 'timelineEvents', redundancyReports: 'redundancyReports' } @@ -252,6 +255,11 @@ function remapReference( owner?: Entity, parentKey = '' ): unknown { + if (owner && (owner.kind === 'chapter_commit' || owner.kind === 'revision_commit') && typeof owner.attemptedAt === 'string') { + if (fieldKey === 'versionId') return remapId(remaps, value, 'chapterVersions') + if (fieldKey === 'commitId') return remapId(remaps, value, + owner.kind === 'chapter_commit' ? 'chapterCommitBundles' : 'revisionCommitBundles') + } const collection = REFERENCE_COLLECTION_BY_KEY[fieldKey] ?? (owner ? discriminatedCollection(owner, parentKey, fieldKey) : undefined) return remapId(remaps, value, collection) diff --git a/src/main/index.ts b/src/main/index.ts index 6eb0413..63d2d17 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, shell, session } from 'electron' +import { app, BrowserWindow, net, shell, session } from 'electron' import { existsSync } from 'node:fs' import { join, resolve } from 'node:path' import { AppConfigService } from './AppConfigService' @@ -7,6 +7,8 @@ import { LogService } from './LogService' import { SecureCredentialService } from './SecureCredentialService' import { TokenBucketRateLimiter } from './RateLimiter' import { AIService } from './services/AIService' +import { AIHttpClient } from './services/AIHttpClient' +import { appendProjectUsageAttempts } from '../storage/projectUsageJournal' import type { StorageService } from '../storage/StorageService' import { createStorageService } from '../storage/SqliteStorageService' import { registerIpcHandlers } from './ipc/registerIpcHandlers' @@ -265,7 +267,10 @@ if (!hasSingleInstanceLock) { appConfig = new AppConfigService(app.getPath('userData')) credentialService = new SecureCredentialService(app.getPath('userData')) backupService = new BackupService(app.getPath('userData')) - const aiService = new AIService(credentialService, aiRateLimiters) + const aiService = new AIService(credentialService, aiRateLimiters, { + httpClient: new AIHttpClient({ fetch: (url, options) => net.fetch(url, options), transport: 'electron-net' }), + usageJournal: { getStoragePath: () => storage.getStoragePath(), append: appendProjectUsageAttempts } + }) aiService.setLogger({ info: (message) => LogService.info(message), warn: (message) => LogService.warn(message) diff --git a/src/main/ipc/aiChatValidation.ts b/src/main/ipc/aiChatValidation.ts index 85027aa..532e0fb 100644 --- a/src/main/ipc/aiChatValidation.ts +++ b/src/main/ipc/aiChatValidation.ts @@ -1,10 +1,23 @@ import type { ChatCompletionRequest } from '../../shared/ipc/ipcTypes' import type { ApiProvider } from '../../shared/types' +import { usagePhases, type ProjectUsageContext, type UsagePhase } from '../../shared/projectUsage' import { ValidationError, validateNumber, validateString, validateUrl } from '../../shared/validation' const apiProviders = new Set(['openai', 'compatible', 'local', 'codex_cli']) const chatMessageRoles = new Set(['system', 'user']) +function validateUsageContext(value: unknown): ProjectUsageContext | undefined { + if (value === undefined) return undefined + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ValidationError('Invalid AI usage context.') + const record = value as Record + if (Object.keys(record).some(key => key !== 'projectId' && key !== 'phase')) { + throw new ValidationError('Unsupported AI usage context field.') + } + const projectId = validateString(record.projectId, 'projectId', { minLength: 1, maxLength: 200, pattern: /^[^\x00-\x1f\x7f]+$/ }) + if (!usagePhases.includes(record.phase as UsagePhase)) throw new ValidationError('Unsupported AI usage phase.') + return { projectId, phase: record.phase as UsagePhase } +} + function validateApiProvider(value: unknown): ApiProvider { if (typeof value !== 'string' || !apiProviders.has(value as ApiProvider)) { throw new ValidationError('AI provider is not supported.') @@ -91,6 +104,7 @@ export function validateChatCompletionRequest(request: ChatCompletionRequest): C : 300_000 }, messages: validateChatMessages(record.messages), + usageContext: validateUsageContext(record.usageContext), runId: typeof record.runId === 'string' ? validateString(record.runId, 'AI runId', { minLength: 1, maxLength: 200 }) : undefined diff --git a/src/main/ipc/appDataCredentialSanitizer.ts b/src/main/ipc/appDataCredentialSanitizer.ts index 5dec7f5..b71f401 100644 --- a/src/main/ipc/appDataCredentialSanitizer.ts +++ b/src/main/ipc/appDataCredentialSanitizer.ts @@ -31,7 +31,7 @@ export async function secureAndSanitizeAppData( if (legacyApiKey && migrateLegacyApiKey) { try { - await context.credentialService.migrateLegacyApiKey(legacyApiKey) + await context.credentialService.migrateLegacyApiKey(legacyApiKey, normalized.settings.baseUrl) hasApiKey = true migratedLegacyApiKey = true } catch (error) { diff --git a/src/main/ipc/credentialIpcHandlers.ts b/src/main/ipc/credentialIpcHandlers.ts index 0c2e3d2..0d9a4e2 100644 --- a/src/main/ipc/credentialIpcHandlers.ts +++ b/src/main/ipc/credentialIpcHandlers.ts @@ -7,7 +7,7 @@ import type { CredentialSetApiKeyRequest, CredentialSetApiKeyResult } from '../../shared/ipc/ipcTypes' -import { validateApiKey } from '../../shared/validation' +import { validateApiKey, validateUrl } from '../../shared/validation' import type { SecureCredentialService } from '../SecureCredentialService' import { safeIpcHandler } from './safeIpcHandler' @@ -16,17 +16,16 @@ export function registerCredentialIpcHandlers(credentialService: SecureCredentia IPC_CHANNELS.CREDENTIALS_SET_API_KEY, safeIpcHandler(async (_event, request: CredentialSetApiKeyRequest | string): Promise => { const apiKey = validateApiKey(typeof request === 'string' ? request : request.apiKey) - await credentialService.setApiKey(apiKey) + const baseUrl = typeof request === 'object' && request.baseUrl ? validateUrl(request.baseUrl, 'AI Base URL') : '' + await credentialService.setApiKey(apiKey, baseUrl) return { ok: true, hasApiKey: true } }) ) ipcMain.handle( IPC_CHANNELS.CREDENTIALS_HAS_API_KEY, - safeIpcHandler(async (): Promise => ({ - ok: true, - hasApiKey: await credentialService.hasApiKey() - })) + safeIpcHandler(async (_event, baseUrl?: string): Promise => + credentialService.getState(typeof baseUrl === 'string' ? baseUrl : '')) ) ipcMain.handle( diff --git a/src/main/ipc/dataIpcHandlers.ts b/src/main/ipc/dataIpcHandlers.ts index 046b2c6..0e040ea 100644 --- a/src/main/ipc/dataIpcHandlers.ts +++ b/src/main/ipc/dataIpcHandlers.ts @@ -1,13 +1,21 @@ import { dialog, ipcMain } from 'electron' import { readFile, writeFile } from 'node:fs/promises' import { safeParseJson } from '../../services/AIJsonParser' +import { observeFormalChapterWrites } from '../../services/ChapterWritebackFailureService' +import { parseChapterTitleRename, previewChapterTitleRename } from '../../services/ChapterTitleRenameService' +import { loadChapterTitlesReadonly } from '../../storage/SqliteStorageService' +import { StorageRevisionConflictError } from '../../storage/StorageService' +import type { ChapterTitleCommit, ChapterTitleRename } from '../../shared/types/chapterTitle' import { collectionName } from '../../shared/appDataCollections' import { normalizeAppData, sanitizeAppDataForPersistence } from '../../shared/defaults' import { IPC_CHANNELS } from '../../shared/ipc/ipcChannels' +import { readProjectUsage } from '../../storage/readProjectUsage' import type { ExportDataResult, ExecuteCandidateDecisionRequest, CandidateDecisionWriteResult, + CharacterCardReviewRequest, + CharacterCardReviewWriteResult, ImportDataResult, ImportDataRequest, SaveChapterCommitBundleRequest, @@ -27,6 +35,38 @@ import { secureAndSanitizeAppData } from './appDataCredentialSanitizer' import { assertCurrentStorage, maybeCreateAutomaticBackup } from './storageDataOperations' export function registerDataIpcHandlers(context: StorageDataOperationContext): void { + ipcMain.handle(IPC_CHANNELS.DATA_PREVIEW_CHAPTER_TITLES, safeIpcHandler(async (_event, + request: { command: ChapterTitleRename; expectedRevision: string }) => { + const command = parseChapterTitleRename(request?.command), storage = context.getStorage() + const path = storage.getStoragePath(), snapshot = await loadChapterTitlesReadonly(path, command.projectId) + assertCurrentStorage(context, storage) + if (request.expectedRevision !== snapshot.revision) throw new StorageRevisionConflictError() + return previewChapterTitleRename(snapshot.state, command, path, snapshot.revision, { kind: 'user' }) + })) + ipcMain.handle(IPC_CHANNELS.DATA_RENAME_CHAPTER_TITLES, safeIpcHandler(async (_event, request: ChapterTitleCommit) => { + const command = parseChapterTitleRename(request?.command), storage = context.getStorage() + if (!request.expectedRevision || !request.expectedPreviewHash) throw new Error('请先预览正式章名。') + await maybeCreateAutomaticBackup(context, 'chapter title rename', storage) + assertCurrentStorage(context, storage) + return storage.renameChapterTitles({ command, expectedRevision: request.expectedRevision, + expectedPreviewHash: request.expectedPreviewHash }, { kind: 'user' }) + })) + ipcMain.handle(IPC_CHANNELS.DATA_GET_PROJECT_USAGE, safeIpcHandler(async (_event, projectId: unknown) => { + if (typeof projectId !== 'string' || !projectId.trim()) throw new Error('请选择要查看用量的项目。') + return readProjectUsage(context.getStorage(), projectId) + })) + ipcMain.handle( + IPC_CHANNELS.DATA_REVIEW_CHARACTER_CARD, + safeIpcHandler(async (_event, request: CharacterCardReviewRequest): Promise => { + if (!request?.command || typeof request.expectedRevision !== 'string' || !request.expectedRevision) { + throw new Error('请重新加载数据后再核对角色卡。') + } + const storage = context.getStorage() + await maybeCreateAutomaticBackup(context, 'character card review', storage) + assertCurrentStorage(context, storage) + return storage.reviewCharacterCard(request.command, request.expectedRevision) + }) + ) ipcMain.handle( IPC_CHANNELS.DATA_EXECUTE_CANDIDATE_DECISION, safeIpcHandler(async (_event, request: ExecuteCandidateDecisionRequest): Promise => { @@ -98,9 +138,11 @@ export function registerDataIpcHandlers(context: StorageDataOperationContext): v ipcMain.handle( IPC_CHANNELS.DATA_SAVE_CHAPTER_COMMIT_BUNDLE, safeIpcHandler(async (_event, request: SaveChapterCommitBundleRequest): Promise => { - const storage = context.getStorage() - await maybeCreateAutomaticBackup(context, 'chapter commit bundle save', storage) - assertCurrentStorage(context, storage) + const target = context.getStorage() + await maybeCreateAutomaticBackup(context, 'chapter commit bundle save', target) + const assertCurrent = () => assertCurrentStorage(context, target) + assertCurrent() + const storage = observeFormalChapterWrites(target, assertCurrent) LogService.info(`Saving ChapterCommitBundle: commitId=${request.bundle.commitId}`) return storage.saveChapterCommitBundle(request.bundle, request.expectedRevision) }) @@ -109,9 +151,11 @@ export function registerDataIpcHandlers(context: StorageDataOperationContext): v ipcMain.handle( IPC_CHANNELS.DATA_SAVE_REVISION_COMMIT_BUNDLE, safeIpcHandler(async (_event, request: SaveRevisionCommitBundleRequest): Promise => { - const storage = context.getStorage() - await maybeCreateAutomaticBackup(context, 'revision commit bundle save', storage) - assertCurrentStorage(context, storage) + const target = context.getStorage() + await maybeCreateAutomaticBackup(context, 'revision commit bundle save', target) + const assertCurrent = () => assertCurrentStorage(context, target) + assertCurrent() + const storage = observeFormalChapterWrites(target, assertCurrent) LogService.info(`Saving RevisionCommitBundle: revisionCommitId=${request.bundle.revisionCommitId}`) return storage.saveRevisionCommitBundle(request.bundle, request.expectedRevision) }) diff --git a/src/main/ipc/diagnosticsIpcHandlers.ts b/src/main/ipc/diagnosticsIpcHandlers.ts index 644ba08..86691be 100644 --- a/src/main/ipc/diagnosticsIpcHandlers.ts +++ b/src/main/ipc/diagnosticsIpcHandlers.ts @@ -9,6 +9,7 @@ import type { DiagnosticsEvaluateQualityGateResult } from '../../shared/ipc/ipcTypes' import { validateString } from '../../shared/validation' +import { normalizeHardCanonAudit } from '../../shared/normalizers/hardCanonAudit' import type { ChapterTask } from '../../shared/types' import type { DiagnosticsService } from '../services/DiagnosticsService' import { validateChatCompletionRequest } from './aiChatValidation' @@ -98,6 +99,8 @@ export function registerDiagnosticsIpcHandlers(diagnosticsService: DiagnosticsSe ipcMain.handle( IPC_CHANNELS.DIAGNOSTICS_EVALUATE_QUALITY_GATE, safeIpcHandler(async (_event, request: DiagnosticsEvaluateQualityGateRequest): Promise => { + const hardCanonAudit = normalizeHardCanonAudit(request.hardCanonAudit) + if (request.hardCanonAudit !== undefined && !hardCanonAudit) throw new Error('硬设定核查记录不完整,请重新核查当前正文。') return diagnosticsService.evaluateQualityGate({ ...request, runId: request.runId === undefined @@ -110,6 +113,7 @@ export function registerDiagnosticsIpcHandlers(diagnosticsService: DiagnosticsSe draftId: request.draftId === null ? null : validateString(request.draftId, 'draftId', { minLength: 1, maxLength: 200 }), context: validateString(request.context, 'context', { minLength: 0, maxLength: 1_000_000, trim: false }), targetChapterOrder: optionalPositiveInteger(request.targetChapterOrder, 'targetChapterOrder'), + hardCanonAudit, hasAuthoritativeChapterTask: optionalBoolean(request.hasAuthoritativeChapterTask, 'hasAuthoritativeChapterTask'), chapterTask: optionalChapterTask(request.chapterTask, 'chapterTask'), characterStateFacts: boundedArray(request.characterStateFacts, 'characterStateFacts', 2_000)?.filter((fact) => fact.projectId === request.projectId), diff --git a/src/main/services/AIHttpClient.ts b/src/main/services/AIHttpClient.ts index a326726..b09bda8 100644 --- a/src/main/services/AIHttpClient.ts +++ b/src/main/services/AIHttpClient.ts @@ -1,7 +1,13 @@ import { AiHttpError, AiRequestCancelledError, AiRequestTimeoutError } from '../utils/aiErrors' +import { AiTransportFailure } from '../utils/connectionDiagnosis' +import { readAiResponseMetadata } from './AIResponseParser' +import type { AiUsageReceipts } from './AiUsageReceipts' export interface AIHttpClientOptions { timeoutMs?: number + fetch?: (url: string, options: RequestInit) => Promise + transport?: 'node-fetch' | 'electron-net' | 'custom-fetch' + beforeRequest?: () => Promise } export interface AIHttpRequestOptions { @@ -9,16 +15,31 @@ export interface AIHttpRequestOptions { signal?: AbortSignal onResponseFormatFallback?: () => void onResponseStarted?: () => void + receipts?: AiUsageReceipts +} + +async function readErrorBody(response: Response, receipts?: AiUsageReceipts): Promise { + const text = await response.text() + if (receipts) { + try { receipts.observe(readAiResponseMetadata(JSON.parse(text))) } catch { /* Not a JSON error envelope. */ } + } + return text } export class AIHttpClient { constructor(private readonly options: AIHttpClientOptions = {}) {} + get transport(): NonNullable { + return this.options.transport ?? (this.options.fetch ? 'custom-fetch' : 'node-fetch') + } + async post(url: string, body: unknown, headers: Record, requestOptions: AIHttpRequestOptions = {}): Promise { if (requestOptions.signal?.aborted) throw new AiRequestCancelledError() const timeoutMs = Math.max(1_000, requestOptions.timeoutMs ?? this.options.timeoutMs ?? 300_000) const controller = new AbortController() let timedOut = false + let dispatched = false + let receivedHeaders = false const abortFromCaller = () => controller.abort() requestOptions.signal?.addEventListener('abort', abortFromCaller, { once: true }) const timeout = setTimeout(() => { @@ -27,25 +48,46 @@ export class AIHttpClient { }, timeoutMs) try { - const response = await fetch(url, { + const send = this.options.fetch ?? fetch + const target = new URL(url) + if (!['http:', 'https:'].includes(target.protocol) || target.username || target.password) { + throw new Error('AI Base URL 必须使用 HTTP 或 HTTPS,且不能包含登录凭据。') + } + // Validate headers and serialize locally before counting a dispatch. + const init: RequestInit = { method: 'POST', - headers, + headers: new Headers(headers), + credentials: 'omit', + redirect: 'error', body: JSON.stringify(body), signal: controller.signal - }) + } + if (this.options.beforeRequest) await this.options.beforeRequest() + if (requestOptions.signal?.aborted) throw new AiRequestCancelledError() + if (timedOut) throw new AiRequestTimeoutError(timeoutMs) + requestOptions.receipts?.start(target.origin) + dispatched = true + const response = await send(url, init) + receivedHeaders = true + requestOptions.receipts?.observe({ providerRequestId: response.headers.get('x-request-id') ?? + response.headers.get('request-id') ?? response.headers.get('x-ms-request-id') }) requestOptions.onResponseStarted?.() // Buffer the response while the timeout and caller signal are still // active. Returning the original streaming body would clear our timer // after headers arrive and could leave response.json() hanging forever. const responseBody = await response.arrayBuffer() - return new Response(responseBody, { + return new Response([204, 205, 304].includes(response.status) ? null : responseBody, { status: response.status, statusText: response.statusText, headers: response.headers }) } catch (error) { - if (requestOptions.signal?.aborted) throw new AiRequestCancelledError() - if (timedOut) throw new AiRequestTimeoutError(timeoutMs) + if (requestOptions.signal?.aborted) { + if (dispatched) requestOptions.receipts?.observe({}, 'cancelled') + throw new AiRequestCancelledError() + } + if (timedOut) throw new AiRequestTimeoutError(timeoutMs, receivedHeaders ? 'reading' : 'total') + if (dispatched) throw new AiTransportFailure(error, receivedHeaders ? 'reading' : 'waiting_headers') throw error } finally { clearTimeout(timeout) @@ -65,12 +107,12 @@ export class AIHttpClient { const response = await this.post(url, body, headers, { ...requestOptions, timeoutMs: remaining() }) if (response.ok) return response - const errorText = await response.text() - if (fallbackBody && /response_format|json_object|unsupported/i.test(errorText)) { + const errorText = await readErrorBody(response, requestOptions.receipts) + if ([400, 422].includes(response.status) && fallbackBody && /response_format|json_object|unsupported/i.test(errorText)) { requestOptions.onResponseFormatFallback?.() const fallbackResponse = await this.post(url, fallbackBody, headers, { ...requestOptions, timeoutMs: remaining() }) if (fallbackResponse.ok) return fallbackResponse - throw new AiHttpError(fallbackResponse.status, await fallbackResponse.text()) + throw new AiHttpError(fallbackResponse.status, await readErrorBody(fallbackResponse, requestOptions.receipts)) } throw new AiHttpError(response.status, errorText) diff --git a/src/main/services/AIResponseParser.ts b/src/main/services/AIResponseParser.ts index e624d08..801310a 100644 --- a/src/main/services/AIResponseParser.ts +++ b/src/main/services/AIResponseParser.ts @@ -1,4 +1,7 @@ import type { AiTokenUsage, ChatCompletionResult } from '../../shared/ipc/ipcTypes' +import { readTokenUsage } from '../../shared/tokenUsage' +import { isTruncatedFinishReason } from '../../services/ai/responseNormalizers/chapter' +import type { AiUsageReceipts, ReceiptMetadata } from './AiUsageReceipts' type JsonRecord = Record @@ -20,14 +23,15 @@ function asRecord(value: unknown): JsonRecord { return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : {} } -function textFromPart(value: unknown, depth = 0): string { +function textFromPart(value: unknown, depth = 0, omitPart?: (part: JsonRecord) => boolean): string { if (depth > 3 || value === null || value === undefined) return '' if (typeof value === 'string') return value - if (Array.isArray(value)) return value.map((item) => textFromPart(item, depth + 1)).join('') + if (Array.isArray(value)) return value.map((item) => textFromPart(item, depth + 1, omitPart)).join('') const record = asRecord(value) + if (omitPart?.(record)) return '' for (const key of ['text', 'output_text', 'value', 'content']) { - const text = textFromPart(record[key], depth + 1) + const text = textFromPart(record[key], depth + 1, omitPart) if (text) return text } return '' @@ -39,36 +43,45 @@ function valueType(value: unknown): string { return typeof value } -function finiteTokenCount(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined +function publicFinishReason(value: unknown): string { + const reason = textFromPart(value).trim() + if (/content[_-]?filter/i.test(reason)) return 'content_filter' + if (isTruncatedFinishReason(reason) || /^(?:stop|end_turn|stop_sequence|eos|tool_calls|tool_use|function_call|refusal|error|cancelled)$/i.test(reason)) return reason + return reason ? 'unknown' : '' } function normalizedUsage(value: unknown): AiTokenUsage | undefined { const usage = asRecord(value) - const completionDetails = asRecord(usage.completion_tokens_details ?? usage.completionTokensDetails) - const promptDetails = asRecord(usage.prompt_tokens_details ?? usage.promptTokensDetails) - const normalized: AiTokenUsage = { - promptTokens: finiteTokenCount(usage.prompt_tokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.inputTokens), - completionTokens: finiteTokenCount( - usage.completion_tokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.outputTokens - ), - totalTokens: finiteTokenCount(usage.total_tokens ?? usage.totalTokens), - reasoningTokens: finiteTokenCount(completionDetails.reasoning_tokens ?? completionDetails.reasoningTokens), - cachedPromptTokens: finiteTokenCount(promptDetails.cached_tokens ?? promptDetails.cachedTokens) + const completionDetails = asRecord(usage.completion_tokens_details ?? usage.completionTokensDetails ?? usage.output_tokens_details) + const promptDetails = asRecord(usage.prompt_tokens_details ?? usage.promptTokensDetails ?? usage.input_tokens_details) + return readTokenUsage({ + promptTokens: usage.prompt_tokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.inputTokens, + completionTokens: usage.completion_tokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.outputTokens, + totalTokens: usage.total_tokens ?? usage.totalTokens, + reasoningTokens: completionDetails.reasoning_tokens ?? completionDetails.reasoningTokens ?? usage.reasoning_output_tokens ?? usage.reasoningTokens, + cachedPromptTokens: promptDetails.cached_tokens ?? promptDetails.cachedTokens ?? usage.cached_input_tokens ?? usage.prompt_cache_hit_tokens ?? usage.cachedPromptTokens + }) +} + +export function readAiResponseMetadata(payload: unknown): ReceiptMetadata { + const root = asRecord(payload) + const error = asRecord(root.error) + return { + usage: normalizedUsage(root.usage) ?? normalizedUsage(error.usage), + providerRequestId: root.request_id ?? root.requestId ?? error.request_id ?? error.requestId } - return Object.values(normalized).some((item) => item !== undefined) ? normalized : undefined } function emptyResponseError(input: { finishReason: string reasoningCharacters: number - refusal: string + refused: boolean }): string { const suffix = input.finishReason ? `(finish_reason=${input.finishReason})` : '' - if (input.refusal) { + if (input.refused) { return `AI 拒绝返回正文${suffix}。请检查模型内容策略,或调整本章任务后重试。` } - if (/length|max[_-]?tokens|token_limit/i.test(input.finishReason)) { + if (isTruncatedFinishReason(input.finishReason)) { return `AI 推理或输出已耗尽 Max Tokens,但正文为空${suffix}。请提高 Max Tokens、降低目标字数,或切换非推理型模型后重试。` } if (input.reasoningCharacters > 0) { @@ -77,21 +90,38 @@ function emptyResponseError(input: { return `AI 返回为空${suffix}。系统会尝试关闭 JSON response_format;若仍失败,请检查模型兼容性。` } -export function parseAiResponsePayload(payload: unknown): ParsedAiResponse { +export function parseAiResponsePayload(payload: unknown, metadata = readAiResponseMetadata(payload)): ParsedAiResponse { const root = asRecord(payload) const choices = Array.isArray(root.choices) ? root.choices : [] const choice = asRecord(choices[0]) const message = asRecord(choice.message) const rawContent = message.content ?? choice.text - const content = textFromPart(rawContent) - const finishReason = textFromPart(choice.finish_reason ?? choice.finishReason).trim() - const reasoningContent = textFromPart(message.reasoning_content ?? message.reasoningContent) - const refusal = textFromPart(message.refusal) - const usage = normalizedUsage(root.usage) - const completionTokens = usage?.completionTokens ?? null - const trimmedContent = content.trim() + const finishReason = publicFinishReason(choice.finish_reason ?? choice.finishReason) + const usage = metadata.usage + const diagnostics = { + finishReason, + reasoningCharacters: textFromPart(message.reasoning_content ?? message.reasoningContent).length, + refusalCharacters: textFromPart(message.refusal).length, + completionTokens: usage?.completionTokens ?? null, + contentType: valueType(rawContent), + usage + } + let refused = diagnostics.refusalCharacters > 0 || /content[_-]?filter|^refusal$/i.test(finishReason) + // Keep provider-marked private parts out of public text before multipart boundaries are lost. + const content = textFromPart(rawContent, 0, (part) => { + if (part.type === 'refusal') { + refused = true + diagnostics.refusalCharacters += textFromPart(part.refusal ?? part).length + return true + } + if (typeof part.type === 'string' && /^(?:analysis|reasoning(?:_content|_text)?|(?:redacted_)?thinking)$/i.test(part.type)) { + diagnostics.reasoningCharacters += textFromPart(part.thinking ?? part.reasoning_content ?? part).length + return true + } + return false + }) - if (trimmedContent) { + if (!refused && content.trim()) { return { result: { ok: true, @@ -100,45 +130,37 @@ export function parseAiResponsePayload(payload: unknown): ParsedAiResponse { }, emptyContent: false, canRetryWithoutResponseFormat: false, - diagnostics: { - finishReason, - reasoningCharacters: reasoningContent.length, - refusalCharacters: refusal.length, - completionTokens, - contentType: valueType(rawContent), - usage - } + diagnostics } } - const truncated = /length|max[_-]?tokens|token_limit/i.test(finishReason) + const truncated = isTruncatedFinishReason(finishReason) return { result: { ok: false, - error: emptyResponseError({ - finishReason, - reasoningCharacters: reasoningContent.length, - refusal - }), + error: emptyResponseError({ finishReason, reasoningCharacters: diagnostics.reasoningCharacters, refused }), finishReason: finishReason || undefined }, emptyContent: true, // Removing response_format helps truly silent JSON-mode responses. It does // not repair reasoning-only output and can consume the whole remaining // timeout while the model produces another long chain of thought. - canRetryWithoutResponseFormat: - reasoningContent.length === 0 && !truncated && !refusal && !/content[_-]?filter/i.test(finishReason), - diagnostics: { - finishReason, - reasoningCharacters: reasoningContent.length, - refusalCharacters: refusal.length, - completionTokens, - contentType: valueType(rawContent), - usage - } + canRetryWithoutResponseFormat: diagnostics.reasoningCharacters === 0 && !truncated && !refused, + diagnostics } } -export async function parseAiResponse(response: Response): Promise { - return parseAiResponsePayload(await response.json()) +export async function parseAiResponse(response: Response, receipts?: AiUsageReceipts): Promise { + let payload: unknown + try { + payload = await response.json() + } catch (error) { + // Native JSON syntax errors can quote the private response body. + if (error instanceof SyntaxError) throw new Error('AI 服务返回的响应不是有效 JSON。') + throw error + } + const metadata = readAiResponseMetadata(payload) + const parsed = parseAiResponsePayload(payload, metadata) + receipts?.observe(metadata, parsed.result.ok ? 'success' : 'failed') + return parsed } diff --git a/src/main/services/AIService.ts b/src/main/services/AIService.ts index f7c164a..b39b7e6 100644 --- a/src/main/services/AIService.ts +++ b/src/main/services/AIService.ts @@ -7,13 +7,17 @@ import type { CodexCliStatusResult } from '../../shared/ipc/ipcTypes' import type { AiCallProgress, AiCallProgressStage, ApiProvider, AppSettings } from '../../shared/types' +import type { AIJsonTransport } from '../../services/ai/AITransport' import { describeNetworkError, redactSensitiveText } from '../../shared/errorUtils' import type { TokenBucketRateLimiter } from '../RateLimiter' import { retryWithBackoff } from '../utils/retry' -import { AiRequestCancelledError, AiRequestTimeoutError, describeAiRetryError, isRetryableAiError } from '../utils/aiErrors' +import { diagnoseAiConnection } from '../utils/connectionDiagnosis' +import { AiHttpError, AiRequestCancelledError, AiRequestTimeoutError, describeAiRetryError, isRetryableAiError } from '../utils/aiErrors' import { AIHttpClient } from './AIHttpClient' +import { apiEndpoint } from './ApiRegistry' import { AiCallProgressStore } from './AiCallProgressStore' -import { parseAiResponse } from './AIResponseParser' +import { AiUsageReceipts, type UsageJournal } from './AiUsageReceipts' +import { parseAiResponse, readAiResponseMetadata } from './AIResponseParser' import { CodexCliService } from './CodexCliService' export interface ValidationResult { @@ -22,6 +26,8 @@ export interface ValidationResult { } export interface IAIService { + bindToCurrentStorage?(): AIJsonTransport + isManagedEndpoint?(baseUrl: string): Promise chatCompletion(request: ChatCompletionRequest): Promise cancelRun(runId: string): boolean cancelCall(runId: string, callId: string): boolean @@ -33,7 +39,9 @@ export interface IAIService { } export interface AiCredentialReader { - getApiKey(): Promise + isManagedEndpoint?(baseUrl: string): Promise + getApiKey(baseUrl: string): Promise + getBackupApiKey?(baseUrl: string, rejected: string): Promise } export interface AIServiceLogger { @@ -42,6 +50,7 @@ export interface AIServiceLogger { } export interface AIServiceOptions { + usageJournal?: UsageJournal httpClient?: AIHttpClient codexCliService?: CodexCliService logger?: AIServiceLogger @@ -53,19 +62,12 @@ const consoleLogger: AIServiceLogger = { warn: (message) => console.warn(message) } -interface ChatRequestBody { +type ChatRequestBody = { model: string messages: ChatCompletionRequest['messages'] - temperature: number - max_tokens: number response_format?: { type: 'json_object' } thinking?: { type: 'disabled' } -} - -interface ChatCompletionAttemptResult { - result: ChatCompletionResult - usage?: AiTokenUsage -} +} & ({ max_completion_tokens: number } | { temperature: number; max_tokens: number }) function usesOfficialDeepSeekV4(settings: AppSettings): boolean { if (!/^deepseek-(?:flash|v4-(?:flash|flash-vision-exp|pro))$/i.test(settings.modelName.trim())) return false @@ -76,36 +78,27 @@ function usesOfficialDeepSeekV4(settings: AppSettings): boolean { } } +function chatGenerationParameters(settings: AppSettings) { + const modernOpenAIModel = /^(?:gpt-[56]|o[134])(?:[.-]|$)/i.test(settings.modelName.trim()) + if (modernOpenAIModel && apiEndpoint(settings.baseUrl) === 'https://api.openai.com/v1') { + return { max_completion_tokens: settings.maxTokens } + } + return { temperature: settings.temperature, max_tokens: settings.maxTokens } +} + function sanitizeAiErrorText( text: string, apiKey?: string, - messages: ChatCompletionRequest['messages'] = [] + messages: ChatCompletionRequest['messages'] = [], + rejectedKey?: string ): string { - return redactSensitiveText(text, [apiKey, ...messages.map((message) => message.content)]).slice(0, 800) + return redactSensitiveText(text, [apiKey, rejectedKey, ...messages.map((message) => message.content)]).slice(0, 800) } function providerUsesApiKey(provider: ApiProvider): boolean { return provider === 'openai' || provider === 'compatible' } -function mergeTokenUsage(...values: Array): AiTokenUsage | undefined { - const keys: Array = [ - 'promptTokens', - 'completionTokens', - 'totalTokens', - 'reasoningTokens', - 'cachedPromptTokens' - ] - const merged: AiTokenUsage = {} - for (const key of keys) { - const counts = values - .map((usage) => usage?.[key]) - .filter((count): count is number => typeof count === 'number' && Number.isFinite(count)) - if (counts.length > 0) merged[key] = counts.reduce((sum, count) => sum + count, 0) - } - return Object.keys(merged).length > 0 ? merged : undefined -} - function telemetryResult( result: ChatCompletionResult, input: { @@ -144,6 +137,7 @@ function telemetryResult( export class AIService implements IAIService { private readonly activeRunControllers = new Map>() private readonly httpClient: AIHttpClient + private readonly usageJournal?: UsageJournal private readonly callProgress: AiCallProgressStore private logger: AIServiceLogger private codexCliService: CodexCliService @@ -154,6 +148,7 @@ export class AIService implements IAIService { options: AIServiceOptions = {} ) { this.httpClient = options.httpClient ?? new AIHttpClient() + this.usageJournal = options.usageJournal this.callProgress = options.progressStore ?? new AiCallProgressStore() this.logger = options.logger ?? consoleLogger this.codexCliService = options.codexCliService ?? new CodexCliService(this.logger) @@ -240,13 +235,29 @@ export class AIService implements IAIService { return this.codexCliService.getStatus(command) } - async chatCompletion(request: ChatCompletionRequest): Promise { - const { settings, messages } = request + async isManagedEndpoint(baseUrl: string): Promise { + return (await this.credentialService.isManagedEndpoint?.(baseUrl)) ?? false + } + + bindToCurrentStorage(): AIJsonTransport { + const storagePath = this.usageJournal?.getStoragePath() ?? null + return { + chatCompletion: request => this.chatCompletion(request, storagePath), + isManagedEndpoint: baseUrl => this.isManagedEndpoint(baseUrl) + } + } + + async chatCompletion(request: ChatCompletionRequest, storagePath = this.usageJournal?.getStoragePath() ?? null): Promise { + const settings = { ...request.settings } + const messages = request.messages.map(({ role, content }) => ({ role, content })) + const usageContext = request.usageContext && { projectId: request.usageContext.projectId, phase: request.usageContext.phase } const startedAt = Date.now() const runId = request.runId?.trim() || '' const clientCallId = request.clientCallId?.trim() || '' const callId = clientCallId || randomUUID() let apiKey = '' + let rejectedKey: string | undefined + const receipts = new AiUsageReceipts(id => sanitizeAiErrorText(id, apiKey, messages, rejectedKey) === id) let attempts = 0 let responseFormatFallback = false let progressStarted = false @@ -256,9 +267,10 @@ export class AIService implements IAIService { const requestedModel = settings.apiProvider === 'codex_cli' ? settings.codexCliModel?.trim() || 'cli-default' : settings.modelName - const withTelemetry = ( + const call = { provider: settings.apiProvider, model: requestedModel, callId, runId: runId || undefined } + const withTelemetry = async ( result: ChatCompletionResult, - options: { usage?: AiTokenUsage; terminationCategory?: AiCallTelemetry['terminationCategory'] } = {} + options: { terminationCategory?: AiCallTelemetry['terminationCategory'] } = {} ) => { const stage: AiCallProgressStage = options.terminationCategory === 'cancelled' ? 'cancelled' @@ -268,16 +280,24 @@ export class AIService implements IAIService { if (progressStarted) { this.callProgress.update(runId || undefined, callId, stage, { attempt: attempts }) } - return telemetryResult(result, { - provider: settings.apiProvider, - model: requestedModel, - callId, - runId: runId || undefined, + const completed = telemetryResult(result, { + ...call, startedAt, - attempts, + attempts: receipts.count, + usage: receipts.totalKnownUsage(), responseFormatFallback, ...options }) + if (usageContext && receipts.count) { + try { + if (!this.usageJournal || !storagePath) throw new Error('Missing usage storage') + await this.usageJournal.append(storagePath, receipts.records(usageContext, call)) + } catch { + completed.telemetry!.usageJournalIncomplete = true + this.logger.warn('项目用量收据未确认保存。用量记录可能不完整;不会为此重发 AI 请求。') + } + } + return completed } try { @@ -302,7 +322,7 @@ export class AIService implements IAIService { const settingsValidation = this.validateSettings(settings) if (!settingsValidation.ok) return withTelemetry({ ok: false as const, error: settingsValidation.error }) - apiKey = providerUsesApiKey(settings.apiProvider) ? await this.credentialService.getApiKey() : '' + apiKey = providerUsesApiKey(settings.apiProvider) ? await this.credentialService.getApiKey(settings.baseUrl) : '' if (runController.signal.aborted) throw new AiRequestCancelledError() const thinkingMode = usesOfficialDeepSeekV4(settings) ? 'disabled' : 'provider_default' this.logger.info( @@ -322,8 +342,11 @@ export class AIService implements IAIService { messages, maxTokens: settings.maxTokens, signal: runController.signal, + onDispatch: () => receipts.start(''), + onTurnFinished: metadata => receipts.observe(readAiResponseMetadata(metadata)), timeoutMs: Math.max(1_000, deadline - Date.now()) }) + receipts.observe({}, runController.signal.aborted ? 'cancelled' : result.ok ? 'success' : 'failed') return withTelemetry(result, { terminationCategory: runController.signal.aborted ? 'cancelled' : undefined }) @@ -336,31 +359,30 @@ export class AIService implements IAIService { if (runController.signal.aborted) throw new AiRequestCancelledError() const maxRetries = settings.retryEnabled === false ? 0 : settings.maxRetries + const send = async () => { + if (runController.signal.aborted) throw new AiRequestCancelledError() + if (Date.now() >= deadline) throw new AiRequestTimeoutError(timeoutMs) + attempts += 1 + this.callProgress.update(runId || undefined, callId, 'waiting_response', { attempt: attempts }) + return this.performChatCompletion(settings, messages, apiKey, runController.signal, deadline, timeoutMs, receipts, + () => { + responseFormatFallback = true + this.callProgress.update(runId || undefined, callId, 'format_retry', { attempt: attempts }) + }, + () => this.callProgress.update(runId || undefined, callId, 'reading_response', { attempt: attempts })) + } const attemptResult = await retryWithBackoff( async () => { - attempts += 1 - this.callProgress.update(runId || undefined, callId, 'waiting_response', { attempt: attempts }) - if (runController.signal.aborted) throw new AiRequestCancelledError() - const remainingMs = deadline - Date.now() - if (remainingMs <= 0) throw new AiRequestTimeoutError(timeoutMs) - return this.performChatCompletion( - settings, - messages, - apiKey, - runController.signal, - deadline, - timeoutMs, - () => { - responseFormatFallback = true - this.callProgress.update(runId || undefined, callId, 'format_retry', { attempt: attempts }) - }, - () => this.callProgress.update( - runId || undefined, - callId, - 'reading_response', - { attempt: attempts } - ) - ) + try { + return await send() + } catch (error) { + if (!(error instanceof AiHttpError) || error.status !== 401 || rejectedKey || runController.signal.aborted) throw error + const backup = await this.credentialService.getBackupApiKey?.(settings.baseUrl, apiKey) + if (!backup || backup === apiKey) throw error + rejectedKey = apiKey + apiKey = backup + return send() + } }, { maxRetries, @@ -371,27 +393,33 @@ export class AIService implements IAIService { retryDelayMs: delayMs }) this.logger.warn( - `AI request retry scheduled: provider=${settings.apiProvider}, model=${settings.modelName}, attempt=${attempt}, delayMs=${delayMs}, reason=${sanitizeAiErrorText(describeAiRetryError(error), apiKey, messages)}` + `AI request retry scheduled: provider=${settings.apiProvider}, model=${settings.modelName}, attempt=${attempt}, delayMs=${delayMs}, reason=${sanitizeAiErrorText(describeAiRetryError(error), apiKey, messages, rejectedKey)}` ) } } ) - return withTelemetry(attemptResult.result, { usage: attemptResult.usage }) + return withTelemetry(attemptResult) } catch (error) { // Nested compatibility requests receive the remaining deadline. Report // the configured run budget instead of a confusing value such as 259s. const normalizedError = error instanceof AiRequestTimeoutError - ? new AiRequestTimeoutError(timeoutMs) + ? new AiRequestTimeoutError(timeoutMs, error.phase) : error - const message = describeNetworkError(normalizedError) + const diagnosis = diagnoseAiConnection(normalizedError, { + transport: settings.apiProvider === 'codex_cli' ? 'codex-cli' : this.httpClient.transport ?? 'custom-fetch', + baseUrl: settings.baseUrl + }) + const message = diagnosis?.message ?? describeNetworkError(normalizedError) const terminationCategory: AiCallTelemetry['terminationCategory'] = - normalizedError instanceof AiRequestTimeoutError + normalizedError instanceof AiRequestTimeoutError || diagnosis?.kind === 'cli_timeout' || + diagnosis?.kind === 'connect_timeout' || diagnosis?.kind === 'read_timeout' ? 'timeout' - : normalizedError instanceof AiRequestCancelledError || runController.signal.aborted + : normalizedError instanceof AiRequestCancelledError || diagnosis?.kind === 'cancelled' || runController.signal.aborted ? 'cancelled' : 'none' + if (settings.apiProvider === 'codex_cli' && terminationCategory === 'cancelled') receipts.observe({}, 'cancelled') return withTelemetry( - { ok: false as const, error: `AI 请求失败:${sanitizeAiErrorText(message, apiKey, messages)}` }, + { ok: false as const, error: `AI 请求失败:${sanitizeAiErrorText(message, apiKey, messages, rejectedKey)}` }, { terminationCategory } ) } finally { @@ -406,10 +434,11 @@ export class AIService implements IAIService { signal: AbortSignal, deadline: number, totalTimeoutMs: number, + receipts: AiUsageReceipts, markResponseFormatFallback: () => void, markResponseStarted: () => void - ): Promise { - const url = `${settings.baseUrl}/chat/completions` + ): Promise { + const url = `${settings.baseUrl.trim().replace(/\/+$/, '')}/chat/completions` const headers: Record = { 'Content-Type': 'application/json' } @@ -420,8 +449,7 @@ export class AIService implements IAIService { const requestBody: ChatRequestBody = { model: settings.modelName, messages, - temperature: settings.temperature, - max_tokens: settings.maxTokens, + ...chatGenerationParameters(settings), response_format: { type: 'json_object' }, ...(usesOfficialDeepSeekV4(settings) ? { thinking: { type: 'disabled' as const } } : {}) } @@ -433,14 +461,15 @@ export class AIService implements IAIService { return value } const response = await this.httpClient.postWithFallback(url, requestBody, headers, fallbackBody, { + receipts, signal, timeoutMs: remaining(), onResponseFormatFallback: markResponseFormatFallback, onResponseStarted: markResponseStarted }) - const parsed = await parseAiResponse(response) + const parsed = await parseAiResponse(response, receipts) if (!parsed.emptyContent || !parsed.canRetryWithoutResponseFormat) { - return { result: parsed.result, usage: parsed.diagnostics.usage } + return parsed.result } markResponseFormatFallback() @@ -448,20 +477,18 @@ export class AIService implements IAIService { `AI returned empty content; retrying without response_format. provider=${settings.apiProvider}, model=${settings.modelName}, finishReason=${parsed.diagnostics.finishReason || 'unknown'}, reasoningCharacters=${parsed.diagnostics.reasoningCharacters}, completionTokens=${parsed.diagnostics.completionTokens ?? 'unknown'}, contentType=${parsed.diagnostics.contentType}` ) const fallbackResponse = await this.httpClient.postWithFallback(url, fallbackBody, headers, undefined, { + receipts, signal, timeoutMs: remaining(), onResponseStarted: markResponseStarted }) - const fallbackParsed = await parseAiResponse(fallbackResponse) + const fallbackParsed = await parseAiResponse(fallbackResponse, receipts) if (fallbackParsed.emptyContent) { this.logger.warn( `AI fallback also returned empty content. provider=${settings.apiProvider}, model=${settings.modelName}, finishReason=${fallbackParsed.diagnostics.finishReason || 'unknown'}, reasoningCharacters=${fallbackParsed.diagnostics.reasoningCharacters}, completionTokens=${fallbackParsed.diagnostics.completionTokens ?? 'unknown'}, contentType=${fallbackParsed.diagnostics.contentType}` ) } - return { - result: fallbackParsed.result, - usage: mergeTokenUsage(parsed.diagnostics.usage, fallbackParsed.diagnostics.usage) - } + return fallbackParsed.result } } diff --git a/src/main/services/AiUsageReceipts.ts b/src/main/services/AiUsageReceipts.ts new file mode 100644 index 0000000..f8fae4d --- /dev/null +++ b/src/main/services/AiUsageReceipts.ts @@ -0,0 +1,67 @@ +import { randomUUID } from 'node:crypto' +import type { ProjectUsageContext, UsageAttempt } from '../../shared/projectUsage' +import type { AiTokenUsage } from '../../shared/types' +import { usageCountFields } from '../../shared/tokenUsage' + +export interface UsageJournal { + getStoragePath(): string | null + append(storagePath: string, attempts: readonly UsageAttempt[]): Promise +} + +type Dispatch = Pick +type CallMetadata = Pick +export type ReceiptMetadata = { providerRequestId?: unknown; usage?: AiTokenUsage } + +/** Private to one logical call. Its dispatches (including retries) run sequentially. */ +export class AiUsageReceipts { + private readonly dispatches: Dispatch[] = [] + + constructor(private readonly isSafeId: (id: string) => boolean) {} + + start(endpoint: string): void { + this.dispatches.push({ id: randomUUID(), startedAt: new Date().toISOString(), endpoint, outcome: 'failed' }) + } + + observe({ providerRequestId, usage }: ReceiptMetadata, outcome?: Dispatch['outcome']): void { + const attempt = this.dispatches.at(-1) + if (!attempt) return + if (outcome) attempt.outcome = outcome + if (usage) attempt.usage = usage + if (!attempt.providerRequestId && typeof providerRequestId === 'string' && + /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(providerRequestId) && + !/^(?:sk-|eyJ[^.]*\.)/i.test(providerRequestId) && this.isSafeId(providerRequestId)) { + attempt.providerRequestId = providerRequestId + } + } + + get count(): number { return this.dispatches.length } + + totalKnownUsage(): AiTokenUsage | undefined { + // The journal keeps all dispatches. A provider can replay the same charge on a retry. + const seen = new Map() + const conflicts = new Set() + const total: AiTokenUsage = {} + for (const { id, endpoint, providerRequestId, usage } of this.dispatches) { + if (!usage) continue + const source = providerRequestId ? JSON.stringify([endpoint, providerRequestId]) : id + const previous = seen.get(source) ?? {} + for (const key of usageCountFields) { + const n = usage[key] + if (n === undefined || conflicts.has(key)) continue + if (previous[key] === undefined) { + previous[key] = n + total[key] = (total[key] ?? 0) + n + } else if (previous[key] !== n) { + conflicts.add(key) + delete total[key] + } + } + seen.set(source, previous) + } + return Object.keys(total).length ? total : undefined + } + + records(context: ProjectUsageContext, call: CallMetadata): UsageAttempt[] { + return this.dispatches.map(attempt => ({ ...context, ...call, ...attempt })) + } +} diff --git a/src/main/services/ApiRegistry.ts b/src/main/services/ApiRegistry.ts new file mode 100644 index 0000000..63533d6 --- /dev/null +++ b/src/main/services/ApiRegistry.ts @@ -0,0 +1,96 @@ +import { readFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' + +export interface ApiRoute { + id: string + keyEnv: string + backupKeyEnv?: string + legacyDesktopKey: boolean +} + +export type ApiRegistry = ReadonlyMap + +export function apiEndpoint(value: string): string { + try { + const url = new URL(value) + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) return '' + return url.origin + url.pathname.replace(/\/+$/, '') + } catch { + return '' + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export async function readApiRegistry(): Promise { + const path = process.env.NOVEL_DIRECTOR_API_REGISTRY || join(homedir(), '.config', 'ai', 'api-registry.json') + const routes = new Map() + let raw: string + try { + raw = await readFile(path, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return routes + throw new Error(`无法读取 API 清单 ${path}`) + } + let config: unknown + try { + config = JSON.parse(raw.replace(/^\uFEFF/, '')) + } catch { + throw new Error(`API 清单不是有效的 JSON:${path}`) + } + if (!isRecord(config) || config.version !== 1 || !isRecord(config.apis)) { + throw new Error('API 清单需要 version: 1 和 apis 对象。') + } + if (config.desktopSavedKeyFor != null && + (typeof config.desktopSavedKeyFor !== 'string' || !Object.hasOwn(config.apis, config.desktopSavedKeyFor))) { + throw new Error('desktopSavedKeyFor 必须是清单中的一个服务 ID。') + } + for (const [id, item] of Object.entries(config.apis)) { + const keyName = /^[A-Z][A-Z0-9_]*_API_KEY$/ + if (!isRecord(item) || typeof item.baseUrl !== 'string' || typeof item.keyEnv !== 'string' || + !keyName.test(item.keyEnv) || (item.aliases !== undefined && !Array.isArray(item.aliases)) || + (item.backupKeyEnv !== undefined && (typeof item.backupKeyEnv !== 'string' || + !keyName.test(item.backupKeyEnv) || item.backupKeyEnv === item.keyEnv))) { + throw new Error(`请检查 API 清单中 ${id} 的 baseUrl、keyEnv、backupKeyEnv 和 aliases。`) + } + const route: ApiRoute = { id, keyEnv: item.keyEnv, backupKeyEnv: item.backupKeyEnv, legacyDesktopKey: id === config.desktopSavedKeyFor } + const base = apiEndpoint(item.baseUrl) + for (const address of [item.baseUrl, ...(item.aliases ?? [])]) { + const endpoint = typeof address === 'string' ? apiEndpoint(address) : '' + const url = endpoint ? new URL(endpoint) : undefined + if (!base || !url || url.origin !== new URL(base).origin || + (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) || routes.has(endpoint)) { + throw new Error(`API 清单中 ${id} 的服务地址无效或重复。别名必须属于同一来源。`) + } + routes.set(endpoint, route) + } + } + return routes +} + +export function sameApiEndpoint(registry: ApiRegistry, left: string, right: string): boolean { + const a = apiEndpoint(left) + const b = apiEndpoint(right) + return Boolean(a && b && (a === b || (registry.has(a) && registry.get(a) === registry.get(b)))) +} + +export function routeKey(route: ApiRoute): string { + return process.env[route.keyEnv]?.trim() || (route.backupKeyEnv && process.env[route.backupKeyEnv]?.trim()) || '' +} + +export function backupKeyAfterRejection(registry: ApiRegistry, baseUrl: string, rejected: string): string { + const route = registry.get(apiEndpoint(baseUrl)) + if (!route?.backupKeyEnv || !rejected || process.env[route.keyEnv]?.trim() !== rejected) return '' + const backup = process.env[route.backupKeyEnv]?.trim() + return backup && backup !== rejected ? backup : '' +} + +export function agentApiKey(registry: ApiRegistry, baseUrl: string, defaultBaseUrl: string): string { + const route = registry.get(apiEndpoint(baseUrl)) + if (route) return routeKey(route) + if (registry.size && !sameApiEndpoint(registry, baseUrl, defaultBaseUrl)) return '' + return process.env.NOVEL_DIRECTOR_API_KEY?.trim() ?? '' +} diff --git a/src/main/services/CodexCliService.ts b/src/main/services/CodexCliService.ts index 0326a55..f6d6825 100644 --- a/src/main/services/CodexCliService.ts +++ b/src/main/services/CodexCliService.ts @@ -17,6 +17,8 @@ interface CodexCliCompletionInput { maxTokens: number signal: AbortSignal timeoutMs: number + onDispatch?: () => void + onTurnFinished?: (metadata: { usage?: unknown; request_id?: unknown }) => void } interface ProcessResult { @@ -39,8 +41,23 @@ function trimBom(value: string): string { return value.replace(/^\uFEFF/, '').trim() } -function safeErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) +function cancelledError(): Error { + return Object.assign(new Error('Codex CLI 调用已取消。'), { code: 'CODEX_CLI_CANCELLED' }) +} + +function readExecEvents(stdout: string, onTurnFinished: CodexCliCompletionInput['onTurnFinished']): string { + let finalMessage = '' + for (const line of stdout.split(/\r?\n/)) { + let event + try { event = JSON.parse(line) } catch { continue } + if (event?.type === 'turn.completed' || event?.type === 'turn.failed') { + onTurnFinished?.({ usage: event.usage ?? event.error?.usage, request_id: event.request_id ?? event.error?.request_id }) + } else if (event?.type === 'item.completed' && event.item?.type === 'agent_message' && + (!event.item.phase || event.item.phase === 'final_answer') && typeof event.item.text === 'string') { + finalMessage = event.item.text.trim() + } + } + return finalMessage } function validateCodexCommand(command: string): string { @@ -112,9 +129,12 @@ async function runCapturedProcess( stdin?: string signal?: AbortSignal timeoutMs: number + onSpawn?: () => void + onStdoutCaptured?: (stdout: string) => void } ): Promise { const invocation = createSpawnInvocation(executable, args) + if (options.signal?.aborted) throw cancelledError() const child = spawn(invocation.command, invocation.args, { cwd: options.cwd, env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, @@ -146,18 +166,20 @@ async function runCapturedProcess( const forceKill = setTimeout(() => child.kill('SIGKILL'), 150) forceKill.unref() } - const onAbort = () => failAfterClose(new Error('Codex CLI 调用已取消。')) + const onAbort = () => failAfterClose(cancelledError()) const timeout = setTimeout(() => { - failAfterClose(new Error(`Codex CLI 调用超过 ${Math.ceil(options.timeoutMs / 1_000)} 秒,已终止。`)) + failAfterClose(Object.assign(new Error(`Codex CLI 调用超过 ${Math.ceil(options.timeoutMs / 1_000)} 秒,已终止。`), + { code: 'CODEX_CLI_TIMEOUT', timeoutMs: options.timeoutMs })) }, options.timeoutMs) options.signal?.addEventListener('abort', onAbort, { once: true }) if (options.signal?.aborted) onAbort() + if (options.onSpawn) child.once('spawn', options.onSpawn) child.stdout.on('data', (chunk: Buffer) => { stdoutBytes += chunk.length if (stdoutBytes > MAX_CAPTURE_BYTES) { - failAfterClose(new Error('Codex CLI 标准输出过大,已终止。')) + failAfterClose(Object.assign(new Error('Codex CLI 标准输出过大,已终止。'), { code: 'CODEX_CLI_OUTPUT_LIMIT' })) return } stdoutChunks.push(chunk) @@ -165,28 +187,36 @@ async function runCapturedProcess( child.stderr.on('data', (chunk: Buffer) => { stderrBytes += chunk.length if (stderrBytes > MAX_CAPTURE_BYTES) { - failAfterClose(new Error('Codex CLI 错误输出过大,已终止。')) + failAfterClose(Object.assign(new Error('Codex CLI 错误输出过大,已终止。'), { code: 'CODEX_CLI_OUTPUT_LIMIT' })) return } stderrChunks.push(chunk) }) - child.once('error', (error) => { + child.once('error', () => { if (settled) return settled = true cleanup() - reject(error) + reject(Object.assign(new Error('无法启动 Codex CLI。'), { code: 'CODEX_CLI_SPAWN' })) }) child.once('close', (exitCode) => { if (settled) return settled = true cleanup() + // A failed/killed exec may already have reported usage before it exited. + const stdout = Buffer.concat(stdoutChunks).toString('utf8') + try { + options.onStdoutCaptured?.(stdout) + } catch (error) { + reject(error) + return + } if (terminalError) { reject(terminalError) return } resolvePromise({ exitCode, - stdout: Buffer.concat(stdoutChunks).toString('utf8'), + stdout, stderr: Buffer.concat(stderrChunks).toString('utf8') }) }) @@ -229,7 +259,7 @@ export class CodexCliService { timeoutMs: STATUS_TIMEOUT_MS }) if (versionResult.exitCode !== 0) { - throw new Error(trimBom(versionResult.stderr) || '无法读取 Codex CLI 版本。') + throw new Error('无法读取 Codex CLI 版本。') } const loginResult = await runCapturedProcess(executable, ['login', 'status'], { timeoutMs: STATUS_TIMEOUT_MS @@ -247,34 +277,36 @@ export class CodexCliService { authenticated, authenticationMode, resolvedPath: executable, - version: trimBom(versionResult.stdout || versionResult.stderr), + version: /^codex-cli \d+\.\d+\.\d+(?:-[A-Za-z0-9.-]{1,40})?$/m.exec(trimBom(versionResult.stdout))?.[0], message: authenticated && authenticationMode === 'chatgpt' - ? 'Codex CLI 已安装,并已通过 ChatGPT 登录,可以使用当前 Codex 计划额度。' + ? 'Codex CLI 已通过 ChatGPT 登录;未查询订阅剩余额度。' : authenticated && authenticationMode === 'api_key' ? 'Codex CLI 当前使用 API Key 登录;调用可能计入 API 账单,而不是 ChatGPT/Codex 计划额度。' : authenticated ? 'Codex CLI 已登录,但无法确认登录方式。请在终端运行 codex login status 核对是否为 ChatGPT 登录。' : 'Codex CLI 已安装,但尚未登录。请先在终端运行 codex login。' } - } catch (error) { + } catch { return { ok: true, available: false, authenticated: false, authenticationMode: 'unknown', - message: `未找到可用的 Codex CLI:${safeErrorMessage(error)}` + message: '未能确认 Codex CLI 状态,请检查安装路径和执行权限。' } } } async chatCompletion(input: CodexCliCompletionInput): Promise { + if (input.signal.aborted) throw cancelledError() const executable = await this.resolveExecutable(input.command) const model = validateCodexModel(input.model) const workspace = await mkdtemp(join(tmpdir(), 'novel-director-codex-')) const outputPath = join(workspace, 'last-message.txt') const args = [ 'exec', + '--json', '--skip-git-repo-check', '--sandbox', 'read-only', @@ -294,25 +326,28 @@ export class CodexCliService { ) try { + let finalMessage = '' const result = await runCapturedProcess(executable, args, { cwd: workspace, stdin: formatCodexCliPrompt(input.messages, input.maxTokens), signal: input.signal, + onSpawn: input.onDispatch, + onStdoutCaptured: stdout => { finalMessage = readExecEvents(stdout, input.onTurnFinished) }, timeoutMs: input.timeoutMs }) if (result.exitCode !== 0) { const stderr = trimBom(result.stderr).slice(0, 800) if (/login|not authenticated|unauthorized|sign in/i.test(stderr)) { - throw new Error('Codex CLI 尚未登录,请先在终端运行 codex login。') + throw Object.assign(new Error('Codex CLI 身份验证失败。'), { code: 'CODEX_CLI_AUTH' }) } - throw new Error(`Codex CLI 执行失败(退出码 ${result.exitCode ?? 'unknown'}):${stderr || '没有错误详情。'}`) + throw Object.assign(new Error('Codex CLI 执行失败。'), { code: 'CODEX_CLI_EXIT', exitCode: result.exitCode }) } let content = '' try { content = trimBom(await readFile(outputPath, 'utf8')) } catch { - content = trimBom(result.stdout) + content = finalMessage } if (!content) { this.logger.warn('Codex CLI returned an empty final message.') diff --git a/src/main/services/DiagnosticsService.ts b/src/main/services/DiagnosticsService.ts index 9a6124f..7f7eac3 100644 --- a/src/main/services/DiagnosticsService.ts +++ b/src/main/services/DiagnosticsService.ts @@ -5,6 +5,7 @@ import type { } from '../../shared/ipc/ipcTypes' import type { RedundancyReport, NoveltyAuditResult, QualityGateReport } from '../../shared/types' import type { IAIService } from './AIService' +import type { AIJsonTransport } from '../../services/ai/AITransport' export class DiagnosticsService { constructor(private readonly aiTransport: IAIService) {} @@ -20,16 +21,18 @@ export class DiagnosticsService { } evaluateQualityGate(request: DiagnosticsEvaluateQualityGateRequest): Promise { - return this.evaluateQualityGateLazy(request) + // A library switch while loading the reviewers must not redirect this request's receipts. + const transport = this.aiTransport.bindToCurrentStorage?.() ?? this.aiTransport + return this.evaluateQualityGateLazy({ ...request }, transport) } - private async evaluateQualityGateLazy(request: DiagnosticsEvaluateQualityGateRequest): Promise { + private async evaluateQualityGateLazy(request: DiagnosticsEvaluateQualityGateRequest, transport: AIJsonTransport): Promise { const [{ QualityGateService }, { QualityGateAI }, { MainAIJsonClient }] = await Promise.all([ import('../../services/QualityGateService'), import('../../services/ai/QualityGateAI'), import('./MainAIJsonClient') ]) - const aiClient = new MainAIJsonClient(request.settings, this.aiTransport, request.runId) + const aiClient = new MainAIJsonClient(request.settings, transport, request.runId, request.projectId) const qualityGateAI = new QualityGateAI(aiClient) return QualityGateService.evaluateChapterDraft({ projectId: request.projectId, @@ -41,6 +44,7 @@ export class DiagnosticsService { chapterPlan: request.chapterPlan, consistencyReports: request.consistencyReports ?? [], noveltyAuditResult: request.noveltyAuditResult ?? null, + hardCanonAudit: request.hardCanonAudit, redundancyReport: request.redundancyReport ?? null, characterStateFacts: request.characterStateFacts, characters: request.characters ?? [], diff --git a/src/main/services/MainAIJsonClient.ts b/src/main/services/MainAIJsonClient.ts index fd72f1e..a8b20f6 100644 --- a/src/main/services/MainAIJsonClient.ts +++ b/src/main/services/MainAIJsonClient.ts @@ -3,14 +3,15 @@ import type { AIResult, AppSettings } from '../../shared/types' import { normalizeAIError, parseWithFallback } from '../../services/AIJsonParser' import { fallbackResult, isTruncatedFinishReason } from '../../services/ai/AIResponseNormalizer' import { formatSchemaValidationError, type AISchemaValidator } from '../../services/ai/AISchemaValidator' -import type { AIJsonClient } from '../../services/ai/AIJsonClient' -import type { IAIService } from './AIService' +import { getRejectedChapterEnvelope, type AIJsonClient, type AIRequestControl } from '../../services/ai/AIJsonClient' +import { projectUsageContext, type AIJsonTransport } from '../../services/ai/AITransport' export class MainAIJsonClient implements AIJsonClient { constructor( private readonly settings: AppSettings, - private readonly aiTransport: IAIService, - private readonly runId?: string + private readonly aiTransport: AIJsonTransport, + private readonly runId?: string, + private readonly projectId?: string ) {} private hasApiConfig(): boolean { @@ -23,14 +24,16 @@ export class MainAIJsonClient implements AIJsonClient { normalize: (value: unknown) => T, fallback: T, parseFallback?: (rawText: string) => T | null, - validate?: AISchemaValidator + validate?: AISchemaValidator, + requestControl?: AIRequestControl ): Promise> { - if (!this.hasApiConfig()) return fallbackResult(fallback) - try { + if (!this.hasApiConfig() && !(await this.aiTransport.isManagedEndpoint?.(this.settings.baseUrl))) return fallbackResult(fallback) const request: ChatCompletionRequest = { settings: this.settings, - runId: this.runId, + runId: requestControl?.runId ?? this.runId, + clientCallId: requestControl?.clientCallId, + usageContext: projectUsageContext(this.projectId, requestControl?.phase), messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt } @@ -57,6 +60,8 @@ export class MainAIJsonClient implements AIJsonClient { error: 'AI 输出被 max tokens 截断。请提高 Max Tokens,或降低章节预计字数后重试。' } } + const rejected = getRejectedChapterEnvelope(response, normalize, validate) + if (rejected) return rejected try { const parsed = parseWithFallback(response.content, 'AI 返回内容') if (!parsed.ok) throw new Error(parsed.parseError) diff --git a/src/main/utils/aiErrors.ts b/src/main/utils/aiErrors.ts index 9468fe7..9ee54df 100644 --- a/src/main/utils/aiErrors.ts +++ b/src/main/utils/aiErrors.ts @@ -1,14 +1,14 @@ -import { getUserFriendlyError } from '../../shared/errorUtils' +import { aiNetworkFailure, readQuotaCode } from './connectionDiagnosis' export class AiHttpError extends Error { readonly status: number - readonly responseText: string + readonly quotaCode?: string constructor(status: number, responseText: string) { - super(`HTTP ${status} ${responseText}`) + super(`HTTP ${status}`) this.name = 'AiHttpError' this.status = status - this.responseText = responseText + this.quotaCode = readQuotaCode(status, responseText) } } @@ -24,24 +24,12 @@ export class AiRequestCancelledError extends Error { export class AiRequestTimeoutError extends Error { readonly code = 'AI_REQUEST_TIMEOUT' - constructor(readonly timeoutMs: number) { + constructor(readonly timeoutMs: number, readonly phase: 'reading' | 'total' = 'total') { super(`AI 请求超过 ${Math.ceil(timeoutMs / 1000)} 秒硬超时,已自动终止。可切换模型或从当前步骤重试。`) this.name = 'AiRequestTimeoutError' } } -function errorCode(error: unknown): string { - if (!error || typeof error !== 'object') return '' - const record = error as Record - if (typeof record.code === 'string') return record.code - const cause = record.cause - if (cause && typeof cause === 'object') { - const causeCode = (cause as Record).code - if (typeof causeCode === 'string') return causeCode - } - return '' -} - function httpStatus(error: unknown): number | null { if (error instanceof AiHttpError) return error.status if (!error || typeof error !== 'object') return null @@ -55,22 +43,25 @@ export function isRetryableAiError(error: unknown): boolean { const status = httpStatus(error) if (status !== null) { if ([400, 401, 403, 413].includes(status)) return false + if (error instanceof AiHttpError && error.quotaCode) return false return status === 429 || status === 502 || status === 503 || status === 504 } - const code = errorCode(error).toUpperCase() - if (['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED'].includes(code)) return true - if (['UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_SOCKET'].includes(code)) return true - - const message = getUserFriendlyError(error).toLowerCase() - return /network|fetch failed|timeout|timed out|econnreset|etimedout|enotfound|eai_again|socket hang up|temporar/.test(message) + const failure = aiNetworkFailure(error) + if (!failure) return false + if (failure.kind === 'read_failure' && !failure.code) return false + if (failure.kind === 'connect_timeout' || failure.kind === 'read_timeout' || !failure.code) return true + return [ + 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'UND_ERR_SOCKET', + 'ERR_CONNECTION_RESET', 'ERR_CONNECTION_CLOSED', 'ERR_CONNECTION_REFUSED', 'ERR_TIMED_OUT', + 'ERR_NAME_NOT_RESOLVED', 'ERR_NAME_RESOLUTION_FAILED', 'ERR_DNS_TIMED_OUT' + ].includes(failure.code) } export function describeAiRetryError(error: unknown): string { if (error instanceof AiRequestCancelledError) return error.code if (error instanceof AiRequestTimeoutError) return `${error.code}:${error.timeoutMs}` - if (error instanceof AiHttpError) return `HTTP ${error.status}` - const code = errorCode(error) - if (code) return code - return getUserFriendlyError(error).slice(0, 120) + const status = httpStatus(error) + if (status !== null) return `HTTP ${status}` + return aiNetworkFailure(error)?.code ?? '网络请求失败' } diff --git a/src/main/utils/connectionDiagnosis.ts b/src/main/utils/connectionDiagnosis.ts new file mode 100644 index 0000000..c8b2a02 --- /dev/null +++ b/src/main/utils/connectionDiagnosis.ts @@ -0,0 +1,251 @@ +export type AiConnectionTransport = 'node-fetch' | 'electron-net' | 'codex-cli' | 'custom-fetch' + +interface ConnectionContext { + transport: AiConnectionTransport + baseUrl?: string +} + +type NetworkKind = 'dns' | 'connect_timeout' | 'read_timeout' | 'read_failure' | 'connection' | 'network' +type DiagnosisKind = NetworkKind | 'total_timeout' | 'authentication' | 'quota_exhausted' | 'rate_limited' | + 'http' | 'cli_process' | 'cli_timeout' | 'cancelled' + +interface ConnectionDiagnosis { + kind: DiagnosisKind + message: string +} + +type Fields = Record +function fields(value: unknown): Fields { + return value !== null && typeof value === 'object' ? value as Fields : {} +} + +const quotaCodes = new Set([ + 'insufficient_quota', 'insufficient_balance', 'insufficient_credits', 'quota_exhausted', + 'balance_not_enough', 'balance_exhausted', 'credits_exhausted', 'out_of_credits', + 'credit_balance_too_low', 'billing_hard_limit_reached' +]) + +// Only machine-readable fields qualify. "quota" in a message can just mean tokens per minute. +export function readQuotaCode(status: number, responseText: string): string | undefined { + if ((status !== 402 && status !== 429) || responseText.length > 64 * 1024) return undefined + try { + const root = fields(JSON.parse(responseText)) + const error = fields(root.error) + for (const value of [error.code, error.type, root.code, root.type]) { + if (typeof value !== 'string' || value.length > 64) continue + const code = value.trim().replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase() + if (quotaCodes.has(code)) return code + } + } catch { + // Empty, HTML and malformed error bodies carry no reliable quota evidence. + } + return undefined +} + +const networkKinds = new Map([ + ['ENOTFOUND', 'dns'], ['EAI_AGAIN', 'dns'], ['ERR_NAME_NOT_RESOLVED', 'dns'], + ['ERR_NAME_RESOLUTION_FAILED', 'dns'], ['ERR_DNS_TIMED_OUT', 'dns'], + ['UND_ERR_CONNECT_TIMEOUT', 'connect_timeout'], ['ERR_CONNECTION_TIMED_OUT', 'connect_timeout'], + ['UND_ERR_HEADERS_TIMEOUT', 'read_timeout'], ['UND_ERR_BODY_TIMEOUT', 'read_timeout'], + ['ECONNRESET', 'connection'], ['ECONNREFUSED', 'connection'], ['EHOSTUNREACH', 'connection'], + ['ENETUNREACH', 'connection'], ['UND_ERR_SOCKET', 'connection'], ['ERR_CONNECTION_RESET', 'connection'], + ['ERR_CONNECTION_CLOSED', 'connection'], ['ERR_CONNECTION_REFUSED', 'connection'], + ['ERR_CONNECTION_ABORTED', 'connection'], ['ERR_EMPTY_RESPONSE', 'connection'], + ['ERR_ADDRESS_UNREACHABLE', 'connection'], ['ERR_INTERNET_DISCONNECTED', 'connection'], + ['ERR_PROXY_CONNECTION_FAILED', 'connection'], ['ERR_TUNNEL_CONNECTION_FAILED', 'connection'], + ['ERR_TIMED_OUT', 'network'], ['ETIMEDOUT', 'network'] +]) + +interface NetworkFailure { kind: NetworkKind; code?: string } + +type HttpPhase = 'waiting_headers' | 'reading' + +export class AiTransportFailure extends TypeError { + constructor(error: unknown, readonly phase: HttpPhase) { + const failure = networkCause(error) + const syscall = failure?.kind === 'connect_timeout' ? 'connect' : failure?.kind === 'read_timeout' ? 'read' : undefined + // Even console.error(error) must not print a body fragment, arbitrary stderr or a URL from the original cause. + super(phase === 'reading' ? '读取 AI 响应正文失败。' : 'fetch failed', + failure?.code ? { cause: { code: failure.code, syscall } } : undefined) + this.name = 'AiTransportFailure' + } +} + +function networkCause(error: unknown): NetworkFailure | undefined { + // fetch wraps OS/Undici failures in TypeError; dual-stack connects can wrap them again in AggregateError. + const queue: unknown[] = [error] + const seen = new Set() + let generic: NetworkFailure | undefined + for (let i = 0; i < queue.length && i < 16; i++) { + const item = queue[i] + if (!item || seen.has(item)) continue + seen.add(item) + const e = fields(item) + const message = typeof item === 'string' ? item : typeof e.message === 'string' ? e.message : '' + const reported = typeof e.code === 'string' ? e.code.replace(/^net::/, '') : '' + const chromium = /^net::(ERR_[A-Z0-9_]+)\b/.exec(message)?.[1] + for (const code of [reported, chromium]) { + if (!code) continue + if (code === 'ETIMEDOUT' && (e.syscall === 'connect' || /^connect ETIMEDOUT\b/.test(message))) { + return { kind: 'connect_timeout', code } + } + if (code === 'ETIMEDOUT' && e.syscall === 'read') return { kind: 'read_timeout', code } + const kind = networkKinds.get(code) + if (kind && kind !== 'network') return { kind, code } + if (kind) generic = { kind, code } + } + if (!generic && (chromium || /^(fetch failed|failed to fetch|network error|socket hang up|failed to parse URL|request cannot be constructed from a URL)/i.test(message))) { + generic = { kind: 'network' } + } + if (e.cause) queue.push(e.cause) + if (Array.isArray(e.errors)) queue.push(...e.errors.slice(0, 8)) + } + return generic +} + +export function aiNetworkFailure(error: unknown): NetworkFailure | undefined { + const failure = networkCause(error) + const e = fields(error) + if (e.name !== 'AiTransportFailure' || e.phase !== 'reading') return failure + const timedOut = failure?.kind === 'read_timeout' || failure?.kind === 'connect_timeout' || + failure?.code === 'ETIMEDOUT' || failure?.code === 'ERR_TIMED_OUT' + return { kind: timedOut ? 'read_timeout' : 'read_failure', code: failure?.code } +} + +export function supportsNodeFetchEnvProxy(version: string): boolean { + // Built-in fetch gained the env switch before node:http did on 24.x. + // https://nodejs.org/en/learn/http/enterprise-network-configuration + const match = /^v?(\d+)\.(\d+)\./.exec(version) + return !!match && (Number(match[1]) >= 24 || (Number(match[1]) === 22 && Number(match[2]) >= 21)) +} + +function proxySwitch(env: NodeJS.ProcessEnv, execArgv: readonly string[]): 'on' | 'off' | 'off-by-flag' { + // Command-line flags take precedence over NODE_OPTIONS, which in turn overrides the env switch. + const flags = (env.NODE_OPTIONS ?? '').match(/"[^"]*"|[^\s"]+/g) ?? [] + let setting: 'on' | 'off' | 'off-by-flag' = env.NODE_USE_ENV_PROXY === '1' ? 'on' : 'off' + for (const flag of [...flags.map(value => value.replace(/^"|"$/g, '')), ...execArgv]) { + if (flag === '--use-env-proxy') setting = 'on' + else if (flag === '--no-use-env-proxy') setting = 'off-by-flag' + } + return setting +} + +function bypassFor(url: URL, noProxy: string): 'yes' | 'no' | 'uncertain' { + const host = url.hostname.toLowerCase() + const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80)) + let uncertain = false + for (const token of noProxy.toLowerCase().split(/[,\s]+/).filter(Boolean)) { + if (token === '*') return 'yes' + const match = /^(.+):(\d+)$/.exec(token) + if (match && Number(match[2]) !== port) continue + const rule = match ? match[1] : token + if (host === rule) return 'yes' + const suffix = rule.replace(/^\*?\./, '') + if (rule !== suffix && host.endsWith('.' + suffix)) return 'yes' + // Bare parent domains, dotted apex names and nonstandard rules vary between bundled Undici versions. + if (host === suffix || host.endsWith('.' + suffix) || /[*/]/.test(suffix) || + host.endsWith('.') || rule.endsWith('.') || /^\d[\d.]*-\d/.test(rule)) uncertain = true + } + return uncertain ? 'uncertain' : 'no' +} + +export function describeNodeProxy( + baseUrl: string | undefined, + version = process.versions.node, + env: NodeJS.ProcessEnv = process.env, + execArgv: readonly string[] = process.execArgv +): string { + let url: URL + try { url = new URL(baseUrl ?? '') } catch { return '无法判断此地址的环境代理配置。' } + if (!['http:', 'https:'].includes(url.protocol)) return '无法判断此地址的环境代理配置。' + const httpProxy = env.http_proxy ?? env.HTTP_PROXY + const proxy = url.protocol === 'https:' ? (env.https_proxy ?? env.HTTPS_PROXY) || httpProxy : httpProxy + if (!proxy) return '未配置适用于此地址的环境代理,按配置直连。' + if (!supportsNodeFetchEnvProxy(version)) return '此版本的内置 fetch 不支持环境代理开关。需要环境代理时请使用 Node.js 22.21 或更新的 22.x,或使用 24 及以上版本。' + const bypass = bypassFor(url, env.no_proxy ?? env.NO_PROXY ?? '') + const setting = proxySwitch(env, execArgv) + if (setting !== 'on') { + if (bypass === 'yes') return '环境代理未启用;此地址也匹配 NO_PROXY,按配置直连。' + if (setting === 'off-by-flag') return '启动参数 --no-use-env-proxy 禁用了环境代理,按配置直连。需要使用代理时请调整启动参数。' + return '已配置代理,但未启用 Node 环境代理,按配置直连。确需代理时可在启动 Node 前设置 NODE_USE_ENV_PROXY=1。' + } + if (bypass === 'yes') return '环境代理已启用;此地址匹配 NO_PROXY,按配置绕过代理直连。' + if (bypass === 'uncertain') return '环境代理已启用;请核对此版本对 NO_PROXY 规则的匹配,未确认此地址是否绕过代理。' + return '环境代理已启用,此地址按配置使用代理。' +} + +const balance = '未查询账户余额,额度未知。' +const cliValidationMessages = new Set([ + 'Codex CLI 路径无效。', 'CLI 命令只允许使用 codex,或选择 codex/codex.exe/codex.cmd 的绝对路径。', + '所选文件不是 Codex CLI 可执行文件。', 'Codex CLI 模型名包含不支持的字符。', 'Codex CLI 参数无效。' +]) + +function timeLimit(ms: unknown): string { + return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? `${Math.ceil(ms / 1_000)} 秒` : '' +} + +function cliDiagnosis(e: Fields): ConnectionDiagnosis { + let kind: DiagnosisKind = 'cli_process' + let reason: string + switch (e.code) { + case 'CODEX_CLI_CANCELLED': kind = 'cancelled'; reason = 'Codex CLI 调用已取消。'; break + case 'CODEX_CLI_TIMEOUT': + kind = 'cli_timeout' + reason = timeLimit(e.timeoutMs) ? `Codex CLI 调用超过 ${timeLimit(e.timeoutMs)},已终止。` : 'Codex CLI 调用超时,已终止。' + break + case 'CODEX_CLI_AUTH': kind = 'authentication'; reason = 'Codex CLI 身份验证失败,请在终端运行 codex login status 核对登录状态。'; break + case 'CODEX_CLI_SPAWN': case 'ENOENT': case 'EACCES': case 'EPERM': + reason = '无法启动 Codex CLI,请检查安装位置和执行权限。'; break + case 'CODEX_CLI_OUTPUT_LIMIT': reason = 'Codex CLI 输出过大,已终止。'; break + case 'CODEX_CLI_EXIT': + reason = Number.isSafeInteger(e.exitCode) ? `Codex CLI 执行失败(退出码 ${e.exitCode})。` : 'Codex CLI 已异常退出。' + break + default: + reason = typeof e.message === 'string' && cliValidationMessages.has(e.message) ? e.message : 'Codex CLI 调用失败,请检查本机 CLI 的安装与登录状态。' + } + return { kind, message: `${reason} 请求路径:Codex CLI 子进程。${balance}` } +} + +export function diagnoseAiConnection(error: unknown, context: ConnectionContext): ConnectionDiagnosis | undefined { + const e = fields(error) + const path = context.transport === 'node-fetch' ? `独立 Node.js ${process.versions.node}(内置 fetch)` + : context.transport === 'electron-net' ? (process.versions.electron ? `桌面 Electron ${process.versions.electron}(Chromium net.fetch)` : 'Electron net.fetch 接口(非 Electron 运行时,未验证桌面网络路径)') + : context.transport === 'codex-cli' ? 'Codex CLI 子进程' : '自定义 fetch(实际网络路径未知)' + const report = (kind: DiagnosisKind, reason: string, routing = false): ConnectionDiagnosis => { + const hint = !routing ? '' : context.transport === 'node-fetch' ? describeNodeProxy(context.baseUrl) + : context.transport === 'electron-net' && process.versions.electron ? '请检查桌面会话使用的系统或应用代理。' : '' + return { kind, message: `${reason} 请求路径:${path}。${hint}${balance}` } + } + if (e.code === 'AI_REQUEST_CANCELLED') return report('cancelled', '用户已取消 AI 请求。可从最后成功步骤继续重试。') + if (e.name === 'AbortError') return report('cancelled', 'AI 请求已中止。可从最后成功步骤继续重试。') + if (context.transport === 'codex-cli') return cliDiagnosis(e) + if (e.code === 'AI_REQUEST_TIMEOUT') { + const reading = e.phase === 'reading' + return report(reading ? 'read_timeout' : 'total_timeout', + `AI 请求${reading ? '在读取响应时达到' : '超过'} ${timeLimit(e.timeoutMs)}硬超时,已自动终止。可切换模型或从当前步骤重试。`) + } + const status = e.status + if (typeof status === 'number' && Number.isInteger(status) && status >= 300 && status <= 599) { + const http = (kind: DiagnosisKind, reason: string) => report(kind, `HTTP ${status}:${reason}`) + if (status === 401) return http('authentication', '服务商拒绝了身份验证,请核对该服务地址对应的 API Key。') + const code = typeof e.quotaCode === 'string' && quotaCodes.has(e.quotaCode) ? e.quotaCode : undefined + if ((status === 402 || status === 429) && code) return http('quota_exhausted', `服务商报告可用额度不足(${code})。请到服务商后台核对账单或额度上限。`) + if (status === 402) return http('quota_exhausted', '服务商要求付款,请核对余额和计费状态;此状态码不能证明余额为零。') + if (status === 429) return http('rate_limited', '请求被限流,请降低并发或稍后重试。') + if (status === 403) return http('http', '服务商拒绝访问,请核对模型和账户权限。') + if (status === 407) return http('http', '代理要求身份验证,请检查代理登录设置。') + return http('http', status >= 500 ? '服务或网关暂时无法处理请求,请稍后重试。' : '请求未被接受,请核对服务地址、模型和请求参数。') + } + const failure = aiNetworkFailure(error) + if (!failure) return undefined // Application/configuration errors have their own messages. + const reasons = { + dns: 'DNS 域名解析失败,请核对服务或代理地址及 DNS 配置。', + connect_timeout: '建立连接超时,尚未收到响应。请检查网络是否可达。', + read_timeout: failure.code === 'UND_ERR_HEADERS_TIMEOUT' ? '等待读取响应头超时。' : '读取响应超时,未能按时读完服务端响应。', + read_failure: '已收到响应头,但未能读完响应正文。', + connection: '连接失败或中断,请检查服务和网络是否可达。', + network: /TIMED?_?OUT/.test(failure.code ?? '') ? '网络超时,无法确定发生在连接还是读取阶段。' : '网络请求失败,现有信息不足以确定原因。' + } + return report(failure.kind, reasons[failure.kind] + (failure.code ? `(${failure.code})` : ''), + failure.kind !== 'read_failure' && failure.kind !== 'read_timeout') +} diff --git a/src/preload/index.ts b/src/preload/index.ts index ee09f2a..8d99f9d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,9 +1,14 @@ import { contextBridge, ipcRenderer } from 'electron' import { IPC_CHANNELS } from '../shared/ipc/ipcChannels' +import type { ChapterTitleCommit, ChapterTitlePreview, ChapterTitleRename } from '../shared/types/chapterTitle' +import type { ChapterTitleWriteResult } from '../shared/ipc/ipcTypes' +import { replayNeedsReload, StorageRevisionConflictError } from '../shared/storageRevisionConflict' +import type { ProjectUsageReport } from '../shared/projectUsage' import type { ChatCompletionRequest, CancelAiCallResult, CandidateDecisionWriteResult, + CharacterCardReviewWriteResult, ChatCompletionResult, AgentAuthorizationGrantRequest, AgentAuthorizationGrantResult, @@ -50,7 +55,7 @@ import type { StorageGetResult, StorageSaveResult } from '../shared/ipc/ipcTypes' -import type { AppData, CandidateDecisionCommand, ChapterCommitBundle, GenerationRunBundle, RevisionCommitBundle } from '../shared/types' +import type { AppData, CandidateDecisionCommand, CharacterCardReviewCommand, ChapterCommitBundle, GenerationRunBundle, RevisionCommitBundle } from '../shared/types' function isIpcFailure(value: unknown): value is IpcFailure { return Boolean( @@ -106,8 +111,26 @@ function rememberStorageRevision(result: { revision?: string }): void { const novelDirector = { data: { + getProjectUsage: (projectId: string) => invokeOrThrow(IPC_CHANNELS.DATA_GET_PROJECT_USAGE, assertText(projectId, 'projectId')), load: loadStorageData, save: saveStorageData, + previewChapterTitleRename: (command: ChapterTitleRename) => + invokeOrThrow(IPC_CHANNELS.DATA_PREVIEW_CHAPTER_TITLES, { command, expectedRevision: storageRevision }), + applyChapterTitleRename: async (commit: ChapterTitleCommit) => { + const result = await invokeOrThrow(IPC_CHANNELS.DATA_RENAME_CHAPTER_TITLES, commit) + // A replay can observe another writer. Never pair its revision with stale renderer data. + if (result.replayed && storageRevision !== result.revision) { + throw new StorageRevisionConflictError(replayNeedsReload) + } + storageRevision = result.revision + return result + }, + reviewCharacterCard: async (command: CharacterCardReviewCommand) => { + const result = await invokeOrThrow(IPC_CHANNELS.DATA_REVIEW_CHARACTER_CARD, + { command, expectedRevision: storageRevision }) + storageRevision = result.revision + return result + }, executeCandidateDecision: async (command: CandidateDecisionCommand) => { const result = await invokeOrThrow(IPC_CHANNELS.DATA_EXECUTE_CANDIDATE_DECISION, { command, expectedRevision: storageRevision }) @@ -211,11 +234,11 @@ const novelDirector = { invokeOrThrow<{ ok: true }>(IPC_CHANNELS.CLIPBOARD_WRITE_TEXT, assertText(text, 'text')) }, credentials: { - setApiKey: (apiKey: string) => + setApiKey: (apiKey: string, baseUrl?: string) => invokeOrThrow(IPC_CHANNELS.CREDENTIALS_SET_API_KEY, { - apiKey: assertText(apiKey, 'apiKey') + apiKey: assertText(apiKey, 'apiKey'), baseUrl }), - hasApiKey: () => invokeOrThrow(IPC_CHANNELS.CREDENTIALS_HAS_API_KEY), + hasApiKey: (baseUrl?: string) => invokeOrThrow(IPC_CHANNELS.CREDENTIALS_HAS_API_KEY, baseUrl), deleteApiKey: () => invokeOrThrow(IPC_CHANNELS.CREDENTIALS_DELETE_API_KEY), migrateLegacyApiKey: (apiKey: string) => invokeOrThrow( diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index da2e07d..a0204b7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5,6 +5,7 @@ import { useConfirm } from './components/ConfirmDialog' import { Shell, type View } from './components/Layout' import { useAppData } from './hooks/useAppData' import { QuickRewriteDraftProvider } from './components/QuickRewriteDraftProvider' +import type { BookProgressLocation } from './views/bookProgress/navigation' const HomeView = lazy(() => import('./views/HomeView').then((module) => ({ default: module.HomeView }))) const DashboardView = lazy(() => import('./views/DashboardView').then((module) => ({ default: module.DashboardView }))) @@ -26,7 +27,7 @@ const GenerationPipelineView = lazy(() => const RevisionStudioView = lazy(() => import('./views/RevisionStudioView').then((module) => ({ default: module.RevisionStudioView }))) const SettingsView = lazy(() => import('./views/SettingsView').then((module) => ({ default: module.SettingsView }))) -function StorageConflictNotice({ onReload }: { onReload: () => Promise }) { +function StorageConflictNotice({ onReload, writeback }: { onReload: () => Promise; writeback?: boolean }) { const [isReloading, setIsReloading] = useState(false) async function reload() { @@ -41,8 +42,8 @@ function StorageConflictNotice({ onReload }: { onReload: () => Promise return (
- 本地数据已在其他窗口或 Agent 中更新 - 当前保存已停止,以免覆盖较新的章节或提交记录。 + {writeback ? '本次正式写回未确认' : '本地数据已在其他窗口或 Agent 中更新'} + {writeback ? '重新加载后,可在工作台的“全书进度与缺章”核对哪些稿件已经写入。' : '当前保存已停止,以免覆盖较新的章节或提交记录。'}
} + +} + +export function HardCanonAuditPanel({ audit, scope = 'draft', body }: HardCanonAuditPanelProps) { + const matchesText = useMemo(() => audit && body !== undefined ? hardCanonAuditMatchesBody(audit, body) : null, [audit, body]) + const { findings, blocks, titles } = useMemo(() => { + const blocking: HardCanonAuditFinding[] = [], hints: HardCanonAuditFinding[] = [] + const titles = new Map() + for (const item of audit?.findings ?? []) { + if (item.severity === 'block') blocking.push(item) + else hints.push(item) + titles.set(item.ruleId, item.ruleTitle) + } + return { findings: [...blocking, ...hints], blocks: blocking.length, titles } + }, [audit]) + return ( +
+

{headings[scope]}

+ {matchesText === false && scope !== 'report_snapshot' ?

正文已变化,请重新核查后查看原句。

+ : !audit ?

{scope === 'report_snapshot' ? '该报告没有保存硬设定核查记录。' : '没有可显示的硬设定核查结果。'}

: <> +

第 {audit.chapterOrder} 章 · {audit.checkedRuleIds.length ? `核对 ${audit.checkedRuleIds.length} 条规则,${blocks} 处确证违反,${findings.length - blocks} 处待复核。` : '本章没有适用的可执行规则。'}

+ {findings.length > 0 && } + {audit.counts.length > 0 &&
+
用语次数
+
    + {audit.counts.map((item) =>
  • + {titles.get(item.ruleId) ?? item.ruleId} + 本章 {item.chapterCount} 次;已采纳全书 {item.bookCount} 次;采纳此稿后 {item.projectedBookCount} 次(变化 {item.delta > 0 ? '+' : ''}{item.delta} 次)。 +
  • )} +
+
} +
+ 核查依据 +

正文指纹:{audit.draftContentHash}

+

原文定位指纹:{audit.rawBodyFingerprint}

+

规则指纹:{audit.rulesFingerprint}

+

核对的规则:{audit.checkedRuleIds.join('、') || '无'}

+
+ } +
+ ) +} diff --git a/src/renderer/src/components/layoutParts/AppShell.tsx b/src/renderer/src/components/layoutParts/AppShell.tsx index c469060..c3bfa73 100644 --- a/src/renderer/src/components/layoutParts/AppShell.tsx +++ b/src/renderer/src/components/layoutParts/AppShell.tsx @@ -38,10 +38,10 @@ function useViewEnterAnimation(view: View) { if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return const animation = element.animate( [ - { opacity: 0, transform: 'translateY(6px)' }, + { opacity: 0, transform: 'translateY(4px)' }, { opacity: 1, transform: 'none' } ], - { duration: 240, easing: 'cubic-bezier(0.22, 0.8, 0.24, 1)' } + { duration: 200, easing: 'cubic-bezier(0.22, 0.8, 0.24, 1)' } ) return () => animation.cancel() }, [view]) diff --git a/src/renderer/src/components/pipeline/PipelineCurrentArtifactPanel.tsx b/src/renderer/src/components/pipeline/PipelineCurrentArtifactPanel.tsx index dd716c8..e161fa7 100644 --- a/src/renderer/src/components/pipeline/PipelineCurrentArtifactPanel.tsx +++ b/src/renderer/src/components/pipeline/PipelineCurrentArtifactPanel.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' import type { ChapterAcceptanceReview, ChapterGenerationJob, ChapterGenerationStep, ChapterGenerationStepType, GeneratedChapterDraft } from '../../../../shared/types' +import { readChapterStepFragment } from '../../../../shared/chapterStepFragment' import { StatusBadge } from '../UI' import { PipelineDraftPanel } from './PipelineDraftPanel' @@ -9,6 +10,18 @@ function latestPlanOutput(steps: ChapterGenerationStep[]) { return steps.find((step) => step.type === 'generate_chapter_plan' && step.output.trim())?.output ?? '' } +function StepOutput({ step }: { step: ChapterGenerationStep }) { + const fragment = readChapterStepFragment(step) + if (!fragment) return step.output ?
{step.output.slice(0, 1200)}
:

暂无输出。

+ return ( + <> +

保留的不完整片段,仅供查看,不能采纳。{fragment.finishReason ? `结束原因:${fragment.finishReason}` : ''}

+ {fragment.title ? {fragment.title} : null} +
{fragment.body}
+ + ) +} + export function PipelineCurrentArtifactPanel({ activeTab, taskEditor, @@ -16,6 +29,9 @@ export function PipelineCurrentArtifactPanel({ job, isRunning, draft, + previousDraft, + lengthOverflowNotice, + hardCanonBlock, steps, labels, onAcceptDraft, @@ -35,6 +51,9 @@ export function PipelineCurrentArtifactPanel({ job: ChapterGenerationJob | null isRunning: boolean draft: GeneratedChapterDraft | null + previousDraft?: GeneratedChapterDraft + lengthOverflowNotice?: string | null + hardCanonBlock?: string | null steps: ChapterGenerationStep[] labels: Record onAcceptDraft: (draft: GeneratedChapterDraft) => void @@ -67,7 +86,7 @@ export function PipelineCurrentArtifactPanel({ -