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 3072bed..0ea2f6d 100644 --- a/docs/data-dictionary.md +++ b/docs/data-dictionary.md @@ -1,6 +1,6 @@ # 内部数据字典 -更新时间:2026-05-14 +更新时间:2026-05-20 这份文档只描述当前主线里最值得稳定下来的几类核心结构: @@ -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) 磁盘位置: @@ -82,20 +82,36 @@ - `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 主文件来源: -- [packages.py](d:/work2/Dreamforge/src/web/run_ops/packages.py) +- [packages.py](../../src/web/run_ops/packages.py) 压缩包内路径: @@ -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`,确保未知版本不会进入后续导入逻辑 + 定位: - 这是“包级元数据”,不是完整运行态 @@ -139,7 +168,7 @@ source of truth 建议: 主文件来源: -- [service.py](d:/work2/Dreamforge/src/web/chat/service.py) +- [service.py](../../src/web/chat/service.py) 磁盘位置: @@ -214,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` 是会话运行态唯一真相。 @@ -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` @@ -321,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/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-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 deleted file mode 100644 index f65d029..0000000 --- a/docs/stage-closure-checklist.md +++ /dev/null @@ -1,86 +0,0 @@ -# 阶段收尾执行表 - -更新时间:2026-05-14 - -这份清单不是路线畅想,而是基于当前代码现状整理出来的收尾表。 -目标是把已经成形的主线继续压到“可稳定推荐他人试用”的状态。 - -## 阶段 1:首体验与主流程反馈 - -### 已完成 - -- [x] 首次启动自动检查模型配置,未配置时直接引导到设置入口 -- [x] 已配置时优先进入书架 / 内置小说试玩路径 -- [x] 内置小说为空时给出明确提示 -- [x] 首次启动自动检查更新,并支持一键更新后刷新页面 -- [x] 安装脚本已经覆盖 Windows / WSL / Linux / Termux 的主要安装分支 - -### 部分完成 - -- [ ] 主流程状态反馈统一 - - 现状:导入、导出、更新、内置小说、继续蒸馏等流程已有统一 feedback helper,但入口仍然分散 - - 收尾目标:统一 loading、成功、失败、下一步建议文案规范 -- [ ] 失败文案统一说明“是否影响聊天” - - 现状:核心主流程已开始统一,但覆盖不完整 -- [ ] 成功文案统一给出下一步建议 - - 现状:核心路径已有,尚未全站一致 - -### 待完成 - -- [x] 整理安装 / 更新 / 运行 / 导入导出的一份 runtime contract 文档 -- [ ] 做一轮跨平台主流程实机回归清单 - -## 阶段 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 的兼容修复 - -### 部分完成 - -- [ ] package manifest 的兼容规则还没有形成正式冻结文档 -- [ ] schema version 已经有了,但兼容策略仍散在多个模块 -- [ ] 缺失 artifact / graph / payload 的降级策略还没有统一成一层 - -### 待完成 - -- [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. 做一轮跨平台主流程实机回归清单 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/__init__.py b/src/web/__init__.py index ab910f1..4e3b6af 100644 --- a/src/web/__init__.py +++ b/src/web/__init__.py @@ -1,6 +1,24 @@ -"""Web application layer for zaomeng.""" +"""Web application layer for zaomeng. -from .app import create_app -from .workflow import WebRunService +Keep package import side effects minimal so utility modules under ``src.web`` +can be imported in lightweight test/runtime environments without requiring +FastAPI and full Web service dependencies to be initialized eagerly. +""" + +from __future__ import annotations + +from typing import Any __all__ = ["WebRunService", "create_app"] + + +def __getattr__(name: str) -> Any: + if name == "create_app": + from .app import create_app + + return create_app + if name == "WebRunService": + from .workflow import WebRunService + + return WebRunService + raise AttributeError(f"module 'src.web' has no attribute {name!r}") diff --git a/src/web/api/__init__.py b/src/web/api/__init__.py index 3de3ae8..540babf 100644 --- a/src/web/api/__init__.py +++ b/src/web/api/__init__.py @@ -1,31 +1,6 @@ -from .deps import get_run_service -from .routes import ( - ROUTERS, - dialogue_router, - opening_presets_router, - runs_router, - scene_cards_router, - self_cards_router, - settings_router, -) -from .schemas import ( - CreateDialogueSessionRequest, - BranchDialogueSessionRequest, - CreateRunRequest, - DialogueResponseItem, - IngestCharacterRequest, - IngestDialogueTurnRequest, - IngestRelationRequest, - PrepareDialogueTurnRequest, - RecommendSceneCardRequest, - RestartRunRequest, - SaveModelSettingsRequest, - SaveOpeningPresetRequest, - SaveSceneCardRequest, - SavePersonaReviewRequest, - SaveSelfCardRequest, - SwitchDialogueSceneCardRequest, -) +from __future__ import annotations + +from typing import Any __all__ = [ "ROUTERS", @@ -53,3 +28,61 @@ "settings_router", "SwitchDialogueSceneCardRequest", ] + + +def __getattr__(name: str) -> Any: + if name == "get_run_service": + from .deps import get_run_service + + return get_run_service + if name in { + "ROUTERS", + "dialogue_router", + "opening_presets_router", + "runs_router", + "scene_cards_router", + "self_cards_router", + "settings_router", + }: + from .routes import ( + ROUTERS, + dialogue_router, + opening_presets_router, + runs_router, + scene_cards_router, + self_cards_router, + settings_router, + ) + + mapping = { + "ROUTERS": ROUTERS, + "dialogue_router": dialogue_router, + "opening_presets_router": opening_presets_router, + "runs_router": runs_router, + "scene_cards_router": scene_cards_router, + "self_cards_router": self_cards_router, + "settings_router": settings_router, + } + return mapping[name] + if name in { + "CreateDialogueSessionRequest", + "BranchDialogueSessionRequest", + "CreateRunRequest", + "DialogueResponseItem", + "IngestCharacterRequest", + "IngestDialogueTurnRequest", + "IngestRelationRequest", + "PrepareDialogueTurnRequest", + "RecommendSceneCardRequest", + "RestartRunRequest", + "SaveModelSettingsRequest", + "SaveOpeningPresetRequest", + "SaveSceneCardRequest", + "SavePersonaReviewRequest", + "SaveSelfCardRequest", + "SwitchDialogueSceneCardRequest", + }: + from . import schemas as _schemas + + return getattr(_schemas, name) + raise AttributeError(f"module 'src.web.api' has no attribute {name!r}") 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/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/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 @@
-