From e670a5129c4ae6f48e2b927f7972b5d2a481a58a Mon Sep 17 00:00:00 2001 From: moranzhi Date: Thu, 30 Jul 2026 00:39:32 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=B8=96=E7=95=8C=E6=A8=A1?= =?UTF-8?q?=E6=8B=9F=E5=99=A8=E4=B8=BA=E6=A8=A1=E5=9D=97=E5=8C=96=E9=85=8D?= =?UTF-8?q?=E6=96=B9=E6=9E=B6=E6=9E=84=EF=BC=8C=E5=AE=8C=E5=96=84=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E7=BC=96=E6=8E=92=E3=80=81=E4=BC=9A=E8=AF=9D=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E4=B8=8E=20Web=20UI=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E6=B8=85=E7=90=86=E8=BF=87=E6=97=B6=E6=8A=80=E8=83=BD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- THIRD_PARTY_NOTICES.md | 32 + docs/architecture.md | 391 ++++-- docs/briefs/capability-authoring-brief.md | 435 ++++++ docs/briefs/capability-mechanism-handoff.md | 20 + docs/context-assembly.md | 30 +- docs/creation-playbook.md | 82 +- docs/daily-use-p0.md | 274 ++++ docs/design-orchestrator-guide.md | 511 +++++++ docs/implementation-guide.md | 65 +- docs/orchestrator-skill-format.md | 176 +-- docs/px-roadmap.md | 195 +++ docs/references.md | 88 ++ docs/run-snapshot.md | 64 +- docs/runtime-state-machine.md | 3 +- docs/skill-design-guide.md | 272 ++-- docs/skill-format.md | 74 +- docs/tag-blackboard.md | 599 +------- docs/tool-contracts.md | 10 +- docs/ui-design.md | 452 ++++++ docs/ui-glossary.md | 171 +++ docs/worker-skill-format.md | 297 +--- docs/world-simulator-modules.md | 139 ++ skills/README.md | 57 +- .../dialogue/character-card-author/README.md | 15 - skills/dialogue/character-card-play/README.md | 13 - .../roleplay-game-theory/llm-bindings.yaml | 16 - .../roleplay-game-theory/orchestrator.md | 441 ------ .../roleplay-game-theory/shared-context.md | 164 --- .../workers/present-round/SKILL.md | 98 -- .../workers/role-decide/SKILL.md | 135 -- .../workers/setup-scenario/SKILL.md | 162 --- .../workers/world-engine/SKILL.md | 204 --- skills/dialogue/scene-roleplay/README.md | 41 - skills/dialogue/world-simulator/README.md | 53 +- .../instantiate-orchestrator.md | 310 ----- .../modules/aesthetics-interaction/prompt.md | 163 +++ .../world-simulator/modules/catalog.yaml | 98 ++ .../modules/concrete-instances/prompt.md | 58 + .../modules/generation-rules/prompt.md | 60 + .../modules/mechanism/prompt.md | 310 +++++ .../modules/narrative/prompt.md | 56 + .../world-simulator/modules/refine/prompt.md | 51 + .../modules/reply-format/prompt.md | 60 + .../modules/status-bar/prompt.md | 59 + .../modules/topology/prompt.md | 59 + .../modules/variable-context/prompt.md | 57 + .../modules/variable-design/prompt.md | 65 + .../modules/worker-spec/prompt.md | 52 + .../modules/world-blueprint/prompt.md | 286 ++++ .../dialogue/world-simulator/orchestrator.md | 123 ++ .../world-simulator/recipes/catalog.yaml | 16 + .../recipes/expand-assistant/recipe.yaml | 8 + .../recipes/world-simulator/recipe.yaml | 16 + .../worker-templates/README.md | 32 + .../worker-templates/chapter-writer.yaml | 25 + .../worker-templates/narrator.yaml | 23 + .../worker-templates/opening-generator.yaml | 25 + .../worker-templates/outline.yaml | 22 + .../worker-templates/role-decide.yaml | 21 + .../worker-templates/round-present.yaml | 21 + .../worker-templates/world-simulator.yaml | 23 + .../workers/design-flow/SKILL.md | 104 ++ .../workers/design-step/SKILL.md | 51 + .../workers/opening-generator/SKILL.md | 135 ++ skills/novel/basic/orchestrator.md | 85 -- skills/novel/basic/workers/outline/SKILL.md | 57 - skills/novel/interactive-novel/README.md | 27 - skills/novel/novel-standard/README.md | 20 - skills/novel/quick-write/README.md | 30 - .../novel/weird-rules-short/orchestrator.md | 286 ---- .../novel/weird-rules-short/shared-context.md | 68 - .../workers/review-author/SKILL.md | 117 -- .../workers/review-infer/SKILL.md | 110 -- .../workers/write-rules/SKILL.md | 103 -- skills/registry.yaml | 18 +- src/blackboard/blackboard.ts | 50 +- src/blackboard/table-cells.ts | 141 ++ src/blackboard/table-side-effects.ts | 275 ++++ src/book/store.ts | 88 +- src/cli/phase-demo.ts | 9 +- src/config/default-orchestrator.ts | 57 + src/config/settings.ts | 16 + src/llm/client.ts | 151 +- src/llm/preset-wrapper.ts | 20 + src/llm/stream-complete.ts | 149 ++ src/llm/token-tracker.ts | 111 ++ src/main-agent/main-agent.ts | 38 +- src/main-agent/tool-loop.ts | 99 +- src/main-agent/tools.ts | 74 +- src/preset/entries.ts | 171 ++- src/runtime/compress-after-worker.ts | 235 ++++ src/runtime/orchestrator.ts | 2 +- src/runtime/phase-machine.ts | 254 +++- src/runtime/phase-runtime.ts | 839 +++++++++++- src/runtime/tool-registry.ts | 9 +- src/server/agent-view.ts | 151 +- src/server/book-handlers.ts | 749 +++++++--- src/server/display-labels.ts | 121 ++ src/server/message-branch.ts | 223 +++ src/server/prune-creation-messages.ts | 162 +++ src/server/session-manager.ts | 1194 +++++++++++++++- src/server/settings-handlers.ts | 109 +- src/server/skill-catalog.ts | 403 +++--- src/server/web-server.ts | 171 +++ src/skills/context-segments.ts | 165 +++ src/skills/creation-flow.ts | 964 +++++++++++++ src/skills/creation-units.ts | 694 ++++++++++ src/skills/declared-worker.ts | 260 ++++ src/skills/loader.ts | 274 +++- src/skills/question-protocol.ts | 134 ++ src/skills/resident-context.ts | 128 ++ src/skills/snapshot.ts | 2 + src/skills/types.ts | 9 +- src/skills/worker-declaration.ts | 166 +++ src/skills/worker-set-parse.ts | 523 +++++++ src/skills/worker-set-view.ts | 720 ++++++++++ src/stats/token-store.ts | 3 + src/types/book-session.ts | 37 + src/types/context-trace.ts | 63 + src/types/questions.ts | 23 + src/types/run-snapshot.ts | 4 +- src/types/runtime.ts | 68 +- src/types/tools.ts | 10 + src/worker/executor.ts | 261 +++- tests/agent-view.test.ts | 377 +++-- tests/book-store.test.ts | 27 + tests/compress-after-worker.test.ts | 54 + tests/context-assembly.test.ts | 136 ++ tests/context-trace.test.ts | 73 + tests/creation-flow.test.ts | 396 ++++++ tests/declared-worker.test.ts | 96 ++ tests/display-labels.test.ts | 30 + tests/intake.test.ts | 64 +- tests/message-branch.test.ts | 64 + tests/phase-machine.test.ts | 198 ++- tests/phase-runtime.test.ts | 93 +- tests/phase-skill.test.ts | 179 +-- tests/preset.test.ts | 33 +- tests/prune-creation-messages.test.ts | 310 +++++ tests/question-protocol.test.ts | 65 + tests/resident-context.test.ts | 98 ++ tests/run-snapshot.test.ts | 8 +- tests/session-persist.test.ts | 33 +- tests/table-cells.test.ts | 51 + tests/table-side-effects.test.ts | 165 +++ tests/token-usage.test.ts | 1 + tests/tool-loop.test.ts | 32 + tests/worker-declaration.test.ts | 102 ++ tests/worker-llm.test.ts | 4 +- tests/worker-set-parse.test.ts | 124 ++ tests/worker-set-sanitize.test.ts | 88 ++ tests/worker-set-view.test.ts | 162 +++ web/agent-ui.js | 1214 +++++++++++++++-- web/app.js | 1029 ++++++++++++-- web/display-labels.js | 94 ++ web/export.js | 4 +- web/index.html | 84 +- web/modules.css | 318 +++++ web/modules.html | 67 + web/modules.js | 232 ++++ web/questions-ui.js | 391 ++++++ web/settings.css | 812 ++++++++--- web/settings.html | 139 +- web/settings.js | 226 ++- web/stats.html | 112 ++ web/stats.js | 113 ++ web/styles.css | 1048 +++++++++++++- 167 files changed, 22955 insertions(+), 5659 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 docs/briefs/capability-authoring-brief.md create mode 100644 docs/briefs/capability-mechanism-handoff.md create mode 100644 docs/daily-use-p0.md create mode 100644 docs/design-orchestrator-guide.md create mode 100644 docs/px-roadmap.md create mode 100644 docs/references.md create mode 100644 docs/ui-design.md create mode 100644 docs/ui-glossary.md create mode 100644 docs/world-simulator-modules.md delete mode 100644 skills/dialogue/character-card-author/README.md delete mode 100644 skills/dialogue/character-card-play/README.md delete mode 100644 skills/dialogue/roleplay-game-theory/llm-bindings.yaml delete mode 100644 skills/dialogue/roleplay-game-theory/orchestrator.md delete mode 100644 skills/dialogue/roleplay-game-theory/shared-context.md delete mode 100644 skills/dialogue/roleplay-game-theory/workers/present-round/SKILL.md delete mode 100644 skills/dialogue/roleplay-game-theory/workers/role-decide/SKILL.md delete mode 100644 skills/dialogue/roleplay-game-theory/workers/setup-scenario/SKILL.md delete mode 100644 skills/dialogue/roleplay-game-theory/workers/world-engine/SKILL.md delete mode 100644 skills/dialogue/scene-roleplay/README.md delete mode 100644 skills/dialogue/world-simulator/instantiate-orchestrator.md create mode 100644 skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md create mode 100644 skills/dialogue/world-simulator/modules/catalog.yaml create mode 100644 skills/dialogue/world-simulator/modules/concrete-instances/prompt.md create mode 100644 skills/dialogue/world-simulator/modules/generation-rules/prompt.md create mode 100644 skills/dialogue/world-simulator/modules/mechanism/prompt.md create mode 100644 skills/dialogue/world-simulator/modules/narrative/prompt.md create mode 100644 skills/dialogue/world-simulator/modules/refine/prompt.md create mode 100644 skills/dialogue/world-simulator/modules/reply-format/prompt.md create mode 100644 skills/dialogue/world-simulator/modules/status-bar/prompt.md create mode 100644 skills/dialogue/world-simulator/modules/topology/prompt.md create mode 100644 skills/dialogue/world-simulator/modules/variable-context/prompt.md create mode 100644 skills/dialogue/world-simulator/modules/variable-design/prompt.md create mode 100644 skills/dialogue/world-simulator/modules/worker-spec/prompt.md create mode 100644 skills/dialogue/world-simulator/modules/world-blueprint/prompt.md create mode 100644 skills/dialogue/world-simulator/orchestrator.md create mode 100644 skills/dialogue/world-simulator/recipes/catalog.yaml create mode 100644 skills/dialogue/world-simulator/recipes/expand-assistant/recipe.yaml create mode 100644 skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml create mode 100644 skills/dialogue/world-simulator/worker-templates/README.md create mode 100644 skills/dialogue/world-simulator/worker-templates/chapter-writer.yaml create mode 100644 skills/dialogue/world-simulator/worker-templates/narrator.yaml create mode 100644 skills/dialogue/world-simulator/worker-templates/opening-generator.yaml create mode 100644 skills/dialogue/world-simulator/worker-templates/outline.yaml create mode 100644 skills/dialogue/world-simulator/worker-templates/role-decide.yaml create mode 100644 skills/dialogue/world-simulator/worker-templates/round-present.yaml create mode 100644 skills/dialogue/world-simulator/worker-templates/world-simulator.yaml create mode 100644 skills/dialogue/world-simulator/workers/design-flow/SKILL.md create mode 100644 skills/dialogue/world-simulator/workers/design-step/SKILL.md create mode 100644 skills/dialogue/world-simulator/workers/opening-generator/SKILL.md delete mode 100644 skills/novel/basic/orchestrator.md delete mode 100644 skills/novel/basic/workers/outline/SKILL.md delete mode 100644 skills/novel/interactive-novel/README.md delete mode 100644 skills/novel/novel-standard/README.md delete mode 100644 skills/novel/quick-write/README.md delete mode 100644 skills/novel/weird-rules-short/orchestrator.md delete mode 100644 skills/novel/weird-rules-short/shared-context.md delete mode 100644 skills/novel/weird-rules-short/workers/review-author/SKILL.md delete mode 100644 skills/novel/weird-rules-short/workers/review-infer/SKILL.md delete mode 100644 skills/novel/weird-rules-short/workers/write-rules/SKILL.md create mode 100644 src/blackboard/table-cells.ts create mode 100644 src/blackboard/table-side-effects.ts create mode 100644 src/config/default-orchestrator.ts create mode 100644 src/llm/stream-complete.ts create mode 100644 src/runtime/compress-after-worker.ts create mode 100644 src/server/display-labels.ts create mode 100644 src/server/message-branch.ts create mode 100644 src/server/prune-creation-messages.ts create mode 100644 src/skills/context-segments.ts create mode 100644 src/skills/creation-flow.ts create mode 100644 src/skills/creation-units.ts create mode 100644 src/skills/declared-worker.ts create mode 100644 src/skills/question-protocol.ts create mode 100644 src/skills/resident-context.ts create mode 100644 src/skills/worker-declaration.ts create mode 100644 src/skills/worker-set-parse.ts create mode 100644 src/skills/worker-set-view.ts create mode 100644 src/types/context-trace.ts create mode 100644 src/types/questions.ts create mode 100644 tests/compress-after-worker.test.ts create mode 100644 tests/context-assembly.test.ts create mode 100644 tests/context-trace.test.ts create mode 100644 tests/creation-flow.test.ts create mode 100644 tests/declared-worker.test.ts create mode 100644 tests/display-labels.test.ts create mode 100644 tests/message-branch.test.ts create mode 100644 tests/prune-creation-messages.test.ts create mode 100644 tests/question-protocol.test.ts create mode 100644 tests/resident-context.test.ts create mode 100644 tests/table-cells.test.ts create mode 100644 tests/table-side-effects.test.ts create mode 100644 tests/worker-declaration.test.ts create mode 100644 tests/worker-set-parse.test.ts create mode 100644 tests/worker-set-sanitize.test.ts create mode 100644 tests/worker-set-view.test.ts create mode 100644 web/display-labels.js create mode 100644 web/modules.css create mode 100644 web/modules.html create mode 100644 web/modules.js create mode 100644 web/questions-ui.js create mode 100644 web/stats.html create mode 100644 web/stats.js diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..305c08c --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,32 @@ +# Third-Party Notices + +本仓库以自研运行内核与文档为主。下列项目**仅作架构与实现参考**(见 [`docs/references.md`](docs/references.md)),默认**不**将它们的源码整仓纳入本仓库。 + +在实际引入 npm/依赖、复制示例代码、或嵌入前端素材之前,必须逐项确认: + +- LICENSE 是否允许修改与再分发 +- 是否要求保留版权声明 +- 前端素材是否使用不同许可证 +- 示例代码与主仓库许可证是否一致 +- 模型 Provider 是否另有使用限制 +- 角色卡等格式是事实标准还是含特定项目代码 + +## Reference projects (not vendored by default) + +| Project | URL | Intended use in this repo | +|---------|-----|---------------------------| +| Mastra | https://github.com/mastra-ai/mastra | Reference only (agent/tool patterns); not the runtime kernel | +| assistant-ui | https://github.com/assistant-ui/assistant-ui | Optional UI pattern reference | +| LobeChat | https://github.com/lobehub/lobe-chat | Product/IA reference | +| SillyTavern | https://github.com/SillyTavern/SillyTavern | UX/concept reference for cards & lorebooks | +| LangGraph.js | https://github.com/langchain-ai/langgraphjs | Checkpoint / recovery ideas | +| AutoGen | https://github.com/microsoft/autogen | Multi-agent runtime ideas | +| Letta | https://github.com/letta-ai/letta | Memory / context window ideas | +| OpenHands | https://github.com/All-Hands-AI/OpenHands | Event stream / observability ideas | +| CopilotKit | https://github.com/CopilotKit/CopilotKit | Agent–UI connection ideas | + +When a dependency is added to `package.json`, record its license here (or under `licenses/`) and keep versions pinned via the lockfile. + +## Direct dependencies + +See `package.json` / lockfile for runtime and dev dependencies and their respective licenses. diff --git a/docs/architecture.md b/docs/architecture.md index 9136b19..9f7e69e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,150 +1,301 @@ -# 整体架构 +# 本地多 Agent 文本创作系统架构 -## 1. 方向 +## 1. 文档定位 -**标签驱动黑板** + **agent tool loop** + **5 相位阶段机** + **skill 能力库**。 +本文档描述**系统级运行内核**:模块边界、创作定义与运行实例如何分离、总管 / Worker / Skill / 上下文如何协作、数据如何持久化,以及外部项目按层借鉴的原则。 -```text -Skill 包(orchestrator manifest + workers/*/SKILL.md) 静态能力定义 -Session + 黑板 tag 一次运行的实参 -Book 跨 Session 的项目与资产 - -Agent(总管) running 相位内 tool loop,调度 invoke 哪个 skill -Runtime 拼接上下文、校验 tool、写黑板、驱动阶段机边界 -阶段机 谁可动、何时等用户、产物生命周期——不是流水线剧本 -``` - -核心文档: - -```text -docs/tag-blackboard.md 黑板、标签、分工 -docs/context-assembly.md ★ 上下文拼接(上半固定、下半动态) -docs/tool-contracts.md 总管 / worker tool -docs/runtime-state-machine.md 5 相位、tool 边界 -docs/book-storage.md 长期存储(过程 / 资产 / 游玩) -docs/skill-design-guide.md 新 skill 包设计方法 -docs/implementation-guide.md 代码文件职责 -``` +本文档**不**设计具体剧本 Skill 的提示词、步骤或字段。剧本与创作方法见独立文档(如 `design-orchestrator-guide.md`);Skill 包格式见 `skill-format.md` 系列。 --- -## 2. 术语 +## 2. 产品目标 + +本地运行的多 Agent 文本生成平台,兼容多种创作模式(角色扮演、长短篇小说、仿写扩写、思想实验、信息隔离模拟、世界观/角色卡/模板创作、动态表与长期状态、存档分支等)。 + +这些模式共享同一运行内核,区别来自: + +- 装载哪些 Skill / 能力包 +- 实例规格(Worker 声明)如何配置 +- 上下文如何按契约编译 +- 对状态读写实施什么权限 + +不为每一种创作模式各实现一套固定工作流。 + +--- + +## 3. 核心架构原则 + +### 3.1 创作定义与运行实例分离 + +| 层 | 当前实现对应 | 含义 | +|----|--------------|------| +| **创作定义 / 规格** | accept 后的 `设计.worker集`、静态设定 tag;可存为 `instance` / `opening` 快照 | 可复用的「函数声明」:Worker、表、常驻上下文、权限与能力边界 | +| **运行实例** | Book + Session + 黑板 + `run` 快照 | 一次具体创作/游玩过程:对话、变量、轮次、中间产物 | + +同一规格可多次开档;实例应**钉住**某次规格截面。规格更新后,已有实例不自动跟随;需显式加载/迁移到新截面。 + +语义上:`instance` 快照 ≈ 已发布的规格版本;`run` 快照 ≈ 运行过程存档。正式 `DefinitionVersion` 发布与迁移协议在现有快照之上增量补齐,不另起一套推倒重写。 + +### 3.2 总管只负责任务调配 + +总管 Agent(Main Agent)负责:判断目标是否完成、决定下一项任务、选择 Worker、必要时询问用户或结束。 + +总管**不**负责:直接创作最终正文、临场拼接完整上下文、绕过权限读私有信息、直接改正式状态、同时兼创作与评测、把隐藏推理写入长期状态。 + +任务所需信息由总管**声明意图**(如 invoke 哪个 worker);真正发给 Worker 的上下文由 Runtime 的上下文编译器按 Skill / 声明契约生成。 + +### 3.3 Agent 决策与程序控制分离 + +| Agent(非确定) | Runtime(确定) | +|-----------------|-----------------| +| 下一步做什么 | 权限与声明校验 | +| 调用哪个能力 | 上下文装载与 Token 裁剪 | +| 是否返工 / 问用户 | 状态提交、表 `rev` 合并 | +| | 相位边界、burst、失败处理 | + +不能依赖提示词保证权限与信息隔离;访问限制在 Tool 与 Context Compiler 层执行。 + +### 3.4 动态调度,不用固定创作 DAG + +「下一步 invoke 哪个 skill」由 agent tool loop 决定。 + +**5 相位阶段机**(`idle` / `running` / `waiting_user` / `done` / `error`)是并发与权限模型——谁可动、何时等用户、产物何时算事实——**不是**流水线剧本。详见 `runtime-state-machine.md`、`tool-contracts.md`。 + +确定性后台流程(导入导出、索引、迁移、批量评测)可用固定步骤;整场 AIRP / 小说创作流程不固化为完整 DAG。 + +--- + +## 4. 总体架构 + +```text +┌─────────────────────────────────────────────┐ +│ Web / Electron Frontend │ +│ 创作 / 游玩 / 存档 / 检查器 / 调试 │ +└─────────────────────┬───────────────────────┘ + │ HTTP(+ 流式事件) +┌─────────────────────▼───────────────────────┐ +│ Application API │ +│ Book、Session、快照、资源、模型配置 │ +└─────────────────────┬───────────────────────┘ + │ +┌─────────────────────▼───────────────────────┐ +│ Agent Runtime │ +│ Main Agent(tool loop) │ +│ Phase Machine + Phase Runtime │ +│ Skill Registry / Worker 声明校验 │ +│ Context Compiler(assembleWorkerContext) │ +│ Commit:黑板写入、表 rev、acceptance │ +└─────────────────────┬───────────────────────┘ + │ +┌─────────────────────▼───────────────────────┐ +│ Domain / Storage │ +│ Book · Session · Blackboard · Snapshots │ +│ Artifacts · RuntimeEvent history │ +│ 文件资源(books/…);向量检索可插拔 │ +└─────────────────────────────────────────────┘ +``` + +合成边界: + +> Book/规格描述可以运行什么,Session/快照保存实际发生了什么,相位机决定谁可动,总管决定下一步 invoke 谁,Context Compiler 决定 Worker 看见什么,Runtime 决定哪些修改正式生效。剧本 Skill 是可装载能力,不是系统固定流水线。 + +--- + +## 5. 系统核心模块 + +### 5.1 规格与 Book(Definition 侧) + +- Book:长期项目容器(过程、资产、游玩状态) +- `设计.worker集` accept 后 → 本实例 Worker 声明 +- `instance` / `opening` 快照:规格与可选开局截面 +- 导入导出、复制、从截面新开 play 线 + +细节:`book-storage.md`、`run-snapshot.md`。 + +### 5.2 Session 与 Instance Manager(运行侧) + +- 按规格创建 / 加载 Session +- 自动续作 `session.json` 与手动 `run-snapshots/` +- 消息 swipe / branch checkpoint +- 从 earlier 快照恢复后重 roll + +### 5.3 Main Agent(Supervisor) + +接收压缩后的任务视图(目标、相位、可调用能力、最近产物摘要等),经 tool loop 输出调度意图;Runtime 校验后执行。 + +### 5.4 Phase Runtime(Dynamic Scheduler 的具体形态) + +执行总管决定并维护相位边界: + +1. 校验 Worker id ∈ 声明 +2. 编译上下文 +3. 短生命周期执行 Worker +4. 收集写入请求 → 校验 / 合并 / 提交 +5. 按 acceptance 可能进入 `waiting_user` +6. 预算:`maxBurst`;后续补齐嵌套深度、重复任务检测、Token/调用预算 + +实现:`src/runtime/phase-machine.ts`、`phase-runtime.ts`。 + +### 5.5 Worker + +按任务实例化的短生命周期调用。保留的是事件、产物与状态修改,不是 Agent 对象。 + +### 5.6 Skill Registry + +架构层两种能力层级: + +- **包 / Orchestrator manifest**:能力发现、验收与进 play 门槛 +- **Worker 契约**:声明驱动的输入输出、上下文段、工具边界 + +注册、加载、版本与磁盘格式见 Skill 系列文档;**具体剧本步骤不在本文**。 + +### 5.7 Context Compiler + +确定性模块:按任务、Worker 权限与实例状态构建 Context View。 + +当前实现:`inputTags` / `contextSegments` + `assembleWorkerContext()`(`context-assembly.md`)。 + +演进契约: + +- **Context Trace**:记录装载了什么、来源、权限依据、因预算/权限排除了什么 +- 逻辑分区语义(public / narrative / characters / tables / artifacts / runtime)可映射到 tag 与可见范围;实现仍以 tag 黑板为主 + +### 5.8 Blackboard + +带类型与来源的运行状态(tag 条目),不是全体 Agent 共享的大字符串。表字段使用 `value + rev + source`;正式写入经 Runtime。见 `tag-blackboard.md`。 + +### 5.9 Artifact 与验收 + +较长或需反复编辑的内容以黑板 tag / 产物记录保存;`drafted → under_review → accepted|rejected|revision_requested`;accepted 前不得当下游事实。 + +### 5.10 Commit and Validation + +Worker 不直接改「当前事实」。路径:输出 → 声明/schema 校验 → 权限与 rev 合并 → 提交事件 → 更新黑板。结构化状态用受控写入;正文类产物仍记录来源与版本。 + +--- + +## 6. 数据持久化 + +当前以**状态快照为主**,会话内保留 `RuntimeEvent` history;完整领域事件溯源(投影 + 事件日志 + 周期快照)按分支/迁移/审计痛点再加深。 + +| 需求 | 机制 | +|------|------| +| 续作 | `session.json` | +| 规格截面 | `instance` / `opening` 快照 | +| 游玩存档 / 重 roll | `run` 快照;load = 恢复到该档 | +| 同开局多线 | 保留 opening/instance 层,清空或分支 run 层 | +| 调试回放 | `RuntimeSession.history` | + +分支不要求复制整库;记录父档 / 基准快照即可。 + +--- + +## 7. 信息隔离 + +隔离由 Runtime 强制:Worker 只看到契约允许的 tag / 段。多角色模拟时,角色 Worker 不得看到他角私有记忆、未公开事件、总管隐藏调度信息。 + +Context Trace 是排查泄漏的主工具(契约已定,实现渐进)。 + +--- + +## 8. 技术栈(现状与边界) + +| 层 | 现状 | 边界 | +|----|------|------| +| 语言 | TypeScript / Node.js | — | +| Agent Runtime | 自研(main-agent、phase-runtime、llm) | 领域对象不绑定外部 Agent 框架内部类型;Mastra 等仅可作按层参考或远期适配 | +| 前端 | `web/` + Electron | 不强制 Next.js / assistant-ui;可借鉴交互模式 | +| 存储 | 文件系统 Book JSON | SQLite/Drizzle 非近端前提 | +| Skill | `skills/` + registry | 包内剧本与系统内核分离 | + +外部仓库按模块借鉴,见 `references.md`;许可证见仓库根 `THIRD_PARTY_NOTICES.md`。 + +--- + +## 9. 术语对照 + +| 规划用语(抽象) | 本仓库用语(实现) | +|------------------|-------------------| +| CreationDefinition / DefinitionVersion | `设计.worker集` 截面;`instance` / `opening` 快照 | +| RunInstance | Book + Session + 黑板 + `run` 快照 | +| 导演 Skill / 能力包 | `orchestrator.md` 包(UI 可选;registry 一项) | +| 剧本层(动态) | 对话谈成的 Worker 集 + play 期 tool loop 调度(非死板选单) | +| Supervisor | Main Agent(tool loop) | +| Dynamic Scheduler | Phase Machine + Phase Runtime | +| Worker Factory | `run_worker` → 声明校验 → executor | +| Context Compiler | `assembleWorkerContext` / contextSegments | +| Blackboard | tag 黑板(`BlackboardItem`) | +| Commit Layer | 写入校验、表 rev 合并、acceptance | +| Skill Registry | `skills/` loader + orchestrator manifest | + +用户可见中文口径(禁止裸露内部 id)见 `ui-glossary.md`。 | 词 | 含义 | |----|------| -| **skill** | `SKILL.md` 定义的能力(设计期能力库中的一条) | -| **worker** | 某 skill 被 invoke 的一次执行 | -| **stage** | 业务阶段:`design`(实例化)→ `play`(运行)→ `done` | -| **phase** | 运行相位:`idle` \| `running` \| `waiting_user` \| `done` \| `error` | - -不使用 **step** 指代设计步骤编号,避免与管道混淆。 +| **Worker 声明** | accept 后的 `设计.worker集` | +| **worker** | 一次 `run_worker` invoke | +| **stage** | `design` → `play` → `done`(创作 → 游玩 → 已完成) | +| **phase** | `idle` \| `running` \| `waiting_user` \| `done` \| `error` | --- -## 3. Agent tool loop +## 10. 文档地图 -```text -用户硬事件(输入 / 确认 / 验收) - → phase = running,toolLoopBurst 计数归零 - → while running && burst < N: - LLM(messages, tools) - → 循环 tool(read_blackboard …)→ 结果 append 到 messages - → 边界 tool(run_worker / ask_user / finish)→ 退出 burst - → 若需用户 → waiting_user -``` - -- **burst 上限 N**:两次用户操作之间的最大推理轮数,非 Session 终身额度。 -- **循环 tool**:不改 phase,只追加 agent 对话。 -- **边界 tool**:触发阶段机转移(等用户、跑 worker、结束)。 - -代码:`src/main-agent/tool-loop.ts`、`src/runtime/phase-runtime.ts`。 - -目标态:worker 执行也用 tool loop(`submit` / `ask_user`),上下文仍由 Runtime 拼接,见 `docs/tool-contracts.md`。 +| 层级 | 文档 | +|------|------| +| **系统架构(本文)** | `architecture.md` | +| 运行内核 | `runtime-state-machine.md`、`tool-contracts.md`、`context-assembly.md`、`tag-blackboard.md` | +| 持久化 | `book-storage.md`、`run-snapshot.md` | +| UI | `ui-design.md`、`ui-glossary.md` | +| 实现顺序 | `implementation-guide.md` | +| **产品体验路线** | **`px-roadmap.md`**(PX0–PX5 交付与 DoD) | +| **日常场景 → P0** | **`daily-use-p0.md`**(场景、功能表、P0 工作包) | +| 外部参考 | `references.md` | +| **剧本 / 创作方法(非系统架构)** | `design-orchestrator-guide.md`、`creation-playbook.md` | +| **Skill 包格式(非系统架构)** | `skill-format.md`、`orchestrator-skill-format.md`、`worker-skill-format.md`、`skill-design-guide.md`、`preset-format.md` | --- -## 4. 阶段机做什么 +## 11. 在现有内核上补齐(路线) -阶段机 **不是** 编排表执行器,而是: +已具备:相位机、Agent tool loop、Skill loader、design-intake、Worker 声明、上下文拼装、表 rev、Web UI、快照 API。 -```text -1. Actor 门禁 此刻用户 / agent / worker 谁在场 -2. Tool 边界 当前态允许哪些 tool -3. 事实生命周期 draft → accepted;用户才能 accept -4. 等待原因 waitingReason 细分等什么 -``` +1. **PX0**:导演选择 UI;AIRP + 长文/爽文主路径;**存档/续作硬稳定**;剧本层保持动态 +2. **PX1–PX2**:创作/游玩体验(可抄 UI);副作用与多 run 线 +3. **PX3**:Context Trace、调度预算、规格钉版本、E2E +4. **PX4–PX5**:长文加深;隔离模拟 + 调试(新方法边界可用新导演包) +5. SQLite / 框架适配仅当痛点单开,不插入 PX0 关键路径 -业务「先跑哪个 skill」由 **agent 在 tool loop 里决定**,不由 orchestrator 逐步剧本写死。 +权威表:[`px-roadmap.md`](./px-roadmap.md)。 --- -## 5. 实例化:skill 能力库 +## 12. 主要技术风险 -实例化 = agent 在 `design` stage 按需 invoke **instantiate skill**(原「设计步骤」),不是固定 1→14 管道。 - -```text -交互范式 skill → 产出 设计.run_skill清单(run 阶段需要哪些 skill) -每个 run skill 倒推 → 缺什么 instantiate skill → agent invoke -declare_instance_ready → 进入 play stage -``` - -orchestrator.md = **manifest**(有哪些 skill、约束、验收策略),不是逐步思维链。 +| 风险 | 解决边界 | +|------|----------| +| 上下文污染 | 全部经 Context Compiler;补 Trace | +| 自然语言误改状态 | 结构化状态只走受校验写入 / Patch | +| 调度死循环 | burst、后续嵌套/重复/预算限制 | +| 规格更新污染旧档 | 实例钉规格截面;显式迁移 | +| 框架耦合 | 领域与持久化不保存外部 Agent 框架内部对象 | --- -## 6. 上下文拼接 - -**上半固定、下半动态**。规则在 skill 定义 + 实例 contextProfile;Runtime 执行。 -见 `docs/context-assembly.md`。 - ---- - -## 7. 模块 - -```text -phase-machine.ts 纯函数:event → phase(边界规则) -phase-runtime.ts Session IO、tool loop 驱动、worker 执行 -main-agent/ tool loop、tool 定义 -worker/executor.ts assembleWorkerContext、run skill -blackboard.ts tag 池 -skills/loader.ts 解析 orchestrator + SKILL.md -book/ Book、快照、持久化 -``` - ---- - -## 8. 数据流(目标态) - -```text -选 orchestrator 包 - → design:agent burst → invoke instantiate skills → 设计.* tag - → declare ready → play - → play:用户输入 → agent burst → invoke run skills → 运行.* tag - → 过程/资产/游玩 归档 Book(见 book-storage.md) -``` - ---- - -## 9. 边界 - -```text -Agent 调度 skill;read_blackboard;不传 inputTags;不写黑板(边界 tool 除外) -Runtime 拼接上下文;校验 tool;写黑板;执行 worker -Worker LLM 在拼接后的 prompt 内产出;submit 写 declared outputTags -用户 approve、accept、输入 -阶段机 只响应 event,不调 LLM -``` - ---- - -## 10. 实现进度(摘要) +## 13. 实现进度(摘要) ```text ✅ phase-machine、phase-runtime、skills loader -✅ 总管 tool loop(read_blackboard、run_worker、…) -⬜ toolLoopBurst 按用户事件归零 -⬜ assembleWorkerContext(contextSegments) -⬜ worker tool loop -⬜ Book:designTrace / CardAsset / PlayBook -⬜ orchestrator 从编排表迁为 manifest +✅ design-intake + Worker 声明校验 +✅ 声明驱动 worker 执行 + acceptance +✅ 表字段格 rev 合并 +✅ Book session / run-snapshots / message branch +✅ 导演选择 UI(新建作品);instance/run 存档人话 kind +✅ 长文模板 outline / chapter-writer;引导覆盖爽文/分段 +✅ 失败/revision/error 出口;黑板用户手改 API +⬜ Context Trace +⬜ 规格版本发布与迁移协议 +⬜ 调度嵌套 / 重复任务 / Token 预算补齐 +⬜ 边沿副作用完整调度器 +⬜ 标准 fixture E2E(双线黄金路径自动化) ``` diff --git a/docs/briefs/capability-authoring-brief.md b/docs/briefs/capability-authoring-brief.md new file mode 100644 index 0000000..d016da3 --- /dev/null +++ b/docs/briefs/capability-authoring-brief.md @@ -0,0 +1,435 @@ +# 能力撰写交接简报(泛用) + +> **用途**:把本文**整份**交给另一 AI / 作者,用于撰写任意【能力】的 `modules/{id}/prompt.md`。 +> **本文是自洽规范**:不依赖读者已熟悉本仓库。 +> **不要**让撰写方改程序代码;**不要**只写某一能力的特例而不遵守通用格式。 + +范例(深度与结构对齐):`skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md` +能力池清单(选题用):`docs/world-simulator-modules.md` +术语权威:`docs/ui-glossary.md` §0 + +--- + +# 第一部分:项目是什么 + +## 1.1 一句话 + +**writing-agent** 是本地运行的多 Agent **文本创作 / 游玩**系统:用户选一个【导演】,在【创作】期谈成【剧本】,再在【游玩】期让【演员】按剧本上场,产出可读文本(角色扮演、长文/爽文、扩写、思想实验等共用同一内核)。 + +## 1.2 要解决什么问题 + +| 要 | 不要 | +|----|------| +| 用程序控制「每一步注入什么上下文」 | 把整本长说明书一次塞进 LLM | +| 用 Agent 按用户体验**编排**用哪些能力、什么顺序 | 死板选单 DAG,或全程让 LLM 自由发明工序 | +| 产物可解析、可渲染、对人可读 | 产物里堆满英文变量名让用户难受 | +| 规格(能复用的声明)与一局游玩过程分离 | 每种玩法重写一套运行时 | + +## 1.3 两段生命周期 + +```text +【创作 design】选导演 → 编排剧本(步骤=能力)→ 逐步执行能力 → 谈成可运行规格 + │ + ▼ 用户手动进入 +【游玩 play】用户输入 → 演员按规格上场 → 可见终稿;可存档 / 重 roll +``` + +- **创作**:谈清「这局要什么体验、用哪些能力钉成什么产物」。 +- **游玩**:按已验收规格跑;用户新输入通常视为认可上一轮展示(否则重 roll)。 + +## 1.4 运行时怎么分工(撰写能力时必须懂) + +| 角色 | 做什么 | 不做什么 | +|------|--------|----------| +| **导演(Agent)** | 决定下一步调哪个演员;编排剧本步骤 | 不直接写小说正文;不私自改相位 | +| **程序(Runtime)** | 校验权限;按契约拼上下文;切步;发 opening;验收门控 | 不替用户发明体验 | +| **能力(modules)** | 某一步「怎么和用户谈、产出什么形状」的方法正文 | 不是整场流水线 | +| **演员(Worker)** | 一次 LLM 执行(创作期跑能力,或游玩期跑声明内 ref) | 不决定全局调度 | + +关键路径: + +```text +用户选【导演】(如世界模拟器) + → design-flow:从【能力】排出**近期**步骤,写出 设计.创作流程 + (可变增量 DAG:每步 id + 固定中文名 + depends_on;status=open|closed) + → 用户验收近期流程 + → 反复 design-step:程序注入「当前能力」的 prompt 切片 + 依赖产物 + → 步骤做完仍 open → 再 design-flow(可追加同能力多次,如生成规则 / 具体实例) + → closed 后收成可进游玩的规格,如 设计.worker集 + → 用户手动进游玩 +``` + +## 1.5 磁盘上两层内容(作者视角) + +```text +【导演】recipes/{导演id}/recipe.yaml 该方法的建议近期起点 / 调味说明 +【能力】modules/{能力id}/prompt.md 共用工序;多导演可选用同一能力 + modules/catalog.yaml 能力目录:中文名 + 短声明 + 产物 tag(+ 可选 repeatable) +``` + +- 新建作品**只选导演一层**,不再叠「能力包 + 配方」。 +- 导演给出的步骤是**近期起点**;本局可增量追加、可反复调用标了 `repeatable` 的能力;验收的是本局【剧本】,不是磁盘死菜谱。 + +--- + +# 第二部分:称呼定义(必须统一) + +对用户与能力正文,优先用**拍摄隐喻**。括号内为内部/旧称,撰写时勿当用户主词。 + +## 2.1 四个核心词 + +| 称呼 | 含义 | 内部大致对应 | 禁止对用户说 | +|------|------|--------------|--------------| +| **导演** | ① 新建时选一次的方法起点(世界模拟器、扩写助手…);② 会话里负责调度的 Agent | `recipes/`、Main Agent / orchestrator | 总管、配方(作选型主词)、能力包(作第二层选项) | +| **剧本** | 本局谈成的流程与规格(活的) | `设计.创作流程` + 各能力产物 + 终稿 `设计.worker集` 等 | 「剧本 Skill 菜单」、死板流程卡 | +| **演员** | 上场执行的一次单元 | Worker / `run_worker` | 对用户堆 `Worker · english-id` | +| **能力** | 共用工序模块;导演从池里选型编排 | `modules/{id}/` | 组件池、模块(作者文档可用;用户文案用「能力」) | + +关系口诀: + +```text +选导演 → 用能力编排 → 谈成剧本 → 演员按剧本上场 +``` + +## 2.2 阶段与界面 + +| 称呼 | 含义 | +|------|------| +| **创作** | lifecycle `design`:谈剧本 | +| **游玩** | lifecycle `play`:演员上场 | +| **产物** | 某步待验收输出(写入黑板 tag) | +| **验收 / 接受** | 用户确认产物算数 | +| **黑板** | 按 tag 存正文的上下文板 | +| **询问卡** | 结构化提问(选项可改写) | + +## 2.3 创作调度相关(可出现在作者文档,少直接甩给用户) + +| 内部 id | 用户可见 | 含义 | +|---------|----------|------| +| `design-flow` | 创作 · 流程编排 | 编排/增量修订可变 DAG → `设计.创作流程` | +| `design-step` | 创作 · 执行步骤 | 执行剧本中当前那一个能力 | +| `opening-generator` | 开局 · 开场白 | 可选;规格收成后的开场白 | + +## 2.4 剧本流程 JSON(编排产物:增量 DAG) + +```json +{ + "brief": "可选:一句话体验复述", + "status": "open", + "steps": [ + { "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] }, + { "id": "生成规则", "name": "生成规则", "depends_on": ["美学纲领与交互范式"] }, + { "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] } + ] +} +``` + +| 字段 | 规则 | +|------|------| +| `status` | `open` = 还可追加;`closed` = 不再扩步。缺省兼容旧稿视为已收口 | +| `steps` 顺序 | 建议执行顺序 | +| `id` | 本局步骤唯一键;同能力多次必须不同 | +| `name` | 必须与能力目录中的**固定中文名**完全一致(可重复) | +| `depends_on` | 依赖的其它步骤 **id**(若某 name 在本流程唯一,也可写 name) | + +程序用中文 `name` 映射到 `artifact` tag;流程 JSON **不写**英文模块 id / tag。 +**禁止**一次排死全程固定长链;「生成规则」「具体实例」等标了 `repeatable` 的能力应允许多次编入。 + +## 2.5 已合并能力(勿拆回) + +| 错误拆法 | 正确能力 | +|----------|----------| +| 「交互范式」+「美学纲领」两步 | **美学纲领与交互范式**(`aesthetics-interaction`)一步 | + +询问相近则合并为一步;不要在新能力里建议再拆开。 + +## 2.6 命名纪律 + +| 种类 | 规则 | 例 | +|------|------|-----| +| 能力中文名 | 稳定、给人看、进流程 JSON | `实现机制` | +| 能力 id | 英文 kebab,= 文件夹名 | `mechanism` | +| 产物 tag | 通常 `设计.{中文名或约定名}` | `设计.实现机制` | +| 游玩演员 ref | 英文 kebab | `narrator` | +| 游玩演员展示名 | 中文 `name` | `叙事转述` | +| UI | 禁止裸露内部英文 id | 不要写 `design-step` 给用户 | + +--- + +# 第三部分:什么是「能力」(泛用定义) + +## 3.1 定义 + +**能力** = 创作期可被编排进剧本的**一步工序**: + +1. 有固定中文名与产物 tag; +2. 有一份程序可切割的方法文档(`prompt.md`); +3. 执行时只注入**本能力**方法 + **依赖产物** + 用户表述; +4. 与用户对话后产出**可验收**的结构化结果。 + +能力**不是**:整场游戏引擎、游玩期每轮演员、也不是导演本身。 + +## 3.2 能力在系统中的生命周期 + +```text +catalog 登记(短声明给编排看;可选 repeatable) + → 导演/编排增量选入 设计.创作流程(可同能力多次) + → 用户验收流程(或后续扩步后再验) + → 轮到该步:若有 opening → 程序先发出 + → 用户首答 → LLM 按能力方法谈/写 + → 写入 artifact tag → 用户验收 + → 下游能力可读该产物(若 depends_on 声明了) + → 不够则再 design-flow 追加同能力新 id +``` +## 3.3 能力作者要交付的两样东西 + +| 交付物 | 路径 | 给谁看 | +|--------|------|--------| +| 目录行 | `modules/catalog.yaml` 一行 | 编排:只看短 `declaration` | +| 方法全文 | `modules/{id}/prompt.md` | 执行该步的 LLM + 程序切割 | + +编排时**禁止**把全文 prompt 塞进导演上下文;执行时才注入切割后的方法块。 + +## 3.4 能力之间如何相处 + +- **正推**:需要 [体验/产物] → 才纳入某能力;勿默认全选池内能力。 +- **边界**:每个能力在 `meta.boundary` 写清「本步定什么 / 不定什么 / 交给谁」。 +- **依赖**:由剧本 `depends_on` 声明;执行期程序注入对应产物。 +- **合并**:询问主题高度重叠 → 合成一个能力(如美学+交互)。 +- **缩减**:每增必问能否删、能否常驻替代、能否合并。 + +## 3.5 方法层硬禁止(写任何能力都适用) + +- 题材 → 固定演员清单。 +- 否定式路由(「因为是 X 所以不需要 Y」);应写「需要 A 体验 → 用 B 手段」。 +- 把「世界模拟」当成一切开场的默认答案。 +- 为排版/格式单独发明演员;能程序拼则程序拼。 +- 临时发明 tool。 +- 一次 burst 写齐全部能力产物。 +- 在能力产物里堆大量用户看不懂的英文变量名(机器 id 可有,但须有中文展示字段)。 + +--- + +# 第四部分:能力文档格式规范(程序契约) + +每个能力 = `modules/{id}/prompt.md` + `catalog.yaml` 一行。 + +程序**只认 fence 的语言标签**切割,**不认**仅靠 `##` 标题。 + +## 4.1 块一览 + +| fence 标签 | 必填 | 用途 | +|------------|------|------| +| `meta` | 强烈建议 | YAML 元数据:选型与边界 | +| `opening` | 可选 | **默认问题**;程序发给用户,**不经**本步 LLM | +| `task` | **必填** | 本步任务与验收边界 | +| `principles` | 强烈建议 | 原则 | +| `probe` | 强烈建议 | 追问策略 | +| `output` | **必填** | 产物形状(多为 JSON) | +| `checklist` | 强烈建议 | 自检 | +| `examples` | 可选 | 好/坏对照 | + +未列出的 fence 名:**不要发明**(除非先改程序切割器)。 + +## 4.2 标准骨架 + +````markdown +# {能力中文名} + +> 可选说明。 + +## meta + +```meta +name: {与 catalog 完全一致的中文名} +id: {文件夹 id} +artifact: 设计.{…} +declaration: > + {一行选型话术;与 catalog 对齐} +when: | + {什么情况下应被编排进来} +when_not: | + {什么情况下不要进来} +boundary: | + 本能力:… + 邻接能力:…(点名其它能力中文名,说明分工) +``` + +## opening + +```opening +{用户第一眼看到的引导;整块可省略或留空 = 本步直接 LLM} +``` + +## task + +```task +{只做本步;写清产物 tag;如何对待 opening 首答与依赖产物} +``` + +## principles + +```principles +{正推、缩减、禁止项、与体验的关系} +``` + +## probe + +```probe +{缺什么问什么;一轮 1~2 点;优先具体选项/短场景;勿重复已答清} +``` + +## output + +```output +{推荐产物 JSON 形状:中文键、可渲染} +``` + +## checklist + +```checklist +- [ ] … +``` + +## examples + +```examples +{可选} +``` +```` + +## 4.3 `meta` 字段 + +| 字段 | 含义 | +|------|------| +| `name` | 固定中文名(= 流程 `steps[].name`) | +| `id` | 英文 id(= 目录名) | +| `artifact` | 本步写入的黑板 tag | +| `declaration` | 给编排看的短声明(不是全文) | +| `when` / `when_not` | 何时该/不该入选剧本 | +| `boundary` | 与邻接能力的分工 | + +## 4.4 `catalog.yaml` 一行 + +```yaml +- id: example-id + name: 示例能力中文名 + declaration: >- + 一句话说明何时选用、解决什么 + artifact: 设计.示例能力中文名 +``` + +| 字段 | 谁用 | +|------|------| +| `declaration` | 插入导演/编排提示,选型 | +| `name` / `artifact` | 流程与执行映射 | +| `opening` | 可选覆盖;一般只写在 prompt 的 `opening` 块 | + +改 `name` 会破坏已有剧本 JSON,尽量不改。 + +## 4.5 opening 节奏(通用) + +```text +若 opening 非空: + 程序发 opening → 用户首答 → 再调 LLM + LLM 上下文含:方法块(通常不含再贴一遍 opening 策略以产品为准) + + 首答 + 依赖产物 + 用户需求 +task 必须写:禁止重复同一开场;在首答上补洞。 +``` + +## 4.6 注入 LLM 时包含哪些块 + +按顺序拼接切割结果,**默认不含** `opening`(开场已由程序发出)。 +`meta` 是否注入以运行实现为准;撰写时把给人/模型的方法写在 `task`/`principles`/`probe`/`output`/`checklist`。 + +## 4.7 产物(`output`)通用要求 + +- 形状稳定,便于 UI 按 JSON 渲染。 +- **键名对人友好**(中文或稳定中文标签)。 +- 机器 id(如 `ref`)若需要,与中文名成对出现。 +- 写清「本步完成的定义」;未决放 `开放问题`,不要假完备。 +- 不要在本能力产物里偷偷交下游能力该交的终稿(除非本能力就是收成步)。 + +## 4.8 追问(`probe`)通用要求 + +- 一轮 askUser **1~2** 点。 +- 优先:可直接采用或微调的完整句选项 / 短场景。 +- 能推断则先写入再复述;只问影响本步核心且不能瞎填的点。 +- 依赖产物已钉死的内容禁止重问。 + +--- + +# 第五部分:撰写任一能力时的工作步骤 + +1. **定身份**:中文名、id、artifact;写清 when / when_not / boundary。 +2. **看邻接**:上/下游能力各定什么;禁止重叠问卷。 +3. **定产物形状**:先定 `output` JSON,再写 task/probe 如何填满它。 +4. **定 opening**:需要程序先问再 LLM → 写 opening;否则留空。 +5. **写 principles / probe / checklist**:正推、缩减、禁止套件。 +6. **对照范例**:深度不低于 `aesthetics-interaction`。 +7. **自检**:§4 块齐全;name 与 catalog 一致;用户文案无裸英文 id。 + +--- + +# 第六部分:给撰写 AI 的通用任务指令(粘贴用) + +请撰写(或重写)一个【能力】文档: +`skills/dialogue/world-simulator/modules/{id}/prompt.md` +(具体中文名 / id / 边界由出题方在指令末行指定。) + +必须遵守: + +1. 本文**第一部分~第四部分**的项目理解、称呼与格式契约。 +2. 只使用规定的 fence 块;不要发明新 fence 名。 +3. 对用户话术使用导演/剧本/演员/能力;不要写「总管调度 design-xxx」。 +4. 正推:体验 → 手段;禁止题材套件与否定式路由。 +5. 交互与美学已合并为「美学纲领与交互范式」;禁止建议拆回两步。 +6. 不要修改程序代码;默认只交付 `prompt.md`(除非出题方要求改 catalog)。 +7. 输出完整 Markdown 文件正文。 + +出题方填写: + +```text +能力中文名: +id: +artifact: +上游依赖(常见): +明确不做(交给谁): +特殊产物要求(可选): +``` + +--- + +# 第七部分:当前能力池速查(选题,非本文重点) + +世界模拟器常用(编排按需,勿默认全选): + +| 能力 | id | 状态(以仓库为准) | +|------|-----|-------------------| +| 美学纲领与交互范式 | `aesthetics-interaction` | 范例 | +| 实现机制 | `mechanism` | 待细写 | +| 世界蓝图与人文地理 | `world-blueprint` | 已写 | +| 生成规则 | `generation-rules` | 待细写 | +| 具体实例 | `concrete-instances` | 待细写 | +| 拓扑图谱 | `topology` | 待细写 | +| 叙事指南 | `narrative` | 待细写 | +| 变量设计与更新规则 | `variable-design` | 待细写 | +| 变量控制上下文 | `variable-context` | 待细写 | +| 设计状态栏 | `status-bar` | 待细写 | +| 设计回复格式 | `reply-format` | 待细写 | +| Worker 规格 | `worker-spec` | 待细写 | +| 细化终稿 | `refine` | 待细写 | + +导演示例:世界模拟器、扩写助手(见 `recipes/`)。 + +--- + +# 附录:与旧文档关系 + +| 文档 | 关系 | +|------|------| +| 本文 | **泛用**能力撰写 + 项目/称呼;给外部 AI 的主交接 | +| `world-simulator-modules.md` | 仓库内清单与格式摘要 | +| `ui-glossary.md` | 用户可见文案权威 | +| `architecture.md` | 运行内核;写能力时不必复述实现细节 | +| 旧 `capability-mechanism-handoff.md` | 已过时为「单能力特例」;以本文为准 | diff --git a/docs/briefs/capability-mechanism-handoff.md b/docs/briefs/capability-mechanism-handoff.md new file mode 100644 index 0000000..ed39566 --- /dev/null +++ b/docs/briefs/capability-mechanism-handoff.md @@ -0,0 +1,20 @@ +# (已迁移)实现机制单能力简报 + +本文件原先只讲「实现机制」特例,**已废弃**。 + +请改用泛用交接简报: + +→ **[`capability-authoring-brief.md`](./capability-authoring-brief.md)** + +其中包含:项目概述、导演/剧本/演员/能力称呼、泛用能力格式规范,以及可粘贴给其它 AI 的任务指令。 + +若要写「实现机制」,在泛用简报第六部分末行填写: + +```text +能力中文名:实现机制 +id:mechanism +artifact:设计.实现机制 +上游依赖(常见):美学纲领与交互范式 +明确不做(交给谁):拓扑图谱 / Worker 规格 / 变量* / 状态栏 / 回复格式 / 细化终稿 +特殊产物要求(可选):总览最小可运行结构;含「为何需要」「未纳入与原因」;正推禁止默认世界模拟套件 +``` diff --git a/docs/context-assembly.md b/docs/context-assembly.md index 154fc52..3c7689e 100644 --- a/docs/context-assembly.md +++ b/docs/context-assembly.md @@ -11,14 +11,19 @@ Worker / skill 执行时,Runtime 将黑板 tag 与固定体裁说明拼成 LLM ## 2. 上半固定、下半动态 -每条 worker prompt 分为两段: +每条 worker prompt 自上而下拼接(**一段式 stack,越稳定越靠上**): ```text +┌─ Preset(会话 presetId)────────────────────────────┐ +│ 生成参数、prompt 条目(system / 文风片段等) │ +│ 见 preset-format.md;与 skill 包正交 │ +└────────────────────────────────────────────────────┘ ┌─ 上半:固定上下文(Static)────────────────────────┐ │ shared-context.md(包级体裁约束) │ │ worker SKILL.md 正文(能力说明、自检) │ │ contextSegments 中 tier=static 的 tag │ -│ 例:角色卡.确认稿、世界.蓝图、设计.交互范式 │ +│ 例:角色卡.确认稿、世界.蓝图、设计.worker集 切片 │ +│ (含 narrator 的 presentation,无单独美学纲领 tag)│ └────────────────────────────────────────────────────┘ ┌─ 下半:动态上下文(Dynamic)────────────────────────┐ │ contextSegments 中 tier=dynamic 的 tag │ @@ -30,7 +35,7 @@ Worker / skill 执行时,Runtime 将黑板 tag 与固定体裁说明拼成 LLM 原则: ```text -越稳定、越少改 → 越靠上(static) +越稳定、越少改 → 越靠上(preset / static) 越增量、每轮变 → 越靠下(dynamic) ``` @@ -80,7 +85,11 @@ contextSegments: | `label` | 拼进 prompt 的 Markdown 标题(可选) | | `policy` | 动态段裁剪,见 §5 | -未声明 `contextSegments` 时,Runtime 回退:按 `inputTags` 顺序输出 JSON `inputs`(当前实现)。 +未声明 `contextSegments` 时,Runtime 回退:按 `inputTags` 顺序输出 JSON `inputs`。 + +有 `contextSegments` 时:按 static → dynamic 顺序拼 Markdown(含 `label` 标题);实现见 `src/skills/context-segments.ts` → `assembleWorkerContext()`。 + +创作前情:`创作.已验收内容` 存各单位**最后一次验收**的内容切片,拼装时格式化为只读前情提要。 --- @@ -93,17 +102,20 @@ contextSegments: | `tail_lines_N` | 事件流等取最后 N 行 | | `tail_tokens_N` | 按估算 token 截断(预留) | -上下文过长时: +上下文过长时(**本节管 Run worker 拼装**;创作会话见 `design-orchestrator-guide.md` §7.2): 1. 优先靠 policy 裁剪动态段 -2. 实例 manifest 可覆盖 variant(如 `historyPolicy: last_10_turns`) -3. agent 可 invoke 显式 **compress-history** skill 写摘要 tag(调度 skill,不是随手删 prompt) +2. **Run 验收后压缩**:过程 tag 归档,仅终产物 + `上下文.定稿摘要` 进入下一 worker(`compress-after-worker.ts`) +3. 实例 manifest 可覆盖 variant(如 `historyPolicy: last_10_turns`) +4. 长线再考虑显式 compress-history / RAG,禁止「完整历史喂一个 LLM 再筛给另一个」 + +**创作**不走本拼装栈:讨论放 session `messages`;单位验收后 **删交互、留产物**。Worker 执行仍始终按契约从黑板重装。 --- ## 6. contextProfile(实例 manifest) -实例化阶段产出(写入 `设计.run_skill清单` 或 Book manifest),agent **只选预置档位**,不列 tag: +实例化阶段产出(写入 `设计.worker集` 或 Book manifest),agent **只选预置档位**,不列 tag: ```json { @@ -161,7 +173,7 @@ user: {按 segment 顺序格式化的 Markdown 或结构化块} ``` -实现:`src/worker/executor.ts` → `assembleWorkerContext()`(待从纯 JSON inputs 升级)。 +实现:`src/skills/context-segments.ts` → `assembleWorkerContext()`;由 `src/worker/executor.ts` 调用。 --- diff --git a/docs/creation-playbook.md b/docs/creation-playbook.md index 3a052ac..066a16f 100644 --- a/docs/creation-playbook.md +++ b/docs/creation-playbook.md @@ -1,10 +1,13 @@ # 创作流程指南(Creation Playbook) +> **文档层级:剧本 / 创作流程概念(非系统架构)。** +> 系统模块与边界见 [`architecture.md`](./architecture.md)。 + ## 0. 内容在哪 -**流程定义在 skill 包里**(orchestrator manifest + `workers/*/SKILL.md`)。本文只保留概念。 - -写新包 → `skill-design-guide.md` → 新建 `skills/.../orchestrator.md`。 +**设计方法(正推、三大步、表与副作用)** → **`design-orchestrator-guide.md`**(权威)。 +**流程落地**在 skill 包(`orchestrator.md` + `design-intake`)。 +写新包 → `skill-design-guide.md`(包格式薄层)→ `skills/.../orchestrator.md`。 --- @@ -14,30 +17,52 @@ Orchestrator 包 能力库 + manifest(静态) 黑板 tag 一次 Session 的实参(动态) Book 跨 Session:过程、资产、游玩 +设计.worker集 实例规格(JSON;accept 后 = Worker 声明) ``` | 层 | 管什么 | |----|--------| | **运行相位** | `idle` / `running` / `waiting_user` — 系统在等什么 | -| **业务 stage** | `design`(实例化)→ `play`(运行)→ `done` | -| **Agent** | tool loop 内 invoke 哪个 skill | -| **Skill** | 读哪些 tag、写哪些 tag、上下文怎么拼 | +| **业务 stage** | `design`(创作)→ `play`(游玩)→ `done` | +| **Agent** | tool loop 内 invoke 哪个 worker | +| **声明** | play 可调度哪些 ref;executor 读声明(非题材管道) | | **Book** | 长期存储,见 `book-storage.md` | --- -## 2. 启动 +## 2. 默认包创作流 ```text -skill_selection 选 orchestrator 包 -intake 启动询问(最小信息) -design stage agent 按需 invoke instantiate skill -declare ready 进入 play -play stage 用户输入 → agent burst → run skill -done 归档 Book +新建作品 → UI 引导 → 用户首句 → 创作单位逐步谈 + → 宜:phase:core → 纲领类 fixed:* → worker:* → refine + (非死管道;可穿插,但禁止默认「先写完 worker 再填上下文」) + → 单位 = 已写出的 fixed:* / resident:* | worker:*(示例话题可问用户,非填空) + → 单位跑:只写 设计.worker集.草稿;讨论进 messages + → 用户接受 → 删本单位交互消息,只留产物;草稿保留 + → ……谈完后终稿写 设计.worker集 → 验收后才可进 play + → (可选)开局 · 开场白 → 用户手动进游玩 + → run:worker 按契约从黑板重装;acceptance=review 处停、压缩过程 tag ``` -`startupCompleted` / `instanceReady`:agent 声明 + 程序校验「当前 run_skill清单 可运行」,非固定 prerequisite 打勾。 +固定上下文 tag 的主收益是 **跨 worker 复用同一份正文**;写下游时仍依赖上游定稿(不能只报 tag 名省掉正文)。能省的是扯皮过程(验收折叠)。 + +不再要求用户选择 skill 包;默认 orchestrator 见 `src/config/default-orchestrator.ts`。 +方法细节见 **`design-orchestrator-guide.md` §7.2**。不要以「世界模拟器」为默认总形态。 + +### 进入游玩 + +- Worker 集 **accepted**(试验期最低门槛) +- **进 play 由用户手动决定**;开局锁定非强制 + +### 编辑与重 roll + +```text +关键节点可存快照 +不满意 → 加载 earlier 快照 / 重 roll +play:重 roll 替代每轮验收;新输入 = 隐式认可上轮终稿 +``` + +见 `run-snapshot.md`。 --- @@ -45,9 +70,9 @@ done 归档 Book | | Agent | Runtime | |--|-------|---------| -| 决定 | invoke 哪个 skill、何时 ask_user/finish | — | -| 拼接上下文 | 只用 read_blackboard 辅助决策 | assembleWorkerContext | -| 写黑板 | 否(边界 tool 驱动 worker 写) | 校验 outputTags 后写入 | +| 决定 | invoke 哪个 worker、何时 ask_user/finish | — | +| 拼接上下文 | 只用 read_blackboard 辅助决策 | 常驻上下文 + 声明拼装 | +| 写黑板 | 否(边界 tool 驱动 worker 写) | 校验后写入;表字段尊重 rev | Agent **不指定 inputTags**。见 `context-assembly.md`。 @@ -55,29 +80,16 @@ Agent **不指定 inputTags**。见 `context-assembly.md`。 ## 4. Tool loop burst -每次用户硬事件后,agent 进入 `running`,在 **burst 上限内** 多轮 tool;碰到边界 tool 或需用户则停。 +每次用户硬事件后,agent 进入 `running`,在 burst 上限内多轮 tool;碰到边界 tool 或需用户则停。 见 `tool-contracts.md`、`runtime-state-machine.md`。 --- -## 5. 角色卡(一种 Book 形态) - -```text -创建过程 designTrace + 对话 → CardBook(design) -创建结果 角色卡.确认稿 等 → CardAsset(可导入资产) -游玩过程 playTrace + runState → PlayBook -游玩存档 PlaySnapshot -``` - -不拆独立 author/play 包;见 `book-storage.md` §角色卡。 - ---- - -## 6. 相关文档 +## 5. 相关 | 文档 | 关系 | |------|------| -| `architecture.md` | 总览 | -| `skill-design-guide.md` | 设计方法 | -| `context-assembly.md` | 上下文拼接 | +| **`design-orchestrator-guide.md`** | ★ 创作设计方法 | +| `skill-design-guide.md` | 包与字段格式 | +| `run-snapshot.md` | 存档 | | `tag-blackboard.md` | 标签 | diff --git a/docs/daily-use-p0.md b/docs/daily-use-p0.md new file mode 100644 index 0000000..a623722 --- /dev/null +++ b/docs/daily-use-p0.md @@ -0,0 +1,274 @@ +# 日常使用场景 → P0 详细路径 + +## 0. 怎么用本文 + +1. **先定场景**:日常到底在干什么(§1) +2. **再列功能**:每个场景要哪些能力、是否已有(§2) +3. **收成 P0**:只把「日常阻断 / 高频刚需」推进 P0 详细路(§3) +4. **其余进后续**:体验打磨与加固,不塞进 P0(§4) + +系统边界见 `architecture.md`;总路线见 `px-roadmap.md`。 + +### 0.1 已确认取向(2026-07-26) + +| # | 结论 | +|---|------| +| 1 | 日常包含 **两类**:AIRP/交互扮演 **与** 长文/爽文创作(同一运行内核) | +| 2 | **UI 不自研美学**:布局/控件直接抄参考产品即可;**存档与续作稳定是硬指标** | +| 3 | **导演**:新建时用 UI **选一个**(世界模拟器 / 扩写助手…;初期可只有 1 项) | +| 4 | **剧本动态**:不在 UI 里点选死板「剧本菜单」;选定导演后,由导演(Agent)听用户说话谈出本局剧本(创作流程 / Worker 集)与调度 | + +用户侧拍摄术语权威对照:**`ui-glossary.md` §0**(导演 / 剧本 / 演员 / 能力)。 + +### 0.2 导演 vs 剧本(动态) + +```text +导演 = 用户 UI 选一次的方法起点(可调味) + = 内部 recipes / 原「能力包·配方」合一 + = 例:世界模拟器、扩写助手 + +剧本 ≠ 再选一张固定流程卡 + = 选定导演之后:用户说话 → 导演调度创作 + → 产出 设计.创作流程 + 设计.worker集(本局活剧本) + → 游玩再按声明让【演员】上场 + +能力 = 共用工序(美学纲领与交互范式…);导演从中编排 +演员 = 实际上场的 Worker +``` + +| | 导演 | 剧本(动态) | +|--|------|----------------| +| 谁选 | **用户 UI**(显式,一层) | **导演 Agent + 用户对话**(隐式) | +| 变不变 | 选项相对稳定 | 每局流程、Worker 集、回合调度都可变 | +| 忌讳 | 再叠一层「配方」选择 | 固化成死 DAG / 死选单 | +| 本仓库对应 | `recipes/` + 默认 skill 包 | `设计.创作流程` / `设计.worker集` | + +因此:**只给导演一个选择 UI**;**不要**再给「剧本 / 配方」做第二层死板选择器。 + +**多导演**才有意义的情况:方法边界真不同(世界模拟 vs 扩写助手),而不是同一导演内的题材分化——后者留在剧本层动态谈成。 + +--- + +## 1. 日常使用场景(产品视角) + +### 1.1 主场景(P0 对准)— 两条产品线,同一内核 + +#### 线 A:AIRP / 交互扮演 + +| ID | 场景 | 用户在干什么 | +|----|------|--------------| +| **A1** | 开新档创作 | 描述想扮演/交互什么,谈到可玩的 Worker 集 | +| **A2** | 验收定稿 | 接受/重来/跳过询问,直到可进游玩 | +| **A3** | 进游玩 + 多回合 | 读终稿 → 输入 → 再生成;偶发重 roll | +| **A4** | 续作 + 存档读档 | 关掉再开接着玩;关键节点存/读档 | + +#### 线 B:长文 / 爽文创作 + +| ID | 场景 | 用户在干什么 | +|----|------|--------------| +| **B1** | 开新档创作 | 描述题材/爽点/篇幅;站位偏「写手/统筹」或「助手分段」 | +| **B2** | 验收定稿 | 钉大纲/章结构/写手类 Worker 与固定上下文,可进「写」 | +| **B3** | 分段出文 | 按大纲或指令生成章/节正文;不满意重 roll 该段 | +| **B4** | 续作 + 存档读档 | 连载式接着写;章节点存档、读档不丢正文与进度 | + +A4 / B4 **共用**同一套存档语义(`session.json` + `instance`/`run` 快照);P0 把稳定性做硬。 + +### 1.2 次场景(P0 不精做) + +| ID | 场景 | 说明 | +|----|------|------| +| S7 | 改表 / 状态面板 | 有就不炸;精致编辑 → 后 | +| S8 | 改规格再开写/玩 | 有 instance 加载即可,流程打磨 → 后 | +| S9 | 开局 swipe / 多版本对比 | 有更好,非 P0 闸门 | +| S10 | 预设商店式管理 | 失败可读即可 | + +### 1.3 明确非 P0 + +| ID | 说明 | +|----|------| +| N2 | 狼人杀级信息隔离 | +| N3 | Context Trace 专业调试台 | +| N4 | 多导演**运营商店**(P0 只要选择 UI,可仅 1 项) | +| N5 | DefinitionVersion 完整发布迁移协议 | +| N6 | SillyTavern 卡批量导入导出 | +| N7 | 自研精美 UI / 按 `ui-design` 大改布局(**抄参考即可**) | + +--- + +## 2. 场景 → 功能需求(含现状) + +图例:✅ 基本有 🟡 有但糙/易踩坑 ❌ 缺或不可靠 + +### 共用:创作 / 验收 / 进出 + +| 功能 | 现状 | P0? | +|------|------|-----| +| 新建作品 → 选导演(能力包;初期可仅一项) | ✅/🟡 | **必做**(有选择 UI;一项时默认选中即可) | +| 意图可走扮演 **或** 写手/爽文(剧本层动态,不另选死板剧本卡) | 🟡 | **必做** | +| 验收出口无死胡同 | 🟡 | **必做** | +| 进入游玩/写作阶段门槛清晰 | 🟡 | **必做** | +| UI 自研打磨 | — | **不做**;缺块就抄参考产品交互 | + +### 线 A:多回合扮演 + +| 功能 | 现状 | P0? | +|------|------|-----| +| 连续 ≥3 回合不卡死 | 🟡 | **必做** | +| 失败可重试 | 🟡 | **必做** | + +### 线 B:长文 / 爽文 + +| 功能 | 现状 | P0? | +|------|------|-----| +| 创作能产出「大纲 + 写章」类 Worker 声明(模板或谈成) | 🟡 | **必做**(不新开第二包;缺则补 **templates / 引导**,不是多包) | +| play 期能按段生成正文并展示 | 🟡 | **必做**(≥2 段/章连续) | +| 章/段级重 roll 或读档回到段前 | 🟡 | **必做**(与存档同一套:load earlier → 重来) | +| 独立长文编辑器 / Monaco | ❌ | **不做**(抄现有主舞台展示正文即可) | + +### 存档 / 续作(最高优先级) + +| 功能 | 现状 | P0? | +|------|------|-----| +| `session.json` 续作相位正确 | 🟡 | **硬必做** | +| 手动存 / 列表 / 加载后可继续 | 🟡 | **硬必做**(线 A、线 B 各验一次) | +| 存档不丢终稿正文与关键 tag | 🟡 | **硬必做** | +| 分支树 UI | ❌ | 不做 | + +--- + +## 3. P0 详细路径 + +**P0 一句话**:用户用 UI 选好**导演 Skill**(初期可只有一个)后,AIRP 与长文/爽文都在**剧本层动态**谈成并跑起来,且 **存档/续作稳定**;UI 抄参考即可。 + +### 3.1 黄金路径(两条都要绿) + +**线 A(扮演)** + +```text +A-GP1 新建 → UI 选导演(可仅一项)→ 描述扮演/交互意图 +A-GP2 验收至可进游玩 +A-GP3 ≥3 回合输入→终稿 +A-GP4 存 run 档 → 刷新/重开续作成功 +A-GP5 加载 earlier 档或新开一条线之一可用 +``` + +**线 B(长文/爽文)** + +```text +B-GP1 新建 → UI 选导演 → 描述爽文/长篇意图(写手或分段助手) +B-GP2 验收至可进写作(Worker 集含大纲/写章类能力,勿只能聊天扮演) +B-GP3 连续生成 ≥2 段(章)正文 +B-GP4 存档含已生成正文 → 续作能接着写下一段 +B-GP5 回到 earlier 档重 roll 某段 或 等价恢复可用 +``` + +### 3.2 工作包 + +#### WP-S 存档稳定(最先 / 贯穿) + +| # | 项 | 验收 | +|---|-----|------| +| S1 | 续作:打开 Book = 恢复可操作相位 + 输入区与等待态一致 | A-GP4 / B-GP4 | +| S2 | 手动存:人话 kind;写入后列表可见 | 同上 | +| S3 | 加载:黑板 + 消息 + 运行进度一致,可继续生成 | A-GP5 / B-GP5 | +| S4 | 回归:线 A、线 B 各完整存读一轮,不手改 `books/` | 双线绿 | +| S5 | 已知「存档丢正文 / 续作进死相位」类 bug 清零 | 无阻断 | + +#### WP-D 导演选择(与存档并列早做) + +| # | 项 | 验收 | +|---|-----|------| +| D0 | 新建作品可选导演(registry);仅 1 项时默认选中并可确认 | 进入该包创作 | +| D1 | 人话展示导演名/简介,不暴露路径 | 可选懂 | + +#### WP-A 进出与失败出口(文案够用即可) + +| # | 项 | 验收 | +|---|-----|------| +| A1 | 裸 id 不进用户主路径(glossary 底线) | 无 `design-core` 裸露 | +| A2 | `waiting_user` 有主按钮出口 | 无死胡同 | +| A3 | 进游玩/写作门槛提示 | 误进可理解 | +| A4 | LLM/worker 失败:中文 + 重试 | 可回到可输入 | + +#### WP-R 线 A 回合可靠 + +| # | 项 | 验收 | +|---|-----|------| +| R1 | ≥3 回合无卡死 | A-GP3 | +| R2 | 假死可刷新恢复且不腐档 | 与 WP-S 一致 | + +#### WP-L 线 B 长文/爽文最小闭环 + +| # | 项 | 验收 | +|---|-----|------| +| L1 | 启动引导明确支持「写爽文/长篇/先大纲再写」 | B-GP1 | +| L2 | design 能稳定收到写手向 Worker 集(补 template 或 design 提示,**同一导演内动态谈成**) | B-GP2 | +| L3 | play 连续 ≥2 段正文 | B-GP3 | +| L4 | 正文在存档/续作中仍在 | B-GP4 | + +#### WP-U UI(抄,不雕) + +| # | 项 | 验收 | +|---|-----|------| +| U1 | 缺的存档列表/确认/重试控件:直接仿参考产品,不写设计长文 | 能点、能完成 GP | +| U2 | **禁止** P0 内按 `ui-design.md` 做大布局重构 | 范围不膨胀 | + +#### WP-E 闸门 + +| # | 项 | 验收 | +|---|-----|------| +| E1 | 线 A、线 B 黄金路径各冷启动 + 续作通过 | 全绿 | +| E2 | 更新 `px-roadmap` / `architecture` 进度勾选 | 文档同步 | + +### 3.3 P0 明确不做 + +- 第二套死板「剧本选单」/ 固定题材 DAG +- 自研精美 UI、工作台大改 +- 隔离模拟、Trace 台、完整副作用调度、版本迁移协议 +- 为文采大改全套提示词(仅修**阻断线 B 收不成写手规格**的引导/模板) + +### 3.4 建议顺序 + +```text +1. WP-S 存档稳定(先修已知腐档) +2. WP-D 导演选择 UI(可仅一项) +3. WP-A 失败出口 / 进出 +4. WP-R 线 A 回合 +5. WP-L 线 B 长文最小闭环(引导 + template + 存档验正文) +6. WP-U 仅补抄来的缺口控件(含选导演/存档若未齐) +7. WP-E 双线验收闸门 → P0 完成 +``` + +### 3.5 P0 DoD + +- [ ] 线 A:A-GP1–5 通过(含选导演、续作/读档) +- [ ] 线 B:B-GP1–5 通过(含选导演、正文不丢) +- [ ] 导演选择 UI 可用(可仅一项) +- [ ] 无等待死胡同;失败可重试 +- [ ] 不依赖手改磁盘文件 +- [ ] UI 无大改承诺;存档稳定优先于观感 + +--- + +## 4. 日常功能 → 阶段归属(修订) + +| 日常能力 | 阶段 | +|----------|------| +| 导演选择 UI(可仅一项)、剧本层动态谈规格、AIRP+长文主路径、**存档续作硬稳定** | **P0** | +| 抄来的 UI 缺口补齐 | **P0(U)** / 不够再后补 | +| 表编辑精修、改规格心流、副作用完整 | **PX1/PX2** | +| Trace、预算、版本协议、E2E | **PX3** | +| 长文「专业编辑器 / 评测 / 多卷」加深 | **原 PX4 收窄**(P0 已含最小长文) | +| 隔离 + 专业调试 | **PX5** | +| 多个导演包(方法真不同时再加) | **需要时再加**;非「剧本菜单」 | + +--- + +## 5. 假设状态 + +| # | 原假设 | 状态 | +|---|--------|------| +| 1 | 仅 AIRP | **已订正**:+ 长文/爽文 | +| 2 | UI 要按设计稿打磨 | **已订正**:抄参考;存档稳定优先 | +| 3 | 永远自动加载、无选包 UI | **已订正**:UI 选**导演**;剧本层保持动态 | +| 4 | 单包 = 不能多种玩法 | **已澄清**:一个导演内剧本动态分化 | diff --git a/docs/design-orchestrator-guide.md b/docs/design-orchestrator-guide.md new file mode 100644 index 0000000..1ca0124 --- /dev/null +++ b/docs/design-orchestrator-guide.md @@ -0,0 +1,511 @@ +# 创作设计指导(总管 / 分步 design skills) + +> **文档层级:剧本 / 创作方法(非系统架构)。** +> 运行内核边界见 [`architecture.md`](./architecture.md);本文不定义相位机、持久化或 Context Compiler。 + +权威说明:如何从用户意图收成 **实例规格(`设计.worker集` JSON)**,并指导 play 期声明调度。 +非固定创作流水线;`world-simulator` 等只是可选能力 ref,不是默认总形态。 + +相关:`context-assembly.md`(拼装)、`preset-format.md`(全局预设)、`tag-blackboard.md`(黑板)、`run-snapshot.md`(快照)。 + +包内落地: + +| 文件 | 角色 | +|------|------| +| `orchestrator.md` | 总管调度 | +| `design-common.md` | 创作共同开头(注入各 design-*) | +| `workers/design-core/` | A 核心(开放,不拆 A1→A2→A3 管道) | +| `workers/design-worker/` | 一次一个 worker | +| `workers/design-fixed/` | 一次一块固定上下文 | +| `workers/design-refine/` | C 细化 / 终稿 | + +**运行时以各 SKILL + design-common 为准**;本文是方法长文,改方法时与 skill 切片一起改。 + +--- + +## 0. 总原则 + +```text +三大步(每步含自检,可反复,无强制题材工序) + A 核心 → 站位 + 系统扮演/输出/交互 + 体验检验 + B 细节交互 → Worker/常驻上下文能否撑起世界与交互;冲突请用户选 + C 细化 → 钉死不能瞎发挥的关键前提;表/数据拓扑/副作用;用户是否满意 + +全程正推:需要 [体验] → 用 [常驻上下文 | 表 | worker | 程序 | 预设 | tool] 维持/产出 +每增必问:能否缩减?常驻上下文能否替代?能否合并?黑板够不够/是否多余? +``` + +**禁止** + +- 题材 → 固定 worker 清单;否定式路由;完整历史喂 LLM 再筛给另一 LLM +- 格式/排版专用 worker;临时生成 tool;默认 input 整理 worker +- 把「世界模拟」当成开场默认答案 +- 固定 instantiate 管道(world-blueprint 等) + +**预设**:凡 LLM 请求(总管 / agent / worker)均插入用户预设。 +**进 play**:用户手动满意后进入(程序不强制开局锁定)。 +**Play 回合**:用户新输入 = 认可上一轮最终展示;否则重 roll / 编辑后重 roll。表维护可延后。 + +**规格格式**:`设计.worker集` 为 **JSON**(权威存储);解析层可兼容旧 YAML 草稿,新产出必须 JSON。 + +--- + +## 1. 交互流程(先定「怎么和人轮转」) + +输出本质可以都是「系统给出一截用户可见结果」,但 **轮转形态**不同,规格必须写明: + +| 形态 | 典型 | 规格要点 | +|------|------|----------| +| 对话回合 | 最终展示一轮 ↔ 用户一轮 | 终稿 tag;无每轮验收 | +| 助手分段 | 先大纲/细纲,用户填表再继续 | 表可编辑;阶段门控;大纲可走表副作用 | +| 其它 | 旁观多角、写手统筹… | 站位 + 系统扮演共同决定 | + +用户可 **编辑表(精确到字段格)** → 必须有 **字段级版本**(见 §5)。 + +--- + +## 2. 三大步 + +### A. 核心 + +#### A1. 用户站位(扮演什么) + +单角代入 / 代理操控 / 旁观·实验 / 写手统筹 / 多角切换… +→ 谁决策、输入如何解释、结构骨架。 + +#### A2. 系统扮演什么(play agent 整体) + +指整个 **play agent 系统** 要: + +- **扮演什么**(世界执行者?对白对手?写作助手?陈述实验?) +- **输出什么**(叙事、摘要、选项、表、大纲块…) +- **怎么输出**(单段终稿 / 程序拼状态栏+正文 / 多面板) +- **怎么与用户交互**(回合对话、填表驱动、先大纲后正文…) + +由此才谈:要不要转述、程序拼格式、表是否上屏等。 + +#### A3. 核心体验检验(首要评分,自检必做) + +对当前理解与「现有 worker/上下文草案」打分或文字判定。 + +**对内**:可只在思维链里过一遍。 +**对外**(总管 `ask_user.assessment`,或关键分叉复述):写成用户可读的完备度评价,再据此出题。 + +**首要(必须检)** + +| 维 | 含义 | +|----|------| +| 核心体验 / 满足来源 | 用户最想反复感到什么;爽点从哪来(权力、求生、关系、解谜…)——**一切评价与追问以此为轴** | +| 用户–`` 关系 | 用户与代入角色/叙述对象的关系是否清楚 | +| 焦点位置 | 注意力与镜头落在哪 | + +**可选(有缺口才写,不必凑齐)** + +| 维 | 含义 | +|----|------| +| 内容维度 | 情节/日常/战斗/推理…比重与情绪质感 | +| 人物与关系 | 核心关系动态 | +| 身体与感官 | 物质、亲密、暴力等描写边界 | +| 背景与规则 | 世界架与规则用途 | +| 意义与主题 | 快感 / 代价 / 终点 | + +`assessment` 建议结构(按需删维): + +```text +# 内容维度 +核心感觉: 完备度 40% + 已知: … + 待探: 用户期望的【情绪质感】是【方向A】?【方向B】?还是组合? + +人物与关系: 完备度 50% + 已知: … + 待探: … +``` + +`questions`:只针对「待探」里必须用户拍板的点;一次 1~2 题。 +- `prompt`:明确题干(「你更倾向于哪种…」) +- `options`:**建议示范**(可含短场景钩子 + 点题句),用户可改写后采用;禁止空泛是/否 + +自问:缺口是常驻上下文、表、worker,还是必须问用户?能推断就别问。 + +变造世界提示:用户常给「基础架 + 变种设定」;核心体验往往来自变种——从设定读内核,但 **不要**写成固定工序,也不要题材锁死体验。 + +#### A 步通用自检 + +见 §3。 + +--- + +### B. 细节交互 + +对照 A 的结论,检查草案: + +1. **现有 worker(+ 常驻上下文)能否满足需求?** +2. **能否支撑起整个世界/助手流程?** +3. **有无冲突必须用户选择?**(两套互斥满足来源、互斥交互形态等 → `ask_user`) + +仍用正推补洞;能固定上下文则不上 worker(§6:示例话题可问用户,不是填空表)。 +**顺序软规则**:纲领类固定上下文(风格/叙事/美学)宜先于大量 worker——同一份 tag 挂多个 worker,而不是先列 worker 再填上下文。 +然后 §3 自检。 + +--- + +### C. 细化 + +原则:只钉 **不能交给 AI 自由发挥、又对体验关键** 的东西。 +变造世界已能推出的(已知现代 → 不问常规科技树)默认不问,除非用户提过。 + +常钉类(按需):规则与 **核心实现前提**、状态、**表结构**、**整体叙事指南**(≠ 文风:管世界态度与体验边界)、输入协议。 + +本步更盯 **数据**: + +- 什么要 **拓扑化**(依赖、阶段边、触发边)? +- 什么要 **数据化/进表**? +- 表字段、显隐(折叠防剧透,非防用户)、维护规则、副作用是否清楚? +- **用户是否满意** 这些钉死项? + +大纲:**不必**单独「大纲管道」。需要时用 **表 + 副作用**——例如表字段 `当前章=第一章` → 边沿触发插入「第一章细纲」上下文(与好感改人设同一套机制,见 §5)。 + +然后 §3 自检。 + +--- + +## 3. 每一步自检清单(三大步与每一小步共用) + +每完成一截推理或改草案,强制过一遍: + +| # | 问 | +|---|-----| +| 1 | **能否缩减?** 这条能力/字段/worker 删了是否仍成立? | +| 2 | **增加的必要性?** 删掉会丢掉哪段体验? | +| 3 | **上文是否足够** 辅助主 LLM 完成其 duty?不足补常驻/表/黑板,而非盲目加工人 | +| 4 | **写入黑板的内容** 够不够?是否多余? | +| 5 | **能否与其它 worker 合并?** 或 **常驻上下文替代?** | +| 6 | **上下文插入是否按「变动/重要度」顺序?**(稳定块与易变块分层;参考 ST 注入位思路) | +| 7 | (C 步加重)数据传播、表、副作用、字段版本是否自洽?有无「持续为真每轮触发」? | + +复述给用户时:说明有什么、**为何没有** 某重型件。 + +--- + +## 4. 短板加装(表见 §5) + +| 短板 | 做法 | +|------|------| +| 可见文本质量 | 转述 worker **或** 常驻文风;能轻则轻 | +| 记忆 / 长线 / 大纲 / 随机分支 / 人设阶段 | **统一用表**(§5):维护 + 显隐 + 副作用插换上下文 | +| 信息差玩法 | 分角 worker;极贵,慎用 | +| 格式 | 程序拼 | +| 真随机 / 骰子卡组计算 | 用户触发或填入,或表;已有 tool 手动调;不临时造 tool | +| 用户不应知道的信息 | 程序固定注入,不进可见区;非 input 整理 worker | +| 输入协议 | 常驻上下文(`()` 元要求、`""` 对白、无包裹=事实等) | + +长线记忆:**不上**则靠模型;**要上**则 RAG(固定少设计)或表;禁止历史互喂筛选。 + +--- + +## 5. 表(结构 · 版本 · 维护 · 副作用 · 大纲) + +### 5.1 用途 + +状态与物品、显隐信息、阶段/章节、随机与事件权重、人设态度段、**大纲/细纲挂载**、驱动常驻上下文插入或替换。 + +### 5.2 结构与显隐 + +- 扁平、利于二维表 UI;schema 写在 Worker 集 +- **显形 / 隐藏(可折叠)**:防剧透、分层观看,不是防用户 +- 维护规则可执行,并带触发语义(默认 once + edge) + +### 5.3 字段格版本(防互相覆盖) + +每一 **字段格**(非仅整表)含至少: + +```text +value, rev, updatedAt, source # source: user | worker: | system +``` + +合并规则: + +- 用户编辑升 `rev`,`source=user` +- Worker 写回须带 **读时的 rev**;若当前 rev 已变 → **不覆盖**,跳过或 ask_user +- 禁止无条件用维护结果盖掉用户格 + +### 5.4 维护 worker + +读:正文/裁决、维护规则、当前表 → 写:字段更新。 +可不写用户终稿;可与展示链解耦、**延后**跑。 + +### 5.5 副作用(含大纲、人设、事件) + +表值变化满足条件 → 触发声明内专用逻辑(改写态度段、插入第 N 章细纲、替换某常驻块等)。 + +优先:查表换段;跨阶段再跑改写 LLM。 + +**防无限触发(程序约定,写入规格):** + +1. **边沿**:`prev` 不满足且 `now` 满足才触发 +2. **阶段字段**:规则绑「进入 stage」,不绑「处于 stage」 +3. **`fired` / ruleId**:once 规则触发后标记,不再入队 +4. 可重复须显式(`mode: every_edge`)+ 冷却/边沿 +5. 同 round 同 ruleId 最多一次;对本轮维护用快照算边沿,限制连锁爆炸 + +例:`好感>=60→恋爱` 不得每轮触发;`当前章=第一章`→插入细纲须 edge 或章字段变更 once。 + +**规格字段(`tables.side_effects[]`):** + +```json +{ + "id": "affinity-romance", + "field": "好感", + "op": "gte", + "value": 60, + "mode": "once", + "action": { + "type": "write_tag", + "tag": "上下文.角色态度", + "content": "恋爱模式:……" + } +} +``` + +| 字段 | 含义 | +|------|------| +| `op` | `eq` / `neq` / `gte` / `lte` / `gt` / `lt` / `truthy` / `changed` | +| `mode` | `once`(边沿 + 记 fired)\| `every_edge`(可反复跨边沿) | +| `action.type` | `write_tag` / `replace_tag` / `queue_worker` | + +实现:`src/blackboard/table-side-effects.ts`;写 `变量.当前` / `运行.初始变量` 后由 runtime 求值;fired 存 `运行.表副作用.fired`。 + +### 5.6 与交互流程 + +助手流:生成大纲/细纲 → **用户写入/改表** → 副作用或调度读表继续;始终尊重字段 `rev`。 + +--- + +## 6. 固定上下文族与预设 + +**「固定上下文」是概称**:不必单独开 worker、由程序按契约插入 prompt 的内容。 +**不上 worker ≠ 不重要**——谈清楚并写进规格的纲领/范式,往往比某个中间 worker 更决定体验。 + +### 6.0 Tag 的真正用途(纠正) + +固定上下文用稳定 **tag / 单位 id** 命名(如美学纲领、叙事指南),便于: + +1. **同一份正文挂到多个 worker**(`mount` / `contextSegments` / 声明映射)——这是主收益 +2. **创作单位分块验收**——扯皮过程可折叠,定稿切片进「创作.已验收内容」(已做) + +**不是**「只报 tag 名就能省掉创作期依赖正文」。写 B 块时若依赖 A 的内容,仍要把 A 的定稿喂给写 B 的 LLM;tag 不能替代依赖传递。 + +```text +能省:反复扯皮直到定稿之前的过程(验收折叠) +不能省:写下游时对上游定稿正文的依赖 +主收益:跨 worker 复用同一份固定上下文,而不是为每个 worker 各写一份 +``` + +### 6.1 用法(重要:不是填空) + +§6.2 里的名称 **只是示例话题**:告诉 design / 总管——**可以向用户询问类似内容**,再收成固定上下文。 + +```text +不是:按表逐项填空、默认必谈、谈完才算过关 +而是:对话中若体验需要钉死某类边界/呈现/协议 → 可以按这类话题问用户 → 写入规格并注入 +``` + +正推仍成立:需要维持 [体验/边界/呈现] 且宜稳定注入 → 固定上下文;会变 → 表;本轮推理 → worker。 +问什么、问几句、要不要问,由当前体验缺口决定——**禁止把示例表当成问卷。** + +### 6.2 示例话题(可问用户的方向) + +| 话题(示例) | 大致在问什么 | 若收成规格,常见落点 | +|--------------|--------------|----------------------| +| 交互怎么转 | 站位、系统扮演、输出、怎么一轮一轮交手 | `interaction`(多由 `phase:core` 收) | +| 叙事指南 | 世界/助手态度与体验边界(≠ 文风) | `narrative_guide` | +| 美学纲领 | 呈现给用户的可读终稿气质 | `presentation` / 常驻美学块 | +| 输入协议 | `()` / `""` / 无包裹等约定 | `input_protocol` | +| 核心前提 | 不能瞎发挥又关键的硬前提 | `core_premises` | +| 其它常驻片段 | 只给部分 worker 的稳定句(态度段、轻规则…) | `resident_context[]` | + +落点 id(`fixed:*` / `resident:*`)仅在 **已经写出内容、要当创作单位验收** 时使用;没有内容就不要当成待填格子。 +实现里的 `FIXED_CONTEXT_CATALOG` 同样是提示目录,不是 UI 填空项。 + +### 6.3 创作顺序(软) + +```text +phase:core(核心体验 + 交互) + → 纲领类固定上下文(风格 / 叙事 / 美学…)——宜先于大量 worker + → 一次一个 worker(声明挂载哪些已有 tag,勿复制纲领) + → 杂项常驻 / 表 / 副作用 → 终稿 +``` + +**禁止默认**「先写完 worker 列表,再为每个 worker 填写上下文」。 +Worker 规格应回答:职责是什么、读哪些已有 tag、写哪些 tag——而不是顺带发明一份私有美学。 + +**UI**:检查器把固定上下文做成 **独立卡片区**(非 Worker 子项),每张卡展示 **塞进哪些 Worker**;见 `worker-set-view.ts` 的 `contextTags`。 + +### 6.4 常驻条目与预设 + +杂项常驻(`resident_context[]`)形状示例: + +```json +{ + "id": "tone", + "position": "static", + "importance": 2, + "content": "文风克制、偏冷", + "mount": ["narrator"] +} +``` + +- 默认黑板 tag `上下文.常驻.{id}`;可显式 `tag` +- `mount` 空 = 全部 worker;否则仅列出的 ref +- 能常驻则不上 worker;叙事指南 / 美学 / 交互等 **若已谈清**,用对应规格字段,不要全塞进含糊 `tone` +- 越稳、越决定体验边界 → 插入位越靠上 + +预设 = 全局;固定上下文 = 实例/worker 级。 +用户不可见、模型必须知 → **程序固定拼装**,不是整理用户输入的 worker。 +实现:`src/skills/resident-context.ts`。 + +--- + +## 7. 产出与验收 + +产出:`设计.worker集` JSON,建议包含: + +- `interaction`:站位、系统扮演、输出形态、与用户轮转方式 +- `experience_check`:首要三维(及可选维)摘要 +- `workers[]`:`ref` / `duty` / `rationale` / **`acceptance`(run 验收点,见 §7.3)** +- `resident_context[]`:位置、内容摘要、挂载哪些 worker +- `tables`:schema、显隐、维护规则、副作用(含 edge/once/stage) +- `narrative_guide` / `core_premises`:叙事指南与核心实现前提 +- `input_protocol`:若需要 +- `design_end`:可选开局 · 开场白等备忘(非强制管道) + +验收复述须含:站位;系统扮演/输出/交互;体验首要三维;有何 worker/表/副作用;**run 在哪些 worker 后停下来给人看**;为何没有某件;进 play 由用户决定。 + +交稿自检:题材词可替换仍成立;§3 清单通过;无持续为真每轮触发;格式程序化;字段级 rev 已设计;每个面向用户的可读终稿点有明确 `acceptance`。 + +--- + +## 7.1 强用户可读(前端约定) + +创作期检查器须让用户看懂: + +| 面板 | 内容 | +|------|------| +| **设计** | Worker 集卡片(含 run 验收点标注)、设计进度 | +| **验收** | 当前产物结构化预览 + 接受/不接受(主确认点) | +| **上下文** | 终产物 tag、活跃 tag、定稿摘要、已归档过程计数 | +| **历史** | 调度时间线(可折叠) | + +主 feed:隐藏过程调度;验收中的全文进验收 Tab;**创作对话 messages 全量保留供浏览**;拼给下一 design worker 的「创作.对话」才裁 AI(见 §7.2)。 + +--- + +## 7.2 创作与 Run:两套上下文(勿混) + +| | 创作 `design` | Run / play | +|--|---------------|------------| +| 单位 | **创作单位**:一个 worker,或固定上下文族中的一块(交互范式 / 叙事指南 / 美学纲领 / …),同级 | 声明内的 run worker | +| 会话 | 用户 ↔ 设计讨论进 **session `messages`(全量,用户可回看)**;**隐藏 AI 历史只作用于拼给模型的 transcript** | Worker **按契约从黑板重装**(`contextSegments` / `inputTags`) | +| 验收后折叠 | **不删 messages**;产物在黑板/规格;同步「创作.对话」供下一单位 | 过程 tag 归档 + `上下文.定稿摘要`(`compress-after-worker.ts`) | +| 禁止 | 一次收齐整份 worker 集 + 全部固定上下文;把纲领当成「顺便写的备注」 | 把创作闲聊塞进 worker prompt | + +### 创作单位(粒度) + +「固定上下文」= **族名**。族内某块 **只有谈出来并写入规格后** 才成为创作单位;§6.1 示例话题不是待填格子。 + +典型推进(非强制工序): + +1. `phase:core`:站位 + 交互 + 核心体验 +2. 需要时:风格 / 叙事 / 美学等 **纲领类** 固定上下文先验收(`design-fixed`) +3. 各 `worker:{ref}`:挂载已有 tag,勿复制纲领 +4. 其它 `resident:*` → 表 / 副作用 → 终稿 `设计.worker集` + +单位跑只写 `设计.worker集.草稿`;终稿才写 `设计.worker集`(才 `designInstanceReady`)。 + +### 创作会话(简单) + +```text +本单位进行中:问答、复述、草稿讨论 → 照常 append 到 messages(用户浏览全量) +拼给 AI:写入黑板 tag「创作.对话」= 用户全量 + 仅最后一次 AI 输出(含未完成提问) +不需要:步内 policy、创作专用拼装栈改写 messages +``` + +### 创作折叠(简单) + +```text +用户接受本单位产物 + → session.messages 不删(用户可继续浏览历史) + → 产物本身在黑板/规格里;已验收内容写入「创作.已验收内容」 + → 同步「创作.对话」transcript(仍按:用户全量 + AI 只留最后一次) + → 下一单位从「已定稿产物 + 新对话」继续 +``` + +不在创作期做「定稿摘要再注入下一 design LLM」的复杂链;产物本身在黑板/规格里,够用。 +「隐藏 AI 历史」= 仅 transcript 裁剪,**绝不**从主 feed 删掉或折叠创作过程消息。 + +### Run 压缩(与创作正交) + +```text +用户在 run 验收点接受产物 + → 终产物 tag 标记 final + → 过程 tag(worker 答复、*.草稿 等)归档 + → 写入 上下文.定稿摘要 + → 下一 worker 按契约重装时带上摘要与终产物 +``` + +实现:`src/runtime/compress-after-worker.ts`。 +禁止把完整过程讨论继续喂给下一 **run** worker。 + +--- + +## 7.3 Run 验收点(创作时必须设计进 Worker 集) + +**不是**「整条 run 有没有验收」的二选一,也不是创作阶段的特例。 + +**是:** 规定 **跑完哪个 worker 之后必须停下来让用户阅读/验收**;其余可连续调度。 + +| `workers[].acceptance` | 含义 | +|------------------------|------| +| `review` | 本 worker 完成后进入用户验收;接受后才压缩并允许续跑 | +| `continue` | 完成后可接着调下一个,不打断用户 | + +**正推示例(写入 rationale / 复述给人听):** + +| 意图 | 典型停点 | +|------|----------| +| 自主长篇 | 大纲 / 主题 worker → `review`;正文链多段 → `continue`,直到用户叫停或大纲段落再 `review` | +| 交互 / RP | 产出用户可见终稿的转述(如 narrator)→ `review`;世界裁决等中间层 → `continue` | + +创作期按 **§7.2 创作单位** 分块验收(worker 与固定上下文同级),与上表正交。 +design-intake / 创作调度必须为 **每个** run worker 写出 `acceptance`;缺省时不得假设「全都不用验收」——面向用户的可读输出默认倾向 `review`,纯中间层倾向 `continue`,吃不准就 ask_user。 + +--- + +## 7.4 开局 · 开场白(opening-generator) + +创作末尾、Worker 集已 accept 之后。此时 **世界设定、故事设定、表结构通常已经很详细**。 + +**主产物是开场白**(玩家迈进世界的第一段可读文本),不是填表。 + +```text +结合前面的世界 + 故事 + 表结构 → 写开场白 +初值表 = 与开场同一真相的状态快照(辅) +能从已有设定/用户话推出的字段 → 直接填并对齐开场,别问卷 +只有开场必须成立却推不出的点 → 轻量 ask_user +``` + +例:已有校园末日设定 + 用户「普通大学生」→ 开场写宿舍/校园第一拍;表里年龄/资产/身份自然对齐;**不要**先发一张年龄资产问卷。 + +产出:`输出.开场白`(主)+ `运行.初始变量` / `变量.当前`(辅,字段格)。 +须用户验收。实现:`workers/opening-generator/SKILL.md`。 + +--- + +## 8. Play / Run 边界(给总管;运行时按声明执行) + +- 只 `run_worker` 声明内的 ref +- 遇 `acceptance: review` → 停、给人看、接受后压缩再继续 +- 遇 `acceptance: continue` → 可在 burst 内续调下一 worker(受 burst 上限约束) +- 表维护 / 副作用可在终稿展示之后延后;写表须尊重字段 `rev` / `source=user` +- 开局类 skill 仅创作末尾可选,禁止当 run 每轮技能 diff --git a/docs/implementation-guide.md b/docs/implementation-guide.md index ade0759..c9cc3d3 100644 --- a/docs/implementation-guide.md +++ b/docs/implementation-guide.md @@ -16,28 +16,57 @@ ## 2. 文档地图 -| 文档 | 管什么 | -|---|---| -| `tag-blackboard.md` | **★ 主规格**:标签黑板、skill 分工 | -| `context-assembly.md` | **★ 上下文拼接**:上半固定、下半动态 | -| `architecture.md` | 总览、agent tool loop、模块边界 | -| `runtime-state-machine.md` | 5 相位、tool 边界、burst | -| `tool-contracts.md` | 总管 / worker tool | -| `orchestrator-skill-format.md` | manifest 写法(非编排表) | -| `worker-skill-format.md` | SKILL.md、contextSegments | -| `skill-format.md` | 包存储、registry | -| `skill-design-guide.md` | 新包设计方法 | -| `creation-playbook.md` | 创作流程概念 | -| `book-storage.md` | Book、CardAsset、PlayBook、过程存储 | -| `run-snapshot.md` | 手动存档 | -| `preset-format.md` | 预设导入 | -| `implementation-guide.md` | 本文件:文件顺序与职责 | +| 层级 | 文档 | 管什么 | +|---|---|---| +| **系统架构** | **`architecture.md`** | 模块边界、定义/实例、调度、上下文、持久化原则、风险 | +| 运行内核 | `runtime-state-machine.md` | 5 相位、tool 边界、burst | +| 运行内核 | `tool-contracts.md` | 总管 / worker tool | +| 运行内核 | `context-assembly.md` | 上下文拼接:上半固定、下半动态 | +| 运行内核 | `tag-blackboard.md` | 标签黑板 | +| 持久化 | `book-storage.md` | Book、CardAsset、PlayBook、过程存储 | +| 持久化 | `run-snapshot.md` | 手动存档 | +| UI | `ui-design.md` | 工作台布局、检查器、composer | +| UI | **`ui-glossary.md`** | 用户可见中文口径;禁止裸露内部 id | +| 外部参考 | `references.md` | 按层借鉴的 GitHub 仓库 | +| 本文件 | `implementation-guide.md` | 文件顺序与职责;在现有内核上补齐 | +| **产品体验路线** | **`px-roadmap.md`** | PX0–PX5 交付、DoD、明确不做 | +| **日常 → P0** | **`daily-use-p0.md`** | 日常场景、功能映射、P0 详细工作包 | +| **剧本 / 方法(非系统架构)** | `design-orchestrator-guide.md` | 创作三大步、表/副作用、自检 | +| **剧本 / 方法(非系统架构)** | `creation-playbook.md` | 创作流程概念(指向指导) | +| **Skill 格式(非系统架构)** | `orchestrator-skill-format.md` | manifest 写法(非编排表) | +| **Skill 格式(非系统架构)** | `worker-skill-format.md` | SKILL.md、contextSegments | +| **Skill 格式(非系统架构)** | `skill-format.md` | 包存储、registry | +| **Skill 格式(非系统架构)** | `skill-design-guide.md` | 包格式薄层 | +| **Skill 格式(非系统架构)** | `preset-format.md` | 预设导入 | + +许可证占位:仓库根 `THIRD_PARTY_NOTICES.md`。 --- -## 3. 实现分期 +## 3. 在现有内核上补齐(当前优先) -### Phase A0 — 可运行阶段机(当前优先) +相位机、Agent tool loop、Skill loader、Worker 声明、上下文拼装、表 rev、Web UI、快照 API **已具备**。 +**不要**按绿场「领域库 → Mastra → …」重开;增量挂在现有 `phase-runtime` / Book 存储上。系统边界见 `architecture.md` §11。 + +**权威执行顺序与验收 DoD → [`px-roadmap.md`](./px-roadmap.md)**(当前焦点 **PX0**)。 +**日常场景与 P0 工作包 → [`daily-use-p0.md`](./daily-use-p0.md)**。 + +| 路线阶段 | 目标 | 备注 | +|----------|------|------| +| **PX0(当前)** | 导演 UI + 双线主路径 + 存档硬稳定 | 详见 `daily-use-p0.md` / `px-roadmap.md` | +| PX1–PX2 | 创作/游玩体验打磨;副作用 | UI 仍可抄 | +| PX3 | Trace、预算、规格版本、E2E | — | +| PX4 | 长文加深(P0 已含最小闭环) | — | +| PX5 | 隔离 + 调试;必要时新导演包 | — | +| 延后 | SQLite / 事件溯源 / Mastra·Next | — | + +历史分期 A0–E 见下节(多数已 done,作文件职责索引,不再当作「当前从零启动」路线)。 + +--- + +## 3b. 历史实现分期(索引) + +### Phase A0 — 可运行阶段机 目标:**不依赖 LLM**,阶段机整体可运行、可手动驱动、可脚本跑通最小闭环。 diff --git a/docs/orchestrator-skill-format.md b/docs/orchestrator-skill-format.md index 7f30a54..668dc42 100644 --- a/docs/orchestrator-skill-format.md +++ b/docs/orchestrator-skill-format.md @@ -1,37 +1,41 @@ # 总管 Skill 格式(Orchestrator / Manifest) +> **文档层级:Skill 包 manifest 格式(非系统架构)。** +> 总管与相位机边界见 [`architecture.md`](./architecture.md)、[`runtime-state-machine.md`](./runtime-state-machine.md)。 + ## 1. 定位 -**orchestrator.md** = 本包的 **manifest**:注册有哪些 skill、如何验收、如何声明 instance ready。 -**不**写逐步流水线剧本;**不**写「总管思维链」逐步调度。 +**orchestrator.md** = 本包的 **manifest**:注册有哪些 capability、如何验收、何时可进 play。 +**不**写逐步流水线剧本。 +**创作方法**见 **`design-orchestrator-guide.md`**(三大步、表、自检)。 ```text -用户选包 - → design:agent 按需 invoke instantiate skill - → declare ready → play:agent invoke run skill - → done:归档 Book +新建作品 → 默认包 + → design:design-intake → 设计.worker集 JSON(实例声明) + → 用户验收 → 用户手动进 play + → play:Agent 按 Worker 声明 invoke worker ``` | 谁决定 | 什么 | |--------|------| -| **Agent** | 何时 invoke 哪个 skill(tool loop) | -| **Manifest** | 可用 skill 列表、验收策略、readiness 规则 | -| **Worker SKILL.md** | inputTags、contextSegments、怎么做 | +| **Agent** | 何时 invoke 哪个 worker id(tool loop) | +| **Manifest** | design worker 列表、验收策略、readiness | +| **Worker 集** | 本实例启哪些 worker、表、常驻上下文、副作用 | +| **templates** | design-intake 合并用的可选默认契约(非运行时权威) | -`shared-context.md`:包级 **固定上下文上半**,注入各 worker prompt。总管不读。 - -见 `architecture.md`、`skill-design-guide.md`、`context-assembly.md`。 +见 `architecture.md`、`creation-playbook.md`、`design-orchestrator-guide.md`。 --- ## 2. 存储位置 ```text -skills/novel/weird-rules-short/ +skills/dialogue/world-simulator/ ├── orchestrator.md ├── shared-context.md # 可选 +├── worker-templates/ # 可选 ref 模板(design 缺省) └── workers/ - └── write-rules/SKILL.md + └── design-intake/SKILL.md ``` - 文件固定名 **`orchestrator.md`**。 @@ -43,142 +47,74 @@ skills/novel/weird-rules-short/ ```yaml --- -name: weird-rules-short -description: >- - 何时选用:用户要写规则怪谈、守则类短篇。 -category: novel -bookKind: novel +name: world-simulator +description: > + 默认能力库:Worker 集即实例声明… +category: dialogue +bookKind: dialogue workers: - - write-rules - - review-infer - - review-author -sharedContext: shared-context.md + - design-intake +demandTag: 用户.需求 +startupMode: agent-first +uiPrompt: | + … --- ``` -| 字段 | 用途 | -|------|------| -| `description` | 何时选本包 | -| `workers` | run / design 可调度 skill id 白名单(loader 用) | -| `bookKind` | Book 存储形态 | - --- -## 4. 正文章节(推荐) - -```markdown -# 标题 - -## 启动询问 # 最小 intake -## Skill 注册表 # instantiate + run skill 清单与说明(见 §5) -## 验收策略 # 哪些 skill 产出需 user_confirmed / approve -## Instance Ready # 按 run_skill清单 的动态最低可行性 -## contextProfile # 可选 variant 说明(见 context-assembly.md) -## 用户回合 # user-turn 等(可选) -## 禁用行为 -``` - -**不应出现:** - -- 逐步 **Worker 编排表** / **总管思维链**(已废弃为主流程) -- `inputTags` / `outputTags` 列表 → 在 `workers/*/SKILL.md` -- 写作细则 → Worker SKILL 正文 - ---- - -## 5. Skill 注册表(替代编排表) - -列出本包 **能力库**,供 agent `list_workers` 与 manifest 校验: +## 4. Manifest 正文应有的节 ```markdown ## Skill 注册表 - -### Instantiate(design stage) - -| id | 说明 | 典型 outputTags | -|----|------|-----------------| -| interaction-paradigm | 定交互范式与 run_skill清单 | 设计.run_skill清单 | -| world-blueprint | 世界背景 | 设计.世界.蓝图 | -| persona-draft | 角色卡草稿 | 角色卡.草稿 | - -### Run(play stage) - -| id | 说明 | 默认 acceptance | -|----|------|-----------------| -| write-rules | 写规则 | user_confirmed | -| review-infer | 读者视角验收 | programmatic_review | +## Instance Ready / 进入游玩 +## 验收策略 +## 总管优先行为 +## 禁用行为 ``` -agent **按需 invoke**,不必按表顺序跑全。 +**不应包含:** 「第 N 步必须跑某 worker」管道剧本。 +方法细节指向 `design-orchestrator-guide.md`,勿在 manifest 重复长文。 --- -## 6. 启动询问与 Instance Ready +## 5. Skill 注册表 -**启动询问:** 只收集 agent 无法从空推断的最小信息 → `用户.需求` 等。 +### Design -**Instance Ready:** 不写死「14 步全完成」,而写: +| id | 说明 | +|----|------| +| design-intake | 产出 设计.worker集 JSON | +| (可选)开局赋初值 | 创作末尾可选,非每轮 | + +### Play + +不在 manifest 写死清单。Agent 只调度 **`设计.worker集` 声明内的 ref**。 + +--- + +## 6. Instance Ready ```markdown -## Instance Ready - -当 `设计.run_skill清单` 已 accepted,且清单中每个 run skill 的 -**最低 input 要求**(见各 SKILL.md)已在黑板存在或已记录跳过理由。 -由 agent `declare_instance_ready` + Runtime 校验。 +当 `设计.worker集` 已 accepted;进 play 由用户手动决定。 ``` --- ## 7. 验收策略 -```markdown -## 验收策略 - | skill | requiresApproval | acceptanceMode | |-------|------------------|----------------| -| setup-scenario | true | user_confirmed | -| world-engine | false | no_confirmation | -| present-round | false | user_confirmed | -``` +| design-intake | true | user_confirmed | -agent 通过 `run_worker(..., requiresApproval)` 触发;默认值也可写在 manifest 供 Runtime 填充。 +play:**无**每轮强制验收;用户新输入 = 认可上轮终稿;重 roll 替代 reject。 --- -## 8. contextProfile - -若同一 skill 有多种游玩/创作模式,在 manifest 列出 variant 名与含义;实例化写入 Book。 -格式见 `context-assembly.md`。 - ---- - -## 9. 与 Worker SKILL 的关系 +## 8. 与 Worker 声明的关系 ```text -orchestrator.md 注册 + 验收 + readiness -workers/*/SKILL.md 能力 + 上下文契约(contextSegments) +accept 设计.worker集 → Runtime 构建 InstanceWorkerDeclaration +play:仅允许声明内 activeWorkerIds +表维护 / 副作用按规格边沿触发(见 design-orchestrator-guide §5) ``` - -Prompt 注入: - -```text -Agent:session 摘要 + tool;可用 read_blackboard -Worker:shared-context + SKILL + assembleWorkerContext(上固定下动态) -``` - ---- - -## 10. 迁移说明 - -旧包中的 `## Worker 编排`、`## 总管思维链` 仍可作为 **人工参考**,但新包与 world-simulator **不应**以此为主流程。 -Runtime 目标态以 agent tool loop + manifest 注册表为准。 - ---- - -## 11. 相关文档 - -| 文档 | 关系 | -|------|------| -| `skill-design-guide.md` | 设计方法 | -| `worker-skill-format.md` | SKILL.md 字段 | -| `creation-playbook.md` | 概念 | diff --git a/docs/px-roadmap.md b/docs/px-roadmap.md new file mode 100644 index 0000000..f6e68a1 --- /dev/null +++ b/docs/px-roadmap.md @@ -0,0 +1,195 @@ +# PX 路线图(产品体验与交付) + +## 0. 定位 + +当前仓库的**执行计划表**。系统边界见 [`architecture.md`](./architecture.md);日常场景与 P0 工作包见 [`daily-use-p0.md`](./daily-use-p0.md);文件级索引见 [`implementation-guide.md`](./implementation-guide.md)。 + +**不**绿场重写、**不**近端迁 Mastra/Next/SQLite。挂在现有相位机 + Book/快照 + 声明驱动 Worker 上增量交付。 + +### 0.1 已锁定产品取向 + +| 项 | 决定 | +|----|------| +| 日常玩法 | **AIRP/交互** + **长文/爽文**(同一内核) | +| 导演 Skill | 新建时 **UI 选择**能力包;初期可只有 1 项(默认选中) | +| 剧本层 | **动态**:听用户说话谈 Worker 集 + tool loop;不做死板剧本选单/DAG | +| UI | **抄参考**即可,不自研美学 | +| 硬指标 | **存档 / 续作稳定** | +| 多导演 | 仅当方法边界真不同时再加包;爽文≠新导演 | + +### 0.2 成功标尺 + +| 级别 | 含义 | +|------|------| +| **L1 可演示** | 带路能跑通一轮 | +| **L2 可自助** | 选导演 → 创作 → 玩/写,少踩坑 | +| **L3 可日常** | 双线存档续作可靠;少死循环 | +| **L4 可扩展** | 新导演包或隔离模式不改内核边界 | + +--- + +## 1. 总计划表(主表) + +| 阶段 | 名称 | 目标标尺 | 核心交付 | 状态 | +|------|------|----------|----------|------| +| **PX0** | 双线可日常最小集 | L2→摸 L3 | 导演选择 UI;线 A 扮演 + 线 B 长文/爽文主路径;**存档硬稳定**;失败出口;UI 只抄缺口 | **进行中(核心已落地)** | +| **PX1** | 创作体验 | L3 design | 规格可读可改、进度可见、表/黑板可手改、instance 改规格 | **进行中(定稿存读+黑板改+进度面板已有)** | +| **PX2** | 游玩/写作体验 | L3 play | 阅读主舞台(抄)、状态抽屉、重 roll 顺手、副作用调度、多 run 线打磨 | 未开始 | +| **PX3** | 运行可信 | L3 加固 | Context Trace、调度预算、规格钉版本、mock E2E | 未开始 | +| **PX4** | 长文加深 | L4 | 章产物/多卷、局部评测、更好的长文阅读面(P0 已含最小长文) | 未开始 | +| **PX5** | 隔离 + 调试 | L4 | 私有上下文、泄漏测试;Trace/成本/分支调试台 | 未开始 | + +```text +现在 → PX0(存档+双线+导演UI) + → PX1(创作好改) + → PX2(玩/写好用) + → PX3(可信可测) + → PX4 / PX5(按需,可调序) +``` + +顺序原则:先 **PX0 双线硬路径**,再体验(1–2),再加固(3),再扩展(4–5)。 +若 PX0–2 出现腐档/死循环,允许提前插入 PX3 对应子项。 + +预估(单人全职约):PX0 1.5–2 周;PX1/PX2 各 2–3 周;PX3 2–3 周;PX4/PX5 各 3–5 周。 + +--- + +## 2. PX0 — 双线可日常最小集(当前) + +**权威细项**:[`daily-use-p0.md`](./daily-use-p0.md) §3。 + +### 2.1 计划表(工作包) + +| 序 | 工作包 | 内容 | 验收要点 | +|----|--------|------|----------| +| 1 | **WP-S** 存档稳定 | 续作相位正确;手动存/列表/加载;正文与关键 tag 不丢 | 线 A、B 各存读一轮,不手改磁盘 | +| 2 | **WP-D** 导演选择 | 新建作品 UI 选导演;registry 列表;仅 1 项时默认选中 | 能选中并进入该包创作 | +| 3 | **WP-A** 进出与失败 | glossary 底线;`waiting_user` 有主按钮;LLM/worker 失败可重试 | 无死胡同、无裸 id 挡路 | +| 4 | **WP-R** 线 A | 扮演 ≥3 回合不卡 | A-GP1–5 | +| 5 | **WP-L** 线 B | 引导+template 能谈成写手规格;≥2 段正文;续作不丢文 | B-GP1–5 | +| 6 | **WP-U** UI 抄补 | 仅补存档/确认/选导演等缺口控件 | 不按 ui-design 大重构 | +| 7 | **WP-E** 闸门 | 双线冷启动+续作验收;勾选 DoD | 全绿才出 PX0 | + +### 2.2 黄金路径(摘要) + +- **线 A**:选导演 → 扮演意图 → 验收 → ≥3 回合 → 存档 → 续作/读档 +- **线 B**:选导演 → 爽文/长篇意图 → 验收写手规格 → ≥2 段正文 → 存档含正文 → 续作/读档 + +### 2.3 非范围 + +死板剧本选单;自研精美 UI;Trace;隔离;完整副作用;多导演商店(列表可扩展,P0 不追求多包运营)。 + +### 2.4 DoD + +- [ ] WP-S~E 完成 +- [ ] 线 A、线 B 黄金路径各冷启动 + 续作通过 +- [ ] 导演选择 UI 可用(可仅一项) +- [ ] 更新本文状态列 + `architecture.md` §13 + +### 2.5 挂载 + +`web/`(新建选导演、saves)、`session-manager`、`skill-catalog` / registry、`phase-runtime`、引导与 `worker-templates` + +--- + +## 3. PX1 — 创作体验 + +| 项 | 内容 | DoD | +|----|------|-----| +| 规格工作台 | 产物为主舞台(可抄 Notion/PR 式验收) | 不看文档能验完 Worker 集进 play | +| 进度 | 创作单位已验收/进行中可见 | — | +| 表 | 字段可编辑 + rev 不覆盖手改 | 手改后 worker 写入可解释 | +| 规格回退 | load instance 再改 | 至少一条路径可用 | + +挂载:`worker-set-view`、`creation-units`、questions UI、表编辑 API。 + +--- + +## 4. PX2 — 游玩 / 写作体验 + +| 项 | 内容 | DoD | +|----|------|-----| +| 阅读面 | 终稿/章文主视线(抄 ST/阅读器) | ≥10 回合或等价连续写段不卡 | +| 重 roll | 发现入口;与快照语义一致 | 存/读/重 roll 各至少一次 | +| 状态 | 抽屉看表/变量 | — | +| 副作用 | `side_effects` 边沿完整可观测 | fixture:once / changed | +| 多线 | 同开局新开 play 线 | 不串 run 状态 | + +挂载:`table-side-effects`、`phase-runtime`、play UI、`run-snapshot`。 + +--- + +## 5. PX3 — 运行可信 + +| 子项 | DoD | +|------|-----| +| Context Trace | 失败调用能看出装载了什么 | +| 调度预算 | 重复/嵌套/Token 触顶可读停止 | +| 规格钉版本 | 改规格不污染未迁移旧 run | +| mock E2E | 双线+存档脚本可进 CI | + +--- + +## 6. PX4 — 长文加深(P0 之后) + +P0 已要求最小长文/爽文闭环。本阶段加深: + +- 章/卷 Artifact 更清晰;多段目录 +- 简单质量检查(程序优先) +- 更好的长文阅读/对照面(仍可抄,可不上 Monaco) + +**不做**:固定小说 DAG;为长文另起调度内核。 + +--- + +## 7. PX5 — 隔离模拟 + 专业调试 + +| 块 | 内容 | +|----|------| +| 隔离 | 私有上下文、公私事件、泄漏测试(依 Trace) | +| 调试 | 调度时间线、Trace 检查器、Token/成本、分支树 | + +若隔离需要**不同方法边界**,以**新导演包**形式加入(UI 多一项),仍保持包内剧本动态。 + +--- + +## 8. 明确不做(周期内) + +- Mastra / LangGraph 换相位机;Next 重写前端 +- 完整事件溯源 + SQLite 近端必达 +- 题材固定 instantiate 管道;死板剧本选单 +- 把创作方法长文塞回系统架构文 + +--- + +## 9. 里程碑 + +| 里程碑 | 退出 | 产出 | +|--------|------|------| +| **M0** | PX0 DoD | 双线验收记录;导演 UI;存档稳定 | +| **M1** | PX1+PX2 DoD | 内部「可日常」说明 | +| **M2** | PX3 DoD | Trace/预算/E2E 绿灯 | +| **M3** | PX4 或 PX5 | 长文加深或隔离/调试演示 | + +--- + +## 10. 文档 / 代码映射 + +| 阶段 | 文档 | 代码 | +|------|------|------| +| PX0 | `daily-use-p0.md`、`ui-glossary.md` | `web/`、session、saves、catalog、templates | +| PX1 | `ui-design.md`(可抄着落地)、design-guide | worker-set、creation-units | +| PX2 | `run-snapshot.md`、tag-blackboard | side-effects、phase-runtime | +| PX3 | `context-assembly.md`、architecture | assemble、budgets、tests | +| PX4 | Skill 薄层 | artifacts、长文面 | +| PX5 | 隔离短文(待写) | Trace 强制、debug、stats | + +--- + +## 11. 下一行动(立即) + +1. **手工跑双线黄金路径**(线 A ≥3 回合;线 B ≥2 段;存读档)验收 PX0 DoD +2. 表字段格级编辑器(当前是黑板 tag 预览级手改)可继续打磨 +3. 再开 PX2(阅读面 / 副作用 / 多 run) + +**当前**:PX0/PX1 主代码已落地,待你本地用真实 LLM 走一遍确认「能用」。 diff --git a/docs/references.md b/docs/references.md new file mode 100644 index 0000000..960ed7b --- /dev/null +++ b/docs/references.md @@ -0,0 +1,88 @@ +# 外部项目参考(按层借鉴) + +以下仓库用于架构与实现参考。**只借鉴标明的一层,不把外部项目整体耦合进本仓库实现。** + +本系统运行内核为自研(相位机 + tool loop + 声明驱动 Worker)。Mastra、Next.js、assistant-ui 等**不是**近端迁移目标;若引入,须经适配层,且领域对象不得保存框架内部类型。 + +许可证与再分发注意见仓库根 [`THIRD_PARTY_NOTICES.md`](../THIRD_PARTY_NOTICES.md)。 + +--- + +## 参考原则 + +1. 按模块读代码与文档,禁止整仓复制粘贴为业务内核。 +2. 借鉴交互/产品概念时,数据模型仍用本仓库 Book / Session / 黑板。 +3. Prompt 拼接、权限、上下文编译由本仓库 Context Compiler 实现,不照搬他站拼接逻辑。 +4. 依赖使用 lockfile 精确版本;对外部项目的修改放在适配层。 + +--- + +## 仓库与借鉴层 + +### Mastra + +- 仓库: +- **可借鉴**:Agent/Tool 声明方式、模型 Provider、结构化输出、Memory/Storage 接口形态、Workflow 适用边界、Observability、TypeScript 组织 +- **不借鉴为内核**:不把动态调度、上下文权限、Definition/Instance、事件存档交给 Mastra 领域模型;本仓库已有 phase-runtime + +### assistant-ui + +- 仓库: +- **可借鉴**:流式聊天、消息组件、Tool 调用展示、会话状态、人工确认、前后端连接方式 +- **不负责**:Definition、存档、多 Agent 调度模型(仍由本仓库 API + Runtime) + +### LobeChat + +- 仓库: +- **可借鉴**:本地 AI 应用信息架构、会话列表、模型配置 UI、助手/角色配置、插件与知识库管理体验、布局 +- **不直接采用**:其会话数据模型 + +### SillyTavern + +- 仓库: +- **可借鉴**:角色卡产品概念、世界书编辑体验、聊天存档与分支心流、角色扮演工作方式、导入导出与兼容性概念 +- **不照搬**:Prompt 拼接逻辑;世界书检索与权限由 Context Compiler 实现 + +### LangGraph.js + +- 仓库: +- **可借鉴**:有状态 Runtime、Checkpoint、中断与恢复、Human-in-the-loop、长时间任务可恢复执行 +- **不采用**:用固定 Graph 表达整场创作流程(本仓库用相位机 + 动态 tool loop) + +### AutoGen + +- 仓库: +- **可借鉴**:多 Agent 消息与生命周期、Agent 选择、事件驱动运行、Tool 执行边界、Runtime 抽象 +- **不采用**:Agent 间自由转发消息作为权限模型;上下文不得依赖自由转发 + +### Letta + +- 仓库: +- **可借鉴**:长期记忆分层、上下文窗口管理、状态与消息历史区分 +- **仍需自建**:小说、世界书、角色私有状态等领域模型 + +### OpenHands + +- 仓库: +- **可借鉴**:运行会话、事件流、Action/Observation、前后端状态同步、暂停恢复与可观测性 +- **不参考**:软件工程沙箱任务模型 + +### CopilotKit + +- 仓库: +- **可借鉴**:Agent 与前端状态连接、流式事件、Human-in-the-loop、生成式 UI、动态表与确认交互呈现 +- **不替代**:本仓库 Book/黑板领域模型 + +--- + +## 与本仓库模块的映射 + +| 本仓库模块 | 主要可参考项目 | +|------------|----------------| +| Runtime / 调度 / 恢复 | LangGraph.js、AutoGen、OpenHands、Mastra(边界) | +| 上下文与记忆 | Letta、Mastra Memory 接口形态 | +| 聊天与 HITL UI | assistant-ui、CopilotKit、LobeChat | +| 角色卡 / 世界书 / 存档体验 | SillyTavern、LobeChat | +| 模型接入与可观测 | Mastra | + +系统架构总览见 [`architecture.md`](./architecture.md)。 diff --git a/docs/run-snapshot.md b/docs/run-snapshot.md index a502718..92a37c6 100644 --- a/docs/run-snapshot.md +++ b/docs/run-snapshot.md @@ -2,52 +2,92 @@ 用户手动保存、多档位;与自动续作 `session.json` 分离。 -## 两种 kind(更新语义) +**编辑 / 重 roll 的统一做法:恢复到之前的快照,再从该点继续。** +创作(design)与运行(play)共用此语义。 + +```text +存快照 → 继续生成 → 不满意 → 加载 earlier 快照 → 重跑后续 worker / 重出展示 +``` + +## 三种 kind(更新语义) | kind | 名称 | 何时存 | 存什么 | |------|------|--------|--------| -| **`instance`** | 设计完成 / 资产截面 | design 完成或从 play 剥离设定 | **CardAsset 级** tag(角色卡.确认稿、世界蓝图…);**不含**轮次、事件流 | -| **`run`** | 游玩存档 | play 任意时刻 | instance 层 + `运行.*`、轮次、变量、对话 | +| **`instance`** | 创作定稿截面 | Worker 集 accept;可选含 **锁定开局** | `设计.worker集`、静态设定 tag、`运行.初始变量`、`输出.开场白`;**不含**后续轮次、事件流 | +| **`opening`** | 开局锁定(可选单独存) | 创作末尾 swipe 选定开场后 | 与 instance 中开局部分相同;便于「同 Worker 集、换开局再开 play 线」 | +| **`run`** | 游玩存档 | play 任意时刻 | instance/opening 层 + `运行.*`、轮次、变量当前值、对话 | ```text -CardBook design 完成 → 可存 instance(导出角色卡 / 世界设定) -PlayBook 进行中 → 可存 run(第 N 轮续玩) +Worker 集 accept → 可存 instance(规格截面) +可选开局赋初值 → 可存 instance / opening(非进 play 强制门槛) +play 任意时刻 → 可存 多条 run +改 Worker 集 → load 旧 instance 再改 +改某轮输出 → load 该轮前的 run → 重 roll / 重跑 ``` -**instance** 不再表示「14 步 pipeline 填完」,而是 **agent declare ready 时的可复用资产截面**。详见 `book-storage.md`。 +**instance** = **Worker 集(+ 可选开局截面)**。进 play 由用户手动决定。见 `book-storage.md`、`design-orchestrator-guide.md`。 + +### 同一开局 · 多条存档 + +```text +opening-generator 锁定 checkpoint = 共用起点 + ├─ run 存档 A(第 5 轮) + ├─ run 存档 B(第 12 轮,另一分支) + └─ 「从同一开局新开一条线」= 清空 run 层、保留 opening/instance 层(见 session-manager startNewPlayLine) +``` + +多条 **run** 快照共享同一 **opening**(或含 opening 的 **instance**),无需为每条 play 线复制 Worker 集。 + +## Swipe 与快照 + +创作末尾 **开局** 产出时,可用 message **branch / swipe** 多版本对比(可选)。 +play 对终稿可用 **重 roll**;用户新输入 = 认可上轮终稿。见 `design-orchestrator-guide.md`。 ## 与三种存储需求 | 需求 | 用什么 | |------|--------| -| 创建角色卡**过程** | CardBook `designTrace` + messages(快照不替代,须 Book 级) | -| 创建好的**卡** | CardAsset 或 `instance` 快照 | -| 游玩**过程** | PlayBook `playTrace` + `run` 快照 | +| 创作过程 | Book `designTrace` + messages | +| 实例规格 + 锁定开局 | `instance` / `opening` 快照 | +| 游玩过程 | PlayBook `playTrace` + **`run` 快照(同一开局可多条)** | +| 改设定 / 重 roll | 加载对应 kind 的 earlier 快照 | ## 与自动续作 | | `session.json` | `run-snapshots/` | |--|----------------|------------------| -| 触发 | 自动 | 用户手动 | +| 触发 | 自动 | 用户手动(关键节点建议提示存档) | | 数量 | 每 Book 1 份 | 多档、可命名 | | 打开作品 | 默认恢复 | 选档 **加载** | +| 编辑 | — | **load = 恢复到该档状态** | ## API ```text GET /api/books/:bookId/saves -POST /api/books/:bookId/saves { label, kind: "instance"|"run", note?, sessionId? } +POST /api/books/:bookId/saves { label, kind: "instance"|"opening"|"run", note?, sessionId? } POST /api/books/:bookId/saves/:id/load DELETE /api/books/:bookId/saves/:id ``` +(`opening` kind 可在实现中与 `instance` 合并存储,文档层区分语义即可。) + ## 实现 ```text src/types/run-snapshot.ts src/book/run-snapshot-store.ts -src/book/snapshot-filters.ts instance 时剥离 运行.* 等 +src/book/snapshot-filters.ts src/server/session-manager.ts +src/server/message-branch.ts swipe / branch checkpoint ``` 存储:`books/{bookId}/run-snapshots/{id}.json` + +## 相关 + +| 文档 | 关系 | +|------|------| +| `skill-design-guide.md` §0.4 | Worker 集与快照 | +| `creation-playbook.md` | 创作流中的开局与存档 | +| `context-assembly.md` | 恢复后 worker prompt 仍含 preset + static + dynamic | diff --git a/docs/runtime-state-machine.md b/docs/runtime-state-machine.md index 9bd6acc..10383af 100644 --- a/docs/runtime-state-machine.md +++ b/docs/runtime-state-machine.md @@ -79,8 +79,7 @@ type WaitingReason = ## 6. 典型转移(简化) ```text -idle → skill_selection -skill_selected → intake 或 input +idle → intake(session_started + initialSkill) intake 完成 / confirm → running → agent burst running → run_worker → approve_step 或 worker 执行 worker_completed → review_artifact(user_confirmed) diff --git a/docs/skill-design-guide.md b/docs/skill-design-guide.md index 1d8b129..bb0a505 100644 --- a/docs/skill-design-guide.md +++ b/docs/skill-design-guide.md @@ -1,184 +1,136 @@ -# Skill 设计指南 +# Skill 设计指南(包格式薄层) -指导 **如何从零设计一个 orchestrator 包**(skill 能力库 + run skill 组合)。 -格式见 `skill-format.md`、`orchestrator-skill-format.md`、`worker-skill-format.md`;上下文见 `context-assembly.md`。 +> **文档层级:Skill 包格式薄层(非系统架构)。** +> 运行内核见 [`architecture.md`](./architecture.md);创作方法见下。 -**设计顺序:** +**创作方法(三大步、表、自检、正推)** 的权威文档是: + +→ **[`design-orchestrator-guide.md`](./design-orchestrator-guide.md)** + +本文只保留:**如何在仓库里建包、Worker 集弱结构示例、与旧概念对照**。不要在本文重复长方法文。 + +格式字段见 `orchestrator-skill-format.md`、`worker-skill-format.md`;上下文见 `context-assembly.md`。 + +**设计顺序(概念):** ```text -1. 用户意图 → run skill 清单(交互范式 skill 产出) -2. 每个 run skill 倒推需要哪些 instantiate skill / tag -3. 定义各 skill 的 inputTags、outputTags、contextSegments -4. 上下文隔离与验收边界 -5. orchestrator manifest(能力注册,非逐步剧本) +1. 用户意图 → design-intake(按 design-orchestrator-guide)→ 设计.worker集 JSON +2. 需要时合并 worker-templates 默认契约(仅 design 缺省) +3. 用户验收 → 用户手动进 play +4. play:按声明调度;表维护/副作用边沿触发 ``` --- -## 1. 实例化:agent 选 skill,不是填步骤 +## 0. 设计.worker集 -### 1.1 两层倒推 +### 0.1 定位 -```text -用户意图(长篇 / 短篇 / 思想实验 / 角色卡扮演 …) - → 交互范式 skill:产出 设计.run_skill清单 - → 每个 run skill 需要什么输入? - → agent 按需 invoke instantiate skill(能力库中的 SKILL.md) - → declare_instance_ready → play stage +旧 **交互范式 / run_skill 清单 / 美学纲领** 合并为单一 tag:**`设计.worker集`(JSON)**。 + +| 旧概念 | 新做法 | +|--------|--------| +| `设计.交互范式` | `interaction`(站位、系统扮演、输出与轮转) | +| `设计.run_skill清单` | `workers` + 数据依赖 / 表副作用 | +| `设计.美学纲领` | 转述 presentation **或** 常驻上下文;叙事指南 ≠ 文风 | + +Worker 集不是闭集枚举,也不是逐步管道。方法见 `design-orchestrator-guide.md`。 + +### 0.2 design-intake + +与用户对话,增量更新草稿,accept 后即实例规格。进 play **由用户手动决定**。 + +### 0.3 弱结构示例(JSON) + +```json +{ + "version": 1, + "interaction": { + "user_stance": "单角代入", + "system_role": "世界执行并给出可读叙事", + "output": "输出.用户展示 + 可折叠状态表", + "turn_shape": "对话回合" + }, + "experience_check": { + "user_relation": "…", + "focus": "…", + "satisfaction_source": "…" + }, + "workers": [ + { + "ref": "world-simulator", + "duty": "世界推进与裁决", + "rationale": "删掉则无本轮世界结果", + "acceptance": "continue" + }, + { + "ref": "narrator", + "duty": "组装用户可见回复", + "rationale": "核心产出干巴,需要可读终稿", + "acceptance": "review" + } + ], + "resident_context": [ + { + "id": "tone", + "position": "static", + "content": "文风示例", + "mount": ["narrator"] + } + ], + "tables": { + "schemas": [], + "side_effects": [ + { + "id": "affinity-romance", + "field": "好感", + "op": "gte", + "value": 60, + "mode": "once", + "action": { + "type": "write_tag", + "tag": "上下文.角色态度", + "content": "恋爱模式" + } + } + ] + }, + "narrative_guide": "", + "core_premises": [], + "input_protocol": { + "parens": "() 元要求", + "quotes": "\"\" 角色对白", + "bare": "无包裹视为事实陈述" + }, + "design_end": { + "opening": "optional" + } +} ``` -**能力库**(如 world-simulator 包内十几个 instantiate skill)不是 1→N 管道;agent 跳过不需要的 skill,并记录 `设计.跳过.{skillId}`。 +`presentation` 主要挂转述类;裁决类不挂展示美学。包级共性可写 `shared-context.md`。 -### 1.2 示例:不同意图的 run skill 组合 +### 0.4 编辑与重 roll -| 用户意图 | run skill 示例 | 实例化倒推 | -|----------|----------------|------------| -| 长篇小说 | 变量管理、大纲推荐、转述者、世界机 | 变量目录 skill、叙事指南、世界蓝图… | -| 短篇 | 情感流生成器 | 美学纲领、情感曲线约定 | -| 思想实验 | 世界模拟器 | 规则、行动格式;**无**转述者 | -| 角色卡扮演 | 转述者(+ 可选世界机) | 角色卡确认稿、口吻、回复格式 | - -### 1.3 run 阶段交互形态 - -agent 在 play stage 的 burst 内调度 run skill,形态因包而异: - -| 形态 | 适用 | sketch | -|------|------|--------| -| 单 skill 直出 | 简单大纲 | `outline` | -| 行动–反应循环 | 博弈、世界模拟 | 世界机 ↔ 角色决策 ↔ 展示 | -| 分叉验收 | 规则怪谈 | `write` → 双 `review` | - -形态写在各 skill 的 **能力说明**里,**不**写进全局编排表逐步剧本。 +见 `run-snapshot.md`。 +**创作**:按创作单位验收后删交互留产物。固定上下文相关名称是 **可向用户询问的示例话题**(非填空表);宜先钉纲领类 tag 再写 worker(挂载复用),见 `design-orchestrator-guide.md` §6。 +**Run** 验收点(`acceptance: review`):接受后压缩过程 tag。 +`acceptance: continue` 的中间 worker 可连跑;面向用户的可读停点由 Worker 集声明。 --- -## 2. 暂停与用户 +## 1. 暂停与用户 -### 2.1 边界 tool 即暂停 - -agent tool loop 在下列情况 **退出 burst**,进入 `waiting_user`: - -```text -ask_user / review_blackboard -run_worker + requiresApproval -worker 完成 + user_confirmed → review_artifact -worker ask_user → worker_questions -``` - -暂停策略写在 orchestrator manifest 的 **验收策略**,不是「第几步必须停」的管道表。 - -### 2.2 user-turn - -用户亲自决策的环节:独立 skill,`用户.最新输入` 写入与 LLM 角色同形 tag,供世界机裁决。见 `worker-skill-format.md`。 +边界 tool 即暂停(`ask_user`、`review_artifact` 等)。策略写在 manifest **验收策略**,不是「第几步必须停」。 +Play 默认不对每轮终稿强制 accept。详见 `design-orchestrator-guide.md` §0、§7。 --- -## 3. 上下文:上半固定、下半动态 - -每个 skill(`SKILL.md`)声明: - -```text -inputTags / outputTags 黑板接口 -contextSegments static(上)+ dynamic(下) -contextIsolation 谁看不见什么 -contextProfile variants 实例化时选档位 -``` - -原则:**稳定在上、增量在下**;Runtime 拼接,agent 不改。 -全文见 `docs/context-assembly.md`。 - -### 3.1 分层示例(roleplay-game-theory) - -| tier | 含义 | tag 示例 | -|------|------|----------| -| static | 前提、实体设定 | `情境.实验.设定`、`角色.{id}.设定` | -| dynamic | 历史、本轮 | `运行.事件流`、`可见信息` | - ---- - -## 4. 倒推标签 - -对每个 skill 填表: - -```text -skill id | 职责 | stage(design/play) | inputTags | outputTags | contextSegments | 验收者 -``` - -### 4.1 角色扮演博弈(摘录) - -| skill | 读取 | 写入 | 验收 | -|-------|------|------|------| -| setup-scenario | `用户.博弈需求` | 情境、规则、角色设定 | 用户 | -| world-engine | 情境、规则、事件流、行动 | 可见信息、事件流 | 程序 | -| role-decide | 本人设定、事件流、可见信息 | `.思考`、`.行动` | 程序 | -| present-round | 本轮产物 | `输出.用户展示` | 用户 | - -展示类 skill 单独存在;agent 调度,不拼长文。 - ---- - -## 5. 上下文隔离(必查) - -```text -□ 哪些 tag 只给用户、不注入生产 skill? -□ 盲读 review 不可见哪些 tag? -□ 草稿 vs 确认稿:下游何时可当作事实? -□ 多角色:role_pov 是否生效? -``` - -靠 `inputTags` + `contextIsolation` + Runtime 过滤,不靠 prompt 口头禁止。 - ---- - -## 6. orchestrator manifest(非编排表) - -orchestrator.md 应包含: - -```text -□ 启动询问(最小 intake) -□ instantiate skill 注册表(id、stage、description) -□ run skill 注册表 -□ 验收策略(哪些 skill 产出需 user_confirmed) -□ contextProfile 可选 variant 说明 -□ declare_instance_ready 最低可行性(按 run_skill清单 动态校验) -``` - -**不应包含:** 逐步思维链、「第 5 步必须跑 world-engine」类管道脚本。 - ---- - -## 7. checklist - -```text -□ 1. 一句话:本包 play stage 的交互形态 -□ 2. 交互范式 skill 产出 run_skill清单 的 schema -□ 3. 每个 run skill 倒推 instantiate skill 需求 -□ 4. 各 SKILL.md:input/output、contextSegments、隔离 -□ 5. shared-context.md(static 上半) -□ 6. manifest:注册表 + 验收 + readiness -□ 7. skills/README.md 注册 -``` - ---- - -## 8. 示例包 - -| 包 | 特点 | -|----|------| -| `basic` | 单 run skill,最小上下文 | -| `weird-rules-short` | 分叉验收 | -| `roleplay-game-theory` | 行动–反应循环 | -| `world-simulator`(规划) | 大能力库 + agent 实例化 | - -对照时复用 **方法**,不照搬 tag 名或 skill 数量。 - ---- - -## 9. 相关文档 +## 2. 相关 | 文档 | 关系 | |------|------| -| `context-assembly.md` | 拼接规格 | -| `creation-playbook.md` | 概念 | -| `orchestrator-skill-format.md` | manifest 写法 | -| `book-storage.md` | 过程与资产归档 | +| **design-orchestrator-guide.md** | ★ 方法 | +| creation-playbook.md | 流程概念 | +| orchestrator-skill-format.md | manifest | +| worker-skill-format.md | 磁盘 SKILL(声明驱动演进中) | diff --git a/docs/skill-format.md b/docs/skill-format.md index 72da4f2..15cf7fc 100644 --- a/docs/skill-format.md +++ b/docs/skill-format.md @@ -1,5 +1,8 @@ # Skill 格式与存储 +> **文档层级:Skill 包格式(非系统架构)。** +> 系统级模块与调度边界见 [`architecture.md`](./architecture.md)。 + ## 1. 定位:两层 Skill 本项目有 **两种 Skill 文档**,不要混在一个文件里: @@ -355,72 +358,63 @@ skills: --- -## 5. 会话启动:第一个询问是选 Skill +## 5. 会话启动:描述需求,而非选包 -Skill 选择发生在**任何创作逻辑之前**。 +新建作品 **不再** 让用户输入 skill name 或列表编号。Runtime 自动加载默认 orchestrator(`world-simulator`,见 `src/config/default-orchestrator.ts`),直接进入 intake。 ### 5.1 启动转移 ```text idle - session_started - → waiting_user(skill_selection) + session_started { initialSkill } + → waiting_user(intake) ``` -新增 `waitingReason`: +`session_started` 携带 `initialSkill` 时跳过 `skill_selection`。 +registry 中其它包仅供 `startWithOrchestrator` / 旧作品读档兼容。 + +**Legacy(旧会话恢复):** + +```text +session_started(无 initialSkill) + → waiting_user(skill_selection) # 仅旧快照可能出现 +``` ```ts -| { kind: "skill_selection"; availableSkills: SkillIndexEntry[] } +| { kind: "skill_selection"; availableSkills: SkillIndexEntry[] } // legacy +| { kind: "intake"; prompt: string } ``` ### 5.2 向用户展示 +来自 orchestrator `## 启动询问`,例如 world-simulator: + ```text -请选择创作类型: +请用你自己的话描述想做什么,例如: -【小说】(Book 形态:卷 / 章) - 1. novel-standard — 标准长篇:大纲 → 事件 → 正文 - 2. weird-rules-short — 短篇规则怪谈:核心 → 规则 → 成章 - 3. basic — 最小演示 +- 西幻升级、长篇 AI 交互、世界推着走 +- 部分代入:() 是指令,"" 是角色话,【】 是行动 +- 或:仿写/扩写、规则怪谈、快节奏爽文…… -【对话】(Book 形态:回合 / 多角色) - 4. theater-roleplay — 剧场式角色扮演 - -也可直接描述你想写什么,我会帮你匹配 skill name。 +Agent 会根据你的描述推理需要哪些 Worker,并产出 Worker 集。 ``` ### 5.3 用户回答方式 ```text -输入编号或 name:novel-standard -输入自然语言:我想写一个剧场扮演 -输入自定义:用 novel-standard,但是偏悬疑 +直接描述创作目标(一句话即可) ``` -Runtime 解析为 `skill_selected` 事件: +Runtime 写入 `用户.需求`(或 skill 指定的 startupTargetKey),确认后进入 design stage。 -```ts -{ type: "skill_selected"; payload: { skillId: string; userHint?: string } } -``` - -然后: +### 5.4 两阶段启动(现行) ```text -加载 skills/{skillId}/SKILL.md -解析 ## 启动询问 → session.slots.activeSkill - → waiting_user(input) - message 来自 SKILL.md「启动询问·向用户展示」 - 必收集项 / 写入目标 同样来自该节 +阶段 1(系统) 自动绑定默认 orchestrator +阶段 2(intake) 启动询问 → 用户描述需求 → confirm → agent burst ``` -### 5.4 两阶段启动 - -```text -询问 1(系统) skill_selection 「用哪个 skill?」→ registry / description -询问 2(skill) input 「启动询问」章节 → 每类内容问的不同 -``` - -**只有询问 1 是系统固定的。询问 2 及之后所有创作逻辑,都在 SKILL.md 里。** +**Agent 根据需求推理 worker 集**(`design-intake`),不再让用户在 registry 里四选一。 --- @@ -495,10 +489,10 @@ Creation Playbook(概念) ## 11. 第一版范围 ```text -skills/ 目录 + 2 个示例 SKILL.md(novel、theater) -启动 → skill_selection → 用户选择 → 加载 skill +skills/ 目录 + 示例 orchestrator 包 +启动 → 自动绑定默认 orchestrator → intake(描述需求) 总管 prompt 注入 skill 摘要 -registry.yaml 可选 +registry.yaml 可选(legacy 包 / 读档兼容) ``` 不做: diff --git a/docs/tag-blackboard.md b/docs/tag-blackboard.md index d59edf1..ca9c24e 100644 --- a/docs/tag-blackboard.md +++ b/docs/tag-blackboard.md @@ -2,572 +2,67 @@ ## 1. 定位 -本系统用于多 worker 协作式文本生成,覆盖: - -```text -简易小说快速撰写(quick-write) -规则怪谈 / 短篇结构化创作(weird-rules-short 等) -长篇小说 / 交互式写作助手(interactive-novel,TODO) -场景扮演模拟(scene-roleplay,TODO) -角色扮演 / 角色卡互动(并入 scene-roleplay 的 instantiate + run,见 §2) -``` - -核心思想: +多 worker 协作式文本生成;**默认能力库包名 = `world-simulator`**(创作方法见 `design-orchestrator-guide.md`,不以「世界模拟」为默认总形态)。 ```text 黑板 = 标签化数据池 -标签 = skill 之间的接口 -skill = SKILL.md 定义的能力;worker = 一次 invoke -Agent = tool loop 内调度 invoke 哪个 skill -Runtime = 按 skill 契约拼接上下文(上半固定、下半动态) +标签 = worker 之间的接口 +设计.worker集 JSON / 声明 = 读哪些 tag、写哪些 tag、表与常驻上下文 +Agent = tool loop 内调度 invoke 哪个 id +Runtime = 按声明拼接上下文;表字段 rev 合并 ``` -一句话:**标签驱动 + agent 选 skill + Runtime 拼上下文**——不是固定流水线,也不是 LLM 自由分发上下文。 +## 2. Skill 包、Worker 声明与 Book -上下文拼接见 `docs/context-assembly.md`。不要让 agent 临场改 inputTags;分发由 `inputTags` + `contextSegments` 静态声明。 +```text +Skill 包 能力库 + design-intake + 可选 templates +设计.worker集(accepted,JSON) 本实例 Worker 声明(play 权威) +Session + 黑板 一次运行的实参 +Book 过程、存档、资产(book-storage.md) +``` + +### 业务 stage + +```text +design(创作:核心→细节交互→细化)→ 用户验收 → 用户手动进 play → done +运行相位:idle | running | waiting_user | done | error +``` + +创作方法权威:`design-orchestrator-guide.md`。 + +### 创作 + +`design-intake` → accept **`设计.worker集` JSON** → 用户手动进 play(可选开局 · 开场白,非强制锁定)。 +无独立 instantiate 管道。见 `design-orchestrator-guide.md`。 + +### Play + +Agent 只能 `run_worker` **声明中的 ref**;执行契约来自 Worker 集条目(可选合并 `worker-templates/`)。 --- -## 2. Skill 定义、实例化与 Book - -### 2.1 类比 +## 3. 核心 tag(world-simulator) ```text -Skill 包(orchestrator manifest + workers) = 能力定义(静态) -Session + 黑板 tag = 一次运行的实参 -Book = 过程、资产、游玩(见 book-storage.md) -play stage = agent invoke run skill +用户.需求 / 用户.最新输入 / 用户.修订说明 +设计.worker集 / 设计.worker集.草稿 +创作.当前单位 / 创作.已验收单位 / 创作.已验收内容 / 创作.对话 +运行.初始变量 / 运行.事件流 / 运行.本轮.* +变量.当前 +输出.用户展示 / 输出.开场白 ``` -**写角色卡、收集设定、启动询问** 都不是独立「产品模式」,而是 **实例化阶段** 的不同形态:把 prerequisite tags 写满,然后才进入运行阶段。 +固定上下文(叙事指南、美学纲领等)在实例规格里落地,play 时经 `resident_context.mount` / `contextSegments` **挂到多个 worker**——这是 tag 化的主收益,不是省掉创作期依赖正文。 -### 2.2 三层阶段(勿混淆) - -```text -运行相位(RuntimePhase) - idle | running | waiting_user | done | error - 系统在等什么。见 runtime-state-machine.md - -业务 stage(Book / Session) - design(实例化)→ play(运行)→ done - -tag 阶段(黑板条目 tag 名中的段) - 候选 | 草稿 | 确认稿 | 当前 | 更新 - 单条数据的 lifecycle -``` - -业务 stage **写在各 skill 的 orchestrator.md**,不扩运行相位 enum。 - -### 2.3 实例化(design) - -**职责:** agent 按需 invoke instantiate skill,沉淀 `设计.*` tag;产出 `设计.run_skill清单` 后 declare ready。 - -```text -选 orchestrator 包 - → design stage:启动询问 → agent invoke instantiate skill(能力库) - → declare_instance_ready - → play stage:agent invoke run skill - → done → 归档 Book(过程 + 资产,见 book-storage.md) -``` - -可从 Book / CardAsset 加载已有 tag,跳过部分 instantiate skill。 - -运行相位上,实例化阶段多为 `waiting_user(input)`(启动询问、总管 ask_user);实例化也可调用 **setup worker**,但仍是 worker(固定 inputTags/outputTags),不是第二个生产总管。 - -### 2.4 每个 skill 声明什么 - -在 orchestrator.md 中写清(见 orchestrator-skill-format.md): - -```text -## 启动询问 / ## 实例化 - prerequisiteTags:本 skill 运行前必须有的 tag - instanceReadyWhen:何时可进入 run stage(文字条件 + tag 列表) - 写入目标 tag(可多个;不必再塞进单一 book.brief) - -## 阶段定义 - instantiate(或沿用 stageId brief)→ run(write / review …)→ done - -## Worker 编排 - 仅 run stage 及之后调度生产 worker;instantiate 阶段只 ask_user 或 run setup worker -``` - -### 2.5 已有雏形:weird-rules-short - -| 业务 stage | 现 stageId | 含义 | -|---|---|---| -| 实例化 | `brief` | 启动询问 → `book.brief`(≈ `需求.核心要点`)→ `startupCompleted` | -| 运行 | `write` | `write-rules` 产出规则与 core | -| 运行 | `review` | 双验收 worker | -| 结束 | `done` | finish | - -`basic` 同理:`brief` = 实例化,`outline` worker = 运行。 -文档与实现迁移时,可将 stageId 改名为 `instantiate`,或保留 `brief` 但在 ## 阶段定义 注明 **brief ≡ instantiate**。 - -### 2.6 角色卡 - -不再单独维护 `character-card-author` skill 包。角色设定、口吻、行为边界等 tag 在 **扮演类 skill 的 instantiate 段** 收集或生成(可选 setup worker)。 -若需跨 Session 复用,将 `角色.A.设定`、`角色卡.确认稿` 等 **确认稿** 存入 Book,新 Session **加载 Book** 而非再跑完整实例化。 - -### 2.7 Book 与黑板 - -```text -黑板(Session 内) 运行时 tag 池;worker 读写 -Book(项目级) 长期实例;accepted 确认稿归档;下一场 Session 可加载 -``` - -详见 `docs/book-storage.md`。Session 是一次运行;Book 是一个创作项目(一本小说、一个扮演项目)。 +命名与创作单位 id 见 `design-orchestrator-guide.md` §6、`ui-glossary.md`;词表可另补 `docs/tag-vocabulary.md`。 --- -## 3. 黑板条目 - -### 2.1 最小结构 - -```ts -type BlackboardItem = { - id: string; - tag: string; - content: string; - source: string; - metadata?: Record; -}; -``` - -### 2.2 可选增强 - -```ts -type BlackboardItem = { - id: string; - tag: string; - content: string; - source: string; - scope?: string; - createdAt?: number; - updatedAt?: number; - dependencies?: string[]; - metadata?: Record; -}; -``` - -### 2.3 字段含义 - -```text -id 唯一标识,追踪与依赖 -tag 标签本体,决定身份与消费路径(核心) -content 具体内容(字符串) -source 产生该条目的 worker id,调试用 -scope 作用范围,如当前章节、场景、项目(可选) -dependencies 依赖的其他黑板条目 id(可选) -metadata 置信度、真实性、控制模式等(不参与基础路由) -``` - -注意: - -```text -tag 是核心路由依据。 -source 不是路由依据,只用于调试与溯源。 -metadata 不参与基础路由,除非某 skill 明确约定。 -``` - -**已废弃:** 旧模型的 `key`、`summary`、`tags[]`、`readableBy`、`writableBy` 作为路由字段。迁移期代码可能仍保留 `BlackboardEntry`,以本文为准逐步替换。 - ---- - -## 4. 标签原则 - -标签不是信息分类学,而是 **工作流接口**。 - -### 3.1 设计原则 - -```text -标签越少越好,但必须能区分不同消费路径。 -两个信息若永远被同一批 worker 消费,可合并 tag。 -若在不同阶段被不同 worker 消费,必须拆分 tag。 -若可能被误当成事实,必须加阶段段。 -若涉及角色私有认知,tag 里必须体现角色归属。 -``` - -### 3.2 推荐格式 - -```text -对象.内容 -对象.内容.阶段 -领域.对象.内容.阶段 -``` - -不强制四段式;按复杂度逐级增加。 - -示例: - -```text -大纲.草稿 -事件.草稿 -正文.草稿 -正文.确认稿 - -角色.A.行动.候选 -角色.A.台词.候选 -角色.A.内心想法.候选 -角色.A.记忆.当前 -``` - -### 3.3 标签替代的旧字段 - -```text -角色.A.内心想法.候选 -``` - -已表达:归属 A、类型为内心想法、阶段为候选、消费路径由声明 inputTags 的 worker 决定。 - -**不需要** 再写 `owner`、`visibleTo`、`type=action` 等平行字段。 - -### 3.4 匹配规则(Runtime) - -Worker frontmatter 声明 `inputTags`: - -```yaml -inputTags: - - "需求.核心要点" # 精确匹配 - - "角色.A.*" # 前缀匹配:tag 以「角色.A.」开头 - - "大纲.*.草稿" # 前缀匹配 -``` - -规则: - -```text -无通配 → 精确匹配 tag -以 .* 结尾 → 前缀匹配(实现优先于完整正则) -多条命中 → 默认按 updatedAt 取最新;或 worker 声明 inputMerge: concat | latest -``` - -总管调度时也可对 **tag 索引**(不含 content)做存在性判断,例如「是否有 规则.确认稿」。 - ---- - -## 5. 阶段标签 - -阶段表示同一类信息在不同生命周期下的语义。 - -```text -候选 worker 生成的可能内容,不等于事实 -草稿 生成中的文本或结构 -确认稿 用户或流程确认,可进入长期状态 -当前 当前生效状态 -更新 状态变化结果 -``` - -重要规则: - -```text -候选 ≠ 已发生。 -草稿 ≠ 确认稿。 -角色行动候选不能直接写入长期记忆。 -长期记忆优先从正文.确认稿 与明确 记忆.更新 生成。 -``` - -验收(`user_confirmed` / `programmatic_review`)通过后,Runtime 将对应产物 tag 从 `.草稿` 升级为 `.确认稿`(或写入新的确认稿条目并标记旧草稿 superseded)。 - ---- - -## 6. Worker - -每个 worker 是固定的标签消费者和生产者。 - -### 5.1 类型 - -```ts -type WorkerDefinition = { - id: string; - name: string; - description: string; - inputTags: string[]; - outputTags: string[]; - inputMerge?: "latest" | "concat"; - run: (context: WorkerContext) => Promise; -}; - -type WorkerContext = { - taskId: string; - workerId: string; - items: BlackboardItem[]; - params?: Record; -}; - -type WorkerResult = { - items: BlackboardItem[]; - logs?: string[]; - askUser?: string[]; -}; -``` - -### 5.2 规则 - -```text -worker 只能读取 inputTags 声明的标签(含前缀规则)。 -worker 只能输出 outputTags 声明的标签。 -worker 不读取全量黑板。 -LLM 不决定自己能看什么。 -Runtime 校验 outputs 的 tag ⊆ outputTags。 -``` - -### 5.3 Worker Skill 落盘 - -见 `docs/worker-skill-format.md`。正文写「怎么做」;`inputTags` / `outputTags` 写在 frontmatter。 - -### 5.4 ask_user - -提问是 worker **能力**,不是独立 worker。中途提问时 `resumeContext` 保存 workerId,**不**保存 inputTags(恢复时仍从 Worker Skill 读 inputTags)。 - ---- - -## 7. 总管(Main Agent) - -总管只负责流程推进。 - -### 6.1 职责 - -```text -判断当前任务属于哪种 skill / 业务阶段 -选择下一个 worker(run_worker) -判断是否需要追问用户(ask_user) -判断是否需要用户确认下一步(requiresApproval) -判断是否 finish -``` - -### 6.2 不负责 - -```text -不决定某条信息给谁看 -不手动拼接 worker 上下文 -不让 LLM 判断信息权限 -不把全量黑板交给 worker -不在 run_worker 里指定 inputTags / outputTags -``` - -### 6.3 决策结构 - -```ts -type MainAgentDecision = { - id: string; - action: "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish"; - reason: string; - workerId?: string; - requiresApproval: boolean; - statePatchAllowed: false; -}; -``` - -执行链: - -```text -总管选择 worker -→ Runtime 读取该 worker 的 inputTags -→ 从黑板取匹配条目,组装 WorkerContext -→ 调用 worker -→ worker 输出固定 outputTags -→ 写回黑板 -→ 按 acceptanceMode 验收 -``` - -Tool 合约见 `docs/tool-contracts.md`。 - ---- - -## 8. Skill 包与 manifest - -Skill 包 = `orchestrator.md`(manifest)+ `workers/*/SKILL.md`。详见 `orchestrator-skill-format.md`。 - -manifest 包含: - -```text -Skill 注册表(instantiate + run) -验收策略 -Instance Ready 规则 -``` - -**不写** 逐步编排表。inputTags / contextSegments 在 Worker SKILL.md。 - -### 8.1 已启用包 - -| 包 | 说明 | -|---|---| -| `novel/basic` | 演示:需求 → 大纲 | -| `novel/weird-rules-short` | 规则怪谈:写 + 双验收 | - -### 8.2 规划包(见 `skills/README.md`) - -| 包 | 说明 | -|---|---| -| `novel/quick-write` | **简易档**:几乎无 tag 路由,上下文全量给 LLM | -| `novel/interactive-novel` | 长篇 / 写作助手:instantiate + 多轮 run | -| `novel/novel-standard` | 标准流水线(或与 interactive 合并) | -| `dialogue/scene-roleplay` | 场景扮演(含角色设定 instantiate + 互动 run) | - ---- - -## 9. 各模式核心 tag(参考) - -实现 skill 时从下列词汇出发,不必一次全部实现。 - -### 8.1 简易小说(quick-write) - -极简;可大量依赖 session + 全量上下文,tag 仅作交付锚点: - -```text -用户.输入 -需求.摘要 -正文.草稿 -正文.确认稿 -``` - -### 8.2 结构化短篇(如 weird-rules-short) - -迁移目标示例(与现 key 对照实施): - -```text -需求.核心要点 ← book.brief -核心.危险.隐藏 ← core.danger -规则.草稿 ← rules.draft -规则.说明.草稿 ← rules.commentary -验收.读者视角.记录 ← review.infer.notes -验收.作者视角.记录 ← review.author.notes -``` - -### 8.3 交互式长篇 - -```text -用户.原始输入 | 用户.意图转述 | 用户.确认结果 -项目.设定 | 项目.风格要求 -大纲.当前 | 大纲.候选修改 | 大纲.确认稿 -事件.当前 | 事件.确认稿 -正文.原文 | 正文.续写锚点 | 正文.草稿 | 正文.确认稿 -记忆.长期摘要 | 记忆.确认稿 -``` - -### 8.4 场景扮演 - -```text -用户.行动输入 | 用户.行动意图 -世界.规则 | 世界.当前状态 | 世界.隐藏状态 -场景.可见信息 | 场景.隐藏信息 -角色.{id}.记忆 | 信念 | 行动.候选 | 台词.候选 -行动.裁决结果 -输出.场景反馈 -更新.世界状态 | 更新.角色状态 -``` - -### 8.5 角色卡 - -撰写:`角色卡.草稿` → `角色卡.确认稿` -游玩:读 `角色卡.确认稿` + `用户.控制模式` + `NPC.*` - ---- - -## 10. 正文 worker 与角色候选 - -角色 worker 输出 `角色.*.台词.候选` 等,**不**直接进入正文事实。 - -正文 worker 读取候选 + 风格约束,输出 `正文.草稿`;用户确认后为 `正文.确认稿`。 - -```text -角色候选 → 提供意图 -正文 worker → 文本化、风格化、叙事化 -正文.确认稿 → 最终发生与表达 -``` - ---- - -## 11. 可选增强 - -### 10.1 采用记录 - -```text -tag: 采用.记录 -``` - -记录正文采用了哪些候选条目,避免未采用候选污染记忆。早期可省略,记忆 worker 只从 `正文.确认稿` 抽取。 - -### 10.2 真实性 / 置信度(metadata) - -```ts -type TruthMode = - | "truth" | "belief" | "claim" | "lie" | "rumor" | "plan" | "unknown"; -``` - -不参与基础路由。 - -### 10.3 RAG - -RAG 不替代黑板。检索结果也应写成 tag,例如 `角色.A.相关记忆摘要`,由 worker 通过 inputTags 读取。 - ---- - -## 12. 与阶段机的关系 - -运行相位、业务 stage、tag 阶段 **三者正交**(详见 §2.2): - -```text -运行相位 系统在等什么(见 runtime-state-machine.md) -业务 stage instantiate → run → done(orchestrator.md;brief 即 instantiate) -tag 阶段 黑板条目 lifecycle(候选 / 草稿 / 确认稿) -``` - -实例化阶段在运行相位上通常体现为 `waiting_user(input)`;进入 run stage 后为 `running` + worker 验收循环。 -阶段机 enum 不增加 `instantiate` 相位——业务 stage 由 orchestrator + tag 索引判断。 - -阶段机规则本身不因 tag 迁移而改变。变的是:黑板读写、worker 上下文、总管决策字段。 - ---- - -## 13. 与 Book 存储 - -Book 存长期实例;黑板存 **当前 Session** 的运行时 tag。实例化产物与 run 阶段确认稿可归档到 Book。 -详见 `docs/book-storage.md` 与 §2.7。 - ---- - -## 14. 实现原则(必须遵守) - -```text -1. worker 不读取全量黑板(quick-write 等显式声明全量 inputTags 的 skill 除外)。 -2. worker 只能读取 inputTags 声明的标签。 -3. worker 只能输出 outputTags 声明的标签。 -4. LLM 不决定上下文分发。 -5. 总管 run_worker 时不带 inputTags / outputTags。 -6. 标签是 worker 之间的接口。 -7. 候选不等于事实;草稿不等于确认稿。 -8. 正文 worker 可重写角色候选产物。 -9. 长期记忆优先从正文.确认稿 更新。 -10. 简单 skill 用简单 tag;复杂 skill 再增加对象与阶段段。 -11. 角色卡与设定在 instantiate 阶段写入 tag;跨 Session 复用走 Book 加载,不单独 author skill 包。 -12. 代码与旧文档冲突时,以本文为准改代码。 -``` - ---- - -## 15. 代码迁移顺序(参考) - -```text -1. src/types/blackboard.ts → BlackboardItem + listByTag / matchPrefix -2. src/skills/types.ts + loader → 解析 inputTags / outputTags -3. src/worker/executor.ts → 按 tag 组装 context,校验 outputTags -4. src/types/runtime.ts + main-agent → 决策去掉 inputKeys / outputKeys -5. skills/novel/weird-rules-short → 第一个 tag 化样板 -6. skills/novel/quick-write → 简易全量 LLM 档 -``` - -当前代码仍为旧 key 模型;实现前以本文为规格。 - ---- - -## 16. 相关文档 - -| 文档 | 内容 | -|---|---| -| `orchestrator-skill-format.md` | 总管 orchestrator.md 写法 | -| `worker-skill-format.md` | Worker SKILL.md 写法 | -| `tool-contracts.md` | 总管 / worker tool | -| `skill-format.md` | Skill 包存储 | -| `runtime-state-machine.md` | 5 相位阶段机 | -| `implementation-guide.md` | 写代码顺序 | -| `skills/README.md` | Skill 包索引与 TODO | +## 4. 相关 + +| 文档 | 关系 | +|------|------| +| `creation-playbook.md` | 创作 / 游玩流 | +| `worker-skill-format.md` | 声明驱动 | +| `context-assembly.md` | 上下文拼接 | +| `run-snapshot.md` | 存档 | diff --git a/docs/tool-contracts.md b/docs/tool-contracts.md index 1f37562..193b36e 100644 --- a/docs/tool-contracts.md +++ b/docs/tool-contracts.md @@ -37,7 +37,7 @@ LLM 通过 **tool call** 表达意图;Runtime 校验后执行 tool 或转为 * | name | 行为 | |------|------| | `run_worker` | 执行 skill;`requiresApproval` → `approve_step` | -| `ask_user` | `waiting_user(input)` | +| `ask_user` | `waiting_user(input)`;`assessment` → 内容评价(主内容);`questions` → 挂载询问卡(可 Skip) | | `review_blackboard` | 向用户展示概况 → `input` | | `finish` | `done` | @@ -72,7 +72,7 @@ running 内每轮 LLM+tool 使 burst+1;超过 maxBurst(默认 12,可配置 | name | 行为 | |------|------| -| `ask_user` | `worker_questions` + resumeContext | +| `ask_user` | 无产物 → `worker_questions`;有产物 → 仍 `worker_completed`,追问挂到 `review_artifact.questions`(可直接 Accept) | | `submit` | 校验 tag ⊆ outputTags → 写黑板 → `worker_completed` | Phase A 仍用 JSON `outputs` + `askUser`,语义等价。 @@ -83,11 +83,11 @@ Phase A 仍用 JSON `outputs` + `askUser`,语义等价。 | waitingReason | 用户动作 | |---------------|----------| -| `skill_selection` | 选包 | +| `skill_selection` | 选包(**legacy**,新作品不再进入) | | `intake` / `input` | 输入 | | `approve_step` | approve / reject | -| `review_artifact` | accept / reject | -| `worker_questions` | 输入 | +| `review_artifact` | accept / reject;可选 `questions` 随 Accept 一并收起(表示无需再完善) | +| `worker_questions` | 作答 / Skip(无产物时的阻塞追问) | | `revision` | 输入修改说明 | --- diff --git a/docs/ui-design.md b/docs/ui-design.md new file mode 100644 index 0000000..02561a3 --- /dev/null +++ b/docs/ui-design.md @@ -0,0 +1,452 @@ +# Web UI 心流设计 + +## 1. 定位 + +Writing Agent 的 Web 界面是 **全屏创作工作台**,不是即时通讯客户端。 + +**用户可见文案**(阶段名、Worker 名、气泡标题)须服从 **`ui-glossary.md`**:内部仍用 `design` / `design-core` 等 id,界面映射为「创作」「创作 · 核心」等中文。实现见 `src/server/display-labels.ts` 与 `web/display-labels.js`。 + +| 层 | 角色 | +|----|------| +| **工作面(working surface)** | 主舞台:用户此刻要看、要改、要验收的对象 | +| **协调层(coordination)** | 对话与确认:说明意图、回答问题、触发下一步 | +| **项目轨(project rail)** | 低频:作品切换、快照、模式切换 | +| **系统态(ambient)** | 后台:Agent 执行、调度日志,默认不抢视线 | + +**对话是手段,产物与叙事是目的。** 聊天气泡适合承载协调叙事,不适合承载长规格全文或沉浸式阅读。 + +主场景是 **全屏浏览器、长会话(1–2 小时)**,布局应稳定、可预期,而非频繁切 Tab 或在三块等权面板间跳视线。 + +相关代码:`web/index.html`、`web/app.js`、`web/agent-ui.js`、`web/styles.css`;视图模型 `src/server/agent-view.ts`。 + +--- + +## 2. 两种业务阶段 = 两种主布局 + +`lifecycleStage`(`design` / `play`)切换时,**主舞台内容应变形**,壳子(顶栏、底栏、左轨)保持同一套肌肉记忆。 + +### 2.1 创作 `design` — 规格工作台 + +用户在定义世界、验收 Worker 集、选定开局;核心是 **结构化产物**(JSON 规格、表格、规格卡)。 + +```text +主视线:正在成形 / 正在审阅的产物 +辅视线:协调对话(窄列或底栏上方的摘要条) +冷信息:项目轨、快照、设计进度 +``` + +典型路径: + +```text +描述意图 → 主区草稿成形 → 回答追问(表单) → 验收全文 → Accept + ↑ 主舞台 ↑ 检查器 ↑ 绑在产物上 +``` + +**心流标杆(组合参考)**:Notion(结构化编辑)+ GitHub PR(版本验收)+ NotebookLM(零散想法 → 结构化草稿)。 + +### 2.2 游玩 `play` — 叙事驾驶舱 + +用户代入角色、推进回合;核心是 **叙事流 + 世界状态**。 + +```text +主视线:回合输出(阅读优先,视野占比 70%+) +辅视线:底部命令输入(短、快、位置固定) +冷信息:角色/世界状态抽屉、多 run 存档 +``` + +典型路径: + +```text +读回合输出 → 输入行动 → 等生成 → 继续读 + ↑ 主舞台 ↑ 固定底栏 +``` + +**心流标杆**:SillyTavern / 视觉小说阅读器(沉浸叙事)+ Roguelike 存档抽屉(同一开局多条 run)。 + +`world-simulator` 包说明见 `skills/dialogue/world-simulator/README.md`;流程概念见 `creation-playbook.md`。 + +--- + +## 3. 全屏网页约束 + +与手机 IM(如 QQ)不同,全屏 Web 有以下硬约束: + +| 约束 | 设计含义 | +|------|----------| +| **视口宽** | 可横向分栏:工作面 55–65% + 协调区 35–45%,不必单列堆叠 | +| **会话长** | 底栏输入/确认位置永远不变;减少布局跳动 | +| **产物大** | Worker 集、开场白等长文进工作面,不进聊天气泡 | +| **双模式** | 同一壳子、两种主画布,不靠一套气泡布局打天下 | + +QQ 等 IM 仅保留一条可借鉴点:**底栏作为唯一行动锚点**。其余按工作台设计。 + +--- + +## 4. 壳子布局(Figma 式分区) + +全屏下的推荐骨架: + +```text +┌────┬──────────────────────────────┬────────────┐ +│项目│ 主画布(随 stage 变) │ 检查器 │ +│轨 │ │(按需展开) │ +│窄 │ design: 规格 / 表格 / 验收 │ │ +│ │ play: 叙事流 │ │ +└────┴──────────────────────────────┴────────────┘ +│ 命令栏(composer,固定) │ +└──────────────────────────────────────────────────┘ +``` + +| 区域 | 职责 | 频率 | +|------|------|------| +| **左轨** | 作品列表、路径、快照入口 | 低 | +| **顶栏** | 作品名、创作/游玩切换、状态 pill、`waitingReason` 摘要 | 常瞥 | +| **主画布** | 当前任务的工作面 | 主视线 | +| **检查器** | 产物元数据、验收按钮、进度、可编辑表格 | 按需 | +| **命令栏** | 输入、发送、主确认(与检查器不重复) | 手常驻 | + +检查器默认 **收起**;有产物验收、结构化填空、开局 swipe 等任务时 **滑出**(推开主画布,窄屏用全屏 drawer)。不是常驻 300px Agent 监控台。 + +--- + +## 5. 参考产品(按心流相似度) + +| 产品 | 借鉴什么 | 对应本项目的阶段 | +|------|----------|------------------| +| **Cursor `AskQuestion`** | 独立询问卡、字母点选、分页、Skip/Continue、键盘捷径;聊天只留索引 | `worker_questions`(§8;选项可编辑为我们的增强) | +| **ChatGPT 澄清问法** | 题干短、选项 = 完整可采纳句;一次收齐再继续 | 发问侧文案与批大小 | +| **Claude Artifacts / ChatGPT Canvas** | 产物占主视区,对话收窄为协调 | `review_artifact`、Worker 集验收;询问卡放置 | +| **GitHub Pull Request** | 被审对象为主舞台;讨论为时间线;Merge/Request changes 绑在对象上 | Accept、variant 切换、重 roll | +| **Notion / Coda** | 文档/数据库是「家」;AI 辅助不抢主位 | `intake`、变量 schema、Worker 分工 | +| **Figma** | 主画布 + 窄轨 + 按需检查器 | 全屏壳子结构 | +| **SillyTavern / AI Dungeon** | 叙事占满视野;侧栏可收 | `play` 回合阅读 | +| **NotebookLM** | 多源 → 中间协调 → 右侧成型笔记 | 创作开头 intake、草稿预览 | +| **Linear** | 状态一眼可见、操作路径短、装饰极少 | 顶栏状态、长按效率 | + +--- + +## 6. 四条统一原则 + +跨 `design` / `play` 均遵守: + +| 原则 | 含义 | 反面 | +|------|------|------| +| **一屏一事** | 同一时刻只有一个主任务(审产物 / 答提问 / 读叙事) | 右侧同时堆历史 + 验收 + 进度 + Agent focus | +| **行动点唯一** | Accept、发送、swipe 只在一处出现 | composer 与检查器各放一套接受按钮 | +| **对话是索引** | 气泡放摘要 + 跳转;全文在工作面 | Worker 长产出塞进中间气泡 | +| **底栏锚定** | 输入/确认永远在底部同一区域 | 底栏在纯按钮与 textarea 间大幅换位 | + +--- + +## 7. 消息与面板的职责切分 + +### 7.1 中间协调区(当前 `message-feed`) + +**应展示:** + +- 用户输入 +- Worker 提问的 **一行索引**(「提问中 · 1/3」);题干与选项在询问卡(§8) +- 简短结论、错误提示 +- 流式 pending(执行中摘要) +- 长产物的 **摘要 +「已在检查器打开」** 链接 + +**不应展示:** + +- `agent_tool`、`orchestrator_decision`、`worker_running`(已在 `FEED_HIDDEN_KINDS`) +- 验收中的产物全文(`review_artifact` 时隐藏 `worker_output`) +- intake 完整表格(仅进度摘要) +- `worker_questions` 的完整选项列表(进 §8 询问卡) + +实现:`web/agent-ui.js` 中 `shouldShowInFeed`、`FEED_HIDDEN_KINDS`。 + +### 7.2 检查器(目标态;当前部分在 `panel-agent`) + +**应承载:** + +- 产物预览 / 编辑 / diff +- intake 结构化表单 +- `worker_questions` 的备选容器(主路径见 §8 询问卡) +- 开局 swipe 卡片栈 +- 设计进度(Worker 集卡片、步骤) +- **固定上下文(独立区)**:纲领 / 常驻 / 黑板 tag 与 Worker 解耦;每张卡标明 **塞进哪些 Worker**(注入正文 / 常驻挂载 / 读入黑板) +- **上下文 / 黑板**(终产物 tag、定稿摘要、归档计数) +- 验收主按钮(接受 / 不接受;创作:删本单位交互消息只留产物;run:压缩过程 tag) + +Worker 卡不再嵌套完整 tag 列表,只显示「上下文」摘要;完整挂载关系见上方固定上下文区。 + +**应降级折叠:** + +- Agent focus、tool trace +- 调度时间线(现「历史」Tab)→ 高级 / 调试模式 + +### 7.3 命令栏(`composer`) + +由 `resolveComposer`(`web/app.js`)按 `phase` + `waitingReason` 变形,但位置不变: + +| 态 | 表现 | +|----|------| +| `running` | 轻量状态 + 允许预输入草稿 | +| `approve` / `accept` | 主按钮在底栏;hint 一行 | +| `review_artifact` | 底栏:输入并发送=改产物;旁挂「接受目前产物」。产物卡上不放接受钮 | +| `intake` / `input` | textarea + 发送 | +| `worker_questions` | **不抢主交互**;主回答在询问卡内(见 §8) | + +--- + +## 8. 结构化询问卡(Questions Card) + +`worker_questions` / `ask_user` 的 **首选交互**。壳子与键盘心流对齐 **Cursor `AskQuestion`**;选项语义与文案改写对齐 **ChatGPT / Claude 的「选项 = 可执行草稿」**;放置原则对齐 **Artifacts / Canvas「决策面 ≠ 聊天气泡」**。 + +### 8.0 业内标杆:借鉴什么、刻意改进什么 + +| 产品 / 模式 | 借鉴(照抄心流) | 不照抄 / 我们的改进 | +|-------------|------------------|---------------------| +| **Cursor `AskQuestion`** | 独立询问卡(非气泡堆选项);`A/B/C` 字母点选;`n of N` 分页;底部 **Skip + Continue**;Enter / Esc;聊天流只留「在答」索引 | Cursor 选项多为**只读**,改细节只能塞底部通用备注 → 我们默认**选项文案可改写**,点文案编辑、点字母选中(社区对 Cursor 的高频诉求,见 AskQuestion inline-edit 讨论) | +| **ChatGPT** | 澄清问法:题干短、选项是完整可采纳句;多题一次收齐再继续;自由补充不抢主路径 | 不用纯聊天气泡做多选题主交互;长选项不进 feed | +| **Claude Artifacts / ChatGPT Canvas** | 「结构化决策 / 产物」与对话分离:卡/面板是工作面,聊天是协调 | 询问卡不是 Artifact 全文,而是短决策面;验收长产物仍走检查器 | +| **Linear / GitHub PR 决策** | 一屏一事、主按钮唯一、状态一眼可见 | 不引入 Issue 式侧栏评论串 | + +**产品结论(写死):** + +1. **主路径 = Cursor 式询问卡**(字母选中 + Continue),不是 QQ/ChatGPT 气泡里的长问答。 +2. **选项 = 可编辑草稿**(相对 Cursor 默认只读的增强):多数一点即答;不满意就改文案再选,不必另开 Other。 +3. **对话流只索引**:「提问中 · 1/3」;题干与选项只在卡上。 +4. **命令栏降级**:本态不当主输入;可选「自由补充」兜底,语义从属于卡上 Continue。 + +```text +┌─────────────────────────────────────────────┐ +│ ? Questions ▲ ▼ 1 of 3 │ +├─────────────────────────────────────────────┤ +│ 1. Q1: 问题正文…… │ +│ [A] 选项文案(可编辑) ← 点文案 = 改写 │ +│ [B] 选项文案… ← 点字母 = 选中 │ +│ [C] 选项文案… │ +│ [+] Other… [_________] │ +│ │ +│ 2. Q2: …… │ +│ … │ +├─────────────────────────────────────────────┤ +│ Skip Esc [ Continue ↵ ]│ +└─────────────────────────────────────────────┘ +``` + +**最关键交互:选项默认可改写;点选项文案是编辑,点字母按钮才是选中。** +Agent 给的选项是「可编辑草稿」,不是只读单选列表。 + +### 8.1 为什么适合本产品 + +| 特质 | 对用户心流的作用 | 业内对应 | +|------|------------------|----------| +| **点选优先,改写兜底** | 多数情况一点字母即答;不满意就改文案再选,不必另开 Other | Cursor 点选 + ChatGPT「改一句再说」 | +| **选项即可执行语义** | 提交的是用户确认(或改写后)的完整句子,可直接喂给 worker | ChatGPT / Claude 澄清选项写法 | +| **编辑与选中分离** | 避免「想改一下措辞」却误触提交路径;符合「一屏一事」 | 相对 Cursor 只读选项的增强 | +| **分页批次** | 「1 of 3」管理预期;单屏别堆太多题 | Cursor `AskQuestion` | +| **键盘捷径** | Enter = Continue,Esc = Skip;字母键可选中(可选增强) | Cursor / IDE 习惯 | +| **模态焦点** | 当前这一批问清了再 Continue | Cursor 卡内提交;非边聊边答 | + +适用:`design` 里 intake 追问、规格确认;`play` 里「局面 + 可选行动」;凡原先要用户在 composer 里写长段回答的提问,优先改成卡。 + +### 8.2 放置 + +| 区域 | 职责 | 业内对应 | +|------|------|----------| +| **主画布消息流下方(推荐)** | 询问卡嵌在协调列底部(composer 之上),宽度随列自适应,不遮盖历史消息 | 决策面与对话分离,但占文档流而非浮层 | +| **检查器** | 备选:与产物并排时,卡可嵌在检查器顶部;长产物场景优先主画布居中卡 | Canvas / 侧栏表单 | +| **中间气泡** | 只留一行索引:「Worker 提问中 · 1/3」;点开回到卡 | Cursor:聊天不堆完整选项列表 | +| **命令栏** | 降级:本态不当主输入;仅作兜底自由补充(可选) | Cursor 底部「optional details」——我们刻意弱化,主语义在选项改写 | + +Continue 提交后:卡收起 → 系统 `running` → 回复摘要进对话流(摘要用**用户最终确认的文案**,含改写)。 + +### 8.3 卡片结构与命中区 + +| 元件 | 规则 | 对齐 | +|------|------|------| +| **Header** | 标题「Questions」+ 批次 `n of N` + 上下翻批 | Cursor | +| **题干** | 编号 `1.`;一句说清决策点;避免在选项里复述背景 | ChatGPT 澄清问法 | +| **字母按钮 `[A]`…** | **唯一「选中」命中区**;选中后该行高亮;同题单选 | Cursor | +| **选项文案区** | **默认可编辑**;点击文案(或已聚焦时)进入改写,**不触发选中** | 相对 Cursor 的增强(社区诉求) | +| **Other…** | 每题末可选;短输入;与改写选项二选一语义相同——都是「自拟答案」 | Cursor Other + ChatGPT 自由答 | +| **Skip** | 本批可跳过(Esc);下游需能处理「未答」 | Cursor | +| **Continue** | 主按钮(Enter);校验必选题已有选中项(提交的是该选项当前文案) | Cursor | + +#### 8.3.1 编辑 vs 选中(强制) + +```text +一行选项 = [字母按钮] + [文案区域] + +点 [A] → 选中 A(提交候选变为 A 的当前文案) +点 文案区 → 进入编辑;改写本地草稿;不改变选中态 + (若尚未选中,可编辑完再点字母选中; + 若已选中 A 又改了文案,提交仍用改写后的 A) +``` + +细则: + +1. **默认所有选项文案可改**(含 Agent 预填的 A/B/C);不是「只能选、想改去 Other」(Cursor 现状的痛点,我们默认解掉)。 +2. **点文案 ≠ 选中**。禁止「点整行即选中」的常见单选实现。 +3. **只有字母按钮(或等价:键盘 A/B/C)负责选中**。 +4. 编辑中:Enter 可结束编辑并保持焦点在卡内;勿与 Continue 的 Enter 冲突(编辑态下 Enter = 收起编辑)。 +5. 提交 payload 必须带上 **最终文案**(`label` / `text`),不能只交 `optionId`——否则改写丢失(ChatGPT/Cursor 都要求「用户说了什么」完整回传)。 +6. Other 与「改写某选项」并存:Other 是空槽自拟;改写是在 Agent 草稿上微调。二者都合法。 + +多批:Continue 先推进当前页,再进下一批;**全部页完成后再**统一提交并解除 `waiting_user`(对齐 Cursor 一批问清再继续)。 + +### 8.4 与 runtime 的契约 + +`askUser` / `ask_user` 已支持结构化 `QuestionItem[]`(兼容旧 `string[]`)。提交走 `POST /api/sessions/:id/answers`: + +```ts +{ + answers: [ + { questionId: "q1", optionId: "A", text: "(用户看到的/改写后的完整句)" }, + { questionId: "q2", optionId: "other", text: "…" }, + ] +} +``` + +前端:主画布消息流下方询问卡(点字母选中、点文案编辑、分页 Continue);气泡可只显示答句,发给 AI 仍含问+答。 + +Worker / Agent 发问侧(对齐 Cursor AskQuestion:询问不阻断主产出): + +- 总管 `ask_user`:**assessment 是主内容**;`questions` 挂在其下,用户可 Skip 并请总管基于现有信息继续。 +- Worker:优先 `outputs` + `askUser` 同时给出;有产物时追问挂在验收态下,用户可直接「接受目前产物」而不作答。仅完全无法产出时才阻塞提问。 +- 能推断选项时 **必须** 给 `options`;每项写成用户可直接采用或微调的**建议示范**(可含短场景钩子),禁止空泛「是 / 否」。 +- `editable` 默认 `true`;挂载题 `required` 默认 `false`。 +- 单批题量控制在 Cursor 舒适区:**默认每页 1 题(左右切换),整批不宜超过 5 题**;更细的拆到下一轮 `ask_user`。 + +过渡期:仅有 `string[]` 时,UI 将每题渲染为题干 + Other。询问卡首屏可展示 `waitingReason.message` 中的内容评价。 + +实现:`web/questions-ui.js`、`web/styles.css`(`.qcard*`)、`web/agent-ui.js` 索引气泡。 + +### 8.5 反模式 + +- 点整行 / 点文案就选中(应点字母才选中)— 违反 Cursor 式命中区 +- 选项只读、想改只能走 Other 或底栏备注(Cursor 痛点,禁止照抄) +- 提交只带 `optionId`、丢掉用户改写后的 `text`(业内答案回传完整性要求) +- 把 5+ 道长选项塞进一条聊天气泡(ChatGPT/Cursor 都已证明劣于独立卡) +- 选项写「是 / 否」却题干含糊 +- Continue 与底栏「发送」并存且语义不同 +- 无 Skip/必答标记,用户卡死无法继续 +- 编辑态下 Enter 误触 Continue 整卡提交 + +--- + +## 9. `waitingReason` × `lifecycleStage` 显示规格 + +目标态 UI 分配表(实现时对照 `agent-view` 的 `buildFocus` 与 `actions`)。 + +| waitingReason / phase | design 主画布 | play 主画布 | 检查器 | 命令栏 | 顶栏状态 | +|------------------------|---------------|-------------|--------|--------|----------| +| `idle` / 未打开作品 | 空状态引导 | — | 关 | 禁用 | — | +| `intake` | 空或首条引导文案 | — | intake 表单(有字段时展开) | 描述需求 + 发送;必要项满则确认 | 描述创作需求 | +| `input`(首句) | 协调摘要 | 叙事历史 | 关 | 发送 | 描述 / 补充 | +| `input`(补充) | 协调摘要 | 叙事历史 | 关 | 发送 | 补充说明 | +| `running` | 流式 pending | 流式 pending | 关 | 等待态(可预输入) | Agent/Skill 执行中 | +| `worker_questions` | **询问卡(§8)** + 一行索引气泡 | 同左;行动题尤其用选项 | 可选副展示 | 降级;可 Skip | 回答提问 | +| `approve_step` | 决策摘要 | 决策摘要 | 可选:待 invoke 说明 | 确认 / 暂不 / 说明意见 | 建议 invoke | +| `review_artifact` | 「请验收」摘要;若有挂载题则询问卡可选(**Accept 即收起**) | 同左 | **产物全文 + 接受/不接受** | 修改意见(可选);接受 = 无需再完善 | 验收产物 | +| `revision` | 修改说明上下文 | 同左 | 相关产物 | 按 instruction 输入 | 修订中 | +| opening swipe(业务) | 卡片栈主舞台 | — | 版本 nav + 锁定 | 选定 / 换一版 | 选定开局 | +| `done` | 阶段结束摘要 | 会话结束 | 关 | 空闲提示 | 已完成 | + +`skill_selection` 为遗留恢复态,UI 引导用户发消息继续即可。 + +--- + +## 10. 阶段心流图 + +```mermaid +flowchart TB + subgraph shell [固定壳子] + Rail[左轨 · 作品] + Top[顶栏 · 模式与状态] + Cmd[底栏 · 命令栏] + end + + subgraph design [design 主画布] + Spec[规格 / 表格 / 验收] + CoordD[协调摘要] + QCardD[询问卡 · worker_questions] + end + + subgraph play [play 主画布] + Narr[叙事流] + CoordP[回合摘要] + QCardP[询问卡 · 行动选择] + end + + subgraph inspector [检查器 · 按需] + Artifact[产物 / 表单] + Progress[设计进度] + Debug[调度日志 · 折叠] + end + + Rail --> Spec + Rail --> Narr + Top --> Spec + Top --> Narr + Spec --> Cmd + Narr --> Cmd + Spec -.-> Artifact + Narr -.-> Artifact + CoordD --> Cmd + CoordP --> Cmd + QCardD -->|Continue| Cmd + QCardP -->|Continue| Cmd +``` + +--- + +## 11. 现状与目标差距 + +| 现状(`web/`) | 心流问题 | 目标 | +|----------------|----------|------| +| 右侧常驻 `panel-agent`(约 300px) | 空跑时也占视线 | 按需检查器,默认收起 | +| focus / tool-trace 在右侧顶部 | 像 IDE 调试台 | 并入折叠日志或顶栏小状态 | +| intake 可在 feed 与 composer 两处 | 信息重复 | 表格进检查器,feed 仅摘要 | +| `worker_questions` = 文本列表 + composer | 用户写长段、选项语义易丢 | **§8 询问卡**:文案可改、点字母选中 | +| `askUser: string[]` | 无 options / Other / 分页 | 升级 `QuestionItem` 协议 | +| 验收时 composer 与「验收」Tab 均可操作 | 行动点分散 | 主按钮只在检查器 | +| 移动端隐藏整个 `panel-agent` | 无产物面 | 全屏 drawer | +| 主区标题写「agent-first」 | 与产品定位不符 | 创作 = 产物优先;游玩 = 叙事优先 | + +已有正确方向(保持并强化): + +- SillyTavern 式气泡与左右对齐(`play` 适用) +- `FEED_HIDDEN_KINDS` 分流内部消息 +- `review_artifact` 时产物不进 feed、自动切验收视图 +- 底栏 `composer` 按 `waitingReason` 变形 + +--- + +## 12. 实现备注 + +- 视图数据:`SessionView` / `buildAgentView`(`src/server/agent-view.ts`)已区分 `messages`、`reviewArtifact`、`waitingReason`、`actions`、`intake`;询问卡需扩展 questions 结构,见 §8.4。 +- 消息分类:`classifyAgentMessage`、`EnrichedMessage.kind` 驱动 feed 过滤;`worker_questions` 在 feed 仅留索引。 +- 生命周期:`inferLifecycleStage`、`body[data-lifecycle]`(`web/agent-ui.js`)控制 accent;可扩展为 **主画布布局切换**。 +- 验收:`renderReviewPanel` → 迁移为检查器主内容,与 composer 去重。 +- 设计进度:`renderSkillGuide` / `worker-set-user-panel` → 检查器内按需 Tab,非默认主视图。 +- 发问侧:`src/worker/executor.ts` 的 `askUser`、docs/`worker-skill-format.md` §ask_user → 支持带 `options` 的结构化问题。 + +改版时建议顺序: + +1. 检查器按需展开 + 验收动作收敛 +2. **§8 询问卡 UI**(先兼容现有 `string[]`,再补 options 协议) +3. design 主画布产物优先(收窄协调列) +4. play 叙事区拉满 + 状态抽屉 +5. 调度日志降级为高级折叠 +6. 窄屏 drawer 与 opening swipe 专屏 + +--- + +## 13. 相关文档 + +| 文档 | 关系 | +|------|------| +| `architecture.md` | 总览;UI 为 Session 的人机界面 | +| `creation-playbook.md` | design → play 业务流程 | +| `runtime-state-machine.md` | `waitingReason` 定义 | +| `worker-skill-format.md` | `ask_user` / 提问侧契约 | +| `run-snapshot.md` | 快照与分支;左轨/检查器入口 | +| `implementation-guide.md` | `web/*` 文件职责 | +| `book-storage.md` | 作品、存档、资产形态 | diff --git a/docs/ui-glossary.md b/docs/ui-glossary.md new file mode 100644 index 0000000..7f6fd50 --- /dev/null +++ b/docs/ui-glossary.md @@ -0,0 +1,171 @@ +# 用户可见文案与术语对照(UI Glossary) + +**用途**:凡出现在 Web 界面、导出 Markdown、状态 pill、气泡标题、询问卡、检查器上的文案,**禁止直接暴露**内部英文 id(如 `design-core`、`design`、`play`)。内部协议、SKILL 路径、黑板 tag、API 字段仍用英文 id。 + +**实现**:`src/server/display-labels.ts`(权威映射);`web/display-labels.js` 须与本文及该文件保持一致。新增高频 id 时:**先改本文 → 再改代码**。 + +**产品隐喻**:用户侧统一用**拍摄**用语——**导演 / 剧本 / 演员 / 能力**。旧称「总管 / 能力包 / 配方 / Worker」仅作内部或过渡别称。 + +--- + +## 0. 拍摄术语(首选) + +| 拍摄术语 | 用户可见含义 | 内部对应 | 勿再对用户说 | +|----------|--------------|----------|--------------| +| **导演** | ① 新建时**手动选一次**的方法起点(如世界模拟器、扩写助手),可按现场「调味」;② 会话里负责调度的 Agent | `recipes/` 选型 → 黑板 `创作.选用配方`;Main Agent / orchestrator | 配方、能力包(作第二层选项)、总管 | +| **剧本** | 本局谈成的流程与规格(活的,不是死选单) | `设计.创作流程`、`设计.worker集` | 「剧本 Skill 菜单」、死板流程卡 | +| **演员** | 上场执行的单元(一次 `run_worker`) | Worker / design-* / play workers | 对用户堆「Worker · id」;标题用中文名 | +| **能力** | 共用工序模块(美学纲领与交互范式…);导演从中选型编排 | `modules/` 组件池 | 组件池、模块(可作作者文档用词) | + +```text +用户选【导演】(世界模拟器 / 扩写助手 …) + │ + ▼ +【导演】调度 → 从【能力】里编排 → 谈成【剧本】 + │ + ▼ +【演员】按剧本上场执行 → 游玩 / 成稿 +``` + +**一层选型**:新建作品只选**导演**,不要再叠「能力包 + 配方」两层。 + +**调味**:导演给出的建议步骤是**近期起点**;编排时可增量追加、可反复调用标了可反复的能力(像现场改戏),验收的是本局【剧本】,不是磁盘上的静态菜谱。 + +--- + +## 1. 原则 + +1. **内外分离**:id 给机器;中文给用户。 +2. **拍摄词优先**:对用户优先用 §0;技术文档可写「导演(Main Agent / recipe)」对照。 +3. **阶段用产品词**:lifecycle 的 `design` / `play` 用户侧统一为 **创作** / **游玩**(勿译成「设计模式 / 播放」)。 +4. **与 SKILL `name` 对齐**:磁盘 worker 若已有中文 `name`,展示优先用 name;本表是缺省与高频兜底。 +5. **气泡标题形态**:`{中文名}` 或 `{中文名} · {动作}`,例如「创作 · 流程编排 · 产出」,不要「Worker · design-flow 产出」。 + +--- + +## 2. 生命周期 / 阶段 + +| 内部 id / 词 | 用户可见 | 业界对照与选用说明 | +|--------------|----------|-------------------| +| `design` | **创作** | 谈【剧本】的工作台。不用「设计」作顶栏主词。 | +| `play` | **游玩** | 【演员】按剧本上场。不用「播放」。 | +| `done` | **已完成** | — | +| `idle` | **待命** | — | +| `running` | **执行中** | — | +| `waiting_user` | **等待你** | — | + +--- + +## 3. 角色与系统概念(对照表) + +| 内部词 | 用户可见(首选) | 过渡别称 | 说明 | +|--------|------------------|----------|------| +| Agent / Main Agent / orchestrator | **导演** | 总管(旧) | 调度【演员】、推进【剧本】;气泡可用「导演 · 思考」。 | +| Worker | **演员** | Worker / 执行单元 | UI 标题用中文名;检查器可写演员(Worker)。 | +| Tool | **工具** | Tool | 导演 tool call;气泡可用「工具 · 读黑板」。 | +| Skill pack / recipe(用户选型) | **导演**(选项名) | 能力包 / 配方(旧) | 新建下拉只出现导演选项(世界模拟器、扩写助手…)。 | +| Module / modules pool | **能力** | 组件池(作者文档) | 美学纲领与交互范式等;见 `world-simulator-modules.md`。 | +| Creation flow + Worker 集 | **剧本** | — | 动态谈成;非死选单。 | +| Blackboard | **黑板** | 上下文板 | — | +| Artifact | **产物** | — | 待验收输出。 | +| Accept / Review | **验收** / **接受** | — | 按钮用「接受」;阶段说明用「验收」。 | +| Intake | **需求描述** | 启动填空 | — | +| Burst | **本轮调度** | — | 少对用户说 burst。 | +| Questions card | **询问卡** | Questions | 详见 `ui-design.md` §8。 | + +--- + +## 4. 创作期磁盘 Worker(高频) + +| 内部 id | 用户可见 | 备注 | +|---------|----------|------| +| `design-flow` | **创作 · 流程编排** | 以已选【导演】为起点,编排/增量修订可变 DAG → 写入剧本(创作流程) | +| `design-step` | **创作 · 执行步骤** | 按剧本执行当前【能力】 | +| `opening-generator` | **开局 · 开场白** | 创作末尾可选 | +| `design-core` 等 | (已废弃) | 旧分步 skill;勿再调度 | + +标题动作后缀(拼在中文名后): + +| 动作 | 后缀 | +|------|------| +| 运行中 | (可省略或「执行中」) | +| 产出 / 已完成 | **· 产出** | +| 提问 | **· 提问** | +| 占位 | **· 占位** | + +--- + +## 5. 游玩期常用演员(缺省) + +声明里可覆盖;无中文名时用下表: + +| 内部 id | 用户可见 | +|---------|----------| +| `narrator` | **叙事转述** | +| `role-decide` | **角色决策** | +| `world-simulator` | **世界推演** | +| `round-present` | **回合呈现** | + +导演选项展示名示例:`world-simulator`(recipe)→ **世界模拟器**;`expand-assistant` → **扩写助手**。 + +**创造演员时**:规格里另写 `name`(中文展示名)。`ref` 仍用英文 kebab;UI 优先 `name`。 + +--- + +## 6. 创作单位 id(检查器 / 进度) + +| 内部 id 形态 | 用户可见规则 | 示例 | +|--------------|--------------|------| +| `phase:core` | **单位 · 核心** | — | +| `phase:refine` | **单位 · 细化** | — | +| `worker:{ref}` | **演员 · {中文名或 ref}** | `worker:narrator` → 演员 · 叙事转述 | +| `fixed:{topic}` | **能力 · {话题中文}** | `fixed:aesthetics-interaction` → 能力 · 美学纲领与交互范式 | +| `resident:{id}` | **常驻 · {id 或名}** | — | + +能力话题建议译名: + +| topic | 用户可见 | +|-------|----------| +| `aesthetics-interaction` | 美学纲领与交互范式 | +| `interaction` | 交互范式(旧;已并入上一行) | +| `narrative_guide` | 叙事指南 | +| `input_protocol` | 输入协议 | +| `core_premise` | 核心前提 | +| `aesthetics` | 美学纲领(旧;已并入美学纲领与交互范式) | + +--- + +## 7. 等待态 / 焦点(对用户) + +| `waitingReason.kind` | 状态摘要(短) | 焦点动作 | +|----------------------|----------------|----------| +| `intake` | 描述需求 | 描述创作需求 | +| `input` | 补充说明 | 补充说明 / 回答追问 | +| `worker_questions` | 回答提问 | 回答 · {中文演员名} | +| `approve_step` | 确认执行 | 建议调用 {中文名} | +| `review_artifact` | 验收产物 | 验收产物 | + +--- + +## 8. 禁止出现在用户主路径上的写法 + +- `Worker · design-core`、`run_worker(design-core)` +- 顶栏 / pill 写 `design` / `play` 英文 +- 「总管会调度 design-core」(应写「导演会先谈剧本」或「将开始:创作 · 流程编排」) +- 新建作品同时出现「导演 + 配方」两层选择 +- 询问卡标题直接写 `design-core` + +技术日志、导出里的「调试附录」、开发者文档不受本条限制,但默认导出给用户的 Markdown 应走同一套映射。 + +--- + +## 9. 相关文档 + +| 文档 | 关系 | +|------|------| +| `ui-design.md` | 布局与心流;文案须服从本文 | +| `architecture.md` | 内部术语;用户侧以本文为准 | +| `daily-use-p0.md` | 导演 / 剧本产品定义 | +| `world-simulator-modules.md` | 【能力】与导演(recipes)作者清单 | +| `creation-playbook.md` | 创作/游玩流程概念 | +| `design-orchestrator-guide.md` | 创作方法(可继续写英文 id,面向作者) | diff --git a/docs/worker-skill-format.md b/docs/worker-skill-format.md index 2b9812c..5bbc2d0 100644 --- a/docs/worker-skill-format.md +++ b/docs/worker-skill-format.md @@ -1,279 +1,106 @@ # Worker Skill 格式 -## 1. 定位 +> **文档层级:Worker / 声明契约格式(非系统架构)。** +> 上下文编译原则见 [`architecture.md`](./architecture.md)、[`context-assembly.md`](./context-assembly.md)。 -**Worker Skill** 服务 **Worker Agent**:规定 **读哪些 tag、写哪些 tag、怎么做** 本阶段产出。 +## 1. 定位(现行:声明驱动) -Worker **从属于某一个总管 Skill 包**,不与其它 skill 共享。 +**默认包 `world-simulator`:** ```text -总管:run_worker(write-rules) - → Runtime 读 workers/write-rules/SKILL.md 的 inputTags / outputTags - → 从黑板取匹配条目 → Worker 执行 → 写回 outputTags +play 时执行契约 = accept 后的 设计.worker集 某条 workers[](实例 Worker 声明) +可选模板 = worker-templates/{ref}.yaml(design-intake 合并默认值) +磁盘 SKILL.md = 仅创建阶段必要 worker(现:design-intake) ``` -规格背景见 `docs/tag-blackboard.md`、`docs/context-assembly.md`。 +总管 `run_worker(id)` → Runtime 校验 id ∈ Worker 声明 → 从 **Worker 集条目**(+ 可选模板合并)拼 prompt → 写声明的 `outputs`。 + +**不要**为每个 play ref 预置 `workers/narrator/SKILL.md`;实例差异写在 Worker 集里。 + +规格见 `docs/tag-blackboard.md`、`docs/context-assembly.md`、`skills/dialogue/world-simulator/worker-templates/`。 --- -## 2. 存储位置 +## 2. 创作阶段 Worker(磁盘 SKILL.md) + +仅包内 **design 专用** worker 用磁盘文件: ```text -skills/novel/weird-rules-short/ +skills/dialogue/world-simulator/ ├── orchestrator.md +├── worker-templates/ # 可选模板,非执行文件 └── workers/ - ├── write-rules/SKILL.md - └── review-infer/SKILL.md + └── design-intake/SKILL.md ``` -- 目录名 = 包内 **worker id**。 -- 文件统一 **`SKILL.md`**。 -- **没有** 全局共享 worker 目录。 +- 目录名 = worker id。 +- 文件名固定 **`SKILL.md`**。 --- -## 3. Frontmatter +## 3. Design Worker Frontmatter(示例) ```yaml --- -id: write-rules -skill: weird-rules-short -name: 规则与解析创作 -description: >- - 从 需求.核心要点 推演内部核心,产出规则与说明。 -version: 1 +id: design-intake +skill: world-simulator +name: 实例设计 · Worker 集 +stage: design inputTags: - - "需求.核心要点" - - "验收.读者视角.记录" - - "验收.作者视角.记录" - - "用户.修改说明" + - "用户.需求" outputTags: - - "核心.危险.隐藏" - - "规则.草稿" - - "规则.说明.草稿" + - "设计.worker集" + - "设计.worker集.草稿" inputMerge: latest --- ``` | 字段 | 用途 | |------|------| -| `id` | 包内 skill id,与 manifest 注册表一致 | -| `skill` | 所属 orchestrator 包 name | -| `inputTags` | Runtime 从黑板取数的 tag(精确或 `前缀.*`) | -| `outputTags` | 允许写回的 tag;Runtime 校验 | -| `inputMerge` | 可选,`latest`(默认)或 `concat` | -| `contextSegments` | 可选,上下文拼接:上半 static、下半 dynamic(见 §3.1) | -| `contextIsolation` | 可选:`none` \| `role_pov` \| `blind_review` | +| `id` | worker id | +| `skill` | 所属包 name | +| `inputTags` / `outputTags` | 黑板读写白名单 | +| `contextSegments` | 可选;上下拼接 | -### 3.1 contextSegments(上下文拼接) +### 3.1 contextSegments -见 `docs/context-assembly.md`。示例: - -```yaml -contextSegments: - - id: brief - tier: static - tags: ["book.brief"] - label: "## 创作需求" - - id: history - tier: dynamic - tags: ["运行.事件流"] - policy: tail_lines_80 - - id: turn - tier: dynamic - tags: ["可见信息", "用户.最新输入"] - label: "## 本轮" -``` - -未声明时 Runtime 回退为 JSON `inputs`(当前实现)。 - -**review-infer 示例**(不得读隐藏核心): - -```yaml -inputTags: - - "需求.核心要点" - - "规则.草稿" - - "规则.说明.草稿" -outputTags: - - "验收.读者视角.记录" -``` - -**review-author 示例**(可读隐藏核心): - -```yaml -inputTags: - - "需求.核心要点" - - "核心.危险.隐藏" - - "规则.草稿" - - "规则.说明.草稿" -outputTags: - - "验收.作者视角.记录" -``` +见 `docs/context-assembly.md`。 --- -## 4. 正文章节 +## 4. 实例声明字段(写入 `设计.worker集`) -```markdown -# 标题 - -## 角色与口吻 -## 能力范围 # 能做什么 / 不能做什么 -## 思维链与自检 -## 上下文用法 # 各 inputTag 如何使用(不重复 frontmatter 列表) -## 输出格式 # 各 outputTag 的 content 格式 -## 示例 # 可选 -``` - -正文中用 **tag 名** 指代上下文,例如「读 `需求.核心要点`」而非旧 key `book.brief`。 - -### 评估类 Worker - -总管 orchestrator 只写:`rules 确认后 → run review-infer`。 - -本 SKILL 写 **评估怎么做**、verdict 写入 `验收.*.记录` 的 JSON 形状等。 - -### 用户回合 worker(user-turn) - -**用途:** 该环节 **完全由用户输入** 组成,LLM 不替用户选行动(21 点玩家、线下人类一方等)。 - -**与 role-decide 的区别:** - -| | role-decide | user-turn | -|--|-------------|-----------| -| 决策 | LLM 产出 `.思考` + `.行动` | 用户经 ask_user 提供;worker **只**写 `.行动` | -| LLM | 需要 | 仅需展示/校验/格式化(可无生成模型) | - -**frontmatter 示例:** +design-intake 产出的每条 worker: ```yaml -id: user-turn -skill: blackjack-roleplay -name: 用户回合 -description: 展示局面,收集用户合法行动,写入角色.用户.行动 -inputTags: - - "角色.用户.可见信息" - - "场景.公开叙述" -outputTags: - - "角色.用户.行动" -``` - -**SKILL 正文要点:** - -```markdown -## 角色 -你是 **用户操作的采集器**,不是玩家 AI。禁止替用户选择行动。 - -## 执行 -1. 读可见信息与合法行动集 -2. ask_user:简短展示局面 + 列出可选行动 -3. 校验用户输入是否在合法集内;不合法则再问 -4. 写 `角色.用户.行动`(行动选择 + 可选说话) - -## 禁止 -- 调用 LLM 模拟用户策略 -- 写入 `.思考`(用户无内心 tag,或仅 UI 留空) -``` - -编排:总管在轮到用户时 `run_worker(user-turn)`;world-engine 与 role-decide **同一套** 读 `.行动` 规则。 - ---- - -## 5. 运行时输出协议 - -Worker LLM 返回 JSON(Phase A);Phase B 改为 tool call。语义不变: - -```json -{ - "outputs": { - "规则.草稿": "...", - "规则.说明.草稿": "..." - }, - "summary": "50字以内摘要", - "askUser": null -} -``` - -- `outputs` 的 key 必须是 **outputTags 中的 tag**(或与 tag 一一映射的别名,由 Runtime 归一化)。 -- 缺信息时 `askUser` 提问,不臆造。 - -Runtime 写黑板: - -```ts -{ - id: "...", - tag: "规则.草稿", - content: "...", - source: "write-rules", -} -``` - ---- - -## 6. ask_user - -任何 worker 可中途提问。Runtime 暂停并保存 `resumeContext`(workerId 等);恢复时 **重新** 从 SKILL 读 inputTags,不依赖总管。 - ---- - -## 7. 命名原则 - -Worker id 按 **本包流程职责** 命名,包内唯一: - -| 包 | worker id | 职责 | -|----|-----------|------| -| weird-rules-short | write-rules | 写规则 | -| weird-rules-short | review-infer | 读者视角验收 | -| novel-standard | outline | 大纲 | - -不要设计全局共享 worker id。 - ---- - -## 8. 与代码的关系 - -| 文档 | 代码 | -|------|------| -| frontmatter inputTags / outputTags | `src/skills/loader.ts` → `ParsedWorkerSkill` | -| 运行时取数 | `src/worker/executor.ts` | -| 角色 worker 独立 LLM | `llmProfileId` / `llm-bindings.yaml` | `src/skills/worker-llm.ts` | - -当前代码仍为旧 `inputKeys` / `outputKeys` 模型;迁移以 `tag-blackboard.md` 为准。 - ---- - -## 9. Worker 独立 LLM(可选,预留多 AI 博弈) - -默认:worker 与会话 **同一 ApiProfile**(设置页当前选中的 profile)。 - -### 9.1 Worker SKILL frontmatter - -```yaml -llmProfileId: "" -``` - -省略 = 走 skill 包 `llm-bindings.yaml` 或会话默认。 - -### 9.2 Skill 包 llm-bindings.yaml - -```yaml -defaultProfileId: null # null = 会话默认 - workers: - world-engine: {} - role-decide: - byRole: - A: "" - B: "" + - ref: narrator # 能力库 id;null = gap + role: transcription + duty: … + when: … + rationale: … + context: + static: [设计.worker集] + dynamic: [运行.本轮.裁决] + outputs: [输出.用户展示] + presentation: + tone: … ``` -Runtime 解析顺序见 `src/skills/worker-llm.ts`。 -`role-decide` 按 `slots.世界.当前角色.id` 匹配 `byRole`。 - -### 9.3 设计意图 - -- 配置仍在 **profiles.json**(或 .env),不在 SKILL 里写密钥 -- 同一 skill 可让不同角色用不同模型/API,实现真实多 agent 博弈 -- 总管 LLM 不受 worker 绑定影响(始终会话默认) +未写全的 `context`/`outputs` 可由 `worker-templates/{ref}.yaml` 合并。 --- + +## 5. 执行要点 + +- Agent **不**指定 inputTags;读 Worker 声明 / 模板。 +- `ref: null` + `gap`:声明了职责但无模板 / SKILL,需补声明或 temp worker。 +- 验收:`design-intake` 默认 `user_confirmed`;play 中间 worker 可 `no_confirmation`。 + +## 相关 + +| 文档 | 关系 | +|------|------| +| `creation-playbook.md` | 创作流 | +| `worker-declaration.ts` | Runtime 声明校验 | +| `worker-templates/README.md` | 可选模板 | diff --git a/docs/world-simulator-modules.md b/docs/world-simulator-modules.md new file mode 100644 index 0000000..44d1b54 --- /dev/null +++ b/docs/world-simulator-modules.md @@ -0,0 +1,139 @@ +# 世界模拟器 · 导演与能力撰写清单 + +> 给作者用。用户侧术语:**`docs/ui-glossary.md` §0**。 +> 运行:选导演 → 编排**增量**剧本 DAG → `design-step` 执行能力;可再扩步反复调用。 +> **标准范例**:`modules/aesthetics-interaction/prompt.md`。 +> **给外部 AI 的完整泛用规范**:**`docs/briefs/capability-authoring-brief.md`**(项目概述 + 称呼 + 格式契约)。 + +## 两层 + +```text +【导演】recipes/ 【能力】modules/ + └─ 编排增量 DAG → design-step 注入能力块 →(open 则再编排)→ 演员上场 +``` + +--- + +## 能力文档格式(程序可切割) + +每个能力 = `modules/{id}/prompt.md` + `catalog.yaml` 一行。 + +程序**只认 fence 语言标签**切割,不认散文/`##` alone: + +| 块 id | 必填 | 用途 | +|-------|------|------| +| `meta` | 建议 | YAML:name / id / artifact / declaration / when / when_not / boundary | +| `opening` | 可选 | **默认问题**正文;程序发给用户,不经 LLM | +| `task` | 是 | 本步任务与验收边界 | +| `principles` | 建议 | 原则 | +| `probe` | 建议 | 追问策略 | +| `output` | 是 | 产物形状(多为 JSON) | +| `checklist` | 建议 | 自检 | +| `examples` | 可选 | 好/坏对照 | + +````markdown +# 能力中文名 + +## meta +```meta +name: … +id: … +artifact: 设计.… +declaration: … +``` + +## opening +```opening +(用户看到的开场白;可省略整块 = 本步直接调 LLM) +``` + +## task +```task +… +``` + +## principles +```principles +… +``` + +## probe +```probe +… +``` + +## output +```output +{ … } +``` + +## checklist +```checklist +- [ ] … +``` +```` + +切割实现:`parseModulePromptSections` / `extractModuleOpening` / `formatModulePromptForLlm`(`src/skills/creation-flow.ts`)。 +注入 LLM 时按块顺序拼接,**不含** `opening`(开场已由程序发出)。 + +### catalog 一行(插入导演提示词) + +| 字段 | 作用 | +|------|------| +| `id` / `name` / `declaration` / `artifact` | 选型与产物映射 | +| `repeatable` | 可选;`true` = 允许同能力多次编入增量 DAG | +| `opening` | 可选覆盖;一般只写在 prompt 的 `opening` 块 | + +### 默认问题节奏(通用) + +```text +程序发 opening → 用户首答 → LLM(opening + 首答 + 切割后的方法块 + 依赖) +``` + +--- + +## 目录与清单 + +```text +modules/catalog.yaml +modules/{id}/prompt.md +recipes/world-simulator|expand-assistant/recipe.yaml +``` + +世界模拟器**可能用到**的能力(编排按需选用,勿默认全选): + +| 能力 | id | 状态 | +|------|-----|------| +| 美学纲领与交互范式 | `aesthetics-interaction` | **范例已写** | +| 实现机制 | `mechanism` | **已写** | +| 世界蓝图与人文地理 | `world-blueprint` | **已写** | +| 生成规则 | `generation-rules` | 骨架,**可反复** | +| 具体实例 | `concrete-instances` | 骨架,**可反复** | +| 拓扑图谱 | `topology` | 骨架,待细写 | +| 设计状态栏 | `status-bar` | 骨架,待细写 | +| 叙事指南 | `narrative` | 骨架,待细写 | +| 变量设计与更新规则 | `variable-design` | 骨架,待细写 | +| 变量控制上下文 | `variable-context` | 骨架,待细写 | +| 设计回复格式 | `reply-format` | 骨架,待细写 | + +共用收成(池内保留,按需): + +| 能力 | id | 状态 | +|------|-----|------| +| Worker 规格 | `worker-spec` | 骨架,**可反复** | +| 细化终稿 | `refine` | 骨架,待细写 | + +| 导演 | 状态 | +|------|------| +| 世界模拟器 | 建议第一步:美学纲领与交互范式 | +| 扩写助手 | 待完善 | + +--- + +## 验收 + +1. 只选导演 → 出**近期**创作流程(`status=open`) +2. design-step 能切割出 `opening`/`task`/… +3. 有 `opening` 时先程序开场再 LLM +4. 可追加同能力多次(不同 step.id);收成前 `status=closed` +5. UI 用拍摄术语 diff --git a/skills/README.md b/skills/README.md index d88827d..1a0160b 100644 --- a/skills/README.md +++ b/skills/README.md @@ -1,40 +1,41 @@ # Skill 包索引 -总管 skill 以 `orchestrator.md` + `registry.yaml` 注册后才会出现在启动列表。 -本目录下 **仅有 README 的文件夹** 为规划占位,AI 不会加载。 +> 用户侧术语:**导演 / 剧本 / 演员 / 能力**(见 `docs/ui-glossary.md` §0)。 +> 系统内核见 `docs/architecture.md`。 -## 业务 stage 通则 +当前仅注册 **`world-simulator`**(`registry.yaml`)。 -每个 skill 包内 orchestrator 应区分: +## 结构(一层包) ```text -instantiate(实例化) 启动询问 / setup worker → prerequisite tags;现 stageId 常叫 brief -run(运行) 生产 worker 流水线(write / review / outline …) -done finish → 确认稿归档 Book +skills/ + registry.yaml + dialogue/ + world-simulator/ # 默认 skill 包(内部英文 id) + orchestrator.md # 导演调度 manifest + recipes/ # 【导演】选项(用户新建时选) + modules/ # 【能力】共用工序 + workers/ # 创作期磁盘演员 + design-flow/ + design-step/ + opening-generator/ + worker-templates/ # 游玩演员默认契约(合并进 Worker 集) ``` -详见 `docs/tag-blackboard.md` §2。 +## 运行(简) -## 已启用 +```text +用户选【导演】 + → design-flow:编排【剧本】增量 DAG(设计.创作流程,可追加/可反复) + → design-step:逐步执行能力 → … → 设计.worker集 + → (可选)opening-generator + → 用户手动进 play → 按声明调度【演员】 +``` -| 包 | 路径 | instantiate → run | -|---|---|---| -| basic | `novel/basic/` | `book.brief` → outline | -| weird-rules-short | `novel/weird-rules-short/` | `book.brief` → write-rules + 双 review | -| roleplay-game-theory | `dialogue/roleplay-game-theory/` | instantiate → world-engine + role-decide × N + present-round(多 AI 可选) | +路径 / `run_worker` id **保持英文**;中文只出现在 `name` / `declaration` / 正文。 -## 规划中(TODO) +## 相关 -| 包 | 路径 | 说明 | -|---|---|---| -| quick-write | `novel/quick-write/` | 简易档:弱化 tag,全量 LLM | -| interactive-novel | `novel/interactive-novel/` | 长篇:多 tag instantiate + 多轮 run | -| novel-standard | `novel/novel-standard/` | 标准流水线(或与 interactive 合并) | -| scene-roleplay | `dialogue/scene-roleplay/` | 扮演:角色/世界 instantiate + 互动 run | -| world-simulator | `dialogue/world-simulator/` | 世界模拟器:Step1–14 实例化设计 + run(大工程,设计期) | - -**已取消独立包:** `character-card-author` / `character-card-play` — 角色设定与互动并入 `scene-roleplay` 的 instantiate / run,跨 Session 复用走 Book。 - -占位目录 `dialogue/character-card-*` 仅保留说明,不注册。 - -标签命名规范等细节以后补 `docs/tag-vocabulary.md`(低优先级)。 +- 作者清单:`docs/world-simulator-modules.md` +- 包内说明:`dialogue/world-simulator/README.md` +- 创作方法长文:`docs/design-orchestrator-guide.md`(部分章节仍写旧分步名,以本包为准) diff --git a/skills/dialogue/character-card-author/README.md b/skills/dialogue/character-card-author/README.md deleted file mode 100644 index ea7cdf7..0000000 --- a/skills/dialogue/character-card-author/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# character-card-author(已合并概念) - -**不再作为独立 skill 包。** - -角色卡撰写 = **实例化(instantiate)阶段** 的一种产出形态:在扮演类 skill(规划中的 `scene-roleplay`)里,通过启动询问或 setup worker 写入 tag,例如: - -```text -用户.角色需求 | 用户.互动偏好 -角色.A.设定 | 角色卡.口吻样例 | 角色卡.行为边界 -角色卡.确认稿 -``` - -跨 Session 复用角色 → 从 **Book** 加载已有 tag,不必再跑完整实例化。 - -见 `docs/tag-blackboard.md` §2.6、`skills/README.md`。 diff --git a/skills/dialogue/character-card-play/README.md b/skills/dialogue/character-card-play/README.md deleted file mode 100644 index 001b021..0000000 --- a/skills/dialogue/character-card-play/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# character-card-play(已合并概念) - -**不再作为独立 skill 包。** - -角色卡游玩 = 同一 skill 在 **run 阶段** 的互动流水线:prerequisite tags(含 `角色卡.确认稿` 或 `角色.*.设定`)已在 instantiate 填好或从 Book 加载后,总管调度扮演 worker。 - -```text -instantiate 收集/加载角色与世界 tag -run 多轮互动、用户.控制模式、场景反馈 -done 归档 Book -``` - -见 `docs/tag-blackboard.md` §2、`skills/dialogue/scene-roleplay/README.md`(规划)。 diff --git a/skills/dialogue/roleplay-game-theory/llm-bindings.yaml b/skills/dialogue/roleplay-game-theory/llm-bindings.yaml deleted file mode 100644 index 341f85e..0000000 --- a/skills/dialogue/roleplay-game-theory/llm-bindings.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# 可选:为本 skill 包的 worker 指定独立 LLM API(profiles.json 中的 profile id) -# -# 省略本文件、或 profileId 为空 → 全部 worker 使用会话当前默认 API(与总管相同)。 -# 配置多个 profile 后,可为不同角色绑定不同模型,实现「多 AI 博弈」。 - -# defaultProfileId: null - -workers: - setup-scenario: {} - world-engine: {} - role-decide: - # 示例:按决策角色 id 分配不同 profile(把 uuid 换成你本地的 ApiProfile.id) - # byRole: - # A: "00000000-0000-0000-0000-000000000001" - # B: "00000000-0000-0000-0000-000000000002" - present-round: {} diff --git a/skills/dialogue/roleplay-game-theory/orchestrator.md b/skills/dialogue/roleplay-game-theory/orchestrator.md deleted file mode 100644 index 6f67c42..0000000 --- a/skills/dialogue/roleplay-game-theory/orchestrator.md +++ /dev/null @@ -1,441 +0,0 @@ ---- -name: roleplay-game-theory -description: >- - 何时选用:用户想模拟多个不同角色在简单博弈/思想实验处境下的决策与互动 - (如囚徒困境、最后通牒、公共池、信任游戏等)。 - 不适用:自由剧场扮演、写小说章节、长篇叙事、需要复杂世界观的 RPG。 - 产出:结构化博弈实例 +(后续 run 阶段)多角色决策模拟记录。 -category: dialogue -bookKind: dialogue -version: 1 -tags: - - game_theory - - roleplay - - simulation -workers: - - setup-scenario - - world-engine - - role-decide - - present-round -sharedContext: shared-context.md ---- - -# 角色扮演博弈 · 总管 - -你是本 skill 的 **总管**,只负责 **流程调度**:读黑板 → 判断阶段 → `run_worker` / `ask_user` / `finish`。 -不写角色决策、不替 worker 模拟回合——执行细节在包内 `workers/*/SKILL.md`。 -固定体裁规则在 `shared-context.md`,由 Runtime 注入 **本包所有 worker**,总管不读。 - -**当前进度:** instantiate + run(单轮/多轮 simulate + present-round)已定义;`semi_auto` 推进与 programmatic 验收待接入。 - -设计方法见 `docs/skill-design-guide.md`(抽象循环 → L0~L3 分层 → 倒推标签)。 - ---- - -## 架构:三实体 + 展示 + 信息隔离 - -```text -world-engine 中立世界机:发 L3 可见信息 → 收齐行动 → 裁决 → 追加 L2 事件流 -role-decide 各角色独立决策:读 L0~L3 → 写 `.思考`(仅用户)与 `.行动`(agent 可见) -present-round 展示:读 L0 + 本轮产物 + 思考 → 写 `输出.用户展示` -总管 调度轮次;role-decide 须带 workerContext.roleId -``` - -**抽象循环:** 角色行动 → 世界反应 → 角色行动 → …(每轮末 present-round → 用户验收) - -**分工:** 角色内心由 `role-decide` 产出;世界只写客观事实;**用户可见编排由 present-round 产出**,总管不拼长文。 - -**多 AI 博弈(可选):** 包内 `llm-bindings.yaml` 为 `role-decide.byRole` 指定不同 `ApiProfile.id`;默认全部用会话同一 API。 - ---- - -## 启动询问 - -选定本 skill 后,**第一个创作询问**。系统从本节读取问什么、写入哪。 - -**向用户展示:** - -```text -你选择了「角色扮演博弈」。在开始模拟之前,请告诉我: - -1. 实验情境 - - 可直接说经典名(囚徒困境、最后通牒、公共池、信任游戏……) - - 或用自己的话描述一个「每人要选行动、结果取决于组合」的简单局面 - -2. 参与角色(2~4 人即可) - - 每个角色用一句话说明策略倾向(如:算计型、讲公平、怕吃亏、爱冒险) - - 若有想用的称呼可一并说 - -3. 进程 - - 单轮定胜负 / 重复多轮 / 有限 N 轮 / 直到某条件(如有人破产) - -4. 信息结构(可选) - - 大家知道的都一样?有无私密信息或误解? - -5. 输出偏好(可选) - - 要不要看角色思考(`.思考` tag,仅你可见)? - - 偏冷静报告还是带一点场景描写? - -6. 特殊规则或收益(可选) - - 例如:背叛惩罚加倍、允许口头承诺但不具约束力 - -可以一次说完。不必懂博弈论术语——我会整理成可模拟的结构。 -``` - -**必须收集:** - -- 情境(玩什么局面:经典名或自定义) -- 角色(至少 **2 个**参与者,各一句策略倾向) -- 进程(怎么进行、何时结束;未说明时 setup 按单轮默认并标注) - -**可选收集:** - -- 信息结构 -- 输出偏好(思考可见性、叙事风格) -- 特殊规则或收益改动 - -**写入目标:** `用户.博弈需求` - -**足够进入 setup 当:** 上述 3 项必要填空已齐,用户确认后写入 `用户.博弈需求`,再调度 setup-scenario。 - ---- - -## 实例化 - -**职责:** 把 `用户.博弈需求` 整理为结构化 prerequisite tags,供后续 run 阶段模拟 worker 使用。 - -### prerequisiteTags - -运行阶段 worker 启动前,下列 tag 须存在且对应 artifact 为 **accepted**: - -```text -用户.博弈需求 -情境.实验.设定 -博弈.规则.草稿 -博弈.参数.草稿 -博弈.角色列表 -角色.*.设定 (至少 2 条,id 互不重复) -``` - -### instanceReadyWhen - -```text -startupCompleted -且 setup-scenario 产出已被用户 accept -且 角色.*.设定 匹配条目数 ≥ 2 -``` - -等价说法:**instantiate 阶段完成 = 用户确认结构化实例,可进入 run。** - -### setupWorkers - -| worker | 时机 | 说明 | -|--------|------|------| -| setup-scenario | `用户.博弈需求` 已写入,尚无 accepted 的 `情境.实验.设定` | 整理情境 / 规则 / 参数 / 角色 tag | - -### loadFromBook(续开) - -新 Session 绑定已有 Book 时,若 Book 中已有 prerequisite tag 的 **确认稿**,总管可 `ask_user` 是否跳过启动询问与 setup,直接加载后继续 run。 - -**运行快照**(保存某一 run 步、换角色 fork 等)由 Book 层 `run-snapshot-store` 处理,见 `docs/run-snapshot.md`——**不**写进本 orchestrator。 - ---- - -## 产物说明 - -| 产出 | 黑板 tag | 写入者 | 阶段 | 对用户可见 | -|------|----------|--------|------|------------| -| 用户原始需求 | 用户.博弈需求 | 启动询问 / 用户 | instantiate | 是 | -| 实验情境 | 情境.实验.设定 | setup-scenario | instantiate | 是 | -| 博弈规则 | 博弈.规则.草稿 | setup-scenario | instantiate | 是 | -| 模拟参数 | 博弈.参数.草稿 | setup-scenario | instantiate | 是 | -| 角色列表 | 博弈.角色列表 | setup-scenario | instantiate | 是 | -| 角色设定 | 角色.{id}.设定 | setup-scenario | instantiate | 是 | -| 世界状态 | 世界.当前状态 | world-engine | run | 是 | -| 当前轮次 | 世界.当前轮次 | world-engine | run | 是 | -| 事件流(L2 记忆) | 运行.事件流 | world-engine | run | 内部(追加式) | -| 角色可见信息(L3) | 角色.{id}.可见信息 | world-engine | run | 内部 | -| 思考 | 角色.{id}.思考 | role-decide | run | **仅用户**(经 present-round) | -| 行动 | 角色.{id}.行动 | role-decide | run | 用户 + 其他 agent(经 world-engine 公开) | -| 裁决记录 | 世界.裁决.记录 | world-engine | run | 是(仅规则与状态,无剧情) | -| 公开叙述 | 场景.公开叙述 | world-engine | run | 是(客观事实陈述) | -| 回合摘要 | 输出.回合摘要 | world-engine | run | 是(事实摘要;**不含**角色内心) | -| 用户展示 | 输出.用户展示 | present-round | run | 是(验收用主稿) | - -**Book:** `bookKind: dialogue`。实例化确认稿归档后,可在新 Session 复用同一博弈设定。运行快照见 `docs/run-snapshot.md`。 - -**流程概览:** - -```text -instantiate: - 用户.博弈需求 → setup-scenario → [用户验收] → instanceReady - -run(每轮): - world-engine(发牌) - → role-decide × |博弈.角色列表| - → world-engine(裁决;追加 运行.事件流) - → present-round → 输出.用户展示 - → [用户验收](默认每轮确认) - → 若未终局且未达轮次上限 → 下一轮 -``` - ---- - -## 阶段定义 - -| stageId | 名称 | 别名 | 进入条件 | 退出条件 | -|---------|------|------|----------|----------| -| brief | 需求收集 | **instantiate** | skill 已选 | `用户.博弈需求` 已写入 | -| setup | 情境实例化 | **instantiate** | brief 完成 | setup-scenario 产出 **accepted**,且 ≥2 个 `角色.*.设定` | -| simulate | 回合模拟 | **run** | setup 完成 | 终局或达轮次上限,且末轮 **accepted** | -| done | 结束 | **done** | simulate 完成 | — | - -**阶段链:** `brief` → `setup` → `simulate` → `done` - ---- - -## 运行流程 - -**默认推进:** manual。 - -### 暂停(A · 按 worker 产出) - -| worker 产出 | acceptanceMode | 实例化可覆盖? | -|-------------|----------------|----------------| -| setup-scenario 全套 | user_confirmed | 否 | -| present-round → `输出.用户展示` | user_confirmed | 是 → 启动询问「每轮验收 / 每 N 轮 / 仅终局」(写入 `博弈.参数.草稿`) | -| world-engine 发牌/裁决、role-decide | no_confirmation | — | - -### 暂停(C · 本 skill 专属) - -| 检查点 | 何时停一次 | -|--------|------------| -| `every_n_rounds` | 每 N 轮 `输出.用户展示` accept 后(N 由参数;N=1 即每轮) | -| `terminal` | 终局当轮验收后 finish | - -### 用户回合 - -本包 **默认无** user-turn(全员 LLM 角色)。若实例为「人类参与博弈」(如 21 点),在包内增加 `user-turn` worker,编排插入在 world-engine 发牌与裁决之间;见 `docs/worker-skill-format.md` §用户回合 worker。 - ---- - -## 推进策略(预留 semi_auto) - -**默认 manual:** 每轮 `输出.用户展示` 须 `user_confirmed`。 - -未来 `semi_auto` 可在 `## 推进策略` 声明 pauseCheckpoint,例如: - -| id | 何时暂停 | -|----|----------| -| every_n_rounds | 每 N 轮 simulate 后 review_artifact | -| terminal | 终局时 review_artifact | - -链内可省略:`world-engine` / `role-decide` / `present-round` 的 `requiresApproval=false`,`acceptanceMode=no_confirmation`(见 `docs/runtime-state-machine.md` §8)。 - ---- - -## Worker 编排 - -### instantiate 阶段 - -| stageId | 条件 | worker | acceptanceMode | requiresApproval | -|---------|------|--------|----------------|------------------| -| setup | `用户.博弈需求` 非空,无 **accepted** 的 `情境.实验.设定`;或 reject 后重做 | setup-scenario | user_confirmed | true | - -### run 阶段 · 单轮子流程 - -**先读 `博弈.参数.草稿` 中的「决策顺序」**,再按下表调度。 - -#### 同时决策 - -| 步骤 | 条件 | worker | acceptanceMode | requiresApproval | 备注 | -|------|------|--------|----------------|------------------|------| -| 发牌 | instanceReady,且(首轮无 `世界.当前状态` **或** 上轮已裁决且无待收行动) | world-engine | no_confirmation | false | 无 `角色.*.行动` 输入 | -| 决策 | 已发牌,存在角色 R 尚无本轮 `角色.R.行动` | role-decide | no_confirmation | false | **workerContext.roleId=R**;LLM 调用顺序任意 | -| 裁决 | **全部**角色已有 `行动` | world-engine | no_confirmation | false | 有行动输入;追加 L2 | -| 展示 | 裁决完成,尚无本轮 `输出.用户展示` | present-round | no_confirmation | false | | -| 终局 | 展示完成,`输出.用户展示` 待验收 | — | user_confirmed | — | 见验收策略 | - -#### 序贯决策 - -| 步骤 | 条件 | worker | acceptanceMode | requiresApproval | 备注 | -|------|------|--------|----------------|------------------|------| -| 发牌 | 同同时模式 | world-engine | no_confirmation | false | | -| 决策 | 按 `序贯顺序` 找 **第一个** 尚无 `行动` 的 R | role-decide | no_confirmation | false | **一次只跑一个 R** | -| 公开 | R 刚产出 `行动`,且序贯链未结束 | world-engine | no_confirmation | false | **仅**公布 R 的行动选择与说话;**不**做全员裁决 | -| 裁决 | 序贯顺序上 **全部**角色已有 `行动` | world-engine | no_confirmation | false | 全员行动齐后结算 | -| 展示 | 裁决完成 | present-round | no_confirmation | false | | -| 终局 | 展示待验收 | — | user_confirmed | — | | - -> setup-scenario 一次产出含 `博弈.角色列表`(如 `A,B`)与 `博弈.参数.草稿`(含决策顺序)。 -> role-decide:**禁止** 不带 `workerContext.roleId` 调度。 -> world-engine:**禁止** 在缺行动候选时做裁决;**禁止** 在发牌模式写行动;序贯「公开」步 **禁止** 提前结算未决策角色的收益。 - ---- - -## 总管思维链 - -每轮 `planning` 按序检查,**命中第一条即行动**: - -### instantiate - -1. **phase = waiting_user(input)** 且 `用户.博弈需求` 未齐 → `ask_user` 补全必收集项。 -2. **brief 已齐**,无 accepted 的 `情境.实验.设定` → `run_worker(setup-scenario)`,`requiresApproval: true`。 -3. **waiting_user(review_artifact)**(setup)→ 引导用户核对规则与角色。 -4. 用户 **accept** setup → 进入 run(见下)。 -5. 用户 **reject** setup → 收 `用户.修订说明` → 重跑 setup-scenario。 - -### run(simulate) - -6. instanceReady,无 `世界.当前状态` 或需新开一轮(无 pending 行动)→ `run_worker(world-engine)` 发牌。 -7. **读 `博弈.参数.草稿` 决策顺序:** - - **同时**:存在角色 R 尚无 `角色.R.行动` → `run_worker(role-decide)`,**workerContext: { roleId: R }**(顺序任意,须跑齐全员)。 - - **序贯**:按 `序贯顺序` 找第一个尚无行动候选的 R → `run_worker(role-decide)` → 若链未结束 → `run_worker(world-engine)` **公开**(非裁决)→ 再下一 R;若链已齐 → 步骤 8。 -8. 全部角色行动齐(同时模式一次齐;序贯模式链结束)→ `run_worker(world-engine)` 裁决。 -9. 裁决完成,无 accepted 的 `输出.用户展示` → `run_worker(present-round)`。 -10. **waiting_user(review_artifact)**(`输出.用户展示`)→ 展示 present-round 产物;用户可选:**接受产物** / **不接受,重新来** / **说明修改意见**。 -11. 用户 **accept** 回合 → 若终局或达轮次上限 → `finish`;否则回到步骤 6 下一轮。 -12. 用户 **reject**(重新来,无说明)→ 清除本轮行动候选、展示稿与相关草稿 tag → 从步骤 6 重跑本轮。 -13. 用户 **reject**(带修改说明)→ 写入 `用户.修订说明` → 按说明决定重跑 setup 或仅重跑本轮(步骤 6)。 - -**禁止** role-decide 不带 roleId。 -**禁止** 总管撰写可见信息、行动或 **用户展示稿**(由 present-round 产出)。 -**禁止** 向 role-decide 注入 `博弈.规则.草稿` 全文或其他角色的 `.思考` / `.行动` tag(对方言行仅经 world-engine 公开叙述)。 - ---- - -## 调度决策表 - -| 会话信号 | 总管 action | 参数要点 | -|----------|-------------|----------| -| 缺 用户.博弈需求 必收集项 | ask_user | 情境、角色、轮次 | -| 需求齐,无 accepted 情境设定 | run_worker | workerId=setup-scenario | -| 用户 reject setup 产物 | ask_user → run_worker | 收修订意见 → setup-scenario | -| setup accepted,进入 run | run_worker | world-engine(发牌) | -| 某角色未决策 | run_worker | role-decide + workerContext.roleId;同时=可任意顺序跑齐;序贯=只跑序贯顺序上下一个 | -| 序贯:某角色刚决策、链未结束 | run_worker | world-engine(公开,非裁决) | -| 全员行动齐 | run_worker | world-engine(裁决) | -| 裁决完成 | run_worker | present-round | -| 展示稿待验收 | 展示 输出.用户展示 | 用户 accept / reject(重新来)/ 带说明 reject | -| 终局且末轮 accepted | finish | — | - ---- - -## 询问策略 - -### 总管应先问 - -| 何时 | 问题 | 目标 | -|------|------|------| -| brief 不完整 | 什么情境?几个角色各什么倾向?几轮? | 用户.博弈需求 | -| setup 待验收 | 规则看清了吗?角色分得够开吗? | 用户 accept/reject | -| 用户想跳过设定直接「开跑」 | 说明须先 instanceReady | — | -| reject 且未说明原因 | 改规则、改角色还是改轮次? | 用户.修订说明 | - -### 交给 Worker 问 - -| 何时 | 问题 | 负责 worker | -|------|------|-------------| -| setup 执行中 | 情境属于哪类框架?缺收益描述? | setup-scenario | - ---- - -## 验收策略 - -| 阶段 / 产物 | acceptanceMode | 验收者 | 通过后 | -|-------------|----------------|--------|--------| -| setup-scenario 产出 | user_confirmed | 用户 | instanceReady | -| world-engine 发牌 / 裁决 | no_confirmation | 程序 | 可调度 role-decide 或 present-round | -| role-decide 产出 | no_confirmation | 程序 | 下一角色或 world-engine 裁决 | -| present-round 产出 | no_confirmation | 程序 | 进入用户验收 | -| 输出.用户展示 | user_confirmed | 用户 | 下一轮或 finish | - -**user_confirmed 时总管职责:** 展示 `情境.实验.设定`、`博弈.规则.草稿`、`博弈.参数.草稿`、全部 `角色.*.设定`;不省略规则收益部分。 - -**revision:** 用户 reject → 保留 `用户.博弈需求`,追加 `用户.修订说明`(若有)→ 重跑 setup-scenario。 - ---- - -## Worker 独立 LLM(llm-bindings.yaml) - -| worker | 默认 API | 典型独立配置 | -|--------|----------|--------------| -| setup-scenario | 会话默认 | 一般不需要 | -| world-engine | 会话默认 | 可选专用(更「冷」的裁判模型;须严格客观、零叙事) | -| role-decide | 会话默认 | **byRole**:A/B/C 各绑不同 profile,多 AI 博弈 | -| present-round | 会话默认 | 一般不需要 | - -配置见包内 `llm-bindings.yaml`。Runtime 解析优先级: - -```text -worker SKILL llmProfileId → llm-bindings workers[id].byRole[roleId] → profileId → 会话默认 -``` - ---- - -## 与代码的关系 - -| 能力 | 实现 | -|------|------| -| workerContext.roleId | `MainAgentDecision` + phase-runtime 写入 `世界.当前角色.id` | -| 角色 input 隔离 | `filterInputsForRolePerspective`(role-decide) | -| 按 worker 解析 LLM | `resolveWorkerLlmProvider`(`src/skills/worker-llm.ts`) | - ---- - -## 禁用行为 - -### instantiate - -- **禁止** 总管直接撰写结构化实例 tag 正文。 -- **禁止** 在仅 1 个角色设定时标记 instanceReady。 -- **禁止** 跳过 setup 用户验收(requiresApproval: true)。 - -### run - -- **禁止** role-decide 不带 `workerContext.roleId`。 -- **禁止** 向 role-decide 注入其他角色的 `.思考` 或 `.行动` tag(对方言行仅经 world-engine 公开叙述)。 -- **禁止** world-engine 替角色选行动、带角色口吻、**写或概括角色思考**。 -- **禁止** world-engine 做剧情化叙述、心理描写、规则外「合理推测」。 -- **禁止** 在行动未齐时做裁决。 -- **禁止** 调度本包以外 worker。 -- **禁止** 把未 accepted 的草稿当作已定事实。 - ---- - -## 质量评估标准(instantiate) - -| 维度 | 说明 | 检查方式 | -|------|------|----------| -| 情境可模拟 | 局面与决策时点清楚 | 用户 + setup 自检 | -| 规则可执行 | 行动集与收益无歧义 | 用户验收 | -| 角色可区分 | ≥2 角色策略倾向可预测差异 | 用户验收 | -| 参数一致 | 轮次、风格与需求一致 | 用户验收 | -| **接受度** | setup 产物 accept/reject | user_confirmed | - ---- - -## 示例(instantiate) - -**用户:** 「囚徒困境,两个角色:一个很会算计,一个先相信别人;重复 3 轮,要看他们心里怎么想。」 - -```text -→ 写入 用户.博弈需求 -→ run_worker(setup-scenario) -→ 产出 情境.实验.设定 / 博弈.规则.草稿 / 博弈.参数.草稿 / 角色.A.设定 / 角色.B.设定 -→ 用户验收 → accept → instanceReady -→ run_worker(world-engine) 发牌 -→ run_worker(role-decide, workerContext={roleId:A}) -→ run_worker(role-decide, workerContext={roleId:B}) -→ run_worker(world-engine) 裁决 -→ run_worker(present-round) -→ 用户验收 输出.用户展示 → accept →(若还有轮次)下一轮 … -``` - -**用户 reject:** 「B 不是相信别人,是怕冲突的老好人。」 - -```text -→ 用户.修订说明 -→ run_worker(setup-scenario)(input 含 用户.博弈需求 + 用户.修订说明) -→ 再次验收 -``` diff --git a/skills/dialogue/roleplay-game-theory/shared-context.md b/skills/dialogue/roleplay-game-theory/shared-context.md deleted file mode 100644 index df72fd1..0000000 --- a/skills/dialogue/roleplay-game-theory/shared-context.md +++ /dev/null @@ -1,164 +0,0 @@ -# 角色扮演博弈 · 共享上下文 - -本文件由 Runtime 注入 **本包所有 worker**;总管不读。 - -设计方法见 `docs/skill-design-guide.md`(抽象循环 → 上下文分层 → 倒推标签)。 - ---- - -## 定位 - -**角色扮演博弈** = 在简单、类思想实验的博弈处境下,让多个 **有鲜明策略倾向的角色** 各自决策,观察互动结果。 - -参考 **狼人杀 / 德州扑克** 的信息结构: - -```text -world-engine 中立;发 L3「可见信息」、收行动、写 L2 事件流与公开结果 -role-decide 每个角色独立 LLM;读 L0~L3,写思考+行动 -present-round 读 L0 + 本轮产物 + 思考;写用户展示稿 -总管 只调度,不替任何一方思考或拼展示 -``` - -不是:自由剧场、写小说、教学讲义。 -是:结构化情境 + 明确规则 + **信息隔离** 的多角色决策模拟。 - ---- - -## 抽象循环 - -```text -instantiate → run 循环: - world-engine(发牌) - → role-decide × N - → world-engine(裁决 / 序贯公开) - → present-round - → 用户验收 → 下一轮 … -``` - -即:**角色行动 → 世界反应 → 角色行动 → …** - ---- - -## 上下文分层(L0 → L3) - -Worker prompt 内上下文 **按此顺序排列**(上层变动更少): - -| 层 | 内容 | 典型 tag | 变动 | -|----|------|----------|------| -| **L0** | 前提 / 境遇 | `情境.实验.设定`、本 shared-context | 实例化后不变 | -| **L1** | 人设 | `角色.{id}.设定` | 极少改 | -| **L2** | 历史记忆 | `运行.事件流` | 每轮 **追加**,不整段重写 | -| **L3** | 本轮看见 | `角色.{id}.可见信息`、`场景.公开叙述` | 每轮更新 | - -**L2 原则:** 类似聊天记录——world-engine 裁决后追加一条;role-decide 读全流作记忆,**不**依赖 L3 重复携带全部历史。 - ---- - -## 三实体 + 展示 - -| 实体 | worker | 读 | 写 | -|------|--------|-----|-----| -| 角色 A…N | role-decide | L0 情境、L1 本人设定、L2 事件流、L3 可见+公开、参数 | `.思考`(仅用户)、`.行动`(agent 可见) | -| 世界 | world-engine | L0 规则情境、L2、L3 行动 | L3、L2 追加、状态、裁决、回合摘要 | -| 展示 | present-round | L0、参数、本轮摘要/公开/思考/行动、L2 可选 | `输出.用户展示` | -| 总管 | — | tag 索引 | 调度 | - -## 两层输出(角色) - -| 层 | tag | 用户 | 其他 role-decide | world-engine | -|----|-----|------|------------------|--------------| -| **思考** | `角色.{id}.思考` | ✅ | ❌ | ❌ | -| **行动** | `角色.{id}.行动` | ✅ | ✅(经 L3 公开) | ✅ | - -- **行动** = 规则上的 **行动选择** + 对外 **说话** -- **思考** = 内心权衡(思想实验核心观察面,**只给用户与 present-round**) - -**禁止** role-decide 读 `博弈.规则.草稿` 全文(不对称信息由 world-engine 裁进 L3)。 -**禁止** world-engine 读/写 `.思考` 或做剧情化推演。 -**禁止** role-decide 读其他角色的 `.思考` 或 `.行动` 原文(仅 L3)。 - ---- - -## 经典情境参考(worker 可引用) - -| 情境 | 核心张力 | 典型角色分化 | -|------|----------|--------------| -| 囚徒困境 | 个体理性 vs 集体最优 | 算计者、互惠者、怀疑者 | -| 最后通牒 | 公平 vs 收益最大化 | 公平敏感、冷酷最大化、面子型 | -| 公共池 | 短期私益 vs 长期共益 | 搭便车、规范执行、观望 | -| 信任游戏 | 风险与回报 | 冒险信任、条件合作、防御 | - -自定义须写清 **行动集合** 与 **收益随组合变化**(自然语言即可)。 - ---- - -## 角色设定原则(L1) - -每个 `角色.{id}.设定` 须含:称呼、策略倾向、信息立场、行为边界。 -**禁止** 空泛人设而无决策含义。 - ---- - -## 规则表述原则 - -`博弈.规则.草稿` 须让 **world-engine** 能回答:决策时点、合法行动、收益映射、信息对称性、多轮衔接。 - ---- - -## 决策顺序 - -写入 `博弈.参数.草稿`: - -| 模式 | 世界语义 | 总管调度 | -|------|----------|----------| -| **同时** | 互不可见本轮选择,齐后结算 | 发牌 → role-decide × 全员 → 裁决 | -| **序贯** | 后动者见先动者 **已公开** 行动 | 发牌 → role-decide → 公开 → … → 裁决 | - -缺省:同时框架→同时;序贯框架(最后通牒等)→序贯。 - ---- - -## 输出风格 - -**world-engine 不受风格影响**——始终客观事实。 - -| 模式 | role-decide | present-round | -|------|-------------|---------------| -| 分析报告 | 思考简短 | 表格化、少戏剧 | -| 角色内心 | 完整 `.思考` | 思考段完整 | -| 戏剧化 | `.行动.说话` 可带台词 | 可润色 **已公开** 言行 | - -默认:展示思考 + 简短客观摘要。 - ---- - -## Book - -- **确认稿**(情境、规则、角色设定等)跨 Session 复用,见 orchestrator `loadFromBook`。 -- **实例快照**(`kind: instance`):实例化完成后的 **对象**(情境、规则、角色设定),不含轮次进度;见 `docs/run-snapshot.md`。 -- **运行存档**(`kind: run`):run 中某一时刻的完整进度。 - ---- - -## 单轮流程(run) - -### 同时决策 - -```text -world-engine(发牌)→ L3 -role-decide × 全员 → .思考 + .行动 -world-engine(裁决)→ L2 追加、L3、摘要 -present-round → 输出.用户展示 -用户验收 -``` - -### 序贯决策 - -```text -world-engine(发牌) -对每个 id:role-decide → world-engine(公开,非裁决) -world-engine(裁决)→ L2 追加 … -present-round → 用户验收 -``` - -多轮重复直至终局或轮次上限。 diff --git a/skills/dialogue/roleplay-game-theory/workers/present-round/SKILL.md b/skills/dialogue/roleplay-game-theory/workers/present-round/SKILL.md deleted file mode 100644 index fcab5f2..0000000 --- a/skills/dialogue/roleplay-game-theory/workers/present-round/SKILL.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -id: present-round -skill: roleplay-game-theory -name: 回合展示 -description: >- - 将本轮世界反馈与各角色思考、行动按实例化参数编排为用户可读展示稿。 - 属于 run 阶段;不模拟决策、不修改规则或状态。 -version: 1 -inputTags: - - "情境.实验.设定" - - "博弈.参数.草稿" - - "博弈.角色列表" - - "世界.当前轮次" - - "输出.回合摘要" - - "场景.公开叙述" - - "角色.*.思考" - - "角色.*.行动" - - "运行.事件流" -outputTags: - - "输出.用户展示" -inputMerge: latest ---- - -# 回合展示 Worker - -## 角色 - -你是 **面向用户的展示编排者**——把已发生的客观事实与各角色内心思考,按 `博弈.参数.草稿` 中的输出偏好组装成 **一份** 用户展示稿。 - -你不做决策、不改规则、不替 world-engine 补充裁决、不泄露给其他 role-decide。 - -shared-context 已注入(L0 前提)。 - -## 输入分层(按此顺序阅读与编排) - -| 层 | tag | 用途 | -|----|-----|------| -| L0 | `情境.实验.设定` | 始终在最开头点明「正在什么思想实验里」 | -| 参数 | `博弈.参数.草稿` | 输出风格、是否展示思考 | -| 本轮 | `输出.回合摘要`、`场景.公开叙述` | 世界客观反馈 | -| 本轮 | `角色.{id}.思考`、`角色.{id}.行动` | 各角色内心与对外言行 | -| L2 | `运行.事件流` | 可选:前几轮摘要,便于多轮阅读 | - -## 能力范围 - -**可以做:** - -- 按 `输出风格` 组织 Markdown(分析报告 / 角色内心 / 戏剧化) -- 在展示稿 **开头** 用 1~2 句重述 L0 实验名称(勿每段重复) -- 分列「局面 / 各角色思考 / 各角色行动 / 规则结果」 -- 若 `展示思考: 否`,省略或极短概括思考段 - -**不可以做:** - -- 编造未出现在 input 中的行动或裁决 -- 把思考写进「公开局面」段(思考段须与用户专阅层一致,单独小节) -- 修改任何黑板 tag 除 `输出.用户展示` 外 - -## 输出格式 - -### 输出.用户展示 - -```markdown -## 实验 -(来自 情境.实验.设定 的实验名称,一句) - -## 第 N 轮 -(世界.当前轮次) - -### 局面 -(来自 场景.公开叙述 + 输出.回合摘要 的客观内容;无心理描写) - -### 角色思考 -(按 博弈.角色列表 顺序;每角色一小节;展示思考=否 时可省略本段) - -#### {称呼}({id}) -(来自 角色.{id}.思考) - -### 角色行动 -(按 博弈.角色列表 顺序) - -#### {称呼}({id}) -(来自 角色.{id}.行动:行动选择 + 说话) - -### 规则结果 -(来自 输出.回合摘要 的结果部分;纯事实) -``` - -**戏剧化** 风格:可在「局面」「行动」段适度润色 **已公开** 的说话与行动,**禁止** 润色思考为公开对白,**禁止** 添加规则外情节。 - -**分析报告** 风格:思考段可缩短;局面与结果优先表格化。 - -## 自检 - -- [ ] 展示稿开头含 L0 实验名 -- [ ] 思考与公开局面分节,未混写 -- [ ] 所有行动/结果可追溯到 input tag -- [ ] 符合 `博弈.参数.草稿` 的输出风格与展示思考开关 diff --git a/skills/dialogue/roleplay-game-theory/workers/role-decide/SKILL.md b/skills/dialogue/roleplay-game-theory/workers/role-decide/SKILL.md deleted file mode 100644 index 3857642..0000000 --- a/skills/dialogue/roleplay-game-theory/workers/role-decide/SKILL.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -id: role-decide -skill: roleplay-game-theory -name: 角色决策 -description: >- - 当前角色产出两层内容:思考(仅用户可见)与行动含说话(其余角色 agent 可见)。 - 世界机只读行动层做规则裁决与公开分发。 -version: 1 -inputTags: - - "世界.当前角色.id" - - "情境.实验.设定" - - "角色.*.设定" - - "运行.事件流" - - "角色.*.可见信息" - - "场景.公开叙述" - - "博弈.参数.草稿" -outputTags: - - 角色.*.思考 - - 角色.*.行动 -inputMerge: latest ---- - -# 角色决策 Worker - -## 角色 - -你是 **某一个参与者的决策代理**——只代表 `世界.当前角色.id` 所指的角色。 -你不是 narrator、不是裁判;不知道其他角色本轮选了什么(除非 L3 已公开)。 - -## 输入分层(按此顺序理解) - -| 层 | tag | 说明 | -|----|-----|------| -| L0 | `情境.实验.设定` | 你处在什么思想实验里(如囚徒困境、最后通牒) | -| L1 | `角色.{id}.设定` | 你的人设:策略倾向、边界(几乎不变) | -| L2 | `运行.事件流` | 之前各轮已发生的 **公开** 事件(追加式记忆,勿重复改写) | -| L3 | `角色.{id}.可见信息`、`场景.公开叙述` | **本轮** 你看见的内容 | -| 参数 | `博弈.参数.草稿` | 思考详略、表达风格 | - -**禁止** 读 `博弈.规则.草稿` 全文;规则片段仅来自 L3 中 world-engine 裁剪给你的部分。 - -## 两层输出(可见性硬边界) - -思想实验里,**角色如何思考** 是核心观察面,但必须与其他角色能感知到的行为分开: - -| 层 | tag | 谁可见 | 内容 | -|----|-----|--------|------| -| **思考** | `角色.{id}.思考` | **仅用户** | 内心权衡、动机、怀疑、策略推演;**不含**对外说的话 | -| **行动** | `角色.{id}.行动` | **用户** + **其余角色 agent**(经 world-engine 公开) | 规则意义上的行动选择 + **说话**(对外可见言论) | - -```text -思考 → 用户读;其他 agent 永远看不到 -行动 → world-engine 读取 → 写入 公开叙述 / 后续角色的 可见信息 → 其他 agent 据此决策 -``` - -**禁止** 把内心独白、未说出口的打算写进 `行动`。 -**禁止** 把对外说的话只写在 `思考` 里——那会导致其他 agent 听不见。 - -shared-context 已注入。 - -## 能力范围 - -**可以做:** - -- 根据 `角色.{id}.可见信息` 与 `场景.公开叙述` 决策 -- 写 **`角色.{id}.思考`**(完整内心过程) -- 写 **`角色.{id}.行动`**(行动选择 + 可选的对外说话) - -**不可以做:** - -- 读取其他 `角色.*` 的 tag(含对方的思考;对方的行动也只认 world-engine 公开后的叙述) -- 替 world-engine 判定收益或编造规则外结果 -- 编造合法行动集以外的选择 -- 同时决策回合中假设他人已选行动 -- 序贯回合中引用尚未公开的行动 - -## 输入用法 - -| tag | 用法 | -|-----|------| -| 世界.当前角色.id | 你的角色标识(如 A、B) | -| 情境.实验.设定 | L0:实验名称与局面(始终先读) | -| 角色.{id}.设定 | L1:本人人设 | -| 运行.事件流 | L2:过往轮次公开记录(类似聊天记录) | -| 角色.{id}.可见信息 | L3:本轮私有 + 已公开信息(**不含**他人思考) | -| 场景.公开叙述 | L3:所有人已知的客观局面与 **已公开** 言行 | -| 博弈.参数.草稿 | 思考详略、表达风格 | - -## 输出格式 - -### 角色.{id}.思考(仅用户可见) - -```markdown -## 已知事实 -(仅复述可见信息与公开叙述;不加戏) - -## 内心权衡 -(策略倾向、风险、公平、对他人类型的猜测等——**未说出口**) - -## 决策倾向 -(为何倾向某行动;备选与不确定性) -``` - -若 `博弈.参数.草稿` 中 `展示思考: 否`,仍须写本 tag 供归档,可缩短「内心权衡」。 - -### 角色.{id}.行动(用户 + 其他 agent 可见) - -```markdown -## 行动选择 -(从可见信息中的合法行动集选一项;world-engine 规则裁决 **只读此字段**) - -## 说话 -(可选:本情境若允许沟通,写对外说出的原话;无则写「(无)」) -``` - -- **行动选择** = 博弈规则中的离散选项(合作/背叛、出价 50…) -- **说话** = 其他参与者能听见/看见的表述;戏剧化参数下可写台词,但须与行动选择一致 -- **禁止** 在本 tag 写内心独白或未公开意图 - -`{id}` 必须等于 `世界.当前角色.id`。 - -## 与世界机的协作 - -```text -你写:思考(用户专阅) + 行动(含说话,供公开) -世界机读:行动 → 规则裁决 + 将行动/说话写入公开叙述与他人可见信息 -世界机不读:思考 -``` - -## 自检 - -- [ ] 思考与行动严格分离:内心在 `.思考`,对外言行在 `.行动` -- [ ] 行动选择在合法集合内 -- [ ] 未引用其他角色私有信息或未公开行动 -- [ ] output tag 的 id 与 世界.当前角色.id 一致 diff --git a/skills/dialogue/roleplay-game-theory/workers/setup-scenario/SKILL.md b/skills/dialogue/roleplay-game-theory/workers/setup-scenario/SKILL.md deleted file mode 100644 index 03df5a6..0000000 --- a/skills/dialogue/roleplay-game-theory/workers/setup-scenario/SKILL.md +++ /dev/null @@ -1,162 +0,0 @@ ---- -id: setup-scenario -skill: roleplay-game-theory -name: 博弈情境实例化 -description: >- - 从用户.博弈需求 整理结构化情境、规则、角色设定与模拟参数。 - 属于 instantiate 阶段,不执行回合模拟。 -version: 1 -inputTags: - - "用户.博弈需求" - - "用户.修订说明" - - "用户.worker答复" -outputTags: - - "情境.实验.设定" - - "博弈.规则.草稿" - - "博弈.参数.草稿" - - "博弈.角色列表" - - "角色.*.设定" -inputMerge: latest ---- - -# 博弈情境实例化 Worker - -## 角色 - -你是 **实例化执行者**:把用户的口语需求整理成可运行的博弈实例 tag。 -固定上下文(shared-context)已注入——**情境类型、角色原则、规则表述标准** 以此为准。 - -你不模拟回合、不写最终博弈结果。 - -## 能力范围 - -**可以做:** - -- 识别或构造思想实验式情境(含经典变体与合理自定义) -- 为每个参与角色分配稳定 id(`A`、`B`、`C`… 或用户给定短名) -- 写出 `情境.实验.设定`:局面、背景、决策时点 -- 写出 `博弈.规则.草稿`:行动集、收益逻辑、信息结构、多轮衔接 -- 写出 `博弈.参数.草稿`:轮次、决策顺序、序贯顺序、输出风格、是否展示思考 -- 为每个角色写 `角色.{id}.设定` - -**不可以做:** - -- 替角色做第一轮决策或预测结果 -- 引入规则中未声明的「超能力」或无法追溯的任意裁决 -- 把多个角色合并成一个 tag -- 写小说章节正文 - -## 执行顺序 - -```text -读 用户.博弈需求 - → 定情境类型与实验 id - → 列角色清单与 id - → 写 情境.实验.设定 - → 写 博弈.规则.草稿 - → 写 博弈.参数.草稿 - → 写 博弈.角色列表(逗号分隔 id,如 A,B) - → 逐个写 角色.{id}.设定 - → 自检 → 提交 -``` - -## 各 tag 格式 - -### 情境.实验.设定 - -```markdown -## 实验名称 -(简短标题) - -## 情境描述 -(2~5 句:参与者面对什么局面) - -## 决策结构 -- 参与人数:N -- 决策模式:同时 / 序贯 / 混合 -- 轮次:单轮 | 重复 K 轮 | 直到某条件 - -## 信息结构 -(谁知道什么;有无私有信号或隐藏类型) -``` - -### 博弈.规则.草稿 - -```markdown -## 合法行动 -(每个角色在每决策点的行动集合;可表格) - -## 收益与结果 -(行动组合如何映射到收益或状态变化;自然语言即可,须无歧义) - -## 约束与特殊条款 -(可选:承诺、惩罚、沟通轮、随机事件等) - -## 终止条件 -(何时结束、如何汇总多轮) -``` - -### 博弈.参数.草稿 - -```markdown -## 轮次 -(数字或「单轮」) - -## 决策顺序 -同时 | 序贯 -(世界内语义:同时=互不可见本轮选择;序贯=按顺序公开行动) - -## 序贯顺序 -(仅序贯时填写,逗号分隔 id,如 A,B;同时模式可省略) - -## 输出风格 -分析报告 | 角色内心 | 戏剧化 -(仅影响 role-decide 的表达;world-engine 始终客观事实模式) - -## 展示思考 -是 | 否 -(是 → 用户验收时展示各 `角色.{id}.思考`;**仅用户**可见,不注入其他 agent) - -## 备注 -(用户特殊要求、实例化时的假设) -``` - -### 角色.{id}.设定 - -```markdown -## 称呼 -(显示名) - -## 策略倾向 -(如何做决策:风险偏好、公平权重、对背叛的反应等) - -## 信息立场 -(相信什么、怀疑什么、是否误解规则) - -## 行为边界 -(绝不会选的行动或风格) - -## 一句话人设 -(供模拟时快速把握) -``` - -## 自检清单 - -- [ ] 至少 2 个角色,每个有独立 `角色.{id}.设定` -- [ ] 规则中每个角色的行动集已写明 -- [ ] 收益逻辑覆盖主要行动组合,无「由裁判随意决定」 -- [ ] 参数与用户需求一致(轮次、**决策顺序**、风格) -- [ ] 未写入任何回合结果或胜负预测 - -## 缺信息时 - -若存在 `用户.worker答复`,须与 `用户.博弈需求` 一并理解,**勿重复询问已答内容**。 - -通过 `ask_user` 向用户确认(**优先一次问清**): - -- 情境模糊:经典框架二选一,或请用户补行动/收益 -- 角色不足 2 人:请用户补第二个角色倾向 -- 轮次未说明:默认单轮,并告知用户 -- 决策顺序未说明:根据情境类型推断(囚徒困境等同行动→同时;最后通牒等先后手→序贯),写入参数并在备注标注假设 - -**禁止** 在缺关键信息时用纯默认糊过去而不标注假设;若用假设,须在 `博弈.参数.草稿` 的备注中写明。 diff --git a/skills/dialogue/roleplay-game-theory/workers/world-engine/SKILL.md b/skills/dialogue/roleplay-game-theory/workers/world-engine/SKILL.md deleted file mode 100644 index 404ff49..0000000 --- a/skills/dialogue/roleplay-game-theory/workers/world-engine/SKILL.md +++ /dev/null @@ -1,204 +0,0 @@ ---- -id: world-engine -skill: roleplay-game-theory -name: 世界运行机 -description: >- - 中立裁判:仅按规则处理行动、更新世界状态、生成客观事实叙述与各角色可见信息。 - 不做角色式决策、不写内心思考、不做剧情推演。 -version: 1 -inputTags: - - "情境.实验.设定" - - "博弈.规则.草稿" - - "博弈.参数.草稿" - - "博弈.角色列表" - - "世界.当前状态" - - "世界.当前轮次" - - "运行.事件流" - - "角色.*.行动" -outputTags: - - "世界.当前状态" - - "世界.当前轮次" - - "世界.裁决.记录" - - "场景.公开叙述" - - "角色.*.可见信息" - - "运行.事件流" - - "输出.回合摘要" -inputMerge: latest ---- - -# 世界运行机 Worker - -## 角色 - -你是 **中立的世界运行机器**(参考狼人杀主持人 / 德州扑克发牌员): - -- 只对 **已提交的行动** 与 **规则** 作机械反应 -- 输出 **可复核的客观事实**(状态变量、行动组合、规则映射结果) -- 维护 L3(`可见信息`、`公开叙述`)与 L2(`运行.事件流` **追加**) -- **绝不** 替角色做决策、**绝不** 写角色内心、**绝不** 做剧情化推演 - -shared-context 已注入(含 L0~L3 分层说明)。 - -## 硬边界:你只读「行动」,不读「思考」 - -```text -你的输入(来自 role-decide) 你不读、不写 -──────────────────────────────────────────────────── -角色.{id}.行动(行动选择 + 说话) 角色.{id}.思考(仅用户可见) -``` - -- **行动选择** → 规则裁决 -- **说话** → 写入 `场景.公开叙述` 与他人 `可见信息`(客观转述原话,不加心理描写) -- **思考** → 本 worker **无此输入**;不得臆造、概括或泄露 - -**禁止** 读取、引用、概括或复述 `角色.*.思考`(及旧 tag `推理.候选`)。 -**禁止** 在 `场景.公开叙述`、`输出.回合摘要`、`世界.裁决.记录` 中加入: -- 角色心理、动机猜测、性格评价 -- 剧情走向、悬念、气氛、「接下来可能…」 -- 规则未定义的 extrapolation(「合理推测」「为了故事好看」等) -- 文学化场景描写(环境、表情、对话 dramatization) - -有规则依据才写;规则算不出就写「规则未覆盖」或 ask_user,**不** 用叙事填补空白。 - -## 两种运行模式(由输入自动判断) - -```text -若无任何 角色.*.行动 → 开局 / 新轮次「发牌」模式 -若已有全部待决策角色的 行动 → 裁决模式 -若序贯进行中:仅部分角色有 行动,且 博弈.参数 为序贯 → 「公开」模式 -若仅部分角色有 行动 且 同时模式 → ask_user:请总管先补齐缺失角色的 role-decide -``` - -### 发牌 / 新轮次模式 - -- 初始化或推进 `世界.当前轮次` -- 根据 `博弈.规则.草稿` 写本轮回合各角色 **各自能看见什么** → `角色.{id}.可见信息` -- 写 `场景.公开叙述`(**公共桌面上的客观信息**:轮次、已知状态、已公开历史结果) -- **不** 写行动候选、**不** 写任何角色的思考 - -### 裁决模式 - -- 读取所有 `角色.*.行动` 中的 **行动选择** 字段,按规则计算结果 -- 将各角色的 **说话**(若有)客观写入 `场景.公开叙述` -- 写 `世界.裁决.记录`(结构化:输入行动组合 → 规则条款 → 数值/状态变化) -- 更新 `世界.当前状态` -- 写 `场景.公开叙述`(**本轮已发生的客观结果**,所有人可见) -- 为下一轮准备各 `角色.{id}.可见信息`(若终局则写终局可见信息) -- 写 `输出.回合摘要`(**纯事实摘要**,见下) -- **追加** `运行.事件流`(L2 记忆,见下) - -### 序贯 · 公开模式(非裁决) - -当 `博弈.参数.草稿` 为 **序贯**,且仅 **部分** 角色已有本轮 `行动`: - -- **只**公布序贯顺序上 **最新已提交** 角色的 **行动选择** 与 **说话** -- 更新 **尚未决策** 角色的 `角色.{id}.可见信息`(使其能看见已公开行动) -- **不**写 `世界.裁决.记录`、**不**写 `输出.回合摘要`、**不**更新最终收益(等全员齐后再裁决) -- **不**泄露尚未决策角色的任何信息 - -## 信息边界(关键) - -`角色.{id}.可见信息` **不得** 包含: - -- 其他角色的 `思考` / 内心(**永不可写入可见信息**) -- 其他角色尚未公开的行动或说话(同时决策阶段) -- 规则中的隐藏信息(除非该角色设定允许知道) -- 你对局势的「解读」或「建议」 - -`场景.公开叙述` 只含 **所有参与者都已知或刚一起见证的客观内容**——像牌局记录,不像小说段落。 - -## 各 tag 格式 - -### 世界.当前状态 - -```markdown -## 状态摘要 -(结构化键值:收益、关键变量、是否终局;无形容词堆砌) - -## 历史指针 -(可选:已发生轮次列表) -``` - -### 世界.当前轮次 - -纯数字字符串,如 `1`、`2`。 - -### 世界.裁决.记录 - -```markdown -## 本轮行动 -| 角色 | 行动选择 | 说话(若有) | -|------|----------|--------------| - -## 规则适用 -(引用 博弈.规则.草稿 中具体条款,逐步映射) - -## 状态变化 -(前后对比;仅规则推出的变化,可复核) -``` - -### 场景.公开叙述 - -```markdown -(第三人称 **事实陈述**:轮次、各角色已公开的行动选择与说话、状态变化。禁止内心、禁止剧情、禁止推测。) -``` - -示例(好):「第 2 轮。A 选择合作,B 选择背叛。按规则,A 收益 -1,B 收益 +3。」 -示例(坏):「B 冷酷地背叛了信任他的 A,局面变得紧张……」 - -### 角色.{id}.可见信息 - -```markdown -## 他人已公开的言行 -(仅 world-engine 从 行动.说话 / 行动选择 转述的客观记录;**无**对方思考) - -## 你看到的局面 -(该角色视角下 **已知的客观状态**) - -## 你的可选行动 -(列出合法行动集) - -## 你知道的规则片段 -(仅该角色应知道的部分) - -## 私有信号 -(若有;无则省略) -``` - -**不得** 在本 tag 中写「你应该…」「对方可能在想…」等引导性主观内容。 - -### 输出.回合摘要 - -```markdown -## 第 N 轮摘要 -- 各角色行动:(仅列行动,不解释动机) -- 规则结果:(收益/状态变化) -- 终局:(若适用) -``` - -**禁止** 在本 tag 中写角色思考、心理、剧情评价。用户要看思考 → 由 present-round 读各 `角色.{id}.思考`。 - -### 运行.事件流(L2 · 追加式记忆) - -**裁决模式**下须 **在原有内容后追加** 本轮条目(读 input 中的 `运行.事件流`,勿整段覆盖): - -```markdown ---- -## 第 N 轮 -- 公开局面:(一句,来自 场景.公开叙述 要点) -- 各角色行动:(行动选择,不含动机) -- 规则结果:(收益/状态变化要点) -``` - -发牌 / 序贯公开模式 **不** 写事件流(仅裁决后追加)。 -role-decide 读全流作历史;L3 `可见信息` **不必** 重复全部旧轮内容。 - -## 自检 - -- [ ] 裁决后已 **追加** `运行.事件流`,未覆盖历史 -- [ ] 未替任何角色选择行动 -- [ ] 未写任何角色内心或动机 -- [ ] 裁决每一步可追溯到 博弈.规则.草稿 具体条款 -- [ ] 各 可见信息 无交叉泄露 -- [ ] 公开叙述、裁决记录、回合摘要 **三者事实一致** -- [ ] 全文无剧情化、无规则外推测 diff --git a/skills/dialogue/scene-roleplay/README.md b/skills/dialogue/scene-roleplay/README.md deleted file mode 100644 index 36f991e..0000000 --- a/skills/dialogue/scene-roleplay/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# scene-roleplay(TODO) - -## 定位 - -场景扮演 + 角色卡互动(**一个 skill 包**,不拆 author / play)。 - -```text -instantiate 用户.角色需求、角色.{id}.设定、角色卡.*、世界.规则、用户.控制模式 … -run 用户行动 → 世界裁决 → 角色反应 → 输出.场景反馈 → 状态更新 -done 确认稿归档 Book;下一场 Session 可从 Book 加载 instantiate tag -``` - -## instantiate 预期 tag(草案) - -```text -用户.角色需求 | 用户.互动偏好 | 用户.控制模式 -角色.{id}.设定 | 角色卡.口吻样例 | 角色卡.行为边界 | 角色卡.确认稿 -世界.规则 | 世界.当前状态 -场景.当前状态(可选开场) -``` - -可选 **setup worker** 把用户口语整理为上述 tag(仍是 worker,非第二总管)。 - -## run 预期 tag(草案) - -```text -用户.行动输入 | 用户.行动意图 | 用户.导演指令 -场景.可见信息 | 场景.隐藏信息 -角色.{id}.记忆 | 信念 | 行动.候选 | 台词.候选 | 反应 -行动.裁决结果 -输出.场景反馈 | 输出.互动回复 -更新.世界状态 | 更新.角色状态 -``` - -## 下一步 - -- [ ] 写 `orchestrator.md`(## 阶段定义:instantiate → run → done) -- [ ] workers:可选 setup;世界运行、角色、场景反馈、状态更新 -- [ ] 注册到 `registry.yaml` - -见 `docs/tag-blackboard.md` §2。 diff --git a/skills/dialogue/world-simulator/README.md b/skills/dialogue/world-simulator/README.md index aaf4344..1c2b863 100644 --- a/skills/dialogue/world-simulator/README.md +++ b/skills/dialogue/world-simulator/README.md @@ -1,37 +1,32 @@ -# world-simulator(规划中) +# world-simulator -## 定位 +默认 skill 包。术语与作者清单:`docs/ui-glossary.md` §0、`docs/world-simulator-modules.md`。 -RP 代入式交互小说 · **世界模拟器**:用户扮演固定角色,在预先设定好的世界观里遇见不同的人、不同的事。 -交互范式接近 SillyTavern,但本包专精为 **跑团式世界运转**。 - -架构见 `docs/architecture.md`、`docs/skill-design-guide.md`、`docs/context-assembly.md`。 - -## 工程结构 +## 目录 ```text -orchestrator.md manifest:skill 注册表 + 验收 + readiness(待写) -workers/ 各 instantiate / run skill(SKILL.md) -shared-context.md 包级固定上下文上半(待写) -instantiate-orchestrator.md 【遗留】Step1–14 管道草稿;将拆为 workers/ 能力库后废弃主流程地位 +orchestrator.md # 调度 manifest + uiPrompt +recipes/ + catalog.yaml # 导演选项列表 + {id}/recipe.yaml # 单份导演(建议 steps,可调味) +modules/ + catalog.yaml # 能力目录(id / 中文名 / declaration / artifact) + {id}/prompt.md # 能力正文(design-step 注入) +workers/ + design-flow/SKILL.md # 编排剧本骨架 + design-step/SKILL.md # 执行当前能力步 + opening-generator/SKILL.md # 可选开场白 +worker-templates/ + {ref}.yaml # 游玩演员默认契约(写入 Worker 集时合并) ``` -**不注册到 `registry.yaml`**,直至 run manifest 与 readiness 定稿。 +## 运行 -## 实例化(design) +```text +选导演(recipes)→ design-flow → 验收近期 设计.创作流程(status=open) + → 反复 design-step(注入 modules/{id}/prompt.md) + → 不够则再 design-flow(可追加 repeatable 能力)→ closed + → (可选)opening-generator → 手动进 play +``` -不是固定 1→14 管道。agent 在 design stage: - -1. 调 **交互范式** skill → `设计.run_skill清单` -2. 按清单倒推,按需 invoke 其他 instantiate skill(世界蓝图、变量目录、叙事指南…) -3. `declare_instance_ready` → play - -原 `instantiate-orchestrator.md` 中的 Step 表可迁移为 **workers/** 下独立 SKILL.md,供 agent 选用。 - -## 运行(play) - -agent tool loop 内 invoke run skill(世界模拟器、转述者、变量管理…),上下文 **上半固定、下半动态**,见 `docs/context-assembly.md`。 - -## 与 scene-roleplay - -`scene-roleplay` 为通用占位;本包是其 **世界层 + 固定 POV + 跑团式流向** 专精版。 +旧分步 `design-core` / `design-fixed` / `design-worker` / `design-refine` / `design-common.md` **已移除**,勿再添加。 diff --git a/skills/dialogue/world-simulator/instantiate-orchestrator.md b/skills/dialogue/world-simulator/instantiate-orchestrator.md deleted file mode 100644 index 7aa2911..0000000 --- a/skills/dialogue/world-simulator/instantiate-orchestrator.md +++ /dev/null @@ -1,310 +0,0 @@ ---- -name: world-simulator-instantiate -description: >- - 何时选用:正在从零搭建「世界模拟器」skill 包的实例化阶段(设计规格,非运行游玩)。 - 适用:RP 代入式交互小说;用户扮演固定角色,在预设世界观中遇人遇事;偏跑团而非单角色倾向型 AIRP。 - 本 skill 指导初始化 LLM 选择下一步,并逐步撰写 Step1–14。 - 不适用:已实例化完毕只需 run 游玩;写其它 skill 包。 -category: dialogue -bookKind: dialogue -version: 0.3 -tags: - - world_simulator - - instantiate - - design - - rp -relatedSkill: world-simulator ---- - -# 世界模拟器 · 实例化设计引导 - -你是 **实例化设计期的引导 LLM**。职责:在「世界模拟器」大工程里 **一次只推进一个 Step**,把实例化规格写清楚。 - -**不做:** 模拟游玩、调度 run 阶段 worker、一次性写完 Step1–14。 - -运行期总管见同包 `orchestrator.md`(待写)。本文档只服务 **instantiate 规格的设计与填充**。 - ---- - -## 产品概括 - -| 维度 | 定调 | -|------|------| -| **形态** | RP 代入式交互小说;交互范式接近 SillyTavern | -| **本包焦点** | **世界模拟器**:固定 POV 角色 × 预设世界观 × 遇人遇事 | -| **体验** | 偏跑团:世界运转、事件推进、NPC 有轨迹;同时覆盖故事运行与角色扮演 | -| **工程** | 大工程 = **实例化 skill**(本文)+ **总管 skill**(运行)+ worker skill(Step12 倒推) | - ---- - -## 本 Skill 与总管 Skill 的分工 - -```text -【设计期 · instantiate-orchestrator(本文)】 - 用户 + 初始化 LLM - → Step1–14 逐步撰写规格、规则、样例、tag 草案、worker 倒推表 - → 产出:设计文档 + shared-context 草案 + tag 词汇表 + worker 清单 - -【运行期 · orchestrator.md(待写)】 - 选 skill → 启动询问 → setup worker → instanceReady - → run:世界机 / 叙事 / 展示 / 用户回合 … - → done:归档 Book -``` - -设计 Step11 是在 **定 tag 接口**;运行期总管 **只读已定编排**,不在 run 中临场发明 tag 或 worker。 - ---- - -## 实例化步骤总览 - -每一步的正文 **逐步展开**;下表为 **经用户确认的 Step 含义**(v0.3)。 - -| Step | 名称 | 定义(做什么) | 预期产出 | 状态 | -|------|------|----------------|----------|------| -| **1** | 交互范式和美学纲领 | 定 **整体结构、架构**,以及最终呈现给用户的 **整体感受**(沉浸气质、信息层次、节奏感——不是单条 UI 规则) | `设计.交互范式`、`设计.美学纲领` | 待撰写 | -| **2** | 实现机制 | **非程序机制**。实例化进程中的 **锚点设定**:为实现 Step1 而必须写进世界里的 **关键设定**(世界如何「撑住」那套交互与美学) | `设计.实现机制.锚点` | 待撰写 | -| **3** | 故事流向 | **极宽广**。协助定义开场方向、故事可走的主轴(如:高中 → 考试/学习/恋爱;电竞 → 比赛/训练/舆论)——不是细纲,是 **流向域** | `设计.故事流向` | 待撰写 | -| **4** | 世界蓝图 | **整体背景板**:可具体(大陆、势力割据)或抽象(主神空间、无限世界);尺度可小(囚禁的单间)或大(多元宇宙) | `世界.蓝图.草稿` | 待撰写 | -| **5** | 拓扑图形 | **可多次调用的拓扑规格**。不限于地图:职业进阶路径、人物关系网、区域连通……凡满足「节点 + 边 + 约束」的均可 | `世界.拓扑.{id}.草稿`(可多份) | 待撰写 | -| **6** | 生成规则 | **用来生成实例的规则**(元规则)。可多次调用;run 阶段也可用于 **实时生成** 故事内所需内容 | `世界.生成规则.{id}.草稿`(可多份) | 待撰写 | -| **7** | 具体实例 | 用 Step6 规则 **逐条生成** 的示例;每份实例 **必须声明遵循哪条生成规则**。可多次:角色、物品、功法等 | `实例.{类型}.{id}.草稿`(可多份) | 待撰写 | -| **8** | 叙事指南核心 | 指导 **输出 worker 如何整理「正文」**:POV、时态、详略、禁忌、段落习惯 | `shared-context.md` 叙事核心章 | 待撰写 | -| **9** | 语料库与场景策略集 | 口吻样例、场景类型模板、对话/描写/节奏策略 | `语料.场景策略集.草稿` | 待撰写 | -| **10** | 变量管理与变化规则 | 世界/角色/场景 **状态变量**;读写时机;谁有权改;变化约束 | `变量.目录.草稿`、`变量.变化规则.草稿` | 待撰写 | -| **11** | tag 目录与分配规则 | 全包 tag 命名、阶段(草稿/确认稿)、谁写谁读 | `tag.目录.md` | 待撰写 | -| **12** | tag 倒推 worker 管理与上下文继承 | 从 tag 反推 worker 列表、inputTags、隔离与继承 | `workers/` 清单 + 倒推表 + orchestrator 编排草案 | 待撰写 | -| **13** | 设计回复格式 | **最终给用户看的完整组装**(含正文):抬头(日期时间等)、正文区块位置、尾部附加块(状态/选项/meta)。Step8 只管正文;本步管 **整页/整条回复** | `输出.回复格式.规范` | 待撰写 | -| **14** | 开场白与初始变量处理 | 开场白 = **第一次完整输入–输出校验**:须遵循 Step13 格式、Step8 正文、Step10 初始变量;同时定 instanceReady 条件 | `实例.开场.规范`、`instanceReadyWhen` 草案 | 待撰写 | - -### Step8 与 Step13 的分工(重要) - -```text -Step8 叙事指南核心 → 「正文」怎么写、怎么排段落、什么气质 -Step13 设计回复格式 → 「整条回复」怎么组装:壳层 + 正文槽位 + 附加模块 -Step14 开场白 → 用真实开场跑通 8 + 13 + 10,当作验收轮 -``` - ---- - -## 可重复调用的 Step(5 / 6 / 7) - -Step5、Step6、Step7 **不是「各做一次」**,而是 **按 id 多次产出**: - -| Step | 多次调用含义 | 命名建议 | -|------|--------------|----------| -| 5 拓扑图形 | 每张地图、每条进阶树、每个关系网各一份 | `世界.拓扑.{id}.草稿` | -| 6 生成规则 | 每种生成逻辑各一条规则 | `世界.生成规则.{id}.草稿` | -| 7 具体实例 | 每条规则下可生成多个实例;实例须 **引用** 所遵循的规则 id | `实例.{类型}.{id}.草稿` + 元数据 `遵循规则: {ruleId}` | - -**Step7 约束:** 每一份具体实例必须准确对应 **某一条** Step6 生成规则;不可无规则「手写特例」混入实例库(除非该特例本身先补一条规则)。 - ---- - -## 步骤依赖关系 - -```text -Step1 ──→ Step2 ──→ Step3 - │ │ - └──────────┬───────────┘ - ↓ - Step4 ──→ Step5(可多次) - │ - Step4 ──→ Step6(可多次)──→ Step7(可多次,依赖对应 rule id) - │ -Step1,2,3,4 ──────────────→ Step8 ──→ Step9 -Step4,6 ────────────────────→ Step10 -Step8,9,10 ────────────────→ Step11 ──→ Step12 -Step1,8 ────────────────────→ Step13 -Step7,10,13 + 8 ────────────→ Step14 -``` - -**硬依赖:** - -| Step | 必须先有 | -|------|----------| -| 2 | 1 | -| 3 | 1;建议 2 | -| 4 | 1;建议 2, 3 | -| 5 | 4 | -| 6 | 4, 5;建议 2, 3 | -| 7 | 6(对应 rule id) | -| 8 | 1, 2, 3 | -| 9 | 8;建议 3, 4 | -| 10 | 4, 6;建议 2 | -| 11 | 8, 9, 10 | -| 12 | 11 | -| 13 | 1, 8 | -| 14 | 7(至少一个样例实例), 8, 10, 13 | - -**推荐顺序(首次搭建):** - -```text -1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 -``` - -Step5 / 6 / 7 在 run 设计期也可 **追加** 新 id,不必等全链结束。 - ---- - -## 如何选择下一步 - -每轮开始或用户说「继续 / 下一步 / 做 Step N」时: - -### 1. 读取进度 - -检查包内各 Step 产出与 `设计.Step{N}.确认稿` / 多 id 条目(拓扑、规则、实例)是否已有。 - -### 2. 向用户展示 - -```text -世界模拟器 · 实例化设计 - -进度摘要: - Step1 … Step2 … … Step14 … - 拓扑 id 列表:… 生成规则 id 列表:… 实例 id 列表:… - -建议下一步:Step {N} — {名称} -理由:{依赖已满足} - -可选: - A. 按建议做 Step {N} - B. 指定 Step 编号 - C. 为 Step 5 / 6 / 7 新增一个 id(多次调用) - D. 修订某已完成 Step 或某个 id - E. 查看摘要 -``` - -### 3. 默认算法 - -```text -IF 用户指定 Step K 或「新增 拓扑/规则/实例 id」 - → 检查依赖;缺则说明 -ELSE IF 存在「草稿未确认」的 Step 或 id - → 建议先确认或修订 -ELSE - → 按推荐顺序取第一个「待撰写」且依赖已满足的 Step -``` - -### 4. 单 Step 工作模式(命中即停) - -```text -1. 用 3~5 句复述本 Step 目标(用上表定义,勿偷换概念) -2. ask_user:本 Step 关键问题(见下节) -3. 与用户迭代草稿 -4. 写入约定路径 / tag;多 id 步须写清 id 与交叉引用 -5. 自检 -6. 用户确认 → 标「确认稿」→ 停止;提示下次入口 -``` - -**禁止:** 一个 turn 连写多个 Step;未经确认将草稿当定稿。 - ---- - -## 各 Step 启动问句 - -| Step | 至少问什么 | -|------|------------| -| **1** | 整体架构几层(用户 / 世界 / 叙事 / meta)?最终呈现想让人 **感受到** 什么(例:冷峻观测、沉浸第二人称、群像剧场)? | -| **2** | 为撑住 Step1,世界里 **必须钉死** 的设定有哪些(例:信息可见性规则、权力结构、时间粒度)? | -| **3** | 本世界的 **流向域** 有哪些(例:高中:学业/恋爱/社交;电竞:赛事/训练/舆论)?开场倾向哪一条? | -| **4** | 背景板尺度?具体地理 vs 抽象空间?核心冲突与时代? | -| **5** | 本 id 拓扑 **类型**(地图 / 关系网 / 进阶树 / 其他)?节点与边?约束? | -| **6** | 本 id 规则 **生成什么**?输入依赖哪些拓扑/蓝图?确定性 vs 随机?实例化 vs run 实时共用? | -| **7** | 遵循哪条 `生成规则.{id}`?本实例类型与规模? | -| **8** | 正文 POV、时态、篇幅、禁止出现在正文里的 meta? | -| **9** | 需要哪些场景类型模板?固定口吻样例? | -| **10** | 跟踪哪些变量?初始值谁定?变化触发条件? | -| **11** | tag 前缀与生命周期?instantiate / run 分界? | -| **12** | run 循环 sketch;展示 worker 与生产 worker 边界 | -| **13** | 一条回复含哪些 **模块**(抬头字段、正文槽、尾栏)?各模块数据来源 tag? | -| **14** | 开场白文本;初始变量赋值;是否通过格式与叙事自检?instanceReady 条件? | - ---- - -## 单 Step 自检清单 - -```text -- [ ] 与 Step1 整体感受 / 架构无冲突 -- [ ] Step2 锚点未被误写成程序流程或 run worker 调度 -- [ ] Step7 实例已标明所遵循的 Step6 rule id -- [ ] Step8 只管正文;Step13 才定义壳层与模块组装 -- [ ] Step14 明确引用 Step13 格式与 Step10 初始变量 -- [ ] 多 id 产物(5/6/7)id 唯一、可交叉引用 -- [ ] 用户已确认 -``` - ---- - -## 启动询问 - -**向用户展示:** - -```text -你正在搭建「世界模拟器」的 **实例化设计**(不是开始游玩)。 - -请告诉我: -1. 全新开始,还是已有部分 Step / 文档?(可粘贴) -2. 严格按推荐顺序,还是指定 Step / 指定新增拓扑·规则·实例 id? -3. (可选)对标作品或气质(供 Step1、Step3 参考) - -确认后给出「建议下一步」,且 **只做一个 Step 或一个 id**。 -``` - -**设计期 tag(可选):** - -```text -用户.实例化设计.意图 -用户.实例化设计.进度摘要 -设计.Step{N}.草稿 | 设计.Step{N}.确认稿 -世界.拓扑.{id}.* -世界.生成规则.{id}.* -实例.{类型}.{id}.* -``` - ---- - -## 总管思维链(设计期) - -```text -1. 是否在 run 游玩?→ 是则改读 orchestrator.md -2. 当前 Step / id 状态?依赖是否满足? -3. 用户指定 Step 或「新增 id」?否则用默认顺序 -4. ask_user → 只写当前 Step 或当前 id -5. 自检 → 用户确认 → 更新进度 → 命中即停 -``` - ---- - -## 禁用行为 - -```text -- 不要一次性写完 Step1–14 -- 不要把 Step2 写成程序、Runtime 或 API 机制 -- 不要把 Step3 写成细纲或分章目录(它是流向域) -- 不要写无 Step6 rule id 绑定的 Step7 实例 -- 不要在 Step8 里定义抬头/尾栏/状态栏(那是 Step13) -- 不要跳过 Step11 直接写 worker inputTags -- 不要照搬 roleplay-game-theory 的 tag 名;本包 Step11 自定 -``` - ---- - -## 与邻近 skill 的关系 - -| 包 | 关系 | -|----|------| -| `scene-roleplay` | 近亲;本包专精世界层 + 固定 POV + 跑团流向 | -| `roleplay-game-theory` | 仅借鉴设计方法(`docs/skill-design-guide.md`),不照搬 tag/worker | - ---- - -## 当前工程状态 - -```text -instantiate-orchestrator.md v0.3 步骤定义 + 选步逻辑(本文) -README.md v0.2 包索引 -orchestrator.md 未写 -workers/ 未写 -shared-context.md 未写 -Step1–14 正文 未写 -``` diff --git a/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md b/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md new file mode 100644 index 0000000..94f0810 --- /dev/null +++ b/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md @@ -0,0 +1,163 @@ +# 美学纲领与交互范式 + +> 能力标准形范例。程序只切割下方 **fence 块**(`meta` / `opening` / `task` / …);`##` 标题仅供人读。 +> 写法说明:`docs/world-simulator-modules.md`。 + +## meta + +```meta +name: 美学纲领与交互范式 +id: aesthetics-interaction +artifact: 设计.美学纲领与交互范式 +declaration: > + 钉清站位、正文呈现、核心体验与禁忌、何时等用户; + 询问相近故合为一步,勿拆成「交互」「美学」两步 +when: | + 可玩/可沉浸体验,但站位、呈现、核心感觉或轮转边界仍不清时; + 世界模拟类导演通常作为剧本第一步(无上游依赖)。 + 已有等价定稿且用户未要求重谈 → 不要重复排入。 +when_not: | + 只改实现细节且体验契约已验收;纯工具/排版;与「给玩家什么体验」无关。 +boundary: | + 本能力:体验是什么、怎么发生在用户身上、轮转怎么停。 + 叙事指南:世界/助手态度口径(契约已写清的勿重复问卷)。 + 世界蓝图 / 具体实例 / 生成规则:世界内容与可执行规则——本步不定。 + 实现机制 / 拓扑 / 变量* / 状态栏 / 回复格式 / Worker 规格:落到可运行结构——本步不定。 +``` + +## opening + +```opening +先用几句话抓住你要的「变造世界」和体验核心(想到什么写什么,不必整齐): + +1. 原型世界是什么?变在哪里? + 例:丧尸世界,但只有我不会被感染;现代都市,但灵异是真的;也可以只说「现代都市」,变点稍后补。 + +2. 你代入吗? + · 完全代入(我就是那个人,TA 的遭遇≈我的遭遇) + · 部分代入 / 跟着体验(有距离) + · 操控或旁观(角色是棋子/观察对象) + · 其它(请写明) + +3. 最想反复感受到的是什么? + 一句话即可——权力、求生紧张、关系拉扯、解谜、荣耀、禁忌刺激……用你自己的词。 +``` + +## task + +```task +与用户一起钉清一份「体验契约」:给玩家什么感受、如何参与、正文如何呈现、何时等待用户。 + +写入产物 tag(通常为 设计.美学纲领与交互范式)。 + +一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问,不要先交「半份交互」再开「半份美学」。 + +若程序已发出默认问题:用户首答在「用户.worker答复」,开场白在「创作.能力开场白」。禁止重复同一开场;在首答基础上补洞、追问、产出。 +``` + +## principles + +```principles +1. 核心是用户想要的体验;其余询问都是为了碰到这个核心。 +2. 信息不足时宁可少写,不要强行填充。 +3. 做好随时修改的准备;探测时大胆,用户可以说不。 +4. 完善设定时不要忘掉原本的核心体验。 +5. 用户常说不清想要什么 → 展示优于提问、大胆优于保守、去道德化/常态化、具体化;负面信号同样有效。 +6. 变造世界:先抓「原型 + 变在哪里」;核心体验往往来自变种,不要写成设定百科。 +7. 同一设定不同用户要的东西可以完全不同(角斗士:荣耀 / 自由 / 血与酒)——找到核心与禁忌,不是补全世界观。 +``` + +## probe + +```probe +用户首句或黑板已有的直接采纳。一次 askUser 1~2 点,优先具体选项/短场景。 + +【默认问题已覆盖】变造(原型+变点)、代入与否、最想感受到的核心。首答后按缺口补,勿重问已答清的。 + +【参与 · 结构性】 +- 用户与 :代入程度 / 情感距离 / 控制期待 / 多角色 +- 输入解释:主视角行动 vs 世界行动;正文人称(第二人称 / 上帝 / 第一人称…) +- 焦点位置:自己 / 人物 / 群像 / 关系 / 世界规则 / 感觉 / 叙事 +- 满足来源:感同身受 / 互动 / 观察 / 掌控 / 创作质量 +完备:能判断用户站哪、镜头看哪、爽从哪来。 + +【内容 · 深度随焦点】 +核心感觉;人物与关系;身体与感官;背景与规则(宜短);意义与主题。 +变造已定位的,只挖服务核心体验的部分。 + +【轮转】 +思考与抉择、言语:何时等、怎么等、例外、输入后怎么补(只确认/补细节/补心理/连带后果)。 + +【收成三块】 +体验内核(含形状:过程/张力/层次/构成/循环);呈现要点;边界禁忌。 +``` + +## output + +```output +{ + "brief": "一句话:用户要的核心体验", + "变造": { + "原型": "…", + "变点": "…" + }, + "参与": { + "站位": "…", + "代入": "完全|部分|操控|旁观|…", + "用户与user": "情感距离 / 控制期待 / 多角色", + "输入解释": "主视角行动 vs 世界行动等", + "焦点": "主焦点;次要(若有)", + "满足来源": "…" + }, + "呈现": { + "正文人称或体裁": "…", + "系统扮演": "世界执行者 / …", + "输出形态": "单段叙事 / …" + }, + "体验": { + "内核": "最想反复感受到的…", + "形状": "过程|张力|层次|构成|循环|…", + "呈现要点": ["…"], + "边界禁忌": ["…"] + }, + "内容摘要": { + "核心感觉": "…", + "人物与关系": "…", + "身体与感官": "…", + "背景与规则": "…", + "意义与主题": "…" + }, + "轮转": { + "思考与抉择": "何时等、怎么等、例外、输入后怎么补", + "言语": "…" + } +} +``` + +未知字段省略或写「未定」,禁止编造。summary:`美学纲领与交互范式 · …`(点题核心体验)。 + +## checklist + +```checklist +- [ ] 变造:原型与变点是否清楚(或已标未定)? +- [ ] 代入与否 / 站位是否清楚? +- [ ] 最想反复感受到的核心是否一句话能说清? +- [ ] 体验内核 + 呈现要点 + 边界禁忌是否成套(可短,不可互相矛盾)? +- [ ] 重大抉择与言语的等待边界是否够下游引用? +- [ ] 有没有为「完整」强行填充的设定百科? +- [ ] 有没有完善着偏离用户原话里的核心? +- [ ] 是否误写成 Worker 列表? +``` + +## examples + +```examples +好: +- 「丧尸世界,变点是只有我不会被感染;完全代入;最想感到特权下的求生紧张与道德压力。」 +- 用户要艰苦角斗 → 禁忌写「轻松连胜」;选项带短场景。 + +坏: +- 「现代都市,有公司有地铁…」(百科,无变点、无核心感受) +- 用户要爽文角斗,仍按真实艰苦写满受苦 +- 「你想要什么体验?」抽象难答;或一张表全勾选 +``` diff --git a/skills/dialogue/world-simulator/modules/catalog.yaml b/skills/dialogue/world-simulator/modules/catalog.yaml new file mode 100644 index 0000000..f191270 --- /dev/null +++ b/skills/dialogue/world-simulator/modules/catalog.yaml @@ -0,0 +1,98 @@ +# 共用【能力】目录(modules) +# 各【导演】与编排都从这里选型。 +# id = modules/{id}/ 文件夹 +# name = 固定中文名(流程 JSON 的 name;同能力可多次时靠 step.id 区分) +# declaration = 插入导演 / design-flow 提示词的短声明(选型用;勿塞全文) +# artifact = 执行期产物 tag(程序映射;不写进流程 JSON) +# repeatable = true 时允许同能力多次编入增量 DAG(如生成规则、具体实例) +# opening = 可选;覆盖 prompt.md 里 ```opening 默认问题(一般只写在 prompt.md) +# +# 标准范例:aesthetics-interaction(美学纲领与交互范式) +# 后来写能力:同结构 prompt.md(含默认问题)+ 本表一行 declaration +# +# 世界模拟器常用能力见下「体验→世界→机制→呈现→收成」;编排按需选用,勿默认全选。 +# 流程是可变增量 DAG:勿一次排完全程;可反复调用标了 repeatable 的能力。 + +modules: + # —— 体验契约 —— + - id: aesthetics-interaction + name: 美学纲领与交互范式 + declaration: >- + 钉清站位、正文呈现、核心体验与禁忌、何时等用户; + 询问相近故合为一步,勿拆成「交互」「美学」两步 + artifact: 设计.美学纲领与交互范式 + + # —— 世界与内容 —— + - id: world-blueprint + name: 世界蓝图与人文地理 + declaration: >- + 当体验需要可引用的「舞台背景」时使用:钉舞台尺度、熟悉基底与变造、 + 关键格局与人文地理;只细化舞台上会出现的部分,不做设定百科或纸面地图 + artifact: 设计.世界蓝图与人文地理 + + - id: generation-rules + name: 生成规则 + repeatable: true + declaration: >- + 钉内容如何生成/推进的可执行规则(触发、约束、节奏),非散文设定; + 可按题材块反复调用(每次增量补规则) + artifact: 设计.生成规则 + + - id: concrete-instances + name: 具体实例 + repeatable: true + declaration: >- + 钉关键人物/地点/物件等具体实例,供开局与生成锚定;勿堆无关名单; + 可按需反复调用(每次增量补实例) + artifact: 设计.具体实例 + + - id: narrative + name: 叙事指南 + declaration: 世界/助手态度与体验边界(不等于文风;契约已写清的态度勿重复问卷) + artifact: 设计.叙事指南 + + # —— 机制与数据 —— + - id: mechanism + name: 实现机制 + declaration: >- + 当核心体验已经明确,需要识别由哪些角色、世界、关系、规则或情境切面支撑, + 并把体验落成可供后续设定工作的核心支点时使用 + artifact: 设计.实现机制 + + - id: topology + name: 拓扑图谱 + declaration: 钉 worker/表/阶段的依赖、触发与数据流向;一张图说清谁读谁写 + artifact: 设计.拓扑图谱 + + - id: variable-design + name: 变量设计与更新规则 + declaration: 钉变量字段、初值与更新时机/规则;与表副作用对齐 + artifact: 设计.变量设计与更新规则 + + - id: variable-context + name: 变量控制上下文 + declaration: 钉哪些变量如何挂进常驻上下文 / 控制生成口径(可见性与措辞) + artifact: 设计.变量控制上下文 + + # —— 呈现 —— + - id: status-bar + name: 设计状态栏 + declaration: 钉用户可见状态栏:字段、刷新时机、与正文如何拼装 + artifact: 设计.状态栏 + + - id: reply-format + name: 设计回复格式 + declaration: 钉单轮可见输出的结构(正文块、面板、拼接顺序),服务体验契约 + artifact: 设计.回复格式 + + # —— 收成 / 钉声明(共用;世界模拟器按需) —— + - id: worker-spec + name: Worker 规格 + repeatable: true + declaration: 钉一个游玩期执行单元(职责、读写、挂载);多演员时可多次调用 + artifact: 设计.worker规格 + + - id: refine + name: 细化终稿 + declaration: 钉死关键前提、表与副作用,收成可进游玩的规格 + artifact: 设计.worker集 diff --git a/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md b/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md new file mode 100644 index 0000000..84d427a --- /dev/null +++ b/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md @@ -0,0 +1,58 @@ +# 具体实例 + +> **状态:待完善** — 块格式见 `docs/world-simulator-modules.md`;范例见 `aesthetics-interaction`。 + +## meta + +```meta +name: 具体实例 +id: concrete-instances +artifact: 设计.具体实例 +declaration: 钉关键人物/地点/物件等具体实例,供开局与生成锚定;勿堆无关名单 +when: 需要可点名的人/地/物锚定开局或生成时 +when_not: 蓝图骨架未定时就堆长名单;用户明确只要即时生成、不要预置实例时 +boundary: | + 本能力:具体可引用条目。 + 世界蓝图与人文地理:骨架与尺度,非逐条名片。 +``` + +## opening + +```opening +``` + +## task + +```task +钉清关键具体实例(人物/地点/物件等),写入 设计.具体实例。只保留服务体验与开局的条目。 +(待作者细写) +``` + +## principles + +```principles +少而可用;每条要说清为何需要。 +``` + +## probe + +```probe +(待作者细写) +``` + +## output + +```output +{ + "人物": [{ "名": "…", "要点": "…", "为何需要": "…" }], + "地点": [], + "物件": [], + "其它": [] +} +``` + +## checklist + +```checklist +- [ ] 删掉某条会丢掉哪段体验?说不清则删 +``` diff --git a/skills/dialogue/world-simulator/modules/generation-rules/prompt.md b/skills/dialogue/world-simulator/modules/generation-rules/prompt.md new file mode 100644 index 0000000..179264b --- /dev/null +++ b/skills/dialogue/world-simulator/modules/generation-rules/prompt.md @@ -0,0 +1,60 @@ +# 生成规则 + +> **状态:待完善** — 块格式见 `docs/world-simulator-modules.md`;范例见 `aesthetics-interaction`。 + +## meta + +```meta +name: 生成规则 +id: generation-rules +artifact: 设计.生成规则 +declaration: 钉内容如何生成/推进的可执行规则(触发、约束、节奏),非散文设定 +when: 需要把「世界怎么动、内容怎么长出来」写成可执行约束时 +when_not: 仍在谈体验感受、尚未需要可执行规则时 +boundary: | + 本能力:触发、约束、节奏、可否随机等可执行规则。 + 变量设计与更新规则:字段与何时改值。 + 实现机制:落到哪些 worker/表来执行这些规则。 +``` + +## opening + +```opening +``` + +## task + +```task +钉清生成与推进规则,写入 设计.生成规则。须可被下游 worker/程序引用,禁止只写散文氛围。 +(待作者细写) +``` + +## principles + +```principles +可执行优于文采;与体验禁忌对齐。 +``` + +## probe + +```probe +(待作者细写) +``` + +## output + +```output +{ + "触发": ["…"], + "约束": ["…"], + "节奏": "…", + "例外": ["…"] +} +``` + +## checklist + +```checklist +- [ ] 规则是否可被执行/检查,而非纯描写? +- [ ] 是否与体验边界禁忌冲突? +``` diff --git a/skills/dialogue/world-simulator/modules/mechanism/prompt.md b/skills/dialogue/world-simulator/modules/mechanism/prompt.md new file mode 100644 index 0000000..19c6dac --- /dev/null +++ b/skills/dialogue/world-simulator/modules/mechanism/prompt.md @@ -0,0 +1,310 @@ +# 实现机制 + +> 能力文档。程序只切割下方 **fence 块**(`meta` / `opening` / `task` / …);`##` 标题仅供人读。 +> 写法说明:`docs/world-simulator-modules.md`;泛用规范:`docs/briefs/capability-authoring-brief.md`。 + +## meta + +```meta +name: 实现机制 +id: mechanism +artifact: 设计.实现机制 +declaration: > + 当核心体验已经明确,需要识别由哪些角色、世界、关系、规则或情境切面支撑, + 并把体验落成可供后续设定工作的核心支点时使用。 +when: | + 已有「美学纲领与交互范式」或等价的体验声明,但还不能回答: + “这个体验具体靠什么存在?” + “拿掉哪些东西后,核心感觉就会明显变质?” + “哪些设定切面必须被后续世界、角色与规则设计继承?” +when_not: | + 核心体验尚未明确时,不用本能力代替「美学纲领与交互范式」决定想要什么体验。 + 只需要补写完整世界、地点、组织、人物生平或具体事件时,交给「世界蓝图与人文地理」或「具体实例」。 + 需要定义内容如何持续生成、演化或响应时,交给「生成规则」。 + 需要定义变量、状态及更新条件时,交给「变量设计与更新规则」。 + 需要决定文本如何叙述、取舍和呈现时,交给「叙事指南」。 +boundary: | + 本能力:识别支撑核心体验的关键元素及其支撑切面,说明该切面是什么、如何支撑体验,以及缺失后会损失什么。 + 美学纲领与交互范式:决定用户要什么体验、以什么交互关系接近该体验;本能力不重定体验目标,也不重定谁能做什么。 + 世界蓝图与人文地理:把已确认的世界支点展开成完整环境、社会和人文结构;本能力只钉住其中承担体验功能的切面。 + 具体实例:把机制实例化为具体人物、地点、组织或事件;本能力不补齐实例的全部设定。 + 生成规则:定义内容在游玩中如何产生、变化和延续;本能力只指出需要被规则维护的支撑条件。 + 叙事指南:决定如何写出这些体验;本能力不规定文风、镜头、节奏或信息揭示方式。 +``` + +## opening + +```opening +``` + +## task + +```task +你正在执行剧本中的「实现机制」步骤。本步产物写入「设计.实现机制」。 + +这里的「机制」不是一套必须解释到底的科学原理,也不等于程序规则。它更接近科幻小说中可以直接成立的初始设定:后续内容都依赖它,但本步不必为它补写起源史或完整理论。 + +核心操作:从已经确认的核心体验出发,追问「什么在支撑它」,识别那些缺失后会让核心体验不成立、变弱或变质的角色、关系、世界、规则与情境切面。 + +执行顺序: +1. 读取依赖产物和用户表述,提取已经确认的核心体验。只做忠实复述,不重新设计美学纲领或交互范式。 +2. 在思考阶段判断本叙事空间继承了怎样的可能性范围,以此检查候选支撑点是否可能存在。这个「基准世界」只用于推理和校验,不写入产物。 +3. 分别检查体验如何产生、如何增强、如何维持,以及多个支撑点如何共同成立。 +4. 对每个候选点做移除检验:「如果删掉或替换这个切面,核心体验会损失什么?」 +5. 只保留有明确支撑关系的主要支点。常识、装饰、完整人物设定和应由后续能力展开的内容不纳入。 +6. 描述每个支点所属的元素、承担支撑作用的切面、切面的必要结构,以及它与核心体验的关系。 +7. 若支点之间存在重要的前提、对照、制衡、循环或共同支撑关系,再单独说明;关系简单时不要强行建图。 +8. 输出符合 output 契约的 JSON,供用户验收。 + +工作姿态——「识别已经在那里之物」: +- 不凭空另造一套体验。 +- 不把无关设定做成菜单让用户挑选。 +- 不因为某类题材常见某套设定,就直接套用固定机制。 +- 可以把用户已经表达但尚未命名的支撑关系整理成清晰语言。 +- 可以提出基于现有体验的暂定识别,交给用户修正。 +- 如果两种不同理解会导向不同的核心支点,先用一个针对性问题辨明,不要同时堆出多套方案。 + +若依赖产物内部仍有含混或矛盾,不要越权重做上一步。只询问会改变本步支撑识别的关键差异;无法在本步解决的内容写入「开放问题」。 + +若程序已发出默认问题:用户首答在「用户.worker答复」。禁止重复同一开场;在首答与依赖产物上补洞。 + +summary:`实现机制 · …`(点题主要支撑,非题材标签)。 +``` + +## principles + +```principles +1. 体验向支撑正推 + 始终保持:核心体验 → 成立所需条件 → 承担该条件的具体元素 → 该元素真正相关的切面 → 切面如何支撑体验。 + 不能从题材标签、常见套路或预制角色清单反推「应该具有什么」。题材只能提供可能性,不能代替支撑关系。 + +2. 支撑点不是完整设定 + 同一元素可有很多面向,本步只描述承担支撑功能的那一面。 + 例:「丈夫」可有职业、外貌、童年;若核心体验只依赖认知过滤,本步只写认知过滤如何运作及支撑了什么,不补完整人物小传。 + +3. 以缺失后果检验必要性 + 每个支撑点至少能回答其一,最好两个都能答: + - 它如何让某项核心体验产生、增强或持续? + - 缺少它以后,哪项核心体验会消失、减弱或改变性质? + 只能回答「更丰富」「一般都会有」「以后可能用得上」的,不是本步核心支点。 + +4. 机制可以是不同性质的切面 + 不必都是世界法则,也不必形成统一机械系统。可以是: + 角色的认知/欲望/能力/限制/心理张力;关系结构;社会制度/习俗/权力/信息条件; + 环境物理/空间/资源;被直接承认的超常前提;约束/循环/反馈;反衬/代价/对照。 + 按体验所需识别性质,不把所有支点强行改写成规则。 + +5. 基准世界只作内部校验 + 思考阶段判断「什么可能存在」,不要写入产物。最小充分继承: + - 现实地球:默认现实物理、心理、社会规律。 + - 变造地球:继承现实基础,只在用户已表达或体验确实依赖处承认变造。 + - 具体作品世界:优先最近一层作品设定;原作未定义处再参考其底层世界。 + - 类型世界:只继承与当前体验有关的类型约定,不带入类型全部常见元素。 + - 从头创造:显式设定优先;未定义处用最少量常识基础。 + - 混合或二创:只处理与当前支撑点有关的继承与变造,不整理完整谱系。 + 变造可表现为情境极端化、既有效果强化或新增法则。候选超出已知可能性时不要暗中加入;先确认变造是否本在用户设想中。 + +6. 初始设定可以不解释来源 + 解释「它怎样支撑体验」,不要求「宇宙为什么会有它」。 + 超常前提、社会常态或人物特性可作为初始事实成立;不要为显得严谨而补起源、发明者、历史沿革或伪科学理论,除非这些本身就是核心体验的支撑。 + +7. 按真实复杂度描述 + 一句话能说清就用一句话;否则才按过程、张力、层次、构成、循环或关系网展开。 + 多支点关系简单时用自然语言(共同支撑 / 前提 / 反衬 / 制衡);关系确实复杂才展开,不为形式制造图谱。 + +8. 识别盲区,但不替用户下心理诊断 + 可用具体情境、移除检验、对照帮助表达;注意题材标签是否代替真体验、作品引用指向哪一切面、默认条件是否承担支撑、抽象体验能否落到短场景、多项需求是否张力未调和。 + 不能把拒绝解释成隐藏欲望,不能把犹豫诊断为羞耻或自我欺骗。用户明确拒绝即边界;陈述与反馈不一致时只中性复述差异并请求确认。 + +9. 宁少勿多 + 支撑点无预设数量。主要支撑已覆盖、剩余只是常识/装饰/后续展开对象时立即停止。 + 不为显得完整而增加人物、势力、地点、规则或冲突。 +``` + +## probe + +```probe +只追问会改变核心支点、支撑切面或支撑关系的缺口。每轮 1~2 点。依赖产物或用户已答清的禁止重问。 + +优先方式: + +1. 移除检验 + 暂时拿掉疑似支点,问核心感觉是否仍成立。 + 例:「如果他不是在主动隐瞒,而是真的无法识别异常,这种紧张感还是你要的吗?」 + +2. 短场景落地 + 抽象感觉 → 紧贴现有材料的短场景请修正。 + 例:「更接近哪种瞬间:证据已经出现却被他自行合理化,还是他其实察觉了但选择不追问?」 + +3. 支撑关系核对 + 已识别元素但作用不清 → 核对缺失后果。 + 例:「这个身份差距主要制造不可抗拒的吸引,还是让暴露后的代价变得更高?」 + +4. 张力辨认 + 两项体验可能由相反条件支撑 → 放进同一具体描述确认。 + 例:「你既要长期安全,又要濒临暴露;是否意味着真正需要的是『客观上有保护,但角色主观上始终无法确信安全』?」 + +5. 引用拆解 + 引用作品/类型 → 只问与支撑机制有关的切面。 + 例:「你提到这部作品,主要是想保留其中的信息差、人物关系,还是那个无法撤销的超常前提?」 + +提问给出可直接采用或微调的完整句子,不罗列大批名词选项。对照只用于暴露差异,不变成设定菜单。 + +能稳妥推断时:先写成暂定支点并复述,请用户校正;不要要求用户从零发明专业术语。 + +才追问: +- 同一体验有两种会显著改变后续设定的支撑解释; +- 关键支点在当前基准世界不可能成立,须确认是否存在变造; +- 两项已表达体验无法由同一组条件同时维持; +- 缺失信息会决定某元素究竟是不是核心支点; +- 作品引用/类型标签/例子无法判断指向哪一切面。 + +不追问: +- 只缺姓名、外貌、职业等实例细节; +- 只缺完整世界史、地理、组织或技术解释; +- 只是还可增加装饰性设定; +- 后续能力能在不改变本步支点的情况下展开; +- 用户已明确拒绝的内容。 +``` + +## output + +```output +最终只输出一个合法 JSON 对象,不加代码块外说明,不使用注释,不夹带未定义的英文 id。 + +{ + "依据的核心体验": [ + "从依赖产物中忠实提取的体验锚点,不在这里重新设计" + ], + "支撑点": [ + { + "名称": "简短、稳定、对人可读的支撑点名称", + "归属元素": "这个切面属于哪个角色、关系、群体、世界条件、规则或情境", + "支撑切面": "只指出该元素中与核心体验有关的那个面向", + "切面形态": { + "结构": "一句话|过程|张力|层次|构成|循环|关系网", + "概述": "用最短的充分描述说清这个切面是什么样的", + "展开": [ + { + "部分": "仅在确有内部结构时填写", + "描述": "该部分在结构中的位置或作用" + } + ] + }, + "对应体验": [ + "该支撑点直接服务的体验锚点" + ], + "如何支撑": "说明它通过什么条件、限制、对照或张力让体验成立", + "缺失后果": "说明拿掉或改弱这个切面后,体验会失去什么" + } + ], + "支撑点关系": [ + { + "涉及": ["支撑点名称"], + "关系": "前提、共同支撑、反衬、制衡、递进、循环或其它自然语言关系", + "体验作用": "这项关系为何需要被保留" + } + ], + "开放问题": [ + { + "问题": "尚不能可靠确定、且会影响本步结论的问题", + "影响": "不确定性会改变哪些支撑点或支撑关系", + "当前暂定": "若已有最可能的理解,用可撤销的方式写明;没有则留空字符串" + } + ] +} + +填写规则: +- 「依据的核心体验」只建可追溯关系,不得扩写成新的美学纲领。 +- 每个「支撑点」必须同时写清归属元素、支撑切面和支撑关系(如何支撑 + 缺失后果)。 +- 简单支点「结构」填「一句话」,「展开」填空数组。 +- 只有一句话不足以保留关键结构时才用其它结构类型。 +- 「缺失后果」不能只写「体验变差」,必须指出具体损失。 +- 「支撑点关系」只记对体验有实际影响的关系;没有则空数组。 +- 基准世界、推理过程、候选菜单和被淘汰支点不得写入产物。 +- 没有开放问题时填空数组,不要制造问题。 +- 不要输出完整角色卡、世界观百科、剧情大纲、叙事规则、变量表或演员规格。 + +本步完成的定义: +- 核心体验的主要支撑已经被覆盖; +- 每个支撑点都通过了「如何支撑/缺了会怎样」的检验; +- 每个元素只保留了承担支撑作用的切面; +- 所有支点在当前叙事空间中都可能成立,没有暗中引入未经确认的变造; +- 剩余内容属于常识、具体实例或后续能力的展开范围。 +``` + +## checklist + +```checklist +- [ ] 「依据的核心体验」是否忠实来自依赖产物,而非本步新设计? +- [ ] 每个支撑点是否都有:归属元素、支撑切面、如何支撑、缺失后果? +- [ ] 简单支点是否避免了不必要的「展开」? +- [ ] 是否误写成设定菜单、完整人物卡、起源史或叙事文风指南? +- [ ] 是否暗中引入了用户未确认的变造? +- [ ] 宁少勿多:主要支撑已覆盖后是否停住? +- [ ] 「支撑点关系」是否只含对体验有实际影响的项(或空数组)? +- [ ] 开放问题是否都真正影响本步结论(无则空)? +``` + +## examples + +```examples +示例一:简单支撑点 + +核心体验:「秘密长期存在,但每次接近暴露时都令人紧张。」 + +合格: +{ + "名称": "丈夫的认知过滤", + "归属元素": "丈夫", + "支撑切面": "面对威胁完美家庭信念的证据时,会无意识地优先采用无害解释", + "切面形态": { + "结构": "一句话", + "概述": "他的信任不是单纯迟钝,而是一种维护既有信念的认知过滤。", + "展开": [] + }, + "对应体验": ["秘密能够长期存在", "证据接近暴露时的紧张"], + "如何支撑": "认知过滤让可疑证据能够出现而不立刻终结秘密,使暴露风险可以反复逼近。", + "缺失后果": "如果他能稳定、直接地识别证据,秘密会迅速结束;如果完全没有证据出现,濒临暴露的紧张也会消失。" +} +只描述认知切面,不补职业、外貌、成长史。 + +示例二:具有内部结构的支撑点 + +核心体验:「每次获得力量都伴随自我逐渐陌生化的诱惑与恐惧。」 + +合格: +{ + "名称": "力量与自我侵蚀的同步增长", + "归属元素": "超常力量", + "支撑切面": "力量的每次增长都会永久改变使用者的一项感知、欲望或判断方式", + "切面形态": { + "结构": "循环", + "概述": "危机促使角色使用力量,力量解决危机并造成侵蚀,侵蚀又让下一次使用更容易发生。", + "展开": [ + { "部分": "诱因", "描述": "现实危机让使用力量成为最有效的解决方式。" }, + { "部分": "收益", "描述": "力量立即兑现效果,使继续使用具有真实吸引力。" }, + { "部分": "代价", "描述": "每次使用都会留下不可完全逆转的自我改变。" }, + { "部分": "反馈", "描述": "改变后的角色更容易接受下一次使用,循环因此加深。" } + ] + }, + "对应体验": ["力量带来的诱惑", "逐渐失去自我的恐惧"], + "如何支撑": "收益与侵蚀来自同一次行动,角色不能只取其一,因此诱惑和恐惧能够持续共存。", + "缺失后果": "如果侵蚀可以轻易撤销,恐惧会退化成短期成本;如果力量没有即时收益,诱惑则无法成立。" +} +不解释力量由谁创造、完整物理理论或全部侵蚀实例。 + +示例三:支撑点之间的关系 + +{ + "涉及": ["力量与自我侵蚀的同步增长", "身边人对变化的延迟识别"], + "关系": "共同支撑并形成时间差", + "体验作用": "侵蚀让角色真实改变,延迟识别则给变化留下累积空间;两者共同维持「尚能隐藏但终将无法隐藏」的过程感。" +} + +不合格: +- 「可以选择诅咒、寄生物、人格分裂、外星科技或邪神污染…」→ 脱离体验的设定菜单。 +- 「丈夫四十二岁,是律师,外表温和,童年…」→ 把认知切面扩成完整人物设定。 +- 「为了营造压抑美感,叙述应采用近距离视角…」→ 叙事呈现,不是支撑设定。 +- 「这种力量源于三千年前的实验事故…」→ 为可直接成立的初始设定补不必要起源史。 +``` diff --git a/skills/dialogue/world-simulator/modules/narrative/prompt.md b/skills/dialogue/world-simulator/modules/narrative/prompt.md new file mode 100644 index 0000000..d0f5e6c --- /dev/null +++ b/skills/dialogue/world-simulator/modules/narrative/prompt.md @@ -0,0 +1,56 @@ +# 叙事指南 + +> **状态:待完善** — 块格式见 `docs/world-simulator-modules.md`;范例见 `aesthetics-interaction`。 + +## meta + +```meta +name: 叙事指南 +id: narrative +artifact: 设计.叙事指南 +declaration: 世界/助手态度与体验边界(不等于文风;契约已写清的态度勿重复问卷) +when: 需要钉世界/助手态度口径,且美学纲领与交互范式未写清或需单独收口时 +when_not: 契约里已写清态度/禁忌,仅重复问卷 +boundary: 不等于文风;呈现与站位优先读 设计.美学纲领与交互范式 +``` + +## opening + +```opening +``` + +## task + +```task +钉清世界/助手态度与体验边界。写入 设计.叙事指南。 +依赖已验收的美学纲领与交互范式时先读再写;已写清的勿重复问卷。 +(待作者细写) +``` + +## principles + +```principles +(待作者细写) +``` + +## probe + +```probe +(待作者细写) +``` + +## output + +```output +{ + "态度": "…", + "边界": ["…"], + "焦点": "…" +} +``` + +## checklist + +```checklist +- [ ] 与美学纲领与交互范式不重复、不矛盾 +``` diff --git a/skills/dialogue/world-simulator/modules/refine/prompt.md b/skills/dialogue/world-simulator/modules/refine/prompt.md new file mode 100644 index 0000000..8160a9f --- /dev/null +++ b/skills/dialogue/world-simulator/modules/refine/prompt.md @@ -0,0 +1,51 @@ +# 细化终稿 + +> **状态:待完善** — 块格式见 `docs/world-simulator-modules.md`。 + +## meta + +```meta +name: 细化终稿 +id: refine +artifact: 设计.worker集 +declaration: 钉死关键前提、表与副作用,收成可进游玩的规格 +when: 前面步骤已大致谈清,需要收成可进游玩的 Worker 集时 +boundary: 产出设计.worker集 JSON;进 play 由用户手动 +``` + +## task + +```task +钉死关键前提;表/副作用;收成可进游玩的设计.worker集 JSON。 +(待作者细写) +``` + +## principles + +```principles +只钉不能瞎发挥又对体验关键的东西。 +``` + +## probe + +```probe +(待作者细写) +``` + +## output + +```output +{ + "version": 1, + "interaction": {}, + "workers": [], + "resident_context": [], + "tables": {} +} +``` + +## checklist + +```checklist +- [ ] 验收复述含站位、体验核心、worker/表、为何没有某件 +``` diff --git a/skills/dialogue/world-simulator/modules/reply-format/prompt.md b/skills/dialogue/world-simulator/modules/reply-format/prompt.md new file mode 100644 index 0000000..1479fe8 --- /dev/null +++ b/skills/dialogue/world-simulator/modules/reply-format/prompt.md @@ -0,0 +1,60 @@ +# 设计回复格式 + +> **状态:待完善** — 块格式见 `docs/world-simulator-modules.md`;范例见 `aesthetics-interaction`。 + +## meta + +```meta +name: 设计回复格式 +id: reply-format +artifact: 设计.回复格式 +declaration: 钉单轮可见输出的结构(正文块、面板、拼接顺序),服务体验契约 +when: 需要钉单轮「用户看见什么、什么顺序」时(含多块拼接) +when_not: 美学纲领与交互范式里呈现已足够且无多块结构时 +boundary: | + 本能力:单轮可见结构与拼接。 + 设计状态栏:状态栏块的字段细则。 + 美学纲领与交互范式:人称、系统扮演、体验边界——本步不重谈。 +``` + +## opening + +```opening +``` + +## task + +```task +钉清单轮可见回复格式(块、顺序、可选面板),写入 设计.回复格式。对齐已验收的呈现契约。 +(待作者细写) +``` + +## principles + +```principles +结构服务体验;块要少;与状态栏/终稿 tag 约定一致。 +``` + +## probe + +```probe +(待作者细写) +``` + +## output + +```output +{ + "块顺序": ["状态栏", "正文", "…"], + "正文约定": "…", + "可选面板": [], + "终稿tag或拼装说明": "…" +} +``` + +## checklist + +```checklist +- [ ] 是否与美学纲领的呈现/轮转一致? +- [ ] 状态栏块是否指向 设计.状态栏(若有)? +``` diff --git a/skills/dialogue/world-simulator/modules/status-bar/prompt.md b/skills/dialogue/world-simulator/modules/status-bar/prompt.md new file mode 100644 index 0000000..d8bdc14 --- /dev/null +++ b/skills/dialogue/world-simulator/modules/status-bar/prompt.md @@ -0,0 +1,59 @@ +# 设计状态栏 + +> **状态:待完善** — 块格式见 `docs/world-simulator-modules.md`;范例见 `aesthetics-interaction`。 + +## meta + +```meta +name: 设计状态栏 +id: status-bar +artifact: 设计.状态栏 +declaration: 钉用户可见状态栏:字段、刷新时机、与正文如何拼装 +when: 体验需要程序拼状态栏+正文,或用户要持续可见关键状态时 +when_not: 纯单段叙事、明确不要 HUD/状态条时 +boundary: | + 本能力:可见状态栏字段与刷新/拼装。 + 变量设计与更新规则:背后变量如何变。 + 设计回复格式:整轮输出结构(状态栏可为其一块)。 +``` + +## opening + +```opening +``` + +## task + +```task +钉清用户可见状态栏:字段、来源、刷新时机、与正文拼装方式。写入 设计.状态栏。 +(待作者细写) +``` + +## principles + +```principles +只展示影响决策或沉浸的字段;与变量设计对齐。 +``` + +## probe + +```probe +(待作者细写) +``` + +## output + +```output +{ + "字段": [{ "名": "…", "来源": "…", "可见条件": "…" }], + "刷新": "每轮|事件|…", + "拼装": "状态栏在正文前|后|旁|…" +} +``` + +## checklist + +```checklist +- [ ] 每个字段是否有变量/表来源? +- [ ] 是否与回复格式拼装不冲突? +``` diff --git a/skills/dialogue/world-simulator/modules/topology/prompt.md b/skills/dialogue/world-simulator/modules/topology/prompt.md new file mode 100644 index 0000000..0f8a300 --- /dev/null +++ b/skills/dialogue/world-simulator/modules/topology/prompt.md @@ -0,0 +1,59 @@ +# 拓扑图谱 + +> **状态:待完善** — 块格式见 `docs/world-simulator-modules.md`;范例见 `aesthetics-interaction`。 + +## meta + +```meta +name: 拓扑图谱 +id: topology +artifact: 设计.拓扑图谱 +declaration: 钉 worker/表/阶段的依赖、触发与数据流向;一张图说清谁读谁写 +when: 实现机制大致清楚,需要钉依赖边、触发边与数据流向时 +when_not: 尚未决定有哪些执行单元就先画复杂图 +boundary: | + 本能力:依赖/触发/读写流向。 + Worker 规格:单个单元契约。 + 实现机制:总览「要哪些件」,本步钉件与件的边。 +``` + +## opening + +```opening +``` + +## task + +```task +钉清拓扑:单元、依赖、触发、读写流向。写入 设计.拓扑图谱。 +(待作者细写) +``` + +## principles + +```principles +一张图说清;环依赖与隐式读写必须显式写出或拆掉。 +``` + +## probe + +```probe +(待作者细写) +``` + +## output + +```output +{ + "节点": [{ "id": "…", "类型": "worker|表|阶段|…" }], + "边": [{ "from": "…", "to": "…", "关系": "依赖|触发|读写|…" }], + "说明": "…" +} +``` + +## checklist + +```checklist +- [ ] 每个关键读写是否有边? +- [ ] 是否与实现机制列表一致? +``` diff --git a/skills/dialogue/world-simulator/modules/variable-context/prompt.md b/skills/dialogue/world-simulator/modules/variable-context/prompt.md new file mode 100644 index 0000000..1ddb114 --- /dev/null +++ b/skills/dialogue/world-simulator/modules/variable-context/prompt.md @@ -0,0 +1,57 @@ +# 变量控制上下文 + +> **状态:待完善** — 块格式见 `docs/world-simulator-modules.md`;范例见 `aesthetics-interaction`。 + +## meta + +```meta +name: 变量控制上下文 +id: variable-context +artifact: 设计.变量控制上下文 +declaration: 钉哪些变量如何挂进常驻上下文 / 控制生成口径(可见性与措辞) +when: 变量已大致设计,需要规定它们如何进入 worker 上下文与措辞时 +when_not: 尚无变量,或变量仅程序内部、从不进提示词时 +boundary: | + 本能力:变量 → 上下文挂载与生成口径。 + 变量设计与更新规则:字段与改值规则本身。 +``` + +## opening + +```opening +``` + +## task + +```task +钉清变量如何挂进常驻上下文、控制哪些生成口径。写入 设计.变量控制上下文。 +(待作者细写) +``` + +## principles + +```principles +只挂影响生成的变量;措辞服务体验,不泄露不该剧透的内部态(除非体验需要)。 +``` + +## probe + +```probe +(待作者细写) +``` + +## output + +```output +{ + "挂载": [{ "变量": "…", "挂到": "常驻|某worker|…", "措辞要点": "…" }], + "禁止泄露": ["…"] +} +``` + +## checklist + +```checklist +- [ ] 挂载是否与变量设计名单一致? +- [ ] 剧透边界是否与体验禁忌一致? +``` diff --git a/skills/dialogue/world-simulator/modules/variable-design/prompt.md b/skills/dialogue/world-simulator/modules/variable-design/prompt.md new file mode 100644 index 0000000..56226ad --- /dev/null +++ b/skills/dialogue/world-simulator/modules/variable-design/prompt.md @@ -0,0 +1,65 @@ +# 变量设计与更新规则 + +> **状态:待完善** — 块格式见 `docs/world-simulator-modules.md`;范例见 `aesthetics-interaction`。 + +## meta + +```meta +name: 变量设计与更新规则 +id: variable-design +artifact: 设计.变量设计与更新规则 +declaration: 钉变量字段、初值与更新时机/规则;与表副作用对齐 +when: 需要可追踪状态(进度、关系、资源等)且要写清谁何时改时 +when_not: 无状态纯对话、或状态仅散文描述从不程序化时 +boundary: | + 本能力:字段、初值、更新规则、与副作用。 + 变量控制上下文:这些变量如何进入提示词口径。 + 设计状态栏:哪些对用户可见。 +``` + +## opening + +```opening +``` + +## task + +```task +钉清变量字段、初值与更新规则,写入 设计.变量设计与更新规则。与表/副作用命名对齐。 +(待作者细写) +``` + +## principles + +```principles +字段要少;更新规则可检查;禁止隐式改值。 +``` + +## probe + +```probe +(待作者细写) +``` + +## output + +```output +{ + "变量": [ + { + "名": "…", + "类型": "…", + "初值": "…", + "更新": "谁、何时、怎么变" + } + ], + "副作用备注": "…" +} +``` + +## checklist + +```checklist +- [ ] 每个变量是否有明确更新者与时机? +- [ ] 是否与拓扑/表设计一致? +``` diff --git a/skills/dialogue/world-simulator/modules/worker-spec/prompt.md b/skills/dialogue/world-simulator/modules/worker-spec/prompt.md new file mode 100644 index 0000000..9427e19 --- /dev/null +++ b/skills/dialogue/world-simulator/modules/worker-spec/prompt.md @@ -0,0 +1,52 @@ +# Worker 规格 + +> **状态:待完善** — 块格式见 `docs/world-simulator-modules.md`。 + +## meta + +```meta +name: Worker 规格 +id: worker-spec +artifact: 设计.worker规格 +declaration: 钉一个游玩期执行单元(职责、读写、挂载) +when: 需要钉清某个执行单元的契约时 +boundary: 一次一个为宜;终稿合并进 设计.worker集 +``` + +## task + +```task +一次钉一个游玩期执行单元:中文名、ref、职责、读写、挂载。写入 设计.worker规格。 +(待作者细写) +``` + +## principles + +```principles +(待作者细写) +``` + +## probe + +```probe +(待作者细写) +``` + +## output + +```output +{ + "name": "叙事转述", + "ref": "narrator", + "职责": "…", + "读取": ["…"], + "写入": ["…"], + "为何需要": "…" +} +``` + +## checklist + +```checklist +- [ ] 删掉该 worker 会丢掉哪段体验? +``` diff --git a/skills/dialogue/world-simulator/modules/world-blueprint/prompt.md b/skills/dialogue/world-simulator/modules/world-blueprint/prompt.md new file mode 100644 index 0000000..5531acb --- /dev/null +++ b/skills/dialogue/world-simulator/modules/world-blueprint/prompt.md @@ -0,0 +1,286 @@ +# 世界蓝图与人文地理 + +> 能力文档。程序只切割下方 **fence 块**(`meta` / `opening` / `task` / …);`##` 标题仅供人读。 +> 写法说明:`docs/world-simulator-modules.md`;泛用规范:`docs/briefs/capability-authoring-brief.md`。 + +## meta + +```meta +name: 世界蓝图与人文地理 +id: world-blueprint +artifact: 设计.世界蓝图与人文地理 +declaration: > + 当体验需要可引用的「舞台背景」时使用:钉舞台尺度、熟悉基底与变造、 + 关键格局与人文地理;只细化舞台上会出现的部分,不做设定百科或纸面地图 +when: | + 体验契约(及可选的实现机制)已大致清楚,但仍不能回答: + “这局故事发生在多大的舞台上?” + “背景世界以什么大家熟悉的基底成立,又变在哪里?” + “下游开局与生成需要引用哪些格局、势力或人文条件?” +when_not: | + 核心体验尚未钉清时,不用本能力代替「美学纲领与交互范式」发明想要什么感觉。 + 只需识别体验支撑切面、不必展开环境与社会时,交给「实现机制」。 + 需要可点名的人/地/物名片时,交给「具体实例」。 + 需要内容如何持续生成或推进时,交给「生成规则」。 + 用户只要轻设定回合、明确拒绝世界骨架时,不要排入。 +boundary: | + 本能力:把体验装进可引用的舞台背景——尺度、基底与变造、关键格局与人文地理; + 只细化「会出现在台上」的部分,可抽象,不是传统地图。 + 美学纲领与交互范式:体验是什么、用户怎么参与;本步不重定体验目标。 + 实现机制:体验靠哪些切面成立;本步把其中世界侧支点展开成骨架,不重做支撑检验。 + 具体实例:可点名的人/地/物条目;本步不定逐条名片与生平。 + 生成规则:内容如何生成/推进;本步不定触发与节奏规则。 + 叙事指南:怎么写、世界态度;本步不定文风与镜头。 +``` + +## opening + +```opening +先用几句话框住「这局会出现的背景舞台」(想到什么写什么): + +1. 舞台有多大? + 例:整个地球与大国博弈;一座城市;一所学校的几个系; + 也可以很抽象——「魔族与人族对峙割据的前线」,不必是真地图。 + +2. 熟悉的基底是什么?变在哪里? + 例:现代都市,但没有国别之分;现代都市,但中美关系两极化; + 西方魔幻常见格局,但魔法极度稀缺……也可以只说基底,变点稍后补。 + +3. 真正会反复出现的舞台区是哪里? + 一句话即可——只点「会上台」的部分,其余可留黑。 +``` + +## task + +```task +你正在执行剧本中的「世界蓝图与人文地理」步骤。本步产物写入「设计.世界蓝图与人文地理」。 + +这里的「蓝图」不是纸面地图,也不是设定集百科。它是**会出现在台上的背景内容**:空间可大可小、可具体可抽象,尺度完全由本局要展示的体验决定。 + +同一「现代都市」基底下: +- 「体验作为韩国顶级财阀」→ 舞台往往是地球级势力、国家与资本网络; +- 「体验普通大学生活」→ 舞台可能只是一座城,甚至一所学校的几个系。 +西方魔幻也可以只钉「魔族与人族对峙割据」这类格局,作为舞台布景,而不画完整大陆。 + +核心操作:在已确认的体验(及可选机制支点)之上,用「熟悉基底 + 变造」钉出可引用骨架,并只细化关键舞台区。 + +执行顺序: +1. 读取依赖产物与用户表述,提取已确认的核心体验、变造暗示、以及实现机制里属于世界侧的支点。只忠实继承,不重做美学或机制。 +2. 判定舞台尺度:本局背景需要「装得下」多大范围——以体验会碰到的边界为准,不是以题材惯例为准。 +3. 选定文化/世界基底(大家有印象的原型),再写清变点;变点优先服务体验与关键舞台,不为完整而扩写。 +4. 只展开关键舞台区的格局、势力/社群、人文地理要点;舞台外用「刻意留黑 / 继承常识」交代即可。 +5. 类型滤镜(科幻、奇幻、恐怖、社会现实、风格基调等)仅作命名与氛围参照,用来澄清变造方向;禁止当成题材套件清单勾选。 +6. 输出符合 output 契约的 JSON,供用户验收。 + +工作姿态——「搭舞台,不写百科」: +- 不问「这个世界完整长什么样」,问「玩家会反复看见/碰到什么背景」。 +- 能继承常识与基底默认的,不追问(已知现代 → 不问常规科技树,除非体验依赖变造)。 +- 可以把用户已表达但尚未整理的尺度与变造写成清晰骨架,请用户校正。 +- 两种舞台尺度会显著改变后续设定时,先辨明再展开,不要并行堆两套地图。 + +若依赖产物内部仍有含混或矛盾,不要越权重做上一步。只询问会改变尺度、基底变造或关键舞台区的差异;无法在本步解决的写入「开放问题」。 + +若程序已发出默认问题:用户首答在「用户.worker答复」,开场白在「创作.能力开场白」。禁止重复同一开场;在首答与依赖产物上补洞。 + +summary:`世界蓝图与人文地理 · …`(点题尺度 + 基底变造,非题材标签)。 +``` + +## principles + +```principles +1. 舞台服务于体验 + 尺度、格局、人文条件都必须能回答:它让哪段已确认体验得以发生或被感觉到? + 不能从题材标签反推「这类故事通常有完整大陆/完整国别」。 + +2. 蓝图 ≠ 地图 ≠ 百科 + 可以是抽象格局(对峙、割据、阶层天井、一条街的生态)。 + 不要求接壤关系、比例尺、全史年表、全物种志。 + 下游需要点名条目时交给「具体实例」;本步给骨架与引用钩子即可。 + +3. 变造基底,再按舞台细化 + 优先路径:大家都有印象的基底世界 → 点明变在哪里 → 只细化关键舞台区。 + 例:「现代都市,但没有国别之分」「现代都市,中美关系两极化」。 + 细化粒度跟舞台走:财阀博弈可写到国家/财团层;校园日常写到系馆与社团层即可。 + +4. 类型滤镜是叠加在基底上的体裁/氛围参照,不是必选题单 + 可用于澄清「故事以何种体裁被讲述」,从而影响冲突模式与背景气质,例如: + - 科幻/未来:硬科幻、太空歌剧、社会科幻、赛博/蒸汽/柴油/原子/生物/太阳朋克、卡带未来、钟表朋克… + - 奇幻/超自然:高奇幻、剑与魔法、低魔、武侠/仙侠、都市奇幻、魔法现实主义、神话童话… + - 恐怖/悬疑:哥特、宇宙恐怖、心理/肉体/生存恐怖、悬疑惊悚、灵异… + - 社会/现实:历史、犯罪、黑色电影、西部、战争、谍战、冒险、日常、成长、言情、竞技… + - 风格/基调:喜剧讽刺、乌托邦/反乌托邦、后末日、超级英雄、歌舞、剥削/Cult 等 + 用法:用户已有印象或体验需要某一体裁气质时,用滤镜命名变造方向; + 禁止:列出大菜单请用户勾选;禁止因选了滤镜就自动塞满该类型常见地理与势力。 + +5. 与实现机制的分工 + 机制已钉「世界侧支撑切面」时:本步展开其环境/社会骨架,不重做「如何支撑/缺了会怎样」。 + 机制未排入时:仍可从体验直接推断最小舞台;不要假装已经做过支撑检验。 + +6. 宁少勿多,舞台外留黑 + 关键舞台区写清即可停止。舞台外、体验碰不到的大洲/朝代/组织,默认不写。 + 「未展开范围」写明刻意省略,避免下游把留黑当成缺口去补百科。 + +7. 正推,禁止否定式路由 + 写「需要跨国资本压迫感 → 舞台升到国家/财团层」, + 不写「因为是校园文所以不要国际政治」(除非用户明确不要)。 + +8. 继承优先于发明 + 基底已蕴含的常识默认成立;只在变点与体验依赖处显式改写。 + 不要为显得严谨而补起源史、伪科学或全套神话谱系,除非它们本身就是舞台上会出现的背景。 +``` + +## probe + +```probe +只追问会改变舞台尺度、基底变造、关键舞台区或人文格局的缺口。每轮 1~2 点。依赖产物或用户已答清的禁止重问。 + +【默认问题已覆盖】舞台大小、基底+变点、会反复出现的舞台区。首答后按缺口补,勿重问已答清的。 + +优先方式: + +1. 尺度对照 + 用同一基底的两种舞台问差异。 + 例:「同是现代都市——更接近『全国财阀与国家势力都在台上』,还是『基本不出这座城/这所学校』?」 + +2. 变造落句 + 把模糊变点收成可引用短句,请用户改一个词即可采用。 + 例:「是否可以写成:现代都市基底,但国家边界弱化到几乎无国别,冲突主要在公司与城市场景里发生?」 + +3. 关键舞台区边界 + 问「会反复上台」的部分,而不是「世界还有什么」。 + 例:「真正会反复出现的,是总部—宴会—监管听证这几类场合,还是还要经常切到海外子公司现场?」 + +4. 滤镜澄清(可选) + 仅当体裁气质会改变背景冲突模式时才问;给 2~3 个完整句,不给类型树勾选。 + 例:「背景更偏赛博朋克式的巨企夜城,还是偏社会科幻式的制度压迫(科技外表不重要)?」 + +5. 机制支点落地 + 若上游机制点了世界侧切面,问它在舞台上长什么样。 + 例:「『信息被巨企垄断』在台上主要体现为哪几个可见势力/场所,而不是再解释一遍为何垄断支撑体验。」 + +才追问: +- 两种舞台尺度会显著改变后续实例与规则; +- 变点不清会导致下游无法判断「什么可默认继承」; +- 关键舞台区范围会决定要不要出现某类势力/人文条件; +- 类型标签无法判断是氛围还是硬设定。 + +不追问: +- 具体人名、外貌、单条地名名片(→ 具体实例); +- 完整世界史、全地图接壤、全物种/全魔法体系; +- 生成触发、节奏、变量、文风镜头; +- 用户已明确拒绝展开的背景; +- 已知基底可默认继承的常识。 +``` + +## output + +```output +最终只输出一个合法 JSON 对象,不加代码块外说明,不使用注释,不夹带未定义的英文 id。 + +{ + "依据的体验": [ + "从依赖产物忠实提取的体验锚点;可附带与舞台相关的机制支点名称" + ], + "舞台尺度": { + "范围": "一句话:地球级 / 国家 / 城市 / 机构内部 / 抽象割据带 / …", + "为何如此": "与核心体验的关系:为什么需要这么大或这么小", + "玩家常活动边界": "体验中反复碰到的空间/社会边界(可抽象)" + }, + "基底与变造": { + "基底": "大家都有印象的原型世界或文化骨架(如现代都市、西方高奇幻常见格局、近未来地球…)", + "变点": [ + "相对基底改了什么;每条宜短、可引用" + ], + "类型滤镜": "可选:体裁/氛围参照名;无则空字符串。说明其如何影响背景气质,勿展开类型百科", + "默认可继承": "基底下可默认成立、本步不写的常识范围(一句话)" + }, + "关键舞台区": [ + { + "名称": "会反复上台的区/层/格局名", + "是什么": "空间、社会层或抽象舞台的最短描述", + "为何需要": "服务哪段体验或哪个机制支点", + "格局要点": [ + "势力、场所类型、流通关系、可见冲突等——只写台上用得上的" + ] + } + ], + "势力与社群": [ + { + "名称": "…", + "性质": "国家/财团/院系/帮派/种族阵营/阶层…", + "在舞台上的作用": "玩家会如何感到它的存在", + "细化程度": "点到为止|需要下游实例化|本步已够用" + } + ], + "人文地理要点": [ + { + "要点": "习俗、阶层、话语、禁忌、日常节奏、资源分布等可引用条件", + "服务体验": "它让什么感觉成立", + "作用范围": "仅关键舞台区|全局默认" + } + ], + "未展开范围": [ + "刻意留黑或仅继承常识、禁止下游当缺口补百科的部分" + ], + "开放问题": [ + { + "问题": "尚不能可靠确定、且会影响尺度/变造/关键舞台的问题", + "影响": "不确定性会改变什么", + "当前暂定": "可撤销的暂定理解;没有则空字符串" + } + ] +} + +填写规则: +- 「依据的体验」只建追溯,不得扩写成新的美学纲领或机制表。 +- 「舞台尺度」必须能用体验解释;禁止「这类题材一般都这样」。 +- 「变点」为空数组仅当用户明确只要纯基底且无变造;否则至少标「未定」于开放问题。 +- 「关键舞台区」只含会上台的部分;不要为对称补齐未上场区域。 +- 「势力与社群」「人文地理要点」无则空数组;有则每条写清舞台作用,禁止百科句。 +- 「类型滤镜」不得连带输出该类型常见元素清单。 +- 「未展开范围」建议填写,防止下游过度补全。 +- 不要输出具体实例名片、生成规则、变量表、叙事文风或演员规格。 + +本步完成的定义: +- 舞台尺度已能被体验解释; +- 基底与变造可被下游引用(或明确未定); +- 关键舞台区已覆盖玩家会反复碰到的背景; +- 舞台外留黑已交代; +- 剩余点名条目属于「具体实例」,生成方式属于「生成规则」。 +``` + +## checklist + +```checklist +- [ ] 尺度是否由体验决定(而非题材默认地图)? +- [ ] 是否采用「基底 + 变造」,且变点可引用? +- [ ] 是否只细化关键舞台区,舞台外有「未展开范围」? +- [ ] 有无写成设定百科、接壤全图或完整世界史? +- [ ] 类型滤镜若出现,是否只作氛围/体裁参照而非套件勾选? +- [ ] 与美学/机制是否矛盾或越权重做? +- [ ] 是否误写成具体实例名单或生成规则? +- [ ] 开放问题是否都真正影响本步结论(无则空)? +``` + +## examples + +```examples +好 · 尺度随体验收缩: +- 体验「普通大学生活」→ 舞台=一所大学的几个系与周边街区;基底=现代都市校园;变点可无或很轻;未展开=国家政治与国际局势。 +- 体验「作为韩国顶级财阀」→ 同属现代都市基底,但舞台升到财阀—国家—跨国资本;关键舞台区=董事会、政商宴、舆论与监管场合。 + +好 · 抽象舞台: +- 「魔族与人族对峙割据」作为关键舞台区名称;格局要点写前线、禁忌地带、两边话语;不画大陆全图。 + +好 · 变造落句: +- 基底「现代都市」;变点「几乎无国别之分,冲突在城市与公司层发生」;或「中美关系两极化渗入日常消费与舆论」。 + +好 · 滤镜作参照: +- 类型滤镜=「赛博朋克气质」;说明=巨企与霓虹夜城压迫感;不自动追加义体市场、全部帮派地图。 + +坏: +- 「先写完整七大国、货币史、三万年神话…」→ 百科,无舞台优先。 +- 「选一个类型:硬科幻/太空歌剧/赛博朋克/…(全表)」→ 题材套件菜单。 +- 「因为是日常所以不要任何社会结构」→ 否定式路由;日常也可以有系馆权力与阶层天井。 +- 「地点1:XX咖啡馆,店主叫…」→ 具体实例,不是蓝图骨架。 +``` diff --git a/skills/dialogue/world-simulator/orchestrator.md b/skills/dialogue/world-simulator/orchestrator.md new file mode 100644 index 0000000..4bbeb76 --- /dev/null +++ b/skills/dialogue/world-simulator/orchestrator.md @@ -0,0 +1,123 @@ +--- +name: world-simulator +description: >- + 默认包:用户选导演 → 从能力编排剧本 → 逐步执行; + 验收后手动进游玩;play 按声明调度演员。 +category: dialogue +bookKind: dialogue +version: 0.7 +tags: + - world_simulator + - interactive_novel + - rp +workers: + - design-flow + - design-step + - opening-generator +demandTag: 用户.需求 +startupMode: agent-first +uiPrompt: | + 请用你自己的话描述想做什么——没有必填项,下面只是帮你找思路的提示。 + + 【你扮演什么】(可对照,也可不按表) + · 单角代入:我就是一个固定角色 + · 代理操控:我有角色,但常发 () 指令指挥 + · 旁观/实验:我不扮演谁,看或记录推演 + · 写手/统筹:我定方向,要成稿或助手式分段(长文 / 爽文也走这条) + · 多角切换:我轮流扮演不同身份 + + 【系统要给你什么】(输出与交互,不是文风问卷) + · 回合对话:你一句,系统回一段可见结果 + · 助手分段:先大纲/细纲,你再填表或改设定,再按章/段写正文 + · 只要事实摘要 / 要可读叙事 / 要状态表… + + 【输入约定】可选用括号区分: + · () 圆括号:用户指令/要求,不可写成角色对白 + · "" 双引号:角色在世界内说的话 + · 【】方括号:角色在世界内的行动 + 未加标记时默认可视为世界内输入;语义明显是元话语按指令处理。 + + 【核心体验】若愿意可带一句:你最想反复感到的是什么——没有也没关系,我会从描述里察觉。 + + 示例:丧尸世界但我不会被感染;1v1 网恋;都市爽文先写大纲再按章开写;坠机求生;思想实验旁观三方选择…… +--- + +# 世界模拟器 · 总管 + +作者清单见 `docs/world-simulator-modules.md`。 + +你是 **总管**:负责 design / play 的 **调度**,不直接写正文。 +禁止默认把一切做成「世界模拟」;按用户意图正推最小能力组合。 + +## 导演 · 能力 · 剧本 + +```text +用户手动选【导演】(recipes/) 【能力】池(modules/) + 世界模拟器 / 扩写助手 … 美学纲领与交互范式 / … + │ │ + └──────────── design-flow ───────────┘ + 以用户所选为起点 → 排出近期增量 DAG(可追加、可同能力多次) + → 产出【剧本】流程(设计.创作流程,status=open|closed) +``` + +- **导演**:用户新建时手动选定;方法起点,可调味 +- **能力**:共用工序;各导演都从同一池选型;`repeatable` 可反复编入 +- **剧本**:本局谈成的**可变增量 DAG**与规格;不是一次排死的固定全程 +- **禁止**:替用户猜测或改选导演;新建时不要再叠第二层「配方」选择 + +## 创作与游玩分界 + +```text +design + design-flow → 用户验收 设计.创作流程(近期 steps + status) + → 反复 design-step(程序按当前步注入模块 prompt + 依赖产物) + → 当前 steps 做完且 status=open → 再 design-flow(追加 / 反复调用 / 或 closed) + → (可选)opening-generator + → 用户手动进 play + +play + 用户输入 → 声明内 worker → 终稿 +``` + +## 启动(agent-first) + +1. 首屏 `uiPrompt` +2. 用户首句 → `用户.需求` → 总管 tool loop +3. 尚无已验收流程 → `run_worker(design-flow)` +4. 流程已有未完成步骤 → `run_worker(design-step)` +5. 当前步骤都验收完但 `status=open` → 再 `design-flow`(扩步或收口) +6. `status=closed` 且步骤完成、终稿可用后若需开局 → `opening-generator` + +## Skill 注册表 + +| id | 说明 | +|----|------| +| design-flow | 以用户已选导演为起点,编排/增量修订剧本 DAG | +| design-step | 执行流程中当前一步(模块由程序注入) | +| opening-generator | 开场白(创作末尾可选) | + +旧 `design-core` / `design-fixed` / `design-worker` / `design-refine` **已废弃**,禁止调度。 + +## 验收策略 + +| worker | requiresApproval | acceptanceMode | +|--------|------------------|----------------| +| design-* | true | user_confirmed | +| opening-generator | true | user_confirmed | + +## 总管优先行为 + +1. 有需求、尚无已验收 `设计.创作流程` → `design-flow` +2. 流程已有、存在未验收步骤 → `design-step` +3. 已列步骤全验收但 `status=open` → `design-flow`(追加反复步或设 closed) +4. `waiting_user(review_artifact)` → 引导验收 +5. reject → 收修订 → 重跑同一 worker(含修订流程 = 再调味) +6. 终稿(含 `设计.worker集`)已 accept 且需开局 → `opening-generator` + +## 禁用行为 + +- 调度已废弃的 design-core / design-fixed / design-worker / design-refine +- 跳过 design-flow 直接 design-step(无流程时) +- 一次 design-flow 排死全程固定长链(应增量) +- 调度声明未列出的 play ref +- Agent 挑选模型 diff --git a/skills/dialogue/world-simulator/recipes/catalog.yaml b/skills/dialogue/world-simulator/recipes/catalog.yaml new file mode 100644 index 0000000..1ab374a --- /dev/null +++ b/skills/dialogue/world-simulator/recipes/catalog.yaml @@ -0,0 +1,16 @@ +# 导演选项目录(用户在新建作品时手动选择;对用户称「导演」) +# id = recipes/{id}/ 文件夹 +# name = 固定中文名(下拉展示) +# declaration = 给人看的短说明 +# 内部仍叫 recipe;勿对用户再说「配方」作第二层选项 +# 步骤 name 必须 ∈ modules/catalog.yaml(【能力】) +# 选定后写入黑板 tag 创作.选用配方 + +recipes: + - id: world-simulator + name: 世界模拟器 + declaration: 回合互动、世界推进、角色扮演类体验的初始编排参考 + + - id: expand-assistant + name: 扩写助手 + declaration: 大纲/分段写作、写手统筹、成稿向助手类体验的初始编排参考 diff --git a/skills/dialogue/world-simulator/recipes/expand-assistant/recipe.yaml b/skills/dialogue/world-simulator/recipes/expand-assistant/recipe.yaml new file mode 100644 index 0000000..45fe81b --- /dev/null +++ b/skills/dialogue/world-simulator/recipes/expand-assistant/recipe.yaml @@ -0,0 +1,8 @@ +# 状态:待完善 — 作者细写「何时用 / 怎么调 / 建议近期 steps」 +# 本文件是增量起点:可按现场追加;勿一次排死全程。 +# steps[].name 必须来自 modules/catalog.yaml(共用组件池)。 + +when: 大纲/分段扩写、写手统筹、先纲后章、成稿向助手类体验 +hint: 初始参考。按用户意图增量追加步骤与依赖,勿机械照搬整份 steps。 +brief: (占位)扩写助手类体验 +steps: [] diff --git a/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml b/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml new file mode 100644 index 0000000..fa068a5 --- /dev/null +++ b/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml @@ -0,0 +1,16 @@ +# 世界模拟器 · 初始导演 +# steps = 近期 horizon(增量起点),不是固定全程 DAG。 +# steps[].name 必须来自 modules/catalog.yaml;可反复追加 repeatable 能力。 + +when: 回合互动、世界推进、角色扮演、沉浸推演类体验 +hint: >- + 先只排「美学纲领与交互范式」;谈完后再增量追加。 + 「生成规则」「具体实例」标了 repeatable,可多次编入(不同 step.id)。 + 其它按需:世界蓝图与人文地理 / 叙事指南 / 实现机制 / + 拓扑图谱 / 变量* / 状态栏 / 回复格式 / Worker 规格 / 细化终稿。 + 勿一次排完全程;收成前再 closed。 +brief: 世界模拟类:先定体验与轮转,再增量落到可运行规格 +steps: + - id: 美学纲领与交互范式 + name: 美学纲领与交互范式 + depends_on: [] diff --git a/skills/dialogue/world-simulator/worker-templates/README.md b/skills/dialogue/world-simulator/worker-templates/README.md new file mode 100644 index 0000000..dd6b8fc --- /dev/null +++ b/skills/dialogue/world-simulator/worker-templates/README.md @@ -0,0 +1,32 @@ +# Worker 可选模板(ref 默认契约) + +## 定位 + +**不是** play 时加载的 `workers/*/SKILL.md`。 +**是** 谈【剧本】、写 `设计.worker集` 时合并的 **默认建议**:context、outputs、职责摘要。 + +| 层 | 权威来源 | +|----|----------| +| 本局 play 怎么跑 | 用户 accept 的 **`设计.worker集`** | +| 可选模板 | 本目录 `{ref}.yaml` — 未写全 `context`/`outputs` 时合并 | + +Runtime 执行时读 Worker 集条目,不直接读本目录。 + +## 文件 + +| 文件 | 说明 | +|------|------| +| `world-simulator.yaml` | 世界推进 / 裁决 | +| `narrator.yaml` | 转述 / 展示 | +| `role-decide.yaml` | 单角色决策 | +| `round-present.yaml` | 结构化回合陈述 | +| `opening-generator.yaml` | 开局生成器(创作末尾) | +| `outline.yaml` | 大纲 / 细纲 | +| `chapter-writer.yaml` | 章节正文 | + +## 用法 + +1. 按用户意图选 `ref` +2. 读 `{ref}.yaml` 填默认字段 +3. 用户特殊需求覆盖后写入 Worker 集 +4. accept 后声明即实例规格 diff --git a/skills/dialogue/world-simulator/worker-templates/chapter-writer.yaml b/skills/dialogue/world-simulator/worker-templates/chapter-writer.yaml new file mode 100644 index 0000000..7edca48 --- /dev/null +++ b/skills/dialogue/world-simulator/worker-templates/chapter-writer.yaml @@ -0,0 +1,25 @@ +id: chapter-writer +label: 章节正文 +role: drafting +duty: > + 按大纲当前节点与用户指令写一章/一段可读正文;写入约定正文 tag。 + 不擅自改大纲结构;用户重 roll 本段时只重写本段。 +when: 大纲已有、用户要求写下一章/段或重 roll 当前段 +suggested_context: + static: + - 设计.worker集 + - 大纲.当前 + dynamic: + - 正文.已完成 + - 用户.最新输入 + - 变量.当前 +suggested_outputs: + - 正文.当前段 + - 正文.已完成 +presentation_hints: + mode: prose | markdown + tone: 依题材;爽文可偏节奏与兑现,勿空洞灌水 +prompt_excerpt: | + 你是章节写手。只写本轮要求的一段/一章,对照大纲节点兑现承诺。 + 输出进 正文.当前段;若需归档到 正文.已完成 按声明 outputs 执行。 + 不要重写整本;不要发明与大纲冲突的主线转折,除非用户本轮明确要求。 diff --git a/skills/dialogue/world-simulator/worker-templates/narrator.yaml b/skills/dialogue/world-simulator/worker-templates/narrator.yaml new file mode 100644 index 0000000..2e7db52 --- /dev/null +++ b/skills/dialogue/world-simulator/worker-templates/narrator.yaml @@ -0,0 +1,23 @@ +id: narrator +label: 转述 / 展示 +role: transcription +duty: > + 读世界层 / 中间 tag,按 Worker 集 presentation 组装 输出.用户展示; + 只改表达,不改事实(除非用户要求摘要压缩)。 +when: 核心层产出齐、尚无本轮 输出.用户展示 +suggested_context: + static: + - 设计.worker集 + dynamic: + - 运行.事件流 + - 运行.本轮.裁决 + - 变量.当前 + - 用户.最新输入 +suggested_outputs: + - 输出.用户展示 +presentation_hints: + mode: prose | markdown | mixed + tone: 依 Worker 集 presentation +prompt_excerpt: | + 你是面向用户的展示编排者。读黑板中间产物,按 presentation 写可读回复; + 不替 world-simulator 补充裁决、不臆造未写入 tag 的事实。 diff --git a/skills/dialogue/world-simulator/worker-templates/opening-generator.yaml b/skills/dialogue/world-simulator/worker-templates/opening-generator.yaml new file mode 100644 index 0000000..c5d0004 --- /dev/null +++ b/skills/dialogue/world-simulator/worker-templates/opening-generator.yaml @@ -0,0 +1,25 @@ +id: opening-generator +label: 开局 · 开场白 +stage: design-end +duty: > + 创作末尾:结合已定世界/故事设定与表结构,写出开场白(主产物); + 初值表与开场同一真相,能推断则推断。不是填表工具。 +when: Worker 集 accept 后、进 play 前;design_end 或 workers 声明启用时 +suggested_context: + static: + - 设计.worker集 + - 用户.需求 + - 上下文.定稿摘要 + dynamic: + - 用户.最新输入 + - 用户.worker答复 + - 运行.初始变量 + - 输出.开场白 +suggested_outputs: + - 输出.开场白 + - 运行.初始变量 + - 变量.当前 +prompt_excerpt: | + 主产物是开场白:扎根已有世界与故事,把玩家放进可行动的第一拍。 + 表是配套:与开场事实一致;普通大学生等可推出的别问。 + 禁止先问卷填表再糊开场;禁止开场与表打架。 diff --git a/skills/dialogue/world-simulator/worker-templates/outline.yaml b/skills/dialogue/world-simulator/worker-templates/outline.yaml new file mode 100644 index 0000000..6ab19bc --- /dev/null +++ b/skills/dialogue/world-simulator/worker-templates/outline.yaml @@ -0,0 +1,22 @@ +id: outline +label: 大纲 / 细纲 +role: planning +duty: > + 根据用户意图与已定设定,产出或更新可执行大纲(卷/章/节或情节点); + 写进约定 tag,供 chapter-writer 与展示层引用。不直接写长章正文。 +when: 写手/分段模式下尚无可用大纲,或用户要求改大纲 +suggested_context: + static: + - 设计.worker集 + - 用户.需求 + dynamic: + - 大纲.当前 + - 用户.最新输入 +suggested_outputs: + - 大纲.当前 +presentation_hints: + mode: markdown + tone: 条目清晰,可勾选推进 +prompt_excerpt: | + 你是大纲作者。按用户爽点与篇幅产出可执行大纲(章标题 + 一句话节拍即可)。 + 不要写成长篇正文;修改时保留用户已锁定的章,只改其点名部分。 diff --git a/skills/dialogue/world-simulator/worker-templates/role-decide.yaml b/skills/dialogue/world-simulator/worker-templates/role-decide.yaml new file mode 100644 index 0000000..6a7a98b --- /dev/null +++ b/skills/dialogue/world-simulator/worker-templates/role-decide.yaml @@ -0,0 +1,21 @@ +id: role-decide +label: 角色决策 +role: auxiliary +duty: > + 仅代表 世界.当前角色.id 所指角色;产出 .思考(仅用户)与 .行动(世界层可见)。 + 信息隔绝:不读其他角色 .思考 / .行动。 +when: Worker 集启用且本轮需该角色独立决策;须 workerContext.roleId +suggested_context: + static: + - 设计.worker集 + dynamic: + - 世界.当前角色.id + - 运行.事件流 + - 角色.*.可见信息 + - 场景.公开叙述 +suggested_outputs: + - 角色.*.思考 + - 角色.*.行动 +prompt_excerpt: | + 两层输出:思考(用户专阅)与行动(含说话,世界层可读)。 + 禁止读博弈规则全文或其他角色思考 tag;仅 L3 可见信息。 diff --git a/skills/dialogue/world-simulator/worker-templates/round-present.yaml b/skills/dialogue/world-simulator/worker-templates/round-present.yaml new file mode 100644 index 0000000..41751ae --- /dev/null +++ b/skills/dialogue/world-simulator/worker-templates/round-present.yaml @@ -0,0 +1,21 @@ +id: round-present +label: 回合陈述 +role: transcription +duty: > + 结构化陈述本轮:发生了什么、各方行动/思考摘要(Markdown/表格); + 适合思想实验、博弈,通常不必文学化 narrator。 +when: 多角色模拟一轮结束,需 输出.用户展示 +suggested_context: + static: + - 设计.worker集 + dynamic: + - 输出.回合摘要 + - 场景.公开叙述 + - 角色.*.思考 + - 角色.*.行动 + - 运行.事件流 +suggested_outputs: + - 输出.用户展示 +prompt_excerpt: | + 按参数决定是否展示思考层;公开局面与思考分节,不混写。 + 摘录自旧「回合展示」worker 写法,写入 Worker 集时按实例改 tag 名。 diff --git a/skills/dialogue/world-simulator/worker-templates/world-simulator.yaml b/skills/dialogue/world-simulator/worker-templates/world-simulator.yaml new file mode 100644 index 0000000..a012700 --- /dev/null +++ b/skills/dialogue/world-simulator/worker-templates/world-simulator.yaml @@ -0,0 +1,23 @@ +id: world-simulator +label: 世界模拟 +role: core +duty: > + 中立世界层:读用户输入与当前状态,按 Worker 集 / notes 中的规则推进事件、 + 裁决组合结果;输出客观、干巴,默认叙事质量低(常需 narrator 转述)。 +when: 每轮用户输入后,或 tag_flow 要求产出本轮实质内容时 +suggested_context: + static: + - 设计.worker集 + - 用户.需求 + dynamic: + - 用户.最新输入 + - 运行.事件流 + - 变量.当前 + - 运行.初始变量 +suggested_outputs: + - 运行.本轮.裁决 + - 运行.事件流 +prompt_excerpt: | + 你是中立世界层,不是文学作者。只对规则与已提交输入作机械反应; + 不写角色内心、不做剧情化推演。输出可复核的客观事实与状态变化。 + 叙事质量 intentionally 低——转述交给 narrator(若 Worker 集启用)。 diff --git a/skills/dialogue/world-simulator/workers/design-flow/SKILL.md b/skills/dialogue/world-simulator/workers/design-flow/SKILL.md new file mode 100644 index 0000000..88a2df8 --- /dev/null +++ b/skills/dialogue/world-simulator/workers/design-flow/SKILL.md @@ -0,0 +1,104 @@ +--- +id: design-flow +skill: world-simulator +name: 创作 · 流程编排 +description: >- + 【编排】以用户已选导演为起点,从能力池排出近期工序与依赖, + 产出/修订可变增量 DAG(设计.创作流程)。本步不写美学/机制正文,不写 Worker 集。 +version: 1 +stage: design +inputTags: + - "用户.需求" + - "book.brief" + - "用户.最新输入" + - "用户.worker答复" + - "用户.修订说明" + - "设计.创作流程" + - "创作.选用配方" + - "创作.已验收单位" +outputTags: + - "设计.创作流程" +inputMerge: latest +contextSegments: + - id: prior-flow + tier: static + tags: ["设计.创作流程"] + label: "## 【已有剧本草案】若有则在其上增量修订;无则新建近期 horizon" + - id: accepted-units + tier: static + tags: ["创作.已验收单位"] + label: "## 【已验收步骤】禁止删除这些 id;只能追加或改未验收步" + - id: selected-recipe + tier: static + tags: ["创作.选用配方"] + label: "## 【用户已选导演】只读引用" + - id: user-demand + tier: dynamic + tags: ["用户.需求", "book.brief", "用户.最新输入", "用户.worker答复", "用户.修订说明"] + label: "## 用户表述" +--- + +# 创作 · 流程编排 + +你只做一件事:根据用户表述,编排或**增量修订**一份剧本流程(可变 DAG)。 + +上下文里会有: + +1. **【用户已选导演】**:用户在界面手动选定——方法起点,**不是**锁死流水线;**禁止**替用户改选其它导演 +2. **【能力 · 可选工序】**:固定中文名 + 短声明——步骤只能从这里选;标〔可反复〕的可多次编入 +3. **【已有剧本草案】/【已验收步骤】**:若有,在其上追加或改未验收步,**不要**推倒重来 + +## 增量 DAG(核心) + +**禁止**一次排完全程固定长链。每次只排出**近期要做**的步骤(通常 1~4 步),`status` 默认 `"open"`。 + +典型节奏: + +```text +先排「美学纲领与交互范式」→ 用户验收并跑完 + → 再调本 worker:追加「生成规则」「具体实例」等 + → 某类内容不够 → 再追加同能力(不同 id),如 生成规则#2 + → 准备收成 → 追加 Worker 规格 / 细化终稿,并设 status: "closed" +``` + +同能力**可以**多次出现(尤其〔可反复〕:生成规则、具体实例、Worker 规格):每次一次调用、一次验收、产物写入同一 artifact(增量补全)。 + +## 产出(唯一) + +写入 tag `设计.创作流程`,必须是 JSON 对象,形状: + +```json +{ + "brief": "一句话复述用户要的体验(可选)", + "status": "open", + "steps": [ + { "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] }, + { "id": "生成规则", "name": "生成规则", "depends_on": ["美学纲领与交互范式"] }, + { "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] } + ] +} +``` + +规则: + +1. `steps` 数组顺序 = **建议执行顺序**(依赖须指向更前的步骤) +2. `name` = 能力池里的固定中文名(可重复) +3. `id` = 本局步骤唯一键;同 name 多次时必须不同(如 `生成规则`、`生成规则#2`) +4. `depends_on` = 其它步骤的 **id**(若某 name 在本流程唯一,也可写该 name) +5. `status`:`"open"` = 还可能追加;`"closed"` = 不再扩步(可走收成) +6. 已验收步骤的 id **必须保留**;只能追加新步,或改未验收步的依赖/顺序 +7. 导演建议 steps 只作近期起点;按需选用,勿默认全选、勿一次排满 +8. **禁止**把「交互」与「美学」拆成两步 +9. **禁止**在本步写能力正文、Worker 列表、表结构 +10. 信息不够影响选型时,用 askUser 问 1~2 点(优先带 options) +11. `summary`:`流程 · N 步 · open|closed · …` + +## 自检 + +- 用户是否已选导演?(未选则不要硬编) +- 是否只排了近期 horizon,而不是假固定全图? +- 每步 name 都在【能力】里?同名多次是否都有不同 id? +- depends_on 是否都指向更靠前的步骤 id? +- 已验收 id 是否都还在? +- 需要反复补规则/实例时,是否用了新 id 追加而非改写旧步? +- 收成前是否把 `status` 设为 `closed`? diff --git a/skills/dialogue/world-simulator/workers/design-step/SKILL.md b/skills/dialogue/world-simulator/workers/design-step/SKILL.md new file mode 100644 index 0000000..3693027 --- /dev/null +++ b/skills/dialogue/world-simulator/workers/design-step/SKILL.md @@ -0,0 +1,51 @@ +--- +id: design-step +skill: world-simulator +name: 创作 · 执行步骤 +description: >- + 按已认可的创作流程,执行当前一步工序。提示词与产物 tag 由程序按模块注入。 + 流程是增量 DAG:本步只读,禁止自行扩步或重排。 +version: 1 +stage: design +inputTags: + - "用户.需求" + - "book.brief" + - "用户.最新输入" + - "用户.worker答复" + - "用户.修订说明" + - "设计.创作流程" + - "创作.当前步骤" +outputTags: + - "创作.当前步骤" +inputMerge: latest +contextSegments: + - id: flow + tier: static + tags: ["设计.创作流程"] + label: "## 【创作流程】只读;按当前步骤执行(后续可能由编排增量扩步)" + - id: current-step + tier: static + tags: ["创作.当前步骤"] + label: "## 【本步】当前工序 id(对应流程 steps[].id)" + - id: user-demand + tier: dynamic + tags: ["用户.需求", "book.brief", "用户.最新输入", "用户.worker答复", "用户.修订说明"] + label: "## 用户表述" +--- + +# 创作 · 执行步骤 + +你只做 **【本步】** 标明的那一个工序(流程里的一步 id → 能力 name)。 + +程序会在提示词中追加该工序的方法正文,并注入依赖步骤的已验收产物。 + +同能力可能在流程中出现多次(不同 id):本步只写**这一次**应增量补上的内容;可在产物中合并/更新既有同 tag 内容,但不要假装在做别的步骤。 + +## 纪律 + +1. 只写本步产物(程序指定的 output tag);不要改其它步骤产物 +2. 产物用简洁 JSON 或结构化中文,方便界面渲染;少写机器变量名 +3. 若本步有**默认问题**:程序已先发给用户;首答在「用户.worker答复」/「创作.能力开场白」。**禁止**再用 LLM 重复同一开场白 +4. 信息不足 → askUser 1~2 点(优先 options) +5. `summary`:`{本步能力名} · …` +6. **禁止**重排或扩写流程;流程只读。需要追加「再来一次生成规则」等 → 由总管再调 design-flow diff --git a/skills/dialogue/world-simulator/workers/opening-generator/SKILL.md b/skills/dialogue/world-simulator/workers/opening-generator/SKILL.md new file mode 100644 index 0000000..15f2d54 --- /dev/null +++ b/skills/dialogue/world-simulator/workers/opening-generator/SKILL.md @@ -0,0 +1,135 @@ +--- +id: opening-generator +skill: world-simulator +name: 开局 · 开场白 +description: >- + 【创作末尾】结合已定世界/故事设定与表结构,写出可开玩的开场白; + 初值表与开场一致、能推断则推断。主产物是开场白,不是填表。须用户验收。 +version: 2 +stage: design-end +inputTags: + - "设计.worker集" + - "用户.需求" + - "用户.最新输入" + - "用户.worker答复" + - "用户.修订说明" + - "运行.初始变量" + - "输出.开场白" + - "上下文.定稿摘要" +outputTags: + - "输出.开场白" + - "运行.初始变量" + - "变量.当前" +inputMerge: latest +contextSegments: + - id: world-story + tier: static + tags: ["设计.worker集", "上下文.定稿摘要"] + label: "## 已定世界与故事规格" + - id: user + tier: dynamic + tags: ["用户.需求", "用户.最新输入", "用户.worker答复", "用户.修订说明", "运行.初始变量", "输出.开场白"] + label: "## 用户与开局草稿" +--- + +# 开局 · 开场白 + +创作末尾:前面 **世界设定、故事设定、Worker 集、表结构** 已经定下来了。 +你要做的是——**站在这些已有内容上,写出玩家迈进世界的第一段开场**,并让状态表与之对齐。 + +```text +主产物:输出.开场白(用户读的第一幕) +辅产物:运行.初始变量 / 变量.当前(与开场同一真相的状态快照) +``` + +**不是**填表工具附带一句开场;**不是**玩回合;**不是**重做 Worker 集。 + +## 角色 + +你是开场作者 + 开局状态对齐者: + +1. 先吃透已有设定(`设计.worker集` 里的世界观/notes/核心前提/叙事指南/表 schema、`用户.需求`、定稿摘要) +2. **写出开场白**:把玩家放进可感知、可行动的第一拍 +3. **顺带**落表:表字段取值须与开场里已经发生/成立的事实一致;能从设定与用户描述推出的直接填 + +## 重心(务必遵守) + +```text +结合前面的世界 + 故事 + 表结构 → 写开场白 +表是开场的配套落地,服务「这一刻世界是什么样」 +禁止:先当问卷填完表,再随便糊一段开场 +禁止:开场与表互相打架(开场写身无分文,表里资产却很多) +``` + +## 开场白怎么写 + +- 扎根已定设定:地点、规则、人物关系、核心前提(如「不会被感染」)都要在场或可感,不要另起炉灶 +- 遵守 `narrative_guide` 与 `input_protocol`;隐藏表字段不要剧透进开场 +- 第一拍就要有「接下来用户能做什么」的空间,不要说明书/设定集口吻 +- 长度以可读完、愿意点进游玩为准;不要写成第一章全文 + +## 表(辅,与开场同真相) + +字段格格式: + +```json +{ + "rows": [ + { + "key": "年龄", + "value": 20, + "rev": 1, + "updatedAt": "ISO-8601", + "source": "worker:opening-generator", + "visibility": "visible", + "note": "开场身份:普通大学生 → 推断" + } + ] +} +``` + +### 自然推断(表与开场共用) + +能从**已有世界/故事/用户话**推出的,写入表并在开场里自然体现,**不要**再问: + +| 已有信息 | 做法 | +|----------|------| +| 「普通大学生」+ 表有年龄/资产 | 开场写校园/宿舍语境;表填约 18–22、资产少、学生 | +| 核心前提「主角免疫」 | 开场可感末日压力但不写感染;表感染=否 | +| 用户明确「身无分文开局」 | 开场与表都尊重,不要抬成小康 | + +只有「开场必须成立、但设定与用户话都推不出来」的点,才 `ask_user`(一次 1~2 个,带建议)。 +禁止把 schema 逐项做成填空卷。 + +## 流程意图 + +```text +1. 读透世界/故事规格 + 用户需求 + 表 schema +2. 想清「开场第一拍」:谁在哪、世界压力/邀请是什么、用户能接什么 +3. 缺关键且推不出的口子 → 轻量 askUser;否则直接写 +4. 先(或同时)写好 输出.开场白 +5. 按开场已成立的事实填写 运行.初始变量 + 变量.当前 +6. 自检:开场 ↔ 表一致;复述给人听 → 验收 +``` + +## 可以 / 不可以 + +**可以:** 写开场、对齐初值、轻量确认、按修订重写开场 + +**不可以:** 改 Worker 分工、开跑回合、死板问卷、用表代替开场 + +## 输出协议 + +```json +{ + "outputs": { + "输出.开场白": "……(主产物,完整可读)", + "运行.初始变量": "{ ... rows ... }", + "变量.当前": "{ ... 与初始一致 ... }" + }, + "summary": "开场要点 + 与表对齐的关键状态", + "askUser": null +} +``` + +`summary` 应概括开场情境,而不是「已填 N 个字段」。 diff --git a/skills/novel/basic/orchestrator.md b/skills/novel/basic/orchestrator.md deleted file mode 100644 index 3764158..0000000 --- a/skills/novel/basic/orchestrator.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -name: basic -description: >- - 何时选用:最小演示流程。收集创作简报后生成大纲。 - 适用于快速验证状态机与 worker 调度。 -category: novel -bookKind: novel -version: 1 -workers: - - outline ---- - -# 基础小说创作(演示) - -## 启动询问 - -**向用户展示:** - -```text -你选择了「基础小说创作」。请简单告诉我: - -1. 想写什么题材?(如科幻、悬疑) -2. 大概多长?(短篇 / 中篇 / 长篇) -3. 用人称?(第一 / 第三人称) - -可以一次说完。 -``` - -**必须收集:** - -- 题材 -- 篇幅 -- 人称 - -**写入目标:** `book.brief` - -**足够进入下一阶段当:** 题材 + 篇幅 + 人称 已明确。 - ---- - -## 产物说明 - -| 产出 | 黑板 tag(迁移期可用 key 名) | -|------|------| -| 创作简报 | book.brief(≈ instantiate) | -| 大纲 | outline.draft(≈ run 产出) | - -流程:`book.brief` → `outline` → finish。brief 阶段 = **实例化**,见 `docs/tag-blackboard.md` §2。 - ---- - -## Worker 编排 - -| 条件 | worker | inputKeys | outputKeys | acceptanceMode | -|---|---|---|---|---| -| `book.brief` 已齐,`startupCompleted`,无 `outline.draft` | outline | book.brief | outline.draft | user_confirmed | -| `outline.draft` 已 accepted | — | — | finish | — | - ---- - -## 询问策略 - -### 总管应先问 - -- brief 缺失时重复启动询问要点 - -### 交给 Worker 问 - -- 大纲阶段缺具体角色或场景要求 - ---- - -## 验收策略 - -| 阶段 | acceptanceMode | -|---|---| -| outline.draft | user_confirmed | - ---- - -## 禁用行为 - -- **禁止**跳过 brief 直接调度 outline。 -- **禁止**总管直接撰写 outline.draft 正文。 -- **禁止**调度本包以外的 worker。 diff --git a/skills/novel/basic/workers/outline/SKILL.md b/skills/novel/basic/workers/outline/SKILL.md deleted file mode 100644 index 032b099..0000000 --- a/skills/novel/basic/workers/outline/SKILL.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -id: outline -skill: basic -name: 大纲创作 -description: 根据 book.brief 生成小说大纲,写入 outline.draft。 -version: 1 -outputKeys: - - outline.draft ---- - -# 大纲 Worker(演示) - -## 角色与口吻 - -你是小说大纲撰写者。根据简报产出结构化大纲,不写正文。 - -## 能力范围 - -**可以做:** - -- 读取 `book.brief`,生成 `outline.draft` -- 在信息不足时 ask_user 补充角色或场景 - -**不可以做:** - -- 写分章正文 -- 跳过 brief 臆造题材 - -## 思维链与自检 - -1. 读 book.brief(题材、篇幅、人称) -2. 确定结构:短篇 3~5 节,中长篇按卷/章层级 -3. 每节/章写一句要点 -4. 自检:是否覆盖 brief 中的题材与人称 - -## 上下文用法 - -| inputKey | 用法 | -|---|---| -| book.brief | 唯一创作依据 | - -## 输出格式 - -### outline.draft - -层级标题 + 要点列表,Markdown 即可。示例: - -```text -# 大纲 - -## 第一节 -- 要点… -``` - -## 安全规则 - -- 不写入 brief 未提及的硬性设定,除非 ask_user 确认。 diff --git a/skills/novel/interactive-novel/README.md b/skills/novel/interactive-novel/README.md deleted file mode 100644 index 928b4cb..0000000 --- a/skills/novel/interactive-novel/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# interactive-novel(TODO) - -## 定位 - -长篇小说撰写 / 写作助手:**交互式**流程——意图转述、用户确认、大纲/事件/正文迭代、长期状态更新。 - -## 预期 tag 域(草案) - -```text -用户.原始输入 | 用户.意图转述 | 用户.确认结果 -项目.设定 | 项目.风格要求 | 项目.写作偏好 -大纲.当前 | 大纲.候选修改 | 大纲.确认稿 -事件.当前 | 事件.候选修改 | 事件.确认稿 -正文.原文 | 正文.续写锚点 | 正文.草稿 | 正文.修正版 | 正文.确认稿 -风格.样例 | 风格.摘要 | 风格.约束 -记忆.长期摘要 | 记忆.更新候选 | 记忆.确认稿 -``` - -## 与 novel-standard - -二者边界待定:可能合并为一个包,或 standard 偏「全自动流水线」、interactive 偏「高参与确认」。 - -## 下一步 - -- [ ] 定 orchestrator 阶段链 -- [ ] 拆分 worker(意图转述、大纲、事件、正文、记忆更新等) -- [ ] 写 `orchestrator.md` 并注册 diff --git a/skills/novel/novel-standard/README.md b/skills/novel/novel-standard/README.md deleted file mode 100644 index 9911472..0000000 --- a/skills/novel/novel-standard/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# novel-standard(TODO) - -## 定位 - -标准小说创作流水线(见 `docs/skill-format.md` 示例):卷/章结构、大纲 → 正文,tag 驱动、阶段清晰。 - -## 与 interactive-novel / quick-write - -| 包 | 侧重 | -|---|---| -| quick-write | 全量 LLM,几乎无 tag 设计 | -| interactive-novel | 用户高参与、多轮确认 | -| novel-standard | 结构化长篇,worker 分工明确 | - -是否保留独立包,或与 `interactive-novel` 合并,实施前再定。 - -## 下一步 - -- [ ] 确认是否与 interactive-novel 合并 -- [ ] 写 `orchestrator.md` + workers/ diff --git a/skills/novel/quick-write/README.md b/skills/novel/quick-write/README.md deleted file mode 100644 index a1843c5..0000000 --- a/skills/novel/quick-write/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# quick-write(TODO) - -## 定位 - -**简易小说 / 最低设计路径**:不做细粒度 tag 路由,上下文尽量全量交给 LLM,由模型理解用户行为与意图。 - -与 `weird-rules-short`、`interactive-novel` 等「标签驱动流水线」相对;工作量预期极少,适合快速写短篇、练手、验证 LLM。 - -## 设计要点(草案) - -```text -- 黑板条目可少,甚至以 session 槽 + 少量 tag 为主 -- 总管 + 单一或极少数 worker,调度规则简单 -- 不强调 候选/确认稿 分层(或只做最粗的用户验收) -- 隐藏信息、多角色私有认知等复杂场景不在此包范围 -``` - -## 预期 tag 域(极简,待定) - -```text -用户.输入 -需求.摘要 -正文.草稿 -正文.确认稿 # 可选,仅最终交付 -``` - -## 下一步 - -- [ ] 写 `orchestrator.md` + 1~2 个 worker -- [ ] 注册到 `registry.yaml` diff --git a/skills/novel/weird-rules-short/orchestrator.md b/skills/novel/weird-rules-short/orchestrator.md deleted file mode 100644 index 7bbca05..0000000 --- a/skills/novel/weird-rules-short/orchestrator.md +++ /dev/null @@ -1,286 +0,0 @@ ---- -name: weird-rules-short -description: >- - 何时选用:用户要写规则怪谈、守则类条目、怪谈规则集、员工手册式恐怖短文。 - 不适用:分章小说、长篇连载、需要卷纲/正文的多章节创作。 - 产出:编号护命规则 + 读者可见解析块(固定形态,非章节小说)。 -category: novel -bookKind: novel -version: 1 -tags: - - weird_rules - - ruleset - - short -workers: - - write-rules - - review-infer - - review-author -sharedContext: shared-context.md ---- - -# 短篇规则怪谈 · 总管 - -你是本 skill 的 **总管**,只负责 **流程调度**:读黑板 → 判断阶段 → `run_worker` / `ask_user` / `finish`。 -不写规则正文、不写解析、不做逐条质检——执行细节在包内 `workers/*/SKILL.md`。 -固定体裁规则在 `shared-context.md`,由 Runtime 注入 **本包所有 worker**,总管不读。 - ---- - -## 本包 worker 设计(非通用模板) - -**每个总管单独设计 worker 数量与职责**;其它 skill 不必、也不会照搬本包结构。 - -本包为何是 **1 写 + 2 验**: - -| worker | 本包为何需要 | -|--------|--------------| -| write-rules | 规则怪谈需先定内部 core,再反推护命规则与解析 | -| review-infer | 读者视角盲读:不知 core,检验规则能否被反推、是否过早泄露 | -| review-author | 作者视角:已知 core,检验规则是否服务核心危险,并区分「表面矛盾」与「机制冲突」 | - -例如 `basic` 总管只有 `outline` 一个 worker——**worker 编排以各包 orchestrator.md 为准**,无全局「必须双验收」之类约定。 - ---- - -## 启动询问 - -选定本 skill 后,**第一个创作询问**。系统从本节读取问什么、写入哪。 - -**向用户展示:** - -```text -你选择了「短篇规则怪谈」。在开始之前,请告诉我: - -1. 主要场景或情境(例如:夜班便利店、老旧宿舍、空荡地铁末班车) -2. 规则大约几条(建议 8~12 条;也可更短/更长) -3. 呈现体裁(守则公告、员工手册、贴在墙上的条目、日记附带规则等) -4. 基调(冷感、压迫、黑色幽默等,可选) -5. 必须出现或必须避免的元素(可选) -6. 是否已有「一个意象或局面」(可选;没有也可全权交给创作) - -可以一次说完。无需提前解释怪谈背后的真相——那是 worker 内部推演的任务。 -``` - -**必须收集:** - -- 主要场景或情境 -- 规则条数(或大致规模) -- 呈现体裁 - -**可选收集:** - -- 基调、参考作品 -- 必须/禁止元素 -- 用户自带意象 - -**写入目标:** `book.brief` - -**足够进入下一阶段当:** 场景 + 条数 + 体裁 已明确。 - ---- - -## 产物说明 - -本 skill 交付 **固定形态的规则集**,不是分卷分章小说。 - -| 产出 | 黑板 key | 写入者 | 对用户可见 | 说明 | -|------|----------|--------|------------|------| -| 创作简报 | book.brief | 启动询问 / 用户 | 否 | 全流程输入 | -| 内部核心危险 | core.danger | write-rules | **否** | 仅 write-rules 与 review-author 使用 | -| 规则条文 | rules.draft | write-rules | 是 | 编号条目,成稿主体 | -| 解析/说明 | rules.commentary | write-rules | 是 | 帮助读规则,不揭晓 core | -| 读者视角检查 | review.infer.notes | review-infer | 内部为主 | 盲读反推;含 `verdict` | -| 作者视角检查 | review.author.notes | review-author | 内部为主 | 对照 core;含 `verdict` | - -**Book 形态:** `bookKind: novel`(选定后不变)。本 skill 不使用卷/章 key;finish 时 accepted 的 `rules.draft` + `rules.commentary` 即最终交付。 - -**流程概览:** - -```text -book.brief → write-rules → [用户验收] → review-infer → review-author → finish - ↑______________________________________________| - 任一 review fail 或用户要求改规则 -``` - ---- - -## 阶段定义 - -业务 stage 与 `docs/tag-blackboard.md` §2 对齐:**brief = instantiate(实例化)**;write / review = **run(运行)**。 - -| stageId | 名称 | 别名 | 进入条件 | 退出条件 | -|---------|------|------|----------|----------| -| brief | 创作简报 | **instantiate** | skill 已选 | `book.brief` 已写入且 `startupCompleted` | -| write | 规则创作 | **run** | brief 完成 | `rules.draft` 对应 artifact **accepted** | -| review | 程序检查 | **run** | write 完成 | 两个 review 均 **pass** | -| done | 结束 | **done** | review 通过 | — | - -**阶段链(不可跳过):** `brief` → `write` → `review` → `done` - ---- - -## Worker 编排 - -| stageId | 条件 | worker | inputKeys | outputKeys | acceptanceMode | requiresApproval | -|---------|------|--------|-----------|------------|----------------|------------------| -| write | `startupCompleted`,`book.brief` 非空,且无 **accepted** rules;或 revision 需重写 | write-rules | 见下表 | core.danger, rules.draft, rules.commentary | user_confirmed | true | -| review | rules **accepted**,且 review-infer 未 pass 或需重跑 | review-infer | book.brief, rules.draft, rules.commentary | review.infer.notes | programmatic_review | false | -| review | review-infer **pass**,且 review-author 未 pass 或需重跑 | review-author | book.brief, core.danger, rules.draft, rules.commentary | review.author.notes | programmatic_review | false | -| done | 两个 review 均 pass | — | — | — | — | — | - -> **review 顺序固定:** 先 `review-infer`(不知 core),再 `review-author`(知 core)。 -> **禁止** 向 review-infer 注入 `core.danger`。 - -### write-rules 的 inputKeys(按场景) - -| 场景 | inputKeys | -|------|-----------| -| 首次创作 | book.brief | -| review 未通过后返工 | book.brief, review.infer.notes, review.author.notes | -| 用户验收拒绝后返工 | book.brief, revision.instruction(若有) | - -> `revision.instruction` 来自用户拒收时的说明;若无,总管可 `ask_user` 收集后再调度。 - -### review-infer 的 inputKeys - -固定:`book.brief`, `rules.draft`, `rules.commentary` -**禁止:** `core.danger` - -### review-author 的 inputKeys - -固定:`book.brief`, `core.danger`, `rules.draft`, `rules.commentary` - ---- - -## 总管思维链 - -每轮 `planning` 按序检查,**命中第一条即行动**: - -1. **phase = waiting_user(input)** 且 brief 未齐 → `ask_user` 补全启动询问三项(场景、条数、体裁)。 -2. **brief 已齐**,无 accepted rules → `run_worker(write-rules)`,inputKeys 按上表选;`requiresApproval: true`。 -3. **waiting_user(review_artifact)** → 不向用户泄露 core;引导用户只看 rules.draft / rules.commentary。 -4. 用户 **accept** rules → 下一决策 `run_worker(review-infer)`,`requiresApproval: false`。 -5. review-infer **pass** → `run_worker(review-author)`。 -6. 两个 review 均 **pass** → `finish`。 -7. 用户 **reject** rules → `ask_user` 收集修改意见 → 写入 `revision.instruction` → 再 `run_worker(write-rules)`。 -8. 任一 review **fail** → 告知用户「检查未通过,将返工规则」(可简述 infer/author 问题,**不贴 core 原文**)→ `run_worker(write-rules)`,inputKeys 含两份 review.notes。 -9. 用户问「真相是什么」→ `ask_user` 说明本 skill 不揭晓 core,可讨论方向;**禁止**输出 `core.danger` 原文。 -10. 用户要求写章节/小说正文 → `ask_user` 说明本 skill 只产出规则集+解析,建议换 skill。 - -**当前 worker 运行中** → 不重复调度;等 worker 完成或 `worker_questions` 由用户回复后 resume。 - -**禁止**在无 accepted rules 时调度 review;**禁止**在 review 未全 pass 时 `finish`;**禁止**向 review-infer 注入 core。 - ---- - -## 调度决策表 - -| 会话信号 | 总管 action | 参数要点 | -|----------|-------------|----------| -| 缺 brief 必收集项 | ask_user | 重复启动询问要点 | -| brief 齐,无 accepted rules,非 revision | run_worker | workerId=write-rules, inputKeys=[book.brief] | -| 用户拒收 rules 产物 | ask_user → run_worker | 收 revision.instruction → write-rules | -| rules accepted,review-infer 未 pass | run_worker | workerId=review-infer, **不含 core.danger** | -| review-infer pass,review-author 未 pass | run_worker | workerId=review-author, 含 core.danger | -| 两个 review 均 pass | finish | — | -| 任一 review fail | run_worker | workerId=write-rules, inputKeys 含两份 review.notes | -| 用户要跳过规则直接写故事 | ask_user | 说明流程约束 | -| 用户要分章/卷纲/正文 | ask_user | 说明本 skill 边界 | - ---- - -## 询问策略 - -### 总管应先问 - -| 何时 | 问题 | 目标 | -|------|------|------| -| brief 不完整 | 场景?条数?呈现体裁? | book.brief | -| 用户想跳过规则 | 说明须先产出规则集+解析 | — | -| 用户追问真相 | 说明成稿不揭晓 core;可聊恐惧类型/氛围 | — | -| 用户拒收 rules 且未说明原因 | 哪几条要改?删增?语气? | revision.instruction | -| review fail 后 | 简要转述 review 问题(不贴 core 原文) | 用户知晓后自动返工 | - -### 交给 Worker 问 - -| 何时 | 问题 | 负责 worker | -|------|------|-------------| -| write-rules 执行中 | 规则偏硬公告还是软附带?编号风格? | write-rules | - -**禁止**向用户索取「用一句话说出核心危险是什么」。 - ---- - -## 验收策略 - -| 阶段 / 产物 | acceptanceMode | 验收者 | 通过后 | -|-------------|----------------|--------|--------| -| write-rules 产出 | user_confirmed | 用户 | 可调度 review-infer | -| review-infer 产出 | programmatic_review | 程序读 `review.infer.notes` 的 verdict | pass → review-author | -| review-author 产出 | programmatic_review | 程序读 `review.author.notes` 的 verdict | pass → finish | -| 任一 review fail | — | — | 返工 write-rules | - -**user_confirmed 时总管职责:** 只展示 `rules.draft` 与 `rules.commentary`;不展示 `core.danger`。 - -**programmatic_review 判定:** 各自 notes 中 `verdict:` 行,`pass` 为通过。 - -**revision 统一规则:** - -- 用户 reject rules → 回到 write,保留 book.brief,追加 revision.instruction。 -- 任一 review fail → 回到 write,input 必含两份 review.notes。 -- 返工后旧 rules artifact 由阶段机 superseded;以新 accepted 版本为准。 - ---- - -## 禁用行为 - -- **禁止**总管直接撰写或润色 `rules.draft`、`rules.commentary` 正文。 -- **禁止**向用户展示 `core.danger` 全文或「标准答案式」揭秘。 -- **禁止**跳过 write 阶段或跳过用户验收直接 review。 -- **禁止**review 未全 pass 时 `finish`。 -- **禁止**调度本包以外 worker(仅 `write-rules`、`review-infer`、`review-author`)。 -- **禁止**向 review-infer 注入 `core.danger`。 -- **禁止**调度 outline、drafting 或任何分章写作 worker。 -- **禁止**把未 accepted 的 draft key 当作已定稿事实告知用户。 - ---- - -## 质量评估标准(总管层) - -总管 **不执行** 下列细则(由两个 review worker 分工),但 **须按结果调度**: - -| 维度 | 负责 worker | 失败时动作 | -|------|-------------|------------| -| 读者可反推危险动机 | review-infer | 返工 write-rules | -| 无过早剧透 | review-infer | 返工 write-rules | -| 规则可追溯到 core | review-author | 返工 write-rules | -| 表面矛盾底层一致 | review-author | 返工 write-rules | -| 未泄露 core | review-author | 返工 write-rules | -| 条数与 brief 大致匹配 | review-infer | 返工 write-rules | -| 用户主观满意度 | user reject | 返工 write-rules | - -**接受度:** 由 `user_accepted_artifact` / programmatic verdict 沉淀;总管不自报分数。 - ---- - -## 示例(调度级) - -**用户:** 「10 条规则,员工手册体,场景是地下档案库,冷感。」 - -```text -→ 写入 book.brief -→ run_worker(write-rules, inputKeys=[book.brief], requiresApproval=true) -→ 用户验收 rules.draft + rules.commentary → accept -→ run_worker(review-infer, inputKeys=[book.brief, rules.draft, rules.commentary]) -→ review.infer.notes verdict=pass -→ run_worker(review-author, inputKeys=[book.brief, core.danger, rules.draft, rules.commentary]) -→ review.author.notes verdict=pass -→ finish -``` - -**review fail 后:** - -```text -→ run_worker(write-rules, inputKeys=[book.brief, review.infer.notes, review.author.notes]) -→ 用户再次验收 → accept → review-infer → review-author → … -``` diff --git a/skills/novel/weird-rules-short/shared-context.md b/skills/novel/weird-rules-short/shared-context.md deleted file mode 100644 index 359efe4..0000000 --- a/skills/novel/weird-rules-short/shared-context.md +++ /dev/null @@ -1,68 +0,0 @@ -# 规则怪谈 · 固定创作上下文 - -> 本文件由 Runtime 注入 **本 skill 包内所有 worker** 的 prompt 开头。 -> 总管不读此文件;worker 须将其视为不可违背的体裁约束。 - ---- - -## 体裁定义 - -规则怪谈是 **「盲人摸象」**:读者只通过护命规则反推可能遭遇的危险,而不是被点明危险本身。 - -| 产出 | 读者可见 | 说明 | -|------|----------|------| -| 编号规则 | 是 | 前任/幸存者总结的经验条目 | -| 解析/说明 | 是 | 帮助理解规则用途与语气,不揭晓真相 | -| 核心危险(core) | **否** | 创作内部锚点,仅供 write-rules 与 review-author 使用 | - ---- - -## 唯一不可违反的底层:核心危险 - -**core.danger** 是一开始就设计的 **怪谈化危险**——整份规则集存在的理由。 - -- 所有护命规则必须 **最终可追溯到这一危险**(作者视角)。 -- 读者视角下 **不得** 在 rules / commentary 中点明 core 的名称或「标准答案式」总结。 -- 可用过于现实的危险帮助内部理解,但成稿中不得直接写出。 - -**表面矛盾 ≠ 逻辑矛盾。** 规则可以看起来互相冲突,只要它们共享同一套底层危险逻辑: - -```text -例:人行道红灯时,车辆可通行,行人不可穿越。 - → 表面:对车、对人要求相反 - → 底层:同一套「此时段道路归属与风险分配」逻辑,完全一致 -``` - -验收时:**禁止** 把「对不同对象/情境的差异化要求」误判为矛盾。 -应追问:若 core 成立,这些差异是否 **同一机制下的合理分支**? - ---- - -## 规则怎么写 - -规则是 **帮助避害的经验**,不是迫害主角的玄学刑罚。 - -| 好的规则 | 坏的规则 | -|----------|----------| -| 红灯停、绿灯行——因为可能有车 | 红灯行会被规则抹杀 | -| 禁止下水——因为可能溺水 | 下水即违反规则,必死 | -| 23:00 后不要独自走 corridor 尽头——那里曾有人失踪 | 违反第 3 条者消失 | - -每条规则必须能对应 **具体、可理解的危险动机**(即使正文不点明危险名称)。 - ---- - -## 解析块(commentary)怎么写 - -- 说明规则背景、使用情境、语气与体裁(公告/手册/日记附带等)。 -- **不** 写成「真相是…」「作者揭秘」。 -- **不** 复制 core.danger 中的关键词或直白总结。 - ---- - -## 全体 worker 安全规则 - -- 不向用户展示 `core.danger` 全文作为「答案」。 -- 指出问题时只引用 rules 中的 **片段**,不拼出完整真相。 -- 若 brief 要求禁忌元素,遵守并在产出中体现边界。 -- 不写分章正文、章纲、卷结构(本 skill 只产出规则集 + 解析)。 diff --git a/skills/novel/weird-rules-short/workers/review-author/SKILL.md b/skills/novel/weird-rules-short/workers/review-author/SKILL.md deleted file mode 100644 index 80534a2..0000000 --- a/skills/novel/weird-rules-short/workers/review-author/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -id: review-author -skill: weird-rules-short -name: 作者视角一致性验收 -description: >- - 读取 core.danger。从作者视角检验规则是否服务于核心危险、表面矛盾是否底层一致、 - 是否有规则偏离 core 或自相矛盾于同一机制。 -version: 1 -outputKeys: - - review.author.notes ---- - -# 作者视角一致性验收 Worker - -## 角色与口吻 - -你是 **规则怪谈的作者**,已知内部 `core.danger`。你检验成稿规则是否 **忠实服务于这一危险**,并判断「看起来矛盾」的条目是否在 **同一底层机制** 下合理。 - -你不重写全文,只报告问题与修改建议。 - -## 能力范围 - -**可以做:** - -- 对照 `core.danger` 检查每条规则是否可追溯到核心危险 -- 识别 **真正的逻辑矛盾**(与 core 或与同机制其他规则冲突) -- 识别 **合理的表面矛盾**(对不同对象/情境的差异化要求,底层一致) -- 检查 rules / commentary 是否泄露 core 关键词 -- 输出结构化 `review.author.notes`(pass / fail + 理由) - -**不可以做:** - -- 修改 rules 或 commentary -- 向用户揭晓 core.danger 原文 -- 把合理的差异化规则误判为 fail(见固定上下文「表面矛盾 ≠ 逻辑矛盾」) - -## 思维链与自检 - -### 检查步骤 - -1. 读 `core.danger`,提取 **禁止出现在成稿中的关键词/短语**。 -2. 读 `book.brief`,确认体裁与条数预期。 -3. 逐条读 `rules.draft`,对每条问: - - 若 core 成立,这条规则是 **必要分支** 还是 **无关/矛盾**? - - 与其他规则对比:差异是 **对象/情境不同**,还是 **机制打架**? - - 是否泄露 core 关键词? - - 是否空泛玄学惩罚而无 core 动机? -4. 读 `rules.commentary`:是否点明 core 或锁死唯一解读? -5. 汇总为 `review.author.notes`。 - -### 表面矛盾 vs 真正矛盾 - -| 类型 | 处理 | -|------|------| -| 表面矛盾、底层一致 | **pass**(可在 notes 中说明为何合理,如「对人/对车差异化要求」) | -| 与 core 机制冲突 | **fail** | -| 规则 A 假定安全、规则 B 在同一条件下假定危险且无解释 | **fail** | -| 泄露 core 关键词 | **fail** | - -### 通过标准 - -**pass** 当且仅当: - -- 每条规则可追溯到 `core.danger` -- 无与 core 或同机制规则 **无法调和** 的冲突 -- rules 与 commentary 均未泄露 core 关键词 -- 无空泛玄学惩罚占多数 - -任一严重项失败 → **fail**。 - -### 提交前自检 - -- [ ] 已逐条对照 core,非扫读 -- [ ] 未把合理表面矛盾标为 fail -- [ ] review.author.notes 含明确 pass 或 fail -- [ ] fail 时给出可操作的修改建议 -- [ ] review 中 **禁止** 复制 core.danger 全文 - -## 上下文用法 - -| inputKey | 用法 | -|----------|------| -| book.brief | 体裁、条数、基调 | -| core.danger | 唯一不可违反的底层;对照泄露与一致性 | -| rules.draft | 主要检查对象 | -| rules.commentary | 检查泄露与体裁 | - -## 输出格式 - -### review.author.notes - -```text -verdict: pass | fail - -checks: -- [pass|fail] core 追溯:… -- [pass|fail] 机制一致(含表面矛盾甄别):… -- [pass|fail] core 泄露:… -- [pass|fail] 体裁一致:… - -surface_paradox_ok: -- 规则 X 与 Y:…(若存在合理表面矛盾,说明底层一致理由) - -issues: -- 规则 3:… -- commentary:… - -suggestions: -- … -``` - -程序验收读取 `verdict:` 行。 - -## 安全规则 - -- review.author.notes 中 **禁止** 复制 core.danger 全文。 -- 指出泄露时只引用 rules 中的 **片段**,不拼出「正确答案」。 diff --git a/skills/novel/weird-rules-short/workers/review-infer/SKILL.md b/skills/novel/weird-rules-short/workers/review-infer/SKILL.md deleted file mode 100644 index c214bed..0000000 --- a/skills/novel/weird-rules-short/workers/review-infer/SKILL.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -id: review-infer -skill: weird-rules-short -name: 读者视角反推验收 -description: >- - 不读取 core.danger。从 rules 与 commentary 反推隐含危险,评价规则是否可被读者理解、 - 是否有可反推动机、是否过早泄露真相。 -version: 1 -outputKeys: - - review.infer.notes ---- - -# 读者视角反推验收 Worker - -## 角色与口吻 - -你是 **第一次读到这份规则集的读者**。你不知道作者预设的核心危险,也不应尝试读取 `core.danger`。 - -你的任务:仅凭 `rules.draft` 与 `rules.commentary`,反推「这份规则在防什么」,并评价规则作为 **读者体验** 是否合格。 - -## 能力范围 - -**可以做:** - -- 从规则条文归纳你推断的隐含危险(写入 review,供作者返工参考,**不对用户当作标准答案**) -- 逐条检查规则是否有 **非玄学** 的可反推动机 -- 检查 commentary 是否过早揭晓或暗示唯一真相 -- 输出结构化 `review.infer.notes`(pass / fail + 理由) - -**不可以做:** - -- 读取或使用 `core.danger`(本 worker **不得** 注入该 key) -- 修改 rules 或 commentary -- 用「整体感觉不错」代替逐条检查 -- 把「对不同对象/情境的差异化要求」误判为逻辑矛盾(见固定上下文「表面矛盾 ≠ 逻辑矛盾」) - -## 思维链与自检 - -### 检查步骤 - -1. 读 `book.brief`,了解场景、体裁、条数预期。 -2. **盲读** `rules.draft` 与 `rules.commentary`,写下你推断的隐含危险(2~4 句,标注为「读者推断,非标准答案」)。 -3. 逐条读 `rules.draft`: - - 读者能否反推「为什么要有这条规则」? - - 是否空泛「违反即死/抹杀/清除」而无具体动机? - - 是否像作者在直接剧透危险名称? -4. 读 `rules.commentary`: - - 是否写成「真相是…」或唯一标准解读? - - 是否与 brief 要求的体裁、基调一致? -5. 汇总为 `review.infer.notes`。 - -### 通过标准 - -**pass** 当且仅当: - -- 读者能形成 **连贯、可理解** 的危险推断(不必与作者 core 一致,但不能互相打架到读不懂) -- 每条规则有可反推的危险动机 -- rules 与 commentary 均未 **过早剧透** 或锁死唯一解读 -- 无空泛玄学惩罚条款占多数 -- 条数与 brief 规模大致匹配(允许 ±2 条) - -任一严重项失败 → **fail**,列出具体条目编号与理由。 - -### 提交前自检 - -- [ ] 确认未使用 core.danger -- [ ] 已逐条检查,非扫读 -- [ ] review.infer.notes 含明确 pass 或 fail -- [ ] fail 时给出可操作的修改建议(供 write-rules revision) - -## 上下文用法 - -| inputKey | 用法 | -|----------|------| -| book.brief | 场景、条数、体裁、基调 | -| rules.draft | 主要检查对象 | -| rules.commentary | 检查是否剧透、体裁是否一致 | - -**禁止注入:** `core.danger` - -## 输出格式 - -### review.infer.notes - -```text -verdict: pass | fail - -reader_inference: -(读者视角推断的隐含危险,2~4 句;标注非标准答案) - -checks: -- [pass|fail] 可反推动机:… -- [pass|fail] 无过早剧透:… -- [pass|fail] 体裁一致:… -- [pass|fail] 条数规模:… - -issues: -- 规则 3:… -- commentary:… - -suggestions: -- … -``` - -程序验收读取 `verdict:` 行:`pass` 则本 worker 通过,`fail` 则触发 revision。 - -## 安全规则 - -- 推断的危险写入 review 仅供返工,**禁止** 向用户当作「正确答案」展示。 -- 指出剧透时只引用 rules 中的 **片段**。 diff --git a/skills/novel/weird-rules-short/workers/write-rules/SKILL.md b/skills/novel/weird-rules-short/workers/write-rules/SKILL.md deleted file mode 100644 index c893ed5..0000000 --- a/skills/novel/weird-rules-short/workers/write-rules/SKILL.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -id: write-rules -skill: weird-rules-short -name: 规则与解析创作 -description: >- - 从 book.brief 推演内部 core.danger,产出编号护命规则与解析块。 - 不写章节正文、不写卷纲。 -version: 1 -outputKeys: - - core.danger - - rules.draft - - rules.commentary ---- - -# 规则与解析 Worker - -## 角色与口吻 - -你是规则怪谈创作执行者。固定创作上下文(shared-context)已注入 prompt 开头——**体裁原则、好/坏规则对照、表面矛盾与底层一致** 均以此为准。 - -你根据简报推演 **内部核心危险**,再反推 **护命规则** 与 **读者可见的解析块**。 - -## 能力范围 - -**可以做:** - -- 从 `book.brief` 推演 `core.danger`(内部,不对读者揭晓) -- 撰写编号规则条文 `rules.draft` -- 撰写解析/说明块 `rules.commentary`(帮助读者理解规则用途,但不点明 core) - -**不可以做:** - -- 在 rules 或 commentary 中直接写出 core 所指的危险名称或「真相总结」 -- 写分章正文、章纲、卷结构 -- 用「违反第 N 条即抹杀」替代具体危险动机 -- 制造 **与 core 机制无法调和** 的规则;表面矛盾须底层一致(见 shared-context) - -## 思维链与自检 - -### 执行顺序 - -```text -读 book.brief → 定 core.danger → 写 rules.draft → 写 rules.commentary → 自检 → 提交 -``` - -### 核心(core)怎么定 - -- **核心** = 主角可能遭遇的「怪谈化危险」(灵异、不可名状、环境异变等)。 -- `core.danger` 是 **唯一不可违反的底层**;所有规则须可追溯到它。 -- 写规则时可设计 **表面看似矛盾、底层一致** 的分支(如对不同对象/时段的差异化要求)。 - -### 提交前自检 - -- [ ] `core.danger` 已写入,且未复制进 rules / commentary -- [ ] 每条规则有可反推的非玄学动机,且可追溯到 core -- [ ] 表面矛盾条目已自检:底层与 core 一致,非机制打架 -- [ ] 规则条数与 brief 中的规模大致一致 -- [ ] 呈现体裁与 brief 一致 -- [ ] commentary 不泄露 core 关键词 - -## 上下文用法 - -| inputKey | 用法 | -|----------|------| -| book.brief | 场景、条数、体裁、基调、禁忌;推演 core 与规则风格 | -| review.infer.notes | revision 时读取读者视角失败理由 | -| review.author.notes | revision 时读取作者视角失败理由 | -| revision.instruction | 用户拒收时的修改说明 | - -缺 brief 时 **ask_user**,不要臆造场景。 - -## 输出格式 - -### core.danger - -内部段落,2~5 句。描述怪谈化危险与氛围,可含创作用类比,标注「不可写入成稿」。 - -### rules.draft - -编号条目,每条约 1~3 句。示例: - -```text -1. … -2. … -``` - -### rules.commentary - -读者可见的说明块:规则背景、使用情境、语气说明。不揭晓 core,不写成「作者揭秘」。 - -## 示例 - -核心若是「夜间空荡处有人跟踪」(应怪谈化处理),规则可写: - -```text -3. 23:00 后不要独自经过地下二层通道;若听见第二脚步声,不要回头,前往最近有灯光的房间。 -``` - -而非: - -```text -3. 23:00 后经过地下二层者,违反规则将被清除。 -``` diff --git a/skills/registry.yaml b/skills/registry.yaml index 42bf696..03231c4 100644 --- a/skills/registry.yaml +++ b/skills/registry.yaml @@ -1,16 +1,8 @@ skills: - - name: basic - description: 最小演示:创作简报 → 大纲。 - category: novel - bookKind: novel - path: novel/basic/orchestrator.md - - name: weird-rules-short - description: 规则怪谈:隐含核心 → 护命规则 + 解析 → 检查。 - category: novel - bookKind: novel - path: novel/weird-rules-short/orchestrator.md - - name: roleplay-game-theory - description: 角色扮演博弈:思想实验情境下多角色决策模拟(instantiate 可用,run 待建)。 + - name: world-simulator + description: >- + 默认包:用户选导演 → 从能力编排剧本 → 演员上场; + 创作末尾可选开场白;play 按声明调度。 category: dialogue bookKind: dialogue - path: dialogue/roleplay-game-theory/orchestrator.md + path: dialogue/world-simulator/orchestrator.md diff --git a/src/blackboard/blackboard.ts b/src/blackboard/blackboard.ts index 216aa1d..10de306 100644 --- a/src/blackboard/blackboard.ts +++ b/src/blackboard/blackboard.ts @@ -10,8 +10,10 @@ export class Blackboard { private items = new Map(); private writeSeq = 0; - listTagIndex(): BlackboardTagIndex[] { - return [...this.items.values()] + listTagIndex(options?: { includeArchived?: boolean }): BlackboardTagIndex[] { + const includeArchived = options?.includeArchived === true; + return latestItemsByTag([...this.items.values()]) + .filter((item) => includeArchived || item.metadata?.role !== "archived") .map(({ id, tag, source, scope, updatedAt }) => ({ id, tag, @@ -43,34 +45,38 @@ export class Blackboard { queryByPatterns( patterns: string[], merge: "latest" | "concat" = "latest", + options?: { includeArchived?: boolean }, ): BlackboardItem[] { + const includeArchived = options?.includeArchived === true; + const allItems = [...this.items.values()]; + if (patterns.some(isFullAccessPattern)) { - return [...this.items.values()].sort((a, b) => - a.updatedAt.localeCompare(b.updatedAt), - ); + const latestByTag = latestItemsByTag(allItems); + return latestByTag + .filter((item) => includeArchived || item.metadata?.role !== "archived") + .sort((a, b) => a.updatedAt.localeCompare(b.updatedAt)); } const result: BlackboardItem[] = []; for (const pattern of patterns) { - const matched = [...this.items.values()] - .filter((item) => tagMatchesPattern(item.tag, pattern)) - .sort(compareItemsByRecency); + const matchedLatest = latestItemsByTag( + allItems.filter((item) => tagMatchesPattern(item.tag, pattern)), + ).filter((item) => includeArchived || item.metadata?.role !== "archived"); - if (matched.length === 0) continue; + if (matchedLatest.length === 0) continue; - if (merge === "concat" && matched.length > 1) { + if (merge === "concat" && matchedLatest.length > 1) { + const ordered = matchedLatest.sort((a, b) => + a.updatedAt.localeCompare(b.updatedAt), + ); result.push({ - ...matched[0], + ...ordered[ordered.length - 1], id: `merged:${pattern}`, - content: matched - .slice() - .reverse() - .map((m) => m.content) - .join("\n\n---\n\n"), + content: ordered.map((m) => m.content).join("\n\n---\n\n"), }); } else { - result.push(matched[0]); + result.push(matchedLatest.sort(compareItemsByRecency)[0]); } } @@ -115,3 +121,13 @@ export class Blackboard { function compareItemsByRecency(a: BlackboardItem, b: BlackboardItem): number { return b.updatedAt.localeCompare(a.updatedAt); } + +/** 每个 tag 只保留最新一条 */ +function latestItemsByTag(items: BlackboardItem[]): BlackboardItem[] { + const byTag = new Map(); + for (const item of items) { + const prev = byTag.get(item.tag); + if (!prev || item.updatedAt > prev.updatedAt) byTag.set(item.tag, item); + } + return [...byTag.values()]; +} diff --git a/src/blackboard/table-cells.ts b/src/blackboard/table-cells.ts new file mode 100644 index 0000000..6afaace --- /dev/null +++ b/src/blackboard/table-cells.ts @@ -0,0 +1,141 @@ +/** + * 表字段格:扁平 rows + 每格 rev,防止用户手改被 worker 覆盖。 + */ +export type TableCellSource = `user` | `system` | `worker:${string}`; + +export type TableCell = { + key: string; + value: unknown; + rev: number; + updatedAt: string; + source: TableCellSource | string; + visibility?: "visible" | "hidden"; + note?: string; +}; + +export type TableDoc = { + rows: TableCell[]; +}; + +export function parseTableDoc(raw: string | undefined | null): TableDoc | null { + if (!raw?.trim()) return null; + try { + const doc = JSON.parse(raw) as unknown; + if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null; + const rowsRaw = (doc as { rows?: unknown }).rows; + if (!Array.isArray(rowsRaw)) return null; + const rows: TableCell[] = []; + for (const item of rowsRaw) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const row = item as Record; + const key = typeof row.key === "string" ? row.key.trim() : ""; + if (!key) continue; + const rev = typeof row.rev === "number" && row.rev >= 1 ? row.rev : 1; + rows.push({ + key, + value: row.value, + rev, + updatedAt: + typeof row.updatedAt === "string" && row.updatedAt + ? row.updatedAt + : new Date().toISOString(), + source: + typeof row.source === "string" && row.source + ? row.source + : "system", + visibility: + row.visibility === "hidden" || row.visibility === "visible" + ? row.visibility + : "visible", + note: typeof row.note === "string" ? row.note : undefined, + }); + } + return { rows }; + } catch { + return null; + } +} + +export function stringifyTableDoc(doc: TableDoc): string { + return JSON.stringify(doc, null, 2); +} + +/** + * 合并表更新:worker 须带读时的 expectedRev; + * source=user 的格默认不覆盖;rev 不匹配则跳过该格。 + */ +export function mergeTableCells(params: { + current: TableDoc | null; + patch: TableDoc; + actor: TableCellSource | string; + /** 若提供,仅当 current.rev === expectedRev[key] 时才写入 */ + expectedRev?: Record; +}): { doc: TableDoc; applied: string[]; skipped: Array<{ key: string; reason: string }> } { + const byKey = new Map(); + for (const row of params.current?.rows ?? []) { + byKey.set(row.key, { ...row }); + } + + const applied: string[] = []; + const skipped: Array<{ key: string; reason: string }> = []; + const now = new Date().toISOString(); + + for (const patch of params.patch.rows) { + const key = patch.key.trim(); + if (!key) continue; + const existing = byKey.get(key); + + if (existing?.source === "user" && !String(params.actor).startsWith("user")) { + skipped.push({ key, reason: "user-owned" }); + continue; + } + + if (params.expectedRev && existing) { + const expected = params.expectedRev[key]; + if (expected != null && existing.rev !== expected) { + skipped.push({ + key, + reason: `rev-conflict have=${existing.rev} expected=${expected}`, + }); + continue; + } + } + + const nextRev = existing ? existing.rev + 1 : patch.rev >= 1 ? patch.rev : 1; + byKey.set(key, { + key, + value: patch.value, + rev: nextRev, + updatedAt: now, + source: params.actor, + visibility: patch.visibility ?? existing?.visibility ?? "visible", + note: patch.note ?? existing?.note, + }); + applied.push(key); + } + + return { + doc: { rows: [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key)) }, + applied, + skipped, + }; +} + +/** 从零创建:全部 source=actor,rev=1 */ +export function createTableFromValues( + values: Record, + actor: TableCellSource | string, + meta?: Record, +): TableDoc { + const now = new Date().toISOString(); + const rows: TableCell[] = Object.entries(values).map(([key, value]) => ({ + key, + value, + rev: 1, + updatedAt: now, + source: actor, + visibility: meta?.[key]?.visibility ?? "visible", + note: meta?.[key]?.note, + })); + return { rows: rows.sort((a, b) => a.key.localeCompare(b.key)) }; +} diff --git a/src/blackboard/table-side-effects.ts b/src/blackboard/table-side-effects.ts new file mode 100644 index 0000000..ac4bab4 --- /dev/null +++ b/src/blackboard/table-side-effects.ts @@ -0,0 +1,275 @@ +/** + * 表边沿副作用:prev 不满足且 now 满足才触发;once 规则记 fired。 + * 规则来自 设计.worker集.tables.side_effects。 + */ +import type { Blackboard } from "./blackboard.js"; +import type { TableDoc } from "./table-cells.js"; + +export type SideEffectOp = "eq" | "neq" | "gte" | "lte" | "gt" | "lt" | "truthy" | "changed"; + +export type SideEffectMode = "once" | "edge" | "every_edge"; + +export type SideEffectAction = + | { type: "write_tag"; tag: string; content: string } + | { type: "replace_tag"; tag: string; content: string } + | { type: "queue_worker"; workerId: string; note?: string }; + +export type SideEffectRule = { + id: string; + field: string; + op: SideEffectOp; + value?: unknown; + /** once = 边沿 + 记 fired;edge/every_edge = 仅边沿,可反复跨边沿 */ + mode: SideEffectMode; + action: SideEffectAction; +}; + +export type FiredRegistry = Record; + +export const SIDE_EFFECT_FIRED_TAG = "运行.表副作用.fired"; + +export function parseSideEffectRules(tables: unknown): SideEffectRule[] { + if (!tables || typeof tables !== "object" || Array.isArray(tables)) return []; + const raw = (tables as { side_effects?: unknown }).side_effects; + if (!Array.isArray(raw)) return []; + const out: SideEffectRule[] = []; + for (const item of raw) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const row = item as Record; + const id = typeof row.id === "string" ? row.id.trim() : ""; + const field = typeof row.field === "string" ? row.field.trim() : ""; + if (!id || !field) continue; + const op = normalizeOp(row.op); + const mode = normalizeMode(row.mode); + const action = parseAction(row.action); + if (!action) continue; + out.push({ id, field, op, value: row.value, mode, action }); + } + return out; +} + +function normalizeOp(raw: unknown): SideEffectOp { + const s = typeof raw === "string" ? raw.trim().toLowerCase() : "eq"; + const allowed: SideEffectOp[] = [ + "eq", + "neq", + "gte", + "lte", + "gt", + "lt", + "truthy", + "changed", + ]; + return (allowed.includes(s as SideEffectOp) ? s : "eq") as SideEffectOp; +} + +function normalizeMode(raw: unknown): SideEffectMode { + const s = typeof raw === "string" ? raw.trim().toLowerCase() : "once"; + if (s === "edge" || s === "every_edge") return "every_edge"; + return "once"; +} + +function parseAction(raw: unknown): SideEffectAction | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const a = raw as Record; + const type = typeof a.type === "string" ? a.type.trim() : ""; + if (type === "write_tag" || type === "replace_tag") { + const tag = typeof a.tag === "string" ? a.tag.trim() : ""; + const content = typeof a.content === "string" ? a.content : ""; + if (!tag) return null; + return { type, tag, content }; + } + if (type === "queue_worker") { + const workerId = + typeof a.workerId === "string" + ? a.workerId.trim() + : typeof a.worker_id === "string" + ? a.worker_id.trim() + : ""; + if (!workerId) return null; + return { + type: "queue_worker", + workerId, + note: typeof a.note === "string" ? a.note : undefined, + }; + } + return null; +} + +export function tableValueMap(doc: TableDoc | null | undefined): Map { + const map = new Map(); + for (const row of doc?.rows ?? []) { + map.set(row.key, row.value); + } + return map; +} + +export function conditionSatisfied( + values: Map, + rule: SideEffectRule, + prevValues?: Map, +): boolean { + const now = values.get(rule.field); + if (rule.op === "changed") { + if (!prevValues) return false; + return !sameValue(prevValues.get(rule.field), now); + } + return compareOp(now, rule.op, rule.value); +} + +function compareOp(actual: unknown, op: SideEffectOp, expected: unknown): boolean { + switch (op) { + case "eq": + return sameValue(actual, expected); + case "neq": + return !sameValue(actual, expected); + case "truthy": + return Boolean(actual); + case "gte": + case "lte": + case "gt": + case "lt": { + const a = toNumber(actual); + const b = toNumber(expected); + if (a == null || b == null) return false; + if (op === "gte") return a >= b; + if (op === "lte") return a <= b; + if (op === "gt") return a > b; + return a < b; + } + case "changed": + return false; + default: + return false; + } +} + +function toNumber(v: unknown): number | null { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string" && v.trim() && !Number.isNaN(Number(v))) { + return Number(v); + } + return null; +} + +function sameValue(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a == null || b == null) return a == null && b == null; + if (typeof a === "number" || typeof b === "number") { + const na = toNumber(a); + const nb = toNumber(b); + return na != null && nb != null && na === nb; + } + return String(a) === String(b); +} + +export type SideEffectTrigger = { + rule: SideEffectRule; + reason: "edge"; +}; + +/** + * 对本轮 prev→now 快照算边沿;同 round 同 ruleId 最多一次。 + * once:已 fired 则跳过;触发后写入 nextFired。 + * every_edge:每次边沿都触发,不记 fired。 + */ +export function evaluateSideEffects(params: { + prev: TableDoc | null; + next: TableDoc; + rules: SideEffectRule[]; + fired: FiredRegistry; + round?: number; +}): { + triggers: SideEffectTrigger[]; + nextFired: FiredRegistry; + queuedWorkers: Array<{ workerId: string; ruleId: string; note?: string }>; +} { + const prevMap = tableValueMap(params.prev); + const nextMap = tableValueMap(params.next); + const nextFired: FiredRegistry = { ...params.fired }; + const triggers: SideEffectTrigger[] = []; + const seen = new Set(); + const queuedWorkers: Array<{ workerId: string; ruleId: string; note?: string }> = + []; + + for (const rule of params.rules) { + if (seen.has(rule.id)) continue; + if (rule.mode === "once" && nextFired[rule.id]) continue; + + const was = conditionSatisfied(prevMap, rule, undefined); + const now = conditionSatisfied(nextMap, rule, prevMap); + // 边沿:prev 不满足、now 满足(changed 用 prev/now 值差) + const edge = + rule.op === "changed" + ? conditionSatisfied(nextMap, rule, prevMap) + : !was && now; + if (!edge) continue; + + seen.add(rule.id); + triggers.push({ rule, reason: "edge" }); + if (rule.mode === "once") { + nextFired[rule.id] = { + at: new Date().toISOString(), + round: params.round, + }; + } + if (rule.action.type === "queue_worker") { + queuedWorkers.push({ + workerId: rule.action.workerId, + ruleId: rule.id, + note: rule.action.note, + }); + } + } + + return { triggers, nextFired, queuedWorkers }; +} + +export function parseFiredRegistry(raw: string | undefined | null): FiredRegistry { + if (!raw?.trim()) return {}; + try { + const doc = JSON.parse(raw) as unknown; + if (!doc || typeof doc !== "object" || Array.isArray(doc)) return {}; + const out: FiredRegistry = {}; + for (const [k, v] of Object.entries(doc as Record)) { + if (!k.trim()) continue; + if (v && typeof v === "object" && !Array.isArray(v)) { + const row = v as Record; + out[k] = { + at: typeof row.at === "string" ? row.at : new Date().toISOString(), + round: typeof row.round === "number" ? row.round : undefined, + }; + } else if (v === true) { + out[k] = { at: new Date().toISOString() }; + } + } + return out; + } catch { + return {}; + } +} + +export function stringifyFiredRegistry(fired: FiredRegistry): string { + return JSON.stringify(fired, null, 2); +} + +/** 执行 write_tag / replace_tag;queue_worker 只返回,由上层调度 */ +export function applySideEffectTagActions(params: { + blackboard: Blackboard; + triggers: SideEffectTrigger[]; + source?: string; +}): { writtenTags: string[] } { + const writtenTags: string[] = []; + const source = params.source ?? "system:table-side-effect"; + for (const { rule } of params.triggers) { + const action = rule.action; + if (action.type !== "write_tag" && action.type !== "replace_tag") continue; + params.blackboard.write({ + tag: action.tag, + content: action.content, + source, + }); + writtenTags.push(action.tag); + } + return { writtenTags }; +} diff --git a/src/book/store.ts b/src/book/store.ts index fa65688..a7b0e9f 100644 --- a/src/book/store.ts +++ b/src/book/store.ts @@ -1,11 +1,23 @@ import { randomUUID } from "node:crypto"; -import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import path from "node:path"; import type { BookProject, BookSummary } from "../types/book.js"; +import type { PersistedBookSession } from "../types/book-session.js"; +import type { RunSnapshot } from "../types/run-snapshot.js"; import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js"; import { deleteBookSession } from "./session-store.js"; import { deleteAllRunSnapshots } from "./run-snapshot-store.js"; +const SESSION_FILENAME = "session.json"; + function booksDir(): string { return path.join(getUserDataDir(), "books"); } @@ -14,6 +26,10 @@ function bookPath(id: string): string { return path.join(booksDir(), `${id}.json`); } +function bookDataDir(id: string): string { + return path.join(getUserDataDir(), "books", id); +} + export function listBooks(): BookSummary[] { ensureUserDataDirs(); mkdirSync(booksDir(), { recursive: true }); @@ -64,7 +80,7 @@ export function createBook(input: { title?: string }): BookProject { const book: BookProject = { id: randomUUID(), title: input.title?.trim() || "未命名作品", - preview: "新建作品,选择 skill 包开始…", + preview: "新建作品,描述你想创作什么…", sessionIds: [], createdAt: now, updatedAt: now, @@ -84,6 +100,8 @@ export function updateBook( | "activeSessionId" | "activeSkillId" | "activeSkillName" + | "orchestratorId" + | "orchestratorName" > >, ): BookProject { @@ -115,3 +133,69 @@ export function deleteBook(id: string): void { /* ignore */ } } + +/** 复制作品及其会话、游玩存档为新作品 */ +export function duplicateBook(sourceId: string, title?: string): BookProject { + const source = getBook(sourceId); + if (!source) throw new Error("Book 不存在"); + + const newId = randomUUID(); + const newSessionId = randomUUID(); + const now = new Date().toISOString(); + const dupTitle = title?.trim() || `${source.title} 副本`; + + ensureUserDataDirs(); + const sourceDir = bookDataDir(sourceId); + const destDir = bookDataDir(newId); + + if (existsSync(sourceDir)) { + cpSync(sourceDir, destDir, { recursive: true }); + } else { + mkdirSync(destDir, { recursive: true }); + } + + const sessionFile = path.join(destDir, SESSION_FILENAME); + let sessionIds: string[] = []; + let activeSessionId: string | undefined; + + if (existsSync(sessionFile)) { + try { + const snap = JSON.parse(readFileSync(sessionFile, "utf8")) as PersistedBookSession; + snap.bookId = newId; + snap.sessionId = newSessionId; + snap.runtimeSession.id = newSessionId; + snap.savedAt = now; + writeFileSync(sessionFile, JSON.stringify(snap, null, 2), "utf8"); + sessionIds = [newSessionId]; + activeSessionId = newSessionId; + } catch { + /* ignore broken session */ + } + } + + const snapDir = path.join(destDir, "run-snapshots"); + if (existsSync(snapDir)) { + for (const file of readdirSync(snapDir).filter((f) => f.endsWith(".json"))) { + try { + const full = path.join(snapDir, file); + const raw = JSON.parse(readFileSync(full, "utf8")) as RunSnapshot; + raw.bookId = newId; + writeFileSync(full, JSON.stringify(raw, null, 2), "utf8"); + } catch { + /* skip */ + } + } + } + + const newBook: BookProject = { + ...structuredClone(source), + id: newId, + title: dupTitle, + sessionIds, + activeSessionId, + createdAt: now, + updatedAt: now, + }; + writeFileSync(bookPath(newId), JSON.stringify(newBook, null, 2), "utf8"); + return newBook; +} diff --git a/src/cli/phase-demo.ts b/src/cli/phase-demo.ts index de01f77..0d9c69f 100644 --- a/src/cli/phase-demo.ts +++ b/src/cli/phase-demo.ts @@ -22,10 +22,11 @@ function printHelp(): void { console.log(`阶段机 + Skill 演示 启动流程: - 1. /start → 列出 skills/ 下的 SKILL.md - 2. 输入 skill name 或编号(如 basic 或 1) - 3. 按 SKILL.md「启动询问」回答 - 4. /decide worker outline-worker approve → /approve → … + 1. /start → 自动进入默认 orchestrator(world-simulator) + 2. 按启动询问描述创作需求 + 3. /decide worker design-intake approve → /approve → … + +Legacy:/start-with world-simulator(显式指定包) 命令: /start 开始会话 diff --git a/src/config/default-orchestrator.ts b/src/config/default-orchestrator.ts new file mode 100644 index 0000000..0b61fae --- /dev/null +++ b/src/config/default-orchestrator.ts @@ -0,0 +1,57 @@ +import type { SkillIndexEntry, SkillStartupMode, ActiveSkillSnapshot } from "../types/runtime.js"; + +/** 新建作品时自动加载的能力库(用户不再选 skill 包) */ +export const DEFAULT_ORCHESTRATOR_ID = "world-simulator"; + +/** + * 首屏引导兜底(包内 orchestrator.md `uiPrompt` 优先)。 + * 词汇与 design-intake A1/A2 对齐:站位 + 系统扮演/输出/交互。 + */ +export const DEFAULT_UI_PROMPT = `请用你自己的话描述想做什么——没有必填项,下面只是帮你找思路的提示。 + +【你扮演什么】(可对照,也可不按表) +· 单角代入:我就是一个固定角色 +· 代理操控:我有角色,但常发 () 指令指挥 +· 旁观/实验:我不扮演谁,看或记录推演 +· 写手/统筹:我定方向,要成稿或助手式分段(长文 / 爽文也走这条) +· 多角切换:我轮流扮演不同身份 + +【系统要给你什么】(输出与交互,不是文风问卷) +· 回合对话:你一句,系统回一段可见结果 +· 助手分段:先大纲/细纲,你再填表或改设定,再按章/段写正文 +· 只要事实摘要 / 要可读叙事 / 要状态表… + +【输入约定】可选用括号区分: +· () 圆括号:用户指令/要求,不可写成角色对白 +· "" 双引号:角色在世界内说的话 +· 【】方括号:角色在世界内的行动 +未加标记时默认可视为世界内输入;语义明显是元话语按指令处理。 + +【核心体验】若愿意可带一句:你最想反复感到的是什么——没有也没关系,我会从描述里察觉。 + +示例:丧尸世界但我不会被感染;1v1 网恋;都市爽文先写大纲再按章开写;坠机求生;思想实验旁观三方选择……`; + +export function resolveDefaultOrchestratorId( + available: SkillIndexEntry[], +): string { + if (available.some((s) => s.name === DEFAULT_ORCHESTRATOR_ID)) { + return DEFAULT_ORCHESTRATOR_ID; + } + if (available.length === 0) { + throw new Error("registry 中没有可用 orchestrator"); + } + return available[0].name; +} + +/** 兼容旧快照:world-simulator 默认 agent-first(UI 引导 → 用户输入 → Agent 调 Skill) */ +export function effectiveStartupMode( + skill: Pick, +): SkillStartupMode { + if (skill.startupMode === "agent-first" || skill.startupMode === "intake") { + return skill.startupMode; + } + // 旧 frontmatter design-intake bootstrap 已废弃,等同 agent-first + if (skill.startupMode === "design-intake") return "agent-first"; + if (skill.name === DEFAULT_ORCHESTRATOR_ID) return "agent-first"; + return "intake"; +} diff --git a/src/config/settings.ts b/src/config/settings.ts index 1530101..f0745ff 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -8,9 +8,15 @@ export type AppSettings = { version: 1; activeProfileId: string | null; activePresetId: string | null; + /** + * 每个会话保留带全量上下文痕迹的消息条数(最新 N 条)。 + * 更早的消息仍保留,但去掉 contextTrace 正文。默认 5;0 表示不存痕迹。 + */ + contextTraceKeepLatest?: number; }; const FILE_NAME = "settings.json"; +const DEFAULT_CONTEXT_TRACE_KEEP = 5; function settingsPath(): string { return path.join(getUserDataDir(), FILE_NAME); @@ -21,9 +27,16 @@ function defaultSettings(): AppSettings { version: 1, activeProfileId: null, activePresetId: null, + contextTraceKeepLatest: DEFAULT_CONTEXT_TRACE_KEEP, }; } +export function normalizeContextTraceKeepLatest(raw: unknown): number { + const n = typeof raw === "number" ? raw : Number(raw); + if (!Number.isFinite(n) || n < 0) return DEFAULT_CONTEXT_TRACE_KEEP; + return Math.min(50, Math.floor(n)); +} + export function loadAppSettings(): AppSettings { ensureUserDataDirs(); try { @@ -34,6 +47,9 @@ export function loadAppSettings(): AppSettings { version: 1, activeProfileId: parsed.activeProfileId ?? null, activePresetId: parsed.activePresetId ?? null, + contextTraceKeepLatest: normalizeContextTraceKeepLatest( + parsed.contextTraceKeepLatest ?? DEFAULT_CONTEXT_TRACE_KEEP, + ), }; } catch { return defaultSettings(); diff --git a/src/llm/client.ts b/src/llm/client.ts index b567563..9019d09 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -1,5 +1,6 @@ import type { LlmConfig } from "../config/env.js"; import type { GenerationParameters } from "../types/preset.js"; +import { consumeOpenAiToolStream } from "./stream-complete.js"; export type ToolCallPayload = { id: string; @@ -71,21 +72,36 @@ export type CompleteWithToolsResult = { model?: string; }; +export type StreamCallbacks = { + onReasoningDelta?: (delta: string) => void; + onContentDelta?: (delta: string) => void; +}; + export type LlmProvider = { complete( messages: ChatMessage[], options?: CompleteOptions, ): Promise; + completeStream?( + messages: ChatMessage[], + options?: CompleteOptions, + callbacks?: StreamCallbacks, + ): Promise; completeWithTools( messages: ChatMessage[], options: CompleteWithToolsOptions, ): Promise; + completeWithToolsStream?( + messages: ChatMessage[], + options: CompleteWithToolsOptions, + callbacks: StreamCallbacks, + ): Promise; }; function buildRequestBody( config: LlmConfig, messages: ChatMessage[], - options?: CompleteOptions & { tools?: ToolDefinition[] }, + options?: CompleteOptions & { tools?: ToolDefinition[]; stream?: boolean }, ): Record { const gen = options?.generation ?? {}; const body: Record = { @@ -125,6 +141,11 @@ function buildRequestBody( body.response_format = { type: "json_object" }; } + if (options?.stream) { + body.stream = true; + body.stream_options = { include_usage: true }; + } + return body; } @@ -255,6 +276,44 @@ export class OpenAiCompatibleProvider implements LlmProvider { }; } + async completeStream( + messages: ChatMessage[], + options?: CompleteOptions, + callbacks: StreamCallbacks = {}, + ): Promise { + const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify( + buildRequestBody(this.config, messages, { ...options, stream: true }), + ), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`LLM request failed (${response.status}): ${body}`); + } + + if (!response.body) { + throw new Error("LLM stream response has no body"); + } + + const parts = await consumeOpenAiToolStream(response.body, callbacks); + if (!parts.content && !parts.reasoning) { + throw new Error("LLM stream returned empty content"); + } + return { + content: parts.content ?? parts.reasoning ?? "", + reasoning: parts.reasoning || undefined, + usage: parts.usage, + model: parts.model ?? this.config.model, + }; + } + async completeWithTools( messages: ChatMessage[], options: CompleteWithToolsOptions, @@ -296,6 +355,49 @@ export class OpenAiCompatibleProvider implements LlmProvider { model: data.model ?? this.config.model, }; } + + async completeWithToolsStream( + messages: ChatMessage[], + options: CompleteWithToolsOptions, + callbacks: StreamCallbacks, + ): Promise { + const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify( + buildRequestBody(this.config, messages, { + ...options, + tools: options.tools, + stream: true, + }), + ), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`LLM request failed (${response.status}): ${body}`); + } + + if (!response.body) { + throw new Error("LLM stream response has no body"); + } + + const parts = await consumeOpenAiToolStream(response.body, callbacks); + if (!parts.content && parts.toolCalls.length === 0 && !parts.reasoning) { + throw new Error("LLM stream returned empty content and no tool calls"); + } + return { + content: parts.content, + toolCalls: parts.toolCalls, + reasoning: parts.reasoning || undefined, + usage: parts.usage, + model: parts.model ?? this.config.model, + }; + } } export type MockLlmStep = @@ -341,6 +443,39 @@ export class MockLlmProvider implements LlmProvider { }; } + async completeStream( + _messages: ChatMessage[], + options?: CompleteOptions, + callbacks: StreamCallbacks = {}, + ): Promise { + const step = this.nextStep(); + const response = + typeof step === "string" + ? step + : (step.content ?? JSON.stringify({ action: "ask_user", reason: "mock" })); + const reasoning = "用户需要明确分工 → 调用 design-intake 产出 worker 集。"; + for (const ch of reasoning) { + callbacks.onReasoningDelta?.(ch); + await new Promise((r) => setTimeout(r, 0)); + } + for (const ch of response) { + callbacks.onContentDelta?.(ch); + await new Promise((r) => setTimeout(r, 0)); + } + const approx = Math.max(1, Math.ceil(response.length / 4)); + return { + content: response, + reasoning, + usage: { + promptTokens: approx, + completionTokens: approx, + totalTokens: approx * 2, + }, + model: "mock", + ...(options?.caller ? {} : {}), + }; + } + async completeWithTools( _messages: ChatMessage[], _options: CompleteWithToolsOptions, @@ -366,6 +501,20 @@ export class MockLlmProvider implements LlmProvider { model: "mock", }; } + + async completeWithToolsStream( + _messages: ChatMessage[], + _options: CompleteWithToolsOptions, + callbacks: StreamCallbacks, + ): Promise { + const reasoning = + "用户需要明确 Worker 分工 → 先读取黑板与 worker 列表 → 调用 design-intake 产出 worker 集。"; + for (const ch of reasoning) { + callbacks.onReasoningDelta?.(ch); + await new Promise((r) => setTimeout(r, 0)); + } + return this.completeWithTools(_messages, _options); + } } export function createMockMainAgentResponse( diff --git a/src/llm/preset-wrapper.ts b/src/llm/preset-wrapper.ts index d54b61e..38bc3fe 100644 --- a/src/llm/preset-wrapper.ts +++ b/src/llm/preset-wrapper.ts @@ -7,6 +7,7 @@ import type { CompleteWithToolsOptions, CompleteWithToolsResult, LlmProvider, + StreamCallbacks, } from "./client.js"; /** @@ -55,4 +56,23 @@ export class PresetLlmProvider implements LlmProvider { generation, }); } + + async completeWithToolsStream( + messages: ChatMessage[], + options: CompleteWithToolsOptions, + callbacks: StreamCallbacks, + ): Promise { + const preset = this.getPreset(); + if (!this.inner.completeWithToolsStream) { + return this.completeWithTools(messages, options); + } + const presetMessages = preset ? assemblePresetMessages(preset) : []; + const merged = preset ? mergeMessages(presetMessages, messages) : messages; + const generation = options.generation ?? preset?.generation; + + return this.inner.completeWithToolsStream(merged, { + ...options, + generation, + }, callbacks); + } } diff --git a/src/llm/stream-complete.ts b/src/llm/stream-complete.ts new file mode 100644 index 0000000..83eb429 --- /dev/null +++ b/src/llm/stream-complete.ts @@ -0,0 +1,149 @@ +import type { + ChatMessage, + CompleteWithToolsOptions, + CompleteWithToolsResult, + ParsedToolCall, + StreamCallbacks, + TokenUsage, +} from "./client.js"; +import { parseUsage } from "./client.js"; + +type ToolCallAccumulator = Map< + number, + { id?: string; name?: string; arguments: string } +>; + +function applyToolCallDelta( + acc: ToolCallAccumulator, + raw: unknown, +): void { + if (!Array.isArray(raw)) return; + for (const item of raw) { + if (!item || typeof item !== "object") continue; + const row = item as Record; + const index = Number(row.index ?? 0); + const entry = acc.get(index) ?? { arguments: "" }; + if (typeof row.id === "string") entry.id = row.id; + const fn = row.function; + if (fn && typeof fn === "object") { + const f = fn as Record; + if (typeof f.name === "string") entry.name = f.name; + if (typeof f.arguments === "string") entry.arguments += f.arguments; + } + acc.set(index, entry); + } +} + +function toolCallsFromAccumulator(acc: ToolCallAccumulator): ParsedToolCall[] { + const out: ParsedToolCall[] = []; + for (const [, entry] of [...acc.entries()].sort((a, b) => a[0] - b[0])) { + const name = entry.name?.trim(); + const id = entry.id?.trim(); + if (!name || !id) continue; + out.push({ id, name, arguments: entry.arguments || "{}" }); + } + return out; +} + +/** 解析 OpenAI 兼容 SSE 流,累积 reasoning / content / tool_calls */ +export async function consumeOpenAiToolStream( + body: ReadableStream, + callbacks: StreamCallbacks, +): Promise<{ + content: string | null; + reasoning: string; + toolCalls: ParsedToolCall[]; + usage?: TokenUsage; + model?: string; +}> { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let content = ""; + let reasoning = ""; + const toolAcc: ToolCallAccumulator = new Map(); + let usage: TokenUsage | undefined; + let model: string | undefined; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + const payload = trimmed.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + + let parsed: Record; + try { + parsed = JSON.parse(payload) as Record; + } catch { + continue; + } + + if (typeof parsed.model === "string") model = parsed.model; + const u = parseUsage(parsed.usage); + if (u) usage = u; + + const choice = (parsed.choices as unknown[])?.[0]; + if (!choice || typeof choice !== "object") continue; + const delta = (choice as Record).delta; + if (!delta || typeof delta !== "object") continue; + const d = delta as Record; + + if (typeof d.reasoning_content === "string" && d.reasoning_content) { + reasoning += d.reasoning_content; + callbacks.onReasoningDelta?.(d.reasoning_content); + } + if (typeof d.content === "string" && d.content) { + content += d.content; + callbacks.onContentDelta?.(d.content); + } + if (d.tool_calls) applyToolCallDelta(toolAcc, d.tool_calls); + } + } + + return { + content: content.trim() || null, + reasoning: reasoning.trim(), + toolCalls: toolCallsFromAccumulator(toolAcc), + usage, + model, + }; +} + +export type StreamableLlm = { + completeWithToolsStream?( + messages: ChatMessage[], + options: CompleteWithToolsOptions, + callbacks: StreamCallbacks, + ): Promise; +}; + +export function supportsToolStream(llm: unknown): llm is StreamableLlm { + return ( + typeof llm === "object" && + llm != null && + typeof (llm as StreamableLlm).completeWithToolsStream === "function" + ); +} + +export type ContentStreamableLlm = { + completeStream?( + messages: ChatMessage[], + options?: import("./client.js").CompleteOptions, + callbacks?: StreamCallbacks, + ): Promise; +}; + +export function supportsContentStream(llm: unknown): llm is ContentStreamableLlm { + return ( + typeof llm === "object" && + llm != null && + typeof (llm as ContentStreamableLlm).completeStream === "function" + ); +} diff --git a/src/llm/token-tracker.ts b/src/llm/token-tracker.ts index 85e8220..f663eae 100644 --- a/src/llm/token-tracker.ts +++ b/src/llm/token-tracker.ts @@ -4,12 +4,17 @@ import type { CompleteWithToolsOptions, CompleteWithToolsResult, LlmProvider, + StreamCallbacks, } from "./client.js"; import { recordTokenUsage, toMessageTokenUsage, type MessageTokenUsage, } from "../stats/token-store.js"; +import { + buildContextTrace, + type LlmContextTrace, +} from "../types/context-trace.js"; export type LlmTrackingContext = { sessionId?: string; @@ -19,8 +24,23 @@ export type LlmTrackingContext = { /** Set after each LLM call; consumed when the next system chat message is created */ pendingUsage?: MessageTokenUsage; pendingReasoning?: string; + /** 全量请求上下文;挂到下一条「结果向」系统消息 */ + pendingContextTrace?: LlmContextTrace; }; +function capturePendingTrace( + ctx: LlmTrackingContext, + messages: Array<{ role: string; content: string }>, + caller: string | undefined, + model?: string, +): void { + ctx.pendingContextTrace = buildContextTrace({ + caller: caller ?? "unknown", + messages, + model, + }); +} + export class TokenTrackingProvider implements LlmProvider { constructor( private readonly inner: LlmProvider, @@ -33,6 +53,53 @@ export class TokenTrackingProvider implements LlmProvider { ): Promise { const result = await this.inner.complete(messages, options); const ctx = this.getContext(); + capturePendingTrace(ctx, messages, options?.caller, result.model); + if (result.usage) { + const record = recordTokenUsage({ + sessionId: ctx.sessionId, + bookId: ctx.bookId, + bookTitle: ctx.bookTitle, + orchestratorId: ctx.orchestratorId, + caller: options?.caller ?? "unknown", + model: result.model ?? "unknown", + promptTokens: result.usage.promptTokens, + completionTokens: result.usage.completionTokens, + totalTokens: result.usage.totalTokens, + cachedTokens: result.usage.cachedTokens, + cacheMissTokens: result.usage.cacheMissTokens, + }); + ctx.pendingUsage = toMessageTokenUsage(record); + } + if (result.reasoning?.trim()) { + ctx.pendingReasoning = result.reasoning.trim(); + } + return result; + } + + async completeStream( + messages: Parameters[0], + options?: CompleteOptions, + callbacks: StreamCallbacks = {}, + ): Promise { + const inner = this.inner; + if (!inner.completeStream) { + const result = await this.complete(messages, options); + if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning); + if (result.content) callbacks.onContentDelta?.(result.content); + return result; + } + let reasoningBuf = ""; + const result = await inner.completeStream(messages, options, { + onReasoningDelta: (delta) => { + reasoningBuf += delta; + const ctx = this.getContext(); + ctx.pendingReasoning = reasoningBuf; + callbacks.onReasoningDelta?.(delta); + }, + onContentDelta: callbacks.onContentDelta, + }); + const ctx = this.getContext(); + capturePendingTrace(ctx, messages, options?.caller, result.model); if (result.usage) { const record = recordTokenUsage({ sessionId: ctx.sessionId, @@ -61,6 +128,50 @@ export class TokenTrackingProvider implements LlmProvider { ): Promise { const result = await this.inner.completeWithTools(messages, options); const ctx = this.getContext(); + capturePendingTrace(ctx, messages, options.caller, result.model); + if (result.usage) { + const record = recordTokenUsage({ + sessionId: ctx.sessionId, + bookId: ctx.bookId, + bookTitle: ctx.bookTitle, + orchestratorId: ctx.orchestratorId, + caller: options.caller ?? "unknown", + model: result.model ?? "unknown", + promptTokens: result.usage.promptTokens, + completionTokens: result.usage.completionTokens, + totalTokens: result.usage.totalTokens, + cachedTokens: result.usage.cachedTokens, + cacheMissTokens: result.usage.cacheMissTokens, + }); + ctx.pendingUsage = toMessageTokenUsage(record); + } + if (result.reasoning?.trim()) { + ctx.pendingReasoning = result.reasoning.trim(); + } + return result; + } + + async completeWithToolsStream( + messages: Parameters[0], + options: CompleteWithToolsOptions, + callbacks: StreamCallbacks, + ): Promise { + const inner = this.inner; + if (!inner.completeWithToolsStream) { + return this.completeWithTools(messages, options); + } + let reasoningBuf = ""; + const result = await inner.completeWithToolsStream(messages, options, { + onReasoningDelta: (delta) => { + reasoningBuf += delta; + const ctx = this.getContext(); + ctx.pendingReasoning = reasoningBuf; + callbacks.onReasoningDelta?.(delta); + }, + onContentDelta: callbacks.onContentDelta, + }); + const ctx = this.getContext(); + capturePendingTrace(ctx, messages, options.caller, result.model); if (result.usage) { const record = recordTokenUsage({ sessionId: ctx.sessionId, diff --git a/src/main-agent/main-agent.ts b/src/main-agent/main-agent.ts index 700d04c..33ed9e3 100644 --- a/src/main-agent/main-agent.ts +++ b/src/main-agent/main-agent.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import type { LlmProvider } from "../llm/client.js"; +import { normalizeQuestions } from "../skills/question-protocol.js"; import type { BlackboardTagIndex } from "../types/blackboard.js"; import type { MainAgentDecision, RuntimeSession } from "../types/runtime.js"; import { runMainAgentToolLoop, type ToolLoopHandlers } from "./tool-loop.js"; @@ -22,27 +23,46 @@ function buildMainAgentSystemPrompt( ? workers.map((w) => `- ${w.id}:${w.description}`).join("\n") : "- (当前 skill 未加载 worker 列表)"; - return `你是写作系统的总管(Main Agent)。你的职责是调度 worker,而不是直接创作正文。 + return `你是写作系统的导演(Main Agent)。你的职责是调度演员(worker),而不是直接创作正文。 规则: 1. 你不能直接生成小说/文章正文。 2. 你不能修改运行状态;statePatchAllowed 必须始终为 false。 3. 你只能建议下一步动作:ask_user、run_worker、create_temp_worker、review_blackboard、finish。 -4. 当信息不足时,使用 ask_user 向用户提问。 +4. 当信息不足时,使用 ask_user:assessment 是主内容(完备度评价);questions 挂在其下且用户可跳过;一次 1~2 题。 5. 当需要执行任务时,使用 run_worker,只指定 workerId。不要指定 inputTags 或 outputTags——Runtime 从 Worker Skill 读取。 6. requiresApproval 表示运行 worker 前是否需要用户确认。代笔模式通常为 true。 7. run_worker 可选 workerContext:{ "roleId": "A" },用于 role-decide 等指定当前决策角色(Runtime 写入 世界.当前角色.id)。 8. 你不能把未验收内容当作事实。 -9. 向用户提问是 worker 的能力(ask_user tool),不是独立 worker。总管只在调度层提问。 +9. 向用户提问是 worker 的能力(ask_user tool),不是独立 worker。导演只在调度层提问。 10. blackboardIndex 只有 tag 索引,不含正文 content。 +## 调度思维 +用需求正推:「用户需要 [具体体验/能力] → 调用 [worker/skill] 来 [生成/补充/调整] [什么],以便更好满足用户。」 +禁止否定式路由:「某模式 / 背景形态 → 不需要某步骤 / 跳过某 worker」。 + +反例(禁止):「背景为单一角色,无需世界构建」「不是规则怪谈,跳过 write-rules」「默认上世界模拟套件」 +正例:「用户已选世界模拟器导演且要网恋对话 → design-flow 排出近期增量步骤(可调味、可后补)→ 用户认可后反复 design-step;需要再补规则/实例 → 再 design-flow 追加同能力 → 收成后若需开局 → opening-generator;用户手动进 play」 + 当前 skill 可用 worker(workerId 必须与下列 id 完全一致): ${workerLines} +## design 优先顺序 +- 尚无「设计.创作流程」→ **design-flow**(近期 horizon;status=open;未选导演则先请用户选) +- 流程已验收且还有未完成步骤 → **design-step**(一次一步;能力由程序注入) +- 已列步骤都验收但流程仍 **status=open** → 再 **design-flow**(追加反复步或 closed) +- 禁止调度已废弃的 design-core / design-fixed / design-worker / design-refine +- 终稿已 accept 且可用 opening-generator、尚无开场产物 → opening-generator +- 进 play 由用户手动决定 +- **禁止**替用户猜测或改选导演 +- **禁止**一次编排排死全程固定 DAG + 输出必须是 JSON 对象,字段: { "action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish", "reason": "string", + "assessment": "string | null", + "questions": [{ "id": "q1", "prompt": "…", "options": [{ "label": "建议示范…" }] }] | null, "workerId": "string | null", "requiresApproval": boolean, "workerContext": { "roleId": "string" } | null @@ -109,7 +129,7 @@ export function buildMainAgentUserPrompt(context: MainAgentContext): string { blackboardIndex, availableWorkers, instruction: - "根据当前状态决定下一步。若 collecting_input 或 revision_requested,优先理解用户最新输入。若 planning 且已有足够信息,建议 run_worker(仅 workerId)。", + "根据当前状态决定下一步。尚无设计.创作流程 → design-flow;有未完成步骤 → design-step;steps 做完但 status=open → 再 design-flow;禁止旧 design-core/fixed/worker/refine。已 accept 且需开局 → opening-generator。进 play 由用户手动。", }, null, 2, @@ -160,10 +180,20 @@ export function parseMainAgentDecision(raw: string): MainAgentDecision { if (roleId) workerContext = { roleId }; } + const assessmentRaw = + typeof obj.assessment === "string" + ? obj.assessment.trim() + : typeof obj.message === "string" + ? obj.message.trim() + : ""; + const questions = normalizeQuestions(obj.questions); + return { id: randomUUID(), action, reason, + assessment: assessmentRaw || undefined, + questions: questions.length ? questions : undefined, workerId, workerContext, requiresApproval: Boolean(obj.requiresApproval), diff --git a/src/main-agent/tool-loop.ts b/src/main-agent/tool-loop.ts index e65ba33..a4c5dfc 100644 --- a/src/main-agent/tool-loop.ts +++ b/src/main-agent/tool-loop.ts @@ -1,4 +1,5 @@ -import type { ChatMessage, LlmProvider, ParsedToolCall } from "../llm/client.js"; +import type { ChatMessage, LlmProvider, ParsedToolCall, StreamCallbacks } from "../llm/client.js"; +import { supportsToolStream } from "../llm/stream-complete.js"; import { toolCallToDecision, validateLoopToolCall } from "../runtime/tool-registry.js"; import type { MainAgentDecision } from "../types/runtime.js"; import { isMainAgentTerminalTool } from "../types/tools.js"; @@ -17,6 +18,10 @@ export type ToolLoopHandlers = { outputTags: string[]; }>; onToolCall?: (name: string, detail: string) => void; + /** 思维链 / reasoning 流式增量(供 UI 实时展示) */ + onThinkingDelta?: (delta: string) => void; + /** 一轮 LLM 调用结束后的完整思考文本 */ + onThinkingDone?: (text: string) => void; }; export type ToolLoopResult = { @@ -37,20 +42,84 @@ function buildToolLoopSystemPrompt( return `你是写作系统的总管(Main Agent)。你在 running 相位通过 **tool call** 推进流程。 -规则: +## 能力边界 1. 你不能直接生成小说/文章正文。 2. 你不能修改运行状态。 3. 先用 read_blackboard / list_workers / list_artifacts 收集信息,再决定下一步。 4. 终止动作只能用 tool:ask_user、run_worker、review_blackboard、finish。 + - ask_user:**主内容是 assessment(内容完备度评价)**;questions 挂在其下且可被用户跳过。 + · assessment(必填):给用户看的评价正文。以【核心体验】为首;按需列维度(内容/人物关系/感官/背景规则/意义主题…),不必凑齐。每维写完备度%、已知、待探;待探用【】标互斥或可组合方向。 + · questions(必填数组,语义可选):只针对 assessment「待探」里想确认的点;一次 1~2 题。用户可不答、直接让你基于现有信息继续。prompt=明确题干;options=2~4 条**建议示范**;required 默认 false。 + · reason:调度短句,正推,勿塞长文。 + · 能推断就别问;已写在 用户.需求 里的不要重复问。 5. run_worker 只传 workerId;inputTags/outputTags 由 Runtime 从 Worker Skill 读取。 6. requiresApproval=true 时 run_worker 需用户确认后再执行。 -7. run_worker 可选 roleId,用于 role-decide 等指定当前决策角色。 -8. 不能把未验收产物当作已定事实。 +7. 不能把未验收产物当作已定事实。 +## 调度思维(必须遵守) +用 **需求正推**,禁止 **模式否定式** 表述。 + +**要这样思考(正推):** +「用户需要 [具体体验/能力/产物] → 因此调用 [worker/skill] 来 [生成/补充/调整] [什么],以便更好满足用户。」 + +**不要这样思考(反例):** +「这是 xxx 模式 / 形态 → 不需要 xxx 步骤 / 跳过 xxx。」 +「背景为单一角色,无需世界构建。」 +「不是规则怪谈,跳过 write-rules。」 + +示例(好): +- 「用户已选导演且要 1v1 网恋 → design-flow 排出近期增量步骤(可调味)→ 认可后反复 design-step;不够再追加。」 +- 「尚无设计.创作流程 → run_worker(design-flow);有未完成步骤 → design-step;steps 完但 status=open → 再 design-flow。」 +- 「Worker 集已 accept 且声明需要开局 → 调用 opening-generator 写开场白。」 + +示例(坏): +- 「调度 design-core / design-fixed(已废弃)。」 +- 「一次 design-flow 排死全程固定 DAG。」 +- 「跳过流程编排直接写满 Worker 集。」 +- 「默认先上世界模拟全套再问用户要什么。」 +- 「替用户改选 / 猜测导演。」 + +在 reasoning / 可见 content 中请用 **正推句式** 写出调度理由;终止 tool 的 reason 字段同样用正推表述。 + +## design 阶段提示 +- 尚无「设计.创作流程」→ **优先 design-flow**(近期 horizon;status=open;未选则先请用户选)。 +- 流程已验收、还有未完成步骤 → **design-step**(一次一步)。 +- 已列步骤全验收但 **status=open** → 再 **design-flow**(追加生成规则/具体实例等,或设 closed)。 +- 禁止 run_worker(design-core|design-fixed|design-worker|design-refine)——已废弃。 +- 不要重复问用户「想做什么」——意图已在 用户.需求。 +- Worker 集已 accept,且可用 worker 含 opening-generator,尚无开场产物 → opening-generator。 +- 进 play 由用户手动决定。 +- 未声明开局、用户也未要求开场时,不要硬调 opening-generator。 +- **禁止**替用户猜测或改选导演。 +- **禁止**一次编排排死全程固定长链。 当前 skill 可用 worker: ${workerLines} -在信息足够前可多次调用 read_blackboard 等;一旦调用终止 tool,本轮循环结束。`; +信息不足时可多次 read_blackboard;一旦调用终止 tool,本轮循环结束。`; +} + +async function completeToolsPreferStream( + llm: LlmProvider, + messages: ChatMessage[], + callbacks: StreamCallbacks, +): Promise>> { + if (supportsToolStream(llm)) { + return llm.completeWithToolsStream!(messages, { + tools: MAIN_AGENT_TOOL_DEFINITIONS, + caller: "main_agent", + }, callbacks); + } + const result = await llm.completeWithTools(messages, { + tools: MAIN_AGENT_TOOL_DEFINITIONS, + caller: "main_agent", + }); + if (result.reasoning) { + callbacks.onReasoningDelta?.(result.reasoning); + } + if (result.content) { + callbacks.onContentDelta?.(result.content); + } + return result; } function executeLoopTool( @@ -94,6 +163,16 @@ function assistantMessageFromToolCalls( }; } +function emitThinkingDone(handlers: ToolLoopHandlers, parts: { + reasoning?: string; + content?: string | null; +}): void { + const reasoning = parts.reasoning?.trim() ?? ""; + const content = parts.content?.trim() ?? ""; + const combined = [reasoning, content].filter(Boolean).join("\n\n").trim(); + if (combined) handlers.onThinkingDone?.(combined); +} + /** * 总管 tool loop:在 running 相位内可多轮调用 read_blackboard 等, * 直到调用终止 tool 并返回 MainAgentDecision。 @@ -112,12 +191,14 @@ export async function runMainAgentToolLoop( ]; const toolTrace: string[] = []; + const streamCallbacks: StreamCallbacks = { + onReasoningDelta: (delta) => handlers.onThinkingDelta?.(delta), + onContentDelta: (delta) => handlers.onThinkingDelta?.(delta), + }; for (let iteration = 1; iteration <= MAX_TOOL_LOOP_ITERATIONS; iteration++) { - const result = await llm.completeWithTools(messages, { - tools: MAIN_AGENT_TOOL_DEFINITIONS, - caller: "main_agent", - }); + const result = await completeToolsPreferStream(llm, messages, streamCallbacks); + emitThinkingDone(handlers, result); if (result.toolCalls.length === 0) { if (result.content?.trim()) { diff --git a/src/main-agent/tools.ts b/src/main-agent/tools.ts index ca4b109..a3d1fde 100644 --- a/src/main-agent/tools.ts +++ b/src/main-agent/tools.ts @@ -50,14 +50,80 @@ export const MAIN_AGENT_TOOL_DEFINITIONS: ToolDefinition[] = [ type: "function", function: { name: "ask_user", - description: "信息不足时向用户提问,将暂停 agent 循环等待用户输入。", + description: + "向用户追问。assessment 是给用户看的主内容(完备度评价);questions 挂在其下,用户可跳过并请你基于现有信息继续。将暂停等待用户。", parameters: { type: "object", properties: { - reason: { type: "string", description: "为何需要用户输入" }, - message: { type: "string", description: "展示给用户的问题或说明" }, + reason: { + type: "string", + description: + "调度层短理由(正推:用户需要澄清 X → 因此追问)。勿把长文评价写这里。", + }, + assessment: { + type: "string", + description: + "给用户看的内容完备度评价(Markdown)。以【核心体验】为首要;按需列维度(不必凑齐),每维:完备度%、已知、待探。待探用【】标出互斥/可组合方向。评价只服务「用户想要什么」。", + }, + message: { + type: "string", + description: + "兼容旧字段:等同 assessment。优先传 assessment。", + }, + questions: { + type: "array", + description: + "挂在 assessment 下的可选追问(用户可不答)。基于「待探」出题,一次 1~2 题。prompt=明确选择题干;options=建议示范(可改写后采用)。", + items: { + type: "object", + properties: { + id: { + type: "string", + description: "稳定 id,如 q1 / stance", + }, + prompt: { + type: "string", + description: + "题干:引导用户选定一种详细体验倾向(例:「你更倾向于哪种皇帝的享受?」)。", + }, + options: { + type: "array", + description: + "2~4 个建议示范。label 写完整可采纳文案(可先一句场景钩子再点题),不是标签词。", + items: { + type: "object", + properties: { + id: { + type: "string", + description: "A/B/C…", + }, + label: { + type: "string", + description: + "建议正文:用户点选后可直接当答案,也可改写。例:「冰冷的、主宰一切的权力感——九重宫阙一言定生死…」", + }, + editable: { + type: "boolean", + description: "默认 true:点文案可改写,点字母才选中", + }, + }, + required: ["label"], + }, + }, + allowOther: { + type: "boolean", + description: "默认 true:允许 Other 自拟", + }, + required: { + type: "boolean", + description: "默认 false(可选追问);仅关键阻塞题才 true", + }, + }, + required: ["prompt", "options"], + }, + }, }, - required: ["reason"], + required: ["reason", "assessment", "questions"], additionalProperties: false, }, }, diff --git a/src/preset/entries.ts b/src/preset/entries.ts index 13f5eba..18e0537 100644 --- a/src/preset/entries.ts +++ b/src/preset/entries.ts @@ -1,46 +1,167 @@ -import type { PresetPackage, PresetPromptRole } from "../types/preset.js"; +import type { + PresetPackage, + PresetPromptEntry, + PresetPromptRole, +} from "../types/preset.js"; +import { savePreset } from "./store.js"; -export type PresetEnabledEntryView = { +export type PresetEntryView = { orderIndex: number; id: string; name: string; role: PresetPromptRole; marker: boolean; content: string; - /** 实际会注入 LLM 请求(有非空 content) */ + /** prompt_order 与条目自身均启用 */ + enabled: boolean; + orderEnabled: boolean; + entryEnabled: boolean; + /** 启用且有非空 content → 会注入 LLM */ willInject: boolean; }; -/** 按 prompt_order 列出所有启用条目及其内容 */ -export function listEnabledPresetEntries( - preset: PresetPackage, -): PresetEnabledEntryView[] { +/** @deprecated 名称保留:现为全部条目;过滤启用请用 filter */ +export type PresetEnabledEntryView = PresetEntryView; + +function sortOrder(preset: PresetPackage) { + return [...preset.promptOrder].sort((a, b) => a.orderIndex - b.orderIndex); +} + +function toView( + orderIndex: number, + entry: PresetPromptEntry, + orderEnabled: boolean, +): PresetEntryView { + const content = entry.content ?? ""; + const enabled = orderEnabled && entry.enabled; + const trimmed = content.trim(); + return { + orderIndex, + id: entry.id, + name: entry.name, + role: entry.role, + marker: entry.marker, + content, + enabled, + orderEnabled, + entryEnabled: entry.enabled, + willInject: enabled && trimmed.length > 0, + }; +} + +/** 按 prompt_order 列出全部条目(含未启用),便于设置页编辑 */ +export function listAllPresetEntries(preset: PresetPackage): PresetEntryView[] { const promptById = new Map(preset.prompts.map((p) => [p.id, p])); - const ordered = [...preset.promptOrder].sort( - (a, b) => a.orderIndex - b.orderIndex, - ); - const entries: PresetEnabledEntryView[] = []; + const seen = new Set(); + const entries: PresetEntryView[] = []; - for (const orderItem of ordered) { - if (!orderItem.enabled) continue; + for (const orderItem of sortOrder(preset)) { const entry = promptById.get(orderItem.promptId); - if (!entry || !entry.enabled) continue; + if (!entry) continue; + seen.add(entry.id); + entries.push(toView(orderItem.orderIndex, entry, orderItem.enabled)); + } - const content = entry.content.trim(); - entries.push({ - orderIndex: orderItem.orderIndex, - id: entry.id, - name: entry.name, - role: entry.role, - marker: entry.marker, - content, - willInject: content.length > 0, - }); + // 未进 order 的条目附在末尾 + let nextIndex = + entries.reduce((m, e) => Math.max(m, e.orderIndex), -1) + 1; + for (const entry of preset.prompts) { + if (seen.has(entry.id)) continue; + entries.push(toView(nextIndex++, entry, false)); } return entries; } -export function countInjectingEntries(entries: PresetEnabledEntryView[]): number { +/** 仅启用中的条目(装配 / 旧 API 兼容) */ +export function listEnabledPresetEntries( + preset: PresetPackage, +): PresetEntryView[] { + return listAllPresetEntries(preset).filter((e) => e.enabled); +} + +export function countInjectingEntries( + entries: Array<{ willInject: boolean }>, +): number { return entries.filter((e) => e.willInject).length; } + +export function countEnabledEntries( + entries: Array<{ enabled: boolean }>, +): number { + return entries.filter((e) => e.enabled).length; +} + +export type PresetEntryPatch = { + id: string; + enabled?: boolean; + content?: string; + name?: string; + role?: PresetPromptRole; +}; + +/** + * 纯函数更新(不写盘)。启用状态同步 order + entry。 + */ +export function applyPresetEntryPatches( + preset: PresetPackage, + patches: PresetEntryPatch[], +): PresetPackage { + if (!patches.length) return preset; + + const prompts = preset.prompts.map((p) => ({ ...p })); + const promptById = new Map(prompts.map((p) => [p.id, p])); + let order = preset.promptOrder.map((o) => ({ ...o })); + + for (const patch of patches) { + const entry = promptById.get(patch.id); + if (!entry) { + throw new Error(`条目不存在: ${patch.id}`); + } + if (typeof patch.content === "string") { + entry.content = patch.content; + if (entry.content.trim()) entry.marker = false; + } + if (typeof patch.name === "string" && patch.name.trim()) { + entry.name = patch.name.trim(); + } + if ( + patch.role === "system" || + patch.role === "user" || + patch.role === "assistant" + ) { + entry.role = patch.role; + } + if (typeof patch.enabled === "boolean") { + entry.enabled = patch.enabled; + const orderItem = order.find((o) => o.promptId === patch.id); + if (orderItem) { + orderItem.enabled = patch.enabled; + } else if (patch.enabled) { + const orderIndex = + order.reduce((m, o) => Math.max(m, o.orderIndex), -1) + 1; + order.push({ + promptId: patch.id, + enabled: true, + orderIndex, + }); + } + } + } + + return { + ...preset, + prompts, + promptOrder: order, + }; +} + +/** + * 更新一条或多条并落盘。 + */ +export function patchPresetEntries( + preset: PresetPackage, + patches: PresetEntryPatch[], +): PresetPackage { + return savePreset(applyPresetEntryPatches(preset, patches)); +} diff --git a/src/runtime/compress-after-worker.ts b/src/runtime/compress-after-worker.ts new file mode 100644 index 0000000..f0bf207 --- /dev/null +++ b/src/runtime/compress-after-worker.ts @@ -0,0 +1,235 @@ +import type { Blackboard } from "../blackboard/blackboard.js"; + +/** 下一 worker / 总管可读的定稿摘要 tag */ +export const CONTEXT_BRIEF_TAG = "上下文.定稿摘要"; + +export type CompressWorkerResult = { + archivedTags: string[]; + finals: Array<{ tag: string; chars: number; preview: string }>; + briefText: string; +}; + +const ALWAYS_ARCHIVE_AFTER_ACCEPT = ["用户.worker答复", "用户.修订说明"]; + +/** + * Worker 产物经用户验收(或自动完工)后: + * - 终产物打 final + * - 过程/草稿 tag 归档(不再进入后续 worker 取数) + * - 写入定稿摘要,供下一阶段「指点」用 + */ +export function compressAfterWorkerAccept(params: { + blackboard: Blackboard; + workerId: string; + outputTags: string[]; + summary?: string; + /** + * final = 终稿验收(归档草稿、写定稿摘要) + * unit = 创作单位验收(只归档过程答复,保留 设计.worker集.草稿) + */ + mode?: "final" | "unit"; +}): CompressWorkerResult { + const { blackboard, workerId, outputTags, summary } = params; + const mode = params.mode ?? "final"; + const finals: CompressWorkerResult["finals"] = []; + const archivedTags: string[] = []; + + const uniqueOutputs = [...new Set(outputTags.map((t) => t.trim()).filter(Boolean))]; + + if (mode === "final") { + for (const tag of uniqueOutputs) { + const content = blackboard.getContentByTag(tag); + if (content == null) continue; + blackboard.write({ + tag, + content, + source: workerId, + metadata: { + role: "final", + acceptedAt: new Date().toISOString(), + workerId, + }, + }); + finals.push({ + tag, + chars: content.length, + preview: previewText(content, 120), + }); + } + } else { + // 单位验收:草稿保持活跃,终产物列表仅作面板提示 + for (const tag of uniqueOutputs) { + if (tag === "用户.需求" || tag === "创作.当前单位") continue; + const content = blackboard.getContentByTag(tag); + if (content == null) continue; + finals.push({ + tag, + chars: content.length, + preview: previewText(content, 120), + }); + } + } + + const toArchive = new Set(ALWAYS_ARCHIVE_AFTER_ACCEPT); + + if (mode === "final") { + if (blackboard.getContentByTag("设计.worker集")?.trim()) { + toArchive.add("设计.worker集.草稿"); + } + for (const tag of uniqueOutputs) { + if (tag.endsWith(".草稿")) continue; + const draft = `${tag}.草稿`; + if (blackboard.getContentByTag(draft) != null) toArchive.add(draft); + } + } + + for (const tag of toArchive) { + if (uniqueOutputs.includes(tag) && mode === "final") continue; + if (mode === "unit" && tag.endsWith(".草稿")) continue; + const content = blackboard.getContentByTag(tag); + if (content == null) continue; + blackboard.write({ + tag, + content: `(已压缩归档)原过程内容已折叠。定稿见:${ + finals.map((f) => f.tag).join("、") || "(无)" + }\n\n---\n${previewText(content, 400)}`, + source: "compress", + metadata: { + role: "archived", + archivedAt: new Date().toISOString(), + fromWorker: workerId, + compressMode: mode, + }, + }); + archivedTags.push(tag); + } + + const briefText = + mode === "unit" + ? buildUnitBriefText({ workerId, summary, finals, archivedTags }) + : buildBriefText({ + workerId, + summary, + finals, + archivedTags, + }); + + if (mode === "final") { + blackboard.write({ + tag: CONTEXT_BRIEF_TAG, + content: briefText, + source: "compress", + metadata: { role: "final", workerId }, + }); + } + + return { archivedTags, finals, briefText }; +} + +function buildUnitBriefText(params: { + workerId: string; + summary?: string; + finals: CompressWorkerResult["finals"]; + archivedTags: string[]; +}): string { + return [ + `## 创作单位已验收(${params.workerId})`, + "", + params.summary?.trim() ? `摘要:${params.summary.trim()}` : "摘要:(无)", + "", + "草稿仍保留在 `设计.worker集.草稿`;下一单位继续增量,勿依赖已删的过程对话。", + ].join("\n"); +} + +function buildBriefText(params: { + workerId: string; + summary?: string; + finals: CompressWorkerResult["finals"]; + archivedTags: string[]; +}): string { + const lines = [ + `## 上一阶段定稿(${params.workerId})`, + "", + params.summary?.trim() + ? `摘要:${params.summary.trim()}` + : "摘要:(无)", + "", + "### 保留的终产物 tag", + ]; + if (params.finals.length === 0) { + lines.push("- (无)"); + } else { + for (const f of params.finals) { + lines.push(`- \`${f.tag}\`(${f.chars} 字)`); + lines.push(` ${f.preview}`); + } + } + if (params.archivedTags.length) { + lines.push("", "### 已压缩的过程 tag"); + for (const t of params.archivedTags) { + lines.push(`- \`${t}\`(归档,后续 worker 默认不读)`); + } + } + lines.push( + "", + "下一 worker 应以以上终产物为准继续;勿依赖已归档的过程讨论。", + ); + return lines.join("\n"); +} + +export function previewText(content: string, max: number): string { + const t = content.replace(/\s+/g, " ").trim(); + if (t.length <= max) return t; + return `${t.slice(0, max)}…`; +} + +/** 供 UI:黑板可读条目(默认隐藏 archived 全文,只给索引) */ +export function buildBoardPanel(blackboard: Blackboard): { + finals: Array<{ tag: string; source: string; preview: string; updatedAt: string }>; + active: Array<{ tag: string; source: string; preview: string; updatedAt: string }>; + archivedCount: number; + brief?: string; +} { + const items = latestItemsByTag(blackboard.exportItems()); + const finals: Array<{ + tag: string; + source: string; + preview: string; + updatedAt: string; + }> = []; + const active: typeof finals = []; + let archivedCount = 0; + let brief: string | undefined; + + for (const item of items.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))) { + const role = String(item.metadata?.role ?? ""); + if (item.tag === CONTEXT_BRIEF_TAG) { + brief = item.content; + continue; + } + if (role === "archived") { + archivedCount += 1; + continue; + } + const row = { + tag: item.tag, + source: item.source, + preview: previewText(item.content, 160), + updatedAt: item.updatedAt, + }; + if (role === "final") finals.push(row); + else active.push(row); + } + + return { finals, active, archivedCount, brief }; +} + +function latestItemsByTag( + items: import("../types/blackboard.js").BlackboardItem[], +): import("../types/blackboard.js").BlackboardItem[] { + const byTag = new Map(); + for (const item of items) { + const prev = byTag.get(item.tag); + if (!prev || item.updatedAt > prev.updatedAt) byTag.set(item.tag, item); + } + return [...byTag.values()]; +} diff --git a/src/runtime/orchestrator.ts b/src/runtime/orchestrator.ts index 7ff5f7b..223050f 100644 --- a/src/runtime/orchestrator.ts +++ b/src/runtime/orchestrator.ts @@ -60,7 +60,7 @@ export class RuntimeOrchestrator { payload: { presetId: this.session.presetId, flowId: this.session.flowId, - availableSkills: [{ name: "basic", description: "fallback", category: "novel" }], + availableSkills: [{ name: "world-simulator", description: "fallback", category: "dialogue" }], }, }); return this.session; diff --git a/src/runtime/phase-machine.ts b/src/runtime/phase-machine.ts index 179d752..d6d910c 100644 --- a/src/runtime/phase-machine.ts +++ b/src/runtime/phase-machine.ts @@ -19,22 +19,56 @@ import type { WaitingReason, } from "../types/runtime.js"; import type { ActiveSkillSnapshot } from "../types/runtime.js"; +import { effectiveStartupMode } from "../config/default-orchestrator.js"; import { buildIntakeProgress, buildIntakeFollowUpMessage, readIntakeValues, synthesizeDemandText, } from "../intake/intake.js"; +import { normalizeQuestions } from "../skills/question-protocol.js"; +import type { QuestionItem } from "../types/questions.js"; function nowIso(): string { return new Date().toISOString(); } +/** 挂在产物下的追问一律可选(required=false),用户可直接 Accept */ +function asOptionalSidecarQuestions( + raw: QuestionItem[] | string[] | undefined, +): QuestionItem[] | undefined { + const normalized = normalizeQuestions(raw ?? []); + if (!normalized.length) return undefined; + return normalized.map((q) => ({ ...q, required: false })); +} + /** 更新 updatedAt 时间戳 */ function touch(session: RuntimeSession): RuntimeSession { return { ...session, updatedAt: nowIso() }; } +/** 用户每条输入写入 用户.最新输入;首句同时写入需求 tag */ +function applyUserTextToSlots( + slots: Record, + text: string, + demandKey: string, + opts: { initial?: boolean }, +): void { + if (!text) return; + slots["用户.最新输入"] = text; + if (opts.initial) { + slots[demandKey] = text; + slots.startupCompleted = true; + return; + } + if (!slots.startupCompleted) { + slots[demandKey] = text; + slots.startupCompleted = true; + return; + } + mergeSlotText(slots, demandKey, text); +} + /** 将用户补充文本追加到 slot(用于合并多轮 ask_user / worker 答复到需求 tag) */ function mergeSlotText( slots: Record, @@ -136,6 +170,7 @@ export function getAllowedEvents( "user_accepted_artifact", "user_rejected_artifact", "user_requested_revision", + "user_resolved_sidecar_questions", "main_agent_decision_created", "runtime_failed", ]; @@ -179,6 +214,11 @@ export function canApplyEvent( case "user_accepted_artifact": case "user_rejected_artifact": return reason?.kind === "review_artifact"; + case "user_resolved_sidecar_questions": + return ( + reason?.kind === "review_artifact" && + Boolean(reason.questions?.length) + ); case "user_requested_revision": return ( reason?.kind === "review_artifact" || reason?.kind === "approve_step" @@ -272,7 +312,7 @@ export function applyEvent( } switch (event.type) { - // ── 启动:选 skill ── + // ── 启动:默认 orchestrator 或 legacy 选包 ── case "session_started": { const next = appendHistory( { @@ -283,40 +323,22 @@ export function applyEvent( event, ); const skills = event.payload.availableSkills; + const initialSkill = event.payload.initialSkill; + if (initialSkill) { + return applySkillBinding(next, initialSkill, event); + } return { session: waiting(next, { kind: "skill_selection", availableSkills: skills, }), - effects: [ - { - type: "emit_message", - message: formatSkillSelectionPrompt(skills), - }, - ], + effects: [{ type: "emit_message", message: formatSkillSelectionPrompt(skills) }], }; } case "skill_selected": { const { skill } = event.payload; - const next = appendHistory( - { - ...session, - flowId: skill.defaultFlowId ?? session.flowId, - slots: { - ...session.slots, - activeSkill: skill, - }, - }, - event, - ); - return { - session: waiting(next, { - kind: "intake", - prompt: skill.startupPrompt, - }), - effects: [{ type: "emit_message", message: skill.startupPrompt }], - }; + return applySkillBinding(session, skill, event); } case "user_confirmed_intake": { @@ -366,6 +388,21 @@ export function applyEvent( if (session.waitingReason?.kind === "intake") { const skill = activeSkill as ActiveSkillSnapshot | undefined; + + if (skill && effectiveStartupMode(skill) === "agent-first" && text) { + const key = skill.startupTargetKey || "用户.需求"; + applyUserTextToSlots(slots, text, key, { initial: true }); + slots.lastUserInput = text; + const next = appendHistory( + { ...session, slots, resumeContext: undefined }, + event, + ); + return { + session: touch(running(next)), + effects: [{ type: "invoke_main_agent" }], + }; + } + const intakeValues = event.payload.intakeValues ?? readIntakeValues(session.slots); @@ -419,16 +456,17 @@ export function applyEvent( if (session.waitingReason?.kind === "worker_questions") { slots["用户.worker答复"] = text; + if (text) slots["用户.最新输入"] = text; if (demandKey && text) { mergeSlotText(slots, demandKey, text); } - } else if (demandKey && session.waitingReason?.kind === "input" && text) { - if (!session.slots.startupCompleted) { - slots[demandKey] = text; - slots.startupCompleted = true; - } else { - mergeSlotText(slots, demandKey, text); - } + } else if (session.waitingReason?.kind === "input" && text) { + const key = demandKey || "用户.需求"; + applyUserTextToSlots(slots, text, key, { + initial: !session.slots.startupCompleted, + }); + } else if (text) { + slots["用户.最新输入"] = text; } const next = appendHistory( { @@ -465,14 +503,44 @@ export function applyEvent( switch (decision.action) { case "ask_user": - case "review_blackboard": + case "review_blackboard": { + const questions = decision.questions?.length + ? decision.questions.map((q) => ({ ...q, required: false })) + : undefined; + const assessment = + decision.action === "ask_user" + ? decision.assessment?.trim() || undefined + : undefined; + const effects: PhaseEffect[] = []; + if (assessment) { + effects.push({ + type: "emit_message", + message: `[Agent] 内容评价:\n${assessment}`, + }); + } + if (questions?.length) { + effects.push({ + type: "emit_message", + message: `[Agent] 可选追问(可跳过):\n${questions.map((q) => `- ${q.prompt}`).join("\n")}`, + }); + } + const inputMessage = assessment + ? assessment + : decision.action === "review_blackboard" || !questions?.length + ? decision.reason + : undefined; return { session: waiting( { ...next, pendingDecision: undefined }, - { kind: "input", message: decision.reason }, + { + kind: "input", + message: inputMessage, + questions, + }, ), - effects: [], + effects, }; + } case "finish": return { session: touch({ @@ -580,13 +648,12 @@ export function applyEvent( } case "worker_needs_input": { - const questions = event.payload.questions - .map((q) => q.trim()) - .filter(Boolean); - const normalized = - questions.length > 0 - ? questions - : ["请补充当前步骤所需的信息(情境、参数或你的具体设想)。"]; + let normalized = normalizeQuestions(event.payload.questions); + if (normalized.length === 0) { + normalized = normalizeQuestions([ + "请补充当前步骤所需的信息(情境、参数或你的具体设想)。", + ]); + } const ctx: ResumeContext = { workerId: event.payload.workerId, stepId: event.payload.stepId ?? session.currentStepId, @@ -608,7 +675,7 @@ export function applyEvent( effects: [ { type: "emit_message", - message: `[Worker] ${event.payload.workerId} 提问:\n${normalized.map((q) => `- ${q}`).join("\n")}`, + message: `[Worker] ${event.payload.workerId} 提问:\n${normalized.map((q) => `- ${q.prompt}`).join("\n")}`, }, ], }; @@ -618,23 +685,64 @@ export function applyEvent( const result = handleWorkerCompleted(session, event); const mode = session.acceptanceMode ?? "user_confirmed"; if (mode === "user_confirmed" && result.session.pendingArtifactId) { + const sidecar = asOptionalSidecarQuestions(event.payload.questions); + const effects = [...result.effects]; + if (sidecar?.length) { + effects.push({ + type: "emit_message", + message: `[Worker] 可选追问(可跳过,直接接受目前产物):\n${sidecar.map((q) => `- ${q.prompt}`).join("\n")}`, + }); + } return { ...result, + effects, session: waiting(result.session, { kind: "review_artifact", artifactId: result.session.pendingArtifactId, + questions: sidecar, }), }; } return result; } + case "user_resolved_sidecar_questions": { + const reason = session.waitingReason; + if (reason?.kind !== "review_artifact") { + return fail(session, "sidecar questions require review_artifact"); + } + const answersText = event.payload.answersText?.trim(); + const slots: Record = { ...session.slots }; + const effects: PhaseEffect[] = []; + if (answersText) { + slots["用户.worker答复"] = answersText; + slots["用户.最新输入"] = answersText; + effects.push({ + type: "emit_message", + message: `[用户] 已补充可选追问(产物仍待验收)`, + }); + } + const next = appendHistory({ ...session, slots }, event); + return { + session: waiting(touch(next), { + kind: "review_artifact", + artifactId: reason.artifactId, + }), + effects, + }; + } + // ── 产物验收 ── case "user_accepted_artifact": { const artifact = findArtifact(session, event.payload.artifactId); if (!artifact) { return fail(session, `Artifact not found: ${event.payload.artifactId}`); } + const slots: Record = { ...session.slots }; + // 仅终稿 tag「设计.worker集」表示完整 Worker 集验收;草稿单位验收不得开 play + if (artifact.outputTags.some((tag) => tag === "设计.worker集")) { + slots.designInstanceReady = true; + } const next = appendHistory( updateArtifact(session, artifact.id, { status: "accepted" }), event, @@ -643,6 +751,7 @@ export function applyEvent( session: touch( running({ ...next, + slots, pendingArtifactId: undefined, pendingDecision: undefined, }), @@ -791,3 +900,62 @@ function formatSkillSelectionPrompt( const lines = skills.map((s, i) => ` ${i + 1}. ${s.name} — ${s.description}`); return ["请选择创作 skill(输入 name 或编号):", ...lines].join("\n"); } + +/** agent-first:仅 UI 引导,等用户首句后再 invoke 总管 */ +function enterAwaitFirstInput( + session: RuntimeSession, + skill: ActiveSkillSnapshot, + event: RuntimeEvent, +): ApplyEventResult { + const next = appendHistory( + { + ...session, + flowId: skill.defaultFlowId ?? session.flowId, + slots: { + ...session.slots, + activeSkill: skill, + }, + }, + event, + ); + return { + session: waiting(next, { kind: "input" }), + effects: [], + }; +} + +function enterIntakeWaiting( + session: RuntimeSession, + skill: ActiveSkillSnapshot, + event: RuntimeEvent, +): ApplyEventResult { + const next = appendHistory( + { + ...session, + flowId: skill.defaultFlowId ?? session.flowId, + slots: { + ...session.slots, + activeSkill: skill, + }, + }, + event, + ); + return { + session: waiting(next, { + kind: "intake", + prompt: skill.startupPrompt, + }), + effects: [{ type: "emit_message", message: skill.startupPrompt }], + }; +} + +function applySkillBinding( + session: RuntimeSession, + skill: ActiveSkillSnapshot, + event: RuntimeEvent, +): ApplyEventResult { + if (effectiveStartupMode(skill) === "agent-first") { + return enterAwaitFirstInput(session, skill, event); + } + return enterIntakeWaiting(session, skill, event); +} diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index 7b6f753..c2a01db 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -18,13 +18,75 @@ import { import { Blackboard } from "../blackboard/blackboard.js"; import type { LlmProvider } from "../llm/client.js"; import { DEFAULT_WORKERS, MainAgent } from "../main-agent/main-agent.js"; -import { listSkills, listWorkerSkills, loadSkill, loadWorkerSkill, resolveSkillId } from "../skills/loader.js"; +import { listSkills, listWorkerSkills, loadSkill, resolveSkillId } from "../skills/loader.js"; +import { + buildInstanceWorkerDeclaration, + formatUndeclaredWorkerError, + inferLifecycleStage, + mergeDeclaredWorkersForAgent, + readWorkerSetYamlForDeclaration, + shouldEnforceWorkerDeclaration, +} from "../skills/worker-declaration.js"; +import { resolveDefaultOrchestratorId } from "../config/default-orchestrator.js"; import { toActiveSkillSnapshot } from "../skills/snapshot.js"; import { runWorkerSkill } from "../worker/executor.js"; import { resolveWorkerId } from "../worker/resolve-id.js"; import { resolveWorkerLlmProvider } from "../skills/worker-llm.js"; import { extractIntakeFromMessage } from "../intake/extract.js"; import { readIntakeValues } from "../intake/intake.js"; +import { compressAfterWorkerAccept } from "./compress-after-worker.js"; +import { + CREATION_ACCEPTED_UNITS_TAG, + CREATION_CURRENT_UNIT_TAG, + CREATION_ACCEPTED_CONTENT_TAG, + WORKER_SET_DRAFT_TAG, + WORKER_SET_FINAL_TAG, + SLOT_CREATION_ACCEPTED_UNITS, + SLOT_CREATION_CURRENT_UNIT, + SLOT_CREATION_UNIT_ANCHOR_AT, + extractUnitContentFromDraft, + isDesignDiskWorker, + isDesignUnitArtifact, + parseAcceptedUnits, + upsertAcceptedUnitContent, +} from "../skills/creation-units.js"; +import { + CREATION_FLOW_TAG, + CREATION_CURRENT_STEP_TAG, + CREATION_MODULE_OPENING_TAG, + CREATION_MODULE_OPENING_STATE_TAG, + SLOT_CREATION_MODULE_OPENING_STATE, + nextPendingStep, + parseCreationFlow, + parseModuleOpeningState, + resolveDesignStepBinding, + stepUnitId, + stringifyModuleOpeningState, +} from "../skills/creation-flow.js"; +import { normalizeQuestions } from "../skills/question-protocol.js"; +import { parseWorkerSetYaml } from "../skills/worker-set-parse.js"; +import { + resolveAcceptanceModeForWorker, + resolveRunnableWorker, +} from "../skills/declared-worker.js"; +import { + mergeTableCells, + parseTableDoc, + stringifyTableDoc, + type TableDoc, +} from "../blackboard/table-cells.js"; +import { + SIDE_EFFECT_FIRED_TAG, + applySideEffectTagActions, + evaluateSideEffects, + parseFiredRegistry, + parseSideEffectRules, + stringifyFiredRegistry, +} from "../blackboard/table-side-effects.js"; +import { + mountResidentContextForWorker as writeResidentContextTags, + parseResidentContext, +} from "../skills/resident-context.js"; import type { AcceptanceMode, ActiveSkillSnapshot, @@ -50,6 +112,18 @@ export type PhaseRuntimeOptions = { blackboard?: Blackboard; /** 副作用 emit_message 与用户提示的回调(Web / CLI 接入点) */ onMessage?: (message: string) => void; + /** 总管 reasoning 流式增量 */ + onAgentThinkingDelta?: (delta: string) => void; + /** 一轮总管 LLM 思考完成 */ + onAgentThinkingDone?: (text: string) => void; + /** worker 开始流式输出 */ + onWorkerStreamStart?: (workerId: string) => void; + /** worker 思维链流式增量 */ + onWorkerThinkingDelta?: (workerId: string, delta: string) => void; + /** worker 正文/JSON 流式增量 */ + onWorkerOutputDelta?: (workerId: string, delta: string) => void; + /** worker 流式结束 */ + onWorkerStreamDone?: (workerId: string) => void; /** 从磁盘恢复时使用,跳过 createSession */ initialSession?: RuntimeSession; /** 与 initialSession 一并恢复黑板 */ @@ -65,13 +139,25 @@ export class PhaseRuntime { private readonly acceptanceMode: AcceptanceMode; private autoStubWorker: boolean; private readonly onMessage: (message: string) => void; - private readonly blackboard: Blackboard; + private readonly onAgentThinkingDelta?: (delta: string) => void; + private readonly onAgentThinkingDone?: (text: string) => void; + private readonly onWorkerStreamStart?: (workerId: string) => void; + private readonly onWorkerThinkingDelta?: (workerId: string, delta: string) => void; + private readonly onWorkerOutputDelta?: (workerId: string, delta: string) => void; + private readonly onWorkerStreamDone?: (workerId: string) => void; + private blackboard: Blackboard; private llm?: LlmProvider; private mainAgent?: MainAgent; /** autoStubWorker=false 时,run_worker effect 暂存于此,等 startPendingWorker() */ private pendingWorkerEffect: Extract | null = null; - /** start() 时从 registry 加载,供 skill_selection 展示与编号解析 */ + /** 最近一次 worker 启动前的快照,供 UI 重 roll 使用 */ + private lastWorkerRunSnapshot: { + workerId: string; + runtimeSession: RuntimeSession; + blackboardItems: BlackboardItem[]; + } | null = null; + /** start() 时从 registry 加载;legacy skill_selection 会话仍用编号解析 */ private availableSkills: SkillIndexEntry[] = []; constructor(options: PhaseRuntimeOptions = {}) { @@ -81,6 +167,12 @@ export class PhaseRuntime { this.acceptanceMode = options.acceptanceMode ?? "user_confirmed"; this.autoStubWorker = options.autoStubWorker ?? false; this.onMessage = options.onMessage ?? (() => {}); + this.onAgentThinkingDelta = options.onAgentThinkingDelta; + this.onAgentThinkingDone = options.onAgentThinkingDone; + this.onWorkerStreamStart = options.onWorkerStreamStart; + this.onWorkerThinkingDelta = options.onWorkerThinkingDelta; + this.onWorkerOutputDelta = options.onWorkerOutputDelta; + this.onWorkerStreamDone = options.onWorkerStreamDone; this.blackboard = options.blackboard ?? new Blackboard(); if (options.initialBlackboardItems?.length) { this.blackboard.seed(options.initialBlackboardItems); @@ -116,6 +208,51 @@ export class PhaseRuntime { return this.session; } + getLastWorkerRunSnapshot(): { + workerId: string; + runtimeSession: RuntimeSession; + blackboardItems: BlackboardItem[]; + } | null { + return this.lastWorkerRunSnapshot + ? { + workerId: this.lastWorkerRunSnapshot.workerId, + runtimeSession: structuredClone(this.lastWorkerRunSnapshot.runtimeSession), + blackboardItems: structuredClone(this.lastWorkerRunSnapshot.blackboardItems), + } + : null; + } + + /** 从 checkpoint 恢复 runtime + 黑板(编辑 / 切换分支) */ + restoreFromCheckpoint( + session: RuntimeSession, + blackboardItems: BlackboardItem[], + ): void { + this.session = structuredClone(session); + this.blackboard = new Blackboard(); + if (blackboardItems.length) { + this.blackboard.seed(structuredClone(blackboardItems)); + } + this.pendingWorkerEffect = null; + this.lastWorkerRunSnapshot = null; + } + + /** 重 roll 指定 worker(刷新 Skill 回复) */ + async rerunWorker(workerId: string): Promise { + const effect: Extract = { + type: "run_worker", + workerId, + }; + if (this.autoStubWorker) { + await this.runStubWorker(effect); + } else if (this.llm) { + await this.runRealWorker(effect); + } else { + this.pendingWorkerEffect = effect; + this.onMessage(`[阶段机] 重 roll worker: ${workerId}。输入 /worker-start`); + } + return this.session; + } + setLifecycleStage(stage: "design" | "play"): void { this.session = { ...this.session, @@ -155,29 +292,42 @@ export class PhaseRuntime { ); } - /** 加载 skills/registry → 触发 session_started → waiting_user(skill_selection) */ + /** 加载 registry → 自动进入默认 orchestrator → intake(无选包阶段) */ async start(): Promise { this.availableSkills = await listSkills(); if (this.availableSkills.length === 0) { - throw new Error("skills/ 下没有找到任何 skill 文件(novel/*.md 或 dialogue/*.md)"); + throw new Error("skills/registry 中没有注册任何 orchestrator"); } + const defaultId = resolveDefaultOrchestratorId(this.availableSkills); + const initialSkill = toActiveSkillSnapshot(await loadSkill(defaultId)); await this.dispatch({ type: "session_started", payload: { presetId: this.session.presetId, flowId: this.session.flowId, availableSkills: this.availableSkills, + initialSkill, }, }); return this.session; } - /** 启动并预选总管,跳过 skill_selection */ + /** 启动并加载指定 orchestrator(legacy 包 / 测试用) */ async startWithOrchestrator(orchestratorId: string): Promise { - await this.start(); - if (this.session.waitingReason?.kind === "skill_selection") { - await this.selectSkill(orchestratorId); + this.availableSkills = await listSkills(); + if (this.availableSkills.length === 0) { + throw new Error("skills/registry 中没有注册任何 orchestrator"); } + const initialSkill = toActiveSkillSnapshot(await loadSkill(orchestratorId)); + await this.dispatch({ + type: "session_started", + payload: { + presetId: this.session.presetId, + flowId: this.session.flowId, + availableSkills: this.availableSkills, + initialSkill, + }, + }); return this.session; } @@ -219,16 +369,22 @@ export class PhaseRuntime { /** 将 slots 中的启动目标等同步为黑板 tag(迁移期 tag 名可与旧 key 相同) */ private syncSlotsToBlackboard(session: RuntimeSession): void { const activeSkill = session.slots.activeSkill as ActiveSkillSnapshot | undefined; - if (activeSkill?.startupTargetKey) { - const tag = activeSkill.startupTargetKey; - const content = session.slots[tag]; - if (typeof content === "string" && content.trim()) { - this.blackboard.write({ - tag, - content: content.trim(), - source: "user", - }); - } + const demandTag = activeSkill?.startupTargetKey || "用户.需求"; + const demandContent = session.slots[demandTag]; + if (typeof demandContent === "string" && demandContent.trim()) { + this.blackboard.write({ + tag: demandTag, + content: demandContent.trim(), + source: "user", + }); + } + const latestInput = session.slots["用户.最新输入"]; + if (typeof latestInput === "string" && latestInput.trim()) { + this.blackboard.write({ + tag: "用户.最新输入", + content: latestInput.trim(), + source: "user", + }); } const workerReply = session.slots["用户.worker答复"]; if (typeof workerReply === "string" && workerReply.trim()) { @@ -250,13 +406,15 @@ export class PhaseRuntime { /** * 用户文本输入的统一入口。 - * skill_selection 阶段会先解析为 selectSkill,否则走 user_submitted_input。 + * legacy skill_selection 阶段会先解析为 selectSkill,否则走 user_submitted_input。 */ async submitInput(text: string): Promise { if (this.session.waitingReason?.kind === "skill_selection") { const picked = await this.resolveSkillFromUserInput(text); if (!picked) { - throw new Error(`无法识别 skill: ${text}。请输入 name 或列表编号。`); + throw new Error( + `无法识别 skill: ${text}。旧版会话请用 skill id(如 world-simulator)或列表编号。`, + ); } return this.selectSkill(picked); } @@ -329,11 +487,70 @@ export class PhaseRuntime { return this.session; } - /** 用户接受 pendingArtifact */ + /** 将实例 Worker 声明写入 slots,便于 session 持久化与调试 */ + private persistInstanceWorkerDeclaration(): void { + const active = this.getActiveSkill(); + if (!shouldEnforceWorkerDeclaration(active?.name)) return; + + const declaration = this.getInstanceWorkerDeclaration(); + this.session = { + ...this.session, + slots: { + ...this.session.slots, + instanceWorkerDeclaration: { + sourceTag: declaration.sourceTag, + accepted: declaration.accepted, + playWorkerIds: declaration.playWorkerIds, + designEndWorkerIds: declaration.designEndWorkerIds, + activeWorkerIds: declaration.activeWorkerIds, + }, + }, + }; + } + + /** 用户接受 pendingArtifact;创作单位验收 vs 终稿验收分流 */ async acceptArtifact(artifactId?: string): Promise { const id = artifactId ?? this.session.pendingArtifactId; if (!id) throw new Error("当前没有待验收的产物"); + const artifact = this.session.artifacts.find((a) => a.id === id); await this.dispatch({ type: "user_accepted_artifact", payload: { artifactId: id } }); + this.persistInstanceWorkerDeclaration(); + + if (artifact) { + const unitAccept = isDesignUnitArtifact(artifact); + const result = compressAfterWorkerAccept({ + blackboard: this.blackboard, + workerId: artifact.workerId, + outputTags: artifact.outputTags, + summary: artifact.summary, + mode: unitAccept ? "unit" : "final", + }); + + const slots: Record = { + ...this.session.slots, + lastCompressBrief: result.briefText, + lastCompressedWorkerId: artifact.workerId, + }; + + if (unitAccept) { + this.recordCreationUnitAccepted(slots, artifact); + const unitId = String(slots[SLOT_CREATION_CURRENT_UNIT] ?? "").trim(); + this.onMessage( + `[创作单位已验收] ${unitId || artifact.workerId}:${ + artifact.summary?.trim() || "草稿单位" + };过程对话将折叠,草稿保留。`, + ); + slots[SLOT_CREATION_CURRENT_UNIT] = undefined; + slots[SLOT_CREATION_UNIT_ANCHOR_AT] = undefined; + } else { + this.onMessage( + `[上下文已压缩] ${artifact.workerId}:保留 ${result.finals.map((f) => f.tag).join("、") || "(无终产物)"};归档 ${result.archivedTags.length} 个过程 tag。下一阶段以定稿为准。`, + ); + } + + this.session = { ...this.session, slots }; + } + return this.session; } @@ -348,8 +565,10 @@ export class PhaseRuntime { return this.session; } - /** worker 调用 ask_user 能力时,由外层触发此事件 */ - async workerAsk(questions: string[]): Promise { + /** worker 调用 ask_user 能力时,由外层触发此事件(仅无产物时阻塞) */ + async workerAsk( + questions: import("../types/questions.js").QuestionItem[] | string[], + ): Promise { const workerId = this.session.currentWorkerId; if (!workerId) throw new Error("当前没有运行中的 worker"); await this.dispatch({ @@ -359,6 +578,32 @@ export class PhaseRuntime { return this.session; } + /** + * 验收态下作答或跳过挂载追问:不离开 review_artifact。 + * answersText 缺省 = 跳过。 + */ + async resolveSidecarQuestions(answersText?: string): Promise { + await this.dispatch({ + type: "user_resolved_sidecar_questions", + payload: { answersText }, + }); + return this.session; + } + + /** 写入创作过程对话摘要(design worker contextSegments 用) */ + writeCreationDialogue(transcript: string): void { + const tag = "创作.对话"; + this.blackboard.write({ + tag, + content: transcript, + source: "system", + }); + this.session = { + ...this.session, + slots: { ...this.session.slots, [tag]: transcript }, + }; + } + /** * 手动标记 worker 完成:先创建 artifact 写入 session,再 dispatch worker_completed。 * 真实 worker 集成时,产物内容应在此之前写入 slots / blackboard。 @@ -441,9 +686,31 @@ export class PhaseRuntime { } } + private getInstanceWorkerDeclaration() { + return buildInstanceWorkerDeclaration( + this.session, + this.blackboard, + inferLifecycleStage(this.session), + ); + } + + /** world-simulator:run_worker 必须在 设计.worker集 声明的 activeWorkerIds 内 */ + private assertWorkerDeclared(workerId: string): void { + const active = this.getActiveSkill(); + if (!shouldEnforceWorkerDeclaration(active?.name)) return; + + const declaration = this.getInstanceWorkerDeclaration(); + const resolved = resolveWorkerId(workerId); + if (!declaration.activeWorkerIds.includes(resolved)) { + throw new Error(formatUndeclaredWorkerError(resolved, declaration)); + } + } + private async runRealWorker( effect: Extract, ): Promise { + this.assertWorkerDeclared(effect.workerId); + const activeSkill = this.getActiveSkill(); if (!activeSkill?.name) { throw new Error("当前没有 active skill,无法运行 worker"); @@ -453,6 +720,12 @@ export class PhaseRuntime { } const workerId = resolveWorkerId(effect.workerId); + this.lastWorkerRunSnapshot = { + workerId, + runtimeSession: structuredClone(this.session), + blackboardItems: this.blackboard.exportItems(), + }; + let slots = { ...this.session.slots }; this.syncSlotsToBlackboard(this.session); @@ -468,56 +741,93 @@ export class PhaseRuntime { this.session = { ...this.session, slots }; } + const acceptanceMode = resolveAcceptanceModeForWorker({ + session: this.session, + blackboard: this.blackboard, + workerId: effect.workerId, + }); + this.session = ( await this.dispatch({ type: "worker_started", payload: { workerId: effect.workerId, stepId: this.session.currentStepId, - acceptanceMode: this.session.resumeContext?.acceptanceMode ?? this.acceptanceMode, + acceptanceMode: + this.session.resumeContext?.acceptanceMode ?? acceptanceMode, }, }) ).session; - const skill = await loadSkill(activeSkill.name); - const worker = await loadWorkerSkill(activeSkill.name, effect.workerId); + if (isDesignDiskWorker(workerId)) { + this.beginCreationUnitRun(workerId); + } + + // design-step:能力「默认问题」由程序先发出,等用户首答后再调 LLM + if (workerId === "design-step") { + const delivered = await this.maybeDeliverModuleOpening(activeSkill.name); + if (delivered) return; + } + + const resolved = await resolveRunnableWorker({ + skillPackName: activeSkill.name, + workerId: effect.workerId, + session: this.session, + blackboard: this.blackboard, + }); + const worker = resolved.worker; + this.ensureResidentContextMounted(workerId); const workerLlm = resolveWorkerLlmProvider({ worker, - bindings: skill.workerLlmBindings, + bindings: (await loadSkill(activeSkill.name)).workerLlmBindings, slots, fallbackLlm: this.llm, }); - this.onMessage(`[Worker] ${workerId} 执行中…`); + this.onMessage( + `[Worker] ${workerId} 执行中…${resolved.source === "declaration" ? "(声明驱动)" : ""}`, + ); + this.onWorkerStreamStart?.(workerId); - const result = await runWorkerSkill({ - skillName: activeSkill.name, - workerId: effect.workerId, - slots, - blackboard: this.blackboard, - llm: workerLlm, - }); + try { + const result = await runWorkerSkill({ + skillName: activeSkill.name, + workerId: effect.workerId, + slots, + blackboard: this.blackboard, + llm: workerLlm, + declared: + resolved.source === "declaration" + ? { worker: resolved.worker, promptBody: resolved.promptBody } + : undefined, + stream: { + onThinkingDelta: (delta) => + this.onWorkerThinkingDelta?.(workerId, delta), + onOutputDelta: (delta) => + this.onWorkerOutputDelta?.(workerId, delta), + }, + }); - if (result.askUser?.length) { + if (result.askUser?.length && Object.keys(result.outputs).length === 0) { + // 无产物:阻塞追问(信息不足,必须补) await this.workerAsk(result.askUser); return; } slots = { ...this.session.slots }; for (const [tag, content] of Object.entries(result.outputs)) { - this.blackboard.write({ - tag, - content, - source: workerId, - }); - slots[tag] = content; + const written = this.writeWorkerTagContent(tag, content, workerId); + slots[tag] = written; } this.session = { ...this.session, slots }; const artifact = createArtifact({ workerId: effect.workerId, stepId: this.session.currentStepId, - outputTags: Object.keys(result.outputs), + outputTags: + Object.keys(result.outputs).length > 0 + ? Object.keys(result.outputs) + : worker.outputTags, summary: result.summary, }); this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] }; @@ -528,8 +838,16 @@ export class PhaseRuntime { await this.dispatch({ type: "worker_completed", - payload: { artifactId: artifact.id }, + payload: { + artifactId: artifact.id, + // 有产物时 askUser 挂到验收态,不阻断 Accept + questions: result.askUser?.length ? result.askUser : undefined, + }, }); + this.maybeCompressAcceptedArtifact(artifact.id); + } finally { + this.onWorkerStreamDone?.(workerId); + } } /** @@ -540,13 +858,29 @@ export class PhaseRuntime { effect: Extract, autoComplete = true, ): Promise { + this.assertWorkerDeclared(effect.workerId); + + const workerId = resolveWorkerId(effect.workerId); + this.lastWorkerRunSnapshot = { + workerId, + runtimeSession: structuredClone(this.session), + blackboardItems: this.blackboard.exportItems(), + }; + + const acceptanceMode = resolveAcceptanceModeForWorker({ + session: this.session, + blackboard: this.blackboard, + workerId: effect.workerId, + }); + this.session = ( await this.dispatch({ type: "worker_started", payload: { workerId: effect.workerId, stepId: this.session.currentStepId, - acceptanceMode: this.session.resumeContext?.acceptanceMode ?? this.acceptanceMode, + acceptanceMode: + this.session.resumeContext?.acceptanceMode ?? acceptanceMode, }, }) ).session; @@ -581,14 +915,384 @@ export class PhaseRuntime { type: "worker_completed", payload: { artifactId: artifact.id }, }); + this.maybeCompressAcceptedArtifact(artifact.id); + } + + /** 表类 tag 走字段格合并,尊重 user 手改与 rev;合并后算边沿副作用 */ + private writeWorkerTagContent( + tag: string, + content: string, + workerId: string, + ): string { + const tableTags = new Set(["运行.初始变量", "变量.当前"]); + let toWrite = content; + let prevDoc: TableDoc | null = null; + let nextDoc: TableDoc | null = null; + if (tableTags.has(tag)) { + const patch = parseTableDoc(content); + if (patch) { + prevDoc = parseTableDoc(this.blackboard.getContentByTag(tag)); + const { doc, skipped } = mergeTableCells({ + current: prevDoc, + patch, + actor: `worker:${workerId}`, + }); + nextDoc = doc; + toWrite = stringifyTableDoc(doc); + if (skipped.length) { + this.onMessage( + `[表合并] ${tag}:跳过 ${skipped.map((s) => `${s.key}(${s.reason})`).join("、")}`, + ); + } + } + } + this.blackboard.write({ + tag, + content: toWrite, + source: workerId, + }); + if (nextDoc) { + this.applyTableSideEffects(prevDoc, nextDoc, workerId); + } + return toWrite; + } + + /** 从 Worker 集 tables.side_effects 算边沿触发并写 tag / 记 fired */ + private applyTableSideEffects( + prev: TableDoc | null, + next: TableDoc, + workerId: string, + ): void { + const raw = readWorkerSetYamlForDeclaration(this.blackboard, this.session); + const parsed = raw ? parseWorkerSetYaml(raw.yaml) : null; + const rules = parseSideEffectRules(parsed?.tables); + if (rules.length === 0) return; + + const fired = parseFiredRegistry( + this.blackboard.getContentByTag(SIDE_EFFECT_FIRED_TAG), + ); + const { triggers, nextFired, queuedWorkers } = evaluateSideEffects({ + prev, + next, + rules, + fired, + }); + if (triggers.length === 0) return; + + const { writtenTags } = applySideEffectTagActions({ + blackboard: this.blackboard, + triggers, + source: `side-effect:${workerId}`, + }); + this.blackboard.write({ + tag: SIDE_EFFECT_FIRED_TAG, + content: stringifyFiredRegistry(nextFired), + source: "system:table-side-effect", + }); + + const ruleIds = triggers.map((t) => t.rule.id).join("、"); + this.onMessage( + `[表副作用] 边沿触发 ${ruleIds}` + + (writtenTags.length ? ` → 写入 ${writtenTags.join("、")}` : "") + + (queuedWorkers.length + ? `;建议调度 ${queuedWorkers.map((q) => q.workerId).join("、")}` + : ""), + ); + + if (queuedWorkers.length) { + this.session = { + ...this.session, + slots: { + ...this.session.slots, + pendingSideEffectWorkers: JSON.stringify(queuedWorkers), + }, + }; + } + } + + /** 按规格把常驻上下文写入黑板,供本 worker inputTags 读取 */ + private ensureResidentContextMounted(workerId: string): void { + const raw = readWorkerSetYamlForDeclaration(this.blackboard, this.session); + const parsed = raw ? parseWorkerSetYaml(raw.yaml) : null; + const entries = parseResidentContext(parsed?.resident_context); + if (entries.length === 0) return; + writeResidentContextTags({ + blackboard: this.blackboard, + entries, + workerId, + source: "system:resident-context", + }); + } + + /** + * 若本步能力声明了默认问题且尚未收过首答:写开场白、workerAsk,不调 LLM。 + * @returns true = 已进入等待用户,调用方应 return + */ + private async maybeDeliverModuleOpening( + skillPackName: string, + ): Promise { + const skill = await loadSkill(skillPackName); + const packRoot = skill.skillPackRoot; + if (!packRoot) return false; + + const flowRaw = this.blackboard.getContentByTag(CREATION_FLOW_TAG); + const currentStepName = + this.blackboard.getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim() || + String(this.session.slots[SLOT_CREATION_CURRENT_UNIT] ?? "").trim(); + const accepted = parseAcceptedUnits( + this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ?? + this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG), + ); + + const binding = await resolveDesignStepBinding({ + skillPackRoot: packRoot, + flowRaw, + currentStepName, + acceptedStepNames: accepted, + }); + if (!binding?.opening?.trim()) return false; + + const stepKey = stepUnitId(binding.step); + const state = parseModuleOpeningState( + this.session.slots[SLOT_CREATION_MODULE_OPENING_STATE] ?? + this.blackboard.getContentByTag(CREATION_MODULE_OPENING_STATE_TAG), + ); + const phase = state[stepKey]; + + if (phase === "answered") return false; + + if (phase === "shown" && this.session.resumeContext) { + // 用户刚答完默认问题 → 标记已答,继续走 LLM + state[stepKey] = "answered"; + const raw = stringifyModuleOpeningState(state); + this.blackboard.write({ + tag: CREATION_MODULE_OPENING_STATE_TAG, + content: raw, + source: "runtime", + }); + this.session = { + ...this.session, + slots: { + ...this.session.slots, + [SLOT_CREATION_MODULE_OPENING_STATE]: raw, + }, + resumeContext: undefined, + }; + return false; + } + + if (phase === "shown" && !this.session.resumeContext) { + // 异常重入:开场已发过但未走 resume;避免死循环,直接进 LLM + state[stepKey] = "answered"; + const raw = stringifyModuleOpeningState(state); + this.blackboard.write({ + tag: CREATION_MODULE_OPENING_STATE_TAG, + content: raw, + source: "runtime", + }); + this.session = { + ...this.session, + slots: { + ...this.session.slots, + [SLOT_CREATION_MODULE_OPENING_STATE]: raw, + }, + }; + return false; + } + + // 首次:发出默认问题 + state[stepKey] = "shown"; + const raw = stringifyModuleOpeningState(state); + this.blackboard.write({ + tag: CREATION_MODULE_OPENING_TAG, + content: binding.opening.trim(), + source: "runtime", + }); + this.blackboard.write({ + tag: CREATION_MODULE_OPENING_STATE_TAG, + content: raw, + source: "runtime", + }); + this.session = { + ...this.session, + slots: { + ...this.session.slots, + [CREATION_MODULE_OPENING_TAG]: binding.opening.trim(), + [SLOT_CREATION_MODULE_OPENING_STATE]: raw, + }, + }; + + const questions = normalizeQuestions([ + { + id: "module-opening", + prompt: binding.opening.trim(), + allowOther: true, + required: true, + }, + ]); + this.onMessage( + `[Worker] ${binding.module.name} · 默认问题(程序):\n${binding.opening.trim()}`, + ); + await this.workerAsk(questions); + return true; + } + + /** design-flow / design-step:钉当前步骤 */ + private beginCreationUnitRun(workerId: string): void { + const accepted = parseAcceptedUnits( + this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ?? + this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG), + ); + + let current = + this.blackboard.getContentByTag(CREATION_CURRENT_UNIT_TAG)?.trim() || + String(this.session.slots[SLOT_CREATION_CURRENT_UNIT] ?? "").trim(); + + if (workerId === "design-flow") { + current = "flow"; + } else if (workerId === "design-step") { + const flow = parseCreationFlow( + this.blackboard.getContentByTag(CREATION_FLOW_TAG), + ); + const pending = nextPendingStep(flow, accepted); + current = pending ? stepUnitId(pending) : current || "(无待执行步骤)"; + this.blackboard.write({ + tag: CREATION_CURRENT_STEP_TAG, + content: current, + source: "runtime", + }); + } + + if (!current) current = "flow"; + + const anchorAt = new Date().toISOString(); + this.blackboard.write({ + tag: CREATION_CURRENT_UNIT_TAG, + content: current, + source: "runtime", + }); + this.blackboard.write({ + tag: CREATION_ACCEPTED_UNITS_TAG, + content: JSON.stringify(accepted), + source: "runtime", + }); + this.session = { + ...this.session, + slots: { + ...this.session.slots, + [SLOT_CREATION_CURRENT_UNIT]: current, + [SLOT_CREATION_ACCEPTED_UNITS]: accepted, + [SLOT_CREATION_UNIT_ANCHOR_AT]: anchorAt, + }, + }; + } + + private recordCreationUnitAccepted( + slots: Record, + artifact: { summary?: string; outputTags: string[]; workerId?: string }, + ): void { + const fromBoard = this.blackboard.getContentByTag(CREATION_CURRENT_UNIT_TAG)?.trim(); + const fromSlot = String(slots[SLOT_CREATION_CURRENT_UNIT] ?? "").trim(); + const fromStep = this.blackboard.getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim(); + const fromSummary = artifact.summary?.match(/单位\s+([^\s·]+)/)?.[1]?.trim(); + const unitId = + (artifact.workerId === "design-step" ? fromStep : "") || + fromBoard || + fromSlot || + fromSummary || + ""; + const prev = parseAcceptedUnits( + slots[SLOT_CREATION_ACCEPTED_UNITS] ?? + this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG), + ); + const next = unitId && !prev.includes(unitId) ? [...prev, unitId] : prev; + slots[SLOT_CREATION_ACCEPTED_UNITS] = next; + slots[SLOT_CREATION_CURRENT_UNIT] = unitId || undefined; + this.blackboard.write({ + tag: CREATION_ACCEPTED_UNITS_TAG, + content: JSON.stringify(next), + source: "runtime", + }); + + // 前情提要:写入该单位「最后一次验收」的内容切片 + if (unitId) { + let slice: unknown = null; + if (artifact.workerId === "design-step" || artifact.workerId === "design-flow") { + const pack = this.getActiveSkill()?.skillPackRoot; + // skillPackRoot may not be on snapshot — resolve via artifact tags + const contentTag = + artifact.outputTags.find( + (t) => t !== CREATION_CURRENT_STEP_TAG && t !== CREATION_CURRENT_UNIT_TAG, + ) || artifact.outputTags[0]; + if (contentTag) { + const raw = this.blackboard.getContentByTag(contentTag)?.trim(); + if (raw) { + try { + slice = JSON.parse(raw); + } catch { + slice = raw; + } + } + } + void pack; + } else { + const draftRaw = + this.blackboard.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() || + this.blackboard.getContentByTag(WORKER_SET_FINAL_TAG)?.trim() || + ""; + const parsed = draftRaw ? parseWorkerSetYaml(draftRaw) : null; + slice = extractUnitContentFromDraft(parsed, unitId); + } + if (slice != null) { + const existing = this.blackboard.getContentByTag(CREATION_ACCEPTED_CONTENT_TAG); + const updated = upsertAcceptedUnitContent(existing, { + unitId, + content: slice, + summary: artifact.summary, + }); + this.blackboard.write({ + tag: CREATION_ACCEPTED_CONTENT_TAG, + content: updated, + source: "runtime", + }); + slots[CREATION_ACCEPTED_CONTENT_TAG] = updated; + } + } + } + + private maybeCompressAcceptedArtifact(artifactId: string): void { + const artifact = this.session.artifacts.find((a) => a.id === artifactId); + if (!artifact || artifact.status !== "accepted") return; + const result = compressAfterWorkerAccept({ + blackboard: this.blackboard, + workerId: artifact.workerId, + outputTags: artifact.outputTags, + summary: artifact.summary, + }); + this.session = { + ...this.session, + slots: { + ...this.session.slots, + lastCompressBrief: result.briefText, + lastCompressedWorkerId: artifact.workerId, + }, + }; + this.onMessage( + `[上下文已压缩] ${artifact.workerId}:保留 ${result.finals.map((f) => f.tag).join("、") || "(无终产物)"};归档 ${result.archivedTags.length} 个过程 tag。下一阶段以定稿为准。`, + ); } private async resolveWorkerOutputTags(workerId: string): Promise { const active = this.getActiveSkill(); if (!active?.name) return []; try { - const worker = await loadWorkerSkill(active.name, workerId); - return worker.outputTags; + const resolved = await resolveRunnableWorker({ + skillPackName: active.name, + workerId, + session: this.session, + blackboard: this.blackboard, + }); + return resolved.worker.outputTags; } catch { return []; } @@ -648,6 +1352,13 @@ export class PhaseRuntime { detail.length > 120 ? `${detail.slice(0, 120)}…` : detail; this.onMessage(`[总管 tool] ${name}${preview ? `: ${preview}` : ""}`); }, + onThinkingDelta: (delta) => { + this.onAgentThinkingDelta?.(delta); + }, + onThinkingDone: (text) => { + this.onAgentThinkingDone?.(text); + this.onMessage(`[总管 思考]\n\n${text}`); + }, }, }, ); @@ -696,8 +1407,15 @@ export class PhaseRuntime { if (!active?.name) return [...DEFAULT_WORKERS]; try { const workers = await listWorkerSkills(active.name); - if (workers.length === 0) return [...DEFAULT_WORKERS]; - return workers.map((w) => ({ id: w.id, description: w.description })); + const installed = workers.map((w) => ({ id: w.id, description: w.description })); + + if (!shouldEnforceWorkerDeclaration(active.name)) { + return installed.length > 0 ? installed : [...DEFAULT_WORKERS]; + } + + const declaration = this.getInstanceWorkerDeclaration(); + const merged = mergeDeclaredWorkersForAgent(installed, declaration); + return merged.length > 0 ? merged : installed; } catch { return [...DEFAULT_WORKERS]; } @@ -736,27 +1454,26 @@ export function formatSession(session: RuntimeSession): string { export async function runMinimalClosedLoop( runtime: PhaseRuntime, ): Promise { - await runtime.start(); - await runtime.selectSkill("basic"); - await runtime.submitInput("科幻长篇,第三人称,约 20 万字"); - await runtime.confirmIntake(); - + await runtime.startWithOrchestrator("world-simulator"); + await runtime.submitInput("科幻长篇,带世界运转,约 20 万字交互"); const decision = createDecision({ action: "run_worker", - reason: "生成大纲", - workerId: "outline-worker", - requiresApproval: true, + reason: "根据用户需求产出 Worker 集", + workerId: "design-flow", + requiresApproval: false, }); await runtime.submitDecision(decision); - await runtime.approve(); if (runtime.getSession().currentWorkerId) { await runtime.workerComplete(); } - await runtime.acceptArtifact(); + if (runtime.getSession().pendingArtifactId) { + await runtime.acceptArtifact(); + } + await runtime.submitDecision( - createDecision({ action: "finish", reason: "完成", requiresApproval: false }), + createDecision({ action: "finish", reason: "演示结束", requiresApproval: false }), ); return runtime.getSession(); diff --git a/src/runtime/tool-registry.ts b/src/runtime/tool-registry.ts index 95ce02f..58a39b4 100644 --- a/src/runtime/tool-registry.ts +++ b/src/runtime/tool-registry.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { ParsedToolCall } from "../llm/client.js"; import type { MainAgentDecision } from "../types/runtime.js"; +import { normalizeQuestions } from "../skills/question-protocol.js"; import { isMainAgentLoopTool, isMainAgentTerminalTool, @@ -60,11 +61,15 @@ export function toolCallToDecision(call: ParsedToolCall): MainAgentDecision { switch (name as MainAgentToolName) { case "ask_user": { const reason = requireString(args, "reason"); - const message = optionalString(args, "message"); + const assessment = + optionalString(args, "assessment") ?? optionalString(args, "message"); + const questions = normalizeQuestions(args.questions); return { id: randomUUID(), action: "ask_user", - reason: message ? `${reason}\n${message}` : reason, + reason, + assessment: assessment || undefined, + questions: questions.length ? questions : undefined, requiresApproval: false, statePatchAllowed: false, }; diff --git a/src/server/agent-view.ts b/src/server/agent-view.ts index a7f2de3..ed7ff0b 100644 --- a/src/server/agent-view.ts +++ b/src/server/agent-view.ts @@ -8,11 +8,18 @@ import { type LifecycleStage, type SkillCatalogEntry, } from "./skill-catalog.js"; +import { + displayWorkerLabel, + formatAgentDisplayTitle, + formatWorkerDisplayTitle, +} from "./display-labels.js"; export type AgentMessageKind = | "user_input" | "orchestrator_decision" + | "orchestrator_thinking" | "orchestrator_prompt" + | "orchestrator_assessment" | "agent_tool" | "worker_running" | "worker_output" @@ -77,18 +84,29 @@ export function classifyAgentMessage(text: string): EnrichedMessage { return { kind: "agent_tool", actor: "orchestrator", - title: `Tool · ${name}`, + title: `工具 · ${name}`, body: detail || "(无输出)", text: trimmed, }; } + const agentThink = trimmed.match(/^\[总管 思考\]\s*\n?\n?([\s\S]*)$/); + if (agentThink) { + return { + kind: "orchestrator_thinking", + actor: "orchestrator", + title: formatAgentDisplayTitle("思考"), + body: agentThink[1]?.trim() || "(无内容)", + text: trimmed, + }; + } + const orchestrator = trimmed.match(/^\[总管\]\s*(\w+):\s*([\s\S]+)$/); if (orchestrator) { return { kind: "orchestrator_decision", actor: "orchestrator", - title: `Agent · ${orchestrator[1]}`, + title: formatAgentDisplayTitle(orchestrator[1]), body: orchestrator[2].trim(), text: trimmed, }; @@ -99,8 +117,30 @@ export function classifyAgentMessage(text: string): EnrichedMessage { return { kind: "worker_running", actor: workerRunning[1], - title: `Worker · ${workerRunning[1]}`, - body: "正在调用模型执行 SKILL…", + title: formatWorkerDisplayTitle(workerRunning[1], "running"), + body: "正在调用模型执行…", + text: trimmed, + }; + } + + const compressed = trimmed.match(/^\[上下文已压缩\]\s*([\s\S]+)$/); + if (compressed) { + return { + kind: "system_info", + actor: "system", + title: "上下文已压缩", + body: compressed[1].trim(), + text: trimmed, + }; + } + + const unitAccepted = trimmed.match(/^\[创作单位已验收\]\s*([\s\S]+)$/); + if (unitAccepted) { + return { + kind: "system_info", + actor: "system", + title: "创作单位已验收", + body: unitAccepted[1].trim(), text: trimmed, }; } @@ -110,7 +150,7 @@ export function classifyAgentMessage(text: string): EnrichedMessage { return { kind: "worker_output", actor: workerDone[1], - title: `Worker · ${workerDone[1]} 产出`, + title: formatWorkerDisplayTitle(workerDone[1], "output"), body: workerDone[2]?.trim() || "(无正文)", text: trimmed, }; @@ -121,7 +161,7 @@ export function classifyAgentMessage(text: string): EnrichedMessage { return { kind: "worker_stub", actor: stub?.[1], - title: `占位 Worker · ${stub?.[1] ?? "?"}`, + title: formatWorkerDisplayTitle(stub?.[1], "stub"), body: trimmed, text: trimmed, }; @@ -135,7 +175,7 @@ export function classifyAgentMessage(text: string): EnrichedMessage { return { kind: "worker_questions", actor: workerAskTagged[1], - title: `Worker · ${workerAskTagged[1]} 提问`, + title: formatWorkerDisplayTitle(workerAskTagged[1], "questions"), body, text: trimmed, }; @@ -170,6 +210,44 @@ export function classifyAgentMessage(text: string): EnrichedMessage { }; } + if (trimmed.startsWith("[Agent] 内容评价")) { + return { + kind: "orchestrator_assessment", + actor: "orchestrator", + title: "总管 · 内容评价", + body: trimmed.replace(/^\[Agent\]\s*内容评价[::]\s*/, "").trim() || trimmed, + text: trimmed, + }; + } + + if (trimmed.startsWith("[Agent] 提问") || trimmed.startsWith("[Agent] 可选追问")) { + return { + kind: "worker_questions", + actor: "orchestrator", + title: "总管 · 可选追问", + body: formatWorkerQuestionBody( + trimmed + .replace(/^\[Agent\]\s*可选追问(可跳过)[::]\s*/, "") + .replace(/^\[Agent\]\s*提问[::]\s*/, ""), + ), + text: trimmed, + }; + } + + if (trimmed.match(/^\[Worker\]\s*可选追问/)) { + return { + kind: "worker_questions", + title: "可选追问", + body: formatWorkerQuestionBody( + trimmed.replace( + /^\[Worker\]\s*可选追问(可跳过,直接接受(?:目前)?产物)[::]\s*/, + "", + ), + ), + text: trimmed, + }; + } + if ( trimmed.includes("请告诉我") || trimmed.includes("启动询问") || @@ -178,7 +256,7 @@ export function classifyAgentMessage(text: string): EnrichedMessage { return { kind: "orchestrator_prompt", actor: "orchestrator", - title: "Agent · 启动询问", + title: "总管 · 启动询问", body: trimmed, text: trimmed, }; @@ -268,16 +346,37 @@ export function buildFocus( const detail = intake && intake.requiredTotal > 0 ? `必要项 ${intake.requiredFilled}/${intake.requiredTotal}` - : "完成必要项后可进入实例化"; + : "总管将根据描述推理 Worker 集"; return { actorType: "user", actorLabel: "你", - action: "填写创作信息", + action: "描述创作需求", detail, }; } + if (reason?.kind === "input" && !session.slots.startupCompleted) { + return { + actorType: "user", + actorLabel: "你", + action: "描述创作需求", + detail: "发送后总管将开始:创作 · 核心", + }; + } + if (reason?.kind === "input") { + if (reason.questions?.length) { + const q = reason.questions + .map((item) => item.prompt) + .filter((s) => s?.trim()) + .join(";"); + return { + actorType: "user", + actorLabel: "你", + action: "回答追问", + detail: q.slice(0, 200) || reason.message, + }; + } return { actorType: "user", actorLabel: "你", @@ -291,30 +390,37 @@ export function buildFocus( return { actorType: "orchestrator", actorId: "orchestrator", - actorLabel: "Agent", - action: `建议 invoke ${worker}`, + actorLabel: "总管", + action: `建议调用 ${displayWorkerLabel(worker)}`, detail: session.pendingDecision?.reason, }; } if (reason?.kind === "worker_questions") { - const q = reason.questions?.filter((s) => s?.trim()).join(";") ?? ""; + const q = + reason.questions + ?.map((item) => item.prompt) + .filter((s) => s?.trim()) + .join(";") ?? ""; return { actorType: "user", actorId: reason.workerId, actorLabel: "你", - action: `回答 · ${reason.workerId}`, - detail: q.slice(0, 200) || "请在下框补充", + action: `回答 · ${displayWorkerLabel(reason.workerId)}`, + detail: q.slice(0, 200) || "请在询问卡作答", }; } if (reason?.kind === "review_artifact") { const art = session.artifacts.find((a) => a.id === session.pendingArtifactId); + const optionalQs = reason.questions?.length + ? `;另有 ${reason.questions.length} 道可选追问` + : ""; return { actorType: "user", actorLabel: "你", action: "验收产物", - detail: art?.summary ?? art?.workerId, + detail: `${art?.summary ?? displayWorkerLabel(art?.workerId) ?? ""}${optionalQs}`, }; } @@ -322,9 +428,9 @@ export function buildFocus( return { actorType: "worker", actorId: session.currentWorkerId, - actorLabel: `Skill · ${session.currentWorkerId}`, + actorLabel: displayWorkerLabel(session.currentWorkerId), action: "执行中", - detail: "模型按 SKILL 产出…", + detail: "模型正在产出…", }; } @@ -332,9 +438,9 @@ export function buildFocus( return { actorType: "orchestrator", actorId: "orchestrator", - actorLabel: "Agent", - action: stage === "design" ? "设计 burst" : "游玩 burst", - detail: "tool loop:读黑板 → 选 skill", + actorLabel: "总管", + action: stage === "design" ? "创作调度" : "游玩调度", + detail: "读黑板 → 选下一步", }; } @@ -342,7 +448,8 @@ export function buildFocus( return { actorType: "user", actorLabel: "你", - action: "选择 Skill 包", + action: "恢复中的旧会话", + detail: "请发送任意消息继续,或联系维护者", }; } diff --git a/src/server/book-handlers.ts b/src/server/book-handlers.ts index 877d1a2..7ecfdbb 100644 --- a/src/server/book-handlers.ts +++ b/src/server/book-handlers.ts @@ -1,190 +1,559 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; -import { listSkills } from "../skills/loader.js"; -import { createBook, deleteBook, getBook, listBooks, updateBook } from "../book/store.js"; -import { sessionManager } from "./session-manager.js"; - -async function readBody(req: IncomingMessage): Promise { - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(chunk as Buffer); - } - return Buffer.concat(chunks).toString("utf8"); -} - -function json(res: ServerResponse, status: number, data: unknown): void { - res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); - res.end(JSON.stringify(data)); -} - -export async function handleBooksApi( - req: IncomingMessage, - res: ServerResponse, - pathname: string, -): Promise { - if (pathname === "/api/skills" && req.method === "GET") { - const skills = await listSkills(); - json(res, 200, { - skills: skills.map((s) => ({ - id: s.name, - name: s.name, - description: s.description, - category: s.category, - bookKind: s.bookKind, - })), - }); - return true; - } - - if (pathname === "/api/books" && req.method === "GET") { - json(res, 200, { books: listBooks() }); - return true; - } - - if (pathname === "/api/books" && req.method === "POST") { - const body = JSON.parse(await readBody(req)) as { title?: string }; - const book = createBook({ title: body.title }); - const session = await sessionManager.createForBook(book.id); - json(res, 201, { book, session }); - return true; - } - - const savesListMatch = pathname.match(/^\/api\/books\/([^/]+)\/saves$/); - if (savesListMatch) { - const bookId = decodeURIComponent(savesListMatch[1]); - const book = getBook(bookId); - if (!book) { - json(res, 404, { error: "Book 不存在" }); - return true; - } - - if (req.method === "GET") { - try { - const saves = sessionManager.listGameSnapshots(bookId); - json(res, 200, { saves }); - } catch (err) { - json(res, 400, { - error: err instanceof Error ? err.message : "读取存档失败", - }); - } - return true; - } - - if (req.method === "POST") { - const body = JSON.parse(await readBody(req)) as { - label?: string; - note?: string; - sessionId?: string; - kind?: "instance" | "run"; - }; - const sessionId = - body.sessionId?.trim() || - sessionManager.getActiveSessionForBook(bookId)?.id; - if (!sessionId) { - json(res, 400, { error: "当前作品没有活跃会话,无法存档" }); - return true; - } - const kind = body.kind === "instance" ? "instance" : "run"; - try { - const save = sessionManager.saveGameSnapshot( - sessionId, - body.label ?? "", - kind, - body.note, - ); - json(res, 201, { save }); - } catch (err) { - json(res, 400, { - error: err instanceof Error ? err.message : "存档失败", - }); - } - return true; - } - } - - const saveItemMatch = pathname.match( - /^\/api\/books\/([^/]+)\/saves\/([^/]+)(\/load)?$/, - ); - if (saveItemMatch) { - const bookId = decodeURIComponent(saveItemMatch[1]); - const saveId = decodeURIComponent(saveItemMatch[2]); - const isLoad = saveItemMatch[3] === "/load"; - - if (isLoad && req.method === "POST") { - try { - const session = await sessionManager.loadGameSnapshot(bookId, saveId); - json(res, 200, { session }); - } catch (err) { - json(res, 400, { - error: err instanceof Error ? err.message : "读档失败", - }); - } - return true; - } - - if (req.method === "DELETE") { - try { - sessionManager.deleteGameSnapshot(bookId, saveId); - json(res, 200, { ok: true }); - } catch (err) { - json(res, 404, { - error: err instanceof Error ? err.message : "删除失败", - }); - } - return true; - } - } - - const bookMatch = pathname.match(/^\/api\/books\/([^/]+)(\/open)?$/); - if (bookMatch) { - const bookId = decodeURIComponent(bookMatch[1]); - const isOpen = bookMatch[2] === "/open"; - - if (isOpen && req.method === "POST") { - const book = getBook(bookId); - if (!book) { - json(res, 404, { error: "Book 不存在" }); - return true; - } - const session = await sessionManager.openBook(book.id); - json(res, 200, { book, session }); - return true; - } - - if (req.method === "GET") { - const book = getBook(bookId); - if (!book) { - json(res, 404, { error: "Book 不存在" }); - return true; - } - json(res, 200, { book }); - return true; - } - - if (req.method === "PUT") { - const body = JSON.parse(await readBody(req)) as { title?: string }; - try { - const book = updateBook(bookId, { title: body.title?.trim() }); - json(res, 200, { book }); - } catch (err) { - json(res, 404, { - error: err instanceof Error ? err.message : "更新失败", - }); - } - return true; - } - - if (req.method === "DELETE") { - const book = getBook(bookId); - if (!book) { - json(res, 404, { error: "Book 不存在" }); - return true; - } - sessionManager.dropBookSessions(bookId); - deleteBook(bookId); - json(res, 200, { ok: true }); - return true; - } - } - - return false; -} +import type { IncomingMessage, ServerResponse } from "node:http"; +import { listSkills } from "../skills/loader.js"; +import { createBook, deleteBook, duplicateBook, getBook, listBooks, updateBook } from "../book/store.js"; +import { sessionManager } from "./session-manager.js"; + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function json(res: ServerResponse, status: number, data: unknown): void { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(data)); +} + +type ModuleStatus = "ready" | "partial" | "skeleton" | "missing"; + +function moduleStatusFromSections( + hasPrompt: boolean, + present: string[], + taskBody?: string, +): ModuleStatus { + if (!hasPrompt) return "missing"; + const hasTask = present.includes("task"); + const hasOutput = present.includes("output"); + if (!hasTask || !hasOutput) return "skeleton"; + const task = taskBody?.trim() ?? ""; + const stub = + !task || + /待作者细写|待完善|(待/.test(task) || + task.length < 120; + if (stub) return "skeleton"; + const depth = ["principles", "probe", "checklist", "examples"].filter((id) => + present.includes(id), + ).length; + return depth >= 2 ? "ready" : "partial"; +} + +async function loadModulesPayload(skillId: string): Promise<{ + skillPackId: string; + modules: Array<{ + id: string; + name: string; + declaration: string; + artifact: string; + hasPrompt: boolean; + sectionsPresent: string[]; + status: ModuleStatus; + }>; +}> { + const { loadSkill } = await import("../skills/loader.js"); + const { + loadModuleCatalog, + loadModulePrompt, + parseModulePromptSections, + MODULE_SECTION_IDS, + } = await import("../skills/creation-flow.js"); + const skill = await loadSkill(skillId); + if (!skill.skillPackRoot) { + return { skillPackId: skillId, modules: [] }; + } + const catalog = await loadModuleCatalog(skill.skillPackRoot); + const modules = []; + for (const m of catalog?.modules ?? []) { + const prompt = await loadModulePrompt(skill.skillPackRoot, m.id); + const sections = prompt + ? parseModulePromptSections(prompt) + : { raw: "", blocks: {} }; + const sectionsPresent = MODULE_SECTION_IDS.filter((id) => + Boolean(sections.blocks[id]?.trim()), + ); + const hasPrompt = Boolean(prompt?.trim()); + const taskBody = sections.blocks.task?.trim(); + modules.push({ + id: m.id, + name: m.name, + declaration: m.declaration, + artifact: m.artifact, + hasPrompt, + sectionsPresent: [...sectionsPresent], + status: moduleStatusFromSections(hasPrompt, sectionsPresent, taskBody), + }); + } + return { skillPackId: skillId, modules }; +} + +async function loadModuleDetailPayload( + skillId: string, + moduleId: string, +): Promise<{ + skillPackId: string; + id: string; + name: string; + declaration: string; + artifact: string; + hasPrompt: boolean; + sectionsPresent: string[]; + status: ModuleStatus; + sections: Record; + meta: Record | null; + raw: string; +} | null> { + const { loadSkill } = await import("../skills/loader.js"); + const { + loadModuleCatalog, + loadModulePrompt, + parseModulePromptSections, + MODULE_SECTION_IDS, + } = await import("../skills/creation-flow.js"); + const { parse: parseYaml } = await import("yaml"); + const skill = await loadSkill(skillId); + if (!skill.skillPackRoot) return null; + const catalog = await loadModuleCatalog(skill.skillPackRoot); + const entry = catalog?.modules.find((m) => m.id === moduleId) ?? null; + if (!entry) return null; + const prompt = await loadModulePrompt(skill.skillPackRoot, moduleId); + const parsed = prompt + ? parseModulePromptSections(prompt) + : { raw: "", blocks: {} }; + const sections: Record = {}; + for (const id of MODULE_SECTION_IDS) { + const body = parsed.blocks[id]?.trim(); + if (body) sections[id] = body; + } + const sectionsPresent = Object.keys(sections); + const hasPrompt = Boolean(prompt?.trim()); + let meta: Record | null = null; + const metaRaw = sections.meta; + if (metaRaw) { + try { + const doc = parseYaml(metaRaw); + if (doc && typeof doc === "object" && !Array.isArray(doc)) { + meta = doc as Record; + } + } catch { + meta = null; + } + } + return { + skillPackId: skillId, + id: entry.id, + name: entry.name, + declaration: entry.declaration, + artifact: entry.artifact, + hasPrompt, + sectionsPresent, + status: moduleStatusFromSections( + hasPrompt, + sectionsPresent, + sections.task, + ), + sections, + meta, + raw: prompt ?? "", + }; +} + +export async function handleBooksApi( + req: IncomingMessage, + res: ServerResponse, + pathname: string, +): Promise { + if (pathname === "/api/skills" && req.method === "GET") { + const skills = await listSkills(); + json(res, 200, { + skills: skills.map((s) => ({ + id: s.name, + name: s.name, + description: s.description, + category: s.category, + bookKind: s.bookKind, + })), + }); + return true; + } + + /** 新建作品:导演一层选型(内部 = recipes catalog) */ + if (pathname === "/api/directors" && req.method === "GET") { + const { DEFAULT_ORCHESTRATOR_ID } = await import( + "../config/default-orchestrator.js" + ); + const skillId = DEFAULT_ORCHESTRATOR_ID; + try { + const { loadSkill } = await import("../skills/loader.js"); + const { loadRecipeCatalog } = await import("../skills/creation-flow.js"); + const skill = await loadSkill(skillId); + if (!skill.skillPackRoot) { + json(res, 200, { directors: [], skillPackId: skillId }); + return true; + } + const catalog = await loadRecipeCatalog(skill.skillPackRoot); + json(res, 200, { + skillPackId: skillId, + directors: (catalog?.recipes ?? []).map((r) => ({ + id: r.id, + name: r.name, + declaration: r.declaration, + })), + }); + } catch (err) { + json(res, 404, { + error: err instanceof Error ? err.message : "未找到导演列表", + }); + } + return true; + } + + const recipesMatch = pathname.match(/^\/api\/skills\/([^/]+)\/recipes$/); + if (recipesMatch && req.method === "GET") { + const skillId = decodeURIComponent(recipesMatch[1]); + try { + const { loadSkill } = await import("../skills/loader.js"); + const { loadRecipeCatalog } = await import("../skills/creation-flow.js"); + const skill = await loadSkill(skillId); + if (!skill.skillPackRoot) { + json(res, 200, { recipes: [] }); + return true; + } + const catalog = await loadRecipeCatalog(skill.skillPackRoot); + json(res, 200, { + recipes: (catalog?.recipes ?? []).map((r) => ({ + id: r.id, + name: r.name, + declaration: r.declaration, + })), + }); + } catch (err) { + json(res, 404, { + error: err instanceof Error ? err.message : "未找到能力包", + }); + } + return true; + } + + /** 默认导演包的能力池(编排备选) */ + if (pathname === "/api/modules" && req.method === "GET") { + const { DEFAULT_ORCHESTRATOR_ID } = await import( + "../config/default-orchestrator.js" + ); + try { + const payload = await loadModulesPayload(DEFAULT_ORCHESTRATOR_ID); + json(res, 200, payload); + } catch (err) { + json(res, 404, { + error: err instanceof Error ? err.message : "未找到能力目录", + }); + } + return true; + } + + const moduleDetailMatch = pathname.match(/^\/api\/modules\/([^/]+)$/); + if (moduleDetailMatch && req.method === "GET") { + const { DEFAULT_ORCHESTRATOR_ID } = await import( + "../config/default-orchestrator.js" + ); + const moduleId = decodeURIComponent(moduleDetailMatch[1]); + try { + const detail = await loadModuleDetailPayload( + DEFAULT_ORCHESTRATOR_ID, + moduleId, + ); + if (!detail) { + json(res, 404, { error: `未找到能力:${moduleId}` }); + return true; + } + json(res, 200, detail); + } catch (err) { + json(res, 404, { + error: err instanceof Error ? err.message : "未找到能力", + }); + } + return true; + } + + const skillModulesMatch = pathname.match( + /^\/api\/skills\/([^/]+)\/modules$/, + ); + if (skillModulesMatch && req.method === "GET") { + const skillId = decodeURIComponent(skillModulesMatch[1]); + try { + const payload = await loadModulesPayload(skillId); + json(res, 200, payload); + } catch (err) { + json(res, 404, { + error: err instanceof Error ? err.message : "未找到能力目录", + }); + } + return true; + } + + const skillModuleDetailMatch = pathname.match( + /^\/api\/skills\/([^/]+)\/modules\/([^/]+)$/, + ); + if (skillModuleDetailMatch && req.method === "GET") { + const skillId = decodeURIComponent(skillModuleDetailMatch[1]); + const moduleId = decodeURIComponent(skillModuleDetailMatch[2]); + try { + const detail = await loadModuleDetailPayload(skillId, moduleId); + if (!detail) { + json(res, 404, { error: `未找到能力:${moduleId}` }); + return true; + } + json(res, 200, detail); + } catch (err) { + json(res, 404, { + error: err instanceof Error ? err.message : "未找到能力", + }); + } + return true; + } + + if (pathname === "/api/books" && req.method === "GET") { + json(res, 200, { books: listBooks() }); + return true; + } + + if (pathname === "/api/books" && req.method === "POST") { + const body = JSON.parse(await readBody(req)) as { + title?: string; + /** 导演 Skill / 能力包 id(registry name) */ + orchestratorId?: string; + /** 用户手动选定的导演 id(内部 recipe) */ + recipeId?: string; + }; + const skills = await listSkills(); + const requested = body.orchestratorId?.trim(); + const director = + (requested && skills.find((s) => s.name === requested)) || + skills.find((s) => s.name === "world-simulator") || + skills[0]; + if (!director) { + json(res, 400, { error: "没有可用的导演 Skill(能力包)" }); + return true; + } + const book = createBook({ title: body.title }); + updateBook(book.id, { + activeSkillId: director.name, + activeSkillName: director.description?.split("\n")[0]?.slice(0, 80) || director.name, + orchestratorId: director.name, + orchestratorName: director.name, + }); + const session = await sessionManager.createForBook( + book.id, + director.name, + body.recipeId?.trim(), + ); + json(res, 201, { book: getBook(book.id) ?? book, session }); + return true; + } + + if (pathname === "/api/books/batch" && req.method === "DELETE") { + const body = JSON.parse(await readBody(req)) as { ids?: string[] }; + const ids = (body.ids ?? []).filter(Boolean); + const deleted: string[] = []; + for (const id of ids) { + const book = getBook(id); + if (!book) continue; + sessionManager.dropBookSessions(id); + deleteBook(id); + deleted.push(id); + } + json(res, 200, { deleted }); + return true; + } + + const duplicateMatch = pathname.match(/^\/api\/books\/([^/]+)\/duplicate$/); + if (duplicateMatch && req.method === "POST") { + const bookId = decodeURIComponent(duplicateMatch[1]); + const body = JSON.parse(await readBody(req)) as { title?: string }; + try { + const book = duplicateBook(bookId, body.title); + json(res, 201, { book }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "复制失败", + }); + } + return true; + } + + const playNewMatch = pathname.match(/^\/api\/books\/([^/]+)\/play\/new$/); + if (playNewMatch && req.method === "POST") { + const bookId = decodeURIComponent(playNewMatch[1]); + const book = getBook(bookId); + if (!book) { + json(res, 404, { error: "Book 不存在" }); + return true; + } + const active = sessionManager.getActiveSessionForBook(bookId); + if (!active?.id) { + json(res, 400, { error: "请先打开该作品" }); + return true; + } + try { + const session = await sessionManager.startNewPlayRun(active.id); + json(res, 200, { session }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "无法新建游玩", + }); + } + return true; + } + + const savesListMatch = pathname.match(/^\/api\/books\/([^/]+)\/saves$/); + if (savesListMatch) { + const bookId = decodeURIComponent(savesListMatch[1]); + const book = getBook(bookId); + if (!book) { + json(res, 404, { error: "Book 不存在" }); + return true; + } + + if (req.method === "GET") { + try { + const saves = sessionManager.listGameSnapshots(bookId).map((s) => ({ + ...s, + kindLabel: s.kind === "instance" ? "创作定稿" : "游玩进度", + })); + json(res, 200, { saves }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "读取存档失败", + }); + } + return true; + } + + if (req.method === "POST") { + const body = JSON.parse(await readBody(req)) as { + label?: string; + note?: string; + sessionId?: string; + /** instance=创作定稿截面;run=游玩进度(默认) */ + kind?: "instance" | "run"; + }; + const sessionId = + body.sessionId?.trim() || + sessionManager.getActiveSessionForBook(bookId)?.id; + if (!sessionId) { + json(res, 400, { error: "当前作品没有活跃会话,无法存档" }); + return true; + } + const kind = body.kind === "instance" ? "instance" : "run"; + try { + const save = + kind === "run" + ? sessionManager.savePlaySnapshot(sessionId, body.label ?? "", body.note) + : sessionManager.saveGameSnapshot( + sessionId, + body.label ?? "", + "instance", + body.note, + ); + json(res, 201, { + save: { + ...save, + kindLabel: save.kind === "instance" ? "创作定稿" : "游玩进度", + }, + }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "存档失败", + }); + } + return true; + } + } + + const saveItemMatch = pathname.match( + /^\/api\/books\/([^/]+)\/saves\/([^/]+)(\/load)?$/, + ); + if (saveItemMatch) { + const bookId = decodeURIComponent(saveItemMatch[1]); + const saveId = decodeURIComponent(saveItemMatch[2]); + const isLoad = saveItemMatch[3] === "/load"; + + if (isLoad && req.method === "POST") { + try { + const session = await sessionManager.loadGameSnapshot(bookId, saveId); + json(res, 200, { session }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "读档失败", + }); + } + return true; + } + + if (req.method === "DELETE") { + try { + sessionManager.deleteGameSnapshot(bookId, saveId); + json(res, 200, { ok: true }); + } catch (err) { + json(res, 404, { + error: err instanceof Error ? err.message : "删除失败", + }); + } + return true; + } + } + + const bookMatch = pathname.match(/^\/api\/books\/([^/]+)(\/open)?$/); + if (bookMatch) { + const bookId = decodeURIComponent(bookMatch[1]); + const isOpen = bookMatch[2] === "/open"; + + if (isOpen && req.method === "POST") { + const book = getBook(bookId); + if (!book) { + json(res, 404, { error: "Book 不存在" }); + return true; + } + const session = await sessionManager.openBook(book.id); + json(res, 200, { book, session }); + return true; + } + + if (req.method === "GET") { + const book = getBook(bookId); + if (!book) { + json(res, 404, { error: "Book 不存在" }); + return true; + } + json(res, 200, { book }); + return true; + } + + if (req.method === "PUT") { + const body = JSON.parse(await readBody(req)) as { title?: string }; + try { + const book = updateBook(bookId, { title: body.title?.trim() }); + json(res, 200, { book }); + } catch (err) { + json(res, 404, { + error: err instanceof Error ? err.message : "更新失败", + }); + } + return true; + } + + if (req.method === "DELETE") { + const book = getBook(bookId); + if (!book) { + json(res, 404, { error: "Book 不存在" }); + return true; + } + sessionManager.dropBookSessions(bookId); + deleteBook(bookId); + json(res, 200, { ok: true }); + return true; + } + } + + return false; +} diff --git a/src/server/display-labels.ts b/src/server/display-labels.ts new file mode 100644 index 0000000..9203d43 --- /dev/null +++ b/src/server/display-labels.ts @@ -0,0 +1,121 @@ +/** + * 用户可见中文标签(与 docs/ui-glossary.md 同步)。 + * 内部 id 不变;仅展示层映射。 + */ + +const STAGE_LABELS: Record = { + design: "创作", + play: "游玩", + done: "已完成", + idle: "待命", + running: "执行中", + waiting_user: "等待你", + error: "出错", +}; + +const WORKER_LABELS: Record = { + "design-core": "创作 · 核心", + "design-worker": "创作 · 演员规格", + "design-fixed": "创作 · 固定上下文", + "design-refine": "创作 · 细化与终稿", + "design-intake": "创作 · 综合收口", + "design-flow": "创作 · 流程编排", + "design-step": "创作 · 执行步骤", + "opening-generator": "开局 · 开场白", + orchestrator: "导演", + "agent-burst": "导演调度", + narrator: "叙事转述", + "role-decide": "角色决策", + "world-simulator": "世界推演", + "round-present": "回合呈现", + outline: "大纲 / 细纲", + "chapter-writer": "章节正文", +}; + +const FIXED_TOPIC_LABELS: Record = { + "aesthetics-interaction": "美学纲领与交互范式", + interaction: "交互范式", + narrative_guide: "叙事指南", + input_protocol: "输入协议", + core_premise: "核心前提", + aesthetics: "美学纲领", +}; + +const PHASE_UNIT_LABELS: Record = { + core: "核心", + refine: "细化", +}; + +const SKILL_PACK_LABELS: Record = { + "world-simulator": "世界模拟器", + "expand-assistant": "扩写助手", +}; + +/** 生命周期 / 相位 */ +export function displayStageLabel(id: string | undefined | null): string { + if (!id) return ""; + return STAGE_LABELS[id] ?? id; +} + +/** 导演选项 / skill pack 展示名 */ +export function displaySkillPackLabel(id: string | undefined | null): string { + if (!id) return ""; + return SKILL_PACK_LABELS[id] ?? id; +} + +/** + * 演员 / 单位 / 能力 id → 用户可见名(不含动作后缀)。 + */ +export function displayWorkerLabel(id: string | undefined | null): string { + if (!id) return ""; + const trimmed = id.trim(); + if (!trimmed) return ""; + if (WORKER_LABELS[trimmed]) return WORKER_LABELS[trimmed]!; + + if (trimmed.startsWith("phase:")) { + const key = trimmed.slice("phase:".length); + const name = PHASE_UNIT_LABELS[key] ?? key; + return `单位 · ${name}`; + } + if (trimmed.startsWith("worker:")) { + const ref = trimmed.slice("worker:".length); + return `演员 · ${displayWorkerLabel(ref)}`; + } + if (trimmed.startsWith("fixed:")) { + const topic = trimmed.slice("fixed:".length); + return `能力 · ${FIXED_TOPIC_LABELS[topic] ?? topic}`; + } + if (trimmed.startsWith("resident:")) { + return `常驻 · ${trimmed.slice("resident:".length)}`; + } + + return trimmed; +} + +export type WorkerTitleAction = "running" | "output" | "questions" | "stub" | null; + +/** 气泡 / 流式标题:中文名 + 可选动作 */ +export function formatWorkerDisplayTitle( + workerId: string | undefined | null, + action: WorkerTitleAction = null, +): string { + const base = displayWorkerLabel(workerId) || "演员"; + switch (action) { + case "output": + return `${base} · 产出`; + case "questions": + return `${base} · 提问`; + case "stub": + return `${base} · 占位`; + case "running": + return `${base} · 执行中`; + default: + return base; + } +} + +/** 导演(调度 Agent)相关标题 */ +export function formatAgentDisplayTitle(detail?: string): string { + if (detail?.trim()) return `导演 · ${detail.trim()}`; + return "导演"; +} diff --git a/src/server/message-branch.ts b/src/server/message-branch.ts new file mode 100644 index 0000000..61fd70a --- /dev/null +++ b/src/server/message-branch.ts @@ -0,0 +1,223 @@ +import { randomUUID } from "node:crypto"; +import type { BlackboardItem } from "../types/blackboard.js"; +import type { RuntimeSession } from "../types/runtime.js"; + +export type BranchableMessage = { + id: string; + role: "system" | "user"; + text: string; + createdAt: string; + kind?: string; + actor?: string; + title?: string; + body?: string; + branchGroupId?: string; + branchIndex?: number; + branchTotal?: number; +}; + +export type SessionCheckpoint = { + runtimeSession: RuntimeSession; + blackboardItems: BlackboardItem[]; +}; + +export type MessageBranchVariant = { + /** 从分支点起的消息链(含分支点消息本身) */ + messages: BranchableMessage[]; + checkpoint: SessionCheckpoint; +}; + +export type MessageBranch = { + anchorIndex: number; + groupId: string; + variants: MessageBranchVariant[]; + activeIndex: number; +}; + +export type MessageBranchState = { + branches: Record; + /** 下标 i = 追加 messages[i] 之前的 runtime 快照 */ + preMessageCheckpoints: Record; +}; + +export function createMessageBranchState(): MessageBranchState { + return { branches: {}, preMessageCheckpoints: {} }; +} + +export function cloneCheckpoint(cp: SessionCheckpoint): SessionCheckpoint { + return { + runtimeSession: structuredClone(cp.runtimeSession), + blackboardItems: structuredClone(cp.blackboardItems), + }; +} + +export function recordPreMessageCheckpoint( + state: MessageBranchState, + index: number, + checkpoint: SessionCheckpoint, +): void { + state.preMessageCheckpoints[index] = cloneCheckpoint(checkpoint); +} + +export function attachBranchMeta(messages: BranchableMessage[], groupId: string, activeIndex: number): void { + const count = messages.filter((m) => m.branchGroupId === groupId).length || 1; + const total = Math.max(count, activeIndex + 1); + for (const m of messages) { + if (m.branchGroupId === groupId && m.branchIndex === activeIndex) { + m.branchTotal = total; + } + } +} + +export function syncBranchTotals( + messages: BranchableMessage[], + branch: MessageBranch, +): void { + const total = branch.variants.length; + const active = branch.activeIndex; + const head = branch.variants[active]?.messages[0]; + if (!head) return; + for (const m of messages) { + if (m.id === head.id || (m.branchGroupId === branch.groupId && m.branchIndex === active)) { + m.branchGroupId = branch.groupId; + m.branchIndex = active; + m.branchTotal = total; + } + } +} + +function cloneMessages(msgs: BranchableMessage[]): BranchableMessage[] { + return msgs.map((m) => ({ ...m })); +} + +export function ensureBranchForEdit( + state: MessageBranchState, + messages: BranchableMessage[], + messageIndex: number, + checkpoint: SessionCheckpoint, +): MessageBranch { + const msg = messages[messageIndex]; + const groupId = msg.branchGroupId ?? msg.id; + let branch = state.branches[groupId]; + if (!branch) { + branch = { + anchorIndex: messageIndex, + groupId, + activeIndex: 0, + variants: [ + { + messages: cloneMessages(messages.slice(messageIndex)), + checkpoint: cloneCheckpoint(checkpoint), + }, + ], + }; + state.branches[groupId] = branch; + for (const m of messages.slice(messageIndex)) { + m.branchGroupId = groupId; + m.branchIndex = 0; + m.branchTotal = 1; + } + } + return branch; +} + +export function ensureBranchForRefresh( + state: MessageBranchState, + messages: BranchableMessage[], + messageIndex: number, + checkpoint: SessionCheckpoint, +): MessageBranch { + const msg = messages[messageIndex]; + const groupId = msg.branchGroupId ?? msg.id; + let branch = state.branches[groupId]; + if (!branch) { + branch = { + anchorIndex: messageIndex, + groupId, + activeIndex: 0, + variants: [ + { + messages: cloneMessages(messages.slice(messageIndex)), + checkpoint: cloneCheckpoint(checkpoint), + }, + ], + }; + state.branches[groupId] = branch; + for (const m of messages.slice(messageIndex)) { + m.branchGroupId = groupId; + m.branchIndex = 0; + m.branchTotal = 1; + } + } + return branch; +} + +export function appendBranchVariant( + branch: MessageBranch, + headMessage: BranchableMessage, + checkpoint: SessionCheckpoint, +): number { + const index = branch.variants.length; + branch.variants.push({ + messages: [{ ...headMessage, branchGroupId: branch.groupId, branchIndex: index }], + checkpoint: cloneCheckpoint(checkpoint), + }); + branch.activeIndex = index; + return index; +} + +export function updateActiveBranchVariant( + branch: MessageBranch, + tailMessages: BranchableMessage[], + checkpoint: SessionCheckpoint, +): void { + const variant = branch.variants[branch.activeIndex]; + if (!variant) return; + variant.messages = cloneMessages(tailMessages); + variant.checkpoint = cloneCheckpoint(checkpoint); +} + +export function switchBranchVariant( + state: MessageBranchState, + messages: BranchableMessage[], + groupId: string, + delta: -1 | 1, +): { messages: BranchableMessage[]; checkpoint: SessionCheckpoint } | null { + const branch = state.branches[groupId]; + if (!branch) return null; + const next = branch.activeIndex + delta; + if (next < 0 || next >= branch.variants.length) return null; + branch.activeIndex = next; + const variant = branch.variants[next]; + const prefix = messages.slice(0, branch.anchorIndex); + const merged = [...prefix, ...cloneMessages(variant.messages)]; + syncBranchTotals(merged, branch); + return { messages: merged, checkpoint: cloneCheckpoint(variant.checkpoint) }; +} + +export function createUserVariantMessage(text: string, groupId: string, branchIndex: number): BranchableMessage { + return { + id: randomUUID(), + role: "user", + text, + createdAt: new Date().toISOString(), + kind: "user_input", + title: "你的输入", + body: text, + branchGroupId: groupId, + branchIndex, + }; +} + +export function isRefreshableMessage(msg: BranchableMessage): boolean { + if (msg.role === "user") return false; + const kind = msg.kind ?? "system_info"; + return kind === "worker_questions" || kind === "worker_output"; +} + +export function findPrecedingUserIndex(messages: BranchableMessage[], fromIndex: number): number { + for (let i = fromIndex - 1; i >= 0; i--) { + if (messages[i].role === "user") return i; + } + return -1; +} diff --git a/src/server/prune-creation-messages.ts b/src/server/prune-creation-messages.ts new file mode 100644 index 0000000..57387f8 --- /dev/null +++ b/src/server/prune-creation-messages.ts @@ -0,0 +1,162 @@ +/** + * 创作过程对话策略(与黑板固定上下文正交): + * - session.messages:全量保留,供用户浏览(编辑分支只暴露当前认可版本) + * - 拼给 AI 的「创作.对话」:用户全量 + AI 永远只留最后一次(含未完成提问) + * + * 「隐藏 AI 历史」只作用于拼接,不删、不藏用户可见消息。 + */ + +export type DialogueChatMessage = { + id: string; + role: "system" | "user"; + text: string; + createdAt?: string; + kind?: string; + actor?: string; + title?: string; + body?: string; + compressed?: boolean; +}; + +/** @deprecated 用 DialogueChatMessage */ +export type PrunableChatMessage = DialogueChatMessage; + +/** 写入黑板、注入 design worker 的对话 transcript tag */ +export const CREATION_DIALOGUE_TAG = "创作.对话"; + +const AI_CONTENT_KINDS = new Set([ + "worker_output", + "worker_questions", + "orchestrator_thinking", + "orchestrator_decision", +]); + +const AI_EPHEMERAL_KINDS = new Set(["worker_running", "worker_stub", "agent_tool"]); + +function isAiContent(m: DialogueChatMessage): boolean { + const kind = m.kind ?? ""; + if (AI_CONTENT_KINDS.has(kind)) return true; + if (m.role === "system" && kind.startsWith("worker_")) return true; + return false; +} + +function isAiEphemeral(m: DialogueChatMessage): boolean { + return AI_EPHEMERAL_KINDS.has(m.kind ?? ""); +} + +function isUserMessage(m: DialogueChatMessage): boolean { + return m.role === "user" || m.kind === "user_input"; +} + +/** + * 从全量 messages 选出「拼给 AI」的视图:全部用户 + 最后一次 AI 内容。 + * 不修改原数组。 + */ +export function selectCreationDialogueForAi( + messages: readonly T[], +): T[] { + let lastAiIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (isAiContent(messages[i]!)) { + lastAiIdx = i; + break; + } + } + + const out: T[] = []; + for (let i = 0; i < messages.length; i++) { + const m = messages[i]!; + if (m.compressed) continue; + if (isUserMessage(m)) { + out.push(m); + continue; + } + if (isAiEphemeral(m)) continue; + if (isAiContent(m) && i === lastAiIdx) out.push(m); + } + return out; +} + +/** + * @deprecated 勿再改写 session.messages;改用 selectCreationDialogueForAi / buildCreationDialogueTranscript。 + * 保留空操作兼容旧调用点。 + */ +export function trimCreationDialogueMessages( + _messages: T[], +): { removedCount: number } { + return { removedCount: 0 }; +} + +function displayText(m: DialogueChatMessage): string { + const body = (m.body ?? m.text ?? "").trim(); + return body || "(空)"; +} + +/** 拼给 design worker 的对话前情(用户全量 + 最后一次 AI;不改 messages) */ +export function buildCreationDialogueTranscript( + messages: readonly DialogueChatMessage[], +): string { + const selected = selectCreationDialogueForAi(messages); + const lines: string[] = [ + "以下为创作过程对话(用户发言全部保留;AI 仅保留最后一次输出,含未完成提问)。", + "", + ]; + for (const m of selected) { + if (isUserMessage(m)) { + lines.push(`### 用户`); + lines.push(displayText(m)); + lines.push(""); + continue; + } + if (isAiContent(m)) { + const who = + m.kind === "worker_questions" + ? `AI · ${m.actor ?? "worker"} 提问` + : m.kind === "worker_output" + ? `AI · ${m.actor ?? "worker"} 产出` + : m.kind === "orchestrator_thinking" + ? "AI · 总管思考" + : `AI · ${m.title ?? m.kind ?? "系统"}`; + lines.push(`### ${who}`); + lines.push(displayText(m)); + lines.push(""); + } + } + const text = lines.join("\n").trim(); + return text || "(尚无创作对话)"; +} + +/** + * @deprecated 创作验收后不再删 messages(用户可继续浏览)。保留空操作兼容。 + */ +export function pruneCreationUnitMessages( + _messages: T[], + _workerId: string, + _options: { afterCreatedAt?: string | null } = {}, +): { removedCount: number; productKept: boolean } { + return { removedCount: 0, productKept: false }; +} + +/** Run 验收:过程消息标 compressed(主 feed 隐藏),不删除。 */ +export function foldRunProcessMessages( + messages: T[], + workerId: string, +): void { + for (const m of messages) { + if (m.compressed) continue; + if ( + (m.kind === "worker_questions" || m.kind === "worker_running") && + (m.actor === workerId || (m.text ?? "").includes(workerId)) + ) { + m.compressed = true; + continue; + } + if ( + m.kind === "worker_output" && + m.actor === workerId && + !String(m.text ?? "").includes("[上下文已压缩]") + ) { + m.compressed = true; + } + } +} diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index 72676e4..4fe0adf 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -50,7 +50,72 @@ import { sessionSkillPackId, skillPacksMatch, } from "../book/skill-id.js"; +import { + loadAppSettings, + normalizeContextTraceKeepLatest, +} from "../config/settings.js"; +import { + pruneContextTraces, + type LlmContextTrace, +} from "../types/context-trace.js"; import type { ActiveSkillSnapshot } from "../types/runtime.js"; +import type { BlackboardItem } from "../types/blackboard.js"; +import { Blackboard } from "../blackboard/blackboard.js"; +import { parseWorkerSetYaml } from "../skills/worker-set-parse.js"; +import { parseAcceptedUnits, isDesignDiskWorker } from "../skills/creation-units.js"; +import { + formatWorkerSetForUser, + type WorkerSetUserView, +} from "../skills/worker-set-view.js"; +import { + CREATION_FLOW_TAG, + CREATION_SELECTED_RECIPE_TAG, + findRecipeCatalogEntry, + formatCreationFlowForUser, + loadRecipeCatalog, + parseCreationFlow, + parseSelectedRecipeRef, + type CreationFlowUserView, + type RecipeCatalogEntry, +} from "../skills/creation-flow.js"; +import { loadSkill } from "../skills/loader.js"; +import { effectiveStartupMode, DEFAULT_UI_PROMPT } from "../config/default-orchestrator.js"; +import { buildBoardPanel } from "../runtime/compress-after-worker.js"; +import { + displayWorkerLabel, + formatWorkerDisplayTitle, +} from "./display-labels.js"; +import { + buildCreationDialogueTranscript, + foldRunProcessMessages, +} from "./prune-creation-messages.js"; +import { + formatQuestionAnswersForAi, + formatQuestionAnswersForDisplay, + normalizeQuestions, +} from "../skills/question-protocol.js"; +import type { QuestionAnswer } from "../types/questions.js"; +import { + appendBranchVariant, + createMessageBranchState, + createUserVariantMessage, + ensureBranchForEdit, + ensureBranchForRefresh, + isRefreshableMessage, + recordPreMessageCheckpoint, + switchBranchVariant, + updateActiveBranchVariant, + type MessageBranchState, + type SessionCheckpoint, +} from "./message-branch.js"; + +/** 这些消息种类挂载全量 LLM 上下文痕迹(右键可查看) */ +const CONTEXT_TRACE_MESSAGE_KINDS = new Set([ + "worker_output", + "orchestrator_decision", + "orchestrator_assessment", + "orchestrator_thinking", +]); export type ChatMessage = { id: string; @@ -61,9 +126,17 @@ export type ChatMessage = { actor?: string; title?: string; body?: string; - /** LLM 思维链 / reasoning_content */ + /** 思维链 / reasoning_content */ thinking?: string; + /** 该次 LLM 请求实际携带的全量上下文(观察用;按设置只保留最新 N 条) */ + contextTrace?: LlmContextTrace; + /** run 验收后过程讨论折叠(主 feed 隐藏);创作 messages 不删不藏 */ + compressed?: boolean; tokenUsage?: MessageTokenUsage; + /** 消息分支:同位多版本(编辑 / 重 roll) */ + branchGroupId?: string; + branchIndex?: number; + branchTotal?: number; }; export type SessionView = { @@ -89,6 +162,24 @@ export type SessionView = { /** 启动填空进度(waitingReason.kind === intake 时有值) */ intake?: IntakeProgress; intakePrompt?: string; + /** agent-first 首屏固定引导(纯 UI,非消息流) */ + uiPrompt?: string; + /** 待验收产物全文(review_artifact 时在右侧 Tab 展示) */ + reviewArtifact?: ReviewArtifactView; + /** Worker 集用户视图(验收 / 设计进度共用) */ + workerSetView?: WorkerSetUserView; + /** 创作流程用户视图(步骤顺序 + 依赖) */ + creationFlowView?: CreationFlowUserView; + /** 能力包内可选初始配方(用户手动选) */ + recipes?: Array<{ id: string; name: string; declaration: string }>; + /** 用户已选初始配方 */ + selectedRecipe?: { id: string; name: string; declaration: string } | null; + /** 黑板与定稿上下文(强可读) */ + boardPanel?: BoardPanelView; + /** 总管正在输出的思维链(流式,轮询用) */ + agentThinking?: string; + /** Agent / Worker 流式输出(轮询用) */ + liveStream?: LiveStreamView; resumed?: boolean; tokenStats?: { sessionTotal: number; @@ -105,6 +196,8 @@ export type SessionView = { export type SessionAction = | { type: "send_message"; label: string; placeholder: string } + | { type: "answer_questions"; label: string } + | { type: "skip_questions"; label: string } | { type: "confirm_intake"; label: string } | { type: "approve"; label: string } | { type: "accept"; label: string } @@ -112,17 +205,59 @@ export type SessionAction = | { type: "run_outline"; label: string } | { type: "finish"; label: string }; +export type LiveStreamView = { + actor: "orchestrator" | "worker"; + actorId?: string; + label: string; + thinking?: string; + output?: string; +}; + +export type BoardPanelView = { + finals: Array<{ tag: string; source: string; preview: string; updatedAt: string }>; + active: Array<{ tag: string; source: string; preview: string; updatedAt: string }>; + archivedCount: number; + brief?: string; +}; + +export type ReviewArtifactView = { + id: string; + workerId: string; + summary?: string; + body: string; + /** 产出该产物的 worker_output 消息(验收态下 feed 会隐藏该消息) */ + sourceMessageId?: string; + /** 与 sourceMessage 同源的全量 LLM 上下文,供待验收卡片右键查看 */ + contextTrace?: LlmContextTrace; + /** 创作 design-* 验收时解析后的 Worker 集(供 UI 结构化展示) */ + workerSet?: import("../skills/worker-set-parse.js").ParsedWorkerSet; + /** 中文卡片化用户视图 */ + workerSetView?: WorkerSetUserView; + /** 创作流程(步骤 + 依赖) */ + creationFlowView?: CreationFlowUserView; +}; + type ManagedSession = { runtime: PhaseRuntime; messages: ChatMessage[]; bookId?: string; trackingRef: LlmTrackingRef; + branchState: MessageBranchState; + /** 当前能力包可选初始配方(用户手动选) */ + recipeOptions?: RecipeCatalogEntry[]; }; export class SessionManager { private readonly sessions = new Map(); /** bookId → 当前内存中的 sessionId */ private readonly activeBookSessions = new Map(); + /** sessionId → 流式 thinking 缓冲 */ + private readonly agentThinkingLive = new Map(); + /** sessionId → worker 流式缓冲 */ + private readonly workerLive = new Map< + string, + { workerId: string; thinking: string; output: string } + >(); async create(): Promise { return this.createForBook(); @@ -139,6 +274,24 @@ export class SessionManager { return this.toView(sessionId); } + /** 用户手改黑板 tag(创作期改设定/表;尊重后续 worker 的表 rev 合并) */ + writeUserBoardTag( + sessionId: string, + tag: string, + content: string, + ): SessionView { + const s = this.require(sessionId); + const trimmedTag = tag.trim(); + if (!trimmedTag) throw new Error("tag 不能为空"); + s.runtime.getBlackboard().write({ + tag: trimmedTag, + content: content ?? "", + source: "user", + }); + if (s.bookId) this.persist(s); + return this.toView(sessionId); + } + /** * 打开 Book:优先恢复磁盘快照;无快照则新建 Session。 * 若该 Book 已在内存中,直接返回当前视图。 @@ -162,6 +315,7 @@ export class SessionManager { async createForBook( bookId?: string, preselectSkillId?: string, + selectedRecipeId?: string, ): Promise { const id = randomUUID(); const messages: ChatMessage[] = []; @@ -182,14 +336,17 @@ export class SessionManager { messages, bookId, trackingRef, + branchState: createMessageBranchState(), }; const onMessage = this.buildOnMessageHandler(() => managed); + const thinkingHandlers = this.buildStreamHandlers(id, () => managed); const llm = createDefaultMainAgentLlm(trackingRef); const runtime = new PhaseRuntime({ autoStubWorker: !hasRealLlmConfig(), llm, onMessage, + ...thinkingHandlers, initialSession, }); managed.runtime = runtime; @@ -199,6 +356,17 @@ export class SessionManager { } else { await runtime.start(); } + await this.refreshRecipeOptions(managed); + if (selectedRecipeId?.trim()) { + await this.applySelectedRecipe(managed, selectedRecipeId.trim()); + } + await this.maybeKickAgentAfterDemand(runtime); + + recordPreMessageCheckpoint( + managed.branchState, + 0, + this.captureCheckpoint(managed), + ); this.sessions.set(id, managed); if (bookId) { @@ -208,6 +376,18 @@ export class SessionManager { return this.toView(id); } + /** 用户手动选定 / 更换初始配方 */ + async setSelectedRecipe( + sessionId: string, + recipeId: string, + ): Promise { + const s = this.require(sessionId); + await this.refreshRecipeOptions(s); + await this.applySelectedRecipe(s, recipeId); + if (s.bookId) this.persist(s); + return this.toView(sessionId); + } + get(id: string): SessionView | null { if (!this.sessions.has(id)) return null; return this.toView(id); @@ -242,8 +422,8 @@ export class SessionManager { let blackboardItems = s.runtime.getBlackboard().exportItems(); if (kind === "instance") { - if (!runtimeSession.slots.startupCompleted) { - throw new Error("实例尚未完成(需先完成启动与 setup 验收),无法保存实例快照"); + if (!canEnterPlay(runtimeSession) && !runtimeSession.slots.startupCompleted) { + throw new Error("须先验收 Worker 集,才能保存创作定稿"); } ({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({ runtimeSession, @@ -276,6 +456,60 @@ export class SessionManager { return listRunSnapshots(bookId); } + /** 仅游玩 run 存档(UI 抽屉用) */ + listPlaySnapshots(bookId: string): RunSnapshotMeta[] { + return this.listGameSnapshots(bookId).filter((s) => s.kind === "run"); + } + + /** 保存游玩进度(创作靠 session.json 自动续作,不手动存 instance) */ + savePlaySnapshot( + sessionId: string, + label: string, + note?: string, + ): RunSnapshotMeta { + const s = this.require(sessionId); + const session = s.runtime.getSession(); + if (!canEnterPlay(session)) { + throw new Error("须先验收 Worker 集,才能保存游玩存档"); + } + if (inferLifecycleStage(session) !== "play") { + throw new Error("请先切换到「游玩」再保存游玩存档"); + } + return this.saveGameSnapshot(sessionId, label, "run", note); + } + + /** 从已验收实例开一条新的游玩线(清空 run 层状态) */ + async startNewPlayRun(sessionId: string): Promise { + const s = this.require(sessionId); + if (!s.bookId) throw new Error("仅绑定作品时可新建游玩"); + const session = s.runtime.getSession(); + if (!canEnterPlay(session)) { + throw new Error("须先验收 Worker 集,才能开始游玩"); + } + + let runtimeSession = structuredClone(session); + let blackboardItems = s.runtime.getBlackboard().exportItems(); + ({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({ + runtimeSession, + blackboardItems, + })); + runtimeSession.slots = { + ...runtimeSession.slots, + uiLifecycleStage: "play", + startupCompleted: true, + }; + runtimeSession.phase = "waiting_user"; + runtimeSession.waitingReason = { kind: "input" }; + runtimeSession.pendingArtifactId = undefined; + runtimeSession.pendingDecision = undefined; + runtimeSession.currentWorkerId = undefined; + runtimeSession.resumeContext = undefined; + + s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems); + this.persist(s); + return this.toView(sessionId); + } + /** 从存档读档:替换当前作品进度,可继续创作 */ async loadGameSnapshot(bookId: string, snapshotId: string): Promise { const snapshot = loadRunSnapshotFile(bookId, snapshotId); @@ -299,6 +533,11 @@ export class SessionManager { runtimeSession, blackboardItems, })); + } else { + runtimeSession.slots = { + ...runtimeSession.slots, + uiLifecycleStage: "play", + }; } runtimeSession.id = newSessionId; @@ -350,7 +589,14 @@ export class SessionManager { async sendMessage(id: string, text: string): Promise { const s = this.require(id); + this.clearAgentThinking(id); + recordPreMessageCheckpoint( + s.branchState, + s.messages.length, + this.captureCheckpoint(s), + ); s.messages.push(this.msg("user", text)); + this.syncCreationDialogue(s); if (s.bookId) this.syncBookPreview(s.bookId, s.messages); const reason = s.runtime.getSession().waitingReason; try { @@ -366,7 +612,259 @@ export class SessionManager { console.error("[session] sendMessage failed:", detail); s.messages.push(this.msg("system", formatRuntimeError(detail))); if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + } finally { + this.clearAgentThinking(id); + this.syncCreationDialogue(s); } + this.persist(s); + return this.toView(id); + } + + /** + * 结构化追问作答:UI 可省略结构,发给 AI 必须含问+答。 + * displayText 进气泡;aiText 进 runtime / 黑板。 + * review_artifact 挂载题:作答后仍停留在验收态(可再 Accept)。 + */ + async answerQuestions( + id: string, + answers: QuestionAnswer[], + note?: string, + ): Promise { + const s = this.require(id); + const reason = s.runtime.getSession().waitingReason; + const isSidecar = + reason?.kind === "review_artifact" && Boolean(reason.questions?.length); + const questions = + reason?.kind === "worker_questions" + ? reason.questions + : reason?.kind === "input" + ? (reason.questions ?? []) + : reason?.kind === "review_artifact" + ? (reason.questions ?? []) + : []; + if (!questions.length) { + throw new Error("当前没有待回答的结构化追问"); + } + const aiText = formatQuestionAnswersForAi(questions, answers, note); + const displayText = formatQuestionAnswersForDisplay(questions, answers, note); + + this.clearAgentThinking(id); + recordPreMessageCheckpoint( + s.branchState, + s.messages.length, + this.captureCheckpoint(s), + ); + const userMsg = this.msg("user", aiText); + userMsg.body = displayText; + s.messages.push(userMsg); + this.syncCreationDialogue(s); + if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + + try { + if (isSidecar) { + await s.runtime.resolveSidecarQuestions(aiText); + } else { + await s.runtime.submitInput(aiText); + } + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + console.error("[session] answerQuestions failed:", detail); + s.messages.push(this.msg("system", formatRuntimeError(detail))); + if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + } finally { + this.clearAgentThinking(id); + this.syncCreationDialogue(s); + } + this.persist(s); + return this.toView(id); + } + + /** + * 跳过追问: + * - review_artifact 挂载题:只收起题,留在验收 + * - input / worker_questions:提交跳过句并继续 + */ + async skipQuestions(id: string): Promise { + const s = this.require(id); + const reason = s.runtime.getSession().waitingReason; + const skipText = "(用户跳过追问)请基于已有信息继续。"; + + if (reason?.kind === "review_artifact" && reason.questions?.length) { + this.clearAgentThinking(id); + s.messages.push(this.msg("user", "跳过可选追问")); + try { + await s.runtime.resolveSidecarQuestions(); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + s.messages.push(this.msg("system", formatRuntimeError(detail))); + } + this.syncCreationDialogue(s); + this.persist(s); + return this.toView(id); + } + + if ( + (reason?.kind === "input" && reason.questions?.length) || + reason?.kind === "worker_questions" + ) { + return this.sendMessage(id, skipText); + } + + throw new Error("当前没有可跳过的追问"); + } + + /** 编辑用户消息 → 新分支 + 从该点重跑 */ + async editMessage(id: string, messageId: string, newText: string): Promise { + const s = this.require(id); + const trimmed = newText.trim(); + if (!trimmed) throw new Error("内容不能为空"); + + const idx = s.messages.findIndex((m) => m.id === messageId); + if (idx < 0) throw new Error("消息不存在"); + const target = s.messages[idx]; + if (target.role !== "user") throw new Error("只能编辑用户消息"); + + const checkpoint = + s.branchState.preMessageCheckpoints[idx] ?? this.captureCheckpoint(s); + const branch = ensureBranchForEdit(s.branchState, s.messages, idx, checkpoint); + + s.runtime.restoreFromCheckpoint(checkpoint.runtimeSession, checkpoint.blackboardItems); + s.messages = s.messages.slice(0, idx); + + const variantIndex = branch.variants.length; + const userMsg = createUserVariantMessage(trimmed, branch.groupId, variantIndex) as ChatMessage; + appendBranchVariant(branch, userMsg, checkpoint); + s.messages.push(userMsg); + this.syncCreationDialogue(s); + if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + + try { + await s.runtime.submitInput(trimmed); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + s.messages.push(this.msg("system", formatRuntimeError(detail))); + } + + updateActiveBranchVariant( + branch, + s.messages.slice(branch.anchorIndex), + this.captureCheckpoint(s), + ); + for (const m of s.messages.slice(branch.anchorIndex)) { + m.branchGroupId = branch.groupId; + m.branchIndex = branch.activeIndex; + m.branchTotal = branch.variants.length; + } + + this.syncCreationDialogue(s); + this.persist(s); + return this.toView(id); + } + + /** 刷新 Worker / Skill 回复 → 重 roll */ + async refreshMessage(id: string, messageId: string): Promise { + const s = this.require(id); + const idx = s.messages.findIndex((m) => m.id === messageId); + if (idx < 0) throw new Error("消息不存在"); + const target = s.messages[idx]; + if (!isRefreshableMessage(target)) { + throw new Error("此消息不支持刷新"); + } + + const workerSnap = s.runtime.getLastWorkerRunSnapshot(); + const workerId = target.actor ?? workerSnap?.workerId; + if (!workerId) throw new Error("无法识别要刷新的 Worker"); + + const checkpoint = workerSnap ?? s.branchState.preMessageCheckpoints[idx]; + if (!checkpoint) { + throw new Error("缺少重 roll 快照,请尝试编辑上一条用户消息"); + } + + const branch = ensureBranchForRefresh( + s.branchState, + s.messages, + idx, + { + runtimeSession: checkpoint.runtimeSession, + blackboardItems: checkpoint.blackboardItems, + }, + ); + + s.runtime.restoreFromCheckpoint(checkpoint.runtimeSession, checkpoint.blackboardItems); + s.messages = s.messages.slice(0, idx); + if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + + try { + await s.runtime.rerunWorker(workerId); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + s.messages.push(this.msg("system", formatRuntimeError(detail))); + } + + const newIndex = branch.variants.length; + branch.variants.push({ + messages: structuredClone(s.messages.slice(idx)), + checkpoint: this.captureCheckpoint(s), + }); + branch.activeIndex = newIndex; + + for (const m of s.messages.slice(branch.anchorIndex)) { + m.branchGroupId = branch.groupId; + m.branchIndex = branch.activeIndex; + m.branchTotal = branch.variants.length; + } + + this.persist(s); + return this.toView(id); + } + + /** 删除消息及其后的对话 */ + async deleteMessage(id: string, messageId: string): Promise { + const s = this.require(id); + const idx = s.messages.findIndex((m) => m.id === messageId); + if (idx < 0) throw new Error("消息不存在"); + + const checkpoint = s.branchState.preMessageCheckpoints[idx]; + if (!checkpoint && idx > 0) throw new Error("无法删除此消息"); + + if (checkpoint) { + s.runtime.restoreFromCheckpoint( + checkpoint.runtimeSession, + checkpoint.blackboardItems, + ); + } + s.messages = s.messages.slice(0, idx); + for (const key of Object.keys(s.branchState.preMessageCheckpoints)) { + const n = Number(key); + if (n >= idx) delete s.branchState.preMessageCheckpoints[n]; + } + if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + this.persist(s); + return this.toView(id); + } + + /** 左右切换同位消息版本(swipe / branch) */ + async switchMessageVariant( + id: string, + messageId: string, + direction: "prev" | "next", + ): Promise { + const s = this.require(id); + const idx = s.messages.findIndex((m) => m.id === messageId); + if (idx < 0) throw new Error("消息不存在"); + const target = s.messages[idx]; + const groupId = target.branchGroupId ?? target.id; + const delta = direction === "prev" ? -1 : 1; + const switched = switchBranchVariant(s.branchState, s.messages, groupId, delta); + if (!switched) throw new Error("没有更多版本"); + + s.messages = switched.messages as ChatMessage[]; + s.runtime.restoreFromCheckpoint( + switched.checkpoint.runtimeSession, + switched.checkpoint.blackboardItems, + ); + if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + this.persist(s); return this.toView(id); } @@ -397,7 +895,30 @@ export class SessionManager { async accept(id: string): Promise { const s = this.require(id); + const pendingId = s.runtime.getSession().pendingArtifactId; + const waiting = s.runtime.getSession().waitingReason; + const hadSidecarQuestions = + waiting?.kind === "review_artifact" && Boolean(waiting.questions?.length); + const artifact = pendingId + ? s.runtime.getSession().artifacts.find((a) => a.id === pendingId) + : undefined; + const stageBefore = inferLifecycleStage(s.runtime.getSession()); await s.runtime.acceptArtifact(); + // 接受产物 = 不再用追问完善;询问模块随 waitingReason 清除而收起 + if (hadSidecarQuestions) { + s.messages.push( + this.msg("system", "[验收] 已接受产物,可选追问已收起(无需再完善)。"), + ); + } + if (artifact) { + if (stageBefore === "design") { + // 创作验收:messages 全量保留供用户浏览;仅同步拼给 AI 的「创作.对话」 + this.syncCreationDialogue(s); + } else { + foldRunProcessMessages(s.messages, artifact.workerId); + } + } + this.persist(s); return this.toView(id); } @@ -457,6 +978,7 @@ export class SessionManager { runtimeSession: snapshot.runtimeSession, blackboardItems: snapshot.blackboardItems, messages: snapshot.messages.map((m) => ({ ...m })) as ChatMessage[], + messageBranchState: snapshot.messageBranchState, resumeHint: "已从上次进度恢复,可继续创作。", }); } @@ -468,6 +990,7 @@ export class SessionManager { runtimeSession: RuntimeSession; blackboardItems: import("../types/blackboard.js").BlackboardItem[]; messages: ChatMessage[]; + messageBranchState?: import("../types/book-session.js").PersistedMessageBranchState; resumeHint: string; }): Promise { const book = getBook(params.bookId); @@ -482,39 +1005,109 @@ export class SessionManager { let managedRef: ManagedSession | null = null; const onMessage = this.buildOnMessageHandler(() => managedRef); + const thinkingHandlers = this.buildStreamHandlers( + params.sessionId, + () => managedRef, + ); const runtime = new PhaseRuntime({ autoStubWorker: !hasRealLlmConfig(), llm: createDefaultMainAgentLlm(trackingRef), onMessage, + ...thinkingHandlers, initialSession: params.runtimeSession, initialBlackboardItems: params.blackboardItems, }); await runtime.ensureAvailableSkills(); + const keep = normalizeContextTraceKeepLatest( + loadAppSettings().contextTraceKeepLatest, + ); managedRef = { runtime, - messages: params.messages, + messages: pruneContextTraces(params.messages, keep), bookId: params.bookId, trackingRef, + branchState: this.deserializeBranchState(params.messageBranchState), }; this.sessions.set(params.sessionId, managedRef); this.activeBookSessions.set(params.bookId, params.sessionId); appendBookSession(params.bookId, params.sessionId); + await this.refreshRecipeOptions(managedRef); this.persist(managedRef); + await this.maybeKickAgentAfterDemand(runtime); return this.toView(params.sessionId, true, params.resumeHint); } + /** 旧会话:用户已写需求但 Agent 未跑过 → 补 invoke 总管 */ + private async maybeKickAgentAfterDemand(runtime: PhaseRuntime): Promise { + const session = runtime.getSession(); + const active = session.slots.activeSkill as ActiveSkillSnapshot | undefined; + if (!active || effectiveStartupMode(active) !== "agent-first") return; + if (session.slots.startupCompleted) return; + + const demandKey = active.startupTargetKey || "用户.需求"; + const text = String( + session.slots[demandKey] ?? session.slots.lastUserInput ?? "", + ).trim(); + if (!text) return; + + const reason = session.waitingReason?.kind; + if (reason !== "input" && reason !== "intake") return; + + try { + await runtime.dispatch({ + type: "user_submitted_input", + payload: { text }, + }); + } catch (err) { + console.error("[session] maybeKickAgentAfterDemand failed:", err); + } + } + private buildOnMessageHandler(getManaged: () => ManagedSession | null) { return (text: string) => { const managed = getManaged(); if (!managed) return; + recordPreMessageCheckpoint( + managed.branchState, + managed.messages.length, + this.captureCheckpoint(managed), + ); const usage = managed.trackingRef.current.pendingUsage; const thinking = managed.trackingRef.current.pendingReasoning; managed.trackingRef.current.pendingUsage = undefined; managed.trackingRef.current.pendingReasoning = undefined; - managed.messages.push(this.msg("system", text, usage, thinking)); + + const classified = + text.trim().length > 0 ? classifyAgentMessage(text) : null; + const pendingTrace = managed.trackingRef.current.pendingContextTrace; + let contextTrace: LlmContextTrace | undefined; + if ( + pendingTrace && + classified && + CONTEXT_TRACE_MESSAGE_KINDS.has(classified.kind) + ) { + const keep = normalizeContextTraceKeepLatest( + loadAppSettings().contextTraceKeepLatest, + ); + if (keep > 0) { + contextTrace = pendingTrace; + } + managed.trackingRef.current.pendingContextTrace = undefined; + } + + managed.messages.push( + this.msg("system", text, usage, thinking, contextTrace), + ); + managed.messages = pruneContextTraces( + managed.messages, + normalizeContextTraceKeepLatest( + loadAppSettings().contextTraceKeepLatest, + ), + ); + this.syncCreationDialogue(managed); if (managed.bookId) { this.syncBookPreview(managed.bookId, managed.messages); this.persist(managed); @@ -522,6 +1115,17 @@ export class SessionManager { }; } + /** 创作模式:从全量 messages 拼「创作.对话」(AI 只留最后一次),不改用户可见消息 */ + private syncCreationDialogue(s: ManagedSession): void { + try { + if (inferLifecycleStage(s.runtime.getSession()) !== "design") return; + const transcript = buildCreationDialogueTranscript(s.messages); + s.runtime.writeCreationDialogue(transcript); + } catch (err) { + console.warn("[session] syncCreationDialogue:", err); + } + } + private persist(s: ManagedSession): void { if (!s.bookId) return; const book = getBook(s.bookId); @@ -538,6 +1142,7 @@ export class SessionManager { runtimeSession: session, blackboardItems: s.runtime.getBlackboard().exportItems(), messages: s.messages, + messageBranchState: this.serializeBranchState(s.branchState), savedAt: new Date().toISOString(), }; saveBookSession(snapshot); @@ -575,6 +1180,84 @@ export class SessionManager { return "等待开始…"; } + private clearAgentThinking(sessionId: string): void { + this.agentThinkingLive.delete(sessionId); + this.workerLive.delete(sessionId); + } + + private buildStreamHandlers( + sessionId: string, + _getManaged: () => ManagedSession | null, + ): Pick< + import("../runtime/phase-runtime.js").PhaseRuntimeOptions, + | "onAgentThinkingDelta" + | "onAgentThinkingDone" + | "onWorkerStreamStart" + | "onWorkerThinkingDelta" + | "onWorkerOutputDelta" + | "onWorkerStreamDone" + > { + return { + onAgentThinkingDelta: (delta) => { + const prev = this.agentThinkingLive.get(sessionId) ?? ""; + this.agentThinkingLive.set(sessionId, prev + delta); + }, + onAgentThinkingDone: () => { + this.agentThinkingLive.delete(sessionId); + }, + onWorkerStreamStart: (workerId) => { + this.workerLive.set(sessionId, { workerId, thinking: "", output: "" }); + }, + onWorkerThinkingDelta: (workerId, delta) => { + const prev = + this.workerLive.get(sessionId) ?? { + workerId, + thinking: "", + output: "", + }; + prev.workerId = workerId; + prev.thinking += delta; + this.workerLive.set(sessionId, prev); + }, + onWorkerOutputDelta: (workerId, delta) => { + const prev = + this.workerLive.get(sessionId) ?? { + workerId, + thinking: "", + output: "", + }; + prev.workerId = workerId; + prev.output += delta; + this.workerLive.set(sessionId, prev); + }, + onWorkerStreamDone: () => { + this.workerLive.delete(sessionId); + }, + }; + } + + private buildLiveStreamView(sessionId: string): LiveStreamView | undefined { + const worker = this.workerLive.get(sessionId); + if (worker && (worker.thinking || worker.output)) { + return { + actor: "worker", + actorId: worker.workerId, + label: formatWorkerDisplayTitle(worker.workerId, "running"), + thinking: worker.thinking || undefined, + output: worker.output || undefined, + }; + } + const agentThinking = this.agentThinkingLive.get(sessionId); + if (agentThinking) { + return { + actor: "orchestrator", + label: "总管 · 思考", + thinking: agentThinking, + }; + } + return undefined; + } + private require(id: string): ManagedSession { const s = this.sessions.get(id); if (!s) throw new Error("会话不存在"); @@ -586,6 +1269,15 @@ export class SessionManager { messages: ChatMessage[], ): ChatMessage[] { const reason = session.waitingReason; + const activeSkill = session.slots.activeSkill as ActiveSkillSnapshot | undefined; + if ( + !session.slots.startupCompleted && + activeSkill && + effectiveStartupMode(activeSkill) === "agent-first" && + (reason?.kind === "input" || reason?.kind === "intake") + ) { + return messages; + } if (reason?.kind === "intake" && !session.slots.startupCompleted) { const prompt = reason.prompt?.trim(); if (!prompt) return messages; @@ -618,6 +1310,7 @@ export class SessionManager { text: string, tokenUsage?: MessageTokenUsage, thinking?: string, + contextTrace?: LlmContextTrace, ): ChatMessage { const base = { id: randomUUID(), @@ -626,6 +1319,7 @@ export class SessionManager { createdAt: new Date().toISOString(), ...(tokenUsage ? { tokenUsage } : {}), ...(thinking ? { thinking } : {}), + ...(contextTrace ? { contextTrace } : {}), }; if (role === "user") { return { @@ -639,6 +1333,81 @@ export class SessionManager { return { ...base, ...classified }; } + /** 按设置修剪本会话上下文痕迹;返回仍保留痕迹的条数 */ + pruneSessionContextTraces(sessionId: string): { kept: number; cleared: number } { + const s = this.require(sessionId); + const before = s.messages.filter((m) => m.contextTrace).length; + const keep = normalizeContextTraceKeepLatest( + loadAppSettings().contextTraceKeepLatest, + ); + s.messages = pruneContextTraces(s.messages, keep); + const after = s.messages.filter((m) => m.contextTrace).length; + if (s.bookId) this.persist(s); + return { kept: after, cleared: Math.max(0, before - after) }; + } + + /** 清空本会话全部上下文痕迹 */ + clearSessionContextTraces(sessionId: string): { cleared: number } { + const s = this.require(sessionId); + const before = s.messages.filter((m) => m.contextTrace).length; + s.messages = pruneContextTraces(s.messages, 0); + if (s.bookId) this.persist(s); + return { cleared: before }; + } + + /** 按当前设置修剪所有内存中的会话 */ + pruneAllOpenContextTraces(): { + sessions: number; + kept: number; + cleared: number; + } { + let sessions = 0; + let kept = 0; + let cleared = 0; + for (const id of this.sessions.keys()) { + const r = this.pruneSessionContextTraces(id); + sessions += 1; + kept += r.kept; + cleared += r.cleared; + } + return { sessions, kept, cleared }; + } + + /** 清空所有内存会话的上下文痕迹 */ + clearAllOpenContextTraces(): { sessions: number; cleared: number } { + let sessions = 0; + let cleared = 0; + for (const id of this.sessions.keys()) { + const r = this.clearSessionContextTraces(id); + sessions += 1; + cleared += r.cleared; + } + return { sessions, cleared }; + } + + /** 兼容旧会话 string[] questions → QuestionItem[] */ + private normalizeWaitingReasonForView( + reason: WaitingReason | undefined, + ): WaitingReason | undefined { + if (!reason) return reason; + if (reason.kind === "worker_questions") { + return { ...reason, questions: normalizeQuestions(reason.questions) }; + } + if (reason.kind === "input" && reason.questions?.length) { + return { ...reason, questions: normalizeQuestions(reason.questions) }; + } + if (reason.kind === "review_artifact" && reason.questions?.length) { + return { + ...reason, + questions: normalizeQuestions(reason.questions).map((q) => ({ + ...q, + required: false, + })), + }; + } + return reason; + } + private toView(id: string, resumed = false, resumeHint?: string): SessionView { const s = this.require(id); const session = s.runtime.getSession(); @@ -681,60 +1450,122 @@ export class SessionManager { actions.push({ type: "send_message", label: "发送", - placeholder: "按上方说明补充信息,可一次说多项…", + placeholder: "描述你想创作什么…", }); } } else if (reason?.kind === "skill_selection") { - hints.push("请选择创作类型(输入 skill 名称或编号)"); + hints.push("旧版会话处于选包阶段;建议新建作品直接描述需求"); actions.push({ type: "send_message", label: "发送", - placeholder: "例如:basic 或 1", + placeholder: "skill id(如 world-simulator)或列表编号", + }); + } else if (reason?.kind === "input" && !session.slots.startupCompleted) { + actions.push({ + type: "send_message", + label: "发送", + placeholder: "描述你想做的作品与体验…", }); } else if (reason?.kind === "intake") { - hints.push(reason.prompt?.trim() ?? "请按填空项补充创作信息"); + const isAgentFirst = + activeSnap && effectiveStartupMode(activeSnap) === "agent-first"; + hints.push( + isAgentFirst + ? "描述你想做的体验;发送后总管将开始:创作 · 核心" + : (reason.prompt?.trim() ?? "请按填空项补充创作信息"), + ); actions.push({ type: "send_message", label: "发送", - placeholder: "描述你想写什么…", + placeholder: isAgentFirst + ? "例如:网恋对象对话、西幻升级交互、规则怪谈……" + : "描述你想写什么…", }); } else if (reason?.kind === "input") { - if (reason.message?.trim()) { - hints.push(reason.message.trim()); + if (reason.questions?.length) { + const qs = normalizeQuestions(reason.questions); + const hasAssessment = Boolean(reason.message?.trim()); + hints.push( + hasAssessment + ? "请阅读内容评价;下方追问可选,也可跳过并直接回复继续" + : `总管追问(可跳过):${qs.map((q) => q.prompt).join(" ")}`, + ); + actions.push({ type: "answer_questions", label: "提交作答" }); + actions.push({ type: "skip_questions", label: "跳过追问" }); + actions.push({ + type: "send_message", + label: "自由补充", + placeholder: "也可在此自由补充…", + }); } else { - hints.push("请回答启动问题,或补充创作目标"); + if (reason.message?.trim()) { + hints.push(reason.message.trim()); + } else { + hints.push("请回答启动问题,或补充创作目标"); + } + actions.push({ + type: "send_message", + label: "发送", + placeholder: "描述你想写什么…", + }); } - actions.push({ - type: "send_message", - label: "发送", - placeholder: "描述你想写什么…", - }); } else if (reason?.kind === "worker_questions") { - const qs = reason.questions.filter((q) => q?.trim()); + const qs = normalizeQuestions(reason.questions); if (qs.length) { - hints.push(`Worker · ${reason.workerId} 提问:${qs.join(" ")}`); + hints.push( + `${displayWorkerLabel(reason.workerId)} · 提问(请在询问卡作答):${qs.map((q) => q.prompt).join(" ")}`, + ); } else { - hints.push(`Worker · ${reason.workerId} 需要更多信息,请补充说明`); + hints.push( + `${displayWorkerLabel(reason.workerId)} 需要更多信息,请补充说明`, + ); } - const placeholder = - qs[0]?.slice(0, 120) ?? "回答 Worker 的问题,或补充情境与参数…"; + actions.push({ type: "answer_questions", label: "提交作答" }); + actions.push({ type: "skip_questions", label: "跳过追问" }); actions.push({ type: "send_message", - label: "发送", - placeholder, + label: "自由补充", + placeholder: "也可在此自由补充…", }); } else if (reason?.kind === "approve_step") { actions.push({ type: "approve", label: "确认执行" }); actions.push({ type: "reject", label: "暂不执行" }); } else if (reason?.kind === "review_artifact") { - actions.push({ type: "accept", label: "接受产物" }); + actions.push({ type: "accept", label: "接受目前产物" }); actions.push({ type: "reject", label: "不接受,重新来" }); + actions.push({ + type: "send_message", + label: "发送修改意见", + placeholder: "输入修改意见后发送…", + }); + if (reason.questions?.length) { + hints.push("下方追问可选;接受产物即收起(表示无需再完善)"); + actions.push({ type: "answer_questions", label: "提交作答" }); + actions.push({ type: "skip_questions", label: "跳过追问" }); + } + } else if (reason?.kind === "revision") { + hints.push( + reason.instruction?.trim() || + "需要按反馈修改后继续;请在下方说明或直接发送继续", + ); + actions.push({ + type: "send_message", + label: "发送", + placeholder: "说明修改意见,或直接发送让总管继续…", + }); + } else if (session.phase === "error") { + hints.push("上一轮执行出错。可直接发消息让总管重试,或刷新作品后继续。"); + actions.push({ + type: "send_message", + label: "重试 / 继续", + placeholder: "例如:重试上一步,或补充说明…", + }); } else if ( session.phase === "running" && s.runtime.needsMainAgentDecision() && !s.runtime.hasMainAgent() ) { - hints.push("简报已就绪,可手动生成大纲(未配置 Agent LLM)"); + hints.push("简报已就绪,可手动生成大纲(未配置总管模型)"); actions.push({ type: "run_outline", label: "生成大纲" }); actions.push({ type: "send_message", @@ -742,7 +1573,7 @@ export class SessionManager { placeholder: "补充说明…", }); } else if (session.phase === "running" && s.runtime.needsMainAgentDecision()) { - hints.push("Agent 正在调度…"); + hints.push("总管正在调度…"); } else if (session.phase === "running" && session.pendingArtifactId) { /* worker 运行中 */ } else if ( @@ -770,10 +1601,12 @@ export class SessionManager { const lifecycleStage = inferLifecycleStage(session); const skillPackId = sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined); + const workerSetYaml = readWorkerSetYaml(s.runtime.getBlackboard()); const skillCatalog = buildSkillCatalog( session, skillPackId, lifecycleStage, + { workerSetYaml }, ); return { @@ -782,7 +1615,7 @@ export class SessionManager { bookTitle: book?.title, activeSkill: s.runtime.getActiveSkill()?.name ?? skillPackId, phase: session.phase, - waitingReason: reason, + waitingReason: this.normalizeWaitingReasonForView(reason), startupCompleted: Boolean(session.slots.startupCompleted), skills: s.runtime.getAvailableSkills(), messages, @@ -797,6 +1630,30 @@ export class SessionManager { focus: buildFocus(session, reason, intake, lifecycleStage), intake, intakePrompt, + uiPrompt: + !session.slots.startupCompleted && + activeSnap && + effectiveStartupMode(activeSnap) === "agent-first" + ? activeSnap.uiPrompt?.trim() || DEFAULT_UI_PROMPT + : undefined, + reviewArtifact: buildReviewArtifactView(session, messages, s.runtime.getBlackboard()), + workerSetView: buildWorkerSetUserView( + s.runtime.getBlackboard(), + s.runtime.getSession(), + ), + creationFlowView: buildCreationFlowUserView(s.runtime.getBlackboard()), + recipes: s.recipeOptions?.map((r) => ({ + id: r.id, + name: r.name, + declaration: r.declaration, + })), + selectedRecipe: resolveSelectedRecipeView( + s.runtime.getBlackboard(), + s.recipeOptions, + ), + boardPanel: buildBoardPanelFromRuntime(s.runtime.getBlackboard()), + liveStream: this.buildLiveStreamView(id), + agentThinking: this.agentThinkingLive.get(id), resumed: resumed || undefined, tokenStats: { sessionTotal: tokens.totalTokens, @@ -808,10 +1665,289 @@ export class SessionManager { }, }; } + + private captureCheckpoint(s: ManagedSession): SessionCheckpoint { + return { + runtimeSession: structuredClone(s.runtime.getSession()), + blackboardItems: s.runtime.getBlackboard().exportItems(), + }; + } + + private serializeBranchState( + state: MessageBranchState, + ): import("../types/book-session.js").PersistedMessageBranchState { + const preMessageCheckpoints: Record< + string, + { runtimeSession: RuntimeSession; blackboardItems: BlackboardItem[] } + > = {}; + for (const [key, cp] of Object.entries(state.preMessageCheckpoints)) { + preMessageCheckpoints[key] = { + runtimeSession: structuredClone(cp.runtimeSession), + blackboardItems: structuredClone(cp.blackboardItems), + }; + } + const branches: import("../types/book-session.js").PersistedMessageBranchState["branches"] = + {}; + for (const [groupId, branch] of Object.entries(state.branches)) { + branches[groupId] = { + anchorIndex: branch.anchorIndex, + groupId: branch.groupId, + activeIndex: branch.activeIndex, + variants: branch.variants.map((v) => ({ + messages: v.messages.map((m) => ({ ...m })), + checkpoint: { + runtimeSession: structuredClone(v.checkpoint.runtimeSession), + blackboardItems: structuredClone(v.checkpoint.blackboardItems), + }, + })), + }; + } + return { branches, preMessageCheckpoints }; + } + + private deserializeBranchState( + raw?: import("../types/book-session.js").PersistedMessageBranchState, + ): MessageBranchState { + const state = createMessageBranchState(); + if (!raw) return state; + for (const [key, cp] of Object.entries(raw.preMessageCheckpoints ?? {})) { + state.preMessageCheckpoints[Number(key)] = { + runtimeSession: structuredClone(cp.runtimeSession), + blackboardItems: structuredClone(cp.blackboardItems), + }; + } + for (const [groupId, branch] of Object.entries(raw.branches ?? {})) { + state.branches[groupId] = { + anchorIndex: branch.anchorIndex, + groupId: branch.groupId, + activeIndex: branch.activeIndex, + variants: branch.variants.map((v) => ({ + messages: v.messages.map((m) => ({ ...m })), + checkpoint: { + runtimeSession: structuredClone(v.checkpoint.runtimeSession), + blackboardItems: structuredClone(v.checkpoint.blackboardItems), + }, + })), + }; + } + return state; + } + + private async refreshRecipeOptions(s: ManagedSession): Promise { + const skillName = s.runtime.getActiveSkill()?.name; + if (!skillName) { + s.recipeOptions = []; + return; + } + try { + const skill = await loadSkill(skillName); + if (!skill.skillPackRoot) { + s.recipeOptions = []; + return; + } + const catalog = await loadRecipeCatalog(skill.skillPackRoot); + s.recipeOptions = catalog?.recipes ?? []; + } catch { + s.recipeOptions = []; + } + } + + private async applySelectedRecipe( + s: ManagedSession, + recipeId: string, + ): Promise { + const options = s.recipeOptions ?? []; + const entry = findRecipeCatalogEntry( + options.length ? { recipes: options } : null, + recipeId, + ); + if (!entry) { + throw new Error(`未知初始配方:${recipeId}`); + } + s.runtime.getBlackboard().write({ + tag: CREATION_SELECTED_RECIPE_TAG, + content: JSON.stringify({ + id: entry.id, + name: entry.name, + }), + source: "user", + }); + } } export const sessionManager = new SessionManager(); +function readWorkerSetYaml(blackboard: Blackboard): string | undefined { + for (const tag of ["设计.worker集", "设计.worker集.草稿"]) { + const content = blackboard.getContentByTag(tag)?.trim(); + if (content) return content; + } + return undefined; +} + +function collectFilledTags(blackboard: Blackboard): string[] { + return blackboard.listTagIndex().map((item) => item.tag); +} + +function buildCreationFlowUserView( + blackboard: Blackboard, +): CreationFlowUserView | undefined { + const raw = blackboard.getContentByTag(CREATION_FLOW_TAG)?.trim(); + if (!raw) return undefined; + const flow = parseCreationFlow(raw); + if (!flow) { + return { steps: [], parseError: "创作流程无法解析为 JSON(需要 steps 数组)" }; + } + return formatCreationFlowForUser(flow); +} + +function resolveSelectedRecipeView( + blackboard: Blackboard, + options?: RecipeCatalogEntry[], +): { id: string; name: string; declaration: string } | null { + const ref = parseSelectedRecipeRef( + blackboard.getContentByTag(CREATION_SELECTED_RECIPE_TAG), + ); + if (!ref) return null; + const entry = findRecipeCatalogEntry( + options?.length ? { recipes: options } : null, + ref, + ); + if (entry) { + return { + id: entry.id, + name: entry.name, + declaration: entry.declaration, + }; + } + return { id: ref, name: ref, declaration: "" }; +} + +function buildWorkerSetUserView( + blackboard: Blackboard, + session?: RuntimeSession, +): WorkerSetUserView | undefined { + const yaml = readWorkerSetYaml(blackboard); + if (!yaml) return undefined; + const parsed = parseWorkerSetYaml(yaml); + if (!parsed) return undefined; + const acceptedUnitIds = parseAcceptedUnits( + session?.slots?.creationAcceptedUnits ?? + blackboard.getContentByTag("创作.已验收单位"), + ); + const currentUnitId = + (typeof session?.slots?.creationCurrentUnitId === "string" + ? session.slots.creationCurrentUnitId + : null) || + blackboard.getContentByTag("创作.当前单位")?.trim() || + null; + return ( + formatWorkerSetForUser(parsed, { + filledTags: collectFilledTags(blackboard), + acceptedUnitIds, + currentUnitId, + }) ?? undefined + ); +} + +function buildReviewArtifactView( + session: RuntimeSession, + messages: ChatMessage[], + blackboard: Blackboard, +): ReviewArtifactView | undefined { + if (session.waitingReason?.kind !== "review_artifact") return undefined; + const art = session.artifacts.find((a) => a.id === session.pendingArtifactId); + if (!art) return undefined; + + const workerSetTags = ["设计.worker集", "设计.worker集.草稿"]; + const tagBodies: string[] = []; + for (const tag of workerSetTags) { + const content = blackboard.getContentByTag(tag); + if (content?.trim()) { + tagBodies.push(`## ${tag}\n\n${content.trim()}`); + } + } + + for (const tag of art.outputTags) { + if (workerSetTags.includes(tag)) continue; + const content = blackboard.getContentByTag(tag); + if (content?.trim()) { + tagBodies.push(`## ${tag}\n\n${content.trim()}`); + } + } + + const outputMsg = [...messages] + .reverse() + .find((m) => m.kind === "worker_output" && m.actor === art.workerId); + + const body = + tagBodies.join("\n\n") || + (outputMsg?.body ?? outputMsg?.text ?? "").trim() || + art.summary?.trim() || + "(Worker 未写入可读正文,请检查黑板 tag 或重试创作 skill)"; + + const isFinalSet = art.outputTags.includes("设计.worker集"); + const isFlowArt = + art.workerId === "design-flow" || art.outputTags.includes(CREATION_FLOW_TAG); + const currentUnit = + blackboard.getContentByTag("创作.当前单位")?.trim() || + (typeof session.slots.creationCurrentUnitId === "string" + ? session.slots.creationCurrentUnitId + : ""); + const isDesignArt = isDesignDiskWorker(art.workerId); + const summary = + art.summary?.trim() || + (isFlowArt + ? "创作流程(步骤与依赖)" + : isDesignArt + ? isFinalSet + ? "完整 Worker 集(终稿)" + : currentUnit + ? `创作单位 · ${currentUnit}` + : "创作单位草稿" + : `${art.workerId} 产物`); + + const workerSetYaml = readWorkerSetYaml(blackboard); + const workerSet = + isDesignArt && !isFlowArt && workerSetYaml + ? parseWorkerSetYaml(workerSetYaml) ?? undefined + : undefined; + const acceptedUnitIds = parseAcceptedUnits( + session.slots.creationAcceptedUnits ?? + blackboard.getContentByTag("创作.已验收单位"), + ); + const workerSetView = + isDesignArt && workerSet + ? formatWorkerSetForUser(workerSet, { + filledTags: collectFilledTags(blackboard), + acceptedUnitIds, + currentUnitId: currentUnit || null, + }) ?? undefined + : undefined; + + const creationFlowView = isFlowArt + ? buildCreationFlowUserView(blackboard) + : undefined; + + return { + id: art.id, + workerId: art.workerId, + summary, + body, + sourceMessageId: outputMsg?.id, + contextTrace: outputMsg?.contextTrace, + workerSet, + workerSetView, + creationFlowView, + }; +} + +function buildBoardPanelFromRuntime( + blackboard: import("../blackboard/blackboard.js").Blackboard, +): BoardPanelView { + return buildBoardPanel(blackboard); +} + function formatRuntimeError(detail: string): string { if (detail.includes("401") || detail.toLowerCase().includes("authentication")) { return ( diff --git a/src/server/settings-handlers.ts b/src/server/settings-handlers.ts index 4673142..a94425e 100644 --- a/src/server/settings-handlers.ts +++ b/src/server/settings-handlers.ts @@ -10,20 +10,24 @@ import { import { ensureActiveProfileDefault, loadAppSettings, + normalizeContextTraceKeepLatest, saveAppSettings, setActivePresetId, setActiveProfileId, } from "../config/settings.js"; +import { + countEnabledEntries, + countInjectingEntries, + listAllPresetEntries, + patchPresetEntries, + type PresetEntryPatch, +} from "../preset/entries.js"; import { deletePreset, getPreset, importAndSavePreset, listPresets, } from "../preset/store.js"; -import { - countInjectingEntries, - listEnabledPresetEntries, -} from "../preset/entries.js"; import { sessionManager } from "./session-manager.js"; async function readBody(req: IncomingMessage): Promise { @@ -49,13 +53,14 @@ export async function handleSettingsApi( const settings = loadAppSettings(); const profiles = listApiProfiles(); const presets = listPresets().map((p) => { - const entries = listEnabledPresetEntries(p); + const all = listAllPresetEntries(p); return { id: p.id, name: p.name, source: p.source, - enabledCount: p.promptOrder.filter((o) => o.enabled).length, - injectingCount: countInjectingEntries(entries), + enabledCount: countEnabledEntries(all), + injectingCount: countInjectingEntries(all), + entryCount: all.length, importedAt: p.importedAt, }; }); @@ -67,6 +72,7 @@ export async function handleSettingsApi( const body = JSON.parse(await readBody(req)) as { activeProfileId?: string | null; activePresetId?: string | null; + contextTraceKeepLatest?: number; }; const settings = loadAppSettings(); if (body.activeProfileId !== undefined) { @@ -75,11 +81,28 @@ export async function handleSettingsApi( if (body.activePresetId !== undefined) { settings.activePresetId = body.activePresetId; } + if (body.contextTraceKeepLatest !== undefined) { + settings.contextTraceKeepLatest = normalizeContextTraceKeepLatest( + body.contextTraceKeepLatest, + ); + } saveAppSettings(settings); json(res, 200, { settings }); return true; } + if (pathname === "/api/settings/context-traces/prune" && req.method === "POST") { + const result = sessionManager.pruneAllOpenContextTraces(); + json(res, 200, result); + return true; + } + + if (pathname === "/api/settings/context-traces/clear" && req.method === "POST") { + const result = sessionManager.clearAllOpenContextTraces(); + json(res, 200, result); + return true; + } + if (pathname === "/api/profiles" && req.method === "GET") { ensureActiveProfileDefault(); json(res, 200, { profiles: listApiProfiles() }); @@ -206,22 +229,65 @@ export async function handleSettingsApi( const presetEntriesMatch = pathname.match( /^\/api\/presets\/([^/]+)\/entries$/, ); - if (presetEntriesMatch && req.method === "GET") { + if (presetEntriesMatch) { const id = decodeURIComponent(presetEntriesMatch[1]); const preset = getPreset(id); if (!preset) { json(res, 404, { error: "预设不存在" }); return true; } - const entries = listEnabledPresetEntries(preset); - json(res, 200, { - presetId: preset.id, - presetName: preset.name, - generation: preset.generation, - entries, - injectingCount: countInjectingEntries(entries), - }); - return true; + + if (req.method === "GET") { + const entries = listAllPresetEntries(preset); + json(res, 200, { + presetId: preset.id, + presetName: preset.name, + generation: preset.generation, + entries, + enabledCount: countEnabledEntries(entries), + injectingCount: countInjectingEntries(entries), + }); + return true; + } + + if (req.method === "PATCH" || req.method === "PUT") { + const body = JSON.parse(await readBody(req)) as { + entries?: PresetEntryPatch[]; + entry?: PresetEntryPatch; + }; + const patches = body.entries?.length + ? body.entries + : body.entry + ? [body.entry] + : []; + if (!patches.length) { + json(res, 400, { error: "缺少 entries 或 entry" }); + return true; + } + try { + const next = patchPresetEntries(preset, patches); + const entries = listAllPresetEntries(next); + const settings = loadAppSettings(); + let reloadedSessions = 0; + if (settings.activePresetId === id) { + reloadedSessions = sessionManager.reloadAllLlms(); + } + json(res, 200, { + presetId: next.id, + presetName: next.name, + generation: next.generation, + entries, + enabledCount: countEnabledEntries(entries), + injectingCount: countInjectingEntries(entries), + reloadedSessions, + }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : String(err), + }); + } + return true; + } } const presetMatch = pathname.match(/^\/api\/presets\/([^/]+)(\/activate)?$/); @@ -246,8 +312,13 @@ export async function handleSettingsApi( json(res, 404, { error: "预设不存在" }); return true; } - const entries = listEnabledPresetEntries(preset); - json(res, 200, { preset, entries, injectingCount: countInjectingEntries(entries) }); + const entries = listAllPresetEntries(preset); + json(res, 200, { + preset, + entries, + enabledCount: countEnabledEntries(entries), + injectingCount: countInjectingEntries(entries), + }); return true; } diff --git a/src/server/skill-catalog.ts b/src/server/skill-catalog.ts index a9944dc..ad54c5b 100644 --- a/src/server/skill-catalog.ts +++ b/src/server/skill-catalog.ts @@ -1,6 +1,20 @@ import type { RuntimeSession } from "../types/runtime.js"; +import { + deriveDesignStageScope, + deriveRunWorkerScope, + instantiateMeta, + parseWorkerSetYaml, + runWorkerMeta, +} from "../skills/worker-set-parse.js"; +import { + canEnterPlay, + hasAcceptedWorkerSet, + inferLifecycleStage, + type LifecycleStage, +} from "../skills/worker-declaration.js"; -export type LifecycleStage = "design" | "play"; +export type { LifecycleStage }; +export { canEnterPlay, hasAcceptedWorkerSet, inferLifecycleStage }; export type SkillCatalogEntry = { id: string; @@ -9,6 +23,8 @@ export type SkillCatalogEntry = { /** 这一步要干嘛(占位说明,详细设计后续补充) */ purpose: string; status: "pending" | "active" | "done" | "skipped"; + /** 同一 skill 被 invoke 的次数(design 阶段 instantiate 可多次) */ + runCount?: number; }; type CatalogTemplate = { @@ -18,242 +34,172 @@ type CatalogTemplate = { purpose: string; }; -const GENERIC_DESIGN: CatalogTemplate[] = [ - { - id: "interaction-paradigm", - stage: "design", - label: "交互范式", - purpose: "弄清用户要什么体验,产出 run skill 清单(要哪些能力)。", - }, - { - id: "intake", - stage: "design", - label: "启动收集", - purpose: "收集最小需求,写入用户.需求 / book.brief。", - }, - { - id: "world-blueprint", - stage: "design", - label: "世界蓝图", - purpose: "定背景板与核心冲突,供后续 skill 引用。", - }, - { - id: "narrative-guide", - stage: "design", - label: "叙事指南", - purpose: "定 POV、时态、文风(static 上下文上半)。", - }, - { +export type BuildSkillCatalogOptions = { + /** `设计.worker集` 或 `.草稿` 的 YAML 正文 */ + workerSetYaml?: string; +}; + +function templatesFor(_skillPackId?: string): CatalogTemplate[] { + return []; +} + +type ArtifactRunStats = { + total: number; + accepted: number; + pending: number; + rejected: number; +}; + +function collectArtifactRuns(session: RuntimeSession): Map { + const map = new Map(); + for (const art of session.artifacts) { + if (!art.workerId) continue; + const prev = map.get(art.workerId) ?? { + total: 0, + accepted: 0, + pending: 0, + rejected: 0, + }; + prev.total += 1; + if (art.status === "accepted") prev.accepted += 1; + else if (art.status === "rejected") prev.rejected += 1; + else prev.pending += 1; + map.set(art.workerId, prev); + } + return map; +} + +function statusForWorkerId( + id: string, + session: RuntimeSession, + runs: Map, + skipped: Set, +): SkillCatalogEntry["status"] { + if (skipped.has(id)) return "skipped"; + const stats = runs.get(id); + if (stats?.accepted) return "done"; + if (session.currentWorkerId === id) return "active"; + if (stats?.pending) return "active"; + return "pending"; +} + +function buildWorldSimulatorCatalog( + session: RuntimeSession, + lifecycle: LifecycleStage, + workerSetYaml?: string, +): SkillCatalogEntry[] { + const workerSet = parseWorkerSetYaml(workerSetYaml); + const runs = collectArtifactRuns(session); + const skipped = new Set(workerSet?.instantiate_hints?.skip ?? []); + + if (lifecycle === "play") { + const runIds = deriveRunWorkerScope(workerSet); + const entries: SkillCatalogEntry[] = [ + { + id: "agent-burst", + stage: "run", + label: "总管调度", + purpose: "总管 tool loop:读黑板 → 选择下一步 Worker。", + status: + session.phase === "running" && !session.currentWorkerId + ? "active" + : "pending", + }, + ]; + + for (const id of runIds) { + const meta = runWorkerMeta(id); + const stats = runs.get(id); + entries.push({ + id, + stage: "run", + label: meta.label, + purpose: meta.purpose, + status: statusForWorkerId(id, session, runs, new Set()), + runCount: stats?.total || undefined, + }); + } + return entries; + } + + const designSteps: Array<{ id: string; label: string; purpose: string }> = [ + { + id: "design-flow", + label: "创作 · 流程编排", + purpose: "编排/增量修订可变 DAG → 设计.创作流程(可反复编入同能力)。", + }, + { + id: "design-step", + label: "创作 · 执行步骤", + purpose: "按已认可流程执行当前一步模块。", + }, + ]; + + const entries: SkillCatalogEntry[] = designSteps.map((step) => ({ + id: step.id, + stage: "design" as const, + label: step.label, + purpose: step.purpose, + status: statusForWorkerId(step.id, session, runs, skipped), + runCount: runs.get(step.id)?.total || undefined, + })); + + const planned = deriveDesignStageScope(workerSet); + for (const id of planned) { + const meta = instantiateMeta(id); + const stats = runs.get(id); + entries.push({ + id, + stage: "design", + label: meta.label, + purpose: meta.purpose, + status: statusForWorkerId(id, session, runs, skipped), + runCount: stats?.total || undefined, + }); + } + + const designIds = new Set(designSteps.map((s) => s.id)); + for (const [id, stats] of runs) { + if (designIds.has(id) || id === "design-intake") continue; + if (planned.includes(id) || skipped.has(id)) continue; + const meta = instantiateMeta(id); + entries.push({ + id, + stage: "design", + label: meta.label, + purpose: meta.purpose, + status: statusForWorkerId(id, session, runs, skipped), + runCount: stats.total || undefined, + }); + } + + entries.push({ id: "declare-ready", stage: "design", label: "实例就绪", - purpose: "agent 确认设计够开跑,进入游玩阶段。", - }, -]; + purpose: "Worker 集已 accept 即可进游玩;若声明了开局,建议先验收开场白(非强制锁定)。", + status: hasAcceptedWorkerSet(session) ? "done" : "pending", + }); -const GENERIC_RUN: CatalogTemplate[] = [ - { - id: "agent-burst", - stage: "run", - label: "Agent 调度", - purpose: "总管 tool loop:读黑板 → 选择 invoke 哪个 run skill。", - }, - { - id: "narrator", - stage: "run", - label: "转述 / 展示", - purpose: "把世界状态编排成给用户看的叙事回复。", - }, - { - id: "world-simulator", - stage: "run", - label: "世界模拟", - purpose: "裁决规则、更新事件流与可见信息。", - }, -]; - -const BY_SKILL_PACK: Record = { - basic: [ - { - id: "intake", - stage: "design", - label: "创作简报", - purpose: "收集题材、篇幅、风格 → book.brief。", - }, - { - id: "declare-ready", - stage: "design", - label: "进入运行", - purpose: "简报确认后 declare ready。", - }, - { - id: "outline", - stage: "run", - label: "生成大纲", - purpose: "根据 brief 生成 outline 产物。", - }, - ], - "weird-rules-short": [ - { - id: "intake", - stage: "design", - label: "创作简报", - purpose: "收集规则怪谈情境与条数。", - }, - { - id: "write-rules", - stage: "run", - label: "写规则", - purpose: "产出规则草稿与隐藏 core。", - }, - { - id: "review-infer", - stage: "run", - label: "读者验收", - purpose: "盲读规则,不写 core。", - }, - { - id: "review-author", - stage: "run", - label: "作者验收", - purpose: "对照 core 查一致性。", - }, - ], - "roleplay-game-theory": [ - { - id: "intake", - stage: "design", - label: "博弈需求", - purpose: "收集情境、角色、轮次 → 用户.博弈需求。", - }, - { - id: "setup-scenario", - stage: "design", - label: "结构化设定", - purpose: "整理为情境、规则、角色设定 tag。", - }, - { - id: "declare-ready", - stage: "design", - label: "开始模拟", - purpose: "setup 验收后进入 run。", - }, - { - id: "world-engine", - stage: "run", - label: "世界机", - purpose: "发可见信息、收行动、裁决回合。", - }, - { - id: "role-decide", - stage: "run", - label: "角色决策", - purpose: "各角色独立产出思考与行动。", - }, - { - id: "present-round", - stage: "run", - label: "回合展示", - purpose: "编排给用户看的本轮摘要。", - }, - ], - "world-simulator": [ - { - id: "interaction-paradigm", - stage: "design", - label: "交互范式", - purpose: "定体验与 run skill 清单。", - }, - { - id: "world-blueprint", - stage: "design", - label: "世界蓝图", - purpose: "背景板与核心设定。", - }, - { - id: "topology", - stage: "design", - label: "拓扑 / 关系", - purpose: "地图、关系网或进阶路径(按需)。", - }, - { - id: "generation-rules", - stage: "design", - label: "生成规则", - purpose: "元规则:如何生成实例内容。", - }, - { - id: "narrative-guide", - stage: "design", - label: "叙事指南", - purpose: "正文气质与禁忌(static 上)。", - }, - { - id: "variable-catalog", - stage: "design", - label: "变量目录", - purpose: "要跟踪的状态与更新格式。", - }, - { - id: "declare-ready", - stage: "design", - label: "实例就绪", - purpose: "agent 声明可开跑。", - }, - { - id: "world-simulator", - stage: "run", - label: "世界模拟器", - purpose: "每轮推进世界状态与事件流。", - }, - { - id: "narrator", - stage: "run", - label: "转述者", - purpose: "把状态写成用户可见叙事。", - }, - ], -}; - -function templatesFor(skillPackId?: string): CatalogTemplate[] { - if (skillPackId && BY_SKILL_PACK[skillPackId]) { - return BY_SKILL_PACK[skillPackId]; - } - return [...GENERIC_DESIGN, ...GENERIC_RUN]; -} - -export function inferLifecycleStage(session: RuntimeSession): LifecycleStage { - const override = session.slots.uiLifecycleStage; - if (override === "design" || override === "play") { - return override; - } - if (!session.slots.startupCompleted) return "design"; - if (session.phase === "done") return "play"; - return "play"; -} - -export function canEnterPlay(session: RuntimeSession): boolean { - return Boolean(session.slots.startupCompleted); + return entries; } export function buildSkillCatalog( session: RuntimeSession, skillPackId?: string, lifecycle: LifecycleStage = inferLifecycleStage(session), + options?: BuildSkillCatalogOptions, ): SkillCatalogEntry[] { + if (skillPackId === "world-simulator") { + return buildWorldSimulatorCatalog(session, lifecycle, options?.workerSetYaml); + } + const templates = templatesFor(skillPackId); const filtered = templates.filter((t) => lifecycle === "design" ? t.stage === "design" : t.stage === "run", ); - const workerIds = new Set( - session.artifacts.map((a) => a.workerId).filter(Boolean), - ); - const acceptedWorkers = new Set( - session.artifacts - .filter((a) => a.status === "accepted") - .map((a) => a.workerId), - ); + const runs = collectArtifactRuns(session); return filtered.map((t) => { let status: SkillCatalogEntry["status"] = "pending"; @@ -267,18 +213,25 @@ export function buildSkillCatalog( status = "active"; } } else if (t.id === "declare-ready") { - if (session.slots.startupCompleted) status = "done"; + if (session.slots.startupCompleted || hasAcceptedWorkerSet(session)) { + status = "done"; + } } else if (t.id === "agent-burst") { if (session.phase === "running" && !session.currentWorkerId) { status = "active"; } - } else if (workerIds.has(t.id)) { - status = acceptedWorkers.has(t.id) ? "done" : "active"; + } else if (runs.has(t.id)) { + status = statusForWorkerId(t.id, session, runs, new Set()); } else if (session.currentWorkerId === t.id) { status = "active"; } - return { ...t, status }; + const runCount = runs.get(t.id)?.total; + return { + ...t, + status, + runCount: runCount || undefined, + }; }); } diff --git a/src/server/web-server.ts b/src/server/web-server.ts index a00ca0a..ccc8c94 100644 --- a/src/server/web-server.ts +++ b/src/server/web-server.ts @@ -65,6 +65,8 @@ const server = createServer(async (req, res) => { "GET /api/presets", "GET /api/books", "GET /api/skills", + "GET /api/directors", + "GET /api/modules", "GET /api/stats/tokens", ], }); @@ -130,6 +132,104 @@ const server = createServer(async (req, res) => { return; } + if (req.method === "POST" && sub === "/answers") { + const body = JSON.parse(await readBody(req)) as { + answers?: Array<{ + questionId?: string; + optionId?: string; + text?: string; + }>; + note?: string; + }; + const answers = (body.answers ?? []) + .filter( + (a) => + typeof a?.questionId === "string" && + a.questionId.trim() && + typeof a?.text === "string" && + a.text.trim(), + ) + .map((a) => ({ + questionId: a.questionId!.trim(), + optionId: + typeof a.optionId === "string" && a.optionId.trim() + ? a.optionId.trim() + : undefined, + text: a.text!.trim(), + })); + if (!answers.length) { + json(res, 400, { error: "answers 不能为空" }); + return; + } + const note = + typeof body.note === "string" && body.note.trim() + ? body.note.trim() + : undefined; + try { + const view = await sessionManager.answerQuestions( + sessionId, + answers, + note, + ); + json(res, 200, view); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "提交失败", + }); + } + return; + } + + const messageActionMatch = sub.match( + /^\/messages\/([^/]+)\/(edit|refresh|variant|delete)$/, + ); + if (req.method === "POST" && messageActionMatch) { + const messageId = decodeURIComponent(messageActionMatch[1]); + const action = messageActionMatch[2]; + const body = JSON.parse(await readBody(req).catch(() => "{}")) as { + text?: string; + direction?: string; + }; + let view; + try { + if (action === "edit") { + if (!body.text?.trim()) { + json(res, 400, { error: "text 不能为空" }); + return; + } + view = await sessionManager.editMessage( + sessionId, + messageId, + body.text.trim(), + ); + } else if (action === "refresh") { + view = await sessionManager.refreshMessage(sessionId, messageId); + } else if (action === "variant") { + if (body.direction !== "prev" && body.direction !== "next") { + json(res, 400, { error: "direction 须为 prev 或 next" }); + return; + } + view = await sessionManager.switchMessageVariant( + sessionId, + messageId, + body.direction, + ); + } else if (action === "delete") { + view = await sessionManager.deleteMessage(sessionId, messageId); + } else { + json(res, 400, { error: "未知 action" }); + return; + } + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "操作失败", + }); + return; + } + json(res, 200, view); + return; + } + if (req.method === "POST" && sub === "/lifecycle") { const body = JSON.parse(await readBody(req)) as { stage?: string }; if (body.stage !== "design" && body.stage !== "play") { @@ -147,6 +247,74 @@ const server = createServer(async (req, res) => { return; } + if (req.method === "POST" && sub === "/recipe") { + const body = JSON.parse(await readBody(req)) as { recipeId?: string }; + if (!body.recipeId?.trim()) { + json(res, 400, { error: "缺少 recipeId" }); + return; + } + try { + const view = await sessionManager.setSelectedRecipe( + sessionId, + body.recipeId.trim(), + ); + json(res, 200, view); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "选定配方失败", + }); + } + return; + } + + if (req.method === "POST" && sub === "/board") { + const body = JSON.parse(await readBody(req)) as { + tag?: string; + content?: string; + }; + if (!body.tag?.trim()) { + json(res, 400, { error: "缺少 tag" }); + return; + } + try { + const view = sessionManager.writeUserBoardTag( + sessionId, + body.tag, + body.content ?? "", + ); + json(res, 200, view); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "写入失败", + }); + } + return; + } + + if (req.method === "POST" && sub === "/context-traces/prune") { + try { + const result = sessionManager.pruneSessionContextTraces(sessionId); + json(res, 200, { ...result, session: sessionManager.get(sessionId) }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "修剪失败", + }); + } + return; + } + + if (req.method === "POST" && sub === "/context-traces/clear") { + try { + const result = sessionManager.clearSessionContextTraces(sessionId); + json(res, 200, { ...result, session: sessionManager.get(sessionId) }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "清除失败", + }); + } + return; + } + if (req.method === "POST" && sub === "/actions") { const body = JSON.parse(await readBody(req)) as { action?: string }; let view; @@ -160,6 +328,9 @@ const server = createServer(async (req, res) => { case "accept": view = await sessionManager.accept(sessionId); break; + case "skip_questions": + view = await sessionManager.skipQuestions(sessionId); + break; case "reject": view = await sessionManager.reject(sessionId); break; diff --git a/src/skills/context-segments.ts b/src/skills/context-segments.ts new file mode 100644 index 0000000..9cce413 --- /dev/null +++ b/src/skills/context-segments.ts @@ -0,0 +1,165 @@ +/** + * Worker contextSegments 拼装:按 tier 顺序把黑板 tag 拼成 Markdown。 + * 见 docs/context-assembly.md + */ +import type { Blackboard } from "../blackboard/blackboard.js"; +import type { BlackboardInputMerge } from "../types/blackboard.js"; +import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js"; +import { + CREATION_ACCEPTED_CONTENT_TAG, + formatAcceptedContentForPrompt, +} from "./creation-units.js"; + +export type ContextSegmentTier = "static" | "dynamic"; + +export type ContextSegmentDef = { + id: string; + tier: ContextSegmentTier; + tags: string[]; + label?: string; + /** latest | concat | tail_lines_N */ + policy?: string; +}; + +export function parseContextSegments(raw: unknown): ContextSegmentDef[] { + if (!Array.isArray(raw)) return []; + const out: ContextSegmentDef[] = []; + for (const row of raw) { + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + const r = row as Record; + const id = typeof r.id === "string" ? r.id.trim() : ""; + const tier = r.tier === "dynamic" ? "dynamic" : "static"; + const tags = Array.isArray(r.tags) + ? r.tags.filter((t): t is string => typeof t === "string" && t.trim()).map((t) => t.trim()) + : []; + if (!id || tags.length === 0) continue; + out.push({ + id, + tier, + tags, + label: typeof r.label === "string" ? r.label.trim() : undefined, + policy: typeof r.policy === "string" ? r.policy.trim() : undefined, + }); + } + return out; +} + +function applyPolicy(content: string, policy?: string): string { + if (!policy || policy === "latest" || policy === "concat") return content; + const m = /^tail_lines_(\d+)$/.exec(policy); + if (m) { + const n = Number(m[1]); + if (Number.isFinite(n) && n > 0) { + const lines = content.split(/\r?\n/); + return lines.slice(-n).join("\n"); + } + } + return content; +} + +function readTagContent( + tag: string, + inputs: Record, + blackboard: Blackboard, + inputMerge: BlackboardInputMerge, +): string { + if (inputs[tag]?.trim()) return inputs[tag]!.trim(); + const items = blackboard.queryByPatterns([tag], inputMerge); + if (items.length === 0) return ""; + if (inputMerge === "concat") { + return items.map((i) => i.content).filter(Boolean).join("\n\n"); + } + return items[items.length - 1]?.content?.trim() ?? ""; +} + +function formatSegmentBody( + segment: ContextSegmentDef, + inputs: Record, + blackboard: Blackboard, + inputMerge: BlackboardInputMerge, +): string { + const parts: string[] = []; + for (const tag of segment.tags) { + let content = readTagContent(tag, inputs, blackboard, inputMerge); + if (!content) continue; + if (tag === CREATION_ACCEPTED_CONTENT_TAG) { + content = formatAcceptedContentForPrompt(content); + } + content = applyPolicy(content, segment.policy); + if (!content.trim()) continue; + parts.push(content.trim()); + } + return parts.join("\n\n"); +} + +/** + * 按 contextSegments 拼装 user 侧上下文。 + * 无 segments 时回退为 JSON inputs(兼容旧 skill)。 + */ +export function assembleWorkerContext(params: { + inputs: Record; + segments?: ContextSegmentDef[] | null; + blackboard: Blackboard; + inputMerge?: BlackboardInputMerge; + workerId: string; + workerName: string; + outputTags: string[]; +}): string { + const inputMerge = params.inputMerge ?? "latest"; + const segments = params.segments ?? []; + + if (segments.length === 0) { + return JSON.stringify( + { + workerId: params.workerId, + workerName: params.workerName, + outputTags: params.outputTags, + inputs: params.inputs, + instruction: + "根据 SKILL 说明完成任务。若 inputs 含「上下文.定稿摘要」,以终产物为准。" + + "设计.worker集 / 草稿须为 JSON 对象文本。", + }, + null, + 2, + ); + } + + const staticSegs = segments.filter((s) => s.tier === "static"); + const dynamicSegs = segments.filter((s) => s.tier === "dynamic"); + const blocks: string[] = []; + + const render = (seg: ContextSegmentDef) => { + const body = formatSegmentBody(seg, params.inputs, params.blackboard, inputMerge); + if (!body) return; + if (seg.label) { + blocks.push(`${seg.label}\n\n${body}`); + } else { + blocks.push(body); + } + }; + + for (const seg of staticSegs) render(seg); + for (const seg of dynamicSegs) render(seg); + + // 定稿摘要:若未在 segments 中声明,仍附在末尾 + const brief = params.inputs[CONTEXT_BRIEF_TAG]?.trim(); + const briefInSegments = segments.some((s) => s.tags.includes(CONTEXT_BRIEF_TAG)); + if (brief && !briefInSegments) { + blocks.push(`## 上下文.定稿摘要\n\n${brief}`); + } + + blocks.push( + [ + "## 本步任务", + "", + `- workerId: \`${params.workerId}\``, + `- workerName: ${params.workerName}`, + `- outputTags: ${params.outputTags.map((t) => `\`${t}\``).join("、") || "(无)"}`, + "", + "按 SKILL 与上方分区完成任务。标为「只读 / 已定稿」的分区不要擅自改写;只改【本单位】范围。", + "设计.worker集 / 草稿须为 JSON 对象文本(以 `{` 开头)。", + ].join("\n"), + ); + + return blocks.join("\n\n---\n\n"); +} diff --git a/src/skills/creation-flow.ts b/src/skills/creation-flow.ts new file mode 100644 index 0000000..4d60d74 --- /dev/null +++ b/src/skills/creation-flow.ts @@ -0,0 +1,964 @@ +/** + * 创作流程:编排产物(设计.创作流程)。 + * 可变增量 DAG:有序 steps + 每步 id/中文名 + depends_on;可追加、可同能力多次。 + * + * 两层内容(作者细写,运行时只搭骨架): + * - recipes/:初始配方(给总管 / design-flow 的参考起点,可调味) + * - modules/:共用组件池(步骤名与方法正文;配方与总管都从这里选型) + */ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; + +export const CREATION_FLOW_TAG = "设计.创作流程"; +export const CREATION_CURRENT_STEP_TAG = "创作.当前步骤"; +/** 用户手动选定的初始配方(存 recipe id,或 JSON {id,name}) */ +export const CREATION_SELECTED_RECIPE_TAG = "创作.选用配方"; +export const MODULE_CATALOG_FILENAME = "modules/catalog.yaml"; +export const RECIPE_CATALOG_FILENAME = "recipes/catalog.yaml"; +export const DESIGN_STEP_WORKER_ID = "design-step"; +export const DESIGN_FLOW_WORKER_ID = "design-flow"; + +const DEFAULT_SKILLS_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../skills", +); + +export type CreationFlowStep = { + /** + * 本局步骤唯一 id(验收与 depends_on 用这个)。 + * 同能力可多次出现时必须不同;缺省时程序按 name / name#n 补齐。 + */ + id: string; + /** 固定中文名,须 ∈ 模块目录(可重复) */ + name: string; + /** 依赖的其它步骤 id(旧稿若 name 唯一也可写 name) */ + depends_on: string[]; +}; + +export type CreationFlowStatus = "open" | "closed"; + +export type CreationFlow = { + version: 1; + /** 可选一句体验复述(给人看) */ + brief?: string; + /** + * open = 当前只是近期 horizon,还可增量追加 / 反复编排同能力; + * closed = 不再扩步(可走收成)。缺省按 closed(兼容旧固定 DAG)。 + */ + status?: CreationFlowStatus; + steps: CreationFlowStep[]; +}; + +export type ModuleCatalogEntry = { + /** 目录文件夹名,如 aesthetics-interaction */ + id: string; + name: string; + /** 给 agent 的短声明:用来决定要不要调度这一步 */ + declaration: string; + /** 执行期产物 tag(流程 JSON 不写;程序映射) */ + artifact: string; + /** + * 可选:默认可反复编排进流程(如生成规则、具体实例)。 + * 程序不硬拦;给编排与校验提示。 + */ + repeatable?: boolean; + /** + * 可选:默认问题(开场白)。优先用 prompt.md 的 ```opening 块; + * catalog 写了则作覆盖。程序发出,不经 LLM。 + */ + opening?: string; +}; + +/** 本步程序开场白正文(design-step 发出后写入,供 LLM 看见) */ +export const CREATION_MODULE_OPENING_TAG = "创作.能力开场白"; +/** JSON:{ [步骤中文名]: "shown" | "answered" } */ +export const CREATION_MODULE_OPENING_STATE_TAG = "创作.能力开场状态"; +export const SLOT_CREATION_MODULE_OPENING_STATE = "creationModuleOpeningState"; + +export type ModuleOpeningState = Record; + +export type ModuleCatalog = { + modules: ModuleCatalogEntry[]; +}; + +/** 初始配方目录条目(短声明,给选型) */ +export type RecipeCatalogEntry = { + id: string; + name: string; + declaration: string; +}; + +export type RecipeCatalog = { + recipes: RecipeCatalogEntry[]; +}; + +/** + * 单份初始配方详情。 + * seed = 建议步骤(可为空;name 须 ∈ 模块池);编排时允许增删改。 + */ +export type RecipeDetail = { + id: string; + name: string; + declaration: string; + when?: string; + hint?: string; + seed: CreationFlow | null; +}; + +export type CreationFlowValidation = { + ok: boolean; + errors: string[]; +}; + +export type CreationFlowUserView = { + brief?: string; + status?: CreationFlowStatus; + steps: Array<{ + order: number; + id: string; + name: string; + depends_on: string[]; + /** 同能力第几次(>1 时 UI 可标「再来」) */ + occurrence?: number; + /** 目录里的短声明(有则展示) */ + declaration?: string; + repeatable?: boolean; + }>; + parseError?: string; +}; + +/** 从任意正文抽取 JSON 对象 */ +export function extractJsonObject(raw: string): unknown | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed); + } catch { + /* try slice */ + } + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start >= 0 && end > start) { + try { + return JSON.parse(trimmed.slice(start, end + 1)); + } catch { + return null; + } + } + return null; +} + +/** 为缺 id 的步骤补齐唯一 id;同 name 多次 → name#2、name#3… */ +export function ensureCreationFlowStepIds( + steps: Array<{ id?: string; name: string; depends_on: string[] }>, +): CreationFlowStep[] { + const used = new Set(); + const nameCount = new Map(); + const out: CreationFlowStep[] = []; + + for (const raw of steps) { + const name = raw.name.trim(); + const n = (nameCount.get(name) ?? 0) + 1; + nameCount.set(name, n); + + let id = typeof raw.id === "string" ? raw.id.trim() : ""; + if (!id) { + id = n === 1 ? name : `${name}#${n}`; + } + if (used.has(id)) { + let i = 2; + while (used.has(`${id}#${i}`)) i++; + id = `${id}#${i}`; + } + used.add(id); + out.push({ + id, + name, + depends_on: raw.depends_on.map((d) => d.trim()).filter(Boolean), + }); + } + return out; +} + +/** 按 id 或(唯一)name 解析步骤引用 */ +export function findStepByRef( + flow: CreationFlow, + ref: string, +): CreationFlowStep | null { + const key = ref.trim(); + if (!key) return null; + const byId = flow.steps.find((s) => s.id === key); + if (byId) return byId; + const byName = flow.steps.filter((s) => s.name === key); + return byName.length === 1 ? byName[0]! : null; +} + +/** 验收 / 当前步骤用的单位 id(优先 step.id) */ +export function stepUnitId(step: CreationFlowStep): string { + return step.id || step.name; +} + +export function parseCreationFlow(raw: string | undefined | null): CreationFlow | null { + if (!raw?.trim()) return null; + const doc = extractJsonObject(raw); + if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null; + const row = doc as Record; + const stepsRaw = row.steps; + if (!Array.isArray(stepsRaw) || stepsRaw.length === 0) return null; + + const drafted: Array<{ id?: string; name: string; depends_on: string[] }> = []; + for (const item of stepsRaw) { + if (!item || typeof item !== "object" || Array.isArray(item)) return null; + const s = item as Record; + const name = typeof s.name === "string" ? s.name.trim() : ""; + if (!name) return null; + const id = typeof s.id === "string" && s.id.trim() ? s.id.trim() : undefined; + const depsRaw = s.depends_on ?? s.dependsOn ?? []; + const depends_on = Array.isArray(depsRaw) + ? depsRaw.map((d) => String(d).trim()).filter(Boolean) + : []; + drafted.push({ id, name, depends_on }); + } + + const steps = ensureCreationFlowStepIds(drafted); + + const brief = + typeof row.brief === "string" && row.brief.trim() ? row.brief.trim() : undefined; + const statusRaw = typeof row.status === "string" ? row.status.trim() : ""; + const status: CreationFlowStatus | undefined = + statusRaw === "open" || statusRaw === "closed" ? statusRaw : undefined; + + return { version: 1, brief, status, steps }; +} + +export function parseModuleCatalog(raw: string): ModuleCatalog | null { + let doc: unknown; + try { + doc = parseYaml(raw); + } catch { + return null; + } + if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null; + const modulesRaw = (doc as Record).modules; + if (!Array.isArray(modulesRaw)) return null; + + const modules: ModuleCatalogEntry[] = []; + for (const item of modulesRaw) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const m = item as Record; + const name = typeof m.name === "string" ? m.name.trim() : ""; + const declaration = + typeof m.declaration === "string" ? m.declaration.trim() : ""; + const artifact = typeof m.artifact === "string" ? m.artifact.trim() : ""; + const id = + typeof m.id === "string" && m.id.trim() + ? m.id.trim() + : name + ? slugFromName(name) + : ""; + if (!name || !declaration || !artifact || !id) continue; + const opening = + typeof m.opening === "string" && m.opening.trim() + ? m.opening.trim() + : undefined; + const repeatable = m.repeatable === true; + modules.push({ + id, + name, + declaration, + artifact, + ...(repeatable ? { repeatable: true } : {}), + ...(opening ? { opening } : {}), + }); + } + if (modules.length === 0) return null; + return { modules }; +} + +/** + * 能力 prompt.md 可切割块(fence 语言标签 = 块 id)。 + * 标准块见 MODULE_SECTION_IDS;程序只认 ```id … ```,不认散文标题 alone。 + */ +export const MODULE_SECTION_IDS = [ + "meta", + "opening", + "task", + "principles", + "probe", + "output", + "checklist", + "examples", +] as const; + +export type ModuleSectionId = (typeof MODULE_SECTION_IDS)[number]; + +export type ModulePromptSections = { + /** 原文 */ + raw: string; + /** 按 fence 标签切出的块;缺块则为空串 */ + blocks: Partial> & Record; +}; + +/** + * 切割能力文档:抽取全部 ```lang … ``` 块。 + * 同一 lang 多次出现时拼接(中间空行)。 + */ +export function parseModulePromptSections(promptMd: string): ModulePromptSections { + const blocks: Record = {}; + if (!promptMd?.trim()) return { raw: promptMd ?? "", blocks }; + const re = /```([a-zA-Z][\w-]*)\s*\r?\n([\s\S]*?)```/g; + let m: RegExpExecArray | null; + while ((m = re.exec(promptMd)) !== null) { + const id = m[1]!.toLowerCase(); + const body = m[2]!.trim(); + if (!body) continue; + blocks[id] = blocks[id] ? `${blocks[id]}\n\n${body}` : body; + } + return { raw: promptMd, blocks }; +} + +export function getModuleSection( + sections: ModulePromptSections, + id: ModuleSectionId | string, +): string | null { + const body = sections.blocks[id.toLowerCase()]?.trim(); + return body || null; +} + +/** + * 从能力 prompt.md 抽取默认问题(开场白)。 + * 只认 ```opening … ```(能力标准块)。 + */ +export function extractModuleOpening(promptMd: string): string | null { + return getModuleSection(parseModulePromptSections(promptMd), "opening"); +} + +/** + * 拼给 LLM 的方法正文:标准块按固定顺序;无标准块时回退全文。 + * 不含 opening(开场已由程序发出)。 + */ +export function formatModulePromptForLlm(promptMd: string): string { + const { blocks } = parseModulePromptSections(promptMd); + const order: ModuleSectionId[] = [ + "meta", + "task", + "principles", + "probe", + "output", + "checklist", + "examples", + ]; + const parts: string[] = []; + for (const id of order) { + const body = blocks[id]?.trim(); + if (body) parts.push(`## ${id}\n\n\`\`\`${id}\n${body}\n\`\`\``); + } + if (parts.length === 0) return promptMd.trim(); + return parts.join("\n\n"); +} + +export function parseModuleOpeningState( + raw: string | unknown | null | undefined, +): ModuleOpeningState { + if (raw == null) return {}; + const text = + typeof raw === "string" + ? raw.trim() + : typeof raw === "object" + ? JSON.stringify(raw) + : String(raw); + if (!text) return {}; + try { + const doc = JSON.parse(text) as unknown; + if (!doc || typeof doc !== "object" || Array.isArray(doc)) return {}; + const out: ModuleOpeningState = {}; + for (const [k, v] of Object.entries(doc as Record)) { + if (v === "shown" || v === "answered") out[k] = v; + } + return out; + } catch { + return {}; + } +} + +export function stringifyModuleOpeningState(state: ModuleOpeningState): string { + return JSON.stringify(state); +} + +/** 无 id 时的兜底(目录仍应显式写 id) */ +function slugFromName(name: string): string { + const map: Record = { + 美学纲领与交互范式: "aesthetics-interaction", + 交互范式: "interaction", + 美学纲领: "aesthetics", + 叙事指南: "narrative", + 实现机制: "mechanism", + 世界蓝图与人文地理: "world-blueprint", + 生成规则: "generation-rules", + 具体实例: "concrete-instances", + 拓扑图谱: "topology", + 设计状态栏: "status-bar", + 变量设计与更新规则: "variable-design", + 变量控制上下文: "variable-context", + 设计回复格式: "reply-format", + "Worker 规格": "worker-spec", + 细化终稿: "refine", + }; + return map[name] ?? name; +} + +export async function loadModuleCatalog( + skillPackRoot: string, + skillsRoot = DEFAULT_SKILLS_ROOT, +): Promise { + const fullPath = path.join(skillsRoot, skillPackRoot, MODULE_CATALOG_FILENAME); + try { + const raw = await readFile(fullPath, "utf8"); + return parseModuleCatalog(raw); + } catch { + return null; + } +} + +/** 注入 design-flow 的短目录(非全文 prompt) */ +export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string { + const lines = catalog.modules.map((m) => { + const flags = m.repeatable ? "〔可反复〕" : ""; + return `- ${m.name}${flags}:${m.declaration}`; + }); + return `【能力 · 可选工序】(按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入)\n${lines.join("\n")}`; +} + +export function parseRecipeCatalog(raw: string): RecipeCatalog | null { + let doc: unknown; + try { + doc = parseYaml(raw); + } catch { + return null; + } + if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null; + const recipesRaw = (doc as Record).recipes; + if (!Array.isArray(recipesRaw)) return null; + + const recipes: RecipeCatalogEntry[] = []; + for (const item of recipesRaw) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const r = item as Record; + const name = typeof r.name === "string" ? r.name.trim() : ""; + const declaration = + typeof r.declaration === "string" ? r.declaration.trim() : ""; + const id = + typeof r.id === "string" && r.id.trim() + ? r.id.trim() + : name + ? slugFromName(name) + : ""; + if (!name || !declaration || !id) continue; + recipes.push({ id, name, declaration }); + } + if (recipes.length === 0) return null; + return { recipes }; +} + +export async function loadRecipeCatalog( + skillPackRoot: string, + skillsRoot = DEFAULT_SKILLS_ROOT, +): Promise { + const fullPath = path.join(skillsRoot, skillPackRoot, RECIPE_CATALOG_FILENAME); + try { + const raw = await readFile(fullPath, "utf8"); + return parseRecipeCatalog(raw); + } catch { + return null; + } +} + +/** + * 解析单份 recipe.yaml(when / hint / brief / steps)。 + * steps 空或缺失 → seed 为 null(仍可作选型参考)。 + */ +export function parseRecipeYaml( + raw: string, + meta: RecipeCatalogEntry, +): RecipeDetail { + let doc: unknown; + try { + doc = parseYaml(raw); + } catch { + return { + id: meta.id, + name: meta.name, + declaration: meta.declaration, + seed: null, + }; + } + if (!doc || typeof doc !== "object" || Array.isArray(doc)) { + return { + id: meta.id, + name: meta.name, + declaration: meta.declaration, + seed: null, + }; + } + const row = doc as Record; + const when = + typeof row.when === "string" && row.when.trim() + ? row.when.trim() + : undefined; + const hint = + typeof row.hint === "string" && row.hint.trim() + ? row.hint.trim() + : undefined; + const name = + typeof row.name === "string" && row.name.trim() + ? row.name.trim() + : meta.name; + + const seedParsed = parseCreationFlow(JSON.stringify({ + brief: typeof row.brief === "string" ? row.brief : undefined, + status: "open", + steps: Array.isArray(row.steps) ? row.steps : [], + })); + const seed = seedParsed; + + return { + id: meta.id, + name, + declaration: meta.declaration, + when, + hint, + seed, + }; +} + +export async function loadRecipeDetail( + skillPackRoot: string, + entry: RecipeCatalogEntry, + skillsRoot = DEFAULT_SKILLS_ROOT, +): Promise { + const fullPath = path.join( + skillsRoot, + skillPackRoot, + "recipes", + entry.id, + "recipe.yaml", + ); + try { + const raw = await readFile(fullPath, "utf8"); + return parseRecipeYaml(raw, entry); + } catch { + return { + id: entry.id, + name: entry.name, + declaration: entry.declaration, + seed: null, + }; + } +} + +export async function loadAllRecipeDetails( + skillPackRoot: string, + skillsRoot = DEFAULT_SKILLS_ROOT, +): Promise { + const catalog = await loadRecipeCatalog(skillPackRoot, skillsRoot); + if (!catalog) return []; + const out: RecipeDetail[] = []; + for (const entry of catalog.recipes) { + out.push(await loadRecipeDetail(skillPackRoot, entry, skillsRoot)); + } + return out; +} + +/** + * 解析用户选定的配方引用。 + * 接受纯 id / 中文名,或 JSON `{ "id": "…" }` / `{ "name": "…" }`。 + */ +export function parseSelectedRecipeRef( + raw: string | null | undefined, +): string | null { + if (!raw?.trim()) return null; + const trimmed = raw.trim(); + try { + const doc = JSON.parse(trimmed) as unknown; + if (doc && typeof doc === "object" && !Array.isArray(doc)) { + const row = doc as Record; + const id = typeof row.id === "string" ? row.id.trim() : ""; + const name = typeof row.name === "string" ? row.name.trim() : ""; + return id || name || null; + } + } catch { + /* plain string */ + } + return trimmed; +} + +export function findRecipeCatalogEntry( + catalog: RecipeCatalog | null | undefined, + ref: string, +): RecipeCatalogEntry | null { + if (!catalog || !ref.trim()) return null; + const key = ref.trim(); + return ( + catalog.recipes.find((r) => r.id === key || r.name === key) ?? null + ); +} + +/** 给人 / API 看的配方目录短列表(不是给 agent 选型) */ +export function formatRecipeCatalogForAgent(catalog: RecipeCatalog): string { + const lines = catalog.recipes.map( + (r) => `- ${r.name}:${r.declaration}`, + ); + return `【可选导演】(须由用户手动选择)\n${lines.join("\n")}`; +} + +/** 注入 design-flow:用户已选导演(内部 recipe) */ +export function formatSelectedRecipeForAgent(detail: RecipeDetail): string { + const lines: string[] = [ + `【用户已选导演 · ${detail.name}】`, + "这是用户手动选定的方法起点,不是锁死流水线。", + "产出**增量 DAG**:只排近期要做的步骤;已验收步保留,可追加同能力多次调用(如生成规则 / 具体实例)。", + "按用户表述增删改未验收步骤与依赖(像现场改戏 / 调味);步骤名只能从【能力】选。", + "禁止改选其它导演;若用户要换导演,须等用户重新选定后再编排。", + ]; + if (detail.declaration) lines.push(`简介:${detail.declaration}`); + if (detail.when) lines.push(`适用:${detail.when}`); + if (detail.hint) lines.push(`调味提示:${detail.hint}`); + if (detail.seed?.steps.length) { + const stepsJson = JSON.stringify( + { + brief: detail.seed.brief, + status: detail.seed.status ?? "open", + steps: detail.seed.steps, + }, + null, + 2, + ); + lines.push("建议近期 steps(增量起点,可改;勿一次排完全程):"); + lines.push("```json"); + lines.push(stepsJson); + lines.push("```"); + } else { + lines.push("建议 steps:(待作者完善 recipe.yaml;可从【能力】自行编排近期 horizon)"); + } + return lines.join("\n"); +} + +/** design-flow 一次注入:已选配方 + 组件池 */ +export function formatDesignFlowContentBlocks(params: { + selectedRecipe?: RecipeDetail | null; + modules?: ModuleCatalog | null; + missingSelection?: boolean; +}): string[] { + const blocks: string[] = []; + if (params.missingSelection) { + blocks.push( + [ + "【导演】用户尚未手动选择。", + "禁止自行猜测或替用户选定导演。", + "请 askUser 请用户从可用导演中选择,或等待用户在界面选定后再编排。", + ].join("\n"), + ); + } else if (params.selectedRecipe) { + blocks.push(formatSelectedRecipeForAgent(params.selectedRecipe)); + } + if (params.modules) { + blocks.push(formatModuleCatalogForAgent(params.modules)); + } + return blocks; +} + +/** 解析并加载用户已选配方详情 */ +export async function resolveSelectedRecipeDetail(params: { + skillPackRoot: string; + selectedRecipeRef?: string | null; + skillsRoot?: string; +}): Promise { + const ref = parseSelectedRecipeRef(params.selectedRecipeRef); + if (!ref) return null; + const skillsRoot = params.skillsRoot ?? DEFAULT_SKILLS_ROOT; + const catalog = await loadRecipeCatalog(params.skillPackRoot, skillsRoot); + const entry = findRecipeCatalogEntry(catalog, ref); + if (!entry) return null; + return loadRecipeDetail(params.skillPackRoot, entry, skillsRoot); +} + +export function validateCreationFlow( + flow: CreationFlow, + catalog: ModuleCatalog | null, +): CreationFlowValidation { + const errors: string[] = []; + const ids = flow.steps.map((s) => s.id); + const seenIds = new Set(); + const allowed = catalog + ? new Set(catalog.modules.map((m) => m.name)) + : null; + const nameCount = new Map(); + for (const step of flow.steps) { + nameCount.set(step.name, (nameCount.get(step.name) ?? 0) + 1); + } + + for (let i = 0; i < flow.steps.length; i++) { + const step = flow.steps[i]!; + if (seenIds.has(step.id)) { + errors.push(`步骤 id「${step.id}」重复`); + } + seenIds.add(step.id); + + if (allowed && !allowed.has(step.name)) { + errors.push(`「${step.name}」不在模块目录中`); + } + + const mod = catalog?.modules.find((m) => m.name === step.name); + if ( + catalog && + (nameCount.get(step.name) ?? 0) > 1 && + mod && + mod.repeatable !== true + ) { + errors.push( + `「${step.name}」出现多次,但目录未标 repeatable(仅可反复能力可同名多次)`, + ); + } + + for (const dep of step.depends_on) { + const depStep = findStepByRef(flow, dep); + if (!depStep) { + errors.push(`「${step.id}」依赖「${dep}」,但流程中没有该步骤`); + continue; + } + const depIndex = flow.steps.findIndex((s) => s.id === depStep.id); + if (depIndex >= i) { + errors.push( + `「${step.id}」依赖「${depStep.id}」,但「${depStep.id}」未排在其前面`, + ); + } + } + } + + return { ok: errors.length === 0, errors }; +} + +export function artifactTagForStep( + name: string, + catalog: ModuleCatalog | null, +): string | null { + return catalog?.modules.find((m) => m.name === name)?.artifact ?? null; +} + +export function formatCreationFlowForUser( + flow: CreationFlow, + catalog?: ModuleCatalog | null, +): CreationFlowUserView { + const decl = new Map( + (catalog?.modules ?? []).map((m) => [m.name, m] as const), + ); + const seenName = new Map(); + return { + brief: flow.brief, + status: flow.status, + steps: flow.steps.map((s, i) => { + const n = (seenName.get(s.name) ?? 0) + 1; + seenName.set(s.name, n); + const mod = decl.get(s.name); + return { + order: i + 1, + id: s.id, + name: s.name, + depends_on: s.depends_on, + occurrence: n, + declaration: mod?.declaration, + repeatable: mod?.repeatable, + }; + }), + }; +} + +export function findModuleByName( + catalog: ModuleCatalog | null | undefined, + name: string, +): ModuleCatalogEntry | null { + if (!catalog) return null; + return catalog.modules.find((m) => m.name === name) ?? null; +} + +/** 已验收步骤名列表(JSON 数组或换行文本) */ +export function parseAcceptedSteps(raw: unknown): string[] { + if (Array.isArray(raw)) { + return raw.map((x) => String(x).trim()).filter(Boolean); + } + if (typeof raw !== "string" || !raw.trim()) return []; + try { + const doc = JSON.parse(raw); + if (Array.isArray(doc)) { + return doc.map((x) => String(x).trim()).filter(Boolean); + } + } catch { + /* fall through */ + } + return raw + .split(/[\n,]/) + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * 某步是否已验收:认 step.id;兼容旧会话只记了中文 name(且当时 name 唯一)。 + */ +export function isStepAccepted( + step: CreationFlowStep, + acceptedStepIds: readonly string[], +): boolean { + const done = new Set(acceptedStepIds); + if (done.has(step.id)) return true; + // 旧稿:验收列表里是中文名,且 id 就是 name + if (step.id === step.name && done.has(step.name)) return true; + return false; +} + +/** + * 流程中下一个待做步骤:未验收,且 depends_on 均已验收。 + */ +export function nextPendingStep( + flow: CreationFlow | null, + acceptedStepIds: readonly string[], +): CreationFlowStep | null { + if (!flow?.steps.length) return null; + for (const step of flow.steps) { + if (isStepAccepted(step, acceptedStepIds)) continue; + const depsOk = step.depends_on.every((dep) => { + const depStep = findStepByRef(flow, dep); + if (!depStep) return false; + return isStepAccepted(depStep, acceptedStepIds); + }); + if (depsOk) return step; + } + return null; +} + +/** 当前已列出的步骤是否都已验收(不管 status) */ +export function areListedStepsAccepted( + flow: CreationFlow | null, + acceptedStepIds: readonly string[], +): boolean { + if (!flow?.steps.length) return false; + return flow.steps.every((s) => isStepAccepted(s, acceptedStepIds)); +} + +/** + * 流程是否收束完毕:listed steps 全验收,且 status 非 open。 + * status=open 或缺省但还要扩步 → 应再调 design-flow。 + * 缺省 status:兼容旧固定 DAG,视为 closed。 + */ +export function isCreationFlowComplete( + flow: CreationFlow | null, + acceptedStepIds: readonly string[], +): boolean { + if (!areListedStepsAccepted(flow, acceptedStepIds)) return false; + if (flow?.status === "open") return false; + return true; +} + +/** + * 当前步骤做完、但 DAG 仍 open → 需要再编排(追加 / 关闭)。 + */ +export function needsFlowExpansion( + flow: CreationFlow | null, + acceptedStepIds: readonly string[], +): boolean { + if (!flow?.steps.length) return true; + if (nextPendingStep(flow, acceptedStepIds)) return false; + return flow.status === "open"; +} + +export async function loadModulePrompt( + skillPackRoot: string, + moduleId: string, + skillsRoot = DEFAULT_SKILLS_ROOT, +): Promise { + const fullPath = path.join( + skillsRoot, + skillPackRoot, + "modules", + moduleId, + "prompt.md", + ); + try { + return await readFile(fullPath, "utf8"); + } catch { + return null; + } +} + +/** 依赖步骤 → 产物 tag(执行期注入;按依赖步的能力 name 映射) */ +export function dependencyArtifactTags( + step: CreationFlowStep, + catalog: ModuleCatalog | null, + flow?: CreationFlow | null, +): string[] { + if (!catalog) return []; + const tags: string[] = []; + for (const dep of step.depends_on) { + const depName = flow + ? findStepByRef(flow, dep)?.name ?? dep + : dep; + const art = artifactTagForStep(depName, catalog); + if (art) tags.push(art); + } + return [...new Set(tags)]; +} + +export type DesignStepBinding = { + step: CreationFlowStep; + module: ModuleCatalogEntry; + depTags: string[]; + modulePrompt: string; + /** 程序开场白;无则本步直接调 LLM */ + opening: string | null; +}; + +/** + * 解析 design-step 本轮绑定:当前步骤、模块 prompt、依赖 tag、产物 tag。 + */ +export async function resolveDesignStepBinding(params: { + skillPackRoot: string; + flowRaw: string | null | undefined; + currentStepName?: string | null; + acceptedStepNames?: readonly string[]; + skillsRoot?: string; +}): Promise { + const skillsRoot = params.skillsRoot ?? DEFAULT_SKILLS_ROOT; + const catalog = await loadModuleCatalog(params.skillPackRoot, skillsRoot); + const flow = parseCreationFlow(params.flowRaw); + if (!catalog || !flow) return null; + + const accepted = params.acceptedStepNames ?? []; + let step: CreationFlowStep | null = null; + const named = params.currentStepName?.trim(); + if (named) { + step = findStepByRef(flow, named); + } + if (!step) { + step = nextPendingStep(flow, accepted); + } + if (!step) return null; + + const module = findModuleByName(catalog, step.name); + if (!module) return null; + + const modulePromptRaw = + (await loadModulePrompt(params.skillPackRoot, module.id, skillsRoot)) ?? + `# ${module.name}\n\n(模块 prompt.md 缺失,请补充 skills/.../modules/${module.id}/prompt.md)`; + + const opening = + module.opening?.trim() || extractModuleOpening(modulePromptRaw) || null; + + return { + step, + module, + depTags: dependencyArtifactTags(step, catalog, flow), + modulePrompt: formatModulePromptForLlm(modulePromptRaw), + opening, + }; +} diff --git a/src/skills/creation-units.ts b/src/skills/creation-units.ts new file mode 100644 index 0000000..065fd8c --- /dev/null +++ b/src/skills/creation-units.ts @@ -0,0 +1,694 @@ +/** + * 创作单位:与 run worker 调度正交。 + * + * 两大族(同级): + * - worker:独立 LLM 工序 + * - fixed:已写入规格的固定上下文(不上 worker ≠ 不重要) + * + * FIXED_CONTEXT_CATALOG = 给 design 的「可向用户询问的示例话题」提示目录, + * 不是填空表;默认 listCreationUnits 只列出**已有内容**的固定块。 + * + * 见 docs/design-orchestrator-guide.md §6、§7.2 + */ +import type { ParsedWorkerSet } from "./worker-set-parse.js"; +import { parseResidentContext } from "./resident-context.js"; + +export const WORKER_SET_FINAL_TAG = "设计.worker集"; +export const WORKER_SET_DRAFT_TAG = "设计.worker集.草稿"; +export const CREATION_CURRENT_UNIT_TAG = "创作.当前单位"; +export const CREATION_ACCEPTED_UNITS_TAG = "创作.已验收单位"; +/** 各单位最后一次验收时的内容切片(JSON store → 拼装时格式化为前情提要) */ +export const CREATION_ACCEPTED_CONTENT_TAG = "创作.已验收内容"; + +export const SLOT_CREATION_CURRENT_UNIT = "creationCurrentUnitId"; +export const SLOT_CREATION_ACCEPTED_UNITS = "creationAcceptedUnits"; +export const SLOT_CREATION_UNIT_ANCHOR_AT = "creationUnitAnchorAt"; + +/** 创作阶段磁盘 skill(新流程:编排 + 按步执行) */ +export const DESIGN_DISK_WORKERS = [ + "design-flow", + "design-step", +] as const; + +export type DesignDiskWorkerId = (typeof DESIGN_DISK_WORKERS)[number]; + +/** @deprecated 旧分步 skill;已废弃,仅兼容读旧会话 */ +const LEGACY_DESIGN_DISK_WORKERS = [ + "design-core", + "design-worker", + "design-fixed", + "design-refine", + "design-intake", +] as const; + +/** 含历史 id,便于读旧会话 */ +export function isDesignDiskWorker(workerId: string): boolean { + const id = workerId.trim(); + return ( + (DESIGN_DISK_WORKERS as readonly string[]).includes( + id as (typeof DESIGN_DISK_WORKERS)[number], + ) || + (LEGACY_DESIGN_DISK_WORKERS as readonly string[]).includes( + id as (typeof LEGACY_DESIGN_DISK_WORKERS)[number], + ) + ); +} + +/** 新流程不再注入 design-common.md */ +export function usesDesignCommon(_workerId: string): boolean { + return false; +} + +/** worker = 工序;fixed = 固定上下文;phase = A/C 阶段单位 */ +export type CreationUnitKind = "worker" | "fixed" | "phase"; + +/** + * 固定上下文示例话题 → 规格落点(提示用,不是必填问卷)。 + * 仅当草稿里已有对应内容时,才作为创作单位列出(除非显式 includeCatalogFixed)。 + */ +export type FixedContextFlavor = + | "interaction" + | "narrative_guide" + | "aesthetics" + | "input_protocol" + | "core_premises" + | "resident"; + +export type CreationUnitView = { + id: string; + kind: CreationUnitKind; + label: string; + /** 固定上下文族内的细分;worker / phase 无此字段 */ + flavor?: FixedContextFlavor; + /** 规格里是否已有实质内容 */ + filled: boolean; + /** session 是否已验收本单位 */ + accepted?: boolean; + /** 是否为当前正在谈的单位 */ + current?: boolean; + /** + * 创作时是否应优先谈清(纲领/范式类默认真)。 + * 杂项 resident 可为 false,但不代表可忽略——由体验决定。 + */ + weighty?: boolean; + detail?: string; +}; + +/** 示例话题目录:告诉 design 可以问用户类似内容;非 UI 填空项 */ +export const FIXED_CONTEXT_CATALOG: Array<{ + id: string; + flavor: FixedContextFlavor; + label: string; + weighty: boolean; + hint: string; +}> = [ + { + id: "fixed:interaction", + flavor: "interaction", + label: "交互范式", + weighty: true, + hint: "站位、系统扮演、输出形态、与用户怎么轮转(旧称交互骨架;现多由 phase:core 收)", + }, + { + id: "fixed:narrative_guide", + flavor: "narrative_guide", + label: "叙事指南", + weighty: true, + hint: "世界态度与体验边界(残酷/不有求必应/随机危险等);≠ 文风", + }, + { + id: "fixed:aesthetics", + flavor: "aesthetics", + label: "美学纲领", + weighty: true, + hint: "可读终稿的呈现气质;转述 presentation 或常驻美学块", + }, + { + id: "fixed:input_protocol", + flavor: "input_protocol", + label: "输入协议", + weighty: true, + hint: "() 元要求、\"\" 对白、无包裹=事实等", + }, + { + id: "fixed:core_premises", + flavor: "core_premises", + label: "核心实现前提", + weighty: true, + hint: "不能瞎发挥又关键的硬前提", + }, +]; + +/** @deprecated 旧 id;读进度时兼容 */ +const LEGACY_UNIT_ALIASES: Record = { + "skeleton:interaction": "fixed:interaction", + "fixed:interaction": "phase:core", +}; + +export function normalizeCreationUnitId(id: string): string { + let cur = id; + const seen = new Set(); + while (LEGACY_UNIT_ALIASES[cur] && !seen.has(cur)) { + seen.add(cur); + cur = LEGACY_UNIT_ALIASES[cur]!; + } + return cur; +} + +/** 验收判定:旧 fixed:interaction 与 phase:core 互通 */ +function unitIdMatches(candidate: string, target: string): boolean { + const a = normalizeCreationUnitId(candidate); + const b = normalizeCreationUnitId(target); + if (a === b) return true; + // 双向:未规范化的旧 id 也要对上 + if ( + (candidate === "fixed:interaction" || candidate === "phase:core") && + (target === "fixed:interaction" || target === "phase:core") + ) { + return true; + } + return false; +} + +function workerLabel( + ref: string | null, + name?: string, + role?: string, + duty?: string, +): string { + if (name?.trim()) return name.trim(); + // role 若是 taxonomy(core/auxiliary…)不当作展示名 + const roleTrim = role?.trim(); + if (roleTrim && !/^(core|auxiliary|transcription)$/i.test(roleTrim)) { + return roleTrim; + } + if (ref?.trim()) return ref.trim(); + if (duty?.trim()) return duty.trim().slice(0, 40); + return "(未命名 worker)"; +} + +function workerFilled(entry: { + ref: string | null; + duty?: string; + rationale?: string; + gap?: string | null; +}): boolean { + if (entry.gap) return false; + return Boolean(entry.ref?.trim() && (entry.duty?.trim() || entry.rationale?.trim())); +} + +function interactionFilled(parsed: ParsedWorkerSet): boolean { + const i = parsed.interaction; + if (!i) return false; + return Boolean( + String(i.user_stance ?? "").trim() && + String(i.system_role ?? "").trim() && + String(i.output ?? "").trim(), + ); +} + +function experienceCheckFilled(parsed: ParsedWorkerSet): boolean { + const e = parsed.experience_check; + if (!e || typeof e !== "object") return false; + return Object.values(e).some((v) => typeof v === "string" && v.trim()); +} + +function corePhaseFilled(parsed: ParsedWorkerSet): boolean { + return interactionFilled(parsed) || experienceCheckFilled(parsed); +} + +function refinePhaseFilled(parsed: ParsedWorkerSet): boolean { + const tables = parsed.tables; + if (!tables || typeof tables !== "object") return false; + const schemas = (tables as { schemas?: unknown }).schemas; + const effects = (tables as { side_effects?: unknown }).side_effects; + return ( + (Array.isArray(schemas) && schemas.length > 0) || + (Array.isArray(effects) && effects.length > 0) + ); +} + +function textFilled(v: unknown): boolean { + return typeof v === "string" && v.trim().length > 0; +} + +function aestheticsFilled(parsed: ParsedWorkerSet): boolean { + for (const w of parsed.workers) { + const p = w.presentation; + if (!p || typeof p !== "object") continue; + if (Object.values(p).some((x) => (typeof x === "string" ? x.trim() : x != null))) { + return true; + } + } + // 仅显式美学 id,不把普通 tone/文风常驻当成「美学纲领」填空项 + const residents = parseResidentContext(parsed.resident_context); + return residents.some((r) => /^(美学|aesthetics|presentation)$/i.test(r.id)); +} + +function inputProtocolFilled(parsed: ParsedWorkerSet): boolean { + const p = parsed.input_protocol; + if (!p || typeof p !== "object") return false; + return Object.values(p).some((v) => typeof v === "string" && v.trim()); +} + +function corePremisesFilled(parsed: ParsedWorkerSet): boolean { + return (parsed.core_premises?.length ?? 0) > 0; +} + +function fixedFilled(flavor: FixedContextFlavor, parsed: ParsedWorkerSet): boolean { + switch (flavor) { + case "interaction": + return interactionFilled(parsed); + case "narrative_guide": + return textFilled(parsed.narrative_guide); + case "aesthetics": + return aestheticsFilled(parsed); + case "input_protocol": + return inputProtocolFilled(parsed); + case "core_premises": + return corePremisesFilled(parsed); + default: + return false; + } +} + +function annotate( + unit: CreationUnitView, + accepted: Set, + current: string | null, +): CreationUnitView { + const acceptedHit = [...accepted].some((id) => unitIdMatches(id, unit.id)); + const currentHit = + current != null && + (unitIdMatches(current, unit.id) || current === unit.id); + return { + ...unit, + accepted: acceptedHit, + current: currentHit, + }; +} + +export type ListCreationUnitsOptions = { + acceptedUnitIds?: string[]; + currentUnitId?: string | null; + /** + * true:列出全部示例话题(调试用)。 + * 默认 false:只列草稿里**已有内容**的固定块 + workers + resident——避免当成填空表。 + */ + includeCatalogFixed?: boolean; +}; + +/** + * 列出创作单位:已写出的固定上下文 + workers + resident(同级)。 + * 空的示例话题默认不出现在列表里。 + */ +export function listCreationUnits( + parsed: ParsedWorkerSet | null | undefined, + options: ListCreationUnitsOptions = {}, +): CreationUnitView[] { + if (!parsed) return []; + const accepted = new Set( + (options.acceptedUnitIds ?? []).map(normalizeCreationUnitId), + ); + const currentRaw = options.currentUnitId?.trim() || null; + const current = currentRaw ? normalizeCreationUnitId(currentRaw) : null; + const includeCatalog = options.includeCatalogFixed === true; + const units: CreationUnitView[] = []; + + const coreFilled = corePhaseFilled(parsed); + if (includeCatalog || coreFilled) { + units.push( + annotate( + { + id: "phase:core", + kind: "phase", + label: "A · 核心", + filled: coreFilled, + weighty: true, + detail: "站位 / 系统扮演 / 体验骨架", + }, + accepted, + current, + ), + ); + } + + for (const cat of FIXED_CONTEXT_CATALOG) { + // interaction 已并入 phase:core,避免重复列出 + if (cat.id === "fixed:interaction") continue; + const filled = fixedFilled(cat.flavor, parsed); + if (!includeCatalog && !filled) continue; + units.push( + annotate( + { + id: cat.id, + kind: "fixed", + flavor: cat.flavor, + label: cat.label, + filled, + weighty: cat.weighty, + detail: cat.hint, + }, + accepted, + current, + ), + ); + } + + for (let i = 0; i < parsed.workers.length; i++) { + const w = parsed.workers[i]!; + const ref = w.ref?.trim() || null; + const id = ref ? `worker:${ref}` : `worker:#${i + 1}`; + units.push( + annotate( + { + id, + kind: "worker", + label: workerLabel(ref, w.name, w.role, w.duty), + filled: workerFilled(w), + weighty: true, + detail: w.duty?.trim() || w.rationale?.trim(), + }, + accepted, + current, + ), + ); + } + + const residents = parseResidentContext(parsed.resident_context); + for (const r of residents) { + // 已由 aesthetics 启发式覆盖的 tone 类仍单独列出(挂载/正文可单独验收) + const id = `resident:${r.id}`; + units.push( + annotate( + { + id, + kind: "fixed", + flavor: "resident", + label: r.id, + filled: Boolean(r.content.trim()), + weighty: false, + detail: r.content.trim().slice(0, 80), + }, + accepted, + current, + ), + ); + } + + const refineFilled = refinePhaseFilled(parsed); + if (includeCatalog || refineFilled) { + units.push( + annotate( + { + id: "phase:refine", + kind: "phase", + label: "C · 细化", + filled: refineFilled, + weighty: true, + detail: "表 / 副作用 / 数据拓扑", + }, + accepted, + current, + ), + ); + } + + return units; +} + +/** + * 下一个未验收单位(软启发,非硬闸): + * 核心 → 已开写的固定块 → 纲领类固定上下文 → worker → 其余 → 细化 + * + * 固定上下文先于 worker:便于同一份风格/叙事/美学挂到多个 worker, + * 避免「先写完 worker 再逐个填上下文」。 + * 已开写的 fixed/phase 仍优先续完(filled pending)。 + */ +export function nextCreationUnitId( + parsed: ParsedWorkerSet | null | undefined, + acceptedUnitIds: string[] = [], +): string | null { + if (!parsed) return "phase:core"; + const units = listCreationUnits(parsed, { + acceptedUnitIds, + includeCatalogFixed: true, + }); + const core = units.find((u) => u.id === "phase:core"); + if (core && !core.accepted) return "phase:core"; + + const filledFixedPending = units.find( + (u) => + (u.kind === "fixed" || u.kind === "phase") && + u.id !== "phase:core" && + !u.accepted && + u.filled, + ); + if (filledFixedPending) return filledFixedPending.id; + + const foundationFixed = units.find( + (u) => u.kind === "fixed" && u.weighty && !u.accepted, + ); + if (foundationFixed) return foundationFixed.id; + + const filledWorkerPending = units.find( + (u) => u.kind === "worker" && !u.accepted && u.filled, + ); + if (filledWorkerPending) return filledWorkerPending.id; + + const worker = units.find((u) => u.kind === "worker" && !u.accepted); + if (worker) return worker.id; + + const otherFixed = units.find( + (u) => u.kind === "fixed" && !u.weighty && !u.accepted, + ); + if (otherFixed) return otherFixed.id; + + const refine = units.find((u) => u.id === "phase:refine"); + if (refine && !refine.accepted) return "phase:refine"; + + return null; +} + +export function parseAcceptedUnits(raw: unknown): string[] { + if (Array.isArray(raw)) { + return raw.map((x) => String(x).trim()).filter(Boolean).map(normalizeCreationUnitId); + } + if (typeof raw === "string" && raw.trim()) { + try { + const doc = JSON.parse(raw) as unknown; + if (Array.isArray(doc)) { + return doc.map((x) => String(x).trim()).filter(Boolean).map(normalizeCreationUnitId); + } + } catch { + return raw + .split(/[,,\n]/) + .map((s) => s.trim()) + .filter(Boolean) + .map(normalizeCreationUnitId); + } + } + return []; +} + +export function isFinalWorkerSetArtifact(artifact: { + workerId: string; + outputTags: string[]; +}): boolean { + return artifact.outputTags.includes(WORKER_SET_FINAL_TAG); +} + +/** 创作磁盘 skill 产出但尚未写终稿 tag → 单位验收 */ +export function isDesignUnitArtifact(artifact: { + workerId: string; + outputTags: string[]; +}): boolean { + return ( + isDesignDiskWorker(artifact.workerId) && !isFinalWorkerSetArtifact(artifact) + ); +} + +/** 总管选 skill:有流程待执行 → design-step;否则 design-flow */ +export function designWorkerForUnit(unitId: string | null | undefined): DesignDiskWorkerId { + const id = (unitId ?? "").trim(); + if (id === "flow" || !id) return "design-flow"; + return "design-step"; +} + +export type AcceptedUnitContentEntry = { + unitId: string; + acceptedAt: string; + summary?: string; + /** 该单位验收时的内容切片 */ + content: unknown; +}; + +export type AcceptedContentStore = { + version: 1; + units: Record; +}; + +export function parseAcceptedContentStore(raw: unknown): AcceptedContentStore { + if (typeof raw === "string" && raw.trim()) { + try { + return parseAcceptedContentStore(JSON.parse(raw) as unknown); + } catch { + return { version: 1, units: {} }; + } + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return { version: 1, units: {} }; + } + const row = raw as Record; + const unitsRaw = row.units; + const units: Record = {}; + if (unitsRaw && typeof unitsRaw === "object" && !Array.isArray(unitsRaw)) { + for (const [k, v] of Object.entries(unitsRaw as Record)) { + if (!v || typeof v !== "object" || Array.isArray(v)) continue; + const e = v as Record; + const unitId = normalizeCreationUnitId( + typeof e.unitId === "string" ? e.unitId : k, + ); + units[unitId] = { + unitId, + acceptedAt: + typeof e.acceptedAt === "string" ? e.acceptedAt : new Date().toISOString(), + summary: typeof e.summary === "string" ? e.summary : undefined, + content: e.content, + }; + } + } + return { version: 1, units }; +} + +/** 从草稿抽出某一创作单位在验收时的内容 */ +export function extractUnitContentFromDraft( + parsed: ParsedWorkerSet | null | undefined, + unitId: string, +): unknown | null { + if (!parsed) return null; + const id = normalizeCreationUnitId(unitId.trim()); + if (!id) return null; + + if (id === "phase:core") { + const slice: Record = {}; + if (parsed.interaction) slice.interaction = parsed.interaction; + if (parsed.experience_check) slice.experience_check = parsed.experience_check; + return Object.keys(slice).length ? slice : null; + } + if (id === "phase:refine") { + return parsed.tables ? { tables: parsed.tables } : null; + } + if (id.startsWith("worker:")) { + const ref = id.slice("worker:".length); + const entry = parsed.workers.find((w) => (w.ref?.trim() || "") === ref); + return entry ?? null; + } + if (id === "fixed:narrative_guide") { + return textFilled(parsed.narrative_guide) + ? { narrative_guide: parsed.narrative_guide } + : null; + } + if (id === "fixed:input_protocol") { + return parsed.input_protocol ? { input_protocol: parsed.input_protocol } : null; + } + if (id === "fixed:core_premises") { + return (parsed.core_premises?.length ?? 0) > 0 + ? { core_premises: parsed.core_premises } + : null; + } + if (id === "fixed:aesthetics") { + const presentations = parsed.workers + .filter((w) => w.presentation) + .map((w) => ({ ref: w.ref, presentation: w.presentation })); + return presentations.length ? { presentations } : null; + } + if (id === "fixed:interaction") { + return parsed.interaction ? { interaction: parsed.interaction } : null; + } + if (id.startsWith("resident:")) { + const rid = id.slice("resident:".length); + const residents = parseResidentContext(parsed.resident_context); + const hit = residents.find((r) => r.id === rid); + return hit ?? null; + } + return null; +} + +/** 写入/覆盖某一单位的最后验收内容 */ +export function upsertAcceptedUnitContent( + existingRaw: unknown, + entry: { + unitId: string; + content: unknown; + summary?: string; + acceptedAt?: string; + }, +): string { + const store = parseAcceptedContentStore(existingRaw); + const unitId = normalizeCreationUnitId(entry.unitId); + store.units[unitId] = { + unitId, + acceptedAt: entry.acceptedAt ?? new Date().toISOString(), + summary: entry.summary, + content: entry.content, + }; + return JSON.stringify(store, null, 2); +} + +/** 前情提要:把已验收单位内容格式化为只读 Markdown */ +export function formatAcceptedContentForPrompt(raw: unknown): string { + const store = parseAcceptedContentStore(raw); + const entries = Object.values(store.units).sort((a, b) => + a.acceptedAt.localeCompare(b.acceptedAt), + ); + if (entries.length === 0) { + return "(尚无已验收单位)"; + } + return entries + .map((e) => { + const head = `### ${e.unitId}${e.summary ? ` · ${e.summary}` : ""}`; + const meta = `验收于 ${e.acceptedAt} · **只读,勿擅自改写**`; + const body = + typeof e.content === "string" + ? e.content + : JSON.stringify(e.content ?? null, null, 2); + return `${head}\n${meta}\n\n\`\`\`json\n${body}\n\`\`\``; + }) + .join("\n\n"); +} + +/** + * 完整 Worker 集是否可交终稿。 + * 具名 weighty 固定单位:已填的必须已验收;至少 1 个 filled worker 已验收。 + */ +export function isWorkerSetReadyForFinal( + parsed: ParsedWorkerSet | null | undefined, + acceptedUnitIds: string[], +): { ready: boolean; missing: string[] } { + if (!parsed) return { ready: false, missing: ["(无草稿)"] }; + const accepted = acceptedUnitIds.map(normalizeCreationUnitId); + const units = listCreationUnits(parsed, { acceptedUnitIds: accepted }); + const missing = units + .filter((u) => u.weighty && u.filled && !u.accepted) + .map((u) => u.id); + const acceptedWorkers = units.filter( + (u) => u.kind === "worker" && u.accepted && u.filled, + ); + const interaction = units.find((u) => u.id === "fixed:interaction"); + const core = units.find((u) => u.id === "phase:core"); + if (core && core.filled && !core.accepted) { + return { ready: false, missing: missing.length ? missing : [core.id] }; + } + if (interaction && !interaction.accepted && interaction.filled) { + return { ready: false, missing: missing.length ? missing : [interaction.id] }; + } + if (acceptedWorkers.length === 0) { + return { + ready: false, + missing: missing.length ? missing : ["(至少一个 worker 单位)"], + }; + } + if (missing.length) return { ready: false, missing }; + return { ready: true, missing: [] }; +} diff --git a/src/skills/declared-worker.ts b/src/skills/declared-worker.ts new file mode 100644 index 0000000..dcacd3d --- /dev/null +++ b/src/skills/declared-worker.ts @@ -0,0 +1,260 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { parse as parseYaml } from "yaml"; +import type { Blackboard } from "../blackboard/blackboard.js"; +import type { RuntimeSession } from "../types/runtime.js"; +import type { AcceptanceMode } from "../types/runtime.js"; +import type { ParsedWorkerSkill } from "./types.js"; +import { + parseWorkerSetYaml, + type ParsedWorkerSet, + type WorkerSetEntry, +} from "./worker-set-parse.js"; +import { + buildInstanceWorkerDeclaration, + inferLifecycleStage, + readWorkerSetYamlForDeclaration, +} from "./worker-declaration.js"; +import { loadWorkerSkill, SKILLS_ROOT } from "./loader.js"; +import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js"; +import { isDesignDiskWorker } from "./creation-units.js"; +import { + entriesForWorker, + formatResidentPromptSection, + parseResidentContext, + residentTagFor, +} from "./resident-context.js"; + +type WorkerTemplateDoc = { + id?: string; + label?: string; + duty?: string; + prompt_excerpt?: string; + suggested_context?: { static?: string[]; dynamic?: string[] }; + suggested_outputs?: string[]; +}; + +/** + * 解析本次 worker 的验收模式(创作 / run 共用入口)。 + * - design-* / opening-generator 磁盘创作 worker → 始终 user_confirmed + * - Worker 集 acceptance: review → user_confirmed;continue → no_confirmation + * - 未声明 acceptance:design 生命周期默认确认;play 缺省按 review(稳妥) + */ +export function resolveAcceptanceModeForWorker(params: { + session: RuntimeSession; + blackboard: Blackboard; + workerId: string; +}): AcceptanceMode { + const workerId = params.workerId.trim(); + if (isDesignDiskWorker(workerId) || workerId === "opening-generator") { + return "user_confirmed"; + } + + const decl = buildInstanceWorkerDeclaration( + params.session, + params.blackboard, + inferLifecycleStage(params.session), + ); + const entry = decl.parsed?.workers.find((w) => w.ref?.trim() === workerId); + if (entry?.acceptance === "continue") return "no_confirmation"; + if (entry?.acceptance === "review") return "user_confirmed"; + + // 未写明:创作阶段默认验收;游玩缺省也验收(避免静默连跑) + return "user_confirmed"; +} + +/** 从声明 + 可选模板构建可执行 worker;有磁盘 SKILL 时优先磁盘(design-intake) */ +export async function resolveRunnableWorker(params: { + skillPackName: string; + workerId: string; + session: RuntimeSession; + blackboard: Blackboard; + skillsRoot?: string; +}): Promise<{ + worker: ParsedWorkerSkill; + promptBody: string; + source: "disk" | "declaration"; +}> { + const root = params.skillsRoot ?? SKILLS_ROOT; + try { + const { loadWorkerSkillWithContext } = await import("./loader.js"); + const flowRaw = params.blackboard.getContentByTag("设计.创作流程"); + const currentStepName = params.blackboard.getContentByTag("创作.当前步骤"); + const selectedRecipeRef = params.blackboard.getContentByTag("创作.选用配方"); + const acceptedRaw = + params.session.slots?.creationAcceptedUnits ?? + params.blackboard.getContentByTag("创作.已验收单位"); + const { parseAcceptedSteps } = await import("./creation-flow.js"); + const withCtx = await loadWorkerSkillWithContext( + params.skillPackName, + params.workerId, + root, + { + flowRaw, + currentStepName, + acceptedStepNames: parseAcceptedSteps(acceptedRaw), + selectedRecipeRef, + }, + ); + return { + worker: withCtx.worker, + promptBody: withCtx.promptBody, + source: "disk", + }; + } catch { + // fall through to declaration + } + + const raw = readWorkerSetYamlForDeclaration(params.blackboard, params.session); + const parsed = raw ? parseWorkerSetYaml(raw.yaml) : null; + const entry = parsed?.workers.find( + (w) => w.ref?.trim() === params.workerId.trim(), + ); + if (!entry?.ref) { + throw new Error( + `未找到 worker「${params.workerId}」的磁盘 SKILL,且 设计.worker集 中无对应声明`, + ); + } + + const template = await loadWorkerTemplate( + params.skillPackName, + entry.ref, + root, + ); + const built = buildDeclaredWorkerSkill({ + skillPackName: params.skillPackName, + entry, + template, + workerSet: parsed, + }); + return { ...built, source: "declaration" }; +} + +async function loadWorkerTemplate( + skillPackName: string, + workerId: string, + skillsRoot: string, +): Promise { + const { loadSkill } = await import("./loader.js"); + let packRoot: string | undefined; + try { + const skill = await loadSkill(skillPackName, skillsRoot); + packRoot = skill.skillPackRoot; + } catch { + return null; + } + if (!packRoot) return null; + const file = path.join( + skillsRoot, + packRoot, + "worker-templates", + `${workerId}.yaml`, + ); + try { + const raw = await readFile(file, "utf8"); + const doc = parseYaml(raw) as WorkerTemplateDoc; + return doc && typeof doc === "object" ? doc : null; + } catch { + return null; + } +} + +export function buildDeclaredWorkerSkill(params: { + skillPackName: string; + entry: WorkerSetEntry; + template: WorkerTemplateDoc | null; + workerSet: ParsedWorkerSet | null; +}): { worker: ParsedWorkerSkill; promptBody: string } { + const id = params.entry.ref!.trim(); + const staticTags = + params.entry.context?.static ?? + params.template?.suggested_context?.static ?? + ["设计.worker集", CONTEXT_BRIEF_TAG]; + const dynamicTags = + params.entry.context?.dynamic ?? + params.template?.suggested_context?.dynamic ?? + ["用户.最新输入"]; + const outputTags = + params.entry.outputs?.length + ? params.entry.outputs + : params.template?.suggested_outputs?.length + ? params.template.suggested_outputs + : ["输出.用户展示"]; + + const resident = parseResidentContext(params.workerSet?.resident_context); + const residentForWorker = entriesForWorker(resident, id); + const residentStaticTags = residentForWorker + .filter((e) => e.position !== "dynamic") + .map(residentTagFor); + const residentDynamicTags = residentForWorker + .filter((e) => e.position === "dynamic") + .map(residentTagFor); + + const inputTags = [ + ...new Set([ + ...staticTags, + ...residentStaticTags, + ...dynamicTags, + ...residentDynamicTags, + CONTEXT_BRIEF_TAG, + ]), + ]; + + const duty = + params.entry.duty?.trim() || + params.template?.duty?.trim() || + `执行 ${id}`; + const excerpt = params.template?.prompt_excerpt?.trim() || ""; + const presentation = params.entry.presentation + ? JSON.stringify(params.entry.presentation, null, 2) + : ""; + const narrative = params.workerSet?.narrative_guide?.trim() || ""; + const premises = (params.workerSet?.core_premises ?? []).filter(Boolean); + const residentSection = formatResidentPromptSection(resident, id); + + const body = [ + `# ${params.template?.label ?? id}`, + "", + "## 角色与职责", + "", + duty, + "", + excerpt ? `## 写法要点\n\n${excerpt}` : "", + presentation ? `## presentation(实例)\n\n\`\`\`json\n${presentation}\n\`\`\`` : "", + narrative ? `## 叙事指南\n\n${narrative}` : "", + premises.length + ? `## 核心实现前提\n\n${premises.map((p) => `- ${p}`).join("\n")}` + : "", + residentSection, + params.entry.rationale + ? `## 为何需要本 worker\n\n${params.entry.rationale}` + : "", + "", + "## 输出", + "", + `写入 outputTags:${outputTags.join("、")}`, + ] + .filter(Boolean) + .join("\n"); + + const worker: ParsedWorkerSkill = { + id, + skill: params.skillPackName, + name: params.template?.label ?? id, + description: duty.slice(0, 200), + version: 1, + inputTags, + outputTags, + inputMerge: "latest", + path: `declaration:${id}`, + body, + }; + + const promptBody = [ + "(本 worker 由 设计.worker集 声明驱动,无独立磁盘 SKILL。)", + "", + body, + ].join("\n"); + + return { worker, promptBody }; +} diff --git a/src/skills/loader.ts b/src/skills/loader.ts index 7f0b34e..91722f7 100644 --- a/src/skills/loader.ts +++ b/src/skills/loader.ts @@ -2,7 +2,7 @@ import { readFile, readdir, stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { parse as parseYaml } from "yaml"; -import type { BookKind } from "../types/runtime.js"; +import type { BookKind, SkillStartupMode } from "../types/runtime.js"; import type { ParsedSkill, ParsedWorkerSkill, @@ -10,13 +10,17 @@ import type { SkillWorkerLlmBindings, StartupInquiry, } from "./types.js"; +import { parseContextSegments } from "./context-segments.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const SKILLS_ROOT = path.resolve(__dirname, "../../skills"); export const ORCHESTRATOR_FILENAME = "orchestrator.md"; export const WORKER_SKILL_FILENAME = "SKILL.md"; +/** 部分环境写 SKILL.md 会损坏非 ASCII;允许同目录 body.md 作为回退 */ +export const WORKER_SKILL_FALLBACK_FILENAME = "body.md"; export const DEFAULT_SHARED_CONTEXT_FILENAME = "shared-context.md"; +export const DESIGN_COMMON_FILENAME = "design-common.md"; export const LLM_BINDINGS_FILENAME = "llm-bindings.yaml"; /** 按 Book 形态分文件夹;skill 可为平铺 .md 或 {name}/orchestrator.md 包 */ @@ -28,11 +32,10 @@ type RegistryDoc = { >; }; -/** 解析 YAML frontmatter(仅支持本项目用到的简单字段) */ -function parseFrontmatter(raw: string): { - meta: Record; - body: string; -} { +type FrontmatterMeta = Record; + +/** 解析 YAML frontmatter(使用 yaml 包,支持折叠标量与列表) */ +function parseFrontmatter(raw: string): { meta: FrontmatterMeta; body: string } { if (!raw.startsWith("---")) { return { meta: {}, body: raw }; } @@ -40,44 +43,17 @@ function parseFrontmatter(raw: string): { if (end === -1) { return { meta: {}, body: raw }; } - const yaml = raw.slice(3, end).trim(); + const yamlText = raw.slice(3, end).trim(); const body = raw.slice(end + 4).trim(); - const meta: Record = {}; - - let currentKey = ""; - let listItems: string[] = []; - let inList = false; - - const flushList = () => { - if (inList && currentKey) { - meta[currentKey] = listItems; - listItems = []; - inList = false; - } - }; - - for (const line of yaml.split("\n")) { - const listMatch = line.match(/^\s+-\s+(.+)$/); - if (listMatch && inList) { - listItems.push(listMatch[1].trim()); - continue; - } - flushList(); - const kv = line.match(/^([\w-]+):\s*(.*)$/); - if (!kv) continue; - const [, key, value] = kv; - currentKey = key; - if (value === "" || value === ">-" || value === "|") { - inList = true; - listItems = []; - } else if (value === ">-" || value.startsWith(">")) { - meta[key] = value; - } else { - meta[key] = value.trim(); - inList = false; + let meta: FrontmatterMeta = {}; + try { + const parsed = parseYaml(yamlText); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + meta = parsed as FrontmatterMeta; } + } catch { + meta = {}; } - flushList(); return { meta, body }; } @@ -119,14 +95,17 @@ function parseStartupInquiry(section: string): StartupInquiry { return { prompt, targetKey, requiredFields: required, optionalFields: optional }; } -function metaString(meta: Record, key: string): string { +function metaString(meta: FrontmatterMeta, key: string): string { const v = meta[key]; - return typeof v === "string" ? v : ""; + if (typeof v === "string") return v; + if (typeof v === "number") return String(v); + return ""; } -function metaStringArray(meta: Record, key: string): string[] { +function metaStringArray(meta: FrontmatterMeta, key: string): string[] { const v = meta[key]; - return Array.isArray(v) ? v : []; + if (!Array.isArray(v)) return []; + return v.map((item) => String(item)); } function parseBookKind(value: string): BookKind | undefined { @@ -147,7 +126,7 @@ function skillPackRootFromPath(relativePath: string): string | undefined { return undefined; } -function workerIdsFromMeta(meta: Record): string[] { +function workerIdsFromMeta(meta: FrontmatterMeta): string[] { const workers = metaStringArray(meta, "workers"); if (workers.length > 0) return workers; return metaStringArray(meta, "suggestedWorkers"); @@ -227,7 +206,10 @@ async function parseSkillFile( const fullPath = path.join(skillsRoot, relativePath); const raw = await readFile(fullPath, "utf8"); const { meta, body } = parseFrontmatter(raw); - const startupSection = extractSection(body, "启动询问"); + const startupSection = + extractSection(body, "启动询问") || + extractSection(body, "启动(agent-first)") || + extractSection(body, "启动"); const folderBookKind = bookKindFromRelativePath(relativePath); const category = metaString(meta, "category") || folderBookKind || "custom"; const bookKind = @@ -248,19 +230,35 @@ async function parseSkillFile( bookKind, path: normalizedPath, skillPackRoot: packRoot, - version: typeof meta.version === "number" ? meta.version : Number(meta.version) || 1, + version: + typeof meta.version === "number" + ? meta.version + : Number(metaString(meta, "version")) || 1, defaultFlowId: metaString(meta, "defaultFlowId") || undefined, suggestedWorkers: workerIdsFromMeta(meta), tags: metaStringArray(meta, "tags"), sharedContextPath: resolveSharedContextPath(meta, packRoot), workerLlmBindings, - startupInquiry: parseStartupInquiry(startupSection), + startupInquiry: { + ...parseStartupInquiry(startupSection), + targetKey: + metaString(meta, "demandTag") || + parseStartupInquiry(startupSection).targetKey, + }, + startupMode: parseStartupMode(metaString(meta, "startupMode")), + uiPrompt: metaString(meta, "uiPrompt") || undefined, body, }; } +function parseStartupMode(raw: string): SkillStartupMode | undefined { + if (raw === "agent-first" || raw === "design-intake") return "agent-first"; + if (raw === "intake") return "intake"; + return undefined; +} + function resolveSharedContextPath( - meta: Record, + meta: FrontmatterMeta, packRoot: string | undefined, ): string | undefined { if (!packRoot) return undefined; @@ -291,17 +289,22 @@ async function parseWorkerSkillFile( ? inputMergeRaw : undefined; const llmProfileId = metaString(meta, "llmProfileId") || undefined; + const contextSegments = parseContextSegments(meta.contextSegments); return { id: metaString(meta, "id") || idFromPath, skill: metaString(meta, "skill"), name: metaString(meta, "name") || idFromPath, description: metaString(meta, "description"), - version: typeof meta.version === "number" ? meta.version : Number(meta.version) || 1, + version: + typeof meta.version === "number" + ? meta.version + : Number(metaString(meta, "version")) || 1, inputTags, outputTags, inputMerge, llmProfileId, + contextSegments: contextSegments.length ? contextSegments : undefined, path: normalizedPath, body, }; @@ -394,7 +397,7 @@ export async function loadSkill( return parseSkillFile(relativePath, skillsRoot); } -/** 解析 skill 包内 worker 的 SKILL.md 相对路径 */ +/** 解析 skill 包内 worker 的 SKILL.md(或 body.md 回退)相对路径 */ export async function resolveWorkerSkillPath( skillIdOrName: string, workerId: string, @@ -404,13 +407,25 @@ export async function resolveWorkerSkillPath( if (!skill.skillPackRoot) { return null; } - const relativePath = `${skill.skillPackRoot}/workers/${workerId}/${WORKER_SKILL_FILENAME}`; - try { - await readFile(path.join(skillsRoot, relativePath), "utf8"); - return relativePath; - } catch { - return null; + const base = `${skill.skillPackRoot}/workers/${workerId}`; + for (const filename of [WORKER_SKILL_FILENAME, WORKER_SKILL_FALLBACK_FILENAME]) { + const relativePath = `${base}/${filename}`; + try { + const raw = await readFile(path.join(skillsRoot, relativePath), "utf8"); + // 损坏的 SKILL.md(中文变 ?)时跳过,改用 body.md + if ( + filename === WORKER_SKILL_FILENAME && + /\?\?/.test(raw) && + !/[\u4e00-\u9fff]/.test(raw) + ) { + continue; + } + return relativePath; + } catch { + /* try next */ + } } + return null; } /** 加载 skill 包固定上下文(注入所有 worker prompt 开头) */ @@ -428,18 +443,146 @@ export async function loadSkillSharedContext( } } -/** 加载 worker skill 正文,可选拼接 skill 包固定上下文 */ +/** 创作分步 skill 的共同开头(仅 design-*) */ +export async function loadDesignCommon( + skillIdOrName: string, + skillsRoot = SKILLS_ROOT, +): Promise { + const skill = await loadSkill(skillIdOrName, skillsRoot); + if (!skill.skillPackRoot) return null; + const fullPath = path.join( + skillsRoot, + skill.skillPackRoot, + DESIGN_COMMON_FILENAME, + ); + try { + return await readFile(fullPath, "utf8"); + } catch { + return null; + } +} + +/** 加载 worker skill 正文;design-flow 注入目录;design-step 注入模块 prompt + 动态 tag */ export async function loadWorkerSkillWithContext( skillIdOrName: string, workerId: string, skillsRoot = SKILLS_ROOT, + opts?: { + flowRaw?: string | null; + currentStepName?: string | null; + acceptedStepNames?: readonly string[]; + /** 用户手动选定的配方 id / 名 */ + selectedRecipeRef?: string | null; + }, ): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> { const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot); const sharedContext = await loadSkillSharedContext(skillIdOrName, skillsRoot); - const promptBody = sharedContext - ? `# 固定创作上下文\n\n${sharedContext}\n\n---\n\n${worker.body}` - : worker.body; - return { worker, sharedContext, promptBody }; + const skill = await loadSkill(skillIdOrName, skillsRoot); + + let moduleCatalogBlock: string | null = null; + let modulePromptBlock: string | null = null; + let patchedWorker = worker; + + if (workerId.trim() === "design-flow" && skill.skillPackRoot) { + const { + loadModuleCatalog, + resolveSelectedRecipeDetail, + formatDesignFlowContentBlocks, + } = await import("./creation-flow.js"); + const modules = await loadModuleCatalog(skill.skillPackRoot, skillsRoot); + const selectedRecipe = await resolveSelectedRecipeDetail({ + skillPackRoot: skill.skillPackRoot, + selectedRecipeRef: opts?.selectedRecipeRef, + skillsRoot, + }); + const blocks = formatDesignFlowContentBlocks({ + selectedRecipe, + modules, + missingSelection: !selectedRecipe, + }); + if (blocks.length) { + moduleCatalogBlock = blocks.join("\n\n"); + } + } + + if (workerId.trim() === "design-step" && skill.skillPackRoot) { + const { + resolveDesignStepBinding, + CREATION_CURRENT_STEP_TAG, + CREATION_MODULE_OPENING_TAG, + } = await import("./creation-flow.js"); + const binding = await resolveDesignStepBinding({ + skillPackRoot: skill.skillPackRoot, + flowRaw: opts?.flowRaw, + currentStepName: opts?.currentStepName, + acceptedStepNames: opts?.acceptedStepNames, + skillsRoot, + }); + if (binding) { + const openingNote = binding.opening + ? `\n\n【程序开场】若黑板有「${CREATION_MODULE_OPENING_TAG}」,该默认问题已由程序发给用户(不经 LLM);用户首答在「用户.worker答复」。勿重复同一开场白,在其答复与提示词基础上继续追问或产出。` + : ""; + modulePromptBlock = `## 【本步方法 · ${binding.module.name}】\n\n${binding.modulePrompt.trim()}${openingNote}`; + const baseInputs = [ + "用户.需求", + "book.brief", + "用户.最新输入", + "用户.worker答复", + "用户.修订说明", + "设计.创作流程", + CREATION_CURRENT_STEP_TAG, + CREATION_MODULE_OPENING_TAG, + ...binding.depTags, + ]; + const inputTags = [...new Set(baseInputs)]; + const outputTags = [ + binding.module.artifact, + CREATION_CURRENT_STEP_TAG, + ]; + const depSegments = binding.depTags.map((tag, i) => ({ + id: `dep-${i}`, + tier: "static" as const, + tags: [tag], + label: `## 【依赖产物 · ${tag}】只读`, + })); + const openingSegment = binding.opening + ? [ + { + id: "module-opening", + tier: "static" as const, + tags: [CREATION_MODULE_OPENING_TAG], + label: "## 【本步默认问题 · 程序已发出】只读", + }, + ] + : []; + patchedWorker = { + ...worker, + name: `创作 · ${binding.module.name}`, + description: binding.module.declaration, + inputTags, + outputTags, + contextSegments: [ + ...(worker.contextSegments ?? []), + ...openingSegment, + ...depSegments, + ], + }; + } + } + + const parts: string[] = []; + if (sharedContext?.trim()) { + parts.push(`# 固定创作上下文\n\n${sharedContext.trim()}`); + } + if (moduleCatalogBlock?.trim()) { + parts.push(moduleCatalogBlock.trim()); + } + if (modulePromptBlock?.trim()) { + parts.push(modulePromptBlock.trim()); + } + parts.push(patchedWorker.body); + const promptBody = parts.join("\n\n---\n\n"); + return { worker: patchedWorker, sharedContext, promptBody }; } /** 加载 skill 包内专属 worker skill */ @@ -473,9 +616,10 @@ export async function listWorkerSkills( } const workers: ParsedWorkerSkill[] = []; for (const entry of entries.sort()) { - const skillPath = `${skill.skillPackRoot}/workers/${entry}/${WORKER_SKILL_FILENAME}`; + const relativePath = await resolveWorkerSkillPath(skillIdOrName, entry, skillsRoot); + if (!relativePath) continue; try { - workers.push(await parseWorkerSkillFile(skillPath, skillsRoot)); + workers.push(await parseWorkerSkillFile(relativePath, skillsRoot)); } catch { // skip } diff --git a/src/skills/question-protocol.ts b/src/skills/question-protocol.ts new file mode 100644 index 0000000..43f5df0 --- /dev/null +++ b/src/skills/question-protocol.ts @@ -0,0 +1,134 @@ +/** + * Worker / Agent 结构化追问协议。 + * UI:左右分页选项卡;经 composer 发送,payload 须含问+答(可附自由补充)。 + */ +import type { + QuestionAnswer, + QuestionItem, + QuestionOption, +} from "../types/questions.js"; + +export type { QuestionAnswer, QuestionItem, QuestionOption }; + +function letterId(i: number): string { + return String.fromCharCode(65 + (i % 26)); +} + +/** 把 askUser 原始值规范成 QuestionItem[](兼容纯 string[]) */ +export function normalizeQuestions(raw: unknown): QuestionItem[] { + if (!Array.isArray(raw)) return []; + const out: QuestionItem[] = []; + for (let i = 0; i < raw.length; i++) { + const item = raw[i]; + if (typeof item === "string") { + const prompt = item.trim(); + if (!prompt) continue; + out.push({ + id: `q${i + 1}`, + prompt, + allowOther: true, + required: true, + }); + continue; + } + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const row = item as Record; + const prompt = + typeof row.prompt === "string" + ? row.prompt.trim() + : typeof row.question === "string" + ? row.question.trim() + : typeof row.text === "string" + ? row.text.trim() + : ""; + if (!prompt) continue; + const id = + typeof row.id === "string" && row.id.trim() + ? row.id.trim() + : `q${i + 1}`; + let options: QuestionOption[] | undefined; + if (Array.isArray(row.options)) { + options = []; + for (let j = 0; j < row.options.length; j++) { + const opt = row.options[j]; + if (typeof opt === "string") { + const label = opt.trim(); + if (!label) continue; + options.push({ id: letterId(j), label, editable: true }); + continue; + } + if (!opt || typeof opt !== "object" || Array.isArray(opt)) continue; + const o = opt as Record; + const label = + typeof o.label === "string" + ? o.label.trim() + : typeof o.text === "string" + ? o.text.trim() + : ""; + if (!label) continue; + options.push({ + id: + typeof o.id === "string" && o.id.trim() + ? o.id.trim() + : letterId(j), + label, + editable: o.editable === false ? false : true, + }); + } + if (options.length === 0) options = undefined; + } + out.push({ + id, + prompt, + options, + allowOther: row.allowOther === false ? false : true, + required: row.required === false ? false : true, + }); + } + return out; +} + +/** 发给 AI:必须含问题与答案;可选自由补充 */ +export function formatQuestionAnswersForAi( + questions: QuestionItem[], + answers: QuestionAnswer[], + note?: string, +): string { + const byId = new Map(answers.map((a) => [a.questionId, a])); + const lines: string[] = ["【追问作答】"]; + for (const q of questions) { + const a = byId.get(q.id); + const answer = a?.text?.trim() || "(未答)"; + lines.push(`问:${q.prompt}`); + lines.push(`答:${answer}`); + lines.push(""); + } + const trimmedNote = note?.trim(); + if (trimmedNote) { + lines.push("【补充】"); + lines.push(trimmedNote); + } + return lines.join("\n").trim(); +} + +/** 气泡摘要:答句 + 可选补充 */ +export function formatQuestionAnswersForDisplay( + questions: QuestionItem[], + answers: QuestionAnswer[], + note?: string, +): string { + const byId = new Map(answers.map((a) => [a.questionId, a])); + const parts: string[] = []; + for (const q of questions) { + const a = byId.get(q.id); + if (!a?.text?.trim()) continue; + parts.push(a.text.trim()); + } + const trimmedNote = note?.trim(); + if (trimmedNote) parts.push(trimmedNote); + return parts.length ? parts.join("\n") : "(已提交追问作答)"; +} + +export function questionPrompts(questions: QuestionItem[]): string[] { + return questions.map((q) => q.prompt).filter(Boolean); +} diff --git a/src/skills/resident-context.ts b/src/skills/resident-context.ts new file mode 100644 index 0000000..fa98836 --- /dev/null +++ b/src/skills/resident-context.ts @@ -0,0 +1,128 @@ +/** + * 常驻上下文:按 Worker 集 resident_context 挂载到指定 worker。 + * 写入黑板 tag,并并入该 worker 的 static 输入。 + */ +import type { Blackboard } from "../blackboard/blackboard.js"; + +export type ResidentContextEntry = { + id: string; + /** 注入位提示:static | dynamic(拼装分层) */ + position?: "static" | "dynamic"; + importance?: number; + content: string; + /** 挂载到哪些 worker ref;空 = 全部声明 worker */ + mount?: string[]; + /** 显式 tag;默认 上下文.常驻.{id} */ + tag?: string; +}; + +export function parseResidentContext(raw: unknown): ResidentContextEntry[] { + if (!Array.isArray(raw)) return []; + const out: ResidentContextEntry[] = []; + for (const item of raw) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const row = item as Record; + const id = + typeof row.id === "string" + ? row.id.trim() + : typeof row.key === "string" + ? row.key.trim() + : ""; + const content = + typeof row.content === "string" + ? row.content + : typeof row.text === "string" + ? row.text + : typeof row.summary === "string" + ? row.summary + : ""; + if (!id || !content.trim()) continue; + const mount = Array.isArray(row.mount) + ? row.mount + .filter((m): m is string => typeof m === "string" && m.trim().length > 0) + .map((m) => m.trim()) + : Array.isArray(row.workers) + ? row.workers + .filter((m): m is string => typeof m === "string" && m.trim().length > 0) + .map((m) => m.trim()) + : undefined; + const position = + row.position === "dynamic" || row.tier === "dynamic" + ? "dynamic" + : row.position === "static" || row.tier === "static" + ? "static" + : "static"; + out.push({ + id, + position, + importance: + typeof row.importance === "number" ? row.importance : undefined, + content: content.trim(), + mount, + tag: typeof row.tag === "string" && row.tag.trim() ? row.tag.trim() : undefined, + }); + } + return out.sort((a, b) => (b.importance ?? 0) - (a.importance ?? 0)); +} + +export function residentTagFor(entry: ResidentContextEntry): string { + return entry.tag ?? `上下文.常驻.${entry.id}`; +} + +export function entriesForWorker( + entries: ResidentContextEntry[], + workerId: string, +): ResidentContextEntry[] { + const id = workerId.trim(); + return entries.filter((e) => { + if (!e.mount || e.mount.length === 0) return true; + return e.mount.includes(id); + }); +} + +/** + * 把匹配本 worker 的常驻块写入黑板,返回应并入 inputTags 的 tag 列表(按 position)。 + */ +export function mountResidentContextForWorker(params: { + blackboard: Blackboard; + entries: ResidentContextEntry[]; + workerId: string; + source?: string; +}): { staticTags: string[]; dynamicTags: string[]; written: string[] } { + const matched = entriesForWorker(params.entries, params.workerId); + const staticTags: string[] = []; + const dynamicTags: string[] = []; + const written: string[] = []; + const source = params.source ?? "system:resident-context"; + + for (const entry of matched) { + const tag = residentTagFor(entry); + const existing = params.blackboard.getContentByTag(tag); + if (existing !== entry.content) { + params.blackboard.write({ + tag, + content: entry.content, + source, + }); + written.push(tag); + } + if (entry.position === "dynamic") dynamicTags.push(tag); + else staticTags.push(tag); + } + + return { staticTags, dynamicTags, written }; +} + +/** 拼进声明驱动 prompt 的常驻段(无黑板时也可纯文本注入) */ +export function formatResidentPromptSection( + entries: ResidentContextEntry[], + workerId: string, +): string { + const matched = entriesForWorker(entries, workerId); + if (matched.length === 0) return ""; + const blocks = matched.map((e) => { + const label = e.id; + return `### ${label}\n\n${e.content}`; + }); + return `## 常驻上下文(本 worker 挂载)\n\n${blocks.join("\n\n")}`; +} diff --git a/src/skills/snapshot.ts b/src/skills/snapshot.ts index 4293e6b..d850cd2 100644 --- a/src/skills/snapshot.ts +++ b/src/skills/snapshot.ts @@ -12,6 +12,8 @@ export function toActiveSkillSnapshot(skill: ParsedSkill): ActiveSkillSnapshot { bookKind: skill.bookKind, defaultFlowId: skill.defaultFlowId, suggestedWorkers: skill.suggestedWorkers, + startupMode: skill.startupMode, + uiPrompt: skill.uiPrompt, startupPrompt: skill.startupInquiry.prompt, startupTargetKey: skill.startupInquiry.targetKey, intakeFields, diff --git a/src/skills/types.ts b/src/skills/types.ts index 0ab7f52..7574217 100644 --- a/src/skills/types.ts +++ b/src/skills/types.ts @@ -1,5 +1,6 @@ import type { BlackboardInputMerge } from "../types/blackboard.js"; -import type { AdvancePolicy } from "../types/runtime.js"; +import type { AdvancePolicy, SkillStartupMode } from "../types/runtime.js"; +import type { ContextSegmentDef } from "./context-segments.js"; export type WorkerLlmBinding = { /** 固定 ApiProfile.id;省略 = 会话默认 */ @@ -53,6 +54,10 @@ export type ParsedSkill = { /** llm-bindings.yaml(可选);见 docs/worker-skill-format.md §9 */ workerLlmBindings?: SkillWorkerLlmBindings; startupInquiry: StartupInquiry; + /** intake(legacy 包)或 agent-first(默认:UI 引导后 Agent 调度) */ + startupMode?: SkillStartupMode; + /** agent-first 首屏固定引导文案 */ + uiPrompt?: string; /** 推进策略(预留)。loader 第一版不解析 orchestrator ## 推进策略 */ advancePolicy?: AdvancePolicy; body: string; @@ -70,6 +75,8 @@ export type ParsedWorkerSkill = { inputMerge?: BlackboardInputMerge; /** ApiProfile.id;省略 = 走 llm-bindings 或会话默认 */ llmProfileId?: string; + /** 上下半拼装;缺省则 executor 回退 JSON inputs */ + contextSegments?: ContextSegmentDef[]; /** 相对 skills/ 的路径 */ path: string; body: string; diff --git a/src/skills/worker-declaration.ts b/src/skills/worker-declaration.ts new file mode 100644 index 0000000..a05470c --- /dev/null +++ b/src/skills/worker-declaration.ts @@ -0,0 +1,166 @@ +import type { Blackboard } from "../blackboard/blackboard.js"; +import type { RuntimeSession } from "../types/runtime.js"; +import { + deriveDesignStageScope, + deriveRunWorkerScope, + parseWorkerSetYaml, + runWorkerMeta, + type ParsedWorkerSet, +} from "./worker-set-parse.js"; + +export type LifecycleStage = "design" | "play"; + +/** 实例 Worker 声明:由创作阶段 设计.worker集 动态定义,play 只调度声明内的 id */ +export type InstanceWorkerDeclaration = { + /** 声明正文来源 tag;null 表示尚无 Worker 集 */ + sourceTag: "设计.worker集" | "设计.worker集.草稿" | null; + /** Worker 集是否已通过用户验收 */ + accepted: boolean; + parsed: ParsedWorkerSet | null; + /** 当前 lifecycle 下总管可 run_worker 的 id 列表 */ + activeWorkerIds: string[]; + /** play 阶段声明(deriveRunWorkerScope) */ + playWorkerIds: string[]; + /** 创作末尾声明(如 opening-generator) */ + designEndWorkerIds: string[]; +}; + +const WORKER_SET_ACCEPTED_TAG = "设计.worker集"; +const WORKER_SET_DRAFT_TAG = "设计.worker集.草稿"; + +export function hasAcceptedWorkerSet(session: RuntimeSession): boolean { + if (Boolean(session.slots.designInstanceReady)) return true; + return session.artifacts.some( + (a) => + a.status === "accepted" && + a.outputTags.some((tag) => tag === WORKER_SET_ACCEPTED_TAG), + ); +} + +export function canEnterPlay(session: RuntimeSession): boolean { + return hasAcceptedWorkerSet(session); +} + +export function inferLifecycleStage(session: RuntimeSession): LifecycleStage { + const override = session.slots.uiLifecycleStage; + if (override === "play" && canEnterPlay(session)) return "play"; + return "design"; +} + +/** 读取用于构建声明的 Worker 集 YAML */ +export function readWorkerSetYamlForDeclaration( + blackboard: Blackboard, + session: RuntimeSession, +): { yaml: string; sourceTag: InstanceWorkerDeclaration["sourceTag"] } | null { + const accepted = blackboard.getContentByTag(WORKER_SET_ACCEPTED_TAG)?.trim(); + const draft = blackboard.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim(); + const workerSetAccepted = hasAcceptedWorkerSet(session); + + if (workerSetAccepted && accepted) { + return { yaml: accepted, sourceTag: WORKER_SET_ACCEPTED_TAG }; + } + if (draft) { + return { yaml: draft, sourceTag: WORKER_SET_DRAFT_TAG }; + } + if (accepted) { + return { yaml: accepted, sourceTag: WORKER_SET_ACCEPTED_TAG }; + } + return null; +} + +/** + * 构建实例 Worker 声明。 + * world-simulator:play 仅 activeWorkerIds;design 验收前为分步 design-*。 + */ +export function buildInstanceWorkerDeclaration( + session: RuntimeSession, + blackboard: Blackboard, + lifecycle: LifecycleStage = inferLifecycleStage(session), +): InstanceWorkerDeclaration { + const accepted = hasAcceptedWorkerSet(session); + const raw = readWorkerSetYamlForDeclaration(blackboard, session); + const parsed = raw ? parseWorkerSetYaml(raw.yaml) : null; + const playWorkerIds = accepted && parsed ? deriveRunWorkerScope(parsed) : []; + const designEndWorkerIds = + accepted && parsed ? deriveDesignStageScope(parsed) : []; + + const designStepIds = ["design-flow", "design-step"]; + + let activeWorkerIds: string[]; + + if (lifecycle === "play") { + activeWorkerIds = [...playWorkerIds]; + } else if (!accepted) { + activeWorkerIds = [...designStepIds]; + } else { + activeWorkerIds = [...designStepIds, ...designEndWorkerIds]; + // 去重保序 + const seen = new Set(); + activeWorkerIds = activeWorkerIds.filter((id) => { + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + } + + return { + sourceTag: raw?.sourceTag ?? null, + accepted, + parsed, + activeWorkerIds, + playWorkerIds, + designEndWorkerIds, + }; +} + +export function isWorkerDeclared( + declaration: InstanceWorkerDeclaration, + workerId: string, +): boolean { + const id = workerId.trim(); + return declaration.activeWorkerIds.includes(id); +} + +export function formatUndeclaredWorkerError( + workerId: string, + declaration: InstanceWorkerDeclaration, +): string { + const allowed = + declaration.activeWorkerIds.length > 0 + ? declaration.activeWorkerIds.join("、") + : "(尚无)"; + return ( + `Worker「${workerId}」不在本实例声明内。` + + `当前可调度:${allowed}。` + + (declaration.accepted + ? " play 阶段仅允许 设计.worker集 中 ref 列出的 Worker。" + : " 请先完成 design-flow → design-step,并验收终稿 Worker 集。") + ); +} + +/** 合并磁盘已安装 SKILL 与声明中的 gap id(供 list_workers 展示) */ +export function mergeDeclaredWorkersForAgent( + installed: Array<{ id: string; description: string }>, + declaration: InstanceWorkerDeclaration, +): Array<{ id: string; description: string }> { + const byId = new Map(installed.map((w) => [w.id, w])); + const out: Array<{ id: string; description: string }> = []; + + for (const id of declaration.activeWorkerIds) { + const found = byId.get(id); + if (found) { + out.push(found); + } else { + const meta = runWorkerMeta(id); + out.push({ + id, + description: `[声明已启用 · SKILL 待补] ${meta.purpose}`, + }); + } + } + return out; +} + +export function shouldEnforceWorkerDeclaration(skillPackName?: string): boolean { + return skillPackName === "world-simulator"; +} diff --git a/src/skills/worker-set-parse.ts b/src/skills/worker-set-parse.ts new file mode 100644 index 0000000..15dc57c --- /dev/null +++ b/src/skills/worker-set-parse.ts @@ -0,0 +1,523 @@ +import { parse as parseYaml } from "yaml"; + +export type WorkerSetPresentation = { + tone?: string; + pacing?: string; + information_layers?: string[]; + avoid?: string[]; + [key: string]: unknown; +}; + +export type WorkerSetContext = { + static?: string[]; + dynamic?: string[]; +}; + +export type WorkerAcceptance = "review" | "continue"; + +export type WorkerSetEntry = { + ref: string | null; + /** + * 用户可见中文名(创造 worker 时优先填写)。 + * 与 `ref` 分离:`ref` 仍为英文机器 id(调度 / 模板 / mount)。 + */ + name?: string; + role?: string; + duty?: string; + when?: string; + rationale?: string; + /** + * run 验收点:本 worker 完成后是否停下来给人读。 + * review = 用户验收;continue = 可连跑下一 worker。 + */ + acceptance?: WorkerAcceptance; + merge_considered?: string; + gap?: string | null; + presentation?: WorkerSetPresentation | null; + /** play 阶段冻结的上下文插入顺序(design-intake 设计) */ + context?: WorkerSetContext; + /** 本 worker 写入黑板的 tag */ + outputs?: string[]; +}; + +export type ParsedWorkerSetInteraction = { + user_stance?: string; + system_role?: string; + output?: string; + turn_shape?: string; + [key: string]: unknown; +}; + +export type ParsedWorkerSet = { + version?: number; + form_summary?: string; + interaction_paradigm?: string; + /** 新规格:站位 / 系统扮演 / 输出 / 轮转 */ + interaction?: ParsedWorkerSetInteraction; + experience_check?: Record; + core_worker?: string; + reasoning?: string; + play_morphology?: string; + input_protocol?: Record; + workers: WorkerSetEntry[]; + tag_flow?: string[]; + resident_context?: unknown[]; + tables?: Record; + narrative_guide?: string; + core_premises?: string[]; + design_end?: Record; + /** @deprecated 旧字段;新规格用 design_end */ + instantiate_hints?: { + invoke?: string[]; + skip?: string[]; + skip_reason?: string; + notes?: string; + }; + open_questions?: string[]; + notes?: string; + parseError?: string; +}; + +/** 创作阶段可选 skill 元数据(accept Worker 集之后、play 之前) */ +export const DESIGN_STAGE_SKILL_META: Record< + string, + { label: string; purpose: string } +> = { + "opening-generator": { + label: "开局 · 开场白", + purpose: + "创作末尾:结合已定世界/故事写开场白(主);表初值与开场对齐,能推则推。", + }, +}; + +/** + * @deprecated 旧「run worker → instantiate 管道」映射。Worker 集即实例规格,不再用于推导 design 进度。 + */ +export const RUN_TO_INSTANTIATE: Record = { + "world-simulator": ["world-blueprint"], + narrator: ["narrative-guide"], + "variable-update": ["variable-catalog"], + "input-expand": ["narrative-guide"], + "plot-continue": ["narrative-guide"], + "role-decide": ["generation-rules"], +}; + +/** @deprecated 旧管道 skill 元数据;仅兼容旧 Worker 集 YAML 展示 */ +export const INSTANTIATE_SKILL_META: Record< + string, + { label: string; purpose: string } +> = { + "world-blueprint": { + label: "世界蓝图", + purpose: "背景板与核心设定,供 world-simulator 等读取。", + }, + topology: { + label: "拓扑 / 关系", + purpose: "地图、关系网或进阶路径(按需多次)。", + }, + "generation-rules": { + label: "生成规则", + purpose: "元规则:如何生成 NPC、物品等实例内容。", + }, + "narrative-guide": { + label: "叙事 / 描写指南", + purpose: "正文 POV、时态、文风(narrator 等 static 上下文)。", + }, + "variable-catalog": { + label: "变量目录", + purpose: "要跟踪的状态与变化规则(variable-update 用)。", + }, + corpus: { + label: "语料 / 场景策略", + purpose: "口吻样例、场景模板、描写与节奏策略。", + }, +}; + +export const RUN_WORKER_META: Record = + { + "world-simulator": { + label: "世界模拟", + purpose: "裁决规则、更新事件流与可见信息。", + }, + narrator: { + label: "转述 / 展示", + purpose: "把核心/世界层干巴输出转为用户可读回复(文学化或 Markdown 等)。", + }, + "variable-update": { + label: "变量更新", + purpose: "跟踪等级、资源、职业等状态变量。", + }, + "input-expand": { + label: "输入拓写", + purpose: "把用户裸输入拓写为场景内行动/意图。", + }, + "plot-continue": { + label: "剧情续写", + purpose: "基于拓写结果续写剧情片段。", + }, + "role-decide": { + label: "角色决策", + purpose: "单个重要角色独立决策(信息隔绝时用)。", + }, + "round-present": { + label: "回合陈述", + purpose: "结构化陈述本轮事件与各方行动/思考摘要。", + }, + }; + +function asString(value: unknown): string | undefined { + if (value == null) return undefined; + if (typeof value === "string") return value.trim() || undefined; + return String(value).trim() || undefined; +} + +function asStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => asString(item)) + .filter((item): item is string => Boolean(item)); +} + +function parsePresentation(raw: unknown): WorkerSetPresentation | null | undefined { + if (raw == null) return raw === null ? null : undefined; + if (typeof raw !== "object" || Array.isArray(raw)) return undefined; + return raw as WorkerSetPresentation; +} + +function parseContext(raw: unknown): WorkerSetContext | undefined { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const row = raw as Record; + const staticTags = asStringList(row.static); + const dynamicTags = asStringList(row.dynamic); + if (!staticTags.length && !dynamicTags.length) return undefined; + return { + static: staticTags.length ? staticTags : undefined, + dynamic: dynamicTags.length ? dynamicTags : undefined, + }; +} + +function parseWorkers(raw: unknown): WorkerSetEntry[] { + if (!Array.isArray(raw)) return []; + return raw.map((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return { ref: null }; + } + const row = item as Record; + const refRaw = row.ref; + const ref = + refRaw == null + ? null + : asString(refRaw) ?? (typeof refRaw === "string" ? refRaw : null); + const outputs = asStringList(row.outputs); + const acceptanceRaw = asString(row.acceptance); + const acceptance: WorkerAcceptance | undefined = + acceptanceRaw === "review" || acceptanceRaw === "continue" + ? acceptanceRaw + : undefined; + return { + ref, + name: asString(row.name), + role: asString(row.role), + duty: asString(row.duty), + when: asString(row.when), + rationale: asString(row.rationale), + acceptance, + merge_considered: asString(row.merge_considered), + gap: ref == null ? asString(row.gap) ?? null : asString(row.gap) ?? null, + presentation: parsePresentation(row.presentation), + context: parseContext(row.context), + outputs: outputs.length ? outputs : undefined, + }; + }); +} + +function parseInstantiateHints(raw: unknown): ParsedWorkerSet["instantiate_hints"] { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const row = raw as Record; + const invoke = asStringList(row.invoke); + const skip = asStringList(row.skip); + const skip_reason = asString(row.skip_reason); + const notes = asString(row.notes); + if (invoke.length === 0 && skip.length === 0 && !skip_reason && !notes) return undefined; + return { + invoke: invoke.length ? invoke : undefined, + skip: skip.length ? skip : undefined, + skip_reason, + notes, + }; +} + +function parseWorkerSetObject(row: Record): ParsedWorkerSet { + const interactionRaw = row.interaction; + const interaction = + interactionRaw && + typeof interactionRaw === "object" && + !Array.isArray(interactionRaw) + ? (interactionRaw as ParsedWorkerSetInteraction) + : undefined; + + return { + version: typeof row.version === "number" ? row.version : undefined, + form_summary: asString(row.form_summary), + interaction_paradigm: asString(row.interaction_paradigm), + interaction, + experience_check: + row.experience_check && + typeof row.experience_check === "object" && + !Array.isArray(row.experience_check) + ? (row.experience_check as Record) + : undefined, + core_worker: asString(row.core_worker), + reasoning: asString(row.reasoning), + play_morphology: asString(row.play_morphology), + input_protocol: + row.input_protocol && + typeof row.input_protocol === "object" && + !Array.isArray(row.input_protocol) + ? Object.fromEntries( + Object.entries(row.input_protocol as Record) + .map(([k, v]) => [k, asString(v) ?? ""]) + .filter(([, v]) => v), + ) + : undefined, + workers: parseWorkers(row.workers), + tag_flow: asStringList(row.tag_flow), + resident_context: Array.isArray(row.resident_context) + ? row.resident_context + : undefined, + tables: + row.tables && typeof row.tables === "object" && !Array.isArray(row.tables) + ? (row.tables as Record) + : undefined, + narrative_guide: asString(row.narrative_guide), + core_premises: asStringList(row.core_premises), + design_end: + row.design_end && + typeof row.design_end === "object" && + !Array.isArray(row.design_end) + ? (row.design_end as Record) + : undefined, + instantiate_hints: parseInstantiateHints(row.instantiate_hints), + open_questions: asStringList(row.open_questions), + notes: asString(row.notes), + }; +} + +/** + * 解析 `设计.worker集`:优先 JSON(含从说明文字中抽取 `{...}`),失败再试 YAML(兼容旧草稿)。 + * 新产出应为 JSON。散文/提问文字会得到明确的 parseError,而不是晦涩的 YAML 报错。 + */ +export function parseWorkerSetYaml(raw: string | undefined): ParsedWorkerSet | null { + const text = raw?.trim(); + if (!text) return null; + + const jsonCandidate = extractJsonObjectText(text); + if (jsonCandidate) { + try { + const doc = JSON.parse(jsonCandidate) as unknown; + if (!doc || typeof doc !== "object" || Array.isArray(doc)) { + return { workers: [], parseError: "Worker 集不是有效的 JSON 对象" }; + } + return parseWorkerSetObject(doc as Record); + } catch (err) { + return { + workers: [], + parseError: err instanceof Error ? err.message : "JSON 解析失败", + }; + } + } + + // 明显是中文说明/提问,不要丢给 YAML(会报 Implicit keys…) + if (looksLikeProseNotSpec(text)) { + return { + workers: [], + parseError: + "内容不是 JSON 规格(像是说明或提问文字)。提问应走 askUser,规格字段只能是 {…} JSON。", + }; + } + + try { + const doc = parseYaml(text); + if (!doc || typeof doc !== "object" || Array.isArray(doc)) { + return { workers: [], parseError: "Worker 集不是有效的对象" }; + } + return parseWorkerSetObject(doc as Record); + } catch (err) { + return { + workers: [], + parseError: err instanceof Error ? err.message : "Worker 集解析失败", + }; + } +} + +/** 从纯 JSON、```json 围栏或夹杂说明的文本中抽出对象字面量 */ +export function extractJsonObjectText(raw: string): string | null { + const text = raw.trim(); + if (!text) return null; + if (text.startsWith("{")) { + try { + JSON.parse(text); + return text; + } catch { + /* fall through to brace scan */ + } + } + const fence = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/i); + if (fence?.[1]) { + try { + JSON.parse(fence[1]); + return fence[1].trim(); + } catch { + /* continue */ + } + } + const first = text.indexOf("{"); + const last = text.lastIndexOf("}"); + if (first >= 0 && last > first) { + const slice = text.slice(first, last + 1); + try { + JSON.parse(slice); + return slice; + } catch { + return null; + } + } + return null; +} + +export function looksLikeProseNotSpec(text: string): boolean { + const t = text.trim(); + if (!t) return false; + if (t.startsWith("{") || t.startsWith("[")) return false; + // YAML 文档常见开头 + if (/^(---|version:|workers:|interaction:|form_summary:)/m.test(t)) return false; + // 中文叙述 / 明显自然语言 + if (/[\u4e00-\u9fff]{8,}/.test(t) && !/^\s*[\w.-]+\s*:/.test(t)) return true; + if (/^(我们|首先|根据|请|用户需求|我(?:们)?被要求)/.test(t)) return true; + return false; +} + +/** 是否像一份可用的 Worker 集(而非空壳 / 仅 parseError) */ +export function isUsableWorkerSet(parsed: ParsedWorkerSet | null | undefined): boolean { + if (!parsed || parsed.parseError) return false; + if ((parsed.workers?.length ?? 0) > 0) return true; + if (parsed.interaction && Object.keys(parsed.interaction).length > 0) return true; + if (parsed.form_summary?.trim() || parsed.interaction_paradigm?.trim()) return true; + if (parsed.narrative_guide?.trim() || (parsed.core_premises?.length ?? 0) > 0) { + return true; + } + return false; +} + +/** + * 从 Worker 集推导创作阶段收尾可选 skill(如 opening-generator)。 + * 读 design_end / instantiate_hints.invoke + workers[].ref。 + */ +export function deriveDesignStageScope(workerSet: ParsedWorkerSet | null): string[] { + if (!workerSet) return []; + const seen = new Set(); + const ordered: string[] = []; + const add = (id: string) => { + const key = id.trim(); + if (!key || seen.has(key)) return; + seen.add(key); + ordered.push(key); + }; + + const designEnd = workerSet.design_end; + if (designEnd) { + const opening = designEnd.opening; + if ( + opening === "optional" || + opening === true || + opening === "opening-generator" + ) { + add("opening-generator"); + } + for (const id of asStringList(designEnd.invoke)) add(id); + } + + for (const id of workerSet.instantiate_hints?.invoke ?? []) add(id); + + for (const worker of workerSet.workers) { + const ref = worker.ref?.trim(); + if (ref && DESIGN_STAGE_SKILL_META[ref]) add(ref); + } + + for (const id of workerSet.instantiate_hints?.skip ?? []) { + seen.delete(id); + const idx = ordered.indexOf(id); + if (idx >= 0) ordered.splice(idx, 1); + } + + return ordered; +} + +/** @deprecated 请用 deriveDesignStageScope */ +export function deriveInstantiateScope(workerSet: ParsedWorkerSet | null): string[] { + return deriveDesignStageScope(workerSet); +} + +/** play 阶段启用的 run worker ref 列表(保序、去重;不含 opening-generator 等 design-end skill) */ +export function deriveRunWorkerScope(workerSet: ParsedWorkerSet | null): string[] { + if (!workerSet) return []; + const seen = new Set(); + const ordered: string[] = []; + for (const worker of workerSet.workers) { + const ref = worker.ref?.trim(); + if (!ref || seen.has(ref)) continue; + if (DESIGN_STAGE_SKILL_META[ref]) continue; + seen.add(ref); + ordered.push(ref); + } + return ordered; +} + +/** + * run 阶段需用户验收的 worker ref 列表(acceptance === review)。 + * 未写 acceptance 的不列入(创作时应写全;运行时缺省策略另议)。 + */ +export function deriveReviewWorkerScope(workerSet: ParsedWorkerSet | null): string[] { + if (!workerSet) return []; + const seen = new Set(); + const ordered: string[] = []; + for (const worker of workerSet.workers) { + const ref = worker.ref?.trim(); + if (!ref || seen.has(ref)) continue; + if (DESIGN_STAGE_SKILL_META[ref]) continue; + if (worker.acceptance !== "review") continue; + seen.add(ref); + ordered.push(ref); + } + return ordered; +} + +/** 该 run worker 完成后是否应停下来给人验收 */ +export function workerRequiresReview( + workerSet: ParsedWorkerSet | null, + workerId: string, +): boolean { + if (!workerSet) return false; + const id = workerId.trim(); + const entry = workerSet.workers.find((w) => w.ref?.trim() === id); + return entry?.acceptance === "review"; +} + +export function instantiateMeta(id: string): { label: string; purpose: string } { + return ( + DESIGN_STAGE_SKILL_META[id] ?? + INSTANTIATE_SKILL_META[id] ?? { + label: id, + purpose: "创作阶段可选 skill。", + } + ); +} + +export function runWorkerMeta(id: string): { label: string; purpose: string } { + return ( + RUN_WORKER_META[id] ?? { + label: id, + purpose: "play 阶段按需 invoke。", + } + ); +} diff --git a/src/skills/worker-set-view.ts b/src/skills/worker-set-view.ts new file mode 100644 index 0000000..dda0766 --- /dev/null +++ b/src/skills/worker-set-view.ts @@ -0,0 +1,720 @@ +import type { + ParsedWorkerSet, + WorkerSetEntry, + WorkerSetPresentation, +} from "./worker-set-parse.js"; +import { instantiateMeta, runWorkerMeta } from "./worker-set-parse.js"; +import { listCreationUnits, extractUnitContentFromDraft } from "./creation-units.js"; +import type { CreationUnitView } from "./creation-units.js"; +import { + parseResidentContext, + residentTagFor, + type ResidentContextEntry, +} from "./resident-context.js"; + +export type TagLineView = { + tag: string; + note?: string; + filled?: boolean; +}; + +export type ContextLayerView = { + staticTags: TagLineView[]; + dynamicTags: TagLineView[]; + /** false = 部分来自包内默认模板,design-intake 未写全 context */ + explicit: boolean; +}; + +export type PresentationLineView = { + label: string; + value: string; +}; + +/** tag / 固定上下文 → 会塞进哪些 worker(与 worker 卡片解耦) */ +export type ContextTagMountView = { + workerId: string | null; + workerName: string; + /** prompt_body=写入该 worker 提示词正文;input_tag=声明为黑板读入;resident=常驻挂载 */ + how: "prompt_body" | "input_tag" | "resident"; + tier?: "static" | "dynamic"; +}; + +export type ContextTagCardView = { + /** fixed:… / resident:… / board:tag名 */ + id: string; + label: string; + kind: "fixed" | "resident" | "board"; + /** 内容预览(截断) */ + preview?: string; + filled: boolean; + mounts: ContextTagMountView[]; + /** 给人看的挂载摘要,如「全部 Worker」「叙事转述 · 世界模拟」 */ + mountSummary: string; +}; + +export type WorkerCardView = { + order: number; + id: string | null; + displayName: string; + roleLabel?: string; + status: "ready" | "gap"; + gapNote?: string; + purpose: string; + invokeWhen: string; + rationale?: string; + /** run:完成后是否停下来给人验收 */ + acceptance?: "review" | "continue"; + acceptanceLabel?: string; + mergeConsidered?: string; + /** @deprecated UI 以 contextTags 独立区为准;卡片上只展示 readsSummary */ + context: ContextLayerView; + /** 本 worker 会读/注入的上下文短摘要(不含完整 tag 列表) */ + readsSummary?: string; + writes: string[]; + presentation?: PresentationLineView[]; +}; + +export type TagFlowEdgeView = { + from: string[]; + to: string[]; +}; + +export type DesignTaskView = { + id: string; + label: string; + status: "planned" | "skipped" | "filled"; + skipReason?: string; +}; + +export type WorkerSetUserView = { + headline?: string; + interactionParadigm?: string; + coreWorker?: string; + reasoning?: string; + playModeLabel?: string; + playModeHint?: string; + inputProtocol?: PresentationLineView[]; + workers: WorkerCardView[]; + /** + * 固定 / 常驻 / 黑板上下文(独立于 worker 卡)。 + * 每张卡标明会塞进哪些 worker。 + */ + contextTags?: ContextTagCardView[]; + tagFlow: TagFlowEdgeView[]; + designTasks: DesignTaskView[]; + /** 创作单位:worker 规格 + 固定插入上下文(同级) */ + creationUnits?: CreationUnitView[]; + /** + * 当前创作单位:用于验收主舞台聚焦。 + * fixed/resident → 高亮「本块上下文」正文;worker → 高亮该卡。 + */ + focusUnit?: { + id: string; + kind: CreationUnitView["kind"]; + label: string; + /** fixed / resident / phase 时抽出的本块内容(给 UI 高亮展示) */ + body?: unknown; + }; + openQuestions: string[]; + notes?: string; + parseError?: string; +}; + +export type FormatWorkerSetOptions = { + /** 黑板上已有内容的 tag 名(用于设计任务 / static 是否已填) */ + filledTags?: string[]; + acceptedUnitIds?: string[]; + currentUnitId?: string | null; +}; + +const PLAY_MORPHOLOGY: Record = { + action_reaction_loop: { + label: "行动–反应循环", + hint: "你输入一句,世界推进并回复一段", + }, + chain: { + label: "链式输出", + hint: "按顺序生成,通常无多轮世界机", + }, + single: { + label: "单次生成", + hint: "一次产出成稿,非多轮交互", + }, + fork_review: { + label: "分叉验收", + hint: "生成后分角色验收,非交互小说", + }, + multi_actor_sim: { + label: "多角色模拟", + hint: "多个角色各自决策,世界裁决后汇总", + }, +}; + +const PRESENTATION_LABELS: Record = { + mode: "输出模式", + tone: "语气", + pacing: "节奏", + information_layers: "信息层", + avoid: "避免", + layers: "信息层", +}; + +const TAG_NOTES: Record = { + "设计.worker集": "实例分工与展示要求(本 worker 读切片)", + "世界.蓝图.确认稿": "设计阶段写入,游玩时只读", + "变量.目录.确认稿": "设计阶段写入,游玩时只读", + "变量.变化规则.确认稿": "设计阶段写入,游玩时只读", + "叙事.指南.确认稿": "设计阶段写入,游玩时只读", + "语料.场景策略集.确认稿": "设计阶段写入,游玩时只读", + "输出.回复格式.规范": "设计阶段写入,游玩时只读", + "变量.当前": "每轮更新", + "运行.事件流": "每轮追加", + "用户.最新输入": "每轮用户输入", +}; + +/** 包内 run worker 默认上下文契约(design-intake 未写 context 时合并) */ +const DEFAULT_WORKER_CONTRACTS: Record< + string, + { context: { static: string[]; dynamic: string[] }; outputs: string[] } +> = { + "world-simulator": { + context: { + static: [ + "设计.worker集", + "世界.蓝图.确认稿", + "世界.拓扑.*", + "变量.目录.确认稿", + "变量.变化规则.确认稿", + ], + dynamic: ["变量.当前", "运行.事件流", "用户.最新输入"], + }, + outputs: ["运行.本轮.裁决", "运行.事件流"], + }, + "variable-update": { + context: { + static: ["变量.目录.确认稿", "变量.变化规则.确认稿"], + dynamic: ["运行.本轮.裁决", "变量.当前"], + }, + outputs: ["运行.本轮.变量变更", "变量.当前"], + }, + narrator: { + context: { + static: [ + "设计.worker集", + "叙事.指南.确认稿", + "语料.场景策略集.确认稿", + "输出.回复格式.规范", + ], + dynamic: ["运行.事件流", "变量.当前", "运行.本轮.变量变更"], + }, + outputs: ["输出.用户展示"], + }, + "input-expand": { + context: { + static: ["设计.worker集", "叙事.指南.确认稿", "用户.需求"], + dynamic: ["用户.最新输入"], + }, + outputs: ["运行.本轮.拓写"], + }, + "plot-continue": { + context: { + static: ["设计.worker集", "叙事.指南.确认稿", "世界.蓝图.确认稿"], + dynamic: ["运行.本轮.拓写", "运行.事件流"], + }, + outputs: ["运行.本轮.续写"], + }, + "role-decide": { + context: { + static: ["设计.worker集", "世界.生成规则.*", "实例.角色.*"], + dynamic: ["运行.事件流", "可见信息"], + }, + outputs: ["运行.本轮.角色决策"], + }, +}; + +const INPUT_PROTOCOL_LABELS: Record = { + parens: "()圆括号", + quotes: "「」/ \"\" 台词", + brackets: "【】行动", + default: "默认规则", +}; + +const WORKER_ROLE_LABELS: Record = { + core: "核心", + auxiliary: "辅助", + transcription: "转述", +}; + +function tagMatchesPattern(tag: string, pattern: string): boolean { + if (pattern.endsWith(".*")) { + const prefix = pattern.slice(0, -2); + return tag === prefix || tag.startsWith(`${prefix}.`); + } + return tag === pattern; +} + +function tagFilled(tag: string, filledTags: string[]): boolean { + if (filledTags.some((t) => tagMatchesPattern(t, tag))) return true; + if (tag.endsWith(".*")) { + const prefix = tag.slice(0, -2); + return filledTags.some((t) => t === prefix || t.startsWith(`${prefix}.`)); + } + return filledTags.includes(tag); +} + +function resolveContract(entry: WorkerSetEntry): { + context: { static: string[]; dynamic: string[] }; + outputs: string[]; + explicit: boolean; +} { + const defaults = entry.ref ? DEFAULT_WORKER_CONTRACTS[entry.ref] : undefined; + const ctx = entry.context; + const hasExplicitContext = + Boolean(ctx?.static?.length) || Boolean(ctx?.dynamic?.length); + const hasExplicitOutputs = Boolean(entry.outputs?.length); + + return { + context: { + static: hasExplicitContext + ? (ctx?.static ?? []) + : (defaults?.context.static ?? []), + dynamic: hasExplicitContext + ? (ctx?.dynamic ?? []) + : (defaults?.context.dynamic ?? []), + }, + outputs: hasExplicitOutputs + ? (entry.outputs ?? []) + : (defaults?.outputs ?? []), + explicit: hasExplicitContext || hasExplicitOutputs, + }; +} + +function formatPresentation(pres: WorkerSetPresentation): PresentationLineView[] { + const lines: PresentationLineView[] = []; + for (const [key, value] of Object.entries(pres)) { + if (value == null || value === "") continue; + const label = PRESENTATION_LABELS[key] ?? key; + const text = Array.isArray(value) ? value.join("、") : String(value); + lines.push({ label, value: text }); + } + return lines; +} + +function parseTagFlow(lines: string[] | undefined): TagFlowEdgeView[] { + if (!lines?.length) return []; + return lines + .map((line) => { + const arrow = line.includes("→") ? "→" : "->"; + const parts = line.split(arrow); + if (parts.length < 2) return null; + const from = parts[0] + .split("+") + .map((s) => s.trim()) + .filter(Boolean); + const to = parts + .slice(1) + .join(arrow) + .split("+") + .map((s) => s.trim()) + .filter(Boolean); + return { from, to }; + }) + .filter((e): e is TagFlowEdgeView => e != null && e.to.length > 0); +} + +function toTagLines( + tags: string[], + filledTags: string[], + tier: "static" | "dynamic", +): TagLineView[] { + return tags.map((tag) => ({ + tag, + note: + TAG_NOTES[tag] ?? + (tier === "static" ? "设计阶段写入,游玩时只读" : "每轮读写"), + filled: filledTags.length ? tagFilled(tag, filledTags) : undefined, + })); +} + +function previewText(raw: string | undefined, max = 120): string | undefined { + const t = raw?.trim(); + if (!t) return undefined; + return t.length > max ? `${t.slice(0, max)}…` : t; +} + +function formatMountSummary( + mounts: ContextTagMountView[], + allWorkerCount: number, +): string { + if (mounts.length === 0) return "尚未指定 Worker"; + const names = [...new Set(mounts.map((m) => m.workerName))]; + if (allWorkerCount > 0 && names.length >= allWorkerCount) { + return "全部 Worker"; + } + if (names.length <= 3) return names.join(" · "); + return `${names.slice(0, 2).join(" · ")} 等 ${names.length} 个`; +} + +function workerDisplayName(entry: WorkerSetEntry, index: number): string { + const meta = entry.ref ? runWorkerMeta(entry.ref) : null; + return ( + entry.name?.trim() || + (entry.ref == null + ? entry.duty?.slice(0, 24) || `待命名 Worker ${index + 1}` + : meta!.label) + ); +} + +/** 从规格反查:每块固定/常驻/黑板 tag 会塞进哪些 worker */ +function buildContextTagCards( + parsed: ParsedWorkerSet, + workers: WorkerCardView[], + filledTags: string[], +): ContextTagCardView[] { + const cards: ContextTagCardView[] = []; + const allWorkerMounts: ContextTagMountView[] = workers + .filter((w) => w.id) + .map((w) => ({ + workerId: w.id, + workerName: w.displayName, + how: "prompt_body" as const, + })); + const workerCount = allWorkerMounts.length; + + const narrative = parsed.narrative_guide?.trim(); + if (narrative) { + const mounts = allWorkerMounts.map((m) => ({ ...m, how: "prompt_body" as const })); + cards.push({ + id: "fixed:narrative_guide", + label: "叙事指南", + kind: "fixed", + preview: previewText(narrative), + filled: true, + mounts, + mountSummary: formatMountSummary(mounts, workerCount), + }); + } + + const premises = (parsed.core_premises ?? []).filter( + (p): p is string => typeof p === "string" && Boolean(p.trim()), + ); + if (premises.length) { + const mounts = allWorkerMounts.map((m) => ({ ...m, how: "prompt_body" as const })); + cards.push({ + id: "fixed:core_premises", + label: "核心实现前提", + kind: "fixed", + preview: previewText(premises.join(";")), + filled: true, + mounts, + mountSummary: formatMountSummary(mounts, workerCount), + }); + } + + if (parsed.input_protocol && Object.values(parsed.input_protocol).some(Boolean)) { + const mounts = allWorkerMounts.map((m) => ({ ...m, how: "prompt_body" as const })); + const bits = Object.entries(parsed.input_protocol) + .filter(([, v]) => v) + .map(([k, v]) => `${INPUT_PROTOCOL_LABELS[k] ?? k}:${v}`); + cards.push({ + id: "fixed:input_protocol", + label: "输入协议", + kind: "fixed", + preview: previewText(bits.join(";")), + filled: true, + mounts, + mountSummary: formatMountSummary(mounts, workerCount), + }); + } + + // 美学:各 worker 自己的 presentation → 只挂该 worker + for (const w of workers) { + if (!w.presentation?.length) continue; + const preview = w.presentation.map((p) => `${p.label}:${p.value}`).join(";"); + const mounts: ContextTagMountView[] = [ + { + workerId: w.id, + workerName: w.displayName, + how: "prompt_body", + }, + ]; + cards.push({ + id: `fixed:aesthetics:${w.id ?? w.order}`, + label: `美学纲领 · ${w.displayName}`, + kind: "fixed", + preview: previewText(preview), + filled: true, + mounts, + mountSummary: formatMountSummary(mounts, workerCount), + }); + } + + const residents = parseResidentContext(parsed.resident_context); + for (const entry of residents) { + const mounts = resolveResidentMounts(entry, workers); + cards.push({ + id: `resident:${entry.id}`, + label: `常驻 · ${entry.id}`, + kind: "resident", + preview: previewText(entry.content), + filled: true, + mounts, + mountSummary: formatMountSummary(mounts, workerCount), + }); + } + + // 黑板 tag:按「谁读」反查(与固定正文解耦) + const boardMap = new Map< + string, + { mounts: ContextTagMountView[]; tiers: Set } + >(); + for (const w of workers) { + for (const t of w.context.staticTags) { + const cur = boardMap.get(t.tag) ?? { mounts: [], tiers: new Set() }; + cur.mounts.push({ + workerId: w.id, + workerName: w.displayName, + how: "input_tag", + tier: "static", + }); + cur.tiers.add("static"); + boardMap.set(t.tag, cur); + } + for (const t of w.context.dynamicTags) { + const cur = boardMap.get(t.tag) ?? { mounts: [], tiers: new Set() }; + cur.mounts.push({ + workerId: w.id, + workerName: w.displayName, + how: "input_tag", + tier: "dynamic", + }); + cur.tiers.add("dynamic"); + boardMap.set(t.tag, cur); + } + } + + // 跳过已由 resident 显式 tag 覆盖的 + const residentBoardTags = new Set(residents.map((e) => residentTagFor(e))); + for (const [tag, info] of boardMap) { + if (residentBoardTags.has(tag)) continue; + if (tag === "设计.worker集") continue; // 规格本身,不当「上下文块」 + const mounts = info.mounts; + cards.push({ + id: `board:${tag}`, + label: tag, + kind: "board", + filled: filledTags.length ? tagFilled(tag, filledTags) : false, + mounts, + mountSummary: formatMountSummary(mounts, workerCount), + }); + } + + return cards; +} + +function resolveResidentMounts( + entry: ResidentContextEntry, + workers: WorkerCardView[], +): ContextTagMountView[] { + const withIds = workers.filter((w) => w.id); + if (!entry.mount || entry.mount.length === 0) { + return withIds.map((w) => ({ + workerId: w.id, + workerName: w.displayName, + how: "resident" as const, + tier: entry.position === "dynamic" ? ("dynamic" as const) : ("static" as const), + })); + } + const wanted = new Set(entry.mount); + return withIds + .filter((w) => w.id && wanted.has(w.id)) + .map((w) => ({ + workerId: w.id, + workerName: w.displayName, + how: "resident" as const, + tier: entry.position === "dynamic" ? ("dynamic" as const) : ("static" as const), + })); +} + +function summarizeWorkerReads( + w: WorkerCardView, + contextTags: ContextTagCardView[], +): string { + const injected = contextTags + .filter( + (c) => + (c.kind === "fixed" || c.kind === "resident") && + c.mounts.some((m) => m.workerId === w.id || (!m.workerId && !w.id)), + ) + .map((c) => c.label); + // 「全部 Worker」挂载的 fixed 也算 + const allInjected = contextTags + .filter( + (c) => + (c.kind === "fixed" || c.kind === "resident") && + (c.mountSummary === "全部 Worker" || + c.mounts.some((m) => m.workerId === w.id)), + ) + .map((c) => c.label); + const labels = [...new Set(allInjected.length ? allInjected : injected)]; + const boardCount = + w.context.staticTags.length + w.context.dynamicTags.length; + const parts: string[] = []; + if (labels.length) parts.push(labels.slice(0, 3).join("、") + (labels.length > 3 ? "…" : "")); + if (boardCount) parts.push(`黑板 ${boardCount} 项`); + return parts.length ? parts.join(" · ") : "(未声明上下文)"; +} + +/** 将解析后的 Worker 集格式化为用户可读视图 */ +export function formatWorkerSetForUser( + parsed: ParsedWorkerSet | null, + options: FormatWorkerSetOptions = {}, +): WorkerSetUserView | null { + if (!parsed) return null; + const filledTags = options.filledTags ?? []; + + if (parsed.parseError) { + return { + workers: [], + contextTags: [], + tagFlow: [], + designTasks: [], + openQuestions: parsed.open_questions ?? [], + parseError: parsed.parseError, + }; + } + + const morphKey = parsed.play_morphology?.trim(); + const morph = morphKey ? PLAY_MORPHOLOGY[morphKey] : undefined; + + const workers: WorkerCardView[] = parsed.workers.map((entry, index) => { + const meta = entry.ref ? runWorkerMeta(entry.ref) : null; + const contract = resolveContract(entry); + const displayName = workerDisplayName(entry, index); + + return { + order: index + 1, + id: entry.ref, + displayName, + roleLabel: entry.role ? WORKER_ROLE_LABELS[entry.role] ?? entry.role : undefined, + status: entry.ref == null ? "gap" : "ready", + gapNote: entry.gap ?? (entry.ref == null ? "尚未有对应 SKILL" : undefined), + purpose: entry.duty?.trim() || meta?.purpose || "—", + invokeWhen: entry.when?.trim() || "由 Agent 按本轮状态决定", + rationale: entry.rationale, + acceptance: entry.acceptance, + acceptanceLabel: + entry.acceptance === "review" + ? "完成后验收" + : entry.acceptance === "continue" + ? "可连跑" + : undefined, + mergeConsidered: entry.merge_considered, + context: { + staticTags: toTagLines(contract.context.static, filledTags, "static"), + dynamicTags: toTagLines(contract.context.dynamic, filledTags, "dynamic"), + explicit: contract.explicit, + }, + writes: contract.outputs, + presentation: entry.presentation + ? formatPresentation(entry.presentation) + : undefined, + }; + }); + + const contextTags = buildContextTagCards(parsed, workers, filledTags); + for (const w of workers) { + w.readsSummary = summarizeWorkerReads(w, contextTags); + } + + const invoke = parsed.instantiate_hints?.invoke ?? []; + const designTasks: DesignTaskView[] = []; + + for (const id of invoke) { + const meta = instantiateMeta(id); + const relatedTags = instantiateTagsForSkill(id); + const filled = relatedTags.some((t) => tagFilledOnBoard(t, filledTags)); + designTasks.push({ + id, + label: meta.label, + status: filled ? "filled" : "planned", + }); + } + for (const id of parsed.instantiate_hints?.skip ?? []) { + designTasks.push({ + id, + label: instantiateMeta(id).label, + status: "skipped", + skipReason: parsed.instantiate_hints?.skip_reason, + }); + } + + const inputProtocol = parsed.input_protocol + ? Object.entries(parsed.input_protocol) + .filter(([, v]) => v) + .map(([key, value]) => ({ + label: INPUT_PROTOCOL_LABELS[key] ?? key, + value, + })) + : undefined; + + const creationUnits = listCreationUnits(parsed, { + acceptedUnitIds: options.acceptedUnitIds, + currentUnitId: options.currentUnitId, + }); + + const currentUnit = + creationUnits.find((u) => u.current) ?? + (options.currentUnitId + ? creationUnits.find((u) => u.id === options.currentUnitId) + : undefined); + + let focusUnit: WorkerSetUserView["focusUnit"]; + if (currentUnit) { + const body = + currentUnit.kind === "fixed" || currentUnit.kind === "phase" + ? extractUnitContentFromDraft(parsed, currentUnit.id) + : currentUnit.kind === "worker" + ? extractUnitContentFromDraft(parsed, currentUnit.id) + : null; + focusUnit = { + id: currentUnit.id, + kind: currentUnit.kind, + label: currentUnit.label, + body: body ?? undefined, + }; + } + + return { + headline: parsed.form_summary, + interactionParadigm: parsed.interaction_paradigm, + coreWorker: parsed.core_worker, + reasoning: parsed.reasoning, + playModeLabel: morph?.label ?? morphKey, + playModeHint: morph?.hint, + inputProtocol, + workers, + contextTags, + tagFlow: parseTagFlow(parsed.tag_flow), + designTasks, + creationUnits, + focusUnit, + openQuestions: parsed.open_questions ?? [], + notes: parsed.notes, + }; +} + +function tagFilledOnBoard(tagPattern: string, filledTags: string[]): boolean { + if (tagPattern.endsWith(".")) { + return filledTags.some((t) => t.startsWith(tagPattern)); + } + return tagFilled(tagPattern, filledTags); +} + +/** 创作阶段收尾 skill 典型写入 tag(用于设计任务「已填」检测) */ +function instantiateTagsForSkill(skillId: string): string[] { + if (skillId === "opening-generator") { + return ["输出.开场白", "运行.初始变量"]; + } + return []; +} diff --git a/src/stats/token-store.ts b/src/stats/token-store.ts index 61d42e4..68c443e 100644 --- a/src/stats/token-store.ts +++ b/src/stats/token-store.ts @@ -30,6 +30,8 @@ export type MessageTokenUsage = { caller: string; model?: string; recordId?: string; + /** ISO 时间戳,便于按时间段汇总 */ + at?: string; }; export type CallerTokenBreakdown = { @@ -243,5 +245,6 @@ export function toMessageTokenUsage(record: TokenUsageRecord): MessageTokenUsage caller: record.caller, model: record.model, recordId: record.id, + at: record.at, }; } diff --git a/src/types/book-session.ts b/src/types/book-session.ts index ba397ad..6af2216 100644 --- a/src/types/book-session.ts +++ b/src/types/book-session.ts @@ -10,6 +10,7 @@ export type PersistedBookSession = { runtimeSession: RuntimeSession; blackboardItems: BlackboardItem[]; messages: PersistedChatMessage[]; + messageBranchState?: PersistedMessageBranchState; savedAt: string; }; @@ -31,4 +32,40 @@ export type PersistedChatMessage = { caller?: string; model?: string; }; + /** 全量 LLM 请求上下文(可选;按设置只保留最新 N 条) */ + contextTrace?: { + caller: string; + createdAt: string; + messages: Array<{ role: string; content: string }>; + charCount: number; + model?: string; + }; + branchGroupId?: string; + branchIndex?: number; + branchTotal?: number; +}; + +export type PersistedMessageBranchState = { + branches: Record< + string, + { + anchorIndex: number; + groupId: string; + activeIndex: number; + variants: Array<{ + messages: PersistedChatMessage[]; + checkpoint: { + runtimeSession: RuntimeSession; + blackboardItems: BlackboardItem[]; + }; + }>; + } + >; + preMessageCheckpoints: Record< + string, + { + runtimeSession: RuntimeSession; + blackboardItems: BlackboardItem[]; + } + >; }; diff --git a/src/types/context-trace.ts b/src/types/context-trace.ts new file mode 100644 index 0000000..ff2ec83 --- /dev/null +++ b/src/types/context-trace.ts @@ -0,0 +1,63 @@ +/** 一次 LLM 调用实际发送的上下文(全量,供右键观察) */ +export type LlmContextMessage = { + role: string; + content: string; +}; + +export type LlmContextTrace = { + /** 调用方,如 worker:design-core / main-agent */ + caller: string; + createdAt: string; + messages: LlmContextMessage[]; + /** 便于列表展示 */ + charCount: number; + model?: string; +}; + +export function buildContextTrace(params: { + caller: string; + messages: Array<{ role: string; content: string }>; + model?: string; +}): LlmContextTrace { + const messages = params.messages.map((m) => ({ + role: m.role, + content: typeof m.content === "string" ? m.content : String(m.content ?? ""), + })); + const charCount = messages.reduce((n, m) => n + m.content.length, 0); + return { + caller: params.caller, + createdAt: new Date().toISOString(), + messages, + charCount, + model: params.model, + }; +} + +/** + * 只保留「带 contextTrace 的消息」中最新 keepLatest 条的全文; + * 更早的消息删除 contextTrace 字段(消息本身保留)。 + */ +export function pruneContextTraces( + messages: T[], + keepLatest: number, +): T[] { + const keep = Math.max(0, Math.floor(keepLatest)); + if (keep <= 0) { + return messages.map((m) => { + if (!m.contextTrace) return m; + const { contextTrace: _drop, ...rest } = m as T & { contextTrace?: LlmContextTrace }; + return rest as T; + }); + } + const withTraceIdx: number[] = []; + for (let i = 0; i < messages.length; i++) { + if (messages[i].contextTrace) withTraceIdx.push(i); + } + if (withTraceIdx.length <= keep) return messages; + const drop = new Set(withTraceIdx.slice(0, withTraceIdx.length - keep)); + return messages.map((m, i) => { + if (!drop.has(i) || !m.contextTrace) return m; + const { contextTrace: _drop, ...rest } = m as T & { contextTrace?: LlmContextTrace }; + return rest as T; + }); +} diff --git a/src/types/questions.ts b/src/types/questions.ts new file mode 100644 index 0000000..2d2bc27 --- /dev/null +++ b/src/types/questions.ts @@ -0,0 +1,23 @@ +/** 结构化追问协议类型(Worker askUser / Agent ask_user 共用) */ + +export type QuestionOption = { + id: string; + label: string; + /** 默认 true:点文案可改写,点字母才选中 */ + editable?: boolean; +}; + +export type QuestionItem = { + id: string; + prompt: string; + options?: QuestionOption[]; + allowOther?: boolean; + required?: boolean; +}; + +export type QuestionAnswer = { + questionId: string; + optionId?: string; + /** 最终确认文案(必填,可含用户改写) */ + text: string; +}; diff --git a/src/types/run-snapshot.ts b/src/types/run-snapshot.ts index 6322f10..7d08582 100644 --- a/src/types/run-snapshot.ts +++ b/src/types/run-snapshot.ts @@ -51,6 +51,6 @@ export function toRunSnapshotMeta(snapshot: RunSnapshot): RunSnapshotMeta { } export const SNAPSHOT_KIND_LABELS: Record = { - instance: "实例", - run: "进度", + instance: "创作定稿", + run: "游玩进度", }; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index d36f739..9300e45 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -8,6 +8,7 @@ */ import type { IntakeFieldDef } from "./intake.js"; +import type { QuestionItem } from "./questions.js"; /** 运行相位:系统当前在等什么。只有 5 种。 */ export type RuntimePhase = @@ -24,10 +25,30 @@ export type RuntimePhase = export type WaitingReason = | { kind: "skill_selection"; availableSkills: SkillIndexEntry[] } // 启动:选 SKILL.md | { kind: "intake"; prompt: string } // 启动填空:必要/可选项收集 - | { kind: "input"; message?: string } // 总管 ask_user / 返工说明(启动完成后) + | { + kind: "input"; + message?: string; + /** 总管 ask_user 结构化追问(有则走询问卡) */ + questions?: QuestionItem[]; + pageSize?: number; + } // 总管 ask_user / 返工说明(启动完成后) | { kind: "approve_step"; decisionId: string } // 总管建议 run_worker,等用户确认 - | { kind: "review_artifact"; artifactId: string } // worker 产物待验收 - | { kind: "worker_questions"; workerId: string; questions: string[] } // worker 中途提问 + | { + kind: "review_artifact"; + artifactId: string; + /** + * 挂在产物下的可选追问(Cursor AskQuestion 心流)。 + * 有值时不阻断验收:用户可直接 Accept,也可先作答再 Accept。 + */ + questions?: QuestionItem[]; + pageSize?: number; + } // worker 产物待验收 + | { + kind: "worker_questions"; + workerId: string; + questions: QuestionItem[]; + pageSize?: number; + } // worker 无产物时的阻塞提问 | { kind: "revision"; instruction?: string }; // 产物被拒或程序验收失败 /** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */ @@ -43,6 +64,9 @@ export type SkillIndexEntry = { */ export type BookKind = "novel" | "dialogue"; +/** orchestrator 启动模式 */ +export type SkillStartupMode = "intake" | "agent-first"; + /** 选中的 skill 快照,写入 session.slots.activeSkill,供启动询问与后续流程使用 */ export type ActiveSkillSnapshot = { name: string; @@ -52,6 +76,10 @@ export type ActiveSkillSnapshot = { bookKind?: BookKind; defaultFlowId?: string; suggestedWorkers: string[]; + /** intake:总管填空;agent-first:UI 固定引导 → 用户首句 → Agent 调 Skill */ + startupMode?: SkillStartupMode; + /** agent-first 时首屏展示给用户的固定引导(纯 UI) */ + uiPrompt?: string; /** 来自 SKILL.md ## 启动询问 的展示文案 */ startupPrompt: string; /** 用户首次输入写入的 slots 键,如 book.brief */ @@ -118,6 +146,10 @@ export type MainAgentDecision = { workerId?: string; /** 调度 role-decide 等时指定当前决策角色,Runtime 写入 世界.当前角色.id */ workerContext?: { roleId?: string }; + /** ask_user:给用户看的内容完备度评价(写入 waitingReason.message) */ + assessment?: string; + /** ask_user 结构化追问(有则前端询问卡) */ + questions?: QuestionItem[]; /** true 时进入 waiting_user(approve_step),等用户确认后才 run_worker */ requiresApproval: boolean; statePatchAllowed: false; @@ -149,7 +181,7 @@ export type ResumeContext = { workerId: string; stepId?: string; acceptanceMode: AcceptanceMode; - questions: string[]; + questions: QuestionItem[]; }; /** @@ -159,7 +191,13 @@ export type ResumeContext = { export type RuntimeEvent = | { type: "session_started"; - payload: { presetId: string; flowId?: string; availableSkills: SkillIndexEntry[] }; + payload: { + presetId: string; + flowId?: string; + availableSkills: SkillIndexEntry[]; + /** 提供时跳过 skill_selection,直接进入 intake(新建作品默认路径) */ + initialSkill?: ActiveSkillSnapshot; + }; } | { type: "skill_selected"; payload: { skill: ActiveSkillSnapshot } } | { @@ -184,10 +222,26 @@ export type RuntimeEvent = acceptanceMode: AcceptanceMode; }; } - | { type: "worker_completed"; payload: { artifactId: string } } + | { + type: "worker_completed"; + payload: { + artifactId: string; + /** 有产物时的可选追问,挂到 review_artifact */ + questions?: QuestionItem[] | string[]; + }; + } | { type: "worker_needs_input"; - payload: { workerId: string; stepId?: string; questions: string[] }; + payload: { + workerId: string; + stepId?: string; + questions: QuestionItem[] | string[]; + }; + } + | { + /** 验收态下作答/跳过挂载追问:不离开 review_artifact */ + type: "user_resolved_sidecar_questions"; + payload: { answersText?: string }; } | { type: "user_accepted_artifact"; payload: { artifactId: string } } | { diff --git a/src/types/tools.ts b/src/types/tools.ts index 6b1102e..41011b8 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -41,7 +41,17 @@ export type ListArtifactsParams = Record; export type AskUserParams = { reason: string; + /** 给用户看的内容完备度评价(Markdown);优先于 message */ + assessment?: string; + /** @deprecated 兼容旧调用,等同 assessment */ message?: string; + questions?: Array<{ + id?: string; + prompt: string; + options?: Array<{ id?: string; label: string; editable?: boolean } | string>; + allowOther?: boolean; + required?: boolean; + }>; }; export type RunWorkerParams = { diff --git a/src/worker/executor.ts b/src/worker/executor.ts index a8f74a1..1b0e274 100644 --- a/src/worker/executor.ts +++ b/src/worker/executor.ts @@ -1,10 +1,23 @@ import type { Blackboard } from "../blackboard/blackboard.js"; -import type { LlmProvider } from "../llm/client.js"; +import type { LlmProvider, StreamCallbacks } from "../llm/client.js"; +import { supportsContentStream } from "../llm/stream-complete.js"; import { loadWorkerSkillWithContext } from "../skills/loader.js"; import { filterInputsForRolePerspective } from "../skills/worker-llm.js"; import { collectUserInputTranscript } from "../intake/intake.js"; import { resolveWorkerId } from "./resolve-id.js"; import type { BlackboardInputMerge } from "../types/blackboard.js"; +import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js"; +import type { ParsedWorkerSkill } from "../skills/types.js"; +import { + extractJsonObjectText, + isUsableWorkerSet, + parseWorkerSetYaml, +} from "../skills/worker-set-parse.js"; +import { assembleWorkerContext } from "../skills/context-segments.js"; +import { + normalizeQuestions, + type QuestionItem, +} from "../skills/question-protocol.js"; export type WorkerRunParams = { skillName: string; @@ -12,15 +25,25 @@ export type WorkerRunParams = { slots: Record; blackboard: Blackboard; llm: LlmProvider; + stream?: WorkerStreamCallbacks; + /** 声明驱动:跳过磁盘 SKILL,直接用解析好的契约 */ + declared?: { worker: ParsedWorkerSkill; promptBody: string }; +}; + +export type WorkerStreamCallbacks = { + onThinkingDelta?: (delta: string) => void; + onOutputDelta?: (delta: string) => void; }; export type WorkerRunResult = { outputs: Record; summary: string; preview: string; - askUser?: string[]; + askUser?: QuestionItem[]; }; +const WORKER_SET_OUTPUT_TAGS = new Set(["设计.worker集", "设计.worker集.草稿"]); + const WORKER_OUTPUT_INSTRUCTION = ` --- @@ -31,12 +54,18 @@ const WORKER_OUTPUT_INSTRUCTION = ` { "outputs": { "": "<内容字符串>" }, "summary": "50字以内产物摘要", - "askUser": null 或 ["需要用户补充的问题"] + "askUser": null 或 问题数组 } +askUser 每项可为: +- 字符串:"需要用户补充的问题" +- 或结构化:{ "id": "q1", "prompt": "问题", "options": [{ "id": "A", "label": "可编辑完整句选项" }], "allowOther": true } + - outputs 的 key 必须是要求的 outputTags -- 若信息不足,outputs 可为空对象,askUser 填入问题 -- 若 inputs 中 \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问 +- **设计.worker集 / 设计.worker集.草稿**:value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML +- **优先同时给 outputs + askUser**:先交出可用草稿/产物,追问挂在产物下(用户可直接接受而不作答)。仅当完全无法产出时才留空 outputs、只填 askUser +- 能推断选项时 **必须**给 options(完整句、可改写);不要只丢裸问题逼用户写长段 +- 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问 - summary 用于界面展示`; function slotValueForTag( @@ -82,6 +111,16 @@ function gatherInputs( } } + if (inputTags.includes("用户.需求") && !inputs["用户.需求"]) { + const fromSlot = slotValueForTag("用户.需求", slots); + if (fromSlot) { + inputs["用户.需求"] = fromSlot; + } else { + const transcript = collectUserInputTranscript(slots); + if (transcript) inputs["用户.需求"] = transcript; + } + } + return inputs; } @@ -93,6 +132,20 @@ function parseWorkerResponse( try { parsed = JSON.parse(raw); } catch { + // 整段不是协议 JSON:切勿把散文塞进设计.worker集* + if (outputTags.some((t) => WORKER_SET_OUTPUT_TAGS.has(t))) { + const questions = normalizeQuestions(extractQuestionsFromText(raw)); + return { + outputs: {}, + summary: "未产出合法协议 JSON", + preview: raw.slice(0, 600), + askUser: questions.length + ? questions.slice(0, 2) + : normalizeQuestions([ + "请补充设计所需的关键信息(上一次未产出合法 JSON 规格)。", + ]), + }; + } const fallback: Record = {}; if (outputTags.length === 1) { fallback[outputTags[0]] = raw; @@ -119,21 +172,26 @@ function parseWorkerResponse( const val = (outputsRaw as Record)[tag]; if (typeof val === "string" && val.trim()) { outputs[tag] = val.trim(); + } else if (val && typeof val === "object") { + // 模型有时直接回对象而非字符串 + outputs[tag] = JSON.stringify(val); } } } const isMetaAskToken = (s: string) => /^ask[_-]?user$/i.test(s.trim()); - let askUser: string[] | undefined; + let askUser: QuestionItem[] | undefined; if (Array.isArray(obj.askUser)) { - askUser = obj.askUser.filter( - (q): q is string => - typeof q === "string" && q.trim().length > 0 && !isMetaAskToken(q), + askUser = normalizeQuestions( + obj.askUser.filter((q) => { + if (typeof q === "string") return q.trim() && !isMetaAskToken(q); + return true; + }), ); } else if (typeof obj.askUser === "string" && obj.askUser.trim()) { const q = obj.askUser.trim(); - askUser = isMetaAskToken(q) ? undefined : [q]; + askUser = isMetaAskToken(q) ? undefined : normalizeQuestions([q]); } if ((!askUser || askUser.length === 0) && Object.keys(outputs).length === 0) { const summaryText = @@ -144,9 +202,11 @@ function parseWorkerResponse( summaryText.length > 8 && /[??]/.test(summaryText) ) { - askUser = [summaryText]; + askUser = normalizeQuestions([summaryText]); } else if (isMetaAskToken(summaryText)) { - askUser = ["请补充当前步骤所需的信息(情境、参数或你的具体设想)。"]; + askUser = normalizeQuestions([ + "请补充当前步骤所需的信息(情境、参数或你的具体设想)。", + ]); } } @@ -161,15 +221,150 @@ function parseWorkerResponse( .join("\n\n") .slice(0, 4000) || summary; - return { outputs, summary, preview, askUser: askUser?.length ? askUser : undefined }; + return sanitizeWorkerSetOutputs({ + outputs, + summary, + preview, + askUser: askUser?.length ? askUser : undefined, + }); +} + +/** 规格 tag 必须是可用 JSON;非法散文改为 askUser,避免污染黑板 */ +export function sanitizeWorkerSetOutputs(result: WorkerRunResult): WorkerRunResult { + const outputs = { ...result.outputs }; + const askUser = [...(result.askUser ?? [])]; + let droppedProse = false; + + for (const tag of [...Object.keys(outputs)]) { + if (!WORKER_SET_OUTPUT_TAGS.has(tag)) continue; + const content = outputs[tag]; + if (!content?.trim()) { + delete outputs[tag]; + continue; + } + const extracted = extractJsonObjectText(content) ?? content.trim(); + const parsed = parseWorkerSetYaml(extracted); + if (isUsableWorkerSet(parsed)) { + outputs[tag] = extracted.startsWith("{") + ? extracted + : (extractJsonObjectText(extracted) ?? extracted); + continue; + } + droppedProse = true; + delete outputs[tag]; + if (askUser.length === 0) { + const qs = normalizeQuestions(extractQuestionsFromText(content)); + if (qs.length) askUser.push(...qs.slice(0, 2)); + } + } + + if (droppedProse && askUser.length === 0) { + askUser.push( + ...normalizeQuestions([ + "请补充或确认开局关键前提(上一次把说明文字写进了规格字段,未产出合法 JSON)。", + ]), + ); + } + + // 提问时不要夹带半残规格 + if (askUser.length > 0) { + for (const tag of WORKER_SET_OUTPUT_TAGS) { + // 保留仍合法的草稿;已在上面删掉非法的 + void tag; + } + } + + const summary = + askUser.length && Object.keys(outputs).length === 0 + ? `待补充:${askUser[0]!.prompt.slice(0, 40)}` + : result.summary; + + const preview = + askUser.length && Object.keys(outputs).length === 0 + ? askUser.map((q) => `- ${q.prompt}`).join("\n") + : Object.entries(outputs) + .map(([tag, v]) => `### ${tag}\n\n${v}`) + .join("\n\n") + .slice(0, 4000) || summary; + + return { + outputs, + summary, + preview, + askUser: askUser.length ? askUser : undefined, + }; +} + +function extractQuestionsFromText(text: string): string[] { + const lines = text + .split(/\n+/) + .map((l) => l.replace(/^[-*•\d.、))]+\s*/, "").trim()) + .filter((l) => l.length >= 6); + const withMark = lines.filter((l) => /[??]/.test(l) || /请(?:描述|选择|确认|补充)/.test(l)); + if (withMark.length) return withMark.slice(0, 3); + // 整段像提问说明 + if (/请(?:描述|选择|确认|补充)|你选择|或者你也可以/.test(text)) { + const compact = text.replace(/\s+/g, " ").trim().slice(0, 240); + if (compact) return [compact]; + } + return []; +} + +async function completeWorkerPreferStream( + llm: LlmProvider, + messages: Parameters[0], + options: Parameters[1], + stream?: WorkerStreamCallbacks, +): Promise>> { + const callbacks: StreamCallbacks = { + onReasoningDelta: (delta) => stream?.onThinkingDelta?.(delta), + onContentDelta: (delta) => stream?.onOutputDelta?.(delta), + }; + if (supportsContentStream(llm)) { + return llm.completeStream!(messages, options, callbacks); + } + const result = await llm.complete(messages, options); + if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning); + if (result.content) callbacks.onContentDelta?.(result.content); + return result; } export async function runWorkerSkill(params: WorkerRunParams): Promise { const workerId = resolveWorkerId(params.workerId); - const { worker, promptBody } = await loadWorkerSkillWithContext( - params.skillName, - workerId, - ); + let worker: ParsedWorkerSkill; + let promptBody: string; + + if (params.declared) { + worker = params.declared.worker; + promptBody = params.declared.promptBody; + } else { + const flowRaw = params.blackboard.getContentByTag("设计.创作流程"); + const currentStepName = params.blackboard.getContentByTag("创作.当前步骤"); + const selectedRecipeRef = params.blackboard.getContentByTag("创作.选用配方"); + const acceptedRaw = + params.slots.creationAcceptedUnits ?? + params.blackboard.getContentByTag("创作.已验收单位"); + let acceptedStepNames: string[] = []; + try { + const { parseAcceptedSteps } = await import("../skills/creation-flow.js"); + acceptedStepNames = parseAcceptedSteps(acceptedRaw); + } catch { + acceptedStepNames = []; + } + const loaded = await loadWorkerSkillWithContext( + params.skillName, + workerId, + undefined, + { + flowRaw, + currentStepName, + acceptedStepNames, + selectedRecipeRef, + }, + ); + worker = loaded.worker; + promptBody = loaded.promptBody; + } const inputMerge = worker.inputMerge ?? "latest"; let inputs = gatherInputs( @@ -183,30 +378,46 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise { - it("parses orchestrator decisions", () => { - const m = classifyAgentMessage("[总管] run_worker: 生成规则"); - expect(m.kind).toBe("orchestrator_decision"); - expect(m.body).toBe("生成规则"); - }); - - it("parses agent tool calls", () => { - const m = classifyAgentMessage("[总管 tool] invoke_worker: write-rules"); - expect(m.kind).toBe("agent_tool"); - expect(m.title).toContain("invoke_worker"); - expect(m.body).toBe("write-rules"); - }); - - it("parses worker output", () => { - const m = classifyAgentMessage("[Worker] write-rules 已完成\n\n### rules\n\n1. foo"); - expect(m.kind).toBe("worker_output"); - expect(m.actor).toBe("write-rules"); - expect(m.body).toContain("1. foo"); - }); - - it("parses worker questions", () => { - const m = classifyAgentMessage( - "[Worker] setup-scenario 提问:\n- 请提供玩家人数", - ); - expect(m.kind).toBe("worker_questions"); - expect(m.actor).toBe("setup-scenario"); - expect(m.body).toContain("请提供玩家人数"); - }); - - it("falls back when worker question body is empty", () => { - const m = classifyAgentMessage("Worker 提问:\n"); - expect(m.kind).toBe("worker_questions"); - expect(m.body).toContain("请补充当前步骤"); - }); -}); - -describe("lifecycle", () => { - it("infers design before startup completed", () => { - const session = createSession(); - expect(inferLifecycleStage(session)).toBe("design"); - expect(canEnterPlay(session)).toBe(false); - }); - - it("infers play after startup completed", () => { - const session = { - ...createSession(), - slots: { ...createSession().slots, startupCompleted: true }, - }; - expect(inferLifecycleStage(session)).toBe("play"); - expect(canEnterPlay(session)).toBe(true); - }); - - it("respects uiLifecycleStage override", () => { - const session = { - ...createSession(), - slots: { ...createSession().slots, uiLifecycleStage: "play" }, - }; - expect(inferLifecycleStage(session)).toBe("play"); - }); -}); - -describe("buildSkillCatalog", () => { - it("lists design skills for world-simulator", () => { - const session = createSession(); - const catalog = buildSkillCatalog(session, "world-simulator", "design"); - expect(catalog.length).toBeGreaterThan(0); - expect(catalog.every((s) => s.stage === "design")).toBe(true); - expect(catalog.some((s) => s.id === "interaction-paradigm")).toBe(true); - expect(catalog[0].purpose.length).toBeGreaterThan(0); - }); - - it("marks intake active during intake wait", () => { - const session = { - ...createSession(), - waitingReason: { kind: "intake" as const, prompt: "请填写" }, - }; - const catalog = buildSkillCatalog(session, "basic", "design"); - const intake = catalog.find((s) => s.id === "intake"); - expect(intake?.status).toBe("active"); - }); -}); - -describe("buildToolTrace", () => { - it("collects agent_tool messages since last user input", () => { - const trace = buildToolTrace([ - { - kind: "user_input", - text: "hello", - createdAt: "2026-01-01T00:00:00Z", - }, - { - kind: "agent_tool", - text: "[总管 tool] read_blackboard: tags=3", - createdAt: "2026-01-01T00:00:01Z", - }, - ]); - expect(trace).toHaveLength(1); - expect(trace[0].name).toBe("read_blackboard"); - }); -}); - -describe("buildFocus", () => { - it("focuses user on input wait", () => { - const session = { - ...createSession(), - phase: "waiting_user" as const, - waitingReason: { kind: "input" as const, message: "请描述场景" }, - }; - const focus = buildFocus(session, session.waitingReason); - expect(focus.actorType).toBe("user"); - }); - - it("focuses user on worker questions", () => { - const session = { - ...createSession(), - phase: "waiting_user" as const, - waitingReason: { - kind: "worker_questions" as const, - workerId: "setup-scenario", - questions: ["请提供玩家人数"], - }, - }; - const focus = buildFocus(session, session.waitingReason); - expect(focus.actorType).toBe("user"); - expect(focus.action).toContain("setup-scenario"); - expect(focus.detail).toContain("请提供玩家人数"); - }); - - it("shows design burst when running in design lifecycle", () => { - const session = { - ...createSession(), - phase: "running" as const, - }; - const focus = buildFocus(session, undefined, undefined, "design"); - expect(focus.actorLabel).toBe("Agent"); - expect(focus.action).toContain("设计"); - }); -}); - \ No newline at end of file +import { describe, expect, it } from "vitest"; +import { + classifyAgentMessage, + buildFocus, + buildSkillCatalog, + buildToolTrace, + inferLifecycleStage, + canEnterPlay, +} from "../src/server/agent-view.js"; +import { createSession } from "../src/runtime/phase-machine.js"; + +describe("classifyAgentMessage", () => { + it("parses orchestrator decisions", () => { + const m = classifyAgentMessage("[总管] run_worker: 生成规则"); + expect(m.kind).toBe("orchestrator_decision"); + expect(m.body).toBe("生成规则"); + }); + + it("parses agent tool calls", () => { + const m = classifyAgentMessage("[总管 tool] invoke_worker: write-rules"); + expect(m.kind).toBe("agent_tool"); + expect(m.title).toContain("工具 ·"); + expect(m.title).toContain("invoke_worker"); + expect(m.body).toBe("write-rules"); + }); + + it("parses worker output", () => { + const m = classifyAgentMessage("[Worker] design-core 已完成\n\n### rules\n\n1. foo"); + expect(m.kind).toBe("worker_output"); + expect(m.actor).toBe("design-core"); + expect(m.title).toBe("创作 · 核心 · 产出"); + expect(m.body).toContain("1. foo"); + }); + + it("parses worker questions", () => { + const m = classifyAgentMessage( + "[Worker] design-core 提问:\n- 请提供玩家人数", + ); + expect(m.kind).toBe("worker_questions"); + expect(m.actor).toBe("design-core"); + expect(m.title).toBe("创作 · 核心 · 提问"); + expect(m.body).toContain("请提供玩家人数"); + }); + + it("parses agent assessment and ask prompts", () => { + const a = classifyAgentMessage( + "[Agent] 内容评价:\n核心感觉: 完备度 40%\n 已知: 权力幻想", + ); + expect(a.kind).toBe("orchestrator_assessment"); + expect(a.title).toContain("内容评价"); + expect(a.body).toContain("完备度 40%"); + + const q = classifyAgentMessage("[Agent] 提问:\n- 你更倾向于哪种享受?"); + expect(q.kind).toBe("worker_questions"); + expect(q.actor).toBe("orchestrator"); + expect(q.body).toContain("哪种享受"); + + const opt = classifyAgentMessage( + "[Agent] 可选追问(可跳过):\n- 还想补感官吗?", + ); + expect(opt.kind).toBe("worker_questions"); + expect(opt.title).toContain("可选追问"); + }); + + it("falls back when worker question body is empty", () => { + const m = classifyAgentMessage("Worker 提问:\n"); + expect(m.kind).toBe("worker_questions"); + expect(m.body).toContain("请补充当前步骤"); + }); +}); + +describe("lifecycle", () => { + it("infers design before startup completed", () => { + const session = createSession(); + expect(inferLifecycleStage(session)).toBe("design"); + expect(canEnterPlay(session)).toBe(false); + }); + + it("stays in design until worker set accepted", () => { + const session = { + ...createSession(), + slots: { ...createSession().slots, startupCompleted: true }, + }; + expect(inferLifecycleStage(session)).toBe("design"); + expect(canEnterPlay(session)).toBe(false); + }); + + it("allows play only after accepted worker set", () => { + const session = { + ...createSession(), + slots: { ...createSession().slots, designInstanceReady: true }, + }; + expect(canEnterPlay(session)).toBe(true); + expect(inferLifecycleStage(session)).toBe("design"); + }); + + it("respects uiLifecycleStage play when instance ready", () => { + const session = { + ...createSession(), + slots: { + ...createSession().slots, + uiLifecycleStage: "play", + designInstanceReady: true, + }, + }; + expect(inferLifecycleStage(session)).toBe("play"); + }); + + it("blocks play override when instance not ready", () => { + const session = { + ...createSession(), + slots: { ...createSession().slots, uiLifecycleStage: "play" }, + }; + expect(inferLifecycleStage(session)).toBe("design"); + }); +}); + +describe("buildSkillCatalog", () => { + it("lists design skills for world-simulator from worker set scope", () => { + const session = createSession(); + const yaml = ` +form_summary: 测试 +workers: + - ref: narrator + duty: 展示 +instantiate_hints: + invoke: [narrative-guide] +`; + const catalog = buildSkillCatalog(session, "world-simulator", "design", { + workerSetYaml: yaml, + }); + expect(catalog.some((s) => s.id === "design-flow")).toBe(true); + expect(catalog.some((s) => s.id === "design-refine")).toBe(false); + expect(catalog.some((s) => s.id === "narrative-guide")).toBe(true); + expect(catalog.some((s) => s.id === "declare-ready")).toBe(true); + expect(catalog.every((s) => s.stage === "design")).toBe(true); + expect(catalog.find((s) => s.id === "world-blueprint")).toBeUndefined(); + }); + + it("lists run workers in play lifecycle from worker set", () => { + const session = { + ...createSession(), + slots: { ...createSession().slots, designInstanceReady: true }, + }; + const yaml = ` +workers: + - ref: world-simulator + - ref: narrator +`; + const catalog = buildSkillCatalog(session, "world-simulator", "play", { + workerSetYaml: yaml, + }); + expect(catalog.some((s) => s.id === "agent-burst")).toBe(true); + expect(catalog.some((s) => s.id === "world-simulator")).toBe(true); + expect(catalog.some((s) => s.id === "narrator")).toBe(true); + }); + + it("lists design step skills when no worker set yet", () => { + const session = { + ...createSession(), + waitingReason: { kind: "input" as const, message: "描述需求" }, + }; + const catalog = buildSkillCatalog(session, "world-simulator", "design"); + expect(catalog.some((s) => s.id === "design-flow")).toBe(true); + expect(catalog.some((s) => s.id === "design-step")).toBe(true); + expect(catalog.some((s) => s.id === "design-core")).toBe(false); + }); +}); + +describe("buildToolTrace", () => { + it("collects agent_tool messages since last user input", () => { + const trace = buildToolTrace([ + { + kind: "user_input", + text: "hello", + createdAt: "2026-01-01T00:00:00Z", + }, + { + kind: "agent_tool", + text: "[总管 tool] read_blackboard: tags=3", + createdAt: "2026-01-01T00:00:01Z", + }, + ]); + expect(trace).toHaveLength(1); + expect(trace[0].name).toBe("read_blackboard"); + }); +}); + +describe("buildFocus", () => { + it("focuses user on input wait", () => { + const session = { + ...createSession(), + phase: "waiting_user" as const, + waitingReason: { kind: "input" as const, message: "请描述场景" }, + }; + const focus = buildFocus(session, session.waitingReason); + expect(focus.actorType).toBe("user"); + }); + + it("focuses user on worker questions", () => { + const session = { + ...createSession(), + phase: "waiting_user" as const, + waitingReason: { + kind: "worker_questions" as const, + workerId: "design-core", + questions: [{ id: "q1", prompt: "请提供玩家人数" }], + }, + }; + const focus = buildFocus(session, session.waitingReason); + expect(focus.actorType).toBe("user"); + expect(focus.action).toContain("创作 · 核心"); + expect(focus.detail).toContain("请提供玩家人数"); + }); + + it("shows design burst when running in design lifecycle", () => { + const session = { + ...createSession(), + phase: "running" as const, + }; + const focus = buildFocus(session, undefined, undefined, "design"); + expect(focus.actorLabel).toBe("总管"); + expect(focus.action).toContain("创作"); + }); +}); + diff --git a/tests/book-store.test.ts b/tests/book-store.test.ts index d1ceff2..820e749 100644 --- a/tests/book-store.test.ts +++ b/tests/book-store.test.ts @@ -17,4 +17,31 @@ describe("book store", () => { expect(getBook(book.id)?.title).toBe("测试之书"); expect(listBooks().some((b) => b.id === book.id)).toBe(true); }); + + it("duplicates book with session data", async () => { + const { createBook, duplicateBook, getBook, listBooks } = await import("../src/book/store.js"); + const { saveBookSession } = await import("../src/book/session-store.js"); + const source = createBook({ title: "源作品" }); + saveBookSession({ + version: 1, + sessionId: "sess-1", + bookId: source.id, + runtimeSession: { + id: "sess-1", + phase: "idle", + presetId: "default", + slots: {}, + artifacts: [], + history: [], + }, + blackboardItems: [], + messages: [], + savedAt: new Date().toISOString(), + }); + const copy = duplicateBook(source.id, "备份作品"); + expect(copy.id).not.toBe(source.id); + expect(copy.title).toBe("备份作品"); + expect(listBooks().some((b) => b.id === copy.id)).toBe(true); + expect(getBook(copy.id)?.title).toBe("备份作品"); + }); }); diff --git a/tests/compress-after-worker.test.ts b/tests/compress-after-worker.test.ts new file mode 100644 index 0000000..e7a8fff --- /dev/null +++ b/tests/compress-after-worker.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { + CONTEXT_BRIEF_TAG, + buildBoardPanel, + compressAfterWorkerAccept, +} from "../src/runtime/compress-after-worker.js"; + +describe("compressAfterWorkerAccept", () => { + it("keeps finals, archives process tags, writes brief", () => { + const bb = new Blackboard(); + bb.write({ + tag: "设计.worker集.草稿", + content: '{ "workers": [] }', + source: "design-core", + }); + bb.write({ + tag: "设计.worker集", + content: '{ "workers": [{ "ref": "narrator" }] }', + source: "design-core", + }); + bb.write({ + tag: "用户.worker答复", + content: "过程问答……", + source: "user", + }); + + const result = compressAfterWorkerAccept({ + blackboard: bb, + workerId: "design-core", + outputTags: ["设计.worker集", "用户.需求"], + summary: "单角代入 + 叙事", + }); + + expect(result.finals.map((f) => f.tag)).toContain("设计.worker集"); + expect(result.archivedTags).toEqual( + expect.arrayContaining(["用户.worker答复", "设计.worker集.草稿"]), + ); + expect(bb.getContentByTag(CONTEXT_BRIEF_TAG)).toContain("设计.worker集"); + expect(bb.getContentByTag(CONTEXT_BRIEF_TAG)).toContain("单角代入"); + + const draftHits = bb.queryByPatterns(["设计.worker集.草稿"]); + expect(draftHits).toHaveLength(0); + + const finalHits = bb.queryByPatterns(["设计.worker集"]); + expect(finalHits).toHaveLength(1); + expect(finalHits[0].metadata?.role).toBe("final"); + + const panel = buildBoardPanel(bb); + expect(panel.finals.some((f) => f.tag === "设计.worker集")).toBe(true); + expect(panel.archivedCount).toBeGreaterThanOrEqual(1); + expect(panel.brief).toContain("定稿"); + }); +}); diff --git a/tests/context-assembly.test.ts b/tests/context-assembly.test.ts new file mode 100644 index 0000000..ea4aa05 --- /dev/null +++ b/tests/context-assembly.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { + assembleWorkerContext, + parseContextSegments, +} from "../src/skills/context-segments.js"; +import { + CREATION_ACCEPTED_CONTENT_TAG, + extractUnitContentFromDraft, + formatAcceptedContentForPrompt, + upsertAcceptedUnitContent, +} from "../src/skills/creation-units.js"; +import type { ParsedWorkerSet } from "../src/skills/worker-set-parse.js"; +import { loadWorkerSkill } from "../src/skills/loader.js"; + +describe("contextSegments assembly", () => { + it("parses segments from frontmatter shape", () => { + const segs = parseContextSegments([ + { id: "a", tier: "static", tags: ["创作.已验收内容"], label: "## 前情" }, + { id: "b", tier: "dynamic", tags: ["用户.需求"], policy: "latest" }, + ]); + expect(segs).toHaveLength(2); + expect(segs[0]?.label).toContain("前情"); + }); + + it("assembles markdown with labels when segments present", () => { + const bb = new Blackboard(); + bb.write({ + tag: CREATION_ACCEPTED_CONTENT_TAG, + content: upsertAcceptedUnitContent(null, { + unitId: "phase:core", + content: { interaction: { user_stance: "单角" } }, + summary: "单位 phase:core · 核心", + acceptedAt: "2026-01-01T00:00:00.000Z", + }), + source: "test", + }); + bb.write({ tag: "用户.需求", content: "网恋对话", source: "test" }); + bb.write({ tag: "创作.当前单位", content: "worker:narrator", source: "test" }); + + const text = assembleWorkerContext({ + inputs: { + [CREATION_ACCEPTED_CONTENT_TAG]: bb.getContentByTag(CREATION_ACCEPTED_CONTENT_TAG)!, + "用户.需求": "网恋对话", + "创作.当前单位": "worker:narrator", + }, + segments: [ + { + id: "prior", + tier: "static", + tags: [CREATION_ACCEPTED_CONTENT_TAG], + label: "## 【前情提要 · 已定稿】", + }, + { + id: "unit", + tier: "static", + tags: ["创作.当前单位"], + label: "## 【本单位】", + }, + { + id: "user", + tier: "dynamic", + tags: ["用户.需求"], + label: "## 用户表述", + }, + ], + blackboard: bb, + workerId: "design-worker", + workerName: "B", + outputTags: ["设计.worker集.草稿"], + }); + + expect(text).toContain("【前情提要 · 已定稿】"); + expect(text).toContain("phase:core"); + expect(text).toContain("只读"); + expect(text).toContain("【本单位】"); + expect(text).toContain("worker:narrator"); + expect(text).toContain("网恋对话"); + expect(text).not.toMatch(/^\s*\{/); + }); + + it("falls back to JSON inputs without segments", () => { + const bb = new Blackboard(); + const text = assembleWorkerContext({ + inputs: { "用户.需求": "x" }, + segments: [], + blackboard: bb, + workerId: "x", + workerName: "x", + outputTags: [], + }); + expect(JSON.parse(text).inputs["用户.需求"]).toBe("x"); + }); + + it("design-flow skill loads contextSegments", async () => { + const w = await loadWorkerSkill("world-simulator", "design-flow"); + expect(w.contextSegments?.length).toBeGreaterThan(0); + expect(w.outputTags).toContain("设计.创作流程"); + }); +}); + +describe("accepted unit content", () => { + it("extracts worker slice and upserts last accept", () => { + const parsed: ParsedWorkerSet = { + interaction: { + user_stance: "单角", + system_role: "世界", + output: "叙事", + }, + workers: [ + { ref: "narrator", duty: "转述", rationale: "可读", acceptance: "review" }, + ], + }; + expect(extractUnitContentFromDraft(parsed, "phase:core")).toMatchObject({ + interaction: { user_stance: "单角" }, + }); + expect(extractUnitContentFromDraft(parsed, "worker:narrator")).toMatchObject({ + ref: "narrator", + duty: "转述", + }); + + const v1 = upsertAcceptedUnitContent(null, { + unitId: "worker:narrator", + content: { ref: "narrator", duty: "旧" }, + acceptedAt: "2026-01-01T00:00:00.000Z", + }); + const v2 = upsertAcceptedUnitContent(v1, { + unitId: "worker:narrator", + content: { ref: "narrator", duty: "新" }, + acceptedAt: "2026-01-02T00:00:00.000Z", + }); + const prompt = formatAcceptedContentForPrompt(v2); + expect(prompt).toContain("新"); + expect(prompt).not.toContain('"duty": "旧"'); + }); +}); diff --git a/tests/context-trace.test.ts b/tests/context-trace.test.ts new file mode 100644 index 0000000..45c9aff --- /dev/null +++ b/tests/context-trace.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + buildContextTrace, + pruneContextTraces, +} from "../src/types/context-trace.js"; + +function msg(id: string, withTrace = false) { + return { + id, + ...(withTrace + ? { + contextTrace: buildContextTrace({ + caller: `c-${id}`, + messages: [{ role: "user", content: `hello-${id}` }], + }), + } + : {}), + }; +} + +describe("pruneContextTraces", () => { + it("keeps only the latest N traces", () => { + const messages = [ + msg("1", true), + msg("2"), + msg("3", true), + msg("4", true), + msg("5", true), + msg("6", true), + ]; + const pruned = pruneContextTraces(messages, 2); + expect(pruned.map((m) => Boolean(m.contextTrace))).toEqual([ + false, + false, + false, + false, + true, + true, + ]); + expect(pruned[4].id).toBe("5"); + expect(pruned[5].contextTrace?.caller).toBe("c-6"); + }); + + it("clears all when keepLatest is 0", () => { + const messages = [msg("a", true), msg("b", true)]; + const pruned = pruneContextTraces(messages, 0); + expect(pruned.every((m) => !m.contextTrace)).toBe(true); + expect(pruned.map((m) => m.id)).toEqual(["a", "b"]); + }); + + it("is a no-op when under the limit", () => { + const messages = [msg("1", true), msg("2", true)]; + const pruned = pruneContextTraces(messages, 5); + expect(pruned).toBe(messages); + }); +}); + +describe("buildContextTrace", () => { + it("counts characters and copies messages", () => { + const trace = buildContextTrace({ + caller: "worker:x", + model: "m", + messages: [ + { role: "system", content: "abc" }, + { role: "user", content: "de" }, + ], + }); + expect(trace.charCount).toBe(5); + expect(trace.caller).toBe("worker:x"); + expect(trace.model).toBe("m"); + expect(trace.messages).toHaveLength(2); + }); +}); diff --git a/tests/creation-flow.test.ts b/tests/creation-flow.test.ts new file mode 100644 index 0000000..0e7111f --- /dev/null +++ b/tests/creation-flow.test.ts @@ -0,0 +1,396 @@ +/** + * 创作流程 parse / validate / catalog / recipes + */ +import { describe, expect, it } from "vitest"; +import { + artifactTagForStep, + extractModuleOpening, + formatCreationFlowForUser, + formatModuleCatalogForAgent, + formatRecipeCatalogForAgent, + formatSelectedRecipeForAgent, + isCreationFlowComplete, + loadAllRecipeDetails, + loadModuleCatalog, + loadModulePrompt, + loadRecipeCatalog, + needsFlowExpansion, + nextPendingStep, + parseCreationFlow, + parseModuleCatalog, + parseModuleOpeningState, + parseRecipeCatalog, + parseRecipeYaml, + parseSelectedRecipeRef, + resolveDesignStepBinding, + validateCreationFlow, +} from "../src/skills/creation-flow.js"; +import { loadWorkerSkillWithContext } from "../src/skills/loader.js"; + +const sampleCatalog = parseModuleCatalog(` +modules: + - name: 美学纲领与交互范式 + declaration: 站位与体验契约 + artifact: 设计.美学纲领与交互范式 + - name: 实现机制 + declaration: worker 与表 + artifact: 设计.实现机制 + - name: 生成规则 + declaration: 可执行规则 + artifact: 设计.生成规则 + repeatable: true + - name: 具体实例 + declaration: 锚定实例 + artifact: 设计.具体实例 + repeatable: true +`)!; + +describe("creation-flow", () => { + it("parses minimal flow JSON and fills step ids", () => { + const flow = parseCreationFlow(`{ + "brief": "单角代入", + "steps": [ + { "name": "美学纲领与交互范式", "depends_on": [] } + ] + }`); + expect(flow).toEqual({ + version: 1, + brief: "单角代入", + status: undefined, + steps: [ + { + id: "美学纲领与交互范式", + name: "美学纲领与交互范式", + depends_on: [], + }, + ], + }); + }); + + it("allows duplicate capability names with distinct ids", () => { + const flow = parseCreationFlow(`{ + "status": "open", + "steps": [ + { "id": "生成规则", "name": "生成规则", "depends_on": [] }, + { "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] }, + { "id": "具体实例", "name": "具体实例", "depends_on": ["生成规则#2"] } + ] + }`)!; + expect(flow.status).toBe("open"); + expect(flow.steps.map((s) => s.id)).toEqual([ + "生成规则", + "生成规则#2", + "具体实例", + ]); + expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true); + }); + + it("auto-numbers duplicate names when id omitted", () => { + const flow = parseCreationFlow(`{ + "steps": [ + { "name": "生成规则", "depends_on": [] }, + { "name": "生成规则", "depends_on": ["生成规则"] } + ] + }`)!; + expect(flow.steps[0]?.id).toBe("生成规则"); + expect(flow.steps[1]?.id).toBe("生成规则#2"); + expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true); + }); + + it("extracts JSON from surrounding text", () => { + const flow = parseCreationFlow( + `说明如下\n{"steps":[{"name":"美学纲领与交互范式","depends_on":[]}]}\n完`, + ); + expect(flow?.steps[0]?.name).toBe("美学纲领与交互范式"); + }); + + it("validates deps must appear earlier", () => { + const bad = parseCreationFlow(`{ + "steps": [ + { "name": "实现机制", "depends_on": ["美学纲领与交互范式"] }, + { "name": "美学纲领与交互范式", "depends_on": [] } + ] + }`)!; + const result = validateCreationFlow(bad, sampleCatalog); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.includes("未排在其前面"))).toBe(true); + }); + + it("rejects unknown module names when catalog given", () => { + const flow = parseCreationFlow(`{ + "steps": [{ "name": "不存在的工序", "depends_on": [] }] + }`)!; + const result = validateCreationFlow(flow, sampleCatalog); + expect(result.ok).toBe(false); + expect(result.errors[0]).toContain("不在模块目录"); + }); + + it("rejects non-repeatable duplicate names", () => { + const flow = parseCreationFlow(`{ + "steps": [ + { "name": "实现机制", "depends_on": [] }, + { "id": "实现机制#2", "name": "实现机制", "depends_on": ["实现机制"] } + ] + }`)!; + const result = validateCreationFlow(flow, sampleCatalog); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.includes("repeatable"))).toBe(true); + }); + + it("accepts a valid ordered flow", () => { + const flow = parseCreationFlow(`{ + "steps": [ + { "name": "美学纲领与交互范式", "depends_on": [] }, + { "name": "实现机制", "depends_on": ["美学纲领与交互范式"] } + ] + }`)!; + expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true); + expect(artifactTagForStep("美学纲领与交互范式", sampleCatalog)).toBe( + "设计.美学纲领与交互范式", + ); + }); + + it("formats user view without English tags", () => { + const flow = parseCreationFlow(`{ + "brief": "网恋回合", + "status": "open", + "steps": [ + { "name": "美学纲领与交互范式", "depends_on": [] }, + { "name": "生成规则", "depends_on": ["美学纲领与交互范式"] }, + { "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] } + ] + }`)!; + const view = formatCreationFlowForUser(flow, sampleCatalog); + expect(view.brief).toBe("网恋回合"); + expect(view.status).toBe("open"); + expect(view.steps[0]).toMatchObject({ + order: 1, + name: "美学纲领与交互范式", + depends_on: [], + declaration: "站位与体验契约", + }); + expect(view.steps[2]?.occurrence).toBe(2); + expect(view.steps[2]?.repeatable).toBe(true); + }); + + it("loads world-simulator catalog and formats for agent", async () => { + const catalog = await loadModuleCatalog("dialogue/world-simulator"); + expect(catalog?.modules.some((m) => m.name === "美学纲领与交互范式")).toBe( + true, + ); + expect(catalog?.modules.some((m) => m.name === "交互范式")).toBe(false); + expect(catalog?.modules.some((m) => m.name === "美学纲领")).toBe(false); + expect( + catalog?.modules.find((m) => m.name === "生成规则")?.repeatable, + ).toBe(true); + expect( + catalog?.modules.find((m) => m.name === "具体实例")?.repeatable, + ).toBe(true); + const block = formatModuleCatalogForAgent(catalog!); + expect(block).toContain("【能力"); + expect(block).toContain("美学纲领与交互范式:"); + expect(block).toContain("生成规则〔可反复〕"); + expect(block).not.toContain("设计.美学纲领与交互范式"); + }); + + it("loads recipe catalog for user director selection UI", async () => { + const recipes = await loadRecipeCatalog("dialogue/world-simulator"); + expect(recipes?.recipes.some((r) => r.name === "世界模拟器")).toBe(true); + expect(recipes?.recipes.some((r) => r.name === "扩写助手")).toBe(true); + const block = formatRecipeCatalogForAgent(recipes!); + expect(block).toContain("须由用户手动选择"); + expect(block).toContain("世界模拟器"); + + const details = await loadAllRecipeDetails("dialogue/world-simulator"); + expect(details.length).toBeGreaterThanOrEqual(2); + expect(details.find((d) => d.id === "world-simulator")?.when).toBeTruthy(); + expect( + details + .find((d) => d.id === "world-simulator") + ?.seed?.steps.some((s) => s.name === "美学纲领与交互范式"), + ).toBe(true); + expect(details.find((d) => d.id === "world-simulator")?.seed?.status).toBe( + "open", + ); + }); + + it("parses selected recipe ref", () => { + expect(parseSelectedRecipeRef("world-simulator")).toBe("world-simulator"); + expect(parseSelectedRecipeRef('{"id":"expand-assistant","name":"扩写助手"}')).toBe( + "expand-assistant", + ); + expect(parseSelectedRecipeRef("")).toBeNull(); + }); + + it("parses recipe yaml with suggested steps", () => { + const detail = parseRecipeYaml( + ` +when: 测试适用 +hint: 可调味 +brief: 测试 brief +steps: + - name: 美学纲领与交互范式 + depends_on: [] +`, + { id: "t", name: "测试配方", declaration: "测" }, + ); + expect(detail.when).toBe("测试适用"); + expect(detail.seed?.status).toBe("open"); + expect(detail.seed?.steps).toEqual([ + { + id: "美学纲领与交互范式", + name: "美学纲领与交互范式", + depends_on: [], + }, + ]); + expect(formatSelectedRecipeForAgent(detail)).toContain("用户已选导演"); + expect(formatSelectedRecipeForAgent(detail)).toContain("增量"); + }); + + it("parseRecipeCatalog skips incomplete rows", () => { + const cat = parseRecipeCatalog(` +recipes: + - id: ok + name: 好配方 + declaration: 有声明 + - id: bad + name: 缺声明 +`); + expect(cat?.recipes).toEqual([ + { id: "ok", name: "好配方", declaration: "有声明" }, + ]); + }); + + it("injects selected director into design-flow; missing selection warns", async () => { + const missing = await loadWorkerSkillWithContext( + "world-simulator", + "design-flow", + ); + expect(missing.promptBody).toContain("用户尚未手动选择"); + expect(missing.promptBody).toContain("【能力"); + expect(missing.promptBody).toContain("禁止自行猜测"); + + const selected = await loadWorkerSkillWithContext( + "world-simulator", + "design-flow", + undefined, + { selectedRecipeRef: "world-simulator" }, + ); + expect(selected.worker.outputTags).toContain("设计.创作流程"); + expect(selected.promptBody).toContain("【用户已选导演 · 世界模拟器】"); + expect(selected.promptBody).toContain("世界模拟器"); + expect(selected.promptBody).toContain("【能力"); + expect(selected.promptBody).toContain("美学纲领与交互范式"); + expect(selected.promptBody).toContain("增量"); + expect(selected.promptBody).not.toContain("【导演】用户尚未手动选择"); + }); + + it("injects aesthetics-interaction module prompt into design-step", async () => { + const flowRaw = JSON.stringify({ + steps: [{ name: "美学纲领与交互范式", depends_on: [] }], + }); + const loaded = await loadWorkerSkillWithContext( + "world-simulator", + "design-step", + undefined, + { + flowRaw, + currentStepName: "美学纲领与交互范式", + acceptedStepNames: [], + }, + ); + expect(loaded.worker.outputTags).toContain("设计.美学纲领与交互范式"); + expect(loaded.promptBody).toContain("美学纲领与交互范式"); + expect(loaded.promptBody).toContain("体验契约"); + expect(loaded.promptBody).toContain("程序开场"); + expect(loaded.worker.inputTags).toContain("创作.能力开场白"); + }); + + it("extracts ```opening default question from module prompt", async () => { + const prompt = await loadModulePrompt( + "dialogue/world-simulator", + "aesthetics-interaction", + ); + expect(prompt).toBeTruthy(); + const opening = extractModuleOpening(prompt!); + expect(opening).toContain("原型世界"); + expect(opening).toContain("变在哪里"); + expect(opening).toContain("代入"); + expect(opening).toContain("最想反复感受到"); + expect(opening).not.toContain("nail清站位"); + + const { + parseModulePromptSections, + formatModulePromptForLlm, + MODULE_SECTION_IDS, + } = await import("../src/skills/creation-flow.js"); + const sections = parseModulePromptSections(prompt!); + for (const id of ["meta", "opening", "task", "output", "checklist"] as const) { + expect(sections.blocks[id]?.length).toBeGreaterThan(0); + } + expect(MODULE_SECTION_IDS).toContain("opening"); + + const forLlm = formatModulePromptForLlm(prompt!); + expect(forLlm).toContain("```task"); + expect(forLlm).not.toContain("原型世界是什么"); + + expect( + extractModuleOpening("## opening\n\n```opening\nhello world\n```\n"), + ).toBe("hello world"); + + expect(parseModuleOpeningState('{"美学纲领与交互范式":"shown"}')).toEqual({ + 美学纲领与交互范式: "shown", + }); + + const binding = await resolveDesignStepBinding({ + skillPackRoot: "dialogue/world-simulator", + flowRaw: JSON.stringify({ + steps: [{ name: "美学纲领与交互范式", depends_on: [] }], + }), + currentStepName: "美学纲领与交互范式", + }); + expect(binding?.opening).toContain("原型世界"); + expect(binding?.modulePrompt).toContain("```task"); + expect(binding?.modulePrompt).not.toContain("最想反复感受到的是什么"); + }); + + it("nextPendingStep respects deps and accepted by id", () => { + const flow = parseCreationFlow(`{ + "status": "open", + "steps": [ + { "name": "美学纲领与交互范式", "depends_on": [] }, + { "name": "生成规则", "depends_on": ["美学纲领与交互范式"] }, + { "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] } + ] + }`)!; + expect(nextPendingStep(flow, [])?.id).toBe("美学纲领与交互范式"); + expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.id).toBe("生成规则"); + expect(nextPendingStep(flow, ["美学纲领与交互范式", "生成规则"])?.id).toBe( + "生成规则#2", + ); + expect( + nextPendingStep(flow, ["美学纲领与交互范式", "生成规则", "生成规则#2"]), + ).toBeNull(); + }); + + it("open flow needs expansion after listed steps accepted", () => { + const flow = parseCreationFlow(`{ + "status": "open", + "steps": [{ "name": "美学纲领与交互范式", "depends_on": [] }] + }`)!; + expect(isCreationFlowComplete(flow, ["美学纲领与交互范式"])).toBe(false); + expect(needsFlowExpansion(flow, ["美学纲领与交互范式"])).toBe(true); + + const closed = parseCreationFlow(`{ + "status": "closed", + "steps": [{ "name": "美学纲领与交互范式", "depends_on": [] }] + }`)!; + expect(isCreationFlowComplete(closed, ["美学纲领与交互范式"])).toBe(true); + expect(needsFlowExpansion(closed, ["美学纲领与交互范式"])).toBe(false); + + const legacy = parseCreationFlow(`{ + "steps": [{ "name": "美学纲领与交互范式", "depends_on": [] }] + }`)!; + expect(isCreationFlowComplete(legacy, ["美学纲领与交互范式"])).toBe(true); + }); +}); diff --git a/tests/declared-worker.test.ts b/tests/declared-worker.test.ts new file mode 100644 index 0000000..02ffd81 --- /dev/null +++ b/tests/declared-worker.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { createSession } from "../src/runtime/phase-machine.js"; +import { + buildDeclaredWorkerSkill, + resolveAcceptanceModeForWorker, +} from "../src/skills/declared-worker.js"; +import { parseWorkerSetYaml } from "../src/skills/worker-set-parse.js"; + +describe("resolveAcceptanceModeForWorker", () => { + it("always confirms design-flow", () => { + const mode = resolveAcceptanceModeForWorker({ + session: createSession(), + blackboard: new Blackboard(), + workerId: "design-flow", + }); + expect(mode).toBe("user_confirmed"); + }); + + it("maps review → user_confirmed and continue → no_confirmation", () => { + const bb = new Blackboard(); + const yaml = JSON.stringify({ + version: 1, + workers: [ + { ref: "world-simulator", acceptance: "continue" }, + { ref: "narrator", acceptance: "review" }, + ], + }); + bb.write({ tag: "设计.worker集", content: yaml, source: "test" }); + const session = { + ...createSession(), + slots: { + ...createSession().slots, + designInstanceReady: true, + uiLifecycleStage: "play", + }, + }; + + expect( + resolveAcceptanceModeForWorker({ + session, + blackboard: bb, + workerId: "world-simulator", + }), + ).toBe("no_confirmation"); + expect( + resolveAcceptanceModeForWorker({ + session, + blackboard: bb, + workerId: "narrator", + }), + ).toBe("user_confirmed"); + }); +}); + +describe("buildDeclaredWorkerSkill", () => { + it("builds runnable contract from entry + template", () => { + const parsed = parseWorkerSetYaml( + JSON.stringify({ + version: 1, + narrative_guide: "残酷但不虐主", + core_premises: ["主角免疫"], + workers: [ + { + ref: "narrator", + duty: "组装用户可见回复", + rationale: "需要可读终稿", + acceptance: "review", + outputs: ["输出.用户展示"], + context: { + static: ["设计.worker集"], + dynamic: ["运行.本轮.裁决"], + }, + }, + ], + }), + ); + const entry = parsed!.workers[0]; + const { worker, promptBody } = buildDeclaredWorkerSkill({ + skillPackName: "world-simulator", + entry, + template: { + label: "转述", + prompt_excerpt: "只改表达不改事实", + suggested_outputs: ["输出.用户展示"], + }, + workerSet: parsed, + }); + expect(worker.id).toBe("narrator"); + expect(worker.outputTags).toContain("输出.用户展示"); + expect(worker.inputTags).toContain("设计.worker集"); + expect(promptBody).toContain("残酷但不虐主"); + expect(promptBody).toContain("主角免疫"); + expect(promptBody).toContain("声明驱动"); + }); +}); diff --git a/tests/display-labels.test.ts b/tests/display-labels.test.ts new file mode 100644 index 0000000..d1a2d93 --- /dev/null +++ b/tests/display-labels.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { + displayStageLabel, + displayWorkerLabel, + formatWorkerDisplayTitle, +} from "../src/server/display-labels.js"; + +describe("display-labels", () => { + it("maps lifecycle stages", () => { + expect(displayStageLabel("design")).toBe("创作"); + expect(displayStageLabel("play")).toBe("游玩"); + }); + + it("maps design workers", () => { + expect(displayWorkerLabel("design-flow")).toBe("创作 · 流程编排"); + expect(displayWorkerLabel("design-step")).toBe("创作 · 执行步骤"); + expect(formatWorkerDisplayTitle("design-flow", "output")).toBe( + "创作 · 流程编排 · 产出", + ); + }); + + it("maps creation unit ids", () => { + expect(displayWorkerLabel("phase:core")).toBe("单位 · 核心"); + expect(displayWorkerLabel("fixed:interaction")).toBe("能力 · 交互范式"); + expect(displayWorkerLabel("fixed:aesthetics-interaction")).toBe( + "能力 · 美学纲领与交互范式", + ); + expect(displayWorkerLabel("worker:narrator")).toBe("演员 · 叙事转述"); + }); +}); diff --git a/tests/intake.test.ts b/tests/intake.test.ts index e9f7f52..25a74c8 100644 --- a/tests/intake.test.ts +++ b/tests/intake.test.ts @@ -4,24 +4,13 @@ import { buildIntakeProgress, extractIntakeHeuristic, intakeFieldsFromInquiry, - synthesizeDemandText, } from "../src/intake/intake.js"; -import { applyEvent, createSession } from "../src/runtime/phase-machine.js"; -import { toActiveSkillSnapshot } from "../src/skills/snapshot.js"; -import { loadSkill } from "../src/skills/loader.js"; describe("intake fields", () => { - it("parses required and optional from roleplay skill", async () => { - const skill = await loadSkill("roleplay-game-theory"); - const fields = intakeFieldsFromInquiry(skill.startupInquiry); - expect(fields.filter((f) => f.required).length).toBe(3); - expect(fields.filter((f) => !f.required).length).toBe(3); - }); - it("marks ready when all required filled", () => { const fields = intakeFieldsFromInquiry({ prompt: "p", - targetKey: "用户.博弈需求", + targetKey: "用户.需求", requiredFields: ["情境", "角色", "轮次"], optionalFields: ["输出偏好"], }); @@ -46,23 +35,10 @@ describe("intake fields", () => { requiredFields: ["实验情境", "轮次模式"], optionalFields: [], }); - const values = extractIntakeHeuristic( - "德州扑克,单轮定胜负", - fields, - {}, - ); + const values = extractIntakeHeuristic("德州扑克,单轮定胜负", fields, {}); expect(Object.keys(values).length).toBeGreaterThan(0); }); - it("parses roleplay intake fields as 3 required + optional", async () => { - const skill = await loadSkill("roleplay-game-theory"); - const fields = intakeFieldsFromInquiry(skill.startupInquiry); - expect(fields.filter((f) => f.required).length).toBe(3); - expect(fields.filter((f) => !f.required).length).toBe(3); - expect(fields.some((f) => f.label.includes("情境"))).toBe(true); - expect(fields.some((f) => f.label.includes("进程"))).toBe(true); - }); - it("buildIntakeFollowUpMessage lists missing required fields", () => { const fields = intakeFieldsFromInquiry({ prompt: "p", @@ -78,40 +54,4 @@ describe("intake fields", () => { expect(msg).toContain("角色"); expect(msg).toContain("进程"); }); - - it("confirm intake writes structured demand tag", async () => { - const snap = toActiveSkillSnapshot(await loadSkill("basic")); - let session = createSession("default"); - session = applyEvent(session, { - type: "session_started", - payload: { - presetId: "default", - availableSkills: [{ name: "basic", description: "", category: "novel" }], - }, - }).session; - session = applyEvent(session, { - type: "skill_selected", - payload: { skill: snap }, - }).session; - expect(session.waitingReason?.kind).toBe("intake"); - - const fields = snap.intakeFields; - const values: Record = {}; - for (const f of fields) { - values[f.id] = `值-${f.label}`; - } - session = applyEvent(session, { - type: "user_submitted_input", - payload: { text: "一次性提交", intakeValues: values }, - }).session; - expect(session.waitingReason?.kind).toBe("intake"); - - session = applyEvent(session, { - type: "user_confirmed_intake", - payload: {}, - }).session; - expect(session.slots.startupCompleted).toBe(true); - expect(String(session.slots["book.brief"])).toContain("题材"); - expect(synthesizeDemandText(fields, values)).toContain("值-题材"); - }); }); diff --git a/tests/message-branch.test.ts b/tests/message-branch.test.ts new file mode 100644 index 0000000..600d3cd --- /dev/null +++ b/tests/message-branch.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + createMessageBranchState, + createUserVariantMessage, + ensureBranchForEdit, + recordPreMessageCheckpoint, + switchBranchVariant, +} from "../src/server/message-branch.js"; +import { createSession } from "../src/runtime/phase-machine.js"; + +describe("message-branch", () => { + it("switchBranchVariant restores alternate user text", () => { + const state = createMessageBranchState(); + const checkpoint = { + runtimeSession: createSession("default"), + blackboardItems: [], + }; + const messages = [ + createUserVariantMessage("版本 A", "g1", 0), + { id: "w1", role: "system" as const, text: "回复 A", createdAt: "1" }, + ]; + messages[0].branchGroupId = "g1"; + messages[0].branchIndex = 0; + messages[0].branchTotal = 2; + + state.branches.g1 = { + anchorIndex: 0, + groupId: "g1", + activeIndex: 1, + variants: [ + { + messages: [messages[0], messages[1]], + checkpoint, + }, + { + messages: [ + createUserVariantMessage("版本 B", "g1", 1), + { id: "w2", role: "system" as const, text: "回复 B", createdAt: "2" }, + ], + checkpoint, + }, + ], + }; + + const switched = switchBranchVariant(state, messages, "g1", -1); + expect(switched?.messages[0].text).toBe("版本 A"); + expect(switched?.messages[1].text).toBe("回复 A"); + expect(state.branches.g1.activeIndex).toBe(0); + }); + + it("ensureBranchForEdit captures first variant", () => { + const state = createMessageBranchState(); + const messages = [ + createUserVariantMessage("首句", "u1", 0), + ]; + recordPreMessageCheckpoint(state, 0, { + runtimeSession: createSession("default"), + blackboardItems: [], + }); + const branch = ensureBranchForEdit(state, messages, 0, state.preMessageCheckpoints[0]!); + expect(branch.variants).toHaveLength(1); + expect(branch.variants[0].messages[0].text).toBe("首句"); + }); +}); diff --git a/tests/phase-machine.test.ts b/tests/phase-machine.test.ts index 1f184ba..4819fc8 100644 --- a/tests/phase-machine.test.ts +++ b/tests/phase-machine.test.ts @@ -2,50 +2,39 @@ import { describe, expect, it } from "vitest"; import { createDecision } from "../src/runtime/orchestrator.js"; import { applyEvent, - canApplyEvent, createArtifact, createSession, } from "../src/runtime/phase-machine.js"; import { loadSkill } from "../src/skills/loader.js"; import { toActiveSkillSnapshot } from "../src/skills/snapshot.js"; -const mockSkills = [{ name: "basic", description: "基础", category: "novel" }]; +const mockSkills = [ + { name: "world-simulator", description: "默认", category: "dialogue" }, +]; describe("phase machine", () => { - it("runs worker loop after skill and startup input", async () => { - const { loadSkill } = await import("../src/skills/loader.js"); - const { toActiveSkillSnapshot } = await import("../src/skills/snapshot.js"); - const snap = toActiveSkillSnapshot(await loadSkill("basic")); + it("runs design-core approve loop on agent-first pack", async () => { + const snap = toActiveSkillSnapshot(await loadSkill("world-simulator")); - let session = createSession("default"); - session = applyEvent(session, { + let session = applyEvent(createSession("default"), { type: "session_started", - payload: { presetId: "default", availableSkills: mockSkills }, + payload: { + presetId: "default", + availableSkills: mockSkills, + initialSkill: snap, + }, }).session; - session = applyEvent(session, { - type: "skill_selected", - payload: { skill: snap }, - }).session; - const values: Record = {}; - for (const f of snap.intakeFields) { - values[f.id] = "科幻短篇,第一人称"; - } + session = applyEvent(session, { type: "user_submitted_input", - payload: { text: "科幻短篇,第一人称", intakeValues: values }, - }).session; - expect(session.waitingReason?.kind).toBe("intake"); - session = applyEvent(session, { - type: "user_confirmed_intake", - payload: {}, + payload: { text: "1v1 网恋对话" }, }).session; expect(session.phase).toBe("running"); - expect(session.slots["book.brief"]).toBeTruthy(); const decision = createDecision({ action: "run_worker", - reason: "生成大纲", - workerId: "outline-worker", + reason: "产出 Worker 集", + workerId: "design-core", requiresApproval: true, }); session = applyEvent(session, { @@ -60,12 +49,12 @@ describe("phase machine", () => { }).session; session = applyEvent(session, { type: "worker_started", - payload: { workerId: "outline-worker", acceptanceMode: "user_confirmed" }, + payload: { workerId: "design-core", acceptanceMode: "user_confirmed" }, }).session; const artifact = createArtifact({ - workerId: "outline-worker", - outputTags: ["outline.draft"], + workerId: "design-core", + outputTags: ["设计.worker集"], }); session = { ...session, artifacts: [artifact] }; session = applyEvent(session, { @@ -78,95 +67,96 @@ describe("phase machine", () => { type: "user_accepted_artifact", payload: { artifactId: artifact.id }, }).session; + expect(session.slots.designInstanceReady).toBe(true); expect(session.phase).toBe("running"); - - session = applyEvent(session, { type: "flow_completed", payload: {} }).session; - expect(session.phase).toBe("done"); }); - it("rejects illegal events from idle", () => { - const session = createSession("default"); - expect(canApplyEvent(session, { type: "user_submitted_input", payload: { text: "x" } })).toBe( - false, - ); - const result = applyEvent(session, { - type: "user_submitted_input", - payload: { text: "x" }, - }); - expect(result.session.phase).toBe("error"); - }); - - it("merges follow-up input after intake confirm via ask_user", async () => { - const { createDecision } = await import("../src/runtime/orchestrator.js"); - const snap = toActiveSkillSnapshot(await loadSkill("roleplay-game-theory")); - + it("hangs optional questions under review_artifact without blocking accept", () => { let session = createSession("default"); + session = { + ...session, + phase: "running" as const, + acceptanceMode: "user_confirmed" as const, + }; + const artifact = createArtifact({ + workerId: "design-core", + outputTags: ["设计.worker集.草稿"], + }); + session = { ...session, artifacts: [artifact] }; session = applyEvent(session, { - type: "session_started", - payload: { presetId: "default", availableSkills: mockSkills }, - }).session; - session = applyEvent(session, { - type: "skill_selected", - payload: { skill: snap }, + type: "worker_completed", + payload: { + artifactId: artifact.id, + questions: [ + { + id: "q1", + prompt: "更偏哪种节奏?", + options: [{ id: "A", label: "慢热拉扯" }], + }, + ], + }, }).session; - const fields = snap.intakeFields; - const values: Record = {}; - for (const f of fields.filter((x) => x.required)) { - values[f.id] = "已填"; - } + expect(session.waitingReason?.kind).toBe("review_artifact"); + if (session.waitingReason?.kind !== "review_artifact") return; + expect(session.waitingReason.questions?.length).toBe(1); + expect(session.waitingReason.questions?.[0]?.required).toBe(false); + + // 不答追问,直接接受产物 session = applyEvent(session, { - type: "user_submitted_input", - payload: { text: "首次", intakeValues: values }, + type: "user_accepted_artifact", + payload: { artifactId: artifact.id }, }).session; + expect(session.phase).toBe("running"); + }); + + it("sidecar skip clears questions but stays in review", () => { + let session = createSession("default"); + session = { + ...session, + phase: "running" as const, + acceptanceMode: "user_confirmed" as const, + }; + const artifact = createArtifact({ + workerId: "design-core", + outputTags: ["设计.worker集.草稿"], + }); + session = { ...session, artifacts: [artifact] }; session = applyEvent(session, { - type: "user_confirmed_intake", + type: "worker_completed", + payload: { + artifactId: artifact.id, + questions: ["还想补一点感官细节吗?"], + }, + }).session; + + session = applyEvent(session, { + type: "user_resolved_sidecar_questions", payload: {}, }).session; - expect(session.slots.startupCompleted).toBe(true); - - session = applyEvent(session, { - type: "main_agent_decision_created", - payload: { - decision: createDecision({ - action: "ask_user", - reason: "输出偏好?", - }), - }, - }).session; - expect(session.waitingReason?.kind).toBe("input"); - - session = applyEvent(session, { - type: "user_submitted_input", - payload: { text: "需要思考标签,带场景描写" }, - }).session; - const demand = String(session.slots["用户.博弈需求"]); - expect(demand).toContain("需要思考标签"); + expect(session.waitingReason?.kind).toBe("review_artifact"); + if (session.waitingReason?.kind !== "review_artifact") return; + expect(session.waitingReason.questions).toBeUndefined(); + expect(session.pendingArtifactId).toBe(artifact.id); }); - it("emits one follow-up when roleplay intake required fields incomplete", async () => { - const snap = toActiveSkillSnapshot(await loadSkill("roleplay-game-theory")); - let session = createSession("default"); - session = applyEvent(session, { - type: "session_started", - payload: { presetId: "default", availableSkills: mockSkills }, - }).session; - session = applyEvent(session, { - type: "skill_selected", - payload: { skill: snap }, - }).session; - const scenarioField = snap.intakeFields.find((f) => f.label.includes("情境")); - expect(scenarioField).toBeDefined(); - const result = applyEvent(session, { - type: "user_submitted_input", - payload: { - text: "德州扑克", - intakeValues: { [scenarioField!.id]: "德州扑克" }, - }, + it("accepting design-core with only draft does not set designInstanceReady", () => { + let session = createSession(); + const artifact = createArtifact({ + workerId: "design-core", + outputTags: ["设计.worker集.草稿", "创作.当前单位"], }); - expect(result.effects.some((e) => e.type === "emit_message")).toBe(true); - const msg = result.effects.find((e) => e.type === "emit_message"); - expect(msg && "message" in msg && msg.message).toContain("还缺以下必要项"); - expect(result.session.slots.intakeFollowUpSent).toBe(true); + session = { + ...session, + artifacts: [artifact], + pendingArtifactId: artifact.id, + phase: "waiting_user", + waitingReason: { kind: "review_artifact", artifactId: artifact.id }, + }; + session = applyEvent(session, { + type: "user_accepted_artifact", + payload: { artifactId: artifact.id }, + }).session; + expect(session.slots.designInstanceReady).toBeUndefined(); }); }); diff --git a/tests/phase-runtime.test.ts b/tests/phase-runtime.test.ts index e92d122..f063a51 100644 --- a/tests/phase-runtime.test.ts +++ b/tests/phase-runtime.test.ts @@ -4,74 +4,47 @@ import { createMockToolCall, MockLlmProvider, } from "../src/llm/client.js"; -import { buildIntakeProgress, readIntakeValues } from "../src/intake/intake.js"; -import { PhaseRuntime } from "../src/runtime/phase-runtime.js"; +import { PhaseRuntime, createDecision } from "../src/runtime/phase-runtime.js"; describe("phase runtime", () => { - it("selects basic skill and shows startup inquiry", async () => { + it("auto-loads default orchestrator and awaits first user input", async () => { const runtime = new PhaseRuntime(); await runtime.start(); - expect(runtime.getSession().waitingReason?.kind).toBe("skill_selection"); - - await runtime.selectSkill("basic"); - expect(runtime.getSession().waitingReason?.kind).toBe("intake"); - expect(runtime.getActiveSkill()?.name).toBe("basic"); + expect(runtime.getActiveSkill()?.name).toBe("world-simulator"); + expect(runtime.getActiveSkill()?.startupMode).toBe("agent-first"); + expect(runtime.getSession().waitingReason?.kind).toBe("input"); }); - it("writes book.brief after intake confirm", async () => { - const runtime = new PhaseRuntime(); - await runtime.start(); - await runtime.selectSkill("basic"); - await runtime.submitInput("科幻中篇,第三人称"); - await runtime.confirmIntake(); - expect(runtime.getSession().slots["book.brief"]).toContain("科幻"); - expect(runtime.getSession().slots.startupCompleted).toBe(true); - }); - - it("syncs demand tag to blackboard after intake confirm", async () => { - const runtime = new PhaseRuntime(); - await runtime.start(); - await runtime.selectSkill("roleplay-game-theory"); - const fields = runtime.getActiveSkill()?.intakeFields ?? []; - await runtime.submitInput("德州扑克,经典博弈情境"); - await runtime.submitInput("玩家A算计型,玩家B怕吃亏"); - await runtime.submitInput("单轮定胜负,需要思考标签与场景描写"); - const progress = buildIntakeProgress( - fields, - readIntakeValues(runtime.getSession().slots), - ); - if (!progress.ready) { - const values = readIntakeValues(runtime.getSession().slots); - for (const f of fields.filter((x) => x.required && !values[x.id])) { - values[f.id] = "补充"; - } - await runtime.dispatch({ - type: "user_submitted_input", - payload: { text: "补充", intakeValues: values }, - }); - } - await runtime.confirmIntake(); - const demand = runtime.getBlackboard().getContentByTag("用户.博弈需求") ?? ""; - expect(demand).toContain("德州扑克"); - expect(runtime.getSession().slots.startupCompleted).toBe(true); - }); - - it("mock main agent tool loop reads blackboard then proposes worker", async () => { - const runtime = new PhaseRuntime({ - llm: new MockLlmProvider([ - createMockToolCall("read_blackboard", { tags: ["book.brief"] }), - createMockToolCall("run_worker", { - workerId: "outline", - reason: "已读需求,生成大纲", - requiresApproval: true, + it("writes 用户.需求 and invokes main agent after first input", async () => { + const llm = new MockLlmProvider([ + createMockMainAgentResponse([ + createMockToolCall("ask_user", { + question: "还需要补充吗?", + reason: "确认交互细节", }), ]), - }); + ]); + const runtime = new PhaseRuntime({ llm }); await runtime.start(); - await runtime.selectSkill("basic"); - await runtime.submitInput("科幻中篇,第三人称"); - await runtime.confirmIntake(); - expect(runtime.getSession().waitingReason?.kind).toBe("approve_step"); - expect(runtime.getSession().pendingDecision?.workerId).toBe("outline"); + await runtime.submitInput("西幻升级交互,世界推着走"); + expect(runtime.getSession().slots["用户.需求"]).toContain("西幻"); + expect(runtime.getSession().slots.startupCompleted).toBe(true); + }); + + it("stub run_worker design-flow is allowed before worker set accept", async () => { + const runtime = new PhaseRuntime({ autoStubWorker: true }); + await runtime.start(); + await runtime.submitInput("网恋对象对话"); + await runtime.submitDecision( + createDecision({ + action: "run_worker", + reason: "编排创作流程", + workerId: "design-flow", + requiresApproval: false, + }), + ); + expect(runtime.getSession().artifacts.some((a) => a.workerId === "design-flow")).toBe( + true, + ); }); }); diff --git a/tests/phase-skill.test.ts b/tests/phase-skill.test.ts index ebe702f..93504e6 100644 --- a/tests/phase-skill.test.ts +++ b/tests/phase-skill.test.ts @@ -1,137 +1,91 @@ import { describe, expect, it } from "vitest"; -import { loadSkill, listSkills } from "../src/skills/loader.js"; -import { toActiveSkillSnapshot } from "../src/skills/snapshot.js"; -import { createDecision } from "../src/runtime/phase-runtime.js"; import { applyEvent, canApplyEvent, createSession, } from "../src/runtime/phase-machine.js"; +import { toActiveSkillSnapshot } from "../src/skills/snapshot.js"; +import { loadSkill, listSkills, listWorkerSkills, loadWorkerSkill } from "../src/skills/loader.js"; const mockSkills = [ - { name: "basic", description: "基础小说", category: "novel" }, + { name: "world-simulator", description: "默认能力库", category: "dialogue" }, ]; describe("skill loader", () => { - it("lists basic skill", async () => { + it("lists only world-simulator", async () => { const skills = await listSkills(); - expect(skills.some((s) => s.name === "basic")).toBe(true); + expect(skills.map((s) => s.name)).toEqual(["world-simulator"]); }); - it("loads startup inquiry from SKILL.md", async () => { - const skill = await loadSkill("basic"); - expect(skill.startupInquiry.targetKey).toBe("book.brief"); - expect(skill.startupInquiry.prompt).toContain("基础小说创作"); - const snap = toActiveSkillSnapshot(skill); - expect(snap.startupPrompt).toBe(skill.startupInquiry.prompt); - }); + it("loads world-simulator pack and design step skills", async () => { + const skill = await loadSkill("world-simulator"); + expect(skill.name).toBe("world-simulator"); + expect(skill.path).toBe("dialogue/world-simulator/orchestrator.md"); + expect(skill.skillPackRoot).toBe("dialogue/world-simulator"); - it("loads weird-rules-short skill pack", async () => { - const skill = await loadSkill("weird-rules-short"); - expect(skill.name).toBe("weird-rules-short"); - expect(skill.path).toBe("novel/weird-rules-short/orchestrator.md"); - expect(skill.skillPackRoot).toBe("novel/weird-rules-short"); - expect(skill.bookKind).toBe("novel"); - expect(skill.tags).toContain("weird_rules"); - expect(skill.suggestedWorkers).toEqual(["write-rules", "review-infer", "review-author"]); - expect(skill.sharedContextPath).toBe("shared-context.md"); - expect(skill.startupInquiry.prompt).toContain("规则怪谈"); - expect(skill.startupInquiry.prompt).not.toContain("叙事人称"); - }); - - it("loads roleplay-game-theory skill pack (instantiate)", async () => { - const skill = await loadSkill("roleplay-game-theory"); - expect(skill.name).toBe("roleplay-game-theory"); - expect(skill.path).toBe("dialogue/roleplay-game-theory/orchestrator.md"); - expect(skill.bookKind).toBe("dialogue"); - expect(skill.suggestedWorkers).toEqual(["setup-scenario", "world-engine", "role-decide", "present-round"]); - expect(skill.startupInquiry.targetKey).toBe("用户.博弈需求"); - expect(skill.startupInquiry.prompt).toContain("角色扮演博弈"); - }); - - it("loads worker skills from skill pack", async () => { - const { - loadWorkerSkill, - loadWorkerSkillWithContext, - loadSkillSharedContext, - listWorkerSkills, - } = await import("../src/skills/loader.js"); - const workers = await listWorkerSkills("weird-rules-short"); + const workers = await listWorkerSkills("world-simulator"); expect(workers.map((w) => w.id).sort()).toEqual([ - "review-author", - "review-infer", - "write-rules", + "design-flow", + "design-step", + "opening-generator", ]); + const flow = await loadWorkerSkill("world-simulator", "design-flow"); + expect(flow.outputTags).toContain("设计.创作流程"); + expect(flow.name).toContain("流程"); - const writeRules = await loadWorkerSkill("weird-rules-short", "write-rules"); - expect(writeRules.outputTags).toContain("rules.draft"); - expect(writeRules.body).toContain("shared-context"); + const step = await loadWorkerSkill("world-simulator", "design-step"); + expect(step.id).toBe("design-step"); - const reviewInfer = await loadWorkerSkill("weird-rules-short", "review-infer"); - expect(reviewInfer.outputTags).toContain("review.infer.notes"); - expect(reviewInfer.body).toContain("verdict:"); - - const reviewAuthor = await loadWorkerSkill("weird-rules-short", "review-author"); - expect(reviewAuthor.outputTags).toContain("review.author.notes"); - expect(reviewAuthor.body).toContain("表面矛盾"); - - const shared = await loadSkillSharedContext("weird-rules-short"); - expect(shared).toContain("表面矛盾 ≠ 逻辑矛盾"); - - const withCtx = await loadWorkerSkillWithContext("weird-rules-short", "write-rules"); - expect(withCtx.sharedContext).toContain("盲人摸象"); - expect(withCtx.promptBody).toContain("固定创作上下文"); - }); - - it("loads roleplay role-decide with thinking/action output tags", async () => { - const { loadWorkerSkill } = await import("../src/skills/loader.js"); - const roleDecide = await loadWorkerSkill("roleplay-game-theory", "role-decide"); - expect(roleDecide.outputTags).toContain("角色.*.思考"); - expect(roleDecide.outputTags).toContain("角色.*.行动"); - expect(roleDecide.body).toContain("仅用户可见"); - }); - - it("loads basic skill pack", async () => { - const skill = await loadSkill("basic"); - expect(skill.path).toBe("novel/basic/orchestrator.md"); - expect(skill.skillPackRoot).toBe("novel/basic"); - expect(skill.suggestedWorkers).toEqual(["outline"]); - }); - - it("lists only registered skills", async () => { - const skills = await listSkills(); - const names = skills.map((s) => s.name).sort(); - expect(names).toEqual(["basic", "roleplay-game-theory", "weird-rules-short"]); + const opening = await loadWorkerSkill("world-simulator", "opening-generator"); + expect(opening.outputTags[0]).toBe("输出.开场白"); + expect(opening.body).toContain("开场白"); + expect(opening.body).toContain("填表工具"); + expect(opening.body).toContain("普通大学生"); }); }); -describe("phase machine with skill", () => { - it("starts with skill_selection", () => { - let session = createSession("default"); - const result = applyEvent(session, { +describe("phase machine with world-simulator", () => { + it("session_started agent-first awaits user input", async () => { + const skill = await loadSkill("world-simulator"); + const snap = toActiveSkillSnapshot(skill); + expect(snap.startupMode).toBe("agent-first"); + + const result = applyEvent(createSession("default"), { type: "session_started", - payload: { presetId: "default", availableSkills: mockSkills }, + payload: { + presetId: "default", + availableSkills: mockSkills, + initialSkill: snap, + }, }); expect(result.session.phase).toBe("waiting_user"); - expect(result.session.waitingReason?.kind).toBe("skill_selection"); + expect(result.session.waitingReason?.kind).toBe("input"); }); - it("skill_selected shows startup prompt from skill", async () => { - const skill = await loadSkill("basic"); + it("first user input invokes main agent", async () => { + const skill = await loadSkill("world-simulator"); const snap = toActiveSkillSnapshot(skill); - let session = applyEvent(createSession("default"), { + const session = applyEvent(createSession("default"), { type: "session_started", - payload: { presetId: "default", availableSkills: mockSkills }, + payload: { presetId: "default", availableSkills: mockSkills, initialSkill: snap }, }).session; - const selected = applyEvent(session, { - type: "skill_selected", - payload: { skill: snap }, + const result = applyEvent(session, { + type: "user_submitted_input", + payload: { text: "我要你塑造一个网恋对象和我对话" }, }); - expect(selected.session.waitingReason?.kind).toBe("input"); - expect(selected.session.slots.activeSkill).toBeDefined(); - expect((selected.session.slots.activeSkill as { name: string }).name).toBe("basic"); + expect(result.session.phase).toBe("running"); + expect(result.session.slots["用户.需求"]).toContain("网恋"); + expect(result.effects.some((e) => e.type === "invoke_main_agent")).toBe(true); + }); + + it("legacy session_started without initialSkill enters skill_selection", () => { + const result = applyEvent(createSession("default"), { + type: "session_started", + payload: { presetId: "default", availableSkills: mockSkills }, + }); + expect(result.session.waitingReason?.kind).toBe("skill_selection"); }); it("rejects input before skill selected", () => { @@ -145,12 +99,21 @@ describe("phase machine with skill", () => { }); }); -describe("phase runtime with skill", () => { - it("runs full loop with basic skill", async () => { - const { PhaseRuntime, runMinimalClosedLoop } = await import("../src/runtime/phase-runtime.js"); - const session = await runMinimalClosedLoop(new PhaseRuntime({ autoStubWorker: true })); - expect(session.phase).toBe("done"); - expect(session.slots.startupCompleted).toBe(true); - expect(session.slots["book.brief"]).toContain("科幻"); +describe("phase runtime with world-simulator", () => { + it("starts default orchestrator and accepts stub design-flow", async () => { + const { PhaseRuntime, createDecision } = await import("../src/runtime/phase-runtime.js"); + const runtime = new PhaseRuntime({ autoStubWorker: true }); + await runtime.start(); + expect(runtime.getActiveSkill()?.name).toBe("world-simulator"); + + await runtime.submitInput("西幻升级交互,世界推着走"); + const decision = createDecision({ + action: "run_worker", + reason: "编排创作流程", + workerId: "design-flow", + requiresApproval: false, + }); + await runtime.submitDecision(decision); + expect(runtime.getSession().artifacts.length).toBeGreaterThan(0); }); }); diff --git a/tests/preset.test.ts b/tests/preset.test.ts index 3725b18..bd437a3 100644 --- a/tests/preset.test.ts +++ b/tests/preset.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import { assemblePresetMessages } from "../src/preset/assembler.js"; -import { listEnabledPresetEntries, countInjectingEntries } from "../src/preset/entries.js"; +import { + applyPresetEntryPatches, + countInjectingEntries, + listAllPresetEntries, + listEnabledPresetEntries, +} from "../src/preset/entries.js"; import { importSillyTavernPreset } from "../src/preset/importer.js"; const samplePreset = { @@ -82,3 +87,29 @@ describe("listEnabledPresetEntries", () => { ]); }); }); + +describe("applyPresetEntryPatches", () => { + it("toggles enable and edits content", () => { + const report = importSillyTavernPreset(samplePreset); + const disabled = applyPresetEntryPatches(report.preset, [ + { id: "custom-a", enabled: false }, + ]); + expect(listAllPresetEntries(disabled).find((e) => e.id === "custom-a")?.enabled).toBe( + false, + ); + expect(assemblePresetMessages(disabled).map((m) => m.content)).toEqual([ + "prefill B", + ]); + + const edited = applyPresetEntryPatches(disabled, [ + { id: "main", enabled: true, content: "hello main", name: "主提示" }, + ]); + const main = listAllPresetEntries(edited).find((e) => e.id === "main"); + expect(main?.enabled).toBe(true); + expect(main?.willInject).toBe(true); + expect(main?.name).toBe("主提示"); + expect( + assemblePresetMessages(edited).some((m) => m.content === "hello main"), + ).toBe(true); + }); +}); diff --git a/tests/prune-creation-messages.test.ts b/tests/prune-creation-messages.test.ts new file mode 100644 index 0000000..5484466 --- /dev/null +++ b/tests/prune-creation-messages.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from "vitest"; +import { + foldRunProcessMessages, + pruneCreationUnitMessages, + trimCreationDialogueMessages, + selectCreationDialogueForAi, + buildCreationDialogueTranscript, + type PrunableChatMessage, +} from "../src/server/prune-creation-messages.js"; +import { + isDesignUnitArtifact, + isFinalWorkerSetArtifact, + isWorkerSetReadyForFinal, + listCreationUnits, + nextCreationUnitId, +} from "../src/skills/creation-units.js"; +import type { ParsedWorkerSet } from "../src/skills/worker-set-parse.js"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { compressAfterWorkerAccept } from "../src/runtime/compress-after-worker.js"; +import { hasAcceptedWorkerSet } from "../src/skills/worker-declaration.js"; +import { createSession } from "../src/runtime/phase-machine.js"; + +function msg( + partial: Partial & Pick, +): PrunableChatMessage { + return { createdAt: "2026-01-01T00:00:00.000Z", ...partial }; +} + +describe("pruneCreationUnitMessages", () => { + it("is a no-op: does not delete user-visible messages", () => { + const messages: PrunableChatMessage[] = [ + msg({ id: "1", role: "user", text: "我想玩校园末日", kind: "user_input" }), + msg({ + id: "2", + role: "system", + text: "[Worker] design-core 运行中", + kind: "worker_running", + actor: "design-core", + }), + msg({ + id: "3", + role: "system", + text: "[Worker] design-core 提问", + kind: "worker_questions", + actor: "design-core", + }), + msg({ id: "4", role: "user", text: "单角代入", kind: "user_input" }), + msg({ + id: "5", + role: "system", + text: "[Worker] design-core 草稿", + kind: "worker_output", + actor: "design-core", + body: "{ draft }", + }), + msg({ + id: "6", + role: "system", + text: "[Worker] design-core 定稿", + kind: "worker_output", + actor: "design-core", + body: "{ final }", + }), + ]; + const idsBefore = messages.map((m) => m.id); + const result = pruneCreationUnitMessages(messages, "design-core"); + expect(result).toEqual({ removedCount: 0, productKept: false }); + expect(messages.map((m) => m.id)).toEqual(idsBefore); + }); +}); + +describe("selectCreationDialogueForAi / buildCreationDialogueTranscript", () => { + it("keeps full messages for browsing; AI transcript only last AI + all users", () => { + const messages: PrunableChatMessage[] = [ + msg({ id: "u1", role: "user", text: "荒野求生", kind: "user_input" }), + msg({ + id: "a1", + role: "system", + text: "draft1", + kind: "worker_output", + actor: "design-core", + }), + msg({ id: "u2", role: "user", text: "无其它幸存者", kind: "user_input" }), + msg({ + id: "run", + role: "system", + text: "running", + kind: "worker_running", + actor: "design-core", + }), + msg({ + id: "a2", + role: "system", + text: "draft2", + kind: "worker_output", + actor: "design-core", + body: "{ v2 }", + }), + ]; + + // 用户浏览:原数组不动 + const { removedCount } = trimCreationDialogueMessages(messages); + expect(removedCount).toBe(0); + expect(messages.map((m) => m.id)).toEqual(["u1", "a1", "u2", "run", "a2"]); + + // 拼给 AI:用户全量 + 最后一次 AI + expect(selectCreationDialogueForAi(messages).map((m) => m.id)).toEqual([ + "u1", + "u2", + "a2", + ]); + const transcript = buildCreationDialogueTranscript(messages); + expect(transcript).toContain("荒野求生"); + expect(transcript).toContain("无其它幸存者"); + expect(transcript).toContain("{ v2 }"); + expect(transcript).not.toContain("draft1"); + expect(transcript).not.toContain("running"); + }); +}); + +describe("foldRunProcessMessages", () => { + it("marks process messages compressed without deleting", () => { + const messages: PrunableChatMessage[] = [ + msg({ + id: "1", + role: "system", + text: "q", + kind: "worker_questions", + actor: "narrator", + }), + msg({ + id: "2", + role: "system", + text: "out", + kind: "worker_output", + actor: "narrator", + }), + ]; + foldRunProcessMessages(messages, "narrator"); + expect(messages).toHaveLength(2); + expect(messages.every((m) => m.compressed)).toBe(true); + }); +}); + +describe("listCreationUnits", () => { + it("only lists filled fixed topics by default (not empty catalog blanks)", () => { + const parsed: ParsedWorkerSet = { + interaction: { + user_stance: "单角", + system_role: "世界", + output: "叙事", + }, + narrative_guide: "世界不有求必应", + workers: [ + { + ref: "narrator", + duty: "转述", + rationale: "可读", + acceptance: "review", + }, + { ref: null, duty: "缺口", gap: "待定" }, + ], + resident_context: [ + { id: "tone", content: "文风克制", position: "static" }, + ], + }; + const units = listCreationUnits(parsed, { + acceptedUnitIds: ["fixed:interaction", "skeleton:interaction"], + currentUnitId: "worker:narrator", + }); + expect(units.map((u) => u.id)).toEqual([ + "phase:core", + "fixed:narrative_guide", + "worker:narrator", + "worker:#2", + "resident:tone", + ]); + expect(units.find((u) => u.id === "phase:core")?.accepted).toBe(true); + expect(units.every((u) => u.id !== "fixed:input_protocol" || u.filled)).toBe( + true, + ); + expect(units.find((u) => u.id === "fixed:input_protocol")).toBeUndefined(); + expect(units.find((u) => u.id === "worker:narrator")?.current).toBe(true); + }); + + it("nextCreationUnitId prefers foundation fixed tags before workers", () => { + const parsed: ParsedWorkerSet = { + interaction: { + user_stance: "单角", + system_role: "世界", + output: "叙事", + }, + workers: [{ ref: "narrator", duty: "转述", rationale: "r" }], + }; + expect(nextCreationUnitId(parsed, ["fixed:interaction"])).toBe( + "fixed:narrative_guide", + ); + }); + + it("nextCreationUnitId reaches workers after weighty fixed accepted", () => { + const parsed: ParsedWorkerSet = { + interaction: { + user_stance: "单角", + system_role: "世界", + output: "叙事", + }, + narrative_guide: "边界清晰", + workers: [{ ref: "narrator", duty: "转述", rationale: "r" }], + }; + expect( + nextCreationUnitId(parsed, [ + "fixed:interaction", + "fixed:narrative_guide", + "fixed:aesthetics", + "fixed:input_protocol", + "fixed:core_premises", + ]), + ).toBe("worker:narrator"); + }); +}); + +describe("artifact classification", () => { + it("distinguishes unit vs final", () => { + expect( + isDesignUnitArtifact({ + workerId: "design-core", + outputTags: ["设计.worker集.草稿"], + }), + ).toBe(true); + expect( + isFinalWorkerSetArtifact({ + workerId: "design-core", + outputTags: ["设计.worker集"], + }), + ).toBe(true); + expect( + isDesignUnitArtifact({ + workerId: "design-core", + outputTags: ["设计.worker集", "设计.worker集.草稿"], + }), + ).toBe(false); + }); +}); + +describe("hasAcceptedWorkerSet", () => { + it("does not treat draft-only accepted artifact as ready", () => { + const session = { + ...createSession(), + artifacts: [ + { + id: "a1", + workerId: "design-core", + outputTags: ["设计.worker集.草稿"], + status: "accepted" as const, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ], + }; + expect(hasAcceptedWorkerSet(session)).toBe(false); + }); +}); + +describe("compressAfterWorkerAccept unit mode", () => { + it("keeps draft active and does not write brief as final", () => { + const bb = new Blackboard(); + bb.write({ + tag: "设计.worker集.草稿", + content: '{ "workers": [{ "ref": "narrator" }] }', + source: "design-core", + }); + bb.write({ + tag: "用户.worker答复", + content: "过程问答", + source: "user", + }); + const result = compressAfterWorkerAccept({ + blackboard: bb, + workerId: "design-core", + outputTags: ["设计.worker集.草稿"], + summary: "单位 worker:narrator", + mode: "unit", + }); + expect(result.archivedTags).toContain("用户.worker答复"); + expect(bb.getContentByTag("设计.worker集.草稿")).toContain("narrator"); + expect(bb.getLatestByTag("设计.worker集.草稿")?.metadata?.role).not.toBe( + "archived", + ); + }); +}); + +describe("isWorkerSetReadyForFinal", () => { + it("requires accepted filled units", () => { + const parsed: ParsedWorkerSet = { + interaction: { + user_stance: "单角", + system_role: "世界", + output: "叙事", + }, + workers: [{ ref: "narrator", duty: "转述", rationale: "r" }], + }; + expect(isWorkerSetReadyForFinal(parsed, []).ready).toBe(false); + expect( + isWorkerSetReadyForFinal(parsed, [ + "fixed:interaction", + "worker:narrator", + ]).ready, + ).toBe(true); + }); +}); diff --git a/tests/question-protocol.test.ts b/tests/question-protocol.test.ts new file mode 100644 index 0000000..81f344b --- /dev/null +++ b/tests/question-protocol.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + formatQuestionAnswersForAi, + formatQuestionAnswersForDisplay, + normalizeQuestions, +} from "../src/skills/question-protocol.js"; + +describe("normalizeQuestions", () => { + it("accepts string[]", () => { + const qs = normalizeQuestions(["站位倾向?", " "]); + expect(qs).toHaveLength(1); + expect(qs[0]?.prompt).toBe("站位倾向?"); + expect(qs[0]?.allowOther).toBe(true); + }); + + it("accepts structured items with options", () => { + const qs = normalizeQuestions([ + { + id: "stance", + prompt: "你更想以什么身份玩?", + options: ["单角幸存者代入", { id: "B", label: "旁观调度" }], + }, + ]); + expect(qs[0]?.id).toBe("stance"); + expect(qs[0]?.options).toHaveLength(2); + expect(qs[0]?.options?.[0]?.id).toBe("A"); + expect(qs[0]?.options?.[0]?.label).toContain("幸存者"); + }); +}); + +describe("formatQuestionAnswers", () => { + it("AI payload keeps Q+A; display omits prompts", () => { + const questions = normalizeQuestions([ + { id: "q1", prompt: "有无其它幸存者?", options: ["没有,仅自然生物"] }, + ]); + const answers = [ + { questionId: "q1", optionId: "A", text: "没有,仅自然生物" }, + ]; + const ai = formatQuestionAnswersForAi(questions, answers); + expect(ai).toContain("问:有无其它幸存者?"); + expect(ai).toContain("答:没有,仅自然生物"); + const display = formatQuestionAnswersForDisplay(questions, answers); + expect(display).toBe("没有,仅自然生物"); + expect(display).not.toContain("问:"); + }); + + it("appends free-text note to AI and display payloads", () => { + const questions = normalizeQuestions([ + { id: "q1", prompt: "节奏?", options: ["严格回合制"] }, + ]); + const answers = [ + { questionId: "q1", optionId: "A", text: "严格回合制" }, + ]; + const ai = formatQuestionAnswersForAi(questions, answers, "希望偏慢热"); + expect(ai).toContain("【补充】"); + expect(ai).toContain("希望偏慢热"); + const display = formatQuestionAnswersForDisplay( + questions, + answers, + "希望偏慢热", + ); + expect(display).toContain("严格回合制"); + expect(display).toContain("希望偏慢热"); + }); +}); diff --git a/tests/resident-context.test.ts b/tests/resident-context.test.ts new file mode 100644 index 0000000..109d73a --- /dev/null +++ b/tests/resident-context.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { + entriesForWorker, + formatResidentPromptSection, + mountResidentContextForWorker, + parseResidentContext, + residentTagFor, +} from "../src/skills/resident-context.js"; +import { buildDeclaredWorkerSkill } from "../src/skills/declared-worker.js"; +import type { ParsedWorkerSet } from "../src/skills/worker-set-parse.js"; + +describe("resident-context", () => { + const entries = parseResidentContext([ + { + id: "tone", + position: "static", + importance: 2, + content: "文风克制、偏冷", + mount: ["narrator"], + }, + { + id: "rules", + content: "硬规则:不死复活", + mount: ["world-simulator", "narrator"], + }, + { + id: "scratch", + position: "dynamic", + content: "本轮提示", + mount: ["world-simulator"], + }, + ]); + + it("parses and sorts by importance", () => { + expect(entries[0]?.id).toBe("tone"); + expect(residentTagFor(entries[0]!)).toBe("上下文.常驻.tone"); + }); + + it("filters mount per worker", () => { + expect(entriesForWorker(entries, "narrator").map((e) => e.id)).toEqual([ + "tone", + "rules", + ]); + expect(entriesForWorker(entries, "world-simulator").map((e) => e.id)).toEqual([ + "rules", + "scratch", + ]); + }); + + it("writes tags to blackboard", () => { + const bb = new Blackboard(); + const { staticTags, dynamicTags, written } = mountResidentContextForWorker({ + blackboard: bb, + entries, + workerId: "world-simulator", + }); + expect(written).toContain("上下文.常驻.rules"); + expect(staticTags).toContain("上下文.常驻.rules"); + expect(dynamicTags).toContain("上下文.常驻.scratch"); + expect(bb.getContentByTag("上下文.常驻.rules")).toContain("不死复活"); + }); + + it("injects into declared worker prompt and inputTags", () => { + const workerSet: ParsedWorkerSet = { + workers: [ + { + ref: "narrator", + duty: "转述", + acceptance: "review", + }, + ], + resident_context: entries, + narrative_guide: "残酷求生", + }; + const built = buildDeclaredWorkerSkill({ + skillPackName: "world-simulator", + entry: workerSet.workers[0]!, + template: { + id: "narrator", + label: "转述", + suggested_outputs: ["输出.用户展示"], + }, + workerSet, + }); + expect(built.promptBody).toContain("常驻上下文"); + expect(built.promptBody).toContain("文风克制"); + expect(built.worker.inputTags).toContain("上下文.常驻.tone"); + expect(built.worker.inputTags).toContain("上下文.常驻.rules"); + expect(built.worker.inputTags).not.toContain("上下文.常驻.scratch"); + }); + + it("formats prompt section", () => { + const text = formatResidentPromptSection(entries, "narrator"); + expect(text).toContain("### tone"); + expect(text).not.toContain("scratch"); + }); +}); diff --git a/tests/run-snapshot.test.ts b/tests/run-snapshot.test.ts index 6904e5d..21f5632 100644 --- a/tests/run-snapshot.test.ts +++ b/tests/run-snapshot.test.ts @@ -23,7 +23,7 @@ describe("run snapshot store", () => { bookId, label, kind, - orchestratorId: "roleplay-game-theory", + orchestratorId: "world-simulator", runtimeSession, blackboardItems: [ { @@ -129,7 +129,7 @@ describe("run snapshot store", () => { const book = createBook({ title: "读档测试" }); const mgr = new SessionManager(); - const view1 = await mgr.createForBook(book.id, "basic"); + const view1 = await mgr.createForBook(book.id, "world-simulator"); await mgr.sendMessage(view1.id, "读档前消息"); const save = mgr.saveGameSnapshot(view1.id, "第一章末"); @@ -141,7 +141,7 @@ describe("run snapshot store", () => { expect(view2.messages.some((m) => m.text === "读档前消息")).toBe(true); expect(view2.messages.some((m) => m.text === "读档后不应保留的消息")).toBe(false); expect(view2.hints.some((h) => h.includes("第一章末"))).toBe(true); - }); + }, 20_000); it("instance snapshot strips run progress on save and load", async () => { const { createBook } = await import("../src/book/store.js"); @@ -151,7 +151,7 @@ describe("run snapshot store", () => { const book = createBook({ title: "实例快照" }); const mgr = new SessionManager(); - const view = await mgr.createForBook(book.id, "basic"); + const view = await mgr.createForBook(book.id, "world-simulator"); const managed = mgr["require"](view.id) as { runtime: { getSession: () => { slots: Record }; getBlackboard: () => { write: (i: object) => void } }; }; diff --git a/tests/session-persist.test.ts b/tests/session-persist.test.ts index e52a702..5cc9a94 100644 --- a/tests/session-persist.test.ts +++ b/tests/session-persist.test.ts @@ -30,7 +30,7 @@ describe("session persistence", () => { version: 1, sessionId, bookId: book.id, - orchestratorId: "basic", + orchestratorId: "world-simulator", runtimeSession, blackboardItems: [ { @@ -70,7 +70,7 @@ describe("session persistence", () => { const book = createBook({ title: "续作测试" }); const mgr1 = new SessionManager(); - const view1 = await mgr1.createForBook(book.id, "basic"); + const view1 = await mgr1.createForBook(book.id, "world-simulator"); await mgr1.sendMessage(view1.id, "测试输入"); const mgr2 = new SessionManager(); @@ -81,7 +81,7 @@ describe("session persistence", () => { expect(view2.hints.some((h) => h.includes("恢复"))).toBe(true); expect(view2.messages.some((m) => m.text === "测试输入")).toBe(true); expect(getBook(book.id)?.activeSessionId).toBe(view1.id); - }); + }, 20_000); it("deleteBook removes session snapshot", async () => { const { createBook, deleteBook } = await import("../src/book/store.js"); @@ -91,24 +91,39 @@ describe("session persistence", () => { const book = createBook({ title: "删除测试" }); const mgr = new SessionManager(); - await mgr.createForBook(book.id, "basic"); + await mgr.createForBook(book.id, "world-simulator"); expect(hasBookSession(book.id)).toBe(true); deleteBook(book.id); expect(hasBookSession(book.id)).toBe(false); }); - it("createForBook captures startup inquiry in messages", async () => { + it("createForBook auto-starts world-simulator agent-first", async () => { const { createBook, deleteBook } = await import("../src/book/store.js"); const { SessionManager } = await import("../src/server/session-manager.js"); const book = createBook({ title: "启动询问" }); const mgr = new SessionManager(); - const view = await mgr.createForBook(book.id, "roleplay-game-theory"); - expect(view.waitingReason?.kind).toBe("intake"); - expect(view.messages.some((m) => m.text?.includes("角色扮演博弈"))).toBe(true); - expect(view.messages.some((m) => m.kind === "orchestrator_prompt")).toBe(true); + const view = await mgr.createForBook(book.id); + expect(view.waitingReason?.kind).toBe("input"); + expect(view.activeSkill).toBe("world-simulator"); + expect(view.uiPrompt).toContain("描述想做什么"); + expect(view.messages.some((m) => m.text?.includes("描述想做什么"))).toBe(false); + + deleteBook(book.id); + }); + + it("createForBook with explicit world-simulator override", async () => { + const { createBook, deleteBook } = await import("../src/book/store.js"); + const { SessionManager } = await import("../src/server/session-manager.js"); + + const book = createBook({ title: "显式包" }); + + const mgr = new SessionManager(); + const view = await mgr.createForBook(book.id, "world-simulator"); + expect(view.waitingReason?.kind).toBe("input"); + expect(view.activeSkill).toBe("world-simulator"); deleteBook(book.id); }); diff --git a/tests/table-cells.test.ts b/tests/table-cells.test.ts new file mode 100644 index 0000000..1ee73c2 --- /dev/null +++ b/tests/table-cells.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + createTableFromValues, + mergeTableCells, + parseTableDoc, + stringifyTableDoc, +} from "../src/blackboard/table-cells.js"; + +describe("table-cells", () => { + it("creates and parses rows with rev", () => { + const doc = createTableFromValues( + { 年龄: 20, 资产: "少" }, + "worker:opening-generator", + { 年龄: { note: "由普通大学生推断" } }, + ); + expect(doc.rows).toHaveLength(2); + const roundtrip = parseTableDoc(stringifyTableDoc(doc)); + expect(roundtrip?.rows.find((r) => r.key === "年龄")?.value).toBe(20); + expect(roundtrip?.rows.find((r) => r.key === "年龄")?.note).toContain("大学生"); + }); + + it("does not overwrite user-owned cells", () => { + const current = createTableFromValues({ 年龄: 22 }, "user"); + const patch = createTableFromValues({ 年龄: 18, 资产: 100 }, "worker:opening-generator"); + const { doc, applied, skipped } = mergeTableCells({ + current, + patch, + actor: "worker:variable-update", + }); + expect(skipped.some((s) => s.key === "年龄" && s.reason === "user-owned")).toBe( + true, + ); + expect(applied).toContain("资产"); + expect(doc.rows.find((r) => r.key === "年龄")?.value).toBe(22); + expect(doc.rows.find((r) => r.key === "资产")?.value).toBe(100); + }); + + it("respects expectedRev conflicts", () => { + const current = createTableFromValues({ 好感: 10 }, "worker:a"); + current.rows[0].rev = 3; + const patch = createTableFromValues({ 好感: 60 }, "worker:b"); + const { skipped, doc } = mergeTableCells({ + current, + patch, + actor: "worker:b", + expectedRev: { 好感: 2 }, + }); + expect(skipped[0]?.reason).toContain("rev-conflict"); + expect(doc.rows.find((r) => r.key === "好感")?.value).toBe(10); + }); +}); diff --git a/tests/table-side-effects.test.ts b/tests/table-side-effects.test.ts new file mode 100644 index 0000000..f16fd82 --- /dev/null +++ b/tests/table-side-effects.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { + createTableFromValues, + mergeTableCells, +} from "../src/blackboard/table-cells.js"; +import { + SIDE_EFFECT_FIRED_TAG, + applySideEffectTagActions, + evaluateSideEffects, + parseFiredRegistry, + parseSideEffectRules, + stringifyFiredRegistry, +} from "../src/blackboard/table-side-effects.js"; + +describe("table-side-effects", () => { + const rules = parseSideEffectRules({ + side_effects: [ + { + id: "affinity-romance", + field: "好感", + op: "gte", + value: 60, + mode: "once", + action: { + type: "write_tag", + tag: "上下文.角色态度", + content: "恋爱模式", + }, + }, + { + id: "chapter-1", + field: "当前章", + op: "eq", + value: "第一章", + mode: "once", + action: { + type: "write_tag", + tag: "上下文.细纲", + content: "第一章细纲", + }, + }, + { + id: "hp-warn", + field: "HP", + op: "lte", + value: 20, + mode: "every_edge", + action: { + type: "queue_worker", + workerId: "narrator", + note: "低血警告", + }, + }, + ], + }); + + it("parses side_effects from tables", () => { + expect(rules).toHaveLength(3); + expect(rules[0]?.id).toBe("affinity-romance"); + expect(rules[0]?.mode).toBe("once"); + }); + + it("fires on rising edge only, not while condition stays true", () => { + const prev = createTableFromValues({ 好感: 50 }, "worker:a"); + const mid = createTableFromValues({ 好感: 65 }, "worker:a"); + const first = evaluateSideEffects({ + prev, + next: mid, + rules, + fired: {}, + }); + expect(first.triggers.map((t) => t.rule.id)).toEqual(["affinity-romance"]); + + const stillHigh = createTableFromValues({ 好感: 80 }, "worker:a"); + const second = evaluateSideEffects({ + prev: mid, + next: stillHigh, + rules, + fired: first.nextFired, + }); + expect(second.triggers).toHaveLength(0); + expect(second.nextFired["affinity-romance"]).toBeTruthy(); + }); + + it("does not re-fire once rule after drop-and-rise when already fired", () => { + const fired = { "affinity-romance": { at: "2020-01-01T00:00:00.000Z" } }; + const prev = createTableFromValues({ 好感: 40 }, "worker:a"); + const next = createTableFromValues({ 好感: 70 }, "worker:a"); + const result = evaluateSideEffects({ prev, next, rules, fired }); + expect(result.triggers.find((t) => t.rule.id === "affinity-romance")).toBeUndefined(); + }); + + it("every_edge can fire again after leaving and re-entering", () => { + const low = createTableFromValues({ HP: 10 }, "worker:a"); + const ok = createTableFromValues({ HP: 50 }, "worker:a"); + const lowAgain = createTableFromValues({ HP: 15 }, "worker:a"); + + const enter = evaluateSideEffects({ + prev: createTableFromValues({ HP: 40 }, "worker:a"), + next: low, + rules, + fired: {}, + }); + expect(enter.queuedWorkers.map((q) => q.workerId)).toEqual(["narrator"]); + + const leave = evaluateSideEffects({ + prev: low, + next: ok, + rules, + fired: enter.nextFired, + }); + expect(leave.triggers).toHaveLength(0); + + const reenter = evaluateSideEffects({ + prev: ok, + next: lowAgain, + rules, + fired: leave.nextFired, + }); + expect(reenter.queuedWorkers.map((q) => q.workerId)).toEqual(["narrator"]); + }); + + it("applies write_tag actions and persists fired registry", () => { + const bb = new Blackboard(); + const prev = createTableFromValues({ 当前章: "序章" }, "user"); + const next = createTableFromValues({ 当前章: "第一章" }, "worker:var"); + const { triggers, nextFired } = evaluateSideEffects({ + prev, + next, + rules, + fired: {}, + }); + const { writtenTags } = applySideEffectTagActions({ + blackboard: bb, + triggers, + source: "side-effect:test", + }); + expect(writtenTags).toContain("上下文.细纲"); + expect(bb.getContentByTag("上下文.细纲")).toBe("第一章细纲"); + bb.write({ + tag: SIDE_EFFECT_FIRED_TAG, + content: stringifyFiredRegistry(nextFired), + source: "system", + }); + expect(parseFiredRegistry(bb.getContentByTag(SIDE_EFFECT_FIRED_TAG))["chapter-1"]).toBeTruthy(); + }); + + it("works after mergeTableCells patch", () => { + const current = createTableFromValues({ 好感: 55 }, "worker:a"); + const patch = createTableFromValues({ 好感: 60 }, "worker:b"); + const { doc } = mergeTableCells({ + current, + patch, + actor: "worker:b", + }); + const { triggers } = evaluateSideEffects({ + prev: current, + next: doc, + rules, + fired: {}, + }); + expect(triggers[0]?.rule.id).toBe("affinity-romance"); + }); +}); diff --git a/tests/token-usage.test.ts b/tests/token-usage.test.ts index a11de32..1bd9a8f 100644 --- a/tests/token-usage.test.ts +++ b/tests/token-usage.test.ts @@ -79,6 +79,7 @@ describe("TokenTrackingProvider", () => { caller: "worker:write-rules", model: "test-model", }); + expect(ctx.pendingUsage?.at).toBeTruthy(); expect(ctx.pendingUsage?.recordId).toBeTruthy(); }); diff --git a/tests/tool-loop.test.ts b/tests/tool-loop.test.ts index 0c442e9..4c15c45 100644 --- a/tests/tool-loop.test.ts +++ b/tests/tool-loop.test.ts @@ -63,4 +63,36 @@ describe("main agent tool loop", () => { expect(decision.action).toBe("finish"); expect(decision.reason).toBe("完成"); }); + + it("maps ask_user assessment + questions", () => { + const decision = toolCallToDecision({ + id: "c2", + name: "ask_user", + arguments: JSON.stringify({ + reason: "核心体验分叉需用户拍板", + assessment: + "核心感觉: 完备度 40%\n 已知: 皇帝权力幻想\n 待探: 【冷峻威严】还是【感官沉沦】?", + questions: [ + { + id: "q1", + prompt: "你更倾向于哪种皇帝的享受?", + options: [ + { + label: "冰冷的、主宰一切的权力感——九重宫阙一言定生死", + }, + { + label: "私密的、极致的感官享受——温柔乡中的沉沦", + }, + ], + }, + ], + }), + }); + expect(decision.action).toBe("ask_user"); + expect(decision.reason).toBe("核心体验分叉需用户拍板"); + expect(decision.assessment).toContain("完备度 40%"); + expect(decision.questions).toHaveLength(1); + expect(decision.questions?.[0]?.prompt).toContain("皇帝的享受"); + expect(decision.questions?.[0]?.options?.[0]?.label).toContain("权力感"); + }); }); diff --git a/tests/worker-declaration.test.ts b/tests/worker-declaration.test.ts new file mode 100644 index 0000000..3b9c068 --- /dev/null +++ b/tests/worker-declaration.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { createSession } from "../src/runtime/phase-machine.js"; +import { + buildInstanceWorkerDeclaration, + inferLifecycleStage, + isWorkerDeclared, + readWorkerSetYamlForDeclaration, +} from "../src/skills/worker-declaration.js"; + +const WORKER_SET = ` +version: 1 +workers: + - ref: world-simulator + duty: 世界推进 + - ref: narrator + duty: 转述 + - ref: opening-generator + duty: 开局 +instantiate_hints: + invoke: [opening-generator] +`; + +function sessionWithAcceptedWorkerSet() { + return { + ...createSession(), + slots: { + ...createSession().slots, + designInstanceReady: true, + }, + }; +} + +describe("worker-declaration", () => { + it("design before accept: design step skills", () => { + const bb = new Blackboard(); + const decl = buildInstanceWorkerDeclaration(createSession(), bb, "design"); + expect(decl.activeWorkerIds).toEqual(["design-flow", "design-step"]); + expect(decl.accepted).toBe(false); + }); + + it("play after accept: only run workers from worker set", () => { + const bb = new Blackboard(); + bb.write({ tag: "设计.worker集", content: WORKER_SET, source: "test" }); + const session = { + ...sessionWithAcceptedWorkerSet(), + slots: { + ...sessionWithAcceptedWorkerSet().slots, + uiLifecycleStage: "play", + }, + }; + const decl = buildInstanceWorkerDeclaration(session, bb, "play"); + expect(decl.playWorkerIds).toEqual(["world-simulator", "narrator"]); + expect(decl.activeWorkerIds).toEqual(["world-simulator", "narrator"]); + expect(decl.designEndWorkerIds).toEqual(["opening-generator"]); + expect(isWorkerDeclared(decl, "narrator")).toBe(true); + expect(isWorkerDeclared(decl, "design-flow")).toBe(false); + expect(isWorkerDeclared(decl, "opening-generator")).toBe(false); + }); + + it("design after accept: design steps + design-end workers", () => { + const bb = new Blackboard(); + bb.write({ tag: "设计.worker集", content: WORKER_SET, source: "test" }); + const decl = buildInstanceWorkerDeclaration( + sessionWithAcceptedWorkerSet(), + bb, + "design", + ); + expect(decl.activeWorkerIds).toContain("design-flow"); + expect(decl.activeWorkerIds).toContain("design-step"); + expect(decl.activeWorkerIds).toContain("opening-generator"); + expect(decl.activeWorkerIds).not.toContain("world-simulator"); + }); + + it("prefers accepted tag over draft when worker set accepted", () => { + const bb = new Blackboard(); + bb.write({ tag: "设计.worker集", content: WORKER_SET, source: "test" }); + bb.write({ tag: "设计.worker集.草稿", content: "workers: []", source: "test" }); + const raw = readWorkerSetYamlForDeclaration(bb, sessionWithAcceptedWorkerSet()); + expect(raw?.sourceTag).toBe("设计.worker集"); + expect(raw?.yaml).toContain("world-simulator"); + }); + + it("inferLifecycleStage respects play tab and acceptance", () => { + const bb = new Blackboard(); + const s1 = { + ...createSession(), + slots: { ...createSession().slots, uiLifecycleStage: "play" }, + }; + expect(inferLifecycleStage(s1)).toBe("design"); + + bb.write({ tag: "设计.worker集", content: WORKER_SET, source: "test" }); + const s2 = { + ...sessionWithAcceptedWorkerSet(), + slots: { + ...sessionWithAcceptedWorkerSet().slots, + uiLifecycleStage: "play", + }, + }; + expect(inferLifecycleStage(s2)).toBe("play"); + }); +}); diff --git a/tests/worker-llm.test.ts b/tests/worker-llm.test.ts index 6e7fcbd..77c981b 100644 --- a/tests/worker-llm.test.ts +++ b/tests/worker-llm.test.ts @@ -52,7 +52,7 @@ describe("resolveWorkerLlmProvider", () => { it("returns fallback when no binding", () => { const worker: ParsedWorkerSkill = { id: "role-decide", - skill: "roleplay-game-theory", + skill: "world-simulator", name: "x", description: "", version: 1, @@ -73,7 +73,7 @@ describe("resolveWorkerLlmProvider", () => { it("uses byRole when bindings match", () => { const worker: ParsedWorkerSkill = { id: "role-decide", - skill: "roleplay-game-theory", + skill: "world-simulator", name: "x", description: "", version: 1, diff --git a/tests/worker-set-parse.test.ts b/tests/worker-set-parse.test.ts new file mode 100644 index 0000000..7596965 --- /dev/null +++ b/tests/worker-set-parse.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + deriveDesignStageScope, + deriveInstantiateScope, + deriveReviewWorkerScope, + deriveRunWorkerScope, + parseWorkerSetYaml, +} from "../src/skills/worker-set-parse.js"; + +const SAMPLE = ` +version: 1 +form_summary: 西幻升级交互,带状态跟踪 +play_morphology: action_reaction_loop +workers: + - ref: world-simulator + duty: 世界推进与裁决 + when: 每轮用户输入后 + presentation: null + - ref: narrator + duty: 组装用户可见回复 + when: 中间产物齐后 + presentation: + tone: 冷感 + pacing: 中等 + avoid: [冗长环境描写] + - ref: variable-update + duty: 跟踪等级与资源 + when: 每轮结束后 + - ref: opening-generator + duty: 填初始等级职业与开场白 + when: Worker 集 accept 后、进 play 前 +instantiate_hints: + invoke: + - opening-generator + skip: [] + skip_reason: "" + notes: 需要 swipe 选定开局 +tag_flow: + - "用户.最新输入 → 运行.本轮.拓写" +open_questions: [] +notes: "" +`; + +describe("parseWorkerSetYaml", () => { + it("parses worker set structure", () => { + const parsed = parseWorkerSetYaml(SAMPLE); + expect(parsed).not.toBeNull(); + expect(parsed?.form_summary).toContain("西幻"); + expect(parsed?.workers).toHaveLength(4); + expect(parsed?.workers[1].ref).toBe("narrator"); + expect(parsed?.workers[1].presentation?.tone).toBe("冷感"); + expect(parsed?.instantiate_hints?.invoke).toContain("opening-generator"); + expect(parsed?.instantiate_hints?.notes).toContain("swipe"); + }); + + it("parses JSON worker set (preferred format)", () => { + const json = JSON.stringify({ + version: 1, + interaction: { + user_stance: "单角代入", + system_role: "世界执行+叙事", + turn_shape: "对话回合", + }, + experience_check: { + satisfaction_source: "关系推进", + }, + workers: [ + { + ref: "world-simulator", + duty: "推进", + rationale: "需要世界结果", + acceptance: "continue", + }, + { + ref: "narrator", + duty: "展示", + rationale: "需要可读终稿", + acceptance: "review", + }, + ], + design_end: { opening: "optional" }, + narrative_guide: "不有求必应", + core_premises: ["主角免疫"], + }); + const parsed = parseWorkerSetYaml(json); + expect(parsed?.parseError).toBeUndefined(); + expect(parsed?.interaction?.user_stance).toBe("单角代入"); + expect(parsed?.workers).toHaveLength(2); + expect(parsed?.workers[0].acceptance).toBe("continue"); + expect(parsed?.workers[1].acceptance).toBe("review"); + expect(parsed?.narrative_guide).toBe("不有求必应"); + expect(parsed?.core_premises).toEqual(["主角免疫"]); + expect(deriveDesignStageScope(parsed)).toEqual(["opening-generator"]); + expect(deriveReviewWorkerScope(parsed)).toEqual(["narrator"]); + }); +}); + +describe("deriveDesignStageScope", () => { + it("uses instantiate_hints.invoke and design-end worker refs", () => { + const workerSet = parseWorkerSetYaml(SAMPLE)!; + const scope = deriveDesignStageScope(workerSet); + expect(scope).toEqual(["opening-generator"]); + expect(scope).not.toContain("world-blueprint"); + expect(scope).not.toContain("variable-catalog"); + }); + + it("deriveInstantiateScope aliases deriveDesignStageScope", () => { + const workerSet = parseWorkerSetYaml(SAMPLE)!; + expect(deriveInstantiateScope(workerSet)).toEqual( + deriveDesignStageScope(workerSet), + ); + }); +}); + +describe("deriveRunWorkerScope", () => { + it("lists play run worker refs excluding opening-generator", () => { + const workerSet = parseWorkerSetYaml(SAMPLE)!; + expect(deriveRunWorkerScope(workerSet)).toEqual([ + "world-simulator", + "narrator", + "variable-update", + ]); + }); +}); diff --git a/tests/worker-set-sanitize.test.ts b/tests/worker-set-sanitize.test.ts new file mode 100644 index 0000000..036628f --- /dev/null +++ b/tests/worker-set-sanitize.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + extractJsonObjectText, + isUsableWorkerSet, + looksLikeProseNotSpec, + parseWorkerSetYaml, +} from "../src/skills/worker-set-parse.js"; +import { + parseWorkerResponseForTest, + sanitizeWorkerSetOutputs, +} from "../src/worker/executor.js"; + +describe("worker-set prose rejection", () => { + it("does not YAML-crash on Chinese prose; returns clear parseError", () => { + const prose = + "我们被要求输出 JSON。首先,根据用户输入和 SKILL,进行实例设计。用户需求是:扮演一个坠机的幸存者…"; + expect(looksLikeProseNotSpec(prose)).toBe(true); + const parsed = parseWorkerSetYaml(prose); + expect(parsed?.parseError).toMatch(/不是 JSON 规格|askUser/); + expect(parsed?.parseError).not.toMatch(/Implicit keys/); + }); + + it("extracts JSON object from surrounding prose", () => { + const raw = `说明如下:\n\`\`\`json\n{"version":1,"workers":[{"ref":"narrator","duty":"转述","acceptance":"review"}]}\n\`\`\``; + const extracted = extractJsonObjectText(raw); + expect(extracted?.startsWith("{")).toBe(true); + expect(isUsableWorkerSet(parseWorkerSetYaml(extracted!))).toBe(true); + }); +}); + +describe("sanitizeWorkerSetOutputs", () => { + it("converts prose-in-draft to askUser and clears bad tag", () => { + const result = sanitizeWorkerSetOutputs({ + outputs: { + "设计.worker集.草稿": + "坠机后伤势如何?\n请描述你的初始状态:轻伤还是重伤?\n也可以让我随机生成。", + }, + summary: "提问", + preview: "…", + }); + expect(result.outputs["设计.worker集.草稿"]).toBeUndefined(); + expect(result.askUser?.length).toBeGreaterThan(0); + }); + + it("keeps valid JSON draft", () => { + const json = JSON.stringify({ + version: 1, + workers: [{ ref: "world-simulator", duty: "世界", acceptance: "continue" }], + }); + const result = sanitizeWorkerSetOutputs({ + outputs: { "设计.worker集.草稿": json }, + summary: "草案", + preview: json, + }); + expect(result.outputs["设计.worker集.草稿"]).toContain("world-simulator"); + expect(result.askUser).toBeUndefined(); + }); + + it("parseWorkerResponse does not dump raw prose into worker-set tags", () => { + const raw = + "我们被要求输出 JSON。请描述你的初始状态?伤势如何?"; + const result = parseWorkerResponseForTest(raw, [ + "设计.worker集.草稿", + "设计.worker集", + "用户.需求", + ]); + expect(result.outputs["设计.worker集.草稿"]).toBeUndefined(); + expect(result.askUser?.length).toBeGreaterThan(0); + }); + + it("accepts nested object outputs for worker set", () => { + const result = parseWorkerResponseForTest( + JSON.stringify({ + outputs: { + "设计.worker集.草稿": { + version: 1, + workers: [{ ref: "narrator", duty: "转述", acceptance: "review" }], + }, + }, + summary: "ok", + askUser: null, + }), + ["设计.worker集.草稿"], + ); + expect(result.outputs["设计.worker集.草稿"]).toContain("narrator"); + expect(result.askUser).toBeUndefined(); + }); +}); diff --git a/tests/worker-set-view.test.ts b/tests/worker-set-view.test.ts new file mode 100644 index 0000000..4cf4c34 --- /dev/null +++ b/tests/worker-set-view.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { parseWorkerSetYaml } from "../src/skills/worker-set-parse.js"; +import { formatWorkerSetForUser } from "../src/skills/worker-set-view.js"; + +const FULL_SAMPLE = ` +version: 1 +form_summary: 西幻升级交互,带状态跟踪 +play_morphology: action_reaction_loop +workers: + - ref: world-simulator + duty: 世界推进与裁决 + when: 每轮用户输入后 + context: + static: + - 设计.worker集 + - 世界.蓝图.确认稿 + dynamic: + - 用户.最新输入 + - 运行.事件流 + outputs: + - 运行.本轮.裁决 + - 运行.事件流 + - ref: narrator + duty: 组装用户可见回复 + when: 中间产物齐后 + presentation: + tone: 西幻 + avoid: [空泛公告] + context: + static: + - 叙事.指南.确认稿 + dynamic: + - 运行.事件流 + outputs: + - 输出.用户展示 +instantiate_hints: + invoke: [opening-generator] +tag_flow: + - "用户.最新输入 → 运行.本轮.裁决" + - "运行.本轮.裁决 → 输出.用户展示" +`; + +describe("formatWorkerSetForUser", () => { + it("builds Chinese worker cards with context order", () => { + const parsed = parseWorkerSetYaml(FULL_SAMPLE)!; + const view = formatWorkerSetForUser(parsed)!; + expect(view.headline).toContain("西幻"); + expect(view.playModeLabel).toBe("行动–反应循环"); + expect(view.workers).toHaveLength(2); + expect(view.workers[0].displayName).toBe("世界模拟"); + expect(view.creationUnits?.map((u) => u.id)).toEqual( + expect.arrayContaining([ + "fixed:aesthetics", // sample 含 narrator.presentation + "worker:world-simulator", + "worker:narrator", + ]), + ); + // 未写入的示例话题不出现(不是填空表) + expect(view.creationUnits?.some((u) => u.id === "fixed:input_protocol")).toBe( + false, + ); + expect(view.workers[0].context.staticTags[0].tag).toBe("设计.worker集"); + expect(view.workers[0].writes).toEqual(["运行.本轮.裁决", "运行.事件流"]); + expect(view.workers[0].context.explicit).toBe(true); + expect(view.workers[1].presentation?.[0].label).toBe("语气"); + expect(view.tagFlow).toHaveLength(2); + expect(view.designTasks).toHaveLength(1); + expect(view.designTasks[0].id).toBe("opening-generator"); + }); + + it("exposes contextTags as standalone cards with mount targets", () => { + const parsed = parseWorkerSetYaml(` +narrative_guide: 残酷、不有求必应 +resident_context: + - id: tone + content: 文风克制 + mount: [narrator] +workers: + - ref: world-simulator + duty: 裁决 + - ref: narrator + duty: 转述 + presentation: + tone: 冷峻 +`)!; + const view = formatWorkerSetForUser(parsed)!; + expect(view.contextTags?.length).toBeGreaterThanOrEqual(2); + const guide = view.contextTags?.find((c) => c.id === "fixed:narrative_guide"); + expect(guide?.mountSummary).toBe("全部 Worker"); + expect(guide?.mounts.map((m) => m.workerId).sort()).toEqual([ + "narrator", + "world-simulator", + ]); + const tone = view.contextTags?.find((c) => c.id === "resident:tone"); + expect(tone?.mounts).toHaveLength(1); + expect(tone?.mounts[0].workerId).toBe("narrator"); + expect(tone?.mountSummary).toBe(tone!.mounts[0].workerName); + const aesthetics = view.contextTags?.find((c) => + c.id.startsWith("fixed:aesthetics"), + ); + expect(aesthetics?.mounts[0].workerId).toBe("narrator"); + expect(view.workers[1].readsSummary).toMatch(/美学|叙事|常驻/); + }); + + it("surfaces interaction paradigm, core worker, and rationale", () => { + const parsed = parseWorkerSetYaml(` +form_summary: 囚徒困境旁观 +interaction_paradigm: 旁观多角(推断) +core_worker: world-simulator +reasoning: 三个角色需信息隔绝,各用 role-decide;陈述用 Markdown,不必文学转述。 +workers: + - ref: world-simulator + role: core + rationale: 裁决组合结果,兼任结构化陈述。 + - ref: role-decide + role: auxiliary + rationale: 仅 3 个参与者,隔绝决策上下文。 + merge_considered: 不为每个 NPC 各开一个 worker。 +`)!; + const view = formatWorkerSetForUser(parsed)!; + expect(view.interactionParadigm).toContain("旁观"); + expect(view.coreWorker).toBe("world-simulator"); + expect(view.reasoning).toContain("信息隔绝"); + expect(view.workers[0].roleLabel).toBe("核心"); + expect(view.workers[0].rationale).toContain("裁决"); + expect(view.workers[1].mergeConsidered).toContain("NPC"); + }); + + it("merges default context when explicit context omitted", () => { + const parsed = parseWorkerSetYaml(` +workers: + - ref: variable-update + duty: 跟踪变量 + when: 世界机之后 +`)!; + const view = formatWorkerSetForUser(parsed)!; + expect(view.workers[0].context.staticTags.some((t) => t.tag.includes("变量.目录"))).toBe( + true, + ); + expect(view.workers[0].context.explicit).toBe(false); + expect(view.workers[0].writes).toContain("变量.当前"); + }); + + it("marks design tasks filled when blackboard has tags", () => { + const parsed = parseWorkerSetYaml(` +workers: + - ref: narrator +instantiate_hints: + invoke: [opening-generator] +`)!; + const view = formatWorkerSetForUser(parsed, { + filledTags: ["运行.初始变量", "输出.开场白"], + })!; + expect(view.designTasks[0].status).toBe("filled"); + }); + + it("parses context and outputs fields", () => { + const parsed = parseWorkerSetYaml(FULL_SAMPLE)!; + expect(parsed.workers[0].context?.static).toContain("世界.蓝图.确认稿"); + expect(parsed.workers[0].outputs).toContain("运行.事件流"); + }); +}); diff --git a/web/agent-ui.js b/web/agent-ui.js index 19c785c..3d9e75b 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -1,26 +1,39 @@ import { renderIntakePanel } from "./intake-ui.js"; +import { getActiveQuestions, renderQuestionsCard } from "./questions-ui.js"; +import { displayWorkerLabel, formatWorkerDisplayTitle } from "./display-labels.js"; const HIDE_KINDS = new Set(["worker_stub"]); +/** 中间对话区不展示的内部调度消息(仅出现在右侧「历史」) */ +const FEED_HIDDEN_KINDS = new Set([ + "agent_tool", + "orchestrator_decision", + "worker_running", +]); + const MSG_CLASS = { user_input: "user", agent_tool: "agent", orchestrator_decision: "agent", + orchestrator_thinking: "agent", orchestrator_prompt: "agent", + orchestrator_assessment: "agent", worker_running: "skill", worker_output: "skill", - worker_questions: "skill", + worker_questions: "questions", error: "error", }; const MSG_LABEL = { user_input: "你", - agent_tool: "Tool", - orchestrator_decision: "Agent", - orchestrator_prompt: "Agent", - worker_running: "Skill", - worker_output: "Skill", - worker_questions: "Skill 提问", + agent_tool: "工具", + orchestrator_decision: "导演", + orchestrator_thinking: "导演 · 思考", + orchestrator_prompt: "导演", + orchestrator_assessment: "导演 · 内容评价", + worker_running: "Worker", + worker_output: "Worker", + worker_questions: "提问", error: "错误", system_info: "系统", }; @@ -32,6 +45,31 @@ function esc(s) { .replace(/>/g, ">"); } +function renderThinkingBlock(thinking, { open = false } = {}) { + const text = (thinking ?? "").trim(); + if (!text) return ""; + return `
+ 思考过程 +
${esc(text)}
+
`; +} + +function renderLiveStreamBody(live) { + if (!live) return "…"; + const parts = []; + if (live.thinking?.trim()) { + parts.push( + `
思考
${esc(live.thinking.trim())}
`, + ); + } + if (live.output?.trim()) { + parts.push( + `
输出
${esc(live.output.trim())}
`, + ); + } + return parts.length ? parts.join("") : "…"; +} + function fmtTime(iso) { if (!iso) return ""; return new Date(iso).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }); @@ -39,50 +77,363 @@ function fmtTime(iso) { function msgLabel(msg) { const k = msg.kind ?? (msg.role === "user" ? "user_input" : "system_info"); - if (k.startsWith("worker") && msg.actor) return `Skill · ${msg.actor}`; + if (k.startsWith("worker") && msg.actor) { + if (k === "worker_questions") return formatWorkerDisplayTitle(msg.actor, "questions"); + if (k === "worker_output") return formatWorkerDisplayTitle(msg.actor, "output"); + if (k === "worker_running") return formatWorkerDisplayTitle(msg.actor, "running"); + return displayWorkerLabel(msg.actor); + } return MSG_LABEL[k] ?? k; } +function canEditMessage(msg) { + return msg.role === "user"; +} + +function canRefreshMessage(msg) { + if (msg.role === "user") return false; + const kind = msg.kind ?? "system_info"; + return kind === "worker_questions" || kind === "worker_output"; +} + +function renderMsgVariantBadge(msg) { + const total = msg.branchTotal ?? 1; + if (total <= 1) return ""; + const index = (msg.branchIndex ?? 0) + 1; + return `${index}/${total}`; +} + +function hideMsgMenu() { + const menu = document.getElementById("msg-action-menu"); + if (menu) menu.hidden = true; + msgMenuState.messageId = null; +} + +const msgMenuState = { messageId: null, handlers: null }; + +function showMsgMenu(card, x, y) { + const menu = document.getElementById("msg-action-menu"); + if (!menu || !card) return; + const isReview = card.dataset.review === "1"; + const canEdit = !isReview && card.dataset.canEdit === "1"; + const canDelete = !isReview && Boolean(card.dataset.messageId); + const hasContext = card.dataset.hasContext === "1"; + const editBtn = document.getElementById("msg-menu-edit"); + const delBtn = document.getElementById("msg-menu-delete"); + const ctxBtn = document.getElementById("msg-menu-context"); + if (editBtn) editBtn.toggleAttribute("hidden", !canEdit); + if (delBtn) delBtn.toggleAttribute("hidden", !canDelete); + if (ctxBtn) ctxBtn.toggleAttribute("hidden", !hasContext); + menu.hidden = false; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + msgMenuState.messageId = card.dataset.messageId ?? null; + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + menu.style.left = `${Math.max(4, window.innerWidth - rect.width - 4)}px`; + } + if (rect.bottom > window.innerHeight) { + menu.style.top = `${Math.max(4, window.innerHeight - rect.height - 4)}px`; + } +} + +function wireMsgActionMenu(handlers) { + const menu = document.getElementById("msg-action-menu"); + if (!menu) return; + msgMenuState.handlers = handlers; + if (menu.dataset.wired) return; + menu.dataset.wired = "1"; + menu.addEventListener("click", async (e) => { + const action = e.target.closest("[data-msg-menu-action]")?.getAttribute("data-msg-menu-action"); + const messageId = msgMenuState.messageId; + hideMsgMenu(); + if (!messageId || !action) return; + const card = document.querySelector(`[data-message-id="${messageId}"]`); + const bodyEl = card?.querySelector(".msg-body:not(.msg-body-editing)"); + const body = bodyEl?.textContent ?? card?.dataset.originalText ?? ""; + + if (action === "copy") { + try { + await navigator.clipboard.writeText(body); + } catch { + /* ignore */ + } + return; + } + if (action === "view-context") { + msgMenuState.handlers?.onViewContext?.(messageId); + return; + } + if (action === "edit") { + startInlineEdit(card, body); + return; + } + if (action === "delete") { + if (!confirm("删除此消息及之后的对话?")) return; + msgMenuState.handlers?.onDeleteMessage?.(messageId); + } + }); +} + +function wireMessageContextMenu(feed, handlers) { + if (!feed || feed.dataset.contextWired) return; + feed.dataset.contextWired = "1"; + wireMsgActionMenu(handlers); + feed.addEventListener("contextmenu", (e) => { + const bubble = e.target.closest(".msg-bubble"); + const card = e.target.closest("[data-message-id]"); + if (!bubble || !card || card.classList.contains("msg-pending")) return; + e.preventDefault(); + showMsgMenu(card, e.clientX, e.clientY); + }); +} + +function startInlineEdit(card, bodyText) { + if (!card || card.classList.contains("is-editing")) return; + card.classList.add("is-editing"); + card.dataset.originalText = bodyText; + card.querySelector(".msg-foot")?.setAttribute("hidden", ""); + + const bodyEl = card.querySelector(".msg-body"); + if (!bodyEl) return; + + const messageId = card.dataset.messageId ?? ""; + bodyEl.outerHTML = ` +
+ +
+ + +
+

Ctrl/⌘ + Enter 保存 · Esc 取消

+
`; + + const ta = card.querySelector(".msg-edit-input"); + if (ta) { + ta.focus(); + const len = ta.value.length; + ta.setSelectionRange(len, len); + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 320)}px`; + ta.addEventListener("input", () => { + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 320)}px`; + }); + ta.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + e.preventDefault(); + cancelInlineEdit(card); + } + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + card.querySelector("[data-msg-action=edit-save]")?.click(); + } + }); + } +} + +function cancelInlineEdit(card) { + if (!card) return; + const original = card.dataset.originalText ?? ""; + card.classList.remove("is-editing"); + delete card.dataset.originalText; + card.querySelector(".msg-foot")?.removeAttribute("hidden"); + const editing = card.querySelector(".msg-body-editing"); + if (editing) { + editing.outerHTML = `
${esc(original)}
`; + } +} + +function wireMessageFeedActions(feed, handlers) { + if (!feed || feed.dataset.actionsWired) return; + feed.dataset.actionsWired = "1"; + feed.addEventListener("click", async (e) => { + const btn = e.target.closest("[data-msg-action]"); + if (!btn || btn.disabled) return; + const action = btn.getAttribute("data-msg-action"); + const messageId = btn.getAttribute("data-msg-id"); + if (!messageId && action !== "edit-cancel" && action !== "edit-save") return; + + const card = btn.closest("[data-message-id]"); + const bodyEl = card?.querySelector(".msg-body:not(.msg-body-editing)"); + const body = bodyEl?.textContent ?? card?.dataset.originalText ?? ""; + + if (action === "copy") { + const copyText = + card?.querySelector(".msg-edit-input")?.value ?? + card?.querySelector(".msg-body")?.textContent ?? + body; + try { + await navigator.clipboard.writeText(copyText); + } catch { + /* ignore */ + } + return; + } + + if (action === "edit") { + startInlineEdit(card, body); + return; + } + + if (action === "edit-cancel") { + cancelInlineEdit(card); + return; + } + + if (action === "edit-save") { + const ta = card?.querySelector(".msg-edit-input"); + const next = ta?.value?.trim() ?? ""; + const original = (card?.dataset.originalText ?? body).trim(); + cancelInlineEdit(card); + if (!next || next === original) return; + handlers.onEditMessage?.(messageId, next); + return; + } + + if (action === "refresh") { + handlers.onRefreshMessage?.(messageId); + return; + } + + if (action === "variant-prev") { + handlers.onSwitchVariant?.(messageId, "prev"); + return; + } + + if (action === "variant-next") { + handlers.onSwitchVariant?.(messageId, "next"); + } + }); +} + function msgBody(msg, view) { let body = (msg.body ?? msg.text ?? "").trim(); - if (msg.kind === "worker_questions" && !body) { - const wr = view.waitingReason; - if (wr?.kind === "worker_questions") { - const qs = (wr.questions ?? []).filter(Boolean); - if (qs.length) body = qs.map((q) => `- ${q}`).join("\n"); + if (msg.kind === "worker_questions") { + if (getActiveQuestions(view)) { + const qs = view.waitingReason?.questions ?? []; + const n = Array.isArray(qs) ? qs.length : 0; + return `提问中 · ${n || "?"} 题(请在下方询问卡作答)`; + } + if (!body) { + const wr = view.waitingReason; + if (wr?.kind === "worker_questions") { + const qs = wr.questions ?? []; + const prompts = qs.map((q) => + typeof q === "string" ? q : q?.prompt, + ).filter(Boolean); + if (prompts.length) body = prompts.map((q) => `- ${q}`).join("\n"); + } + if (!body) body = "请补充当前 Worker 需要的信息。"; } - if (!body) body = "请补充当前 skill 需要的信息。"; } return body; } -export function renderSkillPicker(view, onPick) { +function formatQuestionsHtml(body) { + const lines = body + .split(/\n+/) + .map((l) => l.replace(/^[-*•]\s*/, "").trim()) + .filter(Boolean); + if (lines.length <= 1) { + return `

${esc(body)}

`; + } + return `
    ${lines.map((l) => `
  1. ${esc(l)}
  2. `).join("")}
`; +} + +/** 旧会话 skill 选择提示,新流程不再展示 */ +const SKILL_SELECTION_RE = /请选择创作 skill|请选择创作类型/; + +/** @deprecated 旧会话可能仍处于 skill_selection;新作品不再展示选包 UI */ +export function renderSkillPicker(_view, _onPick) { const el = document.getElementById("skill-picker"); if (!el) return; - const selecting = view.waitingReason?.kind === "skill_selection"; - const skills = view.skills ?? []; - if (!selecting || !skills.length) { - el.hidden = true; - el.innerHTML = ""; - return; - } - el.hidden = false; - el.innerHTML = ` -

选择 skill 包

-

Agent 需要知道你要用哪套能力。选一项开始实例化(也可在下方输入名称或编号)。

-
`; - const grid = el.querySelector("#skill-grid"); - skills.forEach((skill, i) => { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "skill-card"; - btn.innerHTML = ` -
${esc(skill.name)}
-
${esc(skill.description || "无说明")}
-
${esc(skill.category || "")} · #${i + 1}
`; - btn.addEventListener("click", () => onPick(skill.name)); - grid.appendChild(btn); + el.hidden = true; + el.innerHTML = ""; +} + +let activeAgentTab = "timeline"; +let lastAutoTabReason = null; +let userPinnedAgentTab = false; + +export function setAgentTab(tab, { user = false } = {}) { + activeAgentTab = tab; + if (user) userPinnedAgentTab = true; + const tabs = document.getElementById("agent-tabs"); + tabs?.querySelectorAll(".agent-tab").forEach((btn) => { + const id = btn.getAttribute("data-tab"); + const active = id === tab && !btn.hidden; + btn.classList.toggle("active", active); + btn.setAttribute("aria-selected", active ? "true" : "false"); }); + for (const id of ["timeline", "review", "progress", "board"]) { + const panel = document.getElementById(`agent-panel-${id}`); + panel?.classList.toggle("active", tab === id); + panel?.toggleAttribute("hidden", tab !== id); + } +} + +function wireAgentTabs() { + const tabs = document.getElementById("agent-tabs"); + if (!tabs || tabs.dataset.wired) return; + tabs.dataset.wired = "1"; + tabs.addEventListener("click", (e) => { + const btn = e.target.closest(".agent-tab"); + if (!btn || btn.hidden) return; + setAgentTab(btn.getAttribute("data-tab") ?? "timeline", { user: true }); + }); +} + +function hasUserMessages(view) { + return (view.messages ?? []).some((m) => m.role === "user"); +} + +function isSkillSelectionMessage(msg) { + const body = (msg.body ?? msg.text ?? "").trim(); + return SKILL_SELECTION_RE.test(body); +} + +function isStartupPromptMessage(msg, view) { + if (msg.role === "user") return false; + const body = (msg.body ?? msg.text ?? "").trim(); + const prompt = (view.intakePrompt ?? "").trim(); + return Boolean(prompt && body === prompt); +} + +function shouldShowInFeed(msg, view) { + if (msg.role === "user") return true; + const kind = msg.kind ?? "system_info"; + if (HIDE_KINDS.has(kind)) return false; + if (FEED_HIDDEN_KINDS.has(kind)) return false; + if (msg.compressed) return false; + if (isSkillSelectionMessage(msg)) return false; + if ( + view.waitingReason?.kind === "intake" && + !hasUserMessages(view) && + isStartupPromptMessage(msg, view) + ) { + return false; + } + if (kind === "worker_output" && view.waitingReason?.kind === "review_artifact") { + return false; + } + if (kind === "orchestrator_prompt") return false; + return true; +} + +function maybeAutoSwitchTab(view) { + const reason = view.waitingReason?.kind ?? null; + // 创作验收改在主对话展示,不再自动跳到侧栏「验收」Tab + if (reason === "review_artifact") { + if (lastAutoTabReason !== "review_artifact" && activeAgentTab === "review") { + setAgentTab("timeline"); + } + } else if (!userPinnedAgentTab && reason === "worker_questions") { + if (lastAutoTabReason !== "worker_questions" && activeAgentTab === "review") { + setAgentTab("timeline"); + } + } + lastAutoTabReason = reason; } export function renderLifecycle(view) { @@ -95,7 +446,9 @@ export function renderLifecycle(view) { btn.classList.toggle("active", s === stage); if (s === "play") { btn.disabled = !view.playReady; - btn.title = view.playReady ? "" : "实例就绪后可切换"; + btn.title = view.playReady + ? "进入游玩 / 写作" + : "请先验收 Worker 集(创作定稿)后再切换"; } else { btn.disabled = false; } @@ -103,22 +456,49 @@ export function renderLifecycle(view) { } export function renderSkillGuide(view) { - const wrap = document.getElementById("skill-guide"); + wireAgentTabs(); + const progressTabBtn = document.getElementById("agent-tab-btn-progress"); const list = document.getElementById("skill-guide-list"); - if (!wrap || !list) return; - const show = view.lifecycleStage === "design" && (view.skillCatalog?.length ?? 0) > 0; + const workerPanel = document.getElementById("worker-set-user-panel"); + if (!list) return; + + const hasCatalog = + view.lifecycleStage === "design" && (view.skillCatalog?.length ?? 0) > 0; + const hasWorkerView = Boolean( + view.workerSetView?.workers?.length || view.workerSetView?.contextTags?.length, + ); + const hasFlowView = Boolean(view.creationFlowView?.steps?.length); + const show = hasCatalog || hasWorkerView || hasFlowView; + + if (progressTabBtn) { + progressTabBtn.hidden = !show; + } + + if (workerPanel) { + const parts = []; + if (hasFlowView && view.lifecycleStage === "design") { + parts.push(renderCreationFlowView(view.creationFlowView)); + } + if (hasWorkerView && view.lifecycleStage === "design") { + parts.push(renderWorkerSetUserView(view.workerSetView, { compact: true })); + } + workerPanel.innerHTML = parts.join(""); + workerPanel.hidden = !workerPanel.innerHTML; + } + if (!show) { - wrap.hidden = true; + list.innerHTML = ""; + if (activeAgentTab === "progress") setAgentTab("timeline"); return; } - wrap.hidden = false; - list.innerHTML = view.skillCatalog + + list.innerHTML = (view.skillCatalog ?? []) .map( (s) => `
-
${esc(s.label)} ${esc(s.id)}
+
${esc(s.label)} ${esc(s.id)}${s.runCount && s.runCount > 1 ? ` ×${s.runCount}` : ""}
${esc(s.purpose)}
`, @@ -126,6 +506,515 @@ export function renderSkillGuide(view) { .join(""); } +function renderTagList(tags, emptyLabel) { + if (!tags?.length) { + return `
${esc(emptyLabel)}
`; + } + return `
    ${tags + .map((t, i) => { + const filled = + t.filled === true + ? `` + : t.filled === false + ? `` + : ""; + const note = t.note ? `${esc(t.note)}` : ""; + return `
  1. ${esc(t.tag)}${filled}${note}
  2. `; + }) + .join("")}
`; +} + +function howLabel(how) { + if (how === "prompt_body") return "注入正文"; + if (how === "resident") return "常驻挂载"; + return "读入黑板"; +} + +function formatFocusBodyHtml(body) { + if (body == null) return `

(本块尚无正文,请在下方对话中补充)

`; + if (typeof body === "string") { + const t = body.trim(); + return t + ? `
${esc(t)}
` + : `

(本块尚无正文,请在下方对话中补充)

`; + } + try { + const pretty = JSON.stringify(body, null, 2); + return `
${highlightJson(pretty)}
`; + } catch { + return `
${esc(String(body))}
`; + } +} + +function renderFocusUnitBanner(focusUnit) { + if (!focusUnit) return ""; + const isContext = + focusUnit.kind === "fixed" || + (typeof focusUnit.id === "string" && + (focusUnit.id.startsWith("fixed:") || focusUnit.id.startsWith("resident:"))); + const kindLabel = + focusUnit.kind === "worker" + ? "创造 Worker" + : focusUnit.kind === "phase" + ? "创作阶段" + : "填充上下文"; + const hint = isContext + ? "请重点审阅高亮区:这是本次要写入 / 验收的上下文正文。" + : focusUnit.kind === "worker" + ? "请重点审阅当前 Worker 规格;展示名应使用中文。" + : "请重点审阅当前创作单位。"; + const bodyHtml = isContext || focusUnit.kind === "phase" + ? `
${formatFocusBodyHtml(focusUnit.body)}
` + : ""; + return `
+
+ ${esc(kindLabel)} + ${esc(focusUnit.label)} + ${esc(focusUnit.id)} +
+

${esc(hint)}

+ ${bodyHtml} +
`; +} + +function renderContextTagCards(contextTags, opts = {}) { + if (!contextTags?.length) return ""; + const compact = opts.compact === true; + const focusId = opts.focusId || ""; + const focusContext = opts.focusContext === true; + const fixedFirst = [...contextTags].sort((a, b) => { + const rank = (k) => (k === "fixed" ? 0 : k === "resident" ? 1 : 2); + return rank(a.kind) - rank(b.kind); + }); + const cards = fixedFirst + .filter((c) => (compact ? c.kind !== "board" : true)) + .map((c) => { + const kindBadge = + c.kind === "fixed" + ? `纲领` + : c.kind === "resident" + ? `常驻` + : `黑板`; + const filled = + c.filled === true + ? `` + : ``; + const mountChips = (c.mounts || []) + .slice(0, compact ? 4 : 12) + .map( + (m) => + `${esc(howLabel(m.how))}${esc(m.workerName)}`, + ) + .join(""); + const more = + (c.mounts?.length ?? 0) > (compact ? 4 : 12) + ? `+${c.mounts.length - (compact ? 4 : 12)}` + : ""; + const isFocus = + focusId && + (c.id === focusId || + (focusId.startsWith("fixed:aesthetics") && c.id.startsWith("fixed:aesthetics")) || + (focusId === "fixed:aesthetics" && c.id.startsWith("fixed:aesthetics:"))); + const dim = focusContext && focusId && !isFocus ? " ws-dimmed" : ""; + const focusCls = isFocus ? " ws-context-focus" : ""; + return `
+
+ ${esc(c.label)} + ${kindBadge} + ${filled} + ${isFocus ? `本次` : ""} +
+ ${c.preview && !compact ? `

${esc(c.preview)}

` : ""} +
+ 塞进 + ${esc(c.mountSummary || "尚未指定 Worker")} +
+ ${mountChips || more ? `
${mountChips}${more}
` : ""} +
`; + }) + .join(""); + if (!cards) return ""; + const sectionCls = focusContext ? " ws-section-focus-context" : ""; + return `
+

${focusContext ? "固定上下文 · 本次要填" : "固定上下文"}

+

${ + focusContext + ? "高亮卡片是本次验收对象;其它块仅供对照。" + : "与 Worker 解耦:先看纲领挂到谁,再看下方分工。同一块可挂多个 Worker。" + }

+
${cards}
+
`; +} + +function renderWorkerSetUserView(userView, opts = {}) { + if (!userView) return ""; + if (userView.parseError) { + return ``; + } + + const compact = opts.compact === true; + const parts = []; + const focusUnit = userView.focusUnit; + const focusId = focusUnit?.id || ""; + const focusContext = + focusUnit?.kind === "fixed" || + (typeof focusId === "string" && + (focusId.startsWith("fixed:") || focusId.startsWith("resident:"))); + const focusWorkerRef = + focusUnit?.kind === "worker" && focusId.startsWith("worker:") + ? focusId.slice("worker:".length) + : ""; + + // 当前单位横幅:填充上下文时高亮正文;创造 worker 时标明焦点 + const focusBanner = renderFocusUnitBanner(focusUnit); + if (focusBanner) parts.push(focusBanner); + + if (userView.headline) { + parts.push( + `

这次体验

${esc(userView.headline)}

`, + ); + } + + if (userView.interactionParadigm) { + parts.push( + `

交互范式

${esc(userView.interactionParadigm)}

`, + ); + } + + if (userView.coreWorker) { + parts.push( + `

核心 Worker

${esc(userView.coreWorker)} — 负责推剧情并产出本轮实质内容

`, + ); + } + + if (!compact && userView.reasoning) { + parts.push( + `

分工推理

${esc(userView.reasoning)}

`, + ); + } + + if (userView.playModeLabel) { + parts.push( + `

游玩方式

${esc(userView.playModeLabel)}${userView.playModeHint ? ` — ${esc(userView.playModeHint)}` : ""}

`, + ); + } + + // 固定上下文独立区(在 worker 之前;填充模式下高亮本次块) + const contextSection = renderContextTagCards(userView.contextTags, { + compact, + focusId, + focusContext, + }); + if (contextSection) parts.push(contextSection); + + if (userView.inputProtocol?.length && !userView.contextTags?.some((c) => c.id === "fixed:input_protocol")) { + const rows = userView.inputProtocol + .map((p) => `
  • ${esc(p.label)}:${esc(p.value)}
  • `) + .join(""); + parts.push(`

    输入协议

      ${rows}
    `); + } + + if (userView.workers?.length) { + const cards = userView.workers + .map((w) => { + const isFocusWorker = focusWorkerRef && w.id === focusWorkerRef; + const statusBadge = + w.status === "gap" + ? `待补 SKILL` + : `就绪`; + const idLine = w.id ? `${esc(w.id)}` : ""; + const roleBadge = w.roleLabel + ? `${esc(w.roleLabel)}` + : ""; + const acceptBadge = w.acceptanceLabel + ? `${esc(w.acceptanceLabel)}` + : ""; + const explicitNote = w.context?.explicit + ? "" + : `
    部分读入来自包内默认模板
    `; + const rationale = w.rationale + ? `
    为何需要${esc(w.rationale)}
    ` + : ""; + const merge = w.mergeConsidered + ? `
    合并考量${esc(w.mergeConsidered)}
    ` + : ""; + const reads = w.readsSummary + ? `
    上下文${esc(w.readsSummary)}
    ` + : ""; + const dim = + focusWorkerRef && !isFocusWorker + ? " ws-dimmed" + : focusContext + ? " ws-dimmed" + : ""; + const focusCls = isFocusWorker ? " ws-worker-focus" : ""; + + return `
    +
    + ${w.order} + ${esc(w.displayName)} + ${isFocusWorker ? `本次` : ""} + ${roleBadge} + ${acceptBadge} + ${statusBadge} +
    + ${idLine} +
    用来干嘛${esc(w.purpose)}
    +
    何时调用${esc(w.invokeWhen)}
    + ${rationale} + ${merge} + ${reads} + ${w.gapNote ? `
    ${esc(w.gapNote)}
    ` : ""} +
    + 写入黑板 + ${w.writes?.length ? w.writes.map((t) => `${esc(t)}`).join(" ") : "—"} +
    + ${explicitNote} +
    `; + }) + .join(""); + parts.push( + `

    游玩 Worker${compact ? "(摘要)" : ""}${focusWorkerRef ? " · 本次规格" : ""}

    ${cards}
    `, + ); + } + + if (!compact && userView.tagFlow?.length) { + const flow = userView.tagFlow + .map( + (edge) => + `
    ${edge.from.map((t) => `${esc(t)}`).join(" + ")}${edge.to.map((t) => `${esc(t)}`).join(" + ")}
    `, + ) + .join(""); + parts.push(`

    数据怎么流

    ${flow}
    `); + } + + if (userView.creationUnits?.length) { + const units = userView.creationUnits + .map((u) => { + const kindLabel = + u.kind === "worker" + ? "Worker" + : u.kind === "phase" + ? "阶段" + : u.flavor === "resident" + ? "常驻块" + : "固定上下文"; + const filled = u.accepted + ? "已验收" + : u.current + ? "进行中" + : u.filled + ? "已写" + : "待谈"; + const weight = u.weighty ? " · 纲领" : ""; + const cls = u.accepted + ? "ws-task-filled" + : u.current + ? "ws-task-planned ws-unit-current" + : "ws-task-planned"; + return `
  • ${esc(kindLabel)}${esc(weight)}${esc(u.label)}${filled}${u.detail ? ` — ${esc(u.detail)}` : ""}
  • `; + }) + .join(""); + parts.push( + `

    创作单位

    纲领与 Worker 同级;不必先写完 Worker 再填上下文。

      ${units}
    `, + ); + } + + if (userView.designTasks?.length) { + const tasks = userView.designTasks + .map((t) => { + const cls = + t.status === "filled" + ? "ws-task-filled" + : t.status === "skipped" + ? "ws-task-skipped" + : "ws-task-planned"; + const statusLabel = + t.status === "filled" ? "已填" : t.status === "skipped" ? "跳过" : "待填"; + return `
  • ${esc(t.label)}${esc(t.id)}${statusLabel}${t.skipReason ? ` — ${esc(t.skipReason)}` : ""}
  • `; + }) + .join(""); + parts.push( + `

    设计阶段还需准备

      ${tasks}
    `, + ); + } + + if (userView.openQuestions?.length) { + parts.push( + `

    待澄清

      ${userView.openQuestions.map((q) => `
    • ${esc(q)}
    • `).join("")}
    `, + ); + } + + if (userView.notes && !compact) { + parts.push(`

    补充说明

    ${esc(userView.notes)}

    `); + } + + return `
    ${parts.join("")}
    `; +} + +/** 尝试把正文美化为缩进 JSON;失败则原样 pre */ +function formatArtifactBodyHtml(body) { + const trimmed = (body || "").trim(); + if (!trimmed) { + return `

    (无正文)

    `; + } + + const tryParse = (text) => { + try { + return JSON.parse(text); + } catch { + return null; + } + }; + + let parsed = tryParse(trimmed); + if (!parsed) { + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start >= 0 && end > start) { + parsed = tryParse(trimmed.slice(start, end + 1)); + } + } + if (!parsed) { + const start = trimmed.indexOf("["); + const end = trimmed.lastIndexOf("]"); + if (start >= 0 && end > start) { + parsed = tryParse(trimmed.slice(start, end + 1)); + } + } + + if (parsed != null && typeof parsed === "object") { + const pretty = JSON.stringify(parsed, null, 2); + return `
    ${highlightJson(pretty)}
    `; + } + + return `
    ${esc(trimmed)}
    `; +} + +/** 极简 JSON 着色(仅 key / string / 其它) */ +function highlightJson(pretty) { + return pretty + .split("\n") + .map((line) => { + const m = /^(\s*)("(?:\\.|[^"\\])*")(\s*:\s*)(.*)$/.exec(line); + if (m) { + const [, indent, key, colon, rest] = m; + let valueHtml = esc(rest); + if (/^"/.test(rest.trim())) { + valueHtml = `${esc(rest)}`; + } else if (/^(true|false|null)\b/.test(rest.trim())) { + valueHtml = `${esc(rest)}`; + } else if (/^-?\d/.test(rest.trim())) { + valueHtml = `${esc(rest)}`; + } + return `${indent}${esc(key)}${esc(colon)}${valueHtml}`; + } + return esc(line); + }) + .join("\n"); +} + +function renderCreationFlowView(flowView) { + if (!flowView) return ""; + if (flowView.parseError) { + return ``; + } + if (!flowView.steps?.length) return ""; + + const brief = flowView.brief + ? `

    ${esc(flowView.brief)}

    ` + : ""; + const statusLabel = + flowView.status === "open" + ? "可继续追加" + : flowView.status === "closed" + ? "已收口" + : ""; + const statusHtml = statusLabel + ? `

    ${esc(statusLabel)}

    ` + : ""; + const rows = flowView.steps + .map((s) => { + const deps = + s.depends_on?.length > 0 + ? s.depends_on.map((d) => esc(d)).join("、") + : "无"; + const occ = + s.occurrence && s.occurrence > 1 + ? `第 ${esc(String(s.occurrence))} 次` + : s.repeatable + ? `可反复` + : ""; + const nameLabel = + s.id && s.id !== s.name + ? `${esc(s.name)} (${esc(s.id)})` + : esc(s.name); + return `
  • + ${esc(String(s.order))} + ${nameLabel}${occ} + 依赖:${deps} +
  • `; + }) + .join(""); + + return `
    +

    创作流程

    + ${brief} + ${statusHtml} +
      ${rows}
    +
    `; +} + +function renderReviewFeedCard(review) { + const flowHtml = review.creationFlowView + ? renderCreationFlowView(review.creationFlowView) + : ""; + const structured = !flowHtml && review.workerSetView + ? renderWorkerSetUserView(review.workerSetView) + : ""; + const jsonHtml = formatArtifactBodyHtml(review.body); + const contextId = review.sourceMessageId || review.id || ""; + const hasContext = review.contextTrace ? "1" : "0"; + const structuredBlock = flowHtml || structured; + + return ` +
    +
    +
    +
    + 待验收 + ${esc(displayWorkerLabel(review.workerId) || "产物")} +
    +
    + ${review.summary ? `

    ${esc(review.summary)}

    ` : ""} +

    接受后:本单位过程讨论会折叠,产物进入前情。确认请用底栏「接受目前产物」;要改则在底栏输入修改意见后发送。

    + ${structuredBlock ? `
    ${structuredBlock}
    ` : ""} +
    +

    ${structuredBlock ? "规格 JSON" : "产物正文"}

    + ${jsonHtml} +
    +
    +
    `; +} + +/** 侧栏验收 Tab 已弃用:验收改在主对话;此处仅隐藏 Tab */ +export function renderReviewPanel(view, _handlers = {}) { + const tabBtn = document.getElementById("agent-tab-btn-review"); + const panel = document.getElementById("review-artifact-panel"); + if (!tabBtn || !panel) return; + tabBtn.hidden = true; + panel.innerHTML = ""; + if (activeAgentTab === "review") setAgentTab("timeline"); +} + export function renderAgentPanel(view, loading) { const focusEl = document.getElementById("agent-focus"); const traceEl = document.getElementById("tool-trace"); @@ -177,6 +1066,7 @@ export function renderAgentPanel(view, loading) { } const kind = msg.kind ?? "system_info"; if (HIDE_KINDS.has(kind)) continue; + if (isSkillSelectionMessage(msg)) continue; items.push({ kind, title: msg.title ?? msgLabel(msg), @@ -185,7 +1075,13 @@ export function renderAgentPanel(view, loading) { }); } if (loading && f) { - items.push({ kind: "pending", title: f.action, body: f.detail ?? "", at: "" }); + const live = view.liveStream; + items.push({ + kind: "pending", + title: live?.label ?? f.action, + body: [live?.thinking, live?.output].filter(Boolean).join("\n").slice(0, 200) || (f.detail ?? ""), + at: "", + }); } if (!items.length) { timelineEl.innerHTML = `

    调度记录将出现在这里

    `; @@ -204,73 +1100,235 @@ export function renderAgentPanel(view, loading) { timelineEl.scrollTop = timelineEl.scrollHeight; } -export function renderMessageFeed(view, loading) { +export function renderMessageFeed(view, loading, handlers = {}) { const feed = document.getElementById("message-feed"); if (!feed) return; + if (feed.querySelector(".msg.is-editing")) { + return; + } + const intake = view.waitingReason?.kind === "intake" && view.intake?.fields?.length; - const selecting = view.waitingReason?.kind === "skill_selection"; + const showIntakePanel = intake && hasUserMessages(view); feed.innerHTML = ""; - if (intake) { + if (showIntakePanel) { const box = document.createElement("div"); box.className = "intake-panel"; box.innerHTML = renderIntakePanel(view.intake, { variant: "feed" }); feed.appendChild(box); } - const visible = (view.messages ?? []).filter((m) => { - if (m.role === "user") return true; - return !HIDE_KINDS.has(m.kind ?? ""); - }); + const visible = (view.messages ?? []).filter((m) => shouldShowInFeed(m, view)); - if (!visible.length && !loading && !intake) { - const p = document.createElement("p"); - p.className = "empty"; - if (selecting) { - p.textContent = "在上方选择 skill 包,或在下方输入 skill 名称。"; - } else if (view.lifecycleStage === "play") { - p.textContent = "游玩模式:Agent 将调度 run skill,推进世界与叙事。"; - } else { - p.textContent = "创作模式:与 Agent 对话,完成实例化后可切到游玩。"; + if (!visible.length && !loading) { + const reviewing = + view.waitingReason?.kind === "review_artifact" && view.reviewArtifact; + if (!reviewing) { + const p = document.createElement("p"); + p.className = "empty"; + if (view.lifecycleStage === "play") { + p.textContent = "游玩模式:Agent 将按 Worker 集调度,推进世界与叙事。"; + } else if (view.uiPrompt) { + const recipeLine = view.selectedRecipe?.name + ? `\n\n已选导演:${view.selectedRecipe.name}` + : view.recipes?.length + ? "\n\n(请先在新建作品时选定导演)" + : ""; + p.textContent = `${view.uiPrompt}${recipeLine}`; + p.classList.add("empty-intake"); + } else if (view.waitingReason?.kind === "worker_questions") { + p.textContent = "在下方回答 Skill 的提问。"; + } else if (intake) { + p.textContent = "在下方描述你想创作什么;Agent 会收成 Worker 集供你验收。"; + } else { + p.textContent = "在下方继续对话。"; + } + p.classList.add("empty-intake"); + feed.appendChild(p); + return; } - feed.appendChild(p); - return; } for (const msg of visible) { const isUser = msg.role === "user"; const kind = msg.kind ?? (isUser ? "user_input" : "system_info"); + const body = msgBody(msg, view); const card = document.createElement("article"); - card.className = `msg ${MSG_CLASS[kind] ?? "system"}`; + card.className = `msg ${MSG_CLASS[kind] ?? "system"}${isUser ? " msg-user-row" : " msg-assistant-row"}`; + card.dataset.messageId = msg.id; + + const label = msgLabel(msg); + const subtitle = (msg.title ?? "").trim(); + const showSubtitle = + !isUser && + kind !== "orchestrator_thinking" && + subtitle.length > 0 && + !label.includes(subtitle) && + subtitle !== label; + const headInner = isUser + ? `${fmtTime(msg.createdAt)}${renderMsgVariantBadge(msg)}` + : `
    + ${esc(label)} + ${showSubtitle ? `${esc(subtitle)}` : ""} +
    + ${renderMsgVariantBadge(msg)} + ${fmtTime(msg.createdAt)}`; + + card.dataset.canEdit = canEditMessage(msg) ? "1" : "0"; + card.dataset.hasContext = msg.contextTrace ? "1" : "0"; + card.dataset.originalText = body; + card.innerHTML = ` -
    - ${esc(msgLabel(msg))} - ${esc(msg.title ?? "")} - ${fmtTime(msg.createdAt)} -
    -
    ${esc(msgBody(msg, view))}
    `; +
    + ${!isUser && kind === "worker_questions" ? `
    需要你回答
    ` : ""} + ${isUser ? "" : `
    ${headInner}
    `} + ${!isUser && msg.thinking ? renderThinkingBlock(msg.thinking) : ""} +
    ${kind === "worker_questions" ? formatQuestionsHtml(body) : esc(body)}
    + ${isUser ? `
    ${headInner}
    ` : ""} +
    `; feed.appendChild(card); } + wireMessageFeedActions(feed, handlers); + wireMessageContextMenu(feed, handlers); + + if ( + view.waitingReason?.kind === "review_artifact" && + view.reviewArtifact && + !loading + ) { + const wrap = document.createElement("div"); + wrap.innerHTML = renderReviewFeedCard(view.reviewArtifact); + const card = wrap.firstElementChild; + if (card) feed.appendChild(card); + } + if (loading) { const pending = document.createElement("article"); - pending.className = "msg agent msg-pending"; + pending.className = "msg agent msg-assistant-row msg-pending"; + pending.id = "msg-live-pending"; + const live = view.liveStream; + const label = live?.label ?? view.focus?.action ?? "处理中"; pending.innerHTML = ` -
    进行中${esc(view.focus?.action ?? "处理中")}
    -
    `; +
    +
    ${esc(label)}流式输出中
    +
    ${renderLiveStreamBody(live)}
    +
    `; feed.appendChild(pending); } feed.scrollTop = feed.scrollHeight; } -export function renderWorkspace(view, loading, onPickSkill) { +/** 轮询时仅更新流式 pending 卡片,避免整页重绘 */ +export function updateLiveStreamPanel(view) { + const pending = document.getElementById("msg-live-pending"); + if (!pending) return; + const body = pending.querySelector(".msg-live-body"); + if (!body) return; + const live = view.liveStream; + const labelEl = pending.querySelector(".msg-tag"); + if (labelEl && live?.label) labelEl.textContent = live.label; + body.innerHTML = renderLiveStreamBody(live); + const feed = document.getElementById("message-feed"); + if (feed) feed.scrollTop = feed.scrollHeight; +} + +export function renderBoardPanel(view) { + const panel = document.getElementById("board-panel"); + if (!panel) return; + const board = view.boardPanel; + if (!board) { + panel.innerHTML = `

    尚无黑板条目。Worker 写入 tag 后会出现在这里。

    `; + return; + } + + const tagRow = (row, badge) => ` +
  • +
    + ${esc(row.tag)} + ${badge} + +
    +
    ${esc(row.source)} · ${esc(String(row.updatedAt).slice(0, 19))}
    +
    ${esc(row.preview)}
    +
  • `; + + const finals = (board.finals ?? []).map((r) => tagRow(r, "定稿")).join(""); + const active = (board.active ?? []).map((r) => tagRow(r, "进行中")).join(""); + + panel.innerHTML = ` +
    +

    定稿摘要(给下一 Worker)

    + ${ + board.brief + ? `
    ${esc(board.brief)}
    ` + : `

    验收产物后生成。下一 Worker 会自动读到此处。

    ` + } +
    +
    +

    终产物 tag ${board.finals?.length ? `(${board.finals.length})` : ""}

    + ${finals ? `
      ${finals}
    ` : `

    暂无

    `} +
    +
    +

    当前活跃 tag ${board.active?.length ? `(${board.active.length})` : ""}

    + ${active ? `
      ${active}
    ` : `

    暂无

    `} +
    + ${ + board.archivedCount + ? `

    已压缩归档 ${board.archivedCount} 个过程 tag(默认不进入后续 Worker 取数)

    ` + : "" + } + `; + + panel.querySelectorAll("[data-edit-tag]").forEach((btn) => { + btn.addEventListener("click", () => { + const tag = btn.getAttribute("data-edit-tag"); + if (!tag || !view.id) return; + const current = + [...(board.finals ?? []), ...(board.active ?? [])].find((r) => r.tag === tag) + ?.preview ?? ""; + const next = prompt(`编辑黑板「${tag}」\n(完整内容请从导出或后续增强编辑器查看;此处为预览级修改)`, current); + if (next == null) return; + void fetch(`/api/sessions/${encodeURIComponent(view.id)}/board`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tag, content: next }), + }) + .then(async (res) => { + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "写入失败"); + window.dispatchEvent(new CustomEvent("wa:session-updated", { detail: data })); + }) + .catch((err) => alert(err.message)); + }); + }); +} + +export function renderWorkspace(view, loading, _onPickSkill, handlers = {}) { renderLifecycle(view); - renderSkillPicker(view, onPickSkill); - renderMessageFeed(view, loading); + renderSkillPicker(view); + maybeAutoSwitchTab(view); + const qHost = document.getElementById("questions-card-host"); + if (qHost) { + renderQuestionsCard(qHost, view, { + onSkipQuestions: () => handlers.onSkipQuestions?.(), + }); + } + renderMessageFeed(view, loading, handlers); renderSkillGuide(view); + renderReviewPanel(view, handlers); + renderBoardPanel(view); renderAgentPanel(view, loading); } + +document.addEventListener("click", (e) => { + if (!e.target.closest("#msg-action-menu")) hideMsgMenu(); +}); +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") hideMsgMenu(); +}); +document.addEventListener("scroll", hideMsgMenu, true); diff --git a/web/app.js b/web/app.js index 960a8f2..c4b67b3 100644 --- a/web/app.js +++ b/web/app.js @@ -1,25 +1,45 @@ -import { renderWorkspace } from "./agent-ui.js"; +import { renderWorkspace, updateLiveStreamPanel } from "./agent-ui.js"; import { downloadMarkdown, sessionToMarkdown } from "./export.js"; import { renderIntakePanel } from "./intake-ui.js"; +import { displaySkillPackLabel } from "./display-labels.js"; +import { + clearQuestionCardState, + collectQuestionAnswers, + getActiveQuestions, + renderQuestionsCard, +} from "./questions-ui.js"; let sessionId = null; let activeBookId = null; let lastView = null; let books = []; let composerForceInput = false; +let sidebarNav = { level: "root" }; +let bookSelectMode = false; +let selectedBookIds = new Set(); +const playSavesByBook = new Map(); +const playSavesLoading = new Set(); +let bookMenuBookId = null; +let livePollTimer = null; + +const LIVE_POLL_MS = 280; const $ = (id) => document.getElementById(id); const PHASE = { idle: "待命", running: "执行中", waiting_user: "等待你", done: "已完成", error: "出错" }; const REASON = { - skill_selection: "选择 skill", - intake: "填写需求", - input: "补充说明", - worker_questions: "回答提问", + skill_selection: "选择能力包", + intake: "补充信息", + input: "等待输入", approve_step: "确认执行", review_artifact: "验收产物", + worker_questions: "回答提问", + revision: "按反馈修改", }; +/** @type {{ id: string, name: string, description: string }[]} */ +let directors = []; + async function api(path, options = {}) { const res = await fetch(path, { headers: { "Content-Type": "application/json" }, @@ -38,7 +58,7 @@ function statusFor(view, loading) { if (loading) return { cls: "running", text: "处理中…" }; if (view.phase === "done") return { cls: "done", text: "已完成" }; if (view.phase === "running" && !view.waitingReason) { - return { cls: "running", text: view.focus?.action ?? "Agent 运行中" }; + return { cls: "running", text: view.focus?.action ?? "总管运行中" }; } if (view.waitingReason) { return { cls: "waiting", text: REASON[view.waitingReason.kind] ?? view.waitingReason.kind }; @@ -53,46 +73,105 @@ function isAgentBusy(view, loading) { function resolveComposer(view, loading) { if (view.phase === "done") return { mode: "idle", text: "会话已结束" }; + if (view.phase === "error") { + const send = view.actions?.find((a) => a.type === "send_message"); + return { + mode: "input", + placeholder: send?.placeholder ?? "说明后重试,或直接发送继续…", + hint: view.hints?.[0] ?? "执行出错,可在下方重试", + }; + } if (isAgentBusy(view, loading)) { - return { mode: "waiting", text: view.focus?.action ?? "Agent 或 Skill 执行中…" }; + return { mode: "waiting", text: view.focus?.action ?? "总管或 Worker 执行中…" }; } const confirmIntake = view.actions?.find((a) => a.type === "confirm_intake"); if (view.waitingReason?.kind === "intake" && view.intake) { + const hasUser = (view.messages ?? []).some((m) => m.role === "user"); const send = view.actions?.find((a) => a.type === "send_message"); return { mode: view.intake.ready && confirmIntake ? "intake_ready" : "intake", intake: view.intake, - intakePrompt: view.intakePrompt, + intakePrompt: hasUser ? view.intakePrompt : null, + showIntakePanel: hasUser, confirmIntake, - placeholder: send?.placeholder ?? "补充信息…", + placeholder: send?.placeholder ?? "描述你想创作什么…", }; } const approve = view.actions?.find((a) => a.type === "approve"); const accept = view.actions?.find((a) => a.type === "accept"); - if ((approve || accept) && !composerForceInput) { + if (approve && !composerForceInput) { return { mode: "action", - primary: approve ?? accept, - primaryType: approve ? "approve" : "accept", - hint: approve ? view.focus?.detail : "验收 Skill 产出", + primary: approve, + primaryType: "approve", + hint: view.focus?.detail, + showReject: true, + }; + } + if (accept && view.waitingReason?.kind !== "review_artifact" && !composerForceInput) { + return { + mode: "action", + primary: accept, + primaryType: "accept", + hint: "验收产物", showReject: true, }; } const send = view.actions?.find((a) => a.type === "send_message"); + const hasQuestionsCard = + (view.waitingReason?.kind === "worker_questions" && + view.waitingReason?.questions?.length) || + (view.waitingReason?.kind === "input" && + view.waitingReason?.questions?.length) || + (view.waitingReason?.kind === "review_artifact" && + view.waitingReason?.questions?.length); + + if (view.waitingReason?.kind === "review_artifact") { + const parseBroken = Boolean(view.reviewArtifact?.workerSetView?.parseError); + return { + mode: "input", + placeholder: send?.placeholder ?? "输入修改意见后发送…", + hint: hasQuestionsCard + ? "接受产物即收起追问(表示无需再完善)" + : null, + acceptAction: accept + ? { + label: accept.label || "接受目前产物", + disabled: parseBroken, + disabledTitle: parseBroken + ? "规格解析失败,请先发送修改意见让 Worker 重写" + : undefined, + } + : null, + }; + } + + if (hasQuestionsCard && !composerForceInput) { + return { + mode: "input", + placeholder: send?.placeholder ?? "也可在此自由补充…", + hint: null, + }; + } + if ( send || view.waitingReason?.kind === "input" || - view.waitingReason?.kind === "skill_selection" || view.waitingReason?.kind === "worker_questions" || composerForceInput ) { - let hint = view.hints?.[0] ?? null; - if (view.waitingReason?.kind === "skill_selection") { - hint = "选择 skill 包,或输入名称 / 编号"; + const wr = view.waitingReason; + if (wr?.kind === "worker_questions" && !hasQuestionsCard) { + return { + mode: "input", + placeholder: "在此直接回答上面的问题…", + hint: null, + }; } + const hint = view.hints?.[0] ?? null; return { mode: "input", placeholder: send?.placeholder ?? "输入消息…", hint }; } @@ -119,13 +198,17 @@ function renderComposer(view, loading) { } if (spec.mode === "intake" || spec.mode === "intake_ready") { + const intakePanel = + spec.showIntakePanel + ? `
    ${renderIntakePanel(spec.intake, { variant: "composer" })}
    ` + : ""; const confirm = spec.mode === "intake_ready" && spec.confirmIntake ? `
    ` : ""; root.innerHTML = ` ${spec.intakePrompt ? `

    ${esc(spec.intakePrompt)}

    ` : ""} -
    ${renderIntakePanel(spec.intake, { variant: "composer" })}
    + ${intakePanel} ${confirm}
    @@ -156,13 +239,25 @@ function renderComposer(view, loading) { return; } + const acceptBtn = spec.acceptAction + ? `` + : ""; root.innerHTML = ` + ${spec.askBanner + ? `
    ${esc(spec.askBanner.title)}${esc(spec.askBanner.body)}
    ` + : ""} ${spec.hint ? `

    ${esc(spec.hint)}

    ` : ""} - + + ${acceptBtn}
    `; wireComposerForm(); + root.querySelector("[data-act=accept]")?.addEventListener("click", () => runAction("accept")); } function wireComposerForm() { @@ -181,40 +276,490 @@ function wireComposerForm() { input?.focus(); } +function bookPlayReady(bookId) { + return lastView?.bookId === bookId && Boolean(lastView?.playReady); +} + +async function fetchPlaySavesForBook(bookId, force = false) { + if (!force && (playSavesByBook.has(bookId) || playSavesLoading.has(bookId))) return; + playSavesLoading.add(bookId); + renderBookList(); + try { + const data = await api(`/api/books/${encodeURIComponent(bookId)}/saves`); + playSavesByBook.set(bookId, data.saves ?? []); + } catch { + playSavesByBook.set(bookId, []); + } finally { + playSavesLoading.delete(bookId); + renderBookList(); + } +} + +function setBookSelectMode(on, { keepSelection = false } = {}) { + bookSelectMode = on; + if (!on) selectedBookIds = new Set(); + else if (!keepSelection) selectedBookIds = new Set(); + renderBookList(); +} + +function toggleBookSelection(bookId) { + if (!bookSelectMode) setBookSelectMode(true, { keepSelection: true }); + if (selectedBookIds.has(bookId)) selectedBookIds.delete(bookId); + else selectedBookIds.add(bookId); + renderBookList(); +} + +function hideBookMenu() { + const menu = $("book-action-menu"); + if (menu) menu.hidden = true; + bookMenuBookId = null; +} + +function refreshBookMenuLabels() { + const n = selectedBookIds.size; + const multi = bookSelectMode && n > 0; + $("book-menu-open")?.toggleAttribute("hidden", multi); + $("book-menu-rename")?.toggleAttribute("hidden", n !== 1); + const dup = $("book-menu-duplicate"); + if (dup) dup.textContent = n > 1 ? `复制 ${n} 项备份` : "复制备份"; + const del = $("book-menu-delete"); + if (del) del.textContent = n > 1 ? `删除 ${n} 项` : "删除"; + const sel = $("book-menu-select"); + if (sel) sel.textContent = bookSelectMode ? "取消多选" : "多选"; +} + +function showBookMenu(bookId, x, y) { + const menu = $("book-action-menu"); + if (!menu) return; + bookMenuBookId = bookId; + refreshBookMenuLabels(); + menu.hidden = false; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + menu.style.left = `${Math.max(4, window.innerWidth - rect.width - 4)}px`; + } + if (rect.bottom > window.innerHeight) { + menu.style.top = `${Math.max(4, window.innerHeight - rect.height - 4)}px`; + } +} + +function getNavBook() { + if (sidebarNav.level !== "book") return null; + return books.find((b) => b.id === sidebarNav.bookId) ?? null; +} + +function navigateToRoot() { + sidebarNav = { level: "root" }; + renderPathBar(); + renderBookList(); +} + +function navigateToBook(bookId) { + sidebarNav = { level: "book", bookId }; + renderPathBar(); + renderBookList(); + void fetchPlaySavesForBook(bookId); +} + +async function renameBookById(bookId) { + const book = books.find((b) => b.id === bookId); + if (!book) return; + const title = prompt("作品名称", book.title); + if (title == null) return; + const trimmed = title.trim(); + if (!trimmed) return alert("名称不能为空"); + try { + const data = await api(`/api/books/${encodeURIComponent(bookId)}`, { + method: "PUT", + body: JSON.stringify({ title: trimmed }), + }); + const i = books.findIndex((b) => b.id === bookId); + if (i >= 0 && data.book) books[i] = { ...books[i], ...data.book }; + if (activeBookId === bookId && lastView) { + renderSession({ ...lastView, bookTitle: trimmed }, false); + } else { + renderBookList(); + } + } catch (err) { + alert(err.message); + } +} + +async function duplicateBookById(bookId, title) { + try { + const data = await api(`/api/books/${encodeURIComponent(bookId)}/duplicate`, { + method: "POST", + body: JSON.stringify(title ? { title } : {}), + }); + if (data.book) { + books.unshift({ + id: data.book.id, + title: data.book.title, + activeSkillId: data.book.activeSkillId ?? data.book.orchestratorId, + activeSkillName: data.book.activeSkillName ?? data.book.orchestratorName, + preview: data.book.preview, + updatedAt: data.book.updatedAt, + orchestratorId: data.book.orchestratorId, + orchestratorName: data.book.orchestratorName, + }); + renderBookList(); + } + return data.book; + } catch (err) { + alert(err.message); + return null; + } +} + +async function duplicateSelectedBooks() { + const ids = bookSelectMode || selectedBookIds.size ? [...selectedBookIds] : []; + if (!ids.length) return; + for (const id of ids) { + await duplicateBookById(id); + } + setBookSelectMode(false); +} + +async function deleteSelectedBooks() { + const ids = [...selectedBookIds]; + if (!ids.length) return; + const label = + ids.length === 1 + ? `「${books.find((b) => b.id === ids[0])?.title ?? "该作品"}」` + : `${ids.length} 个作品`; + if (!confirm(`删除 ${label}?不可恢复。`)) return; + try { + const data = await api("/api/books/batch", { + method: "DELETE", + body: JSON.stringify({ ids }), + }); + const deleted = new Set(data.deleted ?? ids); + books = books.filter((b) => !deleted.has(b.id)); + setBookSelectMode(false); + if (activeBookId && deleted.has(activeBookId)) { + if (books.length) await openBook(books[0].id); + else renderEmpty(); + } else { + renderBookList(); + } + } catch (err) { + alert(err.message); + } +} + +function rectsIntersect(a, b) { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +} + +function setupBookBoxSelect() { + const list = $("book-list"); + const overlay = $("book-box-overlay"); + if (!list || !overlay) return; + + let dragging = false; + let start = null; + + const clearDrag = () => { + dragging = false; + start = null; + overlay.hidden = true; + }; + + list.addEventListener("mousedown", (e) => { + if (sidebarNav.level !== "root" || e.button !== 0) return; + if (e.target.closest("#book-action-menu")) return; + dragging = true; + start = { x: e.clientX, y: e.clientY }; + overlay.hidden = true; + + const onMove = (ev) => { + if (!dragging || !start) return; + const dx = Math.abs(ev.clientX - start.x); + const dy = Math.abs(ev.clientY - start.y); + if (dx < 6 && dy < 6) return; + if (!bookSelectMode) setBookSelectMode(true, { keepSelection: true }); + const left = Math.min(start.x, ev.clientX); + const top = Math.min(start.y, ev.clientY); + const width = Math.abs(ev.clientX - start.x); + const height = Math.abs(ev.clientY - start.y); + overlay.hidden = false; + overlay.style.left = `${left}px`; + overlay.style.top = `${top}px`; + overlay.style.width = `${width}px`; + overlay.style.height = `${height}px`; + }; + + const onUp = (ev) => { + if (!dragging || !start) return; + const dx = Math.abs(ev.clientX - start.x); + const dy = Math.abs(ev.clientY - start.y); + if (dx >= 6 || dy >= 6) { + const box = { + left: Math.min(start.x, ev.clientX), + right: Math.max(start.x, ev.clientX), + top: Math.min(start.y, ev.clientY), + bottom: Math.max(start.y, ev.clientY), + }; + for (const row of list.querySelectorAll("[data-book-id]")) { + const rect = row.getBoundingClientRect(); + if (rectsIntersect(box, rect)) selectedBookIds.add(row.dataset.bookId); + } + renderBookList(); + } + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + clearDrag(); + }; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }); +} + +function renderPathBar() { + const bar = $("book-path-bar"); + if (!bar) return; + bar.innerHTML = ""; + bar.hidden = !books.length; + + const rootBtn = document.createElement("button"); + rootBtn.type = "button"; + rootBtn.className = "path-seg"; + rootBtn.dataset.nav = "root"; + rootBtn.innerHTML = `作品`; + if (sidebarNav.level === "root") rootBtn.classList.add("current"); + rootBtn.addEventListener("click", () => { + if (sidebarNav.level !== "root") navigateToRoot(); + }); + bar.appendChild(rootBtn); + + if (sidebarNav.level === "book") { + const book = getNavBook(); + if (!book) return; + const sep = document.createElement("span"); + sep.className = "path-sep"; + sep.setAttribute("aria-hidden", "true"); + sep.textContent = "›"; + bar.appendChild(sep); + + const bookSeg = document.createElement("span"); + bookSeg.className = "path-seg current"; + bookSeg.textContent = book.title; + bar.appendChild(bookSeg); + } +} + +function renderExplorerRow({ name, meta, active, selected, bookId, actionClass, onClick, onDelete }) { + const row = document.createElement("button"); + row.type = "button"; + row.className = ["explorer-row", actionClass, active ? "active" : "", selected ? "selected" : ""] + .filter(Boolean) + .join(" "); + if (bookId) row.dataset.bookId = bookId; + row.innerHTML = ` + ${esc(name)} + ${meta ? `${esc(meta)}` : ""} + ${onDelete ? `×` : ""}`; + row.addEventListener("click", (e) => { + if (e.target.closest(".explorer-del")) return; + onClick?.(e); + }); + row.querySelector(".explorer-del")?.addEventListener("click", (e) => { + e.stopPropagation(); + onDelete?.(); + }); + if (bookId) { + row.addEventListener("contextmenu", (e) => { + e.preventDefault(); + if (bookSelectMode && !selectedBookIds.has(bookId)) { + selectedBookIds.add(bookId); + renderBookList(); + } + showBookMenu(bookId, e.clientX, e.clientY); + }); + } + return row; +} + +function renderBookContents(book, list) { + const loading = playSavesLoading.has(book.id); + const saves = playSavesByBook.get(book.id); + const ready = bookPlayReady(book.id); + + list.appendChild( + renderExplorerRow({ + name: "继续创作", + meta: "自动保存", + active: activeBookId === book.id && lastView?.lifecycleStage !== "play", + onClick: () => { + void openBook(book.id).then(() => navigateToRoot()); + }, + }), + ); + + if (loading && saves === undefined) { + const hint = document.createElement("p"); + hint.className = "explorer-hint"; + hint.textContent = "加载存档…"; + list.appendChild(hint); + return; + } + + const saveList = saves ?? []; + if (!saveList.length && !ready) { + const hint = document.createElement("p"); + hint.className = "explorer-hint"; + hint.textContent = "验收 Worker 集后可保存定稿 / 游玩存档"; + list.appendChild(hint); + return; + } + + if (saveList.length) { + const label = document.createElement("div"); + label.className = "explorer-section-label"; + label.textContent = "存档"; + list.appendChild(label); + } + + for (const s of saveList) { + const kindLabel = + s.kindLabel || + (s.kind === "instance" ? "创作定稿" : s.kind === "opening" ? "开局" : "游玩进度"); + list.appendChild( + renderExplorerRow({ + name: s.label, + meta: `${kindLabel} · ${new Date(s.createdAt).toLocaleString("zh-CN")}`, + onClick: () => void loadPlaySave(book.id, s.id), + onDelete: () => void deletePlaySave(book.id, s.id, s.label), + actionClass: "explorer-file", + }), + ); + } + + if (ready) { + list.appendChild( + renderExplorerRow({ + name: "+ 新建游玩", + actionClass: "explorer-action", + onClick: () => void startNewPlayForBook(book.id), + }), + ); + } else if (!saveList.length) { + const hint = document.createElement("p"); + hint.className = "explorer-hint"; + hint.textContent = "暂无游玩存档"; + list.appendChild(hint); + } +} + function renderBookList() { const list = $("book-list"); if (!list) return; + renderPathBar(); if (!books.length) { list.innerHTML = ``; $("btn-new-inline")?.addEventListener("click", openNewBookDialog); return; } list.innerHTML = ""; + + if (sidebarNav.level === "book") { + const book = getNavBook(); + if (!book) { + navigateToRoot(); + return; + } + renderBookContents(book, list); + return; + } + for (const book of books) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = `book-item${book.id === activeBookId ? " active" : ""}`; - const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "未选 skill"; - btn.innerHTML = ` -
    ${esc(book.title)}
    -
    ${esc(skill)} · ${esc(book.preview?.slice(0, 40) || "")}
    `; - btn.addEventListener("click", () => openBook(book.id)); - list.appendChild(btn); + const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "实例设计"; + const selected = selectedBookIds.has(book.id); + list.appendChild( + renderExplorerRow({ + name: book.title, + meta: skill, + active: book.id === activeBookId && !selected, + selected, + bookId: book.id, + actionClass: "explorer-folder", + onClick: () => { + if (bookSelectMode) { + toggleBookSelection(book.id); + return; + } + navigateToBook(book.id); + void openBook(book.id); + }, + }), + ); + } +} + +async function loadPlaySave(bookId, saveId) { + try { + if (lastView) renderSession(lastView, true); + const data = await api( + `/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}/load`, + { method: "POST" }, + ); + sidebarNav = { level: "root" }; + renderSession(data.session, false); + } catch (err) { + if (lastView) renderSession(lastView, false); + alert(err.message); + } +} + +async function deletePlaySave(bookId, saveId, label) { + if (!confirm(`删除游玩存档「${label}」?`)) return; + try { + await api( + `/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}`, + { method: "DELETE" }, + ); + await fetchPlaySavesForBook(bookId, true); + } catch (err) { + alert(err.message); + } +} + +async function startNewPlayForBook(bookId) { + if (!bookPlayReady(bookId)) { + alert("须先验收 Worker 集"); + return; + } + try { + if (lastView) renderSession(lastView, true); + const data = await api(`/api/books/${encodeURIComponent(bookId)}/play/new`, { method: "POST" }); + sidebarNav = { level: "root" }; + renderSession(data.session, false); + } catch (err) { + if (lastView) renderSession(lastView, false); + alert(err.message); } } function renderHeader(view, loading) { const st = statusFor(view, loading); $("work-title").textContent = view.bookTitle ?? "未命名作品"; - const skill = view.activeSkill ?? "未选 skill"; + const skill = displaySkillPackLabel(view.activeSkill) || + view.selectedRecipe?.name || + "导演"; const phase = view.waitingReason ? REASON[view.waitingReason.kind] ?? view.phase : PHASE[view.phase] ?? view.phase; $("work-meta").textContent = `${skill} · ${phase}`; $("status-dot").className = `status-dot ${st.cls}`; $("status-text").textContent = st.text; - $("btn-delete").hidden = !view.bookId; - $("btn-saves").hidden = !view.bookId; + const showSavePlay = Boolean(view.bookId && view.playReady && view.lifecycleStage === "play"); + const showSaveInstance = Boolean(view.bookId && view.playReady); + $("btn-save-play").hidden = !showSavePlay; + const btnInst = $("btn-save-instance"); + if (btnInst) btnInst.hidden = !showSaveInstance; $("btn-export").disabled = !(view.messages?.length); } @@ -222,41 +767,157 @@ function renderEmpty() { sessionId = null; lastView = null; activeBookId = null; + sidebarNav = { level: "root" }; + setBookSelectMode(false); composerForceInput = false; $("work-title").textContent = "未打开作品"; $("work-meta").textContent = ""; $("status-dot").className = "status-dot"; $("status-text").textContent = "—"; - $("btn-delete").hidden = true; - $("btn-saves").hidden = true; + $("btn-save-play").hidden = true; + const btnInst = $("btn-save-instance"); + if (btnInst) btnInst.hidden = true; $("btn-export").disabled = true; $("message-feed").innerHTML = `

    点击左侧 + 新建作品

    `; $("skill-picker").hidden = true; - $("skill-guide").hidden = true; $("agent-focus").innerHTML = ""; $("agent-timeline").innerHTML = ""; + $("skill-guide-list").innerHTML = ""; + const wsp = $("worker-set-user-panel"); + if (wsp) wsp.innerHTML = ""; $("tool-trace").hidden = true; + hideBookMenu(); document.body.dataset.lifecycle = "design"; $("composer").innerHTML = `
    暂无打开的作品
    `; renderBookList(); } +function stopLivePoll() { + if (livePollTimer != null) { + clearInterval(livePollTimer); + livePollTimer = null; + } +} + +function startLivePoll() { + stopLivePoll(); + if (!sessionId) return; + livePollTimer = setInterval(async () => { + if (!sessionId) return; + try { + const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}`); + lastView = { ...lastView, liveStream: view.liveStream, agentThinking: view.agentThinking }; + updateLiveStreamPanel(view); + } catch { + /* ignore transient poll errors */ + } + }, LIVE_POLL_MS); +} + function renderSession(view, loading = false) { lastView = view; sessionId = view.id; activeBookId = view.bookId ?? activeBookId; - if (!loading && !["approve_step", "review_artifact"].includes(view.waitingReason?.kind)) { + if ( + !loading && + view.waitingReason?.kind !== "approve_step" && + view.waitingReason?.kind !== "review_artifact" && + view.waitingReason?.kind !== "worker_questions" && + !(view.waitingReason?.kind === "input" && view.waitingReason?.questions?.length) + ) { composerForceInput = false; } renderHeader(view, loading); renderBookList(); - renderWorkspace(view, loading, (skillId) => sendText(skillId)); + renderWorkspace(view, loading, (skillId) => sendText(skillId), { + onSkipQuestions: () => runAction("skip_questions"), + onEditMessage: (messageId, text) => messageAction("edit", messageId, { text }), + onRefreshMessage: (messageId) => messageAction("refresh", messageId), + onDeleteMessage: (messageId) => messageAction("delete", messageId), + onViewContext: (messageId) => showContextTraceDialog(messageId), + onSwitchVariant: (messageId, direction) => + messageAction("variant", messageId, { direction }), + }); renderComposer(view, loading); + if (loading) startLivePoll(); + else stopLivePoll(); +} + +async function messageAction(kind, messageId, body = {}) { + if (!sessionId) return; + try { + renderSession(lastView, true); + const view = await api( + `/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/${kind}`, + { + method: "POST", + body: JSON.stringify(body), + }, + ); + renderSession(view, false); + } catch (err) { + if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); + } } async function sendText(text) { - const trimmed = text?.trim(); - if (!trimmed || !sessionId) return; + if (!sessionId) return; + const trimmed = text?.trim() ?? ""; + const qHost = $("questions-card-host"); + const activeQs = getActiveQuestions(lastView); + const reviewing = lastView?.waitingReason?.kind === "review_artifact"; + + // 产物验收:底栏有字 ⇒ 按修改意见重做;接受走「接受目前产物」 + if (reviewing && trimmed) { + try { + renderSession(lastView, true); + const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { + method: "POST", + body: JSON.stringify({ text: trimmed }), + }); + clearQuestionCardState(qHost); + renderSession(view, false); + } catch (err) { + if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); + } + return; + } + + if (activeQs?.questions?.length && qHost?.classList.contains("is-open")) { + const collected = collectQuestionAnswers(qHost, lastView); + const answered = collected.ok + ? collected.answers.filter((a) => a.text && a.text !== "(未答)") + : []; + // 可选追问:未选任何选项时,允许直接发自由文本(或空操作交给下方逻辑) + if (!collected.ok && !activeQs.optional) { + if (typeof collected.page === "number" && qHost._qState) { + qHost._qState.page = collected.page; + renderQuestionsCard(qHost, lastView, { + onSkipQuestions: () => runAction("skip_questions"), + }); + } + alert(collected.error); + return; + } + if (collected.ok && answered.length) { + try { + renderSession(lastView, true); + const body = { answers: collected.answers }; + if (trimmed) body.note = trimmed; + const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/answers`, { + method: "POST", + body: JSON.stringify(body), + }); + clearQuestionCardState(qHost); + renderSession(view, false); + } catch (err) { + if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); + } + return; + } + } + + if (!trimmed) return; try { renderSession(lastView, true); const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { @@ -277,6 +938,10 @@ async function runAction(action) { method: "POST", body: JSON.stringify({ action }), }); + // 接受产物 = 不再完善 → 收起询问卡;跳过追问同理 + if (action === "accept" || action === "skip_questions") { + clearQuestionCardState($("questions-card-host")); + } renderSession(view, false); } catch (err) { if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); @@ -285,7 +950,10 @@ async function runAction(action) { async function setLifecycle(stage) { if (!sessionId || stage === lastView?.lifecycleStage) return; - if (stage === "play" && !lastView?.playReady) return; + if (stage === "play" && !lastView?.playReady) { + alert("请先验收 Worker 集(创作定稿)后再进入游玩"); + return; + } try { const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/lifecycle`, { method: "POST", @@ -293,7 +961,8 @@ async function setLifecycle(stage) { }); renderSession(view, false); } catch (err) { - if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); + alert(err.message || "无法切换阶段"); + if (lastView) renderSession(lastView, false); } } @@ -305,21 +974,78 @@ async function loadBooks() { function openNewBookDialog() { $("input-book-title").value = ""; + void populateDirectorSelect(); $("dialog-new-book").showModal(); $("input-book-title").focus(); } +async function populateDirectorSelect() { + const sel = $("select-director"); + const desc = $("director-desc"); + if (!sel) return; + try { + const data = await api("/api/directors"); + const directors = data.directors ?? []; + sel.innerHTML = ""; + if (!directors.length) { + sel.innerHTML = ``; + if (desc) { + desc.hidden = false; + desc.textContent = "尚未配置导演选项(recipes/catalog.yaml)。"; + } + return; + } + for (const d of directors) { + const opt = document.createElement("option"); + opt.value = d.id; + opt.textContent = displaySkillPackLabel(d.id) || d.name || d.id; + sel.appendChild(opt); + } + const preferred = + directors.find((d) => d.id === "world-simulator") ?? directors[0]; + sel.value = preferred.id; + const syncDesc = () => { + const cur = directors.find((d) => d.id === sel.value); + if (desc) { + const text = (cur?.declaration ?? "").trim(); + desc.hidden = !text; + desc.textContent = text; + } + }; + sel.onchange = syncDesc; + syncDesc(); + } catch (err) { + sel.innerHTML = ``; + if (desc) { + desc.hidden = false; + desc.textContent = err.message; + } + } +} + async function createBook() { const title = $("input-book-title").value.trim() || "未命名作品"; + const recipeId = $("select-director")?.value?.trim(); + if (!recipeId) { + alert("请选择导演"); + return; + } $("btn-create-book").disabled = true; try { + const directorsMeta = await api("/api/directors").catch(() => null); + const orchestratorId = directorsMeta?.skillPackId || "world-simulator"; const data = await api("/api/books", { method: "POST", - body: JSON.stringify({ title }), + body: JSON.stringify({ + title, + orchestratorId, + recipeId, + }), }); $("dialog-new-book").close(); if (data.book) books.unshift(data.book); activeBookId = data.book?.id; + navigateToBook(data.book.id); renderSession(data.session, false); } catch (err) { alert(err.message); @@ -344,91 +1070,148 @@ async function openBook(bookId) { } } -async function deleteBook() { - if (!activeBookId || !lastView?.bookTitle) return; - if (!confirm(`删除「${lastView.bookTitle}」?不可恢复。`)) return; +async function deleteBookById(bookId) { + const book = books.find((b) => b.id === bookId); + if (!book) return; + if (!confirm(`删除「${book.title}」?不可恢复。`)) return; try { - await api(`/api/books/${encodeURIComponent(activeBookId)}`, { method: "DELETE" }); - books = books.filter((b) => b.id !== activeBookId); - if (books.length) await openBook(books[0].id); - else renderEmpty(); + await api(`/api/books/${encodeURIComponent(bookId)}`, { method: "DELETE" }); + books = books.filter((b) => b.id !== bookId); + playSavesByBook.delete(bookId); + selectedBookIds.delete(bookId); + if (sidebarNav.level === "book" && sidebarNav.bookId === bookId) sidebarNav = { level: "root" }; + hideBookMenu(); + if (activeBookId === bookId) { + if (books.length) { + await openBook(books[0].id); + } else { + renderEmpty(); + } + } else { + renderBookList(); + } } catch (err) { alert(err.message); } } +async function saveCurrentPlay() { + if (!activeBookId || !lastView?.playReady) return alert("须先验收 Worker 集"); + if (lastView.lifecycleStage !== "play") return alert("请先切换到「游玩」"); + const label = prompt("游玩进度存档名称"); + if (!label?.trim()) return; + try { + await api(`/api/books/${encodeURIComponent(activeBookId)}/saves`, { + method: "POST", + body: JSON.stringify({ label: label.trim(), sessionId, kind: "run" }), + }); + playSavesByBook.delete(activeBookId); + if (sidebarNav.level === "book" && sidebarNav.bookId === activeBookId) { + void fetchPlaySavesForBook(activeBookId, true); + } + } catch (err) { + alert(err.message); + } +} + +async function saveCurrentInstance() { + if (!activeBookId || !lastView?.playReady) return alert("须先验收 Worker 集"); + const label = prompt("创作定稿存档名称"); + if (!label?.trim()) return; + try { + await api(`/api/books/${encodeURIComponent(activeBookId)}/saves`, { + method: "POST", + body: JSON.stringify({ label: label.trim(), sessionId, kind: "instance" }), + }); + playSavesByBook.delete(activeBookId); + if (sidebarNav.level === "book" && sidebarNav.bookId === activeBookId) { + void fetchPlaySavesForBook(activeBookId, true); + } + } catch (err) { + alert(err.message); + } +} + +function showContextTraceDialog(messageId) { + const msg = (lastView?.messages ?? []).find((m) => m.id === messageId); + const review = lastView?.reviewArtifact; + const trace = + msg?.contextTrace || + (review && + (review.sourceMessageId === messageId || review.id === messageId) + ? review.contextTrace + : undefined); + const dlg = $("dialog-context-trace"); + const meta = $("context-trace-meta"); + const body = $("context-trace-body"); + if (!dlg || !meta || !body) return; + if (!trace?.messages?.length) { + alert("这条消息没有保存请求上下文(可能已被修剪,或当时未开启保存)"); + return; + } + const kb = ((trace.charCount || 0) / 1024).toFixed(1); + meta.textContent = `${trace.caller || "unknown"} · ${trace.model || "model?"} · ${kb} KB · ${trace.createdAt || ""}`; + body.textContent = trace.messages + .map((m) => `======== ${m.role} ========\n${m.content}`) + .join("\n\n"); + dlg.showModal(); +} + +$("btn-copy-context")?.addEventListener("click", async () => { + const body = $("context-trace-body")?.textContent ?? ""; + try { + await navigator.clipboard.writeText(body); + } catch { + alert("复制失败"); + } +}); + function exportSession() { if (!lastView) return; const name = (lastView.bookTitle ?? "session").replace(/[\\/:*?"<>|]/g, "_"); downloadMarkdown(`${name}.md`, sessionToMarkdown(lastView)); } -async function refreshSaves() { - if (!activeBookId) return; - const data = await api(`/api/books/${encodeURIComponent(activeBookId)}/saves`); - const list = $("saves-list"); - const saves = data.saves ?? []; - if (!saves.length) { - list.innerHTML = `

    暂无存档

    `; - return; - } - list.innerHTML = saves - .map( - (s) => ` -
    - ${esc(s.label)} ${esc(s.kind)} - - - - -
    `, - ) - .join(""); - list.querySelectorAll("[data-load]").forEach((btn) => { - btn.addEventListener("click", async () => { - try { - const data = await api( - `/api/books/${encodeURIComponent(activeBookId)}/saves/${encodeURIComponent(btn.getAttribute("data-load"))}/load`, - { method: "POST" }, - ); - $("dialog-saves").close(); - renderSession(data.session, false); - } catch (err) { - alert(err.message); - } - }); - }); - list.querySelectorAll("[data-del]").forEach((btn) => { - btn.addEventListener("click", async () => { - if (!confirm("删除此存档?")) return; - await api( - `/api/books/${encodeURIComponent(activeBookId)}/saves/${encodeURIComponent(btn.getAttribute("data-del"))}`, - { method: "DELETE" }, - ); - refreshSaves(); - }); - }); -} - -async function createSave() { - const label = $("input-save-label").value.trim(); - const kind = document.querySelector('input[name="save-kind"]:checked')?.value ?? "run"; - if (!label) return alert("请输入名称"); - try { - await api(`/api/books/${encodeURIComponent(activeBookId)}/saves`, { - method: "POST", - body: JSON.stringify({ label, kind, sessionId }), - }); - $("input-save-label").value = ""; - refreshSaves(); - } catch (err) { - alert(err.message); - } -} - $("btn-new-book")?.addEventListener("click", openNewBookDialog); -$("btn-new-inline")?.addEventListener("click", openNewBookDialog); $("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close()); +$("book-action-menu")?.addEventListener("click", (e) => { + const action = e.target.closest("[data-action]")?.getAttribute("data-action"); + const bookId = bookMenuBookId; + hideBookMenu(); + if (!bookId || !action) return; + if (action === "open") { + setBookSelectMode(false); + navigateToBook(bookId); + void openBook(bookId); + } else if (action === "rename") { + const id = selectedBookIds.size === 1 ? [...selectedBookIds][0] : bookId; + void renameBookById(id); + } else if (action === "duplicate") { + if (selectedBookIds.size > 1) void duplicateSelectedBooks(); + else void duplicateBookById(bookId).then(() => setBookSelectMode(false)); + } else if (action === "select-mode") { + if (bookSelectMode) { + setBookSelectMode(false); + } else { + setBookSelectMode(true, { keepSelection: true }); + selectedBookIds.add(bookId); + renderBookList(); + } + } else if (action === "delete") { + if (selectedBookIds.size > 1) void deleteSelectedBooks(); + else void deleteBookById(bookId); + } +}); +document.addEventListener("click", (e) => { + if (!e.target.closest("#book-action-menu")) hideBookMenu(); +}); +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + hideBookMenu(); + if (bookSelectMode || selectedBookIds.size) setBookSelectMode(false); + } +}); +document.addEventListener("scroll", hideBookMenu, true); $("form-new-book")?.addEventListener("submit", (e) => { e.preventDefault(); createBook(); @@ -437,23 +1220,29 @@ $("lifecycle-toggle")?.addEventListener("click", (e) => { const btn = e.target.closest("[data-stage]"); if (!btn?.disabled) setLifecycle(btn.getAttribute("data-stage")); }); -$("btn-delete")?.addEventListener("click", deleteBook); $("btn-export")?.addEventListener("click", exportSession); -$("btn-saves")?.addEventListener("click", () => { - $("dialog-saves").showModal(); - refreshSaves(); +$("btn-save-play")?.addEventListener("click", () => void saveCurrentPlay()); +$("btn-save-instance")?.addEventListener("click", () => void saveCurrentInstance()); + +window.addEventListener("wa:session-updated", (e) => { + const view = e.detail; + if (view?.id) renderSession(view, false); }); -$("btn-close-saves")?.addEventListener("click", () => $("dialog-saves").close()); -$("btn-save-create")?.addEventListener("click", createSave); async function init() { try { + void populateDirectorSelect(); await loadBooks(); - if (books.length) await openBook(books[0].id); - else renderEmpty(); + if (books.length) { + sidebarNav = { level: "root" }; + await openBook(books[0].id); + } else { + renderEmpty(); + } } catch { $("status-text").textContent = "加载失败"; } } init(); +setupBookBoxSelect(); diff --git a/web/display-labels.js b/web/display-labels.js new file mode 100644 index 0000000..bc68818 --- /dev/null +++ b/web/display-labels.js @@ -0,0 +1,94 @@ +/** + * 与 docs/ui-glossary.md、src/server/display-labels.ts 保持同步。 + * 用户侧拍摄术语:导演 / 剧本 / 演员 / 能力。 + */ + +const STAGE_LABELS = { + design: "创作", + play: "游玩", + done: "已完成", + idle: "待命", + running: "执行中", + waiting_user: "等待你", + error: "出错", +}; + +const WORKER_LABELS = { + "design-core": "创作 · 核心", + "design-worker": "创作 · 演员规格", + "design-fixed": "创作 · 固定上下文", + "design-refine": "创作 · 细化与终稿", + "design-intake": "创作 · 综合收口", + "design-flow": "创作 · 流程编排", + "design-step": "创作 · 执行步骤", + "opening-generator": "开局 · 开场白", + orchestrator: "导演", + "agent-burst": "导演调度", + narrator: "叙事转述", + "role-decide": "角色决策", + "world-simulator": "世界推演", + "round-present": "回合呈现", + outline: "大纲 / 细纲", + "chapter-writer": "章节正文", +}; + +const FIXED_TOPIC_LABELS = { + "aesthetics-interaction": "美学纲领与交互范式", + interaction: "交互范式", + narrative_guide: "叙事指南", + input_protocol: "输入协议", + core_premise: "核心前提", + aesthetics: "美学纲领", +}; + +const PHASE_UNIT_LABELS = { + core: "核心", + refine: "细化", +}; + +export function displayStageLabel(id) { + if (!id) return ""; + return STAGE_LABELS[id] ?? id; +} + +export function displaySkillPackLabel(id) { + if (!id) return ""; + const map = { + "world-simulator": "世界模拟器", + "expand-assistant": "扩写助手", + }; + return map[id] ?? id; +} + +export function displayWorkerLabel(id) { + if (!id) return ""; + const trimmed = String(id).trim(); + if (!trimmed) return ""; + if (WORKER_LABELS[trimmed]) return WORKER_LABELS[trimmed]; + + if (trimmed.startsWith("phase:")) { + const key = trimmed.slice("phase:".length); + return `单位 · ${PHASE_UNIT_LABELS[key] ?? key}`; + } + if (trimmed.startsWith("worker:")) { + const ref = trimmed.slice("worker:".length); + return `演员 · ${displayWorkerLabel(ref)}`; + } + if (trimmed.startsWith("fixed:")) { + const topic = trimmed.slice("fixed:".length); + return `能力 · ${FIXED_TOPIC_LABELS[topic] ?? topic}`; + } + if (trimmed.startsWith("resident:")) { + return `常驻 · ${trimmed.slice("resident:".length)}`; + } + return trimmed; +} + +export function formatWorkerDisplayTitle(workerId, action = null) { + const base = displayWorkerLabel(workerId) || "演员"; + if (action === "output") return `${base} · 产出`; + if (action === "questions") return `${base} · 提问`; + if (action === "stub") return `${base} · 占位`; + if (action === "running") return `${base} · 执行中`; + return base; +} diff --git a/web/export.js b/web/export.js index ce2fb8c..bcf2617 100644 --- a/web/export.js +++ b/web/export.js @@ -13,8 +13,8 @@ function formatExportTime(iso) { const KIND_LABELS = { user_input: "用户输入", - orchestrator_decision: "总管决策", - orchestrator_prompt: "总管询问", + orchestrator_decision: "导演决策", + orchestrator_prompt: "导演询问", agent_tool: "Agent Tool", worker_running: "Worker 执行", worker_output: "Worker 产出", diff --git a/web/index.html b/web/index.html index 5965df6..35f0e37 100644 --- a/web/index.html +++ b/web/index.html @@ -11,9 +11,13 @@
    @@ -29,10 +33,12 @@
    + 能力 + Token 设置 - + + -
    @@ -43,10 +49,7 @@

    点击左侧 + 新建作品

    - + @@ -67,7 +88,14 @@

    新建作品

    -

    只需起名。打开后由 Agent 引导你选择 skill 包并开始实例化。

    +

    选择导演,再起名。导演是本局方法起点,之后按对话谈成剧本(可调味)。

    + +
    - -
    -

    存档

    - -
    -

    实例 = 设计完成后的设定;进度 = 含运行状态的完整快照。

    -
    - - - - -
    -
    + + + + + +
    +

    请求上下文

    +

    +
    
    +        
    + + +
    +
    diff --git a/web/modules.css b/web/modules.css new file mode 100644 index 0000000..7a44dfa --- /dev/null +++ b/web/modules.css @@ -0,0 +1,318 @@ +/* 能力池浏览页 — 叠在 settings.css 壳上 */ + +.mod-shell { + max-width: 1280px; + --st-rail-w: 260px; +} + +.mod-pack { + margin: 0 8px 10px; + font-size: 12px; + color: var(--st-muted); + word-break: break-all; +} + +.mod-filter { + padding: 0 8px 8px; +} + +.mod-filter input { + width: 100%; + padding: 7px 10px; + border: 1px solid var(--st-border); + border-radius: 6px; + background: var(--st-bg); +} + +.mod-status-filters { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 0 8px 12px; +} + +.mod-chip { + border: 1px solid var(--st-border); + background: var(--st-surface); + color: var(--st-muted); + border-radius: 999px; + padding: 3px 9px; + font-size: 12px; + cursor: pointer; +} + +.mod-chip:hover { + color: var(--st-text); + border-color: var(--st-border-strong); +} + +.mod-chip.active { + color: var(--st-accent); + background: var(--st-accent-soft); + border-color: transparent; +} + +.mod-list { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + overflow: auto; + padding-bottom: 12px; +} + +.mod-item { + display: block; + width: 100%; + text-align: left; + border: none; + background: transparent; + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + color: inherit; +} + +.mod-item:hover { + background: #f4f4f5; +} + +.mod-item.active { + background: var(--st-accent-soft); +} + +.mod-item-name { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + font-weight: 600; + font-size: 13px; +} + +.mod-item-decl { + margin: 4px 0 0; + font-size: 11px; + color: var(--st-muted); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.mod-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.mod-dot.ready { + background: var(--st-ok); +} + +.mod-dot.partial { + background: #ca8a04; +} + +.mod-dot.skeleton, +.mod-dot.missing { + background: var(--st-faint); +} + +.mod-badge { + font-size: 12px; + font-weight: 600; + padding: 4px 10px; + border-radius: 999px; + background: #f4f4f5; + color: var(--st-muted); +} + +.mod-badge.ready { + background: #dcfce7; + color: var(--st-ok); +} + +.mod-badge.partial { + background: #fef9c3; + color: #a16207; +} + +.mod-badge.skeleton, +.mod-badge.missing { + background: #f4f4f5; + color: var(--st-muted); +} + +.mod-error { + margin: 0 0 12px; + color: var(--st-danger); + font-size: 13px; +} + +.mod-empty { + color: var(--st-muted); + margin: 2rem 0; +} + +.mod-body { + display: flex; + flex-direction: column; + gap: 14px; +} + +.mod-summary { + display: grid; + gap: 10px; + padding: 14px 16px; + background: var(--st-surface); + border: 1px solid var(--st-border); + border-radius: var(--st-radius); +} + +.mod-summary dl { + display: grid; + grid-template-columns: 6.5rem minmax(0, 1fr); + gap: 6px 12px; + margin: 0; + font-size: 13px; +} + +.mod-summary dt { + color: var(--st-muted); + font-weight: 500; +} + +.mod-summary dd { + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +.mod-decl { + margin: 0; + font-size: 14px; + line-height: 1.5; +} + +.mod-section-nav { + display: flex; + flex-wrap: wrap; + gap: 6px; + position: sticky; + top: calc(var(--st-top-h) + 8px); + z-index: 5; + background: var(--st-bg); + padding: 6px 0; +} + +.mod-section-nav a { + font-size: 12px; + text-decoration: none; + color: var(--st-muted); + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--st-border); + background: var(--st-surface); +} + +.mod-section-nav a:hover { + color: var(--st-accent); + border-color: var(--st-accent); +} + +.mod-section { + background: var(--st-surface); + border: 1px solid var(--st-border); + border-radius: var(--st-radius); + overflow: hidden; +} + +.mod-section > summary { + list-style: none; + cursor: pointer; + padding: 12px 16px; + font-weight: 650; + font-size: 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + user-select: none; +} + +.mod-section > summary::-webkit-details-marker { + display: none; +} + +.mod-section > summary::after { + content: "▸"; + color: var(--st-faint); + font-size: 12px; +} + +.mod-section[open] > summary::after { + content: "▾"; +} + +.mod-section > summary:hover { + background: #fafafa; +} + +.mod-section-body { + border-top: 1px solid var(--st-border); + padding: 12px 16px 16px; +} + +.mod-section pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + font-size: 12.5px; + line-height: 1.55; + color: #27272a; +} + +.mod-raw-toggle { + align-self: flex-start; + border: 1px solid var(--st-border); + background: var(--st-surface); + border-radius: 6px; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + color: var(--st-muted); +} + +.mod-raw-toggle:hover { + color: var(--st-text); +} + +.mod-raw { + background: #18181b; + color: #e4e4e7; + border-radius: var(--st-radius); + padding: 14px 16px; + max-height: 60vh; + overflow: auto; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 820px) { + .mod-shell { + grid-template-columns: 1fr; + } + + .mod-rail { + min-height: auto; + border-right: none; + border-bottom: 1px solid var(--st-border); + max-height: 42vh; + } +} diff --git a/web/modules.html b/web/modules.html new file mode 100644 index 0000000..26ab6e4 --- /dev/null +++ b/web/modules.html @@ -0,0 +1,67 @@ + + + + + + 能力池 · Writing Agent + + + + +
    + Writing Agent + +
    + +
    + + +
    +
    +
    +

    能力池

    +

    选择左侧一项查看方法块

    +
    +
    + +
    +
    + + + + +
    +

    从左侧选择一项能力

    +
    +
    +
    + + + + diff --git a/web/modules.js b/web/modules.js new file mode 100644 index 0000000..9b2d828 --- /dev/null +++ b/web/modules.js @@ -0,0 +1,232 @@ +const SECTION_LABELS = { + meta: "元数据", + opening: "默认开场", + task: "任务", + principles: "原则", + probe: "追问", + output: "产物形状", + checklist: "自检", + examples: "示例", +}; + +const SECTION_ORDER = [ + "meta", + "opening", + "task", + "principles", + "probe", + "output", + "checklist", + "examples", +]; + +const STATUS_LABELS = { + ready: "已写", + partial: "部分", + skeleton: "骨架", + missing: "缺文件", +}; + +/** @type {Array} */ +let modulesCache = []; +let activeId = ""; +let statusFilter = ""; +let searchQuery = ""; + +function escapeHtml(s) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function setError(msg) { + const el = document.getElementById("mod-error"); + if (!msg) { + el.hidden = true; + el.textContent = ""; + return; + } + el.hidden = false; + el.textContent = msg; +} + +function filteredModules() { + const q = searchQuery.trim().toLowerCase(); + return modulesCache.filter((m) => { + if (statusFilter && m.status !== statusFilter) return false; + if (!q) return true; + const hay = `${m.name} ${m.id} ${m.declaration} ${m.artifact}`.toLowerCase(); + return hay.includes(q); + }); +} + +function renderList() { + const list = document.getElementById("mod-list"); + const items = filteredModules(); + if (items.length === 0) { + list.innerHTML = `

    无匹配项

    `; + return; + } + list.innerHTML = items + .map( + (m) => ``, + ) + .join(""); +} + +function metaField(meta, key) { + if (!meta || meta[key] == null) return ""; + const v = meta[key]; + return typeof v === "string" ? v.trim() : String(v); +} + +function renderDetail(detail) { + const title = document.getElementById("panel-title"); + const sub = document.getElementById("panel-subtitle"); + const badge = document.getElementById("status-badge"); + const metaBar = document.getElementById("meta-bar"); + const host = document.getElementById("mod-detail"); + + title.textContent = detail.name; + sub.textContent = `${detail.id} · ${detail.artifact}`; + + badge.hidden = false; + badge.className = `mod-badge ${detail.status}`; + badge.textContent = STATUS_LABELS[detail.status] ?? detail.status; + + const when = metaField(detail.meta, "when"); + const whenNot = metaField(detail.meta, "when_not"); + const boundary = metaField(detail.meta, "boundary"); + metaBar.hidden = !(when || whenNot || boundary); + if (!metaBar.hidden) { + const bits = []; + if (when) bits.push(`何时选用 ${escapeHtml(when).replace(/\n/g, "
    ")}`); + if (whenNot) bits.push(`何时不选 ${escapeHtml(whenNot).replace(/\n/g, "
    ")}`); + if (boundary) bits.push(`边界 ${escapeHtml(boundary).replace(/\n/g, "
    ")}`); + metaBar.innerHTML = bits.join("
    "); + } + + const present = SECTION_ORDER.filter((id) => detail.sections?.[id]); + const nav = + present.length > 0 + ? `` + : ""; + + const sectionsHtml = present + .map((id) => { + const open = id === "task" || id === "output" || id === "opening" ? " open" : ""; + return `
    + ${escapeHtml(SECTION_LABELS[id] ?? id)} +
    ${escapeHtml(detail.sections[id])}
    +
    `; + }) + .join(""); + + host.innerHTML = ` +
    +

    ${escapeHtml(detail.declaration)}

    +
    +
    产物 tag
    ${escapeHtml(detail.artifact)}
    +
    已有块
    ${present.map((id) => SECTION_LABELS[id] ?? id).join(" · ") || "—"}
    +
    +
    + ${nav} + ${sectionsHtml || `

    尚无可切割的方法块(可能仍是骨架)。

    `} + + + `; + + const btn = document.getElementById("btn-raw"); + const raw = document.getElementById("raw-view"); + btn.addEventListener("click", () => { + const showing = !raw.hidden; + raw.hidden = showing; + if (!showing) { + raw.textContent = detail.raw || "(空)"; + btn.textContent = "隐藏原始 prompt.md"; + } else { + btn.textContent = "显示原始 prompt.md"; + } + }); +} + +async function selectModule(id) { + if (!id) return; + activeId = id; + renderList(); + setError(""); + const host = document.getElementById("mod-detail"); + host.innerHTML = `

    加载中…

    `; + try { + const res = await fetch(`/api/modules/${encodeURIComponent(id)}`); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `HTTP ${res.status}`); + } + const detail = await res.json(); + renderDetail(detail); + history.replaceState(null, "", `#${encodeURIComponent(id)}`); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + host.innerHTML = `

    加载失败

    `; + } +} + +async function boot() { + setError(""); + try { + const res = await fetch("/api/modules"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + modulesCache = data.modules || []; + document.getElementById("pack-label").textContent = + `导演包 · ${data.skillPackId || "—"} · ${modulesCache.length} 项`; + renderList(); + + const fromHash = decodeURIComponent((location.hash || "").replace(/^#/, "")); + const initial = + (fromHash && modulesCache.find((m) => m.id === fromHash)?.id) || + modulesCache.find((m) => m.status === "ready")?.id || + modulesCache[0]?.id; + if (initial) await selectModule(initial); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + document.getElementById("pack-label").textContent = "加载失败"; + } +} + +document.getElementById("mod-list").addEventListener("click", (e) => { + const btn = e.target.closest(".mod-item"); + if (!btn) return; + selectModule(btn.dataset.id); +}); + +document.getElementById("mod-search").addEventListener("input", (e) => { + searchQuery = e.target.value || ""; + renderList(); +}); + +document.getElementById("status-filters").addEventListener("click", (e) => { + const chip = e.target.closest(".mod-chip"); + if (!chip) return; + statusFilter = chip.dataset.status || ""; + for (const c of document.querySelectorAll(".mod-chip")) { + c.classList.toggle("active", c === chip); + } + renderList(); +}); + +boot(); diff --git a/web/questions-ui.js b/web/questions-ui.js new file mode 100644 index 0000000..6074ffe --- /dev/null +++ b/web/questions-ui.js @@ -0,0 +1,391 @@ +/** + * 结构化询问卡:点字母选中,点文案编辑;左右分页。 + * 普通提问:提交走底栏 composer(问+答拼进上下文),卡上无独立发送钮。 + * 产物验收挂载题:底栏有字优先按「改产物」发送;无字时才提交所选答案。 + * 分页 / 跳过为悬浮控件,不占独立 header/footer 行。 + */ + +function esc(s) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function normalizeQuestions(raw) { + if (!Array.isArray(raw)) return []; + return raw + .map((item, i) => { + if (typeof item === "string") { + const prompt = item.trim(); + if (!prompt) return null; + return { id: `q${i + 1}`, prompt, allowOther: true, required: false, options: [] }; + } + if (!item || typeof item !== "object") return null; + const prompt = String(item.prompt ?? item.question ?? item.text ?? "").trim(); + if (!prompt) return null; + const options = Array.isArray(item.options) + ? item.options + .map((o, j) => { + if (typeof o === "string") { + const label = o.trim(); + return label + ? { id: String.fromCharCode(65 + (j % 26)), label, editable: true } + : null; + } + if (!o || typeof o !== "object") return null; + const label = String(o.label ?? o.text ?? "").trim(); + if (!label) return null; + return { + id: String(o.id ?? String.fromCharCode(65 + (j % 26))), + label, + editable: o.editable === false ? false : true, + }; + }) + .filter(Boolean) + : []; + return { + id: String(item.id ?? `q${i + 1}`), + prompt, + options, + allowOther: item.allowOther === false ? false : true, + required: item.required === true ? true : false, + }; + }) + .filter(Boolean); +} + +/** @returns {{ questions: any[], workerId?: string, pageSize: number, assessment?: string, optional?: boolean, skipLabel?: string } | null} */ +export function getActiveQuestions(view) { + const wr = view?.waitingReason; + if (!wr) return null; + // 默认每页 1 题,左右切换;勿一次堆多题 + const pageSize = Math.max(1, Number(wr.pageSize) || 1); + if (wr.kind === "worker_questions" && wr.questions?.length) { + return { + questions: normalizeQuestions(wr.questions), + workerId: wr.workerId, + pageSize, + optional: true, + skipLabel: "跳过", + }; + } + if (wr.kind === "review_artifact" && wr.questions?.length) { + return { + questions: normalizeQuestions(wr.questions).map((q) => ({ + ...q, + required: false, + })), + workerId: view.reviewArtifact?.workerId, + pageSize, + optional: true, + skipLabel: "跳过", + dismissOnAccept: true, + }; + } + if (wr.kind === "input" && wr.questions?.length) { + const assessment = + typeof wr.message === "string" && wr.message.trim() + ? wr.message.trim() + : ""; + // 短调度句不当作内容评价展示 + const showAssessment = + assessment.length > 80 || + /完备度|核心体验|已知|待探|#\s/.test(assessment); + return { + questions: normalizeQuestions(wr.questions).map((q) => ({ + ...q, + required: q.required === true ? true : false, + })), + workerId: "orchestrator", + pageSize, + assessment: showAssessment ? assessment : "", + optional: true, + skipLabel: "跳过", + }; + } + return null; +} + +function persistDraftsFromDom(host, state) { + host.querySelectorAll("[data-draft]").forEach((el) => { + const key = el.getAttribute("data-draft"); + if (key) state.drafts[key] = el.textContent ?? ""; + }); + host.querySelectorAll("[data-other]").forEach((el) => { + const qid = el.getAttribute("data-other"); + if (!qid) return; + const prev = state.answers[qid] ?? {}; + state.answers[qid] = { ...prev, otherText: el.value }; + }); +} + +/** + * 从询问卡收集并校验全部作答(供 composer 发送时调用)。 + * @returns {{ ok: true, answers: Array<{questionId:string, optionId?:string, text:string}> } | { ok: false, error: string, page?: number }} + */ +export function collectQuestionAnswers(host, view) { + const active = getActiveQuestions(view); + if (!active?.questions?.length) { + return { ok: false, error: "当前没有待回答的追问" }; + } + if (!host?._qState) { + return { ok: false, error: "请先在询问卡中选择选项" }; + } + const state = host._qState; + persistDraftsFromDom(host, state); + + const { questions, pageSize } = active; + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + if (q.required === false) continue; + const ans = state.answers[q.id]; + if (!ans?.optionId) { + return { + ok: false, + error: `请先选择第 ${i + 1} 题的选项(点字母选中)`, + page: Math.floor(i / pageSize), + }; + } + if (ans.optionId === "other") { + const text = (ans.otherText ?? ans.text ?? "").trim(); + if (!text) { + return { + ok: false, + error: `请填写第 ${i + 1} 题的 Other`, + page: Math.floor(i / pageSize), + }; + } + state.answers[q.id] = { optionId: "other", text, otherText: text }; + } else { + const draftKey = `${q.id}:${ans.optionId}`; + const text = (state.drafts[draftKey] ?? ans.text ?? "").trim(); + if (!text) { + return { + ok: false, + error: `第 ${i + 1} 题选项文案不能为空`, + page: Math.floor(i / pageSize), + }; + } + state.answers[q.id] = { ...ans, text }; + } + } + + const answers = questions.map((q) => { + const ans = state.answers[q.id]; + if (!ans?.optionId) { + return { questionId: q.id, text: "(未答)" }; + } + if (ans.optionId === "other") { + return { + questionId: q.id, + optionId: "other", + text: (ans.otherText ?? ans.text ?? "").trim() || "(未答)", + }; + } + const draftKey = `${q.id}:${ans.optionId}`; + const text = (state.drafts[draftKey] ?? ans.text ?? "").trim(); + return { + questionId: q.id, + optionId: ans.optionId, + text: text || "(未答)", + }; + }); + + return { ok: true, answers }; +} + +export function clearQuestionCardState(host) { + if (host) host._qState = null; +} + +/** + * @param {HTMLElement} host + * @param {object} view + * @param {{}} [_handlers] + */ +export function renderQuestionsCard(host, view, _handlers = {}) { + if (!host) return false; + const active = getActiveQuestions(view); + if (!active?.questions?.length) { + host.hidden = true; + host.innerHTML = ""; + host.classList.remove("is-open"); + return false; + } + + host.hidden = false; + host.classList.add("is-open"); + + const state = host._qState?.sessionId === view.id && host._qState?.key === JSON.stringify(active.questions.map((q) => q.id)) + ? host._qState + : { + sessionId: view.id, + key: JSON.stringify(active.questions.map((q) => q.id)), + page: 0, + // questionId -> { optionId?, text, otherText? } + answers: {}, + drafts: {}, // option key -> edited label + }; + host._qState = state; + + const { questions, pageSize, assessment, optional, skipLabel, dismissOnAccept } = + active; + const pageCount = Math.max(1, Math.ceil(questions.length / pageSize)); + if (state.page >= pageCount) state.page = pageCount - 1; + if (state.page < 0) state.page = 0; + const start = state.page * pageSize; + const pageQs = questions.slice(start, start + pageSize); + + const assessmentHtml = + assessment && state.page === 0 + ? `
    ${esc(assessment)}
    ` + : ""; + + const dismissNote = + dismissOnAccept && state.page === 0 + ? `

    可选 · 接受产物即收起(无需再完善)

    ` + : optional && state.page === 0 + ? `

    可选 · 可跳过继续

    ` + : ""; + + const pagerHtml = + pageCount > 1 + ? `
    + + ${state.page + 1}/${pageCount} + +
    ` + : ""; + + const skipHtml = optional + ? `` + : ""; + + const chromeClass = [ + "qcard", + pageCount > 1 ? "has-pager" : "", + optional ? "has-skip" : "", + ] + .filter(Boolean) + .join(" "); + + host.innerHTML = ` + `; + + host.querySelector("[data-qskip]")?.addEventListener("click", () => { + _handlers.onSkipQuestions?.(); + }); + + host.querySelectorAll("[data-qnav]").forEach((btn) => { + btn.addEventListener("click", () => { + persistDraftsFromDom(host, state); + const dir = btn.getAttribute("data-qnav"); + if (dir === "prev" && state.page > 0) state.page -= 1; + if (dir === "next" && state.page < pageCount - 1) state.page += 1; + renderQuestionsCard(host, view, _handlers); + }); + }); + + host.querySelectorAll("[data-select]").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + persistDraftsFromDom(host, state); + const section = btn.closest(".qcard-q"); + const qid = section?.getAttribute("data-qid"); + const oid = btn.getAttribute("data-select"); + if (!qid || !oid) return; + let text = ""; + if (oid === "other") { + const input = section.querySelector(`[data-other="${qid}"]`); + text = (input?.value ?? "").trim(); + state.answers[qid] = { optionId: "other", text, otherText: text }; + } else { + const draftKey = `${qid}:${oid}`; + const labelEl = section.querySelector(`[data-draft="${draftKey}"]`); + text = (labelEl?.textContent ?? state.drafts[draftKey] ?? "").trim(); + state.answers[qid] = { optionId: oid, text }; + } + renderQuestionsCard(host, view, _handlers); + }); + }); + + host.querySelectorAll("[data-draft]").forEach((el) => { + el.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + el.blur(); + } + }); + el.addEventListener("blur", () => { + const key = el.getAttribute("data-draft"); + if (key) state.drafts[key] = el.textContent ?? ""; + const section = el.closest(".qcard-q"); + const qid = section?.getAttribute("data-qid"); + const oid = el.closest(".qcard-opt")?.getAttribute("data-oid"); + if (qid && oid && state.answers[qid]?.optionId === oid) { + state.answers[qid] = { + ...state.answers[qid], + text: (el.textContent ?? "").trim(), + }; + } + }); + }); + + return true; +} diff --git a/web/settings.css b/web/settings.css index e0769da..9e44c86 100644 --- a/web/settings.css +++ b/web/settings.css @@ -1,136 +1,323 @@ -.settings-sidebar { - min-width: var(--sidebar-width); +/* Settings — self-contained shell (main styles.css no longer provides app-shell) */ + +:root { + --st-bg: #f4f4f5; + --st-surface: #ffffff; + --st-border: #e4e4e7; + --st-border-strong: #d4d4d8; + --st-text: #18181b; + --st-muted: #71717a; + --st-faint: #a1a1aa; + --st-accent: #2563eb; + --st-accent-soft: #dbeafe; + --st-ok: #15803d; + --st-danger: #dc2626; + --st-rail-w: 200px; + --st-top-h: 48px; + --st-radius: 8px; + font-family: "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + font-size: 14px; + color: var(--st-text); + line-height: 1.45; } -.settings-content { - max-width: 720px; - width: 100%; +* { + box-sizing: border-box; } -.active-settings-bar { +html, +body.settings-page { + height: 100%; + margin: 0; + background: var(--st-bg); + color: var(--st-text); +} + +button, +input, +textarea { + font: inherit; +} + +code { + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + font-size: 0.85em; + background: #f4f4f5; + padding: 0.1em 0.35em; + border-radius: 4px; +} + +/* —— Top bar —— */ +.st-top { + height: var(--st-top-h); display: flex; - flex-wrap: wrap; - gap: 8px; - padding: 10px 24px; - background: var(--surface-hover); - border-bottom: 1px solid var(--border); - font-size: 0.8125rem; + align-items: center; + justify-content: space-between; + padding: 0 20px; + background: var(--st-surface); + border-bottom: 1px solid var(--st-border); + position: sticky; + top: 0; + z-index: 20; } -.active-settings-bar .label { - color: var(--text-tertiary); - margin-right: 4px; +.st-brand { + font-weight: 650; + font-size: 15px; + letter-spacing: -0.02em; + color: var(--st-text); + text-decoration: none; } -.active-settings-bar .active-tag { - padding: 3px 10px; - border-radius: 999px; - background: var(--surface); - border: 1px solid var(--border); - color: var(--text); +.st-brand:hover { + color: var(--st-accent); +} + +.st-nav { + display: flex; + gap: 4px; +} + +.st-nav a { + padding: 6px 12px; + border-radius: 6px; + text-decoration: none; + color: var(--st-muted); + font-size: 13px; font-weight: 500; } -.active-settings-bar .active-tag.missing { - color: var(--danger); - border-color: rgba(245, 74, 69, 0.35); +.st-nav a:hover { + color: var(--st-text); + background: #f4f4f5; } -.settings-toast { - margin: 0 24px 0; - padding: 10px 14px; - border-radius: var(--radius-md); - background: rgba(0, 181, 120, 0.12); - color: #00875a; - font-size: 0.82rem; +.st-nav a[aria-current="page"] { + color: var(--st-accent); + background: var(--st-accent-soft); } -.settings-toast.error { - background: rgba(245, 74, 69, 0.1); - color: var(--danger); +/* —— Shell —— */ +.st-shell { + display: grid; + grid-template-columns: var(--st-rail-w) minmax(0, 1fr); + min-height: calc(100% - var(--st-top-h)); + max-width: 1100px; + margin: 0 auto; } -.config-card-actions .btn-activate { - background: var(--text) !important; - color: #fff !important; - border: none !important; - border-radius: 999px; - padding: 6px 16px; - font-size: 0.82rem; +.st-rail { + padding: 20px 12px; + border-right: 1px solid var(--st-border); + background: var(--st-surface); + display: flex; + flex-direction: column; + gap: 4px; + min-height: calc(100dvh - var(--st-top-h)); +} + +.st-rail-label { + margin: 0 8px 8px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--st-faint); +} + +.st-rail-item { + display: block; + width: 100%; + text-align: left; + border: none; + background: transparent; + padding: 9px 12px; + border-radius: 6px; + color: var(--st-muted); font-weight: 500; + font-size: 13px; cursor: pointer; } -.config-card-actions .btn-activate:hover { - background: #000 !important; +.st-rail-item:hover { + background: #f4f4f5; + color: var(--st-text); } -.config-card-actions .btn-active-label { - background: var(--accent-soft); - color: var(--accent); - border: none; - border-radius: 999px; - padding: 6px 14px; - font-size: 0.78rem; - cursor: default; +.st-rail-item.active { + background: var(--st-accent-soft); + color: var(--st-accent); } -.import-report-actions { - margin-top: 10px; +.st-rail-note { + margin-top: auto; + padding: 12px 8px 4px; + font-size: 11px; + line-height: 1.5; + color: var(--st-faint); +} + +/* —— Main —— */ +.st-main { + display: flex; + flex-direction: column; + min-width: 0; + min-height: calc(100dvh - var(--st-top-h)); +} + +.st-main-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 22px 28px 12px; +} + +.st-main-head h1 { + margin: 0; + font-size: 22px; + font-weight: 650; + letter-spacing: -0.02em; +} + +.st-sub { + margin: 4px 0 0; + font-size: 13px; + color: var(--st-muted); +} + +.st-main-actions { display: flex; gap: 8px; + flex-shrink: 0; } -.panel-body { - flex: 1; - overflow-y: auto; - padding: 24px 32px 40px; - background: var(--surface); +.active-settings-bar, +.st-status { display: flex; - justify-content: center; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 0 28px; + padding: 10px 12px; + background: var(--st-surface); + border: 1px solid var(--st-border); + border-radius: var(--st-radius); + font-size: 12px; +} + +.active-settings-bar .label, +.st-status .label { + color: var(--st-faint); + margin-right: 2px; +} + +.active-settings-bar .active-tag, +.st-status .active-tag { + padding: 3px 10px; + border-radius: 999px; + background: #f4f4f5; + border: 1px solid var(--st-border); + color: var(--st-text); + font-weight: 500; +} + +.active-settings-bar .active-tag.missing, +.st-status .active-tag.missing { + color: var(--st-danger); + border-color: #fecaca; + background: #fef2f2; +} + +.st-body { + flex: 1; + padding: 16px 28px 40px; + overflow-y: auto; } .settings-section.hidden { display: none; } -.sidebar-nav-item { - display: flex; - align-items: center; - gap: 8px; +/* —— Buttons —— */ +.btn-primary, +.btn-secondary, +.btn-danger, +.config-card-actions button { + border-radius: 6px; + padding: 7px 14px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; } -.sidebar-nav-icon { - font-size: 0.9rem; - opacity: 0.85; +.btn-primary { + background: var(--st-accent); + color: #fff; + border-color: var(--st-accent); } +.btn-primary:hover { + background: #1d4ed8; +} + +.btn-secondary { + background: var(--st-surface); + color: var(--st-text); + border-color: var(--st-border-strong); +} + +.btn-secondary:hover { + background: #f4f4f5; +} + +.btn-danger { + background: transparent; + color: var(--st-danger); + border-color: #fecaca; +} + +.btn-danger:hover { + background: #fef2f2; +} + +.danger-outline { + border-color: #fecaca !important; + color: var(--st-danger) !important; +} + +/* —— Cards —— */ .card-list { display: flex; flex-direction: column; gap: 10px; - max-width: 720px; + max-width: 640px; +} + +.empty-hint { + margin: 24px 0; + color: var(--st-muted); + font-size: 13px; } .config-card { display: flex; flex-direction: column; gap: 12px; - padding: 16px 18px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); - transition: border-color 0.15s, box-shadow 0.15s; + padding: 16px; + background: var(--st-surface); + border: 1px solid var(--st-border); + border-radius: var(--st-radius); } .config-card:hover { - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); + border-color: var(--st-border-strong); } .config-card.active { - border-color: rgba(51, 112, 255, 0.45); - box-shadow: 0 0 0 1px var(--accent-soft), 0 4px 16px rgba(51, 112, 255, 0.08); + border-color: #93c5fd; + box-shadow: inset 3px 0 0 var(--st-accent); } .config-card-head { @@ -142,21 +329,21 @@ .config-card-icon { width: 36px; height: 36px; - border-radius: 50%; + border-radius: 8px; display: grid; place-items: center; - font-size: 0.85rem; + font-size: 13px; + font-weight: 700; flex-shrink: 0; color: #fff; - font-weight: 700; } .config-card-icon.api { - background: linear-gradient(135deg, #6b9fff, var(--agent-blue)); + background: #3b82f6; } .config-card-icon.preset { - background: linear-gradient(135deg, #ff85c0, var(--agent-pink)); + background: #0f766e; } .config-card-info { @@ -166,12 +353,24 @@ .config-card-title { font-weight: 600; - font-size: 0.9rem; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; +} + +.badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + background: var(--st-accent-soft); + color: var(--st-accent); } .config-card-meta { - color: var(--text-tertiary); - font-size: 0.78rem; + color: var(--st-muted); + font-size: 12px; word-break: break-all; margin-top: 4px; line-height: 1.5; @@ -184,47 +383,92 @@ padding-left: 48px; } -.config-card-actions button:not(.btn-danger) { - background: var(--surface-hover); - color: var(--text); - border: none; - border-radius: 999px; - padding: 5px 14px; - font-size: 0.78rem; - cursor: pointer; - transition: background 0.15s, color 0.15s; +.config-card-actions button:not(.btn-danger):not(.btn-activate):not(.btn-active-label) { + background: #f4f4f5; + color: var(--st-text); + border: 1px solid transparent; } -.config-card-actions button:not(.btn-danger):hover { - background: var(--accent-soft); - color: var(--accent); +.config-card-actions button:not(.btn-danger):not(.btn-activate):not(.btn-active-label):hover { + background: var(--st-accent-soft); + color: var(--st-accent); +} + +.config-card-actions .btn-activate { + background: var(--st-text) !important; + color: #fff !important; + border: none !important; + border-radius: 6px; + padding: 6px 14px; + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.config-card-actions .btn-activate:hover { + background: #000 !important; +} + +.config-card-actions .btn-active-label { + background: var(--st-accent-soft); + color: var(--st-accent); + border: none; + border-radius: 6px; + padding: 6px 12px; + font-size: 12px; + cursor: default; } .test-result { - font-size: 0.78rem; - margin-top: 2px; + font-size: 12px; padding-left: 48px; } .test-result.ok { - color: var(--success); + color: var(--st-ok); } .test-result.fail { - color: var(--danger); + color: var(--st-danger); } +/* —— Toast —— */ +.settings-toast { + margin: 10px 28px 0; + padding: 10px 14px; + border-radius: var(--st-radius); + background: #ecfdf5; + color: var(--st-ok); + font-size: 13px; + border: 1px solid #bbf7d0; +} + +.settings-toast.error { + background: #fef2f2; + color: var(--st-danger); + border-color: #fecaca; +} + +/* —— Preset entries —— */ .import-report { margin-top: 16px; padding: 14px 16px; - background: var(--bubble); - border-radius: var(--radius-md); - font-size: 0.76rem; + background: var(--st-surface); + border: 1px solid var(--st-border); + border-radius: var(--st-radius); + font-size: 12px; overflow-x: auto; white-space: pre-wrap; - color: var(--text-secondary); - max-width: 720px; + color: var(--st-muted); + max-width: 640px; line-height: 1.6; + font-family: inherit; +} + +.import-report-actions { + margin-top: 10px; + display: flex; + gap: 8px; } .preset-entries-panel { @@ -233,31 +477,32 @@ } .preset-entries-inner { - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--surface-hover); - padding: 12px 14px; + border: 1px solid var(--st-border); + border-radius: var(--st-radius); + background: #fafafa; + padding: 12px; } .preset-gen-params { - font-size: 0.76rem; - color: var(--text-secondary); + font-size: 12px; + color: var(--st-muted); margin-bottom: 10px; line-height: 1.5; } .preset-entries-summary { - font-size: 0.74rem; - color: var(--text-tertiary); + font-size: 12px; + color: var(--st-faint); margin: 0 0 10px; } .preset-entry { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 10px 12px; + background: var(--st-surface); + border: 1px solid var(--st-border); + border-radius: 6px; + padding: 0; margin-bottom: 8px; + overflow: hidden; } .preset-entry:last-child { @@ -265,27 +510,165 @@ } .preset-entry.skipped { + opacity: 1; +} + +.preset-entry.disabled { opacity: 0.72; } +.preset-entry.is-open { + border-color: #93c5fd; + box-shadow: 0 0 0 1px var(--st-accent-soft); +} + .preset-entry-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; - margin-bottom: 8px; - font-size: 0.78rem; + margin: 0; + padding: 10px 12px; + font-size: 12px; + cursor: pointer; + user-select: none; +} + +.preset-entry-head:hover { + background: #fafafa; +} + +.preset-entry.is-open .preset-entry-head { + background: #f8fafc; + border-bottom: 1px solid var(--st-border); +} + +.entry-enable { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 500; + color: var(--st-muted); + cursor: pointer; + user-select: none; +} + +.entry-enable input { + accent-color: var(--st-accent); +} + +.entry-name { + flex: 1; + min-width: 6rem; + font-weight: 600; + color: var(--st-text); +} + +.entry-chevron { + margin-left: auto; + color: var(--st-faint); + font-size: 11px; +} + +.entry-preview { + margin: 0; + padding: 0 12px 10px 48px; + font-size: 11px; + color: var(--st-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.4; + cursor: pointer; +} + +.preset-entry.is-open .entry-preview { + display: none; +} + +.preset-entry-body { + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + background: #fafafa; +} + +.preset-entry-body[hidden] { + display: none; +} + +.entry-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + font-weight: 500; + color: var(--st-muted); +} + +.entry-name-input { + width: 100%; + border: 1px solid var(--st-border); + border-radius: 6px; + padding: 6px 10px; + font-size: 13px; + font-weight: 600; + background: var(--st-surface); + color: var(--st-text); +} + +.entry-name-input:focus { + outline: none; + border-color: #93c5fd; + box-shadow: 0 0 0 2px var(--st-accent-soft); +} + +.preset-entry-editor { + display: block; + width: 100%; + margin: 0; + padding: 10px 12px; + background: var(--st-surface); + border: 1px solid var(--st-border); + border-radius: 6px; + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + resize: vertical; + min-height: 5rem; + font-family: ui-monospace, Consolas, monospace; + color: var(--st-text); +} + +.preset-entry-editor:focus { + outline: none; + border-color: #93c5fd; + box-shadow: 0 0 0 2px var(--st-accent-soft); +} + +.preset-entry-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 2px; +} + +.entry-skip.ok { + color: var(--st-ok); } .entry-index { width: 20px; height: 20px; border-radius: 50%; - background: var(--accent-soft); - color: var(--accent); + background: var(--st-accent-soft); + color: var(--st-accent); display: grid; place-items: center; - font-size: 0.68rem; + font-size: 11px; font-weight: 700; flex-shrink: 0; } @@ -293,127 +676,226 @@ .entry-role { padding: 2px 8px; border-radius: 999px; - font-size: 0.68rem; + font-size: 11px; font-weight: 600; text-transform: uppercase; } .entry-role.role-system { - background: rgba(51, 112, 255, 0.12); - color: var(--accent); + background: #dbeafe; + color: #1d4ed8; } .entry-role.role-user { - background: rgba(0, 181, 120, 0.12); - color: var(--mention); + background: #dcfce7; + color: #15803d; } .entry-role.role-assistant { - background: rgba(114, 46, 209, 0.12); - color: var(--agent-purple); + background: #f4f4f5; + color: #52525b; } .entry-name { font-weight: 600; - color: var(--text); } .entry-skip { - font-size: 0.68rem; - color: var(--text-tertiary); + font-size: 11px; + color: var(--st-faint); } .preset-entry-content { margin: 0; padding: 10px 12px; - background: var(--bubble); - border-radius: var(--radius-sm); - font-size: 0.76rem; + background: #f4f4f5; + border-radius: 6px; + font-size: 12px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; max-height: 240px; overflow: auto; - font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + font-family: ui-monospace, Consolas, monospace; } .preset-entry-empty { margin: 0; - font-size: 0.74rem; - color: var(--text-tertiary); + font-size: 12px; + color: var(--st-faint); font-style: italic; } +/* —— Storage —— */ +.storage-card { + max-width: 640px; + padding: 20px; + border: 1px solid var(--st-border); + border-radius: var(--st-radius); + background: var(--st-surface); +} + +.storage-card h3 { + margin: 0 0 8px; + font-size: 15px; +} + +.settings-help { + margin: 0 0 14px; + font-size: 13px; + color: var(--st-muted); + line-height: 1.55; +} + +.settings-help.muted { + margin-top: -6px; + font-size: 12px; + color: var(--st-faint); +} + +.storage-card .field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 10px; + font-size: 13px; + font-weight: 500; +} + +.storage-card .field input { + max-width: 120px; + padding: 8px 10px; + border: 1px solid var(--st-border-strong); + border-radius: 6px; + background: var(--st-surface); + color: inherit; +} + +.storage-card .field input:focus { + outline: none; + border-color: #93c5fd; + box-shadow: 0 0 0 3px var(--st-accent-soft); +} + +.storage-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} + +/* —— Dialog —— */ .profile-dialog { border: none; - border-radius: var(--radius-lg); + border-radius: 10px; padding: 0; - background: var(--surface); - color: var(--text); + background: var(--st-surface); + color: var(--st-text); max-width: 420px; width: calc(100% - 32px); - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.15); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.18); } .profile-dialog::backdrop { - background: rgba(0, 0, 0, 0.3); - backdrop-filter: blur(4px); + background: rgba(24, 24, 27, 0.4); } .profile-dialog form { - padding: 24px; + padding: 22px; display: flex; flex-direction: column; gap: 14px; } .profile-dialog h3 { - margin: 0 0 4px; - font-size: 1rem; - font-weight: 600; + margin: 0 0 2px; + font-size: 16px; + font-weight: 650; } .profile-dialog label { display: flex; flex-direction: column; gap: 6px; - font-size: 0.78rem; - color: var(--text-secondary); + font-size: 12px; + color: var(--st-muted); font-weight: 500; } .profile-dialog input { - padding: 10px 14px; - border-radius: var(--radius-md); - border: 1px solid var(--border-strong); - background: var(--surface); - color: var(--text); - font-size: 0.875rem; - transition: border-color 0.15s, box-shadow 0.15s; + padding: 9px 12px; + border-radius: 6px; + border: 1px solid var(--st-border-strong); + background: var(--st-surface); + color: var(--st-text); + font-size: 14px; } .profile-dialog input:focus { outline: none; - border-color: rgba(51, 112, 255, 0.5); - box-shadow: 0 0 0 3px var(--accent-soft); + border-color: #93c5fd; + box-shadow: 0 0 0 3px var(--st-accent-soft); } .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; - margin-top: 8px; + margin-top: 6px; } -@media (max-width: 900px) { - .config-card-actions { - padding-left: 0; +/* —— Compat aliases used by settings.js —— */ +.sidebar-nav-item { + /* JS still toggles .active on these; map to rail items */ +} + +.active-settings-bar { + /* id used; styled via .st-status on same element — keep both */ +} + +@media (max-width: 800px) { + .st-shell { + grid-template-columns: 1fr; } - .test-result { - padding-left: 0; + .st-rail { + min-height: auto; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + border-right: none; + border-bottom: 1px solid var(--st-border); + padding: 12px; + gap: 6px; } + .st-rail-label, + .st-rail-note { + display: none; + } + + .st-rail-item { + width: auto; + } + + .st-main-head, + .st-status, + .active-settings-bar, + .settings-toast, + .st-body { + padding-left: 16px; + padding-right: 16px; + } + + .st-status, + .active-settings-bar, + .settings-toast { + margin-left: 16px; + margin-right: 16px; + } + + .config-card-actions, + .test-result, .preset-entries-panel { padding-left: 0; } diff --git a/web/settings.html b/web/settings.html index 81591ee..c7d0c97 100644 --- a/web/settings.html +++ b/web/settings.html @@ -4,80 +4,85 @@ 设置 · Writing Agent - - - - - -
    - - -
    - - -
    -
    -
    -

    API 配置

    - 管理 LLM 连接 -
    -
    -
    - -
    - - -
    -
    -
    -
    -
    - - -
    -
    -
    -
    +
    -

    新增 API 配置

    +

    新增 API

    diff --git a/web/settings.js b/web/settings.js index 5a4ae36..6dbeecd 100644 --- a/web/settings.js +++ b/web/settings.js @@ -7,7 +7,10 @@ let state = { let editingProfileId = null; let activeSection = "api"; let lastImportedPresetId = null; +/** presetId → entries API payload */ const expandedPresetEntries = new Map(); +/** presetId → 当前展开编辑的 entry id(一次一条) */ +const openPresetEntryId = new Map(); const profilesListEl = document.getElementById("profiles-list"); const presetsListEl = document.getElementById("presets-list"); @@ -21,8 +24,10 @@ const panelSubtitleEl = document.getElementById("panel-subtitle"); const panelActionsEl = document.getElementById("panel-actions"); const sectionApiEl = document.getElementById("section-api"); const sectionPresetEl = document.getElementById("section-preset"); +const sectionStorageEl = document.getElementById("section-storage"); const activeSettingsBarEl = document.getElementById("active-settings-bar"); const settingsToastEl = document.getElementById("settings-toast"); +const contextTraceKeepEl = document.getElementById("context-trace-keep"); async function api(path, options = {}) { const res = await fetch(path, { @@ -79,34 +84,39 @@ function renderActiveBar() { function switchSection(section) { activeSection = section; - document.querySelectorAll(".sidebar-nav-item").forEach((el) => { + document.querySelectorAll(".st-rail-item").forEach((el) => { el.classList.toggle("active", el.dataset.section === section); }); sectionApiEl.classList.toggle("hidden", section !== "api"); sectionPresetEl.classList.toggle("hidden", section !== "preset"); + sectionStorageEl?.classList.toggle("hidden", section !== "storage"); renderPanelHeader(); } function renderPanelHeader() { panelActionsEl.innerHTML = ""; if (activeSection === "api") { - panelTitleEl.textContent = "API 配置"; - panelSubtitleEl.textContent = "保存后点击「选用」或「保存并选用」立即生效"; + panelTitleEl.textContent = "API"; + panelSubtitleEl.textContent = "保存后点「选用」才会用于对话"; const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn-primary"; - btn.textContent = "新增配置"; + btn.textContent = "新增"; btn.addEventListener("click", () => openProfileDialog()); panelActionsEl.appendChild(btn); - } else { - panelTitleEl.textContent = "预设 Preset"; - panelSubtitleEl.textContent = "导入后点击「选用此预设」注入所有 LLM 请求"; + } else if (activeSection === "preset") { + panelTitleEl.textContent = "预设"; + panelSubtitleEl.textContent = + "导入后可启用/编辑条目;选用后注入全部 LLM 请求"; const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn-primary"; btn.textContent = "导入 JSON"; btn.addEventListener("click", () => presetFileEl.click()); panelActionsEl.appendChild(btn); + } else { + panelTitleEl.textContent = "上下文"; + panelSubtitleEl.textContent = "控制每个对话保留多少条完整 prompt 痕迹"; } } @@ -174,8 +184,8 @@ function renderPresets() { ${active ? '当前' : ""}
    - ${escapeHtml(p.source)} · 启用 ${p.enabledCount} 条 - · 注入 ${p.injectingCount ?? "?"} 条 + ${escapeHtml(p.source)} · 共 ${p.entryCount ?? "?"} 条 + · 启用 ${p.enabledCount} · 注入 ${p.injectingCount ?? "?"} · ${escapeHtml(p.importedAt?.slice(0, 10) ?? "")}
    @@ -183,7 +193,7 @@ function renderPresets() {
    ${activateButtonHtml(active, p.id, "activate-preset")}
    @@ -204,46 +214,111 @@ function renderPresetEntriesPanel(presetId, data) { .filter(([, v]) => v !== undefined && v !== null) .map(([k, v]) => `${k}: ${v}`); + const openId = openPresetEntryId.get(presetId) ?? null; + const entriesHtml = data.entries .map((entry, idx) => { const roleClass = `role-${entry.role}`; - const status = entry.willInject - ? "" + const open = openId === entry.id; + const preview = (entry.content || "").trim(); + const previewText = preview + ? preview.length > 72 + ? `${preview.slice(0, 72)}…` + : preview : entry.marker - ? 'marker · 无内容' - : '空内容 · 不注入'; + ? "(marker · 无正文)" + : "(空)"; + const status = entry.enabled + ? entry.willInject + ? '将注入' + : '已启用 · 不注入' + : '未启用'; + return ` -
    -
    +
    +
    + ${idx + 1} ${escapeHtml(entry.role)} ${escapeHtml(entry.name)} ${status} +
    - ${entry.content - ? `
    ${escapeHtml(entry.content)}
    ` - : `

    (无文本内容)

    `} + ${open + ? "" + : `

    ${escapeHtml(previewText)}

    `} +
    + + +
    + + +
    +
    `; }) .join(""); panel.innerHTML = ` -
    +
    ${genLines.length ? `
    生成参数 ${escapeHtml(genLines.join(" · "))}
    ` : ""} -

    共 ${data.entries.length} 条启用顺序,${data.injectingCount} 条会注入请求

    - ${entriesHtml || '

    无启用条目

    '} +

    共 ${data.entries.length} 条 · 启用 ${data.enabledCount ?? 0} · 注入 ${data.injectingCount ?? 0}。点条目展开编辑;启用开关即时保存。

    + ${entriesHtml || '

    无条目

    '}
    `; } +async function refreshPresetEntries(presetId) { + const data = await api(`/api/presets/${encodeURIComponent(presetId)}/entries`); + expandedPresetEntries.set(presetId, data); + // 同步卡片上的计数 + const cardMeta = state.presets.find((p) => p.id === presetId); + if (cardMeta) { + cardMeta.enabledCount = data.enabledCount; + cardMeta.injectingCount = data.injectingCount; + cardMeta.entryCount = data.entries?.length; + } + renderPresets(); +} + async function togglePresetEntries(presetId) { if (expandedPresetEntries.has(presetId)) { expandedPresetEntries.delete(presetId); + openPresetEntryId.delete(presetId); renderPresets(); return; } - const data = await api(`/api/presets/${encodeURIComponent(presetId)}/entries`); + await refreshPresetEntries(presetId); +} + +async function patchPresetEntry(presetId, patch) { + const data = await api(`/api/presets/${encodeURIComponent(presetId)}/entries`, { + method: "PATCH", + body: JSON.stringify({ entry: patch }), + }); expandedPresetEntries.set(presetId, data); + const cardMeta = state.presets.find((p) => p.id === presetId); + if (cardMeta) { + cardMeta.enabledCount = data.enabledCount; + cardMeta.injectingCount = data.injectingCount; + cardMeta.entryCount = data.entries?.length; + } renderPresets(); + const n = data.reloadedSessions ?? 0; + showToast( + n > 0 + ? `已保存,并热更新 ${n} 个会话` + : "已保存(选用此预设后才会进入请求)", + ); + return data; } async function loadAll() { @@ -251,6 +326,11 @@ async function loadAll() { state.settings = data.settings; state.profiles = data.profiles; state.presets = data.presets; + if (contextTraceKeepEl) { + contextTraceKeepEl.value = String( + state.settings.contextTraceKeepLatest ?? 5, + ); + } renderActiveBar(); renderProfiles(); renderPresets(); @@ -321,7 +401,7 @@ async function activatePreset(id) { showToast(`预设已生效${n > 0 ? `(${n} 个会话已更新)` : ""}`); } -document.querySelectorAll(".sidebar-nav-item").forEach((btn) => { +document.querySelectorAll(".st-rail-item").forEach((btn) => { btn.addEventListener("click", () => switchSection(btn.dataset.section)); }); @@ -372,6 +452,22 @@ profilesListEl.addEventListener("click", async (e) => { }); presetsListEl.addEventListener("click", async (e) => { + if (e.target.closest("[data-stop-expand]")) return; + + const expandHead = e.target.closest('[data-action="expand-entry"]'); + if (expandHead) { + const panel = expandHead.closest(".preset-entries-inner"); + const presetId = panel?.dataset.presetId; + const entryId = expandHead.dataset.id; + if (!presetId || !entryId) return; + const cur = openPresetEntryId.get(presetId); + if (cur === entryId) openPresetEntryId.delete(presetId); + else openPresetEntryId.set(presetId, entryId); + const data = expandedPresetEntries.get(presetId); + if (data) renderPresetEntriesPanel(presetId, data); + return; + } + const btn = e.target.closest("[data-action]"); if (!btn) return; const id = btn.dataset.id; @@ -385,14 +481,56 @@ presetsListEl.addEventListener("click", async (e) => { } else if (action === "delete-preset") { if (!confirm("确定删除此预设?")) return; await api(`/api/presets/${id}`, { method: "DELETE" }); + expandedPresetEntries.delete(id); + openPresetEntryId.delete(id); await loadAll(); showToast("已删除"); + } else if (action === "collapse-entry") { + const panel = btn.closest(".preset-entries-inner"); + const presetId = panel?.dataset.presetId; + if (!presetId) return; + openPresetEntryId.delete(presetId); + const data = expandedPresetEntries.get(presetId); + if (data) renderPresetEntriesPanel(presetId, data); + } else if (action === "save-entry") { + const article = btn.closest(".preset-entry"); + const panel = btn.closest(".preset-entries-inner"); + const presetId = panel?.dataset.presetId; + if (!article || !presetId) return; + const name = article.querySelector('[data-field="name"]')?.value ?? ""; + const content = article.querySelector('[data-field="content"]')?.value ?? ""; + openPresetEntryId.set(presetId, id); + await patchPresetEntry(presetId, { id, name, content }); } } catch (err) { showToast(err.message, true); } }); +presetsListEl.addEventListener("keydown", (e) => { + if (e.key !== "Enter" && e.key !== " ") return; + const head = e.target.closest?.('[data-action="expand-entry"]'); + if (!head) return; + e.preventDefault(); + head.click(); +}); + +presetsListEl.addEventListener("change", async (e) => { + const input = e.target.closest('input[data-action="toggle-entry"]'); + if (!input) return; + const article = input.closest(".preset-entry"); + const panel = input.closest(".preset-entries-inner"); + const presetId = panel?.dataset.presetId; + const id = input.dataset.id; + if (!presetId || !id) return; + try { + await patchPresetEntry(presetId, { id, enabled: input.checked }); + } catch (err) { + input.checked = !input.checked; + showToast(err.message, true); + } +}); + presetFileEl.addEventListener("change", async () => { const file = presetFileEl.files?.[0]; if (!file) return; @@ -435,6 +573,46 @@ presetFileEl.addEventListener("change", async () => { } }); +document.getElementById("btn-save-context-keep")?.addEventListener("click", async () => { + try { + const n = Number(contextTraceKeepEl?.value ?? 5); + const data = await api("/api/settings", { + method: "PUT", + body: JSON.stringify({ contextTraceKeepLatest: n }), + }); + state.settings = data.settings; + if (contextTraceKeepEl) { + contextTraceKeepEl.value = String(data.settings.contextTraceKeepLatest ?? 5); + } + showToast(`已保存:每个对话保留最新 ${data.settings.contextTraceKeepLatest} 条上下文`); + } catch (err) { + showToast(err.message, true); + } +}); + +document.getElementById("btn-prune-context")?.addEventListener("click", async () => { + try { + const r = await api("/api/settings/context-traces/prune", { method: "POST" }); + showToast( + `已修剪 ${r.sessions} 个会话:保留 ${r.kept} 条痕迹,清除 ${r.cleared} 条`, + ); + } catch (err) { + showToast(err.message, true); + } +}); + +document.getElementById("btn-clear-context")?.addEventListener("click", async () => { + if (!confirm("清除所有已打开会话中保存的 LLM 请求上下文?消息正文不受影响。")) { + return; + } + try { + const r = await api("/api/settings/context-traces/clear", { method: "POST" }); + showToast(`已清除 ${r.sessions} 个会话中的 ${r.cleared} 条上下文痕迹`); + } catch (err) { + showToast(err.message, true); + } +}); + renderPanelHeader(); loadAll().catch((err) => { showToast(`加载设置失败: ${err.message}`, true); diff --git a/web/stats.html b/web/stats.html new file mode 100644 index 0000000..cf475b5 --- /dev/null +++ b/web/stats.html @@ -0,0 +1,112 @@ + + + + + + Token 统计 · Writing Agent + + + + + +
    +
    +

    ← 对话 · 能力 · 设置

    +

    Token 统计

    +

    按时间戳筛选调用记录(存于本地 stats/*.jsonl)

    +
    + +
    + + + + + + +
    + +
    +

    + + + + + + + + + + + + + +
    时间callermodelpromptcompletiontotalcached
    +
    + + + diff --git a/web/stats.js b/web/stats.js new file mode 100644 index 0000000..6d61c91 --- /dev/null +++ b/web/stats.js @@ -0,0 +1,113 @@ +function toLocalInputValue(d) { + const pad = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function localInputToIso(value) { + if (!value) return undefined; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); +} + +function formatAt(iso) { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(); +} + +async function loadStats() { + const from = localInputToIso(document.getElementById("from").value); + const to = localInputToIso(document.getElementById("to").value); + const limit = Number(document.getElementById("limit").value) || 200; + const qs = new URLSearchParams(); + if (from) qs.set("from", from); + if (to) qs.set("to", to); + qs.set("limit", String(limit)); + + const status = document.getElementById("status"); + status.textContent = "加载中…"; + try { + const res = await fetch(`/api/stats/tokens?${qs}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + renderSummary(data.summary); + renderRows(data.records || []); + status.textContent = `共 ${data.summary?.totalCalls ?? 0} 次调用 · 显示 ${(data.records || []).length} 条`; + } catch (err) { + status.textContent = `加载失败:${err instanceof Error ? err.message : String(err)}`; + } +} + +function renderSummary(summary) { + const el = document.getElementById("summary"); + if (!summary) { + el.innerHTML = ""; + return; + } + const metrics = [ + ["调用次数", summary.totalCalls], + ["总 token", summary.totalTokens], + ["prompt", summary.promptTokens], + ["completion", summary.completionTokens], + ["cached", summary.totalCached], + ]; + el.innerHTML = metrics + .map( + ([label, value]) => + `
    ${label}${value ?? 0}
    `, + ) + .join(""); +} + +function renderRows(records) { + const tbody = document.getElementById("rows"); + tbody.innerHTML = records + .map( + (r) => ` + ${formatAt(r.at)} + ${escapeHtml(r.caller || "")} + ${escapeHtml(r.model || "")} + ${r.promptTokens ?? 0} + ${r.completionTokens ?? 0} + ${r.totalTokens ?? 0} + ${r.cachedTokens ?? "—"} + `, + ) + .join(""); +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function setRange(fromDate, toDate) { + document.getElementById("from").value = toLocalInputValue(fromDate); + document.getElementById("to").value = toLocalInputValue(toDate); +} + +document.getElementById("btn-load").addEventListener("click", () => loadStats()); +document.getElementById("btn-today").addEventListener("click", () => { + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); + setRange(start, now); + loadStats(); +}); +document.getElementById("btn-7d").addEventListener("click", () => { + const now = new Date(); + const start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + setRange(start, now); + loadStats(); +}); + +// 默认:今天 +(() => { + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); + setRange(start, now); + loadStats(); +})(); diff --git a/web/styles.css b/web/styles.css index 199a34f..8242c6d 100644 --- a/web/styles.css +++ b/web/styles.css @@ -21,7 +21,11 @@ } * { box-sizing: border-box; } -html, body { height: 100%; margin: 0; } +html, body { + height: 100%; + margin: 0; + overflow: hidden; /* 浏览器层永不出现页面滚动条 */ +} button, input, textarea { font: inherit; } a.link { color: var(--accent); text-decoration: none; } a.link:hover { text-decoration: underline; } @@ -29,18 +33,29 @@ a.link:hover { text-decoration: underline; } .layout { display: grid; grid-template-columns: var(--books-w) 1fr; - height: 100vh; + height: 100%; + max-height: 100dvh; + overflow: hidden; background: var(--bg); } .panel { background: var(--surface); border: 1px solid var(--border); min-height: 0; } -.panel-books { border-right: none; display: flex; flex-direction: column; } +.panel-books { border-right: none; display: flex; flex-direction: column; min-height: 0; overflow: hidden; } .panel-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 13px; + flex-shrink: 0; +} +/* composer 按内容自适应;中间区吃掉剩余高度,整页锁在视口内 */ +.panel-main { + display: grid; + grid-template-rows: var(--head-h) minmax(0, 1fr) auto; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; } -.panel-main { display: grid; grid-template-rows: var(--head-h) 1fr var(--composer-min); min-width: 0; } .main-head { display: grid; @@ -59,9 +74,16 @@ a.link:hover { text-decoration: underline; } display: grid; grid-template-columns: 1fr var(--agent-w); min-height: 0; + overflow: hidden; } -.panel-feed { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--border); } -.panel-agent { display: flex; flex-direction: column; min-height: 0; background: #fafafa; } +.panel-feed { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + border-right: 1px solid var(--border); +} +.panel-agent { display: flex; flex-direction: column; min-height: 0; overflow: hidden; background: #fafafa; } .btn, .btn-sm { border: 1px solid var(--border); @@ -81,17 +103,69 @@ a.link:hover { text-decoration: underline; } .status-dot.waiting { background: var(--warn); } .status-dot.done { background: var(--ok); } -/* Books */ -.book-list { flex: 1; overflow-y: auto; padding: 6px; } -.book-item { - display: block; width: 100%; text-align: left; - border: 1px solid transparent; border-radius: 6px; - background: transparent; padding: 8px; cursor: pointer; +.panel-head-actions { display: flex; gap: 4px; align-items: center; } +.btn-sm.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); } + +/* Books — explorer breadcrumb + list */ +.path-bar { + display: flex; align-items: center; flex-wrap: nowrap; gap: 2px; + margin: 6px 8px 4px; padding: 6px 10px; + background: var(--bg); border: 1px solid var(--border); border-radius: 8px; + overflow-x: auto; font-size: 12px; line-height: 1.3; flex-shrink: 0; +} +.path-bar[hidden] { display: none; } +.path-seg { + display: inline-flex; align-items: center; gap: 4px; + border: none; background: transparent; padding: 2px 4px; border-radius: 4px; + color: var(--text); cursor: pointer; white-space: nowrap; font-size: 12px; +} +.path-seg:not(.current):hover { background: var(--surface); color: var(--accent); } +.path-seg.current { font-weight: 600; cursor: default; color: var(--text); } +.path-icon { font-size: 11px; color: var(--muted); } +.path-sep { color: var(--muted); padding: 0 2px; user-select: none; } + +.book-list { + flex: 1; overflow-y: auto; padding: 4px 8px 8px; + font-size: 12px; line-height: 1.35; position: relative; +} +.book-box-overlay { + position: fixed; z-index: 160; pointer-events: none; + border: 1px solid #3b82f6; background: rgba(59, 130, 246, 0.12); + border-radius: 2px; +} +.book-box-overlay[hidden] { display: none; } +.explorer-row { + display: flex; align-items: center; gap: 8px; width: 100%; min-height: 30px; + padding: 6px 8px; border: none; background: transparent; cursor: pointer; + text-align: left; color: var(--text); border-radius: 6px; +} +.explorer-row:hover { background: var(--bg); } +.explorer-row.active { background: var(--accent-soft); } +.explorer-row.active .explorer-name { color: var(--accent); font-weight: 600; } +.explorer-row.selected { background: #dbeafe; } +.explorer-row.selected .explorer-name { color: #1d4ed8; font-weight: 600; } +.explorer-row.selected.active { background: #bfdbfe; } +.explorer-name { + flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.explorer-meta { + flex-shrink: 0; max-width: 46%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 10px; color: var(--muted); +} +.explorer-del { + flex-shrink: 0; border: none; background: transparent; color: var(--muted); + cursor: pointer; padding: 0 4px; font-size: 13px; opacity: 0; +} +.explorer-row:hover .explorer-del { opacity: 1; } +.explorer-del:hover { color: var(--danger); } +.explorer-action .explorer-name { color: var(--accent); font-weight: 500; } +.explorer-section-label { + padding: 8px 8px 4px; font-size: 10px; font-weight: 600; + color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; +} +.explorer-hint { + margin: 4px 8px; font-size: 11px; color: var(--muted); } -.book-item:hover { background: var(--bg); } -.book-item.active { border-color: var(--accent); background: var(--accent-soft); } -.book-item-title { font-weight: 600; font-size: 13px; } -.book-item-sub { font-size: 11px; color: var(--muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sidebar-empty { padding: 12px; color: var(--muted); font-size: 13px; text-align: center; } /* Lifecycle */ @@ -122,28 +196,245 @@ body[data-lifecycle="play"] .panel-agent { background: #f7fef9; } .skill-card-desc { font-size: 12px; color: var(--muted); line-height: 1.4; } .skill-card-tag { font-size: 11px; color: var(--accent); margin-top: 6px; } -/* Feed */ -.message-feed { flex: 1; overflow-y: auto; padding: 12px; min-height: 0; } -.empty { color: var(--muted); text-align: center; padding: 40px 16px; } +/* Feed — SillyTavern 式对话气泡 */ +.message-feed { + flex: 1; + overflow-y: auto; + padding: 16px 20px; + min-height: 0; + display: flex; + flex-direction: column; + gap: 14px; + background: var(--bg); +} +.empty { color: var(--muted); text-align: center; padding: 40px 16px; align-self: center; max-width: 100%; } +.empty-intake { white-space: pre-wrap; text-align: left; max-width: 560px; margin: 40px auto; line-height: 1.6; } .msg { - border: 1px solid var(--border); border-radius: 8px; - padding: 10px 12px; margin-bottom: 8px; background: var(--surface); + display: flex; + flex-direction: column; + border: none; + border-radius: 0; + padding: 0; + margin: 0; + background: transparent; + max-width: 100%; } -.msg.user { border-left: 3px solid var(--warn); } -.msg.agent { border-left: 3px solid var(--accent); } -.msg.skill { border-left: 3px solid var(--ok); } -.msg.system { border-left: 3px solid var(--border); } -.msg.error { border-left: 3px solid var(--danger); } -.msg-head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 6px; font-size: 12px; } -.msg-tag { font-weight: 600; } -.msg-time { color: var(--muted); margin-left: auto; } -.msg-body { white-space: pre-wrap; word-break: break-word; line-height: 1.5; font-size: 13px; } +.msg-user-row { align-items: flex-end; } +.msg-assistant-row { align-items: flex-start; } + +.msg-bubble { + position: relative; + width: 90%; + max-width: 90%; + border-radius: 14px; + border: 1px solid var(--border); + background: var(--surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + box-sizing: border-box; +} +.msg-user-row .msg-bubble { + background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%); + border-color: #93c5fd; + border-bottom-right-radius: 4px; +} +.msg-assistant-row.msg.skill .msg-bubble, +.msg-assistant-row.msg.agent .msg-bubble { + border-bottom-left-radius: 4px; +} +.msg-assistant-row.msg.error .msg-bubble { + border-color: #fecaca; + background: #fef2f2; +} + +.msg-head { + display: flex; + gap: 8px; + align-items: center; + padding: 8px 12px 0; + font-size: 11px; + flex-wrap: wrap; +} +.msg-foot { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + padding: 4px 10px 8px; + font-size: 11px; + flex-wrap: wrap; +} +.msg-head-main { display: flex; gap: 6px; align-items: baseline; min-width: 0; flex: 1; } +.msg-subtitle { color: var(--muted); font-weight: normal; } +.msg-variant-badge { + font-size: 10px; color: var(--muted); padding: 0 4px; + border: 1px solid var(--border); border-radius: 4px; +} +.msg-bubble { cursor: context-menu; } + +.msg-action { + border: none; + background: transparent; + color: var(--muted); + border-radius: 4px; + padding: 2px 6px; + font-size: 11px; + cursor: pointer; + line-height: 1.4; +} +.msg-action:hover:not(:disabled) { color: var(--accent); background: rgba(37, 99, 235, 0.08); } +.msg-action-primary { + color: var(--accent); + font-weight: 600; +} +.msg-action-primary:hover:not(:disabled) { + background: var(--accent-soft); +} +.msg-action:disabled { opacity: 0.35; cursor: default; } +.msg-variant-nav { + display: inline-flex; + align-items: center; + gap: 2px; + margin-right: 2px; +} +.msg-variant-count { + min-width: 2.2em; + text-align: center; + color: var(--warn); + font-size: 11px; + font-weight: 600; +} +.msg-time { color: var(--muted); flex-shrink: 0; font-size: 10px; } +.msg-tag { font-weight: 600; color: var(--text); } +.msg-body { + white-space: pre-wrap; + word-break: break-word; + line-height: 1.55; + font-size: 14px; + padding: 10px 14px; +} +.msg-user-row .msg-body { padding-bottom: 4px; } +.msg-assistant-row .msg-head + .msg-body { padding-top: 4px; } .msg-pending { opacity: 0.85; } -/* Skill guide */ -.skill-guide { border-top: 1px solid var(--border); padding: 10px 12px; max-height: 180px; overflow-y: auto; background: #fafafa; } -.skill-guide-head { font-size: 12px; font-weight: 600; color: var(--muted); margin-bottom: 8px; } +.msg-thinking { + margin: 6px 10px 0; + font-size: 12px; + color: var(--muted); + border: 1px dashed var(--border); + border-radius: 6px; + background: #fafafa; +} +.msg-thinking summary { + cursor: pointer; + padding: 6px 8px; + user-select: none; +} +.msg-thinking-body { + margin: 0; + padding: 8px; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, "Cascadia Code", monospace; + font-size: 12px; + color: var(--text); + max-height: 240px; + overflow: auto; +} + +.msg-live-indicator { + margin-left: 8px; + font-size: 11px; + color: var(--accent); +} +.msg-live-body { min-height: 1.5em; } +.msg-live-section { margin-bottom: 8px; } +.msg-live-section:last-child { margin-bottom: 0; } +.msg-live-section header { + font-size: 11px; + color: var(--muted); + margin-bottom: 4px; +} +.msg-live-pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, "Cascadia Code", monospace; + font-size: 12px; + max-height: 320px; + overflow: auto; +} + +.msg-body-editing { padding: 8px 10px 10px; } +.msg-edit-input { + display: block; + width: 100%; + min-height: 72px; + max-height: 320px; + border: 1px solid var(--accent); + border-radius: 10px; + padding: 10px 12px; + font: inherit; + font-size: 14px; + line-height: 1.55; + resize: vertical; + background: var(--surface); + box-sizing: border-box; +} +.msg-user-row .msg-edit-input { + background: #fff; +} +.msg-edit-bar { + display: flex; + gap: 8px; + margin-top: 10px; + justify-content: flex-end; + align-items: center; + flex-wrap: wrap; +} +.msg-edit-bar .btn { + min-width: 4.5rem; +} +.msg-edit-hint { + margin: 6px 0 0; + font-size: 11px; + color: var(--muted); + text-align: right; +} +.msg-body-editing { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; +} +.msg.is-editing .msg-bubble { + box-shadow: 0 0 0 2px var(--accent-soft); + width: 90%; + max-width: 90%; +} +.msg-user-row.is-editing .msg-bubble { + width: 90%; + max-width: 90%; +} +@media (hover: none) { + .msg-actions { opacity: 1; } +} + +/* Skill guide (right panel tab) */ +.agent-tabs { + display: flex; gap: 0; border-bottom: 1px solid var(--border); + padding: 0 8px; background: var(--surface); +} +.agent-tab { + border: none; background: transparent; padding: 8px 12px; font-size: 12px; + cursor: pointer; color: var(--muted); border-bottom: 2px solid transparent; + margin-bottom: -1px; +} +.agent-tab:hover { color: var(--text); } +.agent-tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; } +.agent-tab-panel { flex: 1; min-height: 0; display: none; flex-direction: column; } +.agent-tab-panel.active { display: flex; } +.skill-guide-list { flex: 1; overflow-y: auto; padding: 8px 12px; font-size: 12px; } .skill-guide-item { display: grid; grid-template-columns: 8px 1fr; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; } .skill-guide-item:last-child { border-bottom: none; } .skill-guide-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border); margin-top: 4px; } @@ -152,6 +443,640 @@ body[data-lifecycle="play"] .panel-agent { background: #f7fef9; } .skill-guide-label { font-weight: 600; } .skill-guide-purpose { color: var(--muted); margin-top: 2px; } +.review-artifact-panel { + flex: 1; overflow-y: auto; padding: 10px 12px; font-size: 12px; min-height: 0; + display: flex; flex-direction: column; gap: 10px; +} +.review-artifact-head { font-weight: 600; font-size: 13px; } +.review-artifact-summary { color: var(--muted); line-height: 1.4; } +.review-artifact-body { + flex: 1; overflow-y: auto; white-space: pre-wrap; word-break: break-word; + line-height: 1.55; font-size: 12px; padding: 10px; border: 1px solid var(--border); + border-radius: 8px; background: var(--surface); min-height: 120px; +} +.review-artifact-actions { display: flex; gap: 8px; flex-shrink: 0; } +.review-artifact-actions .btn { flex: 1; } +.review-hint { + font-size: 12px; + color: var(--muted); + line-height: 1.45; + margin: 0 0 8px; +} + +/* 主对话验收卡 */ +.msg-review .msg-review-bubble { + width: 90%; + max-width: 90%; + padding: 14px 16px 16px; +} +.review-feed-summary { + margin: 0 0 8px; + font-size: 14px; + line-height: 1.45; + color: var(--text, inherit); +} +.review-json-block { + margin: 12px 0 14px; +} +.review-json-title { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + color: var(--muted); +} +.json-pretty, +.review-feed-plain { + margin: 0; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-2, rgba(0, 0, 0, 0.03)); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12.5px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + max-height: min(70vh, 640px); + overflow: auto; +} +.json-pretty code { + font-family: inherit; + font-size: inherit; +} +.json-key { color: #0b6e4f; } +.json-str { color: #1d4ed8; } +.json-num { color: #b45309; } +.json-lit { color: #7c3aed; } +.review-feed-actions { + margin-top: 4px; + max-width: 200px; +} +.review-feed-actions .btn { + flex: 1; +} +.msg-review .review-worker-set { + margin: 8px 0 4px; +} +.msg-review .ws-section h4 { + font-size: 13px; +} +.msg-review .ws-user-view { + gap: 12px; +} + +.ws-creation-flow .flow-brief { + margin: 0 0 8px; + color: var(--muted, #666); + font-size: 13px; +} +.ws-creation-flow .flow-status { + margin: 0 0 8px; + color: var(--muted, #666); + font-size: 12px; +} +.flow-steps { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.flow-step { + display: grid; + grid-template-columns: 1.5rem 1fr; + grid-template-rows: auto auto; + column-gap: 8px; + row-gap: 2px; + padding: 8px 10px; + background: var(--surface-2, rgba(0, 0, 0, 0.04)); + border-radius: 6px; + font-size: 13px; +} +.flow-occ, +.flow-id { + margin-left: 6px; + color: var(--muted, #666); + font-size: 12px; + font-weight: 400; +} +.flow-order { + grid-row: 1 / span 2; + align-self: center; + font-weight: 600; + color: var(--muted, #666); +} +.flow-name { + font-weight: 600; +} +.flow-deps { + font-size: 12px; + color: var(--muted, #666); +} + +.board-panel { + flex: 1; + overflow-y: auto; + padding: 10px 12px; + font-size: 12px; + display: flex; + flex-direction: column; + gap: 14px; +} +.board-section h4 { + margin: 0 0 6px; + font-size: 12px; + font-weight: 600; +} +.board-brief { + margin: 0; + padding: 8px; + background: var(--surface-2, rgba(0,0,0,0.04)); + border-radius: 6px; + white-space: pre-wrap; + word-break: break-word; + max-height: 180px; + overflow-y: auto; + font-size: 11px; + line-height: 1.45; +} +.board-tag-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.board-tag-item { + border: 1px solid var(--border, #ddd); + border-radius: 6px; + padding: 8px; +} +.board-tag-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.board-edit-btn { margin-left: auto; opacity: 0.7; } +.board-edit-btn:hover { opacity: 1; } +.board-badge { + font-size: 10px; + color: var(--accent); + flex-shrink: 0; +} +.board-tag-meta { + color: var(--muted); + font-size: 10px; + margin-top: 2px; +} +.board-tag-preview { + margin-top: 6px; + line-height: 1.4; + color: var(--text); + word-break: break-word; +} +.board-archived { + color: var(--muted); + margin: 0; + font-size: 11px; +} +.empty-sm { + color: var(--muted); + margin: 0; + font-size: 12px; +} + +.review-worker-set { display: flex; flex-direction: column; gap: 10px; flex: 1; overflow-y: auto; min-height: 0; } +.worker-set-section h4 { margin: 0 0 6px; font-size: 12px; color: var(--muted); font-weight: 600; } +.worker-set-section p { margin: 0; line-height: 1.5; } +.worker-set-cards { display: flex; flex-direction: column; gap: 8px; } +.worker-set-card { + border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; background: var(--surface); +} +.worker-set-card-head { font-weight: 600; margin-bottom: 4px; } +.worker-set-duty { line-height: 1.45; } +.worker-set-when { color: var(--muted); font-size: 11px; margin-top: 4px; } +.worker-set-gap { color: var(--warn); font-size: 11px; margin-top: 4px; } +.worker-set-pres { margin-top: 6px; font-size: 11px; } +.worker-set-pres-label, .worker-set-hint-label { color: var(--muted); margin-right: 4px; } +.worker-set-kv { margin: 4px 0 0; padding-left: 16px; } +.worker-set-list { margin: 0; padding-left: 18px; line-height: 1.5; } +.worker-set-open { color: var(--warn); } +.worker-set-hint-reason { color: var(--muted); font-size: 11px; margin-top: 4px; } +.worker-gap { color: var(--warn); font-size: 12px; } +.review-parse-error { + color: var(--danger); + font-size: 13px; + border: 1px solid color-mix(in srgb, var(--danger) 45%, transparent); + background: color-mix(in srgb, var(--danger) 10%, var(--surface)); + border-radius: 10px; + padding: 12px 14px; + line-height: 1.5; +} +.review-parse-error-title { + font-weight: 700; + font-size: 14px; + margin-bottom: 6px; +} +.review-parse-error-hint { + margin: 8px 0 0; + color: var(--muted); + font-size: 12px; +} +.review-raw-yaml summary { cursor: pointer; color: var(--muted); font-size: 11px; user-select: none; } +.review-raw-yaml pre { max-height: 160px; margin-top: 6px; } + +.msg-assistant-row.msg.questions .msg-bubble { + border: 2px solid color-mix(in srgb, var(--accent, #2a6) 55%, var(--border)); + background: color-mix(in srgb, var(--accent, #2a6) 8%, var(--surface)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #2a6) 20%, transparent); +} +.msg-questions-banner { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--accent, #1a7a4c); + margin-bottom: 6px; +} +.msg-q-list { margin: 0; padding-left: 1.25rem; line-height: 1.55; } +.msg-q-list li { margin: 0.35rem 0; } +.msg-q-lead { margin: 0; line-height: 1.55; font-size: 14px; } + +/* Cursor 式询问卡:贴在消息流下方,占满协调列宽度,不遮盖历史 */ +.questions-card-host { + flex: 0 1 auto; + width: 100%; + box-sizing: border-box; + padding: 0 16px 8px; + border-top: 1px solid var(--border); + background: var(--surface, #fff); + min-height: 0; +} +.questions-card-host[hidden] { display: none !important; } +/* 开启时占 feed 剩余高度的上限,不把整页撑出视口 */ +.questions-card-host.is-open { + display: flex; + flex-direction: column; + max-height: 48%; + overflow: hidden; +} +.qcard { + position: relative; + display: flex; + flex-direction: column; + flex: 1 1 auto; + width: 100%; + min-height: 0; + max-height: 100%; + margin: 6px 0 0; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface, #fff); + overflow: hidden; +} +/* 悬浮控件层:不占文档流高度 */ +.qcard-float { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; +} +.qcard-pager { + position: absolute; + top: 8px; + right: 8px; + display: flex; + align-items: center; + gap: 2px; + padding: 2px 4px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + background: color-mix(in srgb, var(--surface, #fff) 82%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: 0 1px 3px color-mix(in srgb, #000 8%, transparent); + font-size: 11px; + color: var(--muted); + pointer-events: auto; +} +.qcard-page { + min-width: 2.4em; + text-align: center; + font-variant-numeric: tabular-nums; + font-weight: 600; + color: var(--text); +} +.qcard-nav { + border: none; + background: transparent; + border-radius: 999px; + width: 22px; + height: 22px; + cursor: pointer; + line-height: 1; + font-size: 14px; + padding: 0; + color: var(--muted); +} +.qcard-nav:hover:not(:disabled) { + background: color-mix(in srgb, var(--muted, #888) 12%, transparent); + color: var(--text); +} +.qcard-nav:disabled { opacity: 0.35; cursor: default; } +.qcard-skip { + position: absolute; + bottom: 8px; + right: 8px; + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + background: color-mix(in srgb, var(--surface, #fff) 82%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: 0 1px 3px color-mix(in srgb, #000 8%, transparent); + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + color: var(--muted); + cursor: pointer; + pointer-events: auto; +} +.qcard-skip:hover { + color: var(--text); + border-color: var(--border-strong, #999); +} +.qcard-body { + flex: 1 1 auto; + min-height: 0; + padding: 10px 12px; + overflow: auto; /* 仅卡内滚动;页面本身不滚 */ +} +.qcard.has-pager .qcard-body { padding-top: 36px; } +.qcard.has-skip .qcard-body { padding-bottom: 40px; } +.qcard-q { margin-bottom: 0; } +.qcard-q + .qcard-q { margin-top: 14px; } +.qcard-assessment { + margin-bottom: 10px; + padding: 8px 10px; + border-left: 3px solid var(--border-strong, #c4b5a0); + background: color-mix(in srgb, var(--panel, #fff) 88%, var(--muted, #888) 12%); + font-size: 12.5px; + line-height: 1.5; + white-space: pre-wrap; + color: var(--text); +} +.qcard-optional-note { + margin: 0 0 8px; + font-size: 12px; + color: var(--muted); + line-height: 1.4; +} +.qcard-prompt { font-size: 14px; font-weight: 600; margin-bottom: 8px; line-height: 1.45; } +.qcard-options { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.qcard-opt { + display: grid; + grid-template-columns: 36px 1fr; + gap: 8px; + align-items: start; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid transparent; +} +.qcard-opt.is-selected { + border-color: color-mix(in srgb, var(--accent, #2a6) 45%, var(--border)); + background: color-mix(in srgb, var(--accent, #2a6) 8%, var(--surface)); +} +.qcard-letter { + width: 32px; + height: 28px; + border-radius: 6px; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--surface, #fff) 80%, #ddd); + font-weight: 700; + font-size: 12px; + cursor: pointer; +} +.qcard-opt.is-selected .qcard-letter { + background: var(--accent, #2a6); + border-color: var(--accent, #2a6); + color: #fff; +} +.qcard-label { + min-height: 28px; + padding: 4px 6px; + border-radius: 6px; + font-size: 13px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + flex: 1; + line-height: 1.45; + outline: none; +} +.qcard-label:focus { + background: color-mix(in srgb, var(--surface, #fff) 70%, #e8e4dc); + box-shadow: inset 0 0 0 1px var(--border); +} +.qcard-label.is-readonly { color: var(--muted); } +.qcard-other-input { + width: 100%; + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 8px; + font-size: 13px; + background: var(--surface, #fff); +} + +.composer-ask-banner { + margin: 0 0 10px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--accent, #2a6) 40%, var(--border)); + background: color-mix(in srgb, var(--accent, #2a6) 10%, var(--surface)); + font-size: 13px; + line-height: 1.45; +} +.composer-ask-banner strong { display: block; margin-bottom: 4px; } +.skill-guide-runs { color: var(--muted); font-size: 11px; font-weight: normal; } + +.worker-set-user-panel { + flex-shrink: 0; max-height: 45%; overflow-y: auto; + border-bottom: 1px solid var(--border); padding: 8px 10px; font-size: 12px; +} +.worker-set-user-panel[hidden] { display: none; } + +.ws-user-view { display: flex; flex-direction: column; gap: 10px; } +.ws-section h4 { margin: 0 0 6px; font-size: 12px; color: var(--muted); font-weight: 600; } +.ws-section p { margin: 0; line-height: 1.5; } +.ws-muted { color: var(--muted); font-size: 11px; } +.ws-headline p { font-weight: 500; } +.ws-list, .ws-kv { margin: 0; padding-left: 18px; line-height: 1.5; } +.ws-worker-cards { display: flex; flex-direction: column; gap: 8px; } +.ws-worker-card { + border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; background: var(--surface); +} +.ws-worker-card.gap { border-color: var(--warn); } +.ws-worker-head { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; } +.ws-worker-order { + display: inline-flex; align-items: center; justify-content: center; + width: 18px; height: 18px; border-radius: 50%; background: var(--accent-soft); + color: var(--accent); font-size: 11px; font-weight: 700; +} +.ws-worker-name { font-weight: 600; flex: 1; } +.ws-worker-id { font-size: 11px; color: var(--muted); display: block; margin-bottom: 4px; } +.ws-badge { font-size: 10px; padding: 1px 6px; border-radius: 4px; } +.ws-badge-ok { background: #dcfce7; color: var(--ok); } +.ws-badge-warn { background: #fef9c3; color: var(--warn); } +.ws-badge-review { background: #e0f2fe; color: #0369a1; } +.ws-badge-continue { background: #f3f4f6; color: #4b5563; } +.ws-row { display: grid; grid-template-columns: 72px 1fr; gap: 6px; margin: 4px 0; line-height: 1.45; } +.ws-label { color: var(--muted); font-size: 11px; } +.ws-gap { color: var(--warn); font-size: 11px; margin: 4px 0; } +.ws-pres { margin: 6px 0; } +.ws-context-block { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; } +.ws-context-tier { background: var(--bg); border-radius: 6px; padding: 6px 8px; } +.ws-tag-order { margin: 4px 0 0; padding-left: 20px; line-height: 1.55; } +.ws-tag-order li { margin: 2px 0; } +.ws-tag-order code { font-size: 11px; } +.ws-tag-note { display: block; color: var(--muted); font-size: 10px; margin-left: 2px; } +.ws-tag-filled { color: var(--ok); margin-left: 4px; font-size: 10px; } +.ws-tag-pending { color: var(--muted); margin-left: 4px; font-size: 10px; } +.ws-tag-empty { color: var(--muted); font-size: 11px; margin-top: 2px; } +.ws-context-hint { margin: 0 0 0.5rem; font-size: 12px; } +.ws-context-cards { display: flex; flex-direction: column; gap: 8px; } +.ws-context-card { + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; + background: var(--panel, #fff); +} +.ws-context-card.kind-fixed { border-left: 3px solid var(--accent); } +.ws-context-card.kind-resident { border-left: 3px solid #0d9488; } +.ws-context-card.kind-board { border-left: 3px solid #94a3b8; } +.ws-context-card-head { + display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 4px; +} +.ws-context-card-title { font-weight: 600; flex: 1; } +.ws-context-preview { + margin: 0 0 6px; font-size: 12px; color: var(--text); line-height: 1.45; + white-space: pre-wrap; max-height: 4.5em; overflow: hidden; +} +.ws-mount-row { + display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; margin-bottom: 4px; +} +.ws-mount-summary { font-size: 13px; font-weight: 500; } +.ws-mount-chips { display: flex; flex-wrap: wrap; gap: 4px; } +.ws-mount-chip { + display: inline-flex; align-items: center; gap: 4px; + font-size: 11px; padding: 2px 7px; border-radius: 4px; + background: var(--bg); border: 1px solid var(--border); +} +.ws-mount-how { color: var(--muted); font-size: 10px; } +.ws-badge-fixed { background: #e0e7ff; color: #3730a3; } +.ws-badge-resident { background: #ccfbf1; color: #0f766e; } +.ws-badge-board { background: #f1f5f9; color: #475569; } +.ws-badge-focus { + background: #fef3c7; + color: #92400e; + font-weight: 700; +} +.ws-focus-banner { + border: 1px solid #f59e0b; + border-radius: 10px; + padding: 10px 12px; + background: linear-gradient(180deg, #fffbeb 0%, var(--panel, #fff) 100%); + box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.12); +} +.ws-focus-banner-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} +.ws-focus-mode { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + padding: 2px 8px; + border-radius: 999px; + background: #f59e0b; + color: #111827; +} +.ws-focus-label { font-weight: 700; font-size: 15px; } +.ws-focus-id { font-size: 11px; color: var(--muted); } +.ws-focus-hint { + margin: 0 0 8px; + font-size: 12px; + color: #92400e; + line-height: 1.45; +} +.ws-focus-body { + border-radius: 8px; + background: #fff; + border: 1px dashed #f59e0b; + padding: 8px 10px; + max-height: 280px; + overflow: auto; +} +.ws-focus-body-text, +.ws-focus-body-json { + margin: 0; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; +} +.ws-context-card.ws-context-focus, +.ws-worker-card.ws-worker-focus { + border-color: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25); + background: #fffbeb; +} +.ws-context-card.ws-dimmed, +.ws-worker-card.ws-dimmed { + opacity: 0.45; +} +.ws-unit-current { + background: #fffbeb; + border-radius: 6px; + padding: 4px 6px !important; + border: 1px solid #fcd34d; +} +.ws-section-focus-context h4 { + color: #92400e; +} +.ws-writes { margin-top: 6px; line-height: 1.6; } +.ws-writes code { font-size: 11px; margin-right: 4px; } +.ws-inferred { color: var(--muted); font-size: 10px; margin-top: 4px; font-style: italic; } +.ws-flow { display: flex; flex-direction: column; gap: 4px; } +.ws-flow-edge { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; line-height: 1.5; } +.ws-flow-arrow { color: var(--muted); } +.ws-task-list { list-style: none; margin: 0; padding: 0; } +.ws-task { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 4px 0; border-bottom: 1px solid var(--border); } +.ws-task:last-child { border-bottom: none; } +.ws-task-status { font-size: 10px; padding: 1px 5px; border-radius: 4px; background: var(--bg); } +.ws-task-filled .ws-task-status { color: var(--ok); } +.ws-task-skipped .ws-task-status { color: var(--muted); } +.ws-task-planned .ws-task-status { color: var(--accent); } +.ws-open { color: var(--warn); } + +/* Context menu */ +.context-menu { + position: fixed; z-index: 1000; min-width: 120px; + margin: 0; padding: 4px; list-style: none; + background: var(--surface); border: 1px solid var(--border); + border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.12); +} +.context-menu-item { + display: block; width: 100%; text-align: left; + border: none; background: transparent; padding: 8px 12px; + font-size: 13px; cursor: pointer; border-radius: 4px; +} +.context-menu-item:hover { background: var(--bg); } +.context-menu-danger { color: var(--danger); } +.context-menu-danger:hover { background: #fef2f2; } + /* Agent panel */ .agent-focus { padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 13px; } .agent-focus-who { font-weight: 600; } @@ -162,16 +1087,20 @@ body[data-lifecycle="play"] .panel-agent { background: #f7fef9; } .tool-trace-item { padding: 4px 0; border-bottom: 1px dashed var(--border); } .tool-trace-item:last-child { border-bottom: none; } .tool-trace-name { font-family: ui-monospace, monospace; font-weight: 600; color: var(--accent); } -.agent-timeline { flex: 1; overflow-y: auto; padding: 8px 12px; font-size: 12px; } +.agent-timeline { flex: 1; overflow-y: auto; padding: 8px 12px; font-size: 12px; min-height: 0; } .timeline-item { padding: 6px 0; border-bottom: 1px solid var(--border); } .timeline-empty { color: var(--muted); } -/* Composer */ +/* Composer — 高度随内容,但有上限,避免挤爆视口 */ .composer { border-top: 1px solid var(--border); padding: 10px 12px; min-height: var(--composer-min); + max-height: min(36vh, 280px); + overflow-x: hidden; + overflow-y: auto; background: var(--surface); + flex-shrink: 0; } .composer-waiting, .composer-idle { padding: 12px; text-align: center; color: var(--muted); font-size: 13px; @@ -179,8 +1108,14 @@ body[data-lifecycle="play"] .panel-agent { background: #f7fef9; } .composer-hint { margin: 0 0 8px; font-size: 12px; color: var(--muted); } .composer-form { display: flex; gap: 8px; align-items: flex-end; } .composer-form textarea { - flex: 1; min-height: 40px; max-height: 120px; resize: vertical; - border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; + flex: 1; min-height: 44px; max-height: 96px; resize: none; + border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px; + line-height: 1.5; background: var(--surface); +} +.composer-form textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); } .composer-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; } .composer-actions .btn-primary { flex: 1; } @@ -194,8 +1129,40 @@ body[data-lifecycle="play"] .panel-agent { background: #f7fef9; } .intake-value.empty { color: var(--muted); } /* Dialog */ -.dialog { border: 1px solid var(--border); border-radius: 10px; padding: 16px; max-width: 400px; } -.dialog-wide { max-width: 520px; } +.dialog { + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + max-width: 400px; + background: var(--panel, #fff); + color: inherit; +} +.dialog-wide { + width: min(920px, 94vw); + max-width: min(920px, 94vw); + max-height: min(88vh, 900px); +} +.context-trace-form { + display: flex; + flex-direction: column; + gap: 10px; + max-height: min(84vh, 860px); +} +.context-trace-body { + margin: 0; + padding: 12px; + overflow: auto; + flex: 1; + min-height: 240px; + max-height: min(60vh, 640px); + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + background: color-mix(in srgb, var(--panel, #fff) 92%, #000 8%); + border: 1px solid var(--border); + border-radius: 8px; +} .dialog::backdrop { background: rgba(0,0,0,0.3); } .dialog h2 { margin: 0 0 8px; font-size: 16px; } .dialog-desc { margin: 0 0 12px; font-size: 13px; color: var(--muted); } @@ -203,9 +1170,6 @@ body[data-lifecycle="play"] .panel-agent { background: #f7fef9; } .field input { padding: 8px; border: 1px solid var(--border); border-radius: 6px; } .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; } .dialog-head-row { display: flex; justify-content: space-between; align-items: center; } -.saves-new { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; } -.saves-list { max-height: 240px; overflow-y: auto; font-size: 13px; } -.save-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); } @media (max-width: 900px) { .layout { grid-template-columns: 1fr; }