完善创作节点循环与等待态交互,并大幅打磨 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` |
|
| 实现机制 | **专用卡**(支撑点) | `renderMechanismBodyHtml` |
|
||||||
| 舞台骨架 | **专用卡** | `renderWorldBlueprintBodyHtml` |
|
| 舞台骨架 | **专用卡** | `renderWorldBlueprintBodyHtml` |
|
||||||
| 生成规则 | **专用卡** | `renderGenerationRulesBodyHtml` |
|
| 生成规则 | **专用卡** | `renderGenerationRulesBodyHtml` |
|
||||||
|
|||||||
@@ -356,7 +356,8 @@ task 必须写:禁止重复同一开场;在首答上补洞。
|
|||||||
|
|
||||||
## 4.8 追问(`probe`)通用要求
|
## 4.8 追问(`probe`)通用要求
|
||||||
|
|
||||||
- 一轮 askUser **1~2** 点。
|
- 一轮 **1~2** 点,只写进产物 `追问`(建议选项 + 示例)。
|
||||||
|
- 程序会把 `追问` 挂到询问卡;**禁止**同一问再抄一份顶层 `askUser`(`askUser` 留 `null`)。
|
||||||
- 优先:可直接采用或微调的完整句选项 / 短场景。
|
- 优先:可直接采用或微调的完整句选项 / 短场景。
|
||||||
- 能推断则先写入再复述;只问影响本步核心且不能瞎填的点。
|
- 能推断则先写入再复述;只问影响本步核心且不能瞎填的点。
|
||||||
- 依赖产物已钉死的内容禁止重问。
|
- 依赖产物已钉死的内容禁止重问。
|
||||||
@@ -397,7 +398,7 @@ task 必须写:禁止重复同一开场;在首答上补洞。
|
|||||||
|----|------|
|
|----|------|
|
||||||
| `正文` | **产物主体**。技能自定内部结构(如美学步=设定逻辑 + 交互范式 + 美学纲领);须含「详细各个方面」。禁止整份只交散文。 |
|
| `正文` | **产物主体**。技能自定内部结构(如美学步=设定逻辑 + 交互范式 + 美学纲领);须含「详细各个方面」。禁止整份只交散文。 |
|
||||||
| `自评` | **自评评分**。`维度[]` 名目按技能定(美学步常用:交互范式 / 美学纲领 / 整体协调);**`分数` 为 0–10 十分制**(可一位小数;高质约 8–10);可含 `薄弱点`。禁止用 0–100 百分数。 |
|
| `自评` | **自评评分**。`维度[]` 名目按技能定(美学步常用:交互范式 / 美学纲领 / 整体协调);**`分数` 为 0–10 十分制**(可一位小数;高质约 8–10);可含 `薄弱点`。禁止用 0–100 百分数。 |
|
||||||
| `追问` | **示例 + 建议选项**。给用户的下一问;`题目` 可空数组。与程序 askUser 可并存,但产物里要留结构化题面。 |
|
| `追问` | **示例 + 建议选项**。给用户的下一问(程序挂询问卡);`题目` 可空数组。禁止同一问再写入顶层 `askUser`。 |
|
||||||
|
|
||||||
| 规则 | 说明 |
|
| 规则 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
|||||||
@@ -38,10 +38,20 @@
|
|||||||
| `schema` / `技能` / `brief` / `mount` / `稳变` | 索引与挂载;不写 `order` |
|
| `schema` / `技能` / `brief` / `mount` / `稳变` | 索引与挂载;不写 `order` |
|
||||||
| `正文` | **产物主体**(技能自定内部维度,但必须有「各个方面」) |
|
| `正文` | **产物主体**(技能自定内部维度,但必须有「各个方面」) |
|
||||||
| `自评` | **自评评分**(`维度[]` + 可选薄弱点;维度名按技能) |
|
| `自评` | **自评评分**(`维度[]` + 可选薄弱点;维度名按技能) |
|
||||||
| `追问` | **导语 + 题目**(建议选项 / 示例);可无题则 `题目: []` |
|
| `追问` | **导语 + 题目**(建议选项 / 示例);可无题则 `题目: []`。程序挂询问卡;不要再抄一份 `askUser` |
|
||||||
| `开放问题` | 未结构化的残项(可选) |
|
| `开放问题` | 未结构化的残项(可选) |
|
||||||
|
|
||||||
美学步正文:`设定逻辑` + `交互范式` + `美学纲领`(见 `aesthetics-interaction`)。
|
美学步正文:`设定逻辑` + `交互范式` + `美学纲领`(见 `aesthetics-interaction`)。
|
||||||
|
|
||||||
|
**验收卡排版约定(美学步已落地,其它节点逐步对齐)**:
|
||||||
|
|
||||||
|
| 栏 | 放什么 | 美学步对应 |
|
||||||
|
|----|--------|------------|
|
||||||
|
| **左 · 产物** | 最终希望插入游玩上下文的契约正文 | `美学纲领`、`交互范式` |
|
||||||
|
| **右 · 评估** | 诊断、完备度、待探、区域化等给人验收用的 | `设定逻辑`(参与/内容维度上的结论·依据·完备度) |
|
||||||
|
|
||||||
|
外壳另有 `自评`(顶栏分数 pill)与 `追问`(询问卡)。
|
||||||
|
**存盘**:三块都在同一 `正文` 里。**注入**:默认 `full` 时整份正文都会进模型——若日后只要左栏进游玩,再改投影,不靠藏 UI。
|
||||||
实现机制正文:`依据的核心体验` + `支撑点[]` + `支撑点关系[]` + `覆盖检验`(见 `mechanism`)。
|
实现机制正文:`依据的核心体验` + `支撑点[]` + `支撑点关系[]` + `覆盖检验`(见 `mechanism`)。
|
||||||
舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。
|
舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。
|
||||||
生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段);自评=必要性/属性妥当/格式准确。
|
生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段);自评=必要性/属性妥当/格式准确。
|
||||||
|
|||||||
@@ -10,14 +10,25 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
固定壳(本仓库预置,七种)
|
固定壳(本仓库预置,七种)
|
||||||
→ 创作期:选一个 shell_id
|
→ 按「要同时看见什么」选 shell_id(不是按好看)
|
||||||
→ 只改允许的微调轴(字段、显示名、开关、tone_chrome…)
|
→ 只改允许的微调轴
|
||||||
→ 禁止改分区骨架、禁止发明第八种布局
|
→ 禁止改分区骨架、禁止发明第八种布局
|
||||||
游玩期:往壳的区域里灌数(present.v1 / 投影 / settlement)
|
游玩期:往壳区域灌数;正文随 600~3000 字撑高滚动
|
||||||
```
|
```
|
||||||
|
|
||||||
「正文组成」= **壳适配单**,不是 UI 创意稿。
|
选壳依据(同时可见关系):
|
||||||
用户看见的差异应来自**同壳不同绑定**,而不是每局一套新布局。
|
|
||||||
|
| 同时要看见 | 壳 |
|
||||||
|
|------------|-----|
|
||||||
|
| 只要正文 | `prose` |
|
||||||
|
| 正文 + 扫状态 | `chat_monitor` |
|
||||||
|
| 大正文 + 弱状态 | `spotlight` |
|
||||||
|
| 场面 + 交互物 + 行动 | `turn_panel` |
|
||||||
|
| 正文 ‖ 线索对照 | `split_board` |
|
||||||
|
| 局面 + 选项 | `choice_dock` |
|
||||||
|
| 章正文 + 进度 | `chapter_reader` |
|
||||||
|
|
||||||
|
预览页 `/shells.html`:主工作面是壳画布;侧栏只换壳;长度/语气在顶栏。
|
||||||
|
|
||||||
## 2. 信息层级与长文容量
|
## 2. 信息层级与长文容量
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
| `设计.worker集` | **运行规格** | 剧本(旧,规格部分) | 可进游玩的声明截面。 |
|
| `设计.worker集` | **运行规格** | 剧本(旧,规格部分) | 可进游玩的声明截面。 |
|
||||||
| Blackboard | **黑板** | 上下文板 | — |
|
| Blackboard | **黑板** | 上下文板 | — |
|
||||||
| Artifact | **产物** | — | 待验收输出。 |
|
| Artifact | **产物** | — | 待验收输出。 |
|
||||||
| Accept / Review | **验收** / **接受** | 人工审批 | 按钮用「接受」;阶段说明用「验收」。 |
|
| Accept / Review | **验收** / **接受** | 人工审批 | 技能产物按钮用「接受」;工作流计划用「确认编排」;开场用「选定此开场」。阶段说明:技能步「验收」,编排「核对」。 |
|
||||||
| Intake | **需求描述** | 启动填空 | — |
|
| Intake | **需求描述** | 启动填空 | — |
|
||||||
| Burst | **本轮调度** | — | 少对用户说 burst。 |
|
| Burst | **本轮调度** | — | 少对用户说 burst。 |
|
||||||
| Questions card | **询问卡** | Questions | 详见 `ui-design.md` §8。 |
|
| Questions card | **询问卡** | Questions | 详见 `ui-design.md` §8。 |
|
||||||
@@ -177,13 +177,15 @@
|
|||||||
|
|
||||||
## 7. 等待态 / 焦点(对用户)
|
## 7. 等待态 / 焦点(对用户)
|
||||||
|
|
||||||
| `waitingReason.kind` | 状态摘要(短) | 焦点动作 |
|
| `waitingReason.kind` | 状态摘要(短) | 焦点动作 / 底栏主按钮 |
|
||||||
|----------------------|----------------|----------|
|
|----------------------|----------------|----------|
|
||||||
| `intake` | 描述需求 | 描述创作需求 |
|
| `intake` | 描述需求 | 发送 |
|
||||||
| `input` | 补充说明 | 补充说明 / 回答追问 |
|
| `input` | 补充说明 | 发送 / 补充并发送 |
|
||||||
| `worker_questions` | 回答提问 | 回答 · {中文执行单元名} |
|
| `worker_questions` | 答题 | 补充并发送 |
|
||||||
| `approve_step` | 确认执行 | 建议调用 {中文名} |
|
| `approve_step` | 确认 | 同意并开始 |
|
||||||
| `review_artifact` | 验收产物 | 验收产物 |
|
| `review_artifact` | 视产物(勿一律「验收」) | **流程编排** → 确认编排;技能步 → 接受;开场 → 选定此开场。旁挂「按意见改编排 / 按意见修改」 |
|
||||||
|
| `next_intent` | 说话 | 继续(可留空) |
|
||||||
|
| `revision` | 说话 | 按意见修改 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
| 块 id | 必填 | 用途 |
|
| 块 id | 必填 | 用途 |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
| `meta` | 建议 | YAML:name / id / artifact / declaration / when / when_not / boundary |
|
| `meta` | 建议 | YAML:name / id / artifact / declaration / when / when_not / boundary |
|
||||||
| `opening` | 可选 | **默认问题**正文;程序发给用户,不经 LLM |
|
| `opening` | 可选 | **默认问题**正文。开局:配方 `steps` 已预置进「设计.创作流程」,用户首句即坐在 DAG 第一步;若「用户.需求」已有内容,程序**跳过**再抛 opening,直接进本步 LLM |
|
||||||
| `task` | 是 | 本步任务与验收边界 |
|
| `task` | 是 | 本步任务与验收边界 |
|
||||||
| `principles` | 建议 | 原则 |
|
| `principles` | 建议 | 原则 |
|
||||||
| `probe` | 建议 | 追问策略 |
|
| `probe` | 建议 | 追问策略 |
|
||||||
@@ -104,13 +104,24 @@ declaration: …
|
|||||||
|
|
||||||
配方**不要**重复罗列各能力调用条件;那是能力 meta 的职责。旧字段 `hint` 仍可读作兜底。
|
配方**不要**重复罗列各能力调用条件;那是能力 meta 的职责。旧字段 `hint` 仍可读作兜底。
|
||||||
|
|
||||||
### 默认问题节奏(通用)
|
### 节点循环(通用)
|
||||||
|
|
||||||
```text
|
```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-demo": "tsx src/cli/phase-demo.ts",
|
||||||
"phase-script": "tsx src/cli/phase-demo.ts --script --auto",
|
"phase-script": "tsx src/cli/phase-demo.ts --script --auto",
|
||||||
"web": "tsx src/server/web-server.ts",
|
"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 ."
|
"electron": "electron ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -54,8 +54,10 @@ feeds: design_only
|
|||||||
业务字段全部放在「正文」内;须含 schema / 技能 / brief;建议填 mount 与稳变(本步不写数字 order)。
|
业务字段全部放在「正文」内;须含 schema / 技能 / brief;建议填 mount 与稳变(本步不写数字 order)。
|
||||||
|
|
||||||
一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问,不要先交「半份交互」再开「半份美学」。
|
一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问,不要先交「半份交互」再开「半份美学」。
|
||||||
|
用户已答过追问:把答复写入正文三块,`追问.题目` 优先 `[]`;禁止在「交互范式」与「美学纲领」之间轮流补洞拖验收。核心体验与权限边界够用即可收。
|
||||||
|
|
||||||
若程序已发出默认问题:用户首答在「用户.worker答复」,开场白在「创作.能力开场白」。禁止重复同一开场;在首答基础上补洞、追问、产出。
|
若程序已发出默认问题:用户首答在「用户.worker答复」,开场白在「创作.能力开场白」。禁止重复同一开场;在首答基础上补洞、追问、产出。
|
||||||
|
开局通常已由配方写入「设计.创作流程」第一步;若「用户.需求」已有首句:程序会跳过默认问题——首句即本步对话,只补真正缺口。
|
||||||
```
|
```
|
||||||
|
|
||||||
## principles
|
## principles
|
||||||
@@ -74,9 +76,11 @@ feeds: design_only
|
|||||||
## probe
|
## probe
|
||||||
|
|
||||||
```probe
|
```probe
|
||||||
用户首句或黑板已有的直接采纳。一次 askUser 1~2 点,优先具体选项/短场景。
|
用户首句或黑板已有的直接采纳。一次只在产物「追问」写 1~2 点(建议选项 + 示例);顶层 askUser 留 null,不要同一问写两遍。
|
||||||
|
已有一轮追问答复或核心体验+权限边界已可执行:`追问.题目` 用 `[]`,让用户验收。禁止这轮问交互、下轮问美学地来回打转。
|
||||||
|
|
||||||
【默认问题已覆盖】变造(原型+变点)、代入与否、最想感受到的核心。首答后按缺口补,勿重问已答清的。
|
【默认问题已覆盖】变造(原型+变点)、代入与否、最想感受到的核心。首答后按缺口补,勿重问已答清的。
|
||||||
|
若开场已跳过(用户.需求已有首句):把首句当默认问题的答复,只补真正缺口。
|
||||||
|
|
||||||
【参与 · 结构性】
|
【参与 · 结构性】
|
||||||
- 用户与 <user>:代入程度 / 情感距离 / 控制期待 / 多角色
|
- 用户与 <user>:代入程度 / 情感距离 / 控制期待 / 多角色
|
||||||
@@ -205,7 +209,7 @@ feeds: design_only
|
|||||||
2. **公共三段**:`正文`=产物主体;`自评`=完备度打分(**分数 0–10 十分制,禁止百分数**);`追问`=导语+题目(建议选项/示例)。禁止用散文代替这三段。
|
2. **公共三段**:`正文`=产物主体;`自评`=完备度打分(**分数 0–10 十分制,禁止百分数**);`追问`=导语+题目(建议选项/示例)。禁止用散文代替这三段。
|
||||||
3. **本技正文三块**:`设定逻辑`(参与/内容维度/区域/复杂性/完备度)+ `交互范式` + `美学纲领`。未知省略或「未定」,禁止编造百科。
|
3. **本技正文三块**:`设定逻辑`(参与/内容维度/区域/复杂性/完备度)+ `交互范式` + `美学纲领`。未知省略或「未定」,禁止编造百科。
|
||||||
4. `mount` 建议挂主世界层+转述;**禁止**写数字 `order`。`稳变` 定稿后多为 `stable`。
|
4. `mount` 建议挂主世界层+转述;**禁止**写数字 `order`。`稳变` 定稿后多为 `stable`。
|
||||||
5. `追问.题目` 一次宜 1~3 题;有建议选项;允许「其它」。已答清的不要再问。
|
5. `追问.题目` 一次宜 1~3 题;有建议选项与示例;允许「其它」。已答清的不要再问。题目只写在「追问」,askUser 为 null。
|
||||||
6. summary:`美学纲领与交互范式 · {brief 缩略}`。
|
6. summary:`美学纲领与交互范式 · {brief 缩略}`。
|
||||||
|
|
||||||
## checklist
|
## checklist
|
||||||
@@ -216,7 +220,7 @@ feeds: design_only
|
|||||||
- [ ] 未写 order、未输出执行单元列表?
|
- [ ] 未写 order、未输出执行单元列表?
|
||||||
- [ ] brief 能点题核心体验?变造/代入/满足来源清楚或已标未定?
|
- [ ] brief 能点题核心体验?变造/代入/满足来源清楚或已标未定?
|
||||||
- [ ] 交互范式权限与美学纲领互相保护、不矛盾?
|
- [ ] 交互范式权限与美学纲领互相保护、不矛盾?
|
||||||
- [ ] 追问只打薄弱点,带建议选项?
|
- [ ] 追问只打真正薄弱点,带建议选项?已答过则题目为空、准备验收?
|
||||||
- [ ] 有没有为「完整」强行填充百科或偏离用户核心?
|
- [ ] 有没有为「完整」强行填充百科或偏离用户核心?
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ modules:
|
|||||||
- key: target
|
- key: target
|
||||||
label: 生成对象
|
label: 生成对象
|
||||||
required: true
|
required: true
|
||||||
hint: 本轮为哪一类内容建规则;编排时 askUser 给选项+其它
|
hint: 本轮为哪一类内容建规则;编排时 askUser 给选项+其它;用户改对象时必须同步改 rule_id 与步骤 id
|
||||||
- key: rule_id
|
- key: rule_id
|
||||||
label: 规则 id
|
label: 规则 id
|
||||||
required: false
|
required: false
|
||||||
hint: 建议英文 kebab-case;可执行时再最终钉死
|
hint: 英文 kebab-case,须与 target 对应(女租客→female-tenants,丧尸→zombies);改对象时禁止沿用旧 id
|
||||||
- key: lifecycle_intent
|
- key: lifecycle_intent
|
||||||
label: 生命周期意图
|
label: 生命周期意图
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ feeds: gm,auditor
|
|||||||
4. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。
|
4. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。
|
||||||
5. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。
|
5. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。
|
||||||
6. `rule_id`:params 已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。
|
6. `rule_id`:params 已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。
|
||||||
|
若本步对象与 params 不一致(用户中途改了对象),以最新对象为准并重钉对应英文 rule_id,禁止沿用旧 id。
|
||||||
7. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。
|
7. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。
|
||||||
|
|
||||||
若程序已发默认问题:禁止重复开场;在首答上补洞。
|
若程序已发默认问题:禁止重复开场;在首答上补洞。
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ feeds: gm
|
|||||||
## opening
|
## opening
|
||||||
|
|
||||||
```opening
|
```opening
|
||||||
|
核心体验已有方向时,用几句话想想:它具体靠什么撑住?(想到什么写什么,不必整齐;点名即可,不必论证来源或补起源史。)
|
||||||
|
|
||||||
|
1. 这体验主要靠哪些东西成立?
|
||||||
|
角色的某一面、一段关系、世界条件、规则或情境……用你自己的词点名。
|
||||||
|
例:丈夫会把异常证据合理化;力量越用越侵蚀自我;村里每年必须献祭才能过冬。
|
||||||
|
|
||||||
|
2. 拿掉或改弱哪一样,核心感觉会明显变味甚至垮掉?
|
||||||
|
一句话即可——只点「缺了就不对」的,装饰和以后再展开的先别列。
|
||||||
|
|
||||||
|
3. 若已有具体画面或作品参照,它主要指哪一切面在撑体验?
|
||||||
|
例:信息差、无法撤销的前提、身份代价、循环反馈……不必复述整部设定。
|
||||||
```
|
```
|
||||||
|
|
||||||
## task
|
## task
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ feeds: narrator
|
|||||||
```opening
|
```opening
|
||||||
本局在固定呈现壳上做适配(不是设计新界面)。
|
本局在固定呈现壳上做适配(不是设计新界面)。
|
||||||
|
|
||||||
0. 七选一壳(主面积给谁):
|
0. 按「要同时看见什么」七选一:
|
||||||
· prose — 纯散文(正文≈全屏)
|
· prose — 只要正文
|
||||||
· chat_monitor — 对话 + 顶栏芯片
|
· chat_monitor — 正文 + 扫状态
|
||||||
· spotlight — 场面主视(正文最大)
|
· spotlight — 大正文 + 弱状态
|
||||||
· turn_panel — 回合面板(场面+侧栏交互)
|
· turn_panel — 场面 + 交互物 + 行动
|
||||||
· split_board — 双栏(左故事·右线索)
|
· split_board — 正文 ‖ 线索
|
||||||
· choice_dock — 选择坞(底部大行动区)
|
· choice_dock — 局面 + 选项
|
||||||
· chapter_reader — 章节阅读
|
· chapter_reader — 章正文 + 进度
|
||||||
|
|
||||||
1. 要微调什么?(监控字段、块称呼、要不要建议行动、tone_chrome…)
|
1. 要微调什么?(监控字段、块称呼、要不要建议行动、tone_chrome…)
|
||||||
2. 有没有隐藏段给模型/程序维护变量?
|
2. 有没有隐藏段给模型/程序维护变量?
|
||||||
|
|||||||
@@ -44,90 +44,9 @@ uiPrompt: |
|
|||||||
|
|
||||||
# 世界模拟器 · 总管
|
# 世界模拟器 · 总管
|
||||||
|
|
||||||
作者清单见 `docs/world-simulator-modules.md`。
|
你只负责读取 `设计.创作流程` DAG,并选择下一个创作节点:
|
||||||
|
|
||||||
你是 **总管**:负责 design / play 的 **调度**,不直接写正文。
|
- 没有 DAG 或 DAG 需要追加节点:`design-flow`
|
||||||
禁止默认把一切做成「世界模拟」;按用户意图正推最小能力组合。
|
- DAG 已给出下一个可执行节点:`design-step`
|
||||||
|
|
||||||
## 导演 · 能力 · 剧本
|
节点如何执行、何时等待、验收、返工、结束和运行状态变化均由 Runtime 与节点自身处理。
|
||||||
|
|
||||||
```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
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 扩写助手 · 配方
|
# 扩写助手 · 配方
|
||||||
# 写方法论:适用、核心思路、设计流程、原则。
|
# 写方法论:适用、核心思路、设计流程、原则。
|
||||||
# 各能力何时用 / 不用 → 读能力 meta(编排器会注入),勿在此重复。
|
# 各能力何时用 / 不用 → 读能力 meta(编排器会注入),勿在此重复。
|
||||||
# steps = 近期起点,不是固定全程 DAG。
|
# steps = 近期起点;选中本配方时程序写入「设计.创作流程」,开局即坐在第一步(不是固定全程 DAG)。
|
||||||
|
|
||||||
when: 大纲/分段扩写、写手统筹、先纲后章、成稿向助手、长文/爽文类体验
|
when: 大纲/分段扩写、写手统筹、先纲后章、成稿向助手、长文/爽文类体验
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 世界模拟器 · 配方
|
# 世界模拟器 · 配方
|
||||||
# 写方法论:适用、核心思路、设计流程、原则。
|
# 写方法论:适用、核心思路、设计流程、原则。
|
||||||
# 各技能何时用 / 不用 → 读技能 meta(编排器会注入),勿在此重复。
|
# 各技能何时用 / 不用 → 读技能 meta(编排器会注入),勿在此重复。
|
||||||
# steps = 近期起点,不是固定全程 DAG。
|
# steps = 近期起点;选中本配方时程序写入「设计.创作流程」,开局即坐在第一步(不是固定全程 DAG)。
|
||||||
|
|
||||||
when: 回合互动、世界推进、角色扮演、沉浸推演类体验
|
when: 回合互动、世界推进、角色扮演、沉浸推演类体验
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,12 @@ contextSegments:
|
|||||||
|
|
||||||
上下文里会有:
|
上下文里会有:
|
||||||
|
|
||||||
1. **【用户已选配方】**:该方法的适用、核心思路、设计流程与原则 + 近期起点 steps——**不是**锁死流水线;**禁止**替用户改选其它配方
|
1. **【流程进度】**(程序钉死):已完成哪些步、草案里已有哪些步、哪些能力才允许再编入。必须先读完再排。
|
||||||
2. **【能力 · 可选工序】**:固定中文名 + 声明 + **何时用 / 何时不用 / 边界**(来自各能力 meta)+(若有)编排参数——步骤只能从这里选;标〔可反复〕的可多次编入
|
2. **【用户已选配方】**:该方法的适用、核心思路、设计流程与原则 + 近期起点 steps——**不是**锁死流水线;**禁止**替用户改选其它配方
|
||||||
3. **【已有剧本草案】/【已验收步骤】**:若有,在其上追加或改未验收步,**不要**推倒重来
|
3. **【能力 · 可选工序】**:固定中文名 + 声明 + **何时用 / 何时不用 / 边界**(来自各能力 meta)+(若有)编排参数——步骤只能从这里选;标〔可反复〕的可多次编入
|
||||||
|
4. **【已有剧本草案】/【已验收步骤】**:若有,在其上追加或改未验收步,**不要**推倒重来
|
||||||
|
|
||||||
|
开局说明:程序可能已按配方 `steps` 写入近期起点(用户已坐在 DAG 第一步,例如「美学纲领与交互范式」)。此时**不要**把开局步再规划一遍;仅追加缺口。listed 步已齐仍需扩写时,保留已完成 id,只加新步。
|
||||||
|
|
||||||
## 增量 DAG(核心)
|
## 增量 DAG(核心)
|
||||||
|
|
||||||
@@ -56,9 +59,9 @@ contextSegments:
|
|||||||
|
|
||||||
- **跟配方方法论**:用配方的 core / process / principles 理解整局怎么设计、何时收成。
|
- **跟配方方法论**:用配方的 core / process / principles 理解整局怎么设计、何时收成。
|
||||||
- **跟能力 meta**:某步该不该排,看能力的「何时用 / 何时不用 / 边界」;**不要**在配方里找各能力调用条件,也不要凭题材默认全选。
|
- **跟能力 meta**:某步该不该排,看能力的「何时用 / 何时不用 / 边界」;**不要**在配方里找各能力调用条件,也不要凭题材默认全选。
|
||||||
- 有「编排参数」声明的能力:写入 DAG 前必须钉齐 **必填 params**。
|
- 有「编排参数」声明的能力:写入 DAG 时可先留空壳;**确认开干前**必须钉齐必填 params(用户在「下一步」意向后、点同意开始时补齐)。
|
||||||
- 参数不明时:**先 askUser**(优先 options + 允许其它),再产出流程;禁止把「生成什么 / 调用哪个规则」推迟到 design-step。
|
- 参数不明时:可先产出带空 params 的步骤,或 askUser;禁止把「生成什么 / 调用哪个规则」拖到 design-step 执行中途。
|
||||||
- 用户先认可带参数的 DAG 雏形,再进入执行;用户提出新要求时,再调本 worker 修订未验收步或追加新步。
|
- 用户先认可**下一节点**(含参数),再进入执行;用户提出新要求时,再调本 worker 修订未验收步或追加新步。
|
||||||
|
|
||||||
同能力**可以**多次出现(尤其〔可反复〕):每次一次调用、一次验收、产物写入同一 artifact(增量补全)。id 应尽量带上调用目标,如 `生成规则·怪物`,避免无信息的 `#2`。
|
同能力**可以**多次出现(尤其〔可反复〕):每次一次调用、一次验收、产物写入同一 artifact(增量补全)。id 应尽量带上调用目标,如 `生成规则·怪物`,避免无信息的 `#2`。
|
||||||
|
|
||||||
@@ -105,13 +108,20 @@ contextSegments:
|
|||||||
5. `params` = 本步调用参数(对象)。能力目录声明了编排参数时,**必填项必须写出**;可选则能推断就写
|
5. `params` = 本步调用参数(对象)。能力目录声明了编排参数时,**必填项必须写出**;可选则能推断就写
|
||||||
6. `status`:`"open"` = 还可能追加;`"closed"` = 不再扩步(可走收成)
|
6. `status`:`"open"` = 还可能追加;`"closed"` = 不再扩步(可走收成)
|
||||||
7. 已验收步骤的 id **必须保留**;只能追加新步,或改未验收步的依赖/顺序/params
|
7. 已验收步骤的 id **必须保留**;只能追加新步,或改未验收步的依赖/顺序/params
|
||||||
8. 配方建议 steps 只作近期起点;按能力 meta 按需选用,勿默认全选、勿一次排满
|
8. 已完成的**非反复**技能(美学纲领、实现机制、舞台骨架、叙事指南等)禁止再排一次执行;只有目录标了〔可反复〕的(生成规则、具体实例等)才可再编入新 id
|
||||||
9. **禁止**把「交互」与「美学」拆成两步
|
9. 配方建议 steps 只作近期起点;按能力 meta 按需选用,勿默认全选、勿一次排满
|
||||||
10. **禁止**在本步写能力正文、自由 Worker 列表、表结构
|
10. **禁止**把「交互」与「美学」拆成两步
|
||||||
11. **禁止**编排「反复发明执行单元」;收成链:确认「游玩拓扑」→(上下文已多时)「上下文投影排序」→「细化终稿」。挂谁可早、排第几须晚,勿让中段技能写死 order
|
11. **禁止**在本步写能力正文、自由 Worker 列表、表结构
|
||||||
12. 参数或选型不够时,用 askUser 问 1~2 点(**优先带 options,并允许其它**)
|
12. **禁止**编排「反复发明执行单元」;收成链:确认「游玩拓扑」→(上下文已多时)「上下文投影排序」→「细化终稿」。挂谁可早、排第几须晚,勿让中段技能写死 order
|
||||||
13. 依赖真实产物的步骤(如具体实例依赖某条生成规则)须等上游验收后再编排,并从产物中列出可选项供用户选
|
13. 参数或选型不够时,用 askUser 问 1~2 点(**优先带 options,并允许其它**)。
|
||||||
14. `summary`:`流程 · N 步 · open|closed · …`
|
只许问**排布层**的事:下一步排哪个能力、某步的必填 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 而不是产出空壳?
|
- 有编排参数的步骤,必填 params 是否已钉齐?缺参是否应先 askUser 而不是产出空壳?
|
||||||
- depends_on 是否都指向更靠前的步骤 id?
|
- depends_on 是否都指向更靠前的步骤 id?
|
||||||
- 已验收 id 是否都还在?
|
- 已验收 id 是否都还在?
|
||||||
|
- 【流程进度】里已完成的非反复技能,是否误当成新规划又排了一次?
|
||||||
|
- askUser 是否只问了排哪步 / 填哪个参数,而不是体验偏好等正文问题?
|
||||||
- 需要反复补同类内容时,是否用了新 id + 新 params 追加而非改写旧步?
|
- 需要反复补同类内容时,是否用了新 id + 新 params 追加而非改写旧步?
|
||||||
- 收成前是否把 `status` 设为 `closed`?
|
- 收成前是否把 `status` 设为 `closed`?
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ name: 创作 · 执行步骤
|
|||||||
description: >-
|
description: >-
|
||||||
按已认可的创作流程,执行当前一步工序。提示词与产物 tag 由程序按模块注入。
|
按已认可的创作流程,执行当前一步工序。提示词与产物 tag 由程序按模块注入。
|
||||||
流程是增量 DAG:本步只读,禁止自行扩步或重排。
|
流程是增量 DAG:本步只读,禁止自行扩步或重排。
|
||||||
|
outputTags 由程序注入为该步能力 artifact;勿把「创作.当前步骤」当产物写回。
|
||||||
version: 1
|
version: 1
|
||||||
stage: design
|
stage: design
|
||||||
inputTags:
|
inputTags:
|
||||||
@@ -15,8 +16,7 @@ inputTags:
|
|||||||
- "用户.修订说明"
|
- "用户.修订说明"
|
||||||
- "设计.创作流程"
|
- "设计.创作流程"
|
||||||
- "创作.当前步骤"
|
- "创作.当前步骤"
|
||||||
outputTags:
|
outputTags: []
|
||||||
- "创作.当前步骤"
|
|
||||||
inputMerge: latest
|
inputMerge: latest
|
||||||
contextSegments:
|
contextSegments:
|
||||||
- id: flow
|
- id: flow
|
||||||
@@ -46,7 +46,7 @@ contextSegments:
|
|||||||
1. 只写本步产物(程序指定的 output tag);不要改其它步骤产物
|
1. 只写本步产物(程序指定的 output tag);不要改其它步骤产物
|
||||||
2. 产物用简洁 JSON 或结构化中文,方便界面渲染;少写机器变量名
|
2. 产物用简洁 JSON 或结构化中文,方便界面渲染;少写机器变量名
|
||||||
3. 若本步有**默认问题**:程序已先发给用户;首答在「用户.worker答复」/「创作.能力开场白」。**禁止**再用 LLM 重复同一开场白
|
3. 若本步有**默认问题**:程序已先发给用户;首答在「用户.worker答复」/「创作.能力开场白」。**禁止**再用 LLM 重复同一开场白
|
||||||
4. 信息不足 → askUser 1~2 点(优先 options)
|
4. 信息不足:context-fragment 产物把题写在「追问」(1~2 点,建议选项 + 示例),顶层 askUser 为 null。仅完全无法产出时才用顶层 askUser
|
||||||
5. `summary`:`{本步能力名} · …`
|
5. `summary`:`{本步能力名} · …`
|
||||||
6. **禁止**重排或扩写流程;流程只读。需要追加「再来一次生成规则」等 → 由总管再调 design-flow
|
6. **禁止**重排或扩写流程;流程只读。需要追加「再来一次生成规则」等 → 由总管再调 design-flow
|
||||||
7. **本步参数**由编排期写入 steps[].params,程序会注入【本步参数】。按参数执行;禁止再问「这一步生成什么 / 调用哪个规则」。参数缺失或与目录必填项不符 → 停止产出,提示返回 design-flow 补参
|
7. **本步参数**由编排期写入 steps[].params,程序会注入【本步参数】。按参数执行;禁止再问「这一步生成什么 / 调用哪个规则」。参数缺失或与目录必填项不符 → 停止产出,提示返回 design-flow 补参
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
CompleteWithToolsResult,
|
CompleteWithToolsResult,
|
||||||
LlmProvider,
|
LlmProvider,
|
||||||
StreamCallbacks,
|
StreamCallbacks,
|
||||||
|
TokenUsage,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
import {
|
import {
|
||||||
recordTokenUsage,
|
recordTokenUsage,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
buildContextTrace,
|
buildContextTrace,
|
||||||
type LlmContextTrace,
|
type LlmContextTrace,
|
||||||
} from "../types/context-trace.js";
|
} from "../types/context-trace.js";
|
||||||
|
import { debugLog, labelCaller, labelLlmMode, labelTool } from "../log.js";
|
||||||
|
|
||||||
export type LlmTrackingContext = {
|
export type LlmTrackingContext = {
|
||||||
sessionId?: string;
|
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 {
|
export class TokenTrackingProvider implements LlmProvider {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly inner: LlmProvider,
|
private readonly inner: LlmProvider,
|
||||||
@@ -51,29 +124,21 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
messages: Parameters<LlmProvider["complete"]>[0],
|
messages: Parameters<LlmProvider["complete"]>[0],
|
||||||
options?: CompleteOptions,
|
options?: CompleteOptions,
|
||||||
): Promise<CompleteResult> {
|
): Promise<CompleteResult> {
|
||||||
const result = await this.inner.complete(messages, options);
|
return loggedLlm(
|
||||||
const ctx = this.getContext();
|
"complete",
|
||||||
capturePendingTrace(ctx, messages, options?.caller, result.model);
|
options?.caller,
|
||||||
if (result.usage) {
|
async () => {
|
||||||
const record = recordTokenUsage({
|
const result = await this.inner.complete(messages, options);
|
||||||
sessionId: ctx.sessionId,
|
const ctx = this.getContext();
|
||||||
bookId: ctx.bookId,
|
capturePendingTrace(ctx, messages, options?.caller, result.model);
|
||||||
bookTitle: ctx.bookTitle,
|
recordUsage(ctx, options?.caller, result.model, result.usage);
|
||||||
orchestratorId: ctx.orchestratorId,
|
if (result.reasoning?.trim()) {
|
||||||
caller: options?.caller ?? "unknown",
|
ctx.pendingReasoning = result.reasoning.trim();
|
||||||
model: result.model ?? "unknown",
|
}
|
||||||
promptTokens: result.usage.promptTokens,
|
return result;
|
||||||
completionTokens: result.usage.completionTokens,
|
},
|
||||||
totalTokens: result.usage.totalTokens,
|
summarizeComplete,
|
||||||
cachedTokens: result.usage.cachedTokens,
|
);
|
||||||
cacheMissTokens: result.usage.cacheMissTokens,
|
|
||||||
});
|
|
||||||
ctx.pendingUsage = toMessageTokenUsage(record);
|
|
||||||
}
|
|
||||||
if (result.reasoning?.trim()) {
|
|
||||||
ctx.pendingReasoning = result.reasoning.trim();
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async completeStream(
|
async completeStream(
|
||||||
@@ -88,67 +153,51 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
if (result.content) callbacks.onContentDelta?.(result.content);
|
if (result.content) callbacks.onContentDelta?.(result.content);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
let reasoningBuf = "";
|
return loggedLlm(
|
||||||
const result = await inner.completeStream(messages, options, {
|
"stream",
|
||||||
onReasoningDelta: (delta) => {
|
options?.caller,
|
||||||
reasoningBuf += delta;
|
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();
|
const ctx = this.getContext();
|
||||||
ctx.pendingReasoning = reasoningBuf;
|
capturePendingTrace(ctx, messages, options?.caller, result.model);
|
||||||
callbacks.onReasoningDelta?.(delta);
|
recordUsage(ctx, options?.caller, result.model, result.usage);
|
||||||
|
if (result.reasoning?.trim()) {
|
||||||
|
ctx.pendingReasoning = result.reasoning.trim();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
onContentDelta: callbacks.onContentDelta,
|
summarizeComplete,
|
||||||
});
|
);
|
||||||
const ctx = this.getContext();
|
|
||||||
capturePendingTrace(ctx, messages, options?.caller, result.model);
|
|
||||||
if (result.usage) {
|
|
||||||
const record = recordTokenUsage({
|
|
||||||
sessionId: ctx.sessionId,
|
|
||||||
bookId: ctx.bookId,
|
|
||||||
bookTitle: ctx.bookTitle,
|
|
||||||
orchestratorId: ctx.orchestratorId,
|
|
||||||
caller: options?.caller ?? "unknown",
|
|
||||||
model: result.model ?? "unknown",
|
|
||||||
promptTokens: result.usage.promptTokens,
|
|
||||||
completionTokens: result.usage.completionTokens,
|
|
||||||
totalTokens: result.usage.totalTokens,
|
|
||||||
cachedTokens: result.usage.cachedTokens,
|
|
||||||
cacheMissTokens: result.usage.cacheMissTokens,
|
|
||||||
});
|
|
||||||
ctx.pendingUsage = toMessageTokenUsage(record);
|
|
||||||
}
|
|
||||||
if (result.reasoning?.trim()) {
|
|
||||||
ctx.pendingReasoning = result.reasoning.trim();
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async completeWithTools(
|
async completeWithTools(
|
||||||
messages: Parameters<LlmProvider["completeWithTools"]>[0],
|
messages: Parameters<LlmProvider["completeWithTools"]>[0],
|
||||||
options: CompleteWithToolsOptions,
|
options: CompleteWithToolsOptions,
|
||||||
): Promise<CompleteWithToolsResult> {
|
): Promise<CompleteWithToolsResult> {
|
||||||
const result = await this.inner.completeWithTools(messages, options);
|
return loggedLlm(
|
||||||
const ctx = this.getContext();
|
"tools",
|
||||||
capturePendingTrace(ctx, messages, options.caller, result.model);
|
options.caller,
|
||||||
if (result.usage) {
|
async () => {
|
||||||
const record = recordTokenUsage({
|
const result = await this.inner.completeWithTools(messages, options);
|
||||||
sessionId: ctx.sessionId,
|
const ctx = this.getContext();
|
||||||
bookId: ctx.bookId,
|
capturePendingTrace(ctx, messages, options.caller, result.model);
|
||||||
bookTitle: ctx.bookTitle,
|
recordUsage(ctx, options.caller, result.model, result.usage);
|
||||||
orchestratorId: ctx.orchestratorId,
|
if (result.reasoning?.trim()) {
|
||||||
caller: options.caller ?? "unknown",
|
ctx.pendingReasoning = result.reasoning.trim();
|
||||||
model: result.model ?? "unknown",
|
}
|
||||||
promptTokens: result.usage.promptTokens,
|
return result;
|
||||||
completionTokens: result.usage.completionTokens,
|
},
|
||||||
totalTokens: result.usage.totalTokens,
|
summarizeTools,
|
||||||
cachedTokens: result.usage.cachedTokens,
|
);
|
||||||
cacheMissTokens: result.usage.cacheMissTokens,
|
|
||||||
});
|
|
||||||
ctx.pendingUsage = toMessageTokenUsage(record);
|
|
||||||
}
|
|
||||||
if (result.reasoning?.trim()) {
|
|
||||||
ctx.pendingReasoning = result.reasoning.trim();
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async completeWithToolsStream(
|
async completeWithToolsStream(
|
||||||
@@ -160,37 +209,29 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
if (!inner.completeWithToolsStream) {
|
if (!inner.completeWithToolsStream) {
|
||||||
return this.completeWithTools(messages, options);
|
return this.completeWithTools(messages, options);
|
||||||
}
|
}
|
||||||
let reasoningBuf = "";
|
return loggedLlm(
|
||||||
const result = await inner.completeWithToolsStream(messages, options, {
|
"tools-stream",
|
||||||
onReasoningDelta: (delta) => {
|
options.caller,
|
||||||
reasoningBuf += delta;
|
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();
|
const ctx = this.getContext();
|
||||||
ctx.pendingReasoning = reasoningBuf;
|
capturePendingTrace(ctx, messages, options.caller, result.model);
|
||||||
callbacks.onReasoningDelta?.(delta);
|
recordUsage(ctx, options.caller, result.model, result.usage);
|
||||||
|
if (result.reasoning?.trim()) {
|
||||||
|
ctx.pendingReasoning = result.reasoning.trim();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
onContentDelta: callbacks.onContentDelta,
|
summarizeTools,
|
||||||
});
|
);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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")
|
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
|
||||||
: "- (当前 skill 未加载 worker 列表)";
|
: "- (当前 skill 未加载 worker 列表)";
|
||||||
|
|
||||||
return `你是写作系统的编排器(Main Agent)。你的职责是调度执行单元(worker),而不是直接创作正文。
|
return `你是创作节点路由器。只读取「设计.创作流程」DAG,并选择下一个要执行的创作节点。
|
||||||
|
|
||||||
规则:
|
- DAG 不存在或需要追加节点:调用 design-flow。
|
||||||
1. 你不能直接生成小说/文章正文。
|
- DAG 中已有下一个可执行节点:调用 design-step。
|
||||||
2. 你不能修改运行状态;statePatchAllowed 必须始终为 false。
|
- 只调度 worker,不创作正文,不规划 DAG 之外的流程。
|
||||||
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」
|
|
||||||
|
|
||||||
当前 skill 可用 worker(workerId 必须与下列 id 完全一致):
|
当前 skill 可用 worker(workerId 必须与下列 id 完全一致):
|
||||||
${workerLines}
|
${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 对象,字段:
|
输出必须是 JSON 对象,字段:
|
||||||
{
|
{
|
||||||
"action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish",
|
"action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish",
|
||||||
@@ -110,40 +77,21 @@ export class MainAgent {
|
|||||||
|
|
||||||
export function buildMainAgentUserPrompt(context: MainAgentContext): string {
|
export function buildMainAgentUserPrompt(context: MainAgentContext): string {
|
||||||
const { session, blackboardIndex, availableWorkers } = context;
|
const { session, blackboardIndex, availableWorkers } = context;
|
||||||
const stage = String(session.slots?.uiLifecycleStage ?? "design");
|
const creationTags = new Set([
|
||||||
const pendingSide = session.slots?.pendingSideEffectWorkers;
|
"设计.创作流程",
|
||||||
|
"创作.当前步骤",
|
||||||
|
"创作.当前单位",
|
||||||
|
"创作.已验收单位",
|
||||||
|
"用户.下一步意向",
|
||||||
|
]);
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
{
|
{
|
||||||
runtimePhase: session.phase,
|
|
||||||
waitingReason: session.waitingReason,
|
|
||||||
lifecycleStage: stage,
|
|
||||||
flowId: session.flowId,
|
|
||||||
currentStepId: session.currentStepId,
|
currentStepId: session.currentStepId,
|
||||||
currentWorkerId: session.currentWorkerId,
|
creationDagTags: blackboardIndex.filter((entry) =>
|
||||||
acceptanceMode: session.acceptanceMode,
|
creationTags.has(entry.tag),
|
||||||
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,
|
|
||||||
availableWorkers,
|
availableWorkers,
|
||||||
instruction:
|
instruction: "读取创作 DAG,选择下一个创作节点。",
|
||||||
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 由用户手动。",
|
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { isMainAgentTerminalTool } from "../types/tools.js";
|
|||||||
import { MAIN_AGENT_TOOL_DEFINITIONS } from "./tools.js";
|
import { MAIN_AGENT_TOOL_DEFINITIONS } from "./tools.js";
|
||||||
import { buildMainAgentUserPrompt, parseMainAgentDecision } from "./main-agent.js";
|
import { buildMainAgentUserPrompt, parseMainAgentDecision } from "./main-agent.js";
|
||||||
import type { MainAgentContext } from "./main-agent.js";
|
import type { MainAgentContext } from "./main-agent.js";
|
||||||
|
import { debugLog } from "../log.js";
|
||||||
|
|
||||||
export type ToolLoopHandlers = {
|
export type ToolLoopHandlers = {
|
||||||
readBlackboard: (tags: string[]) => Record<string, string>;
|
readBlackboard: (tags: string[]) => Record<string, string>;
|
||||||
@@ -20,7 +21,7 @@ export type ToolLoopHandlers = {
|
|||||||
onToolCall?: (name: string, detail: string) => void;
|
onToolCall?: (name: string, detail: string) => void;
|
||||||
/** 思维链 / reasoning 流式增量(供 UI 实时展示) */
|
/** 思维链 / reasoning 流式增量(供 UI 实时展示) */
|
||||||
onThinkingDelta?: (delta: string) => void;
|
onThinkingDelta?: (delta: string) => void;
|
||||||
/** 一轮 LLM 调用结束后的完整思考文本 */
|
/** 一轮 LLM 调用结束后的完整思维链(不含正文) */
|
||||||
onThinkingDone?: (text: string) => void;
|
onThinkingDone?: (text: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,71 +41,17 @@ function buildToolLoopSystemPrompt(
|
|||||||
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
|
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
|
||||||
: "- (当前 skill 未加载 worker 列表)";
|
: "- (当前 skill 未加载 worker 列表)";
|
||||||
|
|
||||||
return `你是写作系统的总管(Main Agent)。你在 running 相位通过 **tool call** 推进流程。
|
return `你是创作节点路由器。只根据「设计.创作流程」DAG 选择下一个创作节点。
|
||||||
|
|
||||||
## 能力边界
|
1. 用 read_blackboard 读取「设计.创作流程」及其进度 tag。
|
||||||
1. 你不能直接生成小说/文章正文。
|
2. 没有 DAG 或 DAG 需要追加节点时,run_worker(design-flow)。
|
||||||
2. 你不能修改运行状态。
|
3. DAG 已给出下一个可执行节点时,run_worker(design-step)。
|
||||||
3. 先用 read_blackboard / list_workers / list_artifacts 收集信息,再决定下一步。
|
4. 不创作正文,不改写 DAG,不处理运行状态或游玩流程。
|
||||||
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;旁观维护默认空操作,不是正文作者。
|
|
||||||
|
|
||||||
当前 skill 可用 worker:
|
当前 skill 可用 worker:
|
||||||
${workerLines}
|
${workerLines}
|
||||||
|
|
||||||
信息不足时可多次 read_blackboard;一旦调用终止 tool,本轮循环结束。`;
|
选定节点后立即调用 run_worker。`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function completeToolsPreferStream(
|
async function completeToolsPreferStream(
|
||||||
@@ -172,14 +119,9 @@ function assistantMessageFromToolCalls(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitThinkingDone(handlers: ToolLoopHandlers, parts: {
|
function emitThinkingDone(handlers: ToolLoopHandlers, reasoning?: string): void {
|
||||||
reasoning?: string;
|
const text = reasoning?.trim() ?? "";
|
||||||
content?: string | null;
|
if (text) handlers.onThinkingDone?.(text);
|
||||||
}): 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -202,12 +144,12 @@ export async function runMainAgentToolLoop(
|
|||||||
const toolTrace: string[] = [];
|
const toolTrace: string[] = [];
|
||||||
const streamCallbacks: StreamCallbacks = {
|
const streamCallbacks: StreamCallbacks = {
|
||||||
onReasoningDelta: (delta) => handlers.onThinkingDelta?.(delta),
|
onReasoningDelta: (delta) => handlers.onThinkingDelta?.(delta),
|
||||||
onContentDelta: (delta) => handlers.onThinkingDelta?.(delta),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let iteration = 1; iteration <= MAX_TOOL_LOOP_ITERATIONS; iteration++) {
|
for (let iteration = 1; iteration <= MAX_TOOL_LOOP_ITERATIONS; iteration++) {
|
||||||
|
debugLog("llm", `总管循环 第${iteration}轮`);
|
||||||
const result = await completeToolsPreferStream(llm, messages, streamCallbacks);
|
const result = await completeToolsPreferStream(llm, messages, streamCallbacks);
|
||||||
emitThinkingDone(handlers, result);
|
emitThinkingDone(handlers, result.reasoning);
|
||||||
|
|
||||||
if (result.toolCalls.length === 0) {
|
if (result.toolCalls.length === 0) {
|
||||||
if (result.content?.trim()) {
|
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",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: "run_worker",
|
name: "run_worker",
|
||||||
description:
|
description: "执行选中的下一个创作节点。",
|
||||||
"调度 worker。常规只传 workerId。调度 chance(机遇裁定)时传 chance 请求对象(程序掷骰/比点/抽签,禁止让模型编随机)。",
|
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -143,50 +36,10 @@ export const MAIN_AGENT_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
description: "true 时需用户确认后才执行",
|
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"],
|
required: ["workerId", "reason", "requiresApproval"],
|
||||||
additionalProperties: false,
|
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,
|
readIntakeValues,
|
||||||
synthesizeDemandText,
|
synthesizeDemandText,
|
||||||
} from "../intake/intake.js";
|
} 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";
|
import type { QuestionItem } from "../types/questions.js";
|
||||||
|
|
||||||
function nowIso(): string {
|
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 状态的新会话 */
|
/** 创建 idle 状态的新会话 */
|
||||||
export function createSession(
|
export function createSession(
|
||||||
presetId: string,
|
presetId: string,
|
||||||
@@ -154,6 +218,7 @@ export function getAllowedEvents(
|
|||||||
"worker_started",
|
"worker_started",
|
||||||
"worker_completed",
|
"worker_completed",
|
||||||
"worker_needs_input",
|
"worker_needs_input",
|
||||||
|
"worker_revision_produced_nothing",
|
||||||
"programmatic_review_started",
|
"programmatic_review_started",
|
||||||
"programmatic_review_passed",
|
"programmatic_review_passed",
|
||||||
"programmatic_review_failed",
|
"programmatic_review_failed",
|
||||||
@@ -204,7 +269,8 @@ export function canApplyEvent(
|
|||||||
reason?.kind === "intake" ||
|
reason?.kind === "intake" ||
|
||||||
reason?.kind === "input" ||
|
reason?.kind === "input" ||
|
||||||
reason?.kind === "worker_questions" ||
|
reason?.kind === "worker_questions" ||
|
||||||
reason?.kind === "revision"
|
reason?.kind === "revision" ||
|
||||||
|
reason?.kind === "next_intent"
|
||||||
);
|
);
|
||||||
case "user_confirmed_intake":
|
case "user_confirmed_intake":
|
||||||
return reason?.kind === "intake";
|
return reason?.kind === "intake";
|
||||||
@@ -256,6 +322,14 @@ function handleWorkerCompleted(
|
|||||||
const mode = session.acceptanceMode ?? "user_confirmed";
|
const mode = session.acceptanceMode ?? "user_confirmed";
|
||||||
let next = appendHistory(session, event);
|
let next = appendHistory(session, event);
|
||||||
|
|
||||||
|
if (next.slots.revisionTargetArtifactId) {
|
||||||
|
// 已写出新版,旧底稿不再是退回目标
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
slots: { ...next.slots, revisionTargetArtifactId: undefined },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === "user_confirmed") {
|
if (mode === "user_confirmed") {
|
||||||
next = updateArtifact(next, artifact.id, { status: "under_review" });
|
next = updateArtifact(next, artifact.id, { status: "under_review" });
|
||||||
return {
|
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") {
|
if (session.waitingReason?.kind === "worker_questions") {
|
||||||
slots["用户.worker答复"] = text;
|
slots["用户.worker答复"] = text;
|
||||||
if (text) slots["用户.最新输入"] = text;
|
if (text) slots["用户.最新输入"] = text;
|
||||||
@@ -660,6 +790,7 @@ export function applyEvent(
|
|||||||
acceptanceMode: session.acceptanceMode ?? "user_confirmed",
|
acceptanceMode: session.acceptanceMode ?? "user_confirmed",
|
||||||
questions: normalized,
|
questions: normalized,
|
||||||
};
|
};
|
||||||
|
const openingAsk = isModuleOpeningQuestions(normalized);
|
||||||
return {
|
return {
|
||||||
session: waiting(
|
session: waiting(
|
||||||
appendHistory(
|
appendHistory(
|
||||||
@@ -675,7 +806,10 @@ export function applyEvent(
|
|||||||
effects: [
|
effects: [
|
||||||
{
|
{
|
||||||
type: "emit_message",
|
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"
|
typeof event.payload.assessment === "string"
|
||||||
? event.payload.assessment.trim()
|
? event.payload.assessment.trim()
|
||||||
: "";
|
: "";
|
||||||
const effects = [...result.effects];
|
// 追问与产物同一次产出:只挂 review_artifact,勿再 emit 独立「可选追问」消息
|
||||||
if (sidecar?.length) {
|
//(否则历史里会拆成 worker_output + worker_questions 两段)
|
||||||
effects.push({
|
|
||||||
type: "emit_message",
|
|
||||||
message: `[Worker] 可选追问(可跳过,直接接受目前产物):\n${sidecar.map((q) => `- ${q.prompt}`).join("\n")}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
effects,
|
|
||||||
session: waiting(result.session, {
|
session: waiting(result.session, {
|
||||||
kind: "review_artifact",
|
kind: "review_artifact",
|
||||||
artifactId: result.session.pendingArtifactId,
|
artifactId: result.session.pendingArtifactId,
|
||||||
@@ -711,6 +839,42 @@ export function applyEvent(
|
|||||||
return result;
|
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": {
|
case "user_resolved_sidecar_questions": {
|
||||||
const reason = session.waitingReason;
|
const reason = session.waitingReason;
|
||||||
if (reason?.kind !== "review_artifact") {
|
if (reason?.kind !== "review_artifact") {
|
||||||
@@ -745,22 +909,43 @@ export function applyEvent(
|
|||||||
}
|
}
|
||||||
const slots: Record<string, unknown> = { ...session.slots };
|
const slots: Record<string, unknown> = { ...session.slots };
|
||||||
// 仅终稿 tag「设计.worker集」表示完整 Worker 集验收;草稿单位验收不得开 play
|
// 仅终稿 tag「设计.worker集」表示完整 Worker 集验收;草稿单位验收不得开 play
|
||||||
if (artifact.outputTags.some((tag) => tag === "设计.worker集")) {
|
const isFinalSet = artifact.outputTags.some((tag) => tag === "设计.worker集");
|
||||||
|
if (isFinalSet) {
|
||||||
slots.designInstanceReady = true;
|
slots.designInstanceReady = true;
|
||||||
}
|
}
|
||||||
const next = appendHistory(
|
const next = appendHistory(
|
||||||
updateArtifact(session, artifact.id, { status: "accepted" }),
|
updateArtifact(session, artifact.id, { status: "accepted" }),
|
||||||
event,
|
event,
|
||||||
);
|
);
|
||||||
return {
|
const cleared = {
|
||||||
session: touch(
|
...next,
|
||||||
running({
|
slots,
|
||||||
...next,
|
pendingArtifactId: undefined,
|
||||||
slots,
|
pendingDecision: undefined,
|
||||||
pendingArtifactId: undefined,
|
currentWorkerId: undefined,
|
||||||
pendingDecision: 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" }],
|
effects: [{ type: "invoke_main_agent" }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -770,19 +955,21 @@ export function applyEvent(
|
|||||||
if (!artifact) {
|
if (!artifact) {
|
||||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
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(
|
const next = appendHistory(
|
||||||
updateArtifact(session, artifact.id, { status: "rejected" }),
|
updateArtifact(session, artifact.id, { status: "rejected" }),
|
||||||
event,
|
event,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
session: waiting(next, {
|
session: waiting(next, { kind: "revision" }),
|
||||||
kind: "revision",
|
|
||||||
instruction: event.payload.reason,
|
|
||||||
}),
|
|
||||||
effects: [
|
effects: [
|
||||||
{
|
{
|
||||||
type: "emit_message",
|
type: "emit_message",
|
||||||
message: event.payload.reason ?? "产物已被拒绝,等待修改指示。",
|
message: "已保留当前产物,请说明要改哪里。",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -800,15 +987,26 @@ export function applyEvent(
|
|||||||
effects: [{ type: "invoke_main_agent" }],
|
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 {
|
return {
|
||||||
session: waiting(
|
session: waiting(
|
||||||
appendHistory({ ...session, pendingArtifactId: undefined }, event),
|
appendHistory(session, event),
|
||||||
{ kind: "revision", instruction: event.payload.instruction },
|
{ kind: "revision", instruction: instruction || undefined },
|
||||||
),
|
),
|
||||||
effects: [
|
effects: [
|
||||||
{
|
{
|
||||||
type: "emit_message",
|
type: "emit_message",
|
||||||
message: `返工请求:${event.payload.instruction}`,
|
message: instruction
|
||||||
|
? `修改意见:${instruction}`
|
||||||
|
: "请说明要改哪里(在现有产物上改)。",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -840,20 +1038,16 @@ export function applyEvent(
|
|||||||
if (!artifact) {
|
if (!artifact) {
|
||||||
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
|
||||||
}
|
}
|
||||||
const next = appendHistory(
|
const reason = event.payload.reason?.trim() || "程序验收失败";
|
||||||
updateArtifact(session, artifact.id, { status: "revision_requested" }),
|
const result = beginRevisionRerun(session, event, artifact, reason);
|
||||||
event,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
session: waiting(next, {
|
...result,
|
||||||
kind: "revision",
|
|
||||||
instruction: event.payload.reason,
|
|
||||||
}),
|
|
||||||
effects: [
|
effects: [
|
||||||
{
|
{
|
||||||
type: "emit_message",
|
type: "emit_message",
|
||||||
message: `程序验收失败:${event.payload.reason}`,
|
message: `程序验收未过,按意见在现有产物上改:${reason}`,
|
||||||
},
|
},
|
||||||
|
...result.effects,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,18 +57,36 @@ import {
|
|||||||
import {
|
import {
|
||||||
CREATION_FLOW_TAG,
|
CREATION_FLOW_TAG,
|
||||||
CREATION_CURRENT_STEP_TAG,
|
CREATION_CURRENT_STEP_TAG,
|
||||||
|
CREATION_PROPOSED_STEP_TAG,
|
||||||
CREATION_MODULE_OPENING_TAG,
|
CREATION_MODULE_OPENING_TAG,
|
||||||
CREATION_MODULE_OPENING_STATE_TAG,
|
CREATION_MODULE_OPENING_STATE_TAG,
|
||||||
SLOT_CREATION_MODULE_OPENING_STATE,
|
SLOT_CREATION_MODULE_OPENING_STATE,
|
||||||
|
loadModuleCatalog,
|
||||||
|
mergeCreationFlowPreservingAccepted,
|
||||||
nextPendingStep,
|
nextPendingStep,
|
||||||
parseCreationFlow,
|
parseCreationFlow,
|
||||||
parseModuleOpeningState,
|
parseModuleOpeningState,
|
||||||
|
patchCreationFlowStepParams,
|
||||||
|
pickRecordedStepId,
|
||||||
resolveDesignStepBinding,
|
resolveDesignStepBinding,
|
||||||
|
shouldSkipModuleOpening,
|
||||||
|
isProgressPointerTag,
|
||||||
stepUnitId,
|
stepUnitId,
|
||||||
|
stringifyCreationFlow,
|
||||||
stringifyModuleOpeningState,
|
stringifyModuleOpeningState,
|
||||||
|
type CreationFlowStepParams,
|
||||||
|
type ModuleParamSpec,
|
||||||
} from "../skills/creation-flow.js";
|
} 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 { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
|
||||||
|
import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js";
|
||||||
import {
|
import {
|
||||||
executeChance,
|
executeChance,
|
||||||
resolveChanceRequest,
|
resolveChanceRequest,
|
||||||
@@ -363,10 +381,18 @@ export class PhaseRuntime {
|
|||||||
return this.session;
|
return this.session;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 统一事件入口:applyEvent + 更新 session + 处理 effects + 可选总管 LLM */
|
/** applyEvent 并打状态日志(dispatch / 总管决策共用) */
|
||||||
async dispatch(event: RuntimeEvent): Promise<ApplyEventResult> {
|
private commitEvent(event: RuntimeEvent): ApplyEventResult {
|
||||||
|
const before = sessionSnap(this.session);
|
||||||
const result = applyEvent(this.session, event);
|
const result = applyEvent(this.session, event);
|
||||||
this.session = result.session;
|
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 (
|
if (
|
||||||
event.type === "user_submitted_input" ||
|
event.type === "user_submitted_input" ||
|
||||||
@@ -416,14 +442,26 @@ export class PhaseRuntime {
|
|||||||
source: "user",
|
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()) {
|
if (typeof revision === "string" && revision.trim()) {
|
||||||
this.blackboard.write({
|
this.blackboard.write({
|
||||||
tag: "用户.修改说明",
|
tag: "用户.修订说明",
|
||||||
content: revision.trim(),
|
content: revision.trim(),
|
||||||
source: "user",
|
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;
|
return this.session;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户确认 pendingDecision(approve_step) */
|
/** 用户确认 pendingDecision(approve_step);可顺带钉死下一步 params */
|
||||||
async approve(): Promise<RuntimeSession> {
|
async approve(stepParams?: CreationFlowStepParams): Promise<RuntimeSession> {
|
||||||
const id = this.session.pendingDecision?.id;
|
const id = this.session.pendingDecision?.id;
|
||||||
if (!id) throw new Error("当前没有待确认的决策");
|
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 } });
|
await this.dispatch({ type: "user_approved_next_step", payload: { decisionId: id } });
|
||||||
return this.session;
|
return this.session;
|
||||||
}
|
}
|
||||||
@@ -577,7 +620,7 @@ export class PhaseRuntime {
|
|||||||
return this.session;
|
return this.session;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户拒绝 pendingArtifact,进入 revision */
|
/** 用户拒绝 pendingArtifact:有意见则立刻按「用户.修订说明」在现有产物上改 */
|
||||||
async rejectArtifact(reason?: string, artifactId?: string): Promise<RuntimeSession> {
|
async rejectArtifact(reason?: string, artifactId?: string): Promise<RuntimeSession> {
|
||||||
const id = artifactId ?? this.session.pendingArtifactId;
|
const id = artifactId ?? this.session.pendingArtifactId;
|
||||||
if (!id) throw new Error("当前没有待拒绝的产物");
|
if (!id) throw new Error("当前没有待拒绝的产物");
|
||||||
@@ -588,12 +631,39 @@ export class PhaseRuntime {
|
|||||||
return this.session;
|
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 能力时,由外层触发此事件(仅无产物时阻塞) */
|
/** worker 调用 ask_user 能力时,由外层触发此事件(仅无产物时阻塞) */
|
||||||
async workerAsk(
|
async workerAsk(
|
||||||
questions: import("../types/questions.js").QuestionItem[] | string[],
|
questions: import("../types/questions.js").QuestionItem[] | string[],
|
||||||
): Promise<RuntimeSession> {
|
): Promise<RuntimeSession> {
|
||||||
const workerId = this.session.currentWorkerId;
|
const workerId = this.session.currentWorkerId;
|
||||||
if (!workerId) throw new Error("当前没有运行中的 worker");
|
if (!workerId) throw new Error("当前没有运行中的 worker");
|
||||||
|
this.rememberAskedQuestions(normalizeQuestions(questions));
|
||||||
await this.dispatch({
|
await this.dispatch({
|
||||||
type: "worker_needs_input",
|
type: "worker_needs_input",
|
||||||
payload: { workerId, stepId: this.session.currentStepId, questions },
|
payload: { workerId, stepId: this.session.currentStepId, questions },
|
||||||
@@ -601,6 +671,35 @@ export class PhaseRuntime {
|
|||||||
return this.session;
|
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。
|
* 验收态下作答或跳过挂载追问:不离开 review_artifact。
|
||||||
* answersText 缺省 = 跳过。
|
* answersText 缺省 = 跳过。
|
||||||
@@ -662,6 +761,13 @@ export class PhaseRuntime {
|
|||||||
/** 消费 applyEvent 返回的 PhaseEffect 列表 */
|
/** 消费 applyEvent 返回的 PhaseEffect 列表 */
|
||||||
private async processEffects(effects: PhaseEffect[]): Promise<void> {
|
private async processEffects(effects: PhaseEffect[]): Promise<void> {
|
||||||
for (const effect of effects) {
|
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) {
|
switch (effect.type) {
|
||||||
case "emit_message":
|
case "emit_message":
|
||||||
this.onMessage(effect.message);
|
this.onMessage(effect.message);
|
||||||
@@ -705,10 +811,209 @@ export class PhaseRuntime {
|
|||||||
case "run_programmatic_review":
|
case "run_programmatic_review":
|
||||||
await this.runProgrammaticReview(effect.artifactId);
|
await this.runProgrammaticReview(effect.artifactId);
|
||||||
break;
|
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() {
|
private getInstanceWorkerDeclaration() {
|
||||||
return buildInstanceWorkerDeclaration(
|
return buildInstanceWorkerDeclaration(
|
||||||
this.session,
|
this.session,
|
||||||
@@ -840,24 +1145,50 @@ export class PhaseRuntime {
|
|||||||
|
|
||||||
if (result.askUser?.length && Object.keys(result.outputs).length === 0) {
|
if (result.askUser?.length && Object.keys(result.outputs).length === 0) {
|
||||||
// 无产物:阻塞追问(信息不足,必须补)
|
// 无产物:阻塞追问(信息不足,必须补)
|
||||||
|
if (await this.restoreRevisionTargetForReview(workerId, result.askUser)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.workerAsk(result.askUser);
|
await this.workerAsk(result.askUser);
|
||||||
return;
|
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 };
|
slots = { ...this.session.slots };
|
||||||
|
const writtenTags: string[] = [];
|
||||||
for (const [tag, content] of Object.entries(result.outputs)) {
|
for (const [tag, content] of Object.entries(result.outputs)) {
|
||||||
|
if (isProgressPointerTag(tag)) continue;
|
||||||
const written = this.writeWorkerTagContent(tag, content, workerId);
|
const written = this.writeWorkerTagContent(tag, content, workerId);
|
||||||
slots[tag] = written;
|
slots[tag] = written;
|
||||||
|
writtenTags.push(tag);
|
||||||
}
|
}
|
||||||
this.session = { ...this.session, slots };
|
this.session = { ...this.session, slots };
|
||||||
|
|
||||||
const artifact = createArtifact({
|
const artifact = createArtifact({
|
||||||
workerId: effect.workerId,
|
workerId: effect.workerId,
|
||||||
stepId: this.session.currentStepId,
|
stepId: this.session.currentStepId,
|
||||||
outputTags:
|
outputTags: (writtenTags.length > 0 ? writtenTags : worker.outputTags).filter(
|
||||||
Object.keys(result.outputs).length > 0
|
(t) => !isProgressPointerTag(t),
|
||||||
? Object.keys(result.outputs)
|
),
|
||||||
: worker.outputTags,
|
|
||||||
summary: result.summary,
|
summary: result.summary,
|
||||||
});
|
});
|
||||||
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
|
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
|
||||||
@@ -871,7 +1202,7 @@ export class PhaseRuntime {
|
|||||||
payload: {
|
payload: {
|
||||||
artifactId: artifact.id,
|
artifactId: artifact.id,
|
||||||
// 有产物时 askUser 挂到验收态,不阻断 Accept
|
// 有产物时 askUser 挂到验收态,不阻断 Accept
|
||||||
questions: result.askUser?.length ? result.askUser : undefined,
|
questions: this.freshSidecarQuestions(result.askUser),
|
||||||
assessment: result.askAssessment?.trim() || undefined,
|
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({
|
this.blackboard.write({
|
||||||
tag,
|
tag,
|
||||||
content: toWrite,
|
content: toWrite,
|
||||||
@@ -1252,6 +1599,48 @@ export class PhaseRuntime {
|
|||||||
|
|
||||||
if (phase === "answered") return false;
|
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) {
|
if (phase === "shown" && this.session.resumeContext) {
|
||||||
// 用户刚答完默认问题 → 标记已答,继续走 LLM
|
// 用户刚答完默认问题 → 标记已答,继续走 LLM
|
||||||
state[stepKey] = "answered";
|
state[stepKey] = "answered";
|
||||||
@@ -1315,15 +1704,13 @@ export class PhaseRuntime {
|
|||||||
|
|
||||||
const questions = normalizeQuestions([
|
const questions = normalizeQuestions([
|
||||||
{
|
{
|
||||||
id: "module-opening",
|
id: MODULE_OPENING_QUESTION_ID,
|
||||||
prompt: binding.opening.trim(),
|
prompt: binding.opening.trim(),
|
||||||
allowOther: true,
|
allowOther: true,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
this.onMessage(
|
// 正文由说话面 openingGuide 展示(对齐美学纲领);workerAsk 只发短调度句
|
||||||
`[Worker] ${binding.module.name} · 默认问题(程序):\n${binding.opening.trim()}`,
|
|
||||||
);
|
|
||||||
await this.workerAsk(questions);
|
await this.workerAsk(questions);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1356,6 +1743,8 @@ export class PhaseRuntime {
|
|||||||
|
|
||||||
if (!current) current = "flow";
|
if (!current) current = "flow";
|
||||||
|
|
||||||
|
debugLog("step", `钉住当前步骤 ${workerId} → ${current}`);
|
||||||
|
|
||||||
const anchorAt = new Date().toISOString();
|
const anchorAt = new Date().toISOString();
|
||||||
this.blackboard.write({
|
this.blackboard.write({
|
||||||
tag: CREATION_CURRENT_UNIT_TAG,
|
tag: CREATION_CURRENT_UNIT_TAG,
|
||||||
@@ -1386,17 +1775,29 @@ export class PhaseRuntime {
|
|||||||
const fromSlot = String(slots[SLOT_CREATION_CURRENT_UNIT] ?? "").trim();
|
const fromSlot = String(slots[SLOT_CREATION_CURRENT_UNIT] ?? "").trim();
|
||||||
const fromStep = this.blackboard.getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim();
|
const fromStep = this.blackboard.getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim();
|
||||||
const fromSummary = artifact.summary?.match(/单位\s+([^\s·]+)/)?.[1]?.trim();
|
const fromSummary = artifact.summary?.match(/单位\s+([^\s·]+)/)?.[1]?.trim();
|
||||||
const unitId =
|
// 接受工作流计划 ≠ 某一步做完;「flow」不得写入已验收步骤
|
||||||
(artifact.workerId === "design-step" ? fromStep : "") ||
|
if (artifact.workerId === "design-flow") return;
|
||||||
fromBoard ||
|
|
||||||
fromSlot ||
|
|
||||||
fromSummary ||
|
|
||||||
"";
|
|
||||||
const prev = parseAcceptedUnits(
|
const prev = parseAcceptedUnits(
|
||||||
slots[SLOT_CREATION_ACCEPTED_UNITS] ??
|
slots[SLOT_CREATION_ACCEPTED_UNITS] ??
|
||||||
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
|
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;
|
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_ACCEPTED_UNITS] = next;
|
||||||
slots[SLOT_CREATION_CURRENT_UNIT] = unitId || undefined;
|
slots[SLOT_CREATION_CURRENT_UNIT] = unitId || undefined;
|
||||||
this.blackboard.write({
|
this.blackboard.write({
|
||||||
@@ -1518,6 +1919,7 @@ export class PhaseRuntime {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugLog("step", "总管开始");
|
||||||
const availableWorkers = await this.resolveAvailableWorkers();
|
const availableWorkers = await this.resolveAvailableWorkers();
|
||||||
|
|
||||||
const decision = await this.mainAgent.runToolLoop(
|
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}`);
|
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
|
||||||
|
|
||||||
const result = applyEvent(this.session, {
|
const result = this.commitEvent({
|
||||||
type: "main_agent_decision_created",
|
type: "main_agent_decision_created",
|
||||||
payload: { decision },
|
payload: { decision },
|
||||||
});
|
});
|
||||||
this.session = result.session;
|
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
this.onMessage(result.error);
|
this.onMessage(result.error);
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import {
|
|||||||
displayWorkerLabel,
|
displayWorkerLabel,
|
||||||
formatAgentDisplayTitle,
|
formatAgentDisplayTitle,
|
||||||
formatWorkerDisplayTitle,
|
formatWorkerDisplayTitle,
|
||||||
|
reviewComposerCopy,
|
||||||
} from "./display-labels.js";
|
} from "./display-labels.js";
|
||||||
|
import {
|
||||||
|
isModuleOpeningQuestions,
|
||||||
|
normalizeQuestions,
|
||||||
|
} from "../skills/question-protocol.js";
|
||||||
|
|
||||||
export type AgentMessageKind =
|
export type AgentMessageKind =
|
||||||
| "user_input"
|
| "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(
|
const workerAskTagged = trimmed.match(
|
||||||
/^\[Worker\]\s*(\S+)\s*提问[::]\s*\n?([\s\S]*)$/,
|
/^\[Worker\]\s*(\S+)\s*提问[::]\s*\n?([\s\S]*)$/,
|
||||||
);
|
);
|
||||||
@@ -240,7 +259,7 @@ export function classifyAgentMessage(text: string): EnrichedMessage {
|
|||||||
title: "可选追问",
|
title: "可选追问",
|
||||||
body: formatWorkerQuestionBody(
|
body: formatWorkerQuestionBody(
|
||||||
trimmed.replace(
|
trimmed.replace(
|
||||||
/^\[Worker\]\s*可选追问(可跳过,直接接受(?:目前)?产物)[::]\s*/,
|
/^\[Worker\]\s*可选追问(可跳过(?:,直接[^)]+)?)[::]\s*/,
|
||||||
"",
|
"",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -387,19 +406,52 @@ export function buildFocus(
|
|||||||
|
|
||||||
if (reason?.kind === "approve_step") {
|
if (reason?.kind === "approve_step") {
|
||||||
const worker = session.pendingDecision?.workerId ?? "skill";
|
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 {
|
return {
|
||||||
actorType: "orchestrator",
|
actorType: "orchestrator",
|
||||||
actorId: "orchestrator",
|
actorId: "orchestrator",
|
||||||
actorLabel: "总管",
|
actorLabel: "编排",
|
||||||
action: `建议调用 ${displayWorkerLabel(worker)}`,
|
action: proposed?.name
|
||||||
|
? `确认开始 · ${proposed.name}`
|
||||||
|
: `建议调用 ${displayWorkerLabel(worker)}`,
|
||||||
detail: session.pendingDecision?.reason,
|
detail: session.pendingDecision?.reason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reason?.kind === "next_intent") {
|
||||||
|
return {
|
||||||
|
actorType: "user",
|
||||||
|
actorLabel: "你",
|
||||||
|
action: "下一步想写什么",
|
||||||
|
detail: "可留空;发送后会展示下一节点供确认开干",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (reason?.kind === "worker_questions") {
|
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 =
|
const q =
|
||||||
reason.questions
|
qs
|
||||||
?.map((item) => item.prompt)
|
.map((item) => item.prompt)
|
||||||
.filter((s) => s?.trim())
|
.filter((s) => s?.trim())
|
||||||
.join(";") ?? "";
|
.join(";") ?? "";
|
||||||
return {
|
return {
|
||||||
@@ -413,13 +465,16 @@ export function buildFocus(
|
|||||||
|
|
||||||
if (reason?.kind === "review_artifact") {
|
if (reason?.kind === "review_artifact") {
|
||||||
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
|
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
|
const optionalQs = reason.questions?.length
|
||||||
? `;另有 ${reason.questions.length} 道可选追问`
|
? `;另有 ${reason.questions.length} 道可选追问`
|
||||||
: "";
|
: "";
|
||||||
return {
|
return {
|
||||||
actorType: "user",
|
actorType: "user",
|
||||||
actorLabel: "你",
|
actorLabel: "你",
|
||||||
action: "验收产物",
|
action: copy.taskTitle,
|
||||||
detail: `${art?.summary ?? displayWorkerLabel(art?.workerId) ?? ""}${optionalQs}`,
|
detail: `${art?.summary ?? displayWorkerLabel(art?.workerId) ?? ""}${optionalQs}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,3 +122,84 @@ export function formatAgentDisplayTitle(detail?: string): string {
|
|||||||
if (detail?.trim()) return `编排器 · ${detail.trim()}`;
|
if (detail?.trim()) return `编排器 · ${detail.trim()}`;
|
||||||
return "编排器";
|
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 { Blackboard } from "../blackboard/blackboard.js";
|
||||||
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
|
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
|
||||||
import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.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 {
|
import {
|
||||||
formatWorkerSetForUser,
|
formatWorkerSetForUser,
|
||||||
type WorkerSetUserView,
|
type WorkerSetUserView,
|
||||||
@@ -78,13 +83,21 @@ import {
|
|||||||
type ContextOrderEdit,
|
type ContextOrderEdit,
|
||||||
} from "../skills/context-order.js";
|
} from "../skills/context-order.js";
|
||||||
import {
|
import {
|
||||||
|
CREATION_CURRENT_STEP_TAG,
|
||||||
CREATION_FLOW_TAG,
|
CREATION_FLOW_TAG,
|
||||||
|
CREATION_PROPOSED_STEP_TAG,
|
||||||
CREATION_SELECTED_RECIPE_TAG,
|
CREATION_SELECTED_RECIPE_TAG,
|
||||||
|
creationFlowFromRecipeSeed,
|
||||||
findRecipeCatalogEntry,
|
findRecipeCatalogEntry,
|
||||||
formatCreationFlowForUser,
|
formatCreationFlowForUser,
|
||||||
|
isProgressPointerTag,
|
||||||
loadRecipeCatalog,
|
loadRecipeCatalog,
|
||||||
|
loadRecipeDetail,
|
||||||
parseCreationFlow,
|
parseCreationFlow,
|
||||||
parseSelectedRecipeRef,
|
parseSelectedRecipeRef,
|
||||||
|
resolveDesignStepBinding,
|
||||||
|
stepUnitId,
|
||||||
|
stringifyCreationFlow,
|
||||||
type CreationFlowUserView,
|
type CreationFlowUserView,
|
||||||
type RecipeCatalogEntry,
|
type RecipeCatalogEntry,
|
||||||
} from "../skills/creation-flow.js";
|
} from "../skills/creation-flow.js";
|
||||||
@@ -94,6 +107,7 @@ import { buildBoardPanel } from "../runtime/compress-after-worker.js";
|
|||||||
import {
|
import {
|
||||||
displayWorkerLabel,
|
displayWorkerLabel,
|
||||||
formatWorkerDisplayTitle,
|
formatWorkerDisplayTitle,
|
||||||
|
reviewComposerCopy,
|
||||||
} from "./display-labels.js";
|
} from "./display-labels.js";
|
||||||
import {
|
import {
|
||||||
buildCreationDialogueTranscript,
|
buildCreationDialogueTranscript,
|
||||||
@@ -102,6 +116,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
formatQuestionAnswersForAi,
|
formatQuestionAnswersForAi,
|
||||||
formatQuestionAnswersForDisplay,
|
formatQuestionAnswersForDisplay,
|
||||||
|
isModuleOpeningQuestions,
|
||||||
normalizeQuestions,
|
normalizeQuestions,
|
||||||
} from "../skills/question-protocol.js";
|
} from "../skills/question-protocol.js";
|
||||||
import type { QuestionAnswer } from "../types/questions.js";
|
import type { QuestionAnswer } from "../types/questions.js";
|
||||||
@@ -180,10 +195,16 @@ export type SessionView = {
|
|||||||
workerSetView?: WorkerSetUserView;
|
workerSetView?: WorkerSetUserView;
|
||||||
/** 创作流程用户视图(步骤顺序 + 依赖) */
|
/** 创作流程用户视图(步骤顺序 + 依赖) */
|
||||||
creationFlowView?: CreationFlowUserView;
|
creationFlowView?: CreationFlowUserView;
|
||||||
|
/** 确认开干:下一步提案(含可编辑编排参数) */
|
||||||
|
proposedNextStep?: ProposedNextStepView;
|
||||||
/** 能力包内可选初始配方(用户手动选) */
|
/** 能力包内可选初始配方(用户手动选) */
|
||||||
recipes?: Array<{ id: string; name: string; declaration: string }>;
|
recipes?: Array<{ id: string; name: string; declaration: string }>;
|
||||||
/** 用户已选初始配方 */
|
/** 用户已选初始配方 */
|
||||||
selectedRecipe?: { id: string; name: string; declaration: string } | null;
|
selectedRecipe?: { id: string; name: string; declaration: string } | null;
|
||||||
|
/**
|
||||||
|
* 开局引导:配方起点或当前能力默认问题的 opening(说话面主柱;对齐美学纲领)。
|
||||||
|
*/
|
||||||
|
openingGuide?: { stepId: string; stepName: string; text: string } | null;
|
||||||
/** 黑板与定稿上下文(强可读) */
|
/** 黑板与定稿上下文(强可读) */
|
||||||
boardPanel?: BoardPanelView;
|
boardPanel?: BoardPanelView;
|
||||||
/** 游玩呈现壳微调(来自设计.正文组成) */
|
/** 游玩呈现壳微调(来自设计.正文组成) */
|
||||||
@@ -238,6 +259,21 @@ export type BoardPanelView = {
|
|||||||
brief?: string;
|
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 = {
|
export type ReviewArtifactView = {
|
||||||
id: string;
|
id: string;
|
||||||
workerId: string;
|
workerId: string;
|
||||||
@@ -263,14 +299,18 @@ type ManagedSession = {
|
|||||||
branchState: MessageBranchState;
|
branchState: MessageBranchState;
|
||||||
/** 当前能力包可选初始配方(用户手动选) */
|
/** 当前能力包可选初始配方(用户手动选) */
|
||||||
recipeOptions?: RecipeCatalogEntry[];
|
recipeOptions?: RecipeCatalogEntry[];
|
||||||
|
/** 开局模块 opening,缓存给意图页 */
|
||||||
|
openingGuide?: { stepId: string; stepName: string; text: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class SessionManager {
|
export class SessionManager {
|
||||||
private readonly sessions = new Map<string, ManagedSession>();
|
private readonly sessions = new Map<string, ManagedSession>();
|
||||||
/** bookId → 当前内存中的 sessionId */
|
/** bookId → 当前内存中的 sessionId */
|
||||||
private readonly activeBookSessions = new Map<string, string>();
|
private readonly activeBookSessions = new Map<string, string>();
|
||||||
/** sessionId → 流式 thinking 缓冲 */
|
/** sessionId → 流式 thinking 缓冲(思考结束后仍保留,直到本轮结束) */
|
||||||
private readonly agentThinkingLive = new Map<string, string>();
|
private readonly agentThinkingLive = new Map<string, string>();
|
||||||
|
/** 本轮思维链已收束;下一次 delta 起新一轮 */
|
||||||
|
private readonly agentThinkingClosed = new Set<string>();
|
||||||
/** sessionId → worker 流式缓冲 */
|
/** sessionId → worker 流式缓冲 */
|
||||||
private readonly workerLive = new Map<
|
private readonly workerLive = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -679,7 +719,11 @@ export class SessionManager {
|
|||||||
return count;
|
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);
|
const s = this.require(id);
|
||||||
this.clearAgentThinking(id);
|
this.clearAgentThinking(id);
|
||||||
recordPreMessageCheckpoint(
|
recordPreMessageCheckpoint(
|
||||||
@@ -687,21 +731,62 @@ export class SessionManager {
|
|||||||
s.messages.length,
|
s.messages.length,
|
||||||
this.captureCheckpoint(s),
|
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 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 {
|
try {
|
||||||
if (reason?.kind === "approve_step") {
|
if (reason?.kind === "approve_step") {
|
||||||
await s.runtime.rejectStep(text);
|
await s.runtime.rejectStep(trimmed || text);
|
||||||
} else if (reason?.kind === "review_artifact") {
|
} 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 {
|
} else {
|
||||||
await s.runtime.submitInput(text);
|
await s.runtime.submitInput(trimmed || text);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const detail = err instanceof Error ? err.message : String(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)));
|
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -755,12 +840,14 @@ export class SessionManager {
|
|||||||
try {
|
try {
|
||||||
if (isSidecar) {
|
if (isSidecar) {
|
||||||
await s.runtime.resolveSidecarQuestions(aiText);
|
await s.runtime.resolveSidecarQuestions(aiText);
|
||||||
|
// 作答必须写回产物,否则验收后进度空转、追问在美学/交互间打转
|
||||||
|
await s.runtime.rejectArtifact("按追问作答更新产物");
|
||||||
} else {
|
} else {
|
||||||
await s.runtime.submitInput(aiText);
|
await s.runtime.submitInput(aiText);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const detail = err instanceof Error ? err.message : String(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)));
|
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -960,13 +1047,16 @@ export class SessionManager {
|
|||||||
return this.toView(id);
|
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);
|
const s = this.require(id);
|
||||||
try {
|
try {
|
||||||
await s.runtime.approve();
|
await s.runtime.approve(stepParams);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const detail = err instanceof Error ? err.message : String(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)));
|
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||||
}
|
}
|
||||||
return this.toView(id);
|
return this.toView(id);
|
||||||
@@ -978,7 +1068,7 @@ export class SessionManager {
|
|||||||
await s.runtime.confirmIntake();
|
await s.runtime.confirmIntake();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const detail = err instanceof Error ? err.message : String(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)));
|
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||||
}
|
}
|
||||||
@@ -1028,7 +1118,7 @@ export class SessionManager {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const detail = err instanceof Error ? err.message : String(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)));
|
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||||
}
|
}
|
||||||
@@ -1154,7 +1244,7 @@ export class SessionManager {
|
|||||||
payload: { text },
|
payload: { text },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[session] maybeKickAgentAfterDemand failed:", err);
|
console.error("[会话] 需求后唤醒总管失败", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1214,7 +1304,7 @@ export class SessionManager {
|
|||||||
const transcript = buildCreationDialogueTranscript(s.messages);
|
const transcript = buildCreationDialogueTranscript(s.messages);
|
||||||
s.runtime.writeCreationDialogue(transcript);
|
s.runtime.writeCreationDialogue(transcript);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[session] syncCreationDialogue:", err);
|
console.warn("[会话] 同步创作对话失败", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1274,6 +1364,7 @@ export class SessionManager {
|
|||||||
|
|
||||||
private clearAgentThinking(sessionId: string): void {
|
private clearAgentThinking(sessionId: string): void {
|
||||||
this.agentThinkingLive.delete(sessionId);
|
this.agentThinkingLive.delete(sessionId);
|
||||||
|
this.agentThinkingClosed.delete(sessionId);
|
||||||
this.workerLive.delete(sessionId);
|
this.workerLive.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1287,15 +1378,20 @@ export class SessionManager {
|
|||||||
| "onWorkerStreamStart"
|
| "onWorkerStreamStart"
|
||||||
| "onWorkerThinkingDelta"
|
| "onWorkerThinkingDelta"
|
||||||
| "onWorkerOutputDelta"
|
| "onWorkerOutputDelta"
|
||||||
| "onWorkerStreamDone"
|
|
||||||
> {
|
> {
|
||||||
return {
|
return {
|
||||||
onAgentThinkingDelta: (delta) => {
|
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);
|
this.agentThinkingLive.set(sessionId, prev + delta);
|
||||||
},
|
},
|
||||||
onAgentThinkingDone: () => {
|
onAgentThinkingDone: (text) => {
|
||||||
this.agentThinkingLive.delete(sessionId);
|
// 正文仍等完整后再渲染;思考收束后继续挂在 liveStream,避免中间只剩「…」
|
||||||
|
this.agentThinkingClosed.add(sessionId);
|
||||||
|
const done = text.trim();
|
||||||
|
if (done) this.agentThinkingLive.set(sessionId, done);
|
||||||
},
|
},
|
||||||
onWorkerStreamStart: (workerId) => {
|
onWorkerStreamStart: (workerId) => {
|
||||||
this.workerLive.set(sessionId, { workerId, thinking: "", output: "" });
|
this.workerLive.set(sessionId, { workerId, thinking: "", output: "" });
|
||||||
@@ -1322,9 +1418,6 @@ export class SessionManager {
|
|||||||
prev.output += delta;
|
prev.output += delta;
|
||||||
this.workerLive.set(sessionId, prev);
|
this.workerLive.set(sessionId, prev);
|
||||||
},
|
},
|
||||||
onWorkerStreamDone: () => {
|
|
||||||
this.workerLive.delete(sessionId);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1603,47 +1696,94 @@ export class SessionManager {
|
|||||||
}
|
}
|
||||||
} else if (reason?.kind === "worker_questions") {
|
} else if (reason?.kind === "worker_questions") {
|
||||||
const qs = normalizeQuestions(reason.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(
|
hints.push(
|
||||||
`${displayWorkerLabel(reason.workerId)} · 提问(请在询问卡作答):${qs.map((q) => q.prompt).join(" ")}`,
|
`${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 {
|
} else {
|
||||||
hints.push(
|
hints.push(
|
||||||
`${displayWorkerLabel(reason.workerId)} 需要更多信息,请补充说明`,
|
`${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") {
|
} 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: "暂不执行" });
|
actions.push({ type: "reject", label: "暂不执行" });
|
||||||
} else if (reason?.kind === "review_artifact") {
|
|
||||||
actions.push({ type: "accept", label: "接受目前产物" });
|
|
||||||
actions.push({ type: "reject", label: "不接受,重新来" });
|
|
||||||
actions.push({
|
actions.push({
|
||||||
type: "send_message",
|
type: "send_message",
|
||||||
label: "发送修改意见",
|
label: "说明意见",
|
||||||
placeholder: "输入修改意见后发送…",
|
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) {
|
if (reason.questions?.length) {
|
||||||
hints.push("下方追问可选;接受产物即收起(表示无需再完善)");
|
hints.push(`下方追问可选;${copy.acceptLabel}即收起(表示无需再完善)`);
|
||||||
actions.push({ type: "answer_questions", label: "提交作答" });
|
actions.push({ type: "answer_questions", label: "提交作答" });
|
||||||
actions.push({ type: "skip_questions", label: "跳过追问" });
|
actions.push({ type: "skip_questions", label: "跳过追问" });
|
||||||
}
|
}
|
||||||
} else if (reason?.kind === "revision") {
|
} else if (reason?.kind === "revision") {
|
||||||
hints.push(
|
hints.push(
|
||||||
reason.instruction?.trim() ||
|
reason.instruction?.trim() ||
|
||||||
"需要按反馈修改后继续;请在下方说明或直接发送继续",
|
"请说明要改哪里;发送后在现有产物上修改",
|
||||||
);
|
);
|
||||||
actions.push({
|
actions.push({
|
||||||
type: "send_message",
|
type: "send_message",
|
||||||
label: "发送",
|
label: "按意见修改",
|
||||||
placeholder: "说明修改意见,或直接发送让总管继续…",
|
placeholder: "说明要改哪里…",
|
||||||
});
|
});
|
||||||
} else if (session.phase === "error") {
|
} else if (session.phase === "error") {
|
||||||
hints.push("上一轮执行出错。可直接发消息让总管重试,或刷新作品后继续。");
|
hints.push("上一轮执行出错。可直接发消息让总管重试,或刷新作品后继续。");
|
||||||
@@ -1737,7 +1877,17 @@ export class SessionManager {
|
|||||||
s.runtime.getBlackboard(),
|
s.runtime.getBlackboard(),
|
||||||
s.runtime.getSession(),
|
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) => ({
|
recipes: s.recipeOptions?.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
@@ -1747,6 +1897,7 @@ export class SessionManager {
|
|||||||
s.runtime.getBlackboard(),
|
s.runtime.getBlackboard(),
|
||||||
s.recipeOptions,
|
s.recipeOptions,
|
||||||
),
|
),
|
||||||
|
openingGuide: this.resolveOpeningGuideForView(s, reason),
|
||||||
boardPanel: buildBoardPanelFromRuntime(s.runtime.getBlackboard()),
|
boardPanel: buildBoardPanelFromRuntime(s.runtime.getBlackboard()),
|
||||||
presentationTweaks: shellAdapt
|
presentationTweaks: shellAdapt
|
||||||
? {
|
? {
|
||||||
@@ -1839,12 +1990,14 @@ export class SessionManager {
|
|||||||
const skillName = s.runtime.getActiveSkill()?.name;
|
const skillName = s.runtime.getActiveSkill()?.name;
|
||||||
if (!skillName) {
|
if (!skillName) {
|
||||||
s.recipeOptions = [];
|
s.recipeOptions = [];
|
||||||
|
s.openingGuide = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const skill = await loadSkill(skillName);
|
const skill = await loadSkill(skillName);
|
||||||
if (!skill.skillPackRoot) {
|
if (!skill.skillPackRoot) {
|
||||||
s.recipeOptions = [];
|
s.recipeOptions = [];
|
||||||
|
s.openingGuide = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const catalog = await loadRecipeCatalog(skill.skillPackRoot);
|
const catalog = await loadRecipeCatalog(skill.skillPackRoot);
|
||||||
@@ -1852,6 +2005,125 @@ export class SessionManager {
|
|||||||
} catch {
|
} catch {
|
||||||
s.recipeOptions = [];
|
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(
|
private async applySelectedRecipe(
|
||||||
@@ -1866,7 +2138,8 @@ export class SessionManager {
|
|||||||
if (!entry) {
|
if (!entry) {
|
||||||
throw new Error(`未知初始配方:${recipeId}`);
|
throw new Error(`未知初始配方:${recipeId}`);
|
||||||
}
|
}
|
||||||
s.runtime.getBlackboard().write({
|
const board = s.runtime.getBlackboard();
|
||||||
|
board.write({
|
||||||
tag: CREATION_SELECTED_RECIPE_TAG,
|
tag: CREATION_SELECTED_RECIPE_TAG,
|
||||||
content: JSON.stringify({
|
content: JSON.stringify({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
@@ -1874,6 +2147,33 @@ export class SessionManager {
|
|||||||
}),
|
}),
|
||||||
source: "user",
|
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(
|
function buildCreationFlowUserView(
|
||||||
blackboard: Blackboard,
|
blackboard: Blackboard,
|
||||||
|
acceptedRaw?: unknown,
|
||||||
): CreationFlowUserView | undefined {
|
): CreationFlowUserView | undefined {
|
||||||
const raw = blackboard.getContentByTag(CREATION_FLOW_TAG)?.trim();
|
const raw = blackboard.getContentByTag(CREATION_FLOW_TAG)?.trim();
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
@@ -1900,7 +2201,56 @@ function buildCreationFlowUserView(
|
|||||||
if (!flow) {
|
if (!flow) {
|
||||||
return { steps: [], parseError: "创作流程无法解析为 JSON(需要 steps 数组)" };
|
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(
|
function resolveSelectedRecipeView(
|
||||||
@@ -1972,19 +2322,46 @@ function buildReviewArtifactView(
|
|||||||
|
|
||||||
for (const tag of art.outputTags) {
|
for (const tag of art.outputTags) {
|
||||||
if (workerSetTags.includes(tag)) continue;
|
if (workerSetTags.includes(tag)) continue;
|
||||||
|
if (isProgressPointerTag(tag)) continue;
|
||||||
const content = blackboard.getContentByTag(tag);
|
const content = blackboard.getContentByTag(tag);
|
||||||
if (content?.trim()) {
|
if (content?.trim()) {
|
||||||
tagBodies.push(`## ${tag}\n\n${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]
|
const outputMsg = [...messages]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((m) => m.kind === "worker_output" && m.actor === art.workerId);
|
.find((m) => m.kind === "worker_output" && m.actor === art.workerId);
|
||||||
|
|
||||||
const body =
|
const body =
|
||||||
tagBodies.join("\n\n") ||
|
tagBodies.join("\n\n") ||
|
||||||
(outputMsg?.body ?? outputMsg?.text ?? "").trim() ||
|
stripPointerSections((outputMsg?.body ?? outputMsg?.text ?? "").trim()) ||
|
||||||
art.summary?.trim() ||
|
art.summary?.trim() ||
|
||||||
"(Worker 未写入可读正文,请检查黑板 tag 或重试创作 skill)";
|
"(Worker 未写入可读正文,请检查黑板 tag 或重试创作 skill)";
|
||||||
|
|
||||||
@@ -2028,7 +2405,10 @@ function buildReviewArtifactView(
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const creationFlowView = isFlowArt
|
const creationFlowView = isFlowArt
|
||||||
? buildCreationFlowUserView(blackboard)
|
? buildCreationFlowUserView(
|
||||||
|
blackboard,
|
||||||
|
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
|
||||||
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { sessionManager } from "./session-manager.js";
|
|||||||
import { handleSettingsApi } from "./settings-handlers.js";
|
import { handleSettingsApi } from "./settings-handlers.js";
|
||||||
import { handleBooksApi } from "./book-handlers.js";
|
import { handleBooksApi } from "./book-handlers.js";
|
||||||
import { handleStatsApi } from "./stats-handlers.js";
|
import { handleStatsApi } from "./stats-handlers.js";
|
||||||
|
import { ensureConsoleUtf8, getRuntimeLogPath } from "../log.js";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const PROJECT_ROOT = path.resolve(__dirname, "../..");
|
const PROJECT_ROOT = path.resolve(__dirname, "../..");
|
||||||
|
ensureConsoleUtf8();
|
||||||
loadDotEnv(PROJECT_ROOT);
|
loadDotEnv(PROJECT_ROOT);
|
||||||
const WEB_ROOT = path.resolve(__dirname, "../../web");
|
const WEB_ROOT = path.resolve(__dirname, "../../web");
|
||||||
const PORT = Number(process.env.PORT ?? 23337);
|
const PORT = Number(process.env.PORT ?? 23337);
|
||||||
@@ -122,12 +124,45 @@ const server = createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && sub === "/messages") {
|
if (req.method === "POST" && sub === "/messages") {
|
||||||
const body = JSON.parse(await readBody(req)) as { text?: string };
|
const body = JSON.parse(await readBody(req)) as {
|
||||||
if (!body.text?.trim()) {
|
text?: string;
|
||||||
json(res, 400, { error: "text 不能为空" });
|
answers?: Array<{
|
||||||
return;
|
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);
|
json(res, 200, view);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -406,11 +441,19 @@ const server = createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && sub === "/actions") {
|
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;
|
let view;
|
||||||
switch (body.action) {
|
switch (body.action) {
|
||||||
case "approve":
|
case "approve":
|
||||||
view = await sessionManager.approve(sessionId);
|
view = await sessionManager.approve(
|
||||||
|
sessionId,
|
||||||
|
body.stepParams && typeof body.stepParams === "object"
|
||||||
|
? body.stepParams
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "confirm_intake":
|
case "confirm_intake":
|
||||||
view = await sessionManager.confirmIntake(sessionId);
|
view = await sessionManager.confirmIntake(sessionId);
|
||||||
@@ -452,7 +495,7 @@ const server = createServer(async (req, res) => {
|
|||||||
json(res, 404, { error: "Not found" });
|
json(res, 404, { error: "Not found" });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[api]", req.method, url.pathname, err);
|
console.error("[接口]", req.method, url.pathname, err);
|
||||||
json(res, 500, {
|
json(res, 500, {
|
||||||
error: err instanceof Error ? err.message : "服务器错误",
|
error: err instanceof Error ? err.message : "服务器错误",
|
||||||
});
|
});
|
||||||
@@ -460,5 +503,6 @@ const server = createServer(async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
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));
|
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 尝试列出正文顶层键 */
|
/** 投影级别:summary 优先 brief;fields 尝试列出正文顶层键 */
|
||||||
export function projectFragmentContent(
|
export function projectFragmentContent(
|
||||||
raw: string,
|
raw: string,
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import type { BlackboardInputMerge } from "../types/blackboard.js";
|
|||||||
import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js";
|
import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js";
|
||||||
import {
|
import {
|
||||||
CREATION_ACCEPTED_CONTENT_TAG,
|
CREATION_ACCEPTED_CONTENT_TAG,
|
||||||
|
CREATION_ACCEPTED_UNITS_TAG,
|
||||||
formatAcceptedContentForPrompt,
|
formatAcceptedContentForPrompt,
|
||||||
|
formatAcceptedUnitsForPrompt,
|
||||||
} from "./creation-units.js";
|
} from "./creation-units.js";
|
||||||
import { projectFragmentContent } from "./context-fragment.js";
|
import { projectFragmentContent } from "./context-fragment.js";
|
||||||
import {
|
import {
|
||||||
@@ -107,6 +109,8 @@ function formatSegmentBody(
|
|||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
if (tag === CREATION_ACCEPTED_CONTENT_TAG) {
|
if (tag === CREATION_ACCEPTED_CONTENT_TAG) {
|
||||||
content = formatAcceptedContentForPrompt(content);
|
content = formatAcceptedContentForPrompt(content);
|
||||||
|
} else if (tag === CREATION_ACCEPTED_UNITS_TAG) {
|
||||||
|
content = formatAcceptedUnitsForPrompt(content);
|
||||||
}
|
}
|
||||||
content = applyPolicy(content, segment.policy);
|
content = applyPolicy(content, segment.policy);
|
||||||
content = applyProjection(content, segment.projection);
|
content = applyProjection(content, segment.projection);
|
||||||
@@ -154,15 +158,20 @@ export function assembleWorkerContext(params: {
|
|||||||
const segments = params.segments ?? [];
|
const segments = params.segments ?? [];
|
||||||
|
|
||||||
if (segments.length === 0) {
|
if (segments.length === 0) {
|
||||||
|
const revisionNote = (
|
||||||
|
params.inputs["用户.修订说明"] ??
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
{
|
{
|
||||||
workerId: params.workerId,
|
workerId: params.workerId,
|
||||||
workerName: params.workerName,
|
workerName: params.workerName,
|
||||||
outputTags: params.outputTags,
|
outputTags: params.outputTags,
|
||||||
inputs: params.inputs,
|
inputs: params.inputs,
|
||||||
instruction:
|
instruction: revisionNote
|
||||||
"根据 SKILL 说明完成任务。若 inputs 含「上下文.定稿摘要」,以终产物为准。" +
|
? "修订模式:在已有产物(inputs 中对应 outputTags)上按「用户.修订说明」以及「用户.worker答复」中的追问作答(选项与补充)修改;保留未点名要改的部分,禁止无故整份重写。节点方法(task/principles/probe/output)仍须遵守。"
|
||||||
"设计.worker集 / 草稿须为 JSON 对象文本。",
|
: "根据 SKILL 说明完成任务。若 inputs 含「上下文.定稿摘要」,以终产物为准。" +
|
||||||
|
"设计.worker集 / 草稿须为 JSON 对象文本。",
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
@@ -191,18 +200,61 @@ export function assembleWorkerContext(params: {
|
|||||||
blocks.push(`## 上下文.定稿摘要\n\n${brief}`);
|
blocks.push(`## 上下文.定稿摘要\n\n${brief}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
blocks.push(
|
// 修订态:把本步 outputTags 上已有正文钉成「待改底稿」,禁止无故推倒重写
|
||||||
[
|
const revisionNote = readTagContent(
|
||||||
"## 本步任务",
|
"用户.修订说明",
|
||||||
"",
|
params.inputs,
|
||||||
`- workerId: \`${params.workerId}\``,
|
params.blackboard,
|
||||||
`- workerName: ${params.workerName}`,
|
inputMerge,
|
||||||
`- outputTags: ${params.outputTags.map((t) => `\`${t}\``).join("、") || "(无)"}`,
|
).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 与上方分区完成任务。标为「只读 / 已定稿」的分区不要擅自改写;只改【本单位】范围。",
|
"按 SKILL 与上方分区完成任务。标为「只读 / 已定稿」的分区不要擅自改写;只改【本单位】范围。",
|
||||||
"设计.worker集 / 草稿须为 JSON 对象文本(以 `{` 开头)。",
|
);
|
||||||
].join("\n"),
|
}
|
||||||
);
|
taskLines.push("设计.worker集 / 草稿须为 JSON 对象文本(以 `{` 开头)。");
|
||||||
|
blocks.push(taskLines.join("\n"));
|
||||||
|
|
||||||
return blocks.join("\n\n---\n\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_FLOW_TAG = "设计.创作流程";
|
||||||
export const CREATION_CURRENT_STEP_TAG = "创作.当前步骤";
|
export const CREATION_CURRENT_STEP_TAG = "创作.当前步骤";
|
||||||
|
/** 确认开干前:待确认的下一步快照(JSON) */
|
||||||
|
export const CREATION_PROPOSED_STEP_TAG = "创作.待确认步骤";
|
||||||
/** 用户手动选定的初始配方(存 recipe id,或 JSON {id,name}) */
|
/** 用户手动选定的初始配方(存 recipe id,或 JSON {id,name}) */
|
||||||
export const CREATION_SELECTED_RECIPE_TAG = "创作.选用配方";
|
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 MODULE_CATALOG_FILENAME = "modules/catalog.yaml";
|
||||||
export const RECIPE_CATALOG_FILENAME = "recipes/catalog.yaml";
|
export const RECIPE_CATALOG_FILENAME = "recipes/catalog.yaml";
|
||||||
export const DESIGN_STEP_WORKER_ID = "design-step";
|
export const DESIGN_STEP_WORKER_ID = "design-step";
|
||||||
@@ -152,6 +180,9 @@ export type CreationFlowValidation = {
|
|||||||
errors: string[];
|
errors: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 工作流计划节点相对执行进度(给人看的三态) */
|
||||||
|
export type FlowStepRunState = "done" | "current" | "pending";
|
||||||
|
|
||||||
export type CreationFlowUserView = {
|
export type CreationFlowUserView = {
|
||||||
brief?: string;
|
brief?: string;
|
||||||
status?: CreationFlowStatus;
|
status?: CreationFlowStatus;
|
||||||
@@ -169,6 +200,8 @@ export type CreationFlowUserView = {
|
|||||||
params?: CreationFlowStepParams;
|
params?: CreationFlowStepParams;
|
||||||
/** 参数缺必填项时的提示(给人看) */
|
/** 参数缺必填项时的提示(给人看) */
|
||||||
paramsMissing?: string[];
|
paramsMissing?: string[];
|
||||||
|
/** 已执行 / 将要执行 / 未执行 */
|
||||||
|
runState: FlowStepRunState;
|
||||||
}>;
|
}>;
|
||||||
parseError?: string;
|
parseError?: string;
|
||||||
};
|
};
|
||||||
@@ -301,6 +334,42 @@ export function stepUnitId(step: CreationFlowStep): string {
|
|||||||
return step.id || step.name;
|
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 {
|
export function parseCreationFlow(raw: string | undefined | null): CreationFlow | null {
|
||||||
if (!raw?.trim()) return null;
|
if (!raw?.trim()) return null;
|
||||||
const doc = extractJsonObject(raw);
|
const doc = extractJsonObject(raw);
|
||||||
@@ -591,6 +660,93 @@ export function stringifyModuleOpeningState(state: ModuleOpeningState): string {
|
|||||||
return JSON.stringify(state);
|
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) */
|
/** 无 id 时的兜底(目录仍应显式写 id) */
|
||||||
function slugFromName(name: string): string {
|
function slugFromName(name: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
@@ -661,6 +817,7 @@ export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
|
|||||||
"【能力 · 可选工序】",
|
"【能力 · 可选工序】",
|
||||||
"按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入。",
|
"按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入。",
|
||||||
"选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta);有「编排参数」的步骤必须在 DAG 里写齐 params,缺参时用 askUser 选项+其它,禁止空壳进执行。",
|
"选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta);有「编排参数」的步骤必须在 DAG 里写齐 params,缺参时用 askUser 选项+其它,禁止空壳进执行。",
|
||||||
|
"生成规则:改 target 时必须同步改 rule_id(英文 kebab-case)与步骤 id(生成规则·{对象});禁止只改中文对象沿用旧后台 id。",
|
||||||
"不要把能力执行全文塞进本步;执行由 design-step 注入。",
|
"不要把能力执行全文塞进本步;执行由 design-step 注入。",
|
||||||
lines.join("\n"),
|
lines.join("\n"),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
@@ -929,13 +1086,23 @@ function formatRecipeFieldBlock(value: string | string[]): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** design-flow 一次注入:已选配方 + 技能池 */
|
/** design-flow 一次注入:流程进度 + 已选配方 + 技能池 */
|
||||||
export function formatDesignFlowContentBlocks(params: {
|
export function formatDesignFlowContentBlocks(params: {
|
||||||
selectedRecipe?: RecipeDetail | null;
|
selectedRecipe?: RecipeDetail | null;
|
||||||
modules?: ModuleCatalog | null;
|
modules?: ModuleCatalog | null;
|
||||||
missingSelection?: boolean;
|
missingSelection?: boolean;
|
||||||
|
flow?: CreationFlow | null;
|
||||||
|
acceptedStepIds?: readonly string[];
|
||||||
|
filledArtifactTags?: readonly string[];
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const blocks: string[] = [];
|
const blocks: string[] = [
|
||||||
|
formatFlowProgressForAgent({
|
||||||
|
flow: params.flow,
|
||||||
|
acceptedStepIds: params.acceptedStepIds,
|
||||||
|
catalog: params.modules,
|
||||||
|
filledArtifactTags: params.filledArtifactTags,
|
||||||
|
}),
|
||||||
|
];
|
||||||
if (params.missingSelection) {
|
if (params.missingSelection) {
|
||||||
blocks.push(
|
blocks.push(
|
||||||
[
|
[
|
||||||
@@ -953,6 +1120,120 @@ export function formatDesignFlowContentBlocks(params: {
|
|||||||
return blocks;
|
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: {
|
export async function resolveSelectedRecipeDetail(params: {
|
||||||
skillPackRoot: string;
|
skillPackRoot: string;
|
||||||
@@ -973,7 +1254,6 @@ export function validateCreationFlow(
|
|||||||
catalog: ModuleCatalog | null,
|
catalog: ModuleCatalog | null,
|
||||||
): CreationFlowValidation {
|
): CreationFlowValidation {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const ids = flow.steps.map((s) => s.id);
|
|
||||||
const seenIds = new Set<string>();
|
const seenIds = new Set<string>();
|
||||||
const allowed = catalog
|
const allowed = catalog
|
||||||
? new Set(catalog.modules.map((m) => m.name))
|
? new Set(catalog.modules.map((m) => m.name))
|
||||||
@@ -1020,25 +1300,53 @@ export function validateCreationFlow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mod?.params?.length) {
|
// 必填 params 改在「确认开干」时钉齐,不再阻挡编排产出空壳步骤
|
||||||
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 钉齐后再执行)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: errors.length === 0, errors };
|
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(
|
export function artifactTagForStep(
|
||||||
name: string,
|
name: string,
|
||||||
catalog: ModuleCatalog | null,
|
catalog: ModuleCatalog | null,
|
||||||
@@ -1049,11 +1357,13 @@ export function artifactTagForStep(
|
|||||||
export function formatCreationFlowForUser(
|
export function formatCreationFlowForUser(
|
||||||
flow: CreationFlow,
|
flow: CreationFlow,
|
||||||
catalog?: ModuleCatalog | null,
|
catalog?: ModuleCatalog | null,
|
||||||
|
acceptedStepIds: readonly string[] = [],
|
||||||
): CreationFlowUserView {
|
): CreationFlowUserView {
|
||||||
const decl = new Map(
|
const decl = new Map(
|
||||||
(catalog?.modules ?? []).map((m) => [m.name, m] as const),
|
(catalog?.modules ?? []).map((m) => [m.name, m] as const),
|
||||||
);
|
);
|
||||||
const seenName = new Map<string, number>();
|
const seenName = new Map<string, number>();
|
||||||
|
const next = nextPendingStep(flow, acceptedStepIds);
|
||||||
return {
|
return {
|
||||||
brief: flow.brief,
|
brief: flow.brief,
|
||||||
status: flow.status,
|
status: flow.status,
|
||||||
@@ -1062,6 +1372,11 @@ export function formatCreationFlowForUser(
|
|||||||
seenName.set(s.name, n);
|
seenName.set(s.name, n);
|
||||||
const mod = decl.get(s.name);
|
const mod = decl.get(s.name);
|
||||||
const paramsMissing = missingRequiredStepParams(s, mod);
|
const paramsMissing = missingRequiredStepParams(s, mod);
|
||||||
|
const runState: FlowStepRunState = isStepAccepted(s, acceptedStepIds)
|
||||||
|
? "done"
|
||||||
|
: next && next.id === s.id
|
||||||
|
? "current"
|
||||||
|
: "pending";
|
||||||
return {
|
return {
|
||||||
order: i + 1,
|
order: i + 1,
|
||||||
id: s.id,
|
id: s.id,
|
||||||
@@ -1070,6 +1385,7 @@ export function formatCreationFlowForUser(
|
|||||||
occurrence: n,
|
occurrence: n,
|
||||||
declaration: mod?.declaration,
|
declaration: mod?.declaration,
|
||||||
repeatable: mod?.repeatable,
|
repeatable: mod?.repeatable,
|
||||||
|
runState,
|
||||||
...(s.params ? { params: s.params } : {}),
|
...(s.params ? { params: s.params } : {}),
|
||||||
...(paramsMissing.length > 0 ? { paramsMissing } : {}),
|
...(paramsMissing.length > 0 ? { paramsMissing } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -491,6 +491,13 @@ export function parseAcceptedUnits(raw: unknown): string[] {
|
|||||||
return [];
|
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: {
|
export function isFinalWorkerSetArtifact(artifact: {
|
||||||
workerId: string;
|
workerId: string;
|
||||||
outputTags: string[];
|
outputTags: string[];
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export async function resolveRunnableWorker(params: {
|
|||||||
currentStepName,
|
currentStepName,
|
||||||
acceptedStepNames: parseAcceptedSteps(acceptedRaw),
|
acceptedStepNames: parseAcceptedSteps(acceptedRaw),
|
||||||
selectedRecipeRef,
|
selectedRecipeRef,
|
||||||
|
filledArtifactTags: params.blackboard.listTagIndex().map((item) => item.tag),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -473,6 +473,8 @@ export async function loadWorkerSkillWithContext(
|
|||||||
acceptedStepNames?: readonly string[];
|
acceptedStepNames?: readonly string[];
|
||||||
/** 用户手动选定的配方 id / 名 */
|
/** 用户手动选定的配方 id / 名 */
|
||||||
selectedRecipeRef?: string | null;
|
selectedRecipeRef?: string | null;
|
||||||
|
/** 黑板已有产物 tag,供编排器判断哪些技能已经做过 */
|
||||||
|
filledArtifactTags?: readonly string[];
|
||||||
},
|
},
|
||||||
): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> {
|
): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> {
|
||||||
const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot);
|
const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot);
|
||||||
@@ -488,6 +490,7 @@ export async function loadWorkerSkillWithContext(
|
|||||||
loadModuleCatalog,
|
loadModuleCatalog,
|
||||||
resolveSelectedRecipeDetail,
|
resolveSelectedRecipeDetail,
|
||||||
formatDesignFlowContentBlocks,
|
formatDesignFlowContentBlocks,
|
||||||
|
parseCreationFlow,
|
||||||
} = await import("./creation-flow.js");
|
} = await import("./creation-flow.js");
|
||||||
const modules = await loadModuleCatalog(skill.skillPackRoot, skillsRoot);
|
const modules = await loadModuleCatalog(skill.skillPackRoot, skillsRoot);
|
||||||
const selectedRecipe = await resolveSelectedRecipeDetail({
|
const selectedRecipe = await resolveSelectedRecipeDetail({
|
||||||
@@ -499,6 +502,9 @@ export async function loadWorkerSkillWithContext(
|
|||||||
selectedRecipe,
|
selectedRecipe,
|
||||||
modules,
|
modules,
|
||||||
missingSelection: !selectedRecipe,
|
missingSelection: !selectedRecipe,
|
||||||
|
flow: parseCreationFlow(opts?.flowRaw),
|
||||||
|
acceptedStepIds: opts?.acceptedStepNames,
|
||||||
|
filledArtifactTags: opts?.filledArtifactTags,
|
||||||
});
|
});
|
||||||
if (blocks.length) {
|
if (blocks.length) {
|
||||||
moduleCatalogBlock = blocks.join("\n\n");
|
moduleCatalogBlock = blocks.join("\n\n");
|
||||||
@@ -537,10 +543,7 @@ export async function loadWorkerSkillWithContext(
|
|||||||
...binding.depTags,
|
...binding.depTags,
|
||||||
];
|
];
|
||||||
const inputTags = [...new Set(baseInputs)];
|
const inputTags = [...new Set(baseInputs)];
|
||||||
const outputTags = [
|
const outputTags = [binding.module.artifact];
|
||||||
binding.module.artifact,
|
|
||||||
CREATION_CURRENT_STEP_TAG,
|
|
||||||
];
|
|
||||||
const depSegments = binding.depTags.map((tag, i) => ({
|
const depSegments = binding.depTags.map((tag, i) => ({
|
||||||
id: `dep-${i}`,
|
id: `dep-${i}`,
|
||||||
tier: "static" as const,
|
tier: "static" as const,
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ import type {
|
|||||||
|
|
||||||
export type { QuestionAnswer, QuestionItem, QuestionOption };
|
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 {
|
function letterId(i: number): string {
|
||||||
return String.fromCharCode(65 + (i % 26));
|
return String.fromCharCode(65 + (i % 26));
|
||||||
}
|
}
|
||||||
@@ -88,6 +99,138 @@ export function normalizeQuestions(raw: unknown): QuestionItem[] {
|
|||||||
return out;
|
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:必须含问题与答案;可选自由补充 */
|
/** 发给 AI:必须含问题与答案;可选自由补充 */
|
||||||
export function formatQuestionAnswersForAi(
|
export function formatQuestionAnswersForAi(
|
||||||
questions: QuestionItem[],
|
questions: QuestionItem[],
|
||||||
|
|||||||
@@ -51,7 +51,13 @@ export type WaitingReason =
|
|||||||
questions: QuestionItem[];
|
questions: QuestionItem[];
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
} // worker 无产物时的阻塞提问
|
} // worker 无产物时的阻塞提问
|
||||||
| { kind: "revision"; instruction?: string }; // 产物被拒或程序验收失败
|
| { kind: "revision"; instruction?: string } // 无意见打回时等补交;有意见则直接重跑
|
||||||
|
| {
|
||||||
|
/** 上一步已验收:先问「下一步想写什么」(可留空),再提案并确认开干 */
|
||||||
|
kind: "next_intent";
|
||||||
|
afterWorkerId?: string;
|
||||||
|
afterUnitId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */
|
/** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */
|
||||||
export type SkillIndexEntry = {
|
export type SkillIndexEntry = {
|
||||||
@@ -249,6 +255,11 @@ export type RuntimeEvent =
|
|||||||
questions: QuestionItem[] | string[];
|
questions: QuestionItem[] | string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
/** 按意见重跑没写出新产物:退回上一版产物验收,重试追问改挂为可选 */
|
||||||
|
type: "worker_revision_produced_nothing";
|
||||||
|
payload: { artifactId: string; questions?: QuestionItem[] | string[] };
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
/** 验收态下作答/跳过挂载追问:不离开 review_artifact */
|
/** 验收态下作答/跳过挂载追问:不离开 review_artifact */
|
||||||
type: "user_resolved_sidecar_questions";
|
type: "user_resolved_sidecar_questions";
|
||||||
@@ -316,7 +327,9 @@ export type PhaseEffect =
|
|||||||
}
|
}
|
||||||
| { type: "resume_worker" }
|
| { type: "resume_worker" }
|
||||||
| { type: "run_programmatic_review"; artifactId: string }
|
| { type: "run_programmatic_review"; artifactId: string }
|
||||||
| { type: "emit_message"; message: string };
|
| { type: "emit_message"; message: string }
|
||||||
|
/** 根据流程与用户下一步意向,提案下一节点并进入确认 */
|
||||||
|
| { type: "propose_next_creation_step" };
|
||||||
|
|
||||||
/** applyEvent 的返回值:新会话快照 + 待处理副作用 + 可选错误 */
|
/** applyEvent 的返回值:新会话快照 + 待处理副作用 + 可选错误 */
|
||||||
export type ApplyEventResult = {
|
export type ApplyEventResult = {
|
||||||
|
|||||||
@@ -15,10 +15,17 @@ import {
|
|||||||
} from "../skills/worker-set-parse.js";
|
} from "../skills/worker-set-parse.js";
|
||||||
import { assembleWorkerContext } from "../skills/context-segments.js";
|
import { assembleWorkerContext } from "../skills/context-segments.js";
|
||||||
import {
|
import {
|
||||||
|
mergeQuestionsPreferFragment,
|
||||||
normalizeQuestions,
|
normalizeQuestions,
|
||||||
type QuestionItem,
|
type QuestionItem,
|
||||||
} from "../skills/question-protocol.js";
|
} 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 = {
|
export type WorkerRunParams = {
|
||||||
skillName: string;
|
skillName: string;
|
||||||
@@ -66,7 +73,8 @@ askUser 每项可为:
|
|||||||
|
|
||||||
- outputs 的 key 必须是要求的 outputTags
|
- outputs 的 key 必须是要求的 outputTags
|
||||||
- **设计.worker集 / 设计.worker集.草稿**:value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML
|
- **设计.worker集 / 设计.worker集.草稿**:value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML
|
||||||
- **优先同时给 outputs + askUser**:先交出可用草稿/产物,追问挂在产物下(用户可直接接受而不作答)。仅当完全无法产出时才留空 outputs、只填 askUser
|
- **context-fragment.v1**:必须放在 outputs["<本步 artifact tag>"],禁止把片段当根对象。题目只写在产物「追问」(建议选项 + 示例);顶层 askUser 必须为 **null**。程序会把「追问」挂到询问卡。禁止同一问再抄一份 askUser。禁止写入「创作.当前步骤」等进度指针
|
||||||
|
- **其它产物**:优先同时给 outputs + askUser;有产物时追问挂在产物下(用户可直接接受而不作答)。仅当完全无法产出时才留空 outputs、只填 askUser
|
||||||
- 能推断选项时 **必须**给 options(完整句、可改写);不要只丢裸问题逼用户写长段
|
- 能推断选项时 **必须**给 options(完整句、可改写);不要只丢裸问题逼用户写长段
|
||||||
- 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
|
- 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
|
||||||
- summary 用于界面展示`;
|
- summary 用于界面展示`;
|
||||||
@@ -127,6 +135,102 @@ function gatherInputs(
|
|||||||
return inputs;
|
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(
|
function parseWorkerResponse(
|
||||||
raw: string,
|
raw: string,
|
||||||
outputTags: string[],
|
outputTags: string[],
|
||||||
@@ -135,7 +239,6 @@ function parseWorkerResponse(
|
|||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw);
|
parsed = JSON.parse(raw);
|
||||||
} catch {
|
} catch {
|
||||||
// 整段不是协议 JSON:切勿把散文塞进设计.worker集*
|
|
||||||
if (outputTags.some((t) => WORKER_SET_OUTPUT_TAGS.has(t))) {
|
if (outputTags.some((t) => WORKER_SET_OUTPUT_TAGS.has(t))) {
|
||||||
const questions = normalizeQuestions(extractQuestionsFromText(raw));
|
const questions = normalizeQuestions(extractQuestionsFromText(raw));
|
||||||
return {
|
return {
|
||||||
@@ -149,16 +252,20 @@ function parseWorkerResponse(
|
|||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const fallback: Record<string, string> = {};
|
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
|
||||||
if (outputTags.length === 1) {
|
if (target && isUsableContextFragment(raw)) {
|
||||||
fallback[outputTags[0]] = raw;
|
return finalizeParsedOutputs(
|
||||||
} else {
|
{ [target]: raw },
|
||||||
fallback[outputTags[0] ?? "output.草稿"] = raw;
|
{ summary: raw.slice(0, 80) },
|
||||||
|
outputTags,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
const questions = normalizeQuestions(extractQuestionsFromText(raw));
|
||||||
return {
|
return {
|
||||||
outputs: fallback,
|
outputs: {},
|
||||||
summary: raw.slice(0, 80),
|
summary: "未产出可验收产物",
|
||||||
preview: raw.slice(0, 600),
|
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> = {};
|
const outputs: Record<string, string> = {};
|
||||||
|
|
||||||
if (outputsRaw && typeof outputsRaw === "object") {
|
if (outputsRaw && typeof outputsRaw === "object") {
|
||||||
for (const tag of outputTags) {
|
for (const tag of productOutputTags(outputTags)) {
|
||||||
const val = (outputsRaw as Record<string, unknown>)[tag];
|
assignOutput(outputs, tag, (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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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());
|
const isMetaAskToken = (s: string) => /^ask[_-]?user$/i.test(s.trim());
|
||||||
|
|
||||||
let askUser: QuestionItem[] | undefined;
|
let askUser: QuestionItem[] | undefined;
|
||||||
@@ -213,7 +323,7 @@ function parseWorkerResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// context-fragment.v1:正文内 追问/自评 → 挂到询问卡(不必再抄一份 askUser)
|
// 先抽追问,再丢掉半残片段——无正文时仍要把题留给 LLM loop
|
||||||
let askAssessment: string | undefined;
|
let askAssessment: string | undefined;
|
||||||
const fragQuestions: QuestionItem[] = [];
|
const fragQuestions: QuestionItem[] = [];
|
||||||
for (const content of Object.values(outputs)) {
|
for (const content of Object.values(outputs)) {
|
||||||
@@ -226,19 +336,20 @@ function parseWorkerResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fragQuestions.length) {
|
if (fragQuestions.length) {
|
||||||
if (!askUser?.length) {
|
askUser = mergeQuestionsPreferFragment(askUser, fragQuestions);
|
||||||
askUser = fragQuestions;
|
}
|
||||||
} else {
|
|
||||||
for (const q of fragQuestions) {
|
dropUnusableFragmentOutputs(outputs);
|
||||||
if (!askUser.some((x) => x.prompt === q.prompt)) askUser.push(q);
|
|
||||||
}
|
if (Object.keys(outputs).length === 0 && (!askUser || askUser.length === 0)) {
|
||||||
}
|
askUser = normalizeQuestions(INCOMPLETE_FRAGMENT_ASK);
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary =
|
const summary =
|
||||||
typeof obj.summary === "string" && obj.summary.trim()
|
typeof obj.summary === "string" && obj.summary.trim()
|
||||||
? 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 =
|
const preview =
|
||||||
Object.entries(outputs)
|
Object.entries(outputs)
|
||||||
@@ -379,6 +490,9 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
|||||||
} catch {
|
} catch {
|
||||||
acceptedStepNames = [];
|
acceptedStepNames = [];
|
||||||
}
|
}
|
||||||
|
const filledArtifactTags = params.blackboard
|
||||||
|
.listTagIndex()
|
||||||
|
.map((item) => item.tag);
|
||||||
const loaded = await loadWorkerSkillWithContext(
|
const loaded = await loadWorkerSkillWithContext(
|
||||||
params.skillName,
|
params.skillName,
|
||||||
workerId,
|
workerId,
|
||||||
@@ -388,6 +502,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
|||||||
currentStepName,
|
currentStepName,
|
||||||
acceptedStepNames,
|
acceptedStepNames,
|
||||||
selectedRecipeRef,
|
selectedRecipeRef,
|
||||||
|
filledArtifactTags,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
worker = loaded.worker;
|
worker = loaded.worker;
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ if exist ".env" (
|
|||||||
)
|
)
|
||||||
|
|
||||||
echo [信息] 启动桌面窗口 http://localhost:%PORT%
|
echo [信息] 启动桌面窗口 http://localhost:%PORT%
|
||||||
echo [信息] 调试: F12 或 Ctrl+Shift+I 打开开发者工具
|
echo [信息] 后端日志在 %USERPROFILE%\.writing-agent\logs\runtime.log
|
||||||
echo [信息] 自动打开 DevTools: set WRITING_AGENT_ELECTRON_DEV=1 ^&^& npm run electron
|
echo [信息] 显示控制台请用 start-app.bat console
|
||||||
|
echo [信息] F12 或 Ctrl+Shift+I 打开开发者工具,只有前端
|
||||||
echo [信息] 关闭窗口即停止服务
|
echo [信息] 关闭窗口即停止服务
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
|
|||||||
@@ -41,13 +41,15 @@ if exist ".env" (
|
|||||||
)
|
)
|
||||||
|
|
||||||
echo [信息] 启动服务 http://localhost:%PORT%
|
echo [信息] 启动服务 http://localhost:%PORT%
|
||||||
echo [信息] 调试: 浏览器 F12 打开开发者工具
|
echo [信息] 改 src 或 skills 后后端会自动重启
|
||||||
echo [信息] 桌面壳: 运行 start-app.bat
|
echo [信息] 后端日志在本窗口,文件在 %USERPROFILE%\.writing-agent\logs\runtime.log
|
||||||
|
echo [信息] 浏览器按 F12 打开开发者工具
|
||||||
|
echo [信息] 桌面窗口请用 start-app.bat
|
||||||
echo [信息] 按 Ctrl+C 停止
|
echo [信息] 按 Ctrl+C 停止
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
start "" "http://localhost:%PORT%"
|
start "" "http://localhost:%PORT%"
|
||||||
|
|
||||||
call npm run web
|
call npm run web:watch
|
||||||
|
|
||||||
pause
|
pause
|
||||||
|
|||||||
@@ -79,6 +79,38 @@ describe("contextSegments assembly", () => {
|
|||||||
expect(text).not.toMatch(/^\s*\{/);
|
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", () => {
|
it("falls back to JSON inputs without segments", () => {
|
||||||
const bb = new Blackboard();
|
const bb = new Blackboard();
|
||||||
const text = assembleWorkerContext({
|
const text = assembleWorkerContext({
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
projectFragmentContent,
|
projectFragmentContent,
|
||||||
contextFragmentToView,
|
contextFragmentToView,
|
||||||
extractFragmentAskSidecar,
|
extractFragmentAskSidecar,
|
||||||
|
isUsableContextFragment,
|
||||||
|
expectsContextFragmentTag,
|
||||||
} from "../src/skills/context-fragment.js";
|
} from "../src/skills/context-fragment.js";
|
||||||
import {
|
import {
|
||||||
parseContextOrder,
|
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", () => {
|
describe("context-order.v1", () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
artifactTagForStep,
|
artifactTagForStep,
|
||||||
extractModuleOpening,
|
extractModuleOpening,
|
||||||
formatCreationFlowForUser,
|
formatCreationFlowForUser,
|
||||||
|
formatFlowProgressForAgent,
|
||||||
formatModuleCatalogForAgent,
|
formatModuleCatalogForAgent,
|
||||||
formatRecipeCatalogForAgent,
|
formatRecipeCatalogForAgent,
|
||||||
formatSelectedRecipeForAgent,
|
formatSelectedRecipeForAgent,
|
||||||
@@ -14,16 +15,23 @@ import {
|
|||||||
loadModuleCatalog,
|
loadModuleCatalog,
|
||||||
loadModulePrompt,
|
loadModulePrompt,
|
||||||
loadRecipeCatalog,
|
loadRecipeCatalog,
|
||||||
|
mergeCreationFlowPreservingAccepted,
|
||||||
needsFlowExpansion,
|
needsFlowExpansion,
|
||||||
nextPendingStep,
|
nextPendingStep,
|
||||||
parseCreationFlow,
|
parseCreationFlow,
|
||||||
|
pickRecordedStepId,
|
||||||
parseModuleCatalog,
|
parseModuleCatalog,
|
||||||
parseModuleOpeningState,
|
parseModuleOpeningState,
|
||||||
parseRecipeCatalog,
|
parseRecipeCatalog,
|
||||||
parseRecipeYaml,
|
parseRecipeYaml,
|
||||||
parseSelectedRecipeRef,
|
parseSelectedRecipeRef,
|
||||||
resolveDesignStepBinding,
|
resolveDesignStepBinding,
|
||||||
|
shouldSkipModuleOpening,
|
||||||
|
isProgressPointerTag,
|
||||||
|
stringifyCreationFlow,
|
||||||
|
creationFlowFromRecipeSeed,
|
||||||
validateCreationFlow,
|
validateCreationFlow,
|
||||||
|
validateStepReadyToRun,
|
||||||
} from "../src/skills/creation-flow.js";
|
} from "../src/skills/creation-flow.js";
|
||||||
import { loadWorkerSkillWithContext } from "../src/skills/loader.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", () => {
|
it("allows duplicate capability names with distinct ids", () => {
|
||||||
const flow = parseCreationFlow(`{
|
const flow = parseCreationFlow(`{
|
||||||
"status": "open",
|
"status": "open",
|
||||||
@@ -171,6 +252,29 @@ describe("creation-flow", () => {
|
|||||||
});
|
});
|
||||||
expect(view.steps[2]?.occurrence).toBe(2);
|
expect(view.steps[2]?.occurrence).toBe(2);
|
||||||
expect(view.steps[2]?.repeatable).toBe(true);
|
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 () => {
|
it("loads world-simulator catalog and formats for agent", async () => {
|
||||||
@@ -273,6 +377,18 @@ steps:
|
|||||||
expect(formatted).toContain("用户已选配方");
|
expect(formatted).toContain("用户已选配方");
|
||||||
expect(formatted).toContain("核心思路");
|
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", () => {
|
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).toContain("增量");
|
expect(selected.promptBody).toContain("增量");
|
||||||
|
expect(selected.promptBody).toContain("【流程进度】");
|
||||||
expect(selected.promptBody).not.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 () => {
|
it("injects aesthetics-interaction module prompt into design-step", async () => {
|
||||||
const flowRaw = JSON.stringify({
|
const flowRaw = JSON.stringify({
|
||||||
steps: [{ name: "美学纲领与交互范式", depends_on: [] }],
|
steps: [{ name: "美学纲领与交互范式", depends_on: [] }],
|
||||||
@@ -344,6 +501,8 @@ recipes:
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(loaded.worker.outputTags).toContain("设计.美学纲领与交互范式");
|
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("体验契约");
|
expect(loaded.promptBody).toContain("体验契约");
|
||||||
expect(loaded.promptBody).toContain("程序开场");
|
expect(loaded.promptBody).toContain("程序开场");
|
||||||
@@ -386,6 +545,35 @@ recipes:
|
|||||||
美学纲领与交互范式: "shown",
|
美学纲领与交互范式: "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({
|
const binding = await resolveDesignStepBinding({
|
||||||
skillPackRoot: "dialogue/world-simulator",
|
skillPackRoot: "dialogue/world-simulator",
|
||||||
flowRaw: JSON.stringify({
|
flowRaw: JSON.stringify({
|
||||||
@@ -428,9 +616,11 @@ recipes:
|
|||||||
const missing = parseCreationFlow(`{
|
const missing = parseCreationFlow(`{
|
||||||
"steps": [{ "name": "生成规则", "depends_on": [] }]
|
"steps": [{ "name": "生成规则", "depends_on": [] }]
|
||||||
}`)!;
|
}`)!;
|
||||||
const bad = validateCreationFlow(missing, catalog);
|
// 编排期允许空壳;必填 params 在确认开干时钉齐
|
||||||
expect(bad.ok).toBe(false);
|
expect(validateCreationFlow(missing, catalog).ok).toBe(true);
|
||||||
expect(bad.errors.some((e) => e.includes("target"))).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!);
|
const block = formatModuleCatalogForAgent(catalog!);
|
||||||
expect(block).toContain("编排参数");
|
expect(block).toContain("编排参数");
|
||||||
@@ -483,6 +673,43 @@ recipes:
|
|||||||
).toBeNull();
|
).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", () => {
|
it("open flow needs expansion after listed steps accepted", () => {
|
||||||
const flow = parseCreationFlow(`{
|
const flow = parseCreationFlow(`{
|
||||||
"status": "open",
|
"status": "open",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
displayStageLabel,
|
displayStageLabel,
|
||||||
displayWorkerLabel,
|
displayWorkerLabel,
|
||||||
formatWorkerDisplayTitle,
|
formatWorkerDisplayTitle,
|
||||||
|
reviewComposerCopy,
|
||||||
} from "../src/server/display-labels.js";
|
} from "../src/server/display-labels.js";
|
||||||
|
|
||||||
describe("display-labels", () => {
|
describe("display-labels", () => {
|
||||||
@@ -27,4 +28,22 @@ describe("display-labels", () => {
|
|||||||
);
|
);
|
||||||
expect(displayWorkerLabel("worker:narrator")).toBe("执行单元 · 叙事转述");
|
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集.草稿"],
|
outputTags: ["设计.worker集.草稿"],
|
||||||
});
|
});
|
||||||
session = { ...session, artifacts: [artifact] };
|
session = { ...session, artifacts: [artifact] };
|
||||||
session = applyEvent(session, {
|
const completed = applyEvent(session, {
|
||||||
type: "worker_completed",
|
type: "worker_completed",
|
||||||
payload: {
|
payload: {
|
||||||
artifactId: artifact.id,
|
artifactId: artifact.id,
|
||||||
@@ -95,12 +95,22 @@ describe("phase machine", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}).session;
|
});
|
||||||
|
session = completed.session;
|
||||||
|
|
||||||
expect(session.waitingReason?.kind).toBe("review_artifact");
|
expect(session.waitingReason?.kind).toBe("review_artifact");
|
||||||
if (session.waitingReason?.kind !== "review_artifact") return;
|
if (session.waitingReason?.kind !== "review_artifact") return;
|
||||||
expect(session.waitingReason.questions?.length).toBe(1);
|
expect(session.waitingReason.questions?.length).toBe(1);
|
||||||
expect(session.waitingReason.questions?.[0]?.required).toBe(false);
|
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, {
|
session = applyEvent(session, {
|
||||||
@@ -159,4 +169,152 @@ describe("phase machine", () => {
|
|||||||
}).session;
|
}).session;
|
||||||
expect(session.slots.designInstanceReady).toBeUndefined();
|
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 { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
appendAskedQuestions,
|
||||||
|
dropAlreadyAskedQuestions,
|
||||||
formatQuestionAnswersForAi,
|
formatQuestionAnswersForAi,
|
||||||
formatQuestionAnswersForDisplay,
|
formatQuestionAnswersForDisplay,
|
||||||
|
isModuleOpeningQuestions,
|
||||||
|
MODULE_OPENING_QUESTION_ID,
|
||||||
|
mergeQuestionsPreferFragment,
|
||||||
normalizeQuestions,
|
normalizeQuestions,
|
||||||
|
parseAskedQuestions,
|
||||||
} from "../src/skills/question-protocol.js";
|
} from "../src/skills/question-protocol.js";
|
||||||
|
|
||||||
describe("normalizeQuestions", () => {
|
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", () => {
|
describe("formatQuestionAnswers", () => {
|
||||||
it("AI payload keeps Q+A; display omits prompts", () => {
|
it("AI payload keeps Q+A; display omits prompts", () => {
|
||||||
const questions = normalizeQuestions([
|
const questions = normalizeQuestions([
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
MockLlmProvider,
|
MockLlmProvider,
|
||||||
} from "../src/llm/client.js";
|
} from "../src/llm/client.js";
|
||||||
import { runMainAgentToolLoop } from "../src/main-agent/tool-loop.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 { toolCallToDecision } from "../src/runtime/tool-registry.js";
|
||||||
import { createSession } from "../src/runtime/phase-machine.js";
|
import { createSession } from "../src/runtime/phase-machine.js";
|
||||||
|
|
||||||
@@ -22,6 +23,12 @@ describe("main agent tool loop", () => {
|
|||||||
listArtifacts: () => [],
|
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 () => {
|
it("runs loop tools then returns terminal decision", async () => {
|
||||||
const llm = new MockLlmProvider([
|
const llm = new MockLlmProvider([
|
||||||
createMockToolCall("read_blackboard", { tags: ["book.brief"] }),
|
createMockToolCall("read_blackboard", { tags: ["book.brief"] }),
|
||||||
|
|||||||
@@ -119,4 +119,114 @@ describe("sanitizeWorkerSetOutputs", () => {
|
|||||||
expect(result.askAssessment).toContain("美学纲领 6/10");
|
expect(result.askAssessment).toContain("美学纲领 6/10");
|
||||||
expect(result.askAssessment).toContain("还需确认");
|
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} · 执行中`;
|
if (action === "running") return `${base} · 执行中`;
|
||||||
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-panels">
|
||||||
<div class="rail-tab-panel active" id="rail-panel-books" role="tabpanel" data-rail-panel="books">
|
<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 class="book-list" id="book-list"></div>
|
||||||
<div id="book-box-overlay" class="book-box-overlay" hidden aria-hidden="true"></div>
|
<div id="book-box-overlay" class="book-box-overlay" hidden aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,14 +60,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="main-head-right">
|
<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-instance" hidden>保存定稿</button>
|
||||||
<button type="button" class="btn-sm" id="btn-save-play" 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>
|
<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>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -77,8 +82,28 @@
|
|||||||
<div id="message-feed" class="message-feed">
|
<div id="message-feed" class="message-feed">
|
||||||
<p class="empty">点击左侧 + 新建作品</p>
|
<p class="empty">点击左侧 + 新建作品</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="workspace-stage" class="workspace-stage" hidden aria-label="验收工作面"></div>
|
||||||
<div id="questions-card-host" class="questions-card-host" hidden></div>
|
<div id="questions-card-host" class="questions-card-host" hidden></div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|
||||||
<footer class="composer" id="composer"></footer>
|
<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-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-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" 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>
|
</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">
|
<form method="dialog" class="context-trace-form">
|
||||||
<h2>请求上下文</h2>
|
<h2 id="context-trace-title">上下文调用日志</h2>
|
||||||
<p class="dialog-desc" id="context-trace-meta"></p>
|
<p class="dialog-desc" id="context-trace-meta"></p>
|
||||||
<pre id="context-trace-body" class="context-trace-body"></pre>
|
<pre id="context-trace-body" class="context-trace-body"></pre>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
|
|||||||
@@ -2,10 +2,15 @@
|
|||||||
* 结构化询问卡:点字母选中,点文案编辑;左右分页。
|
* 结构化询问卡:点字母选中,点文案编辑;左右分页。
|
||||||
* 询问是对同一次发送的可选增强:有选中则问+答拼接;未选中则不带问。
|
* 询问是对同一次发送的可选增强:有选中则问+答拼接;未选中则不带问。
|
||||||
* 点发送即答复 → 收起本轮卡(卡上无独立发送钮)。
|
* 点发送即答复 → 收起本轮卡(卡上无独立发送钮)。
|
||||||
* 产物验收挂载题:底栏有字优先按「改产物」发送;无字时才提交所选答案。
|
* 产物验收挂载题:选项 + 底栏意见一并随「按意见修改」写回产物;空内容点该钮不等于接受。
|
||||||
* 分页 / 跳过为悬浮控件,不占独立 header/footer 行。
|
* 分页 / 跳过为悬浮控件,不占独立 header/footer 行。
|
||||||
|
*
|
||||||
|
* 能力「默认问题」(id=module-opening) 不是追问:对齐美学纲领开局,走说话面 + openingGuide。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** 与 phase-runtime maybeAskModuleOpening 写入的 id 对齐 */
|
||||||
|
export const MODULE_OPENING_QUESTION_ID = "module-opening";
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s ?? "")
|
return String(s ?? "")
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -14,6 +19,22 @@ function esc(s) {
|
|||||||
.replace(/"/g, """);
|
.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) {
|
function normalizeQuestions(raw) {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
return raw
|
return raw
|
||||||
@@ -64,6 +85,8 @@ export function getActiveQuestions(view) {
|
|||||||
// 默认每页 1 题,左右切换;勿一次堆多题
|
// 默认每页 1 题,左右切换;勿一次堆多题
|
||||||
const pageSize = Math.max(1, Number(wr.pageSize) || 1);
|
const pageSize = Math.max(1, Number(wr.pageSize) || 1);
|
||||||
if (wr.kind === "worker_questions" && wr.questions?.length) {
|
if (wr.kind === "worker_questions" && wr.questions?.length) {
|
||||||
|
// 能力默认问题:不进答题卡(与美学纲领 openingGuide 同形)
|
||||||
|
if (isModuleOpeningWaiting(view)) return null;
|
||||||
return {
|
return {
|
||||||
questions: normalizeQuestions(wr.questions),
|
questions: normalizeQuestions(wr.questions),
|
||||||
workerId: wr.workerId,
|
workerId: wr.workerId,
|
||||||
@@ -114,6 +137,18 @@ export function getActiveQuestions(view) {
|
|||||||
return null;
|
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) {
|
function persistDraftsFromDom(host, state) {
|
||||||
host.querySelectorAll("[data-draft]").forEach((el) => {
|
host.querySelectorAll("[data-draft]").forEach((el) => {
|
||||||
const key = el.getAttribute("data-draft");
|
const key = el.getAttribute("data-draft");
|
||||||
@@ -224,7 +259,9 @@ export function clearQuestionCardState(host, view) {
|
|||||||
host._qState = null;
|
host._qState = null;
|
||||||
host.hidden = true;
|
host.hidden = true;
|
||||||
host.innerHTML = "";
|
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) {
|
if (!active?.questions?.length) {
|
||||||
host.hidden = true;
|
host.hidden = true;
|
||||||
host.innerHTML = "";
|
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;
|
host._qDismissed = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -251,7 +290,9 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
|
|||||||
) {
|
) {
|
||||||
host.hidden = true;
|
host.hidden = true;
|
||||||
host.innerHTML = "";
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,11 +305,14 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
|
|||||||
sessionId: view.id,
|
sessionId: view.id,
|
||||||
key: qKey,
|
key: qKey,
|
||||||
page: 0,
|
page: 0,
|
||||||
|
expanded: false,
|
||||||
// questionId -> { optionId?, text, otherText? }
|
// questionId -> { optionId?, text, otherText? }
|
||||||
answers: {},
|
answers: {},
|
||||||
drafts: {}, // option key -> edited label
|
drafts: {}, // option key -> edited label
|
||||||
};
|
};
|
||||||
|
if (typeof state.expanded !== "boolean") state.expanded = false;
|
||||||
host._qState = state;
|
host._qState = state;
|
||||||
|
host.classList.toggle("is-expanded", state.expanded);
|
||||||
|
|
||||||
const { questions, pageSize, assessment, optional, skipLabel, dismissOnAccept } =
|
const { questions, pageSize, assessment, optional, skipLabel, dismissOnAccept } =
|
||||||
active;
|
active;
|
||||||
@@ -303,20 +347,24 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
|
|||||||
? `<button type="button" class="qcard-skip" data-qskip title="Esc">${esc(skipLabel || "跳过")}</button>`
|
? `<button type="button" class="qcard-skip" data-qskip title="Esc">${esc(skipLabel || "跳过")}</button>`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const chromeClass = [
|
const chromeMods = [
|
||||||
"qcard",
|
|
||||||
pageCount > 1 ? "has-pager" : "",
|
pageCount > 1 ? "has-pager" : "",
|
||||||
optional ? "has-skip" : "",
|
optional ? "has-skip" : "",
|
||||||
]
|
].filter(Boolean);
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
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 = `
|
host.innerHTML = `
|
||||||
<div class="${chromeClass}" role="dialog" aria-label="询问">
|
|
||||||
<div class="qcard-float">
|
<div class="qcard-float">
|
||||||
${pagerHtml}
|
${pagerHtml}
|
||||||
${skipHtml}
|
${skipHtml}
|
||||||
</div>
|
</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">
|
<div class="qcard-body">
|
||||||
${dismissNote}
|
${dismissNote}
|
||||||
${assessmentHtml}
|
${assessmentHtml}
|
||||||
@@ -364,8 +412,43 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
|
|||||||
</section>`;
|
</section>`;
|
||||||
})
|
})
|
||||||
.join("")}
|
.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", () => {
|
host.querySelector("[data-qskip]")?.addEventListener("click", () => {
|
||||||
_handlers.onSkipQuestions?.();
|
_handlers.onSkipQuestions?.();
|
||||||
@@ -401,17 +484,37 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
|
|||||||
text = (labelEl?.textContent ?? state.drafts[draftKey] ?? "").trim();
|
text = (labelEl?.textContent ?? state.drafts[draftKey] ?? "").trim();
|
||||||
state.answers[qid] = { optionId: oid, text };
|
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) => {
|
host.querySelectorAll("[data-draft]").forEach((el) => {
|
||||||
el.addEventListener("keydown", (e) => {
|
el.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key !== "Enter" || e.shiftKey || e.isComposing) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
el.blur();
|
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", () => {
|
el.addEventListener("blur", () => {
|
||||||
const key = el.getAttribute("data-draft");
|
const key = el.getAttribute("data-draft");
|
||||||
if (key) state.drafts[key] = el.textContent ?? "";
|
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,
|
html,
|
||||||
body.shells-page {
|
body.shells-page {
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
background: var(--st-bg, #f4f4f5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shells-page .st-shell.shells-shell {
|
.shells-page .st-shell.shells-shell {
|
||||||
max-width: 1180px;
|
max-width: 1280px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
grid-template-columns: 200px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shells-page .st-rail {
|
.shells-page .st-rail {
|
||||||
--st-rail-w: 220px;
|
width: 200px;
|
||||||
width: var(--st-rail-w);
|
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: var(--st-top-h, 48px);
|
top: var(--st-top-h, 48px);
|
||||||
max-height: calc(100dvh - var(--st-top-h, 48px));
|
max-height: calc(100dvh - var(--st-top-h, 48px));
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shells-tone-label {
|
.st-rail-hint {
|
||||||
margin-top: 1rem !important;
|
margin: 0 8px 10px;
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
line-height: 1.45;
|
||||||
letter-spacing: 0.04em;
|
color: var(--st-faint, #a1a1aa);
|
||||||
color: var(--muted, #666);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shells-list {
|
.shells-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
flex: 0 0 auto;
|
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 {
|
.shells-tones {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -66,65 +128,102 @@ body.shells-page {
|
|||||||
.shells-tone-chip {
|
.shells-tone-chip {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 4px 10px;
|
padding: 5px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border, #ddd);
|
border: 1px solid var(--st-border, #e4e4e7);
|
||||||
background: var(--surface, #fff);
|
background: var(--st-surface, #fff);
|
||||||
cursor: pointer;
|
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 {
|
.shells-tone-chip.active {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background: var(--accent-soft, #e8f0fe);
|
background: var(--st-accent-soft, #dbeafe);
|
||||||
|
color: var(--st-accent, #2563eb);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shells-body {
|
/* 主工作面:尽量大的阅读画布 */
|
||||||
display: flex;
|
.shells-preview-wrap {
|
||||||
flex-direction: column;
|
flex: 1 1 auto;
|
||||||
gap: 1.25rem;
|
padding: 20px 24px 28px;
|
||||||
}
|
background: #ebebed;
|
||||||
|
|
||||||
.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 {
|
.shells-preview {
|
||||||
min-height: 280px;
|
min-height: min(70vh, 720px);
|
||||||
padding: 16px;
|
padding: 20px 16px 28px;
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
border: 1px dashed var(--border, #ccc);
|
border: 1px solid var(--st-border, #e4e4e7);
|
||||||
background:
|
background: #f4f4f5;
|
||||||
radial-gradient(ellipse at 20% 0%, rgba(15, 118, 110, 0.06), transparent 50%),
|
overflow: visible;
|
||||||
var(--bg, #f6f6f4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shells-preview .present-shell {
|
.shells-preview .present-shell {
|
||||||
max-width: 640px;
|
max-width: 42rem;
|
||||||
margin: 0 auto;
|
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="turn_panel"] .present-shell,
|
||||||
.shells-preview[data-shell="split_board"] .present-shell,
|
.shells-preview[data-shell="split_board"] .present-shell,
|
||||||
.shells-preview[data-shell="chapter_reader"] .present-shell {
|
.shells-preview[data-shell="chapter_reader"] .present-shell {
|
||||||
max-width: 780px;
|
max-width: 52rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 预览区不裁切长正文 */
|
.shells-preview .present-shell-badge {
|
||||||
.shells-preview {
|
display: none;
|
||||||
overflow: visible;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.shells-tweak-grid {
|
||||||
@@ -144,30 +243,68 @@ body.shells-page {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-radius: 6px;
|
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;
|
margin: 0;
|
||||||
padding-left: 1.2rem;
|
padding-left: 1.15rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
color: var(--st-muted, #71717a);
|
||||||
|
|
||||||
.shells-help .muted {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted, #888);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
|
.shells-page .st-shell.shells-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.shells-page .st-rail {
|
.shells-page .st-rail {
|
||||||
position: static;
|
position: static;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
width: auto;
|
width: auto;
|
||||||
overflow: visible;
|
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 {
|
.shells-preview .present-shell {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.shells-details {
|
||||||
|
margin: 0 12px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,44 +28,42 @@
|
|||||||
|
|
||||||
<div class="st-shell shells-shell">
|
<div class="st-shell shells-shell">
|
||||||
<aside class="st-rail" aria-label="壳列表">
|
<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>
|
<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">
|
<p class="st-rail-note">
|
||||||
正文按 600~3000 字设计:随字数撑高、页面滚动;侧栏/选择坞钉住。创作只改内容绑定。
|
主任务永远是读正文(常 600~3000 字)。侧栏只换壳;长度/语气在预览顶栏。
|
||||||
</p>
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="st-main">
|
<main class="st-main shells-main">
|
||||||
<header class="st-main-head">
|
<div class="shells-toolbar" role="toolbar" aria-label="预览条件">
|
||||||
<div>
|
<div class="shells-toolbar-block">
|
||||||
<h1 id="panel-title">呈现壳预览</h1>
|
<span class="shells-toolbar-label" id="length-label">正文长度</span>
|
||||||
<p class="st-sub" id="panel-subtitle">用示例数据看布局与可调项</p>
|
<div class="shells-tones" id="length-list" role="group" aria-labelledby="length-label"></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<div class="shells-toolbar-block">
|
||||||
<div class="st-body shells-body">
|
<span class="shells-toolbar-label" id="tone-label">语气</span>
|
||||||
<section class="shells-meta" id="shell-meta"></section>
|
<div class="shells-tones" id="tone-list" role="group" aria-labelledby="tone-label"></div>
|
||||||
<section class="shells-preview-wrap">
|
</div>
|
||||||
<h2 class="shells-preview-label">预览</h2>
|
<div class="shells-toolbar-block shells-toolbar-meta" id="shell-meta"></div>
|
||||||
<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>
|
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +1,64 @@
|
|||||||
/**
|
/**
|
||||||
* 呈现壳预览页:切换壳 / 装饰语气 / 正文长度,用示例数据渲染 present-shells。
|
* 呈现壳预览:主工作面 = 壳画布。
|
||||||
* 正文设计目标:单轮常见 600~3000 字 —— 随字数撑高,外层滚动;侧栏/选项坞钉住。
|
* 按「要同时看见什么」选壳;正文按 600~3000 字撑高滚动。
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
PRESENT_SHELL_IDS,
|
PRESENT_SHELL_IDS,
|
||||||
renderPresentShellHtml,
|
renderPresentShellHtml,
|
||||||
} from "./present-shells.js";
|
} from "./present-shells.js";
|
||||||
|
|
||||||
|
/** task = 同时可见关系(选壳依据);name = 短名 */
|
||||||
const SHELL_INFO = {
|
const SHELL_INFO = {
|
||||||
prose: {
|
prose: {
|
||||||
name: "纯散文",
|
name: "纯散文",
|
||||||
blurb: "整块都是正文,无 HUD。长文直接往下滚。",
|
task: "只要正文",
|
||||||
density: "正文随字数伸展;无侧栏争面积",
|
blurb: "无 HUD。读完即止。",
|
||||||
scenes: "轻对话、纯叙事、不要状态栏",
|
|
||||||
},
|
},
|
||||||
chat_monitor: {
|
chat_monitor: {
|
||||||
name: "对话 + 监控",
|
name: "对话+监控",
|
||||||
blurb: "顶栏芯片固定矮;主聊/场面再长也只往下长。",
|
task: "正文 + 扫状态",
|
||||||
density: "监控一行;正文撑高滚动",
|
blurb: "顶栏芯片矮;主聊/场面往下长。",
|
||||||
scenes: "网恋 / 日常扮演 / 轻 AIRP",
|
|
||||||
},
|
},
|
||||||
spotlight: {
|
spotlight: {
|
||||||
name: "场面主视",
|
name: "场面主视",
|
||||||
blurb: "芯片一行 + 大正文柱。适合一幕写满。",
|
task: "大正文 + 弱状态",
|
||||||
density: "正文主导并伸展;监控不占高",
|
blurb: "无聊天气泡框;场面即画布。",
|
||||||
scenes: "电影感 RP、一幕一景、少机制",
|
|
||||||
},
|
},
|
||||||
turn_panel: {
|
turn_panel: {
|
||||||
name: "回合面板",
|
name: "回合面板",
|
||||||
blurb: "场面叙述再长,右侧交互物钉在视口内对照。",
|
task: "场面 + 物 + 行动",
|
||||||
density: "正文撑高;侧栏 sticky;行动在下",
|
blurb: "对照交互物;行动在下。",
|
||||||
scenes: "资源、检定、场景交互、跑团感",
|
|
||||||
},
|
},
|
||||||
split_board: {
|
split_board: {
|
||||||
name: "双栏看板",
|
name: "双栏看板",
|
||||||
blurb: "左栏长文滚动,右栏线索钉住——对照读。",
|
task: "正文 ‖ 线索",
|
||||||
density: "左正文伸展;右侧栏 sticky",
|
blurb: "左右对照;侧栏钉住。",
|
||||||
scenes: "调查、推理、多线索并行",
|
|
||||||
},
|
},
|
||||||
choice_dock: {
|
choice_dock: {
|
||||||
name: "选择坞",
|
name: "选择坞",
|
||||||
blurb: "先读完长局面;选项坞 sticky 贴视口底,不用翻回顶。",
|
task: "局面 + 选项",
|
||||||
density: "正文撑高;底部坞钉住",
|
blurb: "读完再选;坞贴视口底。",
|
||||||
scenes: "选项驱动、AVG、分支关口",
|
|
||||||
},
|
},
|
||||||
chapter_reader: {
|
chapter_reader: {
|
||||||
name: "章节阅读",
|
name: "章节阅读",
|
||||||
blurb: "章题 + 长阅读柱;窄进度侧栏 sticky。",
|
task: "章正文 + 进度",
|
||||||
density: "正文伸展为主;侧栏窄且钉住",
|
blurb: "阅读柱为主;进度窄栏钉住。",
|
||||||
scenes: "长文 / 爽文 / 先纲后章",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const TONES = [
|
const TONES = [
|
||||||
{ id: "default", label: "默认" },
|
{ id: "default", label: "默认" },
|
||||||
{ id: "messenger", label: "讯息感" },
|
{ id: "messenger", label: "讯息" },
|
||||||
{ id: "book", label: "书页感" },
|
{ id: "book", label: "书页" },
|
||||||
{ id: "terminal", label: "终端感" },
|
{ id: "terminal", label: "终端" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const LENGTHS = [
|
const LENGTHS = [
|
||||||
{ id: "short", label: "短(~120)" },
|
{ id: "short", label: "短" },
|
||||||
{ id: "mid", label: "中(~800)" },
|
{ id: "mid", label: "中 ·800" },
|
||||||
{ id: "long", label: "长(~2500)" },
|
{ id: "long", label: "长 ·2500" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 中等长度 ≈800 字 */
|
|
||||||
const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话,只是把购物袋往怀里收紧了一点。水从伞骨边缘连成线,砸在柏油路上,溅起细碎的白点。
|
const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话,只是把购物袋往怀里收紧了一点。水从伞骨边缘连成线,砸在柏油路上,溅起细碎的白点。
|
||||||
|
|
||||||
路口的灯跳成绿色。你们一起迈步——这一次,谁也没有先松开。对面便利店的玻璃上映着两个人并肩的轮廓,忽明忽暗,像谁随手画的草稿。
|
路口的灯跳成绿色。你们一起迈步——这一次,谁也没有先松开。对面便利店的玻璃上映着两个人并肩的轮廓,忽明忽暗,像谁随手画的草稿。
|
||||||
@@ -82,7 +75,6 @@ const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话
|
|||||||
|
|
||||||
门开了一条缝。暖光漏出来,照在你们脚边的水渍上。她没有立刻进去,只是抬眼看你,像在等一句还没说出口的话。你把伞收拢,水仍顺着伞尖滴到地毯边缘。谁也没有提「进来坐坐」,但空气里已经有了那句话的形状。`;
|
门开了一条缝。暖光漏出来,照在你们脚边的水渍上。她没有立刻进去,只是抬眼看你,像在等一句还没说出口的话。你把伞收拢,水仍顺着伞尖滴到地毯边缘。谁也没有提「进来坐坐」,但空气里已经有了那句话的形状。`;
|
||||||
|
|
||||||
/** 长文 ≈2500 字(重复段落拼成,预览用) */
|
|
||||||
const BODY_LONG = `${BODY_MID}
|
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,
|
show_suggested_actions: state.showActions,
|
||||||
block_labels: { ...state.blockLabels },
|
block_labels: { ...state.blockLabels },
|
||||||
empty_states: {
|
empty_states: {
|
||||||
aside: "(侧栏空:可在创作里改空态文案)",
|
aside: "(侧栏空)",
|
||||||
footer: "(文末空)",
|
footer: "(文末空)",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -252,27 +242,25 @@ function buildPacket() {
|
|||||||
function render() {
|
function render() {
|
||||||
const info = SHELL_INFO[state.shell] || {
|
const info = SHELL_INFO[state.shell] || {
|
||||||
name: state.shell,
|
name: state.shell,
|
||||||
|
task: "",
|
||||||
blurb: "",
|
blurb: "",
|
||||||
scenes: "",
|
|
||||||
density: "",
|
|
||||||
};
|
};
|
||||||
const packet = buildPacket();
|
const packet = buildPacket();
|
||||||
document.getElementById("panel-title").textContent = info.name;
|
|
||||||
document.getElementById("panel-subtitle").textContent = `${info.scenes} · 正文约 ${packet._chars} 字(不计空白)`;
|
|
||||||
|
|
||||||
document.getElementById("shell-meta").innerHTML = `
|
document.getElementById("shell-meta").innerHTML = `
|
||||||
<p><strong>${esc(info.name)}</strong>(<code>${esc(state.shell)}</code>)</p>
|
<p class="shells-meta-title">${esc(info.name)}</p>
|
||||||
<p>${esc(info.blurb)}</p>
|
<p class="shells-meta-sub">${esc(info.task)} · 试读 ${packet._chars} 字<br>${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>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const preview = document.getElementById("shell-preview");
|
const preview = document.getElementById("shell-preview");
|
||||||
preview.dataset.shell = state.shell;
|
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) => {
|
document.querySelectorAll("[data-shell]").forEach((btn) => {
|
||||||
btn.classList.toggle("active", btn.getAttribute("data-shell") === state.shell);
|
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) => {
|
document.querySelectorAll("[data-tone]").forEach((btn) => {
|
||||||
btn.classList.toggle("active", btn.getAttribute("data-tone") === state.tone);
|
btn.classList.toggle("active", btn.getAttribute("data-tone") === state.tone);
|
||||||
@@ -289,8 +277,9 @@ function mount() {
|
|||||||
const list = document.getElementById("shell-list");
|
const list = document.getElementById("shell-list");
|
||||||
list.innerHTML = PRESENT_SHELL_IDS.map((id) => {
|
list.innerHTML = PRESENT_SHELL_IDS.map((id) => {
|
||||||
const info = SHELL_INFO[id];
|
const info = SHELL_INFO[id];
|
||||||
return `<button type="button" class="st-rail-item" data-shell="${esc(id)}" role="tab">
|
return `<button type="button" class="st-rail-item" data-shell="${esc(id)}" role="tab" aria-selected="false">
|
||||||
${esc(info?.name || id)}
|
<span class="shell-item-name">${esc(info?.name || id)}</span>
|
||||||
|
<span class="shell-item-task">${esc(info?.task || "")}</span>
|
||||||
</button>`;
|
</button>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
@@ -307,15 +296,15 @@ function mount() {
|
|||||||
document.getElementById("tweak-controls").innerHTML = `
|
document.getElementById("tweak-controls").innerHTML = `
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<input type="checkbox" id="tweak-show-actions" />
|
<input type="checkbox" id="tweak-show-actions" />
|
||||||
显示「建议行动」(对应微调轴 show_suggested_actions)
|
显示建议行动(show_suggested_actions)
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
正文区显示名(block_labels.body)
|
正文显示名(block_labels.body)
|
||||||
<input type="text" id="tweak-label-body" placeholder="例如:短信记录 / 场面 / 本章" />
|
<input type="text" id="tweak-label-body" placeholder="短信记录 / 场面 / 本章" />
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
监控区显示名(block_labels.monitor)
|
监控显示名(block_labels.monitor)
|
||||||
<input type="text" id="tweak-label-monitor" placeholder="例如:状态 / 今日" />
|
<input type="text" id="tweak-label-monitor" placeholder="状态 / 今日" />
|
||||||
</label>
|
</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