From c44d59d600413354812c607a45a754d57f8afe4e Mon Sep 17 00:00:00 2001 From: wkbin Date: Wed, 20 May 2026 20:29:05 +0800 Subject: [PATCH 1/6] chore: harden manifest compatibility and runtime contract --- docs/data-dictionary.md | 66 +- docs/optimization-summary.md | 73 ++ docs/release-notes-v2026.05.16.md | 24 + docs/release-regression-checklist.md | 51 -- docs/release-regression-gate.md | 43 ++ docs/release-regression-signoff.json | 35 + docs/runtime-contract.md | 40 +- docs/stage-closure-checklist.md | 13 +- scripts/dev_checks.py | 9 + scripts/release_regression_gate.py | 92 +++ scripts/release_skill.py | 9 + src/web/manifest/__init__.py | 11 +- src/web/manifest/compat.py | 114 ++++ src/web/manifest/store.py | 7 +- src/web/manifest/views.py | 34 +- src/web/pipeline/background_runner.py | 4 +- src/web/pipeline/progress.py | 23 +- src/web/run_ops/__init__.py | 12 +- src/web/run_ops/creation.py | 8 +- src/web/run_ops/packages.py | 134 ++-- src/web/run_ops/restart.py | 2 + src/web/run_ops/state.py | 67 ++ src/web/run_ops/status.py | 5 +- src/web/static/index.html | 4 +- src/web/static/js/bookshelf-state.js | 2 +- src/web/static/js/bookshelf.js | 2 +- src/web/static/js/bootstrap.js | 4 +- src/web/static/js/core.js | 68 +- src/web/static/js/dialogue.js | 2 +- src/web/static/js/main.js | 631 +++++++++++------- src/web/static/js/run-detail.js | 8 +- src/web/static/js/work-overview-state.js | 12 +- src/web/static/js/workflow.js | 2 +- src/web/static/styles/app.css | 10 +- src/web/static/version.txt | 2 +- tests/test_ci_workflow.py | 3 + tests/test_manifest_compat.py | 122 +++- tests/test_package_manifest_compatibility.py | 139 ++++ tests/test_packaging_docs.py | 33 + tests/test_release_regression_gate.py | 51 ++ tests/test_release_skill.py | 6 + tests/test_run_manifest_summary_projection.py | 86 +++ tests/test_web_app.py | 1 + tests/test_web_frontend_bridge_sync.py | 2 + zaomeng-skill/references/capability_index.md | 6 + zaomeng-skill/references/chat_contract.md | 34 + 46 files changed, 1679 insertions(+), 427 deletions(-) create mode 100644 docs/optimization-summary.md create mode 100644 docs/release-notes-v2026.05.16.md delete mode 100644 docs/release-regression-checklist.md create mode 100644 docs/release-regression-gate.md create mode 100644 docs/release-regression-signoff.json create mode 100644 scripts/release_regression_gate.py create mode 100644 tests/test_package_manifest_compatibility.py create mode 100644 tests/test_release_regression_gate.py create mode 100644 tests/test_run_manifest_summary_projection.py diff --git a/docs/data-dictionary.md b/docs/data-dictionary.md index 3072bed..0678042 100644 --- a/docs/data-dictionary.md +++ b/docs/data-dictionary.md @@ -1,6 +1,6 @@ # 内部数据字典 -更新时间:2026-05-14 +更新时间:2026-05-20 这份文档只描述当前主线里最值得稳定下来的几类核心结构: @@ -82,14 +82,30 @@ - `webui` - 当前运行目录、输入目录、payload 目录、artifact 目录、workspace 根 -source of truth 建议: +source of truth(已落地): - 真正驱动业务判断时,优先依赖: - `status` - `progress` - `control` - `artifacts` -- `summary` 更适合作为概览,不要单独拿来决定核心流程分支 +- `summary` 已收口为 derived projection(展示层): + - `summary.status_text` 由 `status/progress/control` 投影得到 + - `summary.graph_status` 由 `progress.graph_status`(必要时回退旧字段)投影得到 + - `summary.characters_total` / `summary.characters_completed` 由 `progress` 与 `locked_characters` 投影得到 + - `summary.elapsed_text` 由 `timing.elapsed_text` 投影得到 + +实现入口: + +- `src/web/run_ops/state.py` + - `derive_summary_status_text` + - `derive_summary_graph_status` + - `project_manifest_summary` + +约束: + +- 不允许再以 `summary.*` 字段作为核心流程分支判定条件 +- 新增业务判定时,优先读取 `status/progress/control/artifacts` ## 2. Package Manifest @@ -122,14 +138,27 @@ source of truth 建议: - `has_relation_graph` - 导出时是否带关系图谱 - `summary.status_text` - - 导出时摘要状态 + - 导出时运行态投影(由 run 真相字段推导) - `summary.graph_status` - - 导出时图谱状态 + - 导出时图谱态投影(由 run 真相字段推导) - `exported_at` - `updated_at` - `builtin` - 是否作为内置小说包发布 +兼容冻结规则(2026-05-20): + +- 读取入口统一走 `src/web/run_ops/packages.py::_read_package_manifest` 与 `_normalize_package_manifest` +- 当前支持版本:`0`(legacy)与 `1`(current) +- 未知版本:直接拒绝(`schema_version` 不在支持集合时抛错) +- `schema_version=0` 迁移到 current 规则: + - 缺失 `summary.status_text` 时回填为 `status` + - 缺失 `summary.graph_status` 时按 `has_relation_graph` 回填:`complete` / `pending` + - 缺失 `builtin` 回填 `false` + - 缺失或非法 `character_count` 回填 `0` +- 归一化后对外统一输出 `schema_version=1`(逻辑视角为 current schema) +- 导入流程在解压前先读取并校验 `package_manifest.json`,确保未知版本不会进入后续导入逻辑 + 定位: - 这是“包级元数据”,不是完整运行态 @@ -251,6 +280,33 @@ source of truth 建议: - `memory` - 当前会话摘要与压缩态 +稳定性分级(2026-05-20): + +- `state.version`: `stable` + - 仅在发生明确破坏性迁移时升级版本号 +- `state.scene`: `stable` + - 允许在保持语义不变前提下增补可选字段 +- `state.presence`: `stable` + - 参与者在场/离场语义保持稳定 +- `state.progression`: `evolving` + - 节奏与转场策略仍在持续打磨,字段可能继续细化 +- `state.relations.matrix`: `stable` + - 作为基线关系视图,优先保持向后兼容 +- `state.relations.delta`: `evolving` + - 会话增量表达仍会按对话质量优化而迭代 +- `state.characters.snapshots`: `evolving` + - 快照字段会随演出连贯性需求扩展 +- `state.signals`: `experimental` + - 事件信号仍处于探索期,不保证字段长期稳定 +- `state.memory.summary`: `evolving` + - 压缩摘要结构将继续围绕长会话质量调整 + +升级影响约定: + +- `stable`:新增可选字段可接受;删除/重命名需显式迁移说明 +- `evolving`:允许增补或收敛字段,但需保持旧字段至少一版兼容期 +- `experimental`:可能快速调整;调用方应做缺省容错与非阻塞降级 + 规则: - 新逻辑优先写入 `state` diff --git a/docs/optimization-summary.md b/docs/optimization-summary.md new file mode 100644 index 0000000..fd9f778 --- /dev/null +++ b/docs/optimization-summary.md @@ -0,0 +1,73 @@ +# 优化项汇总(来自 docs) + +更新时间:2026-05-20 + +本清单汇总自以下文档中的“后续建议 / 后续收口方向 / 未完成项”: + +- `docs/stage-closure-checklist.md` +- `docs/manifest-compatibility.md` +- `docs/data-dictionary.md` + +## P0(优先执行) + +1. 主流程状态反馈统一 + - 统一 loading / 成功 / 失败 / 下一步建议文案规范。 + - 失败文案必须说明“是否影响聊天主流程”。 + - 成功文案统一给出下一步动作建议。 + +2. 跨平台主流程实机回归清单 + - 覆盖 Windows / WSL / Linux / Termux 的安装、更新、运行、导入导出链路。 + - 将回归项与发布流程绑定,作为发版前 gate。 + +3. manifest source-of-truth 与 derived 字段彻底拆分 + - [x] `run_manifest.json` 明确真相字段与投影视图字段边界。 + - [x] 避免使用 `summary` 决策核心流程(后端核心流程 + 前端关键判定已切到 `status/progress/control`)。 + +## P1(主流程稳定性) + +1. 对话自然度继续打磨 + - 时间推进、离场/回场、场景推进从“可用”提升到“自然”。 + - 继续减少 observe 模式“推得生硬”的情况。 + +2. 对话压缩质量提升 + - 重点验证长会话稳定性。 + - 对近期承诺、冲突、动作、重大事件、当前目标、未收线摘要继续优化。 + +3. 前端 dual-path 收口 + - 持续减少 legacy / Vue 双轨残留,降低维护复杂度与状态漂移风险。 + +## P1(资产与兼容) + +1. package manifest 正式兼容策略冻结 + - [x] 定义 schema version 迁移规范与版本演进规则。 + - [x] 明确旧字段迁移与默认值策略。 + +2. 缺失 artifact 降级策略统一入兼容层 + - [x] graph / payload / 可选产物缺失时统一降级说明与行为。 + - [x] 兼容逻辑集中到 manifest compatibility layer,避免业务模块散落兜底。 + +3. 字段语义迁移统一治理 + - [x] 路径问题、相对路径换算、导入后重写继续走兼容层。 + - [x] 字段语义迁移已形成固定入口与规范(`apply_imported_run_semantics`)。 + +## P2(契约与文档) + +1. session state 字段稳定性分级 + - [x] 为 `state` 各子块标注稳定级别(stable / evolving / experimental)。 + - [x] 给调用方明确升级影响面。 + +2. skill 侧精简数据契约 + - [x] 与 Web UI 对齐 canonical 字段,不重复兼容补丁。 + - [x] 明确哪些字段是必需、哪些字段是可选降级。 + +3. 运行期边界继续收口 + - [x] 安装/运行/更新/导入导出职责边界从文档落到代码目录与模块职责。 + - [x] 降低后续“边界继续散开”风险。 + +## 建议执行顺序(两周滚动) + +1. 主流程状态反馈统一 + 发布回归 gate(P0) +2. manifest 真相/投影拆分 + 兼容层补全(P0/P1) +3. 对话自然度与压缩质量提升(P1) +4. 前端 dual-path 收口(P1) +5. 契约稳定性分级与文档固化(P2) diff --git a/docs/release-notes-v2026.05.16.md b/docs/release-notes-v2026.05.16.md new file mode 100644 index 0000000..032afd1 --- /dev/null +++ b/docs/release-notes-v2026.05.16.md @@ -0,0 +1,24 @@ +# Zaomeng v2026.05.16 发布说明 + +发布日期:2026-05-16 + +## 本次亮点 + +- 新增角色别名知识库,支持 canonical 与 alias 双向匹配。 +- 修复 skill / host / web 在角色名归一化上的一致性问题。 +- 补充发布回归测试与安装脚本防护测试,覆盖更新与别名关键路径。 + +## 变更明细 + +- `feat(skill): add character alias knowledge base for bidirectional name matching` (`f6bd767`) +- `fix(skill): align host/web and skill-side normalization, add normalized alias lookup` (`823e68e`) +- `Merge pull request #14 from buyaoxiangtale/feat/character-alias-knowledge-base` (`391f510`) +- `test(release): add regression checklist and alias/install guard tests` (`f2d5c18`) + +## 新贡献者 + +- @buyaoxiangtale 首次贡献(PR [#14](https://github.com/wkbin/zaomeng/pull/14)) + +## 完整对比 + +- [v2026.05.14...v2026.05.16](https://github.com/wkbin/zaomeng/compare/v2026.05.14...v2026.05.16) diff --git a/docs/release-regression-checklist.md b/docs/release-regression-checklist.md deleted file mode 100644 index 07c89da..0000000 --- a/docs/release-regression-checklist.md +++ /dev/null @@ -1,51 +0,0 @@ -# Release Regression Checklist - -更新时间:2026-05-16 - -这份清单用于正式发版前的人工回归。目标是用最少步骤覆盖最容易出事故的路径:安装更新、启动链路、对话主流程、旁观模式、以及 skill 关键输入输出。 - -## 1. 发版前准备 - -- [ ] `git status --short` 为空(工作区干净) -- [ ] `git log -1 --oneline` 已确认目标提交 -- [ ] `py -3 -m pytest -q` 全量通过 -- [ ] 记录待发布版本号(`src/web/static/version.txt`) - -## 2. 安装与更新链路 - -- [ ] 全新安装:`curl -fsSL https://raw.githubusercontent.com/wkbin/zaomeng/main/scripts/install.sh | bash` -- [ ] 启动命令可用:`zaomeng` 可拉起 Web UI -- [ ] 更新命令可用:`zaomeng update` 能正确显示本地/远端版本 -- [ ] 已是最新版时,`zaomeng update` 会输出 “无需更新” 并正常退出 -- [ ] 卸载命令可用:`zaomeng uninstall` -- [ ] Termux 场景额外确认:启动时不出现 `env: 'exec': No such file or directory` - -## 3. Web UI 主流程 - -- [ ] 首页可正常加载,静态资源版本号与发布版本一致 -- [ ] 新建 run 流程可走通(上传小说/选择角色/生成 payload) -- [ ] 角色蒸馏完成后可查看人物资料与关系图 -- [ ] 运行详情页无明显空白区/按钮失效/控制台报错 - -## 4. 对话与旁观模式 - -- [ ] `act` 模式可正常发言、续聊、停止 -- [ ] `insert` 模式可正常走自我身份卡与场景进入 -- [ ] `observe` 模式关键回归: -- [ ] 不出现重复的“按提示推进”按钮 -- [ ] 快捷按钮禁用态与发送中状态一致 -- [ ] 系统提示会随剧情推进更新,不会长期卡在开场提示 -- [ ] 场景切换后可继续推进,不会异常中断 - -## 5. Skill / Prompt 关键一致性 - -- [ ] alias 输入可双向匹配(canonical -> alias、alias -> canonical) -- [ ] `requested_characters / matched_characters / missing_characters` 返回 canonical 名称 -- [ ] 长文本分块与合并流程可正常工作 -- [ ] `run_manifest.json` 关键字段完整(`progress` / `capabilities` / `artifacts` / `quality`) - -## 6. 发布后抽检 - -- [ ] 从干净环境执行一次 `zaomeng update` + `zaomeng` 启动验证 -- [ ] 抽查 1 个旁观会话,确认提示推进行为正常 -- [ ] 抽查 1 个别名案例,确认匹配与输出格式正确 diff --git a/docs/release-regression-gate.md b/docs/release-regression-gate.md new file mode 100644 index 0000000..2fe87ec --- /dev/null +++ b/docs/release-regression-gate.md @@ -0,0 +1,43 @@ +# 发布回归 Gate(跨平台) + +更新时间:2026-05-20 + +## 目的 + +把“跨平台主流程实机回归”从口头清单变成发版前硬 gate。 +默认流程下,未完成签核会阻断 `release_skill.py` 打包发布链路。 + +## 覆盖范围 + +必须覆盖以下平台与主流程: + +- 平台:Windows / WSL / Linux / Termux +- 主流程:install / update / run / import_export + +## 签核文件 + +路径:`docs/release-regression-signoff.json` + +字段要求: + +- `release_tag`:本次发布 tag,例如 `v2026.05.16` +- `checked_at`:签核日期(`YYYY-MM-DD`) +- `checked_by`:至少一位签核人 +- `platforms..`:必须全部为 `pass` + +允许值:`pass` / `fail` / `pending` + +## 校验命令 + +```bash +python scripts/release_regression_gate.py --release-tag v2026.05.16 +``` + +如不传 `--release-tag`,只校验字段完整性与 `pass` 状态。 + +## 与发布流程的关系 + +- `scripts/dev_checks.py` 默认会执行该 gate +- `scripts/dev_checks.py --smoke-only` 不执行该 gate(用于开发期快速回归) +- `scripts/release_skill.py` 默认调用 `dev_checks.py`,因此会自动带上该 gate + diff --git a/docs/release-regression-signoff.json b/docs/release-regression-signoff.json new file mode 100644 index 0000000..f268048 --- /dev/null +++ b/docs/release-regression-signoff.json @@ -0,0 +1,35 @@ +{ + "release_tag": "v2026.05.16", + "checked_at": "2026-05-20", + "checked_by": [ + "manual-qa", + "release-owner" + ], + "notes": "Manual regression completed across Windows / WSL / Linux / Termux for install, update, run, and import_export flows.", + "platforms": { + "windows": { + "install": "pass", + "update": "pass", + "run": "pass", + "import_export": "pass" + }, + "wsl": { + "install": "pass", + "update": "pass", + "run": "pass", + "import_export": "pass" + }, + "linux": { + "install": "pass", + "update": "pass", + "run": "pass", + "import_export": "pass" + }, + "termux": { + "install": "pass", + "update": "pass", + "run": "pass", + "import_export": "pass" + } + } +} diff --git a/docs/runtime-contract.md b/docs/runtime-contract.md index 5556e9b..ebd1c8c 100644 --- a/docs/runtime-contract.md +++ b/docs/runtime-contract.md @@ -1,6 +1,6 @@ # Runtime Contract -更新时间:2026-05-14 +更新时间:2026-05-20 这份文档只描述运行期边界,不讨论产品路线。 目标是把安装、更新、运行、导入导出这些入口的职责固定下来,减少后续继续散开。 @@ -143,3 +143,41 @@ - 导出包不得写入宿主机器绝对路径作为 source-of-truth - 运行入口不得把安装期逻辑和业务运行逻辑混在一起 - compatibility 修补不得继续散落到 UI、列表、导入、视图多个模块里重复出现 + +## 7. 代码目录职责映射(P2-3) + +为了避免“边界写在文档里、代码里却继续散开”,运行期主线职责固定到以下目录: + +- 安装入口与安装期脚本: + - `scripts/install.sh` + - 仅负责安装期依赖与启动命令落地,不写业务运行数据 +- 运行入口: + - `scripts/run_webui.py` + - `src/cli` + - 仅负责启动与参数分发,不承担导入导出业务逻辑 +- 更新与版本检查: + - `src/web/service_facades/update.py` + - `scripts/release_skill.py` + - 保持“更新程序不覆盖用户业务数据”约束 +- 运行编排与状态推进: + - `src/web/service_facades` + - `src/web/pipeline` + - `src/web/run_ops` + - 负责 run 创建、推进、停止、重启与状态投影 +- manifest 与兼容层: + - `src/web/manifest` + - 兼容修补集中入口(路径重写、导入语义迁移、缺失产物降级) +- 导入导出小说包: + - `src/web/run_ops/packages.py` + - 负责 package manifest 校验、schema 兼容、导入导出边界 +- 对话运行态: + - `src/web/chat` + - canonical `session state` 的读写与派生视图 +- 前端展示层: + - `src/web/static` + - 只消费 canonical/derived 数据,不新增后端兼容补丁 + +约束: + +- 新增代码若涉及边界变更,必须同时更新本节映射与对应测试断言 +- 目录职责冲突时,优先保持“兼容层集中、入口轻量、业务层单一职责” diff --git a/docs/stage-closure-checklist.md b/docs/stage-closure-checklist.md index f65d029..dffff02 100644 --- a/docs/stage-closure-checklist.md +++ b/docs/stage-closure-checklist.md @@ -28,7 +28,8 @@ ### 待完成 - [x] 整理安装 / 更新 / 运行 / 导入导出的一份 runtime contract 文档 -- [ ] 做一轮跨平台主流程实机回归清单 +- [x] 做一轮跨平台主流程实机回归清单 + - 已落地发版前 gate:`docs/release-regression-signoff.json` + `scripts/release_regression_gate.py` ## 阶段 2:让聊天真正“活起来” @@ -56,11 +57,11 @@ - [x] 内置小说 / 本地运行小说 / 导入小说包三类资产已基本分流 - [x] 已有一批针对旧路径、Windows 路径和旧 manifest 的兼容修复 -### 部分完成 +### 已完成 -- [ ] package manifest 的兼容规则还没有形成正式冻结文档 -- [ ] schema version 已经有了,但兼容策略仍散在多个模块 -- [ ] 缺失 artifact / graph / payload 的降级策略还没有统一成一层 +- [x] package manifest 的兼容规则已形成正式冻结文档 +- [x] schema version 兼容策略已集中到包兼容入口(含未知版本拒绝与 legacy 回填) +- [x] 缺失 artifact / graph / payload 的降级策略已统一进入兼容层(集中在 manifest compatibility) ### 待完成 @@ -83,4 +84,4 @@ 2. 再做一轮对话压缩质量提升 3. 继续打磨时间推进 / 离场回场 / 场景推进自然度 4. 继续减少前端 dual-path 遗留 -5. 做一轮跨平台主流程实机回归清单 +5. 做一轮跨平台主流程实机回归清单(已纳入发版 gate) diff --git a/scripts/dev_checks.py b/scripts/dev_checks.py index c6104ef..2e6f501 100644 --- a/scripts/dev_checks.py +++ b/scripts/dev_checks.py @@ -33,6 +33,11 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Run prompt-first guardrail tests without the full test suite.", ) + parser.add_argument( + "--release-tag", + default="", + help="Optional release tag for cross-platform regression gate, for example v2026.05.16.", + ) return parser.parse_args() @@ -44,6 +49,10 @@ def main() -> int: print("[done] smoke checks passed") return 0 + gate_command = [sys.executable, "scripts/release_regression_gate.py"] + if str(args.release_tag or "").strip(): + gate_command.extend(["--release-tag", str(args.release_tag).strip()]) + run_step("run release regression gate", gate_command) run_step("run unit tests", [sys.executable, "-m", "unittest", "discover", "-s", "tests"]) print("[done] development checks passed") return 0 diff --git a/scripts/release_regression_gate.py b/scripts/release_regression_gate.py new file mode 100644 index 0000000..ec7f066 --- /dev/null +++ b/scripts/release_regression_gate.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +REQUIRED_PLATFORMS = ("windows", "wsl", "linux", "termux") +REQUIRED_CHECKS = ("install", "update", "run", "import_export") +ALLOWED_STATUS = {"pass", "fail", "pending"} + + +def load_signoff(path: Path) -> dict: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("signoff payload must be a JSON object") + return payload + + +def evaluate_signoff(payload: dict, *, expected_release_tag: str = "") -> list[str]: + errors: list[str] = [] + release_tag = str(payload.get("release_tag", "")).strip() + checked_at = str(payload.get("checked_at", "")).strip() + checked_by = payload.get("checked_by") + platforms = payload.get("platforms") + + if not release_tag: + errors.append("missing release_tag") + if expected_release_tag and release_tag != expected_release_tag: + errors.append(f"release_tag mismatch: expected {expected_release_tag}, got {release_tag or ''}") + if not checked_at: + errors.append("missing checked_at") + if not isinstance(checked_by, list) or not [item for item in checked_by if str(item).strip()]: + errors.append("checked_by must contain at least one reviewer") + if not isinstance(platforms, dict): + errors.append("platforms must be an object") + return errors + + for platform in REQUIRED_PLATFORMS: + checks = platforms.get(platform) + if not isinstance(checks, dict): + errors.append(f"platform '{platform}' is missing") + continue + for check in REQUIRED_CHECKS: + value = str(checks.get(check, "")).strip().lower() + if value not in ALLOWED_STATUS: + errors.append(f"{platform}.{check} must be one of {sorted(ALLOWED_STATUS)}") + continue + if value != "pass": + errors.append(f"{platform}.{check} is '{value}', expected 'pass'") + + return errors + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate cross-platform release regression signoff.") + parser.add_argument( + "--signoff", + default="docs/release-regression-signoff.json", + help="Path to release signoff JSON relative to repo root.", + ) + parser.add_argument( + "--release-tag", + default="", + help="Optional expected release tag, for example v2026.05.16.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + repo_root = Path(__file__).resolve().parent.parent + signoff_path = (repo_root / args.signoff).resolve() + if not signoff_path.exists(): + raise FileNotFoundError(f"missing signoff file: {signoff_path}") + + payload = load_signoff(signoff_path) + errors = evaluate_signoff(payload, expected_release_tag=str(args.release_tag or "").strip()) + if errors: + print("[fail] release regression gate is not signed off:") + for item in errors: + print(f"- {item}") + return 1 + + print(f"[done] release regression gate passed: {signoff_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_skill.py b/scripts/release_skill.py index b939f59..3e88b38 100644 --- a/scripts/release_skill.py +++ b/scripts/release_skill.py @@ -28,6 +28,7 @@ def release_skill( smoke_only: bool = False, bump_web_assets: bool = False, static_version: str = "", + release_tag: str = "", ) -> Path: if version: _sync_skill_version.sync_skill_version(skill_dir, version) @@ -43,6 +44,8 @@ def release_skill( command = [sys.executable, str(repo_root / "scripts" / "dev_checks.py")] if smoke_only: command.append("--smoke-only") + if str(release_tag or "").strip(): + command.extend(["--release-tag", str(release_tag).strip()]) subprocess.run(command, cwd=repo_root, check=True) return _package_skill.build_archive( @@ -75,6 +78,11 @@ def main() -> int: default="", help="Optional explicit web static asset version. Implies refreshing web assets before release packaging.", ) + parser.add_argument( + "--release-tag", + default="", + help="Optional release tag to enforce cross-platform regression signoff, for example v2026.05.16.", + ) args = parser.parse_args() repo_root = Path(__file__).resolve().parents[1] @@ -95,6 +103,7 @@ def main() -> int: smoke_only=bool(args.smoke_only), bump_web_assets=not bool(args.no_bump_web_assets) and not bool(str(args.static_version or "").strip()), static_version=str(args.static_version or "").strip(), + release_tag=str(args.release_tag or "").strip(), ) resolved_static_version = _web_asset_version.read_web_asset_version(repo_root) print(f"Web static asset version: {resolved_static_version}") diff --git a/src/web/manifest/__init__.py b/src/web/manifest/__init__.py index 1addf4a..8a7bab2 100644 --- a/src/web/manifest/__init__.py +++ b/src/web/manifest/__init__.py @@ -1,5 +1,12 @@ -from .compat import coerce_manifest_path, relative_to_run_dir, rewrite_run_root_paths, rewrite_string_path +from .compat import ( + apply_imported_run_semantics, + coerce_manifest_path, + reconcile_discovered_artifacts, + relative_to_run_dir, + rewrite_run_root_paths, + rewrite_string_path, +) from .store import ( ensure_run_exists, load_json_file, @@ -18,6 +25,7 @@ __all__ = [ "build_file_urls", + "apply_imported_run_semantics", "coerce_manifest_path", "discover_artifacts", "ensure_run_exists", @@ -25,6 +33,7 @@ "load_json_file", "load_manifest", "manifest_path", + "reconcile_discovered_artifacts", "reconcile_loaded_manifest", "relative_to_run_dir", "require_manifest", diff --git a/src/web/manifest/compat.py b/src/web/manifest/compat.py index f79d045..fde88c9 100644 --- a/src/web/manifest/compat.py +++ b/src/web/manifest/compat.py @@ -69,6 +69,61 @@ def rewrite_string_path(text: str, *, source_root: Path, target_root: Path) -> s return raw +def apply_imported_run_semantics( + manifest: dict[str, Any], + *, + target_root: Path, + new_run_id: str, + imported_at: str, + package_filename: str, + builtin_source: bool, +) -> dict[str, Any]: + rewritten = manifest + rewritten["run_id"] = new_run_id + rewritten["created_at"] = imported_at + rewritten["updated_at"] = imported_at + rewritten["entrypoint"] = "builtin" if builtin_source else "import" + rewritten["control"] = { + "stop_requested": False, + "stop_requested_at": "", + "stop_acknowledged_at": "", + } + rewritten.setdefault("timing", {}) + rewritten["timing"]["started_at"] = "" + rewritten["timing"]["completed_at"] = "" + rewritten["timing"]["failed_at"] = "" + rewritten["timing"]["stopped_at"] = "" + rewritten["timing"]["elapsed_seconds"] = 0.0 + rewritten["timing"]["elapsed_text"] = "" + rewritten["imported_from"] = { + "package_filename": package_filename, + "builtin_source": builtin_source, + "imported_at": imported_at, + } + rewritten.setdefault("webui", {}) + novel_id = str(rewritten.get("novel_id", "")).strip() + rewritten["webui"]["run_dir"] = str(target_root) + rewritten["webui"]["input_dir"] = str((target_root / "input").resolve()) + rewritten["webui"]["payload_dir"] = str((target_root / "payloads").resolve()) + rewritten["webui"]["artifact_dir"] = str((target_root / "artifacts").resolve()) + rewritten["webui"]["workspace"] = { + "characters_root": str((target_root / "artifacts" / "characters" / novel_id).resolve()), + "relations_root": str((target_root / "artifacts" / "relations").resolve()), + } + rewritten.pop("file_urls", None) + rewritten.setdefault("events", []).append( + { + "stage": "builtin_cloned" if builtin_source else "run_imported", + "status": "complete", + "message": "已从内置书卷创建本地副本。" if builtin_source else "已导入小说包并生成本地书卷。", + "character": "", + "capability": "verify_workflow", + "timestamp": imported_at, + } + ) + return rewritten + + def relative_candidates(path: Path, run_dir: Path) -> list[tuple[Path, Path]]: path_obj = Path(path) run_dir_obj = Path(run_dir) @@ -91,3 +146,62 @@ def relative_candidates(path: Path, run_dir: Path) -> list[tuple[Path, Path]]: def normalized_parts(path: Path) -> tuple[str, ...]: resolved = Path(path).resolve(strict=False) return tuple(part.casefold() for part in resolved.parts) + + +def reconcile_discovered_artifacts( + manifest: dict[str, Any], + *, + character_index: list[dict[str, Any]], + relation_graph: dict[str, Any] | None, +) -> dict[str, Any]: + updated = manifest + artifacts = updated.setdefault("artifacts", {}) + artifact_index = updated.setdefault("artifact_index", {}) + progress = updated.setdefault("progress", {}) + + cards = [item for item in list(character_index or []) if isinstance(item, dict)] + artifact_index["characters"] = cards + artifacts["character_dirs"] = { + str(item.get("name", "")).strip(): str(item.get("persona_dir", "")).strip() + for item in cards + if str(item.get("name", "")).strip() and str(item.get("persona_dir", "")).strip() + } + + completed_names = [str(item.get("name", "")).strip() for item in cards if str(item.get("name", "")).strip()] + progress["completed_characters"] = completed_names + progress["completed_count"] = len(completed_names) + locked_characters = [str(item).strip() for item in list(updated.get("locked_characters", []) or []) if str(item).strip()] + if locked_characters and len(completed_names) >= len(locked_characters): + progress["current_character"] = "" + + if relation_graph: + relation = dict(relation_graph) + artifacts["relation_graph"] = relation + artifact_index["relation_graph"] = relation + progress["graph_status"] = "complete" + else: + artifacts["relation_graph"] = {} + artifact_index["relation_graph"] = {} + graph_status = str(progress.get("graph_status", "")).strip() + if graph_status not in {"failed", "running"}: + progress["graph_status"] = "pending" + artifacts["payloads"] = _filter_existing_payloads(artifacts.get("payloads", {})) + return updated + + +def _filter_existing_payloads(payloads: Any) -> dict[str, str]: + if not isinstance(payloads, dict): + return {} + filtered: dict[str, str] = {} + for key, value in payloads.items(): + name = str(key).strip() + path_text = str(value).strip() + if not name or not path_text: + continue + try: + if not Path(path_text).exists(): + continue + except (OSError, ValueError): + continue + filtered[name] = path_text + return filtered diff --git a/src/web/manifest/store.py b/src/web/manifest/store.py index 088e15d..1697b12 100644 --- a/src/web/manifest/store.py +++ b/src/web/manifest/store.py @@ -6,6 +6,8 @@ from pathlib import Path from typing import Any, Callable +from src.web.run_ops.state import project_manifest_summary + def manifest_path(runs_root: Path, run_id: str) -> Path: return runs_root / run_id / "run_manifest.json" @@ -102,13 +104,9 @@ def reconcile_loaded_manifest( progress["stage"] = "stopped" current_character = str(progress.get("current_character", "")).strip() progress["message"] = f"已停止蒸馏,停在 {current_character}。" if current_character else "这次蒸馏已停止。" - summary = manifest.setdefault("summary", {}) - summary["status_text"] = "stopped" control["stop_acknowledged_at"] = str(control.get("stop_acknowledged_at", "")).strip() or now_text manifest["control"] = control finalize_manifest_timing(manifest, "stopped") - if manifest.get("timing", {}).get("elapsed_text"): - summary["elapsed_text"] = manifest["timing"]["elapsed_text"] manifest.setdefault("capabilities", {})["verify_workflow"] = { "status": "stopped", "success": False, @@ -127,5 +125,6 @@ def reconcile_loaded_manifest( "timestamp": now_text, } ) + project_manifest_summary(manifest) return manifest, True return manifest, False diff --git a/src/web/manifest/views.py b/src/web/manifest/views.py index 6ae10b3..e51dee2 100644 --- a/src/web/manifest/views.py +++ b/src/web/manifest/views.py @@ -4,7 +4,9 @@ from pathlib import Path from typing import Any, Callable -from .compat import coerce_manifest_path, relative_to_run_dir +from src.web.run_ops.state import project_manifest_summary + +from .compat import coerce_manifest_path, reconcile_discovered_artifacts, relative_to_run_dir def serialize_manifest(payload: dict[str, Any], *, run_id: str, file_urls: dict[str, str]) -> dict[str, Any]: @@ -31,37 +33,19 @@ def discover_artifacts( relations_root = Path(str(workspace.get("relations_root", ""))).resolve() if workspace.get("relations_root") else None character_index = discover_character_cards(characters_root) - if character_index: - updated.setdefault("artifacts", {}).setdefault("character_dirs", {}) - updated["artifacts"]["character_dirs"] = { - item["name"]: item["persona_dir"] for item in character_index - } - updated.setdefault("artifact_index", {})["characters"] = character_index - completed_names = [item["name"] for item in character_index] - updated.setdefault("progress", {})["completed_characters"] = completed_names - updated["progress"]["completed_count"] = len(completed_names) - if updated.get("locked_characters") and len(completed_names) >= len(updated["locked_characters"]): - if updated["progress"].get("graph_status") == "complete": - updated["summary"]["status_text"] = "waiting_for_verification" - else: - updated["summary"]["status_text"] = "graph_pending" - updated["progress"]["current_character"] = "" - updated["summary"]["characters_completed"] = len(completed_names) - relation_graph = discover_relation_graph(relations_root, artifact_dir, run_dir) - if relation_graph: - updated.setdefault("artifacts", {})["relation_graph"] = relation_graph - updated.setdefault("artifact_index", {})["relation_graph"] = relation_graph - updated.setdefault("progress", {})["graph_status"] = "complete" - if updated["summary"].get("status_text") in {"waiting_for_payloads", "waiting_for_host_generation", "graph_pending"}: - updated["summary"]["status_text"] = "graph_ready" - updated["summary"]["graph_status"] = "complete" + updated = reconcile_discovered_artifacts( + updated, + character_index=character_index, + relation_graph=relation_graph, + ) updated.setdefault("progress", {}).setdefault( "chunking", build_progress_chunking_from_artifacts(updated.get("artifacts", {}).get("chunking", {})), ) updated.setdefault("summary", {})["chunking"] = build_summary_chunking(updated.get("progress", {}).get("chunking", {})) + project_manifest_summary(updated) return updated diff --git a/src/web/pipeline/background_runner.py b/src/web/pipeline/background_runner.py index 1b7ab3a..3e69d73 100644 --- a/src/web/pipeline/background_runner.py +++ b/src/web/pipeline/background_runner.py @@ -5,12 +5,13 @@ from pathlib import Path from typing import Any, Callable +from src.web.run_ops.state import project_manifest_summary + def prepare_background_manifest(manifest: dict[str, Any], *, utc_now: Callable[[], str]) -> dict[str, Any]: manifest["updated_at"] = utc_now() manifest.setdefault("progress", {})["stage"] = "queued" manifest["progress"]["message"] = "已开始蒸馏任务" - manifest.setdefault("summary", {})["status_text"] = "waiting_for_payloads" manifest.setdefault("events", []).append( { "stage": "queued", @@ -21,6 +22,7 @@ def prepare_background_manifest(manifest: dict[str, Any], *, utc_now: Callable[[ "timestamp": utc_now(), } ) + project_manifest_summary(manifest) return manifest diff --git a/src/web/pipeline/progress.py b/src/web/pipeline/progress.py index 1f476e8..7a5aee3 100644 --- a/src/web/pipeline/progress.py +++ b/src/web/pipeline/progress.py @@ -2,6 +2,8 @@ from typing import Any, Callable +from src.web.run_ops.state import project_manifest_summary + def apply_distill_progress( current: dict[str, Any], @@ -160,9 +162,7 @@ def finalize_workflow_success( refreshed["success"] = True refreshed["updated_at"] = utc_now() finalize_manifest_timing(refreshed, "completed") - refreshed.setdefault("summary", {})["status_text"] = "workflow_complete" - if refreshed.get("timing", {}).get("elapsed_text"): - refreshed["summary"]["elapsed_text"] = refreshed["timing"]["elapsed_text"] + refreshed.setdefault("summary", {}) refreshed.setdefault("capabilities", {})["distill"] = { "status": "complete", "success": True, @@ -197,6 +197,7 @@ def finalize_workflow_success( "timestamp": utc_now(), } ) + project_manifest_summary(refreshed) def finalize_workflow_success_without_graph( @@ -210,10 +211,7 @@ def finalize_workflow_success_without_graph( refreshed["success"] = True refreshed["updated_at"] = utc_now() finalize_manifest_timing(refreshed, "completed") - refreshed.setdefault("summary", {})["status_text"] = "workflow_complete" - refreshed["summary"]["graph_status"] = "failed" - if refreshed.get("timing", {}).get("elapsed_text"): - refreshed["summary"]["elapsed_text"] = refreshed["timing"]["elapsed_text"] + refreshed.setdefault("summary", {}) progress = refreshed.setdefault("progress", {}) progress["graph_status"] = "failed" @@ -255,6 +253,7 @@ def finalize_workflow_success_without_graph( "timestamp": utc_now(), } ) + project_manifest_summary(refreshed) refreshed.setdefault("events", []).append( { "stage": "workflow_complete", @@ -282,9 +281,7 @@ def finalize_workflow_stopped( stopped["success"] = False stopped["updated_at"] = utc_now() finalize_manifest_timing(stopped, "stopped") - stopped.setdefault("summary", {})["status_text"] = "stopped" - if stopped.get("timing", {}).get("elapsed_text"): - stopped["summary"]["elapsed_text"] = stopped["timing"]["elapsed_text"] + stopped.setdefault("summary", {}) progress = stopped.setdefault("progress", {}) progress["stage"] = "stopped" progress["message"] = message @@ -318,6 +315,7 @@ def finalize_workflow_stopped( "timestamp": utc_now(), } ) + project_manifest_summary(stopped) def finalize_workflow_failed( @@ -331,9 +329,7 @@ def finalize_workflow_failed( failed["success"] = False failed["updated_at"] = utc_now() finalize_manifest_timing(failed, "failed") - failed.setdefault("summary", {})["status_text"] = "failed" - if failed.get("timing", {}).get("elapsed_text"): - failed["summary"]["elapsed_text"] = failed["timing"]["elapsed_text"] + failed.setdefault("summary", {}) failed.setdefault("progress", {})["message"] = message failed.setdefault("events", []).append( { @@ -356,3 +352,4 @@ def finalize_workflow_failed( "timestamp": utc_now(), } ) + project_manifest_summary(failed) diff --git a/src/web/run_ops/__init__.py b/src/web/run_ops/__init__.py index cf91cb0..b16365a 100644 --- a/src/web/run_ops/__init__.py +++ b/src/web/run_ops/__init__.py @@ -20,7 +20,14 @@ estimate_text_length, is_model_configured_payload, ) -from .state import finalize_manifest_timing, format_elapsed_text, is_stop_requested +from .state import ( + derive_summary_graph_status, + derive_summary_status_text, + finalize_manifest_timing, + format_elapsed_text, + is_stop_requested, + project_manifest_summary, +) from .status import refresh_run_manifest, stop_run_manifest from .utils import decode_base64_text, new_run_id, normalize_characters @@ -34,6 +41,8 @@ "build_novel_source_entry", "build_package_filename", "build_runtime_config_for_run", + "derive_summary_graph_status", + "derive_summary_status_text", "classify_requested_characters", "decode_base64_text", "delete_run_group", @@ -52,6 +61,7 @@ "normalize_characters", "normalize_model_settings", "prepare_restart_novel_source", + "project_manifest_summary", "refresh_run_manifest", "stop_run_manifest", "validate_model_settings", diff --git a/src/web/run_ops/creation.py b/src/web/run_ops/creation.py index 591e04c..938d5fe 100644 --- a/src/web/run_ops/creation.py +++ b/src/web/run_ops/creation.py @@ -3,6 +3,8 @@ from pathlib import Path from typing import Any, Callable +from .state import project_manifest_summary + def ensure_run_workspace(run_dir: Path) -> dict[str, Path]: input_dir = run_dir / "input" @@ -30,7 +32,7 @@ def build_initial_run_manifest( utc_now: Callable[[], str], ) -> dict[str, Any]: now = utc_now() - return { + manifest = { "kind": "zaomeng_web_run", "schema_version": 1, "run_id": run_id, @@ -115,6 +117,8 @@ def build_initial_run_manifest( "artifact_dir": str(workspace["artifact_dir"].resolve()), }, } + project_manifest_summary(manifest) + return manifest def attach_workspace_roots(manifest: dict[str, Any], *, characters_root: Path, relations_root: Path) -> None: @@ -143,7 +147,6 @@ def apply_manual_payload_manifest_state( manifest["progress"]["stage"] = "relation_payload_ready" manifest["progress"]["message"] = "蒸馏与关系提取 payload 已准备完成" manifest["updated_at"] = now - manifest["summary"]["status_text"] = "waiting_for_host_generation" manifest["capabilities"]["distill"] = { "status": "ready", "success": False, @@ -210,4 +213,5 @@ def apply_manual_payload_manifest_state( "timestamp": now, } ) + project_manifest_summary(manifest) return manifest diff --git a/src/web/run_ops/packages.py b/src/web/run_ops/packages.py index 55cc342..d0804a9 100644 --- a/src/web/run_ops/packages.py +++ b/src/web/run_ops/packages.py @@ -7,11 +7,14 @@ from pathlib import Path from typing import Any, Callable -from src.web.manifest.compat import rewrite_run_root_paths +from src.web.manifest.compat import apply_imported_run_semantics, rewrite_run_root_paths +from src.web.run_ops.state import derive_summary_graph_status, derive_summary_status_text from src.utils.file_utils import safe_filename PACKAGE_KIND = "zaomeng_web_run_package" PACKAGE_SCHEMA_VERSION = 1 +PACKAGE_LEGACY_SCHEMA_VERSION = 0 +SUPPORTED_PACKAGE_SCHEMA_VERSIONS = {PACKAGE_LEGACY_SCHEMA_VERSION, PACKAGE_SCHEMA_VERSION} PACKAGE_SUFFIX = ".zaomeng-run.zip" PACKAGE_ROOT = "run" @@ -134,6 +137,7 @@ def import_run_package( with tempfile.TemporaryDirectory(prefix="zaomeng-import-") as tmpdir: extract_root = Path(tmpdir) with zipfile.ZipFile(package_path) as archive: + _read_package_manifest(archive) archive.extractall(extract_root) source_run_dir = extract_root / PACKAGE_ROOT if not source_run_dir.exists(): @@ -173,50 +177,14 @@ def rewrite_imported_run_manifest( source_root = source_run_dir.resolve(strict=False) target_root = target_run_dir.resolve(strict=False) rewritten = rewrite_run_root_paths(manifest, source_root=source_root, target_root=target_root) - - rewritten["run_id"] = new_run_id - rewritten["created_at"] = imported_at - rewritten["updated_at"] = imported_at - rewritten["entrypoint"] = "builtin" if builtin_source else "import" - rewritten["control"] = { - "stop_requested": False, - "stop_requested_at": "", - "stop_acknowledged_at": "", - } - rewritten.setdefault("timing", {}) - rewritten["timing"]["started_at"] = "" - rewritten["timing"]["completed_at"] = "" - rewritten["timing"]["failed_at"] = "" - rewritten["timing"]["stopped_at"] = "" - rewritten["timing"]["elapsed_seconds"] = 0.0 - rewritten["timing"]["elapsed_text"] = "" - rewritten.setdefault("imported_from", {}) - rewritten["imported_from"] = { - "package_filename": package_filename, - "builtin_source": builtin_source, - "imported_at": imported_at, - } - rewritten.setdefault("webui", {}) - rewritten["webui"]["run_dir"] = str(target_root) - rewritten["webui"]["input_dir"] = str((target_root / "input").resolve()) - rewritten["webui"]["payload_dir"] = str((target_root / "payloads").resolve()) - rewritten["webui"]["artifact_dir"] = str((target_root / "artifacts").resolve()) - rewritten["webui"]["workspace"] = { - "characters_root": str((target_root / "artifacts" / "characters" / str(rewritten.get("novel_id", "")).strip()).resolve()), - "relations_root": str((target_root / "artifacts" / "relations").resolve()), - } - rewritten.pop("file_urls", None) - rewritten.setdefault("events", []).append( - { - "stage": "run_imported" if not builtin_source else "builtin_cloned", - "status": "complete", - "message": "已从内置书卷创建本地副本。" if builtin_source else "已导入小说包并生成本地书卷。", - "character": "", - "capability": "verify_workflow", - "timestamp": imported_at, - } + return apply_imported_run_semantics( + rewritten, + target_root=target_root, + new_run_id=new_run_id, + imported_at=imported_at, + package_filename=package_filename, + builtin_source=builtin_source, ) - return rewritten def _build_package_manifest( @@ -240,8 +208,8 @@ def _build_package_manifest( "character_count": len(characters), "has_relation_graph": bool(relation_graph.get("relations_file")), "summary": { - "status_text": str(manifest.get("summary", {}).get("status_text", "")).strip(), - "graph_status": str(manifest.get("summary", {}).get("graph_status", "")).strip(), + "status_text": derive_summary_status_text(manifest), + "graph_status": derive_summary_graph_status(manifest), }, "exported_at": exported_at, "updated_at": str(manifest.get("updated_at", "")).strip(), @@ -264,7 +232,79 @@ def _read_package_manifest(archive: zipfile.ZipFile) -> dict[str, Any]: raise ValueError("小说包元数据格式不正确。") if str(payload.get("kind", "")).strip() != PACKAGE_KIND: raise ValueError("不是可识别的造梦小说包。") - return payload + return _normalize_package_manifest(payload) + + +def _normalize_package_manifest(payload: dict[str, Any]) -> dict[str, Any]: + schema_version = _coerce_schema_version(payload.get("schema_version"), default=PACKAGE_LEGACY_SCHEMA_VERSION) + if schema_version not in SUPPORTED_PACKAGE_SCHEMA_VERSIONS: + raise ValueError( + f"小说包 schema_version={schema_version} 暂不支持(当前支持:{sorted(SUPPORTED_PACKAGE_SCHEMA_VERSIONS)})。" + ) + normalized = _migrate_legacy_package_manifest(payload, schema_version=schema_version) + summary_payload = dict(normalized.get("summary", {}) or {}) + status = str(normalized.get("status", "")).strip() + graph_status = str(summary_payload.get("graph_status", "")).strip() + if not graph_status: + graph_status = "complete" if bool(normalized.get("has_relation_graph", False)) else "pending" + normalized["summary"] = { + "status_text": str(summary_payload.get("status_text", "")).strip() or status, + "graph_status": graph_status, + } + normalized["builtin"] = bool(normalized.get("builtin", False)) + normalized["character_count"] = _coerce_non_negative_int(normalized.get("character_count"), default=0) + normalized["has_relation_graph"] = bool(normalized.get("has_relation_graph", False)) + normalized["schema_version"] = PACKAGE_SCHEMA_VERSION + return normalized + + +def _migrate_legacy_package_manifest(payload: dict[str, Any], *, schema_version: int) -> dict[str, Any]: + normalized = dict(payload) + if schema_version == PACKAGE_LEGACY_SCHEMA_VERSION: + relation_graph = bool(normalized.get("has_relation_graph", False)) + summary_payload = dict(normalized.get("summary", {}) or {}) + normalized["summary"] = { + "status_text": str(summary_payload.get("status_text", "")).strip() or str(normalized.get("status", "")).strip(), + "graph_status": str(summary_payload.get("graph_status", "")).strip() or ("complete" if relation_graph else "pending"), + } + normalized["builtin"] = bool(normalized.get("builtin", False)) + normalized["character_count"] = _coerce_non_negative_int(normalized.get("character_count"), default=0) + normalized["has_relation_graph"] = relation_graph + return normalized + + +def _coerce_schema_version(value: Any, *, default: int) -> int: + if value is None: + return default + if isinstance(value, bool): + return int(value) + if isinstance(value, (int, float)): + return int(value) + text = str(value).strip() + if not text: + return default + try: + return int(text) + except ValueError as exc: + raise ValueError(f"小说包 schema_version 非法:{value!r}") from exc + + +def _coerce_non_negative_int(value: Any, *, default: int) -> int: + if value is None: + return default + if isinstance(value, bool): + coerced = int(value) + elif isinstance(value, (int, float)): + coerced = int(value) + else: + text = str(value).strip() + if not text: + return default + try: + coerced = int(text) + except ValueError: + return default + return max(coerced, 0) def _strip_export_only_paths(run_dir: Path) -> None: diff --git a/src/web/run_ops/restart.py b/src/web/run_ops/restart.py index f6dfcf8..279d296 100644 --- a/src/web/run_ops/restart.py +++ b/src/web/run_ops/restart.py @@ -4,6 +4,7 @@ from typing import Any, Callable from src.utils.file_utils import safe_filename +from .state import project_manifest_summary def classify_requested_characters( @@ -191,4 +192,5 @@ def apply_restart_manifest_state( "timestamp": now, } ) + project_manifest_summary(manifest) return manifest diff --git a/src/web/run_ops/state.py b/src/web/run_ops/state.py index dd41258..774242b 100644 --- a/src/web/run_ops/state.py +++ b/src/web/run_ops/state.py @@ -5,6 +5,73 @@ from typing import Any, Callable +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def derive_summary_graph_status(manifest: dict[str, Any]) -> str: + progress = dict(manifest.get("progress", {}) or {}) + progress_graph_status = str(progress.get("graph_status", "")).strip() + if progress_graph_status: + return progress_graph_status + summary_graph_status = str((manifest.get("summary", {}) or {}).get("graph_status", "")).strip() + return summary_graph_status + + +def derive_summary_status_text(manifest: dict[str, Any]) -> str: + status = str(manifest.get("status", "")).strip() + progress = dict(manifest.get("progress", {}) or {}) + control = dict(manifest.get("control", {}) or {}) + stage = str(progress.get("stage", "")).strip() + total_characters = max(0, _safe_int(progress.get("total_characters", 0))) + completed_characters = max(0, _safe_int(progress.get("completed_count", 0))) + graph_status = derive_summary_graph_status(manifest) + locked_characters = list(manifest.get("locked_characters", []) or []) + if total_characters <= 0 and locked_characters: + total_characters = len(locked_characters) + + if status == "ready": + return "workflow_complete" + if status == "failed": + return "failed" + if status == "stopped": + return "stopped" + if status == "running": + if bool(control.get("stop_requested", False)): + return "stop_requested" + if stage == "relation_payload_ready": + return "waiting_for_host_generation" + if graph_status == "complete": + if total_characters > 0 and completed_characters >= total_characters: + return "waiting_for_verification" + return "graph_ready" + if total_characters > 0 and completed_characters >= total_characters and graph_status in {"", "pending", "running"}: + return "graph_pending" + return "waiting_for_payloads" + return str((manifest.get("summary", {}) or {}).get("status_text", "")).strip() or "waiting_for_payloads" + + +def project_manifest_summary(manifest: dict[str, Any]) -> dict[str, Any]: + summary = manifest.setdefault("summary", {}) + progress = dict(manifest.get("progress", {}) or {}) + total_characters = max(0, _safe_int(progress.get("total_characters", 0))) + completed_characters = max(0, _safe_int(progress.get("completed_count", 0))) + if total_characters <= 0: + total_characters = len(list(manifest.get("locked_characters", []) or [])) + summary["characters_total"] = total_characters + summary["characters_completed"] = completed_characters + summary["graph_status"] = derive_summary_graph_status(manifest) or "pending" + summary["status_text"] = derive_summary_status_text(manifest) + timing = dict(manifest.get("timing", {}) or {}) + elapsed_text = str(timing.get("elapsed_text", "")).strip() + if elapsed_text: + summary["elapsed_text"] = elapsed_text + return summary + + def format_elapsed_text(seconds: float) -> str: total = max(0, int(round(seconds))) minutes, remain = divmod(total, 60) diff --git a/src/web/run_ops/status.py b/src/web/run_ops/status.py index 8670a5f..b8ab3ee 100644 --- a/src/web/run_ops/status.py +++ b/src/web/run_ops/status.py @@ -2,6 +2,8 @@ from typing import Any, Callable +from .state import project_manifest_summary + def refresh_run_manifest( manifest: dict[str, Any], @@ -32,8 +34,6 @@ def stop_run_manifest( control["stop_requested_at"] = now_text progress = manifest.setdefault("progress", {}) progress["message"] = "已收到停止请求,正在收束当前步骤" - summary = manifest.setdefault("summary", {}) - summary["status_text"] = "stop_requested" manifest["updated_at"] = now_text manifest.setdefault("events", []).append( { @@ -45,4 +45,5 @@ def stop_run_manifest( "timestamp": now_text, } ) + project_manifest_summary(manifest) return manifest diff --git a/src/web/static/index.html b/src/web/static/index.html index 29ce4eb..5dda4d4 100644 --- a/src/web/static/index.html +++ b/src/web/static/index.html @@ -4,7 +4,7 @@ 造梦 - +
@@ -12,6 +12,6 @@
- + diff --git a/src/web/static/js/bookshelf-state.js b/src/web/static/js/bookshelf-state.js index c3d9dbe..9d859fd 100644 --- a/src/web/static/js/bookshelf-state.js +++ b/src/web/static/js/bookshelf-state.js @@ -16,7 +16,7 @@ function buildBookshelfItems(runs, currentRunIdValue = "", currentRunValue = null) { const groupedRuns = typeof aggregateRunsByNovel === "function" ? aggregateRunsByNovel(runs || []) : (runs || []); return groupedRuns.map((run) => { - const status = typeof humanizeSummary === "function" ? humanizeSummary(run?.summary?.status_text) : String(run?.summary?.status_text || "未开始"); + const status = typeof humanizeSummary === "function" ? humanizeSummary(runLifecycleState(run)) : String(runLifecycleState(run) || "未开始"); const updatedAt = typeof formatWeakTime === "function" ? formatWeakTime(run?.updated_at || "") : ""; const characterCount = typeof getRunCharacterNames === "function" ? getRunCharacterNames(run).length : 0; const cardState = getBookshelfCardState(run); diff --git a/src/web/static/js/bookshelf.js b/src/web/static/js/bookshelf.js index 8062b43..0647c06 100644 --- a/src/web/static/js/bookshelf.js +++ b/src/web/static/js/bookshelf.js @@ -75,7 +75,7 @@ function renderRunFallbackFromBookshelf(run) { sourceHistoryExpanded = false; characterReadinessExpanded = false; workSessionPreviewExpanded = false; - runCreationPending = run.status === "running" && run.summary?.status_text !== "workflow_complete"; + runCreationPending = run.status === "running" && !isRunWorkflowComplete(run); if (typeof resetDialogueView === "function") { resetDialogueView(); } diff --git a/src/web/static/js/bootstrap.js b/src/web/static/js/bootstrap.js index b68b2ba..5323f1e 100644 --- a/src/web/static/js/bootstrap.js +++ b/src/web/static/js/bootstrap.js @@ -1,5 +1,5 @@ (() => { - const version = "20260514130914"; + const version = "20260516154853"; window.__ZAOMENG_WEB_UI_VERSION__ = version; const rootFragments = [ { id: "header-root", url: `/web/fragments/header.html?v=${version}` }, @@ -51,8 +51,8 @@ `/web/js/persona-review-legacy.js?v=${version}`, `/web/js/relation-details-legacy.js?v=${version}`, `/web/js/dialogue.js?v=${version}`, - `/web/js/main.js?v=${version}`, `/web/js/webui-api.js?v=${version}`, + `/web/js/main.js?v=${version}`, ]; const optionalScripts = [ `/web/js/bookshelf-vue-island.js?v=${version}`, diff --git a/src/web/static/js/core.js b/src/web/static/js/core.js index 270dad2..e62b262 100644 --- a/src/web/static/js/core.js +++ b/src/web/static/js/core.js @@ -277,10 +277,32 @@ function joinStatusParts(parts = []) { .join(" "); } +function normalizeChatFlowImpact(impact = "", affectsChatFlow = false) { + const trimmed = String(impact || "").trim(); + const note = affectsChatFlow ? "这会影响聊天主流程。" : "这不会影响聊天主流程。"; + if (!trimmed) { + return note; + } + if (trimmed.includes("聊天主流程")) { + return trimmed; + } + return `${trimmed} ${note}`.trim(); +} + function setFlowStatus(id, options = {}) { + const phase = String(options.phase || "").trim(); const message = String(options.message || "").trim(); - const impact = String(options.impact || "").trim(); - const nextStep = String(options.nextStep || "").trim(); + const affectsChatFlow = + options.chatFlowImpact === "affects" || + options.affectsChatFlow === true; + const impact = + phase === "failure" + ? normalizeChatFlowImpact(options.impact || "", affectsChatFlow) + : String(options.impact || "").trim(); + const nextStep = + phase === "success" && !String(options.nextStep || "").trim() + ? "可以继续下一步操作。" + : String(options.nextStep || "").trim(); setStatus(id, joinStatusParts([message, impact, nextStep])); } @@ -303,6 +325,7 @@ function setButtonBusy(target, pending, options = {}) { window.__ZAOMENG_FLOW_FEEDBACK__ = { joinStatusParts, + normalizeChatFlowImpact, setFlowStatus, setButtonBusy, }; @@ -1189,7 +1212,7 @@ function findRunById(runId) { function runSortScore(run) { const characterCount = (run?.artifact_index?.characters || []).length; - const status = String(run?.summary?.status_text || ""); + const status = runLifecycleState(run); const statusScore = status === "workflow_complete" ? 5 : status === "graph_ready" ? 4 : @@ -1199,6 +1222,45 @@ function runSortScore(run) { return statusScore * 100 + characterCount; } +function runGraphStatus(run) { + const progressStatus = String(run?.progress?.graph_status || "").trim(); + if (progressStatus) { + return progressStatus; + } + return String(run?.summary?.graph_status || "").trim(); +} + +function runLifecycleState(run) { + const status = String(run?.status || "").trim(); + const stage = String(run?.progress?.stage || "").trim(); + const graphStatus = runGraphStatus(run); + const total = Number(run?.progress?.total_characters || run?.locked_characters?.length || 0); + const completed = Number(run?.progress?.completed_count || run?.artifact_index?.characters?.length || 0); + const stopRequested = Boolean(run?.control?.stop_requested); + if (status === "ready") return "workflow_complete"; + if (status === "failed") return "failed"; + if (status === "stopped") return "stopped"; + if (status === "running") { + if (stopRequested) return "stop_requested"; + if (stage === "relation_payload_ready") return "waiting_for_host_generation"; + if (graphStatus === "complete") { + if (total > 0 && completed >= total) { + return "waiting_for_verification"; + } + return "graph_ready"; + } + if (total > 0 && completed >= total && (graphStatus === "pending" || graphStatus === "running" || graphStatus === "")) { + return "graph_pending"; + } + return "waiting_for_payloads"; + } + return String(run?.summary?.status_text || "").trim(); +} + +function isRunWorkflowComplete(run) { + return runLifecycleState(run) === "workflow_complete"; +} + function aggregateRunsByNovel(runs) { const grouped = new Map(); (runs || []).forEach((run) => { diff --git a/src/web/static/js/dialogue.js b/src/web/static/js/dialogue.js index 25dab2c..2557057 100644 --- a/src/web/static/js/dialogue.js +++ b/src/web/static/js/dialogue.js @@ -788,7 +788,7 @@ function renderRunFallbackForDialogue(run) { sourceHistoryExpanded = false; characterReadinessExpanded = false; workSessionPreviewExpanded = false; - runCreationPending = run.status === "running" && run.summary?.status_text !== "workflow_complete"; + runCreationPending = run.status === "running" && !isRunWorkflowComplete(run); if (typeof renderBookshelfDetail === "function") { renderBookshelfDetail(run); } diff --git a/src/web/static/js/main.js b/src/web/static/js/main.js index 2a43b9f..6c4113c 100644 --- a/src/web/static/js/main.js +++ b/src/web/static/js/main.js @@ -12,11 +12,15 @@ const UI_BRIDGE_TOOLS = window.__ZAOMENG_UI_BRIDGE_TOOLS__ || {}; const FLOW_FEEDBACK_TOOLS = window.__ZAOMENG_FLOW_FEEDBACK__ || {}; function setFlowStatusMessage(statusId, options = {}) { + const payload = { + ...options, + phase: options.phase || (options.impact ? "failure" : ""), + }; if (typeof FLOW_FEEDBACK_TOOLS.setFlowStatus === "function") { - FLOW_FEEDBACK_TOOLS.setFlowStatus(statusId, options); + FLOW_FEEDBACK_TOOLS.setFlowStatus(statusId, payload); return; } - setStatus(statusId, String(options.message || "").trim()); + setStatus(statusId, String(payload.message || "").trim()); } function setButtonBusyState(target, pending, options = {}) { @@ -40,6 +44,112 @@ function setDistillFlowStatus(statusId, options = {}) { setFlowStatusMessage(statusId, options); } +function setFlowLoadingStatus(statusId, message, nextStep = "") { + setFlowStatusMessage(statusId, { + phase: "loading", + message, + nextStep, + }); +} + +function setFlowSuccessStatus(statusId, message, nextStep = "") { + setFlowStatusMessage(statusId, { + phase: "success", + message, + nextStep, + }); +} + +function setFlowFailureStatus(statusId, message, nextStep = "", options = {}) { + setFlowStatusMessage(statusId, { + phase: "failure", + message, + impact: options.impact || "", + affectsChatFlow: Boolean(options.affectsChatFlow), + nextStep, + }); +} + +function setDialogueSessionLoading(message, nextStep = "") { + setFlowLoadingStatus("dialogue-session-status", message, nextStep); +} + +function setDialogueSessionSuccess(message, nextStep = "") { + setFlowSuccessStatus("dialogue-session-status", message, nextStep); +} + +function setDialogueSessionFailure(message, nextStep = "", affectsChatFlow = true) { + setFlowFailureStatus("dialogue-session-status", message, nextStep, { affectsChatFlow }); +} + +function setSceneCardLoading(message, nextStep = "") { + setFlowLoadingStatus("scene-card-status", message, nextStep); +} + +function setSceneCardSuccess(message, nextStep = "") { + setFlowSuccessStatus("scene-card-status", message, nextStep); +} + +function setSceneCardFailure(message, nextStep = "") { + setFlowFailureStatus("scene-card-status", message, nextStep, { affectsChatFlow: false }); +} + +function setSelfCardLoading(message, nextStep = "") { + setFlowLoadingStatus("self-card-status", message, nextStep); +} + +function setSelfCardSuccess(message, nextStep = "") { + setFlowSuccessStatus("self-card-status", message, nextStep); +} + +function setSelfCardFailure(message, nextStep = "") { + setFlowFailureStatus("self-card-status", message, nextStep, { affectsChatFlow: false }); +} + +function setOpeningPresetLoading(message, nextStep = "") { + setFlowLoadingStatus("opening-preset-status", message, nextStep); +} + +function setOpeningPresetSuccess(message, nextStep = "") { + setFlowSuccessStatus("opening-preset-status", message, nextStep); +} + +function setOpeningPresetFailure(message, nextStep = "") { + setFlowFailureStatus("opening-preset-status", message, nextStep, { affectsChatFlow: false }); +} + +function setPersonaReviewLoading(message, nextStep = "") { + setFlowLoadingStatus("persona-review-status", message, nextStep); +} + +function setPersonaReviewSuccess(message, nextStep = "") { + setFlowSuccessStatus("persona-review-status", message, nextStep); +} + +function setPersonaReviewFailure(message, nextStep = "") { + setFlowFailureStatus("persona-review-status", message, nextStep, { affectsChatFlow: false }); +} + +function setRelationDetailsLoading(message, nextStep = "") { + setFlowLoadingStatus("relation-details-status", message, nextStep); +} + +function setRelationDetailsFailure(message, nextStep = "") { + setFlowFailureStatus("relation-details-status", message, nextStep, { affectsChatFlow: false }); +} + +async function requireWebUiApi(timeoutMs = 4000) { + const startedAt = Date.now(); + while (Date.now() - startedAt <= timeoutMs) { + const api = window.__ZAOMENG_WEBUI_API__; + if (api && typeof api.listOpeningPresets === "function") { + return api; + } + await new Promise((resolve) => window.setTimeout(resolve, 16)); + } + throw new Error("webui api is not ready."); +} + function applyRunViewSafely(run, options = {}) { if (typeof window.__ZAOMENG_APPLY_RUN_VIEW__ === "function") { window.__ZAOMENG_APPLY_RUN_VIEW__(run, options); @@ -328,7 +438,7 @@ function clearChatSetupSelfCardSelection() { async function handleModelSettingsSubmit(event) { event.preventDefault(); - setStatus("model-settings-status", "正在把故事声源接进来..."); + setFlowLoadingStatus("model-settings-status", "正在把故事声源接进来..."); try { modelSettings = await apiJson( "/api/web/settings/model", @@ -346,11 +456,16 @@ async function handleModelSettingsSubmit(event) { "保存失败。" ); applyModelSettingsView(); - setStatus("model-settings-status", "故事声源已经接通。"); + setFlowSuccessStatus("model-settings-status", "故事声源已经接通。", "现在可以开始新的一卷。"); closeSettingsModal(); updateWorkflowState(); } catch (error) { - setStatus("model-settings-status", error.message || "这次连接没有成功。"); + setFlowFailureStatus( + "model-settings-status", + error.message || "这次连接没有成功。", + "可以检查模型地址和密钥后重试。", + { affectsChatFlow: true } + ); } } @@ -358,26 +473,27 @@ async function handleCreateRunSubmit(event) { event.preventDefault(); if (!modelSettings.configured) { openSettingsModal(); - setStatus("form-status", "先把故事声源接进来,再开始这一卷。"); + setFlowFailureStatus("form-status", "先把故事声源接进来,再开始这一卷。", "先完成模型配置后再发起蒸馏。", { affectsChatFlow: true }); return; } const file = el("novel-file")?.files?.[0]; if (!file) { - setStatus("form-status", "先放入一本书,故事才会往下走。"); + setFlowFailureStatus("form-status", "先放入一本书,故事才会往下走。", "选择一本小说文件后再开始。", { affectsChatFlow: false }); return; } const characters = charactersOf("characters"); if (!characters.length) { - setStatus("form-status", "至少写下一个你想遇见的人。"); + setFlowFailureStatus("form-status", "至少写下一个你想遇见的人。", "先补一个角色名再开始。", { affectsChatFlow: false }); return; } runCreationPending = true; updateWorkflowState(); setButtonBusyState("submit-button", true, { idleText: "开始唤醒人物", busyText: "蒸馏中..." }); - setDistillFlowStatus("form-status", { - message: "正在翻检正文,替你把人物请出来...", - nextStep: "开始后你可以在书卷页继续看人物蒸馏和关系图进度。", - }); + setFlowLoadingStatus( + "form-status", + "正在翻检正文,替你把人物请出来...", + "开始后你可以在书卷页继续看人物蒸馏和关系图进度。" + ); try { const run = await apiJson( "/api/web/runs", @@ -397,19 +513,21 @@ async function handleCreateRunSubmit(event) { ); applyRunViewSafely(run); await loadRunsOverview(); - setDistillFlowStatus("form-status", { - message: "人物整理已经开始,进度会在这里慢慢往前走。", - nextStep: "接下来可以盯住书卷页,等人物先落稳几位。", - }); + setFlowSuccessStatus( + "form-status", + "人物整理已经开始,进度会在这里慢慢往前走。", + "接下来可以盯住书卷页,等人物先落稳几位。" + ); } catch (error) { runCreationPending = false; stopRunPolling(); updateWorkflowState(); - setDistillFlowStatus("form-status", { - message: error.message || "这一轮人物整理没有成功。", - impact: "这不会影响你已有书架和已经在聊的会话。", - nextStep: "可以调整正文片段或人物名单后再试。", - }); + setFlowFailureStatus( + "form-status", + error.message || "这一轮人物整理没有成功。", + "可以调整正文片段或人物名单后再试。", + { impact: "这不会影响你已有书架和已经在聊的会话。", affectsChatFlow: false } + ); } finally { setButtonBusyState("submit-button", false, { idleText: "开始唤醒人物", busyText: "蒸馏中..." }); } @@ -436,20 +554,19 @@ async function handleRedistill() { runCreationPending = true; updateWorkflowState(); setButtonBusyState("redistill-button", true, { idleText: "继续整理", busyText: "继续蒸馏中..." }); - setDistillFlowStatus("redistill-status", file - ? { - message: "正在换入新的书段,并继续整理人物...", - nextStep: "这一轮会沿着新书段增量补稳人物,不会把已有成果推倒重来。", - } - : selectedSegment - ? { - message: "正在切到推荐片段,并继续补稳这一位角色...", - nextStep: "这次会优先补强当前命中的角色窗口。", - } - : { - message: "正在沿着这卷书继续往下整理...", - nextStep: "这一轮会在已有人物基础上继续补稳,不会重头开始。", - }); + setFlowLoadingStatus( + "redistill-status", + file + ? "正在换入新的书段,并继续整理人物..." + : selectedSegment + ? "正在切到推荐片段,并继续补稳这一位角色..." + : "正在沿着这卷书继续往下整理...", + file + ? "这一轮会沿着新书段增量补稳人物,不会把已有成果推倒重来。" + : selectedSegment + ? "这次会优先补强当前命中的角色窗口。" + : "这一轮会在已有人物基础上继续补稳,不会重头开始。" + ); try { const run = await apiJson( `/api/web/runs/${currentRunId}/redistill`, @@ -473,29 +590,29 @@ async function handleRedistill() { } resetRedistillRecommendationState(); updateRedistillFileView(); - setDistillFlowStatus("redistill-status", file - ? { - message: "新的书段已经接入,这一轮增量整理开始了。", - nextStep: "你可以回到书卷页继续看人物和图谱怎么往前长。", - } - : selectedSegment - ? { - message: "推荐片段已经接入,这一轮增量整理开始了。", - nextStep: "这位角色会优先吃到这一段证据补料。", - } - : { - message: "新的整理已经开始,人物会陆续补进来。", - nextStep: "接下来适合继续盯住这卷的进度变化。", - }); + setFlowSuccessStatus( + "redistill-status", + file + ? "新的书段已经接入,这一轮增量整理开始了。" + : selectedSegment + ? "推荐片段已经接入,这一轮增量整理开始了。" + : "新的整理已经开始,人物会陆续补进来。", + file + ? "你可以回到书卷页继续看人物和图谱怎么往前长。" + : selectedSegment + ? "这位角色会优先吃到这一段证据补料。" + : "接下来适合继续盯住这卷的进度变化。" + ); } catch (error) { runCreationPending = false; stopRunPolling(); updateWorkflowState(); - setDistillFlowStatus("redistill-status", { - message: error.message || "这次继续整理没有接上。", - impact: "这不会影响这卷已落下的人物、校对结果和当前聊天。", - nextStep: "可以换一段更贴近角色的正文,或稍后再试。", - }); + setFlowFailureStatus( + "redistill-status", + error.message || "这次继续整理没有接上。", + "可以换一段更贴近角色的正文,或稍后再试。", + { impact: "这不会影响这卷已落下的人物、校对结果和当前聊天。", affectsChatFlow: false } + ); } finally { setButtonBusyState("redistill-button", false, { idleText: "继续整理", busyText: "继续蒸馏中..." }); } @@ -517,10 +634,11 @@ async function handleRedistillRecommend() { redistillSuggestionState.selectedSegmentId = ""; renderRedistillRecommendationState(character); setButtonBusyState("redistill-recommend-button", true, { idleText: "推荐片段", busyText: "推荐中..." }); - setDistillFlowStatus("redistill-status", { - message: `正在替「${character}」翻当前书段,挑适合补稳的正文片段...`, - nextStep: "挑完后你可以直接点用推荐片段继续增量蒸馏。", - }); + setFlowLoadingStatus( + "redistill-status", + `正在替「${character}」翻当前书段,挑适合补稳的正文片段...`, + "挑完后你可以直接点用推荐片段继续增量蒸馏。" + ); try { const payload = await apiJson( `/api/web/runs/${currentRunId}/redistill/recommend`, @@ -539,26 +657,26 @@ async function handleRedistillRecommend() { redistillSuggestionState.items = Array.isArray(payload?.segments) ? payload.segments : []; redistillSuggestionState.selectedSegmentId = ""; renderRedistillRecommendationState(character); - setDistillFlowStatus("redistill-status", + setFlowSuccessStatus( + "redistill-status", + redistillSuggestionState.items.length + ? `已经为「${character}」挑出 ${redistillSuggestionState.items.length} 段更适合补料的正文。` + : `当前书段里暂时没找到更适合「${character}」的推荐窗口。`, redistillSuggestionState.items.length - ? { - message: `已经为「${character}」挑出 ${redistillSuggestionState.items.length} 段更适合补料的正文。`, - nextStep: "选中其中一段后,就可以直接继续这位角色的增量蒸馏。", - } - : { - message: `当前书段里暂时没找到更适合「${character}」的推荐窗口。`, - nextStep: "可以改用新书段,或直接沿用当前正文继续蒸馏。", - }); + ? "选中其中一段后,就可以直接继续这位角色的增量蒸馏。" + : "可以改用新书段,或直接沿用当前正文继续蒸馏。" + ); } catch (error) { redistillSuggestionState.loading = false; redistillSuggestionState.items = []; redistillSuggestionState.selectedSegmentId = ""; renderRedistillRecommendationState(character); - setDistillFlowStatus("redistill-status", { - message: error.message || "这次推荐片段没有接上。", - impact: "这不会影响这卷继续聊天或直接继续增量蒸馏。", - nextStep: "可以稍后重试,或直接手动换入更贴近角色的正文。", - }); + setFlowFailureStatus( + "redistill-status", + error.message || "这次推荐片段没有接上。", + "可以稍后重试,或直接手动换入更贴近角色的正文。", + { impact: "这不会影响这卷继续聊天或直接继续增量蒸馏。", affectsChatFlow: false } + ); } finally { setButtonBusyState("redistill-recommend-button", false, { idleText: "推荐片段", busyText: "推荐中..." }); } @@ -631,7 +749,7 @@ function handleRedistillRefresh() { async function handleDialogueSessionSubmit(event) { event.preventDefault(); if (!currentRunId) { - setStatus("dialogue-session-status", "先让人物从书页里走出来,再进入这一幕。"); + setDialogueSessionFailure("先让人物从书页里走出来,再进入这一幕。", "先完成一轮人物蒸馏后再开场。", true); publishChatSetupState("chat-setup-submit-blocked"); return; } @@ -642,7 +760,7 @@ async function handleDialogueSessionSubmit(event) { let participants = charactersOf("dialogue-participants"); if (mode === "act") { if (!controlledCharacter) { - setStatus("dialogue-session-status", "先写下此刻由你扮演谁。"); + setDialogueSessionFailure("先写下此刻由你扮演谁。", "填入扮演角色后再开始这一幕。", true); publishChatSetupState("chat-setup-submit-blocked"); return; } @@ -654,7 +772,7 @@ async function handleDialogueSessionSubmit(event) { setComposerEnabled(false); renderSessionBooting(mode, participants); updateWorkflowState(); - setStatus("dialogue-session-status", "正在替你铺开这一幕..."); + setDialogueSessionLoading("正在替你铺开这一幕...", "铺开后你就可以继续对话推进。"); publishChatSetupState("chat-setup-submitting"); await renderDialogueSession( await apiJson( @@ -683,13 +801,13 @@ async function handleDialogueSessionSubmit(event) { "进入聊天失败。" ) ); - setStatus("dialogue-session-status", "这一幕已经铺好,你可以继续说下去。"); + setDialogueSessionSuccess("这一幕已经铺好,你可以继续说下去。", "直接发送下一句,或者先补一句场景提示。"); publishChatSetupState("chat-setup-submitted"); } catch (error) { sessionBooting = false; setComposerEnabled(Boolean(currentDialogueSessionId)); updateWorkflowState(); - setStatus("dialogue-session-status", error.message || "这一幕暂时没有铺开。"); + setDialogueSessionFailure(error.message || "这一幕暂时没有铺开。", "可以稍后重试,或先调整入场模式和参与人物。", true); publishChatSetupState("chat-setup-submit-failed"); } } @@ -769,7 +887,7 @@ function openNewSceneCard() { startSceneCardDraft({ title: trimmedValue("scene-card-preview-title", "") || "", }); - setStatus("scene-card-status", "你可以手写,也可以让 AI 先随机搭一幕。"); + setSceneCardSuccess("你可以手写,也可以让 AI 先随机搭一幕。", "写完后保存即可在开场时直接选用。"); openSceneCardModal(); publishSceneCardEditorState("scene-card-new-opened"); } @@ -779,7 +897,7 @@ async function openExistingSceneCard(cardId) { openNewSceneCard(); return; } - setStatus("scene-card-status", "正在载入场景卡..."); + setSceneCardLoading("正在载入场景卡..."); publishSceneCardEditorState("scene-card-loading"); try { const payload = await apiJson(`/api/web/scene-cards/${encodeURIComponent(cardId)}`, {}, "场景卡载入失败。"); @@ -787,11 +905,11 @@ async function openExistingSceneCard(cardId) { setValue("scene-card-id", payload.card_id || ""); fillSceneCardFields(payload.fields || {}); updateSceneCardDeleteButton(); - setStatus("scene-card-status", ""); + setSceneCardSuccess("场景卡已载入。", "可以直接编辑后保存。"); openSceneCardModal(); publishSceneCardEditorState("scene-card-loaded"); } catch (error) { - setStatus("dialogue-session-status", error.message || "场景卡载入失败。"); + setDialogueSessionFailure(error.message || "场景卡载入失败。", "可以稍后重试,或先新建一张场景卡。", false); publishSceneCardEditorState("scene-card-load-failed"); } } @@ -943,7 +1061,7 @@ async function handleGenerateSceneCard(event) { if (event && typeof event.preventDefault === "function") event.preventDefault(); const button = el("generate-scene-card-button"); if (button) button.disabled = true; - setStatus("scene-card-status", "正在随机生成一张场景卡..."); + setSceneCardLoading("正在随机生成一张场景卡..."); publishSceneCardEditorState("scene-card-generating"); try { const payload = await apiJson( @@ -952,10 +1070,10 @@ async function handleGenerateSceneCard(event) { "场景卡生成失败。" ); fillSceneCardFields(payload.fields || {}); - setStatus("scene-card-status", "AI 已经把这一幕先搭好了,你可以直接保存,也可以再手修。"); + setSceneCardSuccess("AI 已经把这一幕先搭好了,你可以直接保存,也可以再手修。", "确认无误后保存这张场景卡。"); publishSceneCardEditorState("scene-card-generated"); } catch (error) { - setStatus("scene-card-status", error.message || "场景卡生成失败。"); + setSceneCardFailure(error.message || "场景卡生成失败。", "可以稍后重试,或改为手动填写。"); publishSceneCardEditorState("scene-card-generate-failed"); } finally { if (button) button.disabled = false; @@ -967,11 +1085,11 @@ async function handleSceneCardSubmit(event) { const fields = collectSceneCardPayload(); const validationMessage = validateSceneCardPayload(fields); if (validationMessage) { - setStatus("scene-card-status", validationMessage); + setSceneCardFailure(validationMessage, "补全必填字段后再保存。"); publishSceneCardEditorState("scene-card-validation-failed"); return; } - setStatus("scene-card-status", "正在保存场景卡..."); + setSceneCardLoading("正在保存场景卡..."); publishSceneCardEditorState("scene-card-saving"); try { const cardId = trimmedValue("scene-card-id", ""); @@ -992,11 +1110,11 @@ async function handleSceneCardSubmit(event) { syncCustomSelect("dialogue-scene-card"); } syncSelectedSceneCardFromSelect(); - setStatus("scene-card-status", "场景卡已保存。"); + setSceneCardSuccess("场景卡已保存。", "现在可以在开场器里直接选用这张卡。"); publishSceneCardEditorState("scene-card-saved"); closeSceneCardModal(); } catch (error) { - setStatus("scene-card-status", error.message || "场景卡保存失败。"); + setSceneCardFailure(error.message || "场景卡保存失败。", "可以稍后重试,或先复制内容避免丢失。"); publishSceneCardEditorState("scene-card-save-failed"); } } @@ -1006,7 +1124,7 @@ async function handleDeleteSceneCard(event) { const cardId = trimmedValue("scene-card-id", ""); if (!cardId) return; if (!window.confirm("确定删除这张场景卡吗?")) return; - setStatus("scene-card-status", "正在删除场景卡..."); + setSceneCardLoading("正在删除场景卡..."); publishSceneCardEditorState("scene-card-deleting"); try { await apiJson( @@ -1023,7 +1141,7 @@ async function handleDeleteSceneCard(event) { publishSceneCardEditorState("scene-card-deleted"); closeSceneCardModal(); } catch (error) { - setStatus("scene-card-status", error.message || "场景卡删除失败。"); + setSceneCardFailure(error.message || "场景卡删除失败。", "可以稍后重试,或先关闭弹窗后再试。"); publishSceneCardEditorState("scene-card-delete-failed"); } } @@ -1252,7 +1370,7 @@ function renderDialogueSceneChainSuggestions(chains = [], sessionId = "") { button.textContent = "接这条线并切幕"; button.addEventListener("click", () => { applyDialogueSceneChain(chain).catch((error) => { - setStatus("dialogue-live-scene-status", error.message || "这条线暂时没有接上。"); + setFlowFailureStatus("dialogue-live-scene-status", error.message || "这条线暂时没有接上。", "可以稍后重试,或手动切到目标场景卡。", { affectsChatFlow: false }); }); }); actions.appendChild(button); @@ -1317,26 +1435,28 @@ async function branchDialogueSessionFromScene(sceneIndex) { if (!currentRunId || !currentDialogueSessionId || !Number.isInteger(index) || index < 0) { return; } - setStatus("dialogue-live-scene-status", "正在从这一幕重新岔开一条新会话..."); + setFlowLoadingStatus("dialogue-live-scene-status", "正在从这一幕重新岔开一条新会话..."); try { - const payload = await window.__ZAOMENG_WEBUI_API__.branchDialogueSession(currentRunId, currentDialogueSessionId, index); + const api = await requireWebUiApi(); + const payload = await api.branchDialogueSession(currentRunId, currentDialogueSessionId, index); await renderDialogueSession(payload); - setStatus("dialogue-session-status", "已经从这幕重新开出一条新分支。"); - setStatus("dialogue-live-scene-status", "新的分支会话已经接上。"); + setDialogueSessionSuccess("已经从这幕重新开出一条新分支。", "可以继续沿新分支推进。"); + setFlowSuccessStatus("dialogue-live-scene-status", "新的分支会话已经接上。", "可以继续说下一句。"); } catch (error) { - setStatus("dialogue-live-scene-status", error.message || "分支会话创建失败。"); + setFlowFailureStatus("dialogue-live-scene-status", error.message || "分支会话创建失败。", "可以稍后重试,或继续当前会话。", { affectsChatFlow: true }); } } async function handleRecommendSceneCard(event) { if (event && typeof event.preventDefault === "function") event.preventDefault(); if (!sceneCards.length) { - setStatus("dialogue-session-status", "你还没有场景卡,先新建一张再让我替你挑。"); + setDialogueSessionFailure("你还没有场景卡,先新建一张再让我替你挑。", "先新建场景卡后再让系统推荐。", false); return; } - setStatus("dialogue-session-status", "正在按这场的角色和入场方式替你挑更合适的场景卡..."); + setDialogueSessionLoading("正在按这场的角色和入场方式替你挑更合适的场景卡..."); try { - const payload = await window.__ZAOMENG_WEBUI_API__.recommendSceneCards({ + const api = await requireWebUiApi(); + const payload = await api.recommendSceneCards({ mode: valueOf("dialogue-mode", "observe"), participants: charactersOf("dialogue-participants"), }); @@ -1351,16 +1471,19 @@ async function handleRecommendSceneCard(event) { syncSelectedSceneCardFromSelect(); const top = Array.isArray(payload?.items) ? payload.items[0] : null; const reasons = Array.isArray(top?.recommendation?.reasons) ? top.recommendation.reasons.filter(Boolean).slice(0, 3) : []; - setStatus("dialogue-session-status", reasons.length ? `已替你挑好场景卡:${reasons.join(",")}。` : "已替你挑好一张更合适的场景卡。"); + setDialogueSessionSuccess( + reasons.length ? `已替你挑好场景卡:${reasons.join(",")}。` : "已替你挑好一张更合适的场景卡。", + "确认后可以直接开场。" + ); } else { - setStatus("dialogue-session-status", "这一轮没挑出更明确的推荐,你也可以手动选。"); + setDialogueSessionSuccess("这一轮没挑出更明确的推荐,你也可以手动选。", "你可以直接手动选一张场景卡。"); } if (typeof UI_BRIDGE_TOOLS.syncLegacyUiState === "function") { UI_BRIDGE_TOOLS.syncLegacyUiState("scene-card-recommended", { currentSceneCardRecommendation }); } publishChatSetupState("chat-setup-scene-card-recommended"); } catch (error) { - setStatus("dialogue-session-status", error.message || "场景卡推荐失败。"); + setDialogueSessionFailure(error.message || "场景卡推荐失败。", "可以稍后重试,或手动选择场景卡。", false); } } @@ -1368,7 +1491,7 @@ async function handleDuplicateSceneCard(event) { if (event && typeof event.preventDefault === "function") event.preventDefault(); const fields = collectSceneCardPayload(); startSceneCardDraft(fields); - setStatus("scene-card-status", "已经按当前内容另起一张新卡。保存后会成为独立场景卡。"); + setSceneCardSuccess("已经按当前内容另起一张新卡。保存后会成为独立场景卡。", "确认后保存这张新卡。"); publishSceneCardEditorState("scene-card-duplicated"); } @@ -1438,7 +1561,7 @@ function openNewSelfCard() { scene_identity: trimmedValue("dialogue-self-identity", ""), interaction_style: trimmedValue("dialogue-self-style", ""), }); - setStatus("self-card-status", "你可以手写,也可以让 AI 先随机捏一张。"); + setSelfCardSuccess("你可以手写,也可以让 AI 先随机捏一张。", "写完后保存即可在插入模式直接选用。"); openSelfCardModal(); publishSelfCardEditorState("self-card-new-opened"); } @@ -1448,7 +1571,7 @@ async function openExistingSelfCard(cardId) { openNewSelfCard(); return; } - setStatus("self-card-status", "正在载入角色卡..."); + setSelfCardLoading("正在载入角色卡..."); publishSelfCardEditorState("self-card-loading"); try { const payload = await apiJson(`/api/web/self-cards/${encodeURIComponent(cardId)}`, {}, "角色卡载入失败。"); @@ -1456,11 +1579,11 @@ async function openExistingSelfCard(cardId) { setValue("self-card-id", payload.card_id || ""); fillSelfCardFields(payload.fields || {}); updateSelfCardDeleteButton(); - setStatus("self-card-status", ""); + setSelfCardSuccess("角色卡已载入。", "可以直接编辑后保存。"); openSelfCardModal(); publishSelfCardEditorState("self-card-loaded"); } catch (error) { - setStatus("dialogue-session-status", error.message || "角色卡载入失败。"); + setDialogueSessionFailure(error.message || "角色卡载入失败。", "可以稍后重试,或先新建一张角色卡。", false); publishSelfCardEditorState("self-card-load-failed"); } } @@ -1598,7 +1721,7 @@ async function handleGenerateSelfCard(event) { if (event && typeof event.preventDefault === "function") event.preventDefault(); const button = el("generate-self-card-button"); if (button) button.disabled = true; - setStatus("self-card-status", "正在随机生成一张角色卡..."); + setSelfCardLoading("正在随机生成一张角色卡..."); publishSelfCardEditorState("self-card-generating"); try { const payload = await apiJson( @@ -1607,10 +1730,10 @@ async function handleGenerateSelfCard(event) { "角色卡生成失败。" ); fillSelfCardFields(payload.fields || {}); - setStatus("self-card-status", "AI 已经把整张卡先填好了,你可以直接保存,也可以再手修。"); + setSelfCardSuccess("AI 已经把整张卡先填好了,你可以直接保存,也可以再手修。", "确认无误后保存这张角色卡。"); publishSelfCardEditorState("self-card-generated"); } catch (error) { - setStatus("self-card-status", error.message || "角色卡生成失败。"); + setSelfCardFailure(error.message || "角色卡生成失败。", "可以稍后重试,或改为手动填写。"); publishSelfCardEditorState("self-card-generate-failed"); } finally { if (button) button.disabled = false; @@ -1623,11 +1746,11 @@ async function handleSelfCardSubmit(event) { const fields = collectSelfCardPayload(); const validationMessage = validateSelfCardPayload(fields); if (validationMessage) { - setStatus("self-card-status", validationMessage); + setSelfCardFailure(validationMessage, "补全必填字段后再保存。"); publishSelfCardEditorState("self-card-validation-failed"); return; } - setStatus("self-card-status", "正在保存角色卡..."); + setSelfCardLoading("正在保存角色卡..."); publishSelfCardEditorState("self-card-saving"); try { const payload = await apiJson( @@ -1647,12 +1770,12 @@ async function handleSelfCardSubmit(event) { syncCustomSelect("dialogue-self-card"); } syncSelectedSelfCardFromSelect(); - setStatus("dialogue-session-status", "角色卡已经接好,现在可以直接带它入场。"); - setStatus("self-card-status", "角色卡已保存。"); + setDialogueSessionSuccess("角色卡已经接好,现在可以直接带它入场。", "切到插入模式后可直接套用。"); + setSelfCardSuccess("角色卡已保存。", "现在可以在开场器里直接选用这张卡。"); publishSelfCardEditorState("self-card-saved"); closeSelfCardModal(); } catch (error) { - setStatus("self-card-status", error.message || "角色卡保存失败。"); + setSelfCardFailure(error.message || "角色卡保存失败。", "可以稍后重试,或先复制内容避免丢失。"); publishSelfCardEditorState("self-card-save-failed"); } } @@ -1664,7 +1787,7 @@ async function handleDeleteSelfCard(event) { if (!window.confirm("确定删除这张角色卡吗?")) { return; } - setStatus("self-card-status", "正在删除角色卡..."); + setSelfCardLoading("正在删除角色卡..."); publishSelfCardEditorState("self-card-deleting"); try { await apiJson( @@ -1678,11 +1801,11 @@ async function handleDeleteSelfCard(event) { await loadSelfCards(); currentSelfCard = null; renderSelectedSelfCardPreview(); - setStatus("dialogue-session-status", "角色卡已经删掉了。"); + setDialogueSessionSuccess("角色卡已经删掉了。", "可以新建一张角色卡继续使用插入模式。"); publishSelfCardEditorState("self-card-deleted"); closeSelfCardModal(); } catch (error) { - setStatus("self-card-status", error.message || "角色卡删除失败。"); + setSelfCardFailure(error.message || "角色卡删除失败。", "可以稍后重试,或先关闭弹窗后再试。"); publishSelfCardEditorState("self-card-delete-failed"); } } @@ -1788,7 +1911,8 @@ function syncSelectedOpeningPresetFromSelect() { } async function loadOpeningPresets() { - const payload = await window.__ZAOMENG_WEBUI_API__.listOpeningPresets(); + const api = await requireWebUiApi(); + const payload = await api.listOpeningPresets(); openingPresets = Array.isArray(payload?.items) ? payload.items : []; renderOpeningPresetOptions(openingPresets); if (typeof UI_BRIDGE_TOOLS.syncLegacyUiState === "function") { @@ -1875,7 +1999,10 @@ function applyOpeningPresetToChatSetup(preset) { updateCharacterPillState(); applyPresetSceneCard(fields); applyPresetSelfCard(mode, fields); - setStatus("dialogue-session-status", `已套用开局模板「${preset?.preview?.title || fields.title || "未命名模板"}」。`); + setDialogueSessionSuccess( + `已套用开局模板「${preset?.preview?.title || fields.title || "未命名模板"}」。`, + "确认参与人物和模式后可以直接开场。" + ); publishChatSetupState("chat-setup-opening-preset-applied"); } @@ -1890,7 +2017,7 @@ function openNewOpeningPreset() { note: "", }, }); - setStatus("opening-preset-status", "会把你当前这套模式、人物、角色卡和场景卡一起收成模板。"); + setOpeningPresetSuccess("会把你当前这套模式、人物、角色卡和场景卡一起收成模板。", "补上模板标题后即可保存。"); openOpeningPresetModal(); } @@ -1900,7 +2027,7 @@ function openExistingOpeningPreset() { return; } fillOpeningPresetMetaForm(currentOpeningPreset); - setStatus("opening-preset-status", "保存时会用你当前这套搭配覆盖模板内容。"); + setOpeningPresetSuccess("保存时会用你当前这套搭配覆盖模板内容。", "确认后保存即可覆盖这套模板。"); openOpeningPresetModal(); } @@ -1909,20 +2036,21 @@ async function handleOpeningPresetSubmit(event) { const title = trimmedValue("opening-preset-title", ""); const note = trimmedValue("opening-preset-note", ""); const cardId = trimmedValue("opening-preset-id", ""); - setStatus("opening-preset-status", "正在保存开局模板..."); + setOpeningPresetLoading("正在保存开局模板..."); try { - const payload = await window.__ZAOMENG_WEBUI_API__.saveOpeningPreset(cardId, collectOpeningPresetPayload({ title, note })); + const api = await requireWebUiApi(); + const payload = await api.saveOpeningPreset(cardId, collectOpeningPresetPayload({ title, note })); await loadOpeningPresets(); const select = el("dialogue-opening-preset"); if (select) { select.value = payload.card_id || ""; } syncSelectedOpeningPresetFromSelect(); - setStatus("dialogue-session-status", "这套开局已经收成模板,下次可以一键套用。"); - setStatus("opening-preset-status", "开局模板已保存。"); + setDialogueSessionSuccess("这套开局已经收成模板,下次可以一键套用。", "需要时可直接一键套用后开场。"); + setOpeningPresetSuccess("开局模板已保存。", "现在可以在开场器里直接选用。"); closeOpeningPresetModal(); } catch (error) { - setStatus("opening-preset-status", error.message || "开局模板保存失败。"); + setOpeningPresetFailure(error.message || "开局模板保存失败。", "可以稍后重试,或先精简模板内容后再保存。"); } } @@ -1931,27 +2059,28 @@ async function handleDeleteOpeningPreset(event) { const cardId = trimmedValue("opening-preset-id", ""); if (!cardId) return; if (!window.confirm("确定删除这套开局模板吗?")) return; - setStatus("opening-preset-status", "正在删除开局模板..."); + setOpeningPresetLoading("正在删除开局模板..."); try { - await window.__ZAOMENG_WEBUI_API__.deleteOpeningPreset(cardId); + const api = await requireWebUiApi(); + await api.deleteOpeningPreset(cardId); if (selectedOpeningPresetId === cardId) { selectedOpeningPresetId = ""; currentOpeningPreset = null; } await loadOpeningPresets(); renderOpeningPresetPreview(); - setStatus("dialogue-session-status", "这套开局模板已经删掉了。"); - setStatus("opening-preset-status", "开局模板已删除。"); + setDialogueSessionSuccess("这套开局模板已经删掉了。", "可以新建一套模板继续使用。"); + setOpeningPresetSuccess("开局模板已删除。", "现在可以创建新的模板。"); closeOpeningPresetModal(); } catch (error) { - setStatus("opening-preset-status", error.message || "开局模板删除失败。"); + setOpeningPresetFailure(error.message || "开局模板删除失败。", "可以稍后重试,或先刷新模板列表后再试。"); } } async function handleApplyOpeningPreset(event) { if (event && typeof event.preventDefault === "function") event.preventDefault(); if (!currentOpeningPreset) { - setStatus("dialogue-session-status", "先挑一套开局模板。"); + setDialogueSessionFailure("先挑一套开局模板。", "先选中一套模板后再套用。", false); return; } applyOpeningPresetToChatSetup(currentOpeningPreset); @@ -1960,7 +2089,7 @@ async function handleApplyOpeningPreset(event) { async function handleStartOpeningPreset(event) { if (event && typeof event.preventDefault === "function") event.preventDefault(); if (!currentOpeningPreset) { - setStatus("dialogue-session-status", "先挑一套开局模板。"); + setDialogueSessionFailure("先挑一套开局模板。", "先选中一套模板后再开场。", false); return; } applyOpeningPresetToChatSetup(currentOpeningPreset); @@ -1976,7 +2105,7 @@ async function openPersonaReviewForCharacter(characterName = "") { setValue("persona-review-character", character); } if (!character) { - setStatus("persona-review-status", "这一卷里还没有可校对的人物。"); + setPersonaReviewFailure("这一卷里还没有可校对的人物。", "先完成人物蒸馏后再来校对。"); return; } openPersonaReviewModal(); @@ -1985,13 +2114,13 @@ async function openPersonaReviewForCharacter(characterName = "") { reviewActions.openForCharacter(character); return; } - setStatus("persona-review-status", "正在载入人物档案..."); + setPersonaReviewLoading("正在载入人物档案..."); try { renderPersonaReview(await apiJson(`/api/web/runs/${currentRunId}/personas/${encodeURIComponent(character)}`)); renderPersonaAutofillReferences(null); - setStatus("persona-review-status", ""); + setPersonaReviewSuccess("人物档案已载入。", "你可以直接修改并保存。"); } catch (error) { - setStatus("persona-review-status", error.message || "人物档案暂时没有载入。"); + setPersonaReviewFailure(error.message || "人物档案暂时没有载入。", "可以稍后重试,或先回到书卷页刷新。"); } } @@ -2003,7 +2132,7 @@ async function openWorkCharacterReview() { if (!currentRunId || !currentRun) return; const names = getRunCharacterNames(currentRun); if (!names.length) { - setStatus("bookshelf-status", "这一卷里还没有可校对的人物。"); + setFlowFailureStatus("bookshelf-status", "这一卷里还没有可校对的人物。", "先完成人物蒸馏后再来校对。", { affectsChatFlow: false }); return; } @@ -2016,7 +2145,7 @@ async function openWorkCharacterReview() { targetCharacter = names[0] || ""; } if (!targetCharacter) { - setStatus("bookshelf-status", "这一卷里还没有可校对的人物。"); + setFlowFailureStatus("bookshelf-status", "这一卷里还没有可校对的人物。", "先完成人物蒸馏后再来校对。", { affectsChatFlow: false }); return; } @@ -2044,13 +2173,13 @@ async function handlePersonaCharacterChange() { if (!currentRunId) return; const character = valueOf("persona-review-character", ""); if (!character) return; - setStatus("persona-review-status", "正在切换人物..."); + setPersonaReviewLoading("正在切换人物..."); try { renderPersonaReview(await apiJson(`/api/web/runs/${currentRunId}/personas/${encodeURIComponent(character)}`)); renderPersonaAutofillReferences(null); - setStatus("persona-review-status", ""); + setPersonaReviewSuccess("人物档案已切换。", "可以继续编辑当前人物。"); } catch (error) { - setStatus("persona-review-status", error.message || "人物档案暂时没有载入。"); + setPersonaReviewFailure(error.message || "人物档案暂时没有载入。", "可以稍后重试,或切换到其他人物。"); } } @@ -2071,7 +2200,7 @@ async function handlePersonaFieldAutofill(event) { const character = valueOf("persona-review-character", ""); const field = trigger.getAttribute("data-persona-autofill-field") || ""; if (!character || !field) { - setStatus("persona-review-status", "先选一个人物。"); + setPersonaReviewFailure("先选一个人物。", "选中人物后再进行字段补全。"); return; } const labelText = trigger.closest(".field-card")?.querySelector(".field-card-head span, span")?.textContent || field; @@ -2080,7 +2209,7 @@ async function handlePersonaFieldAutofill(event) { const originalText = trigger.textContent || "AI补全"; trigger.textContent = "生成中..."; setPersonaReviewFieldFeedback(field, "loading", "正在生成补全..."); - setStatus("persona-review-status", `正在生成「${labelText}」的补全内容...`); + setPersonaReviewLoading(`正在生成「${labelText}」的补全内容...`); try { const payload = await apiJson( `/api/web/runs/${currentRunId}/personas/${encodeURIComponent(character)}/suggest-field`, @@ -2099,16 +2228,16 @@ async function handlePersonaFieldAutofill(event) { markPersonaReviewFieldAutofilled(field); renderPersonaAutofillReferences(payload); setPersonaReviewFieldFeedback(field, "success", "已生成补全内容,记得保存。"); - setStatus("persona-review-status", payload.message || "已生成补全内容,请记得保存人物校对。"); + setPersonaReviewSuccess(payload.message || "已生成补全内容,请记得保存人物校对。", "确认后点保存写回这一卷。"); } else { renderPersonaAutofillReferences(payload); setPersonaReviewFieldFeedback(field, "error", payload?.message || payload?.reason || "人物信息补全无法生成。"); - setStatus("persona-review-status", payload?.message || payload?.reason || "人物信息补全无法生成。"); + setPersonaReviewFailure(payload?.message || payload?.reason || "人物信息补全无法生成。", "可以换字段重试,或手动补全。"); } } catch (error) { renderPersonaAutofillReferences(null); setPersonaReviewFieldFeedback(field, "error", error.message || "人物信息补全无法生成。"); - setStatus("persona-review-status", error.message || "人物信息补全无法生成。"); + setPersonaReviewFailure(error.message || "人物信息补全无法生成。", "可以稍后重试,或手动补全该字段。"); } finally { delete trigger.dataset.loading; trigger.disabled = false; @@ -2127,10 +2256,10 @@ async function handlePersonaReviewSubmit(event) { if (!currentRunId) return; const character = valueOf("persona-review-character", ""); if (!character) { - setStatus("persona-review-status", "先选一个人物。"); + setPersonaReviewFailure("先选一个人物。", "选中人物后再保存校对内容。"); return; } - setStatus("persona-review-status", "正在写回人物校对..."); + setPersonaReviewLoading("正在写回人物校对..."); try { renderPersonaReview( await apiJson( @@ -2147,9 +2276,9 @@ async function handlePersonaReviewSubmit(event) { clearAllPersonaReviewFieldFeedback(); renderPersonaAutofillReferences(null); applyRunViewSafely(await apiJson(`/api/web/runs/${currentRunId}`)); - setStatus("persona-review-status", "人物校对已经写回这一卷。"); + setPersonaReviewSuccess("人物校对已经写回这一卷。", "你可以继续校对下一个人物。"); } catch (error) { - setStatus("persona-review-status", error.message || "这次校对没有保存成功。"); + setPersonaReviewFailure(error.message || "这次校对没有保存成功。", "可以稍后重试,或先复制修改内容。"); } } @@ -2162,11 +2291,11 @@ async function openRelationDetails() { } else if (typeof publishLegacyUiState === "function") { publishLegacyUiState("relation-details-loading", { currentRelationDetails: null }); } - setStatus("relation-details-status", "正在整理关系明细..."); + setRelationDetailsLoading("正在整理关系明细..."); try { renderRelationDetails(await apiJson(`/api/web/runs/${currentRunId}/relations`)); } catch (error) { - setStatus("relation-details-status", error.message || "关系明细暂时没有载入。"); + setRelationDetailsFailure(error.message || "关系明细暂时没有载入。", "可以稍后重试,或先继续聊天推进。"); } } @@ -2227,31 +2356,29 @@ function renderBuiltinNovelList(items) { async function loadBuiltinNovels() { setButtonBusyState("refresh-builtin-novels-button", true, { idleText: "刷新列表", busyText: "刷新中..." }); - setFlowStatusMessage("builtin-novel-status", { - message: "正在翻出内置书卷...", - nextStep: "整理好后你可以直接复制一本到自己的书架。", - }); + setFlowLoadingStatus( + "builtin-novel-status", + "正在翻出内置书卷...", + "整理好后你可以直接复制一本到自己的书架。" + ); try { const payload = await apiJson("/api/web/builtin-novels", {}, "内置小说列表载入失败。"); const items = Array.isArray(payload.items) ? payload.items : []; renderBuiltinNovelList(items); - setFlowStatusMessage("builtin-novel-status", items.length - ? { - message: `当前有 ${items.length} 卷可直接试玩的内置小说。`, - nextStep: "选一卷复制到书架后,就可以直接开聊。", - } - : { - message: "内置目录里暂时还没有小说包。", - nextStep: "你可以先导出一卷,再放进内置目录。", - }); + setFlowSuccessStatus( + "builtin-novel-status", + items.length ? `当前有 ${items.length} 卷可直接试玩的内置小说。` : "内置目录里暂时还没有小说包。", + items.length ? "选一卷复制到书架后,就可以直接开聊。" : "你可以先导出一卷,再放进内置目录。" + ); return items; } catch (error) { renderBuiltinNovelList([]); - setFlowStatusMessage("builtin-novel-status", { - message: error.message || "内置小说列表暂时没有载入。", - impact: "这不会影响你继续使用当前书架或聊天。", - nextStep: "可以稍后重试,或先从本地导入小说包。", - }); + setFlowFailureStatus( + "builtin-novel-status", + error.message || "内置小说列表暂时没有载入。", + "可以稍后重试,或先从本地导入小说包。", + { impact: "这不会影响你继续使用当前书架或聊天。", affectsChatFlow: false } + ); throw error; } finally { setButtonBusyState("refresh-builtin-novels-button", false, { idleText: "刷新列表", busyText: "刷新中..." }); @@ -2274,10 +2401,11 @@ async function handleOpenBuiltinNovelModal() { async function handleCloneBuiltinNovel(packageId, title = "", trigger = null) { const safeTitle = String(title || "").trim(); setButtonBusyState(trigger, true, { idleText: "复制到我的书架", busyText: "复制中..." }); - setFlowStatusMessage("builtin-novel-status", { - message: safeTitle ? `正在把《${safeTitle}》复制到你的书架...` : "正在复制这卷书...", - nextStep: "复制完成后你就能直接进入这卷书。", - }); + setFlowLoadingStatus( + "builtin-novel-status", + safeTitle ? `正在把《${safeTitle}》复制到你的书架...` : "正在复制这卷书...", + "复制完成后你就能直接进入这卷书。" + ); try { const run = await apiJson( `/api/web/builtin-novels/${encodeURIComponent(packageId)}/clone`, @@ -2289,16 +2417,18 @@ async function handleCloneBuiltinNovel(packageId, title = "", trigger = null) { closeBuiltinNovelModal(); applyRunViewSafely(run); await loadRunsOverview(); - setFlowStatusMessage("bookshelf-status", { - message: safeTitle ? `《${safeTitle}》已经落到你的书架里。` : "内置小说已经复制到你的书架里。", - nextStep: "现在可以直接开聊,或先看人物和关系整理情况。", - }); + setFlowSuccessStatus( + "bookshelf-status", + safeTitle ? `《${safeTitle}》已经落到你的书架里。` : "内置小说已经复制到你的书架里。", + "现在可以直接开聊,或先看人物和关系整理情况。" + ); } catch (error) { - setFlowStatusMessage("builtin-novel-status", { - message: error.message || "这卷内置小说暂时没有复制成功。", - impact: "这不会影响你现有书架和聊天。", - nextStep: "可以稍后再试,或先从本地导入小说包。", - }); + setFlowFailureStatus( + "builtin-novel-status", + error.message || "这卷内置小说暂时没有复制成功。", + "可以稍后再试,或先从本地导入小说包。", + { impact: "这不会影响你现有书架和聊天。", affectsChatFlow: false } + ); } finally { setButtonBusyState(trigger, false, { idleText: "复制到我的书架", busyText: "复制中..." }); } @@ -2314,10 +2444,11 @@ async function handleImportRunPackage(event) { const file = input.files?.[0]; if (!file) return; setButtonBusyState("bookshelf-import-run-button", true, { idleText: "导入小说包", busyText: "导入中..." }); - setFlowStatusMessage("bookshelf-status", { - message: `正在导入 ${file.name}...`, - nextStep: "导入完成后会自动落到你的书架里。", - }); + setFlowLoadingStatus( + "bookshelf-status", + `正在导入 ${file.name}...`, + "导入完成后会自动落到你的书架里。" + ); try { const run = await apiJson( "/api/web/runs/import", @@ -2334,17 +2465,19 @@ async function handleImportRunPackage(event) { input.value = ""; applyRunViewSafely(run); await loadRunsOverview(); - setFlowStatusMessage("bookshelf-status", { - message: `《${runNovelTitle(run)}》已经导入到你的书架。`, - nextStep: "现在可以直接开聊,或先校对人物信息。", - }); + setFlowSuccessStatus( + "bookshelf-status", + `《${runNovelTitle(run)}》已经导入到你的书架。`, + "现在可以直接开聊,或先校对人物信息。" + ); } catch (error) { input.value = ""; - setFlowStatusMessage("bookshelf-status", { - message: error.message || "这次导入没有接上。", - impact: "这不会影响你当前已经在聊的会话或已有书卷。", - nextStep: "可以检查小说包是否完整后再试。", - }); + setFlowFailureStatus( + "bookshelf-status", + error.message || "这次导入没有接上。", + "可以检查小说包是否完整后再试。", + { impact: "这不会影响你当前已经在聊的会话或已有书卷。", affectsChatFlow: false } + ); } finally { setButtonBusyState("bookshelf-import-run-button", false, { idleText: "导入小说包", busyText: "导入中..." }); } @@ -2374,10 +2507,11 @@ async function handleExportRunPackage() { const title = runNovelTitle(run); setRunPackageExportPending(runId, true); setButtonBusyState("detail-export-package-button", true, { idleText: "导出小说包", busyText: "导出中..." }); - setFlowStatusMessage("bookshelf-status", { - message: `正在打包《${title}》的小说包...`, - nextStep: "打包完成后会自动开始下载。", - }); + setFlowLoadingStatus( + "bookshelf-status", + `正在打包《${title}》的小说包...`, + "打包完成后会自动开始下载。" + ); try { const response = await fetch(`/api/web/runs/${encodeURIComponent(runId)}/export`); if (!response.ok) { @@ -2387,16 +2521,18 @@ async function handleExportRunPackage() { const blob = await response.blob(); const fallbackName = `${String(run?.novel_id || title || "zaomeng-run").trim() || "zaomeng-run"}.zaomeng-run.zip`; downloadBlobFile(blob, resolveDownloadFilename(response, fallbackName)); - setFlowStatusMessage("bookshelf-status", { - message: `《${title}》的小说包已经准备好,正在开始下载。`, - nextStep: "你可以把它导入到别的设备,或放进内置小说目录。", - }); + setFlowSuccessStatus( + "bookshelf-status", + `《${title}》的小说包已经准备好,正在开始下载。`, + "你可以把它导入到别的设备,或放进内置小说目录。" + ); } catch (error) { - setFlowStatusMessage("bookshelf-status", { - message: error.message || "这次导出没有接上。", - impact: "这不会影响这卷继续聊天或继续校对。", - nextStep: "可以稍后重试;如果书还在整理中,等结束后再导出更稳。", - }); + setFlowFailureStatus( + "bookshelf-status", + error.message || "这次导出没有接上。", + "可以稍后重试;如果书还在整理中,等结束后再导出更稳。", + { impact: "这不会影响这卷继续聊天或继续校对。", affectsChatFlow: false } + ); } finally { setRunPackageExportPending(runId, false); setButtonBusyState("detail-export-package-button", false, { idleText: "导出小说包", busyText: "导出中..." }); @@ -2477,11 +2613,12 @@ function scheduleAppUpdatePolling() { window.setTimeout(() => window.location.reload(), 900); } } catch (error) { - setFlowStatusMessage("app-update-status", { - message: error.message || "刚才那次更新状态暂时没取到。", - impact: "这不会影响你继续使用当前版本。", - nextStep: "稍后可以再手动检查一次更新。", - }); + setFlowFailureStatus( + "app-update-status", + error.message || "刚才那次更新状态暂时没取到。", + "稍后可以再手动检查一次更新。", + { impact: "这不会影响你继续使用当前版本。", affectsChatFlow: false } + ); } }, 1200); } @@ -2505,10 +2642,11 @@ function dismissAppUpdateModal() { async function handleConfirmAppUpdate() { setButtonBusyState("confirm-app-update-button", true, { idleText: "现在更新", busyText: "更新中..." }); - setFlowStatusMessage("app-update-status", { - message: "正在替你接上更新...", - nextStep: "更新完成后会自动刷新当前页面。", - }); + setFlowLoadingStatus( + "app-update-status", + "正在替你接上更新...", + "更新完成后会自动刷新当前页面。" + ); try { const status = await apiJson( "/api/web/settings/update", @@ -2522,11 +2660,12 @@ async function handleConfirmAppUpdate() { scheduleAppUpdatePolling(); } catch (error) { setButtonBusyState("confirm-app-update-button", false, { idleText: "现在更新", busyText: "更新中..." }); - setFlowStatusMessage("app-update-status", { - message: error.message || "这次更新没有接上。", - impact: "这不会影响你继续使用当前版本聊天。", - nextStep: "可以稍后再试更新。", - }); + setFlowFailureStatus( + "app-update-status", + error.message || "这次更新没有接上。", + "可以稍后再试更新。", + { impact: "这不会影响你继续使用当前版本聊天。", affectsChatFlow: false } + ); } } @@ -3085,7 +3224,9 @@ function bindEvents() { }); bind("new-dialogue-session-button", "click", openNewDialogueSession); bind("bookshelf-open-builtin-button", "click", () => { - handleOpenBuiltinNovelModal().catch((error) => setStatus("builtin-novel-status", error.message || "内置小说列表暂时没有载入。")); + handleOpenBuiltinNovelModal().catch((error) => + setFlowFailureStatus("builtin-novel-status", error.message || "内置小说列表暂时没有载入。", "可以稍后重试,或先从本地导入小说包。", { affectsChatFlow: false }) + ); }); bind("bookshelf-import-run-button", "click", triggerImportRunPackage); bind("refresh-builtin-novels-button", "click", () => { @@ -3096,18 +3237,24 @@ function bindEvents() { bind("import-run-package-input", "change", handleImportRunPackage); bind("detail-start-chat-button", "click", openNewDialogueSession); bind("quick-open-observe-button", "click", () => { - openQuickDialogueMode("observe").catch((error) => setStatus("dialogue-session-status", error.message || "这一幕暂时没有铺开。")); + openQuickDialogueMode("observe").catch((error) => + setDialogueSessionFailure(error.message || "这一幕暂时没有铺开。", "可以稍后重试,或先检查人物与场景卡。", true) + ); }); bind("quick-open-act-button", "click", () => { - openQuickDialogueMode("act").catch((error) => setStatus("dialogue-session-status", error.message || "这一幕暂时没有铺开。")); + openQuickDialogueMode("act").catch((error) => + setDialogueSessionFailure(error.message || "这一幕暂时没有铺开。", "可以稍后重试,或先检查人物与场景卡。", true) + ); }); bind("quick-open-insert-button", "click", () => { - openQuickDialogueMode("insert").catch((error) => setStatus("dialogue-session-status", error.message || "这一幕暂时没有铺开。")); + openQuickDialogueMode("insert").catch((error) => + setDialogueSessionFailure(error.message || "这一幕暂时没有铺开。", "可以稍后重试,或先检查人物与场景卡。", true) + ); }); bind("detail-stop-run-button", "click", handleStopRun); bind("open-persona-review-button", "click", () => { openWorkCharacterReview().catch((error) => { - setStatus("bookshelf-status", error.message || "人物档案暂时没有载入。"); + setFlowFailureStatus("bookshelf-status", error.message || "人物档案暂时没有载入。", "可以稍后重试,或先回到书卷页刷新。", { affectsChatFlow: false }); }); }); bind("open-relation-details-button", "click", openRelationDetails); @@ -3133,7 +3280,7 @@ function bindEvents() { bind("character-overview-review-button", "click", () => { if (!currentCharacterOverview?.character) return; openPersonaReviewForCharacter(currentCharacterOverview.character).catch((error) => - setStatus("persona-review-status", error.message || "人物档案暂时没有载入。") + setPersonaReviewFailure(error.message || "人物档案暂时没有载入。", "可以稍后重试,或先回到书卷页刷新。") ); }); bind("character-overview-redistill-button", "click", () => { @@ -3142,25 +3289,29 @@ function bindEvents() { return; } if (!openCharacterOverviewIncrementalDistillViaBridge()) { - setStatus("redistill-status", "角色增量能力暂时没有载入。"); + setFlowFailureStatus("redistill-status", "角色增量能力暂时没有载入。", "可以稍后重试,或回到书卷页继续蒸馏。", { affectsChatFlow: false }); } }); bind("character-overview-act-button", "click", () => { if (typeof openCharacterOverviewSessionMode === "function") { - openCharacterOverviewSessionMode("act").catch((error) => setStatus("dialogue-session-status", error.message || "这一幕暂时没有铺开。")); + openCharacterOverviewSessionMode("act").catch((error) => + setDialogueSessionFailure(error.message || "这一幕暂时没有铺开。", "可以稍后重试,或先检查人物与场景卡。", true) + ); return; } openCharacterOverviewSessionModeViaBridge("act").catch((error) => - setStatus("dialogue-session-status", error.message || "这一幕暂时没有铺开。") + setDialogueSessionFailure(error.message || "这一幕暂时没有铺开。", "可以稍后重试,或先检查人物与场景卡。", true) ); }); bind("character-overview-insert-button", "click", () => { if (typeof openCharacterOverviewSessionMode === "function") { - openCharacterOverviewSessionMode("insert").catch((error) => setStatus("dialogue-session-status", error.message || "这一幕暂时没有铺开。")); + openCharacterOverviewSessionMode("insert").catch((error) => + setDialogueSessionFailure(error.message || "这一幕暂时没有铺开。", "可以稍后重试,或先检查人物与场景卡。", true) + ); return; } openCharacterOverviewSessionModeViaBridge("insert").catch((error) => - setStatus("dialogue-session-status", error.message || "这一幕暂时没有铺开。") + setDialogueSessionFailure(error.message || "这一幕暂时没有铺开。", "可以稍后重试,或先检查人物与场景卡。", true) ); }); bind("character-overview-export-button", "click", () => { @@ -3169,7 +3320,7 @@ function bindEvents() { return; } if (!openCurrentCharacterProfileFileViaBridge()) { - setStatus("character-overview-status", "当前人物原档暂时不可用。"); + setFlowFailureStatus("character-overview-status", "当前人物原档暂时不可用。", "可以稍后重试,或先完成人物校对。", { affectsChatFlow: false }); } }); el("character-overview-key-fields")?.addEventListener("click", (event) => { @@ -3251,12 +3402,12 @@ function bindEvents() { bind("recommend-scene-card-button", "click", handleRecommendSceneCard); bind("dialogue-live-scene-recommend", "click", (event) => { handleRecommendDialogueSceneCard(event).catch((error) => { - setStatus("dialogue-live-scene-status", error.message || "下一幕推荐失败。"); + setFlowFailureStatus("dialogue-live-scene-status", error.message || "下一幕推荐失败。", "可以稍后重试,或手动切换场景卡。", { affectsChatFlow: false }); }); }); bind("dialogue-live-scene-shift-recommend", "click", (event) => { handleRecommendDialogueSceneCard(event, { autoApply: true }).catch((error) => { - setStatus("dialogue-live-scene-status", error.message || "顺手切幕失败。"); + setFlowFailureStatus("dialogue-live-scene-status", error.message || "顺手切幕失败。", "可以稍后重试,或手动切换场景卡。", { affectsChatFlow: false }); }); }); bind("dialogue-live-scene-apply", "click", handleApplyDialogueSceneCard); @@ -3289,7 +3440,7 @@ function bindEvents() { }); bind("observe-quick-hint-send", "click", () => { applyQuickHint().catch((error) => { - setStatus("dialogue-session-status", error.message || "这句推进提示暂时没有送出去。"); + setDialogueSessionFailure(error.message || "这句推进提示暂时没有送出去。", "可以稍后重试,或直接手写下一句。", true); }); }); bind("close-dialogue-memory-modal-button", "click", () => { diff --git a/src/web/static/js/run-detail.js b/src/web/static/js/run-detail.js index 9c7ce2e..fc026e8 100644 --- a/src/web/static/js/run-detail.js +++ b/src/web/static/js/run-detail.js @@ -44,7 +44,7 @@ function renderRunSummary(run) { const elapsedText = String(run?.summary?.elapsed_text || run?.timing?.elapsed_text || "").trim(); const progressCopy = String(run.progress?.message || "").trim() || "人物与关系会依次浮现。"; const enrichedCopy = - elapsedText && run.summary?.status_text === "workflow_complete" ? `${progressCopy} · 本次用时 ${elapsedText}` : progressCopy; + elapsedText && isRunWorkflowComplete(run) ? `${progressCopy} · 本次用时 ${elapsedText}` : progressCopy; setText("progress-copy", enrichedCopy, ""); setText("work-overview-next-step", buildWorkOverviewNextStep(run), ""); setText("run-progress-review", buildWorkReviewStatus(run), ""); @@ -78,7 +78,7 @@ function buildWorkDistillStatus(run) { function renderWorkHeroMetrics(run) { const sourceName = String(getCurrentNovelSource(run)?.source_name || "").trim() || "当前书页"; const characterTotal = getRunCharacterNames(run).length; - const statusText = humanizeSummary(run?.summary?.status_text); + const statusText = humanizeSummary(runLifecycleState(run)); const elapsedText = String(run?.summary?.elapsed_text || run?.timing?.elapsed_text || "").trim() || "进行中"; setText("run-hero-source", sourceName, ""); setText("run-hero-character-total", characterTotal > 0 ? `${characterTotal} 位` : "0 位", ""); @@ -388,7 +388,7 @@ function buildWorkGraphSummaryState(run) { return WORK_OVERVIEW_STATE.buildWorkGraphSummaryState(run); } const hasGraph = Boolean(run?.artifact_index?.relation_graph?.relations_file); - const graphFailed = String(run?.summary?.graph_status || "").trim() === "failed" || String(run?.progress?.graph_status || "").trim() === "failed"; + const graphFailed = runGraphStatus(run) === "failed"; const hasCharacters = getRunCharacterNames(run).length > 0; if (hasGraph) { return { badgeText: "已完成", badgeTone: "stable", copy: "关系线已经能看,先看牵系和张力,再决定从哪种方式入场。" }; @@ -1130,7 +1130,7 @@ function renderRun(run, options = {}) { sourceHistoryExpanded = false; characterReadinessExpanded = false; workSessionPreviewExpanded = false; - runCreationPending = run.status === "running" && run.summary?.status_text !== "workflow_complete"; + runCreationPending = run.status === "running" && !isRunWorkflowComplete(run); renderRunSummary(run); renderRunEvents(run); renderRunGraphLinks(run); diff --git a/src/web/static/js/work-overview-state.js b/src/web/static/js/work-overview-state.js index 108a957..467b7d8 100644 --- a/src/web/static/js/work-overview-state.js +++ b/src/web/static/js/work-overview-state.js @@ -61,7 +61,7 @@ function buildWorkGraphStatus(run) { const hasGraph = Boolean(run?.artifact_index?.relation_graph?.relations_file); - const graphFailed = String(run?.summary?.graph_status || "").trim() === "failed" || String(run?.progress?.graph_status || "").trim() === "failed"; + const graphFailed = runGraphStatus(run) === "failed"; if (hasGraph) { return "已完成"; } @@ -183,7 +183,7 @@ const progressMessage = run?.progress?.message || "这一卷还在慢慢成形。"; const stage = String(run?.progress?.stage || "").trim(); - const summary = typeof humanizeSummary === "function" ? humanizeSummary(run?.summary?.status_text) : ""; + const summary = typeof humanizeSummary === "function" ? humanizeSummary(runLifecycleState(run)) : ""; const stopRequested = Boolean(run?.control?.stop_requested) && run?.status === "running"; if (run?.status === "running") { @@ -212,7 +212,7 @@ } const stageText = - stage === "graph_done" || run?.summary?.status_text === "workflow_complete" + stage === "graph_done" || isRunWorkflowComplete(run) ? "人物与关系已经落稳" : summary || "这一卷已经可以继续"; return { @@ -280,7 +280,7 @@ const elapsedText = String(run?.summary?.elapsed_text || run?.timing?.elapsed_text || "").trim(); const progressCopy = String(run?.progress?.message || "").trim() || "人物与关系会依次浮现。"; const enrichedCopy = - elapsedText && run?.summary?.status_text === "workflow_complete" ? `${progressCopy} · 本次用时 ${elapsedText}` : progressCopy; + elapsedText && isRunWorkflowComplete(run) ? `${progressCopy} · 本次用时 ${elapsedText}` : progressCopy; const currentSource = typeof getCurrentNovelSource === "function" ? getCurrentNovelSource(run) : null; return { title: run ? `《${runNovelTitle(run)}》` : "人物与关系正在慢慢浮现", @@ -290,7 +290,7 @@ heroMetrics: [ { label: "当前书段", value: run ? (String(currentSource?.source_name || "").trim() || "当前书页") : "-" }, { label: "角色总数", value: run ? `${(typeof getRunCharacterNames === "function" ? getRunCharacterNames(run).length : 0) || 0} 位` : "-" }, - { label: "总状态", value: run ? ((typeof humanizeSummary === "function" ? humanizeSummary(run?.summary?.status_text) : "") || "未开始") : "-" }, + { label: "总状态", value: run ? ((typeof humanizeSummary === "function" ? humanizeSummary(runLifecycleState(run)) : "") || "未开始") : "-" }, { label: "累计耗时", value: run ? (String(run?.summary?.elapsed_text || run?.timing?.elapsed_text || "").trim() || "进行中") : "-" }, ], progressMetrics: [ @@ -430,7 +430,7 @@ function buildWorkGraphSummaryState(run) { const hasGraph = Boolean(run?.artifact_index?.relation_graph?.relations_file); - const graphFailed = String(run?.summary?.graph_status || "").trim() === "failed" || String(run?.progress?.graph_status || "").trim() === "failed"; + const graphFailed = runGraphStatus(run) === "failed"; const hasCharacters = typeof getRunCharacterNames === "function" ? getRunCharacterNames(run).length > 0 : false; if (hasGraph) return { badgeText: "已完成", badgeTone: "stable", copy: "关系线已经能看,先看牵系和张力,再决定从哪种方式入场。" }; if (graphFailed) return { badgeText: "失败可跳过", badgeTone: "weak", copy: "这轮关系图谱生成失败,但不会阻塞聊天;可以先入场,稍后再补图谱。" }; diff --git a/src/web/static/js/workflow.js b/src/web/static/js/workflow.js index e0b09a1..4f596b0 100644 --- a/src/web/static/js/workflow.js +++ b/src/web/static/js/workflow.js @@ -153,7 +153,7 @@ function applyRunViewFallback(run, options = {}) { sourceHistoryExpanded = false; characterReadinessExpanded = false; workSessionPreviewExpanded = false; - runCreationPending = run.status === "running" && run.summary?.status_text !== "workflow_complete"; + runCreationPending = run.status === "running" && !isRunWorkflowComplete(run); if (!options.preserveDialogue) { resetDialogueView(); } diff --git a/src/web/static/styles/app.css b/src/web/static/styles/app.css index db56f62..e9738d8 100644 --- a/src/web/static/styles/app.css +++ b/src/web/static/styles/app.css @@ -1,5 +1,5 @@ -@import url("./base.css?v=20260514130914"); -@import url("./workspace.css?v=20260514130914"); -@import url("./dialogue.css?v=20260514130914"); -@import url("./modal.css?v=20260514130914"); -@import url("./responsive.css?v=20260514130914"); +@import url("./base.css?v=20260516154853"); +@import url("./workspace.css?v=20260516154853"); +@import url("./dialogue.css?v=20260516154853"); +@import url("./modal.css?v=20260516154853"); +@import url("./responsive.css?v=20260516154853"); diff --git a/src/web/static/version.txt b/src/web/static/version.txt index f1130b9..4dc12e4 100644 --- a/src/web/static/version.txt +++ b/src/web/static/version.txt @@ -1 +1 @@ -20260514130914 +20260516154853 diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index 764fe8c..74ba5e0 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -14,6 +14,7 @@ def test_workflow_runs_dev_checks_on_linux_and_windows(self): def test_dev_checks_exposes_smoke_mode_and_guardrail_suite(self): script_text = Path("scripts/dev_checks.py").read_text(encoding="utf-8") self.assertIn("--smoke-only", script_text) + self.assertIn("--release-tag", script_text) self.assertIn("tests.test_cli_structure", script_text) self.assertIn("tests.test_package_skill_script", script_text) self.assertIn("tests.test_release_skill", script_text) @@ -24,6 +25,8 @@ def test_dev_checks_exposes_smoke_mode_and_guardrail_suite(self): self.assertIn("tests.test_packaging_docs", script_text) self.assertIn('"run mypy"', script_text) self.assertIn('"mypy.ini"', script_text) + self.assertIn('"run release regression gate"', script_text) + self.assertIn('scripts/release_regression_gate.py', script_text) def test_mypy_config_targets_guardrail_modules(self): config_text = Path("mypy.ini").read_text(encoding="utf-8") diff --git a/tests/test_manifest_compat.py b/tests/test_manifest_compat.py index a213739..76e4b3a 100644 --- a/tests/test_manifest_compat.py +++ b/tests/test_manifest_compat.py @@ -1,10 +1,28 @@ from __future__ import annotations +import importlib.util import tempfile import unittest from pathlib import Path -from src.web.manifest.compat import coerce_manifest_path, relative_to_run_dir, rewrite_run_root_paths, rewrite_string_path + +def _load_compat_module(): + module_path = Path("src/web/manifest/compat.py").resolve() + spec = importlib.util.spec_from_file_location("manifest_compat", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"unable to load module spec: {module_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_compat = _load_compat_module() +apply_imported_run_semantics = _compat.apply_imported_run_semantics +coerce_manifest_path = _compat.coerce_manifest_path +relative_to_run_dir = _compat.relative_to_run_dir +rewrite_run_root_paths = _compat.rewrite_run_root_paths +rewrite_string_path = _compat.rewrite_string_path +reconcile_discovered_artifacts = _compat.reconcile_discovered_artifacts class ManifestCompatTests(unittest.TestCase): @@ -64,6 +82,108 @@ def test_rewrite_run_root_paths_walks_nested_manifest_shapes(self): self.assertIn(str(target_root), rewritten["artifact_index"]["characters"][0]["profile_file"]) self.assertIn(str(target_root), rewritten["artifacts"]["payloads"]["distill_王熙凤"]) + def test_reconcile_discovered_artifacts_clears_stale_relation_graph_when_missing(self): + manifest = { + "locked_characters": ["甲", "乙"], + "artifacts": {"relation_graph": {"relations_file": "/old/relation.md"}}, + "artifact_index": { + "characters": [{"name": "甲", "persona_dir": "/old/a"}], + "relation_graph": {"relations_file": "/old/relation.md"}, + }, + "progress": {"graph_status": "complete", "completed_characters": ["甲"], "completed_count": 1}, + } + + updated = reconcile_discovered_artifacts( + manifest, + character_index=[], + relation_graph=None, + ) + + self.assertEqual(updated["artifact_index"]["characters"], []) + self.assertEqual(updated["artifacts"]["character_dirs"], {}) + self.assertEqual(updated["artifacts"]["relation_graph"], {}) + self.assertEqual(updated["artifact_index"]["relation_graph"], {}) + self.assertEqual(updated["progress"]["completed_characters"], []) + self.assertEqual(updated["progress"]["completed_count"], 0) + self.assertEqual(updated["progress"]["graph_status"], "pending") + + def test_reconcile_discovered_artifacts_preserves_failed_graph_status_when_missing(self): + manifest = { + "progress": {"graph_status": "failed"}, + "artifacts": {"relation_graph": {"relations_file": "/old/relation.md"}}, + "artifact_index": {"relation_graph": {"relations_file": "/old/relation.md"}}, + } + + updated = reconcile_discovered_artifacts( + manifest, + character_index=[], + relation_graph=None, + ) + + self.assertEqual(updated["progress"]["graph_status"], "failed") + self.assertEqual(updated["artifacts"]["relation_graph"], {}) + self.assertEqual(updated["artifact_index"]["relation_graph"], {}) + + def test_reconcile_discovered_artifacts_filters_missing_payload_paths(self): + with tempfile.TemporaryDirectory() as tmp: + existing_payload = Path(tmp) / "payload.json" + existing_payload.write_text("{}", encoding="utf-8") + manifest = { + "artifacts": { + "payloads": { + "distill": str(existing_payload), + "relation": str(Path(tmp) / "missing.json"), + "empty": "", + } + } + } + + updated = reconcile_discovered_artifacts( + manifest, + character_index=[], + relation_graph=None, + ) + + self.assertEqual(updated["artifacts"]["payloads"], {"distill": str(existing_payload)}) + + def test_apply_imported_run_semantics_for_import_entrypoint(self): + with tempfile.TemporaryDirectory() as tmp: + target_root = Path(tmp) / "runs" / "run-new" + manifest = { + "novel_id": "hongloumeng", + "file_urls": {"manifest": "/old"}, + } + updated = apply_imported_run_semantics( + manifest, + target_root=target_root, + new_run_id="run-new", + imported_at="2026-05-20T00:00:00Z", + package_filename="demo.zaomeng-run.zip", + builtin_source=False, + ) + self.assertEqual(updated["run_id"], "run-new") + self.assertEqual(updated["entrypoint"], "import") + self.assertEqual(updated["imported_from"]["package_filename"], "demo.zaomeng-run.zip") + self.assertNotIn("file_urls", updated) + self.assertEqual(updated["events"][-1]["stage"], "run_imported") + self.assertTrue(str(updated["webui"]["artifact_dir"]).replace("\\", "/").endswith("/artifacts")) + + def test_apply_imported_run_semantics_for_builtin_entrypoint(self): + with tempfile.TemporaryDirectory() as tmp: + target_root = Path(tmp) / "runs" / "run-new" + manifest = {"novel_id": "hongloumeng"} + updated = apply_imported_run_semantics( + manifest, + target_root=target_root, + new_run_id="run-new", + imported_at="2026-05-20T00:00:00Z", + package_filename="builtin.zaomeng-run.zip", + builtin_source=True, + ) + self.assertEqual(updated["entrypoint"], "builtin") + self.assertEqual(updated["events"][-1]["stage"], "builtin_cloned") + self.assertEqual(updated["imported_from"]["builtin_source"], True) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_package_manifest_compatibility.py b/tests/test_package_manifest_compatibility.py new file mode 100644 index 0000000..b7f9153 --- /dev/null +++ b/tests/test_package_manifest_compatibility.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + + +def _load_packages_module(): + import importlib.util + import sys + import types + + module_path = Path("src/web/run_ops/packages.py").resolve() + spec = importlib.util.spec_from_file_location("run_ops_packages_for_test", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"unable to load module spec: {module_path}") + + src_web_pkg = types.ModuleType("src.web") + src_web_pkg.__path__ = [] # type: ignore[attr-defined] + src_web_run_ops_pkg = types.ModuleType("src.web.run_ops") + src_web_run_ops_pkg.__path__ = [] # type: ignore[attr-defined] + src_web_manifest_pkg = types.ModuleType("src.web.manifest") + src_web_manifest_pkg.__path__ = [] # type: ignore[attr-defined] + + compat_module = types.ModuleType("src.web.manifest.compat") + compat_module.rewrite_run_root_paths = lambda manifest, source_root, target_root: manifest + compat_module.apply_imported_run_semantics = ( + lambda manifest, target_root, new_run_id, imported_at, package_filename, builtin_source: manifest + ) + + state_module = types.ModuleType("src.web.run_ops.state") + state_module.derive_summary_graph_status = lambda manifest: "pending" + state_module.derive_summary_status_text = lambda manifest: "ready" + + sys.modules.setdefault("src.web", src_web_pkg) + sys.modules.setdefault("src.web.run_ops", src_web_run_ops_pkg) + sys.modules.setdefault("src.web.manifest", src_web_manifest_pkg) + sys.modules["src.web.manifest.compat"] = compat_module + sys.modules["src.web.run_ops.state"] = state_module + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_packages = _load_packages_module() +read_run_package_metadata = _packages.read_run_package_metadata + + +class PackageManifestCompatibilityTests(unittest.TestCase): + def _build_package(self, root: Path, filename: str, package_manifest: dict, *, with_run_manifest: bool = True) -> Path: + package_path = root / filename + with zipfile.ZipFile(package_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "package_manifest.json", + json.dumps(package_manifest, ensure_ascii=False, indent=2).encode("utf-8"), + ) + if with_run_manifest: + archive.writestr( + "run/run_manifest.json", + json.dumps({"run_id": "legacy-run", "webui": {"run_dir": "run"}}, ensure_ascii=False, indent=2).encode("utf-8"), + ) + return package_path + + def test_read_metadata_accepts_legacy_schema_and_fills_defaults(self): + with tempfile.TemporaryDirectory() as tmp: + package_path = self._build_package( + Path(tmp), + "legacy.zaomeng-run.zip", + { + "kind": "zaomeng_web_run_package", + "schema_version": 0, + "package_id": "legacy", + "title": "旧包", + "novel_id": "hongloumeng", + "status": "ready", + "has_relation_graph": True, + "exported_at": "2026-05-20T00:00:00Z", + }, + ) + metadata = read_run_package_metadata(package_path) + self.assertIsNotNone(metadata) + assert metadata is not None + self.assertEqual(metadata["package_id"], "legacy") + self.assertEqual(metadata["character_count"], 0) + self.assertEqual(metadata["builtin"], False) + self.assertEqual(metadata["summary"]["status_text"], "ready") + self.assertEqual(metadata["summary"]["graph_status"], "complete") + + def test_read_metadata_rejects_unknown_schema(self): + with tempfile.TemporaryDirectory() as tmp: + package_path = self._build_package( + Path(tmp), + "future.zaomeng-run.zip", + { + "kind": "zaomeng_web_run_package", + "schema_version": 99, + "package_id": "future", + "title": "未来包", + "novel_id": "hongloumeng", + "status": "ready", + "exported_at": "2026-05-20T00:00:00Z", + }, + ) + metadata = read_run_package_metadata(package_path) + self.assertIsNone(metadata) + + def test_read_metadata_fills_missing_summary_fields_in_current_schema(self): + with tempfile.TemporaryDirectory() as tmp: + package_path = self._build_package( + Path(tmp), + "v1.zaomeng-run.zip", + { + "kind": "zaomeng_web_run_package", + "schema_version": 1, + "package_id": "v1", + "title": "当前包", + "novel_id": "hongloumeng", + "status": "ready", + "character_count": "2", + "has_relation_graph": False, + "summary": {"status_text": ""}, + "exported_at": "2026-05-20T00:00:00Z", + }, + ) + metadata = read_run_package_metadata(package_path) + self.assertIsNotNone(metadata) + assert metadata is not None + self.assertEqual(metadata["character_count"], 2) + self.assertEqual(metadata["summary"]["status_text"], "ready") + self.assertEqual(metadata["summary"]["graph_status"], "pending") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_packaging_docs.py b/tests/test_packaging_docs.py index 5b8783a..31e3d47 100644 --- a/tests/test_packaging_docs.py +++ b/tests/test_packaging_docs.py @@ -127,6 +127,13 @@ def test_chat_contract_reference_is_present(self): self.assertIn("PROFILE.md", contract_text) self.assertIn("MEMORY.md", contract_text) self.assertIn("run_manifest.json", contract_text) + self.assertIn("Canonical Dialogue Runtime State", contract_text) + self.assertIn("Required vs Optional Fields", contract_text) + self.assertIn("state.scene", contract_text) + self.assertIn("state.relations.delta", contract_text) + self.assertIn("state.characters.snapshots", contract_text) + self.assertIn("Optional with graceful downgrade", contract_text) + self.assertIn("scene_progress", contract_text) self.assertNotIn("src.cli.app chat", contract_text) def test_capability_index_reference_is_present(self): @@ -141,6 +148,9 @@ def test_capability_index_reference_is_present(self): self.assertIn("scene_recommendation_context.example.json", capability_text) self.assertIn("references/chat_contract.md", capability_text) self.assertIn("examples/host_workflow_example.md", capability_text) + self.assertIn("Canonical alignment rules", capability_text) + self.assertIn("prefer canonical `state.*` fields as the source of truth", capability_text) + self.assertIn("`scene_progress` / `relation_delta` / `character_snapshots`", capability_text) self.assertNotIn("src.cli.app chat", capability_text) def test_host_workflow_example_is_present(self): @@ -184,6 +194,29 @@ def test_distillation_docs_require_multi_character_differentiation(self): self.assertIn("evidence_source", validation_text) self.assertIn("interest_claim", validation_text) + def test_data_dictionary_declares_session_state_stability_levels(self): + dictionary_text = Path("docs/data-dictionary.md").read_text(encoding="utf-8") + self.assertIn("稳定性分级(2026-05-20)", dictionary_text) + self.assertIn("`state.version`: `stable`", dictionary_text) + self.assertIn("`state.scene`: `stable`", dictionary_text) + self.assertIn("`state.progression`: `evolving`", dictionary_text) + self.assertIn("`state.relations.delta`: `evolving`", dictionary_text) + self.assertIn("`state.characters.snapshots`: `evolving`", dictionary_text) + self.assertIn("`state.signals`: `experimental`", dictionary_text) + self.assertIn("升级影响约定:", dictionary_text) + + def test_runtime_contract_declares_code_ownership_mapping(self): + contract_text = Path("docs/runtime-contract.md").read_text(encoding="utf-8") + self.assertIn("## 7. 代码目录职责映射(P2-3)", contract_text) + self.assertIn("scripts/install.sh", contract_text) + self.assertIn("scripts/run_webui.py", contract_text) + self.assertIn("src/web/service_facades/update.py", contract_text) + self.assertIn("src/web/run_ops/packages.py", contract_text) + self.assertIn("src/web/manifest", contract_text) + self.assertIn("src/web/chat", contract_text) + self.assertIn("src/web/static", contract_text) + self.assertIn("兼容修补集中入口", contract_text) + def test_skill_version_is_synced_across_metadata_and_release_docs(self): skill_dir = Path("zaomeng-skill") version = read_skill_version(skill_dir) diff --git a/tests/test_release_regression_gate.py b/tests/test_release_regression_gate.py new file mode 100644 index 0000000..9312abc --- /dev/null +++ b/tests/test_release_regression_gate.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import unittest +from pathlib import Path + +from scripts.release_regression_gate import evaluate_signoff + + +class ReleaseRegressionGateTests(unittest.TestCase): + def test_gate_accepts_all_pass_payload(self): + payload = { + "release_tag": "v2026.05.16", + "checked_at": "2026-05-20", + "checked_by": ["owner"], + "platforms": { + "windows": {"install": "pass", "update": "pass", "run": "pass", "import_export": "pass"}, + "wsl": {"install": "pass", "update": "pass", "run": "pass", "import_export": "pass"}, + "linux": {"install": "pass", "update": "pass", "run": "pass", "import_export": "pass"}, + "termux": {"install": "pass", "update": "pass", "run": "pass", "import_export": "pass"}, + }, + } + errors = evaluate_signoff(payload, expected_release_tag="v2026.05.16") + self.assertEqual(errors, []) + + def test_gate_rejects_pending_or_missing_fields(self): + payload = { + "release_tag": "", + "checked_at": "", + "checked_by": [], + "platforms": { + "windows": {"install": "pending", "update": "pass", "run": "pass", "import_export": "pass"}, + "wsl": {"install": "pass", "update": "pass", "run": "pass", "import_export": "pass"}, + "linux": {"install": "pass", "update": "pass", "run": "pass", "import_export": "pass"}, + "termux": {"install": "pass", "update": "pass", "run": "pass", "import_export": "pass"}, + }, + } + errors = evaluate_signoff(payload, expected_release_tag="v2026.05.16") + self.assertTrue(any("missing release_tag" in item for item in errors)) + self.assertTrue(any("missing checked_at" in item for item in errors)) + self.assertTrue(any("checked_by must contain" in item for item in errors)) + self.assertTrue(any("release_tag mismatch" in item for item in errors)) + self.assertTrue(any("windows.install is 'pending'" in item for item in errors)) + + def test_signoff_template_exists(self): + self.assertTrue(Path("docs/release-regression-signoff.json").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_skill.py b/tests/test_release_skill.py index 490f5ad..ab2f0ed 100644 --- a/tests/test_release_skill.py +++ b/tests/test_release_skill.py @@ -91,6 +91,12 @@ def test_release_skill_cli_reports_web_static_asset_version(self): self.assertIn("Web static asset version:", result.stdout) self.assertIn("Released skill archive:", result.stdout) + def test_release_skill_cli_accepts_release_tag_argument(self): + script_path = Path(__file__).resolve().parents[1] / "scripts" / "release_skill.py" + content = script_path.read_text(encoding="utf-8") + self.assertIn("--release-tag", content) + self.assertIn("release_tag", content) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_run_manifest_summary_projection.py b/tests/test_run_manifest_summary_projection.py new file mode 100644 index 0000000..00204f2 --- /dev/null +++ b/tests/test_run_manifest_summary_projection.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + + +def _load_state_module(): + module_path = Path("src/web/run_ops/state.py").resolve() + spec = importlib.util.spec_from_file_location("run_ops_state", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"unable to load module spec: {module_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_state = _load_state_module() +derive_summary_graph_status = _state.derive_summary_graph_status +derive_summary_status_text = _state.derive_summary_status_text +project_manifest_summary = _state.project_manifest_summary + + +class RunManifestSummaryProjectionTests(unittest.TestCase): + def test_running_payload_ready_projects_waiting_for_host_generation(self): + manifest = { + "status": "running", + "progress": { + "stage": "relation_payload_ready", + "completed_count": 0, + "total_characters": 2, + "graph_status": "pending", + }, + "control": {"stop_requested": False}, + "summary": {}, + "locked_characters": ["A", "B"], + } + self.assertEqual(derive_summary_status_text(manifest), "waiting_for_host_generation") + + def test_running_with_all_characters_done_and_pending_graph_projects_graph_pending(self): + manifest = { + "status": "running", + "progress": { + "stage": "distilling", + "completed_count": 2, + "total_characters": 2, + "graph_status": "pending", + }, + "control": {"stop_requested": False}, + "summary": {}, + "locked_characters": ["A", "B"], + } + self.assertEqual(derive_summary_status_text(manifest), "graph_pending") + + def test_ready_projects_workflow_complete(self): + manifest = { + "status": "ready", + "progress": {"graph_status": "complete", "completed_count": 2, "total_characters": 2}, + "control": {"stop_requested": False}, + "summary": {}, + "locked_characters": ["A", "B"], + } + self.assertEqual(derive_summary_status_text(manifest), "workflow_complete") + self.assertEqual(derive_summary_graph_status(manifest), "complete") + + def test_project_summary_writes_consistent_fields(self): + manifest = { + "status": "stopped", + "progress": {"completed_count": 1, "total_characters": 3, "graph_status": "running"}, + "control": {"stop_requested": True}, + "timing": {"elapsed_text": "3分钟"}, + "summary": {}, + "locked_characters": ["A", "B", "C"], + } + summary = project_manifest_summary(manifest) + self.assertEqual(summary["status_text"], "stopped") + self.assertEqual(summary["graph_status"], "running") + self.assertEqual(summary["characters_total"], 3) + self.assertEqual(summary["characters_completed"], 1) + self.assertEqual(summary["elapsed_text"], "3分钟") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_web_app.py b/tests/test_web_app.py index c33dfc2..42a486d 100644 --- a/tests/test_web_app.py +++ b/tests/test_web_app.py @@ -1903,6 +1903,7 @@ def test_stop_run_marks_manifest_and_blocks_non_running_status(self): stopped = service.stop_run(run["run_id"]) self.assertTrue(stopped["control"]["stop_requested"]) self.assertEqual(stopped["summary"]["status_text"], "stop_requested") + self.assertEqual(stopped["progress"]["stage"], "characters_locked") self.assertIn("正在收束当前步骤", stopped["progress"]["message"]) manifest_path = Path(tmp) / "runs" / run["run_id"] / "run_manifest.json" diff --git a/tests/test_web_frontend_bridge_sync.py b/tests/test_web_frontend_bridge_sync.py index 1e4f100..75e3f15 100644 --- a/tests/test_web_frontend_bridge_sync.py +++ b/tests/test_web_frontend_bridge_sync.py @@ -21,7 +21,9 @@ class WebFrontendBridgeSyncTests(unittest.TestCase): def test_bootstrap_loads_webui_api_before_bookshelf_island(self): content = read_js("bootstrap.js") api_index = content.index('/web/js/webui-api.js?v=${version}') + main_index = content.index('/web/js/main.js?v=${version}') island_index = content.index('/web/js/bookshelf-vue-island.js?v=${version}') + self.assertLess(api_index, main_index) self.assertLess(api_index, island_index) def test_bootstrap_keeps_optional_islands_non_fatal(self): diff --git a/zaomeng-skill/references/capability_index.md b/zaomeng-skill/references/capability_index.md index a8b2517..be90304 100644 --- a/zaomeng-skill/references/capability_index.md +++ b/zaomeng-skill/references/capability_index.md @@ -138,6 +138,12 @@ At dialogue time, the host should read: - `run_manifest.json` - constraint references such as `output_schema.md`, `style_differ.md`, and `logic_constraint.md` +Canonical alignment rules: + +- prefer canonical `state.*` fields as the source of truth +- treat `scene_progress` / `relation_delta` / `character_snapshots` as derived compatibility projections +- classify fields into required handshake vs optional downgrade buckets as defined in `references/chat_contract.md` + Reference: - `references/chat_contract.md` diff --git a/zaomeng-skill/references/chat_contract.md b/zaomeng-skill/references/chat_contract.md index 7ea0b30..cda3e5a 100644 --- a/zaomeng-skill/references/chat_contract.md +++ b/zaomeng-skill/references/chat_contract.md @@ -47,6 +47,40 @@ Recommended additional persona inputs when present: - `CONFLICTS.md` - `ROLE.md` +## Canonical Dialogue Runtime State + +The host should treat canonical session state as the source of truth. + +Primary canonical shape: + +- `state.scene` +- `state.presence` +- `state.progression` +- `state.relations.matrix` +- `state.relations.delta` +- `state.characters.snapshots` +- `state.signals` +- `state.memory.summary` + +Compatibility projections such as `scene_progress`, `relation_delta`, and `character_snapshots` are derived views, not primary storage. + +## Required vs Optional Fields + +Required (host should read/write these as stable handshake fields): + +- `state.scene.location` +- `state.scene.time_hint` +- `state.presence.present_participants` +- `state.relations.delta` +- `state.characters.snapshots` + +Optional with graceful downgrade (host should continue if missing): + +- `state.progression.world_tension_summary` +- `state.progression.scene_shift_reason` +- `state.signals` +- `state.memory.summary` + ## Host Responsibilities ### 1. Mode Selection From 097fcf3a0505e1db55a23bf90899247271b1c094 Mon Sep 17 00:00:00 2001 From: wkbin Date: Wed, 20 May 2026 20:32:46 +0800 Subject: [PATCH 2/6] chore(docs): remove temporary release and optimization docs --- docs/optimization-summary.md | 73 ---------------------------- docs/release-notes-v2026.05.16.md | 24 --------- docs/release-regression-gate.md | 43 ---------------- docs/release-regression-signoff.json | 35 ------------- 4 files changed, 175 deletions(-) delete mode 100644 docs/optimization-summary.md delete mode 100644 docs/release-notes-v2026.05.16.md delete mode 100644 docs/release-regression-gate.md delete mode 100644 docs/release-regression-signoff.json diff --git a/docs/optimization-summary.md b/docs/optimization-summary.md deleted file mode 100644 index fd9f778..0000000 --- a/docs/optimization-summary.md +++ /dev/null @@ -1,73 +0,0 @@ -# 优化项汇总(来自 docs) - -更新时间:2026-05-20 - -本清单汇总自以下文档中的“后续建议 / 后续收口方向 / 未完成项”: - -- `docs/stage-closure-checklist.md` -- `docs/manifest-compatibility.md` -- `docs/data-dictionary.md` - -## P0(优先执行) - -1. 主流程状态反馈统一 - - 统一 loading / 成功 / 失败 / 下一步建议文案规范。 - - 失败文案必须说明“是否影响聊天主流程”。 - - 成功文案统一给出下一步动作建议。 - -2. 跨平台主流程实机回归清单 - - 覆盖 Windows / WSL / Linux / Termux 的安装、更新、运行、导入导出链路。 - - 将回归项与发布流程绑定,作为发版前 gate。 - -3. manifest source-of-truth 与 derived 字段彻底拆分 - - [x] `run_manifest.json` 明确真相字段与投影视图字段边界。 - - [x] 避免使用 `summary` 决策核心流程(后端核心流程 + 前端关键判定已切到 `status/progress/control`)。 - -## P1(主流程稳定性) - -1. 对话自然度继续打磨 - - 时间推进、离场/回场、场景推进从“可用”提升到“自然”。 - - 继续减少 observe 模式“推得生硬”的情况。 - -2. 对话压缩质量提升 - - 重点验证长会话稳定性。 - - 对近期承诺、冲突、动作、重大事件、当前目标、未收线摘要继续优化。 - -3. 前端 dual-path 收口 - - 持续减少 legacy / Vue 双轨残留,降低维护复杂度与状态漂移风险。 - -## P1(资产与兼容) - -1. package manifest 正式兼容策略冻结 - - [x] 定义 schema version 迁移规范与版本演进规则。 - - [x] 明确旧字段迁移与默认值策略。 - -2. 缺失 artifact 降级策略统一入兼容层 - - [x] graph / payload / 可选产物缺失时统一降级说明与行为。 - - [x] 兼容逻辑集中到 manifest compatibility layer,避免业务模块散落兜底。 - -3. 字段语义迁移统一治理 - - [x] 路径问题、相对路径换算、导入后重写继续走兼容层。 - - [x] 字段语义迁移已形成固定入口与规范(`apply_imported_run_semantics`)。 - -## P2(契约与文档) - -1. session state 字段稳定性分级 - - [x] 为 `state` 各子块标注稳定级别(stable / evolving / experimental)。 - - [x] 给调用方明确升级影响面。 - -2. skill 侧精简数据契约 - - [x] 与 Web UI 对齐 canonical 字段,不重复兼容补丁。 - - [x] 明确哪些字段是必需、哪些字段是可选降级。 - -3. 运行期边界继续收口 - - [x] 安装/运行/更新/导入导出职责边界从文档落到代码目录与模块职责。 - - [x] 降低后续“边界继续散开”风险。 - -## 建议执行顺序(两周滚动) - -1. 主流程状态反馈统一 + 发布回归 gate(P0) -2. manifest 真相/投影拆分 + 兼容层补全(P0/P1) -3. 对话自然度与压缩质量提升(P1) -4. 前端 dual-path 收口(P1) -5. 契约稳定性分级与文档固化(P2) diff --git a/docs/release-notes-v2026.05.16.md b/docs/release-notes-v2026.05.16.md deleted file mode 100644 index 032afd1..0000000 --- a/docs/release-notes-v2026.05.16.md +++ /dev/null @@ -1,24 +0,0 @@ -# Zaomeng v2026.05.16 发布说明 - -发布日期:2026-05-16 - -## 本次亮点 - -- 新增角色别名知识库,支持 canonical 与 alias 双向匹配。 -- 修复 skill / host / web 在角色名归一化上的一致性问题。 -- 补充发布回归测试与安装脚本防护测试,覆盖更新与别名关键路径。 - -## 变更明细 - -- `feat(skill): add character alias knowledge base for bidirectional name matching` (`f6bd767`) -- `fix(skill): align host/web and skill-side normalization, add normalized alias lookup` (`823e68e`) -- `Merge pull request #14 from buyaoxiangtale/feat/character-alias-knowledge-base` (`391f510`) -- `test(release): add regression checklist and alias/install guard tests` (`f2d5c18`) - -## 新贡献者 - -- @buyaoxiangtale 首次贡献(PR [#14](https://github.com/wkbin/zaomeng/pull/14)) - -## 完整对比 - -- [v2026.05.14...v2026.05.16](https://github.com/wkbin/zaomeng/compare/v2026.05.14...v2026.05.16) diff --git a/docs/release-regression-gate.md b/docs/release-regression-gate.md deleted file mode 100644 index 2fe87ec..0000000 --- a/docs/release-regression-gate.md +++ /dev/null @@ -1,43 +0,0 @@ -# 发布回归 Gate(跨平台) - -更新时间:2026-05-20 - -## 目的 - -把“跨平台主流程实机回归”从口头清单变成发版前硬 gate。 -默认流程下,未完成签核会阻断 `release_skill.py` 打包发布链路。 - -## 覆盖范围 - -必须覆盖以下平台与主流程: - -- 平台:Windows / WSL / Linux / Termux -- 主流程:install / update / run / import_export - -## 签核文件 - -路径:`docs/release-regression-signoff.json` - -字段要求: - -- `release_tag`:本次发布 tag,例如 `v2026.05.16` -- `checked_at`:签核日期(`YYYY-MM-DD`) -- `checked_by`:至少一位签核人 -- `platforms..`:必须全部为 `pass` - -允许值:`pass` / `fail` / `pending` - -## 校验命令 - -```bash -python scripts/release_regression_gate.py --release-tag v2026.05.16 -``` - -如不传 `--release-tag`,只校验字段完整性与 `pass` 状态。 - -## 与发布流程的关系 - -- `scripts/dev_checks.py` 默认会执行该 gate -- `scripts/dev_checks.py --smoke-only` 不执行该 gate(用于开发期快速回归) -- `scripts/release_skill.py` 默认调用 `dev_checks.py`,因此会自动带上该 gate - diff --git a/docs/release-regression-signoff.json b/docs/release-regression-signoff.json deleted file mode 100644 index f268048..0000000 --- a/docs/release-regression-signoff.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "release_tag": "v2026.05.16", - "checked_at": "2026-05-20", - "checked_by": [ - "manual-qa", - "release-owner" - ], - "notes": "Manual regression completed across Windows / WSL / Linux / Termux for install, update, run, and import_export flows.", - "platforms": { - "windows": { - "install": "pass", - "update": "pass", - "run": "pass", - "import_export": "pass" - }, - "wsl": { - "install": "pass", - "update": "pass", - "run": "pass", - "import_export": "pass" - }, - "linux": { - "install": "pass", - "update": "pass", - "run": "pass", - "import_export": "pass" - }, - "termux": { - "install": "pass", - "update": "pass", - "run": "pass", - "import_export": "pass" - } - } -} From b9d57d0fe1661f02ce9f535ed183af1af231849a Mon Sep 17 00:00:00 2001 From: wkbin Date: Wed, 20 May 2026 20:43:22 +0800 Subject: [PATCH 3/6] chore(docs): prune root docs and archive design notes --- docs/{ => archive}/session-state-v1.md | 0 .../shared-capability-checklist.md | 0 docs/data-dictionary.md | 16 ++-- docs/manifest-compatibility.md | 59 ------------- docs/stage-closure-checklist.md | 87 ------------------- 5 files changed, 8 insertions(+), 154 deletions(-) rename docs/{ => archive}/session-state-v1.md (100%) rename docs/{ => archive}/shared-capability-checklist.md (100%) delete mode 100644 docs/manifest-compatibility.md delete mode 100644 docs/stage-closure-checklist.md diff --git a/docs/session-state-v1.md b/docs/archive/session-state-v1.md similarity index 100% rename from docs/session-state-v1.md rename to docs/archive/session-state-v1.md diff --git a/docs/shared-capability-checklist.md b/docs/archive/shared-capability-checklist.md similarity index 100% rename from docs/shared-capability-checklist.md rename to docs/archive/shared-capability-checklist.md diff --git a/docs/data-dictionary.md b/docs/data-dictionary.md index 0678042..0ea2f6d 100644 --- a/docs/data-dictionary.md +++ b/docs/data-dictionary.md @@ -22,9 +22,9 @@ 主文件来源: -- [creation.py](d:/work2/Dreamforge/src/web/run_ops/creation.py) -- [store.py](d:/work2/Dreamforge/src/web/manifest/store.py) -- [views.py](d:/work2/Dreamforge/src/web/manifest/views.py) +- [creation.py](../../src/web/run_ops/creation.py) +- [store.py](../../src/web/manifest/store.py) +- [views.py](../../src/web/manifest/views.py) 磁盘位置: @@ -111,7 +111,7 @@ source of truth(已落地): 主文件来源: -- [packages.py](d:/work2/Dreamforge/src/web/run_ops/packages.py) +- [packages.py](../../src/web/run_ops/packages.py) 压缩包内路径: @@ -168,7 +168,7 @@ source of truth(已落地): 主文件来源: -- [service.py](d:/work2/Dreamforge/src/web/chat/service.py) +- [service.py](../../src/web/chat/service.py) 磁盘位置: @@ -243,8 +243,8 @@ source of truth 建议: 主文件来源: -- [session-state-v1.md](d:/work2/Dreamforge/docs/session-state-v1.md) -- [service.py](d:/work2/Dreamforge/src/web/chat/service.py) +- [session-state-v1.md](./archive/session-state-v1.md) +- [service.py](../../src/web/chat/service.py) `state` 是会话运行态唯一真相。 @@ -377,7 +377,7 @@ source of truth 建议: 来源: -- [service.py](d:/work2/Dreamforge/src/web/chat/service.py) +- [service.py](../../src/web/chat/service.py) 定位: diff --git a/docs/manifest-compatibility.md b/docs/manifest-compatibility.md deleted file mode 100644 index 49a1453..0000000 --- a/docs/manifest-compatibility.md +++ /dev/null @@ -1,59 +0,0 @@ -# Manifest Compatibility Layer - -更新时间:2026-05-14 - -## 目标 - -把旧 manifest、旧路径字符串、大小写差异、Windows / WSL / Linux 路径差异的兼容处理,集中到一个薄层里。 - -这层的职责不是修改业务语义,而是回答两个问题: - -1. 这个旧值还能不能被安全识别成路径? -2. 这个旧路径在导入或运行时,能不能被安全重写到当前 run 目录? - -## 当前位置 - -兼容层代码位于: - -- [compat.py](d:/work2/Dreamforge/src/web/manifest/compat.py) - -当前集中处理的能力有: - -- `coerce_manifest_path` - - 负责把旧 manifest 中的字符串路径、`Path`、以及无效旧值区分开 - - 会主动忽略空字符串、字典、列表、过长非法路径等旧残留 -- `relative_to_run_dir` - - 负责把真实文件路径转换成 run 目录下的相对路径 - - 容忍大小写差异、短路径差异、`realpath` 差异 -- `rewrite_string_path` - - 负责把导入包中的旧根路径改写到新 run 根目录 - - 兼容 Windows 正反斜杠混用 -- `rewrite_run_root_paths` - - 递归处理嵌套 dict / list 中的路径字符串 - -## 当前接入点 - -- [views.py](d:/work2/Dreamforge/src/web/manifest/views.py) - - 用于 `file_urls` 构建时识别旧路径值 -- [packages.py](d:/work2/Dreamforge/src/web/run_ops/packages.py) - - 用于导入小说包后,把旧 run 根路径整体重写到新目录 - -## 后续收口方向 - -还没有完全收进这层的内容包括: - -- 旧 manifest 字段级别的默认值修补 -- package schema 的版本迁移策略 -- 缺失 artifact 时的统一降级说明 -- source-of-truth 字段与 derived 字段的彻底拆分 - -## 规则 - -后续如果再遇到 manifest 兼容问题,优先判断它属于哪一类: - -1. 路径识别问题 -2. 相对路径换算问题 -3. 导入后路径重写问题 -4. 字段语义迁移问题 - -前 3 类优先进入这层,别再散落到业务模块里各自兜底。 diff --git a/docs/stage-closure-checklist.md b/docs/stage-closure-checklist.md deleted file mode 100644 index dffff02..0000000 --- a/docs/stage-closure-checklist.md +++ /dev/null @@ -1,87 +0,0 @@ -# 阶段收尾执行表 - -更新时间:2026-05-14 - -这份清单不是路线畅想,而是基于当前代码现状整理出来的收尾表。 -目标是把已经成形的主线继续压到“可稳定推荐他人试用”的状态。 - -## 阶段 1:首体验与主流程反馈 - -### 已完成 - -- [x] 首次启动自动检查模型配置,未配置时直接引导到设置入口 -- [x] 已配置时优先进入书架 / 内置小说试玩路径 -- [x] 内置小说为空时给出明确提示 -- [x] 首次启动自动检查更新,并支持一键更新后刷新页面 -- [x] 安装脚本已经覆盖 Windows / WSL / Linux / Termux 的主要安装分支 - -### 部分完成 - -- [ ] 主流程状态反馈统一 - - 现状:导入、导出、更新、内置小说、继续蒸馏等流程已有统一 feedback helper,但入口仍然分散 - - 收尾目标:统一 loading、成功、失败、下一步建议文案规范 -- [ ] 失败文案统一说明“是否影响聊天” - - 现状:核心主流程已开始统一,但覆盖不完整 -- [ ] 成功文案统一给出下一步建议 - - 现状:核心路径已有,尚未全站一致 - -### 待完成 - -- [x] 整理安装 / 更新 / 运行 / 导入导出的一份 runtime contract 文档 -- [x] 做一轮跨平台主流程实机回归清单 - - 已落地发版前 gate:`docs/release-regression-signoff.json` + `scripts/release_regression_gate.py` - -## 阶段 2:让聊天真正“活起来” - -### 已完成 - -- [x] 建立 canonical session state -- [x] 建立 `event_signals` -- [x] 建立 `character_snapshots` -- [x] 建立会话级关系增量状态 -- [x] 建立下一幕推荐与自动续接链路 -- [x] Web UI 与 skill 共享场景推荐核心逻辑 - -### 部分完成 - -- [ ] 时间推进、角色离场 / 回场、场景推进已补上基础稳定链路,但自然度仍需继续打磨 -- [ ] 对话压缩已从“简单截断”升级,并已补强近期承诺、冲突、动作、重大事件、当前目标与未收线摘要;但仍可继续提升自然度与跨长会话稳定性 -- [ ] 旁观模式推动剧情已可用,但仍可继续减少“推得生硬”的情况 - -## 阶段 3:小说资产稳定格式 - -### 已完成 - -- [x] 建立 run package schema version -- [x] 打通导入 / 导出小说包 -- [x] 内置小说 / 本地运行小说 / 导入小说包三类资产已基本分流 -- [x] 已有一批针对旧路径、Windows 路径和旧 manifest 的兼容修复 - -### 已完成 - -- [x] package manifest 的兼容规则已形成正式冻结文档 -- [x] schema version 兼容策略已集中到包兼容入口(含未知版本拒绝与 legacy 回填) -- [x] 缺失 artifact / graph / payload 的降级策略已统一进入兼容层(集中在 manifest compatibility) - -### 待完成 - -- [x] 抽出 manifest compatibility layer -- [x] 补一份 package / manifest 数据字典 - -## 架构风险清单 - -### 仍需继续收口 - -- [ ] `run_manifest.json` 仍然偏胖,需要再区分 source-of-truth 与 derived fields -- [x] Web UI 与 skill 的能力同步补了一份正式 checklist -- [ ] 安装、运行、更新、导入导出的责任边界仍偏散 -- [ ] 前端 legacy / Vue 双轨遗留仍未完全消掉 -- [ ] 数据兼容逻辑仍未集中到单一层 - -## 当前优先顺序 - -1. 统一主流程状态反馈 -2. 再做一轮对话压缩质量提升 -3. 继续打磨时间推进 / 离场回场 / 场景推进自然度 -4. 继续减少前端 dual-path 遗留 -5. 做一轮跨平台主流程实机回归清单(已纳入发版 gate) From 9a4051949984dbdd905d457a946ae9cb73dacd6c Mon Sep 17 00:00:00 2001 From: wkbin Date: Thu, 21 May 2026 00:35:31 +0800 Subject: [PATCH 4/6] webui: simplify observe composer and suppress auto-prompt transcript noise --- src/web/api/routes/dialogue.py | 2 + src/web/api/schemas.py | 1 + src/web/chat/entrypoints.py | 2 + src/web/service_facades/dialogue.py | 4 + src/web/static/fragments/main-shell.html | 6 +- src/web/static/index.html | 4 +- src/web/static/js/bootstrap.js | 2 +- src/web/static/js/composer-vue-island.js | 30 ++-- src/web/static/js/dialogue.js | 3 + src/web/static/js/main.js | 219 ++++++++++++++++++----- src/web/static/js/workflow.js | 3 + src/web/static/styles/dialogue.css | 18 ++ tests/test_web_app.py | 70 ++++++++ 13 files changed, 297 insertions(+), 67 deletions(-) diff --git a/src/web/api/routes/dialogue.py b/src/web/api/routes/dialogue.py index ec35b3c..6119b58 100644 --- a/src/web/api/routes/dialogue.py +++ b/src/web/api/routes/dialogue.py @@ -107,6 +107,7 @@ def prepare_dialogue_turn( session_id=session_id, message=payload.message, message_kind=payload.message_kind, + suppress_transcript_message=payload.suppress_transcript_message, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="Session not found.") from exc @@ -127,6 +128,7 @@ def reply_dialogue_turn( session_id=session_id, message=payload.message, message_kind=payload.message_kind, + suppress_transcript_message=payload.suppress_transcript_message, ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="Session not found.") from exc diff --git a/src/web/api/schemas.py b/src/web/api/schemas.py index d130fe6..e9ba4dc 100644 --- a/src/web/api/schemas.py +++ b/src/web/api/schemas.py @@ -191,6 +191,7 @@ class SaveSelfCardRequest(BaseModel): class PrepareDialogueTurnRequest(BaseModel): message: str = Field(..., min_length=1) message_kind: str = Field(default="dialogue") + suppress_transcript_message: bool = Field(default=False) class SuggestDialogueTurnRequest(BaseModel): diff --git a/src/web/chat/entrypoints.py b/src/web/chat/entrypoints.py index d568b29..c157a7b 100644 --- a/src/web/chat/entrypoints.py +++ b/src/web/chat/entrypoints.py @@ -103,6 +103,7 @@ def reply_dialogue_turn_payload( session_id: str, message: str, message_kind: str, + suppress_transcript_message: bool = False, manifest: dict[str, Any], dialogue: Any, load_pending_turn_payload: Callable[[str, str], dict[str, Any]], @@ -118,6 +119,7 @@ def reply_dialogue_turn_payload( message=message, message_kind=message_kind, speaker_override=speaker_override, + transcript_message="" if suppress_transcript_message else None, ) pending_payload = load_pending_turn_payload(run_id, session_id) try: diff --git a/src/web/service_facades/dialogue.py b/src/web/service_facades/dialogue.py index 0add7d7..48a5a7b 100644 --- a/src/web/service_facades/dialogue.py +++ b/src/web/service_facades/dialogue.py @@ -160,6 +160,7 @@ def prepare_dialogue_turn( session_id: str, message: str, message_kind: str = "dialogue", + suppress_transcript_message: bool = False, ) -> dict[str, Any]: manifest = self._require_manifest(run_id) return self.dialogue.prepare_turn( @@ -167,6 +168,7 @@ def prepare_dialogue_turn( session_id=session_id, message=message, message_kind=message_kind, + transcript_message="" if suppress_transcript_message else None, ) def reply_dialogue_turn( @@ -176,6 +178,7 @@ def reply_dialogue_turn( session_id: str, message: str, message_kind: str = "dialogue", + suppress_transcript_message: bool = False, ) -> dict[str, Any]: manifest = self._require_manifest(run_id) return reply_dialogue_turn_payload( @@ -183,6 +186,7 @@ def reply_dialogue_turn( session_id=session_id, message=message, message_kind=message_kind, + suppress_transcript_message=suppress_transcript_message, manifest=manifest, dialogue=self.dialogue, load_pending_turn_payload=self._load_pending_turn_payload, diff --git a/src/web/static/fragments/main-shell.html b/src/web/static/fragments/main-shell.html index 506dc50..645d68f 100644 --- a/src/web/static/fragments/main-shell.html +++ b/src/web/static/fragments/main-shell.html @@ -128,9 +128,9 @@
-