Compare commits

..
Author SHA1 Message Date
chenmoranzhi 1a5ef3b2bc 抓包对比工具补一批一次性脚本:拆预设、对请求、跑 A/B 与拒答抽样。
这些脚本都是我对着酒馆真实请求手工核对用的,留在仓库里方便下次重跑;跑出来的本地数据目录含接口 key,已经写进忽略规则。
2026-10-26 01:09:56 +08:00
chenmoranzhi e2413b0677 主题与字号从页面顶栏搬进设置页的「外观」,顶部导航加上预设入口。
顶栏那套汉堡菜单在每页都要单独挂载、样式也重复,收进设置页后只剩一处改;顺手把「整组都没有曾用型号」的配置组整组收起,不再留空卡片。
2026-10-26 01:09:43 +08:00
chenmoranzhi f3303038fe 新增预设工作台:编辑、版本树、结构检查、用例试跑放在同一页。
改预设要能一边写一边看装出来的 messages,还要多模型对照,所以把编辑与试跑拆成两个视图塞进一个页面;版本存完整快照、可分叉,用例全局共用一份、运行记录按预设存。顺带让读预设文件先剥 BOM,静态资源改成不缓存,免得改了脚本还要强刷。
2026-10-26 01:09:40 +08:00
chenmoranzhi 98e51f9c6c 直聊的种子历史改成逐条真多轮,不再靠块头文本猜轮次。
一条一个角色下拉加一整句正文,换行原样保留;旧盘里的纯文本在读盘时迁移,切得出轮次的转成条目,切不出的挪进史前设定免得悄悄丢掉。历史编辑器做成共用控件,预设工作台的用例与假历史也用同一套。
2026-10-26 01:09:34 +08:00
chenmoranzhi 860eff59b2 预设可以带一段手写的假历史,按真多轮发出去。
它进对话历史区、占真实轮次,跟酒馆示例对话(只做示范、不占轮次)不是一回事,所以要分开存:有它时排在真实历史之前,宏照样展开。
2026-10-26 01:09:29 +08:00
chenmoranzhi 7e7b9c5083 宏扫描认酒馆的连带花括号写法,并补上示例对话宏。
{{{setvar::x::}} 的首个花括号在酒馆里是字面量,以前整段漏进正文;顺手把 mesExamples 接进来,导入报告也用酒馆写法显示宏名。
2026-10-26 01:09:23 +08:00
chenmoranzhi b4c83e706b 装配只补缺不重排,消息整形也不再把尾部系统提示搬到前面。
工作台左轨的顺序被当成真实顺序用:补洞时只插缺的、不动已有的条目;整形把落单的 system 接在最后一条后面,之前是插在它前面,排出来的正文顺序跟左轨对不上。
2026-10-26 01:09:20 +08:00
chenmoranzhi 9dc927282f 预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
2026-10-23 00:13:08 +08:00
chenmoranzhi ed09e5c831 加一套抓真实请求做预设对比的工具,并把三份预设的机制差异记进 README。 2026-10-22 20:07:33 +08:00
chenmoranzhiandCursor 18f034bc2d 直聊边生成边把回复写入卡片并落盘,方便对照多个模型。
应用上下文后不再停在临时会话;卡片可拖动阅读、盲测时隐藏型号并打乱位置,用户气泡按正文收高。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 01:38:40 +08:00
chenmoranzhiandCursor ba5485a9be 价目对照保留仍在预览中的专业档,以及非快闪、非专业的代号档。
否则当前在用的型号会被整档滤掉,统计对不上实际调用。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 01:38:34 +08:00
chenmoranzhiandCursor e683df28dc 记下本局游玩选项跟书走的约定,先不改运行逻辑。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 08:30:11 +08:00
chenmoranzhiandCursor 749eb2f5b0 增加与作品对话并列的直聊,多模型按列对照并可选定基准。
服务启动时顺带拉取开放路由价目,用户目录同时预留生图工作流。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 08:30:04 +08:00
chenmoranzhiandCursor e7a4b6c954 作品在列表里新建,落盘产物可在侧栏直接改。
顶栏挂上价目提示和直聊入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 08:29:57 +08:00
chenmoranzhiandCursor b59ccf976c 启动时对照开放路由价目,并在统计页看清缓存费用。
最近用过的模型变价时可以在页上直接核对。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 08:29:46 +08:00
chenmoranzhiandCursor b864591afa 对话落盘同一轮填写失败后可以按次数补写。
游玩投影不再送进只给程序用的壳源码。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 08:29:38 +08:00
chenmoranzhiandCursor bc77bc4a3c 改写回合推演技能合同,并接上画面工作流库。
旧产物名仍能读到,避免改名后上下文对不上。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 08:29:28 +08:00
chenmoranzhiandCursor e4c3141be5 清理 shells 页面并同步相关文档与导航。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 02:21:39 +08:00
chenmoranzhiandCursor e0d9db7ec1 创作降本:取消同轮retry、两轮history;听书创作/播放分层与前端配套。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 02:17:25 +08:00
chenmoranzhiandCursor 7185f25dec 补上费用核对与缓存审计材料,方便后续按结论继续压创作成本。
交接说明自包含,不依赖聊天记录也能接着做优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:49:51 +08:00
chenmoranzhiandCursor 66ed1eaca8 会话与前端接上听书、呈现投影、请求轮廓和旁观气泡偏好。
新建作品可选听书连载,朗读与消费进度走专用接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:49:44 +08:00
chenmoranzhiandCursor cf6cb68429 收紧世界模拟器能力与壳提示,对齐新的落盘合同和呈现分工。
让选题填写与游玩投影读到一致的规格口径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:49:34 +08:00
chenmoranzhiandCursor ff5c77bfb1 新增听书连载进料,按消费进度连续吐出正文缓冲。
与对话落盘并列,适合边听边往后生成的路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:49:25 +08:00
chenmoranzhiandCursor 820397e578 接入边缘浏览器语音合成并做本地缓存,听书段落可直接朗读。
依赖随锁文件一并落下,避免环境缺包。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:48:43 +08:00
chenmoranzhiandCursor 1cc4ae557a 创作一轮改为先选题再填写,减少工具空转和重复覆盖。
上下文按规则、历史、产物分段拼装,便于吃提示缓存并核对费用。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:48:20 +08:00
chenmoranzhiandCursor d0a48f9431 把正文组成收成数据契约,程序投影与旁观维护不再靠模型临场猜。
有变量真值时固定开旁观填表,开场与游玩只填模型生成区。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:47:58 +08:00
chenmoranzhiandCursor bb4f934b3d 补齐缓存写量与请求轮廓记账,便于核对提示缓存是否真的命中。
给用量行挂上轮次步骤和分段字数,统计页能看出缓存断在哪。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:47:51 +08:00
fazhu d0274afb80 Merge pull request 'docs(README):更新一版README,丰富了项目介绍,提高可读性' (#1) from docs/readme-and-review into master
Reviewed-on: #1
2026-09-30 16:10:31 +08:00
ociflab-bot 48801c656d docs(README):更新一版README,丰富了项目介绍,提高可读性 2026-09-30 16:09:53 +08:00
301 changed files with 43727 additions and 3223 deletions
+6
View File
@@ -1,3 +1,9 @@
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.deepseek.com
OPENAI_MODEL=deepseek-v4-pro
# 出网代理(可选)。不填则自动探测 127.0.0.1:7890,探不到就直连。
# 注意:本文件仅在 Web / 桌面壳启动时加载;CLI(npm run dev)请直接在 shell 里设变量。
# WRITING_AGENT_PROXY=http://127.0.0.1:7890
# WRITING_AGENT_PROXY=off
+3
View File
@@ -2,3 +2,6 @@ node_modules/
dist/
.env
*.log
# 抓包对比工具跑出来的本地数据(含接口 key 与预设副本),不进仓库
tools/st-request-diff/.ab-data/
+95
View File
@@ -0,0 +1,95 @@
# 直聊(chat.html)思维链默认折叠,点击展开
## Context
直聊页面的多模型回复卡(`.chat-reply-card`)把模型的思维链整段铺在正文上方,一张卡的可视高度被思维链吃掉,正文(真正要看的内容)被挤到下面。截图现场:盲测模式下的「模型 1」卡,头部下面整块浅色区域就是思维链,之后才是终端流水线正文。
目标:思维链收成一行折叠条,默认收起,点一下展开;流式期间模型只吐思考时自动展开,正文一开始到达就自动收起。
## 已定决策(用户)
1. 流式阶段:**自动展开,正文开始后自动收起**(不是"始终保持折叠")。
2. 折叠条文案:**带字数**,形如 `思考过程 · 1234 字`。
3. 右上角「复制」:**仍然包含思维链**(`replyPlainText` 不动)。
## 现状(已确认)
- `web/chat.js`
- `replyBodyHtml(body)` §902-908:思维链 `<div class="chat-bubble-thinking">` + 正文,永远展开。
- `renderReplyCard()` §893 调用它;`paintStreamingReply()` §1848-1857 每帧 `textEl.innerHTML = replyBodyHtml(body)`,**任何展开状态都会被重渲染冲掉**,所以展开状态必须存在模块级集合里,不能只留在 DOM。
- 流式钩子:`onDelta` 收到 `event.field === "thinking"` 累加 `reply.thinking`,否则累加 `reply.content`(§1952-1956、§2112-2116)。
- 卡片内状态集合的既有惯例:`revealedCards`、`expandedCards` 都是 `Set<${turnId}:${windowId}>`,key 用 `cardExpandKey(turnId, windowId)`(§449);移除模型时按 `` key.endsWith(`:${windowId}`) `` 清理(§2479-2484)。
- 卡片点击是委托在 arena 上的单一大 handler(§2790-2880),`expand-card` / `reveal-card` 都在里面处理,`reveal-card` 走 `applyCardReveal()` 就地改 DOM 不重渲染。
- 拖动滚动:`pointerdown` 里 `e.target.closest("button, a, textarea, input")` 才跳过(§1129),`summary` 不在名单里 → 点折叠条会先进入拖动状态。
- `web/chat.css`
- §1155 `.chat-bubble-thinking`:浅底、muted、12px、`margin: 0 0 8px`。改完即无引用,直接替换。
- §815-836 多卡并排时卡片是固定高度、`.chat-reply-card-text` 内部滚动;单卡时高度 auto。
- `web/agent-ui.js` §93 `renderThinkingBlock()` 是主对话的同类实现(`<details class="msg-thinking">`,默认折叠),样式在 `web/styles.css` §2948 —— 但直聊页不加载 styles.css,不能直接复用 class,只复用手法。
- 无前端单测,`tests/` 全是 `src/` 的 vitest;验证靠手动跑页面。
## Approach
1. **状态**:新增 `const thinkingToggles = new Map();`,key 同 `cardExpandKey`,value 是用户手动选择(`true` 展开 / `false` 收起)。
2. **展开判定**(纯函数,渲染与流式刷新共用):
```js
function thinkingOpenFor(key, body) {
const manual = thinkingToggles.get(key);
if (manual !== undefined) return manual; // 用户点过就听用户的
const hasThinking = Boolean(String(body.thinking ?? "").trim());
return hasThinking && !String(body.text ?? "").trim(); // 只有思考、还没正文 → 自动展开
}
```
- 流式:只有 `thinking` → 自动展开;`content` 一有内容 → 自动收起(就是选择的方案 B)。
- 加载历史/结束后既有思考又有正文 → 默认收起。
- 用户在流式中点开过 → 一直保持展开(用户意图优先)。
3. **渲染**:`replyBodyHtml(body, key)` 产出
```html
<details class="chat-think" open?>
<summary class="chat-think-summary">思考过程 · N 字</summary>
<div class="chat-think-body">…renderMarkdownToHtml(thinking)…</div>
</details>
```
字数取 `thinking.trim().length`(中文字符数即字数,流式时随内容增长)。`renderReplyCard` 和 `paintStreamingReply` 都传 `cardExpandKey(turnId, windowId)`。
4. **点击展开**:在 arena 的委托 handler 里,`expand-card` 之前加一段——命中 `summary.chat-think-summary` 时 `e.preventDefault()`,读 `details.open` 取反,写回 `thinkingToggles` 并直接改 `details.open`(不整卡重渲染,不打断选中)。键盘 Enter/Space 在 `summary` 上也会派发 click,一并生效。
5. **别被拖动吞掉**:`pointerdown` 的排除名单加 `summary`。
6. **样式**:把 `.chat-bubble-thinking` 换成 `.chat-think` 一组(虚线边框/muted/12px,与 `.msg-thinking` 同气质但用 chat.css 的 `--st-*` 变量),`summary` 保留原生三角、`cursor: pointer`、`user-select: none`、hover 提亮;`.chat-arena.is-copy-off` 下 `summary` 仍保持 `cursor: pointer`。展开后正文不再受 12px 之外的额外限制(不额外给 max-height,避免卡片内嵌套滚动条和现有 wheel 逻辑打架)。
7. **清理**:`removeWindow` 的清理循环里一并删 `thinkingToggles` 的 `:windowId` 项;`retryReplies` 清掉该卡 `thinking` 时同步删掉对应 toggle,避免重试后残留"强制展开"。
## Files to modify
- `web/chat.js` — 状态集合、`thinkingOpenFor`、`replyBodyHtml`、`renderReplyCard`、`paintStreamingReply`、委托 handler、`pointerdown` 排除、`removeWindow` / `retryReplies` 清理
- `web/chat.css` — `.chat-bubble-thinking` → `.chat-think` / `.chat-think-summary` / `.chat-think-body`,copy-off 下的 cursor 覆盖
不动:`replyPlainText`(复制仍含思维链)、`export.js`、`agent-ui.js`(主对话早已折叠)。
## 边界 / 已知取舍
- 删掉唯一一轮回复或整段会话时 `thinkingToggles` 可能留残留 key —— 与现有 `revealedCards` 一致,且 key 带 turnId 不会串到别的轮次。
- 多条并排时卡片是固定高度,展开思维链后内容在 `.chat-reply-card-text` 内滚动,不重算 `collapseReplyTracks()`(重算会把整行高度重新洗一遍,副作用更大)。「展开」按钮的可见性仍按折叠前的测量结果,可接受。
- 思维链全为空 → 不渲染折叠条(行为同现状)。
## Steps
- [x] `web/chat.js`:加 `thinkingToggles` Map、`thinkingOpenFor()`、`thinkingSummaryText()`
- [x] `web/chat.js`:`replyBodyHtml(body, key)` 改成 `<details>` 结构,字数进 summary
- [x] `web/chat.js`:`renderReplyCard()` 与 `paintStreamingReply()` 传 key
- [x] `web/chat.js`:委托 handler 处理 `summary.chat-think-summary`(preventDefault + 写 map + 就地开合)
- [x] `web/chat.js`:`pointerdown` 排除 `summary`;`removeWindow` / `retryReplies` 清理 map
- [x] `web/chat.css`:替换 `.chat-bubble-thinking` 为 `.chat-think*` 样式组
- [x] 手动回归:单卡 / 双卡、盲测、流式、复制、移除模型
## Verification
1. 语法自检:`node --check web/chat.js`(浏览器 ESM,仅查语法;或 `npx esbuild web/chat.js --bundle=false --loader=js > $null`)。
2. `npm run web` 打开 `http://localhost:<port>/chat.html`(端口见 server 启动日志):
- 普通对话,模型有思维链 → 卡片顶部只有一行 `思考过程 · N 字`,正文直接从顶部开始;点开后思维链以 markdown 展开、再点收起。
- 打开浏览器 DevTools Network 慢速/用长思考模型,观察流式:正文未到时折叠条自动展开、文本在增长;第一段正文到达后自动收起;此时再手动点开应一直保持展开直到本轮结束、且不被后续流式刷新冲掉。
- 双模型并排 + 盲测(「模型 1」标签):折叠/展开两张卡互不影响;展开后卡片内部可滚动,「展开/收起」按钮行为不变。
- 右上角「复制」→ 粘贴检查内容仍含思维链 + 正文。
- 「移除」某模型、「重试」失败卡片后,展开状态不串卡。
- 关闭复制模式(copy-off)点折叠条仍能正常开合、不触发拖动。
3. 刷新页面重新加载历史会话(`thinking` 由服务端持久化)→ 仍是默认折叠。
+92 -57
View File
@@ -1,84 +1,119 @@
# Writing Agent 使用说明
# Writing Agent
解压或下载完成后,按下面的顺序即可:双击启动、打开浏览器、写一张最简单的卡,然后游玩一句。
本地运行的多 Agent 文本创作与游玩平台:先为一部作品搭好可复用的运行规格,再进入游玩——你说一句,它按规格写一轮。适合交互式角色扮演、短局/长文、仿写扩写等场景。
需要先安装 [Node.js](https://nodejs.org/)(勾选安装时附带的 npm)。第一次启动会自动安装依赖,请等黑窗口跑完。
数据默认落在本机用户目录(`~/.writing-agent`),通过浏览器或 Electron 桌面窗口使用;模型走你自己配置的 API。
## 1. 双击 `start.bat`
## 它能做什么
在文件夹里双击 `start.bat`。会弹出一个黑窗口,并自动打开浏览器。地址是:
- **创作**:为新作品选进料方式(工序编排 / 对话落盘(默认选项))与配方,由编排器调度技能,落成可复用的运行规格与开场
- **游玩**:在钉住的规格下回合推进;可切换呈现壳,保存游玩进度
- **技能包**:能力以磁盘上的技能包装载(当前主包为「世界模拟器」相关配方与模块)(目前不需要手动选择)
- **预设与库**:兼容 SillyTavern 风格的提示词预设;文风库 / 偏好库可注入创作节点,种子可从仓库恢复
- **预设工作台**:`/presets.html` 内编辑模块与生成参数、版本树分叉、结构检查、用例组与多模型试跑(见 [preset-workbench.md](docs/preset-workbench.md))
- **多接口**:分组管理 API、嗅探型号、切换同一接口下的模型
- **本机优先**:Web(`localhost:23337`)或 `start-app.bat` 桌面壳;运维可用 `console.bat`
http://localhost:23337
## 快速开始
窗口不要关。关窗口或按 `Ctrl+C` 就是停止软件。
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/01-start.png)
完整图文步骤(含截图与示例提示词)见 **[从启动到开玩](docs/guide/getting-started.md)**。
想用桌面窗口而不是浏览器,改双击 `start-app.bat`。后面的操作一样。
## 2. 第一次要填模型接口
点右上角 **设置**,再点 **新增**。填上这四项后点 **保存并选用**:
- 名称:自己认得就行,例如 DeepSeek
- Base URL:接口地址,例如 `https://api.deepseek.com/v1`
- API Key:你的密钥
- 型号:这一行要用的模型名,例如 `deepseek-chat`
填完型号后也可以点 **嗅探**,从列表里点一个型号。
![新增 API](docs/guide/03-api.png)
项目根目录如果已经有填好的 `.env`,启动时会自动带上,这一步可以跳过。
## 3. 新建作品
还没有任何作品时,打开页面就会弹出 **新建作品**。输入名称,点 **创建**。以后再开新的,点左侧 **+**。
![填写作品名](docs/guide/04-new-book.png)
这里用「雨天小书店」做例子。
## 4. 写一张最简单的卡
在底部输入框里原样贴上这段,点发送(或按 Enter):
## 核心回路
```text
请写一张最简单的可玩卡,写完就停。
题材:雨天小书店。玩家是进来躲雨的客人,店员是一只戴蓝色围巾的猫。
只要短对话。不要数值、地图、复杂世界观。
开场只要两三句:雨还在下,猫店员抬起头。
新建作品 → 选进料方式与配方
→ 创作(编排技能 / 对话落盘)→ 运行规格 + 开场
→ 保存定稿
→ 游玩(执行单元按规格上场)→ 保存游玩
```
![把这段话放进输入框](docs/guide/05-prompt.png)
术语以 `[docs/ui-glossary.md](docs/ui-glossary.md)` 为准(进料方式、配方、编排器、技能、运行规格、执行单元)。
等它写完。中间的回复会说明卡已经好了,右侧 **已持久化** 是落下来的几张卡片。
## 仓库一览
![写好的卡](docs/guide/06-card.png)
写完它如果还追问,回复「都不用再加,写完开场就可以开玩」。
| 路径 | 用途 |
| ------------------- | ------------------------------ |
| `src/` | 运行时、会话、技能装载、Book/预设/库、HTTP API |
| `web/` | 前端静态页 |
| `skills/` | 技能包(见 `skills/README.md`) |
| `examples/presets/` | 示例预设 |
| `seeds/libraries/` | 文风/偏好库种子 |
| `electron/` | 桌面壳 |
| `docs/` | 架构与契约文档 |
| `tests/` | Vitest |
## 5. 保存并开玩
右上角出现 **保存定稿** 时,点它,给这份稿起个名字。没有这个按钮时,点输入框右侧的保存图标,效果相同。
用户数据目录可用环境变量 `WRITING_AGENT_DATA_DIR` 覆盖;默认 `~/.writing-agent`(Windows 多为 `%USERPROFILE%\.writing-agent`)。
然后点左侧的作品名,在弹出的菜单里选 **游玩**。
## 代理与网络
会先看到开场白:
国内直连 `api.deepseek.com` 就够了,不需要任何代理。若要用 OpenRouter、Gemini 等境外接口,**不要依赖 Windows「系统代理」—— Node 的内置 `fetch` 既不读系统代理,也不读 `HTTPS_PROXY`**。
![进入游玩后的开场](docs/guide/07-play.png)
在底部写你要做的事,再发送。例如:
本仓库自带一层进程级代理引导(`src/config/network.ts`):启动后自动探测本地代理内核,探到就把本进程的 `fetch` 与 WebSocket 都交给它,探不到就静默退化为直连。
```text
我收起伞,问店员今天适合躲雨看的书。
本仓库出网 ──→ 本地代理内核(默认 127.0.0.1:7890)──→ 内核「规则模式」自动分流
├─ 国内域名 → 直连
└─ 境外域名 → 代理节点
```
它会接下去写这一轮:
**这意味着不需要手动切开关**:`api.deepseek.com` 始终直连不减速,`openrouter.ai` 自动走节点,以后新增任何接口也不用改代码。
![说完一句之后](docs/guide/08-play-reply.png)
因为它只作用于本进程 —— 不改系统代理、不装虚拟网卡、不动路由表 —— 所以可以和 UU 加速器、Radmin VPN、ZeroTier 同时开,不会互相抢路由。
之后就一直这样:你说一句,它写一轮。右上角 **保存游玩** 可以把进度存下来。
| 环境变量 | 作用 |
| --- | --- |
| (不设置) | 自动探测,默认目标 `http://127.0.0.1:7890` |
| `WRITING_AGENT_PROXY=http://host:port` | 指定代理 |
| `WRITING_AGENT_PROXY=off` | 完全直连 |
| `HTTPS_PROXY` / `ALL_PROXY` | 未设上面变量时的回退来源 |
想换你在故事里的名字,到 **设置 → 用户角色** 里新增一个角色并选用。创作时如果希望开场先写占位,把角色名写成 `@玩家`。
> 代理内核需保持运行。启动时若探不到,本次运行全程直连(stderr 会提示一行)。
## 维护者
开发与排障常用命令:
```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) | 预设格式 |
| [preset-workbench.md](docs/preset-workbench.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`)。
+64
View File
@@ -0,0 +1,64 @@
# 本局游玩选项
> 状态:已定,未实现。
> 记录:2026-10-11。
创作收口后点「保存定稿」,保存成功再弹出本局选项。选项跟书走,写在 `books/{id}.json`,不跟模型档案,也不进全局 `settings.json`。
换模型是临时的,这本书的楼层是固定的。选项若跟模型走,切模型时上下文会突然展开或收起。全局一个开关则短篇试玩和长书只能共用一套策略。
## 何时出现
```text
创作收口 → 点「保存定稿」→ 定稿写入成功 → 弹出「本局游玩选项」
```
保存失败不弹。关掉弹窗等于沿用这本书上已有的值;新书没有写过时,各项用下表默认。
同一张面板以后可以从这本书再打开,用来改已保存的选项。改动从下一轮模型调用生效,不要求重存一份定稿。
## 存哪一层
| 东西 | 落点 |
|------|------|
| 开关、间隔、以后的卫星开关 | 这本书(`books/{id}.json`) |
| 已经写好的总结段落 | 这条游玩线的历史里,随 run 存档 |
| 当前选用的模型 | 不读、不写这项 |
一本书可以有多条游玩线。开关是书级的,各条线共用;每条线自己的总结段落不互相覆盖。
关掉总结后,已经写过的段落留在书里,只是不再送进模型。再次打开时从上次停下的楼层接着写,不把旧总结再总结一遍。
## 第一项:历史滚动总结
游玩时,每隔 N 层楼,把更早的原文楼层换成一段新总结。已有总结原样保留,程序不得把它们再送去改写。
| 字段 | 默认 | 含义 |
|------|------|------|
| `historySummary.enabled` | `false` | 新书关闭。打开后才按间隔写总结并送进上下文 |
| `historySummary.everyFloors` | `6` | 每隔多少层替换更早的原文。可调范围 4~8;一回合往往是一整场,不宜每层都总结 |
| `historySummary.keepRawFloors` | `4` | 始终保留的最近原文层数。可调范围 2~4 |
打开后,送进模型的历史段顺序是:
```text
稳定设计产物
→ 已冻结的总结段落(旧段不改写)
→ 最近 keepRawFloors 层原文
→ 变量与本轮用户输入
```
关闭时维持现在的做法:`对话.历史` 按 projection `summary` 保留最近约 20 段原文,不调用总结。
这和 `docs/briefs/dictate-only-creation.md` §10.4 的「写大总结 / 清空旧楼层」不是同一件事。那边是游玩中手动两步,且清空会删楼层。本项只替换模型看到的更早楼层,书上的原文楼层仍留着。
## 以后加在同一张弹窗里
新的本局开关加一行,仍然只写这本书。不为此另开页面,也不按模型分叉。
当前预留、尚未定义行为的例子:
- 是否启用生图 worker 及其他卫星 worker
- 壳生图(背景、立绘):以后再说,见 `docs/briefs/shell-image-todo.md`
卫星项未实现前,弹窗只显示「历史滚动总结」。
+84
View File
@@ -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 千字,一次写两三份会在产物之上再加一两成,但只发送一次。
Binary file not shown.
@@ -0,0 +1,17 @@
# 创作对话降本:取消同轮 retry + 2 轮 history
> 状态:已落地。承接 `token-cost-report-2026-10-06.md` / `prompt-cache-audit-2026-10-07.md`。
> 记录:2026-10-08。
## 做了什么
1. **取消同轮 `fill-retry`。** fill 只打一枪;未通过项写入 `设计.对话落盘待补`(readTag,不进产物列表),checklist 标 **失败待补**,下轮 select/fill 自动并入。
2. **select / fill 更早对话默认只留 2 轮**(1 轮 = 一问一答,不含本轮末句)。可用 `DICTATE_HISTORY_TURNS` 或 `historyTurns` 参数改;负数 = 不限制。
3. **产物全黑板暂不动**(质量 / play 传导风险;内容侧优化另议)。
4. Gemini 路径上跨轮缓存 ROI 很低;本改动主攻 **少开枪 + 砍无用 history**,不为保缓存去裁产物。
## 验收
- 同书同对话长度:`fill-retry` 调用应接近 0;每轮模型调用中位趋向 **2**(澄清 1;缺 reply_user 时 lean reply 仍可能 3)。
- checklist 可见:待写 / 失败待补;失败回复含「失败待补(下轮继续)」。
- `DICTATE_HISTORY_TURNS=0` 时更早对话段应接近空。
+1 -1
View File
@@ -288,7 +288,7 @@
写开场或结束创作前,用仍未落的「必须」和「有必要」问用户哪些还没设计。同轮写入多个已说清产物时综合回应整轮,不逐模块重复模板。
写正文组成或 0 层正文组成时,回复里直接给出一份隔离内容供预览:与本局前端同一套骨架,示例已经写进去。完整 JSON 仍由产物卡展示。
正文组成、0 层正文组成和开场的画面由产物卡按 frontend 渲染。完整 JSON 仍由产物卡展示。
## 6. 工作包 D · 游玩投影编译〔未开始〕
Binary file not shown.
@@ -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 占比 &lt;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。
+11
View File
@@ -0,0 +1,11 @@
# 壳生图
> 状态:以后再说。记录:2026-10-11。
> 不在每层配图的第一版里做。
呈现壳的美化图(背景、角色立绘等)以后可以走 ComfyUI。和游玩每层配图分开:
- 每层配图跟着回合,挂在对应楼层上,主世界层不读。
- 壳上的图换得更慢,多半在开场或人物、地点稳定之后才出,由前端填进壳里预留的图位。
提示词仍归旁观副 LLM。主世界层不写图片地址,图也不进正文呈现和下一轮上下文。
+219
View File
@@ -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 调用占比 **&lt;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 太大」两件事。
+132
View File
@@ -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 万字还在不在填写请求里。故意不做的项写原因。和第三节、第六节冲突的地方单独列出,不要写进推荐步骤。
+45 -8
View File
@@ -46,11 +46,26 @@ Worker / skill 执行时,Runtime 将黑板 tag 与固定体裁说明拼成 LLM
「对话.历史」是排序表中的普通标签,按 projection 动态裁剪后插入
```
「对话.历史」不只是文本块:装配时它被切成**真多轮 message[]**(user / assistant 轮)进预设的 `chatHistory` 洞;
分袋时它之前的块进 `worldBookBefore`、之后的进 `worldBookAfter`。
`order 0` 建议为槽位人设(`worker.persona`)。
`tier` 仅缓存提示;权威顺序是投影排序表。
**不是** agent 在游玩时自由往 prompt 里插段落;agent 只决定 **invoke 哪个 skill**;该 skill 的契约决定看见什么。
### 2.1 落档不进游玩投影
对话落盘绑定时(`src/dictate/play-bind.ts`)下列 tag **不**写入 `设计.上下文投影排序`:
| 类别 | 例 | 原因 |
|------|----|------|
| 创作中转 `feeds: design_only` | `设计.本局创作方案`、`设计.美学纲领与交互范式` | 只指导后续写作 |
| 呈现壳 `feeds: program` | `设计.正文组成`、`设计.0层正文组成` | 程序持有 html/css/js;模型只经 `contractOutputInstruction` 看见数据契约 |
| 开场 / 编排 | `设计.开场白#*`、`设计.worker集`、排序表自身 | 开场进历史;编排不重复挂 |
游玩填楼时:程序读壳并灌 `present.v1`;主世界/转述 system 里只追加契约区块列表,不附 frontend 源码。
---
## 3. 三分工
@@ -171,19 +186,41 @@ contextIsolation: none | role_pov | blind_review
---
## 9. 拼接结果形态(目标)
## 9. 拼接结果形态
worker 先给出**分段结果**,再由预设决定最终 `messages[]` 的形状:
```text
system:
{shared-context}
{worker SKILL body}
{固定输出协议}
assembleWorkerContextSections()
├─ chunks: block|history|turn (按投影排序表顺序)
└─ tail: 本步任务 / 定稿摘要 / 待改底稿 …
user:
{按 segment 顺序格式化的 Markdown 或结构化块}
workerContextToBags()
├─ before → presetPack.worldBookBefore
├─ history → presetPack.history【真多轮 message[]】
├─ after → presetPack.worldBookAfter
├─ turn → presetPack.currentTurn
└─ postTurn → presetPack.postTurn
PresetLlmProvider(options.presetPack)→ assemblePlayWorkerMessages()
```
实现:`src/skills/context-segments.ts` → `assembleWorkerContext()`;由 `src/worker/executor.ts` 调用。
发出去的形式(有历史时):
```text
system 预设条目(main / 文风 / 破限)
system worldBookBefore:契约 + 史前资料
system worldBookAfter:变量 / 真值
assistant 历史轮…
user 历史轮…
user currentTurn:用户.最新输入
system postTurn:本步任务
```
`flat`(`assembleWorkerContext()` 的返回值)仍保留,作为无 historical 分段时的单包形式;
`contextSegments` 里没有 `对话.历史` 的 worker(创作期产出 JSON 那些)继续走单包。
实现:`src/skills/context-segments.ts` 与 `src/preset/play-frame.ts`;由 `src/worker/executor.ts` 调用。
---
+5 -1
View File
@@ -119,10 +119,14 @@ order 4: 用户.最新输入(full)
- 排序表里出现该 ref 时,按 **projection** 动态裁剪后注入(不是整段永远塞满)。
- 若尚未写入历史,可用 `运行.事件流` + `用户.最新输入` 拼兜底。
- 投影建议:`summary`(默认)/ `fields`(更短)/ `full`(更长)/ `fixed`(几乎不注入)。
- 裁剪是**块级 + 滞回**的(`projectDialogueHistory()`):段数在 n~2n 之间原样全给,超过 2n 才裁回 n。
这样历史 message 数组的首条不是每轮都变,前缀缓存每 n 轮才断一次。
**不要**改回「每轮取最近 n 段」——那会让整段前缀每轮全价重发。
- 投影后的正文会被 `parseHistoryMessages()` 切成真多轮 message,进预设的 `chatHistory` 洞。
### 4.2 拼装
Runtime 按 inserts 数组顺序拼进单条 user 上下文(`assembleWorkerContext` **不再**按 static/dynamic 重排)。
Runtime 按 inserts 数组顺序拼块,再由 `workerContextToBags()` 分袋(before / history / after / turn / postTurn)。
`tier` 仅作缓存提示(历史标签之前偏 static,之后偏 dynamic)。
### 4.3 可编排
+45
View File
@@ -0,0 +1,45 @@
# 直聊
与作品工作台「对话」并列的轻量多轮 LLM 面。请求始终由预设夹心包裹;不改动设置里的预设「试跑」。
## 两层
| 层 | 含义 | 落盘 |
|----|------|------|
| L1 上下文壳 | 系统提示、史前设定、种子历史、史后、尾注;可选绑定 `presetId`(缺省用全局选用预设) | `~/.writing-agent/direct-chats/shells/` |
| L2 会话 | 该壳下的多轮消息;助手消息可带 `contextTrace` 供对比 | `.../conversations/` |
| 临时会话 | 未选存档时的默认快速对话 | 仅浏览器 `localStorage`,最多 **2** 条;可「保存」升为持久 L1+L2 |
面包屑:`直聊 › {壳名\|临时} › {会话名}`。入口页:`/chat.html`。未选任何存档时中间即是快速对话,无单独「快速对话」按钮。
## 多模型窗口(竞技场)
- 标题栏「新增窗口」:中间按模型分栏(LMSYS Arena 式 side-by-side),每列独立对话流。
- 同一用户句在各列各显一份;发送时所有列并行 completion。
- 列内「保留此回复」:写入该轮 `selectedWindowId`;之后各列发下一轮时,本轮助手侧都以该回复进历史。保留后该轮界面只显示被保留的那张卡(再点「已保留 · 取消」恢复全部),下一轮仍是每个模型分别请求。
- 未保留:各列继续用自己的回复拼 prior。
## 请求
- 槽位 → WorldInfoPack;`chatHistory` = 种子历史 + 已落定多轮 transcript;本轮用户输入进 turn。
- 种子历史是**逐条编辑的真多轮**(`seedMessages: {role, content}[]`):界面上一条一个角色下拉 + 一整句正文 + 「+ 加一句」,
正文换行原样保留,不再靠 `## 用户` 块头猜边界。旧盘里的 `seedHistory` 文本在 `getShell()` 读盘时自动迁移:
能切出轮次的迁进 `seedMessages`(历史里的「系统」降为 assistant),切不出的挪进「史前设定」。
- 经 `PresetLlmProvider` 按选用/壳绑定预设夹心后发模型。
- 无选用预设:拒绝发送。
- 无 API Key:可只返回拼装结果。
## 多模型
助手消息保留 `contextTrace` 时,可对多个 API 配置并行重放,再「采用」写入 variants / branchIndex。临时会话需先保存再对比。
## API(节选)
- `GET/POST /api/direct-chat/shells`
- `GET/PATCH/DELETE /api/direct-chat/shells/:id`
- `POST /api/direct-chat/shells/:id/conversations`
- `GET/PATCH/DELETE /api/direct-chat/conversations/:id`
- `POST /api/direct-chat/conversations/:id/messages`
- `POST /api/direct-chat/ephemeral/complete`
- `POST /api/direct-chat/promote`
- `POST .../messages/:mid/compare`、`.../compare/adopt`
+84
View File
@@ -0,0 +1,84 @@
# 从启动到开玩
解压或下载完成后,按下面的顺序即可:双击启动、打开浏览器、写一张最简单的卡,然后游玩一句。
需要先安装 [Node.js](https://nodejs.org/)(勾选安装时附带的 npm)。第一次启动会自动安装依赖,请等黑窗口跑完。
## 1. 双击 `start.bat`
在文件夹里双击 `start.bat`。会弹出一个黑窗口,并自动打开浏览器。地址是:
http://localhost:23337
窗口不要关。关窗口或按 `Ctrl+C` 就是停止软件。
![启动后的黑窗口](01-start.png)
想用桌面窗口而不是浏览器,改双击 `start-app.bat`。后面的操作一样。
## 2. 第一次要填模型接口
点右上角 **设置**,再点 **新增**。填上这四项后点 **保存并选用**:
- 名称:自己认得就行,例如 DeepSeek
- Base URL:接口地址,例如 `https://api.deepseek.com/v1`
- API Key:你的密钥
- 型号:这一行要用的模型名,例如 `deepseek-chat`
填完型号后也可以点 **嗅探**,从列表里点一个型号。
![新增 API](03-api.png)
项目根目录如果已经有填好的 `.env`,启动时会自动带上,这一步可以跳过。
## 3. 新建作品
还没有任何作品时,打开页面就会弹出 **新建作品**。输入名称,点 **创建**。以后再开新的,点左侧 **+**。
![填写作品名](04-new-book.png)
这里用「雨天小书店」做例子。
## 4. 写一张最简单的卡
在底部输入框里原样贴上这段,点发送(或按 Enter):
```text
请写一张最简单的可玩卡,写完就停。
题材:雨天小书店。玩家是进来躲雨的客人,店员是一只戴蓝色围巾的猫。
只要短对话。不要数值、地图、复杂世界观。
开场只要两三句:雨还在下,猫店员抬起头。
```
![把这段话放进输入框](05-prompt.png)
等它写完。中间的回复会说明卡已经好了,右侧 **已持久化** 是落下来的几张卡片。
![写好的卡](06-card.png)
写完它如果还追问,回复「都不用再加,写完开场就可以开玩」。
## 5. 保存并开玩
右上角出现 **保存定稿** 时,点它,给这份稿起个名字。没有这个按钮时,点输入框右侧的保存图标,效果相同。
然后点左侧的作品名,在弹出的菜单里选 **游玩**。
会先看到开场白:
![进入游玩后的开场](07-play.png)
在底部写你要做的事,再发送。例如:
```text
我收起伞,问店员今天适合躲雨看的书。
```
它会接下去写这一轮:
![说完一句之后](08-play-reply.png)
之后就一直这样:你说一句,它写一轮。右上角 **保存游玩** 可以把进度存下来。
想换你在故事里的名字,到 **设置 → 用户角色** 里新增一个角色并选用。创作时如果希望开场先写占位,把角色名写成 `@玩家`。
+1 -5
View File
@@ -5,8 +5,7 @@
> 创作挂钩:`设计.正文组成`(怎么展示)/ `设计.监控栏`(每轮看什么,技能名「回复呈现」);终稿 tag 通常 `输出.用户展示`。
> 各壳介绍与规格:`skills/dialogue/world-simulator/present-shells/`(正文组成执行时动态注入)。
> **创作定位:按回复呈现清单选壳并分区;正文语法见【正文安全子集】(程序动态装载)。**
> 日常折叠/表格走 Markdown 安全子集;一般不写本局任意 CSS/HTML/JS。
> 预览:`/shells.html`
> 日常折叠/表格走 Markdown 安全子集;一般不写本局任意 CSS/HTML/JS。
## 1. 创作怎么做(权威口径)
@@ -30,8 +29,6 @@
| 局面 + 选项 | `choice_dock` |
| 章正文 + 进度 | `chapter_reader` |
预览页 `/shells.html`:主工作面是壳画布;侧栏只换壳;长度/语气在顶栏。
## 2. 信息层级与长文容量
所有壳共用同一优先级——**改壳只是改谁占主面积**:
@@ -127,7 +124,6 @@
| 序 | 项 | 状态 |
|----|----|------|
| 1 | `web/` 壳渲染器 + 层级样式 | **已接** `present-shells.js` + `styles.css` |
| 1b | 壳预览页 | **已接** `/shells.html` |
| 2 | 正文组成微调进 SessionView | **已接** |
| 3 | present.v1 解析 | **已接** `present-packet.ts` |
| 4 | narrator 按壳输出 | **已接** |
+185 -16
View File
@@ -1,10 +1,16 @@
# 预设格式
> 编辑、版本树、用例组与多模型试跑见 [preset-workbench.md](preset-workbench.md)。
## 1. 定位
Preset 是一次 LLM 请求的上下文编排资产。它负责描述 prompt manager 中有哪些条目、这些条目的启用状态与排列顺序,以及本次生成使用哪些模型参数。
第一版的 preset 目标是兼容 SillyTavern 类预设文件中的核心部分,而不是完整复刻 SillyTavern 的所有扩展能力。
第一版的目标是兼容 SillyTavern 类预设文件中的核心部分,而不是完整复刻 SillyTavern 的所有扩展能力。
> 实现状态(v2):
> 已实现 —— prompt manager 条目与顺序、生成参数、双括号宏、消息整形(squash)、工具协议适配、导入诊断。
> 仍未实现 —— ST 正则引擎、预设内 JS 脚本、injection_position=1 的深度插入、世界书关键词/概率/sticky。
需要支持的内容:
@@ -23,10 +29,10 @@ generation parameters
```text
regex_scripts
正则隐藏、正文提取、格式美化等后处理脚本。
正则隐藏、正文提取、格式美化等后处理脚本(导入时只做诊断,不执行)。
extension scripts
预设内携带的前端脚本、按钮、插件配置。
预设内携带的前端脚本、按钮、插件配置(一律不执行,导入报告点名)。
UI-only fields
只影响 SillyTavern 界面展示、不影响 LLM 请求组装的字段。
@@ -105,12 +111,14 @@ type ImportedPromptOrder = Array<{
第一版采用的规则:
```text
1. 以 prompt_order[0].order 作为主顺序。
1. prompt_order 可能有多套(按 character_id 分)。优先 character_id=100001(酒馆里通用于所有角色的那套);
它不存在时才按「启用内容最多」打分。
2. 按 order 数组顺序遍历 identifier。
3. 找到对应 prompts 条目。
4. 只有 order.enabled 和 prompt.enabled 都为 true 时,条目才参与本轮上下文装配。
5. 如果 prompt_order 引用了不存在的 identifier,导入时记录 warning,但不阻断导入。
6. 如果 prompts 中存在但 prompt_order 未引用,默认不参与请求,但保留在 preset 中。
7. 选中的那一套若缺 main / chatHistory / jailbreak,导入时告警(框架可能不完整)。
```
`prompt_order` 比 `prompts` 中的排列更重要。`prompts` 是条目仓库,`prompt_order` 才是实际启用的编排表。
@@ -231,7 +239,7 @@ scenario worldInfoAfter dialogueExamples chatHistory
| identifier | 填什么 |
|---|---|
| `worldBookBefore` | `对话.历史` 之前的 inserts,每条为标题+正文 |
| `chatHistory` | 共用酒馆 id;填 `对话.历史` |
| `chatHistory` | 共用酒馆 id;填 `对话.历史`——**展开成真多轮 message[]**,不是一段文本 |
| `worldBookAfter` | 历史之后、本轮之前(变量、工单等)。**不是** `worldInfoAfter` |
| `currentTurn` | `用户.最新输入`,role=user |
| `postTurn` | 本轮输入之后的 inserts(可空) |
@@ -257,7 +265,6 @@ postTurn
社区预设里包着 WI 洞的装饰分隔会留在历史前段,中间可能是空的。第一版接受。
### 4.3 六段夹心(扫 order 后的效果)
```text
1. 酒馆内、历史前(ST 普通条目)
2. 我们写的、历史前(worldBookBefore)
@@ -276,11 +283,122 @@ postTurn
4. 再过滤 entry.enabled=false 的条目。
5. 对 marker:用 WorldInfoPack 解析我们的四个洞 + chatHistory;酒馆角色卡/WI 恒为空。
6. 普通 content 条目原样插入。
7. 按条目 role 合成 LLM messages。
8. 应用 generation 参数。
7. 按条目 role 合成 LLM messages(chatHistory 洞展开成多条)。
8. injection_position=1 的条目插进历史区(见 4.4.1)。
9. 应用 generation 参数。
```
不实现:世界书关键词、概率、sticky、Depth 插进历史中间、outlet、正则、插件脚本。
不实现:世界书关键词、概率、sticky、outlet、ST 正则引擎、插件脚本。
#### 4.4.1 深度插入
`injection_position = 1` 的条目**不进线性位置**,而是按 `injection_depth` 从历史底部往上插:
```text
depth 0 历史最后一条之后(紧贴 currentTurn 之前)
depth N 倒数第 N 条之前
```
同深度按 `injection_order` 升序排列(数值大的更靠后)。插进去的 message 与历史同等待遇:
`injection_order` 默认 0,`role` 取自条目自身。无历史区时(单包请求)按收集顺序附在末尾。
### 4.5 双括号宏
预设正文在装配前过一遍宏层(`src/directives/macros.ts`),与 `@` 指令共用 persona 与骰子引擎。
| 宏 | 行为 |
|---|---|
| `{{// … }}` | 注释,展开为空(多行也吃) |
| `{{trim}}` | 展开为空,并让本条首尾去空白 |
| `{{user}}` | 当前用户角色名 |
| `{{persona}}` | 当前用户角色(`@人设`)的详细人设正文;未设 = 空 |
| `{{char}}` | 预设角色名;取当前作品名(对话落盘时的快照名),没绑作品时用「作品」 |
| `{{lastUserMessage}}` | 最近一条用户输入 |
| `{{getvar::k}}` | 变量值;未设 = 空 |
| `{{setvar::k::v}}` / `{{addvar::k::v}}` | 写入变量,展开为空 |
| `{{roll:1d6}}` / `{{roll N}}` | 掷骰 / 1..N 随机 |
| 其它(如 `{{charPrompt}}`、`{{description}}`) | **原样保留**,导入报告点名 |
变量作用域是**单次装配**一份 Map,不落盘、不跨轮。依据:6 份真实预设里所有 `setvar` 要么在每次请求都执行的条目里,要么在关闭的条目里 —— 单次作用域与原行为等价。
展开后变空的条目不入 messages(只写了 `{{setvar}}` 或纯注释的条目会被跳过)。
### 4.6 消息整形(squash)
对应酒馆的 Squash System Messages,在夹心完成后、发请求前执行(`src/llm/message-squash.ts`)。
#### 酒馆到底提供了什么(核过源码)
```text
squash_system_messages bool,默认 false
true = 把相邻的、没有 name 的 system 消息合并成一条
(exception: newMainChat / newChat / groupNudge)
出处:public/scripts/openai.js → ChatCompletion.squashSystemMessages()
custom_prompt_post_processing none / merge / strict(claude 已弃用 → merge)
管的是「自定义 system prompt 那几段是合成一条,还是分开发」,
与 squash 是两个设置,不碰 user/assistant 轮次。
```
所以酒馆侧只有两档真实语义:**不合并** / **合并相邻 system**。
我们多出的 `semi` / `strict` / `single-user-turn` 是为「上游拒收 system 或连续同角色」自造的降级档。
#### 我们的五档
| 档 | 行为 | 对历史区 |
|---|---|---|
| `none` | 原样(= 酒馆默认) | 不动 |
| `merge` | 只合并相邻同角色(= 酒馆勾上 squash) | 全序列合并 |
| `semi` | 合并后把 system 折进相邻普通消息,强制 user/assistant 交替 | 只合并相邻同角色 |
| `strict` | 同 semi,并保证首条为 user | 只合并相邻同角色 |
| `single-user-turn` | 全部合成一条 user | 原样保留 |
约束:
```text
工具调用组(assistant(tool_calls) + tool)永不参与合并与重排。
请求里出现工具组时,semi / strict / single-user-turn 一律退化为 merge。
历史区(真多轮)最多只做「合并相邻同角色」:
用户连发两条(上一轮助手没出正文)→ 合成一条,结构不破;
injection_depth 插进历史的 system → 不被折掉;
single-user-turn 不会把历史堆成一条。
```
来源:预设的 `squash_system_messages`(bool 或枚举)→ `PresetPackage.messagePolicy.squash`;
字段缺失时不写 `messagePolicy`,运行时按 **`strict`** 处理(`DEFAULT_PRESET_SQUASH`)——
兼容性优先:各家上游对「首条 system + 连续同角色」的容忍度不一,默认取最保守的一档。
想只合并 system(更贴酒馆)选 `merge`,想原样发出选 `none`;单个预设可在预设里自己写。
### 4.7 工具协议适配
两份真实预设把破限写在工具协议里,形态有两种:
```text
SPreset.ToolBindings 明确声明(含结构化 form.name / form.parameters)
条目正文占位符 %TOOL_NAME% + stream_target = display_stream 之类
```
导入时只做诊断(`src/preset/diagnostics.ts`):列出伪工具协议条目、待映射占位符、已声明工具。
运行时按 `toolPolicy` 处理(`src/preset/tool-policy.ts`):
| 模式 | 行为 |
|---|---|
| `adapt`(默认) | 把预设要求的工具折算到已有工具:给宿主工具追加描述与兼容字段;工具名对不上时另挂一个同名载荷工具,收到调用再折算回宿主 |
| `app` | 只发本应用工具,并把伪工具协议条目从装配中剔除 |
| `preset` | 只发预设声明的那个工具(直聊/纯 RP) |
正文字段从预设里反推:只认「被赋值」的名字(`stream_target = display_stream`)与 ToolBindings 的参数名,
不把正文里偶然出现的词当字段名。工具描述只取 ToolBindings 里作者写的那句,
**不把条目正文抄进工具描述**(那会把 prompt 体积翻倍)。
### 4.8 截断保留
正文走工具参数,`maxOutputTokens` 到顶时 `arguments` 是半截 JSON,
直接 parse 会把已经写好的正文整段丢掉。`src/llm/truncation.ts` 在参数无法解析时
把目标字段已生成的部分拨出来(只比截断处少一截),并记一行日志。
它在工具调用、消息整形、宏层全部完成后才执行,所以只救正文,不改变协议。
## 5. 与创作流程的关系
@@ -310,22 +428,73 @@ Runtime Session
```text
保留 prompts。
保留 prompt_order。
保留 prompt_order(多套时优先 100001)。
保留生成参数。
记录但忽略 regex_scripts。
记录但忽略 extension scripts。
读取 squash_system_messages → messagePolicy。
记录但忽略 regex_scripts(只做诊断)。
记录但忽略 extension scripts(只做诊断)。
记录未知字段,不丢弃原始 JSON。
```
导入结果应该给用户可读的报告:
导入结果应该给用户可读的报告(`PresetImportReport`):
```text
导入 prompt 条目数量
prompt 条目数量
启用条目数量
未被 prompt_order 引用的条目数量
缺失 identifier 的 order 项
选中的 order 属于哪个 character_id;缺哪些核心位置(main / chatHistory / jailbreak)
已读取的生成参数
被忽略的扩展字段
诊断五项:
· 伪工具协议条目 + 待映射占位符 + 已声明的工具
· 跨条目标签配对(合并消息会打散的那些)
· 宏清单:哪些会展开、哪些会原样外发
· 随机宏相对 chatHistory 的位置(在历史前会每轮击穿前缀缓存)
· 静默失效:injection_position=1 的启用条目、脚本里没被替代的能力
```
这个报告比静默导入更重要。预设文件经常很大,且混有脚本、正则、UI 配置和模型参数,必须让用户知道哪些内容真正进入了我们的运行时。
这个报告比静默导入更重要。预设文件经常很大,且混有脚本、正则、UI 配置和模型参数,必须让用户知道哪些内容真正进入了我们的运行时。
## 7. 保真度矩阵(预设到底实现了没有)
对应测试:`tests/preset-conformance.test.ts`(预设声明什么,发出去的 `messages[]` 就得是什么)。
### 7.1 已实现
| 预设里的东西 | 行为 | 实现 |
|---|---|---|
| `prompts[].identifier / content` | 原文入装配(宏展开后) | `src/preset/importer.ts` → `assembler.ts` |
| `prompts[].role` | 逐条保留(squash 可能按预设要求改角色) | `assembler.ts` |
| `prompts[].enabled` × `prompt_order[].enabled` | 两级过滤,两者都 true 才发 | `assembler.ts` |
| `prompt_order[].order` | 权威顺序(`prompts` 数组顺序无效) | `assembler.ts` |
| 多套 `prompt_order` | 优先 `character_id=100001`,否则按启用内容最多打分 | `importer.ts` |
| 未被 order 引用的条目 | 不发(仍存于预设) | `assembler.ts` |
| `injection_position=1` | 按 `injection_depth` 从历史底部插入;`injection_order` 排同深度次序 | `assembler.ts` |
| `chatHistory` | 展开成真多轮 `message[]`(role 来自 `## 用户/## 助手` 块) | `dialogue-history.ts` |
| `personaDescription` | 填当前用户角色人设 | `play-frame.ts` |
| 双括号宏 | `{{//}} {{trim}} {{user}} {{persona}} {{char}} {{lastUserMessage}} {{getvar}} {{setvar}} {{addvar}} {{roll}}` | `directives/macros.ts` |
| 生成参数 | `temperature / top_p / top_k / min_p / frequency_penalty / presence_penalty / repetition_penalty / openai_max_context / openai_max_tokens / stream_openai / verbosity / seed / n` | `importer.ts` → `generation-compat.ts` |
| `squash_system_messages` | `true → merge`、`false → none`、枚举/序号直读;缺失 → `strict` | `importer.ts` / `types/preset.ts` |
| 伪工具协议 | `toolPolicy` 三档(`adapt` / `app` / `preset`)折算到已有工具 | `preset/tool-policy.ts` |
### 7.2 不实现(会静默变弱,导入报告点名)
| 预设里的东西 | 为什么 | 影响 |
|---|---|---|
| `regex_scripts` | 不执行 ST 正则引擎 | 正文抽取/隐藏/美化那层没有;只在诊断里列出 |
| `extensions` / 插件脚本 / 按钮 | 不执行前端脚本 | 脚本类功能全无 |
| 世界书关键词 / 概率 / sticky / outlet | 我们用自己的黑板 tag | 用世界书关键词触发的条目不会自动出现 |
| `charDescription` / `charPersonality` / `scenario` / `dialogueExamples` / `worldInfoBefore` / `worldInfoAfter` | 这些洞恒空(跳过) | 靠角色卡占位的预设会变薄;角色卡主要靠 `personaDescription` + 我们的 `worldBook*` 洞 |
| `forbid_overrides` / `system_prompt` 等 UI 字段 | 保留字段,不参与装配 | 无影响 |
| 未列在宏表里的 `{{...}}` | 原样外发 | 模型会看到宏原文(`{{charPrompt}}` 等) |
| `injection_position` 非 0/1 | 按普通位置发 | 少见 |
| 预设里的 `reasoning_effort` | 读入但发送前剥离 | 思考强度只跟 API 配置 |
### 7.3 怎么自己验
```text
1. 单元:npx vitest run tests/preset-conformance.test.ts
2. 试跑:设置页 → 预设 → 试跑,看「实际发出」的 messages 列表(system/user/assistant 逐条)
3. 请求痕迹:每条回复上挂的 contextTrace.messages 就是发出去的那一份
4. 成本:npm run stats:cache —— 组内首次 vs 组内后续的 cached/prompt
```
+183
View File
@@ -0,0 +1,183 @@
# 预设工作台
本仓库自用的预设编辑 / 试跑页面:`/presets.html`。它只编辑本仓库的原生 `PresetPackage`,
不做 SillyTavern 兼容导入(导入仍在设置页);目标是把「改一版 → 看结构 → 看实际 messages →
多模型试跑 → 标记好坏」压在同一页里。
## 1. 页面分工
页面是「一条身份条 + 两个视图 + 两个抽屉」。编辑与试跑是两件不同任务:编辑要连续写,试跑要宽度做对照,
因此不挤在同一个视口里。
```text
身份条 预设选择 / 新建 / 改名 / 脏状态 / 重新载入 / 试跑入口 / 版本树入口 / 保存两档 / 应用到对话页
唯一脏状态、唯一 primary(应用到对话页)都在这里,任何视图都能看到。
保存两档:「保存至当前版本」覆盖当前节点,「保存为新版本」追加子节点。
版本 id 不上条(只做 title),要看序列去版本树。
编辑视图 左轨(240)发送顺序大纲:序号 1..N、启用开关、位置标记、错误红点;
行可直接拖动改顺序(落点画一条强调色横线),标记洞也能拖——左轨顺序就是装配顺序;
未加入顺序的模块折叠在底部;最后两栏是「生成参数」与「假历史」。
中区 当前模块正文(一次只编辑一个;标记洞只显示说明,不能改名/删除,但能移顺序)。
「假历史」中区是逐条编辑:每条一个角色下拉 + 一整句正文 + 加一句 / 删除 / 上下移。
右栏(380)实际发出的 messages:顶部是结构检查状态带 + 用例选择 + 随时重算。
改正文、切模块、切用例都会本地重算预览(不调模型)。
试跑视图 顶部运行栏一行:当前用例名 · 模型(下拉多选:勾选不关菜单,可全选可用 / 清空)
· 已选条(只占一行,超出可左右拖,点一项取消) · 「运行此用例」。
左侧用例列表(一条 = 一个用例,点开进大浮窗编辑),右侧全宽结果网格(同一用例 × 多模型对照)。
低频入口:身份条上的「试跑 · 上次结果摘要」按钮,进入后按钮变成「← 编辑」。
抽屉 版本是右侧抽屉(版本树 / 说明 / 保存 / 载入 / 分叉);
用例表单是**大浮窗**(左列 5 个字段,右列历史条目编辑器),字段多、历史要宽度,
不再挤在窄侧栏里。两者都是低频或临时的,不常驻占内容宽度。
```
编辑视图一屏不滚:大纲、正文、预览各自滚动。预览在 ≤1180px 退成右侧浮层,≤860px 大纲也退成浮层。
## 2. 草稿、版本与应用
```text
草稿 页面内存里的 state.preset;每次编辑只改这里。
重新载入 丢掉草稿,从 presets/<presetId>.json 重读;不动版本树。
保存至当前版本 覆盖当前节点(amend),不新增节点。
保存为新版本 把草稿作为子节点追加到版本树,当前节点移到新节点。
载入选中 把选中节点的完整快照载入草稿,并把当前节点切过去。
从选中分叉 用当前草稿在选中节点下建子节点,原分支不动。
应用到对话页 先保存为新版本,再写 presets/<presetId>.json;对话页从这里读。
```
版本节点保存的是**完整 PresetPackage 快照**,不是 diff;因此任意节点都可独立载入。
版本树文件:`<data>/presets/versions/<presetId>.json`。新建预设会同时落预设文件和根版本。
版本抽屉里是 git 式的树:竖线是分支,圆点是节点(实心 = 当前编辑位置),分叉另开一条线。
点任一节点弹菜单:「读取」把这个快照换进编辑器,「从它分叉」用当前草稿在它下面开新分支,
再往右是「复制 id」。底部四个按钮(保存为新版本 / 保存至当前版本 / 载入选中 / 从选中分叉)保留给键盘流。
两种保存别搞混:
```text
保存为新版本(Ctrl+S) 在当前位置下追加子节点;历史留着,能回滚。
保存至当前版本(Ctrl+Shift+S) 直接覆盖当前节点,不产生新节点。适合“这条还没定,先改它”。
应用到对话页 先保存为新版本,再覆盖 presets/<presetId>.json。对话页读的是这个文件。
```
装配只看 `prompt_order.enabled`;`prompts[].enabled` 是酒馆遗留字段。工作台的模块开关
写的是顺序开关,因此和真实发送一致。
### 2.1 左轨定死顺序
```text
左轨顺序 = 装配顺序 = 真实发送顺序。
装配(src/preset/markers.ts: applyExtendedMarkers)只补缺不重排:
缺哪个洞就按锚点插进来(历史前 → 对话历史 → 历史后 → 本轮对话 → … → 本轮之后),
已经在 prompt_order 里的条目一个不动,作者拖到哪儿就发在哪儿。
右侧预览只是展示:同一条装配路径 + 预设的「消息整形」。整形会合并消息(`strict` 默认),
条数变少时预览元信息会写「整形 strict(装配 N 条 → 发出 M 条)」;
想逐条原样发,把「生成参数 → 消息整形」改成 `none`。
```
## 3. 结构检查
不调用模型,只查会在装配 / 发送时出问题的结构:
- 顺序引用了不存在的模块、同一模块在顺序里重复、顺序号重复;
- 未启用 `chatHistory`、模块为空、全角 `{{`;
- 未闭合的 `{{`(`{{{...}}` 的首个 `{` 是字面量,不算未闭合);
- 未识别宏(会原样外发);
- 标记位置重复启用。
`error` 只阻止「应用到对话页」,不阻止保存版本 / 预览;需要时可以强制写回。
## 4. 用例
一个用例就是一条记录:自己存、自己运行。页面上不出现「组」这个层级。
**用例是全局的,不和预设绑定**:一个用例只描述「要测的输入 + 上下文」,任何预设都能拿它去跑。
盘上是 `<data>/presets/tests/global/*.json`(沿用旧 `TestCaseGroup` 形状,每份用例一个只装它的组文件);
旧的按预设分目录存的(`tests/<presetId>/*.json`)会在第一次访问时并进 global,原文件不删。
删掉最后一份用例时组文件一并收掉。
「这个预设上次打开的是哪条用例」另外存:`<data>/presets/lab-state/<presetId>.json`,
记 `selectedCaseId`(运行视图当前用例)与 `previewCaseId`(右栏预览用例)。
写盘而不是塞浏览器缓存 —— 换浏览器、重开页面都还是同一条;切回别的预设又回到它自己那条。
用例列表的点击分工:**点卡片 = 选中(设为当前用例)**,只有卡片右边的「编辑」才打开用例编辑浮窗。
一个用例只包含变动项:
```text
用例名 / 角色名({{char}}) / 角色描述 / 世界书文本 / 用户输入 / 可选历史
```
历史是**逐条编辑**的:每条一个角色下拉(`user` / `assistant`)加一整句正文,正文里的换行就是真换行,
不再用「每行 `user: 内容`」的文本行和 `\n` 转义(旧写法会把不带头部的续行吞掉)。
落盘仍是 `{role, content}[]`,装配时一条一个 message,接在预设假历史之后。
组装走线上同一条路径:`assemblePlayWorkerMessages` → `shapeFramedMessages`。
## 4.1 假历史与示例对话是两回事
```text
假历史 预设级、真多轮(user / assistant),进「对话.历史」区,排在真实历史之前。
模型会把它当成已经说过的话,会占轮次;编辑器里正文换行原样保留。
存储:PresetPackage.pseudoHistory,装配见 src/preset/pseudo-history.ts。
示例对话 酒馆 mes_example(<START> 分块),发给模型时是 system + example_user /
example_assistant,不占轮次,只做 few-shot;正文仍是整段文本,保持酒馆兼容。
```
两者互不覆盖:要「让模型以为前情已经发生」用假历史,要「示范写法但不冒充轮次」用示例对话。
三者(预设假历史 / 用例历史 / 直聊种子历史)用的是同一个控件:`web/history-editor.js` + `web/history-editor.css`。
每行左侧的 `⠿` 是拖动手柄:按住拖到另一行的上半 / 下半即插到它前面 / 后面,松手就到位;
↑↓ 按钮保留(精确微调 / 不用鼠标拖时)。
## 5. 预览与多模型运行
```text
预览 不调用模型;显示当前草稿 + 当前用例实际发出的 messages。
运行 每个 用例 × 模型 发一次;默认最多 4 路并发、单次 180 秒超时。
运行使用请求里的草稿,所以不必先保存版本。
```
运行记录:`<data>/presets/runs/<presetId>/<runId>.json`,保存 `presetVersionId`、每次运行的
`messages` 快照和每个模型的结果。结果不自动评分,人工标记成功 / 失败;标记会落盘。
历史运行可在结果卡上方切回,并展开「发送内容」核对实际请求。
## 6. HTTP 接口
前缀 `/api/preset-lab/`:
```text
GET presets 预设列表
POST presets/create 新建原生预设 + 根版本
GET preset?presetId= 读预设文件
POST preset/apply 应用到对话页({force:true} 跳过 error)
GET versions?presetId= 版本树
POST versions/commit 追加节点(缺省父节点=当前;指定历史节点=分叉)
POST versions/amend 覆盖当前节点(保存至当前版本)
POST versions/checkout 切换当前节点
POST check 结构检查
POST preview 按用例组装 messages
POST run 多模型运行(可带 preset 草稿;一次跑一个用例)
GET cases 使用例列表(全局,不看 presetId)
POST cases/save 存单份用例(带 groupId 就写回原文件)
POST cases/delete 删单份用例(空文件自动收掉)
GET lab-state?presetId= 这个预设上次打开的用例
POST lab-state 写 { presetId, selectedCaseId?, previewCaseId? }
GET groups 旧接口:测试组列表
POST groups/save 旧接口:整组覆盖
POST groups/delete 旧接口:删组
GET runs?presetId= 运行记录列表
GET run?presetId=&runId= 单次运行
POST verdict 修改人工标记
GET profiles 可选 API 配置
```
## 7. 已知边界
- 版本树不做自动合并;分叉后各分支独立。
- 运行是普通 HTTP 请求,页面会一直等到全部结果返回;没有逐条流式进度。
- 「应用到对话页」只改预设文件;要让已有会话立刻使用,去设置页点「启用」。
- 测试用例不会自动生成,也不会自动评分。
+2
View File
@@ -125,6 +125,7 @@
| Intake | **需求描述** | 启动填空 | — |
| Burst | **本轮调度** | — | 少对用户说 burst。 |
| Questions card | **询问卡** | Questions | 详见 `ui-design.md` §8。 |
| Direct chat / `direct-chat` | **直聊** | — | 与作品「对话」并列的轻量多轮 LLM 面;两层(上下文壳 → 会话)。见 `direct-chat.md`。 |
---
@@ -206,6 +207,7 @@
| `interaction` | 交互范式(旧;已并入上一行) |
| `narrative_guide` | 叙事指南与故事推进 |
| `narrative-guide` | 叙事指南(对话落盘) |
| `image-prompt` | 画面与生图(对话落盘) |
| `story-progression` | 故事推进(对话落盘) |
| `user-requirements` | 用户需求(偏好库选用) |
| `input_protocol` | 输入协议 |
+3 -2
View File
@@ -5,7 +5,7 @@
> **标准范例**:`modules/aesthetics-interaction/prompt.md`。
> **真值 / Data / Progressive(变量与门控)**:**`docs/progressive-data-design.md`**。
> **上下文片段 / 槽位 / 投影排序**:**`docs/context-fragment-design.md`**。
> **游玩呈现壳**:**`docs/play-presentation-shells.md`**(预览页 `/shells.html`)。
> **游玩呈现壳**:**`docs/play-presentation-shells.md`**。
> **主世界层↔旁观怎么配合**:**`docs/play-dm-auditor.md`**。
> **提示词待改清单**:**`docs/briefs/prompt-revision-inventory.md`**。
> **design-flow 提示词人工审查**:**`docs/briefs/design-flow-prompt-review.md`**。
@@ -173,12 +173,13 @@ recipes/world-simulator|expand-assistant/recipe.yaml
|------|-----|------|
| 美学纲领与交互范式 | `aesthetics-interaction` | **范例已写**;前端 mosaic 视图 |
| 主角设定 | `protagonist` | **已写**:代入名片(名字/背景/特殊设定);挂在当前选用的用户角色卡下;关掉这局就不加载 |
| 实现机制 | `mechanism` | **已写**:`context-fragment.v1`(支撑点正文 + 自评 + 追问);`feeds: gm`;**专用卡已接** |
| 世界法则 | `mechanism` | **已写**:`context-fragment.v1`(法则 + 连锁);旧称实现机制,产物旧 tag `设计.实现机制` 仍可读;`feeds: gm`;**专用卡已接** |
| 舞台骨架 | `world-blueprint` | **已写**:`context-fragment.v1`;社会结构 + 世界状况;**专用卡已接** |
| 生成规则 | `generation-rules` | **已写**:`context-fragment.v1`;方向池优先、元素池仅封闭集合;可反复;**〔先验产物〕**;规划字段 `target`;挂 gm+auditor;**专用卡已接** |
| 具体实例 | `concrete-instances` | **已写**:`context-fragment.v1`;只按规则执行;可反复;**〔先验产物〕**;规划字段 `rule_id` |
| 叙事指南与故事推进 | `narrative` | **工序编排**:一份全文双挂;结构化卡。对话落盘不排本步 |
| 叙事指南 | `narrative-guide` | **对话落盘**:遣词/笔墨/禁忌/写法档;`intake: dictate` |
| 画面与生图 | `image-prompt` | **对话落盘**:从工作流库选一种并写提示词合同;只挂旁观;`intake: dictate` |
| 故事推进 | `story-progression` | **对话落盘**:推进方式 + 用户输入用法;`intake: dictate` |
| 拓扑图谱 | `topology` | 必须生成且不适合走生成规则→实例时,用拓扑结构写出(升级路径、地图、人物关系等) |
| 回复呈现 | `status-bar` | **已写**:每轮终稿看什么(正文/字数/日期/变量);旧称设计监控栏 |
+506 -8
View File
@@ -8,6 +8,9 @@
"name": "writing-agent",
"version": "0.1.0",
"dependencies": {
"https-proxy-agent": "^9.1.0",
"msedge-tts": "^2.0.9",
"undici": "^8.12.2",
"yaml": "^2.9.0"
},
"devDependencies": {
@@ -1046,6 +1049,15 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"license": "MIT",
"engines": {
"node": ">= 20"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1056,6 +1068,69 @@
"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/axios/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/axios/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/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": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
@@ -1065,6 +1140,30 @@
"license": "MIT",
"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": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -1114,6 +1213,19 @@
"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": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
@@ -1154,11 +1266,22 @@
"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": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1259,6 +1382,15 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
@@ -1267,6 +1399,20 @@
"license": "MIT",
"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": {
"version": "35.7.5",
"resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz",
@@ -1310,9 +1456,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
@@ -1321,9 +1465,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
@@ -1335,6 +1477,33 @@
"dev": true,
"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": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
@@ -1450,6 +1619,42 @@
"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": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@@ -1480,6 +1685,52 @@
"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": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@@ -1551,9 +1802,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
},
@@ -1608,6 +1857,45 @@
"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": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -1629,6 +1917,55 @@
"node": ">=10.19.0"
}
},
"node_modules/https-proxy-agent": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
"integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==",
"license": "MIT",
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"proxy-agent-negotiate": "1.1.0"
},
"engines": {
"node": ">= 20"
}
},
"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": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -1705,6 +2042,36 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
@@ -1719,9 +2086,25 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"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": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -1855,6 +2238,32 @@
"node": ">=0.4.0"
}
},
"node_modules/proxy-agent-negotiate": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz",
"integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==",
"license": "MIT",
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"kerberos": "^2.0.0"
},
"peerDependenciesMeta": {
"kerberos": {
"optional": true
}
}
},
"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": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -1879,6 +2288,20 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
@@ -1963,6 +2386,26 @@
"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": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -2037,6 +2480,25 @@
"dev": true,
"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": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
@@ -2141,6 +2603,15 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "8.12.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz",
"integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -2158,6 +2629,12 @@
"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": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -2761,6 +3238,27 @@
"dev": true,
"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": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+5 -1
View File
@@ -15,7 +15,8 @@
"web:watch": "tsx watch --clear-screen=false --include \"./src/**/*.ts\" --include \"./skills/**\" src/server/web-server.ts",
"electron": "electron .",
"libraries:restore": "tsx src/cli/restore-library-seeds.ts",
"libraries:export": "tsx src/cli/restore-library-seeds.ts --export"
"libraries:export": "tsx src/cli/restore-library-seeds.ts --export",
"stats:cache": "tsx src/cli/cache-report.ts"
},
"devDependencies": {
"@types/node": "^22.10.0",
@@ -25,6 +26,9 @@
"vitest": "^2.1.9"
},
"dependencies": {
"https-proxy-agent": "^9.1.0",
"msedge-tts": "^2.0.9",
"undici": "^8.12.2",
"yaml": "^2.9.0"
}
}
+163
View File
@@ -0,0 +1,163 @@
# 预设 = message[] 骨架:真多轮装配
> 状态:**已实现**(788 测试全绿)。剩:实测缓存收益、滑窗恢复策略、`{{charPrompt}}` 一族。
## Context
**问题**:现在每条 LLM 请求实际只有「N 条预设 system 条目 + 1 条巨大 user 文本」。原因是两处拍平叠加:
1. `src/worker/executor.ts:868` 交给预设层的任务消息只有 `[{system: 契约}, {user: assembleWorkerContext(...)}]`,历史被 `formatHistorySegment` 拼成一段带 `## 用户 / ## 助手` 的字符串。
2. `src/preset/play-frame.ts` 的 `frameMessagesWithPreset()` 只有 body ≥2 且末条是 user 时才切出历史袋 —— 实际永远只有 1 条,于是 `chatHistory` 洞**恒空**,整包上下文落进 `currentTurn`。
后果:
- **成本**:历史 blob 每轮整体变化,前缀在 `currentTurn` 处断,前 N-1 轮重发全价。对上 `docs/briefs/prompt-cache-audit-2026-10-07.md` §5「未打折的是几乎每次的首填大包」。
- **表达**:模型没有 assistant 轮可模仿节奏;`injection_depth` 无意义;`squash` 的 `semi/strict` 在给拍平打补丁。
**目标**:预设回归它本来的职责 —— 提供 `message[]` 的结构指导;内容(我们自己的上下文)按预设的洞折进去;历史是真多轮。
---
## 确认后的模型(用户要确认的那一条)
> 预设 = 「最后发出去的 messages 长什么样」的声明式骨架;宏在装配期展开;运行时只负责把每块内容填进骨架留的洞里,绝不追加在骨架之外。
### 预设负责
| 职责 | 载体 |
|---|---|
| 有哪些条目、什么角色(system / user / assistant) | `prompts[].role` |
| 谁参与本次请求 | `prompt_order[].enabled` + `prompts[].enabled` |
| 装配顺序(谁在前、谁在后、谁进历史深处) | `prompt_order[].orderIndex`,未来 `injection.depth` |
| 正文里的宏(`{{user}}` 等) | `prompts[].content`,装配期展开 |
| 生成参数 | 顶层 `temperature/top_p/...` |
| 发出前整形要求(squash) | `messagePolicy.squash` |
### 运行时负责(只填洞,不加料)
5 个自有洞 + 1 个共用洞:
| identifier | 填入内容 | 形态 |
|---|---|---|
| `worldBookBefore` | 历史之前的上下文块(worker 契约、角色卡、设计资料) | 一段 system 文本 |
| `chatHistory` | `对话.历史` | **真多轮 message[]** |
| `worldBookAfter` | 历史之后、本轮之前(变量 / 真值 / 本轮状态) | 一段 system 文本 |
| `currentTurn` | `用户.最新输入` | 一条 user |
| `postTurn` | 本轮之后的插入(本步任务、提示) | 一段 system 文本 |
### 宏(装配期展开,`src/directives/macros.ts`)
`{{// 注释}}`、`{{trim}}`、`{{user}}`、`{{char}}`、`{{lastUserMessage}}`、`{{getvar::k}}`、`{{setvar::k::v}}`、`{{addvar::k::v}}`、`{{roll:1d6}}`。
未知宏**原样保留**(与酒馆一致),并在导入诊断 `diagnostics.macros[].known=false` 里点名。
作用域:单次装配一份 Map,不落盘、不跨轮。
### 目标形态(游玩一轮)
```text
system 预设条目:main / 文风 / 破限(ST 历史前)
system worldBookBefore:worker 契约 + 角色卡 + 设计资料(史前块)
system worldBookAfter:变量.当前 / 真值(史后块)
assistant 开场白 ← chatHistory 洞展开
user 「我推门进去」 ← chatHistory 洞展开
assistant 「门后是雨……」
user 「我抬头」 ← chatHistory 洞展开
user currentTurn:用户.最新输入
system postTurn:本步任务 / 提示(若有)
```
前 6 条是稳定前缀 → 可命中缓存;只有末尾随轮次变化。
---
## 当前代码状态(已落地)
| 文件 | 改动 |
|---|---|
| `src/skills/dialogue-history.ts` | `parseHistoryMessages()`:`## 用户` / `【用户】` / `用户:` → message[];`CURRENT_TURN_TAG` / `isCurrentTurnRef` 迁入 |
| `src/skills/context-segments.ts` | `assembleWorkerContextSections()` + `workerContextToBags()`;`assembleWorkerContext()` 保留为 `.flat` 包装 |
| `src/preset/world-info-pack.ts` | `history: string → ChatMessage[]`;`chatHistory` 不再字符串填洞 |
| `src/preset/assembler.ts` | 历史洞展开多条;`PRESET_HISTORY_MARK` / `splitPresetHistory()`;`injection_position=1` 深度插入 |
| `src/preset/play-frame.ts` | 不再把正文拍平成 `【用户】/【助手】`;`shapeFramedMessages()`:历史上只用「合并相邻同角色」 |
| `src/llm/preset-wrapper.ts` | `options.presetPack` 通道;squash 只整形历史前后两段;tool 循环尾原样接在后面 |
| `src/llm/client.ts` | `CompleteOptions.presetPack`(不进请求体) |
| `src/worker/executor.ts` | 含 `对话.历史` 的 worker 走 `presetPack`(含 gm harness) |
| `src/runtime/driver.ts` / `src/worker/gm-harness.ts` | 透传 `presetPack` |
| `src/direct-chat/turn.ts` | `priorMessages` 直传真多轮;种子前情无块头时进 `worldBookBefore` |
| `src/preset/probe.ts` | 历史解析 + `presetPack` |
| `src/directives/macros.ts` / `expand.ts` | 新增 `{{persona}}` → `@人设` |
| `src/types/preset.ts` | `DEFAULT_PRESET_SQUASH = "strict"` |
| `src/dictate/history-window.ts` | 缺省改为不限(-1),先让前缀能命中 |
## 已定决策
| 问题 | 结论 |
|---|---|
| 预设没写 squash | 缺省 `strict`(兼容性优先);单个预设可自行写;设置页仍可覆盖 |
| 酒馆真实 squash 语义 | 核过源码:`squash_system_messages` 是 bool(默认 false)= 合并相邻无 name 的 system;`merge`/`strict` 属于另一个设置 `custom_prompt_post_processing` |
| 历史区要不要参与整形 | 参与,但只用「合并相邻同角色」:用户连发两条会合并,`injection_depth` 的 system 不被折掉 |
| `injection_depth` | 已实现:depth 0 = 历史末尾,同深度按 `injection_order` 升序 |
| 创作期 worker | 不特殊化;含历史 ref 的真多轮,纯数据 worker 保持单包 |
| `{{persona}}` | 接 `@人设`(当前用户角色详细人设) |
| `{{charPrompt}}` 一族 | 不补:我们靠世界书建角色卡,未知宏原样保留 + 导入报告点名 |
| 变量 / 真值 | 不动:继续当 `worldBookAfter` 的文本段 |
| 创作期历史滑窗 | 暂时关闭(不限) |
---
## Approach
1. **先修好编译**:`context-segments.ts` 补回 `export function assembleWorkerContext(params): string { return assembleWorkerContextSections(params).flat; }`。旧调用点零改动,扁平输出与改动前逐字一致(`chunks.map(text) + tail`,`"\n\n---\n\n"` 分隔)。
2. **worker 路径接上真多轮**:`executor.ts` 里当 `worker.contextSegments` 含历史 ref 时,用 `workerContextToBags(sections)` 建 `WorldInfoPack`,把它作为 `presetPack` 传给三条请求路径(`completeStructured` / `completeOpts` 的 `complete`|`completeStream` / `runGmHarness` 暂不传)。`messages` 保持现状(trace 与 harness 仍可用)。
3. **保证不重复、不丢块**:`workerContextToBags` 已把「史前块 / 历史 / 史后块 / 本轮 / 尾块」分好;历史只从 `chatHistory` 洞出一次。
4. **squash 保护**:`shapeFramedMessages()` 只对历史区前后两段整形(已实现)。
5. **测试**:改 `play-frame.test.ts` 的 `pack.history` 期望(现在应展开成多条);新增「历史洞展开成多轮」「chatHistory 不再恒空」「squash 不动历史」三组用例。
6. **文档**:更新 `docs/preset-format.md` §4(洞 + 装配规则 + 宏)、`docs/context-assembly.md` §2/§9(历史是 message 序列)。
### 不在本轮(建议单独排)
- `injection_position=1` / `injection_depth` 深度插入(真多轮之后才有意义)。
- 历史滑窗策略:`projection=summary`(最近 20 段)在块级滑动时会整体位移、击穿前缀缓存。需要「按块裁剪 + 仅在超阈值截断」。
- 主世界层 `runGmHarness` 的多轮化(它是 tool loop,前缀已在循环内复用)。
---
## Files to modify
- `src/skills/context-segments.ts` — 补 `assembleWorkerContext` 包装(关键:让编译先过)
- `src/worker/executor.ts` — `presetPack` 接线
- `tests/play-frame.test.ts`、`tests/direct-chat.test.ts`、`tests/world-info-pack.test.ts`、`tests/preset-probe.test.ts` — 期望更新
- `docs/preset-format.md`、`docs/context-assembly.md`
## Reuse(已存在,别重写)
| 用途 | 位置 |
|---|---|
| 历史文本 ↔ 结构 | `src/skills/dialogue-history.ts`:`parseHistoryMessages()`(新)、`projectDialogueHistory()`、`appendDialogueHistoryTurn()` |
| 分袋 | `src/skills/context-segments.ts`:`workerContextToBags()`(新)、`renderContextSegmentBody()` |
| 洞词汇表 / 扩展 order | `src/preset/markers.ts`:`applyExtendedMarkers()`、`MARKER_DEFS` |
| 装配 | `src/preset/assembler.ts`、`src/preset/play-frame.ts` |
| 整形 | `src/llm/message-squash.ts` + `shapeFramedMessages()` |
| 宏 | `src/directives/macros.ts` + `src/directives/expand.ts` |
## Steps
- [x] 补 `assembleWorkerContext()` 包装
- [x] `executor.ts` 含历史 ref 时构造 pack 并作为 `presetPack` 传出(含 gm harness)
- [x] `{{persona}}` 宏
- [x] `injection_depth` 深度插入
- [x] squash 缺省 `strict`,且不碰历史区
- [x] 创作期历史滑窗暂时不限
- [x] 测试:历史解析、深度插入、squash 保护、`{{persona}}`、缺省 strict(788 全绿)
- [x] 文档:`docs/preset-format.md`、`docs/context-assembly.md`
- [x] 滑窗恢复:块级裁剪 + 滞回(超过 2n 才裁到 n),前缀每 n 轮才断一次
- [x] 缓存报告:`npm run stats:cache`(`src/cli/cache-report.ts`),按「组内首次 / 组内后续」切命中率
- [ ] 实测:改后用 `npm run stats:cache -- --days 3` 对照修复前的基线(首次 8.6%,后续 20.7%)
## Verification
1. `npx vitest run` —— 788 全绿(已达成)。
2. `npx tsc --noEmit` —— 本仓库有**既有**报错(`dictate/round.ts`、`log.ts`、`phase-runtime.ts` 未用变量与 `skillPackRoot`、`session-manager.ts`),本次未新增。
3. 预设试跑窗口:`assemblePresetProbe()` 输出应是 `system… / system… / assistant… / user… / user(turn)`,而不是一条大 user。
4. 成本侧:`npm run stats:cache`(推荐 `--days 3`)。看两件事:
- `worker:world-simulator` / `direct-chat` 的「组内后续」命中率是否明显高于「组内首次」;
- 整体是否高于修复前基线(最近 30 天:首次 8.6%、后续 20.7%)。
基线出处:`docs/briefs/prompt-cache-audit-2026-10-07.md` §3。
@@ -1,48 +1,80 @@
# 美学纲领与交互范式
# 美学纲领
## meta
```meta
name: 美学纲领与交互范式
name: 美学纲领
id: aesthetics-interaction
artifact: 设计.美学纲领与交互范式
artifact: 设计.美学纲领
declaration: >
提取用户最想反复获得的核心体验,以及让它成立的背景与规则内核;同时钉清参与和停笔边界。
提取用户最想反复获得的核心体验和让它成立的承重内核。
体验锚定只留三句短锚:站位、核心感觉、轮转停点。描写权限、后果裁定和停笔做法与推进合同一起写在故事推进与交互范式。
when: |
用户要的是叙事、角色扮演或持续互动,但还不能明确说出:最想反复获得什么体验,以及什么承重前提让它成立。
用户主动改变核心体验、代入站位或关键权限时也应重写。
用户要的是叙事、角色扮演或持续互动,但还不能明确说出最想反复获得什么体验时使用。
用户主动改变核心体验时也应重写。站位与轮转停点若已有短句,只补体验,不在本步展开权限。
when_not: |
本局不依赖体验契约的纯转换、资料整理或机械格式任务,可不使用。
只改遣词和具体写法 → 叙事指南;只改推进与输入用法 → 故事推进;只改舞台详情或硬法则 → 对应能力。
只改谁代写、谁裁定、何时停笔、输入用法或篇幅 → 故事推进与交互范式。
只改遣词和具体写法 → 叙事指南。只改舞台或硬法则 → 对应能力。
boundary: |
本能力只交核心体验、体验成立的背景与规则内核、参与边界。
背景与规则只提取承重内核,不扩写百科;舞台细节归舞台骨架,可执行硬法则归实现机制。
本步只交美学纲领、背景与规则的承重内核,以及体验锚定三句。
体验写在 `体验内核` 一处;同义的发生方式、角色人文、描写焦点省略。
背景内核写成后续可引用的短句。场所归舞台骨架,因果归世界法则,遣词归叙事指南。
交互权限与推进合同归故事推进与交互范式,本步不写描写权限表。
feeds: design_only
```
## opening
```opening
先说你最想在这局里反复获得什么体验,以及你希望自己站在什么位置。可以直接给一个场景、关系或感觉,不必先整理设定。
先用几句话抓住你要的世界和体验核心(想到什么写什么,不必整齐):
1. 原型世界是什么?变在哪里?
例:丧尸世界,但只有我不会被感染;现代都市,但灵异是真的;也可以只说「现代都市」,变点稍后补。
2. 你代入吗?希望自己站在什么位置?
· 完全代入(我就是那个人,TA 的遭遇≈我的遭遇)
· 部分代入 / 跟着体验(有距离)
· 操控或旁观(角色是棋子/观察对象)
· 其它(请写明)
3. 你最想在这局里反复获得什么体验?
一句话即可——权力、求生紧张、关系拉扯、解谜、荣耀、禁忌刺激……用你自己的词。
```
## task
```task
从用户材料中提取一份体验契约,写入 `设计.美学纲领与交互范式`。产物只回答三件事:用户最想反复获得什么核心体验;什么最小背景或规则内核让它成立;模型与用户各自能决定什么、何时必须停笔。
从用户材料提取体验契约,写入 `设计.美学纲领`。它给后续能力做取舍,不进游玩投影。产物回答两件事,并留下三句短锚:
保留用户自己的重点和尺度。材料不足时少写,把真正影响核心体验或背景内核的缺口交给 `self_score.gap`;产物正文不放评分、诊断、追问或开放问题。
1. 美学纲领:用户最想反复获得什么体验,它靠什么关系或处境反复发生,呈现时的尺度。体验本身只写在 `体验内核`。
2. 背景与规则:删掉就会让这份体验不成立的最小前提,写成后续能原句引用的短句。
3. `体验锚定`:站位、核心感觉、轮转停点各一句。完整的谁代写、谁裁定、怎么停笔,留给故事推进与交互范式。
保留用户自己的重点、用词和尺度。材料不足时少写或写「未定」,真正影响体验的缺口进 `self_score.gap`。正文不含评分、诊断或追问。
```
## principles
```principles
1. 核心体验不是题材、剧情简介或情绪标签;要写用户如何参与、体验怎样反复发生。
2. 背景与规则寻找最小承重内核:可以是关键关系、社会前提、世界规则或作用机制。删掉它会使核心体验不成立。
3. 角色人文关系、描写焦点和文风信号可作为核心体验的组成材料,但不为评分硬拆成独立维度。
4. 本步不扩写世界百科,不制定完整硬机制,不细化遣词句式,不决定故事推进方法。
5. 参与边界写可执行权限:模型可补哪些反应与行动,哪些决定必须留给用户,何时停笔。
6. 用户改口导致体验内核改变时整份重写,不在旧契约后追加互相冲突的补丁。
1. 核心是用户想反复获得的体验。其余字段只为让它稳定发生。设定变长时,先核对体验句还在不在。
2. 体验不是题材、剧情简介或情绪标签。写用户怎么参与、它怎样反复发生。同一设定可以要荣耀、自由或血与酒,只写这个人要的那一个。
3. 先抓「原型 + 变在哪里」。`核心内核` 只留删掉体验就不成立的那一句关系、社会前提或规则。场所、因果链、价目不在这里展开。
4. 用户说不清时,用两种站位或两种感觉的短场景让用户选;问在回复里,不写进产物。用户改口导致内核变了,整份重写,不在旧契约后打补丁。
5. 尺度按用户给的写进 `边界与尺度`:服务体验的用法,以及真正的禁忌。不替用户加码或减码。
6. `发生方式`、`角色人文`、`作用方式`、`描写焦点` 与体验内核或核心内核同义时整项省略。描写焦点只点注意力放在哪类瞬间,不写遣词,不写示范段,不写权限表。
```
## probe
```probe
本步最先要钉清三件:站位(代入还是旁观、镜头看哪、爽从哪来)、核心感觉(反复感受到什么)、轮转停点(何时等用户)。三件写进 `体验锚定`;开场白写入前程序会检查它们。
用户首句、默认问题首答或黑板「用户.需求」里已有的直接采纳,已答清的不重问。
缺其中一件:回复里只问这一件,给 2~3 个带短场景的建议选项,不问开放式「你还想要什么」。三件已清:再挑尺度或描写焦点里最影响体验的一点给选项;权限和停笔做法留给故事推进与交互范式,本步不再追问。
用户讲到人物、舞台、规则细节:只抽取能改变体验、站位或权限的那一句写进正文,其余留给对应能力,本步不追问细节。
```
## output
@@ -50,64 +82,87 @@ feeds: design_only
```output
{
"schema": "context-fragment.v1",
"技能": "美学纲领与交互范式",
"brief": "一句话点出用户最想反复获得的核心体验",
"技能": "美学纲领",
"brief": "一句话:用户最想反复获得的核心体验",
"体验锚定": {
"站位": "用户与当前角色/作品的关系",
"站位": "代入、部分代入、旁观或操控",
"核心感觉": "最想反复获得的体验",
"轮转停点": "何时必须停笔等待用户"
},
"mount": ["world-simulator", "narrator"],
"mount": [],
"稳变": "stable",
"正文": {
"核心体验": {
"体验主张": "用户最想反复获得什么",
"发生方式": "它通过哪些关系、处境、感官或选择反复发生",
"角色人文": "关键人物关系中的权力、尊严、亲疏或人性张力;无则省略",
"描写信号": "应把注意力放在哪里、呈现什么质感;具体写法交给叙事指南"
"美学纲领": {
"体验内核": "一段:反复感到什么,靠什么关系或处境成立,怎样被反复兑现",
"发生方式": "可选;内核没写到的反复条件",
"角色人文": "可选;内核没写到的权力、尊严或亲疏",
"呈现要点": {
"氛围基调": "整体质感",
"描写焦点": "注意力放在哪类瞬间;不写遣词",
"边界与尺度": "按用户尺度:服务体验的用法与真正的禁忌",
"其它要点": ["可选短条"]
}
},
"背景与规则": {
"核心内核": "让体验成立的最小承重前提",
"作用方式": "它如何约束人物、选择与后果",
"非必要设定": "明确本步不展开的装饰信息;无则省略"
},
"参与边界": {
"用户站位": "代入、部分代入、旁观、操控或本局自定义描述",
"模型可代演": "可补写的行动、反应和既有习惯",
"用户保留权": "不得替用户决定的事项",
"世界反馈": "模型可直接裁定和推进的外部后果",
"停笔时机": "必须等用户输入的明确时机"
"原型与变点": "原型世界,以及变在哪里;无变造则省略",
"核心内核": "后续可引用的一句承重前提",
"作用方式": "可选;这句前提约束哪类选择或后果",
"非必要设定": "可选;本步不展开的范围"
}
}
}
```
硬规则:
1. 输出合法 JSON;保留 `体验锚定` 仅为兼容,不做计数门槛。
2. `正文` 只含 `核心体验`、`背景与规则`、`参与边界`;未知字段省略,不用“完备度”或诊断对象占位。
3. 不在产物正文写 `自评`、`追问`、`开放问题`、评分依据或用户回复。
4. `mount` 固定包含 `world-simulator` 与 `narrator`;后续投影器分别裁剪字段。
1. 合法 JSON。`体验锚定` 三项必填短句。`mount` 固定 `[]`。
2. `正文` 只含 `美学纲领`、`背景与规则`。不写 `交互范式`。键名不用「核心体验」「参与边界」「描写信号」。
3. `体验内核` 与 `核心内核` 各只写一次。同义的可选槽整项省略。
4. 未知写「未定」或省略。正文不含评分、追问、权限表、舞台清单、法则全文或推进方案。
## score
```score
核心体验:
- 5:准确提取用户最想反复获得的体验,写清用户站位、体验如何发生及何时停笔;后续能力可据此取舍。
- 3:大方向正确,但仍偏题材或情绪概括,或体验如何反复发生尚不明确。
- 1:误读用户重点,只有剧情简介、设定标签或泛化氛围。
美学纲领:
- 5:体验内核写清反复感到什么、怎样兑现;可选槽没有把这句话再写一遍;尺度贴用户原话。
- 3:方向对,但仍是题材或情绪标签,或发生方式在复读内核。
- 1:只有剧情简介或设定堆砌。
体验锚定:
- 5:站位、核心感觉、轮转停点各是一句可核对的短锚,没有展开成权限表。
- 3:三件都有,但某一件是空话或和体验内核无关。
- 1:缺件,或把描写权限、停笔做法写进本产物。
背景与规则:
- 5:找到让核心体验成立的最小内核——关键关系、背景前提或作用规则,并说明它怎样持续制造该体验。
- 3:找到相关前提,但内核过宽、因果不清,或混有较多装饰设定。
- 1:没找到承重内核;只有百科堆砌,或规则与核心体验无关。
- 5:核心内核是一句删掉体验就不成立的前提,后续能引用。
- 3:找到前提,但过宽,或作用方式在复读内核。
- 1:百科,或与体验无关。
```
## checklist
```checklist
- [ ] 核心体验写的是用户反复获得什么,而非题材或剧情简介?
- [ ] 背景与规则是删掉就会破坏体验的最小内核?
- [ ] 参与边界明确模型可代演、用户保留权和停笔时机?
- [ ] 未扩写舞台百科、硬机制、具体文风或推进方案?
- [ ] 产物正文不含评分、诊断、追问和开放问题?
- [ ] `体验锚定` 三项都已写出?
- [ ] 体验只在 `体验内核` 写清,同义槽已省略?
- [ ] `核心内核` 是后续能引用的短句,不是舞台或法则全文?
- [ ] 体验锚定是三句短锚,没有描写权限表?
- [ ] 正文无评分、诊断、追问?
```
## examples
```examples
好:
- 体验内核:「反复感到垄断生存资源的掌控;对方因资源断供而让步或反抗时兑现。」发生方式与这句话同义,省略。
- 核心内核:「物资只从用户控制的仓库流出。」作用方式一句:「拒绝供货会在下一顿改变对方的选择。」不写仓库在哪条街。
- 体验锚定:站位「完全代入」;核心感觉「断供时的掌控」;轮转停点「对方让步或反抗后等用户决定供不供」。
缺料时:
- 站位、核心感觉、轮转停点缺一件 → 回复里只问这一件,产物里该项写「未定」或先不落盘。
- 用户只给了题材词 → 不编体验,gap 问他要反复感到的那一种。
偏了就改回:
- 体验内核、发生方式、角色人文、描写焦点四段同一句掌控感 → 留体验内核,其余省略。
- 核心内核写成古武阶梯、五阶段链或十阶价目 → 收成一句前提。阶梯和价目留给拓扑图谱,因果留给世界法则。
- 描写焦点写成一段感官范文 → 改成「注意力放在让步或反抗的那一刻」。
- 用「核心体验 / 描写信号」当键,或在本步写描写权限表 → 体验留在本产物,权限改写入故事推进与交互范式。
```
@@ -21,7 +21,7 @@
# (declaration / when / when_not / boundary)注入 design-flow。
# when/when_not 必须是规划对照用户话与已有产物就能判定的信号;调用条件写在技能 meta,不要写进配方 process。
#
# 标准范例:aesthetics-interaction(美学纲领与交互范式)
# 标准范例:aesthetics-interaction(美学纲领)
# 后来写能力:同结构 prompt.md(含默认问题)+ 本表一行索引
#
# 世界模拟器常用能力见下「体验→世界→机制→呈现→收成」;编排按需选用,勿默认全选。
@@ -43,13 +43,13 @@ modules:
# —— 体验契约 ——
- id: aesthetics-interaction
name: 美学纲领与交互范式
name: 美学纲领
layer: final
mount: [world-simulator, narrator]
declaration: >-
钉清用户会在意的体验承诺:站位、反复感到什么、轮转何时停。
询问相近故合为一步。
artifact: 设计.美学纲领与交互范式
artifact: 设计.美学纲领
- id: protagonist
name: 主角设定
@@ -58,7 +58,7 @@ modules:
mount: [world-simulator]
declaration: >-
钉清 @玩家 扮演的那个人:名字、背景、特殊设定(身份/金手指等);
自动写入时加性格。代入有名片时排本步;可 设计.主角设定#短码 与开场白同槽绑定;禁止把名字/简介写进开场白正文。
自动写入时加性格。代入有名片时排本步,写各开场共通的部分;各开场不同的玩家角色写在开场白的 meta.用户角色,由程序同步为同短码主角设定;禁止把名字/简介写进开场白正文。
artifact: 设计.主角设定
# —— 世界与内容 ——
@@ -119,7 +119,7 @@ modules:
libraries: [style-packs]
declaration: >-
美学已有方向时:把感觉落成可执行的遣词、笔墨落点、禁忌与推进方式;
不重做体验问卷。工序编排用本步;对话落盘拆成「叙事指南」「故事推进」
不重做体验问卷。工序编排用本步;对话落盘拆成「叙事指南」「故事推进与交互范式」
artifact: 设计.叙事指南与故事推进
- id: narrative-guide
@@ -129,30 +129,41 @@ modules:
layer: final
mount: [narrator, world-simulator]
declaration: >-
从文风库选取本局写法(默认 1~2,确需可到 3),
落成风格推荐与要求、笔墨落点、少量禁忌与可选写法档;不钉推进
从文风库写成遣词、视角执行、段落形态、笔墨落点和少量禁忌;
体验句留在美学纲领,不钉推进
artifact: 设计.叙事指南
- id: story-progression
name: 故事推进
name: 故事推进与交互范式
intake: dictate
libraries: [preferences]
layer: final
mount: [world-simulator, narrator]
declaration: >-
从偏好库选取约束,只选定一种用户输入用法,
并写清扩写/续写定义、比例与 OOC 处理;不写遣词
artifact: 设计.故事推进
并写清每一轮里扩写(演本轮材料)与续写(材料后往下写)的定义、比例与 OOC 处理;不写遣词
artifact: 设计.故事推进与交互范式
- id: image-prompt
name: 画面与生图
intake: dictate
libraries: [image-workflows]
layer: final
mount: [auditor]
declaration: >-
从工作流库选定一种画面工作流,并写成旁观每轮可执行的生图提示词合同;
游玩时不再改选。只挂旁观
artifact: 设计.画面与生图
# —— 机制与数据 ——
- id: mechanism
name: 实现机制
name: 世界法则
layer: final
mount: [world-simulator]
declaration: >-
保存世界中稳定成立的因果法则,供主世界层推演事件后果、
剧情变化和人物反应;一次可写多条共同作用的机制
artifact: 设计.实现机制
写出世界相对熟悉基底的根本变造及其连锁(制度、权力、日常),
以及金手指与玩法系统的固定规则;不预定人物对玩家的反应
artifact: 设计.世界法则
- id: topology
name: 拓扑图谱
@@ -161,8 +172,8 @@ modules:
mount: [world-simulator, auditor]
repeatable: true
declaration: >-
为一个独有但结构复杂的对象定义节点、连接、初始图与开场后的维护规则;
一份产物一张图,可按地图、关系链、升级树等对象反复
为一张图定义节点、连接、初始图与开场后的维护规则。
地图、关系链、升级树,以及社会阶梯、价目、兑换等级等有序标尺,都在这里;一份产物一张图
artifact: 设计.拓扑图谱
- id: variable-design
@@ -171,8 +182,8 @@ modules:
layer: final
mount: [world-simulator, auditor]
declaration: >-
定义必须跨轮保存的最少真值、合法更新条件、权限与派生映射,
供程序选择简单状态工具或复杂维护副 Worker
定义必须跨轮保存的最少真值、合法更新条件、权限与派生映射;
默认值只留短事实,人物档案留在具体实例;有真值即旁观填表
artifact: 设计.变量设计与更新规则
- id: variable-context
@@ -190,7 +201,7 @@ modules:
mount: [world-simulator, narrator]
declaration: >-
定义普通楼层每轮实际启用的语义区域、出现条件、权威来源与内容要求;
可含隐藏更新语义区,不决定前端排版和渲染
默认含文字选项;字数区间与占比照写故事推进与交互范式,不决定前端排版
artifact: 设计.监控栏
- id: zero-layer-status
@@ -243,7 +254,7 @@ modules:
declaration: >-
普通楼层创作终节点,且可增殖:每条开场各占一个节点(设计.开场白#场景短码)。
默认 1 条;还要入口就再增殖。全部保存。游玩时左右切换,选中的一条作为 0 层
artifact: 设计.开场白与开场变量
artifact: 设计.开场白
- id: zero-layer-opening-setup
name: 0层开场白与初态
@@ -17,8 +17,9 @@ when_not: |
一张有节点和连接的结构图 → 拓扑图谱。
规则写明创作期不预填、只在游玩期生成 → 本步不写记录。
boundary: |
本能力只交一条记录。有规则则严格按字段、池或提示、填写工作流填写;无规则则只写用户给出的少量简单字段。
不重议合同,不写世界百科,不改变量公式。一次写入多条时,程序按记录拆成多份产物。
本步只交一条按合同填好的记录。有规则则按字段、池或提示、地板和工作流填写,填到地板即停。
不重议合同,不把规则里的提示、分级表象或反例抄成正文,不预写尚未发生的场面。
世界格局与硬法则留在舞台骨架和世界法则,本步只写这一条自己的事实。
feeds: gm
```
@@ -31,23 +32,27 @@ feeds: gm
## task
```task
写出一条具体实例,写入 `设计.具体实例#中文名`。一份产物只含一条记录。用户一次给出多条时,每条各写一份;若交来的是记录数组,程序会按条拆开。
写出一条具体实例,写入 `设计.具体实例#中文名`。一份产物只含一条记录。用户一次给出多条时,每条各写一份;交来的是记录数组时,程序按条拆开。
有生成规则时:认定规则的中文名,按规则的字段、池或提示、填写工作流生成这一条;用户已写明的专名与结果只扩写不改写,未写字段从已有材料往下补满。无规则且只是一条字段很少的具名内容时:直接写用户给出的字段。
有生成规则时:认定规则的中文名,按字段、池或提示、地板和填写工作流填这一条。用户已写明的专名与结果保持原样。未写字段从已有材料补到该字段的地板:地板是最低要看见或碰到的事实,补完即停。无规则且只是一条字段很少的具名内容时:只写用户给出的字段。
材料足够便直接成稿。缺的是合同本身时,先回到生成规则,不在这里补合同。产物正文不放评分、诊断、追问或开放问题。
规则为同批时:一次写齐本批,`记录` 写成数组,每项一条并带上姓名或名称,程序按条拆成多份。先定各条如何互指、对立、同场,再填字段。
材料够就成稿。缺的是合同本身时,回到生成规则,不在这里补合同。规则里的硬门槛(年龄、禁止项、档位)对不上材料时,不把这条写成违规内容,缺口进 `self_score.gap`。正文只含记录 JSON,不含评分、诊断或追问。
```
## principles
```principles
1. 有规则:字段名、取值方式、工作流阶段与依赖都以规则为准;不增删字段,不改池。
2. 池选取按合理度选择,不是程序抽签;提示约束按那句话的范围写开。
3. 按填写工作流推进:先骨架后血肉;阶段依赖与自检能核对的,生成后要能过。
4. 一份产物一条记录。多条各自成稿,用中文名称区分。
5. 无规则只写用户点名的那一条,字段保持很少;不借机发明完整人设合同。
6. 用户已写明的专名、关系、结果保持原样,只做厚度扩写;未写字段从这些材料延伸。
7. 给人读的键和专名用中文。材料里的英文名音译成中文,不把英文原名留在字段值里。外壳的 schema、brief、mount 保持既有键。
1. 有规则:字段名、取值方式、工作流与依赖以规则为准。不增删字段,不改池。
2. 池选取按合理度选。提示是范围,按范围写这一条的事实,不把提示里的方向句原样贴进记录。
3. 按工作流:先定会约束后面的项,再填其余。填完要能过规则的自检。
4. 一份产物一条记录。同批一次写齐:先对齐关系,再填字段。互指的专名与事由必须一致,同一次写完。
5. 句子级字段写到地板规定的可感知事实即停。词条、档位、数字保持短值。只丢一个词、低于地板,不算填完;写成一场戏、一段台词或「以后会怎样沦陷」,算写过了,收回到事实。
6. 命门、突破口、限制类字段写失效条件:什么事实一发生,这项依仗就变了。不写征服过程。外貌写能认出的年龄感、衣服和一处外形,不写尚未发生的身体反应。
7. 分级字段只填档位短值。表象留在规则的分级表现里,不抄进记录。舞台、机制、美学里已有的句子不在记录里再写一遍。
8. 无规则只写用户点名的那一条,字段保持很少。
9. 给人读的键和专名用中文。英文专名音译,不把英文原名留在字段值里。外壳的 schema、brief、mount 保持既有键。
```
## output
@@ -63,35 +68,56 @@ feeds: gm
"规则": "所依生成规则的中文名;无规则时省略",
"名称": "这条记录的中文名",
"记录": {
"字段名": "按规则字段或极简内容填写的值"
"字段名": "按规则填到地板的事实;档位与数字保持短值"
}
}
}
```
硬规则:
1. 输出合法 JSON。`记录` 是单个对象。`名称` 用中文,并与这条记录的主名称一致。
1. 合法 JSON。`记录` 是单个对象;同批时写成至少 2 项的对象数组,每项带姓名或名称。`名称` 用中文,并与这条记录的主名称一致。`mount` 固定 `["world-simulator"]`。
2. 有规则时 `记录` 的键必须来自该规则字段,不得增删。
3. 无规则时省略 `规则`,`记录` 只含用户给出的少量简单字段。
4. 字段名和专名用中文;英文专名音译为中文。
5. 不在产物正文写自评、追问、开放问题或合同草稿。
6. `mount` 固定为 `["world-simulator"]`。
3. 每个句子级字段停在地板:可核对的事实,不附场面、台词或预告。分级只填短值。
4. 无规则时省略 `规则`,`记录` 只含用户给出的少量简单字段。
5. 字段名和专名用中文;英文专名音译。正文不含自评、追问、合同草稿或规则条文。
## score
```score
工作流符合度:
- 5:严格按规则填写工作流推进;字段、池选取、依赖与自检都落实;无规则路径则字段简单够用且不越权发明合同。
- 3:大体按工作流,但有一处阶段跳跃、依赖未咬合,或某字段明显偏薄。
- 1:忽略工作流;乱增字段;或把用户已定内容改掉。
- 5:按规则字段与工作流填写;句子级达到地板并停在事实;同批互指一致;用户已定内容未改。
- 3:大体按工作流,但有一处低于地板,或有一句在预写场面。
- 1:乱增字段、忽略地板写成整场戏、同批对不上,或改掉用户已定内容。
```
## checklist
```checklist
- [ ] 有规则则按字段与填写工作流执行;无规则则只是一条字段很少的具名内容?
- [ ] 一份产物只有一条记录,名称是中文?
- [ ] 记录键值合法,用户已定专名未改写,英文专名已音译?
- [ ] 未改合同?
- [ ] 产物正文不含评分、诊断、追问?
- [ ] 有规则则键与工作流一致;无规则则只是一条字段很少的内容?
- [ ] 一份产物一条记录,名称是中文?
- [ ] 同批已一次写齐,互指的专名与事由一致?
- [ ] 句子级字段达到地板且停在事实,没有场面或预告?
- [ ] 分级只填短值;硬门槛对不上时已进 gap 而不是写违规记录?
- [ ] 用户已定专名未改,英文专名已音译?
- [ ] 正文无评分、诊断、追问?
```
## examples
```examples
好:
- 同批仇敌二人:一次写齐。甲的关系写乙与夺位之事,乙写甲与同一件事。
- 外貌地板是年龄感、一套衣服、一处外形:写「三十四岁,黑套裙,金丝眼镜,坐在桌沿按着档案」。不写被制服后的神态。
- 命门:写「三份签章权被公开或被上层改掉,高位当天失效;失效前她加价,失效后改口说补件是自愿」。不写一场惩戒。
- 阶层填「T2·豪门顶流」。不把分级表现里的表象再抄进记录。
缺料时:
- 没有可依的规则,又不止一条简单具名内容 → 先写生成规则。
- 材料过不了规则的年龄或禁止项 → 不填成违规记录,进 `self_score.gap`。
偏了就改回:
- 命门写成完整征服过程 → 收成「什么事实让这项依仗失效」。
- 外貌后半段写成身体反应 → 删到能认出的外形为止。
- 把规则提示里的方向句原样贴进字段 → 改成这一条自己的事实,仍落在该范围内。
- 记录里重写「古武核威慑」→ 删掉,留在世界法则;「五阶段崩解」这类全员反应链不写,这一条怎么破防由她的性格与命门推出。
```
@@ -49,7 +49,7 @@ feeds: design_only
2. 禁止重写上游长文;只写顺序与投影。
3. 每轮槽:order 0=该槽固定人设;其后按「稳→对话.历史→易变」软排。
4. 主世界层必须含 ref=`对话.历史`(默认 projection=summary);转述与旁观维护默认不含历史。
5. 主世界层应挂「设计.叙事指南」「设计.故事推进」(工序编排则为「设计.叙事指南与故事推进」)等 mount 到 gm 的块;有「设计.随机范围整理」则挂 gm(summary 即可);转述只读本轮正文(输出.用户展示)+ 文风/壳。
5. 主世界层应挂「设计.叙事指南」「设计.故事推进与交互范式」(旧档仍挂「设计.故事推进」;工序编排则为「设计.叙事指南与故事推进」)等 mount 到 gm 的块;有「设计.随机范围整理」则挂 gm(summary 即可);转述只读本轮正文(输出.用户展示)+ 文风/壳。
6. 旁观维护在回合末:须含「输出.用户展示」与变量合同,不含历史。
7. 越稳越靠前;本轮输入/真值/正文通常在历史之后。
8. 同一内容可挂多槽。
@@ -96,7 +96,7 @@ feeds: design_only
"inserts": [
{ "order": 0, "ref": "worker.persona", "projection": "fixed" },
{ "order": 1, "ref": "设计.舞台骨架", "projection": "summary" },
{ "order": 2, "ref": "设计.实现机制", "projection": "summary" },
{ "order": 2, "ref": "设计.世界法则", "projection": "summary" },
{ "order": 3, "ref": "设计.变量设计与更新规则", "projection": "summary" },
{ "order": 4, "ref": "设计.生成规则", "projection": "summary" },
{ "order": 5, "ref": "设计.叙事指南与故事推进", "projection": "summary" },
@@ -7,47 +7,49 @@ name: 生成规则
id: generation-rules
artifact: 设计.生成规则
declaration: >
为需要统一生成的一类对象写出合同:名称、用途、字段、池或提示约束、填写工作流;不填最终实例;可按对象反复。
为一类要统一生成的对象写填写合同:名称、用途、字段、池或提示、填写工作流。不填最终实例。每个产物一条规则。
when: |
要落两份或以上同类对象时使用。
只落一份,但字段多、结构重要,或需要先审字段和填写工作流时也使用。
不因开场后是否继续按同一合同生成而改道。每个产物只写一条规则。
不因开场后是否继续按同一合同生成而改道。
when_not: |
只有一条、字段很少、也不承载玩法的具名内容,才不建规则。这种内容通常也不必落具体实例。
一张有节点和连接的结构(地图、升级路径、关系网)→ 拓扑图谱。
跨轮真值 → 变量设计;舞台格局与硬法则 → 舞台骨架 / 实现机制。
只有一条、字段很少、也不承载玩法的具名内容 → 不建规则,通常也不落具体实例。
一张有节点和连接的结构 → 拓扑图谱。
跨轮真值 → 变量设计。舞台格局与硬法则 → 舞台骨架 / 世界法则;本步只短引用。
boundary: |
本能力只交生成合同:名称、用途、字段、池或提示、填写工作流。
不填最终 records,不重写世界百科,不改变量公式与剧情推进。
本步交生成合同,供具体实例与游玩期按同一字段填写。
同一条约束只放一个槽:填充要求、提示、地板、分级表现不换壳复述。
地板写最低要让人看见或碰到什么,不写可照抄的场面。价目、境界全文、台上格局已在别处时,本步引用名称,不重抄表。
feeds: gm,auditor
```
## opening
```opening
先说这套规则生成什么、用在什么场合。只有一条字段很少的具名内容,通常不必建规则,也不必落具体实例。
先说这套规则生成什么、用在什么场合。只有一条字段很少的具名内容,通常不必建规则。
```
## task
```task
为单一对象族写出一份生成合同,写入 `设计.生成规则#中文规则名`。先写名称与用途,再按用途推出字段与每项填充要求;需要封闭选取的字段绑池,更适合一句话约束的字段写提示;最后写填写工作流(含依赖、自检、反模式),供具体实例与游玩期动态生成共用。
为单一对象族写一份生成合同,写入 `设计.生成规则#中文规则名`。先写名称与用途,再按用途推出字段:每项用 `填充要求` 写清填什么、为何需要。取值默认用提示约束;能数完且必须全员一致的封闭集合才建池。描写类字段另给一句 `地板`(最低可感知到什么)。标签本身看不懂的分级才写 `分级表现`。最后写填写工作流,只保留真正改变填写顺序的阶段。
每个产物只含一条规则。材料足够便直接成稿;真正会改合同的缺口(生命周期、必填字段、单条/同批关系)交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
同一事实只写一次。`提示` 只在填充要求没写清范围时补范围;`地板` 不重复填充要求,也不写示范句。舞台骨架、世界法则或拓扑图谱里已有的格局、法则、阶梯和价目,在用途或相关填充要求里点名引用。
每个产物一条规则。材料够就成稿。会改合同的缺口(生命周期、必填字段、单条还是同批)进 `self_score.gap`。正文只含合同 JSON,不含评分、诊断、追问或最终记录。
```
## principles
```principles
1. 用途决定字段:每个字段都要能回答「对这个用途是否必要」;女 NPC 不写性别;药水要写清效果参数,不要只写外观。
2. 字段不多不少、互不重叠:资产与存款这类同义字段只留一个。
3. 取值分三类:自由生成;从池中选取(填写方按合理度选,不是伪随机程序);提示约束(如「现代都市中正常的职业」)。默认优先提示约束;只有需要封闭一致或分阶段选型时才建池。
4. 填写工作流按重要性推进:先锚定会约束后续的选项,再填血肉;阶段、依赖、自检、反模式都服务于「怎么填对」,写短写准。
5. 生命周期三选一:`仅游玩期`(创作期不预填)、`只预生成`(预填完即可丢掉规则)、`预生成并游玩期`(既写种子,开场后仍可再生成)。选哪一档不改变「要不要先写这份合同」。
6. `只预生成` 为中间层;另两类为最终层,挂主世界层与旁观。
7. 上游舞台/机制只短引用;不在本步发明共通前提百科。
8. 不输出最终实例记录。
9. 给人读的键和专名用中文。材料里的英文名音译成中文。外壳的 schema、brief、mount 保持既有键。
1. 用途决定字段。每个字段都要能回答「这个用途是否必要」。女 NPC 不写性别;药水写效果参数;要接战斗的写等级、攻击骰等接口。同义字段只留一个。
2. 取值三类:自由;池选取(填写方按合理度选);提示约束。默认提示约束。姓名、绰号、能力概念、外貌、气质不建池,也不在提示里列一串冒充穷举。怕生成雷同且会反复生成时,`提示` 写 2~6 个「方向名:一句概括」,不挂作品名、不堆例句。
3. 描写类字段的 `地板` 是最低栏:写到能看见、听见或碰到什么,或对手能抓住什么空隙。一句话。词条、档位、数字、骰子不写地板。地板不是小场景,不写「下一句怎么说」「手怎么放」。
4. 标签看不懂、且还没有拓扑标尺的分级,才写 `分级表现`:每档一句概括,加一句能与相邻档分开的表象。实例只填档位短值。社会阶梯、境界、价目、兑换等级已在拓扑图谱时,填充要求点图名,不另造一套数,也不把全表抄进 `分级表现`。
5. 几面拼成一格时,配方写在该字段的 `填充要求`:哪几面、每面写什么。字段值仍是一段扁平文字。
6. 工作流按填写顺序写短:先定会约束后面的项,再填其余。用不到的阶段不写。会反复生成时,自检含去重(不撞名、不撞核心特征)。`反模式` 写错在哪;需要时加半句反例,不写一场戏。
7. 生命周期三选一:`仅游玩期`、`只预生成`、`预生成并游玩期`。`数量` 是总共几条;`一次生成` 看记录是否必须互指、对立或同场。有则同批,关系只写谁对谁;彼此独立则单条。总共 5 个且互不改写,仍是单条。
8. 共享的台上格局与既成法则已有则短引用;没有则只写填写时必须守的最短条款,缺口进 `self_score.gap`。给人读的键和专名用中文;英文专名音译。外壳的 schema、brief、mount 保持既有键。
```
## output
@@ -60,56 +62,58 @@ feeds: gm,auditor
"mount": ["world-simulator", "auditor"],
"稳变": "stable",
"正文": {
"名称": "人话规则名,如「末世可征服 NPC」",
"用途": "这套规则在本局用来生成什么、服务哪类游玩反馈",
"规则名": "中文短名,与名称对应且稳定",
"名称": "人话规则名",
"用途": "生成什么、服务哪类反馈;已有舞台或机制则点名",
"规则名": "中文短名,与名称对应",
"生命周期": "仅游玩期|只预生成|预生成并游玩期",
"数量": {
"模式": "固定|区间|看情况",
"值": 1,
"最小": 1,
"最大": 1,
"决定规则": "contextual 时说明如何决定条数"
"决定规则": "看情况时说明如何决定条数"
},
"一次生成": {
"模式": "单条|同批",
"关系": "同批必填:本批必须一起钉死的互指;单条写彼此独立"
"关系": "同批:谁对谁必须一起钉死;单条:彼此独立"
},
"字段": [
{
"名": "顶层字段名",
"填充要求": "填什么、填到多厚、为何本用途需要它",
"填充要求": "填什么、为何需要;几面拼一格时写配方",
"取值方式": "自由|池选取|提示约束",
"绑定池": "池选取时填写池名称;其它省略",
"提示": "提示约束时写可执行的一句范围;其它省略"
"绑定池": "仅池选取时写池名称",
"提示": "仅提示约束且填充要求未写清范围时:一句范围,或 2~6 个「方向名:一句概括」",
"地板": "仅描写类:一句最低可感知标准"
}
],
"池": [
{
"名称": "与字段绑定的人话名",
"说明": "何时从中选取、选取口径",
"条目": ["封闭可选项;只列必须一致的有限集合"]
"说明": "何时选取、按什么合理度",
"条目": ["封闭可选项"]
}
],
"分级表现": [
{
"字段": "标签看不懂的分级字段名;无则省略整个键",
"档位": [
{
"值": "与实例所填一致的短值",
"概括": "一句:这档的量级",
"表象": "一句:与相邻档可分开的可见差别"
}
]
}
],
"填写工作流": {
"工作流": {
"阶段1 骨架锚定": "先选哪些池/字段、定下什么内核",
"阶段2 灵魂注入": "下一步推什么,如何与上一阶段咬合",
"阶段3 血肉填充": "姓名、外貌等如何服务已定内核",
"阶段4 立场定调": "可选;最后定关系或立场类字段"
"阶段1": "先定什么"
},
"依赖关系": [
"字段A → 字段B:约束说明"
],
"自检清单": [
"填完后必须能回答的关键问题"
],
"依赖关系": ["字段A → 字段B:约束"],
"自检清单": ["填完必须能核对的一点"],
"反模式": [
{
"名": "短名",
"说明": "为什么错",
"反例": "可选一句"
}
{ "名": "短名", "说明": "错在哪", "反例": "可选半句" }
]
}
}
@@ -117,42 +121,62 @@ feeds: gm,auditor
```
硬规则:
1. 输出合法 JSON;每个产物只描述一条规则,禁止 `rules` 数组堆多条。
2. `字段` 由用途推出;可省略的字段不写;重叠字段合并。
3. `池` 可 `[]`;池选取字段必须能对应到某个池;提示约束字段必须有 `提示`。
4. `填写工作流` 必含 `工作流`;`依赖关系` / `自检清单` / `反模式` 为子字段,无则 `[]`,不要拆成正文顶层并列大段。
5. `一次生成` 为同批时关系必填且本批至少 2 条;为单条时一次只交 1 条。
6. 字段值为扁平可填内容;不要嵌套对象当字段值契约。
7. 不在产物正文写自评、追问、开放问题或最终记录。
8. `只预生成` 时程序记为中间层;`仅游玩期` / `预生成并游玩期` 记为最终层。
9. 字段名和专名用中文;英文专名音译为中文。
1. 合法 JSON;每个产物一条规则,不写 `rules` 数组。`mount` 固定 `["world-simulator", "auditor"]`。
2. 字段由用途推出;重叠合并;可省略的键不写。池选取必须对应到某个池;无池则 `池` 为 `[]`。姓名、绰号、能力概念、外貌、气质不绑池。
3. 同一约束只出现在一个槽。`提示` 与 `填充要求` 同义则省略 `提示`。`地板` 与填充要求同义、或写成场面/台词,则改成一句可感知标准或省略。
4. `填写工作流` 必含 `工作流`;依赖、自检、反模式无则 `[]`。阶段按实际顺序命名,不凑满四段。
5. 同批时关系必填且本批至少 2 条;单条一次只交 1 条。字段值是扁平文字,配方写在 `填充要求`。
6. 没有难懂分级则省略 `分级表现`。不在正文写最终记录、价目全表、百科、评分或追问。`只预生成` 为中间层;另两类为最终层。字段名与专名用中文。
## score
```score
字段妥当:
- 5:字段由用途推出,不多不少、不重叠;该有的玩法/效果接口齐全,该省略的已省略。
- 3:大体可用,但有 1 个冗余、重叠或明显缺失字段。
- 1:通用名片乱塞,或关键效果/接口字段缺失。
- 5:字段由用途推出,不多不少;描写地板是一句可感知标准;难懂分级能与邻档分开。
- 3:大体可用,但有一处冗余、缺失,或地板/提示在复读填充要求。
- 1:关键接口缺失,或地板、提示、反例写成场面。
池与提示:
- 5:该用池的用了,不该用的改成一句提示约束;池精炼可选取;提示具体可执行。
- 3:大体合理,但个别字段误用/漏用池,或池过泛、提示含糊。
- 1:大段假穷举,或本该一句话提示却做成巨型词表。
- 5:封闭集合用池,其余用提示或自由;提示是范围或方向名,不堆例句。
- 3:个别字段误用池,或提示含糊。
- 1:为姓名、气质、能力建词池,或提示变成例句库。
填写工作流:
- 5:阶段简洁有力,依赖与自检/反模式能引导按重要性推进,不臃肿。
- 3:有阶段但顺序或依赖不清,或清单偏长却抓不住关键咬合。
- 1:无工作流,或与字段脱节,无法指导动态生成。
- 5:阶段只含会改变顺序的步骤,自检抓得住,反复生成时含去重。
- 3:有阶段但依赖不清,或清单偏长。
- 1:无工作流,或与字段脱节。
```
## checklist
```checklist
- [ ] 名称与用途已写清?
- [ ] 字段由用途推出,无冗余重叠、无关键缺失?
- [ ] 池与提示分工正确?
- [ ] 填写工作流含子字段:工作流 / 依赖关系 / 自检清单 / 反模式?
- [ ] 生命周期与一次生成已钉?
- [ ] 未输出最终实例、百科长文、评分或追问?
- [ ] 名称、用途、生命周期已写清,且只含一条规则?
- [ ] 字段由用途推出,无同义字段?
- [ ] 填充要求、提示、地板没有同一句写两遍?
- [ ] 地板是一句可感知标准,不是场面或台词?
- [ ] 池只绑封闭集合;难懂分级才有分级表现,且未另造一套已有标尺?
- [ ] 工作流阶段没有凑数;同批/单条按关系而非总数?
- [ ] 格局与法则是短引用,没有重抄舞台或机制?
- [ ] 正文无最终实例、评分或追问?
```
## examples
```examples
好:
- 武功典籍:字段为名字、效果、阶层。阶层走封闭池,分级表现每档一句概括加一句与邻档不同的可见差别。名字与效果用提示,不建池。
- 外貌:填充要求写配方「轮廓一句 + 质感一句 + 一个旁人会记住的细节」。地板一句:「能认出年龄感、一套衣服、一处身体曲线。」不写被征服时的神态。
- 实权:填充要求写「相对主角有没有二十四小时内生效的赏罚;没有则写无实权落差」。提示与地板不把这句话再写一遍。
- 已有 10 阶价目拓扑:填充要求写「强度取该图的阶名,价格按该档已写基价」。不在本规则重列全表。
- 互为仇敌的两名开局 NPC:一次生成同批,关系写谁夺了谁的什么。彼此无关的五个路人:数量 5,一次生成单条。
缺料时:
- 生命周期或同批关系定不下来 → `self_score.gap`。
- 台上格局还没有 → 填充要求只写必须守的最短条款,并 gap 指明缺舞台或机制。
偏了就改回:
- 填充要求、提示、地板三份都在讲「二十四小时内失去什么」→ 留在填充要求,另两槽删掉或只补真正的缺口。
- 地板写成「她会哭着改口、手按在锁骨上」→ 改成「写出哪份文件作废」。
- 分级表象写成一段调教 → 改成一句与邻档不同的可见差别。
- 反例写成完整小剧场 → 留半句错法,或只留说明。
```
@@ -0,0 +1,115 @@
# 画面与生图
## meta
```meta
name: 画面与生图
id: image-prompt
artifact: 设计.画面与生图
declaration: >
从工作流库选定一种画面工作流,并写成旁观每轮可执行的生图提示词合同。
游玩时不再改选工作流。
when: |
用户要给游玩配图,或点名某种画面风格(西幻、写实、东方玄幻等)。
when_not: |
用户没提配图、插图或画面风格。
只改遣词 → 叙事指南。只改真值 → 变量设计与更新规则。
壳上的背景、立绘先不做。
boundary: |
本步只交:选中的工作流 id,以及这一局的画面提示词怎么写。
不写 ComfyUI 节点图,不写某一轮的成品提示词,不写真值与填表规则。
产物只挂旁观。主世界层不读。
feeds: auditor
```
## opening
```opening
这局游玩要不要配图?要的话,画面更接近库里的哪一种?人物或场面里有什么必须反复画出来?
```
## task
```task
为这本书选定一种画面工作流,并写下旁观每轮写生图提示词时要遵守的合同,写入 `设计.画面与生图`。
先看工作流库目录。用户点了名、或和本局世界明显同一种画面时,用 `read_library_entry` 读取那一条(只读 1 条)。把该条目的 id 原样写入 `工作流`。`画什么`、`始终带上`、`不画`、`写法` 按本局来写:工作流已经带上模型和画风,写法里说明提示词写场面与可见人物,不另起一套画风形容词。
库是空的,或没有对得上的条目:不要编一个 id。`工作流` 留空字符串,并在 `self_score.gap` 写明库里还没有可用工作流。
材料够就成稿。产物正文不含评分、诊断或某一轮的成品提示词。
```
## principles
```principles
1. 工作流只选库里已有的一个 id。游玩期间定死,不在合同里留备选。
2. `画什么` 写每轮从正文取哪些可见内容(场所、在场的人、刚发生的动作),一句。
3. `始终带上` 只留本局必须反复出现的外貌或标志,每条一句。没有就空数组。
4. `不画` 只留不该进画面的东西,例如界面、选项、系统字。没有就空数组。
5. `写法` 写提示词的语言、粗细,以及要不要重复画风词。不写一场示范提示词。
6. 节点图、采样器、模型文件名不写入产物。
7. `mount` 只有 `auditor`。
```
## output
```output
{
"schema": "context-fragment.v1",
"技能": "画面与生图",
"brief": "一句话:用哪份工作流,提示词写什么",
"mount": ["auditor"],
"稳变": "stable",
"正文": {
"工作流": "工作流库条目 id;库空则空字符串",
"画什么": "每轮从正文取哪些可见内容,一句",
"始终带上": ["必须反复出现的外貌或标志,一句一条"],
"不画": ["不该进画面的东西"],
"写法": "语言、粗细、是否重复画风词"
}
}
```
硬规则:
1. 输出合法 JSON。`工作流` 是库里的一个 id,或库空时的空字符串。
2. `mount` 只有 `auditor`。
3. 不写节点图,不写某一轮的成品提示词,不写真值。
4. 产物正文不写自评和追问。
## score
```score
工作流:
- 5:id 来自库,且和本局画面一致。
- 3:id 在库里,但和用户说的风格偏了。
- 1:库里没有却编了一个 id。
提示词合同:
- 5:画什么、始终带上、写法都能让旁观直接写下一轮提示词。
- 3:方向有了,但写法仍是氛围词,或写成了一段示范提示词。
- 1:只有风格标签,没有可执行的取景说明。
```
## checklist
```checklist
- [ ] 工作流 id 来自库,或库空时明确留空?
- [ ] 只选了一种,没有备选?
- [ ] 写法没有示范提示词,也没有节点图?
- [ ] mount 只有 auditor?
```
## examples
```examples
好:
- 库有 `eastern-fantasy`,用户要仙侠 → `工作流` 写该 id。`画什么`:「这一轮正文里看得到的场所、在场的人和正在做的动作」。`写法`:「中文短句;模型已是东方玄幻,提示词不另堆画风词」。
缺料时:
- 工作流库没有任何条目 → `工作流` 为 "",gap 写明还没有可用工作流。
偏了就改回:
- 把某一场的完整提示词写进 `写法` → 改成语言和粗细。
- `mount` 写了 world-simulator → 改成只有 auditor。
```
@@ -1,50 +1,63 @@
# 实现机制
# 世界法则
## meta
```meta
name: 实现机制
name: 世界法则
id: mechanism
artifact: 设计.实现机制
artifact: 设计.世界法则
declaration: >
保存世界中稳定成立的因果法则,供主世界层推演事件后果、剧情变化和人物反应;一次可写多条共同作用的机制。
写出这个世界相对熟悉基底的根本变造及其连锁后果,以及金手指、玩法系统的固定规则;
供主世界层判断「在这个世界里,这件事意味着什么、会引来什么」。
when: |
本局存在拿掉后会改变故事骨架的既成法则,且后续需要据此推演环境变化、事件后果或人物反应时使用。
用户给出了与现实或原作不同的世界前提(超常力量、社会常态、禁忌、缺失之物),且它会持续改变人们怎样看待、处理玩家要做的事时使用。
本局有金手指或玩法系统,需要固定的触发条件与总则时使用。档位全表、价目、社会阶梯归拓扑图谱。
when_not: |
只有熟悉常识,没有特殊因果法则 → 不使用。
地点、势力与社会格局 → 舞台骨架;多份人物或要先审字段的名片 → 生成规则,单条字段很少的具名内容才归具体实例;字段合同 → 生成规则。
世界与熟悉基底无差别,也没有需要固定规则的能力或系统 → 不使用。
只是身份或开场处境 → 主角设定 / 开场白。
地点、势力分布与主要场所 → 舞台骨架。
社会阶梯、境界、价目、兑换等级的全表 → 拓扑图谱;本步只点图名。
某人或某类人对玩家的态度、好感、屈服阶段 → 具体实例 / 生成规则 / 变量设计。
怎么写、写多细 → 叙事指南。
boundary: |
本能力交稳定因果,不预写唯一剧情,也不把所有人物钉成同一种反应。
不解释起源,不扩写世界百科,不制定推进节奏、变量表或检定公式。
本步只写对整个世界客观成立的规则:变造本身,它在制度、权力、日常里的连锁,触碰它的代价与边界;能力与系统的触发和总则。档表不写在这里。
规则不预定任何人对玩家的反应或结局;人物如何反应由其性格、利益与当前变量推出。
舞台骨架的「必要变造」只留一句台上结果,完整连锁写在本步。
feeds: gm
```
## opening
```opening
说说这个世界有哪些已经成立、会持续改变事件后果的法则。重点是它如何起作用,以及不同处境和人物会因此面对什么;多条机制可以一起写清。
说说这个世界和熟悉的现实(或原作)哪里不一样,这些不同让社会、权力和日常变成了什么样。如果有金手指或玩法系统,说说它的触发条件和总则;整张档表交给拓扑图谱。
```
## task
```task
把本局承重的既成因果法则整理为实现机制,写入 `设计.实现机制`。一个产物可以包含多条共同作用的机制;分别写清法则内容、生效方式、因果影响和推演口径,再说明机制之间如何共同作用。
把材料整理为 `设计.世界法则`:这个世界对玩家要做的事持什么态度,由哪些规则决定。
先在内部对每条候选做移除检验:拿掉后故事骨架是否改变。`缺失后果`只用于筛选,不得写进实际产物。机制应让主世界层能够推演事件怎样发展、不同人物可能怎样反应,但不得指定唯一剧情或统一人物反应。
先从美学纲领找出玩家反复要做的那件事(讲黄段子、越级修炼、复制能力、扳倒高位者……),再问:在这个世界里,这件事是什么地位——稀缺、禁忌、违法、被垄断、有代价,还是寻常?给出这个地位的世界前提,就是要写的法则。每条写:
1. `法则`:世界里成立什么,何时对谁生效。一到两句,主语是世界或某类人,不是玩家。
2. `连锁`:因此世界长成什么样。往下推到制度、权力分布、风俗日常、普通人的常见看法,每条一句,1~4 条。
3. `代价与边界`:有人去做相关的事会撞上什么(处罚、排斥、追捕、反噬、争夺),法则在哪里失效。无则省略。
4. 法则依赖社会阶梯、境界、价目或兑换等级时,在法则或连锁里点拓扑图名,不抄档表。
材料足够便直接成稿;真正会改变法则内容的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
写完核对两件事:删掉所有提到玩家的句子,每条法则仍成立;没有一句替某类人预定了对玩家的态度或结局。
材料够就成稿;会改变法则或连锁方向的缺口进 `self_score.gap`。产物正文只含机制 JSON,不含评分、诊断或追问。
```
## principles
```principles
1. 机制是稳定因果:写世界承认什么、何时生效、会带来什么后果。
2. 内部使用移除检验筛选承重法则;只能回答“更丰富”或“以后也许有用”的候选不纳入。缺失后果不输出。
3. 因果影响写行动可能性、代价、资源与关系怎样改变;推演口径写推演情节和人物反应时必须考虑什么。
4. 人物反应保持开放:同一机制下,人物可因认知、利益、身份和性格不同而隐藏、合作、利用、抵抗或误判。
5. 多条机制可一次生成。分别写清后,再说明它们是前提、叠加、制衡还是反馈循环;没有关系则不硬连。
6. 机制不等于题材标签。“灵气复苏”“全民直播求生”必须拆成可推演的生效方式与因果链。
7. 一句话够用便停止;不补起源史、体系百科、人物卡、剧情大纲或推进节奏。
1. 世界给体验以分量,不替体验兑现结果。用户要的感觉(爽、禁忌、反差)归美学纲领;本步写让这种感觉成立的地形:这件事为什么难、为什么稀罕、为什么冒险。写成「某类人都喜欢 / 都会屈服 / 绝不反抗」,就是把愿望写成了法则;改回世界前提,人物反应交给实例与变量。
2. 两类来源,按序写:世界变造(与基底不同的物理、超常力量、社会常态、禁忌或缺失)优先且写全;其次是特殊力量规则(金手指、功法、血统:触发、效果、限制)。社会阶梯、境界、价目、兑换等级的全表归拓扑图谱。
3. 用户描述世界时给出的每一处变造,都要落到某条法则或连锁里,与主角无关的也一样。只写主角身上的力量、漏掉世界本身的不同,是本步最常见的偏法。
4. 连锁按常识与人性往下推:谁受益、谁吃亏、制度怎样适应、普通人信什么。可以补出用户没说的合理后果,不得与用户已说的冲突;推不准且会改方向的进 gap。
5. 用户否定过的方向本身就是法则(如「古武加入不意味着能单凭武力颠覆一切」),写进去,其它条服从它。
6. 同一事实只出现一次。场所写进舞台骨架,体验句留在美学纲领,遣词与「重点描写什么」归叙事指南。
7. 一条说清就停,不为凑数拆条。
```
## output
@@ -52,88 +65,81 @@ feeds: gm
```output
{
"schema": "context-fragment.v1",
"技能": "实现机制",
"brief": "一句话说明哪些稳定因果在改变本局世界",
"技能": "世界法则",
"brief": "一句话:这个世界哪些根本不同,让玩家要做的事有了什么分量",
"mount": ["world-simulator"],
"稳变": "stable",
"正文": {
"既成机制": [
{
"名称": "简短稳定的人话名",
"法则内容": "世界中稳定成立什么",
"生效方式": [
"对谁、在什么条件下、以什么方式起作用"
],
"因果影响": [
"它怎样改变行动可能性、代价、资源、关系或事件后果"
],
"推演口径": [
"推演剧情发展和人物反应时必须结合哪些认知、利益、身份或处境"
],
"边界或例外": [
"确有必要才写;无则省略"
]
"法则": "世界里成立什么;何时对谁生效",
"连锁": ["因此世界在制度、权力、日常或普遍看法上变成什么样"],
"代价与边界": ["可选;触碰时撞上什么,法则在哪里失效"]
}
],
"共同作用": [
{
"涉及机制": ["机制 A", "机制 B"],
"关系": "前提|叠加|制衡|反馈循环",
"推演影响": "共同作用时会怎样改变事件与人物选择空间"
}
]
"共同作用": []
}
}
```
硬规则:
1. 输出合法 JSON;`既成机制` 可含多条承重机制。
2. `缺失后果`、移除检验和被淘汰候选只用于内部推理,不得进入正文。
3. 每条机制必须写 `法则内容 / 生效方式 / 因果影响 / 推演口径`。
4. `共同作用` 只写真实存在的机制关系;无则 `[]`。
5. 推演口径提供因果依据,不指定唯一剧情,也不规定所有人物作同一种反应。
6. 不在产物正文写自评、追问、开放问题、起源百科或检定公式。
7. `mount` 固定为 `["world-simulator"]`。
1. 合法 JSON;`既成机制` 可多条;`mount` 固定 `["world-simulator"]`。
2. 每条必填 `名称`、`法则`、`连锁`;`代价与边界` 无则整项省略。不写档表、价目全表。
3. 正文不出现某人或某类人对玩家的态度、好感、屈服阶段或必然结局;不出现「详写」「重点刻画」一类写法指令。
4. `共同作用` 每项一句,只写两条法则相互放大或制约后的世界后果;无则 `[]`。
5. 正文不含评分、诊断、追问或历史百科。
## score
```score
骨架必要度:
- 5:每条都是拿掉后会改变故事骨架的承重法则,没有常识、装饰或下游细节。
- 3:主体承重,但夹有一条作用较弱或可交给其它能力的内容。
- 1:只有题材标签或装饰设定,没有稳定承重法则。
世界客观度:
- 5:每条对整个世界成立,删去玩家仍成立;无人物态度或结局预设。
- 3:主体客观,夹一句「某类人会配合 / 屈服」或一条写法指令。
- 1:法则主要在描述人物如何回应玩家,或在教如何攻略。
因果清晰度:
- 5:生效条件与影响链清楚,能据此推演环境、事件和关系怎样变化。
- 3:法则方向正确,但生效方式或一段关键因果仍需猜测。
- 1:只说“存在某设定”,无法推出后果。
连锁充分度:
- 5:用户给出的变造全部落下,且推到制度、权力或日常;主世界层能据此回答「在这个世界里做某事会怎样」。
- 3:变造齐了但连锁只有一层,或漏了一处与主角无关的变造。
- 1:只有题材标签,或只写了主角的力量。
推演开放度:
- 5:能支持人物按不同认知、利益、身份和性格作出多种合理反应,而不失去法则约束。
- 3:提供了反应依据,但部分表述暗含单一路线。
- 1:直接预写唯一剧情,或把所有人物规定成同一种反应。
承重克制度:
- 5:每条拿掉都会改变世界反馈;无场所、名片、文风内容;同一事实只出现一次。
- 3:夹一条该交给其它能力的内容,或两条互相复读。
- 1:凑数、百科化,或把场所、人物、体验句挤进来。
```
## checklist
```checklist
- [ ] 每条候选已在内部通过移除检验,且缺失后果未写入产物?
- [ ] 法则内容、生效方式、因果影响与推演口径均可执行?
- [ ] 多条机制的共同作用已写清,或明确无须关联?
- [ ] 能支持不同人物作出不同但合理的反应?
- [ ] 未写成题材标签、世界百科、剧情大纲或裁定表?
- [ ] 产物正文不含评分、诊断、追问?
- [ ] 用户给出的每一处世界变造都落到了法则或连锁里?
- [ ] 删掉提到玩家的句子,每条仍成立?
- [ ] 没有预定某类人对玩家的态度、屈服阶段或结局?
- [ ] 连锁推到了制度、权力或日常层?
- [ ] 阶梯、价目、兑换等级只点了拓扑图名,没有抄进本产物?
- [ ] 无写法指令、场所、人物名片;正文无评分、诊断、追问?
```
## examples
```examples
灵气复苏:
- 灵气重新进入环境,生物可吸收并强化、异变;浓度、接触方式与修炼方法影响变化速度和可控程度。
- 因果上会改变资源价值和既有力量关系;推演人物时结合其认知、资源、身份得失与控制能力,不能默认人人都追求修炼。
没有黄段子的世界:
- 好:法则「这个世界的语言与文化里没有性玩笑;性只在医疗与法律语境里被提起」。连锁「公开谈论性视同猥亵,可被举报处罚」「人们没有接这种话的经验,第一反应多是困惑或惊骇」「极少数地下圈子私下流传,被抓即身败名裂」。代价与边界「说出口的人承担社会性死亡与治安风险;医生、律师在职业语境内例外」。
- 偏:「玩家很爱讲黄段子」「某某很爱听」。前者是体验,归美学纲领;谁会好奇、谁会举报,由各人性格推出。
全民直播求生:
- 可拆为“强制进入求生场”“关键行为公开直播”“观众反馈可进入场内(若本局采用)”等多条机制。
- 公开评价与即时生存会共同改变选择代价;人物是否表演、隐藏或利用镜头,应由其是否在乎声誉、是否相信能返回现实等条件推出。
- 观众若只能观看,就不能推演出打赏资源;边界会直接改变因果链。
世界变造写全:
- 「阴气偏重,女性习武天然占优」→ 连锁「古武世家上层多为女性」「表世界招聘与言论默认偏向女性,女权乃至种姓式言论常见」「男性高手稀少,罕见的男性天才格外扎眼」。只写主角禁功的突破与反噬、漏掉这条,就是偏了。
- 「古武者能绕开任何安保近身刺杀,但扛不住重武器」→ 连锁「真正掌权的是古武世家或受其庇护者」「古武者不公开对抗国家机器,靠隐秘施压」「市井当古武是传言」。
- 「超级英雄是合法职业,接替了旧时代偶像的商业位置」→ 连锁「经纪公司与广告左右英雄的曝光与资源」「守序多因利益,被逼急可以转做反派,名声不是绝对枷锁」。
- 「灵气重回环境」→ 连锁「生物可经接触与修炼强化或异变」「浓度与方法决定速度和可控度」「资源价值与既有力量关系被改写」。
特殊力量与系统:
- 复制金手指:法则写触发与总则(对方无感、不被削弱;装备、金钱、智力、寄生体不可复制)。五档效果写成拓扑图谱的一条链;主角设定只点图名。
- 跨世界对数等级与价目 → 全表是一张 `path` 拓扑;本步只写「强度与价格按那张图」。生成规则和变量只填阶名。
偏了就改回:
- 「恶女必经五阶段:自负→惊恐→……→顺从」→ 删。各人怎么破防写在实例性格,进度交给变量。
- 「养母绝不会对主角动手,只会包容」→ 删。这是人物关系,写在实例与变量。
- 「主角出手必然形成绝对压制」→ 删;用户说过武力不能颠覆一切时,改写成法则「权与钱仍是表世界主轴,武力不能直接换来合法地位与资产」。
- 「推演时详写其神态与身体反差」→ 删,写法归叙事指南。
```
@@ -7,18 +7,20 @@ name: 叙事指南
id: narrative-guide
artifact: 设计.叙事指南
declaration: >
从文风库选取本局写法,落成可执行的遣词要求、笔墨落点、少量禁忌与可选写法档;不钉故事怎么往前。
从文风库写成遣词、视角执行、段落形态、笔墨落点和少量禁忌。
体验句留在美学纲领,人称与描写权限留在故事推进与交互范式。
when: |
已有美学纲领(或等价),但游玩正文仍不知道怎么写:遣词什么要求、笔墨落谁身上、场合是否要换写法档、有哪些绝对不能写。
用户主动谈文风、措辞、写法档时也使用。
已有美学纲领,但游玩正文还不知道怎么落笔:遣词、视角执行、笔墨落谁身上、场合是否换写法档、绝对不能写什么。
用户主动谈文风、措辞、叙述声音、写法档时也使用。
when_not: |
本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。
美学未钉 → 先「美学纲领与交互范式」。
美学未钉 → 先「美学纲领」。
用户明确说本局不用文风细则,且美学里已有可执行遣词方向 → 可不使用。
只谈推进、输入用法 → 「故事推进」。版式 → 「正文组成」。字数与可见区域 → 「回复呈现」。
只谈推进、输入用法、单轮字数 → 「故事推进与交互范式」。可见区域 → 「回复呈现」。版式 → 「正文组成」。
boundary: |
本能力只交怎么写:选用文风、遣词要求、笔墨落点、少量绝对禁忌、可选写法档。
推进与输入用法归故事推进;体验契约归美学纲领。
本步只交怎么落笔:选用文风、遣词(叙述声音、视角执行、段落形态)、笔墨落点、少量绝对禁忌、有切换才写的写法档。
体验内核、描写焦点、边界与尺度留在美学纲领,本步点名引用,不改写成第二份体验。
人称、可写内心、输入用法、单轮字数归故事推进与交互范式。
feeds: narrator,gm
```
@@ -31,22 +33,26 @@ feeds: narrator,gm
## task
```task
把本局写法整理为叙事指南,写入 `设计.叙事指南`。先用 `read_library_entry` 从绑定的文风库读取最相关条目(默认 1~2;确需第三种时须写 reason 并标明适用场合),再按本局体验本局化,写成可执行的风格推荐与要求。
把本局落笔方法整理为叙事指南,写入 `设计.叙事指南`。先读美学的 `体验内核` 和 `描写焦点`,只把焦点里的对象或瞬间点进 `笔墨焦点.详写`;体验句本身不搬过来,也不换说法再写一遍。人称、可写内心以故事推进与交互范式的 `交互范式` 为准,本步在 `遣词.视角` 只写镜头距离和如何不漂人称。
产物回答:选了哪些文风、各自用在什么场合;遣词与笔墨要怎么做;只有少数绝对禁忌。除禁忌外,少写“不要怎样”,多写“要怎样”。不写示范段落,不写推进与输入用法。
再用 `read_library_entry` 从文风库读取最相关条目(默认 1~2;确需第三种时须写 reason 并标明适用场合)。`风格推荐` 和 `本局要求` 只写节奏、用词层级、句段形态、对话与叙述怎么配。写成台词、角色正在做的场面,或把某类人物当例子演一遍,就改回做法。
材料足够便直接成稿;真正会改写法的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
材料够就成稿。会改落笔的缺口进 `self_score.gap`。产物正文不含评分、诊断或追问。
```
## principles
```principles
1. 文风库是参考,不是照抄:读取后改成本局可执行要求,并记下适用场合与微调。
2. 优先 1~2 种文风;确需第三种时,每种都必须有清晰切换场合,禁止无差别堆叠。
3. 写法用“风格推荐 + 要求”表达:对象、节奏、注意力落点、句段形态都写成要做什么。示范段落不写。
4. 绝对禁忌只留真正碰不得的少数几条;其余偏好都改成正向做法。
5. 写法档是多文风/多调性容器:有切换才写,无切换整项省略。场合名要能指导何时切入,不是具体剧情桥段清单。
6. 不写故事推进、输入用法、字数区间或版式壳。
1. 美学已经写了要什么感受。本步写怎么落笔。`详写` 点名对象或瞬间;一句若是体验内核的同义改写,删掉。`边界与尺度` 里真正碰不得的收成禁忌短句,用法本身留在美学,不整段抄来。
2. 文风库是参考:读完改成本局可执行的节奏和句段,记下适用场合。不搬库里的范文。
3. 优先 1~2 种文风。确需第三种时,每种都要有切换场合。
4. `风格推荐` 与 `写法要求` 写对象、节奏、注意力落点、句段形态。不写示范段,不写台词,不用「某类角色被怎样」代替做法。
5. 用户未提段落形态时,`遣词.写法要求` 写入「完整长自然段叙述,不要单句成段」;用户另定则按用户写。单轮字数归故事推进与交互范式。
6. `遣词.视角` 只写执行:镜头距离(贴身 / 中景 / 远观,何时拉近);内心写到哪(只写交互范式允许写内心的一方,其余用外显言行);如何不漂人称。交互范式未定视角时按美学站位取最贴的写法,并在 gap 写明。
7. `叙述者`、`代入取向` 能从站位推出、且和视角执行不是同一句时才写;否则省略。
8. 禁忌只留少数绝对不能写的短句。其余写成正向做法。
9. 写法档只在有切换时写。用户报的具体场合(如当众折辱、独处)升进某一档的适用场合与写法要求,不单列成桥段清单。
10. 推进、输入用法、单轮字数、版式不在本步写。
```
## output
@@ -55,7 +61,7 @@ feeds: narrator,gm
{
"schema": "context-fragment.v1",
"技能": "叙事指南",
"brief": "一句话:本局正文怎么写",
"brief": "一句话:本局正文怎么落笔",
"mount": ["narrator", "world-simulator"],
"稳变": "stable",
"正文": {
@@ -63,24 +69,34 @@ feeds: narrator,gm
{
"库条目": "style-packs 中的 id 或名称",
"适用场合": "默认全文 / 亲密场面 / 战斗反转 等",
"本局要求": "从该条目提炼并改写后的可执行写法要求"
"本局要求": "节奏、用词、句段;不写台词和场面"
}
],
"遣词": {
"整体取向": "本局叙述应呈现的风格方向",
"风格推荐": ["优先采用的节奏、用词层级、句段形态"],
"写法要求": ["要写到什么密度、如何处理对话与叙述、注意力放哪"]
"整体取向": "本局叙述的风格方向,一句",
"叙述者": "可选;与视角执行不同才写",
"风格推荐": ["节奏、用词层级、句段形态;不写台词"],
"写法要求": [
"完整长自然段叙述,不要单句成段(用户另定段落形态则改写)",
"密度、对话与叙述怎么配、注意力落在哪类瞬间"
],
"视角": {
"镜头距离": "贴身 / 中景 / 远观,以及何时拉近",
"内心": "只写交互范式允许的一方;其余用外显",
"人称": "全文固定一种称呼,回忆与独白也不换"
}
},
"笔墨焦点": {
"详写": ["应花笔墨的对象与感受"],
"代入取向": "可选;与视角执行不同才写",
"详写": ["点名对象或瞬间;不复述体验内核"],
"可略": ["可一带而过的部分"]
},
"禁忌": ["仅少数绝对不能写的内容;无则省略"],
"禁忌": ["仅少数绝对不能写的短句;无则省略"],
"写法档": [
{
"名字": "档名,如展示情色 / 网文爽文",
"适用场合": "何时切到这一档",
"写法要求": "这一档要怎么写、笔墨落哪、如何切回"
"名字": "展示情色",
"适用场合": "切到身体与权力交换时",
"写法要求": "感官词加重、节奏放慢、对话变短;切回日常则收"
}
]
}
@@ -88,44 +104,65 @@ feeds: narrator,gm
```
硬规则:
1. 输出合法 JSON;`选用文风` 默认 1~2 项,最多 3 项,每项必须有适用场合与本局要求。
2. 不写示范段落、示例正文或仿写样章。
3. 除 `禁忌` 外,正文以正向要求为主;空话氛围词改成可执行做法。
4. `写法档` 无切换时整项省略;有则每档写清场合与写法要求。
5. 不在产物正文写自评、追问、开放问题或推进/输入用法。
6. `mount` 固定包含 `narrator` 与 `world-simulator`。
1. 输出合法 JSON。`选用文风` 默认 1~2 项,最多 3 项,每项有适用场合与本局要求。
2. `风格推荐`、`本局要求`、`写法要求`、`详写` 不写台词、不写角色正在做的场面、不把体验内核换个说法再写一遍。
3. 除 `禁忌` 外,正文以正向要求为主。
4. 用户未定段落形态时,`遣词.写法要求` 含「完整长自然段叙述,不要单句成段」。
5. `遣词.视角` 只执行交互范式里的人称与可写内心,不改范围。
6. `写法档` 无切换则整项省略。有则每档写场合与写法要求。
7. 产物正文不写自评、追问、推进、输入用法、单轮字数。
8. `mount` 固定包含 `narrator` 与 `world-simulator`。
## score
```score
遣词可执行:
- 5:风格推荐与要求具体到能直接落笔,无需再猜味道。
- 3:大方向清楚,但仍有几处停留在氛围概括。
- 1:只有题材或情绪标签,无法指导遣词。
- 5:风格推荐与写法要求具体到节奏和句段,能直接落笔;视角执行写清。
- 3:方向清楚,但仍有几处是氛围词,或视角含糊。
- 1:只有题材或情绪标签。
笔墨与禁忌:
- 5:详略落点清楚;绝对禁忌少而可执行;其余多为正向要求。
- 3:落点大体有,但禁忌偏多或详略仍含糊。
- 1:几乎不谈笔墨,或堆满否定句却无正向做法。
- 5:详写点名对象或瞬间,没有把体验内核再写一遍;禁忌少而可执行。
- 3:落点大体有,但详写在复述感受,或禁忌偏多。
- 1:几乎不谈笔墨,或堆满否定句。
文风选用:
- 5:1~2(偶发 3)条贴合本局;多档时切换场合清楚,无重复堆叠。
- 3:选用相关,但场合区分弱,或有一条不够贴。
- 5:1~2(偶发 3)条贴合本局;多档时切换场合清楚。
- 3:选用相关,但场合区分弱。
- 1:未读库硬编,或多种文风无差别并列。
本局化:
- 5:库内容已改成本局说法与要求,能看出针对本局体验的取舍。
- 3:有微调,但仍有整段近乎原文搬用。
- 1:基本照抄库条目,或与本局体验脱节。
- 5:库条目收成可执行的节奏和句段;体验句仍只在美学。
- 3:有微调,但仍有整段近乎库原文,或详写在改写体验内核。
- 1:照抄库条目,或本局要求写成台词和场面。
```
## checklist
```checklist
- [ ] 已按需读取文风库并本局化?
- [ ] 详写只点名对象或瞬间,没有改写体验内核?
- [ ] 已读取文风库,本局要求是节奏和句段?
- [ ] 选用文风 1~2(最多 3),各有适用场合?
- [ ] 遣词是风格推荐与要求,不是示范段落?
- [ ] 除少数禁忌外,多为“要怎样”?
- [ ] 未写入推进、输入用法、字数或版式?
- [ ] 产物正文不含评分、诊断、追问?
- [ ] 风格推荐里没有台词、场面或角色举例?
- [ ] 视角只写执行,未改交互范式的人称与可写内心?
- [ ] 用户未定段落形态时已写长自然段默认?
- [ ] 未写入推进、输入用法、单轮字数或版式?
```
## examples
```examples
好:
- 描写焦点已是「让步或反抗的那一刻」→ 详写只写「让步与反抗」,不把体验内核再写一遍。
- 风格推荐:「短句、少形容词、对话压成一句」。本局要求同样只写节奏和句段。
- 视角:「第二人称贴身;只写你的内心;NPC 只写外显」。
缺料时:
- 文风库没有贴合条目 → gap 问一个方向,不编第三种文风充数。
- 交互范式未定人称 → 按美学站位先写一种执行,gap 标明。
偏了就改回:
- 风格推荐写成某角色的台词,或「骄横千金被按住后改口」→ 改成节奏、用词、句段。
- 详写把「反复感到掌控」再写一遍 → 删掉,只留焦点里的对象或瞬间。
- 禁忌把美学的边界与尺度整段抄来 → 只留真正碰不得的短句。
```
@@ -3,7 +3,7 @@
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`;对照美学:感觉 → 本步「怎么写、故事怎么往前」。
> **一份产物、一次询问**;转述与主世界层都挂同一份(不必为省 token 拆成两套投影)。
> **工序编排用本步。** 对话落盘拆成「叙事指南」与「故事推进」,不要在落盘池排本步。
> **工序编排用本步。** 对话落盘拆成「叙事指南」与「故事推进与交互范式」,不要在落盘池排本步。
> 旧称:叙事指南。
## meta
@@ -18,8 +18,8 @@ when: |
已有美学纲领(或等价),但转述/主世界层仍会不知道这四件:遣词什么味道、笔墨落谁身上、绝对不能写什么、故事靠场面自己推还是等用户。
用户主动谈文风、措辞禁忌、写法档(官能/爽文/恐怖/情色等)也排。
when_not: |
本技能只给工序编排。对话落盘不排本步(落盘池是「叙事指南」与「故事推进」)。
美学未钉 → 先「美学纲领与交互范式」。
本技能只给工序编排。对话落盘不排本步(落盘池是「叙事指南」与「故事推进与交互范式」)。
美学未钉 → 先「美学纲领」。
用户只要感觉/站位、不要文风细则 → 不排,沿用美学。
版式/壳 → 「正文组成」;每轮看什么(字数/变量/日期)→ 「回复呈现」。
事件池 schema、真值增减 → 「生成规则」/「变量设计与更新规则」。
@@ -86,7 +86,7 @@ summary:`叙事指南与故事推进 · …`
- **续写**(旧称扮演停笔):行为与对话作拓写起点;装载上列准则;主角设定偏好同大纲扩写。
- **自动写入**:短 meta/极简扮演→长续写;允许按主角设定代写用户角色;倾向详钉性格。
4. 情境备用是写法档:名字用「官能小说」「网文爽文」「展示恐怖」「展示情色」同级;用法写该档遣词与何时切入。无切换则空数组。
5. 工序编排保持本步一份产物;对话落盘拆成「叙事指南」与「故事推进」,不要在本步执行时再拆技能。
5. 工序编排保持本步一份产物;对话落盘拆成「叙事指南」与「故事推进与交互范式」,不要在本步执行时再拆技能。
6. 字数区间归「回复呈现」;本步管怎么写与怎么推,不重钉字数。
```
@@ -5,10 +5,10 @@
```meta
name: 开场白与开场变量
id: opening-setup
artifact: 设计.开场白与开场变量
artifact: 设计.开场白
declaration: >
普通楼层创作终节点,且可增殖:每条开场是一个节点,落成 `设计.开场白#场景短码`。
默认写 1 条;用户要多个入口时再增殖。每条自带 present.v1、同真相初态与可选用户角色。
默认写 1 条;用户要多个入口时再增殖。每条自带 present.v1、同真相初态与这条开场的最少玩家角色事实。
全部保存。游玩时左右切换,选中的一条作为 0 层。
when: |
回复呈现、正文组成和其它本局内容能力均已完成,且程序已依次编译信息可见范围、游玩执行方式、上下文排序与运行配置后,需要可开玩的第一屏时使用。
@@ -34,22 +34,25 @@ feeds: gm,narrator
## task
```task
读取程序已组装的 `设计.worker集` 和全部最终层产物,写入 `设计.开场白与开场变量`。这是普通楼层创作终节点;不得在本步首次整理执行单元或上下文顺序。
读取程序已组装的 `设计.worker集` 和全部最终层产物,把每一张开场写成 `设计.开场白#场景短码`。这是普通楼层创作终节点;不得在本步首次整理执行单元或上下文顺序。
默认只生成 1 条开场。用户要多个入口时,每条再增殖一份产物,对话落盘写入 `设计.开场白#场景短码`(如 dorm、gate);改某一条则覆盖同一完整 tag。不要把多条入口收成聊天里的选择题。每条候选各自携带:
- 稳定 id 与一句区分说明
- 可选用户角色名片(不同候选可不同)
- 与该条叙述同真相的变量初值
- 若本局有拓扑:当前位置/焦点与已揭示范围
position 必须带场景短码,例如 `设计.开场白#opening-cycle2`。改某一条就再 insert 同一个完整 tag。裸 `设计.开场白` 和整包 `设计.开场白与开场变量` 会和带短码的卡抢同一条写入,不要使用。
默认只生成 1 条,仍然要带场景短码。用户要多个入口时,每条再 insert 一个新的短码。不要把多条入口收成聊天里的选择题,也不要把多条塞进同一次 insert。游玩时按这些短码左右切换,选中的一条作为 0 层,并整套换该条正文、初态与角色。
每一张卡携带:
- 符合正文组成数据契约的 `present.v1`(只填模型生成区)
全部候选都保存。每条正文只写在该候选的 `正文` 里,不要再抄一份全文或初值。`默认候选id` 指向第一次进入 0 层的那条;程序按这个 id 读取它的正文和初值。游玩时左右切换,选中的一条作为 0 层,并整套换该条正文、初态与角色。
- 可选一句区分说明
- 这一条开场的玩家角色 `meta.用户角色`(名字 + 简介):不同开场的玩家可以不同,写在各自卡里;程序会同步成同短码的主角设定,游玩切到这条时替换 `@玩家`
- 与该条叙述同真相的变量初值:这张卡有要开场定下的变量才写 `开场变量`;没有就不写,卡仍然是 `设计.开场白#场景短码`
- 若本局有拓扑:当前位置/焦点与已揭示范围
硬规格:
1. 每条可见正文至少出现一次字面 `@玩家`;不写死具体姓名顶名字位。
1. 可见正文里凡指称玩家角色的名字位都写字面 `@玩家`,每条至少出现一次。「你」可以和 `@玩家` 并用(如「你(@玩家)站在闸机前」),但全文只有「你」、零个 `@玩家` 不合格;不写死具体姓名,也不用「用户」「主角」顶名字位。
2. `present.v1` 的区域只覆盖正文组成声明的模型生成 blocks;程序投影区留空给程序灌。
3. 不输出 `名称.随机数` 的具体数组;随机行由程序在第一次正式回复前或回复时补齐。
4. 开场宜短,愿点进游玩即可,不抄故事推进的单轮篇幅预算当第一章。
5. 人物、地点、势力只引用已有产物里的;第一拍需要的路人只写身份,不在开场新造有名有姓的设定。
材料足够便直接成稿;会改变第一拍成立的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
```
@@ -57,12 +60,13 @@ feeds: gm,narrator
## principles
```principles
1. 开场为主,初态配套。叙述与该条变量、拓扑初态必须同真相。
2. 默认一条。多条是不同入口,每条一个可增殖节点,不是同文换皮,也不是先让用户选唯一开场。
3. 用户角色可按候选不同;没有名片时仍用 `@玩家` 占位,进游玩再替换。
4. 只写模型生成区。状态芯片、地图等权威显示由程序投影,不在开场正文里重编一份。
5. 不剧透隐藏档,不写成第一章,不在本步改前端模板。
6. 本步结束后创作收口;不要再排后续内容能力。
1. 开场为主,初态配套。叙述与该条变量、拓扑初态必须同真相;写完发现两者对不上时以叙述为准,改初值。
2. 默认一条。多条是不同入口,每条一个 `设计.开场白#场景短码`,不是同文换皮,也不是先让用户选唯一开场。没有变量的卡省略 `开场变量`,短码照旧。
3. 玩家角色最少化:把这个角色交还给用户扮演。`用户角色.简介` 只写这条开场成立所必需的客观事实——身份、处境、与在场人物的已知关系,一到三句;不写性格、心理、动机、口癖、外貌细节,也不替玩家定态度或下一步。共通的主角设定已有的事实不重抄,只写这条开场独有或与它不同的部分。
4. 用户没给名字时不编名字,省略 `用户角色`,正文照用 `@玩家`;这条开场必需的身份事实写进叙述里让玩家看见。
5. 只写模型生成区。状态芯片、地图等权威显示由程序投影,不在开场正文里重编一份。
6. 文风、视角和人称跟叙事指南与交互范式;不剧透隐藏档,不写成第一章,不在本步改前端模板。
7. 本步结束后创作收口;不要再排后续内容能力。
```
## probe
@@ -74,62 +78,48 @@ feeds: gm,narrator
## output
```output
position: 设计.开场白#场景短码
content:
{
"schema": "context-fragment.v1",
"技能": "开场白与开场变量",
"brief": "一句话:默认开场场景 + 有几条候选",
"mount": ["world-simulator", "narrator"],
"稳变": "stable",
"正文": {
"默认候选id": "opening-1",
"开场白候选": [
"schema": "present.v1",
"shell": "与正文组成一致",
"blocks": {
"body": "清晨,@玩家推开门。……",
"其余模型生成区": "read_module 回包 reply_contract.model_blocks 里的每一项都要出现,类型按契约"
},
"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 条。
2. `默认候选id` 必须指向候选之一。正文和初值只写在该候选上,不另抄全文或开场变量。
3. 每条 `正文` 必须是 `present.v1`,只含正文组成允许模型生成的 blocks;每条可见正文须含字面 `@玩家`。
4. 不同候选可有不同用户角色与初态;切换时整套替换,不混用。
1. 每次 insert 只写一张卡。position 是 `设计.开场白#场景短码`,content 是这一张的 `present.v1`。
2. 这张卡有初值才写 `meta.开场变量`;没有变量就省略该字段,position 仍带场景短码。
3. `present.v1` 只含正文组成允许模型生成的 blocks;可见正文须含字面 `@玩家`。
4. 不同卡可有不同用户角色与初态;切换时整套替换,不混用。
5. 不输出随机数具体值,不重写前端,不把程序投影区填成模型编造状态。
6. 产物正文不含自评、追问或开放问题。
7. `mount` 固定为 `["world-simulator", "narrator"]`。
7. 玩家角色只写在 `meta.用户角色`,名字不进 blocks 正文;程序据此写入同短码的 `设计.主角设定#场景短码`,不要另 insert 一份。
```
## score
@@ -150,9 +140,9 @@ feeds: gm,narrator
- 1:叙述与初值冲突,或多条共用一套却声称不同入口。
角色占位:
- 5:每条含字面 `@玩家`;有名片时不把姓名写进正文名字位。
- 3:占位大体正确,有一条名片与正文关系略乱。
- 1:写死姓名,或全文没有 `@玩家`。
- 5:每条含字面 `@玩家`;`用户角色.简介` 只有这条开场必需的客观事实,玩家性格与决定留给用户。
- 3:占位正确,但简介多写了性格、动机或与共通主角设定重复的内容。
- 1:写死姓名、全文只有「你」没有 `@玩家`,或简介替玩家定了性格和态度。
收口可切换:
- 5:默认条明确,全部候选可保存;多条时入口差异成立。
@@ -163,17 +153,22 @@ feeds: gm,narrator
## checklist
```checklist
- [ ] 默认 1 条;仅用户要求时才有 2~3 条?
- [ ] `默认候选id` 指向一条候选,且该条正文、初值没有在外面再抄一份?
- [ ] 每条含 `@玩家`,present.v1 只含模型生成区?
- [ ] 各候选初态自洽,未填写随机数具体值?
- [ ] position 是 `设计.开场白#场景短码`,没有另写裸 `设计.开场白` 或 `设计.开场白与开场变量`?
- [ ] 默认 1 条;仅用户要求时才再增殖别的短码?
- [ ] 有初值才写 `开场变量`;没有变量就省略,卡名不变?
- [ ] 含 `@玩家`,present.v1 只含模型生成区?
- [ ] `用户角色.简介` 只有必需客观事实,没有性格、心理和替玩家做的决定?
- [ ] 初态与这条叙述同真相,未填写随机数具体值?
- [ ] 未重开前端、篇幅或区域清单?
```
## examples
```examples
- 单条:堂屋债契,body 含 `@玩家`,开场变量写债额初值;状态栏由程序投影。
- 用户要两条入口:一条放贷人名片、一条社畜穿越名片;各带自己的初态,默认指定其中一条开玩。
- 不合适:开场里填写 `敏捷.随机数=[…]`;或把地图权威数据写成模型散文却不给拓扑初态。
- 单条:`设计.开场白#debt-hall`,堂屋债契,body 含 `@玩家`,开场变量写债额初值;状态栏由程序投影。
- 用户要两条入口:`设计.开场白#lender` 与 `设计.开场白#office`,各带自己的初态。没有初值的那条省略开场变量。
- 单条带玩家角色:`meta.用户角色` 为 `{"名字":"沈聿","简介":"本地放贷人,持有这家的债契;与这对母女相识但不熟。"}`,body 写「清晨,@玩家推开堂屋门,债契还摊在桌上。」
- 不合适:再写一份裸 `设计.开场白` 或 `设计.开场白与开场变量`;开场里填写 `敏捷.随机数=[…]`。
- 不合适:全文只有「你推开门」,零个 `@玩家`。
- 不合适:简介写「沉稳果决,内心渴望被认可,决定今天收回债款」——性格、心理和决定都该留给玩家。
```
@@ -13,9 +13,9 @@ when: |
LLM 获得较多推进权,而现有材料不足以稳定代演该角色时也使用。
when_not: |
旁观、无固定主角或纯作者视角时不使用。刻意无名的自我投射不因缺名字而强制使用。
NPC 有多份或需要统一字段时归生成规则,要留下的单条再写具体实例;世界硬法则归实现机制;第一幕归开场白。
NPC 有多份或需要统一字段时归生成规则,要留下的单条再写具体实例;世界硬法则归世界法则;第一幕归开场白。
boundary: |
本能力只交用户角色名片和必要行为依据。体验与权限来自美学纲领,输入及推进方式来自故事推进,本步据此控制详略。
本能力只交用户角色名片和必要行为依据。体验与权限来自美学纲领,输入及推进方式来自故事推进与交互范式,本步据此控制详略。
feeds: gm
```
@@ -28,20 +28,23 @@ feeds: gm
## task
```task
把用户材料整理为本局用户角色名片,写入 `设计.主角设定`。先保留承载核心体验的身份、能力、关系与限制,再按 LLM 获得的推进权决定是否需要行为依据。
把用户材料整理为本局用户角色名片,写入 `设计.主角设定`。先保留承载体验的身份、关系和此人能做的那一件,再按故事推进与交互范式里的代写权限决定要不要行为依据。
用户自己扮演或提供详细大纲时,身份背景够用即可,不替用户设计人格与选择。LLM 代演越多,性格、处事倾向和默认反应越需要具体可执行。产物正文不放评分、追问或诊断。
`特殊设定` 只写这张名片上的能力或身份。拓扑图谱或世界法则里已有的档位、百分比、价目和阶段链不抄进来,作用里点图名或机制名即可。用户自己扮演时,身份背景够用即可,不替用户设计人格与选择。产物正文不放评分、追问或诊断。
```
## principles
```principles
1. 只设计用户已明确给出、核心体验依赖或运行确实需要的内容。
2. “首富之子”“唯我独法”等若是核心体验成立条件,属于必要设定,不算过度设计。
3. 特殊能力写“能做什么 + 限制”;世界为何如此由实现机制处理。
4. `称呼` 可写固定名字,也可写 `@玩家` 表示刻意留白;不要因无名阻塞成卡。
5. 行为依据随推进权增减:用户控制越多越简;LLM 代演越多越具体。
6. 不写 NPC 名片、世界百科、文风规则或开场事件。
2. 一张脸测试:逐句问「删掉这句,玩家还知道自己是谁吗?」删了仍知道的句子不落盘。
3. “首富之子”“唯我独法”等若是核心体验成立条件,属于必要设定,不算过度设计。
4. 特殊设定写此人能做什么,限制写做不到什么。档位、百分比、价目、阶段链已在拓扑图谱或世界法则里时,作用只点图名或机制名。作用与设定是同一句时省略作用。
5. `称呼` 可写固定名字,也可写 `@玩家` 表示刻意留白;不要因无名阻塞成卡。
6. 身份与背景一两句;用户没给就写「未定」或省略,不代编来历。
7. 行为依据随推进权增减:用户控制越多越简;LLM 代演越多越具体。
8. 本能力只写各开场共通的玩家角色。不同开场白里玩家身份、处境不同的部分,由开场白写在自己的 `meta.用户角色`,程序会同步成 `设计.主角设定#场景短码`;本能力不为各开场另写 `设计.主角设定#短码`。
9. 不写 NPC 名片、世界百科、文风规则或开场事件。
```
## output
@@ -59,7 +62,7 @@ feeds: gm
"特殊设定": [
{
"设定": "身份、能力、关系或随身之物",
"作用": "它怎样承载核心体验或影响世界反应",
"作用": "可选;此人因此能做什么。与设定同义,或只是机制公式的复述时省略",
"限制": "无则省略"
}
],
@@ -77,6 +80,7 @@ feeds: gm
2. 不写产物内自评、追问、开放问题或完备度。
3. 不把核心体验所依赖的身份、能力或关系误删为“过度设计”。
4. 不替用户补未授权的人格、目标、情绪和关键决定。
5. `特殊设定` 不复制拓扑图谱或世界法则里的档位表、百分比、价目和阶段链。
## score
@@ -99,5 +103,17 @@ feeds: gm
- [ ] 非必要、未经确认的性格与行为设计已删除?
- [ ] 行为依据的详细度与 LLM 推进权匹配?
- [ ] 称呼允许固定名字或 `@玩家` 留白?
- [ ] 未写 NPC、世界百科、文风或开场事件?
- [ ] 特殊设定没有抄机制里的档位、百分比或价目?
```
## examples
```examples
- 好:称呼「林晚」;身份与背景一句穿来当日的处境;特殊设定「能看见别人对她的好感数字」,限制「别人看不见」。
- 好:复制能力的作用只写「接触后可复制对方一项能力」,五档百分比留在拓扑图谱,这里点图名。
偏了就改回:
- 作用写成 1%–100% 的五档表 → 删表,作用留一句能做什么,或与设定同义则省略。
- 把侯府地理、朝堂势力写进背景 → 收到舞台或开场白。
- 为宿舍开场和天台开场各写一张 `设计.主角设定#短码` → 各开场不同的处境写进对应开场白的 `meta.用户角色`。
```
@@ -13,7 +13,7 @@ when: |
机制、变量或生成规则已出现必须用随机数才能裁定的点时使用。
when_not: |
全程不靠随机推进 → 不使用。
公式本身还没钉死 → 先实现机制、生成规则或变量设计。
公式本身还没钉死 → 先世界法则、生成规则或变量设计。
按合理度从池里选,而不是抽签 → 不列入。
boundary: |
不新建表,不发骰面。数字是普通隐藏变量,默认玩家看不见。
@@ -30,7 +30,7 @@ feeds: gm
## task
```task
写入 `设计.随机范围整理`。每项是 `变量.当前` 的一行,行名写成「名称.随机数」,玩家默认不可见。值是剩余数字组成的数组,下一个可用的是第一个。
先扫世界法则、生成规则、变量设计、叙事与故事推进、美学,列出所有需要随机数才能裁定的点,再写入 `设计.随机范围整理`。每项是 `变量.当前` 的一行,行名写成「名称.随机数」,玩家默认不可见。值是剩余数字组成的数组,下一个可用的是第一个。
用掉第一个后,程序改写这一行,去掉那个数。改完后如果只剩 1 个,就按范围补到预备个数,不要等这一行空了。模型不自己编数字。
@@ -41,9 +41,10 @@ feeds: gm
```principles
1. 只登记上游已经成立的掷法,不新写法则。
2. 一行一项,行名是「名称.随机数」,例如 `敏捷.随机数`。
3. 和别的隐藏变量一样存在现有表里:能改、有版本;不对玩家默认展示。
4. 预备个数够连用即可,少了靠「只剩 1 个就补」,不要一次放很多。
2. 一行一项,一项只管一类用途,行名是「名称.随机数」,例如 `敏捷.随机数`。不要把「战斗」糊成一行却含命中、伤害、闪避几种掷法,拆成 `命中.随机数`、`伤害.随机数`、`闪避.随机数`。
3. 范围写成人与程序都能读的形式,如 `1~100`、`力量*5~10`,并注明单位(整数、闭区间等);不写「中等偏高」这类散文。
4. 和别的隐藏变量一样存在现有表里:能改、有版本;不对玩家默认展示。
5. 预备个数够连用即可,少了靠「只剩 1 个就补」,不要一次放很多。
```
## probe
@@ -68,6 +69,7 @@ feeds: gm
"用途": "敏捷检定",
"范围": "1~100",
"表达式": "无表达式时省略",
"单位": "整数,闭区间",
"依赖真值": [],
"预备个数": 3,
"来源": "上游短引用"
@@ -40,10 +40,10 @@ boundary: |
你正在执行「细化终稿」。产物必须写入 **设计.worker集**,且为 **JSON**。
本步是收成,不是发明新执行单元。优先合并:
- 设计.美学纲领与交互范式 → interaction / experience_check
- 设计.美学纲领(旧档仍认 设计.美学纲领与交互范式)→ interaction / experience_check
- 设计.worker规格(游玩拓扑)→ play_slots 或 writing_slots
- 设计.上下文投影排序 → 写入规格的 context_order(每槽 inserts:order/anchor/ref/projection);无表且上下文很少时可按默认 static/dynamic 退化
- 设计.叙事指南 / 设计.故事推进(工序编排:设计.叙事指南与故事推进)/ 世界 / 机制 / 生成规则等 → resident_context(挂载以排序表与 mount 为准,本步不重排数字序)
- 设计.叙事指南 / 设计.故事推进与交互范式(旧档 设计.故事推进;工序编排:设计.叙事指南与故事推进)/ 世界 / 机制 / 生成规则等 → resident_context(挂载以排序表与 mount 为准,本步不重排数字序)
- 设计.变量设计与更新规则 → tables.side_effects(及 schemas 摘要)
- 设计.变量控制上下文 → 核对挂载与剧透,写入 notes 或 resident 短句
@@ -18,9 +18,12 @@ when_not: |
boundary: |
回复呈现决定“有什么、何时出现、来自哪里”;本能力决定“在前端哪里、如何渲染”。
状态和拓扑数据由程序投影,本能力只写绑定与渲染器;不得让正文模型重写权威数据。
隐藏维护区不进 DOM。随机数是变量表隐藏行,也与本能力无关。
隐藏维护区不进 DOM;表由旁观副 LLM 维护,主回复不建填表通道。
随机数是变量表隐藏行,也与本能力无关。
壳参考库只提供布局与交互方法参考,不是必须照抄的固定产品模板。
feeds: gm,narrator
创作时一次写好 frontend 并由程序持有;游玩不把 html/css/js 塞进模型上下文。
程序按数据契约要求模型只交 present.v1 各 block(如正文、标题、选项),再灌进已固定的壳。
feeds: program
```
## opening
@@ -35,11 +38,11 @@ feeds: gm,narrator
为普通楼层生成一份可复用前端,写入 `设计.正文组成`。每条助手消息仍各自形成一个气泡;程序把当轮 `present.v1` 灌入同一模板。
执行顺序:
1. 读取回复呈现,只处理其中实际启用的语义区域。
1. 读取回复呈现,只处理其中实际启用的语义区域。同时读美学纲领的呈现要点与叙事指南,作为配色、字体层级和纹样的依据;不照搬参考壳的样子。
2. 从【壳参考库】选一只与本局已启用区域最接近的壳。按该壳备注改写样本里的名称、栏目、配色和内部小节。本局没有的区从样本骨架删除并重排,不留空位。
3. 为每个用户可见区域定义 block、位置、显示名和渲染方式。模型生成区读取对应 block;程序投影区直接绑定权威数据。
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` 预览数据,覆盖所有可见区域与空态。
模板总量不超过 60 KB。材料足够便直接成稿;会改变阅读结构的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
@@ -51,12 +54,14 @@ feeds: gm,narrator
1. 语义区域必须全部有去处;未启用区域不补齐,隐藏维护区不渲染。
2. 正文是主阅读面。辅助状态、线索、地图和文字选项按用途确定层级,不平均分割注意力。
3. 程序投影区只消费权威 block:变量、拓扑和程序结果不要求模型重复填写。
4. HTML 只放稳定骨架;数据由 `present.onData` 注入。缺 block 时按合同省略或显示空态。
5. Tab、折叠、缩放等纯本地交互可以使用;不得联网、提交表单、弹窗、顶层导航或向宿主发送用户行动。
6. Markdown 使用宿主提供的安全渲染能力;动态文字使用安全 API 或转义后写入,不能拼接未清洗 HTML。
7. 地图/关系图由 JS 根据结构化拓扑绘制;显示坐标只属于布局,不回写世界真相。
8. 预览只用于验收前端,不写成开场白或正史。
9. 美化落在配色、字体层级、边框、间距和简单内联 SVG 上,写进 CSS,按本次能写稳的程度完成。
4. HTML 只放稳定骨架,每个可见区一个 `data-block` 空节点。程序按数据契约灌数;`present.onData` 只留给拓扑和本地交互。markdown/text 正文禁止用 `textContent` 写入(会挤掉换行并露出 `**`);若本地必须写正文,用 `present.markdown` 再赋 `innerHTML`。缺 block 时按合同省略或显示空态。不要把气泡做成独立窗口。
5. 外壳铺满宿主可用宽度(`width: 100%`、背景与顶栏跟宽走)。行长只限正文柱,不限整壳;缺则按宿主全宽排,不另造居中窄卡片。
6. Tab、折叠、缩放等纯本地交互可以使用;不得联网、提交表单、弹窗、顶层导航或向宿主发送用户行动。
7. Markdown 使用宿主提供的安全渲染能力;动态文字使用安全 API 或转义后写入,不能拼接未清洗 HTML。
8. 地图/关系图由 JS 根据结构化拓扑绘制;显示坐标只属于布局,不回写世界真相。
9. 预览只用于验收前端,不写成开场白或正史。示例灌数不为好看新造人名、地名、势力,用已有产物里的;文笔及格即可。
10. 美化落在配色、字体层级、边框、间距和简单内联 SVG 上,写进 CSS,按本次能写稳的程度完成。
11. 改稿以最新产物为底。用户只提美化时只改样式,`数据契约` 和 `区域落点` 保持不变;要换阅读结构必须用户明确提出。
```
## probe
@@ -72,7 +77,7 @@ feeds: gm,narrator
"schema": "context-fragment.v1",
"技能": "正文组成",
"brief": "一句话说明普通消息的阅读结构与特殊渲染",
"mount": ["world-simulator", "narrator"],
"mount": [],
"稳变": "stable",
"正文": {
"参考壳": [
@@ -121,8 +126,8 @@ feeds: gm,narrator
}
],
"frontend": {
"html": "<div id=\"app\">完整稳定骨架</div>",
"css": "完整样式;正文优先;含窄屏规则",
"html": "<div id=\"app\"><article data-block=\"body\"></article><section data-block=\"本局其它可见区域 id\"></section></div>",
"css": "完整样式;根容器 width:100%;正文优先;行长只限正文柱;含窄屏规则",
"js": "使用 present.onData(render) 的完整渲染代码"
},
"示例灌数": {
@@ -139,13 +144,13 @@ feeds: gm,narrator
硬规则:
1. 输出合法 JSON;`数据契约.blocks` 必须覆盖回复呈现的全部用户可见区域,id 保持一致。
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 不嵌入本轮数据;动态文字安全渲染。禁止网络、表单、弹窗、顶层导航和宿主行动回传。
5. topology 渲染读取结构化节点与连接;不得要求游玩模型输出 SVG。
6. `示例灌数` 是非正史预览,schema 固定 `present.v1`,只含用户可见 blocks。
7. 不重定义语义区域、篇幅、文风、变量规则、随机数或 0 层槽位更新。
8. 产物正文不含自评、追问或开放问题。
9. `mount` 固定为 `["world-simulator", "narrator"]`;兼容旧称 tag `设计.回复格式`。
9. `mount` 固定为 `[]`:壳不进游玩投影;程序持有 frontend,只向模型注入数据契约。兼容旧称 tag `设计.回复格式`。
## score
@@ -156,9 +161,9 @@ feeds: gm,narrator
- 1:漏区,或擅自添加回复呈现没有的内容。
阅读结构:
- 5:正文始终是主阅读面,辅助信息层级与响应式布局服务本局体验。
- 3:总体可读,但某个并列区略抢正文或窄屏处理不足。
- 1:区域平均堆叠,阅读焦点混乱。
- 5:正文始终是主阅读面;外壳铺满宿主宽度;辅助层级与响应式服务本局体验。
- 3:总体可读,但并列区略抢正文、窄屏不足,或整壳被限宽成居中卡片。
- 1:区域平均堆叠,阅读焦点混乱,或外壳明显窄于宿主。
权威绑定:
- 5:变量、拓扑和程序结果只读权威投影,模型生成区边界清楚。
@@ -182,7 +187,7 @@ feeds: gm,narrator
- [ ] 回复呈现的全部可见区域均有 block、落点与渲染方式?
- [ ] 隐藏维护区未进入 DOM 或预览?
- [ ] 程序投影的变量和拓扑没有要求模型重写?
- [ ] HTML/CSS/JS 完整,JS 使用 present.onData(render),总量不超 60 KB?
- [ ] HTML/CSS/JS 完整,每个可见区有 `data-block` 空节点,总量不超 60 KB,根容器铺满宿主宽度,正文不用 textContent 灌 markdown,没有整壳居中窄卡片或独立滚动窗口?
- [ ] 美化已用配色、字体层级、边框、间距或简单 SVG 写进 CSS,程度限于本次能写稳的部分?
- [ ] 本地交互不联网、不回传行动,动态内容安全渲染?
- [ ] present.v1 预览覆盖可见区域并明确非正史?
@@ -196,5 +201,5 @@ feeds: gm,narrator
- 正文 + 地图:JS 根据 topology block 绘制 SVG;节点名称安全转义,窄屏可切到列表降级。
- 多个辅助区:确有必要时使用本地 Tab;切换只改变当前气泡显示,不向宿主发送消息。
- 武侠阅读:纸色、墨色、细线框和一条简单 SVG 纹样;只保留本局启用的区。
- 不合适:把隐藏 state_ops 做成折叠面板;只写“机械风界面”却不给代码;让模型每轮输出地图 SVG。
- 不合适:把隐藏 state_ops 做成折叠面板;只用 textContent 写正文导致不分段、露出 `**`;只写“机械风界面”却不给代码;让模型每轮输出地图 SVG。
```
@@ -9,14 +9,16 @@ artifact: 设计.监控栏
declaration: >
定义普通楼层每轮回复实际启用的语义区域、出现条件、权威来源与内容要求;不决定前端排版和渲染。
when: |
已有故事推进及本局需要展示的变量、拓扑或其它内容后使用;排在正文组成与开场白之前。
本局会游玩、每轮有一条助手回复时就使用。默认声明正文区和文字选项;用户明确不要选项时关掉选项区。回复里还有抬头、状态、小故事或数据卡时,也在本步声明。
排在故事推进与交互范式之后、正文组成之前。用户谈到选项、状态栏、每轮除正文外还看什么时也使用。
when_not: |
每轮只需普通正文且语义合同已由上游明确、无需额外区域时可使用最小正文区域,不扩展其它块。
只讨论 HTML、CSS、Tab、分区或具体渲染 → 正文组成。
本局没有游玩回复(纯转换、资料整理、一次性成稿)→ 不使用。
持久 0 层卡、不增加普通楼层 → 0层回复呈现。
语义区已经钉死,只讨论 HTML、CSS、Tab 或分区 → 正文组成。语义区还没有时,仍先本步。
boundary: |
本能力决定回复由哪些可见或隐藏语义区域组成、何时出现、内容来自哪里、怎样才算填对。
正文组成负责把这些区域放到前端的具体位置并实现 HTML/CSS/JS、Tab 和特殊渲染。
故事推进是单轮字数及扩写/续写预算的唯一来源;本能力不重复定义篇幅。
字数区间与扩写/续写占比以故事推进与交互范式已写的为准,本步照写进正文内容要求,不另起一套数字。
变量设计与拓扑图谱定义权威数据和更新规则;本能力只决定回复是否包含其展示区或隐藏更新区。
feeds: gm,narrator
```
@@ -24,7 +26,7 @@ feeds: gm,narrator
## opening
```opening
每轮回复除正文外,还需要哪些语义内容?逐项说明它何时出现、由模型生成还是由程序投影,以及用户是否可见。若有地图或状态展示,请指出权威数据来源;若维护策略要求结构化隐藏更新,也把它列为隐藏区域。
每轮回复除正文外,还需要哪些可见内容?逐项说明它何时出现、由模型生成还是由程序投影,以及用户是否可见。有地图或状态时,指出要投影的字段。
```
## task
@@ -39,9 +41,9 @@ feeds: gm,narrator
- 对应的上游引用与正向内容要求。
- 没有内容时是省略、给空态,还是仍须输出。
正文区域只引用 `设计.故事推进` 的单轮篇幅与扩写/续写预算,不复制数值。状态、地图等权威内容使用程序投影,正文模型不重复生成。选项若启用,只定义文字建议的组成规则,不声明点击交互。
正文区的 `内容要求` 照写 `设计.故事推进与交互范式` 里已经定下的目标字数或区间、扩写占比、续写占比。数字与上游一致,不另起一套。状态、地图等权威内容使用程序投影,正文模型不重复生成。文字选项默认启用:`内容约束` 写 4 条,每条约束选项该满足什么,不写成品行动句。用户明确不要选项时,省略该区。
简单变量采用 `inline_tool / inline_structured` 内联维护时,回复合同包含用户不可见的变量更新语义区;程序优先把它投递为 tool call,工具不可用时改为结构化 `state_ops`。`auditor_bundle` 由副 Worker 维护,不建立主回复更新区。
表维护由旁观副 LLM 固定承担;本步**不**建立主回复里的隐藏变量更新区(无 `state_ops` / maintenance_channel)。可见区只投影权威数据。
材料足够便直接成稿;真正会改变区域合同的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
```
@@ -52,12 +54,12 @@ feeds: gm,narrator
1. 语义区域回答“回复里有什么”,正文组成回答“在前端哪里、怎么显示”。
2. 只写实际启用区域。固定每轮出现用 `every_turn`;条件出现须给可判定条件。
3. 模型生成区写内容要求;程序投影区写权威 ref 与字段选择。程序已有的数据不让模型复述一份。
4. 正文篇幅、扩写和续写预算只引用故事推进;文风只引用叙事指南。
4. 正文内容要求照写故事推进与交互范式里的字数区间和扩写/续写占比,数字保持一致。文风只引用叙事指南,不抄遣词全文。
5. 状态与拓扑的真相来自运行数据。可见区域只选择投影字段,不在这里修改变量或拓扑。
6. 简单变量的隐藏更新也是语义区域;运行期可投递为 tool call 或结构化 `state_ops`。副 Worker 路径不在主回复伪造空区域。
7. 选项是文本建议。是否有交互、点击后做什么,只有正文组成和宿主前端真正实现时才声明。
6. 不在主回复建隐藏填表区;表由旁观副 LLM 维护。
7. 文字选项默认每轮都有,用户明确不要时整区省略。选项是文本建议;是否可点击,只有正文组成和宿主前端真正实现时才声明。
8. 区域要求使用正向规格;没有特殊要求便保持短,不为完整感堆区域。
9. 每个可见区属于一种:抬头、正文、小故事、数据卡、结构、文字选项。抬头是页首一行短内容;条目一多或当前值一长则改用数据卡。数据卡和结构要写出露出的列或字段。隐藏的变量更新不占这些区。
9. 每个可见区属于一种:抬头、正文、小故事、数据卡、结构、文字选项。抬头是页首一行短内容;小故事是旁支短文,不推进主线,也不复述正文;条目一多或当前值一长则改用数据卡。数据卡和结构要写出露出的列或字段。
```
## probe
@@ -90,11 +92,12 @@ feeds: gm,narrator
"区域种类": "正文",
"来源": {
"方式": "model_generated",
"ref": "设计.故事推进"
"ref": "设计.故事推进与交互范式"
},
"内容要求": [
"遵守故事推进中的单轮篇幅与扩写/续写预算",
"遵守叙事指南;在本轮自然停笔点结束"
"目标字数或区间:照写故事推进与交互范式",
"扩写占比与续写占比:照写故事推进与交互范式,两者一致",
"遵守叙事指南"
],
"空值处理": "error"
},
@@ -107,18 +110,46 @@ feeds: gm,narrator
"方式": "every_turn|conditional",
"条件": "conditional 时写程序或模型可判断的条件"
},
"内容类型": "text|markdown|kv|list|table|topology|state_ops",
"区域种类": "小故事|数据卡|结构|文字选项",
"内容类型": "text|markdown|kv|list|table|topology",
"区域种类": "抬头|小故事|数据卡|结构|文字选项",
"来源": {
"方式": "model_generated|program_projection|maintenance_channel",
"方式": "model_generated|program_projection",
"ref": "上游产物、运行状态或维护策略的稳定引用",
"字段": ["程序投影时只选择实际需要的字段"],
"投递": "仅维护区使用:runtime_selects_tool_or_structured"
"字段": ["程序投影时只选择实际需要的字段"]
},
"内容要求": [
"正向说明该区域应该提供什么"
],
"空值处理": "omit|empty_state|error"
},
{
"id": "choices",
"名称": "文字选项",
"用途": "给用户几条方向不同的行动参考",
"用户可见": true,
"出现": {
"方式": "every_turn",
"条件": ""
},
"内容类型": "list",
"区域种类": "文字选项",
"来源": {
"方式": "model_generated",
"ref": "设计.故事推进与交互范式"
},
"选项": {
"数量": "本局每轮几条",
"内容约束": [
"选项必须满足的条件",
"第二条约束,仍不是成品行动",
"第三条约束",
"第四条约束"
]
},
"内容要求": [
"按四条内容约束生成;每条是约束,不是成品行动"
],
"空值处理": "omit"
}
]
}
@@ -127,11 +158,11 @@ feeds: gm,narrator
硬规则:
1. 输出合法 JSON;`语义区域` 只含实际启用项,不写 `启用:false` 或未启用清单。
2. 普通楼层至少含 `body`;正文只引用故事推进,不复制目标字数、占比或字数预算。
2. 普通楼层至少含 `body`,默认再含文字选项。正文 `内容要求` 照写故事推进与交互范式的字数区间和扩写/续写占比,不另起数字。
3. 每个区域必须声明出现方式、内容类型、区域种类、来源、内容要求和空值处理。可见区的区域种类只能是抬头、正文、小故事、数据卡、结构、文字选项。
4. `program_projection` 必须给稳定 ref;状态和拓扑数据不得标为临场或要求模型重新生成。
5. 内联变量更新区必须 `用户可见:false`、内容类型为 `state_ops`、来源为 `maintenance_channel`;程序按 API 能力选择 tool call 或结构化区域。`auditor_bundle` 不建立该区。
6. 文字选项只规定数量、差异维度和内容要求;不写可点击、按钮动作或回传协议。
5. 不建立主回复隐藏填表区(无 `state_ops` / `maintenance_channel`);表由旁观副 LLM 维护。
6. 文字选项默认写入。`内容约束` 恰好 4 条,每条是对选项的约束,不是成品行动。不写可点击、按钮动作或回传协议。用户明确不要选项时省略该区。
7. 不选壳,不写布局、HTML、CSS、JS、Tab、SVG 或槽位替换;这些属于正文组成。
8. 产物正文不含自评、追问或开放问题。
9. `mount` 固定为 `["world-simulator", "narrator"]`;artifact tag 暂时保留 `设计.监控栏`。
@@ -160,9 +191,9 @@ feeds: gm,narrator
- 1:正文外长期堆积大量无关信息。
职责边界:
- 5:只定义语义内容,并把渲染、维护真相和篇幅分别交给正确节点。
- 3:主体边界正确,但夹有少量排版或重复篇幅要求。
- 1:在本步写 HTML、变量公式或重新定义推进篇幅。
- 5:只定义语义内容;字数区间和占比与故事推进与交互范式一致,渲染和变量真相交给对应节点。
- 3:主体边界正确,但夹有少量排版,或占比和上游不一致。
- 1:在本步写 HTML、变量公式,或另起一套字数。
```
## checklist
@@ -171,17 +202,27 @@ feeds: gm,narrator
- [ ] 只列实际启用区域,普通楼层含正文?
- [ ] 每个区域的出现条件、来源、要求与空值处理完整?
- [ ] 状态、地图等权威数据均由程序投影,模型没有重复生成?
- [ ] 内联维护的隐藏 state_ops 已声明运行期选路,副 Worker 路径未建立该区?
- [ ] 正文只引用故事推进,没有重复字数和比例?
- [ ] 没有建立主回复里的隐藏填表区?
- [ ] 正文内容要求写了字数区间和扩写/续写占比,且与故事推进与交互范式一致?
- [ ] 默认有文字选项;内容约束恰好 4 条,每条是约束而不是成品行动?
- [ ] 用户明确不要选项时,该区已省略?
- [ ] 未写布局、HTML/CSS/JS、Tab、SVG 或交互动作?
```
## examples
```examples
- 只有正文:只列 body,并引用故事推进和叙事指南。
- 展示状态:新增 kv 区域,来源指向变量当前值及明确字段;内容由程序投影。
- 展示地图:新增 topology 区域,说明用户应看到哪些节点、连接或当前位置;具体 SVG、表格或 Tab 留给正文组成。
- 简单变量内联维护:新增用户不可见的 state_ops 语义区域;运行期优先投递为 tool call,工具不可用时进入结构化整包。
- 文字选项:写数量、彼此差异和应提供的行动方向;不声明点击行为。
好:
- 正文内容要求照写上游已定的「800–2500 字,扩写 60%,续写 40%」,不改成另一套区间。
- 文字选项默认在。内容约束四条,例如「一句此刻能做的行动」「彼此风险不同」「不写旁白」「不重复上轮原句」。
- 展示状态:新增 kv 区,来源指向变量当前值及要露出的字段,由程序投影。
缺料时:
- 故事推进还没写字数区间 → 正文内容要求该项写「未定」,gap 指向那一份,不在本步另定数字。
- 用户说不要选项 → 省略文字选项区,其余照写。
偏了就改回:
- 内容约束写成四条成品行动(「推门进去」「先观察」)→ 改成四条必须满足的约束。
- 本步把 800–2500 改成 2000–4000 → 改回故事推进与交互范式里的数字。
- 为了完整感再加隐藏填表区 → 删掉;表由旁观副 LLM 维护。
```
@@ -1,39 +1,45 @@
# 故事推进
# 故事推进与交互范式
## meta
```meta
name: 故事推进
name: 故事推进与交互范式
id: story-progression
artifact: 设计.故事推进
artifact: 设计.故事推进与交互范式
declaration: >
从偏好库选取本局约束,只选定一种用户输入用法,并写清单轮目标字数、扩写与续写的定义和篇幅分配、OOC 处理;不写遣词。
把交互权限和推进合同写成同一份:谁能代写、谁裁定、何时停笔,以及唯一输入用法、扩写/续写和单轮篇幅。不写遣词。
when: |
已有美学纲领(或等价),但尚未钉死唯一的用户输入用法,或扩写/续写边界与比例仍不清。
用户主动谈推进、节奏、停笔、大纲扩写、续写、自动写入时也使用。
已有美学体验(或等价短锚),但还没把权限和推进钉成一份合同:谁代写、何时停、输入是大纲还是扮演、扩写和续写各做什么、单轮多长。
用户主动谈权限、停笔、推进、大纲扩写、续写、自动写入、单轮字数时也使用。
when_not: |
本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。
美学未钉 → 先「美学纲领与交互范式」。
只改遣词、笔墨、写法档 → 「叙事指南」。
固定输入标记(裸写 / `#行动#` / 引号)由程序解析,不在本步重定义或按局反转。
体验内核还没有 → 先「美学纲领」。本步引用那三句短锚,不改写体验正文。
只改遣词、笔墨、写法档、段落形态 → 「叙事指南」。
每轮回复里有哪些区域、选项怎么列 → 「回复呈现」。
好感公式、进度表、事件池 → 「变量设计与更新规则」/「生成规则」。
固定输入标记(裸写 / `#行动#` / 引号)由程序解析,不在本步重定义。
boundary: |
本能力只交推进合同:唯一输入用法、单轮目标字数、扩写要求、续写要求、篇幅分配、OOC 处理策略。
叙事指南管怎么写;变量与生成规则管表与合同;主角详略只写偏好钩子。
本步交两块,写在同一份产物里:`交互范式`(权限与停笔)和推进合同(用法、扩写、续写、篇幅)。
停笔只写在 `交互范式.停笔时机`;续写要求只写怎样走到这个停点,不把停笔条件再写一遍。
单轮字数只在本步定义。选项、抬头、状态等可见区域归回复呈现。段落形态归叙事指南。
变量与生成规则管表,本步只写钩子。
feeds: gm,narrator
```
## opening
```opening
先选定这局唯一的输入用法:大纲扩写、续写,或自动写入。再说清楚单轮大约写多长、扩写和续写各做什么及各占多少;上帝视角/OOC 指令准备怎么处理。
先钉谁能替你写、写到哪必须停。再选定唯一输入用法:大纲扩写、续写,或自动写入。然后说单轮大约多长,扩写和续写各做什么、各占多少。
```
## task
```task
把本局推进合同写入 `设计.故事推进`。先用 `read_library_entry` 从绑定的偏好库读取最相关 1~2 条并本局化,再只选定一种用户输入用法,按该用法写清单轮目标字数、扩写定义、续写定义、两者各自篇幅预算,以及裸写(上帝视角/OOC)指令如何处理。
把交互权限和推进合同一起写入 `设计.故事推进与交互范式`。先读美学的体验锚定三句,按它写 `交互范式`:人称与视角、逐类描写权限、后果由谁裁定、停笔时机。权限要让那句体验发生得了。再用 `read_library_entry` 从偏好库取最相关 1~2 条并本局化,钉推进方式(不明则「无专门推进」),只选一种用户输入用法。
禁止混用两种用法或写成半套规则。固定标记怎么识别交给程序;本步只写识别之后怎么服从与落笔。不写遣词、示范段落或版式。
扩写与续写是每一轮正文里的两段工作,不是两种输入用法。按所选用法写清扩写做什么、续写怎样走到已写的停笔时机、两者的字数预算,以及裸写指令如何处理。停笔条件不在续写要求里再抄一遍。
不混用两种用法。固定标记怎么识别交给程序;本步只写识别之后怎么服从。不写遣词、段落形态、选项清单或版式。
材料足够便直接成稿;多种用法都像、必须用户点选时,把缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
```
@@ -41,16 +47,28 @@ feeds: gm,narrator
## principles
```principles
1. 全局只允许一种用户输入用法。切分依据是扩写与续写的定义,以及两者在本局的职责和篇幅预算。
2. 三种用法互斥,写入时只保留一个完整合同:
- 大纲扩写:输入是待演成场面的大纲。扩写要把每个节拍演满,节拍上的人、事、话、结果保持不变,节拍之间补具体动作把处境写出来;续写短,且不代用户做新决定。
- 续写:扩写只跟用户本轮已声明的行为与对话;续写为主,停在其他角色等待用户抉择处,不替用户做态度或推进性决定。
- 自动写入:输入多为短 meta 或极简扮演;续写宜长;可按主角设定代演用户角色言行。
3. 多写“要怎样完成扩写/续写”,少写否定清单;只有会破坏该用法边界的禁点才保留。
4. OOC/上帝视角内容由程序从裸写识别;本步写处理策略:服从到什么程度、是否打断当前场面、如何与既定扩写/续写比例兼容。
5. 偏好库是参考:读取后改成本局可执行要求,不照抄。
6. 单轮篇幅在这里一次钉死:给目标字数或窄区间,并把扩写、续写的占比与字数预算对应起来;其它节点直接消费,不重复定义。
7. 不写遣词、写法档、版式或事件池公式。
1. `用户输入用法` 与「扩写 / 续写」不是同一层。用法是本局选哪一种合同(三选一);扩写、续写是每一轮正文里先后做的两段工作。产物必须先按所选用法写清这两段各自做什么,再分配篇幅。
2. 扩写:主路径不动,把用户本轮已经给出的材料演成流畅场面。人、事、话、结果保持不变;允许在节拍之间增加使动作接得上的小动作、感官和当场小纠正。只加形容词、比喻或气氛、不增加这些连接动作,不算扩写。另起新冲突、新目标、新决定,或改掉已定结果,算出界。
例:用户写「打开冰箱门、放入大象、关闭冰箱门」——主路径就是这三步,结果仍是大象进了冰箱。只写这三句会不流畅,扩写应补门怎么拉开、大象怎么塞进去、门关不上再用力扣上等细节;不能改成没放进去、或中途改去别的房间。
- 用法为「大纲扩写」时:材料是待演成场面的大纲,不是终稿句子。每个节拍都要演到;节拍之间必须补连接动作,大纲没写这些动作也要写。另例:大纲「我去相亲、坐了下来,她说要婚后自由出轨权,我拒绝了」——坐下前写先要一杯咖啡,又急忙改成两杯,显出局促;她的要求和拒绝的结果不动。
- 用法为「续写」或「自动写入」时:材料是用户本轮已声明的行为与对话。扩写只在这些声明之间补过程与连接动作,可写他人怎么接这一下;不另写用户没声明的新决定。
3. 续写:从用户材料结束后往下写。写其他角色与世界如何反应、把场面推到下一个停笔点。默认不可再写用户角色的实际行动、说话或带倾向抉择;最多被动占位(望去、让开)。整轮停在其他角色等用户角色行动抉择之刻。
- 用法为「大纲扩写」或「续写」时:禁止替用户角色做决定,不引入材料外的新剧情事实。
- 用法为「自动写入」时:可按主角设定代演用户角色言行,把续写写长;关键抉择仍按交互范式的权限处理。
4. 三种输入用法互斥,按刚写的交互范式选一种。差别在输入是什么、扩写/续写谁长谁短、续写能不能代演用户角色:
- 大纲扩写:用户是写手、要大纲成稿,输入多为剧情节拍时选它。扩写充分、续写短。
- 续写(旧称扮演停笔):代入扮演、轮转等用户决策时选它(交互范式规定关键抉择或言语只由用户做、停笔时机是等用户回应)。输入往往较短;扩写只跟已声明内容,续写多于大纲扩写、短于自动写入。
- 自动写入:用户只给短 meta 指令或极简扮演、希望 AI 代演用户角色,且交互范式允许模型代写 user 言行(旁观、操控站位常见)时选它。扩写少、续写宜长。
5. 多写“要怎样完成扩写/续写”,少写否定清单;只有会破坏该用法边界的禁点才保留。
6. 推进可轻可无:`推进方式` 写「场面自推 / 等用户决策 / 无专门推进」之一,不明则写「无专门推进」。不自己发明好感公式、进度表或事件池;表规格交给「变量设计与更新规则」与「生成规则」。
7. 推进子维度按需填,推进方式为「无专门推进」时可全省:
- `驱动力`:故事靠谁、靠什么往前(NPC 主动作为、世界压力与时限、用户决策),承接交互范式的 `世界对user的影响` 与 `引入新内容`。
- `成败与难度`:用户行动会不会失败、失败怎么写(代价落在哪、挫败场面写多重),承接 `行为后果判定` 与 `设计困境`。体验本身要顺遂碾压时写明「不设失败」以及仍保留的阻力;未写明时不默认无条件顺遂。
- `变量钩子`:推进挂在哪些变量上,每条一句「变量名:推进如何看它」;只写钩子,不写公式、阈值或数值表。
8. OOC/上帝视角内容由程序从裸写识别;本步写处理策略:服从到什么程度、是否打断当前场面、如何与既定扩写/续写比例兼容。
9. 偏好库是参考:读取后改成本局可执行要求,不照抄。
10. 单轮篇幅在这里一次钉死:给目标字数或窄区间,并把扩写、续写的占比与字数预算对应起来;其它节点直接消费,不重复定义。段落形态归叙事指南,本步不写。
11. 不写遣词、写法档、段落形态、版式、选项文案或事件池公式。可见区域交给回复呈现。
```
## output
@@ -58,11 +76,41 @@ feeds: gm,narrator
```output
{
"schema": "context-fragment.v1",
"技能": "故事推进",
"技能": "故事推进与交互范式",
"brief": "一句话:唯一输入用法 + 单轮篇幅与扩写/续写分配",
"mount": ["world-simulator", "narrator"],
"稳变": "stable",
"正文": {
"交互范式": {
"前置配置": {
"用户与user关系": "完全代入 / 部分代入 / 旁观 / 操控",
"人称": "如:第二人称称「你」",
"元叙事": "决策是否用情节呈现"
},
"叙事视角": {
"主要视角": "镜头跟谁、看什么",
"视角切换": "不允许,或允许的例外",
"可写内心": "可以写谁的内心",
"不呈现的信息": "正文不能透露的内容"
},
"描写权限": {
"思考与抉择": "关键决定写到哪里停",
"言语": "能否替 user 说话、说到什么程度",
"主动行动": "能否替 user 发起行动",
"情绪与身体反应": "可补写的范围",
"习惯与既有技能": "可按既有设定自动执行的部分",
"外在状态": "外貌、伤势、衣着可否直接写",
"用户输入后": "只确认 / 补细节 / 连带推出后果"
},
"后果与叙事控制": {
"行为后果判定": "谁裁定成败与代价",
"世界对user的影响": "NPC 与世界如何作用于 user",
"时间与场景": "可否推进时间、切换场景,跨度多大",
"引入新内容": "可否引入新人物、事件、威胁",
"设计困境": "是否制造两难;怎样的两难服务体验"
},
"停笔时机": "必须等用户的时机;续写停在这里,不另写一套"
},
"选用偏好": [
{
"库条目": "preferences 中的 id",
@@ -70,9 +118,18 @@ feeds: gm,narrator
}
],
"推进方式": "场面自推 / 等用户决策 / 无专门推进",
"驱动力": "可选:故事靠谁、靠什么往前;无专门推进时省略",
"成败与难度": "可选:用户行动会不会失败、失败怎么写、代价落在哪;无专门推进时省略",
"变量钩子": ["可选:变量名:推进如何看它(只写钩子,不写公式)"],
"用户输入用法": "大纲扩写 | 续写 | 自动写入",
"扩写要求": ["该用法下扩写要做什么、守住什么结果"],
"续写要求": ["该用法下续写写到哪里、何时停笔"],
"扩写要求": [
"主路径(人、事、话、结果)不动;节拍之间补连接小动作,把场面写顺。例:打开冰箱、放入大象、关上冰箱——补怎么塞、门怎么扣,不能改成没放进去",
"所选用法下材料是什么、节拍之间补什么(按 principles 第 2 条写具体)"
],
"续写要求": [
"从材料结束后写他人与世界反应,停在等用户角色回应之刻",
"所选用法下能否代演用户角色、续写写到哪停(按 principles 第 3 条写具体)"
],
"单轮篇幅": {
"目标字数": "明确数字或窄区间",
"扩写占比": "百分比",
@@ -88,25 +145,26 @@ feeds: gm,narrator
```
硬规则:
1. 输出合法 JSON;`用户输入用法` 只能是三者之一,不得并列或混装半套。
2. `扩写要求`、`续写要求`、`单轮篇幅` 必须与所选用法一致;两项占比合计 100%,字数预算与目标字数对应。
3. `选用偏好` 默认 1~2 项;无合适条目时可 `[]`,但仍须钉死唯一用法。
4. 不重定义裸写 / `#行动#` / 引号的识别规则;只写 OOC 处理策略。
5. 不在产物正文写自评、追问、开放问题或遣词示范。
6. `mount` 固定包含 `world-simulator` 与 `narrator`。
1. 输出合法 JSON。`交互范式` 与推进合同在同一份正文里。`用户输入用法` 只能是三者之一。`停笔时机` 是唯一停笔定义;`续写要求` 只写如何走到该停点。不写选项清单。
2. `扩写要求` 只写「把本轮材料演成场面」怎么做;`续写要求` 只写「材料结束后往下写」怎么做。不要把另一种输入用法的规则抄进这两项。两项占比合计 100%,字数预算与目标字数对应。
3. `推进方式` 是单个字符串;`驱动力`、`成败与难度`、`变量钩子` 可选,`变量钩子` 不写公式或数值。
4. `选用偏好` 默认 1~2 项;无合适条目时可 `[]`,但仍须钉死唯一用法。
5. 不重定义裸写 / `#行动#` / 引号的识别规则;只写 OOC 处理策略。
6. 不在产物正文写自评、追问、开放问题、遣词示范或段落形态。
7. `mount` 固定包含 `world-simulator` 与 `narrator`。
## score
```score
唯一性与切分:
- 5:只选定一种用法;扩写/续写定义、目标字数和预算切分清楚,无第二种用法残留。
- 5:只选定一种用法,且与交互范式的权限和停笔时机相符;扩写(演本轮材料)与续写(材料后往下写)定义清楚,目标字数和预算切分清楚,无第二种用法残留。
- 3:主用法明确,但仍有一处边界、占比或字数预算含糊。
- 1:混用多种用法,或扩写/续写职责无法区分。
- 1:混用多种用法,或把扩写/续写当成另一种输入用法、两段职责无法区分。
可执行性:
- 5:按该合同可直接开玩,扩写、续写、停笔与 OOC 处理都能执行。
- 3:大方向可用,但停笔或 OOC 处理仍需猜测。
- 1:只有用法名称,没有可执行要求。
- 5:按该合同可直接开玩,推进方式、扩写、续写、停笔与 OOC 处理都能执行;有推进时成败与难度写清,不默认无条件顺遂。
- 3:大方向可用,但停笔、成败或 OOC 处理仍需猜测。
- 1:只有用法名称,没有可执行要求,或自行发明公式与进度表。
偏好本局化:
- 5:已读偏好并改成本局要求,与唯一用法咬合。
@@ -117,10 +175,22 @@ feeds: gm,narrator
## checklist
```checklist
- [ ] 只选定一种用户输入用法?
- [ ] `交互范式` 已写清逐类权限、裁定和唯一停笔时机,并与输入用法一致?
- [ ] 推进方式已写(不明则「无专门推进」),未发明公式、进度表或事件池?
- [ ] 只选定一种用户输入用法,且符合它的适用场景?
- [ ] 扩写与续写已按两段工作分别写清(扩写=演本轮材料,续写=材料后往下写),且与所选用法相符?
- [ ] 扩写已写明「主路径不动、节拍之间补连接小动作」?只加形容词、或改掉已定结果,都不算写清。
- [ ] 单轮目标字数、扩写/续写定义、占比与字数预算已写清且互相一致?
- [ ] OOC/裸写处理策略已写,且未重定义固定标记语法?
- [ ] 偏好已按需读取并本局化?
- [ ] 未写入遣词、写法档或半套混用规则?
- [ ] 未写入遣词、写法档、段落形态或半套混用规则?
- [ ] 产物正文不含评分、诊断、追问?
```
## examples
```examples
好:用法钉「续写」;用户写「我推开门」——扩写补门轴、屋里人抬头,不另写用户没说的台词;续写写屋里人的反应,停在等用户开口之刻。
好:用法钉「大纲扩写」;用户写「打开冰箱门、放入大象、关闭冰箱门」——扩写补门怎么拉开、大象怎么塞、门关不上再扣上,三步结果不变;续写短。
坏:把「扩写」理解成另一种输入用法;只把三句动作各加形容词、场面仍不接;中途改成没放进冰箱或另起新目标;续写里替用户角色补一句台词。
```
@@ -7,15 +7,20 @@ name: 拓扑图谱
id: topology
artifact: 设计.拓扑图谱
declaration: >
为一个独有但结构复杂的对象定义节点、连接、初始图与开场后的维护规则;可用于地图、关系链、升级树等可变备忘录。
为一个独有但结构复杂的对象定义节点、连接、初始图与开场后的维护规则。
地图、关系链、升级树,以及社会阶梯、境界、价目、兑换等级这类有序标尺,都在这里;一份产物一张图。
when: |
单个对象要用节点和连接才能写清时使用,例如地图、升级路径、关系网。
要保存的结构是树、图或链时使用:节点之间有上下、相邻或交叉关系,拿掉连接就说不清。
树:升级路径、分支前提、从属。
图:地图、关系网,节点可多向相连。
链:社会阶梯、境界、价目、兑换等级,每一档只和相邻档比较。
一张图一份产物。不因开场后会不会再长节点而改用别的能力。
when_not: |
没有节点和连接,只是同类对象的多份记录,或一份要先审字段的合同 → 生成规则,再按需填具体实例。
只有一条字段很少的具名内容,且用户明确要留下 → 具体实例。
只有地点、势力或社会格局的叙述,没有可定义的连接 → 舞台骨架。
只需保存少量数值或枚举 → 变量设计。
世界里成立的因果、触碰的代价 → 世界法则;那里只点本图的图名,不抄档表。
只需保存少量数值或枚举(当前点数、当前落在哪一档)→ 变量设计。
boundary: |
本能力定义一张图的结构真相与维护合同,不负责界面、SVG、排版或是否展示。
「正文组成」决定是否渲染以及显示入口;维护合同编译工具负责把本产物的更新要求交给运行期维护者。
@@ -26,7 +31,7 @@ feeds: gm,auditor
## opening
```opening
说说要保存的这张图是什么、游玩时用它记住什么,以及开场后它应保持固定、只记录状态,还是允许继续长出节点和连接。
说说要保存的这张图是什么。地图、关系、升级树,或是一套有序标尺(社会阶梯、境界、价目、兑换等级)都可以。再补一句:开场后它保持固定、只记录状态,还是允许继续长出节点和连接。
```
## task
@@ -34,6 +39,8 @@ feeds: gm,auditor
```task
为一个独有的结构对象设计拓扑图谱,写入 `设计.拓扑图谱#图名`。每份产物只定义一张图:先写名称与用途,再定义节点字段、连接字段和初始图,最后写开场后的动态模式与维护规则。
有序标尺写成一条链,类型用 `path`:一档一个节点,连接只连相邻档。节点写这一档是谁、能做什么,或这一档的基价与强度;连接写跨过这一档要什么、和下一档差多少。档位在开场前已由用户定死时用 `static`。人物、物品、变量只填档名,不在别处重抄整表。
动态模式只选一种:
- `static`:节点、连接和状态均固定。
- `record`:节点与连接固定,但可记录发现、备注或节点/连接状态。
@@ -47,7 +54,7 @@ feeds: gm,auditor
## principles
```principles
1. 一产物一张图。不同地图、关系网或升级树分别调用本能力,不能塞进同一 `拓扑` 数组。
1. 一产物一张图。不同地图、关系网、升级树或标尺分别调用本能力,不能塞进同一 `拓扑` 数组。社会阶梯和价目是两张图。
2. 用途决定字段。节点与连接只保留识别、推演和维护所需信息;字段不重叠,不把节点写成完整名片。
3. 节点 id 稳定且唯一;连接只能引用已存在节点,并明确方向、关系和必要属性。
4. 动态模式决定维护权限:`static` 无运行写入;`record` 只改允许的状态字段;`expand` 才能改变图结构。
@@ -157,9 +164,9 @@ feeds: gm,auditor
```score
对象适配:
- 5:确实是一张独有的关系结构,普通实例或扁平变量不足以表达。
- 5:确实是一张独有的关系结构或有序标尺,普通实例或扁平变量不足以表达。
- 3:存在真实连接,但部分内容可移交舞台、实例或变量。
- 1:只是清单、名片或背景散文,没有拓扑必要性。
- 1:只是互不相连的清单、名片或背景散文。
字段合同:
- 5:节点与连接字段由用途推出,数量恰当、互不重叠,更新权限明确。
@@ -199,6 +206,8 @@ feeds: gm,auditor
- 固定空间:节点与通路固定;若只需记录“已发现/封锁”,使用 record,不因探索重新生成建筑结构。
- 关系网络:人物节点固定或可扩展,关系边可记录类型、强度与公开状态;关系变化必须由正文中的已成立事实触发。
- 升级树:阶段和前置连接固定时用 static;若允许游玩中发现新分支才用 expand,并写清新增分支必须满足的前置关系。
- 社会阶梯:节点 t0 到 t5。t0 写「冠号大宗师里的顶尖,不动用核武器无法威胁」;t5 写「普通人,暗面对他们是都市传说」。连接只连相邻档,字段写跨档要什么(个人武力、开宗立派、表世界权位)。`static`。实例只填「t2」。
- 价目或兑换等级:一档一个节点,字段写强度范围和基价;连接写相邻档的价差。物品规则和变量只填档名与当前点数。
- 不合适:把若干互不相连的人物简介装进节点;应改用生成规则,需要留下的单条再写具体实例。
- 不合适:在本产物中指定 SVG 坐标、颜色和 Tab;展示应由正文组成决定。
```
@@ -7,16 +7,16 @@ name: 变量设计与更新规则
id: variable-design
artifact: 设计.变量设计与更新规则
declaration: >
定义必须跨轮保存的最少真值、合法更新条件、权限与派生映射,供程序选择简单状态工具或复杂维护副 Worker。
定义必须跨轮保存的最少真值、合法更新条件、权限与派生映射;有真值时由程序固定启用旁观副 LLM 按准则填表。
when: |
某项状态若不持久保存,就会导致数值、阶段、资源、门控或已发生事实跨轮漂移时使用。
when_not: |
状态只需从最近正文理解、无需查询或门控 → 不使用。
节点与连接构成的地图、关系或路径 → 拓扑图谱。
节点与连接构成的地图、关系、路径,以及社会阶梯、价目、兑换等级的全表 → 拓扑图谱。本步只存当前点数或当前档位。
大段设定或同类对象合同 → 舞台骨架 / 生成规则。
boundary: |
本能力定义什么是真值、什么事实允许它改变,以及改变后的约束;不决定 API 投递方式。
少量简单变量由程序生成主世界层状态工具;复杂变量与拓扑等由维护合同编译工具汇总。
本能力定义什么是真值、什么事实允许它改变,以及改变后的约束。
表维护固定走旁观副 LLM(读上一份表、本产物维护准则、本轮主世界正文);不在此决定内联 tool / state_ops。
变量控制上下文负责详细可见范围;正文组成负责展示。本能力不写隐藏段、正则、maintain 包或界面格式。
feeds: gm,auditor
```
@@ -30,7 +30,9 @@ feeds: gm,auditor
## task
```task
为本局定义跨轮真值及其更新规则,写入 `设计.变量设计与更新规则`。先做必要性检验,再为每个真值写稳定 id、类型、初始化要求、公开性、允许写入者、允许操作、范围和基于事实的更新规则。
为本局定义跨轮真值及其更新规则,写入 `设计.变量设计与更新规则`。先做必要性检验,再为每个真值写稳定 id、类型、初始化、公开性、允许写入者、允许操作、范围和基于事实的更新规则。
`初始化.默认值` 只放开局就要有、且具体实例里没有的那一条已发生事实:数字、枚举、布尔或空集合。人物是谁、关系、外貌和命门留在具体实例;变量要指向人时只存 id 或名字。
区分两类判断:
- `deterministic`:程序能从明确指令、公式或工具结果直接计算。
@@ -38,7 +40,7 @@ feeds: gm,auditor
若真值只用于选择分档文本或实例切片,保存真值并引用映射来源,不把派生结果建立成第二个可写真值。若跨越阈值会触发一次性或边沿效果,写成触发规则。
最后根据变量数量、相互依赖、判断方式和触发复杂度给出维护复杂度事实,供程序选择 `none / inline_tool / inline_structured / auditor_bundle`;这是建议,不绑定 API 能力。
可写简短「维护复杂度」备注(交叉依赖、须结合正文推断的点),供旁观副 LLM 参考;**程序有真值即固定开旁观填表**,不再按建议在内联/旁观间切换。勿手写 `play_slots`。
材料足够便直接成稿;真正会改变变量合同的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
```
@@ -52,8 +54,9 @@ feeds: gm,auditor
4. 类型决定操作:number/integer 可 `set|delta`;enum/string/boolean 可 `set`;set 可 `add|remove`。不用散文值绕过类型。
5. 公开性只区分用户可见与隐藏;更细的主世界层、转述、旁观投影交给变量控制上下文。
6. 阈值映射只保存引用和选取条件;长文内容仍在生成规则/具体实例中。
7. 维护复杂度按实际依赖判断,不机械按数量:一个简单余额可走 inline tool;一个可扩展关系图不属于简单变量。
8. 不在正文中设计隐藏标签、正则或维护 JSON;投递方式由程序根据 API 能力选择。
7. 默认值匹配类型,且短。人物档案、关系长文、外貌和命门不写进默认值;实例里已有的事实不在默认值再写一遍。
8. 「维护复杂度」只写给旁观看的难点备注(交叉依赖、叙事推断证据),不决定开不开旁观;有真值即旁观填表。
9. 不在正文中设计隐藏标签、正则或维护 JSON;主世界不承担隐藏填表通道。
```
## probe
@@ -79,7 +82,7 @@ feeds: gm,auditor
"用途": "不保存它会造成什么跨轮错误",
"类型": "number|integer|enum|string|boolean|set",
"初始化": {
"默认值": 0,
"默认值": "数字、枚举、布尔或空集合;不写人物档案",
"开场确定规则": "开场可覆盖默认值时写条件;否则写固定"
},
"公开性": "public|hidden",
@@ -133,7 +136,7 @@ feeds: gm,auditor
}
],
"维护复杂度": {
"建议": "inline_tool|auditor_bundle",
"备注": "交叉依赖、须结合正文推断的点;给旁观副 LLM 参考,不驱动开槽",
"依据": [
"变量数量、交叉依赖、narrative_inference、触发规则与其它可变产物"
],
@@ -147,10 +150,10 @@ feeds: gm,auditor
硬规则:
1. 输出合法 JSON;每个真值必须有稳定 `id`、用途、类型、初始化、权限、操作、约束和更新规则。
2. 类型与允许操作必须相容;number/integer 的边界、enum 的枚举值必须明确;不适用的约束字段省略。
2. 类型与允许操作必须相容;number/integer 的边界、enum 的枚举值必须明确;不适用的约束字段省略。`默认值` 与类型一致,且不是人物档案或关系长文。
3. `narrative_inference` 必须写证据要求;正文没有该证据时不更新。
4. 派生映射与触发规则可为空;存在时必须引用已声明变量,且派生结果不得成为第二个可写真值。
5. `维护复杂度.建议` 只在 `inline_tool|auditor_bundle` 中选择;程序仍按 API 能力决定具体投递方式。
5. `维护复杂度` 可选;有则写备注与依据给旁观,勿再写 `建议: inline_*` 指望关旁观。有真值时程序固定 `play_slots.auditor=true`。勿手写 `play_slots`。
6. 不输出隐藏段、正则、`maintain.v1/v2`、tool schema、Worker 提示或界面字段。
7. 产物正文不含自评、追问或开放问题。
8. `mount` 固定为 `["world-simulator", "auditor"]`。
@@ -164,9 +167,9 @@ feeds: gm,auditor
- 1:把人物感受、场景描述或大段设定全部变量化。
字段妥当性:
- 5:类型、初值、操作和约束彼此一致,字段数量恰当且不重叠。
- 5:类型、初值、操作和约束彼此一致,字段数量恰当且不重叠。默认值是短值,人物事实在具体实例。
- 3:基本可用,但有边界、枚举或初始化要求不够明确。
- 1:类型与操作冲突,或存在余额/存款一类重复真值。
- 1:类型与操作冲突,存在余额/存款一类重复真值,或默认值是一段人物关系。
更新可执行性:
- 5:每条规则都有已发生事实、判断方式、证据、操作、值规则和时机。
@@ -188,18 +191,21 @@ feeds: gm,auditor
```checklist
- [ ] 每个真值都通过“不保存会出错”的必要性检验?
- [ ] 类型、初值、允许操作、约束和写入者互相一致?
- [ ] 默认值是短值,没有把人物关系或实例里已有的事实再写一遍?
- [ ] 每条更新规则都有事实证据和可执行的值规则?
- [ ] 派生映射未被重复建成可写真值?
- [ ] 维护复杂度依据实际依赖,而不是只按变量数量?
- [ ] 维护复杂度若写了,是旁观备注而非开槽开关?
- [ ] 未写隐藏标签、正则、维护包或界面格式?
```
## examples
```examples
- 单个数值真值、只有 set/delta 和上下界、无交叉依赖:建议 inline_tool;程序按 API 探测生成类型明确的小工具。
- 多个真值需要结合正文共同判断,且阈值会触发映射或补实例:建议 auditor_bundle;维护编译工具统一收集。
- 单个数值真值或关系矩阵:都写清更新规则;有真值即由旁观副 LLM 填表,主世界只写正文。关系矩阵的默认值是空集合或少量 id,不是每人一段小传。
- 多个真值需结合正文共同判断:在维护复杂度.备注里点出推断难点与证据,供旁观读。
- 当前态度由好感区间映射得到:只保存好感,态度作为派生映射,不建立第二个可写“当前态度”。
- 不合适:每轮重写一段“当前性格”长字符串,或只写“随剧情变化”而没有证据与操作。
偏了就改回:
- 默认值写成十几个角色的关系、外貌或命门 → 档案留在具体实例,这里改成数字、枚举或 id 列表。
- 每轮重写一段“当前性格”长字符串,或只写“随剧情变化”而没有证据与操作 → 改成有证据的 set/delta,或取消该真值。
```
@@ -14,7 +14,7 @@ artifact: 设计.worker规格
declaration: >
程序根据前面已经确认的写作、维护和秘密隔离需求,整理本局需要启用的固定执行单元
when: |
落档或开玩时自动执行;从叙事、维护复杂度、信息隔离和写作路径中推导固定槽位。
落档或开玩时自动执行;从叙事、信息隔离、写作路径推导固定槽位;有变量真值时程序固定开旁观填表。
when_not: |
前置语义尚未确定时沿用安全默认值,不向用户重复询问。
boundary: |
@@ -57,7 +57,8 @@ boundary: |
2. 默认世界模拟:`gm: true, narrator: false, auditor: false, perspective: false`;
调度序 gm → perspective? → narrator? → auditor?;
主世界层必开(用户明确只要旁观/写手路径除外);
要独立文风改写才 narrator true;有变量设计/表维护需求 → auditor true;
要独立文风改写才 narrator true;
**旁观填表由程序固定**:有「设计.变量设计与更新规则」真值时落档会开 `auditor=true`,本步不必猜;手写 play_slots 时旁观与之对齐即可。
仅信息隔离才开 perspective。
3. 写手路径:`outline` + `chapter-writer` 默认都开;用户明确只要正文则可关 outline。
4. 可写简短 `mount_notes`(哪类上游产物挂哪槽),不粘贴长文。
@@ -70,8 +71,8 @@ boundary: |
```principles
1. 只勾选,不发明:ref 必须在白名单内;无机遇裁定槽。
2. 默认:世界模拟 = 仅主世界层;转述/旁观/perspective 默认关。主世界层必要。
3. 要独立文风层再开转述;有变量/表/规则要盯再开旁观。
2. 默认:世界模拟 = 仅主世界层;转述/perspective 默认关。主世界层必要。
3. 要独立文风层再开转述。表维护固定旁观副 LLM:有变量真值即 auditor true,不再按 inline 建议关旁观。
4. 变量 / Data / Progressive 不是执行单元;旁观维护只出 maintain.v1,不写真相。
5. 删掉检验仍适用:关某个槽要说得清损失什么。
6. 本步不输出完整 Worker 集、不写 tables 全文、不写随机范围表(交给细化终稿 / 变量设计 / 随机范围整理)。
@@ -85,10 +86,9 @@ boundary: |
- 是否必须独立转述改写正文?(要才 narrator=true;默认不要,主世界层原文即终稿)
- 是否有「主世界层不该知道的角色秘密」?(有才 perspective=true)
- 有变量/表要盯却关了旁观?或无状态却硬开旁观?
- 扩写:要不要先验大纲再写章?
不要问「还想加什么 Worker」;不要问要不要机遇裁定。
不要问「还想加什么 Worker」;不要问要不要机遇裁定;旁观开否由程序按有无变量真值固定。
```
## output
@@ -123,7 +123,7 @@ boundary: |
- `path=writing` 时必须有 `writing_slots`;`play_slots` 可省略。
- `play_slots.gm` 世界模拟路径下应为 true。
- `narrator` 缺省按 false(主世界层兼呈现);仅用户明确要独立文风层时 true。
- `auditor` 缺省按 false;有变量/表维护意图才 true。
- `auditor` 缺省按 false;有变量真值时落档程序固定为 true(旁观副 LLM 填表)。对话落盘不必本步猜。
- 不要输出 `chance` 字段;旧产物若有 `chance: true`,本步改为 false/删除,并指向「随机范围整理」。
- 不要输出自造 `actors[]` / 自由 `workers[]`。
- 旧产物若含 `actors[]`:本步应改写为槽位勾选,不再追加自定义 ref。
@@ -132,8 +132,8 @@ boundary: |
```checklist
- [ ] 是否只有白名单槽,无自造 ref、无机遇裁定?
- [ ] 主世界层是否开启(世界模拟路径)?旁观是否与变量需求一致?
- [ ] auditor / perspective / 只要正文等非常规选择是否有理由?
- [ ] 主世界层是否开启(世界模拟路径)?有变量真值时旁观是否按程序约定开启?
- [ ] perspective / 只要正文等非常规选择是否有理由?
- [ ] 是否误把变量管理做成槽?是否误交完整 设计.worker集?
```
@@ -7,42 +7,48 @@ name: 舞台骨架
id: world-blueprint
artifact: 设计.舞台骨架
declaration: >
保存会在游玩中反复影响世界反馈的外部舞台:熟悉基底、必要变造、核心运转和主要活动场所。
保存会反复影响世界反馈的外部舞台:熟悉基底、必要变造、核心运转和主要活动场所。
推进时据此知道该推哪些地方,以及玩家行为在哪个尺度上产生回馈。
when: |
核心体验依赖外部环境、社会力量或世界状况持续作出一致反馈,而现有材料还不足以支撑这些反馈时使用。
是否换区不是判断标准:跨越多地可以需要舞台骨架;一室之内的朝堂、审讯或家庭关系同样可以需要。
when_not: |
熟悉现实基底已经够用,地点和外部格局不会改变核心体验或世界反馈时不使用。
既成硬法则归实现机制;多份人物或要先审字段的名片归生成规则,单条字段很少的具名内容才归具体实例;重点在节点与连接本身时归拓扑图谱。
既成硬法则归世界法则;本步最多引用其台上结果。
多份人物或要先审字段的名片归生成规则;单条字段很少的具名内容归具体实例。
重点在节点与连接本身时归拓扑图谱。
boundary: |
本能力只交会上台并反复有用的外部事实,其余沿用熟悉常识或明确留白。
不重写核心体验,不解释硬法则起源,不制作人物名片、完整地图、历史或百科。
本步交四块会上台并反复有用的外部事实:熟悉基底、必要变造、核心运转、主要活动场所。
在这四块上再写清推进落点与回馈尺度:该推哪些地方,玩家行为在哪一层产生回馈。
熟悉常识默认继承;体验碰不到的范围写进留白。硬法则正文、人物名片、完整地图、历史与百科交给对应能力。
feeds: gm
```
## opening
```opening
说说这局里会反复影响你的外部舞台:它以什么熟悉世界为基底,哪里不同,哪些场所、关系或世界状况会持续参与反馈。只写真正会上台的部分即可。
说说这局里会反复影响你的外部舞台:以什么熟悉世界为基底,哪里不同,哪些运转和场所会持续参与反馈。再补一句:推进时该推哪些地方,你的行为会在哪一层尺度上回馈。
```
## task
```task
把用户材料整理为一份可供主世界层稳定反馈的舞台骨架,写入 `设计.舞台骨架`。先确定最短的熟悉基底与必要变造,再提取会反复影响玩家的社会关系、环境条件和主要场所;体验碰不到的范围明确留白。
把材料整理为 `设计.舞台骨架`。先写最短的 `熟悉基底` 和回馈 `尺度`,再列会改变台上反馈的 `必要变造`,然后写 `核心运转` 和会反复上台的 `主要舞台`;碰不到的范围进 `留白边界`。
材料足够便直接成稿。若两种舞台尺度或运转条件会导致明显不同的世界反馈,把这一处缺口交给 `self_score.gap`;产物正文不放评分、诊断、追问或开放问题。
四块写完后,核对推进能不能用:`主要舞台` 是该推的地方;`尺度`(或某一舞台自有的一层)是玩家行为产生回馈的层级。`核心运转` 写力量或状况怎样持续影响玩家;`玩家如何接触` 写这一处怎么进场、碰到什么、回馈落在哪。两处说同一句时,删舞台侧的因果复读,场所与回馈层留下。
材料够就成稿。两种尺度或运转会导致明显不同反馈、又无法从材料判定时,缺口进 `self_score.gap`。产物正文只含舞台 JSON,不含评分、诊断或追问。
```
## principles
```principles
1. 舞台以世界怎样回应玩家为准,不以是否换区、地图大小或题材类型为准。
2. 熟悉常识默认继承,只写相对基底的必要变造,以及会反复参与体验的外部事实。
3. 核心运转可以是社会力量、阶层关系、周期事件、资源条件、禁忌或日常秩序;只说明它如何作用于玩家和主要舞台。
4. 主要舞台可以是空间、机构、社会层或一间房;详细度以能稳定反馈为止。
5. 既成硬法则只引用其结果,规则本身交给实现机制;多份或要先审字段的具名人物交给生成规则,单条字段很少的具名内容才交给具体实例。
6. 用户未触及的地区、历史、物种、民俗和势力不补全,在留白边界中说明即可。
1. 舞台以世界怎样回应玩家为准,不以换区数量、地图大小或题材类型为准。
2. `尺度` 由体验会碰到的边界正推,也是行为回馈的层级。同是「现代都市」:财阀压迫收到国家与资本网;大学生活收到一座城或一所学校。某一舞台的回馈层与全局不同时,写在该舞台上。
3. 熟悉常识默认继承。`必要变造` 只列相对基底、且会改变世界反馈的差异(一句一条)。差异本质是因果法则时,法则进世界法则;本步写台上看得见的结果。
4. `核心运转` 是社会力量、阶层关系、周期事件、资源、禁忌、日常秩序,以及用户点名的灾变、祭祀、异界出口。写它怎样持续影响玩家和主要舞台。
5. `主要舞台` 是会反复上台的空间、机构、社会层或一间房,也是推进时要推的地方。写玩家在这里做什么、会碰到谁或什么、回馈落在哪一层,写到能稳定反馈为止。玩法标签不代替场所。
6. 硬法则只引用台上结果;多张名片共享的台上格局写在本步,生成规则只引用。用户未触及的地区、历史、势力不补全,进留白。类型名只作氛围参照,不自动塞该类型常见地理与谱系。
```
## output
@@ -51,12 +57,13 @@ feeds: gm
{
"schema": "context-fragment.v1",
"技能": "舞台骨架",
"brief": "一句话说明什么外部舞台在支撑核心体验",
"brief": "一句话:什么外部舞台在支撑反馈,推进该推哪,回馈落在哪一层",
"mount": ["world-simulator"],
"稳变": "stable",
"正文": {
"舞台基底": {
"熟悉基底": "现实或常见类型中可直接继承的最短描述",
"尺度": "体验边界,也是行为回馈的层级;与熟悉基底同一句时可并入基底",
"必要变造": ["只列会改变世界反馈的差异;无则省略"]
},
"核心运转": [
@@ -69,8 +76,8 @@ feeds: gm
"主要舞台": [
{
"名称": "会反复上台的空间、机构或社会层",
"玩家如何接触": "玩家在这里做什么、受到什么反馈",
"关键运转": ["仅引用与此处直接相关的核心运转"]
"玩家如何接触": "在这里做什么、碰到谁或什么、回馈落在哪一层",
"关键运转": ["仅引用与此处直接相关的核心运转名称"]
}
],
"留白边界": ["本局不会上台、只继承常识或刻意不展开的范围"]
@@ -79,33 +86,52 @@ feeds: gm
```
硬规则:
1. 输出合法 JSON;未知或无内容的可选字段直接省略,不写“未定”占位。
2. `核心运转` 统一容纳必要的社会结构与世界状况,不为分类而补内容。
3. `主要舞台` 只写会反复上台且需要稳定反馈的部分;单一房间也可成为完整舞台。
4. 不在产物正文写自评、追问、开放问题、覆盖检验或设定诊断。
5. `mount` 固定为 `["world-simulator"]`。
1. 合法 JSON;无内容的可选字段整项省略;`mount` 固定 `["world-simulator"]`。
2. 四块都写:熟悉基底、必要变造(无则省略该项)、核心运转、主要舞台。`主要舞台` 是推进落点,不因与运转相关就删掉。
3. `玩家如何接触` 不复述对应运转的 `如何作用`;复读删因果句,场所与回馈层留下。`关键运转` 只写名称。
4. `必要变造` 不写法则全文、价目或检定。正文不含评分、诊断、追问、百科或人物名片。
## score
```score
支撑度:
- 5:核心体验依赖的舞台、关系和运转条件均足以支持稳定世界反馈。
- 3:方向正确,但缺少一项会反复影响体验的环境或社会条件,或其作用尚不明确。
- 1:只有题材或地点标签,无法据此判断世界如何回应玩家。
- 5:熟悉基底、变造、运转和主要场所足以稳定反馈;并能指出该推哪些地方、行为在哪一层回馈。
- 3:四块方向对,但缺一项会反复影响体验的条件,或回馈层不清。
- 1:只有题材或地点标签,世界如何回应、推进该推哪都无从判断。
克制度:
- 5:只写会上台且反复有用的内容,熟悉常识默认继承,其余明确留白。
- 3:夹带少量暂时不会影响游玩的地图、历史或设定。
- 1:写成完整世界百科,核心舞台反而不突出。
- 5:只写会上台且反复有用的内容;常识继承;其余留白;运转与舞台无同义复读。
- 3:夹带少量暂不影响游玩的设定,或有一处舞台在复读运转。
- 1:写成世界百科,或把变造、舞台写成硬法则与玩法标签。
```
## checklist
```checklist
- [ ] 舞台内容足以让主世界层稳定回应玩家?
- [ ] 判断依据是外部反馈,而不是换区数量或地图大小?
- [ ] 核心运转只含会反复作用的关系与状况?
- [ ] 主要舞台详细度够用即止,未触及范围已留白?
- [ ] 未重写体验、硬法则、人物名片、完整历史或百科?
- [ ] 产物正文不含评分、诊断、追问和开放问题?
- [ ] 熟悉基底、必要变造、核心运转、主要舞台四块都在(无变造则该项省略)?
- [ ] 推进时能从主要舞台选出该推的地方?
- [ ] 行为回馈的尺度已写明?
- [ ] 舞台未把运转因果换壳重写,也未写成玩法标签?
- [ ] 未触及范围已留白;共享格局写在本步;硬法则未展开?
- [ ] 正文无评分、诊断、追问?
```
## examples
```examples
好:
- 普通大学生活:熟悉基底是现代都市,尺度是一所学校(冲突回馈落在系馆与宿舍,不落到国家)。必要变造若无则省略。主要舞台写系馆、宿舍、社团:玩家在那里做什么、碰到谁、回馈落在校园这一层。国家政治进留白。
- 财阀压迫:同一都市基底,尺度升到国家与资本网。核心运转写资本与名声怎样持续施压;主要舞台写财团、宴会、听证,并写明一次出手的回馈落在资本与名声层。
- 一室朝堂:主要舞台只有一座殿。核心运转写皇权与派系怎样在奏对里记账;接触栏写入殿后对面是谁、当场回馈是被谁记账。
- 必要变造短写:「市井当古武是传言,权贵圈才兑现」;刺杀阈值等因果进世界法则。
缺料时:
- 两种尺度都会改反馈且材料未钉 → `self_score.gap`。
- 只有咖啡馆店主姓名、无台上作用 → 留给具体实例,不写进本步。
偏了就改回:
- 用「该推哪里、回馈哪一层」替换掉基底、变造、运转、场所 → 四块补回,落点与尺度写在尺度和主要舞台上。
- 接触栏只是把「如何作用」换壳 → 删复读,留下进场、在场对象和这一处的回馈层。
- 必要变造写成核威慑长因果 → 因果进机制,变造改成台上可见的圈层差异。
- 接触栏写成「正义堕落、战败调教」→ 改成该场所谁进出、玩家以何身份入场、回馈落在哪一层。
```
@@ -16,7 +16,9 @@ when_not: |
boundary: |
0层回复呈现决定槽位内容和更新语义;本能力决定固定外壳、位置、渲染与更新应用。
权威状态由程序投影;本能力只消费数据,不让正文模型重复生成。
feeds: gm,narrator
创作时一次写好 frontend 并由程序持有;游玩不把 html/css/js 塞进模型上下文。
程序按数据契约要求模型只交槽位更新结构化内容,再灌进已固定的壳。
feeds: program
```
## opening
@@ -39,7 +41,7 @@ feeds: gm,narrator
- `append`:按顺序追加记录,由程序执行上限或压缩规则。
- `data`:对程序权威数据应用结构化投影,不接收模型伪造的完整状态。
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. 给一份非正史初始卡和一次更新预览,覆盖所有可见槽位与空态。
HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变主工作面的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
@@ -50,13 +52,14 @@ HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变
```principles
1. 固定外壳只创建一次;每轮只更新目标槽位,不重发整页代码。
2. 正文或核心工作面保持最高阅读优先级,辅助 Tab 不平均争夺注意力。
3. 更新模式来自 0层回复呈现,前端不能擅自把 replace 改成 append。
4. 程序投影槽只消费权威数据;正文模型不输出状态副本、地图 SVG 或完整卡快照。
5. 每次更新带目标槽位和模式;程序校验允许列表与 revision 后原子提交。
6. 动态文字安全渲染;Markdown 使用宿主安全能力,不能拼接未清洗 HTML。
7. 允许本地 Tab、折叠、缩放和 SVG 图谱;禁止联网、表单、弹窗、顶层导航和宿主行动回传。
8. 正规路径使用结构化 slot_updates;正则只属于外部兼容适配器,不写进本产物。
9. 美化落在配色、字体层级、边框、间距和简单内联 SVG 上,写进 CSS,按本次能写稳的程度完成。
3. 外壳铺满宿主可用宽度(`width: 100%`);行长只限正文柱,不限整壳;缺则按宿主全宽排。
4. 更新模式来自 0层回复呈现,前端不能擅自把 replace 改成 append。
5. 程序投影槽只消费权威数据;正文模型不输出状态副本、地图 SVG 或完整卡快照。
6. 每次更新带目标槽位和模式;程序校验允许列表与 revision 后原子提交。
7. 动态文字安全渲染;Markdown 使用宿主安全能力,不能拼接未清洗 HTML。
8. 允许本地 Tab、折叠、缩放和 SVG 图谱;禁止联网、表单、弹窗、顶层导航和宿主行动回传。
9. 正规路径使用结构化 slot_updates;正则只属于外部兼容适配器,不写进本产物。
10. 美化落在配色、字体层级、边框、间距和简单内联 SVG 上,写进 CSS,按本次能写稳的程度完成。
```
## probe
@@ -72,7 +75,7 @@ HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变
"schema": "context-fragment.v1",
"技能": "0层正文组成",
"brief": "一句话说明持久卡的主工作面、辅助导航与更新方式",
"mount": ["world-simulator", "narrator"],
"mount": [],
"稳变": "stable",
"正文": {
"参考壳": [
@@ -130,7 +133,7 @@ HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变
],
"frontend": {
"html": "<div id=\"zero-layer-app\">完整稳定外壳</div>",
"css": "完整样式与响应式规则",
"css": "完整样式;根容器 width:100%;行长只限正文柱;含响应式规则",
"js": "使用 zeroLayer.onInit(renderCard) 与 zeroLayer.onPatch(applyPatch) 的完整代码"
},
"预览": {
@@ -153,7 +156,7 @@ HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变
硬规则:
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 或完整卡。
4. `data` 槽只接受程序权威投影;隐藏维护通道不进入 DOM。
5. 更新必须带 `base_revision`,由程序校验后原子提交;失败时保持旧卡。
@@ -161,7 +164,7 @@ HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变
7. topology 读取结构化节点与连接绘制 SVG,并提供列表降级。
8. 预览是非正史验收数据,不写成开场。
9. 产物正文不含自评、追问或开放问题。
10. `mount` 固定为 `["world-simulator", "narrator"]`。
10. `mount` 固定为 `[]`:壳不进游玩投影;程序持有 frontend,只向模型注入数据契约。
## score
@@ -182,9 +185,9 @@ HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变
- 1:模型每轮重编权威状态。
界面结构:
- 5:主工作面明确,Tab 和辅助区服务本局体验,窄屏仍可用。
- 3:总体可读,但一个辅助区抢占注意力。
- 1:机械地堆满 Tab,没有主次。
- 5:主工作面明确;外壳铺满宿主宽度;Tab 与辅助区服务本局体验,窄屏仍可用。
- 3:总体可读,但一个辅助区抢占注意力,或整壳被限宽成居中卡片。
- 1:机械地堆满 Tab,没有主次,或外壳明显窄于宿主。
实现与安全:
- 5:代码完整、安全渲染、无联网与回传,结构化区域有降级。
@@ -199,7 +202,7 @@ HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变
- [ ] 外壳只初始化一次,后续只应用 slot_updates?
- [ ] revision、原子提交与失败保持旧值完整?
- [ ] 权威状态没有被模型重复生成,隐藏维护区未进 DOM?
- [ ] 前端完整、安全、无正则依赖与外部联网?
- [ ] 前端完整、安全、根容器铺满宿主宽度、无正则依赖与外部联网?
- [ ] 美化已用配色、字体层级、边框、间距或简单 SVG 写进 CSS,程度限于本次能写稳的部分?
```
@@ -90,7 +90,7 @@ feeds: gm,narrator
},
"来源": {
"方式": "model_generated",
"ref": "设计.故事推进"
"ref": "设计.故事推进与交互范式"
},
"内容要求": [
"遵守故事推进与叙事指南",
@@ -11,7 +11,7 @@ core: >-
世界舞台与状态机仅在体验真需要时才引入,默认不做世界模拟全套。
process:
- 开始通常先做「美学纲领与交互范式」。
- 开始通常先做「美学纲领」。
- 对照【能力 · 可选工序】的「何时用 / 何时不用」选型;调用条件以技能 meta 为准。
- 收成时由程序根据已确认需求整理「游玩执行方式」,再用「运行配置组装」生成可执行规格并 closed。
- 舞台、机制、变量、监控栏等:仅当对应技能「何时用」成立时再选,勿套世界模拟全套。
@@ -27,6 +27,6 @@ principles:
brief: 扩写/写手类:先定站位与轮转,再落到大纲→分段写文规格
steps:
- id: 美学纲领与交互范式
name: 美学纲领与交互范式
- id: 美学纲领
name: 美学纲领
depends_on: []
@@ -12,7 +12,7 @@ core: >-
再由「运行配置组装」生成可执行规格。执行单元只用固定槽(主世界层必开 / 叙事转述默认关 / 可选角色视角;旁观按变量需要),不为表/百科再拆槽,不开机遇裁定槽。
process:
- 开始通常先做「美学纲领与交互范式」。
- 开始通常先做「美学纲领」。
- 读美学卡上用户会在意的部分,对照【能力 · 可选工序】的何时用 / 何时不用,把兑现那些承诺所需的节点写进 steps(需要几步排几步)。各技能调用条件以 meta 为准,本表不重复。
- 世界模拟默认槽:仅主世界层(转述关、旁观关、视角关)。「游玩执行方式」由程序从独立文风层、变量维护和秘密隔离需求中整理。
- 中段按承诺写上下文;每块只标挂谁(mount)与稳/变,具体顺序留给自动「上下文排序」。挂载归属:世界/机制/生成/叙事指南 → 主世界层(无转述时叙事也挂主世界层);开了转述则叙事指南可挂转述;美学进常驻。
@@ -29,12 +29,12 @@ principles:
- 真值 / Data / Progressive 三分;派生性格与章细纲不落可写真值。
- 有编排参数的步骤:进执行前钉齐 params(askUser 选项+其它);缺参时先问再进 design-step。
- 〔可反复〕可再追加(彻底新建);已验收 id 留在 steps。
- 舞台骨架通常都排(备案,只分详细度);代入后几乎不移动、只在一处待着则不排。实现机制对照其 meta。
- 舞台骨架通常都排(备案,只分详细度);代入后几乎不移动、只在一处待着则不排。世界法则对照其 meta。
- 百科与查表归主世界层;可见正文默认由主世界层直接写;显式开转述时转述在主世界正文上改写/镶壳。
brief: 回合推演:美学卡上在意的部分→挂槽写上下文→晚排序投影→收成运行规格
steps:
- id: 美学纲领与交互范式
name: 美学纲领与交互范式
- id: 美学纲领
name: 美学纲领
depends_on: []
@@ -23,7 +23,7 @@ id: alongside
## note
```note
不替换:正文和结构同屏。窄屏改为上下。选项在最下,直接可见。结构用创作时固定的绘图器,游玩灌入拓扑。
不替换:外壳铺满宿主宽度;正文和结构同屏。窄屏改为上下。选项在最下,直接可见。结构用创作时固定的绘图器,游玩灌入拓扑。
可替换:两栏谁更宽、抬头文字、结构块的短名、配色和字体。本局没有结构时改用阅读或正文为主。
```
@@ -32,19 +32,19 @@ id: alongside
```sample
html:
<div id="app">
<p id="heading"></p>
<p id="heading" data-block="heading"></p>
<div class="pair">
<article id="body"></article>
<article id="body" data-block="body"></article>
<section id="structure">
<div id="graph"></div>
<div id="graph" data-block="structure"></div>
</section>
</div>
<ol id="options"></ol>
<ol id="options" data-block="options"></ol>
</div>
css:
: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; }
#structure { border: 1px solid #d5cbb8; border-radius: 6px; padding: 0.8rem; background: #fffdf8; }
#graph { min-height: 8rem; }
@@ -54,17 +54,8 @@ css:
js:
present.onData(function (data) {
var blocks = (data && data.blocks) || {};
putText("heading", blocks.heading);
putText("body", blocks.body);
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) {
var el = document.getElementById(id);
if (!el) return;
@@ -110,15 +101,4 @@ function putGraph(id, value) {
});
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
不替换:数据区在抬头下一直展开。正文在其下较宽的一栏,篇幅仍是数百字。窄栏在较窄的一栏。选项在两栏之下,直接可见。窄屏时宽栏在上。
不替换:外壳铺满宿主宽度;数据区在抬头下一直展开。正文在其下较宽的一栏,篇幅仍是数百字。窄栏在较窄的一栏。选项在两栏之下,直接可见。窄屏时宽栏在上。
可替换:数据项的名称、条数和当前值,窄栏的标题和条目,配色和字体。本局没有窄栏时删掉窄栏,正文占回整宽。
```
@@ -34,21 +34,21 @@ id: data-cards
```sample
html:
<div id="app">
<p id="heading"></p>
<p id="heading" data-block="heading"></p>
<div id="cards"></div>
<div class="pair">
<article id="body"></article>
<article id="body" data-block="body"></article>
<aside class="side">
<p id="side-title"></p>
<p id="side-title" data-block="side_title"></p>
<div id="side"></div>
</aside>
</div>
<ol id="options"></ol>
<ol id="options" data-block="options"></ol>
</div>
css:
: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; }
#cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.45rem; margin-bottom: 0.75rem; }
#cards.stack { grid-template-columns: 1fr; }
@@ -66,19 +66,9 @@ css:
js:
present.onData(function (data) {
var blocks = (data && data.blocks) || {};
putText("heading", blocks.heading);
putText("side-title", blocks.side_title);
putText("body", blocks.body);
putCards("cards", blocks.cards);
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) {
var el = document.getElementById(id);
if (!el) return;
@@ -111,15 +101,4 @@ function putLines(id, value) {
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
不替换:正文占满阅读宽度。其余块收在标签或折叠里,默认不展开。选项在这些控件之外,直接可见。
可替换:标签和折叠的短名、收起块里的文字、配色和字体。本局没有的块删掉。数据要一直展开时改用数据常驻。
不替换:外壳铺满宿主宽度;正文是主阅读面。其余块收在标签或折叠里,默认不展开。选项在这些控件之外,直接可见。
可替换:标签和折叠的短名、收起块里的文字、配色和字体、正文柱行长上限。本局没有的块删掉。数据要一直展开时改用数据常驻。
```
## sample
@@ -33,47 +33,26 @@ id: dual-text
```sample
html:
<div id="app">
<p id="heading"></p>
<article id="body"></article>
<p id="heading" data-block="heading"></p>
<article id="body" data-block="body"></article>
<details>
<summary id="vignette-title"></summary>
<div id="vignette"></div>
<summary id="vignette-title" data-block="vignette_title"></summary>
<div id="vignette" data-block="vignette"></div>
</details>
<ol id="options"></ol>
<ol id="options" data-block="options"></ol>
</div>
css:
: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; }
#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; }
js:
present.onData(function (data) {
var blocks = (data && data.blocks) || {};
putText("heading", blocks.heading);
putText("body", blocks.body);
putText("vignette-title", blocks.vignette_title);
putText("vignette", blocks.vignette);
putList("options", blocks.options);
present.onData(function () {
/* 文本区由程序按 data-block 灌数;折叠开合是浏览器原生行为 */
});
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
不替换:同时只显示一页。页用一行标签切换。抬头在标签之外。
可替换:每个标签的短名、页内文字和数据项、配色和字体。本局未启用的页不建标签。
不替换:外壳铺满宿主宽度;同时只显示一页。页用一行标签切换。抬头在标签之外。
可替换:每个标签的短名、页内文字和数据项、配色和字体、页内正文柱行长上限。本局未启用的页不建标签。
```
## sample
@@ -32,14 +32,15 @@ id: paged
```sample
html:
<div id="app">
<p id="heading"></p>
<p id="heading" data-block="heading"></p>
<div id="tabs"></div>
<div id="page"></div>
<div id="page" data-block="page"></div>
</div>
css:
: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 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; }
@@ -49,7 +50,6 @@ var pages = [];
var current = 0;
present.onData(function (data) {
var blocks = (data && data.blocks) || {};
putText("heading", blocks.heading);
pages = Array.isArray(blocks.pages) ? blocks.pages : [];
current = 0;
drawTabs();
@@ -73,12 +73,7 @@ function drawTabs() {
}
function drawPage() {
var el = document.getElementById("page");
el.textContent = pages[current] ? pages[current].text : "";
}
function putText(id, value) {
var el = document.getElementById(id);
if (!el) return;
if (value == null || value === "") { el.remove(); return; }
el.textContent = String(value);
var text = pages[current] ? pages[current].text : "";
el.innerHTML = text ? present.markdown(String(text)) : "";
}
```
@@ -13,7 +13,7 @@ id: piece
主区: 一份成稿
可加窄带: 文字选项
摆放:
成稿: 居中。内部小节在创作时按范例重写
成稿: 外壳铺满宿主;成稿正文柱可限行长并水平居中
文字选项: 成稿之后,始终直接可见
未启用的区不留空位。
```
@@ -21,8 +21,8 @@ id: piece
## note
```note
不替换:一份成稿容器居中。选项在容器之后,直接可见。
可替换:容器内部的标记、分段、配色和字体。按本局这一份成品重写内部。样本内部与本局不同时整段替换,保留容器和选项的前后关系。
不替换:外壳铺满宿主宽度;一份成稿容器。选项在容器之后,直接可见。
可替换:容器内部的标记、分段、配色和字体、成稿柱行长上限。按本局这一份成品重写内部。样本内部与本局不同时整段替换,保留容器和选项的前后关系。
```
## sample
@@ -30,37 +30,19 @@ id: piece
```sample
html:
<div id="app">
<article id="piece"></article>
<ol id="options"></ol>
<article id="piece" data-block="piece"></article>
<ol id="options" data-block="options"></ol>
</div>
css:
: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; }
#piece { margin: 0; }
#options { margin: 1rem 0 0; padding-left: 1.15rem; }
#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, #options { max-width: 34rem; margin-inline: auto; }
#piece { margin: 0 auto; }
#options { margin: 1rem auto 0; padding-left: 1.15rem; }
js:
present.onData(function (data) {
var blocks = (data && data.blocks) || {};
putText("piece", blocks.piece);
putList("options", blocks.options);
present.onData(function () {
/* 成稿与选项由程序按 data-block 灌数 */
});
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
不替换:单栏阅读柱。抬头在正文前,独占一行。选项在正文后,直接可见。
可替换:抬头文字、正文文字、配色和字体。本局没有抬头时删掉抬头。
不替换:外壳铺满宿主宽度;单栏阅读柱。抬头在正文前,独占一行。选项在正文后,直接可见。
可替换:抬头文字、正文文字、配色和字体、正文柱行长上限。本局没有抬头时删掉抬头。
```
## sample
@@ -31,53 +31,21 @@ id: reading
```sample
html:
<div id="app">
<p id="heading"></p>
<article id="body"></article>
<ol id="options"></ol>
<p id="heading" data-block="heading"></p>
<article id="body" data-block="body"></article>
<ol id="options" data-block="options"></ol>
</div>
css:
: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; }
#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:
present.onData(function (data) {
var blocks = (data && data.blocks) || {};
putHeading("heading", blocks.heading);
putText("body", blocks.body);
putList("options", blocks.options);
present.onData(function () {
/* 正文/抬头/选项由程序按 data-block 灌数;本地交互再写这里 */
});
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);
});
}
```
@@ -11,6 +11,7 @@ suggested_context:
- 设计.变量设计与更新规则
- 设计.生成规则
- 设计.变量控制上下文
- 设计.画面与生图
dynamic:
- 输出.用户展示
- 用户.最新输入
@@ -34,3 +35,4 @@ prompt_excerpt: |
need_generate 仅在确需按某条 rule_id 补实例时写成 { "rule_id", "reason" }。
程序只会抽该规则里的元素池;方向池由主世界层按规则自创,不要指望抽签出标签。
禁止编造长对话续写;禁止输出用户可见正文;禁止修改未点名的无关字段。
上下文里若有「设计.画面与生图」,填表时不使用它;table_ops 只按变量合同。画面提示词不写进 notes。
@@ -10,6 +10,7 @@ suggested_context:
- 设计.叙事指南与故事推进
- 设计.叙事指南
- 设计.故事推进
- 设计.故事推进与交互范式
- 设计.正文组成
- 设计.监控栏
dynamic:
@@ -41,7 +42,7 @@ prompt_excerpt: |
monitor 用短键值(芯片),勿写长文。只填该壳已有区域。
用户主读正文(body)默认 1000~2000 字(汉字);以「设计.监控栏」回复呈现已钉字数为准。
采用完整长自然段叙述,不要单句成段;不要几句短信就结束。
用户输入用法以「设计.故事推进」或「设计.叙事指南与故事推进」为准:
用户输入用法以「设计.故事推进与交互范式」(旧档「设计.故事推进」)或「设计.叙事指南与故事推进」为准:
- 大纲扩写:把主世界正文当事实底稿写终稿。底稿里节拍之间的具体动作原样保留;文风只改遣词与节奏,不要把这些动作收成形容词。不另补底稿没有的动作,不改底稿已有的人、事、话、结果。续写短;用户角色实操仅保留底稿已有,续写不加代控。
- 续写:仅当故事推进(或合并技能)明确钉了此用法时——终稿停在其他角色等用户角色回应之刻;不可写用户角色未给出的其它行为/对话。
- 自动写入:续写宜长;可按主角设定保留/补全用户角色言行(与底稿、人设一致)。
@@ -13,6 +13,7 @@ suggested_context:
- 设计.叙事指南与故事推进
- 设计.叙事指南
- 设计.故事推进
- 设计.故事推进与交互范式
- 设计.正文组成
- 设计.监控栏
dynamic:
@@ -30,7 +31,7 @@ prompt_excerpt: |
无独立转述时:你写的正文就是用户终稿。
若本局启用了叙事转述:你仍写完整(或尽量完整)的场面正文;文风润色、残稿补全、前端壳由转述处理。不要改成「只交事实包」。
真值/表字段变更不靠你交结构化包:有旁观维护时由副 LLM 根据本轮正文改表;若正文组成钉了隐藏段维护语句,可按约定写短键值,否则不要为维护打断叙述。
用户输入用法以「设计.故事推进」或「设计.叙事指南与故事推进」为准:
用户输入用法以「设计.故事推进与交互范式」(旧档「设计.故事推进」)或「设计.叙事指南与故事推进」为准:
- 钉了「大纲扩写」:本轮输入是待演成场面的大纲,不是终稿句子。已写明的人、事、话、结果保持不变,每个节拍都要演到。节拍之间补具体动作、物件和当场的小纠正,把场合里已有的处境演出来;大纲没写这些动作也要写。例:「我去相亲、坐了下来」——坐下前可以写先跟服务员要一杯咖啡,话出口又急忙改成两杯,用来显出局促;后面她提出的要求和拒绝的结果不动。只加形容词、比喻或气氛、不增加动作,不算拓写。另起大纲没有的冲突、新目标,或改掉已定结果,才算出界。设计文若写「不发明未涉及的剧情」,指的是这一种出界,不是禁止节拍之间的具体动作。续写段宜短且不可代写用户角色行动/说话;演绎后停在抉择点(配方另钉一次收束则从其配方)。
- 钉了「续写」:拓写跟用户已声明行为/对话;续写可推进场面但用户角色仅被动占位;禁止替用户角色做决定;停在需用户回应之刻。
- 钉了「自动写入」:短需求驱动;续写宜长;可按主角设定代写用户角色言行。
@@ -13,7 +13,8 @@ inputTags:
- "用户.最新输入"
- "用户.worker答复"
- "用户.修订说明"
- "设计.美学纲领与交互范式"
- "设计.美学纲领"
- "设计.美学纲领"
- "设计.创作流程"
- "创作.选用配方"
- "创作.已验收单位"
@@ -23,7 +24,7 @@ inputMerge: latest
contextSegments:
- id: opening-aesthetics
tier: static
tags: ["设计.美学纲领与交互范式"]
tags: ["设计.美学纲领", "设计.美学纲领"]
label: "## 【开头节点 · 美学纲领】用户会在意这张卡的哪几处?用那些承诺选节点(无则先排本步)"
- id: accepted-units
tier: static
@@ -65,7 +66,7 @@ contextSegments:
题材名(丧尸、都市、宫廷)本身不决定套件。用户会反复感到的那几处才决定。
无美学卡:体验尚未钉清,先排「美学纲领与交互范式」;其余只排此刻能从【用户要求】看出用户在意什么。美学已验收之后,按这些承诺把该排的节点写进 `steps`——需要几步排几步。
无美学卡:体验尚未钉清,先排「美学纲领」;其余只排此刻能从【用户要求】看出用户在意什么。美学已验收之后,按这些承诺把该排的节点写进 `steps`——需要几步排几步。
选定节点时对照【能力 · 可选工序】的何时用 / 何时不用 / 边界:这份承诺该由哪个技能兑现。配方 `core` / `process` / `principles` 给方法顺序(默认槽、收成链),不代替「用户在意什么」。含糊或可跳过则不排。已在草案里的 `name` 保留,一次性技能不写第二份。能力池里兑不了那处承诺:不发明新 `name`,askUser 问用户改体验还是放弃那一处。
@@ -107,23 +108,23 @@ askUser 只问排布(补哪个原型、改依赖)。用户谈到体验正文
}
],
"steps": [
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] },
{ "id": "美学纲领", "name": "美学纲领", "depends_on": [] },
{
"id": "生成规则",
"name": "生成规则",
"role": "prototype",
"suggestion": "异能觉醒与升级合同",
"depends_on": ["美学纲领与交互范式"]
"depends_on": ["美学纲领"]
},
{
"id": "变量设计与更新规则",
"name": "变量设计与更新规则",
"depends_on": ["美学纲领与交互范式"]
"depends_on": ["美学纲领"]
},
{
"id": "叙事指南与故事推进",
"name": "叙事指南与故事推进",
"depends_on": ["美学纲领与交互范式"]
"depends_on": ["美学纲领"]
},
{
"id": "细化终稿",
+26 -3
View File
@@ -9,7 +9,20 @@ import {
writeFileSync,
} from "node:fs";
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 { RunSnapshot } from "../types/run-snapshot.js";
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
@@ -57,6 +70,7 @@ export function listBooks(): BookSummary[] {
title: b.title,
activeSkillId: b.activeSkillId ?? b.orchestratorId,
activeSkillName: b.activeSkillName ?? b.orchestratorName,
creationMode: normalizeBookCreationMode(b.creationMode),
preview: b.preview,
updatedAt: b.updatedAt,
orchestratorId: b.orchestratorId,
@@ -67,19 +81,27 @@ export function listBooks(): BookSummary[] {
export function getBook(id: string): BookProject | null {
try {
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 {
return null;
}
}
export function createBook(input: { title?: string }): BookProject {
export function createBook(input: {
title?: string;
creationMode?: BookCreationMode;
}): BookProject {
ensureUserDataDirs();
mkdirSync(booksDir(), { recursive: true });
const now = new Date().toISOString();
const book: BookProject = {
id: randomUUID(),
title: input.title?.trim() || "未命名作品",
creationMode: normalizeBookCreationMode(input.creationMode),
preview: "新建作品,描述你想创作什么…",
sessionIds: [],
createdAt: now,
@@ -100,6 +122,7 @@ export function updateBook(
| "activeSessionId"
| "activeSkillId"
| "activeSkillName"
| "creationMode"
| "orchestratorId"
| "orchestratorName"
>
+152
View File
@@ -0,0 +1,152 @@
/**
* 缓存命中报告:按「同一会话里第几次调用」切开看 cached/prompt。
*
* npx tsx src/cli/cache-report.ts
* npx tsx src/cli/cache-report.ts --days 7
* npx tsx src/cli/cache-report.ts --caller worker:world-simulator
*
* 为什么要按调用序切:真多轮之后,历史是稳定前缀,
* 同会话第 2 次起命中率应明显高于第 1 次。若两者都≈0,说明前缀还在被击穿
* (历史每轮整体位移 / 随机宏在历史前 / chatHistory 洞又被拍平)。
*/
import { readRecords, type TokenUsageRecord } from "../stats/token-store.js";
type Opts = { days: number; caller?: string };
function parseArgs(argv: string[]): Opts {
const opts: Opts = { days: 3 };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--days") {
const raw = argv[i + 1];
const n = Number(raw);
if (Number.isFinite(n) && n > 0) opts.days = Math.trunc(n);
i += 1;
continue;
}
if (arg === "--caller") {
const value = argv[i + 1]?.trim();
if (value) opts.caller = value;
i += 1;
}
}
return opts;
}
/** 一轮会话的分组键:优先 sessionId,退到 bookId + 模型,最后按天。 */
function groupKeyOf(record: TokenUsageRecord): string {
const session = record.sessionId?.trim();
if (session) return `session:${session}`;
const book = record.bookId?.trim();
if (book) return `book:${book}:${record.model}`;
return `day:${record.at.slice(0, 10)}:${record.model}`;
}
type Bucket = {
calls: number;
prompt: number;
cached: number;
cachedCalls: number;
};
function emptyBucket(): Bucket {
return { calls: 0, prompt: 0, cached: 0, cachedCalls: 0 };
}
function addTo(bucket: Bucket, record: TokenUsageRecord): void {
bucket.calls += 1;
bucket.prompt += record.promptTokens;
bucket.cached += record.cachedTokens ?? 0;
if ((record.cachedTokens ?? 0) > 0) bucket.cachedCalls += 1;
}
function rate(bucket: Bucket): string {
if (bucket.calls === 0) return "-";
const hit = bucket.prompt > 0 ? (bucket.cached / bucket.prompt) * 100 : 0;
const covered = (bucket.cachedCalls / bucket.calls) * 100;
return `${hit.toFixed(1).padStart(5)}% (有缓存请求 ${covered.toFixed(0).padStart(3)}%)`;
}
function pad(text: string, width: number): string {
// 中文按两格算,保证表头对齐
let visible = 0;
for (const ch of text) visible += /[\u3000-\u9fff\uff00-\uffef]/.test(ch) ? 2 : 1;
return text + " ".repeat(Math.max(0, width - visible));
}
const opts = parseArgs(process.argv.slice(2));
const from = new Date(Date.now() - opts.days * 24 * 60 * 60 * 1000).toISOString();
const records = readRecords({ from, limit: 200000 })
.filter((r) => (opts.caller ? r.caller === opts.caller : true))
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
if (records.length === 0) {
console.log(
`最近 ${opts.days} 天没有统计记录${opts.caller ? `(caller=${opts.caller})` : ""}。`,
);
process.exit(0);
}
// 按 会话 → caller 分组,组内按时间排序后取「第几次调用」
const groups = new Map<string, TokenUsageRecord[]>();
for (const record of records) {
const key = `${groupKeyOf(record)}\u0000${record.caller}`;
const list = groups.get(key);
if (list) list.push(record);
else groups.set(key, [record]);
}
const byCaller = new Map<string, Bucket[]>();
const overall: Bucket[] = [];
for (const [, list] of groups) {
list.forEach((record, index) => {
const slot = index === 0 ? 0 : 1; // 0 = 组内首次,1 = 后续
const caller = record.caller || "(未标注)";
const buckets = byCaller.get(caller) ?? [];
buckets[slot] = buckets[slot] ?? emptyBucket();
addTo(buckets[slot]!, record);
byCaller.set(caller, buckets);
overall[slot] = overall[slot] ?? emptyBucket();
addTo(overall[slot]!, record);
});
}
console.log(
`窗口:最近 ${opts.days} 天 · 请求 ${records.length} 条 · 分组 ${groups.size} 个` +
`${opts.caller ? ` · caller=${opts.caller}` : ""}\n`,
);
console.log(
`${pad("caller", 28)}${pad("组内首次", 34)}${pad("组内后续", 34)}${pad("请求数", 10)}`,
);
console.log("-".repeat(106));
const rows = [...byCaller.entries()].sort((a, b) => {
const total = (buckets: Bucket[]) =>
(buckets[0]?.calls ?? 0) + (buckets[1]?.calls ?? 0);
return total(b[1]) - total(a[1]);
});
for (const [caller, buckets] of rows) {
const first = buckets[0] ?? emptyBucket();
const later = buckets[1] ?? emptyBucket();
console.log(
pad(caller, 28) +
pad(rate(first), 34) +
pad(rate(later), 34) +
pad(String(first.calls + later.calls), 10),
);
}
console.log("-".repeat(106));
console.log(
pad("合计", 28) +
pad(rate(overall[0] ?? emptyBucket()), 34) +
pad(rate(overall[1] ?? emptyBucket()), 34) +
pad(String(records.length), 10),
);
console.log(
"\n读法:组内首次 ≈ 0 是正常的(前缀还没被写过);组内后续应明显上升。\n" +
"若后续仍 ≈ 0,检查:历史是否每轮整体位移、随机宏是否在 chatHistory 之前、" +
"上游是否支持隐式缓存。",
);
+186
View File
@@ -0,0 +1,186 @@
/**
* 出网代理引导:把本进程的对外请求交给本地代理内核(默认 127.0.0.1:7890),
* 由内核的「规则模式」自动分流 —— 国内域名直连、国外域名走节点。
*
* 为什么需要这一层:
* - Node 内置 fetch(undici)既不读 Windows 系统代理,也不读 HTTPS_PROXY,
* 所以「开系统代理」对本仓库无效。
* - ws(Edge TTS 使用的 WebSocket)连 undici 的 dispatcher 也不认,必须单独传 agent。
*
* 设计取向:
* - 只影响当前 Node 进程:不改系统代理、不装虚拟网卡、不动路由表,
* 因此可以和 UU 加速器 / Radmin VPN / ZeroTier 共存而不抢路由。
* - 什么时候需要代理由代理内核的规则决定,这里只负责「把流量递进去」,
* 所以以后新增任何 API 都不必改代码。
* - 代理没开时自动退化为直连,不让整条链路直接崩掉。
*
* 环境变量:
* - `WRITING_AGENT_PROXY=off` 关闭(完全直连)
* - `WRITING_AGENT_PROXY=http://host:port` 指定代理
* - 未设置时依次回退 `HTTPS_PROXY` → `ALL_PROXY` → `http://127.0.0.1:7890`
*/
import net from "node:net";
import type { Agent } from "node:http";
import { HttpsProxyAgent } from "https-proxy-agent";
import { ProxyAgent, setGlobalDispatcher } from "undici";
/** 代理内核默认监听地址(Clash / mihomo / TAG 的混合端口) */
const DEFAULT_PROXY_URL = "http://127.0.0.1:7890";
/** 探测代理是否在线的超时;本机端口通常 1ms 内就能返回 */
const PROBE_TIMEOUT_MS = 400;
const OFF_VALUES = new Set([
"off",
"false",
"0",
"no",
"none",
"direct",
"disable",
"disabled",
]);
/**
* - `proxied`:代理可用,全局 fetch 已接管
* - `fallback`:配置了代理但探测不通,已退化为直连
* - `direct`:主动关闭,或已由其它机制接管
*/
export type NetworkProxyStatus =
| { mode: "proxied"; url: string }
| { mode: "fallback"; url: string; reason: string }
| { mode: "direct"; reason: string };
let statusPromise: Promise<NetworkProxyStatus> | null = null;
let currentStatus: NetworkProxyStatus | null = null;
let cachedWsAgent: Agent | null = null;
function readEnvProxy(): string | undefined {
for (const key of ["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]) {
const value = process.env[key]?.trim();
if (value) return value;
}
return undefined;
}
function normalizeProxyUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) return DEFAULT_PROXY_URL;
return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
}
function resolveTarget(): { url: string | null; reason: string } {
const explicit = process.env.WRITING_AGENT_PROXY?.trim();
if (explicit && OFF_VALUES.has(explicit.toLowerCase())) {
return { url: null, reason: "WRITING_AGENT_PROXY 已显式关闭" };
}
if ((process.env.NODE_OPTIONS ?? "").includes("--use-env-proxy")) {
return { url: null, reason: "已由 NODE_OPTIONS=--use-env-proxy 接管" };
}
return {
url: normalizeProxyUrl(explicit || readEnvProxy() || DEFAULT_PROXY_URL),
reason: "",
};
}
async function probeProxy(url: string): Promise<boolean> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
const port = Number(
parsed.port || (parsed.protocol === "https:" ? 443 : 80),
);
if (!parsed.hostname || !Number.isFinite(port)) return false;
return await new Promise<boolean>((resolve) => {
let settled = false;
const finish = (ok: boolean) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(ok);
};
const socket = net.connect({ host: parsed.hostname, port });
socket.setTimeout(PROBE_TIMEOUT_MS, () => finish(false));
socket.once("connect", () => finish(true));
socket.once("error", () => finish(false));
});
}
function describeError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
async function apply(): Promise<NetworkProxyStatus> {
const { url, reason } = resolveTarget();
if (!url) return { mode: "direct", reason };
if (!(await probeProxy(url))) {
return {
mode: "fallback",
url,
reason: `代理 ${url} 不可达,本次以直连运行`,
};
}
try {
setGlobalDispatcher(new ProxyAgent(url));
} catch (err) {
return {
mode: "fallback",
url,
reason: `ProxyAgent 初始化失败(${describeError(err)}),本次以直连运行`,
};
}
return { mode: "proxied", url };
}
/**
* 确保全局 fetch 已按需接管代理。幂等,只探测一次。
*
* 放在真正发起请求之前调用,而不是模块加载时执行,
* 这样既不会拖慢启动,也不会在代理未开时报错。
*/
export function ensureNetworkProxy(): Promise<NetworkProxyStatus> {
if (!statusPromise) {
statusPromise = apply().then((status) => {
currentStatus = status;
if (status.mode === "fallback") {
// 走 stderr,避免污染 CLI 的正常输出
process.stderr.write(`[network] ${status.reason}\n`);
}
return status;
});
}
return statusPromise;
}
/** 当前状态;未调用过 ensureNetworkProxy 时为 null。 */
export function getNetworkProxyStatus(): NetworkProxyStatus | null {
return currentStatus;
}
/**
* 给不走 undici dispatcher 的客户端(如 ws)用的 Node Agent。
*
* 只在 `ensureNetworkProxy()` 完成后返回有效值;否则返回 undefined(直连)。
* 实例被缓存复用,避免每个 TTS 分片都新建连接池。
*/
export function networkProxyAgent(): Agent | undefined {
if (!currentStatus || currentStatus.mode !== "proxied") return undefined;
if (!cachedWsAgent) {
cachedWsAgent = new HttpsProxyAgent(currentStatus.url);
}
return cachedWsAgent;
}
+86
View File
@@ -3,6 +3,19 @@ import path from "node:path";
import { listApiProfiles } from "./api-profiles.js";
import { loadLlmConfigOptional } from "./env.js";
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js";
import type { MessageSquashMode } from "../types/preset.js";
import type { ToolPolicyMode } from "../preset/tool-policy.js";
export type MessageSquashSetting = "follow-preset" | MessageSquashMode;
const MESSAGE_SQUASH_MODES: MessageSquashMode[] = [
"none",
"merge",
"semi",
"strict",
"single-user-turn",
"system",
];
export type AppSettings = {
version: 1;
@@ -17,11 +30,37 @@ export type AppSettings = {
* 游玩期每隔多少个用户回合尝试采集偏好候选。默认 10;0 = 关闭。
*/
preferenceCollectEveryTurns?: number;
/**
* 直聊卡片拖动灵敏度:鼠标移 1px 实际滚动 N px。默认 1.5;范围 0.5–3。
*/
chatDragGain?: number;
/**
* 对话落盘同一轮里,第一次填写失败后最多再补写几次。
* 0 = 只填一次,失败留给下一句用户消息。默认 0。
*/
dictateFillRetries?: number;
/**
* 发出前的消息整形。默认跟随预设(`follow-preset`)。
* 选具体值时覆盖预设自己的 squash_system_messages。
*/
messageSquash?: MessageSquashSetting;
/**
* 预设工具协议怎么处理。默认 adapt:把预设要求的工具折算到已有工具上。
*/
presetToolPolicy?: ToolPolicyMode;
};
const FILE_NAME = "settings.json";
const DEFAULT_CONTEXT_TRACE_KEEP = 5;
const DEFAULT_PREF_COLLECT_EVERY = 10;
const DEFAULT_DICTATE_FILL_RETRIES = 0;
const MAX_DICTATE_FILL_RETRIES = 12;
const DEFAULT_CHAT_DRAG_GAIN = 1.5;
const MIN_CHAT_DRAG_GAIN = 0.5;
const MAX_CHAT_DRAG_GAIN = 3;
export const DEFAULT_MESSAGE_SQUASH: MessageSquashSetting = "follow-preset";
export const DEFAULT_PRESET_TOOL_POLICY: ToolPolicyMode = "adapt";
const TOOL_POLICY_MODES: ToolPolicyMode[] = ["adapt", "app", "preset"];
function settingsPath(): string {
return path.join(getUserDataDir(), FILE_NAME);
@@ -34,9 +73,32 @@ function defaultSettings(): AppSettings {
activePresetId: null,
contextTraceKeepLatest: DEFAULT_CONTEXT_TRACE_KEEP,
preferenceCollectEveryTurns: DEFAULT_PREF_COLLECT_EVERY,
dictateFillRetries: DEFAULT_DICTATE_FILL_RETRIES,
chatDragGain: DEFAULT_CHAT_DRAG_GAIN,
messageSquash: DEFAULT_MESSAGE_SQUASH,
presetToolPolicy: DEFAULT_PRESET_TOOL_POLICY,
};
}
/** 预设工具策略;非法值回落到 adapt。 */
export function normalizePresetToolPolicy(raw: unknown): ToolPolicyMode {
if (typeof raw === "string") {
const hit = TOOL_POLICY_MODES.find((m) => m === raw);
if (hit) return hit;
}
return DEFAULT_PRESET_TOOL_POLICY;
}
/** 消息整形:"follow-preset" 或 6 个模式之一;非法值回落到跟随预设。 */
export function normalizeMessageSquash(raw: unknown): MessageSquashSetting {
if (raw === "follow-preset") return "follow-preset";
if (typeof raw === "string") {
const hit = MESSAGE_SQUASH_MODES.find((m) => m === raw);
if (hit) return hit;
}
return DEFAULT_MESSAGE_SQUASH;
}
export function normalizeContextTraceKeepLatest(raw: unknown): number {
const n = typeof raw === "number" ? raw : Number(raw);
if (!Number.isFinite(n) || n < 0) return DEFAULT_CONTEXT_TRACE_KEEP;
@@ -49,6 +111,21 @@ export function normalizePreferenceCollectEveryTurns(raw: unknown): number {
return Math.min(100, Math.floor(n));
}
/** 拖动增益 0.5–3 倍,保留一位小数;非法值回落到 1.5。 */
export function normalizeChatDragGain(raw: unknown): number {
const n = typeof raw === "number" ? raw : Number(raw);
if (!Number.isFinite(n) || n <= 0) return DEFAULT_CHAT_DRAG_GAIN;
const clamped = Math.min(MAX_CHAT_DRAG_GAIN, Math.max(MIN_CHAT_DRAG_GAIN, n));
return Math.round(clamped * 10) / 10;
}
/** 同轮补写次数:0 = 只填一次;非法值回落到 0。 */
export function normalizeDictateFillRetries(raw: unknown): number {
const n = typeof raw === "number" ? raw : Number(raw);
if (!Number.isFinite(n) || n < 0) return DEFAULT_DICTATE_FILL_RETRIES;
return Math.min(MAX_DICTATE_FILL_RETRIES, Math.floor(n));
}
export function loadAppSettings(): AppSettings {
ensureUserDataDirs();
try {
@@ -65,6 +142,14 @@ export function loadAppSettings(): AppSettings {
preferenceCollectEveryTurns: normalizePreferenceCollectEveryTurns(
parsed.preferenceCollectEveryTurns ?? DEFAULT_PREF_COLLECT_EVERY,
),
dictateFillRetries: normalizeDictateFillRetries(
parsed.dictateFillRetries ?? DEFAULT_DICTATE_FILL_RETRIES,
),
chatDragGain: normalizeChatDragGain(
parsed.chatDragGain ?? DEFAULT_CHAT_DRAG_GAIN,
),
messageSquash: normalizeMessageSquash(parsed.messageSquash),
presetToolPolicy: normalizePresetToolPolicy(parsed.presetToolPolicy),
};
} catch {
return defaultSettings();
@@ -131,3 +216,4 @@ export function ensureActiveProfileDefault(): AppSettings {
}
return settings;
}
+8
View File
@@ -18,8 +18,16 @@ export function getBooksDir(): string {
return path.join(getUserDataDir(), "books");
}
export function getDirectChatsDir(): string {
return path.join(getUserDataDir(), "direct-chats");
}
export function ensureUserDataDirs(): void {
mkdirSync(getPresetsDir(), { recursive: true });
mkdirSync(getBooksDir(), { recursive: true });
mkdirSync(path.join(getUserDataDir(), "stats"), { recursive: true });
mkdirSync(path.join(getUserDataDir(), "image-workflows"), { recursive: true });
mkdirSync(getDirectChatsDir(), { recursive: true });
mkdirSync(path.join(getDirectChatsDir(), "shells"), { recursive: true });
mkdirSync(path.join(getDirectChatsDir(), "conversations"), { recursive: true });
}
+172 -29
View File
@@ -1,4 +1,8 @@
import type { ChatMessage } from "../llm/client.js";
import {
clipDictateDialogue,
resolveDictateHistoryTurns,
} from "./history-window.js";
import {
effectiveDictateOrder,
sortDictateProducts,
@@ -6,42 +10,147 @@ import {
type DictateProduct,
} 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;
};
/**
* 拼给模型的上下文:系统提示 + 全部产物(按相对序)+ 全量对话。
* 清空对话后,只剩产物块,产物即浓缩上下文。
* 创作请求的块顺序:固定规则、更早的对话、全部产物、本轮注入、末尾。
* 选题不带产物正文和本轮注入。回复步只带本轮刚落盘产物与末句用户话。
* 更早对话按 historyTurns 裁剪(默认 2 轮),最后一轮用户输入放在末尾。
*/
export function buildDictateContext(params: {
systemPrompt: string;
products: DictateProduct[];
dialogue: DictateChatTurn[];
phase?: DictateContextPhase;
injected?: string;
tail?: string;
/** 更早对话保留轮数;默认见 resolveDictateHistoryTurns */
historyTurns?: number;
}): DictateBuiltContext {
const phase = params.phase ?? "fill";
if (phase === "reply") {
return buildReplyContext(params);
}
const clipped = clipDictateDialogue(
params.dialogue,
resolveDictateHistoryTurns(params.historyTurns),
);
const { earlier, lastUser } = splitEarlierDialogue(clipped);
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: {
systemPrompt: string;
products: DictateProduct[];
dialogue: DictateChatTurn[];
phase?: DictateContextPhase;
injected?: string;
tail?: string;
historyTurns?: number;
}): ChatMessage[] {
const parts: string[] = [];
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") },
];
return buildDictateContext(params).messages;
}
/** 从会话消息里抽出转述式对话(用户输入 + 助手回复) */
@@ -62,3 +171,37 @@ export function extractDictateDialogue(
}
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");
}
+55 -7
View File
@@ -1,5 +1,7 @@
import {
catalogModulesForIntake,
isDictateProgramModule,
moduleOwnsArtifactTag,
type ModuleCatalog,
type ModuleCatalogEntry,
} from "../skills/creation-flow.js";
@@ -33,6 +35,53 @@ const ALL_PRESENTATION_MODULE_IDS = new Set(
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 = {
module_id: string;
tier: CreationPlanTier;
@@ -270,8 +319,8 @@ export function formatCreationPlanProgress(params: {
for (const item of params.plan.items) {
const module = modules.get(item.module_id);
if (!module || !CREATION_PLAN_TIERS.includes(item.tier)) continue;
const landedTags = productTags.filter(
(tag) => tag === module.artifact || tag.startsWith(`${module.artifact}#`),
const landedTags = productTags.filter((tag) =>
moduleOwnsArtifactTag(module, tag),
);
const scores = landedTags
.map((tag) => {
@@ -334,12 +383,11 @@ export function requiredCreationPlanGaps(params: {
return plan.items
.filter((item) => item.tier === "必须")
.filter((item) => {
const artifact = modules.get(item.module_id)?.artifact;
const module = modules.get(item.module_id);
return (
!artifact ||
!params.products.some(
(product) =>
product.tag === artifact || product.tag.startsWith(`${artifact}#`),
!module ||
!params.products.some((product) =>
moduleOwnsArtifactTag(module, product.tag),
)
);
})
+3 -3
View File
@@ -5,7 +5,7 @@
import { tryParseJsonDoc } from "../parse/json-doc.js";
export const AESTHETICS_PRODUCT_TAG = "设计.美学纲领与交互范式";
export const AESTHETICS_PRODUCT_TAG = "设计.美学纲领";
export const EXPERIENCE_ANCHOR_RECIPE_NAMES = ["快穿短局", "数据化跑团体验"] as const;
@@ -94,7 +94,7 @@ export function formatExperienceAnchorBlock(score: ExperienceAnchorScore): strin
lines.push(
score.anchored
? "3/3:各能力何时用里的「已有美学」成立。开场白仍须等其它何时用已成立的能力落完。"
: "未满 3/3:「已有美学」不成立。本轮只把缺口写进「设计.美学纲领与交互范式」的「体验锚定」(站位、核心感觉、轮转停点)。不得宣称已锚定,不得 insert 开场白。",
: "未满 3/3:「已有美学」不成立。本轮只把缺口写进「设计.美学纲领」的「体验锚定」(站位、核心感觉、轮转停点)。不得宣称已锚定,不得 insert 开场白。",
);
return lines.join("\n");
}
@@ -115,7 +115,7 @@ export function experienceAnchorToolPayload(score: ExperienceAnchorScore): {
export function openingBlockedByAnchorMessage(score: ExperienceAnchorScore): string {
const missing = score.items.filter((item) => !item.ok).map((item) => item.id);
return `拒绝写入开场白:体验锚定 ${score.passed}/${score.total},缺 ${missing.join("、") || "三件"}。先补「设计.美学纲领与交互范式」的「体验锚定」,再写开场。`;
return `拒绝写入开场白:体验锚定 ${score.passed}/${score.total},缺 ${missing.join("、") || "三件"}。先补「设计.美学纲领」的「体验锚定」,再写开场。`;
}
function scoreStance(raw: string | undefined): ExperienceAnchorItem {
+51
View File
@@ -0,0 +1,51 @@
import type { DictateChatTurn } from "./types.js";
/**
* 创作上下文保留的更早对话轮数。
* 1 轮 = 一次用户输入 + 一次助手输出(不含本轮正在处理的末句用户话)。
* 负数 = 不限制。(当前缺省即不限制:块级滑窗会让整段前缀位移、击穿缓存,
* 先关掉以便量真多轮之后的首填命中,见 docs/briefs/prompt-cache-audit-2026-10-07.md)
* 可用环境变量 `DICTATE_HISTORY_TURNS` 或调用参数覆盖。
*/
export const DEFAULT_DICTATE_HISTORY_TURNS = -1;
export function resolveDictateHistoryTurns(override?: number): number {
if (typeof override === "number" && Number.isFinite(override)) {
return Math.trunc(override);
}
const raw = process.env.DICTATE_HISTORY_TURNS?.trim();
if (raw) {
const n = Number(raw);
if (Number.isFinite(n)) return Math.trunc(n);
}
return DEFAULT_DICTATE_HISTORY_TURNS;
}
/**
* 裁剪进模型的对话:保留末句用户输入 + 最近 `historyTurns` 轮更早问答。
* `historyTurns < 0` 时原样返回(= 不限制,当前缺省)。
*/
export function clipDictateDialogue(
dialogue: DictateChatTurn[],
historyTurns: number,
): DictateChatTurn[] {
if (historyTurns < 0) return dialogue;
const last = dialogue.at(-1);
const hasCurrentUser = last?.role === "user";
const earlier = hasCurrentUser ? dialogue.slice(0, -1) : [...dialogue];
const current = hasCurrentUser && last ? [last] : [];
if (historyTurns === 0) return current;
let userCount = 0;
let start = 0;
for (let i = earlier.length - 1; i >= 0; i -= 1) {
if (earlier[i]?.role === "user") {
userCount += 1;
if (userCount === historyTurns) {
start = i;
break;
}
}
}
return [...earlier.slice(start), ...current];
}
+27 -5
View File
@@ -1,4 +1,29 @@
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 {
DEFAULT_DICTATE_HISTORY_TURNS,
clipDictateDialogue,
resolveDictateHistoryTurns,
} from "./history-window.js";
export {
DICTATE_REMEDIATE_TAG,
DICTATE_REMEDIATE_SCHEMA,
parseDictateRemediate,
serializeDictateRemediate,
formatRemediateChecklist,
type DictateRemediateDoc,
type DictateRemediateItem,
} from "./remediate.js";
export {
CREATION_INTAKE_MODE_TAG,
DICTATE_MODE_VALUE,
@@ -16,10 +41,6 @@ export {
type DictateSelfScore,
type DictateChatTurn,
} from "./types.js";
export {
buildDictateMessages,
extractDictateDialogue,
} from "./context.js";
export { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
export { runDictateTurn, type DictateTurnResult } from "./turn.js";
export {
@@ -72,6 +93,7 @@ export {
bindDictateProductsToPlaySpec,
buildDictateContextOrder,
collectDictateBindProducts,
isExcludedFromPlayBind,
type DictatePlayBindResult,
} from "./play-bind.js";
export {
+7 -1
View File
@@ -15,9 +15,14 @@ import {
/** 对话落盘常用简化 tag → 能力 id(catalog.artifact 之外) */
export const DICTATE_INSERT_TAG_ALIASES: Readonly<Record<string, string>> = {
"设计.开场白": "opening-setup",
"设计.开场白与开场变量": "opening-setup",
"设计.美学纲领": "aesthetics-interaction",
"设计.美学纲领与交互范式": "aesthetics-interaction",
"设计.故事推进": "story-progression",
"设计.故事推进与交互范式": "story-progression",
"设计.文风": "narrative-guide",
"设计.监控栏": "status-bar",
"设计.实现机制": "mechanism",
};
/** 多模块同轮:写进每条 insert tool result 的 reply_hint */
@@ -54,7 +59,8 @@ export function resolveModuleForInsertTag(
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) {
return catalog.modules.find((m) => m.id === aliasId) ?? null;
}
+147 -10
View File
@@ -1,7 +1,9 @@
/**
* 对话落盘 · 落档/开玩时自动把产物 tag 挂到游玩 worker。
* 只做「固定上下文绑定」;变量清单、可见性、分档映射仍靠对话产出,此处不发明。
* 保存时按上游产物指纹决定是否重跑:没改过就保留已有排序和运行配置。
*/
import { createHash } from "node:crypto";
import type { Blackboard } from "../blackboard/blackboard.js";
import {
CONTEXT_ORDER_SCHEMA,
@@ -38,6 +40,12 @@ import {
VALUE_MAP_TAG,
listValueMapTargetTags,
} from "../skills/value-map.js";
import {
applyMaintenanceStrategyToPlaySlots,
MAINTENANCE_STRATEGY_TAG,
resolveMaintenanceStrategyFromBoard,
writeMaintenanceStrategy,
} from "../skills/maintenance-strategy.js";
import {
DICTATE_ORDER_META_KEY,
effectiveDictateOrder,
@@ -57,12 +65,35 @@ const SKIP_BIND_TAGS = new Set([
CREATION_SELECTED_RECIPE_TAG,
VARIABLE_CATALOG_TAG,
VALUE_MAP_TAG,
MAINTENANCE_STRATEGY_TAG,
]);
/**
* 创作中转(模块 meta `feeds: design_only`)。
* 只指导后续写作,不进游玩每轮投影。
*/
const DESIGN_ONLY_BIND_TAGS = new Set([
"设计.本局创作方案",
"设计.美学纲领与交互范式",
"设计.美学纲领",
]);
/**
* 呈现壳由程序持有:游玩读 frontend 灌 present.v1,
* 模型只经 `contractOutputInstruction` 看见数据契约,不把 html/css/js 塞进上下文。
*/
const REPLY_SHELL_BIND_TAGS = new Set([
"设计.正文组成",
"设计.0层正文组成",
"设计.回复格式",
]);
function shouldSkipBindTag(tag: string): boolean {
const t = tag.trim();
if (!t) return true;
if (SKIP_BIND_TAGS.has(t)) return true;
if (DESIGN_ONLY_BIND_TAGS.has(t)) return true;
if (REPLY_SHELL_BIND_TAGS.has(t)) return true;
if (t.startsWith("设计.开场白#")) return true;
if (t.startsWith("输出.")) return true;
if (t.startsWith("运行.")) return true;
@@ -70,24 +101,48 @@ function shouldSkipBindTag(tag: string): boolean {
return false;
}
/** 偏文风/呈现/硬约束:有叙事转述时挂转述;否则仍挂主世界层 */
/** 是否不应挂进游玩投影(创作中转、呈现壳、开场等) */
export function isExcludedFromPlayBind(tag: string): boolean {
return shouldSkipBindTag(tag);
}
/** 偏文风/呈现/硬约束:有叙事转述时挂转述;否则仍挂主世界层(壳与 design_only 已在收集阶段剔除) */
const STYLE_TAG_RE =
/叙事指南|故事推进|文风|美学|纲领|禁忌|用户约束|用户需求|示例|模仿|正文组成|回复呈现|回复格式|监控栏|篇幅|结构/;
/叙事指南|故事推进|文风|禁忌|用户约束|用户需求|示例|模仿|回复呈现|监控栏|篇幅|结构/;
/** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */
const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]);
/** 上次编译时所依据的上游指纹;两边产物一致且未过期则跳过重跑 */
export const DICTATE_PLAY_BIND_INPUT_META = "dictatePlayBindInput";
export type DictatePlayBindResult = {
workerSetJson: string;
contextOrder: ContextOrderDoc;
boundTags: string[];
changed: boolean;
/** 上游产物未改,保留已有上下文排序与运行配置 */
skipped: boolean;
};
function isStyleProductTag(tag: string): boolean {
return STYLE_TAG_RE.test(tag);
}
/** 只挂旁观的产物。主世界层与转述不读。 */
const AUDITOR_ONLY_TAG_RE = /画面与生图/;
function isAuditorOnlyProduct(product: DictateProduct): boolean {
if (AUDITOR_ONLY_TAG_RE.test(product.tag)) return true;
const mounts = parseContextFragment(product.content)?.mount ?? [];
if (!mounts.length) return false;
const set = new Set(mounts);
const onAuditor = set.has("auditor");
const onGm = set.has("world-simulator") || set.has("gm");
const onNarrator = set.has("narrator");
return onAuditor && !onGm && !onNarrator;
}
function isFrequentlyChangingProduct(product: DictateProduct): boolean {
const stability = parseContextFragment(product.content)?.稳变;
return stability === "semi" || stability === "volatile";
@@ -164,7 +219,9 @@ function buildGmInserts(params: {
];
let o = 1;
const included = params.products.filter(
(p) => params.includeStyle || !isStyleProductTag(p.tag),
(p) =>
(params.includeStyle || !isStyleProductTag(p.tag)) &&
!isAuditorOnlyProduct(p),
);
const stable = included.filter((p) => !isFrequentlyChangingProduct(p));
const changing = included.filter(isFrequentlyChangingProduct);
@@ -229,7 +286,7 @@ function buildAuditorInserts(params: {
];
let o = 1;
for (const p of params.products) {
if (!/变量|生成规则/.test(p.tag)) continue;
if (!/变量|生成规则/.test(p.tag) && !isAuditorOnlyProduct(p)) continue;
inserts.push(insert(o++, p.tag, "full"));
}
for (const tag of params.runtimeTags) {
@@ -249,6 +306,7 @@ export function buildDictateContextOrder(params: {
runtimeTags?: string[];
}): ContextOrderDoc {
const runtimeTags = params.runtimeTags ?? [];
const products = params.products.filter((p) => !shouldSkipBindTag(p.tag));
const slots: ContextOrderSlot[] = [];
for (const slotId of enabledPlaySlotIds(params.playSlots)) {
@@ -258,7 +316,7 @@ export function buildDictateContextOrder(params: {
ref,
label: "主世界层",
inserts: buildGmInserts({
products: params.products,
products,
runtimeTags,
// 主世界推进剧情:固定产物全挂(含文风);有转述时转述另挂文风切片
includeStyle: true,
@@ -270,7 +328,7 @@ export function buildDictateContextOrder(params: {
slots.push({
ref,
label: "叙事转述",
inserts: buildNarratorInserts(params.products),
inserts: buildNarratorInserts(products),
});
continue;
}
@@ -279,7 +337,7 @@ export function buildDictateContextOrder(params: {
ref,
label: "旁观维护",
inserts: buildAuditorInserts({
products: params.products,
products,
runtimeTags,
}),
});
@@ -314,7 +372,7 @@ function resolvePlaySlots(
const parsed = existingRaw?.trim()
? parseWorkerSetYaml(existingRaw)
: null;
const slots = { ...(parsed?.play_slots ?? defaultPlaySlots()) };
let slots = { ...(parsed?.play_slots ?? defaultPlaySlots()) };
const recipeRef = parseSelectedRecipeRef(
board.getContentByTag(CREATION_SELECTED_RECIPE_TAG),
);
@@ -322,6 +380,9 @@ function resolvePlaySlots(
slots.gm = true;
slots.narrator = true;
}
// 变量设计.维护复杂度 → auditor true/false(内联则关旁观)
const strategy = resolveMaintenanceStrategyFromBoard(board);
slots = applyMaintenanceStrategyToPlaySlots(slots, strategy);
return slots;
}
@@ -336,6 +397,8 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin
board.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim() ||
"";
const playSlots = resolvePlaySlots(existing || undefined, board);
const strategy = resolveMaintenanceStrategyFromBoard(board);
writeMaintenanceStrategy(board, strategy, "runtime:play-bind");
const runtimeTags = collectRuntimeStateTags(board);
const contextOrder = buildDictateContextOrder({
products,
@@ -371,26 +434,100 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin
? 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 workerSetJson = mergeContextOrderIntoWorkerSetJson(withSlots, contextOrder);
const boundTags = products.map((p) => p.tag);
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 {
const key = dictatePlayBindInputKey(board);
const fresh = freshPlayBind(board, key);
if (fresh) return fresh;
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({
tag: WORKER_SET_FINAL_TAG,
content: result.workerSetJson,
source: "runtime",
metadata: { ...(workerItem?.metadata ?? {}), ...stamp },
});
board.write({
tag: CONTEXT_ORDER_TAG,
content: JSON.stringify(result.contextOrder, null, 2),
source: "runtime",
metadata: { ...(orderItem?.metadata ?? {}), ...stamp },
});
return result;
}
+101 -42
View File
@@ -1,16 +1,13 @@
/**
* 对话落盘 · 创作进料专用系统提示
*
* 拼装:本提示(system)+ 当前产物(按相对序)+ 全量对话。
* 仅用于创作层;开玩后由游玩管线接棒。
* 对话落盘 · 创作系统提示。
* 系统侧只放跨轮不变的规则。方案进度、能力目录和本轮输入放在请求末尾。
*/
export function buildDictateSystemPrompt(opts?: {
/** 已有方案 + 分档状态;存在时不再注入全能力目录 */
export type DictatePromptTailOpts = {
/** 已有方案的短进度;放在末尾,不进系统提示 */
creationPlanState?: string;
/** 尚无方案时使用的短能力目录 */
/** 尚无方案时的短能力目录 */
capabilityCatalog?: string;
/** 尚无方案时可引用的旧配方案例目录 */
recipeExamples?: string;
/** @deprecated 仅兼容旧调用,作为案例而非已选路径 */
recipeName?: string;
@@ -18,43 +15,40 @@ export function buildDictateSystemPrompt(opts?: {
recipeBrief?: string;
/** @deprecated 使用 capabilityCatalog */
moduleGuide?: string;
}): string {
const legacyExample =
opts?.recipeName || opts?.recipeBrief
? `
【旧配方案例 · 兼容参考】
${opts.recipeName ? `- ${opts.recipeName}` : ""}
${opts.recipeBrief ?? ""}
它只提供可改造的方法,不是用户已选路径,也不规定能力顺序。
`
: "";
const decisionBlock = opts?.creationPlanState?.trim()
? `${opts.creationPlanState.trim()}
/** select:只定计划。fill:按注入规格写正文。reply:只交用户可见回复。 */
phase?: "select" | "fill" | "reply";
/** 本轮要落实或重写的 tag 与错误 */
checklist?: string;
};
方案是收口前的核对清单,不是本轮待办。写哪项先对照该能力旁的何时用 / 何时不用;档位只说明值不值得做。条件成立,且用户这句话已经说清的,通常 2~3 个:先 read_module,再连续 insert。说清指用户已经给出这份产物要写的内容,不是能从题材推演出一整份。标成「必须」或「有必要」、但这句话没覆盖到或使用条件不成立的,本轮不写,按已有材料给 2~3 个选项来问,选定或改写后再写入。写开场或结束创作前,列出仍未落的「必须」和「有必要」,问用户哪些还没设计、要补哪几项或明确不要。用户改口或方案明显失效时,先整份覆盖 \`设计.本局创作方案\`;使用条件仍留在能力旁。`
: `【当前状态 · 尚无本局创作方案】
先抽出用户要的核心体验,再对照能力目录主动标出有帮助的能力,生成 \`creation-plan.v1\`,用 insert 写入 \`设计.本局创作方案\`。先写 \`presentation_mode: "message"|"zero_layer"\`,再覆盖公共能力与该模式对应的三项呈现能力;两套呈现不得同时纳入。分档看能力对这份体验和可运行性的贡献:用户没点名、但缺了体验会明显变差的,标进「必须」或「有必要」,具体还没定的选择写进 missing。方案只表达本局方法、能力档位、理由与缺口,不生成固定流水线。
/** 第一次请求:只从目录里点出这段话要填的节点,不写产物。 */
export function buildDictateSelectPrompt(): string {
return `你只做选题。对照能力目录和用户这句话,只调用一个工具。
${opts?.capabilityCatalog?.trim() || opts?.moduleGuide?.trim() || "(能力目录暂不可用;目录恢复前不要猜造方案。)"}
${opts?.recipeExamples?.trim() ? `\n\n【旧配方案例目录】\n${opts.recipeExamples.trim()}` : ""}
${legacyExample}`;
这段话已经说清要落盘时,调用 select_nodes,nodes 里放目录中的 module_id,通常 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。
若末尾 checklist 标了「失败待补」,且用户这句仍在推进落盘,这些节点必须再次纳入 nodes(可与新选的一起)。
用户只要讨论、核对,或明确先不要落盘时,调用 reply_user:直接回答,或写成 1~2 个问题,每个问题给 2~3 个可接着说的选项。这种一轮不落盘,也不把失败待补写回去。
用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或世界法则。
select_nodes 不写产物正文。`;
}
export function buildDictateSystemPrompt(_opts?: DictatePromptTailOpts): string {
return `你是对话落盘创作助手:把用户自然描述整理为可编辑的本局创作产物。作用对象是创作黑板;开玩后由游玩 Worker 接棒。
**本提示只用于创作。** 开玩后叙事由游玩 worker 执行;创作期只落规格与引导,不写游玩回合正文。
${decisionBlock}
**填写收尾:** 节点表产物写完后,同一次请求必须再调用 reply_user。只 insert、把话写在普通正文里,都不算完成。回复写法见文末「用户可见回复」。
## 写入规则
设定 / 变量 / 映射必须经 tool 写入;聊天只做确认与引导,不当落盘载体。
设定、变量、映射和删除通过本步的工具调用落盘。reply_user 只写给用户看的回复。
### insert — 写入/覆盖固定产物
- position:\`用户.*\` / \`设计.*\`
- content:正文;order 可选(同一变化频率组内,基础材料用较小值,越接近生成时使用的合同越大)
- 同 position 再 insert = 整份覆盖改写;变量目录与映射改用 declare_variable / declare_map
- 可反复能力每个实例用 \`基名#唯一id\`;改某实例时覆盖同一完整 tag
- 只有方案中该填的内容都完成后才写开场白;先单独调用 prepare_opening,让程序编译信息可见范围、游玩执行方式、上下文排序和运行配置;工具成功后的下一步再生成并 insert 开场白
- 开场白与生成规则、具体实例一样可增殖:每条入口 insert \`设计.开场白#场景短码\`(0 层卡用 \`设计.0层开场白与初态#场景短码\`)。改某一条则覆盖同一完整 tag。全部保存;游玩时左右切换,选中的一条作为 0 层。不要在回复里把多条入口收成一道先选定的题
- 只有方案中该填的内容都完成后才写开场白。程序在这次填写前编译信息可见范围、游玩执行方式、上下文排序和运行配置;本步按清单写出开场正文
- 开场白与生成规则、具体实例一样可增殖:每条入口写入 \`设计.开场白#场景短码\`(0 层卡用 \`设计.0层开场白与初态#场景短码\`)。改某一条则覆盖同一完整 tag。全部保存;游玩时左右切换,选中的一条作为 0 层。不要在回复里把多条入口收成一道先选定的题
### delete — 删除固定产物
- position:已写入的 \`用户.*\` / \`设计.*\` tag,整份移除
@@ -73,22 +67,87 @@ ${decisionBlock}
- 移除一条 → remove_map(同 id 再 declare_map = 覆盖改写)
## 推进原则
1. 本局创作方案是决策信息,不是 DAG;不要按 items 顺序逐项执行。有方案时,写哪项先对照该能力的何时用 / 何时不用。
2. 写任何内容能力前先调用 read_module 读取当前提示版本;需要库条目时,再用 read_library_entry 读取当前能力绑定库条目(默认 1~2;确需更多须给 reason)。不要凭目录短说明猜产物。
3. 本轮只写入用户这句话已经说清的能力,通常 2~3 个。对这些能力先 read_module,再 insert / declare_*。每次 insert 都是独立产物写入。
4. 写某能力前以 read_module 返回的 task / principles / output / score 为准;尚未读到专用提示时,不猜造其复杂结构。能力提示里的「材料足够便直接成稿」只用于这句已经说清的能力。
5. 「必须」和「有必要」要补齐:即使用户没点过能力名,也根据已抽出的核心体验和已有输入给 2~3 个选项来问,选定或改写后再写入。不要因为原话没出现就把有帮助的能力降成不推进。用户明确不要的「有必要」可以降档。「有一定效果」可顺带问一次。「没有意义」表示对这份体验没有帮助,不推进。未落的「必须」和「有必要」留到写开场或结束创作前,问用户哪些还没设计。
6. 用户改口、新增方向或现有方案明显不成立时,先覆盖本局创作方案,再据此增删或改写其它产物。
1. 本局创作方案是决策信息,不是 DAG;不要按 items 顺序逐项执行。尚无方案时,按末尾目录生成 \`creation-plan.v1\`,写入 \`设计.本局创作方案\`。有方案时,写哪项先对照该能力的何时用 / 何时不用。
2. 本轮只处理用户这句话已经说清的能力,通常 2~3 个。说清指用户已经给出这份产物要写的内容,不是能从题材推演出一整份。
3. 先定范围再写。用户的话按最窄的成立范围落盘:体验目标说明玩家追求的对象,举例和比方说明用户想要的感觉或基底,都不说明整个世界都是这样。例:体验是「在各个世界征服高洁女性、打败恶役反派」,落成每个世界里有这样的目标人物和对手;世界本身仍按题材常识(如普通西幻),不写成全世界女性都高洁,也不为此发明神殿戒律。用「和现实一样」时顺口提到的现实事实,只确认基底是现实,不单独成条。宽窄拿不准、且会写出不同产物时,按最窄范围写或先不写,在回复里请用户确认范围。
4. 填写时以「本轮注入」里的 task、principles、output、score 和库正文为规格。注入尚未给出的能力,不猜造其复杂结构。
5. 变量和映射不写入普通产物。每次产物写入都是独立的一份。
6. 「必须」和「有必要」要补齐:即使用户没点过能力名,也根据已抽出的核心体验和已有输入给 2~3 个选项来问,选定或改写后再写入。不要因为原话没出现就把有帮助的能力降成不推进。用户明确不要的「有必要」可以降档。「有一定效果」可顺带问一次。「没有意义」表示对这份体验没有帮助,不推进。未落的「必须」和「有必要」留到写开场或结束创作前,问用户哪些还没设计。
7. 用户改口、新增方向或现有方案明显不成立时,先覆盖本局创作方案,再据此增删或改写其它产物。
## 用户可见回复
自然回应用户本轮有效判断、矛盾和值得保留之处,再简要说明形成或改写了什么。完整 JSON 由产物卡展示,不粘贴到回复。
写正文组成或 0 层正文组成时,在回复里直接给出一份隔离内容供预览:与写入的 frontend 同一套骨架,示例内容已经放进去,单独可看。产物仍只保存 frontend 与数据契约。
这句话没覆盖到的「必须」或「有必要」,用 2~3 个从用户原话推出的选项询问,不先写成产物。已经说清并写入的,说明形成了什么,不再另找问题。同轮多个已说清产物只做一次综合回应,不逐模块套固定标题。
## 其它
对话明显过长时 clear_dialogue(产物与变量/映射保留)。
${dictateReplyGuide()}
`;
}
/** 方案进度、目录和本轮任务。拼在请求末尾。 */
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 带上;已通过的以工具结果为准)。失败待补若附了底稿,在底稿上只改失败点,已写对的段落、字段和已接受的 self_score 维度保持原样。写完后同一次请求必须调用 reply_user,正文按系统提示「用户可见回复」。`
: "本步只调用一个工具。要落盘就调用 select_nodes,只填 nodes。只要讨论或先不要落盘,就调用 reply_user 直接回答或提问。";
// 填写时把收尾要求放在方案进度之前,避免被大段进度挤到注意力末尾。
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() */
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()}`;
}
+38
View File
@@ -19,6 +19,10 @@ import {
getPreference,
listActivePreferences,
} from "../preference/store.js";
import {
getImageWorkflow,
listImageWorkflows,
} from "../image-workflow/store.js";
import {
getStylePack,
listActiveStylePacks,
@@ -26,6 +30,13 @@ import {
export const DICTATE_MODULE_READ_STATE_TAG = "运行.对话落盘.已读能力";
/** 改名后旧目录名仍能 read_module,已有对话不用重学 id。 */
const MODULE_READ_NAME_ALIASES: Record<string, readonly string[]> = {
"aesthetics-interaction": ["美学纲领与交互范式"],
"story-progression": ["故事推进"],
mechanism: ["实现机制"],
};
export type DictateModuleReadPayload = {
module_id: string;
name: string;
@@ -99,6 +110,9 @@ export async function buildDictateModuleReadIndex(params: {
};
index.set(module.id, payload);
index.set(module.name, payload);
for (const alias of MODULE_READ_NAME_ALIASES[module.id] ?? []) {
if (!index.has(alias)) index.set(alias, payload);
}
}
return index;
}
@@ -171,6 +185,19 @@ export function readLibraryEntries(params: {
})),
};
}
if (libraryId === "image-workflows") {
const entries = ids.map(getImageWorkflow);
const missing = ids.filter((_, index) => !entries[index]);
if (missing.length) return { error: `工作流库无条目:${missing.join("、")}` };
return {
entries: entries.map((entry) => ({
id: entry!.id,
name: entry!.name,
summary: entry!.summary,
...(entry!.promptNotes ? { prompt_notes: entry!.promptNotes } : {}),
})),
};
}
return { error: `库 ${libraryId} 尚不支持按条目读取` };
}
@@ -205,6 +232,17 @@ function libraryDirectories(module: ModuleCatalogEntry): DictateModuleReadPayloa
})),
};
}
if (id === "image-workflows") {
return {
library_id: id,
label: provider?.label ?? id,
entries: listImageWorkflows().map((entry) => ({
id: entry.id,
name: entry.name,
summary: entry.summary,
})),
};
}
return { library_id: id, label: provider?.label ?? id, entries: [] };
});
}
+125
View File
@@ -0,0 +1,125 @@
/**
* 同轮补写用尽后,失败项写入「待补」状态,下轮 select/fill 继续补。
* 存 readTag/writeTag,不进产物列表,避免污染黑板正文。
* draft / keptDims 只给下一枪做增量:底稿上改失败点,已接受的自评维度带回去。
*/
export const DICTATE_REMEDIATE_TAG = "设计.对话落盘待补";
export const DICTATE_REMEDIATE_SCHEMA = "dictate-remediate.v1" as const;
export type DictateRemediateKind = "insert" | "delete";
/** 已经合法、可在下一枪原样带回的自评维度。 */
export type DictateKeptDim = {
name: string;
score: number;
gap?: string;
options?: string[];
};
export type DictateRemediateItem = {
tag: string;
moduleId?: string;
error: string;
kind: DictateRemediateKind;
/** 未通过时的正文底稿 */
draft?: string;
/** 这次没整份通过、但单项已经合法的自评维度 */
keptDims?: DictateKeptDim[];
};
export type DictateRemediateDoc = {
schema: typeof DICTATE_REMEDIATE_SCHEMA;
items: DictateRemediateItem[];
};
export function parseDictateRemediate(raw: string | undefined | null): DictateRemediateDoc {
if (!raw?.trim()) return { schema: DICTATE_REMEDIATE_SCHEMA, items: [] };
try {
const row = JSON.parse(raw) as Record<string, unknown>;
const items = Array.isArray(row.items) ? row.items : [];
const parsed: DictateRemediateItem[] = [];
for (const item of items) {
if (!item || typeof item !== "object") continue;
const rec = item as Record<string, unknown>;
const tag = String(rec.tag ?? "").trim();
if (!tag) continue;
const kind = rec.kind === "delete" ? "delete" : "insert";
const moduleId = String(rec.moduleId ?? rec.module_id ?? "").trim();
const error = String(rec.error ?? "尚未通过").trim() || "尚未通过";
const draft = String(rec.draft ?? "").trim();
const keptDims = parseKeptDims(rec.keptDims ?? rec.kept_dims);
parsed.push({
tag,
error,
kind,
...(moduleId ? { moduleId } : {}),
...(draft ? { draft } : {}),
...(keptDims.length ? { keptDims } : {}),
});
}
return { schema: DICTATE_REMEDIATE_SCHEMA, items: dedupeRemediate(parsed) };
} catch {
return { schema: DICTATE_REMEDIATE_SCHEMA, items: [] };
}
}
export function serializeDictateRemediate(doc: DictateRemediateDoc): string {
return JSON.stringify(
{
schema: DICTATE_REMEDIATE_SCHEMA,
items: dedupeRemediate(doc.items),
},
null,
2,
);
}
export function formatRemediateChecklist(items: readonly DictateRemediateItem[]): string {
if (!items.length) return "";
return [
"失败待补(须继续写入,勿忽略):",
...items.map((item) => {
const kind = item.kind === "delete" ? "删除" : "写入";
const mod = item.moduleId ? ` / ${item.moduleId}` : "";
return `- 〔${kind}〕${item.tag}${mod}:${item.error}`;
}),
].join("\n");
}
function parseKeptDims(raw: unknown): DictateKeptDim[] {
if (!Array.isArray(raw)) return [];
const out: DictateKeptDim[] = [];
const seen = new Set<string>();
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const name = String(row.name ?? "").trim();
const score = Number(row.score);
if (!name || seen.has(name) || !Number.isInteger(score) || score < 1 || score > 5) continue;
const gap = String(row.gap ?? "").trim();
const options = Array.isArray(row.options)
? row.options.map((option) => String(option).trim()).filter(Boolean)
: [];
seen.add(name);
out.push({
name,
score,
...(gap ? { gap } : {}),
...(options.length ? { options } : {}),
});
}
return out;
}
function dedupeRemediate(items: DictateRemediateItem[]): DictateRemediateItem[] {
const seen = new Set<string>();
const out: DictateRemediateItem[] = [];
for (const item of items) {
const key = `${item.kind}:${item.tag}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
+83 -6
View File
@@ -143,6 +143,74 @@ function bodyOfFragment(content: string): Record<string, unknown> | null {
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[];
repeatableFamilies: ReadonlySet<string>;
}): ResolveRepeatableInsertResult {
const raw = params.tag.trim();
if (!raw) return { error: "position 为空" };
const requested = params.tag.trim();
if (!requested) return { error: "position 为空" };
const raw = rewriteLegacyOpeningTag(requested);
const family = dictateProductFamily(raw);
if (!params.repeatableFamilies.has(family)) {
@@ -217,13 +286,21 @@ export function resolveRepeatableInsertTag(params: {
const explicit = dictateProductSlot(raw);
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
.map((t) => t.trim())
.filter((t) => t && dictateProductFamily(t) === family);
if (!slot) {
if (family === OPENING_SLOT_FAMILY) {
return { error: OPENING_SLOT_REQUIRED };
}
// 允许覆盖基名自身;若已有其它 #槽位,禁止再用裸基名(须写明 #id)
const otherSiblings = siblings.filter((t) => t !== family);
if (otherSiblings.length > 0 && raw === family) {
@@ -235,11 +312,11 @@ export function resolveRepeatableInsertTag(params: {
}
const resolved = buildRepeatableProductTag(family, slot);
if (raw === family || raw !== resolved) {
if (requested !== resolved) {
return {
tag: resolved,
remapped: raw !== resolved,
note: raw !== resolved ? `可增殖已拆到 ${resolved}` : undefined,
remapped: true,
note: `已写入 ${resolved}。之后改这一条用同一完整 tag。`,
};
}
return { tag: resolved };
+1072
View File
File diff suppressed because it is too large Load Diff
+89 -2
View File
@@ -3,6 +3,87 @@ import type { ToolDefinition } from "../llm/client.js";
/**
* 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。
*/
/** 第一次请求与 reply_user 二选一:点出下一步要写的节点。 */
export const DICTATE_SELECT_TOOL: ToolDefinition = {
type: "function",
function: {
name: "select_nodes",
description:
"规定下一步要写入的目标节点。nodes 只填能力目录中的 module_id。这一步不写产物正文。本轮只回答或只提问时不要调用本工具,改调 reply_user。",
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"],
},
},
};
/** 选题步的回复。与 select_nodes 二选一;调用后不再填写、不再另起回复。 */
export const DICTATE_SELECT_REPLY_TOOL: ToolDefinition = {
type: "function",
function: {
name: "reply_user",
description:
"本轮不写入、不删除。直接回答,或提出 1~2 个问题,每个问题给 2~3 个可接着说的选项。与 select_nodes 二选一。",
parameters: {
type: "object",
additionalProperties: false,
properties: {
text: {
type: "string",
description: "给用户的回复。提问时写成 1~2 个问题,每个问题给 2~3 个可接着说的选项。",
},
},
required: ["text"],
},
},
};
/** 写入循环的收尾。未通过的节点还在时,程序不把这份正文给用户。 */
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[] = [
{
type: "function",
@@ -80,12 +161,12 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
position: {
type: "string",
description:
"产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白、设计.开场白#dorm;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id、设计.主角设定#dorm。勿用本工具写变量目录/映射。",
"产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白#opening-cycle2;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id、设计.主角设定#dorm。开场必须带场景短码,不要写裸「设计.开场白」或「设计.开场白与开场变量」。勿用本工具写变量目录/映射。",
},
content: {
type: "string",
description:
"落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白[#短码]=叙事正文(有正文组成时为 present.v1 JSON;否则 Markdown),必须含字面 @玩家,禁止写入用户角色.名字/简介;设计.主角设定[#同短码]=名字/背景/特殊设定 JSON,与开场同短码即绑定该开局",
"落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白#场景短码=这一张开场的 present.v1,blocks 必须含正文组成数据契约里的全部模型生成区(用来灌进已固定前端并替换示例),不要只写 shell+body,也不要填程序投影区;必须含字面 @玩家,名字不进 blocks。这条开场的玩家角色写在 meta.用户角色(名字+一到三句客观事实),程序同步为同短码主角设定。这张开场有初值才在同一份正文里写开场变量,没有就不写。设计.主角设定=共通的名字/背景/特殊设定 JSON;各开场不同的玩家角色写进开场 meta,不另写主角设定#短码",
},
order: {
type: "number",
@@ -283,3 +364,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,
];
+164 -4
View File
@@ -1,9 +1,9 @@
import type { LlmProvider } from "../llm/client.js";
import {
createLocalLlmDriver,
DriverIterationLimitError,
type AgentDriver,
} from "../runtime/driver.js";
import { createPlannedDictateDriver } from "./round.js";
import { buildDictateMessages } from "./context.js";
import { buildDictateSystemPrompt } from "./prompt.js";
import { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
@@ -41,8 +41,14 @@ import {
serializeVariableCatalog,
upsertVariableField,
removeVariableField,
seedVariablesFromCatalog,
type UpsertVariableInput,
} from "../skills/variable-catalog.js";
import {
VARIABLE_DESIGN_TAG,
syncVariableDesignToRuntime,
writeVariableDesignSync,
} from "../skills/variable-design-compile.js";
import {
VALUE_MAP_TAG,
parseValueMapDoc,
@@ -57,6 +63,46 @@ import {
} from "../blackboard/table-cells.js";
import { isDictateOpeningTag } from "./experience-anchor.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 = {
listProducts: () => DictateProduct[];
@@ -125,9 +171,37 @@ export async function runDictateTurn(params: {
moduleReadIndex?: ReadonlyMap<string, DictateModuleReadPayload>;
/** catalog 中 repeatable 能力的 artifact 基名(设计.生成规则 等) */
repeatableFamilies?: ReadonlySet<string> | readonly string[];
/** 更早对话保留轮数;默认 2,可用 DICTATE_HISTORY_TURNS 覆盖 */
historyTurns?: number;
/** 第一次填写失败后同轮最多再补写几次;缺省 0 */
fillRetries?: number;
driver?: AgentDriver;
}): 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,
deleteTag: params.handlers.deleteTag,
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,
historyTurns: params.historyTurns,
fillRetries: params.fillRetries,
});
const wroteTags: string[] = [];
const deletedTags: string[] = [];
let clearedDialogue = false;
@@ -217,9 +291,31 @@ export async function runDictateTurn(params: {
call.name,
`${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 {
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") {
@@ -385,6 +481,18 @@ export async function runDictateTurn(params: {
const order = parseDictateOrder(args.order);
const productsBeforeWrite = params.handlers.listProducts();
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;
if (isOpening && !explicitlyPrepared) {
params.handlers.prepareOpening?.();
@@ -464,6 +572,18 @@ export async function runDictateTurn(params: {
const order = parseDictateOrder(args.order);
const productsBeforeWrite = params.handlers.listProducts();
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;
if (isOpening && !explicitlyPrepared) {
params.handlers.prepareOpening?.();
@@ -480,6 +600,10 @@ export async function runDictateTurn(params: {
: {}),
...(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;
wroteTags.push(tag);
const detail =
@@ -557,6 +681,8 @@ export async function runDictateTurn(params: {
serializeVariableCatalog(doc),
);
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}`;
params.handlers.onToolCall?.(call.name, detail);
return {
@@ -826,6 +952,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 {
if (auto) return false;
return !new Set([
@@ -927,8 +1077,18 @@ function parseAndValidateSelfScore(
expectedNames.some((name) => !actual.includes(name)) ||
actual.some((name) => !expectedNames.includes(name))
) {
const kept = actual.filter((name) => expectedNames.includes(name));
const missing = expectedNames.filter((name) => !actual.includes(name));
const extra = actual.filter((name) => !expectedNames.includes(name));
return {
error: `self_score 维度必须与能力 score 一致:${expectedNames.join("、")}`,
error: [
`self_score 维度必须与能力 score 一致:${expectedNames.join("、")}`,
kept.length ? `已接受、请原样保留:${kept.join("、")}` : "",
missing.length ? `缺少:${missing.join("、")}` : "",
extra.length ? `多余:${extra.join("、")}` : "",
]
.filter(Boolean)
.join(";"),
};
}
return { value: { dims: parsed } };
+4
View File
@@ -0,0 +1,4 @@
export * from "./types.js";
export * from "./store.js";
export * from "./turn.js";
export * from "./turn-variants.js";
+658
View File
@@ -0,0 +1,658 @@
/**
* 直聊持久化:{用户数据}/direct-chats/
* shells/<shellId>.json
* conversations/<conversationId>.json
*/
import { randomUUID } from "node:crypto";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { getDirectChatsDir } from "../config/user-data-dir.js";
import { parseHistoryMessages } from "../skills/dialogue-history.js";
import {
EMPTY_SHELL_SLOTS,
countTurnMessages,
migrateMessagesToTurns,
type DirectChatConversation,
type DirectChatConversationSummary,
type DirectChatMessage,
type DirectChatSeedTurn,
type DirectChatShell,
type DirectChatShellSlots,
type DirectChatShellSummary,
type DirectChatTurn,
type DirectChatWindow,
} from "./types.js";
export { getDirectChatsDir };
function shellsDir(): string {
return path.join(getDirectChatsDir(), "shells");
}
function conversationsDir(): string {
return path.join(getDirectChatsDir(), "conversations");
}
export function ensureDirectChatDirs(): void {
mkdirSync(shellsDir(), { recursive: true });
mkdirSync(conversationsDir(), { recursive: true });
}
function nowIso(): string {
return new Date().toISOString();
}
function asString(v: unknown): string | undefined {
if (typeof v !== "string") return undefined;
return v;
}
function readJson(file: string): unknown | null {
try {
return JSON.parse(readFileSync(file, "utf8")) as unknown;
} catch {
return null;
}
}
function writeJsonAtomic(file: string, data: unknown): void {
mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
renameSync(tmp, file);
}
function shellPath(id: string): string {
return path.join(shellsDir(), `${id}.json`);
}
function conversationPath(id: string): string {
return path.join(conversationsDir(), `${id}.json`);
}
function parseSeedMessages(raw: unknown): DirectChatSeedTurn[] {
if (!Array.isArray(raw)) return [];
const out: DirectChatSeedTurn[] = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const role = asString(row.role);
const content = asString(row.content);
if ((role !== "user" && role !== "assistant") || content == null) continue;
if (!content.trim()) continue;
out.push({ role, content });
}
return out;
}
/**
* 旧盘迁移:种子历史原本是一段文本,现在由结构化条目编辑。
* - 能切出真轮次的 → seedMessages(历史里的「系统」降为 assistant,历史只认两个角色);
* - 切不出的(没有块头的普通文本)→ 挪进 loreBefore 的「前情」
* (否则它还会被发送,但界面上已经看不到)。
*/
function migrateSeedHistory(shell: DirectChatShell): DirectChatShell {
if (shell.seedMessages && shell.seedMessages.length > 0) return shell;
const raw = (shell.seedHistory || "").trim();
if (!raw) return shell;
const turns = parseHistoryMessages(raw);
if (turns.length === 0) {
return {
...shell,
loreBefore: [shell.loreBefore.trim(), raw].filter(Boolean).join("\n\n"),
seedHistory: "",
};
}
const seedMessages: DirectChatSeedTurn[] = turns
.map((message) => ({
role: message.role === "user" ? ("user" as const) : ("assistant" as const),
content: typeof message.content === "string" ? message.content : "",
}))
.filter((message) => message.content.trim().length > 0);
return { ...shell, seedHistory: "", seedMessages };
}
function parseShell(raw: unknown): DirectChatShell | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const row = raw as Record<string, unknown>;
const id = asString(row.id)?.trim();
const name = asString(row.name)?.trim();
if (!id || !name) return null;
const presetRaw = row.presetId;
const presetId =
presetRaw === null || presetRaw === undefined
? null
: typeof presetRaw === "string" && presetRaw.trim()
? presetRaw.trim()
: null;
return {
id,
name,
systemPrompt: asString(row.systemPrompt) ?? "",
loreBefore: asString(row.loreBefore) ?? "",
seedHistory: asString(row.seedHistory) ?? "",
seedMessages: parseSeedMessages(row.seedMessages),
loreAfter: asString(row.loreAfter) ?? "",
postTurn: asString(row.postTurn) ?? "",
presetId,
createdAt: asString(row.createdAt) ?? nowIso(),
updatedAt: asString(row.updatedAt) ?? nowIso(),
};
}
function parseMessage(raw: unknown): DirectChatMessage | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const row = raw as Record<string, unknown>;
const id = asString(row.id)?.trim();
const role = asString(row.role);
const content = asString(row.content);
if (!id || (role !== "user" && role !== "assistant") || content == null) {
return null;
}
const msg: DirectChatMessage = {
id,
role,
content,
createdAt: asString(row.createdAt) ?? nowIso(),
};
if (typeof row.thinking === "string" && row.thinking) msg.thinking = row.thinking;
if (typeof row.model === "string" && row.model) msg.model = row.model;
if (row.contextTrace && typeof row.contextTrace === "object") {
msg.contextTrace = row.contextTrace as DirectChatMessage["contextTrace"];
}
if (Array.isArray(row.variants)) {
msg.variants = row.variants as DirectChatMessage["variants"];
}
if (typeof row.branchIndex === "number" && Number.isFinite(row.branchIndex)) {
msg.branchIndex = row.branchIndex;
}
return msg;
}
function parseWindow(raw: unknown): DirectChatWindow | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const row = raw as Record<string, unknown>;
const id = asString(row.id)?.trim();
const profileId = asString(row.profileId)?.trim();
if (!id || !profileId) return null;
return {
id,
profileId,
...(asString(row.profileName) ? { profileName: asString(row.profileName) } : {}),
...(asString(row.model) ? { model: asString(row.model) } : {}),
};
}
function parseReplies(raw: unknown): DirectChatTurn["replies"] {
const replies: DirectChatTurn["replies"] = [];
if (!Array.isArray(raw)) return replies;
for (const r of raw) {
if (!r || typeof r !== "object" || Array.isArray(r)) continue;
const rr = r as Record<string, unknown>;
const windowId = asString(rr.windowId)?.trim();
const content = asString(rr.content);
if (!windowId || content == null) continue;
replies.push({
windowId,
content,
...(typeof rr.thinking === "string" && rr.thinking
? { thinking: rr.thinking }
: {}),
...(typeof rr.model === "string" && rr.model ? { model: rr.model } : {}),
...(typeof rr.error === "string" && rr.error ? { error: rr.error } : {}),
...(rr.contextTrace && typeof rr.contextTrace === "object"
? { contextTrace: rr.contextTrace as DirectChatMessage["contextTrace"] }
: {}),
});
}
return replies;
}
function parseSelected(
raw: unknown,
): string | null | undefined {
if (raw === null) return null;
return asString(raw)?.trim() || undefined;
}
function parseVariants(raw: unknown): DirectChatTurn["variants"] {
if (!Array.isArray(raw)) return undefined;
const variants: NonNullable<DirectChatTurn["variants"]> = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const userRaw = row.user;
if (!userRaw || typeof userRaw !== "object" || Array.isArray(userRaw)) continue;
const userRow = userRaw as Record<string, unknown>;
const userContent = asString(userRow.content);
if (userContent == null) continue;
const after: DirectChatTurn[] = [];
if (Array.isArray(row.after)) {
for (const child of row.after) {
const parsed = parseTurn(child);
if (parsed) after.push(parsed);
}
}
const selected = parseSelected(row.selectedWindowId);
variants.push({
user: {
content: userContent,
createdAt: asString(userRow.createdAt) ?? nowIso(),
},
replies: parseReplies(row.replies),
...(selected !== undefined ? { selectedWindowId: selected } : {}),
after,
});
}
return variants.length ? variants : undefined;
}
function parseTurn(raw: unknown): DirectChatTurn | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const row = raw as Record<string, unknown>;
const id = asString(row.id)?.trim();
const userRaw = row.user;
if (!id || !userRaw || typeof userRaw !== "object" || Array.isArray(userRaw)) {
return null;
}
const userRow = userRaw as Record<string, unknown>;
const userContent = asString(userRow.content);
if (userContent == null) return null;
const replies = parseReplies(row.replies);
const selected = parseSelected(row.selectedWindowId);
const variants = parseVariants(row.variants);
const variantIndex =
typeof row.variantIndex === "number" && Number.isFinite(row.variantIndex)
? Math.max(0, Math.floor(row.variantIndex))
: undefined;
return {
id,
user: {
content: userContent,
createdAt: asString(userRow.createdAt) ?? nowIso(),
},
replies,
...(selected !== undefined ? { selectedWindowId: selected } : {}),
createdAt: asString(row.createdAt) ?? nowIso(),
...(variants
? {
variants,
variantIndex: Math.min(variantIndex ?? 0, variants.length - 1),
}
: {}),
};
}
function parseConversation(raw: unknown): DirectChatConversation | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const row = raw as Record<string, unknown>;
const id = asString(row.id)?.trim();
const shellId = asString(row.shellId)?.trim();
const name = asString(row.name)?.trim();
if (!id || !shellId || !name) return null;
const messages: DirectChatMessage[] = [];
if (Array.isArray(row.messages)) {
for (const m of row.messages) {
const parsed = parseMessage(m);
if (parsed) messages.push(parsed);
}
}
const windows: DirectChatWindow[] = [];
if (Array.isArray(row.windows)) {
for (const w of row.windows) {
const parsed = parseWindow(w);
if (parsed) windows.push(parsed);
}
}
let turns: DirectChatTurn[] = [];
if (Array.isArray(row.turns)) {
for (const t of row.turns) {
const parsed = parseTurn(t);
if (parsed) turns.push(parsed);
}
}
if (!turns.length && messages.length) {
const wid = windows[0]?.id ?? "default";
turns = migrateMessagesToTurns(messages, wid);
if (!windows.length) {
windows.push({ id: wid, profileId: "", profileName: "默认" });
}
}
return {
id,
shellId,
name,
messages,
windows,
turns,
createdAt: asString(row.createdAt) ?? nowIso(),
updatedAt: asString(row.updatedAt) ?? nowIso(),
};
}
export function listShells(): DirectChatShellSummary[] {
ensureDirectChatDirs();
const root = shellsDir();
if (!existsSync(root)) return [];
const out: DirectChatShellSummary[] = [];
for (const name of readdirSync(root)) {
if (!name.endsWith(".json")) continue;
const shell = parseShell(readJson(path.join(root, name)));
if (!shell) continue;
const conversationCount = listConversations(shell.id).length;
out.push({
id: shell.id,
name: shell.name,
presetId: shell.presetId,
conversationCount,
updatedAt: shell.updatedAt,
});
}
out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return out;
}
export function getShell(id: string): DirectChatShell | null {
const key = id.trim();
if (!key) return null;
ensureDirectChatDirs();
const parsed = parseShell(readJson(shellPath(key)));
if (!parsed) return null;
const shell = migrateSeedHistory(parsed);
if (shell !== parsed) {
try {
writeJsonAtomic(shellPath(shell.id), shell);
} catch {
// 写不进去也不影响本次使用
}
}
return shell;
}
export function createShell(input: {
name?: string;
slots?: Partial<DirectChatShellSlots>;
presetId?: string | null;
}): DirectChatShell {
ensureDirectChatDirs();
const ts = nowIso();
const slots = { ...EMPTY_SHELL_SLOTS, ...input.slots };
const shell: DirectChatShell = {
id: randomUUID(),
name: (input.name?.trim() || "未命名上下文") as string,
...slots,
presetId:
input.presetId === undefined
? null
: input.presetId && input.presetId.trim()
? input.presetId.trim()
: null,
createdAt: ts,
updatedAt: ts,
};
writeJsonAtomic(shellPath(shell.id), shell);
return shell;
}
export function updateShell(
id: string,
patch: Partial<
Pick<
DirectChatShell,
| "name"
| "systemPrompt"
| "loreBefore"
| "seedHistory"
| "seedMessages"
| "loreAfter"
| "postTurn"
| "presetId"
>
>,
): DirectChatShell {
const existing = getShell(id);
if (!existing) throw new Error("上下文壳不存在");
const next: DirectChatShell = {
...existing,
name:
patch.name !== undefined
? patch.name.trim() || existing.name
: existing.name,
systemPrompt:
patch.systemPrompt !== undefined ? patch.systemPrompt : existing.systemPrompt,
loreBefore:
patch.loreBefore !== undefined ? patch.loreBefore : existing.loreBefore,
seedHistory:
patch.seedHistory !== undefined ? patch.seedHistory : existing.seedHistory,
seedMessages:
patch.seedMessages !== undefined ? patch.seedMessages : existing.seedMessages,
loreAfter:
patch.loreAfter !== undefined ? patch.loreAfter : existing.loreAfter,
postTurn: patch.postTurn !== undefined ? patch.postTurn : existing.postTurn,
presetId:
patch.presetId !== undefined
? patch.presetId && patch.presetId.trim()
? patch.presetId.trim()
: null
: existing.presetId,
updatedAt: nowIso(),
};
writeJsonAtomic(shellPath(next.id), next);
return next;
}
export function deleteShell(id: string): void {
const shell = getShell(id);
if (!shell) throw new Error("上下文壳不存在");
for (const c of listConversations(shell.id)) {
deleteConversation(c.id);
}
const file = shellPath(shell.id);
if (existsSync(file)) unlinkSync(file);
}
export function listConversations(
shellId: string,
): DirectChatConversationSummary[] {
ensureDirectChatDirs();
const key = shellId.trim();
if (!key) return [];
const root = conversationsDir();
if (!existsSync(root)) return [];
const out: DirectChatConversationSummary[] = [];
for (const name of readdirSync(root)) {
if (!name.endsWith(".json")) continue;
const conv = parseConversation(readJson(path.join(root, name)));
if (!conv || conv.shellId !== key) continue;
out.push({
id: conv.id,
shellId: conv.shellId,
name: conv.name,
messageCount: countTurnMessages(conv.turns),
updatedAt: conv.updatedAt,
});
}
out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return out;
}
export function getConversation(id: string): DirectChatConversation | null {
const key = id.trim();
if (!key) return null;
ensureDirectChatDirs();
return parseConversation(readJson(conversationPath(key)));
}
export function createConversation(input: {
shellId: string;
name?: string;
messages?: DirectChatMessage[];
windows?: DirectChatWindow[];
turns?: DirectChatTurn[];
}): DirectChatConversation {
const shell = getShell(input.shellId);
if (!shell) throw new Error("上下文壳不存在");
ensureDirectChatDirs();
const ts = nowIso();
const windows = Array.isArray(input.windows) ? input.windows : [];
let turns = Array.isArray(input.turns) ? input.turns : [];
const messages = Array.isArray(input.messages) ? input.messages : [];
if (!turns.length && messages.length) {
turns = migrateMessagesToTurns(messages, windows[0]?.id ?? "default");
}
const conv: DirectChatConversation = {
id: randomUUID(),
shellId: shell.id,
name: (input.name?.trim() || "未命名会话") as string,
messages,
windows,
turns,
createdAt: ts,
updatedAt: ts,
};
writeJsonAtomic(conversationPath(conv.id), conv);
updateShell(shell.id, {});
return conv;
}
export function updateConversation(
id: string,
patch: Partial<
Pick<DirectChatConversation, "name" | "messages" | "windows" | "turns">
>,
): DirectChatConversation {
const existing = getConversation(id);
if (!existing) throw new Error("会话不存在");
const next: DirectChatConversation = {
...existing,
name:
patch.name !== undefined
? patch.name.trim() || existing.name
: existing.name,
messages:
patch.messages !== undefined ? patch.messages : existing.messages,
windows: patch.windows !== undefined ? patch.windows : existing.windows,
turns: patch.turns !== undefined ? patch.turns : existing.turns,
updatedAt: nowIso(),
};
writeJsonAtomic(conversationPath(next.id), next);
return next;
}
export function deleteConversation(id: string): void {
const conv = getConversation(id);
if (!conv) throw new Error("会话不存在");
const file = conversationPath(conv.id);
if (existsSync(file)) unlinkSync(file);
}
function copyName(name: string): string {
const base = name.replace(/(?: 副本)+$/, "").trim() || "会话";
return `${base} 副本`;
}
function remapWindowId(
id: string | null | undefined,
idMap: Map<string, string>,
): string | null | undefined {
if (!id) return id;
return idMap.get(id) ?? id;
}
function remapTurn(turn: DirectChatTurn, idMap: Map<string, string>): DirectChatTurn {
const replies = turn.replies.map((r) => ({
...r,
windowId: idMap.get(r.windowId) ?? r.windowId,
}));
const selected = remapWindowId(turn.selectedWindowId, idMap);
const variants = turn.variants?.map((v) => ({
...v,
replies: v.replies.map((r) => ({
...r,
windowId: idMap.get(r.windowId) ?? r.windowId,
})),
selectedWindowId: remapWindowId(v.selectedWindowId, idMap),
after: v.after.map((child) => remapTurn(child, idMap)),
}));
const next: DirectChatTurn = {
...turn,
id: randomUUID(),
user: { ...turn.user },
replies,
...(variants ? { variants } : {}),
};
if (selected === undefined) delete next.selectedWindowId;
else next.selectedWindowId = selected;
return next;
}
/** 复制会话:同一上下文下新开一份,模型与对话记录都带上,窗口 id 重映射。 */
export function duplicateConversation(id: string): DirectChatConversation {
const existing = getConversation(id);
if (!existing) throw new Error("会话不存在");
const idMap = new Map<string, string>();
const windows = existing.windows.map((w) => {
const nextId = randomUUID();
idMap.set(w.id, nextId);
return { ...w, id: nextId };
});
const turns = existing.turns.map((t) => remapTurn(t, idMap));
return createConversation({
shellId: existing.shellId,
name: copyName(existing.name),
windows,
turns,
messages: [],
});
}
export function saveConversation(conv: DirectChatConversation): DirectChatConversation {
const next = { ...conv, updatedAt: nowIso() };
ensureDirectChatDirs();
writeJsonAtomic(conversationPath(next.id), next);
return next;
}
/** 将临时会话升为持久:可选新建壳,或挂到已有壳下 */
export function promoteTemporary(input: {
shell?: {
name?: string;
slots?: Partial<DirectChatShellSlots>;
presetId?: string | null;
};
shellId?: string;
conversationName?: string;
messages?: DirectChatMessage[];
windows?: DirectChatWindow[];
turns?: DirectChatTurn[];
}): { shell: DirectChatShell; conversation: DirectChatConversation } {
let shell: DirectChatShell;
if (input.shellId) {
const existing = getShell(input.shellId);
if (!existing) throw new Error("上下文壳不存在");
shell = existing;
} else {
shell = createShell({
name: input.shell?.name,
slots: input.shell?.slots,
presetId: input.shell?.presetId,
});
}
const conversation = createConversation({
shellId: shell.id,
name: input.conversationName,
messages: input.messages,
windows: input.windows,
turns: input.turns,
});
return { shell, conversation };
}
+222
View File
@@ -0,0 +1,222 @@
import type {
DirectChatAssistantReply,
DirectChatTurn,
DirectChatTurnVariant,
} from "./types.js";
function clone<T>(value: T): T {
return structuredClone(value);
}
function selectedFields(
id: string | null | undefined,
): { selectedWindowId?: string | null } {
if (id === undefined) return {};
return { selectedWindowId: id };
}
function snapshotVariant(
turn: DirectChatTurn,
after: DirectChatTurn[],
): DirectChatTurnVariant {
return {
user: { content: turn.user.content, createdAt: turn.user.createdAt },
replies: clone(turn.replies),
...selectedFields(turn.selectedWindowId),
after: clone(after),
};
}
function applyVariant(
turn: DirectChatTurn,
variant: DirectChatTurnVariant,
index: number,
variants: DirectChatTurnVariant[],
): DirectChatTurn {
return {
id: turn.id,
user: { content: variant.user.content, createdAt: variant.user.createdAt },
replies: clone(variant.replies),
createdAt: turn.createdAt,
variants,
variantIndex: index,
...selectedFields(variant.selectedWindowId),
};
}
/** 把当前线性历史写回激活版本,便于切走后再切回。 */
export function commitActiveVariant(
turns: DirectChatTurn[],
index: number,
): DirectChatTurn[] {
const turn = turns[index];
if (!turn) return turns;
const after = turns.slice(index + 1);
const existing = turn.variants?.length
? turn.variants.map((v) => ({ ...v, after: v.after ?? [] }))
: [snapshotVariant(turn, after)];
const idx = Math.min(
existing.length - 1,
Math.max(0, turn.variantIndex ?? 0),
);
existing[idx] = snapshotVariant(
{ ...turn, variants: existing, variantIndex: idx },
after,
);
const next: DirectChatTurn = {
...turn,
variants: existing,
variantIndex: idx,
};
return [...turns.slice(0, index), next, ...after];
}
export function beginReroll(
turns: DirectChatTurn[],
turnId: string,
text: string,
): { turns: DirectChatTurn[]; prior: DirectChatTurn[]; index: number } | null {
const trimmed = text.trim();
if (!trimmed) return null;
const index = turns.findIndex((t) => t.id === turnId);
if (index < 0) return null;
const committed = commitActiveVariant(turns, index);
const turn = committed[index];
if (!turn) return null;
const variant: DirectChatTurnVariant = {
user: { content: trimmed, createdAt: turn.user.createdAt },
replies: [],
after: [],
};
const variants = [...(turn.variants ?? []), variant];
const { selectedWindowId: _drop, ...rest } = turn;
const nextTurn: DirectChatTurn = {
...rest,
user: { content: trimmed, createdAt: turn.user.createdAt },
replies: [],
variants,
variantIndex: variants.length - 1,
};
return {
turns: [...committed.slice(0, index), nextTurn],
prior: committed.slice(0, index),
index,
};
}
/**
* 只重跑部分窗口时,把新回复按 windowId 合并进本轮当前版本。
*
* 与 `writeRerollReplies` 的区别:不新开版本、不清空整轮,
* 没重跑的窗口保留原回复(也不会丢掉「保留此回复」的选择)。
*/
export function mergeTurnReplies(
turns: DirectChatTurn[],
turnId: string,
replies: DirectChatAssistantReply[],
): DirectChatTurn[] {
const index = turns.findIndex((t) => t.id === turnId);
if (index < 0) return turns;
const turn = turns[index];
if (!turn) return turns;
const byWindow = new Map<string, DirectChatAssistantReply>();
for (const reply of turn.replies ?? []) byWindow.set(reply.windowId, reply);
for (const reply of replies) byWindow.set(reply.windowId, reply);
const merged = clone([...byWindow.values()]);
const variants = (turn.variants ?? []).slice();
const idx = Math.min(
Math.max(0, turn.variantIndex ?? Math.max(0, variants.length - 1)),
Math.max(0, variants.length - 1),
);
const current = variants[idx];
if (current) {
variants[idx] = { ...current, replies: clone(merged) };
}
const next: DirectChatTurn = {
...turn,
replies: clone(merged),
variants,
variantIndex: idx,
};
return [...turns.slice(0, index), next, ...turns.slice(index + 1)];
}
/** 把本轮回复写入激活版本。新版本不带「保留此回复」。 */
export function writeRerollReplies(
turns: DirectChatTurn[],
turnId: string,
replies: DirectChatAssistantReply[],
): DirectChatTurn[] {
const index = turns.findIndex((t) => t.id === turnId);
if (index < 0) return turns;
const turn = turns[index];
if (!turn) return turns;
const variants = (turn.variants ?? []).slice();
const idx = Math.min(
Math.max(0, turn.variantIndex ?? Math.max(0, variants.length - 1)),
Math.max(0, variants.length - 1),
);
const cloned = clone(replies);
const current = variants[idx];
if (current) {
variants[idx] = {
user: { ...turn.user },
replies: cloned,
after: [],
};
}
const { selectedWindowId: _drop, ...rest } = turn;
const next: DirectChatTurn = {
...rest,
replies: clone(cloned),
variants,
variantIndex: idx,
};
return [...turns.slice(0, index), next, ...turns.slice(index + 1)];
}
export function switchTurnVariant(
turns: DirectChatTurn[],
turnId: string,
direction: "prev" | "next",
): DirectChatTurn[] | null {
const index = turns.findIndex((t) => t.id === turnId);
if (index < 0) return null;
const committed = commitActiveVariant(turns, index);
const turn = committed[index];
if (!turn) return null;
const variants = turn.variants ?? [];
const cur = turn.variantIndex ?? 0;
const next = direction === "next" ? cur + 1 : cur - 1;
if (next < 0 || next >= variants.length) return null;
const variant = variants[next];
if (!variant) return null;
const restored = applyVariant(turn, variant, next, variants);
return [
...committed.slice(0, index),
restored,
...clone(variant.after),
];
}
/** 生成失败且没有任何输出时,丢掉刚开的空版本并回到上一版。 */
export function abortReroll(
turns: DirectChatTurn[],
turnId: string,
): DirectChatTurn[] | null {
const index = turns.findIndex((t) => t.id === turnId);
if (index < 0) return null;
const turn = turns[index];
if (!turn) return null;
const variants = turn.variants ?? [];
const active = turn.variantIndex ?? variants.length - 1;
if (active <= 0) return null;
const kept = variants.slice(0, active);
const prev = kept[active - 1];
if (!prev) return null;
const restored = applyVariant(turn, prev, active - 1, kept);
return [...turns.slice(0, index), restored, ...clone(prev.after)];
}
+454
View File
@@ -0,0 +1,454 @@
import { randomUUID } from "node:crypto";
import { getApiProfile, profileToLlmConfig } from "../config/api-profiles.js";
import {
ensureActiveProfileDefault,
loadAppSettings,
resolveActiveProfile,
} from "../config/settings.js";
import {
OpenAiCompatibleProvider,
type ChatMessage,
type LlmProvider,
} from "../llm/client.js";
import { wrapLlmForSession } from "../llm/preset-wrapper.js";
import type { LlmTrackingContext } from "../llm/token-tracker.js";
import { assemblePlayWorkerMessages } from "../preset/play-frame.js";
import { applyRegexScripts, readRegexScripts, REGEX_PLACEMENT } from "../preset/regex-scripts.js";
import { expandPresetDirectives } from "../directives/expand.js";
import { getPreset, resolveActivePreset } from "../preset/store.js";
import type { WorldInfoPack } from "../preset/world-info-pack.js";
import { hasRealLlmConfig } from "../runtime/llm-factory.js";
import {
pruneContextTraces,
type LlmContextTrace,
} from "../types/context-trace.js";
import type { PresetPackage } from "../types/preset.js";
import { parseHistoryMessages } from "../skills/dialogue-history.js";
import {
buildPriorForWindow,
type DirectChatAssistantReply,
type DirectChatMessage,
type DirectChatShellSlots,
type DirectChatTurn,
type DirectChatWindow,
} from "./types.js";
export type DirectChatTurnInput = {
message: string;
slots: DirectChatShellSlots;
/** 本会话已落定的多轮(不含本轮) */
priorMessages: Array<{ role: "user" | "assistant"; content: string }>;
presetId?: string | null;
/** 覆盖当前 API profile */
profileId?: string | null;
complete?: boolean;
/** 思维链或正文的增量,边生成边回调 */
onDelta?: (field: "thinking" | "content", delta: string) => void;
};
export type DirectChatTurnResult = {
messages: Array<{ role: string; content: string }>;
reply: string | null;
reasoning: string | null;
model?: string;
completed: boolean;
contextTrace?: LlmContextTrace;
skipReason?: string;
error?: string;
};
function optionalEntry(
id: string,
name: string,
content: string,
): WorldInfoPack["worldBookBefore"] {
const text = content.trim();
return text ? [{ id, name, content: text }] : [];
}
function formatTranscript(
prior: Array<{ role: "user" | "assistant"; content: string }>,
): string {
const parts: string[] = [];
for (const m of prior) {
const text = m.content.trim();
if (!text) continue;
if (m.role === "user") parts.push(`【用户】\n${text}`);
else parts.push(`【助手】\n${text}`);
}
return parts.join("\n\n");
}
export function buildChatHistory(
seedHistory: string,
priorMessages: Array<{ role: "user" | "assistant"; content: string }>,
): string {
const seed = seedHistory.trim();
const transcript = formatTranscript(priorMessages);
if (seed && transcript) return `${seed}\n\n${transcript}`;
return seed || transcript;
}
export function worldInfoPackFromDirectChat(input: {
message: string;
slots: DirectChatShellSlots;
priorMessages: Array<{ role: "user" | "assistant"; content: string }>;
}): WorldInfoPack {
// 种子前情:结构化条目直接用(真多轮);旧盘文本靠块头切,切不出的当史前读料。
const seedFromSlots = (input.slots.seedMessages ?? [])
.filter((turn) => (turn.role === "user" || turn.role === "assistant") && turn.content.trim())
.map((turn) => ({ role: turn.role, content: turn.content }));
const seedTurns =
seedFromSlots.length > 0
? seedFromSlots
: parseHistoryMessages(input.slots.seedHistory);
const seedIsStructured = seedTurns.length > 0;
const history: ChatMessage[] = [
...seedTurns,
...input.priorMessages
.map((m) => ({
role: m.role,
content: (m.content ?? "").trim(),
}))
.filter((m) => m.content.length > 0),
];
return {
worldBookBefore: [
...optionalEntry(
"direct-seed",
"前情",
seedIsStructured ? "" : input.slots.seedHistory,
),
...optionalEntry("direct-before", "直聊设定", input.slots.loreBefore),
],
history,
...(input.slots.exampleDialogue?.trim()
? { exampleDialogue: input.slots.exampleDialogue.trim() }
: {}),
card: {
description: input.slots.charDescription,
personality: input.slots.charPersonality,
scenario: input.slots.scenario,
},
worldBookAfter: optionalEntry(
"direct-after",
"本轮状态",
input.slots.loreAfter,
),
turn: input.message.trim(),
postTurn: optionalEntry("direct-post", "尾注", input.slots.postTurn),
};
}
export function resolveDirectChatPreset(
presetId?: string | null,
): PresetPackage {
if (presetId?.trim()) {
const preset = getPreset(presetId.trim());
if (!preset) throw new Error(`预设不存在:${presetId}`);
return preset;
}
const active = resolveActivePreset(loadAppSettings().activePresetId);
if (!active) {
throw new Error("请先在设置中选用一条预设;直聊请求必须由预设包裹");
}
return active;
}
export function systemPromptForSlots(slots: DirectChatShellSlots): string {
// 不填就不发:酒馆没有"隐式系统提示"这回事,缺省内容会把请求撑成多一条。
return slots.systemPrompt.trim();
}
/** UI / 对照用的理想夹心 */
export function assembleDirectChatMessages(
preset: PresetPackage,
input: {
message: string;
slots: DirectChatShellSlots;
priorMessages: Array<{ role: "user" | "assistant"; content: string }>;
},
): ChatMessage[] {
return assemblePlayWorkerMessages({
systemPrompt: systemPromptForSlots(input.slots),
preset,
pack: worldInfoPackFromDirectChat(input),
charName: input.slots.charName ?? null,
});
}
/** 交给 PresetLlmProvider 的任务消息(未夹心) */
export function buildDirectChatTaskMessages(input: {
message: string;
slots: DirectChatShellSlots;
priorMessages: Array<{ role: "user" | "assistant"; content: string }>;
}): ChatMessage[] {
const pack = worldInfoPackFromDirectChat(input);
const parts: string[] = [];
for (const e of pack.worldBookBefore) {
parts.push(`### ${e.name}\n\n${e.content}`);
}
for (const m of pack.history) {
const text = typeof m.content === "string" ? m.content.trim() : "";
if (text) parts.push(text);
}
for (const e of pack.worldBookAfter) {
parts.push(`### ${e.name}\n\n${e.content}`);
}
for (const e of pack.postTurn) {
parts.push(`### ${e.name}\n\n${e.content}`);
}
const ctx = parts.join("\n\n").trim();
const user = ctx ? `${ctx}\n\n## 本轮输入\n${pack.turn}` : pack.turn;
const systemPrompt = systemPromptForSlots(input.slots);
return [
...(systemPrompt ? [{ role: "system" as const, content: systemPrompt }] : []),
{ role: "user", content: user },
];
}
function serializeMessages(
messages: ChatMessage[],
): Array<{ role: string; content: string }> {
return messages.map((m) => ({
role: m.role,
content: typeof m.content === "string" ? m.content : String(m.content ?? ""),
}));
}
function createTrackedLlm(
preset: PresetPackage,
profileId: string | null | undefined,
trackingRef: { current: LlmTrackingContext },
charName: string | null | undefined,
): LlmProvider {
if (profileId?.trim()) {
const profile = getApiProfile(profileId.trim());
if (!profile?.apiKey?.trim()) {
throw new Error(`API 配置不可用:${profileId}`);
}
const inner = new OpenAiCompatibleProvider(profileToLlmConfig(profile));
return wrapLlmForSession(inner, trackingRef, () => preset, undefined, () => charName);
}
ensureActiveProfileDefault();
const profile = resolveActiveProfile();
if (!profile?.apiKey?.trim()) {
throw new Error("未配置 API Key");
}
const inner = new OpenAiCompatibleProvider(profileToLlmConfig(profile));
return wrapLlmForSession(inner, trackingRef, () => preset, undefined, () => charName);
}
export async function runDirectChatTurn(
input: DirectChatTurnInput,
): Promise<DirectChatTurnResult> {
const message = input.message.trim();
if (!message) throw new Error("缺少本轮输入");
const preset = resolveDirectChatPreset(input.presetId);
const turnInput = {
message,
slots: input.slots,
priorMessages: input.priorMessages,
};
const assembled = serializeMessages(
assembleDirectChatMessages(preset, turnInput),
);
const wantComplete = input.complete !== false;
if (!wantComplete) {
return {
messages: assembled,
reply: null,
reasoning: null,
completed: false,
skipReason: "未请求生成",
};
}
if (!hasRealLlmConfig() && !input.profileId?.trim()) {
return {
messages: assembled,
reply: null,
reasoning: null,
completed: false,
skipReason: "未配置 API Key,只返回拼装后的上下文",
};
}
const trackingRef = { current: {} as LlmTrackingContext };
try {
const llm = createTrackedLlm(preset, input.profileId, trackingRef, input.slots.charName);
const messages = buildDirectChatTaskMessages(turnInput);
const callbacks = {
onReasoningDelta: (delta: string) => input.onDelta?.("thinking", delta),
onContentDelta: (delta: string) => input.onDelta?.("content", delta),
};
// 真多轮:历史轮次以 message 序列交给夹心层,不再拍平成一段。
const completeOptions = {
caller: "direct-chat",
presetPack: worldInfoPackFromDirectChat(turnInput),
};
const result = llm.completeStream
? await llm.completeStream(messages, completeOptions, callbacks)
: await llm.complete(messages, completeOptions);
const contextTrace = trackingRef.current.pendingContextTrace;
const reasoning = result.reasoning ?? null;
let reply = result.content ?? "";
if (reasoning && reply === reasoning) reply = "";
// 酒馆在"收到回复那一刻"就跑一遍既非 promptOnly 也非 markdownOnly 的正则,
// 改的是存进 chat 的文本;这里同样在落盘前跑。
if (reply) {
const scripts = readRegexScripts(preset.raw);
if (scripts.length > 0) {
reply = applyRegexScripts(reply, scripts, {
placement: REGEX_PLACEMENT.AI_OUTPUT,
direction: "raw",
expand: (text) => expandPresetDirectives(text, {}).text,
});
}
}
return {
messages: assembled,
reply,
reasoning,
model: result.model,
completed: true,
...(contextTrace ? { contextTrace } : {}),
};
} catch (err) {
return {
messages: assembled,
reply: null,
reasoning: null,
completed: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export function appendTurnMessages(params: {
prior: DirectChatMessage[];
userText: string;
reply: string;
reasoning?: string | null;
model?: string;
contextTrace?: LlmContextTrace;
keepTraceLatest?: number;
}): DirectChatMessage[] {
const ts = new Date().toISOString();
const userMsg: DirectChatMessage = {
id: randomUUID(),
role: "user",
content: params.userText,
createdAt: ts,
};
const assistantMsg: DirectChatMessage = {
id: randomUUID(),
role: "assistant",
content: params.reply,
createdAt: ts,
...(params.reasoning ? { thinking: params.reasoning } : {}),
...(params.model ? { model: params.model } : {}),
...(params.contextTrace ? { contextTrace: params.contextTrace } : {}),
};
const keep =
params.keepTraceLatest ?? loadAppSettings().contextTraceKeepLatest ?? 3;
return pruneContextTraces([...params.prior, userMsg, assistantMsg], keep);
}
export type DirectChatMultiTurnInput = {
message: string;
slots: DirectChatShellSlots;
presetId?: string | null;
windows: DirectChatWindow[];
/** 已落定轮次(不含本轮) */
turns: DirectChatTurn[];
complete?: boolean;
/** 落盘用的回合 id;流式写入时与中途存档对齐 */
turnId?: string;
/** 某个窗口的增量 */
onDelta?: (event: DirectChatStreamDelta) => void;
/** 某个窗口一结束就回调,不必等其余窗口 */
onWindow?: (result: DirectChatMultiWindowResult) => void;
};
export type DirectChatStreamDelta = {
windowId: string;
field: "thinking" | "content";
delta: string;
};
export type DirectChatMultiWindowResult = {
windowId: string;
profileId: string;
reply: string | null;
reasoning: string | null;
model?: string;
completed: boolean;
contextTrace?: LlmContextTrace;
skipReason?: string;
error?: string;
};
export type DirectChatMultiTurnResult = {
results: DirectChatMultiWindowResult[];
turn: DirectChatTurn;
};
/** 同一用户输入,对各窗口并行 completion;各自 prior 按选定规则拼装 */
export async function runDirectChatMultiTurn(
input: DirectChatMultiTurnInput,
): Promise<DirectChatMultiTurnResult> {
const message = input.message.trim();
if (!message) throw new Error("缺少本轮输入");
if (!input.windows.length) throw new Error("至少需要一个模型窗口");
const ts = new Date().toISOString();
const results = await Promise.all(
input.windows.map(async (win) => {
const prior = buildPriorForWindow(input.turns, win.id);
const single = await runDirectChatTurn({
message,
slots: input.slots,
priorMessages: prior,
presetId: input.presetId,
profileId: win.profileId,
complete: input.complete,
onDelta: (field, delta) =>
input.onDelta?.({ windowId: win.id, field, delta }),
});
const result = {
windowId: win.id,
profileId: win.profileId,
reply: single.reply,
reasoning: single.reasoning,
model: single.model ?? win.model,
completed: single.completed,
...(single.contextTrace ? { contextTrace: single.contextTrace } : {}),
...(single.skipReason ? { skipReason: single.skipReason } : {}),
...(single.error ? { error: single.error } : {}),
} satisfies DirectChatMultiWindowResult;
input.onWindow?.(result);
return result;
}),
);
const replies: DirectChatAssistantReply[] = results.map((r) => ({
windowId: r.windowId,
content: r.reply ?? "",
...(r.reasoning ? { thinking: r.reasoning } : {}),
...(r.model ? { model: r.model } : {}),
...(r.error ? { error: r.error } : {}),
...(r.contextTrace ? { contextTrace: r.contextTrace } : {}),
}));
const turn: DirectChatTurn = {
id: input.turnId || randomUUID(),
user: { content: message, createdAt: ts },
replies,
createdAt: ts,
};
return { results, turn };
}
+218
View File
@@ -0,0 +1,218 @@
import type { LlmContextTrace } from "../types/context-trace.js";
/** 种子历史里的真多轮条目(user / assistant 各占一条消息) */
export type DirectChatSeedTurn = {
role: "user" | "assistant";
content: string;
};
/** L1:上下文壳(系统提示 / 设定 / 种子历史等) */
export type DirectChatShellSlots = {
/** 任务契约 / 系统提示(进 worldBookBefore 任务袋) */
systemPrompt: string;
/** 史前设定 */
loreBefore: string;
/**
* 旧版种子历史:一段文本,靠 `## 用户` / `【用户】` / `用户:` 块头切真轮次。
* 新数据改用 `seedMessages`;这里只用于读旧盘 / 迁移。
*/
seedHistory: string;
/** 种子历史(真多轮):逐条 user / assistant,拼在多轮之前 */
seedMessages?: DirectChatSeedTurn[];
/** 史后状态 */
loreAfter: string;
/** 尾注 */
postTurn: string;
/** 角色名(`{{char}}` 取值);空则回落「作品」 */
charName?: string;
/** 示例对话(酒馆 `mes_example` 同格式,用 `<START>` 分块) */
exampleDialogue?: string;
/** 角色描述(酒馆 charDescription 洞) */
charDescription?: string;
/** 角色性格(酒馆 charPersonality 洞,会套预设的 personality_format) */
charPersonality?: string;
/** 场景(酒馆 scenario 洞,会套预设的 scenario_format) */
scenario?: string;
};
export type DirectChatShell = DirectChatShellSlots & {
id: string;
name: string;
/** 缺省用全局 activePresetId */
presetId: string | null;
createdAt: string;
updatedAt: string;
};
/** 并列助手窗口(一个 API 配置一列) */
export type DirectChatWindow = {
id: string;
profileId: string;
profileName?: string;
model?: string;
};
export type DirectChatAssistantReply = {
windowId: string;
content: string;
thinking?: string;
model?: string;
error?: string;
contextTrace?: LlmContextTrace;
};
/**
* 同一轮的一个版本:编辑原话或直接重 roll 都会新开一版。
* `after` 是切回这一版时,接在本轮后面的对话。
*/
export type DirectChatTurnVariant = {
user: { content: string; createdAt: string };
replies: DirectChatAssistantReply[];
selectedWindowId?: string | null;
after: DirectChatTurn[];
};
/** 一轮:共用用户消息 + 各窗口助手回复 */
export type DirectChatTurn = {
id: string;
user: { content: string; createdAt: string };
replies: DirectChatAssistantReply[];
/**
* 若选定某窗口:之后所有窗口发下一轮时,本轮助手侧都以该回复进历史。
* 未选定:各窗口继续用自己的回复。
*/
selectedWindowId?: string | null;
createdAt: string;
/**
* 重 roll / 编辑原话后的版本。
* 当前 `user` 与 `replies` 对应 `variantIndex`。
*/
variants?: DirectChatTurnVariant[];
variantIndex?: number;
};
/** 旧版扁平消息(读盘兼容;新写入用 turns) */
export type DirectChatMessageRole = "user" | "assistant";
export type DirectChatMessage = {
id: string;
role: DirectChatMessageRole;
content: string;
thinking?: string;
createdAt: string;
model?: string;
contextTrace?: LlmContextTrace;
variants?: Array<{
content: string;
thinking?: string;
model?: string;
profileId?: string;
profileName?: string;
}>;
branchIndex?: number;
};
/** L2:壳下的具体会话 */
export type DirectChatConversation = {
id: string;
shellId: string;
name: string;
/** @deprecated 兼容旧数据;优先 turns */
messages?: DirectChatMessage[];
windows: DirectChatWindow[];
turns: DirectChatTurn[];
createdAt: string;
updatedAt: string;
};
export type DirectChatShellSummary = {
id: string;
name: string;
presetId: string | null;
conversationCount: number;
updatedAt: string;
};
export type DirectChatConversationSummary = {
id: string;
shellId: string;
name: string;
messageCount: number;
updatedAt: string;
};
export const EMPTY_SHELL_SLOTS: DirectChatShellSlots = {
systemPrompt: "",
loreBefore: "",
seedHistory: "",
seedMessages: [],
loreAfter: "",
postTurn: "",
};
export const DEFAULT_DIRECT_CHAT_SYSTEM =
"直聊:按上下文与历史继续对话。回答简洁、直接;用户未要求时不另起长篇设定。";
/** 把旧扁平 messages 收成单窗口 turns */
export function migrateMessagesToTurns(
messages: DirectChatMessage[],
windowId = "default",
): DirectChatTurn[] {
const turns: DirectChatTurn[] = [];
let pendingUser: { content: string; createdAt: string } | null = null;
for (const m of messages) {
if (m.role === "user") {
pendingUser = { content: m.content, createdAt: m.createdAt };
continue;
}
if (m.role === "assistant" && pendingUser) {
turns.push({
id: m.id || `turn_${turns.length}`,
user: pendingUser,
replies: [
{
windowId,
content: m.content,
thinking: m.thinking,
model: m.model,
contextTrace: m.contextTrace,
},
],
createdAt: m.createdAt,
});
pendingUser = null;
}
}
return turns;
}
/**
* 为某个窗口拼 prior:用户消息共用;
* 若该轮 selectedWindowId 有值则全员用选定回复,否则用本窗口自己的回复。
*/
export function buildPriorForWindow(
turns: DirectChatTurn[],
windowId: string,
): Array<{ role: "user" | "assistant"; content: string }> {
const out: Array<{ role: "user" | "assistant"; content: string }> = [];
for (const turn of turns) {
const userText = turn.user?.content?.trim();
if (userText) out.push({ role: "user", content: userText });
const selected = turn.selectedWindowId?.trim();
const reply = selected
? turn.replies.find((r) => r.windowId === selected)
: turn.replies.find((r) => r.windowId === windowId);
const text = reply?.content?.trim();
if (text) out.push({ role: "assistant", content: text });
}
return out;
}
export function countTurnMessages(turns: DirectChatTurn[]): number {
let n = 0;
for (const t of turns) {
if (t.user?.content) n += 1;
n += t.replies.filter((r) => r.content || r.error).length;
}
return n;
}
+46 -17
View File
@@ -1,9 +1,11 @@
/**
* `@` 指令展开:匹配已注册指令即替换;未注册保留原文。
* `@` 指令 + 酒馆双括号宏 `{{...}}` 展开:
* 匹配已注册形式即替换;未注册一律保留原文。
* 身份类可复用于预设/开场白;骰子类仅在发送/进游玩时展开。
*/
import { executeChance } from "../skills/chance-tools.js";
import { expandMacros, type MacroKind } from "./macros.js";
export type DirectivePersona = {
name: string;
@@ -12,6 +14,12 @@ export type DirectivePersona = {
export type DirectiveContext = {
persona?: DirectivePersona | null;
/** `{{char}}` → 预设角色名(我们这边是书名 / 主角名)。缺省回落「作品」。 */
charName?: string | null;
/** `{{lastUserMessage}}` */
lastUserMessage?: string | null;
/** `{{setvar}}` / `{{getvar}}` 容器;同一次装配传同一份。 */
vars?: Map<string, string>;
};
export type ExpandOptions = {
@@ -19,9 +27,11 @@ export type ExpandOptions = {
allowDice?: boolean;
};
export type DirectiveKind = "persona_name" | "persona_desc" | "dice" | MacroKind;
export type DirectiveHit = {
raw: string;
kind: "persona_name" | "persona_desc" | "dice" | "st_user";
kind: DirectiveKind;
replacement: string;
};
@@ -141,13 +151,30 @@ export function expandAtDirectives(
options: ExpandOptions = {},
): ExpandResult {
const allowDice = options.allowDice !== false;
const hits: DirectiveHit[] = [];
// 先展开双括号宏:注释在这里就被吃掉,`@` 不会误伤注释里的内容。
const macros = expandMacros(input, {
user: personaName(ctx),
char: ctx.charName ?? null,
persona: personaDesc(ctx) || null,
lastUserMessage: ctx.lastUserMessage ?? null,
vars: ctx.vars,
allowDice,
});
const hits: DirectiveHit[] = macros.hits.map((h) => ({
raw: h.raw,
kind: h.kind,
replacement: h.replacement,
}));
const src = macros.text;
let i = 0;
let out = "";
while (i < input.length) {
if (input[i] === "@") {
const tried = tryExpandAt(input.slice(i), ctx, allowDice);
while (i < src.length) {
if (src[i] === "@") {
const tried = tryExpandAt(src.slice(i), ctx, allowDice);
if (tried) {
out += tried.hit.replacement;
hits.push(tried.hit);
@@ -156,21 +183,12 @@ export function expandAtDirectives(
}
}
// 兼容 SillyTavern {{user}}
if (input.startsWith("{{user}}", i)) {
const replacement = personaName(ctx);
out += replacement;
hits.push({ raw: "{{user}}", kind: "st_user", replacement });
i += "{{user}}".length;
continue;
}
out += input[i];
out += src[i];
i += 1;
}
return {
text: out,
text: macros.trim ? out.trim() : out,
hits,
changed: hits.length > 0,
};
@@ -184,6 +202,17 @@ export function expandIdentityDirectives(
return expandAtDirectives(input, ctx, { allowDice: false });
}
/**
* 预设装配用:身份 + 双括号宏,`{{roll}}` 照常掷(酒馆预设的随机填充依赖它)。
* 与 `expandIdentityDirectives` 的区别只在 allowDice。
*/
export function expandPresetDirectives(
input: string,
ctx: DirectiveContext = {},
): ExpandResult {
return expandAtDirectives(input, ctx, { allowDice: true });
}
export type DirectiveCatalogItem = {
insert: string;
label: string;
+366
View File
@@ -0,0 +1,366 @@
/**
* 酒馆双括号宏 `{{...}}`。与 `@` 指令共用 persona 与骰子引擎。
*
* 变量(`{{setvar}}` / `{{getvar}}`)的作用域由调用方给定:
* 预设装配用「单次装配」一份 Map,不落盘、不跨轮 —— 与酒馆 prompt 级宏的实际行为等价
* (宏每次请求都会重算,跨轮状态走的是聊天变量而不是 prompt 宏)。
*
* 未知宏一律原样保留,与酒馆一致。
*/
import { executeChance } from "../skills/chance-tools.js";
export type MacroContext = {
/** `{{user}}` → 用户角色名 */
user?: string | null;
/** `{{char}}` → 预设角色名(酒馆是角色卡名;我们这边是书名 / 主角名) */
char?: string | null;
/** `{{persona}}` → 当前用户角色(`@人设`)的详细人设正文 */
persona?: string | null;
/** `{{lastUserMessage}}` → 最近一条用户消息原文 */
lastUserMessage?: string | null;
/** `{{mesExamples}}` → 示例对话按 `<START>` 分块后的整段文本(酒馆的 parseMesExamples) */
mesExamples?: string | null;
/** setvar / getvar 容器。同一次装配共享一份。 */
vars?: Map<string, string>;
/** false 时 `{{roll}}` 原样保留。默认 true。 */
allowDice?: boolean;
};
export type MacroKind =
| "comment"
| "trim"
| "user"
| "char"
| "persona"
| "last_user_message"
| "mes_examples"
| "getvar"
| "setvar"
| "addvar"
| "roll";
export type MacroHit = {
raw: string;
kind: MacroKind;
replacement: string;
};
export type MacroResult = {
text: string;
hits: MacroHit[];
/** 命中 `{{trim}}`;调用方应对结果首尾去空白 */
trim: boolean;
};
/**
* 逐个扫 `{{...}}`(非贪婪,等价于 /\{\{([\s\S]*?)\}\}/g),
* 外加酒馆的一条规则:`{{{...}}` 里第一个 `{` 是**字面量**,宏从第二个 `{` 起算。
* 少了这条,`{{{setvar::SEMOD1::}}`(kunlun / 简单预设里都有)会整段原样漏进正文。
*/
type MacroToken = { start: number; end: number; raw: string; inner: string };
function* macroTokens(input: string): Generator<MacroToken> {
if (input.indexOf("{{") < 0) return;
let i = 0;
while (i < input.length) {
const at = input.indexOf("{{", i);
if (at < 0) return;
if (input[at + 2] === "{") {
i = at + 1;
continue;
}
const end = input.indexOf("}}", at + 2);
if (end < 0) return;
yield {
start: at,
end: end + 2,
raw: input.slice(at, end + 2),
inner: input.slice(at + 2, end),
};
i = end + 2;
}
}
/**
* 扫描到一个永远等不到 `}}` 的 `{{...` 就返回它(截前 40 字)。
* 与展开器同一套规则:`{{{...}}` 的首个 `{` 是字面量,不算宏起点。
*/
export function findUnclosedMacro(input: string): string | null {
if (input.indexOf("{{") < 0) return null;
let i = 0;
while (i < input.length) {
const at = input.indexOf("{{", i);
if (at < 0) return null;
if (input[at + 2] === "{") {
i = at + 1;
continue;
}
const end = input.indexOf("}}", at + 2);
if (end < 0) return input.slice(at, at + 40);
i = end + 2;
}
return null;
}
const COMMENT_HEAD_RE = /^\{\{\s*\/\//;
/**
* 先整体啃掉 `{{// ... }}` 注释。
* 酒馆的注释里常嵌 `{{user}}` 这类宏,非贪婪扫描会在内层 `}}` 处提前收尾,
* 把注释尾巴漏成正文(Reborn2.4 那条 57 字残片就是这么来的)。这里按 {{ / }} 配平扫描。
*/
export function stripCommentMacros(input: string): string {
return stripCommentMacrosDetailed(input).text;
}
function stripCommentMacrosDetailed(input: string): {
text: string;
comments: string[];
} {
const comments: string[] = [];
if (input.indexOf("{{") < 0) return { text: input, comments };
let out = "";
let i = 0;
while (i < input.length) {
const start = input.indexOf("{{", i);
if (start < 0) {
out += input.slice(i);
break;
}
out += input.slice(i, start);
if (!COMMENT_HEAD_RE.test(input.slice(start, start + 8))) {
out += "{{";
i = start + 2;
continue;
}
const commentStart = start;
let depth = 0;
let j = start;
while (j < input.length) {
if (input.startsWith("{{", j)) {
depth += 1;
j += 2;
continue;
}
if (input.startsWith("}}", j)) {
depth -= 1;
j += 2;
if (depth === 0) break;
continue;
}
j += 1;
}
comments.push(input.slice(commentStart, j));
i = j;
}
return { text: out, comments };
}
export function createMacroVars(): Map<string, string> {
return new Map<string, string>();
}
function randomInt(min: number, max: number): number {
return min + Math.floor(Math.random() * (max - min + 1));
}
function rollDice(expression: string): string | null {
const result = executeChance({ op: "roll", expression });
if (!result.ok) return null;
return String(result.detail.total);
}
/** `1d6` / `d9` / `1999999`。空或不可解析返回 null(宏原样保留)。 */
function evalRoll(expression: string): string | null {
const expr = expression.trim();
if (!expr) return null;
if (/^d\d+$/i.test(expr)) return rollDice(`1${expr}`);
if (/^\d+d\d+$/i.test(expr)) return rollDice(expr);
if (/^\d+$/.test(expr)) {
const n = Number(expr);
if (!Number.isFinite(n) || n < 1) return null;
return String(randomInt(1, Math.floor(n)));
}
return null;
}
type Parsed =
| {
kind:
| "comment"
| "trim"
| "user"
| "char"
| "persona"
| "last_user_message"
| "mes_examples";
}
| { kind: "getvar"; key: string }
| { kind: "setvar" | "addvar"; key: string; value: string }
| { kind: "roll"; expression: string }
| { kind: "unknown" };
function parseInner(inner: string): Parsed {
// `lead` 保留值区域的原始空白(`{{setvar::set1:: }}` 的四个空格有意义);
// `text` 只用于识别关键字。
const lead = inner.replace(/^\s+/, "");
const text = lead.trim();
if (text.startsWith("//")) return { kind: "comment" };
const lower = text.toLowerCase();
if (lower === "trim") return { kind: "trim" };
if (lower === "user") return { kind: "user" };
if (lower === "char") return { kind: "char" };
// 酒馆的 `{{persona}}` = 当前用户角色的详细人设正文(我们的 `@人设`)
if (lower === "persona") return { kind: "persona" };
if (lower === "lastusermessage") return { kind: "last_user_message" };
if (lower === "mesexamples") return { kind: "mes_examples" };
if (lower.startsWith("getvar::")) {
return { kind: "getvar", key: text.slice("getvar::".length).trim() };
}
if (lower.startsWith("setvar::") || lower.startsWith("addvar::")) {
const kind = lower.startsWith("setvar::") ? "setvar" : "addvar";
const body = lead.slice("setvar::".length);
const at = body.indexOf("::");
if (at < 0) return { kind: "unknown" };
return {
kind,
key: body.slice(0, at).trim(),
// 值保留原样:`{{setvar::set1:: }}` 的四个空格是有意义的
value: body.slice(at + 2),
};
}
if (/^roll(?=$|[:\s])/i.test(text)) {
return { kind: "roll", expression: text.slice(4).replace(/^[:\s]+/, "") };
}
return { kind: "unknown" };
}
function readVars(ctx: MacroContext): Map<string, string> {
if (!ctx.vars) ctx.vars = createMacroVars();
return ctx.vars;
}
/**
* 展开一行文本里的全部 `{{...}}`。未知形式原样返回。
* 命中 `{{trim}}` 时 `result.trim === true`,由调用方决定怎么去空白。
*/
export function expandMacros(
input: string,
ctx: MacroContext = {},
): MacroResult {
const hits: MacroHit[] = [];
const vars = readVars(ctx);
const allowDice = ctx.allowDice !== false;
let trim = false;
const stripped = stripCommentMacrosDetailed(input);
for (const comment of stripped.comments) {
hits.push({ raw: comment, kind: "comment", replacement: "" });
}
const expandOne = (raw: string, inner: string): string => {
const parsed = parseInner(inner);
const push = (kind: MacroKind, replacement: string): string => {
hits.push({ raw: raw, kind, replacement });
return replacement;
};
switch (parsed.kind) {
case "comment":
return push("comment", "");
case "trim":
trim = true;
return push("trim", "");
case "user":
return push("user", ctx.user?.trim() || "User");
case "char":
return push("char", ctx.char?.trim() || "作品");
case "persona":
return push("persona", ctx.persona?.trim() ?? "");
case "last_user_message":
return push("last_user_message", ctx.lastUserMessage ?? "");
case "mes_examples":
// 酒馆里 `{{mesExamples}}` 由 parseMesExamples 展开成 `<START>…` 的整段文本。
// 没给上下文就原样保留,避免静默吞内容。
return ctx.mesExamples == null ? raw : push("mes_examples", ctx.mesExamples);
case "getvar":
return push("getvar", vars.get(parsed.key) ?? "");
case "setvar":
vars.set(parsed.key, parsed.value);
return push("setvar", "");
case "addvar": {
const prev = vars.get(parsed.key) ?? "";
vars.set(parsed.key, prev + parsed.value);
return push("addvar", "");
}
case "roll": {
if (!allowDice) return raw;
const value = evalRoll(parsed.expression);
if (value === null) return raw;
return push("roll", value);
}
default:
return raw;
}
};
let text = "";
let cursor = 0;
for (const token of macroTokens(stripped.text)) {
text += stripped.text.slice(cursor, token.start);
text += expandOne(token.raw, token.inner);
cursor = token.end;
}
text += stripped.text.slice(cursor);
return { text, hits, trim };
}
/** 导入报告里给个别宏用酒馆的写法,别把内部类型名漏给用户。 */
const KIND_LABEL: Partial<Record<MacroKind, string>> = {
mes_examples: "{{mesExamples}}",
};
/** 只列出文本里出现过的宏名(含未知),用于导入报告。 */
export function collectMacroNames(text: string): string[] {
return collectMacroUsages(text).map((u) => u.name);
}
export type MacroUsage = {
/** 已知宏用类型名(如 `setvar`),未知宏保留原文(如 `{{turnPlus1}}`) */
name: string;
known: boolean;
count: number;
};
/** 统计文本里的宏。已知宏按类型归一,未知宏按原文归类。 */
export function collectMacroUsages(text: string): MacroUsage[] {
const byName = new Map<string, MacroUsage>();
for (const token of macroTokens(text)) {
const inner = token.inner;
const parsed = parseInner(inner);
const known = parsed.kind !== "unknown";
const name = known
? (KIND_LABEL[parsed.kind] ?? `{{${parsed.kind}}}`)
: `{{${inner.trim().slice(0, 40)}}}`;
const existing = byName.get(name);
if (existing) existing.count += 1;
else byName.set(name, { name, known, count: 1 });
}
return [...byName.values()].sort((a, b) => b.count - a.count);
}
/** 每次请求结果都会变的宏(噪声填充、骰子、时间)。 */
export const RANDOM_MACRO_NAMES = new Set([
"{{roll}}",
"{{random}}",
"{{pick}}",
"{{time}}",
"{{date}}",
]);
export function isRandomMacroName(name: string): boolean {
if (RANDOM_MACRO_NAMES.has(name)) return true;
return /turn|time|date|random|pick/i.test(name);
}
+22
View File
@@ -0,0 +1,22 @@
/**
* 工作流库 → 创作提示里的短目录。不含 ComfyUI 节点图。
*/
import { listImageWorkflows, type ImageWorkflowEntry } from "./store.js";
export function formatImageWorkflowCatalogForPrompt(
entries?: ImageWorkflowEntry[],
): string {
const workflows = entries ?? listImageWorkflows();
if (!workflows.length) return "";
const lines = workflows.map(
(entry) => `- ${entry.id}|${entry.name}|${entry.summary}`,
);
return `## 【工作流库 · 可选用】
本机已导出的画面工作流。选定一个 id 写入本步产物。这里只有短名和一句画面说明。
${lines.join("\n")}`;
}
+7
View File
@@ -0,0 +1,7 @@
export {
getImageWorkflow,
getImageWorkflowsDir,
listImageWorkflows,
type ImageWorkflowEntry,
} from "./store.js";
export { formatImageWorkflowCatalogForPrompt } from "./catalog.js";
+102
View File
@@ -0,0 +1,102 @@
/**
* 本机 ComfyUI 工作流库。
* 目录:{用户数据}/image-workflows/<id>/
* meta.json 短名、一句画面说明、提示词节点
* workflow.json ComfyUI「API 格式」导出的节点图
* 创作模型只看短目录;节点图留在磁盘,提交生图时才读。
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { getUserDataDir } from "../config/user-data-dir.js";
export type ImageWorkflowEntry = {
id: string;
name: string;
/** 给创作模型看的一句画面说明 */
summary: string;
/** ComfyUI API JSON 里的节点 id */
promptNode: string;
/** 该节点上要覆盖的字段,CLIP 文本一般为 text */
promptField: string;
/** 可选:这种风格的提示词习惯,仍不是节点图 */
promptNotes?: string;
};
const DIR_NAME = "image-workflows";
export function getImageWorkflowsDir(): string {
return path.join(getUserDataDir(), DIR_NAME);
}
function asString(v: unknown): string | undefined {
if (typeof v !== "string") return undefined;
const t = v.trim();
return t || undefined;
}
function readJson(file: string): unknown | null {
try {
return JSON.parse(readFileSync(file, "utf8")) as unknown;
} catch {
return null;
}
}
function parseEntry(folderName: string, dir: string): ImageWorkflowEntry | null {
const metaPath = path.join(dir, "meta.json");
const workflowPath = path.join(dir, "workflow.json");
if (!existsSync(metaPath) || !existsSync(workflowPath)) return null;
const meta = readJson(metaPath);
const workflow = readJson(workflowPath);
if (!meta || typeof meta !== "object" || Array.isArray(meta)) return null;
if (!workflow || typeof workflow !== "object" || Array.isArray(workflow)) return null;
const row = meta as Record<string, unknown>;
const id = asString(row.id) ?? folderName.trim();
const name = asString(row.name);
const summary = asString(row.summary);
const promptNode = asString(row.prompt_node) ?? asString(row.promptNode);
const promptField = asString(row.prompt_field) ?? asString(row.promptField);
if (!id || !name || !summary || !promptNode || !promptField) return null;
const promptNotes = asString(row.prompt_notes) ?? asString(row.promptNotes);
return {
id,
name,
summary,
promptNode,
promptField,
...(promptNotes ? { promptNotes } : {}),
};
}
/** 扫描目录。缺文件或字段不全的文件夹跳过。 */
export function listImageWorkflows(): ImageWorkflowEntry[] {
const root = getImageWorkflowsDir();
if (!existsSync(root)) return [];
const out: ImageWorkflowEntry[] = [];
let names: string[] = [];
try {
names = readdirSync(root);
} catch {
return [];
}
for (const name of names) {
if (!name || name.startsWith(".")) continue;
const dir = path.join(root, name);
try {
if (!statSync(dir).isDirectory()) continue;
} catch {
continue;
}
const entry = parseEntry(name, dir);
if (entry) out.push(entry);
}
out.sort((a, b) => a.name.localeCompare(b.name, "zh"));
return out;
}
export function getImageWorkflow(id: string): ImageWorkflowEntry | null {
const key = id.trim();
if (!key) return null;
return listImageWorkflows().find((entry) => entry.id === key) ?? null;
}
+7
View File
@@ -3,6 +3,7 @@
* 新库:实现 formatForPrompt,在本文件 register。
*/
import { formatImageWorkflowCatalogForPrompt } from "../image-workflow/catalog.js";
import { formatPreferenceCatalogForPrompt } from "../preference/catalog.js";
import { formatStylePackCatalogForPrompt } from "../style-pack/catalog.js";
@@ -74,6 +75,12 @@ registerLibrary({
formatForPrompt: formatPreferenceCatalogForPrompt,
});
registerLibrary({
id: "image-workflows",
label: "工作流库",
formatForPrompt: formatImageWorkflowCatalogForPrompt,
});
/**
* 从模块列表收集绑定的库 id(去重、保序)。
* 对话落盘:通常传入 intake≠recipe 的模块。
+173
View File
@@ -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) || "已按你的意见改后面的走向。",
};
}
+45
View File
@@ -0,0 +1,45 @@
export {
DEFAULT_BUFFER_TARGET_CHARS,
LISTEN_SERIAL_MODE_VALUE,
LISTEN_PHASE_TAG,
LISTEN_PLAY_LAYER_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,
isListenPlayActive,
setListenPlayActive,
} from "./state.js";
export {
runListenSerialTurn,
ensureListenPlayContent,
markListenConsumed,
type ListenChatTurn,
type ListenSerialTurnResult,
} from "./turn.js";
+139
View File
@@ -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": "给用户看的一句说明"
}`;
}
+265
View File
@@ -0,0 +1,265 @@
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_PLAY_LAYER_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 === "ready" ||
v === "running" ||
v === "soft_stop"
) {
return v;
}
return "intake";
}
export function isListenPlayActive(board: Blackboard): boolean {
return (board.getContentByTag(LISTEN_PLAY_LAYER_TAG) ?? "").trim() === "1";
}
export function setListenPlayActive(board: Blackboard, on: boolean): void {
writeTag(board, LISTEN_PLAY_LAYER_TAG, on ? "1" : "0");
}
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,
playActive = false,
): ListenSerialView {
return {
phase: state.phase,
playActive,
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);
}
+416
View File
@@ -0,0 +1,416 @@
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 ensureListenPlayContent(params: {
llm: LlmProvider;
board: Blackboard;
}): Promise<ListenSerialTurnResult> {
const { llm, board } = params;
ensureBufferTarget(board);
let state = readListenState(board);
if (!state.thrill || !state.world) {
throw new Error("请先在创作里确认核心爽点与一句世界");
}
if (
state.phase === "running" ||
state.phase === "soft_stop"
) {
if (state.outline.length && unreadChars(state) > 0) {
return {
reply: [
"继续播放。",
formatOutlineLine(state),
formatUnreadLine(state),
]
.filter(Boolean)
.join("\n\n"),
softStop: state.phase === "soft_stop",
};
}
}
if (!state.outline.some((o) => o.status === "pending" || o.status === "done")) {
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,
};
}
const filled = await fillBuffer(llm, board, state);
state = filled.state;
return {
reply: [
filled.notes.join("\n") || "已进入播放。",
formatOutlineLine(state),
formatUnreadLine(state),
]
.filter(Boolean)
.join("\n\n"),
softStop: filled.softStop || state.phase === "soft_stop",
};
}
export async function runListenSerialTurn(params: {
llm: LlmProvider;
board: Blackboard;
dialogue: ListenChatTurn[];
userText: string;
/** 是否在播放层;创作层只允许开书/确认 */
inPlay?: boolean;
}): Promise<ListenSerialTurnResult> {
const { llm, board, dialogue, userText } = params;
const inPlay = Boolean(params.inPlay);
ensureBufferTarget(board);
let state = readListenState(board);
const text = userText.trim();
// 创作层:已进播放态的正文不在这里改;ready 时可空提或改 brief
if (!inPlay && (state.phase === "running" || state.phase === "soft_stop")) {
return {
reply: "大纲与正文在「播放」里。请从目录点「播放」继续听/改向。",
softStop: state.phase === "soft_stop",
};
}
if (!inPlay && state.phase === "ready") {
if (!text || isConfirmText(text)) {
return {
reply:
"本局已确认。请点「播放」开始写大纲与正文;想改爽点直接说新需求。",
};
}
// 有新需求 → 重新提案
}
// —— 软停:收工 / 下一波 ——
if (inPlay && 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回复「确认」锁定本局;确认后点「播放」写大纲与正文。`,
};
}
state = persistState(board, state, { phase: "ready" });
return {
reply: `已确认本局。\n\n【核心爽点】${state.thrill}\n【一句世界】${state.world}\n【结构】${state.structure}\n\n请点「播放」开始生成大纲与正文。`,
};
}
// —— 播放层连载:继续 = 补货;有意见 = 改向 ——
if (inPlay && (state.phase === "running" || state.phase === "soft_stop" || state.phase === "ready")) {
if (state.phase === "ready" || !state.outline.length) {
return ensureListenPlayContent({ llm, board });
}
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 };
}
+87
View File
@@ -0,0 +1,87 @@
/** 听书连载 · 类型与常量 */
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 = "听书.已消费字数";
/** 播放层开关:创作只定 brief,播放层才写大纲/正文 */
export const LISTEN_PLAY_LAYER_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"
/** 创作已确认,等待进入播放写大纲正文 */
| "ready"
| "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;
/** 是否在播放层(大纲/正文/TTS) */
playActive: boolean;
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;
}
+200 -28
View File
@@ -1,5 +1,7 @@
import type { LlmConfig } from "../config/env.js";
import { ensureNetworkProxy } from "../config/network.js";
import type { GenerationParameters } from "../types/preset.js";
import type { WorldInfoPack as PresetPackForFrame } from "../preset/world-info-pack.js";
import {
applyOpenAiGeneration,
fetchWithGenerationCompat,
@@ -24,13 +26,14 @@ export type ToolCallPayload = {
};
export type ChatMessage =
| { role: "system" | "user"; content: string }
| { role: "system" | "user"; content: string; name?: string }
| {
role: "assistant";
content: string | null;
tool_calls?: ToolCallPayload[];
name?: string;
}
| { role: "tool"; content: string; tool_call_id: string };
| { role: "tool"; content: string; tool_call_id: string; name?: string };
export type ToolDefinition = {
type: "function";
@@ -57,8 +60,33 @@ export type TokenUsage = {
cachedTokens?: number;
/** Prompt tokens not served from cache (DeepSeek prompt_cache_miss_tokens) */
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 = {
content: string;
/** 推理模型思维链(如 DeepSeek reasoner 的 reasoning_content) */
@@ -84,6 +112,19 @@ export type CompleteOptions = {
label?: string;
/** 取消进行中的 LLM 请求(停止并重试) */
signal?: AbortSignal;
/**
* 本局会话 id。仅 OpenRouter 会写成请求体 `session_id`,用来粘住同一上游,
* 避免开头 message 一变就换缓存节点。其它渠道不发送。
*/
sessionId?: string;
/** 统计补充。不进上游请求体。 */
usageDetail?: UsageDetail;
usageSink?: UsageSink;
/**
* 内部:play 真多轮装配的分袋上下文(由 worker 从 contextSegments 切成四袋 + 历史轮次)。
* 不进上游请求体;由 PresetLlmProvider 取用后丢弃。
*/
presetPack?: PresetPackForFrame;
};
export type ToolChoice =
@@ -132,6 +173,33 @@ export type LlmProvider = {
};
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 {
for (const message of messages) {
@@ -163,6 +231,33 @@ export function ensureJsonObjectPrompt(messages: ChatMessage[]): ChatMessage[] {
return [{ role: "system", content: JSON_OBJECT_HINT }, ...messages];
}
/** 空正文时报得能定位:是限额吃光了,还是模型真没给。 */
function emptyToolResponseMessage(params: {
stream: boolean;
finishReason?: string;
reasoningChars?: number;
usage?: TokenUsage;
}): string {
const bits: string[] = [];
if (params.finishReason) bits.push(`finish_reason=${params.finishReason}`);
if (params.reasoningChars) bits.push(`思维链=${params.reasoningChars}字`);
if (params.usage) {
bits.push(
`prompt=${params.usage.promptTokens}/completion=${params.usage.completionTokens}`,
);
}
const hint =
params.finishReason === "length"
? "(输出上限被思考/正文吃光,调大 maxOutputTokens)"
: params.reasoningChars
? "(只产出思维链没有正文,多半是输出上限不足)"
: "";
const head = params.stream
? "LLM 流式返回空正文且没有工具调用"
: "LLM 返回空正文且没有工具调用";
return `${head}${bits.length ? `:${bits.join(" ")}` : ""}${hint}`;
}
export function buildRequestBody(
config: LlmConfig,
messages: ChatMessage[],
@@ -210,6 +305,9 @@ export function buildRequestBody(
body.stream_options = { include_usage: true };
}
const sessionId = openRouterSessionId(config.baseUrl, options?.sessionId);
if (sessionId) body.session_id = sessionId;
return body;
}
@@ -232,6 +330,20 @@ function readCacheMissTokens(u: Record<string, unknown>): number | undefined {
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. */
export function parseUsage(raw: unknown): TokenUsage | undefined {
if (!raw || typeof raw !== "object") return undefined;
@@ -243,6 +355,7 @@ export function parseUsage(raw: unknown): TokenUsage | undefined {
const cachedTokens = readCachedTokens(u);
const cacheMissTokens = readCacheMissTokens(u);
const cacheWriteTokens = readCacheWriteTokens(u);
return {
promptTokens: Number.isFinite(prompt) ? prompt : 0,
@@ -253,22 +366,66 @@ export function parseUsage(raw: unknown): TokenUsage | undefined {
(Number.isFinite(completion) ? completion : 0),
...(cachedTokens != null ? { cachedTokens } : {}),
...(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;
reasoning?: string;
toolCalls: ParsedToolCall[];
} {
if (!message) return { content: "", toolCalls: [] };
const rawContent = message.content;
const content =
typeof rawContent === "string"
? rawContent.trim() || null
: rawContent == null
? null
: "";
const embedded = embeddedContent(message.content);
const reasoningRaw =
(typeof message.reasoning_content === "string" &&
message.reasoning_content.trim()) ||
@@ -280,21 +437,18 @@ function extractMessageParts(message: Record<string, unknown> | undefined): {
const toolCalls: ParsedToolCall[] = [];
const rawCalls = message.tool_calls;
if (Array.isArray(rawCalls)) {
for (const call of rawCalls) {
if (!call || typeof call !== "object") continue;
const c = call as Record<string, unknown>;
const fn = c.function;
if (!fn || typeof fn !== "object") continue;
const f = fn as Record<string, unknown>;
const name = typeof f.name === "string" ? f.name : "";
const id = typeof c.id === "string" ? c.id : "";
const args =
typeof f.arguments === "string" ? f.arguments : "{}";
if (name && id) {
toolCalls.push({ id, name, arguments: args });
}
rawCalls.forEach((call, index) => {
if (!call || typeof call !== "object") return;
const parsed = toolCallFromRow(call as Record<string, unknown>, index);
if (parsed) toolCalls.push(parsed);
});
}
for (const call of embedded.calls) {
if (!toolCalls.some((item) => item.id === call.id && item.name === call.name)) {
toolCalls.push(call);
}
}
const content = embedded.text;
if (toolCalls.length > 0) {
return { content, reasoning: reasoning || undefined, toolCalls };
@@ -312,6 +466,7 @@ async function llmFetch(
): Promise<Response> {
return fetchWithGenerationCompat(async (payload) => {
throwIfAborted(options?.signal);
await ensureNetworkProxy();
return fetch(url, {
method: "POST",
headers: {
@@ -414,17 +569,29 @@ export class OpenAiCompatibleProvider implements LlmProvider {
const data = (await response.json()) as {
model?: string;
usage?: unknown;
choices?: Array<{ message?: Record<string, unknown> }>;
choices?: Array<{
message?: Record<string, unknown>;
finish_reason?: string;
}>;
};
const parts = extractMessageParts(data.choices?.[0]?.message);
const choice = data.choices?.[0];
const parts = extractMessageParts(choice?.message);
const usage = parseUsage(data.usage);
if (!parts.content && parts.toolCalls.length === 0) {
throw new Error("LLM returned empty content and no tool calls");
throw new Error(
emptyToolResponseMessage({
stream: false,
finishReason: choice?.finish_reason,
reasoningChars: parts.reasoning?.length ?? 0,
usage,
}),
);
}
return {
content: parts.content,
toolCalls: parts.toolCalls,
reasoning: parts.reasoning,
usage: parseUsage(data.usage),
usage,
model: data.model ?? this.config.model,
};
}
@@ -457,7 +624,12 @@ export class OpenAiCompatibleProvider implements LlmProvider {
const parts = await consumeOpenAiToolStream(response.body, callbacks);
if (!parts.content && parts.toolCalls.length === 0 && !parts.reasoning) {
throw new Error("LLM stream returned empty content and no tool calls");
throw new Error(
emptyToolResponseMessage({
stream: true,
usage: parts.usage,
}),
);
}
const content =
parts.content ??
+181
View File
@@ -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;
}
+3
View File
@@ -2,6 +2,8 @@
* 拉取 OpenAI 兼容的 GET {base}/models,供同一 API 配置下快捷切换模型。
*/
import { ensureNetworkProxy } from "../config/network.js";
const MAX_BODY_CHARS = 2_000_000;
const MAX_MODELS = 4000;
@@ -70,6 +72,7 @@ type FetchOutcome =
async function fetchModelList(url: string, apiKey: string): Promise<FetchOutcome> {
let response: Response;
await ensureNetworkProxy();
try {
response = await fetch(url, {
method: "GET",
+208
View File
@@ -0,0 +1,208 @@
/**
* 发出前的消息序列整形(对应酒馆的 Squash System Messages)。
*
* 硬约束:工具调用组(`assistant(tool_calls)` + `tool`)永远不参与合并与重排 ——
* 拆开或合并都会让上游接口拒绝这次请求。
*/
import type { MessageSquashMode } from "../types/preset.js";
import type { ChatMessage } from "./client.js";
function isToolGroupMessage(message: ChatMessage): boolean {
if (message.role === "tool") return true;
return message.role === "assistant" && Boolean(message.tool_calls?.length);
}
export function hasToolGroup(messages: ChatMessage[]): boolean {
return messages.some(isToolGroupMessage);
}
/**
* 酒馆 squashSystemMessages 的两条例外,打在消息上的内部记号:
* 带 `name` 的消息、以及 `newMainChat` / `newChat` / `groupNudge` 这几条不参与合并。
* 用 Symbol 标记,不会进请求体,也不会进存档。
*/
export const SQUASH_EXEMPT_MARK = Symbol.for("writingAgent.squashExempt");
export function markSquashExempt(message: ChatMessage): ChatMessage {
(message as unknown as Record<symbol, unknown>)[SQUASH_EXEMPT_MARK] = true;
return message;
}
function isSquashExempt(message: ChatMessage): boolean {
if (message.name) return true;
return Boolean((message as unknown as Record<symbol, unknown>)[SQUASH_EXEMPT_MARK]);
}
function contentOf(message: ChatMessage): string {
return typeof message.content === "string" ? message.content : "";
}
function joinText(left: string, right: string): string {
if (!left) return right;
if (!right) return left;
return `${left}\n\n${right}`;
}
function mergeTwo(a: ChatMessage, b: ChatMessage): ChatMessage {
const content = joinText(contentOf(a), contentOf(b));
if (a.role === "assistant") return { role: "assistant", content };
return { role: a.role, content } as ChatMessage;
}
/** 只合并相邻同角色;工具组两侧永远断开。 */
export function mergeAdjacentSameRole(messages: ChatMessage[]): ChatMessage[] {
const out: ChatMessage[] = [];
for (const message of messages) {
const prev = out[out.length - 1];
if (
prev &&
!isToolGroupMessage(prev) &&
!isToolGroupMessage(message) &&
!isSquashExempt(prev) &&
!isSquashExempt(message) &&
prev.role === message.role
) {
out[out.length - 1] = mergeTwo(prev, message);
continue;
}
out.push(message);
}
return out;
}
/**
* 只合并相邻 system(酒馆 ChatCompletion.squashSystemMessages 的等价物)。
* user / assistant 一律不动 —— 酒馆那份实现从来不动非 system 消息。
* 酒馆另有两条例外(消息带 name、identifier 属于 newMainChat/newChat/groupNudge),
* 本仓库的 ChatMessage 还没有这两个字段,等包装层补上再接。
*/
export function mergeAdjacentSystemMessages(messages: ChatMessage[]): ChatMessage[] {
const out: ChatMessage[] = [];
for (const message of messages) {
const prev = out[out.length - 1];
if (
prev &&
prev.role === "system" &&
message.role === "system" &&
!isSquashExempt(prev) &&
!isSquashExempt(message) &&
!isToolGroupMessage(prev) &&
!isToolGroupMessage(message)
) {
// 酒馆是 `lastMessage.content += "\n" + message.content`:单个换行,不加空行。
out[out.length - 1] = {
role: "system",
content: [contentOf(prev), contentOf(message)].filter(Boolean).join("\n"),
};
continue;
}
out.push(message);
}
return out;
}
function prefixInto(message: ChatMessage, prefix: string): ChatMessage {
if (message.role === "assistant" && message.tool_calls?.length) {
return { ...message, content: joinText(prefix, contentOf(message)) };
}
return { ...message, content: joinText(prefix, contentOf(message)) } as ChatMessage;
}
/** 排在末尾、后面没有可吞的消息时,往最后一条**后面**接(前缀会把顺序倒过来)。 */
function suffixInto(message: ChatMessage, suffix: string): ChatMessage {
return { ...message, content: joinText(contentOf(message), suffix) } as ChatMessage;
}
/**
* 把所有 system 内容并进**紧随其后**的非 system 消息(无则并回上一条),
* 使序列只剩 user / assistant。
* 这类模式会打散预设原本的角色编排(破限常靠 system→user→assistant 交错),
* 仅供「上游拒收连续同角色」的场景显式开启。
*/
function foldSystemMessages(messages: ChatMessage[]): ChatMessage[] {
const out: ChatMessage[] = [];
const pending: string[] = [];
const flushInto = (message: ChatMessage): ChatMessage => {
const prefix = pending.splice(0).join("\n\n");
return prefix ? prefixInto(message, prefix) : message;
};
for (const message of messages) {
if (message.role === "system") {
pending.push(contentOf(message));
continue;
}
// 工具组不吃前缀:等下一个普通消息
if (pending.length > 0 && !isToolGroupMessage(message)) {
out.push(flushInto(message));
continue;
}
out.push(message);
}
if (pending.length > 0) {
for (let i = out.length - 1; i >= 0; i--) {
if (!isToolGroupMessage(out[i]!)) {
out[i] = suffixInto(out[i]!, pending.splice(0).join("\n\n"));
break;
}
}
}
if (pending.length > 0) {
out.unshift({ role: "user", content: pending.join("\n\n") });
}
return out;
}
/** `strict` 额外保证:第一条消息是 user。 */
function ensureUserFirst(messages: ChatMessage[]): ChatMessage[] {
const first = messages[0];
if (!first || first.role === "user") return messages;
const userIndex = messages.findIndex((m) => m.role === "user");
if (userIndex <= 0) return messages;
const head = messages.slice(0, userIndex);
const user = messages[userIndex]!;
const folded = head.reduce<ChatMessage>(
(acc, m) => mergeTwo(acc, m),
{ role: "user", content: "" },
);
return [
mergeTwo({ role: "user", content: contentOf(folded) }, user),
...messages.slice(userIndex + 1),
];
}
/** 全部塞进一条 user 消息(无工具场景)。 */
function singleUserTurn(messages: ChatMessage[]): ChatMessage[] {
const parts: string[] = [];
for (const message of messages) {
const text = contentOf(message).trim();
if (text) parts.push(text);
}
if (parts.length === 0) return messages;
return [{ role: "user", content: parts.join("\n\n") }];
}
/**
* 按预设要求整形消息序列。
* 请求里出现工具组时,`semi` / `strict` / `single-user-turn` 一律退化为 `merge`。
*/
export function squashMessages(
messages: ChatMessage[],
mode: MessageSquashMode,
): ChatMessage[] {
if (mode === "none" || messages.length === 0) return messages;
if (mode === "system") return mergeAdjacentSystemMessages(messages);
const merged = mergeAdjacentSameRole(messages);
if (mode === "merge") return merged;
if (hasToolGroup(merged)) return merged;
if (mode === "single-user-turn") return singleUserTurn(merged);
if (mode === "semi") return foldSystemMessages(merged);
return ensureUserFirst(foldSystemMessages(merged));
}

Some files were not shown because too many files have changed in this diff Show More