Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7185f25dec | ||
|
|
66ed1eaca8 | ||
|
|
cf6cb68429 | ||
|
|
ff5c77bfb1 | ||
|
|
820397e578 | ||
|
|
1cc4ae557a | ||
|
|
d0a48f9431 | ||
|
|
bb4f934b3d | ||
|
|
d0274afb80 | ||
|
|
48801c656d | ||
|
|
d09829bdb8 | ||
|
|
8bd469ceeb |
@@ -0,0 +1,92 @@
|
|||||||
|
# Writing Agent
|
||||||
|
|
||||||
|
本地运行的多 Agent 文本创作与游玩平台:先为一部作品搭好可复用的运行规格,再进入游玩——你说一句,它按规格写一轮。适合交互式角色扮演、短局/长文、仿写扩写等场景。
|
||||||
|
|
||||||
|
数据默认落在本机用户目录(`~/.writing-agent`),通过浏览器或 Electron 桌面窗口使用;模型走你自己配置的 API。
|
||||||
|
|
||||||
|
## 它能做什么
|
||||||
|
|
||||||
|
- **创作**:为新作品选进料方式(工序编排 / 对话落盘(默认选项))与配方,由编排器调度技能,落成可复用的运行规格与开场
|
||||||
|
- **游玩**:在钉住的规格下回合推进;可切换呈现壳,保存游玩进度
|
||||||
|
- **技能包**:能力以磁盘上的技能包装载(当前主包为「世界模拟器」相关配方与模块)(目前不需要手动选择)
|
||||||
|
- **预设与库**:兼容 SillyTavern 风格的提示词预设;文风库 / 偏好库可注入创作节点,种子可从仓库恢复
|
||||||
|
- **多接口**:分组管理 API、嗅探型号、切换同一接口下的模型
|
||||||
|
- **本机优先**:Web(`localhost:23337`)或 `start-app.bat` 桌面壳;运维可用 `console.bat`
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
1. 安装 [Node.js](https://nodejs.org/)(含 npm)
|
||||||
|
2. 双击 `start.bat`(桌面窗口则用 `start-app.bat`),浏览器打开 [http://localhost:23337](http://localhost:23337)
|
||||||
|
3. 在 **设置** 里新增模型接口(Base URL、API Key、型号);根目录已有 `.env` 时可跳过
|
||||||
|
4. **新建作品** → 对话写出一张简单可玩卡 → **保存定稿** → 左侧菜单选 **游玩**
|
||||||
|
|
||||||
|
完整图文步骤(含截图与示例提示词)见 **[从启动到开玩](docs/guide/getting-started.md)**。
|
||||||
|
|
||||||
|
## 核心回路
|
||||||
|
|
||||||
|
```text
|
||||||
|
新建作品 → 选进料方式与配方
|
||||||
|
→ 创作(编排技能 / 对话落盘)→ 运行规格 + 开场
|
||||||
|
→ 保存定稿
|
||||||
|
→ 游玩(执行单元按规格上场)→ 保存游玩
|
||||||
|
```
|
||||||
|
|
||||||
|
术语以 `[docs/ui-glossary.md](docs/ui-glossary.md)` 为准(进料方式、配方、编排器、技能、运行规格、执行单元)。
|
||||||
|
|
||||||
|
## 仓库一览
|
||||||
|
|
||||||
|
|
||||||
|
| 路径 | 用途 |
|
||||||
|
| ------------------- | ------------------------------ |
|
||||||
|
| `src/` | 运行时、会话、技能装载、Book/预设/库、HTTP API |
|
||||||
|
| `web/` | 前端静态页 |
|
||||||
|
| `skills/` | 技能包(见 `skills/README.md`) |
|
||||||
|
| `examples/presets/` | 示例预设 |
|
||||||
|
| `seeds/libraries/` | 文风/偏好库种子 |
|
||||||
|
| `electron/` | 桌面壳 |
|
||||||
|
| `docs/` | 架构与契约文档 |
|
||||||
|
| `tests/` | Vitest |
|
||||||
|
|
||||||
|
|
||||||
|
用户数据目录可用环境变量 `WRITING_AGENT_DATA_DIR` 覆盖;默认 `~/.writing-agent`(Windows 多为 `%USERPROFILE%\.writing-agent`)。
|
||||||
|
|
||||||
|
## 维护者
|
||||||
|
|
||||||
|
开发与排障常用命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run web # 启动 HTTP 服务(不监视文件)
|
||||||
|
npm run web:watch # 开发时监视 src/ 与 skills/
|
||||||
|
npm test # Vitest
|
||||||
|
npm run libraries:restore # 从 seeds 恢复库
|
||||||
|
npm run libraries:export # 导出库到 seeds
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 运维脚本:`console.bat`(查询 / 启停 / 日志)。入口实现:`src/server/web-server.ts` → `session-manager.ts` → `src/runtime/` + `src/main-agent/` + `skills/`。
|
||||||
|
|
||||||
|
**安全注意**:本地 HTTP 控制面默认无鉴权,且会监听本机端口;请勿把服务暴露到不可信网络。API Key 以明文保存在用户数据目录的配置里。
|
||||||
|
|
||||||
|
### 文档索引
|
||||||
|
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
| --------------------------------------------------------------- | -------------------- |
|
||||||
|
| [architecture.md](docs/architecture.md) | 系统架构与模块边界 |
|
||||||
|
| [ui-glossary.md](docs/ui-glossary.md) | 用户可见术语 |
|
||||||
|
| [implementation-guide.md](docs/implementation-guide.md) | 文档地图与实现约定 |
|
||||||
|
| [runtime-state-machine.md](docs/runtime-state-machine.md) | 相位机 |
|
||||||
|
| [tool-contracts.md](docs/tool-contracts.md) | Tool 契约 |
|
||||||
|
| [run-snapshot.md](docs/run-snapshot.md) | 存档 / 快照(以代码现状为准) |
|
||||||
|
| [play-presentation-shells.md](docs/play-presentation-shells.md) | 游玩呈现壳 |
|
||||||
|
| [preset-format.md](docs/preset-format.md) | 预设格式 |
|
||||||
|
| [skill-format.md](docs/skill-format.md) 等 | 技能 / 编排器 / Worker 格式 |
|
||||||
|
| [px-roadmap.md](docs/px-roadmap.md) | 产品路线图 |
|
||||||
|
| [skills/README.md](skills/README.md) | 技能包目录说明 |
|
||||||
|
|
||||||
|
|
||||||
|
`docs/book-storage.md` 描述的是目标存储模型,**不是**当前磁盘布局;当前以 Book JSON + session + run-snapshots 为准。
|
||||||
|
|
||||||
|
## 许可与第三方
|
||||||
|
|
||||||
|
见 `[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)`。项目版本见 `package.json`(当前 `0.1.0`,`private`)。
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# 创作轮次与前缀花费
|
||||||
|
|
||||||
|
> 状态:结论已记录。缓存粘滞已接入:本局 `sessionId` 在 OpenRouter 请求体写成 `session_id`。不手选节点导致的「先选再写」尚无做法。
|
||||||
|
> 记录:2026-10-01。用量取自本地 `~/.writing-agent/stats`,2026-09-20 起。
|
||||||
|
> 相关:`src/dictate/prompt.ts`、`src/dictate/turn.ts`、`src/dictate/read-module.ts`、`src/llm/client.ts`。
|
||||||
|
|
||||||
|
## 已记录的花费
|
||||||
|
|
||||||
|
9 月 20 日以来,OpenRouter 上的 token 主要在创作落盘的输入,不在游玩输出。
|
||||||
|
|
||||||
|
| 路径 | 请求 | 会话约 | prompt | 其中缓存 | 未缓存 | 输出 |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|
|
||||||
|
| 创作落盘 `dictate_agent` | 416 | 5 | 1198 万 | 782 万 | 417 万 | 38 万 |
|
||||||
|
| 游玩 `worker:world-simulator` | 28 | 2 | 77 万 | 0 | 77 万 | 7 万 |
|
||||||
|
|
||||||
|
输出大约是 prompt 的 3%–9%。输出单价更高,仍然要算,但排在输入后面。缓存命中仍按折扣价计,缩体积会同时降低全价和折扣价。
|
||||||
|
|
||||||
|
每轮花费可以看成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
请求次数 ×(未缓存输入 × 全价 + 缓存输入 × 折扣价 + 输出 × 输出价)
|
||||||
|
```
|
||||||
|
|
||||||
|
创作这 416 次是最大的一块。大约五个会话,一句用户话后面跟着一串读和写。
|
||||||
|
|
||||||
|
## 缓存粘滞:必做
|
||||||
|
|
||||||
|
OpenRouter 请求体现由统计层带上本局 `sessionId`,字段名 `session_id`(最长 256 字)。直连其它渠道不写这个字段。有 `session_id` 后,粘滞从该会话第一次成功请求就开始,不再等缓存命中,也不再因为第一条 user 被重写而换上游。`provider.order` 仍不发送,它会关掉这层粘滞。
|
||||||
|
|
||||||
|
同一条前缀,落点稳定时能命中,换上游就是 0:
|
||||||
|
|
||||||
|
- 创作工具循环间隔大约 7–15 秒。Gemini 上同一轮里缓存从约 3.8k 爬到 7.6k、11.5k、15k,整段 prompt 大约 50%–68% 命中。burst 的第一下经常是 0。中间偶尔整段掉回 0,是换了上游。
|
||||||
|
- 游玩 world-simulator 在 OpenRouter 上 28 次全是 0。gemini-3.7 间隔中位数约 5.5 分钟,gemini-3.8 约 22 分钟,prompt 1.8 万–3.4 万。
|
||||||
|
- 直连 glm-5.3 的游玩,间隔 8–30 分钟仍常见 80%–91%。某一轮从约 11k 掉到 6656 并停住,说明前缀中段改过,只保住开头约 6.6k。
|
||||||
|
|
||||||
|
因此缓存粘滞是必做项。不传稳定的 `session_id`,OpenRouter 每一轮按新开头重新选上游,游玩前缀缓存等于没有。`provider.order` 会关掉这层粘滞,不能拿来代替 `session_id`。
|
||||||
|
|
||||||
|
上下文排序对游玩前缀是有效的:人设和静态产物在前,历史居中,变量和本轮输入在后。它只在同一上游上表现得出来。
|
||||||
|
|
||||||
|
## 未决:不手选节点,选和写至少两轮
|
||||||
|
|
||||||
|
用户不手动指定本轮写哪个能力。系统提示里只有能力目录和短说明(何时用、何时不用、档位)。`prompt.md` 里的任务、原则、产物规格、评分不在这条提示里。
|
||||||
|
|
||||||
|
模型选定能力后调用 `read_module`。程序把这些段落放进工具结果,并在 `运行.对话落盘.已读能力` 记下该能力当前版本。版本是这些段落的哈希。随后的 `insert` 要核对这个账:没读过,或磁盘上的提示已经改过,就拒绝写入。`self_score` 也按这次读到的评分维度检查。`narrative`、`variable-context`、`context-order`、`worker-spec`、`refine` 以及 `auto` 能力不走这道门,其余内容能力都走。库条目同理:目录只是摘要,正文要等 `read_library_entry` 回来。
|
||||||
|
|
||||||
|
读的那一轮,完整写法还不在上下文里,模型给不出合格正文。同一项能力至少是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
第 1 次请求:凭短说明选定,并 read_module
|
||||||
|
第 2 次请求:看见任务 / 原则 / 规格 / 评分之后 insert
|
||||||
|
```
|
||||||
|
|
||||||
|
前缀发送两遍。第二遍还多带上那份写法。手选节点时,程序可以把该节点的完整写法放进唯一一次请求,模型当场写完。不手选时,第一次请求不能把所有节点的完整提示都放进去,这第二次省不掉。
|
||||||
|
|
||||||
|
一次只返回一个工具时,倍数再乘上去。提示要求一轮通常写 2~3 个已经说清的能力,模型常做成「读甲、写甲、读乙、写乙」,最后再单独回用户一句。每一拍都把已经变长的前缀再送一遍。「选和写拆成两次」是翻倍的原因;「每次只动一个节点」把两倍变成好几倍。
|
||||||
|
|
||||||
|
驱动程序已经会在同一步执行模型这一次返回的全部工具。接口没有禁止一轮多个 `insert`。日志里偶尔同轮两次写入。多数时候模型一次只吐一个工具。
|
||||||
|
|
||||||
|
结构化输出可以把一次生成拆成多份产物:一份 JSON 里多条 `{ position, content }`,程序再落盘,用户可见回复也可以放在同一份里。这省的是写入侧重复发送前缀。规格说明仍须在生成这批产物之前到达模型;否则模型是在没看见写法的情况下填内容。读和写如何合成更少的请求,本文不规定做法。
|
||||||
|
|
||||||
|
## 一本书上的拆账
|
||||||
|
|
||||||
|
本机作品 `4c13b2b4` 的当前会话(25 份产物、22 轮用户话)按现在的 `buildDictateMessages` 拼出来,不发请求。字数大约按 2 字 1 token 对照:同书末次 OpenRouter 请求 prompt 为 46897 token。
|
||||||
|
|
||||||
|
| 块 | 字数 | 占当前 messages |
|
||||||
|
|---|---:|---:|
|
||||||
|
| 已有产物合计 | 60518 | 69% |
|
||||||
|
| 全量对话 | 19975 | 23% |
|
||||||
|
| 系统提示(含方案进度 4392) | 6695 | 8% |
|
||||||
|
| 工具定义 JSON | 4917 | 另计,约等于系统提示 |
|
||||||
|
| 当前选用预设里启用的正文 | 362 | 可忽略 |
|
||||||
|
|
||||||
|
产物没有一份独自占满。最大的是 `设计.正文组成` 8879 字,其次变量规则 6005、实现机制 4569。其余多为两三千字,加在一起才是大头。会话中段的检查点(第 119 条消息之前)产物已经是 56102 字,和结尾同一量级。
|
||||||
|
|
||||||
|
按前面两段收窄再量同一份材料:
|
||||||
|
|
||||||
|
| 方案 | 字数 |
|
||||||
|
|---|---:|
|
||||||
|
| 选择:方案进度 + 用户最后一句(130 字) | 4522 |
|
||||||
|
| 填写若仍带全部产物 + 最近 4 条对话(1770 字) | 约 6.2 万 |
|
||||||
|
| 再注入 1 份节点提示(正文约 5 千字,叙事指南 5557) | 约 6.7 万 |
|
||||||
|
| 现在这一次请求的 messages | 88218 |
|
||||||
|
|
||||||
|
选择请求很小,目录也稳定,缓存几乎打在整段上。填写请求的大头仍是已有产物,不是对话,也不是刚插入的节点提示。砍掉全量对话大约省两成,改不了产物那七成。节点提示每份约 5 千字,一次写两三份会在产物之上再加一两成,但只发送一次。
|
||||||
@@ -288,7 +288,7 @@
|
|||||||
|
|
||||||
写开场或结束创作前,用仍未落的「必须」和「有必要」问用户哪些还没设计。同轮写入多个已说清产物时综合回应整轮,不逐模块重复模板。
|
写开场或结束创作前,用仍未落的「必须」和「有必要」问用户哪些还没设计。同轮写入多个已说清产物时综合回应整轮,不逐模块重复模板。
|
||||||
|
|
||||||
写正文组成或 0 层正文组成时,回复里直接给出一份隔离内容供预览:与本局前端同一套骨架,示例已经写进去。完整 JSON 仍由产物卡展示。
|
正文组成、0 层正文组成和开场的画面由产物卡按 frontend 渲染。完整 JSON 仍由产物卡展示。
|
||||||
|
|
||||||
## 6. 工作包 D · 游玩投影编译〔未开始〕
|
## 6. 工作包 D · 游玩投影编译〔未开始〕
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# 提示缓存核对:记账是否可信、首填为何仍≈0
|
||||||
|
|
||||||
|
> 状态:核对结论。承接 `docs/briefs/token-cost-report-2026-10-06.md` §1.5 / §4.P2。
|
||||||
|
> 记录:2026-10-07。
|
||||||
|
> 疑问:若 OpenRouter 上 DeepSeek 缓存能挂住,是否说明「首填几乎不吃缓存」是本地记账错了,或粘滞/TTL 才是主因?
|
||||||
|
|
||||||
|
## 1. 结论(先看这个)
|
||||||
|
|
||||||
|
1. **缓存记账可信。** 我们只是把 OpenRouter 的 `usage.prompt_tokens_details.cached_tokens`(及 DeepSeek 的 `prompt_cache_hit_tokens`)原样写入 `~/.writing-agent/stats`;不是自估、不是漏字段。
|
||||||
|
2. **「首填≈0、retry 高」是上游真值,不是漏记。** 同轮对照里,绝大多数是 fill 几乎无缓存、fill-retry 立刻上到八成上下。若解析坏了,两边应同假。
|
||||||
|
3. **DeepSeek 探测成功,不能推翻 Gemini 首填账。** 花销报告窗口主模型是 `google/gemini-3.*-flash`;DeepSeek 只证明「同 baseUrl + 同解析链路」在另一模型上能读到非零缓存。
|
||||||
|
4. **粘滞/`session_id` 不是当前主账缺口。** 同会话内 fill→retry 已能吃到缓存,说明粘到了同一上游且前缀可复用;贵在 **每一轮第一枪大前缀仍全价**,以及 **重试常态**。
|
||||||
|
5. **优化优先级不变:** 仍以 P0(fill 去工具循环、消灭 retry)为主;P2(跨 select/fill 稳定前缀)对 Gemini 有意义,但不是「先修记账」或「再抠 TTL」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 记账链路(我们记了什么)
|
||||||
|
|
||||||
|
| 层 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| OpenRouter 响应 | `prompt_tokens` / `prompt_tokens_details.cached_tokens` / 可选 `cache_write_tokens` |
|
||||||
|
| `parseUsage`(`src/llm/client.ts`) | 读 `cached_tokens`;DeepSeek 另认 `prompt_cache_hit_tokens` |
|
||||||
|
| stats jsonl | `cachedTokens` / `cacheWriteTokens` 等落盘 |
|
||||||
|
| 报告命中率 | `cached / prompt`(按请求平均或合计) |
|
||||||
|
|
||||||
|
单元测试覆盖 OpenAI details、DeepSeek hit/miss、cache write。探测脚本与生产走同一 `complete` → `parseUsage`。
|
||||||
|
|
||||||
|
**原始对照(2026-10-07,Gemini 探测):** 直接 `fetch` OpenRouter,两次固定大前缀请求的 `usage` 均为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"prompt_tokens_details": {
|
||||||
|
"cached_tokens": 0,
|
||||||
|
"cache_write_tokens": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
落盘若记成 0,与上游一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 生产窗口再切一刀(缓存行为)
|
||||||
|
|
||||||
|
范围:`~/.writing-agent/stats` 中 2026-10-03、10-04、10-06、10-07 的 `dictate_agent`(与花销报告同一量级窗口,略含 10-07)。
|
||||||
|
|
||||||
|
| step | 请求 | 有缓存占比 | 均命中率 | 中位命中 |
|
||||||
|
|---|---:|---:|---:|---:|
|
||||||
|
| select | 79 | 1.3% | ~0.8% | 0% |
|
||||||
|
| **fill** | 79 | **1.3%** | **~0.5%** | **0%** |
|
||||||
|
| **fill-retry** | 83 | **84.3%** | **~71.9%** | **~85%** |
|
||||||
|
| reply | 30 | 0% | 0% | 0% |
|
||||||
|
|
||||||
|
同轮 `fill` + 至少一次 `fill-retry` 的配对约 **45**:
|
||||||
|
|
||||||
|
| 模式 | 对数 |
|
||||||
|
|---|---:|
|
||||||
|
| 首填≈0 且 retry 命中 >40% | **34** |
|
||||||
|
| 两者都≈0 | 11 |
|
||||||
|
| 首填命中 >20% | **0** |
|
||||||
|
|
||||||
|
样本:同轮 `fill` `cached=0`、随后 `fill-retry` `prompt≈90k / cached≈82k`(命中约 91%)。这只能解释为:**上游在 retry 上报了高缓存,我们照记。**
|
||||||
|
|
||||||
|
模型侧:窗口几乎全是 `google/gemini-3.7-flash` / `gemini-3.8-flash`;与花销报告一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 探测:DeepSeek vs Gemini(同 OpenRouter)
|
||||||
|
|
||||||
|
核对方式:固定大前缀连打 OpenRouter,读 `usage.prompt_tokens_details.cached_tokens`(脚本已删,结论仍有效)。
|
||||||
|
|
||||||
|
### 4.1 `deepseek-v4-pro`
|
||||||
|
|
||||||
|
| 调用 | prompt | cached | 命中 |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| A#1 | 10602 | 7650 | 72.2% |
|
||||||
|
| A#2~A#4 | 10602 | 9743 | 91.9% |
|
||||||
|
| 等 60s 后 B#1~B#2 | 10602 | 9743 | 91.9% |
|
||||||
|
|
||||||
|
→ 隐式缓存可用;短间隔爬升;**约 1 分钟间隔未掉光**。说明「同 baseUrl + 粘滞 + 记账」这条链对 DeepSeek 成立。
|
||||||
|
|
||||||
|
### 4.2 `google/gemini-3.8-flash`
|
||||||
|
|
||||||
|
| 调用 | prompt | cached | 命中 |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| A#1~A#3(间隔 5s) | ~12.7k~15k | **0** | **0%** |
|
||||||
|
|
||||||
|
原始 `usage` 同上,`cached_tokens=0` 且 `cache_write_tokens=0`。
|
||||||
|
|
||||||
|
→ **不是我们没记上**,是这次探测形态下上游就报 0。与生产里「仅当 fill 刚跑过、retry 几乎同前缀」才高频命中,并不矛盾:retry 复用的是**刚发过的整包 fill 前缀**;探测的「新会话 + 纯文本 system」未复现那种命中。
|
||||||
|
|
||||||
|
OpenRouter 文档对 Gemini 同时提到隐式缓存与 `cache_control` 显式断点;本仓库**未打** `cache_control`。生产 retry 仍能命中,说明至少部分 Gemini 路由在「极近、极同前缀」下会报 `cached_tokens>0`;**不能**因此假设跨 select→fill、或跨轮首填也会自动吃到折扣。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 对花销报告结论的修正与保留
|
||||||
|
|
||||||
|
| 命题 | 判定 |
|
||||||
|
|---|---|
|
||||||
|
| 首填缓存几乎失效 | **保留**(记账复核后仍成立) |
|
||||||
|
| 贵主要因为 fill 大前缀 ×(1+retry) | **保留** |
|
||||||
|
| 怀疑「其实有缓存但没记」 | **否定** |
|
||||||
|
| 怀疑「DeepSeek 能缓存 ⇒ 当前账单不该贵」 | **否定**(账单模型是 Gemini;且 DeepSeek≠本窗口主路径) |
|
||||||
|
| 怀疑「再抠 TTL / session 就能救首填」 | **弱化**:同会话 retry 已证明粘滞可用;缺口在 **select≠fill 前缀** 与 **每轮第一枪**,不是「完全没粘住」 |
|
||||||
|
| P2 共享稳定前缀 | **仍值得做**(对 Gemini 跨步命中),但收益次于 P0 去循环 |
|
||||||
|
| 换 DeepSeek 指望隐式缓存 | **可作实验**,质量/工具/价格需另验;不能单靠本次探测定案 |
|
||||||
|
|
||||||
|
相对花销直觉(未缓存=1、缓存≈0.25):retry 已打折,**未打折的是几乎每次的首填大包**。消灭 retry 或把首填变小,都比「怀疑仪表」更贴账单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 建议(本核对后的落地顺序)
|
||||||
|
|
||||||
|
与 `token-cost-report-2026-10-06.md` §5 对齐,本文件只钉缓存相关验收:
|
||||||
|
|
||||||
|
| 步 | 动作 | 验收 |
|
||||||
|
|---|---|---|
|
||||||
|
| A | **不改记账**;需要时用原始 `usage` 或 `/generation` 抽查 | 抽查与 jsonl `cachedTokens` 一致 |
|
||||||
|
| B | P0:fill 改为一次 structured,去掉写工具循环 | 每轮调用 p50→2;fill-retry 占比 <10%(主收益) |
|
||||||
|
| C | P2:select/fill 共用不变字节前缀,易变块置尾 | 同会话第 2 轮起 **fill 首次** `cached/prompt` 明显上升(Gemini 目标仍可订 ≥40%,以实测为准) |
|
||||||
|
| D | 若坚持 Gemini 显式缓存 | 单独评估 OpenRouter `cache_control`;先小流量探测,再决定是否进主路径 |
|
||||||
|
| E | 可选:用 DeepSeek 等隐式缓存友好模型做同书对照周 | 比的是 **有效单价 × 质量**,不是单看 hit% |
|
||||||
|
|
||||||
|
**明确不做:** 为「修缓存」去动 `parseUsage` 估命中;用 `provider.order` 换 `session_id`;把探测脚本的 DeepSeek 命中率写进创作账单当成已省钱。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 一句话答复原疑问
|
||||||
|
|
||||||
|
> 缓存记录是正确的。DeepSeek 能挂缓存,只说明链路和仪表没坏;当前创作窗口里 Gemini **首填几乎不打折是上游如实返回**,retry 高命中反而证明记对了。钱仍主要花在「每轮第一枪大前缀 + 填写真的在重试」,优先去 Agent 循环,而不是先怀疑统计或死磕 TTL。
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# 创作对话 Token 花销报告与优化建议
|
||||||
|
|
||||||
|
> 状态:体验核对结论。数据来自本机 `~/.writing-agent/stats`(2026-10-02~10-06),对照 `docs/briefs/creation-round-cost.md` 旧基线与当前 `src/dictate/round.ts` 实现。
|
||||||
|
> 记录:2026-10-06。
|
||||||
|
> 诉求:对话不要继续走「自由 Agent 工具循环」,花销过大。
|
||||||
|
|
||||||
|
## 1. 结论(先看这个)
|
||||||
|
|
||||||
|
1. **主战场仍是创作落盘,不是游玩。** 本窗口 `dictate_agent` 约占总 prompt 的 **99%+**;游玩 Worker 几乎可忽略。
|
||||||
|
2. **已经不是旧版「读甲写甲再读乙」自由 Agent**,而是 `select → 程序注入 → fill 工具循环(可 retry)→ 偶发 lean reply`。但 **fill 仍是 agent 形态**(`toolChoice: required` + 多工具 + 最多 4 次重试),这是当前贵的原因。
|
||||||
|
3. **一句用户话平均约 3.3 次模型请求**,中位 3、P90 5、最多 6。其中 **60% 的轮次至少 1 次 fill-retry**,约 **29% 要 2 次及以上重试**。
|
||||||
|
4. **相对花销(未缓存=1、缓存=0.25、输出=4)**:fill **53%**、fill-retry **28%**、select **15%**、reply **4%**。去 Agent 的收益应打在 **fill 一次写完、消灭重试、砍历史**,而不是再抠游玩。
|
||||||
|
5. **首填缓存几乎失效**:fill 首次命中率约 **1%**,retry 才爬到约 **70%**。大前缀每一轮至少全价付一遍。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 本窗口花销账
|
||||||
|
|
||||||
|
统计范围:本地 jsonl,2026-10-02、10-03、10-04、10-06(10-05 无落盘)。共 **298** 条用量记录。
|
||||||
|
|
||||||
|
### 2.1 按 caller
|
||||||
|
|
||||||
|
| 路径 | 请求 | prompt | 其中缓存 | 命中率 | completion |
|
||||||
|
|---|---:|---:|---:|---:|---:|
|
||||||
|
| `dictate_agent` | 279 | **636.7 万** | 202.6 万 | 31.8% | 57.9 万 |
|
||||||
|
| `worker:*` | 13 | 2.5 万 | ~0.1 万 | — | 0.3 万 |
|
||||||
|
| `main_agent` | 6 | 0.06 万 | — | — | — |
|
||||||
|
|
||||||
|
创作 : 游玩(prompt)≈ **250 : 1**(本窗口游玩样本很少;方向与旧基线「创作远大于游玩」一致)。
|
||||||
|
|
||||||
|
### 2.2 按日(仅创作)
|
||||||
|
|
||||||
|
| 日 | 请求 | prompt(百万) | 缓存(百万) | completion(千) |
|
||||||
|
|---|---:|---:|---:|---:|
|
||||||
|
| 10-02 | 37 | 0.30 | 0.04 | 117 |
|
||||||
|
| 10-03 | 86 | 2.25 | 0.76 | 187 |
|
||||||
|
| 10-04 | 23 | 0.31 | 0.07 | 45 |
|
||||||
|
| 10-06 | 133 | 3.50 | 1.15 | 229 |
|
||||||
|
|
||||||
|
### 2.3 创作按 step(关键)
|
||||||
|
|
||||||
|
| step | 请求 | prompt 合计 | **单次均 prompt** | 缓存命中 | completion |
|
||||||
|
|---|---:|---:|---:|---:|---:|
|
||||||
|
| **fill-retry** | 89 | 284.1 万 | **31.9k** | **70.1%** | 16.4 万 |
|
||||||
|
| **fill** | 82 | 242.5 万 | **29.6k** | **1.1%** | 33.7 万 |
|
||||||
|
| select | 85 | 89.6 万 | 10.5k | 0.9% | 5.0 万 |
|
||||||
|
| reply | 23 | 20.5 万 | 8.9k | 0% | 2.7 万 |
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- retry 请求数 **多于** 首次 fill(89 > 82)→ 重试是常态,不是边角。
|
||||||
|
- 首次 fill 几乎不吃缓存;钱主要花在「每一轮第一枪大前缀」。
|
||||||
|
- select 已不带产物正文(实现正确),但仍带全量更早对话,均约 10k prompt。
|
||||||
|
- lean reply 只覆盖约 **28%** 有 fill 的轮次;其余多在 fill 同请求里 `reply_user`(设计意图),或失败后走 prose 回退。
|
||||||
|
|
||||||
|
### 2.4 按用户轮次(`turnId`)
|
||||||
|
|
||||||
|
| 指标 | 值 |
|
||||||
|
|---|---:|
|
||||||
|
| 轮次数 | 85 |
|
||||||
|
| 每轮模型调用 avg / p50 / p90 / max | **3.28 / 3 / 5 / 6** |
|
||||||
|
| 每轮 prompt 合计 avg / p50 / p90 / max | **7.5 万 / 6.0 万 / 14.9 万 / 25.9 万** |
|
||||||
|
| 无 retry | 34(40%) |
|
||||||
|
| ≥1 次 retry | 51(60%) |
|
||||||
|
| ≥2 次 retry | 25(29%) |
|
||||||
|
|
||||||
|
### 2.5 上下文块(`segmentChars`,字符,非 token)
|
||||||
|
|
||||||
|
| step | n | rules | history | **products** | injected | tail | 均 prompt tok |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|---:|
|
||||||
|
| fill | 82 | ~2.1k | ~9.3k | **~23.0k** | ~5.6k | ~5.0k | 29.6k |
|
||||||
|
| fill-retry | 89 | ~2.1k | ~8.7k | **~21.9k** | ~6.3k | ~4.9k | 31.9k |
|
||||||
|
| select | 85 | ~0.3k | ~9.0k | **0** | 0 | ~4.9k | 10.5k |
|
||||||
|
| reply | 23 | ~0.5k | 0 | ~6.7k(仅本轮) | 0 | ~6.3k | 8.9k |
|
||||||
|
|
||||||
|
近期 fill 样本(10-06):products 已到 **4.3~5.0 万字**,history **2.6~3.2 万字**,单次 prompt **5.5~6.9 万 token**,且 `cached=0`。
|
||||||
|
|
||||||
|
### 2.6 模型
|
||||||
|
|
||||||
|
| 模型 | 请求 | prompt(百万) | 命中率 |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| `google/gemini-3.8-flash` | 171 | 4.13 | 29.6% |
|
||||||
|
| `google/gemini-3.7-flash` | 102 | 2.17 | 36.4% |
|
||||||
|
| `openai/gpt-6.1-sol` | 6 | 0.07 | 16.1% |
|
||||||
|
|
||||||
|
金额粗算(按 Flash 量级:未缓存输入 $0.15/M、缓存 $0.0375/M、输出 $0.60/M,**仅作数量级**):本窗口创作约 **$1.1**,约 **$0.013/用户轮**。体感「贵」主要来自 **轮次×大前缀×重试**,以及若换成更贵模型会线性放大;不是输出写爆。
|
||||||
|
|
||||||
|
### 2.7 与旧基线对照
|
||||||
|
|
||||||
|
旧账(`creation-round-cost.md`,约 9/20 起、创作 416 次 / prompt 1198 万):自由工具循环、先 `read_module` 再 `insert`。
|
||||||
|
|
||||||
|
| | 旧自由 Agent | 本窗口(已 planned) |
|
||||||
|
|---|---|---|
|
||||||
|
| 形状 | 读/写交织、工具定义常驻 | select + 注入 + fill 循环 |
|
||||||
|
| 同轮请求倍数 | 常「每节点×2」再加回复 | 中位 **3**(已封顶感更好) |
|
||||||
|
| 产物进选题 | 会进(旧整包) | **已去掉** |
|
||||||
|
| 填写大头 | 全产物 + 全对话 | **仍是全产物 + 近乎全对话** |
|
||||||
|
| 首填缓存 | 依赖粘滞 | **仍≈0**(select/fill 前缀形状不同) |
|
||||||
|
|
||||||
|
**程序已省下「读模块那一轮」和选题侧产物**;**未省下「大前缀×(1+重试)」**。用户觉得「还是 Agent、还是贵」,与数据一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 当前链路(体验视角)
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户一句
|
||||||
|
→ select(小上下文,强制 select_nodes) ≈1 次,~10k prompt
|
||||||
|
→ 程序注入本轮节点写法 / 库正文(确定性,不计模型)
|
||||||
|
→ fill(大上下文,强制工具:insert/delete/变量/映射 + reply_user)
|
||||||
|
└─ 未过则 append 工具结果,再 fill-retry(最多约 4)
|
||||||
|
→ 若填完却未 reply_user → lean reply(只带本轮产物) ≈9k
|
||||||
|
```
|
||||||
|
|
||||||
|
与「不要 Agent」的落差:
|
||||||
|
|
||||||
|
| 已做到 | 仍像 Agent |
|
||||||
|
|---|---|
|
||||||
|
| 选题与填写拆开 | fill 仍多工具 + required |
|
||||||
|
| 规格由程序注入,不再 `read_module` | 校验失败靠模型再跑一整轮大前缀 |
|
||||||
|
| 回复可瘦身 | 变量/映射/删除仍走 tool loop |
|
||||||
|
| `session_id` 粘滞已接 | select→fill 系统提示与正文布局不同,首填缓存打不穿 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 优化建议(按性价比)
|
||||||
|
|
||||||
|
### P0 — 对话填写去 Agent(最贴合诉求)
|
||||||
|
|
||||||
|
**目标形状:一句用户话 = 2 次模型请求(澄清时 1 次),禁止 fill 工具循环。**
|
||||||
|
|
||||||
|
1. **select**:保持现状(或改为纯 JSON structured output,去掉 tools 税)。只出 `module_id[]` / 澄清题。
|
||||||
|
2. **fill**:改为 **一次 structured complete**:`{ products: [...], reply, declares?, deletes? }`,程序本地切开落盘与校验。
|
||||||
|
3. **失败只重写失败 tag**:上下文 = 规则摘要 + 该 tag 注入规格 + 相关产物白名单 + 错误信息;**不要**再塞全黑板 + 全历史重跑。
|
||||||
|
4. **变量/映射**:优先由程序从 structured 字段落盘,或极窄工具面;不要与长正文 insert 共用大循环。
|
||||||
|
|
||||||
|
预期(相对本窗口相对花销):
|
||||||
|
|
||||||
|
- 去掉 fill-retry 常态 → 相对花销约 **-25%~35%**
|
||||||
|
- 首填仍大,但每轮从 3.3 枪降到 ~2 枪 → 再 **-20%~30%**(视澄清/失败率)
|
||||||
|
- 合计数量级:**创作输入花销降到现在的一半左右**(前提:一次交多份成功率够高)
|
||||||
|
|
||||||
|
与相位机约束不冲突:仍在同一次 `running` 内、用户不手选节点、方案不是执行序。
|
||||||
|
|
||||||
|
### P1 — 砍 history(实现便宜、收益稳)
|
||||||
|
|
||||||
|
现状:select/fill 的「更早对话」均值约 9k 字,会话后期到 **3 万字+**,占填写真份额。
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- select:方案进度 + **最近 2~4 轮** + 末句(旧 brief 已论证「只留末句会误选」,不能只留一句)。
|
||||||
|
- fill:同样只留最近对话;产物冲突靠产物正文,不靠早期闲聊。
|
||||||
|
- 清空对话能力保持;长记忆另走总结产物,不靠无限 chat。
|
||||||
|
|
||||||
|
预期:fill/select 各再 **-10%~20%** prompt;会话越长收益越大。
|
||||||
|
|
||||||
|
### P2 — 稳首填缓存前缀
|
||||||
|
|
||||||
|
`session_id` 只保证同上游;**select 与 fill 的 system + 前部布局不一致 → 首填命中≈0**。
|
||||||
|
|
||||||
|
建议(不改业务语义):
|
||||||
|
|
||||||
|
- 抽出 **跨 select/fill 不变的规则前缀**(身份、写入规则骨架),两边共用同一字节前缀。
|
||||||
|
- 易变块(方案进度、checklist、注入、末句)固定在末尾(现已部分如此)。
|
||||||
|
- 工具定义:若 fill 改 structured,则工具 JSON 不再每轮跟着大请求。
|
||||||
|
|
||||||
|
预期:在粘滞有效时,同一会话后续轮次的「规则+稳定壳」可从全价变折扣;**不降低发送体积,但降低未缓存账单**。对 OpenRouter+Gemini 块粒度(约 4k)敏感,前缀差几百 token 可能整块失效。
|
||||||
|
|
||||||
|
### P3 — 产物相关性(大头,风险高)
|
||||||
|
|
||||||
|
products 占 fill 字符大头(样本约一半以上)。旧 brief 已说明:误裁会导致冲突设定。
|
||||||
|
|
||||||
|
建议分两档:
|
||||||
|
|
||||||
|
1. **安全档**:按本轮 `module_id` 维护「必带 tag」白名单(依赖边写在 catalog);其余只带 **标题+一行摘要+hash**。
|
||||||
|
2. **激进档**:检索/摘要。必须配套:写入前冲突扫描、失败则自动补全文重试(否则质量回退会被当成「省钱事故」)。
|
||||||
|
|
||||||
|
预期:做好可再砍 fill **30%~50%**;做不好会表现为反复 rewrite,把 P0 收益吃掉。
|
||||||
|
|
||||||
|
### P4 — 游玩与总管
|
||||||
|
|
||||||
|
本窗口不必优先。游玩保持「一次生成、非创作 tool loop」。OpenRouter 上游玩缓存曾为 0,靠 `session_id` 已具备前提;前缀顺序现已正确。总管 `main_agent` 可忽略。
|
||||||
|
|
||||||
|
### 明确不做 / 慎做
|
||||||
|
|
||||||
|
| 项 | 原因 |
|
||||||
|
|---|---|
|
||||||
|
| 改回用户手选节点以省一轮 | 与产品「自然语言推进」冲突 |
|
||||||
|
| 相位机按固定工序跑完全部节点 | 与「方案不是执行序」冲突 |
|
||||||
|
| 选题带全产物 | 已证明浪费;会毁掉 select 的小请求优势 |
|
||||||
|
| 用 `provider.order` 代替 `session_id` | 会关掉粘滞 |
|
||||||
|
| 只砍输出 | 输出约是 prompt 的 3%~9%,不是主账 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 建议落地顺序(可验收)
|
||||||
|
|
||||||
|
| 步 | 改动 | 验收指标(同书同对话长度) |
|
||||||
|
|---|---|---|
|
||||||
|
| A | fill 改为一次 structured,去掉 write tool loop | 每轮模型调用 p50→**2**;fill-retry 调用占比 **<10%** |
|
||||||
|
| B | select/fill history 窗口 | fill `segmentChars.history` 中位下降 **≥50%** |
|
||||||
|
| C | 共享稳定 system 前缀 | 同会话第 2 轮起 fill 首次 `cached/prompt` **≥40%**(Gemini/OpenRouter) |
|
||||||
|
| D | 产物白名单/摘要 | fill 均 prompt **再降 ≥25%**,且 insert 拒绝率不升高 |
|
||||||
|
|
||||||
|
体验验收(主观):
|
||||||
|
|
||||||
|
- 用户感知仍是「说一句 → 落几块 → 一段综合回复」,无「工具来回思考很久」的 Agent 感。
|
||||||
|
- 澄清轮仍可 1 次请求结束(仅选题提问)。
|
||||||
|
- 失败时只看到「某几块重写」,而不是整轮长时间空转。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 和「不要 Agent」的直接答复
|
||||||
|
|
||||||
|
- **创作对话:应该停止以 Agent tool loop 作为填写主路径。** 选题可以极窄工具或 JSON;填写应程序注入规格 + 一次结构化交卷 + 本地校验。
|
||||||
|
- **游玩:已经不是创作那种 Agent**;继续单次 Worker 生成即可。
|
||||||
|
- **总管 Agent:不是当前账单**;可维持。
|
||||||
|
|
||||||
|
若只做一件事:做 **P0(fill 去循环)**。它同时回应「体感像 Agent」和「token 太大」两件事。
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
这份文档是自包含的。接收方看不到代码仓库、聊天记录和作品正文。请只根据本文做事。
|
||||||
|
|
||||||
|
请你做两件事,都要写成我们可以逐条核对的文字:
|
||||||
|
|
||||||
|
1. 列出业界在「长上下文 Agent、提示缓存、工具循环」上的常见优化方案。每条写清:做法、它节省的是请求次数、未缓存输入、缓存折扣价还是输出,以及它依赖什么前提(例如上游必须支持前缀缓存、模型必须能一次返回多个工具)。
|
||||||
|
2. 针对下面描述的这个程序,给出你建议采用的组合方案。说明每一步落在哪一次模型请求、上下文里留哪些块、块的顺序、相位机的哪一段执行。不要假装读过仓库。标出你不确定、需要我们核对的点。
|
||||||
|
|
||||||
|
我们会用仓库和现有规则检查你的方案。与本文第三节冲突的做法请单独标出,不要藏进推荐里。
|
||||||
|
|
||||||
|
|
||||||
|
一、这是什么程序
|
||||||
|
|
||||||
|
这是一个写作代理,分两段使用。
|
||||||
|
|
||||||
|
创作期:用户用自然语言描述想要的作品,不手动点选节点。模型决定这一轮要落实哪些「能力」,把结果写成黑板上的产物。产物是规格(舞台、规则、变量、正文如何呈现、开场),不是游玩回合的正文。聊天只做确认和追问。清空对话后产物还在,产物才是创作期的持久记忆。
|
||||||
|
|
||||||
|
游玩期:另一套 Worker 按已经写好的规格往下演。一次用户输入通常对应一次生成,不是创作那种多步工具循环。
|
||||||
|
|
||||||
|
主要渠道是 OpenRouter 上的 Gemini 与 OpenAI 兼容模型。也有直连的单一上游做对照。费用近似为:
|
||||||
|
|
||||||
|
请求次数 ×(未缓存输入 × 全价 + 缓存输入 × 折扣价 + 输出 × 输出价)
|
||||||
|
|
||||||
|
缓存命中不是免费的,只是更便宜。输出大约是 prompt 的 3% 到 9%,单价更高,但当前主账仍是输入。最近约三天(2026-09-28 10:35 到 2026-10-01 10:35,东八区)创作落盘 416 次请求、prompt 1199 万 token,游玩 29 次、prompt 80 万。创作大约是游玩的 14 倍。优化主战场是创作。
|
||||||
|
|
||||||
|
|
||||||
|
二、会有哪些上下文
|
||||||
|
|
||||||
|
下面这些块都会进模型,但不是每一次请求全有。
|
||||||
|
|
||||||
|
创作落盘,一句用户话开始时拼一次,工具循环中途只在末尾追加:
|
||||||
|
|
||||||
|
- 系统提示:身份、写入规则、推进原则。尚无本局方案时附整份能力短目录(何时用、何时不用、档位)。已有方案时改为方案进度:每个能力一行,已落或未落、档位、理由、缺口。不含产物正文。样本里系统提示 6695 字,其中方案进度 4392 字。
|
||||||
|
- 一条用户消息:全部已有产物,按相对顺序,然后全量对话。样本 25 份产物共 60518 字(约 69%),全量对话 19975 字(约 23%)。没有一份产物独大,最大的正文组成 8879 字,其次变量规则 6005、实现机制 4569,其余多为两三千字。会话中段产物已经约 5.6 万字。
|
||||||
|
- 工具定义:每次都带,约 4917 字,一轮内不变。工具有读取能力、读取库条目、准备开场、写入产物、删除、声明变量、声明映射、清空对话。
|
||||||
|
- 预设:包在系统侧的文风或格式片段。当前选用预设启用正文约 362 字,可忽略。换长预设时它变成稳定前缀,应放在易变内容之前。
|
||||||
|
- 工具结果:循环后半才出现。读取能力的返回体是该能力的任务、原则、产物规格、评分和库目录,一份约 5000 字。库条目正文同理,目录里只有摘要。这些全文一开始不在上下文里。
|
||||||
|
|
||||||
|
游玩一次生成:
|
||||||
|
|
||||||
|
- 预设与该 Worker 的系统说明(相对稳定)。
|
||||||
|
- 槽位人设。
|
||||||
|
- 静态规格投影(舞台、规则、叙事、正文组成等,开局后很少改)。
|
||||||
|
- 对话历史(每轮变长,按投影裁剪)。
|
||||||
|
- 变量当前值与本轮用户输入(每轮都变)。
|
||||||
|
|
||||||
|
总管工具循环(决定何时调用哪个 Worker)另有一套 message 历史。最近三天的账几乎不在这条上,优化时不要把它和创作落盘混成同一次请求。
|
||||||
|
|
||||||
|
能力的完整写法存放在各自的提示文件里,不在系统目录里。不把所有能力的完整提示放进第一次请求。完整写法只在已经确定本轮要写哪几项之后进入上下文。
|
||||||
|
|
||||||
|
|
||||||
|
三、相位机之内,程序可以动哪里
|
||||||
|
|
||||||
|
运行分两层。
|
||||||
|
|
||||||
|
业务阶段:创作(设计规格)然后游玩。这是作品生命周期,不是每一次模型调用。
|
||||||
|
|
||||||
|
运行相位:idle、running、waiting_user、done、error。相位机是纯函数:给定会话和事件,决定谁在场、哪些工具可用、何时必须等用户、产物何时算数。它不决定「下一步叫哪个能力」。下一步由 running 里的模型工具循环决定。
|
||||||
|
|
||||||
|
一句用户话是硬事件:相位进入 running,这一轮的模型调用次数从零计。创作落盘在这次 running 里跑工具循环,上限大约三十多步。循环开始时拼好开头,之后只追加助手工具调用和工具结果。模型不再欠请求、或写出给用户的回复之后,相位回到 waiting_user,等待下一句。
|
||||||
|
|
||||||
|
因此:
|
||||||
|
|
||||||
|
- 减少「读一份、写一份、再读下一份」必须发生在同一次 running 里,也就是两次用户话之间。
|
||||||
|
- 不能把相位机改成按固定工序自动跑完全部节点。用户不手选节点,方案也不是执行顺序。
|
||||||
|
- Worker 执行时总管暂停。游玩的那一次生成是 Worker 自己的请求,上下文按该 Worker 的投影拼,不复用创作工具循环的消息历史。
|
||||||
|
- 程序可以在两次模型请求之间插入确定性步骤:注入选中能力的当前提示、核对版本哈希、逐条校验、只重试失败的那一条。这些不是相位变化。
|
||||||
|
|
||||||
|
|
||||||
|
四、缓存优化
|
||||||
|
|
||||||
|
目标是稳定前缀留在同一上游的提示缓存里。不追求两次请求的整包字节相同。Agent 每一轮消息都会变。
|
||||||
|
|
||||||
|
已经接上:OpenRouter 请求体带本局 session_id(最长 256 字)。直连其它渠道不带,避免未知字段被拒绝。有 session_id 后,粘滞从该会话第一次成功请求就开始,不必先看到缓存命中,也不再因为第一条用户消息被重写就换上游。不要发送 provider.order,它会关掉粘滞。
|
||||||
|
|
||||||
|
接上之前的测量(不要把「再做一遍粘滞」当成主体):
|
||||||
|
|
||||||
|
- 创作工具循环间隔约 7 到 15 秒。Gemini 上同一次循环内缓存从约 3800 token 按大约 4000 一块爬到 7600、11500、15000,整段 prompt 约 50% 到 68% 命中。每个 burst 的第一下经常是 0。中途偶尔整段掉回 0,是换了上游。
|
||||||
|
- 游玩在 OpenRouter 上 28 次、prompt 1.8 万到 3.4 万,间隔约 5 到 22 分钟,缓存为 0。直连单一上游时,间隔 8 到 30 分钟仍常见命中 80% 到 91%。有一轮从约 1.1 万掉到 6656 并停住,说明前缀中段改过,只保住开头。
|
||||||
|
- Gemini 隐式缓存大约 4000 token 一块记账。前缀差几百 token,账单上可以差一整块。
|
||||||
|
|
||||||
|
session_id 只保证落到同一上游。命中到第几个 token,取决于前缀从哪一字开始和上一次不同。
|
||||||
|
|
||||||
|
|
||||||
|
五、上下文编排顺序
|
||||||
|
|
||||||
|
原则:越少改的越靠前,每轮都变的放在最后。一处改动只作废它后面的缓存。
|
||||||
|
|
||||||
|
游玩已按此排列:人设与静态规格、对话历史、变量与本轮输入。排序本身在直连上游上有效;OpenRouter 上曾经是 0,是因为当时没有粘住上游,不是因为顺序反了。
|
||||||
|
|
||||||
|
创作当前顺序是:系统提示(含会随落盘改写的方案进度)、然后一条用户消息里先全部产物再全量对话、工具定义在消息之外、工具结果接在末尾。产物任何一份被覆盖,如果它不在整包的末尾,它后面的产物和对话都会离开缓存。工具定义一轮内稳定,适合留在前缀可缓存的区域。方案进度放在系统提示里,方案一改,系统提示之后全部重计。
|
||||||
|
|
||||||
|
选择与填写若拆成两次请求,稳定的能力目录应在选择请求的前部,方案进度、上一问和本轮用户输入在末尾。填写请求里,少改的产物在前,本轮新注入的节点提示和最近对话在后。节点提示每轮都会换,不能放在全部产物之前,否则一换节点,后面整包产物都按未缓存计。
|
||||||
|
|
||||||
|
|
||||||
|
六、上下文发送哪些
|
||||||
|
|
||||||
|
用同一本书量过,不发请求。当前一次创作请求的 messages 约 88218 字,末次实际 prompt 约 46897 token(大约 2 字 1 token)。
|
||||||
|
|
||||||
|
- 选择若只带方案进度加用户最后一句:约 4522 字。再加助手上一问仍然很小。产物正文不要放进选择。只看用户这句原文不够,否则回答「第二个」或修改已有地图时会对错节点,已写过的能力会被再选。
|
||||||
|
- 填写若仍带全部产物,对话只留最近约两轮(样本 1770 字):约 6.2 万字。砍掉全量对话大约只省两成。
|
||||||
|
- 再注入一份节点提示约 5000 字。一次注入两三份,是在产物之上加一两成,而且只发送一次。
|
||||||
|
|
||||||
|
所以「少发对话」省得有限。「少发哪些产物」才碰到大头,但填写时不带相关产物就会和已有设定冲突。哪些产物是这份能力的材料、哪些只是其它规格,本文没有一份可执行的白名单。常见的相关性裁剪、摘要、检索如果要用,必须说明误裁后怎样发现。
|
||||||
|
|
||||||
|
模型在看见该能力当前版本的任务、原则、规格、评分之前,写出来的正文不算数。程序用这些段落的哈希做版本。同一轮生成里先调用读取、同时把正文交上去,不算看见过写法。不要求先读的只有 narrative、variable-context、context-order、worker-spec、refine 和标记为自动的程序步骤。库条目正文同样要先看见再写进产物,默认 1 到 2 条。
|
||||||
|
|
||||||
|
|
||||||
|
七、程序上的优化,仍在同一次 running 里
|
||||||
|
|
||||||
|
现在的倍数来自工具循环,不是来自相位来回切换。
|
||||||
|
|
||||||
|
同一项内容能力至少两次模型请求:第一次凭短说明选定并读取,第二次看见写法之后写入。提示要求一轮通常写这句已经说清的 2 到 3 个能力。模型常一次只返回一个工具,于是变成读甲、写甲、读乙、写乙,最后再单独回用户一句。驱动程序其实会在同一步执行该次响应里的全部工具,日志里偶发一次写两份。
|
||||||
|
|
||||||
|
讨论过、尚未定案的程序形状,供你对照业界做法,不是已经同意的设计:
|
||||||
|
|
||||||
|
- 同一次 running 里两次模型请求。第一次只输出能力 id 和库条目 id。程序注入对应提示并记下版本。第二次一次交回多份产物和一段给用户的回复。某一条校验失败只重写那一条。
|
||||||
|
- 若模型一次只能调用一个工具,用一次结构化输出携带多条产物,程序在本地切开。这只去掉写入侧的重复前缀。规格说明仍须在这次生成之前就在上下文里。
|
||||||
|
- 写开场前有一次程序准备:编译信息可见范围、游玩执行方式、上下文排序和运行配置。这一步不应再变成一次让模型自由发挥的长上下文请求。
|
||||||
|
|
||||||
|
必须仍然成立的行为:用户不手选节点;方案不是执行顺序;没说清的「必须」和「有必要」本轮不写,先问 2 到 3 个选项;同轮多份产物只给一次综合回复,不把完整 JSON 贴进回复;变量和映射不走普通写入。
|
||||||
|
|
||||||
|
|
||||||
|
八、请你列出的常见方案,以及针对我们的组合
|
||||||
|
|
||||||
|
常见方案请覆盖至少这四类,每类不要只写一句口号。我们没给仓库,所以请把业界做法写全,包括你最后不推荐的。我们会删掉不适合的。
|
||||||
|
|
||||||
|
1. 缓存:粘滞路由、前缀稳定性、缓存块粒度、显式缓存断点、把变化赶出前缀。session_id 已经有了,请写它解决不了的部分。
|
||||||
|
2. 编排顺序:稳定段、历史、本轮输入的常见排序,以及中段一改、后段全失效时业界怎么处理。
|
||||||
|
3. 发送哪些:全文产物、摘要、按能力取材料、检索、滑动窗口、把目录和正文拆开。请写误删材料的后果。
|
||||||
|
4. 程序:一次多工具、结构化输出一次交多份、选择与填写拆请求、把规格预注入从而取消读取轮、限制工具循环次数。请写哪种依赖模型能力,哪种只依赖程序。
|
||||||
|
|
||||||
|
然后给出你建议我们采用的组合:一句用户话几次请求、每次上下文块的顺序、预计相对现在少发送几次大前缀、产物那约 6 万字还在不在填写请求里。故意不做的项写原因。和第三节、第六节冲突的地方单独列出,不要写进推荐步骤。
|
||||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 121 KiB |
@@ -0,0 +1,84 @@
|
|||||||
|
# 从启动到开玩
|
||||||
|
|
||||||
|
解压或下载完成后,按下面的顺序即可:双击启动、打开浏览器、写一张最简单的卡,然后游玩一句。
|
||||||
|
|
||||||
|
需要先安装 [Node.js](https://nodejs.org/)(勾选安装时附带的 npm)。第一次启动会自动安装依赖,请等黑窗口跑完。
|
||||||
|
|
||||||
|
## 1. 双击 `start.bat`
|
||||||
|
|
||||||
|
在文件夹里双击 `start.bat`。会弹出一个黑窗口,并自动打开浏览器。地址是:
|
||||||
|
|
||||||
|
http://localhost:23337
|
||||||
|
|
||||||
|
窗口不要关。关窗口或按 `Ctrl+C` 就是停止软件。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
想用桌面窗口而不是浏览器,改双击 `start-app.bat`。后面的操作一样。
|
||||||
|
|
||||||
|
## 2. 第一次要填模型接口
|
||||||
|
|
||||||
|
点右上角 **设置**,再点 **新增**。填上这四项后点 **保存并选用**:
|
||||||
|
|
||||||
|
- 名称:自己认得就行,例如 DeepSeek
|
||||||
|
- Base URL:接口地址,例如 `https://api.deepseek.com/v1`
|
||||||
|
- API Key:你的密钥
|
||||||
|
- 型号:这一行要用的模型名,例如 `deepseek-chat`
|
||||||
|
|
||||||
|
填完型号后也可以点 **嗅探**,从列表里点一个型号。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
项目根目录如果已经有填好的 `.env`,启动时会自动带上,这一步可以跳过。
|
||||||
|
|
||||||
|
## 3. 新建作品
|
||||||
|
|
||||||
|
还没有任何作品时,打开页面就会弹出 **新建作品**。输入名称,点 **创建**。以后再开新的,点左侧 **+**。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
这里用「雨天小书店」做例子。
|
||||||
|
|
||||||
|
## 4. 写一张最简单的卡
|
||||||
|
|
||||||
|
在底部输入框里原样贴上这段,点发送(或按 Enter):
|
||||||
|
|
||||||
|
```text
|
||||||
|
请写一张最简单的可玩卡,写完就停。
|
||||||
|
|
||||||
|
题材:雨天小书店。玩家是进来躲雨的客人,店员是一只戴蓝色围巾的猫。
|
||||||
|
只要短对话。不要数值、地图、复杂世界观。
|
||||||
|
开场只要两三句:雨还在下,猫店员抬起头。
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
等它写完。中间的回复会说明卡已经好了,右侧 **已持久化** 是落下来的几张卡片。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
写完它如果还追问,回复「都不用再加,写完开场就可以开玩」。
|
||||||
|
|
||||||
|
## 5. 保存并开玩
|
||||||
|
|
||||||
|
右上角出现 **保存定稿** 时,点它,给这份稿起个名字。没有这个按钮时,点输入框右侧的保存图标,效果相同。
|
||||||
|
|
||||||
|
然后点左侧的作品名,在弹出的菜单里选 **游玩**。
|
||||||
|
|
||||||
|
会先看到开场白:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
在底部写你要做的事,再发送。例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
我收起伞,问店员今天适合躲雨看的书。
|
||||||
|
```
|
||||||
|
|
||||||
|
它会接下去写这一轮:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
之后就一直这样:你说一句,它写一轮。右上角 **保存游玩** 可以把进度存下来。
|
||||||
|
|
||||||
|
想换你在故事里的名字,到 **设置 → 用户角色** 里新增一个角色并选用。创作时如果希望开场先写占位,把角色名写成 `@玩家`。
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"name": "writing-agent",
|
"name": "writing-agent",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"msedge-tts": "^2.0.9",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1046,6 +1047,18 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -1056,6 +1069,44 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
|
||||||
|
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.16.0",
|
||||||
|
"form-data": "^4.0.6",
|
||||||
|
"https-proxy-agent": "^5.0.1",
|
||||||
|
"proxy-from-env": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/boolean": {
|
"node_modules/boolean": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||||
@@ -1065,6 +1116,30 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer-crc32": {
|
"node_modules/buffer-crc32": {
|
||||||
"version": "0.2.13",
|
"version": "0.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||||
@@ -1114,6 +1189,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chai": {
|
"node_modules/chai": {
|
||||||
"version": "5.3.3",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||||
@@ -1154,11 +1242,22 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -1259,6 +1358,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-node": {
|
"node_modules/detect-node": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
|
||||||
@@ -1267,6 +1375,20 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron": {
|
"node_modules/electron": {
|
||||||
"version": "35.7.5",
|
"version": "35.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz",
|
||||||
@@ -1310,9 +1432,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
@@ -1321,9 +1441,7 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
@@ -1335,6 +1453,33 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es6-error": {
|
"node_modules/es6-error": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
|
||||||
@@ -1450,6 +1595,42 @@
|
|||||||
"pend": "~1.2.0"
|
"pend": "~1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.16.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
|
||||||
|
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.4",
|
||||||
|
"mime-types": "^2.1.35"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||||
@@ -1480,6 +1661,52 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-stream": {
|
"node_modules/get-stream": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||||
@@ -1551,9 +1778,7 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
},
|
},
|
||||||
@@ -1608,6 +1833,45 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-cache-semantics": {
|
"node_modules/http-cache-semantics": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||||
@@ -1629,6 +1893,54 @@
|
|||||||
"node": ">=10.19.0"
|
"node": ">=10.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ieee754": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/isomorphic-ws": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"ws": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-buffer": {
|
"node_modules/json-buffer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||||
@@ -1705,6 +2017,36 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mimic-response": {
|
"node_modules/mimic-response": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
|
||||||
@@ -1719,9 +2061,25 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/msedge-tts": {
|
||||||
|
"version": "2.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/msedge-tts/-/msedge-tts-2.0.9.tgz",
|
||||||
|
"integrity": "sha512-Qw9pphrz/GTGee+XbXrmwXkDWsR6BVdKyDJKDZBC1RnmBMrZJ8VfMTC44j0v9dIptmLMkmLOuBtQX6LHuodcDw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"isomorphic-ws": "^5.0.0",
|
||||||
|
"stream-browserify": "^3.0.0",
|
||||||
|
"ws": "^8.21.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.15",
|
"version": "3.3.15",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||||
@@ -1855,6 +2213,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pump": {
|
"node_modules/pump": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||||
@@ -1879,6 +2246,20 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve-alpn": {
|
"node_modules/resolve-alpn": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
|
||||||
@@ -1963,6 +2344,26 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@@ -2037,6 +2438,25 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/stream-browserify": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "~2.0.4",
|
||||||
|
"readable-stream": "^3.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sumchecker": {
|
"node_modules/sumchecker": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||||
@@ -2158,6 +2578,12 @@
|
|||||||
"node": ">= 4.0.0"
|
"node": ">= 4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
@@ -2761,6 +3187,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
||||||
|
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"vitest": "^2.1.9"
|
"vitest": "^2.1.9"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"msedge-tts": "^2.0.9",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,42 +7,74 @@ name: 美学纲领与交互范式
|
|||||||
id: aesthetics-interaction
|
id: aesthetics-interaction
|
||||||
artifact: 设计.美学纲领与交互范式
|
artifact: 设计.美学纲领与交互范式
|
||||||
declaration: >
|
declaration: >
|
||||||
提取用户最想反复获得的核心体验,以及让它成立的背景与规则内核;同时钉清参与和停笔边界。
|
提取用户最想反复获得的核心体验与让它成立的承重内核,写成给后续能力读的美学纲领;
|
||||||
|
同时钉清交互范式:人称视角、模型可代写什么、用户保留什么、后果由谁裁定、何时停笔。
|
||||||
when: |
|
when: |
|
||||||
用户要的是叙事、角色扮演或持续互动,但还不能明确说出:最想反复获得什么体验,以及什么承重前提让它成立。
|
用户要的是叙事、角色扮演或持续互动,但还不能明确说出:最想反复获得什么体验、自己站在什么位置、轮转到哪必须停等用户。
|
||||||
用户主动改变核心体验、代入站位或关键权限时也应重写。
|
用户主动改变核心体验、代入站位或关键权限时也应重写。
|
||||||
when_not: |
|
when_not: |
|
||||||
本局不依赖体验契约的纯转换、资料整理或机械格式任务,可不使用。
|
本局不依赖体验契约的纯转换、资料整理或机械格式任务,可不使用。
|
||||||
只改遣词和具体写法 → 叙事指南;只改推进与输入用法 → 故事推进;只改舞台详情或硬法则 → 对应能力。
|
只改遣词和具体写法 → 叙事指南;只改推进与输入用法 → 故事推进;只改舞台详情或硬法则 → 对应能力。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力只交核心体验、体验成立的背景与规则内核、参与边界。
|
本能力只交三块:美学纲领(体验是什么、怎样发生、呈现尺度)、背景与规则的承重内核、交互范式(视角、描写权限、后果控制、停笔)。
|
||||||
背景与规则只提取承重内核,不扩写百科;舞台细节归舞台骨架,可执行硬法则归实现机制。
|
背景与规则只提取承重内核,不扩写百科;舞台细节归舞台骨架,可执行硬法则归实现机制;具体遣词句式归叙事指南。
|
||||||
feeds: design_only
|
feeds: design_only
|
||||||
```
|
```
|
||||||
|
|
||||||
## opening
|
## opening
|
||||||
|
|
||||||
```opening
|
```opening
|
||||||
先说你最想在这局里反复获得什么体验,以及你希望自己站在什么位置。可以直接给一个场景、关系或感觉,不必先整理设定。
|
先用几句话抓住你要的世界和体验核心(想到什么写什么,不必整齐):
|
||||||
|
|
||||||
|
1. 原型世界是什么?变在哪里?
|
||||||
|
例:丧尸世界,但只有我不会被感染;现代都市,但灵异是真的;也可以只说「现代都市」,变点稍后补。
|
||||||
|
|
||||||
|
2. 你代入吗?希望自己站在什么位置?
|
||||||
|
· 完全代入(我就是那个人,TA 的遭遇≈我的遭遇)
|
||||||
|
· 部分代入 / 跟着体验(有距离)
|
||||||
|
· 操控或旁观(角色是棋子/观察对象)
|
||||||
|
· 其它(请写明)
|
||||||
|
|
||||||
|
3. 你最想在这局里反复获得什么体验?
|
||||||
|
一句话即可——权力、求生紧张、关系拉扯、解谜、荣耀、禁忌刺激……用你自己的词。
|
||||||
```
|
```
|
||||||
|
|
||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
从用户材料中提取一份体验契约,写入 `设计.美学纲领与交互范式`。产物只回答三件事:用户最想反复获得什么核心体验;什么最小背景或规则内核让它成立;模型与用户各自能决定什么、何时必须停笔。
|
从用户材料中提取一份体验契约,写入 `设计.美学纲领与交互范式`。它是后续所有能力取舍的依据,也会挂给主世界层与转述层直接阅读。产物回答三件事:
|
||||||
|
|
||||||
保留用户自己的重点和尺度。材料不足时少写,把真正影响核心体验或背景内核的缺口交给 `self_score.gap`;产物正文不放评分、诊断、追问或开放问题。
|
1. 美学纲领:用户最想反复获得什么体验,它通过什么关系、处境、感官或选择反复发生,呈现时的氛围与尺度。
|
||||||
|
2. 背景与规则:删掉就会让这份体验不成立的最小承重前提,以及它如何约束人物、选择与后果。
|
||||||
|
3. 交互范式:用户与所扮演角色的关系、人称与视角、模型对 user 每类行为能代写到哪一步、世界后果由谁裁定、何时必须停笔等用户。
|
||||||
|
|
||||||
|
保留用户自己的重点、用词和尺度。材料不足时少写或写「未定」,把真正影响体验或权限的缺口交给 `self_score.gap`;产物正文不放评分、诊断、追问或开放问题。
|
||||||
```
|
```
|
||||||
|
|
||||||
## principles
|
## principles
|
||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 核心体验不是题材、剧情简介或情绪标签;要写用户如何参与、体验怎样反复发生。
|
1. 核心是用户想要的体验;其余字段都是为了让它稳定地反复发生。完善设定时回头核对原本的核心体验,不让它被设定冲淡。
|
||||||
2. 背景与规则寻找最小承重内核:可以是关键关系、社会前提、世界规则或作用机制。删掉它会使核心体验不成立。
|
2. 核心体验不是题材、剧情简介或情绪标签:写用户如何参与、体验怎样反复发生。同一设定不同用户要的可以完全不同(角斗士:荣耀 / 自由 / 血与酒),找到这个人要的那一个,而不是补全世界观。
|
||||||
3. 角色人文关系、描写焦点和文风信号可作为核心体验的组成材料,但不为评分硬拆成独立维度。
|
3. 变造世界先抓「原型 + 变在哪里」;核心体验往往来自变点。背景与规则只写承重内核:关键关系、社会前提、世界规则或作用机制。
|
||||||
4. 本步不扩写世界百科,不制定完整硬机制,不细化遣词句式,不决定故事推进方法。
|
4. 用户常说不清想要什么:展示优于提问,大胆优于保守;用两种站位或两种感觉的短场景让用户选或改;负面信号同样有效。用户可以说不,随时准备整份改写。
|
||||||
5. 参与边界写可执行权限:模型可补哪些反应与行动,哪些决定必须留给用户,何时停笔。
|
5. 用户要的内容去道德化、常态化处理:按用户给的尺度写进「边界与尺度」,写清服务体验的用法与真正的禁忌,不替用户加或减尺度。
|
||||||
6. 用户改口导致体验内核改变时整份重写,不在旧契约后追加互相冲突的补丁。
|
6. 交互范式写可执行权限,不写口号。描写权限按行为类别逐项写:哪些模型可直接代写、哪些只写到起势就停、哪些必须留给用户;「用户输入后」写模型对一句输入是只确认、补细节还是连带推出后果。
|
||||||
|
7. 交互范式要保护美学纲领:权限、视角和停笔点的设置要让核心体验更容易发生,两者矛盾时以美学纲领为准改范式。
|
||||||
|
8. 本步不扩写世界百科,不制定完整硬机制,不细化遣词句式,不决定故事推进方法,不输出执行单元列表或插入 order。
|
||||||
|
9. 用户改口导致体验内核改变时整份重写,不在旧契约后追加互相冲突的补丁。
|
||||||
|
```
|
||||||
|
|
||||||
|
## probe
|
||||||
|
|
||||||
|
```probe
|
||||||
|
本步最先要钉清三件:站位(代入还是旁观、镜头看哪、爽从哪来)、核心感觉(反复感受到什么)、轮转停点(何时等用户)。三件写进 `体验锚定`;开场白写入前程序会检查它们。
|
||||||
|
|
||||||
|
用户首句、默认问题首答或黑板「用户.需求」里已有的直接采纳,已答清的不重问。
|
||||||
|
|
||||||
|
缺其中一件:回复里只问这一件,给 2~3 个带短场景的建议选项,不问开放式「你还想要什么」。三件已清:再挑一个最影响体验的薄弱点(视角、某类描写权限、尺度)给选项;都够用就不再追问。
|
||||||
|
|
||||||
|
用户讲到人物、舞台、规则细节:只抽取能改变体验、站位或权限的那一句写进正文,其余留给对应能力,本步不追问细节。
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -53,29 +85,58 @@ feeds: design_only
|
|||||||
"技能": "美学纲领与交互范式",
|
"技能": "美学纲领与交互范式",
|
||||||
"brief": "一句话点出用户最想反复获得的核心体验",
|
"brief": "一句话点出用户最想反复获得的核心体验",
|
||||||
"体验锚定": {
|
"体验锚定": {
|
||||||
"站位": "用户与当前角色/作品的关系",
|
"站位": "用户与所扮演角色/作品的关系:代入、部分代入、旁观或操控",
|
||||||
"核心感觉": "最想反复获得的体验",
|
"核心感觉": "最想反复获得的体验",
|
||||||
"轮转停点": "何时必须停笔等待用户"
|
"轮转停点": "何时必须停笔等待用户"
|
||||||
},
|
},
|
||||||
"mount": ["world-simulator", "narrator"],
|
"mount": ["world-simulator", "narrator"],
|
||||||
"稳变": "stable",
|
"稳变": "stable",
|
||||||
"正文": {
|
"正文": {
|
||||||
"核心体验": {
|
"美学纲领": {
|
||||||
"体验主张": "用户最想反复获得什么",
|
"体验内核": "给后续能力和游玩层读的纲领正文,可多段:用户要反复感到什么、它靠什么关系或处境成立、从哪一刻开始、怎样被反复兑现",
|
||||||
"发生方式": "它通过哪些关系、处境、感官或选择反复发生",
|
"发生方式": "体验通过哪些关系、处境、感官或选择反复发生",
|
||||||
"角色人文": "关键人物关系中的权力、尊严、亲疏或人性张力;无则省略",
|
"角色人文": "关键人物关系中的权力、尊严、亲疏或人性张力;无则省略",
|
||||||
"描写信号": "应把注意力放在哪里、呈现什么质感;具体写法交给叙事指南"
|
"呈现要点": {
|
||||||
|
"氛围基调": "整体质感与情绪温度",
|
||||||
|
"描写焦点": "注意力放在哪里:哪些瞬间、反应、细节值得放慢写;具体遣词交给叙事指南",
|
||||||
|
"边界与尺度": "色情/暴力/血腥等按用户给的尺度:服务体验的用法与真正的禁忌",
|
||||||
|
"其它要点": ["反差、日常化、节奏偏好等短条;无则省略"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"背景与规则": {
|
"背景与规则": {
|
||||||
|
"原型与变点": "熟悉的原型世界,以及变在哪里;无变造则省略",
|
||||||
"核心内核": "让体验成立的最小承重前提",
|
"核心内核": "让体验成立的最小承重前提",
|
||||||
"作用方式": "它如何约束人物、选择与后果",
|
"作用方式": "它如何约束人物、选择与后果,怎样持续制造该体验",
|
||||||
"非必要设定": "明确本步不展开的装饰信息;无则省略"
|
"非必要设定": "明确本步不展开的装饰信息;无则省略"
|
||||||
},
|
},
|
||||||
"参与边界": {
|
"交互范式": {
|
||||||
"用户站位": "代入、部分代入、旁观、操控或本局自定义描述",
|
"前置配置": {
|
||||||
"模型可代演": "可补写的行动、反应和既有习惯",
|
"用户与user关系": "完全代入 / 部分代入 / 旁观 / 操控,及其含义",
|
||||||
"用户保留权": "不得替用户决定的事项",
|
"人称": "如:第二人称称「你」",
|
||||||
"世界反馈": "模型可直接裁定和推进的外部后果",
|
"元叙事": "如:不跳出故事直接问卷,决策用情节呈现"
|
||||||
|
},
|
||||||
|
"叙事视角": {
|
||||||
|
"主要视角": "镜头跟谁、看什么",
|
||||||
|
"视角切换": "禁止,或允许的例外",
|
||||||
|
"可写内心": "可以写谁的内心;如仅 user 或仅 NPC 外显",
|
||||||
|
"不呈现的信息": "user 不该知道、正文不能透露的内容"
|
||||||
|
},
|
||||||
|
"描写权限": {
|
||||||
|
"思考与抉择": "关键决定是否只能由用户做,模型写到哪里停",
|
||||||
|
"言语": "模型能否替 user 说话、说到什么程度",
|
||||||
|
"主动行动": "模型能否替 user 发起行动",
|
||||||
|
"情绪与身体反应": "模型可补写的生理与情绪反应",
|
||||||
|
"习惯与既有技能": "模型可按既有设定自动执行的部分",
|
||||||
|
"外在状态": "外貌、伤势、衣着等可否直接描写",
|
||||||
|
"用户输入后": "对用户一句输入:只确认 / 补细节 / 连带推出后果"
|
||||||
|
},
|
||||||
|
"后果与叙事控制": {
|
||||||
|
"行为后果判定": "谁裁定 user 行为的成败与代价",
|
||||||
|
"世界对user的影响": "NPC 与世界如何主动作用于 user",
|
||||||
|
"时间与场景": "模型可否推进时间、切换场景,跨度多大",
|
||||||
|
"引入新内容": "模型可否主动引入新人物、事件、威胁",
|
||||||
|
"设计困境": "是否主动给 user 制造两难;怎样的两难服务体验"
|
||||||
|
},
|
||||||
"停笔时机": "必须等用户输入的明确时机"
|
"停笔时机": "必须等用户输入的明确时机"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,19 +144,24 @@ feeds: design_only
|
|||||||
```
|
```
|
||||||
|
|
||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON;保留 `体验锚定` 仅为兼容,不做计数门槛。
|
1. 输出合法 JSON。`体验锚定` 三项必填短句:开场白写入前程序按它判定体验是否已锚定,缺项会拒绝写入开场白。
|
||||||
2. `正文` 只含 `核心体验`、`背景与规则`、`参与边界`;未知字段省略,不用“完备度”或诊断对象占位。
|
2. `正文` 只含 `美学纲领`、`背景与规则`、`交互范式` 三块,每块是对象,不写成一段散文。未知字段写「未定」或省略,不编造百科。
|
||||||
3. 不在产物正文写 `自评`、`追问`、`开放问题`、评分依据或用户回复。
|
3. 不在产物里写 `自评`、`追问`、`开放问题`、完备度、评分依据或用户回复;缺口交给 `self_score.gap`。
|
||||||
4. `mount` 固定包含 `world-simulator` 与 `narrator`;后续投影器分别裁剪字段。
|
4. `mount` 固定包含 `world-simulator` 与 `narrator`;不写数字 `order`,不输出执行单元列表。
|
||||||
|
|
||||||
## score
|
## score
|
||||||
|
|
||||||
```score
|
```score
|
||||||
核心体验:
|
美学纲领:
|
||||||
- 5:准确提取用户最想反复获得的体验,写清用户站位、体验如何发生及何时停笔;后续能力可据此取舍。
|
- 5:准确提取用户最想反复获得的体验,写清它怎样反复发生、从哪一刻成立,呈现焦点与尺度贴合用户原话;后续能力可据此取舍。
|
||||||
- 3:大方向正确,但仍偏题材或情绪概括,或体验如何反复发生尚不明确。
|
- 3:大方向正确,但仍偏题材或情绪概括,或体验如何反复发生、尺度如何把握尚不明确。
|
||||||
- 1:误读用户重点,只有剧情简介、设定标签或泛化氛围。
|
- 1:误读用户重点,只有剧情简介、设定标签或泛化氛围。
|
||||||
|
|
||||||
|
交互范式:
|
||||||
|
- 5:站位、人称视角、逐类描写权限、后果裁定与停笔时机都可执行,且在保护核心体验。
|
||||||
|
- 3:有站位与停笔,但描写权限笼统,或某类行为(言语、行动、反应)谁来写仍不清。
|
||||||
|
- 1:只有口号式描述,或权限设置与核心体验矛盾。
|
||||||
|
|
||||||
背景与规则:
|
背景与规则:
|
||||||
- 5:找到让核心体验成立的最小内核——关键关系、背景前提或作用规则,并说明它怎样持续制造该体验。
|
- 5:找到让核心体验成立的最小内核——关键关系、背景前提或作用规则,并说明它怎样持续制造该体验。
|
||||||
- 3:找到相关前提,但内核过宽、因果不清,或混有较多装饰设定。
|
- 3:找到相关前提,但内核过宽、因果不清,或混有较多装饰设定。
|
||||||
@@ -105,9 +171,23 @@ feeds: design_only
|
|||||||
## checklist
|
## checklist
|
||||||
|
|
||||||
```checklist
|
```checklist
|
||||||
- [ ] 核心体验写的是用户反复获得什么,而非题材或剧情简介?
|
- [ ] `体验锚定` 三项(站位、核心感觉、轮转停点)都已写出?
|
||||||
|
- [ ] 美学纲领写的是用户反复获得什么、怎样发生,而非题材或剧情简介?体验内核能直接给游玩层读?
|
||||||
- [ ] 背景与规则是删掉就会破坏体验的最小内核?
|
- [ ] 背景与规则是删掉就会破坏体验的最小内核?
|
||||||
- [ ] 参与边界明确模型可代演、用户保留权和停笔时机?
|
- [ ] 交互范式逐类写清描写权限、后果裁定和停笔时机,并且在保护美学纲领?
|
||||||
- [ ] 未扩写舞台百科、硬机制、具体文风或推进方案?
|
- [ ] 未扩写舞台百科、硬机制、具体文风或推进方案?
|
||||||
- [ ] 产物正文不含评分、诊断、追问和开放问题?
|
- [ ] 产物正文不含评分、诊断、追问和开放问题?
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## examples
|
||||||
|
|
||||||
|
```examples
|
||||||
|
好:
|
||||||
|
- brief 点题「垄断生存资源的绝对掌控感」;美学纲领的体验内核写清掌控感从哪一刻成立、靠什么反复兑现,边界与尺度照用户原话;交互范式写明 user 的抉择只由用户做、模型可补身体反应与习惯动作、NPC 的屈服与反抗由模型裁定、每次 NPC 给出回应后停笔。
|
||||||
|
|
||||||
|
坏:
|
||||||
|
- 只有一段散文纲领,三块没有拆成对象。
|
||||||
|
- 百科式背景堆砌,看不出体验靠哪条前提成立。
|
||||||
|
- 交互范式只写「尊重玩家选择」,没说言语、行动、反应各由谁写。
|
||||||
|
- 在正文里写评分、追问或「待确认」清单。
|
||||||
|
```
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ modules:
|
|||||||
mount: [world-simulator]
|
mount: [world-simulator]
|
||||||
declaration: >-
|
declaration: >-
|
||||||
钉清 @玩家 扮演的那个人:名字、背景、特殊设定(身份/金手指等);
|
钉清 @玩家 扮演的那个人:名字、背景、特殊设定(身份/金手指等);
|
||||||
自动写入时加性格。代入有名片时排本步;可 设计.主角设定#短码 与开场白同槽绑定;禁止把名字/简介写进开场白正文。
|
自动写入时加性格。代入有名片时排本步,写各开场共通的部分;各开场不同的玩家角色写在开场白的 meta.用户角色,由程序同步为同短码主角设定;禁止把名字/简介写进开场白正文。
|
||||||
artifact: 设计.主角设定
|
artifact: 设计.主角设定
|
||||||
|
|
||||||
# —— 世界与内容 ——
|
# —— 世界与内容 ——
|
||||||
@@ -141,7 +141,7 @@ modules:
|
|||||||
mount: [world-simulator, narrator]
|
mount: [world-simulator, narrator]
|
||||||
declaration: >-
|
declaration: >-
|
||||||
从偏好库选取约束,只选定一种用户输入用法,
|
从偏好库选取约束,只选定一种用户输入用法,
|
||||||
并写清扩写/续写定义、比例与 OOC 处理;不写遣词
|
并写清每一轮里扩写(演本轮材料)与续写(材料后往下写)的定义、比例与 OOC 处理;不写遣词
|
||||||
artifact: 设计.故事推进
|
artifact: 设计.故事推进
|
||||||
|
|
||||||
# —— 机制与数据 ——
|
# —— 机制与数据 ——
|
||||||
@@ -243,7 +243,7 @@ modules:
|
|||||||
declaration: >-
|
declaration: >-
|
||||||
普通楼层创作终节点,且可增殖:每条开场各占一个节点(设计.开场白#场景短码)。
|
普通楼层创作终节点,且可增殖:每条开场各占一个节点(设计.开场白#场景短码)。
|
||||||
默认 1 条;还要入口就再增殖。全部保存。游玩时左右切换,选中的一条作为 0 层
|
默认 1 条;还要入口就再增殖。全部保存。游玩时左右切换,选中的一条作为 0 层
|
||||||
artifact: 设计.开场白与开场变量
|
artifact: 设计.开场白
|
||||||
|
|
||||||
- id: zero-layer-opening-setup
|
- id: zero-layer-opening-setup
|
||||||
name: 0层开场白与初态
|
name: 0层开场白与初态
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ feeds: gm
|
|||||||
|
|
||||||
有生成规则时:认定规则的中文名,按规则的字段、池或提示、填写工作流生成这一条;用户已写明的专名与结果只扩写不改写,未写字段从已有材料往下补满。无规则且只是一条字段很少的具名内容时:直接写用户给出的字段。
|
有生成规则时:认定规则的中文名,按规则的字段、池或提示、填写工作流生成这一条;用户已写明的专名与结果只扩写不改写,未写字段从已有材料往下补满。无规则且只是一条字段很少的具名内容时:直接写用户给出的字段。
|
||||||
|
|
||||||
|
规则的一次生成为同批时:一次 insert 写齐本批,`记录` 写成数组,每项一条记录并带上规则里的姓名或名称字段,程序按条拆成多份。先定各条如何互指、对立、同场,再填各条字段。
|
||||||
|
|
||||||
材料足够便直接成稿。缺的是合同本身时,先回到生成规则,不在这里补合同。产物正文不放评分、诊断、追问或开放问题。
|
材料足够便直接成稿。缺的是合同本身时,先回到生成规则,不在这里补合同。产物正文不放评分、诊断、追问或开放问题。
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -44,10 +46,13 @@ feeds: gm
|
|||||||
1. 有规则:字段名、取值方式、工作流阶段与依赖都以规则为准;不增删字段,不改池。
|
1. 有规则:字段名、取值方式、工作流阶段与依赖都以规则为准;不增删字段,不改池。
|
||||||
2. 池选取按合理度选择,不是程序抽签;提示约束按那句话的范围写开。
|
2. 池选取按合理度选择,不是程序抽签;提示约束按那句话的范围写开。
|
||||||
3. 按填写工作流推进:先骨架后血肉;阶段依赖与自检能核对的,生成后要能过。
|
3. 按填写工作流推进:先骨架后血肉;阶段依赖与自检能核对的,生成后要能过。
|
||||||
4. 一份产物一条记录。多条各自成稿,用中文名称区分。
|
4. 一份产物一条记录。单条规则一次写一条;多条各自成稿,用中文名称区分。
|
||||||
5. 无规则只写用户点名的那一条,字段保持很少;不借机发明完整人设合同。
|
5. 同批规则一次写齐本批:先对齐关系,再填字段;各条里互指的专名与关系必须一致(甲记乙为仇敌,乙也记甲,且说的是同一件事)。有关系的人在同一次里写完,不拆到不同轮。
|
||||||
6. 用户已写明的专名、关系、结果保持原样,只做厚度扩写;未写字段从这些材料延伸。
|
6. 句子级字段写到规则 `地板` 规定的可感知程度;词条、档位、数字可以短。句子级字段只丢一个词不算填完。
|
||||||
7. 给人读的键和专名用中文。材料里的英文名音译成中文,不把英文原名留在字段值里。外壳的 schema、brief、mount 保持既有键。
|
7. 分级字段只填档位短值(如「金丹」);表象留在规则的分级表现里,不复制进每条记录。
|
||||||
|
8. 无规则只写用户点名的那一条,字段保持很少;不借机发明完整人设合同。
|
||||||
|
9. 用户已写明的专名、关系、结果保持原样,只做厚度扩写;未写字段从这些材料延伸。
|
||||||
|
10. 给人读的键和专名用中文。材料里的英文名音译成中文,不把英文原名留在字段值里。外壳的 schema、brief、mount 保持既有键。
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -70,7 +75,7 @@ feeds: gm
|
|||||||
```
|
```
|
||||||
|
|
||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON。`记录` 是单个对象。`名称` 用中文,并与这条记录的主名称一致。
|
1. 输出合法 JSON。`记录` 是单个对象;规则为同批时写成至少 2 项的对象数组,每项带姓名或名称。`名称` 用中文,并与这条记录的主名称一致。
|
||||||
2. 有规则时 `记录` 的键必须来自该规则字段,不得增删。
|
2. 有规则时 `记录` 的键必须来自该规则字段,不得增删。
|
||||||
3. 无规则时省略 `规则`,`记录` 只含用户给出的少量简单字段。
|
3. 无规则时省略 `规则`,`记录` 只含用户给出的少量简单字段。
|
||||||
4. 字段名和专名用中文;英文专名音译为中文。
|
4. 字段名和专名用中文;英文专名音译为中文。
|
||||||
@@ -81,9 +86,9 @@ feeds: gm
|
|||||||
|
|
||||||
```score
|
```score
|
||||||
工作流符合度:
|
工作流符合度:
|
||||||
- 5:严格按规则填写工作流推进;字段、池选取、依赖与自检都落实;无规则路径则字段简单够用且不越权发明合同。
|
- 5:严格按规则填写工作流推进;字段、池选取、依赖与自检都落实;句子级字段达到地板;同批各条互指一致;无规则路径则字段简单够用且不越权发明合同。
|
||||||
- 3:大体按工作流,但有一处阶段跳跃、依赖未咬合,或某字段明显偏薄。
|
- 3:大体按工作流,但有一处阶段跳跃、依赖未咬合,或某句子级字段明显低于地板。
|
||||||
- 1:忽略工作流;乱增字段;或把用户已定内容改掉。
|
- 1:忽略工作流;乱增字段;句子级只填一个词;同批互指对不上;或把用户已定内容改掉。
|
||||||
```
|
```
|
||||||
|
|
||||||
## checklist
|
## checklist
|
||||||
@@ -91,7 +96,18 @@ feeds: gm
|
|||||||
```checklist
|
```checklist
|
||||||
- [ ] 有规则则按字段与填写工作流执行;无规则则只是一条字段很少的具名内容?
|
- [ ] 有规则则按字段与填写工作流执行;无规则则只是一条字段很少的具名内容?
|
||||||
- [ ] 一份产物只有一条记录,名称是中文?
|
- [ ] 一份产物只有一条记录,名称是中文?
|
||||||
|
- [ ] 同批规则已一次写齐,各条互指的专名与关系一致?
|
||||||
|
- [ ] 句子级字段达到地板?分级字段只填档位短值?
|
||||||
- [ ] 记录键值合法,用户已定专名未改写,英文专名已音译?
|
- [ ] 记录键值合法,用户已定专名未改写,英文专名已音译?
|
||||||
- [ ] 未改合同?
|
- [ ] 未改合同?
|
||||||
- [ ] 产物正文不含评分、诊断、追问?
|
- [ ] 产物正文不含评分、诊断、追问?
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## examples
|
||||||
|
|
||||||
|
```examples
|
||||||
|
好:规则一次生成=同批、关系=开局仇敌二人 → 一次写齐 2 条,甲的仇敌字段写乙与夺位之事,乙的写甲与同一件事,两条对得上。
|
||||||
|
坏:记录里加了规则没有的字段(规则无「性别」却写进去)。
|
||||||
|
坏:规则要求句子级的「气质」只填「阴郁」,没达到地板。
|
||||||
|
坏:同批的仇敌二人分两轮各写各的,互指的名字和事由对不上。
|
||||||
|
```
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ feeds: gm,auditor
|
|||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
为单一对象族写出一份生成合同,写入 `设计.生成规则#中文规则名`。先写名称与用途,再按用途推出字段与每项填充要求;需要封闭选取的字段绑池,更适合一句话约束的字段写提示;最后写填写工作流(含依赖、自检、反模式),供具体实例与游玩期动态生成共用。
|
为单一对象族写出一份生成合同,写入 `设计.生成规则#中文规则名`。先写名称与用途,再按用途推出字段与每项填充要求;描写类字段写明地板,标签看不懂的分级另写分级表现;需要封闭选取的字段绑池,更适合一句话约束的字段写提示;最后写填写工作流(含依赖、自检、反模式),供具体实例与游玩期动态生成共用。
|
||||||
|
|
||||||
每个产物只含一条规则。材料足够便直接成稿;真正会改合同的缺口(生命周期、必填字段、单条/同批关系)交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
每个产物只含一条规则。材料足够便直接成稿;真正会改合同的缺口(生命周期、必填字段、单条/同批关系)交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
||||||
```
|
```
|
||||||
@@ -39,15 +39,20 @@ feeds: gm,auditor
|
|||||||
## principles
|
## principles
|
||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 用途决定字段:每个字段都要能回答「对这个用途是否必要」;女 NPC 不写性别;药水要写清效果参数,不要只写外观。
|
1. 用途决定字段:每个字段都要能回答「对这个用途是否必要」;女 NPC 不写性别;药水要写清效果参数,不要只写外观;怪物这类要接战斗的,等级、攻击骰等玩法接口不能漏。
|
||||||
2. 字段不多不少、互不重叠:资产与存款这类同义字段只留一个。
|
2. 字段不多不少、互不重叠:资产与存款这类同义字段只留一个。
|
||||||
3. 取值分三类:自由生成;从池中选取(填写方按合理度选,不是伪随机程序);提示约束(如「现代都市中正常的职业」)。默认优先提示约束;只有需要封闭一致或分阶段选型时才建池。
|
3. 取值分三类:自由生成;从池中选取(填写方按合理度选,不是伪随机程序);提示约束(如「现代都市中正常的职业」)。默认优先提示约束;只有能数完、必须全员一致的封闭集合(阶层、强度档、武器种类)或分阶段选型时才建池。
|
||||||
4. 填写工作流按重要性推进:先锚定会约束后续的选项,再填血肉;阶段、依赖、自检、反模式都服务于「怎么填对」,写短写准。
|
4. 姓名、绰号、能力概念、外貌、气质写不完:用自由或提示约束,不建池,也不在填充要求或提示里列一串可选值冒充穷举。要反复生成又怕雷同时,`提示` 可写 2~6 个「方向名:一句概括」划多样的范围;方向不挂作品名、不堆例句。
|
||||||
5. 生命周期三选一:`仅游玩期`(创作期不预填)、`只预生成`(预填完即可丢掉规则)、`预生成并游玩期`(既写种子,开场后仍可再生成)。选哪一档不改变「要不要先写这份合同」。
|
5. 描写类字段(外貌、能力现象、限制、互动手感等)写 `地板`:最低要写到读者能看见、听见或碰到什么,对手能抓住什么空隙。词条、档位、数字、骰子可以短,不写地板。规则里出现的示例必须达到地板,不用「气质:阴郁」「能力:时空斩」这种单标签当示例。
|
||||||
6. `只预生成` 为中间层;另两类为最终层,挂主世界层与旁观。
|
6. 标签本身看不懂的分级(境界、品阶、强度档、好感档)另写 `分级表现`:每档一句概括 + 一段可感知表象,相邻档必须能分清。实例字段只填档位短值,叙事按此表展开。
|
||||||
7. 上游舞台/机制只短引用;不在本步发明共通前提百科。
|
7. 外貌、服装、仪式等几面拼成一格的字段,在该字段 `填充要求` 里写配方:用哪几面、每面写什么、怎么拼成一段。字段仍是一个扁平值,不拆嵌套对象,也不为每一面各建池。
|
||||||
8. 不输出最终实例记录。
|
8. 填写工作流按重要性推进:先锚定会约束后续的选项,再填血肉;阶段、依赖、自检、反模式都服务于「怎么填对」,写短写准。会反复生成的规则,自检清单要含去重:与已有记录不撞名、不撞核心特征。
|
||||||
9. 给人读的键和专名用中文。材料里的英文名音译成中文。外壳的 schema、brief、mount 保持既有键。
|
9. 生命周期三选一:`仅游玩期`(创作期不预填)、`只预生成`(预填完即可丢掉规则)、`预生成并游玩期`(既写种子,开场后仍可再生成)。选哪一档不改变「要不要先写这份合同」。
|
||||||
|
10. `只预生成` 为中间层;另两类为最终层,挂主世界层与旁观。
|
||||||
|
11. `数量` 与 `一次生成` 是两件事:`数量` 是总共几条;`一次生成` 看记录之间有没有必须一起钉死的关系(互指、对立、同场),不看总数。有 → 同批,`关系` 只写谁对谁;彼此独立 → 单条。「总共 5 个」不等于同批。
|
||||||
|
12. 多张名片共享的台上格局、世界状况、既成法则归舞台骨架或实现机制:已有则在 `用途` 或 `填充要求` 里短引用;没有则只写填写时必须守的最短条款,缺口交 `self_score.gap`。不在规则里重抄「共通前提」长文,也不借 `关系` 写前史与社会律。
|
||||||
|
13. 不输出最终实例记录。
|
||||||
|
14. 给人读的键和专名用中文。材料里的英文名音译成中文。外壳的 schema、brief、mount 保持既有键。
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -78,10 +83,11 @@ feeds: gm,auditor
|
|||||||
"字段": [
|
"字段": [
|
||||||
{
|
{
|
||||||
"名": "顶层字段名",
|
"名": "顶层字段名",
|
||||||
"填充要求": "填什么、填到多厚、为何本用途需要它",
|
"填充要求": "填什么、填到多厚、为何本用途需要它;几面拼一格的写配方(哪几面、各写什么)",
|
||||||
"取值方式": "自由|池选取|提示约束",
|
"取值方式": "自由|池选取|提示约束",
|
||||||
"绑定池": "池选取时填写池名称;其它省略",
|
"绑定池": "池选取时填写池名称;其它省略",
|
||||||
"提示": "提示约束时写可执行的一句范围;其它省略"
|
"提示": "提示约束时写可执行的一句范围,或 2~6 个「方向名:一句概括」;其它省略",
|
||||||
|
"地板": "描写类字段写:最低填到什么可感知程度;词条、档位、数字省略"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"池": [
|
"池": [
|
||||||
@@ -91,6 +97,18 @@ feeds: gm,auditor
|
|||||||
"条目": ["封闭可选项;只列必须一致的有限集合"]
|
"条目": ["封闭可选项;只列必须一致的有限集合"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"分级表现": [
|
||||||
|
{
|
||||||
|
"字段": "标签看不懂的分级字段名;无此类分级则省略整个键",
|
||||||
|
"档位": [
|
||||||
|
{
|
||||||
|
"值": "档位短值,与实例所填一致",
|
||||||
|
"概括": "一句:这档能做到什么量级",
|
||||||
|
"表象": "看见、听见、碰到什么;须能和上下档分清"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"填写工作流": {
|
"填写工作流": {
|
||||||
"工作流": {
|
"工作流": {
|
||||||
"阶段1 骨架锚定": "先选哪些池/字段、定下什么内核",
|
"阶段1 骨架锚定": "先选哪些池/字段、定下什么内核",
|
||||||
@@ -119,29 +137,30 @@ feeds: gm,auditor
|
|||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON;每个产物只描述一条规则,禁止 `rules` 数组堆多条。
|
1. 输出合法 JSON;每个产物只描述一条规则,禁止 `rules` 数组堆多条。
|
||||||
2. `字段` 由用途推出;可省略的字段不写;重叠字段合并。
|
2. `字段` 由用途推出;可省略的字段不写;重叠字段合并。
|
||||||
3. `池` 可 `[]`;池选取字段必须能对应到某个池;提示约束字段必须有 `提示`。
|
3. `池` 可 `[]`;池选取字段必须能对应到某个池;提示约束字段必须有 `提示`。姓名、绰号、能力概念、外貌、气质不绑池。
|
||||||
4. `填写工作流` 必含 `工作流`;`依赖关系` / `自检清单` / `反模式` 为子字段,无则 `[]`,不要拆成正文顶层并列大段。
|
4. `填写工作流` 必含 `工作流`;`依赖关系` / `自检清单` / `反模式` 为子字段,无则 `[]`,不要拆成正文顶层并列大段。
|
||||||
5. `一次生成` 为同批时关系必填且本批至少 2 条;为单条时一次只交 1 条。
|
5. `一次生成` 为同批时关系必填且本批至少 2 条;为单条时一次只交 1 条。
|
||||||
6. 字段值为扁平可填内容;不要嵌套对象当字段值契约。
|
6. 字段值为扁平可填内容;不要嵌套对象当字段值契约。几面拼一格的配方写在 `填充要求`。
|
||||||
7. 不在产物正文写自评、追问、开放问题或最终记录。
|
7. 描写类字段必须有 `地板`;规则里任何示例都要达到地板。标签看不懂的分级必须在 `分级表现` 写齐每档;没有此类分级则省略 `分级表现`。
|
||||||
8. `只预生成` 时程序记为中间层;`仅游玩期` / `预生成并游玩期` 记为最终层。
|
8. 不在产物正文写自评、追问、开放问题或最终记录。
|
||||||
9. 字段名和专名用中文;英文专名音译为中文。
|
9. `只预生成` 时程序记为中间层;`仅游玩期` / `预生成并游玩期` 记为最终层。
|
||||||
|
10. 字段名和专名用中文;英文专名音译为中文。
|
||||||
|
|
||||||
## score
|
## score
|
||||||
|
|
||||||
```score
|
```score
|
||||||
字段妥当:
|
字段妥当:
|
||||||
- 5:字段由用途推出,不多不少、不重叠;该有的玩法/效果接口齐全,该省略的已省略。
|
- 5:字段由用途推出,不多不少、不重叠;该有的玩法/效果接口齐全,该省略的已省略;描写类字段有达到可感知的地板,难懂分级有分级表现。
|
||||||
- 3:大体可用,但有 1 个冗余、重叠或明显缺失字段。
|
- 3:大体可用,但有 1 个冗余、重叠或明显缺失字段,或个别描写字段地板含糊、某档分级只有阶名。
|
||||||
- 1:通用名片乱塞,或关键效果/接口字段缺失。
|
- 1:通用名片乱塞,或关键效果/接口字段缺失;描写字段与示例都是单标签。
|
||||||
|
|
||||||
池与提示:
|
池与提示:
|
||||||
- 5:该用池的用了,不该用的改成一句提示约束;池精炼可选取;提示具体可执行。
|
- 5:只有封闭集合用池,不可穷举项用提示或自由;提示具体可执行,方向式提示以「方向名:一句概括」为主、不挂作品名。
|
||||||
- 3:大体合理,但个别字段误用/漏用池,或池过泛、提示含糊。
|
- 3:大体合理,但个别字段误用/漏用池,或池过泛、提示含糊。
|
||||||
- 1:大段假穷举,或本该一句话提示却做成巨型词表。
|
- 1:为姓名、气质、能力等建词池,大段假穷举,或本该一句话提示却做成巨型词表。
|
||||||
|
|
||||||
填写工作流:
|
填写工作流:
|
||||||
- 5:阶段简洁有力,依赖与自检/反模式能引导按重要性推进,不臃肿。
|
- 5:阶段简洁有力,依赖与自检/反模式能引导按重要性推进,反复生成时含去重,不臃肿。
|
||||||
- 3:有阶段但顺序或依赖不清,或清单偏长却抓不住关键咬合。
|
- 3:有阶段但顺序或依赖不清,或清单偏长却抓不住关键咬合。
|
||||||
- 1:无工作流,或与字段脱节,无法指导动态生成。
|
- 1:无工作流,或与字段脱节,无法指导动态生成。
|
||||||
```
|
```
|
||||||
@@ -151,8 +170,30 @@ feeds: gm,auditor
|
|||||||
```checklist
|
```checklist
|
||||||
- [ ] 名称与用途已写清?
|
- [ ] 名称与用途已写清?
|
||||||
- [ ] 字段由用途推出,无冗余重叠、无关键缺失?
|
- [ ] 字段由用途推出,无冗余重叠、无关键缺失?
|
||||||
- [ ] 池与提示分工正确?
|
- [ ] 池与提示分工正确?姓名、绰号、能力、外貌、气质没有建池?
|
||||||
- [ ] 填写工作流含子字段:工作流 / 依赖关系 / 自检清单 / 反模式?
|
- [ ] 描写类字段有地板,示例不是单标签?难懂分级有分级表现?
|
||||||
- [ ] 生命周期与一次生成已钉?
|
- [ ] 几面拼一格的字段在填充要求里写了配方?
|
||||||
|
- [ ] 填写工作流含子字段:工作流 / 依赖关系 / 自检清单 / 反模式?反复生成时自检含去重?
|
||||||
|
- [ ] 生命周期已钉?一次生成按关系定,而非按总数?
|
||||||
|
- [ ] 共享格局只引用舞台骨架,没有重抄共通前提?
|
||||||
- [ ] 未输出最终实例、百科长文、评分或追问?
|
- [ ] 未输出最终实例、百科长文、评分或追问?
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## examples
|
||||||
|
|
||||||
|
```examples
|
||||||
|
好:
|
||||||
|
- 武功典籍:字段为名字 / 效果 / 阶层。阶层池选取封闭档位,另写 `分级表现`,每阶一句概括 + 可感知表象;名字与效果用提示约束。
|
||||||
|
- 女主规则:不设「性别」;池为 `[]`;内在冲突自由写,地板是「能在一场戏里演出来的两难」。
|
||||||
|
- D&D 怪物:名称、等级、攻击骰齐全;气质用提示约束,地板写到一个可观察的习性。
|
||||||
|
- 外貌:`填充要求` 写配方「轮廓一句 + 质感一句 + 一个旁人会记住的细节」,拼成扁平的一段「外貌」。
|
||||||
|
- 互为仇敌的两名开局 NPC → 一次生成=同批,关系写谁夺了谁的什么;可互换的酒馆路人 → 单条;总共要 5 个但彼此无关 → 数量 5、一次生成单条。
|
||||||
|
|
||||||
|
坏:
|
||||||
|
- 为气质、绰号、能力各建一个 8~20 词的池,填出来全是标签。
|
||||||
|
- 提示里的方向每条挂作品名或例句,变成假选项库。
|
||||||
|
- 示例写「气质:阴郁」「能力:时空斩」,地板被示例教坏。
|
||||||
|
- 阶层只写「A」「金丹」,没有分级表现,叙事时无从下笔。
|
||||||
|
- 把社会格局、前史写成「共通前提」长文塞进规则,而不是引用舞台骨架。
|
||||||
|
- 女主规则塞「性别」;怪物规则只有名字和性格,没有等级、攻击骰。
|
||||||
|
```
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ declaration: >
|
|||||||
when: |
|
when: |
|
||||||
本局存在拿掉后会改变故事骨架的既成法则,且后续需要据此推演环境变化、事件后果或人物反应时使用。
|
本局存在拿掉后会改变故事骨架的既成法则,且后续需要据此推演环境变化、事件后果或人物反应时使用。
|
||||||
when_not: |
|
when_not: |
|
||||||
只有熟悉常识,没有特殊因果法则 → 不使用。
|
只有熟悉常识,没有特殊因果法则 → 不使用。 「特殊」只是身份或开场处境、能被舞台骨架、具体实例或开场白覆盖(如现实世界里模拟一名普通大学生)→ 不使用。 地点、势力与社会格局 → 舞台骨架;多份人物或要先审字段的名片 → 生成规则,单条字段很少的具名内容才归具体实例;字段合同 → 生成规则。
|
||||||
地点、势力与社会格局 → 舞台骨架;多份人物或要先审字段的名片 → 生成规则,单条字段很少的具名内容才归具体实例;字段合同 → 生成规则。
|
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力交稳定因果,不预写唯一剧情,也不把所有人物钉成同一种反应。
|
本能力交稳定因果,不预写唯一剧情,也不把所有人物钉成同一种反应。
|
||||||
不解释起源,不扩写世界百科,不制定推进节奏、变量表或检定公式。
|
不解释起源,不扩写世界百科,不制定推进节奏、变量表或检定公式。
|
||||||
@@ -39,12 +38,16 @@ feeds: gm
|
|||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 机制是稳定因果:写世界承认什么、何时生效、会带来什么后果。
|
1. 机制是稳定因果:写世界承认什么、何时生效、会带来什么后果。
|
||||||
2. 内部使用移除检验筛选承重法则;只能回答“更丰富”或“以后也许有用”的候选不纳入。缺失后果不输出。
|
2. 法则可以是超常前提、约束性公理,也可以是社会性或规范性的既成事实,如“这个世界没有花里胡哨的魔法,只有繁衍到极致的斗气”“这个西幻世界里男尊女卑是常态”“这里以三国杀高手为尊”。同一设定有很多面向,只写后续推演必须承认的那一面。
|
||||||
3. 因果影响写行动可能性、代价、资源与关系怎样改变;推演口径写推演情节和人物反应时必须考虑什么。
|
3. 身份特殊、开场处境特殊不是法则:能由舞台骨架、具体实例或开场白承担的(如现实世界里的普通大学生),不写成机制。
|
||||||
4. 人物反应保持开放:同一机制下,人物可因认知、利益、身份和性格不同而隐藏、合作、利用、抵抗或误判。
|
4. 内部使用移除检验筛选承重法则;只能回答“更丰富”或“以后也许有用”的候选不纳入。缺失后果不输出。
|
||||||
5. 多条机制可一次生成。分别写清后,再说明它们是前提、叠加、制衡还是反馈循环;没有关系则不硬连。
|
5. 内部按基准世界做最小充分继承,校验过程不写入产物:现实地球沿用现实物理、心理与社会规律;变造地球只在用户已表达或体验依赖处承认变造;具体作品世界优先最近一层作品设定,原作未定义处再参考其底层世界;类型世界只继承与当前体验有关的类型约定;从头创造以显式设定为准,未定义处用最少常识;混合或二创只处理与当前法则有关的继承与变造。
|
||||||
6. 机制不等于题材标签。“灵气复苏”“全民直播求生”必须拆成可推演的生效方式与因果链。
|
候选法则超出基准世界已知可能性时,只有用户材料已明示或体验确实依赖才写入,不暗中变造;拿不准是否在用户设想中,就不写进产物,把这处变造交给 `self_score.gap`。
|
||||||
7. 一句话够用便停止;不补起源史、体系百科、人物卡、剧情大纲或推进节奏。
|
6. 因果影响写行动可能性、代价、资源与关系怎样改变;推演口径写推演情节和人物反应时必须考虑什么。
|
||||||
|
7. 人物反应保持开放:同一机制下,人物可因认知、利益、身份和性格不同而隐藏、合作、利用、抵抗或误判。
|
||||||
|
8. 多条机制可一次生成。分别写清后,再说明它们是前提、叠加、制衡还是反馈循环;没有关系则不硬连。
|
||||||
|
9. 机制不等于题材标签。“灵气复苏”“全民直播求生”必须拆成可推演的生效方式与因果链;题材标签也不能代替“这条法则缺了故事会换”。能指明它支撑美学纲领里哪段体验时,写进 `服务体验`。
|
||||||
|
10. 一句话够用便停止;不补起源史、体系百科、人物卡、剧情大纲或推进节奏。
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -60,7 +63,8 @@ feeds: gm
|
|||||||
"既成机制": [
|
"既成机制": [
|
||||||
{
|
{
|
||||||
"名称": "简短稳定的人话名",
|
"名称": "简短稳定的人话名",
|
||||||
"法则内容": "世界中稳定成立什么",
|
"法则内容": "世界中稳定成立什么;可以是超常前提、公理,也可以是社会常态或规范",
|
||||||
|
"服务体验": "一句话说明它支撑美学纲领里的哪段体验;无明确对应则省略",
|
||||||
"生效方式": [
|
"生效方式": [
|
||||||
"对谁、在什么条件下、以什么方式起作用"
|
"对谁、在什么条件下、以什么方式起作用"
|
||||||
],
|
],
|
||||||
@@ -89,7 +93,7 @@ feeds: gm
|
|||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON;`既成机制` 可含多条承重机制。
|
1. 输出合法 JSON;`既成机制` 可含多条承重机制。
|
||||||
2. `缺失后果`、移除检验和被淘汰候选只用于内部推理,不得进入正文。
|
2. `缺失后果`、移除检验和被淘汰候选只用于内部推理,不得进入正文。
|
||||||
3. 每条机制必须写 `法则内容 / 生效方式 / 因果影响 / 推演口径`。
|
3. 每条机制必须写 `法则内容 / 生效方式 / 因果影响 / 推演口径`;`服务体验` 与 `边界或例外` 可选。
|
||||||
4. `共同作用` 只写真实存在的机制关系;无则 `[]`。
|
4. `共同作用` 只写真实存在的机制关系;无则 `[]`。
|
||||||
5. 推演口径提供因果依据,不指定唯一剧情,也不规定所有人物作同一种反应。
|
5. 推演口径提供因果依据,不指定唯一剧情,也不规定所有人物作同一种反应。
|
||||||
6. 不在产物正文写自评、追问、开放问题、起源百科或检定公式。
|
6. 不在产物正文写自评、追问、开放问题、起源百科或检定公式。
|
||||||
@@ -101,7 +105,7 @@ feeds: gm
|
|||||||
骨架必要度:
|
骨架必要度:
|
||||||
- 5:每条都是拿掉后会改变故事骨架的承重法则,没有常识、装饰或下游细节。
|
- 5:每条都是拿掉后会改变故事骨架的承重法则,没有常识、装饰或下游细节。
|
||||||
- 3:主体承重,但夹有一条作用较弱或可交给其它能力的内容。
|
- 3:主体承重,但夹有一条作用较弱或可交给其它能力的内容。
|
||||||
- 1:只有题材标签或装饰设定,没有稳定承重法则。
|
- 1:只有题材标签或装饰设定,或把身份、开场处境、暗中加入的变造写成法则。
|
||||||
|
|
||||||
因果清晰度:
|
因果清晰度:
|
||||||
- 5:生效条件与影响链清楚,能据此推演环境、事件和关系怎样变化。
|
- 5:生效条件与影响链清楚,能据此推演环境、事件和关系怎样变化。
|
||||||
@@ -121,6 +125,7 @@ feeds: gm
|
|||||||
- [ ] 法则内容、生效方式、因果影响与推演口径均可执行?
|
- [ ] 法则内容、生效方式、因果影响与推演口径均可执行?
|
||||||
- [ ] 多条机制的共同作用已写清,或明确无须关联?
|
- [ ] 多条机制的共同作用已写清,或明确无须关联?
|
||||||
- [ ] 能支持不同人物作出不同但合理的反应?
|
- [ ] 能支持不同人物作出不同但合理的反应?
|
||||||
|
- [ ] 未把身份或开场处境写成法则?超出基准世界的变造都来自用户材料或体验依赖?
|
||||||
- [ ] 未写成题材标签、世界百科、剧情大纲或裁定表?
|
- [ ] 未写成题材标签、世界百科、剧情大纲或裁定表?
|
||||||
- [ ] 产物正文不含评分、诊断、追问?
|
- [ ] 产物正文不含评分、诊断、追问?
|
||||||
```
|
```
|
||||||
@@ -136,4 +141,14 @@ feeds: gm
|
|||||||
- 可拆为“强制进入求生场”“关键行为公开直播”“观众反馈可进入场内(若本局采用)”等多条机制。
|
- 可拆为“强制进入求生场”“关键行为公开直播”“观众反馈可进入场内(若本局采用)”等多条机制。
|
||||||
- 公开评价与即时生存会共同改变选择代价;人物是否表演、隐藏或利用镜头,应由其是否在乎声誉、是否相信能返回现实等条件推出。
|
- 公开评价与即时生存会共同改变选择代价;人物是否表演、隐藏或利用镜头,应由其是否在乎声誉、是否相信能返回现实等条件推出。
|
||||||
- 观众若只能观看,就不能推演出打赏资源;边界会直接改变因果链。
|
- 观众若只能观看,就不能推演出打赏资源;边界会直接改变因果链。
|
||||||
|
|
||||||
|
好:
|
||||||
|
- 社会常态类法则“这个西幻世界里男尊女卑是常态”:生效方式写继承、婚配、对外交涉由谁做主;因果影响写女性角色行动的额外代价与绕行渠道;推演口径写人物可能顺从、利用或反抗,不规定人人同一态度。
|
||||||
|
- “这个世界没有花里胡哨的魔法,只有繁衍到极致的斗气”:只写斗气是唯一超常力量、怎样决定地位与胜负,不展开境界百科。
|
||||||
|
- 公理“黑暗森林”:一句话钉“暴露坐标即招来打击,文明默认隐藏”;后续情节不能靠对外广播求援收场,不写宇宙社会学论文。
|
||||||
|
|
||||||
|
坏:
|
||||||
|
- 现实世界模拟普通大学生,把“主角是普通大学生”写成机制:这是身份与开场处境,交给具体实例或开场白;没有特殊法则就不使用本能力。
|
||||||
|
- 二创里暗中加入原作没有的力量体系(如在无超能力的原作里写入“觉醒异能”):用户未设想的变造不写入,拿不准时交给 `self_score.gap`。
|
||||||
|
- 只写“灵气复苏”“末世”等题材标签,没有生效方式与因果链:主世界层无法据此推演任何后果。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ name: 叙事指南
|
|||||||
id: narrative-guide
|
id: narrative-guide
|
||||||
artifact: 设计.叙事指南
|
artifact: 设计.叙事指南
|
||||||
declaration: >
|
declaration: >
|
||||||
从文风库选取本局写法,落成可执行的遣词要求、笔墨落点、少量禁忌与可选写法档;不钉故事怎么往前。
|
继承美学纲领与交互范式,从文风库选取本局写法,落成可执行的遣词要求、视角执行、段落形态、笔墨落点、少量禁忌与可选写法档;不钉故事怎么往前。
|
||||||
when: |
|
when: |
|
||||||
已有美学纲领(或等价),但游玩正文仍不知道怎么写:遣词什么要求、笔墨落谁身上、场合是否要换写法档、有哪些绝对不能写。
|
已有美学纲领(或等价),但游玩正文仍不知道怎么写:遣词什么要求、视角怎么落笔、笔墨落谁身上、场合是否要换写法档、有哪些绝对不能写。
|
||||||
用户主动谈文风、措辞、写法档时也使用。
|
用户主动谈文风、措辞、叙述声音、写法档时也使用。
|
||||||
when_not: |
|
when_not: |
|
||||||
本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。
|
本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。
|
||||||
美学未钉 → 先「美学纲领与交互范式」。
|
美学未钉 → 先「美学纲领与交互范式」。
|
||||||
用户明确说本局不用文风细则,且美学里已有可执行遣词方向 → 可不使用。
|
用户明确说本局不用文风细则,且美学里已有可执行遣词方向 → 可不使用。
|
||||||
只谈推进、输入用法 → 「故事推进」。版式 → 「正文组成」。字数与可见区域 → 「回复呈现」。
|
只谈推进、输入用法 → 「故事推进」。单轮字数与篇幅 → 「故事推进」;可见区域 → 「回复呈现」。版式 → 「正文组成」。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力只交怎么写:选用文风、遣词要求、笔墨落点、少量绝对禁忌、可选写法档。
|
本能力只交怎么写:选用文风、遣词要求(含叙述声音、视角执行、段落形态)、笔墨落点与代入取向、少量绝对禁忌、可选写法档。
|
||||||
推进与输入用法归故事推进;体验契约归美学纲领。
|
推进、输入用法、单轮字数与篇幅归故事推进;体验契约、人称与叙事视角的定义归美学纲领与交互范式。
|
||||||
feeds: narrator,gm
|
feeds: narrator,gm
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -31,9 +31,9 @@ feeds: narrator,gm
|
|||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
把本局写法整理为叙事指南,写入 `设计.叙事指南`。先用 `read_library_entry` 从绑定的文风库读取最相关条目(默认 1~2;确需第三种时须写 reason 并标明适用场合),再按本局体验本局化,写成可执行的风格推荐与要求。
|
把本局写法整理为叙事指南,写入 `设计.叙事指南`。先继承上游「美学纲领与交互范式」:把 `美学纲领.体验内核`、`呈现要点.描写焦点` 与 `呈现要点.边界与尺度` 改写成做法向句子;`交互范式` 已定的人称与叙事视角不重定义,只在 `遣词.视角` 写怎么执行。再用 `read_library_entry` 从绑定的文风库读取最相关条目(默认 1~2;确需第三种时须写 reason 并标明适用场合),按本局体验本局化,写成可执行的风格推荐与要求。
|
||||||
|
|
||||||
产物回答:选了哪些文风、各自用在什么场合;遣词与笔墨要怎么做;只有少数绝对禁忌。除禁忌外,少写“不要怎样”,多写“要怎样”。不写示范段落,不写推进与输入用法。
|
产物回答:选了哪些文风、各自用在什么场合;遣词、视角执行、段落形态与笔墨要怎么做;叙述声音与代入取向(推得出才写);只有少数绝对禁忌。除禁忌外,少写“不要怎样”,多写“要怎样”。不写示范段落,不写推进、输入用法或单轮字数。
|
||||||
|
|
||||||
材料足够便直接成稿;真正会改写法的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
材料足够便直接成稿;真正会改写法的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
||||||
```
|
```
|
||||||
@@ -41,12 +41,16 @@ feeds: narrator,gm
|
|||||||
## principles
|
## principles
|
||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 文风库是参考,不是照抄:读取后改成本局可执行要求,并记下适用场合与微调。
|
1. 美学写要什么感受,本步写怎么遣词、笔墨落哪。已在美学写清的感受不重问,直接改写成做法:体验内核与描写焦点落成详写对象和写法要求;边界与尺度按美学给的用法写进写法要求或写法档,只把其中真正碰不得的收进禁忌。
|
||||||
2. 优先 1~2 种文风;确需第三种时,每种都必须有清晰切换场合,禁止无差别堆叠。
|
2. 文风库是参考,不是照抄:读取后改成本局可执行要求,并记下适用场合与微调。
|
||||||
3. 写法用“风格推荐 + 要求”表达:对象、节奏、注意力落点、句段形态都写成要做什么。示范段落不写。
|
3. 优先 1~2 种文风;确需第三种时,每种都必须有清晰切换场合,禁止无差别堆叠。
|
||||||
4. 绝对禁忌只留真正碰不得的少数几条;其余偏好都改成正向做法。
|
4. 写法用“风格推荐 + 要求”表达:对象、节奏、注意力落点、句段形态都写成要做什么。示范段落不写。
|
||||||
5. 写法档是多文风/多调性容器:有切换才写,无切换整项省略。场合名要能指导何时切入,不是具体剧情桥段清单。
|
5. 段落形态默认值:用户未提段落形态时,`遣词.写法要求` 写入「完整长自然段叙述,不要单句成段」;用户另定则按用户写。段落形态归本步,单轮字数与篇幅归故事推进。
|
||||||
6. 不写故事推进、输入用法、字数区间或版式壳。
|
6. 视角只写执行:人称、主要视角、可写内心、不呈现的信息以交互范式为准,本步写落笔做法——镜头距离(贴身 / 中景 / 远观,何时拉近);内心写到哪(只写交互范式允许写内心的一方,其余人物用外显言行与身体反应表现);如何不漂人称(全文固定一种称呼,回忆、转述与内心独白也不换人称)。交互范式未定视角时按美学站位取最贴的写法,并在 gap 写明。
|
||||||
|
7. `叙述者` 写叙述声音:冷眼旁观、调侃、贴身低语等,以及它对人物的态度。`代入取向` 写笔墨替谁看、读者站在谁那边。两项都从美学站位与体验推出;推不出或与视角执行重复时省略。
|
||||||
|
8. 绝对禁忌只留真正碰不得的少数几条;其余偏好都改成正向做法。
|
||||||
|
9. 写法档是多文风/多调性容器:有切换才写,无切换整项省略。场合名要能指导何时切入,不是具体剧情桥段清单。用户报出「当众折辱」「独处一室」这类具体场合时,判断它要哪种写法,升一档写进对应写法档的适用场合与写法要求(感官密度、节奏、对话长短、详略),场合本身不单列;每档写清切入与切回。
|
||||||
|
10. 不写故事推进、输入用法、单轮字数与篇幅(归故事推进)或版式壳。
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -68,19 +72,34 @@ feeds: narrator,gm
|
|||||||
],
|
],
|
||||||
"遣词": {
|
"遣词": {
|
||||||
"整体取向": "本局叙述应呈现的风格方向",
|
"整体取向": "本局叙述应呈现的风格方向",
|
||||||
|
"叙述者": "可选:叙述声音,如冷眼旁观 / 调侃 / 贴身低语,及它对人物的态度;无则省略",
|
||||||
"风格推荐": ["优先采用的节奏、用词层级、句段形态"],
|
"风格推荐": ["优先采用的节奏、用词层级、句段形态"],
|
||||||
"写法要求": ["要写到什么密度、如何处理对话与叙述、注意力放哪"]
|
"写法要求": [
|
||||||
|
"完整长自然段叙述,不要单句成段(用户另定段落形态则改写)",
|
||||||
|
"要写到什么密度、如何处理对话与叙述、注意力放哪"
|
||||||
|
],
|
||||||
|
"视角": {
|
||||||
|
"镜头距离": "如:贴身跟随 user,冲突时拉近到手与眼神,转场时拉远一句带过",
|
||||||
|
"内心": "如:只写 user 的内心;NPC 只写外显言行与身体反应",
|
||||||
|
"人称": "如:全文第二人称「你」,回忆与内心独白不换人称"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"笔墨焦点": {
|
"笔墨焦点": {
|
||||||
|
"代入取向": "可选:笔墨替谁看、读者站在谁那边;无则省略",
|
||||||
"详写": ["应花笔墨的对象与感受"],
|
"详写": ["应花笔墨的对象与感受"],
|
||||||
"可略": ["可一带而过的部分"]
|
"可略": ["可一带而过的部分"]
|
||||||
},
|
},
|
||||||
"禁忌": ["仅少数绝对不能写的内容;无则省略"],
|
"禁忌": ["仅少数绝对不能写的内容;无则省略"],
|
||||||
"写法档": [
|
"写法档": [
|
||||||
{
|
{
|
||||||
"名字": "档名,如展示情色 / 网文爽文",
|
"名字": "展示情色",
|
||||||
"适用场合": "何时切到这一档",
|
"适用场合": "切到身体与权力交换时;用户报的「独处一室」这类场合升到这一档",
|
||||||
"写法要求": "这一档要怎么写、笔墨落哪、如何切回"
|
"写法要求": "感官词加重、节奏放慢、对话变短;切回日常则收"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"名字": "网文爽文",
|
||||||
|
"适用场合": "打脸、反转时;用户报的「当众折辱」之后的反击升到这一档",
|
||||||
|
"写法要求": "可复述对方原话、结果写满、过程可略;反转落定后切回默认文风"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -91,41 +110,53 @@ feeds: narrator,gm
|
|||||||
1. 输出合法 JSON;`选用文风` 默认 1~2 项,最多 3 项,每项必须有适用场合与本局要求。
|
1. 输出合法 JSON;`选用文风` 默认 1~2 项,最多 3 项,每项必须有适用场合与本局要求。
|
||||||
2. 不写示范段落、示例正文或仿写样章。
|
2. 不写示范段落、示例正文或仿写样章。
|
||||||
3. 除 `禁忌` 外,正文以正向要求为主;空话氛围词改成可执行做法。
|
3. 除 `禁忌` 外,正文以正向要求为主;空话氛围词改成可执行做法。
|
||||||
4. `写法档` 无切换时整项省略;有则每档写清场合与写法要求。
|
4. 用户未定段落形态时,`遣词.写法要求` 必含「完整长自然段叙述,不要单句成段」。
|
||||||
5. 不在产物正文写自评、追问、开放问题或推进/输入用法。
|
5. `遣词.视角` 只写交互范式人称与视角的执行,不改人称、不扩大可写内心的范围。`叙述者`、`代入取向` 可选,无则省略。
|
||||||
6. `mount` 固定包含 `narrator` 与 `world-simulator`。
|
6. `写法档` 无切换时整项省略;有则每档写清场合与写法要求;具体场合升档写入,不单列桥段。
|
||||||
|
7. 不在产物正文写自评、追问、开放问题,或推进、输入用法、单轮字数。
|
||||||
|
8. `mount` 固定包含 `narrator` 与 `world-simulator`。
|
||||||
|
|
||||||
## score
|
## score
|
||||||
|
|
||||||
```score
|
```score
|
||||||
遣词可执行:
|
遣词可执行:
|
||||||
- 5:风格推荐与要求具体到能直接落笔,无需再猜味道。
|
- 5:风格推荐与要求具体到能直接落笔,无需再猜味道;视角执行与段落形态写清。
|
||||||
- 3:大方向清楚,但仍有几处停留在氛围概括。
|
- 3:大方向清楚,但仍有几处停留在氛围概括,或视角执行含糊。
|
||||||
- 1:只有题材或情绪标签,无法指导遣词。
|
- 1:只有题材或情绪标签,无法指导遣词。
|
||||||
|
|
||||||
笔墨与禁忌:
|
笔墨与禁忌:
|
||||||
- 5:详略落点清楚;绝对禁忌少而可执行;其余多为正向要求。
|
- 5:详略落点清楚,贴合美学的描写焦点与边界尺度;绝对禁忌少而可执行;其余多为正向要求。
|
||||||
- 3:落点大体有,但禁忌偏多或详略仍含糊。
|
- 3:落点大体有,但禁忌偏多或详略仍含糊。
|
||||||
- 1:几乎不谈笔墨,或堆满否定句却无正向做法。
|
- 1:几乎不谈笔墨,或堆满否定句却无正向做法。
|
||||||
|
|
||||||
文风选用:
|
文风选用:
|
||||||
- 5:1~2(偶发 3)条贴合本局;多档时切换场合清楚,无重复堆叠。
|
- 5:1~2(偶发 3)条贴合本局;多档时切换场合清楚,具体场合已升档写入,无重复堆叠。
|
||||||
- 3:选用相关,但场合区分弱,或有一条不够贴。
|
- 3:选用相关,但场合区分弱,或有一条不够贴。
|
||||||
- 1:未读库硬编,或多种文风无差别并列。
|
- 1:未读库硬编,或多种文风无差别并列。
|
||||||
|
|
||||||
本局化:
|
本局化:
|
||||||
- 5:库内容已改成本局说法与要求,能看出针对本局体验的取舍。
|
- 5:库内容与美学感受已改成本局说法与要求,能看出针对本局体验的取舍。
|
||||||
- 3:有微调,但仍有整段近乎原文搬用。
|
- 3:有微调,但仍有整段近乎原文搬用,或重问了美学已写清的感受。
|
||||||
- 1:基本照抄库条目,或与本局体验脱节。
|
- 1:基本照抄库条目,或与本局体验脱节。
|
||||||
```
|
```
|
||||||
|
|
||||||
## checklist
|
## checklist
|
||||||
|
|
||||||
```checklist
|
```checklist
|
||||||
|
- [ ] 已继承美学的体验、描写焦点与边界尺度,改写成做法?
|
||||||
- [ ] 已按需读取文风库并本局化?
|
- [ ] 已按需读取文风库并本局化?
|
||||||
- [ ] 选用文风 1~2(最多 3),各有适用场合?
|
- [ ] 选用文风 1~2(最多 3),各有适用场合?
|
||||||
- [ ] 遣词是风格推荐与要求,不是示范段落?
|
- [ ] 遣词是风格推荐与要求,不是示范段落?用户未定段落形态时已写长自然段默认?
|
||||||
|
- [ ] 视角只写执行,未改交互范式的人称与可写内心?
|
||||||
|
- [ ] 具体场合已升档写入写法档,未单列桥段?
|
||||||
- [ ] 除少数禁忌外,多为“要怎样”?
|
- [ ] 除少数禁忌外,多为“要怎样”?
|
||||||
- [ ] 未写入推进、输入用法、字数或版式?
|
- [ ] 未写入推进、输入用法、单轮字数或版式?
|
||||||
- [ ] 产物正文不含评分、诊断、追问?
|
- [ ] 产物正文不含评分、诊断、追问?
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## examples
|
||||||
|
|
||||||
|
```examples
|
||||||
|
好:美学的「掌控感」落成详写对方的迟疑与屈从、user 的动作从容简短;视角写「第二人称贴身镜头,只写你的内心,NPC 只写外显」;写法要求含长自然段默认;用户提的「当众折辱」升进「网文爽文」档,写清切入与切回。
|
||||||
|
坏:重新定义人称或扩大可写内心;把「当众折辱」「独处一室」列成桥段清单;写示范段落;在本步写单轮字数或推进方式。
|
||||||
|
```
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
```meta
|
```meta
|
||||||
name: 开场白与开场变量
|
name: 开场白与开场变量
|
||||||
id: opening-setup
|
id: opening-setup
|
||||||
artifact: 设计.开场白与开场变量
|
artifact: 设计.开场白
|
||||||
declaration: >
|
declaration: >
|
||||||
普通楼层创作终节点,且可增殖:每条开场是一个节点,落成 `设计.开场白#场景短码`。
|
普通楼层创作终节点,且可增殖:每条开场是一个节点,落成 `设计.开场白#场景短码`。
|
||||||
默认写 1 条;用户要多个入口时再增殖。每条自带 present.v1、同真相初态与可选用户角色。
|
默认写 1 条;用户要多个入口时再增殖。每条自带 present.v1、同真相初态与这条开场的最少玩家角色事实。
|
||||||
全部保存。游玩时左右切换,选中的一条作为 0 层。
|
全部保存。游玩时左右切换,选中的一条作为 0 层。
|
||||||
when: |
|
when: |
|
||||||
回复呈现、正文组成和其它本局内容能力均已完成,且程序已依次编译信息可见范围、游玩执行方式、上下文排序与运行配置后,需要可开玩的第一屏时使用。
|
回复呈现、正文组成和其它本局内容能力均已完成,且程序已依次编译信息可见范围、游玩执行方式、上下文排序与运行配置后,需要可开玩的第一屏时使用。
|
||||||
@@ -34,22 +34,25 @@ feeds: gm,narrator
|
|||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
读取程序已组装的 `设计.worker集` 和全部最终层产物,写入 `设计.开场白与开场变量`。这是普通楼层创作终节点;不得在本步首次整理执行单元或上下文顺序。
|
读取程序已组装的 `设计.worker集` 和全部最终层产物,把每一张开场写成 `设计.开场白#场景短码`。这是普通楼层创作终节点;不得在本步首次整理执行单元或上下文顺序。
|
||||||
|
|
||||||
默认只生成 1 条开场。用户要多个入口时,每条再增殖一份产物,对话落盘写入 `设计.开场白#场景短码`(如 dorm、gate);改某一条则覆盖同一完整 tag。不要把多条入口收成聊天里的选择题。每条候选各自携带:
|
position 必须带场景短码,例如 `设计.开场白#opening-cycle2`。改某一条就再 insert 同一个完整 tag。裸 `设计.开场白` 和整包 `设计.开场白与开场变量` 会和带短码的卡抢同一条写入,不要使用。
|
||||||
- 稳定 id 与一句区分说明
|
|
||||||
- 可选用户角色名片(不同候选可不同)
|
默认只生成 1 条,仍然要带场景短码。用户要多个入口时,每条再 insert 一个新的短码。不要把多条入口收成聊天里的选择题,也不要把多条塞进同一次 insert。游玩时按这些短码左右切换,选中的一条作为 0 层,并整套换该条正文、初态与角色。
|
||||||
- 与该条叙述同真相的变量初值
|
|
||||||
- 若本局有拓扑:当前位置/焦点与已揭示范围
|
每一张卡携带:
|
||||||
- 符合正文组成数据契约的 `present.v1`(只填模型生成区)
|
- 符合正文组成数据契约的 `present.v1`(只填模型生成区)
|
||||||
|
- 可选一句区分说明
|
||||||
全部候选都保存。每条正文只写在该候选的 `正文` 里,不要再抄一份全文或初值。`默认候选id` 指向第一次进入 0 层的那条;程序按这个 id 读取它的正文和初值。游玩时左右切换,选中的一条作为 0 层,并整套换该条正文、初态与角色。
|
- 这一条开场的玩家角色 `meta.用户角色`(名字 + 简介):不同开场的玩家可以不同,写在各自卡里;程序会同步成同短码的主角设定,游玩切到这条时替换 `@玩家`
|
||||||
|
- 与该条叙述同真相的变量初值:这张卡有要开场定下的变量才写 `开场变量`;没有就不写,卡仍然是 `设计.开场白#场景短码`
|
||||||
|
- 若本局有拓扑:当前位置/焦点与已揭示范围
|
||||||
|
|
||||||
硬规格:
|
硬规格:
|
||||||
1. 每条可见正文至少出现一次字面 `@玩家`;不写死具体姓名顶名字位。
|
1. 可见正文里凡指称玩家角色的名字位都写字面 `@玩家`,每条至少出现一次。「你」可以和 `@玩家` 并用(如「你(@玩家)站在闸机前」),但全文只有「你」、零个 `@玩家` 不合格;不写死具体姓名,也不用「用户」「主角」顶名字位。
|
||||||
2. `present.v1` 的区域只覆盖正文组成声明的模型生成 blocks;程序投影区留空给程序灌。
|
2. `present.v1` 的区域只覆盖正文组成声明的模型生成 blocks;程序投影区留空给程序灌。
|
||||||
3. 不输出 `名称.随机数` 的具体数组;随机行由程序在第一次正式回复前或回复时补齐。
|
3. 不输出 `名称.随机数` 的具体数组;随机行由程序在第一次正式回复前或回复时补齐。
|
||||||
4. 开场宜短,愿点进游玩即可,不抄故事推进的单轮篇幅预算当第一章。
|
4. 开场宜短,愿点进游玩即可,不抄故事推进的单轮篇幅预算当第一章。
|
||||||
|
5. 人物、地点、势力只引用已有产物里的;第一拍需要的路人只写身份,不在开场新造有名有姓的设定。
|
||||||
|
|
||||||
材料足够便直接成稿;会改变第一拍成立的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
材料足够便直接成稿;会改变第一拍成立的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
||||||
```
|
```
|
||||||
@@ -57,12 +60,13 @@ feeds: gm,narrator
|
|||||||
## principles
|
## principles
|
||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 开场为主,初态配套。叙述与该条变量、拓扑初态必须同真相。
|
1. 开场为主,初态配套。叙述与该条变量、拓扑初态必须同真相;写完发现两者对不上时以叙述为准,改初值。
|
||||||
2. 默认一条。多条是不同入口,每条一个可增殖节点,不是同文换皮,也不是先让用户选唯一开场。
|
2. 默认一条。多条是不同入口,每条一个 `设计.开场白#场景短码`,不是同文换皮,也不是先让用户选唯一开场。没有变量的卡省略 `开场变量`,短码照旧。
|
||||||
3. 用户角色可按候选不同;没有名片时仍用 `@玩家` 占位,进游玩再替换。
|
3. 玩家角色最少化:把这个角色交还给用户扮演。`用户角色.简介` 只写这条开场成立所必需的客观事实——身份、处境、与在场人物的已知关系,一到三句;不写性格、心理、动机、口癖、外貌细节,也不替玩家定态度或下一步。共通的主角设定已有的事实不重抄,只写这条开场独有或与它不同的部分。
|
||||||
4. 只写模型生成区。状态芯片、地图等权威显示由程序投影,不在开场正文里重编一份。
|
4. 用户没给名字时不编名字,省略 `用户角色`,正文照用 `@玩家`;这条开场必需的身份事实写进叙述里让玩家看见。
|
||||||
5. 不剧透隐藏档,不写成第一章,不在本步改前端模板。
|
5. 只写模型生成区。状态芯片、地图等权威显示由程序投影,不在开场正文里重编一份。
|
||||||
6. 本步结束后创作收口;不要再排后续内容能力。
|
6. 文风、视角和人称跟叙事指南与交互范式;不剧透隐藏档,不写成第一章,不在本步改前端模板。
|
||||||
|
7. 本步结束后创作收口;不要再排后续内容能力。
|
||||||
```
|
```
|
||||||
|
|
||||||
## probe
|
## probe
|
||||||
@@ -74,62 +78,48 @@ feeds: gm,narrator
|
|||||||
## output
|
## output
|
||||||
|
|
||||||
```output
|
```output
|
||||||
|
position: 设计.开场白#场景短码
|
||||||
|
content:
|
||||||
{
|
{
|
||||||
"schema": "context-fragment.v1",
|
"schema": "present.v1",
|
||||||
"技能": "开场白与开场变量",
|
"shell": "与正文组成一致",
|
||||||
"brief": "一句话:默认开场场景 + 有几条候选",
|
"blocks": {
|
||||||
"mount": ["world-simulator", "narrator"],
|
"body": "清晨,@玩家推开门。……",
|
||||||
"稳变": "stable",
|
"其余模型生成区": "read_module 回包 reply_contract.model_blocks 里的每一项都要出现,类型按契约"
|
||||||
"正文": {
|
},
|
||||||
"默认候选id": "opening-1",
|
"meta": {
|
||||||
"开场白候选": [
|
"区分": "多条时写这一入口和其它条差在哪;仅一条可省略",
|
||||||
|
"用户角色": {
|
||||||
|
"名字": "用户给过的名字;没给就省略整个用户角色",
|
||||||
|
"简介": "一到三句客观事实:这条开场里玩家是谁、处境如何、与在场人物的已知关系"
|
||||||
|
},
|
||||||
|
"开场变量": [
|
||||||
{
|
{
|
||||||
"id": "opening-1",
|
"名": "与变量设计真值名一致",
|
||||||
"区分": "这条入口与其它条差在哪;仅一条时可写「默认」",
|
"值": "初值",
|
||||||
"meta": {
|
"依据": "开场事实"
|
||||||
"用户角色": {
|
|
||||||
"名字": "可选;无则省略整段",
|
|
||||||
"简介": "可选"
|
|
||||||
},
|
|
||||||
"开场变量": [
|
|
||||||
{
|
|
||||||
"名": "与变量设计真值名一致",
|
|
||||||
"值": "初值",
|
|
||||||
"依据": "开场事实"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"拓扑初态": [
|
|
||||||
{
|
|
||||||
"topology_id": "有拓扑时填写",
|
|
||||||
"当前位置": "节点 id",
|
|
||||||
"已揭示": ["节点或连接 id"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"正文": {
|
|
||||||
"schema": "present.v1",
|
|
||||||
"blocks": {
|
|
||||||
"body": "清晨,@玩家推开门。……"
|
|
||||||
},
|
|
||||||
"meta": {
|
|
||||||
"suggested_actions": ["若正文组成启用文字选项则给短建议;否则 []"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"用户可行动空间": "开场结束后玩家显然能尝试什么"
|
"拓扑初态": [
|
||||||
|
{
|
||||||
|
"topology_id": "有拓扑时填写",
|
||||||
|
"当前位置": "节点 id",
|
||||||
|
"已揭示": ["节点或连接 id"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"suggested_actions": ["若正文组成启用文字选项则给短建议;否则 []"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON;`开场白候选` 默认 1 条,仅用户明确要求时 2~3 条。
|
1. 每次 insert 只写一张卡。position 是 `设计.开场白#场景短码`,content 是这一张的 `present.v1`。
|
||||||
2. `默认候选id` 必须指向候选之一。正文和初值只写在该候选上,不另抄全文或开场变量。
|
2. 这张卡有初值才写 `meta.开场变量`;没有变量就省略该字段,position 仍带场景短码。
|
||||||
3. 每条 `正文` 必须是 `present.v1`,只含正文组成允许模型生成的 blocks;每条可见正文须含字面 `@玩家`。
|
3. `present.v1` 只含正文组成允许模型生成的 blocks;可见正文须含字面 `@玩家`。
|
||||||
4. 不同候选可有不同用户角色与初态;切换时整套替换,不混用。
|
4. 不同卡可有不同用户角色与初态;切换时整套替换,不混用。
|
||||||
5. 不输出随机数具体值,不重写前端,不把程序投影区填成模型编造状态。
|
5. 不输出随机数具体值,不重写前端,不把程序投影区填成模型编造状态。
|
||||||
6. 产物正文不含自评、追问或开放问题。
|
6. 产物正文不含自评、追问或开放问题。
|
||||||
7. `mount` 固定为 `["world-simulator", "narrator"]`。
|
7. 玩家角色只写在 `meta.用户角色`,名字不进 blocks 正文;程序据此写入同短码的 `设计.主角设定#场景短码`,不要另 insert 一份。
|
||||||
|
```
|
||||||
|
|
||||||
## score
|
## score
|
||||||
|
|
||||||
@@ -150,9 +140,9 @@ feeds: gm,narrator
|
|||||||
- 1:叙述与初值冲突,或多条共用一套却声称不同入口。
|
- 1:叙述与初值冲突,或多条共用一套却声称不同入口。
|
||||||
|
|
||||||
角色占位:
|
角色占位:
|
||||||
- 5:每条含字面 `@玩家`;有名片时不把姓名写进正文名字位。
|
- 5:每条含字面 `@玩家`;`用户角色.简介` 只有这条开场必需的客观事实,玩家性格与决定留给用户。
|
||||||
- 3:占位大体正确,有一条名片与正文关系略乱。
|
- 3:占位正确,但简介多写了性格、动机或与共通主角设定重复的内容。
|
||||||
- 1:写死姓名,或全文没有 `@玩家`。
|
- 1:写死姓名、全文只有「你」没有 `@玩家`,或简介替玩家定了性格和态度。
|
||||||
|
|
||||||
收口可切换:
|
收口可切换:
|
||||||
- 5:默认条明确,全部候选可保存;多条时入口差异成立。
|
- 5:默认条明确,全部候选可保存;多条时入口差异成立。
|
||||||
@@ -163,17 +153,22 @@ feeds: gm,narrator
|
|||||||
## checklist
|
## checklist
|
||||||
|
|
||||||
```checklist
|
```checklist
|
||||||
- [ ] 默认 1 条;仅用户要求时才有 2~3 条?
|
- [ ] position 是 `设计.开场白#场景短码`,没有另写裸 `设计.开场白` 或 `设计.开场白与开场变量`?
|
||||||
- [ ] `默认候选id` 指向一条候选,且该条正文、初值没有在外面再抄一份?
|
- [ ] 默认 1 条;仅用户要求时才再增殖别的短码?
|
||||||
- [ ] 每条含 `@玩家`,present.v1 只含模型生成区?
|
- [ ] 有初值才写 `开场变量`;没有变量就省略,卡名不变?
|
||||||
- [ ] 各候选初态自洽,未填写随机数具体值?
|
- [ ] 含 `@玩家`,present.v1 只含模型生成区?
|
||||||
|
- [ ] `用户角色.简介` 只有必需客观事实,没有性格、心理和替玩家做的决定?
|
||||||
|
- [ ] 初态与这条叙述同真相,未填写随机数具体值?
|
||||||
- [ ] 未重开前端、篇幅或区域清单?
|
- [ ] 未重开前端、篇幅或区域清单?
|
||||||
```
|
```
|
||||||
|
|
||||||
## examples
|
## examples
|
||||||
|
|
||||||
```examples
|
```examples
|
||||||
- 单条:堂屋债契,body 含 `@玩家`,开场变量写债额初值;状态栏由程序投影。
|
- 单条:`设计.开场白#debt-hall`,堂屋债契,body 含 `@玩家`,开场变量写债额初值;状态栏由程序投影。
|
||||||
- 用户要两条入口:一条放贷人名片、一条社畜穿越名片;各带自己的初态,默认指定其中一条开玩。
|
- 用户要两条入口:`设计.开场白#lender` 与 `设计.开场白#office`,各带自己的初态。没有初值的那条省略开场变量。
|
||||||
- 不合适:开场里填写 `敏捷.随机数=[…]`;或把地图权威数据写成模型散文却不给拓扑初态。
|
- 单条带玩家角色:`meta.用户角色` 为 `{"名字":"沈聿","简介":"本地放贷人,持有这家的债契;与这对母女相识但不熟。"}`,body 写「清晨,@玩家推开堂屋门,债契还摊在桌上。」
|
||||||
|
- 不合适:再写一份裸 `设计.开场白` 或 `设计.开场白与开场变量`;开场里填写 `敏捷.随机数=[…]`。
|
||||||
|
- 不合适:全文只有「你推开门」,零个 `@玩家`。
|
||||||
|
- 不合适:简介写「沉稳果决,内心渴望被认可,决定今天收回债款」——性格、心理和决定都该留给玩家。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -37,11 +37,14 @@ feeds: gm
|
|||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 只设计用户已明确给出、核心体验依赖或运行确实需要的内容。
|
1. 只设计用户已明确给出、核心体验依赖或运行确实需要的内容。
|
||||||
2. “首富之子”“唯我独法”等若是核心体验成立条件,属于必要设定,不算过度设计。
|
2. 一张脸测试:逐句问「删掉这句,玩家还知道自己是谁吗?」删了仍知道的句子不落盘。
|
||||||
3. 特殊能力写“能做什么 + 限制”;世界为何如此由实现机制处理。
|
3. “首富之子”“唯我独法”等若是核心体验成立条件,属于必要设定,不算过度设计。
|
||||||
4. `称呼` 可写固定名字,也可写 `@玩家` 表示刻意留白;不要因无名阻塞成卡。
|
4. 特殊能力写“能做什么 + 限制”;世界为何如此由实现机制处理。
|
||||||
5. 行为依据随推进权增减:用户控制越多越简;LLM 代演越多越具体。
|
5. `称呼` 可写固定名字,也可写 `@玩家` 表示刻意留白;不要因无名阻塞成卡。
|
||||||
6. 不写 NPC 名片、世界百科、文风规则或开场事件。
|
6. 身份与背景一两句;用户没给就写「未定」或省略,不代编来历。
|
||||||
|
7. 行为依据随推进权增减:用户控制越多越简;LLM 代演越多越具体。
|
||||||
|
8. 本能力只写各开场共通的玩家角色。不同开场白里玩家身份、处境不同的部分,由开场白写在自己的 `meta.用户角色`,程序会同步成 `设计.主角设定#场景短码`;本能力不为各开场另写 `设计.主角设定#短码`。
|
||||||
|
9. 不写 NPC 名片、世界百科、文风规则或开场事件。
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -101,3 +104,12 @@ feeds: gm
|
|||||||
- [ ] 称呼允许固定名字或 `@玩家` 留白?
|
- [ ] 称呼允许固定名字或 `@玩家` 留白?
|
||||||
- [ ] 未写 NPC、世界百科、文风或开场事件?
|
- [ ] 未写 NPC、世界百科、文风或开场事件?
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## examples
|
||||||
|
|
||||||
|
```examples
|
||||||
|
- 好:称呼「林晚」;身份与背景一句穿来当日的处境;特殊设定「能看见别人对她的好感数字」,限制「别人看不见」。
|
||||||
|
- 坏:把侯府地理、朝堂势力写进背景。
|
||||||
|
- 坏:把开场羞辱戏全文写进名片。
|
||||||
|
- 坏:为宿舍开场和天台开场各写一张 `设计.主角设定#短码`;各开场不同的处境应写进对应开场白的 `meta.用户角色`。
|
||||||
|
```
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ feeds: gm
|
|||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
写入 `设计.随机范围整理`。每项是 `变量.当前` 的一行,行名写成「名称.随机数」,玩家默认不可见。值是剩余数字组成的数组,下一个可用的是第一个。
|
先扫实现机制、生成规则、变量设计、叙事与故事推进、美学,列出所有需要随机数才能裁定的点,再写入 `设计.随机范围整理`。每项是 `变量.当前` 的一行,行名写成「名称.随机数」,玩家默认不可见。值是剩余数字组成的数组,下一个可用的是第一个。
|
||||||
|
|
||||||
用掉第一个后,程序改写这一行,去掉那个数。改完后如果只剩 1 个,就按范围补到预备个数,不要等这一行空了。模型不自己编数字。
|
用掉第一个后,程序改写这一行,去掉那个数。改完后如果只剩 1 个,就按范围补到预备个数,不要等这一行空了。模型不自己编数字。
|
||||||
|
|
||||||
@@ -41,9 +41,10 @@ feeds: gm
|
|||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 只登记上游已经成立的掷法,不新写法则。
|
1. 只登记上游已经成立的掷法,不新写法则。
|
||||||
2. 一行一项,行名是「名称.随机数」,例如 `敏捷.随机数`。
|
2. 一行一项,一项只管一类用途,行名是「名称.随机数」,例如 `敏捷.随机数`。不要把「战斗」糊成一行却含命中、伤害、闪避几种掷法,拆成 `命中.随机数`、`伤害.随机数`、`闪避.随机数`。
|
||||||
3. 和别的隐藏变量一样存在现有表里:能改、有版本;不对玩家默认展示。
|
3. 范围写成人与程序都能读的形式,如 `1~100`、`力量*5~10`,并注明单位(整数、闭区间等);不写「中等偏高」这类散文。
|
||||||
4. 预备个数够连用即可,少了靠「只剩 1 个就补」,不要一次放很多。
|
4. 和别的隐藏变量一样存在现有表里:能改、有版本;不对玩家默认展示。
|
||||||
|
5. 预备个数够连用即可,少了靠「只剩 1 个就补」,不要一次放很多。
|
||||||
```
|
```
|
||||||
|
|
||||||
## probe
|
## probe
|
||||||
@@ -68,6 +69,7 @@ feeds: gm
|
|||||||
"用途": "敏捷检定",
|
"用途": "敏捷检定",
|
||||||
"范围": "1~100",
|
"范围": "1~100",
|
||||||
"表达式": "无表达式时省略",
|
"表达式": "无表达式时省略",
|
||||||
|
"单位": "整数,闭区间",
|
||||||
"依赖真值": [],
|
"依赖真值": [],
|
||||||
"预备个数": 3,
|
"预备个数": 3,
|
||||||
"来源": "上游短引用"
|
"来源": "上游短引用"
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ when_not: |
|
|||||||
boundary: |
|
boundary: |
|
||||||
回复呈现决定“有什么、何时出现、来自哪里”;本能力决定“在前端哪里、如何渲染”。
|
回复呈现决定“有什么、何时出现、来自哪里”;本能力决定“在前端哪里、如何渲染”。
|
||||||
状态和拓扑数据由程序投影,本能力只写绑定与渲染器;不得让正文模型重写权威数据。
|
状态和拓扑数据由程序投影,本能力只写绑定与渲染器;不得让正文模型重写权威数据。
|
||||||
隐藏维护区不进 DOM。随机数是变量表隐藏行,也与本能力无关。
|
隐藏维护区不进 DOM;表由旁观副 LLM 维护,主回复不建填表通道。
|
||||||
|
随机数是变量表隐藏行,也与本能力无关。
|
||||||
壳参考库只提供布局与交互方法参考,不是必须照抄的固定产品模板。
|
壳参考库只提供布局与交互方法参考,不是必须照抄的固定产品模板。
|
||||||
feeds: gm,narrator
|
feeds: gm,narrator
|
||||||
```
|
```
|
||||||
@@ -35,11 +36,11 @@ feeds: gm,narrator
|
|||||||
为普通楼层生成一份可复用前端,写入 `设计.正文组成`。每条助手消息仍各自形成一个气泡;程序把当轮 `present.v1` 灌入同一模板。
|
为普通楼层生成一份可复用前端,写入 `设计.正文组成`。每条助手消息仍各自形成一个气泡;程序把当轮 `present.v1` 灌入同一模板。
|
||||||
|
|
||||||
执行顺序:
|
执行顺序:
|
||||||
1. 读取回复呈现,只处理其中实际启用的语义区域。
|
1. 读取回复呈现,只处理其中实际启用的语义区域。同时读美学纲领的呈现要点与叙事指南,作为配色、字体层级和纹样的依据;不照搬参考壳的样子。
|
||||||
2. 从【壳参考库】选一只与本局已启用区域最接近的壳。按该壳备注改写样本里的名称、栏目、配色和内部小节。本局没有的区从样本骨架删除并重排,不留空位。
|
2. 从【壳参考库】选一只与本局已启用区域最接近的壳。按该壳备注改写样本里的名称、栏目、配色和内部小节。本局没有的区从样本骨架删除并重排,不留空位。
|
||||||
3. 为每个用户可见区域定义 block、位置、显示名和渲染方式。模型生成区读取对应 block;程序投影区直接绑定权威数据。
|
3. 为每个用户可见区域定义 block、位置、显示名和渲染方式。模型生成区读取对应 block;程序投影区直接绑定权威数据。
|
||||||
4. 隐藏 `state_ops` 等维护区列入非渲染处理,不创建 DOM 节点。
|
4. 隐藏 `state_ops` 等维护区列入非渲染处理,不创建 DOM 节点。
|
||||||
5. 写完整 HTML/CSS/JS,创作时一次生成并固定;游玩只灌数据,不再重写前端。美化用配色、字体层级、边框、间距,以及 CSS 或简单内联 SVG,按本次能写稳的程度完成。JS 通过 `present.onData(render)` 接收数据;地图所需的 SVG 从拓扑数据绘制,游玩模型不输出 SVG。
|
5. 写完整 HTML/CSS/JS,创作时一次生成并固定;游玩只灌数据,不再重写前端。每个可见区放一个空节点,`data-block` 等于数据契约里的 id。程序按契约把当轮内容填进这些节点;JS 只做拓扑图和本地切换,通过 `present.onData(cb)` 拿当轮数据;不要用 `textContent` 写 markdown/text 正文。根容器(如 `#app`)`width: 100%`,铺满宿主气泡/iframe,禁止整壳 `max-width` + `margin: 0 auto` 收成居中卡片;若要控阅读行长,只给正文柱设 `max-width`(可 `margin-inline: auto`)。气泡随内容增高,禁止 `100vh` 和整页或侧栏滚动条,滚动只留在外层对话。美化用配色、字体层级、边框、间距,以及 CSS 或简单内联 SVG,按本次能写稳的程度完成。地图所需的 SVG 从拓扑数据绘制,游玩模型不输出 SVG。
|
||||||
6. 给一份非正史 `present.v1` 预览数据,覆盖所有可见区域与空态。
|
6. 给一份非正史 `present.v1` 预览数据,覆盖所有可见区域与空态。
|
||||||
|
|
||||||
模板总量不超过 60 KB。材料足够便直接成稿;会改变阅读结构的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
模板总量不超过 60 KB。材料足够便直接成稿;会改变阅读结构的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
||||||
@@ -51,12 +52,14 @@ feeds: gm,narrator
|
|||||||
1. 语义区域必须全部有去处;未启用区域不补齐,隐藏维护区不渲染。
|
1. 语义区域必须全部有去处;未启用区域不补齐,隐藏维护区不渲染。
|
||||||
2. 正文是主阅读面。辅助状态、线索、地图和文字选项按用途确定层级,不平均分割注意力。
|
2. 正文是主阅读面。辅助状态、线索、地图和文字选项按用途确定层级,不平均分割注意力。
|
||||||
3. 程序投影区只消费权威 block:变量、拓扑和程序结果不要求模型重复填写。
|
3. 程序投影区只消费权威 block:变量、拓扑和程序结果不要求模型重复填写。
|
||||||
4. HTML 只放稳定骨架;数据由 `present.onData` 注入。缺 block 时按合同省略或显示空态。
|
4. HTML 只放稳定骨架,每个可见区一个 `data-block` 空节点。程序按数据契约灌数;`present.onData` 只留给拓扑和本地交互。markdown/text 正文禁止用 `textContent` 写入(会挤掉换行并露出 `**`);若本地必须写正文,用 `present.markdown` 再赋 `innerHTML`。缺 block 时按合同省略或显示空态。不要把气泡做成独立窗口。
|
||||||
5. Tab、折叠、缩放等纯本地交互可以使用;不得联网、提交表单、弹窗、顶层导航或向宿主发送用户行动。
|
5. 外壳铺满宿主可用宽度(`width: 100%`、背景与顶栏跟宽走)。行长只限正文柱,不限整壳;缺则按宿主全宽排,不另造居中窄卡片。
|
||||||
6. Markdown 使用宿主提供的安全渲染能力;动态文字使用安全 API 或转义后写入,不能拼接未清洗 HTML。
|
6. Tab、折叠、缩放等纯本地交互可以使用;不得联网、提交表单、弹窗、顶层导航或向宿主发送用户行动。
|
||||||
7. 地图/关系图由 JS 根据结构化拓扑绘制;显示坐标只属于布局,不回写世界真相。
|
7. Markdown 使用宿主提供的安全渲染能力;动态文字使用安全 API 或转义后写入,不能拼接未清洗 HTML。
|
||||||
8. 预览只用于验收前端,不写成开场白或正史。
|
8. 地图/关系图由 JS 根据结构化拓扑绘制;显示坐标只属于布局,不回写世界真相。
|
||||||
9. 美化落在配色、字体层级、边框、间距和简单内联 SVG 上,写进 CSS,按本次能写稳的程度完成。
|
9. 预览只用于验收前端,不写成开场白或正史。示例灌数不为好看新造人名、地名、势力,用已有产物里的;文笔及格即可。
|
||||||
|
10. 美化落在配色、字体层级、边框、间距和简单内联 SVG 上,写进 CSS,按本次能写稳的程度完成。
|
||||||
|
11. 改稿以最新产物为底。用户只提美化时只改样式,`数据契约` 和 `区域落点` 保持不变;要换阅读结构必须用户明确提出。
|
||||||
```
|
```
|
||||||
|
|
||||||
## probe
|
## probe
|
||||||
@@ -121,8 +124,8 @@ feeds: gm,narrator
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"html": "<div id=\"app\">完整稳定骨架</div>",
|
"html": "<div id=\"app\"><article data-block=\"body\"></article><section data-block=\"本局其它可见区域 id\"></section></div>",
|
||||||
"css": "完整样式;正文优先;含窄屏规则",
|
"css": "完整样式;根容器 width:100%;正文优先;行长只限正文柱;含窄屏规则",
|
||||||
"js": "使用 present.onData(render) 的完整渲染代码"
|
"js": "使用 present.onData(render) 的完整渲染代码"
|
||||||
},
|
},
|
||||||
"示例灌数": {
|
"示例灌数": {
|
||||||
@@ -139,7 +142,7 @@ feeds: gm,narrator
|
|||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON;`数据契约.blocks` 必须覆盖回复呈现的全部用户可见区域,id 保持一致。
|
1. 输出合法 JSON;`数据契约.blocks` 必须覆盖回复呈现的全部用户可见区域,id 保持一致。
|
||||||
2. 程序投影区不得改成模型生成;隐藏维护区只能进入 `不渲染`,不得出现在 DOM 或预览 blocks。
|
2. 程序投影区不得改成模型生成;隐藏维护区只能进入 `不渲染`,不得出现在 DOM 或预览 blocks。
|
||||||
3. `frontend.html/css/js` 均须给出可执行内容;合计不超过 60 KB。JS 必须调用 `present.onData(render)`。
|
3. `frontend.html/css/js` 均须给出可执行内容;合计不超过 60 KB。每个可见 block 对应一个 `data-block` 空节点,id 与契约一致。JS 可以调用 `present.onData` 画拓扑或做本地切换,但文本区由程序填充。根容器铺满宿主宽度;不要整壳 `max-width` 居中卡片;不要 `100vh`,不要整页或侧栏滚动条。
|
||||||
4. HTML 不嵌入本轮数据;动态文字安全渲染。禁止网络、表单、弹窗、顶层导航和宿主行动回传。
|
4. HTML 不嵌入本轮数据;动态文字安全渲染。禁止网络、表单、弹窗、顶层导航和宿主行动回传。
|
||||||
5. topology 渲染读取结构化节点与连接;不得要求游玩模型输出 SVG。
|
5. topology 渲染读取结构化节点与连接;不得要求游玩模型输出 SVG。
|
||||||
6. `示例灌数` 是非正史预览,schema 固定 `present.v1`,只含用户可见 blocks。
|
6. `示例灌数` 是非正史预览,schema 固定 `present.v1`,只含用户可见 blocks。
|
||||||
@@ -156,9 +159,9 @@ feeds: gm,narrator
|
|||||||
- 1:漏区,或擅自添加回复呈现没有的内容。
|
- 1:漏区,或擅自添加回复呈现没有的内容。
|
||||||
|
|
||||||
阅读结构:
|
阅读结构:
|
||||||
- 5:正文始终是主阅读面,辅助信息层级与响应式布局服务本局体验。
|
- 5:正文始终是主阅读面;外壳铺满宿主宽度;辅助层级与响应式服务本局体验。
|
||||||
- 3:总体可读,但某个并列区略抢正文或窄屏处理不足。
|
- 3:总体可读,但并列区略抢正文、窄屏不足,或整壳被限宽成居中卡片。
|
||||||
- 1:区域平均堆叠,阅读焦点混乱。
|
- 1:区域平均堆叠,阅读焦点混乱,或外壳明显窄于宿主。
|
||||||
|
|
||||||
权威绑定:
|
权威绑定:
|
||||||
- 5:变量、拓扑和程序结果只读权威投影,模型生成区边界清楚。
|
- 5:变量、拓扑和程序结果只读权威投影,模型生成区边界清楚。
|
||||||
@@ -182,7 +185,7 @@ feeds: gm,narrator
|
|||||||
- [ ] 回复呈现的全部可见区域均有 block、落点与渲染方式?
|
- [ ] 回复呈现的全部可见区域均有 block、落点与渲染方式?
|
||||||
- [ ] 隐藏维护区未进入 DOM 或预览?
|
- [ ] 隐藏维护区未进入 DOM 或预览?
|
||||||
- [ ] 程序投影的变量和拓扑没有要求模型重写?
|
- [ ] 程序投影的变量和拓扑没有要求模型重写?
|
||||||
- [ ] HTML/CSS/JS 完整,JS 使用 present.onData(render),总量不超 60 KB?
|
- [ ] HTML/CSS/JS 完整,每个可见区有 `data-block` 空节点,总量不超 60 KB,根容器铺满宿主宽度,正文不用 textContent 灌 markdown,没有整壳居中窄卡片或独立滚动窗口?
|
||||||
- [ ] 美化已用配色、字体层级、边框、间距或简单 SVG 写进 CSS,程度限于本次能写稳的部分?
|
- [ ] 美化已用配色、字体层级、边框、间距或简单 SVG 写进 CSS,程度限于本次能写稳的部分?
|
||||||
- [ ] 本地交互不联网、不回传行动,动态内容安全渲染?
|
- [ ] 本地交互不联网、不回传行动,动态内容安全渲染?
|
||||||
- [ ] present.v1 预览覆盖可见区域并明确非正史?
|
- [ ] present.v1 预览覆盖可见区域并明确非正史?
|
||||||
@@ -196,5 +199,5 @@ feeds: gm,narrator
|
|||||||
- 正文 + 地图:JS 根据 topology block 绘制 SVG;节点名称安全转义,窄屏可切到列表降级。
|
- 正文 + 地图:JS 根据 topology block 绘制 SVG;节点名称安全转义,窄屏可切到列表降级。
|
||||||
- 多个辅助区:确有必要时使用本地 Tab;切换只改变当前气泡显示,不向宿主发送消息。
|
- 多个辅助区:确有必要时使用本地 Tab;切换只改变当前气泡显示,不向宿主发送消息。
|
||||||
- 武侠阅读:纸色、墨色、细线框和一条简单 SVG 纹样;只保留本局启用的区。
|
- 武侠阅读:纸色、墨色、细线框和一条简单 SVG 纹样;只保留本局启用的区。
|
||||||
- 不合适:把隐藏 state_ops 做成折叠面板;只写“机械风界面”却不给代码;让模型每轮输出地图 SVG。
|
- 不合适:把隐藏 state_ops 做成折叠面板;只用 textContent 写正文导致不分段、露出 `**`;只写“机械风界面”却不给代码;让模型每轮输出地图 SVG。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ feeds: gm,narrator
|
|||||||
- 对应的上游引用与正向内容要求。
|
- 对应的上游引用与正向内容要求。
|
||||||
- 没有内容时是省略、给空态,还是仍须输出。
|
- 没有内容时是省略、给空态,还是仍须输出。
|
||||||
|
|
||||||
正文区域只引用 `设计.故事推进` 的单轮篇幅与扩写/续写预算,不复制数值。状态、地图等权威内容使用程序投影,正文模型不重复生成。选项若启用,只定义文字建议的组成规则,不声明点击交互。
|
正文区域只引用 `设计.故事推进` 的单轮篇幅与扩写/续写预算,不复制数值。状态、地图等权威内容使用程序投影,正文模型不重复生成。选项若启用,写清数量、各项的差异维度(如冒险/稳重)和每项的组成规则,让选项彼此不同;不声明点击交互。
|
||||||
|
|
||||||
简单变量采用 `inline_tool / inline_structured` 内联维护时,回复合同包含用户不可见的变量更新语义区;程序优先把它投递为 tool call,工具不可用时改为结构化 `state_ops`。`auditor_bundle` 由副 Worker 维护,不建立主回复更新区。
|
表维护由旁观副 LLM 固定承担;本步**不**建立主回复里的隐藏变量更新区(无 `state_ops` / maintenance_channel)。可见区只投影权威数据。
|
||||||
|
|
||||||
材料足够便直接成稿;真正会改变区域合同的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
材料足够便直接成稿;真正会改变区域合同的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
||||||
```
|
```
|
||||||
@@ -54,10 +54,10 @@ feeds: gm,narrator
|
|||||||
3. 模型生成区写内容要求;程序投影区写权威 ref 与字段选择。程序已有的数据不让模型复述一份。
|
3. 模型生成区写内容要求;程序投影区写权威 ref 与字段选择。程序已有的数据不让模型复述一份。
|
||||||
4. 正文篇幅、扩写和续写预算只引用故事推进;文风只引用叙事指南。
|
4. 正文篇幅、扩写和续写预算只引用故事推进;文风只引用叙事指南。
|
||||||
5. 状态与拓扑的真相来自运行数据。可见区域只选择投影字段,不在这里修改变量或拓扑。
|
5. 状态与拓扑的真相来自运行数据。可见区域只选择投影字段,不在这里修改变量或拓扑。
|
||||||
6. 简单变量的隐藏更新也是语义区域;运行期可投递为 tool call 或结构化 `state_ops`。副 Worker 路径不在主回复伪造空区域。
|
6. 不在主回复建隐藏填表区;表由旁观副 LLM 维护。
|
||||||
7. 选项是文本建议。是否有交互、点击后做什么,只有正文组成和宿主前端真正实现时才声明。
|
7. 选项是文本建议。是否有交互、点击后做什么,只有正文组成和宿主前端真正实现时才声明。
|
||||||
8. 区域要求使用正向规格;没有特殊要求便保持短,不为完整感堆区域。
|
8. 区域要求使用正向规格;没有特殊要求便保持短,不为完整感堆区域。
|
||||||
9. 每个可见区属于一种:抬头、正文、小故事、数据卡、结构、文字选项。抬头是页首一行短内容;条目一多或当前值一长则改用数据卡。数据卡和结构要写出露出的列或字段。隐藏的变量更新不占这些区。
|
9. 每个可见区属于一种:抬头、正文、小故事、数据卡、结构、文字选项。抬头是页首一行短内容;小故事是旁支短文,不推进主线,也不复述正文;条目一多或当前值一长则改用数据卡。数据卡和结构要写出露出的列或字段。
|
||||||
```
|
```
|
||||||
|
|
||||||
## probe
|
## probe
|
||||||
@@ -119,6 +119,33 @@ feeds: gm,narrator
|
|||||||
"正向说明该区域应该提供什么"
|
"正向说明该区域应该提供什么"
|
||||||
],
|
],
|
||||||
"空值处理": "omit|empty_state|error"
|
"空值处理": "omit|empty_state|error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "choices",
|
||||||
|
"名称": "文字选项",
|
||||||
|
"用途": "给用户几条方向不同的行动参考",
|
||||||
|
"用户可见": true,
|
||||||
|
"出现": {
|
||||||
|
"方式": "every_turn",
|
||||||
|
"条件": ""
|
||||||
|
},
|
||||||
|
"内容类型": "list",
|
||||||
|
"区域种类": "文字选项",
|
||||||
|
"来源": {
|
||||||
|
"方式": "model_generated",
|
||||||
|
"ref": "设计.故事推进"
|
||||||
|
},
|
||||||
|
"选项": {
|
||||||
|
"数量": 2,
|
||||||
|
"各项": [
|
||||||
|
{ "差异维度": "冒险", "组成": "一句推进风险或探索的具体行动" },
|
||||||
|
{ "差异维度": "稳重", "组成": "一句观察、保守或缓和局面的具体行动" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"内容要求": [
|
||||||
|
"每项是用户角色此刻能直接采取的一句行动,按各自差异维度写,彼此不重复"
|
||||||
|
],
|
||||||
|
"空值处理": "omit"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -130,8 +157,8 @@ feeds: gm,narrator
|
|||||||
2. 普通楼层至少含 `body`;正文只引用故事推进,不复制目标字数、占比或字数预算。
|
2. 普通楼层至少含 `body`;正文只引用故事推进,不复制目标字数、占比或字数预算。
|
||||||
3. 每个区域必须声明出现方式、内容类型、区域种类、来源、内容要求和空值处理。可见区的区域种类只能是抬头、正文、小故事、数据卡、结构、文字选项。
|
3. 每个区域必须声明出现方式、内容类型、区域种类、来源、内容要求和空值处理。可见区的区域种类只能是抬头、正文、小故事、数据卡、结构、文字选项。
|
||||||
4. `program_projection` 必须给稳定 ref;状态和拓扑数据不得标为临场或要求模型重新生成。
|
4. `program_projection` 必须给稳定 ref;状态和拓扑数据不得标为临场或要求模型重新生成。
|
||||||
5. 内联变量更新区必须 `用户可见:false`、内容类型为 `state_ops`、来源为 `maintenance_channel`;程序按 API 能力选择 tool call 或结构化区域。`auditor_bundle` 不建立该区。
|
5. 不建立主回复隐藏填表区(无 `state_ops` / `maintenance_channel`);表由旁观副 LLM 维护。
|
||||||
6. 文字选项只规定数量、差异维度和内容要求;不写可点击、按钮动作或回传协议。
|
6. 文字选项须含 `选项`:数量,以及每项的差异维度和组成;不写可点击、按钮动作或回传协议。
|
||||||
7. 不选壳,不写布局、HTML、CSS、JS、Tab、SVG 或槽位替换;这些属于正文组成。
|
7. 不选壳,不写布局、HTML、CSS、JS、Tab、SVG 或槽位替换;这些属于正文组成。
|
||||||
8. 产物正文不含自评、追问或开放问题。
|
8. 产物正文不含自评、追问或开放问题。
|
||||||
9. `mount` 固定为 `["world-simulator", "narrator"]`;artifact tag 暂时保留 `设计.监控栏`。
|
9. `mount` 固定为 `["world-simulator", "narrator"]`;artifact tag 暂时保留 `设计.监控栏`。
|
||||||
@@ -184,4 +211,5 @@ feeds: gm,narrator
|
|||||||
- 展示地图:新增 topology 区域,说明用户应看到哪些节点、连接或当前位置;具体 SVG、表格或 Tab 留给正文组成。
|
- 展示地图:新增 topology 区域,说明用户应看到哪些节点、连接或当前位置;具体 SVG、表格或 Tab 留给正文组成。
|
||||||
- 简单变量内联维护:新增用户不可见的 state_ops 语义区域;运行期优先投递为 tool call,工具不可用时进入结构化整包。
|
- 简单变量内联维护:新增用户不可见的 state_ops 语义区域;运行期优先投递为 tool call,工具不可用时进入结构化整包。
|
||||||
- 文字选项:写数量、彼此差异和应提供的行动方向;不声明点击行为。
|
- 文字选项:写数量、彼此差异和应提供的行动方向;不声明点击行为。
|
||||||
|
- 坏:只写「要选项」,不定数量、各项差异维度与组成,结果每轮几条选项大同小异。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -7,33 +7,37 @@ name: 故事推进
|
|||||||
id: story-progression
|
id: story-progression
|
||||||
artifact: 设计.故事推进
|
artifact: 设计.故事推进
|
||||||
declaration: >
|
declaration: >
|
||||||
从偏好库选取本局约束,只选定一种用户输入用法,并写清单轮目标字数、扩写与续写的定义和篇幅分配、OOC 处理;不写遣词。
|
继承交互范式的描写权限与停笔时机,从偏好库选取本局约束,钉推进方式(可无专门推进),只选定一种用户输入用法;每一轮正文分成扩写(演本轮材料)与续写(材料后往下写),写清定义、字数分配与 OOC 处理;不写遣词。
|
||||||
when: |
|
when: |
|
||||||
已有美学纲领(或等价),但尚未钉死唯一的用户输入用法,或扩写/续写边界与比例仍不清。
|
已有美学纲领(或等价),但尚未钉死唯一的用户输入用法,或扩写/续写边界与比例仍不清,或还不知道故事靠场面自推、等用户决策还是无专门推进。
|
||||||
用户主动谈推进、节奏、停笔、大纲扩写、续写、自动写入时也使用。
|
用户主动谈推进、节奏、停笔、大纲扩写、续写、自动写入、单轮字数时也使用。
|
||||||
when_not: |
|
when_not: |
|
||||||
本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。
|
本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。
|
||||||
美学未钉 → 先「美学纲领与交互范式」。
|
美学未钉 → 先「美学纲领与交互范式」。
|
||||||
只改遣词、笔墨、写法档 → 「叙事指南」。
|
只改遣词、笔墨、写法档、段落形态 → 「叙事指南」。可见区域 → 「回复呈现」。
|
||||||
|
好感公式、进度表、事件池等表规格 → 「变量设计与更新规则」/「生成规则」。
|
||||||
固定输入标记(裸写 / `#行动#` / 引号)由程序解析,不在本步重定义或按局反转。
|
固定输入标记(裸写 / `#行动#` / 引号)由程序解析,不在本步重定义或按局反转。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力只交推进合同:唯一输入用法、单轮目标字数、扩写要求、续写要求、篇幅分配、OOC 处理策略。
|
本能力只交推进合同:推进方式及可选的驱动力、成败与难度、变量钩子;唯一输入用法、单轮目标字数、扩写要求、续写要求、篇幅分配、OOC 处理策略。
|
||||||
叙事指南管怎么写;变量与生成规则管表与合同;主角详略只写偏好钩子。
|
单轮字数与篇幅只在本步定义;段落形态(长段不碎行)归叙事指南,可见区域归回复呈现。
|
||||||
|
叙事指南管怎么写;变量与生成规则管表与合同,本步只写钩子;主角详略只写偏好钩子。
|
||||||
feeds: gm,narrator
|
feeds: gm,narrator
|
||||||
```
|
```
|
||||||
|
|
||||||
## opening
|
## opening
|
||||||
|
|
||||||
```opening
|
```opening
|
||||||
先选定这局唯一的输入用法:大纲扩写、续写,或自动写入。再说清楚单轮大约写多长、扩写和续写各做什么及各占多少;上帝视角/OOC 指令准备怎么处理。
|
先选定这局唯一的输入用法:大纲扩写、续写,或自动写入。再说清楚单轮大约写多长;扩写(主路径不动,在用户已给的节拍之间补连接小动作,把场面写顺)和续写(从材料结束后往下写他人与世界反应)各做什么、各占多少。上帝视角/OOC 指令准备怎么处理。故事要不要专门往前推、用户会不会失败,也可以一并说;没想过就按体验先定。
|
||||||
```
|
```
|
||||||
|
|
||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
把本局推进合同写入 `设计.故事推进`。先用 `read_library_entry` 从绑定的偏好库读取最相关 1~2 条并本局化,再只选定一种用户输入用法,按该用法写清单轮目标字数、扩写定义、续写定义、两者各自篇幅预算,以及裸写(上帝视角/OOC)指令如何处理。
|
把本局推进合同写入 `设计.故事推进`。先继承上游「美学纲领与交互范式」:把 `交互范式.描写权限`(思考与抉择、言语、主动行动、用户输入后)、`后果与叙事控制` 与 `停笔时机` 改写成推进做法;交互范式已写过停笔权限,也要落进本步的用法与续写要求,不因已有而省略。再用 `read_library_entry` 从绑定的偏好库读取最相关 1~2 条并本局化,然后钉推进方式(不明则写「无专门推进」),只选定一种用户输入用法。
|
||||||
|
|
||||||
禁止混用两种用法或写成半套规则。固定标记怎么识别交给程序;本步只写识别之后怎么服从与落笔。不写遣词、示范段落或版式。
|
扩写与续写是每一轮正文里的两段工作,不是两种输入用法。按所选用法写清单轮目标字数、扩写做什么、续写做什么、两者各自篇幅预算,以及裸写(上帝视角/OOC)指令如何处理。
|
||||||
|
|
||||||
|
禁止混用两种用法或写成半套规则。固定标记怎么识别交给程序;本步只写识别之后怎么服从与落笔。不写遣词、段落形态、示范段落或版式。
|
||||||
|
|
||||||
材料足够便直接成稿;多种用法都像、必须用户点选时,把缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
材料足够便直接成稿;多种用法都像、必须用户点选时,把缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
||||||
```
|
```
|
||||||
@@ -41,16 +45,28 @@ feeds: gm,narrator
|
|||||||
## principles
|
## principles
|
||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 全局只允许一种用户输入用法。切分依据是扩写与续写的定义,以及两者在本局的职责和篇幅预算。
|
1. `用户输入用法` 与「扩写 / 续写」不是同一层。用法是本局选哪一种合同(三选一);扩写、续写是每一轮正文里先后做的两段工作。产物必须先按所选用法写清这两段各自做什么,再分配篇幅。
|
||||||
2. 三种用法互斥,写入时只保留一个完整合同:
|
2. 扩写:主路径不动,把用户本轮已经给出的材料演成流畅场面。人、事、话、结果保持不变;允许在节拍之间增加使动作接得上的小动作、感官和当场小纠正。只加形容词、比喻或气氛、不增加这些连接动作,不算扩写。另起新冲突、新目标、新决定,或改掉已定结果,算出界。
|
||||||
- 大纲扩写:输入是待演成场面的大纲。扩写要把每个节拍演满,节拍上的人、事、话、结果保持不变,节拍之间补具体动作把处境写出来;续写短,且不代用户做新决定。
|
例:用户写「打开冰箱门、放入大象、关闭冰箱门」——主路径就是这三步,结果仍是大象进了冰箱。只写这三句会不流畅,扩写应补门怎么拉开、大象怎么塞进去、门关不上再用力扣上等细节;不能改成没放进去、或中途改去别的房间。
|
||||||
- 续写:扩写只跟用户本轮已声明的行为与对话;续写为主,停在其他角色等待用户抉择处,不替用户做态度或推进性决定。
|
- 用法为「大纲扩写」时:材料是待演成场面的大纲,不是终稿句子。每个节拍都要演到;节拍之间必须补连接动作,大纲没写这些动作也要写。另例:大纲「我去相亲、坐了下来,她说要婚后自由出轨权,我拒绝了」——坐下前写先要一杯咖啡,又急忙改成两杯,显出局促;她的要求和拒绝的结果不动。
|
||||||
- 自动写入:输入多为短 meta 或极简扮演;续写宜长;可按主角设定代演用户角色言行。
|
- 用法为「续写」或「自动写入」时:材料是用户本轮已声明的行为与对话。扩写只在这些声明之间补过程与连接动作,可写他人怎么接这一下;不另写用户没声明的新决定。
|
||||||
3. 多写“要怎样完成扩写/续写”,少写否定清单;只有会破坏该用法边界的禁点才保留。
|
3. 续写:从用户材料结束后往下写。写其他角色与世界如何反应、把场面推到下一个停笔点。默认不可再写用户角色的实际行动、说话或带倾向抉择;最多被动占位(望去、让开)。整轮停在其他角色等用户角色行动抉择之刻。
|
||||||
4. OOC/上帝视角内容由程序从裸写识别;本步写处理策略:服从到什么程度、是否打断当前场面、如何与既定扩写/续写比例兼容。
|
- 用法为「大纲扩写」或「续写」时:禁止替用户角色做决定,不引入材料外的新剧情事实。
|
||||||
5. 偏好库是参考:读取后改成本局可执行要求,不照抄。
|
- 用法为「自动写入」时:可按主角设定代演用户角色言行,把续写写长;关键抉择仍按交互范式的权限处理。
|
||||||
6. 单轮篇幅在这里一次钉死:给目标字数或窄区间,并把扩写、续写的占比与字数预算对应起来;其它节点直接消费,不重复定义。
|
4. 三种输入用法互斥,按体验与交互范式选一种,写入时只保留一个完整合同。差别在输入是什么、扩写/续写谁长谁短、续写能不能代演用户角色:
|
||||||
7. 不写遣词、写法档、版式或事件池公式。
|
- 大纲扩写:用户是写手、要大纲成稿,输入多为剧情节拍时选它。扩写充分、续写短。
|
||||||
|
- 续写(旧称扮演停笔):代入扮演、轮转等用户决策时选它(交互范式规定关键抉择或言语只由用户做、停笔时机是等用户回应)。输入往往较短;扩写只跟已声明内容,续写多于大纲扩写、短于自动写入。
|
||||||
|
- 自动写入:用户只给短 meta 指令或极简扮演、希望 AI 代演用户角色,且交互范式允许模型代写 user 言行(旁观、操控站位常见)时选它。扩写少、续写宜长。
|
||||||
|
5. 多写“要怎样完成扩写/续写”,少写否定清单;只有会破坏该用法边界的禁点才保留。
|
||||||
|
6. 推进可轻可无:`推进方式` 写「场面自推 / 等用户决策 / 无专门推进」之一,不明则写「无专门推进」。不自己发明好感公式、进度表或事件池;表规格交给「变量设计与更新规则」与「生成规则」。
|
||||||
|
7. 推进子维度按需填,推进方式为「无专门推进」时可全省:
|
||||||
|
- `驱动力`:故事靠谁、靠什么往前(NPC 主动作为、世界压力与时限、用户决策),承接交互范式的 `世界对user的影响` 与 `引入新内容`。
|
||||||
|
- `成败与难度`:用户行动会不会失败、失败怎么写(代价落在哪、挫败场面写多重),承接 `行为后果判定` 与 `设计困境`。体验本身要顺遂碾压时写明「不设失败」以及仍保留的阻力;未写明时不默认无条件顺遂。
|
||||||
|
- `变量钩子`:推进挂在哪些变量上,每条一句「变量名:推进如何看它」;只写钩子,不写公式、阈值或数值表。
|
||||||
|
8. OOC/上帝视角内容由程序从裸写识别;本步写处理策略:服从到什么程度、是否打断当前场面、如何与既定扩写/续写比例兼容。
|
||||||
|
9. 偏好库是参考:读取后改成本局可执行要求,不照抄。
|
||||||
|
10. 单轮篇幅在这里一次钉死:给目标字数或窄区间,并把扩写、续写的占比与字数预算对应起来;其它节点直接消费,不重复定义。段落形态归叙事指南,本步不写。
|
||||||
|
11. 不写遣词、写法档、段落形态、版式或事件池公式。
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -70,9 +86,18 @@ feeds: gm,narrator
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"推进方式": "场面自推 / 等用户决策 / 无专门推进",
|
"推进方式": "场面自推 / 等用户决策 / 无专门推进",
|
||||||
|
"驱动力": "可选:故事靠谁、靠什么往前;无专门推进时省略",
|
||||||
|
"成败与难度": "可选:用户行动会不会失败、失败怎么写、代价落在哪;无专门推进时省略",
|
||||||
|
"变量钩子": ["可选:变量名:推进如何看它(只写钩子,不写公式)"],
|
||||||
"用户输入用法": "大纲扩写 | 续写 | 自动写入",
|
"用户输入用法": "大纲扩写 | 续写 | 自动写入",
|
||||||
"扩写要求": ["该用法下扩写要做什么、守住什么结果"],
|
"扩写要求": [
|
||||||
"续写要求": ["该用法下续写写到哪里、何时停笔"],
|
"主路径(人、事、话、结果)不动;节拍之间补连接小动作,把场面写顺。例:打开冰箱、放入大象、关上冰箱——补怎么塞、门怎么扣,不能改成没放进去",
|
||||||
|
"所选用法下材料是什么、节拍之间补什么(按 principles 第 2 条写具体)"
|
||||||
|
],
|
||||||
|
"续写要求": [
|
||||||
|
"从材料结束后写他人与世界反应,停在等用户角色回应之刻",
|
||||||
|
"所选用法下能否代演用户角色、续写写到哪停(按 principles 第 3 条写具体)"
|
||||||
|
],
|
||||||
"单轮篇幅": {
|
"单轮篇幅": {
|
||||||
"目标字数": "明确数字或窄区间",
|
"目标字数": "明确数字或窄区间",
|
||||||
"扩写占比": "百分比",
|
"扩写占比": "百分比",
|
||||||
@@ -89,24 +114,25 @@ feeds: gm,narrator
|
|||||||
|
|
||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON;`用户输入用法` 只能是三者之一,不得并列或混装半套。
|
1. 输出合法 JSON;`用户输入用法` 只能是三者之一,不得并列或混装半套。
|
||||||
2. `扩写要求`、`续写要求`、`单轮篇幅` 必须与所选用法一致;两项占比合计 100%,字数预算与目标字数对应。
|
2. `扩写要求` 只写「把本轮材料演成场面」怎么做;`续写要求` 只写「材料结束后往下写」怎么做。不要把另一种输入用法的规则抄进这两项。两项占比合计 100%,字数预算与目标字数对应。
|
||||||
3. `选用偏好` 默认 1~2 项;无合适条目时可 `[]`,但仍须钉死唯一用法。
|
3. `推进方式` 是单个字符串;`驱动力`、`成败与难度`、`变量钩子` 可选,`变量钩子` 不写公式或数值。
|
||||||
4. 不重定义裸写 / `#行动#` / 引号的识别规则;只写 OOC 处理策略。
|
4. `选用偏好` 默认 1~2 项;无合适条目时可 `[]`,但仍须钉死唯一用法。
|
||||||
5. 不在产物正文写自评、追问、开放问题或遣词示范。
|
5. 不重定义裸写 / `#行动#` / 引号的识别规则;只写 OOC 处理策略。
|
||||||
6. `mount` 固定包含 `world-simulator` 与 `narrator`。
|
6. 不在产物正文写自评、追问、开放问题、遣词示范或段落形态。
|
||||||
|
7. `mount` 固定包含 `world-simulator` 与 `narrator`。
|
||||||
|
|
||||||
## score
|
## score
|
||||||
|
|
||||||
```score
|
```score
|
||||||
唯一性与切分:
|
唯一性与切分:
|
||||||
- 5:只选定一种用法;扩写/续写定义、目标字数和预算切分清楚,无第二种用法残留。
|
- 5:只选定一种用法,且与交互范式的权限和停笔时机相符;扩写(演本轮材料)与续写(材料后往下写)定义清楚,目标字数和预算切分清楚,无第二种用法残留。
|
||||||
- 3:主用法明确,但仍有一处边界、占比或字数预算含糊。
|
- 3:主用法明确,但仍有一处边界、占比或字数预算含糊。
|
||||||
- 1:混用多种用法,或扩写/续写职责无法区分。
|
- 1:混用多种用法,或把扩写/续写当成另一种输入用法、两段职责无法区分。
|
||||||
|
|
||||||
可执行性:
|
可执行性:
|
||||||
- 5:按该合同可直接开玩,扩写、续写、停笔与 OOC 处理都能执行。
|
- 5:按该合同可直接开玩,推进方式、扩写、续写、停笔与 OOC 处理都能执行;有推进时成败与难度写清,不默认无条件顺遂。
|
||||||
- 3:大方向可用,但停笔或 OOC 处理仍需猜测。
|
- 3:大方向可用,但停笔、成败或 OOC 处理仍需猜测。
|
||||||
- 1:只有用法名称,没有可执行要求。
|
- 1:只有用法名称,没有可执行要求,或自行发明公式与进度表。
|
||||||
|
|
||||||
偏好本局化:
|
偏好本局化:
|
||||||
- 5:已读偏好并改成本局要求,与唯一用法咬合。
|
- 5:已读偏好并改成本局要求,与唯一用法咬合。
|
||||||
@@ -117,10 +143,22 @@ feeds: gm,narrator
|
|||||||
## checklist
|
## checklist
|
||||||
|
|
||||||
```checklist
|
```checklist
|
||||||
- [ ] 只选定一种用户输入用法?
|
- [ ] 已继承交互范式的描写权限与停笔时机并落进本步?
|
||||||
|
- [ ] 推进方式已写(不明则「无专门推进」),未发明公式、进度表或事件池?
|
||||||
|
- [ ] 只选定一种用户输入用法,且符合它的适用场景?
|
||||||
|
- [ ] 扩写与续写已按两段工作分别写清(扩写=演本轮材料,续写=材料后往下写),且与所选用法相符?
|
||||||
|
- [ ] 扩写已写明「主路径不动、节拍之间补连接小动作」?只加形容词、或改掉已定结果,都不算写清。
|
||||||
- [ ] 单轮目标字数、扩写/续写定义、占比与字数预算已写清且互相一致?
|
- [ ] 单轮目标字数、扩写/续写定义、占比与字数预算已写清且互相一致?
|
||||||
- [ ] OOC/裸写处理策略已写,且未重定义固定标记语法?
|
- [ ] OOC/裸写处理策略已写,且未重定义固定标记语法?
|
||||||
- [ ] 偏好已按需读取并本局化?
|
- [ ] 偏好已按需读取并本局化?
|
||||||
- [ ] 未写入遣词、写法档或半套混用规则?
|
- [ ] 未写入遣词、写法档、段落形态或半套混用规则?
|
||||||
- [ ] 产物正文不含评分、诊断、追问?
|
- [ ] 产物正文不含评分、诊断、追问?
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## examples
|
||||||
|
|
||||||
|
```examples
|
||||||
|
好:用法钉「续写」;用户写「我推开门」——扩写补门轴、屋里人抬头,不另写用户没说的台词;续写写屋里人的反应,停在等用户开口之刻。
|
||||||
|
好:用法钉「大纲扩写」;用户写「打开冰箱门、放入大象、关闭冰箱门」——扩写补门怎么拉开、大象怎么塞、门关不上再扣上,三步结果不变;续写短。
|
||||||
|
坏:把「扩写」理解成另一种输入用法;只把三句动作各加形容词、场面仍不接;中途改成没放进冰箱或另起新目标;续写里替用户角色补一句台词。
|
||||||
|
```
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ name: 变量设计与更新规则
|
|||||||
id: variable-design
|
id: variable-design
|
||||||
artifact: 设计.变量设计与更新规则
|
artifact: 设计.变量设计与更新规则
|
||||||
declaration: >
|
declaration: >
|
||||||
定义必须跨轮保存的最少真值、合法更新条件、权限与派生映射,供程序选择简单状态工具或复杂维护副 Worker。
|
定义必须跨轮保存的最少真值、合法更新条件、权限与派生映射;有真值时由程序固定启用旁观副 LLM 按准则填表。
|
||||||
when: |
|
when: |
|
||||||
某项状态若不持久保存,就会导致数值、阶段、资源、门控或已发生事实跨轮漂移时使用。
|
某项状态若不持久保存,就会导致数值、阶段、资源、门控或已发生事实跨轮漂移时使用。
|
||||||
when_not: |
|
when_not: |
|
||||||
@@ -15,8 +15,8 @@ when_not: |
|
|||||||
节点与连接构成的地图、关系或路径 → 拓扑图谱。
|
节点与连接构成的地图、关系或路径 → 拓扑图谱。
|
||||||
大段设定或同类对象合同 → 舞台骨架 / 生成规则。
|
大段设定或同类对象合同 → 舞台骨架 / 生成规则。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力定义什么是真值、什么事实允许它改变,以及改变后的约束;不决定 API 投递方式。
|
本能力定义什么是真值、什么事实允许它改变,以及改变后的约束。
|
||||||
少量简单变量由程序生成主世界层状态工具;复杂变量与拓扑等由维护合同编译工具汇总。
|
表维护固定走旁观副 LLM(读上一份表、本产物维护准则、本轮主世界正文);不在此决定内联 tool / state_ops。
|
||||||
变量控制上下文负责详细可见范围;正文组成负责展示。本能力不写隐藏段、正则、maintain 包或界面格式。
|
变量控制上下文负责详细可见范围;正文组成负责展示。本能力不写隐藏段、正则、maintain 包或界面格式。
|
||||||
feeds: gm,auditor
|
feeds: gm,auditor
|
||||||
```
|
```
|
||||||
@@ -38,7 +38,7 @@ feeds: gm,auditor
|
|||||||
|
|
||||||
若真值只用于选择分档文本或实例切片,保存真值并引用映射来源,不把派生结果建立成第二个可写真值。若跨越阈值会触发一次性或边沿效果,写成触发规则。
|
若真值只用于选择分档文本或实例切片,保存真值并引用映射来源,不把派生结果建立成第二个可写真值。若跨越阈值会触发一次性或边沿效果,写成触发规则。
|
||||||
|
|
||||||
最后根据变量数量、相互依赖、判断方式和触发复杂度给出维护复杂度事实,供程序选择 `none / inline_tool / inline_structured / auditor_bundle`;这是建议,不绑定 API 能力。
|
可写简短「维护复杂度」备注(交叉依赖、须结合正文推断的点),供旁观副 LLM 参考;**程序有真值即固定开旁观填表**,不再按建议在内联/旁观间切换。勿手写 `play_slots`。
|
||||||
|
|
||||||
材料足够便直接成稿;真正会改变变量合同的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
材料足够便直接成稿;真正会改变变量合同的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
||||||
```
|
```
|
||||||
@@ -52,8 +52,8 @@ feeds: gm,auditor
|
|||||||
4. 类型决定操作:number/integer 可 `set|delta`;enum/string/boolean 可 `set`;set 可 `add|remove`。不用散文值绕过类型。
|
4. 类型决定操作:number/integer 可 `set|delta`;enum/string/boolean 可 `set`;set 可 `add|remove`。不用散文值绕过类型。
|
||||||
5. 公开性只区分用户可见与隐藏;更细的主世界层、转述、旁观投影交给变量控制上下文。
|
5. 公开性只区分用户可见与隐藏;更细的主世界层、转述、旁观投影交给变量控制上下文。
|
||||||
6. 阈值映射只保存引用和选取条件;长文内容仍在生成规则/具体实例中。
|
6. 阈值映射只保存引用和选取条件;长文内容仍在生成规则/具体实例中。
|
||||||
7. 维护复杂度按实际依赖判断,不机械按数量:一个简单余额可走 inline tool;一个可扩展关系图不属于简单变量。
|
7. 「维护复杂度」只写给旁观看的难点备注(交叉依赖、叙事推断证据),不决定开不开旁观;有真值即旁观填表。
|
||||||
8. 不在正文中设计隐藏标签、正则或维护 JSON;投递方式由程序根据 API 能力选择。
|
8. 不在正文中设计隐藏标签、正则或维护 JSON;主世界不承担隐藏填表通道。
|
||||||
```
|
```
|
||||||
|
|
||||||
## probe
|
## probe
|
||||||
@@ -133,7 +133,7 @@ feeds: gm,auditor
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"维护复杂度": {
|
"维护复杂度": {
|
||||||
"建议": "inline_tool|auditor_bundle",
|
"备注": "交叉依赖、须结合正文推断的点;给旁观副 LLM 参考,不驱动开槽",
|
||||||
"依据": [
|
"依据": [
|
||||||
"变量数量、交叉依赖、narrative_inference、触发规则与其它可变产物"
|
"变量数量、交叉依赖、narrative_inference、触发规则与其它可变产物"
|
||||||
],
|
],
|
||||||
@@ -150,7 +150,7 @@ feeds: gm,auditor
|
|||||||
2. 类型与允许操作必须相容;number/integer 的边界、enum 的枚举值必须明确;不适用的约束字段省略。
|
2. 类型与允许操作必须相容;number/integer 的边界、enum 的枚举值必须明确;不适用的约束字段省略。
|
||||||
3. `narrative_inference` 必须写证据要求;正文没有该证据时不更新。
|
3. `narrative_inference` 必须写证据要求;正文没有该证据时不更新。
|
||||||
4. 派生映射与触发规则可为空;存在时必须引用已声明变量,且派生结果不得成为第二个可写真值。
|
4. 派生映射与触发规则可为空;存在时必须引用已声明变量,且派生结果不得成为第二个可写真值。
|
||||||
5. `维护复杂度.建议` 只在 `inline_tool|auditor_bundle` 中选择;程序仍按 API 能力决定具体投递方式。
|
5. `维护复杂度` 可选;有则写备注与依据给旁观,勿再写 `建议: inline_*` 指望关旁观。有真值时程序固定 `play_slots.auditor=true`。勿手写 `play_slots`。
|
||||||
6. 不输出隐藏段、正则、`maintain.v1/v2`、tool schema、Worker 提示或界面字段。
|
6. 不输出隐藏段、正则、`maintain.v1/v2`、tool schema、Worker 提示或界面字段。
|
||||||
7. 产物正文不含自评、追问或开放问题。
|
7. 产物正文不含自评、追问或开放问题。
|
||||||
8. `mount` 固定为 `["world-simulator", "auditor"]`。
|
8. `mount` 固定为 `["world-simulator", "auditor"]`。
|
||||||
@@ -191,15 +191,15 @@ feeds: gm,auditor
|
|||||||
- [ ] 类型、初值、允许操作、约束和写入者互相一致?
|
- [ ] 类型、初值、允许操作、约束和写入者互相一致?
|
||||||
- [ ] 每条更新规则都有事实证据和可执行的值规则?
|
- [ ] 每条更新规则都有事实证据和可执行的值规则?
|
||||||
- [ ] 派生映射未被重复建成可写真值?
|
- [ ] 派生映射未被重复建成可写真值?
|
||||||
- [ ] 维护复杂度依据实际依赖,而不是只按变量数量?
|
- [ ] 维护复杂度若写了,是旁观备注而非开槽开关?
|
||||||
- [ ] 未写隐藏标签、正则、维护包或界面格式?
|
- [ ] 未写隐藏标签、正则、维护包或界面格式?
|
||||||
```
|
```
|
||||||
|
|
||||||
## examples
|
## examples
|
||||||
|
|
||||||
```examples
|
```examples
|
||||||
- 单个数值真值、只有 set/delta 和上下界、无交叉依赖:建议 inline_tool;程序按 API 探测生成类型明确的小工具。
|
- 单个数值真值或关系矩阵:都写清更新规则;有真值即由旁观副 LLM 填表,主世界只写正文。
|
||||||
- 多个真值需要结合正文共同判断,且阈值会触发映射或补实例:建议 auditor_bundle;维护编译工具统一收集。
|
- 多个真值需结合正文共同判断:在维护复杂度.备注里点出推断难点与证据,供旁观读。
|
||||||
- 当前态度由好感区间映射得到:只保存好感,态度作为派生映射,不建立第二个可写“当前态度”。
|
- 当前态度由好感区间映射得到:只保存好感,态度作为派生映射,不建立第二个可写“当前态度”。
|
||||||
- 不合适:每轮重写一段“当前性格”长字符串,或只写“随剧情变化”而没有证据与操作。
|
- 不合适:每轮重写一段“当前性格”长字符串,或只写“随剧情变化”而没有证据与操作。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ artifact: 设计.worker规格
|
|||||||
declaration: >
|
declaration: >
|
||||||
程序根据前面已经确认的写作、维护和秘密隔离需求,整理本局需要启用的固定执行单元
|
程序根据前面已经确认的写作、维护和秘密隔离需求,整理本局需要启用的固定执行单元
|
||||||
when: |
|
when: |
|
||||||
落档或开玩时自动执行;从叙事、维护复杂度、信息隔离和写作路径中推导固定槽位。
|
落档或开玩时自动执行;从叙事、信息隔离、写作路径推导固定槽位;有变量真值时程序固定开旁观填表。
|
||||||
when_not: |
|
when_not: |
|
||||||
前置语义尚未确定时沿用安全默认值,不向用户重复询问。
|
前置语义尚未确定时沿用安全默认值,不向用户重复询问。
|
||||||
boundary: |
|
boundary: |
|
||||||
@@ -57,7 +57,8 @@ boundary: |
|
|||||||
2. 默认世界模拟:`gm: true, narrator: false, auditor: false, perspective: false`;
|
2. 默认世界模拟:`gm: true, narrator: false, auditor: false, perspective: false`;
|
||||||
调度序 gm → perspective? → narrator? → auditor?;
|
调度序 gm → perspective? → narrator? → auditor?;
|
||||||
主世界层必开(用户明确只要旁观/写手路径除外);
|
主世界层必开(用户明确只要旁观/写手路径除外);
|
||||||
要独立文风改写才 narrator true;有变量设计/表维护需求 → auditor true;
|
要独立文风改写才 narrator true;
|
||||||
|
**旁观填表由程序固定**:有「设计.变量设计与更新规则」真值时落档会开 `auditor=true`,本步不必猜;手写 play_slots 时旁观与之对齐即可。
|
||||||
仅信息隔离才开 perspective。
|
仅信息隔离才开 perspective。
|
||||||
3. 写手路径:`outline` + `chapter-writer` 默认都开;用户明确只要正文则可关 outline。
|
3. 写手路径:`outline` + `chapter-writer` 默认都开;用户明确只要正文则可关 outline。
|
||||||
4. 可写简短 `mount_notes`(哪类上游产物挂哪槽),不粘贴长文。
|
4. 可写简短 `mount_notes`(哪类上游产物挂哪槽),不粘贴长文。
|
||||||
@@ -70,8 +71,8 @@ boundary: |
|
|||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 只勾选,不发明:ref 必须在白名单内;无机遇裁定槽。
|
1. 只勾选,不发明:ref 必须在白名单内;无机遇裁定槽。
|
||||||
2. 默认:世界模拟 = 仅主世界层;转述/旁观/perspective 默认关。主世界层必要。
|
2. 默认:世界模拟 = 仅主世界层;转述/perspective 默认关。主世界层必要。
|
||||||
3. 要独立文风层再开转述;有变量/表/规则要盯再开旁观。
|
3. 要独立文风层再开转述。表维护固定旁观副 LLM:有变量真值即 auditor true,不再按 inline 建议关旁观。
|
||||||
4. 变量 / Data / Progressive 不是执行单元;旁观维护只出 maintain.v1,不写真相。
|
4. 变量 / Data / Progressive 不是执行单元;旁观维护只出 maintain.v1,不写真相。
|
||||||
5. 删掉检验仍适用:关某个槽要说得清损失什么。
|
5. 删掉检验仍适用:关某个槽要说得清损失什么。
|
||||||
6. 本步不输出完整 Worker 集、不写 tables 全文、不写随机范围表(交给细化终稿 / 变量设计 / 随机范围整理)。
|
6. 本步不输出完整 Worker 集、不写 tables 全文、不写随机范围表(交给细化终稿 / 变量设计 / 随机范围整理)。
|
||||||
@@ -85,10 +86,9 @@ boundary: |
|
|||||||
|
|
||||||
- 是否必须独立转述改写正文?(要才 narrator=true;默认不要,主世界层原文即终稿)
|
- 是否必须独立转述改写正文?(要才 narrator=true;默认不要,主世界层原文即终稿)
|
||||||
- 是否有「主世界层不该知道的角色秘密」?(有才 perspective=true)
|
- 是否有「主世界层不该知道的角色秘密」?(有才 perspective=true)
|
||||||
- 有变量/表要盯却关了旁观?或无状态却硬开旁观?
|
|
||||||
- 扩写:要不要先验大纲再写章?
|
- 扩写:要不要先验大纲再写章?
|
||||||
|
|
||||||
不要问「还想加什么 Worker」;不要问要不要机遇裁定。
|
不要问「还想加什么 Worker」;不要问要不要机遇裁定;旁观开否由程序按有无变量真值固定。
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -123,7 +123,7 @@ boundary: |
|
|||||||
- `path=writing` 时必须有 `writing_slots`;`play_slots` 可省略。
|
- `path=writing` 时必须有 `writing_slots`;`play_slots` 可省略。
|
||||||
- `play_slots.gm` 世界模拟路径下应为 true。
|
- `play_slots.gm` 世界模拟路径下应为 true。
|
||||||
- `narrator` 缺省按 false(主世界层兼呈现);仅用户明确要独立文风层时 true。
|
- `narrator` 缺省按 false(主世界层兼呈现);仅用户明确要独立文风层时 true。
|
||||||
- `auditor` 缺省按 false;有变量/表维护意图才 true。
|
- `auditor` 缺省按 false;有变量真值时落档程序固定为 true(旁观副 LLM 填表)。对话落盘不必本步猜。
|
||||||
- 不要输出 `chance` 字段;旧产物若有 `chance: true`,本步改为 false/删除,并指向「随机范围整理」。
|
- 不要输出 `chance` 字段;旧产物若有 `chance: true`,本步改为 false/删除,并指向「随机范围整理」。
|
||||||
- 不要输出自造 `actors[]` / 自由 `workers[]`。
|
- 不要输出自造 `actors[]` / 自由 `workers[]`。
|
||||||
- 旧产物若含 `actors[]`:本步应改写为槽位勾选,不再追加自定义 ref。
|
- 旧产物若含 `actors[]`:本步应改写为槽位勾选,不再追加自定义 ref。
|
||||||
@@ -132,8 +132,8 @@ boundary: |
|
|||||||
|
|
||||||
```checklist
|
```checklist
|
||||||
- [ ] 是否只有白名单槽,无自造 ref、无机遇裁定?
|
- [ ] 是否只有白名单槽,无自造 ref、无机遇裁定?
|
||||||
- [ ] 主世界层是否开启(世界模拟路径)?旁观是否与变量需求一致?
|
- [ ] 主世界层是否开启(世界模拟路径)?有变量真值时旁观是否按程序约定开启?
|
||||||
- [ ] auditor / perspective / 只要正文等非常规选择是否有理由?
|
- [ ] perspective / 只要正文等非常规选择是否有理由?
|
||||||
- [ ] 是否误把变量管理做成槽?是否误交完整 设计.worker集?
|
- [ ] 是否误把变量管理做成槽?是否误交完整 设计.worker集?
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ when: |
|
|||||||
核心体验依赖外部环境、社会力量或世界状况持续作出一致反馈,而现有材料还不足以支撑这些反馈时使用。
|
核心体验依赖外部环境、社会力量或世界状况持续作出一致反馈,而现有材料还不足以支撑这些反馈时使用。
|
||||||
是否换区不是判断标准:跨越多地可以需要舞台骨架;一室之内的朝堂、审讯或家庭关系同样可以需要。
|
是否换区不是判断标准:跨越多地可以需要舞台骨架;一室之内的朝堂、审讯或家庭关系同样可以需要。
|
||||||
when_not: |
|
when_not: |
|
||||||
熟悉现实基底已经够用,地点和外部格局不会改变核心体验或世界反馈时不使用。
|
熟悉现实基底已经够用,地点和外部格局不会改变核心体验或世界反馈时不使用。 既成硬法则归实现机制;多份人物或要先审字段的名片归生成规则,单条字段很少的具名内容才归具体实例;重点在节点与连接本身时归拓扑图谱。
|
||||||
既成硬法则归实现机制;多份人物或要先审字段的名片归生成规则,单条字段很少的具名内容才归具体实例;重点在节点与连接本身时归拓扑图谱。
|
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力只交会上台并反复有用的外部事实,其余沿用熟悉常识或明确留白。
|
本能力只交会上台并反复有用的外部事实,其余沿用熟悉常识或明确留白。
|
||||||
不重写核心体验,不解释硬法则起源,不制作人物名片、完整地图、历史或百科。
|
不重写核心体验,不解释硬法则起源,不制作人物名片、完整地图、历史或百科。
|
||||||
@@ -38,11 +37,12 @@ feeds: gm
|
|||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 舞台以世界怎样回应玩家为准,不以是否换区、地图大小或题材类型为准。
|
1. 舞台以世界怎样回应玩家为准,不以是否换区、地图大小或题材类型为准。
|
||||||
2. 熟悉常识默认继承,只写相对基底的必要变造,以及会反复参与体验的外部事实。
|
2. 尺度由核心体验会碰到的边界决定。同是“现代都市”基底:体验作为韩国顶级财阀,尺度升到地球级势力、国家与资本网络,主要舞台写到财团、宴会、听证层;体验普通大学生活,尺度是一座城或一所学校,主要舞台写到系馆与社团层。按体验正推,写“需要跨国资本压迫感 → 升到国家层”,不写“因为是校园文所以不要国际政治”。
|
||||||
3. 核心运转可以是社会力量、阶层关系、周期事件、资源条件、禁忌或日常秩序;只说明它如何作用于玩家和主要舞台。
|
3. 熟悉常识默认继承,只写相对基底的必要变造,以及会反复参与体验的外部事实。
|
||||||
4. 主要舞台可以是空间、机构、社会层或一间房;详细度以能稳定反馈为止。
|
4. 核心运转可以是社会力量、阶层关系、周期事件、资源条件、禁忌或日常秩序;只说明它如何作用于玩家和主要舞台。用户已点名的灾变、祭祀、异界出口等世界状况必须写进核心运转。
|
||||||
5. 既成硬法则只引用其结果,规则本身交给实现机制;多份或要先审字段的具名人物交给生成规则,单条字段很少的具名内容才交给具体实例。
|
5. 主要舞台可以是空间、机构、社会层或一间房;写到能稳定反馈为止:玩家在这里做什么、会碰到谁或什么、得到什么反馈,不只点名。
|
||||||
6. 用户未触及的地区、历史、物种、民俗和势力不补全,在留白边界中说明即可。
|
6. 既成硬法则只引用其结果,规则本身交给实现机制;多份或要先审字段的具名人物交给生成规则,单条字段很少的具名内容才交给具体实例。多张名片共享的台上格局与世界状况写在本能力,生成规则只引用,不在规则里重抄。
|
||||||
|
7. 用户未触及的地区、历史、物种、民俗和势力不补全,在留白边界中说明即可。类型(西幻、修仙、末日等)只作体裁与氛围参照,不是必选题单:不因题材自动塞入该类型常见的地理与势力;不为显得严谨补起源史、伪科学或神话谱系,除非它们本身会上台。
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -57,6 +57,7 @@ feeds: gm
|
|||||||
"正文": {
|
"正文": {
|
||||||
"舞台基底": {
|
"舞台基底": {
|
||||||
"熟悉基底": "现实或常见类型中可直接继承的最短描述",
|
"熟悉基底": "现实或常见类型中可直接继承的最短描述",
|
||||||
|
"尺度": "体验会碰到的边界,如国家与资本网络、一座城、一所学校、一间殿;熟悉基底已说明时省略",
|
||||||
"必要变造": ["只列会改变世界反馈的差异;无则省略"]
|
"必要变造": ["只列会改变世界反馈的差异;无则省略"]
|
||||||
},
|
},
|
||||||
"核心运转": [
|
"核心运转": [
|
||||||
@@ -69,7 +70,7 @@ feeds: gm
|
|||||||
"主要舞台": [
|
"主要舞台": [
|
||||||
{
|
{
|
||||||
"名称": "会反复上台的空间、机构或社会层",
|
"名称": "会反复上台的空间、机构或社会层",
|
||||||
"玩家如何接触": "玩家在这里做什么、受到什么反馈",
|
"玩家如何接触": "玩家在这里做什么、会碰到谁或什么、受到什么反馈;写到能稳定反馈的具体程度,不只点名",
|
||||||
"关键运转": ["仅引用与此处直接相关的核心运转"]
|
"关键运转": ["仅引用与此处直接相关的核心运转"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -96,7 +97,7 @@ feeds: gm
|
|||||||
克制度:
|
克制度:
|
||||||
- 5:只写会上台且反复有用的内容,熟悉常识默认继承,其余明确留白。
|
- 5:只写会上台且反复有用的内容,熟悉常识默认继承,其余明确留白。
|
||||||
- 3:夹带少量暂时不会影响游玩的地图、历史或设定。
|
- 3:夹带少量暂时不会影响游玩的地图、历史或设定。
|
||||||
- 1:写成完整世界百科,核心舞台反而不突出。
|
- 1:写成完整世界百科,或因题材自动塞满该类型常见地理与势力,核心舞台反而不突出。
|
||||||
```
|
```
|
||||||
|
|
||||||
## checklist
|
## checklist
|
||||||
@@ -107,5 +108,20 @@ feeds: gm
|
|||||||
- [ ] 核心运转只含会反复作用的关系与状况?
|
- [ ] 核心运转只含会反复作用的关系与状况?
|
||||||
- [ ] 主要舞台详细度够用即止,未触及范围已留白?
|
- [ ] 主要舞台详细度够用即止,未触及范围已留白?
|
||||||
- [ ] 未重写体验、硬法则、人物名片、完整历史或百科?
|
- [ ] 未重写体验、硬法则、人物名片、完整历史或百科?
|
||||||
|
- [ ] 尺度由体验正推?多张名片共享的格局已写在本步?
|
||||||
- [ ] 产物正文不含评分、诊断、追问和开放问题?
|
- [ ] 产物正文不含评分、诊断、追问和开放问题?
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## examples
|
||||||
|
|
||||||
|
```examples
|
||||||
|
好:
|
||||||
|
- 体验普通大学生活:熟悉基底为现代都市,尺度是一所学校;主要舞台写到系馆、宿舍、社团,各写玩家在那里做什么、受到什么反馈;国家政治列入留白边界。
|
||||||
|
- 核心运转写世界状况:“每年寒灾”“南极通往怪物世界的出口”“村中活人祭祀”各写如何作用,如寒灾按年逼近、决定储粮与迁徙的代价;祭祀每年要交出活人,决定村里谁有话语权。
|
||||||
|
- 一室朝堂:主要舞台只有一座殿,核心运转是皇权、派系与言官的相互牵制,如何作用写清每次奏对会被谁记账、牵动哪一方。
|
||||||
|
|
||||||
|
坏:
|
||||||
|
- 只写山脉河流,不写用户已点名的祭祀、灾变、异界出口:世界怎样回应玩家仍无从判断。
|
||||||
|
- “地点1:XX咖啡馆,店主叫…”:这是具体实例的活;本能力只写这类场所在舞台上怎样作用于玩家。
|
||||||
|
- 把多张名片共享的台上格局丢给生成规则写“共通前提”:格局写在本能力,生成规则只引用。
|
||||||
|
```
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ feeds: gm,narrator
|
|||||||
- `append`:按顺序追加记录,由程序执行上限或压缩规则。
|
- `append`:按顺序追加记录,由程序执行上限或压缩规则。
|
||||||
- `data`:对程序权威数据应用结构化投影,不接收模型伪造的完整状态。
|
- `data`:对程序权威数据应用结构化投影,不接收模型伪造的完整状态。
|
||||||
5. 隐藏维护通道不创建 DOM。模型只提交允许它生成的槽位;状态和拓扑更新由程序投影。
|
5. 隐藏维护通道不创建 DOM。模型只提交允许它生成的槽位;状态和拓扑更新由程序投影。
|
||||||
6. 写完整 HTML/CSS/JS,创作时一次生成并固定。美化用配色、字体层级、边框、间距,以及 CSS 或简单内联 SVG,按本次能写稳的程度完成。JS 使用宿主提供的 `zeroLayer.onInit(renderCard)` 与 `zeroLayer.onPatch(applyPatch)`;不得依赖正则替换。地图 SVG 只从结构化数据绘制。
|
6. 写完整 HTML/CSS/JS,创作时一次生成并固定。每个可见槽位一个空的 `data-block` 节点,程序按契约把内容灌进去。markdown/text 槽禁止用 `textContent` 写入;本地必须写时用 `present.markdown`(若宿主提供)或等价安全渲染。根容器(如 `#zero-layer-app`)`width: 100%`,铺满宿主可用宽度,禁止整壳 `max-width` + `margin: 0 auto` 收成居中卡片;若要控阅读行长,只给正文柱设 `max-width`。不要 `100vh`,不要整页或侧栏滚动条。美化用配色、字体层级、边框、间距,以及 CSS 或简单内联 SVG,按本次能写稳的程度完成。JS 使用宿主提供的 `zeroLayer.onInit(renderCard)` 与 `zeroLayer.onPatch(applyPatch)`;不得依赖正则替换。地图 SVG 只从结构化数据绘制。
|
||||||
7. 给一份非正史初始卡和一次更新预览,覆盖所有可见槽位与空态。
|
7. 给一份非正史初始卡和一次更新预览,覆盖所有可见槽位与空态。
|
||||||
|
|
||||||
HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变主工作面的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变主工作面的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
||||||
@@ -50,13 +50,14 @@ HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变
|
|||||||
```principles
|
```principles
|
||||||
1. 固定外壳只创建一次;每轮只更新目标槽位,不重发整页代码。
|
1. 固定外壳只创建一次;每轮只更新目标槽位,不重发整页代码。
|
||||||
2. 正文或核心工作面保持最高阅读优先级,辅助 Tab 不平均争夺注意力。
|
2. 正文或核心工作面保持最高阅读优先级,辅助 Tab 不平均争夺注意力。
|
||||||
3. 更新模式来自 0层回复呈现,前端不能擅自把 replace 改成 append。
|
3. 外壳铺满宿主可用宽度(`width: 100%`);行长只限正文柱,不限整壳;缺则按宿主全宽排。
|
||||||
4. 程序投影槽只消费权威数据;正文模型不输出状态副本、地图 SVG 或完整卡快照。
|
4. 更新模式来自 0层回复呈现,前端不能擅自把 replace 改成 append。
|
||||||
5. 每次更新带目标槽位和模式;程序校验允许列表与 revision 后原子提交。
|
5. 程序投影槽只消费权威数据;正文模型不输出状态副本、地图 SVG 或完整卡快照。
|
||||||
6. 动态文字安全渲染;Markdown 使用宿主安全能力,不能拼接未清洗 HTML。
|
6. 每次更新带目标槽位和模式;程序校验允许列表与 revision 后原子提交。
|
||||||
7. 允许本地 Tab、折叠、缩放和 SVG 图谱;禁止联网、表单、弹窗、顶层导航和宿主行动回传。
|
7. 动态文字安全渲染;Markdown 使用宿主安全能力,不能拼接未清洗 HTML。
|
||||||
8. 正规路径使用结构化 slot_updates;正则只属于外部兼容适配器,不写进本产物。
|
8. 允许本地 Tab、折叠、缩放和 SVG 图谱;禁止联网、表单、弹窗、顶层导航和宿主行动回传。
|
||||||
9. 美化落在配色、字体层级、边框、间距和简单内联 SVG 上,写进 CSS,按本次能写稳的程度完成。
|
9. 正规路径使用结构化 slot_updates;正则只属于外部兼容适配器,不写进本产物。
|
||||||
|
10. 美化落在配色、字体层级、边框、间距和简单内联 SVG 上,写进 CSS,按本次能写稳的程度完成。
|
||||||
```
|
```
|
||||||
|
|
||||||
## probe
|
## probe
|
||||||
@@ -130,7 +131,7 @@ HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变
|
|||||||
],
|
],
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"html": "<div id=\"zero-layer-app\">完整稳定外壳</div>",
|
"html": "<div id=\"zero-layer-app\">完整稳定外壳</div>",
|
||||||
"css": "完整样式与响应式规则",
|
"css": "完整样式;根容器 width:100%;行长只限正文柱;含响应式规则",
|
||||||
"js": "使用 zeroLayer.onInit(renderCard) 与 zeroLayer.onPatch(applyPatch) 的完整代码"
|
"js": "使用 zeroLayer.onInit(renderCard) 与 zeroLayer.onPatch(applyPatch) 的完整代码"
|
||||||
},
|
},
|
||||||
"预览": {
|
"预览": {
|
||||||
@@ -153,7 +154,7 @@ HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变
|
|||||||
|
|
||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON;槽位落点覆盖 0层回复呈现的全部可见槽位,id 与更新模式一致。
|
1. 输出合法 JSON;槽位落点覆盖 0层回复呈现的全部可见槽位,id 与更新模式一致。
|
||||||
2. `frontend.html/css/js` 完整且总量不超过 60 KB;JS 必须实现初始化与增量更新入口。
|
2. `frontend.html/css/js` 完整且总量不超过 60 KB;JS 必须实现初始化与增量更新入口。根容器铺满宿主宽度;不要整壳 `max-width` 居中卡片。
|
||||||
3. 后续回合只提交 `zero-layer.update.v1`,不重发 HTML 或完整卡。
|
3. 后续回合只提交 `zero-layer.update.v1`,不重发 HTML 或完整卡。
|
||||||
4. `data` 槽只接受程序权威投影;隐藏维护通道不进入 DOM。
|
4. `data` 槽只接受程序权威投影;隐藏维护通道不进入 DOM。
|
||||||
5. 更新必须带 `base_revision`,由程序校验后原子提交;失败时保持旧卡。
|
5. 更新必须带 `base_revision`,由程序校验后原子提交;失败时保持旧卡。
|
||||||
@@ -182,9 +183,9 @@ HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变
|
|||||||
- 1:模型每轮重编权威状态。
|
- 1:模型每轮重编权威状态。
|
||||||
|
|
||||||
界面结构:
|
界面结构:
|
||||||
- 5:主工作面明确,Tab 和辅助区服务本局体验,窄屏仍可用。
|
- 5:主工作面明确;外壳铺满宿主宽度;Tab 与辅助区服务本局体验,窄屏仍可用。
|
||||||
- 3:总体可读,但一个辅助区抢占注意力。
|
- 3:总体可读,但一个辅助区抢占注意力,或整壳被限宽成居中卡片。
|
||||||
- 1:机械地堆满 Tab,没有主次。
|
- 1:机械地堆满 Tab,没有主次,或外壳明显窄于宿主。
|
||||||
|
|
||||||
实现与安全:
|
实现与安全:
|
||||||
- 5:代码完整、安全渲染、无联网与回传,结构化区域有降级。
|
- 5:代码完整、安全渲染、无联网与回传,结构化区域有降级。
|
||||||
@@ -199,7 +200,7 @@ HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变
|
|||||||
- [ ] 外壳只初始化一次,后续只应用 slot_updates?
|
- [ ] 外壳只初始化一次,后续只应用 slot_updates?
|
||||||
- [ ] revision、原子提交与失败保持旧值完整?
|
- [ ] revision、原子提交与失败保持旧值完整?
|
||||||
- [ ] 权威状态没有被模型重复生成,隐藏维护区未进 DOM?
|
- [ ] 权威状态没有被模型重复生成,隐藏维护区未进 DOM?
|
||||||
- [ ] 前端完整、安全、无正则依赖与外部联网?
|
- [ ] 前端完整、安全、根容器铺满宿主宽度、无正则依赖与外部联网?
|
||||||
- [ ] 美化已用配色、字体层级、边框、间距或简单 SVG 写进 CSS,程度限于本次能写稳的部分?
|
- [ ] 美化已用配色、字体层级、边框、间距或简单 SVG 写进 CSS,程度限于本次能写稳的部分?
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ id: alongside
|
|||||||
## note
|
## note
|
||||||
|
|
||||||
```note
|
```note
|
||||||
不替换:正文和结构同屏。窄屏改为上下。选项在最下,直接可见。结构用创作时固定的绘图器,游玩灌入拓扑。
|
不替换:外壳铺满宿主宽度;正文和结构同屏。窄屏改为上下。选项在最下,直接可见。结构用创作时固定的绘图器,游玩灌入拓扑。
|
||||||
可替换:两栏谁更宽、抬头文字、结构块的短名、配色和字体。本局没有结构时改用阅读或正文为主。
|
可替换:两栏谁更宽、抬头文字、结构块的短名、配色和字体。本局没有结构时改用阅读或正文为主。
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -32,19 +32,19 @@ id: alongside
|
|||||||
```sample
|
```sample
|
||||||
html:
|
html:
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<p id="heading"></p>
|
<p id="heading" data-block="heading"></p>
|
||||||
<div class="pair">
|
<div class="pair">
|
||||||
<article id="body"></article>
|
<article id="body" data-block="body"></article>
|
||||||
<section id="structure">
|
<section id="structure">
|
||||||
<div id="graph"></div>
|
<div id="graph" data-block="structure"></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<ol id="options"></ol>
|
<ol id="options" data-block="options"></ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
css:
|
css:
|
||||||
:root { color: #1c1915; background: #f7f3ea; }
|
:root { color: #1c1915; background: #f7f3ea; }
|
||||||
#app { max-width: 56rem; margin: 0 auto; padding: 1.25rem; font: 16px/1.6 "Palatino Linotype", "Songti SC", serif; }
|
#app { width: 100%; box-sizing: border-box; margin: 0; padding: 1.25rem; font: 16px/1.6 "Palatino Linotype", "Songti SC", serif; }
|
||||||
.pair { display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 1rem; }
|
.pair { display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 1rem; }
|
||||||
#structure { border: 1px solid #d5cbb8; border-radius: 6px; padding: 0.8rem; background: #fffdf8; }
|
#structure { border: 1px solid #d5cbb8; border-radius: 6px; padding: 0.8rem; background: #fffdf8; }
|
||||||
#graph { min-height: 8rem; }
|
#graph { min-height: 8rem; }
|
||||||
@@ -54,17 +54,8 @@ css:
|
|||||||
js:
|
js:
|
||||||
present.onData(function (data) {
|
present.onData(function (data) {
|
||||||
var blocks = (data && data.blocks) || {};
|
var blocks = (data && data.blocks) || {};
|
||||||
putText("heading", blocks.heading);
|
|
||||||
putText("body", blocks.body);
|
|
||||||
putGraph("graph", blocks.structure);
|
putGraph("graph", blocks.structure);
|
||||||
putList("options", blocks.options);
|
|
||||||
});
|
});
|
||||||
function putText(id, value) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
if (value == null || value === "") { el.remove(); return; }
|
|
||||||
el.textContent = String(value);
|
|
||||||
}
|
|
||||||
function putGraph(id, value) {
|
function putGraph(id, value) {
|
||||||
var el = document.getElementById(id);
|
var el = document.getElementById(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -110,15 +101,4 @@ function putGraph(id, value) {
|
|||||||
});
|
});
|
||||||
el.appendChild(svg);
|
el.appendChild(svg);
|
||||||
}
|
}
|
||||||
function putList(id, value) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
el.replaceChildren();
|
|
||||||
if (!Array.isArray(value) || !value.length) { el.remove(); return; }
|
|
||||||
value.forEach(function (line) {
|
|
||||||
var li = document.createElement("li");
|
|
||||||
li.textContent = String(line);
|
|
||||||
el.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ id: data-cards
|
|||||||
## note
|
## note
|
||||||
|
|
||||||
```note
|
```note
|
||||||
不替换:数据区在抬头下一直展开。正文在其下较宽的一栏,篇幅仍是数百字。窄栏在较窄的一栏。选项在两栏之下,直接可见。窄屏时宽栏在上。
|
不替换:外壳铺满宿主宽度;数据区在抬头下一直展开。正文在其下较宽的一栏,篇幅仍是数百字。窄栏在较窄的一栏。选项在两栏之下,直接可见。窄屏时宽栏在上。
|
||||||
可替换:数据项的名称、条数和当前值,窄栏的标题和条目,配色和字体。本局没有窄栏时删掉窄栏,正文占回整宽。
|
可替换:数据项的名称、条数和当前值,窄栏的标题和条目,配色和字体。本局没有窄栏时删掉窄栏,正文占回整宽。
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -34,21 +34,21 @@ id: data-cards
|
|||||||
```sample
|
```sample
|
||||||
html:
|
html:
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<p id="heading"></p>
|
<p id="heading" data-block="heading"></p>
|
||||||
<div id="cards"></div>
|
<div id="cards"></div>
|
||||||
<div class="pair">
|
<div class="pair">
|
||||||
<article id="body"></article>
|
<article id="body" data-block="body"></article>
|
||||||
<aside class="side">
|
<aside class="side">
|
||||||
<p id="side-title"></p>
|
<p id="side-title" data-block="side_title"></p>
|
||||||
<div id="side"></div>
|
<div id="side"></div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<ol id="options"></ol>
|
<ol id="options" data-block="options"></ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
css:
|
css:
|
||||||
:root { color: #1c2420; background: #d5ddd4; }
|
:root { color: #1c2420; background: #d5ddd4; }
|
||||||
#app { max-width: 52rem; margin: 0 auto; padding: 1rem; font: 15px/1.75 "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
#app { width: 100%; box-sizing: border-box; margin: 0; padding: 1rem; font: 15px/1.75 "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||||
#heading { margin: 0 0 0.6rem; font-size: 13px; }
|
#heading { margin: 0 0 0.6rem; font-size: 13px; }
|
||||||
#cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.45rem; margin-bottom: 0.75rem; }
|
#cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.45rem; margin-bottom: 0.75rem; }
|
||||||
#cards.stack { grid-template-columns: 1fr; }
|
#cards.stack { grid-template-columns: 1fr; }
|
||||||
@@ -66,19 +66,9 @@ css:
|
|||||||
js:
|
js:
|
||||||
present.onData(function (data) {
|
present.onData(function (data) {
|
||||||
var blocks = (data && data.blocks) || {};
|
var blocks = (data && data.blocks) || {};
|
||||||
putText("heading", blocks.heading);
|
|
||||||
putText("side-title", blocks.side_title);
|
|
||||||
putText("body", blocks.body);
|
|
||||||
putCards("cards", blocks.cards);
|
putCards("cards", blocks.cards);
|
||||||
putLines("side", blocks.side);
|
putLines("side", blocks.side);
|
||||||
putList("options", blocks.options);
|
|
||||||
});
|
});
|
||||||
function putText(id, value) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
if (value == null || value === "") { el.remove(); return; }
|
|
||||||
el.textContent = String(value);
|
|
||||||
}
|
|
||||||
function putCards(id, value) {
|
function putCards(id, value) {
|
||||||
var el = document.getElementById(id);
|
var el = document.getElementById(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -111,15 +101,4 @@ function putLines(id, value) {
|
|||||||
el.appendChild(p);
|
el.appendChild(p);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function putList(id, value) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
el.replaceChildren();
|
|
||||||
if (!Array.isArray(value) || !value.length) { el.remove(); return; }
|
|
||||||
value.forEach(function (line) {
|
|
||||||
var li = document.createElement("li");
|
|
||||||
li.textContent = String(line);
|
|
||||||
el.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ id: dual-text
|
|||||||
可加: 短文、结构、数据、抬头、文字选项
|
可加: 短文、结构、数据、抬头、文字选项
|
||||||
摆放:
|
摆放:
|
||||||
抬头: 最上的一行短内容
|
抬头: 最上的一行短内容
|
||||||
正文: 占满阅读宽度,按数百字来写
|
正文: 占满宿主阅读面;行长可限在正文柱,按数百字来写
|
||||||
短文、结构: 标签或折叠,点开才看。标签用短显示名
|
短文、结构: 标签或折叠,点开才看。标签用短显示名
|
||||||
数据: 若有,默认可收起
|
数据: 若有,默认可收起
|
||||||
文字选项: 始终直接可见,不放进标签或折叠
|
文字选项: 始终直接可见,不放进标签或折叠
|
||||||
@@ -24,8 +24,8 @@ id: dual-text
|
|||||||
## note
|
## note
|
||||||
|
|
||||||
```note
|
```note
|
||||||
不替换:正文占满阅读宽度。其余块收在标签或折叠里,默认不展开。选项在这些控件之外,直接可见。
|
不替换:外壳铺满宿主宽度;正文是主阅读面。其余块收在标签或折叠里,默认不展开。选项在这些控件之外,直接可见。
|
||||||
可替换:标签和折叠的短名、收起块里的文字、配色和字体。本局没有的块删掉。数据要一直展开时改用数据常驻。
|
可替换:标签和折叠的短名、收起块里的文字、配色和字体、正文柱行长上限。本局没有的块删掉。数据要一直展开时改用数据常驻。
|
||||||
```
|
```
|
||||||
|
|
||||||
## sample
|
## sample
|
||||||
@@ -33,47 +33,26 @@ id: dual-text
|
|||||||
```sample
|
```sample
|
||||||
html:
|
html:
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<p id="heading"></p>
|
<p id="heading" data-block="heading"></p>
|
||||||
<article id="body"></article>
|
<article id="body" data-block="body"></article>
|
||||||
<details>
|
<details>
|
||||||
<summary id="vignette-title"></summary>
|
<summary id="vignette-title" data-block="vignette_title"></summary>
|
||||||
<div id="vignette"></div>
|
<div id="vignette" data-block="vignette"></div>
|
||||||
</details>
|
</details>
|
||||||
<ol id="options"></ol>
|
<ol id="options" data-block="options"></ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
css:
|
css:
|
||||||
:root { color: #1c1915; background: #f7f3ea; }
|
:root { color: #1c1915; background: #f7f3ea; }
|
||||||
#app { max-width: 40rem; margin: 0 auto; padding: 1.25rem; font: 17px/1.75 "Palatino Linotype", "Songti SC", serif; }
|
#app { width: 100%; box-sizing: border-box; margin: 0; padding: 1.25rem; font: 17px/1.75 "Palatino Linotype", "Songti SC", serif; }
|
||||||
|
#heading, #body, details, #options { max-width: 40rem; margin-inline: auto; }
|
||||||
#heading { margin: 0 0 0.8rem; font-size: 0.95rem; }
|
#heading { margin: 0 0 0.8rem; font-size: 0.95rem; }
|
||||||
#vignette { font-size: 0.92rem; margin-top: 0.6rem; }
|
#vignette { font-size: 0.92rem; margin-top: 0.6rem; }
|
||||||
#options { margin: 0.8rem 0 0; padding-left: 1.2rem; }
|
#options { margin: 0.8rem auto 0; padding-left: 1.2rem; }
|
||||||
summary { cursor: pointer; }
|
summary { cursor: pointer; }
|
||||||
|
|
||||||
js:
|
js:
|
||||||
present.onData(function (data) {
|
present.onData(function () {
|
||||||
var blocks = (data && data.blocks) || {};
|
/* 文本区由程序按 data-block 灌数;折叠开合是浏览器原生行为 */
|
||||||
putText("heading", blocks.heading);
|
|
||||||
putText("body", blocks.body);
|
|
||||||
putText("vignette-title", blocks.vignette_title);
|
|
||||||
putText("vignette", blocks.vignette);
|
|
||||||
putList("options", blocks.options);
|
|
||||||
});
|
});
|
||||||
function putText(id, value) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
if (value == null || value === "") { el.remove(); return; }
|
|
||||||
el.textContent = String(value);
|
|
||||||
}
|
|
||||||
function putList(id, value) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
el.replaceChildren();
|
|
||||||
if (!Array.isArray(value) || !value.length) { el.remove(); return; }
|
|
||||||
value.forEach(function (line) {
|
|
||||||
var li = document.createElement("li");
|
|
||||||
li.textContent = String(line);
|
|
||||||
el.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ id: paged
|
|||||||
## note
|
## note
|
||||||
|
|
||||||
```note
|
```note
|
||||||
不替换:同时只显示一页。页用一行标签切换。抬头在标签之外。
|
不替换:外壳铺满宿主宽度;同时只显示一页。页用一行标签切换。抬头在标签之外。
|
||||||
可替换:每个标签的短名、页内文字和数据项、配色和字体。本局未启用的页不建标签。
|
可替换:每个标签的短名、页内文字和数据项、配色和字体、页内正文柱行长上限。本局未启用的页不建标签。
|
||||||
```
|
```
|
||||||
|
|
||||||
## sample
|
## sample
|
||||||
@@ -32,14 +32,15 @@ id: paged
|
|||||||
```sample
|
```sample
|
||||||
html:
|
html:
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<p id="heading"></p>
|
<p id="heading" data-block="heading"></p>
|
||||||
<div id="tabs"></div>
|
<div id="tabs"></div>
|
||||||
<div id="page"></div>
|
<div id="page" data-block="page"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
css:
|
css:
|
||||||
:root { color: #1c1915; background: #f7f3ea; }
|
:root { color: #1c1915; background: #f7f3ea; }
|
||||||
#app { max-width: 44rem; margin: 0 auto; padding: 1.25rem; font: 16px/1.65 "Segoe UI", "Source Han Sans SC", sans-serif; }
|
#app { width: 100%; box-sizing: border-box; margin: 0; padding: 1.25rem; font: 16px/1.65 "Segoe UI", "Source Han Sans SC", sans-serif; }
|
||||||
|
#heading, #tabs, #page { max-width: 44rem; margin-inline: auto; }
|
||||||
#tabs { display: flex; gap: 0.4rem; margin-bottom: 0.9rem; }
|
#tabs { display: flex; gap: 0.4rem; margin-bottom: 0.9rem; }
|
||||||
#tabs button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 0.35rem 0.6rem; font: inherit; color: inherit; }
|
#tabs button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 0.35rem 0.6rem; font: inherit; color: inherit; }
|
||||||
#tabs button[aria-current="true"] { border-bottom-color: #8a5a2b; }
|
#tabs button[aria-current="true"] { border-bottom-color: #8a5a2b; }
|
||||||
@@ -49,7 +50,6 @@ var pages = [];
|
|||||||
var current = 0;
|
var current = 0;
|
||||||
present.onData(function (data) {
|
present.onData(function (data) {
|
||||||
var blocks = (data && data.blocks) || {};
|
var blocks = (data && data.blocks) || {};
|
||||||
putText("heading", blocks.heading);
|
|
||||||
pages = Array.isArray(blocks.pages) ? blocks.pages : [];
|
pages = Array.isArray(blocks.pages) ? blocks.pages : [];
|
||||||
current = 0;
|
current = 0;
|
||||||
drawTabs();
|
drawTabs();
|
||||||
@@ -73,12 +73,7 @@ function drawTabs() {
|
|||||||
}
|
}
|
||||||
function drawPage() {
|
function drawPage() {
|
||||||
var el = document.getElementById("page");
|
var el = document.getElementById("page");
|
||||||
el.textContent = pages[current] ? pages[current].text : "";
|
var text = pages[current] ? pages[current].text : "";
|
||||||
}
|
el.innerHTML = text ? present.markdown(String(text)) : "";
|
||||||
function putText(id, value) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
if (value == null || value === "") { el.remove(); return; }
|
|
||||||
el.textContent = String(value);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ id: piece
|
|||||||
主区: 一份成稿
|
主区: 一份成稿
|
||||||
可加窄带: 文字选项
|
可加窄带: 文字选项
|
||||||
摆放:
|
摆放:
|
||||||
成稿: 居中。内部小节在创作时按范例重写
|
成稿: 外壳铺满宿主;成稿正文柱可限行长并水平居中
|
||||||
文字选项: 成稿之后,始终直接可见
|
文字选项: 成稿之后,始终直接可见
|
||||||
未启用的区不留空位。
|
未启用的区不留空位。
|
||||||
```
|
```
|
||||||
@@ -21,8 +21,8 @@ id: piece
|
|||||||
## note
|
## note
|
||||||
|
|
||||||
```note
|
```note
|
||||||
不替换:一份成稿容器居中。选项在容器之后,直接可见。
|
不替换:外壳铺满宿主宽度;一份成稿容器。选项在容器之后,直接可见。
|
||||||
可替换:容器内部的标记、分段、配色和字体。按本局这一份成品重写内部。样本内部与本局不同时整段替换,保留容器和选项的前后关系。
|
可替换:容器内部的标记、分段、配色和字体、成稿柱行长上限。按本局这一份成品重写内部。样本内部与本局不同时整段替换,保留容器和选项的前后关系。
|
||||||
```
|
```
|
||||||
|
|
||||||
## sample
|
## sample
|
||||||
@@ -30,37 +30,19 @@ id: piece
|
|||||||
```sample
|
```sample
|
||||||
html:
|
html:
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<article id="piece"></article>
|
<article id="piece" data-block="piece"></article>
|
||||||
<ol id="options"></ol>
|
<ol id="options" data-block="options"></ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
css:
|
css:
|
||||||
:root { color: #1c1916; background: #ebe6dc; }
|
:root { color: #1c1916; background: #ebe6dc; }
|
||||||
#app { max-width: 34rem; margin: 0 auto; padding: 1.6rem 1.25rem 2rem; font: 16px/1.65 "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
#app { width: 100%; box-sizing: border-box; margin: 0; padding: 1.6rem 1.25rem 2rem; font: 16px/1.65 "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||||
#piece { margin: 0; }
|
#piece, #options { max-width: 34rem; margin-inline: auto; }
|
||||||
#options { margin: 1rem 0 0; padding-left: 1.15rem; }
|
#piece { margin: 0 auto; }
|
||||||
|
#options { margin: 1rem auto 0; padding-left: 1.15rem; }
|
||||||
|
|
||||||
js:
|
js:
|
||||||
present.onData(function (data) {
|
present.onData(function () {
|
||||||
var blocks = (data && data.blocks) || {};
|
/* 成稿与选项由程序按 data-block 灌数 */
|
||||||
putText("piece", blocks.piece);
|
|
||||||
putList("options", blocks.options);
|
|
||||||
});
|
});
|
||||||
function putText(id, value) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
if (value == null || value === "") { el.remove(); return; }
|
|
||||||
el.textContent = String(value);
|
|
||||||
}
|
|
||||||
function putList(id, value) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
el.replaceChildren();
|
|
||||||
if (!Array.isArray(value) || !value.length) { el.remove(); return; }
|
|
||||||
value.forEach(function (line) {
|
|
||||||
var li = document.createElement("li");
|
|
||||||
li.textContent = String(line);
|
|
||||||
el.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ id: reading
|
|||||||
可加窄带: 抬头、文字选项
|
可加窄带: 抬头、文字选项
|
||||||
摆放:
|
摆放:
|
||||||
抬头: 正文之前的一行短内容
|
抬头: 正文之前的一行短内容
|
||||||
正文: 阅读柱,占主要宽度
|
正文: 阅读柱;外壳铺满宿主,行长可限在正文柱
|
||||||
文字选项: 正文之后,始终直接可见,不放进标签或折叠。不显示「文字选项」这个区名
|
文字选项: 正文之后,始终直接可见,不放进标签或折叠。不显示「文字选项」这个区名
|
||||||
未启用的区不留空位。
|
未启用的区不留空位。
|
||||||
```
|
```
|
||||||
@@ -22,8 +22,8 @@ id: reading
|
|||||||
## note
|
## note
|
||||||
|
|
||||||
```note
|
```note
|
||||||
不替换:单栏阅读柱。抬头在正文前,独占一行。选项在正文后,直接可见。
|
不替换:外壳铺满宿主宽度;单栏阅读柱。抬头在正文前,独占一行。选项在正文后,直接可见。
|
||||||
可替换:抬头文字、正文文字、配色和字体。本局没有抬头时删掉抬头。
|
可替换:抬头文字、正文文字、配色和字体、正文柱行长上限。本局没有抬头时删掉抬头。
|
||||||
```
|
```
|
||||||
|
|
||||||
## sample
|
## sample
|
||||||
@@ -31,53 +31,21 @@ id: reading
|
|||||||
```sample
|
```sample
|
||||||
html:
|
html:
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<p id="heading"></p>
|
<p id="heading" data-block="heading"></p>
|
||||||
<article id="body"></article>
|
<article id="body" data-block="body"></article>
|
||||||
<ol id="options"></ol>
|
<ol id="options" data-block="options"></ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
css:
|
css:
|
||||||
:root { color: #1c1915; background: #f7f3ea; }
|
:root { color: #1c1915; background: #f7f3ea; }
|
||||||
#app { max-width: 38rem; margin: 0 auto; padding: 1.5rem 1.25rem 2.5rem; font: 17px/1.75 "Palatino Linotype", "Songti SC", serif; }
|
#app { width: 100%; box-sizing: border-box; margin: 0; padding: 1.5rem 1.25rem 2.5rem; font: 17px/1.75 "Palatino Linotype", "Songti SC", serif; }
|
||||||
|
#heading, #body, #options { max-width: 38rem; margin-inline: auto; }
|
||||||
#heading { margin: 0 0 0.85rem; font-size: 0.95rem; letter-spacing: 0.04em; }
|
#heading { margin: 0 0 0.85rem; font-size: 0.95rem; letter-spacing: 0.04em; }
|
||||||
#body { font-size: 1.05rem; }
|
#body { font-size: 1.05rem; }
|
||||||
#options { margin: 1.25rem 0 0; padding-left: 1.2rem; }
|
#options { margin: 1.25rem auto 0; padding-left: 1.2rem; }
|
||||||
|
|
||||||
js:
|
js:
|
||||||
present.onData(function (data) {
|
present.onData(function () {
|
||||||
var blocks = (data && data.blocks) || {};
|
/* 正文/抬头/选项由程序按 data-block 灌数;本地交互再写这里 */
|
||||||
putHeading("heading", blocks.heading);
|
|
||||||
putText("body", blocks.body);
|
|
||||||
putList("options", blocks.options);
|
|
||||||
});
|
});
|
||||||
function putHeading(id, value) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
el.replaceChildren();
|
|
||||||
if (value == null || value === "") { el.remove(); return; }
|
|
||||||
if (typeof value === "string") { el.textContent = value; return; }
|
|
||||||
Object.keys(value).forEach(function (key) {
|
|
||||||
var item = document.createElement("span");
|
|
||||||
item.textContent = key + " " + String(value[key]);
|
|
||||||
item.style.marginRight = "1rem";
|
|
||||||
el.appendChild(item);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function putText(id, value) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
if (value == null || value === "") { el.remove(); return; }
|
|
||||||
el.textContent = String(value);
|
|
||||||
}
|
|
||||||
function putList(id, value) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
el.replaceChildren();
|
|
||||||
if (!Array.isArray(value) || !value.length) { el.remove(); return; }
|
|
||||||
value.forEach(function (line) {
|
|
||||||
var li = document.createElement("li");
|
|
||||||
li.textContent = String(line);
|
|
||||||
el.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -9,7 +9,20 @@ import {
|
|||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { BookProject, BookSummary } from "../types/book.js";
|
import type {
|
||||||
|
BookCreationMode,
|
||||||
|
BookProject,
|
||||||
|
BookSummary,
|
||||||
|
} from "../types/book.js";
|
||||||
|
|
||||||
|
function normalizeBookCreationMode(
|
||||||
|
raw: string | undefined | null,
|
||||||
|
): BookCreationMode {
|
||||||
|
if (raw === "listen_serial" || raw === "recipe" || raw === "dictate") {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return "dictate";
|
||||||
|
}
|
||||||
import type { PersistedBookSession } from "../types/book-session.js";
|
import type { PersistedBookSession } from "../types/book-session.js";
|
||||||
import type { RunSnapshot } from "../types/run-snapshot.js";
|
import type { RunSnapshot } from "../types/run-snapshot.js";
|
||||||
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
|
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
|
||||||
@@ -57,6 +70,7 @@ export function listBooks(): BookSummary[] {
|
|||||||
title: b.title,
|
title: b.title,
|
||||||
activeSkillId: b.activeSkillId ?? b.orchestratorId,
|
activeSkillId: b.activeSkillId ?? b.orchestratorId,
|
||||||
activeSkillName: b.activeSkillName ?? b.orchestratorName,
|
activeSkillName: b.activeSkillName ?? b.orchestratorName,
|
||||||
|
creationMode: normalizeBookCreationMode(b.creationMode),
|
||||||
preview: b.preview,
|
preview: b.preview,
|
||||||
updatedAt: b.updatedAt,
|
updatedAt: b.updatedAt,
|
||||||
orchestratorId: b.orchestratorId,
|
orchestratorId: b.orchestratorId,
|
||||||
@@ -67,19 +81,27 @@ export function listBooks(): BookSummary[] {
|
|||||||
export function getBook(id: string): BookProject | null {
|
export function getBook(id: string): BookProject | null {
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(bookPath(id), "utf8");
|
const raw = readFileSync(bookPath(id), "utf8");
|
||||||
return JSON.parse(raw) as BookProject;
|
const book = JSON.parse(raw) as BookProject;
|
||||||
|
return {
|
||||||
|
...book,
|
||||||
|
creationMode: normalizeBookCreationMode(book.creationMode),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBook(input: { title?: string }): BookProject {
|
export function createBook(input: {
|
||||||
|
title?: string;
|
||||||
|
creationMode?: BookCreationMode;
|
||||||
|
}): BookProject {
|
||||||
ensureUserDataDirs();
|
ensureUserDataDirs();
|
||||||
mkdirSync(booksDir(), { recursive: true });
|
mkdirSync(booksDir(), { recursive: true });
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const book: BookProject = {
|
const book: BookProject = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
title: input.title?.trim() || "未命名作品",
|
title: input.title?.trim() || "未命名作品",
|
||||||
|
creationMode: normalizeBookCreationMode(input.creationMode),
|
||||||
preview: "新建作品,描述你想创作什么…",
|
preview: "新建作品,描述你想创作什么…",
|
||||||
sessionIds: [],
|
sessionIds: [],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -100,6 +122,7 @@ export function updateBook(
|
|||||||
| "activeSessionId"
|
| "activeSessionId"
|
||||||
| "activeSkillId"
|
| "activeSkillId"
|
||||||
| "activeSkillName"
|
| "activeSkillName"
|
||||||
|
| "creationMode"
|
||||||
| "orchestratorId"
|
| "orchestratorId"
|
||||||
| "orchestratorName"
|
| "orchestratorName"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,42 +6,140 @@ import {
|
|||||||
type DictateProduct,
|
type DictateProduct,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
|
export type DictateContextPhase = "select" | "fill" | "reply";
|
||||||
|
|
||||||
|
/** 五段字数,只给用量统计,不另存正文。 */
|
||||||
|
export type DictateSegmentChars = {
|
||||||
|
rules: number;
|
||||||
|
history: number;
|
||||||
|
products: number;
|
||||||
|
injected: number;
|
||||||
|
tail: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DictateBuiltContext = {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
segmentChars: DictateSegmentChars;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拼给模型的上下文:系统提示 + 全部产物(按相对序)+ 全量对话。
|
* 创作请求的块顺序:固定规则、更早的对话、全部产物、本轮注入、末尾。
|
||||||
* 清空对话后,只剩产物块,产物即浓缩上下文。
|
* 选题不带产物正文和本轮注入。回复步只带本轮刚落盘产物与末句用户话。
|
||||||
|
* 更早对话只追加,最后一轮用户输入放在末尾。
|
||||||
*/
|
*/
|
||||||
|
export function buildDictateContext(params: {
|
||||||
|
systemPrompt: string;
|
||||||
|
products: DictateProduct[];
|
||||||
|
dialogue: DictateChatTurn[];
|
||||||
|
phase?: DictateContextPhase;
|
||||||
|
injected?: string;
|
||||||
|
tail?: string;
|
||||||
|
}): DictateBuiltContext {
|
||||||
|
const phase = params.phase ?? "fill";
|
||||||
|
if (phase === "reply") {
|
||||||
|
return buildReplyContext(params);
|
||||||
|
}
|
||||||
|
const { earlier, lastUser } = splitEarlierDialogue(params.dialogue);
|
||||||
|
const history = formatEarlierDialogue(earlier);
|
||||||
|
const products =
|
||||||
|
phase === "fill" ? formatProducts(sortDictateProducts(params.products)) : "";
|
||||||
|
const injected = phase === "fill" ? params.injected?.trim() ?? "" : "";
|
||||||
|
const tail = joinBlocks([params.tail?.trim() ?? "", lastUser ? `最后一轮用户输入\n${lastUser}` : ""]);
|
||||||
|
|
||||||
|
const fillDuty =
|
||||||
|
phase === "fill"
|
||||||
|
? "## 本步收尾\n\n写完本轮节点表后,同一次请求必须调用 reply_user。只 insert 不算完成。"
|
||||||
|
: "";
|
||||||
|
const sections = [
|
||||||
|
fillDuty,
|
||||||
|
history,
|
||||||
|
products ? `## 当前产物(已写入黑板,按相对序;对话清空后仍保留)\n\n${products}` : "",
|
||||||
|
injected
|
||||||
|
? `## 本轮注入\n\n写完下列节点后,同一次请求必须调用 reply_user。\n\n本轮节点表\n\n${injected}`
|
||||||
|
: "",
|
||||||
|
tail ? `## 本轮\n\n${tail}` : "",
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const user = sections.join("\n\n");
|
||||||
|
return {
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: params.systemPrompt },
|
||||||
|
{ role: "user", content: user },
|
||||||
|
],
|
||||||
|
segmentChars: {
|
||||||
|
rules: params.systemPrompt.length,
|
||||||
|
history: history.length,
|
||||||
|
products: products.length,
|
||||||
|
injected: injected.length,
|
||||||
|
tail: tail.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回复兜底:只给本轮产物 + 末句,不带全黑板与节点规格。 */
|
||||||
|
function buildReplyContext(params: {
|
||||||
|
systemPrompt: string;
|
||||||
|
products: DictateProduct[];
|
||||||
|
dialogue: DictateChatTurn[];
|
||||||
|
tail?: string;
|
||||||
|
}): DictateBuiltContext {
|
||||||
|
const { lastUser } = splitEarlierDialogue(params.dialogue);
|
||||||
|
const products = formatRoundProducts(params.products);
|
||||||
|
const tail = joinBlocks([
|
||||||
|
params.tail?.trim() ?? "",
|
||||||
|
lastUser ? `最后一轮用户输入\n${lastUser}` : "",
|
||||||
|
]);
|
||||||
|
const sections = [
|
||||||
|
products ? `## 本轮刚落盘\n\n${products}` : "## 本轮刚落盘\n\n(无正文产物;按删除与用户原话回复)",
|
||||||
|
tail ? `## 本轮\n\n${tail}` : "",
|
||||||
|
].filter(Boolean);
|
||||||
|
const user = sections.join("\n\n");
|
||||||
|
return {
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: params.systemPrompt },
|
||||||
|
{ role: "user", content: user },
|
||||||
|
],
|
||||||
|
segmentChars: {
|
||||||
|
rules: params.systemPrompt.length,
|
||||||
|
history: 0,
|
||||||
|
products: products.length,
|
||||||
|
injected: 0,
|
||||||
|
tail: tail.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoundProducts(products: DictateProduct[]): string {
|
||||||
|
if (products.length === 0) return "";
|
||||||
|
return sortDictateProducts(products)
|
||||||
|
.map((product) => {
|
||||||
|
const score = formatSelfScore(product);
|
||||||
|
return [`### ${product.tag}`, score, product.content].filter(Boolean).join("\n");
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSelfScore(product: DictateProduct): string {
|
||||||
|
const dims = product.selfScore?.dims;
|
||||||
|
if (!dims?.length) return "";
|
||||||
|
const lines = dims.map((dim) => {
|
||||||
|
const gap = dim.gap?.trim() ? `;缺口:${dim.gap.trim()}` : "";
|
||||||
|
const options =
|
||||||
|
dim.options?.length ? `;选项:${dim.options.map((item) => item.trim()).filter(Boolean).join(" / ")}` : "";
|
||||||
|
return `- ${dim.name}:${dim.score}${gap}${options}`;
|
||||||
|
});
|
||||||
|
return `self_score:\n${lines.join("\n")}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDictateMessages(params: {
|
export function buildDictateMessages(params: {
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
products: DictateProduct[];
|
products: DictateProduct[];
|
||||||
dialogue: DictateChatTurn[];
|
dialogue: DictateChatTurn[];
|
||||||
|
phase?: DictateContextPhase;
|
||||||
|
injected?: string;
|
||||||
|
tail?: string;
|
||||||
}): ChatMessage[] {
|
}): ChatMessage[] {
|
||||||
const parts: string[] = [];
|
return buildDictateContext(params).messages;
|
||||||
const products = sortDictateProducts(params.products);
|
|
||||||
|
|
||||||
if (products.length > 0) {
|
|
||||||
parts.push("## 当前产物(已写入黑板,按相对序;对话清空后仍保留)");
|
|
||||||
for (const p of products) {
|
|
||||||
const ord = effectiveDictateOrder(p);
|
|
||||||
parts.push(`### ${p.tag} 〔order=${ord}〕\n${p.content}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
parts.push("## 当前产物\n(尚无)");
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.push("## 对话(全量)");
|
|
||||||
if (params.dialogue.length === 0) {
|
|
||||||
parts.push("(尚无对话;等用户开口)");
|
|
||||||
} else {
|
|
||||||
for (const turn of params.dialogue) {
|
|
||||||
const who = turn.role === "user" ? "用户" : "助手";
|
|
||||||
parts.push(`【${who}】\n${turn.text}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{ role: "system", content: params.systemPrompt },
|
|
||||||
{ role: "user", content: parts.join("\n\n") },
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从会话消息里抽出转述式对话(用户输入 + 助手回复) */
|
/** 从会话消息里抽出转述式对话(用户输入 + 助手回复) */
|
||||||
@@ -62,3 +160,37 @@ export function extractDictateDialogue(
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitEarlierDialogue(dialogue: DictateChatTurn[]): {
|
||||||
|
earlier: DictateChatTurn[];
|
||||||
|
lastUser: string;
|
||||||
|
} {
|
||||||
|
const last = dialogue.at(-1);
|
||||||
|
if (last?.role === "user") {
|
||||||
|
return { earlier: dialogue.slice(0, -1), lastUser: last.text.trim() };
|
||||||
|
}
|
||||||
|
return { earlier: dialogue, lastUser: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEarlierDialogue(dialogue: DictateChatTurn[]): string {
|
||||||
|
if (dialogue.length === 0) return "## 更早的对话\n\n(尚无更早对话)";
|
||||||
|
const lines = dialogue.map((turn) => {
|
||||||
|
const who = turn.role === "user" ? "用户" : "助手";
|
||||||
|
return `【${who}】\n${turn.text}`;
|
||||||
|
});
|
||||||
|
return `## 更早的对话\n\n${lines.join("\n\n")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProducts(products: DictateProduct[]): string {
|
||||||
|
if (products.length === 0) return "(尚无)";
|
||||||
|
return products
|
||||||
|
.map((product) => {
|
||||||
|
const ord = effectiveDictateOrder(product);
|
||||||
|
return `### ${product.tag} 〔order=${ord}〕\n${product.content}`;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinBlocks(blocks: string[]): string {
|
||||||
|
return blocks.map((block) => block.trim()).filter(Boolean).join("\n\n");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
catalogModulesForIntake,
|
catalogModulesForIntake,
|
||||||
|
isDictateProgramModule,
|
||||||
type ModuleCatalog,
|
type ModuleCatalog,
|
||||||
type ModuleCatalogEntry,
|
type ModuleCatalogEntry,
|
||||||
} from "../skills/creation-flow.js";
|
} from "../skills/creation-flow.js";
|
||||||
@@ -33,6 +34,53 @@ const ALL_PRESENTATION_MODULE_IDS = new Set(
|
|||||||
Object.values(PRESENTATION_MODULE_IDS).flat(),
|
Object.values(PRESENTATION_MODULE_IDS).flat(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 技能页按节点在对话落盘里的角色分组,不按旧配方包。 */
|
||||||
|
export const MODULE_BROWSE_GROUPS = [
|
||||||
|
{
|
||||||
|
id: "plan",
|
||||||
|
label: "本局方案",
|
||||||
|
hint: "抽出体验并给内容节点分档,不进入游玩",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "content",
|
||||||
|
label: "内容节点",
|
||||||
|
hint: "本局方案按档取用;这句话说清了再写",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "present",
|
||||||
|
label: "呈现链",
|
||||||
|
hint: "普通楼层与 0 层卡各用一套,两套不混用",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "program",
|
||||||
|
label: "落档程序",
|
||||||
|
hint: "开玩前由程序编译,创作对话不写入",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ModuleBrowseGroupId = (typeof MODULE_BROWSE_GROUPS)[number]["id"];
|
||||||
|
|
||||||
|
export function moduleBrowseGroup(module: {
|
||||||
|
id: string;
|
||||||
|
auto?: boolean;
|
||||||
|
}): ModuleBrowseGroupId {
|
||||||
|
if (module.id === "creation-plan") return "plan";
|
||||||
|
if (isDictateProgramModule(module)) return "program";
|
||||||
|
if (ALL_PRESENTATION_MODULE_IDS.has(module.id)) return "present";
|
||||||
|
return "content";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modulePresentationMode(
|
||||||
|
moduleId: string,
|
||||||
|
): CreationPresentationMode | null {
|
||||||
|
for (const mode of ["message", "zero_layer"] as const) {
|
||||||
|
if ((PRESENTATION_MODULE_IDS[mode] as readonly string[]).includes(moduleId)) {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export type CreationPlanItem = {
|
export type CreationPlanItem = {
|
||||||
module_id: string;
|
module_id: string;
|
||||||
tier: CreationPlanTier;
|
tier: CreationPlanTier;
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
export { buildDictateSystemPrompt, DICTATE_SYSTEM_PROMPT } from "./prompt.js";
|
export {
|
||||||
|
buildDictateReplyPrompt,
|
||||||
|
buildDictateSelectPrompt,
|
||||||
|
buildDictateSystemPrompt,
|
||||||
|
DICTATE_SYSTEM_PROMPT,
|
||||||
|
formatDictateRoundTail,
|
||||||
|
} from "./prompt.js";
|
||||||
|
export {
|
||||||
|
buildDictateMessages,
|
||||||
|
buildDictateContext,
|
||||||
|
extractDictateDialogue,
|
||||||
|
} from "./context.js";
|
||||||
export {
|
export {
|
||||||
CREATION_INTAKE_MODE_TAG,
|
CREATION_INTAKE_MODE_TAG,
|
||||||
DICTATE_MODE_VALUE,
|
DICTATE_MODE_VALUE,
|
||||||
@@ -16,10 +27,6 @@ export {
|
|||||||
type DictateSelfScore,
|
type DictateSelfScore,
|
||||||
type DictateChatTurn,
|
type DictateChatTurn,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
export {
|
|
||||||
buildDictateMessages,
|
|
||||||
extractDictateDialogue,
|
|
||||||
} from "./context.js";
|
|
||||||
export { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
|
export { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
|
||||||
export { runDictateTurn, type DictateTurnResult } from "./turn.js";
|
export { runDictateTurn, type DictateTurnResult } from "./turn.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
/** 对话落盘常用简化 tag → 能力 id(catalog.artifact 之外) */
|
/** 对话落盘常用简化 tag → 能力 id(catalog.artifact 之外) */
|
||||||
export const DICTATE_INSERT_TAG_ALIASES: Readonly<Record<string, string>> = {
|
export const DICTATE_INSERT_TAG_ALIASES: Readonly<Record<string, string>> = {
|
||||||
"设计.开场白": "opening-setup",
|
"设计.开场白": "opening-setup",
|
||||||
|
"设计.开场白与开场变量": "opening-setup",
|
||||||
"设计.美学纲领": "aesthetics-interaction",
|
"设计.美学纲领": "aesthetics-interaction",
|
||||||
"设计.文风": "narrative-guide",
|
"设计.文风": "narrative-guide",
|
||||||
"设计.监控栏": "status-bar",
|
"设计.监控栏": "status-bar",
|
||||||
@@ -54,7 +55,8 @@ export function resolveModuleForInsertTag(
|
|||||||
if (byFamily) return byFamily;
|
if (byFamily) return byFamily;
|
||||||
}
|
}
|
||||||
|
|
||||||
const aliasId = DICTATE_INSERT_TAG_ALIASES[t];
|
const family = t.includes("#") ? t.slice(0, t.indexOf("#")).trim() : t;
|
||||||
|
const aliasId = DICTATE_INSERT_TAG_ALIASES[t] ?? DICTATE_INSERT_TAG_ALIASES[family];
|
||||||
if (aliasId) {
|
if (aliasId) {
|
||||||
return catalog.modules.find((m) => m.id === aliasId) ?? null;
|
return catalog.modules.find((m) => m.id === aliasId) ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* 对话落盘 · 落档/开玩时自动把产物 tag 挂到游玩 worker。
|
* 对话落盘 · 落档/开玩时自动把产物 tag 挂到游玩 worker。
|
||||||
* 只做「固定上下文绑定」;变量清单、可见性、分档映射仍靠对话产出,此处不发明。
|
* 只做「固定上下文绑定」;变量清单、可见性、分档映射仍靠对话产出,此处不发明。
|
||||||
|
* 保存时按上游产物指纹决定是否重跑:没改过就保留已有排序和运行配置。
|
||||||
*/
|
*/
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import type { Blackboard } from "../blackboard/blackboard.js";
|
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||||
import {
|
import {
|
||||||
CONTEXT_ORDER_SCHEMA,
|
CONTEXT_ORDER_SCHEMA,
|
||||||
@@ -38,6 +40,12 @@ import {
|
|||||||
VALUE_MAP_TAG,
|
VALUE_MAP_TAG,
|
||||||
listValueMapTargetTags,
|
listValueMapTargetTags,
|
||||||
} from "../skills/value-map.js";
|
} from "../skills/value-map.js";
|
||||||
|
import {
|
||||||
|
applyMaintenanceStrategyToPlaySlots,
|
||||||
|
MAINTENANCE_STRATEGY_TAG,
|
||||||
|
resolveMaintenanceStrategyFromBoard,
|
||||||
|
writeMaintenanceStrategy,
|
||||||
|
} from "../skills/maintenance-strategy.js";
|
||||||
import {
|
import {
|
||||||
DICTATE_ORDER_META_KEY,
|
DICTATE_ORDER_META_KEY,
|
||||||
effectiveDictateOrder,
|
effectiveDictateOrder,
|
||||||
@@ -57,6 +65,7 @@ const SKIP_BIND_TAGS = new Set([
|
|||||||
CREATION_SELECTED_RECIPE_TAG,
|
CREATION_SELECTED_RECIPE_TAG,
|
||||||
VARIABLE_CATALOG_TAG,
|
VARIABLE_CATALOG_TAG,
|
||||||
VALUE_MAP_TAG,
|
VALUE_MAP_TAG,
|
||||||
|
MAINTENANCE_STRATEGY_TAG,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function shouldSkipBindTag(tag: string): boolean {
|
function shouldSkipBindTag(tag: string): boolean {
|
||||||
@@ -77,11 +86,16 @@ const STYLE_TAG_RE =
|
|||||||
/** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */
|
/** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */
|
||||||
const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]);
|
const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]);
|
||||||
|
|
||||||
|
/** 上次编译时所依据的上游指纹;两边产物一致且未过期则跳过重跑 */
|
||||||
|
export const DICTATE_PLAY_BIND_INPUT_META = "dictatePlayBindInput";
|
||||||
|
|
||||||
export type DictatePlayBindResult = {
|
export type DictatePlayBindResult = {
|
||||||
workerSetJson: string;
|
workerSetJson: string;
|
||||||
contextOrder: ContextOrderDoc;
|
contextOrder: ContextOrderDoc;
|
||||||
boundTags: string[];
|
boundTags: string[];
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
|
/** 上游产物未改,保留已有上下文排序与运行配置 */
|
||||||
|
skipped: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isStyleProductTag(tag: string): boolean {
|
function isStyleProductTag(tag: string): boolean {
|
||||||
@@ -314,7 +328,7 @@ function resolvePlaySlots(
|
|||||||
const parsed = existingRaw?.trim()
|
const parsed = existingRaw?.trim()
|
||||||
? parseWorkerSetYaml(existingRaw)
|
? parseWorkerSetYaml(existingRaw)
|
||||||
: null;
|
: null;
|
||||||
const slots = { ...(parsed?.play_slots ?? defaultPlaySlots()) };
|
let slots = { ...(parsed?.play_slots ?? defaultPlaySlots()) };
|
||||||
const recipeRef = parseSelectedRecipeRef(
|
const recipeRef = parseSelectedRecipeRef(
|
||||||
board.getContentByTag(CREATION_SELECTED_RECIPE_TAG),
|
board.getContentByTag(CREATION_SELECTED_RECIPE_TAG),
|
||||||
);
|
);
|
||||||
@@ -322,6 +336,9 @@ function resolvePlaySlots(
|
|||||||
slots.gm = true;
|
slots.gm = true;
|
||||||
slots.narrator = true;
|
slots.narrator = true;
|
||||||
}
|
}
|
||||||
|
// 变量设计.维护复杂度 → auditor true/false(内联则关旁观)
|
||||||
|
const strategy = resolveMaintenanceStrategyFromBoard(board);
|
||||||
|
slots = applyMaintenanceStrategyToPlaySlots(slots, strategy);
|
||||||
return slots;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +353,8 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin
|
|||||||
board.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() ||
|
board.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() ||
|
||||||
"";
|
"";
|
||||||
const playSlots = resolvePlaySlots(existing || undefined, board);
|
const playSlots = resolvePlaySlots(existing || undefined, board);
|
||||||
|
const strategy = resolveMaintenanceStrategyFromBoard(board);
|
||||||
|
writeMaintenanceStrategy(board, strategy, "runtime:play-bind");
|
||||||
const runtimeTags = collectRuntimeStateTags(board);
|
const runtimeTags = collectRuntimeStateTags(board);
|
||||||
const contextOrder = buildDictateContextOrder({
|
const contextOrder = buildDictateContextOrder({
|
||||||
products,
|
products,
|
||||||
@@ -371,26 +390,100 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin
|
|||||||
? row.brief
|
? row.brief
|
||||||
: "对话落盘:主世界出内容,叙事转述出文风";
|
: "对话落盘:主世界出内容,叙事转述出文风";
|
||||||
}
|
}
|
||||||
|
if (playSlots.auditor) {
|
||||||
|
const base =
|
||||||
|
typeof row.brief === "string" && row.brief.trim() ? String(row.brief) : "对话落盘落档自动生成";
|
||||||
|
if (!base.includes("旁观")) {
|
||||||
|
row.brief = `${base};旁观副 LLM 填表(固定)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
const withSlots = JSON.stringify(row, null, 2);
|
const withSlots = JSON.stringify(row, null, 2);
|
||||||
const workerSetJson = mergeContextOrderIntoWorkerSetJson(withSlots, contextOrder);
|
const workerSetJson = mergeContextOrderIntoWorkerSetJson(withSlots, contextOrder);
|
||||||
|
|
||||||
const boundTags = products.map((p) => p.tag);
|
const boundTags = products.map((p) => p.tag);
|
||||||
const changed = workerSetJson.trim() !== existing.trim();
|
const changed = workerSetJson.trim() !== existing.trim();
|
||||||
return { workerSetJson, contextOrder, boundTags, changed };
|
return { workerSetJson, contextOrder, boundTags, changed, skipped: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 写入黑板:设计.worker集 + 设计.上下文投影排序 */
|
/**
|
||||||
|
* 上游指纹:会挂进游玩上下文的产物正文与次序,加上会改变槽位的配方、变量目录、映射和已启用槽。
|
||||||
|
* 不含投影结果(变量.当前、上下文排序、worker 集正文),避免保存后自己把自己标脏。
|
||||||
|
*/
|
||||||
|
export function dictatePlayBindInputKey(board: Blackboard): string {
|
||||||
|
const products = collectDictateBindProducts(board).map((product) => ({
|
||||||
|
tag: product.tag,
|
||||||
|
order: product.order ?? null,
|
||||||
|
content: product.content,
|
||||||
|
}));
|
||||||
|
const existing =
|
||||||
|
board.getContentByTag(WORKER_SET_FINAL_TAG)?.trim() ||
|
||||||
|
board.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() ||
|
||||||
|
"";
|
||||||
|
const payload = {
|
||||||
|
products,
|
||||||
|
recipe: board.getContentByTag(CREATION_SELECTED_RECIPE_TAG)?.trim() ?? "",
|
||||||
|
catalog: board.getContentByTag(VARIABLE_CATALOG_TAG)?.trim() ?? "",
|
||||||
|
maps: board.getContentByTag(VALUE_MAP_TAG)?.trim() ?? "",
|
||||||
|
playSlots: resolvePlaySlots(existing || undefined, board),
|
||||||
|
};
|
||||||
|
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedPlayBindKey(
|
||||||
|
board: Blackboard,
|
||||||
|
tag: string,
|
||||||
|
): string | undefined {
|
||||||
|
const stamp = board.getLatestByTag(tag)?.metadata?.[DICTATE_PLAY_BIND_INPUT_META];
|
||||||
|
return typeof stamp === "string" && stamp.trim() ? stamp : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshPlayBind(
|
||||||
|
board: Blackboard,
|
||||||
|
key: string,
|
||||||
|
): DictatePlayBindResult | null {
|
||||||
|
const orderItem = board.getLatestByTag(CONTEXT_ORDER_TAG);
|
||||||
|
const workerItem = board.getLatestByTag(WORKER_SET_FINAL_TAG);
|
||||||
|
const orderRaw = orderItem?.content?.trim() ?? "";
|
||||||
|
const workerRaw = workerItem?.content?.trim() ?? "";
|
||||||
|
if (!orderRaw || !workerRaw) return null;
|
||||||
|
if (storedPlayBindKey(board, CONTEXT_ORDER_TAG) !== key) return null;
|
||||||
|
if (storedPlayBindKey(board, WORKER_SET_FINAL_TAG) !== key) return null;
|
||||||
|
try {
|
||||||
|
const contextOrder = JSON.parse(orderRaw) as ContextOrderDoc;
|
||||||
|
if (!contextOrder || typeof contextOrder !== "object") return null;
|
||||||
|
return {
|
||||||
|
workerSetJson: workerItem!.content,
|
||||||
|
contextOrder,
|
||||||
|
boundTags: collectDictateBindProducts(board).map((product) => product.tag),
|
||||||
|
changed: false,
|
||||||
|
skipped: true,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入黑板:设计.worker集 + 设计.上下文投影排序。上游未改则不覆盖。 */
|
||||||
export function applyDictatePlayBind(board: Blackboard): DictatePlayBindResult {
|
export function applyDictatePlayBind(board: Blackboard): DictatePlayBindResult {
|
||||||
|
const key = dictatePlayBindInputKey(board);
|
||||||
|
const fresh = freshPlayBind(board, key);
|
||||||
|
if (fresh) return fresh;
|
||||||
|
|
||||||
const result = bindDictateProductsToPlaySpec(board);
|
const result = bindDictateProductsToPlaySpec(board);
|
||||||
|
const orderItem = board.getLatestByTag(CONTEXT_ORDER_TAG);
|
||||||
|
const workerItem = board.getLatestByTag(WORKER_SET_FINAL_TAG);
|
||||||
|
const stamp = { [DICTATE_PLAY_BIND_INPUT_META]: key };
|
||||||
board.write({
|
board.write({
|
||||||
tag: WORKER_SET_FINAL_TAG,
|
tag: WORKER_SET_FINAL_TAG,
|
||||||
content: result.workerSetJson,
|
content: result.workerSetJson,
|
||||||
source: "runtime",
|
source: "runtime",
|
||||||
|
metadata: { ...(workerItem?.metadata ?? {}), ...stamp },
|
||||||
});
|
});
|
||||||
board.write({
|
board.write({
|
||||||
tag: CONTEXT_ORDER_TAG,
|
tag: CONTEXT_ORDER_TAG,
|
||||||
content: JSON.stringify(result.contextOrder, null, 2),
|
content: JSON.stringify(result.contextOrder, null, 2),
|
||||||
source: "runtime",
|
source: "runtime",
|
||||||
|
metadata: { ...(orderItem?.metadata ?? {}), ...stamp },
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 对话落盘 · 创作进料专用系统提示
|
* 对话落盘 · 创作系统提示。
|
||||||
*
|
* 系统侧只放跨轮不变的规则。方案进度、能力目录和本轮输入放在请求末尾。
|
||||||
* 拼装:本提示(system)+ 当前产物(按相对序)+ 全量对话。
|
|
||||||
* 仅用于创作层;开玩后由游玩管线接棒。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function buildDictateSystemPrompt(opts?: {
|
export type DictatePromptTailOpts = {
|
||||||
/** 已有方案 + 分档状态;存在时不再注入全能力目录 */
|
/** 已有方案的短进度;放在末尾,不进系统提示 */
|
||||||
creationPlanState?: string;
|
creationPlanState?: string;
|
||||||
/** 尚无方案时使用的短能力目录 */
|
/** 尚无方案时的短能力目录 */
|
||||||
capabilityCatalog?: string;
|
capabilityCatalog?: string;
|
||||||
/** 尚无方案时可引用的旧配方案例目录 */
|
|
||||||
recipeExamples?: string;
|
recipeExamples?: string;
|
||||||
/** @deprecated 仅兼容旧调用,作为案例而非已选路径 */
|
/** @deprecated 仅兼容旧调用,作为案例而非已选路径 */
|
||||||
recipeName?: string;
|
recipeName?: string;
|
||||||
@@ -18,43 +15,38 @@ export function buildDictateSystemPrompt(opts?: {
|
|||||||
recipeBrief?: string;
|
recipeBrief?: string;
|
||||||
/** @deprecated 使用 capabilityCatalog */
|
/** @deprecated 使用 capabilityCatalog */
|
||||||
moduleGuide?: string;
|
moduleGuide?: string;
|
||||||
}): string {
|
/** select:只定计划。fill:按注入规格写正文。reply:只交用户可见回复。 */
|
||||||
const legacyExample =
|
phase?: "select" | "fill" | "reply";
|
||||||
opts?.recipeName || opts?.recipeBrief
|
/** 本轮要落实或重写的 tag 与错误 */
|
||||||
? `
|
checklist?: string;
|
||||||
【旧配方案例 · 兼容参考】
|
};
|
||||||
${opts.recipeName ? `- ${opts.recipeName}` : ""}
|
|
||||||
${opts.recipeBrief ?? ""}
|
|
||||||
它只提供可改造的方法,不是用户已选路径,也不规定能力顺序。
|
|
||||||
`
|
|
||||||
: "";
|
|
||||||
const decisionBlock = opts?.creationPlanState?.trim()
|
|
||||||
? `${opts.creationPlanState.trim()}
|
|
||||||
|
|
||||||
方案是收口前的核对清单,不是本轮待办。写哪项先对照该能力旁的何时用 / 何时不用;档位只说明值不值得做。条件成立,且用户这句话已经说清的,通常 2~3 个:先 read_module,再连续 insert。说清指用户已经给出这份产物要写的内容,不是能从题材推演出一整份。标成「必须」或「有必要」、但这句话没覆盖到或使用条件不成立的,本轮不写,按已有材料给 2~3 个选项来问,选定或改写后再写入。写开场或结束创作前,列出仍未落的「必须」和「有必要」,问用户哪些还没设计、要补哪几项或明确不要。用户改口或方案明显失效时,先整份覆盖 \`设计.本局创作方案\`;使用条件仍留在能力旁。`
|
/** 第一次请求:只从目录里点出这段话要填的节点,不写产物。 */
|
||||||
: `【当前状态 · 尚无本局创作方案】
|
export function buildDictateSelectPrompt(): string {
|
||||||
先抽出用户要的核心体验,再对照能力目录主动标出有帮助的能力,生成 \`creation-plan.v1\`,用 insert 写入 \`设计.本局创作方案\`。先写 \`presentation_mode: "message"|"zero_layer"\`,再覆盖公共能力与该模式对应的三项呈现能力;两套呈现不得同时纳入。分档看能力对这份体验和可运行性的贡献:用户没点名、但缺了体验会明显变差的,标进「必须」或「有必要」,具体还没定的选择写进 missing。方案只表达本局方法、能力档位、理由与缺口,不生成固定流水线。
|
return `你只做选题。对照能力目录和用户这句话,调用 select_nodes,选出已经够写的节点。
|
||||||
|
|
||||||
${opts?.capabilityCatalog?.trim() || opts?.moduleGuide?.trim() || "(能力目录暂不可用;目录恢复前不要猜造方案。)"}
|
nodes 里的 module_id 必须来自目录。通常选这段话已经说清的 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。
|
||||||
${opts?.recipeExamples?.trim() ? `\n\n【旧配方案例目录】\n${opts.recipeExamples.trim()}` : ""}
|
用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或实现机制。
|
||||||
${legacyExample}`;
|
这一步不写产物正文,不回复用户。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDictateSystemPrompt(_opts?: DictatePromptTailOpts): string {
|
||||||
return `你是对话落盘创作助手:把用户自然描述整理为可编辑的本局创作产物。作用对象是创作黑板;开玩后由游玩 Worker 接棒。
|
return `你是对话落盘创作助手:把用户自然描述整理为可编辑的本局创作产物。作用对象是创作黑板;开玩后由游玩 Worker 接棒。
|
||||||
|
|
||||||
**本提示只用于创作。** 开玩后叙事由游玩 worker 执行;创作期只落规格与引导,不写游玩回合正文。
|
**本提示只用于创作。** 开玩后叙事由游玩 worker 执行;创作期只落规格与引导,不写游玩回合正文。
|
||||||
|
|
||||||
${decisionBlock}
|
**填写收尾:** 节点表产物写完后,同一次请求必须再调用 reply_user。只 insert、把话写在普通正文里,都不算完成。回复写法见文末「用户可见回复」。
|
||||||
|
|
||||||
## 写入规则
|
## 写入规则
|
||||||
设定 / 变量 / 映射必须经 tool 写入;聊天只做确认与引导,不当落盘载体。
|
设定、变量、映射和删除通过本步的工具调用落盘。reply_user 只写给用户看的回复。
|
||||||
|
|
||||||
### insert — 写入/覆盖固定产物
|
### insert — 写入/覆盖固定产物
|
||||||
- position:\`用户.*\` / \`设计.*\`
|
- position:\`用户.*\` / \`设计.*\`
|
||||||
- content:正文;order 可选(同一变化频率组内,基础材料用较小值,越接近生成时使用的合同越大)
|
- content:正文;order 可选(同一变化频率组内,基础材料用较小值,越接近生成时使用的合同越大)
|
||||||
- 同 position 再 insert = 整份覆盖改写;变量目录与映射改用 declare_variable / declare_map
|
- 同 position 再 insert = 整份覆盖改写;变量目录与映射改用 declare_variable / declare_map
|
||||||
- 可反复能力每个实例用 \`基名#唯一id\`;改某实例时覆盖同一完整 tag
|
- 可反复能力每个实例用 \`基名#唯一id\`;改某实例时覆盖同一完整 tag
|
||||||
- 只有方案中该填的内容都完成后才写开场白;先单独调用 prepare_opening,让程序编译信息可见范围、游玩执行方式、上下文排序和运行配置;工具成功后的下一步再生成并 insert 开场白
|
- 只有方案中该填的内容都完成后才写开场白。程序在这次填写前编译信息可见范围、游玩执行方式、上下文排序和运行配置;本步按清单写出开场正文
|
||||||
- 开场白与生成规则、具体实例一样可增殖:每条入口 insert \`设计.开场白#场景短码\`(0 层卡用 \`设计.0层开场白与初态#场景短码\`)。改某一条则覆盖同一完整 tag。全部保存;游玩时左右切换,选中的一条作为 0 层。不要在回复里把多条入口收成一道先选定的题
|
- 开场白与生成规则、具体实例一样可增殖:每条入口写入 \`设计.开场白#场景短码\`(0 层卡用 \`设计.0层开场白与初态#场景短码\`)。改某一条则覆盖同一完整 tag。全部保存;游玩时左右切换,选中的一条作为 0 层。不要在回复里把多条入口收成一道先选定的题
|
||||||
|
|
||||||
### delete — 删除固定产物
|
### delete — 删除固定产物
|
||||||
- position:已写入的 \`用户.*\` / \`设计.*\` tag,整份移除
|
- position:已写入的 \`用户.*\` / \`设计.*\` tag,整份移除
|
||||||
@@ -73,22 +65,87 @@ ${decisionBlock}
|
|||||||
- 移除一条 → remove_map(同 id 再 declare_map = 覆盖改写)
|
- 移除一条 → remove_map(同 id 再 declare_map = 覆盖改写)
|
||||||
|
|
||||||
## 推进原则
|
## 推进原则
|
||||||
1. 本局创作方案是决策信息,不是 DAG;不要按 items 顺序逐项执行。有方案时,写哪项先对照该能力的何时用 / 何时不用。
|
1. 本局创作方案是决策信息,不是 DAG;不要按 items 顺序逐项执行。尚无方案时,按末尾目录生成 \`creation-plan.v1\`,写入 \`设计.本局创作方案\`。有方案时,写哪项先对照该能力的何时用 / 何时不用。
|
||||||
2. 写任何内容能力前先调用 read_module 读取当前提示版本;需要库条目时,再用 read_library_entry 读取当前能力绑定库条目(默认 1~2;确需更多须给 reason)。不要凭目录短说明猜产物。
|
2. 本轮只处理用户这句话已经说清的能力,通常 2~3 个。说清指用户已经给出这份产物要写的内容,不是能从题材推演出一整份。
|
||||||
3. 本轮只写入用户这句话已经说清的能力,通常 2~3 个。对这些能力先 read_module,再 insert / declare_*。每次 insert 都是独立产物写入。
|
3. 先定范围再写。用户的话按最窄的成立范围落盘:体验目标说明玩家追求的对象,举例和比方说明用户想要的感觉或基底,都不说明整个世界都是这样。例:体验是「在各个世界征服高洁女性、打败恶役反派」,落成每个世界里有这样的目标人物和对手;世界本身仍按题材常识(如普通西幻),不写成全世界女性都高洁,也不为此发明神殿戒律。用「和现实一样」时顺口提到的现实事实,只确认基底是现实,不单独成条。宽窄拿不准、且会写出不同产物时,按最窄范围写或先不写,在回复里请用户确认范围。
|
||||||
4. 写某能力前以 read_module 返回的 task / principles / output / score 为准;尚未读到专用提示时,不猜造其复杂结构。能力提示里的「材料足够便直接成稿」只用于这句已经说清的能力。
|
4. 填写时以「本轮注入」里的 task、principles、output、score 和库正文为规格。注入尚未给出的能力,不猜造其复杂结构。
|
||||||
5. 「必须」和「有必要」要补齐:即使用户没点过能力名,也根据已抽出的核心体验和已有输入给 2~3 个选项来问,选定或改写后再写入。不要因为原话没出现就把有帮助的能力降成不推进。用户明确不要的「有必要」可以降档。「有一定效果」可顺带问一次。「没有意义」表示对这份体验没有帮助,不推进。未落的「必须」和「有必要」留到写开场或结束创作前,问用户哪些还没设计。
|
5. 变量和映射不写入普通产物。每次产物写入都是独立的一份。
|
||||||
6. 用户改口、新增方向或现有方案明显不成立时,先覆盖本局创作方案,再据此增删或改写其它产物。
|
6. 「必须」和「有必要」要补齐:即使用户没点过能力名,也根据已抽出的核心体验和已有输入给 2~3 个选项来问,选定或改写后再写入。不要因为原话没出现就把有帮助的能力降成不推进。用户明确不要的「有必要」可以降档。「有一定效果」可顺带问一次。「没有意义」表示对这份体验没有帮助,不推进。未落的「必须」和「有必要」留到写开场或结束创作前,问用户哪些还没设计。
|
||||||
|
7. 用户改口、新增方向或现有方案明显不成立时,先覆盖本局创作方案,再据此增删或改写其它产物。
|
||||||
|
|
||||||
## 用户可见回复
|
## 用户可见回复
|
||||||
自然回应用户本轮有效判断、矛盾和值得保留之处,再简要说明形成或改写了什么。完整 JSON 由产物卡展示,不粘贴到回复。
|
${dictateReplyGuide()}
|
||||||
写正文组成或 0 层正文组成时,在回复里直接给出一份隔离内容供预览:与写入的 frontend 同一套骨架,示例内容已经放进去,单独可看。产物仍只保存 frontend 与数据契约。
|
|
||||||
这句话没覆盖到的「必须」或「有必要」,用 2~3 个从用户原话推出的选项询问,不先写成产物。已经说清并写入的,说明形成了什么,不再另找问题。同轮多个已说清产物只做一次综合回应,不逐模块套固定标题。
|
|
||||||
|
|
||||||
## 其它
|
|
||||||
对话明显过长时 clear_dialogue(产物与变量/映射保留)。
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 方案进度、目录和本轮任务。拼在请求末尾。 */
|
||||||
|
export function formatDictateRoundTail(opts?: DictatePromptTailOpts): string {
|
||||||
|
const legacyExample =
|
||||||
|
opts?.recipeName || opts?.recipeBrief
|
||||||
|
? `【旧配方案例 · 兼容参考】
|
||||||
|
${opts.recipeName ? `- ${opts.recipeName}` : ""}
|
||||||
|
${opts.recipeBrief ?? ""}
|
||||||
|
它只提供可改造的方法,不是用户已选路径,也不规定能力顺序。`
|
||||||
|
: "";
|
||||||
|
const state = opts?.creationPlanState?.trim()
|
||||||
|
? `${opts.creationPlanState.trim()}
|
||||||
|
|
||||||
|
方案是收口前的核对清单,不是本轮待办。写哪项先对照该能力旁的何时用 / 何时不用;档位只说明值不值得做。条件成立,且用户这句话已经说清的,通常 2~3 个。标成「必须」或「有必要」、但这句话没覆盖到或使用条件不成立的,本轮不写,给 2~3 个选项来问。写开场或结束创作前,列出仍未落的「必须」和「有必要」。用户改口或方案明显失效时,先整份覆盖 \`设计.本局创作方案\`。`
|
||||||
|
: `【当前状态 · 尚无本局创作方案】
|
||||||
|
先抽出用户要的核心体验,再对照能力目录生成 \`creation-plan.v1\`,写入 \`设计.本局创作方案\`。先写 \`presentation_mode: "message"|"zero_layer"\`,再覆盖公共能力与该模式对应的三项呈现能力;两套呈现不得同时纳入。分档看能力对这份体验和可运行性的贡献。方案只表达本局方法、能力档位、理由与缺口,不生成固定流水线。
|
||||||
|
|
||||||
|
${opts?.capabilityCatalog?.trim() || opts?.moduleGuide?.trim() || "(能力目录暂不可用;目录恢复前不要猜造方案。)"}
|
||||||
|
${opts?.recipeExamples?.trim() ? `\n【旧配方案例目录】\n${opts.recipeExamples.trim()}` : ""}
|
||||||
|
${legacyExample}`;
|
||||||
|
|
||||||
|
if (opts?.phase === "reply") {
|
||||||
|
const state = opts?.creationPlanState?.trim()
|
||||||
|
? `${opts.creationPlanState.trim()}
|
||||||
|
|
||||||
|
写回复时用这份进度核对:用户这句没覆盖到的「必须」或「有必要」,给 2~3 个选项来问;已写入的不再强行追问。`
|
||||||
|
: "";
|
||||||
|
return [
|
||||||
|
"本步只调用 reply_user。对照本轮刚落盘产物和「本轮关注」写综合回复,含自评最稳处、缺口例子与选项。",
|
||||||
|
opts?.checklist?.trim() ?? "",
|
||||||
|
state,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase =
|
||||||
|
opts?.phase === "fill"
|
||||||
|
? `本步先写「本轮节点表」里尚未通过的产物(有 self_score 维度时 insert 带上;已通过的以工具结果为准),写完后同一次请求必须调用 reply_user,正文按系统提示「用户可见回复」。`
|
||||||
|
: "本步是第一次请求。只调用 select_nodes:这段话用于填充哪些节点。这一步不写产物正文,不回复用户。";
|
||||||
|
|
||||||
|
// 填写时把收尾要求放在方案进度之前,避免被大段进度挤到注意力末尾。
|
||||||
|
return opts?.phase === "fill"
|
||||||
|
? [phase, opts?.checklist?.trim() ?? "", state.trim()].filter(Boolean).join("\n\n")
|
||||||
|
: [state.trim(), opts?.checklist?.trim() ?? "", phase].filter(Boolean).join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
/** @deprecated 用 buildDictateSystemPrompt() */
|
/** @deprecated 用 buildDictateSystemPrompt() */
|
||||||
export const DICTATE_SYSTEM_PROMPT = buildDictateSystemPrompt();
|
export const DICTATE_SYSTEM_PROMPT = buildDictateSystemPrompt();
|
||||||
|
|
||||||
|
/** 用户可见回复正文要求(评分、缺口、建议)。填写与回复兜底共用。 */
|
||||||
|
export function dictateReplyBodyGuide(): string {
|
||||||
|
return `自然回应用户这句里值得保留、要纠正或互相矛盾的地方,再说明形成了什么。产物正文留在产物卡。正文组成、0 层正文组成和开场的画面也以产物卡为准。
|
||||||
|
用 self_score 点出最稳的一处,以及分数一般或还有更好写法的缺口。每个缺口写三样:一个从原话推出来的短例子、一句它会让体验变成什么样、2~3 个可以接着说的选项。这份产物没有 self_score 时,用这句里还没定的具体点代替缺口。
|
||||||
|
本轮按最窄范围写下、或因范围不明先没写的点,排在缺口最前面问:说明你按什么范围理解,给 2~3 个宽窄选项(如只是这个目标人物 / 这一类人物都这样 / 整个世界的规则)。
|
||||||
|
这句话没覆盖到的「必须」或「有必要」,写出下一步可以提及哪些,还可能缺哪些节点也写在这里;还没有方案时,按这句已经说清的设定给 2~3 个可继续的方向。
|
||||||
|
最后补一个和这句相关、用户可能想接着定的点,放在回复里,这一步不为它调用 insert。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户可见回复。只放在默认系统提示里,填写尾巴和工具说明不再抄一遍。 */
|
||||||
|
export function dictateReplyGuide(): string {
|
||||||
|
return `insert 全部完成后,同一次请求必须调用 reply_user,生成给用户看的综合回复。多个产物合成一段。
|
||||||
|
${dictateReplyBodyGuide()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 程序侧兜底回复请求:只看本轮已落盘产物与能力回复口径,只调 reply_user。不向填写步透露。 */
|
||||||
|
export function buildDictateReplyPrompt(): string {
|
||||||
|
return `你只生成给用户看的综合回复。对照本轮刚落盘的产物、附带的「本轮关注」和用户原话,调用 reply_user。
|
||||||
|
多个产物合成一段,不要逐模块套标题。不要再 insert、delete 或改设定。
|
||||||
|
|
||||||
|
${dictateReplyBodyGuide()}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,6 +143,74 @@ function bodyOfFragment(content: string): Record<string, unknown> | null {
|
|||||||
return body as Record<string, unknown>;
|
return body as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 多开场切换用的落盘族。每条必须是 基名#场景短码。 */
|
||||||
|
export const OPENING_SLOT_FAMILY = "设计.开场白";
|
||||||
|
/** 旧整包 tag。insert 时改写到 OPENING_SLOT_FAMILY#槽,避免再并列一份。 */
|
||||||
|
export const LEGACY_OPENING_BUNDLE_FAMILY = "设计.开场白与开场变量";
|
||||||
|
|
||||||
|
const OPENING_SLOT_REQUIRED =
|
||||||
|
"开场每条单独落盘,position 用「设计.开场白#场景短码」(例如 设计.开场白#opening-cycle2),供之后左右切换。不要写裸「设计.开场白」,也不要写「设计.开场白与开场变量」。改某一条则 insert 同一完整 tag。没有变量的卡不写开场变量,tag 仍然带场景短码。";
|
||||||
|
|
||||||
|
/** 旧整包族改写成开场族,槽位原样保留。 */
|
||||||
|
export function rewriteLegacyOpeningTag(tag: string): string {
|
||||||
|
const raw = tag.trim();
|
||||||
|
if (dictateProductFamily(raw) !== LEGACY_OPENING_BUNDLE_FAMILY) return raw;
|
||||||
|
const slot = dictateProductSlot(raw);
|
||||||
|
return slot ? buildRepeatableProductTag(OPENING_SLOT_FAMILY, slot) : OPENING_SLOT_FAMILY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openingCandidateRows(body: Record<string, unknown>): Record<string, unknown>[] {
|
||||||
|
const raw = body.开场白候选 ?? body.候选;
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.filter(
|
||||||
|
(row): row is Record<string, unknown> =>
|
||||||
|
Boolean(row) && typeof row === "object" && !Array.isArray(row),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从单张开场正文抽出场景短码。多条候选挤在一份正文里时拒绝,要求拆成多次 insert。
|
||||||
|
*/
|
||||||
|
export function extractOpeningSlotFromContent(
|
||||||
|
content: string,
|
||||||
|
): { slot?: string; error?: string } {
|
||||||
|
const parsed = tryParseJsonDoc(content);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||||
|
const doc = parsed as Record<string, unknown>;
|
||||||
|
const nested = bodyOfFragment(content);
|
||||||
|
const body = nested ?? doc;
|
||||||
|
const direct =
|
||||||
|
asString(doc.id) ??
|
||||||
|
asString(doc.候选id) ??
|
||||||
|
asString(doc.场景短码) ??
|
||||||
|
asString(body.id) ??
|
||||||
|
asString(body.候选id) ??
|
||||||
|
asString(body.场景短码);
|
||||||
|
if (direct) {
|
||||||
|
const slot = sanitizeRepeatableSlot(direct);
|
||||||
|
return slot ? { slot } : {};
|
||||||
|
}
|
||||||
|
const rows = openingCandidateRows(body);
|
||||||
|
if (rows.length > 1) {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"多条开场不要写进同一份产物。每条 insert 一次「设计.开场白#场景短码」。",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const one = rows[0];
|
||||||
|
const fromOne = one
|
||||||
|
? asString(one.id) ?? asString(one.候选id) ?? asString(one.场景短码)
|
||||||
|
: undefined;
|
||||||
|
const fallback =
|
||||||
|
rows.length <= 1
|
||||||
|
? asString(body.默认候选id) ?? asString(body.默认候选ID)
|
||||||
|
: undefined;
|
||||||
|
const picked = fromOne ?? fallback;
|
||||||
|
if (!picked) return {};
|
||||||
|
const slot = sanitizeRepeatableSlot(picked);
|
||||||
|
return slot ? { slot } : {};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从正文抽出建议槽位:生成规则 → 规则名;具体实例 → 名称 / 记录里的姓名。
|
* 从正文抽出建议槽位:生成规则 → 规则名;具体实例 → 名称 / 记录里的姓名。
|
||||||
*/
|
*/
|
||||||
@@ -207,8 +275,9 @@ export function resolveRepeatableInsertTag(params: {
|
|||||||
existingTags: readonly string[];
|
existingTags: readonly string[];
|
||||||
repeatableFamilies: ReadonlySet<string>;
|
repeatableFamilies: ReadonlySet<string>;
|
||||||
}): ResolveRepeatableInsertResult {
|
}): ResolveRepeatableInsertResult {
|
||||||
const raw = params.tag.trim();
|
const requested = params.tag.trim();
|
||||||
if (!raw) return { error: "position 为空" };
|
if (!requested) return { error: "position 为空" };
|
||||||
|
const raw = rewriteLegacyOpeningTag(requested);
|
||||||
|
|
||||||
const family = dictateProductFamily(raw);
|
const family = dictateProductFamily(raw);
|
||||||
if (!params.repeatableFamilies.has(family)) {
|
if (!params.repeatableFamilies.has(family)) {
|
||||||
@@ -217,13 +286,21 @@ export function resolveRepeatableInsertTag(params: {
|
|||||||
|
|
||||||
const explicit = dictateProductSlot(raw);
|
const explicit = dictateProductSlot(raw);
|
||||||
const extracted = extractRepeatableSlotFromContent(family, params.content);
|
const extracted = extractRepeatableSlotFromContent(family, params.content);
|
||||||
const slot = explicit || extracted || "";
|
const openingSlot =
|
||||||
|
family === OPENING_SLOT_FAMILY && !explicit
|
||||||
|
? extractOpeningSlotFromContent(params.content)
|
||||||
|
: {};
|
||||||
|
if (openingSlot.error) return { error: openingSlot.error };
|
||||||
|
const slot = explicit || extracted || openingSlot.slot || "";
|
||||||
|
|
||||||
const siblings = params.existingTags
|
const siblings = params.existingTags
|
||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter((t) => t && dictateProductFamily(t) === family);
|
.filter((t) => t && dictateProductFamily(t) === family);
|
||||||
|
|
||||||
if (!slot) {
|
if (!slot) {
|
||||||
|
if (family === OPENING_SLOT_FAMILY) {
|
||||||
|
return { error: OPENING_SLOT_REQUIRED };
|
||||||
|
}
|
||||||
// 允许覆盖基名自身;若已有其它 #槽位,禁止再用裸基名(须写明 #id)
|
// 允许覆盖基名自身;若已有其它 #槽位,禁止再用裸基名(须写明 #id)
|
||||||
const otherSiblings = siblings.filter((t) => t !== family);
|
const otherSiblings = siblings.filter((t) => t !== family);
|
||||||
if (otherSiblings.length > 0 && raw === family) {
|
if (otherSiblings.length > 0 && raw === family) {
|
||||||
@@ -235,11 +312,11 @@ export function resolveRepeatableInsertTag(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolved = buildRepeatableProductTag(family, slot);
|
const resolved = buildRepeatableProductTag(family, slot);
|
||||||
if (raw === family || raw !== resolved) {
|
if (requested !== resolved) {
|
||||||
return {
|
return {
|
||||||
tag: resolved,
|
tag: resolved,
|
||||||
remapped: raw !== resolved,
|
remapped: true,
|
||||||
note: raw !== resolved ? `可增殖已拆到 ${resolved}` : undefined,
|
note: `已写入 ${resolved}。之后改这一条用同一完整 tag。`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { tag: resolved };
|
return { tag: resolved };
|
||||||
|
|||||||
@@ -0,0 +1,721 @@
|
|||||||
|
/**
|
||||||
|
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,
|
||||||
|
* 再在同一条消息前缀上循环 insert。已通过的不再覆盖。
|
||||||
|
* 没有未通过项时,同一次请求里的 reply_user 才给用户。
|
||||||
|
* 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。
|
||||||
|
*/
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js";
|
||||||
|
import type { AgentDriver } from "../runtime/driver.js";
|
||||||
|
import type { ChatMessage, LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
|
||||||
|
import { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
|
||||||
|
import { buildDictateContext, type DictateSegmentChars } from "./context.js";
|
||||||
|
import {
|
||||||
|
CREATION_PLAN_SCHEMA,
|
||||||
|
CREATION_PLAN_TAG,
|
||||||
|
formatCreationPlanCapabilityCatalog,
|
||||||
|
formatCreationPlanProgress,
|
||||||
|
parseCreationPlan,
|
||||||
|
} from "./creation-plan.js";
|
||||||
|
import { isDictateOpeningTag } from "./experience-anchor.js";
|
||||||
|
import {
|
||||||
|
buildDictateReplyPrompt,
|
||||||
|
buildDictateSelectPrompt,
|
||||||
|
buildDictateSystemPrompt,
|
||||||
|
formatDictateRoundTail,
|
||||||
|
type DictatePromptTailOpts,
|
||||||
|
} from "./prompt.js";
|
||||||
|
import {
|
||||||
|
parseDictateModuleReadState,
|
||||||
|
readLibraryEntries,
|
||||||
|
serializeDictateModuleReadState,
|
||||||
|
DICTATE_MODULE_READ_STATE_TAG,
|
||||||
|
type DictateModuleReadPayload,
|
||||||
|
} from "./read-module.js";
|
||||||
|
import { resolveModuleForInsertTag } from "./insert-feedback.js";
|
||||||
|
import type { DictateChatTurn, DictateProduct } from "./types.js";
|
||||||
|
import { isAllowedProductTag } from "./types.js";
|
||||||
|
import { VALUE_MAP_TAG } from "../skills/value-map.js";
|
||||||
|
import { VARIABLE_CATALOG_TAG } from "../skills/variable-catalog.js";
|
||||||
|
|
||||||
|
const READ_EXEMPT = new Set([
|
||||||
|
"narrative",
|
||||||
|
"variable-context",
|
||||||
|
"context-order",
|
||||||
|
"worker-spec",
|
||||||
|
"refine",
|
||||||
|
]);
|
||||||
|
|
||||||
|
type Apply = (calls: DriverToolCall[]) => DriverStepOutcome | Promise<DriverStepOutcome>;
|
||||||
|
|
||||||
|
export type PlannedRoundParams = {
|
||||||
|
llm: LlmProvider;
|
||||||
|
dialogue: DictateChatTurn[];
|
||||||
|
handlers: {
|
||||||
|
listProducts: () => DictateProduct[];
|
||||||
|
readTag: (tag: string) => string | undefined;
|
||||||
|
writeTag: (tag: string, content: string) => void;
|
||||||
|
prepareOpening?: () => void;
|
||||||
|
/** 本轮刚写入 tag 的能力回复口径(探测/原则/自评关注) */
|
||||||
|
lookupInsertFeedback?: (tag: string) => string | undefined;
|
||||||
|
onThinkingDelta?: (delta: string) => void;
|
||||||
|
onThinkingDone?: (text: string) => void;
|
||||||
|
};
|
||||||
|
moduleCatalog?: import("../skills/creation-flow.js").ModuleCatalog | null;
|
||||||
|
moduleReadIndex?: ReadonlyMap<string, DictateModuleReadPayload>;
|
||||||
|
moduleGuide?: string;
|
||||||
|
recipeExamples?: string;
|
||||||
|
recipeName?: string;
|
||||||
|
recipeBrief?: string;
|
||||||
|
systemPrompt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlanInsert = {
|
||||||
|
tag: string;
|
||||||
|
moduleId: string;
|
||||||
|
libraryId?: string;
|
||||||
|
entryIds: string[];
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelectPlan = {
|
||||||
|
clarify: string[];
|
||||||
|
inserts: PlanInsert[];
|
||||||
|
deletes: string[];
|
||||||
|
/** 模型对象上的字段名,空计划时写进回复,方便核对解析有没有对上 */
|
||||||
|
keys: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
type ItemFailure = { tag: string; error: string };
|
||||||
|
|
||||||
|
|
||||||
|
const INSERT_ATTEMPTS = 4;
|
||||||
|
|
||||||
|
export function createPlannedDictateDriver(params: PlannedRoundParams): AgentDriver {
|
||||||
|
return {
|
||||||
|
async run(input) {
|
||||||
|
const reply = await runPlannedDictateRound(params, input.handleStep);
|
||||||
|
return { iterations: reply.modelCalls, stop: { kind: "text", content: reply.text } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPlannedDictateRound(
|
||||||
|
params: PlannedRoundParams,
|
||||||
|
apply: Apply,
|
||||||
|
): Promise<{ text: string; modelCalls: number }> {
|
||||||
|
const turnId = randomUUID();
|
||||||
|
const fillSystem = params.systemPrompt?.trim() || buildDictateSystemPrompt();
|
||||||
|
const tailOpts = tailState(params);
|
||||||
|
const selectTail = formatDictateRoundTail({ ...tailOpts, phase: "select" });
|
||||||
|
const selectCtx = buildDictateContext({
|
||||||
|
systemPrompt: buildDictateSelectPrompt(),
|
||||||
|
products: params.handlers.listProducts(),
|
||||||
|
dialogue: params.dialogue,
|
||||||
|
phase: "select",
|
||||||
|
tail: selectTail,
|
||||||
|
});
|
||||||
|
const selected = await params.llm.completeWithTools(selectCtx.messages, {
|
||||||
|
caller: "dictate_agent",
|
||||||
|
label: "对话落盘 选题",
|
||||||
|
tools: [DICTATE_SELECT_TOOL],
|
||||||
|
toolChoice: { type: "function", function: { name: "select_nodes" } },
|
||||||
|
usageDetail: detail(turnId, "select", 0, selectCtx.segmentChars),
|
||||||
|
usageSink: sinkFor(turnId, "select"),
|
||||||
|
});
|
||||||
|
if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim());
|
||||||
|
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0];
|
||||||
|
const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params);
|
||||||
|
if (plan.clarify.length >= 2 && plan.inserts.length === 0 && plan.deletes.length === 0) {
|
||||||
|
return {
|
||||||
|
modelCalls: 1,
|
||||||
|
text: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (plan.inserts.length === 0 && plan.deletes.length === 0) {
|
||||||
|
const fields = plan.keys.length ? plan.keys.join("、") : "空对象";
|
||||||
|
return {
|
||||||
|
modelCalls: 1,
|
||||||
|
text: `这一轮没有写入产物。选题返回的字段是 ${fields},没有可执行的写入项。可以说得更具体些,或点明先写哪一块。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = plan.inserts.filter((item) => item.tag.startsWith("!"));
|
||||||
|
const inserts = plan.inserts.filter((item) => !item.tag.startsWith("!"));
|
||||||
|
const openings = inserts.filter((item) => isDictateOpeningTag(item.tag));
|
||||||
|
const rest = inserts.filter((item) => !isDictateOpeningTag(item.tag));
|
||||||
|
const blockedLines = blocked.map(
|
||||||
|
(item) => `${item.tag.slice(1)}(${item.reason || "计划未通过程序核验"})`,
|
||||||
|
);
|
||||||
|
if (openings.length > 0) params.handlers.prepareOpening?.();
|
||||||
|
const injected = injectGroup(params, [...rest, ...openings]);
|
||||||
|
const tail = formatDictateRoundTail({
|
||||||
|
...tailOpts,
|
||||||
|
phase: "fill",
|
||||||
|
checklist: formatChecklist([...rest, ...openings], plan.deletes, []),
|
||||||
|
});
|
||||||
|
const ctx = buildDictateContext({
|
||||||
|
systemPrompt: fillSystem,
|
||||||
|
products: params.handlers.listProducts(),
|
||||||
|
dialogue: params.dialogue,
|
||||||
|
phase: "fill",
|
||||||
|
injected: injected.text,
|
||||||
|
tail,
|
||||||
|
});
|
||||||
|
const messages: ChatMessage[] = [...ctx.messages];
|
||||||
|
const passed = new Set<string>();
|
||||||
|
const pending = new Set([...rest, ...openings].map((item) => item.tag));
|
||||||
|
const pendingDeletes = new Set(plan.deletes);
|
||||||
|
const errors = new Map<string, string>();
|
||||||
|
let replyFromTool = "";
|
||||||
|
let lastProse = "";
|
||||||
|
let modelCalls = 1;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < INSERT_ATTEMPTS && (pending.size > 0 || pendingDeletes.size > 0); attempt++) {
|
||||||
|
const step = attempt === 0 ? "fill" : "fill-retry";
|
||||||
|
const filled = await params.llm.completeWithTools(messages, {
|
||||||
|
caller: "dictate_agent",
|
||||||
|
label: attempt === 0 ? "对话落盘 填写" : "对话落盘 重写",
|
||||||
|
tools: DICTATE_WRITE_LOOP_TOOLS,
|
||||||
|
toolChoice: "required",
|
||||||
|
usageDetail: {
|
||||||
|
...detail(turnId, step, attempt, ctx.segmentChars),
|
||||||
|
plannedTags: [...pending, ...pendingDeletes],
|
||||||
|
},
|
||||||
|
usageSink: sinkFor(turnId, step),
|
||||||
|
});
|
||||||
|
modelCalls += 1;
|
||||||
|
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
|
||||||
|
const prose = filled.content?.trim() ?? "";
|
||||||
|
if (prose) lastProse = prose;
|
||||||
|
const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({
|
||||||
|
id: call.id || `call-${attempt}-${index}`,
|
||||||
|
name: call.name,
|
||||||
|
arguments: call.arguments,
|
||||||
|
}));
|
||||||
|
if (calls.length === 0) break;
|
||||||
|
const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors);
|
||||||
|
messages.push(assistantToolMessage(filled.content, calls));
|
||||||
|
for (const item of settled.results) {
|
||||||
|
messages.push({ role: "tool", content: item.content, tool_call_id: item.callId });
|
||||||
|
}
|
||||||
|
if (pending.size === 0 && pendingDeletes.size === 0) {
|
||||||
|
replyFromTool = settled.reply;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let reply = replyFromTool;
|
||||||
|
if (
|
||||||
|
!replyFromTool &&
|
||||||
|
pending.size === 0 &&
|
||||||
|
pendingDeletes.size === 0 &&
|
||||||
|
passed.size > 0
|
||||||
|
) {
|
||||||
|
reply = await runLeanReply(params, turnId, passed, plan.deletes, lastProse);
|
||||||
|
modelCalls += 1;
|
||||||
|
} else if (!reply) {
|
||||||
|
reply = lastProse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = [...pending, ...pendingDeletes].map(
|
||||||
|
(tag) => `${tag}(${errors.get(tag) ?? "尚未通过"})`,
|
||||||
|
);
|
||||||
|
const pendingLines = [...blockedLines, ...failed];
|
||||||
|
const text = pendingLines.length
|
||||||
|
? [reply, `尚未完成:${pendingLines.join("、")}。`].filter(Boolean).join("\n")
|
||||||
|
: reply;
|
||||||
|
return { text, modelCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLeanReply(
|
||||||
|
params: PlannedRoundParams,
|
||||||
|
turnId: string,
|
||||||
|
passed: Set<string>,
|
||||||
|
plannedDeletes: string[],
|
||||||
|
fallbackProse: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const writtenTags = [...passed].filter((tag) => !tag.startsWith("del:"));
|
||||||
|
const deletedTags = [...passed]
|
||||||
|
.filter((tag) => tag.startsWith("del:"))
|
||||||
|
.map((tag) => tag.slice(4))
|
||||||
|
.filter(Boolean);
|
||||||
|
const products = params.handlers
|
||||||
|
.listProducts()
|
||||||
|
.filter((product) => writtenTags.some((tag) => product.tag === tag || product.tag.startsWith(`${tag}#`)));
|
||||||
|
const feedbackBlocks = writtenTags
|
||||||
|
.map((tag) => params.handlers.lookupInsertFeedback?.(tag)?.trim())
|
||||||
|
.filter((text): text is string => Boolean(text));
|
||||||
|
if (writtenTags.length > 1 && feedbackBlocks.length > 1) {
|
||||||
|
feedbackBlocks.unshift(
|
||||||
|
"本轮写入了多个产物。用一段自然回复综合说明本轮变化、共同强项与关键缺口;不要逐模块套标题或重复模板。只有缺口确需用户决定时才提问。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const tailOpts = tailState(params);
|
||||||
|
const deleteNote =
|
||||||
|
deletedTags.length || plannedDeletes.length
|
||||||
|
? `本轮已删除:${(deletedTags.length ? deletedTags : plannedDeletes).join("、")}`
|
||||||
|
: "";
|
||||||
|
const replyTail = [
|
||||||
|
formatDictateRoundTail({
|
||||||
|
...tailOpts,
|
||||||
|
phase: "reply",
|
||||||
|
checklist: [
|
||||||
|
writtenTags.length ? `本轮已写入:${writtenTags.join("、")}` : "",
|
||||||
|
deleteNote,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
}),
|
||||||
|
feedbackBlocks.length ? `## 本轮关注(按能力回复口径)\n\n${feedbackBlocks.join("\n\n")}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
const replyCtx = buildDictateContext({
|
||||||
|
systemPrompt: buildDictateReplyPrompt(),
|
||||||
|
products,
|
||||||
|
dialogue: params.dialogue,
|
||||||
|
phase: "reply",
|
||||||
|
tail: replyTail,
|
||||||
|
});
|
||||||
|
const filled = await params.llm.completeWithTools(replyCtx.messages, {
|
||||||
|
caller: "dictate_agent",
|
||||||
|
label: "对话落盘 回复",
|
||||||
|
tools: [DICTATE_REPLY_TOOL],
|
||||||
|
toolChoice: { type: "function", function: { name: DICTATE_REPLY_TOOL.function.name } },
|
||||||
|
usageDetail: {
|
||||||
|
...detail(turnId, "reply", 0, replyCtx.segmentChars),
|
||||||
|
plannedTags: writtenTags,
|
||||||
|
},
|
||||||
|
usageSink: sinkFor(turnId, "reply"),
|
||||||
|
});
|
||||||
|
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
|
||||||
|
const call = filled.toolCalls.find((item) => item.name === DICTATE_REPLY_TOOL.function.name);
|
||||||
|
if (call) {
|
||||||
|
const text = replyText({
|
||||||
|
id: call.id || "reply",
|
||||||
|
name: call.name,
|
||||||
|
arguments: call.arguments,
|
||||||
|
});
|
||||||
|
if (text) return text;
|
||||||
|
}
|
||||||
|
return filled.content?.trim() || fallbackProse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantToolMessage(content: string | null, calls: DriverToolCall[]): ChatMessage {
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
tool_calls: calls.map((call) => ({
|
||||||
|
id: call.id,
|
||||||
|
type: "function" as const,
|
||||||
|
function: { name: call.name, arguments: call.arguments },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleWriteStep(
|
||||||
|
params: PlannedRoundParams,
|
||||||
|
apply: Apply,
|
||||||
|
calls: DriverToolCall[],
|
||||||
|
passed: Set<string>,
|
||||||
|
pending: Set<string>,
|
||||||
|
pendingDeletes: Set<string>,
|
||||||
|
errors: Map<string, string>,
|
||||||
|
): Promise<{ results: Array<{ callId: string; content: string }>; reply: string }> {
|
||||||
|
const results: Array<{ callId: string; content: string }> = [];
|
||||||
|
const forward: DriverToolCall[] = [];
|
||||||
|
let reply = "";
|
||||||
|
for (const call of calls) {
|
||||||
|
if (call.name === DICTATE_REPLY_TOOL.function.name) continue;
|
||||||
|
const tag = callTag(call, params);
|
||||||
|
if (call.name === "insert" && tag && passed.has(tag)) {
|
||||||
|
results.push({
|
||||||
|
callId: call.id,
|
||||||
|
content: JSON.stringify({ error: "已通过,本步不要再写", tag }),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (call.name === "delete" && tag && passed.has(`del:${tag}`)) {
|
||||||
|
results.push({
|
||||||
|
callId: call.id,
|
||||||
|
content: JSON.stringify({ error: "已通过,本步不要再删除", tag }),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (call.name === "insert" && tag) {
|
||||||
|
const args = asRecord(safeJson(call.arguments));
|
||||||
|
forward.push({ ...call, arguments: JSON.stringify({ ...args, position: tag }) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
forward.push(call);
|
||||||
|
}
|
||||||
|
const outcome = await apply(forward);
|
||||||
|
if (outcome.kind === "continue") {
|
||||||
|
for (const result of outcome.results) {
|
||||||
|
results.push(result);
|
||||||
|
const call = forward.find((item) => item.id === result.callId);
|
||||||
|
const payload = asRecord(safeJson(result.content));
|
||||||
|
const tag = call ? callTag(call, params) : "";
|
||||||
|
if (payload.ok === true && tag) {
|
||||||
|
if (call?.name === "delete") {
|
||||||
|
passed.add(`del:${tag}`);
|
||||||
|
pendingDeletes.delete(tag);
|
||||||
|
} else {
|
||||||
|
passed.add(tag);
|
||||||
|
for (const planned of [...pending]) {
|
||||||
|
if (planned === tag || tag.startsWith(`${planned}#`)) pending.delete(planned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errors.delete(tag);
|
||||||
|
} else if (typeof payload.error === "string" && tag) {
|
||||||
|
const planned = [...pending, ...pendingDeletes].find(
|
||||||
|
(item) => item === tag || tag.startsWith(`${item}#`),
|
||||||
|
);
|
||||||
|
errors.set(planned ?? tag, payload.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const call of calls) {
|
||||||
|
if (call.name !== DICTATE_REPLY_TOOL.function.name) continue;
|
||||||
|
const text = replyText(call);
|
||||||
|
if (pending.size > 0 || pendingDeletes.size > 0) {
|
||||||
|
const left = [...pending, ...pendingDeletes].join("、");
|
||||||
|
results.push({
|
||||||
|
callId: call.id,
|
||||||
|
content: JSON.stringify({
|
||||||
|
error: `还有未通过:${left}。这份回复先不给用户。只再写入这些。`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
reply = text;
|
||||||
|
results.push({ callId: call.id, content: JSON.stringify({ ok: true }) });
|
||||||
|
}
|
||||||
|
return { results, reply };
|
||||||
|
}
|
||||||
|
|
||||||
|
function callTag(call: DriverToolCall, params: PlannedRoundParams): string {
|
||||||
|
const args = asRecord(safeJson(call.arguments));
|
||||||
|
const raw = String(args.position ?? args.tag ?? "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
return canonicalProductTag(raw, "", params.moduleCatalog)?.tag ?? raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replyText(call: DriverToolCall): string {
|
||||||
|
const args = asRecord(safeJson(call.arguments));
|
||||||
|
return String(args.text ?? args.reply ?? args.content ?? args.message ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sinkFor(_turnId: string, _step: string): UsageSink {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detail(
|
||||||
|
turnId: string,
|
||||||
|
step: UsageDetail["step"],
|
||||||
|
retryIndex: number,
|
||||||
|
segmentChars: DictateSegmentChars,
|
||||||
|
): UsageDetail {
|
||||||
|
return { turnId, step, retryIndex, segmentChars };
|
||||||
|
}
|
||||||
|
|
||||||
|
function tailState(params: PlannedRoundParams): DictatePromptTailOpts {
|
||||||
|
const products = params.handlers.listProducts();
|
||||||
|
const plan = parseCreationPlan(
|
||||||
|
products.find((product) => product.tag === CREATION_PLAN_TAG)?.content,
|
||||||
|
);
|
||||||
|
if (plan) {
|
||||||
|
return {
|
||||||
|
creationPlanState: formatCreationPlanProgress({
|
||||||
|
plan,
|
||||||
|
catalog: params.moduleCatalog,
|
||||||
|
products,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
capabilityCatalog:
|
||||||
|
formatCreationPlanCapabilityCatalog(params.moduleCatalog) || params.moduleGuide,
|
||||||
|
recipeExamples: params.recipeExamples,
|
||||||
|
recipeName: params.recipeName,
|
||||||
|
recipeBrief: params.recipeBrief,
|
||||||
|
moduleGuide: params.moduleGuide,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
|
||||||
|
const row = asRecord(raw);
|
||||||
|
const clarify = stringList(row.clarify);
|
||||||
|
let deletes = stringList(row.deletes).filter((tag) => isAllowedProductTag(tag));
|
||||||
|
const inserts: PlanInsert[] = [];
|
||||||
|
let items = Array.isArray(row.nodes)
|
||||||
|
? row.nodes
|
||||||
|
: Array.isArray(row.inserts)
|
||||||
|
? row.inserts
|
||||||
|
: [];
|
||||||
|
if (items.length === 0 && clarify.length < 2 && deletes.length === 0) {
|
||||||
|
const recovered = recoverSelectWork(row);
|
||||||
|
items = recovered.items;
|
||||||
|
deletes = recovered.deletes.filter((tag) => isAllowedProductTag(tag));
|
||||||
|
}
|
||||||
|
for (const item of items) {
|
||||||
|
const rec = asRecord(item);
|
||||||
|
const moduleIdHint = String(rec.module_id ?? rec.moduleId ?? "").trim();
|
||||||
|
let tag = String(rec.tag ?? rec.position ?? "").trim();
|
||||||
|
if (!tag && moduleIdHint) {
|
||||||
|
tag =
|
||||||
|
params.moduleCatalog?.modules.find((module) => module.id === moduleIdHint)?.artifact?.trim() ??
|
||||||
|
"";
|
||||||
|
}
|
||||||
|
if (!tag) continue;
|
||||||
|
const canonical = canonicalProductTag(tag, moduleIdHint, params.moduleCatalog);
|
||||||
|
if (canonical) tag = canonical.tag;
|
||||||
|
const resolvedHint = canonical?.moduleId || moduleIdHint;
|
||||||
|
const module =
|
||||||
|
params.moduleReadIndex?.get(resolvedHint) ??
|
||||||
|
params.moduleReadIndex?.get(tag) ??
|
||||||
|
undefined;
|
||||||
|
const fromTag = resolveModuleForInsertTag(tag, params.moduleCatalog);
|
||||||
|
const moduleId = module?.module_id || canonical?.moduleId || fromTag?.id || moduleIdHint;
|
||||||
|
const entryIds = stringList(rec.entry_ids);
|
||||||
|
const reason = String(rec.reason ?? "").trim();
|
||||||
|
if (!isAllowedProductTag(tag) && tag !== VARIABLE_CATALOG_TAG && tag !== VALUE_MAP_TAG) {
|
||||||
|
inserts.push({ tag: `!${tag || "空"}`, moduleId, entryIds, reason: "产物 tag 不合法" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const payload = moduleId ? params.moduleReadIndex?.get(moduleId) : undefined;
|
||||||
|
if (payload && !READ_EXEMPT.has(payload.module_id) && payload.libraries.length) {
|
||||||
|
const libraryId = String(rec.library_id ?? payload.libraries[0]?.library_id ?? "").trim();
|
||||||
|
if (entryIds.length > 2 && !reason) {
|
||||||
|
inserts.push({
|
||||||
|
tag: `!${tag}`,
|
||||||
|
moduleId,
|
||||||
|
entryIds,
|
||||||
|
reason: "默认最多两个库条目",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (libraryId && entryIds.length) {
|
||||||
|
const bound = payload.libraries.some((library) => library.library_id === libraryId);
|
||||||
|
if (!bound) {
|
||||||
|
inserts.push({ tag: `!${tag}`, moduleId, entryIds, reason: `能力未绑定库 ${libraryId}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inserts.push({
|
||||||
|
tag,
|
||||||
|
moduleId,
|
||||||
|
libraryId: String(rec.library_id ?? "").trim() || undefined,
|
||||||
|
entryIds,
|
||||||
|
reason: reason || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { clarify, inserts, deletes, keys: Object.keys(row) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选题没交 inserts 时,从方案正文、operations 或别名字段收出本轮要写的能力,再自动发填写。 */
|
||||||
|
function recoverSelectWork(row: Record<string, unknown>): {
|
||||||
|
items: unknown[];
|
||||||
|
deletes: string[];
|
||||||
|
} {
|
||||||
|
if (isCreationPlanShape(row)) {
|
||||||
|
return { items: [{ tag: CREATION_PLAN_TAG, module_id: "creation-plan" }], deletes: [] };
|
||||||
|
}
|
||||||
|
const items: unknown[] = [];
|
||||||
|
const deletes: string[] = [];
|
||||||
|
for (const list of [row.modules, row.selections, row.selected, row.writes, row.products, row.operations]) {
|
||||||
|
for (const item of asItemList(list)) {
|
||||||
|
const rec = flattenItem(item);
|
||||||
|
if (
|
||||||
|
typeof rec.tier === "string" &&
|
||||||
|
typeof rec.why === "string" &&
|
||||||
|
rec.tag == null &&
|
||||||
|
rec.position == null
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const op = operationName(rec);
|
||||||
|
if (op === "read_module" || op === "read_library_entry" || op === "prepare_opening") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const moduleId = String(rec.module_id ?? rec.moduleId ?? "").trim();
|
||||||
|
const tag = String(rec.tag ?? rec.position ?? rec.artifact ?? "").trim();
|
||||||
|
if (op === "delete" || op === "remove") {
|
||||||
|
if (tag) deletes.push(tag);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
op === "declare_variable" ||
|
||||||
|
op === "undeclare_variable" ||
|
||||||
|
op === "declare_map" ||
|
||||||
|
op === "remove_map"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!tag && !moduleId) continue;
|
||||||
|
items.push({
|
||||||
|
tag,
|
||||||
|
module_id: moduleId,
|
||||||
|
...(rec.library_id != null ? { library_id: rec.library_id } : {}),
|
||||||
|
...(rec.entry_ids != null ? { entry_ids: rec.entry_ids } : {}),
|
||||||
|
...(rec.reason != null ? { reason: rec.reason } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { items, deletes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCreationPlanShape(row: Record<string, unknown>): boolean {
|
||||||
|
if (row.schema === CREATION_PLAN_SCHEMA) return true;
|
||||||
|
if (!Array.isArray(row.items) || row.items.length === 0) return false;
|
||||||
|
return row.items.every((item) => {
|
||||||
|
const rec = asRecord(item);
|
||||||
|
return (
|
||||||
|
typeof rec.module_id === "string" &&
|
||||||
|
rec.module_id.trim().length > 0 &&
|
||||||
|
typeof rec.tier === "string" &&
|
||||||
|
rec.tag == null &&
|
||||||
|
rec.position == null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectGroup(
|
||||||
|
params: PlannedRoundParams,
|
||||||
|
group: PlanInsert[],
|
||||||
|
): { text: string } {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
const state = parseDictateModuleReadState(
|
||||||
|
params.handlers.readTag(DICTATE_MODULE_READ_STATE_TAG),
|
||||||
|
);
|
||||||
|
let changed = false;
|
||||||
|
for (const item of group) {
|
||||||
|
const payload = item.moduleId ? params.moduleReadIndex?.get(item.moduleId) : undefined;
|
||||||
|
if (!payload) continue;
|
||||||
|
const catalogEntry = resolveModuleForInsertTag(item.tag, params.moduleCatalog);
|
||||||
|
if (!READ_EXEMPT.has(payload.module_id) && !catalogEntry?.auto) {
|
||||||
|
state.modules[payload.module_id] = payload.version;
|
||||||
|
state.active_module_id = payload.module_id;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
const parts = [
|
||||||
|
`### ${payload.module_id} @ ${payload.version}`,
|
||||||
|
`产物 tag:${item.tag}`,
|
||||||
|
payload.score_dimensions.length
|
||||||
|
? `self_score 维度(这次写入必填,name 必须一致):${payload.score_dimensions.join("、")}`
|
||||||
|
: "",
|
||||||
|
`#### task\n${payload.task}`,
|
||||||
|
`#### principles\n${payload.principles}`,
|
||||||
|
`#### output\n${payload.output}`,
|
||||||
|
`#### score\n${payload.score}`,
|
||||||
|
];
|
||||||
|
if (item.libraryId && item.entryIds.length) {
|
||||||
|
const loaded = readLibraryEntries({
|
||||||
|
module: payload,
|
||||||
|
libraryId: item.libraryId,
|
||||||
|
entryIds: item.entryIds,
|
||||||
|
reason: item.reason,
|
||||||
|
});
|
||||||
|
if (loaded.entries) {
|
||||||
|
for (const entry of loaded.entries) {
|
||||||
|
const row = entry as { id?: string; content?: string; name?: string };
|
||||||
|
parts.push(`#### 库 ${item.libraryId} / ${row.id ?? ""}\n${row.content ?? ""}`);
|
||||||
|
}
|
||||||
|
} else if (loaded.error) {
|
||||||
|
parts.push(`#### 库读取失败\n${loaded.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blocks.push(parts.filter(Boolean).join("\n\n"));
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
params.handlers.writeTag(DICTATE_MODULE_READ_STATE_TAG, serializeDictateModuleReadState(state));
|
||||||
|
}
|
||||||
|
return { text: blocks.join("\n\n") };
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalProductTag(
|
||||||
|
tag: string,
|
||||||
|
moduleIdHint: string,
|
||||||
|
catalog: PlannedRoundParams["moduleCatalog"],
|
||||||
|
): { tag: string; moduleId: string } | null {
|
||||||
|
const bare = tag.replace(/^(?:用户|设计)\./, "").split("#")[0]?.trim() ?? "";
|
||||||
|
const entry =
|
||||||
|
catalog?.modules.find((module) => module.id === moduleIdHint) ??
|
||||||
|
resolveModuleForInsertTag(tag, catalog) ??
|
||||||
|
catalog?.modules.find(
|
||||||
|
(module) =>
|
||||||
|
module.name === bare ||
|
||||||
|
module.id === bare ||
|
||||||
|
module.artifact === tag ||
|
||||||
|
module.artifact.endsWith(`.${bare}`),
|
||||||
|
);
|
||||||
|
if (!entry?.artifact?.trim()) return null;
|
||||||
|
const hash = tag.indexOf("#");
|
||||||
|
const suffix = hash > 0 ? tag.slice(hash) : "";
|
||||||
|
const next =
|
||||||
|
entry.repeatable && suffix.startsWith("#") && suffix.length > 1
|
||||||
|
? `${entry.artifact.trim()}${suffix}`
|
||||||
|
: entry.artifact.trim();
|
||||||
|
return { tag: next, moduleId: entry.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string {
|
||||||
|
const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean);
|
||||||
|
const lines = [
|
||||||
|
nodes.length ? `第一轮选定的节点:${nodes.join("、")}` : "",
|
||||||
|
group.length ? `本轮写入:${group.map((item) => item.tag).join("、")}` : "",
|
||||||
|
deletes.length ? `本轮删除:${deletes.join("、")}` : "",
|
||||||
|
errors.length
|
||||||
|
? `只重写未成功项:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}`
|
||||||
|
: "",
|
||||||
|
];
|
||||||
|
return lines.filter(Boolean).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function asItemList(value: unknown): unknown[] {
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
const rec = asRecord(value);
|
||||||
|
const values = Object.values(rec);
|
||||||
|
if (values.length > 0 && values.every((item) => item && typeof item === "object")) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenItem(item: unknown): Record<string, unknown> {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
const text = item.trim();
|
||||||
|
if (!text) return {};
|
||||||
|
if (text.startsWith("设计.") || text.startsWith("用户.")) return { tag: text };
|
||||||
|
return { module_id: text };
|
||||||
|
}
|
||||||
|
const rec = asRecord(item);
|
||||||
|
const nested = asRecord(rec.arguments ?? rec.args ?? rec.payload);
|
||||||
|
return { ...rec, ...nested };
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationName(rec: Record<string, unknown>): string {
|
||||||
|
return String(rec.op ?? rec.name ?? rec.type ?? rec.action ?? "insert").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.map((item) => String(item ?? "").trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(raw: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,66 @@ import type { ToolDefinition } from "../llm/client.js";
|
|||||||
/**
|
/**
|
||||||
* 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。
|
* 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。
|
||||||
*/
|
*/
|
||||||
|
/** 第一次请求仅此一个工具:点出下一步要写的节点。 */
|
||||||
|
export const DICTATE_SELECT_TOOL: ToolDefinition = {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "select_nodes",
|
||||||
|
description:
|
||||||
|
"规定下一步要写入的目标节点。nodes 只填能力目录中的 module_id。这一步不写产物正文。",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
nodes: {
|
||||||
|
type: "array",
|
||||||
|
description: "这段用户话要填充的节点。",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
module_id: { type: "string", description: "能力目录中的 id" },
|
||||||
|
},
|
||||||
|
required: ["module_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["nodes"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 写入循环的收尾。未通过的节点还在时,程序不把这份正文给用户。 */
|
||||||
|
export const DICTATE_REPLY_TOOL: ToolDefinition = {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "reply_user",
|
||||||
|
description:
|
||||||
|
"本步必调。与 insert 放在同一次请求:节点表写完后立刻提交给用户的综合回复。正文按系统提示「用户可见回复」写。",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
text: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"综合回复。先回应原话并说明形成了什么,再写自评最稳处;每个分数一般的缺口含短例子、一句体验变化和 2~3 个可接着说的选项;接着写下一步可以提及哪些,并补一个相关设定。多个产物合成一段。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["text"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const WRITE_LOOP_TOOL_NAMES = new Set([
|
||||||
|
"insert",
|
||||||
|
"delete",
|
||||||
|
"declare_variable",
|
||||||
|
"undeclare_variable",
|
||||||
|
"declare_map",
|
||||||
|
"remove_map",
|
||||||
|
]);
|
||||||
|
|
||||||
export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
|
export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||||
{
|
{
|
||||||
type: "function",
|
type: "function",
|
||||||
@@ -80,12 +140,12 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
position: {
|
position: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description:
|
description:
|
||||||
"产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白、设计.开场白#dorm;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id、设计.主角设定#dorm。勿用本工具写变量目录/映射。",
|
"产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白#opening-cycle2;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id、设计.主角设定#dorm。开场必须带场景短码,不要写裸「设计.开场白」或「设计.开场白与开场变量」。勿用本工具写变量目录/映射。",
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description:
|
description:
|
||||||
"落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白[#短码]=叙事正文(有正文组成时为 present.v1 JSON;否则 Markdown),必须含字面 @玩家,禁止写入用户角色.名字/简介;设计.主角设定[#同短码]=名字/背景/特殊设定 JSON,与开场同短码即绑定该开局",
|
"落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白#场景短码=这一张开场的 present.v1,blocks 必须含正文组成数据契约里的全部模型生成区(用来灌进已固定前端并替换示例),不要只写 shell+body,也不要填程序投影区;必须含字面 @玩家,名字不进 blocks。这条开场的玩家角色写在 meta.用户角色(名字+一到三句客观事实),程序同步为同短码主角设定。这张开场有初值才在同一份正文里写开场变量,没有就不写。设计.主角设定=共通的名字/背景/特殊设定 JSON;各开场不同的玩家角色写进开场 meta,不另写主角设定#短码",
|
||||||
},
|
},
|
||||||
order: {
|
order: {
|
||||||
type: "number",
|
type: "number",
|
||||||
@@ -283,3 +343,9 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 第二次起的工具。不含 read_module:写法已由程序注入。同一轮重试不改变这份清单。 */
|
||||||
|
export const DICTATE_WRITE_LOOP_TOOLS: ToolDefinition[] = [
|
||||||
|
...DICTATE_TOOL_DEFINITIONS.filter((tool) => WRITE_LOOP_TOOL_NAMES.has(tool.function.name)),
|
||||||
|
DICTATE_REPLY_TOOL,
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { LlmProvider } from "../llm/client.js";
|
import type { LlmProvider } from "../llm/client.js";
|
||||||
import {
|
import {
|
||||||
createLocalLlmDriver,
|
|
||||||
DriverIterationLimitError,
|
DriverIterationLimitError,
|
||||||
type AgentDriver,
|
type AgentDriver,
|
||||||
} from "../runtime/driver.js";
|
} from "../runtime/driver.js";
|
||||||
|
import { createPlannedDictateDriver } from "./round.js";
|
||||||
import { buildDictateMessages } from "./context.js";
|
import { buildDictateMessages } from "./context.js";
|
||||||
import { buildDictateSystemPrompt } from "./prompt.js";
|
import { buildDictateSystemPrompt } from "./prompt.js";
|
||||||
import { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
|
import { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
|
||||||
@@ -41,8 +41,14 @@ import {
|
|||||||
serializeVariableCatalog,
|
serializeVariableCatalog,
|
||||||
upsertVariableField,
|
upsertVariableField,
|
||||||
removeVariableField,
|
removeVariableField,
|
||||||
|
seedVariablesFromCatalog,
|
||||||
type UpsertVariableInput,
|
type UpsertVariableInput,
|
||||||
} from "../skills/variable-catalog.js";
|
} from "../skills/variable-catalog.js";
|
||||||
|
import {
|
||||||
|
VARIABLE_DESIGN_TAG,
|
||||||
|
syncVariableDesignToRuntime,
|
||||||
|
writeVariableDesignSync,
|
||||||
|
} from "../skills/variable-design-compile.js";
|
||||||
import {
|
import {
|
||||||
VALUE_MAP_TAG,
|
VALUE_MAP_TAG,
|
||||||
parseValueMapDoc,
|
parseValueMapDoc,
|
||||||
@@ -57,6 +63,46 @@ import {
|
|||||||
} from "../blackboard/table-cells.js";
|
} from "../blackboard/table-cells.js";
|
||||||
import { isDictateOpeningTag } from "./experience-anchor.js";
|
import { isDictateOpeningTag } from "./experience-anchor.js";
|
||||||
import { canonicalizeInsertedContent } from "../parse/json-doc.js";
|
import { canonicalizeInsertedContent } from "../parse/json-doc.js";
|
||||||
|
import {
|
||||||
|
openingContractError,
|
||||||
|
parseReplyDataContract,
|
||||||
|
replyContractSummary,
|
||||||
|
type ReplyDataContract,
|
||||||
|
} from "../skills/reply-contract.js";
|
||||||
|
|
||||||
|
function seedCurrentFromCatalog(handlers: DictateTurnHandlers): string[] {
|
||||||
|
const seeded = seedVariablesFromCatalog({
|
||||||
|
catalogRaw: handlers.readTag(VARIABLE_CATALOG_TAG),
|
||||||
|
currentRaw: handlers.readTag("变量.当前"),
|
||||||
|
initialRaw: handlers.readTag("运行.初始变量"),
|
||||||
|
actor: "system:declare_variable",
|
||||||
|
});
|
||||||
|
const wrote: string[] = [];
|
||||||
|
if (seeded.initial) {
|
||||||
|
handlers.writeTag("运行.初始变量", seeded.initial);
|
||||||
|
wrote.push("运行.初始变量");
|
||||||
|
}
|
||||||
|
if (seeded.current) {
|
||||||
|
handlers.writeTag("变量.当前", seeded.current);
|
||||||
|
wrote.push("变量.当前");
|
||||||
|
}
|
||||||
|
return wrote;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDesignProductToTable(
|
||||||
|
handlers: DictateTurnHandlers,
|
||||||
|
designContent?: string,
|
||||||
|
): string[] {
|
||||||
|
const sync = syncVariableDesignToRuntime({
|
||||||
|
designRaw: designContent ?? handlers.readTag(VARIABLE_DESIGN_TAG),
|
||||||
|
catalogRaw: handlers.readTag(VARIABLE_CATALOG_TAG),
|
||||||
|
currentRaw: handlers.readTag("变量.当前"),
|
||||||
|
initialRaw: handlers.readTag("运行.初始变量"),
|
||||||
|
});
|
||||||
|
return writeVariableDesignSync(sync, (tag, content) =>
|
||||||
|
handlers.writeTag(tag, content),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type DictateTurnHandlers = {
|
export type DictateTurnHandlers = {
|
||||||
listProducts: () => DictateProduct[];
|
listProducts: () => DictateProduct[];
|
||||||
@@ -127,7 +173,28 @@ export async function runDictateTurn(params: {
|
|||||||
repeatableFamilies?: ReadonlySet<string> | readonly string[];
|
repeatableFamilies?: ReadonlySet<string> | readonly string[];
|
||||||
driver?: AgentDriver;
|
driver?: AgentDriver;
|
||||||
}): Promise<DictateTurnResult> {
|
}): Promise<DictateTurnResult> {
|
||||||
const driver = params.driver ?? createLocalLlmDriver(params.llm);
|
const driver =
|
||||||
|
params.driver ??
|
||||||
|
createPlannedDictateDriver({
|
||||||
|
llm: params.llm,
|
||||||
|
dialogue: params.dialogue,
|
||||||
|
handlers: {
|
||||||
|
listProducts: params.handlers.listProducts,
|
||||||
|
readTag: params.handlers.readTag,
|
||||||
|
writeTag: params.handlers.writeTag,
|
||||||
|
prepareOpening: params.handlers.prepareOpening,
|
||||||
|
lookupInsertFeedback: params.handlers.lookupInsertFeedback,
|
||||||
|
onThinkingDelta: params.handlers.onThinkingDelta,
|
||||||
|
onThinkingDone: params.handlers.onThinkingDone,
|
||||||
|
},
|
||||||
|
moduleCatalog: params.moduleCatalog,
|
||||||
|
moduleReadIndex: params.moduleReadIndex,
|
||||||
|
moduleGuide: params.moduleGuide,
|
||||||
|
recipeExamples: params.recipeExamples,
|
||||||
|
recipeName: params.recipeName,
|
||||||
|
recipeBrief: params.recipeBrief,
|
||||||
|
systemPrompt: params.systemPrompt,
|
||||||
|
});
|
||||||
const wroteTags: string[] = [];
|
const wroteTags: string[] = [];
|
||||||
const deletedTags: string[] = [];
|
const deletedTags: string[] = [];
|
||||||
let clearedDialogue = false;
|
let clearedDialogue = false;
|
||||||
@@ -217,9 +284,31 @@ export async function runDictateTurn(params: {
|
|||||||
call.name,
|
call.name,
|
||||||
`${module.module_id}@${module.version}`,
|
`${module.module_id}@${module.version}`,
|
||||||
);
|
);
|
||||||
|
const replyContract =
|
||||||
|
module.module_id === "opening-setup" ||
|
||||||
|
module.module_id === "zero-layer-opening-setup"
|
||||||
|
? contractForOpeningTag(
|
||||||
|
module.module_id === "zero-layer-opening-setup"
|
||||||
|
? "设计.0层开场白与初态"
|
||||||
|
: "设计.开场白",
|
||||||
|
params.handlers.listProducts(),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
callId: call.id,
|
callId: call.id,
|
||||||
content: JSON.stringify({ ok: true, ...module }),
|
content: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
...module,
|
||||||
|
...(replyContract
|
||||||
|
? { reply_contract: replyContractSummary(replyContract) }
|
||||||
|
: module.module_id === "opening-setup" ||
|
||||||
|
module.module_id === "zero-layer-opening-setup"
|
||||||
|
? {
|
||||||
|
reply_contract_missing:
|
||||||
|
"还没有可执行的正文组成数据契约。先写正文组成,再写开场。",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (call.name === "read_library_entry") {
|
if (call.name === "read_library_entry") {
|
||||||
@@ -385,6 +474,18 @@ export async function runDictateTurn(params: {
|
|||||||
const order = parseDictateOrder(args.order);
|
const order = parseDictateOrder(args.order);
|
||||||
const productsBeforeWrite = params.handlers.listProducts();
|
const productsBeforeWrite = params.handlers.listProducts();
|
||||||
const isOpening = isDictateOpeningTag(pieceTag);
|
const isOpening = isDictateOpeningTag(pieceTag);
|
||||||
|
const contractMiss = openingContractMiss(
|
||||||
|
pieceTag,
|
||||||
|
piece.content,
|
||||||
|
params.handlers.listProducts(),
|
||||||
|
);
|
||||||
|
if (contractMiss) {
|
||||||
|
params.handlers.onToolCall?.(call.name, contractMiss.error);
|
||||||
|
return {
|
||||||
|
callId: call.id,
|
||||||
|
content: JSON.stringify(contractMiss),
|
||||||
|
};
|
||||||
|
}
|
||||||
const explicitlyPrepared = openingPrepared;
|
const explicitlyPrepared = openingPrepared;
|
||||||
if (isOpening && !explicitlyPrepared) {
|
if (isOpening && !explicitlyPrepared) {
|
||||||
params.handlers.prepareOpening?.();
|
params.handlers.prepareOpening?.();
|
||||||
@@ -464,6 +565,18 @@ export async function runDictateTurn(params: {
|
|||||||
const order = parseDictateOrder(args.order);
|
const order = parseDictateOrder(args.order);
|
||||||
const productsBeforeWrite = params.handlers.listProducts();
|
const productsBeforeWrite = params.handlers.listProducts();
|
||||||
const isOpening = isDictateOpeningTag(tag);
|
const isOpening = isDictateOpeningTag(tag);
|
||||||
|
const contractMiss = openingContractMiss(
|
||||||
|
tag,
|
||||||
|
content,
|
||||||
|
params.handlers.listProducts(),
|
||||||
|
);
|
||||||
|
if (contractMiss) {
|
||||||
|
params.handlers.onToolCall?.(call.name, contractMiss.error);
|
||||||
|
return {
|
||||||
|
callId: call.id,
|
||||||
|
content: JSON.stringify(contractMiss),
|
||||||
|
};
|
||||||
|
}
|
||||||
const explicitlyPrepared = openingPrepared;
|
const explicitlyPrepared = openingPrepared;
|
||||||
if (isOpening && !explicitlyPrepared) {
|
if (isOpening && !explicitlyPrepared) {
|
||||||
params.handlers.prepareOpening?.();
|
params.handlers.prepareOpening?.();
|
||||||
@@ -480,6 +593,10 @@ export async function runDictateTurn(params: {
|
|||||||
: {}),
|
: {}),
|
||||||
...(selfScore.value ? { selfScore: selfScore.value } : {}),
|
...(selfScore.value ? { selfScore: selfScore.value } : {}),
|
||||||
});
|
});
|
||||||
|
if (tag === VARIABLE_DESIGN_TAG || tag.startsWith(`${VARIABLE_DESIGN_TAG}#`)) {
|
||||||
|
const synced = syncDesignProductToTable(params.handlers, content);
|
||||||
|
wroteTags.push(...synced);
|
||||||
|
}
|
||||||
if (!isOpening) openingPrepared = false;
|
if (!isOpening) openingPrepared = false;
|
||||||
wroteTags.push(tag);
|
wroteTags.push(tag);
|
||||||
const detail =
|
const detail =
|
||||||
@@ -557,6 +674,8 @@ export async function runDictateTurn(params: {
|
|||||||
serializeVariableCatalog(doc),
|
serializeVariableCatalog(doc),
|
||||||
);
|
);
|
||||||
wroteTags.push(VARIABLE_CATALOG_TAG);
|
wroteTags.push(VARIABLE_CATALOG_TAG);
|
||||||
|
const seeded = seedCurrentFromCatalog(params.handlers);
|
||||||
|
wroteTags.push(...seeded);
|
||||||
const detail = `${input.key} visible=${doc.fields.find((f) => f.key === input.key)?.user_visible !== false}`;
|
const detail = `${input.key} visible=${doc.fields.find((f) => f.key === input.key)?.user_visible !== false}`;
|
||||||
params.handlers.onToolCall?.(call.name, detail);
|
params.handlers.onToolCall?.(call.name, detail);
|
||||||
return {
|
return {
|
||||||
@@ -826,6 +945,30 @@ function safeParseArgs(raw: string): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function contractForOpeningTag(
|
||||||
|
tag: string,
|
||||||
|
products: DictateProduct[],
|
||||||
|
): ReplyDataContract | undefined {
|
||||||
|
const want = tag.includes("0层") ? "设计.0层正文组成" : "设计.正文组成";
|
||||||
|
const raw =
|
||||||
|
products.find((product) => product.tag === want)?.content ??
|
||||||
|
products.find((product) => product.tag === "设计.回复格式")?.content;
|
||||||
|
return parseReplyDataContract(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openingContractMiss(
|
||||||
|
tag: string,
|
||||||
|
content: string,
|
||||||
|
products: DictateProduct[],
|
||||||
|
): { error: string; reply_contract?: ReturnType<typeof replyContractSummary> } | undefined {
|
||||||
|
if (!isDictateOpeningTag(tag)) return undefined;
|
||||||
|
const contract = contractForOpeningTag(tag, products);
|
||||||
|
if (!contract) return undefined;
|
||||||
|
const error = openingContractError(content, contract);
|
||||||
|
if (!error) return undefined;
|
||||||
|
return { error, reply_contract: replyContractSummary(contract) };
|
||||||
|
}
|
||||||
|
|
||||||
function requiresModuleRead(moduleId: string, auto?: boolean): boolean {
|
function requiresModuleRead(moduleId: string, auto?: boolean): boolean {
|
||||||
if (auto) return false;
|
if (auto) return false;
|
||||||
return !new Set([
|
return !new Set([
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import type { ChatMessage, LlmProvider } from "../llm/client.js";
|
||||||
|
import { tryParseJsonDoc } from "../parse/json-doc.js";
|
||||||
|
import {
|
||||||
|
intakeSystemPrompt,
|
||||||
|
nextWaveSystemPrompt,
|
||||||
|
outlineSystemPrompt,
|
||||||
|
redirectSystemPrompt,
|
||||||
|
sectionSystemPrompt,
|
||||||
|
} from "./prompt.js";
|
||||||
|
import type { ListenSerialState } from "./types.js";
|
||||||
|
|
||||||
|
export type IntakeLlmResult =
|
||||||
|
| { type: "ask"; reply: string }
|
||||||
|
| {
|
||||||
|
type: "propose";
|
||||||
|
reply: string;
|
||||||
|
thrill: string;
|
||||||
|
world: string;
|
||||||
|
structure: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OutlineLlmResult = {
|
||||||
|
titles: string[];
|
||||||
|
payoffTitle: string;
|
||||||
|
reply: string;
|
||||||
|
thrill?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SectionLlmResult = {
|
||||||
|
text: string;
|
||||||
|
summary: string;
|
||||||
|
fulfilled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RedirectLlmResult = {
|
||||||
|
titles: string[];
|
||||||
|
payoffTitle: string;
|
||||||
|
thrillOverride: string;
|
||||||
|
reply: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function completeJson(
|
||||||
|
llm: LlmProvider,
|
||||||
|
system: string,
|
||||||
|
user: string,
|
||||||
|
caller: string,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const messages: ChatMessage[] = [
|
||||||
|
{ role: "system", content: system },
|
||||||
|
{ role: "user", content: user },
|
||||||
|
];
|
||||||
|
const result = await llm.complete(messages, {
|
||||||
|
responseFormat: "json_object",
|
||||||
|
caller,
|
||||||
|
label: "听书连载",
|
||||||
|
});
|
||||||
|
const parsed = tryParseJsonDoc(result.content);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
throw new Error("听书连载:模型未返回合法 JSON 对象");
|
||||||
|
}
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(v: unknown): string {
|
||||||
|
return typeof v === "string" ? v.trim() : v == null ? "" : String(v).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function strList(v: unknown): string[] {
|
||||||
|
if (!Array.isArray(v)) return [];
|
||||||
|
return v.map((x) => str(x)).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function llmIntake(
|
||||||
|
llm: LlmProvider,
|
||||||
|
dialogue: Array<{ role: "user" | "assistant"; text: string }>,
|
||||||
|
): Promise<IntakeLlmResult> {
|
||||||
|
const transcript = dialogue
|
||||||
|
.map((t) => `${t.role === "user" ? "用户" : "助手"}:${t.text}`)
|
||||||
|
.join("\n\n");
|
||||||
|
const obj = await completeJson(
|
||||||
|
llm,
|
||||||
|
intakeSystemPrompt(),
|
||||||
|
transcript || "(用户尚未说话)",
|
||||||
|
"listen_serial_intake",
|
||||||
|
);
|
||||||
|
const type = str(obj.type) === "propose" ? "propose" : "ask";
|
||||||
|
const reply = str(obj.reply) || (type === "ask" ? "还需要一点材料。" : "请确认本局草案。");
|
||||||
|
if (type === "ask") return { type, reply };
|
||||||
|
return {
|
||||||
|
type: "propose",
|
||||||
|
reply,
|
||||||
|
thrill: str(obj.thrill),
|
||||||
|
world: str(obj.world),
|
||||||
|
structure: str(obj.structure),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function llmOutline(
|
||||||
|
llm: LlmProvider,
|
||||||
|
state: ListenSerialState,
|
||||||
|
userNote?: string,
|
||||||
|
): Promise<OutlineLlmResult> {
|
||||||
|
const obj = await completeJson(
|
||||||
|
llm,
|
||||||
|
outlineSystemPrompt(state),
|
||||||
|
userNote?.trim() || "请生成本段大纲。",
|
||||||
|
"listen_serial_outline",
|
||||||
|
);
|
||||||
|
const titles = strList(obj.titles);
|
||||||
|
const payoffTitle = str(obj.payoff_title) || titles[titles.length - 1] || "";
|
||||||
|
return {
|
||||||
|
titles,
|
||||||
|
payoffTitle,
|
||||||
|
reply: str(obj.reply) || "大纲已就绪,开始写正文。",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function llmNextWave(
|
||||||
|
llm: LlmProvider,
|
||||||
|
state: ListenSerialState,
|
||||||
|
): Promise<OutlineLlmResult> {
|
||||||
|
const obj = await completeJson(
|
||||||
|
llm,
|
||||||
|
nextWaveSystemPrompt(state),
|
||||||
|
"请开下一波爽点大纲。",
|
||||||
|
"listen_serial_next_wave",
|
||||||
|
);
|
||||||
|
const titles = strList(obj.titles);
|
||||||
|
return {
|
||||||
|
titles,
|
||||||
|
payoffTitle: str(obj.payoff_title) || titles[titles.length - 1] || "",
|
||||||
|
reply: str(obj.reply) || "下一波大纲已就绪。",
|
||||||
|
thrill: str(obj.thrill) || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function llmSection(
|
||||||
|
llm: LlmProvider,
|
||||||
|
state: ListenSerialState,
|
||||||
|
nextTitle: string,
|
||||||
|
): Promise<SectionLlmResult> {
|
||||||
|
const obj = await completeJson(
|
||||||
|
llm,
|
||||||
|
sectionSystemPrompt(state, nextTitle),
|
||||||
|
`请写标题「${nextTitle}」的正文。`,
|
||||||
|
"listen_serial_section",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
text: str(obj.text),
|
||||||
|
summary: str(obj.summary),
|
||||||
|
fulfilled: Boolean(obj.fulfilled),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function llmRedirect(
|
||||||
|
llm: LlmProvider,
|
||||||
|
state: ListenSerialState,
|
||||||
|
opinion: string,
|
||||||
|
): Promise<RedirectLlmResult> {
|
||||||
|
const obj = await completeJson(
|
||||||
|
llm,
|
||||||
|
redirectSystemPrompt(state, opinion),
|
||||||
|
opinion,
|
||||||
|
"listen_serial_redirect",
|
||||||
|
);
|
||||||
|
const titles = strList(obj.titles);
|
||||||
|
return {
|
||||||
|
titles,
|
||||||
|
payoffTitle: str(obj.payoff_title) || titles[titles.length - 1] || "",
|
||||||
|
thrillOverride: str(obj.thrill_override),
|
||||||
|
reply: str(obj.reply) || "已按你的意见改后面的走向。",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export {
|
||||||
|
DEFAULT_BUFFER_TARGET_CHARS,
|
||||||
|
LISTEN_SERIAL_MODE_VALUE,
|
||||||
|
LISTEN_PHASE_TAG,
|
||||||
|
LISTEN_THRILL_TAG,
|
||||||
|
LISTEN_WORLD_TAG,
|
||||||
|
LISTEN_STRUCTURE_TAG,
|
||||||
|
LISTEN_OUTLINE_TAG,
|
||||||
|
LISTEN_SECTIONS_TAG,
|
||||||
|
LISTEN_SUMMARIES_TAG,
|
||||||
|
isListenSerialModeValue,
|
||||||
|
type ListenPhase,
|
||||||
|
type ListenSerialState,
|
||||||
|
type ListenSerialView,
|
||||||
|
type OutlineItem,
|
||||||
|
type SectionItem,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
readListenState,
|
||||||
|
toListenView,
|
||||||
|
unreadChars,
|
||||||
|
ensureBufferTarget,
|
||||||
|
isConfirmText,
|
||||||
|
isNextWaveText,
|
||||||
|
isEndText,
|
||||||
|
makeOutlineItems,
|
||||||
|
applyRedirectCut,
|
||||||
|
writeListenBrief,
|
||||||
|
writeListenOutline,
|
||||||
|
writeListenPhase,
|
||||||
|
writeListenSections,
|
||||||
|
writeListenSummaries,
|
||||||
|
} from "./state.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
runListenSerialTurn,
|
||||||
|
markListenConsumed,
|
||||||
|
type ListenChatTurn,
|
||||||
|
type ListenSerialTurnResult,
|
||||||
|
} from "./turn.js";
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { ListenSerialState } from "./types.js";
|
||||||
|
import { MAX_SUMMARIES_IN_CONTEXT } from "./types.js";
|
||||||
|
|
||||||
|
const ROLE = `你是「听书连载」写手:快速产出打发时间的廉价爽文(短剧/短篇量级)。
|
||||||
|
文笔可以平、设定可以薄、前后可以略矛盾;但核心爽点必须兑现,节奏要直接。
|
||||||
|
不要写成长篇网文铺垫;允许跳过前史,直接切到打脸/兑现段。
|
||||||
|
输出必须是单个 JSON 对象,不要 markdown 围栏。`;
|
||||||
|
|
||||||
|
export function intakeSystemPrompt(): string {
|
||||||
|
return `${ROLE}
|
||||||
|
|
||||||
|
当前阶段:开书收集。根据用户几句话,要么轻回问(最多 1~2 个关键缺口),要么给出可确认的本局草案。
|
||||||
|
|
||||||
|
JSON schema:
|
||||||
|
{
|
||||||
|
"type": "ask" | "propose",
|
||||||
|
"reply": "给用户看的短回复(中文)",
|
||||||
|
"thrill": "核心爽点一句话(propose 必填)",
|
||||||
|
"world": "一句世界:基础世界+变造(propose 必填)",
|
||||||
|
"structure": "结构调味,如起承转合/三段式+本局怎么用(propose 必填)"
|
||||||
|
}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- type=ask 时不要编造 thrill/world/structure。
|
||||||
|
- type=propose 时三项都要具体、短;爽点要一眼能懂(身份反差/法律制裁/天道报应等)。
|
||||||
|
- 用户材料已够就直接 propose,别为问而问。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function outlineSystemPrompt(state: ListenSerialState): string {
|
||||||
|
return `${ROLE}
|
||||||
|
|
||||||
|
当前阶段:为本段生成大纲标题链,规划到「爽点兑现」为止(不要一次规划整本人生)。
|
||||||
|
|
||||||
|
已钉死:
|
||||||
|
- 核心爽点:${state.thrill}
|
||||||
|
- 一句世界:${state.world}
|
||||||
|
- 结构:${state.structure}
|
||||||
|
|
||||||
|
JSON schema:
|
||||||
|
{
|
||||||
|
"titles": ["标题1", "标题2", "..."],
|
||||||
|
"payoff_title": "其中作为本段兑现的那条标题(通常是最后一条)",
|
||||||
|
"reply": "给用户看的一句说明"
|
||||||
|
}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- titles 6~12 条为宜,每条是短标题,不是正文。
|
||||||
|
- 允许跳过最低境界等前史,直接切到兑现相关桥段。
|
||||||
|
- payoff_title 必须是 titles 中的一项。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sectionSystemPrompt(state: ListenSerialState, nextTitle: string): string {
|
||||||
|
const summaries = state.summaries.slice(-MAX_SUMMARIES_IN_CONTEXT);
|
||||||
|
return `${ROLE}
|
||||||
|
|
||||||
|
当前阶段:写下一节正文,并附小总结。
|
||||||
|
|
||||||
|
已钉死:
|
||||||
|
- 核心爽点:${state.thrill}
|
||||||
|
- 一句世界:${state.world}
|
||||||
|
- 结构:${state.structure}
|
||||||
|
|
||||||
|
近期小总结(仅此记忆,勿要求全文一致):
|
||||||
|
${summaries.length ? summaries.map((s, i) => `${i + 1}. ${s}`).join("\n") : "(尚无)"}
|
||||||
|
|
||||||
|
本节大纲标题:${nextTitle}
|
||||||
|
本节是否兑现节点:${
|
||||||
|
state.outline.find((o) => o.title === nextTitle && o.status === "pending")
|
||||||
|
?.payoff
|
||||||
|
? "是——写到爽点落地"
|
||||||
|
: "否——推进并留下钩子"
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON schema:
|
||||||
|
{
|
||||||
|
"text": "本节正文(约 600~1200 字,中文)",
|
||||||
|
"summary": "本节小总结(一两句,供后文上下文)",
|
||||||
|
"fulfilled": true/false
|
||||||
|
}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- fulfilled=true 仅当本节已兑现核心爽点(通常是兑现标题那节)。
|
||||||
|
- 文笔廉价直白即可;不要长篇内心独白。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redirectSystemPrompt(
|
||||||
|
state: ListenSerialState,
|
||||||
|
opinion: string,
|
||||||
|
): string {
|
||||||
|
const pending = state.outline
|
||||||
|
.filter((o) => o.status === "pending")
|
||||||
|
.map((o) => o.title);
|
||||||
|
const done = state.outline
|
||||||
|
.filter((o) => o.status === "done")
|
||||||
|
.map((o) => o.title);
|
||||||
|
return `${ROLE}
|
||||||
|
|
||||||
|
当前阶段:用户插嘴改向。只重排「尚未消费」的后续大纲;已听过的情节可不动,允许前后矛盾。
|
||||||
|
|
||||||
|
已钉死爽点(尽量仍朝它收,除非用户意见明确改爽点):${state.thrill}
|
||||||
|
一句世界:${state.world}
|
||||||
|
用户意见:${opinion}
|
||||||
|
|
||||||
|
已完成标题:${done.length ? done.join(" / ") : "(无)"}
|
||||||
|
原未写标题(将被替换):${pending.length ? pending.join(" / ") : "(无)"}
|
||||||
|
|
||||||
|
JSON schema:
|
||||||
|
{
|
||||||
|
"titles": ["新的后续标题1", "..."],
|
||||||
|
"payoff_title": "新的兑现标题",
|
||||||
|
"thrill_override": "若用户改了爽点则给新爽点,否则空字符串",
|
||||||
|
"reply": "给用户看的短确认"
|
||||||
|
}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- titles 规划到新的兑现为止;短、直接、服务用户意见。
|
||||||
|
- 不要复述已完成标题。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextWaveSystemPrompt(state: ListenSerialState): string {
|
||||||
|
const recent = state.summaries.slice(-MAX_SUMMARIES_IN_CONTEXT);
|
||||||
|
return `${ROLE}
|
||||||
|
|
||||||
|
上一波爽点已兑现,用户要「下一波」。在同一句世界下开新的兑现段大纲。
|
||||||
|
|
||||||
|
核心爽点(可沿用或小幅升级):${state.thrill}
|
||||||
|
一句世界:${state.world}
|
||||||
|
结构:${state.structure}
|
||||||
|
近期小总结:
|
||||||
|
${recent.length ? recent.map((s, i) => `${i + 1}. ${s}`).join("\n") : "(无)"}
|
||||||
|
|
||||||
|
JSON schema:
|
||||||
|
{
|
||||||
|
"titles": ["标题1", "..."],
|
||||||
|
"payoff_title": "本波兑现标题",
|
||||||
|
"thrill": "本波核心爽点(可与上波相同或升级)",
|
||||||
|
"reply": "给用户看的一句说明"
|
||||||
|
}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_BUFFER_TARGET_CHARS,
|
||||||
|
LISTEN_BUFFER_TARGET_TAG,
|
||||||
|
LISTEN_CONSUMED_CHARS_TAG,
|
||||||
|
LISTEN_OUTLINE_TAG,
|
||||||
|
LISTEN_PHASE_TAG,
|
||||||
|
LISTEN_SECTIONS_TAG,
|
||||||
|
LISTEN_STRUCTURE_TAG,
|
||||||
|
LISTEN_SUMMARIES_TAG,
|
||||||
|
LISTEN_THRILL_TAG,
|
||||||
|
LISTEN_WORLD_TAG,
|
||||||
|
MAX_SUMMARIES_IN_CONTEXT,
|
||||||
|
type ListenPhase,
|
||||||
|
type ListenSerialState,
|
||||||
|
type ListenSerialView,
|
||||||
|
type OutlineItem,
|
||||||
|
type SectionItem,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
function parseJson<T>(raw: string | undefined, fallback: T): T {
|
||||||
|
if (!raw?.trim()) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePhase(raw: string | undefined): ListenPhase {
|
||||||
|
const v = (raw ?? "").trim();
|
||||||
|
if (
|
||||||
|
v === "intake" ||
|
||||||
|
v === "awaiting_confirm" ||
|
||||||
|
v === "running" ||
|
||||||
|
v === "soft_stop"
|
||||||
|
) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
return "intake";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readListenState(board: Blackboard): ListenSerialState {
|
||||||
|
const bufferRaw = board.getContentByTag(LISTEN_BUFFER_TARGET_TAG);
|
||||||
|
const bufferTargetChars = Math.max(
|
||||||
|
500,
|
||||||
|
Number.parseInt(bufferRaw ?? "", 10) || DEFAULT_BUFFER_TARGET_CHARS,
|
||||||
|
);
|
||||||
|
const consumedRaw = board.getContentByTag(LISTEN_CONSUMED_CHARS_TAG);
|
||||||
|
const consumedChars = Math.max(0, Number.parseInt(consumedRaw ?? "", 10) || 0);
|
||||||
|
return {
|
||||||
|
phase: parsePhase(board.getContentByTag(LISTEN_PHASE_TAG)),
|
||||||
|
thrill: (board.getContentByTag(LISTEN_THRILL_TAG) ?? "").trim(),
|
||||||
|
world: (board.getContentByTag(LISTEN_WORLD_TAG) ?? "").trim(),
|
||||||
|
structure: (board.getContentByTag(LISTEN_STRUCTURE_TAG) ?? "").trim(),
|
||||||
|
outline: parseJson<OutlineItem[]>(board.getContentByTag(LISTEN_OUTLINE_TAG), []),
|
||||||
|
sections: parseJson<SectionItem[]>(
|
||||||
|
board.getContentByTag(LISTEN_SECTIONS_TAG),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
summaries: parseJson<string[]>(
|
||||||
|
board.getContentByTag(LISTEN_SUMMARIES_TAG),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
bufferTargetChars,
|
||||||
|
consumedChars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTag(board: Blackboard, tag: string, content: string): void {
|
||||||
|
board.write({ tag, content, source: "agent" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeListenPhase(board: Blackboard, phase: ListenPhase): void {
|
||||||
|
writeTag(board, LISTEN_PHASE_TAG, phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeListenBrief(
|
||||||
|
board: Blackboard,
|
||||||
|
brief: { thrill: string; world: string; structure: string },
|
||||||
|
): void {
|
||||||
|
writeTag(board, LISTEN_THRILL_TAG, brief.thrill.trim());
|
||||||
|
writeTag(board, LISTEN_WORLD_TAG, brief.world.trim());
|
||||||
|
writeTag(board, LISTEN_STRUCTURE_TAG, brief.structure.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeListenOutline(board: Blackboard, outline: OutlineItem[]): void {
|
||||||
|
writeTag(board, LISTEN_OUTLINE_TAG, JSON.stringify(outline));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeListenSections(board: Blackboard, sections: SectionItem[]): void {
|
||||||
|
writeTag(board, LISTEN_SECTIONS_TAG, JSON.stringify(sections));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeListenSummaries(board: Blackboard, summaries: string[]): void {
|
||||||
|
const trimmed = summaries
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(-MAX_SUMMARIES_IN_CONTEXT);
|
||||||
|
writeTag(board, LISTEN_SUMMARIES_TAG, JSON.stringify(trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeConsumedChars(board: Blackboard, n: number): void {
|
||||||
|
writeTag(board, LISTEN_CONSUMED_CHARS_TAG, String(Math.max(0, Math.floor(n))));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureBufferTarget(board: Blackboard): number {
|
||||||
|
const existing = board.getContentByTag(LISTEN_BUFFER_TARGET_TAG);
|
||||||
|
if (!existing?.trim()) {
|
||||||
|
writeTag(board, LISTEN_BUFFER_TARGET_TAG, String(DEFAULT_BUFFER_TARGET_CHARS));
|
||||||
|
return DEFAULT_BUFFER_TARGET_CHARS;
|
||||||
|
}
|
||||||
|
return Math.max(
|
||||||
|
500,
|
||||||
|
Number.parseInt(existing, 10) || DEFAULT_BUFFER_TARGET_CHARS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unreadChars(state: ListenSerialState): number {
|
||||||
|
const total = state.sections.reduce((n, s) => n + s.text.length, 0);
|
||||||
|
return Math.max(0, total - state.consumedChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toListenView(state: ListenSerialState): ListenSerialView {
|
||||||
|
return {
|
||||||
|
phase: state.phase,
|
||||||
|
thrill: state.thrill,
|
||||||
|
world: state.world,
|
||||||
|
structure: state.structure,
|
||||||
|
outline: state.outline.map((o) => ({
|
||||||
|
id: o.id,
|
||||||
|
title: o.title,
|
||||||
|
status: o.status,
|
||||||
|
payoff: o.payoff,
|
||||||
|
})),
|
||||||
|
sections: state.sections.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
outlineId: s.outlineId,
|
||||||
|
title: s.title,
|
||||||
|
text: s.text,
|
||||||
|
summary: s.summary,
|
||||||
|
consumedChars: s.consumedChars,
|
||||||
|
})),
|
||||||
|
unreadChars: unreadChars(state),
|
||||||
|
bufferTargetChars: state.bufferTargetChars,
|
||||||
|
consumedChars: state.consumedChars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未写完的大纲项 */
|
||||||
|
export function nextPendingOutline(state: ListenSerialState): OutlineItem | undefined {
|
||||||
|
return state.outline.find((o) => o.status === "pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeOutlineItems(
|
||||||
|
titles: string[],
|
||||||
|
payoffTitle?: string,
|
||||||
|
): OutlineItem[] {
|
||||||
|
const payoff = (payoffTitle ?? titles[titles.length - 1] ?? "").trim();
|
||||||
|
return titles
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((title) => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
title,
|
||||||
|
status: "pending" as const,
|
||||||
|
payoff: payoff ? title === payoff : false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插嘴:丢掉未消费正文尾部,并把对应大纲改回 pending / 用新标题替换未完成段 */
|
||||||
|
export function applyRedirectCut(
|
||||||
|
state: ListenSerialState,
|
||||||
|
newPendingTitles: string[],
|
||||||
|
): Pick<ListenSerialState, "outline" | "sections" | "summaries" | "consumedChars"> {
|
||||||
|
let keptChars = 0;
|
||||||
|
const keptSections: SectionItem[] = [];
|
||||||
|
for (const sec of state.sections) {
|
||||||
|
const remaining = sec.text.length - sec.consumedChars;
|
||||||
|
if (sec.consumedChars <= 0 && remaining === sec.text.length) {
|
||||||
|
// 整节未消费:丢弃
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sec.consumedChars >= sec.text.length) {
|
||||||
|
keptSections.push(sec);
|
||||||
|
keptChars += sec.text.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 部分消费:截断到已消费
|
||||||
|
const cutText = sec.text.slice(0, sec.consumedChars);
|
||||||
|
keptSections.push({
|
||||||
|
...sec,
|
||||||
|
text: cutText,
|
||||||
|
summary: sec.summary,
|
||||||
|
consumedChars: cutText.length,
|
||||||
|
});
|
||||||
|
keptChars += cutText.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doneOutlineIds = new Set(
|
||||||
|
keptSections.map((s) => s.outlineId).filter(Boolean),
|
||||||
|
);
|
||||||
|
const keptOutline = state.outline
|
||||||
|
.filter((o) => o.status === "done" && doneOutlineIds.has(o.id))
|
||||||
|
.map((o) => ({ ...o, status: "done" as const }));
|
||||||
|
|
||||||
|
const newItems = makeOutlineItems(newPendingTitles);
|
||||||
|
// 最后一节标为兑现
|
||||||
|
if (newItems.length) {
|
||||||
|
for (const item of newItems) item.payoff = false;
|
||||||
|
newItems[newItems.length - 1]!.payoff = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaries = keptSections
|
||||||
|
.map((s) => s.summary)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(-MAX_SUMMARIES_IN_CONTEXT);
|
||||||
|
|
||||||
|
return {
|
||||||
|
outline: [...keptOutline, ...newItems],
|
||||||
|
sections: keptSections,
|
||||||
|
summaries,
|
||||||
|
consumedChars: keptChars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markOutlineDone(
|
||||||
|
outline: OutlineItem[],
|
||||||
|
outlineId: string,
|
||||||
|
): OutlineItem[] {
|
||||||
|
return outline.map((o) =>
|
||||||
|
o.id === outlineId ? { ...o, status: "done" as const } : o,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConfirmText(text: string): boolean {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t) return false;
|
||||||
|
return /^(确认|就这样|开始|可以|好的|没问题|开写|写吧)([!!。.\s]*)$/u.test(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNextWaveText(text: string): boolean {
|
||||||
|
const t = text.trim();
|
||||||
|
return /下一波|再来一波|继续下一|开下一波/u.test(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEndText(text: string): boolean {
|
||||||
|
const t = text.trim();
|
||||||
|
return /^(收工|结束|完结|停)([!!。.\s]*)$/u.test(t);
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||||
|
import type { LlmProvider } from "../llm/client.js";
|
||||||
|
import {
|
||||||
|
llmIntake,
|
||||||
|
llmNextWave,
|
||||||
|
llmOutline,
|
||||||
|
llmRedirect,
|
||||||
|
llmSection,
|
||||||
|
} from "./generate.js";
|
||||||
|
import {
|
||||||
|
applyRedirectCut,
|
||||||
|
ensureBufferTarget,
|
||||||
|
isConfirmText,
|
||||||
|
isEndText,
|
||||||
|
isNextWaveText,
|
||||||
|
makeOutlineItems,
|
||||||
|
markOutlineDone,
|
||||||
|
nextPendingOutline,
|
||||||
|
readListenState,
|
||||||
|
unreadChars,
|
||||||
|
writeConsumedChars,
|
||||||
|
writeListenBrief,
|
||||||
|
writeListenOutline,
|
||||||
|
writeListenPhase,
|
||||||
|
writeListenSections,
|
||||||
|
writeListenSummaries,
|
||||||
|
} from "./state.js";
|
||||||
|
import { MAX_SECTIONS_PER_BURST, type ListenSerialState } from "./types.js";
|
||||||
|
|
||||||
|
export type ListenChatTurn = { role: "user" | "assistant"; text: string };
|
||||||
|
|
||||||
|
export type ListenSerialTurnResult = {
|
||||||
|
reply: string;
|
||||||
|
/** 本轮是否进入/保持软停 */
|
||||||
|
softStop?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function persistState(
|
||||||
|
board: Blackboard,
|
||||||
|
state: ListenSerialState,
|
||||||
|
patch: Partial<ListenSerialState>,
|
||||||
|
): ListenSerialState {
|
||||||
|
const next: ListenSerialState = { ...state, ...patch };
|
||||||
|
if (patch.phase) writeListenPhase(board, patch.phase);
|
||||||
|
if (patch.thrill != null || patch.world != null || patch.structure != null) {
|
||||||
|
writeListenBrief(board, {
|
||||||
|
thrill: next.thrill,
|
||||||
|
world: next.world,
|
||||||
|
structure: next.structure,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (patch.outline) writeListenOutline(board, next.outline);
|
||||||
|
if (patch.sections) writeListenSections(board, next.sections);
|
||||||
|
if (patch.summaries) writeListenSummaries(board, next.summaries);
|
||||||
|
if (patch.consumedChars != null) writeConsumedChars(board, next.consumedChars);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillBuffer(
|
||||||
|
llm: LlmProvider,
|
||||||
|
board: Blackboard,
|
||||||
|
state: ListenSerialState,
|
||||||
|
): Promise<{ state: ListenSerialState; notes: string[]; softStop: boolean }> {
|
||||||
|
let cur = state;
|
||||||
|
const notes: string[] = [];
|
||||||
|
let softStop = false;
|
||||||
|
let wrote = 0;
|
||||||
|
|
||||||
|
while (
|
||||||
|
unreadChars(cur) < cur.bufferTargetChars &&
|
||||||
|
wrote < MAX_SECTIONS_PER_BURST
|
||||||
|
) {
|
||||||
|
const pending = nextPendingOutline(cur);
|
||||||
|
if (!pending) {
|
||||||
|
softStop = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const section = await llmSection(llm, cur, pending.title);
|
||||||
|
if (!section.text) {
|
||||||
|
notes.push(`「${pending.title}」正文为空,已跳过。`);
|
||||||
|
cur = persistState(board, cur, {
|
||||||
|
outline: markOutlineDone(cur.outline, pending.id),
|
||||||
|
});
|
||||||
|
wrote += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const item = {
|
||||||
|
id: randomUUID(),
|
||||||
|
outlineId: pending.id,
|
||||||
|
title: pending.title,
|
||||||
|
text: section.text,
|
||||||
|
summary: section.summary || pending.title,
|
||||||
|
consumedChars: 0,
|
||||||
|
};
|
||||||
|
const summaries = [...cur.summaries, item.summary];
|
||||||
|
let outline = markOutlineDone(cur.outline, pending.id);
|
||||||
|
cur = persistState(board, cur, {
|
||||||
|
outline,
|
||||||
|
sections: [...cur.sections, item],
|
||||||
|
summaries,
|
||||||
|
phase: "running",
|
||||||
|
});
|
||||||
|
notes.push(`已写:${pending.title}(${section.text.length} 字)`);
|
||||||
|
wrote += 1;
|
||||||
|
|
||||||
|
if (pending.payoff || section.fulfilled) {
|
||||||
|
// 兑现后把剩余 pending 标 cut,进入软停(缓冲里已有兑现节可听完)
|
||||||
|
outline = cur.outline.map((o) =>
|
||||||
|
o.status === "pending" ? { ...o, status: "cut" as const } : o,
|
||||||
|
);
|
||||||
|
cur = persistState(board, cur, { outline, phase: "soft_stop" });
|
||||||
|
softStop = true;
|
||||||
|
notes.push("本段爽点已兑现,听完缓冲后可「下一波」或「收工」。");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!softStop && !nextPendingOutline(cur) && unreadChars(cur) > 0) {
|
||||||
|
cur = persistState(board, cur, { phase: "soft_stop" });
|
||||||
|
softStop = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: cur, notes, softStop };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUnreadLine(state: ListenSerialState): string {
|
||||||
|
const n = unreadChars(state);
|
||||||
|
return `目前约有 ${n} 字未读/未听(目标缓冲 ${state.bufferTargetChars} 字)。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOutlineLine(state: ListenSerialState): string {
|
||||||
|
if (!state.outline.length) return "";
|
||||||
|
return (
|
||||||
|
"大纲:\n" +
|
||||||
|
state.outline
|
||||||
|
.map((o) => {
|
||||||
|
const mark =
|
||||||
|
o.status === "done" ? "✓" : o.status === "cut" ? "×" : "·";
|
||||||
|
const pay = o.payoff ? "(兑现)" : "";
|
||||||
|
return `${mark} ${o.title}${pay}`;
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 听书连载单轮:开书 / 确认 / 补货 / 插嘴 / 软停续波。
|
||||||
|
*/
|
||||||
|
export async function runListenSerialTurn(params: {
|
||||||
|
llm: LlmProvider;
|
||||||
|
board: Blackboard;
|
||||||
|
dialogue: ListenChatTurn[];
|
||||||
|
userText: string;
|
||||||
|
}): Promise<ListenSerialTurnResult> {
|
||||||
|
const { llm, board, dialogue, userText } = params;
|
||||||
|
ensureBufferTarget(board);
|
||||||
|
let state = readListenState(board);
|
||||||
|
const text = userText.trim();
|
||||||
|
|
||||||
|
// —— 软停:收工 / 下一波 ——
|
||||||
|
if (state.phase === "soft_stop") {
|
||||||
|
if (isEndText(text)) {
|
||||||
|
return {
|
||||||
|
reply: "本局收工。想再开可以新建作品,或再说「下一波」。",
|
||||||
|
softStop: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isNextWaveText(text) || /继续|再来/u.test(text)) {
|
||||||
|
const wave = await llmNextWave(llm, state);
|
||||||
|
if (wave.thrill) {
|
||||||
|
state = persistState(board, state, { thrill: wave.thrill });
|
||||||
|
}
|
||||||
|
const outline = makeOutlineItems(wave.titles, wave.payoffTitle);
|
||||||
|
state = persistState(board, state, {
|
||||||
|
outline,
|
||||||
|
phase: "running",
|
||||||
|
});
|
||||||
|
const filled = await fillBuffer(llm, board, state);
|
||||||
|
state = filled.state;
|
||||||
|
const parts = [
|
||||||
|
wave.reply,
|
||||||
|
formatOutlineLine(state),
|
||||||
|
filled.notes.join("\n"),
|
||||||
|
formatUnreadLine(state),
|
||||||
|
].filter(Boolean);
|
||||||
|
if (filled.softStop) {
|
||||||
|
parts.push("本波已兑现。可输入「下一波」或「收工」。");
|
||||||
|
}
|
||||||
|
return { reply: parts.join("\n\n"), softStop: filled.softStop };
|
||||||
|
}
|
||||||
|
// 软停时其他话当作插嘴改向再开波
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 等待确认 ——
|
||||||
|
if (state.phase === "awaiting_confirm") {
|
||||||
|
if (!isConfirmText(text) && text && !/确认|开始|开写/u.test(text)) {
|
||||||
|
// 用户继续改需求 → 回到 intake 再提案
|
||||||
|
const intake = await llmIntake(llm, dialogue);
|
||||||
|
if (intake.type === "ask") {
|
||||||
|
state = persistState(board, state, { phase: "intake" });
|
||||||
|
return { reply: intake.reply };
|
||||||
|
}
|
||||||
|
state = persistState(board, state, {
|
||||||
|
phase: "awaiting_confirm",
|
||||||
|
thrill: intake.thrill,
|
||||||
|
world: intake.world,
|
||||||
|
structure: intake.structure,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${intake.structure}\n\n回复「确认」开始生成大纲与正文。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const outlined = await llmOutline(llm, state);
|
||||||
|
const outline = makeOutlineItems(outlined.titles, outlined.payoffTitle);
|
||||||
|
state = persistState(board, state, { outline, phase: "running" });
|
||||||
|
const filled = await fillBuffer(llm, board, state);
|
||||||
|
state = filled.state;
|
||||||
|
return {
|
||||||
|
reply: [
|
||||||
|
outlined.reply,
|
||||||
|
formatOutlineLine(state),
|
||||||
|
filled.notes.join("\n"),
|
||||||
|
formatUnreadLine(state),
|
||||||
|
filled.softStop
|
||||||
|
? "本段已兑现。可「下一波」或「收工」。"
|
||||||
|
: "不满意某桥段就直接说一句意见,我会改后面的走向。",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n"),
|
||||||
|
softStop: filled.softStop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 连载中:空输入/继续 = 补货;有意见 = 改向 ——
|
||||||
|
if (state.phase === "running" || state.phase === "soft_stop") {
|
||||||
|
const isContinue =
|
||||||
|
!text ||
|
||||||
|
/^(继续|接着写|补货|下一节)([!!。.\s]*)$/u.test(text);
|
||||||
|
|
||||||
|
if (!isContinue && text) {
|
||||||
|
const redirect = await llmRedirect(llm, state, text);
|
||||||
|
const cut = applyRedirectCut(state, redirect.titles);
|
||||||
|
// 重新标记 payoff
|
||||||
|
const outline = cut.outline.map((o, i, arr) => ({
|
||||||
|
...o,
|
||||||
|
payoff:
|
||||||
|
o.status === "pending" &&
|
||||||
|
(o.title === redirect.payoffTitle || i === arr.length - 1),
|
||||||
|
}));
|
||||||
|
// 只让最后一个 pending 为 payoff
|
||||||
|
let lastPending = -1;
|
||||||
|
outline.forEach((o, i) => {
|
||||||
|
if (o.status === "pending") lastPending = i;
|
||||||
|
if (o.status === "pending") o.payoff = false;
|
||||||
|
});
|
||||||
|
if (lastPending >= 0) outline[lastPending]!.payoff = true;
|
||||||
|
|
||||||
|
state = persistState(board, state, {
|
||||||
|
...cut,
|
||||||
|
outline,
|
||||||
|
phase: "running",
|
||||||
|
...(redirect.thrillOverride
|
||||||
|
? { thrill: redirect.thrillOverride }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
const filled = await fillBuffer(llm, board, state);
|
||||||
|
state = filled.state;
|
||||||
|
return {
|
||||||
|
reply: [
|
||||||
|
redirect.reply,
|
||||||
|
formatOutlineLine(state),
|
||||||
|
filled.notes.join("\n"),
|
||||||
|
formatUnreadLine(state),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n"),
|
||||||
|
softStop: filled.softStop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const filled = await fillBuffer(llm, board, state);
|
||||||
|
state = filled.state;
|
||||||
|
if (!filled.notes.length && unreadChars(state) >= state.bufferTargetChars) {
|
||||||
|
return {
|
||||||
|
reply: `${formatUnreadLine(state)}\n缓冲仍充足。听/读一些后再说「继续」,或直接一句意见改向。`,
|
||||||
|
softStop: state.phase === "soft_stop",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
reply: [
|
||||||
|
filled.notes.join("\n") || "已尝试补货。",
|
||||||
|
formatOutlineLine(state),
|
||||||
|
formatUnreadLine(state),
|
||||||
|
filled.softStop ? "本段已兑现。可「下一波」或「收工」。" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n"),
|
||||||
|
softStop: filled.softStop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— intake ——
|
||||||
|
const intake = await llmIntake(llm, dialogue);
|
||||||
|
if (intake.type === "ask") {
|
||||||
|
state = persistState(board, state, { phase: "intake" });
|
||||||
|
return { reply: intake.reply };
|
||||||
|
}
|
||||||
|
if (!intake.thrill || !intake.world) {
|
||||||
|
return {
|
||||||
|
reply: intake.reply || "还需要核心爽点和一句世界,请再补一句。",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
state = persistState(board, state, {
|
||||||
|
phase: "awaiting_confirm",
|
||||||
|
thrill: intake.thrill,
|
||||||
|
world: intake.world,
|
||||||
|
structure: intake.structure || "三段式:铺垫冲突 → 对抗升级 → 爽点兑现",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${state.structure}\n\n回复「确认」开始生成大纲与正文。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 前端标记已读:推进消费指针 */
|
||||||
|
export function markListenConsumed(
|
||||||
|
board: Blackboard,
|
||||||
|
absoluteConsumedChars: number,
|
||||||
|
): ListenSerialState {
|
||||||
|
const state = readListenState(board);
|
||||||
|
const total = state.sections.reduce((n, s) => n + s.text.length, 0);
|
||||||
|
const next = Math.max(0, Math.min(total, Math.floor(absoluteConsumedChars)));
|
||||||
|
let remaining = next;
|
||||||
|
const sections = state.sections.map((sec) => {
|
||||||
|
const take = Math.min(sec.text.length, Math.max(0, remaining));
|
||||||
|
remaining -= take;
|
||||||
|
return { ...sec, consumedChars: take };
|
||||||
|
});
|
||||||
|
writeListenSections(board, sections);
|
||||||
|
writeConsumedChars(board, next);
|
||||||
|
return { ...state, sections, consumedChars: next };
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/** 听书连载 · 类型与常量 */
|
||||||
|
|
||||||
|
export const LISTEN_SERIAL_MODE_VALUE = "listen_serial";
|
||||||
|
|
||||||
|
export const LISTEN_PHASE_TAG = "听书.阶段";
|
||||||
|
export const LISTEN_THRILL_TAG = "听书.核心爽点";
|
||||||
|
export const LISTEN_WORLD_TAG = "听书.一句世界";
|
||||||
|
export const LISTEN_STRUCTURE_TAG = "听书.结构";
|
||||||
|
export const LISTEN_OUTLINE_TAG = "听书.大纲";
|
||||||
|
export const LISTEN_SECTIONS_TAG = "听书.正文节";
|
||||||
|
export const LISTEN_SUMMARIES_TAG = "听书.小总结";
|
||||||
|
export const LISTEN_BUFFER_TARGET_TAG = "听书.缓冲目标字数";
|
||||||
|
export const LISTEN_CONSUMED_CHARS_TAG = "听书.已消费字数";
|
||||||
|
|
||||||
|
/** 默认未读/未听缓冲目标(字)——实现默认值,可后续调 */
|
||||||
|
export const DEFAULT_BUFFER_TARGET_CHARS = 4000;
|
||||||
|
/** 单次用户触发最多连写几节,避免一次请求过长 */
|
||||||
|
export const MAX_SECTIONS_PER_BURST = 3;
|
||||||
|
/** 进模型的滚动小总结条数上限 */
|
||||||
|
export const MAX_SUMMARIES_IN_CONTEXT = 8;
|
||||||
|
|
||||||
|
export type ListenPhase =
|
||||||
|
| "intake"
|
||||||
|
| "awaiting_confirm"
|
||||||
|
| "running"
|
||||||
|
| "soft_stop";
|
||||||
|
|
||||||
|
export type OutlineItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
/** pending=未写;done=已有正文;cut=被插嘴砍掉 */
|
||||||
|
status: "pending" | "done" | "cut";
|
||||||
|
/** 本段爽点兑现节点 */
|
||||||
|
payoff?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SectionItem = {
|
||||||
|
id: string;
|
||||||
|
outlineId: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
summary: string;
|
||||||
|
/** 用户已听/读过的字数(相对本节);改向时整节未消费可丢 */
|
||||||
|
consumedChars: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListenSerialState = {
|
||||||
|
phase: ListenPhase;
|
||||||
|
thrill: string;
|
||||||
|
world: string;
|
||||||
|
structure: string;
|
||||||
|
outline: OutlineItem[];
|
||||||
|
sections: SectionItem[];
|
||||||
|
summaries: string[];
|
||||||
|
bufferTargetChars: number;
|
||||||
|
/** 全局已消费正文字数(用于 UI「已听」) */
|
||||||
|
consumedChars: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListenSerialView = {
|
||||||
|
phase: ListenPhase;
|
||||||
|
thrill: string;
|
||||||
|
world: string;
|
||||||
|
structure: string;
|
||||||
|
outline: Array<{ id: string; title: string; status: string; payoff?: boolean }>;
|
||||||
|
sections: Array<{
|
||||||
|
id: string;
|
||||||
|
outlineId: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
summary: string;
|
||||||
|
consumedChars: number;
|
||||||
|
}>;
|
||||||
|
unreadChars: number;
|
||||||
|
bufferTargetChars: number;
|
||||||
|
consumedChars: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isListenSerialModeValue(raw: string | undefined | null): boolean {
|
||||||
|
return (raw ?? "").trim() === LISTEN_SERIAL_MODE_VALUE;
|
||||||
|
}
|
||||||
@@ -57,8 +57,33 @@ export type TokenUsage = {
|
|||||||
cachedTokens?: number;
|
cachedTokens?: number;
|
||||||
/** Prompt tokens not served from cache (DeepSeek prompt_cache_miss_tokens) */
|
/** Prompt tokens not served from cache (DeepSeek prompt_cache_miss_tokens) */
|
||||||
cacheMissTokens?: number;
|
cacheMissTokens?: number;
|
||||||
|
/** 上游若单独返回缓存写入 token(如 cache_creation_input_tokens) */
|
||||||
|
cacheWriteTokens?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 用量行上的调度信息。不包含提示词或产物正文。 */
|
||||||
|
export type DictateUsageStep = "select" | "fill" | "fill-retry" | "reply" | "play";
|
||||||
|
|
||||||
|
export type UsageDetail = {
|
||||||
|
turnId?: string;
|
||||||
|
step?: DictateUsageStep;
|
||||||
|
retryIndex?: number;
|
||||||
|
segmentChars?: {
|
||||||
|
rules: number;
|
||||||
|
history: number;
|
||||||
|
products: number;
|
||||||
|
injected: number;
|
||||||
|
tail: number;
|
||||||
|
};
|
||||||
|
plannedTags?: string[];
|
||||||
|
succeededTags?: string[];
|
||||||
|
failedTags?: string[];
|
||||||
|
finish?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** complete 返回后由统计层写入 recordId,供补记成功或失败的 tag。 */
|
||||||
|
export type UsageSink = { recordId?: string };
|
||||||
|
|
||||||
export type CompleteResult = {
|
export type CompleteResult = {
|
||||||
content: string;
|
content: string;
|
||||||
/** 推理模型思维链(如 DeepSeek reasoner 的 reasoning_content) */
|
/** 推理模型思维链(如 DeepSeek reasoner 的 reasoning_content) */
|
||||||
@@ -84,6 +109,14 @@ export type CompleteOptions = {
|
|||||||
label?: string;
|
label?: string;
|
||||||
/** 取消进行中的 LLM 请求(停止并重试) */
|
/** 取消进行中的 LLM 请求(停止并重试) */
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
/**
|
||||||
|
* 本局会话 id。仅 OpenRouter 会写成请求体 `session_id`,用来粘住同一上游,
|
||||||
|
* 避免开头 message 一变就换缓存节点。其它渠道不发送。
|
||||||
|
*/
|
||||||
|
sessionId?: string;
|
||||||
|
/** 统计补充。不进上游请求体。 */
|
||||||
|
usageDetail?: UsageDetail;
|
||||||
|
usageSink?: UsageSink;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ToolChoice =
|
export type ToolChoice =
|
||||||
@@ -132,6 +165,33 @@ export type LlmProvider = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const JSON_OBJECT_HINT = "请用 JSON 对象回复。";
|
const JSON_OBJECT_HINT = "请用 JSON 对象回复。";
|
||||||
|
const OPENROUTER_SESSION_ID_MAX = 256;
|
||||||
|
|
||||||
|
/** OpenRouter 用 `session_id` 做粘滞路由;其它 OpenAI 兼容渠道不认识这个字段。 */
|
||||||
|
export function isOpenRouterBaseUrl(baseUrl: string): boolean {
|
||||||
|
const trimmed = baseUrl.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
try {
|
||||||
|
const withProtocol = /^https?:\/\//i.test(trimmed)
|
||||||
|
? trimmed
|
||||||
|
: `https://${trimmed}`;
|
||||||
|
const host = new URL(withProtocol).hostname.toLowerCase();
|
||||||
|
return host === "openrouter.ai" || host.endsWith(".openrouter.ai");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合法则返回要写入请求体的 session_id;非 OpenRouter 或空 id 则不写。 */
|
||||||
|
export function openRouterSessionId(
|
||||||
|
baseUrl: string,
|
||||||
|
sessionId: string | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!isOpenRouterBaseUrl(baseUrl)) return undefined;
|
||||||
|
const id = sessionId?.trim();
|
||||||
|
if (!id) return undefined;
|
||||||
|
return id.slice(0, OPENROUTER_SESSION_ID_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
function messagesMentionJson(messages: ChatMessage[]): boolean {
|
function messagesMentionJson(messages: ChatMessage[]): boolean {
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
@@ -210,6 +270,9 @@ export function buildRequestBody(
|
|||||||
body.stream_options = { include_usage: true };
|
body.stream_options = { include_usage: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionId = openRouterSessionId(config.baseUrl, options?.sessionId);
|
||||||
|
if (sessionId) body.session_id = sessionId;
|
||||||
|
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +295,20 @@ function readCacheMissTokens(u: Record<string, unknown>): number | undefined {
|
|||||||
return readFiniteNumber(u.prompt_cache_miss_tokens ?? u.promptCacheMissTokens);
|
return readFiniteNumber(u.prompt_cache_miss_tokens ?? u.promptCacheMissTokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readCacheWriteTokens(u: Record<string, unknown>): number | undefined {
|
||||||
|
const details = u.prompt_tokens_details ?? u.promptTokensDetails;
|
||||||
|
if (details && typeof details === "object") {
|
||||||
|
const row = details as Record<string, unknown>;
|
||||||
|
const nested = readFiniteNumber(row.cache_write_tokens ?? row.cacheWriteTokens);
|
||||||
|
if (nested != null) return nested;
|
||||||
|
}
|
||||||
|
return readFiniteNumber(
|
||||||
|
u.cache_creation_input_tokens ??
|
||||||
|
u.cache_write_input_tokens ??
|
||||||
|
u.cacheWriteTokens,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse OpenAI-compatible usage object, including provider-specific cache fields. */
|
/** Parse OpenAI-compatible usage object, including provider-specific cache fields. */
|
||||||
export function parseUsage(raw: unknown): TokenUsage | undefined {
|
export function parseUsage(raw: unknown): TokenUsage | undefined {
|
||||||
if (!raw || typeof raw !== "object") return undefined;
|
if (!raw || typeof raw !== "object") return undefined;
|
||||||
@@ -243,6 +320,7 @@ export function parseUsage(raw: unknown): TokenUsage | undefined {
|
|||||||
|
|
||||||
const cachedTokens = readCachedTokens(u);
|
const cachedTokens = readCachedTokens(u);
|
||||||
const cacheMissTokens = readCacheMissTokens(u);
|
const cacheMissTokens = readCacheMissTokens(u);
|
||||||
|
const cacheWriteTokens = readCacheWriteTokens(u);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
promptTokens: Number.isFinite(prompt) ? prompt : 0,
|
promptTokens: Number.isFinite(prompt) ? prompt : 0,
|
||||||
@@ -253,22 +331,66 @@ export function parseUsage(raw: unknown): TokenUsage | undefined {
|
|||||||
(Number.isFinite(completion) ? completion : 0),
|
(Number.isFinite(completion) ? completion : 0),
|
||||||
...(cachedTokens != null ? { cachedTokens } : {}),
|
...(cachedTokens != null ? { cachedTokens } : {}),
|
||||||
...(cacheMissTokens != null ? { cacheMissTokens } : {}),
|
...(cacheMissTokens != null ? { cacheMissTokens } : {}),
|
||||||
|
...(cacheWriteTokens != null ? { cacheWriteTokens } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMessageParts(message: Record<string, unknown> | undefined): {
|
function stringifyToolArguments(value: unknown): string {
|
||||||
|
if (typeof value === "string" && value.trim()) return value;
|
||||||
|
if (value && typeof value === "object") return JSON.stringify(value);
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCallFromRow(row: Record<string, unknown>, index: number): ParsedToolCall | null {
|
||||||
|
const fn = row.function;
|
||||||
|
const nested = fn && typeof fn === "object" ? (fn as Record<string, unknown>) : null;
|
||||||
|
const source = nested ?? row;
|
||||||
|
const name = typeof source.name === "string" ? source.name.trim() : "";
|
||||||
|
if (!name) return null;
|
||||||
|
const rawId = typeof row.id === "string" ? row.id.trim() : "";
|
||||||
|
return {
|
||||||
|
id: rawId || `call-${index}`,
|
||||||
|
name,
|
||||||
|
arguments: stringifyToolArguments(
|
||||||
|
source.arguments ?? source.args ?? row.arguments ?? row.args ?? row.input,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function embeddedContent(raw: unknown): { text: string | null; calls: ParsedToolCall[] } {
|
||||||
|
if (typeof raw === "string") return { text: raw.trim() || null, calls: [] };
|
||||||
|
if (!Array.isArray(raw)) return { text: raw == null ? null : "", calls: [] };
|
||||||
|
const texts: string[] = [];
|
||||||
|
const calls: ParsedToolCall[] = [];
|
||||||
|
raw.forEach((part, index) => {
|
||||||
|
if (!part || typeof part !== "object") return;
|
||||||
|
const row = part as Record<string, unknown>;
|
||||||
|
if (typeof row.text === "string" && row.text.trim()) texts.push(row.text.trim());
|
||||||
|
const fn = row.functionCall ?? row.function_call;
|
||||||
|
if (row.type === "tool_use" || (fn && typeof fn === "object")) {
|
||||||
|
const nested = fn && typeof fn === "object" ? (fn as Record<string, unknown>) : row;
|
||||||
|
const parsed = toolCallFromRow(
|
||||||
|
{
|
||||||
|
id: row.id,
|
||||||
|
name: nested.name ?? row.name,
|
||||||
|
arguments: nested.args ?? nested.arguments ?? row.input ?? row.arguments,
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
if (parsed) calls.push(parsed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { text: texts.join("\n").trim() || null, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从一条 assistant message 取出正文和工具调用。对象参数、缺 id、content 片段里的调用都收。 */
|
||||||
|
export function extractMessageParts(message: Record<string, unknown> | undefined): {
|
||||||
content: string | null;
|
content: string | null;
|
||||||
reasoning?: string;
|
reasoning?: string;
|
||||||
toolCalls: ParsedToolCall[];
|
toolCalls: ParsedToolCall[];
|
||||||
} {
|
} {
|
||||||
if (!message) return { content: "", toolCalls: [] };
|
if (!message) return { content: "", toolCalls: [] };
|
||||||
const rawContent = message.content;
|
const embedded = embeddedContent(message.content);
|
||||||
const content =
|
|
||||||
typeof rawContent === "string"
|
|
||||||
? rawContent.trim() || null
|
|
||||||
: rawContent == null
|
|
||||||
? null
|
|
||||||
: "";
|
|
||||||
const reasoningRaw =
|
const reasoningRaw =
|
||||||
(typeof message.reasoning_content === "string" &&
|
(typeof message.reasoning_content === "string" &&
|
||||||
message.reasoning_content.trim()) ||
|
message.reasoning_content.trim()) ||
|
||||||
@@ -280,21 +402,18 @@ function extractMessageParts(message: Record<string, unknown> | undefined): {
|
|||||||
const toolCalls: ParsedToolCall[] = [];
|
const toolCalls: ParsedToolCall[] = [];
|
||||||
const rawCalls = message.tool_calls;
|
const rawCalls = message.tool_calls;
|
||||||
if (Array.isArray(rawCalls)) {
|
if (Array.isArray(rawCalls)) {
|
||||||
for (const call of rawCalls) {
|
rawCalls.forEach((call, index) => {
|
||||||
if (!call || typeof call !== "object") continue;
|
if (!call || typeof call !== "object") return;
|
||||||
const c = call as Record<string, unknown>;
|
const parsed = toolCallFromRow(call as Record<string, unknown>, index);
|
||||||
const fn = c.function;
|
if (parsed) toolCalls.push(parsed);
|
||||||
if (!fn || typeof fn !== "object") continue;
|
});
|
||||||
const f = fn as Record<string, unknown>;
|
}
|
||||||
const name = typeof f.name === "string" ? f.name : "";
|
for (const call of embedded.calls) {
|
||||||
const id = typeof c.id === "string" ? c.id : "";
|
if (!toolCalls.some((item) => item.id === call.id && item.name === call.name)) {
|
||||||
const args =
|
toolCalls.push(call);
|
||||||
typeof f.arguments === "string" ? f.arguments : "{}";
|
|
||||||
if (name && id) {
|
|
||||||
toolCalls.push({ id, name, arguments: args });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const content = embedded.text;
|
||||||
|
|
||||||
if (toolCalls.length > 0) {
|
if (toolCalls.length > 0) {
|
||||||
return { content, reasoning: reasoning || undefined, toolCalls };
|
return { content, reasoning: reasoning || undefined, toolCalls };
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { ToolDefinition } from "./client.js";
|
||||||
|
|
||||||
|
/** 一段请求的位置标签。只存标签、字数和哈希,不存正文。 */
|
||||||
|
export type ContextSegment = {
|
||||||
|
tag: string;
|
||||||
|
chars: number;
|
||||||
|
hash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 挂在一条回复上的各次请求轮廓,选题和填写都保留。 */
|
||||||
|
export type RequestOutline = {
|
||||||
|
at: string;
|
||||||
|
caller: string;
|
||||||
|
step?: string;
|
||||||
|
turnId?: string;
|
||||||
|
model?: string;
|
||||||
|
promptTokens?: number;
|
||||||
|
completionTokens?: number;
|
||||||
|
cachedTokens?: number;
|
||||||
|
cacheMissTokens?: number;
|
||||||
|
cacheWriteTokens?: number;
|
||||||
|
segments: ContextSegment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 和同会话上一条请求比,前缀从哪里开始对不上。 */
|
||||||
|
export type CacheBreakInfo = {
|
||||||
|
/** 本窗口里没有更早的同会话请求 */
|
||||||
|
cold: boolean;
|
||||||
|
/** -1:本条在上一请求的前缀里整段对上 */
|
||||||
|
breakAt: number;
|
||||||
|
tag?: string;
|
||||||
|
/** 断点之前、和上一请求逐段相同的段数 */
|
||||||
|
matched: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPLIT =
|
||||||
|
/(?:^|\n)(#{2,4} [^\n]+|最后一轮用户输入|【用户】|【助手】)/g;
|
||||||
|
|
||||||
|
function hashText(text: string): string {
|
||||||
|
return createHash("sha256").update(text).digest("hex").slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function segment(tag: string, text: string): ContextSegment {
|
||||||
|
return { tag, chars: text.length, hash: hashText(text) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function headingTag(title: string): string {
|
||||||
|
const beforeOrder = title.split("〔")[0]?.trim() ?? title;
|
||||||
|
const beforeParen = beforeOrder.split(/(|\(/)[0]?.trim() ?? beforeOrder;
|
||||||
|
const beforeAt = beforeParen.split(" @ ")[0]?.trim() ?? beforeParen;
|
||||||
|
const tag = (beforeAt || title).trim();
|
||||||
|
return tag.length > 48 ? `${tag.slice(0, 48)}…` : tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMarks(content: string): Array<{ index: number; tag: string }> {
|
||||||
|
const marks: Array<{ index: number; tag: string }> = [];
|
||||||
|
let section = "";
|
||||||
|
for (const match of content.matchAll(SPLIT)) {
|
||||||
|
const raw = match[1] ?? "";
|
||||||
|
const start = (match.index ?? 0) + (match[0].startsWith("\n") ? 1 : 0);
|
||||||
|
if (raw.startsWith("#")) {
|
||||||
|
const level = /^#+/.exec(raw)?.[0].length ?? 2;
|
||||||
|
const title = raw.replace(/^#+\s*/, "");
|
||||||
|
const tag = headingTag(title);
|
||||||
|
if (level === 2) section = tag;
|
||||||
|
marks.push({ index: start, tag });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (raw === "最后一轮用户输入" && section.startsWith("本轮")) {
|
||||||
|
marks.push({ index: start, tag: "最后一轮用户输入" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(raw === "【用户】" || raw === "【助手】") &&
|
||||||
|
section.startsWith("更早的对话")
|
||||||
|
) {
|
||||||
|
marks.push({
|
||||||
|
index: start,
|
||||||
|
tag: raw === "【用户】" ? "更早·用户" : "更早·助手",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return marks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把一块正文切成有序标签。前缀里的标题与更早对话轮次分开,方便对上断点。 */
|
||||||
|
export function outlineBlock(fallbackTag: string, content: string): ContextSegment[] {
|
||||||
|
if (!content) return [];
|
||||||
|
const marks = collectMarks(content);
|
||||||
|
if (marks.length === 0) return [segment(fallbackTag, content)];
|
||||||
|
const out: ContextSegment[] = [];
|
||||||
|
if (marks[0]!.index > 0) {
|
||||||
|
const head = content.slice(0, marks[0]!.index);
|
||||||
|
if (head.trim()) out.push(segment(fallbackTag, head));
|
||||||
|
}
|
||||||
|
for (let i = 0; i < marks.length; i++) {
|
||||||
|
const end = i + 1 < marks.length ? marks[i + 1]!.index : content.length;
|
||||||
|
out.push(segment(marks[i]!.tag, content.slice(marks[i]!.index, end)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleTag(role: string): string {
|
||||||
|
if (role === "system") return "规则";
|
||||||
|
if (role === "user") return "用户";
|
||||||
|
if (role === "assistant") return "助手";
|
||||||
|
if (role === "tool") return "工具结果";
|
||||||
|
return role || "消息";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按实际送出的消息切段。工具定义若有,放在正文之前。 */
|
||||||
|
export function outlineRequest(
|
||||||
|
messages: Array<{ role: string; content?: string | null }>,
|
||||||
|
tools?: ToolDefinition[],
|
||||||
|
): ContextSegment[] {
|
||||||
|
const out: ContextSegment[] = [];
|
||||||
|
if (tools?.length) {
|
||||||
|
out.push(segment("工具", JSON.stringify(tools)));
|
||||||
|
}
|
||||||
|
for (const message of messages) {
|
||||||
|
const content = typeof message.content === "string" ? message.content : "";
|
||||||
|
out.push(...outlineBlock(roleTag(message.role), content));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回下一请求里第一段对不上的下标。
|
||||||
|
* 下一请求比上一请求更短、但已有段全部相同,视为整段接上(-1)。
|
||||||
|
*/
|
||||||
|
export function firstCacheBreak(
|
||||||
|
prev: Array<Pick<ContextSegment, "tag" | "hash">>,
|
||||||
|
next: Array<Pick<ContextSegment, "tag" | "hash">>,
|
||||||
|
): number {
|
||||||
|
const shared = Math.min(prev.length, next.length);
|
||||||
|
for (let i = 0; i < shared; i++) {
|
||||||
|
if (prev[i]!.tag !== next[i]!.tag || prev[i]!.hash !== next[i]!.hash) return i;
|
||||||
|
}
|
||||||
|
if (next.length > prev.length) return prev.length;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function annotateCacheBreaks<
|
||||||
|
T extends { id: string; at: string; sessionId?: string; segments?: ContextSegment[] },
|
||||||
|
>(records: T[]): Map<string, CacheBreakInfo> {
|
||||||
|
const bySession = new Map<string, T[]>();
|
||||||
|
for (const record of records) {
|
||||||
|
const key = record.sessionId?.trim() || `__none__:${record.id}`;
|
||||||
|
const list = bySession.get(key) ?? [];
|
||||||
|
list.push(record);
|
||||||
|
bySession.set(key, list);
|
||||||
|
}
|
||||||
|
const marks = new Map<string, CacheBreakInfo>();
|
||||||
|
for (const list of bySession.values()) {
|
||||||
|
list.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
|
||||||
|
let prev: ContextSegment[] | undefined;
|
||||||
|
for (const record of list) {
|
||||||
|
const segments = record.segments ?? [];
|
||||||
|
if (!prev) {
|
||||||
|
marks.set(record.id, {
|
||||||
|
cold: true,
|
||||||
|
breakAt: segments.length ? 0 : -1,
|
||||||
|
tag: segments[0]?.tag,
|
||||||
|
matched: 0,
|
||||||
|
});
|
||||||
|
prev = segments;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const breakAt = firstCacheBreak(prev, segments);
|
||||||
|
marks.set(record.id, {
|
||||||
|
cold: false,
|
||||||
|
breakAt,
|
||||||
|
tag: breakAt >= 0 ? segments[breakAt]?.tag : undefined,
|
||||||
|
matched: breakAt < 0 ? segments.length : breakAt,
|
||||||
|
});
|
||||||
|
prev = segments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return marks;
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ function applyToolCallDelta(
|
|||||||
const f = fn as Record<string, unknown>;
|
const f = fn as Record<string, unknown>;
|
||||||
if (typeof f.name === "string") entry.name = f.name;
|
if (typeof f.name === "string") entry.name = f.name;
|
||||||
if (typeof f.arguments === "string") entry.arguments += f.arguments;
|
if (typeof f.arguments === "string") entry.arguments += f.arguments;
|
||||||
|
else if (f.arguments && typeof f.arguments === "object" && !entry.arguments) {
|
||||||
|
entry.arguments = JSON.stringify(f.arguments);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
acc.set(index, entry);
|
acc.set(index, entry);
|
||||||
}
|
}
|
||||||
@@ -36,10 +39,10 @@ function applyToolCallDelta(
|
|||||||
|
|
||||||
function toolCallsFromAccumulator(acc: ToolCallAccumulator): ParsedToolCall[] {
|
function toolCallsFromAccumulator(acc: ToolCallAccumulator): ParsedToolCall[] {
|
||||||
const out: ParsedToolCall[] = [];
|
const out: ParsedToolCall[] = [];
|
||||||
for (const [, entry] of [...acc.entries()].sort((a, b) => a[0] - b[0])) {
|
for (const [index, entry] of [...acc.entries()].sort((a, b) => a[0] - b[0])) {
|
||||||
const name = entry.name?.trim();
|
const name = entry.name?.trim();
|
||||||
const id = entry.id?.trim();
|
if (!name) continue;
|
||||||
if (!name || !id) continue;
|
const id = entry.id?.trim() || `call-${index}`;
|
||||||
out.push({ id, name, arguments: entry.arguments || "{}" });
|
out.push({ id, name, arguments: entry.arguments || "{}" });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ import type {
|
|||||||
LlmProvider,
|
LlmProvider,
|
||||||
StreamCallbacks,
|
StreamCallbacks,
|
||||||
TokenUsage,
|
TokenUsage,
|
||||||
|
ToolDefinition,
|
||||||
|
UsageDetail,
|
||||||
|
UsageSink,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
|
import {
|
||||||
|
outlineRequest,
|
||||||
|
type ContextSegment,
|
||||||
|
type RequestOutline,
|
||||||
|
} from "./context-outline.js";
|
||||||
import {
|
import {
|
||||||
recordTokenUsage,
|
recordTokenUsage,
|
||||||
toMessageTokenUsage,
|
toMessageTokenUsage,
|
||||||
@@ -28,6 +36,9 @@ export type LlmTrackingContext = {
|
|||||||
pendingReasoning?: string;
|
pendingReasoning?: string;
|
||||||
/** 全量请求上下文;挂到下一条「结果向」系统消息 */
|
/** 全量请求上下文;挂到下一条「结果向」系统消息 */
|
||||||
pendingContextTrace?: LlmContextTrace;
|
pendingContextTrace?: LlmContextTrace;
|
||||||
|
/** 本条回复之前的各次请求轮廓;查看时选题和填写都在 */
|
||||||
|
pendingOutlines?: RequestOutline[];
|
||||||
|
lastSegments?: ContextSegment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function capturePendingTrace(
|
function capturePendingTrace(
|
||||||
@@ -41,12 +52,16 @@ function capturePendingTrace(
|
|||||||
caller: string | undefined,
|
caller: string | undefined,
|
||||||
model?: string,
|
model?: string,
|
||||||
generation?: CompleteOptions["generation"],
|
generation?: CompleteOptions["generation"],
|
||||||
|
tools?: ToolDefinition[],
|
||||||
): void {
|
): void {
|
||||||
|
const segments = outlineRequest(messages, tools);
|
||||||
|
ctx.lastSegments = segments;
|
||||||
ctx.pendingContextTrace = buildContextTrace({
|
ctx.pendingContextTrace = buildContextTrace({
|
||||||
caller: caller ?? "unknown",
|
caller: caller ?? "unknown",
|
||||||
messages,
|
messages,
|
||||||
model,
|
model,
|
||||||
generation,
|
generation,
|
||||||
|
segments,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,9 +70,13 @@ function recordUsage(
|
|||||||
caller: string | undefined,
|
caller: string | undefined,
|
||||||
model: string | undefined,
|
model: string | undefined,
|
||||||
usage: TokenUsage | undefined,
|
usage: TokenUsage | undefined,
|
||||||
|
meta?: { detail?: UsageDetail; latencyMs?: number; sink?: UsageSink },
|
||||||
): void {
|
): void {
|
||||||
if (!usage) return;
|
const segments = ctx.lastSegments;
|
||||||
const record = recordTokenUsage({
|
ctx.lastSegments = undefined;
|
||||||
|
const detail = meta?.detail;
|
||||||
|
const record = usage
|
||||||
|
? recordTokenUsage({
|
||||||
sessionId: ctx.sessionId,
|
sessionId: ctx.sessionId,
|
||||||
bookId: ctx.bookId,
|
bookId: ctx.bookId,
|
||||||
bookTitle: ctx.bookTitle,
|
bookTitle: ctx.bookTitle,
|
||||||
@@ -69,8 +88,36 @@ function recordUsage(
|
|||||||
totalTokens: usage.totalTokens,
|
totalTokens: usage.totalTokens,
|
||||||
cachedTokens: usage.cachedTokens,
|
cachedTokens: usage.cachedTokens,
|
||||||
cacheMissTokens: usage.cacheMissTokens,
|
cacheMissTokens: usage.cacheMissTokens,
|
||||||
});
|
cacheWriteTokens: usage.cacheWriteTokens,
|
||||||
ctx.pendingUsage = toMessageTokenUsage(record);
|
turnId: detail?.turnId,
|
||||||
|
step: detail?.step,
|
||||||
|
retryIndex: detail?.retryIndex,
|
||||||
|
segmentChars: detail?.segmentChars,
|
||||||
|
plannedTags: detail?.plannedTags,
|
||||||
|
succeededTags: detail?.succeededTags,
|
||||||
|
failedTags: detail?.failedTags,
|
||||||
|
finish: detail?.finish,
|
||||||
|
latencyMs: meta?.latencyMs,
|
||||||
|
segments,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
if (record && meta?.sink) meta.sink.recordId = record.id;
|
||||||
|
if (record) ctx.pendingUsage = toMessageTokenUsage(record);
|
||||||
|
if (!segments?.length && !record) return;
|
||||||
|
const outline: RequestOutline = {
|
||||||
|
at: record?.at ?? new Date().toISOString(),
|
||||||
|
caller: record?.caller ?? caller ?? "unknown",
|
||||||
|
step: detail?.step,
|
||||||
|
turnId: detail?.turnId,
|
||||||
|
model: record?.model ?? model,
|
||||||
|
promptTokens: record?.promptTokens,
|
||||||
|
completionTokens: record?.completionTokens,
|
||||||
|
cachedTokens: record?.cachedTokens,
|
||||||
|
cacheMissTokens: record?.cacheMissTokens,
|
||||||
|
cacheWriteTokens: record?.cacheWriteTokens,
|
||||||
|
segments: segments ?? [],
|
||||||
|
};
|
||||||
|
ctx.pendingOutlines = [...(ctx.pendingOutlines ?? []), outline];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TokenTrackingProvider implements LlmProvider {
|
export class TokenTrackingProvider implements LlmProvider {
|
||||||
@@ -79,6 +126,14 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
private readonly getContext: () => LlmTrackingContext,
|
private readonly getContext: () => LlmTrackingContext,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** 调用方未指定时,用本局会话 id,供 OpenRouter 粘滞路由。 */
|
||||||
|
private withSession<T extends CompleteOptions>(options?: T): T {
|
||||||
|
const sessionId = this.getContext().sessionId?.trim();
|
||||||
|
if (!sessionId) return (options ?? {}) as T;
|
||||||
|
if (options?.sessionId?.trim()) return options;
|
||||||
|
return { ...options, sessionId } as T;
|
||||||
|
}
|
||||||
|
|
||||||
async complete(
|
async complete(
|
||||||
messages: Parameters<LlmProvider["complete"]>[0],
|
messages: Parameters<LlmProvider["complete"]>[0],
|
||||||
options?: CompleteOptions,
|
options?: CompleteOptions,
|
||||||
@@ -91,7 +146,8 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
messages,
|
messages,
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const result = await this.inner.complete(messages, options);
|
const started = Date.now();
|
||||||
|
const result = await this.inner.complete(messages, this.withSession(options));
|
||||||
const ctx = this.getContext();
|
const ctx = this.getContext();
|
||||||
capturePendingTrace(
|
capturePendingTrace(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -100,7 +156,11 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
result.model,
|
result.model,
|
||||||
options?.generation,
|
options?.generation,
|
||||||
);
|
);
|
||||||
recordUsage(ctx, options?.caller, result.model, result.usage);
|
recordUsage(ctx, options?.caller, result.model, result.usage, {
|
||||||
|
detail: options?.usageDetail,
|
||||||
|
latencyMs: Date.now() - started,
|
||||||
|
sink: options?.usageSink,
|
||||||
|
});
|
||||||
if (result.reasoning?.trim()) {
|
if (result.reasoning?.trim()) {
|
||||||
ctx.pendingReasoning = result.reasoning.trim();
|
ctx.pendingReasoning = result.reasoning.trim();
|
||||||
}
|
}
|
||||||
@@ -130,7 +190,8 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
let reasoningBuf = "";
|
let reasoningBuf = "";
|
||||||
const result = await inner.completeStream!(messages, options, {
|
const started = Date.now();
|
||||||
|
const result = await inner.completeStream!(messages, this.withSession(options), {
|
||||||
onReasoningDelta: (delta) => {
|
onReasoningDelta: (delta) => {
|
||||||
reasoningBuf += delta;
|
reasoningBuf += delta;
|
||||||
const ctx = this.getContext();
|
const ctx = this.getContext();
|
||||||
@@ -147,7 +208,11 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
result.model,
|
result.model,
|
||||||
options?.generation,
|
options?.generation,
|
||||||
);
|
);
|
||||||
recordUsage(ctx, options?.caller, result.model, result.usage);
|
recordUsage(ctx, options?.caller, result.model, result.usage, {
|
||||||
|
detail: options?.usageDetail,
|
||||||
|
latencyMs: Date.now() - started,
|
||||||
|
sink: options?.usageSink,
|
||||||
|
});
|
||||||
if (result.reasoning?.trim()) {
|
if (result.reasoning?.trim()) {
|
||||||
ctx.pendingReasoning = result.reasoning.trim();
|
ctx.pendingReasoning = result.reasoning.trim();
|
||||||
}
|
}
|
||||||
@@ -169,7 +234,11 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
tools: options.tools,
|
tools: options.tools,
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const result = await this.inner.completeWithTools(messages, options);
|
const started = Date.now();
|
||||||
|
const result = await this.inner.completeWithTools(
|
||||||
|
messages,
|
||||||
|
this.withSession(options),
|
||||||
|
);
|
||||||
const ctx = this.getContext();
|
const ctx = this.getContext();
|
||||||
capturePendingTrace(
|
capturePendingTrace(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -177,8 +246,13 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
options.caller,
|
options.caller,
|
||||||
result.model,
|
result.model,
|
||||||
options.generation,
|
options.generation,
|
||||||
|
options.tools,
|
||||||
);
|
);
|
||||||
recordUsage(ctx, options.caller, result.model, result.usage);
|
recordUsage(ctx, options.caller, result.model, result.usage, {
|
||||||
|
detail: options.usageDetail,
|
||||||
|
latencyMs: Date.now() - started,
|
||||||
|
sink: options.usageSink,
|
||||||
|
});
|
||||||
if (result.reasoning?.trim()) {
|
if (result.reasoning?.trim()) {
|
||||||
ctx.pendingReasoning = result.reasoning.trim();
|
ctx.pendingReasoning = result.reasoning.trim();
|
||||||
}
|
}
|
||||||
@@ -206,15 +280,20 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
let reasoningBuf = "";
|
let reasoningBuf = "";
|
||||||
const result = await inner.completeWithToolsStream!(messages, options, {
|
const started = Date.now();
|
||||||
onReasoningDelta: (delta) => {
|
const result = await inner.completeWithToolsStream!(
|
||||||
reasoningBuf += delta;
|
messages,
|
||||||
const ctx = this.getContext();
|
this.withSession(options),
|
||||||
ctx.pendingReasoning = reasoningBuf;
|
{
|
||||||
callbacks.onReasoningDelta?.(delta);
|
onReasoningDelta: (delta) => {
|
||||||
|
reasoningBuf += delta;
|
||||||
|
const ctx = this.getContext();
|
||||||
|
ctx.pendingReasoning = reasoningBuf;
|
||||||
|
callbacks.onReasoningDelta?.(delta);
|
||||||
|
},
|
||||||
|
onContentDelta: callbacks.onContentDelta,
|
||||||
},
|
},
|
||||||
onContentDelta: callbacks.onContentDelta,
|
);
|
||||||
});
|
|
||||||
const ctx = this.getContext();
|
const ctx = this.getContext();
|
||||||
capturePendingTrace(
|
capturePendingTrace(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -222,8 +301,13 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
options.caller,
|
options.caller,
|
||||||
result.model,
|
result.model,
|
||||||
options.generation,
|
options.generation,
|
||||||
|
options.tools,
|
||||||
);
|
);
|
||||||
recordUsage(ctx, options.caller, result.model, result.usage);
|
recordUsage(ctx, options.caller, result.model, result.usage, {
|
||||||
|
detail: options.usageDetail,
|
||||||
|
latencyMs: Date.now() - started,
|
||||||
|
sink: options.usageSink,
|
||||||
|
});
|
||||||
if (result.reasoning?.trim()) {
|
if (result.reasoning?.trim()) {
|
||||||
ctx.pendingReasoning = result.reasoning.trim();
|
ctx.pendingReasoning = result.reasoning.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ const TOOL_LABEL: Record<string, string> = {
|
|||||||
review_blackboard: "审黑板",
|
review_blackboard: "审黑板",
|
||||||
finish: "结束",
|
finish: "结束",
|
||||||
insert: "写入产物",
|
insert: "写入产物",
|
||||||
|
reply_user: "回复用户",
|
||||||
|
select_nodes: "选定节点",
|
||||||
write_product: "写入产物",
|
write_product: "写入产物",
|
||||||
delete: "删除产物",
|
delete: "删除产物",
|
||||||
declare_variable: "声明变量",
|
declare_variable: "声明变量",
|
||||||
@@ -214,6 +216,7 @@ export function labelCaller(caller: string | undefined): string {
|
|||||||
if (who === "main_agent" || who.startsWith("main_agent:")) return "总管";
|
if (who === "main_agent" || who.startsWith("main_agent:")) return "总管";
|
||||||
if (who === "intake_extract") return "需求抽取";
|
if (who === "intake_extract") return "需求抽取";
|
||||||
if (who === "dictate_agent") return "对话落盘";
|
if (who === "dictate_agent") return "对话落盘";
|
||||||
|
if (who.startsWith("listen_serial_")) return "听书连载";
|
||||||
if (who === "preset-probe") return "预设探测";
|
if (who === "preset-probe") return "预设探测";
|
||||||
if (who.startsWith("model-compare:")) {
|
if (who.startsWith("model-compare:")) {
|
||||||
return `模型对比 ${who.slice("model-compare:".length)}`;
|
return `模型对比 ${who.slice("model-compare:".length)}`;
|
||||||
|
|||||||
@@ -75,18 +75,24 @@ export function protagonistFragmentFromPersona(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 黑板上尚无该 tag 的主角设定时写入(可指定开场同槽 tag)。 */
|
/**
|
||||||
|
* 黑板上尚无该 tag 的主角设定时写入(可指定开场同槽 tag)。
|
||||||
|
* overwrite:开场自带的同槽名片随开场改稿同步。
|
||||||
|
*/
|
||||||
export function ensureProtagonistFromPersona(
|
export function ensureProtagonistFromPersona(
|
||||||
board: Blackboard,
|
board: Blackboard,
|
||||||
persona: OpeningUserPersona | null | undefined,
|
persona: OpeningUserPersona | null | undefined,
|
||||||
tag: string = PROTAGONIST_TAG,
|
tag: string = PROTAGONIST_TAG,
|
||||||
|
opts: { overwrite?: boolean } = {},
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!persona?.name?.trim()) return false;
|
if (!persona?.name?.trim()) return false;
|
||||||
const writeTag = tag.trim() || PROTAGONIST_TAG;
|
const writeTag = tag.trim() || PROTAGONIST_TAG;
|
||||||
if (board.getContentByTag(writeTag)?.trim()) return false;
|
const existing = board.getContentByTag(writeTag)?.trim();
|
||||||
|
const content = protagonistFragmentFromPersona(persona);
|
||||||
|
if (existing && (!opts.overwrite || existing === content.trim())) return false;
|
||||||
board.write({
|
board.write({
|
||||||
tag: writeTag,
|
tag: writeTag,
|
||||||
content: protagonistFragmentFromPersona(persona),
|
content,
|
||||||
source: "runtime",
|
source: "runtime",
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ import {
|
|||||||
isDictateModeValue,
|
isDictateModeValue,
|
||||||
} from "../dictate/types.js";
|
} from "../dictate/types.js";
|
||||||
import { applyDictatePlayBind } from "../dictate/play-bind.js";
|
import { applyDictatePlayBind } from "../dictate/play-bind.js";
|
||||||
|
import {
|
||||||
|
applyMaintenanceStrategyToPlaySlots,
|
||||||
|
resolveMaintenanceStrategyFromBoard,
|
||||||
|
} from "../skills/maintenance-strategy.js";
|
||||||
import {
|
import {
|
||||||
dictateProductFamily,
|
dictateProductFamily,
|
||||||
mergeGenerationRulesArtifacts,
|
mergeGenerationRulesArtifacts,
|
||||||
@@ -148,6 +152,11 @@ import {
|
|||||||
VARIABLE_CATALOG_TAG,
|
VARIABLE_CATALOG_TAG,
|
||||||
seedVariablesFromCatalog,
|
seedVariablesFromCatalog,
|
||||||
} from "../skills/variable-catalog.js";
|
} from "../skills/variable-catalog.js";
|
||||||
|
import {
|
||||||
|
VARIABLE_DESIGN_TAG,
|
||||||
|
syncVariableDesignToRuntime,
|
||||||
|
writeVariableDesignSync,
|
||||||
|
} from "../skills/variable-design-compile.js";
|
||||||
import {
|
import {
|
||||||
VALUE_MAP_TAG,
|
VALUE_MAP_TAG,
|
||||||
reprojectValueMaps,
|
reprojectValueMaps,
|
||||||
@@ -164,6 +173,11 @@ import {
|
|||||||
parseShellAdaptationFromReplyFormat,
|
parseShellAdaptationFromReplyFormat,
|
||||||
presentOutputFromFallback,
|
presentOutputFromFallback,
|
||||||
} from "../skills/present-packet.js";
|
} from "../skills/present-packet.js";
|
||||||
|
import { parseReplyDataContract } from "../skills/reply-contract.js";
|
||||||
|
import {
|
||||||
|
mergePresentWithProjections,
|
||||||
|
projectPresentBlocks,
|
||||||
|
} from "../skills/present-project.js";
|
||||||
import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js";
|
import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js";
|
||||||
import {
|
import {
|
||||||
executeChance,
|
executeChance,
|
||||||
@@ -2049,21 +2063,45 @@ export class PhaseRuntime {
|
|||||||
// 偏好/文风改由节点产物(设计.用户需求 / 设计.叙事指南*)挂载,不再全量自动摊库
|
// 偏好/文风改由节点产物(设计.用户需求 / 设计.叙事指南*)挂载,不再全量自动摊库
|
||||||
if (this.isDictateIntakeMode()) {
|
if (this.isDictateIntakeMode()) {
|
||||||
const bound = applyDictatePlayBind(this.blackboard);
|
const bound = applyDictatePlayBind(this.blackboard);
|
||||||
if (bound.boundTags.length) {
|
if (!bound.skipped && bound.boundTags.length) {
|
||||||
this.onMessage(
|
this.onMessage(
|
||||||
`[对话落盘] 已自动挂载 ${bound.boundTags.length} 个产物到游玩上下文:${bound.boundTags.join("、")}`,
|
`[对话落盘] 已自动挂载 ${bound.boundTags.length} 个产物到游玩上下文:${bound.boundTags.join("、")}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else if (!bound.skipped) {
|
||||||
this.onMessage("[对话落盘] 已生成默认游玩规格(尚无产物可挂载)");
|
this.onMessage("[对话落盘] 已生成默认游玩规格(尚无产物可挂载)");
|
||||||
}
|
}
|
||||||
|
const strategyRaw = this.blackboard.getContentByTag("设计.维护策略")?.trim();
|
||||||
|
if (strategyRaw) {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(strategyRaw) as {
|
||||||
|
advice?: string;
|
||||||
|
auditor?: boolean;
|
||||||
|
inline?: boolean;
|
||||||
|
};
|
||||||
|
if (s.auditor) {
|
||||||
|
this.onMessage(
|
||||||
|
`[维护策略] 旁观副 LLM 填表(play_slots.auditor=true)`,
|
||||||
|
);
|
||||||
|
} else if (s.advice && s.advice !== "none") {
|
||||||
|
this.onMessage(`[维护策略] ${s.advice} → 旁观未开`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
this.seedVariablesAndReproject("opening-setup");
|
this.seedVariablesAndReproject("opening-setup");
|
||||||
} else {
|
} else {
|
||||||
const finalTag = this.blackboard.getContentByTag(WORKER_SET_FINAL_TAG)?.trim();
|
const finalTag = this.blackboard.getContentByTag(WORKER_SET_FINAL_TAG)?.trim();
|
||||||
const draft = this.blackboard.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim();
|
const draft = this.blackboard.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim();
|
||||||
if (!finalTag) {
|
if (!finalTag) {
|
||||||
|
const strategy = resolveMaintenanceStrategyFromBoard(this.blackboard);
|
||||||
|
const play_slots = applyMaintenanceStrategyToPlaySlots(
|
||||||
|
defaultPlaySlots(),
|
||||||
|
strategy,
|
||||||
|
);
|
||||||
const spec = draft?.trim()
|
const spec = draft?.trim()
|
||||||
? draft
|
? draft
|
||||||
: JSON.stringify({ play_slots: defaultPlaySlots() });
|
: JSON.stringify({ play_slots }, null, 2);
|
||||||
this.writeWorkerTagContent(WORKER_SET_FINAL_TAG, spec, "opening-setup");
|
this.writeWorkerTagContent(WORKER_SET_FINAL_TAG, spec, "opening-setup");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2077,36 +2115,84 @@ export class PhaseRuntime {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 目录初值 → 变量.当前;再按 设计.变量映射 重写投影 tag */
|
/** 变量设计 → 目录 → 变量.当前;再按 设计.变量映射 重写投影 tag */
|
||||||
private seedVariablesAndReproject(actor: string): void {
|
private seedVariablesAndReproject(actor: string): void {
|
||||||
const seeded = seedVariablesFromCatalog({
|
const designSync = syncVariableDesignToRuntime({
|
||||||
|
designRaw: this.blackboard.getContentByTag(VARIABLE_DESIGN_TAG),
|
||||||
catalogRaw: this.blackboard.getContentByTag(VARIABLE_CATALOG_TAG),
|
catalogRaw: this.blackboard.getContentByTag(VARIABLE_CATALOG_TAG),
|
||||||
currentRaw: this.blackboard.getContentByTag(OPENING_CURRENT_VARS_TAG),
|
currentRaw: this.blackboard.getContentByTag(OPENING_CURRENT_VARS_TAG),
|
||||||
initialRaw: this.blackboard.getContentByTag(OPENING_INITIAL_VARS_TAG),
|
initialRaw: this.blackboard.getContentByTag(OPENING_INITIAL_VARS_TAG),
|
||||||
actor: `system:${actor}`,
|
|
||||||
});
|
});
|
||||||
if (seeded.initial) {
|
if (designSync.catalog || designSync.current || designSync.initial) {
|
||||||
this.blackboard.write({
|
writeVariableDesignSync(designSync, (tag, content) => {
|
||||||
tag: OPENING_INITIAL_VARS_TAG,
|
this.blackboard.write({ tag, content, source: actor });
|
||||||
content: seeded.initial,
|
|
||||||
source: actor,
|
|
||||||
});
|
});
|
||||||
}
|
if (designSync.added.length) {
|
||||||
if (seeded.current) {
|
|
||||||
this.blackboard.write({
|
|
||||||
tag: OPENING_CURRENT_VARS_TAG,
|
|
||||||
content: seeded.current,
|
|
||||||
source: actor,
|
|
||||||
});
|
|
||||||
if (seeded.seededKeys.length) {
|
|
||||||
this.onMessage(
|
this.onMessage(
|
||||||
`[变量] 已种初值:${seeded.seededKeys.join("、")}`,
|
`[变量] 已从变量设计同步目录:${designSync.added.join("、")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (designSync.current && designSync.seededKeys.length) {
|
||||||
|
this.onMessage(
|
||||||
|
`[变量] 已种初值:${designSync.seededKeys.join("、")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const seeded = seedVariablesFromCatalog({
|
||||||
|
catalogRaw: this.blackboard.getContentByTag(VARIABLE_CATALOG_TAG),
|
||||||
|
currentRaw: this.blackboard.getContentByTag(OPENING_CURRENT_VARS_TAG),
|
||||||
|
initialRaw: this.blackboard.getContentByTag(OPENING_INITIAL_VARS_TAG),
|
||||||
|
actor: `system:${actor}`,
|
||||||
|
});
|
||||||
|
if (seeded.initial) {
|
||||||
|
this.blackboard.write({
|
||||||
|
tag: OPENING_INITIAL_VARS_TAG,
|
||||||
|
content: seeded.initial,
|
||||||
|
source: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (seeded.current) {
|
||||||
|
this.blackboard.write({
|
||||||
|
tag: OPENING_CURRENT_VARS_TAG,
|
||||||
|
content: seeded.current,
|
||||||
|
source: actor,
|
||||||
|
});
|
||||||
|
if (seeded.seededKeys.length) {
|
||||||
|
this.onMessage(
|
||||||
|
`[变量] 已种初值:${seeded.seededKeys.join("、")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.applyValueMapProjections(actor);
|
this.applyValueMapProjections(actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 把 program_projection 块从黑板灌进 present 正文 */
|
||||||
|
private enrichPresentWithProjections(content: string): string {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
// 纯 Markdown 终稿保持原文;投影由视图 presentationProjection 灌进壳
|
||||||
|
if (!trimmed.startsWith("{")) return content;
|
||||||
|
const replyFormatRaw =
|
||||||
|
this.blackboard.getContentByTag("设计.正文组成") ??
|
||||||
|
this.blackboard.getContentByTag("设计.0层正文组成") ??
|
||||||
|
this.blackboard.getContentByTag("设计.回复格式");
|
||||||
|
const contract = parseReplyDataContract(replyFormatRaw);
|
||||||
|
if (!contract?.programBlocks.length) return content;
|
||||||
|
const projections = projectPresentBlocks({
|
||||||
|
contract,
|
||||||
|
statusBarRaw:
|
||||||
|
this.blackboard.getContentByTag("设计.监控栏") ??
|
||||||
|
this.blackboard.getContentByTag("设计.回复呈现"),
|
||||||
|
currentVarsRaw: this.blackboard.getContentByTag(OPENING_CURRENT_VARS_TAG),
|
||||||
|
topologyRaw: this.blackboard.getContentByTag("运行.拓扑"),
|
||||||
|
readTag: (tag) => this.blackboard.getContentByTag(tag),
|
||||||
|
});
|
||||||
|
if (!Object.keys(projections).length) return content;
|
||||||
|
const shell =
|
||||||
|
parseShellAdaptationFromReplyFormat(replyFormatRaw)?.shell_id ?? "prose";
|
||||||
|
return mergePresentWithProjections(content, projections, shell);
|
||||||
|
}
|
||||||
|
|
||||||
/** 按当前真值持续投影映射 tag(非 once) */
|
/** 按当前真值持续投影映射 tag(非 once) */
|
||||||
private applyValueMapProjections(workerId: string): void {
|
private applyValueMapProjections(workerId: string): void {
|
||||||
if (!this.blackboard.getContentByTag(VALUE_MAP_TAG)?.trim()) return;
|
if (!this.blackboard.getContentByTag(VALUE_MAP_TAG)?.trim()) return;
|
||||||
@@ -2589,10 +2675,13 @@ export class PhaseRuntime {
|
|||||||
): string {
|
): string {
|
||||||
const tableTags = new Set(["运行.初始变量", "变量.当前"]);
|
const tableTags = new Set(["运行.初始变量", "变量.当前"]);
|
||||||
let toWrite = content;
|
let toWrite = content;
|
||||||
|
if (tag === "输出.用户展示" || tag === "输出.开场白") {
|
||||||
|
toWrite = this.enrichPresentWithProjections(toWrite);
|
||||||
|
}
|
||||||
let prevDoc: TableDoc | null = null;
|
let prevDoc: TableDoc | null = null;
|
||||||
let nextDoc: TableDoc | null = null;
|
let nextDoc: TableDoc | null = null;
|
||||||
if (tableTags.has(tag)) {
|
if (tableTags.has(tag)) {
|
||||||
const patch = parseTableDoc(content);
|
const patch = parseTableDoc(toWrite);
|
||||||
if (patch) {
|
if (patch) {
|
||||||
prevDoc = parseTableDoc(this.blackboard.getContentByTag(tag));
|
prevDoc = parseTableDoc(this.blackboard.getContentByTag(tag));
|
||||||
const { doc, skipped } = mergeTableCells({
|
const { doc, skipped } = mergeTableCells({
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type AgentMessageKind =
|
|||||||
| "orchestrator_prompt"
|
| "orchestrator_prompt"
|
||||||
| "orchestrator_assessment"
|
| "orchestrator_assessment"
|
||||||
| "dictate_reply"
|
| "dictate_reply"
|
||||||
|
| "listen_reply"
|
||||||
| "agent_tool"
|
| "agent_tool"
|
||||||
| "worker_running"
|
| "worker_running"
|
||||||
| "worker_output"
|
| "worker_output"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { listSkills } from "../skills/loader.js";
|
import { listSkills } from "../skills/loader.js";
|
||||||
|
import type { ModuleCatalogEntry } from "../skills/creation-flow.js";
|
||||||
import { createBook, deleteBook, duplicateBook, getBook, listBooks, updateBook } from "../book/store.js";
|
import { createBook, deleteBook, duplicateBook, getBook, listBooks, updateBook } from "../book/store.js";
|
||||||
import { sessionManager } from "./session-manager.js";
|
import { sessionManager } from "./session-manager.js";
|
||||||
|
|
||||||
@@ -39,17 +40,64 @@ function moduleStatusFromSections(
|
|||||||
return depth >= 2 ? "ready" : "partial";
|
return depth >= 2 ? "ready" : "partial";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ModuleBrowseRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
declaration: string;
|
||||||
|
artifact: string;
|
||||||
|
hasPrompt: boolean;
|
||||||
|
sectionsPresent: string[];
|
||||||
|
status: ModuleStatus;
|
||||||
|
group: string;
|
||||||
|
presentMode: "message" | "zero_layer" | null;
|
||||||
|
layer: "intermediate" | "final" | null;
|
||||||
|
repeatable: boolean;
|
||||||
|
closer: boolean;
|
||||||
|
priorArtifact: boolean;
|
||||||
|
programSide: boolean;
|
||||||
|
libraries: string[];
|
||||||
|
land: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function moduleBrowseFields(
|
||||||
|
entry: ModuleCatalogEntry,
|
||||||
|
): Promise<
|
||||||
|
Omit<
|
||||||
|
ModuleBrowseRow,
|
||||||
|
| "id"
|
||||||
|
| "name"
|
||||||
|
| "declaration"
|
||||||
|
| "artifact"
|
||||||
|
| "hasPrompt"
|
||||||
|
| "sectionsPresent"
|
||||||
|
| "status"
|
||||||
|
>
|
||||||
|
> {
|
||||||
|
const { dictateModuleLandHint, isDictateProgramModule } = await import(
|
||||||
|
"../skills/creation-flow.js"
|
||||||
|
);
|
||||||
|
const { moduleBrowseGroup, modulePresentationMode } = await import(
|
||||||
|
"../dictate/creation-plan.js"
|
||||||
|
);
|
||||||
|
const { getLibrary } = await import("../libraries/registry.js");
|
||||||
|
return {
|
||||||
|
group: moduleBrowseGroup(entry),
|
||||||
|
presentMode: modulePresentationMode(entry.id),
|
||||||
|
layer: entry.layer ?? null,
|
||||||
|
repeatable: Boolean(entry.repeatable),
|
||||||
|
closer: Boolean(entry.closer),
|
||||||
|
priorArtifact: entry.kind === "prior-artifact",
|
||||||
|
programSide: isDictateProgramModule(entry),
|
||||||
|
libraries: (entry.libraries ?? []).map(
|
||||||
|
(id) => getLibrary(id)?.label ?? id,
|
||||||
|
),
|
||||||
|
land: dictateModuleLandHint(entry),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function loadModulesPayload(skillId: string): Promise<{
|
async function loadModulesPayload(skillId: string): Promise<{
|
||||||
skillPackId: string;
|
groups: Array<{ id: string; label: string; hint: string }>;
|
||||||
modules: Array<{
|
modules: ModuleBrowseRow[];
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
declaration: string;
|
|
||||||
artifact: string;
|
|
||||||
hasPrompt: boolean;
|
|
||||||
sectionsPresent: string[];
|
|
||||||
status: ModuleStatus;
|
|
||||||
}>;
|
|
||||||
}> {
|
}> {
|
||||||
const { loadSkill } = await import("../skills/loader.js");
|
const { loadSkill } = await import("../skills/loader.js");
|
||||||
const {
|
const {
|
||||||
@@ -59,13 +107,14 @@ async function loadModulesPayload(skillId: string): Promise<{
|
|||||||
catalogModulesForIntake,
|
catalogModulesForIntake,
|
||||||
MODULE_SECTION_IDS,
|
MODULE_SECTION_IDS,
|
||||||
} = await import("../skills/creation-flow.js");
|
} = await import("../skills/creation-flow.js");
|
||||||
|
const { MODULE_BROWSE_GROUPS } = await import("../dictate/creation-plan.js");
|
||||||
const skill = await loadSkill(skillId);
|
const skill = await loadSkill(skillId);
|
||||||
if (!skill.skillPackRoot) {
|
if (!skill.skillPackRoot) {
|
||||||
return { skillPackId: skillId, modules: [] };
|
return { groups: [...MODULE_BROWSE_GROUPS], modules: [] };
|
||||||
}
|
}
|
||||||
const catalog = await loadModuleCatalog(skill.skillPackRoot);
|
const catalog = await loadModuleCatalog(skill.skillPackRoot);
|
||||||
const modules = [];
|
const modules: ModuleBrowseRow[] = [];
|
||||||
for (const m of catalogModulesForIntake(catalog ?? { modules: [] }, "recipe")) {
|
for (const m of catalogModulesForIntake(catalog ?? { modules: [] }, "dictate")) {
|
||||||
const prompt = await loadModulePrompt(skill.skillPackRoot, m.id);
|
const prompt = await loadModulePrompt(skill.skillPackRoot, m.id);
|
||||||
const sections = prompt
|
const sections = prompt
|
||||||
? parseModulePromptSections(prompt)
|
? parseModulePromptSections(prompt)
|
||||||
@@ -83,27 +132,23 @@ async function loadModulesPayload(skillId: string): Promise<{
|
|||||||
hasPrompt,
|
hasPrompt,
|
||||||
sectionsPresent: [...sectionsPresent],
|
sectionsPresent: [...sectionsPresent],
|
||||||
status: moduleStatusFromSections(hasPrompt, sectionsPresent, taskBody),
|
status: moduleStatusFromSections(hasPrompt, sectionsPresent, taskBody),
|
||||||
|
...(await moduleBrowseFields(m)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { skillPackId: skillId, modules };
|
return { groups: [...MODULE_BROWSE_GROUPS], modules };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadModuleDetailPayload(
|
async function loadModuleDetailPayload(
|
||||||
skillId: string,
|
skillId: string,
|
||||||
moduleId: string,
|
moduleId: string,
|
||||||
): Promise<{
|
): Promise<
|
||||||
skillPackId: string;
|
| (ModuleBrowseRow & {
|
||||||
id: string;
|
sections: Record<string, string>;
|
||||||
name: string;
|
meta: Record<string, unknown> | null;
|
||||||
declaration: string;
|
raw: string;
|
||||||
artifact: string;
|
})
|
||||||
hasPrompt: boolean;
|
| null
|
||||||
sectionsPresent: string[];
|
> {
|
||||||
status: ModuleStatus;
|
|
||||||
sections: Record<string, string>;
|
|
||||||
meta: Record<string, unknown> | null;
|
|
||||||
raw: string;
|
|
||||||
} | null> {
|
|
||||||
const { loadSkill } = await import("../skills/loader.js");
|
const { loadSkill } = await import("../skills/loader.js");
|
||||||
const {
|
const {
|
||||||
loadModuleCatalog,
|
loadModuleCatalog,
|
||||||
@@ -141,7 +186,6 @@ async function loadModuleDetailPayload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
skillPackId: skillId,
|
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
declaration: entry.declaration,
|
declaration: entry.declaration,
|
||||||
@@ -153,6 +197,7 @@ async function loadModuleDetailPayload(
|
|||||||
sectionsPresent,
|
sectionsPresent,
|
||||||
sections.task,
|
sections.task,
|
||||||
),
|
),
|
||||||
|
...(await moduleBrowseFields(entry)),
|
||||||
sections,
|
sections,
|
||||||
meta,
|
meta,
|
||||||
raw: prompt ?? "",
|
raw: prompt ?? "",
|
||||||
@@ -254,7 +299,7 @@ export async function handleBooksApi(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 默认配方包的技能池(编排备选) */
|
/** 对话落盘节点:按角色分组,不含旧配方包 */
|
||||||
if (pathname === "/api/modules" && req.method === "GET") {
|
if (pathname === "/api/modules" && req.method === "GET") {
|
||||||
const { DEFAULT_ORCHESTRATOR_ID } = await import(
|
const { DEFAULT_ORCHESTRATOR_ID } = await import(
|
||||||
"../config/default-orchestrator.js"
|
"../config/default-orchestrator.js"
|
||||||
@@ -343,8 +388,8 @@ export async function handleBooksApi(
|
|||||||
orchestratorId?: string;
|
orchestratorId?: string;
|
||||||
/** 旧客户端可传;新建作品改由创作对话判断路径 */
|
/** 旧客户端可传;新建作品改由创作对话判断路径 */
|
||||||
recipeId?: string;
|
recipeId?: string;
|
||||||
/** 旧客户端兼容;新建作品统一使用 dictate */
|
/** 进料方式:对话落盘(默认)| 听书连载 | 旧工序编排 */
|
||||||
creationMode?: "recipe" | "dictate";
|
creationMode?: "recipe" | "dictate" | "listen_serial";
|
||||||
};
|
};
|
||||||
const skills = await listSkills();
|
const skills = await listSkills();
|
||||||
const requested = body.orchestratorId?.trim();
|
const requested = body.orchestratorId?.trim();
|
||||||
@@ -356,14 +401,19 @@ export async function handleBooksApi(
|
|||||||
json(res, 400, { error: "没有可用的编排器技能包" });
|
json(res, 400, { error: "没有可用的编排器技能包" });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const book = createBook({ title: body.title });
|
const rawMode = body.creationMode;
|
||||||
|
const creationMode =
|
||||||
|
rawMode === "listen_serial" || rawMode === "recipe" || rawMode === "dictate"
|
||||||
|
? rawMode
|
||||||
|
: "dictate";
|
||||||
|
const book = createBook({ title: body.title, creationMode });
|
||||||
updateBook(book.id, {
|
updateBook(book.id, {
|
||||||
activeSkillId: director.name,
|
activeSkillId: director.name,
|
||||||
activeSkillName: director.description?.split("\n")[0]?.slice(0, 80) || director.name,
|
activeSkillName: director.description?.split("\n")[0]?.slice(0, 80) || director.name,
|
||||||
orchestratorId: director.name,
|
orchestratorId: director.name,
|
||||||
orchestratorName: director.name,
|
orchestratorName: director.name,
|
||||||
|
creationMode,
|
||||||
});
|
});
|
||||||
const creationMode = "dictate";
|
|
||||||
const session = await sessionManager.createForBook(
|
const session = await sessionManager.createForBook(
|
||||||
book.id,
|
book.id,
|
||||||
director.name,
|
director.name,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const PHASE_UNIT_LABELS: Record<string, string> = {
|
|||||||
const CREATION_MODE_LABELS: Record<string, string> = {
|
const CREATION_MODE_LABELS: Record<string, string> = {
|
||||||
recipe: "工序编排",
|
recipe: "工序编排",
|
||||||
dictate: "对话落盘",
|
dictate: "对话落盘",
|
||||||
|
listen_serial: "听书连载",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SKILL_PACK_LABELS: Record<string, string> = {
|
const SKILL_PACK_LABELS: Record<string, string> = {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ import {
|
|||||||
sessionSkillPackId,
|
sessionSkillPackId,
|
||||||
skillPacksMatch,
|
skillPacksMatch,
|
||||||
} from "../book/skill-id.js";
|
} from "../book/skill-id.js";
|
||||||
|
import type { RequestOutline } from "../llm/context-outline.js";
|
||||||
import {
|
import {
|
||||||
pruneContextTraces,
|
pruneContextTraces,
|
||||||
type LlmContextTrace,
|
type LlmContextTrace,
|
||||||
@@ -102,6 +103,8 @@ 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 { parseReplyDataContract } from "../skills/reply-contract.js";
|
||||||
|
import { projectPresentBlocks } from "../skills/present-project.js";
|
||||||
import {
|
import {
|
||||||
CREATION_ACCEPTED_CONTENT_TAG,
|
CREATION_ACCEPTED_CONTENT_TAG,
|
||||||
CREATION_ACCEPTED_UNITS_TAG,
|
CREATION_ACCEPTED_UNITS_TAG,
|
||||||
@@ -182,6 +185,16 @@ import {
|
|||||||
sortDictateProducts,
|
sortDictateProducts,
|
||||||
type DictateProduct,
|
type DictateProduct,
|
||||||
} from "../dictate/index.js";
|
} from "../dictate/index.js";
|
||||||
|
import {
|
||||||
|
LISTEN_SERIAL_MODE_VALUE,
|
||||||
|
isListenSerialModeValue,
|
||||||
|
markListenConsumed,
|
||||||
|
readListenState,
|
||||||
|
runListenSerialTurn,
|
||||||
|
toListenView,
|
||||||
|
type ListenSerialView,
|
||||||
|
} from "../listen-serial/index.js";
|
||||||
|
import { synthesizeSpeech, type TtsSynthesizeResult } from "../tts/index.js";
|
||||||
import {
|
import {
|
||||||
formatQuestionAnswersForAi,
|
formatQuestionAnswersForAi,
|
||||||
formatQuestionAnswersForDisplay,
|
formatQuestionAnswersForDisplay,
|
||||||
@@ -255,6 +268,8 @@ export type ChatMessage = {
|
|||||||
thinking?: string;
|
thinking?: string;
|
||||||
/** 该次 LLM 请求实际携带的全量上下文(观察用;按设置只保留最新 N 条) */
|
/** 该次 LLM 请求实际携带的全量上下文(观察用;按设置只保留最新 N 条) */
|
||||||
contextTrace?: LlmContextTrace;
|
contextTrace?: LlmContextTrace;
|
||||||
|
/** 本条回复对应的各次请求标签轮廓(选题、填写都在;不含正文) */
|
||||||
|
requestOutlines?: RequestOutline[];
|
||||||
/** run 验收后过程讨论折叠(主 feed 隐藏);创作 messages 不删不藏 */
|
/** run 验收后过程讨论折叠(主 feed 隐藏);创作 messages 不删不藏 */
|
||||||
compressed?: boolean;
|
compressed?: boolean;
|
||||||
tokenUsage?: MessageTokenUsage;
|
tokenUsage?: MessageTokenUsage;
|
||||||
@@ -323,6 +338,14 @@ export type SessionView = {
|
|||||||
block_labels?: Record<string, string>;
|
block_labels?: Record<string, string>;
|
||||||
empty_states?: Record<string, string>;
|
empty_states?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
/** 正文组成固定前端。游玩和开场把 present.v1 灌进去,替换示例内容。 */
|
||||||
|
presentationFrontend?: { html: string; css: string; js: string };
|
||||||
|
/** 正文组成数据契约里的区。格式化时按 id 填进对应 data-block。 */
|
||||||
|
presentationSlots?: Array<{ id: string; type: string }>;
|
||||||
|
/**
|
||||||
|
* 程序投影块(来自 变量.当前 等)。前端灌 present 时与消息正文合并,随真值刷新。
|
||||||
|
*/
|
||||||
|
presentationProjection?: Record<string, unknown>;
|
||||||
/** 总管正在输出的思维链(流式,轮询用) */
|
/** 总管正在输出的思维链(流式,轮询用) */
|
||||||
agentThinking?: string;
|
agentThinking?: string;
|
||||||
/** Agent / Worker 流式输出(轮询用) */
|
/** Agent / Worker 流式输出(轮询用) */
|
||||||
@@ -333,10 +356,12 @@ export type SessionView = {
|
|||||||
*/
|
*/
|
||||||
runActive?: boolean;
|
runActive?: boolean;
|
||||||
resumed?: boolean;
|
resumed?: boolean;
|
||||||
/** 创作进料:recipe=配方节点流;dictate=转述整理直填 */
|
/** 创作进料:recipe=配方节点流;dictate=转述整理;listen_serial=听书连载 */
|
||||||
creationMode?: "recipe" | "dictate";
|
creationMode?: "recipe" | "dictate" | "listen_serial";
|
||||||
/** 转述式已写入黑板的产物(按相对序) */
|
/** 转述式已写入黑板的产物(按相对序) */
|
||||||
dictateProducts?: Array<{ tag: string; content: string; order?: number }>;
|
dictateProducts?: Array<{ tag: string; content: string; order?: number }>;
|
||||||
|
/** 听书连载投影(大纲/正文缓冲/未读字数) */
|
||||||
|
listenSerial?: ListenSerialView;
|
||||||
tokenStats?: {
|
tokenStats?: {
|
||||||
sessionTotal: number;
|
sessionTotal: number;
|
||||||
sessionCached?: number;
|
sessionCached?: number;
|
||||||
@@ -555,8 +580,16 @@ export class SessionManager {
|
|||||||
this.writeOpeningPersonaChoice(s, true);
|
this.writeOpeningPersonaChoice(s, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 与进游玩一致:用完整文档(含 present.v1),勿用侧栏预览的压扁正文
|
||||||
|
const rawOnBoard = board.getContentByTag(picked.tag)?.trim() ?? "";
|
||||||
|
const body =
|
||||||
|
splitOpeningDocument(
|
||||||
|
rawOnBoard || picked.document || picked.text,
|
||||||
|
).text.trim() ||
|
||||||
|
picked.document?.trim() ||
|
||||||
|
picked.text.trim();
|
||||||
const expanded = this.expandDirectivesText(
|
const expanded = this.expandDirectivesText(
|
||||||
picked.text,
|
body,
|
||||||
this.playDirectiveContext(s),
|
this.playDirectiveContext(s),
|
||||||
);
|
);
|
||||||
board.write({
|
board.write({
|
||||||
@@ -566,7 +599,13 @@ export class SessionManager {
|
|||||||
});
|
});
|
||||||
const openingAt = s.messages.findIndex((m) => m.title === "开场白");
|
const openingAt = s.messages.findIndex((m) => m.title === "开场白");
|
||||||
if (openingAt >= 0) {
|
if (openingAt >= 0) {
|
||||||
s.messages[openingAt] = this.playOpeningMessage(expanded);
|
// 保留 id,前端才能 patch 呈现壳 forceFill,而不是拆 iframe 后软灌样本槽
|
||||||
|
s.messages[openingAt] = this.playOpeningMessage(
|
||||||
|
expanded,
|
||||||
|
s.messages[openingAt]!.id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
s.messages.unshift(this.playOpeningMessage(expanded));
|
||||||
}
|
}
|
||||||
if (s.bookId) this.persist(s);
|
if (s.bookId) this.persist(s);
|
||||||
return this.toView(sessionId);
|
return this.toView(sessionId);
|
||||||
@@ -613,11 +652,13 @@ export class SessionManager {
|
|||||||
|
|
||||||
const next = applyContextOrderEdit(current!, edit);
|
const next = applyContextOrderEdit(current!, edit);
|
||||||
const serialized = serializeContextOrder(next);
|
const serialized = serializeContextOrder(next);
|
||||||
|
const orderMeta = bb.getLatestByTag(CONTEXT_ORDER_TAG)?.metadata;
|
||||||
|
|
||||||
bb.write({
|
bb.write({
|
||||||
tag: CONTEXT_ORDER_TAG,
|
tag: CONTEXT_ORDER_TAG,
|
||||||
content: serialized,
|
content: serialized,
|
||||||
source: "user",
|
source: "user",
|
||||||
|
metadata: orderMeta,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (workerSetRaw) {
|
if (workerSetRaw) {
|
||||||
@@ -631,6 +672,7 @@ export class SessionManager {
|
|||||||
tag: targetTag,
|
tag: targetTag,
|
||||||
content: mergeContextOrderIntoWorkerSetJson(workerSetRaw, next),
|
content: mergeContextOrderIntoWorkerSetJson(workerSetRaw, next),
|
||||||
source: "user",
|
source: "user",
|
||||||
|
metadata: bb.getLatestByTag(targetTag)?.metadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -639,6 +681,7 @@ export class SessionManager {
|
|||||||
tag: CONTEXT_ORDER_TAG,
|
tag: CONTEXT_ORDER_TAG,
|
||||||
content: serialized,
|
content: serialized,
|
||||||
source: "user",
|
source: "user",
|
||||||
|
metadata: orderMeta,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,7 +713,7 @@ export class SessionManager {
|
|||||||
bookId?: string,
|
bookId?: string,
|
||||||
preselectSkillId?: string,
|
preselectSkillId?: string,
|
||||||
selectedRecipeId?: string,
|
selectedRecipeId?: string,
|
||||||
opts?: { creationMode?: "recipe" | "dictate" },
|
opts?: { creationMode?: "recipe" | "dictate" | "listen_serial" },
|
||||||
): Promise<SessionView> {
|
): Promise<SessionView> {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const messages: ChatMessage[] = [];
|
const messages: ChatMessage[] = [];
|
||||||
@@ -713,18 +756,22 @@ export class SessionManager {
|
|||||||
} else {
|
} else {
|
||||||
await runtime.start();
|
await runtime.start();
|
||||||
}
|
}
|
||||||
const useDictate = opts?.creationMode === "dictate";
|
const creationMode = opts?.creationMode ?? "recipe";
|
||||||
|
const useDictate = creationMode === "dictate";
|
||||||
|
const useListen = creationMode === "listen_serial";
|
||||||
await this.refreshRecipeOptions(
|
await this.refreshRecipeOptions(
|
||||||
managed,
|
managed,
|
||||||
useDictate ? "dictate" : "recipe",
|
useDictate || useListen ? "dictate" : "recipe",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selectedRecipeId?.trim()) {
|
if (selectedRecipeId?.trim()) {
|
||||||
await this.applySelectedRecipe(managed, selectedRecipeId.trim(), {
|
await this.applySelectedRecipe(managed, selectedRecipeId.trim(), {
|
||||||
seedFlow: !useDictate,
|
seedFlow: !useDictate && !useListen,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (useDictate) {
|
if (useListen) {
|
||||||
|
this.enterListenSerialMode(managed);
|
||||||
|
} else if (useDictate) {
|
||||||
this.enterDictateMode(managed);
|
this.enterDictateMode(managed);
|
||||||
} else {
|
} else {
|
||||||
await this.maybeKickAgentAfterDemand(runtime);
|
await this.maybeKickAgentAfterDemand(runtime);
|
||||||
@@ -780,6 +827,145 @@ export class SessionManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 听书连载:薄上下文连续产文,不走对话落盘工具环 */
|
||||||
|
private enterListenSerialMode(s: ManagedSession): void {
|
||||||
|
const board = s.runtime.getBlackboard();
|
||||||
|
board.write({
|
||||||
|
tag: CREATION_INTAKE_MODE_TAG,
|
||||||
|
content: LISTEN_SERIAL_MODE_VALUE,
|
||||||
|
source: "runtime",
|
||||||
|
});
|
||||||
|
s.runtime.enterDictateIntake(
|
||||||
|
"听书连载:用几句话描述题材与爽点;确认后我会生成大纲并自动补正文。不满意就说一句意见改后面。",
|
||||||
|
);
|
||||||
|
const welcome =
|
||||||
|
"听书连载:说几句你想听的故事(题材、人设、爽点即可)。我会轻回问或给出草案;确认后自动出大纲和正文缓冲。听的过程中随时一句意见,只改后面的走向。";
|
||||||
|
s.messages.push({
|
||||||
|
id: randomUUID(),
|
||||||
|
role: "system",
|
||||||
|
text: welcome,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
kind: "orchestrator_prompt",
|
||||||
|
actor: "orchestrator",
|
||||||
|
title: "听书连载",
|
||||||
|
body: welcome,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isListenSerialMode(s: ManagedSession): boolean {
|
||||||
|
return isListenSerialModeValue(
|
||||||
|
s.runtime.getBlackboard().getContentByTag(CREATION_INTAKE_MODE_TAG),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private creationIntakeMode(
|
||||||
|
s: ManagedSession,
|
||||||
|
): "recipe" | "dictate" | "listen_serial" {
|
||||||
|
if (this.isListenSerialMode(s)) return "listen_serial";
|
||||||
|
if (this.isDictateMode(s)) return "dictate";
|
||||||
|
return "recipe";
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractListenDialogue(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
): Array<{ role: "user" | "assistant"; text: string }> {
|
||||||
|
const out: Array<{ role: "user" | "assistant"; text: string }> = [];
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.kind === "user_input" || m.role === "user") {
|
||||||
|
const t = (m.body ?? m.text ?? "").trim();
|
||||||
|
if (t) out.push({ role: "user", text: t });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (m.kind === "listen_reply") {
|
||||||
|
const t = (m.body ?? m.text ?? "").trim();
|
||||||
|
if (t) out.push({ role: "assistant", text: t });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runListenSerialTurnForSession(
|
||||||
|
s: ManagedSession,
|
||||||
|
userText: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const llm = createDefaultMainAgentLlm(s.trackingRef, () =>
|
||||||
|
this.personaForManaged(s),
|
||||||
|
);
|
||||||
|
const dialogue = this.extractListenDialogue(s.messages);
|
||||||
|
const result = await runListenSerialTurn({
|
||||||
|
llm,
|
||||||
|
board: s.runtime.getBlackboard(),
|
||||||
|
dialogue,
|
||||||
|
userText,
|
||||||
|
});
|
||||||
|
const keep = normalizeContextTraceKeepLatest(
|
||||||
|
loadAppSettings().contextTraceKeepLatest,
|
||||||
|
);
|
||||||
|
const requestOutlines = s.trackingRef.current.pendingOutlines;
|
||||||
|
const pendingTrace = s.trackingRef.current.pendingContextTrace;
|
||||||
|
const tokenUsage = s.trackingRef.current.pendingUsage;
|
||||||
|
s.trackingRef.current.pendingOutlines = undefined;
|
||||||
|
s.trackingRef.current.pendingContextTrace = undefined;
|
||||||
|
s.trackingRef.current.pendingUsage = undefined;
|
||||||
|
s.messages.push({
|
||||||
|
id: randomUUID(),
|
||||||
|
role: "system",
|
||||||
|
text: result.reply,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
kind: "listen_reply",
|
||||||
|
actor: "orchestrator",
|
||||||
|
title: "听书连载",
|
||||||
|
body: result.reply,
|
||||||
|
...(tokenUsage ? { tokenUsage } : {}),
|
||||||
|
...(keep > 0 && pendingTrace ? { contextTrace: pendingTrace } : {}),
|
||||||
|
...(requestOutlines?.length ? { requestOutlines } : {}),
|
||||||
|
});
|
||||||
|
s.messages = pruneContextTraces(s.messages, keep);
|
||||||
|
s.runtime.enterDictateIntake(
|
||||||
|
result.softStop
|
||||||
|
? "本段已告一段落。输入「下一波」或「收工」,或一句意见改向。"
|
||||||
|
: "继续听/读,或说「继续」补货;不满意直接一句意见。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 听书:标记已消费字数(阅读进度) */
|
||||||
|
markListenSerialConsumed(
|
||||||
|
sessionId: string,
|
||||||
|
consumedChars: number,
|
||||||
|
): SessionView {
|
||||||
|
const s = this.require(sessionId);
|
||||||
|
if (!this.isListenSerialMode(s)) {
|
||||||
|
throw new Error("当前不是听书连载模式");
|
||||||
|
}
|
||||||
|
markListenConsumed(s.runtime.getBlackboard(), consumedChars);
|
||||||
|
if (s.bookId) this.persist(s);
|
||||||
|
return this.toView(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 听书:按节合成语音(默认 Edge) */
|
||||||
|
async synthesizeListenSection(
|
||||||
|
sessionId: string,
|
||||||
|
sectionId: string,
|
||||||
|
): Promise<TtsSynthesizeResult & { sectionId: string; title: string }> {
|
||||||
|
const s = this.require(sessionId);
|
||||||
|
if (!this.isListenSerialMode(s)) {
|
||||||
|
throw new Error("当前不是听书连载模式");
|
||||||
|
}
|
||||||
|
const id = sectionId.trim();
|
||||||
|
if (!id) throw new Error("需要 sectionId");
|
||||||
|
const state = readListenState(s.runtime.getBlackboard());
|
||||||
|
const section = state.sections.find((x) => x.id === id);
|
||||||
|
if (!section?.text?.trim()) {
|
||||||
|
throw new Error("未找到该节正文");
|
||||||
|
}
|
||||||
|
const spoken = `【${section.title}】\n${section.text}`.trim();
|
||||||
|
const result = await synthesizeSpeech({
|
||||||
|
text: spoken,
|
||||||
|
cacheKey: `${sessionId}:${id}`,
|
||||||
|
});
|
||||||
|
return { ...result, sectionId: id, title: section.title };
|
||||||
|
}
|
||||||
|
|
||||||
private listDictateProducts(s: ManagedSession): DictateProduct[] {
|
private listDictateProducts(s: ManagedSession): DictateProduct[] {
|
||||||
const board = s.runtime.getBlackboard();
|
const board = s.runtime.getBlackboard();
|
||||||
const products: DictateProduct[] = [];
|
const products: DictateProduct[] = [];
|
||||||
@@ -897,11 +1083,10 @@ export class SessionManager {
|
|||||||
if (dictateProductFamily(tag) === OPENING_PRODUCT_FAMILY) {
|
if (dictateProductFamily(tag) === OPENING_PRODUCT_FAMILY) {
|
||||||
const split = splitOpeningDocument(content);
|
const split = splitOpeningDocument(content);
|
||||||
if (split.persona) {
|
if (split.persona) {
|
||||||
ensureProtagonistFromPersona(
|
const personaTag = protagonistTagForOpeningSlot(tag);
|
||||||
board,
|
ensureProtagonistFromPersona(board, split.persona, personaTag, {
|
||||||
split.persona,
|
overwrite: personaTag !== PROTAGONIST_TAG,
|
||||||
protagonistTagForOpeningSlot(tag),
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// 仅有用户角色 meta、无叙事:抬到主角设定,不污染开场白
|
// 仅有用户角色 meta、无叙事:抬到主角设定,不污染开场白
|
||||||
if (split.persona && !split.text.trim()) {
|
if (split.persona && !split.text.trim()) {
|
||||||
@@ -1009,6 +1194,15 @@ export class SessionManager {
|
|||||||
s.messages.length,
|
s.messages.length,
|
||||||
this.captureCheckpoint(s),
|
this.captureCheckpoint(s),
|
||||||
);
|
);
|
||||||
|
const keep = normalizeContextTraceKeepLatest(
|
||||||
|
loadAppSettings().contextTraceKeepLatest,
|
||||||
|
);
|
||||||
|
const requestOutlines = s.trackingRef.current.pendingOutlines;
|
||||||
|
const pendingTrace = s.trackingRef.current.pendingContextTrace;
|
||||||
|
const tokenUsage = s.trackingRef.current.pendingUsage;
|
||||||
|
s.trackingRef.current.pendingOutlines = undefined;
|
||||||
|
s.trackingRef.current.pendingContextTrace = undefined;
|
||||||
|
s.trackingRef.current.pendingUsage = undefined;
|
||||||
s.messages.push({
|
s.messages.push({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
role: "system",
|
role: "system",
|
||||||
@@ -1019,7 +1213,11 @@ export class SessionManager {
|
|||||||
title: "转述整理",
|
title: "转述整理",
|
||||||
body: result.reply,
|
body: result.reply,
|
||||||
...(thinking ? { thinking } : {}),
|
...(thinking ? { thinking } : {}),
|
||||||
|
...(tokenUsage ? { tokenUsage } : {}),
|
||||||
|
...(keep > 0 && pendingTrace ? { contextTrace: pendingTrace } : {}),
|
||||||
|
...(requestOutlines?.length ? { requestOutlines } : {}),
|
||||||
});
|
});
|
||||||
|
s.messages = pruneContextTraces(s.messages, keep);
|
||||||
s.runtime.enterDictateIntake();
|
s.runtime.enterDictateIntake();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1494,6 +1692,7 @@ export class SessionManager {
|
|||||||
// 转述聊天常夹确认/建议,不能当开场白
|
// 转述聊天常夹确认/建议,不能当开场白
|
||||||
if (
|
if (
|
||||||
kind === "dictate_reply" ||
|
kind === "dictate_reply" ||
|
||||||
|
kind === "listen_reply" ||
|
||||||
kind === "agent_tool" ||
|
kind === "agent_tool" ||
|
||||||
kind === "orchestrator_thinking" ||
|
kind === "orchestrator_thinking" ||
|
||||||
kind === "worker_questions"
|
kind === "worker_questions"
|
||||||
@@ -1830,9 +2029,9 @@ export class SessionManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private playOpeningMessage(text: string): ChatMessage {
|
private playOpeningMessage(text: string, keepId?: string): ChatMessage {
|
||||||
return {
|
return {
|
||||||
id: randomUUID(),
|
id: keepId || randomUUID(),
|
||||||
role: "system",
|
role: "system",
|
||||||
text,
|
text,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@@ -1917,6 +2116,29 @@ export class SessionManager {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换/改了全局用户角色:游玩层开场按新 @玩家 重展开,并改用全局角色卡。
|
||||||
|
*/
|
||||||
|
rebindPlayOpeningsForActivePersona(): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const [sessionId, s] of this.sessions) {
|
||||||
|
if (!isPlayLayerActive(s.runtime.getSession().slots)) continue;
|
||||||
|
if (inferLifecycleStage(s.runtime.getSession()) !== "play") continue;
|
||||||
|
const aux = buildPlayAuxView(
|
||||||
|
s.runtime.getBlackboard(),
|
||||||
|
s.runtime.getSession().slots,
|
||||||
|
);
|
||||||
|
if (!aux.openings.length) continue;
|
||||||
|
try {
|
||||||
|
this.selectPlayOpening(sessionId, aux.selectedOpeningIndex, false);
|
||||||
|
count += 1;
|
||||||
|
} catch {
|
||||||
|
/* 单局失败不挡其它局 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
async sendMessage(
|
async sendMessage(
|
||||||
id: string,
|
id: string,
|
||||||
text: string,
|
text: string,
|
||||||
@@ -1983,13 +2205,15 @@ export class SessionManager {
|
|||||||
const inPlay =
|
const inPlay =
|
||||||
isPlayLayerActive(sess.slots) ||
|
isPlayLayerActive(sess.slots) ||
|
||||||
inferLifecycleStage(sess) === "play";
|
inferLifecycleStage(sess) === "play";
|
||||||
if (
|
if (reason?.kind === "input" && !inPlay) {
|
||||||
this.isDictateMode(s) &&
|
if (this.isListenSerialMode(s)) {
|
||||||
reason?.kind === "input" &&
|
await this.runListenSerialTurnForSession(s, trimmed || text);
|
||||||
!inPlay
|
return;
|
||||||
) {
|
}
|
||||||
await this.runDictateTurnForSession(s, trimmed || text);
|
if (this.isDictateMode(s)) {
|
||||||
return;
|
await this.runDictateTurnForSession(s, trimmed || text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (reason?.kind === "approve_step") {
|
if (reason?.kind === "approve_step") {
|
||||||
@@ -2237,13 +2461,15 @@ export class SessionManager {
|
|||||||
const inPlay =
|
const inPlay =
|
||||||
isPlayLayerActive(sess.slots) ||
|
isPlayLayerActive(sess.slots) ||
|
||||||
inferLifecycleStage(sess) === "play";
|
inferLifecycleStage(sess) === "play";
|
||||||
if (
|
if (reason?.kind === "input" && !inPlay) {
|
||||||
this.isDictateMode(s) &&
|
if (this.isListenSerialMode(s)) {
|
||||||
reason?.kind === "input" &&
|
await this.runListenSerialTurnForSession(s, trimmed);
|
||||||
!inPlay
|
return;
|
||||||
) {
|
}
|
||||||
await this.runDictateTurnForSession(s, trimmed);
|
if (this.isDictateMode(s)) {
|
||||||
return;
|
await this.runDictateTurnForSession(s, trimmed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await s.runtime.submitInput(trimmed);
|
await s.runtime.submitInput(trimmed);
|
||||||
});
|
});
|
||||||
@@ -3100,6 +3326,7 @@ export class SessionManager {
|
|||||||
undefined;
|
undefined;
|
||||||
const pendingTrace = managed.trackingRef.current.pendingContextTrace;
|
const pendingTrace = managed.trackingRef.current.pendingContextTrace;
|
||||||
let contextTrace: LlmContextTrace | undefined;
|
let contextTrace: LlmContextTrace | undefined;
|
||||||
|
let requestOutlines: RequestOutline[] | undefined;
|
||||||
if (
|
if (
|
||||||
pendingTrace &&
|
pendingTrace &&
|
||||||
classified &&
|
classified &&
|
||||||
@@ -3112,10 +3339,19 @@ export class SessionManager {
|
|||||||
contextTrace = pendingTrace;
|
contextTrace = pendingTrace;
|
||||||
}
|
}
|
||||||
managed.trackingRef.current.pendingContextTrace = undefined;
|
managed.trackingRef.current.pendingContextTrace = undefined;
|
||||||
|
requestOutlines = managed.trackingRef.current.pendingOutlines;
|
||||||
|
managed.trackingRef.current.pendingOutlines = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
managed.messages.push(
|
managed.messages.push(
|
||||||
this.msg("system", text, usage, thinking, contextTrace),
|
this.msg(
|
||||||
|
"system",
|
||||||
|
text,
|
||||||
|
usage,
|
||||||
|
thinking,
|
||||||
|
contextTrace,
|
||||||
|
requestOutlines?.length ? requestOutlines : undefined,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
managed.messages = pruneContextTraces(
|
managed.messages = pruneContextTraces(
|
||||||
managed.messages,
|
managed.messages,
|
||||||
@@ -3405,6 +3641,7 @@ export class SessionManager {
|
|||||||
tokenUsage?: MessageTokenUsage,
|
tokenUsage?: MessageTokenUsage,
|
||||||
thinking?: string,
|
thinking?: string,
|
||||||
contextTrace?: LlmContextTrace,
|
contextTrace?: LlmContextTrace,
|
||||||
|
requestOutlines?: RequestOutline[],
|
||||||
): ChatMessage {
|
): ChatMessage {
|
||||||
const base = {
|
const base = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -3414,6 +3651,7 @@ export class SessionManager {
|
|||||||
...(tokenUsage ? { tokenUsage } : {}),
|
...(tokenUsage ? { tokenUsage } : {}),
|
||||||
...(thinking ? { thinking } : {}),
|
...(thinking ? { thinking } : {}),
|
||||||
...(contextTrace ? { contextTrace } : {}),
|
...(contextTrace ? { contextTrace } : {}),
|
||||||
|
...(requestOutlines?.length ? { requestOutlines } : {}),
|
||||||
};
|
};
|
||||||
if (role === "user") {
|
if (role === "user") {
|
||||||
return {
|
return {
|
||||||
@@ -3812,10 +4050,12 @@ export class SessionManager {
|
|||||||
lifecycleStage,
|
lifecycleStage,
|
||||||
{ workerSetYaml },
|
{ workerSetYaml },
|
||||||
);
|
);
|
||||||
const shellAdapt = parseShellAdaptationFromReplyFormat(
|
const replyFormatRaw =
|
||||||
s.runtime.getBlackboard().getContentByTag("设计.正文组成") ??
|
s.runtime.getBlackboard().getContentByTag("设计.正文组成") ??
|
||||||
s.runtime.getBlackboard().getContentByTag("设计.回复格式"),
|
s.runtime.getBlackboard().getContentByTag("设计.0层正文组成") ??
|
||||||
);
|
s.runtime.getBlackboard().getContentByTag("设计.回复格式");
|
||||||
|
const shellAdapt = parseShellAdaptationFromReplyFormat(replyFormatRaw);
|
||||||
|
const replyContract = parseReplyDataContract(replyFormatRaw);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -3831,10 +4071,13 @@ export class SessionManager {
|
|||||||
actions,
|
actions,
|
||||||
pipeline: buildPipeline(session),
|
pipeline: buildPipeline(session),
|
||||||
lifecycleStage,
|
lifecycleStage,
|
||||||
creationMode: this.isDictateMode(s) ? "dictate" : "recipe",
|
creationMode: this.creationIntakeMode(s),
|
||||||
dictateProducts: this.isDictateMode(s)
|
dictateProducts: this.isDictateMode(s)
|
||||||
? this.listDictateProducts(s)
|
? this.listDictateProducts(s)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
listenSerial: this.isListenSerialMode(s)
|
||||||
|
? toListenView(readListenState(s.runtime.getBlackboard()))
|
||||||
|
: undefined,
|
||||||
playLayerActive: isPlayLayerActive(session.slots),
|
playLayerActive: isPlayLayerActive(session.slots),
|
||||||
playReady: canEnterPlay(session),
|
playReady: canEnterPlay(session),
|
||||||
hasProduct: Boolean(
|
hasProduct: Boolean(
|
||||||
@@ -3895,6 +4138,27 @@ export class SessionManager {
|
|||||||
...shellAdapt.tweaks,
|
...shellAdapt.tweaks,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
presentationFrontend: replyContract?.frontend,
|
||||||
|
presentationSlots: replyContract
|
||||||
|
? [...replyContract.modelBlocks, ...replyContract.programBlocks].map((block) => ({
|
||||||
|
id: block.id,
|
||||||
|
type: block.type,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
presentationProjection: (() => {
|
||||||
|
if (!replyContract?.programBlocks.length) return undefined;
|
||||||
|
const board = s.runtime.getBlackboard();
|
||||||
|
const projected = projectPresentBlocks({
|
||||||
|
contract: replyContract,
|
||||||
|
statusBarRaw:
|
||||||
|
board.getContentByTag("设计.监控栏") ??
|
||||||
|
board.getContentByTag("设计.回复呈现"),
|
||||||
|
currentVarsRaw: board.getContentByTag("变量.当前"),
|
||||||
|
topologyRaw: board.getContentByTag("运行.拓扑"),
|
||||||
|
readTag: (tag) => board.getContentByTag(tag),
|
||||||
|
});
|
||||||
|
return Object.keys(projected).length ? projected : undefined;
|
||||||
|
})(),
|
||||||
liveStream: this.buildLiveStreamView(id),
|
liveStream: this.buildLiveStreamView(id),
|
||||||
agentThinking: this.agentThinkingLive.get(id),
|
agentThinking: this.agentThinkingLive.get(id),
|
||||||
runActive: Boolean(s.runAbort),
|
runActive: Boolean(s.runAbort),
|
||||||
|
|||||||
@@ -613,8 +613,13 @@ export async function handleSettingsApi(
|
|||||||
activate: body.activate,
|
activate: body.activate,
|
||||||
creationDefault: body.creationDefault,
|
creationDefault: body.creationDefault,
|
||||||
});
|
});
|
||||||
|
const reboundSessions =
|
||||||
|
body.activate === false
|
||||||
|
? 0
|
||||||
|
: sessionManager.rebindPlayOpeningsForActivePersona();
|
||||||
json(res, 201, {
|
json(res, 201, {
|
||||||
...personasPayload({ persona }),
|
...personasPayload({ persona }),
|
||||||
|
reboundSessions,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
json(res, 400, {
|
json(res, 400, {
|
||||||
@@ -634,8 +639,11 @@ export async function handleSettingsApi(
|
|||||||
if (activate && req.method === "POST") {
|
if (activate && req.method === "POST") {
|
||||||
try {
|
try {
|
||||||
const persona = setActivePersonaId(id);
|
const persona = setActivePersonaId(id);
|
||||||
|
const reboundSessions =
|
||||||
|
sessionManager.rebindPlayOpeningsForActivePersona();
|
||||||
json(res, 200, {
|
json(res, 200, {
|
||||||
...personasPayload({ persona, activeId: persona.id }),
|
...personasPayload({ persona, activeId: persona.id }),
|
||||||
|
reboundSessions,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
json(res, 404, {
|
json(res, 404, {
|
||||||
@@ -653,8 +661,13 @@ export async function handleSettingsApi(
|
|||||||
creationDefault?: boolean;
|
creationDefault?: boolean;
|
||||||
};
|
};
|
||||||
const persona = updatePersona(id, body);
|
const persona = updatePersona(id, body);
|
||||||
|
const reboundSessions =
|
||||||
|
getActivePersona()?.id === persona.id
|
||||||
|
? sessionManager.rebindPlayOpeningsForActivePersona()
|
||||||
|
: 0;
|
||||||
json(res, 200, {
|
json(res, 200, {
|
||||||
...personasPayload({ persona }),
|
...personasPayload({ persona }),
|
||||||
|
reboundSessions,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
@@ -666,8 +679,11 @@ export async function handleSettingsApi(
|
|||||||
if (req.method === "DELETE") {
|
if (req.method === "DELETE") {
|
||||||
try {
|
try {
|
||||||
deletePersona(id);
|
deletePersona(id);
|
||||||
|
const reboundSessions =
|
||||||
|
sessionManager.rebindPlayOpeningsForActivePersona();
|
||||||
json(res, 200, {
|
json(res, 200, {
|
||||||
...personasPayload({ ok: true }),
|
...personasPayload({ ok: true }),
|
||||||
|
reboundSessions,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
SUGGESTED_PRICING_DOCS,
|
SUGGESTED_PRICING_DOCS,
|
||||||
} from "../stats/pricing.js";
|
} from "../stats/pricing.js";
|
||||||
import { attachRecordCost, buildCostReport } from "../stats/pricing-cost.js";
|
import { attachRecordCost, buildCostReport } from "../stats/pricing-cost.js";
|
||||||
|
import { annotateCacheBreaks } from "../llm/context-outline.js";
|
||||||
|
|
||||||
async function readBody(req: IncomingMessage): Promise<string> {
|
async function readBody(req: IncomingMessage): Promise<string> {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -77,6 +78,7 @@ export async function handleStatsApi(
|
|||||||
const records = readRecords({ ...query, limit: query.limit ?? 100 });
|
const records = readRecords({ ...query, limit: query.limit ?? 100 });
|
||||||
const catalog = resolveRateCatalog();
|
const catalog = resolveRateCatalog();
|
||||||
const cost = buildCostReport(summaryRecords, catalog);
|
const cost = buildCostReport(summaryRecords, catalog);
|
||||||
|
const breaks = annotateCacheBreaks(summaryRecords);
|
||||||
json(res, 200, {
|
json(res, 200, {
|
||||||
summary: {
|
summary: {
|
||||||
...summarizeRecords(summaryRecords),
|
...summarizeRecords(summaryRecords),
|
||||||
@@ -92,6 +94,7 @@ export async function handleStatsApi(
|
|||||||
...r,
|
...r,
|
||||||
estimatedCost: priced.cost,
|
estimatedCost: priced.cost,
|
||||||
unmatchedRate: priced.unmatched,
|
unmatchedRate: priced.unmatched,
|
||||||
|
cacheBreak: breaks.get(r.id) ?? null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -230,6 +230,54 @@ const server = createServer(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && sub === "/listen/consume") {
|
||||||
|
const body = JSON.parse(await readBody(req)) as {
|
||||||
|
consumedChars?: number;
|
||||||
|
};
|
||||||
|
const n = Number(body.consumedChars);
|
||||||
|
if (!Number.isFinite(n) || n < 0) {
|
||||||
|
json(res, 400, { error: "需要 consumedChars ≥ 0" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const view = sessionManager.markListenSerialConsumed(sessionId, n);
|
||||||
|
json(res, 200, view);
|
||||||
|
} catch (err) {
|
||||||
|
json(res, 400, {
|
||||||
|
error: err instanceof Error ? err.message : "进度更新失败",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && sub === "/listen/tts") {
|
||||||
|
const sectionId = (url.searchParams.get("sectionId") ?? "").trim();
|
||||||
|
if (!sectionId) {
|
||||||
|
json(res, 400, { error: "需要 sectionId" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const audio = await sessionManager.synthesizeListenSection(
|
||||||
|
sessionId,
|
||||||
|
sectionId,
|
||||||
|
);
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": audio.contentType,
|
||||||
|
"Content-Length": audio.audio.length,
|
||||||
|
"Cache-Control": audio.cached ? "private, max-age=86400" : "no-store",
|
||||||
|
"X-TTS-Provider": audio.provider,
|
||||||
|
"X-TTS-Voice": audio.voice,
|
||||||
|
"X-TTS-Cached": audio.cached ? "1" : "0",
|
||||||
|
});
|
||||||
|
res.end(audio.audio);
|
||||||
|
} catch (err) {
|
||||||
|
json(res, 400, {
|
||||||
|
error: err instanceof Error ? err.message : "语音合成失败",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && sub === "/answers") {
|
if (req.method === "POST" && sub === "/answers") {
|
||||||
const body = JSON.parse(await readBody(req)) as {
|
const body = JSON.parse(await readBody(req)) as {
|
||||||
answers?: Array<{
|
answers?: Array<{
|
||||||
|
|||||||
@@ -1423,6 +1423,14 @@ const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([
|
|||||||
"refine",
|
"refine",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** 落档程序或目录标了 auto 的节点:创作对话不 insert */
|
||||||
|
export function isDictateProgramModule(module: {
|
||||||
|
id: string;
|
||||||
|
auto?: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return Boolean(module.auto) || DICTATE_PROGRAM_SIDE_MODULE_IDS.has(module.id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注入对话落盘:能力何时落盘(meta when/when_not)+ insert 目标 tag。
|
* 注入对话落盘:能力何时落盘(meta when/when_not)+ insert 目标 tag。
|
||||||
* 不写 DAG / design-step 话术;不塞执行全文。
|
* 不写 DAG / design-step 话术;不塞执行全文。
|
||||||
@@ -1430,7 +1438,7 @@ const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([
|
|||||||
export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
|
export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
|
||||||
const dictateModules = catalogModulesForIntake(catalog, "dictate");
|
const dictateModules = catalogModulesForIntake(catalog, "dictate");
|
||||||
const lines = dictateModules.map((m) => {
|
const lines = dictateModules.map((m) => {
|
||||||
const programSide = DICTATE_PROGRAM_SIDE_MODULE_IDS.has(m.id) || m.auto;
|
const programSide = isDictateProgramModule(m);
|
||||||
const flags = [
|
const flags = [
|
||||||
m.repeatable ? "〔可反复〕" : "",
|
m.repeatable ? "〔可反复〕" : "",
|
||||||
m.closer ? "〔收口〕" : "",
|
m.closer ? "〔收口〕" : "",
|
||||||
@@ -1439,7 +1447,7 @@ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("");
|
.join("");
|
||||||
const land = dictateLandHint(m);
|
const land = dictateModuleLandHint(m);
|
||||||
const parts: string[] = [
|
const parts: string[] = [
|
||||||
`- ${m.name}${flags}:${m.declaration}`,
|
`- ${m.name}${flags}:${m.declaration}`,
|
||||||
` 落盘:${land}`,
|
` 落盘:${land}`,
|
||||||
@@ -1455,7 +1463,7 @@ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
|
|||||||
"「必须」和「有必要」里用户还没提到的:本轮不落盘,按已有材料给 2~3 个选项询问,选定后再写。含糊或未到时机的下游同样先问,不先写。",
|
"「必须」和「有必要」里用户还没提到的:本轮不落盘,按已有材料给 2~3 个选项询问,选定后再写。含糊或未到时机的下游同样先问,不先写。",
|
||||||
"同轮写入的每份产物各回 reply_module,可见回复综合成一次。",
|
"同轮写入的每份产物各回 reply_module,可见回复综合成一次。",
|
||||||
"标〔落档程序〕的:创作对话一般不 insert,开玩/落档时由程序处理。",
|
"标〔落档程序〕的:创作对话一般不 insert,开玩/落档时由程序处理。",
|
||||||
"标〔收口〕的开场:写开场前,列出仍未落的「必须」和「有必要」,问用户这些还没设计要补哪几项或明确不要。用户这句已经说清的再写入。普通模式使用回复呈现/正文组成,0 层模式使用对应 0 层节点,不得混用。用户确认可以收口后,程序依次编译信息可见范围、游玩执行方式、上下文排序与运行配置,再写所选模式的开场产物。",
|
"标〔收口〕的开场:写开场前,列出仍未落的「必须」和「有必要」,问用户这些还没设计要补哪几项或明确不要。用户这句已经说清的再写入。普通模式使用回复呈现/正文组成,0 层模式使用对应 0 层节点,不得混用。用户确认可以收口后,程序依次按有无变量真值固定开旁观填表、编译信息可见范围、游玩执行方式、上下文排序与运行配置,再写所选模式的开场产物。",
|
||||||
"标〔可反复〕的(生成规则、具体实例、开场白、拓扑等):每条单独落盘,用「基名#唯一id」(生成规则用规则名,具体实例用名称,开场白用场景短码)。同完整 tag 才覆盖,禁止反复写基名盖掉别的条。开场全部保留;游玩时左右切换,选中的一条作为 0 层。",
|
"标〔可反复〕的(生成规则、具体实例、开场白、拓扑等):每条单独落盘,用「基名#唯一id」(生成规则用规则名,具体实例用名称,开场白用场景短码)。同完整 tag 才覆盖,禁止反复写基名盖掉别的条。开场全部保留;游玩时左右切换,选中的一条作为 0 层。",
|
||||||
"能力执行全文不进聊天;规格只走 toolcall。insert 成功后按返回的 reply_module 写可见回复(见系统提示「对用户可见回复」)。",
|
"能力执行全文不进聊天;规格只走 toolcall。insert 成功后按返回的 reply_module 写可见回复(见系统提示「对用户可见回复」)。",
|
||||||
lines.join("\n"),
|
lines.join("\n"),
|
||||||
@@ -1464,12 +1472,15 @@ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
|
|||||||
return head;
|
return head;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dictateLandHint(m: ModuleCatalogEntry): string {
|
export function dictateModuleLandHint(m: ModuleCatalogEntry): string {
|
||||||
if (DICTATE_PROGRAM_SIDE_MODULE_IDS.has(m.id) || m.auto) {
|
if (isDictateProgramModule(m)) {
|
||||||
|
if (m.id === "worker-spec") {
|
||||||
|
return "落档/开玩程序处理:有变量真值时固定开旁观填表,创作期一般不 insert";
|
||||||
|
}
|
||||||
return "落档/开玩程序处理;创作期一般不 insert";
|
return "落档/开玩程序处理;创作期一般不 insert";
|
||||||
}
|
}
|
||||||
if (m.id === "variable-design") {
|
if (m.id === "variable-design") {
|
||||||
return "declare_variable / declare_map;长文规则可另 insert 「设计.变量设计与更新规则」";
|
return "insert 「设计.变量设计与更新规则」(有真值时程序固定旁观副 LLM 填表,并同步目录与真表);或用 declare_variable / declare_map 逐条钉目录";
|
||||||
}
|
}
|
||||||
if (m.id === "opening-setup") {
|
if (m.id === "opening-setup") {
|
||||||
return "insert 「设计.开场白#场景短码」——可增殖:每条开场各占一 tag;同完整 tag 才覆盖。游玩时左右切换,选中的一条作为 0 层";
|
return "insert 「设计.开场白#场景短码」——可增殖:每条开场各占一 tag;同完整 tag 才覆盖。游玩时左右切换,选中的一条作为 0 层";
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* 有变量真值时固定旁观副 LLM 填表(auditor_bundle)。
|
||||||
|
* 旁观上下文刻意保持小:上一份表 + 创作期维护准则 + 本轮主世界正文;
|
||||||
|
* 不再按维护复杂度.建议在内联 / 旁观间切换,避免主世界 tool / state_ops 反复探测。
|
||||||
|
* 变量设计里的维护复杂度可作旁观备注,不驱动开槽。
|
||||||
|
*/
|
||||||
|
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||||
|
import { extractJsonObjectText } from "./worker-set-parse.js";
|
||||||
|
import type { PlaySlotsConfig } from "./play-slots.js";
|
||||||
|
import { VARIABLE_DESIGN_TAG } from "./variable-design-compile.js";
|
||||||
|
|
||||||
|
export const MAINTENANCE_STRATEGY_SCHEMA = "maintenance-strategy.v1" as const;
|
||||||
|
export const MAINTENANCE_STRATEGY_TAG = "设计.维护策略";
|
||||||
|
|
||||||
|
export type MaintenanceAdvice =
|
||||||
|
| "none"
|
||||||
|
| "inline_tool"
|
||||||
|
| "inline_structured"
|
||||||
|
| "auditor_bundle";
|
||||||
|
|
||||||
|
export type MaintenanceStrategyDoc = {
|
||||||
|
schema: typeof MAINTENANCE_STRATEGY_SCHEMA;
|
||||||
|
advice: MaintenanceAdvice;
|
||||||
|
/** 是否启用旁观维护槽 */
|
||||||
|
auditor: boolean;
|
||||||
|
/** 是否走主世界内联维护(已弃用开槽;固定旁观后恒为 false) */
|
||||||
|
inline: boolean;
|
||||||
|
source: string;
|
||||||
|
依据?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function asString(v: unknown): string | undefined {
|
||||||
|
if (typeof v !== "string") return undefined;
|
||||||
|
const t = v.trim();
|
||||||
|
return t || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObject(v: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
|
||||||
|
return v as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMaintenanceAdvice(raw: unknown): MaintenanceAdvice {
|
||||||
|
const s = asString(raw)?.toLowerCase() ?? "";
|
||||||
|
if (
|
||||||
|
s === "auditor_bundle" ||
|
||||||
|
s === "auditor" ||
|
||||||
|
s === "旁观" ||
|
||||||
|
s === "旁观维护" ||
|
||||||
|
s === "bundle"
|
||||||
|
) {
|
||||||
|
return "auditor_bundle";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
s === "inline_structured" ||
|
||||||
|
s === "structured" ||
|
||||||
|
s === "内联结构化" ||
|
||||||
|
s === "state_ops"
|
||||||
|
) {
|
||||||
|
return "inline_structured";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
s === "inline_tool" ||
|
||||||
|
s === "inline" ||
|
||||||
|
s === "tool" ||
|
||||||
|
s === "内联" ||
|
||||||
|
s === "内联工具"
|
||||||
|
) {
|
||||||
|
return "inline_tool";
|
||||||
|
}
|
||||||
|
if (s === "none" || s === "无" || s === "不维护") return "none";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function designHasTruths(raw: string | null | undefined): boolean {
|
||||||
|
if (!raw?.trim()) return false;
|
||||||
|
const text = extractJsonObjectText(raw) ?? raw.trim();
|
||||||
|
let doc: unknown;
|
||||||
|
try {
|
||||||
|
doc = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const root = asObject(doc);
|
||||||
|
if (!root) return false;
|
||||||
|
const body = asObject(root.正文) ?? root;
|
||||||
|
const truths = body.真值 ?? body.fields ?? body.变量;
|
||||||
|
return Array.isArray(truths) && truths.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析变量设计产物中的维护复杂度(仅作备注;开槽不读此建议) */
|
||||||
|
export function parseMaintenanceAdviceFromVariableDesign(
|
||||||
|
raw: string | null | undefined,
|
||||||
|
): { advice: MaintenanceAdvice; 依据: string[] } | null {
|
||||||
|
if (!raw?.trim()) return null;
|
||||||
|
const text = extractJsonObjectText(raw) ?? raw.trim();
|
||||||
|
let doc: unknown;
|
||||||
|
try {
|
||||||
|
doc = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const root = asObject(doc);
|
||||||
|
if (!root) return null;
|
||||||
|
const body = asObject(root.正文) ?? root;
|
||||||
|
const box = asObject(body.维护复杂度) ?? asObject(body.maintenance);
|
||||||
|
if (!box) {
|
||||||
|
if (designHasTruths(raw)) {
|
||||||
|
return {
|
||||||
|
advice: "auditor_bundle",
|
||||||
|
依据: ["变量设计有真值;程序固定旁观副 LLM 填表"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { advice: "none", 依据: [] };
|
||||||
|
}
|
||||||
|
const advice = normalizeMaintenanceAdvice(box.建议 ?? box.advice ?? box.strategy);
|
||||||
|
const reasonsRaw = box.依据 ?? box.reasons ?? box.why;
|
||||||
|
const 依据 = Array.isArray(reasonsRaw)
|
||||||
|
? reasonsRaw.map((r) => asString(r)).filter((r): r is string => Boolean(r))
|
||||||
|
: asString(reasonsRaw)
|
||||||
|
? [asString(reasonsRaw)!]
|
||||||
|
: [];
|
||||||
|
return { advice, 依据 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strategyFromAdvice(
|
||||||
|
advice: MaintenanceAdvice,
|
||||||
|
依据: string[] = [],
|
||||||
|
source = VARIABLE_DESIGN_TAG,
|
||||||
|
): MaintenanceStrategyDoc {
|
||||||
|
// 开槽层:仅 auditor_bundle 开旁观;inline_* 在 resolve 阶段会被抬成旁观
|
||||||
|
const auditor = advice === "auditor_bundle";
|
||||||
|
const inline = advice === "inline_tool" || advice === "inline_structured";
|
||||||
|
return {
|
||||||
|
schema: MAINTENANCE_STRATEGY_SCHEMA,
|
||||||
|
advice,
|
||||||
|
auditor,
|
||||||
|
inline,
|
||||||
|
source,
|
||||||
|
...(依据.length ? { 依据 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有真值 → 固定旁观填表;无真值 → 不启旁观。
|
||||||
|
* 设计里的 inline_* 建议不关闭旁观,只记入依据。
|
||||||
|
*/
|
||||||
|
export function resolveMaintenanceStrategyFromBoard(
|
||||||
|
board: Blackboard,
|
||||||
|
): MaintenanceStrategyDoc {
|
||||||
|
const designRaw = board.getContentByTag(VARIABLE_DESIGN_TAG);
|
||||||
|
if (!designHasTruths(designRaw)) {
|
||||||
|
return strategyFromAdvice(
|
||||||
|
"none",
|
||||||
|
["无变量真值,不启旁观填表"],
|
||||||
|
VARIABLE_DESIGN_TAG,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parsed = parseMaintenanceAdviceFromVariableDesign(designRaw);
|
||||||
|
const 依据 = [
|
||||||
|
"程序固定旁观副 LLM 填表(上一份表 + 维护准则 + 本轮主世界正文)",
|
||||||
|
...(parsed?.依据 ?? []),
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
parsed.advice !== "auditor_bundle" &&
|
||||||
|
parsed.advice !== "none"
|
||||||
|
) {
|
||||||
|
依据.push(`设计原建议 ${parsed.advice},已忽略内联开槽`);
|
||||||
|
}
|
||||||
|
return strategyFromAdvice("auditor_bundle", 依据, VARIABLE_DESIGN_TAG);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按维护策略覆盖 play_slots.auditor(不改 gm/narrator 等) */
|
||||||
|
export function applyMaintenanceStrategyToPlaySlots(
|
||||||
|
slots: PlaySlotsConfig,
|
||||||
|
strategy: MaintenanceStrategyDoc,
|
||||||
|
): PlaySlotsConfig {
|
||||||
|
return {
|
||||||
|
...slots,
|
||||||
|
auditor: strategy.auditor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeMaintenanceStrategy(doc: MaintenanceStrategyDoc): string {
|
||||||
|
return JSON.stringify(doc, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeMaintenanceStrategy(
|
||||||
|
board: Blackboard,
|
||||||
|
strategy: MaintenanceStrategyDoc,
|
||||||
|
source = "system:maintenance-strategy",
|
||||||
|
): void {
|
||||||
|
board.write({
|
||||||
|
tag: MAINTENANCE_STRATEGY_TAG,
|
||||||
|
content: serializeMaintenanceStrategy(strategy),
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -347,13 +347,18 @@ export function parseOpeningUserPersona(
|
|||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
try {
|
try {
|
||||||
const doc = JSON.parse(text) as unknown;
|
const doc = JSON.parse(text) as unknown;
|
||||||
|
const row =
|
||||||
|
doc && typeof doc === "object" && !Array.isArray(doc)
|
||||||
|
? (doc as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
const meta =
|
||||||
|
row?.meta && typeof row.meta === "object" && !Array.isArray(row.meta)
|
||||||
|
? (row.meta as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
const fromJson =
|
const fromJson =
|
||||||
parseUserPersonaField(doc) ||
|
parseUserPersonaField(doc) ||
|
||||||
parseUserPersonaField(
|
parseUserPersonaField(row?.用户角色) ||
|
||||||
doc && typeof doc === "object" && !Array.isArray(doc)
|
parseUserPersonaField(meta?.用户角色);
|
||||||
? (doc as Record<string, unknown>).用户角色
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
if (fromJson) return fromJson;
|
if (fromJson) return fromJson;
|
||||||
} catch {
|
} catch {
|
||||||
/* not json */
|
/* not json */
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ export type PlayAuxOpening = {
|
|||||||
/** 产物 tag:设计.开场白 或 设计.开场白#槽位;opening-setup 候选为假想 tag */
|
/** 产物 tag:设计.开场白 或 设计.开场白#槽位;opening-setup 候选为假想 tag */
|
||||||
tag: string;
|
tag: string;
|
||||||
slot: string;
|
slot: string;
|
||||||
|
/** 侧栏预览用:present.v1 已压成可读正文 */
|
||||||
text: string;
|
text: string;
|
||||||
|
/** 选用时落盘 / 灌呈现壳:保留完整 present.v1 或散文原文 */
|
||||||
|
document: string;
|
||||||
persona: OpeningUserPersona | null;
|
persona: OpeningUserPersona | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,34 +70,15 @@ function selectedOpeningIndex(slots: Record<string, unknown> | undefined): numbe
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 黑板上全部可选用开场(工序候选优先;否则对话落盘 设计.开场白[#槽]) */
|
type OpeningProductRow = {
|
||||||
export function collectOpenings(board: Blackboard): PlayAuxOpening[] {
|
tag: string;
|
||||||
const payload = parseOpeningSealPayload(
|
content: string;
|
||||||
board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG),
|
order: number;
|
||||||
0,
|
slot: string;
|
||||||
);
|
};
|
||||||
if (payload?.candidates.length) {
|
|
||||||
return payload.candidates.map((text, index) => {
|
|
||||||
const one = parseOpeningSealPayload(
|
|
||||||
board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG),
|
|
||||||
index,
|
|
||||||
);
|
|
||||||
const tag = `${OPENING_SETUP_ARTIFACT_TAG}#${index + 1}`;
|
|
||||||
return {
|
|
||||||
index,
|
|
||||||
tag,
|
|
||||||
slot: String(index + 1),
|
|
||||||
text: presentReadablePlain(text) || text,
|
|
||||||
persona: one?.persona ?? payload.persona,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows: Array<{
|
function listOpeningProductRows(board: Blackboard): OpeningProductRow[] {
|
||||||
tag: string;
|
const rows: OpeningProductRow[] = [];
|
||||||
content: string;
|
|
||||||
order: number;
|
|
||||||
}> = [];
|
|
||||||
for (const e of board.listTagIndex()) {
|
for (const e of board.listTagIndex()) {
|
||||||
const family = dictateProductFamily(e.tag);
|
const family = dictateProductFamily(e.tag);
|
||||||
if (
|
if (
|
||||||
@@ -111,26 +95,74 @@ export function collectOpenings(board: Blackboard): PlayAuxOpening[] {
|
|||||||
typeof rawOrder === "number" && Number.isFinite(rawOrder)
|
typeof rawOrder === "number" && Number.isFinite(rawOrder)
|
||||||
? rawOrder
|
? rawOrder
|
||||||
: effectiveDictateOrder({ tag: e.tag });
|
: effectiveDictateOrder({ tag: e.tag });
|
||||||
rows.push({ tag: e.tag, content, order });
|
rows.push({
|
||||||
|
tag: e.tag,
|
||||||
|
content,
|
||||||
|
order,
|
||||||
|
slot: dictateProductSlot(e.tag),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
rows.sort((a, b) => {
|
rows.sort((a, b) => {
|
||||||
if (a.order !== b.order) return a.order - b.order;
|
if (a.order !== b.order) return a.order - b.order;
|
||||||
return a.tag.localeCompare(b.tag, "zh");
|
return a.tag.localeCompare(b.tag, "zh");
|
||||||
});
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openingsFromRows(
|
||||||
|
board: Blackboard,
|
||||||
|
rows: OpeningProductRow[],
|
||||||
|
): PlayAuxOpening[] {
|
||||||
return rows.map((row, index) => {
|
return rows.map((row, index) => {
|
||||||
const split = splitOpeningDocument(row.content);
|
const split = splitOpeningDocument(row.content);
|
||||||
const text = split.text.trim() || row.content;
|
const document = split.text.trim() || row.content;
|
||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
tag: row.tag,
|
tag: row.tag,
|
||||||
slot: dictateProductSlot(row.tag),
|
slot: row.slot,
|
||||||
text: presentReadablePlain(text) || text,
|
document,
|
||||||
|
text: presentReadablePlain(document) || document,
|
||||||
persona: personaBoundToOpening(board, row.tag, row.content),
|
persona: personaBoundToOpening(board, row.tag, row.content),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可切换开场。已有 `设计.开场白#场景短码` 时只认这些带槽位的卡,
|
||||||
|
* 裸 `设计.开场白` 与整包 `设计.开场白与开场变量` 不进切换列表。
|
||||||
|
* 还没有槽位卡时,才回退旧整包候选或裸基名。
|
||||||
|
*/
|
||||||
|
export function collectOpenings(board: Blackboard): PlayAuxOpening[] {
|
||||||
|
const rows = listOpeningProductRows(board);
|
||||||
|
const slotted = rows.filter((row) => row.slot);
|
||||||
|
if (slotted.length) return openingsFromRows(board, slotted);
|
||||||
|
|
||||||
|
const payload = parseOpeningSealPayload(
|
||||||
|
board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (payload?.candidates.length) {
|
||||||
|
return payload.candidates.map((text, index) => {
|
||||||
|
const one = parseOpeningSealPayload(
|
||||||
|
board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG),
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
const tag = `${OPENING_SETUP_ARTIFACT_TAG}#${index + 1}`;
|
||||||
|
const document = text.trim();
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
tag,
|
||||||
|
slot: String(index + 1),
|
||||||
|
document,
|
||||||
|
text: presentReadablePlain(document) || document,
|
||||||
|
persona: one?.persona ?? payload.persona,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return openingsFromRows(board, rows);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildPlayAuxView(
|
export function buildPlayAuxView(
|
||||||
board: Blackboard,
|
board: Blackboard,
|
||||||
slots?: Record<string, unknown>,
|
slots?: Record<string, unknown>,
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ export type ShellAdaptation = {
|
|||||||
tweaks: PresentShellTweaks;
|
tweaks: PresentShellTweaks;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PresentBlocks = Partial<
|
export type PresentBlockValue = string | Record<string, unknown> | unknown[];
|
||||||
Record<PresentRegionId, string | Record<string, unknown> | unknown[]>
|
|
||||||
>;
|
/** 已知壳区域以外的键来自本局正文组成数据契约,必须原样保留。 */
|
||||||
|
export type PresentBlocks = Partial<Record<PresentRegionId, PresentBlockValue>> &
|
||||||
|
Record<string, PresentBlockValue | undefined>;
|
||||||
|
|
||||||
export type PresentPacket = {
|
export type PresentPacket = {
|
||||||
schema: typeof PRESENT_SCHEMA;
|
schema: typeof PRESENT_SCHEMA;
|
||||||
@@ -112,25 +114,29 @@ export function normalizePresentShellId(
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asPresentBlockValue(value: unknown): PresentBlockValue | undefined {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
if (value && typeof value === "object") return value as Record<string, unknown>;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeBlocks(raw: unknown): PresentBlocks {
|
function normalizeBlocks(raw: unknown): PresentBlocks {
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||||
const row = raw as Record<string, unknown>;
|
const row = raw as Record<string, unknown>;
|
||||||
const out: PresentBlocks = {};
|
const out: PresentBlocks = {};
|
||||||
const keys: PresentRegionId[] = [
|
for (const [key, value] of Object.entries(row)) {
|
||||||
"monitor",
|
if (key === "正文" || key === "监控") continue;
|
||||||
"header",
|
const block = asPresentBlockValue(value);
|
||||||
"body",
|
if (block !== undefined) out[key] = block;
|
||||||
"footer",
|
}
|
||||||
"aside",
|
if (out.body === undefined && row.正文 !== undefined) {
|
||||||
"hidden",
|
const body = asPresentBlockValue(row.正文);
|
||||||
];
|
if (body !== undefined) out.body = body;
|
||||||
for (const k of keys) {
|
|
||||||
if (row[k] !== undefined) out[k] = row[k] as PresentBlocks[typeof k];
|
|
||||||
}
|
}
|
||||||
// 兼容中文键
|
|
||||||
if (out.body === undefined && row.正文 !== undefined) out.body = row.正文 as string;
|
|
||||||
if (out.monitor === undefined && row.监控 !== undefined) {
|
if (out.monitor === undefined && row.监控 !== undefined) {
|
||||||
out.monitor = row.监控 as PresentBlocks["monitor"];
|
const monitor = asPresentBlockValue(row.监控);
|
||||||
|
if (monitor !== undefined) out.monitor = monitor;
|
||||||
}
|
}
|
||||||
if (typeof out.body === "string") {
|
if (typeof out.body === "string") {
|
||||||
out.body = stripPresentSourceFences(out.body);
|
out.body = stripPresentSourceFences(out.body);
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
/**
|
||||||
|
* 程序投影:把 变量.当前 / 拓扑等权威数据灌进 present.v1 的 program_projection 区。
|
||||||
|
* 模型只填 model_generated;本模块在写盘与视图层补齐投影块。
|
||||||
|
*/
|
||||||
|
import { parseTableDoc } from "../blackboard/table-cells.js";
|
||||||
|
import { tableValueMap } from "../blackboard/table-side-effects.js";
|
||||||
|
import { extractJsonObjectText } from "./worker-set-parse.js";
|
||||||
|
import type { ReplyContractBlock, ReplyDataContract } from "./reply-contract.js";
|
||||||
|
import {
|
||||||
|
parsePresentPacket,
|
||||||
|
stringifyPresentPacket,
|
||||||
|
type PresentBlockValue,
|
||||||
|
type PresentShellId,
|
||||||
|
} from "./present-packet.js";
|
||||||
|
|
||||||
|
export type ProjectionBinding = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
ref?: string;
|
||||||
|
fields?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function asString(v: unknown): string | undefined {
|
||||||
|
if (typeof v !== "string") return undefined;
|
||||||
|
const t = v.trim();
|
||||||
|
return t || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObject(v: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
|
||||||
|
return v as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从回复呈现(设计.监控栏)取出程序投影绑定 */
|
||||||
|
export function parseStatusBarProjectionBindings(
|
||||||
|
raw: string | null | undefined,
|
||||||
|
): ProjectionBinding[] {
|
||||||
|
if (!raw?.trim()) return [];
|
||||||
|
const text = extractJsonObjectText(raw) ?? raw.trim();
|
||||||
|
let doc: unknown;
|
||||||
|
try {
|
||||||
|
doc = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const root = asObject(doc);
|
||||||
|
if (!root) return [];
|
||||||
|
const body = asObject(root.正文) ?? root;
|
||||||
|
const regions = body.语义区域 ?? body.regions ?? body.areas;
|
||||||
|
if (!Array.isArray(regions)) return [];
|
||||||
|
const out: ProjectionBinding[] = [];
|
||||||
|
for (const item of regions) {
|
||||||
|
const row = asObject(item);
|
||||||
|
if (!row) continue;
|
||||||
|
const id = asString(row.id);
|
||||||
|
if (!id) continue;
|
||||||
|
const source = asObject(row.来源) ?? asObject(row.source);
|
||||||
|
const mode =
|
||||||
|
asString(source?.方式) ??
|
||||||
|
asString(source?.mode) ??
|
||||||
|
asString(source?.source) ??
|
||||||
|
asString(row.来源);
|
||||||
|
if (
|
||||||
|
mode &&
|
||||||
|
mode !== "program_projection" &&
|
||||||
|
mode !== "程序投影" &&
|
||||||
|
mode !== "projection"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 无方式时:若标了 ref 也当作可投影
|
||||||
|
const ref =
|
||||||
|
asString(source?.ref) ??
|
||||||
|
asString(source?.tag) ??
|
||||||
|
asString(row.ref) ??
|
||||||
|
asString(row.权威ref);
|
||||||
|
const fieldsRaw = source?.字段 ?? source?.fields ?? row.字段 ?? row.fields;
|
||||||
|
const fields = Array.isArray(fieldsRaw)
|
||||||
|
? fieldsRaw
|
||||||
|
.map((f) => asString(f))
|
||||||
|
.filter((f): f is string => Boolean(f))
|
||||||
|
: undefined;
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
type:
|
||||||
|
asString(row.内容类型) ??
|
||||||
|
asString(row.type) ??
|
||||||
|
asString(row.类型) ??
|
||||||
|
"kv",
|
||||||
|
...(ref ? { ref } : {}),
|
||||||
|
...(fields?.length ? { fields } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickValues(
|
||||||
|
all: Map<string, unknown>,
|
||||||
|
fields?: string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
if (fields?.length) {
|
||||||
|
for (const key of fields) {
|
||||||
|
if (all.has(key)) out[key] = all.get(key);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (const [key, value] of all) out[key] = value;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表单元格常把对象/数组存成 JSON 字符串;投影前解开,避免壳只能看到一坨字。 */
|
||||||
|
export function coerceJsonValue(value: unknown): unknown {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const t = value.trim();
|
||||||
|
if (
|
||||||
|
(t.startsWith("{") && t.endsWith("}")) ||
|
||||||
|
(t.startsWith("[") && t.endsWith("]"))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return coerceJsonValue(JSON.parse(t));
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.map(coerceJsonValue);
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
out[key] = coerceJsonValue(child);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceValueMap(
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
out[key] = coerceJsonValue(value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 名字 → 属性对象 的矩阵,压成带 name 列的行数组,方便 table 渲染。 */
|
||||||
|
function matrixMapToRows(
|
||||||
|
map: Record<string, unknown>,
|
||||||
|
nameKey = "name",
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
return Object.entries(map).map(([name, stats]) => {
|
||||||
|
if (isPlainObject(stats)) return { [nameKey]: name, ...stats };
|
||||||
|
return { [nameKey]: name, value: stats };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeTable(values: Record<string, unknown>): PresentBlockValue {
|
||||||
|
const coerced = coerceValueMap(values);
|
||||||
|
const keys = Object.keys(coerced);
|
||||||
|
if (keys.length === 1) {
|
||||||
|
const source = keys[0]!;
|
||||||
|
const only = coerced[source];
|
||||||
|
if (Array.isArray(only)) {
|
||||||
|
return { source, rows: only };
|
||||||
|
}
|
||||||
|
if (isPlainObject(only)) {
|
||||||
|
const entries = Object.entries(only);
|
||||||
|
if (
|
||||||
|
entries.length &&
|
||||||
|
entries.every(([, child]) => isPlainObject(child))
|
||||||
|
) {
|
||||||
|
return { source, rows: matrixMapToRows(only) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return coerced;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeBlock(
|
||||||
|
type: string,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
): PresentBlockValue | undefined {
|
||||||
|
const keys = Object.keys(values);
|
||||||
|
if (!keys.length) return undefined;
|
||||||
|
if (type === "list") {
|
||||||
|
const coerced = coerceValueMap(values);
|
||||||
|
return Object.keys(coerced).map(
|
||||||
|
(key) => `${key}:${typeof coerced[key] === "string" ? coerced[key] : JSON.stringify(coerced[key])}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === "text" || type === "markdown" || type === "safe_markdown") {
|
||||||
|
const coerced = coerceValueMap(values);
|
||||||
|
return Object.keys(coerced)
|
||||||
|
.map((key) => {
|
||||||
|
const cell = coerced[key];
|
||||||
|
const text =
|
||||||
|
typeof cell === "string" ? cell : JSON.stringify(cell, null, 2);
|
||||||
|
return `【${key}】${text}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
if (type === "table") {
|
||||||
|
return shapeTable(values);
|
||||||
|
}
|
||||||
|
// kv / topology / 默认:解开 JSON 字符串后的对象
|
||||||
|
return coerceValueMap(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTagContent(
|
||||||
|
readTag: ((tag: string) => string | null | undefined) | undefined,
|
||||||
|
tag: string,
|
||||||
|
fallbacks: Record<string, string | null | undefined>,
|
||||||
|
): string | null | undefined {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(fallbacks, tag)) {
|
||||||
|
return fallbacks[tag];
|
||||||
|
}
|
||||||
|
return readTag?.(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function valuesFromRef(params: {
|
||||||
|
ref: string;
|
||||||
|
fields?: string[];
|
||||||
|
readTag?: (tag: string) => string | null | undefined;
|
||||||
|
currentVarsRaw?: string | null;
|
||||||
|
topologyRaw?: string | null;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
const ref = params.ref.trim();
|
||||||
|
const fallbacks: Record<string, string | null | undefined> = {
|
||||||
|
"变量.当前": params.currentVarsRaw,
|
||||||
|
"运行.初始变量": params.currentVarsRaw,
|
||||||
|
"运行.拓扑": params.topologyRaw,
|
||||||
|
};
|
||||||
|
const raw = readTagContent(params.readTag, ref, fallbacks);
|
||||||
|
if (ref === "变量.当前" || ref === "运行.初始变量") {
|
||||||
|
const map = tableValueMap(parseTableDoc(raw));
|
||||||
|
return pickValues(map, params.fields);
|
||||||
|
}
|
||||||
|
if (!raw?.trim()) return {};
|
||||||
|
// 表文档
|
||||||
|
const table = parseTableDoc(raw);
|
||||||
|
if (table?.rows.length) {
|
||||||
|
return pickValues(tableValueMap(table), params.fields);
|
||||||
|
}
|
||||||
|
// JSON 对象(拓扑等)
|
||||||
|
try {
|
||||||
|
const text = extractJsonObjectText(raw) ?? raw.trim();
|
||||||
|
const parsed = JSON.parse(text) as unknown;
|
||||||
|
const obj = asObject(parsed);
|
||||||
|
if (obj) {
|
||||||
|
if (params.fields?.length) {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of params.fields) {
|
||||||
|
if (key in obj) out[key] = obj[key];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* plain */
|
||||||
|
}
|
||||||
|
if (params.fields?.length === 1) {
|
||||||
|
return { [params.fields[0]!]: raw.trim() };
|
||||||
|
}
|
||||||
|
return { value: raw.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按正文组成契约 + 回复呈现绑定,从黑板投影 program_projection 块。
|
||||||
|
*/
|
||||||
|
export function projectPresentBlocks(params: {
|
||||||
|
contract: ReplyDataContract;
|
||||||
|
statusBarRaw?: string | null;
|
||||||
|
currentVarsRaw?: string | null;
|
||||||
|
topologyRaw?: string | null;
|
||||||
|
readTag?: (tag: string) => string | null | undefined;
|
||||||
|
}): Record<string, PresentBlockValue> {
|
||||||
|
const bindings = parseStatusBarProjectionBindings(params.statusBarRaw);
|
||||||
|
const byId = new Map(bindings.map((b) => [b.id, b]));
|
||||||
|
const currentMap = tableValueMap(parseTableDoc(params.currentVarsRaw));
|
||||||
|
const out: Record<string, PresentBlockValue> = {};
|
||||||
|
|
||||||
|
for (const block of params.contract.programBlocks) {
|
||||||
|
const bound = byId.get(block.id);
|
||||||
|
const type = bound?.type || block.type || "kv";
|
||||||
|
let values: Record<string, unknown>;
|
||||||
|
if (bound?.ref) {
|
||||||
|
values = valuesFromRef({
|
||||||
|
ref: bound.ref,
|
||||||
|
fields: bound.fields,
|
||||||
|
readTag: params.readTag,
|
||||||
|
currentVarsRaw: params.currentVarsRaw,
|
||||||
|
topologyRaw: params.topologyRaw,
|
||||||
|
});
|
||||||
|
} else if (type === "topology") {
|
||||||
|
values = valuesFromRef({
|
||||||
|
ref: "运行.拓扑",
|
||||||
|
fields: bound?.fields,
|
||||||
|
readTag: params.readTag,
|
||||||
|
currentVarsRaw: params.currentVarsRaw,
|
||||||
|
topologyRaw: params.topologyRaw,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
values = pickValues(currentMap, bound?.fields);
|
||||||
|
}
|
||||||
|
const shaped = shapeBlock(type, values);
|
||||||
|
if (shaped !== undefined) out[block.id] = shaped;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅用契约(无监控栏)时的便捷入口 */
|
||||||
|
export function projectPresentBlocksFromContract(
|
||||||
|
contract: ReplyDataContract,
|
||||||
|
currentVarsRaw: string | null | undefined,
|
||||||
|
): Record<string, PresentBlockValue> {
|
||||||
|
return projectPresentBlocks({ contract, currentVarsRaw });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergePresentWithProjections(
|
||||||
|
content: string,
|
||||||
|
projections: Record<string, PresentBlockValue>,
|
||||||
|
fallbackShell: PresentShellId = "prose",
|
||||||
|
): string {
|
||||||
|
if (!Object.keys(projections).length) return content;
|
||||||
|
const view = parsePresentPacket(content, fallbackShell);
|
||||||
|
const blocks = { ...view.packet.blocks, ...projections };
|
||||||
|
return stringifyPresentPacket({
|
||||||
|
...view.packet,
|
||||||
|
blocks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectionForBlocks(
|
||||||
|
blocks: ReplyContractBlock[],
|
||||||
|
currentVarsRaw: string | null | undefined,
|
||||||
|
): Record<string, PresentBlockValue> {
|
||||||
|
if (!blocks.length) return {};
|
||||||
|
const contract: ReplyDataContract = {
|
||||||
|
modelBlocks: [],
|
||||||
|
programBlocks: blocks,
|
||||||
|
};
|
||||||
|
return projectPresentBlocks({ contract, currentVarsRaw });
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* 从「设计.正文组成」取出数据契约。
|
||||||
|
* 开场与之后每一楼只填模型生成区;程序把这份 present.v1 灌进已固定的 frontend。
|
||||||
|
*/
|
||||||
|
import { extractJsonObjectText } from "./worker-set-parse.js";
|
||||||
|
|
||||||
|
export type ReplyContractBlock = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
source: "model_generated" | "program_projection";
|
||||||
|
role?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplyFrontend = {
|
||||||
|
html: string;
|
||||||
|
css: string;
|
||||||
|
js: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplyDataContract = {
|
||||||
|
modelBlocks: ReplyContractBlock[];
|
||||||
|
programBlocks: ReplyContractBlock[];
|
||||||
|
frontend?: ReplyFrontend;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseReplyDataContract(
|
||||||
|
raw: string | undefined | null,
|
||||||
|
): ReplyDataContract | undefined {
|
||||||
|
const doc = parseObject(raw);
|
||||||
|
if (!doc) return undefined;
|
||||||
|
const body = asObject(doc.正文) ?? doc;
|
||||||
|
const contract = asObject(body.数据契约) ?? asObject(body.data_contract);
|
||||||
|
const blocksRaw = asObject(contract?.blocks);
|
||||||
|
if (!blocksRaw) return undefined;
|
||||||
|
const modelBlocks: ReplyContractBlock[] = [];
|
||||||
|
const programBlocks: ReplyContractBlock[] = [];
|
||||||
|
for (const [id, value] of Object.entries(blocksRaw)) {
|
||||||
|
const row = asObject(value);
|
||||||
|
if (!id.trim() || !row) continue;
|
||||||
|
const block: ReplyContractBlock = {
|
||||||
|
id: id.trim(),
|
||||||
|
type: asString(row.类型 ?? row.type) || "text",
|
||||||
|
source:
|
||||||
|
asString(row.来源 ?? row.source) === "program_projection"
|
||||||
|
? "program_projection"
|
||||||
|
: "model_generated",
|
||||||
|
role: asString(row.职责 ?? row.role),
|
||||||
|
};
|
||||||
|
if (block.source === "program_projection") programBlocks.push(block);
|
||||||
|
else modelBlocks.push(block);
|
||||||
|
}
|
||||||
|
if (!modelBlocks.length && !programBlocks.length) return undefined;
|
||||||
|
const frontend = parseFrontend(asObject(body.frontend));
|
||||||
|
return {
|
||||||
|
modelBlocks,
|
||||||
|
programBlocks,
|
||||||
|
...(frontend ? { frontend } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replyContractSummary(contract: ReplyDataContract): {
|
||||||
|
schema: "present.v1";
|
||||||
|
model_blocks: Array<{ id: string; type: string; role?: string }>;
|
||||||
|
program_blocks: string[];
|
||||||
|
rule: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
schema: "present.v1",
|
||||||
|
model_blocks: contract.modelBlocks.map((block) => ({
|
||||||
|
id: block.id,
|
||||||
|
type: block.type,
|
||||||
|
...(block.role ? { role: block.role } : {}),
|
||||||
|
})),
|
||||||
|
program_blocks: contract.programBlocks.map((block) => block.id),
|
||||||
|
rule: "开场和之后每一轮只填写 model_blocks。程序把这份 present.v1 灌进已固定的 frontend,替换示例内容。不要重写 frontend,不要填写 program_blocks。",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开场 JSON 里所有 present.v1 的 blocks。缺模型区时返回错误,供 insert 拒绝。 */
|
||||||
|
export function openingContractError(
|
||||||
|
content: string,
|
||||||
|
contract: ReplyDataContract,
|
||||||
|
): string | undefined {
|
||||||
|
if (!contract.modelBlocks.length) return undefined;
|
||||||
|
const maps = collectPresentBlockMaps(content);
|
||||||
|
const need = formatModelBlocks(contract);
|
||||||
|
if (!maps.length) {
|
||||||
|
return `有正文组成时,开场必须给出 present.v1,并填写模型生成区:${need}。程序投影区不要填写。`;
|
||||||
|
}
|
||||||
|
const missing = new Set<string>();
|
||||||
|
for (const blocks of maps) {
|
||||||
|
for (const block of contract.modelBlocks) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(blocks, block.id)) {
|
||||||
|
missing.add(block.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!missing.size) return undefined;
|
||||||
|
return `开场的 present.v1 未按正文组成填写模型生成区,缺少:${[...missing].join("、")}。需要:${need}。程序投影区不要填写。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function presentSchemaFromContract(contract: ReplyDataContract): {
|
||||||
|
schema: Record<string, unknown>;
|
||||||
|
loose: boolean;
|
||||||
|
} {
|
||||||
|
const properties: Record<string, unknown> = {};
|
||||||
|
const required: string[] = [];
|
||||||
|
let loose = false;
|
||||||
|
for (const block of contract.modelBlocks) {
|
||||||
|
required.push(block.id);
|
||||||
|
const built = schemaForBlock(block);
|
||||||
|
properties[block.id] = built.schema;
|
||||||
|
if (built.loose) loose = true;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
loose,
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["schema", "blocks"],
|
||||||
|
properties: {
|
||||||
|
schema: { type: "string", enum: ["present.v1"] },
|
||||||
|
blocks: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required,
|
||||||
|
properties,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contractOutputInstruction(contract: ReplyDataContract): string {
|
||||||
|
const lines = contract.modelBlocks.map((block) => {
|
||||||
|
const role = block.role ? `:${block.role}` : "";
|
||||||
|
return `- ${block.id}(${typeHint(block.type)})${role}`;
|
||||||
|
});
|
||||||
|
const projected = contract.programBlocks.map((block) => block.id);
|
||||||
|
return `
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 运行时输出协议(按正文组成灌数)
|
||||||
|
|
||||||
|
本局前端已经固定。只输出一个 present.v1 对象,程序把它灌进正文组成,替换示例内容。
|
||||||
|
不要输出 HTML、CSS、JS,不要重写模板,不要再套 shell。
|
||||||
|
|
||||||
|
只填模型生成区:
|
||||||
|
${lines.join("\n")}
|
||||||
|
${projected.length ? `不要填写程序投影区:${projected.join("、")}。\n` : ""}list 用字符串数组,没有内容时给 []。markdown 与 text 用字符串。
|
||||||
|
不要写追问、askUser、自评或「可验收产物」。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatModelBlocks(contract: ReplyDataContract): string {
|
||||||
|
return contract.modelBlocks
|
||||||
|
.map((block) => `${block.id}(${typeHint(block.type)})`)
|
||||||
|
.join("、");
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeHint(type: string): string {
|
||||||
|
if (type === "list") return "字符串数组";
|
||||||
|
if (type === "markdown" || type === "text") return "字符串";
|
||||||
|
if (type === "kv" || type === "table" || type === "topology") return "对象";
|
||||||
|
return type || "字符串";
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaForBlock(block: ReplyContractBlock): {
|
||||||
|
schema: Record<string, unknown>;
|
||||||
|
loose: boolean;
|
||||||
|
} {
|
||||||
|
if (block.type === "list") {
|
||||||
|
return {
|
||||||
|
loose: false,
|
||||||
|
schema: { type: "array", items: { type: "string" } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (block.type === "markdown" || block.type === "text") {
|
||||||
|
return { loose: false, schema: { type: "string" } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
loose: true,
|
||||||
|
schema: { type: "object", additionalProperties: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPresentBlockMaps(content: string): Array<Record<string, unknown>> {
|
||||||
|
const doc = parseObject(content);
|
||||||
|
if (!doc) return [];
|
||||||
|
const maps: Array<Record<string, unknown>> = [];
|
||||||
|
walk(doc, maps, 0);
|
||||||
|
return maps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(node: unknown, maps: Array<Record<string, unknown>>, depth: number): void {
|
||||||
|
if (depth > 8 || node == null) return;
|
||||||
|
if (typeof node === "string") {
|
||||||
|
if (!node.includes("present.v1")) return;
|
||||||
|
const parsed = parseObject(node);
|
||||||
|
if (parsed) walk(parsed, maps, depth + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const item of node) walk(item, maps, depth + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof node !== "object") return;
|
||||||
|
const row = node as Record<string, unknown>;
|
||||||
|
const blocks = asObject(row.blocks);
|
||||||
|
if ((row.schema === "present.v1" || row.shell != null) && blocks) {
|
||||||
|
maps.push(blocks);
|
||||||
|
}
|
||||||
|
for (const value of Object.values(row)) walk(value, maps, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontend(raw: Record<string, unknown> | undefined): ReplyFrontend | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const html = asString(raw.html);
|
||||||
|
if (!html || !/<\w+/.test(html)) return undefined;
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
css: asString(raw.css) ?? "",
|
||||||
|
js: asString(raw.js) ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObject(raw: string | undefined | null): Record<string, unknown> | undefined {
|
||||||
|
const text = raw?.trim() ?? "";
|
||||||
|
if (!text) return undefined;
|
||||||
|
const jsonText = extractJsonObjectText(text) ?? (text.startsWith("{") ? text : null);
|
||||||
|
if (!jsonText) return undefined;
|
||||||
|
try {
|
||||||
|
const doc = JSON.parse(jsonText) as unknown;
|
||||||
|
return asObject(doc);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") return undefined;
|
||||||
|
const text = value.trim();
|
||||||
|
return text || undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* 从「设计.变量设计与更新规则」编译运行目录,并种进 变量.当前。
|
||||||
|
* 创作产物是合同;设计.变量目录 / 变量.当前 是运行表。
|
||||||
|
*/
|
||||||
|
import { extractJsonObjectText } from "./worker-set-parse.js";
|
||||||
|
import {
|
||||||
|
parseVariableCatalog,
|
||||||
|
seedVariablesFromCatalog,
|
||||||
|
serializeVariableCatalog,
|
||||||
|
upsertVariableField,
|
||||||
|
VARIABLE_CATALOG_TAG,
|
||||||
|
type VariableCatalogDoc,
|
||||||
|
type VariableCatalogField,
|
||||||
|
type VariableFieldType,
|
||||||
|
} from "./variable-catalog.js";
|
||||||
|
|
||||||
|
export const VARIABLE_DESIGN_TAG = "设计.变量设计与更新规则";
|
||||||
|
|
||||||
|
function asString(v: unknown): string | undefined {
|
||||||
|
if (typeof v !== "string") return undefined;
|
||||||
|
const t = v.trim();
|
||||||
|
return t || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObject(v: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
|
||||||
|
return v as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseType(raw: unknown): VariableFieldType {
|
||||||
|
const s = asString(raw)?.toLowerCase();
|
||||||
|
if (s === "number" || s === "数字" || s === "integer" || s === "int") {
|
||||||
|
return "number";
|
||||||
|
}
|
||||||
|
if (s === "boolean" || s === "布尔") return "boolean";
|
||||||
|
if (s === "enum" || s === "枚举") return "enum";
|
||||||
|
return "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析变量设计产物里的真值列表 → 目录字段(未写入) */
|
||||||
|
export function catalogFieldsFromVariableDesign(
|
||||||
|
raw: string | null | undefined,
|
||||||
|
): VariableCatalogField[] {
|
||||||
|
if (!raw?.trim()) return [];
|
||||||
|
const text = extractJsonObjectText(raw) ?? raw.trim();
|
||||||
|
let doc: unknown;
|
||||||
|
try {
|
||||||
|
doc = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const root = asObject(doc);
|
||||||
|
if (!root) return [];
|
||||||
|
const body = asObject(root.正文) ?? root;
|
||||||
|
const truths = body.真值 ?? body.fields ?? body.变量;
|
||||||
|
if (!Array.isArray(truths)) return [];
|
||||||
|
|
||||||
|
const fields: VariableCatalogField[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of truths) {
|
||||||
|
const row = asObject(item);
|
||||||
|
if (!row) continue;
|
||||||
|
const key =
|
||||||
|
asString(row.id) ??
|
||||||
|
asString(row.key) ??
|
||||||
|
asString(row.名) ??
|
||||||
|
asString(row.name) ??
|
||||||
|
asString(row.名称);
|
||||||
|
if (!key || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
const type = parseType(row.类型 ?? row.type);
|
||||||
|
const initBox = asObject(row.初始化) ?? asObject(row.initial);
|
||||||
|
const initial =
|
||||||
|
initBox?.默认值 ??
|
||||||
|
initBox?.default ??
|
||||||
|
initBox?.value ??
|
||||||
|
row.initial ??
|
||||||
|
row.初值 ??
|
||||||
|
row.默认值;
|
||||||
|
const publicity = asString(row.公开性) ?? asString(row.visibility);
|
||||||
|
const userVisible =
|
||||||
|
publicity === "hidden" ||
|
||||||
|
publicity === "隐藏" ||
|
||||||
|
row.user_visible === false ||
|
||||||
|
row.对用户可见 === false
|
||||||
|
? false
|
||||||
|
: true;
|
||||||
|
const note =
|
||||||
|
asString(row.名称) ??
|
||||||
|
asString(row.用途) ??
|
||||||
|
asString(row.note) ??
|
||||||
|
asString(row.备注);
|
||||||
|
const { doc: one } = upsertVariableField(null, {
|
||||||
|
key,
|
||||||
|
type,
|
||||||
|
initial,
|
||||||
|
user_visible: userVisible,
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
const field = one.fields[0];
|
||||||
|
if (field) fields.push(field);
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把设计稿真值并进已有目录(不删目录里多出来的 declare 字段) */
|
||||||
|
export function mergeCatalogFromVariableDesign(
|
||||||
|
existing: VariableCatalogDoc | null,
|
||||||
|
designRaw: string | null | undefined,
|
||||||
|
): {
|
||||||
|
doc: VariableCatalogDoc;
|
||||||
|
added: string[];
|
||||||
|
updated: string[];
|
||||||
|
} | null {
|
||||||
|
const fromDesign = catalogFieldsFromVariableDesign(designRaw);
|
||||||
|
if (!fromDesign.length) return null;
|
||||||
|
|
||||||
|
let doc: VariableCatalogDoc = existing ?? {
|
||||||
|
schema: "variable-catalog.v1",
|
||||||
|
fields: [],
|
||||||
|
};
|
||||||
|
const added: string[] = [];
|
||||||
|
const updated: string[] = [];
|
||||||
|
for (const field of fromDesign) {
|
||||||
|
const before = doc.fields.find((f) => f.key === field.key);
|
||||||
|
const { doc: next } = upsertVariableField(doc, {
|
||||||
|
key: field.key,
|
||||||
|
type: field.type,
|
||||||
|
initial: field.initial,
|
||||||
|
user_visible: field.user_visible,
|
||||||
|
user_editable: field.user_editable,
|
||||||
|
note: field.note,
|
||||||
|
});
|
||||||
|
doc = next;
|
||||||
|
if (!before) added.push(field.key);
|
||||||
|
else updated.push(field.key);
|
||||||
|
}
|
||||||
|
return { doc, added, updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncVariableDesignResult = {
|
||||||
|
/** 有变更时返回新目录 JSON */
|
||||||
|
catalog: string | null;
|
||||||
|
current: string | null;
|
||||||
|
initial: string | null;
|
||||||
|
seededKeys: string[];
|
||||||
|
catalogKeys: string[];
|
||||||
|
added: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设计稿 → 目录 → 种/补 变量.当前 与 运行.初始变量。
|
||||||
|
* 已有格不覆盖;目录缺键才追加。
|
||||||
|
*/
|
||||||
|
export function syncVariableDesignToRuntime(params: {
|
||||||
|
designRaw: string | null | undefined;
|
||||||
|
catalogRaw: string | null | undefined;
|
||||||
|
currentRaw: string | null | undefined;
|
||||||
|
initialRaw: string | null | undefined;
|
||||||
|
}): SyncVariableDesignResult {
|
||||||
|
const empty: SyncVariableDesignResult = {
|
||||||
|
catalog: null,
|
||||||
|
current: null,
|
||||||
|
initial: null,
|
||||||
|
seededKeys: [],
|
||||||
|
catalogKeys: [],
|
||||||
|
added: [],
|
||||||
|
};
|
||||||
|
const existing = parseVariableCatalog(params.catalogRaw);
|
||||||
|
const merged = mergeCatalogFromVariableDesign(existing, params.designRaw);
|
||||||
|
if (!merged) return empty;
|
||||||
|
|
||||||
|
const catalogChanged =
|
||||||
|
!existing ||
|
||||||
|
serializeVariableCatalog(existing) !== serializeVariableCatalog(merged.doc);
|
||||||
|
const catalogRaw = catalogChanged
|
||||||
|
? serializeVariableCatalog(merged.doc)
|
||||||
|
: (params.catalogRaw?.trim() ?? serializeVariableCatalog(merged.doc));
|
||||||
|
|
||||||
|
const seeded = seedVariablesFromCatalog({
|
||||||
|
catalogRaw,
|
||||||
|
currentRaw: params.currentRaw,
|
||||||
|
initialRaw: params.initialRaw,
|
||||||
|
actor: "system:variable-design",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
catalog: catalogChanged ? catalogRaw : null,
|
||||||
|
current: seeded.current,
|
||||||
|
initial: seeded.initial,
|
||||||
|
seededKeys: seeded.seededKeys,
|
||||||
|
catalogKeys: merged.doc.fields.map((f) => f.key),
|
||||||
|
added: merged.added,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 sync 结果写回黑板;返回实际写入的 tag */
|
||||||
|
export function writeVariableDesignSync(
|
||||||
|
sync: SyncVariableDesignResult,
|
||||||
|
writeTag: (tag: string, content: string) => void,
|
||||||
|
): string[] {
|
||||||
|
const wrote: string[] = [];
|
||||||
|
if (sync.catalog) {
|
||||||
|
writeTag(VARIABLE_CATALOG_TAG, sync.catalog);
|
||||||
|
wrote.push(VARIABLE_CATALOG_TAG);
|
||||||
|
}
|
||||||
|
if (sync.initial) {
|
||||||
|
writeTag("运行.初始变量", sync.initial);
|
||||||
|
wrote.push("运行.初始变量");
|
||||||
|
}
|
||||||
|
if (sync.current) {
|
||||||
|
writeTag("变量.当前", sync.current);
|
||||||
|
wrote.push("变量.当前");
|
||||||
|
}
|
||||||
|
return wrote;
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ export const DESIGN_STAGE_SKILL_META: Record<
|
|||||||
"opening-generator": {
|
"opening-generator": {
|
||||||
label: "开局 · 开场白",
|
label: "开局 · 开场白",
|
||||||
purpose:
|
purpose:
|
||||||
"创作末尾:优先落库设计.开场白与开场变量;否则现写开场;初值与开场同真相。",
|
"创作末尾:每条开场落库设计.开场白#场景短码;没有变量的卡不写初值。",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
|
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
|
||||||
|
import type { ContextSegment } from "../llm/context-outline.js";
|
||||||
|
|
||||||
export type TokenUsageRecord = {
|
export type TokenUsageRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,7 +18,25 @@ export type TokenUsageRecord = {
|
|||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
cachedTokens?: number;
|
cachedTokens?: number;
|
||||||
cacheMissTokens?: number;
|
cacheMissTokens?: number;
|
||||||
|
cacheWriteTokens?: number;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
|
turnId?: string;
|
||||||
|
step?: string;
|
||||||
|
retryIndex?: number;
|
||||||
|
segmentChars?: {
|
||||||
|
rules: number;
|
||||||
|
history: number;
|
||||||
|
products: number;
|
||||||
|
injected: number;
|
||||||
|
tail: number;
|
||||||
|
};
|
||||||
|
/** 有序标签与哈希,不含正文 */
|
||||||
|
segments?: ContextSegment[];
|
||||||
|
plannedTags?: string[];
|
||||||
|
succeededTags?: string[];
|
||||||
|
failedTags?: string[];
|
||||||
|
finish?: string;
|
||||||
|
latencyMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Token usage attached to a chat message in SessionView */
|
/** Token usage attached to a chat message in SessionView */
|
||||||
@@ -97,7 +116,18 @@ export function recordTokenUsage(
|
|||||||
totalTokens: partial.totalTokens,
|
totalTokens: partial.totalTokens,
|
||||||
cachedTokens: partial.cachedTokens,
|
cachedTokens: partial.cachedTokens,
|
||||||
cacheMissTokens: partial.cacheMissTokens,
|
cacheMissTokens: partial.cacheMissTokens,
|
||||||
|
cacheWriteTokens: partial.cacheWriteTokens,
|
||||||
messageId: partial.messageId,
|
messageId: partial.messageId,
|
||||||
|
turnId: partial.turnId,
|
||||||
|
step: partial.step,
|
||||||
|
retryIndex: partial.retryIndex,
|
||||||
|
segmentChars: partial.segmentChars,
|
||||||
|
segments: partial.segments,
|
||||||
|
plannedTags: partial.plannedTags,
|
||||||
|
succeededTags: partial.succeededTags,
|
||||||
|
failedTags: partial.failedTags,
|
||||||
|
finish: partial.finish,
|
||||||
|
latencyMs: partial.latencyMs,
|
||||||
};
|
};
|
||||||
appendFileSync(dayFile(new Date(record.at)), `${JSON.stringify(record)}\n`, "utf8");
|
appendFileSync(dayFile(new Date(record.at)), `${JSON.stringify(record)}\n`, "utf8");
|
||||||
return record;
|
return record;
|
||||||
@@ -238,6 +268,39 @@ export function getSessionTokenTotals(sessionId: string): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function patchTokenUsage(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<
|
||||||
|
Pick<
|
||||||
|
TokenUsageRecord,
|
||||||
|
"plannedTags" | "succeededTags" | "failedTags" | "finish"
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
): void {
|
||||||
|
if (!id) return;
|
||||||
|
const files = listStatFiles();
|
||||||
|
for (let i = files.length - 1; i >= 0; i--) {
|
||||||
|
const file = files[i]!;
|
||||||
|
const lines = readFileSync(file, "utf8").split(/\r?\n/);
|
||||||
|
let changed = false;
|
||||||
|
const next = lines.map((line) => {
|
||||||
|
if (!line.trim()) return line;
|
||||||
|
try {
|
||||||
|
const record = JSON.parse(line) as TokenUsageRecord;
|
||||||
|
if (record.id !== id) return line;
|
||||||
|
changed = true;
|
||||||
|
return JSON.stringify({ ...record, ...patch });
|
||||||
|
} catch {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) {
|
||||||
|
writeFileSync(file, next.join("\n"), "utf8");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function toMessageTokenUsage(record: TokenUsageRecord): MessageTokenUsage {
|
export function toMessageTokenUsage(record: TokenUsageRecord): MessageTokenUsage {
|
||||||
return {
|
return {
|
||||||
totalTokens: record.totalTokens,
|
totalTokens: record.totalTokens,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { getUserDataDir } from "../config/user-data-dir.js";
|
||||||
|
|
||||||
|
function cacheDir(): string {
|
||||||
|
const dir = path.join(getUserDataDir(), "tts-cache");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ttsCacheKey(parts: {
|
||||||
|
provider: string;
|
||||||
|
voice: string;
|
||||||
|
text: string;
|
||||||
|
extra?: string;
|
||||||
|
}): string {
|
||||||
|
const h = createHash("sha256");
|
||||||
|
h.update(parts.provider);
|
||||||
|
h.update("\0");
|
||||||
|
h.update(parts.voice);
|
||||||
|
h.update("\0");
|
||||||
|
h.update(parts.extra ?? "");
|
||||||
|
h.update("\0");
|
||||||
|
h.update(parts.text);
|
||||||
|
return h.digest("hex").slice(0, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTtsCache(key: string, ext: string): Buffer | null {
|
||||||
|
const file = path.join(cacheDir(), `${key}.${ext}`);
|
||||||
|
if (!existsSync(file)) return null;
|
||||||
|
try {
|
||||||
|
return readFileSync(file);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeTtsCache(key: string, ext: string, data: Buffer): void {
|
||||||
|
const file = path.join(cacheDir(), `${key}.${ext}`);
|
||||||
|
writeFileSync(file, data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { MsEdgeTTS, OUTPUT_FORMAT } from "msedge-tts";
|
||||||
|
import { readTtsCache, ttsCacheKey, writeTtsCache } from "./cache.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_EDGE_VOICE,
|
||||||
|
type TtsSynthesizeParams,
|
||||||
|
type TtsSynthesizeResult,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
/** Edge SSML 单段不宜过长;按句切到约此字数 */
|
||||||
|
const CHUNK_TARGET = 1200;
|
||||||
|
|
||||||
|
function escapeXml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVoice(explicit?: string): string {
|
||||||
|
const fromEnv = process.env.WRITING_AGENT_TTS_VOICE?.trim();
|
||||||
|
return (explicit?.trim() || fromEnv || DEFAULT_EDGE_VOICE).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitChunks(text: string): string[] {
|
||||||
|
const cleaned = text.replace(/\r\n/g, "\n").trim();
|
||||||
|
if (!cleaned) return [];
|
||||||
|
if (cleaned.length <= CHUNK_TARGET) return [cleaned];
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
let buf = "";
|
||||||
|
const pushBuf = () => {
|
||||||
|
const t = buf.trim();
|
||||||
|
if (t) parts.push(t);
|
||||||
|
buf = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const sentences = cleaned.split(/(?<=[。!?!?;;\n])/u);
|
||||||
|
for (const s of sentences) {
|
||||||
|
if (!s) continue;
|
||||||
|
if (buf.length + s.length > CHUNK_TARGET && buf.length > 0) {
|
||||||
|
pushBuf();
|
||||||
|
}
|
||||||
|
buf += s;
|
||||||
|
if (buf.length >= CHUNK_TARGET) pushBuf();
|
||||||
|
}
|
||||||
|
pushBuf();
|
||||||
|
return parts.length ? parts : [cleaned.slice(0, CHUNK_TARGET)];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamToBuffer(
|
||||||
|
audioStream: NodeJS.ReadableStream,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of audioStream) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function synthesizeChunk(voice: string, text: string): Promise<Buffer> {
|
||||||
|
const tts = new MsEdgeTTS();
|
||||||
|
await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3);
|
||||||
|
const { audioStream } = tts.toStream(escapeXml(text));
|
||||||
|
return streamToBuffer(audioStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge 在线神经 TTS(微软朗读通道)。文本会发往微软服务端。
|
||||||
|
*/
|
||||||
|
export async function synthesizeWithEdge(
|
||||||
|
params: TtsSynthesizeParams,
|
||||||
|
): Promise<TtsSynthesizeResult> {
|
||||||
|
const text = params.text.trim();
|
||||||
|
if (!text) throw new Error("TTS 文本为空");
|
||||||
|
const voice = resolveVoice(params.voice);
|
||||||
|
const key = ttsCacheKey({
|
||||||
|
provider: "edge",
|
||||||
|
voice,
|
||||||
|
text,
|
||||||
|
extra: params.cacheKey,
|
||||||
|
});
|
||||||
|
const hit = readTtsCache(key, "mp3");
|
||||||
|
if (hit) {
|
||||||
|
return {
|
||||||
|
audio: hit,
|
||||||
|
contentType: "audio/mpeg",
|
||||||
|
provider: "edge",
|
||||||
|
voice,
|
||||||
|
cached: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = splitChunks(text);
|
||||||
|
const buffers: Buffer[] = [];
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
buffers.push(await synthesizeChunk(voice, chunk));
|
||||||
|
}
|
||||||
|
const audio = Buffer.concat(buffers);
|
||||||
|
writeTtsCache(key, "mp3", audio);
|
||||||
|
return {
|
||||||
|
audio,
|
||||||
|
contentType: "audio/mpeg",
|
||||||
|
provider: "edge",
|
||||||
|
voice,
|
||||||
|
cached: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { synthesizeWithEdge } from "./edge.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_TTS_PROVIDER,
|
||||||
|
type TtsProviderId,
|
||||||
|
type TtsSynthesizeParams,
|
||||||
|
type TtsSynthesizeResult,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DEFAULT_EDGE_VOICE,
|
||||||
|
DEFAULT_TTS_PROVIDER,
|
||||||
|
type TtsProviderId,
|
||||||
|
type TtsSynthesizeParams,
|
||||||
|
type TtsSynthesizeResult,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export function resolveTtsProvider(): TtsProviderId {
|
||||||
|
const raw = (process.env.WRITING_AGENT_TTS_PROVIDER ?? DEFAULT_TTS_PROVIDER)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (raw === "local" || raw === "tencent" || raw === "edge") return raw;
|
||||||
|
return DEFAULT_TTS_PROVIDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一合成入口。当前仅实现 edge;local / tencent 占位。
|
||||||
|
*/
|
||||||
|
export async function synthesizeSpeech(
|
||||||
|
params: TtsSynthesizeParams,
|
||||||
|
): Promise<TtsSynthesizeResult> {
|
||||||
|
const provider = resolveTtsProvider();
|
||||||
|
if (provider === "edge") {
|
||||||
|
return synthesizeWithEdge(params);
|
||||||
|
}
|
||||||
|
if (provider === "local") {
|
||||||
|
throw new Error("本地 TTS 尚未接入;请将 WRITING_AGENT_TTS_PROVIDER=edge");
|
||||||
|
}
|
||||||
|
if (provider === "tencent") {
|
||||||
|
throw new Error("腾讯云 TTS 尚未接入;请将 WRITING_AGENT_TTS_PROVIDER=edge");
|
||||||
|
}
|
||||||
|
return synthesizeWithEdge(params);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/** TTS 提供方(默认 edge;本地/腾讯后接) */
|
||||||
|
|
||||||
|
export type TtsProviderId = "edge" | "local" | "tencent";
|
||||||
|
|
||||||
|
export type TtsSynthesizeParams = {
|
||||||
|
text: string;
|
||||||
|
/** 缓存键额外盐(如 sectionId),便于按节失效 */
|
||||||
|
cacheKey?: string;
|
||||||
|
voice?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TtsSynthesizeResult = {
|
||||||
|
audio: Buffer;
|
||||||
|
contentType: string;
|
||||||
|
provider: TtsProviderId;
|
||||||
|
voice: string;
|
||||||
|
cached: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_TTS_PROVIDER: TtsProviderId = "edge";
|
||||||
|
/** 听书默认中文声线 */
|
||||||
|
export const DEFAULT_EDGE_VOICE = "zh-CN-XiaoxiaoNeural";
|
||||||
@@ -39,7 +39,22 @@ export type PersistedChatMessage = {
|
|||||||
messages: Array<{ role: string; content: string }>;
|
messages: Array<{ role: string; content: string }>;
|
||||||
charCount: number;
|
charCount: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
segments?: Array<{ tag: string; chars: number; hash: string }>;
|
||||||
};
|
};
|
||||||
|
/** 各次请求的标签轮廓,不含正文 */
|
||||||
|
requestOutlines?: Array<{
|
||||||
|
at: string;
|
||||||
|
caller: string;
|
||||||
|
step?: string;
|
||||||
|
turnId?: string;
|
||||||
|
model?: string;
|
||||||
|
promptTokens?: number;
|
||||||
|
completionTokens?: number;
|
||||||
|
cachedTokens?: number;
|
||||||
|
cacheMissTokens?: number;
|
||||||
|
cacheWriteTokens?: number;
|
||||||
|
segments: Array<{ tag: string; chars: number; hash: string }>;
|
||||||
|
}>;
|
||||||
branchGroupId?: string;
|
branchGroupId?: string;
|
||||||
branchIndex?: number;
|
branchIndex?: number;
|
||||||
branchTotal?: number;
|
branchTotal?: number;
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
/** 进料/创作方式:一书一方式,目录按此分夹 */
|
||||||
|
export type BookCreationMode = "recipe" | "dictate" | "listen_serial";
|
||||||
|
|
||||||
export type BookProject = {
|
export type BookProject = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
/** 用户选定或 agent 确认的 skill 包 */
|
/** 用户选定或 agent 确认的 skill 包 */
|
||||||
activeSkillId?: string;
|
activeSkillId?: string;
|
||||||
activeSkillName?: string;
|
activeSkillName?: string;
|
||||||
|
/** 新建时选定;缺省视为 dictate(旧书) */
|
||||||
|
creationMode?: BookCreationMode;
|
||||||
preview: string;
|
preview: string;
|
||||||
sessionIds: string[];
|
sessionIds: string[];
|
||||||
/** 最近一次持久化的 sessionId,用于续作 */
|
/** 最近一次持久化的 sessionId,用于续作 */
|
||||||
@@ -22,6 +27,7 @@ export type BookSummary = Pick<
|
|||||||
| "title"
|
| "title"
|
||||||
| "activeSkillId"
|
| "activeSkillId"
|
||||||
| "activeSkillName"
|
| "activeSkillName"
|
||||||
|
| "creationMode"
|
||||||
| "preview"
|
| "preview"
|
||||||
| "updatedAt"
|
| "updatedAt"
|
||||||
| "orchestratorId"
|
| "orchestratorId"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ContextSegment } from "../llm/context-outline.js";
|
||||||
import type { GenerationParameters } from "./preset.js";
|
import type { GenerationParameters } from "./preset.js";
|
||||||
|
|
||||||
/** 一次 LLM 调用实际发送的上下文(全量,供右键观察) */
|
/** 一次 LLM 调用实际发送的上下文(全量,供右键观察) */
|
||||||
@@ -11,6 +12,8 @@ export type LlmContextTrace = {
|
|||||||
caller: string;
|
caller: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
messages: LlmContextMessage[];
|
messages: LlmContextMessage[];
|
||||||
|
/** 有序标签,供查看排列和缓存断点;不含正文 */
|
||||||
|
segments?: ContextSegment[];
|
||||||
/** 便于列表展示 */
|
/** 便于列表展示 */
|
||||||
charCount: number;
|
charCount: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -57,6 +60,7 @@ export function buildContextTrace(params: {
|
|||||||
}>;
|
}>;
|
||||||
model?: string;
|
model?: string;
|
||||||
generation?: GenerationParameters;
|
generation?: GenerationParameters;
|
||||||
|
segments?: ContextSegment[];
|
||||||
}): LlmContextTrace {
|
}): LlmContextTrace {
|
||||||
const messages = snapshotRequestMessages(params.messages);
|
const messages = snapshotRequestMessages(params.messages);
|
||||||
const charCount = messages.reduce((n, m) => n + m.content.length, 0);
|
const charCount = messages.reduce((n, m) => n + m.content.length, 0);
|
||||||
@@ -64,6 +68,7 @@ export function buildContextTrace(params: {
|
|||||||
caller: params.caller,
|
caller: params.caller,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
messages,
|
messages,
|
||||||
|
...(params.segments?.length ? { segments: params.segments } : {}),
|
||||||
charCount,
|
charCount,
|
||||||
model: params.model,
|
model: params.model,
|
||||||
...(params.generation && Object.keys(params.generation).length
|
...(params.generation && Object.keys(params.generation).length
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ import {
|
|||||||
} from "../parse/json-doc.js";
|
} from "../parse/json-doc.js";
|
||||||
import { assembleWorkerContext } from "../skills/context-segments.js";
|
import { assembleWorkerContext } from "../skills/context-segments.js";
|
||||||
import { isPlayLayerActive } from "../skills/play-turn.js";
|
import { isPlayLayerActive } from "../skills/play-turn.js";
|
||||||
|
import { isNarratorEnabled } from "../skills/play-slots.js";
|
||||||
|
import {
|
||||||
|
contractOutputInstruction,
|
||||||
|
parseReplyDataContract,
|
||||||
|
presentSchemaFromContract,
|
||||||
|
} from "../skills/reply-contract.js";
|
||||||
import {
|
import {
|
||||||
PRESENT_TAG,
|
PRESENT_TAG,
|
||||||
PRESENT_JSON_SCHEMA,
|
PRESENT_JSON_SCHEMA,
|
||||||
@@ -829,21 +835,33 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
|||||||
isPlayLayerActive(params.slots) && isPlayGmBodyWorker(worker.id);
|
isPlayLayerActive(params.slots) && isPlayGmBodyWorker(worker.id);
|
||||||
const playPresent =
|
const playPresent =
|
||||||
isPlayLayerActive(params.slots) && isPlayPresentWorker(worker.id);
|
isPlayLayerActive(params.slots) && isPlayPresentWorker(worker.id);
|
||||||
|
const replyFormatRaw =
|
||||||
|
params.blackboard.getContentByTag("设计.正文组成") ??
|
||||||
|
params.blackboard.getContentByTag("设计.0层正文组成") ??
|
||||||
|
params.blackboard.getContentByTag("设计.回复格式");
|
||||||
|
const replyContract = parseReplyDataContract(replyFormatRaw);
|
||||||
|
const narratorOn = isNarratorEnabled(playSlots);
|
||||||
|
const fillByContract =
|
||||||
|
Boolean(replyContract?.frontend && replyContract.modelBlocks.length) &&
|
||||||
|
(playPresent || (playGmBody && !narratorOn));
|
||||||
|
const contractSchema =
|
||||||
|
fillByContract && replyContract
|
||||||
|
? presentSchemaFromContract(replyContract)
|
||||||
|
: undefined;
|
||||||
const playShell: PresentShellId | undefined =
|
const playShell: PresentShellId | undefined =
|
||||||
playPresent || playGmBody
|
playPresent || playGmBody
|
||||||
? parseShellAdaptationFromReplyFormat(
|
? parseShellAdaptationFromReplyFormat(replyFormatRaw)?.shell_id ?? "prose"
|
||||||
params.blackboard.getContentByTag("设计.正文组成") ??
|
|
||||||
params.blackboard.getContentByTag("设计.回复格式"),
|
|
||||||
)?.shell_id ?? "prose"
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const systemContent =
|
const systemContent =
|
||||||
promptBody +
|
promptBody +
|
||||||
(playGmBody
|
(contractSchema && replyContract && !useGmHarness
|
||||||
? playGmBodyOutputInstruction()
|
? contractOutputInstruction(replyContract)
|
||||||
: playPresent && playShell
|
: playGmBody
|
||||||
? playPresentOutputInstruction(playShell)
|
? playGmBodyOutputInstruction()
|
||||||
: WORKER_OUTPUT_INSTRUCTION) +
|
: playPresent && playShell
|
||||||
|
? playPresentOutputInstruction(playShell)
|
||||||
|
: WORKER_OUTPUT_INSTRUCTION) +
|
||||||
(useGmHarness ? GM_CHANCE_HARNESS_INSTRUCTION : "");
|
(useGmHarness ? GM_CHANCE_HARNESS_INSTRUCTION : "");
|
||||||
|
|
||||||
// 任务 messages 即可;preset 夹心由 wrapLlmForSession / PresetLlmProvider 统一装配
|
// 任务 messages 即可;preset 夹心由 wrapLlmForSession / PresetLlmProvider 统一装配
|
||||||
@@ -888,7 +906,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (playGmBody) {
|
if (playGmBody && !contractSchema) {
|
||||||
const completeOpts = {
|
const completeOpts = {
|
||||||
caller: `worker:${worker.id}`,
|
caller: `worker:${worker.id}`,
|
||||||
label: worker.name?.trim() || worker.id,
|
label: worker.name?.trim() || worker.id,
|
||||||
@@ -905,12 +923,19 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await completeStructured(params.llm, messages, {
|
const result = await completeStructured(params.llm, messages, {
|
||||||
schema: playPresent ? PRESENT_JSON_SCHEMA : {
|
schema: contractSchema
|
||||||
type: "object",
|
? contractSchema.schema
|
||||||
additionalProperties: true,
|
: playPresent
|
||||||
},
|
? PRESENT_JSON_SCHEMA
|
||||||
name: playPresent ? "submit_present_packet" : "submit_worker_result",
|
: {
|
||||||
schemaIsLoose: !playPresent,
|
type: "object",
|
||||||
|
additionalProperties: true,
|
||||||
|
},
|
||||||
|
name:
|
||||||
|
contractSchema || playPresent
|
||||||
|
? "submit_present_packet"
|
||||||
|
: "submit_worker_result",
|
||||||
|
schemaIsLoose: contractSchema ? contractSchema.loose : !playPresent,
|
||||||
capabilities: resolveActiveProfile()?.capabilities,
|
capabilities: resolveActiveProfile()?.capabilities,
|
||||||
caller: `worker:${worker.id}`,
|
caller: `worker:${worker.id}`,
|
||||||
label: worker.name?.trim() || worker.id,
|
label: worker.name?.trim() || worker.id,
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
annotateCacheBreaks,
|
||||||
|
firstCacheBreak,
|
||||||
|
outlineRequest,
|
||||||
|
} from "../src/llm/context-outline.js";
|
||||||
|
|
||||||
|
const sample = [
|
||||||
|
"规则开头",
|
||||||
|
"",
|
||||||
|
"## 写入规则",
|
||||||
|
"固定规则",
|
||||||
|
"",
|
||||||
|
"## 更早的对话",
|
||||||
|
"",
|
||||||
|
"【用户】",
|
||||||
|
"第一句",
|
||||||
|
"",
|
||||||
|
"【助手】",
|
||||||
|
"回过",
|
||||||
|
"",
|
||||||
|
"## 当前产物(已写入黑板)",
|
||||||
|
"",
|
||||||
|
"### 设计.氛围 〔order=0〕",
|
||||||
|
"雨",
|
||||||
|
"",
|
||||||
|
"### 设计.角色 〔order=1〕",
|
||||||
|
"甲",
|
||||||
|
"",
|
||||||
|
"## 本轮注入",
|
||||||
|
"",
|
||||||
|
"### topology @ 3",
|
||||||
|
"#### task",
|
||||||
|
"做一件事",
|
||||||
|
"",
|
||||||
|
"## 本轮",
|
||||||
|
"",
|
||||||
|
"方案进度",
|
||||||
|
"",
|
||||||
|
"最后一轮用户输入",
|
||||||
|
"再改一下",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("outlineRequest", () => {
|
||||||
|
it("按标题、对话轮次和最后一轮输入切开,不留下正文", () => {
|
||||||
|
const segments = outlineRequest([
|
||||||
|
{ role: "system", content: sample.slice(0, sample.indexOf("## 更早的对话")) },
|
||||||
|
{ role: "user", content: sample.slice(sample.indexOf("## 更早的对话")) },
|
||||||
|
]);
|
||||||
|
expect(segments.map((item) => item.tag)).toEqual([
|
||||||
|
"规则",
|
||||||
|
"写入规则",
|
||||||
|
"更早的对话",
|
||||||
|
"更早·用户",
|
||||||
|
"更早·助手",
|
||||||
|
"当前产物",
|
||||||
|
"设计.氛围",
|
||||||
|
"设计.角色",
|
||||||
|
"本轮注入",
|
||||||
|
"topology",
|
||||||
|
"task",
|
||||||
|
"本轮",
|
||||||
|
"最后一轮用户输入",
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(segments)).not.toContain("再改一下");
|
||||||
|
expect(JSON.stringify(segments)).not.toContain("雨");
|
||||||
|
const again = outlineRequest([
|
||||||
|
{ role: "system", content: sample.slice(0, sample.indexOf("## 更早的对话")) },
|
||||||
|
{ role: "user", content: sample.slice(sample.indexOf("## 更早的对话")) },
|
||||||
|
]);
|
||||||
|
expect(again.map((item) => item.hash)).toEqual(segments.map((item) => item.hash));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("工具定义放在正文之前", () => {
|
||||||
|
const segments = outlineRequest(
|
||||||
|
[{ role: "user", content: "你好" }],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: { name: "insert", description: "写", parameters: {} },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(segments.map((item) => item.tag)).toEqual(["工具", "用户"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("firstCacheBreak", () => {
|
||||||
|
it("标出第一处标签或哈希不同的位置", () => {
|
||||||
|
const prev = outlineRequest([{ role: "user", content: sample.slice(sample.indexOf("## 更早的对话")) }]);
|
||||||
|
const changed = sample.replace("雨", "雪");
|
||||||
|
const next = outlineRequest([{ role: "user", content: changed.slice(changed.indexOf("## 更早的对话")) }]);
|
||||||
|
const index = firstCacheBreak(prev, next);
|
||||||
|
expect(next[index]?.tag).toBe("设计.氛围");
|
||||||
|
expect(firstCacheBreak(prev, prev)).toBe(-1);
|
||||||
|
expect(firstCacheBreak(prev, prev.slice(0, 3))).toBe(-1);
|
||||||
|
expect(firstCacheBreak(prev.slice(0, 3), prev)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("annotateCacheBreaks", () => {
|
||||||
|
it("按同会话时间比较,窗口里第一条算冷启动", () => {
|
||||||
|
const a = outlineRequest([{ role: "user", content: "第一" }]);
|
||||||
|
const b = outlineRequest([{ role: "user", content: "第二" }]);
|
||||||
|
const marks = annotateCacheBreaks([
|
||||||
|
{ id: "2", at: "2026-10-02T02:00:00.000Z", sessionId: "s", segments: b },
|
||||||
|
{ id: "1", at: "2026-10-02T01:00:00.000Z", sessionId: "s", segments: a },
|
||||||
|
{ id: "9", at: "2026-10-02T03:00:00.000Z", sessionId: "other", segments: a },
|
||||||
|
]);
|
||||||
|
expect(marks.get("1")).toMatchObject({ cold: true, matched: 0 });
|
||||||
|
expect(marks.get("2")).toMatchObject({ cold: false, breakAt: 0, tag: "用户", matched: 0 });
|
||||||
|
expect(marks.get("9")?.cold).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1230,8 +1230,12 @@ modules:
|
|||||||
expect(sections.blocks.task).toContain("默认只生成 1 条");
|
expect(sections.blocks.task).toContain("默认只生成 1 条");
|
||||||
expect(sections.blocks.task).toContain("@玩家");
|
expect(sections.blocks.task).toContain("@玩家");
|
||||||
expect(sections.blocks.task).toContain("随机");
|
expect(sections.blocks.task).toContain("随机");
|
||||||
expect(sections.blocks.output).toContain('"默认候选id"');
|
expect(module?.artifact).toBe("设计.开场白");
|
||||||
expect(sections.blocks.output).toContain('"开场白候选"');
|
expect(sections.blocks.task).toContain("设计.开场白#opening-cycle2");
|
||||||
|
expect(sections.blocks.task).not.toContain("写入 `设计.开场白与开场变量`");
|
||||||
|
expect(sections.blocks.output).toContain("设计.开场白#场景短码");
|
||||||
|
expect(sections.blocks.output).toContain('"开场变量"');
|
||||||
|
expect(sections.blocks.output).not.toContain('"开场白候选"');
|
||||||
expect(sections.blocks.output).not.toContain('"开场白全文"');
|
expect(sections.blocks.output).not.toContain('"开场白全文"');
|
||||||
expect(sections.blocks.output).toContain("present.v1");
|
expect(sections.blocks.output).toContain("present.v1");
|
||||||
expect(sections.blocks.output).not.toContain('"自评"');
|
expect(sections.blocks.output).not.toContain('"自评"');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
CREATION_PLAN_TAG,
|
CREATION_PLAN_TAG,
|
||||||
buildDictateSystemPrompt,
|
buildDictateSystemPrompt,
|
||||||
|
formatDictateRoundTail,
|
||||||
creationPlanModules,
|
creationPlanModules,
|
||||||
formatCreationPlanCapabilityCatalog,
|
formatCreationPlanCapabilityCatalog,
|
||||||
formatCreationPlanProgress,
|
formatCreationPlanProgress,
|
||||||
@@ -9,7 +10,9 @@ import {
|
|||||||
validateCreationPlan,
|
validateCreationPlan,
|
||||||
type CreationPlan,
|
type CreationPlan,
|
||||||
} from "../src/dictate/index.js";
|
} from "../src/dictate/index.js";
|
||||||
|
import { moduleBrowseGroup } from "../src/dictate/creation-plan.js";
|
||||||
import {
|
import {
|
||||||
|
catalogModulesForIntake,
|
||||||
formatModuleCatalogForDictate,
|
formatModuleCatalogForDictate,
|
||||||
loadModuleCatalog,
|
loadModuleCatalog,
|
||||||
} from "../src/skills/creation-flow.js";
|
} from "../src/skills/creation-flow.js";
|
||||||
@@ -65,6 +68,32 @@ describe("creation plan", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("groups dictate nodes by runtime role and omits the recipe-only merge", async () => {
|
||||||
|
const catalog = await loadModuleCatalog("dialogue/world-simulator");
|
||||||
|
expect(catalog).toBeTruthy();
|
||||||
|
const dictate = catalogModulesForIntake(catalog!, "dictate");
|
||||||
|
const ids = dictate.map((module) => module.id);
|
||||||
|
expect(ids).not.toContain("narrative");
|
||||||
|
expect(moduleBrowseGroup(dictate.find((module) => module.id === "creation-plan")!)).toBe(
|
||||||
|
"plan",
|
||||||
|
);
|
||||||
|
expect(moduleBrowseGroup(dictate.find((module) => module.id === "narrative-guide")!)).toBe(
|
||||||
|
"content",
|
||||||
|
);
|
||||||
|
expect(moduleBrowseGroup(dictate.find((module) => module.id === "reply-format")!)).toBe(
|
||||||
|
"present",
|
||||||
|
);
|
||||||
|
expect(moduleBrowseGroup(dictate.find((module) => module.id === "zero-layer-status")!)).toBe(
|
||||||
|
"present",
|
||||||
|
);
|
||||||
|
expect(moduleBrowseGroup(dictate.find((module) => module.id === "refine")!)).toBe(
|
||||||
|
"program",
|
||||||
|
);
|
||||||
|
expect(new Set(dictate.map((module) => moduleBrowseGroup(module)))).toEqual(
|
||||||
|
new Set(["plan", "content", "present", "program"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("selects exactly one presentation chain", async () => {
|
it("selects exactly one presentation chain", async () => {
|
||||||
const catalog = await loadModuleCatalog("dialogue/world-simulator");
|
const catalog = await loadModuleCatalog("dialogue/world-simulator");
|
||||||
expect(catalog).toBeTruthy();
|
expect(catalog).toBeTruthy();
|
||||||
@@ -160,17 +189,19 @@ describe("creation plan", () => {
|
|||||||
expect(unusedSection).not.toContain("何时用:");
|
expect(unusedSection).not.toContain("何时用:");
|
||||||
|
|
||||||
const oldFullGuide = formatModuleCatalogForDictate(catalog);
|
const oldFullGuide = formatModuleCatalogForDictate(catalog);
|
||||||
const before = buildDictateSystemPrompt({
|
const fixed = buildDictateSystemPrompt();
|
||||||
|
const before = formatDictateRoundTail({
|
||||||
capabilityCatalog: shortCatalog,
|
capabilityCatalog: shortCatalog,
|
||||||
recipeExamples: "- text-generator:文本生成案例",
|
recipeExamples: "- text-generator:文本生成案例",
|
||||||
});
|
});
|
||||||
const after = buildDictateSystemPrompt({ creationPlanState: progress });
|
const after = formatDictateRoundTail({ creationPlanState: progress });
|
||||||
|
expect(fixed).not.toContain("当前决策");
|
||||||
expect(before).toContain("尚无本局创作方案");
|
expect(before).toContain("尚无本局创作方案");
|
||||||
expect(after).toContain("当前决策");
|
expect(after).toContain("当前决策");
|
||||||
expect(after).toContain("收口前的核对清单");
|
expect(after).toContain("收口前的核对清单");
|
||||||
expect(after).not.toContain("旧配方案例目录");
|
expect(after).not.toContain("旧配方案例目录");
|
||||||
expect(after.length).toBeLessThan(
|
expect(after.length).toBeLessThan(
|
||||||
buildDictateSystemPrompt({ moduleGuide: oldFullGuide }).length,
|
formatDictateRoundTail({ moduleGuide: oldFullGuide }).length,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||||
import {
|
import {
|
||||||
|
applyDictatePlayBind,
|
||||||
buildDictateContextOrder,
|
buildDictateContextOrder,
|
||||||
bindDictateProductsToPlaySpec,
|
bindDictateProductsToPlaySpec,
|
||||||
collectDictateBindProducts,
|
collectDictateBindProducts,
|
||||||
|
DICTATE_PLAY_BIND_INPUT_META,
|
||||||
isDictateStyleBindTag,
|
isDictateStyleBindTag,
|
||||||
} from "../src/dictate/play-bind.js";
|
} from "../src/dictate/play-bind.js";
|
||||||
import { DICTATE_ORDER_META_KEY } from "../src/dictate/types.js";
|
import { DICTATE_ORDER_META_KEY } from "../src/dictate/types.js";
|
||||||
@@ -215,4 +217,51 @@ describe("dictate play-bind", () => {
|
|||||||
expect(refs).toContain("上下文.角色态度");
|
expect(refs).toContain("上下文.角色态度");
|
||||||
expect(refs).not.toContain("设计.变量映射");
|
expect(refs).not.toContain("设计.变量映射");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips rebind on save when mounted products are unchanged", () => {
|
||||||
|
const board = new Blackboard();
|
||||||
|
board.write({ tag: "用户.需求", content: "雨夜", source: "test" });
|
||||||
|
const first = applyDictatePlayBind(board);
|
||||||
|
expect(first.skipped).toBe(false);
|
||||||
|
const orderUpdated = board.getLatestByTag("设计.上下文投影排序")?.updatedAt;
|
||||||
|
const workerUpdated = board.getLatestByTag("设计.worker集")?.updatedAt;
|
||||||
|
expect(board.getLatestByTag("设计.上下文投影排序")?.metadata?.[DICTATE_PLAY_BIND_INPUT_META]).toEqual(
|
||||||
|
board.getLatestByTag("设计.worker集")?.metadata?.[DICTATE_PLAY_BIND_INPUT_META],
|
||||||
|
);
|
||||||
|
|
||||||
|
const second = applyDictatePlayBind(board);
|
||||||
|
expect(second.skipped).toBe(true);
|
||||||
|
expect(second.changed).toBe(false);
|
||||||
|
expect(board.getLatestByTag("设计.上下文投影排序")?.updatedAt).toBe(orderUpdated);
|
||||||
|
expect(board.getLatestByTag("设计.worker集")?.updatedAt).toBe(workerUpdated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a hand-edited order until a mounted product changes", () => {
|
||||||
|
const board = new Blackboard();
|
||||||
|
board.write({ tag: "用户.需求", content: "雨夜", source: "test" });
|
||||||
|
applyDictatePlayBind(board);
|
||||||
|
|
||||||
|
const orderItem = board.getLatestByTag("设计.上下文投影排序")!;
|
||||||
|
const doc = JSON.parse(orderItem.content) as { brief?: string };
|
||||||
|
doc.brief = "手改排序";
|
||||||
|
const handEdited = JSON.stringify(doc, null, 2);
|
||||||
|
board.write({
|
||||||
|
tag: "设计.上下文投影排序",
|
||||||
|
content: handEdited,
|
||||||
|
source: "user",
|
||||||
|
metadata: orderItem.metadata,
|
||||||
|
});
|
||||||
|
const kept = applyDictatePlayBind(board);
|
||||||
|
expect(kept.skipped).toBe(true);
|
||||||
|
expect(board.getContentByTag("设计.上下文投影排序")).toBe(handEdited);
|
||||||
|
|
||||||
|
board.write({ tag: "设计.叙事指南", content: "短句", source: "test" });
|
||||||
|
const rebound = applyDictatePlayBind(board);
|
||||||
|
expect(rebound.skipped).toBe(false);
|
||||||
|
expect(rebound.boundTags).toContain("设计.叙事指南");
|
||||||
|
const rewritten = JSON.parse(
|
||||||
|
board.getContentByTag("设计.上下文投影排序") ?? "{}",
|
||||||
|
) as { brief?: string };
|
||||||
|
expect(rewritten.brief).not.toBe("手改排序");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ describe("dictate read_module", () => {
|
|||||||
content: "{}",
|
content: "{}",
|
||||||
self_score: {
|
self_score: {
|
||||||
dims: [
|
dims: [
|
||||||
{ name: "核心体验", score: 5 },
|
{ name: "美学纲领", score: 5 },
|
||||||
|
{ name: "交互范式", score: 5 },
|
||||||
{ name: "背景与规则", score: 5 },
|
{ name: "背景与规则", score: 5 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -82,7 +83,8 @@ describe("dictate read_module", () => {
|
|||||||
content: "{}",
|
content: "{}",
|
||||||
self_score: {
|
self_score: {
|
||||||
dims: [
|
dims: [
|
||||||
{ name: "核心体验", score: 5 },
|
{ name: "美学纲领", score: 5 },
|
||||||
|
{ name: "交互范式", score: 5 },
|
||||||
{ name: "背景与规则", score: 3, gap: "规则因果仍需确认" },
|
{ name: "背景与规则", score: 3, gap: "规则因果仍需确认" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -205,7 +207,8 @@ describe("dictate read_module", () => {
|
|||||||
content: "{}",
|
content: "{}",
|
||||||
self_score: {
|
self_score: {
|
||||||
dims: [
|
dims: [
|
||||||
{ name: "核心体验", score: 3 },
|
{ name: "美学纲领", score: 3 },
|
||||||
|
{ name: "交互范式", score: 5 },
|
||||||
{ name: "背景与规则", score: 5 },
|
{ name: "背景与规则", score: 5 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -226,7 +229,8 @@ describe("dictate read_module", () => {
|
|||||||
content: "{}",
|
content: "{}",
|
||||||
self_score: {
|
self_score: {
|
||||||
dims: [
|
dims: [
|
||||||
{ name: "核心体验", score: 3, gap: "发生方式仍偏宽" },
|
{ name: "美学纲领", score: 3, gap: "发生方式仍偏宽" },
|
||||||
|
{ name: "交互范式", score: 5 },
|
||||||
{ name: "背景与规则", score: 5 },
|
{ name: "背景与规则", score: 5 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -254,13 +258,114 @@ describe("dictate read_module", () => {
|
|||||||
layer: "final",
|
layer: "final",
|
||||||
selfScore: {
|
selfScore: {
|
||||||
dims: expect.arrayContaining([
|
dims: expect.arrayContaining([
|
||||||
expect.objectContaining({ name: "核心体验", score: 3 }),
|
expect.objectContaining({ name: "美学纲领", score: 3 }),
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("opening uses the reply contract", () => {
|
||||||
|
const contract = JSON.stringify({
|
||||||
|
schema: "context-fragment.v1",
|
||||||
|
正文: {
|
||||||
|
数据契约: {
|
||||||
|
blocks: {
|
||||||
|
body: { 类型: "markdown", 来源: "model_generated" },
|
||||||
|
forum_feed: { 类型: "list", 来源: "model_generated" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
frontend: { html: `<div id="app"></div>`, css: "", js: "" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const scores = {
|
||||||
|
dims: [
|
||||||
|
{ name: "开场必要", score: 5 },
|
||||||
|
{ name: "前端符合", score: 5 },
|
||||||
|
{ name: "状态自洽", score: 5 },
|
||||||
|
{ name: "角色占位", score: 5 },
|
||||||
|
{ name: "收口可切换", score: 5 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("returns the live contract when the opening module is read", async () => {
|
||||||
|
const { catalog, moduleReadIndex } = await setup();
|
||||||
|
const tags = new Map<string, string>([["设计.正文组成", contract]]);
|
||||||
|
const driver: AgentDriver = {
|
||||||
|
async run(input) {
|
||||||
|
const outcome = await input.handleStep([
|
||||||
|
{
|
||||||
|
id: "r1",
|
||||||
|
name: "read_module",
|
||||||
|
arguments: JSON.stringify({ module_id: "opening-setup" }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
if (outcome.kind === "continue") {
|
||||||
|
const payload = JSON.parse(outcome.results[0]!.content);
|
||||||
|
expect(payload.reply_contract.model_blocks.map((block: { id: string }) => block.id)).toEqual([
|
||||||
|
"body",
|
||||||
|
"forum_feed",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return { iterations: 1, stop: { kind: "text", content: "已读" } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await runDictateTurn({
|
||||||
|
llm: {} as never,
|
||||||
|
dialogue: [],
|
||||||
|
moduleCatalog: catalog,
|
||||||
|
moduleReadIndex,
|
||||||
|
driver,
|
||||||
|
handlers: handlers(tags),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an opening that only fills the old body shell", async () => {
|
||||||
|
const { catalog, moduleReadIndex } = await setup();
|
||||||
|
const tags = new Map<string, string>([["设计.正文组成", contract]]);
|
||||||
|
const driver: AgentDriver = {
|
||||||
|
async run(input) {
|
||||||
|
await input.handleStep([
|
||||||
|
{
|
||||||
|
id: "r1",
|
||||||
|
name: "read_module",
|
||||||
|
arguments: JSON.stringify({ module_id: "opening-setup" }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const outcome = await input.handleStep([
|
||||||
|
{
|
||||||
|
id: "i1",
|
||||||
|
name: "insert",
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
position: "设计.开场白",
|
||||||
|
content: JSON.stringify({
|
||||||
|
schema: "present.v1",
|
||||||
|
shell: "prose",
|
||||||
|
blocks: { body: "@玩家推开门。" },
|
||||||
|
}),
|
||||||
|
self_score: scores,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
if (outcome.kind === "continue") {
|
||||||
|
const payload = JSON.parse(outcome.results[0]!.content);
|
||||||
|
expect(payload.error).toContain("forum_feed");
|
||||||
|
}
|
||||||
|
return { iterations: 1, stop: { kind: "text", content: "未写入" } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await runDictateTurn({
|
||||||
|
llm: {} as never,
|
||||||
|
dialogue: [],
|
||||||
|
moduleCatalog: catalog,
|
||||||
|
moduleReadIndex,
|
||||||
|
driver,
|
||||||
|
handlers: handlers(tags),
|
||||||
|
});
|
||||||
|
expect(result.wroteTags).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function handlers(tags: Map<string, string>) {
|
function handlers(tags: Map<string, string>) {
|
||||||
return {
|
return {
|
||||||
listProducts: () =>
|
listProducts: () =>
|
||||||
|
|||||||
@@ -0,0 +1,869 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DICTATE_MODULE_READ_STATE_TAG,
|
||||||
|
buildDictateContext,
|
||||||
|
parseDictateModuleReadState,
|
||||||
|
runDictateTurn,
|
||||||
|
type DictateModuleReadPayload,
|
||||||
|
} from "../src/dictate/index.js";
|
||||||
|
import type { LlmProvider } from "../src/llm/client.js";
|
||||||
|
import type { ModuleCatalog } from "../src/skills/creation-flow.js";
|
||||||
|
|
||||||
|
function scriptedLlm(steps: unknown[]): LlmProvider {
|
||||||
|
let index = 0;
|
||||||
|
return {
|
||||||
|
async complete() {
|
||||||
|
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||||
|
},
|
||||||
|
async completeWithTools() {
|
||||||
|
const body = steps[index++] ?? {};
|
||||||
|
const select = index === 1;
|
||||||
|
return {
|
||||||
|
content: null,
|
||||||
|
toolCalls: toToolCalls(body, select),
|
||||||
|
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toToolCalls(body: unknown, select: boolean) {
|
||||||
|
const row = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||||||
|
if (select) {
|
||||||
|
return [{ id: "select", name: "select_nodes", arguments: JSON.stringify(row) }];
|
||||||
|
}
|
||||||
|
const calls: Array<{ id: string; name: string; arguments: string }> = [];
|
||||||
|
const products = row.products;
|
||||||
|
if (Array.isArray(products)) {
|
||||||
|
products.forEach((item, index) => {
|
||||||
|
const rec = item && typeof item === "object" ? (item as Record<string, unknown>) : {};
|
||||||
|
if ("tag" in rec || "position" in rec || "content" in rec || "正文" in rec) {
|
||||||
|
calls.push({
|
||||||
|
id: `ins-${index}`,
|
||||||
|
name: "insert",
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
position: rec.tag ?? rec.position,
|
||||||
|
content: rec.content ?? rec.正文 ?? "",
|
||||||
|
...(rec.self_score !== undefined ? { self_score: rec.self_score } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.entries(rec).forEach(([key, value], piece) => {
|
||||||
|
const content =
|
||||||
|
typeof value === "string"
|
||||||
|
? value
|
||||||
|
: value && typeof value === "object"
|
||||||
|
? String((value as Record<string, unknown>).内容 ?? (value as Record<string, unknown>).正文 ?? (value as Record<string, unknown>).content ?? "")
|
||||||
|
: "";
|
||||||
|
calls.push({
|
||||||
|
id: `ins-${index}-${piece}`,
|
||||||
|
name: "insert",
|
||||||
|
arguments: JSON.stringify({ position: key, content }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (products && typeof products === "object") {
|
||||||
|
Object.entries(products as Record<string, unknown>).forEach(([key, value], index) => {
|
||||||
|
const content =
|
||||||
|
typeof value === "string"
|
||||||
|
? value
|
||||||
|
: value && typeof value === "object"
|
||||||
|
? String((value as Record<string, unknown>).正文 ?? (value as Record<string, unknown>).content ?? "")
|
||||||
|
: "";
|
||||||
|
calls.push({
|
||||||
|
id: `ins-${index}`,
|
||||||
|
name: "insert",
|
||||||
|
arguments: JSON.stringify({ position: key.startsWith("设计.") || key.startsWith("用户.") ? key : `设计.${key}`, content }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(row)) {
|
||||||
|
if (key === "products" || key === "reply" || key === "operations") continue;
|
||||||
|
if (typeof value === "string" && (key.startsWith("设计.") || key.startsWith("用户."))) {
|
||||||
|
calls.push({
|
||||||
|
id: `extra-${key}`,
|
||||||
|
name: "insert",
|
||||||
|
arguments: JSON.stringify({ position: key, content: value }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(row.operations)) {
|
||||||
|
row.operations.forEach((item, index) => {
|
||||||
|
const rec = item && typeof item === "object" ? (item as Record<string, unknown>) : {};
|
||||||
|
const nested =
|
||||||
|
rec.arguments && typeof rec.arguments === "object"
|
||||||
|
? (rec.arguments as Record<string, unknown>)
|
||||||
|
: rec;
|
||||||
|
const content = String(nested.content ?? "");
|
||||||
|
if (!content.trim()) return;
|
||||||
|
calls.push({
|
||||||
|
id: `op-${index}`,
|
||||||
|
name: "insert",
|
||||||
|
arguments: JSON.stringify({ position: nested.position ?? nested.tag, content }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof row.reply === "string") {
|
||||||
|
calls.push({
|
||||||
|
id: "reply",
|
||||||
|
name: "reply_user",
|
||||||
|
arguments: JSON.stringify({ text: row.reply }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = {
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
id: "mood",
|
||||||
|
name: "氛围",
|
||||||
|
artifact: "设计.氛围",
|
||||||
|
declaration: "细雨",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as ModuleCatalog;
|
||||||
|
|
||||||
|
const modulePayload: DictateModuleReadPayload = {
|
||||||
|
module_id: "mood",
|
||||||
|
name: "氛围",
|
||||||
|
artifact: "设计.氛围",
|
||||||
|
version: "v-test",
|
||||||
|
task: "写氛围",
|
||||||
|
principles: "短句",
|
||||||
|
output: "一段话",
|
||||||
|
score: "",
|
||||||
|
score_dimensions: [],
|
||||||
|
libraries: [],
|
||||||
|
constraints: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("dictate planned round", () => {
|
||||||
|
it("puts earlier dialogue before products and the last user turn at the end", () => {
|
||||||
|
const built = buildDictateContext({
|
||||||
|
systemPrompt: "规则",
|
||||||
|
products: [{ tag: "设计.氛围", content: "旧稿", order: 0 }],
|
||||||
|
dialogue: [
|
||||||
|
{ role: "user", text: "第一句" },
|
||||||
|
{ role: "assistant", text: "记下了" },
|
||||||
|
{ role: "user", text: "改成细雨" },
|
||||||
|
],
|
||||||
|
phase: "fill",
|
||||||
|
injected: "#### task\n写氛围",
|
||||||
|
tail: "方案进度",
|
||||||
|
});
|
||||||
|
const body = built.messages[1]?.content ?? "";
|
||||||
|
expect(body.indexOf("必须调用 reply_user")).toBeLessThan(body.indexOf("第一句"));
|
||||||
|
expect(body.indexOf("第一句")).toBeLessThan(body.indexOf("设计.氛围"));
|
||||||
|
expect(body.indexOf("设计.氛围")).toBeLessThan(body.indexOf("写氛围"));
|
||||||
|
expect(body.indexOf("写氛围")).toBeLessThan(body.indexOf("改成细雨"));
|
||||||
|
expect(body.indexOf("方案进度")).toBeLessThan(body.indexOf("改成细雨"));
|
||||||
|
expect(body.indexOf("写完下列节点后")).toBeLessThan(body.indexOf("写氛围"));
|
||||||
|
expect(built.segmentChars.products).toBeGreaterThan(0);
|
||||||
|
expect(built.segmentChars.rules).toBe("规则".length);
|
||||||
|
|
||||||
|
const select = buildDictateContext({
|
||||||
|
systemPrompt: "规则",
|
||||||
|
products: [{ tag: "设计.氛围", content: "旧稿" }],
|
||||||
|
dialogue: [
|
||||||
|
{ role: "user", text: "第一句" },
|
||||||
|
{ role: "user", text: "改成细雨" },
|
||||||
|
],
|
||||||
|
phase: "select",
|
||||||
|
tail: "方案进度",
|
||||||
|
});
|
||||||
|
const selectBody = select.messages[1]?.content ?? "";
|
||||||
|
expect(selectBody).not.toContain("旧稿");
|
||||||
|
expect(selectBody).toContain("第一句");
|
||||||
|
expect(selectBody.indexOf("第一句")).toBeLessThan(selectBody.indexOf("改成细雨"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects the spec, stamps the read credential, and rewrites only the missing tag", async () => {
|
||||||
|
const products: Array<{ tag: string; content: string }> = [];
|
||||||
|
const tags = new Map<string, string>();
|
||||||
|
const result = await runDictateTurn({
|
||||||
|
llm: scriptedLlm([
|
||||||
|
{
|
||||||
|
clarify: [],
|
||||||
|
inserts: [{ tag: "设计.氛围", module_id: "mood" }],
|
||||||
|
deletes: [],
|
||||||
|
},
|
||||||
|
{ products: [], reply: "还没写" },
|
||||||
|
{
|
||||||
|
products: [{ tag: "设计.氛围", content: "细雨" }],
|
||||||
|
reply: "已写成细雨",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||||
|
moduleCatalog: catalog,
|
||||||
|
moduleReadIndex: new Map([
|
||||||
|
["mood", modulePayload],
|
||||||
|
["氛围", modulePayload],
|
||||||
|
]),
|
||||||
|
handlers: {
|
||||||
|
listProducts: () => products,
|
||||||
|
writeProduct: (tag, content) => {
|
||||||
|
const existing = products.find((item) => item.tag === tag);
|
||||||
|
if (existing) existing.content = content;
|
||||||
|
else products.push({ tag, content });
|
||||||
|
},
|
||||||
|
deleteProduct: () => false,
|
||||||
|
readTag: (tag) => tags.get(tag),
|
||||||
|
writeTag: (tag, content) => {
|
||||||
|
tags.set(tag, content);
|
||||||
|
},
|
||||||
|
deleteTag: () => false,
|
||||||
|
clearDialogue: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.wroteTags).toEqual(["设计.氛围"]);
|
||||||
|
expect(products).toEqual([{ tag: "设计.氛围", content: "细雨" }]);
|
||||||
|
expect(result.reply).toContain("已写成细雨");
|
||||||
|
const state = parseDictateModuleReadState(tags.get(DICTATE_MODULE_READ_STATE_TAG));
|
||||||
|
expect(state.modules.mood).toBe("v-test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops after the select step when the plan is a clarification", async () => {
|
||||||
|
let writes = 0;
|
||||||
|
const result = await runDictateTurn({
|
||||||
|
llm: scriptedLlm([
|
||||||
|
{
|
||||||
|
clarify: ["沿用冷色", "改成暖色"],
|
||||||
|
inserts: [],
|
||||||
|
deletes: [],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
dialogue: [{ role: "user", text: "氛围还没定" }],
|
||||||
|
handlers: {
|
||||||
|
listProducts: () => [],
|
||||||
|
writeProduct: () => {
|
||||||
|
writes += 1;
|
||||||
|
},
|
||||||
|
deleteProduct: () => false,
|
||||||
|
readTag: () => undefined,
|
||||||
|
writeTag: () => undefined,
|
||||||
|
deleteTag: () => false,
|
||||||
|
clearDialogue: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(writes).toBe(0);
|
||||||
|
expect(result.iterations).toBe(1);
|
||||||
|
expect(result.reply).toContain("沿用冷色");
|
||||||
|
expect(result.reply).toContain("改成暖色");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the returned fields when the select plan writes nothing", async () => {
|
||||||
|
const result = await runDictateTurn({
|
||||||
|
llm: scriptedLlm([{ note: "看世界" }]),
|
||||||
|
dialogue: [{ role: "user", text: "超级英雄" }],
|
||||||
|
handlers: {
|
||||||
|
listProducts: () => [],
|
||||||
|
writeProduct: () => undefined,
|
||||||
|
deleteProduct: () => false,
|
||||||
|
readTag: () => undefined,
|
||||||
|
writeTag: () => undefined,
|
||||||
|
deleteTag: () => false,
|
||||||
|
clearDialogue: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.wroteTags).toEqual([]);
|
||||||
|
expect(result.iterations).toBe(1);
|
||||||
|
expect(result.reply).toContain("note");
|
||||||
|
expect(result.reply).toContain("没有写入产物");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows a select result with a fill request that carries specs and products", async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
schema: "creation-plan.v1",
|
||||||
|
items: [{ module_id: "mood", tier: "必须", why: "细雨", missing: "", instances: [] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
products: [{ tag: "设计.本局创作方案", content: "{\"schema\":\"creation-plan.v1\"}" }],
|
||||||
|
reply: "已写方案",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let index = 0;
|
||||||
|
const llm: LlmProvider = {
|
||||||
|
async complete() {
|
||||||
|
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||||
|
},
|
||||||
|
async completeWithTools(messages) {
|
||||||
|
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||||
|
const body = steps[index++] ?? {};
|
||||||
|
return {
|
||||||
|
content: null,
|
||||||
|
toolCalls: toToolCalls(body, index === 1),
|
||||||
|
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const products = [{ tag: "设计.舞台", content: "已有舞台" }];
|
||||||
|
await runDictateTurn({
|
||||||
|
llm,
|
||||||
|
dialogue: [{ role: "user", text: "超级英雄的世界" }],
|
||||||
|
moduleCatalog: catalog,
|
||||||
|
moduleReadIndex: new Map([
|
||||||
|
[
|
||||||
|
"creation-plan",
|
||||||
|
{
|
||||||
|
...modulePayload,
|
||||||
|
module_id: "creation-plan",
|
||||||
|
artifact: "设计.本局创作方案",
|
||||||
|
task: "规格任务正文",
|
||||||
|
principles: "规格原则正文",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
handlers: {
|
||||||
|
listProducts: () => products,
|
||||||
|
writeProduct: (tag, content) => {
|
||||||
|
const existing = products.find((item) => item.tag === tag);
|
||||||
|
if (existing) existing.content = content;
|
||||||
|
else products.push({ tag, content });
|
||||||
|
},
|
||||||
|
deleteProduct: () => false,
|
||||||
|
readTag: () => undefined,
|
||||||
|
writeTag: () => undefined,
|
||||||
|
deleteTag: () => false,
|
||||||
|
clearDialogue: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seen.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(seen[0]).not.toContain("已有舞台");
|
||||||
|
expect(seen[0]).not.toContain("规格任务正文");
|
||||||
|
expect(seen[1]).toContain("已有舞台");
|
||||||
|
expect(seen[1]).toContain("规格任务正文");
|
||||||
|
expect(seen[1]).toContain("规格原则正文");
|
||||||
|
expect(seen[1]).toContain("#### output");
|
||||||
|
expect(seen[1]).toContain("设计.本局创作方案");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats operations as the selection, injects the node table, and writes every returned item", async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
operations: [
|
||||||
|
{ name: "insert", arguments: { position: "设计.氛围", module_id: "mood" } },
|
||||||
|
],
|
||||||
|
reply: "先写氛围",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operations: [
|
||||||
|
{ position: "设计.氛围", content: "细雨" },
|
||||||
|
{ tag: "设计.旁白", content: "旁白一句" },
|
||||||
|
],
|
||||||
|
reply: "写了两份",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let index = 0;
|
||||||
|
const llm: LlmProvider = {
|
||||||
|
async complete() {
|
||||||
|
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||||
|
},
|
||||||
|
async completeWithTools(messages) {
|
||||||
|
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||||
|
const body = steps[index++] ?? {};
|
||||||
|
return {
|
||||||
|
content: null,
|
||||||
|
toolCalls: toToolCalls(body, index === 1),
|
||||||
|
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const products: Array<{ tag: string; content: string }> = [
|
||||||
|
{ tag: "设计.舞台", content: "已有舞台" },
|
||||||
|
];
|
||||||
|
const tags = new Map<string, string>();
|
||||||
|
const result = await runDictateTurn({
|
||||||
|
llm,
|
||||||
|
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||||
|
moduleCatalog: catalog,
|
||||||
|
moduleReadIndex: new Map([["mood", modulePayload]]),
|
||||||
|
handlers: {
|
||||||
|
listProducts: () => products,
|
||||||
|
writeProduct: (tag, content) => {
|
||||||
|
const existing = products.find((item) => item.tag === tag);
|
||||||
|
if (existing) existing.content = content;
|
||||||
|
else products.push({ tag, content });
|
||||||
|
},
|
||||||
|
deleteProduct: () => false,
|
||||||
|
readTag: (tag) => tags.get(tag),
|
||||||
|
writeTag: (tag, content) => {
|
||||||
|
tags.set(tag, content);
|
||||||
|
},
|
||||||
|
deleteTag: () => false,
|
||||||
|
clearDialogue: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seen.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(seen[0]).not.toContain("已有舞台");
|
||||||
|
expect(seen[0]).not.toContain("写氛围");
|
||||||
|
expect(seen[1]).toContain("本轮节点表");
|
||||||
|
expect(seen[1]).toContain("写氛围");
|
||||||
|
expect(seen[1]).toContain("已有舞台");
|
||||||
|
expect(result.wroteTags).toEqual(expect.arrayContaining(["设计.氛围", "设计.旁白"]));
|
||||||
|
expect(result.reply).toContain("写了两份");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists products named by module or keyed outside the products array", async () => {
|
||||||
|
const resultProducts: Array<{ tag: string; content: string }> = [];
|
||||||
|
const tags = new Map<string, string>();
|
||||||
|
const result = await runDictateTurn({
|
||||||
|
llm: scriptedLlm([
|
||||||
|
{
|
||||||
|
inserts: [{ tag: "设计.氛围", module_id: "mood" }],
|
||||||
|
deletes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reply: "写了",
|
||||||
|
products: { 氛围: { 正文: "细雨连绵" } },
|
||||||
|
"设计.旁白": "旁白一句",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||||
|
moduleCatalog: catalog,
|
||||||
|
moduleReadIndex: new Map([["mood", modulePayload]]),
|
||||||
|
handlers: {
|
||||||
|
listProducts: () => resultProducts,
|
||||||
|
writeProduct: (tag, content) => {
|
||||||
|
resultProducts.push({ tag, content });
|
||||||
|
},
|
||||||
|
deleteProduct: () => false,
|
||||||
|
readTag: (tag) => tags.get(tag),
|
||||||
|
writeTag: (tag, content) => {
|
||||||
|
tags.set(tag, content);
|
||||||
|
},
|
||||||
|
deleteTag: () => false,
|
||||||
|
clearDialogue: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(resultProducts.map((item) => item.tag).sort()).toEqual(["设计.旁白", "设计.氛围"]);
|
||||||
|
expect(resultProducts.find((item) => item.tag === "设计.氛围")?.content).toBe("细雨连绵");
|
||||||
|
expect(result.reply).toContain("写了");
|
||||||
|
expect(result.reply).not.toContain("未给出");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects node ids first, then writes them with the user text and the node specs", async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const steps = [
|
||||||
|
{ nodes: [{ module_id: "mood" }] },
|
||||||
|
{
|
||||||
|
products: [{ tag: "设计.氛围", content: "细雨" }],
|
||||||
|
reply: "已写氛围。可能还缺主角设定。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let index = 0;
|
||||||
|
const llm: LlmProvider = {
|
||||||
|
async complete() {
|
||||||
|
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||||
|
},
|
||||||
|
async completeWithTools(messages) {
|
||||||
|
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||||
|
const body = steps[index++] ?? {};
|
||||||
|
return {
|
||||||
|
content: null,
|
||||||
|
toolCalls: toToolCalls(body, index === 1),
|
||||||
|
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const products: Array<{ tag: string; content: string }> = [];
|
||||||
|
const tags = new Map<string, string>();
|
||||||
|
const result = await runDictateTurn({
|
||||||
|
llm,
|
||||||
|
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||||
|
moduleCatalog: catalog,
|
||||||
|
moduleReadIndex: new Map([["mood", modulePayload]]),
|
||||||
|
handlers: {
|
||||||
|
listProducts: () => products,
|
||||||
|
writeProduct: (tag, content) => {
|
||||||
|
products.push({ tag, content });
|
||||||
|
},
|
||||||
|
deleteProduct: () => false,
|
||||||
|
readTag: (tag) => tags.get(tag),
|
||||||
|
writeTag: (tag, content) => {
|
||||||
|
tags.set(tag, content);
|
||||||
|
},
|
||||||
|
deleteTag: () => false,
|
||||||
|
clearDialogue: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(2);
|
||||||
|
expect(seen[0]).toContain("只做选题");
|
||||||
|
expect(seen[0]).toContain("写成细雨");
|
||||||
|
expect(seen[0]).not.toContain("declare_variable");
|
||||||
|
expect(seen[1]).toContain("写成细雨");
|
||||||
|
expect(seen[1]).toContain("写氛围");
|
||||||
|
expect(seen[1]).toContain("第一轮选定的节点:mood");
|
||||||
|
expect(seen[1]).toContain("还可能缺哪些节点");
|
||||||
|
expect(result.wroteTags).toEqual(["设计.氛围"]);
|
||||||
|
expect(result.reply).toContain("可能还缺主角设定");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a user-prefixed tag onto the catalog artifact and reads Chinese content fields", async () => {
|
||||||
|
const narration: DictateModuleReadPayload = {
|
||||||
|
...modulePayload,
|
||||||
|
module_id: "narration",
|
||||||
|
name: "旁白",
|
||||||
|
artifact: "设计.旁白",
|
||||||
|
task: "写旁白",
|
||||||
|
};
|
||||||
|
const products: Array<{ tag: string; content: string }> = [];
|
||||||
|
const tags = new Map<string, string>();
|
||||||
|
const seen: string[] = [];
|
||||||
|
const result = await runDictateTurn({
|
||||||
|
llm: {
|
||||||
|
async complete() {
|
||||||
|
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||||
|
},
|
||||||
|
async completeWithTools(messages) {
|
||||||
|
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||||
|
const step = seen.length;
|
||||||
|
const body =
|
||||||
|
step === 1
|
||||||
|
? { nodes: [{ tag: "用户.氛围" }, { module_id: "narration" }] }
|
||||||
|
: {
|
||||||
|
products: [{ "用户.氛围": { 内容: "细雨连绵" }, "设计.旁白": "旁白一句" }],
|
||||||
|
reply: "写了两份",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
content: null,
|
||||||
|
toolCalls: toToolCalls(body, step === 1),
|
||||||
|
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||||
|
moduleCatalog: {
|
||||||
|
modules: [
|
||||||
|
...catalog.modules,
|
||||||
|
{ id: "narration", name: "旁白", artifact: "设计.旁白", declaration: "旁白" },
|
||||||
|
],
|
||||||
|
} as ModuleCatalog,
|
||||||
|
moduleReadIndex: new Map([
|
||||||
|
["mood", modulePayload],
|
||||||
|
["narration", narration],
|
||||||
|
]),
|
||||||
|
handlers: {
|
||||||
|
listProducts: () => products,
|
||||||
|
writeProduct: (tag, content) => {
|
||||||
|
products.push({ tag, content });
|
||||||
|
},
|
||||||
|
deleteProduct: () => false,
|
||||||
|
readTag: (tag) => tags.get(tag),
|
||||||
|
writeTag: (tag, content) => {
|
||||||
|
tags.set(tag, content);
|
||||||
|
},
|
||||||
|
deleteTag: () => false,
|
||||||
|
clearDialogue: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seen[1]).toContain("产物 tag:设计.氛围");
|
||||||
|
expect(seen[1]).toContain("产物 tag:设计.旁白");
|
||||||
|
expect(products.map((item) => item.tag).sort()).toEqual(["设计.旁白", "设计.氛围"]);
|
||||||
|
expect(products.find((item) => item.tag === "设计.氛围")?.content).toBe("细雨连绵");
|
||||||
|
expect(result.reply).not.toContain("未给出");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for self_score dimensions before a write can land", async () => {
|
||||||
|
const scored: DictateModuleReadPayload = {
|
||||||
|
...modulePayload,
|
||||||
|
score_dimensions: ["短句"],
|
||||||
|
};
|
||||||
|
const products: Array<{ tag: string; content: string }> = [];
|
||||||
|
const tags = new Map<string, string>();
|
||||||
|
const seen: string[] = [];
|
||||||
|
const result = await runDictateTurn({
|
||||||
|
llm: {
|
||||||
|
async complete() {
|
||||||
|
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||||
|
},
|
||||||
|
async completeWithTools(messages) {
|
||||||
|
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||||
|
const body =
|
||||||
|
seen.length === 1
|
||||||
|
? { nodes: [{ module_id: "mood" }] }
|
||||||
|
: { products: [{ tag: "设计.氛围", content: "细雨" }], reply: "已落盘" };
|
||||||
|
return {
|
||||||
|
content: null,
|
||||||
|
toolCalls: toToolCalls(body, seen.length === 1),
|
||||||
|
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||||
|
moduleCatalog: catalog,
|
||||||
|
moduleReadIndex: new Map([["mood", scored]]),
|
||||||
|
handlers: {
|
||||||
|
listProducts: () => products,
|
||||||
|
writeProduct: (tag, content) => {
|
||||||
|
products.push({ tag, content });
|
||||||
|
},
|
||||||
|
deleteProduct: () => false,
|
||||||
|
readTag: (tag) => tags.get(tag),
|
||||||
|
writeTag: (tag, content) => {
|
||||||
|
tags.set(tag, content);
|
||||||
|
},
|
||||||
|
deleteTag: () => false,
|
||||||
|
clearDialogue: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seen[1]).toContain("self_score 维度(这次写入必填,name 必须一致):短句");
|
||||||
|
expect(seen[2]?.startsWith(seen[1] ?? "")).toBe(true);
|
||||||
|
expect(seen[2]).toContain("还有未通过");
|
||||||
|
expect(products).toHaveLength(0);
|
||||||
|
expect(result.reply).toContain("self_score 必填");
|
||||||
|
expect(result.reply).toContain("短句");
|
||||||
|
expect(result.reply).not.toContain("未给出");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the node table and refuses to rewrite a tag that already passed", async () => {
|
||||||
|
const narration: DictateModuleReadPayload = {
|
||||||
|
...modulePayload,
|
||||||
|
module_id: "narration",
|
||||||
|
name: "旁白",
|
||||||
|
artifact: "设计.旁白",
|
||||||
|
task: "写旁白",
|
||||||
|
score_dimensions: ["一句"],
|
||||||
|
};
|
||||||
|
const writes: string[] = [];
|
||||||
|
const tags = new Map<string, string>();
|
||||||
|
const seen: string[] = [];
|
||||||
|
const rounds = [
|
||||||
|
[{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }, { module_id: "narration" }] }) }],
|
||||||
|
[
|
||||||
|
{ id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨" }) },
|
||||||
|
{ id: "b", name: "insert", arguments: JSON.stringify({ position: "设计.旁白", content: "旁白" }) },
|
||||||
|
{ id: "r1", name: "reply_user", arguments: JSON.stringify({ text: "都写了" }) },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ id: "a2", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "改掉细雨" }) },
|
||||||
|
{
|
||||||
|
id: "b2",
|
||||||
|
name: "insert",
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
position: "设计.旁白",
|
||||||
|
content: "旁白",
|
||||||
|
self_score: { dims: [{ name: "一句", score: 4 }] },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[{ id: "r2", name: "reply_user", arguments: JSON.stringify({ text: "旁白补上了" }) }],
|
||||||
|
];
|
||||||
|
let index = 0;
|
||||||
|
await runDictateTurn({
|
||||||
|
llm: {
|
||||||
|
async complete() {
|
||||||
|
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||||
|
},
|
||||||
|
async completeWithTools(messages) {
|
||||||
|
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||||
|
return {
|
||||||
|
content: null,
|
||||||
|
toolCalls: rounds[index++] ?? [],
|
||||||
|
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||||
|
moduleCatalog: {
|
||||||
|
modules: [
|
||||||
|
...catalog.modules,
|
||||||
|
{ id: "narration", name: "旁白", artifact: "设计.旁白", declaration: "旁白" },
|
||||||
|
],
|
||||||
|
} as ModuleCatalog,
|
||||||
|
moduleReadIndex: new Map([
|
||||||
|
["mood", modulePayload],
|
||||||
|
["narration", narration],
|
||||||
|
]),
|
||||||
|
handlers: {
|
||||||
|
listProducts: () => [],
|
||||||
|
writeProduct: (tag, content) => {
|
||||||
|
writes.push(`${tag}:${content}`);
|
||||||
|
},
|
||||||
|
deleteProduct: () => false,
|
||||||
|
readTag: (tag) => tags.get(tag),
|
||||||
|
writeTag: (tag, content) => {
|
||||||
|
tags.set(tag, content);
|
||||||
|
},
|
||||||
|
deleteTag: () => false,
|
||||||
|
clearDialogue: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seen[2]?.startsWith(seen[1] ?? "")).toBe(true);
|
||||||
|
expect(seen[2]).toContain("写旁白");
|
||||||
|
expect(writes).toEqual(["设计.氛围:细雨", "设计.旁白:旁白"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts reply_user guidance early in the fill prompt and does not mention a fallback reply step", async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const products: Array<{ tag: string; content: string }> = [];
|
||||||
|
const rounds = [
|
||||||
|
[{ id: "select", name: "select_nodes", arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }) }],
|
||||||
|
[
|
||||||
|
{ id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨" }) },
|
||||||
|
{ id: "r", name: "reply_user", arguments: JSON.stringify({ text: "细雨留下了" }) },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
let index = 0;
|
||||||
|
const tags = new Map<string, string>();
|
||||||
|
await runDictateTurn({
|
||||||
|
llm: {
|
||||||
|
async complete() {
|
||||||
|
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||||
|
},
|
||||||
|
async completeWithTools(messages) {
|
||||||
|
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||||
|
return {
|
||||||
|
content: null,
|
||||||
|
toolCalls: rounds[index++] ?? [],
|
||||||
|
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||||
|
moduleCatalog: catalog,
|
||||||
|
moduleReadIndex: new Map([["mood", modulePayload]]),
|
||||||
|
handlers: {
|
||||||
|
listProducts: () => products,
|
||||||
|
writeProduct: (tag, content) => {
|
||||||
|
products.push({ tag, content });
|
||||||
|
},
|
||||||
|
deleteProduct: () => false,
|
||||||
|
readTag: (tag) => tags.get(tag),
|
||||||
|
writeTag: (tag, content) => {
|
||||||
|
tags.set(tag, content);
|
||||||
|
},
|
||||||
|
deleteTag: () => false,
|
||||||
|
clearDialogue: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(2);
|
||||||
|
const fill = seen[1] ?? "";
|
||||||
|
expect(fill.indexOf("填写收尾")).toBeLessThan(fill.indexOf("写入规则"));
|
||||||
|
expect(fill.indexOf("本步收尾")).toBeLessThan(fill.indexOf("更早的对话"));
|
||||||
|
expect(fill.indexOf("同一次请求必须调用 reply_user")).toBeLessThan(fill.indexOf("写氛围"));
|
||||||
|
expect(fill).toContain("分数一般或还有更好写法的缺口");
|
||||||
|
expect(fill).toContain("下一步可以提及哪些");
|
||||||
|
expect(fill).not.toContain("本轮刚落盘");
|
||||||
|
expect(fill).not.toContain("你只生成给用户看的综合回复");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("silently asks for reply_user with only this round's products when fill omitted it", async () => {
|
||||||
|
const products: Array<{ tag: string; content: string }> = [
|
||||||
|
{ tag: "设计.舞台", content: "旧舞台不应进入回复请求" },
|
||||||
|
];
|
||||||
|
const seen: string[] = [];
|
||||||
|
const choices: Array<unknown> = [];
|
||||||
|
let index = 0;
|
||||||
|
const tags = new Map<string, string>();
|
||||||
|
const result = await runDictateTurn({
|
||||||
|
llm: {
|
||||||
|
async complete() {
|
||||||
|
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||||
|
},
|
||||||
|
async completeWithTools(messages, options) {
|
||||||
|
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||||
|
choices.push(options?.toolChoice);
|
||||||
|
const step = index++;
|
||||||
|
if (step === 0) {
|
||||||
|
return {
|
||||||
|
content: null,
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
name: "select_nodes",
|
||||||
|
arguments: JSON.stringify({ nodes: [{ module_id: "mood" }] }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (step === 1) {
|
||||||
|
return {
|
||||||
|
content: "细雨留下了,接下来可以定主角怎么进城。",
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
name: "insert",
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
position: "设计.氛围",
|
||||||
|
content: "细雨",
|
||||||
|
self_score: {
|
||||||
|
dims: [{ name: "短句", score: 3, gap: "还缺进城方式", options: ["步行", "马车"] }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: null,
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
id: "r",
|
||||||
|
name: "reply_user",
|
||||||
|
arguments: JSON.stringify({ text: "细雨已落盘,接下来可以定进城方式。" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dialogue: [{ role: "user", text: "写成细雨" }],
|
||||||
|
moduleCatalog: catalog,
|
||||||
|
moduleReadIndex: new Map([
|
||||||
|
["mood", { ...modulePayload, score_dimensions: ["短句"] }],
|
||||||
|
]),
|
||||||
|
handlers: {
|
||||||
|
listProducts: () => products,
|
||||||
|
writeProduct: (tag, content, _order, metadata) => {
|
||||||
|
products.push({
|
||||||
|
tag,
|
||||||
|
content,
|
||||||
|
...(metadata?.selfScore ? { selfScore: metadata.selfScore } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteProduct: () => false,
|
||||||
|
readTag: (tag) => tags.get(tag),
|
||||||
|
writeTag: (tag, content) => {
|
||||||
|
tags.set(tag, content);
|
||||||
|
},
|
||||||
|
deleteTag: () => false,
|
||||||
|
clearDialogue: () => undefined,
|
||||||
|
lookupInsertFeedback: (tag) =>
|
||||||
|
tag === "设计.氛围"
|
||||||
|
? `# 本轮关注 · 氛围\n\n## 写入用户回复时做什么\n1. 自然回应\n3. 结合 self_score 说明最强处与关键缺口\n\n## 自评关注\n- 短句`
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.reply).toBe("细雨已落盘,接下来可以定进城方式。");
|
||||||
|
expect(result.iterations).toBe(3);
|
||||||
|
expect(seen).toHaveLength(3);
|
||||||
|
const replyReq = seen[2] ?? "";
|
||||||
|
expect(replyReq).toContain("本轮刚落盘");
|
||||||
|
expect(replyReq).toContain("细雨");
|
||||||
|
expect(replyReq).toContain("self_score");
|
||||||
|
expect(replyReq).toContain("还缺进城方式");
|
||||||
|
expect(replyReq).toContain("本轮关注");
|
||||||
|
expect(replyReq).toContain("结合 self_score 说明最强处与关键缺口");
|
||||||
|
expect(replyReq).toContain("每个缺口写三样");
|
||||||
|
expect(replyReq).toContain("2~3 个可以接着说的选项");
|
||||||
|
expect(replyReq).not.toContain("旧舞台不应进入回复请求");
|
||||||
|
expect(replyReq).not.toContain("#### task");
|
||||||
|
expect(choices[2]).toEqual({ type: "function", function: { name: "reply_user" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
buildDictateMessages,
|
buildDictateMessages,
|
||||||
buildDictateSystemPrompt,
|
buildDictateSystemPrompt,
|
||||||
|
formatDictateRoundTail,
|
||||||
buildDictateInsertFeedbackIndex,
|
buildDictateInsertFeedbackIndex,
|
||||||
defaultDictateOrder,
|
defaultDictateOrder,
|
||||||
extractDictateDialogue,
|
extractDictateDialogue,
|
||||||
@@ -47,7 +48,7 @@ describe("dictate context", () => {
|
|||||||
});
|
});
|
||||||
const body = messages[1]?.content ?? "";
|
const body = messages[1]?.content ?? "";
|
||||||
expect(body).toContain("冷色短句");
|
expect(body).toContain("冷色短句");
|
||||||
expect(body).toContain("尚无对话");
|
expect(body).toContain("尚无更早对话");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts only user + dictate_reply turns", () => {
|
it("extracts only user + dictate_reply turns", () => {
|
||||||
@@ -73,8 +74,9 @@ describe("dictate context", () => {
|
|||||||
expect(isDictateModeValue("recipe")).toBe(false);
|
expect(isDictateModeValue("recipe")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("system prompt starts from a plan and keeps generic write tools", () => {
|
it("system prompt keeps stable rules; catalog and plan progress sit in the tail", () => {
|
||||||
const prompt = buildDictateSystemPrompt({
|
const prompt = buildDictateSystemPrompt();
|
||||||
|
const tail = formatDictateRoundTail({
|
||||||
recipeName: "数据化跑团体验",
|
recipeName: "数据化跑团体验",
|
||||||
recipeBrief:
|
recipeBrief:
|
||||||
"设定 + 真值 + 分档映射\n怎么做:\n- declare_variable\n- declare_map",
|
"设定 + 真值 + 分档映射\n怎么做:\n- declare_variable\n- declare_map",
|
||||||
@@ -90,17 +92,31 @@ describe("dictate context", () => {
|
|||||||
expect(prompt).toContain("declare_map");
|
expect(prompt).toContain("declare_map");
|
||||||
expect(prompt).toContain("remove_map");
|
expect(prompt).toContain("remove_map");
|
||||||
expect(prompt).toContain("同 position 再 insert");
|
expect(prompt).toContain("同 position 再 insert");
|
||||||
expect(prompt).toContain("数据化跑团体验");
|
expect(prompt).not.toContain("数据化跑团体验");
|
||||||
expect(prompt).toMatch(/order/);
|
expect(prompt).toMatch(/order/);
|
||||||
expect(prompt).toContain("【能力 · 何时落盘】");
|
expect(prompt).not.toContain("【能力 · 何时落盘】");
|
||||||
expect(prompt).toContain("设计.美学纲领与交互范式");
|
|
||||||
expect(prompt).toContain("通常 2~3 个");
|
expect(prompt).toContain("通常 2~3 个");
|
||||||
expect(prompt).toContain("给 2~3 个选项来问");
|
expect(prompt).toContain("给 2~3 个选项来问");
|
||||||
expect(prompt).toContain("不是 DAG");
|
expect(prompt).toContain("不是 DAG");
|
||||||
expect(prompt).toContain("不是用户已选路径");
|
expect(prompt).not.toContain("不是用户已选路径");
|
||||||
expect(prompt).toContain("自然回应");
|
expect(prompt).toContain("自然回应");
|
||||||
|
expect(prompt.indexOf("填写收尾")).toBeLessThan(prompt.indexOf("写入规则"));
|
||||||
expect(prompt).not.toContain("体验锚定(程序打分)");
|
expect(prompt).not.toContain("体验锚定(程序打分)");
|
||||||
expect(prompt).not.toContain("## 本轮落什么");
|
expect(prompt).not.toContain("## 本轮落什么");
|
||||||
|
expect(tail).toContain("数据化跑团体验");
|
||||||
|
expect(tail).toContain("【能力 · 何时落盘】");
|
||||||
|
expect(tail).toContain("不是用户已选路径");
|
||||||
|
expect(tail).toContain("尚无本局创作方案");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the fill reply_user duty before plan progress in the tail", () => {
|
||||||
|
const fillTail = formatDictateRoundTail({
|
||||||
|
phase: "fill",
|
||||||
|
creationPlanState: "【当前决策】\n- mood:已落",
|
||||||
|
checklist: "本轮写入:设计.氛围",
|
||||||
|
});
|
||||||
|
expect(fillTail.indexOf("必须调用 reply_user")).toBeLessThan(fillTail.indexOf("当前决策"));
|
||||||
|
expect(fillTail.indexOf("必须调用 reply_user")).toBeLessThan(fillTail.indexOf("本轮写入"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats module catalog for dictate with land tags and when", async () => {
|
it("formats module catalog for dictate with land tags and when", async () => {
|
||||||
@@ -172,6 +188,12 @@ describe("dictate context", () => {
|
|||||||
expect(
|
expect(
|
||||||
resolveModuleForInsertTag("设计.开场白", catalog!)?.id,
|
resolveModuleForInsertTag("设计.开场白", catalog!)?.id,
|
||||||
).toBe("opening-setup");
|
).toBe("opening-setup");
|
||||||
|
expect(
|
||||||
|
resolveModuleForInsertTag("设计.开场白#opening-cycle2", catalog!)?.id,
|
||||||
|
).toBe("opening-setup");
|
||||||
|
expect(
|
||||||
|
resolveModuleForInsertTag("设计.开场白与开场变量", catalog!)?.id,
|
||||||
|
).toBe("opening-setup");
|
||||||
expect(
|
expect(
|
||||||
resolveModuleForInsertTag("设计.具体实例#lei-ying", catalog!)?.id,
|
resolveModuleForInsertTag("设计.具体实例#lei-ying", catalog!)?.id,
|
||||||
).toBe("concrete-instances");
|
).toBe("concrete-instances");
|
||||||
@@ -244,31 +266,47 @@ describe("dictate context", () => {
|
|||||||
expect("error" in overwriteBare).toBe(true);
|
expect("error" in overwriteBare).toBe(true);
|
||||||
|
|
||||||
const openingFamilies = new Set(["设计.开场白", "设计.主角设定"]);
|
const openingFamilies = new Set(["设计.开场白", "设计.主角设定"]);
|
||||||
const firstOpen = resolveRepeatableInsertTag({
|
const bareOpen = resolveRepeatableInsertTag({
|
||||||
tag: "设计.开场白",
|
tag: "设计.开场白",
|
||||||
content: "@玩家推开门。",
|
content: "@玩家推开门。",
|
||||||
existingTags: [],
|
existingTags: [],
|
||||||
repeatableFamilies: openingFamilies,
|
repeatableFamilies: openingFamilies,
|
||||||
});
|
});
|
||||||
expect("tag" in firstOpen && firstOpen.tag).toBe("设计.开场白");
|
expect("error" in bareOpen).toBe(true);
|
||||||
const overwriteFirst = resolveRepeatableInsertTag({
|
const namedFromBundle = resolveRepeatableInsertTag({
|
||||||
tag: "设计.开场白",
|
tag: "设计.开场白与开场变量",
|
||||||
content: "@玩家改写第一幕。",
|
content: JSON.stringify({
|
||||||
|
schema: "present.v1",
|
||||||
|
id: "opening-cycle2",
|
||||||
|
blocks: { body: "@玩家推开门。" },
|
||||||
|
}),
|
||||||
existingTags: ["设计.开场白"],
|
existingTags: ["设计.开场白"],
|
||||||
repeatableFamilies: openingFamilies,
|
repeatableFamilies: openingFamilies,
|
||||||
});
|
});
|
||||||
expect("tag" in overwriteFirst && overwriteFirst.tag).toBe("设计.开场白");
|
expect("tag" in namedFromBundle && namedFromBundle.tag).toBe(
|
||||||
|
"设计.开场白#opening-cycle2",
|
||||||
|
);
|
||||||
|
const keepNamed = resolveRepeatableInsertTag({
|
||||||
|
tag: "设计.开场白#opening-cycle2",
|
||||||
|
content: JSON.stringify({
|
||||||
|
schema: "present.v1",
|
||||||
|
blocks: { body: "@玩家改写这一幕。" },
|
||||||
|
}),
|
||||||
|
existingTags: ["设计.开场白", "设计.开场白与开场变量", "设计.开场白#opening-cycle2"],
|
||||||
|
repeatableFamilies: openingFamilies,
|
||||||
|
});
|
||||||
|
expect("tag" in keepNamed && keepNamed.tag).toBe("设计.开场白#opening-cycle2");
|
||||||
const secondOpen = resolveRepeatableInsertTag({
|
const secondOpen = resolveRepeatableInsertTag({
|
||||||
tag: "设计.开场白#gate",
|
tag: "设计.开场白#gate",
|
||||||
content: "@玩家在闸机前。",
|
content: "@玩家在闸机前。",
|
||||||
existingTags: ["设计.开场白"],
|
existingTags: ["设计.开场白#opening-cycle2"],
|
||||||
repeatableFamilies: openingFamilies,
|
repeatableFamilies: openingFamilies,
|
||||||
});
|
});
|
||||||
expect("tag" in secondOpen && secondOpen.tag).toBe("设计.开场白#gate");
|
expect("tag" in secondOpen && secondOpen.tag).toBe("设计.开场白#gate");
|
||||||
const bareOpenConflict = resolveRepeatableInsertTag({
|
const bareOpenConflict = resolveRepeatableInsertTag({
|
||||||
tag: "设计.开场白",
|
tag: "设计.开场白",
|
||||||
content: "又写一条",
|
content: "又写一条",
|
||||||
existingTags: ["设计.开场白", "设计.开场白#gate"],
|
existingTags: ["设计.开场白#opening-cycle2", "设计.开场白#gate"],
|
||||||
repeatableFamilies: openingFamilies,
|
repeatableFamilies: openingFamilies,
|
||||||
});
|
});
|
||||||
expect("error" in bareOpenConflict).toBe(true);
|
expect("error" in bareOpenConflict).toBe(true);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ describe("display-labels", () => {
|
|||||||
it("maps creation intake modes and recipe display names", () => {
|
it("maps creation intake modes and recipe display names", () => {
|
||||||
expect(displayCreationModeLabel("recipe")).toBe("工序编排");
|
expect(displayCreationModeLabel("recipe")).toBe("工序编排");
|
||||||
expect(displayCreationModeLabel("dictate")).toBe("对话落盘");
|
expect(displayCreationModeLabel("dictate")).toBe("对话落盘");
|
||||||
|
expect(displayCreationModeLabel("listen_serial")).toBe("听书连载");
|
||||||
expect(displaySkillPackLabel("world-simulator")).toBe("回合推演");
|
expect(displaySkillPackLabel("world-simulator")).toBe("回合推演");
|
||||||
expect(displaySkillPackLabel("快穿短局")).toBe("快穿短局");
|
expect(displaySkillPackLabel("快穿短局")).toBe("快穿短局");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
applyPlayAuxLlmBubbles,
|
||||||
|
isPlayAuxLlmBubblesEnabled,
|
||||||
|
isPlayAuxLlmOutput,
|
||||||
|
} from "../web/feed-prefs.js";
|
||||||
|
|
||||||
|
describe("play aux LLM bubbles", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
applyPlayAuxLlmBubbles(false);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
applyPlayAuxLlmBubbles(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the more-menu preference", () => {
|
||||||
|
expect(isPlayAuxLlmBubblesEnabled()).toBe(false);
|
||||||
|
applyPlayAuxLlmBubbles(true);
|
||||||
|
expect(isPlayAuxLlmBubblesEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats auditor worker_output as aux and world-simulator as main", () => {
|
||||||
|
const play = { lifecycleStage: "play" };
|
||||||
|
expect(
|
||||||
|
isPlayAuxLlmOutput(
|
||||||
|
{ role: "assistant", kind: "worker_output", actor: "auditor" },
|
||||||
|
play,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isPlayAuxLlmOutput(
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
kind: "worker_output",
|
||||||
|
actor: "world-simulator",
|
||||||
|
title: "主世界层 · 产出",
|
||||||
|
},
|
||||||
|
play,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isPlayAuxLlmOutput(
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
kind: "worker_output",
|
||||||
|
actor: "narrator",
|
||||||
|
title: "叙事转述 · 产出",
|
||||||
|
},
|
||||||
|
play,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
sanitizeReasoningEffort,
|
sanitizeReasoningEffort,
|
||||||
sanitizeVerbosity,
|
sanitizeVerbosity,
|
||||||
} from "../src/llm/generation-compat.js";
|
} from "../src/llm/generation-compat.js";
|
||||||
import { buildRequestBody } from "../src/llm/client.js";
|
import { buildRequestBody, extractMessageParts, openRouterSessionId } from "../src/llm/client.js";
|
||||||
import type { LlmConfig } from "../src/config/env.js";
|
import type { LlmConfig } from "../src/config/env.js";
|
||||||
|
|
||||||
const stGeneration = {
|
const stGeneration = {
|
||||||
@@ -410,6 +410,24 @@ describe("fetchWithGenerationCompat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("extractMessageParts", () => {
|
||||||
|
it("keeps reply_user when arguments are an object and id is missing", () => {
|
||||||
|
const parts = extractMessageParts({
|
||||||
|
content: [{ type: "text", text: "同一次请求里的说明" }],
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: { name: "reply_user", arguments: { text: "形成了气氛" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(parts.toolCalls).toEqual([
|
||||||
|
{ id: "call-0", name: "reply_user", arguments: JSON.stringify({ text: "形成了气氛" }) },
|
||||||
|
]);
|
||||||
|
expect(parts.content).toBe("同一次请求里的说明");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("buildRequestBody", () => {
|
describe("buildRequestBody", () => {
|
||||||
const messages = [{ role: "user" as const, content: "hi" }];
|
const messages = [{ role: "user" as const, content: "hi" }];
|
||||||
|
|
||||||
@@ -597,6 +615,24 @@ describe("buildRequestBody", () => {
|
|||||||
expect(body.messages).toBe(messages);
|
expect(body.messages).toBe(messages);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("writes session_id only for OpenRouter", () => {
|
||||||
|
const openrouter: LlmConfig = {
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
apiKey: "k",
|
||||||
|
model: "google/gemini-3.8-flash",
|
||||||
|
};
|
||||||
|
const direct: LlmConfig = {
|
||||||
|
baseUrl: "https://api.deepseek.com/v1",
|
||||||
|
apiKey: "k",
|
||||||
|
model: "deepseek-v4-pro",
|
||||||
|
};
|
||||||
|
const id = "9b76dcb6-0703-4b0d-ba75-faa985b532b4";
|
||||||
|
expect(buildRequestBody(openrouter, messages, { sessionId: id }).session_id).toBe(id);
|
||||||
|
expect(buildRequestBody(direct, messages, { sessionId: id }).session_id).toBeUndefined();
|
||||||
|
expect(buildRequestBody(openrouter, messages, {}).session_id).toBeUndefined();
|
||||||
|
expect(openRouterSessionId(openrouter.baseUrl, ` ${"a".repeat(300)} `)).toHaveLength(256);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not forward top_k from an imported ST preset", () => {
|
it("does not forward top_k from an imported ST preset", () => {
|
||||||
const config: LlmConfig = {
|
const config: LlmConfig = {
|
||||||
baseUrl: "https://example.test/v1",
|
baseUrl: "https://example.test/v1",
|
||||||
|
|||||||