完善创作节点循环与等待态交互,并大幅打磨 Web 壳与会话运行时。
- 节点循环:配方开局直坐 DAG 第一步;验收后先问下一步意向,再确认开干并可钉编排参数;「按意见修改」只重跑当前节点。 - 询问分流:能力 opening 走说话面引导,可跳过追问按题干去重;追问与产物同轮挂载,避免拆成两段历史。 - 运行时:补强 revision 重跑、创作流程合并/进度指针、工具循环与 worker 执行;新增运行日志与 web:watch。 - 前端:统一等待态文案与底栏主按钮,完善询问卡/产物验收/呈现壳样式与交互。 - 同步世界模拟器模块提示、编排文档与相关测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
| 产物/技能 | 美化程度 | 渲染入口 |
|
||||
|-----------|----------|----------|
|
||||
| 美学纲领与交互范式 | **专用 mosaic**(设定逻辑 / 交互范式 / 美学纲领) | `renderSpecialtyBodyHtml` → aesthetics |
|
||||
| 美学纲领与交互范式 | **左右分栏**:左契约(美学纲领+交互范式)/ 右诊断(设定逻辑) | `renderSpecialtyBodyHtml` → aesthetics |
|
||||
| 实现机制 | **专用卡**(支撑点) | `renderMechanismBodyHtml` |
|
||||
| 舞台骨架 | **专用卡** | `renderWorldBlueprintBodyHtml` |
|
||||
| 生成规则 | **专用卡** | `renderGenerationRulesBodyHtml` |
|
||||
|
||||
@@ -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`。 |
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
|
||||
@@ -38,10 +38,20 @@
|
||||
| `schema` / `技能` / `brief` / `mount` / `稳变` | 索引与挂载;不写 `order` |
|
||||
| `正文` | **产物主体**(技能自定内部维度,但必须有「各个方面」) |
|
||||
| `自评` | **自评评分**(`维度[]` + 可选薄弱点;维度名按技能) |
|
||||
| `追问` | **导语 + 题目**(建议选项 / 示例);可无题则 `题目: []` |
|
||||
| `追问` | **导语 + 题目**(建议选项 / 示例);可无题则 `题目: []`。程序挂询问卡;不要再抄一份 `askUser` |
|
||||
| `开放问题` | 未结构化的残项(可选) |
|
||||
|
||||
美学步正文:`设定逻辑` + `交互范式` + `美学纲领`(见 `aesthetics-interaction`)。
|
||||
美学步正文:`设定逻辑` + `交互范式` + `美学纲领`(见 `aesthetics-interaction`)。
|
||||
|
||||
**验收卡排版约定(美学步已落地,其它节点逐步对齐)**:
|
||||
|
||||
| 栏 | 放什么 | 美学步对应 |
|
||||
|----|--------|------------|
|
||||
| **左 · 产物** | 最终希望插入游玩上下文的契约正文 | `美学纲领`、`交互范式` |
|
||||
| **右 · 评估** | 诊断、完备度、待探、区域化等给人验收用的 | `设定逻辑`(参与/内容维度上的结论·依据·完备度) |
|
||||
|
||||
外壳另有 `自评`(顶栏分数 pill)与 `追问`(询问卡)。
|
||||
**存盘**:三块都在同一 `正文` 里。**注入**:默认 `full` 时整份正文都会进模型——若日后只要左栏进游玩,再改投影,不靠藏 UI。
|
||||
实现机制正文:`依据的核心体验` + `支撑点[]` + `支撑点关系[]` + `覆盖检验`(见 `mechanism`)。
|
||||
舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。
|
||||
生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段);自评=必要性/属性妥当/格式准确。
|
||||
|
||||
@@ -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. 信息层级与长文容量
|
||||
|
||||
|
||||
@@ -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` | 说话 | 按意见修改 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;勿把「生成什么」拖到执行中途才问。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,不要同一问写两遍。
|
||||
已有一轮追问答复或核心体验+权限边界已可执行:`追问.题目` 用 `[]`,让用户验收。禁止这轮问交互、下轮问美学地来回打转。
|
||||
|
||||
【默认问题已覆盖】变造(原型+变点)、代入与否、最想感受到的核心。首答后按缺口补,勿重问已答清的。
|
||||
若开场已跳过(用户.需求已有首句):把首句当默认问题的答复,只补真正缺口。
|
||||
|
||||
【参与 · 结构性】
|
||||
- 用户与 <user>:代入程度 / 情感距离 / 控制期待 / 多角色
|
||||
@@ -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 能点题核心体验?变造/代入/满足来源清楚或已标未定?
|
||||
- [ ] 交互范式权限与美学纲领互相保护、不矛盾?
|
||||
- [ ] 追问只打薄弱点,带建议选项?
|
||||
- [ ] 追问只打真正薄弱点,带建议选项?已答过则题目为空、准备验收?
|
||||
- [ ] 有没有为「完整」强行填充百科或偏离用户核心?
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
若程序已发默认问题:禁止重复开场;在首答上补洞。
|
||||
|
||||
@@ -32,6 +32,17 @@ feeds: gm
|
||||
## opening
|
||||
|
||||
```opening
|
||||
核心体验已有方向时,用几句话想想:它具体靠什么撑住?(想到什么写什么,不必整齐;点名即可,不必论证来源或补起源史。)
|
||||
|
||||
1. 这体验主要靠哪些东西成立?
|
||||
角色的某一面、一段关系、世界条件、规则或情境……用你自己的词点名。
|
||||
例:丈夫会把异常证据合理化;力量越用越侵蚀自我;村里每年必须献祭才能过冬。
|
||||
|
||||
2. 拿掉或改弱哪一样,核心感觉会明显变味甚至垮掉?
|
||||
一句话即可——只点「缺了就不对」的,装饰和以后再展开的先别列。
|
||||
|
||||
3. 若已有具体画面或作品参照,它主要指哪一切面在撑体验?
|
||||
例:信息差、无法撤销的前提、身份代价、循环反馈……不必复述整部设定。
|
||||
```
|
||||
|
||||
## task
|
||||
|
||||
@@ -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. 有没有隐藏段给模型/程序维护变量?
|
||||
|
||||
@@ -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 与节点自身处理。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 扩写助手 · 配方
|
||||
# 写方法论:适用、核心思路、设计流程、原则。
|
||||
# 各能力何时用 / 不用 → 读能力 meta(编排器会注入),勿在此重复。
|
||||
# steps = 近期起点,不是固定全程 DAG。
|
||||
# steps = 近期起点;选中本配方时程序写入「设计.创作流程」,开局即坐在第一步(不是固定全程 DAG)。
|
||||
|
||||
when: 大纲/分段扩写、写手统筹、先纲后章、成稿向助手、长文/爽文类体验
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 世界模拟器 · 配方
|
||||
# 写方法论:适用、核心思路、设计流程、原则。
|
||||
# 各技能何时用 / 不用 → 读技能 meta(编排器会注入),勿在此重复。
|
||||
# steps = 近期起点,不是固定全程 DAG。
|
||||
# steps = 近期起点;选中本配方时程序写入「设计.创作流程」,开局即坐在第一步(不是固定全程 DAG)。
|
||||
|
||||
when: 回合互动、世界推进、角色扮演、沉浸推演类体验
|
||||
|
||||
|
||||
@@ -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`?
|
||||
|
||||
@@ -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 补参
|
||||
|
||||
@@ -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<T>(
|
||||
kind: string,
|
||||
caller: string | undefined,
|
||||
run: () => Promise<T>,
|
||||
summarize: (result: T) => string,
|
||||
): Promise<T> {
|
||||
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<LlmProvider["complete"]>[0],
|
||||
options?: CompleteOptions,
|
||||
): Promise<CompleteResult> {
|
||||
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<LlmProvider["completeWithTools"]>[0],
|
||||
options: CompleteWithToolsOptions,
|
||||
): Promise<CompleteWithToolsResult> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
293
src/log.ts
Normal file
293
src/log.ts
Normal file
@@ -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<RuntimePhase, string> = {
|
||||
idle: "空闲",
|
||||
running: "执行中",
|
||||
waiting_user: "等待用户",
|
||||
done: "完成",
|
||||
error: "出错",
|
||||
};
|
||||
|
||||
const WAIT_LABEL: Record<WaitingReason["kind"], string> = {
|
||||
skill_selection: "选能力包",
|
||||
intake: "收集需求",
|
||||
input: "等待输入",
|
||||
approve_step: "确认开干",
|
||||
review_artifact: "验收产物",
|
||||
worker_questions: "执行单元提问",
|
||||
revision: "修订",
|
||||
next_intent: "下一步意向",
|
||||
};
|
||||
|
||||
const EVENT_LABEL: Record<RuntimeEvent["type"], string> = {
|
||||
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<string, string> = {
|
||||
invoke_main_agent: "调用总管",
|
||||
run_worker: "启动执行单元",
|
||||
resume_worker: "恢复执行单元",
|
||||
run_programmatic_review: "程序验收",
|
||||
emit_message: "发消息",
|
||||
propose_next_creation_step: "提案下一步",
|
||||
};
|
||||
|
||||
const TOOL_LABEL: Record<string, string> = {
|
||||
read_blackboard: "读黑板",
|
||||
list_workers: "列执行单元",
|
||||
list_artifacts: "列产物",
|
||||
run_worker: "跑执行单元",
|
||||
ask_user: "问用户",
|
||||
review_blackboard: "审黑板",
|
||||
finish: "结束",
|
||||
};
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
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<typeof sessionSnap>,
|
||||
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(" "));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>;
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
...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<string, unknown> = { ...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,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<ApplyEventResult> {
|
||||
/** 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<ApplyEventResult> {
|
||||
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<RuntimeSession> {
|
||||
/** 用户确认 pendingDecision(approve_step);可顺带钉死下一步 params */
|
||||
async approve(stepParams?: CreationFlowStepParams): Promise<RuntimeSession> {
|
||||
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<RuntimeSession> {
|
||||
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<boolean> {
|
||||
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<RuntimeSession> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, ManagedSession>();
|
||||
/** bookId → 当前内存中的 sessionId */
|
||||
private readonly activeBookSessions = new Map<string, string>();
|
||||
/** sessionId → 流式 thinking 缓冲 */
|
||||
/** sessionId → 流式 thinking 缓冲(思考结束后仍保留,直到本轮结束) */
|
||||
private readonly agentThinkingLive = new Map<string, string>();
|
||||
/** 本轮思维链已收束;下一次 delta 起新一轮 */
|
||||
private readonly agentThinkingClosed = new Set<string>();
|
||||
/** sessionId → worker 流式缓冲 */
|
||||
private readonly workerLive = new Map<
|
||||
string,
|
||||
@@ -679,7 +719,11 @@ export class SessionManager {
|
||||
return count;
|
||||
}
|
||||
|
||||
async sendMessage(id: string, text: string): Promise<SessionView> {
|
||||
async sendMessage(
|
||||
id: string,
|
||||
text: string,
|
||||
opts?: { answers?: QuestionAnswer[] },
|
||||
): Promise<SessionView> {
|
||||
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<SessionView> {
|
||||
async approve(
|
||||
id: string,
|
||||
stepParams?: Record<string, unknown>,
|
||||
): Promise<SessionView> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, unknown>,
|
||||
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<ProposedNextStepView>;
|
||||
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<string, unknown>)
|
||||
: {},
|
||||
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 {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
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()}`);
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<string>([
|
||||
CREATION_CURRENT_STEP_TAG,
|
||||
CREATION_PROPOSED_STEP_TAG,
|
||||
CREATION_SELECTED_RECIPE_TAG,
|
||||
"创作.当前单位",
|
||||
"创作.已验收单位",
|
||||
"创作.已验收内容",
|
||||
"创作.能力开场白",
|
||||
"创作.能力开场状态",
|
||||
]);
|
||||
|
||||
/** 旧名:含「设计.创作流程」。写回/验收过滤请用 isProgressPointerTag */
|
||||
export const RUNTIME_PINNED_TAGS = new Set<string>([
|
||||
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<RecipeDetail, "seed">,
|
||||
): 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<string, string> = {
|
||||
@@ -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<string>();
|
||||
const doneIds = new Set<string>();
|
||||
|
||||
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<string>();
|
||||
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<string, number>();
|
||||
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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -101,6 +101,7 @@ export async function resolveRunnableWorker(params: {
|
||||
currentStepName,
|
||||
acceptedStepNames: parseAcceptedSteps(acceptedRaw),
|
||||
selectedRecipeRef,
|
||||
filledArtifactTags: params.blackboard.listTagIndex().map((item) => item.tag),
|
||||
},
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<number>();
|
||||
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[],
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<string, string>,
|
||||
tag: string | undefined,
|
||||
val: unknown,
|
||||
): void {
|
||||
if (!tag || isProgressPointerTag(tag)) return;
|
||||
const text = stringifyOutputValue(val);
|
||||
if (text) outputs[tag] = text;
|
||||
}
|
||||
|
||||
/** 模型常把片段当根对象,或写进「创作.当前步骤」 */
|
||||
function recoverFragmentOutputs(
|
||||
obj: Record<string, unknown>,
|
||||
outputs: Record<string, string>,
|
||||
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<string, unknown>)
|
||||
: 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<string, unknown> = {};
|
||||
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<string, string>): 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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
|
||||
if (outputsRaw && typeof outputsRaw === "object") {
|
||||
for (const tag of outputTags) {
|
||||
const val = (outputsRaw as Record<string, unknown>)[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<string, unknown>)[tag]);
|
||||
}
|
||||
}
|
||||
recoverFragmentOutputs(obj, outputs, outputTags);
|
||||
|
||||
return finalizeParsedOutputs(outputs, obj, outputTags);
|
||||
}
|
||||
|
||||
function finalizeParsedOutputs(
|
||||
outputs: Record<string, string>,
|
||||
obj: Record<string, unknown>,
|
||||
_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<WorkerRun
|
||||
} catch {
|
||||
acceptedStepNames = [];
|
||||
}
|
||||
const filledArtifactTags = params.blackboard
|
||||
.listTagIndex()
|
||||
.map((item) => item.tag);
|
||||
const loaded = await loadWorkerSkillWithContext(
|
||||
params.skillName,
|
||||
workerId,
|
||||
@@ -388,6 +502,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
currentStepName,
|
||||
acceptedStepNames,
|
||||
selectedRecipeRef,
|
||||
filledArtifactTags,
|
||||
},
|
||||
);
|
||||
worker = loaded.worker;
|
||||
|
||||
@@ -46,8 +46,9 @@ if exist ".env" (
|
||||
)
|
||||
|
||||
echo [信息] 启动桌面窗口 http://localhost:%PORT%
|
||||
echo [信息] 调试: F12 或 Ctrl+Shift+I 打开开发者工具
|
||||
echo [信息] 自动打开 DevTools: set WRITING_AGENT_ELECTRON_DEV=1 ^&^& npm run electron
|
||||
echo [信息] 后端日志在 %USERPROFILE%\.writing-agent\logs\runtime.log
|
||||
echo [信息] 显示控制台请用 start-app.bat console
|
||||
echo [信息] F12 或 Ctrl+Shift+I 打开开发者工具,只有前端
|
||||
echo [信息] 关闭窗口即停止服务
|
||||
echo.
|
||||
|
||||
|
||||
@@ -41,13 +41,15 @@ if exist ".env" (
|
||||
)
|
||||
|
||||
echo [信息] 启动服务 http://localhost:%PORT%
|
||||
echo [信息] 调试: 浏览器 F12 打开开发者工具
|
||||
echo [信息] 桌面壳: 运行 start-app.bat
|
||||
echo [信息] 改 src 或 skills 后后端会自动重启
|
||||
echo [信息] 后端日志在本窗口,文件在 %USERPROFILE%\.writing-agent\logs\runtime.log
|
||||
echo [信息] 浏览器按 F12 打开开发者工具
|
||||
echo [信息] 桌面窗口请用 start-app.bat
|
||||
echo [信息] 按 Ctrl+C 停止
|
||||
echo.
|
||||
|
||||
start "" "http://localhost:%PORT%"
|
||||
|
||||
call npm run web
|
||||
call npm run web:watch
|
||||
|
||||
pause
|
||||
|
||||
@@ -79,6 +79,38 @@ describe("contextSegments assembly", () => {
|
||||
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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("待选定");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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"] }),
|
||||
|
||||
@@ -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("还没写出可验收的产物");
|
||||
});
|
||||
});
|
||||
|
||||
1164
web/agent-ui.js
1164
web/agent-ui.js
File diff suppressed because it is too large
Load Diff
891
web/app.js
891
web/app.js
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<div class="rail-panels">
|
||||
<div class="rail-tab-panel active" id="rail-panel-books" role="tabpanel" data-rail-panel="books">
|
||||
<nav class="path-bar" id="book-path-bar" aria-label="作品路径"></nav>
|
||||
<nav class="path-bar" id="book-path-bar" aria-label="面包屑" hidden></nav>
|
||||
<div class="book-list" id="book-list"></div>
|
||||
<div id="book-box-overlay" class="book-box-overlay" hidden aria-hidden="true"></div>
|
||||
</div>
|
||||
@@ -60,14 +60,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="main-head-right">
|
||||
<a class="link" href="/modules.html">技能</a>
|
||||
<a class="link" href="/shells.html">呈现壳</a>
|
||||
<a class="link" href="/stats.html">Token</a>
|
||||
<a class="link" href="/settings.html">设置</a>
|
||||
<button type="button" class="btn-sm" id="btn-save-instance" hidden>保存定稿</button>
|
||||
<button type="button" class="btn-sm" id="btn-save-play" hidden>保存游玩</button>
|
||||
<button type="button" class="btn-sm" id="btn-export" disabled>导出</button>
|
||||
<span class="status" id="status-pill"><span class="status-dot" id="status-dot"></span><span id="status-text">—</span></span>
|
||||
<details class="more-menu">
|
||||
<summary class="btn-sm more-menu-sum" title="更多页面">更多</summary>
|
||||
<div class="more-menu-panel" role="menu">
|
||||
<a class="more-menu-item" href="/modules.html" role="menuitem">技能</a>
|
||||
<a class="more-menu-item" href="/shells.html" role="menuitem">呈现壳</a>
|
||||
<a class="more-menu-item" href="/stats.html" role="menuitem">Token</a>
|
||||
<a class="more-menu-item" href="/settings.html" role="menuitem">设置</a>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -77,8 +82,28 @@
|
||||
<div id="message-feed" class="message-feed">
|
||||
<p class="empty">点击左侧 + 新建作品</p>
|
||||
</div>
|
||||
<div id="workspace-stage" class="workspace-stage" hidden aria-label="验收工作面"></div>
|
||||
<div id="questions-card-host" class="questions-card-host" hidden></div>
|
||||
</section>
|
||||
<aside id="coord-rail" class="coord-rail" hidden aria-label="此前对话">
|
||||
<button
|
||||
type="button"
|
||||
class="coord-rail-toggle"
|
||||
id="btn-coord-toggle"
|
||||
aria-expanded="false"
|
||||
title="展开 / 收起此前对话"
|
||||
>
|
||||
<span class="coord-rail-toggle-label">对话</span>
|
||||
<span id="coord-drawer-count" class="coord-drawer-count"></span>
|
||||
</button>
|
||||
<div class="coord-rail-panel">
|
||||
<header class="coord-rail-head">
|
||||
<span class="coord-rail-title">此前对话</span>
|
||||
<button type="button" class="btn-sm coord-rail-collapse" id="btn-coord-collapse" title="收起" aria-label="收起此前对话">›</button>
|
||||
</header>
|
||||
<div id="coord-drawer-body" class="coord-drawer-body"></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer class="composer" id="composer"></footer>
|
||||
@@ -119,12 +144,13 @@
|
||||
<button type="button" class="context-menu-item" id="msg-menu-context" data-msg-menu-action="view-context" hidden>查看上下文</button>
|
||||
<button type="button" class="context-menu-item" id="msg-menu-edit" data-msg-menu-action="edit">编辑</button>
|
||||
<button type="button" class="context-menu-item" id="msg-menu-copy" data-msg-menu-action="copy">复制</button>
|
||||
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="delete">删除</button>
|
||||
<button type="button" class="context-menu-item context-menu-separator-before" id="msg-menu-rollback" data-msg-menu-action="rollback" hidden>回退到这里</button>
|
||||
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="delete">从这里重开</button>
|
||||
</menu>
|
||||
|
||||
<dialog id="dialog-context-trace" class="dialog dialog-wide">
|
||||
<dialog id="dialog-context-trace" class="dialog dialog-wide" aria-labelledby="context-trace-title">
|
||||
<form method="dialog" class="context-trace-form">
|
||||
<h2>请求上下文</h2>
|
||||
<h2 id="context-trace-title">上下文调用日志</h2>
|
||||
<p class="dialog-desc" id="context-trace-meta"></p>
|
||||
<pre id="context-trace-body" class="context-trace-body"></pre>
|
||||
<div class="dialog-actions">
|
||||
|
||||
@@ -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 = {}) {
|
||||
? `<button type="button" class="qcard-skip" data-qskip title="Esc">${esc(skipLabel || "跳过")}</button>`
|
||||
: "";
|
||||
|
||||
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 = `
|
||||
<div class="${chromeClass}" role="dialog" aria-label="询问">
|
||||
<div class="qcard-float">
|
||||
${pagerHtml}
|
||||
${skipHtml}
|
||||
</div>
|
||||
<button type="button" class="qcard-top" data-qtop title="${state.expanded ? "点击收起" : "点击展开"}" aria-label="${state.expanded ? "收起询问卡" : "展开询问卡"}">
|
||||
<span class="qcard-top-grip" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="qcard-body">
|
||||
${dismissNote}
|
||||
${assessmentHtml}
|
||||
@@ -364,8 +412,43 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
|
||||
</section>`;
|
||||
})
|
||||
.join("")}
|
||||
</div>
|
||||
</div>`;
|
||||
</div>`;
|
||||
|
||||
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 ?? "";
|
||||
|
||||
289
web/shells.css
289
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%;
|
||||
}
|
||||
}
|
||||
|
||||
.shells-details {
|
||||
margin: 0 12px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,44 +28,42 @@
|
||||
|
||||
<div class="st-shell shells-shell">
|
||||
<aside class="st-rail" aria-label="壳列表">
|
||||
<p class="st-rail-label">呈现壳</p>
|
||||
<p class="st-rail-label">选壳</p>
|
||||
<p class="st-rail-hint">按「要同时看见什么」选,不是按好看选。</p>
|
||||
<nav class="shells-list" id="shell-list" role="tablist"></nav>
|
||||
<p class="st-rail-label shells-tone-label">装饰语气</p>
|
||||
<div class="shells-tones" id="tone-list" role="group" aria-label="tone_chrome"></div>
|
||||
<p class="st-rail-label shells-tone-label">正文长度</p>
|
||||
<div class="shells-tones" id="length-list" role="group" aria-label="body length"></div>
|
||||
<p class="st-rail-note">
|
||||
正文按 600~3000 字设计:随字数撑高、页面滚动;侧栏/选择坞钉住。创作只改内容绑定。
|
||||
主任务永远是读正文(常 600~3000 字)。侧栏只换壳;长度/语气在预览顶栏。
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<main class="st-main">
|
||||
<header class="st-main-head">
|
||||
<div>
|
||||
<h1 id="panel-title">呈现壳预览</h1>
|
||||
<p class="st-sub" id="panel-subtitle">用示例数据看布局与可调项</p>
|
||||
<main class="st-main shells-main">
|
||||
<div class="shells-toolbar" role="toolbar" aria-label="预览条件">
|
||||
<div class="shells-toolbar-block">
|
||||
<span class="shells-toolbar-label" id="length-label">正文长度</span>
|
||||
<div class="shells-tones" id="length-list" role="group" aria-labelledby="length-label"></div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="st-body shells-body">
|
||||
<section class="shells-meta" id="shell-meta"></section>
|
||||
<section class="shells-preview-wrap">
|
||||
<h2 class="shells-preview-label">预览</h2>
|
||||
<div id="shell-preview" class="shells-preview"></div>
|
||||
</section>
|
||||
<section class="shells-tweaks">
|
||||
<h2>本页可调(对应创作「微调轴」)</h2>
|
||||
<div class="shells-tweak-grid" id="tweak-controls"></div>
|
||||
</section>
|
||||
<section class="shells-help">
|
||||
<h2>设计意图</h2>
|
||||
<ul>
|
||||
<li>正文默认装得下单轮 <strong>600~3000 字</strong>:区域随内容长高,由页面/消息区滚动,不塞进固定小框。</li>
|
||||
<li>双栏壳:侧栏 sticky;选择坞:选项区 sticky 贴底——长文读完控件还在。</li>
|
||||
<li>壳差在「谁占主柱」;可改的是字段/显示名/行动开关/tone,不是重画布局。</li>
|
||||
</ul>
|
||||
<p class="muted">说明文档:<code>docs/play-presentation-shells.md</code></p>
|
||||
</section>
|
||||
<div class="shells-toolbar-block">
|
||||
<span class="shells-toolbar-label" id="tone-label">语气</span>
|
||||
<div class="shells-tones" id="tone-list" role="group" aria-labelledby="tone-label"></div>
|
||||
</div>
|
||||
<div class="shells-toolbar-block shells-toolbar-meta" id="shell-meta"></div>
|
||||
</div>
|
||||
|
||||
<section class="shells-preview-wrap" aria-label="壳预览主工作面">
|
||||
<div id="shell-preview" class="shells-preview"></div>
|
||||
</section>
|
||||
|
||||
<details class="shells-details">
|
||||
<summary>微调轴与说明</summary>
|
||||
<div class="shells-details-body">
|
||||
<div class="shells-tweak-grid" id="tweak-controls"></div>
|
||||
<ul class="shells-help-list">
|
||||
<li>主工作面 = 上面的壳。读长文时正文撑高,页面滚动;侧栏 / 选择坞钉住。</li>
|
||||
<li>壳差在「必须同时看见什么」:只正文 / 正文+状态 / 正文+线索 / 正文+选项…</li>
|
||||
<li>创作只改字段绑定与显示名,不发明新布局。文档:<code>docs/play-presentation-shells.md</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<p><strong>${esc(info.name)}</strong>(<code>${esc(state.shell)}</code>)</p>
|
||||
<p>${esc(info.blurb)}</p>
|
||||
<p class="shells-density"><span>长文策略</span>${esc(info.density)} · 当前试读 <strong>${packet._chars}</strong> 字</p>
|
||||
<p class="muted">适用:${esc(info.scenes)}。请用上方「正文长度」切到长文,确认侧栏/选项是否仍可用。</p>
|
||||
<p class="shells-meta-title">${esc(info.name)}</p>
|
||||
<p class="shells-meta-sub">${esc(info.task)} · 试读 ${packet._chars} 字<br>${esc(info.blurb)}</p>
|
||||
`;
|
||||
|
||||
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 `<button type="button" class="st-rail-item" data-shell="${esc(id)}" role="tab">
|
||||
${esc(info?.name || id)}
|
||||
return `<button type="button" class="st-rail-item" data-shell="${esc(id)}" role="tab" aria-selected="false">
|
||||
<span class="shell-item-name">${esc(info?.name || id)}</span>
|
||||
<span class="shell-item-task">${esc(info?.task || "")}</span>
|
||||
</button>`;
|
||||
}).join("");
|
||||
|
||||
@@ -307,15 +296,15 @@ function mount() {
|
||||
document.getElementById("tweak-controls").innerHTML = `
|
||||
<label class="field">
|
||||
<input type="checkbox" id="tweak-show-actions" />
|
||||
显示「建议行动」(对应微调轴 show_suggested_actions)
|
||||
显示建议行动(show_suggested_actions)
|
||||
</label>
|
||||
<label class="field">
|
||||
正文区显示名(block_labels.body)
|
||||
<input type="text" id="tweak-label-body" placeholder="例如:短信记录 / 场面 / 本章" />
|
||||
正文显示名(block_labels.body)
|
||||
<input type="text" id="tweak-label-body" placeholder="短信记录 / 场面 / 本章" />
|
||||
</label>
|
||||
<label class="field">
|
||||
监控区显示名(block_labels.monitor)
|
||||
<input type="text" id="tweak-label-monitor" placeholder="例如:状态 / 今日" />
|
||||
监控显示名(block_labels.monitor)
|
||||
<input type="text" id="tweak-label-monitor" placeholder="状态 / 今日" />
|
||||
</label>
|
||||
`;
|
||||
|
||||
|
||||
2021
web/styles.css
2021
web/styles.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user