diff --git a/docs/briefs/artifact-card-coverage.md b/docs/briefs/artifact-card-coverage.md index b302768..58a25cb 100644 --- a/docs/briefs/artifact-card-coverage.md +++ b/docs/briefs/artifact-card-coverage.md @@ -4,7 +4,7 @@ | 产物/技能 | 美化程度 | 渲染入口 | |-----------|----------|----------| -| 美学纲领与交互范式 | **专用 mosaic**(设定逻辑 / 交互范式 / 美学纲领) | `renderSpecialtyBodyHtml` → aesthetics | +| 美学纲领与交互范式 | **左右分栏**:左契约(美学纲领+交互范式)/ 右诊断(设定逻辑) | `renderSpecialtyBodyHtml` → aesthetics | | 实现机制 | **专用卡**(支撑点) | `renderMechanismBodyHtml` | | 舞台骨架 | **专用卡** | `renderWorldBlueprintBodyHtml` | | 生成规则 | **专用卡** | `renderGenerationRulesBodyHtml` | diff --git a/docs/briefs/capability-authoring-brief.md b/docs/briefs/capability-authoring-brief.md index a3b8926..447bb85 100644 --- a/docs/briefs/capability-authoring-brief.md +++ b/docs/briefs/capability-authoring-brief.md @@ -356,7 +356,8 @@ task 必须写:禁止重复同一开场;在首答上补洞。 ## 4.8 追问(`probe`)通用要求 -- 一轮 askUser **1~2** 点。 +- 一轮 **1~2** 点,只写进产物 `追问`(建议选项 + 示例)。 +- 程序会把 `追问` 挂到询问卡;**禁止**同一问再抄一份顶层 `askUser`(`askUser` 留 `null`)。 - 优先:可直接采用或微调的完整句选项 / 短场景。 - 能推断则先写入再复述;只问影响本步核心且不能瞎填的点。 - 依赖产物已钉死的内容禁止重问。 @@ -397,7 +398,7 @@ task 必须写:禁止重复同一开场;在首答上补洞。 |----|------| | `正文` | **产物主体**。技能自定内部结构(如美学步=设定逻辑 + 交互范式 + 美学纲领);须含「详细各个方面」。禁止整份只交散文。 | | `自评` | **自评评分**。`维度[]` 名目按技能定(美学步常用:交互范式 / 美学纲领 / 整体协调);**`分数` 为 0–10 十分制**(可一位小数;高质约 8–10);可含 `薄弱点`。禁止用 0–100 百分数。 | -| `追问` | **示例 + 建议选项**。给用户的下一问;`题目` 可空数组。与程序 askUser 可并存,但产物里要留结构化题面。 | +| `追问` | **示例 + 建议选项**。给用户的下一问(程序挂询问卡);`题目` 可空数组。禁止同一问再写入顶层 `askUser`。 | | 规则 | 说明 | |------|------| diff --git a/docs/context-fragment-design.md b/docs/context-fragment-design.md index 11ed848..869f83d 100644 --- a/docs/context-fragment-design.md +++ b/docs/context-fragment-design.md @@ -38,10 +38,20 @@ | `schema` / `技能` / `brief` / `mount` / `稳变` | 索引与挂载;不写 `order` | | `正文` | **产物主体**(技能自定内部维度,但必须有「各个方面」) | | `自评` | **自评评分**(`维度[]` + 可选薄弱点;维度名按技能) | -| `追问` | **导语 + 题目**(建议选项 / 示例);可无题则 `题目: []` | +| `追问` | **导语 + 题目**(建议选项 / 示例);可无题则 `题目: []`。程序挂询问卡;不要再抄一份 `askUser` | | `开放问题` | 未结构化的残项(可选) | -美学步正文:`设定逻辑` + `交互范式` + `美学纲领`(见 `aesthetics-interaction`)。 +美学步正文:`设定逻辑` + `交互范式` + `美学纲领`(见 `aesthetics-interaction`)。 + +**验收卡排版约定(美学步已落地,其它节点逐步对齐)**: + +| 栏 | 放什么 | 美学步对应 | +|----|--------|------------| +| **左 · 产物** | 最终希望插入游玩上下文的契约正文 | `美学纲领`、`交互范式` | +| **右 · 评估** | 诊断、完备度、待探、区域化等给人验收用的 | `设定逻辑`(参与/内容维度上的结论·依据·完备度) | + +外壳另有 `自评`(顶栏分数 pill)与 `追问`(询问卡)。 +**存盘**:三块都在同一 `正文` 里。**注入**:默认 `full` 时整份正文都会进模型——若日后只要左栏进游玩,再改投影,不靠藏 UI。 实现机制正文:`依据的核心体验` + `支撑点[]` + `支撑点关系[]` + `覆盖检验`(见 `mechanism`)。 舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。 生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段);自评=必要性/属性妥当/格式准确。 diff --git a/docs/play-presentation-shells.md b/docs/play-presentation-shells.md index 4d34b74..bdb050c 100644 --- a/docs/play-presentation-shells.md +++ b/docs/play-presentation-shells.md @@ -10,14 +10,25 @@ ```text 固定壳(本仓库预置,七种) - → 创作期:选一个 shell_id - → 只改允许的微调轴(字段、显示名、开关、tone_chrome…) + → 按「要同时看见什么」选 shell_id(不是按好看) + → 只改允许的微调轴 → 禁止改分区骨架、禁止发明第八种布局 -游玩期:往壳的区域里灌数(present.v1 / 投影 / settlement) +游玩期:往壳区域灌数;正文随 600~3000 字撑高滚动 ``` -「正文组成」= **壳适配单**,不是 UI 创意稿。 -用户看见的差异应来自**同壳不同绑定**,而不是每局一套新布局。 +选壳依据(同时可见关系): + +| 同时要看见 | 壳 | +|------------|-----| +| 只要正文 | `prose` | +| 正文 + 扫状态 | `chat_monitor` | +| 大正文 + 弱状态 | `spotlight` | +| 场面 + 交互物 + 行动 | `turn_panel` | +| 正文 ‖ 线索对照 | `split_board` | +| 局面 + 选项 | `choice_dock` | +| 章正文 + 进度 | `chapter_reader` | + +预览页 `/shells.html`:主工作面是壳画布;侧栏只换壳;长度/语气在顶栏。 ## 2. 信息层级与长文容量 diff --git a/docs/ui-glossary.md b/docs/ui-glossary.md index a90cf29..64889b9 100644 --- a/docs/ui-glossary.md +++ b/docs/ui-glossary.md @@ -87,7 +87,7 @@ | `设计.worker集` | **运行规格** | 剧本(旧,规格部分) | 可进游玩的声明截面。 | | Blackboard | **黑板** | 上下文板 | — | | Artifact | **产物** | — | 待验收输出。 | -| Accept / Review | **验收** / **接受** | 人工审批 | 按钮用「接受」;阶段说明用「验收」。 | +| Accept / Review | **验收** / **接受** | 人工审批 | 技能产物按钮用「接受」;工作流计划用「确认编排」;开场用「选定此开场」。阶段说明:技能步「验收」,编排「核对」。 | | Intake | **需求描述** | 启动填空 | — | | Burst | **本轮调度** | — | 少对用户说 burst。 | | Questions card | **询问卡** | Questions | 详见 `ui-design.md` §8。 | @@ -177,13 +177,15 @@ ## 7. 等待态 / 焦点(对用户) -| `waitingReason.kind` | 状态摘要(短) | 焦点动作 | +| `waitingReason.kind` | 状态摘要(短) | 焦点动作 / 底栏主按钮 | |----------------------|----------------|----------| -| `intake` | 描述需求 | 描述创作需求 | -| `input` | 补充说明 | 补充说明 / 回答追问 | -| `worker_questions` | 回答提问 | 回答 · {中文执行单元名} | -| `approve_step` | 确认执行 | 建议调用 {中文名} | -| `review_artifact` | 验收产物 | 验收产物 | +| `intake` | 描述需求 | 发送 | +| `input` | 补充说明 | 发送 / 补充并发送 | +| `worker_questions` | 答题 | 补充并发送 | +| `approve_step` | 确认 | 同意并开始 | +| `review_artifact` | 视产物(勿一律「验收」) | **流程编排** → 确认编排;技能步 → 接受;开场 → 选定此开场。旁挂「按意见改编排 / 按意见修改」 | +| `next_intent` | 说话 | 继续(可留空) | +| `revision` | 说话 | 按意见修改 | --- diff --git a/docs/world-simulator-modules.md b/docs/world-simulator-modules.md index 8c2a68b..24ad0b1 100644 --- a/docs/world-simulator-modules.md +++ b/docs/world-simulator-modules.md @@ -28,7 +28,7 @@ | 块 id | 必填 | 用途 | |-------|------|------| | `meta` | 建议 | YAML:name / id / artifact / declaration / when / when_not / boundary | -| `opening` | 可选 | **默认问题**正文;程序发给用户,不经 LLM | +| `opening` | 可选 | **默认问题**正文。开局:配方 `steps` 已预置进「设计.创作流程」,用户首句即坐在 DAG 第一步;若「用户.需求」已有内容,程序**跳过**再抛 opening,直接进本步 LLM | | `task` | 是 | 本步任务与验收边界 | | `principles` | 建议 | 原则 | | `probe` | 建议 | 追问策略 | @@ -104,13 +104,24 @@ declaration: … 配方**不要**重复罗列各能力调用条件;那是能力 meta 的职责。旧字段 `hint` 仍可读作兜底。 -### 默认问题节奏(通用) +### 节点循环(通用) ```text -程序发 opening → 用户首答 → LLM(opening + 首答 + 切割后的方法块 + 依赖) +开局选配方 → 坐到配方预置的第一个节点 + → 节点 opening → 用户首批输入 + → 节点要求 + 用户输入 → 产物 + 追问 + → 用户选选项 / 写意见 →「按意见修改」 + (产物 + 追问 + 选项 + 意见 + 节点要求一并重跑)→ 新产物 + → 循环直到「接受」 + →「下一步想写什么」(可留空,交给工作流计划 DAG) + → 展示下一节点并确认开干(可在此钉编排参数,如生成规则的 target) + → 下一节点 opening / design-step … +直到创作收口 ``` -有编排参数的能力:先由 design-flow askUser 钉 params → 用户认可 DAG → design-step 注入【本步参数】执行;勿把「生成什么」推迟到执行期。 +「按意见修改」只重跑当前节点,不得跳到「下一步想写什么」。底栏空且未选追问时 Enter=接受;已选追问时 Enter=按意见修改。点按钮仍只做按钮自己的事。 + +有编排参数的能力:可在确认开干时钉齐 params;勿把「生成什么」拖到执行中途才问。 --- diff --git a/package.json b/package.json index 3207fc4..9279564 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "phase-demo": "tsx src/cli/phase-demo.ts", "phase-script": "tsx src/cli/phase-demo.ts --script --auto", "web": "tsx src/server/web-server.ts", + "web:watch": "tsx watch --clear-screen=false --include \"./src/**/*.ts\" --include \"./skills/**\" src/server/web-server.ts", "electron": "electron ." }, "devDependencies": { diff --git a/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md b/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md index b9c5138..553ae80 100644 --- a/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md +++ b/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md @@ -54,8 +54,10 @@ feeds: design_only 业务字段全部放在「正文」内;须含 schema / 技能 / brief;建议填 mount 与稳变(本步不写数字 order)。 一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问,不要先交「半份交互」再开「半份美学」。 +用户已答过追问:把答复写入正文三块,`追问.题目` 优先 `[]`;禁止在「交互范式」与「美学纲领」之间轮流补洞拖验收。核心体验与权限边界够用即可收。 若程序已发出默认问题:用户首答在「用户.worker答复」,开场白在「创作.能力开场白」。禁止重复同一开场;在首答基础上补洞、追问、产出。 +开局通常已由配方写入「设计.创作流程」第一步;若「用户.需求」已有首句:程序会跳过默认问题——首句即本步对话,只补真正缺口。 ``` ## principles @@ -74,9 +76,11 @@ feeds: design_only ## probe ```probe -用户首句或黑板已有的直接采纳。一次 askUser 1~2 点,优先具体选项/短场景。 +用户首句或黑板已有的直接采纳。一次只在产物「追问」写 1~2 点(建议选项 + 示例);顶层 askUser 留 null,不要同一问写两遍。 +已有一轮追问答复或核心体验+权限边界已可执行:`追问.题目` 用 `[]`,让用户验收。禁止这轮问交互、下轮问美学地来回打转。 【默认问题已覆盖】变造(原型+变点)、代入与否、最想感受到的核心。首答后按缺口补,勿重问已答清的。 +若开场已跳过(用户.需求已有首句):把首句当默认问题的答复,只补真正缺口。 【参与 · 结构性】 - 用户与 :代入程度 / 情感距离 / 控制期待 / 多角色 @@ -205,7 +209,7 @@ feeds: design_only 2. **公共三段**:`正文`=产物主体;`自评`=完备度打分(**分数 0–10 十分制,禁止百分数**);`追问`=导语+题目(建议选项/示例)。禁止用散文代替这三段。 3. **本技正文三块**:`设定逻辑`(参与/内容维度/区域/复杂性/完备度)+ `交互范式` + `美学纲领`。未知省略或「未定」,禁止编造百科。 4. `mount` 建议挂主世界层+转述;**禁止**写数字 `order`。`稳变` 定稿后多为 `stable`。 -5. `追问.题目` 一次宜 1~3 题;有建议选项;允许「其它」。已答清的不要再问。 +5. `追问.题目` 一次宜 1~3 题;有建议选项与示例;允许「其它」。已答清的不要再问。题目只写在「追问」,askUser 为 null。 6. summary:`美学纲领与交互范式 · {brief 缩略}`。 ## checklist @@ -216,7 +220,7 @@ feeds: design_only - [ ] 未写 order、未输出执行单元列表? - [ ] brief 能点题核心体验?变造/代入/满足来源清楚或已标未定? - [ ] 交互范式权限与美学纲领互相保护、不矛盾? -- [ ] 追问只打薄弱点,带建议选项? +- [ ] 追问只打真正薄弱点,带建议选项?已答过则题目为空、准备验收? - [ ] 有没有为「完整」强行填充百科或偏离用户核心? ``` diff --git a/skills/dialogue/world-simulator/modules/catalog.yaml b/skills/dialogue/world-simulator/modules/catalog.yaml index c8453d6..64b66d6 100644 --- a/skills/dialogue/world-simulator/modules/catalog.yaml +++ b/skills/dialogue/world-simulator/modules/catalog.yaml @@ -46,11 +46,11 @@ modules: - key: target label: 生成对象 required: true - hint: 本轮为哪一类内容建规则;编排时 askUser 给选项+其它 + hint: 本轮为哪一类内容建规则;编排时 askUser 给选项+其它;用户改对象时必须同步改 rule_id 与步骤 id - key: rule_id label: 规则 id required: false - hint: 建议英文 kebab-case;可执行时再最终钉死 + hint: 英文 kebab-case,须与 target 对应(女租客→female-tenants,丧尸→zombies);改对象时禁止沿用旧 id - key: lifecycle_intent label: 生命周期意图 required: false diff --git a/skills/dialogue/world-simulator/modules/generation-rules/prompt.md b/skills/dialogue/world-simulator/modules/generation-rules/prompt.md index 5099ad2..6948672 100644 --- a/skills/dialogue/world-simulator/modules/generation-rules/prompt.md +++ b/skills/dialogue/world-simulator/modules/generation-rules/prompt.md @@ -68,6 +68,7 @@ feeds: gm,auditor 4. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。 5. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。 6. `rule_id`:params 已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。 + 若本步对象与 params 不一致(用户中途改了对象),以最新对象为准并重钉对应英文 rule_id,禁止沿用旧 id。 7. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。 若程序已发默认问题:禁止重复开场;在首答上补洞。 diff --git a/skills/dialogue/world-simulator/modules/mechanism/prompt.md b/skills/dialogue/world-simulator/modules/mechanism/prompt.md index c40c29d..a556735 100644 --- a/skills/dialogue/world-simulator/modules/mechanism/prompt.md +++ b/skills/dialogue/world-simulator/modules/mechanism/prompt.md @@ -32,6 +32,17 @@ feeds: gm ## opening ```opening +核心体验已有方向时,用几句话想想:它具体靠什么撑住?(想到什么写什么,不必整齐;点名即可,不必论证来源或补起源史。) + +1. 这体验主要靠哪些东西成立? + 角色的某一面、一段关系、世界条件、规则或情境……用你自己的词点名。 + 例:丈夫会把异常证据合理化;力量越用越侵蚀自我;村里每年必须献祭才能过冬。 + +2. 拿掉或改弱哪一样,核心感觉会明显变味甚至垮掉? + 一句话即可——只点「缺了就不对」的,装饰和以后再展开的先别列。 + +3. 若已有具体画面或作品参照,它主要指哪一切面在撑体验? + 例:信息差、无法撤销的前提、身份代价、循环反馈……不必复述整部设定。 ``` ## task diff --git a/skills/dialogue/world-simulator/modules/reply-format/prompt.md b/skills/dialogue/world-simulator/modules/reply-format/prompt.md index 2fff466..4dae2da 100644 --- a/skills/dialogue/world-simulator/modules/reply-format/prompt.md +++ b/skills/dialogue/world-simulator/modules/reply-format/prompt.md @@ -34,14 +34,14 @@ feeds: narrator ```opening 本局在固定呈现壳上做适配(不是设计新界面)。 -0. 七选一壳(主面积给谁): - · prose — 纯散文(正文≈全屏) - · chat_monitor — 对话 + 顶栏芯片 - · spotlight — 场面主视(正文最大) - · turn_panel — 回合面板(场面+侧栏交互) - · split_board — 双栏(左故事·右线索) - · choice_dock — 选择坞(底部大行动区) - · chapter_reader — 章节阅读 +0. 按「要同时看见什么」七选一: + · prose — 只要正文 + · chat_monitor — 正文 + 扫状态 + · spotlight — 大正文 + 弱状态 + · turn_panel — 场面 + 交互物 + 行动 + · split_board — 正文 ‖ 线索 + · choice_dock — 局面 + 选项 + · chapter_reader — 章正文 + 进度 1. 要微调什么?(监控字段、块称呼、要不要建议行动、tone_chrome…) 2. 有没有隐藏段给模型/程序维护变量? diff --git a/skills/dialogue/world-simulator/orchestrator.md b/skills/dialogue/world-simulator/orchestrator.md index 0a58b93..29e4cee 100644 --- a/skills/dialogue/world-simulator/orchestrator.md +++ b/skills/dialogue/world-simulator/orchestrator.md @@ -44,90 +44,9 @@ uiPrompt: | # 世界模拟器 · 总管 -作者清单见 `docs/world-simulator-modules.md`。 +你只负责读取 `设计.创作流程` DAG,并选择下一个创作节点: -你是 **总管**:负责 design / play 的 **调度**,不直接写正文。 -禁止默认把一切做成「世界模拟」;按用户意图正推最小能力组合。 +- 没有 DAG 或 DAG 需要追加节点:`design-flow` +- DAG 已给出下一个可执行节点:`design-step` -## 导演 · 能力 · 剧本 - -```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 - 用户输入 - → auditor(旁观维护,默认空操作;无长对话史) - → perspective?(仅强信息隔离) - → gm / world-simulator(主世界层,settlement.v1;variable_changes 由 Runtime 合并) - → narrator(用户可见正文;通常在此 review) - chance 仅按需;禁止发明未声明 ref -``` - -## 启动(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 | -| auditor / world-simulator / role-decide | false(play) | continue(声明默认) | -| narrator | false(play 调度) | review(等人看终稿) | - -## 总管优先行为 - -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` -7. **play**:按 `play_slots` 序 `auditor → perspective? → gm → narrator`;`pendingSideEffectWorkers` 优先 - (旁观与主世界层怎么配合:见 `docs/play-dm-auditor.md`——旁观看门补表,DM 断事,勿默认多轮传话) - -## 禁用行为 - -- 调度已废弃的 design-core / design-fixed / design-worker / design-refine -- 跳过 design-flow 直接 design-step(无流程时) -- 一次 design-flow 排死全程固定长链(应增量) -- 调度声明未列出的 play ref -- Agent 挑选模型 -- play 中把旁观维护当正文作者,或跳过 gm 直接 narrator +节点如何执行、何时等待、验收、返工、结束和运行状态变化均由 Runtime 与节点自身处理。 diff --git a/skills/dialogue/world-simulator/recipes/expand-assistant/recipe.yaml b/skills/dialogue/world-simulator/recipes/expand-assistant/recipe.yaml index f42f9d8..8bd9669 100644 --- a/skills/dialogue/world-simulator/recipes/expand-assistant/recipe.yaml +++ b/skills/dialogue/world-simulator/recipes/expand-assistant/recipe.yaml @@ -1,7 +1,7 @@ # 扩写助手 · 配方 # 写方法论:适用、核心思路、设计流程、原则。 # 各能力何时用 / 不用 → 读能力 meta(编排器会注入),勿在此重复。 -# steps = 近期起点,不是固定全程 DAG。 +# steps = 近期起点;选中本配方时程序写入「设计.创作流程」,开局即坐在第一步(不是固定全程 DAG)。 when: 大纲/分段扩写、写手统筹、先纲后章、成稿向助手、长文/爽文类体验 diff --git a/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml b/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml index ea3b599..9347646 100644 --- a/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml +++ b/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml @@ -1,7 +1,7 @@ # 世界模拟器 · 配方 # 写方法论:适用、核心思路、设计流程、原则。 # 各技能何时用 / 不用 → 读技能 meta(编排器会注入),勿在此重复。 -# steps = 近期起点,不是固定全程 DAG。 +# steps = 近期起点;选中本配方时程序写入「设计.创作流程」,开局即坐在第一步(不是固定全程 DAG)。 when: 回合互动、世界推进、角色扮演、沉浸推演类体验 diff --git a/skills/dialogue/world-simulator/workers/design-flow/SKILL.md b/skills/dialogue/world-simulator/workers/design-flow/SKILL.md index 18a5a4d..e79b5f0 100644 --- a/skills/dialogue/world-simulator/workers/design-flow/SKILL.md +++ b/skills/dialogue/world-simulator/workers/design-flow/SKILL.md @@ -44,9 +44,12 @@ contextSegments: 上下文里会有: -1. **【用户已选配方】**:该方法的适用、核心思路、设计流程与原则 + 近期起点 steps——**不是**锁死流水线;**禁止**替用户改选其它配方 -2. **【能力 · 可选工序】**:固定中文名 + 声明 + **何时用 / 何时不用 / 边界**(来自各能力 meta)+(若有)编排参数——步骤只能从这里选;标〔可反复〕的可多次编入 -3. **【已有剧本草案】/【已验收步骤】**:若有,在其上追加或改未验收步,**不要**推倒重来 +1. **【流程进度】**(程序钉死):已完成哪些步、草案里已有哪些步、哪些能力才允许再编入。必须先读完再排。 +2. **【用户已选配方】**:该方法的适用、核心思路、设计流程与原则 + 近期起点 steps——**不是**锁死流水线;**禁止**替用户改选其它配方 +3. **【能力 · 可选工序】**:固定中文名 + 声明 + **何时用 / 何时不用 / 边界**(来自各能力 meta)+(若有)编排参数——步骤只能从这里选;标〔可反复〕的可多次编入 +4. **【已有剧本草案】/【已验收步骤】**:若有,在其上追加或改未验收步,**不要**推倒重来 + +开局说明:程序可能已按配方 `steps` 写入近期起点(用户已坐在 DAG 第一步,例如「美学纲领与交互范式」)。此时**不要**把开局步再规划一遍;仅追加缺口。listed 步已齐仍需扩写时,保留已完成 id,只加新步。 ## 增量 DAG(核心) @@ -56,9 +59,9 @@ contextSegments: - **跟配方方法论**:用配方的 core / process / principles 理解整局怎么设计、何时收成。 - **跟能力 meta**:某步该不该排,看能力的「何时用 / 何时不用 / 边界」;**不要**在配方里找各能力调用条件,也不要凭题材默认全选。 -- 有「编排参数」声明的能力:写入 DAG 前必须钉齐 **必填 params**。 -- 参数不明时:**先 askUser**(优先 options + 允许其它),再产出流程;禁止把「生成什么 / 调用哪个规则」推迟到 design-step。 -- 用户先认可带参数的 DAG 雏形,再进入执行;用户提出新要求时,再调本 worker 修订未验收步或追加新步。 +- 有「编排参数」声明的能力:写入 DAG 时可先留空壳;**确认开干前**必须钉齐必填 params(用户在「下一步」意向后、点同意开始时补齐)。 +- 参数不明时:可先产出带空 params 的步骤,或 askUser;禁止把「生成什么 / 调用哪个规则」拖到 design-step 执行中途。 +- 用户先认可**下一节点**(含参数),再进入执行;用户提出新要求时,再调本 worker 修订未验收步或追加新步。 同能力**可以**多次出现(尤其〔可反复〕):每次一次调用、一次验收、产物写入同一 artifact(增量补全)。id 应尽量带上调用目标,如 `生成规则·怪物`,避免无信息的 `#2`。 @@ -105,13 +108,20 @@ contextSegments: 5. `params` = 本步调用参数(对象)。能力目录声明了编排参数时,**必填项必须写出**;可选则能推断就写 6. `status`:`"open"` = 还可能追加;`"closed"` = 不再扩步(可走收成) 7. 已验收步骤的 id **必须保留**;只能追加新步,或改未验收步的依赖/顺序/params -8. 配方建议 steps 只作近期起点;按能力 meta 按需选用,勿默认全选、勿一次排满 -9. **禁止**把「交互」与「美学」拆成两步 -10. **禁止**在本步写能力正文、自由 Worker 列表、表结构 -11. **禁止**编排「反复发明执行单元」;收成链:确认「游玩拓扑」→(上下文已多时)「上下文投影排序」→「细化终稿」。挂谁可早、排第几须晚,勿让中段技能写死 order -12. 参数或选型不够时,用 askUser 问 1~2 点(**优先带 options,并允许其它**) -13. 依赖真实产物的步骤(如具体实例依赖某条生成规则)须等上游验收后再编排,并从产物中列出可选项供用户选 -14. `summary`:`流程 · N 步 · open|closed · …` +8. 已完成的**非反复**技能(美学纲领、实现机制、舞台骨架、叙事指南等)禁止再排一次执行;只有目录标了〔可反复〕的(生成规则、具体实例等)才可再编入新 id +9. 配方建议 steps 只作近期起点;按能力 meta 按需选用,勿默认全选、勿一次排满 +10. **禁止**把「交互」与「美学」拆成两步 +11. **禁止**在本步写能力正文、自由 Worker 列表、表结构 +12. **禁止**编排「反复发明执行单元」;收成链:确认「游玩拓扑」→(上下文已多时)「上下文投影排序」→「细化终稿」。挂谁可早、排第几须晚,勿让中段技能写死 order +13. 参数或选型不够时,用 askUser 问 1~2 点(**优先带 options,并允许其它**)。 + 只许问**排布层**的事:下一步排哪个能力、某步的必填 params 取什么值。 + **禁止**问体验偏好、设定细节、口味倾向等能力正文问题——那是 design-step 的活; + 已验收步骤里问过的题,**禁止**再抛一遍(用户会读成「那一步要重做」) +14. 依赖真实产物的步骤(如具体实例依赖某条生成规则)须等上游验收后再编排,并从产物中列出可选项供用户选 +15. `summary`:`流程 · N 步 · open|closed · …` +16. **改对象必须改后台 id**:用户把「生成规则」从女租客改成丧尸怪物时,须同步改 + `params.target`、`params.rule_id`(对应英文 kebab-case,如 `zombies`)、以及步骤 `id`(如 `生成规则·丧尸怪物`)。 + **禁止**只改中文对象却沿用旧 `rule_id` / 旧步骤 id。 ## 自检 @@ -123,5 +133,7 @@ contextSegments: - 有编排参数的步骤,必填 params 是否已钉齐?缺参是否应先 askUser 而不是产出空壳? - depends_on 是否都指向更靠前的步骤 id? - 已验收 id 是否都还在? +- 【流程进度】里已完成的非反复技能,是否误当成新规划又排了一次? +- askUser 是否只问了排哪步 / 填哪个参数,而不是体验偏好等正文问题? - 需要反复补同类内容时,是否用了新 id + 新 params 追加而非改写旧步? - 收成前是否把 `status` 设为 `closed`? diff --git a/skills/dialogue/world-simulator/workers/design-step/SKILL.md b/skills/dialogue/world-simulator/workers/design-step/SKILL.md index 29f917d..efb266b 100644 --- a/skills/dialogue/world-simulator/workers/design-step/SKILL.md +++ b/skills/dialogue/world-simulator/workers/design-step/SKILL.md @@ -5,6 +5,7 @@ name: 创作 · 执行步骤 description: >- 按已认可的创作流程,执行当前一步工序。提示词与产物 tag 由程序按模块注入。 流程是增量 DAG:本步只读,禁止自行扩步或重排。 + outputTags 由程序注入为该步能力 artifact;勿把「创作.当前步骤」当产物写回。 version: 1 stage: design inputTags: @@ -15,8 +16,7 @@ inputTags: - "用户.修订说明" - "设计.创作流程" - "创作.当前步骤" -outputTags: - - "创作.当前步骤" +outputTags: [] inputMerge: latest contextSegments: - id: flow @@ -46,7 +46,7 @@ contextSegments: 1. 只写本步产物(程序指定的 output tag);不要改其它步骤产物 2. 产物用简洁 JSON 或结构化中文,方便界面渲染;少写机器变量名 3. 若本步有**默认问题**:程序已先发给用户;首答在「用户.worker答复」/「创作.能力开场白」。**禁止**再用 LLM 重复同一开场白 -4. 信息不足 → askUser 1~2 点(优先 options) +4. 信息不足:context-fragment 产物把题写在「追问」(1~2 点,建议选项 + 示例),顶层 askUser 为 null。仅完全无法产出时才用顶层 askUser 5. `summary`:`{本步能力名} · …` 6. **禁止**重排或扩写流程;流程只读。需要追加「再来一次生成规则」等 → 由总管再调 design-flow 7. **本步参数**由编排期写入 steps[].params,程序会注入【本步参数】。按参数执行;禁止再问「这一步生成什么 / 调用哪个规则」。参数缺失或与目录必填项不符 → 停止产出,提示返回 design-flow 补参 diff --git a/src/llm/token-tracker.ts b/src/llm/token-tracker.ts index f663eae..f5785a3 100644 --- a/src/llm/token-tracker.ts +++ b/src/llm/token-tracker.ts @@ -5,6 +5,7 @@ import type { CompleteWithToolsResult, LlmProvider, StreamCallbacks, + TokenUsage, } from "./client.js"; import { recordTokenUsage, @@ -15,6 +16,7 @@ import { buildContextTrace, type LlmContextTrace, } from "../types/context-trace.js"; +import { debugLog, labelCaller, labelLlmMode, labelTool } from "../log.js"; export type LlmTrackingContext = { sessionId?: string; @@ -41,6 +43,77 @@ function capturePendingTrace( }); } +function recordUsage( + ctx: LlmTrackingContext, + caller: string | undefined, + model: string | undefined, + usage: TokenUsage | undefined, +): void { + if (!usage) return; + const record = recordTokenUsage({ + sessionId: ctx.sessionId, + bookId: ctx.bookId, + bookTitle: ctx.bookTitle, + orchestratorId: ctx.orchestratorId, + caller: caller ?? "unknown", + model: model ?? "unknown", + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + totalTokens: usage.totalTokens, + cachedTokens: usage.cachedTokens, + cacheMissTokens: usage.cacheMissTokens, + }); + ctx.pendingUsage = toMessageTokenUsage(record); +} + +function summarizeComplete(result: CompleteResult): string { + const bits = [ + result.model ? `模型=${result.model}` : "", + `思维链=${result.reasoning?.trim().length ?? 0}字`, + `正文=${result.content?.length ?? 0}字`, + ]; + if (result.usage) { + bits.push(`用量=${result.usage.promptTokens}+${result.usage.completionTokens}`); + } + return bits.filter(Boolean).join(" "); +} + +function summarizeTools(result: CompleteWithToolsResult): string { + const tools = + result.toolCalls.map((t) => labelTool(t.name)).join("、") || "无"; + const bits = [ + result.model ? `模型=${result.model}` : "", + `思维链=${result.reasoning?.trim().length ?? 0}字`, + `正文=${result.content?.length ?? 0}字`, + `工具=${tools}`, + ]; + if (result.usage) { + bits.push(`用量=${result.usage.promptTokens}+${result.usage.completionTokens}`); + } + return bits.filter(Boolean).join(" "); +} + +async function loggedLlm( + kind: string, + caller: string | undefined, + run: () => Promise, + summarize: (result: T) => string, +): Promise { + const who = labelCaller(caller); + const mode = labelLlmMode(kind); + const t0 = Date.now(); + debugLog("llm", `开始 ${who} · ${mode}`); + try { + const result = await run(); + debugLog("llm", `结束 ${who} · ${mode} ${Date.now() - t0}毫秒 ${summarize(result)}`); + return result; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + debugLog("llm", `失败 ${who} · ${mode} ${Date.now() - t0}毫秒 ${detail.slice(0, 160)}`); + throw err; + } +} + export class TokenTrackingProvider implements LlmProvider { constructor( private readonly inner: LlmProvider, @@ -51,29 +124,21 @@ export class TokenTrackingProvider implements LlmProvider { messages: Parameters[0], options?: CompleteOptions, ): 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; + return loggedLlm( + "complete", + options?.caller, + async () => { + const result = await this.inner.complete(messages, options); + const ctx = this.getContext(); + capturePendingTrace(ctx, messages, options?.caller, result.model); + recordUsage(ctx, options?.caller, result.model, result.usage); + if (result.reasoning?.trim()) { + ctx.pendingReasoning = result.reasoning.trim(); + } + return result; + }, + summarizeComplete, + ); } async completeStream( @@ -88,67 +153,51 @@ export class TokenTrackingProvider implements LlmProvider { if (result.content) callbacks.onContentDelta?.(result.content); return result; } - let reasoningBuf = ""; - const result = await inner.completeStream(messages, options, { - onReasoningDelta: (delta) => { - reasoningBuf += delta; + return loggedLlm( + "stream", + options?.caller, + async () => { + 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(); - ctx.pendingReasoning = reasoningBuf; - callbacks.onReasoningDelta?.(delta); + capturePendingTrace(ctx, messages, options?.caller, result.model); + recordUsage(ctx, options?.caller, result.model, result.usage); + if (result.reasoning?.trim()) { + ctx.pendingReasoning = result.reasoning.trim(); + } + return result; }, - onContentDelta: callbacks.onContentDelta, - }); - 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; + summarizeComplete, + ); } async completeWithTools( messages: Parameters[0], options: CompleteWithToolsOptions, ): 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; + return loggedLlm( + "tools", + options.caller, + async () => { + const result = await this.inner.completeWithTools(messages, options); + const ctx = this.getContext(); + capturePendingTrace(ctx, messages, options.caller, result.model); + recordUsage(ctx, options.caller, result.model, result.usage); + if (result.reasoning?.trim()) { + ctx.pendingReasoning = result.reasoning.trim(); + } + return result; + }, + summarizeTools, + ); } async completeWithToolsStream( @@ -160,37 +209,29 @@ export class TokenTrackingProvider implements LlmProvider { if (!inner.completeWithToolsStream) { return this.completeWithTools(messages, options); } - let reasoningBuf = ""; - const result = await inner.completeWithToolsStream(messages, options, { - onReasoningDelta: (delta) => { - reasoningBuf += delta; + return loggedLlm( + "tools-stream", + options.caller, + async () => { + 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(); - ctx.pendingReasoning = reasoningBuf; - callbacks.onReasoningDelta?.(delta); + capturePendingTrace(ctx, messages, options.caller, result.model); + recordUsage(ctx, options.caller, result.model, result.usage); + if (result.reasoning?.trim()) { + ctx.pendingReasoning = result.reasoning.trim(); + } + return result; }, - onContentDelta: callbacks.onContentDelta, - }); - 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; + summarizeTools, + ); } } diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..92f5246 --- /dev/null +++ b/src/log.ts @@ -0,0 +1,293 @@ +import { execSync } from "node:child_process"; +import { + appendFileSync, + closeSync, + existsSync, + mkdirSync, + openSync, + readSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import type { + ApplyEventResult, + RuntimeEvent, + RuntimePhase, + RuntimeSession, + WaitingReason, +} from "./types/runtime.js"; +import { getUserDataDir } from "./config/user-data-dir.js"; + +const MAX_LOG_BYTES = 2 * 1024 * 1024; +const UTF8_BOM = "\uFEFF"; + +const KIND_LABEL = { + llm: "模型", + state: "状态", + step: "步骤", +} as const; + +const PHASE_LABEL: Record = { + idle: "空闲", + running: "执行中", + waiting_user: "等待用户", + done: "完成", + error: "出错", +}; + +const WAIT_LABEL: Record = { + skill_selection: "选能力包", + intake: "收集需求", + input: "等待输入", + approve_step: "确认开干", + review_artifact: "验收产物", + worker_questions: "执行单元提问", + revision: "修订", + next_intent: "下一步意向", +}; + +const EVENT_LABEL: Record = { + session_started: "会话开始", + skill_selected: "已选能力包", + user_submitted_input: "用户发言", + user_confirmed_intake: "确认需求", + main_agent_decision_created: "总管决策", + user_approved_next_step: "确认下一步", + user_rejected_next_step: "拒绝下一步", + worker_started: "执行单元开始", + worker_completed: "执行单元完成", + worker_needs_input: "执行单元提问", + user_resolved_sidecar_questions: "回答挂载追问", + user_accepted_artifact: "验收通过", + user_rejected_artifact: "打回产物", + user_requested_revision: "请求修订", + programmatic_review_started: "程序验收开始", + programmatic_review_passed: "程序验收通过", + programmatic_review_failed: "程序验收失败", + flow_completed: "流程结束", + runtime_failed: "运行失败", +}; + +const EFFECT_LABEL: Record = { + invoke_main_agent: "调用总管", + run_worker: "启动执行单元", + resume_worker: "恢复执行单元", + run_programmatic_review: "程序验收", + emit_message: "发消息", + propose_next_creation_step: "提案下一步", +}; + +const TOOL_LABEL: Record = { + read_blackboard: "读黑板", + list_workers: "列执行单元", + list_artifacts: "列产物", + run_worker: "跑执行单元", + ask_user: "问用户", + review_blackboard: "审黑板", + finish: "结束", +}; + +const ACTION_LABEL: Record = { + run_worker: "跑执行单元", + ask_user: "问用户", + review_blackboard: "审黑板", + finish: "结束", +}; + +let consoleUtf8Ready = false; + +/** Windows 控制台切到 UTF-8,避免中文日志乱码。 */ +export function ensureConsoleUtf8(): void { + if (consoleUtf8Ready) return; + consoleUtf8Ready = true; + if (process.platform !== "win32") return; + try { + // inherit 才会改当前窗口代码页;stdio ignore 只影响子进程 + execSync("chcp 65001 >NUL", { stdio: "inherit", shell: true, windowsHide: true }); + } catch { + /* 无控制台(桌面隐藏启动)时忽略 */ + } + try { + process.stdout.setDefaultEncoding("utf8"); + process.stderr.setDefaultEncoding("utf8"); + } catch { + /* ignore */ + } +} + +export function getRuntimeLogPath(): string { + return path.join(getUserDataDir(), "logs", "runtime.log"); +} + +let logFilePrepared = false; + +function fileHasUtf8Bom(file: string): boolean { + try { + const fd = openSync(file, "r"); + const buf = Buffer.alloc(3); + const n = readSync(fd, buf, 0, 3, 0); + closeSync(fd); + return n >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf; + } catch { + return false; + } +} + +function prepareLogFile(file: string): void { + if (logFilePrepared) return; + mkdirSync(path.dirname(file), { recursive: true }); + if (existsSync(file) && statSync(file).size > 0 && !fileHasUtf8Bom(file)) { + try { + renameSync(file, `${file}.old`); + } catch { + /* ignore */ + } + } + if (!existsSync(file) || statSync(file).size === 0) { + writeFileSync(file, `${UTF8_BOM}=== 写作助手运行日志(UTF-8)===\n`, "utf8"); + } + logFilePrepared = true; +} + +function appendRuntimeLog(line: string): void { + if (process.env.VITEST) return; + try { + const file = getRuntimeLogPath(); + prepareLogFile(file); + try { + if (statSync(file).size > MAX_LOG_BYTES) { + renameSync(file, `${file}.1`); + writeFileSync(file, `${UTF8_BOM}=== 写作助手运行日志(UTF-8)===\n`, "utf8"); + } + } catch { + /* rotate best-effort */ + } + appendFileSync(file, `${line}\n`, "utf8"); + } catch { + /* logging must not break the app */ + } +} + +function stamp(): string { + return new Date().toLocaleTimeString("zh-CN", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function labelPhase(phase: string): string { + return PHASE_LABEL[phase as RuntimePhase] ?? phase; +} + +function labelWait(wait: string): string { + if (wait === "-") return "无"; + return WAIT_LABEL[wait as WaitingReason["kind"]] ?? wait; +} + +export function labelCaller(caller: string | undefined): string { + const who = caller?.trim() || "未知"; + if (who === "main_agent" || who.startsWith("main_agent:")) return "总管"; + if (who === "intake_extract") return "需求抽取"; + if (who.startsWith("worker:")) return `执行单元 ${who.slice("worker:".length)}`; + if (who === "unknown") return "未知"; + return who; +} + +export function labelLlmMode(kind: string): string { + switch (kind) { + case "complete": + return "整段"; + case "stream": + return "流式"; + case "tools": + return "工具"; + case "tools-stream": + return "工具流式"; + default: + return kind; + } +} + +export function labelTool(name: string): string { + return TOOL_LABEL[name] ?? name; +} + +export function labelAction(action: string): string { + return ACTION_LABEL[action] ?? action; +} + +/** 服务端必要步骤:每次模型请求、状态迁移、创作步骤推进。 */ +export function debugLog(kind: "llm" | "state" | "step", line: string): void { + ensureConsoleUtf8(); + const text = `[${stamp()} ${KIND_LABEL[kind]}] ${line}`; + console.log(text); + appendRuntimeLog(text); +} + +export function sessionSnap(session: RuntimeSession): { + phase: string; + wait: string; + step: string; + worker: string; +} { + return { + phase: session.phase, + wait: session.waitingReason?.kind ?? "-", + step: session.currentStepId ?? "-", + worker: session.currentWorkerId ?? "-", + }; +} + +function eventHint(event: RuntimeEvent): string { + switch (event.type) { + case "worker_started": + case "worker_needs_input": + return event.payload.workerId; + case "main_agent_decision_created": { + const d = event.payload.decision; + const action = labelAction(d.action); + return d.workerId ? `${action} ${d.workerId}` : action; + } + case "skill_selected": + return event.payload.skill.name; + case "user_accepted_artifact": + case "user_rejected_artifact": + return event.payload.artifactId.slice(0, 8); + default: + return ""; + } +} + +export function logStateChange( + event: RuntimeEvent, + before: ReturnType, + result: ApplyEventResult, +): void { + const after = sessionSnap(result.session); + const bits = [EVENT_LABEL[event.type] ?? event.type]; + const hint = eventHint(event); + if (hint) bits.push(hint); + if (before.phase !== after.phase) { + bits.push(`相位 ${labelPhase(before.phase)}→${labelPhase(after.phase)}`); + } else { + bits.push(`相位=${labelPhase(after.phase)}`); + } + if (before.wait !== after.wait) { + bits.push(`等待 ${labelWait(before.wait)}→${labelWait(after.wait)}`); + } else if (after.wait !== "-") { + bits.push(`等待=${labelWait(after.wait)}`); + } + if (before.step !== after.step) bits.push(`步骤 ${before.step}→${after.step}`); + if (before.worker !== after.worker) bits.push(`执行单元 ${before.worker}→${after.worker}`); + const effects = result.effects.map((e) => { + const name = EFFECT_LABEL[e.type] ?? e.type; + return e.type === "run_worker" ? `${name} ${e.workerId}` : name; + }); + if (effects.length) bits.push(`随后 ${effects.join(",")}`); + if (result.error) bits.push(`出错 ${result.error.slice(0, 120)}`); + debugLog("state", bits.join(" ")); +} diff --git a/src/main-agent/main-agent.ts b/src/main-agent/main-agent.ts index ec2fd85..47ff122 100644 --- a/src/main-agent/main-agent.ts +++ b/src/main-agent/main-agent.ts @@ -23,48 +23,15 @@ function buildMainAgentSystemPrompt( ? workers.map((w) => `- ${w.id}:${w.description}`).join("\n") : "- (当前 skill 未加载 worker 列表)"; - return `你是写作系统的编排器(Main Agent)。你的职责是调度执行单元(worker),而不是直接创作正文。 + return `你是创作节点路由器。只读取「设计.创作流程」DAG,并选择下一个要执行的创作节点。 -规则: -1. 你不能直接生成小说/文章正文。 -2. 你不能修改运行状态;statePatchAllowed 必须始终为 false。 -3. 你只能建议下一步动作:ask_user、run_worker、create_temp_worker、review_blackboard、finish。 -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" } 或 { "chance": { "op":"roll", "expression":"2d6" } }(机遇裁定走程序,勿让模型编随机)。 -8. 你不能把未验收内容当作事实。 -9. 向用户提问是 worker 的技能(ask_user tool),不是独立 worker。编排器只在调度层提问。 -10. blackboardIndex 只有 tag 索引,不含正文 content。 - -## 调度思维 -用需求正推:「用户需要 [具体体验/技能] → 调用 [worker/skill] 来 [生成/补充/调整] [什么],以便更好满足用户。」 -禁止否定式路由:「某模式 / 背景形态 → 不需要某步骤 / 跳过某 worker」。 - -反例(禁止):「背景为单一角色,无需世界构建」「不是规则怪谈,跳过 write-rules」「默认上世界模拟套件」 -正例:「用户已选世界模拟器配方且要网恋对话 → design-flow 排出近期增量步骤(可调味、可后补;有编排参数的步骤须钉 params)→ 用户认可后反复 design-step;需要再补规则/实例 → 再 design-flow 追加同技能并写齐 params → 收成后若需开局 → opening-generator;用户手动进 play」 +- DAG 不存在或需要追加节点:调用 design-flow。 +- DAG 中已有下一个可执行节点:调用 design-step。 +- 只调度 worker,不创作正文,不规划 DAG 之外的流程。 当前 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 - -## play 优先顺序(lifecycleStage=play) -- 严格按声明管线顺序调度(play_slots 展开序):**auditor(旁观维护)→ perspective? → gm(主世界层)→ narrator(转述)** -- auditor / gm / perspective 的 acceptance 多为 continue:跑完立刻调度下一槽,不要中途 ask_user -- 仅 narrator(或规格标明 review 的终稿槽)停下来等人看 -- chance 仅在需要真随机时按需 run_worker,不插入每轮固定序 -- slots.pendingSideEffectWorkers 非空时优先调度其中 workerId -- **禁止**发明未声明的 play ref;**禁止**把旁观维护当成正文作者 - 输出必须是 JSON 对象,字段: { "action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish", @@ -110,40 +77,21 @@ export class MainAgent { export function buildMainAgentUserPrompt(context: MainAgentContext): string { const { session, blackboardIndex, availableWorkers } = context; - const stage = String(session.slots?.uiLifecycleStage ?? "design"); - const pendingSide = session.slots?.pendingSideEffectWorkers; + const creationTags = new Set([ + "设计.创作流程", + "创作.当前步骤", + "创作.当前单位", + "创作.已验收单位", + "用户.下一步意向", + ]); return JSON.stringify( { - runtimePhase: session.phase, - waitingReason: session.waitingReason, - lifecycleStage: stage, - flowId: session.flowId, currentStepId: session.currentStepId, - currentWorkerId: session.currentWorkerId, - acceptanceMode: session.acceptanceMode, - slots: session.slots, - pendingSideEffectWorkers: pendingSide ?? null, - pendingDecision: session.pendingDecision - ? { - id: session.pendingDecision.id, - action: session.pendingDecision.action, - reason: session.pendingDecision.reason, - } - : null, - pendingArtifactId: session.pendingArtifactId, - artifacts: session.artifacts.map((a) => ({ - id: a.id, - workerId: a.workerId, - status: a.status, - summary: a.summary, - outputTags: a.outputTags, - })), - blackboardIndex, + creationDagTags: blackboardIndex.filter((entry) => + creationTags.has(entry.tag), + ), availableWorkers, - instruction: - stage === "play" - ? "play:按声明序 auditor→perspective?→gm→narrator;continue 槽连跑;仅终稿 review 停;pendingSideEffectWorkers 优先;禁止未声明 ref。" - : "根据当前状态决定下一步。尚无设计.创作流程 → design-flow;有未完成步骤 → design-step;steps 做完但 status=open → 再 design-flow;禁止旧 design-core/fixed/worker/refine。已 accept 且需开局 → opening-generator。进 play 由用户手动。", + instruction: "读取创作 DAG,选择下一个创作节点。", }, null, 2, diff --git a/src/main-agent/tool-loop.ts b/src/main-agent/tool-loop.ts index 40fda55..eb23564 100644 --- a/src/main-agent/tool-loop.ts +++ b/src/main-agent/tool-loop.ts @@ -6,6 +6,7 @@ import { isMainAgentTerminalTool } from "../types/tools.js"; import { MAIN_AGENT_TOOL_DEFINITIONS } from "./tools.js"; import { buildMainAgentUserPrompt, parseMainAgentDecision } from "./main-agent.js"; import type { MainAgentContext } from "./main-agent.js"; +import { debugLog } from "../log.js"; export type ToolLoopHandlers = { readBlackboard: (tags: string[]) => Record; @@ -20,7 +21,7 @@ export type ToolLoopHandlers = { onToolCall?: (name: string, detail: string) => void; /** 思维链 / reasoning 流式增量(供 UI 实时展示) */ onThinkingDelta?: (delta: string) => void; - /** 一轮 LLM 调用结束后的完整思考文本 */ + /** 一轮 LLM 调用结束后的完整思维链(不含正文) */ onThinkingDone?: (text: string) => void; }; @@ -40,71 +41,17 @@ function buildToolLoopSystemPrompt( ? workers.map((w) => `- ${w.id}:${w.description}`).join("\n") : "- (当前 skill 未加载 worker 列表)"; - return `你是写作系统的总管(Main Agent)。你在 running 相位通过 **tool call** 推进流程。 + return `你是创作节点路由器。只根据「设计.创作流程」DAG 选择下一个创作节点。 -## 能力边界 -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. 不能把未验收产物当作已定事实。 - -## 调度思维(必须遵守) -用 **需求正推**,禁止 **模式否定式** 表述。 - -**要这样思考(正推):** -「用户需要 [具体体验/能力/产物] → 因此调用 [worker/skill] 来 [生成/补充/调整] [什么],以便更好满足用户。」 - -**不要这样思考(反例):** -「这是 xxx 模式 / 形态 → 不需要 xxx 步骤 / 跳过 xxx。」 -「背景为单一角色,无需世界构建。」 -「不是规则怪谈,跳过 write-rules。」 - -示例(好): -- 「用户已选配方且要 1v1 网恋 → design-flow 排出近期增量步骤(可调味)→ 认可后反复 design-step;不够再追加。」 -- 「尚无设计.创作流程 → run_worker(design-flow);有未完成步骤 → design-step;steps 完但 status=open → 再 design-flow。」 -- 「运行规格已 accept 且声明需要开局 → 调用 opening-generator 写开场白。」 - -示例(坏): -- 「调度 design-core / design-fixed(已废弃)。」 -- 「一次 design-flow 排死全程固定 DAG。」 -- 「跳过流程编排直接写满运行规格。」 -- 「默认先上世界模拟全套再问用户要什么。」 -- 「替用户改选 / 猜测配方。」 - -在 reasoning / 可见 content 中请用 **正推句式** 写出调度理由;终止 tool 的 reason 字段同样用正推表述。 - -## design 阶段提示 -- 尚无「设计.创作流程」→ **优先 design-flow**(近期 horizon;status=open;未选则先请用户选)。 -- 流程已验收、还有未完成步骤 → **design-step**(一次一步)。 -- 已列步骤全验收但 **status=open** → 再 **design-flow**(追加生成规则/具体实例等并钉齐 params,或设 closed)。 -- 有编排参数的步骤缺必填 params → 先让 design-flow askUser,不要空壳跑 design-step。 -- 禁止 run_worker(design-core|design-fixed|design-worker|design-refine)——已废弃。 -- 不要重复问用户「想做什么」——意图已在 用户.需求。 -- 运行规格已 accept,且可用 worker 含 opening-generator,尚无开场产物 → opening-generator。 -- 进 play 由用户手动决定。 -- 未声明开局、用户也未要求开场时,不要硬调 opening-generator。 -- **禁止**替用户猜测或改选配方。 -- **禁止**一次编排排死全程固定长链。 - -## play 阶段提示(lifecycleStage=play 时优先) -- 管线顺序:**auditor(旁观维护)→ perspective? → gm(主世界层)→ narrator**。 -- auditor/gm/perspective 多为 continue:写完立刻 run 下一槽,不要中途 ask_user。 -- 只在 narrator(或 review 终稿槽)停给人看。 -- chance 按需;pendingSideEffectWorkers 非空则优先调度。 -- 禁止未声明 play ref;旁观维护默认空操作,不是正文作者。 +1. 用 read_blackboard 读取「设计.创作流程」及其进度 tag。 +2. 没有 DAG 或 DAG 需要追加节点时,run_worker(design-flow)。 +3. DAG 已给出下一个可执行节点时,run_worker(design-step)。 +4. 不创作正文,不改写 DAG,不处理运行状态或游玩流程。 当前 skill 可用 worker: ${workerLines} -信息不足时可多次 read_blackboard;一旦调用终止 tool,本轮循环结束。`; +选定节点后立即调用 run_worker。`; } async function completeToolsPreferStream( @@ -172,14 +119,9 @@ 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); +function emitThinkingDone(handlers: ToolLoopHandlers, reasoning?: string): void { + const text = reasoning?.trim() ?? ""; + if (text) handlers.onThinkingDone?.(text); } /** @@ -202,12 +144,12 @@ 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++) { + debugLog("llm", `总管循环 第${iteration}轮`); const result = await completeToolsPreferStream(llm, messages, streamCallbacks); - emitThinkingDone(handlers, result); + emitThinkingDone(handlers, result.reasoning); if (result.toolCalls.length === 0) { if (result.content?.trim()) { diff --git a/src/main-agent/tools.ts b/src/main-agent/tools.ts index 51b3418..3231827 100644 --- a/src/main-agent/tools.ts +++ b/src/main-agent/tools.ts @@ -22,118 +22,11 @@ export const MAIN_AGENT_TOOL_DEFINITIONS: ToolDefinition[] = [ }, }, }, - { - type: "function", - function: { - name: "list_workers", - description: "列出当前 skill 可调度的 worker id 与说明。", - parameters: { - type: "object", - properties: {}, - additionalProperties: false, - }, - }, - }, - { - type: "function", - function: { - name: "list_artifacts", - description: "列出当前会话 worker 产物(id、workerId、status、summary)。", - parameters: { - type: "object", - properties: {}, - additionalProperties: false, - }, - }, - }, - { - type: "function", - function: { - name: "ask_user", - description: - "向用户追问。assessment 是给用户看的主内容(完备度评价);questions 挂在其下,用户可跳过并请你基于现有信息继续。将暂停等待用户。", - parameters: { - type: "object", - properties: { - 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", "assessment", "questions"], - additionalProperties: false, - }, - }, - }, { type: "function", function: { name: "run_worker", - description: - "调度 worker。常规只传 workerId。调度 chance(机遇裁定)时传 chance 请求对象(程序掷骰/比点/抽签,禁止让模型编随机)。", + description: "执行选中的下一个创作节点。", parameters: { type: "object", properties: { @@ -143,50 +36,10 @@ export const MAIN_AGENT_TOOL_DEFINITIONS: ToolDefinition[] = [ type: "boolean", description: "true 时需用户确认后才执行", }, - roleId: { - type: "string", - description: "role-decide 等 worker 的当前角色 id", - }, - chance: { - type: "object", - description: - "仅 workerId=chance:{ op:'roll'|'compare'|'draw'|'pick', ... }。例 roll:{expression:'2d6+1'};draw:{pool:['a','b'],count:1}", - }, }, required: ["workerId", "reason", "requiresApproval"], additionalProperties: false, }, }, }, - { - type: "function", - function: { - name: "review_blackboard", - description: "向用户说明当前黑板与进度概况,并暂停等待用户回复。", - parameters: { - type: "object", - properties: { - reason: { type: "string" }, - summary: { type: "string", description: "给用户看的概况说明" }, - }, - required: ["reason", "summary"], - additionalProperties: false, - }, - }, - }, - { - type: "function", - function: { - name: "finish", - description: "正常结束当前创作流程。", - parameters: { - type: "object", - properties: { - reason: { type: "string" }, - }, - required: ["reason"], - additionalProperties: false, - }, - }, - }, ]; diff --git a/src/runtime/phase-machine.ts b/src/runtime/phase-machine.ts index 8dc0102..8ac36f0 100644 --- a/src/runtime/phase-machine.ts +++ b/src/runtime/phase-machine.ts @@ -26,7 +26,10 @@ import { readIntakeValues, synthesizeDemandText, } from "../intake/intake.js"; -import { normalizeQuestions } from "../skills/question-protocol.js"; +import { + isModuleOpeningQuestions, + normalizeQuestions, +} from "../skills/question-protocol.js"; import type { QuestionItem } from "../types/questions.js"; function nowIso(): string { @@ -119,6 +122,67 @@ function updateArtifact( }; } +/** 最近一次被拒 / 待修订的产物(用于 revision 等待态补交意见后重跑) */ +function findRevisionTargetArtifact( + session: RuntimeSession, +): ArtifactRecord | undefined { + if (session.pendingArtifactId) { + const pending = findArtifact(session, session.pendingArtifactId); + if (pending) return pending; + } + for (let i = session.artifacts.length - 1; i >= 0; i -= 1) { + const a = session.artifacts[i]; + if (a.status === "revision_requested" || a.status === "rejected") { + return a; + } + } + return undefined; +} + +/** + * 已有修改意见 → 写入「用户.修订说明」并立刻重跑同一 worker。 + * 语义:在已有产物上按意见改(上下文会注入待改底稿),不是推倒重做。 + * 对应验收底栏:看产物 → 输入意见 →「按意见修改」。 + */ +function beginRevisionRerun( + session: RuntimeSession, + event: RuntimeEvent, + artifact: ArtifactRecord, + instruction: string, +): ApplyEventResult { + const trimmed = instruction.trim(); + const slots: Record = { + ...session.slots, + "用户.修订说明": trimmed, + "用户.最新输入": trimmed, + revisionInstruction: trimmed, + // 本次重跑的底稿;重跑写不出新版时只许退回这一份 + revisionTargetArtifactId: artifact.id, + }; + const next = appendHistory( + updateArtifact(session, artifact.id, { status: "revision_requested" }), + event, + ); + return { + session: touch( + running({ + ...next, + slots, + pendingArtifactId: undefined, + pendingDecision: undefined, + currentWorkerId: artifact.workerId, + currentStepId: artifact.stepId ?? session.currentStepId, + }), + ), + effects: [ + { + type: "run_worker", + workerId: artifact.workerId, + }, + ], + }; +} + /** 创建 idle 状态的新会话 */ export function createSession( presetId: string, @@ -154,6 +218,7 @@ export function getAllowedEvents( "worker_started", "worker_completed", "worker_needs_input", + "worker_revision_produced_nothing", "programmatic_review_started", "programmatic_review_passed", "programmatic_review_failed", @@ -204,7 +269,8 @@ export function canApplyEvent( reason?.kind === "intake" || reason?.kind === "input" || reason?.kind === "worker_questions" || - reason?.kind === "revision" + reason?.kind === "revision" || + reason?.kind === "next_intent" ); case "user_confirmed_intake": return reason?.kind === "intake"; @@ -256,6 +322,14 @@ function handleWorkerCompleted( const mode = session.acceptanceMode ?? "user_confirmed"; let next = appendHistory(session, event); + if (next.slots.revisionTargetArtifactId) { + // 已写出新版,旧底稿不再是退回目标 + next = { + ...next, + slots: { ...next.slots, revisionTargetArtifactId: undefined }, + }; + } + if (mode === "user_confirmed") { next = updateArtifact(next, artifact.id, { status: "under_review" }); return { @@ -454,6 +528,62 @@ export function applyEvent( }; } + if (session.waitingReason?.kind === "next_intent") { + if (text) { + slots["用户.下一步意向"] = text; + slots["用户.最新输入"] = text; + } + const next = appendHistory( + { + ...session, + slots, + resumeContext: undefined, + }, + event, + ); + return { + session: touch(running(next)), + effects: [{ type: "propose_next_creation_step" }], + }; + } + + if (session.waitingReason?.kind === "revision") { + const instruction = + text.trim() || session.waitingReason.instruction?.trim() || ""; + if (!instruction) { + return { + session: waiting(touch(appendHistory({ ...session, slots }, event)), { + kind: "revision", + instruction: session.waitingReason.instruction, + }), + effects: [ + { + type: "emit_message", + message: "请说明要改哪里,再发送。", + }, + ], + }; + } + const target = findRevisionTargetArtifact(session); + if (target) { + return beginRevisionRerun(session, event, target, instruction); + } + slots["用户.修订说明"] = instruction; + slots["用户.最新输入"] = instruction; + slots.revisionInstruction = instruction; + return { + session: touch( + running( + appendHistory( + { ...session, slots, pendingArtifactId: undefined }, + event, + ), + ), + ), + effects: [{ type: "invoke_main_agent" }], + }; + } + if (session.waitingReason?.kind === "worker_questions") { slots["用户.worker答复"] = text; if (text) slots["用户.最新输入"] = text; @@ -660,6 +790,7 @@ export function applyEvent( acceptanceMode: session.acceptanceMode ?? "user_confirmed", questions: normalized, }; + const openingAsk = isModuleOpeningQuestions(normalized); return { session: waiting( appendHistory( @@ -675,7 +806,10 @@ export function applyEvent( effects: [ { type: "emit_message", - message: `[Worker] ${event.payload.workerId} 提问:\n${normalized.map((q) => `- ${q.prompt}`).join("\n")}`, + // 默认问题正文由说话面 openingGuide 展示,勿再整段塞进调度消息 + message: openingAsk + ? `[Worker] ${event.payload.workerId} · 默认问题(请在主栏按引导作答)` + : `[Worker] ${event.payload.workerId} 提问:\n${normalized.map((q) => `- ${q.prompt}`).join("\n")}`, }, ], }; @@ -690,16 +824,10 @@ export function applyEvent( typeof event.payload.assessment === "string" ? event.payload.assessment.trim() : ""; - const effects = [...result.effects]; - if (sidecar?.length) { - effects.push({ - type: "emit_message", - message: `[Worker] 可选追问(可跳过,直接接受目前产物):\n${sidecar.map((q) => `- ${q.prompt}`).join("\n")}`, - }); - } + // 追问与产物同一次产出:只挂 review_artifact,勿再 emit 独立「可选追问」消息 + //(否则历史里会拆成 worker_output + worker_questions 两段) return { ...result, - effects, session: waiting(result.session, { kind: "review_artifact", artifactId: result.session.pendingArtifactId, @@ -711,6 +839,42 @@ export function applyEvent( return result; } + case "worker_revision_produced_nothing": { + const artifact = findArtifact(session, event.payload.artifactId); + if (!artifact) { + return fail(session, `Artifact not found: ${event.payload.artifactId}`); + } + const retryQuestions = normalizeQuestions(event.payload.questions).map( + (q) => ({ ...q, required: false }), + ); + const next = appendHistory( + updateArtifact(session, artifact.id, { status: "under_review" }), + event, + ); + return { + session: waiting( + { + ...next, + slots: { ...next.slots, revisionTargetArtifactId: undefined }, + pendingArtifactId: artifact.id, + currentWorkerId: undefined, + }, + { + kind: "review_artifact", + artifactId: artifact.id, + questions: retryQuestions.length ? retryQuestions : undefined, + }, + ), + effects: [ + { + type: "emit_message", + message: + "[系统] 这次按意见重写没能产出可验收的新版本,已退回上一版产物:可补充说明再改,或直接接受上一版。", + }, + ], + }; + } + case "user_resolved_sidecar_questions": { const reason = session.waitingReason; if (reason?.kind !== "review_artifact") { @@ -745,22 +909,43 @@ export function applyEvent( } const slots: Record = { ...session.slots }; // 仅终稿 tag「设计.worker集」表示完整 Worker 集验收;草稿单位验收不得开 play - if (artifact.outputTags.some((tag) => tag === "设计.worker集")) { + const isFinalSet = artifact.outputTags.some((tag) => tag === "设计.worker集"); + if (isFinalSet) { slots.designInstanceReady = true; } const next = appendHistory( updateArtifact(session, artifact.id, { status: "accepted" }), event, ); - return { - session: touch( - running({ - ...next, - slots, - pendingArtifactId: undefined, - pendingDecision: undefined, + const cleared = { + ...next, + slots, + pendingArtifactId: undefined, + pendingDecision: undefined, + currentWorkerId: undefined, + }; + // 创作步验收后:先问下一步意向,再展示下一节点确认开干(勿直接连跑) + const gateNextIntent = + !isFinalSet && + (artifact.workerId === "design-step" || + artifact.workerId === "design-flow"); + if (gateNextIntent) { + return { + session: waiting(cleared, { + kind: "next_intent", + afterWorkerId: artifact.workerId, }), - ), + effects: [ + { + type: "emit_message", + message: + "这一步已收下。下一步想写什么?可留空,直接发送后看下一节点并确认开干。", + }, + ], + }; + } + return { + session: touch(running(cleared)), effects: [{ type: "invoke_main_agent" }], }; } @@ -770,19 +955,21 @@ export function applyEvent( if (!artifact) { return fail(session, `Artifact not found: ${event.payload.artifactId}`); } + const reason = event.payload.reason?.trim() ?? ""; + // 验收态已写意见 → 在现有产物上按意见改;无意见才进入 revision 等待补交 + if (reason) { + return beginRevisionRerun(session, event, artifact, reason); + } const next = appendHistory( updateArtifact(session, artifact.id, { status: "rejected" }), event, ); return { - session: waiting(next, { - kind: "revision", - instruction: event.payload.reason, - }), + session: waiting(next, { kind: "revision" }), effects: [ { type: "emit_message", - message: event.payload.reason ?? "产物已被拒绝,等待修改指示。", + message: "已保留当前产物,请说明要改哪里。", }, ], }; @@ -800,15 +987,26 @@ export function applyEvent( effects: [{ type: "invoke_main_agent" }], }; } + const instruction = event.payload.instruction?.trim() ?? ""; + const artifactId = + event.payload.artifactId ?? session.pendingArtifactId; + const artifact = artifactId + ? findArtifact(session, artifactId) + : findRevisionTargetArtifact(session); + if (instruction && artifact) { + return beginRevisionRerun(session, event, artifact, instruction); + } return { session: waiting( - appendHistory({ ...session, pendingArtifactId: undefined }, event), - { kind: "revision", instruction: event.payload.instruction }, + appendHistory(session, event), + { kind: "revision", instruction: instruction || undefined }, ), effects: [ { type: "emit_message", - message: `返工请求:${event.payload.instruction}`, + message: instruction + ? `修改意见:${instruction}` + : "请说明要改哪里(在现有产物上改)。", }, ], }; @@ -840,20 +1038,16 @@ export function applyEvent( if (!artifact) { return fail(session, `Artifact not found: ${event.payload.artifactId}`); } - const next = appendHistory( - updateArtifact(session, artifact.id, { status: "revision_requested" }), - event, - ); + const reason = event.payload.reason?.trim() || "程序验收失败"; + const result = beginRevisionRerun(session, event, artifact, reason); return { - session: waiting(next, { - kind: "revision", - instruction: event.payload.reason, - }), + ...result, effects: [ { type: "emit_message", - message: `程序验收失败:${event.payload.reason}`, + message: `程序验收未过,按意见在现有产物上改:${reason}`, }, + ...result.effects, ], }; } diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index 5384aca..a0dbbab 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -57,18 +57,36 @@ import { import { CREATION_FLOW_TAG, CREATION_CURRENT_STEP_TAG, + CREATION_PROPOSED_STEP_TAG, CREATION_MODULE_OPENING_TAG, CREATION_MODULE_OPENING_STATE_TAG, SLOT_CREATION_MODULE_OPENING_STATE, + loadModuleCatalog, + mergeCreationFlowPreservingAccepted, nextPendingStep, parseCreationFlow, parseModuleOpeningState, + patchCreationFlowStepParams, + pickRecordedStepId, resolveDesignStepBinding, + shouldSkipModuleOpening, + isProgressPointerTag, stepUnitId, + stringifyCreationFlow, stringifyModuleOpeningState, + type CreationFlowStepParams, + type ModuleParamSpec, } from "../skills/creation-flow.js"; -import { normalizeQuestions } from "../skills/question-protocol.js"; +import { + appendAskedQuestions, + dropAlreadyAskedQuestions, + MODULE_OPENING_QUESTION_ID, + normalizeQuestions, + parseAskedQuestions, + SLOT_ASKED_QUESTIONS, +} from "../skills/question-protocol.js"; import { parseWorkerSetYaml } from "../skills/worker-set-parse.js"; +import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js"; import { executeChance, resolveChanceRequest, @@ -363,10 +381,18 @@ export class PhaseRuntime { return this.session; } - /** 统一事件入口:applyEvent + 更新 session + 处理 effects + 可选总管 LLM */ - async dispatch(event: RuntimeEvent): Promise { + /** applyEvent 并打状态日志(dispatch / 总管决策共用) */ + private commitEvent(event: RuntimeEvent): ApplyEventResult { + const before = sessionSnap(this.session); const result = applyEvent(this.session, event); this.session = result.session; + logStateChange(event, before, result); + return result; + } + + /** 统一事件入口:applyEvent + 更新 session + 处理 effects + 可选总管 LLM */ + async dispatch(event: RuntimeEvent): Promise { + const result = this.commitEvent(event); if ( event.type === "user_submitted_input" || @@ -416,14 +442,26 @@ export class PhaseRuntime { source: "user", }); } - const revision = session.slots.revisionInstruction; + const revision = + (typeof session.slots["用户.修订说明"] === "string" && + session.slots["用户.修订说明"]) || + (typeof session.slots.revisionInstruction === "string" && + session.slots.revisionInstruction); if (typeof revision === "string" && revision.trim()) { this.blackboard.write({ - tag: "用户.修改说明", + tag: "用户.修订说明", content: revision.trim(), source: "user", }); } + const nextIntent = session.slots["用户.下一步意向"]; + if (typeof nextIntent === "string" && nextIntent.trim()) { + this.blackboard.write({ + tag: "用户.下一步意向", + content: nextIntent.trim(), + source: "user", + }); + } } /** @@ -490,10 +528,15 @@ export class PhaseRuntime { return this.session; } - /** 用户确认 pendingDecision(approve_step) */ - async approve(): Promise { + /** 用户确认 pendingDecision(approve_step);可顺带钉死下一步 params */ + async approve(stepParams?: CreationFlowStepParams): Promise { const id = this.session.pendingDecision?.id; if (!id) throw new Error("当前没有待确认的决策"); + if (stepParams && Object.keys(stepParams).length > 0) { + this.applyProposedStepParams(stepParams); + } + const readyErr = this.assertProposedStepReady(); + if (readyErr) throw new Error(readyErr); await this.dispatch({ type: "user_approved_next_step", payload: { decisionId: id } }); return this.session; } @@ -577,7 +620,7 @@ export class PhaseRuntime { return this.session; } - /** 用户拒绝 pendingArtifact,进入 revision */ + /** 用户拒绝 pendingArtifact:有意见则立刻按「用户.修订说明」在现有产物上改 */ async rejectArtifact(reason?: string, artifactId?: string): Promise { const id = artifactId ?? this.session.pendingArtifactId; if (!id) throw new Error("当前没有待拒绝的产物"); @@ -588,12 +631,39 @@ export class PhaseRuntime { return this.session; } + /** + * 按意见重跑却没写出新产物:退回**本次修订的那一份**产物验收,重试追问改挂为可选。 + * 只认 beginRevisionRerun 钉下的底稿;否则会把无关的旧产物(如上一轮流程编排) + * 错误地挂回验收面。首跑无产物仍走阻塞追问。 + * @returns true = 已退回验收,调用方应 return + */ + private async restoreRevisionTargetForReview( + workerId: string, + questions: import("../types/questions.js").QuestionItem[] | string[], + ): Promise { + const targetId = this.session.slots.revisionTargetArtifactId; + if (typeof targetId !== "string" || !targetId) return false; + const target = this.session.artifacts.find( + (a) => + a.id === targetId && + a.workerId === workerId && + a.status === "revision_requested", + ); + if (!target) return false; + await this.dispatch({ + type: "worker_revision_produced_nothing", + payload: { artifactId: target.id, questions }, + }); + return true; + } + /** worker 调用 ask_user 能力时,由外层触发此事件(仅无产物时阻塞) */ async workerAsk( questions: import("../types/questions.js").QuestionItem[] | string[], ): Promise { const workerId = this.session.currentWorkerId; if (!workerId) throw new Error("当前没有运行中的 worker"); + this.rememberAskedQuestions(normalizeQuestions(questions)); await this.dispatch({ type: "worker_needs_input", payload: { workerId, stepId: this.session.currentStepId, questions }, @@ -601,6 +671,35 @@ export class PhaseRuntime { return this.session; } + private askedQuestionHistory() { + return parseAskedQuestions(this.session.slots[SLOT_ASKED_QUESTIONS]); + } + + private rememberAskedQuestions( + questions: readonly import("../types/questions.js").QuestionItem[], + ): void { + if (!questions.length) return; + const next = appendAskedQuestions(this.askedQuestionHistory(), questions); + this.session = { + ...this.session, + slots: { ...this.session.slots, [SLOT_ASKED_QUESTIONS]: next }, + }; + } + + /** + * 挂在验收态的可跳过追问:本局问过的不再问一次。 + * 编排层常把上一步已问过的体验题再抛一遍,用户会读成「又要重做那一步」。 + */ + private freshSidecarQuestions( + questions: readonly import("../types/questions.js").QuestionItem[] | undefined, + ): import("../types/questions.js").QuestionItem[] | undefined { + if (!questions?.length) return undefined; + const fresh = dropAlreadyAskedQuestions(questions, this.askedQuestionHistory()); + if (!fresh.length) return undefined; + this.rememberAskedQuestions(fresh); + return fresh; + } + /** * 验收态下作答或跳过挂载追问:不离开 review_artifact。 * answersText 缺省 = 跳过。 @@ -662,6 +761,13 @@ export class PhaseRuntime { /** 消费 applyEvent 返回的 PhaseEffect 列表 */ private async processEffects(effects: PhaseEffect[]): Promise { for (const effect of effects) { + if (effect.type === "run_worker") { + debugLog("step", `启动执行单元 ${effect.workerId}`); + } else if (effect.type === "propose_next_creation_step") { + debugLog("step", "提案下一步"); + } else if (effect.type === "resume_worker") { + debugLog("step", `恢复执行单元 ${this.session.resumeContext?.workerId ?? "未知"}`); + } switch (effect.type) { case "emit_message": this.onMessage(effect.message); @@ -705,10 +811,209 @@ export class PhaseRuntime { case "run_programmatic_review": await this.runProgrammaticReview(effect.artifactId); break; + case "propose_next_creation_step": + await this.proposeNextCreationStep(); + break; } } } + /** + * 用户填完「下一步想写什么」后:有待执行步则提案并进入确认;否则交总管扩步/收口。 + */ + private async proposeNextCreationStep(): Promise { + this.syncSlotsToBlackboard(this.session); + const flow = parseCreationFlow( + this.blackboard.getContentByTag(CREATION_FLOW_TAG), + ); + const accepted = parseAcceptedUnits( + this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ?? + this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG), + ); + const pending = nextPendingStep(flow, accepted); + if (!pending || !flow) { + debugLog("step", "暂无下一步,交给总管"); + this.session = { + ...this.session, + slots: { + ...this.session.slots, + [CREATION_PROPOSED_STEP_TAG]: undefined, + }, + }; + if (this.mainAgent) { + await this.runMainAgent(); + } else { + this.onMessage( + "[阶段机] 暂无下一节点,等待总管编排或补充意向。", + ); + } + return; + } + + const active = this.getActiveSkill(); + let paramSpecs: ModuleParamSpec[] = []; + let declaration = ""; + if (active?.name) { + try { + const skill = await loadSkill(active.name); + if (skill.skillPackRoot) { + const catalog = await loadModuleCatalog(skill.skillPackRoot); + const mod = catalog?.modules.find((m) => m.name === pending.name); + paramSpecs = mod?.params ?? []; + declaration = mod?.declaration?.trim() || ""; + } + } catch { + /* catalog optional */ + } + } + + debugLog("step", `提案下一步 ${pending.id}(${pending.name})`); + + const params = pending.params ? { ...pending.params } : {}; + const paramsMissing = paramSpecs + .filter((p) => p.required) + .map((p) => p.key) + .filter((key) => { + const v = params[key]; + return v == null || (typeof v === "string" && !v.trim()); + }); + + const intent = + typeof this.session.slots["用户.下一步意向"] === "string" + ? String(this.session.slots["用户.下一步意向"]).trim() + : ""; + const paramHint = + Object.keys(params).length > 0 + ? `;参数 ${Object.entries(params) + .map(([k, v]) => `${k}=${v}`) + .join(" · ")}` + : paramsMissing.length + ? `;待钉参数:${paramsMissing.join("、")}` + : ""; + const reason = [ + `下一步:${pending.name}`, + declaration ? `—— ${declaration}` : "", + paramHint, + intent ? `(你的意向:${intent})` : "", + ] + .filter(Boolean) + .join(""); + + const snapshot = { + stepId: pending.id, + name: pending.name, + declaration: declaration || undefined, + params, + paramsMissing, + paramSpecs: paramSpecs.map((p) => ({ + key: p.key, + label: p.label, + required: Boolean(p.required), + hint: p.hint, + })), + intent: intent || undefined, + }; + this.blackboard.write({ + tag: CREATION_PROPOSED_STEP_TAG, + content: JSON.stringify(snapshot), + source: "runtime", + }); + this.session = { + ...this.session, + slots: { + ...this.session.slots, + [CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot), + }, + }; + + const decision = createDecision({ + action: "run_worker", + workerId: "design-step", + reason, + requiresApproval: true, + }); + await this.dispatch({ + type: "main_agent_decision_created", + payload: { decision }, + }); + } + + private applyProposedStepParams(params: CreationFlowStepParams): void { + const raw = + (typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string" + ? String(this.session.slots[CREATION_PROPOSED_STEP_TAG]) + : null) || + this.blackboard.getContentByTag(CREATION_PROPOSED_STEP_TAG); + if (!raw?.trim()) return; + let snap: { + stepId?: string; + paramSpecs?: Array<{ key: string; required?: boolean }>; + }; + try { + snap = JSON.parse(raw) as typeof snap; + } catch { + return; + } + const stepId = typeof snap.stepId === "string" ? snap.stepId.trim() : ""; + if (!stepId) return; + const flow = parseCreationFlow( + this.blackboard.getContentByTag(CREATION_FLOW_TAG), + ); + if (!flow) return; + const next = patchCreationFlowStepParams(flow, stepId, params); + const content = stringifyCreationFlow(next); + this.blackboard.write({ + tag: CREATION_FLOW_TAG, + content, + source: "user", + }); + const paramsMissing = (snap.paramSpecs ?? []) + .filter((p) => p.required) + .map((p) => p.key) + .filter((key) => { + const v = params[key]; + return v == null || (typeof v === "string" && !v.trim()); + }); + const snapshot = { + ...snap, + params, + paramsMissing, + }; + this.blackboard.write({ + tag: CREATION_PROPOSED_STEP_TAG, + content: JSON.stringify(snapshot), + source: "runtime", + }); + this.session = { + ...this.session, + slots: { + ...this.session.slots, + [CREATION_FLOW_TAG]: content, + [CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot), + }, + }; + } + + private assertProposedStepReady(): string | null { + const decision = this.session.pendingDecision; + if (decision?.workerId !== "design-step") return null; + const raw = + (typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string" + ? String(this.session.slots[CREATION_PROPOSED_STEP_TAG]) + : null) || + this.blackboard.getContentByTag(CREATION_PROPOSED_STEP_TAG); + if (!raw?.trim()) return null; + try { + const snap = JSON.parse(raw) as { paramsMissing?: string[] }; + if (snap.paramsMissing?.length) { + return `请先补齐必填参数:${snap.paramsMissing.join("、")}`; + } + } catch { + /* ignore */ + } + return null; + } + private getInstanceWorkerDeclaration() { return buildInstanceWorkerDeclaration( this.session, @@ -840,24 +1145,50 @@ export class PhaseRuntime { if (result.askUser?.length && Object.keys(result.outputs).length === 0) { // 无产物:阻塞追问(信息不足,必须补) + if (await this.restoreRevisionTargetForReview(workerId, result.askUser)) { + return; + } await this.workerAsk(result.askUser); return; } + if ( + workerId === "design-step" && + Object.keys(result.outputs).length === 0 + ) { + const retryQuestions = result.askUser?.length + ? result.askUser + : [ + { + id: "design-step-retry", + prompt: + "这一步还没写出可验收的产物。请再补一点你最在意的体验或参与方式;也可以说「按已有描述先出一版」。", + allowOther: true, + required: true, + }, + ]; + if (await this.restoreRevisionTargetForReview(workerId, retryQuestions)) { + return; + } + await this.workerAsk(retryQuestions); + return; + } slots = { ...this.session.slots }; + const writtenTags: string[] = []; for (const [tag, content] of Object.entries(result.outputs)) { + if (isProgressPointerTag(tag)) continue; const written = this.writeWorkerTagContent(tag, content, workerId); slots[tag] = written; + writtenTags.push(tag); } this.session = { ...this.session, slots }; const artifact = createArtifact({ workerId: effect.workerId, stepId: this.session.currentStepId, - outputTags: - Object.keys(result.outputs).length > 0 - ? Object.keys(result.outputs) - : worker.outputTags, + outputTags: (writtenTags.length > 0 ? writtenTags : worker.outputTags).filter( + (t) => !isProgressPointerTag(t), + ), summary: result.summary, }); this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] }; @@ -871,7 +1202,7 @@ export class PhaseRuntime { payload: { artifactId: artifact.id, // 有产物时 askUser 挂到验收态,不阻断 Accept - questions: result.askUser?.length ? result.askUser : undefined, + questions: this.freshSidecarQuestions(result.askUser), assessment: result.askAssessment?.trim() || undefined, }, }); @@ -1054,6 +1385,22 @@ export class PhaseRuntime { } } } + if (tag === CREATION_FLOW_TAG) { + const merged = mergeCreationFlowPreservingAccepted({ + prevRaw: this.blackboard.getContentByTag(tag), + nextRaw: toWrite, + acceptedStepIds: parseAcceptedUnits( + this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ?? + this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG), + ), + }); + toWrite = merged.raw; + if (merged.restored.length) { + this.onMessage( + `[流程编排] 已验收步骤按原样保留:${merged.restored.join("、")}`, + ); + } + } this.blackboard.write({ tag, content: toWrite, @@ -1252,6 +1599,48 @@ export class PhaseRuntime { if (phase === "answered") return false; + // 配方开局模块 + 用户首句已在「用户.需求」→ 默认视为 opening 已做完,直接进 LLM + const demand = ( + this.blackboard.getContentByTag("用户.需求") ?? + this.session.slots["用户.需求"] ?? + "" + ) + .toString() + .trim(); + if ( + shouldSkipModuleOpening({ + demand, + dependsOn: binding.step.depends_on, + phase, + }) + ) { + state[stepKey] = "answered"; + const raw = stringifyModuleOpeningState(state); + this.blackboard.write({ + tag: CREATION_MODULE_OPENING_STATE_TAG, + content: raw, + source: "runtime", + }); + this.blackboard.write({ + tag: CREATION_MODULE_OPENING_TAG, + content: "(已跳过默认问题:用户首句见「用户.需求」)", + source: "runtime", + }); + this.session = { + ...this.session, + slots: { + ...this.session.slots, + [SLOT_CREATION_MODULE_OPENING_STATE]: raw, + [CREATION_MODULE_OPENING_TAG]: + "(已跳过默认问题:用户首句见「用户.需求」)", + }, + }; + this.onMessage( + `[系统] ${binding.module.name} · 已坐在配方开局步,首句见「用户.需求」,跳过默认问题`, + ); + return false; + } + if (phase === "shown" && this.session.resumeContext) { // 用户刚答完默认问题 → 标记已答,继续走 LLM state[stepKey] = "answered"; @@ -1315,15 +1704,13 @@ export class PhaseRuntime { const questions = normalizeQuestions([ { - id: "module-opening", + id: MODULE_OPENING_QUESTION_ID, prompt: binding.opening.trim(), allowOther: true, required: true, }, ]); - this.onMessage( - `[Worker] ${binding.module.name} · 默认问题(程序):\n${binding.opening.trim()}`, - ); + // 正文由说话面 openingGuide 展示(对齐美学纲领);workerAsk 只发短调度句 await this.workerAsk(questions); return true; } @@ -1356,6 +1743,8 @@ export class PhaseRuntime { if (!current) current = "flow"; + debugLog("step", `钉住当前步骤 ${workerId} → ${current}`); + const anchorAt = new Date().toISOString(); this.blackboard.write({ tag: CREATION_CURRENT_UNIT_TAG, @@ -1386,17 +1775,29 @@ export class PhaseRuntime { 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 || - ""; + // 接受工作流计划 ≠ 某一步做完;「flow」不得写入已验收步骤 + if (artifact.workerId === "design-flow") return; const prev = parseAcceptedUnits( slots[SLOT_CREATION_ACCEPTED_UNITS] ?? this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG), ); + const unitId = + artifact.workerId === "design-step" + ? pickRecordedStepId({ + flow: parseCreationFlow( + this.blackboard.getContentByTag(CREATION_FLOW_TAG), + ), + alreadyAccepted: prev, + pinnedUnitId: fromSlot || fromBoard, + writtenCurrentStep: fromStep, + summaryHint: fromSummary, + }) ?? "" + : fromBoard || fromSlot || fromSummary || ""; + if (!unitId || unitId === "flow") return; const next = unitId && !prev.includes(unitId) ? [...prev, unitId] : prev; + if (next !== prev) { + debugLog("step", `验收步骤 ${unitId} 已验收 ${next.join("、")}`); + } slots[SLOT_CREATION_ACCEPTED_UNITS] = next; slots[SLOT_CREATION_CURRENT_UNIT] = unitId || undefined; this.blackboard.write({ @@ -1518,6 +1919,7 @@ export class PhaseRuntime { return; } + debugLog("step", "总管开始"); const availableWorkers = await this.resolveAvailableWorkers(); const decision = await this.mainAgent.runToolLoop( @@ -1554,13 +1956,16 @@ export class PhaseRuntime { }, ); + debugLog( + "step", + `总管决策 ${labelAction(decision.action)}${decision.workerId ? ` ${decision.workerId}` : ""}`, + ); this.onMessage(`[总管] ${decision.action}: ${decision.reason}`); - const result = applyEvent(this.session, { + const result = this.commitEvent({ type: "main_agent_decision_created", payload: { decision }, }); - this.session = result.session; if (result.error) { this.onMessage(result.error); diff --git a/src/server/agent-view.ts b/src/server/agent-view.ts index ed7ff0b..887255c 100644 --- a/src/server/agent-view.ts +++ b/src/server/agent-view.ts @@ -12,7 +12,12 @@ import { displayWorkerLabel, formatAgentDisplayTitle, formatWorkerDisplayTitle, + reviewComposerCopy, } from "./display-labels.js"; +import { + isModuleOpeningQuestions, + normalizeQuestions, +} from "../skills/question-protocol.js"; export type AgentMessageKind = | "user_input" @@ -167,6 +172,20 @@ export function classifyAgentMessage(text: string): EnrichedMessage { }; } + // 能力默认问题:正文在主栏说话面,调度消息只留短提示 + const moduleOpeningMsg = trimmed.match( + /^\[Worker\]\s*(\S+)\s*·\s*默认问题/, + ); + if (moduleOpeningMsg) { + return { + kind: "orchestrator_prompt", + actor: moduleOpeningMsg[1], + title: formatWorkerDisplayTitle(moduleOpeningMsg[1], "questions"), + body: "默认问题(请在主栏按引导作答)", + text: trimmed, + }; + } + const workerAskTagged = trimmed.match( /^\[Worker\]\s*(\S+)\s*提问[::]\s*\n?([\s\S]*)$/, ); @@ -240,7 +259,7 @@ export function classifyAgentMessage(text: string): EnrichedMessage { title: "可选追问", body: formatWorkerQuestionBody( trimmed.replace( - /^\[Worker\]\s*可选追问(可跳过,直接接受(?:目前)?产物)[::]\s*/, + /^\[Worker\]\s*可选追问(可跳过(?:,直接[^)]+)?)[::]\s*/, "", ), ), @@ -387,19 +406,52 @@ export function buildFocus( if (reason?.kind === "approve_step") { const worker = session.pendingDecision?.workerId ?? "skill"; + const proposed = + typeof session.slots["创作.待确认步骤"] === "string" + ? (() => { + try { + return JSON.parse(String(session.slots["创作.待确认步骤"])) as { + name?: string; + }; + } catch { + return null; + } + })() + : null; return { actorType: "orchestrator", actorId: "orchestrator", - actorLabel: "总管", - action: `建议调用 ${displayWorkerLabel(worker)}`, + actorLabel: "编排", + action: proposed?.name + ? `确认开始 · ${proposed.name}` + : `建议调用 ${displayWorkerLabel(worker)}`, detail: session.pendingDecision?.reason, }; } + if (reason?.kind === "next_intent") { + return { + actorType: "user", + actorLabel: "你", + action: "下一步想写什么", + detail: "可留空;发送后会展示下一节点供确认开干", + }; + } + if (reason?.kind === "worker_questions") { + const qs = normalizeQuestions(reason.questions); + if (isModuleOpeningQuestions(qs)) { + return { + actorType: "user", + actorId: reason.workerId, + actorLabel: "你", + action: "按引导先说几句", + detail: (qs[0]?.prompt ?? "").slice(0, 200) || "想到什么写什么", + }; + } const q = - reason.questions - ?.map((item) => item.prompt) + qs + .map((item) => item.prompt) .filter((s) => s?.trim()) .join(";") ?? ""; return { @@ -413,13 +465,16 @@ export function buildFocus( if (reason?.kind === "review_artifact") { const art = session.artifacts.find((a) => a.id === session.pendingArtifactId); + const copy = reviewComposerCopy(art?.workerId, { + hasQuestions: Boolean(reason.questions?.length), + }); const optionalQs = reason.questions?.length ? `;另有 ${reason.questions.length} 道可选追问` : ""; return { actorType: "user", actorLabel: "你", - action: "验收产物", + action: copy.taskTitle, detail: `${art?.summary ?? displayWorkerLabel(art?.workerId) ?? ""}${optionalQs}`, }; } diff --git a/src/server/display-labels.ts b/src/server/display-labels.ts index 05d1dd5..b2da090 100644 --- a/src/server/display-labels.ts +++ b/src/server/display-labels.ts @@ -122,3 +122,84 @@ export function formatAgentDisplayTitle(detail?: string): string { if (detail?.trim()) return `编排器 · ${detail.trim()}`; return "编排器"; } + +/** 底栏双按钮 + 工作面 kicker:随 waiting 产物种类变化 */ +export type ReviewComposerCopy = { + /** 底栏短芯片 */ + chip: string; + /** 工作面左上角 */ + kicker: string; + taskLabel: string; + taskTitle: string; + hint: string; + submitLabel: string; + acceptLabel: string; + placeholder: string; + emptyEnterHint: string; + /** plan=编排确认(主色);accept=产物验收(绿) */ + tone: "plan" | "accept"; + kind: "flow" | "opening" | "accept"; +}; + +/** + * `review_artifact` 的用户文案。流程编排核对的是工作流计划,不是「验收通过」。 + */ +export function reviewComposerCopy( + workerId: string | undefined | null, + opts?: { hasQuestions?: boolean }, +): ReviewComposerCopy { + const hasQuestions = Boolean(opts?.hasQuestions); + const id = (workerId ?? "").trim(); + + if (id === "design-flow") { + return { + chip: "编排", + kicker: "核对编排", + taskLabel: "编排", + taskTitle: "核对工作流计划", + hint: hasQuestions + ? "同意就按这个排;要改就写意见,在现有计划上改。下方追问可选答。" + : "同意就按这个排;要改就写意见,在现有计划上改。", + submitLabel: "按意见改编排", + acceptLabel: "确认编排", + placeholder: "改编排意见…", + emptyEnterHint: "空 Enter=确认编排", + tone: "plan", + kind: "flow", + }; + } + + if (id === "opening-generator") { + return { + chip: "开场", + kicker: "待选定", + taskLabel: "开场", + taskTitle: "选定开场白", + hint: hasQuestions + ? "同意就选定此开场;要改就写意见。下方追问可选答。" + : "同意就选定此开场;要改就写意见。", + submitLabel: "按意见修改", + acceptLabel: "选定此开场", + placeholder: "修改意见…", + emptyEnterHint: "空 Enter=选定此开场", + tone: "plan", + kind: "opening", + }; + } + + return { + chip: "验收", + kicker: "待验收", + taskLabel: "验收", + taskTitle: "验收产物", + hint: hasQuestions + ? "同意就接受;要改就写意见,在现有产物上改。下方追问可选答。" + : "同意就接受;要改就写意见,在现有产物上改。", + submitLabel: "按意见修改", + acceptLabel: "接受", + placeholder: "修改意见…", + emptyEnterHint: "空 Enter=接受", + tone: "accept", + kind: "accept", + }; +} diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index 01fcf8c..4313b25 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -63,7 +63,12 @@ import type { BlackboardItem } from "../types/blackboard.js"; import { Blackboard } from "../blackboard/blackboard.js"; import { parseWorkerSetYaml } from "../skills/worker-set-parse.js"; import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.js"; -import { parseAcceptedUnits, isDesignDiskWorker } from "../skills/creation-units.js"; +import { + CREATION_ACCEPTED_UNITS_TAG, + isDesignDiskWorker, + parseAcceptedUnits, + SLOT_CREATION_ACCEPTED_UNITS, +} from "../skills/creation-units.js"; import { formatWorkerSetForUser, type WorkerSetUserView, @@ -78,13 +83,21 @@ import { type ContextOrderEdit, } from "../skills/context-order.js"; import { + CREATION_CURRENT_STEP_TAG, CREATION_FLOW_TAG, + CREATION_PROPOSED_STEP_TAG, CREATION_SELECTED_RECIPE_TAG, + creationFlowFromRecipeSeed, findRecipeCatalogEntry, formatCreationFlowForUser, + isProgressPointerTag, loadRecipeCatalog, + loadRecipeDetail, parseCreationFlow, parseSelectedRecipeRef, + resolveDesignStepBinding, + stepUnitId, + stringifyCreationFlow, type CreationFlowUserView, type RecipeCatalogEntry, } from "../skills/creation-flow.js"; @@ -94,6 +107,7 @@ import { buildBoardPanel } from "../runtime/compress-after-worker.js"; import { displayWorkerLabel, formatWorkerDisplayTitle, + reviewComposerCopy, } from "./display-labels.js"; import { buildCreationDialogueTranscript, @@ -102,6 +116,7 @@ import { import { formatQuestionAnswersForAi, formatQuestionAnswersForDisplay, + isModuleOpeningQuestions, normalizeQuestions, } from "../skills/question-protocol.js"; import type { QuestionAnswer } from "../types/questions.js"; @@ -180,10 +195,16 @@ export type SessionView = { workerSetView?: WorkerSetUserView; /** 创作流程用户视图(步骤顺序 + 依赖) */ creationFlowView?: CreationFlowUserView; + /** 确认开干:下一步提案(含可编辑编排参数) */ + proposedNextStep?: ProposedNextStepView; /** 能力包内可选初始配方(用户手动选) */ recipes?: Array<{ id: string; name: string; declaration: string }>; /** 用户已选初始配方 */ selectedRecipe?: { id: string; name: string; declaration: string } | null; + /** + * 开局引导:配方起点或当前能力默认问题的 opening(说话面主柱;对齐美学纲领)。 + */ + openingGuide?: { stepId: string; stepName: string; text: string } | null; /** 黑板与定稿上下文(强可读) */ boardPanel?: BoardPanelView; /** 游玩呈现壳微调(来自设计.正文组成) */ @@ -238,6 +259,21 @@ export type BoardPanelView = { brief?: string; }; +export type ProposedNextStepView = { + stepId: string; + name: string; + declaration?: string; + params: Record; + paramsMissing: string[]; + paramSpecs: Array<{ + key: string; + label: string; + required: boolean; + hint?: string; + }>; + intent?: string; +}; + export type ReviewArtifactView = { id: string; workerId: string; @@ -263,14 +299,18 @@ type ManagedSession = { branchState: MessageBranchState; /** 当前能力包可选初始配方(用户手动选) */ recipeOptions?: RecipeCatalogEntry[]; + /** 开局模块 opening,缓存给意图页 */ + openingGuide?: { stepId: string; stepName: string; text: string } | null; }; export class SessionManager { private readonly sessions = new Map(); /** bookId → 当前内存中的 sessionId */ private readonly activeBookSessions = new Map(); - /** sessionId → 流式 thinking 缓冲 */ + /** sessionId → 流式 thinking 缓冲(思考结束后仍保留,直到本轮结束) */ private readonly agentThinkingLive = new Map(); + /** 本轮思维链已收束;下一次 delta 起新一轮 */ + private readonly agentThinkingClosed = new Set(); /** sessionId → worker 流式缓冲 */ private readonly workerLive = new Map< string, @@ -679,7 +719,11 @@ export class SessionManager { return count; } - async sendMessage(id: string, text: string): Promise { + async sendMessage( + id: string, + text: string, + opts?: { answers?: QuestionAnswer[] }, + ): Promise { const s = this.require(id); this.clearAgentThinking(id); recordPreMessageCheckpoint( @@ -687,21 +731,62 @@ export class SessionManager { 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; + const trimmed = text.trim(); + const sidecarAnswers = + reason?.kind === "review_artifact" && + reason.questions?.length && + opts?.answers?.length + ? opts.answers.filter((a) => a.text?.trim() && a.text !== "(未答)") + : []; + + // next_intent 允许留空:不塞空白气泡 + if (trimmed || reason?.kind !== "next_intent" || sidecarAnswers.length) { + let recordText = trimmed || text; + let displayBody: string | undefined; + if (sidecarAnswers.length && reason?.kind === "review_artifact") { + const qs = normalizeQuestions(reason.questions ?? []); + recordText = formatQuestionAnswersForAi( + qs, + sidecarAnswers, + trimmed || undefined, + ); + displayBody = formatQuestionAnswersForDisplay( + qs, + sidecarAnswers, + trimmed || undefined, + ); + } + const userMsg = this.msg("user", recordText); + if (displayBody) userMsg.body = displayBody; + s.messages.push(userMsg); + this.syncCreationDialogue(s); + if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + } try { if (reason?.kind === "approve_step") { - await s.runtime.rejectStep(text); + await s.runtime.rejectStep(trimmed || text); } else if (reason?.kind === "review_artifact") { - await s.runtime.rejectArtifact(text); + // 先把卡上追问(问+选项+补充)写入「用户.worker答复」,再按意见改产物 + if (sidecarAnswers.length && reason.questions?.length) { + const qaText = formatQuestionAnswersForAi( + normalizeQuestions(reason.questions), + sidecarAnswers, + trimmed || undefined, + ); + await s.runtime.resolveSidecarQuestions(qaText); + } + if (trimmed || sidecarAnswers.length) { + await s.runtime.rejectArtifact( + trimmed || "按追问作答更新产物", + ); + } } else { - await s.runtime.submitInput(text); + await s.runtime.submitInput(trimmed || text); } } catch (err) { const detail = err instanceof Error ? err.message : String(err); - console.error("[session] sendMessage failed:", detail); + console.error("[会话] 发送消息失败", detail); s.messages.push(this.msg("system", formatRuntimeError(detail))); if (s.bookId) this.syncBookPreview(s.bookId, s.messages); } finally { @@ -755,12 +840,14 @@ export class SessionManager { try { if (isSidecar) { await s.runtime.resolveSidecarQuestions(aiText); + // 作答必须写回产物,否则验收后进度空转、追问在美学/交互间打转 + await s.runtime.rejectArtifact("按追问作答更新产物"); } else { await s.runtime.submitInput(aiText); } } catch (err) { const detail = err instanceof Error ? err.message : String(err); - console.error("[session] answerQuestions failed:", detail); + console.error("[会话] 回答追问失败", detail); s.messages.push(this.msg("system", formatRuntimeError(detail))); if (s.bookId) this.syncBookPreview(s.bookId, s.messages); } finally { @@ -960,13 +1047,16 @@ export class SessionManager { return this.toView(id); } - async approve(id: string): Promise { + async approve( + id: string, + stepParams?: Record, + ): Promise { const s = this.require(id); try { - await s.runtime.approve(); + await s.runtime.approve(stepParams); } catch (err) { const detail = err instanceof Error ? err.message : String(err); - console.error("[session] approve failed:", detail); + console.error("[会话] 确认开干失败", detail); s.messages.push(this.msg("system", formatRuntimeError(detail))); } return this.toView(id); @@ -978,7 +1068,7 @@ export class SessionManager { await s.runtime.confirmIntake(); } catch (err) { const detail = err instanceof Error ? err.message : String(err); - console.error("[session] confirmIntake failed:", detail); + console.error("[会话] 确认需求失败", detail); s.messages.push(this.msg("system", formatRuntimeError(detail))); if (s.bookId) this.syncBookPreview(s.bookId, s.messages); } @@ -1028,7 +1118,7 @@ export class SessionManager { } } catch (err) { const detail = err instanceof Error ? err.message : String(err); - console.error("[session] reject failed:", detail); + console.error("[会话] 打回失败", detail); s.messages.push(this.msg("system", formatRuntimeError(detail))); if (s.bookId) this.syncBookPreview(s.bookId, s.messages); } @@ -1154,7 +1244,7 @@ export class SessionManager { payload: { text }, }); } catch (err) { - console.error("[session] maybeKickAgentAfterDemand failed:", err); + console.error("[会话] 需求后唤醒总管失败", err); } } @@ -1214,7 +1304,7 @@ export class SessionManager { const transcript = buildCreationDialogueTranscript(s.messages); s.runtime.writeCreationDialogue(transcript); } catch (err) { - console.warn("[session] syncCreationDialogue:", err); + console.warn("[会话] 同步创作对话失败", err); } } @@ -1274,6 +1364,7 @@ export class SessionManager { private clearAgentThinking(sessionId: string): void { this.agentThinkingLive.delete(sessionId); + this.agentThinkingClosed.delete(sessionId); this.workerLive.delete(sessionId); } @@ -1287,15 +1378,20 @@ export class SessionManager { | "onWorkerStreamStart" | "onWorkerThinkingDelta" | "onWorkerOutputDelta" - | "onWorkerStreamDone" > { return { onAgentThinkingDelta: (delta) => { - const prev = this.agentThinkingLive.get(sessionId) ?? ""; + const prev = this.agentThinkingClosed.has(sessionId) + ? "" + : (this.agentThinkingLive.get(sessionId) ?? ""); + this.agentThinkingClosed.delete(sessionId); this.agentThinkingLive.set(sessionId, prev + delta); }, - onAgentThinkingDone: () => { - this.agentThinkingLive.delete(sessionId); + onAgentThinkingDone: (text) => { + // 正文仍等完整后再渲染;思考收束后继续挂在 liveStream,避免中间只剩「…」 + this.agentThinkingClosed.add(sessionId); + const done = text.trim(); + if (done) this.agentThinkingLive.set(sessionId, done); }, onWorkerStreamStart: (workerId) => { this.workerLive.set(sessionId, { workerId, thinking: "", output: "" }); @@ -1322,9 +1418,6 @@ export class SessionManager { prev.output += delta; this.workerLive.set(sessionId, prev); }, - onWorkerStreamDone: () => { - this.workerLive.delete(sessionId); - }, }; } @@ -1603,47 +1696,94 @@ export class SessionManager { } } else if (reason?.kind === "worker_questions") { const qs = normalizeQuestions(reason.questions); - if (qs.length) { + if (isModuleOpeningQuestions(qs)) { + // 能力默认问题:对齐美学纲领 → 自由书写,不是可跳过追问卡 + hints.push("按主栏引导先说几句(想到什么写什么,不必整齐)"); + actions.push({ + type: "send_message", + label: "发送", + placeholder: "想到什么写什么…", + }); + } else if (qs.length) { hints.push( `${displayWorkerLabel(reason.workerId)} · 提问(请在询问卡作答):${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( `${displayWorkerLabel(reason.workerId)} 需要更多信息,请补充说明`, ); + actions.push({ + type: "send_message", + label: "发送", + placeholder: "补充说明…", + }); } - actions.push({ type: "answer_questions", label: "提交作答" }); - actions.push({ type: "skip_questions", label: "跳过追问" }); - actions.push({ - type: "send_message", - label: "自由补充", - placeholder: "也可在此自由补充…", - }); } else if (reason?.kind === "approve_step") { - actions.push({ type: "approve", label: "确认执行" }); + const proposed = readProposedNextStep( + s.runtime.getSession().slots, + s.runtime.getBlackboard(), + ); + if (proposed) { + hints.push( + `确认开始「${proposed.name}」${ + proposed.paramsMissing.length + ? `(请先补齐:${proposed.paramsMissing.join("、")})` + : "" + }`, + ); + actions.push({ + type: "approve", + label: proposed.paramsMissing.length ? "补参后开始" : "同意并开始", + }); + } else { + actions.push({ type: "approve", label: "确认执行" }); + } actions.push({ type: "reject", label: "暂不执行" }); - } else if (reason?.kind === "review_artifact") { - actions.push({ type: "accept", label: "接受目前产物" }); - actions.push({ type: "reject", label: "不接受,重新来" }); actions.push({ type: "send_message", - label: "发送修改意见", - placeholder: "输入修改意见后发送…", + label: "说明意见", + placeholder: "不同意可说明要改成什么…", + }); + } else if (reason?.kind === "next_intent") { + hints.push("下一步想写什么?可留空,发送后会展示下一节点供你确认。"); + actions.push({ + type: "send_message", + label: "继续", + placeholder: "下一步想写什么?(可留空)…", + }); + } else if (reason?.kind === "review_artifact") { + const art = session.artifacts.find((a) => a.id === session.pendingArtifactId); + const copy = reviewComposerCopy(art?.workerId, { + hasQuestions: Boolean(reason.questions?.length), + }); + actions.push({ type: "accept", label: copy.acceptLabel }); + actions.push({ type: "reject", label: "不接受" }); + actions.push({ + type: "send_message", + label: copy.submitLabel, + placeholder: `${copy.placeholder};在现有产物上改,不整份重做…`, }); if (reason.questions?.length) { - hints.push("下方追问可选;接受产物即收起(表示无需再完善)"); + hints.push(`下方追问可选;${copy.acceptLabel}即收起(表示无需再完善)`); 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: "说明修改意见,或直接发送让总管继续…", + label: "按意见修改", + placeholder: "说明要改哪里…", }); } else if (session.phase === "error") { hints.push("上一轮执行出错。可直接发消息让总管重试,或刷新作品后继续。"); @@ -1737,7 +1877,17 @@ export class SessionManager { s.runtime.getBlackboard(), s.runtime.getSession(), ), - creationFlowView: buildCreationFlowUserView(s.runtime.getBlackboard()), + creationFlowView: buildCreationFlowUserView( + s.runtime.getBlackboard(), + session.slots[SLOT_CREATION_ACCEPTED_UNITS], + ), + proposedNextStep: + reason?.kind === "approve_step" + ? readProposedNextStep( + session.slots, + s.runtime.getBlackboard(), + ) + : undefined, recipes: s.recipeOptions?.map((r) => ({ id: r.id, name: r.name, @@ -1747,6 +1897,7 @@ export class SessionManager { s.runtime.getBlackboard(), s.recipeOptions, ), + openingGuide: this.resolveOpeningGuideForView(s, reason), boardPanel: buildBoardPanelFromRuntime(s.runtime.getBlackboard()), presentationTweaks: shellAdapt ? { @@ -1839,12 +1990,14 @@ export class SessionManager { const skillName = s.runtime.getActiveSkill()?.name; if (!skillName) { s.recipeOptions = []; + s.openingGuide = null; return; } try { const skill = await loadSkill(skillName); if (!skill.skillPackRoot) { s.recipeOptions = []; + s.openingGuide = null; return; } const catalog = await loadRecipeCatalog(skill.skillPackRoot); @@ -1852,6 +2005,125 @@ export class SessionManager { } catch { s.recipeOptions = []; } + await this.ensureRecipeSeededFlow(s); + await this.refreshOpeningGuide(s); + } + + /** 已选配方但尚无流程 → 写入配方近期起点(兼容旧会话) */ + private async ensureRecipeSeededFlow(s: ManagedSession): Promise { + const board = s.runtime.getBlackboard(); + if (board.getContentByTag(CREATION_FLOW_TAG)?.trim()) return; + const ref = parseSelectedRecipeRef( + board.getContentByTag(CREATION_SELECTED_RECIPE_TAG), + ); + if (!ref) return; + const entry = findRecipeCatalogEntry( + s.recipeOptions?.length ? { recipes: s.recipeOptions } : null, + ref, + ); + if (!entry) return; + const skillName = s.runtime.getActiveSkill()?.name; + if (!skillName) return; + try { + const skill = await loadSkill(skillName); + const packRoot = skill.skillPackRoot?.trim(); + if (!packRoot) return; + const detail = await loadRecipeDetail(packRoot, entry); + const seeded = creationFlowFromRecipeSeed(detail); + if (!seeded) return; + board.write({ + tag: CREATION_FLOW_TAG, + content: stringifyCreationFlow(seeded), + source: "runtime", + }); + } catch (err) { + console.error("[会话] 配方预置失败", err); + } + } + + /** + * 下一待做开局步(无 depends_on)的 opening → 意图页引导。 + * 有起点模块就展示;无则 null(再走 design-flow 生成)。 + * 中段步骤的默认问题改由 resolveOpeningGuideForView 在 waiting 时覆盖。 + */ + private async refreshOpeningGuide(s: ManagedSession): Promise { + const skillName = s.runtime.getActiveSkill()?.name; + if (!skillName) { + s.openingGuide = null; + return; + } + try { + const skill = await loadSkill(skillName); + const packRoot = skill.skillPackRoot?.trim(); + if (!packRoot) { + s.openingGuide = null; + return; + } + const board = s.runtime.getBlackboard(); + const session = s.runtime.getSession(); + const accepted = parseAcceptedUnits( + session.slots[SLOT_CREATION_ACCEPTED_UNITS] ?? + board.getContentByTag(CREATION_ACCEPTED_UNITS_TAG), + ); + const binding = await resolveDesignStepBinding({ + skillPackRoot: packRoot, + flowRaw: board.getContentByTag(CREATION_FLOW_TAG), + acceptedStepNames: accepted, + }); + if ( + !binding?.opening?.trim() || + (binding.step.depends_on?.length ?? 0) > 0 + ) { + s.openingGuide = null; + return; + } + s.openingGuide = { + stepId: binding.step.id, + stepName: binding.module.name || binding.step.name, + text: binding.opening.trim(), + }; + } catch { + s.openingGuide = null; + } + } + + /** + * 视图用开场引导:等待能力默认问题时用题干(对齐美学纲领说话面); + * 中段步骤(有 depends_on)也会覆盖,避免只剩空答题壳。 + */ + private resolveOpeningGuideForView( + s: ManagedSession, + reason: WaitingReason | undefined, + ): { stepId: string; stepName: string; text: string } | null { + if (reason?.kind === "worker_questions") { + const qs = normalizeQuestions(reason.questions); + if (isModuleOpeningQuestions(qs)) { + const text = qs[0]?.prompt?.trim() ?? ""; + if (text) { + const board = s.runtime.getBlackboard(); + const stepKey = + board.getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim() || ""; + const flow = parseCreationFlow(board.getContentByTag(CREATION_FLOW_TAG)); + const step = flow?.steps?.find( + (st) => + stepUnitId(st) === stepKey || + st.id === stepKey || + st.name === stepKey, + ); + const stepName = + step?.name?.trim() || + s.openingGuide?.stepName?.trim() || + stepKey || + "按引导先说几句"; + return { + stepId: qs[0]!.id, + stepName, + text, + }; + } + } + } + return s.openingGuide ?? null; } private async applySelectedRecipe( @@ -1866,7 +2138,8 @@ export class SessionManager { if (!entry) { throw new Error(`未知初始配方:${recipeId}`); } - s.runtime.getBlackboard().write({ + const board = s.runtime.getBlackboard(); + board.write({ tag: CREATION_SELECTED_RECIPE_TAG, content: JSON.stringify({ id: entry.id, @@ -1874,6 +2147,33 @@ export class SessionManager { }), source: "user", }); + + // 剧本已钉死开局模块 → 默认写入近期起点,开局即坐在 DAG 第一步(不必先跑 design-flow) + const existingFlow = board.getContentByTag(CREATION_FLOW_TAG)?.trim(); + if (!existingFlow) { + const skillName = s.runtime.getActiveSkill()?.name; + if (skillName) { + try { + const skill = await loadSkill(skillName); + const packRoot = skill.skillPackRoot?.trim(); + if (packRoot) { + const detail = await loadRecipeDetail(packRoot, entry); + const seeded = creationFlowFromRecipeSeed(detail); + if (seeded) { + board.write({ + tag: CREATION_FLOW_TAG, + content: stringifyCreationFlow(seeded), + source: "runtime", + }); + } + } + } catch (err) { + console.error("[会话] 从配方写入创作流程失败", err); + } + } + } + + await this.refreshOpeningGuide(s); } } @@ -1893,6 +2193,7 @@ function collectFilledTags(blackboard: Blackboard): string[] { function buildCreationFlowUserView( blackboard: Blackboard, + acceptedRaw?: unknown, ): CreationFlowUserView | undefined { const raw = blackboard.getContentByTag(CREATION_FLOW_TAG)?.trim(); if (!raw) return undefined; @@ -1900,7 +2201,56 @@ function buildCreationFlowUserView( if (!flow) { return { steps: [], parseError: "创作流程无法解析为 JSON(需要 steps 数组)" }; } - return formatCreationFlowForUser(flow); + const accepted = parseAcceptedUnits( + acceptedRaw ?? blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG), + ); + return formatCreationFlowForUser(flow, undefined, accepted); +} + +function readProposedNextStep( + slots: Record, + blackboard: Blackboard, +): ProposedNextStepView | undefined { + const raw = + (typeof slots[CREATION_PROPOSED_STEP_TAG] === "string" + ? String(slots[CREATION_PROPOSED_STEP_TAG]) + : null) || blackboard.getContentByTag(CREATION_PROPOSED_STEP_TAG); + if (!raw?.trim()) return undefined; + try { + const doc = JSON.parse(raw) as Partial; + if (typeof doc.stepId !== "string" || typeof doc.name !== "string") { + return undefined; + } + return { + stepId: doc.stepId, + name: doc.name, + declaration: + typeof doc.declaration === "string" ? doc.declaration : undefined, + params: + doc.params && typeof doc.params === "object" && !Array.isArray(doc.params) + ? (doc.params as Record) + : {}, + paramsMissing: Array.isArray(doc.paramsMissing) + ? doc.paramsMissing.map(String) + : [], + paramSpecs: Array.isArray(doc.paramSpecs) + ? doc.paramSpecs + .map((p) => ({ + key: String((p as { key?: string }).key ?? ""), + label: String((p as { label?: string }).label ?? ""), + required: Boolean((p as { required?: boolean }).required), + hint: + typeof (p as { hint?: string }).hint === "string" + ? (p as { hint: string }).hint + : undefined, + })) + .filter((p) => p.key) + : [], + intent: typeof doc.intent === "string" ? doc.intent : undefined, + }; + } catch { + return undefined; + } } function resolveSelectedRecipeView( @@ -1972,19 +2322,46 @@ function buildReviewArtifactView( for (const tag of art.outputTags) { if (workerSetTags.includes(tag)) continue; + if (isProgressPointerTag(tag)) continue; const content = blackboard.getContentByTag(tag); if (content?.trim()) { tagBodies.push(`## ${tag}\n\n${content.trim()}`); } } + // 回退消息里也可能夹带「创作.当前步骤」段落,验收正文里去掉 + const stripPointerSections = (text: string): string => { + const trimmed = text.trim(); + if (!trimmed) return ""; + const re = /^##\s+(.+?)\s*$/gm; + const hits: Array<{ title: string; index: number; headerEnd: number }> = []; + let m: RegExpExecArray | null; + while ((m = re.exec(trimmed))) { + hits.push({ + title: m[1]!.trim(), + index: m.index, + headerEnd: m.index + m[0].length, + }); + } + if (!hits.length) return trimmed; + return hits + .filter((h) => !isProgressPointerTag(h.title) && !/当前步骤|当前单位/.test(h.title)) + .map((h, i) => { + const end = i + 1 < hits.length ? hits[i + 1]!.index : trimmed.length; + const chunk = trimmed.slice(h.headerEnd, end).trim(); + return chunk ? `## ${h.title}\n\n${chunk}` : ""; + }) + .filter(Boolean) + .join("\n\n"); + }; + 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() || + stripPointerSections((outputMsg?.body ?? outputMsg?.text ?? "").trim()) || art.summary?.trim() || "(Worker 未写入可读正文,请检查黑板 tag 或重试创作 skill)"; @@ -2028,7 +2405,10 @@ function buildReviewArtifactView( : undefined; const creationFlowView = isFlowArt - ? buildCreationFlowUserView(blackboard) + ? buildCreationFlowUserView( + blackboard, + session.slots[SLOT_CREATION_ACCEPTED_UNITS], + ) : undefined; return { diff --git a/src/server/web-server.ts b/src/server/web-server.ts index e46003a..7b9b506 100644 --- a/src/server/web-server.ts +++ b/src/server/web-server.ts @@ -7,9 +7,11 @@ import { sessionManager } from "./session-manager.js"; import { handleSettingsApi } from "./settings-handlers.js"; import { handleBooksApi } from "./book-handlers.js"; import { handleStatsApi } from "./stats-handlers.js"; +import { ensureConsoleUtf8, getRuntimeLogPath } from "../log.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, "../.."); +ensureConsoleUtf8(); loadDotEnv(PROJECT_ROOT); const WEB_ROOT = path.resolve(__dirname, "../../web"); const PORT = Number(process.env.PORT ?? 23337); @@ -122,12 +124,45 @@ const server = createServer(async (req, res) => { } if (req.method === "POST" && sub === "/messages") { - const body = JSON.parse(await readBody(req)) as { text?: string }; - if (!body.text?.trim()) { - json(res, 400, { error: "text 不能为空" }); - return; + const body = JSON.parse(await readBody(req)) as { + text?: string; + answers?: Array<{ + questionId?: string; + optionId?: string; + text?: string; + }>; + }; + const text = typeof body.text === "string" ? body.text : ""; + const trimmed = text.trim(); + 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(), + })); + // next_intent 明确可留空;其余态仍要求有字(除非顺带提交了追问) + if (!trimmed && !answers.length) { + const current = sessionManager.get(sessionId); + if (current?.waitingReason?.kind !== "next_intent") { + json(res, 400, { error: "text 不能为空" }); + return; + } } - const view = await sessionManager.sendMessage(sessionId, body.text.trim()); + const view = await sessionManager.sendMessage( + sessionId, + trimmed, + answers.length ? { answers } : undefined, + ); json(res, 200, view); return; } @@ -406,11 +441,19 @@ const server = createServer(async (req, res) => { } if (req.method === "POST" && sub === "/actions") { - const body = JSON.parse(await readBody(req)) as { action?: string }; + const body = JSON.parse(await readBody(req)) as { + action?: string; + stepParams?: Record; + }; let view; switch (body.action) { case "approve": - view = await sessionManager.approve(sessionId); + view = await sessionManager.approve( + sessionId, + body.stepParams && typeof body.stepParams === "object" + ? body.stepParams + : undefined, + ); break; case "confirm_intake": view = await sessionManager.confirmIntake(sessionId); @@ -452,7 +495,7 @@ const server = createServer(async (req, res) => { json(res, 404, { error: "Not found" }); } } catch (err) { - console.error("[api]", req.method, url.pathname, err); + console.error("[接口]", req.method, url.pathname, err); json(res, 500, { error: err instanceof Error ? err.message : "服务器错误", }); @@ -460,5 +503,6 @@ const server = createServer(async (req, res) => { }); server.listen(PORT, () => { - console.log(`Writing Agent 对话页: http://localhost:${PORT}`); + console.log(`对话页 http://localhost:${PORT}`); + console.log(`运行日志(UTF-8) ${getRuntimeLogPath()}`); }); diff --git a/src/skills/context-fragment.ts b/src/skills/context-fragment.ts index 5c63f1f..7341831 100644 --- a/src/skills/context-fragment.ts +++ b/src/skills/context-fragment.ts @@ -115,6 +115,46 @@ export function isContextFragmentDoc(doc: unknown): boolean { return Boolean(parseContextFragment(doc)); } +const NON_FRAGMENT_DESIGN_TAGS = new Set([ + "设计.创作流程", + "设计.worker集", + "设计.worker集.草稿", + "设计.worker规格", + "设计.上下文投影排序", +]); + +/** 该 output tag 应按 context-fragment.v1 验收(有可用「正文」) */ +export function expectsContextFragmentTag(tag: string): boolean { + const t = tag.trim(); + if (!t.startsWith("设计.")) return false; + return !NON_FRAGMENT_DESIGN_TAGS.has(t); +} + +export function hasUsableFragmentBody(body: unknown): boolean { + if (body == null) return false; + if (typeof body === "string") return body.trim().length > 0; + if (Array.isArray(body)) return body.length > 0; + if (typeof body === "object") return Object.keys(body as object).length > 0; + return false; +} + +/** 能进验收卡:解析成功且「正文」非空 */ +export function isUsableContextFragment(raw: unknown): boolean { + const frag = parseContextFragment(raw); + return Boolean(frag && hasUsableFragmentBody(frag.正文)); +} + +export function looksLikeFragmentDoc(doc: unknown): boolean { + if (!doc || typeof doc !== "object" || Array.isArray(doc)) return false; + const row = doc as Record; + if (row.schema === CONTEXT_FRAGMENT_SCHEMA) return true; + if (typeof row.技能 === "string" && row.技能.trim()) return true; + return ( + typeof row.brief === "string" && + ("正文" in row || "body" in row) + ); +} + /** 投影级别:summary 优先 brief;fields 尝试列出正文顶层键 */ export function projectFragmentContent( raw: string, diff --git a/src/skills/context-segments.ts b/src/skills/context-segments.ts index 1c650b3..47ee9b6 100644 --- a/src/skills/context-segments.ts +++ b/src/skills/context-segments.ts @@ -7,7 +7,9 @@ import type { BlackboardInputMerge } from "../types/blackboard.js"; import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js"; import { CREATION_ACCEPTED_CONTENT_TAG, + CREATION_ACCEPTED_UNITS_TAG, formatAcceptedContentForPrompt, + formatAcceptedUnitsForPrompt, } from "./creation-units.js"; import { projectFragmentContent } from "./context-fragment.js"; import { @@ -107,6 +109,8 @@ function formatSegmentBody( if (!content) continue; if (tag === CREATION_ACCEPTED_CONTENT_TAG) { content = formatAcceptedContentForPrompt(content); + } else if (tag === CREATION_ACCEPTED_UNITS_TAG) { + content = formatAcceptedUnitsForPrompt(content); } content = applyPolicy(content, segment.policy); content = applyProjection(content, segment.projection); @@ -154,15 +158,20 @@ export function assembleWorkerContext(params: { const segments = params.segments ?? []; if (segments.length === 0) { + const revisionNote = ( + params.inputs["用户.修订说明"] ?? + "" + ).trim(); return JSON.stringify( { workerId: params.workerId, workerName: params.workerName, outputTags: params.outputTags, inputs: params.inputs, - instruction: - "根据 SKILL 说明完成任务。若 inputs 含「上下文.定稿摘要」,以终产物为准。" + - "设计.worker集 / 草稿须为 JSON 对象文本。", + instruction: revisionNote + ? "修订模式:在已有产物(inputs 中对应 outputTags)上按「用户.修订说明」以及「用户.worker答复」中的追问作答(选项与补充)修改;保留未点名要改的部分,禁止无故整份重写。节点方法(task/principles/probe/output)仍须遵守。" + : "根据 SKILL 说明完成任务。若 inputs 含「上下文.定稿摘要」,以终产物为准。" + + "设计.worker集 / 草稿须为 JSON 对象文本。", }, null, 2, @@ -191,18 +200,61 @@ export function assembleWorkerContext(params: { blocks.push(`## 上下文.定稿摘要\n\n${brief}`); } - blocks.push( - [ - "## 本步任务", - "", - `- workerId: \`${params.workerId}\``, - `- workerName: ${params.workerName}`, - `- outputTags: ${params.outputTags.map((t) => `\`${t}\``).join("、") || "(无)"}`, - "", + // 修订态:把本步 outputTags 上已有正文钉成「待改底稿」,禁止无故推倒重写 + const revisionNote = readTagContent( + "用户.修订说明", + params.inputs, + params.blackboard, + inputMerge, + ).trim(); + const draftBlocks: string[] = []; + if (revisionNote) { + for (const tag of params.outputTags) { + const alreadyInSegments = segments.some((s) => s.tags.includes(tag)); + if (alreadyInSegments) continue; + const draft = readTagContent( + tag, + params.inputs, + params.blackboard, + inputMerge, + ).trim(); + if (!draft) continue; + draftBlocks.push(`### \`${tag}\`\n\n${draft}`); + } + if (draftBlocks.length) { + blocks.push( + [ + "## 【待改底稿】", + "", + "下列为**当前已有产物**。按「用户.修订说明」以及「用户.worker答复」中的追问作答(选项与补充)在其上修改,并写回同名 outputTags。", + "节点方法(task / principles / probe / output)仍须遵守。保留未要求改动的结构与结论;禁止无故整份重写。", + "", + ...draftBlocks, + ].join("\n"), + ); + } + } + + const taskLines = [ + "## 本步任务", + "", + `- workerId: \`${params.workerId}\``, + `- workerName: ${params.workerName}`, + `- outputTags: ${params.outputTags.map((t) => `\`${t}\``).join("、") || "(无)"}`, + "", + ]; + if (revisionNote) { + taskLines.push( + "**修订模式**:接着上方【待改底稿】(若有)与依赖产物,落实「用户.修订说明」以及「用户.worker答复」里的追问作答(选项与补充意见)。", + "不要从零另起一份;不要扩大修改面。标为「只读 / 已定稿」的分区仍不可改。节点方法仍有效。", + ); + } else { + taskLines.push( "按 SKILL 与上方分区完成任务。标为「只读 / 已定稿」的分区不要擅自改写;只改【本单位】范围。", - "设计.worker集 / 草稿须为 JSON 对象文本(以 `{` 开头)。", - ].join("\n"), - ); + ); + } + taskLines.push("设计.worker集 / 草稿须为 JSON 对象文本(以 `{` 开头)。"); + blocks.push(taskLines.join("\n")); return blocks.join("\n\n---\n\n"); } diff --git a/src/skills/creation-flow.ts b/src/skills/creation-flow.ts index 8d11342..59511b5 100644 --- a/src/skills/creation-flow.ts +++ b/src/skills/creation-flow.ts @@ -13,8 +13,36 @@ import { parse as parseYaml } from "yaml"; export const CREATION_FLOW_TAG = "设计.创作流程"; export const CREATION_CURRENT_STEP_TAG = "创作.当前步骤"; +/** 确认开干前:待确认的下一步快照(JSON) */ +export const CREATION_PROPOSED_STEP_TAG = "创作.待确认步骤"; /** 用户手动选定的初始配方(存 recipe id,或 JSON {id,name}) */ export const CREATION_SELECTED_RECIPE_TAG = "创作.选用配方"; + +/** 进度指针:禁止当作 worker 产物写回或列入验收正文 */ +export const PROGRESS_POINTER_TAGS = new Set([ + CREATION_CURRENT_STEP_TAG, + CREATION_PROPOSED_STEP_TAG, + CREATION_SELECTED_RECIPE_TAG, + "创作.当前单位", + "创作.已验收单位", + "创作.已验收内容", + "创作.能力开场白", + "创作.能力开场状态", +]); + +/** 旧名:含「设计.创作流程」。写回/验收过滤请用 isProgressPointerTag */ +export const RUNTIME_PINNED_TAGS = new Set([ + CREATION_FLOW_TAG, + ...PROGRESS_POINTER_TAGS, +]); + +export function isProgressPointerTag(tag: string): boolean { + return PROGRESS_POINTER_TAGS.has(tag.trim()); +} + +export function isRuntimePinnedTag(tag: string): boolean { + return RUNTIME_PINNED_TAGS.has(tag.trim()); +} export const MODULE_CATALOG_FILENAME = "modules/catalog.yaml"; export const RECIPE_CATALOG_FILENAME = "recipes/catalog.yaml"; export const DESIGN_STEP_WORKER_ID = "design-step"; @@ -152,6 +180,9 @@ export type CreationFlowValidation = { errors: string[]; }; +/** 工作流计划节点相对执行进度(给人看的三态) */ +export type FlowStepRunState = "done" | "current" | "pending"; + export type CreationFlowUserView = { brief?: string; status?: CreationFlowStatus; @@ -169,6 +200,8 @@ export type CreationFlowUserView = { params?: CreationFlowStepParams; /** 参数缺必填项时的提示(给人看) */ paramsMissing?: string[]; + /** 已执行 / 将要执行 / 未执行 */ + runState: FlowStepRunState; }>; parseError?: string; }; @@ -301,6 +334,42 @@ export function stepUnitId(step: CreationFlowStep): string { return step.id || step.name; } +/** + * 验收时记下哪一步做完。只认流程里的真实 step.id: + * LLM 可能把产物误写进「创作.当前步骤」,那种内容不能当进度。 + */ +export function pickRecordedStepId(params: { + flow: CreationFlow | null; + alreadyAccepted?: readonly string[]; + pinnedUnitId?: string | null; + writtenCurrentStep?: string | null; + summaryHint?: string | null; +}): string | null { + const flow = params.flow; + const tryRef = (ref: string | undefined | null): string | null => { + const key = ref?.trim(); + if (!key || key === "flow") return null; + if (!flow) return key; + const step = findStepByRef(flow, key); + return step ? stepUnitId(step) : null; + }; + + const pinned = tryRef(params.pinnedUnitId); + if (pinned) return pinned; + + const written = tryRef(params.writtenCurrentStep); + if (written) return written; + + const hinted = tryRef(params.summaryHint); + if (hinted) return hinted; + + if (flow) { + const pending = nextPendingStep(flow, params.alreadyAccepted ?? []); + if (pending) return stepUnitId(pending); + } + return null; +} + export function parseCreationFlow(raw: string | undefined | null): CreationFlow | null { if (!raw?.trim()) return null; const doc = extractJsonObject(raw); @@ -591,6 +660,93 @@ export function stringifyModuleOpeningState(state: ModuleOpeningState): string { return JSON.stringify(state); } +/** + * 序列化「设计.创作流程」(程序 seed / 验收写回共用)。 + */ +export function stringifyCreationFlow(flow: CreationFlow): string { + return JSON.stringify({ + version: 1, + ...(flow.brief ? { brief: flow.brief } : {}), + ...(flow.status ? { status: flow.status } : {}), + steps: flow.steps, + }); +} + +/** + * 编排层重排流程时,已验收步骤按原样保留: + * 缺了就按原位置补回,改了名字/参数就还原。编排只能动未验收步。 + */ +export function mergeCreationFlowPreservingAccepted(params: { + prevRaw: string | null | undefined; + nextRaw: string; + acceptedStepIds: readonly string[]; +}): { raw: string; restored: string[] } { + const next = parseCreationFlow(params.nextRaw); + const prev = parseCreationFlow(params.prevRaw); + if (!next || !prev) return { raw: params.nextRaw, restored: [] }; + + const accepted = prev.steps.filter((s) => isStepAccepted(s, params.acceptedStepIds)); + if (accepted.length === 0) return { raw: params.nextRaw, restored: [] }; + + const restored: string[] = []; + const steps = [...next.steps]; + for (let i = 0; i < accepted.length; i++) { + const frozen = accepted[i]!; + const at = steps.findIndex((s) => s.id === frozen.id); + if (at < 0) { + steps.splice(Math.min(i, steps.length), 0, frozen); + restored.push(frozen.id); + continue; + } + const current = steps[at]!; + const changed = + current.name !== frozen.name || + JSON.stringify(current.depends_on) !== JSON.stringify(frozen.depends_on) || + JSON.stringify(current.params ?? null) !== JSON.stringify(frozen.params ?? null); + if (changed) { + steps[at] = frozen; + restored.push(frozen.id); + } + } + if (restored.length === 0) return { raw: params.nextRaw, restored: [] }; + return { + raw: stringifyCreationFlow({ ...next, version: 1, steps }), + restored, + }; +} + +/** + * 配方近期起点 → 可写入黑板的开局 DAG。 + * steps 为空则返回 null(仍只当选型参考,不预置流程)。 + */ +export function creationFlowFromRecipeSeed( + detail: Pick, +): CreationFlow | null { + const seed = detail.seed; + if (!seed?.steps?.length) return null; + return { + version: 1, + ...(seed.brief ? { brief: seed.brief } : {}), + status: seed.status ?? "open", + steps: ensureCreationFlowStepIds(seed.steps), + }; +} + +/** + * 已坐在配方开局步(无 depends_on)且「用户.需求」有首句时: + * 跳过程序再抛模块 opening——首句即该步对话,不是「先开场再进 DAG」。 + */ +export function shouldSkipModuleOpening(opts: { + demand: string; + dependsOn?: string[] | null; + phase?: "shown" | "answered" | null; +}): boolean { + if (opts.phase === "shown" || opts.phase === "answered") return false; + if (!String(opts.demand ?? "").trim()) return false; + const deps = opts.dependsOn ?? []; + return deps.length === 0; +} + /** 无 id 时的兜底(目录仍应显式写 id) */ function slugFromName(name: string): string { const map: Record = { @@ -661,6 +817,7 @@ export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string { "【能力 · 可选工序】", "按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入。", "选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta);有「编排参数」的步骤必须在 DAG 里写齐 params,缺参时用 askUser 选项+其它,禁止空壳进执行。", + "生成规则:改 target 时必须同步改 rule_id(英文 kebab-case)与步骤 id(生成规则·{对象});禁止只改中文对象沿用旧后台 id。", "不要把能力执行全文塞进本步;执行由 design-step 注入。", lines.join("\n"), ].join("\n"); @@ -929,13 +1086,23 @@ function formatRecipeFieldBlock(value: string | string[]): string { return value; } -/** design-flow 一次注入:已选配方 + 技能池 */ +/** design-flow 一次注入:流程进度 + 已选配方 + 技能池 */ export function formatDesignFlowContentBlocks(params: { selectedRecipe?: RecipeDetail | null; modules?: ModuleCatalog | null; missingSelection?: boolean; + flow?: CreationFlow | null; + acceptedStepIds?: readonly string[]; + filledArtifactTags?: readonly string[]; }): string[] { - const blocks: string[] = []; + const blocks: string[] = [ + formatFlowProgressForAgent({ + flow: params.flow, + acceptedStepIds: params.acceptedStepIds, + catalog: params.modules, + filledArtifactTags: params.filledArtifactTags, + }), + ]; if (params.missingSelection) { blocks.push( [ @@ -953,6 +1120,120 @@ export function formatDesignFlowContentBlocks(params: { return blocks; } +/** + * 编排器必须看见的进度:哪些步已做完、草案里已有什么、哪些能力才允许再编入。 + * 裸 JSON id 列表不够;此块由程序钉死。 + */ +export function formatFlowProgressForAgent(params: { + flow?: CreationFlow | null; + acceptedStepIds?: readonly string[]; + catalog?: ModuleCatalog | null; + filledArtifactTags?: readonly string[]; +}): string { + const accepted = (params.acceptedStepIds ?? []).filter( + (id) => Boolean(id?.trim()) && id.trim() !== "flow", + ); + const flow = params.flow ?? null; + const catalog = params.catalog ?? null; + const filled = new Set(params.filledArtifactTags ?? []); + const repeatableNames = new Set( + (catalog?.modules ?? []) + .filter((m) => m.repeatable === true) + .map((m) => m.name), + ); + + const doneLines: string[] = []; + const doneNames = new Set(); + const doneIds = new Set(); + + const markDone = ( + name: string, + id: string | undefined, + why: string, + repeatable: boolean, + ) => { + const flag = repeatable + ? "〔可反复:可再追加不同 id〕" + : "〔不可反复:禁止再排一次执行〕"; + const idBit = id ? `(id: ${id})` : ""; + doneLines.push(`- ${name}${idBit} · ${why} ${flag}`); + doneNames.add(name); + if (id) doneIds.add(id); + }; + + if (flow) { + for (const step of flow.steps) { + const acceptedHere = isStepAccepted(step, accepted); + const art = catalog ? artifactTagForStep(step.name, catalog) : null; + const hasArtifact = Boolean(art && filled.has(art)); + const repeatable = repeatableNames.has(step.name); + if (acceptedHere || (!repeatable && hasArtifact)) { + markDone( + step.name, + step.id, + acceptedHere ? "已验收" : "产物已在黑板", + repeatable, + ); + } + } + } + + for (const id of accepted) { + if (doneIds.has(id)) continue; + if (flow?.steps.some((s) => isStepAccepted(s, [id]))) continue; + markDone(id, id, "已验收", repeatableNames.has(id)); + } + + if (catalog) { + for (const mod of catalog.modules) { + if (mod.repeatable === true) continue; + if (!mod.artifact || !filled.has(mod.artifact)) continue; + if (doneNames.has(mod.name)) continue; + markDone(mod.name, undefined, "产物已在黑板", false); + } + } + + const draftLines: string[] = []; + if (flow) { + for (const step of flow.steps) { + if (doneIds.has(step.id) || doneNames.has(step.name)) continue; + draftLines.push( + `- ${step.name}(id: ${step.id})· 已在草案,保留原 id;不要当作新规划再写一遍`, + ); + } + } + + const repeatableList = (catalog?.modules ?? []) + .filter((m) => m.repeatable === true) + .map((m) => m.name); + + const lines = [ + "【流程进度】(程序钉死,必须遵守)", + "你的任务是追加缺口,不是从头规划。", + "已完成的非反复技能:只保留原 id 作 depends_on 锚点,禁止再作为将要执行的一步。", + "草案里已有的步骤:原样保留,不要重排一遍开局。", + "只有能力目录标了〔可反复〕的(如生成规则、具体实例)才允许再编入新 id。", + ]; + if (doneLines.length) { + lines.push("", "已完成:", ...doneLines); + } else { + lines.push( + "", + "已完成:尚无已验收步骤。若草案已有开局步,那是配方预置起点,不是你新规划的。", + ); + } + if (draftLines.length) { + lines.push("", "草案已有、尚未验收(保留,勿重排):", ...draftLines); + } + lines.push( + "", + repeatableList.length + ? `可反复追加:${repeatableList.join("、")}` + : "可反复追加:以能力目录〔可反复〕为准。", + ); + return lines.join("\n"); +} + /** 解析并加载用户已选配方详情 */ export async function resolveSelectedRecipeDetail(params: { skillPackRoot: string; @@ -973,7 +1254,6 @@ export function validateCreationFlow( 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)) @@ -1020,25 +1300,53 @@ export function validateCreationFlow( } } - if (mod?.params?.length) { - const missing = missingRequiredStepParams(step, mod); - if (missing.length > 0) { - const labels = missing - .map((key) => { - const spec = mod.params!.find((p) => p.key === key); - return spec ? `${key}(${spec.label})` : key; - }) - .join("、"); - errors.push( - `「${step.id}」缺少必填编排参数:${labels}(须在 design-flow 钉齐后再执行)`, - ); - } - } + // 必填 params 改在「确认开干」时钉齐,不再阻挡编排产出空壳步骤 } return { ok: errors.length === 0, errors }; } +/** 进 design-step 前:本步必填编排参数是否已齐 */ +export function validateStepReadyToRun( + step: CreationFlowStep, + catalog: ModuleCatalog | null, +): CreationFlowValidation { + const mod = catalog?.modules.find((m) => m.name === step.name); + if (!mod?.params?.length) return { ok: true, errors: [] }; + const missing = missingRequiredStepParams(step, mod); + if (missing.length === 0) return { ok: true, errors: [] }; + const labels = missing + .map((key) => { + const spec = mod.params!.find((p) => p.key === key); + return spec ? `${key}(${spec.label})` : key; + }) + .join("、"); + return { + ok: false, + errors: [`「${step.id}」缺少必填编排参数:${labels}(确认开干前请补齐)`], + }; +} + +/** 写回某步 params(确认下一节点时由用户钉死) */ +export function patchCreationFlowStepParams( + flow: CreationFlow, + stepId: string, + params: CreationFlowStepParams, +): CreationFlow { + const id = stepId.trim(); + return { + ...flow, + steps: flow.steps.map((s) => + s.id === id + ? { + ...s, + params: Object.keys(params).length ? { ...params } : undefined, + } + : s, + ), + }; +} + export function artifactTagForStep( name: string, catalog: ModuleCatalog | null, @@ -1049,11 +1357,13 @@ export function artifactTagForStep( export function formatCreationFlowForUser( flow: CreationFlow, catalog?: ModuleCatalog | null, + acceptedStepIds: readonly string[] = [], ): CreationFlowUserView { const decl = new Map( (catalog?.modules ?? []).map((m) => [m.name, m] as const), ); const seenName = new Map(); + const next = nextPendingStep(flow, acceptedStepIds); return { brief: flow.brief, status: flow.status, @@ -1062,6 +1372,11 @@ export function formatCreationFlowForUser( seenName.set(s.name, n); const mod = decl.get(s.name); const paramsMissing = missingRequiredStepParams(s, mod); + const runState: FlowStepRunState = isStepAccepted(s, acceptedStepIds) + ? "done" + : next && next.id === s.id + ? "current" + : "pending"; return { order: i + 1, id: s.id, @@ -1070,6 +1385,7 @@ export function formatCreationFlowForUser( occurrence: n, declaration: mod?.declaration, repeatable: mod?.repeatable, + runState, ...(s.params ? { params: s.params } : {}), ...(paramsMissing.length > 0 ? { paramsMissing } : {}), }; diff --git a/src/skills/creation-units.ts b/src/skills/creation-units.ts index 6c0f624..7c3fa17 100644 --- a/src/skills/creation-units.ts +++ b/src/skills/creation-units.ts @@ -491,6 +491,13 @@ export function parseAcceptedUnits(raw: unknown): string[] { return []; } +/** 给人 / 编排 LLM 看的已验收步骤清单;去掉「flow」(那是计划本身,不是某一步)。 */ +export function formatAcceptedUnitsForPrompt(raw: unknown): string { + const ids = parseAcceptedUnits(raw).filter((id) => id && id !== "flow"); + if (ids.length === 0) return "(尚无已验收步骤)"; + return ids.map((id) => `- ${id}`).join("\n"); +} + export function isFinalWorkerSetArtifact(artifact: { workerId: string; outputTags: string[]; diff --git a/src/skills/declared-worker.ts b/src/skills/declared-worker.ts index 580846e..fb831cb 100644 --- a/src/skills/declared-worker.ts +++ b/src/skills/declared-worker.ts @@ -101,6 +101,7 @@ export async function resolveRunnableWorker(params: { currentStepName, acceptedStepNames: parseAcceptedSteps(acceptedRaw), selectedRecipeRef, + filledArtifactTags: params.blackboard.listTagIndex().map((item) => item.tag), }, ); return { diff --git a/src/skills/loader.ts b/src/skills/loader.ts index f510973..c84abbf 100644 --- a/src/skills/loader.ts +++ b/src/skills/loader.ts @@ -473,6 +473,8 @@ export async function loadWorkerSkillWithContext( acceptedStepNames?: readonly string[]; /** 用户手动选定的配方 id / 名 */ selectedRecipeRef?: string | null; + /** 黑板已有产物 tag,供编排器判断哪些技能已经做过 */ + filledArtifactTags?: readonly string[]; }, ): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> { const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot); @@ -488,6 +490,7 @@ export async function loadWorkerSkillWithContext( loadModuleCatalog, resolveSelectedRecipeDetail, formatDesignFlowContentBlocks, + parseCreationFlow, } = await import("./creation-flow.js"); const modules = await loadModuleCatalog(skill.skillPackRoot, skillsRoot); const selectedRecipe = await resolveSelectedRecipeDetail({ @@ -499,6 +502,9 @@ export async function loadWorkerSkillWithContext( selectedRecipe, modules, missingSelection: !selectedRecipe, + flow: parseCreationFlow(opts?.flowRaw), + acceptedStepIds: opts?.acceptedStepNames, + filledArtifactTags: opts?.filledArtifactTags, }); if (blocks.length) { moduleCatalogBlock = blocks.join("\n\n"); @@ -537,10 +543,7 @@ export async function loadWorkerSkillWithContext( ...binding.depTags, ]; const inputTags = [...new Set(baseInputs)]; - const outputTags = [ - binding.module.artifact, - CREATION_CURRENT_STEP_TAG, - ]; + const outputTags = [binding.module.artifact]; const depSegments = binding.depTags.map((tag, i) => ({ id: `dep-${i}`, tier: "static" as const, diff --git a/src/skills/question-protocol.ts b/src/skills/question-protocol.ts index 43f5df0..6926ad1 100644 --- a/src/skills/question-protocol.ts +++ b/src/skills/question-protocol.ts @@ -10,6 +10,17 @@ import type { export type { QuestionAnswer, QuestionItem, QuestionOption }; +/** 能力默认问题(opening):与追问卡分流,对齐美学纲领开局引导 */ +export const MODULE_OPENING_QUESTION_ID = "module-opening"; + +/** 是否仅为能力默认问题(应走说话面 + openingGuide,不进答题卡) */ +export function isModuleOpeningQuestions( + questions: readonly QuestionItem[] | undefined | null, +): boolean { + if (!questions?.length || questions.length !== 1) return false; + return questions[0]?.id === MODULE_OPENING_QUESTION_ID; +} + function letterId(i: number): string { return String.fromCharCode(65 + (i % 26)); } @@ -88,6 +99,138 @@ export function normalizeQuestions(raw: unknown): QuestionItem[] { return out; } +const EXAMPLE_SUFFIX = /(?:\n|^)\s*示例[::][\s\S]*$/; + +/** 去掉「示例:」后比题干,避免同一问因示例行对不上 */ +export function questionPromptStem(prompt: string): string { + return prompt.replace(EXAMPLE_SUFFIX, "").replace(/\s+/g, "").trim(); +} + +function optionSignature(q: QuestionItem): string { + return (q.options ?? []) + .map((o) => o.label.trim()) + .filter((label) => label && !/^其它/.test(label)) + .join("\n"); +} + +export function isSameFollowUpQuestion(a: QuestionItem, b: QuestionItem): boolean { + const sigA = optionSignature(a); + const sigB = optionSignature(b); + if (sigA && sigA === sigB) return true; + const sa = questionPromptStem(a.prompt); + const sb = questionPromptStem(b.prompt); + if (!sa || !sb) return false; + if (sa === sb) return true; + return sa.includes(sb) || sb.includes(sa); +} + +export const SLOT_ASKED_QUESTIONS = "askedFollowUps"; + +/** 已问过的题目留痕(写进 slots 供跨 worker 去重) */ +export type AskedQuestionRecord = { + prompt: string; + options?: string[]; +}; + +const ASKED_HISTORY_LIMIT = 40; + +export function toAskedQuestionRecord(q: QuestionItem): AskedQuestionRecord { + const options = (q.options ?? []).map((o) => o.label.trim()).filter(Boolean); + return options.length ? { prompt: q.prompt, options } : { prompt: q.prompt }; +} + +export function parseAskedQuestions(raw: unknown): AskedQuestionRecord[] { + const list = Array.isArray(raw) + ? raw + : typeof raw === "string" && raw.trim() + ? (() => { + try { + return JSON.parse(raw) as unknown; + } catch { + return null; + } + })() + : null; + if (!Array.isArray(list)) return []; + const out: AskedQuestionRecord[] = []; + for (const item of list) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const row = item as Record; + const prompt = typeof row.prompt === "string" ? row.prompt.trim() : ""; + if (!prompt) continue; + const options = Array.isArray(row.options) + ? row.options.map((o) => String(o).trim()).filter(Boolean) + : undefined; + out.push(options?.length ? { prompt, options } : { prompt }); + } + return out; +} + +function recordAsItem(rec: AskedQuestionRecord): QuestionItem { + return { + id: "asked", + prompt: rec.prompt, + options: rec.options?.map((label, i) => ({ id: letterId(i), label })), + }; +} + +export function wasAlreadyAsked( + q: QuestionItem, + history: readonly AskedQuestionRecord[], +): boolean { + return history.some((rec) => isSameFollowUpQuestion(q, recordAsItem(rec))); +} + +/** 过滤掉本局已经问过的题(用于可跳过的挂载追问) */ +export function dropAlreadyAskedQuestions( + questions: readonly QuestionItem[], + history: readonly AskedQuestionRecord[], +): QuestionItem[] { + if (!history.length) return [...questions]; + return questions.filter((q) => !wasAlreadyAsked(q, history)); +} + +export function appendAskedQuestions( + history: readonly AskedQuestionRecord[], + questions: readonly QuestionItem[], +): AskedQuestionRecord[] { + const next = [...history]; + for (const q of questions) { + if (wasAlreadyAsked(q, next)) continue; + next.push(toAskedQuestionRecord(q)); + } + return next.slice(-ASKED_HISTORY_LIMIT); +} + +/** + * askUser 与片段「追问」合并:同一问只留一份,优先带示例的片段题。 + * 仅片段没有的缺口才保留 askUser。 + */ +export function mergeQuestionsPreferFragment( + askUser: QuestionItem[] | undefined, + fragment: QuestionItem[], +): QuestionItem[] { + if (!fragment.length) return askUser?.length ? [...askUser] : []; + if (!askUser?.length) return [...fragment]; + + const usedAsk = new Set(); + const out: QuestionItem[] = []; + for (const fq of fragment) { + const matchIdx = askUser.findIndex( + (aq, i) => !usedAsk.has(i) && isSameFollowUpQuestion(aq, fq), + ); + if (matchIdx >= 0) usedAsk.add(matchIdx); + if (!out.some((q) => isSameFollowUpQuestion(q, fq))) out.push(fq); + } + for (let i = 0; i < askUser.length; i++) { + if (usedAsk.has(i)) continue; + const aq = askUser[i]!; + if (out.some((q) => isSameFollowUpQuestion(q, aq))) continue; + out.push(aq); + } + return out; +} + /** 发给 AI:必须含问题与答案;可选自由补充 */ export function formatQuestionAnswersForAi( questions: QuestionItem[], diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 051ad37..f0bd4dd 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -51,7 +51,13 @@ export type WaitingReason = questions: QuestionItem[]; pageSize?: number; } // worker 无产物时的阻塞提问 - | { kind: "revision"; instruction?: string }; // 产物被拒或程序验收失败 + | { kind: "revision"; instruction?: string } // 无意见打回时等补交;有意见则直接重跑 + | { + /** 上一步已验收:先问「下一步想写什么」(可留空),再提案并确认开干 */ + kind: "next_intent"; + afterWorkerId?: string; + afterUnitId?: string; + }; /** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */ export type SkillIndexEntry = { @@ -249,6 +255,11 @@ export type RuntimeEvent = questions: QuestionItem[] | string[]; }; } + | { + /** 按意见重跑没写出新产物:退回上一版产物验收,重试追问改挂为可选 */ + type: "worker_revision_produced_nothing"; + payload: { artifactId: string; questions?: QuestionItem[] | string[] }; + } | { /** 验收态下作答/跳过挂载追问:不离开 review_artifact */ type: "user_resolved_sidecar_questions"; @@ -316,7 +327,9 @@ export type PhaseEffect = } | { type: "resume_worker" } | { type: "run_programmatic_review"; artifactId: string } - | { type: "emit_message"; message: string }; + | { type: "emit_message"; message: string } + /** 根据流程与用户下一步意向,提案下一节点并进入确认 */ + | { type: "propose_next_creation_step" }; /** applyEvent 的返回值:新会话快照 + 待处理副作用 + 可选错误 */ export type ApplyEventResult = { diff --git a/src/worker/executor.ts b/src/worker/executor.ts index 3adaf42..4e1f159 100644 --- a/src/worker/executor.ts +++ b/src/worker/executor.ts @@ -15,10 +15,17 @@ import { } from "../skills/worker-set-parse.js"; import { assembleWorkerContext } from "../skills/context-segments.js"; import { + mergeQuestionsPreferFragment, normalizeQuestions, type QuestionItem, } from "../skills/question-protocol.js"; -import { extractFragmentAskSidecar } from "../skills/context-fragment.js"; +import { + extractFragmentAskSidecar, + expectsContextFragmentTag, + isUsableContextFragment, + looksLikeFragmentDoc, +} from "../skills/context-fragment.js"; +import { isProgressPointerTag } from "../skills/creation-flow.js"; export type WorkerRunParams = { skillName: string; @@ -66,7 +73,8 @@ askUser 每项可为: - outputs 的 key 必须是要求的 outputTags - **设计.worker集 / 设计.worker集.草稿**:value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML -- **优先同时给 outputs + askUser**:先交出可用草稿/产物,追问挂在产物下(用户可直接接受而不作答)。仅当完全无法产出时才留空 outputs、只填 askUser +- **context-fragment.v1**:必须放在 outputs["<本步 artifact tag>"],禁止把片段当根对象。题目只写在产物「追问」(建议选项 + 示例);顶层 askUser 必须为 **null**。程序会把「追问」挂到询问卡。禁止同一问再抄一份 askUser。禁止写入「创作.当前步骤」等进度指针 +- **其它产物**:优先同时给 outputs + askUser;有产物时追问挂在产物下(用户可直接接受而不作答)。仅当完全无法产出时才留空 outputs、只填 askUser - 能推断选项时 **必须**给 options(完整句、可改写);不要只丢裸问题逼用户写长段 - 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问 - summary 用于界面展示`; @@ -127,6 +135,102 @@ function gatherInputs( return inputs; } +function stringifyOutputValue(val: unknown): string | undefined { + if (typeof val === "string" && val.trim()) return val.trim(); + if (val && typeof val === "object") return JSON.stringify(val); + return undefined; +} + +function productOutputTags(outputTags: string[]): string[] { + return outputTags.filter((t) => t.trim() && !isProgressPointerTag(t)); +} + +function assignOutput( + outputs: Record, + tag: string | undefined, + val: unknown, +): void { + if (!tag || isProgressPointerTag(tag)) return; + const text = stringifyOutputValue(val); + if (text) outputs[tag] = text; +} + +/** 模型常把片段当根对象,或写进「创作.当前步骤」 */ +function recoverFragmentOutputs( + obj: Record, + outputs: Record, + outputTags: string[], +): void { + const target = productOutputTags(outputTags)[0]; + if (!target) return; + if (outputs[target] && isUsableContextFragment(outputs[target])) return; + + const outputsRaw = + obj.outputs && typeof obj.outputs === "object" && !Array.isArray(obj.outputs) + ? (obj.outputs as Record) + : null; + + if (outputsRaw) { + for (const [key, val] of Object.entries(outputsRaw)) { + if (key === target) continue; + const text = stringifyOutputValue(val); + if (!text) continue; + if (isUsableContextFragment(text) || looksLikeFragmentDoc(val)) { + outputs[target] = text; + return; + } + } + } + + const protocolKeys = new Set(["outputs", "summary", "askUser", "askAssessment"]); + const rest: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (!protocolKeys.has(k)) rest[k] = v; + } + if (looksLikeFragmentDoc(obj) || looksLikeFragmentDoc(rest)) { + const source = looksLikeFragmentDoc(obj) ? obj : rest; + outputs[target] = JSON.stringify(source); + } +} + +function dropUnusableFragmentOutputs(outputs: Record): void { + for (const tag of Object.keys(outputs)) { + if (isProgressPointerTag(tag)) { + delete outputs[tag]; + continue; + } + const content = outputs[tag]!; + const requireFragment = expectsContextFragmentTag(tag); + const looksFragment = + isUsableContextFragment(content) || + looksLikeFragmentDoc(tryParseObject(content)) || + /"schema"\s*:\s*"context-fragment\.v1"/.test(content) || + /"技能"\s*:/.test(content); + if ((requireFragment || looksFragment) && !isUsableContextFragment(content)) { + delete outputs[tag]; + } + } +} + +function tryParseObject(text: string): unknown { + const extracted = extractJsonObjectText(text) ?? text.trim(); + try { + return JSON.parse(extracted); + } catch { + return undefined; + } +} + +const INCOMPLETE_FRAGMENT_ASK = normalizeQuestions([ + { + id: "design-step-retry", + prompt: + "这一步还没写出可验收的产物。请再补一点你最在意的体验或参与方式;也可以说「按已有描述先出一版」。", + allowOther: true, + required: true, + }, +]); + function parseWorkerResponse( raw: string, outputTags: string[], @@ -135,7 +239,6 @@ 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 { @@ -149,16 +252,20 @@ function parseWorkerResponse( ]), }; } - const fallback: Record = {}; - if (outputTags.length === 1) { - fallback[outputTags[0]] = raw; - } else { - fallback[outputTags[0] ?? "output.草稿"] = raw; + const target = productOutputTags(outputTags)[0] ?? outputTags[0]; + if (target && isUsableContextFragment(raw)) { + return finalizeParsedOutputs( + { [target]: raw }, + { summary: raw.slice(0, 80) }, + outputTags, + ); } + const questions = normalizeQuestions(extractQuestionsFromText(raw)); return { - outputs: fallback, - summary: raw.slice(0, 80), + outputs: {}, + summary: "未产出可验收产物", preview: raw.slice(0, 600), + askUser: questions.length ? questions.slice(0, 2) : INCOMPLETE_FRAGMENT_ASK, }; } @@ -171,17 +278,20 @@ function parseWorkerResponse( const outputs: Record = {}; if (outputsRaw && typeof outputsRaw === "object") { - for (const tag of outputTags) { - 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); - } + for (const tag of productOutputTags(outputTags)) { + assignOutput(outputs, tag, (outputsRaw as Record)[tag]); } } + recoverFragmentOutputs(obj, outputs, outputTags); + return finalizeParsedOutputs(outputs, obj, outputTags); +} + +function finalizeParsedOutputs( + outputs: Record, + obj: Record, + _outputTags: string[], +): WorkerRunResult { const isMetaAskToken = (s: string) => /^ask[_-]?user$/i.test(s.trim()); let askUser: QuestionItem[] | undefined; @@ -213,7 +323,7 @@ function parseWorkerResponse( } } - // context-fragment.v1:正文内 追问/自评 → 挂到询问卡(不必再抄一份 askUser) + // 先抽追问,再丢掉半残片段——无正文时仍要把题留给 LLM loop let askAssessment: string | undefined; const fragQuestions: QuestionItem[] = []; for (const content of Object.values(outputs)) { @@ -226,19 +336,20 @@ function parseWorkerResponse( } } if (fragQuestions.length) { - if (!askUser?.length) { - askUser = fragQuestions; - } else { - for (const q of fragQuestions) { - if (!askUser.some((x) => x.prompt === q.prompt)) askUser.push(q); - } - } + askUser = mergeQuestionsPreferFragment(askUser, fragQuestions); + } + + dropUnusableFragmentOutputs(outputs); + + if (Object.keys(outputs).length === 0 && (!askUser || askUser.length === 0)) { + askUser = normalizeQuestions(INCOMPLETE_FRAGMENT_ASK); } const summary = typeof obj.summary === "string" && obj.summary.trim() ? obj.summary.trim() - : Object.values(outputs)[0]?.slice(0, 80) ?? "Worker 已完成"; + : Object.values(outputs)[0]?.slice(0, 80) ?? + (askUser?.length ? `待补充:${askUser[0]!.prompt.slice(0, 40)}` : "Worker 已完成"); const preview = Object.entries(outputs) @@ -379,6 +490,9 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise item.tag); const loaded = await loadWorkerSkillWithContext( params.skillName, workerId, @@ -388,6 +502,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise { expect(text).not.toMatch(/^\s*\{/); }); + it("revision mode asks the worker to apply sidecar answers and keep the draft", () => { + const bb = new Blackboard(); + bb.write({ + tag: "设计.美学纲领与交互范式", + content: '{"tone":"warm"}', + source: "worker", + }); + const text = assembleWorkerContext({ + inputs: { + "用户.修订说明": "再冷一点", + "用户.worker答复": "【追问作答】\n问:偏好?\n答:偏冷", + "设计.美学纲领与交互范式": '{"tone":"warm"}', + }, + segments: [ + { + id: "user", + tier: "dynamic", + tags: ["用户.worker答复", "用户.修订说明"], + label: "## 用户表述", + }, + ], + blackboard: bb, + workerId: "design-step", + workerName: "创作 · 美学纲领与交互范式", + outputTags: ["设计.美学纲领与交互范式"], + }); + expect(text).toContain("待改底稿"); + expect(text).toContain("用户.worker答复"); + expect(text).toContain("偏冷"); + expect(text).toContain("再冷一点"); + }); + it("falls back to JSON inputs without segments", () => { const bb = new Blackboard(); const text = assembleWorkerContext({ diff --git a/tests/context-fragment.test.ts b/tests/context-fragment.test.ts index 6a4d8fa..e3b1cb9 100644 --- a/tests/context-fragment.test.ts +++ b/tests/context-fragment.test.ts @@ -4,6 +4,8 @@ import { projectFragmentContent, contextFragmentToView, extractFragmentAskSidecar, + isUsableContextFragment, + expectsContextFragmentTag, } from "../src/skills/context-fragment.js"; import { parseContextOrder, @@ -74,6 +76,26 @@ describe("context-fragment.v1", () => { "其它", ]); }); + + it("isUsableContextFragment requires non-empty 正文", () => { + expect( + isUsableContextFragment({ + schema: "context-fragment.v1", + 技能: "美学纲领与交互范式", + brief: "孤立免疫", + 正文: { 美学纲领: { 体验内核: "特权" } }, + }), + ).toBe(true); + expect( + isUsableContextFragment({ + schema: "context-fragment.v1", + 技能: "美学纲领与交互范式", + brief: "孤立免疫", + }), + ).toBe(false); + expect(expectsContextFragmentTag("设计.美学纲领与交互范式")).toBe(true); + expect(expectsContextFragmentTag("设计.创作流程")).toBe(false); + }); }); describe("context-order.v1", () => { diff --git a/tests/creation-flow.test.ts b/tests/creation-flow.test.ts index dbcb8e2..6a85155 100644 --- a/tests/creation-flow.test.ts +++ b/tests/creation-flow.test.ts @@ -6,6 +6,7 @@ import { artifactTagForStep, extractModuleOpening, formatCreationFlowForUser, + formatFlowProgressForAgent, formatModuleCatalogForAgent, formatRecipeCatalogForAgent, formatSelectedRecipeForAgent, @@ -14,16 +15,23 @@ import { loadModuleCatalog, loadModulePrompt, loadRecipeCatalog, + mergeCreationFlowPreservingAccepted, needsFlowExpansion, nextPendingStep, parseCreationFlow, + pickRecordedStepId, parseModuleCatalog, parseModuleOpeningState, parseRecipeCatalog, parseRecipeYaml, parseSelectedRecipeRef, resolveDesignStepBinding, + shouldSkipModuleOpening, + isProgressPointerTag, + stringifyCreationFlow, + creationFlowFromRecipeSeed, validateCreationFlow, + validateStepReadyToRun, } from "../src/skills/creation-flow.js"; import { loadWorkerSkillWithContext } from "../src/skills/loader.js"; @@ -67,6 +75,79 @@ describe("creation-flow", () => { }); }); + it("keeps accepted steps intact when the planner rewrites the flow", () => { + const prev = `{ + "status": "open", + "steps": [ + { "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] } + ] + }`; + const rewritten = `{ + "status": "open", + "steps": [ + { "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": ["实现机制"] }, + { "id": "实现机制", "name": "实现机制", "depends_on": [] } + ] + }`; + const merged = mergeCreationFlowPreservingAccepted({ + prevRaw: prev, + nextRaw: rewritten, + acceptedStepIds: ["美学纲领与交互范式"], + }); + expect(merged.restored).toEqual(["美学纲领与交互范式"]); + const flow = parseCreationFlow(merged.raw)!; + expect(flow.steps[0]?.depends_on).toEqual([]); + expect(flow.steps.map((s) => s.id)).toEqual([ + "美学纲领与交互范式", + "实现机制", + ]); + }); + + it("re-inserts an accepted step the planner dropped", () => { + const prev = `{ + "status": "open", + "steps": [ + { "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] } + ] + }`; + const rewritten = `{ + "status": "open", + "steps": [{ "id": "实现机制", "name": "实现机制", "depends_on": [] }] + }`; + const merged = mergeCreationFlowPreservingAccepted({ + prevRaw: prev, + nextRaw: rewritten, + acceptedStepIds: ["美学纲领与交互范式"], + }); + expect(parseCreationFlow(merged.raw)!.steps.map((s) => s.id)).toEqual([ + "美学纲领与交互范式", + "实现机制", + ]); + }); + + it("leaves an untouched rewrite alone", () => { + const prev = `{ + "status": "open", + "steps": [ + { "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] } + ] + }`; + const rewritten = `{ + "status": "open", + "steps": [ + { "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] }, + { "id": "实现机制", "name": "实现机制", "depends_on": ["美学纲领与交互范式"] } + ] + }`; + const merged = mergeCreationFlowPreservingAccepted({ + prevRaw: prev, + nextRaw: rewritten, + acceptedStepIds: ["美学纲领与交互范式"], + }); + expect(merged.restored).toEqual([]); + expect(merged.raw).toBe(rewritten); + }); + it("allows duplicate capability names with distinct ids", () => { const flow = parseCreationFlow(`{ "status": "open", @@ -171,6 +252,29 @@ describe("creation-flow", () => { }); expect(view.steps[2]?.occurrence).toBe(2); expect(view.steps[2]?.repeatable).toBe(true); + expect(view.steps[0]?.runState).toBe("current"); + expect(view.steps[1]?.runState).toBe("pending"); + expect(view.steps[2]?.runState).toBe("pending"); + }); + + it("marks accepted steps done and the next pending current", () => { + 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.steps.map((s) => s.runState)).toEqual([ + "done", + "current", + "pending", + ]); }); it("loads world-simulator catalog and formats for agent", async () => { @@ -273,6 +377,18 @@ steps: expect(formatted).toContain("用户已选配方"); expect(formatted).toContain("核心思路"); expect(formatted).toContain("增量"); + + const seeded = creationFlowFromRecipeSeed(detail); + expect(seeded?.steps[0]?.name).toBe("美学纲领与交互范式"); + expect(seeded?.status).toBe("open"); + const raw = stringifyCreationFlow(seeded!); + expect(parseCreationFlow(raw)?.steps[0]?.id).toBe("美学纲领与交互范式"); + expect(creationFlowFromRecipeSeed({ seed: null })).toBeNull(); + expect( + creationFlowFromRecipeSeed({ + seed: { version: 1, status: "open", steps: [] }, + }), + ).toBeNull(); }); it("falls back to legacy hint when methodology absent", () => { @@ -326,9 +442,50 @@ recipes: expect(selected.promptBody).toContain("核心思路"); expect(selected.promptBody).toContain("何时用"); expect(selected.promptBody).toContain("增量"); + expect(selected.promptBody).toContain("【流程进度】"); expect(selected.promptBody).not.toContain("【导演】用户尚未手动选择"); }); + it("tells design-flow which steps are done and not to reschedule them", async () => { + const flowRaw = JSON.stringify({ + steps: [{ name: "美学纲领与交互范式", depends_on: [] }], + }); + const loaded = await loadWorkerSkillWithContext( + "world-simulator", + "design-flow", + undefined, + { + selectedRecipeRef: "world-simulator", + flowRaw, + acceptedStepNames: ["美学纲领与交互范式"], + filledArtifactTags: ["设计.美学纲领与交互范式"], + }, + ); + expect(loaded.promptBody).toContain("【流程进度】"); + expect(loaded.promptBody).toContain("已完成:"); + expect(loaded.promptBody).toContain("美学纲领与交互范式"); + expect(loaded.promptBody).toContain("已验收"); + expect(loaded.promptBody).toContain("禁止再排一次执行"); + expect(loaded.promptBody).toContain("生成规则"); + }); + + it("formatFlowProgressForAgent keeps seed steps as draft not new work", () => { + const flow = parseCreationFlow(`{ + "steps": [ + { "name": "美学纲领与交互范式", "depends_on": [] } + ] + }`)!; + const text = formatFlowProgressForAgent({ + flow, + acceptedStepIds: [], + catalog: sampleCatalog, + }); + expect(text).toContain("草案已有、尚未验收"); + expect(text).toContain("美学纲领与交互范式"); + expect(text).toContain("不要当作新规划再写一遍"); + expect(text).toContain("可反复追加:生成规则、具体实例"); + }); + it("injects aesthetics-interaction module prompt into design-step", async () => { const flowRaw = JSON.stringify({ steps: [{ name: "美学纲领与交互范式", depends_on: [] }], @@ -344,6 +501,8 @@ recipes: }, ); expect(loaded.worker.outputTags).toContain("设计.美学纲领与交互范式"); + expect(loaded.worker.outputTags).not.toContain("创作.当前步骤"); + expect(loaded.worker.inputTags).toContain("创作.当前步骤"); expect(loaded.promptBody).toContain("美学纲领与交互范式"); expect(loaded.promptBody).toContain("体验契约"); expect(loaded.promptBody).toContain("程序开场"); @@ -386,6 +545,35 @@ recipes: 美学纲领与交互范式: "shown", }); + expect( + shouldSkipModuleOpening({ + demand: "丧尸世界只有我不会被感染", + dependsOn: [], + }), + ).toBe(true); + expect( + shouldSkipModuleOpening({ + demand: "丧尸世界只有我不会被感染", + dependsOn: ["美学纲领与交互范式"], + }), + ).toBe(false); + expect( + shouldSkipModuleOpening({ + demand: "", + dependsOn: [], + }), + ).toBe(false); + expect( + shouldSkipModuleOpening({ + demand: "已有需求", + dependsOn: [], + phase: "shown", + }), + ).toBe(false); + expect(isProgressPointerTag("创作.当前步骤")).toBe(true); + expect(isProgressPointerTag("设计.美学纲领与交互范式")).toBe(false); + expect(isProgressPointerTag("设计.创作流程")).toBe(false); + const binding = await resolveDesignStepBinding({ skillPackRoot: "dialogue/world-simulator", flowRaw: JSON.stringify({ @@ -428,9 +616,11 @@ recipes: const missing = parseCreationFlow(`{ "steps": [{ "name": "生成规则", "depends_on": [] }] }`)!; - const bad = validateCreationFlow(missing, catalog); - expect(bad.ok).toBe(false); - expect(bad.errors.some((e) => e.includes("target"))).toBe(true); + // 编排期允许空壳;必填 params 在确认开干时钉齐 + expect(validateCreationFlow(missing, catalog).ok).toBe(true); + const stepReady = validateStepReadyToRun(missing.steps[0]!, catalog); + expect(stepReady.ok).toBe(false); + expect(stepReady.errors.some((e) => e.includes("target"))).toBe(true); const block = formatModuleCatalogForAgent(catalog!); expect(block).toContain("编排参数"); @@ -483,6 +673,43 @@ recipes: ).toBeNull(); }); + it("pickRecordedStepId ignores LLM junk on 创作.当前步骤", () => { + const flow = parseCreationFlow(`{ + "status": "open", + "steps": [ + { "name": "美学纲领与交互范式", "depends_on": [] }, + { "name": "生成规则", "depends_on": ["美学纲领与交互范式"] } + ] + }`)!; + expect( + pickRecordedStepId({ + flow, + alreadyAccepted: [], + pinnedUnitId: "美学纲领与交互范式", + writtenCurrentStep: JSON.stringify({ + schema: "context-fragment.v1", + 正文: { 交互范式: {}, 美学纲领: {} }, + }), + }), + ).toBe("美学纲领与交互范式"); + expect( + pickRecordedStepId({ + flow, + alreadyAccepted: [], + pinnedUnitId: "flow", + writtenCurrentStep: "交互范式", + }), + ).toBe("美学纲领与交互范式"); + expect( + pickRecordedStepId({ + flow, + alreadyAccepted: ["美学纲领与交互范式"], + pinnedUnitId: "", + writtenCurrentStep: "", + }), + ).toBe("生成规则"); + }); + it("open flow needs expansion after listed steps accepted", () => { const flow = parseCreationFlow(`{ "status": "open", diff --git a/tests/display-labels.test.ts b/tests/display-labels.test.ts index 12ece73..96b3131 100644 --- a/tests/display-labels.test.ts +++ b/tests/display-labels.test.ts @@ -3,6 +3,7 @@ import { displayStageLabel, displayWorkerLabel, formatWorkerDisplayTitle, + reviewComposerCopy, } from "../src/server/display-labels.js"; describe("display-labels", () => { @@ -27,4 +28,22 @@ describe("display-labels", () => { ); expect(displayWorkerLabel("worker:narrator")).toBe("执行单元 · 叙事转述"); }); + + it("maps review composer copy by worker", () => { + const flow = reviewComposerCopy("design-flow"); + expect(flow.acceptLabel).toBe("确认编排"); + expect(flow.submitLabel).toBe("按意见改编排"); + expect(flow.kicker).toBe("核对编排"); + expect(flow.tone).toBe("plan"); + + const step = reviewComposerCopy("design-step"); + expect(step.acceptLabel).toBe("接受"); + expect(step.submitLabel).toBe("按意见修改"); + expect(step.kicker).toBe("待验收"); + expect(step.tone).toBe("accept"); + + const opening = reviewComposerCopy("opening-generator"); + expect(opening.acceptLabel).toBe("选定此开场"); + expect(opening.kicker).toBe("待选定"); + }); }); diff --git a/tests/phase-machine.test.ts b/tests/phase-machine.test.ts index 4819fc8..360cff6 100644 --- a/tests/phase-machine.test.ts +++ b/tests/phase-machine.test.ts @@ -83,7 +83,7 @@ describe("phase machine", () => { outputTags: ["设计.worker集.草稿"], }); session = { ...session, artifacts: [artifact] }; - session = applyEvent(session, { + const completed = applyEvent(session, { type: "worker_completed", payload: { artifactId: artifact.id, @@ -95,12 +95,22 @@ describe("phase machine", () => { }, ], }, - }).session; + }); + session = completed.session; 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); + // 追问只挂 waitingReason,不另发「可选追问」消息(与产物同面展示) + expect( + completed.effects.some( + (e) => + e.type === "emit_message" && + typeof e.message === "string" && + /可选追问/.test(e.message), + ), + ).toBe(false); // 不答追问,直接接受产物 session = applyEvent(session, { @@ -159,4 +169,152 @@ describe("phase machine", () => { }).session; expect(session.slots.designInstanceReady).toBeUndefined(); }); + + it("rejecting artifact with feedback immediately reruns the same worker", () => { + let session = createSession("default"); + const artifact = createArtifact({ + workerId: "design-step", + stepId: "step-aesthetics", + outputTags: ["设计.美学纲领"], + }); + session = { + ...session, + artifacts: [artifact], + pendingArtifactId: artifact.id, + currentStepId: "step-aesthetics", + phase: "waiting_user", + waitingReason: { kind: "review_artifact", artifactId: artifact.id }, + slots: { + "用户.worker答复": "【追问作答】\n问:偏好?\n答:偏冷", + }, + }; + + const result = applyEvent(session, { + type: "user_rejected_artifact", + payload: { artifactId: artifact.id, reason: "语气再冷一点" }, + }); + + expect(result.session.phase).toBe("running"); + expect(result.session.waitingReason).toBeUndefined(); + expect(result.session.slots["用户.修订说明"]).toBe("语气再冷一点"); + // 追问答复不得被修订说明冲掉 + expect(result.session.slots["用户.worker答复"]).toContain("偏冷"); + expect(result.session.artifacts[0]?.status).toBe("revision_requested"); + // 只有这一份底稿可在重跑写不出新版时退回 + expect(result.session.slots.revisionTargetArtifactId).toBe(artifact.id); + expect(result.session.currentWorkerId).toBe("design-step"); + expect(result.session.currentStepId).toBe("step-aesthetics"); + expect(result.effects).toEqual([ + { type: "run_worker", workerId: "design-step" }, + ]); + }); + + it("failed revision rerun falls back to reviewing the previous artifact", () => { + let session = createSession("default"); + const artifact = { + ...createArtifact({ + workerId: "design-step", + stepId: "step-aesthetics", + outputTags: ["设计.美学纲领"], + }), + status: "revision_requested" as const, + }; + session = { + ...session, + artifacts: [artifact], + currentWorkerId: "design-step", + currentStepId: "step-aesthetics", + phase: "running", + slots: { revisionTargetArtifactId: artifact.id }, + }; + + const result = applyEvent(session, { + type: "worker_revision_produced_nothing", + payload: { + artifactId: artifact.id, + questions: ["这一步还没写出可验收的产物。请再补一点你最在意的体验。"], + }, + }); + + expect(result.session.phase).toBe("waiting_user"); + expect(result.session.waitingReason?.kind).toBe("review_artifact"); + expect(result.session.pendingArtifactId).toBe(artifact.id); + expect(result.session.artifacts[0]?.status).toBe("under_review"); + // 重试题降级为可选追问,用户仍可直接接受上一版 + const reason = result.session.waitingReason; + expect(reason?.kind === "review_artifact" && reason.questions?.[0]?.required).toBe( + false, + ); + // 退回后不再是重跑目标,避免下一步无产物时又被挂回来 + expect(result.session.slots.revisionTargetArtifactId).toBeUndefined(); + }); + + it("rejecting artifact without feedback waits for revision instruction", () => { + let session = createSession("default"); + const artifact = createArtifact({ + workerId: "design-step", + outputTags: ["设计.美学纲领"], + }); + session = { + ...session, + artifacts: [artifact], + pendingArtifactId: artifact.id, + phase: "waiting_user", + waitingReason: { kind: "review_artifact", artifactId: artifact.id }, + }; + + const rejected = applyEvent(session, { + type: "user_rejected_artifact", + payload: { artifactId: artifact.id }, + }); + expect(rejected.session.waitingReason?.kind).toBe("revision"); + expect(rejected.session.artifacts[0]?.status).toBe("rejected"); + + const resumed = applyEvent(rejected.session, { + type: "user_submitted_input", + payload: { text: "加一点冲突感" }, + }); + expect(resumed.session.phase).toBe("running"); + expect(resumed.session.slots["用户.修订说明"]).toBe("加一点冲突感"); + expect(resumed.effects).toEqual([ + { type: "run_worker", workerId: "design-step" }, + ]); + }); + + it("accepting design-step enters next_intent instead of invoking main agent", () => { + let session = createSession("default"); + const artifact = createArtifact({ + workerId: "design-step", + stepId: "美学纲领与交互范式", + outputTags: ["设计.美学纲领与交互范式"], + }); + session = { + ...session, + artifacts: [artifact], + pendingArtifactId: artifact.id, + phase: "waiting_user", + waitingReason: { kind: "review_artifact", artifactId: artifact.id }, + }; + + const accepted = applyEvent(session, { + type: "user_accepted_artifact", + payload: { artifactId: artifact.id }, + }); + expect(accepted.session.waitingReason?.kind).toBe("next_intent"); + expect(accepted.effects.some((e) => e.type === "invoke_main_agent")).toBe( + false, + ); + + const continued = applyEvent(accepted.session, { + type: "user_submitted_input", + payload: { text: "接下来写怪物生成规则" }, + }); + expect(continued.session.phase).toBe("running"); + expect(continued.session.slots["用户.下一步意向"]).toBe( + "接下来写怪物生成规则", + ); + expect(continued.effects).toEqual([ + { type: "propose_next_creation_step" }, + ]); + }); }); diff --git a/tests/question-protocol.test.ts b/tests/question-protocol.test.ts index 81f344b..98acc93 100644 --- a/tests/question-protocol.test.ts +++ b/tests/question-protocol.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from "vitest"; import { + appendAskedQuestions, + dropAlreadyAskedQuestions, formatQuestionAnswersForAi, formatQuestionAnswersForDisplay, + isModuleOpeningQuestions, + MODULE_OPENING_QUESTION_ID, + mergeQuestionsPreferFragment, normalizeQuestions, + parseAskedQuestions, } from "../src/skills/question-protocol.js"; describe("normalizeQuestions", () => { @@ -28,6 +34,131 @@ describe("normalizeQuestions", () => { }); }); +describe("isModuleOpeningQuestions", () => { + it("recognizes the single module-opening question", () => { + const qs = normalizeQuestions([ + { + id: MODULE_OPENING_QUESTION_ID, + prompt: "本轮生成对象已钉死。请直接谈…", + allowOther: true, + required: true, + }, + ]); + expect(isModuleOpeningQuestions(qs)).toBe(true); + }); + + it("rejects ordinary worker questions", () => { + const qs = normalizeQuestions([ + { id: "q1", prompt: "要不要池?" }, + ]); + expect(isModuleOpeningQuestions(qs)).toBe(false); + expect(isModuleOpeningQuestions([])).toBe(false); + expect(isModuleOpeningQuestions(null)).toBe(false); + }); +}); +describe("mergeQuestionsPreferFragment", () => { + it("keeps the 示例 version when askUser repeats the same options", () => { + const ask = normalizeQuestions([ + { + prompt: "为贴近你要的质感:你最想反复感受到的是哪一种?", + options: [ + "隐于都市的日常从容:所有人都只是凡人,而你心里清楚世界是你的", + "身份揭示的瞬间翻转:前一刻还被人平视甚至冒犯,后一刻万人噤声", + ], + }, + { + prompt: "在你亮明身份之前,你希望普通世界如何对待你?", + options: [ + "基本礼貌但无特别敬畏,偶尔会有微小冒犯", + "因你气质不凡而隐隐客气,但没人知道原因", + ], + }, + ]); + const frag = normalizeQuestions([ + { + prompt: + "你最想反复感受到的是哪一种?\n示例:比如:走在现代街头,有人撞了你一下。", + options: [ + "隐于都市的日常从容:所有人都只是凡人,而你心里清楚世界是你的", + "身份揭示的瞬间翻转:前一刻还被人平视甚至冒犯,后一刻万人噤声", + ], + }, + { + prompt: + "在你亮明身份之前,你希望普通世界如何对待你?\n示例:这样我才好确定叙事里普通人对你的默认距离。", + options: [ + "基本礼貌但无特别敬畏,偶尔会有微小冒犯", + "因你气质不凡而隐隐客气,但没人知道原因", + ], + }, + ]); + const merged = mergeQuestionsPreferFragment(ask, frag); + expect(merged).toHaveLength(2); + expect(merged[0]?.prompt).toContain("示例:"); + expect(merged[1]?.prompt).toContain("示例:"); + }); + + it("keeps askUser-only questions that are not in the fragment", () => { + const ask = normalizeQuestions([ + { prompt: "人称?", options: ["第二人称", "第一人称"] }, + ]); + const frag = normalizeQuestions([ + { + prompt: "暴力尺度?\n示例:锈迹即可", + options: ["少而锋利", "常态压抑"], + }, + ]); + const merged = mergeQuestionsPreferFragment(ask, frag); + expect(merged).toHaveLength(2); + expect(merged[0]?.prompt).toContain("暴力尺度"); + expect(merged[1]?.prompt).toBe("人称?"); + }); +}); + +describe("已问追问去重", () => { + const aesthetics = normalizeQuestions([ + { + id: "focus", + prompt: "「永远有 1 万元余额」这个设定,你希望体验更偏向哪种?", + options: [ + "用这 1 万元在城市里精打细算地活下去", + "靠这 1 万元撬动关系、机会与影响力", + ], + }, + ]); + + it("编排层重抛上一步问过的题时整题丢掉", () => { + const history = appendAskedQuestions([], aesthetics); + const replanned = normalizeQuestions([ + { + id: "focus", + prompt: "「永远有 1 万元余额」这个设定,你希望体验更偏向哪种?", + options: [ + "用这 1 万元在城市里精打细算地活下去", + "靠这 1 万元撬动关系、机会与影响力", + ], + }, + ]); + expect(dropAlreadyAskedQuestions(replanned, history)).toHaveLength(0); + }); + + it("新问题照常保留", () => { + const history = appendAskedQuestions([], aesthetics); + const fresh = normalizeQuestions([ + { prompt: "下一步先排实现机制还是叙事指南?", options: ["实现机制"] }, + ]); + expect(dropAlreadyAskedQuestions(fresh, history)).toHaveLength(1); + }); + + it("留痕可经 slots 往返,且同题不重复堆积", () => { + const once = appendAskedQuestions([], aesthetics); + const twice = appendAskedQuestions(once, aesthetics); + expect(twice).toHaveLength(1); + const roundTripped = parseAskedQuestions(JSON.stringify(twice)); + expect(dropAlreadyAskedQuestions(aesthetics, roundTripped)).toHaveLength(0); + }); +}); + describe("formatQuestionAnswers", () => { it("AI payload keeps Q+A; display omits prompts", () => { const questions = normalizeQuestions([ diff --git a/tests/tool-loop.test.ts b/tests/tool-loop.test.ts index 4c15c45..a6565df 100644 --- a/tests/tool-loop.test.ts +++ b/tests/tool-loop.test.ts @@ -5,6 +5,7 @@ import { MockLlmProvider, } from "../src/llm/client.js"; import { runMainAgentToolLoop } from "../src/main-agent/tool-loop.js"; +import { MAIN_AGENT_TOOL_DEFINITIONS } from "../src/main-agent/tools.js"; import { toolCallToDecision } from "../src/runtime/tool-registry.js"; import { createSession } from "../src/runtime/phase-machine.js"; @@ -22,6 +23,12 @@ describe("main agent tool loop", () => { listArtifacts: () => [], }; + it("exposes only DAG routing tools", () => { + expect( + MAIN_AGENT_TOOL_DEFINITIONS.map((tool) => tool.function.name), + ).toEqual(["read_blackboard", "run_worker"]); + }); + it("runs loop tools then returns terminal decision", async () => { const llm = new MockLlmProvider([ createMockToolCall("read_blackboard", { tags: ["book.brief"] }), diff --git a/tests/worker-set-sanitize.test.ts b/tests/worker-set-sanitize.test.ts index cbdfa51..aa01a7e 100644 --- a/tests/worker-set-sanitize.test.ts +++ b/tests/worker-set-sanitize.test.ts @@ -119,4 +119,114 @@ describe("sanitizeWorkerSetOutputs", () => { expect(result.askAssessment).toContain("美学纲领 6/10"); expect(result.askAssessment).toContain("还需确认"); }); + + it("drops askUser copies of fragment 追问 and keeps the 示例 prompt", () => { + const result = parseWorkerResponseForTest( + JSON.stringify({ + outputs: { + "设计.美学纲领与交互范式": { + schema: "context-fragment.v1", + brief: "孤立免疫", + 正文: { 美学纲领: { 体验内核: "特权与惊惶" } }, + 追问: { + 导语: "还需确认:", + 题目: [ + { + 问: "你最想反复感受到的是哪一种?", + 建议选项: ["日常从容", "揭示翻转"], + 示例: "袖口唐纹", + }, + ], + }, + }, + }, + summary: "美学纲领 · 孤立免疫", + askUser: [ + { + prompt: "为贴近你要的质感:你最想反复感受到的是哪一种?", + options: ["日常从容", "揭示翻转"], + }, + ], + }), + ["设计.美学纲领与交互范式"], + ); + expect(result.askUser).toHaveLength(1); + expect(result.askUser?.[0]?.prompt).toContain("示例:袖口唐纹"); + expect(result.askUser?.[0]?.prompt).not.toContain("为贴近你要的质感"); + }); + + it("recovers a root-level context-fragment into the artifact tag", () => { + const frag = { + schema: "context-fragment.v1", + 技能: "美学纲领与交互范式", + brief: "孤立免疫", + 正文: { + 设定逻辑: { 变造: { 原型: "丧尸", 变点: "唯我免疫" } }, + 交互范式: { 代入: "完全" }, + 美学纲领: { 体验内核: "人人录我" }, + }, + }; + const result = parseWorkerResponseForTest(JSON.stringify(frag), [ + "设计.美学纲领与交互范式", + ]); + expect(result.outputs["设计.美学纲领与交互范式"]).toContain("人人录我"); + expect(result.askUser).toBeUndefined(); + }); + + it("moves a fragment written to 创作.当前步骤 onto the artifact tag", () => { + const frag = { + schema: "context-fragment.v1", + 技能: "美学纲领与交互范式", + brief: "孤立免疫", + 正文: { 美学纲领: { 体验内核: "特权与惊惶" } }, + }; + const result = parseWorkerResponseForTest( + JSON.stringify({ + outputs: { "创作.当前步骤": frag }, + summary: "误写入指针", + }), + ["设计.美学纲领与交互范式", "创作.当前步骤"], + ); + expect(result.outputs["创作.当前步骤"]).toBeUndefined(); + expect(result.outputs["设计.美学纲领与交互范式"]).toContain("特权与惊惶"); + }); + + it("does not accept a fragment without 正文; keeps 追问 for the LLM loop", () => { + const result = parseWorkerResponseForTest( + JSON.stringify({ + outputs: { + "设计.美学纲领与交互范式": { + schema: "context-fragment.v1", + 技能: "美学纲领与交互范式", + brief: "在唯一免疫的末日联接中体验绝望掌控感", + mount: "world-simulator", + 追问: { + 导语: "还差站位", + 题目: [{ 问: "你代入吗?", 建议选项: ["完全代入", "旁观"] }], + }, + }, + }, + summary: "半残", + }), + ["设计.美学纲领与交互范式"], + ); + expect(result.outputs["设计.美学纲领与交互范式"]).toBeUndefined(); + expect(result.askUser?.length).toBeGreaterThan(0); + expect(result.askUser?.[0]?.prompt).toContain("你代入吗"); + }); + + it("asks to continue the loop when fragment JSON is unusable", () => { + const result = parseWorkerResponseForTest( + JSON.stringify({ + outputs: { + "设计.美学纲领与交互范式": + '{"schema":"context-fragment.v1","brief":"孤立免疫","mount":"world-simulator"', + }, + summary: "截断", + }), + ["设计.美学纲领与交互范式"], + ); + expect(result.outputs["设计.美学纲领与交互范式"]).toBeUndefined(); + expect(result.askUser?.[0]?.prompt).toContain("还没写出可验收的产物"); + }); }); diff --git a/web/agent-ui.js b/web/agent-ui.js index 95b3cd3..2a03036 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -1,6 +1,12 @@ import { renderIntakePanel } from "./intake-ui.js"; -import { getActiveQuestions, renderQuestionsCard } from "./questions-ui.js"; -import { displayWorkerLabel, formatWorkerDisplayTitle } from "./display-labels.js"; +import { + getActiveQuestions, + getModuleOpeningPrompt, + isModuleOpeningWaiting, + isQuestionCardDismissed, + renderQuestionsCard, +} from "./questions-ui.js"; +import { displayWorkerLabel, formatWorkerDisplayTitle, reviewComposerCopy } from "./display-labels.js"; import { PRESENT_SHELL_IDS, parsePresentDoc, @@ -60,17 +66,20 @@ function renderThinkingBlock(thinking, { open = false } = {}) { `; } -function renderLiveStreamBody(live) { - if (!live) return "…"; +function renderLiveStreamBody(live, view) { + const thinking = (live?.thinking || view?.agentThinking || "").trim(); + const output = (live?.output ?? "").trim(); + const showOutput = + Boolean(output) && (view?.lifecycleStage ?? document.body.dataset.lifecycle) === "play"; const parts = []; - if (live.thinking?.trim()) { + if (thinking) { parts.push( - `
思考
${esc(live.thinking.trim())}
`, + `
思考
${esc(thinking)}
`, ); } - if (live.output?.trim()) { + if (showOutput) { parts.push( - `
输出
${esc(live.output.trim())}
`, + `
输出
${esc(output)}
`, ); } return parts.length ? parts.join("") : "…"; @@ -102,38 +111,70 @@ function canRefreshMessage(msg) { return kind === "worker_questions" || kind === "worker_output"; } +/** ‹ n/total › 进退 + 可重出;后续输入基于当前激活版本 */ +function renderVariantNavHtml(msg) { + if (!msg?.id) return ""; + const total = Math.max(1, Number(msg.branchTotal) || 1); + const index = Math.min(total, Math.max(1, (msg.branchIndex ?? 0) + 1)); + const refreshable = canRefreshMessage(msg); + if (total <= 1 && !refreshable) return ""; + const id = esc(msg.id); + const prevDis = index <= 1 ? " disabled" : ""; + const nextDis = index >= total ? " disabled" : ""; + const refresh = refreshable + ? `` + : ""; + return ` + + ${index}/${total} + + ${refresh} + `; +} + +/** @deprecated 用 renderVariantNavHtml */ function renderMsgVariantBadge(msg) { - const total = msg.branchTotal ?? 1; - if (total <= 1) return ""; - const index = (msg.branchIndex ?? 0) + 1; - return `${index}/${total}`; + return renderVariantNavHtml(msg); } function hideMsgMenu() { const menu = document.getElementById("msg-action-menu"); if (menu) menu.hidden = true; msgMenuState.messageId = null; + msgMenuState.rollbackMessageId = null; + msgMenuState.card = null; } -const msgMenuState = { messageId: null, handlers: null }; +const msgMenuState = { + messageId: null, + rollbackMessageId: null, + card: 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 isReadOnly = card.dataset.readOnly === "1"; const canEdit = !isReview && card.dataset.canEdit === "1"; - const canDelete = !isReview && Boolean(card.dataset.messageId); + const canDelete = !isReview && !isReadOnly && Boolean(card.dataset.messageId); + const canRollback = !isReview && !isReadOnly && Boolean(card.dataset.rollbackMessageId); const hasContext = card.dataset.hasContext === "1"; const editBtn = document.getElementById("msg-menu-edit"); const delBtn = document.getElementById("msg-menu-delete"); + const rollbackBtn = document.getElementById("msg-menu-rollback"); const ctxBtn = document.getElementById("msg-menu-context"); if (editBtn) editBtn.toggleAttribute("hidden", !canEdit); if (delBtn) delBtn.toggleAttribute("hidden", !canDelete); + if (rollbackBtn) rollbackBtn.toggleAttribute("hidden", !canRollback); 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; + msgMenuState.rollbackMessageId = card.dataset.rollbackMessageId ?? null; + msgMenuState.card = card; const rect = menu.getBoundingClientRect(); if (rect.right > window.innerWidth) { menu.style.left = `${Math.max(4, window.innerWidth - rect.width - 4)}px`; @@ -152,10 +193,11 @@ function wireMsgActionMenu(handlers) { menu.addEventListener("click", async (e) => { const action = e.target.closest("[data-msg-menu-action]")?.getAttribute("data-msg-menu-action"); const messageId = msgMenuState.messageId; + const rollbackMessageId = msgMenuState.rollbackMessageId; + const card = msgMenuState.card; hideMsgMenu(); if (!messageId || !action) return; - const card = document.querySelector(`[data-message-id="${messageId}"]`); - const bodyEl = card?.querySelector(".msg-body:not(.msg-body-editing)"); + const bodyEl = card?.querySelector(".msg-body:not(.msg-body-editing), .coord-text"); const body = bodyEl?.textContent ?? card?.dataset.originalText ?? ""; if (action === "copy") { @@ -174,8 +216,14 @@ function wireMsgActionMenu(handlers) { startInlineEdit(card, body); return; } + if (action === "rollback") { + if (!rollbackMessageId) return; + if (!confirm("回退到这条消息?之后的对话和创作状态将被移除。")) return; + msgMenuState.handlers?.onDeleteMessage?.(rollbackMessageId); + return; + } if (action === "delete") { - if (!confirm("删除此消息及之后的对话?")) return; + if (!confirm("从这里重开?这条消息及之后的对话和创作状态将被移除。")) return; msgMenuState.handlers?.onDeleteMessage?.(messageId); } }); @@ -186,12 +234,29 @@ function wireMessageContextMenu(feed, handlers) { feed.dataset.contextWired = "1"; wireMsgActionMenu(handlers); feed.addEventListener("contextmenu", (e) => { - const bubble = e.target.closest(".msg-bubble"); + const bubble = e.target.closest(".msg-bubble, .workspace-review, .coord-line"); 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); }); + feed.addEventListener("click", (e) => { + const contextTrigger = e.target.closest("[data-context-trigger]"); + const contextCard = contextTrigger?.closest("[data-message-id]"); + if (contextTrigger && contextCard) { + e.preventDefault(); + e.stopPropagation(); + handlers?.onViewContext?.(contextCard.dataset.messageId); + return; + } + const trigger = e.target.closest("[data-msg-menu-trigger]"); + const card = trigger?.closest("[data-message-id]"); + if (!trigger || !card) return; + e.preventDefault(); + e.stopPropagation(); + const rect = trigger.getBoundingClientRect(); + showMsgMenu(card, rect.right, rect.bottom + 4); + }); } function startInlineEdit(card, bodyText) { @@ -251,13 +316,19 @@ function cancelInlineEdit(card) { } function wireMessageFeedActions(feed, handlers) { - if (!feed || feed.dataset.actionsWired) return; + if (!feed) return; + // 每次刷新 handlers(委托仍挂一次;回调走最新) + feed._msgHandlers = handlers; + if (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; + e.preventDefault(); + e.stopPropagation(); const action = btn.getAttribute("data-msg-action"); const messageId = btn.getAttribute("data-msg-id"); + const h = feed._msgHandlers || handlers; if (!messageId && action !== "edit-cancel" && action !== "edit-save") return; const card = btn.closest("[data-message-id]"); @@ -293,22 +364,22 @@ function wireMessageFeedActions(feed, handlers) { const original = (card?.dataset.originalText ?? body).trim(); cancelInlineEdit(card); if (!next || next === original) return; - handlers.onEditMessage?.(messageId, next); + h.onEditMessage?.(messageId, next); return; } if (action === "refresh") { - handlers.onRefreshMessage?.(messageId); + h.onRefreshMessage?.(messageId); return; } if (action === "variant-prev") { - handlers.onSwitchVariant?.(messageId, "prev"); + h.onSwitchVariant?.(messageId, "prev"); return; } if (action === "variant-next") { - handlers.onSwitchVariant?.(messageId, "next"); + h.onSwitchVariant?.(messageId, "next"); } }); } @@ -316,10 +387,14 @@ function wireMessageFeedActions(feed, handlers) { function msgBody(msg, view) { let body = (msg.body ?? msg.text ?? "").trim(); if (msg.kind === "worker_questions") { + // 能力默认问题走说话面,侧栏勿显示「提问中 · N 题」空壳索引 + if (isModuleOpeningWaiting(view)) { + return body || "默认问题(主栏引导中)"; + } if (getActiveQuestions(view)) { const qs = view.waitingReason?.questions ?? []; const n = Array.isArray(qs) ? qs.length : 0; - return `提问中 · ${n || "?"} 题(请在下方询问卡作答)`; + return `提问中 · ${n || "?"} 题`; } if (!body) { const wr = view.waitingReason; @@ -405,6 +480,41 @@ export function renderSkillPicker(_view, _onPick) { let activeRailTab = "books"; /** 用户手动展开侧栏后,在本会话保持展开,直到再点收起 */ let railUserExpanded = false; +/** 创作验收右侧「此前对话」:默认收起,把宽度留给产物 */ +let coordRailCollapsed = true; + +function syncCoordRailChrome() { + const rail = document.getElementById("coord-rail"); + const toggle = document.getElementById("btn-coord-toggle"); + document.body.classList.toggle("coord-rail-collapsed", coordRailCollapsed); + if (toggle) { + toggle.setAttribute("aria-expanded", coordRailCollapsed ? "false" : "true"); + toggle.title = coordRailCollapsed ? "展开此前对话" : "收起此前对话"; + } + if (rail) { + rail.dataset.collapsed = coordRailCollapsed ? "1" : "0"; + } +} + +function setCoordRailCollapsed(collapsed) { + coordRailCollapsed = Boolean(collapsed); + syncCoordRailChrome(); +} + +function wireCoordRailChrome() { + const toggle = document.getElementById("btn-coord-toggle"); + if (toggle && !toggle.dataset.wired) { + toggle.dataset.wired = "1"; + toggle.addEventListener("click", () => { + setCoordRailCollapsed(!coordRailCollapsed); + }); + } + const collapse = document.getElementById("btn-coord-collapse"); + if (collapse && !collapse.dataset.wired) { + collapse.dataset.wired = "1"; + collapse.addEventListener("click", () => setCoordRailCollapsed(true)); + } +} export function setRailTab(tab, { expand = false } = {}) { activeRailTab = tab === "log" ? "log" : "books"; @@ -474,10 +584,16 @@ function syncRailCollapse(view) { } export function resetRailChrome() { - railUserExpanded = true; activeRailTab = "books"; + railUserExpanded = true; + coordRailCollapsed = true; document.body.dataset.railCanCollapse = "0"; - document.body.classList.remove("rail-collapsed"); + document.body.classList.remove("rail-collapsed", "coord-rail-collapsed"); + const rail = document.getElementById("coord-rail"); + if (rail) { + rail.hidden = true; + rail.dataset.collapsed = "1"; + } setRailTab("books"); } @@ -497,6 +613,13 @@ function isStartupPromptMessage(msg, view) { return Boolean(prompt && body === prompt); } +/** 产物挂载追问的历史空壳(旧会话可能仍有);追问应跟产物同面,不单开气泡 */ +function isReviewSidecarQuestionStub(msg) { + if ((msg.kind ?? "") !== "worker_questions") return false; + const text = String(msg.text ?? msg.body ?? "").trim(); + return /^\[Worker\]\s*可选追问/.test(text); +} + function shouldShowInFeed(msg, view) { if (msg.role === "user") return true; const kind = msg.kind ?? "system_info"; @@ -504,6 +627,7 @@ function shouldShowInFeed(msg, view) { if (FEED_HIDDEN_KINDS.has(kind)) return false; if (msg.compressed) return false; if (isSkillSelectionMessage(msg)) return false; + if (isReviewSidecarQuestionStub(msg)) return false; if ( view.waitingReason?.kind === "intake" && !hasUserMessages(view) && @@ -514,6 +638,10 @@ function shouldShowInFeed(msg, view) { if (kind === "worker_output" && view.waitingReason?.kind === "review_artifact") { return false; } + // 创作:编排器思考进「调度」,不占主区;游玩保持原样 + if (kind === "orchestrator_thinking" && view.lifecycleStage !== "play") { + return false; + } if (kind === "orchestrator_prompt") return false; return true; } @@ -1039,7 +1167,9 @@ function splitTaggedArtifactSections(text) { function softenJsonText(s) { return String(s || "") .replace(/^\uFEFF/, "") - .replace(/,\s*([\]}])/g, "$1"); + .replace(/,\s*([\]}])/g, "$1") + .replace(/\u2026/g, "") + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, ""); } function tryParseJsonDoc(text) { @@ -1053,11 +1183,25 @@ function tryParseJsonDoc(text) { const cleaned = softenJsonText(stripCodeFences(text)); let parsed = tryParse(cleaned); if (parsed != null) return parsed; + // 双重编码:整段是 JSON 字符串 + if ( + (cleaned.startsWith('"') && cleaned.endsWith('"')) || + (cleaned.startsWith("'") && cleaned.endsWith("'")) + ) { + const inner = tryParse(cleaned); + if (typeof inner === "string") { + parsed = tryParse(softenJsonText(inner)); + if (parsed != null) return parsed; + } + } const objStart = cleaned.indexOf("{"); const objEnd = cleaned.lastIndexOf("}"); if (objStart >= 0 && objEnd > objStart) { parsed = tryParse(cleaned.slice(objStart, objEnd + 1)); if (parsed != null) return parsed; + // 截断 JSON:从第一个 { 起尽量补全括号后再试 + parsed = tryParse(repairTruncatedJsonObject(cleaned.slice(objStart))); + if (parsed != null) return parsed; } const arrStart = cleaned.indexOf("["); const arrEnd = cleaned.lastIndexOf("]"); @@ -1068,16 +1212,105 @@ function tryParseJsonDoc(text) { return null; } -function wrapArtifactRawDetails(rawText) { +/** 截断的 {… 补齐引号/括号,便于验收卡仍能出 mosaic */ +function repairTruncatedJsonObject(slice) { + let s = String(slice || "").trim(); + if (!s.startsWith("{")) return s; + // 去掉末尾半截键值 + s = s.replace(/,\s*"[^"]*$/u, ""); + s = s.replace(/,\s*$/u, ""); + const opens = (s.match(/\{/g) || []).length; + const closes = (s.match(/\}/g) || []).length; + const openBrackets = (s.match(/\[/g) || []).length; + const closeBrackets = (s.match(/\]/g) || []).length; + // 未闭合字符串:奇数个未转义引号 + const quoteCount = (s.match(/(? closeBrackets) s += "]".repeat(openBrackets - closeBrackets); + if (opens > closes) s += "}".repeat(opens - closes); + return s; +} + +const FRAGMENT_HEADER_KEYS = new Set([ + "schema", + "技能", + "skill", + "name", + "brief", + "概要", + "mount", + "挂载", + "稳变", + "稳定", + "stability", + "自评", + "追问", + "开放问题", + "open_questions", + "正文", + "body", +]); + +/** 模型常把正文键摊在根上;验收展示时收进「正文」以便走 mosaic */ +function normalizeContextFragmentDoc(doc) { + if (!doc || typeof doc !== "object" || Array.isArray(doc)) return doc; + const row = { ...doc }; + const hasBody = row.正文 != null || row.body != null; + const looksFrag = + row.schema === "context-fragment.v1" || + (typeof row.技能 === "string" && row.技能.trim()) || + (typeof row.brief === "string" && typeof row.技能 === "string"); + if (!looksFrag) return doc; + if (!hasBody) { + const body = {}; + for (const [k, v] of Object.entries(row)) { + if (FRAGMENT_HEADER_KEYS.has(k)) continue; + if (v == null || v === "") continue; + body[k] = v; + delete row[k]; + } + if (Object.keys(body).length) row.正文 = body; + } + if (!row.schema) row.schema = "context-fragment.v1"; + return row; +} + +function wrapArtifactRawDetails(rawText, opts = {}) { const pretty = (() => { const parsed = tryParseJsonDoc(rawText); if (parsed != null) return JSON.stringify(parsed, null, 2); return String(rawText || "").trim(); })(); + if (!pretty) return ""; const body = pretty.startsWith("{") || pretty.startsWith("[") ? `${highlightJson(pretty)}` : esc(pretty); - return `
原始 JSON
${body}
`; + const quiet = opts.quiet === true; + const label = quiet ? "原文" : "原始 JSON"; + return `
${label}
${body}
`; +} + +function isMetaPointerTag(title) { + const t = String(title || ""); + return /当前步骤|当前单位|已验收单位|创作\.当前/.test(t); +} + +function renderCrumbArtifact(title, leafText, rawSlice) { + const leaf = clampPreviewText(leafText, 120); + const raw = rawSlice + ? wrapArtifactRawDetails(rawSlice, { quiet: true }) + : ""; + return `
+ + ${raw} +
`; } function clampPreviewText(text, max = 140) { @@ -1155,13 +1388,37 @@ function summarizeArtifactDoc(doc, tagTitle = "") { } function renderArtifactCompactCard(doc, tagTitle, rawSlice, opts = {}) { - const meta = summarizeArtifactDoc(doc, tagTitle); + // 进度指针不是产物:验收区完全隐藏 + if (isMetaPointerTag(tagTitle)) return ""; + + const normalized = + doc && typeof doc === "object" && !Array.isArray(doc) + ? normalizeContextFragmentDoc(doc) + : doc; + const meta = summarizeArtifactDoc(normalized, tagTitle); const full = - renderKnownArtifactHtml(doc, { + renderKnownArtifactHtml(normalized, { embed: true, hideAskSidecar: opts.hideAskSidecar === true, - }) || renderStructuredDocHtml(doc); - const raw = rawSlice ? wrapArtifactRawDetails(rawSlice) : ""; + hideScores: opts.hideScores === true || opts.flat === true, + }) || renderStructuredDocHtml(normalized); + const raw = rawSlice ? wrapArtifactRawDetails(rawSlice, { quiet: opts.flat === true }) : ""; + + // 验收展开态:不要 details 套盒 + 摘要预览再占一层;一条 meta,正文直接上场 + if (opts.flat) { + const metaBits = [meta.chipsHtml, meta.scoresHtml].filter(Boolean).join(""); + return `
+
+ + ${metaBits ? `
${metaBits}
` : ""} + ${raw} +
+
${full}
+
`; + } + const metaRow = [meta.chipsHtml, meta.scoresHtml].filter(Boolean).join(""); const openAttr = opts.open ? " open" : ""; return `
@@ -1180,7 +1437,23 @@ function renderArtifactCompactCard(doc, tagTitle, rawSlice, opts = {}) { } function renderPlainCompactCard(title, content, opts = {}) { + if (isMetaPointerTag(title)) { + return ""; + } const preview = clampPreviewText(content, 160); + if (opts.flat) { + return `
+
+ +
+
+

未能解析为可展示产物。

+
${esc(content)}
+
+
`; + } const openAttr = opts.open ? " open" : ""; const looksJson = /^\s*[{\[]/.test(String(content || "")); const body = looksJson @@ -1200,7 +1473,7 @@ function renderPlainCompactCard(title, content, opts = {}) {
`; } -/** 紧凑卡片:预览 + 展开详情;多 tag 各一张。opts.defaultOpen:验收时默认展开吃满阅读区 */ +/** 紧凑卡片:预览 + 展开详情;多 tag 各一张。opts.defaultOpen / flat:验收时扁平直出 */ function formatArtifactBodyHtml(body, opts = {}) { const trimmed = (body || "").trim(); if (!trimmed) { @@ -1208,14 +1481,18 @@ function formatArtifactBodyHtml(body, opts = {}) { } const cardOpts = { open: opts.defaultOpen === true, + flat: opts.flat === true, hideAskSidecar: opts.hideAskSidecar === true, + hideScores: opts.hideScores === true, }; const sections = splitTaggedArtifactSections(trimmed); const hasTagHeaders = sections.length > 1 || (sections.length === 1 && sections[0].title); if (hasTagHeaders) { - const cards = sections.map((sec) => { + const cards = sections + .filter((sec) => !isMetaPointerTag(sec.title)) + .map((sec) => { const parsed = tryParseJsonDoc(sec.content); if (parsed != null && typeof parsed === "object") { return renderArtifactCompactCard(parsed, sec.title, sec.content, cardOpts); @@ -1229,7 +1506,8 @@ function formatArtifactBodyHtml(body, opts = {}) { )}`; } return renderPlainCompactCard(sec.title || "产物", sec.content, cardOpts); - }); + }) + .filter(Boolean); return `
${cards.join("")}
`; } @@ -1315,41 +1593,48 @@ function renderPlaySlotsHtml(doc) { } function isContextFragmentLike(doc) { + if (!doc || typeof doc !== "object" || Array.isArray(doc)) return false; + if (doc.schema === "context-fragment.v1") return true; return ( typeof doc.brief === "string" && - (doc.正文 != null || doc.body != null) && + (doc.正文 != null || + doc.body != null || + typeof doc.技能 === "string") && (typeof doc.技能 === "string" || doc.schema === "context-fragment.v1") ); } function renderContextFragmentHtml(doc, opts = {}) { + const frag = normalizeContextFragmentDoc(doc); const parts = []; // 紧凑卡外层已展示 brief/挂载,展开内不再重复头区 if (!opts.embed) { const metaChips = []; - if (doc.技能) metaChips.push(`${esc(String(doc.技能))}`); - const stability = doc.稳变 || doc.稳定 || doc.stability; + if (frag.技能) metaChips.push(`${esc(String(frag.技能))}`); + const stability = frag.稳变 || frag.稳定 || frag.stability; if (stability) metaChips.push(`${esc(String(stability))}`); - if (Array.isArray(doc.mount) && doc.mount.length) { - for (const m of doc.mount) { + if (Array.isArray(frag.mount) && frag.mount.length) { + for (const m of frag.mount) { metaChips.push(`${esc(String(m))}`); } + } else if (typeof frag.mount === "string" && frag.mount.trim()) { + metaChips.push(`${esc(frag.mount.trim())}`); } - if (doc.brief || metaChips.length) { + if (frag.brief || metaChips.length) { parts.push(`
${metaChips.length ? `
${metaChips.join("")}
` : ""} - ${doc.brief ? `

${esc(String(doc.brief))}

` : ""} + ${frag.brief ? `

${esc(String(frag.brief))}

` : ""}
`); } } - const body = doc.正文 != null ? doc.正文 : doc.body; + const body = frag.正文 != null ? frag.正文 : frag.body; if (typeof body === "string" && body.trim()) { parts.push( `

正文

${renderProseHtml(body)}
`, ); } else if (body && typeof body === "object") { - const specialty = renderSpecialtyBodyHtml(body, doc.技能); + const specialty = renderSpecialtyBodyHtml(body, frag.技能); if (specialty) { parts.push(specialty); } else { @@ -1361,14 +1646,14 @@ function renderContextFragmentHtml(doc, opts = {}) { // 自评/追问已挂询问卡时:产物内保留评分条,追问不重复展示 const hideAsk = opts.hideAskSidecar === true; - const scoreHtml = renderSelfScoreHtml(doc.自评); + const scoreHtml = opts.hideScores ? "" : renderSelfScoreHtml(frag.自评); if (scoreHtml) parts.push(scoreHtml); if (!hideAsk) { - const probeHtml = renderProbeHtml(doc.追问); + const probeHtml = renderProbeHtml(frag.追问); if (probeHtml) parts.push(probeHtml); - const openQs = Array.isArray(doc.开放问题) ? doc.开放问题 : []; + const openQs = Array.isArray(frag.开放问题) ? frag.开放问题 : []; if (openQs.length) { parts.push( `

开放问题

    ${openQs @@ -1426,24 +1711,75 @@ function renderSpecialtyBodyHtml(body, skill) { skillName.includes("美学"); if (!looksAesthetics) return ""; - const order = ["设定逻辑", "交互范式", "美学纲领"]; - const used = new Set(); - const parts = []; - for (const key of order) { - if (body[key] == null) continue; - used.add(key); - const wide = key === "美学纲领"; - parts.push( - `

    ${esc(key)}

    ${renderAestheticsNodeHtml(body[key], key, 0)}
    `, - ); - } - for (const [k, v] of Object.entries(body)) { - if (used.has(k) || v == null || v === "") continue; - parts.push( - `

    ${esc(k)}

    ${renderStructuredValueHtml(v, 0)}
    `, - ); - } - return parts.length ? `
    ${parts.join("")}
    ` : ""; + // 左右约定(验收卡通用意向): + // 左 = 最终会插入的游玩契约(美学纲领 + 交互范式) + // 右 = 评估/诊断(设定逻辑里的完备度、待探、区域化等) + // 注意:当前黑板仍存整份正文;full 投影时右栏也会进模型——布局先对齐「该审什么」。 + const productKeys = [ + { + key: "美学纲领", + hint: "体验内核与呈现要点", + missing: "(未写美学纲领)", + }, + { + key: "交互范式", + hint: "人称、描写权限、后果与等待", + missing: "(未写交互范式)", + }, + ]; + const evalKeys = [ + { + key: "设定逻辑", + hint: "变造定位 + 参与/内容维度诊断(完备度、依据、待探)", + missing: "(未写设定逻辑)", + }, + ]; + const used = new Set([...productKeys, ...evalKeys].map((b) => b.key)); + + const renderBlock = (b, cls) => { + const inner = + body[b.key] != null + ? renderAestheticsNodeHtml(body[b.key], b.key, 0) + : `

    ${esc(b.missing)}

    `; + const ok = body[b.key] != null; + return `
    +
    +

    ${esc(b.key)}${ok ? "" : ``}

    +

    ${esc(b.hint)}

    +
    +
    ${inner}
    +
    `; + }; + + const productHtml = productKeys.map((b) => renderBlock(b, "af-block--product")).join(""); + const evalHtml = evalKeys.map((b) => renderBlock(b, "af-block--eval")).join(""); + const extra = Object.entries(body) + .filter(([k, v]) => !used.has(k) && v != null && v !== "") + .map( + ([k, v]) => + `
    +

    ${esc(k)}

    +
    ${renderStructuredValueHtml(v, 0)}
    +
    `, + ) + .join(""); + + return `
    +
    + 游玩契约(插入意向) + 设定诊断 / 评估 + 存盘仍是整份正文;默认 full 时两侧都会注入 +
    +
    +
    + ${productHtml || `

    (契约块为空)

    `} +
    + +
    +
    `; } /** 叙事指南 · 一份全文结构化卡(不强调双投影裁剪) */ @@ -1911,41 +2247,37 @@ function pctTone(pct) { return "high"; } -/** 统一百分比条:对齐 status-dot / badge 色阶 */ -function renderPctMeterHtml(pct, label = "完备度") { +/** 行内完备度:染色百分比,不单开卡片 */ +function renderInlinePctHtml(pct, label) { if (pct == null || !Number.isFinite(pct)) return ""; const n = Math.max(0, Math.min(100, Math.round(pct))); const tone = pctTone(n); - return `
    -
    - ${esc(label)} - ${n}% -
    -
    -
    `; + const lab = label + ? `${esc(label)}` + : ""; + return `${lab}${n}%`; } -/** 自评十分制条 */ +/** @deprecated 卡片条已弃用;统一走行内百分比 */ +function renderPctMeterHtml(pct, label = "完备度") { + return renderInlinePctHtml(pct, label); +} + +/** 自评十分制:同行染色数字 */ function renderTenMeterHtml(ten, label = "评分") { if (ten == null || !Number.isFinite(ten)) return ""; const score = Math.max(0, Math.min(10, ten)); - const width = Math.round(score * 10); const tone = tenTone(score); const shown = formatTenScore(score); - return `
    -
    - ${esc(label)} - ${shown}/10 -
    -
    -
    `; + const lab = label + ? `${esc(label)}` + : ""; + return `${lab}${shown}/10`; } -/** 瓷砖用迷你百分比(数字 + 细条),省纵向空间 */ +/** 标题旁迷你百分比 */ function renderMiniPctHtml(pct) { - if (pct == null || !Number.isFinite(pct)) return ""; - const n = Math.max(0, Math.min(100, Math.round(pct))); - return `${n}`; + return renderInlinePctHtml(pct); } function extractNodePct(value) { @@ -1966,6 +2298,42 @@ function isDiagnosticNode(value) { ); } +/** 评价维度块:小标题 + 旁路完备度;结论/依据为从属行 */ +function renderDiagnosticDimHtml(title, value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return ``; + } + const pct = extractNodePct(value); + const head = `
    +
    ${esc(title || "项")}
    + ${pct != null ? renderInlinePctHtml(pct) : ""} +
    `; + const skipKey = (k) => + isPctFieldKey(k) || /^(完备度|完成度|分数|覆盖度)$/.test(String(k).trim()); + const preferred = ["结论", "依据", "已知", "待探", "焦点位置", "满足来源", "内容维度", "核心感觉"]; + const keys = Object.keys(value).filter( + (k) => !skipKey(k) && value[k] != null && value[k] !== "", + ); + keys.sort((a, b) => { + const ia = preferred.indexOf(a); + const ib = preferred.indexOf(b); + if (ia >= 0 || ib >= 0) return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib); + return 0; + }); + const rows = keys.map((k) => { + const v = value[k]; + if (typeof v === "object") { + return `
    ${esc(k)}
    ${ + isDiagnosticNode(v) + ? renderDiagnosticDimHtml(k, v) + : renderAestheticsNodeHtml(v, k, 2) + }
    `; + } + return `
    ${esc(k)}${esc(String(v))}
    `; + }); + return `
    ${head}${rows.length ? `
    ${rows.join("")}
    ` : ""}
    `; +} + function isProseHeavyObject(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return false; if (isDiagnosticNode(value)) return false; @@ -1981,6 +2349,15 @@ function isProseHeavyObject(value) { } function renderAfTile(title, value) { + // 诊断块统一走维度标题样式(不再用厚瓷砖+完备度条) + if ( + value && + typeof value === "object" && + !Array.isArray(value) && + (value.结论 != null || value.完备度 != null || value.已知 != null || value.待探 != null) + ) { + return renderDiagnosticDimHtml(title, value); + } const pct = extractNodePct(value); const mini = pct != null ? renderMiniPctHtml(pct) : ""; let body = ""; @@ -1988,15 +2365,7 @@ function renderAfTile(title, value) { body = ``; } else if (typeof value !== "object") { body = `

    ${esc(clampPreviewText(String(value), 100))}

    `; - } else if (value.结论 != null || value.完备度 != null) { - const text = value.结论 != null ? String(value.结论) : ""; - const basis = value.依据 ? `${esc(clampPreviewText(String(value.依据), 60))}` : ""; - body = `${text ? `

    ${esc(clampPreviewText(text, 90))}

    ` : ""}${basis}`; - } else if (value.已知 != null || value.待探 != null) { - body = `${value.已知 != null ? `

    ${esc(clampPreviewText(String(value.已知), 80))}

    ` : ""} - ${value.待探 != null && String(value.待探) !== "无" ? `

    ${esc(clampPreviewText(`待探:${value.待探}`, 60))}

    ` : ""}`; } else { - // 非诊断对象不应进瓷砖;通栏回退 return `
    ${esc(title)}${mini}
    ${renderAestheticsNodeHtml(value, title, 1)}
    `; } return `
    ${esc(title)}${mini}
    ${body}
    `; @@ -2034,12 +2403,12 @@ function renderAestheticsNodeHtml(value, keyHint, depth) { } if (Array.isArray(value)) return renderStructuredValueHtml(value, depth); - // 单点诊断:由上层 af-tile 承载;此处给非网格调用兜底 + // 单点诊断:维度标题 + 行内完备度 if (value.结论 != null || value.完备度 != null) { - return renderAfTile(keyHint || "项", value); + return renderDiagnosticDimHtml(keyHint || "项", value); } if (value.已知 != null || value.待探 != null) { - return renderAfTile(keyHint || "项", value); + return renderDiagnosticDimHtml(keyHint || "项", value); } // 美学纲领:体验内核通栏;诊断块可瓷砖;呈现要点等散文块通栏堆叠 @@ -2077,8 +2446,16 @@ function renderAestheticsNodeHtml(value, keyHint, depth) { return parts.join(""); } - // 呈现要点等:子键多为长文 → 通栏 KV(标签在上) - if (keyHint === "呈现要点" || isProseHeavyObject(value)) { + // 呈现要点 / 交互范式子树:定键长文,统一自适应定义列表(不按字数硬切换行) + if ( + keyHint === "呈现要点" || + keyHint === "交互范式" || + keyHint === "前置配置" || + keyHint === "叙事视角" || + keyHint === "描写权限" || + keyHint === "后果与叙事控制" || + isProseHeavyObject(value) + ) { return renderObjectKvHtml(value, depth); } @@ -2087,13 +2464,13 @@ function renderAestheticsNodeHtml(value, keyHint, depth) { const entries = Object.entries(value).filter(([, v]) => v != null && v !== ""); if (!entries.length) return `(空)`; - // 仅同构短诊断块进瓷砖网格 + // 同构评价维度:用标题切开,不用瓷砖墙 const allDiagnostic = entries.every(([, v]) => isDiagnosticNode(v)); if (allDiagnostic && entries.length >= 2) { - return `
    ${entries.map(([k, v]) => renderAfTile(k, v)).join("")}
    `; + return `
    ${entries.map(([k, v]) => renderDiagnosticDimHtml(k, v)).join("")}
    `; } - // 混合:分组标题 + 内部再网格/kv(通栏) + // 混合:大标题分组 + 维度块 const nestedGroups = entries.filter( ([, v]) => v && @@ -2105,12 +2482,12 @@ function renderAestheticsNodeHtml(value, keyHint, depth) { return `
    ${entries .map(([k, v]) => { if (isDiagnosticNode(v)) { - return renderAfTile(k, v); + return renderDiagnosticDimHtml(k, v); } if (v && typeof v === "object" && !Array.isArray(v)) { - return `
    ${esc(k)}
    ${renderAestheticsNodeHtml(v, k, depth + 1)}
    `; + return `

    ${esc(k)}

    ${renderAestheticsNodeHtml(v, k, depth + 1)}
    `; } - return `
    ${esc(k)}
    ${renderScalarOrPctHtml(v, k)}
    `; + return `

    ${esc(k)}

    ${renderScalarOrPctHtml(v, k)}
    `; }) .join("")}
    `; } @@ -2136,7 +2513,7 @@ function renderSelfScoreHtml(自评) { ? `

    ${esc(clampPreviewText(String(d.说明), 80))}

    ` : ""; if (ten == null) { - return `
    ${esc(String(name))}未评分
    ${note}
    `; + return `
    ${esc(String(name))} 未评分${note}
    `; } return `
    ${renderTenMeterHtml(ten, String(name))}${note}
    `; }) @@ -2146,7 +2523,7 @@ function renderSelfScoreHtml(自评) { weak = `

    薄弱点 ${esc(String(自评.薄弱点))}

    `; } if (!bars.length && !weak) return ""; - return `

    自评

    ${bars.join("")}
    ${weak}
    `; + return `

    自评

    ${bars.join("")}
    ${weak}
    `; } function renderProbeHtml(追问) { @@ -2319,6 +2696,10 @@ function renderStructuredValueHtml(value, depth) { if (depth > 5) { return `
    嵌套对象
    ${highlightJson(JSON.stringify(value, null, 2))}
    `; } + // 结论/完备度块:走维度小标题,避免 KV 再拆出「完备度」行 + if (isDiagnosticNode(value)) { + return renderDiagnosticDimHtml("项", value); + } return renderObjectKvHtml(value, depth); } return `${esc(String(value))}`; @@ -2370,31 +2751,52 @@ function renderArrayItemCard(item, index, depth) { } function renderObjectKvHtml(obj, depth) { + if (isDiagnosticNode(obj)) { + return renderDiagnosticDimHtml("项", obj); + } const entries = Object.entries(obj).filter( ([k, v]) => !ARTIFACT_SKIP_KEYS.has(k) && v != null && v !== "", ); if (!entries.length) return `(空)`; + + // 全是评价维度 → 小标题栈 + if ( + entries.length >= 1 && + entries.every(([, v]) => isDiagnosticNode(v)) + ) { + return `
    ${entries + .map(([k, v]) => renderDiagnosticDimHtml(k, v)) + .join("")}
    `; + } + const rows = entries .map(([k, v]) => { const isComplex = v != null && typeof v === "object"; - if (!isComplex) { - const leaf = renderScalarOrPctHtml(v, k); - // 百分比条自带标签,不再并排挤「完备度」键名 - if (isPctFieldKey(k) && parseCompleteness(v) != null) { - return `
    ${leaf}
    `; - } - // 长文:标签在上,吃满横向宽度(避免窄列硬折行) - const longProse = - typeof v === "string" && (v.length > 40 || v.includes("\n")); - if (longProse) { - return `
    ${esc(k)}
    ${leaf}
    `; - } - return `
    ${esc(k)}
    ${leaf}
    `; + if (isComplex && isDiagnosticNode(v)) { + return renderDiagnosticDimHtml(k, v); } - return `
    ${esc(k)}
    ${renderStructuredValueHtml(v, depth + 1)}
    `; + if (!isComplex) { + if (isPctFieldKey(k) && parseCompleteness(v) != null) { + return renderAdaptiveDefHtml(k, renderInlinePctHtml(parseCompleteness(v))); + } + return renderAdaptiveDefHtml(k, renderScalarOrPctHtml(v, k)); + } + // 嵌套对象:小节标题 + 内部继续自适应,不跟叶子混用两套换行规则 + return `
    +

    ${esc(k)}

    + ${renderStructuredValueHtml(v, depth + 1)} +
    `; }) .join(""); - return `
    ${rows}
    `; + return `
    ${rows}
    `; +} + +/** 标签+内容:CSS 自适应并排/折行,禁止按字数猜换行 */ +function renderAdaptiveDefHtml(key, valueHtml) { + return `
    +
    ${esc(key)}
    +
    ${valueHtml}
    +
    `; } function isSettlementLike(doc) { @@ -2558,8 +2960,13 @@ function renderCreationFlowView(flowView) { const paramsHtml = paramsText ? `${esc(paramsText)}` : ""; - return `
  • + const runState = s.runState === "done" || s.runState === "current" ? s.runState : "pending"; + const runLabel = + runState === "done" ? "已执行" : runState === "current" ? "将要执行" : "未执行"; + const currentAttr = runState === "current" ? ' aria-current="step"' : ""; + return `
  • ${esc(String(s.order))} + ${esc(runLabel)} ${nameLabel}${occ} ${paramsHtml} ${paramsMissing} @@ -2569,7 +2976,6 @@ function renderCreationFlowView(flowView) { .join(""); return `
    -

    创作流程

    ${brief} ${statusHtml}
      ${rows}
    @@ -2635,7 +3041,7 @@ function wireContextOrderEditor(root, sessionId) { }); } -function renderReviewFeedCard(review, opts = {}) { +function renderReviewWorkspace(review, opts = {}) { const flowHtml = review.creationFlowView ? renderCreationFlowView(review.creationFlowView) : ""; @@ -2654,8 +3060,10 @@ function renderReviewFeedCard(review, opts = {}) { omitFocusBody: true, }); } + const hasStructured = Boolean(structured); const artifactHtml = formatArtifactBodyHtml(review.body, { - defaultOpen: true, + defaultOpen: !hasStructured, + flat: !hasStructured, hideAskSidecar: opts.hideAskSidecar === true, asPresentFallback: review.workerId === "narrator" || @@ -2665,22 +3073,49 @@ function renderReviewFeedCard(review, opts = {}) { const contextId = review.sourceMessageId || review.id || ""; const hasContext = review.contextTrace ? "1" : "0"; const label = displayWorkerLabel(review.workerId) || "产物"; + const copy = reviewComposerCopy(review.workerId); + const sourceMsg = + opts.sourceMessage || + (review.sourceMessageId + ? { id: review.sourceMessageId, kind: "worker_output", branchIndex: 0, branchTotal: 1 } + : null); + // 有源消息时尽量用真实 branch 元数据;至少给出可重出入口 + const navMsg = opts.sourceMessage + ? { + ...opts.sourceMessage, + kind: opts.sourceMessage.kind || "worker_output", + } + : sourceMsg + ? { ...sourceMsg, kind: "worker_output" } + : null; + const variantNav = navMsg ? renderVariantNavHtml(navMsg) : ""; return ` -
    -
    -
    -
    - 待验收 - ${esc(label)} -
    -
    - ${structured ? `
    ${structured}
    ` : ""} -
    - ${artifactHtml} -
    +
    +
    +
    + ${esc(copy.kicker)} +

    ${esc(label)}

    +
    + ${variantNav ? `
    ${variantNav}
    ` : ""} +
    + ${structured ? `
    ${structured}
    ` : ""} +
    + ${ + hasStructured + ? `
    + 原始产物 +
    ${artifactHtml}
    +
    ` + : artifactHtml + }
    -
    `; +
    `; +} + +/** @deprecated 验收已迁入 workspace-stage */ +function renderReviewFeedCard(review, opts = {}) { + return renderReviewWorkspace(review, opts); } /** @deprecated 验收已在主对话;无侧栏面板 */ @@ -2709,7 +3144,8 @@ export function renderAgentPanel(view, loading) { if (badgeEl && view.burst) { const show = view.burst.count > 0 || view.phase === "running"; badgeEl.hidden = !show; - badgeEl.textContent = `burst ${view.burst.count}/${view.burst.max}`; + badgeEl.textContent = `${view.burst.count}/${view.burst.max}`; + badgeEl.title = `burst ${view.burst.count}/${view.burst.max}`; } if (traceEl) { @@ -2741,6 +3177,7 @@ export function renderAgentPanel(view, loading) { const kind = msg.kind ?? "system_info"; if (HIDE_KINDS.has(kind)) continue; if (isSkillSelectionMessage(msg)) continue; + if (isReviewSidecarQuestionStub(msg)) continue; items.push({ kind, title: msg.title ?? msgLabel(msg), @@ -2774,6 +3211,245 @@ export function renderAgentPanel(view, loading) { timelineEl.scrollTop = timelineEl.scrollHeight; } +function renderCoordLine(msg, view, opts = {}) { + const isUser = msg.role === "user"; + const kind = msg.kind ?? (isUser ? "user_input" : "system_info"); + const who = isUser ? "你" : msgLabel(msg); + const raw = msgBody(msg, view).replace(/\s+/g, " ").trim(); + const text = raw.length > 320 ? `${raw.slice(0, 320)}…` : raw; + const nav = renderVariantNavHtml(msg); + const allMessages = view.messages ?? []; + const messageIndex = allMessages.findIndex((item) => item.id === msg.id); + const rollbackMessageId = messageIndex >= 0 ? allMessages[messageIndex + 1]?.id : null; + const rollbackAttr = rollbackMessageId + ? ` data-rollback-message-id="${esc(rollbackMessageId)}"` + : ""; + const contextAttr = msg.contextTrace ? ` data-has-context="1"` : ""; + const readOnlyAttr = opts.traceOnly ? ` data-read-only="1"` : ""; + const trace = msg.contextTrace; + const traceMeta = trace + ? ` +
    + ${esc(trace.caller || msg.tokenUsage?.caller || "unknown")} + ${esc(trace.model || msg.tokenUsage?.model || "model?")} + ${msg.tokenUsage?.totalTokens ? `${Number(msg.tokenUsage.totalTokens).toLocaleString("zh-CN")} tok` : ""} +
    ` + : ""; + const traceClass = trace ? " coord-line--trace" : ""; + return `
    + +
    ${esc(who)}${esc(kind)}${fmtTime(msg.createdAt)}${nav}
    +
    ${esc(text || "(空)")}
    + ${traceMeta} +
    `; +} + +function getRollbackMessageId(messages, messageId) { + const messageIndex = messages.findIndex((item) => item.id === messageId); + return messageIndex >= 0 ? messages[messageIndex + 1]?.id ?? null : null; +} + +/** @returns {"speak"|"answer"|"review"|"busy"|null} */ +function resolveDesignSurface(view, loading) { + if (view.lifecycleStage === "play") return null; + if (view.phase === "done") return null; + const busy = Boolean(loading || (view.phase === "running" && !view.waitingReason)); + if (busy) return "busy"; + if (view.phase === "error") return "speak"; + if (view.waitingReason?.kind === "review_artifact" && view.reviewArtifact) { + return "review"; + } + // 能力默认问题:说话面 + openingGuide(勿进空答题壳) + if (isModuleOpeningWaiting(view)) return "speak"; + // 本轮题已收起时不排答题面,否则主区只剩空壳 + if ( + getActiveQuestions(view)?.questions?.length && + !isQuestionCardDismissed(view) + ) { + return "answer"; + } + if ( + view.phase === "waiting_user" || + view.waitingReason || + view.phase === "idle" + ) { + // 已打开作品、等人开口或补充 + if (view.bookId || view.waitingReason) return "speak"; + } + return null; +} + +function ensureQuestionsHostIn(parent) { + const host = document.getElementById("questions-card-host"); + if (!host || !parent) return host; + if (host.parentElement !== parent) parent.appendChild(host); + return host; +} + +function proposedOutputCopy(proposed) { + const params = proposed?.params; + const target = + params && typeof params === "object" + ? params.target ?? params.object ?? params["对象"] ?? params["生成对象"] + : null; + const targetText = + typeof target === "string" || typeof target === "number" + ? String(target).trim() + : ""; + if (targetText) { + return `将生成「${targetText}」的${proposed.name}。`; + } + return `接下来将生成:${proposed?.name || "下一项内容"}。`; +} + +function renderSpeakWorkspace(view) { + const wr = view.waitingReason; + const guide = view.openingGuide; + const moduleOpening = isModuleOpeningWaiting(view); + const openingText = moduleOpening + ? (guide?.text || getModuleOpeningPrompt(view) || "").trim() + : guide?.text && + !hasUserMessages(view) && + view.lifecycleStage !== "play" + ? String(guide.text).trim() + : ""; + const showOpening = Boolean(openingText); + + let title = "继续说"; + let hint = "在底栏输入你的想法。"; + let guideHtml = ""; + + if (showOpening) { + title = guide?.stepName || (moduleOpening ? "按引导先说几句" : "开局"); + hint = moduleOpening + ? "按下面几点先说几句即可,不必整齐;写完发送后继续产出。" + : "按下面几点先说几句即可,不必整齐。"; + guideHtml = `
    ${esc(openingText)}
    `; + } else if (moduleOpening) { + // 兜底:waiting 已识别为默认问题,但题干缺失 + title = guide?.stepName || "按引导先说几句"; + hint = "想到什么写什么,写完发送即可。"; + } else if (view.phase === "error") { + title = "说明后重试"; + hint = view.hints?.[0] ?? "说明问题或直接发送继续。"; + } else if (wr?.kind === "intake") { + const hasUser = hasUserMessages(view); + title = hasUser ? "继续补充" : "描述你想创作什么"; + hint = hasUser + ? "继续说细节;信息齐后可确认。" + : "用几句话说明题材、玩法或爽点即可。"; + } else if (wr?.kind === "revision") { + title = "说明修改意见"; + hint = "写清楚要改哪里;发送后在现有产物上修改。"; + } else if (wr?.kind === "next_intent") { + title = "下一步想写什么"; + hint = "说说接下来想做什么;可留空,发送后会展示下一节点供确认。"; + } else if (wr?.kind === "approve_step") { + title = view.proposedNextStep?.name + ? `接下来生成 · ${view.proposedNextStep.name}` + : "确认下一步"; + hint = view.proposedNextStep + ? proposedOutputCopy(view.proposedNextStep) + : view.focus?.detail || "确认执行,或在底栏说明意见。"; + } else if (wr?.kind === "input") { + title = "继续说"; + hint = view.hints?.[0] || wr.message || "直接输入你的想法或补充。"; + } else if (view.uiPrompt && !hasUserMessages(view)) { + title = "描述你想创作什么"; + hint = String(view.uiPrompt).trim().slice(0, 280); + } + const recipe = + view.selectedRecipe?.name && !hasUserMessages(view) + ? `

    配方:${esc(view.selectedRecipe.name)}

    ` + : ""; + const intake = + wr?.kind === "intake" && view.intake?.fields?.length && hasUserMessages(view) + ? `
    ${renderIntakePanel(view.intake, { variant: "feed" })}
    ` + : ""; + return `
    + 说话 +

    ${esc(title)}

    +

    ${esc(hint)}

    + ${recipe} + ${guideHtml} + ${intake} +
    `; +} + +function renderAnswerWorkspaceShell() { + return `
    +
    + 答题 +

    点字母选中,文案可改;点卡片展开,点顶条收起。

    +
    +
    `; +} + +function fillCoordRail(visible, view, handlers) { + const coordRail = document.getElementById("coord-rail"); + const drawerBody = document.getElementById("coord-drawer-body"); + const drawerCount = document.getElementById("coord-drawer-count"); + if (!coordRail) return; + const visibleIds = new Set(visible.map((message) => message.id)); + const history = (view.messages ?? []).filter((message) => { + if (isReviewSidecarQuestionStub(message)) return false; + return visibleIds.has(message.id) || Boolean(message.contextTrace); + }); + const traceCount = history.filter((message) => message.contextTrace).length; + coordRail.hidden = false; + if (drawerBody) { + drawerBody.innerHTML = history.length + ? history + .map((m) => renderCoordLine(m, view, { traceOnly: !visibleIds.has(m.id) })) + .join("") + : `

    暂无对话摘要

    `; + wireMessageFeedActions(drawerBody, handlers); + wireMessageContextMenu(drawerBody, handlers); + } + if (drawerCount) { + drawerCount.textContent = history.length ? String(history.length) : ""; + drawerCount.title = traceCount ? `${traceCount} 条保留了完整请求上下文` : ""; + } + syncCoordRailChrome(); +} + +function resolveReviewSourceMessage(view) { + const id = view?.reviewArtifact?.sourceMessageId; + if (!id) return null; + const found = (view.messages ?? []).find((m) => m.id === id); + if (found) return found; + return { + id, + role: "system", + kind: "worker_output", + branchIndex: 0, + branchTotal: 1, + }; +} + +function mountReviewWorkspace(stage, view, handlers, opts = {}) { + if (!stage || !view.reviewArtifact) return; + stage.hidden = false; + const askSlot = opts.mountAskCard + ? `
    ` + : ""; + stage.innerHTML = + renderReviewWorkspace(view.reviewArtifact, { + hideAskSidecar: opts.hideAskSidecar === true, + sourceMessage: resolveReviewSourceMessage(view), + }) + askSlot; + wireMessageFeedActions(stage, handlers); + wireMessageContextMenu(stage, handlers); + if (opts.mountAskCard) { + const askHost = document.getElementById("workspace-review-ask"); + if (askHost) ensureQuestionsHostIn(askHost); + } +} + export function renderMessageFeed(view, loading, handlers = {}) { const feed = document.getElementById("message-feed"); if (!feed) return; @@ -2782,17 +3458,54 @@ export function renderMessageFeed(view, loading, handlers = {}) { return; } + wireCoordRailChrome(); + + const stage = document.getElementById("workspace-stage"); + const panelFeed = document.getElementById("panel-feed"); + const coordRail = document.getElementById("coord-rail"); + const surface = resolveDesignSurface(view, loading); + const designWorkspace = surface != null; const reviewingNow = view.waitingReason?.kind === "review_artifact" && Boolean(view.reviewArtifact); + document.body.classList.toggle("is-reviewing", reviewingNow); + document.body.classList.toggle("design-workspace", designWorkspace); + if (reviewingNow) { + document.body.dataset.reviewKind = reviewComposerCopy( + view.reviewArtifact?.workerId, + ).kind; + } else { + delete document.body.dataset.reviewKind; + } + if (designWorkspace) { + document.body.dataset.designSurface = surface; + syncCoordRailChrome(); + } else { + document.body.classList.remove("coord-rail-collapsed"); + delete document.body.dataset.designSurface; + } const intake = view.waitingReason?.kind === "intake" && view.intake?.fields?.length; - const showIntakePanel = intake && hasUserMessages(view); + const activeQuestions = getActiveQuestions(view); feed.innerHTML = ""; + if (stage) { + stage.hidden = true; + stage.innerHTML = ""; + } + if (coordRail) { + coordRail.hidden = true; + const drawerBody = document.getElementById("coord-drawer-body"); + const drawerCount = document.getElementById("coord-drawer-count"); + if (drawerBody) drawerBody.innerHTML = ""; + if (drawerCount) drawerCount.textContent = ""; + } + // 默认把询问卡停回主列底部槽位 + if (panelFeed) ensureQuestionsHostIn(panelFeed); - if (showIntakePanel) { + const showIntake = intake && hasUserMessages(view); + if (showIntake && !designWorkspace) { const box = document.createElement("div"); box.className = "intake-panel"; box.innerHTML = renderIntakePanel(view.intake, { variant: "feed" }); @@ -2801,33 +3514,77 @@ export function renderMessageFeed(view, loading, handlers = {}) { const visible = (view.messages ?? []).filter((m) => shouldShowInFeed(m, view)); - 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); + // —— 创作伪 0 层:主柱随任务换,历史进右侧 —— + if (designWorkspace) { + fillCoordRail(visible, view, handlers); + + if (!stage) return; + + if (surface === "busy" || loading) { + stage.hidden = false; + const live = view.liveStream; + const label = live?.label ?? view.focus?.action ?? "处理中"; + stage.innerHTML = ` +
    +
    + ${esc(label)} + 流式输出中 +
    +
    ${renderLiveStreamBody(live, view)}
    +
    `; return; } + + if (surface === "review" && view.reviewArtifact) { + const hasHungAsk = Boolean(activeQuestions?.questions?.length); + mountReviewWorkspace(stage, view, handlers, { + hideAskSidecar: hasHungAsk, + mountAskCard: hasHungAsk, + }); + // 无挂载追问时询问卡停回主列底部;有则已嵌在产物下方 + if (!hasHungAsk && panelFeed) ensureQuestionsHostIn(panelFeed); + return; + } + + if (surface === "answer") { + stage.hidden = false; + stage.innerHTML = renderAnswerWorkspaceShell(); + const slot = document.getElementById("workspace-answer-slot"); + if (slot) ensureQuestionsHostIn(slot); + return; + } + + // speak(含 approve / intake / revision / error) + stage.hidden = false; + stage.innerHTML = renderSpeakWorkspace(view); + if (panelFeed) ensureQuestionsHostIn(panelFeed); + return; + } + + // —— 游玩:保持原对话流 —— + if (!visible.length && !loading) { + 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 = "在下方回答提问。"; + } else if (intake) { + p.textContent = "在下方描述你想创作什么;Agent 会收成 Worker 集供你验收。"; + } else { + p.textContent = "在下方继续对话。"; + } + p.classList.add("empty-intake"); + feed.appendChild(p); + return; } for (const msg of visible) { @@ -2837,6 +3594,8 @@ export function renderMessageFeed(view, loading, handlers = {}) { const card = document.createElement("article"); card.className = `msg ${MSG_CLASS[kind] ?? "system"}${isUser ? " msg-user-row" : " msg-assistant-row"}`; card.dataset.messageId = msg.id; + const rollbackMessageId = getRollbackMessageId(view.messages ?? [], msg.id); + if (rollbackMessageId) card.dataset.rollbackMessageId = rollbackMessageId; const label = msgLabel(msg); const subtitle = (msg.title ?? "").trim(); @@ -2854,26 +3613,31 @@ export function renderMessageFeed(view, loading, handlers = {}) { ${renderMsgVariantBadge(msg)} ${fmtTime(msg.createdAt)}`; + const menuTrigger = ``; card.dataset.canEdit = canEditMessage(msg) ? "1" : "0"; card.dataset.hasContext = msg.contextTrace ? "1" : "0"; card.dataset.originalText = body; + const questionsActive = kind === "worker_questions" && Boolean(activeQuestions); + const showThinking = + !isUser && msg.thinking && view.lifecycleStage === "play"; + const bodyHtml = questionsActive + ? `

    ${esc(body)}

    ` + : kind === "worker_questions" + ? formatQuestionsHtml(body) + : kind === "worker_output" + ? formatArtifactBodyHtml(body) + : shouldRenderPlayPresent(msg, view) + ? formatPlayPresentHtml(body, view) + : esc(body); + card.innerHTML = ` -
    - ${!isUser && kind === "worker_questions" ? `
    需要你回答
    ` : ""} - ${isUser ? "" : `
    ${headInner}
    `} - ${!isUser && msg.thinking ? renderThinkingBlock(msg.thinking) : ""} -
    ${ - kind === "worker_questions" - ? formatQuestionsHtml(body) - : kind === "worker_output" - ? formatArtifactBodyHtml(body) - : shouldRenderPlayPresent(msg, view) - ? formatPlayPresentHtml(body, view) - : esc(body) - }
    - ${isUser ? `
    ${headInner}
    ` : ""} +
    + ${isUser ? "" : `
    ${headInner}${menuTrigger}
    `} + ${showThinking ? renderThinkingBlock(msg.thinking) : ""} +
    ${bodyHtml}
    + ${isUser ? `
    ${headInner}${menuTrigger}
    ` : ""}
    `; feed.appendChild(card); } @@ -2881,17 +3645,12 @@ export function renderMessageFeed(view, loading, handlers = {}) { 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, { - hideAskSidecar: Boolean(getActiveQuestions(view)), + if (reviewingNow && view.reviewArtifact && !loading && stage) { + const hasHungAsk = Boolean(activeQuestions?.questions?.length); + mountReviewWorkspace(stage, view, handlers, { + hideAskSidecar: hasHungAsk, + mountAskCard: hasHungAsk, }); - const card = wrap.firstElementChild; - if (card) feed.appendChild(card); } if (loading) { @@ -2903,12 +3662,13 @@ export function renderMessageFeed(view, loading, handlers = {}) { pending.innerHTML = `
    ${esc(label)}流式输出中
    -
    ${renderLiveStreamBody(live)}
    +
    ${renderLiveStreamBody(live, view)}
    `; feed.appendChild(pending); } feed.scrollTop = feed.scrollHeight; + if (stage && !stage.hidden) stage.scrollTop = 0; } /** 轮询时仅更新流式 pending 卡片,避免整页重绘 */ @@ -2920,9 +3680,16 @@ export function updateLiveStreamPanel(view) { const live = view.liveStream; const labelEl = pending.querySelector(".msg-tag"); if (labelEl && live?.label) labelEl.textContent = live.label; - body.innerHTML = renderLiveStreamBody(live); + const next = renderLiveStreamBody(live, view); + const hadThinking = Boolean(body.querySelector(".msg-live-pre")); + if (next === "…" && hadThinking) return; + body.innerHTML = next; + const pre = body.querySelector(".msg-live-pre:last-of-type"); + if (pre) pre.scrollTop = pre.scrollHeight; const feed = document.getElementById("message-feed"); - if (feed) feed.scrollTop = feed.scrollHeight; + if (feed && !document.body.classList.contains("design-workspace")) { + feed.scrollTop = feed.scrollHeight; + } } export function renderBoardPanel(view) { @@ -2999,13 +3766,16 @@ export function renderBoardPanel(view) { export function renderWorkspace(view, loading, _onPickSkill, handlers = {}) { renderLifecycle(view); renderSkillPicker(view); + // 先铺主柱(可能把询问卡移进答题槽),再渲染卡 + renderMessageFeed(view, loading, handlers); const qHost = document.getElementById("questions-card-host"); if (qHost) { renderQuestionsCard(qHost, view, { onSkipQuestions: () => handlers.onSkipQuestions?.(), + onAnswersChange: () => handlers.onQuestionAnswersChange?.(), + onCardEnter: () => handlers.onQuestionCardEnter?.(), }); } - renderMessageFeed(view, loading, handlers); renderAgentPanel(view, loading); } diff --git a/web/app.js b/web/app.js index 3e82862..bb4dc2a 100644 --- a/web/app.js +++ b/web/app.js @@ -1,11 +1,12 @@ import { renderWorkspace, updateLiveStreamPanel, resetRailChrome } from "./agent-ui.js"; import { downloadMarkdown, sessionToMarkdown } from "./export.js"; import { renderIntakePanel } from "./intake-ui.js"; -import { displaySkillPackLabel } from "./display-labels.js"; +import { displaySkillPackLabel, reviewComposerCopy } from "./display-labels.js"; import { clearQuestionCardState, collectQuestionAnswers, getActiveQuestions, + isModuleOpeningWaiting, renderQuestionsCard, } from "./questions-ui.js"; @@ -27,16 +28,159 @@ const LIVE_POLL_MS = 280; const $ = (id) => document.getElementById(id); const PHASE = { idle: "待命", running: "执行中", waiting_user: "等待你", done: "已完成", error: "出错" }; -const REASON = { - skill_selection: "选择配方", - intake: "补充信息", - input: "等待输入", - approve_step: "确认执行", - review_artifact: "验收产物", - worker_questions: "回答提问", - revision: "按反馈修改", + +/** 用户任务三态(表层);底层 waitingReason 映射进来 */ +const USER_TASK = { + speak: { id: "speak", label: "说话", title: "继续说" }, + answer: { id: "answer", label: "答题", title: "回答问题" }, + review: { id: "review", label: "验收", title: "验收产物" }, }; +function reviewCopyFromView(view) { + return reviewComposerCopy(view?.reviewArtifact?.workerId, { + hasQuestions: Boolean(getActiveQuestions(view)?.questions?.length), + }); +} + +/** + * 把 waitingReason 收成用户三态:说话 / 答题+自由发挥 / 批阅。 + * @returns {{ id: 'speak'|'answer'|'review'|'busy'|'idle'|'error', label: string, title: string, hint: string|null }} + */ +function resolveUserTask(view, loading) { + if (loading || isAgentBusy(view, loading)) { + return { + id: "busy", + label: "执行中", + title: view.focus?.action ?? "总管或 Worker 执行中…", + hint: null, + }; + } + if (composerForceInput) { + return { + id: "speak", + label: USER_TASK.speak.label, + title: "说明意见", + hint: "写完发送即可。", + }; + } + if (view.phase === "done") { + return { id: "idle", label: "已完成", title: "会话已结束", hint: null }; + } + if (view.phase === "error") { + return { + id: "error", + label: "出错", + title: "说明后重试", + hint: view.hints?.[0] ?? "执行出错,可在下方重试", + }; + } + + const wr = view.waitingReason; + const hasQuestions = Boolean(getActiveQuestions(view)?.questions?.length); + const moduleOpening = isModuleOpeningWaiting(view); + + if (wr?.kind === "review_artifact") { + const copy = reviewCopyFromView(view); + return { + id: "review", + label: copy.taskLabel, + title: copy.taskTitle, + hint: copy.hint, + }; + } + + // 能力默认问题:对齐美学纲领开局 → 说话面,不进「答题」 + if (moduleOpening) { + const stepName = view.openingGuide?.stepName?.trim(); + return { + id: "speak", + label: USER_TASK.speak.label, + title: stepName || "按引导先说几句", + hint: "想到什么写什么,不必整齐;写完发送即可。", + }; + } + + if (hasQuestions || wr?.kind === "worker_questions") { + return { + id: "answer", + label: USER_TASK.answer.label, + title: "回答问题", + hint: "先点选项作答;也可以在底栏自由补充。", + }; + } + + if (wr?.kind === "approve_step") { + const proposed = view.proposedNextStep; + return { + id: "speak", + label: "确认", + title: proposed?.name ? `接下来生成 · ${proposed.name}` : "确认下一步", + hint: proposed + ? proposedOutputCopy(proposed) + : view.focus?.detail ?? "确认执行,或说明意见。", + }; + } + + if (wr?.kind === "next_intent") { + return { + id: "speak", + label: USER_TASK.speak.label, + title: "下一步想写什么", + hint: "说说接下来想做什么;可留空,发送后会展示下一节点。", + }; + } + + if (wr?.kind === "revision") { + return { + id: "speak", + label: USER_TASK.speak.label, + title: "说明修改意见", + hint: "写清楚要改哪里;发送后在现有产物上修改。", + }; + } + + if (wr?.kind === "intake") { + const hasUser = (view.messages ?? []).some((m) => m.role === "user"); + return { + id: "speak", + label: USER_TASK.speak.label, + title: hasUser ? "继续补充" : "描述你想创作什么", + hint: hasUser + ? "继续说细节,或等信息齐后确认。" + : "用几句话说明题材、玩法或爽点即可。", + }; + } + + if (wr?.kind === "input" || wr?.kind === "skill_selection") { + return { + id: "speak", + label: USER_TASK.speak.label, + title: "继续说", + hint: view.hints?.[0] ?? "直接输入你的想法或补充。", + }; + } + + if (view.phase === "waiting_user") { + return { + id: "speak", + label: USER_TASK.speak.label, + title: "继续说", + hint: view.hints?.[0] ?? null, + }; + } + + return { + id: "idle", + label: PHASE[view.phase] ?? view.phase, + title: PHASE[view.phase] ?? "待命", + hint: null, + }; +} + +function applyUserTaskChrome(task) { + document.body.dataset.userTask = task?.id || ""; +} + /** @type {{ id: string, name: string, description: string }[]} */ let directors = []; @@ -54,16 +198,67 @@ function esc(s) { return String(s).replace(/&/g, "&").replace(//g, ">"); } +function proposedOutputCopy(proposed) { + const params = proposed?.params; + const target = + params && typeof params === "object" + ? params.target ?? params.object ?? params["对象"] ?? params["生成对象"] + : null; + const targetText = + typeof target === "string" || typeof target === "number" + ? String(target).trim() + : ""; + if (targetText) { + return `将生成「${targetText}」的${proposed.name}。`; + } + return `接下来将生成:${proposed?.name || "下一项内容"}。`; +} + +/** 确认下一步:给人看「生成什么」;有 target 时顺带露出后台 id,便于改对象时同步。 */ +function proposeVisibleFields(proposed) { + const missing = new Set(proposed?.paramsMissing || []); + const specs = Array.isArray(proposed?.paramSpecs) ? proposed.paramSpecs : []; + const byKey = new Map(specs.map((s) => [s.key, s])); + const out = []; + const push = (key) => { + const spec = byKey.get(key); + if (!spec || out.some((s) => s.key === key)) return; + out.push(spec); + }; + + if (byKey.has("target")) { + push("target"); + // 对象与后台 id 绑定:改女租客→丧尸时,rule_id 也要换成 zombies 一类英文 + if (byKey.has("rule_id")) push("rule_id"); + } else if (byKey.has("rule_id") && (missing.has("rule_id") || proposed?.name === "具体实例")) { + push("rule_id"); + } + + for (const key of missing) push(key); + return out; +} + +function proposeFieldLabel(field) { + if (field.key === "rule_id" && field.label === "规则 id") return "后台 id"; + return field.label || field.key; +} + +function proposeFieldHint(field, proposed) { + if (field.key === "rule_id" && proposed?.paramSpecs?.some((s) => s.key === "target")) { + return "英文 kebab-case,须与上方对象对应;改对象时一并改(如丧尸→zombies)"; + } + return field.hint || field.key; +} + 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 ?? "总管运行中" }; + const task = resolveUserTask(view, loading); + if (task.id === "busy") return { cls: "running", text: task.title }; + if (task.id === "error") return { cls: "", text: task.label }; + if (task.id === "idle" && view.phase === "done") return { cls: "done", text: "已完成" }; + if (task.id === "speak" || task.id === "answer" || task.id === "review") { + return { cls: "waiting", text: task.label }; } - if (view.waitingReason) { - return { cls: "waiting", text: REASON[view.waitingReason.kind] ?? view.waitingReason.kind }; - } - return { cls: "", text: PHASE[view.phase] ?? view.phase }; + return { cls: "", text: task.label }; } function isAgentBusy(view, loading) { @@ -72,18 +267,26 @@ function isAgentBusy(view, loading) { } function resolveComposer(view, loading) { - if (view.phase === "done") return { mode: "idle", text: "会话已结束" }; + const task = resolveUserTask(view, loading); + applyUserTaskChrome(task); + + if (task.id === "busy") { + return { mode: "waiting", text: task.title, task }; + } + if (view.phase === "done") { + return { mode: "idle", text: "会话已结束", task }; + } if (view.phase === "error") { const send = view.actions?.find((a) => a.type === "send_message"); return { mode: "input", + task, + taskTitle: task.title, + taskHint: task.hint, placeholder: send?.placeholder ?? "说明后重试,或直接发送继续…", - hint: view.hints?.[0] ?? "执行出错,可在下方重试", + hint: null, }; } - if (isAgentBusy(view, loading)) { - 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) { @@ -91,6 +294,9 @@ function resolveComposer(view, loading) { const send = view.actions?.find((a) => a.type === "send_message"); return { mode: view.intake.ready && confirmIntake ? "intake_ready" : "intake", + task, + taskTitle: task.title, + taskHint: task.hint, intake: view.intake, intakePrompt: hasUser ? view.intakePrompt : null, showIntakePanel: hasUser, @@ -101,48 +307,65 @@ function resolveComposer(view, loading) { const approve = view.actions?.find((a) => a.type === "approve"); const accept = view.actions?.find((a) => a.type === "accept"); + if (approve && view.proposedNextStep && !composerForceInput) { + return { + mode: "propose_step", + task, + taskTitle: task.title, + taskHint: task.hint, + proposed: view.proposedNextStep, + primary: approve, + hint: null, + }; + } if (approve && !composerForceInput) { return { mode: "action", + task, + taskTitle: task.title, + taskHint: task.hint, primary: approve, primaryType: "approve", - hint: view.focus?.detail, + hint: null, showReject: true, }; } if (accept && view.waitingReason?.kind !== "review_artifact" && !composerForceInput) { return { mode: "action", + task, + taskTitle: "验收产物", + taskHint: "同意就接受;要改可先点「说明意见」。", primary: accept, primaryType: "accept", - hint: "验收产物", + hint: null, 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); + const hasQuestionsCard = Boolean(getActiveQuestions(view)?.questions?.length); if (view.waitingReason?.kind === "review_artifact") { const parseBroken = Boolean(view.reviewArtifact?.workerSetView?.parseError); + const copy = reviewCopyFromView(view); return { mode: "input", - placeholder: send?.placeholder ?? "输入修改意见后发送…", - hint: hasQuestionsCard - ? "接受产物即收起追问(表示无需再完善)" - : null, + task, + taskTitle: copy.taskTitle, + taskHint: copy.hint, + placeholder: send?.placeholder ?? copy.placeholder, + hint: null, + submitLabel: copy.submitLabel, + emptyEnterHint: copy.emptyEnterHint, acceptAction: accept ? { - label: accept.label || "接受目前产物", + label: copy.acceptLabel, + title: copy.hint, + tone: copy.tone, disabled: parseBroken, disabledTitle: parseBroken - ? "规格解析失败,请先发送修改意见让 Worker 重写" + ? "规格解析失败,请先写修改意见再改产物" : undefined, } : null, @@ -152,8 +375,12 @@ function resolveComposer(view, loading) { if (hasQuestionsCard && !composerForceInput) { return { mode: "input", + task, + taskTitle: task.title, + taskHint: task.hint, placeholder: send?.placeholder ?? "也可在此自由补充…", hint: null, + submitLabel: "补充并发送", }; } @@ -161,31 +388,90 @@ function resolveComposer(view, loading) { send || view.waitingReason?.kind === "input" || view.waitingReason?.kind === "worker_questions" || + view.waitingReason?.kind === "revision" || + view.waitingReason?.kind === "next_intent" || composerForceInput ) { const wr = view.waitingReason; - if (wr?.kind === "worker_questions" && !hasQuestionsCard) { - return { - mode: "input", - placeholder: "在此直接回答上面的问题…", - hint: null, - }; + let placeholder = send?.placeholder ?? "输入你想说的…"; + let submitLabel = "发送"; + if (wr?.kind === "next_intent") { + placeholder = send?.placeholder ?? "下一步想写什么?(可留空)…"; + submitLabel = "继续"; + } else if (wr?.kind === "approve_step" && composerForceInput) { + placeholder = + "例如:改成丧尸怪物规则(后台 id 也换成 zombies)…"; + submitLabel = "发送意见"; + } else if (wr?.kind === "revision") { + placeholder = "说明要改哪里…"; + submitLabel = "按意见修改"; + } else if (wr?.kind === "worker_questions" && !hasQuestionsCard) { + placeholder = isModuleOpeningWaiting(view) + ? "想到什么写什么…" + : "直接回答问题…"; + } else if ( + view.openingGuide?.text && + !(view.messages ?? []).some((m) => m.role === "user") + ) { + placeholder = "按上方引导写几句…"; } - const hint = view.hints?.[0] ?? null; - return { mode: "input", placeholder: send?.placeholder ?? "输入消息…", hint }; + return { + mode: "input", + task, + taskTitle: task.title, + taskHint: task.hint, + placeholder, + hint: null, + submitLabel, + allowEmpty: wr?.kind === "next_intent", + }; } const finish = view.actions?.find((a) => a.type === "finish"); if (finish) { - return { mode: "action", primary: finish, primaryType: "finish", hint: null, showReject: false }; + return { + mode: "action", + task, + taskTitle: "结束", + taskHint: null, + primary: finish, + primaryType: "finish", + hint: null, + showReject: false, + }; } - return { mode: "idle", text: "暂无可用操作" }; + return { mode: "idle", text: "暂无可用操作", task }; +} + +function composerModeChip(spec) { + const id = spec.task?.id; + if (id !== "speak" && id !== "answer" && id !== "review") return ""; + const label = spec.task.label || ""; + const tip = [spec.taskTitle, spec.taskHint].filter(Boolean).join(" — "); + const tone = spec.acceptAction?.tone ? ` data-tone="${esc(spec.acceptAction.tone)}"` : ""; + return `${esc(label)}`; +} + +function composerInputShell(spec, { textareaHtml, trailing = "" } = {}) { + const chip = composerModeChip(spec); + const tone = spec.acceptAction?.tone + ? ` data-tone="${esc(spec.acceptAction.tone)}"` + : ""; + return `
    + ${chip} + ${textareaHtml} + ${trailing ? `
    ${trailing}
    ` : ""} +
    `; } function renderComposer(view, loading) { const root = $("composer"); if (!root) return; + if (root._enterHandler) { + document.removeEventListener("keydown", root._enterHandler); + root._enterHandler = null; + } const spec = resolveComposer(view, loading); if (spec.mode === "waiting") { @@ -206,14 +492,14 @@ function renderComposer(view, loading) { spec.mode === "intake_ready" && spec.confirmIntake ? `
    ` : ""; + const shell = composerInputShell(spec, { + textareaHtml: ``, + trailing: ``, + }); root.innerHTML = ` - ${spec.intakePrompt ? `

    ${esc(spec.intakePrompt)}

    ` : ""} ${intakePanel} ${confirm} -
    - - -
    `; +
    ${shell}
    `; wireComposerForm(); root.querySelector("[data-act=confirm_intake]")?.addEventListener("click", () => runAction("confirm_intake")); return; @@ -221,13 +507,14 @@ function renderComposer(view, loading) { if (spec.mode === "action") { const reject = spec.showReject - ? ` + ? ` ` : ""; + const chip = composerModeChip(spec); root.innerHTML = ` - ${spec.hint ? `

    ${esc(spec.hint)}

    ` : ""} -
    - +
    + ${chip} + ${reject}
    `; root.querySelector(`[data-act="${spec.primaryType}"]`)?.addEventListener("click", () => runAction(spec.primaryType)); @@ -236,43 +523,257 @@ function renderComposer(view, loading) { composerForceInput = true; renderComposer(view, loading); }); + wireComposerActionEnter(spec.primaryType); return; } + if (spec.mode === "propose_step") { + const p = spec.proposed; + const missing = new Set(p.paramsMissing || []); + const originalTarget = String(p.params?.target ?? "").trim(); + const originalRuleId = String(p.params?.rule_id ?? "").trim(); + const visible = proposeVisibleFields(p); + const fields = visible + .map((field) => { + const val = p.params?.[field.key]; + const str = + val == null ? "" : typeof val === "string" ? val : JSON.stringify(val); + const need = missing.has(field.key); + const hint = proposeFieldHint(field, p); + return ``; + }) + .join(""); + const syncHint = + visible.some((f) => f.key === "target") && visible.some((f) => f.key === "rule_id") + ? `

    改生成对象时,后台 id 也要改成对应英文(如丧尸怪物 → zombies)。

    ` + : ""; + root.innerHTML = ` +
    +
    + 下一步 + ${esc(p.name)} +
    +

    ${esc(proposedOutputCopy(p))}

    + ${fields ? `
    ${fields}
    ` : ""} + ${syncHint} +
    + + + +
    +
    `; + const targetInput = root.querySelector('[data-param-key="target"]'); + const ruleIdInput = root.querySelector('[data-param-key="rule_id"]'); + const outputEl = root.querySelector("[data-propose-output]"); + const refreshOutput = () => { + if (!outputEl || !targetInput) return; + const next = { + ...p, + params: { ...(p.params || {}), target: String(targetInput.value ?? "").trim() }, + }; + outputEl.textContent = proposedOutputCopy(next); + }; + const markRuleIdSync = () => { + if (!targetInput || !ruleIdInput) return; + const nextTarget = String(targetInput.value ?? "").trim(); + const nextRuleId = String(ruleIdInput.value ?? "").trim(); + const targetChanged = Boolean(originalTarget) && nextTarget !== originalTarget; + const ruleStale = + targetChanged && Boolean(originalRuleId) && nextRuleId === originalRuleId; + ruleIdInput.closest(".propose-field")?.classList.toggle("is-stale-id", ruleStale); + }; + targetInput?.addEventListener("input", () => { + refreshOutput(); + markRuleIdSync(); + }); + ruleIdInput?.addEventListener("input", markRuleIdSync); + const approvePropose = () => { + const stepParams = { ...(p.params || {}) }; + root.querySelectorAll("[data-param-key]").forEach((input) => { + const key = input.getAttribute("data-param-key"); + if (!key) return; + const v = String(input.value ?? "").trim(); + if (v) stepParams[key] = v; + else delete stepParams[key]; + }); + const nextTarget = String(stepParams.target ?? "").trim(); + const nextRuleId = String(stepParams.rule_id ?? "").trim(); + if ( + originalTarget && + nextTarget && + nextTarget !== originalTarget && + originalRuleId && + nextRuleId === originalRuleId + ) { + alert("生成对象已改,请把后台 id 改成对应英文(例如丧尸怪物 → zombies),不要沿用旧 id。"); + ruleIdInput?.focus(); + markRuleIdSync(); + return; + } + void runAction("approve", { stepParams }); + }; + root.querySelector("[data-act=approve]")?.addEventListener("click", approvePropose); + root.querySelector("[data-act=reject]")?.addEventListener("click", () => runAction("reject")); + root.querySelector("[data-act=force-input]")?.addEventListener("click", () => { + composerForceInput = true; + renderComposer(view, loading); + }); + wireComposerActionEnter("approve", { onEnter: approvePropose, allowInInputs: true }); + return; + } + + const acceptOnEmpty = Boolean(spec.acceptAction); + const acceptTone = spec.acceptAction?.tone || "accept"; const acceptBtn = spec.acceptAction - ? `` + ? spec.acceptAction.disabledTitle || "暂不可确认" + : spec.acceptAction.title || spec.acceptAction.label + )}" ${spec.acceptAction.disabled ? "disabled" : ""}>${esc(spec.acceptAction.label)}` : ""; - root.innerHTML = ` - ${spec.askBanner - ? `
    ${esc(spec.askBanner.title)}${esc(spec.askBanner.body)}
    ` - : ""} - ${spec.hint ? `

    ${esc(spec.hint)}

    ` : ""} -
    - - - ${acceptBtn} -
    `; - wireComposerForm(); + const submitLabel = spec.submitLabel || "发送"; + const sendIsPrimary = !acceptOnEmpty; + const placeholder = acceptOnEmpty + ? `${spec.placeholder || "修改意见…"}(${spec.emptyEnterHint || "空 Enter=确认"})` + : spec.placeholder; + const shell = composerInputShell(spec, { + textareaHtml: ``, + trailing: `${acceptBtn}`, + }); + root.innerHTML = `
    ${shell}
    `; + wireComposerForm({ acceptOnEmpty }); root.querySelector("[data-act=accept]")?.addEventListener("click", () => runAction("accept")); } -function wireComposerForm() { +function composerTextareaMaxPx(el) { + const raw = getComputedStyle(el).maxHeight; + const n = Number.parseFloat(raw); + if (Number.isFinite(n) && n > 0) return n; + return Math.min(window.innerHeight * 0.42, 360); +} + +/** SillyTavern 式:高度随内容上长,触顶后框内滚动 */ +function autosizeComposerInput(el) { + if (!el) return; + const max = composerTextareaMaxPx(el); + el.style.height = "auto"; + el.style.overflowY = "hidden"; + const line = Number.parseFloat(getComputedStyle(el).lineHeight) || 21; + const floor = Math.ceil(line); + const next = Math.min(Math.max(el.scrollHeight, floor), max); + el.style.height = `${next}px`; + el.style.overflowY = el.scrollHeight > max + 1 ? "auto" : "hidden"; +} + +/** 无输入框的确认态:Enter = 主按钮 */ +function wireComposerActionEnter(action, opts = {}) { + const root = $("composer"); + if (!root) return; + const prev = root._enterHandler; + if (prev) document.removeEventListener("keydown", prev); + const handler = (e) => { + if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; + if (e.isComposing) return; + const t = e.target; + const tag = t?.tagName; + if (t?.isContentEditable || tag === "TEXTAREA") return; + if (tag === "INPUT" && !opts.allowInInputs) return; + if (t?.closest?.(".questions-card-host [contenteditable=true], .qcard-other-input, .msg-edit-input")) { + return; + } + e.preventDefault(); + if (typeof opts.onEnter === "function") opts.onEnter(); + else void runAction(action); + }; + root._enterHandler = handler; + document.addEventListener("keydown", handler); +} + +function refreshReviewComposerChrome() { + const input = $("composer-input"); + const acceptBtn = $("composer")?.querySelector("[data-act=accept]"); + const sendBtn = $("composer")?.querySelector("[data-act=revise]"); + if (!acceptBtn || !sendBtn) return; + syncDualComposerPrimary(input, acceptBtn, sendBtn); + if (!input || lastView?.waitingReason?.kind !== "review_artifact") return; + const copy = reviewCopyFromView(lastView); + const empty = !String(input.value ?? "").trim(); + const hasAnswers = selectedQuestionAnswers(lastView).answered.length > 0; + input.placeholder = hasAnswers && empty + ? `${copy.placeholder}(Enter=${copy.submitLabel})` + : `${copy.placeholder}(${copy.emptyEnterHint})`; +} + +function syncDualComposerPrimary(input, acceptBtn, sendBtn) { + if (!acceptBtn || !sendBtn) return; + const empty = !String(input?.value ?? "").trim(); + const hasAnswers = selectedQuestionAnswers(lastView).answered.length > 0; + const revisePrimary = !empty || hasAnswers; + acceptBtn.classList.toggle("btn-primary", !revisePrimary && !acceptBtn.disabled); + sendBtn.classList.toggle("btn-primary", revisePrimary); +} + +function wireComposerForm(opts = {}) { const form = $("composer-form"); const input = $("composer-input"); + const acceptOnEmpty = opts.acceptOnEmpty === true; + const sendBtn = form?.querySelector("[data-act=revise]"); + const root = $("composer"); + if (root?._enterHandler) { + document.removeEventListener("keydown", root._enterHandler); + root._enterHandler = null; + } form?.addEventListener("submit", async (e) => { e.preventDefault(); - await sendText(input?.value ?? ""); + await submitComposer(input?.value ?? "", { acceptOnEmpty }); }); input?.addEventListener("keydown", (e) => { - if (e.key === "Enter" && !e.shiftKey) { + if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); - form?.requestSubmit(); + void submitComposer(input?.value ?? "", { acceptOnEmpty }); } }); + sendBtn?.addEventListener("click", () => { + void sendText(input?.value ?? ""); + }); + if (acceptOnEmpty) { + const handler = (e) => { + if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; + if (e.isComposing) return; + const t = e.target; + const tag = t?.tagName; + if (t?.isContentEditable || tag === "TEXTAREA" || tag === "INPUT") return; + if (t?.closest?.(".msg-edit-input, .qcard-other-input, .questions-card-host [contenteditable=true]")) { + return; + } + e.preventDefault(); + void submitComposer(input?.value ?? "", { acceptOnEmpty: true }); + }; + root._enterHandler = handler; + document.addEventListener("keydown", handler); + } + const grow = () => { + autosizeComposerInput(input); + if (acceptOnEmpty) refreshReviewComposerChrome(); + }; + input?.addEventListener("input", grow); + input?.addEventListener("change", grow); + requestAnimationFrame(grow); input?.focus(); } @@ -518,37 +1019,56 @@ function setupBookBoxSelect() { }); } +function pathHomeIcon() { + return ``; +} + function renderPathBar() { const bar = $("book-path-bar"); if (!bar) return; bar.innerHTML = ""; - bar.hidden = !books.length; + // 根级不占行;钻入作品后才显示面包屑 + const inBook = sidebarNav.level === "book"; + const book = inBook ? getNavBook() : null; + bar.hidden = !inBook || !book; + if (bar.hidden) return; + + const list = document.createElement("ol"); + list.className = "path-bar-list"; + + const rootLi = document.createElement("li"); + rootLi.className = "path-bar-item"; const rootBtn = document.createElement("button"); rootBtn.type = "button"; - rootBtn.className = "path-seg"; + rootBtn.className = "path-seg path-seg-root"; 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); + rootBtn.title = "返回作品列表"; + rootBtn.innerHTML = `${pathHomeIcon()}作品`; + rootBtn.addEventListener("click", () => navigateToRoot()); + rootLi.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 sepLi = document.createElement("li"); + sepLi.className = "path-bar-item"; + sepLi.setAttribute("aria-hidden", "true"); + const sep = document.createElement("span"); + sep.className = "path-sep"; + sep.textContent = "›"; + sepLi.appendChild(sep); - const bookSeg = document.createElement("span"); - bookSeg.className = "path-seg current"; - bookSeg.textContent = book.title; - bar.appendChild(bookSeg); - } + const bookLi = document.createElement("li"); + bookLi.className = "path-bar-item path-bar-item-current"; + const bookSeg = document.createElement("span"); + bookSeg.className = "path-seg current"; + bookSeg.setAttribute("aria-current", "page"); + bookSeg.textContent = book.title; + bookSeg.title = book.title; + bookLi.appendChild(bookSeg); + + list.append(rootLi, sepLi, bookLi); + bar.appendChild(list); } function renderExplorerRow({ name, meta, active, selected, bookId, actionClass, onClick, onDelete }) { @@ -559,8 +1079,10 @@ function renderExplorerRow({ name, meta, active, selected, bookId, actionClass, .join(" "); if (bookId) row.dataset.bookId = bookId; row.innerHTML = ` - ${esc(name)} - ${meta ? `${esc(meta)}` : ""} + + ${esc(name)} + ${meta ? `${esc(meta)}` : ""} + ${onDelete ? `×` : ""}`; row.addEventListener("click", (e) => { if (e.target.closest(".explorer-del")) return; @@ -627,10 +1149,19 @@ function renderBookContents(book, list) { const kindLabel = s.kindLabel || (s.kind === "instance" ? "创作定稿" : s.kind === "opening" ? "开局" : "游玩进度"); + const when = new Date(s.createdAt); + const shortWhen = Number.isNaN(when.getTime()) + ? "" + : when.toLocaleString("zh-CN", { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); list.appendChild( renderExplorerRow({ name: s.label, - meta: `${kindLabel} · ${new Date(s.createdAt).toLocaleString("zh-CN")}`, + meta: shortWhen ? `${kindLabel} · ${shortWhen}` : kindLabel, onClick: () => void loadPlaySave(book.id, s.id), onDelete: () => void deletePlaySave(book.id, s.id, s.label), actionClass: "explorer-file", @@ -744,15 +1275,13 @@ async function startNewPlayForBook(bookId) { } function renderHeader(view, loading) { + const task = resolveUserTask(view, loading); const st = statusFor(view, loading); $("work-title").textContent = view.bookTitle ?? "未命名作品"; 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}`; + $("work-meta").textContent = `${skill} · ${task.label}`; $("status-dot").className = `status-dot ${st.cls}`; $("status-text").textContent = st.text; const showSavePlay = Boolean(view.bookId && view.playReady && view.lifecycleStage === "play"); @@ -779,6 +1308,23 @@ function renderEmpty() { if (btnInst) btnInst.hidden = true; $("btn-export").disabled = true; $("message-feed").innerHTML = `

    点击左侧 + 新建作品

    `; + const stage = $("workspace-stage"); + if (stage) { + stage.hidden = true; + stage.innerHTML = ""; + } + const drawer = $("coord-rail"); + if (drawer) { + drawer.hidden = true; + drawer.dataset.collapsed = "1"; + const body = $("coord-drawer-body"); + if (body) body.innerHTML = ""; + const count = $("coord-drawer-count"); + if (count) count.textContent = ""; + } + document.body.classList.remove("is-reviewing", "design-workspace", "coord-rail-collapsed"); + document.body.dataset.userTask = ""; + delete document.body.dataset.designSurface; $("skill-picker").hidden = true; const focus = $("agent-focus"); if (focus) focus.innerHTML = ""; @@ -789,7 +1335,6 @@ function renderEmpty() { trace.hidden = true; trace.innerHTML = ""; } - document.body.classList.remove("is-reviewing"); resetRailChrome(); hideBookMenu(); document.body.dataset.lifecycle = "design"; @@ -836,6 +1381,12 @@ function renderSession(view, loading = false) { renderBookList(); renderWorkspace(view, loading, (skillId) => sendText(skillId), { onSkipQuestions: () => runAction("skip_questions"), + onQuestionAnswersChange: () => refreshReviewComposerChrome(), + onQuestionCardEnter: () => { + const { answered } = selectedQuestionAnswers(lastView); + if (!answered.length) return; + void sendText($("composer-input")?.value ?? ""); + }, onEditMessage: (messageId, text) => messageAction("edit", messageId, { text }), onRefreshMessage: (messageId) => messageAction("refresh", messageId), onDeleteMessage: (messageId) => messageAction("delete", messageId), @@ -865,6 +1416,36 @@ async function messageAction(kind, messageId, body = {}) { } } +/** 询问卡已选中的追问作答(不要求卡仍标 is-open;未选则为空) */ +function selectedQuestionAnswers(view) { + const qHost = $("questions-card-host"); + const activeQs = getActiveQuestions(view); + if (!activeQs?.questions?.length || !qHost?._qState) { + return { collected: null, answered: [] }; + } + const collected = collectQuestionAnswers(qHost, view); + const answered = collected?.ok + ? collected.answers.filter((a) => a.text && a.text !== "(未答)") + : []; + return { collected, answered }; +} + +/** 空 Enter=接受;有字或已选追问则按意见改。点「按意见修改」不走这条。 */ +async function submitComposer(text, { acceptOnEmpty } = {}) { + const trimmed = String(text ?? "").trim(); + if (acceptOnEmpty && !trimmed) { + const { answered } = selectedQuestionAnswers(lastView); + if (answered.length === 0) { + const acceptBtn = $("composer")?.querySelector("[data-act=accept]"); + if (acceptBtn && !acceptBtn.disabled) { + await runAction("accept"); + return; + } + } + } + await sendText(text); +} + async function sendText(text) { if (!sessionId) return; const trimmed = text?.trim() ?? ""; @@ -875,14 +1456,29 @@ async function sendText(text) { activeQs?.questions?.length && qHost?.classList.contains("is-open"), ); - // 产物验收:底栏有字 ⇒ 按修改意见重做;接受走「接受目前产物」 - if (reviewing && trimmed) { + // 能力默认问题:对齐美学纲领,须自由书写,不能空发/跳过当答复 + if (isModuleOpeningWaiting(lastView) && !trimmed) { + alert("请先按主栏引导写几句再发送。"); + return; + } + + // 核对/验收:有选中追问或修改意见 ⇒ 写回产物;点「按意见修改」绝不等于接受 + if (reviewing) { + const { collected, answered } = selectedQuestionAnswers(lastView); + if (!trimmed && answered.length === 0) { + alert("请选择追问选项或填写修改意见;满意请点「接受」收下产物。"); + return; + } try { + const body = { text: trimmed }; + if (answered.length && collected?.answers) { + body.answers = collected.answers; + } clearQuestionCardState(qHost, lastView); renderSession(lastView, true); const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { method: "POST", - body: JSON.stringify({ text: trimmed }), + body: JSON.stringify(body), }); clearQuestionCardState(qHost); renderSession(view, false); @@ -937,7 +1533,25 @@ async function sendText(text) { clearQuestionCardState(qHost, lastView); } - if (!trimmed) return; + if (!trimmed) { + if ( + lastView?.waitingReason?.kind === "next_intent" || + resolveComposer(lastView, false)?.allowEmpty + ) { + try { + renderSession(lastView, true); + const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { + method: "POST", + body: JSON.stringify({ text: "" }), + }); + clearQuestionCardState(qHost); + renderSession(view, false); + } catch (err) { + if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); + } + } + return; + } try { renderSession(lastView, true); const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { @@ -952,7 +1566,7 @@ async function sendText(text) { } } -async function runAction(action) { +async function runAction(action, extra = {}) { if (!sessionId) return; const qHost = $("questions-card-host"); try { @@ -961,9 +1575,10 @@ async function runAction(action) { clearQuestionCardState(qHost, lastView); } renderSession(lastView, true); + const body = { action, ...extra }; const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/actions`, { method: "POST", - body: JSON.stringify({ action }), + body: JSON.stringify(body), }); if (action === "accept" || action === "skip_questions") { clearQuestionCardState(qHost); @@ -1181,11 +1796,51 @@ function showContextTraceDialog(messageId) { 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"); + const subject = msg || review || {}; + const requestChars = + trace.charCount ?? + trace.messages.reduce((sum, item) => sum + String(item.content ?? "").length, 0); + const usage = subject.tokenUsage; + const responseBody = String(subject.body ?? subject.text ?? "").trim(); + const thinking = String(subject.thinking ?? "").trim(); + const requestLog = trace.messages.map((item, index) => { + const content = String(item.content ?? ""); + const part = String(index + 1).padStart(2, "0"); + return `======== REQUEST ${part}/${trace.messages.length} · ${item.role} · ${content.length.toLocaleString("zh-CN")} chars ========\n${content}`; + }); + const responseLog = [ + thinking + ? `======== RESPONSE THINKING · ${thinking.length.toLocaleString("zh-CN")} chars ========\n${thinking}` + : "", + responseBody + ? `======== RESPONSE BODY · ${responseBody.length.toLocaleString("zh-CN")} chars ========\n${responseBody}` + : "", + ].filter(Boolean); + const tokenMeta = usage?.totalTokens + ? ` · ${Number(usage.totalTokens).toLocaleString("zh-CN")} tokens` + : ""; + meta.textContent = [ + subject.kind || "message", + subject.actor || trace.caller || "unknown", + trace.model || usage?.model || "model?", + `${trace.messages.length} 段请求`, + `${(requestChars / 1024).toFixed(1)} KB`, + ].join(" · ") + tokenMeta; + body.textContent = [ + `MESSAGE ${messageId}`, + `KIND ${subject.kind || "unknown"}`, + `ACTOR ${subject.actor || "-"}`, + `CALLER ${trace.caller || usage?.caller || "unknown"}`, + `MODEL ${trace.model || usage?.model || "model?"}`, + `CREATED ${trace.createdAt || subject.createdAt || "-"}`, + `REQUEST ${trace.messages.length} messages / ${requestChars.toLocaleString("zh-CN")} chars`, + usage?.totalTokens + ? `TOKENS total=${usage.totalTokens} cached=${usage.cachedTokens ?? 0} miss=${usage.cacheMissTokens ?? 0}` + : "TOKENS not recorded", + "", + ...requestLog, + ...(responseLog.length ? ["", ...responseLog] : []), + ].join("\n\n"); dlg.showModal(); } @@ -1261,6 +1916,12 @@ window.addEventListener("wa:session-updated", (e) => { if (view?.id) renderSession(view, false); }); +document.addEventListener("click", (e) => { + document.querySelectorAll("details.more-menu[open]").forEach((d) => { + if (!d.contains(e.target)) d.removeAttribute("open"); + }); +}); + async function init() { try { void populateDirectorSelect(); diff --git a/web/display-labels.js b/web/display-labels.js index 4112462..fe030d1 100644 --- a/web/display-labels.js +++ b/web/display-labels.js @@ -93,3 +93,64 @@ export function formatWorkerDisplayTitle(workerId, action = null) { if (action === "running") return `${base} · 执行中`; return base; } + +/** + * `review_artifact` 底栏 / 工作面文案。流程编排核对工作流计划,不是「验收通过」。 + * 须与 src/server/display-labels.ts `reviewComposerCopy` 保持一致。 + */ +export function reviewComposerCopy(workerId, opts = {}) { + const hasQuestions = Boolean(opts.hasQuestions); + const id = String(workerId ?? "").trim(); + + if (id === "design-flow") { + return { + chip: "编排", + kicker: "核对编排", + taskLabel: "编排", + taskTitle: "核对工作流计划", + hint: hasQuestions + ? "同意就按这个排;要改就写意见,在现有计划上改。下方追问可选答。" + : "同意就按这个排;要改就写意见,在现有计划上改。", + submitLabel: "按意见改编排", + acceptLabel: "确认编排", + placeholder: "改编排意见…", + emptyEnterHint: "空 Enter=确认编排", + tone: "plan", + kind: "flow", + }; + } + + if (id === "opening-generator") { + return { + chip: "开场", + kicker: "待选定", + taskLabel: "开场", + taskTitle: "选定开场白", + hint: hasQuestions + ? "同意就选定此开场;要改就写意见。下方追问可选答。" + : "同意就选定此开场;要改就写意见。", + submitLabel: "按意见修改", + acceptLabel: "选定此开场", + placeholder: "修改意见…", + emptyEnterHint: "空 Enter=选定此开场", + tone: "plan", + kind: "opening", + }; + } + + return { + chip: "验收", + kicker: "待验收", + taskLabel: "验收", + taskTitle: "验收产物", + hint: hasQuestions + ? "同意就接受;要改就写意见,在现有产物上改。下方追问可选答。" + : "同意就接受;要改就写意见,在现有产物上改。", + submitLabel: "按意见修改", + acceptLabel: "接受", + placeholder: "修改意见…", + emptyEnterHint: "空 Enter=接受", + tone: "accept", + kind: "accept", + }; +} diff --git a/web/index.html b/web/index.html index 91e57d7..326b688 100644 --- a/web/index.html +++ b/web/index.html @@ -35,7 +35,7 @@
    - +
    @@ -60,14 +60,19 @@
    - 技能 - 呈现壳 - Token - 设置 +
    + 更多 + +
    @@ -77,8 +82,28 @@

    点击左侧 + 新建作品

    +
+
@@ -119,12 +144,13 @@ - + + - +
-

请求上下文

+

上下文调用日志


         
diff --git a/web/questions-ui.js b/web/questions-ui.js index 273ca34..ec99456 100644 --- a/web/questions-ui.js +++ b/web/questions-ui.js @@ -2,10 +2,15 @@ * 结构化询问卡:点字母选中,点文案编辑;左右分页。 * 询问是对同一次发送的可选增强:有选中则问+答拼接;未选中则不带问。 * 点发送即答复 → 收起本轮卡(卡上无独立发送钮)。 - * 产物验收挂载题:底栏有字优先按「改产物」发送;无字时才提交所选答案。 + * 产物验收挂载题:选项 + 底栏意见一并随「按意见修改」写回产物;空内容点该钮不等于接受。 * 分页 / 跳过为悬浮控件,不占独立 header/footer 行。 + * + * 能力「默认问题」(id=module-opening) 不是追问:对齐美学纲领开局,走说话面 + openingGuide。 */ +/** 与 phase-runtime maybeAskModuleOpening 写入的 id 对齐 */ +export const MODULE_OPENING_QUESTION_ID = "module-opening"; + function esc(s) { return String(s ?? "") .replace(/&/g, "&") @@ -14,6 +19,22 @@ function esc(s) { .replace(/"/g, """); } +/** 当前等待是否为能力默认问题(应呈现为开场引导,而非答题卡) */ +export function isModuleOpeningWaiting(view) { + const wr = view?.waitingReason; + if (wr?.kind !== "worker_questions" || !Array.isArray(wr.questions)) return false; + const qs = normalizeQuestions(wr.questions); + return qs.length === 1 && qs[0].id === MODULE_OPENING_QUESTION_ID; +} + +/** 取出默认问题正文(供 openingGuide / 说话面展示) */ +export function getModuleOpeningPrompt(view) { + if (!isModuleOpeningWaiting(view)) return null; + const qs = normalizeQuestions(view.waitingReason.questions); + const prompt = qs[0]?.prompt?.trim(); + return prompt || null; +} + function normalizeQuestions(raw) { if (!Array.isArray(raw)) return []; return raw @@ -64,6 +85,8 @@ export function getActiveQuestions(view) { // 默认每页 1 题,左右切换;勿一次堆多题 const pageSize = Math.max(1, Number(wr.pageSize) || 1); if (wr.kind === "worker_questions" && wr.questions?.length) { + // 能力默认问题:不进答题卡(与美学纲领 openingGuide 同形) + if (isModuleOpeningWaiting(view)) return null; return { questions: normalizeQuestions(wr.questions), workerId: wr.workerId, @@ -114,6 +137,18 @@ export function getActiveQuestions(view) { return null; } +/** + * 本轮题是否已答复收起(卡不会再画出来)。 + * 此时若仍把主区排成答题面,用户只会看到一片空壳。 + */ +export function isQuestionCardDismissed(view, host) { + const el = host ?? document.getElementById("questions-card-host"); + const active = getActiveQuestions(view); + if (!el?._qDismissed || !active?.questions?.length) return false; + const key = JSON.stringify(active.questions.map((q) => q.id)); + return el._qDismissed.sessionId === view?.id && el._qDismissed.key === key; +} + function persistDraftsFromDom(host, state) { host.querySelectorAll("[data-draft]").forEach((el) => { const key = el.getAttribute("data-draft"); @@ -224,7 +259,9 @@ export function clearQuestionCardState(host, view) { host._qState = null; host.hidden = true; host.innerHTML = ""; - host.classList.remove("is-open"); + host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip"); + host.removeAttribute("role"); + host.removeAttribute("aria-label"); } /** @@ -238,7 +275,9 @@ export function renderQuestionsCard(host, view, _handlers = {}) { if (!active?.questions?.length) { host.hidden = true; host.innerHTML = ""; - host.classList.remove("is-open"); + host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip"); + host.removeAttribute("role"); + host.removeAttribute("aria-label"); host._qDismissed = null; return false; } @@ -251,7 +290,9 @@ export function renderQuestionsCard(host, view, _handlers = {}) { ) { host.hidden = true; host.innerHTML = ""; - host.classList.remove("is-open"); + host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip"); + host.removeAttribute("role"); + host.removeAttribute("aria-label"); return false; } @@ -264,11 +305,14 @@ export function renderQuestionsCard(host, view, _handlers = {}) { sessionId: view.id, key: qKey, page: 0, + expanded: false, // questionId -> { optionId?, text, otherText? } answers: {}, drafts: {}, // option key -> edited label }; + if (typeof state.expanded !== "boolean") state.expanded = false; host._qState = state; + host.classList.toggle("is-expanded", state.expanded); const { questions, pageSize, assessment, optional, skipLabel, dismissOnAccept } = active; @@ -303,20 +347,24 @@ export function renderQuestionsCard(host, view, _handlers = {}) { ? `` : ""; - const chromeClass = [ - "qcard", + const chromeMods = [ pageCount > 1 ? "has-pager" : "", optional ? "has-skip" : "", - ] - .filter(Boolean) - .join(" "); + ].filter(Boolean); + + host.classList.remove("has-pager", "has-skip"); + for (const c of chromeMods) host.classList.add(c); + host.setAttribute("role", "dialog"); + host.setAttribute("aria-label", "询问"); host.innerHTML = ` - `; +
`; + + const setExpanded = (next) => { + state.expanded = next; + host.classList.toggle("is-expanded", next); + const top = host.querySelector("[data-qtop]"); + if (top) { + top.title = next ? "点击收起" : "点击展开"; + top.setAttribute("aria-label", next ? "收起询问卡" : "展开询问卡"); + } + }; + + host.querySelector("[data-qtop]")?.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + setExpanded(!state.expanded); + }); + + host.querySelector(".qcard-body")?.addEventListener("click", (e) => { + const t = e.target; + if (!(t instanceof Element)) return; + if ( + t.closest( + ".qcard-letter, .qcard-other-input, [contenteditable=true], .qcard-nav, .qcard-skip, .qcard-pager", + ) + ) { + return; + } + // 展开后点题干 / 评估区收起;收起时点内容区展开 + if (state.expanded) { + if (t.closest(".qcard-prompt, .qcard-assessment, .qcard-optional-note")) { + setExpanded(false); + } + return; + } + setExpanded(true); + }); host.querySelector("[data-qskip]")?.addEventListener("click", () => { _handlers.onSkipQuestions?.(); @@ -401,17 +484,37 @@ export function renderQuestionsCard(host, view, _handlers = {}) { text = (labelEl?.textContent ?? state.drafts[draftKey] ?? "").trim(); state.answers[qid] = { optionId: oid, text }; } - renderQuestionsCard(host, view, _handlers); + // 只切换选中态,避免整卡 innerHTML 重绘把 .qcard-body 滚回顶部 + section.querySelectorAll(".qcard-opt").forEach((li) => { + li.classList.toggle("is-selected", li.getAttribute("data-oid") === oid); + }); + if (!state.expanded) setExpanded(true); + _handlers.onAnswersChange?.(); }); }); + host.querySelectorAll("[data-other]").forEach((el) => { + el.addEventListener("keydown", (e) => { + if (e.key !== "Enter" || e.shiftKey || e.isComposing) return; + e.preventDefault(); + persistDraftsFromDom(host, state); + el.blur(); + _handlers.onCardEnter?.(); + }); + el.addEventListener("input", () => _handlers.onAnswersChange?.()); + }); + host.querySelectorAll("[data-draft]").forEach((el) => { el.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - el.blur(); - } + if (e.key !== "Enter" || e.shiftKey || e.isComposing) return; + e.preventDefault(); + persistDraftsFromDom(host, state); + const key = el.getAttribute("data-draft"); + if (key) state.drafts[key] = el.textContent ?? ""; + el.blur(); + _handlers.onCardEnter?.(); }); + el.addEventListener("input", () => _handlers.onAnswersChange?.()); el.addEventListener("blur", () => { const key = el.getAttribute("data-draft"); if (key) state.drafts[key] = el.textContent ?? ""; diff --git a/web/shells.css b/web/shells.css index 368ad20..aa24a59 100644 --- a/web/shells.css +++ b/web/shells.css @@ -1,62 +1,124 @@ -/* 呈现壳预览页 - * 注意:本页也引入 styles.css(present 样式),会给 body 设 overflow:hidden; - * 必须在此覆盖,否则侧栏后几项点不到。 - */ +/* 呈现壳预览页 — 主工作面 = 壳画布;侧栏只负责换壳 */ + html, body.shells-page { height: auto; min-height: 100%; overflow: auto; + background: var(--st-bg, #f4f4f5); } .shells-page .st-shell.shells-shell { - max-width: 1180px; + max-width: 1280px; align-items: start; + grid-template-columns: 200px minmax(0, 1fr); } .shells-page .st-rail { - --st-rail-w: 220px; - width: var(--st-rail-w); + width: 200px; position: sticky; top: var(--st-top-h, 48px); max-height: calc(100dvh - var(--st-top-h, 48px)); overflow-y: auto; min-height: 0; align-self: start; + gap: 2px; } -.shells-tone-label { - margin-top: 1rem !important; -} - -.shells-density { - display: flex; - flex-wrap: wrap; - gap: 6px 10px; - align-items: baseline; - margin: 0.5rem 0 0.35rem; - padding: 8px 10px; - border-radius: 8px; - background: color-mix(in srgb, var(--accent-soft, #e8f0fe) 70%, transparent); - font-size: 13px; - line-height: 1.45; -} - -.shells-density span { +.st-rail-hint { + margin: 0 8px 10px; font-size: 11px; - font-weight: 700; - letter-spacing: 0.04em; - color: var(--muted, #666); - text-transform: uppercase; + line-height: 1.45; + color: var(--st-faint, #a1a1aa); } .shells-list { display: flex; flex-direction: column; - gap: 4px; + gap: 2px; flex: 0 0 auto; } +.shells-list .st-rail-item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 8px 10px; + line-height: 1.25; +} + +.shells-list .shell-item-name { + font-size: 13px; + font-weight: 600; + color: inherit; +} + +.shells-list .shell-item-task { + font-size: 11px; + font-weight: 400; + color: var(--st-faint, #a1a1aa); +} + +.shells-list .st-rail-item.active .shell-item-task { + color: color-mix(in srgb, var(--st-accent, #2563eb) 65%, #71717a); +} + +.shells-main { + min-height: calc(100dvh - var(--st-top-h, 48px)); + display: flex; + flex-direction: column; +} + +.shells-toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px 20px; + padding: 14px 24px 12px; + border-bottom: 1px solid var(--st-border, #e4e4e7); + background: var(--st-surface, #fff); + position: sticky; + top: var(--st-top-h, 48px); + z-index: 3; +} + +.shells-toolbar-block { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.shells-toolbar-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--st-faint, #a1a1aa); +} + +.shells-toolbar-meta { + margin-left: auto; + align-items: flex-end; + text-align: right; + max-width: 280px; +} + +.shells-toolbar-meta .shells-meta-title { + margin: 0; + font-size: 14px; + font-weight: 650; + color: var(--st-text, #18181b); +} + +.shells-toolbar-meta .shells-meta-sub { + margin: 2px 0 0; + font-size: 12px; + color: var(--st-muted, #71717a); + line-height: 1.4; +} + .shells-tones { display: flex; flex-wrap: wrap; @@ -66,65 +128,102 @@ body.shells-page { .shells-tone-chip { font: inherit; font-size: 12px; - padding: 4px 10px; - border-radius: 999px; - border: 1px solid var(--border, #ddd); - background: var(--surface, #fff); + padding: 5px 10px; + border-radius: 6px; + border: 1px solid var(--st-border, #e4e4e7); + background: var(--st-surface, #fff); cursor: pointer; - color: inherit; + color: var(--st-muted, #71717a); +} + +.shells-tone-chip:hover { + background: #f4f4f5; + color: var(--st-text, #18181b); +} + +.shells-tone-chip:focus-visible { + outline: 2px solid var(--st-accent, #2563eb); + outline-offset: 2px; } .shells-tone-chip.active { border-color: transparent; - background: var(--accent-soft, #e8f0fe); + background: var(--st-accent-soft, #dbeafe); + color: var(--st-accent, #2563eb); font-weight: 600; } -.shells-body { - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -.shells-meta p { - margin: 0 0 0.35rem; - line-height: 1.5; -} - -.shells-preview-label, -.shells-tweaks h2, -.shells-help h2 { - margin: 0 0 0.5rem; - font-size: 13px; - font-weight: 700; - color: var(--muted, #666); +/* 主工作面:尽量大的阅读画布 */ +.shells-preview-wrap { + flex: 1 1 auto; + padding: 20px 24px 28px; + background: #ebebed; } .shells-preview { - min-height: 280px; - padding: 16px; - border-radius: 12px; - border: 1px dashed var(--border, #ccc); - background: - radial-gradient(ellipse at 20% 0%, rgba(15, 118, 110, 0.06), transparent 50%), - var(--bg, #f6f6f4); + min-height: min(70vh, 720px); + padding: 20px 16px 28px; + border-radius: 10px; + border: 1px solid var(--st-border, #e4e4e7); + background: #f4f4f5; + overflow: visible; } .shells-preview .present-shell { - max-width: 640px; + max-width: 42rem; margin: 0 auto; - box-shadow: 0 10px 28px rgba(28, 25, 23, 0.07); + box-shadow: none; } .shells-preview[data-shell="turn_panel"] .present-shell, .shells-preview[data-shell="split_board"] .present-shell, .shells-preview[data-shell="chapter_reader"] .present-shell { - max-width: 780px; + max-width: 52rem; } -/* 预览区不裁切长正文 */ -.shells-preview { - overflow: visible; +.shells-preview .present-shell-badge { + display: none; +} + +.shells-details { + margin: 0 24px 28px; + border: 1px solid var(--st-border, #e4e4e7); + border-radius: 8px; + background: var(--st-surface, #fff); +} + +.shells-details > summary { + cursor: pointer; + padding: 12px 14px; + font-size: 13px; + font-weight: 600; + color: var(--st-muted, #71717a); + list-style: none; +} + +.shells-details > summary::-webkit-details-marker { + display: none; +} + +.shells-details > summary::before { + content: "▸ "; + color: var(--st-faint, #a1a1aa); +} + +.shells-details[open] > summary::before { + content: "▾ "; +} + +.shells-details[open] > summary { + border-bottom: 1px solid var(--st-border, #e4e4e7); + color: var(--st-text, #18181b); +} + +.shells-details-body { + padding: 14px 16px 16px; + display: flex; + flex-direction: column; + gap: 14px; } .shells-tweak-grid { @@ -144,30 +243,68 @@ body.shells-page { font: inherit; padding: 6px 8px; border-radius: 6px; - border: 1px solid var(--border, #ddd); + border: 1px solid var(--st-border, #e4e4e7); + max-width: 320px; } -.shells-help ul { +.shells-help-list { margin: 0; - padding-left: 1.2rem; + padding-left: 1.15rem; line-height: 1.55; font-size: 13px; -} - -.shells-help .muted { - font-size: 12px; - color: var(--muted, #888); + color: var(--st-muted, #71717a); } @media (max-width: 800px) { + .shells-page .st-shell.shells-shell { + grid-template-columns: 1fr; + } + .shells-page .st-rail { position: static; max-height: none; width: auto; overflow: visible; + border-right: none; + border-bottom: 1px solid var(--st-border, #e4e4e7); + } + + .shells-list { + flex-direction: row; + flex-wrap: wrap; + } + + .shells-list .st-rail-item { + width: auto; + max-width: 46%; + } + + .st-rail-hint, + .st-rail-note { + display: none; + } + + .shells-toolbar { + position: static; + } + + .shells-toolbar-meta { + margin-left: 0; + text-align: left; + align-items: flex-start; + max-width: none; + width: 100%; + } + + .shells-preview-wrap { + padding: 12px; } .shells-preview .present-shell { max-width: 100%; } -} \ No newline at end of file + + .shells-details { + margin: 0 12px 20px; + } +} diff --git a/web/shells.html b/web/shells.html index e043f81..1395e72 100644 --- a/web/shells.html +++ b/web/shells.html @@ -28,44 +28,42 @@
-
-
-
-

呈现壳预览

-

用示例数据看布局与可调项

+
+
-
-
-
-

预览

-
-
-
-

本页可调(对应创作「微调轴」)

-
-
-
-

设计意图

-
    -
  • 正文默认装得下单轮 600~3000 字:区域随内容长高,由页面/消息区滚动,不塞进固定小框。
  • -
  • 双栏壳:侧栏 sticky;选择坞:选项区 sticky 贴底——长文读完控件还在。
  • -
  • 壳差在「谁占主柱」;可改的是字段/显示名/行动开关/tone,不是重画布局。
  • -
-

说明文档:docs/play-presentation-shells.md

-
+
+ 语气 +
+
+
+ +
+
+
+ +
+ 微调轴与说明 +
+
+
    +
  • 主工作面 = 上面的壳。读长文时正文撑高,页面滚动;侧栏 / 选择坞钉住。
  • +
  • 壳差在「必须同时看见什么」:只正文 / 正文+状态 / 正文+线索 / 正文+选项…
  • +
  • 创作只改字段绑定与显示名,不发明新布局。文档:docs/play-presentation-shells.md
  • +
+
+
diff --git a/web/shells.js b/web/shells.js index c44620c..8f0100e 100644 --- a/web/shells.js +++ b/web/shells.js @@ -1,71 +1,64 @@ /** - * 呈现壳预览页:切换壳 / 装饰语气 / 正文长度,用示例数据渲染 present-shells。 - * 正文设计目标:单轮常见 600~3000 字 —— 随字数撑高,外层滚动;侧栏/选项坞钉住。 + * 呈现壳预览:主工作面 = 壳画布。 + * 按「要同时看见什么」选壳;正文按 600~3000 字撑高滚动。 */ import { PRESENT_SHELL_IDS, renderPresentShellHtml, } from "./present-shells.js"; +/** task = 同时可见关系(选壳依据);name = 短名 */ const SHELL_INFO = { prose: { name: "纯散文", - blurb: "整块都是正文,无 HUD。长文直接往下滚。", - density: "正文随字数伸展;无侧栏争面积", - scenes: "轻对话、纯叙事、不要状态栏", + task: "只要正文", + blurb: "无 HUD。读完即止。", }, chat_monitor: { - name: "对话 + 监控", - blurb: "顶栏芯片固定矮;主聊/场面再长也只往下长。", - density: "监控一行;正文撑高滚动", - scenes: "网恋 / 日常扮演 / 轻 AIRP", + name: "对话+监控", + task: "正文 + 扫状态", + blurb: "顶栏芯片矮;主聊/场面往下长。", }, spotlight: { name: "场面主视", - blurb: "芯片一行 + 大正文柱。适合一幕写满。", - density: "正文主导并伸展;监控不占高", - scenes: "电影感 RP、一幕一景、少机制", + task: "大正文 + 弱状态", + blurb: "无聊天气泡框;场面即画布。", }, turn_panel: { name: "回合面板", - blurb: "场面叙述再长,右侧交互物钉在视口内对照。", - density: "正文撑高;侧栏 sticky;行动在下", - scenes: "资源、检定、场景交互、跑团感", + task: "场面 + 物 + 行动", + blurb: "对照交互物;行动在下。", }, split_board: { name: "双栏看板", - blurb: "左栏长文滚动,右栏线索钉住——对照读。", - density: "左正文伸展;右侧栏 sticky", - scenes: "调查、推理、多线索并行", + task: "正文 ‖ 线索", + blurb: "左右对照;侧栏钉住。", }, choice_dock: { name: "选择坞", - blurb: "先读完长局面;选项坞 sticky 贴视口底,不用翻回顶。", - density: "正文撑高;底部坞钉住", - scenes: "选项驱动、AVG、分支关口", + task: "局面 + 选项", + blurb: "读完再选;坞贴视口底。", }, chapter_reader: { name: "章节阅读", - blurb: "章题 + 长阅读柱;窄进度侧栏 sticky。", - density: "正文伸展为主;侧栏窄且钉住", - scenes: "长文 / 爽文 / 先纲后章", + task: "章正文 + 进度", + blurb: "阅读柱为主;进度窄栏钉住。", }, }; const TONES = [ { id: "default", label: "默认" }, - { id: "messenger", label: "讯息感" }, - { id: "book", label: "书页感" }, - { id: "terminal", label: "终端感" }, + { id: "messenger", label: "讯息" }, + { id: "book", label: "书页" }, + { id: "terminal", label: "终端" }, ]; const LENGTHS = [ - { id: "short", label: "短(~120)" }, - { id: "mid", label: "中(~800)" }, - { id: "long", label: "长(~2500)" }, + { id: "short", label: "短" }, + { id: "mid", label: "中 ·800" }, + { id: "long", label: "长 ·2500" }, ]; -/** 中等长度 ≈800 字 */ const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话,只是把购物袋往怀里收紧了一点。水从伞骨边缘连成线,砸在柏油路上,溅起细碎的白点。 路口的灯跳成绿色。你们一起迈步——这一次,谁也没有先松开。对面便利店的玻璃上映着两个人并肩的轮廓,忽明忽暗,像谁随手画的草稿。 @@ -82,7 +75,6 @@ const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话 门开了一条缝。暖光漏出来,照在你们脚边的水渍上。她没有立刻进去,只是抬眼看你,像在等一句还没说出口的话。你把伞收拢,水仍顺着伞尖滴到地毯边缘。谁也没有提「进来坐坐」,但空气里已经有了那句话的形状。`; -/** 长文 ≈2500 字(重复段落拼成,预览用) */ const BODY_LONG = `${BODY_MID} 天桥下的风从另一头灌过来,把塑料袋吹得啪啪响。你想起上一次走过这里,还是夏天,蝉声吵得人没法好好说话;如今只剩雨,以及偶尔驶过的车灯,把积水切成一条条亮痕。 @@ -115,9 +107,7 @@ const BODY_LONG = `${BODY_MID} 「有的不用还。」她看着河对岸的塔吊,「有的还了,反而像没发生过。」 -塔吊缓缓旋转,吊臂在灰蓝天空里划出钝角。你忽然明白,呈现壳要解决的不是「字大一点」,而是这一长段话落在界面里时,读者还愿不愿意往下看——状态栏不能抢戏,选项不能在中途消失,侧栏得钉住好让人对照线索。 - -你们在桥上站了一会儿。风把伞套吹得鼓起,又瘪下去。她把热饮塞进你手里:「暖的。你昨晚肯定没睡好。」你没有否认。杯壁的温度从掌心爬上手腕,像把雨夜重新烘了一遍,只留下能说出口的那一层。 +塔吊缓缓旋转,吊臂在灰蓝天空里划出钝角。你们在桥上站了一会儿。风把伞套吹得鼓起,又瘪下去。她把热饮塞进你手里:「暖的。你昨晚肯定没睡好。」你没有否认。杯壁的温度从掌心爬上手腕,像把雨夜重新烘了一遍,只留下能说出口的那一层。 分开前,她抬手点了点伞:「这个,先放你那儿。等下一场雨。」 @@ -231,7 +221,7 @@ function buildTweaks() { show_suggested_actions: state.showActions, block_labels: { ...state.blockLabels }, empty_states: { - aside: "(侧栏空:可在创作里改空态文案)", + aside: "(侧栏空)", footer: "(文末空)", }, }; @@ -252,27 +242,25 @@ function buildPacket() { function render() { const info = SHELL_INFO[state.shell] || { name: state.shell, + task: "", blurb: "", - scenes: "", - density: "", }; const packet = buildPacket(); - document.getElementById("panel-title").textContent = info.name; - document.getElementById("panel-subtitle").textContent = `${info.scenes} · 正文约 ${packet._chars} 字(不计空白)`; document.getElementById("shell-meta").innerHTML = ` -

${esc(info.name)}${esc(state.shell)}

-

${esc(info.blurb)}

-

长文策略${esc(info.density)} · 当前试读 ${packet._chars}

-

适用:${esc(info.scenes)}。请用上方「正文长度」切到长文,确认侧栏/选项是否仍可用。

+

${esc(info.name)}

+

${esc(info.task)} · 试读 ${packet._chars} 字
${esc(info.blurb)}

`; const preview = document.getElementById("shell-preview"); preview.dataset.shell = state.shell; - preview.innerHTML = renderPresentShellHtml(packet, esc, { tweaks: buildTweaks() }); + preview.innerHTML = renderPresentShellHtml(packet, esc, { + tweaks: buildTweaks(), + }); document.querySelectorAll("[data-shell]").forEach((btn) => { btn.classList.toggle("active", btn.getAttribute("data-shell") === state.shell); + btn.setAttribute("aria-selected", btn.getAttribute("data-shell") === state.shell ? "true" : "false"); }); document.querySelectorAll("[data-tone]").forEach((btn) => { btn.classList.toggle("active", btn.getAttribute("data-tone") === state.tone); @@ -289,8 +277,9 @@ function mount() { const list = document.getElementById("shell-list"); list.innerHTML = PRESENT_SHELL_IDS.map((id) => { const info = SHELL_INFO[id]; - return ``; }).join(""); @@ -307,15 +296,15 @@ function mount() { document.getElementById("tweak-controls").innerHTML = ` `; diff --git a/web/styles.css b/web/styles.css index bc4fbe4..ffba014 100644 --- a/web/styles.css +++ b/web/styles.css @@ -11,10 +11,15 @@ --ok: #16a34a; --warn: #ca8a04; --danger: #dc2626; - --books-w: 200px; + --books-w: 216px; --books-w-collapsed: 52px; + --coord-w: clamp(280px, 24vw, 340px); + --coord-w-collapsed: 40px; + --workspace-pad-x: clamp(24px, 4.5vw, 72px); + --workspace-pad-y: clamp(20px, 3vh, 40px); --head-h: 48px; - --composer-min: 72px; + --composer-min: 56px; + --composer-control-h: 40px; --font-ui: "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI", system-ui, sans-serif; --font-read: "Noto Serif SC", "Songti SC", "STSong", "Source Han Serif SC", Georgia, serif; font-family: var(--font-ui); @@ -53,6 +58,7 @@ a.link:hover { text-decoration: underline; } display: flex; flex-direction: column; min-height: 0; + min-width: 0; overflow: hidden; transition: width 0.15s ease; } @@ -63,46 +69,97 @@ a.link:hover { text-decoration: underline; } flex-shrink: 0; } .rail-head { - gap: 6px; - padding: 8px 8px; - flex-wrap: wrap; + gap: 4px; + padding: 6px; + flex-wrap: nowrap; + align-items: center; + min-width: 0; } .rail-toggle { flex-shrink: 0; width: 28px; - padding: 4px 0; - font-size: 14px; + height: 28px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; line-height: 1; } body.rail-collapsed .rail-toggle { transform: rotate(180deg); } .rail-tabs { display: flex; - flex: 1; + flex: 1 1 auto; gap: 2px; min-width: 0; } .rail-tab { - flex: 1; + flex: 1 1 0; display: inline-flex; align-items: center; justify-content: center; - gap: 4px; + gap: 0; + min-width: 0; + height: 28px; border: none; background: transparent; border-radius: 6px; - padding: 5px 6px; + padding: 0 6px; cursor: pointer; color: var(--muted); font-size: 12px; font-weight: 600; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; } .rail-tab:hover { background: var(--bg); color: var(--text); } .rail-tab.active { background: var(--accent-soft); color: var(--accent); } -.rail-tab-icon { font-size: 12px; } -.rail-head-actions { display: flex; align-items: center; gap: 4px; } +.rail-tab-icon { + display: none; + font-size: 13px; + line-height: 1; + flex-shrink: 0; +} +.rail-tab-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.rail-head-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + min-width: 0; +} +.rail-head-actions #btn-new-book { + width: 28px; + height: 28px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + line-height: 1; + flex-shrink: 0; +} +.burst-badge { + max-width: 52px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; + font-variant-numeric: tabular-nums; + padding: 2px 5px; + border-radius: 4px; + background: var(--accent-soft); + color: var(--accent); + line-height: 1.2; +} .rail-panels { flex: 1; min-height: 0; @@ -123,6 +180,7 @@ body.rail-collapsed .rail-tab-label, body.rail-collapsed .path-bar, body.rail-collapsed .rail-head-actions #btn-new-book, body.rail-collapsed .burst-badge { display: none !important; } +body.rail-collapsed .rail-tab-icon { display: block; } body.rail-collapsed .rail-head { flex-direction: column; align-items: stretch; @@ -133,8 +191,10 @@ body.rail-collapsed .rail-tabs { flex-direction: column; } body.rail-collapsed .rail-tab { - flex-direction: column; - padding: 8px 2px; + flex: 0 0 auto; + width: 100%; + height: 32px; + padding: 0; } body.rail-collapsed .rail-panels { display: none; } body.rail-collapsed .panel-rail { align-items: stretch; } @@ -163,17 +223,694 @@ body.rail-collapsed .panel-rail { align-items: stretch; } .main-body { display: grid; - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); min-height: 0; overflow: hidden; + position: relative; +} +body.design-workspace .main-body { + grid-template-columns: minmax(0, 1fr) var(--coord-w, 240px); +} +body.design-workspace.coord-rail-collapsed .main-body { + grid-template-columns: minmax(0, 1fr) var(--coord-w-collapsed, 40px); } .panel-feed { display: flex; flex-direction: column; min-height: 0; + min-width: 0; overflow: hidden; } +/* 验收态:协调流收窄,产物占主工作面 */ +.workspace-stage { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 10px 14px 12px; + background: var(--bg); + border-top: 1px solid var(--border); +} +.workspace-stage[hidden] { display: none !important; } +.workspace-review { + max-width: none; + width: 100%; + margin: 0; + padding: 12px var(--workspace-pad-x, 28px) 16px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + cursor: context-menu; + box-sizing: border-box; +} +.workspace-review-head { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border); +} +.workspace-review-titles { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; + min-width: 0; +} +.workspace-review-nav { + flex-shrink: 0; +} +.workspace-review-nav .msg-variant-nav { + margin: 0; +} +.workspace-review-kicker { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--accent); + text-transform: none; +} +.workspace-review-title { + margin: 0; + font-size: 18px; + font-weight: 650; + font-family: var(--font-read); + line-height: 1.3; + min-width: 0; +} +.workspace-review-summary { + margin: 0; + font-size: 13px; + color: var(--text); + line-height: 1.45; +} +.workspace-review-hint { + margin: 0; + font-size: 12px; + color: var(--muted); + line-height: 1.4; +} +.workspace-review-body { margin-top: 4px; } +.workspace-review .review-worker-set { margin: 0 0 10px; } +.workspace-raw-details { + margin-top: 16px; + border-top: 1px dashed var(--border); + padding-top: 8px; +} +.workspace-raw-details > summary { + cursor: pointer; + font-size: 12px; + color: var(--muted); + user-select: none; + padding: 4px 0; +} +.workspace-raw-body { margin-top: 8px; } + +/* 创作伪 0 层:右侧协调栏(横向折叠,不占主柱高度) */ +.coord-rail { + display: none; + min-width: 0; + min-height: 0; + border-left: 1px solid var(--border); + background: var(--bg); + overflow: hidden; +} +.coord-rail[hidden] { display: none !important; } +body.design-workspace .coord-rail:not([hidden]) { + display: flex; + flex-direction: row; + align-items: stretch; +} +.coord-rail-toggle { + flex: 0 0 var(--coord-w-collapsed, 40px); + width: var(--coord-w-collapsed, 40px); + border: none; + border-right: 1px solid transparent; + background: var(--surface); + color: var(--muted); + cursor: pointer; + padding: 12px 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + font: inherit; + font-size: 12px; + font-weight: 600; + line-height: 1.2; +} +.coord-rail-toggle:hover { + color: var(--accent); + background: var(--accent-soft); +} +.coord-rail-toggle-label { + writing-mode: vertical-rl; + letter-spacing: 0.12em; + user-select: none; +} +.coord-rail .coord-drawer-count { + font-size: 10px; + font-weight: 700; + font-variant-numeric: tabular-nums; + min-width: 1.4em; + padding: 2px 5px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + line-height: 1.2; +} +.coord-rail .coord-drawer-count:empty { display: none; } +.coord-rail-panel { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--surface); + overflow: hidden; +} +body.coord-rail-collapsed .coord-rail-panel { + display: none; +} +body.coord-rail-collapsed .coord-rail-toggle { + border-right: none; + width: 100%; + flex-basis: auto; +} +.coord-rail-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.coord-rail-title { + font-size: 12px; + font-weight: 700; + color: var(--text); +} +.coord-rail-collapse { + width: 28px; + height: 28px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + line-height: 1; +} +body:not(.coord-rail-collapsed) .coord-rail-toggle { + display: none; +} +.coord-drawer-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} +.coord-empty { + margin: 16px 8px; + font-size: 12px; + color: var(--muted); + text-align: center; +} +.coord-line { + position: relative; + display: flex; + flex-direction: column; + gap: 5px; + padding: 8px 32px 8px 8px; + border-radius: 8px; + font-size: 12px; + line-height: 1.35; + min-width: 0; + background: var(--bg); + cursor: context-menu; +} +.coord-line:hover { background: color-mix(in srgb, var(--accent-soft) 45%, var(--bg)); } +.coord-line--trace { + border-left: 2px solid color-mix(in srgb, var(--accent) 55%, var(--border)); + padding-left: 10px; +} +.coord-line-menu { + position: absolute; + top: 5px; + right: 5px; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 16px; + line-height: 20px; + cursor: pointer; + opacity: 0; +} +.coord-line:hover .coord-line-menu, +.coord-line:focus-within .coord-line-menu { + opacity: 1; +} +.coord-line-menu:hover { + background: color-mix(in srgb, var(--border) 65%, transparent); + color: var(--text); +} +.coord-line-menu:focus-visible { + opacity: 1; + outline: 2px solid var(--accent); + outline-offset: 1px; +} +@media (hover: none) { + .coord-line-menu { opacity: 0.75; } +} +.coord-who { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--muted); + font-size: 11px; + flex-wrap: wrap; +} +.coord-who .msg-variant-nav { + margin-left: auto; +} +.coord-user .coord-who { color: var(--accent); } +.coord-kind { + min-width: 0; + padding: 1px 4px; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 9px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.coord-text { + min-width: 0; + color: var(--text); + display: -webkit-box; + -webkit-line-clamp: 7; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: normal; + word-break: break-word; +} +.coord-time { + margin-left: auto; + flex-shrink: 0; + font-size: 10px; + font-weight: 500; + color: var(--muted); +} +.coord-context-trigger { + display: flex; + align-items: center; + gap: 6px; + width: fit-content; + max-width: 100%; + padding: 3px 6px; + border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border)); + border-radius: 5px; + background: color-mix(in srgb, var(--accent-soft) 45%, var(--bg)); + color: var(--muted); + font: inherit; + font-size: 10px; + line-height: 1.3; + cursor: pointer; +} +.coord-context-trigger:hover { + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + color: var(--text); +} +.coord-context-trigger:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +.coord-context-label { + color: var(--accent); + font-weight: 700; +} +.coord-trace-meta { + display: flex; + flex-wrap: wrap; + gap: 3px 8px; + min-width: 0; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 9px; +} +.coord-trace-meta span { + min-width: 0; + overflow-wrap: anywhere; +} + +.workspace-pending { + margin: 0 0 12px; + padding: 10px 12px; + border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--border)); + border-radius: 10px; + background: color-mix(in srgb, var(--accent-soft) 50%, var(--surface)); +} +body.design-workspace .workspace-pending { + margin: var(--workspace-pad-y, 16px) var(--workspace-pad-x, 24px); +} +.workspace-pending-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + font-size: 12px; +} + +body.is-reviewing .message-feed { + flex: 0 1 auto; + max-height: min(28vh, 220px); + min-height: 0; + padding: 8px 12px; + gap: 6px; +} +body.is-reviewing .message-feed:empty { + display: none; +} +body.is-reviewing .workspace-stage { + flex: 1 1 auto; +} +body.is-reviewing .msg-bubble { + width: min(100%, 92%); + max-width: 92%; +} + +/* 创作伪 0 层:产物铺满主柱,对话退居右侧栏 */ +body.design-workspace .message-feed { + display: none !important; +} +body.design-workspace .workspace-stage { + flex: 1 1 auto; + border-top: none; + padding: 0; + background: var(--surface); + display: flex; + flex-direction: column; + min-height: 0; +} +body.design-workspace[data-design-surface="answer"] .workspace-stage { + overflow: hidden; /* 滚动只在询问卡内,避免 stage↔card 双滚 */ +} +body.design-workspace .workspace-review { + max-width: none; + margin: 0; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + border: none; + border-radius: 0; + box-shadow: none; + padding: 16px var(--workspace-pad-x, 24px) 16px; + box-sizing: border-box; + width: 100%; +} +body.design-workspace .workspace-review-head { + border-bottom-color: color-mix(in srgb, var(--border) 80%, transparent); + margin-bottom: 10px; + padding-bottom: 8px; + max-width: none; +} +body.design-workspace .workspace-review-head--slim { + margin-bottom: 8px; + padding-bottom: 6px; +} +body.design-workspace .workspace-review-title { + font-size: 16px; + font-weight: 650; + font-family: var(--font-ui); +} +body.design-workspace .workspace-review-body, +body.design-workspace .review-worker-set { + max-width: none; + width: 100%; +} + +.workspace-surface-kicker { + display: inline-block; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--accent); + margin-bottom: 8px; +} +.workspace-intent, +.workspace-answer { + flex: 1 1 auto; + min-height: 0; + width: 100%; + max-width: none; + margin: 0; + padding: var(--workspace-pad-y, 28px) var(--workspace-pad-x, 28px) 48px; + box-sizing: border-box; + display: flex; + flex-direction: column; +} +body.design-workspace[data-design-surface="answer"] .workspace-answer { + padding: 16px var(--workspace-pad-x, 24px) 20px; +} +.workspace-intent-title { + margin: 0 0 10px; + font-size: clamp(24px, 3vw, 34px); + font-weight: 650; + font-family: var(--font-read); + line-height: 1.25; + color: var(--text); + max-width: 40rem; + text-wrap: balance; +} +.workspace-intent-hint { + margin: 0; + font-size: clamp(14px, 1.15vw, 17px); + line-height: 1.55; + color: var(--muted); + max-width: 46rem; +} +.workspace-intent-meta { + margin: 14px 0 0; + font-size: 12px; + color: var(--muted); +} +.workspace-opening-guide { + margin-top: 22px; + max-width: 40rem; + padding: 0; + font-size: clamp(14px, 1.1vw, 16px); + line-height: 1.65; + color: var(--text); + white-space: pre-wrap; + font-family: var(--font-read); +} +.workspace-intent .intake-panel { + margin-top: 20px; + max-width: 52rem; +} +.workspace-answer-head { + flex-shrink: 0; + margin-bottom: 14px; + max-width: 46rem; +} +.workspace-answer-head--slim { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 10px; + max-width: none; +} +.workspace-answer-head--slim .workspace-surface-kicker { + margin-bottom: 0; +} +.workspace-answer-hint { + margin: 0; + font-size: 12px; + color: var(--muted); + line-height: 1.4; +} +body.design-workspace[data-design-surface="answer"] .questions-card-host { + flex: 0 1 auto; + border-top: none; + margin-top: 0; + padding: 0; +} +body.design-workspace[data-design-surface="speak"] .workspace-surface-kicker { + color: var(--accent); +} +body.design-workspace[data-design-surface="answer"] .workspace-surface-kicker { + color: #a16207; +} +body.design-workspace[data-design-surface="review"] .workspace-review-kicker { + color: #15803d; +} +body.design-workspace[data-design-surface="review"][data-review-kind="flow"] .workspace-review-kicker, +body.design-workspace[data-design-surface="review"][data-review-kind="opening"] .workspace-review-kicker { + color: var(--accent); +} + +/* 挂载追问嵌在产物验收面下方,与产物同一工作面 */ +.workspace-review-ask { + flex: 0 0 auto; + margin-top: 0; + min-height: 0; + max-width: var(--workspace-max, 920px); + width: 100%; + align-self: center; + padding: 0 var(--workspace-pad-x, 24px) var(--workspace-pad-y, 16px); + box-sizing: border-box; +} +.workspace-review-ask .questions-card-host { + padding-left: 0; + padding-right: 0; + border-top: none; +} +.workspace-review-ask .questions-card-host.is-open:not([hidden]) { + margin-top: 0; +} + +body.design-workspace .ws-creation-flow h4 { display: none; } +body.design-workspace .flow-brief { + margin: 0 0 12px; + font-size: 15px; + line-height: 1.55; + font-family: var(--font-read); +} +body.design-workspace .flow-status { + margin: 0 0 14px; + font-size: 12px; + color: var(--muted); +} +body.design-workspace .flow-steps { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 10px; +} +body.design-workspace .flow-step { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: 4px 10px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + align-items: start; + min-width: 0; +} +body.design-workspace .flow-order { + grid-row: 1 / -1; + width: 28px; + height: 28px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--surface-2, rgba(0, 0, 0, 0.04)); + color: var(--muted); + font-weight: 700; + font-size: 13px; +} +body.design-workspace .flow-run { + grid-column: 2; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.04em; + color: var(--muted); + line-height: 1.2; +} +body.design-workspace .flow-name { + font-weight: 600; + font-size: 14px; + line-height: 1.35; + min-width: 0; +} +body.design-workspace .flow-deps, +body.design-workspace .flow-params, +body.design-workspace .flow-params-missing, +body.design-workspace .flow-occ { + grid-column: 2; + font-size: 12px; + color: var(--muted); + line-height: 1.35; +} +body.design-workspace .flow-step[data-run="pending"] { + background: var(--bg); + border-color: var(--border); +} +body.design-workspace .flow-step[data-run="pending"] .flow-name { + color: var(--muted); + font-weight: 500; +} +body.design-workspace .flow-step[data-run="current"] { + background: var(--surface); + border-color: var(--accent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent); +} +body.design-workspace .flow-step[data-run="current"] .flow-order { + background: var(--accent); + color: #fff; +} +body.design-workspace .flow-step[data-run="current"] .flow-run { + color: var(--accent); +} +body.design-workspace .flow-step[data-run="done"] { + background: var(--bg); + border-color: color-mix(in srgb, var(--border) 70%, transparent); +} +body.design-workspace .flow-step[data-run="done"] .flow-order { + background: color-mix(in srgb, #15803d 16%, var(--bg)); + color: #15803d; +} +body.design-workspace .flow-step[data-run="done"] .flow-run { + color: #15803d; +} +body.design-workspace .flow-step[data-run="done"] .flow-name { + color: var(--text); + font-weight: 500; +} + +@media (max-width: 900px) { + body.design-workspace .main-body { + grid-template-columns: minmax(0, 1fr); + } + body.design-workspace .coord-rail:not([hidden]) { + position: absolute; + right: 0; + top: 0; + bottom: 0; + z-index: 30; + width: min(280px, 78vw); + box-shadow: -8px 0 24px rgba(0, 0, 0, 0.08); + } + body.design-workspace.coord-rail-collapsed .coord-rail:not([hidden]) { + width: var(--coord-w-collapsed, 40px); + box-shadow: none; + } + body.design-workspace .panel-feed { + position: relative; + } +} + .btn, .btn-sm { border: 1px solid var(--border); background: var(--surface); @@ -195,27 +932,108 @@ body.rail-collapsed .panel-rail { align-items: stretch; } .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 */ +/* Books — pill 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; + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 0; + margin: 8px 8px 0; + padding: 7px 10px; + border: none; + background: var(--bg); + border-radius: 10px; + overflow-x: auto; + font-size: 12px; + line-height: 1.35; + flex-shrink: 0; + min-width: 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-bar-list { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 0; + margin: 0; + padding: 0; + list-style: none; + min-width: 0; + max-width: 100%; +} +.path-bar-item { + display: inline-flex; + align-items: center; + min-width: 0; + max-width: 100%; + flex-shrink: 0; +} +.path-bar-item-current { + min-width: 0; + flex: 1 1 auto; + max-width: 100%; +} +.path-seg { + display: inline-flex; + align-items: center; + gap: 5px; + border: none; + background: transparent; + padding: 0; + border-radius: 4px; + color: var(--text); + cursor: pointer; + white-space: nowrap; + font-size: 12px; + font-weight: 500; + max-width: 9em; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.path-seg:hover { color: var(--accent); } +.path-seg:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); + outline-offset: 2px; +} +.path-seg-root { + max-width: none; + color: var(--text); +} +.path-home-icon { + display: block; + flex-shrink: 0; + color: var(--muted); +} +.path-seg-root:hover .path-home-icon { color: var(--accent); } +.path-seg.current, +.path-seg[aria-current="page"] { + font-weight: 600; + cursor: default; + color: var(--text); + max-width: none; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.path-seg.current:hover, +.path-seg[aria-current="page"]:hover { color: var(--text); } +.path-sep { + color: var(--muted); + opacity: 0.65; + padding: 0 6px; + user-select: none; + flex-shrink: 0; + font-weight: 400; + font-size: 13px; + line-height: 1; } -.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; + flex: 1; overflow-y: auto; padding: 4px 6px 8px; font-size: 12px; line-height: 1.35; position: relative; + min-width: 0; } .book-box-overlay { position: fixed; z-index: 160; pointer-events: none; @@ -224,9 +1042,21 @@ body.rail-collapsed .panel-rail { align-items: stretch; } } .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; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + column-gap: 6px; + row-gap: 2px; + width: 100%; + min-height: 32px; + padding: 7px 8px; + border: none; + background: transparent; + cursor: pointer; + text-align: left; + color: var(--text); + border-radius: 6px; + min-width: 0; } .explorer-row:hover { background: var(--bg); } .explorer-row.active { background: var(--accent-soft); } @@ -234,26 +1064,62 @@ body.rail-collapsed .panel-rail { align-items: stretch; } .explorer-row.selected { background: #dbeafe; } .explorer-row.selected .explorer-name { color: #1d4ed8; font-weight: 600; } .explorer-row.selected.active { background: #bfdbfe; } +.explorer-main { + grid-column: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} .explorer-name { - flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + line-height: 1.35; } .explorer-meta { - flex-shrink: 0; max-width: 46%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 10px; color: var(--muted); + display: block; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; + line-height: 1.3; + 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; + grid-column: 2; + grid-row: 1 / span 2; + align-self: center; + flex-shrink: 0; + width: 22px; + height: 22px; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + padding: 0; + font-size: 14px; + line-height: 1; + opacity: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; } .explorer-row:hover .explorer-del { opacity: 1; } -.explorer-del:hover { color: var(--danger); } +.explorer-del:hover { color: var(--danger); background: #fef2f2; } .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; + color: var(--muted); letter-spacing: 0.04em; } .explorer-hint { - margin: 4px 8px; font-size: 11px; color: var(--muted); + margin: 4px 8px; font-size: 11px; color: var(--muted); line-height: 1.4; } .sidebar-empty { padding: 12px; color: var(--muted); font-size: 13px; text-align: center; } @@ -296,7 +1162,6 @@ body[data-lifecycle="play"] .panel-rail { background: #f7fef9; } gap: 10px; background: var(--bg); } -body.is-reviewing .message-feed, body.rail-collapsed .message-feed { padding: 6px 10px; gap: 6px; @@ -390,16 +1255,55 @@ body.rail-collapsed .message-feed { align-items: center; gap: 2px; margin-right: 2px; + vertical-align: middle; +} +.msg-variant-nav .msg-action { + min-width: 22px; + padding: 2px 5px; + font-size: 13px; + line-height: 1.2; } .msg-variant-count { - min-width: 2.2em; + min-width: 2.4em; text-align: center; color: var(--warn); font-size: 11px; font-weight: 600; + font-variant-numeric: tabular-nums; } .msg-time { color: var(--muted); flex-shrink: 0; font-size: 10px; } .msg-tag { font-weight: 600; color: var(--text); } +.msg-menu-trigger { + width: 24px; + height: 24px; + padding: 0; + flex: 0 0 auto; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 16px; + line-height: 20px; + cursor: pointer; + opacity: 0; +} +.msg:hover .msg-menu-trigger, +.msg:focus-within .msg-menu-trigger { + opacity: 1; +} +.msg-menu-trigger:hover { + background: color-mix(in srgb, var(--border) 65%, transparent); + color: var(--text); +} +.msg-menu-trigger:focus-visible { + opacity: 1; + outline: 2px solid var(--accent); + outline-offset: 1px; +} +@media (hover: none) { + .msg-menu-trigger { opacity: 0.75; } +} .msg-body { white-space: pre-wrap; word-break: break-word; @@ -557,7 +1461,7 @@ body.rail-collapsed .message-feed { margin: 0 0 8px; } -/* 主对话验收卡:吃满中间栏 */ +/* 主对话验收卡(旧):保留选择器以免历史类名失效 */ .msg-review { align-self: stretch; width: 100%; @@ -594,7 +1498,98 @@ body.rail-collapsed .message-feed { .msg-review .ws-focus-hint { margin-bottom: 0; font-size: 11px; } .msg-review .review-worker-set { margin: 0 0 6px; } -/* 产物紧凑卡:横向主轴,预览一行 */ +/* 顶栏「更多」:冷入口收纳 */ +.more-menu { + position: relative; + display: inline-block; +} +.more-menu-sum { + list-style: none; + cursor: pointer; + user-select: none; +} +.more-menu-sum::-webkit-details-marker { display: none; } +.more-menu-sum::marker { content: ""; } +.more-menu[open] .more-menu-sum { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} +.more-menu-panel { + position: absolute; + right: 0; + top: calc(100% + 4px); + z-index: 40; + min-width: 132px; + padding: 4px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + gap: 1px; +} +.more-menu-item { + display: block; + padding: 7px 10px; + border-radius: 6px; + color: var(--text); + text-decoration: none; + font-size: 13px; +} +.more-menu-item:hover { + background: var(--bg); + color: var(--accent); +} + +/* 提问索引气泡:决策在下方询问卡 */ +.msg-bubble-index { + width: auto; + max-width: 70%; + background: color-mix(in srgb, var(--accent-soft) 55%, var(--surface)); + border-color: color-mix(in srgb, var(--accent) 28%, var(--border)); +} +.msg-q-index { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--accent); + line-height: 1.4; +} +.msg-questions-banner { + padding: 6px 12px 0; + font-size: 11px; + font-weight: 600; + color: var(--warn); +} +.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; } + +/* 游玩:阅读柱略抬权重 */ +body[data-lifecycle="play"] .message-feed { + padding: 14px 18px 18px; + gap: 14px; +} +body[data-lifecycle="play"] .msg-assistant-row .msg-bubble { + width: min(100%, 44rem); + max-width: 100%; + border-color: color-mix(in srgb, var(--border) 70%, transparent); + box-shadow: none; +} +body[data-lifecycle="play"] .msg-assistant-row .msg-head { + padding-top: 6px; +} +body[data-lifecycle="play"] .present-shell { + border-radius: 10px; +} +body[data-lifecycle="play"] .msg-user-row .msg-bubble { + width: min(100%, 28rem); + max-width: 90%; +} + +/* 产物紧凑卡:横向主轴,预览一行 */ .artifact-compact-list { display: flex; flex-direction: column; @@ -606,6 +1601,54 @@ body.rail-collapsed .message-feed { background: var(--surface); overflow: hidden; } + +/* 验收扁平:一条 meta,正文直接读,不套 details/预览摘要 */ +.artifact-flat-review { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} +.artifact-flat-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} +.artifact-flat-meta > .artifact-raw--quiet { + margin-left: auto; +} +.artifact-flat-path .artifact-crumb-root { + font-size: 13px; + font-weight: 700; + color: var(--text); +} +.artifact-flat-title { + font-size: 13px; + font-weight: 700; + color: var(--text); + margin-right: 4px; +} +.artifact-flat-chips { + display: flex; + flex-wrap: wrap; + gap: 5px; + align-items: center; +} +.artifact-flat-chips .ws-badge, +.artifact-flat-chips .ws-slot-chip, +.artifact-flat-chips .pct-pill { + font-size: 11px; + padding: 1px 7px; +} +.artifact-flat-body { + min-width: 0; +} +body.design-workspace .artifact-flat-review { + max-width: none; +} .artifact-compact-sum { list-style: none; cursor: pointer; @@ -707,16 +1750,226 @@ body.rail-collapsed .message-feed { } .ws-focus-body .artifact-compact-list { margin: 0; } -/* 展开区:mosaic / 瓷砖网格,吃满横向宽度 */ +/* 展开区:mosaic / 瓷砖网格 */ .af-mosaic { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; } body.is-reviewing .af-mosaic, -body.rail-collapsed .af-mosaic { - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +body.rail-collapsed .af-mosaic, +body.design-workspace .af-mosaic { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); } + +/* 美学验收:左契约 / 右诊断(整体样式约定) */ +.af-split { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + min-width: 0; +} +.af-split-banner { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px 14px; + font-size: 12px; + color: var(--muted); + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} +.af-split-banner b { + color: var(--text); + font-weight: 700; + margin-right: 2px; +} +.af-split-banner-note { + margin-left: auto; + font-size: 11px; + opacity: 0.85; +} +.af-split-grid { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(220px, 0.9fr); + gap: 18px 22px; + align-items: start; +} +.af-split-product, +.af-split-eval { + min-width: 0; + display: flex; + flex-direction: column; + gap: 16px; +} +.af-split-eval { + position: sticky; + top: 8px; + max-height: calc(100dvh - 7rem); + overflow-y: auto; + padding: 10px 12px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); +} +.af-block-head { + margin-bottom: 10px; +} +.af-block-title { + margin: 0 0 4px; + font-family: var(--font-ui); + font-size: 15px; + font-weight: 700; + color: var(--text); + letter-spacing: 0.01em; + display: flex; + align-items: center; + gap: 8px; +} +.af-block-miss { + font-size: 11px; + font-weight: 700; + color: var(--danger); + padding: 1px 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--danger) 12%, var(--surface)); +} +.af-block-hint { + margin: 0; + font-size: 12px; + color: var(--muted); + line-height: 1.4; +} +.af-block-body { + min-width: 0; +} +.af-block--product .af-block-body .artifact-kernel .artifact-prose, +.af-block--product .af-block-body .artifact-prose { + font-family: var(--font-read); + font-size: 15.5px; + line-height: 1.75; + max-width: 42rem; +} +.af-block--eval .af-block-title { + font-size: 13px; +} +.af-block--extra .af-block-title { + font-size: 12px; + color: var(--muted); +} +@media (max-width: 900px) { + .af-split-grid { + grid-template-columns: 1fr; + } + .af-split-eval { + position: static; + max-height: none; + } + .af-split-banner-note { + margin-left: 0; + width: 100%; + } +} + +/* 旧布局类名保留,避免残留引用崩样式 */ +.af-review-stack, +.af-review-layout { + display: flex; + flex-direction: column; + gap: 18px; + width: 100%; +} +.af-review-primary { + min-width: 0; + padding: 0; +} +.af-review-primary-body { + font-size: 15px; + line-height: 1.7; +} +.af-review-primary-body .artifact-kernel.qcard-assessment, +.af-review-primary-body .artifact-kernel { + margin: 0 0 12px; + padding: 0; + border: none; + background: transparent; +} +.af-review-primary-body .artifact-aside-label { + margin-bottom: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--muted); +} +.af-review-primary-body .artifact-kernel .artifact-prose, +.af-review-primary-body .artifact-kernel .artifact-inline, +.af-review-primary-body .artifact-prose { + font-family: var(--font-read); + font-size: 16px; + line-height: 1.75; + max-width: 42rem; +} +.af-review-primary-body .af-tile-grid { + margin-top: 12px; + grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); +} +.af-review-rail { + min-width: 0; + position: sticky; + top: 8px; + max-height: calc(100dvh - 8rem); + overflow-y: auto; + padding: 10px 12px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); +} +.af-review-rail-label { + margin: 0 0 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} +.af-support-block { + margin: 0 0 12px; + padding: 0; + border: none; + background: transparent; +} +.af-support-block:last-child { + margin-bottom: 0; +} +.af-support-block > h4 { + margin: 0 0 8px; + font-family: var(--font-ui); + font-size: 12.5px; + font-weight: 700; + color: var(--text); + letter-spacing: 0.02em; +} +.af-support-block .af-dim-stack { + gap: 10px; +} +.af-support-block .af-tile-grid { + grid-template-columns: 1fr; +} +.af-support-block .af-tile-text { + -webkit-line-clamp: 8; + font-size: 12px; +} +@media (max-width: 900px) { + .af-review-layout { + grid-template-columns: 1fr; + } + .af-review-rail { + position: static; + max-height: none; + } +} + .af-panel { padding: 6px 8px; border: 1px solid var(--border); @@ -802,12 +2055,115 @@ body.rail-collapsed .af-mosaic { } .af-group-title { font-family: var(--font-ui); - font-size: 10.5px; + font-size: 12px; font-weight: 700; - color: var(--muted); - margin-bottom: 4px; - letter-spacing: 0.05em; + color: var(--text); + margin-bottom: 6px; + letter-spacing: 0.02em; } +.af-section { + margin: 0 0 14px; + padding: 0; + min-width: 0; +} +.af-section:last-child { + margin-bottom: 0; +} +.af-section-title { + margin: 0 0 8px; + font-family: var(--font-ui); + font-size: 13px; + font-weight: 700; + color: var(--text); + letter-spacing: 0.01em; + padding-bottom: 4px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 80%, transparent); +} +.af-dim-stack { + display: flex; + flex-direction: column; + gap: 14px; +} +.af-dim { + min-width: 0; + padding: 0 0 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent); +} +.af-dim:last-child { + border-bottom: none; + padding-bottom: 0; +} +.af-dim-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 10px; + margin-bottom: 8px; +} +.af-dim-title { + margin: 0; + font-family: var(--font-ui); + font-size: 14px; + font-weight: 700; + color: var(--text); + letter-spacing: 0.01em; + line-height: 1.3; +} +.af-dim-head .pct-inline-value { + font-size: 13px; +} +.af-dim-body { + display: flex; + flex-direction: column; + gap: 5px; + padding-left: 2px; +} +.af-dim-row { + display: grid; + grid-template-columns: minmax(2.75rem, 3.25rem) minmax(0, 1fr); + gap: 2px 10px; + align-items: start; + font-size: 12.5px; + line-height: 1.5; +} +.af-dim-row--block { + grid-template-columns: 1fr; +} +.af-dim-k { + font-size: 11px; + font-weight: 500; + color: var(--muted); + padding-top: 2px; +} +.af-dim-v { + min-width: 0; + font-size: 13px; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; +} + +/* 行内完备度 / 评分 */ +.pct-inline { + display: inline-flex; + align-items: baseline; + gap: 4px; + font-variant-numeric: tabular-nums; + line-height: 1.2; +} +.pct-inline-label { + font-size: 11px; + font-weight: 600; + color: var(--muted); +} +.pct-inline-value { + font-size: 13px; + font-weight: 700; +} +.pct-inline.tone-low .pct-inline-value { color: var(--danger); } +.pct-inline.tone-mid .pct-inline-value { color: var(--warn); } +.pct-inline.tone-high .pct-inline-value { color: var(--ok); } + .af-panel-wide .af-stack, .af-panel-wide .artifact-kv { width: 100%; @@ -1050,10 +2406,12 @@ body.rail-collapsed .af-mosaic { .review-feed-actions .btn { flex: 1; } -.msg-review .ws-section h4 { +.msg-review .ws-section h4, +.workspace-review .ws-section h4 { font-size: 12px; } -.msg-review .ws-user-view { +.msg-review .ws-user-view, +.workspace-review .ws-user-view { gap: 8px; } @@ -1067,6 +2425,79 @@ body.rail-collapsed .af-mosaic { color: var(--muted, #666); font-size: 12px; } +.propose-step { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + padding: 4px 0 0; +} +.propose-step-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; +} +.propose-kicker { + font-size: 11px; + font-weight: 650; + letter-spacing: 0.04em; + color: var(--accent); + text-transform: none; +} +.propose-name { + font-size: 15px; + font-weight: 650; +} +.propose-output { + margin: 0; + font-size: 13px; + color: var(--text); + line-height: 1.45; +} +.propose-sync-hint { + margin: 0; + font-size: 12px; + color: var(--muted); + line-height: 1.4; +} +.propose-field.propose-field-id input { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.01em; +} +.propose-field.is-stale-id input { + border-color: color-mix(in srgb, var(--danger) 55%, var(--border)); +} +.propose-fields { + display: grid; + gap: 8px; +} +.propose-field { + display: grid; + gap: 4px; + font-size: 12px; +} +.propose-field-label { + color: var(--muted); + display: flex; + gap: 6px; + align-items: center; +} +.propose-req { + font-size: 10px; + color: var(--danger); +} +.propose-field.is-missing input { + border-color: color-mix(in srgb, var(--danger) 55%, var(--border)); +} +.propose-field input { + height: var(--composer-control-h, 40px); + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text); +} .flow-steps { list-style: none; margin: 0; @@ -1085,6 +2516,26 @@ body.rail-collapsed .af-mosaic { background: var(--surface-2, rgba(0, 0, 0, 0.04)); border-radius: 6px; font-size: 13px; + border: 1px solid transparent; +} +.flow-step[data-run="current"] { + background: var(--surface); + border-color: var(--accent); +} +.flow-step[data-run="done"] { + opacity: 0.78; +} +.flow-run { + grid-column: 2; + font-size: 11px; + font-weight: 650; + color: var(--muted, #666); +} +.flow-step[data-run="current"] .flow-run { + color: var(--accent); +} +.flow-step[data-run="done"] .flow-run { + color: #15803d; } .flow-occ, .flow-id { @@ -1094,7 +2545,7 @@ body.rail-collapsed .af-mosaic { font-weight: 400; } .flow-order { - grid-row: 1 / span 2; + grid-row: 1 / -1; align-self: center; font-weight: 600; color: var(--muted, #666); @@ -1228,24 +2679,16 @@ body.rail-collapsed .af-mosaic { .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 { +.msg-assistant-row.msg.questions .msg-bubble:not(.msg-bubble-index) { 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 式询问卡:贴在消息流下方,占满协调列宽度,不遮盖历史 */ +/* 询问卡:host 本身即卡片(不再套内层 .qcard) */ .questions-card-host { + --qcard-max: 48vh; + --qcard-max-expanded: 82vh; flex: 0 1 auto; width: 100%; box-sizing: border-box; @@ -1253,30 +2696,58 @@ body.rail-collapsed .af-mosaic { border-top: 1px solid var(--border); background: var(--surface, #fff); min-height: 0; + height: fit-content; } .questions-card-host[hidden] { display: none !important; } -/* 开启时占 feed 剩余高度的上限,不把整页撑出视口;勿与 [hidden] 并存抢 display */ .questions-card-host.is-open:not([hidden]) { - 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%; + max-height: var(--qcard-max); + overflow: hidden; margin: 6px 0 0; + padding: 0; border: 1px solid var(--border); border-radius: 10px; background: var(--surface, #fff); - overflow: hidden; + transition: max-height 0.22s ease; } -/* 悬浮控件层:不占文档流高度 */ +.questions-card-host.is-open.is-expanded:not([hidden]) { + max-height: var(--qcard-max-expanded); +} +.qcard-top { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + min-height: 18px; + margin: 0; + padding: 4px 12px 0; + border: none; + background: transparent; + cursor: pointer; + color: var(--muted); + user-select: none; + width: 100%; + box-sizing: border-box; +} +.qcard-top-grip { + width: 28px; + height: 3px; + border-radius: 999px; + background: color-mix(in srgb, var(--muted, #888) 35%, transparent); +} +.questions-card-host.is-expanded .qcard-top { + min-height: 22px; + padding-top: 6px; +} +.questions-card-host.is-expanded .qcard-top:hover .qcard-top-grip { + background: color-mix(in srgb, var(--muted, #888) 55%, transparent); +} +.questions-card-host:not(.is-expanded) .qcard-body { + cursor: pointer; +} +/* 悬浮控件层:不占文档流高度 */ .qcard-float { position: absolute; inset: 0; @@ -1349,10 +2820,11 @@ body.rail-collapsed .af-mosaic { flex: 1 1 auto; min-height: 0; padding: 10px 12px; - overflow: auto; /* 仅卡内滚动;页面本身不滚 */ + overflow: auto; } -.qcard.has-pager .qcard-body { padding-top: 36px; } -.qcard.has-skip .qcard-body { padding-bottom: 40px; } +.questions-card-host.has-pager .qcard-body { padding-top: 12px; } +.questions-card-host.has-pager .qcard-pager { top: 4px; } +.questions-card-host.has-skip .qcard-body { padding-bottom: 40px; } .qcard-q { margin-bottom: 0; } .qcard-q + .qcard-q { margin-top: 14px; } .qcard-assessment { @@ -1561,6 +3033,63 @@ body.rail-collapsed .af-mosaic { .artifact-kv-row.complex .artifact-kv-key { margin-bottom: 2px; } .artifact-kv-val { min-width: 0; font-size: 13px; line-height: 1.45; } +/* 自适应定义行:够宽并排,不够整组折行——同行规则,不按字数抽签 */ +.af-def-list { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + min-width: 0; +} +.af-def { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 12px; + row-gap: 3px; + min-width: 0; + padding-bottom: 8px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent); +} +.af-def:last-child { + border-bottom: none; + padding-bottom: 0; +} +.af-def-k { + flex: 0 1 auto; + max-width: 100%; + font-size: 11px; + font-weight: 650; + color: var(--muted); + line-height: 1.35; +} +.af-def-v { + flex: 1 1 12rem; + min-width: min(100%, 12rem); + font-size: 13px; + line-height: 1.55; + color: var(--text); + word-break: break-word; +} +.af-def-v .artifact-prose, +.af-def-v .artifact-inline { + margin: 0; + font-size: inherit; + line-height: inherit; + max-width: none; +} +.af-def-list > .af-section { + margin: 4px 0 10px; + padding-top: 4px; + border-bottom: none; +} +.af-def-list > .af-section .af-section-title { + margin-bottom: 8px; +} +.af-split-product .af-def-v { + flex-basis: 14rem; +} + /* 百分比进度条 */ .pct-meter { display: flex; @@ -1698,6 +3227,55 @@ body.rail-collapsed .af-mosaic { } .artifact-raw > summary:hover { color: var(--text); } .artifact-raw .json-pretty { margin-top: 6px; max-height: min(40vh, 360px); } + +/* 指针 tag / 扁平面包屑:一行路径 + 不起眼原文 */ +.artifact-crumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + padding: 4px 0 8px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + margin-bottom: 4px; +} +.artifact-crumb-path { + display: inline-flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 6px; + min-width: 0; + font-size: 12px; + line-height: 1.35; +} +.artifact-crumb-root { + color: var(--muted); + font-weight: 500; +} +.artifact-crumb-sep { + color: var(--muted); + opacity: 0.55; + font-weight: 400; +} +.artifact-crumb-leaf { + color: var(--text); + font-weight: 650; +} +.artifact-raw--quiet { + margin: 0 0 0 auto; + font-size: 11px; +} +.artifact-raw--quiet > summary { + padding: 0; + opacity: 0.65; +} +.artifact-raw--quiet > summary:hover { + opacity: 1; +} +.artifact-raw--quiet .json-pretty { + margin-top: 6px; + max-height: min(28vh, 240px); + font-size: 11px; +} .artifact-body-root > .artifact-kv, .artifact-body-root > .artifact-cards { margin-top: 2px; } @@ -1895,16 +3473,16 @@ body.rail-collapsed .af-mosaic { font-size: 13px; cursor: pointer; border-radius: 4px; } .context-menu-item:hover { background: var(--bg); } +.context-menu-separator-before { border-top: 1px solid var(--border); } .context-menu-danger { color: var(--danger); } .context-menu-danger:hover { background: #fef2f2; } /* Agent panel */ -.agent-focus { padding: 8px 10px; border-bottom: 1px solid var(--border); font-size: 12px; flex-shrink: 0; } -.agent-focus-who { font-weight: 600; font-family: var(--font-ui); } -.agent-focus-action { margin-top: 2px; } -.agent-focus-detail { margin-top: 2px; font-size: 11px; color: var(--muted); } +.agent-focus { padding: 8px 10px; border-bottom: 1px solid var(--border); font-size: 12px; flex-shrink: 0; min-width: 0; } +.agent-focus-who { font-weight: 600; font-family: var(--font-ui); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.agent-focus-action { margin-top: 2px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.35; } +.agent-focus-detail { margin-top: 2px; font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; line-height: 1.35; } #rail-panel-log .tool-trace { max-height: 30%; overflow: auto; } -.burst-badge { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--accent-soft); color: var(--accent); } .tool-trace { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; } .tool-trace-item { padding: 4px 0; border-bottom: 1px dashed var(--border); } .tool-trace-item:last-child { border-bottom: none; } @@ -1913,31 +3491,180 @@ body.rail-collapsed .af-mosaic { .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; + padding: 10px var(--workspace-pad-x, 16px) 12px; min-height: var(--composer-min); - max-height: min(36vh, 280px); - overflow-x: hidden; - overflow-y: auto; - background: var(--surface); + max-height: min(50vh, 420px); + overflow: hidden; + background: var(--bg); flex-shrink: 0; + display: flex; + flex-direction: column; + justify-content: flex-end; +} +/* 唯一描边:输入 + 操作同框 */ +.composer-input-shell { + flex: 1 1 auto; + min-width: 0; + min-height: var(--composer-control-h); + display: flex; + align-items: flex-end; + gap: 8px; + border: 1px solid var(--border); + border-radius: 10px; + padding: 4px 6px 4px 10px; + background: var(--surface); + transition: border-color 0.12s ease, box-shadow 0.12s ease; +} +.composer-input-shell:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); +} +.composer-input-shell[data-task="review"]:focus-within { + border-color: #86efac; + box-shadow: 0 0 0 2px #dcfce7; +} +.composer-input-shell[data-task="review"][data-tone="plan"]:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); +} +.composer-shell-actions { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + padding-bottom: 2px; +} +.composer-mode-chip { + flex-shrink: 0; + align-self: center; + margin: 0; + font-size: 11px; + font-weight: 650; + line-height: 1; + padding: 4px 0; + border: none; + border-radius: 0; + background: transparent; + color: var(--muted); + user-select: none; + white-space: nowrap; +} +.composer-mode-chip[data-task="speak"], +.composer-input-shell[data-task="speak"] .composer-mode-chip { + color: var(--accent); + border: none; + background: transparent; +} +.composer-mode-chip[data-task="answer"], +.composer-input-shell[data-task="answer"] .composer-mode-chip { + color: #a16207; + border: none; + background: transparent; +} +.composer-mode-chip[data-task="review"], +.composer-input-shell[data-task="review"] .composer-mode-chip { + color: #15803d; + border: none; + background: transparent; +} +.composer-mode-chip[data-task="review"][data-tone="plan"], +.composer-input-shell[data-task="review"][data-tone="plan"] .composer-mode-chip { + color: var(--accent); +} +.composer-input-shell textarea { + flex: 1; + min-width: 0; + min-height: 1.5em; + max-height: min(42vh, 360px); + height: auto; + resize: none; + overflow-y: hidden; + border: none; + border-radius: 0; + padding: 8px 4px; + line-height: 1.5; + background: transparent; + box-shadow: none; + field-sizing: content; +} +.composer-input-shell textarea:focus { + outline: none; + border: none; + box-shadow: none; +} +.composer-actions-with-chip { + align-items: center; +} +.composer-actions-with-chip .composer-mode-chip { + margin-top: 0; + margin-right: 2px; } .composer-waiting, .composer-idle { padding: 12px; text-align: center; color: var(--muted); font-size: 13px; } .composer-hint { margin: 0 0 8px; font-size: 12px; color: var(--muted); } -.composer-form { display: flex; gap: 8px; align-items: flex-end; } +.composer-form { + display: flex; + gap: 0; + align-items: flex-end; + min-height: 0; + max-height: 100%; + width: 100%; +} +.composer-form .composer-input-shell { + width: 100%; +} +.composer-shell-actions > .btn, +.composer-shell-actions > .btn-primary, +.composer-shell-actions > .composer-btn, +.composer-shell-actions > .composer-btn-accept, +.composer-form > .btn, +.composer-form > .btn-primary, +.composer-form > .composer-btn, +.composer-form > .composer-btn-accept { + flex-shrink: 0; + box-sizing: border-box; + min-height: calc(var(--composer-control-h) - 8px); + height: calc(var(--composer-control-h) - 8px); + padding: 0 12px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + font-size: 13px; +} +/* 验收态:主按钮承接原「批阅」语义 */ +.composer-btn-review { + background: #15803d; + border-color: #15803d; + color: #fff; +} +.composer-btn-review:hover:not(:disabled) { + background: #166534; + border-color: #166534; +} .composer-form textarea { - 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); + flex: 1; + min-height: var(--composer-control-h); + max-height: min(42vh, 360px); + height: auto; + resize: none; + overflow-y: hidden; + border: none; + border-radius: 0; + padding: 8px 4px; + line-height: 1.5; + background: transparent; + field-sizing: content; } .composer-form textarea:focus { outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-soft); + border: none; + box-shadow: none; } .composer-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; } .composer-actions .btn-primary { flex: 1; } @@ -2001,7 +3728,10 @@ body.rail-collapsed .af-mosaic { body:not(.rail-collapsed) .rail-panels { display: none; } } -/* —— 游玩呈现壳 present.v1 —— */ +/* —— 游玩呈现壳 present.v1 —— + * 主工作面 = body(随 600~3000 字撑高)。 + * 视觉:少盒子、少渐变;层级靠字号/间距/对比,不靠装饰抢注意力。 + */ .present-shell { --present-ink: #1c1917; --present-muted: #78716c; @@ -2016,24 +3746,24 @@ body.rail-collapsed .af-mosaic { flex-direction: column; gap: 0; position: relative; - padding: 14px 16px 16px; + padding: 16px 18px 18px; border: 1px solid var(--present-line); - border-radius: 12px; + border-radius: 8px; background: var(--surface, #fff); color: var(--present-ink); } .present-shell-badge { align-self: flex-start; - margin-bottom: 10px; + margin-bottom: 8px; font-size: 10px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--present-muted); - padding: 2px 8px; - border-radius: 4px; - border: 1px solid var(--present-line); - background: var(--present-soft); + opacity: 0.55; + padding: 0; + border: none; + background: transparent; } .present-stack, .present-board, @@ -2041,7 +3771,7 @@ body.rail-collapsed .af-mosaic { .present-reader { display: flex; flex-direction: column; - gap: 12px; + gap: 14px; min-width: 0; } .present-region { @@ -2168,34 +3898,30 @@ body.rail-collapsed .af-mosaic { line-height: 1.45; font-size: 13px; } -/* —— 各壳布局 —— */ +/* —— 各壳布局(结构差在同时可见关系;不靠渐变区分) —— */ .present-shell--chat_monitor { --present-accent: #0369a1; - background: - linear-gradient(180deg, #f0f9ff 0%, #fff 48px); } .present-shell--chat_monitor .present-stage { - padding: 14px 16px; - border-radius: 10px; - border: 1px solid var(--present-line); - background: #fff; + padding: 4px 0 0; + border: none; + background: transparent; } .present-shell--spotlight { --present-accent: #b45309; padding-top: 14px; } .present-shell--spotlight .present-stack--spotlight { - gap: 14px; + gap: 12px; } .present-shell--spotlight .present-stage--hero { - padding: 6px 4px 4px; + padding: 2px 0 4px; } .present-shell--spotlight .present-region--monitor .present-region-title { display: none; } .present-shell--turn_panel { --present-accent: #047857; - background: linear-gradient(165deg, #f0fdf4 0%, #fff 28%); } .present-board-top { display: flex; @@ -2204,8 +3930,8 @@ body.rail-collapsed .af-mosaic { } .present-board-mid { display: grid; - grid-template-columns: minmax(0, 1.7fr) minmax(140px, 0.85fr); - gap: 12px; + grid-template-columns: minmax(0, 1.75fr) minmax(140px, 0.8fr); + gap: 16px; align-items: start; } .present-board-side { @@ -2214,32 +3940,32 @@ body.rail-collapsed .af-mosaic { gap: 8px; } .present-shell--turn_panel .present-stage { - padding: 16px; - border-radius: 10px; - border: 1px solid var(--present-line); - background: #fff; + padding: 0; + border: none; + background: transparent; } .present-shell--split_board { --present-accent: #4338ca; } .present-split { display: grid; - grid-template-columns: minmax(0, 1.55fr) minmax(150px, 0.9fr); - gap: 14px 16px; + grid-template-columns: minmax(0, 1.6fr) minmax(150px, 0.85fr); + gap: 16px 20px; align-items: start; } .present-split-main { display: flex; flex-direction: column; - gap: 10px; + gap: 12px; min-width: 0; } .present-split-side { min-width: 0; - padding: 10px; - border-radius: 10px; - border: 1px solid var(--present-line); - background: var(--present-soft); + padding: 0 0 0 14px; + border-radius: 0; + border: none; + border-left: 1px solid var(--present-line); + background: transparent; } .present-split .present-region--actions { grid-column: 1 / -1; @@ -2248,19 +3974,18 @@ body.rail-collapsed .af-mosaic { --present-accent: #be123c; } .present-stack--dock { - gap: 10px; + gap: 12px; } .present-dock { - margin-top: 4px; - padding: 12px; - border-radius: 12px; - border: 1px solid color-mix(in srgb, var(--present-accent) 28%, var(--present-line)); - background: color-mix(in srgb, var(--present-accent) 6%, #fff); - /* 长正文时选项坞钉在视口底,读完仍看得到 */ + margin-top: 8px; + padding: 12px 0 0; + border-radius: 0; + border: none; + border-top: 1px solid var(--present-line); + background: color-mix(in srgb, var(--surface, #fff) 92%, var(--present-soft)); position: sticky; bottom: 0; z-index: 2; - box-shadow: 0 -8px 20px color-mix(in srgb, #fff 70%, transparent); } .present-dock .present-region-title { margin-bottom: 2px; @@ -2268,18 +3993,15 @@ body.rail-collapsed .af-mosaic { .present-dock .present-action { padding: 9px 14px; font-size: 13.5px; - border-width: 1.5px; + border-width: 1px; } .present-shell--chapter_reader { --present-accent: #44403c; - background: - linear-gradient(90deg, transparent 0, transparent), - linear-gradient(180deg, #fafaf9 0%, #fff 40%); } .present-reader { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(120px, 0.42fr); - gap: 18px; + grid-template-columns: minmax(0, 1fr) minmax(120px, 0.38fr); + gap: 20px; align-items: start; } .present-reader-main { @@ -2300,10 +4022,13 @@ body.rail-collapsed .af-mosaic { font-weight: 600; } .present-shell--prose .present-stage--read { - padding: 6px 2px; + padding: 2px 0; } .present-shell--prose .present-region--footer .present-region-body { - opacity: 0.85; + opacity: 0.75; + border: none; + background: transparent; + padding: 0; } @media (max-width: 640px) { .present-board-mid,