Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a5ef3b2bc | ||
|
|
e2413b0677 | ||
|
|
f3303038fe | ||
|
|
98e51f9c6c | ||
|
|
860eff59b2 | ||
|
|
7e7b9c5083 | ||
|
|
b4c83e706b | ||
|
|
9dc927282f | ||
|
|
ed09e5c831 | ||
|
|
18f034bc2d | ||
|
|
ba5485a9be | ||
|
|
e683df28dc | ||
|
|
749eb2f5b0 | ||
|
|
e7a4b6c954 | ||
|
|
b59ccf976c | ||
|
|
b864591afa | ||
|
|
bc77bc4a3c |
@@ -1,3 +1,9 @@
|
|||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_BASE_URL=https://api.deepseek.com
|
OPENAI_BASE_URL=https://api.deepseek.com
|
||||||
OPENAI_MODEL=deepseek-v4-pro
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# 抓包对比工具跑出来的本地数据(含接口 key 与预设副本),不进仓库
|
||||||
|
tools/st-request-diff/.ab-data/
|
||||||
|
|||||||
@@ -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` 由服务端持久化)→ 仍是默认折叠。
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
- **游玩**:在钉住的规格下回合推进;可切换呈现壳,保存游玩进度
|
- **游玩**:在钉住的规格下回合推进;可切换呈现壳,保存游玩进度
|
||||||
- **技能包**:能力以磁盘上的技能包装载(当前主包为「世界模拟器」相关配方与模块)(目前不需要手动选择)
|
- **技能包**:能力以磁盘上的技能包装载(当前主包为「世界模拟器」相关配方与模块)(目前不需要手动选择)
|
||||||
- **预设与库**:兼容 SillyTavern 风格的提示词预设;文风库 / 偏好库可注入创作节点,种子可从仓库恢复
|
- **预设与库**:兼容 SillyTavern 风格的提示词预设;文风库 / 偏好库可注入创作节点,种子可从仓库恢复
|
||||||
|
- **预设工作台**:`/presets.html` 内编辑模块与生成参数、版本树分叉、结构检查、用例组与多模型试跑(见 [preset-workbench.md](docs/preset-workbench.md))
|
||||||
- **多接口**:分组管理 API、嗅探型号、切换同一接口下的模型
|
- **多接口**:分组管理 API、嗅探型号、切换同一接口下的模型
|
||||||
- **本机优先**:Web(`localhost:23337`)或 `start-app.bat` 桌面壳;运维可用 `console.bat`
|
- **本机优先**:Web(`localhost:23337`)或 `start-app.bat` 桌面壳;运维可用 `console.bat`
|
||||||
|
|
||||||
@@ -50,6 +51,31 @@
|
|||||||
|
|
||||||
用户数据目录可用环境变量 `WRITING_AGENT_DATA_DIR` 覆盖;默认 `~/.writing-agent`(Windows 多为 `%USERPROFILE%\.writing-agent`)。
|
用户数据目录可用环境变量 `WRITING_AGENT_DATA_DIR` 覆盖;默认 `~/.writing-agent`(Windows 多为 `%USERPROFILE%\.writing-agent`)。
|
||||||
|
|
||||||
|
## 代理与网络
|
||||||
|
|
||||||
|
国内直连 `api.deepseek.com` 就够了,不需要任何代理。若要用 OpenRouter、Gemini 等境外接口,**不要依赖 Windows「系统代理」—— Node 的内置 `fetch` 既不读系统代理,也不读 `HTTPS_PROXY`**。
|
||||||
|
|
||||||
|
本仓库自带一层进程级代理引导(`src/config/network.ts`):启动后自动探测本地代理内核,探到就把本进程的 `fetch` 与 WebSocket 都交给它,探不到就静默退化为直连。
|
||||||
|
|
||||||
|
```text
|
||||||
|
本仓库出网 ──→ 本地代理内核(默认 127.0.0.1:7890)──→ 内核「规则模式」自动分流
|
||||||
|
├─ 国内域名 → 直连
|
||||||
|
└─ 境外域名 → 代理节点
|
||||||
|
```
|
||||||
|
|
||||||
|
**这意味着不需要手动切开关**:`api.deepseek.com` 始终直连不减速,`openrouter.ai` 自动走节点,以后新增任何接口也不用改代码。
|
||||||
|
|
||||||
|
因为它只作用于本进程 —— 不改系统代理、不装虚拟网卡、不动路由表 —— 所以可以和 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 会提示一行)。
|
||||||
|
|
||||||
## 维护者
|
## 维护者
|
||||||
|
|
||||||
开发与排障常用命令:
|
开发与排障常用命令:
|
||||||
@@ -80,6 +106,7 @@ Windows 运维脚本:`console.bat`(查询 / 启停 / 日志)。入口实
|
|||||||
| [run-snapshot.md](docs/run-snapshot.md) | 存档 / 快照(以代码现状为准) |
|
| [run-snapshot.md](docs/run-snapshot.md) | 存档 / 快照(以代码现状为准) |
|
||||||
| [play-presentation-shells.md](docs/play-presentation-shells.md) | 游玩呈现壳 |
|
| [play-presentation-shells.md](docs/play-presentation-shells.md) | 游玩呈现壳 |
|
||||||
| [preset-format.md](docs/preset-format.md) | 预设格式 |
|
| [preset-format.md](docs/preset-format.md) | 预设格式 |
|
||||||
|
| [preset-workbench.md](docs/preset-workbench.md) | 预设工作台(编辑 / 试跑) |
|
||||||
| [skill-format.md](docs/skill-format.md) 等 | 技能 / 编排器 / Worker 格式 |
|
| [skill-format.md](docs/skill-format.md) 等 | 技能 / 编排器 / Worker 格式 |
|
||||||
| [px-roadmap.md](docs/px-roadmap.md) | 产品路线图 |
|
| [px-roadmap.md](docs/px-roadmap.md) | 产品路线图 |
|
||||||
| [skills/README.md](skills/README.md) | 技能包目录说明 |
|
| [skills/README.md](skills/README.md) | 技能包目录说明 |
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
卫星项未实现前,弹窗只显示「历史滚动总结」。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 壳生图
|
||||||
|
|
||||||
|
> 状态:以后再说。记录:2026-10-11。
|
||||||
|
> 不在每层配图的第一版里做。
|
||||||
|
|
||||||
|
呈现壳的美化图(背景、角色立绘等)以后可以走 ComfyUI。和游玩每层配图分开:
|
||||||
|
|
||||||
|
- 每层配图跟着回合,挂在对应楼层上,主世界层不读。
|
||||||
|
- 壳上的图换得更慢,多半在开场或人物、地点稳定之后才出,由前端填进壳里预留的图位。
|
||||||
|
|
||||||
|
提示词仍归旁观副 LLM。主世界层不写图片地址,图也不进正文呈现和下一轮上下文。
|
||||||
@@ -46,11 +46,26 @@ Worker / skill 执行时,Runtime 将黑板 tag 与固定体裁说明拼成 LLM
|
|||||||
「对话.历史」是排序表中的普通标签,按 projection 动态裁剪后插入
|
「对话.历史」是排序表中的普通标签,按 projection 动态裁剪后插入
|
||||||
```
|
```
|
||||||
|
|
||||||
|
「对话.历史」不只是文本块:装配时它被切成**真多轮 message[]**(user / assistant 轮)进预设的 `chatHistory` 洞;
|
||||||
|
分袋时它之前的块进 `worldBookBefore`、之后的进 `worldBookAfter`。
|
||||||
|
|
||||||
`order 0` 建议为槽位人设(`worker.persona`)。
|
`order 0` 建议为槽位人设(`worker.persona`)。
|
||||||
`tier` 仅缓存提示;权威顺序是投影排序表。
|
`tier` 仅缓存提示;权威顺序是投影排序表。
|
||||||
|
|
||||||
**不是** agent 在游玩时自由往 prompt 里插段落;agent 只决定 **invoke 哪个 skill**;该 skill 的契约决定看见什么。
|
**不是** 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. 三分工
|
## 3. 三分工
|
||||||
@@ -171,19 +186,41 @@ contextIsolation: none | role_pov | blind_review
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 拼接结果形态(目标)
|
## 9. 拼接结果形态
|
||||||
|
|
||||||
|
worker 先给出**分段结果**,再由预设决定最终 `messages[]` 的形状:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
system:
|
assembleWorkerContextSections()
|
||||||
{shared-context}
|
├─ chunks: block|history|turn (按投影排序表顺序)
|
||||||
{worker SKILL body}
|
└─ tail: 本步任务 / 定稿摘要 / 待改底稿 …
|
||||||
{固定输出协议}
|
|
||||||
|
|
||||||
user:
|
workerContextToBags()
|
||||||
{按 segment 顺序格式化的 Markdown 或结构化块}
|
├─ 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` 调用。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -119,10 +119,14 @@ order 4: 用户.最新输入(full)
|
|||||||
- 排序表里出现该 ref 时,按 **projection** 动态裁剪后注入(不是整段永远塞满)。
|
- 排序表里出现该 ref 时,按 **projection** 动态裁剪后注入(不是整段永远塞满)。
|
||||||
- 若尚未写入历史,可用 `运行.事件流` + `用户.最新输入` 拼兜底。
|
- 若尚未写入历史,可用 `运行.事件流` + `用户.最新输入` 拼兜底。
|
||||||
- 投影建议:`summary`(默认)/ `fields`(更短)/ `full`(更长)/ `fixed`(几乎不注入)。
|
- 投影建议:`summary`(默认)/ `fields`(更短)/ `full`(更长)/ `fixed`(几乎不注入)。
|
||||||
|
- 裁剪是**块级 + 滞回**的(`projectDialogueHistory()`):段数在 n~2n 之间原样全给,超过 2n 才裁回 n。
|
||||||
|
这样历史 message 数组的首条不是每轮都变,前缀缓存每 n 轮才断一次。
|
||||||
|
**不要**改回「每轮取最近 n 段」——那会让整段前缀每轮全价重发。
|
||||||
|
- 投影后的正文会被 `parseHistoryMessages()` 切成真多轮 message,进预设的 `chatHistory` 洞。
|
||||||
|
|
||||||
### 4.2 拼装
|
### 4.2 拼装
|
||||||
|
|
||||||
Runtime 按 inserts 数组顺序拼进单条 user 上下文(`assembleWorkerContext` **不再**按 static/dynamic 重排)。
|
Runtime 按 inserts 数组顺序拼块,再由 `workerContextToBags()` 分袋(before / history / after / turn / postTurn)。
|
||||||
`tier` 仅作缓存提示(历史标签之前偏 static,之后偏 dynamic)。
|
`tier` 仅作缓存提示(历史标签之前偏 static,之后偏 dynamic)。
|
||||||
|
|
||||||
### 4.3 可编排
|
### 4.3 可编排
|
||||||
|
|||||||
@@ -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`
|
||||||
+184
-15
@@ -1,10 +1,16 @@
|
|||||||
# 预设格式
|
# 预设格式
|
||||||
|
|
||||||
|
> 编辑、版本树、用例组与多模型试跑见 [preset-workbench.md](preset-workbench.md)。
|
||||||
|
|
||||||
## 1. 定位
|
## 1. 定位
|
||||||
|
|
||||||
Preset 是一次 LLM 请求的上下文编排资产。它负责描述 prompt manager 中有哪些条目、这些条目的启用状态与排列顺序,以及本次生成使用哪些模型参数。
|
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
|
```text
|
||||||
regex_scripts
|
regex_scripts
|
||||||
正则隐藏、正文提取、格式美化等后处理脚本。
|
正则隐藏、正文提取、格式美化等后处理脚本(导入时只做诊断,不执行)。
|
||||||
|
|
||||||
extension scripts
|
extension scripts
|
||||||
预设内携带的前端脚本、按钮、插件配置。
|
预设内携带的前端脚本、按钮、插件配置(一律不执行,导入报告点名)。
|
||||||
|
|
||||||
UI-only fields
|
UI-only fields
|
||||||
只影响 SillyTavern 界面展示、不影响 LLM 请求组装的字段。
|
只影响 SillyTavern 界面展示、不影响 LLM 请求组装的字段。
|
||||||
@@ -105,12 +111,14 @@ type ImportedPromptOrder = Array<{
|
|||||||
第一版采用的规则:
|
第一版采用的规则:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. 以 prompt_order[0].order 作为主顺序。
|
1. prompt_order 可能有多套(按 character_id 分)。优先 character_id=100001(酒馆里通用于所有角色的那套);
|
||||||
|
它不存在时才按「启用内容最多」打分。
|
||||||
2. 按 order 数组顺序遍历 identifier。
|
2. 按 order 数组顺序遍历 identifier。
|
||||||
3. 找到对应 prompts 条目。
|
3. 找到对应 prompts 条目。
|
||||||
4. 只有 order.enabled 和 prompt.enabled 都为 true 时,条目才参与本轮上下文装配。
|
4. 只有 order.enabled 和 prompt.enabled 都为 true 时,条目才参与本轮上下文装配。
|
||||||
5. 如果 prompt_order 引用了不存在的 identifier,导入时记录 warning,但不阻断导入。
|
5. 如果 prompt_order 引用了不存在的 identifier,导入时记录 warning,但不阻断导入。
|
||||||
6. 如果 prompts 中存在但 prompt_order 未引用,默认不参与请求,但保留在 preset 中。
|
6. 如果 prompts 中存在但 prompt_order 未引用,默认不参与请求,但保留在 preset 中。
|
||||||
|
7. 选中的那一套若缺 main / chatHistory / jailbreak,导入时告警(框架可能不完整)。
|
||||||
```
|
```
|
||||||
|
|
||||||
`prompt_order` 比 `prompts` 中的排列更重要。`prompts` 是条目仓库,`prompt_order` 才是实际启用的编排表。
|
`prompt_order` 比 `prompts` 中的排列更重要。`prompts` 是条目仓库,`prompt_order` 才是实际启用的编排表。
|
||||||
@@ -231,7 +239,7 @@ scenario worldInfoAfter dialogueExamples chatHistory
|
|||||||
| identifier | 填什么 |
|
| identifier | 填什么 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `worldBookBefore` | `对话.历史` 之前的 inserts,每条为标题+正文 |
|
| `worldBookBefore` | `对话.历史` 之前的 inserts,每条为标题+正文 |
|
||||||
| `chatHistory` | 共用酒馆 id;填 `对话.历史` |
|
| `chatHistory` | 共用酒馆 id;填 `对话.历史`——**展开成真多轮 message[]**,不是一段文本 |
|
||||||
| `worldBookAfter` | 历史之后、本轮之前(变量、工单等)。**不是** `worldInfoAfter` |
|
| `worldBookAfter` | 历史之后、本轮之前(变量、工单等)。**不是** `worldInfoAfter` |
|
||||||
| `currentTurn` | `用户.最新输入`,role=user |
|
| `currentTurn` | `用户.最新输入`,role=user |
|
||||||
| `postTurn` | 本轮输入之后的 inserts(可空) |
|
| `postTurn` | 本轮输入之后的 inserts(可空) |
|
||||||
@@ -257,7 +265,6 @@ postTurn
|
|||||||
社区预设里包着 WI 洞的装饰分隔会留在历史前段,中间可能是空的。第一版接受。
|
社区预设里包着 WI 洞的装饰分隔会留在历史前段,中间可能是空的。第一版接受。
|
||||||
|
|
||||||
### 4.3 六段夹心(扫 order 后的效果)
|
### 4.3 六段夹心(扫 order 后的效果)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. 酒馆内、历史前(ST 普通条目)
|
1. 酒馆内、历史前(ST 普通条目)
|
||||||
2. 我们写的、历史前(worldBookBefore)
|
2. 我们写的、历史前(worldBookBefore)
|
||||||
@@ -276,11 +283,122 @@ postTurn
|
|||||||
4. 再过滤 entry.enabled=false 的条目。
|
4. 再过滤 entry.enabled=false 的条目。
|
||||||
5. 对 marker:用 WorldInfoPack 解析我们的四个洞 + chatHistory;酒馆角色卡/WI 恒为空。
|
5. 对 marker:用 WorldInfoPack 解析我们的四个洞 + chatHistory;酒馆角色卡/WI 恒为空。
|
||||||
6. 普通 content 条目原样插入。
|
6. 普通 content 条目原样插入。
|
||||||
7. 按条目 role 合成 LLM messages。
|
7. 按条目 role 合成 LLM messages(chatHistory 洞展开成多条)。
|
||||||
8. 应用 generation 参数。
|
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. 与创作流程的关系
|
## 5. 与创作流程的关系
|
||||||
|
|
||||||
@@ -310,22 +428,73 @@ Runtime Session
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
保留 prompts。
|
保留 prompts。
|
||||||
保留 prompt_order。
|
保留 prompt_order(多套时优先 100001)。
|
||||||
保留生成参数。
|
保留生成参数。
|
||||||
记录但忽略 regex_scripts。
|
读取 squash_system_messages → messagePolicy。
|
||||||
记录但忽略 extension scripts。
|
记录但忽略 regex_scripts(只做诊断)。
|
||||||
|
记录但忽略 extension scripts(只做诊断)。
|
||||||
记录未知字段,不丢弃原始 JSON。
|
记录未知字段,不丢弃原始 JSON。
|
||||||
```
|
```
|
||||||
|
|
||||||
导入结果应该给用户可读的报告:
|
导入结果应该给用户可读的报告(`PresetImportReport`):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
导入 prompt 条目数量
|
prompt 条目数量
|
||||||
启用条目数量
|
启用条目数量
|
||||||
未被 prompt_order 引用的条目数量
|
未被 prompt_order 引用的条目数量
|
||||||
缺失 identifier 的 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
|
||||||
|
```
|
||||||
@@ -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 请求,页面会一直等到全部结果返回;没有逐条流式进度。
|
||||||
|
- 「应用到对话页」只改预设文件;要让已有会话立刻使用,去设置页点「启用」。
|
||||||
|
- 测试用例不会自动生成,也不会自动评分。
|
||||||
@@ -125,6 +125,7 @@
|
|||||||
| Intake | **需求描述** | 启动填空 | — |
|
| Intake | **需求描述** | 启动填空 | — |
|
||||||
| Burst | **本轮调度** | — | 少对用户说 burst。 |
|
| Burst | **本轮调度** | — | 少对用户说 burst。 |
|
||||||
| Questions card | **询问卡** | Questions | 详见 `ui-design.md` §8。 |
|
| Questions card | **询问卡** | Questions | 详见 `ui-design.md` §8。 |
|
||||||
|
| Direct chat / `direct-chat` | **直聊** | — | 与作品「对话」并列的轻量多轮 LLM 面;两层(上下文壳 → 会话)。见 `direct-chat.md`。 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -206,6 +207,7 @@
|
|||||||
| `interaction` | 交互范式(旧;已并入上一行) |
|
| `interaction` | 交互范式(旧;已并入上一行) |
|
||||||
| `narrative_guide` | 叙事指南与故事推进 |
|
| `narrative_guide` | 叙事指南与故事推进 |
|
||||||
| `narrative-guide` | 叙事指南(对话落盘) |
|
| `narrative-guide` | 叙事指南(对话落盘) |
|
||||||
|
| `image-prompt` | 画面与生图(对话落盘) |
|
||||||
| `story-progression` | 故事推进(对话落盘) |
|
| `story-progression` | 故事推进(对话落盘) |
|
||||||
| `user-requirements` | 用户需求(偏好库选用) |
|
| `user-requirements` | 用户需求(偏好库选用) |
|
||||||
| `input_protocol` | 输入协议 |
|
| `input_protocol` | 输入协议 |
|
||||||
|
|||||||
@@ -173,12 +173,13 @@ recipes/world-simulator|expand-assistant/recipe.yaml
|
|||||||
|------|-----|------|
|
|------|-----|------|
|
||||||
| 美学纲领与交互范式 | `aesthetics-interaction` | **范例已写**;前端 mosaic 视图 |
|
| 美学纲领与交互范式 | `aesthetics-interaction` | **范例已写**;前端 mosaic 视图 |
|
||||||
| 主角设定 | `protagonist` | **已写**:代入名片(名字/背景/特殊设定);挂在当前选用的用户角色卡下;关掉这局就不加载 |
|
| 主角设定 | `protagonist` | **已写**:代入名片(名字/背景/特殊设定);挂在当前选用的用户角色卡下;关掉这局就不加载 |
|
||||||
| 实现机制 | `mechanism` | **已写**:`context-fragment.v1`(支撑点正文 + 自评 + 追问);`feeds: gm`;**专用卡已接** |
|
| 世界法则 | `mechanism` | **已写**:`context-fragment.v1`(法则 + 连锁);旧称实现机制,产物旧 tag `设计.实现机制` 仍可读;`feeds: gm`;**专用卡已接** |
|
||||||
| 舞台骨架 | `world-blueprint` | **已写**:`context-fragment.v1`;社会结构 + 世界状况;**专用卡已接** |
|
| 舞台骨架 | `world-blueprint` | **已写**:`context-fragment.v1`;社会结构 + 世界状况;**专用卡已接** |
|
||||||
| 生成规则 | `generation-rules` | **已写**:`context-fragment.v1`;方向池优先、元素池仅封闭集合;可反复;**〔先验产物〕**;规划字段 `target`;挂 gm+auditor;**专用卡已接** |
|
| 生成规则 | `generation-rules` | **已写**:`context-fragment.v1`;方向池优先、元素池仅封闭集合;可反复;**〔先验产物〕**;规划字段 `target`;挂 gm+auditor;**专用卡已接** |
|
||||||
| 具体实例 | `concrete-instances` | **已写**:`context-fragment.v1`;只按规则执行;可反复;**〔先验产物〕**;规划字段 `rule_id` |
|
| 具体实例 | `concrete-instances` | **已写**:`context-fragment.v1`;只按规则执行;可反复;**〔先验产物〕**;规划字段 `rule_id` |
|
||||||
| 叙事指南与故事推进 | `narrative` | **工序编排**:一份全文双挂;结构化卡。对话落盘不排本步 |
|
| 叙事指南与故事推进 | `narrative` | **工序编排**:一份全文双挂;结构化卡。对话落盘不排本步 |
|
||||||
| 叙事指南 | `narrative-guide` | **对话落盘**:遣词/笔墨/禁忌/写法档;`intake: dictate` |
|
| 叙事指南 | `narrative-guide` | **对话落盘**:遣词/笔墨/禁忌/写法档;`intake: dictate` |
|
||||||
|
| 画面与生图 | `image-prompt` | **对话落盘**:从工作流库选一种并写提示词合同;只挂旁观;`intake: dictate` |
|
||||||
| 故事推进 | `story-progression` | **对话落盘**:推进方式 + 用户输入用法;`intake: dictate` |
|
| 故事推进 | `story-progression` | **对话落盘**:推进方式 + 用户输入用法;`intake: dictate` |
|
||||||
| 拓扑图谱 | `topology` | 必须生成且不适合走生成规则→实例时,用拓扑结构写出(升级路径、地图、人物关系等) |
|
| 拓扑图谱 | `topology` | 必须生成且不适合走生成规则→实例时,用拓扑结构写出(升级路径、地图、人物关系等) |
|
||||||
| 回复呈现 | `status-bar` | **已写**:每轮终稿看什么(正文/字数/日期/变量);旧称设计监控栏 |
|
| 回复呈现 | `status-bar` | **已写**:每轮终稿看什么(正文/字数/日期/变量);旧称设计监控栏 |
|
||||||
|
|||||||
Generated
+64
-13
@@ -8,7 +8,9 @@
|
|||||||
"name": "writing-agent",
|
"name": "writing-agent",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"https-proxy-agent": "^9.1.0",
|
||||||
"msedge-tts": "^2.0.9",
|
"msedge-tts": "^2.0.9",
|
||||||
|
"undici": "^8.12.2",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1048,15 +1050,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "9.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
|
||||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6.0.0"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
@@ -1087,6 +1086,31 @@
|
|||||||
"proxy-from-env": "^2.1.0"
|
"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": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -1894,16 +1918,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/https-proxy-agent": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "5.0.1",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
|
||||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
"integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"agent-base": "6",
|
"agent-base": "9.0.0",
|
||||||
"debug": "4"
|
"debug": "^4.3.4",
|
||||||
|
"proxy-agent-negotiate": "1.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
@@ -2213,6 +2238,23 @@
|
|||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
@@ -2561,6 +2603,15 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
|||||||
+4
-1
@@ -15,7 +15,8 @@
|
|||||||
"web:watch": "tsx watch --clear-screen=false --include \"./src/**/*.ts\" --include \"./skills/**\" src/server/web-server.ts",
|
"web:watch": "tsx watch --clear-screen=false --include \"./src/**/*.ts\" --include \"./skills/**\" src/server/web-server.ts",
|
||||||
"electron": "electron .",
|
"electron": "electron .",
|
||||||
"libraries:restore": "tsx src/cli/restore-library-seeds.ts",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
@@ -25,7 +26,9 @@
|
|||||||
"vitest": "^2.1.9"
|
"vitest": "^2.1.9"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"https-proxy-agent": "^9.1.0",
|
||||||
"msedge-tts": "^2.0.9",
|
"msedge-tts": "^2.0.9",
|
||||||
|
"undici": "^8.12.2",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,23 +1,26 @@
|
|||||||
# 美学纲领与交互范式
|
# 美学纲领
|
||||||
|
|
||||||
## meta
|
## meta
|
||||||
|
|
||||||
```meta
|
```meta
|
||||||
name: 美学纲领与交互范式
|
name: 美学纲领
|
||||||
id: aesthetics-interaction
|
id: aesthetics-interaction
|
||||||
artifact: 设计.美学纲领与交互范式
|
artifact: 设计.美学纲领
|
||||||
declaration: >
|
declaration: >
|
||||||
提取用户最想反复获得的核心体验与让它成立的承重内核,写成给后续能力读的美学纲领;
|
提取用户最想反复获得的核心体验和让它成立的承重内核。
|
||||||
同时钉清交互范式:人称视角、模型可代写什么、用户保留什么、后果由谁裁定、何时停笔。
|
体验锚定只留三句短锚:站位、核心感觉、轮转停点。描写权限、后果裁定和停笔做法与推进合同一起写在故事推进与交互范式。
|
||||||
when: |
|
when: |
|
||||||
用户要的是叙事、角色扮演或持续互动,但还不能明确说出:最想反复获得什么体验、自己站在什么位置、轮转到哪必须停等用户。
|
用户要的是叙事、角色扮演或持续互动,但还不能明确说出最想反复获得什么体验时使用。
|
||||||
用户主动改变核心体验、代入站位或关键权限时也应重写。
|
用户主动改变核心体验时也应重写。站位与轮转停点若已有短句,只补体验,不在本步展开权限。
|
||||||
when_not: |
|
when_not: |
|
||||||
本局不依赖体验契约的纯转换、资料整理或机械格式任务,可不使用。
|
本局不依赖体验契约的纯转换、资料整理或机械格式任务,可不使用。
|
||||||
只改遣词和具体写法 → 叙事指南;只改推进与输入用法 → 故事推进;只改舞台详情或硬法则 → 对应能力。
|
只改谁代写、谁裁定、何时停笔、输入用法或篇幅 → 故事推进与交互范式。
|
||||||
|
只改遣词和具体写法 → 叙事指南。只改舞台或硬法则 → 对应能力。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力只交三块:美学纲领(体验是什么、怎样发生、呈现尺度)、背景与规则的承重内核、交互范式(视角、描写权限、后果控制、停笔)。
|
本步只交美学纲领、背景与规则的承重内核,以及体验锚定三句。
|
||||||
背景与规则只提取承重内核,不扩写百科;舞台细节归舞台骨架,可执行硬法则归实现机制;具体遣词句式归叙事指南。
|
体验写在 `体验内核` 一处;同义的发生方式、角色人文、描写焦点省略。
|
||||||
|
背景内核写成后续可引用的短句。场所归舞台骨架,因果归世界法则,遣词归叙事指南。
|
||||||
|
交互权限与推进合同归故事推进与交互范式,本步不写描写权限表。
|
||||||
feeds: design_only
|
feeds: design_only
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -42,27 +45,24 @@ feeds: design_only
|
|||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
从用户材料中提取一份体验契约,写入 `设计.美学纲领与交互范式`。它是后续所有能力取舍的依据,也会挂给主世界层与转述层直接阅读。产物回答三件事:
|
从用户材料提取体验契约,写入 `设计.美学纲领`。它给后续能力做取舍,不进游玩投影。产物回答两件事,并留下三句短锚:
|
||||||
|
|
||||||
1. 美学纲领:用户最想反复获得什么体验,它通过什么关系、处境、感官或选择反复发生,呈现时的氛围与尺度。
|
1. 美学纲领:用户最想反复获得什么体验,它靠什么关系或处境反复发生,呈现时的尺度。体验本身只写在 `体验内核`。
|
||||||
2. 背景与规则:删掉就会让这份体验不成立的最小承重前提,以及它如何约束人物、选择与后果。
|
2. 背景与规则:删掉就会让这份体验不成立的最小前提,写成后续能原句引用的短句。
|
||||||
3. 交互范式:用户与所扮演角色的关系、人称与视角、模型对 user 每类行为能代写到哪一步、世界后果由谁裁定、何时必须停笔等用户。
|
3. `体验锚定`:站位、核心感觉、轮转停点各一句。完整的谁代写、谁裁定、怎么停笔,留给故事推进与交互范式。
|
||||||
|
|
||||||
保留用户自己的重点、用词和尺度。材料不足时少写或写「未定」,把真正影响体验或权限的缺口交给 `self_score.gap`;产物正文不放评分、诊断、追问或开放问题。
|
保留用户自己的重点、用词和尺度。材料不足时少写或写「未定」,真正影响体验的缺口进 `self_score.gap`。正文不含评分、诊断或追问。
|
||||||
```
|
```
|
||||||
|
|
||||||
## principles
|
## principles
|
||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 核心是用户想要的体验;其余字段都是为了让它稳定地反复发生。完善设定时回头核对原本的核心体验,不让它被设定冲淡。
|
1. 核心是用户想反复获得的体验。其余字段只为让它稳定发生。设定变长时,先核对体验句还在不在。
|
||||||
2. 核心体验不是题材、剧情简介或情绪标签:写用户如何参与、体验怎样反复发生。同一设定不同用户要的可以完全不同(角斗士:荣耀 / 自由 / 血与酒),找到这个人要的那一个,而不是补全世界观。
|
2. 体验不是题材、剧情简介或情绪标签。写用户怎么参与、它怎样反复发生。同一设定可以要荣耀、自由或血与酒,只写这个人要的那一个。
|
||||||
3. 变造世界先抓「原型 + 变在哪里」;核心体验往往来自变点。背景与规则只写承重内核:关键关系、社会前提、世界规则或作用机制。
|
3. 先抓「原型 + 变在哪里」。`核心内核` 只留删掉体验就不成立的那一句关系、社会前提或规则。场所、因果链、价目不在这里展开。
|
||||||
4. 用户常说不清想要什么:展示优于提问,大胆优于保守;用两种站位或两种感觉的短场景让用户选或改;负面信号同样有效。用户可以说不,随时准备整份改写。
|
4. 用户说不清时,用两种站位或两种感觉的短场景让用户选;问在回复里,不写进产物。用户改口导致内核变了,整份重写,不在旧契约后打补丁。
|
||||||
5. 用户要的内容去道德化、常态化处理:按用户给的尺度写进「边界与尺度」,写清服务体验的用法与真正的禁忌,不替用户加或减尺度。
|
5. 尺度按用户给的写进 `边界与尺度`:服务体验的用法,以及真正的禁忌。不替用户加码或减码。
|
||||||
6. 交互范式写可执行权限,不写口号。描写权限按行为类别逐项写:哪些模型可直接代写、哪些只写到起势就停、哪些必须留给用户;「用户输入后」写模型对一句输入是只确认、补细节还是连带推出后果。
|
6. `发生方式`、`角色人文`、`作用方式`、`描写焦点` 与体验内核或核心内核同义时整项省略。描写焦点只点注意力放在哪类瞬间,不写遣词,不写示范段,不写权限表。
|
||||||
7. 交互范式要保护美学纲领:权限、视角和停笔点的设置要让核心体验更容易发生,两者矛盾时以美学纲领为准改范式。
|
|
||||||
8. 本步不扩写世界百科,不制定完整硬机制,不细化遣词句式,不决定故事推进方法,不输出执行单元列表或插入 order。
|
|
||||||
9. 用户改口导致体验内核改变时整份重写,不在旧契约后追加互相冲突的补丁。
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## probe
|
## probe
|
||||||
@@ -72,7 +72,7 @@ feeds: design_only
|
|||||||
|
|
||||||
用户首句、默认问题首答或黑板「用户.需求」里已有的直接采纳,已答清的不重问。
|
用户首句、默认问题首答或黑板「用户.需求」里已有的直接采纳,已答清的不重问。
|
||||||
|
|
||||||
缺其中一件:回复里只问这一件,给 2~3 个带短场景的建议选项,不问开放式「你还想要什么」。三件已清:再挑一个最影响体验的薄弱点(视角、某类描写权限、尺度)给选项;都够用就不再追问。
|
缺其中一件:回复里只问这一件,给 2~3 个带短场景的建议选项,不问开放式「你还想要什么」。三件已清:再挑尺度或描写焦点里最影响体验的一点给选项;权限和停笔做法留给故事推进与交互范式,本步不再追问。
|
||||||
|
|
||||||
用户讲到人物、舞台、规则细节:只抽取能改变体验、站位或权限的那一句写进正文,其余留给对应能力,本步不追问细节。
|
用户讲到人物、舞台、规则细节:只抽取能改变体验、站位或权限的那一句写进正文,其余留给对应能力,本步不追问细节。
|
||||||
```
|
```
|
||||||
@@ -82,112 +82,87 @@ feeds: design_only
|
|||||||
```output
|
```output
|
||||||
{
|
{
|
||||||
"schema": "context-fragment.v1",
|
"schema": "context-fragment.v1",
|
||||||
"技能": "美学纲领与交互范式",
|
"技能": "美学纲领",
|
||||||
"brief": "一句话点出用户最想反复获得的核心体验",
|
"brief": "一句话:用户最想反复获得的核心体验",
|
||||||
"体验锚定": {
|
"体验锚定": {
|
||||||
"站位": "用户与所扮演角色/作品的关系:代入、部分代入、旁观或操控",
|
"站位": "代入、部分代入、旁观或操控",
|
||||||
"核心感觉": "最想反复获得的体验",
|
"核心感觉": "最想反复获得的体验",
|
||||||
"轮转停点": "何时必须停笔等待用户"
|
"轮转停点": "何时必须停笔等待用户"
|
||||||
},
|
},
|
||||||
"mount": ["world-simulator", "narrator"],
|
"mount": [],
|
||||||
"稳变": "stable",
|
"稳变": "stable",
|
||||||
"正文": {
|
"正文": {
|
||||||
"美学纲领": {
|
"美学纲领": {
|
||||||
"体验内核": "给后续能力和游玩层读的纲领正文,可多段:用户要反复感到什么、它靠什么关系或处境成立、从哪一刻开始、怎样被反复兑现",
|
"体验内核": "一段:反复感到什么,靠什么关系或处境成立,怎样被反复兑现",
|
||||||
"发生方式": "体验通过哪些关系、处境、感官或选择反复发生",
|
"发生方式": "可选;内核没写到的反复条件",
|
||||||
"角色人文": "关键人物关系中的权力、尊严、亲疏或人性张力;无则省略",
|
"角色人文": "可选;内核没写到的权力、尊严或亲疏",
|
||||||
"呈现要点": {
|
"呈现要点": {
|
||||||
"氛围基调": "整体质感与情绪温度",
|
"氛围基调": "整体质感",
|
||||||
"描写焦点": "注意力放在哪里:哪些瞬间、反应、细节值得放慢写;具体遣词交给叙事指南",
|
"描写焦点": "注意力放在哪类瞬间;不写遣词",
|
||||||
"边界与尺度": "色情/暴力/血腥等按用户给的尺度:服务体验的用法与真正的禁忌",
|
"边界与尺度": "按用户尺度:服务体验的用法与真正的禁忌",
|
||||||
"其它要点": ["反差、日常化、节奏偏好等短条;无则省略"]
|
"其它要点": ["可选短条"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"背景与规则": {
|
"背景与规则": {
|
||||||
"原型与变点": "熟悉的原型世界,以及变在哪里;无变造则省略",
|
"原型与变点": "原型世界,以及变在哪里;无变造则省略",
|
||||||
"核心内核": "让体验成立的最小承重前提",
|
"核心内核": "后续可引用的一句承重前提",
|
||||||
"作用方式": "它如何约束人物、选择与后果,怎样持续制造该体验",
|
"作用方式": "可选;这句前提约束哪类选择或后果",
|
||||||
"非必要设定": "明确本步不展开的装饰信息;无则省略"
|
"非必要设定": "可选;本步不展开的范围"
|
||||||
},
|
|
||||||
"交互范式": {
|
|
||||||
"前置配置": {
|
|
||||||
"用户与user关系": "完全代入 / 部分代入 / 旁观 / 操控,及其含义",
|
|
||||||
"人称": "如:第二人称称「你」",
|
|
||||||
"元叙事": "如:不跳出故事直接问卷,决策用情节呈现"
|
|
||||||
},
|
|
||||||
"叙事视角": {
|
|
||||||
"主要视角": "镜头跟谁、看什么",
|
|
||||||
"视角切换": "禁止,或允许的例外",
|
|
||||||
"可写内心": "可以写谁的内心;如仅 user 或仅 NPC 外显",
|
|
||||||
"不呈现的信息": "user 不该知道、正文不能透露的内容"
|
|
||||||
},
|
|
||||||
"描写权限": {
|
|
||||||
"思考与抉择": "关键决定是否只能由用户做,模型写到哪里停",
|
|
||||||
"言语": "模型能否替 user 说话、说到什么程度",
|
|
||||||
"主动行动": "模型能否替 user 发起行动",
|
|
||||||
"情绪与身体反应": "模型可补写的生理与情绪反应",
|
|
||||||
"习惯与既有技能": "模型可按既有设定自动执行的部分",
|
|
||||||
"外在状态": "外貌、伤势、衣着等可否直接描写",
|
|
||||||
"用户输入后": "对用户一句输入:只确认 / 补细节 / 连带推出后果"
|
|
||||||
},
|
|
||||||
"后果与叙事控制": {
|
|
||||||
"行为后果判定": "谁裁定 user 行为的成败与代价",
|
|
||||||
"世界对user的影响": "NPC 与世界如何主动作用于 user",
|
|
||||||
"时间与场景": "模型可否推进时间、切换场景,跨度多大",
|
|
||||||
"引入新内容": "模型可否主动引入新人物、事件、威胁",
|
|
||||||
"设计困境": "是否主动给 user 制造两难;怎样的两难服务体验"
|
|
||||||
},
|
|
||||||
"停笔时机": "必须等用户输入的明确时机"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON。`体验锚定` 三项必填短句:开场白写入前程序按它判定体验是否已锚定,缺项会拒绝写入开场白。
|
1. 合法 JSON。`体验锚定` 三项必填短句。`mount` 固定 `[]`。
|
||||||
2. `正文` 只含 `美学纲领`、`背景与规则`、`交互范式` 三块,每块是对象,不写成一段散文。未知字段写「未定」或省略,不编造百科。
|
2. `正文` 只含 `美学纲领`、`背景与规则`。不写 `交互范式`。键名不用「核心体验」「参与边界」「描写信号」。
|
||||||
3. 不在产物里写 `自评`、`追问`、`开放问题`、完备度、评分依据或用户回复;缺口交给 `self_score.gap`。
|
3. `体验内核` 与 `核心内核` 各只写一次。同义的可选槽整项省略。
|
||||||
4. `mount` 固定包含 `world-simulator` 与 `narrator`;不写数字 `order`,不输出执行单元列表。
|
4. 未知写「未定」或省略。正文不含评分、追问、权限表、舞台清单、法则全文或推进方案。
|
||||||
|
|
||||||
## score
|
## score
|
||||||
|
|
||||||
```score
|
```score
|
||||||
美学纲领:
|
美学纲领:
|
||||||
- 5:准确提取用户最想反复获得的体验,写清它怎样反复发生、从哪一刻成立,呈现焦点与尺度贴合用户原话;后续能力可据此取舍。
|
- 5:体验内核写清反复感到什么、怎样兑现;可选槽没有把这句话再写一遍;尺度贴用户原话。
|
||||||
- 3:大方向正确,但仍偏题材或情绪概括,或体验如何反复发生、尺度如何把握尚不明确。
|
- 3:方向对,但仍是题材或情绪标签,或发生方式在复读内核。
|
||||||
- 1:误读用户重点,只有剧情简介、设定标签或泛化氛围。
|
- 1:只有剧情简介或设定堆砌。
|
||||||
|
|
||||||
交互范式:
|
体验锚定:
|
||||||
- 5:站位、人称视角、逐类描写权限、后果裁定与停笔时机都可执行,且在保护核心体验。
|
- 5:站位、核心感觉、轮转停点各是一句可核对的短锚,没有展开成权限表。
|
||||||
- 3:有站位与停笔,但描写权限笼统,或某类行为(言语、行动、反应)谁来写仍不清。
|
- 3:三件都有,但某一件是空话或和体验内核无关。
|
||||||
- 1:只有口号式描述,或权限设置与核心体验矛盾。
|
- 1:缺件,或把描写权限、停笔做法写进本产物。
|
||||||
|
|
||||||
背景与规则:
|
背景与规则:
|
||||||
- 5:找到让核心体验成立的最小内核——关键关系、背景前提或作用规则,并说明它怎样持续制造该体验。
|
- 5:核心内核是一句删掉体验就不成立的前提,后续能引用。
|
||||||
- 3:找到相关前提,但内核过宽、因果不清,或混有较多装饰设定。
|
- 3:找到前提,但过宽,或作用方式在复读内核。
|
||||||
- 1:没找到承重内核;只有百科堆砌,或规则与核心体验无关。
|
- 1:百科,或与体验无关。
|
||||||
```
|
```
|
||||||
|
|
||||||
## checklist
|
## checklist
|
||||||
|
|
||||||
```checklist
|
```checklist
|
||||||
- [ ] `体验锚定` 三项(站位、核心感觉、轮转停点)都已写出?
|
- [ ] `体验锚定` 三项都已写出?
|
||||||
- [ ] 美学纲领写的是用户反复获得什么、怎样发生,而非题材或剧情简介?体验内核能直接给游玩层读?
|
- [ ] 体验只在 `体验内核` 写清,同义槽已省略?
|
||||||
- [ ] 背景与规则是删掉就会破坏体验的最小内核?
|
- [ ] `核心内核` 是后续能引用的短句,不是舞台或法则全文?
|
||||||
- [ ] 交互范式逐类写清描写权限、后果裁定和停笔时机,并且在保护美学纲领?
|
- [ ] 体验锚定是三句短锚,没有描写权限表?
|
||||||
- [ ] 未扩写舞台百科、硬机制、具体文风或推进方案?
|
- [ ] 正文无评分、诊断、追问?
|
||||||
- [ ] 产物正文不含评分、诊断、追问和开放问题?
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## examples
|
## examples
|
||||||
|
|
||||||
```examples
|
```examples
|
||||||
好:
|
好:
|
||||||
- brief 点题「垄断生存资源的绝对掌控感」;美学纲领的体验内核写清掌控感从哪一刻成立、靠什么反复兑现,边界与尺度照用户原话;交互范式写明 user 的抉择只由用户做、模型可补身体反应与习惯动作、NPC 的屈服与反抗由模型裁定、每次 NPC 给出回应后停笔。
|
- 体验内核:「反复感到垄断生存资源的掌控;对方因资源断供而让步或反抗时兑现。」发生方式与这句话同义,省略。
|
||||||
|
- 核心内核:「物资只从用户控制的仓库流出。」作用方式一句:「拒绝供货会在下一顿改变对方的选择。」不写仓库在哪条街。
|
||||||
|
- 体验锚定:站位「完全代入」;核心感觉「断供时的掌控」;轮转停点「对方让步或反抗后等用户决定供不供」。
|
||||||
|
|
||||||
坏:
|
缺料时:
|
||||||
- 只有一段散文纲领,三块没有拆成对象。
|
- 站位、核心感觉、轮转停点缺一件 → 回复里只问这一件,产物里该项写「未定」或先不落盘。
|
||||||
- 百科式背景堆砌,看不出体验靠哪条前提成立。
|
- 用户只给了题材词 → 不编体验,gap 问他要反复感到的那一种。
|
||||||
- 交互范式只写「尊重玩家选择」,没说言语、行动、反应各由谁写。
|
|
||||||
- 在正文里写评分、追问或「待确认」清单。
|
偏了就改回:
|
||||||
|
- 体验内核、发生方式、角色人文、描写焦点四段同一句掌控感 → 留体验内核,其余省略。
|
||||||
|
- 核心内核写成古武阶梯、五阶段链或十阶价目 → 收成一句前提。阶梯和价目留给拓扑图谱,因果留给世界法则。
|
||||||
|
- 描写焦点写成一段感官范文 → 改成「注意力放在让步或反抗的那一刻」。
|
||||||
|
- 用「核心体验 / 描写信号」当键,或在本步写描写权限表 → 体验留在本产物,权限改写入故事推进与交互范式。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
# (declaration / when / when_not / boundary)注入 design-flow。
|
# (declaration / when / when_not / boundary)注入 design-flow。
|
||||||
# when/when_not 必须是规划对照用户话与已有产物就能判定的信号;调用条件写在技能 meta,不要写进配方 process。
|
# when/when_not 必须是规划对照用户话与已有产物就能判定的信号;调用条件写在技能 meta,不要写进配方 process。
|
||||||
#
|
#
|
||||||
# 标准范例:aesthetics-interaction(美学纲领与交互范式)
|
# 标准范例:aesthetics-interaction(美学纲领)
|
||||||
# 后来写能力:同结构 prompt.md(含默认问题)+ 本表一行索引
|
# 后来写能力:同结构 prompt.md(含默认问题)+ 本表一行索引
|
||||||
#
|
#
|
||||||
# 世界模拟器常用能力见下「体验→世界→机制→呈现→收成」;编排按需选用,勿默认全选。
|
# 世界模拟器常用能力见下「体验→世界→机制→呈现→收成」;编排按需选用,勿默认全选。
|
||||||
@@ -43,13 +43,13 @@ modules:
|
|||||||
|
|
||||||
# —— 体验契约 ——
|
# —— 体验契约 ——
|
||||||
- id: aesthetics-interaction
|
- id: aesthetics-interaction
|
||||||
name: 美学纲领与交互范式
|
name: 美学纲领
|
||||||
layer: final
|
layer: final
|
||||||
mount: [world-simulator, narrator]
|
mount: [world-simulator, narrator]
|
||||||
declaration: >-
|
declaration: >-
|
||||||
钉清用户会在意的体验承诺:站位、反复感到什么、轮转何时停。
|
钉清用户会在意的体验承诺:站位、反复感到什么、轮转何时停。
|
||||||
询问相近故合为一步。
|
询问相近故合为一步。
|
||||||
artifact: 设计.美学纲领与交互范式
|
artifact: 设计.美学纲领
|
||||||
|
|
||||||
- id: protagonist
|
- id: protagonist
|
||||||
name: 主角设定
|
name: 主角设定
|
||||||
@@ -119,7 +119,7 @@ modules:
|
|||||||
libraries: [style-packs]
|
libraries: [style-packs]
|
||||||
declaration: >-
|
declaration: >-
|
||||||
美学已有方向时:把感觉落成可执行的遣词、笔墨落点、禁忌与推进方式;
|
美学已有方向时:把感觉落成可执行的遣词、笔墨落点、禁忌与推进方式;
|
||||||
不重做体验问卷。工序编排用本步;对话落盘拆成「叙事指南」「故事推进」
|
不重做体验问卷。工序编排用本步;对话落盘拆成「叙事指南」「故事推进与交互范式」
|
||||||
artifact: 设计.叙事指南与故事推进
|
artifact: 设计.叙事指南与故事推进
|
||||||
|
|
||||||
- id: narrative-guide
|
- id: narrative-guide
|
||||||
@@ -129,12 +129,12 @@ modules:
|
|||||||
layer: final
|
layer: final
|
||||||
mount: [narrator, world-simulator]
|
mount: [narrator, world-simulator]
|
||||||
declaration: >-
|
declaration: >-
|
||||||
从文风库选取本局写法(默认 1~2,确需可到 3),
|
从文风库写成遣词、视角执行、段落形态、笔墨落点和少量禁忌;
|
||||||
落成风格推荐与要求、笔墨落点、少量禁忌与可选写法档;不钉推进
|
体验句留在美学纲领,不钉推进
|
||||||
artifact: 设计.叙事指南
|
artifact: 设计.叙事指南
|
||||||
|
|
||||||
- id: story-progression
|
- id: story-progression
|
||||||
name: 故事推进
|
name: 故事推进与交互范式
|
||||||
intake: dictate
|
intake: dictate
|
||||||
libraries: [preferences]
|
libraries: [preferences]
|
||||||
layer: final
|
layer: final
|
||||||
@@ -142,17 +142,28 @@ modules:
|
|||||||
declaration: >-
|
declaration: >-
|
||||||
从偏好库选取约束,只选定一种用户输入用法,
|
从偏好库选取约束,只选定一种用户输入用法,
|
||||||
并写清每一轮里扩写(演本轮材料)与续写(材料后往下写)的定义、比例与 OOC 处理;不写遣词
|
并写清每一轮里扩写(演本轮材料)与续写(材料后往下写)的定义、比例与 OOC 处理;不写遣词
|
||||||
artifact: 设计.故事推进
|
artifact: 设计.故事推进与交互范式
|
||||||
|
|
||||||
|
- id: image-prompt
|
||||||
|
name: 画面与生图
|
||||||
|
intake: dictate
|
||||||
|
libraries: [image-workflows]
|
||||||
|
layer: final
|
||||||
|
mount: [auditor]
|
||||||
|
declaration: >-
|
||||||
|
从工作流库选定一种画面工作流,并写成旁观每轮可执行的生图提示词合同;
|
||||||
|
游玩时不再改选。只挂旁观
|
||||||
|
artifact: 设计.画面与生图
|
||||||
|
|
||||||
# —— 机制与数据 ——
|
# —— 机制与数据 ——
|
||||||
- id: mechanism
|
- id: mechanism
|
||||||
name: 实现机制
|
name: 世界法则
|
||||||
layer: final
|
layer: final
|
||||||
mount: [world-simulator]
|
mount: [world-simulator]
|
||||||
declaration: >-
|
declaration: >-
|
||||||
保存世界中稳定成立的因果法则,供主世界层推演事件后果、
|
写出世界相对熟悉基底的根本变造及其连锁(制度、权力、日常),
|
||||||
剧情变化和人物反应;一次可写多条共同作用的机制
|
以及金手指与玩法系统的固定规则;不预定人物对玩家的反应
|
||||||
artifact: 设计.实现机制
|
artifact: 设计.世界法则
|
||||||
|
|
||||||
- id: topology
|
- id: topology
|
||||||
name: 拓扑图谱
|
name: 拓扑图谱
|
||||||
@@ -161,8 +172,8 @@ modules:
|
|||||||
mount: [world-simulator, auditor]
|
mount: [world-simulator, auditor]
|
||||||
repeatable: true
|
repeatable: true
|
||||||
declaration: >-
|
declaration: >-
|
||||||
为一个独有但结构复杂的对象定义节点、连接、初始图与开场后的维护规则;
|
为一张图定义节点、连接、初始图与开场后的维护规则。
|
||||||
一份产物一张图,可按地图、关系链、升级树等对象反复
|
地图、关系链、升级树,以及社会阶梯、价目、兑换等级等有序标尺,都在这里;一份产物一张图
|
||||||
artifact: 设计.拓扑图谱
|
artifact: 设计.拓扑图谱
|
||||||
|
|
||||||
- id: variable-design
|
- id: variable-design
|
||||||
@@ -171,8 +182,8 @@ modules:
|
|||||||
layer: final
|
layer: final
|
||||||
mount: [world-simulator, auditor]
|
mount: [world-simulator, auditor]
|
||||||
declaration: >-
|
declaration: >-
|
||||||
定义必须跨轮保存的最少真值、合法更新条件、权限与派生映射,
|
定义必须跨轮保存的最少真值、合法更新条件、权限与派生映射;
|
||||||
供程序选择简单状态工具或复杂维护副 Worker
|
默认值只留短事实,人物档案留在具体实例;有真值即旁观填表
|
||||||
artifact: 设计.变量设计与更新规则
|
artifact: 设计.变量设计与更新规则
|
||||||
|
|
||||||
- id: variable-context
|
- id: variable-context
|
||||||
@@ -190,7 +201,7 @@ modules:
|
|||||||
mount: [world-simulator, narrator]
|
mount: [world-simulator, narrator]
|
||||||
declaration: >-
|
declaration: >-
|
||||||
定义普通楼层每轮实际启用的语义区域、出现条件、权威来源与内容要求;
|
定义普通楼层每轮实际启用的语义区域、出现条件、权威来源与内容要求;
|
||||||
可含隐藏更新语义区,不决定前端排版和渲染
|
默认含文字选项;字数区间与占比照写故事推进与交互范式,不决定前端排版
|
||||||
artifact: 设计.监控栏
|
artifact: 设计.监控栏
|
||||||
|
|
||||||
- id: zero-layer-status
|
- id: zero-layer-status
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ when_not: |
|
|||||||
一张有节点和连接的结构图 → 拓扑图谱。
|
一张有节点和连接的结构图 → 拓扑图谱。
|
||||||
规则写明创作期不预填、只在游玩期生成 → 本步不写记录。
|
规则写明创作期不预填、只在游玩期生成 → 本步不写记录。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力只交一条记录。有规则则严格按字段、池或提示、填写工作流填写;无规则则只写用户给出的少量简单字段。
|
本步只交一条按合同填好的记录。有规则则按字段、池或提示、地板和工作流填写,填到地板即停。
|
||||||
不重议合同,不写世界百科,不改变量公式。一次写入多条时,程序按记录拆成多份产物。
|
不重议合同,不把规则里的提示、分级表象或反例抄成正文,不预写尚未发生的场面。
|
||||||
|
世界格局与硬法则留在舞台骨架和世界法则,本步只写这一条自己的事实。
|
||||||
feeds: gm
|
feeds: gm
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -31,28 +32,27 @@ feeds: gm
|
|||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
写出一条具体实例,写入 `设计.具体实例#中文名`。一份产物只含一条记录。用户一次给出多条时,每条各写一份;若交来的是记录数组,程序会按条拆开。
|
写出一条具体实例,写入 `设计.具体实例#中文名`。一份产物只含一条记录。用户一次给出多条时,每条各写一份;交来的是记录数组时,程序按条拆开。
|
||||||
|
|
||||||
有生成规则时:认定规则的中文名,按规则的字段、池或提示、填写工作流生成这一条;用户已写明的专名与结果只扩写不改写,未写字段从已有材料往下补满。无规则且只是一条字段很少的具名内容时:直接写用户给出的字段。
|
有生成规则时:认定规则的中文名,按字段、池或提示、地板和填写工作流填这一条。用户已写明的专名与结果保持原样。未写字段从已有材料补到该字段的地板:地板是最低要看见或碰到的事实,补完即停。无规则且只是一条字段很少的具名内容时:只写用户给出的字段。
|
||||||
|
|
||||||
规则的一次生成为同批时:一次 insert 写齐本批,`记录` 写成数组,每项一条记录并带上规则里的姓名或名称字段,程序按条拆成多份。先定各条如何互指、对立、同场,再填各条字段。
|
规则为同批时:一次写齐本批,`记录` 写成数组,每项一条并带上姓名或名称,程序按条拆成多份。先定各条如何互指、对立、同场,再填字段。
|
||||||
|
|
||||||
材料足够便直接成稿。缺的是合同本身时,先回到生成规则,不在这里补合同。产物正文不放评分、诊断、追问或开放问题。
|
材料够就成稿。缺的是合同本身时,回到生成规则,不在这里补合同。规则里的硬门槛(年龄、禁止项、档位)对不上材料时,不把这条写成违规内容,缺口进 `self_score.gap`。正文只含记录 JSON,不含评分、诊断或追问。
|
||||||
```
|
```
|
||||||
|
|
||||||
## principles
|
## principles
|
||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 有规则:字段名、取值方式、工作流阶段与依赖都以规则为准;不增删字段,不改池。
|
1. 有规则:字段名、取值方式、工作流与依赖以规则为准。不增删字段,不改池。
|
||||||
2. 池选取按合理度选择,不是程序抽签;提示约束按那句话的范围写开。
|
2. 池选取按合理度选。提示是范围,按范围写这一条的事实,不把提示里的方向句原样贴进记录。
|
||||||
3. 按填写工作流推进:先骨架后血肉;阶段依赖与自检能核对的,生成后要能过。
|
3. 按工作流:先定会约束后面的项,再填其余。填完要能过规则的自检。
|
||||||
4. 一份产物一条记录。单条规则一次写一条;多条各自成稿,用中文名称区分。
|
4. 一份产物一条记录。同批一次写齐:先对齐关系,再填字段。互指的专名与事由必须一致,同一次写完。
|
||||||
5. 同批规则一次写齐本批:先对齐关系,再填字段;各条里互指的专名与关系必须一致(甲记乙为仇敌,乙也记甲,且说的是同一件事)。有关系的人在同一次里写完,不拆到不同轮。
|
5. 句子级字段写到地板规定的可感知事实即停。词条、档位、数字保持短值。只丢一个词、低于地板,不算填完;写成一场戏、一段台词或「以后会怎样沦陷」,算写过了,收回到事实。
|
||||||
6. 句子级字段写到规则 `地板` 规定的可感知程度;词条、档位、数字可以短。句子级字段只丢一个词不算填完。
|
6. 命门、突破口、限制类字段写失效条件:什么事实一发生,这项依仗就变了。不写征服过程。外貌写能认出的年龄感、衣服和一处外形,不写尚未发生的身体反应。
|
||||||
7. 分级字段只填档位短值(如「金丹」);表象留在规则的分级表现里,不复制进每条记录。
|
7. 分级字段只填档位短值。表象留在规则的分级表现里,不抄进记录。舞台、机制、美学里已有的句子不在记录里再写一遍。
|
||||||
8. 无规则只写用户点名的那一条,字段保持很少;不借机发明完整人设合同。
|
8. 无规则只写用户点名的那一条,字段保持很少。
|
||||||
9. 用户已写明的专名、关系、结果保持原样,只做厚度扩写;未写字段从这些材料延伸。
|
9. 给人读的键和专名用中文。英文专名音译,不把英文原名留在字段值里。外壳的 schema、brief、mount 保持既有键。
|
||||||
10. 给人读的键和专名用中文。材料里的英文名音译成中文,不把英文原名留在字段值里。外壳的 schema、brief、mount 保持既有键。
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -68,46 +68,56 @@ feeds: gm
|
|||||||
"规则": "所依生成规则的中文名;无规则时省略",
|
"规则": "所依生成规则的中文名;无规则时省略",
|
||||||
"名称": "这条记录的中文名",
|
"名称": "这条记录的中文名",
|
||||||
"记录": {
|
"记录": {
|
||||||
"字段名": "按规则字段或极简内容填写的值"
|
"字段名": "按规则填到地板的事实;档位与数字保持短值"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON。`记录` 是单个对象;规则为同批时写成至少 2 项的对象数组,每项带姓名或名称。`名称` 用中文,并与这条记录的主名称一致。
|
1. 合法 JSON。`记录` 是单个对象;同批时写成至少 2 项的对象数组,每项带姓名或名称。`名称` 用中文,并与这条记录的主名称一致。`mount` 固定 `["world-simulator"]`。
|
||||||
2. 有规则时 `记录` 的键必须来自该规则字段,不得增删。
|
2. 有规则时 `记录` 的键必须来自该规则字段,不得增删。
|
||||||
3. 无规则时省略 `规则`,`记录` 只含用户给出的少量简单字段。
|
3. 每个句子级字段停在地板:可核对的事实,不附场面、台词或预告。分级只填短值。
|
||||||
4. 字段名和专名用中文;英文专名音译为中文。
|
4. 无规则时省略 `规则`,`记录` 只含用户给出的少量简单字段。
|
||||||
5. 不在产物正文写自评、追问、开放问题或合同草稿。
|
5. 字段名和专名用中文;英文专名音译。正文不含自评、追问、合同草稿或规则条文。
|
||||||
6. `mount` 固定为 `["world-simulator"]`。
|
|
||||||
|
|
||||||
## score
|
## score
|
||||||
|
|
||||||
```score
|
```score
|
||||||
工作流符合度:
|
工作流符合度:
|
||||||
- 5:严格按规则填写工作流推进;字段、池选取、依赖与自检都落实;句子级字段达到地板;同批各条互指一致;无规则路径则字段简单够用且不越权发明合同。
|
- 5:按规则字段与工作流填写;句子级达到地板并停在事实;同批互指一致;用户已定内容未改。
|
||||||
- 3:大体按工作流,但有一处阶段跳跃、依赖未咬合,或某句子级字段明显低于地板。
|
- 3:大体按工作流,但有一处低于地板,或有一句在预写场面。
|
||||||
- 1:忽略工作流;乱增字段;句子级只填一个词;同批互指对不上;或把用户已定内容改掉。
|
- 1:乱增字段、忽略地板写成整场戏、同批对不上,或改掉用户已定内容。
|
||||||
```
|
```
|
||||||
|
|
||||||
## checklist
|
## checklist
|
||||||
|
|
||||||
```checklist
|
```checklist
|
||||||
- [ ] 有规则则按字段与填写工作流执行;无规则则只是一条字段很少的具名内容?
|
- [ ] 有规则则键与工作流一致;无规则则只是一条字段很少的内容?
|
||||||
- [ ] 一份产物只有一条记录,名称是中文?
|
- [ ] 一份产物一条记录,名称是中文?
|
||||||
- [ ] 同批规则已一次写齐,各条互指的专名与关系一致?
|
- [ ] 同批已一次写齐,互指的专名与事由一致?
|
||||||
- [ ] 句子级字段达到地板?分级字段只填档位短值?
|
- [ ] 句子级字段达到地板且停在事实,没有场面或预告?
|
||||||
- [ ] 记录键值合法,用户已定专名未改写,英文专名已音译?
|
- [ ] 分级只填短值;硬门槛对不上时已进 gap 而不是写违规记录?
|
||||||
- [ ] 未改合同?
|
- [ ] 用户已定专名未改,英文专名已音译?
|
||||||
- [ ] 产物正文不含评分、诊断、追问?
|
- [ ] 正文无评分、诊断、追问?
|
||||||
```
|
```
|
||||||
|
|
||||||
## examples
|
## examples
|
||||||
|
|
||||||
```examples
|
```examples
|
||||||
好:规则一次生成=同批、关系=开局仇敌二人 → 一次写齐 2 条,甲的仇敌字段写乙与夺位之事,乙的写甲与同一件事,两条对得上。
|
好:
|
||||||
坏:记录里加了规则没有的字段(规则无「性别」却写进去)。
|
- 同批仇敌二人:一次写齐。甲的关系写乙与夺位之事,乙写甲与同一件事。
|
||||||
坏:规则要求句子级的「气质」只填「阴郁」,没达到地板。
|
- 外貌地板是年龄感、一套衣服、一处外形:写「三十四岁,黑套裙,金丝眼镜,坐在桌沿按着档案」。不写被制服后的神态。
|
||||||
坏:同批的仇敌二人分两轮各写各的,互指的名字和事由对不上。
|
- 命门:写「三份签章权被公开或被上层改掉,高位当天失效;失效前她加价,失效后改口说补件是自愿」。不写一场惩戒。
|
||||||
|
- 阶层填「T2·豪门顶流」。不把分级表现里的表象再抄进记录。
|
||||||
|
|
||||||
|
缺料时:
|
||||||
|
- 没有可依的规则,又不止一条简单具名内容 → 先写生成规则。
|
||||||
|
- 材料过不了规则的年龄或禁止项 → 不填成违规记录,进 `self_score.gap`。
|
||||||
|
|
||||||
|
偏了就改回:
|
||||||
|
- 命门写成完整征服过程 → 收成「什么事实让这项依仗失效」。
|
||||||
|
- 外貌后半段写成身体反应 → 删到能认出的外形为止。
|
||||||
|
- 把规则提示里的方向句原样贴进字段 → 改成这一条自己的事实,仍落在该范围内。
|
||||||
|
- 记录里重写「古武核威慑」→ 删掉,留在世界法则;「五阶段崩解」这类全员反应链不写,这一条怎么破防由她的性格与命门推出。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ feeds: design_only
|
|||||||
2. 禁止重写上游长文;只写顺序与投影。
|
2. 禁止重写上游长文;只写顺序与投影。
|
||||||
3. 每轮槽:order 0=该槽固定人设;其后按「稳→对话.历史→易变」软排。
|
3. 每轮槽:order 0=该槽固定人设;其后按「稳→对话.历史→易变」软排。
|
||||||
4. 主世界层必须含 ref=`对话.历史`(默认 projection=summary);转述与旁观维护默认不含历史。
|
4. 主世界层必须含 ref=`对话.历史`(默认 projection=summary);转述与旁观维护默认不含历史。
|
||||||
5. 主世界层应挂「设计.叙事指南」「设计.故事推进」(工序编排则为「设计.叙事指南与故事推进」)等 mount 到 gm 的块;有「设计.随机范围整理」则挂 gm(summary 即可);转述只读本轮正文(输出.用户展示)+ 文风/壳。
|
5. 主世界层应挂「设计.叙事指南」「设计.故事推进与交互范式」(旧档仍挂「设计.故事推进」;工序编排则为「设计.叙事指南与故事推进」)等 mount 到 gm 的块;有「设计.随机范围整理」则挂 gm(summary 即可);转述只读本轮正文(输出.用户展示)+ 文风/壳。
|
||||||
6. 旁观维护在回合末:须含「输出.用户展示」与变量合同,不含历史。
|
6. 旁观维护在回合末:须含「输出.用户展示」与变量合同,不含历史。
|
||||||
7. 越稳越靠前;本轮输入/真值/正文通常在历史之后。
|
7. 越稳越靠前;本轮输入/真值/正文通常在历史之后。
|
||||||
8. 同一内容可挂多槽。
|
8. 同一内容可挂多槽。
|
||||||
@@ -96,7 +96,7 @@ feeds: design_only
|
|||||||
"inserts": [
|
"inserts": [
|
||||||
{ "order": 0, "ref": "worker.persona", "projection": "fixed" },
|
{ "order": 0, "ref": "worker.persona", "projection": "fixed" },
|
||||||
{ "order": 1, "ref": "设计.舞台骨架", "projection": "summary" },
|
{ "order": 1, "ref": "设计.舞台骨架", "projection": "summary" },
|
||||||
{ "order": 2, "ref": "设计.实现机制", "projection": "summary" },
|
{ "order": 2, "ref": "设计.世界法则", "projection": "summary" },
|
||||||
{ "order": 3, "ref": "设计.变量设计与更新规则", "projection": "summary" },
|
{ "order": 3, "ref": "设计.变量设计与更新规则", "projection": "summary" },
|
||||||
{ "order": 4, "ref": "设计.生成规则", "projection": "summary" },
|
{ "order": 4, "ref": "设计.生成规则", "projection": "summary" },
|
||||||
{ "order": 5, "ref": "设计.叙事指南与故事推进", "projection": "summary" },
|
{ "order": 5, "ref": "设计.叙事指南与故事推进", "projection": "summary" },
|
||||||
|
|||||||
@@ -7,52 +7,49 @@ name: 生成规则
|
|||||||
id: generation-rules
|
id: generation-rules
|
||||||
artifact: 设计.生成规则
|
artifact: 设计.生成规则
|
||||||
declaration: >
|
declaration: >
|
||||||
为需要统一生成的一类对象写出合同:名称、用途、字段、池或提示约束、填写工作流;不填最终实例;可按对象反复。
|
为一类要统一生成的对象写填写合同:名称、用途、字段、池或提示、填写工作流。不填最终实例。每个产物一条规则。
|
||||||
when: |
|
when: |
|
||||||
要落两份或以上同类对象时使用。
|
要落两份或以上同类对象时使用。
|
||||||
只落一份,但字段多、结构重要,或需要先审字段和填写工作流时也使用。
|
只落一份,但字段多、结构重要,或需要先审字段和填写工作流时也使用。
|
||||||
不因开场后是否继续按同一合同生成而改道。每个产物只写一条规则。
|
不因开场后是否继续按同一合同生成而改道。
|
||||||
when_not: |
|
when_not: |
|
||||||
只有一条、字段很少、也不承载玩法的具名内容,才不建规则。这种内容通常也不必落具体实例。
|
只有一条、字段很少、也不承载玩法的具名内容 → 不建规则,通常也不落具体实例。
|
||||||
一张有节点和连接的结构(地图、升级路径、关系网)→ 拓扑图谱。
|
一张有节点和连接的结构 → 拓扑图谱。
|
||||||
跨轮真值 → 变量设计;舞台格局与硬法则 → 舞台骨架 / 实现机制。
|
跨轮真值 → 变量设计。舞台格局与硬法则 → 舞台骨架 / 世界法则;本步只短引用。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力只交生成合同:名称、用途、字段、池或提示、填写工作流。
|
本步交生成合同,供具体实例与游玩期按同一字段填写。
|
||||||
不填最终 records,不重写世界百科,不改变量公式与剧情推进。
|
同一条约束只放一个槽:填充要求、提示、地板、分级表现不换壳复述。
|
||||||
|
地板写最低要让人看见或碰到什么,不写可照抄的场面。价目、境界全文、台上格局已在别处时,本步引用名称,不重抄表。
|
||||||
feeds: gm,auditor
|
feeds: gm,auditor
|
||||||
```
|
```
|
||||||
|
|
||||||
## opening
|
## opening
|
||||||
|
|
||||||
```opening
|
```opening
|
||||||
先说这套规则生成什么、用在什么场合。只有一条字段很少的具名内容,通常不必建规则,也不必落具体实例。
|
先说这套规则生成什么、用在什么场合。只有一条字段很少的具名内容,通常不必建规则。
|
||||||
```
|
```
|
||||||
|
|
||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
为单一对象族写出一份生成合同,写入 `设计.生成规则#中文规则名`。先写名称与用途,再按用途推出字段与每项填充要求;描写类字段写明地板,标签看不懂的分级另写分级表现;需要封闭选取的字段绑池,更适合一句话约束的字段写提示;最后写填写工作流(含依赖、自检、反模式),供具体实例与游玩期动态生成共用。
|
为单一对象族写一份生成合同,写入 `设计.生成规则#中文规则名`。先写名称与用途,再按用途推出字段:每项用 `填充要求` 写清填什么、为何需要。取值默认用提示约束;能数完且必须全员一致的封闭集合才建池。描写类字段另给一句 `地板`(最低可感知到什么)。标签本身看不懂的分级才写 `分级表现`。最后写填写工作流,只保留真正改变填写顺序的阶段。
|
||||||
|
|
||||||
每个产物只含一条规则。材料足够便直接成稿;真正会改合同的缺口(生命周期、必填字段、单条/同批关系)交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
同一事实只写一次。`提示` 只在填充要求没写清范围时补范围;`地板` 不重复填充要求,也不写示范句。舞台骨架、世界法则或拓扑图谱里已有的格局、法则、阶梯和价目,在用途或相关填充要求里点名引用。
|
||||||
|
|
||||||
|
每个产物一条规则。材料够就成稿。会改合同的缺口(生命周期、必填字段、单条还是同批)进 `self_score.gap`。正文只含合同 JSON,不含评分、诊断、追问或最终记录。
|
||||||
```
|
```
|
||||||
|
|
||||||
## principles
|
## principles
|
||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 用途决定字段:每个字段都要能回答「对这个用途是否必要」;女 NPC 不写性别;药水要写清效果参数,不要只写外观;怪物这类要接战斗的,等级、攻击骰等玩法接口不能漏。
|
1. 用途决定字段。每个字段都要能回答「这个用途是否必要」。女 NPC 不写性别;药水写效果参数;要接战斗的写等级、攻击骰等接口。同义字段只留一个。
|
||||||
2. 字段不多不少、互不重叠:资产与存款这类同义字段只留一个。
|
2. 取值三类:自由;池选取(填写方按合理度选);提示约束。默认提示约束。姓名、绰号、能力概念、外貌、气质不建池,也不在提示里列一串冒充穷举。怕生成雷同且会反复生成时,`提示` 写 2~6 个「方向名:一句概括」,不挂作品名、不堆例句。
|
||||||
3. 取值分三类:自由生成;从池中选取(填写方按合理度选,不是伪随机程序);提示约束(如「现代都市中正常的职业」)。默认优先提示约束;只有能数完、必须全员一致的封闭集合(阶层、强度档、武器种类)或分阶段选型时才建池。
|
3. 描写类字段的 `地板` 是最低栏:写到能看见、听见或碰到什么,或对手能抓住什么空隙。一句话。词条、档位、数字、骰子不写地板。地板不是小场景,不写「下一句怎么说」「手怎么放」。
|
||||||
4. 姓名、绰号、能力概念、外貌、气质写不完:用自由或提示约束,不建池,也不在填充要求或提示里列一串可选值冒充穷举。要反复生成又怕雷同时,`提示` 可写 2~6 个「方向名:一句概括」划多样的范围;方向不挂作品名、不堆例句。
|
4. 标签看不懂、且还没有拓扑标尺的分级,才写 `分级表现`:每档一句概括,加一句能与相邻档分开的表象。实例只填档位短值。社会阶梯、境界、价目、兑换等级已在拓扑图谱时,填充要求点图名,不另造一套数,也不把全表抄进 `分级表现`。
|
||||||
5. 描写类字段(外貌、能力现象、限制、互动手感等)写 `地板`:最低要写到读者能看见、听见或碰到什么,对手能抓住什么空隙。词条、档位、数字、骰子可以短,不写地板。规则里出现的示例必须达到地板,不用「气质:阴郁」「能力:时空斩」这种单标签当示例。
|
5. 几面拼成一格时,配方写在该字段的 `填充要求`:哪几面、每面写什么。字段值仍是一段扁平文字。
|
||||||
6. 标签本身看不懂的分级(境界、品阶、强度档、好感档)另写 `分级表现`:每档一句概括 + 一段可感知表象,相邻档必须能分清。实例字段只填档位短值,叙事按此表展开。
|
6. 工作流按填写顺序写短:先定会约束后面的项,再填其余。用不到的阶段不写。会反复生成时,自检含去重(不撞名、不撞核心特征)。`反模式` 写错在哪;需要时加半句反例,不写一场戏。
|
||||||
7. 外貌、服装、仪式等几面拼成一格的字段,在该字段 `填充要求` 里写配方:用哪几面、每面写什么、怎么拼成一段。字段仍是一个扁平值,不拆嵌套对象,也不为每一面各建池。
|
7. 生命周期三选一:`仅游玩期`、`只预生成`、`预生成并游玩期`。`数量` 是总共几条;`一次生成` 看记录是否必须互指、对立或同场。有则同批,关系只写谁对谁;彼此独立则单条。总共 5 个且互不改写,仍是单条。
|
||||||
8. 填写工作流按重要性推进:先锚定会约束后续的选项,再填血肉;阶段、依赖、自检、反模式都服务于「怎么填对」,写短写准。会反复生成的规则,自检清单要含去重:与已有记录不撞名、不撞核心特征。
|
8. 共享的台上格局与既成法则已有则短引用;没有则只写填写时必须守的最短条款,缺口进 `self_score.gap`。给人读的键和专名用中文;英文专名音译。外壳的 schema、brief、mount 保持既有键。
|
||||||
9. 生命周期三选一:`仅游玩期`(创作期不预填)、`只预生成`(预填完即可丢掉规则)、`预生成并游玩期`(既写种子,开场后仍可再生成)。选哪一档不改变「要不要先写这份合同」。
|
|
||||||
10. `只预生成` 为中间层;另两类为最终层,挂主世界层与旁观。
|
|
||||||
11. `数量` 与 `一次生成` 是两件事:`数量` 是总共几条;`一次生成` 看记录之间有没有必须一起钉死的关系(互指、对立、同场),不看总数。有 → 同批,`关系` 只写谁对谁;彼此独立 → 单条。「总共 5 个」不等于同批。
|
|
||||||
12. 多张名片共享的台上格局、世界状况、既成法则归舞台骨架或实现机制:已有则在 `用途` 或 `填充要求` 里短引用;没有则只写填写时必须守的最短条款,缺口交 `self_score.gap`。不在规则里重抄「共通前提」长文,也不借 `关系` 写前史与社会律。
|
|
||||||
13. 不输出最终实例记录。
|
|
||||||
14. 给人读的键和专名用中文。材料里的英文名音译成中文。外壳的 schema、brief、mount 保持既有键。
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -65,69 +62,58 @@ feeds: gm,auditor
|
|||||||
"mount": ["world-simulator", "auditor"],
|
"mount": ["world-simulator", "auditor"],
|
||||||
"稳变": "stable",
|
"稳变": "stable",
|
||||||
"正文": {
|
"正文": {
|
||||||
"名称": "人话规则名,如「末世可征服 NPC」",
|
"名称": "人话规则名",
|
||||||
"用途": "这套规则在本局用来生成什么、服务哪类游玩反馈",
|
"用途": "生成什么、服务哪类反馈;已有舞台或机制则点名",
|
||||||
"规则名": "中文短名,与名称对应且稳定",
|
"规则名": "中文短名,与名称对应",
|
||||||
"生命周期": "仅游玩期|只预生成|预生成并游玩期",
|
"生命周期": "仅游玩期|只预生成|预生成并游玩期",
|
||||||
"数量": {
|
"数量": {
|
||||||
"模式": "固定|区间|看情况",
|
"模式": "固定|区间|看情况",
|
||||||
"值": 1,
|
"值": 1,
|
||||||
"最小": 1,
|
"最小": 1,
|
||||||
"最大": 1,
|
"最大": 1,
|
||||||
"决定规则": "contextual 时说明如何决定条数"
|
"决定规则": "看情况时说明如何决定条数"
|
||||||
},
|
},
|
||||||
"一次生成": {
|
"一次生成": {
|
||||||
"模式": "单条|同批",
|
"模式": "单条|同批",
|
||||||
"关系": "同批必填:本批必须一起钉死的互指;单条写彼此独立"
|
"关系": "同批:谁对谁必须一起钉死;单条:彼此独立"
|
||||||
},
|
},
|
||||||
"字段": [
|
"字段": [
|
||||||
{
|
{
|
||||||
"名": "顶层字段名",
|
"名": "顶层字段名",
|
||||||
"填充要求": "填什么、填到多厚、为何本用途需要它;几面拼一格的写配方(哪几面、各写什么)",
|
"填充要求": "填什么、为何需要;几面拼一格时写配方",
|
||||||
"取值方式": "自由|池选取|提示约束",
|
"取值方式": "自由|池选取|提示约束",
|
||||||
"绑定池": "池选取时填写池名称;其它省略",
|
"绑定池": "仅池选取时写池名称",
|
||||||
"提示": "提示约束时写可执行的一句范围,或 2~6 个「方向名:一句概括」;其它省略",
|
"提示": "仅提示约束且填充要求未写清范围时:一句范围,或 2~6 个「方向名:一句概括」",
|
||||||
"地板": "描写类字段写:最低填到什么可感知程度;词条、档位、数字省略"
|
"地板": "仅描写类:一句最低可感知标准"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"池": [
|
"池": [
|
||||||
{
|
{
|
||||||
"名称": "与字段绑定的人话名",
|
"名称": "与字段绑定的人话名",
|
||||||
"说明": "何时从中选取、选取口径",
|
"说明": "何时选取、按什么合理度",
|
||||||
"条目": ["封闭可选项;只列必须一致的有限集合"]
|
"条目": ["封闭可选项"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"分级表现": [
|
"分级表现": [
|
||||||
{
|
{
|
||||||
"字段": "标签看不懂的分级字段名;无此类分级则省略整个键",
|
"字段": "标签看不懂的分级字段名;无则省略整个键",
|
||||||
"档位": [
|
"档位": [
|
||||||
{
|
{
|
||||||
"值": "档位短值,与实例所填一致",
|
"值": "与实例所填一致的短值",
|
||||||
"概括": "一句:这档能做到什么量级",
|
"概括": "一句:这档的量级",
|
||||||
"表象": "看见、听见、碰到什么;须能和上下档分清"
|
"表象": "一句:与相邻档可分开的可见差别"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"填写工作流": {
|
"填写工作流": {
|
||||||
"工作流": {
|
"工作流": {
|
||||||
"阶段1 骨架锚定": "先选哪些池/字段、定下什么内核",
|
"阶段1": "先定什么"
|
||||||
"阶段2 灵魂注入": "下一步推什么,如何与上一阶段咬合",
|
|
||||||
"阶段3 血肉填充": "姓名、外貌等如何服务已定内核",
|
|
||||||
"阶段4 立场定调": "可选;最后定关系或立场类字段"
|
|
||||||
},
|
},
|
||||||
"依赖关系": [
|
"依赖关系": ["字段A → 字段B:约束"],
|
||||||
"字段A → 字段B:约束说明"
|
"自检清单": ["填完必须能核对的一点"],
|
||||||
],
|
|
||||||
"自检清单": [
|
|
||||||
"填完后必须能回答的关键问题"
|
|
||||||
],
|
|
||||||
"反模式": [
|
"反模式": [
|
||||||
{
|
{ "名": "短名", "说明": "错在哪", "反例": "可选半句" }
|
||||||
"名": "短名",
|
|
||||||
"说明": "为什么错",
|
|
||||||
"反例": "可选一句"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,65 +121,62 @@ feeds: gm,auditor
|
|||||||
```
|
```
|
||||||
|
|
||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON;每个产物只描述一条规则,禁止 `rules` 数组堆多条。
|
1. 合法 JSON;每个产物一条规则,不写 `rules` 数组。`mount` 固定 `["world-simulator", "auditor"]`。
|
||||||
2. `字段` 由用途推出;可省略的字段不写;重叠字段合并。
|
2. 字段由用途推出;重叠合并;可省略的键不写。池选取必须对应到某个池;无池则 `池` 为 `[]`。姓名、绰号、能力概念、外貌、气质不绑池。
|
||||||
3. `池` 可 `[]`;池选取字段必须能对应到某个池;提示约束字段必须有 `提示`。姓名、绰号、能力概念、外貌、气质不绑池。
|
3. 同一约束只出现在一个槽。`提示` 与 `填充要求` 同义则省略 `提示`。`地板` 与填充要求同义、或写成场面/台词,则改成一句可感知标准或省略。
|
||||||
4. `填写工作流` 必含 `工作流`;`依赖关系` / `自检清单` / `反模式` 为子字段,无则 `[]`,不要拆成正文顶层并列大段。
|
4. `填写工作流` 必含 `工作流`;依赖、自检、反模式无则 `[]`。阶段按实际顺序命名,不凑满四段。
|
||||||
5. `一次生成` 为同批时关系必填且本批至少 2 条;为单条时一次只交 1 条。
|
5. 同批时关系必填且本批至少 2 条;单条一次只交 1 条。字段值是扁平文字,配方写在 `填充要求`。
|
||||||
6. 字段值为扁平可填内容;不要嵌套对象当字段值契约。几面拼一格的配方写在 `填充要求`。
|
6. 没有难懂分级则省略 `分级表现`。不在正文写最终记录、价目全表、百科、评分或追问。`只预生成` 为中间层;另两类为最终层。字段名与专名用中文。
|
||||||
7. 描写类字段必须有 `地板`;规则里任何示例都要达到地板。标签看不懂的分级必须在 `分级表现` 写齐每档;没有此类分级则省略 `分级表现`。
|
|
||||||
8. 不在产物正文写自评、追问、开放问题或最终记录。
|
|
||||||
9. `只预生成` 时程序记为中间层;`仅游玩期` / `预生成并游玩期` 记为最终层。
|
|
||||||
10. 字段名和专名用中文;英文专名音译为中文。
|
|
||||||
|
|
||||||
## score
|
## score
|
||||||
|
|
||||||
```score
|
```score
|
||||||
字段妥当:
|
字段妥当:
|
||||||
- 5:字段由用途推出,不多不少、不重叠;该有的玩法/效果接口齐全,该省略的已省略;描写类字段有达到可感知的地板,难懂分级有分级表现。
|
- 5:字段由用途推出,不多不少;描写地板是一句可感知标准;难懂分级能与邻档分开。
|
||||||
- 3:大体可用,但有 1 个冗余、重叠或明显缺失字段,或个别描写字段地板含糊、某档分级只有阶名。
|
- 3:大体可用,但有一处冗余、缺失,或地板/提示在复读填充要求。
|
||||||
- 1:通用名片乱塞,或关键效果/接口字段缺失;描写字段与示例都是单标签。
|
- 1:关键接口缺失,或地板、提示、反例写成场面。
|
||||||
|
|
||||||
池与提示:
|
池与提示:
|
||||||
- 5:只有封闭集合用池,不可穷举项用提示或自由;提示具体可执行,方向式提示以「方向名:一句概括」为主、不挂作品名。
|
- 5:封闭集合用池,其余用提示或自由;提示是范围或方向名,不堆例句。
|
||||||
- 3:大体合理,但个别字段误用/漏用池,或池过泛、提示含糊。
|
- 3:个别字段误用池,或提示含糊。
|
||||||
- 1:为姓名、气质、能力等建词池,大段假穷举,或本该一句话提示却做成巨型词表。
|
- 1:为姓名、气质、能力建词池,或提示变成例句库。
|
||||||
|
|
||||||
填写工作流:
|
填写工作流:
|
||||||
- 5:阶段简洁有力,依赖与自检/反模式能引导按重要性推进,反复生成时含去重,不臃肿。
|
- 5:阶段只含会改变顺序的步骤,自检抓得住,反复生成时含去重。
|
||||||
- 3:有阶段但顺序或依赖不清,或清单偏长却抓不住关键咬合。
|
- 3:有阶段但依赖不清,或清单偏长。
|
||||||
- 1:无工作流,或与字段脱节,无法指导动态生成。
|
- 1:无工作流,或与字段脱节。
|
||||||
```
|
```
|
||||||
|
|
||||||
## checklist
|
## checklist
|
||||||
|
|
||||||
```checklist
|
```checklist
|
||||||
- [ ] 名称与用途已写清?
|
- [ ] 名称、用途、生命周期已写清,且只含一条规则?
|
||||||
- [ ] 字段由用途推出,无冗余重叠、无关键缺失?
|
- [ ] 字段由用途推出,无同义字段?
|
||||||
- [ ] 池与提示分工正确?姓名、绰号、能力、外貌、气质没有建池?
|
- [ ] 填充要求、提示、地板没有同一句写两遍?
|
||||||
- [ ] 描写类字段有地板,示例不是单标签?难懂分级有分级表现?
|
- [ ] 地板是一句可感知标准,不是场面或台词?
|
||||||
- [ ] 几面拼一格的字段在填充要求里写了配方?
|
- [ ] 池只绑封闭集合;难懂分级才有分级表现,且未另造一套已有标尺?
|
||||||
- [ ] 填写工作流含子字段:工作流 / 依赖关系 / 自检清单 / 反模式?反复生成时自检含去重?
|
- [ ] 工作流阶段没有凑数;同批/单条按关系而非总数?
|
||||||
- [ ] 生命周期已钉?一次生成按关系定,而非按总数?
|
- [ ] 格局与法则是短引用,没有重抄舞台或机制?
|
||||||
- [ ] 共享格局只引用舞台骨架,没有重抄共通前提?
|
- [ ] 正文无最终实例、评分或追问?
|
||||||
- [ ] 未输出最终实例、百科长文、评分或追问?
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## examples
|
## examples
|
||||||
|
|
||||||
```examples
|
```examples
|
||||||
好:
|
好:
|
||||||
- 武功典籍:字段为名字 / 效果 / 阶层。阶层池选取封闭档位,另写 `分级表现`,每阶一句概括 + 可感知表象;名字与效果用提示约束。
|
- 武功典籍:字段为名字、效果、阶层。阶层走封闭池,分级表现每档一句概括加一句与邻档不同的可见差别。名字与效果用提示,不建池。
|
||||||
- 女主规则:不设「性别」;池为 `[]`;内在冲突自由写,地板是「能在一场戏里演出来的两难」。
|
- 外貌:填充要求写配方「轮廓一句 + 质感一句 + 一个旁人会记住的细节」。地板一句:「能认出年龄感、一套衣服、一处身体曲线。」不写被征服时的神态。
|
||||||
- D&D 怪物:名称、等级、攻击骰齐全;气质用提示约束,地板写到一个可观察的习性。
|
- 实权:填充要求写「相对主角有没有二十四小时内生效的赏罚;没有则写无实权落差」。提示与地板不把这句话再写一遍。
|
||||||
- 外貌:`填充要求` 写配方「轮廓一句 + 质感一句 + 一个旁人会记住的细节」,拼成扁平的一段「外貌」。
|
- 已有 10 阶价目拓扑:填充要求写「强度取该图的阶名,价格按该档已写基价」。不在本规则重列全表。
|
||||||
- 互为仇敌的两名开局 NPC → 一次生成=同批,关系写谁夺了谁的什么;可互换的酒馆路人 → 单条;总共要 5 个但彼此无关 → 数量 5、一次生成单条。
|
- 互为仇敌的两名开局 NPC:一次生成同批,关系写谁夺了谁的什么。彼此无关的五个路人:数量 5,一次生成单条。
|
||||||
|
|
||||||
坏:
|
缺料时:
|
||||||
- 为气质、绰号、能力各建一个 8~20 词的池,填出来全是标签。
|
- 生命周期或同批关系定不下来 → `self_score.gap`。
|
||||||
- 提示里的方向每条挂作品名或例句,变成假选项库。
|
- 台上格局还没有 → 填充要求只写必须守的最短条款,并 gap 指明缺舞台或机制。
|
||||||
- 示例写「气质:阴郁」「能力:时空斩」,地板被示例教坏。
|
|
||||||
- 阶层只写「A」「金丹」,没有分级表现,叙事时无从下笔。
|
偏了就改回:
|
||||||
- 把社会格局、前史写成「共通前提」长文塞进规则,而不是引用舞台骨架。
|
- 填充要求、提示、地板三份都在讲「二十四小时内失去什么」→ 留在填充要求,另两槽删掉或只补真正的缺口。
|
||||||
- 女主规则塞「性别」;怪物规则只有名字和性格,没有等级、攻击骰。
|
- 地板写成「她会哭着改口、手按在锁骨上」→ 改成「写出哪份文件作废」。
|
||||||
|
- 分级表象写成一段调教 → 改成一句与邻档不同的可见差别。
|
||||||
|
- 反例写成完整小剧场 → 留半句错法,或只留说明。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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,53 +1,63 @@
|
|||||||
# 实现机制
|
# 世界法则
|
||||||
|
|
||||||
## meta
|
## meta
|
||||||
|
|
||||||
```meta
|
```meta
|
||||||
name: 实现机制
|
name: 世界法则
|
||||||
id: mechanism
|
id: mechanism
|
||||||
artifact: 设计.实现机制
|
artifact: 设计.世界法则
|
||||||
declaration: >
|
declaration: >
|
||||||
保存世界中稳定成立的因果法则,供主世界层推演事件后果、剧情变化和人物反应;一次可写多条共同作用的机制。
|
写出这个世界相对熟悉基底的根本变造及其连锁后果,以及金手指、玩法系统的固定规则;
|
||||||
|
供主世界层判断「在这个世界里,这件事意味着什么、会引来什么」。
|
||||||
when: |
|
when: |
|
||||||
本局存在拿掉后会改变故事骨架的既成法则,且后续需要据此推演环境变化、事件后果或人物反应时使用。
|
用户给出了与现实或原作不同的世界前提(超常力量、社会常态、禁忌、缺失之物),且它会持续改变人们怎样看待、处理玩家要做的事时使用。
|
||||||
|
本局有金手指或玩法系统,需要固定的触发条件与总则时使用。档位全表、价目、社会阶梯归拓扑图谱。
|
||||||
when_not: |
|
when_not: |
|
||||||
只有熟悉常识,没有特殊因果法则 → 不使用。 「特殊」只是身份或开场处境、能被舞台骨架、具体实例或开场白覆盖(如现实世界里模拟一名普通大学生)→ 不使用。 地点、势力与社会格局 → 舞台骨架;多份人物或要先审字段的名片 → 生成规则,单条字段很少的具名内容才归具体实例;字段合同 → 生成规则。
|
世界与熟悉基底无差别,也没有需要固定规则的能力或系统 → 不使用。
|
||||||
|
只是身份或开场处境 → 主角设定 / 开场白。
|
||||||
|
地点、势力分布与主要场所 → 舞台骨架。
|
||||||
|
社会阶梯、境界、价目、兑换等级的全表 → 拓扑图谱;本步只点图名。
|
||||||
|
某人或某类人对玩家的态度、好感、屈服阶段 → 具体实例 / 生成规则 / 变量设计。
|
||||||
|
怎么写、写多细 → 叙事指南。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力交稳定因果,不预写唯一剧情,也不把所有人物钉成同一种反应。
|
本步只写对整个世界客观成立的规则:变造本身,它在制度、权力、日常里的连锁,触碰它的代价与边界;能力与系统的触发和总则。档表不写在这里。
|
||||||
不解释起源,不扩写世界百科,不制定推进节奏、变量表或检定公式。
|
规则不预定任何人对玩家的反应或结局;人物如何反应由其性格、利益与当前变量推出。
|
||||||
|
舞台骨架的「必要变造」只留一句台上结果,完整连锁写在本步。
|
||||||
feeds: gm
|
feeds: gm
|
||||||
```
|
```
|
||||||
|
|
||||||
## opening
|
## opening
|
||||||
|
|
||||||
```opening
|
```opening
|
||||||
说说这个世界有哪些已经成立、会持续改变事件后果的法则。重点是它如何起作用,以及不同处境和人物会因此面对什么;多条机制可以一起写清。
|
说说这个世界和熟悉的现实(或原作)哪里不一样,这些不同让社会、权力和日常变成了什么样。如果有金手指或玩法系统,说说它的触发条件和总则;整张档表交给拓扑图谱。
|
||||||
```
|
```
|
||||||
|
|
||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
把本局承重的既成因果法则整理为实现机制,写入 `设计.实现机制`。一个产物可以包含多条共同作用的机制;分别写清法则内容、生效方式、因果影响和推演口径,再说明机制之间如何共同作用。
|
把材料整理为 `设计.世界法则`:这个世界对玩家要做的事持什么态度,由哪些规则决定。
|
||||||
|
|
||||||
先在内部对每条候选做移除检验:拿掉后故事骨架是否改变。`缺失后果`只用于筛选,不得写进实际产物。机制应让主世界层能够推演事件怎样发展、不同人物可能怎样反应,但不得指定唯一剧情或统一人物反应。
|
先从美学纲领找出玩家反复要做的那件事(讲黄段子、越级修炼、复制能力、扳倒高位者……),再问:在这个世界里,这件事是什么地位——稀缺、禁忌、违法、被垄断、有代价,还是寻常?给出这个地位的世界前提,就是要写的法则。每条写:
|
||||||
|
1. `法则`:世界里成立什么,何时对谁生效。一到两句,主语是世界或某类人,不是玩家。
|
||||||
|
2. `连锁`:因此世界长成什么样。往下推到制度、权力分布、风俗日常、普通人的常见看法,每条一句,1~4 条。
|
||||||
|
3. `代价与边界`:有人去做相关的事会撞上什么(处罚、排斥、追捕、反噬、争夺),法则在哪里失效。无则省略。
|
||||||
|
4. 法则依赖社会阶梯、境界、价目或兑换等级时,在法则或连锁里点拓扑图名,不抄档表。
|
||||||
|
|
||||||
材料足够便直接成稿;真正会改变法则内容的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
写完核对两件事:删掉所有提到玩家的句子,每条法则仍成立;没有一句替某类人预定了对玩家的态度或结局。
|
||||||
|
|
||||||
|
材料够就成稿;会改变法则或连锁方向的缺口进 `self_score.gap`。产物正文只含机制 JSON,不含评分、诊断或追问。
|
||||||
```
|
```
|
||||||
|
|
||||||
## principles
|
## principles
|
||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 机制是稳定因果:写世界承认什么、何时生效、会带来什么后果。
|
1. 世界给体验以分量,不替体验兑现结果。用户要的感觉(爽、禁忌、反差)归美学纲领;本步写让这种感觉成立的地形:这件事为什么难、为什么稀罕、为什么冒险。写成「某类人都喜欢 / 都会屈服 / 绝不反抗」,就是把愿望写成了法则;改回世界前提,人物反应交给实例与变量。
|
||||||
2. 法则可以是超常前提、约束性公理,也可以是社会性或规范性的既成事实,如“这个世界没有花里胡哨的魔法,只有繁衍到极致的斗气”“这个西幻世界里男尊女卑是常态”“这里以三国杀高手为尊”。同一设定有很多面向,只写后续推演必须承认的那一面。
|
2. 两类来源,按序写:世界变造(与基底不同的物理、超常力量、社会常态、禁忌或缺失)优先且写全;其次是特殊力量规则(金手指、功法、血统:触发、效果、限制)。社会阶梯、境界、价目、兑换等级的全表归拓扑图谱。
|
||||||
3. 身份特殊、开场处境特殊不是法则:能由舞台骨架、具体实例或开场白承担的(如现实世界里的普通大学生),不写成机制。
|
3. 用户描述世界时给出的每一处变造,都要落到某条法则或连锁里,与主角无关的也一样。只写主角身上的力量、漏掉世界本身的不同,是本步最常见的偏法。
|
||||||
4. 内部使用移除检验筛选承重法则;只能回答“更丰富”或“以后也许有用”的候选不纳入。缺失后果不输出。
|
4. 连锁按常识与人性往下推:谁受益、谁吃亏、制度怎样适应、普通人信什么。可以补出用户没说的合理后果,不得与用户已说的冲突;推不准且会改方向的进 gap。
|
||||||
5. 内部按基准世界做最小充分继承,校验过程不写入产物:现实地球沿用现实物理、心理与社会规律;变造地球只在用户已表达或体验依赖处承认变造;具体作品世界优先最近一层作品设定,原作未定义处再参考其底层世界;类型世界只继承与当前体验有关的类型约定;从头创造以显式设定为准,未定义处用最少常识;混合或二创只处理与当前法则有关的继承与变造。
|
5. 用户否定过的方向本身就是法则(如「古武加入不意味着能单凭武力颠覆一切」),写进去,其它条服从它。
|
||||||
候选法则超出基准世界已知可能性时,只有用户材料已明示或体验确实依赖才写入,不暗中变造;拿不准是否在用户设想中,就不写进产物,把这处变造交给 `self_score.gap`。
|
6. 同一事实只出现一次。场所写进舞台骨架,体验句留在美学纲领,遣词与「重点描写什么」归叙事指南。
|
||||||
6. 因果影响写行动可能性、代价、资源与关系怎样改变;推演口径写推演情节和人物反应时必须考虑什么。
|
7. 一条说清就停,不为凑数拆条。
|
||||||
7. 人物反应保持开放:同一机制下,人物可因认知、利益、身份和性格不同而隐藏、合作、利用、抵抗或误判。
|
|
||||||
8. 多条机制可一次生成。分别写清后,再说明它们是前提、叠加、制衡还是反馈循环;没有关系则不硬连。
|
|
||||||
9. 机制不等于题材标签。“灵气复苏”“全民直播求生”必须拆成可推演的生效方式与因果链;题材标签也不能代替“这条法则缺了故事会换”。能指明它支撑美学纲领里哪段体验时,写进 `服务体验`。
|
|
||||||
10. 一句话够用便停止;不补起源史、体系百科、人物卡、剧情大纲或推进节奏。
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -55,100 +65,81 @@ feeds: gm
|
|||||||
```output
|
```output
|
||||||
{
|
{
|
||||||
"schema": "context-fragment.v1",
|
"schema": "context-fragment.v1",
|
||||||
"技能": "实现机制",
|
"技能": "世界法则",
|
||||||
"brief": "一句话说明哪些稳定因果在改变本局世界",
|
"brief": "一句话:这个世界哪些根本不同,让玩家要做的事有了什么分量",
|
||||||
"mount": ["world-simulator"],
|
"mount": ["world-simulator"],
|
||||||
"稳变": "stable",
|
"稳变": "stable",
|
||||||
"正文": {
|
"正文": {
|
||||||
"既成机制": [
|
"既成机制": [
|
||||||
{
|
{
|
||||||
"名称": "简短稳定的人话名",
|
"名称": "简短稳定的人话名",
|
||||||
"法则内容": "世界中稳定成立什么;可以是超常前提、公理,也可以是社会常态或规范",
|
"法则": "世界里成立什么;何时对谁生效",
|
||||||
"服务体验": "一句话说明它支撑美学纲领里的哪段体验;无明确对应则省略",
|
"连锁": ["因此世界在制度、权力、日常或普遍看法上变成什么样"],
|
||||||
"生效方式": [
|
"代价与边界": ["可选;触碰时撞上什么,法则在哪里失效"]
|
||||||
"对谁、在什么条件下、以什么方式起作用"
|
|
||||||
],
|
|
||||||
"因果影响": [
|
|
||||||
"它怎样改变行动可能性、代价、资源、关系或事件后果"
|
|
||||||
],
|
|
||||||
"推演口径": [
|
|
||||||
"推演剧情发展和人物反应时必须结合哪些认知、利益、身份或处境"
|
|
||||||
],
|
|
||||||
"边界或例外": [
|
|
||||||
"确有必要才写;无则省略"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"共同作用": [
|
"共同作用": []
|
||||||
{
|
|
||||||
"涉及机制": ["机制 A", "机制 B"],
|
|
||||||
"关系": "前提|叠加|制衡|反馈循环",
|
|
||||||
"推演影响": "共同作用时会怎样改变事件与人物选择空间"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON;`既成机制` 可含多条承重机制。
|
1. 合法 JSON;`既成机制` 可多条;`mount` 固定 `["world-simulator"]`。
|
||||||
2. `缺失后果`、移除检验和被淘汰候选只用于内部推理,不得进入正文。
|
2. 每条必填 `名称`、`法则`、`连锁`;`代价与边界` 无则整项省略。不写档表、价目全表。
|
||||||
3. 每条机制必须写 `法则内容 / 生效方式 / 因果影响 / 推演口径`;`服务体验` 与 `边界或例外` 可选。
|
3. 正文不出现某人或某类人对玩家的态度、好感、屈服阶段或必然结局;不出现「详写」「重点刻画」一类写法指令。
|
||||||
4. `共同作用` 只写真实存在的机制关系;无则 `[]`。
|
4. `共同作用` 每项一句,只写两条法则相互放大或制约后的世界后果;无则 `[]`。
|
||||||
5. 推演口径提供因果依据,不指定唯一剧情,也不规定所有人物作同一种反应。
|
5. 正文不含评分、诊断、追问或历史百科。
|
||||||
6. 不在产物正文写自评、追问、开放问题、起源百科或检定公式。
|
|
||||||
7. `mount` 固定为 `["world-simulator"]`。
|
|
||||||
|
|
||||||
## score
|
## score
|
||||||
|
|
||||||
```score
|
```score
|
||||||
骨架必要度:
|
世界客观度:
|
||||||
- 5:每条都是拿掉后会改变故事骨架的承重法则,没有常识、装饰或下游细节。
|
- 5:每条对整个世界成立,删去玩家仍成立;无人物态度或结局预设。
|
||||||
- 3:主体承重,但夹有一条作用较弱或可交给其它能力的内容。
|
- 3:主体客观,夹一句「某类人会配合 / 屈服」或一条写法指令。
|
||||||
- 1:只有题材标签或装饰设定,或把身份、开场处境、暗中加入的变造写成法则。
|
- 1:法则主要在描述人物如何回应玩家,或在教如何攻略。
|
||||||
|
|
||||||
因果清晰度:
|
连锁充分度:
|
||||||
- 5:生效条件与影响链清楚,能据此推演环境、事件和关系怎样变化。
|
- 5:用户给出的变造全部落下,且推到制度、权力或日常;主世界层能据此回答「在这个世界里做某事会怎样」。
|
||||||
- 3:法则方向正确,但生效方式或一段关键因果仍需猜测。
|
- 3:变造齐了但连锁只有一层,或漏了一处与主角无关的变造。
|
||||||
- 1:只说“存在某设定”,无法推出后果。
|
- 1:只有题材标签,或只写了主角的力量。
|
||||||
|
|
||||||
推演开放度:
|
承重克制度:
|
||||||
- 5:能支持人物按不同认知、利益、身份和性格作出多种合理反应,而不失去法则约束。
|
- 5:每条拿掉都会改变世界反馈;无场所、名片、文风内容;同一事实只出现一次。
|
||||||
- 3:提供了反应依据,但部分表述暗含单一路线。
|
- 3:夹一条该交给其它能力的内容,或两条互相复读。
|
||||||
- 1:直接预写唯一剧情,或把所有人物规定成同一种反应。
|
- 1:凑数、百科化,或把场所、人物、体验句挤进来。
|
||||||
```
|
```
|
||||||
|
|
||||||
## checklist
|
## checklist
|
||||||
|
|
||||||
```checklist
|
```checklist
|
||||||
- [ ] 每条候选已在内部通过移除检验,且缺失后果未写入产物?
|
- [ ] 用户给出的每一处世界变造都落到了法则或连锁里?
|
||||||
- [ ] 法则内容、生效方式、因果影响与推演口径均可执行?
|
- [ ] 删掉提到玩家的句子,每条仍成立?
|
||||||
- [ ] 多条机制的共同作用已写清,或明确无须关联?
|
- [ ] 没有预定某类人对玩家的态度、屈服阶段或结局?
|
||||||
- [ ] 能支持不同人物作出不同但合理的反应?
|
- [ ] 连锁推到了制度、权力或日常层?
|
||||||
- [ ] 未把身份或开场处境写成法则?超出基准世界的变造都来自用户材料或体验依赖?
|
- [ ] 阶梯、价目、兑换等级只点了拓扑图名,没有抄进本产物?
|
||||||
- [ ] 未写成题材标签、世界百科、剧情大纲或裁定表?
|
- [ ] 无写法指令、场所、人物名片;正文无评分、诊断、追问?
|
||||||
- [ ] 产物正文不含评分、诊断、追问?
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## examples
|
## examples
|
||||||
|
|
||||||
```examples
|
```examples
|
||||||
灵气复苏:
|
没有黄段子的世界:
|
||||||
- 灵气重新进入环境,生物可吸收并强化、异变;浓度、接触方式与修炼方法影响变化速度和可控程度。
|
- 好:法则「这个世界的语言与文化里没有性玩笑;性只在医疗与法律语境里被提起」。连锁「公开谈论性视同猥亵,可被举报处罚」「人们没有接这种话的经验,第一反应多是困惑或惊骇」「极少数地下圈子私下流传,被抓即身败名裂」。代价与边界「说出口的人承担社会性死亡与治安风险;医生、律师在职业语境内例外」。
|
||||||
- 因果上会改变资源价值和既有力量关系;推演人物时结合其认知、资源、身份得失与控制能力,不能默认人人都追求修炼。
|
- 偏:「玩家很爱讲黄段子」「某某很爱听」。前者是体验,归美学纲领;谁会好奇、谁会举报,由各人性格推出。
|
||||||
|
|
||||||
全民直播求生:
|
世界变造写全:
|
||||||
- 可拆为“强制进入求生场”“关键行为公开直播”“观众反馈可进入场内(若本局采用)”等多条机制。
|
- 「阴气偏重,女性习武天然占优」→ 连锁「古武世家上层多为女性」「表世界招聘与言论默认偏向女性,女权乃至种姓式言论常见」「男性高手稀少,罕见的男性天才格外扎眼」。只写主角禁功的突破与反噬、漏掉这条,就是偏了。
|
||||||
- 公开评价与即时生存会共同改变选择代价;人物是否表演、隐藏或利用镜头,应由其是否在乎声誉、是否相信能返回现实等条件推出。
|
- 「古武者能绕开任何安保近身刺杀,但扛不住重武器」→ 连锁「真正掌权的是古武世家或受其庇护者」「古武者不公开对抗国家机器,靠隐秘施压」「市井当古武是传言」。
|
||||||
- 观众若只能观看,就不能推演出打赏资源;边界会直接改变因果链。
|
- 「超级英雄是合法职业,接替了旧时代偶像的商业位置」→ 连锁「经纪公司与广告左右英雄的曝光与资源」「守序多因利益,被逼急可以转做反派,名声不是绝对枷锁」。
|
||||||
|
- 「灵气重回环境」→ 连锁「生物可经接触与修炼强化或异变」「浓度与方法决定速度和可控度」「资源价值与既有力量关系被改写」。
|
||||||
|
|
||||||
好:
|
特殊力量与系统:
|
||||||
- 社会常态类法则“这个西幻世界里男尊女卑是常态”:生效方式写继承、婚配、对外交涉由谁做主;因果影响写女性角色行动的额外代价与绕行渠道;推演口径写人物可能顺从、利用或反抗,不规定人人同一态度。
|
- 复制金手指:法则写触发与总则(对方无感、不被削弱;装备、金钱、智力、寄生体不可复制)。五档效果写成拓扑图谱的一条链;主角设定只点图名。
|
||||||
- “这个世界没有花里胡哨的魔法,只有繁衍到极致的斗气”:只写斗气是唯一超常力量、怎样决定地位与胜负,不展开境界百科。
|
- 跨世界对数等级与价目 → 全表是一张 `path` 拓扑;本步只写「强度与价格按那张图」。生成规则和变量只填阶名。
|
||||||
- 公理“黑暗森林”:一句话钉“暴露坐标即招来打击,文明默认隐藏”;后续情节不能靠对外广播求援收场,不写宇宙社会学论文。
|
|
||||||
|
|
||||||
坏:
|
偏了就改回:
|
||||||
- 现实世界模拟普通大学生,把“主角是普通大学生”写成机制:这是身份与开场处境,交给具体实例或开场白;没有特殊法则就不使用本能力。
|
- 「恶女必经五阶段:自负→惊恐→……→顺从」→ 删。各人怎么破防写在实例性格,进度交给变量。
|
||||||
- 二创里暗中加入原作没有的力量体系(如在无超能力的原作里写入“觉醒异能”):用户未设想的变造不写入,拿不准时交给 `self_score.gap`。
|
- 「养母绝不会对主角动手,只会包容」→ 删。这是人物关系,写在实例与变量。
|
||||||
- 只写“灵气复苏”“末世”等题材标签,没有生效方式与因果链:主世界层无法据此推演任何后果。
|
- 「主角出手必然形成绝对压制」→ 删;用户说过武力不能颠覆一切时,改写成法则「权与钱仍是表世界主轴,武力不能直接换来合法地位与资产」。
|
||||||
|
- 「推演时详写其神态与身体反差」→ 删,写法归叙事指南。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -7,18 +7,20 @@ name: 叙事指南
|
|||||||
id: narrative-guide
|
id: narrative-guide
|
||||||
artifact: 设计.叙事指南
|
artifact: 设计.叙事指南
|
||||||
declaration: >
|
declaration: >
|
||||||
继承美学纲领与交互范式,从文风库选取本局写法,落成可执行的遣词要求、视角执行、段落形态、笔墨落点、少量禁忌与可选写法档;不钉故事怎么往前。
|
从文风库写成遣词、视角执行、段落形态、笔墨落点和少量禁忌。
|
||||||
|
体验句留在美学纲领,人称与描写权限留在故事推进与交互范式。
|
||||||
when: |
|
when: |
|
||||||
已有美学纲领(或等价),但游玩正文仍不知道怎么写:遣词什么要求、视角怎么落笔、笔墨落谁身上、场合是否要换写法档、有哪些绝对不能写。
|
已有美学纲领,但游玩正文还不知道怎么落笔:遣词、视角执行、笔墨落谁身上、场合是否换写法档、绝对不能写什么。
|
||||||
用户主动谈文风、措辞、叙述声音、写法档时也使用。
|
用户主动谈文风、措辞、叙述声音、写法档时也使用。
|
||||||
when_not: |
|
when_not: |
|
||||||
本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。
|
本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。
|
||||||
美学未钉 → 先「美学纲领与交互范式」。
|
美学未钉 → 先「美学纲领」。
|
||||||
用户明确说本局不用文风细则,且美学里已有可执行遣词方向 → 可不使用。
|
用户明确说本局不用文风细则,且美学里已有可执行遣词方向 → 可不使用。
|
||||||
只谈推进、输入用法 → 「故事推进」。单轮字数与篇幅 → 「故事推进」;可见区域 → 「回复呈现」。版式 → 「正文组成」。
|
只谈推进、输入用法、单轮字数 → 「故事推进与交互范式」。可见区域 → 「回复呈现」。版式 → 「正文组成」。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力只交怎么写:选用文风、遣词要求(含叙述声音、视角执行、段落形态)、笔墨落点与代入取向、少量绝对禁忌、可选写法档。
|
本步只交怎么落笔:选用文风、遣词(叙述声音、视角执行、段落形态)、笔墨落点、少量绝对禁忌、有切换才写的写法档。
|
||||||
推进、输入用法、单轮字数与篇幅归故事推进;体验契约、人称与叙事视角的定义归美学纲领与交互范式。
|
体验内核、描写焦点、边界与尺度留在美学纲领,本步点名引用,不改写成第二份体验。
|
||||||
|
人称、可写内心、输入用法、单轮字数归故事推进与交互范式。
|
||||||
feeds: narrator,gm
|
feeds: narrator,gm
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -31,26 +33,26 @@ feeds: narrator,gm
|
|||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
把本局写法整理为叙事指南,写入 `设计.叙事指南`。先继承上游「美学纲领与交互范式」:把 `美学纲领.体验内核`、`呈现要点.描写焦点` 与 `呈现要点.边界与尺度` 改写成做法向句子;`交互范式` 已定的人称与叙事视角不重定义,只在 `遣词.视角` 写怎么执行。再用 `read_library_entry` 从绑定的文风库读取最相关条目(默认 1~2;确需第三种时须写 reason 并标明适用场合),按本局体验本局化,写成可执行的风格推荐与要求。
|
把本局落笔方法整理为叙事指南,写入 `设计.叙事指南`。先读美学的 `体验内核` 和 `描写焦点`,只把焦点里的对象或瞬间点进 `笔墨焦点.详写`;体验句本身不搬过来,也不换说法再写一遍。人称、可写内心以故事推进与交互范式的 `交互范式` 为准,本步在 `遣词.视角` 只写镜头距离和如何不漂人称。
|
||||||
|
|
||||||
产物回答:选了哪些文风、各自用在什么场合;遣词、视角执行、段落形态与笔墨要怎么做;叙述声音与代入取向(推得出才写);只有少数绝对禁忌。除禁忌外,少写“不要怎样”,多写“要怎样”。不写示范段落,不写推进、输入用法或单轮字数。
|
再用 `read_library_entry` 从文风库读取最相关条目(默认 1~2;确需第三种时须写 reason 并标明适用场合)。`风格推荐` 和 `本局要求` 只写节奏、用词层级、句段形态、对话与叙述怎么配。写成台词、角色正在做的场面,或把某类人物当例子演一遍,就改回做法。
|
||||||
|
|
||||||
材料足够便直接成稿;真正会改写法的缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
材料够就成稿。会改落笔的缺口进 `self_score.gap`。产物正文不含评分、诊断或追问。
|
||||||
```
|
```
|
||||||
|
|
||||||
## principles
|
## principles
|
||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 美学写要什么感受,本步写怎么遣词、笔墨落哪。已在美学写清的感受不重问,直接改写成做法:体验内核与描写焦点落成详写对象和写法要求;边界与尺度按美学给的用法写进写法要求或写法档,只把其中真正碰不得的收进禁忌。
|
1. 美学已经写了要什么感受。本步写怎么落笔。`详写` 点名对象或瞬间;一句若是体验内核的同义改写,删掉。`边界与尺度` 里真正碰不得的收成禁忌短句,用法本身留在美学,不整段抄来。
|
||||||
2. 文风库是参考,不是照抄:读取后改成本局可执行要求,并记下适用场合与微调。
|
2. 文风库是参考:读完改成本局可执行的节奏和句段,记下适用场合。不搬库里的范文。
|
||||||
3. 优先 1~2 种文风;确需第三种时,每种都必须有清晰切换场合,禁止无差别堆叠。
|
3. 优先 1~2 种文风。确需第三种时,每种都要有切换场合。
|
||||||
4. 写法用“风格推荐 + 要求”表达:对象、节奏、注意力落点、句段形态都写成要做什么。示范段落不写。
|
4. `风格推荐` 与 `写法要求` 写对象、节奏、注意力落点、句段形态。不写示范段,不写台词,不用「某类角色被怎样」代替做法。
|
||||||
5. 段落形态默认值:用户未提段落形态时,`遣词.写法要求` 写入「完整长自然段叙述,不要单句成段」;用户另定则按用户写。段落形态归本步,单轮字数与篇幅归故事推进。
|
5. 用户未提段落形态时,`遣词.写法要求` 写入「完整长自然段叙述,不要单句成段」;用户另定则按用户写。单轮字数归故事推进与交互范式。
|
||||||
6. 视角只写执行:人称、主要视角、可写内心、不呈现的信息以交互范式为准,本步写落笔做法——镜头距离(贴身 / 中景 / 远观,何时拉近);内心写到哪(只写交互范式允许写内心的一方,其余人物用外显言行与身体反应表现);如何不漂人称(全文固定一种称呼,回忆、转述与内心独白也不换人称)。交互范式未定视角时按美学站位取最贴的写法,并在 gap 写明。
|
6. `遣词.视角` 只写执行:镜头距离(贴身 / 中景 / 远观,何时拉近);内心写到哪(只写交互范式允许写内心的一方,其余用外显言行);如何不漂人称。交互范式未定视角时按美学站位取最贴的写法,并在 gap 写明。
|
||||||
7. `叙述者` 写叙述声音:冷眼旁观、调侃、贴身低语等,以及它对人物的态度。`代入取向` 写笔墨替谁看、读者站在谁那边。两项都从美学站位与体验推出;推不出或与视角执行重复时省略。
|
7. `叙述者`、`代入取向` 能从站位推出、且和视角执行不是同一句时才写;否则省略。
|
||||||
8. 绝对禁忌只留真正碰不得的少数几条;其余偏好都改成正向做法。
|
8. 禁忌只留少数绝对不能写的短句。其余写成正向做法。
|
||||||
9. 写法档是多文风/多调性容器:有切换才写,无切换整项省略。场合名要能指导何时切入,不是具体剧情桥段清单。用户报出「当众折辱」「独处一室」这类具体场合时,判断它要哪种写法,升一档写进对应写法档的适用场合与写法要求(感官密度、节奏、对话长短、详略),场合本身不单列;每档写清切入与切回。
|
9. 写法档只在有切换时写。用户报的具体场合(如当众折辱、独处)升进某一档的适用场合与写法要求,不单列成桥段清单。
|
||||||
10. 不写故事推进、输入用法、单轮字数与篇幅(归故事推进)或版式壳。
|
10. 推进、输入用法、单轮字数、版式不在本步写。
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -59,7 +61,7 @@ feeds: narrator,gm
|
|||||||
{
|
{
|
||||||
"schema": "context-fragment.v1",
|
"schema": "context-fragment.v1",
|
||||||
"技能": "叙事指南",
|
"技能": "叙事指南",
|
||||||
"brief": "一句话:本局正文怎么写",
|
"brief": "一句话:本局正文怎么落笔",
|
||||||
"mount": ["narrator", "world-simulator"],
|
"mount": ["narrator", "world-simulator"],
|
||||||
"稳变": "stable",
|
"稳变": "stable",
|
||||||
"正文": {
|
"正文": {
|
||||||
@@ -67,39 +69,34 @@ feeds: narrator,gm
|
|||||||
{
|
{
|
||||||
"库条目": "style-packs 中的 id 或名称",
|
"库条目": "style-packs 中的 id 或名称",
|
||||||
"适用场合": "默认全文 / 亲密场面 / 战斗反转 等",
|
"适用场合": "默认全文 / 亲密场面 / 战斗反转 等",
|
||||||
"本局要求": "从该条目提炼并改写后的可执行写法要求"
|
"本局要求": "节奏、用词、句段;不写台词和场面"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"遣词": {
|
"遣词": {
|
||||||
"整体取向": "本局叙述应呈现的风格方向",
|
"整体取向": "本局叙述的风格方向,一句",
|
||||||
"叙述者": "可选:叙述声音,如冷眼旁观 / 调侃 / 贴身低语,及它对人物的态度;无则省略",
|
"叙述者": "可选;与视角执行不同才写",
|
||||||
"风格推荐": ["优先采用的节奏、用词层级、句段形态"],
|
"风格推荐": ["节奏、用词层级、句段形态;不写台词"],
|
||||||
"写法要求": [
|
"写法要求": [
|
||||||
"完整长自然段叙述,不要单句成段(用户另定段落形态则改写)",
|
"完整长自然段叙述,不要单句成段(用户另定段落形态则改写)",
|
||||||
"要写到什么密度、如何处理对话与叙述、注意力放哪"
|
"密度、对话与叙述怎么配、注意力落在哪类瞬间"
|
||||||
],
|
],
|
||||||
"视角": {
|
"视角": {
|
||||||
"镜头距离": "如:贴身跟随 user,冲突时拉近到手与眼神,转场时拉远一句带过",
|
"镜头距离": "贴身 / 中景 / 远观,以及何时拉近",
|
||||||
"内心": "如:只写 user 的内心;NPC 只写外显言行与身体反应",
|
"内心": "只写交互范式允许的一方;其余用外显",
|
||||||
"人称": "如:全文第二人称「你」,回忆与内心独白不换人称"
|
"人称": "全文固定一种称呼,回忆与独白也不换"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"笔墨焦点": {
|
"笔墨焦点": {
|
||||||
"代入取向": "可选:笔墨替谁看、读者站在谁那边;无则省略",
|
"代入取向": "可选;与视角执行不同才写",
|
||||||
"详写": ["应花笔墨的对象与感受"],
|
"详写": ["点名对象或瞬间;不复述体验内核"],
|
||||||
"可略": ["可一带而过的部分"]
|
"可略": ["可一带而过的部分"]
|
||||||
},
|
},
|
||||||
"禁忌": ["仅少数绝对不能写的内容;无则省略"],
|
"禁忌": ["仅少数绝对不能写的短句;无则省略"],
|
||||||
"写法档": [
|
"写法档": [
|
||||||
{
|
{
|
||||||
"名字": "展示情色",
|
"名字": "展示情色",
|
||||||
"适用场合": "切到身体与权力交换时;用户报的「独处一室」这类场合升到这一档",
|
"适用场合": "切到身体与权力交换时",
|
||||||
"写法要求": "感官词加重、节奏放慢、对话变短;切回日常则收"
|
"写法要求": "感官词加重、节奏放慢、对话变短;切回日常则收"
|
||||||
},
|
|
||||||
{
|
|
||||||
"名字": "网文爽文",
|
|
||||||
"适用场合": "打脸、反转时;用户报的「当众折辱」之后的反击升到这一档",
|
|
||||||
"写法要求": "可复述对方原话、结果写满、过程可略;反转落定后切回默认文风"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -107,56 +104,65 @@ feeds: narrator,gm
|
|||||||
```
|
```
|
||||||
|
|
||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON;`选用文风` 默认 1~2 项,最多 3 项,每项必须有适用场合与本局要求。
|
1. 输出合法 JSON。`选用文风` 默认 1~2 项,最多 3 项,每项有适用场合与本局要求。
|
||||||
2. 不写示范段落、示例正文或仿写样章。
|
2. `风格推荐`、`本局要求`、`写法要求`、`详写` 不写台词、不写角色正在做的场面、不把体验内核换个说法再写一遍。
|
||||||
3. 除 `禁忌` 外,正文以正向要求为主;空话氛围词改成可执行做法。
|
3. 除 `禁忌` 外,正文以正向要求为主。
|
||||||
4. 用户未定段落形态时,`遣词.写法要求` 必含「完整长自然段叙述,不要单句成段」。
|
4. 用户未定段落形态时,`遣词.写法要求` 含「完整长自然段叙述,不要单句成段」。
|
||||||
5. `遣词.视角` 只写交互范式人称与视角的执行,不改人称、不扩大可写内心的范围。`叙述者`、`代入取向` 可选,无则省略。
|
5. `遣词.视角` 只执行交互范式里的人称与可写内心,不改范围。
|
||||||
6. `写法档` 无切换时整项省略;有则每档写清场合与写法要求;具体场合升档写入,不单列桥段。
|
6. `写法档` 无切换则整项省略。有则每档写场合与写法要求。
|
||||||
7. 不在产物正文写自评、追问、开放问题,或推进、输入用法、单轮字数。
|
7. 产物正文不写自评、追问、推进、输入用法、单轮字数。
|
||||||
8. `mount` 固定包含 `narrator` 与 `world-simulator`。
|
8. `mount` 固定包含 `narrator` 与 `world-simulator`。
|
||||||
|
|
||||||
## score
|
## score
|
||||||
|
|
||||||
```score
|
```score
|
||||||
遣词可执行:
|
遣词可执行:
|
||||||
- 5:风格推荐与要求具体到能直接落笔,无需再猜味道;视角执行与段落形态写清。
|
- 5:风格推荐与写法要求具体到节奏和句段,能直接落笔;视角执行写清。
|
||||||
- 3:大方向清楚,但仍有几处停留在氛围概括,或视角执行含糊。
|
- 3:方向清楚,但仍有几处是氛围词,或视角含糊。
|
||||||
- 1:只有题材或情绪标签,无法指导遣词。
|
- 1:只有题材或情绪标签。
|
||||||
|
|
||||||
笔墨与禁忌:
|
笔墨与禁忌:
|
||||||
- 5:详略落点清楚,贴合美学的描写焦点与边界尺度;绝对禁忌少而可执行;其余多为正向要求。
|
- 5:详写点名对象或瞬间,没有把体验内核再写一遍;禁忌少而可执行。
|
||||||
- 3:落点大体有,但禁忌偏多或详略仍含糊。
|
- 3:落点大体有,但详写在复述感受,或禁忌偏多。
|
||||||
- 1:几乎不谈笔墨,或堆满否定句却无正向做法。
|
- 1:几乎不谈笔墨,或堆满否定句。
|
||||||
|
|
||||||
文风选用:
|
文风选用:
|
||||||
- 5:1~2(偶发 3)条贴合本局;多档时切换场合清楚,具体场合已升档写入,无重复堆叠。
|
- 5:1~2(偶发 3)条贴合本局;多档时切换场合清楚。
|
||||||
- 3:选用相关,但场合区分弱,或有一条不够贴。
|
- 3:选用相关,但场合区分弱。
|
||||||
- 1:未读库硬编,或多种文风无差别并列。
|
- 1:未读库硬编,或多种文风无差别并列。
|
||||||
|
|
||||||
本局化:
|
本局化:
|
||||||
- 5:库内容与美学感受已改成本局说法与要求,能看出针对本局体验的取舍。
|
- 5:库条目收成可执行的节奏和句段;体验句仍只在美学。
|
||||||
- 3:有微调,但仍有整段近乎原文搬用,或重问了美学已写清的感受。
|
- 3:有微调,但仍有整段近乎库原文,或详写在改写体验内核。
|
||||||
- 1:基本照抄库条目,或与本局体验脱节。
|
- 1:照抄库条目,或本局要求写成台词和场面。
|
||||||
```
|
```
|
||||||
|
|
||||||
## checklist
|
## checklist
|
||||||
|
|
||||||
```checklist
|
```checklist
|
||||||
- [ ] 已继承美学的体验、描写焦点与边界尺度,改写成做法?
|
- [ ] 详写只点名对象或瞬间,没有改写体验内核?
|
||||||
- [ ] 已按需读取文风库并本局化?
|
- [ ] 已读取文风库,本局要求是节奏和句段?
|
||||||
- [ ] 选用文风 1~2(最多 3),各有适用场合?
|
- [ ] 选用文风 1~2(最多 3),各有适用场合?
|
||||||
- [ ] 遣词是风格推荐与要求,不是示范段落?用户未定段落形态时已写长自然段默认?
|
- [ ] 风格推荐里没有台词、场面或角色举例?
|
||||||
- [ ] 视角只写执行,未改交互范式的人称与可写内心?
|
- [ ] 视角只写执行,未改交互范式的人称与可写内心?
|
||||||
- [ ] 具体场合已升档写入写法档,未单列桥段?
|
- [ ] 用户未定段落形态时已写长自然段默认?
|
||||||
- [ ] 除少数禁忌外,多为“要怎样”?
|
|
||||||
- [ ] 未写入推进、输入用法、单轮字数或版式?
|
- [ ] 未写入推进、输入用法、单轮字数或版式?
|
||||||
- [ ] 产物正文不含评分、诊断、追问?
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## examples
|
## examples
|
||||||
|
|
||||||
```examples
|
```examples
|
||||||
好:美学的「掌控感」落成详写对方的迟疑与屈从、user 的动作从容简短;视角写「第二人称贴身镜头,只写你的内心,NPC 只写外显」;写法要求含长自然段默认;用户提的「当众折辱」升进「网文爽文」档,写清切入与切回。
|
好:
|
||||||
坏:重新定义人称或扩大可写内心;把「当众折辱」「独处一室」列成桥段清单;写示范段落;在本步写单轮字数或推进方式。
|
- 描写焦点已是「让步或反抗的那一刻」→ 详写只写「让步与反抗」,不把体验内核再写一遍。
|
||||||
|
- 风格推荐:「短句、少形容词、对话压成一句」。本局要求同样只写节奏和句段。
|
||||||
|
- 视角:「第二人称贴身;只写你的内心;NPC 只写外显」。
|
||||||
|
|
||||||
|
缺料时:
|
||||||
|
- 文风库没有贴合条目 → gap 问一个方向,不编第三种文风充数。
|
||||||
|
- 交互范式未定人称 → 按美学站位先写一种执行,gap 标明。
|
||||||
|
|
||||||
|
偏了就改回:
|
||||||
|
- 风格推荐写成某角色的台词,或「骄横千金被按住后改口」→ 改成节奏、用词、句段。
|
||||||
|
- 详写把「反复感到掌控」再写一遍 → 删掉,只留焦点里的对象或瞬间。
|
||||||
|
- 禁忌把美学的边界与尺度整段抄来 → 只留真正碰不得的短句。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
|
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
|
||||||
> 产物外壳:`docs/context-fragment-design.md`;对照美学:感觉 → 本步「怎么写、故事怎么往前」。
|
> 产物外壳:`docs/context-fragment-design.md`;对照美学:感觉 → 本步「怎么写、故事怎么往前」。
|
||||||
> **一份产物、一次询问**;转述与主世界层都挂同一份(不必为省 token 拆成两套投影)。
|
> **一份产物、一次询问**;转述与主世界层都挂同一份(不必为省 token 拆成两套投影)。
|
||||||
> **工序编排用本步。** 对话落盘拆成「叙事指南」与「故事推进」,不要在落盘池排本步。
|
> **工序编排用本步。** 对话落盘拆成「叙事指南」与「故事推进与交互范式」,不要在落盘池排本步。
|
||||||
> 旧称:叙事指南。
|
> 旧称:叙事指南。
|
||||||
|
|
||||||
## meta
|
## meta
|
||||||
@@ -18,8 +18,8 @@ when: |
|
|||||||
已有美学纲领(或等价),但转述/主世界层仍会不知道这四件:遣词什么味道、笔墨落谁身上、绝对不能写什么、故事靠场面自己推还是等用户。
|
已有美学纲领(或等价),但转述/主世界层仍会不知道这四件:遣词什么味道、笔墨落谁身上、绝对不能写什么、故事靠场面自己推还是等用户。
|
||||||
用户主动谈文风、措辞禁忌、写法档(官能/爽文/恐怖/情色等)也排。
|
用户主动谈文风、措辞禁忌、写法档(官能/爽文/恐怖/情色等)也排。
|
||||||
when_not: |
|
when_not: |
|
||||||
本技能只给工序编排。对话落盘不排本步(落盘池是「叙事指南」与「故事推进」)。
|
本技能只给工序编排。对话落盘不排本步(落盘池是「叙事指南」与「故事推进与交互范式」)。
|
||||||
美学未钉 → 先「美学纲领与交互范式」。
|
美学未钉 → 先「美学纲领」。
|
||||||
用户只要感觉/站位、不要文风细则 → 不排,沿用美学。
|
用户只要感觉/站位、不要文风细则 → 不排,沿用美学。
|
||||||
版式/壳 → 「正文组成」;每轮看什么(字数/变量/日期)→ 「回复呈现」。
|
版式/壳 → 「正文组成」;每轮看什么(字数/变量/日期)→ 「回复呈现」。
|
||||||
事件池 schema、真值增减 → 「生成规则」/「变量设计与更新规则」。
|
事件池 schema、真值增减 → 「生成规则」/「变量设计与更新规则」。
|
||||||
@@ -86,7 +86,7 @@ summary:`叙事指南与故事推进 · …`
|
|||||||
- **续写**(旧称扮演停笔):行为与对话作拓写起点;装载上列准则;主角设定偏好同大纲扩写。
|
- **续写**(旧称扮演停笔):行为与对话作拓写起点;装载上列准则;主角设定偏好同大纲扩写。
|
||||||
- **自动写入**:短 meta/极简扮演→长续写;允许按主角设定代写用户角色;倾向详钉性格。
|
- **自动写入**:短 meta/极简扮演→长续写;允许按主角设定代写用户角色;倾向详钉性格。
|
||||||
4. 情境备用是写法档:名字用「官能小说」「网文爽文」「展示恐怖」「展示情色」同级;用法写该档遣词与何时切入。无切换则空数组。
|
4. 情境备用是写法档:名字用「官能小说」「网文爽文」「展示恐怖」「展示情色」同级;用法写该档遣词与何时切入。无切换则空数组。
|
||||||
5. 工序编排保持本步一份产物;对话落盘拆成「叙事指南」与「故事推进」,不要在本步执行时再拆技能。
|
5. 工序编排保持本步一份产物;对话落盘拆成「叙事指南」与「故事推进与交互范式」,不要在本步执行时再拆技能。
|
||||||
6. 字数区间归「回复呈现」;本步管怎么写与怎么推,不重钉字数。
|
6. 字数区间归「回复呈现」;本步管怎么写与怎么推,不重钉字数。
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ when: |
|
|||||||
LLM 获得较多推进权,而现有材料不足以稳定代演该角色时也使用。
|
LLM 获得较多推进权,而现有材料不足以稳定代演该角色时也使用。
|
||||||
when_not: |
|
when_not: |
|
||||||
旁观、无固定主角或纯作者视角时不使用。刻意无名的自我投射不因缺名字而强制使用。
|
旁观、无固定主角或纯作者视角时不使用。刻意无名的自我投射不因缺名字而强制使用。
|
||||||
NPC 有多份或需要统一字段时归生成规则,要留下的单条再写具体实例;世界硬法则归实现机制;第一幕归开场白。
|
NPC 有多份或需要统一字段时归生成规则,要留下的单条再写具体实例;世界硬法则归世界法则;第一幕归开场白。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力只交用户角色名片和必要行为依据。体验与权限来自美学纲领,输入及推进方式来自故事推进,本步据此控制详略。
|
本能力只交用户角色名片和必要行为依据。体验与权限来自美学纲领,输入及推进方式来自故事推进与交互范式,本步据此控制详略。
|
||||||
feeds: gm
|
feeds: gm
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -28,9 +28,9 @@ feeds: gm
|
|||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
把用户材料整理为本局用户角色名片,写入 `设计.主角设定`。先保留承载核心体验的身份、能力、关系与限制,再按 LLM 获得的推进权决定是否需要行为依据。
|
把用户材料整理为本局用户角色名片,写入 `设计.主角设定`。先保留承载体验的身份、关系和此人能做的那一件,再按故事推进与交互范式里的代写权限决定要不要行为依据。
|
||||||
|
|
||||||
用户自己扮演或提供详细大纲时,身份背景够用即可,不替用户设计人格与选择。LLM 代演越多,性格、处事倾向和默认反应越需要具体可执行。产物正文不放评分、追问或诊断。
|
`特殊设定` 只写这张名片上的能力或身份。拓扑图谱或世界法则里已有的档位、百分比、价目和阶段链不抄进来,作用里点图名或机制名即可。用户自己扮演时,身份背景够用即可,不替用户设计人格与选择。产物正文不放评分、追问或诊断。
|
||||||
```
|
```
|
||||||
|
|
||||||
## principles
|
## principles
|
||||||
@@ -39,7 +39,7 @@ feeds: gm
|
|||||||
1. 只设计用户已明确给出、核心体验依赖或运行确实需要的内容。
|
1. 只设计用户已明确给出、核心体验依赖或运行确实需要的内容。
|
||||||
2. 一张脸测试:逐句问「删掉这句,玩家还知道自己是谁吗?」删了仍知道的句子不落盘。
|
2. 一张脸测试:逐句问「删掉这句,玩家还知道自己是谁吗?」删了仍知道的句子不落盘。
|
||||||
3. “首富之子”“唯我独法”等若是核心体验成立条件,属于必要设定,不算过度设计。
|
3. “首富之子”“唯我独法”等若是核心体验成立条件,属于必要设定,不算过度设计。
|
||||||
4. 特殊能力写“能做什么 + 限制”;世界为何如此由实现机制处理。
|
4. 特殊设定写此人能做什么,限制写做不到什么。档位、百分比、价目、阶段链已在拓扑图谱或世界法则里时,作用只点图名或机制名。作用与设定是同一句时省略作用。
|
||||||
5. `称呼` 可写固定名字,也可写 `@玩家` 表示刻意留白;不要因无名阻塞成卡。
|
5. `称呼` 可写固定名字,也可写 `@玩家` 表示刻意留白;不要因无名阻塞成卡。
|
||||||
6. 身份与背景一两句;用户没给就写「未定」或省略,不代编来历。
|
6. 身份与背景一两句;用户没给就写「未定」或省略,不代编来历。
|
||||||
7. 行为依据随推进权增减:用户控制越多越简;LLM 代演越多越具体。
|
7. 行为依据随推进权增减:用户控制越多越简;LLM 代演越多越具体。
|
||||||
@@ -62,7 +62,7 @@ feeds: gm
|
|||||||
"特殊设定": [
|
"特殊设定": [
|
||||||
{
|
{
|
||||||
"设定": "身份、能力、关系或随身之物",
|
"设定": "身份、能力、关系或随身之物",
|
||||||
"作用": "它怎样承载核心体验或影响世界反应",
|
"作用": "可选;此人因此能做什么。与设定同义,或只是机制公式的复述时省略",
|
||||||
"限制": "无则省略"
|
"限制": "无则省略"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -80,6 +80,7 @@ feeds: gm
|
|||||||
2. 不写产物内自评、追问、开放问题或完备度。
|
2. 不写产物内自评、追问、开放问题或完备度。
|
||||||
3. 不把核心体验所依赖的身份、能力或关系误删为“过度设计”。
|
3. 不把核心体验所依赖的身份、能力或关系误删为“过度设计”。
|
||||||
4. 不替用户补未授权的人格、目标、情绪和关键决定。
|
4. 不替用户补未授权的人格、目标、情绪和关键决定。
|
||||||
|
5. `特殊设定` 不复制拓扑图谱或世界法则里的档位表、百分比、价目和阶段链。
|
||||||
|
|
||||||
## score
|
## score
|
||||||
|
|
||||||
@@ -102,14 +103,17 @@ feeds: gm
|
|||||||
- [ ] 非必要、未经确认的性格与行为设计已删除?
|
- [ ] 非必要、未经确认的性格与行为设计已删除?
|
||||||
- [ ] 行为依据的详细度与 LLM 推进权匹配?
|
- [ ] 行为依据的详细度与 LLM 推进权匹配?
|
||||||
- [ ] 称呼允许固定名字或 `@玩家` 留白?
|
- [ ] 称呼允许固定名字或 `@玩家` 留白?
|
||||||
- [ ] 未写 NPC、世界百科、文风或开场事件?
|
- [ ] 特殊设定没有抄机制里的档位、百分比或价目?
|
||||||
```
|
```
|
||||||
|
|
||||||
## examples
|
## examples
|
||||||
|
|
||||||
```examples
|
```examples
|
||||||
- 好:称呼「林晚」;身份与背景一句穿来当日的处境;特殊设定「能看见别人对她的好感数字」,限制「别人看不见」。
|
- 好:称呼「林晚」;身份与背景一句穿来当日的处境;特殊设定「能看见别人对她的好感数字」,限制「别人看不见」。
|
||||||
- 坏:把侯府地理、朝堂势力写进背景。
|
- 好:复制能力的作用只写「接触后可复制对方一项能力」,五档百分比留在拓扑图谱,这里点图名。
|
||||||
- 坏:把开场羞辱戏全文写进名片。
|
|
||||||
- 坏:为宿舍开场和天台开场各写一张 `设计.主角设定#短码`;各开场不同的处境应写进对应开场白的 `meta.用户角色`。
|
偏了就改回:
|
||||||
|
- 作用写成 1%–100% 的五档表 → 删表,作用留一句能做什么,或与设定同义则省略。
|
||||||
|
- 把侯府地理、朝堂势力写进背景 → 收到舞台或开场白。
|
||||||
|
- 为宿舍开场和天台开场各写一张 `设计.主角设定#短码` → 各开场不同的处境写进对应开场白的 `meta.用户角色`。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ when: |
|
|||||||
机制、变量或生成规则已出现必须用随机数才能裁定的点时使用。
|
机制、变量或生成规则已出现必须用随机数才能裁定的点时使用。
|
||||||
when_not: |
|
when_not: |
|
||||||
全程不靠随机推进 → 不使用。
|
全程不靠随机推进 → 不使用。
|
||||||
公式本身还没钉死 → 先实现机制、生成规则或变量设计。
|
公式本身还没钉死 → 先世界法则、生成规则或变量设计。
|
||||||
按合理度从池里选,而不是抽签 → 不列入。
|
按合理度从池里选,而不是抽签 → 不列入。
|
||||||
boundary: |
|
boundary: |
|
||||||
不新建表,不发骰面。数字是普通隐藏变量,默认玩家看不见。
|
不新建表,不发骰面。数字是普通隐藏变量,默认玩家看不见。
|
||||||
@@ -30,7 +30,7 @@ feeds: gm
|
|||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
先扫实现机制、生成规则、变量设计、叙事与故事推进、美学,列出所有需要随机数才能裁定的点,再写入 `设计.随机范围整理`。每项是 `变量.当前` 的一行,行名写成「名称.随机数」,玩家默认不可见。值是剩余数字组成的数组,下一个可用的是第一个。
|
先扫世界法则、生成规则、变量设计、叙事与故事推进、美学,列出所有需要随机数才能裁定的点,再写入 `设计.随机范围整理`。每项是 `变量.当前` 的一行,行名写成「名称.随机数」,玩家默认不可见。值是剩余数字组成的数组,下一个可用的是第一个。
|
||||||
|
|
||||||
用掉第一个后,程序改写这一行,去掉那个数。改完后如果只剩 1 个,就按范围补到预备个数,不要等这一行空了。模型不自己编数字。
|
用掉第一个后,程序改写这一行,去掉那个数。改完后如果只剩 1 个,就按范围补到预备个数,不要等这一行空了。模型不自己编数字。
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ boundary: |
|
|||||||
你正在执行「细化终稿」。产物必须写入 **设计.worker集**,且为 **JSON**。
|
你正在执行「细化终稿」。产物必须写入 **设计.worker集**,且为 **JSON**。
|
||||||
|
|
||||||
本步是收成,不是发明新执行单元。优先合并:
|
本步是收成,不是发明新执行单元。优先合并:
|
||||||
- 设计.美学纲领与交互范式 → interaction / experience_check
|
- 设计.美学纲领(旧档仍认 设计.美学纲领与交互范式)→ interaction / experience_check
|
||||||
- 设计.worker规格(游玩拓扑)→ play_slots 或 writing_slots
|
- 设计.worker规格(游玩拓扑)→ play_slots 或 writing_slots
|
||||||
- 设计.上下文投影排序 → 写入规格的 context_order(每槽 inserts:order/anchor/ref/projection);无表且上下文很少时可按默认 static/dynamic 退化
|
- 设计.上下文投影排序 → 写入规格的 context_order(每槽 inserts:order/anchor/ref/projection);无表且上下文很少时可按默认 static/dynamic 退化
|
||||||
- 设计.叙事指南 / 设计.故事推进(工序编排:设计.叙事指南与故事推进)/ 世界 / 机制 / 生成规则等 → resident_context(挂载以排序表与 mount 为准,本步不重排数字序)
|
- 设计.叙事指南 / 设计.故事推进与交互范式(旧档 设计.故事推进;工序编排:设计.叙事指南与故事推进)/ 世界 / 机制 / 生成规则等 → resident_context(挂载以排序表与 mount 为准,本步不重排数字序)
|
||||||
- 设计.变量设计与更新规则 → tables.side_effects(及 schemas 摘要)
|
- 设计.变量设计与更新规则 → tables.side_effects(及 schemas 摘要)
|
||||||
- 设计.变量控制上下文 → 核对挂载与剧透,写入 notes 或 resident 短句
|
- 设计.变量控制上下文 → 核对挂载与剧透,写入 notes 或 resident 短句
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ boundary: |
|
|||||||
隐藏维护区不进 DOM;表由旁观副 LLM 维护,主回复不建填表通道。
|
隐藏维护区不进 DOM;表由旁观副 LLM 维护,主回复不建填表通道。
|
||||||
随机数是变量表隐藏行,也与本能力无关。
|
随机数是变量表隐藏行,也与本能力无关。
|
||||||
壳参考库只提供布局与交互方法参考,不是必须照抄的固定产品模板。
|
壳参考库只提供布局与交互方法参考,不是必须照抄的固定产品模板。
|
||||||
feeds: gm,narrator
|
创作时一次写好 frontend 并由程序持有;游玩不把 html/css/js 塞进模型上下文。
|
||||||
|
程序按数据契约要求模型只交 present.v1 各 block(如正文、标题、选项),再灌进已固定的壳。
|
||||||
|
feeds: program
|
||||||
```
|
```
|
||||||
|
|
||||||
## opening
|
## opening
|
||||||
@@ -75,7 +77,7 @@ feeds: gm,narrator
|
|||||||
"schema": "context-fragment.v1",
|
"schema": "context-fragment.v1",
|
||||||
"技能": "正文组成",
|
"技能": "正文组成",
|
||||||
"brief": "一句话说明普通消息的阅读结构与特殊渲染",
|
"brief": "一句话说明普通消息的阅读结构与特殊渲染",
|
||||||
"mount": ["world-simulator", "narrator"],
|
"mount": [],
|
||||||
"稳变": "stable",
|
"稳变": "stable",
|
||||||
"正文": {
|
"正文": {
|
||||||
"参考壳": [
|
"参考壳": [
|
||||||
@@ -148,7 +150,7 @@ feeds: gm,narrator
|
|||||||
6. `示例灌数` 是非正史预览,schema 固定 `present.v1`,只含用户可见 blocks。
|
6. `示例灌数` 是非正史预览,schema 固定 `present.v1`,只含用户可见 blocks。
|
||||||
7. 不重定义语义区域、篇幅、文风、变量规则、随机数或 0 层槽位更新。
|
7. 不重定义语义区域、篇幅、文风、变量规则、随机数或 0 层槽位更新。
|
||||||
8. 产物正文不含自评、追问或开放问题。
|
8. 产物正文不含自评、追问或开放问题。
|
||||||
9. `mount` 固定为 `["world-simulator", "narrator"]`;兼容旧称 tag `设计.回复格式`。
|
9. `mount` 固定为 `[]`:壳不进游玩投影;程序持有 frontend,只向模型注入数据契约。兼容旧称 tag `设计.回复格式`。
|
||||||
|
|
||||||
## score
|
## score
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ artifact: 设计.监控栏
|
|||||||
declaration: >
|
declaration: >
|
||||||
定义普通楼层每轮回复实际启用的语义区域、出现条件、权威来源与内容要求;不决定前端排版和渲染。
|
定义普通楼层每轮回复实际启用的语义区域、出现条件、权威来源与内容要求;不决定前端排版和渲染。
|
||||||
when: |
|
when: |
|
||||||
已有故事推进及本局需要展示的变量、拓扑或其它内容后使用;排在正文组成与开场白之前。
|
本局会游玩、每轮有一条助手回复时就使用。默认声明正文区和文字选项;用户明确不要选项时关掉选项区。回复里还有抬头、状态、小故事或数据卡时,也在本步声明。
|
||||||
|
排在故事推进与交互范式之后、正文组成之前。用户谈到选项、状态栏、每轮除正文外还看什么时也使用。
|
||||||
when_not: |
|
when_not: |
|
||||||
每轮只需普通正文且语义合同已由上游明确、无需额外区域时可使用最小正文区域,不扩展其它块。
|
本局没有游玩回复(纯转换、资料整理、一次性成稿)→ 不使用。
|
||||||
只讨论 HTML、CSS、Tab、分区或具体渲染 → 正文组成。
|
持久 0 层卡、不增加普通楼层 → 0层回复呈现。
|
||||||
|
语义区已经钉死,只讨论 HTML、CSS、Tab 或分区 → 正文组成。语义区还没有时,仍先本步。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力决定回复由哪些可见或隐藏语义区域组成、何时出现、内容来自哪里、怎样才算填对。
|
本能力决定回复由哪些可见或隐藏语义区域组成、何时出现、内容来自哪里、怎样才算填对。
|
||||||
正文组成负责把这些区域放到前端的具体位置并实现 HTML/CSS/JS、Tab 和特殊渲染。
|
正文组成负责把这些区域放到前端的具体位置并实现 HTML/CSS/JS、Tab 和特殊渲染。
|
||||||
故事推进是单轮字数及扩写/续写预算的唯一来源;本能力不重复定义篇幅。
|
字数区间与扩写/续写占比以故事推进与交互范式已写的为准,本步照写进正文内容要求,不另起一套数字。
|
||||||
变量设计与拓扑图谱定义权威数据和更新规则;本能力只决定回复是否包含其展示区或隐藏更新区。
|
变量设计与拓扑图谱定义权威数据和更新规则;本能力只决定回复是否包含其展示区或隐藏更新区。
|
||||||
feeds: gm,narrator
|
feeds: gm,narrator
|
||||||
```
|
```
|
||||||
@@ -24,7 +26,7 @@ feeds: gm,narrator
|
|||||||
## opening
|
## opening
|
||||||
|
|
||||||
```opening
|
```opening
|
||||||
每轮回复除正文外,还需要哪些语义内容?逐项说明它何时出现、由模型生成还是由程序投影,以及用户是否可见。若有地图或状态展示,请指出权威数据来源;若维护策略要求结构化隐藏更新,也把它列为隐藏区域。
|
每轮回复除正文外,还需要哪些可见内容?逐项说明它何时出现、由模型生成还是由程序投影,以及用户是否可见。有地图或状态时,指出要投影的字段。
|
||||||
```
|
```
|
||||||
|
|
||||||
## task
|
## task
|
||||||
@@ -39,7 +41,7 @@ feeds: gm,narrator
|
|||||||
- 对应的上游引用与正向内容要求。
|
- 对应的上游引用与正向内容要求。
|
||||||
- 没有内容时是省略、给空态,还是仍须输出。
|
- 没有内容时是省略、给空态,还是仍须输出。
|
||||||
|
|
||||||
正文区域只引用 `设计.故事推进` 的单轮篇幅与扩写/续写预算,不复制数值。状态、地图等权威内容使用程序投影,正文模型不重复生成。选项若启用,写清数量、各项的差异维度(如冒险/稳重)和每项的组成规则,让选项彼此不同;不声明点击交互。
|
正文区的 `内容要求` 照写 `设计.故事推进与交互范式` 里已经定下的目标字数或区间、扩写占比、续写占比。数字与上游一致,不另起一套。状态、地图等权威内容使用程序投影,正文模型不重复生成。文字选项默认启用:`内容约束` 写 4 条,每条约束选项该满足什么,不写成品行动句。用户明确不要选项时,省略该区。
|
||||||
|
|
||||||
表维护由旁观副 LLM 固定承担;本步**不**建立主回复里的隐藏变量更新区(无 `state_ops` / maintenance_channel)。可见区只投影权威数据。
|
表维护由旁观副 LLM 固定承担;本步**不**建立主回复里的隐藏变量更新区(无 `state_ops` / maintenance_channel)。可见区只投影权威数据。
|
||||||
|
|
||||||
@@ -52,10 +54,10 @@ feeds: gm,narrator
|
|||||||
1. 语义区域回答“回复里有什么”,正文组成回答“在前端哪里、怎么显示”。
|
1. 语义区域回答“回复里有什么”,正文组成回答“在前端哪里、怎么显示”。
|
||||||
2. 只写实际启用区域。固定每轮出现用 `every_turn`;条件出现须给可判定条件。
|
2. 只写实际启用区域。固定每轮出现用 `every_turn`;条件出现须给可判定条件。
|
||||||
3. 模型生成区写内容要求;程序投影区写权威 ref 与字段选择。程序已有的数据不让模型复述一份。
|
3. 模型生成区写内容要求;程序投影区写权威 ref 与字段选择。程序已有的数据不让模型复述一份。
|
||||||
4. 正文篇幅、扩写和续写预算只引用故事推进;文风只引用叙事指南。
|
4. 正文内容要求照写故事推进与交互范式里的字数区间和扩写/续写占比,数字保持一致。文风只引用叙事指南,不抄遣词全文。
|
||||||
5. 状态与拓扑的真相来自运行数据。可见区域只选择投影字段,不在这里修改变量或拓扑。
|
5. 状态与拓扑的真相来自运行数据。可见区域只选择投影字段,不在这里修改变量或拓扑。
|
||||||
6. 不在主回复建隐藏填表区;表由旁观副 LLM 维护。
|
6. 不在主回复建隐藏填表区;表由旁观副 LLM 维护。
|
||||||
7. 选项是文本建议。是否有交互、点击后做什么,只有正文组成和宿主前端真正实现时才声明。
|
7. 文字选项默认每轮都有,用户明确不要时整区省略。选项是文本建议;是否可点击,只有正文组成和宿主前端真正实现时才声明。
|
||||||
8. 区域要求使用正向规格;没有特殊要求便保持短,不为完整感堆区域。
|
8. 区域要求使用正向规格;没有特殊要求便保持短,不为完整感堆区域。
|
||||||
9. 每个可见区属于一种:抬头、正文、小故事、数据卡、结构、文字选项。抬头是页首一行短内容;小故事是旁支短文,不推进主线,也不复述正文;条目一多或当前值一长则改用数据卡。数据卡和结构要写出露出的列或字段。
|
9. 每个可见区属于一种:抬头、正文、小故事、数据卡、结构、文字选项。抬头是页首一行短内容;小故事是旁支短文,不推进主线,也不复述正文;条目一多或当前值一长则改用数据卡。数据卡和结构要写出露出的列或字段。
|
||||||
```
|
```
|
||||||
@@ -90,11 +92,12 @@ feeds: gm,narrator
|
|||||||
"区域种类": "正文",
|
"区域种类": "正文",
|
||||||
"来源": {
|
"来源": {
|
||||||
"方式": "model_generated",
|
"方式": "model_generated",
|
||||||
"ref": "设计.故事推进"
|
"ref": "设计.故事推进与交互范式"
|
||||||
},
|
},
|
||||||
"内容要求": [
|
"内容要求": [
|
||||||
"遵守故事推进中的单轮篇幅与扩写/续写预算",
|
"目标字数或区间:照写故事推进与交互范式",
|
||||||
"遵守叙事指南;在本轮自然停笔点结束"
|
"扩写占比与续写占比:照写故事推进与交互范式,两者一致",
|
||||||
|
"遵守叙事指南"
|
||||||
],
|
],
|
||||||
"空值处理": "error"
|
"空值处理": "error"
|
||||||
},
|
},
|
||||||
@@ -107,13 +110,12 @@ feeds: gm,narrator
|
|||||||
"方式": "every_turn|conditional",
|
"方式": "every_turn|conditional",
|
||||||
"条件": "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": "上游产物、运行状态或维护策略的稳定引用",
|
"ref": "上游产物、运行状态或维护策略的稳定引用",
|
||||||
"字段": ["程序投影时只选择实际需要的字段"],
|
"字段": ["程序投影时只选择实际需要的字段"]
|
||||||
"投递": "仅维护区使用:runtime_selects_tool_or_structured"
|
|
||||||
},
|
},
|
||||||
"内容要求": [
|
"内容要求": [
|
||||||
"正向说明该区域应该提供什么"
|
"正向说明该区域应该提供什么"
|
||||||
@@ -133,17 +135,19 @@ feeds: gm,narrator
|
|||||||
"区域种类": "文字选项",
|
"区域种类": "文字选项",
|
||||||
"来源": {
|
"来源": {
|
||||||
"方式": "model_generated",
|
"方式": "model_generated",
|
||||||
"ref": "设计.故事推进"
|
"ref": "设计.故事推进与交互范式"
|
||||||
},
|
},
|
||||||
"选项": {
|
"选项": {
|
||||||
"数量": 2,
|
"数量": "本局每轮几条",
|
||||||
"各项": [
|
"内容约束": [
|
||||||
{ "差异维度": "冒险", "组成": "一句推进风险或探索的具体行动" },
|
"选项必须满足的条件",
|
||||||
{ "差异维度": "稳重", "组成": "一句观察、保守或缓和局面的具体行动" }
|
"第二条约束,仍不是成品行动",
|
||||||
|
"第三条约束",
|
||||||
|
"第四条约束"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"内容要求": [
|
"内容要求": [
|
||||||
"每项是用户角色此刻能直接采取的一句行动,按各自差异维度写,彼此不重复"
|
"按四条内容约束生成;每条是约束,不是成品行动"
|
||||||
],
|
],
|
||||||
"空值处理": "omit"
|
"空值处理": "omit"
|
||||||
}
|
}
|
||||||
@@ -154,11 +158,11 @@ feeds: gm,narrator
|
|||||||
|
|
||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON;`语义区域` 只含实际启用项,不写 `启用:false` 或未启用清单。
|
1. 输出合法 JSON;`语义区域` 只含实际启用项,不写 `启用:false` 或未启用清单。
|
||||||
2. 普通楼层至少含 `body`;正文只引用故事推进,不复制目标字数、占比或字数预算。
|
2. 普通楼层至少含 `body`,默认再含文字选项。正文 `内容要求` 照写故事推进与交互范式的字数区间和扩写/续写占比,不另起数字。
|
||||||
3. 每个区域必须声明出现方式、内容类型、区域种类、来源、内容要求和空值处理。可见区的区域种类只能是抬头、正文、小故事、数据卡、结构、文字选项。
|
3. 每个区域必须声明出现方式、内容类型、区域种类、来源、内容要求和空值处理。可见区的区域种类只能是抬头、正文、小故事、数据卡、结构、文字选项。
|
||||||
4. `program_projection` 必须给稳定 ref;状态和拓扑数据不得标为临场或要求模型重新生成。
|
4. `program_projection` 必须给稳定 ref;状态和拓扑数据不得标为临场或要求模型重新生成。
|
||||||
5. 不建立主回复隐藏填表区(无 `state_ops` / `maintenance_channel`);表由旁观副 LLM 维护。
|
5. 不建立主回复隐藏填表区(无 `state_ops` / `maintenance_channel`);表由旁观副 LLM 维护。
|
||||||
6. 文字选项须含 `选项`:数量,以及每项的差异维度和组成;不写可点击、按钮动作或回传协议。
|
6. 文字选项默认写入。`内容约束` 恰好 4 条,每条是对选项的约束,不是成品行动。不写可点击、按钮动作或回传协议。用户明确不要选项时省略该区。
|
||||||
7. 不选壳,不写布局、HTML、CSS、JS、Tab、SVG 或槽位替换;这些属于正文组成。
|
7. 不选壳,不写布局、HTML、CSS、JS、Tab、SVG 或槽位替换;这些属于正文组成。
|
||||||
8. 产物正文不含自评、追问或开放问题。
|
8. 产物正文不含自评、追问或开放问题。
|
||||||
9. `mount` 固定为 `["world-simulator", "narrator"]`;artifact tag 暂时保留 `设计.监控栏`。
|
9. `mount` 固定为 `["world-simulator", "narrator"]`;artifact tag 暂时保留 `设计.监控栏`。
|
||||||
@@ -187,9 +191,9 @@ feeds: gm,narrator
|
|||||||
- 1:正文外长期堆积大量无关信息。
|
- 1:正文外长期堆积大量无关信息。
|
||||||
|
|
||||||
职责边界:
|
职责边界:
|
||||||
- 5:只定义语义内容,并把渲染、维护真相和篇幅分别交给正确节点。
|
- 5:只定义语义内容;字数区间和占比与故事推进与交互范式一致,渲染和变量真相交给对应节点。
|
||||||
- 3:主体边界正确,但夹有少量排版或重复篇幅要求。
|
- 3:主体边界正确,但夹有少量排版,或占比和上游不一致。
|
||||||
- 1:在本步写 HTML、变量公式或重新定义推进篇幅。
|
- 1:在本步写 HTML、变量公式,或另起一套字数。
|
||||||
```
|
```
|
||||||
|
|
||||||
## checklist
|
## checklist
|
||||||
@@ -198,18 +202,27 @@ feeds: gm,narrator
|
|||||||
- [ ] 只列实际启用区域,普通楼层含正文?
|
- [ ] 只列实际启用区域,普通楼层含正文?
|
||||||
- [ ] 每个区域的出现条件、来源、要求与空值处理完整?
|
- [ ] 每个区域的出现条件、来源、要求与空值处理完整?
|
||||||
- [ ] 状态、地图等权威数据均由程序投影,模型没有重复生成?
|
- [ ] 状态、地图等权威数据均由程序投影,模型没有重复生成?
|
||||||
- [ ] 内联维护的隐藏 state_ops 已声明运行期选路,副 Worker 路径未建立该区?
|
- [ ] 没有建立主回复里的隐藏填表区?
|
||||||
- [ ] 正文只引用故事推进,没有重复字数和比例?
|
- [ ] 正文内容要求写了字数区间和扩写/续写占比,且与故事推进与交互范式一致?
|
||||||
|
- [ ] 默认有文字选项;内容约束恰好 4 条,每条是约束而不是成品行动?
|
||||||
|
- [ ] 用户明确不要选项时,该区已省略?
|
||||||
- [ ] 未写布局、HTML/CSS/JS、Tab、SVG 或交互动作?
|
- [ ] 未写布局、HTML/CSS/JS、Tab、SVG 或交互动作?
|
||||||
```
|
```
|
||||||
|
|
||||||
## examples
|
## examples
|
||||||
|
|
||||||
```examples
|
```examples
|
||||||
- 只有正文:只列 body,并引用故事推进和叙事指南。
|
好:
|
||||||
- 展示状态:新增 kv 区域,来源指向变量当前值及明确字段;内容由程序投影。
|
- 正文内容要求照写上游已定的「800–2500 字,扩写 60%,续写 40%」,不改成另一套区间。
|
||||||
- 展示地图:新增 topology 区域,说明用户应看到哪些节点、连接或当前位置;具体 SVG、表格或 Tab 留给正文组成。
|
- 文字选项默认在。内容约束四条,例如「一句此刻能做的行动」「彼此风险不同」「不写旁白」「不重复上轮原句」。
|
||||||
- 简单变量内联维护:新增用户不可见的 state_ops 语义区域;运行期优先投递为 tool call,工具不可用时进入结构化整包。
|
- 展示状态:新增 kv 区,来源指向变量当前值及要露出的字段,由程序投影。
|
||||||
- 文字选项:写数量、彼此差异和应提供的行动方向;不声明点击行为。
|
|
||||||
- 坏:只写「要选项」,不定数量、各项差异维度与组成,结果每轮几条选项大同小异。
|
缺料时:
|
||||||
|
- 故事推进还没写字数区间 → 正文内容要求该项写「未定」,gap 指向那一份,不在本步另定数字。
|
||||||
|
- 用户说不要选项 → 省略文字选项区,其余照写。
|
||||||
|
|
||||||
|
偏了就改回:
|
||||||
|
- 内容约束写成四条成品行动(「推门进去」「先观察」)→ 改成四条必须满足的约束。
|
||||||
|
- 本步把 800–2500 改成 2000–4000 → 改回故事推进与交互范式里的数字。
|
||||||
|
- 为了完整感再加隐藏填表区 → 删掉;表由旁观副 LLM 维护。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,43 +1,45 @@
|
|||||||
# 故事推进
|
# 故事推进与交互范式
|
||||||
|
|
||||||
## meta
|
## meta
|
||||||
|
|
||||||
```meta
|
```meta
|
||||||
name: 故事推进
|
name: 故事推进与交互范式
|
||||||
id: story-progression
|
id: story-progression
|
||||||
artifact: 设计.故事推进
|
artifact: 设计.故事推进与交互范式
|
||||||
declaration: >
|
declaration: >
|
||||||
继承交互范式的描写权限与停笔时机,从偏好库选取本局约束,钉推进方式(可无专门推进),只选定一种用户输入用法;每一轮正文分成扩写(演本轮材料)与续写(材料后往下写),写清定义、字数分配与 OOC 处理;不写遣词。
|
把交互权限和推进合同写成同一份:谁能代写、谁裁定、何时停笔,以及唯一输入用法、扩写/续写和单轮篇幅。不写遣词。
|
||||||
when: |
|
when: |
|
||||||
已有美学纲领(或等价),但尚未钉死唯一的用户输入用法,或扩写/续写边界与比例仍不清,或还不知道故事靠场面自推、等用户决策还是无专门推进。
|
已有美学体验(或等价短锚),但还没把权限和推进钉成一份合同:谁代写、何时停、输入是大纲还是扮演、扩写和续写各做什么、单轮多长。
|
||||||
用户主动谈推进、节奏、停笔、大纲扩写、续写、自动写入、单轮字数时也使用。
|
用户主动谈权限、停笔、推进、大纲扩写、续写、自动写入、单轮字数时也使用。
|
||||||
when_not: |
|
when_not: |
|
||||||
本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。
|
本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。
|
||||||
美学未钉 → 先「美学纲领与交互范式」。
|
体验内核还没有 → 先「美学纲领」。本步引用那三句短锚,不改写体验正文。
|
||||||
只改遣词、笔墨、写法档、段落形态 → 「叙事指南」。可见区域 → 「回复呈现」。
|
只改遣词、笔墨、写法档、段落形态 → 「叙事指南」。
|
||||||
好感公式、进度表、事件池等表规格 → 「变量设计与更新规则」/「生成规则」。
|
每轮回复里有哪些区域、选项怎么列 → 「回复呈现」。
|
||||||
固定输入标记(裸写 / `#行动#` / 引号)由程序解析,不在本步重定义或按局反转。
|
好感公式、进度表、事件池 → 「变量设计与更新规则」/「生成规则」。
|
||||||
|
固定输入标记(裸写 / `#行动#` / 引号)由程序解析,不在本步重定义。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力只交推进合同:推进方式及可选的驱动力、成败与难度、变量钩子;唯一输入用法、单轮目标字数、扩写要求、续写要求、篇幅分配、OOC 处理策略。
|
本步交两块,写在同一份产物里:`交互范式`(权限与停笔)和推进合同(用法、扩写、续写、篇幅)。
|
||||||
单轮字数与篇幅只在本步定义;段落形态(长段不碎行)归叙事指南,可见区域归回复呈现。
|
停笔只写在 `交互范式.停笔时机`;续写要求只写怎样走到这个停点,不把停笔条件再写一遍。
|
||||||
叙事指南管怎么写;变量与生成规则管表与合同,本步只写钩子;主角详略只写偏好钩子。
|
单轮字数只在本步定义。选项、抬头、状态等可见区域归回复呈现。段落形态归叙事指南。
|
||||||
|
变量与生成规则管表,本步只写钩子。
|
||||||
feeds: gm,narrator
|
feeds: gm,narrator
|
||||||
```
|
```
|
||||||
|
|
||||||
## opening
|
## opening
|
||||||
|
|
||||||
```opening
|
```opening
|
||||||
先选定这局唯一的输入用法:大纲扩写、续写,或自动写入。再说清楚单轮大约写多长;扩写(主路径不动,在用户已给的节拍之间补连接小动作,把场面写顺)和续写(从材料结束后往下写他人与世界反应)各做什么、各占多少。上帝视角/OOC 指令准备怎么处理。故事要不要专门往前推、用户会不会失败,也可以一并说;没想过就按体验先定。
|
先钉谁能替你写、写到哪必须停。再选定唯一输入用法:大纲扩写、续写,或自动写入。然后说单轮大约多长,扩写和续写各做什么、各占多少。
|
||||||
```
|
```
|
||||||
|
|
||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
把本局推进合同写入 `设计.故事推进`。先继承上游「美学纲领与交互范式」:把 `交互范式.描写权限`(思考与抉择、言语、主动行动、用户输入后)、`后果与叙事控制` 与 `停笔时机` 改写成推进做法;交互范式已写过停笔权限,也要落进本步的用法与续写要求,不因已有而省略。再用 `read_library_entry` 从绑定的偏好库读取最相关 1~2 条并本局化,然后钉推进方式(不明则写「无专门推进」),只选定一种用户输入用法。
|
把交互权限和推进合同一起写入 `设计.故事推进与交互范式`。先读美学的体验锚定三句,按它写 `交互范式`:人称与视角、逐类描写权限、后果由谁裁定、停笔时机。权限要让那句体验发生得了。再用 `read_library_entry` 从偏好库取最相关 1~2 条并本局化,钉推进方式(不明则「无专门推进」),只选一种用户输入用法。
|
||||||
|
|
||||||
扩写与续写是每一轮正文里的两段工作,不是两种输入用法。按所选用法写清单轮目标字数、扩写做什么、续写做什么、两者各自篇幅预算,以及裸写(上帝视角/OOC)指令如何处理。
|
扩写与续写是每一轮正文里的两段工作,不是两种输入用法。按所选用法写清扩写做什么、续写怎样走到已写的停笔时机、两者的字数预算,以及裸写指令如何处理。停笔条件不在续写要求里再抄一遍。
|
||||||
|
|
||||||
禁止混用两种用法或写成半套规则。固定标记怎么识别交给程序;本步只写识别之后怎么服从与落笔。不写遣词、段落形态、示范段落或版式。
|
不混用两种用法。固定标记怎么识别交给程序;本步只写识别之后怎么服从。不写遣词、段落形态、选项清单或版式。
|
||||||
|
|
||||||
材料足够便直接成稿;多种用法都像、必须用户点选时,把缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
材料足够便直接成稿;多种用法都像、必须用户点选时,把缺口交给 `self_score.gap`。产物正文不放评分、诊断、追问或开放问题。
|
||||||
```
|
```
|
||||||
@@ -53,7 +55,7 @@ feeds: gm,narrator
|
|||||||
3. 续写:从用户材料结束后往下写。写其他角色与世界如何反应、把场面推到下一个停笔点。默认不可再写用户角色的实际行动、说话或带倾向抉择;最多被动占位(望去、让开)。整轮停在其他角色等用户角色行动抉择之刻。
|
3. 续写:从用户材料结束后往下写。写其他角色与世界如何反应、把场面推到下一个停笔点。默认不可再写用户角色的实际行动、说话或带倾向抉择;最多被动占位(望去、让开)。整轮停在其他角色等用户角色行动抉择之刻。
|
||||||
- 用法为「大纲扩写」或「续写」时:禁止替用户角色做决定,不引入材料外的新剧情事实。
|
- 用法为「大纲扩写」或「续写」时:禁止替用户角色做决定,不引入材料外的新剧情事实。
|
||||||
- 用法为「自动写入」时:可按主角设定代演用户角色言行,把续写写长;关键抉择仍按交互范式的权限处理。
|
- 用法为「自动写入」时:可按主角设定代演用户角色言行,把续写写长;关键抉择仍按交互范式的权限处理。
|
||||||
4. 三种输入用法互斥,按体验与交互范式选一种,写入时只保留一个完整合同。差别在输入是什么、扩写/续写谁长谁短、续写能不能代演用户角色:
|
4. 三种输入用法互斥,按刚写的交互范式选一种。差别在输入是什么、扩写/续写谁长谁短、续写能不能代演用户角色:
|
||||||
- 大纲扩写:用户是写手、要大纲成稿,输入多为剧情节拍时选它。扩写充分、续写短。
|
- 大纲扩写:用户是写手、要大纲成稿,输入多为剧情节拍时选它。扩写充分、续写短。
|
||||||
- 续写(旧称扮演停笔):代入扮演、轮转等用户决策时选它(交互范式规定关键抉择或言语只由用户做、停笔时机是等用户回应)。输入往往较短;扩写只跟已声明内容,续写多于大纲扩写、短于自动写入。
|
- 续写(旧称扮演停笔):代入扮演、轮转等用户决策时选它(交互范式规定关键抉择或言语只由用户做、停笔时机是等用户回应)。输入往往较短;扩写只跟已声明内容,续写多于大纲扩写、短于自动写入。
|
||||||
- 自动写入:用户只给短 meta 指令或极简扮演、希望 AI 代演用户角色,且交互范式允许模型代写 user 言行(旁观、操控站位常见)时选它。扩写少、续写宜长。
|
- 自动写入:用户只给短 meta 指令或极简扮演、希望 AI 代演用户角色,且交互范式允许模型代写 user 言行(旁观、操控站位常见)时选它。扩写少、续写宜长。
|
||||||
@@ -66,7 +68,7 @@ feeds: gm,narrator
|
|||||||
8. OOC/上帝视角内容由程序从裸写识别;本步写处理策略:服从到什么程度、是否打断当前场面、如何与既定扩写/续写比例兼容。
|
8. OOC/上帝视角内容由程序从裸写识别;本步写处理策略:服从到什么程度、是否打断当前场面、如何与既定扩写/续写比例兼容。
|
||||||
9. 偏好库是参考:读取后改成本局可执行要求,不照抄。
|
9. 偏好库是参考:读取后改成本局可执行要求,不照抄。
|
||||||
10. 单轮篇幅在这里一次钉死:给目标字数或窄区间,并把扩写、续写的占比与字数预算对应起来;其它节点直接消费,不重复定义。段落形态归叙事指南,本步不写。
|
10. 单轮篇幅在这里一次钉死:给目标字数或窄区间,并把扩写、续写的占比与字数预算对应起来;其它节点直接消费,不重复定义。段落形态归叙事指南,本步不写。
|
||||||
11. 不写遣词、写法档、段落形态、版式或事件池公式。
|
11. 不写遣词、写法档、段落形态、版式、选项文案或事件池公式。可见区域交给回复呈现。
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -74,11 +76,41 @@ feeds: gm,narrator
|
|||||||
```output
|
```output
|
||||||
{
|
{
|
||||||
"schema": "context-fragment.v1",
|
"schema": "context-fragment.v1",
|
||||||
"技能": "故事推进",
|
"技能": "故事推进与交互范式",
|
||||||
"brief": "一句话:唯一输入用法 + 单轮篇幅与扩写/续写分配",
|
"brief": "一句话:唯一输入用法 + 单轮篇幅与扩写/续写分配",
|
||||||
"mount": ["world-simulator", "narrator"],
|
"mount": ["world-simulator", "narrator"],
|
||||||
"稳变": "stable",
|
"稳变": "stable",
|
||||||
"正文": {
|
"正文": {
|
||||||
|
"交互范式": {
|
||||||
|
"前置配置": {
|
||||||
|
"用户与user关系": "完全代入 / 部分代入 / 旁观 / 操控",
|
||||||
|
"人称": "如:第二人称称「你」",
|
||||||
|
"元叙事": "决策是否用情节呈现"
|
||||||
|
},
|
||||||
|
"叙事视角": {
|
||||||
|
"主要视角": "镜头跟谁、看什么",
|
||||||
|
"视角切换": "不允许,或允许的例外",
|
||||||
|
"可写内心": "可以写谁的内心",
|
||||||
|
"不呈现的信息": "正文不能透露的内容"
|
||||||
|
},
|
||||||
|
"描写权限": {
|
||||||
|
"思考与抉择": "关键决定写到哪里停",
|
||||||
|
"言语": "能否替 user 说话、说到什么程度",
|
||||||
|
"主动行动": "能否替 user 发起行动",
|
||||||
|
"情绪与身体反应": "可补写的范围",
|
||||||
|
"习惯与既有技能": "可按既有设定自动执行的部分",
|
||||||
|
"外在状态": "外貌、伤势、衣着可否直接写",
|
||||||
|
"用户输入后": "只确认 / 补细节 / 连带推出后果"
|
||||||
|
},
|
||||||
|
"后果与叙事控制": {
|
||||||
|
"行为后果判定": "谁裁定成败与代价",
|
||||||
|
"世界对user的影响": "NPC 与世界如何作用于 user",
|
||||||
|
"时间与场景": "可否推进时间、切换场景,跨度多大",
|
||||||
|
"引入新内容": "可否引入新人物、事件、威胁",
|
||||||
|
"设计困境": "是否制造两难;怎样的两难服务体验"
|
||||||
|
},
|
||||||
|
"停笔时机": "必须等用户的时机;续写停在这里,不另写一套"
|
||||||
|
},
|
||||||
"选用偏好": [
|
"选用偏好": [
|
||||||
{
|
{
|
||||||
"库条目": "preferences 中的 id",
|
"库条目": "preferences 中的 id",
|
||||||
@@ -113,7 +145,7 @@ feeds: gm,narrator
|
|||||||
```
|
```
|
||||||
|
|
||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON;`用户输入用法` 只能是三者之一,不得并列或混装半套。
|
1. 输出合法 JSON。`交互范式` 与推进合同在同一份正文里。`用户输入用法` 只能是三者之一。`停笔时机` 是唯一停笔定义;`续写要求` 只写如何走到该停点。不写选项清单。
|
||||||
2. `扩写要求` 只写「把本轮材料演成场面」怎么做;`续写要求` 只写「材料结束后往下写」怎么做。不要把另一种输入用法的规则抄进这两项。两项占比合计 100%,字数预算与目标字数对应。
|
2. `扩写要求` 只写「把本轮材料演成场面」怎么做;`续写要求` 只写「材料结束后往下写」怎么做。不要把另一种输入用法的规则抄进这两项。两项占比合计 100%,字数预算与目标字数对应。
|
||||||
3. `推进方式` 是单个字符串;`驱动力`、`成败与难度`、`变量钩子` 可选,`变量钩子` 不写公式或数值。
|
3. `推进方式` 是单个字符串;`驱动力`、`成败与难度`、`变量钩子` 可选,`变量钩子` 不写公式或数值。
|
||||||
4. `选用偏好` 默认 1~2 项;无合适条目时可 `[]`,但仍须钉死唯一用法。
|
4. `选用偏好` 默认 1~2 项;无合适条目时可 `[]`,但仍须钉死唯一用法。
|
||||||
@@ -143,7 +175,7 @@ feeds: gm,narrator
|
|||||||
## checklist
|
## checklist
|
||||||
|
|
||||||
```checklist
|
```checklist
|
||||||
- [ ] 已继承交互范式的描写权限与停笔时机并落进本步?
|
- [ ] `交互范式` 已写清逐类权限、裁定和唯一停笔时机,并与输入用法一致?
|
||||||
- [ ] 推进方式已写(不明则「无专门推进」),未发明公式、进度表或事件池?
|
- [ ] 推进方式已写(不明则「无专门推进」),未发明公式、进度表或事件池?
|
||||||
- [ ] 只选定一种用户输入用法,且符合它的适用场景?
|
- [ ] 只选定一种用户输入用法,且符合它的适用场景?
|
||||||
- [ ] 扩写与续写已按两段工作分别写清(扩写=演本轮材料,续写=材料后往下写),且与所选用法相符?
|
- [ ] 扩写与续写已按两段工作分别写清(扩写=演本轮材料,续写=材料后往下写),且与所选用法相符?
|
||||||
|
|||||||
@@ -7,15 +7,20 @@ name: 拓扑图谱
|
|||||||
id: topology
|
id: topology
|
||||||
artifact: 设计.拓扑图谱
|
artifact: 设计.拓扑图谱
|
||||||
declaration: >
|
declaration: >
|
||||||
为一个独有但结构复杂的对象定义节点、连接、初始图与开场后的维护规则;可用于地图、关系链、升级树等可变备忘录。
|
为一个独有但结构复杂的对象定义节点、连接、初始图与开场后的维护规则。
|
||||||
|
地图、关系链、升级树,以及社会阶梯、境界、价目、兑换等级这类有序标尺,都在这里;一份产物一张图。
|
||||||
when: |
|
when: |
|
||||||
单个对象要用节点和连接才能写清时使用,例如地图、升级路径、关系网。
|
要保存的结构是树、图或链时使用:节点之间有上下、相邻或交叉关系,拿掉连接就说不清。
|
||||||
|
树:升级路径、分支前提、从属。
|
||||||
|
图:地图、关系网,节点可多向相连。
|
||||||
|
链:社会阶梯、境界、价目、兑换等级,每一档只和相邻档比较。
|
||||||
一张图一份产物。不因开场后会不会再长节点而改用别的能力。
|
一张图一份产物。不因开场后会不会再长节点而改用别的能力。
|
||||||
when_not: |
|
when_not: |
|
||||||
没有节点和连接,只是同类对象的多份记录,或一份要先审字段的合同 → 生成规则,再按需填具体实例。
|
没有节点和连接,只是同类对象的多份记录,或一份要先审字段的合同 → 生成规则,再按需填具体实例。
|
||||||
只有一条字段很少的具名内容,且用户明确要留下 → 具体实例。
|
只有一条字段很少的具名内容,且用户明确要留下 → 具体实例。
|
||||||
只有地点、势力或社会格局的叙述,没有可定义的连接 → 舞台骨架。
|
只有地点、势力或社会格局的叙述,没有可定义的连接 → 舞台骨架。
|
||||||
只需保存少量数值或枚举 → 变量设计。
|
世界里成立的因果、触碰的代价 → 世界法则;那里只点本图的图名,不抄档表。
|
||||||
|
只需保存少量数值或枚举(当前点数、当前落在哪一档)→ 变量设计。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力定义一张图的结构真相与维护合同,不负责界面、SVG、排版或是否展示。
|
本能力定义一张图的结构真相与维护合同,不负责界面、SVG、排版或是否展示。
|
||||||
「正文组成」决定是否渲染以及显示入口;维护合同编译工具负责把本产物的更新要求交给运行期维护者。
|
「正文组成」决定是否渲染以及显示入口;维护合同编译工具负责把本产物的更新要求交给运行期维护者。
|
||||||
@@ -26,7 +31,7 @@ feeds: gm,auditor
|
|||||||
## opening
|
## opening
|
||||||
|
|
||||||
```opening
|
```opening
|
||||||
说说要保存的这张图是什么、游玩时用它记住什么,以及开场后它应保持固定、只记录状态,还是允许继续长出节点和连接。
|
说说要保存的这张图是什么。地图、关系、升级树,或是一套有序标尺(社会阶梯、境界、价目、兑换等级)都可以。再补一句:开场后它保持固定、只记录状态,还是允许继续长出节点和连接。
|
||||||
```
|
```
|
||||||
|
|
||||||
## task
|
## task
|
||||||
@@ -34,6 +39,8 @@ feeds: gm,auditor
|
|||||||
```task
|
```task
|
||||||
为一个独有的结构对象设计拓扑图谱,写入 `设计.拓扑图谱#图名`。每份产物只定义一张图:先写名称与用途,再定义节点字段、连接字段和初始图,最后写开场后的动态模式与维护规则。
|
为一个独有的结构对象设计拓扑图谱,写入 `设计.拓扑图谱#图名`。每份产物只定义一张图:先写名称与用途,再定义节点字段、连接字段和初始图,最后写开场后的动态模式与维护规则。
|
||||||
|
|
||||||
|
有序标尺写成一条链,类型用 `path`:一档一个节点,连接只连相邻档。节点写这一档是谁、能做什么,或这一档的基价与强度;连接写跨过这一档要什么、和下一档差多少。档位在开场前已由用户定死时用 `static`。人物、物品、变量只填档名,不在别处重抄整表。
|
||||||
|
|
||||||
动态模式只选一种:
|
动态模式只选一种:
|
||||||
- `static`:节点、连接和状态均固定。
|
- `static`:节点、连接和状态均固定。
|
||||||
- `record`:节点与连接固定,但可记录发现、备注或节点/连接状态。
|
- `record`:节点与连接固定,但可记录发现、备注或节点/连接状态。
|
||||||
@@ -47,7 +54,7 @@ feeds: gm,auditor
|
|||||||
## principles
|
## principles
|
||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 一产物一张图。不同地图、关系网或升级树分别调用本能力,不能塞进同一 `拓扑` 数组。
|
1. 一产物一张图。不同地图、关系网、升级树或标尺分别调用本能力,不能塞进同一 `拓扑` 数组。社会阶梯和价目是两张图。
|
||||||
2. 用途决定字段。节点与连接只保留识别、推演和维护所需信息;字段不重叠,不把节点写成完整名片。
|
2. 用途决定字段。节点与连接只保留识别、推演和维护所需信息;字段不重叠,不把节点写成完整名片。
|
||||||
3. 节点 id 稳定且唯一;连接只能引用已存在节点,并明确方向、关系和必要属性。
|
3. 节点 id 稳定且唯一;连接只能引用已存在节点,并明确方向、关系和必要属性。
|
||||||
4. 动态模式决定维护权限:`static` 无运行写入;`record` 只改允许的状态字段;`expand` 才能改变图结构。
|
4. 动态模式决定维护权限:`static` 无运行写入;`record` 只改允许的状态字段;`expand` 才能改变图结构。
|
||||||
@@ -157,9 +164,9 @@ feeds: gm,auditor
|
|||||||
|
|
||||||
```score
|
```score
|
||||||
对象适配:
|
对象适配:
|
||||||
- 5:确实是一张独有的关系结构,普通实例或扁平变量不足以表达。
|
- 5:确实是一张独有的关系结构或有序标尺,普通实例或扁平变量不足以表达。
|
||||||
- 3:存在真实连接,但部分内容可移交舞台、实例或变量。
|
- 3:存在真实连接,但部分内容可移交舞台、实例或变量。
|
||||||
- 1:只是清单、名片或背景散文,没有拓扑必要性。
|
- 1:只是互不相连的清单、名片或背景散文。
|
||||||
|
|
||||||
字段合同:
|
字段合同:
|
||||||
- 5:节点与连接字段由用途推出,数量恰当、互不重叠,更新权限明确。
|
- 5:节点与连接字段由用途推出,数量恰当、互不重叠,更新权限明确。
|
||||||
@@ -199,6 +206,8 @@ feeds: gm,auditor
|
|||||||
- 固定空间:节点与通路固定;若只需记录“已发现/封锁”,使用 record,不因探索重新生成建筑结构。
|
- 固定空间:节点与通路固定;若只需记录“已发现/封锁”,使用 record,不因探索重新生成建筑结构。
|
||||||
- 关系网络:人物节点固定或可扩展,关系边可记录类型、强度与公开状态;关系变化必须由正文中的已成立事实触发。
|
- 关系网络:人物节点固定或可扩展,关系边可记录类型、强度与公开状态;关系变化必须由正文中的已成立事实触发。
|
||||||
- 升级树:阶段和前置连接固定时用 static;若允许游玩中发现新分支才用 expand,并写清新增分支必须满足的前置关系。
|
- 升级树:阶段和前置连接固定时用 static;若允许游玩中发现新分支才用 expand,并写清新增分支必须满足的前置关系。
|
||||||
|
- 社会阶梯:节点 t0 到 t5。t0 写「冠号大宗师里的顶尖,不动用核武器无法威胁」;t5 写「普通人,暗面对他们是都市传说」。连接只连相邻档,字段写跨档要什么(个人武力、开宗立派、表世界权位)。`static`。实例只填「t2」。
|
||||||
|
- 价目或兑换等级:一档一个节点,字段写强度范围和基价;连接写相邻档的价差。物品规则和变量只填档名与当前点数。
|
||||||
- 不合适:把若干互不相连的人物简介装进节点;应改用生成规则,需要留下的单条再写具体实例。
|
- 不合适:把若干互不相连的人物简介装进节点;应改用生成规则,需要留下的单条再写具体实例。
|
||||||
- 不合适:在本产物中指定 SVG 坐标、颜色和 Tab;展示应由正文组成决定。
|
- 不合适:在本产物中指定 SVG 坐标、颜色和 Tab;展示应由正文组成决定。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ when: |
|
|||||||
某项状态若不持久保存,就会导致数值、阶段、资源、门控或已发生事实跨轮漂移时使用。
|
某项状态若不持久保存,就会导致数值、阶段、资源、门控或已发生事实跨轮漂移时使用。
|
||||||
when_not: |
|
when_not: |
|
||||||
状态只需从最近正文理解、无需查询或门控 → 不使用。
|
状态只需从最近正文理解、无需查询或门控 → 不使用。
|
||||||
节点与连接构成的地图、关系或路径 → 拓扑图谱。
|
节点与连接构成的地图、关系、路径,以及社会阶梯、价目、兑换等级的全表 → 拓扑图谱。本步只存当前点数或当前档位。
|
||||||
大段设定或同类对象合同 → 舞台骨架 / 生成规则。
|
大段设定或同类对象合同 → 舞台骨架 / 生成规则。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力定义什么是真值、什么事实允许它改变,以及改变后的约束。
|
本能力定义什么是真值、什么事实允许它改变,以及改变后的约束。
|
||||||
@@ -30,7 +30,9 @@ feeds: gm,auditor
|
|||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
为本局定义跨轮真值及其更新规则,写入 `设计.变量设计与更新规则`。先做必要性检验,再为每个真值写稳定 id、类型、初始化要求、公开性、允许写入者、允许操作、范围和基于事实的更新规则。
|
为本局定义跨轮真值及其更新规则,写入 `设计.变量设计与更新规则`。先做必要性检验,再为每个真值写稳定 id、类型、初始化、公开性、允许写入者、允许操作、范围和基于事实的更新规则。
|
||||||
|
|
||||||
|
`初始化.默认值` 只放开局就要有、且具体实例里没有的那一条已发生事实:数字、枚举、布尔或空集合。人物是谁、关系、外貌和命门留在具体实例;变量要指向人时只存 id 或名字。
|
||||||
|
|
||||||
区分两类判断:
|
区分两类判断:
|
||||||
- `deterministic`:程序能从明确指令、公式或工具结果直接计算。
|
- `deterministic`:程序能从明确指令、公式或工具结果直接计算。
|
||||||
@@ -52,8 +54,9 @@ feeds: gm,auditor
|
|||||||
4. 类型决定操作:number/integer 可 `set|delta`;enum/string/boolean 可 `set`;set 可 `add|remove`。不用散文值绕过类型。
|
4. 类型决定操作:number/integer 可 `set|delta`;enum/string/boolean 可 `set`;set 可 `add|remove`。不用散文值绕过类型。
|
||||||
5. 公开性只区分用户可见与隐藏;更细的主世界层、转述、旁观投影交给变量控制上下文。
|
5. 公开性只区分用户可见与隐藏;更细的主世界层、转述、旁观投影交给变量控制上下文。
|
||||||
6. 阈值映射只保存引用和选取条件;长文内容仍在生成规则/具体实例中。
|
6. 阈值映射只保存引用和选取条件;长文内容仍在生成规则/具体实例中。
|
||||||
7. 「维护复杂度」只写给旁观看的难点备注(交叉依赖、叙事推断证据),不决定开不开旁观;有真值即旁观填表。
|
7. 默认值匹配类型,且短。人物档案、关系长文、外貌和命门不写进默认值;实例里已有的事实不在默认值再写一遍。
|
||||||
8. 不在正文中设计隐藏标签、正则或维护 JSON;主世界不承担隐藏填表通道。
|
8. 「维护复杂度」只写给旁观看的难点备注(交叉依赖、叙事推断证据),不决定开不开旁观;有真值即旁观填表。
|
||||||
|
9. 不在正文中设计隐藏标签、正则或维护 JSON;主世界不承担隐藏填表通道。
|
||||||
```
|
```
|
||||||
|
|
||||||
## probe
|
## probe
|
||||||
@@ -79,7 +82,7 @@ feeds: gm,auditor
|
|||||||
"用途": "不保存它会造成什么跨轮错误",
|
"用途": "不保存它会造成什么跨轮错误",
|
||||||
"类型": "number|integer|enum|string|boolean|set",
|
"类型": "number|integer|enum|string|boolean|set",
|
||||||
"初始化": {
|
"初始化": {
|
||||||
"默认值": 0,
|
"默认值": "数字、枚举、布尔或空集合;不写人物档案",
|
||||||
"开场确定规则": "开场可覆盖默认值时写条件;否则写固定"
|
"开场确定规则": "开场可覆盖默认值时写条件;否则写固定"
|
||||||
},
|
},
|
||||||
"公开性": "public|hidden",
|
"公开性": "public|hidden",
|
||||||
@@ -147,7 +150,7 @@ feeds: gm,auditor
|
|||||||
|
|
||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON;每个真值必须有稳定 `id`、用途、类型、初始化、权限、操作、约束和更新规则。
|
1. 输出合法 JSON;每个真值必须有稳定 `id`、用途、类型、初始化、权限、操作、约束和更新规则。
|
||||||
2. 类型与允许操作必须相容;number/integer 的边界、enum 的枚举值必须明确;不适用的约束字段省略。
|
2. 类型与允许操作必须相容;number/integer 的边界、enum 的枚举值必须明确;不适用的约束字段省略。`默认值` 与类型一致,且不是人物档案或关系长文。
|
||||||
3. `narrative_inference` 必须写证据要求;正文没有该证据时不更新。
|
3. `narrative_inference` 必须写证据要求;正文没有该证据时不更新。
|
||||||
4. 派生映射与触发规则可为空;存在时必须引用已声明变量,且派生结果不得成为第二个可写真值。
|
4. 派生映射与触发规则可为空;存在时必须引用已声明变量,且派生结果不得成为第二个可写真值。
|
||||||
5. `维护复杂度` 可选;有则写备注与依据给旁观,勿再写 `建议: inline_*` 指望关旁观。有真值时程序固定 `play_slots.auditor=true`。勿手写 `play_slots`。
|
5. `维护复杂度` 可选;有则写备注与依据给旁观,勿再写 `建议: inline_*` 指望关旁观。有真值时程序固定 `play_slots.auditor=true`。勿手写 `play_slots`。
|
||||||
@@ -164,9 +167,9 @@ feeds: gm,auditor
|
|||||||
- 1:把人物感受、场景描述或大段设定全部变量化。
|
- 1:把人物感受、场景描述或大段设定全部变量化。
|
||||||
|
|
||||||
字段妥当性:
|
字段妥当性:
|
||||||
- 5:类型、初值、操作和约束彼此一致,字段数量恰当且不重叠。
|
- 5:类型、初值、操作和约束彼此一致,字段数量恰当且不重叠。默认值是短值,人物事实在具体实例。
|
||||||
- 3:基本可用,但有边界、枚举或初始化要求不够明确。
|
- 3:基本可用,但有边界、枚举或初始化要求不够明确。
|
||||||
- 1:类型与操作冲突,或存在余额/存款一类重复真值。
|
- 1:类型与操作冲突,存在余额/存款一类重复真值,或默认值是一段人物关系。
|
||||||
|
|
||||||
更新可执行性:
|
更新可执行性:
|
||||||
- 5:每条规则都有已发生事实、判断方式、证据、操作、值规则和时机。
|
- 5:每条规则都有已发生事实、判断方式、证据、操作、值规则和时机。
|
||||||
@@ -188,7 +191,7 @@ feeds: gm,auditor
|
|||||||
|
|
||||||
```checklist
|
```checklist
|
||||||
- [ ] 每个真值都通过“不保存会出错”的必要性检验?
|
- [ ] 每个真值都通过“不保存会出错”的必要性检验?
|
||||||
- [ ] 类型、初值、允许操作、约束和写入者互相一致?
|
- [ ] 默认值是短值,没有把人物关系或实例里已有的事实再写一遍?
|
||||||
- [ ] 每条更新规则都有事实证据和可执行的值规则?
|
- [ ] 每条更新规则都有事实证据和可执行的值规则?
|
||||||
- [ ] 派生映射未被重复建成可写真值?
|
- [ ] 派生映射未被重复建成可写真值?
|
||||||
- [ ] 维护复杂度若写了,是旁观备注而非开槽开关?
|
- [ ] 维护复杂度若写了,是旁观备注而非开槽开关?
|
||||||
@@ -198,8 +201,11 @@ feeds: gm,auditor
|
|||||||
## examples
|
## examples
|
||||||
|
|
||||||
```examples
|
```examples
|
||||||
- 单个数值真值或关系矩阵:都写清更新规则;有真值即由旁观副 LLM 填表,主世界只写正文。
|
- 单个数值真值或关系矩阵:都写清更新规则;有真值即由旁观副 LLM 填表,主世界只写正文。关系矩阵的默认值是空集合或少量 id,不是每人一段小传。
|
||||||
- 多个真值需结合正文共同判断:在维护复杂度.备注里点出推断难点与证据,供旁观读。
|
- 多个真值需结合正文共同判断:在维护复杂度.备注里点出推断难点与证据,供旁观读。
|
||||||
- 当前态度由好感区间映射得到:只保存好感,态度作为派生映射,不建立第二个可写“当前态度”。
|
- 当前态度由好感区间映射得到:只保存好感,态度作为派生映射,不建立第二个可写“当前态度”。
|
||||||
- 不合适:每轮重写一段“当前性格”长字符串,或只写“随剧情变化”而没有证据与操作。
|
|
||||||
|
偏了就改回:
|
||||||
|
- 默认值写成十几个角色的关系、外貌或命门 → 档案留在具体实例,这里改成数字、枚举或 id 列表。
|
||||||
|
- 每轮重写一段“当前性格”长字符串,或只写“随剧情变化”而没有证据与操作 → 改成有证据的 set/delta,或取消该真值。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -7,42 +7,48 @@ name: 舞台骨架
|
|||||||
id: world-blueprint
|
id: world-blueprint
|
||||||
artifact: 设计.舞台骨架
|
artifact: 设计.舞台骨架
|
||||||
declaration: >
|
declaration: >
|
||||||
保存会在游玩中反复影响世界反馈的外部舞台:熟悉基底、必要变造、核心运转和主要活动场所。
|
保存会反复影响世界反馈的外部舞台:熟悉基底、必要变造、核心运转和主要活动场所。
|
||||||
|
推进时据此知道该推哪些地方,以及玩家行为在哪个尺度上产生回馈。
|
||||||
when: |
|
when: |
|
||||||
核心体验依赖外部环境、社会力量或世界状况持续作出一致反馈,而现有材料还不足以支撑这些反馈时使用。
|
核心体验依赖外部环境、社会力量或世界状况持续作出一致反馈,而现有材料还不足以支撑这些反馈时使用。
|
||||||
是否换区不是判断标准:跨越多地可以需要舞台骨架;一室之内的朝堂、审讯或家庭关系同样可以需要。
|
是否换区不是判断标准:跨越多地可以需要舞台骨架;一室之内的朝堂、审讯或家庭关系同样可以需要。
|
||||||
when_not: |
|
when_not: |
|
||||||
熟悉现实基底已经够用,地点和外部格局不会改变核心体验或世界反馈时不使用。 既成硬法则归实现机制;多份人物或要先审字段的名片归生成规则,单条字段很少的具名内容才归具体实例;重点在节点与连接本身时归拓扑图谱。
|
熟悉现实基底已经够用,地点和外部格局不会改变核心体验或世界反馈时不使用。
|
||||||
|
既成硬法则归世界法则;本步最多引用其台上结果。
|
||||||
|
多份人物或要先审字段的名片归生成规则;单条字段很少的具名内容归具体实例。
|
||||||
|
重点在节点与连接本身时归拓扑图谱。
|
||||||
boundary: |
|
boundary: |
|
||||||
本能力只交会上台并反复有用的外部事实,其余沿用熟悉常识或明确留白。
|
本步交四块会上台并反复有用的外部事实:熟悉基底、必要变造、核心运转、主要活动场所。
|
||||||
不重写核心体验,不解释硬法则起源,不制作人物名片、完整地图、历史或百科。
|
在这四块上再写清推进落点与回馈尺度:该推哪些地方,玩家行为在哪一层产生回馈。
|
||||||
|
熟悉常识默认继承;体验碰不到的范围写进留白。硬法则正文、人物名片、完整地图、历史与百科交给对应能力。
|
||||||
feeds: gm
|
feeds: gm
|
||||||
```
|
```
|
||||||
|
|
||||||
## opening
|
## opening
|
||||||
|
|
||||||
```opening
|
```opening
|
||||||
说说这局里会反复影响你的外部舞台:它以什么熟悉世界为基底,哪里不同,哪些场所、关系或世界状况会持续参与反馈。只写真正会上台的部分即可。
|
说说这局里会反复影响你的外部舞台:以什么熟悉世界为基底,哪里不同,哪些运转和场所会持续参与反馈。再补一句:推进时该推哪些地方,你的行为会在哪一层尺度上回馈。
|
||||||
```
|
```
|
||||||
|
|
||||||
## task
|
## task
|
||||||
|
|
||||||
```task
|
```task
|
||||||
把用户材料整理为一份可供主世界层稳定反馈的舞台骨架,写入 `设计.舞台骨架`。先确定最短的熟悉基底与必要变造,再提取会反复影响玩家的社会关系、环境条件和主要场所;体验碰不到的范围明确留白。
|
把材料整理为 `设计.舞台骨架`。先写最短的 `熟悉基底` 和回馈 `尺度`,再列会改变台上反馈的 `必要变造`,然后写 `核心运转` 和会反复上台的 `主要舞台`;碰不到的范围进 `留白边界`。
|
||||||
|
|
||||||
材料足够便直接成稿。若两种舞台尺度或运转条件会导致明显不同的世界反馈,把这一处缺口交给 `self_score.gap`;产物正文不放评分、诊断、追问或开放问题。
|
四块写完后,核对推进能不能用:`主要舞台` 是该推的地方;`尺度`(或某一舞台自有的一层)是玩家行为产生回馈的层级。`核心运转` 写力量或状况怎样持续影响玩家;`玩家如何接触` 写这一处怎么进场、碰到什么、回馈落在哪。两处说同一句时,删舞台侧的因果复读,场所与回馈层留下。
|
||||||
|
|
||||||
|
材料够就成稿。两种尺度或运转会导致明显不同反馈、又无法从材料判定时,缺口进 `self_score.gap`。产物正文只含舞台 JSON,不含评分、诊断或追问。
|
||||||
```
|
```
|
||||||
|
|
||||||
## principles
|
## principles
|
||||||
|
|
||||||
```principles
|
```principles
|
||||||
1. 舞台以世界怎样回应玩家为准,不以是否换区、地图大小或题材类型为准。
|
1. 舞台以世界怎样回应玩家为准,不以换区数量、地图大小或题材类型为准。
|
||||||
2. 尺度由核心体验会碰到的边界决定。同是“现代都市”基底:体验作为韩国顶级财阀,尺度升到地球级势力、国家与资本网络,主要舞台写到财团、宴会、听证层;体验普通大学生活,尺度是一座城或一所学校,主要舞台写到系馆与社团层。按体验正推,写“需要跨国资本压迫感 → 升到国家层”,不写“因为是校园文所以不要国际政治”。
|
2. `尺度` 由体验会碰到的边界正推,也是行为回馈的层级。同是「现代都市」:财阀压迫收到国家与资本网;大学生活收到一座城或一所学校。某一舞台的回馈层与全局不同时,写在该舞台上。
|
||||||
3. 熟悉常识默认继承,只写相对基底的必要变造,以及会反复参与体验的外部事实。
|
3. 熟悉常识默认继承。`必要变造` 只列相对基底、且会改变世界反馈的差异(一句一条)。差异本质是因果法则时,法则进世界法则;本步写台上看得见的结果。
|
||||||
4. 核心运转可以是社会力量、阶层关系、周期事件、资源条件、禁忌或日常秩序;只说明它如何作用于玩家和主要舞台。用户已点名的灾变、祭祀、异界出口等世界状况必须写进核心运转。
|
4. `核心运转` 是社会力量、阶层关系、周期事件、资源、禁忌、日常秩序,以及用户点名的灾变、祭祀、异界出口。写它怎样持续影响玩家和主要舞台。
|
||||||
5. 主要舞台可以是空间、机构、社会层或一间房;写到能稳定反馈为止:玩家在这里做什么、会碰到谁或什么、得到什么反馈,不只点名。
|
5. `主要舞台` 是会反复上台的空间、机构、社会层或一间房,也是推进时要推的地方。写玩家在这里做什么、会碰到谁或什么、回馈落在哪一层,写到能稳定反馈为止。玩法标签不代替场所。
|
||||||
6. 既成硬法则只引用其结果,规则本身交给实现机制;多份或要先审字段的具名人物交给生成规则,单条字段很少的具名内容才交给具体实例。多张名片共享的台上格局与世界状况写在本能力,生成规则只引用,不在规则里重抄。
|
6. 硬法则只引用台上结果;多张名片共享的台上格局写在本步,生成规则只引用。用户未触及的地区、历史、势力不补全,进留白。类型名只作氛围参照,不自动塞该类型常见地理与谱系。
|
||||||
7. 用户未触及的地区、历史、物种、民俗和势力不补全,在留白边界中说明即可。类型(西幻、修仙、末日等)只作体裁与氛围参照,不是必选题单:不因题材自动塞入该类型常见的地理与势力;不为显得严谨补起源史、伪科学或神话谱系,除非它们本身会上台。
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## output
|
## output
|
||||||
@@ -51,13 +57,13 @@ feeds: gm
|
|||||||
{
|
{
|
||||||
"schema": "context-fragment.v1",
|
"schema": "context-fragment.v1",
|
||||||
"技能": "舞台骨架",
|
"技能": "舞台骨架",
|
||||||
"brief": "一句话说明什么外部舞台在支撑核心体验",
|
"brief": "一句话:什么外部舞台在支撑反馈,推进该推哪,回馈落在哪一层",
|
||||||
"mount": ["world-simulator"],
|
"mount": ["world-simulator"],
|
||||||
"稳变": "stable",
|
"稳变": "stable",
|
||||||
"正文": {
|
"正文": {
|
||||||
"舞台基底": {
|
"舞台基底": {
|
||||||
"熟悉基底": "现实或常见类型中可直接继承的最短描述",
|
"熟悉基底": "现实或常见类型中可直接继承的最短描述",
|
||||||
"尺度": "体验会碰到的边界,如国家与资本网络、一座城、一所学校、一间殿;熟悉基底已说明时省略",
|
"尺度": "体验边界,也是行为回馈的层级;与熟悉基底同一句时可并入基底",
|
||||||
"必要变造": ["只列会改变世界反馈的差异;无则省略"]
|
"必要变造": ["只列会改变世界反馈的差异;无则省略"]
|
||||||
},
|
},
|
||||||
"核心运转": [
|
"核心运转": [
|
||||||
@@ -70,8 +76,8 @@ feeds: gm
|
|||||||
"主要舞台": [
|
"主要舞台": [
|
||||||
{
|
{
|
||||||
"名称": "会反复上台的空间、机构或社会层",
|
"名称": "会反复上台的空间、机构或社会层",
|
||||||
"玩家如何接触": "玩家在这里做什么、会碰到谁或什么、受到什么反馈;写到能稳定反馈的具体程度,不只点名",
|
"玩家如何接触": "在这里做什么、碰到谁或什么、回馈落在哪一层",
|
||||||
"关键运转": ["仅引用与此处直接相关的核心运转"]
|
"关键运转": ["仅引用与此处直接相关的核心运转名称"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"留白边界": ["本局不会上台、只继承常识或刻意不展开的范围"]
|
"留白边界": ["本局不会上台、只继承常识或刻意不展开的范围"]
|
||||||
@@ -80,48 +86,52 @@ feeds: gm
|
|||||||
```
|
```
|
||||||
|
|
||||||
硬规则:
|
硬规则:
|
||||||
1. 输出合法 JSON;未知或无内容的可选字段直接省略,不写“未定”占位。
|
1. 合法 JSON;无内容的可选字段整项省略;`mount` 固定 `["world-simulator"]`。
|
||||||
2. `核心运转` 统一容纳必要的社会结构与世界状况,不为分类而补内容。
|
2. 四块都写:熟悉基底、必要变造(无则省略该项)、核心运转、主要舞台。`主要舞台` 是推进落点,不因与运转相关就删掉。
|
||||||
3. `主要舞台` 只写会反复上台且需要稳定反馈的部分;单一房间也可成为完整舞台。
|
3. `玩家如何接触` 不复述对应运转的 `如何作用`;复读删因果句,场所与回馈层留下。`关键运转` 只写名称。
|
||||||
4. 不在产物正文写自评、追问、开放问题、覆盖检验或设定诊断。
|
4. `必要变造` 不写法则全文、价目或检定。正文不含评分、诊断、追问、百科或人物名片。
|
||||||
5. `mount` 固定为 `["world-simulator"]`。
|
|
||||||
|
|
||||||
## score
|
## score
|
||||||
|
|
||||||
```score
|
```score
|
||||||
支撑度:
|
支撑度:
|
||||||
- 5:核心体验依赖的舞台、关系和运转条件均足以支持稳定世界反馈。
|
- 5:熟悉基底、变造、运转和主要场所足以稳定反馈;并能指出该推哪些地方、行为在哪一层回馈。
|
||||||
- 3:方向正确,但缺少一项会反复影响体验的环境或社会条件,或其作用尚不明确。
|
- 3:四块方向对,但缺一项会反复影响体验的条件,或回馈层不清。
|
||||||
- 1:只有题材或地点标签,无法据此判断世界如何回应玩家。
|
- 1:只有题材或地点标签,世界如何回应、推进该推哪都无从判断。
|
||||||
|
|
||||||
克制度:
|
克制度:
|
||||||
- 5:只写会上台且反复有用的内容,熟悉常识默认继承,其余明确留白。
|
- 5:只写会上台且反复有用的内容;常识继承;其余留白;运转与舞台无同义复读。
|
||||||
- 3:夹带少量暂时不会影响游玩的地图、历史或设定。
|
- 3:夹带少量暂不影响游玩的设定,或有一处舞台在复读运转。
|
||||||
- 1:写成完整世界百科,或因题材自动塞满该类型常见地理与势力,核心舞台反而不突出。
|
- 1:写成世界百科,或把变造、舞台写成硬法则与玩法标签。
|
||||||
```
|
```
|
||||||
|
|
||||||
## checklist
|
## checklist
|
||||||
|
|
||||||
```checklist
|
```checklist
|
||||||
- [ ] 舞台内容足以让主世界层稳定回应玩家?
|
- [ ] 熟悉基底、必要变造、核心运转、主要舞台四块都在(无变造则该项省略)?
|
||||||
- [ ] 判断依据是外部反馈,而不是换区数量或地图大小?
|
- [ ] 推进时能从主要舞台选出该推的地方?
|
||||||
- [ ] 核心运转只含会反复作用的关系与状况?
|
- [ ] 行为回馈的尺度已写明?
|
||||||
- [ ] 主要舞台详细度够用即止,未触及范围已留白?
|
- [ ] 舞台未把运转因果换壳重写,也未写成玩法标签?
|
||||||
- [ ] 未重写体验、硬法则、人物名片、完整历史或百科?
|
- [ ] 未触及范围已留白;共享格局写在本步;硬法则未展开?
|
||||||
- [ ] 尺度由体验正推?多张名片共享的格局已写在本步?
|
- [ ] 正文无评分、诊断、追问?
|
||||||
- [ ] 产物正文不含评分、诊断、追问和开放问题?
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## examples
|
## examples
|
||||||
|
|
||||||
```examples
|
```examples
|
||||||
好:
|
好:
|
||||||
- 体验普通大学生活:熟悉基底为现代都市,尺度是一所学校;主要舞台写到系馆、宿舍、社团,各写玩家在那里做什么、受到什么反馈;国家政治列入留白边界。
|
- 普通大学生活:熟悉基底是现代都市,尺度是一所学校(冲突回馈落在系馆与宿舍,不落到国家)。必要变造若无则省略。主要舞台写系馆、宿舍、社团:玩家在那里做什么、碰到谁、回馈落在校园这一层。国家政治进留白。
|
||||||
- 核心运转写世界状况:“每年寒灾”“南极通往怪物世界的出口”“村中活人祭祀”各写如何作用,如寒灾按年逼近、决定储粮与迁徙的代价;祭祀每年要交出活人,决定村里谁有话语权。
|
- 财阀压迫:同一都市基底,尺度升到国家与资本网。核心运转写资本与名声怎样持续施压;主要舞台写财团、宴会、听证,并写明一次出手的回馈落在资本与名声层。
|
||||||
- 一室朝堂:主要舞台只有一座殿,核心运转是皇权、派系与言官的相互牵制,如何作用写清每次奏对会被谁记账、牵动哪一方。
|
- 一室朝堂:主要舞台只有一座殿。核心运转写皇权与派系怎样在奏对里记账;接触栏写入殿后对面是谁、当场回馈是被谁记账。
|
||||||
|
- 必要变造短写:「市井当古武是传言,权贵圈才兑现」;刺杀阈值等因果进世界法则。
|
||||||
|
|
||||||
坏:
|
缺料时:
|
||||||
- 只写山脉河流,不写用户已点名的祭祀、灾变、异界出口:世界怎样回应玩家仍无从判断。
|
- 两种尺度都会改反馈且材料未钉 → `self_score.gap`。
|
||||||
- “地点1:XX咖啡馆,店主叫…”:这是具体实例的活;本能力只写这类场所在舞台上怎样作用于玩家。
|
- 只有咖啡馆店主姓名、无台上作用 → 留给具体实例,不写进本步。
|
||||||
- 把多张名片共享的台上格局丢给生成规则写“共通前提”:格局写在本能力,生成规则只引用。
|
|
||||||
|
偏了就改回:
|
||||||
|
- 用「该推哪里、回馈哪一层」替换掉基底、变造、运转、场所 → 四块补回,落点与尺度写在尺度和主要舞台上。
|
||||||
|
- 接触栏只是把「如何作用」换壳 → 删复读,留下进场、在场对象和这一处的回馈层。
|
||||||
|
- 必要变造写成核威慑长因果 → 因果进机制,变造改成台上可见的圈层差异。
|
||||||
|
- 接触栏写成「正义堕落、战败调教」→ 改成该场所谁进出、玩家以何身份入场、回馈落在哪一层。
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ when_not: |
|
|||||||
boundary: |
|
boundary: |
|
||||||
0层回复呈现决定槽位内容和更新语义;本能力决定固定外壳、位置、渲染与更新应用。
|
0层回复呈现决定槽位内容和更新语义;本能力决定固定外壳、位置、渲染与更新应用。
|
||||||
权威状态由程序投影;本能力只消费数据,不让正文模型重复生成。
|
权威状态由程序投影;本能力只消费数据,不让正文模型重复生成。
|
||||||
feeds: gm,narrator
|
创作时一次写好 frontend 并由程序持有;游玩不把 html/css/js 塞进模型上下文。
|
||||||
|
程序按数据契约要求模型只交槽位更新结构化内容,再灌进已固定的壳。
|
||||||
|
feeds: program
|
||||||
```
|
```
|
||||||
|
|
||||||
## opening
|
## opening
|
||||||
@@ -73,7 +75,7 @@ HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变
|
|||||||
"schema": "context-fragment.v1",
|
"schema": "context-fragment.v1",
|
||||||
"技能": "0层正文组成",
|
"技能": "0层正文组成",
|
||||||
"brief": "一句话说明持久卡的主工作面、辅助导航与更新方式",
|
"brief": "一句话说明持久卡的主工作面、辅助导航与更新方式",
|
||||||
"mount": ["world-simulator", "narrator"],
|
"mount": [],
|
||||||
"稳变": "stable",
|
"稳变": "stable",
|
||||||
"正文": {
|
"正文": {
|
||||||
"参考壳": [
|
"参考壳": [
|
||||||
@@ -162,7 +164,7 @@ HTML、CSS、JS 合计不超过 60 KB。材料足够便直接成稿;会改变
|
|||||||
7. topology 读取结构化节点与连接绘制 SVG,并提供列表降级。
|
7. topology 读取结构化节点与连接绘制 SVG,并提供列表降级。
|
||||||
8. 预览是非正史验收数据,不写成开场。
|
8. 预览是非正史验收数据,不写成开场。
|
||||||
9. 产物正文不含自评、追问或开放问题。
|
9. 产物正文不含自评、追问或开放问题。
|
||||||
10. `mount` 固定为 `["world-simulator", "narrator"]`。
|
10. `mount` 固定为 `[]`:壳不进游玩投影;程序持有 frontend,只向模型注入数据契约。
|
||||||
|
|
||||||
## score
|
## score
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ feeds: gm,narrator
|
|||||||
},
|
},
|
||||||
"来源": {
|
"来源": {
|
||||||
"方式": "model_generated",
|
"方式": "model_generated",
|
||||||
"ref": "设计.故事推进"
|
"ref": "设计.故事推进与交互范式"
|
||||||
},
|
},
|
||||||
"内容要求": [
|
"内容要求": [
|
||||||
"遵守故事推进与叙事指南",
|
"遵守故事推进与叙事指南",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ core: >-
|
|||||||
世界舞台与状态机仅在体验真需要时才引入,默认不做世界模拟全套。
|
世界舞台与状态机仅在体验真需要时才引入,默认不做世界模拟全套。
|
||||||
|
|
||||||
process:
|
process:
|
||||||
- 开始通常先做「美学纲领与交互范式」。
|
- 开始通常先做「美学纲领」。
|
||||||
- 对照【能力 · 可选工序】的「何时用 / 何时不用」选型;调用条件以技能 meta 为准。
|
- 对照【能力 · 可选工序】的「何时用 / 何时不用」选型;调用条件以技能 meta 为准。
|
||||||
- 收成时由程序根据已确认需求整理「游玩执行方式」,再用「运行配置组装」生成可执行规格并 closed。
|
- 收成时由程序根据已确认需求整理「游玩执行方式」,再用「运行配置组装」生成可执行规格并 closed。
|
||||||
- 舞台、机制、变量、监控栏等:仅当对应技能「何时用」成立时再选,勿套世界模拟全套。
|
- 舞台、机制、变量、监控栏等:仅当对应技能「何时用」成立时再选,勿套世界模拟全套。
|
||||||
@@ -27,6 +27,6 @@ principles:
|
|||||||
brief: 扩写/写手类:先定站位与轮转,再落到大纲→分段写文规格
|
brief: 扩写/写手类:先定站位与轮转,再落到大纲→分段写文规格
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- id: 美学纲领与交互范式
|
- id: 美学纲领
|
||||||
name: 美学纲领与交互范式
|
name: 美学纲领
|
||||||
depends_on: []
|
depends_on: []
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ core: >-
|
|||||||
再由「运行配置组装」生成可执行规格。执行单元只用固定槽(主世界层必开 / 叙事转述默认关 / 可选角色视角;旁观按变量需要),不为表/百科再拆槽,不开机遇裁定槽。
|
再由「运行配置组装」生成可执行规格。执行单元只用固定槽(主世界层必开 / 叙事转述默认关 / 可选角色视角;旁观按变量需要),不为表/百科再拆槽,不开机遇裁定槽。
|
||||||
|
|
||||||
process:
|
process:
|
||||||
- 开始通常先做「美学纲领与交互范式」。
|
- 开始通常先做「美学纲领」。
|
||||||
- 读美学卡上用户会在意的部分,对照【能力 · 可选工序】的何时用 / 何时不用,把兑现那些承诺所需的节点写进 steps(需要几步排几步)。各技能调用条件以 meta 为准,本表不重复。
|
- 读美学卡上用户会在意的部分,对照【能力 · 可选工序】的何时用 / 何时不用,把兑现那些承诺所需的节点写进 steps(需要几步排几步)。各技能调用条件以 meta 为准,本表不重复。
|
||||||
- 世界模拟默认槽:仅主世界层(转述关、旁观关、视角关)。「游玩执行方式」由程序从独立文风层、变量维护和秘密隔离需求中整理。
|
- 世界模拟默认槽:仅主世界层(转述关、旁观关、视角关)。「游玩执行方式」由程序从独立文风层、变量维护和秘密隔离需求中整理。
|
||||||
- 中段按承诺写上下文;每块只标挂谁(mount)与稳/变,具体顺序留给自动「上下文排序」。挂载归属:世界/机制/生成/叙事指南 → 主世界层(无转述时叙事也挂主世界层);开了转述则叙事指南可挂转述;美学进常驻。
|
- 中段按承诺写上下文;每块只标挂谁(mount)与稳/变,具体顺序留给自动「上下文排序」。挂载归属:世界/机制/生成/叙事指南 → 主世界层(无转述时叙事也挂主世界层);开了转述则叙事指南可挂转述;美学进常驻。
|
||||||
@@ -29,12 +29,12 @@ principles:
|
|||||||
- 真值 / Data / Progressive 三分;派生性格与章细纲不落可写真值。
|
- 真值 / Data / Progressive 三分;派生性格与章细纲不落可写真值。
|
||||||
- 有编排参数的步骤:进执行前钉齐 params(askUser 选项+其它);缺参时先问再进 design-step。
|
- 有编排参数的步骤:进执行前钉齐 params(askUser 选项+其它);缺参时先问再进 design-step。
|
||||||
- 〔可反复〕可再追加(彻底新建);已验收 id 留在 steps。
|
- 〔可反复〕可再追加(彻底新建);已验收 id 留在 steps。
|
||||||
- 舞台骨架通常都排(备案,只分详细度);代入后几乎不移动、只在一处待着则不排。实现机制对照其 meta。
|
- 舞台骨架通常都排(备案,只分详细度);代入后几乎不移动、只在一处待着则不排。世界法则对照其 meta。
|
||||||
- 百科与查表归主世界层;可见正文默认由主世界层直接写;显式开转述时转述在主世界正文上改写/镶壳。
|
- 百科与查表归主世界层;可见正文默认由主世界层直接写;显式开转述时转述在主世界正文上改写/镶壳。
|
||||||
|
|
||||||
brief: 回合推演:美学卡上在意的部分→挂槽写上下文→晚排序投影→收成运行规格
|
brief: 回合推演:美学卡上在意的部分→挂槽写上下文→晚排序投影→收成运行规格
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- id: 美学纲领与交互范式
|
- id: 美学纲领
|
||||||
name: 美学纲领与交互范式
|
name: 美学纲领
|
||||||
depends_on: []
|
depends_on: []
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ suggested_context:
|
|||||||
- 设计.变量设计与更新规则
|
- 设计.变量设计与更新规则
|
||||||
- 设计.生成规则
|
- 设计.生成规则
|
||||||
- 设计.变量控制上下文
|
- 设计.变量控制上下文
|
||||||
|
- 设计.画面与生图
|
||||||
dynamic:
|
dynamic:
|
||||||
- 输出.用户展示
|
- 输出.用户展示
|
||||||
- 用户.最新输入
|
- 用户.最新输入
|
||||||
@@ -34,3 +35,4 @@ prompt_excerpt: |
|
|||||||
need_generate 仅在确需按某条 rule_id 补实例时写成 { "rule_id", "reason" }。
|
need_generate 仅在确需按某条 rule_id 补实例时写成 { "rule_id", "reason" }。
|
||||||
程序只会抽该规则里的元素池;方向池由主世界层按规则自创,不要指望抽签出标签。
|
程序只会抽该规则里的元素池;方向池由主世界层按规则自创,不要指望抽签出标签。
|
||||||
禁止编造长对话续写;禁止输出用户可见正文;禁止修改未点名的无关字段。
|
禁止编造长对话续写;禁止输出用户可见正文;禁止修改未点名的无关字段。
|
||||||
|
上下文里若有「设计.画面与生图」,填表时不使用它;table_ops 只按变量合同。画面提示词不写进 notes。
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ suggested_context:
|
|||||||
- 设计.叙事指南与故事推进
|
- 设计.叙事指南与故事推进
|
||||||
- 设计.叙事指南
|
- 设计.叙事指南
|
||||||
- 设计.故事推进
|
- 设计.故事推进
|
||||||
|
- 设计.故事推进与交互范式
|
||||||
- 设计.正文组成
|
- 设计.正文组成
|
||||||
- 设计.监控栏
|
- 设计.监控栏
|
||||||
dynamic:
|
dynamic:
|
||||||
@@ -41,7 +42,7 @@ prompt_excerpt: |
|
|||||||
monitor 用短键值(芯片),勿写长文。只填该壳已有区域。
|
monitor 用短键值(芯片),勿写长文。只填该壳已有区域。
|
||||||
用户主读正文(body)默认 1000~2000 字(汉字);以「设计.监控栏」回复呈现已钉字数为准。
|
用户主读正文(body)默认 1000~2000 字(汉字);以「设计.监控栏」回复呈现已钉字数为准。
|
||||||
采用完整长自然段叙述,不要单句成段;不要几句短信就结束。
|
采用完整长自然段叙述,不要单句成段;不要几句短信就结束。
|
||||||
用户输入用法以「设计.故事推进」或「设计.叙事指南与故事推进」为准:
|
用户输入用法以「设计.故事推进与交互范式」(旧档「设计.故事推进」)或「设计.叙事指南与故事推进」为准:
|
||||||
- 大纲扩写:把主世界正文当事实底稿写终稿。底稿里节拍之间的具体动作原样保留;文风只改遣词与节奏,不要把这些动作收成形容词。不另补底稿没有的动作,不改底稿已有的人、事、话、结果。续写短;用户角色实操仅保留底稿已有,续写不加代控。
|
- 大纲扩写:把主世界正文当事实底稿写终稿。底稿里节拍之间的具体动作原样保留;文风只改遣词与节奏,不要把这些动作收成形容词。不另补底稿没有的动作,不改底稿已有的人、事、话、结果。续写短;用户角色实操仅保留底稿已有,续写不加代控。
|
||||||
- 续写:仅当故事推进(或合并技能)明确钉了此用法时——终稿停在其他角色等用户角色回应之刻;不可写用户角色未给出的其它行为/对话。
|
- 续写:仅当故事推进(或合并技能)明确钉了此用法时——终稿停在其他角色等用户角色回应之刻;不可写用户角色未给出的其它行为/对话。
|
||||||
- 自动写入:续写宜长;可按主角设定保留/补全用户角色言行(与底稿、人设一致)。
|
- 自动写入:续写宜长;可按主角设定保留/补全用户角色言行(与底稿、人设一致)。
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ suggested_context:
|
|||||||
- 设计.叙事指南与故事推进
|
- 设计.叙事指南与故事推进
|
||||||
- 设计.叙事指南
|
- 设计.叙事指南
|
||||||
- 设计.故事推进
|
- 设计.故事推进
|
||||||
|
- 设计.故事推进与交互范式
|
||||||
- 设计.正文组成
|
- 设计.正文组成
|
||||||
- 设计.监控栏
|
- 设计.监控栏
|
||||||
dynamic:
|
dynamic:
|
||||||
@@ -30,7 +31,7 @@ prompt_excerpt: |
|
|||||||
无独立转述时:你写的正文就是用户终稿。
|
无独立转述时:你写的正文就是用户终稿。
|
||||||
若本局启用了叙事转述:你仍写完整(或尽量完整)的场面正文;文风润色、残稿补全、前端壳由转述处理。不要改成「只交事实包」。
|
若本局启用了叙事转述:你仍写完整(或尽量完整)的场面正文;文风润色、残稿补全、前端壳由转述处理。不要改成「只交事实包」。
|
||||||
真值/表字段变更不靠你交结构化包:有旁观维护时由副 LLM 根据本轮正文改表;若正文组成钉了隐藏段维护语句,可按约定写短键值,否则不要为维护打断叙述。
|
真值/表字段变更不靠你交结构化包:有旁观维护时由副 LLM 根据本轮正文改表;若正文组成钉了隐藏段维护语句,可按约定写短键值,否则不要为维护打断叙述。
|
||||||
用户输入用法以「设计.故事推进」或「设计.叙事指南与故事推进」为准:
|
用户输入用法以「设计.故事推进与交互范式」(旧档「设计.故事推进」)或「设计.叙事指南与故事推进」为准:
|
||||||
- 钉了「大纲扩写」:本轮输入是待演成场面的大纲,不是终稿句子。已写明的人、事、话、结果保持不变,每个节拍都要演到。节拍之间补具体动作、物件和当场的小纠正,把场合里已有的处境演出来;大纲没写这些动作也要写。例:「我去相亲、坐了下来」——坐下前可以写先跟服务员要一杯咖啡,话出口又急忙改成两杯,用来显出局促;后面她提出的要求和拒绝的结果不动。只加形容词、比喻或气氛、不增加动作,不算拓写。另起大纲没有的冲突、新目标,或改掉已定结果,才算出界。设计文若写「不发明未涉及的剧情」,指的是这一种出界,不是禁止节拍之间的具体动作。续写段宜短且不可代写用户角色行动/说话;演绎后停在抉择点(配方另钉一次收束则从其配方)。
|
- 钉了「大纲扩写」:本轮输入是待演成场面的大纲,不是终稿句子。已写明的人、事、话、结果保持不变,每个节拍都要演到。节拍之间补具体动作、物件和当场的小纠正,把场合里已有的处境演出来;大纲没写这些动作也要写。例:「我去相亲、坐了下来」——坐下前可以写先跟服务员要一杯咖啡,话出口又急忙改成两杯,用来显出局促;后面她提出的要求和拒绝的结果不动。只加形容词、比喻或气氛、不增加动作,不算拓写。另起大纲没有的冲突、新目标,或改掉已定结果,才算出界。设计文若写「不发明未涉及的剧情」,指的是这一种出界,不是禁止节拍之间的具体动作。续写段宜短且不可代写用户角色行动/说话;演绎后停在抉择点(配方另钉一次收束则从其配方)。
|
||||||
- 钉了「续写」:拓写跟用户已声明行为/对话;续写可推进场面但用户角色仅被动占位;禁止替用户角色做决定;停在需用户回应之刻。
|
- 钉了「续写」:拓写跟用户已声明行为/对话;续写可推进场面但用户角色仅被动占位;禁止替用户角色做决定;停在需用户回应之刻。
|
||||||
- 钉了「自动写入」:短需求驱动;续写宜长;可按主角设定代写用户角色言行。
|
- 钉了「自动写入」:短需求驱动;续写宜长;可按主角设定代写用户角色言行。
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ inputTags:
|
|||||||
- "用户.最新输入"
|
- "用户.最新输入"
|
||||||
- "用户.worker答复"
|
- "用户.worker答复"
|
||||||
- "用户.修订说明"
|
- "用户.修订说明"
|
||||||
- "设计.美学纲领与交互范式"
|
- "设计.美学纲领"
|
||||||
|
- "设计.美学纲领"
|
||||||
- "设计.创作流程"
|
- "设计.创作流程"
|
||||||
- "创作.选用配方"
|
- "创作.选用配方"
|
||||||
- "创作.已验收单位"
|
- "创作.已验收单位"
|
||||||
@@ -23,7 +24,7 @@ inputMerge: latest
|
|||||||
contextSegments:
|
contextSegments:
|
||||||
- id: opening-aesthetics
|
- id: opening-aesthetics
|
||||||
tier: static
|
tier: static
|
||||||
tags: ["设计.美学纲领与交互范式"]
|
tags: ["设计.美学纲领", "设计.美学纲领"]
|
||||||
label: "## 【开头节点 · 美学纲领】用户会在意这张卡的哪几处?用那些承诺选节点(无则先排本步)"
|
label: "## 【开头节点 · 美学纲领】用户会在意这张卡的哪几处?用那些承诺选节点(无则先排本步)"
|
||||||
- id: accepted-units
|
- id: accepted-units
|
||||||
tier: static
|
tier: static
|
||||||
@@ -65,7 +66,7 @@ contextSegments:
|
|||||||
|
|
||||||
题材名(丧尸、都市、宫廷)本身不决定套件。用户会反复感到的那几处才决定。
|
题材名(丧尸、都市、宫廷)本身不决定套件。用户会反复感到的那几处才决定。
|
||||||
|
|
||||||
无美学卡:体验尚未钉清,先排「美学纲领与交互范式」;其余只排此刻能从【用户要求】看出用户在意什么。美学已验收之后,按这些承诺把该排的节点写进 `steps`——需要几步排几步。
|
无美学卡:体验尚未钉清,先排「美学纲领」;其余只排此刻能从【用户要求】看出用户在意什么。美学已验收之后,按这些承诺把该排的节点写进 `steps`——需要几步排几步。
|
||||||
|
|
||||||
选定节点时对照【能力 · 可选工序】的何时用 / 何时不用 / 边界:这份承诺该由哪个技能兑现。配方 `core` / `process` / `principles` 给方法顺序(默认槽、收成链),不代替「用户在意什么」。含糊或可跳过则不排。已在草案里的 `name` 保留,一次性技能不写第二份。能力池里兑不了那处承诺:不发明新 `name`,askUser 问用户改体验还是放弃那一处。
|
选定节点时对照【能力 · 可选工序】的何时用 / 何时不用 / 边界:这份承诺该由哪个技能兑现。配方 `core` / `process` / `principles` 给方法顺序(默认槽、收成链),不代替「用户在意什么」。含糊或可跳过则不排。已在草案里的 `name` 保留,一次性技能不写第二份。能力池里兑不了那处承诺:不发明新 `name`,askUser 问用户改体验还是放弃那一处。
|
||||||
|
|
||||||
@@ -107,23 +108,23 @@ askUser 只问排布(补哪个原型、改依赖)。用户谈到体验正文
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] },
|
{ "id": "美学纲领", "name": "美学纲领", "depends_on": [] },
|
||||||
{
|
{
|
||||||
"id": "生成规则",
|
"id": "生成规则",
|
||||||
"name": "生成规则",
|
"name": "生成规则",
|
||||||
"role": "prototype",
|
"role": "prototype",
|
||||||
"suggestion": "异能觉醒与升级合同",
|
"suggestion": "异能觉醒与升级合同",
|
||||||
"depends_on": ["美学纲领与交互范式"]
|
"depends_on": ["美学纲领"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "变量设计与更新规则",
|
"id": "变量设计与更新规则",
|
||||||
"name": "变量设计与更新规则",
|
"name": "变量设计与更新规则",
|
||||||
"depends_on": ["美学纲领与交互范式"]
|
"depends_on": ["美学纲领"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "叙事指南与故事推进",
|
"id": "叙事指南与故事推进",
|
||||||
"name": "叙事指南与故事推进",
|
"name": "叙事指南与故事推进",
|
||||||
"depends_on": ["美学纲领与交互范式"]
|
"depends_on": ["美学纲领"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "细化终稿",
|
"id": "细化终稿",
|
||||||
|
|||||||
@@ -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 之前、" +
|
||||||
|
"上游是否支持隐式缓存。",
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,19 @@ import path from "node:path";
|
|||||||
import { listApiProfiles } from "./api-profiles.js";
|
import { listApiProfiles } from "./api-profiles.js";
|
||||||
import { loadLlmConfigOptional } from "./env.js";
|
import { loadLlmConfigOptional } from "./env.js";
|
||||||
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.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 = {
|
export type AppSettings = {
|
||||||
version: 1;
|
version: 1;
|
||||||
@@ -17,11 +30,37 @@ export type AppSettings = {
|
|||||||
* 游玩期每隔多少个用户回合尝试采集偏好候选。默认 10;0 = 关闭。
|
* 游玩期每隔多少个用户回合尝试采集偏好候选。默认 10;0 = 关闭。
|
||||||
*/
|
*/
|
||||||
preferenceCollectEveryTurns?: number;
|
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 FILE_NAME = "settings.json";
|
||||||
const DEFAULT_CONTEXT_TRACE_KEEP = 5;
|
const DEFAULT_CONTEXT_TRACE_KEEP = 5;
|
||||||
const DEFAULT_PREF_COLLECT_EVERY = 10;
|
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 {
|
function settingsPath(): string {
|
||||||
return path.join(getUserDataDir(), FILE_NAME);
|
return path.join(getUserDataDir(), FILE_NAME);
|
||||||
@@ -34,9 +73,32 @@ function defaultSettings(): AppSettings {
|
|||||||
activePresetId: null,
|
activePresetId: null,
|
||||||
contextTraceKeepLatest: DEFAULT_CONTEXT_TRACE_KEEP,
|
contextTraceKeepLatest: DEFAULT_CONTEXT_TRACE_KEEP,
|
||||||
preferenceCollectEveryTurns: DEFAULT_PREF_COLLECT_EVERY,
|
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 {
|
export function normalizeContextTraceKeepLatest(raw: unknown): number {
|
||||||
const n = typeof raw === "number" ? raw : Number(raw);
|
const n = typeof raw === "number" ? raw : Number(raw);
|
||||||
if (!Number.isFinite(n) || n < 0) return DEFAULT_CONTEXT_TRACE_KEEP;
|
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));
|
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 {
|
export function loadAppSettings(): AppSettings {
|
||||||
ensureUserDataDirs();
|
ensureUserDataDirs();
|
||||||
try {
|
try {
|
||||||
@@ -65,6 +142,14 @@ export function loadAppSettings(): AppSettings {
|
|||||||
preferenceCollectEveryTurns: normalizePreferenceCollectEveryTurns(
|
preferenceCollectEveryTurns: normalizePreferenceCollectEveryTurns(
|
||||||
parsed.preferenceCollectEveryTurns ?? DEFAULT_PREF_COLLECT_EVERY,
|
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 {
|
} catch {
|
||||||
return defaultSettings();
|
return defaultSettings();
|
||||||
@@ -131,3 +216,4 @@ export function ensureActiveProfileDefault(): AppSettings {
|
|||||||
}
|
}
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,16 @@ export function getBooksDir(): string {
|
|||||||
return path.join(getUserDataDir(), "books");
|
return path.join(getUserDataDir(), "books");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDirectChatsDir(): string {
|
||||||
|
return path.join(getUserDataDir(), "direct-chats");
|
||||||
|
}
|
||||||
|
|
||||||
export function ensureUserDataDirs(): void {
|
export function ensureUserDataDirs(): void {
|
||||||
mkdirSync(getPresetsDir(), { recursive: true });
|
mkdirSync(getPresetsDir(), { recursive: true });
|
||||||
mkdirSync(getBooksDir(), { recursive: true });
|
mkdirSync(getBooksDir(), { recursive: true });
|
||||||
mkdirSync(path.join(getUserDataDir(), "stats"), { 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
catalogModulesForIntake,
|
catalogModulesForIntake,
|
||||||
isDictateProgramModule,
|
isDictateProgramModule,
|
||||||
|
moduleOwnsArtifactTag,
|
||||||
type ModuleCatalog,
|
type ModuleCatalog,
|
||||||
type ModuleCatalogEntry,
|
type ModuleCatalogEntry,
|
||||||
} from "../skills/creation-flow.js";
|
} from "../skills/creation-flow.js";
|
||||||
@@ -318,8 +319,8 @@ export function formatCreationPlanProgress(params: {
|
|||||||
for (const item of params.plan.items) {
|
for (const item of params.plan.items) {
|
||||||
const module = modules.get(item.module_id);
|
const module = modules.get(item.module_id);
|
||||||
if (!module || !CREATION_PLAN_TIERS.includes(item.tier)) continue;
|
if (!module || !CREATION_PLAN_TIERS.includes(item.tier)) continue;
|
||||||
const landedTags = productTags.filter(
|
const landedTags = productTags.filter((tag) =>
|
||||||
(tag) => tag === module.artifact || tag.startsWith(`${module.artifact}#`),
|
moduleOwnsArtifactTag(module, tag),
|
||||||
);
|
);
|
||||||
const scores = landedTags
|
const scores = landedTags
|
||||||
.map((tag) => {
|
.map((tag) => {
|
||||||
@@ -382,12 +383,11 @@ export function requiredCreationPlanGaps(params: {
|
|||||||
return plan.items
|
return plan.items
|
||||||
.filter((item) => item.tier === "必须")
|
.filter((item) => item.tier === "必须")
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
const artifact = modules.get(item.module_id)?.artifact;
|
const module = modules.get(item.module_id);
|
||||||
return (
|
return (
|
||||||
!artifact ||
|
!module ||
|
||||||
!params.products.some(
|
!params.products.some((product) =>
|
||||||
(product) =>
|
moduleOwnsArtifactTag(module, product.tag),
|
||||||
product.tag === artifact || product.tag.startsWith(`${artifact}#`),
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { tryParseJsonDoc } from "../parse/json-doc.js";
|
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;
|
export const EXPERIENCE_ANCHOR_RECIPE_NAMES = ["快穿短局", "数据化跑团体验"] as const;
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ export function formatExperienceAnchorBlock(score: ExperienceAnchorScore): strin
|
|||||||
lines.push(
|
lines.push(
|
||||||
score.anchored
|
score.anchored
|
||||||
? "3/3:各能力何时用里的「已有美学」成立。开场白仍须等其它何时用已成立的能力落完。"
|
? "3/3:各能力何时用里的「已有美学」成立。开场白仍须等其它何时用已成立的能力落完。"
|
||||||
: "未满 3/3:「已有美学」不成立。本轮只把缺口写进「设计.美学纲领与交互范式」的「体验锚定」(站位、核心感觉、轮转停点)。不得宣称已锚定,不得 insert 开场白。",
|
: "未满 3/3:「已有美学」不成立。本轮只把缺口写进「设计.美学纲领」的「体验锚定」(站位、核心感觉、轮转停点)。不得宣称已锚定,不得 insert 开场白。",
|
||||||
);
|
);
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ export function experienceAnchorToolPayload(score: ExperienceAnchorScore): {
|
|||||||
|
|
||||||
export function openingBlockedByAnchorMessage(score: ExperienceAnchorScore): string {
|
export function openingBlockedByAnchorMessage(score: ExperienceAnchorScore): string {
|
||||||
const missing = score.items.filter((item) => !item.ok).map((item) => item.id);
|
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 {
|
function scoreStance(raw: string | undefined): ExperienceAnchorItem {
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import type { DictateChatTurn } from "./types.js";
|
|||||||
/**
|
/**
|
||||||
* 创作上下文保留的更早对话轮数。
|
* 创作上下文保留的更早对话轮数。
|
||||||
* 1 轮 = 一次用户输入 + 一次助手输出(不含本轮正在处理的末句用户话)。
|
* 1 轮 = 一次用户输入 + 一次助手输出(不含本轮正在处理的末句用户话)。
|
||||||
* 默认 2;0 = 不保留更早对话;负数 = 不限制。
|
* 负数 = 不限制。(当前缺省即不限制:块级滑窗会让整段前缀位移、击穿缓存,
|
||||||
|
* 先关掉以便量真多轮之后的首填命中,见 docs/briefs/prompt-cache-audit-2026-10-07.md)
|
||||||
* 可用环境变量 `DICTATE_HISTORY_TURNS` 或调用参数覆盖。
|
* 可用环境变量 `DICTATE_HISTORY_TURNS` 或调用参数覆盖。
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_DICTATE_HISTORY_TURNS = 2;
|
export const DEFAULT_DICTATE_HISTORY_TURNS = -1;
|
||||||
|
|
||||||
export function resolveDictateHistoryTurns(override?: number): number {
|
export function resolveDictateHistoryTurns(override?: number): number {
|
||||||
if (typeof override === "number" && Number.isFinite(override)) {
|
if (typeof override === "number" && Number.isFinite(override)) {
|
||||||
@@ -22,7 +23,7 @@ export function resolveDictateHistoryTurns(override?: number): number {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 裁剪进模型的对话:保留末句用户输入 + 最近 `historyTurns` 轮更早问答。
|
* 裁剪进模型的对话:保留末句用户输入 + 最近 `historyTurns` 轮更早问答。
|
||||||
* `historyTurns < 0` 时原样返回。
|
* `historyTurns < 0` 时原样返回(= 不限制,当前缺省)。
|
||||||
*/
|
*/
|
||||||
export function clipDictateDialogue(
|
export function clipDictateDialogue(
|
||||||
dialogue: DictateChatTurn[],
|
dialogue: DictateChatTurn[],
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export {
|
|||||||
bindDictateProductsToPlaySpec,
|
bindDictateProductsToPlaySpec,
|
||||||
buildDictateContextOrder,
|
buildDictateContextOrder,
|
||||||
collectDictateBindProducts,
|
collectDictateBindProducts,
|
||||||
|
isExcludedFromPlayBind,
|
||||||
type DictatePlayBindResult,
|
type DictatePlayBindResult,
|
||||||
} from "./play-bind.js";
|
} from "./play-bind.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ export const DICTATE_INSERT_TAG_ALIASES: Readonly<Record<string, string>> = {
|
|||||||
"设计.开场白": "opening-setup",
|
"设计.开场白": "opening-setup",
|
||||||
"设计.开场白与开场变量": "opening-setup",
|
"设计.开场白与开场变量": "opening-setup",
|
||||||
"设计.美学纲领": "aesthetics-interaction",
|
"设计.美学纲领": "aesthetics-interaction",
|
||||||
|
"设计.美学纲领与交互范式": "aesthetics-interaction",
|
||||||
|
"设计.故事推进": "story-progression",
|
||||||
|
"设计.故事推进与交互范式": "story-progression",
|
||||||
"设计.文风": "narrative-guide",
|
"设计.文风": "narrative-guide",
|
||||||
"设计.监控栏": "status-bar",
|
"设计.监控栏": "status-bar",
|
||||||
|
"设计.实现机制": "mechanism",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 多模块同轮:写进每条 insert tool result 的 reply_hint */
|
/** 多模块同轮:写进每条 insert tool result 的 reply_hint */
|
||||||
|
|||||||
@@ -68,10 +68,32 @@ const SKIP_BIND_TAGS = new Set([
|
|||||||
MAINTENANCE_STRATEGY_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 {
|
function shouldSkipBindTag(tag: string): boolean {
|
||||||
const t = tag.trim();
|
const t = tag.trim();
|
||||||
if (!t) return true;
|
if (!t) return true;
|
||||||
if (SKIP_BIND_TAGS.has(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;
|
if (t.startsWith("输出.")) return true;
|
||||||
if (t.startsWith("运行.")) return true;
|
if (t.startsWith("运行.")) return true;
|
||||||
@@ -79,9 +101,14 @@ function shouldSkipBindTag(tag: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 偏文风/呈现/硬约束:有叙事转述时挂转述;否则仍挂主世界层 */
|
/** 是否不应挂进游玩投影(创作中转、呈现壳、开场等) */
|
||||||
|
export function isExcludedFromPlayBind(tag: string): boolean {
|
||||||
|
return shouldSkipBindTag(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 偏文风/呈现/硬约束:有叙事转述时挂转述;否则仍挂主世界层(壳与 design_only 已在收集阶段剔除) */
|
||||||
const STYLE_TAG_RE =
|
const STYLE_TAG_RE =
|
||||||
/叙事指南|故事推进|文风|美学|纲领|禁忌|用户约束|用户需求|示例|模仿|正文组成|回复呈现|回复格式|监控栏|篇幅|结构/;
|
/叙事指南|故事推进|文风|禁忌|用户约束|用户需求|示例|模仿|回复呈现|监控栏|篇幅|结构/;
|
||||||
|
|
||||||
/** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */
|
/** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */
|
||||||
const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]);
|
const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]);
|
||||||
@@ -102,6 +129,20 @@ function isStyleProductTag(tag: string): boolean {
|
|||||||
return STYLE_TAG_RE.test(tag);
|
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 {
|
function isFrequentlyChangingProduct(product: DictateProduct): boolean {
|
||||||
const stability = parseContextFragment(product.content)?.稳变;
|
const stability = parseContextFragment(product.content)?.稳变;
|
||||||
return stability === "semi" || stability === "volatile";
|
return stability === "semi" || stability === "volatile";
|
||||||
@@ -178,7 +219,9 @@ function buildGmInserts(params: {
|
|||||||
];
|
];
|
||||||
let o = 1;
|
let o = 1;
|
||||||
const included = params.products.filter(
|
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 stable = included.filter((p) => !isFrequentlyChangingProduct(p));
|
||||||
const changing = included.filter(isFrequentlyChangingProduct);
|
const changing = included.filter(isFrequentlyChangingProduct);
|
||||||
@@ -243,7 +286,7 @@ function buildAuditorInserts(params: {
|
|||||||
];
|
];
|
||||||
let o = 1;
|
let o = 1;
|
||||||
for (const p of params.products) {
|
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"));
|
inserts.push(insert(o++, p.tag, "full"));
|
||||||
}
|
}
|
||||||
for (const tag of params.runtimeTags) {
|
for (const tag of params.runtimeTags) {
|
||||||
@@ -263,6 +306,7 @@ export function buildDictateContextOrder(params: {
|
|||||||
runtimeTags?: string[];
|
runtimeTags?: string[];
|
||||||
}): ContextOrderDoc {
|
}): ContextOrderDoc {
|
||||||
const runtimeTags = params.runtimeTags ?? [];
|
const runtimeTags = params.runtimeTags ?? [];
|
||||||
|
const products = params.products.filter((p) => !shouldSkipBindTag(p.tag));
|
||||||
const slots: ContextOrderSlot[] = [];
|
const slots: ContextOrderSlot[] = [];
|
||||||
|
|
||||||
for (const slotId of enabledPlaySlotIds(params.playSlots)) {
|
for (const slotId of enabledPlaySlotIds(params.playSlots)) {
|
||||||
@@ -272,7 +316,7 @@ export function buildDictateContextOrder(params: {
|
|||||||
ref,
|
ref,
|
||||||
label: "主世界层",
|
label: "主世界层",
|
||||||
inserts: buildGmInserts({
|
inserts: buildGmInserts({
|
||||||
products: params.products,
|
products,
|
||||||
runtimeTags,
|
runtimeTags,
|
||||||
// 主世界推进剧情:固定产物全挂(含文风);有转述时转述另挂文风切片
|
// 主世界推进剧情:固定产物全挂(含文风);有转述时转述另挂文风切片
|
||||||
includeStyle: true,
|
includeStyle: true,
|
||||||
@@ -284,7 +328,7 @@ export function buildDictateContextOrder(params: {
|
|||||||
slots.push({
|
slots.push({
|
||||||
ref,
|
ref,
|
||||||
label: "叙事转述",
|
label: "叙事转述",
|
||||||
inserts: buildNarratorInserts(params.products),
|
inserts: buildNarratorInserts(products),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -293,7 +337,7 @@ export function buildDictateContextOrder(params: {
|
|||||||
ref,
|
ref,
|
||||||
label: "旁观维护",
|
label: "旁观维护",
|
||||||
inserts: buildAuditorInserts({
|
inserts: buildAuditorInserts({
|
||||||
products: params.products,
|
products,
|
||||||
runtimeTags,
|
runtimeTags,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,12 +23,13 @@ export type DictatePromptTailOpts = {
|
|||||||
|
|
||||||
/** 第一次请求:只从目录里点出这段话要填的节点,不写产物。 */
|
/** 第一次请求:只从目录里点出这段话要填的节点,不写产物。 */
|
||||||
export function buildDictateSelectPrompt(): string {
|
export function buildDictateSelectPrompt(): string {
|
||||||
return `你只做选题。对照能力目录和用户这句话,调用 select_nodes,选出已经够写的节点。
|
return `你只做选题。对照能力目录和用户这句话,只调用一个工具。
|
||||||
|
|
||||||
nodes 里的 module_id 必须来自目录。通常选这段话已经说清的 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。
|
这段话已经说清要落盘时,调用 select_nodes,nodes 里放目录中的 module_id,通常 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。
|
||||||
若末尾 checklist 标了「失败待补」,这些节点必须再次纳入 nodes(可与新选的一起)。
|
若末尾 checklist 标了「失败待补」,且用户这句仍在推进落盘,这些节点必须再次纳入 nodes(可与新选的一起)。
|
||||||
用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或实现机制。
|
用户只要讨论、核对,或明确先不要落盘时,调用 reply_user:直接回答,或写成 1~2 个问题,每个问题给 2~3 个可接着说的选项。这种一轮不落盘,也不把失败待补写回去。
|
||||||
这一步不写产物正文,不回复用户。`;
|
用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或世界法则。
|
||||||
|
select_nodes 不写产物正文。`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDictateSystemPrompt(_opts?: DictatePromptTailOpts): string {
|
export function buildDictateSystemPrompt(_opts?: DictatePromptTailOpts): string {
|
||||||
@@ -116,8 +117,8 @@ ${legacyExample}`;
|
|||||||
|
|
||||||
const phase =
|
const phase =
|
||||||
opts?.phase === "fill"
|
opts?.phase === "fill"
|
||||||
? `本步先写「本轮节点表」里尚未通过的产物(有 self_score 维度时 insert 带上;已通过的以工具结果为准),写完后同一次请求必须调用 reply_user,正文按系统提示「用户可见回复」。`
|
? `本步先写「本轮节点表」里尚未通过的产物(有 self_score 维度时 insert 带上;已通过的以工具结果为准)。失败待补若附了底稿,在底稿上只改失败点,已写对的段落、字段和已接受的 self_score 维度保持原样。写完后同一次请求必须调用 reply_user,正文按系统提示「用户可见回复」。`
|
||||||
: "本步是第一次请求。只调用 select_nodes:这段话用于填充哪些节点。这一步不写产物正文,不回复用户。";
|
: "本步只调用一个工具。要落盘就调用 select_nodes,只填 nodes。只要讨论或先不要落盘,就调用 reply_user 直接回答或提问。";
|
||||||
|
|
||||||
// 填写时把收尾要求放在方案进度之前,避免被大段进度挤到注意力末尾。
|
// 填写时把收尾要求放在方案进度之前,避免被大段进度挤到注意力末尾。
|
||||||
return opts?.phase === "fill"
|
return opts?.phase === "fill"
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
getPreference,
|
getPreference,
|
||||||
listActivePreferences,
|
listActivePreferences,
|
||||||
} from "../preference/store.js";
|
} from "../preference/store.js";
|
||||||
|
import {
|
||||||
|
getImageWorkflow,
|
||||||
|
listImageWorkflows,
|
||||||
|
} from "../image-workflow/store.js";
|
||||||
import {
|
import {
|
||||||
getStylePack,
|
getStylePack,
|
||||||
listActiveStylePacks,
|
listActiveStylePacks,
|
||||||
@@ -26,6 +30,13 @@ import {
|
|||||||
|
|
||||||
export const DICTATE_MODULE_READ_STATE_TAG = "运行.对话落盘.已读能力";
|
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 = {
|
export type DictateModuleReadPayload = {
|
||||||
module_id: string;
|
module_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -99,6 +110,9 @@ export async function buildDictateModuleReadIndex(params: {
|
|||||||
};
|
};
|
||||||
index.set(module.id, payload);
|
index.set(module.id, payload);
|
||||||
index.set(module.name, 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;
|
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} 尚不支持按条目读取` };
|
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: [] };
|
return { library_id: id, label: provider?.label ?? id, entries: [] };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 同轮不再 fill-retry:失败项写入「待补」状态,下轮 select/fill 继续补。
|
* 同轮补写用尽后,失败项写入「待补」状态,下轮 select/fill 继续补。
|
||||||
* 存 readTag/writeTag,不进产物列表,避免污染黑板正文。
|
* 存 readTag/writeTag,不进产物列表,避免污染黑板正文。
|
||||||
|
* draft / keptDims 只给下一枪做增量:底稿上改失败点,已接受的自评维度带回去。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const DICTATE_REMEDIATE_TAG = "设计.对话落盘待补";
|
export const DICTATE_REMEDIATE_TAG = "设计.对话落盘待补";
|
||||||
@@ -8,11 +9,23 @@ export const DICTATE_REMEDIATE_SCHEMA = "dictate-remediate.v1" as const;
|
|||||||
|
|
||||||
export type DictateRemediateKind = "insert" | "delete";
|
export type DictateRemediateKind = "insert" | "delete";
|
||||||
|
|
||||||
|
/** 已经合法、可在下一枪原样带回的自评维度。 */
|
||||||
|
export type DictateKeptDim = {
|
||||||
|
name: string;
|
||||||
|
score: number;
|
||||||
|
gap?: string;
|
||||||
|
options?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type DictateRemediateItem = {
|
export type DictateRemediateItem = {
|
||||||
tag: string;
|
tag: string;
|
||||||
moduleId?: string;
|
moduleId?: string;
|
||||||
error: string;
|
error: string;
|
||||||
kind: DictateRemediateKind;
|
kind: DictateRemediateKind;
|
||||||
|
/** 未通过时的正文底稿 */
|
||||||
|
draft?: string;
|
||||||
|
/** 这次没整份通过、但单项已经合法的自评维度 */
|
||||||
|
keptDims?: DictateKeptDim[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DictateRemediateDoc = {
|
export type DictateRemediateDoc = {
|
||||||
@@ -34,11 +47,15 @@ export function parseDictateRemediate(raw: string | undefined | null): DictateRe
|
|||||||
const kind = rec.kind === "delete" ? "delete" : "insert";
|
const kind = rec.kind === "delete" ? "delete" : "insert";
|
||||||
const moduleId = String(rec.moduleId ?? rec.module_id ?? "").trim();
|
const moduleId = String(rec.moduleId ?? rec.module_id ?? "").trim();
|
||||||
const error = String(rec.error ?? "尚未通过").trim() || "尚未通过";
|
const error = String(rec.error ?? "尚未通过").trim() || "尚未通过";
|
||||||
|
const draft = String(rec.draft ?? "").trim();
|
||||||
|
const keptDims = parseKeptDims(rec.keptDims ?? rec.kept_dims);
|
||||||
parsed.push({
|
parsed.push({
|
||||||
tag,
|
tag,
|
||||||
error,
|
error,
|
||||||
kind,
|
kind,
|
||||||
...(moduleId ? { moduleId } : {}),
|
...(moduleId ? { moduleId } : {}),
|
||||||
|
...(draft ? { draft } : {}),
|
||||||
|
...(keptDims.length ? { keptDims } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { schema: DICTATE_REMEDIATE_SCHEMA, items: dedupeRemediate(parsed) };
|
return { schema: DICTATE_REMEDIATE_SCHEMA, items: dedupeRemediate(parsed) };
|
||||||
@@ -70,6 +87,31 @@ export function formatRemediateChecklist(items: readonly DictateRemediateItem[])
|
|||||||
].join("\n");
|
].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[] {
|
function dedupeRemediate(items: DictateRemediateItem[]): DictateRemediateItem[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const out: DictateRemediateItem[] = [];
|
const out: DictateRemediateItem[] = [];
|
||||||
|
|||||||
+284
-49
@@ -1,15 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,
|
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,再 fill 写入。
|
||||||
* 再单次 fill 写入。已通过的不再覆盖。
|
* 已通过的不再覆盖。未通过的可在同轮按设置补写若干次:底稿上只改失败点,已接受的自评维度带回。
|
||||||
* 同轮不再 fill-retry:失败项记入「失败待补」,下轮继续。
|
* 补写次数用尽后,失败项记入「失败待补」,下轮继续。
|
||||||
* 没有未通过项时,同一次请求里的 reply_user 才给用户。
|
* 没有未通过项时,同一次请求里的 reply_user 才给用户。
|
||||||
|
* 选题与回复二选一:调用 reply_user 则本轮到此为止,不再填写,也不再补回复。
|
||||||
* 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。
|
* 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。
|
||||||
*/
|
*/
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js";
|
import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js";
|
||||||
import type { AgentDriver } from "../runtime/driver.js";
|
import type { AgentDriver } from "../runtime/driver.js";
|
||||||
import type { LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
|
import type { ChatMessage, LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
|
||||||
import { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
|
import { normalizeDictateFillRetries } from "../config/settings.js";
|
||||||
|
import { DICTATE_REPLY_TOOL, DICTATE_SELECT_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
|
||||||
import { buildDictateContext, type DictateSegmentChars } from "./context.js";
|
import { buildDictateContext, type DictateSegmentChars } from "./context.js";
|
||||||
import {
|
import {
|
||||||
CREATION_PLAN_SCHEMA,
|
CREATION_PLAN_SCHEMA,
|
||||||
@@ -40,6 +42,7 @@ import {
|
|||||||
formatRemediateChecklist,
|
formatRemediateChecklist,
|
||||||
parseDictateRemediate,
|
parseDictateRemediate,
|
||||||
serializeDictateRemediate,
|
serializeDictateRemediate,
|
||||||
|
type DictateKeptDim,
|
||||||
type DictateRemediateItem,
|
type DictateRemediateItem,
|
||||||
} from "./remediate.js";
|
} from "./remediate.js";
|
||||||
import { resolveModuleForInsertTag } from "./insert-feedback.js";
|
import { resolveModuleForInsertTag } from "./insert-feedback.js";
|
||||||
@@ -81,6 +84,11 @@ export type PlannedRoundParams = {
|
|||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
/** 更早对话保留轮数;默认 2,见 resolveDictateHistoryTurns */
|
/** 更早对话保留轮数;默认 2,见 resolveDictateHistoryTurns */
|
||||||
historyTurns?: number;
|
historyTurns?: number;
|
||||||
|
/**
|
||||||
|
* 第一次填写失败后,同轮最多再补写几次。
|
||||||
|
* 缺省 0:只填一次,失败留给下一句。
|
||||||
|
*/
|
||||||
|
fillRetries?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PlanInsert = {
|
type PlanInsert = {
|
||||||
@@ -92,11 +100,12 @@ type PlanInsert = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type SelectPlan = {
|
type SelectPlan = {
|
||||||
|
/** 本轮不落盘时的提问 */
|
||||||
clarify: string[];
|
clarify: string[];
|
||||||
|
/** 本轮不落盘时的回答 */
|
||||||
|
reply: string;
|
||||||
inserts: PlanInsert[];
|
inserts: PlanInsert[];
|
||||||
deletes: string[];
|
deletes: string[];
|
||||||
/** 模型对象上的字段名,空计划时写进回复,方便核对解析有没有对上 */
|
|
||||||
keys: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -137,31 +146,31 @@ async function runPlannedDictateRound(
|
|||||||
const selected = await params.llm.completeWithTools(selectCtx.messages, {
|
const selected = await params.llm.completeWithTools(selectCtx.messages, {
|
||||||
caller: "dictate_agent",
|
caller: "dictate_agent",
|
||||||
label: "对话落盘 选题",
|
label: "对话落盘 选题",
|
||||||
tools: [DICTATE_SELECT_TOOL],
|
tools: [DICTATE_SELECT_TOOL, DICTATE_SELECT_REPLY_TOOL],
|
||||||
toolChoice: { type: "function", function: { name: "select_nodes" } },
|
toolChoice: "required",
|
||||||
usageDetail: detail(turnId, "select", 0, selectCtx.segmentChars),
|
usageDetail: detail(turnId, "select", 0, selectCtx.segmentChars),
|
||||||
usageSink: sinkFor(turnId, "select"),
|
usageSink: sinkFor(turnId, "select"),
|
||||||
});
|
});
|
||||||
if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim());
|
if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim());
|
||||||
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0];
|
const replyCall = selected.toolCalls.find((call) => call.name === DICTATE_REPLY_TOOL.function.name);
|
||||||
const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params);
|
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes");
|
||||||
mergeRemediationsIntoPlan(plan, remediations.items, params);
|
const earlyReply = replyCall
|
||||||
if (
|
? replyText({
|
||||||
plan.clarify.length >= 2 &&
|
id: replyCall.id || "reply",
|
||||||
plan.inserts.length === 0 &&
|
name: replyCall.name,
|
||||||
plan.deletes.length === 0
|
arguments: replyCall.arguments,
|
||||||
) {
|
})
|
||||||
return {
|
: "";
|
||||||
modelCalls: 1,
|
if (earlyReply && !selectCall) {
|
||||||
text: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n"),
|
return { modelCalls: 1, text: earlyReply };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params);
|
||||||
|
const noWork = plan.inserts.length === 0 && plan.deletes.length === 0;
|
||||||
|
const speaking = noWork && (plan.clarify.length > 0 || plan.reply.length > 0);
|
||||||
|
if (!speaking && !earlyReply) mergeRemediationsIntoPlan(plan, remediations.items, params);
|
||||||
if (plan.inserts.length === 0 && plan.deletes.length === 0) {
|
if (plan.inserts.length === 0 && plan.deletes.length === 0) {
|
||||||
const fields = plan.keys.length ? plan.keys.join("、") : "空对象";
|
const text = earlyReply || (speaking ? formatSpeak(plan) : "") || selected.content?.trim() || "这轮先不落盘。";
|
||||||
return {
|
return { modelCalls: 1, text };
|
||||||
modelCalls: 1,
|
|
||||||
text: `这一轮没有写入产物。选题返回的字段是 ${fields},没有可执行的写入项。可以说得更具体些,或点明先写哪一块。`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocked = plan.inserts.filter((item) => item.tag.startsWith("!"));
|
const blocked = plan.inserts.filter((item) => item.tag.startsWith("!"));
|
||||||
@@ -177,6 +186,13 @@ async function runPlannedDictateRound(
|
|||||||
[...rest, ...openings].some((row) => row.tag === item.tag) ||
|
[...rest, ...openings].some((row) => row.tag === item.tag) ||
|
||||||
plan.deletes.includes(item.tag),
|
plan.deletes.includes(item.tag),
|
||||||
);
|
);
|
||||||
|
const drafts = new Map<string, string>();
|
||||||
|
const keptDims = new Map<string, DictateKeptDim[]>();
|
||||||
|
for (const item of priorFailures) {
|
||||||
|
if (item.kind === "delete") continue;
|
||||||
|
if (item.draft) drafts.set(item.tag, item.draft);
|
||||||
|
if (item.keptDims?.length) keptDims.set(item.tag, item.keptDims);
|
||||||
|
}
|
||||||
const tail = formatDictateRoundTail({
|
const tail = formatDictateRoundTail({
|
||||||
...tailOpts,
|
...tailOpts,
|
||||||
phase: "fill",
|
phase: "fill",
|
||||||
@@ -189,6 +205,7 @@ async function runPlannedDictateRound(
|
|||||||
moduleId: item.moduleId,
|
moduleId: item.moduleId,
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
})),
|
})),
|
||||||
|
drafts,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
const ctx = buildDictateContext({
|
const ctx = buildDictateContext({
|
||||||
@@ -207,35 +224,64 @@ async function runPlannedDictateRound(
|
|||||||
let replyFromTool = "";
|
let replyFromTool = "";
|
||||||
let lastProse = "";
|
let lastProse = "";
|
||||||
let modelCalls = 1;
|
let modelCalls = 1;
|
||||||
|
const fillRetries = normalizeDictateFillRetries(params.fillRetries);
|
||||||
|
let messages: ChatMessage[] = ctx.messages;
|
||||||
|
let fillAttempts = 0;
|
||||||
|
|
||||||
// 单次 fill:失败记入待补,留给下轮,不再同轮 fill-retry。
|
while (pending.size > 0 || pendingDeletes.size > 0) {
|
||||||
if (pending.size > 0 || pendingDeletes.size > 0) {
|
if (fillAttempts > fillRetries) break;
|
||||||
const filled = await params.llm.completeWithTools(ctx.messages, {
|
const filled = await params.llm.completeWithTools(messages, {
|
||||||
caller: "dictate_agent",
|
caller: "dictate_agent",
|
||||||
label: "对话落盘 填写",
|
label: fillAttempts === 0 ? "对话落盘 填写" : `对话落盘 补写 ${fillAttempts}`,
|
||||||
tools: DICTATE_WRITE_LOOP_TOOLS,
|
tools: DICTATE_WRITE_LOOP_TOOLS,
|
||||||
toolChoice: "required",
|
toolChoice: "required",
|
||||||
usageDetail: {
|
usageDetail: {
|
||||||
...detail(turnId, "fill", 0, ctx.segmentChars),
|
...detail(
|
||||||
|
turnId,
|
||||||
|
fillAttempts === 0 ? "fill" : "fill-retry",
|
||||||
|
fillAttempts,
|
||||||
|
ctx.segmentChars,
|
||||||
|
),
|
||||||
plannedTags: [...pending, ...pendingDeletes],
|
plannedTags: [...pending, ...pendingDeletes],
|
||||||
},
|
},
|
||||||
usageSink: sinkFor(turnId, "fill"),
|
usageSink: sinkFor(turnId, fillAttempts === 0 ? "fill" : "fill-retry"),
|
||||||
});
|
});
|
||||||
|
fillAttempts += 1;
|
||||||
modelCalls += 1;
|
modelCalls += 1;
|
||||||
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
|
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
|
||||||
const prose = filled.content?.trim() ?? "";
|
const prose = filled.content?.trim() ?? "";
|
||||||
if (prose) lastProse = prose;
|
if (prose) lastProse = prose;
|
||||||
const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({
|
const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({
|
||||||
id: call.id || `call-0-${index}`,
|
id: call.id || `call-${fillAttempts}-${index}`,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
arguments: call.arguments,
|
arguments: call.arguments,
|
||||||
}));
|
}));
|
||||||
if (calls.length > 0) {
|
const settled =
|
||||||
const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors);
|
calls.length > 0
|
||||||
if (pending.size === 0 && pendingDeletes.size === 0) {
|
? await settleWriteStep(
|
||||||
replyFromTool = settled.reply;
|
params,
|
||||||
}
|
apply,
|
||||||
|
calls,
|
||||||
|
passed,
|
||||||
|
pending,
|
||||||
|
pendingDeletes,
|
||||||
|
errors,
|
||||||
|
drafts,
|
||||||
|
keptDims,
|
||||||
|
)
|
||||||
|
: { results: [], reply: "" };
|
||||||
|
if (pending.size === 0 && pendingDeletes.size === 0) {
|
||||||
|
replyFromTool = settled.reply;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
if (fillAttempts > fillRetries) break;
|
||||||
|
messages = appendFillContinuation(
|
||||||
|
messages,
|
||||||
|
filled.content,
|
||||||
|
calls,
|
||||||
|
settled.results,
|
||||||
|
incrementalRetryNote(passed, pending, pendingDeletes, errors),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const moduleByTag = new Map(
|
const moduleByTag = new Map(
|
||||||
@@ -247,11 +293,13 @@ async function runPlannedDictateRound(
|
|||||||
errors,
|
errors,
|
||||||
moduleByTag,
|
moduleByTag,
|
||||||
prior: remediations.items,
|
prior: remediations.items,
|
||||||
|
drafts,
|
||||||
|
keptDims,
|
||||||
});
|
});
|
||||||
|
|
||||||
let reply = replyFromTool;
|
let reply = replyFromTool || earlyReply;
|
||||||
if (
|
if (
|
||||||
!replyFromTool &&
|
!reply &&
|
||||||
pending.size === 0 &&
|
pending.size === 0 &&
|
||||||
pendingDeletes.size === 0 &&
|
pendingDeletes.size === 0 &&
|
||||||
passed.size > 0
|
passed.size > 0
|
||||||
@@ -266,10 +314,11 @@ async function runPlannedDictateRound(
|
|||||||
(tag) => `${tag}(${errors.get(tag) ?? "尚未通过"})`,
|
(tag) => `${tag}(${errors.get(tag) ?? "尚未通过"})`,
|
||||||
);
|
);
|
||||||
const pendingLines = [...blockedLines, ...failed];
|
const pendingLines = [...blockedLines, ...failed];
|
||||||
|
const retryNote = fillAttempts > 1 ? `已同轮补写 ${fillAttempts - 1} 次。` : "";
|
||||||
const text = pendingLines.length
|
const text = pendingLines.length
|
||||||
? [
|
? [
|
||||||
reply,
|
reply,
|
||||||
`失败待补(下轮继续):${pendingLines.join("、")}。`,
|
`失败待补(下轮继续):${pendingLines.join("、")}。${retryNote}`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
@@ -277,6 +326,15 @@ async function runPlannedDictateRound(
|
|||||||
return { text, modelCalls };
|
return { text, modelCalls };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSpeak(plan: SelectPlan): string {
|
||||||
|
const questions =
|
||||||
|
plan.clarify.length <= 1
|
||||||
|
? plan.clarify[0] ?? ""
|
||||||
|
: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n");
|
||||||
|
if (questions && plan.reply && questions !== plan.reply) return `${questions}\n\n${plan.reply}`;
|
||||||
|
return questions || plan.reply;
|
||||||
|
}
|
||||||
|
|
||||||
async function runLeanReply(
|
async function runLeanReply(
|
||||||
params: PlannedRoundParams,
|
params: PlannedRoundParams,
|
||||||
turnId: string,
|
turnId: string,
|
||||||
@@ -359,6 +417,8 @@ async function settleWriteStep(
|
|||||||
pending: Set<string>,
|
pending: Set<string>,
|
||||||
pendingDeletes: Set<string>,
|
pendingDeletes: Set<string>,
|
||||||
errors: Map<string, string>,
|
errors: Map<string, string>,
|
||||||
|
drafts: Map<string, string>,
|
||||||
|
keptDims: Map<string, DictateKeptDim[]>,
|
||||||
): Promise<{ results: Array<{ callId: string; content: string }>; reply: string }> {
|
): Promise<{ results: Array<{ callId: string; content: string }>; reply: string }> {
|
||||||
const results: Array<{ callId: string; content: string }> = [];
|
const results: Array<{ callId: string; content: string }> = [];
|
||||||
const forward: DriverToolCall[] = [];
|
const forward: DriverToolCall[] = [];
|
||||||
@@ -382,7 +442,12 @@ async function settleWriteStep(
|
|||||||
}
|
}
|
||||||
if (call.name === "insert" && tag) {
|
if (call.name === "insert" && tag) {
|
||||||
const args = asRecord(safeJson(call.arguments));
|
const args = asRecord(safeJson(call.arguments));
|
||||||
forward.push({ ...call, arguments: JSON.stringify({ ...args, position: tag }) });
|
const merged = mergeKeptDimsIntoArgs(
|
||||||
|
args,
|
||||||
|
keptDims.get(tag),
|
||||||
|
scoreDimensionsForTag(params, tag),
|
||||||
|
);
|
||||||
|
forward.push({ ...call, arguments: JSON.stringify({ ...merged, position: tag }) });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
forward.push(call);
|
forward.push(call);
|
||||||
@@ -409,7 +474,11 @@ async function settleWriteStep(
|
|||||||
const planned = [...pending, ...pendingDeletes].find(
|
const planned = [...pending, ...pendingDeletes].find(
|
||||||
(item) => item === tag || tag.startsWith(`${item}#`),
|
(item) => item === tag || tag.startsWith(`${item}#`),
|
||||||
);
|
);
|
||||||
errors.set(planned ?? tag, payload.error);
|
const plannedTag = planned ?? tag;
|
||||||
|
errors.set(plannedTag, payload.error);
|
||||||
|
if (call?.name === "insert") {
|
||||||
|
rememberFailedInsert(call, plannedTag, params, drafts, keptDims);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,6 +513,137 @@ function replyText(call: DriverToolCall): string {
|
|||||||
return String(args.text ?? args.reply ?? args.content ?? args.message ?? "").trim();
|
return String(args.text ?? args.reply ?? args.content ?? args.message ?? "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_FAILED_DRAFT_CHARS = 24_000;
|
||||||
|
|
||||||
|
function rememberFailedInsert(
|
||||||
|
call: DriverToolCall,
|
||||||
|
tag: string,
|
||||||
|
params: PlannedRoundParams,
|
||||||
|
drafts: Map<string, string>,
|
||||||
|
keptDims: Map<string, DictateKeptDim[]>,
|
||||||
|
): void {
|
||||||
|
const args = asRecord(safeJson(call.arguments));
|
||||||
|
const content = String(args.content ?? "").trim();
|
||||||
|
if (content) {
|
||||||
|
drafts.set(
|
||||||
|
tag,
|
||||||
|
content.length > MAX_FAILED_DRAFT_CHARS
|
||||||
|
? `${content.slice(0, MAX_FAILED_DRAFT_CHARS)}\n…(底稿已截断)`
|
||||||
|
: content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const expected = scoreDimensionsForTag(params, tag);
|
||||||
|
const harvested = harvestKeptDims(args.self_score, expected);
|
||||||
|
if (!harvested.length) return;
|
||||||
|
const merged = new Map((keptDims.get(tag) ?? []).map((dim) => [dim.name, dim] as const));
|
||||||
|
for (const dim of harvested) merged.set(dim.name, dim);
|
||||||
|
keptDims.set(tag, [...merged.values()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreDimensionsForTag(params: PlannedRoundParams, tag: string): string[] {
|
||||||
|
const module = resolveModuleForInsertTag(tag, params.moduleCatalog);
|
||||||
|
if (!module) return [];
|
||||||
|
return params.moduleReadIndex?.get(module.id)?.score_dimensions ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeKeptDimsIntoArgs(
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
kept: readonly DictateKeptDim[] | undefined,
|
||||||
|
expected: readonly string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!kept?.length || !expected.length) return args;
|
||||||
|
const score = asRecord(args.self_score);
|
||||||
|
const dims = Array.isArray(score.dims) ? [...score.dims] : [];
|
||||||
|
const names = new Set(
|
||||||
|
dims.map((item) => String(asRecord(item).name ?? "").trim()).filter(Boolean),
|
||||||
|
);
|
||||||
|
const additions = kept.filter((dim) => expected.includes(dim.name) && !names.has(dim.name));
|
||||||
|
if (!additions.length) return args;
|
||||||
|
return { ...args, self_score: { ...score, dims: [...dims, ...additions] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function harvestKeptDims(raw: unknown, expected: readonly string[]): DictateKeptDim[] {
|
||||||
|
if (!expected.length) return [];
|
||||||
|
const dims = Array.isArray(asRecord(raw).dims) ? asRecord(raw).dims : [];
|
||||||
|
const out: DictateKeptDim[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of dims) {
|
||||||
|
const row = asRecord(item);
|
||||||
|
const name = String(row.name ?? "").trim();
|
||||||
|
const score = Number(row.score);
|
||||||
|
if (!name || !expected.includes(name) || seen.has(name)) continue;
|
||||||
|
if (!Number.isInteger(score) || score < 1 || score > 5) continue;
|
||||||
|
const gap = String(row.gap ?? "").trim();
|
||||||
|
if (score <= 3 && !gap) continue;
|
||||||
|
const options = Array.isArray(row.options)
|
||||||
|
? row.options.map((option) => String(option).trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (options.length > 0 && (options.length < 2 || options.length > 3)) continue;
|
||||||
|
seen.add(name);
|
||||||
|
out.push({
|
||||||
|
name,
|
||||||
|
score,
|
||||||
|
...(gap ? { gap } : {}),
|
||||||
|
...(options.length ? { options } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendFillContinuation(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
assistantContent: string | null,
|
||||||
|
calls: DriverToolCall[],
|
||||||
|
results: Array<{ callId: string; content: string }>,
|
||||||
|
note: string,
|
||||||
|
): ChatMessage[] {
|
||||||
|
const next = [...messages];
|
||||||
|
if (calls.length === 0) {
|
||||||
|
next.push({ role: "user", content: note });
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
next.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: assistantContent,
|
||||||
|
tool_calls: calls.map((call) => ({
|
||||||
|
id: call.id,
|
||||||
|
type: "function" as const,
|
||||||
|
function: { name: call.name, arguments: call.arguments },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const byId = new Map(results.map((item) => [item.callId, item.content]));
|
||||||
|
for (const call of calls) {
|
||||||
|
next.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: call.id,
|
||||||
|
content: byId.get(call.id) ?? JSON.stringify({ error: "无结果" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next.push({ role: "user", content: note });
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementalRetryNote(
|
||||||
|
passed: ReadonlySet<string>,
|
||||||
|
pending: ReadonlySet<string>,
|
||||||
|
pendingDeletes: ReadonlySet<string>,
|
||||||
|
errors: ReadonlyMap<string, string>,
|
||||||
|
): string {
|
||||||
|
const kept = [...passed].filter((tag) => !tag.startsWith("del:"));
|
||||||
|
const deleted = [...passed].filter((tag) => tag.startsWith("del:")).map((tag) => tag.slice(4));
|
||||||
|
return [
|
||||||
|
"同轮补写:只改未通过项。",
|
||||||
|
kept.length ? `已通过,不要再 insert:${kept.join("、")}` : "",
|
||||||
|
deleted.length ? `已删除,不要再 delete:${deleted.join("、")}` : "",
|
||||||
|
"未通过:",
|
||||||
|
...[...pending].map((tag) => `- 写入 ${tag}:${errors.get(tag) ?? "尚未通过"}`),
|
||||||
|
...[...pendingDeletes].map((tag) => `- 删除 ${tag}:${errors.get(tag) ?? "尚未通过"}`),
|
||||||
|
"未通过的正文以上一次 insert 为底稿,只改失败点。已写对的段落、字段,以及已接受的 self_score 维度,保持原样。不要整份重写。",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function sinkFor(_turnId: string, _step: string): UsageSink {
|
function sinkFor(_turnId: string, _step: string): UsageSink {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -483,7 +683,13 @@ function tailState(params: PlannedRoundParams): DictatePromptTailOpts {
|
|||||||
|
|
||||||
function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
|
function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
|
||||||
const row = asRecord(raw);
|
const row = asRecord(raw);
|
||||||
const clarify = stringList(row.clarify);
|
const clarify = [
|
||||||
|
...askList(row.ask),
|
||||||
|
...askList(row.askUser),
|
||||||
|
...askList(row.ask_user),
|
||||||
|
...stringList(row.clarify),
|
||||||
|
].filter((item, index, all) => all.indexOf(item) === index);
|
||||||
|
const reply = String(row.reply ?? "").trim();
|
||||||
let deletes = stringList(row.deletes).filter((tag) => isAllowedProductTag(tag));
|
let deletes = stringList(row.deletes).filter((tag) => isAllowedProductTag(tag));
|
||||||
const inserts: PlanInsert[] = [];
|
const inserts: PlanInsert[] = [];
|
||||||
let items = Array.isArray(row.nodes)
|
let items = Array.isArray(row.nodes)
|
||||||
@@ -491,7 +697,7 @@ function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
|
|||||||
: Array.isArray(row.inserts)
|
: Array.isArray(row.inserts)
|
||||||
? row.inserts
|
? row.inserts
|
||||||
: [];
|
: [];
|
||||||
if (items.length === 0 && clarify.length < 2 && deletes.length === 0) {
|
if (items.length === 0 && deletes.length === 0) {
|
||||||
const recovered = recoverSelectWork(row);
|
const recovered = recoverSelectWork(row);
|
||||||
items = recovered.items;
|
items = recovered.items;
|
||||||
deletes = recovered.deletes.filter((tag) => isAllowedProductTag(tag));
|
deletes = recovered.deletes.filter((tag) => isAllowedProductTag(tag));
|
||||||
@@ -549,7 +755,7 @@ function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
|
|||||||
reason: reason || undefined,
|
reason: reason || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { clarify, inserts, deletes, keys: Object.keys(row) };
|
return { clarify, reply, inserts, deletes };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 选题没交 inserts 时,从方案正文、operations 或别名字段收出本轮要写的能力,再自动发填写。 */
|
/** 选题没交 inserts 时,从方案正文、operations 或别名字段收出本轮要写的能力,再自动发填写。 */
|
||||||
@@ -699,7 +905,12 @@ function canonicalProductTag(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string {
|
function formatChecklist(
|
||||||
|
group: PlanInsert[],
|
||||||
|
deletes: string[],
|
||||||
|
errors: ItemFailure[],
|
||||||
|
drafts?: ReadonlyMap<string, string>,
|
||||||
|
): string {
|
||||||
const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean);
|
const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean);
|
||||||
const failedTags = new Set(errors.map((item) => item.tag));
|
const failedTags = new Set(errors.map((item) => item.tag));
|
||||||
const pendingWrite = group.filter((item) => !failedTags.has(item.tag)).map((item) => item.tag);
|
const pendingWrite = group.filter((item) => !failedTags.has(item.tag)).map((item) => item.tag);
|
||||||
@@ -709,9 +920,15 @@ function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFai
|
|||||||
pendingWrite.length ? `待写:${pendingWrite.join("、")}` : "",
|
pendingWrite.length ? `待写:${pendingWrite.join("、")}` : "",
|
||||||
pendingDelete.length ? `待删:${pendingDelete.join("、")}` : "",
|
pendingDelete.length ? `待删:${pendingDelete.join("、")}` : "",
|
||||||
errors.length
|
errors.length
|
||||||
? `失败待补:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}`
|
? `失败待补:\n${errors
|
||||||
|
.map((item) => {
|
||||||
|
const draft = item.kind === "delete" ? "" : drafts?.get(item.tag)?.trim() ?? "";
|
||||||
|
if (!draft) return `- ${item.tag}:${item.error}`;
|
||||||
|
return `- ${item.tag}:${item.error}\n 底稿(只改失败点,已写对的段落和字段保持原样):\n${draft}`;
|
||||||
|
})
|
||||||
|
.join("\n")}`
|
||||||
: "",
|
: "",
|
||||||
"状态说明:待写/待删=本轮目标;失败待补=上轮或本轮未通过,须补上;已落的产物以黑板为准,不要无故整份重写。",
|
"状态说明:待写/待删=本轮目标;失败待补=上轮或本轮未通过,须补上;已落的产物以黑板为准。有底稿时只改失败点,不要整份重写。",
|
||||||
];
|
];
|
||||||
return lines.filter(Boolean).join("\n");
|
return lines.filter(Boolean).join("\n");
|
||||||
}
|
}
|
||||||
@@ -753,6 +970,8 @@ function persistRemediations(
|
|||||||
errors: Map<string, string>;
|
errors: Map<string, string>;
|
||||||
moduleByTag: Map<string, string>;
|
moduleByTag: Map<string, string>;
|
||||||
prior: readonly DictateRemediateItem[];
|
prior: readonly DictateRemediateItem[];
|
||||||
|
drafts: ReadonlyMap<string, string>;
|
||||||
|
keptDims: ReadonlyMap<string, DictateKeptDim[]>;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
const priorByKey = new Map(
|
const priorByKey = new Map(
|
||||||
@@ -761,6 +980,8 @@ function persistRemediations(
|
|||||||
const next: DictateRemediateItem[] = [];
|
const next: DictateRemediateItem[] = [];
|
||||||
for (const tag of state.pending) {
|
for (const tag of state.pending) {
|
||||||
const prev = priorByKey.get(`insert:${tag}`);
|
const prev = priorByKey.get(`insert:${tag}`);
|
||||||
|
const draft = state.drafts.get(tag) ?? prev?.draft;
|
||||||
|
const kept = state.keptDims.get(tag) ?? prev?.keptDims;
|
||||||
next.push({
|
next.push({
|
||||||
tag,
|
tag,
|
||||||
kind: "insert",
|
kind: "insert",
|
||||||
@@ -768,6 +989,8 @@ function persistRemediations(
|
|||||||
...(state.moduleByTag.get(tag) || prev?.moduleId
|
...(state.moduleByTag.get(tag) || prev?.moduleId
|
||||||
? { moduleId: state.moduleByTag.get(tag) || prev?.moduleId }
|
? { moduleId: state.moduleByTag.get(tag) || prev?.moduleId }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(draft ? { draft } : {}),
|
||||||
|
...(kept?.length ? { keptDims: kept } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const tag of state.pendingDeletes) {
|
for (const tag of state.pendingDeletes) {
|
||||||
@@ -823,6 +1046,18 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function askList(value: unknown): string[] {
|
||||||
|
if (typeof value === "string") return value.trim() ? [value.trim()] : [];
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === "string") return item.trim();
|
||||||
|
const rec = asRecord(item);
|
||||||
|
return String(rec.prompt ?? rec.text ?? rec.question ?? "").trim();
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
function stringList(value: unknown): string[] {
|
function stringList(value: unknown): string[] {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value.map((item) => String(item ?? "").trim()).filter(Boolean);
|
return value.map((item) => String(item ?? "").trim()).filter(Boolean);
|
||||||
|
|||||||
+23
-2
@@ -3,13 +3,13 @@ import type { ToolDefinition } from "../llm/client.js";
|
|||||||
/**
|
/**
|
||||||
* 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。
|
* 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。
|
||||||
*/
|
*/
|
||||||
/** 第一次请求仅此一个工具:点出下一步要写的节点。 */
|
/** 第一次请求与 reply_user 二选一:点出下一步要写的节点。 */
|
||||||
export const DICTATE_SELECT_TOOL: ToolDefinition = {
|
export const DICTATE_SELECT_TOOL: ToolDefinition = {
|
||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: "select_nodes",
|
name: "select_nodes",
|
||||||
description:
|
description:
|
||||||
"规定下一步要写入的目标节点。nodes 只填能力目录中的 module_id。这一步不写产物正文。",
|
"规定下一步要写入的目标节点。nodes 只填能力目录中的 module_id。这一步不写产物正文。本轮只回答或只提问时不要调用本工具,改调 reply_user。",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
@@ -32,6 +32,27 @@ export const DICTATE_SELECT_TOOL: ToolDefinition = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 选题步的回复。与 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 = {
|
export const DICTATE_REPLY_TOOL: ToolDefinition = {
|
||||||
type: "function",
|
type: "function",
|
||||||
|
|||||||
+14
-1
@@ -173,6 +173,8 @@ export async function runDictateTurn(params: {
|
|||||||
repeatableFamilies?: ReadonlySet<string> | readonly string[];
|
repeatableFamilies?: ReadonlySet<string> | readonly string[];
|
||||||
/** 更早对话保留轮数;默认 2,可用 DICTATE_HISTORY_TURNS 覆盖 */
|
/** 更早对话保留轮数;默认 2,可用 DICTATE_HISTORY_TURNS 覆盖 */
|
||||||
historyTurns?: number;
|
historyTurns?: number;
|
||||||
|
/** 第一次填写失败后同轮最多再补写几次;缺省 0 */
|
||||||
|
fillRetries?: number;
|
||||||
driver?: AgentDriver;
|
driver?: AgentDriver;
|
||||||
}): Promise<DictateTurnResult> {
|
}): Promise<DictateTurnResult> {
|
||||||
const driver =
|
const driver =
|
||||||
@@ -198,6 +200,7 @@ export async function runDictateTurn(params: {
|
|||||||
recipeBrief: params.recipeBrief,
|
recipeBrief: params.recipeBrief,
|
||||||
systemPrompt: params.systemPrompt,
|
systemPrompt: params.systemPrompt,
|
||||||
historyTurns: params.historyTurns,
|
historyTurns: params.historyTurns,
|
||||||
|
fillRetries: params.fillRetries,
|
||||||
});
|
});
|
||||||
const wroteTags: string[] = [];
|
const wroteTags: string[] = [];
|
||||||
const deletedTags: string[] = [];
|
const deletedTags: string[] = [];
|
||||||
@@ -1074,8 +1077,18 @@ function parseAndValidateSelfScore(
|
|||||||
expectedNames.some((name) => !actual.includes(name)) ||
|
expectedNames.some((name) => !actual.includes(name)) ||
|
||||||
actual.some((name) => !expectedNames.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 {
|
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 } };
|
return { value: { dims: parsed } };
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./types.js";
|
||||||
|
export * from "./store.js";
|
||||||
|
export * from "./turn.js";
|
||||||
|
export * from "./turn-variants.js";
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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)];
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* `@` 指令展开:匹配已注册指令即替换;未注册保留原文。
|
* `@` 指令 + 酒馆双括号宏 `{{...}}` 展开:
|
||||||
|
* 匹配已注册形式即替换;未注册一律保留原文。
|
||||||
* 身份类可复用于预设/开场白;骰子类仅在发送/进游玩时展开。
|
* 身份类可复用于预设/开场白;骰子类仅在发送/进游玩时展开。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { executeChance } from "../skills/chance-tools.js";
|
import { executeChance } from "../skills/chance-tools.js";
|
||||||
|
import { expandMacros, type MacroKind } from "./macros.js";
|
||||||
|
|
||||||
export type DirectivePersona = {
|
export type DirectivePersona = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -12,6 +14,12 @@ export type DirectivePersona = {
|
|||||||
|
|
||||||
export type DirectiveContext = {
|
export type DirectiveContext = {
|
||||||
persona?: DirectivePersona | null;
|
persona?: DirectivePersona | null;
|
||||||
|
/** `{{char}}` → 预设角色名(我们这边是书名 / 主角名)。缺省回落「作品」。 */
|
||||||
|
charName?: string | null;
|
||||||
|
/** `{{lastUserMessage}}` */
|
||||||
|
lastUserMessage?: string | null;
|
||||||
|
/** `{{setvar}}` / `{{getvar}}` 容器;同一次装配传同一份。 */
|
||||||
|
vars?: Map<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExpandOptions = {
|
export type ExpandOptions = {
|
||||||
@@ -19,9 +27,11 @@ export type ExpandOptions = {
|
|||||||
allowDice?: boolean;
|
allowDice?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DirectiveKind = "persona_name" | "persona_desc" | "dice" | MacroKind;
|
||||||
|
|
||||||
export type DirectiveHit = {
|
export type DirectiveHit = {
|
||||||
raw: string;
|
raw: string;
|
||||||
kind: "persona_name" | "persona_desc" | "dice" | "st_user";
|
kind: DirectiveKind;
|
||||||
replacement: string;
|
replacement: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,13 +151,30 @@ export function expandAtDirectives(
|
|||||||
options: ExpandOptions = {},
|
options: ExpandOptions = {},
|
||||||
): ExpandResult {
|
): ExpandResult {
|
||||||
const allowDice = options.allowDice !== false;
|
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 i = 0;
|
||||||
let out = "";
|
let out = "";
|
||||||
|
|
||||||
while (i < input.length) {
|
while (i < src.length) {
|
||||||
if (input[i] === "@") {
|
if (src[i] === "@") {
|
||||||
const tried = tryExpandAt(input.slice(i), ctx, allowDice);
|
const tried = tryExpandAt(src.slice(i), ctx, allowDice);
|
||||||
if (tried) {
|
if (tried) {
|
||||||
out += tried.hit.replacement;
|
out += tried.hit.replacement;
|
||||||
hits.push(tried.hit);
|
hits.push(tried.hit);
|
||||||
@@ -156,21 +183,12 @@ export function expandAtDirectives(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容 SillyTavern {{user}}
|
out += src[i];
|
||||||
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];
|
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: out,
|
text: macros.trim ? out.trim() : out,
|
||||||
hits,
|
hits,
|
||||||
changed: hits.length > 0,
|
changed: hits.length > 0,
|
||||||
};
|
};
|
||||||
@@ -184,6 +202,17 @@ export function expandIdentityDirectives(
|
|||||||
return expandAtDirectives(input, ctx, { allowDice: false });
|
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 = {
|
export type DirectiveCatalogItem = {
|
||||||
insert: string;
|
insert: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
getImageWorkflow,
|
||||||
|
getImageWorkflowsDir,
|
||||||
|
listImageWorkflows,
|
||||||
|
type ImageWorkflowEntry,
|
||||||
|
} from "./store.js";
|
||||||
|
export { formatImageWorkflowCatalogForPrompt } from "./catalog.js";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
* 新库:实现 formatForPrompt,在本文件 register。
|
* 新库:实现 formatForPrompt,在本文件 register。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { formatImageWorkflowCatalogForPrompt } from "../image-workflow/catalog.js";
|
||||||
import { formatPreferenceCatalogForPrompt } from "../preference/catalog.js";
|
import { formatPreferenceCatalogForPrompt } from "../preference/catalog.js";
|
||||||
import { formatStylePackCatalogForPrompt } from "../style-pack/catalog.js";
|
import { formatStylePackCatalogForPrompt } from "../style-pack/catalog.js";
|
||||||
|
|
||||||
@@ -74,6 +75,12 @@ registerLibrary({
|
|||||||
formatForPrompt: formatPreferenceCatalogForPrompt,
|
formatForPrompt: formatPreferenceCatalogForPrompt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerLibrary({
|
||||||
|
id: "image-workflows",
|
||||||
|
label: "工作流库",
|
||||||
|
formatForPrompt: formatImageWorkflowCatalogForPrompt,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从模块列表收集绑定的库 id(去重、保序)。
|
* 从模块列表收集绑定的库 id(去重、保序)。
|
||||||
* 对话落盘:通常传入 intake≠recipe 的模块。
|
* 对话落盘:通常传入 intake≠recipe 的模块。
|
||||||
|
|||||||
+60
-7
@@ -1,5 +1,7 @@
|
|||||||
import type { LlmConfig } from "../config/env.js";
|
import type { LlmConfig } from "../config/env.js";
|
||||||
|
import { ensureNetworkProxy } from "../config/network.js";
|
||||||
import type { GenerationParameters } from "../types/preset.js";
|
import type { GenerationParameters } from "../types/preset.js";
|
||||||
|
import type { WorldInfoPack as PresetPackForFrame } from "../preset/world-info-pack.js";
|
||||||
import {
|
import {
|
||||||
applyOpenAiGeneration,
|
applyOpenAiGeneration,
|
||||||
fetchWithGenerationCompat,
|
fetchWithGenerationCompat,
|
||||||
@@ -24,13 +26,14 @@ export type ToolCallPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ChatMessage =
|
export type ChatMessage =
|
||||||
| { role: "system" | "user"; content: string }
|
| { role: "system" | "user"; content: string; name?: string }
|
||||||
| {
|
| {
|
||||||
role: "assistant";
|
role: "assistant";
|
||||||
content: string | null;
|
content: string | null;
|
||||||
tool_calls?: ToolCallPayload[];
|
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 = {
|
export type ToolDefinition = {
|
||||||
type: "function";
|
type: "function";
|
||||||
@@ -117,6 +120,11 @@ export type CompleteOptions = {
|
|||||||
/** 统计补充。不进上游请求体。 */
|
/** 统计补充。不进上游请求体。 */
|
||||||
usageDetail?: UsageDetail;
|
usageDetail?: UsageDetail;
|
||||||
usageSink?: UsageSink;
|
usageSink?: UsageSink;
|
||||||
|
/**
|
||||||
|
* 内部:play 真多轮装配的分袋上下文(由 worker 从 contextSegments 切成四袋 + 历史轮次)。
|
||||||
|
* 不进上游请求体;由 PresetLlmProvider 取用后丢弃。
|
||||||
|
*/
|
||||||
|
presetPack?: PresetPackForFrame;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ToolChoice =
|
export type ToolChoice =
|
||||||
@@ -223,6 +231,33 @@ export function ensureJsonObjectPrompt(messages: ChatMessage[]): ChatMessage[] {
|
|||||||
return [{ role: "system", content: JSON_OBJECT_HINT }, ...messages];
|
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(
|
export function buildRequestBody(
|
||||||
config: LlmConfig,
|
config: LlmConfig,
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
@@ -431,6 +466,7 @@ async function llmFetch(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
return fetchWithGenerationCompat(async (payload) => {
|
return fetchWithGenerationCompat(async (payload) => {
|
||||||
throwIfAborted(options?.signal);
|
throwIfAborted(options?.signal);
|
||||||
|
await ensureNetworkProxy();
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -533,17 +569,29 @@ export class OpenAiCompatibleProvider implements LlmProvider {
|
|||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
model?: string;
|
model?: string;
|
||||||
usage?: unknown;
|
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) {
|
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 {
|
return {
|
||||||
content: parts.content,
|
content: parts.content,
|
||||||
toolCalls: parts.toolCalls,
|
toolCalls: parts.toolCalls,
|
||||||
reasoning: parts.reasoning,
|
reasoning: parts.reasoning,
|
||||||
usage: parseUsage(data.usage),
|
usage,
|
||||||
model: data.model ?? this.config.model,
|
model: data.model ?? this.config.model,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -576,7 +624,12 @@ export class OpenAiCompatibleProvider implements LlmProvider {
|
|||||||
|
|
||||||
const parts = await consumeOpenAiToolStream(response.body, callbacks);
|
const parts = await consumeOpenAiToolStream(response.body, callbacks);
|
||||||
if (!parts.content && parts.toolCalls.length === 0 && !parts.reasoning) {
|
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 =
|
const content =
|
||||||
parts.content ??
|
parts.content ??
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* 拉取 OpenAI 兼容的 GET {base}/models,供同一 API 配置下快捷切换模型。
|
* 拉取 OpenAI 兼容的 GET {base}/models,供同一 API 配置下快捷切换模型。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { ensureNetworkProxy } from "../config/network.js";
|
||||||
|
|
||||||
const MAX_BODY_CHARS = 2_000_000;
|
const MAX_BODY_CHARS = 2_000_000;
|
||||||
const MAX_MODELS = 4000;
|
const MAX_MODELS = 4000;
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ type FetchOutcome =
|
|||||||
|
|
||||||
async function fetchModelList(url: string, apiKey: string): Promise<FetchOutcome> {
|
async function fetchModelList(url: string, apiKey: string): Promise<FetchOutcome> {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
|
await ensureNetworkProxy();
|
||||||
try {
|
try {
|
||||||
response = await fetch(url, {
|
response = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
+260
-13
@@ -1,8 +1,30 @@
|
|||||||
import { loadAppSettings } from "../config/settings.js";
|
import {
|
||||||
|
loadAppSettings,
|
||||||
|
normalizeMessageSquash,
|
||||||
|
normalizePresetToolPolicy,
|
||||||
|
type MessageSquashSetting,
|
||||||
|
} from "../config/settings.js";
|
||||||
import type { PersonaDirective } from "../persona/store.js";
|
import type { PersonaDirective } from "../persona/store.js";
|
||||||
import { frameMessagesWithPreset } from "../preset/play-frame.js";
|
import { frameMessagesWithPreset, assemblePlayWorkerMessages, shapeFramedMessages } from "../preset/play-frame.js";
|
||||||
import { resolveActivePreset } from "../preset/store.js";
|
import { resolveActivePreset } from "../preset/store.js";
|
||||||
import type { GenerationParameters, PresetPackage } from "../types/preset.js";
|
import {
|
||||||
|
declarePresetTools,
|
||||||
|
extractDeclaredPayload,
|
||||||
|
} from "../preset/tool-declaration.js";
|
||||||
|
import {
|
||||||
|
decorateTools,
|
||||||
|
normalizeToolCalls,
|
||||||
|
resolveToolPlan,
|
||||||
|
toolProtocolEntryIds,
|
||||||
|
type ToolPolicyMode,
|
||||||
|
} from "../preset/tool-policy.js";
|
||||||
|
import type {
|
||||||
|
GenerationParameters,
|
||||||
|
MessageSquashMode,
|
||||||
|
PresetPackage,
|
||||||
|
} from "../types/preset.js";
|
||||||
|
import { DEFAULT_PRESET_SQUASH } from "../types/preset.js";
|
||||||
|
import { isCompleteJson, salvageTruncatedPayload } from "./truncation.js";
|
||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
CompleteOptions,
|
CompleteOptions,
|
||||||
@@ -10,8 +32,10 @@ import type {
|
|||||||
CompleteWithToolsOptions,
|
CompleteWithToolsOptions,
|
||||||
CompleteWithToolsResult,
|
CompleteWithToolsResult,
|
||||||
LlmProvider,
|
LlmProvider,
|
||||||
|
ParsedToolCall,
|
||||||
StreamCallbacks,
|
StreamCallbacks,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
|
import { debugLog } from "../log.js";
|
||||||
import {
|
import {
|
||||||
TokenTrackingProvider,
|
TokenTrackingProvider,
|
||||||
type LlmTrackingContext,
|
type LlmTrackingContext,
|
||||||
@@ -19,12 +43,73 @@ import {
|
|||||||
|
|
||||||
export type LlmTrackingRef = { current: LlmTrackingContext };
|
export type LlmTrackingRef = { current: LlmTrackingContext };
|
||||||
|
|
||||||
|
/** 预设声明工具的结果缓存(key = 预设 id@importedAt)。 */
|
||||||
|
const declaredToolCache = new Map<
|
||||||
|
string,
|
||||||
|
ReturnType<typeof declarePresetTools>
|
||||||
|
>();
|
||||||
|
|
||||||
function readActivePreset(): PresetPackage | null {
|
function readActivePreset(): PresetPackage | null {
|
||||||
return resolveActivePreset(loadAppSettings().activePresetId);
|
return resolveActivePreset(loadAppSettings().activePresetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 思考强度只跟 API 配置,不跟预设。 */
|
/** 预设里 `{{char}}` 的取值:优先当前作品名;没有作品时(如试跑)用这个。 */
|
||||||
function generationWithoutReasoningEffort(
|
const PRESET_CHAR_FALLBACK = "作品";
|
||||||
|
const PRESET_CHAR_MAX = 40;
|
||||||
|
|
||||||
|
/** 把拿到的书名/存档名规整成可用的 `{{char}}` 取值。 */
|
||||||
|
function normalizeCharName(raw: string | null | undefined): string {
|
||||||
|
const text = (raw ?? "").trim().slice(0, PRESET_CHAR_MAX);
|
||||||
|
return text || PRESET_CHAR_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一次请求要用到的预设运行参数(设置 + 预设字段一次算好)。 */export type PresetRuntime = {
|
||||||
|
charName: string;
|
||||||
|
squash: MessageSquashMode;
|
||||||
|
toolPolicy: ToolPolicyMode;
|
||||||
|
/** `toolPolicy = app` 时要从装配里剔掉的伪工具条目 */
|
||||||
|
excludeEntryIds?: ReadonlySet<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 单次解析:设置优先,否则跟预设,都未定义时落回 strict。 */
|
||||||
|
export function resolveSquashForPreset(
|
||||||
|
preset: PresetPackage,
|
||||||
|
setting: MessageSquashSetting,
|
||||||
|
): MessageSquashMode {
|
||||||
|
if (setting !== "follow-preset") return setting;
|
||||||
|
return preset.messagePolicy?.squash ?? DEFAULT_PRESET_SQUASH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析本次请求的预设运行参数。
|
||||||
|
* 只读一次 settings.json(之前每个请求要读 3 次)。
|
||||||
|
* `{{char}}` 取当前作品名(对话落盘时给快照起的名字),无作品时回落「作品」。
|
||||||
|
*/
|
||||||
|
export function resolvePresetRuntime(
|
||||||
|
preset: PresetPackage,
|
||||||
|
charName?: string | null,
|
||||||
|
): PresetRuntime {
|
||||||
|
const settings = loadAppSettings();
|
||||||
|
const squashSetting = normalizeMessageSquash(settings.messageSquash);
|
||||||
|
const squash = resolveSquashForPreset(preset, squashSetting);
|
||||||
|
const toolPolicy = normalizePresetToolPolicy(settings.presetToolPolicy);
|
||||||
|
const exclude =
|
||||||
|
toolPolicy === "app" ? toolProtocolEntryIds(preset) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
charName: normalizeCharName(charName),
|
||||||
|
squash,
|
||||||
|
toolPolicy,
|
||||||
|
excludeEntryIds: exclude && exclude.size > 0 ? exclude : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消息整形模式(只要这一项时用)。 */
|
||||||
|
export function resolveSquashMode(preset: PresetPackage): MessageSquashMode {
|
||||||
|
return resolvePresetRuntime(preset).squash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 思考强度只跟 API 配置,不跟预设。 */function generationWithoutReasoningEffort(
|
||||||
generation: GenerationParameters | undefined,
|
generation: GenerationParameters | undefined,
|
||||||
): GenerationParameters | undefined {
|
): GenerationParameters | undefined {
|
||||||
if (!generation || generation.reasoningEffort === undefined) return generation;
|
if (!generation || generation.reasoningEffort === undefined) return generation;
|
||||||
@@ -33,6 +118,40 @@ function generationWithoutReasoningEffort(
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** tool 循环里的消息:不能被夹心丢掉。 */
|
||||||
|
function isToolLoopMessage(m: ChatMessage): boolean {
|
||||||
|
if (m.role === "tool") return true;
|
||||||
|
return m.role === "assistant" && Boolean(m.tool_calls?.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 截断时能救回来的下限:太短的多半是字段名残片,不如让它直接报错。 */
|
||||||
|
const SALVAGE_MIN_CHARS = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出被截断时,把 tool_call 里已经生成的正文拨回来。
|
||||||
|
* 只处理 arguments 无法解析的那些;能解析的一律不动。
|
||||||
|
*/
|
||||||
|
function salvageTruncatedCalls(
|
||||||
|
calls: ParsedToolCall[],
|
||||||
|
decoration: { payloadAliases: Array<{ tool: string; primary: string; fields: string[] }> },
|
||||||
|
): ParsedToolCall[] {
|
||||||
|
return calls.map((call) => {
|
||||||
|
if (isCompleteJson(call.arguments)) return call;
|
||||||
|
const alias = decoration.payloadAliases.find((a) => a.tool === call.name);
|
||||||
|
const fields = alias ? [alias.primary, ...alias.fields] : ["content"];
|
||||||
|
const saved = salvageTruncatedPayload(call.arguments, fields);
|
||||||
|
if (!saved || saved.text.trim().length < SALVAGE_MIN_CHARS) return call;
|
||||||
|
debugLog(
|
||||||
|
"llm",
|
||||||
|
`输出被截断:${call.name} 参数不完整,已保留 ${saved.text.length} 字(可能不完整)`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...call,
|
||||||
|
arguments: JSON.stringify({ [alias?.primary ?? "content"]: saved.text }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计在内、预设在外:痕迹里的 messages / generation 就是发给模型的那一份。
|
* 统计在内、预设在外:痕迹里的 messages / generation 就是发给模型的那一份。
|
||||||
* 有选用预设时:在此按 prompt_order 夹心包裹,并合并采样参数。
|
* 有选用预设时:在此按 prompt_order 夹心包裹,并合并采样参数。
|
||||||
@@ -43,11 +162,19 @@ export function wrapLlmForSession(
|
|||||||
trackingRef?: LlmTrackingRef,
|
trackingRef?: LlmTrackingRef,
|
||||||
getPreset: () => PresetPackage | null = readActivePreset,
|
getPreset: () => PresetPackage | null = readActivePreset,
|
||||||
getPersona?: () => PersonaDirective | null,
|
getPersona?: () => PersonaDirective | null,
|
||||||
|
/** `{{char}}` 的取值来源;缺省用存档名(bookTitle) */
|
||||||
|
getCharName?: () => string | null | undefined,
|
||||||
): LlmProvider {
|
): LlmProvider {
|
||||||
const tracked = trackingRef
|
const tracked = trackingRef
|
||||||
? new TokenTrackingProvider(inner, () => trackingRef.current)
|
? new TokenTrackingProvider(inner, () => trackingRef.current)
|
||||||
: inner;
|
: inner;
|
||||||
return new PresetLlmProvider(tracked, getPreset, getPersona);
|
return new PresetLlmProvider(
|
||||||
|
tracked,
|
||||||
|
getPreset,
|
||||||
|
getPersona,
|
||||||
|
getCharName ??
|
||||||
|
(trackingRef ? () => trackingRef.current.bookTitle : undefined),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 所有 LLM 请求:preset 夹心 + 生成参数。无选用预设则原样转发。 */
|
/** 所有 LLM 请求:preset 夹心 + 生成参数。无选用预设则原样转发。 */
|
||||||
@@ -56,23 +183,107 @@ export class PresetLlmProvider implements LlmProvider {
|
|||||||
private readonly inner: LlmProvider,
|
private readonly inner: LlmProvider,
|
||||||
private readonly getPreset: () => PresetPackage | null,
|
private readonly getPreset: () => PresetPackage | null,
|
||||||
private readonly getPersona?: () => PersonaDirective | null,
|
private readonly getPersona?: () => PersonaDirective | null,
|
||||||
|
/** 当前作品名(存档名),决定 `{{char}}` 的取值 */
|
||||||
|
private readonly getCharName?: () => string | null | undefined,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** 预设声明的工具(只在 `presetToolPolicy = preset` 时生效)。结果按预设 id 缓存。 */
|
||||||
|
private declareTools(
|
||||||
|
preset: PresetPackage | null,
|
||||||
|
runtime: PresetRuntime | null,
|
||||||
|
): ReturnType<typeof declarePresetTools> {
|
||||||
|
if (!preset || !runtime || runtime.toolPolicy !== "preset") return null;
|
||||||
|
const key = `${preset.id}@${preset.importedAt}`;
|
||||||
|
const cached = declaredToolCache.get(key);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const declared = declarePresetTools(preset);
|
||||||
|
declaredToolCache.set(key, declared);
|
||||||
|
return declared;
|
||||||
|
}
|
||||||
|
|
||||||
private prepare(
|
private prepare(
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
options?: CompleteOptions,
|
options?: CompleteOptions,
|
||||||
): { messages: ChatMessage[]; options: CompleteOptions | undefined } {
|
): {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
options: CompleteOptions | undefined;
|
||||||
|
preset: PresetPackage | null;
|
||||||
|
runtime: PresetRuntime | null;
|
||||||
|
} {
|
||||||
const preset = this.getPreset();
|
const preset = this.getPreset();
|
||||||
if (!preset) return { messages, options };
|
if (!preset) return { messages, options, preset: null, runtime: null };
|
||||||
const persona = this.getPersona ? this.getPersona() : undefined;
|
const persona = this.getPersona ? this.getPersona() : undefined;
|
||||||
|
const runtime = resolvePresetRuntime(
|
||||||
|
preset,
|
||||||
|
this.getCharName ? this.getCharName() : undefined,
|
||||||
|
);
|
||||||
|
// 声明了工具时,被吃掉的那几条(工具说明/字段说明)不再作为消息发一遍。
|
||||||
|
const declared = this.declareTools(preset, runtime);
|
||||||
|
const excludeEntryIds = declared?.consumedEntryIds.length
|
||||||
|
? new Set([
|
||||||
|
...(runtime.excludeEntryIds ?? []),
|
||||||
|
...declared.consumedEntryIds,
|
||||||
|
])
|
||||||
|
: runtime.excludeEntryIds;
|
||||||
|
const framed = options?.presetPack
|
||||||
|
? [
|
||||||
|
...assemblePlayWorkerMessages({
|
||||||
|
systemPrompt: messages
|
||||||
|
.filter((m) => m.role === "system")
|
||||||
|
.map((m) => m.content)
|
||||||
|
.join("\n\n"),
|
||||||
|
preset,
|
||||||
|
pack: options.presetPack,
|
||||||
|
persona,
|
||||||
|
charName: runtime.charName,
|
||||||
|
excludeEntryIds,
|
||||||
|
}),
|
||||||
|
// tool 循环尾必须原样接在后面,否则 harness 第二转就丢工具结果。
|
||||||
|
...messages.filter(isToolLoopMessage),
|
||||||
|
]
|
||||||
|
: frameMessagesWithPreset(
|
||||||
|
preset,
|
||||||
|
messages,
|
||||||
|
persona,
|
||||||
|
runtime.charName,
|
||||||
|
excludeEntryIds,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
messages: frameMessagesWithPreset(preset, messages, persona),
|
messages: shapeFramedMessages(framed, runtime.squash),
|
||||||
options: {
|
options: {
|
||||||
...options,
|
...options,
|
||||||
|
presetPack: undefined,
|
||||||
generation: generationWithoutReasoningEffort(
|
generation: generationWithoutReasoningEffort(
|
||||||
options?.generation ?? preset.generation,
|
options?.generation ?? preset.generation,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
preset,
|
||||||
|
runtime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预设声明了工具时,用带 tools 的请求,并把工具调用折回正文。 */
|
||||||
|
private async completeWithDeclaredTools(
|
||||||
|
prepared: ReturnType<PresetLlmProvider["prepare"]>,
|
||||||
|
declared: NonNullable<ReturnType<PresetLlmProvider["declareTools"]>>,
|
||||||
|
callbacks: StreamCallbacks,
|
||||||
|
): Promise<CompleteResult> {
|
||||||
|
const result = await this.inner.completeWithTools(prepared.messages, {
|
||||||
|
...prepared.options,
|
||||||
|
tools: declared.tools,
|
||||||
|
toolChoice: declared.toolChoice,
|
||||||
|
});
|
||||||
|
const payload = result.toolCalls
|
||||||
|
.map((call) => extractDeclaredPayload(call.arguments, declared.payloadFields))
|
||||||
|
.find((text) => Boolean(text && text.trim()));
|
||||||
|
const content = payload ?? result.content ?? "";
|
||||||
|
if (payload) callbacks.onContentDelta?.(payload);
|
||||||
|
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
...(result.reasoning ? { reasoning: result.reasoning } : {}),
|
||||||
|
...(result.usage ? { usage: result.usage } : {}),
|
||||||
|
...(result.model ? { model: result.model } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +292,10 @@ export class PresetLlmProvider implements LlmProvider {
|
|||||||
options?: CompleteOptions,
|
options?: CompleteOptions,
|
||||||
): Promise<CompleteResult> {
|
): Promise<CompleteResult> {
|
||||||
const prepared = this.prepare(messages, options);
|
const prepared = this.prepare(messages, options);
|
||||||
|
const declared = this.declareTools(prepared.preset, prepared.runtime);
|
||||||
|
if (declared) {
|
||||||
|
return this.completeWithDeclaredTools(prepared, declared, {});
|
||||||
|
}
|
||||||
return this.inner.complete(prepared.messages, prepared.options);
|
return this.inner.complete(prepared.messages, prepared.options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +311,10 @@ export class PresetLlmProvider implements LlmProvider {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
const prepared = this.prepare(messages, options);
|
const prepared = this.prepare(messages, options);
|
||||||
|
const declared = this.declareTools(prepared.preset, prepared.runtime);
|
||||||
|
if (declared) {
|
||||||
|
return this.completeWithDeclaredTools(prepared, declared, callbacks);
|
||||||
|
}
|
||||||
return this.inner.completeStream(
|
return this.inner.completeStream(
|
||||||
prepared.messages,
|
prepared.messages,
|
||||||
prepared.options,
|
prepared.options,
|
||||||
@@ -108,12 +327,26 @@ export class PresetLlmProvider implements LlmProvider {
|
|||||||
options: CompleteWithToolsOptions,
|
options: CompleteWithToolsOptions,
|
||||||
): Promise<CompleteWithToolsResult> {
|
): Promise<CompleteWithToolsResult> {
|
||||||
const prepared = this.prepare(messages, options);
|
const prepared = this.prepare(messages, options);
|
||||||
return this.inner.completeWithTools(prepared.messages, {
|
const decoration = prepared.preset
|
||||||
|
? decorateTools({
|
||||||
|
mode: prepared.runtime!.toolPolicy,
|
||||||
|
tools: options.tools,
|
||||||
|
plan: resolveToolPlan(prepared.preset),
|
||||||
|
})
|
||||||
|
: { tools: options.tools, aliases: [], payloadAliases: [], notes: [] };
|
||||||
|
const result = await this.inner.completeWithTools(prepared.messages, {
|
||||||
...options,
|
...options,
|
||||||
...prepared.options,
|
...prepared.options,
|
||||||
tools: options.tools,
|
tools: decoration.tools,
|
||||||
toolChoice: options.toolChoice,
|
toolChoice: options.toolChoice,
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
toolCalls: salvageTruncatedCalls(
|
||||||
|
normalizeToolCalls(result.toolCalls, decoration, decoration.tools),
|
||||||
|
decoration,
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async completeWithToolsStream(
|
async completeWithToolsStream(
|
||||||
@@ -125,15 +358,29 @@ export class PresetLlmProvider implements LlmProvider {
|
|||||||
return this.completeWithTools(messages, options);
|
return this.completeWithTools(messages, options);
|
||||||
}
|
}
|
||||||
const prepared = this.prepare(messages, options);
|
const prepared = this.prepare(messages, options);
|
||||||
return this.inner.completeWithToolsStream(
|
const decoration = prepared.preset
|
||||||
|
? decorateTools({
|
||||||
|
mode: prepared.runtime!.toolPolicy,
|
||||||
|
tools: options.tools,
|
||||||
|
plan: resolveToolPlan(prepared.preset),
|
||||||
|
})
|
||||||
|
: { tools: options.tools, aliases: [], payloadAliases: [], notes: [] };
|
||||||
|
const result = await this.inner.completeWithToolsStream(
|
||||||
prepared.messages,
|
prepared.messages,
|
||||||
{
|
{
|
||||||
...options,
|
...options,
|
||||||
...prepared.options,
|
...prepared.options,
|
||||||
tools: options.tools,
|
tools: decoration.tools,
|
||||||
toolChoice: options.toolChoice,
|
toolChoice: options.toolChoice,
|
||||||
},
|
},
|
||||||
callbacks,
|
callbacks,
|
||||||
);
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
toolCalls: salvageTruncatedCalls(
|
||||||
|
normalizeToolCalls(result.toolCalls, decoration, decoration.tools),
|
||||||
|
decoration,
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* 输出被截断时救回已经生成的那部分。
|
||||||
|
*
|
||||||
|
* 正文走工具参数,一旦 `maxOutputTokens` 到顶,`arguments` 就是半截 JSON:
|
||||||
|
* `{"content":"第一章……` 没有收尾。直接 JSON.parse 会失败,于是整段正文丢掉。
|
||||||
|
* 这里把已生成的字符串值捞出来,至少不丢内容。
|
||||||
|
*/
|
||||||
|
|
||||||
|
function decodeJsonFragment(raw: string): string {
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const ch = raw[i]!;
|
||||||
|
if (ch !== "\\") {
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = raw[i + 1];
|
||||||
|
if (next === undefined) break;
|
||||||
|
i += 1;
|
||||||
|
switch (next) {
|
||||||
|
case "n":
|
||||||
|
out += "\n";
|
||||||
|
break;
|
||||||
|
case "r":
|
||||||
|
out += "\r";
|
||||||
|
break;
|
||||||
|
case "t":
|
||||||
|
out += "\t";
|
||||||
|
break;
|
||||||
|
case "b":
|
||||||
|
out += "\b";
|
||||||
|
break;
|
||||||
|
case "f":
|
||||||
|
out += "\f";
|
||||||
|
break;
|
||||||
|
case "u": {
|
||||||
|
const hex = raw.slice(i + 1, i + 5);
|
||||||
|
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
|
||||||
|
out += String.fromCharCode(Number.parseInt(hex, 16));
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
out += next;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCompleteJson(raw: string): boolean {
|
||||||
|
try {
|
||||||
|
JSON.parse(raw || "{}");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SalvagedPayload = {
|
||||||
|
field: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在(可能不完整的)JSON 片段里取某个字符串字段已生成的部分。
|
||||||
|
* 依次尝试 `fields`,命中第一个非空即返回。
|
||||||
|
*/
|
||||||
|
export function salvageTruncatedPayload(
|
||||||
|
argumentsJson: string,
|
||||||
|
fields: string[],
|
||||||
|
): SalvagedPayload | null {
|
||||||
|
const json = argumentsJson ?? "";
|
||||||
|
for (const field of fields) {
|
||||||
|
const key = `"${field}"`;
|
||||||
|
const keyAt = json.indexOf(key);
|
||||||
|
if (keyAt < 0) continue;
|
||||||
|
const colonAt = json.indexOf(":", keyAt + key.length);
|
||||||
|
if (colonAt < 0) continue;
|
||||||
|
|
||||||
|
let i = colonAt + 1;
|
||||||
|
while (i < json.length && /\s/.test(json[i]!)) i++;
|
||||||
|
if (json[i] !== '"') continue;
|
||||||
|
|
||||||
|
// 从开引号扫到收引号或字符串末尾(截断)
|
||||||
|
let end = json.length;
|
||||||
|
let escaped = false;
|
||||||
|
for (let j = i + 1; j < json.length; j++) {
|
||||||
|
const ch = json[j]!;
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
end = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = decodeJsonFragment(json.slice(i + 1, end));
|
||||||
|
if (text.trim()) return { field, text };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -218,6 +218,7 @@ export function labelCaller(caller: string | undefined): string {
|
|||||||
if (who === "dictate_agent") return "对话落盘";
|
if (who === "dictate_agent") return "对话落盘";
|
||||||
if (who.startsWith("listen_serial_")) return "听书连载";
|
if (who.startsWith("listen_serial_")) return "听书连载";
|
||||||
if (who === "preset-probe") return "预设探测";
|
if (who === "preset-probe") return "预设探测";
|
||||||
|
if (who === "direct-chat") return "直聊";
|
||||||
if (who.startsWith("model-compare:")) {
|
if (who.startsWith("model-compare:")) {
|
||||||
return `模型对比 ${who.slice("model-compare:".length)}`;
|
return `模型对比 ${who.slice("model-compare:".length)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function defaultStore(): PersonaStoreFile {
|
|||||||
personas: [
|
personas: [
|
||||||
{
|
{
|
||||||
id: playId,
|
id: playId,
|
||||||
name: "玩家",
|
name: "User", // 与酒馆默认用户名一致,{{user}} 才能对得上
|
||||||
description: "",
|
description: "",
|
||||||
createdAt: t,
|
createdAt: t,
|
||||||
updatedAt: t,
|
updatedAt: t,
|
||||||
|
|||||||
+125
-8
@@ -2,12 +2,76 @@ import type { ChatMessage } from "../llm/client.js";
|
|||||||
import type { PresetPackage } from "../types/preset.js";
|
import type { PresetPackage } from "../types/preset.js";
|
||||||
import { isStUnfilledMarker } from "./markers.js";
|
import { isStUnfilledMarker } from "./markers.js";
|
||||||
|
|
||||||
export type MarkerResolver = (identifier: string) => string | null;
|
/**
|
||||||
|
* 打在「历史区」message 上的记号:真多轮的那一段。
|
||||||
|
* Symbol 属性不会进请求体,也不会进存档;`splitPresetHistory` 用它切段,
|
||||||
|
* 免得消息整形(squash)把历史轮次揉回一段。
|
||||||
|
*/
|
||||||
|
export const PRESET_HISTORY_MARK = Symbol.for("writingAgent.presetHistory");
|
||||||
|
|
||||||
|
/** 填洞结果:一段文本,或一段 message 序列(历史)。 */
|
||||||
|
export type MarkerFill = string | ChatMessage[] | null;
|
||||||
|
export type MarkerResolver = (identifier: string) => MarkerFill;
|
||||||
export type ContentExpander = (content: string) => string;
|
export type ContentExpander = (content: string) => string;
|
||||||
|
|
||||||
const defaultMarkerResolver: MarkerResolver = () => null;
|
const defaultMarkerResolver: MarkerResolver = () => null;
|
||||||
const defaultContentExpander: ContentExpander = (c) => c;
|
const defaultContentExpander: ContentExpander = (c) => c;
|
||||||
|
|
||||||
|
function isMessageSequence(fill: MarkerFill): fill is ChatMessage[] {
|
||||||
|
return Array.isArray(fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DepthInjection = {
|
||||||
|
depth: number;
|
||||||
|
order: number;
|
||||||
|
seq: number;
|
||||||
|
message: ChatMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 历史区在 messages 里的下标范围(mark 过的连续段)。 */
|
||||||
|
function historyRange(messages: ChatMessage[]): { start: number; end: number } {
|
||||||
|
const hit = (m: ChatMessage) =>
|
||||||
|
Boolean((m as unknown as Record<symbol, unknown>)[PRESET_HISTORY_MARK]);
|
||||||
|
const first = messages.findIndex(hit);
|
||||||
|
if (first < 0) return { start: messages.length, end: messages.length };
|
||||||
|
let last = first;
|
||||||
|
for (let i = messages.length - 1; i > first; i--) {
|
||||||
|
if (hit(messages[i]!)) {
|
||||||
|
last = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { start: first, end: last + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `injection_position = 1`:按 `injection_depth` 从历史底部数往上插。
|
||||||
|
* depth 0 = 历史最后一条之后(紧贴 currentTurn 之前);depth 1 = 倒数第一条之前,以此类推。
|
||||||
|
* 同深度按 `injection_order` 升序排(大的更靠后,同酒馆)。
|
||||||
|
* 无历史区时(单包请求)直接按收集顺序附在末尾。
|
||||||
|
*/
|
||||||
|
function insertDepthInjections(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
items: DepthInjection[],
|
||||||
|
): void {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const { start, end } = historyRange(messages);
|
||||||
|
const placed = items
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
at: Math.max(start, end - Math.max(0, item.depth)),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.at - b.at || a.order - b.order || a.seq - b.seq);
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
for (const item of placed) {
|
||||||
|
const next = { ...item.message } as ChatMessage;
|
||||||
|
(next as unknown as Record<symbol, unknown>)[PRESET_HISTORY_MARK] = true;
|
||||||
|
messages.splice(item.at + inserted, 0, next);
|
||||||
|
inserted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按 prompt_order 装配 preset 消息。
|
* 按 prompt_order 装配 preset 消息。
|
||||||
* 酒馆角色卡 / WI marker 不走 resolver(空洞跳过);自有洞由 resolver 填。
|
* 酒馆角色卡 / WI marker 不走 resolver(空洞跳过);自有洞由 resolver 填。
|
||||||
@@ -17,9 +81,12 @@ export function assemblePresetMessages(
|
|||||||
preset: PresetPackage,
|
preset: PresetPackage,
|
||||||
resolveMarker: MarkerResolver = defaultMarkerResolver,
|
resolveMarker: MarkerResolver = defaultMarkerResolver,
|
||||||
contentExpander: ContentExpander = defaultContentExpander,
|
contentExpander: ContentExpander = defaultContentExpander,
|
||||||
|
excludeEntryIds?: ReadonlySet<string>,
|
||||||
): ChatMessage[] {
|
): ChatMessage[] {
|
||||||
const promptById = new Map(preset.prompts.map((p) => [p.id, p]));
|
const promptById = new Map(preset.prompts.map((p) => [p.id, p]));
|
||||||
const messages: ChatMessage[] = [];
|
const messages: ChatMessage[] = [];
|
||||||
|
const depthItems: DepthInjection[] = [];
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
const ordered = [...preset.promptOrder].sort(
|
const ordered = [...preset.promptOrder].sort(
|
||||||
(a, b) => a.orderIndex - b.orderIndex,
|
(a, b) => a.orderIndex - b.orderIndex,
|
||||||
@@ -27,29 +94,79 @@ export function assemblePresetMessages(
|
|||||||
|
|
||||||
for (const orderItem of ordered) {
|
for (const orderItem of ordered) {
|
||||||
if (!orderItem.enabled) continue;
|
if (!orderItem.enabled) continue;
|
||||||
|
if (excludeEntryIds?.has(orderItem.promptId)) continue;
|
||||||
const entry = promptById.get(orderItem.promptId);
|
const entry = promptById.get(orderItem.promptId);
|
||||||
if (!entry || !entry.enabled) continue;
|
// 酒馆只认 prompt_order.enabled;prompts[].enabled 是历史遗留字段(openai.js 从不读它)。
|
||||||
|
if (!entry) continue;
|
||||||
|
|
||||||
let content = entry.content.trim();
|
let content = entry.content.trim();
|
||||||
const markerId = entry.sourceIdentifier || entry.id;
|
const markerId = entry.sourceIdentifier || entry.id;
|
||||||
const skipFill = isStUnfilledMarker(markerId);
|
const skipFill = isStUnfilledMarker(markerId);
|
||||||
|
let fill: MarkerFill = content;
|
||||||
if (!content && entry.marker && !skipFill) {
|
if (!content && entry.marker && !skipFill) {
|
||||||
const resolved = resolveMarker(markerId);
|
fill = resolveMarker(markerId);
|
||||||
if (resolved?.trim()) content = resolved.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!content) continue;
|
if (isMessageSequence(fill)) {
|
||||||
content = contentExpander(content);
|
// 历史洞:一条条目展开成多轮 message,各自保留角色。
|
||||||
|
for (const message of fill) {
|
||||||
|
const body = contentExpander(message.content ?? "").trim();
|
||||||
|
if (!body) continue;
|
||||||
|
const next = { ...message, content: body } as ChatMessage;
|
||||||
|
// 只保护真实对话轮(user / assistant):
|
||||||
|
// 无结构的历史会降级成一条 system,它仍然允许被 merge 整形。
|
||||||
|
if (next.role !== "system") {
|
||||||
|
(next as unknown as Record<symbol, unknown>)[PRESET_HISTORY_MARK] = true;
|
||||||
|
}
|
||||||
|
messages.push(next);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
messages.push({
|
content = (fill ?? "").trim();
|
||||||
|
if (!content) continue;
|
||||||
|
// 展开后可能变空(只剩注释 / 只有 setvar):空条目不入 messages。
|
||||||
|
content = contentExpander(content).trim();
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
const message: ChatMessage = {
|
||||||
role: entry.role,
|
role: entry.role,
|
||||||
content,
|
content,
|
||||||
});
|
};
|
||||||
|
// 酒馆的「按深度插入」:不进线性位置,等历史区就绪后插进历史里。
|
||||||
|
if (entry.injection?.position === 1) {
|
||||||
|
depthItems.push({
|
||||||
|
depth: entry.injection.depth ?? 0,
|
||||||
|
order: entry.injection.order ?? 0,
|
||||||
|
seq: seq++,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
insertDepthInjections(messages, depthItems);
|
||||||
|
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 把历史区从装配结果里切出来(squash 只整形前后两段)。 */
|
||||||
|
export function splitPresetHistory(messages: ChatMessage[]): {
|
||||||
|
head: ChatMessage[];
|
||||||
|
history: ChatMessage[];
|
||||||
|
tail: ChatMessage[];
|
||||||
|
} {
|
||||||
|
const { start, end } = historyRange(messages);
|
||||||
|
if (start === end) return { head: messages, history: [], tail: [] };
|
||||||
|
return {
|
||||||
|
head: messages.slice(0, start),
|
||||||
|
history: messages.slice(start, end),
|
||||||
|
tail: messages.slice(end),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeMessages(
|
export function mergeMessages(
|
||||||
presetMessages: ChatMessage[],
|
presetMessages: ChatMessage[],
|
||||||
taskMessages: ChatMessage[],
|
taskMessages: ChatMessage[],
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { collectMacroUsages, findUnclosedMacro } from "../directives/macros.js";
|
||||||
|
import type { PresetPackage } from "../types/preset.js";
|
||||||
|
import { CHAT_HISTORY_MARKER } from "./markers.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预设编辑器的结构检查(不调用模型)。
|
||||||
|
* error:会导致装配或发送异常;warn:会让预设行为与预期不符,但仍可发送。
|
||||||
|
*/
|
||||||
|
export type AuthoringIssue = {
|
||||||
|
level: "error" | "warn";
|
||||||
|
entryId: string | null;
|
||||||
|
entryName: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VALID_ROLES = new Set(["system", "user", "assistant"]);
|
||||||
|
const FULLWIDTH_OPEN = "{{";
|
||||||
|
|
||||||
|
export function checkAuthoringPreset(preset: PresetPackage): AuthoringIssue[] {
|
||||||
|
const issues: AuthoringIssue[] = [];
|
||||||
|
const byId = new Map<string, PresetPackage["prompts"][number]>();
|
||||||
|
const push = (
|
||||||
|
level: AuthoringIssue["level"],
|
||||||
|
entryId: string | null,
|
||||||
|
entryName: string,
|
||||||
|
message: string,
|
||||||
|
) => issues.push({ level, entryId, entryName, message });
|
||||||
|
|
||||||
|
if (preset.prompts.length === 0) {
|
||||||
|
push("error", null, "", "预设没有任何模块");
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prompt of preset.prompts) {
|
||||||
|
if (byId.has(prompt.id)) {
|
||||||
|
push("error", prompt.id, prompt.name || prompt.id, "模块 id 重复");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byId.set(prompt.id, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledOrder = preset.promptOrder.filter((o) => o.enabled);
|
||||||
|
if (!enabledOrder.some((o) => o.promptId === CHAT_HISTORY_MARKER)) {
|
||||||
|
push("warn", null, "", "没有启用「对话历史」位置,真实聊天记录不会进入请求");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inOrder = new Set(preset.promptOrder.map((o) => o.promptId));
|
||||||
|
for (const prompt of preset.prompts) {
|
||||||
|
if (!inOrder.has(prompt.id)) {
|
||||||
|
push("warn", prompt.id, prompt.name, "未加入顺序,不会发送");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenOrder = new Set<string>();
|
||||||
|
const seenOrderIndex = new Set<number>();
|
||||||
|
const seenMarkers = new Set<string>();
|
||||||
|
for (const item of preset.promptOrder) {
|
||||||
|
if (seenOrder.has(item.promptId)) {
|
||||||
|
push("error", item.promptId, item.promptId, "顺序中重复引用了同一模块");
|
||||||
|
}
|
||||||
|
seenOrder.add(item.promptId);
|
||||||
|
if (seenOrderIndex.has(item.orderIndex)) {
|
||||||
|
push("warn", item.promptId, item.promptId, `顺序号重复:${item.orderIndex}`);
|
||||||
|
}
|
||||||
|
seenOrderIndex.add(item.orderIndex);
|
||||||
|
if (!byId.has(item.promptId)) {
|
||||||
|
push("error", item.promptId, item.promptId, "顺序中引用了不存在的模块");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of enabledOrder) {
|
||||||
|
const entry = byId.get(item.promptId);
|
||||||
|
if (!entry) continue;
|
||||||
|
const name = entry.name || entry.id;
|
||||||
|
|
||||||
|
if (entry.marker) {
|
||||||
|
if (seenMarkers.has(entry.id)) {
|
||||||
|
push("warn", entry.id, name, "位置在同一顺序中启用了多次,会重复填充");
|
||||||
|
}
|
||||||
|
seenMarkers.add(entry.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_ROLES.has(entry.role)) {
|
||||||
|
push("error", entry.id, name, `角色非法:${String(entry.role)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = entry.content ?? "";
|
||||||
|
if (!body.trim()) {
|
||||||
|
push("warn", entry.id, name, "模块正文为空,将被跳过");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.includes(FULLWIDTH_OPEN)) {
|
||||||
|
push(
|
||||||
|
"warn",
|
||||||
|
entry.id,
|
||||||
|
name,
|
||||||
|
"含全角 {{,运行时不会展开,请改为半角 {{",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unclosed = findUnclosedMacro(body);
|
||||||
|
if (unclosed) {
|
||||||
|
push("error", entry.id, name, `存在未闭合的 {{ 宏:${unclosed}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const usage of collectMacroUsages(body)) {
|
||||||
|
if (!usage.known) {
|
||||||
|
push("warn", entry.id, name, `未识别宏 ${usage.name},将原样发出`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
/**
|
||||||
|
* 导入诊断:把「这份预设在我们这儿能不能跑、哪一块会散」算出来给用户看。
|
||||||
|
* 纯文本扫描,不调用模型。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
collectMacroUsages,
|
||||||
|
isRandomMacroName,
|
||||||
|
} from "../directives/macros.js";
|
||||||
|
import type {
|
||||||
|
PresetDiagnostics,
|
||||||
|
PresetPackage,
|
||||||
|
PresetPromptEntry,
|
||||||
|
} from "../types/preset.js";
|
||||||
|
import { CHAT_HISTORY_MARKER } from "./markers.js";
|
||||||
|
|
||||||
|
/** 伪工具协议的指纹:命中任意一条即认为这条目在“假装有一个工具”。 */
|
||||||
|
const TOOL_PROTOCOL_MARKERS = [
|
||||||
|
"%TOOL_NAME%",
|
||||||
|
"FUNCTION_CALL_ONLY",
|
||||||
|
"INVOKE_NOW",
|
||||||
|
"display_stream",
|
||||||
|
"tool_call",
|
||||||
|
"function_call",
|
||||||
|
"output_channel",
|
||||||
|
"target_sink",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLACEHOLDER_RE = /%[A-Z][A-Z0-9_]*%|dispatch_stream_payload_[0-9a-f]+/g;
|
||||||
|
|
||||||
|
/** 标签:`<tag ...>` / `</tag>`;支持中文标签名(`<对话历史>`)。自闭合与 `->` `=>` 不算。 */
|
||||||
|
const TAG_RE =
|
||||||
|
/<(\/?)([A-Za-z\u4e00-\u9fff][\w.:\-\u4e00-\u9fff]*)([^<>]*?)(\/?)>/g;
|
||||||
|
|
||||||
|
export function orderedEnabledEntries(
|
||||||
|
preset: PresetPackage,
|
||||||
|
): PresetPromptEntry[] {
|
||||||
|
const byId = new Map(preset.prompts.map((p) => [p.id, p]));
|
||||||
|
const out: PresetPromptEntry[] = [];
|
||||||
|
for (const item of [...preset.promptOrder].sort(
|
||||||
|
(a, b) => a.orderIndex - b.orderIndex,
|
||||||
|
)) {
|
||||||
|
if (!item.enabled) continue;
|
||||||
|
const entry = byId.get(item.promptId);
|
||||||
|
if (!entry) continue;
|
||||||
|
out.push(entry);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryLabel(entry: PresetPromptEntry): string {
|
||||||
|
return entry.name.trim() || entry.sourceIdentifier || entry.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** marker 洞的正文要运行时才填,导入期看到的是空串,跳过它们。 */
|
||||||
|
function contentEntries(preset: PresetPackage): PresetPromptEntry[] {
|
||||||
|
return orderedEnabledEntries(preset).filter((e) => e.content.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMacroUsage(preset: PresetPackage): PresetDiagnostics["macros"] {
|
||||||
|
const byName = new Map<
|
||||||
|
string,
|
||||||
|
{ name: string; known: boolean; count: number; entries: string[] }
|
||||||
|
>();
|
||||||
|
for (const entry of contentEntries(preset)) {
|
||||||
|
for (const usage of collectMacroUsages(entry.content)) {
|
||||||
|
const existing = byName.get(usage.name);
|
||||||
|
if (existing) {
|
||||||
|
existing.count += usage.count;
|
||||||
|
existing.entries.push(entryLabel(entry));
|
||||||
|
} else {
|
||||||
|
byName.set(usage.name, {
|
||||||
|
name: usage.name,
|
||||||
|
known: usage.known,
|
||||||
|
count: usage.count,
|
||||||
|
entries: [entryLabel(entry)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byName.values()].sort((a, b) => b.count - a.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedToolBinding = {
|
||||||
|
entryId: string;
|
||||||
|
toolName: string;
|
||||||
|
params: string[];
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 从 `SPreset.ToolBindings[pick].form` 读:酒馆已经把工具定义结构化存了一份。 */
|
||||||
|
function parseToolBindings(raw: Record<string, unknown> | null): ParsedToolBinding[] {
|
||||||
|
const extensions = raw?.extensions as Record<string, unknown> | undefined;
|
||||||
|
const spreset = extensions?.SPreset as Record<string, unknown> | undefined;
|
||||||
|
const bindings = spreset?.ToolBindings;
|
||||||
|
if (!bindings || typeof bindings !== "object") return [];
|
||||||
|
|
||||||
|
const out: ParsedToolBinding[] = [];
|
||||||
|
for (const [entryId, value] of Object.entries(
|
||||||
|
bindings as Record<string, unknown>,
|
||||||
|
)) {
|
||||||
|
if (!value || typeof value !== "object") continue;
|
||||||
|
const binding = value as Record<string, unknown>;
|
||||||
|
const form = binding.form as Record<string, unknown> | undefined;
|
||||||
|
const toolName =
|
||||||
|
(typeof form?.name === "string" && form.name.trim()) ||
|
||||||
|
(typeof binding.resolvedName === "string" && binding.resolvedName.trim()) ||
|
||||||
|
"";
|
||||||
|
if (!toolName) continue;
|
||||||
|
|
||||||
|
const params: string[] = [];
|
||||||
|
const formParams = form?.parameters;
|
||||||
|
if (Array.isArray(formParams)) {
|
||||||
|
for (const p of formParams) {
|
||||||
|
if (p && typeof p === "object") {
|
||||||
|
const name = (p as Record<string, unknown>).name;
|
||||||
|
if (typeof name === "string" && name.trim()) params.push(name.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const description =
|
||||||
|
typeof form?.description === "string" ? form.description.trim() : "";
|
||||||
|
out.push({ entryId, toolName, params, description });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolProtocol(
|
||||||
|
preset: PresetPackage,
|
||||||
|
raw: Record<string, unknown> | null,
|
||||||
|
): PresetDiagnostics["toolProtocol"] {
|
||||||
|
const entries: string[] = [];
|
||||||
|
const placeholders = new Set<string>();
|
||||||
|
const entryLabels = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const entry of contentEntries(preset)) {
|
||||||
|
entryLabels.set(entry.id, entryLabel(entry));
|
||||||
|
const content = entry.content;
|
||||||
|
const hit = TOOL_PROTOCOL_MARKERS.some((m) => content.includes(m));
|
||||||
|
if (!hit) continue;
|
||||||
|
entries.push(entryLabel(entry));
|
||||||
|
for (const match of content.matchAll(PLACEHOLDER_RE)) {
|
||||||
|
placeholders.add(match[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const declarations: string[] = [];
|
||||||
|
const extensions = raw?.extensions as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const spreset = extensions?.SPreset as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
const bindings = parseToolBindings(raw).map((b) => {
|
||||||
|
const entry = entryLabels.get(b.entryId) ?? "(未启用的条目)";
|
||||||
|
// 绑定的条目本身就是在要求“调这个工具”,算伪工具协议条目
|
||||||
|
if (entryLabels.has(b.entryId) && !entries.includes(entry)) {
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
entry,
|
||||||
|
toolName: b.toolName,
|
||||||
|
params: b.params,
|
||||||
|
description: b.description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (spreset) {
|
||||||
|
if (bindings.length > 0) {
|
||||||
|
declarations.push(
|
||||||
|
`SPreset.ToolBindings(${bindings.map((b) => b.toolName).join("、")})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const preprocessing = spreset.OutputPreprocessing as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
if (preprocessing?.enabled === true) {
|
||||||
|
declarations.push("SPreset.OutputPreprocessing(改写工具调用)");
|
||||||
|
}
|
||||||
|
const formatter = preprocessing?.toolCallFormatter as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
if (formatter?.enabled === true) {
|
||||||
|
declarations.push("SPreset.OutputPreprocessing.toolCallFormatter");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw?.function_calling === true) {
|
||||||
|
declarations.push("function_calling = true");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
placeholders: [...placeholders],
|
||||||
|
declarations,
|
||||||
|
bindings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenTag = { tag: string; entry: string };
|
||||||
|
|
||||||
|
function buildTagPairing(preset: PresetPackage): PresetDiagnostics["tags"] {
|
||||||
|
const crossEntry: PresetDiagnostics["tags"]["crossEntry"] = [];
|
||||||
|
const orphanClose: PresetDiagnostics["tags"]["orphanClose"] = [];
|
||||||
|
const stack: OpenTag[] = [];
|
||||||
|
|
||||||
|
for (const entry of contentEntries(preset)) {
|
||||||
|
const label = entryLabel(entry);
|
||||||
|
for (const match of entry.content.matchAll(TAG_RE)) {
|
||||||
|
const closing = match[1] === "/";
|
||||||
|
const tag = match[2]!.toLowerCase();
|
||||||
|
const selfClosing = match[4] === "/";
|
||||||
|
if (selfClosing) continue;
|
||||||
|
|
||||||
|
if (!closing) {
|
||||||
|
stack.push({ tag, entry: label });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let found = -1;
|
||||||
|
for (let i = stack.length - 1; i >= 0; i--) {
|
||||||
|
if (stack[i]!.tag === tag) {
|
||||||
|
found = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found < 0) {
|
||||||
|
orphanClose.push({ tag, closeIn: label });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const open = stack.splice(found, 1)[0]!;
|
||||||
|
if (open.entry !== label) {
|
||||||
|
crossEntry.push({ tag, openIn: open.entry, closeIn: label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
crossEntry,
|
||||||
|
unclosed: stack.map((o) => ({ tag: o.tag, openIn: o.entry })),
|
||||||
|
orphanClose,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRandomMacros(
|
||||||
|
preset: PresetPackage,
|
||||||
|
): PresetDiagnostics["randomMacros"] {
|
||||||
|
const out: PresetDiagnostics["randomMacros"] = [];
|
||||||
|
const historyOrder = preset.promptOrder.find(
|
||||||
|
(o) => o.promptId === CHAT_HISTORY_MARKER,
|
||||||
|
)?.orderIndex;
|
||||||
|
|
||||||
|
for (const entry of contentEntries(preset)) {
|
||||||
|
const usages = collectMacroUsages(entry.content).filter((u) =>
|
||||||
|
isRandomMacroName(u.name),
|
||||||
|
);
|
||||||
|
if (usages.length === 0) continue;
|
||||||
|
const orderIndex = preset.promptOrder.find(
|
||||||
|
(o) => o.promptId === entry.id,
|
||||||
|
)?.orderIndex;
|
||||||
|
// 噪声排在 chatHistory 之后就不额外破坏前缀缓存。
|
||||||
|
const afterHistory =
|
||||||
|
historyOrder !== undefined &&
|
||||||
|
orderIndex !== undefined &&
|
||||||
|
orderIndex > historyOrder;
|
||||||
|
for (const usage of usages) {
|
||||||
|
out.push({
|
||||||
|
entry: entryLabel(entry),
|
||||||
|
macro: usage.name,
|
||||||
|
count: usage.count,
|
||||||
|
afterHistory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawRegex = {
|
||||||
|
id?: string;
|
||||||
|
scriptName?: string;
|
||||||
|
placement?: number[];
|
||||||
|
promptOnly?: boolean;
|
||||||
|
markdownOnly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function collectRawRegexes(raw: Record<string, unknown> | null): RawRegex[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
const out: RawRegex[] = [];
|
||||||
|
const extensions = raw.extensions as Record<string, unknown> | undefined;
|
||||||
|
const push = (value: unknown): void => {
|
||||||
|
if (!Array.isArray(value)) return;
|
||||||
|
for (const item of value) {
|
||||||
|
if (item && typeof item === "object") out.push(item as RawRegex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
push(raw.regex_scripts);
|
||||||
|
push(extensions?.regex_scripts);
|
||||||
|
const spreset = extensions?.SPreset as Record<string, unknown> | undefined;
|
||||||
|
const binding = spreset?.RegexBinding as Record<string, unknown> | undefined;
|
||||||
|
push(binding?.regexes);
|
||||||
|
|
||||||
|
// 去重:SPreset 常把同一批正则存两份
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return out.filter((r) => {
|
||||||
|
const key = r.id ?? `${r.scriptName ?? ""}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 脚本里能认出来的能力,以及本版本是否已有替代。 */
|
||||||
|
const SCRIPT_FEATURES: Array<{
|
||||||
|
label: string;
|
||||||
|
test: RegExp;
|
||||||
|
replaced: boolean;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
label: "注册酒馆前端工具",
|
||||||
|
test: /registerFunctionTool|ToolManager|SillyTavern\./,
|
||||||
|
replaced: true, // toolPolicy=adapt 能把工具协议接到已有工具上
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "注入合成工具定义",
|
||||||
|
test: /function_call|functionCall|tools\s*:/,
|
||||||
|
replaced: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "改写请求 / 拦截 fetch",
|
||||||
|
test: /fetch\s*\(|rewindow|XMLHttpRequest/,
|
||||||
|
replaced: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "流式解包 / 防截断",
|
||||||
|
test: /event-stream|SSE|ReadableStream|getReader\s*\(/,
|
||||||
|
replaced: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "监听酒馆事件",
|
||||||
|
test: /eventOn\s*\(|eventOnce\s*\(|tavern_events/,
|
||||||
|
replaced: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function detectScriptFeatures(content: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const feature of SCRIPT_FEATURES) {
|
||||||
|
if (feature.test.test(content)) out.push(feature.label);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOM_CALL_RE = /document\.\w+|querySelector(?:All)?|innerHTML|createElement|addEventListener/g;
|
||||||
|
|
||||||
|
/** 大量 DOM 操作的多半是界面面板(如 NVWA 的 1.8MB 面板),不该报“没替代的能力”。 */
|
||||||
|
function looksLikeUiPanel(content: string): boolean {
|
||||||
|
return (content.match(DOM_CALL_RE) ?? []).length > 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unportedFeatures(features: string[]): string[] {
|
||||||
|
return features.filter(
|
||||||
|
(label) => !SCRIPT_FEATURES.find((f) => f.label === label)?.replaced,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScripts(
|
||||||
|
raw: Record<string, unknown> | null,
|
||||||
|
): PresetDiagnostics["scripts"] {
|
||||||
|
const extensions = raw?.extensions as Record<string, unknown> | undefined;
|
||||||
|
const helper = extensions?.tavern_helper as
|
||||||
|
| { scripts?: unknown }
|
||||||
|
| undefined;
|
||||||
|
const scripts = helper?.scripts;
|
||||||
|
if (!Array.isArray(scripts)) return [];
|
||||||
|
const out: PresetDiagnostics["scripts"] = [];
|
||||||
|
for (const item of scripts) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
const s = item as Record<string, unknown>;
|
||||||
|
const content = typeof s.content === "string" ? s.content : "";
|
||||||
|
out.push({
|
||||||
|
name: typeof s.name === "string" ? s.name : "(未命名脚本)",
|
||||||
|
kind: typeof s.type === "string" ? s.type : "script",
|
||||||
|
bytes: Buffer.byteLength(content, "utf8"),
|
||||||
|
features: detectScriptFeatures(content),
|
||||||
|
isUiPanel: looksLikeUiPanel(content),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `injection_position=1` 的启用条目:现在会按 `injection_depth` 插进历史区。 */
|
||||||
|
function buildDeepInjection(
|
||||||
|
preset: PresetPackage,
|
||||||
|
raw: Record<string, unknown> | null,
|
||||||
|
): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
const prompts = raw.prompts;
|
||||||
|
if (!Array.isArray(prompts)) return [];
|
||||||
|
const sent = new Set(
|
||||||
|
contentEntries(preset).map((e) => e.sourceIdentifier || e.id),
|
||||||
|
);
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const item of prompts) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
const p = item as Record<string, unknown>;
|
||||||
|
if (p.injection_position !== 1) continue;
|
||||||
|
// prompts[].enabled 不作数(酒馆只看 prompt_order),不再拿它过滤。
|
||||||
|
const identifier = typeof p.identifier === "string" ? p.identifier : "";
|
||||||
|
if (identifier && !sent.has(identifier)) continue;
|
||||||
|
const name =
|
||||||
|
typeof p.name === "string" && p.name.trim()
|
||||||
|
? p.name.trim()
|
||||||
|
: identifier || "(未命名条目)";
|
||||||
|
out.push(name);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPresetDiagnostics(
|
||||||
|
preset: PresetPackage,
|
||||||
|
rawInput?: unknown,
|
||||||
|
): PresetDiagnostics {
|
||||||
|
const raw =
|
||||||
|
rawInput && typeof rawInput === "object"
|
||||||
|
? (rawInput as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const regexes = collectRawRegexes(raw).map((r) => ({
|
||||||
|
name: r.scriptName ?? r.id ?? "(未命名正则)",
|
||||||
|
placement: Array.isArray(r.placement) ? r.placement : [],
|
||||||
|
promptOnly: r.promptOnly === true,
|
||||||
|
markdownOnly: r.markdownOnly === true,
|
||||||
|
disabled: r.disabled === true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const scripts = buildScripts(raw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
macros: buildMacroUsage(preset),
|
||||||
|
toolProtocol: buildToolProtocol(preset, raw),
|
||||||
|
tags: buildTagPairing(preset),
|
||||||
|
randomMacros: buildRandomMacros(preset),
|
||||||
|
regexes,
|
||||||
|
scripts,
|
||||||
|
silentGaps: {
|
||||||
|
deepInjection: buildDeepInjection(preset, raw),
|
||||||
|
unportedScriptFeatures: scripts
|
||||||
|
.filter((s) => !s.isUiPanel)
|
||||||
|
.map((s) => ({ script: s.name, features: unportedFeatures(s.features) }))
|
||||||
|
.filter((s) => s.features.length > 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把诊断压成几句人能读的告警。 */
|
||||||
|
export function diagnosticsWarnings(d: PresetDiagnostics): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
|
||||||
|
const unknown = d.macros.filter((m) => !m.known);
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
out.push(
|
||||||
|
`未识别宏 ${unknown.length} 个将原样发出:${unknown
|
||||||
|
.map((m) => `${m.name}×${m.count}`)
|
||||||
|
.join("、")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.toolProtocol.entries.length > 0) {
|
||||||
|
const ph = d.toolProtocol.placeholders.length
|
||||||
|
? `;待映射占位符 ${d.toolProtocol.placeholders.join("、")}`
|
||||||
|
: "";
|
||||||
|
const bound = d.toolProtocol.bindings.length
|
||||||
|
? `;已声明工具 ${d.toolProtocol.bindings
|
||||||
|
.map((b) => `${b.toolName}(${b.params.join("/")})`)
|
||||||
|
.join("、")}`
|
||||||
|
: "";
|
||||||
|
out.push(
|
||||||
|
`伪工具协议 ${d.toolProtocol.entries.length} 条(${d.toolProtocol.entries
|
||||||
|
.slice(0, 6)
|
||||||
|
.join("、")})${ph}${bound}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.tags.crossEntry.length > 0) {
|
||||||
|
const sample = d.tags.crossEntry
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((t) => `<${t.tag}>`)
|
||||||
|
.join("、");
|
||||||
|
out.push(`跨条目标签配对 ${d.tags.crossEntry.length} 处(${sample}),合并消息会打散`);
|
||||||
|
}
|
||||||
|
if (d.tags.unclosed.length > 0) {
|
||||||
|
out.push(
|
||||||
|
`装配结束后仍未闭合的标签:${d.tags.unclosed
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((t) => `<${t.tag}>`)
|
||||||
|
.join("、")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomBefore = d.randomMacros.filter((r) => !r.afterHistory);
|
||||||
|
if (randomBefore.length > 0) {
|
||||||
|
const total = randomBefore.reduce((n, r) => n + r.count, 0);
|
||||||
|
out.push(
|
||||||
|
`随机宏 ${total} 处位于对话历史之前,会让之后的提示缓存每轮失效`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.regexes.length > 0) {
|
||||||
|
const displayOnly = d.regexes.filter(
|
||||||
|
(r) => r.markdownOnly && !r.disabled,
|
||||||
|
).length;
|
||||||
|
out.push(
|
||||||
|
`正则 ${d.regexes.length} 条已记录但不会执行(当前版本不实现 ST 正则引擎)${
|
||||||
|
displayOnly > 0
|
||||||
|
? `;其中 ${displayOnly} 条是显示层规则,正文里会直接露出它们本来要隐藏的标签`
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (d.scripts.length > 0) {
|
||||||
|
out.push(
|
||||||
|
`脚本 ${d.scripts.length} 个不会执行:${d.scripts
|
||||||
|
.map(
|
||||||
|
(s) =>
|
||||||
|
`${s.name}(${Math.round(s.bytes / 1024)}KB${s.isUiPanel ? ",界面面板" : ""})`,
|
||||||
|
)
|
||||||
|
.join("、")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.silentGaps.deepInjection.length > 0) {
|
||||||
|
out.push(
|
||||||
|
`深度注入 ${d.silentGaps.deepInjection.length} 条(${d.silentGaps.deepInjection
|
||||||
|
.slice(0, 4)
|
||||||
|
.join("、")}):按 injection_depth 插进历史区(depth 0 = 历史末尾,紧贴本轮输入之前)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of d.silentGaps.unportedScriptFeatures) {
|
||||||
|
out.push(
|
||||||
|
`脚本「${item.script}」需要${item.features.join("、")}的能力,本版本没有替代`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ function toView(
|
|||||||
orderEnabled: boolean,
|
orderEnabled: boolean,
|
||||||
): PresetEntryView {
|
): PresetEntryView {
|
||||||
const content = entry.content ?? "";
|
const content = entry.content ?? "";
|
||||||
const enabled = orderEnabled && entry.enabled;
|
// 跟酒馆一致:只看 prompt_order 的开关;prompts[].enabled 不参与。
|
||||||
|
const enabled = orderEnabled;
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
return {
|
return {
|
||||||
orderIndex,
|
orderIndex,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { ChatMessage } from "../llm/client.js";
|
||||||
|
|
||||||
|
/** 酒馆给示例对话消息打的 name(不是角色名)。 */
|
||||||
|
export const EXAMPLE_USER_NAME = "example_user";
|
||||||
|
export const EXAMPLE_ASSISTANT_NAME = "example_assistant";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把酒馆 `mes_example` 切成示例消息(对应 `openai.js` 的 `setOpenAIMessageExamples` +
|
||||||
|
* `parseExampleIntoIndividual`):
|
||||||
|
* - 按 `<START>` 分块;
|
||||||
|
* - 块内以 `用户名:` / `角色名:` 开头的行切换说话人,其余行算续行;
|
||||||
|
* - 每条消息 role 都是 system,name 是 example_user / example_assistant,
|
||||||
|
* 正文去掉 `名字:` 前缀。
|
||||||
|
* 传进来的 text 应该已经展开过宏(`{{user}}` / `{{char}}`),否则名字对不上。
|
||||||
|
*/
|
||||||
|
export function parseExampleDialogue(params: {
|
||||||
|
text: string;
|
||||||
|
userName: string;
|
||||||
|
charName: string;
|
||||||
|
}): ChatMessage[][] {
|
||||||
|
const text = (params.text ?? "").replace(/\r/g, "");
|
||||||
|
if (!text.trim()) return [];
|
||||||
|
const userName = params.userName.trim();
|
||||||
|
const charName = params.charName.trim();
|
||||||
|
const blocks = text
|
||||||
|
.split(/<START>/i)
|
||||||
|
.map((block) => block.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const source = blocks.length > 0 ? blocks : [text.trim()];
|
||||||
|
|
||||||
|
const out: ChatMessage[][] = [];
|
||||||
|
for (const block of source) {
|
||||||
|
const messages: ChatMessage[] = [];
|
||||||
|
let speaker: "user" | "assistant" | null = null;
|
||||||
|
let lines: string[] = [];
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
const content = lines.join("\n").trim();
|
||||||
|
if (speaker && content) {
|
||||||
|
messages.push({
|
||||||
|
role: "system",
|
||||||
|
content,
|
||||||
|
name: speaker === "user" ? EXAMPLE_USER_NAME : EXAMPLE_ASSISTANT_NAME,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lines = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of block.split("\n")) {
|
||||||
|
const line = rawLine.replace(/\r$/, "");
|
||||||
|
if (userName && line.startsWith(`${userName}:`)) {
|
||||||
|
flush();
|
||||||
|
speaker = "user";
|
||||||
|
lines.push(line.slice(userName.length + 1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (charName && line.startsWith(`${charName}:`)) {
|
||||||
|
flush();
|
||||||
|
speaker = "assistant";
|
||||||
|
lines.push(line.slice(charName.length + 1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
if (messages.length > 0) out.push(messages);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+128
-12
@@ -1,6 +1,9 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type {
|
import type {
|
||||||
GenerationParameters,
|
GenerationParameters,
|
||||||
|
MessageSquashMode,
|
||||||
|
PresetChatPrompts,
|
||||||
|
PresetFormats,
|
||||||
PresetImportReport,
|
PresetImportReport,
|
||||||
PresetPackage,
|
PresetPackage,
|
||||||
PresetPromptEntry,
|
PresetPromptEntry,
|
||||||
@@ -8,6 +11,7 @@ import type {
|
|||||||
UnsupportedPresetSection,
|
UnsupportedPresetSection,
|
||||||
} from "../types/preset.js";
|
} from "../types/preset.js";
|
||||||
import { applyExtendedMarkers } from "./markers.js";
|
import { applyExtendedMarkers } from "./markers.js";
|
||||||
|
import { buildPresetDiagnostics, diagnosticsWarnings } from "./diagnostics.js";
|
||||||
|
|
||||||
type StPrompt = {
|
type StPrompt = {
|
||||||
identifier?: string;
|
identifier?: string;
|
||||||
@@ -45,11 +49,67 @@ type SillyTavernPreset = {
|
|||||||
n?: number;
|
n?: number;
|
||||||
prompts?: StPrompt[];
|
prompts?: StPrompt[];
|
||||||
prompt_order?: StPromptOrderBlock[];
|
prompt_order?: StPromptOrderBlock[];
|
||||||
|
squash_system_messages?: unknown;
|
||||||
regex_scripts?: unknown;
|
regex_scripts?: unknown;
|
||||||
extensions?: unknown;
|
extensions?: unknown;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SQUASH_MODES: MessageSquashMode[] = [
|
||||||
|
"none",
|
||||||
|
"merge",
|
||||||
|
"semi",
|
||||||
|
"strict",
|
||||||
|
"single-user-turn",
|
||||||
|
"system",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `squash_system_messages`:旧版是 bool,新版是枚举/序号。
|
||||||
|
* 字段缺失或不可识别时返回 undefined —— 「跟预设走」就是原样发(none)。
|
||||||
|
*/
|
||||||
|
export function readSquashMode(value: unknown): MessageSquashMode | undefined {
|
||||||
|
if (typeof value === "boolean") return value ? "system" : "none";
|
||||||
|
if (typeof value === "number") return SQUASH_MODES[value];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const text = value.trim().toLowerCase();
|
||||||
|
const hit = SQUASH_MODES.find((m) => m === text);
|
||||||
|
if (hit) return hit;
|
||||||
|
if (text === "true") return "system";
|
||||||
|
if (text === "false") return "none";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** new_chat_prompt / new_example_chat_prompt 这几条:酒馆按位置插进历史与示例位。 */
|
||||||
|
function readChatPrompts(raw: SillyTavernPreset): PresetChatPrompts | undefined {
|
||||||
|
const pick = (key: string): string | undefined => {
|
||||||
|
const value = raw[key];
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
};
|
||||||
|
const prompts: PresetChatPrompts = {
|
||||||
|
newChat: pick("new_chat_prompt"),
|
||||||
|
newGroupChat: pick("new_group_chat_prompt"),
|
||||||
|
newExampleChat: pick("new_example_chat_prompt"),
|
||||||
|
continueNudge: pick("continue_nudge_prompt"),
|
||||||
|
};
|
||||||
|
return Object.values(prompts).some(Boolean) ? prompts : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `personality_format` / `scenario_format` / `wi_format`:酒馆用来包裹角色卡字段的模板。 */
|
||||||
|
function readFormats(raw: SillyTavernPreset): PresetFormats | undefined {
|
||||||
|
const pick = (key: string): string | undefined => {
|
||||||
|
const value = raw[key];
|
||||||
|
return typeof value === "string" && value.trim() ? value : undefined;
|
||||||
|
};
|
||||||
|
const formats: PresetFormats = {
|
||||||
|
personality: pick("personality_format"),
|
||||||
|
scenario: pick("scenario_format"),
|
||||||
|
worldInfo: pick("wi_format"),
|
||||||
|
};
|
||||||
|
return Object.values(formats).some(Boolean) ? formats : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function slugify(name: string): string {
|
function slugify(name: string): string {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -83,6 +143,35 @@ function readGeneration(raw: SillyTavernPreset): GenerationParameters {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 酒馆里「通用于所有角色」的那一套 prompt_order。 */
|
||||||
|
export const ST_SHARED_ORDER_CHARACTER_ID = 100001;
|
||||||
|
|
||||||
|
/** 一套装配顺序跑得起来的前提:这三个位置得在。 */
|
||||||
|
export const CORE_ORDER_IDENTIFIERS = ["main", "chatHistory", "jailbreak"] as const;
|
||||||
|
|
||||||
|
function scoreOrderBlock(
|
||||||
|
block: StPromptOrderBlock,
|
||||||
|
promptMap: Map<string, StPrompt>,
|
||||||
|
): number {
|
||||||
|
let score = 0;
|
||||||
|
for (const item of block.order ?? []) {
|
||||||
|
if (!item.enabled) continue;
|
||||||
|
const prompt = promptMap.get(item.identifier);
|
||||||
|
if (!prompt) continue;
|
||||||
|
// prompt_order 里没开的不进上下文,这里也不该给它加分。
|
||||||
|
// 酒馆不读 prompts[].enabled,别拿它给顺序打分。
|
||||||
|
if (prompt.content?.trim()) score += 3;
|
||||||
|
else if (prompt.marker) score += 1;
|
||||||
|
else score += 1;
|
||||||
|
}
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选一套 prompt_order。
|
||||||
|
* 酒馆存在多套(按 character_id 分);100001 是通用那套,优先用它,
|
||||||
|
* 只有它不存在时才按「启用内容最多」打分。
|
||||||
|
*/
|
||||||
function selectPromptOrder(
|
function selectPromptOrder(
|
||||||
raw: SillyTavernPreset,
|
raw: SillyTavernPreset,
|
||||||
promptMap: Map<string, StPrompt>,
|
promptMap: Map<string, StPrompt>,
|
||||||
@@ -91,18 +180,15 @@ function selectPromptOrder(
|
|||||||
if (orders.length === 0) return { order: [] };
|
if (orders.length === 0) return { order: [] };
|
||||||
if (orders.length === 1) return orders[0];
|
if (orders.length === 1) return orders[0];
|
||||||
|
|
||||||
|
const shared = orders.find(
|
||||||
|
(b) => b.character_id === ST_SHARED_ORDER_CHARACTER_ID,
|
||||||
|
);
|
||||||
|
if (shared) return shared;
|
||||||
|
|
||||||
let best = orders[0];
|
let best = orders[0];
|
||||||
let bestScore = -1;
|
let bestScore = -1;
|
||||||
for (const block of orders) {
|
for (const block of orders) {
|
||||||
let score = 0;
|
const score = scoreOrderBlock(block, promptMap);
|
||||||
for (const item of block.order ?? []) {
|
|
||||||
if (!item.enabled) continue;
|
|
||||||
const prompt = promptMap.get(item.identifier);
|
|
||||||
if (!prompt) continue;
|
|
||||||
if (prompt.content?.trim()) score += 3;
|
|
||||||
else if (prompt.marker) score += 0;
|
|
||||||
else score += 1;
|
|
||||||
}
|
|
||||||
if (score > bestScore) {
|
if (score > bestScore) {
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
best = block;
|
best = block;
|
||||||
@@ -111,6 +197,17 @@ function selectPromptOrder(
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 选中的那套顺序里缺了哪些核心位置。 */
|
||||||
|
export function missingCoreOrderIdentifiers(
|
||||||
|
block: StPromptOrderBlock,
|
||||||
|
): string[] {
|
||||||
|
const present = new Set<string>();
|
||||||
|
for (const item of block.order ?? []) {
|
||||||
|
if (item.enabled) present.add(item.identifier);
|
||||||
|
}
|
||||||
|
return CORE_ORDER_IDENTIFIERS.filter((id) => !present.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
function collectUnsupported(raw: SillyTavernPreset): UnsupportedPresetSection[] {
|
function collectUnsupported(raw: SillyTavernPreset): UnsupportedPresetSection[] {
|
||||||
const unsupported: UnsupportedPresetSection[] = [];
|
const unsupported: UnsupportedPresetSection[] = [];
|
||||||
if (raw.regex_scripts) {
|
if (raw.regex_scripts) {
|
||||||
@@ -148,7 +245,13 @@ export function importSillyTavernPreset(
|
|||||||
const selectedOrder = selectPromptOrder(raw, promptMap);
|
const selectedOrder = selectPromptOrder(raw, promptMap);
|
||||||
if ((raw.prompt_order?.length ?? 0) > 1) {
|
if ((raw.prompt_order?.length ?? 0) > 1) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`检测到 ${raw.prompt_order!.length} 套 prompt_order,已自动选用启用内容最多的一套 (character_id=${selectedOrder.character_id ?? "?"})`,
|
`检测到 ${raw.prompt_order!.length} 套 prompt_order,已选用 character_id=${selectedOrder.character_id ?? "?"} 的一套`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const missingCore = missingCoreOrderIdentifiers(selectedOrder);
|
||||||
|
if (missingCore.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`选用的 prompt_order 缺少 ${missingCore.join(" / ")},预设的框架可能不完整`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +260,7 @@ export function importSillyTavernPreset(
|
|||||||
return {
|
return {
|
||||||
id: sourceId,
|
id: sourceId,
|
||||||
name: p.name ?? sourceId,
|
name: p.name ?? sourceId,
|
||||||
enabled: p.enabled !== false,
|
enabled: true, // 酒馆装配只看 prompt_order.enabled;prompts[].enabled 是历史遗留字段
|
||||||
role: normalizeRole(p.role),
|
role: normalizeRole(p.role),
|
||||||
content: p.content ?? "",
|
content: p.content ?? "",
|
||||||
marker: Boolean(p.marker),
|
marker: Boolean(p.marker),
|
||||||
@@ -200,6 +303,9 @@ export function importSillyTavernPreset(
|
|||||||
? (rawInput as { name: string }).name
|
? (rawInput as { name: string }).name
|
||||||
: "导入的预设");
|
: "导入的预设");
|
||||||
|
|
||||||
|
const squashMode = readSquashMode(raw.squash_system_messages);
|
||||||
|
const chatPrompts = readChatPrompts(raw);
|
||||||
|
const formats = readFormats(raw);
|
||||||
const preset: PresetPackage = applyExtendedMarkers({
|
const preset: PresetPackage = applyExtendedMarkers({
|
||||||
id: options.id ?? `${slugify(presetName) || "preset"}-${randomUUID().slice(0, 8)}`,
|
id: options.id ?? `${slugify(presetName) || "preset"}-${randomUUID().slice(0, 8)}`,
|
||||||
name: presetName,
|
name: presetName,
|
||||||
@@ -207,6 +313,9 @@ export function importSillyTavernPreset(
|
|||||||
prompts,
|
prompts,
|
||||||
promptOrder,
|
promptOrder,
|
||||||
generation,
|
generation,
|
||||||
|
...(chatPrompts ? { chatPrompts } : {}),
|
||||||
|
...(formats ? { formats } : {}),
|
||||||
|
...(squashMode ? { messagePolicy: { squash: squashMode } } : {}),
|
||||||
unsupported: collectUnsupported(raw),
|
unsupported: collectUnsupported(raw),
|
||||||
importedAt: new Date().toISOString(),
|
importedAt: new Date().toISOString(),
|
||||||
raw: rawInput,
|
raw: rawInput,
|
||||||
@@ -219,9 +328,12 @@ export function importSillyTavernPreset(
|
|||||||
const framedEnabled = preset.promptOrder.filter((o) => {
|
const framedEnabled = preset.promptOrder.filter((o) => {
|
||||||
if (!o.enabled) return false;
|
if (!o.enabled) return false;
|
||||||
const entry = preset.prompts.find((p) => p.id === o.promptId);
|
const entry = preset.prompts.find((p) => p.id === o.promptId);
|
||||||
return entry?.enabled !== false;
|
return Boolean(entry);
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
|
const diagnostics = buildPresetDiagnostics(preset, rawInput);
|
||||||
|
warnings.push(...diagnosticsWarnings(diagnostics));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
preset,
|
preset,
|
||||||
promptCount: preset.prompts.length,
|
promptCount: preset.prompts.length,
|
||||||
@@ -230,5 +342,9 @@ export function importSillyTavernPreset(
|
|||||||
missingIdentifiers,
|
missingIdentifiers,
|
||||||
generationFields,
|
generationFields,
|
||||||
warnings,
|
warnings,
|
||||||
|
orderCharacterId: selectedOrder.character_id ?? null,
|
||||||
|
missingCoreIdentifiers: missingCore,
|
||||||
|
diagnostics,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { getPresetsDir } from "../config/user-data-dir.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每个预设在试跑视图里的「上次打开过啥」。
|
||||||
|
*
|
||||||
|
* 用例本身是全局的(`presets/tests/global`),但「这个预设上次用的是哪条用例」
|
||||||
|
* 得按预设记,否则切换预设时选择会跳。存盘而不是塞浏览器缓存:换浏览器、
|
||||||
|
* 重开页面都还是同一条。
|
||||||
|
*
|
||||||
|
* 文件:`<data>/presets/lab-state/<presetId>.json`
|
||||||
|
*/
|
||||||
|
export type PresetLabState = {
|
||||||
|
presetId: string;
|
||||||
|
/** 试跑视图的当前用例(`TestCase.id`) */
|
||||||
|
selectedCaseId: string | null;
|
||||||
|
/** 编辑视图右栏预览用的用例 */
|
||||||
|
previewCaseId: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAFE_ID = /^[\w\u4e00-\u9fff-]+$/;
|
||||||
|
|
||||||
|
function stateDir(): string {
|
||||||
|
return path.join(getPresetsDir(), "lab-state");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateFile(presetId: string): string {
|
||||||
|
if (!SAFE_ID.test(presetId)) throw new Error(`非法预设 id:${presetId}`);
|
||||||
|
return path.join(stateDir(), `${presetId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyState(presetId: string): PresetLabState {
|
||||||
|
return { presetId, selectedCaseId: null, previewCaseId: null, updatedAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPresetLabState(presetId: string): PresetLabState {
|
||||||
|
const file = stateFile(presetId);
|
||||||
|
if (!existsSync(file)) return emptyState(presetId);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<PresetLabState>;
|
||||||
|
return {
|
||||||
|
presetId,
|
||||||
|
selectedCaseId: typeof parsed.selectedCaseId === "string" ? parsed.selectedCaseId : null,
|
||||||
|
previewCaseId: typeof parsed.previewCaseId === "string" ? parsed.previewCaseId : null,
|
||||||
|
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return emptyState(presetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只写传进来的字段,没传的保持原样。 */
|
||||||
|
export function savePresetLabState(
|
||||||
|
presetId: string,
|
||||||
|
patch: { selectedCaseId?: string | null; previewCaseId?: string | null },
|
||||||
|
): PresetLabState {
|
||||||
|
const current = loadPresetLabState(presetId);
|
||||||
|
const next: PresetLabState = {
|
||||||
|
...current,
|
||||||
|
selectedCaseId:
|
||||||
|
patch.selectedCaseId === undefined ? current.selectedCaseId : patch.selectedCaseId,
|
||||||
|
previewCaseId:
|
||||||
|
patch.previewCaseId === undefined ? current.previewCaseId : patch.previewCaseId,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
mkdirSync(stateDir(), { recursive: true });
|
||||||
|
writeFileSync(stateFile(presetId), JSON.stringify(next, null, 2), "utf8");
|
||||||
|
return next;
|
||||||
|
}
|
||||||
+50
-48
@@ -6,24 +6,29 @@ import type {
|
|||||||
} from "../types/preset.js";
|
} from "../types/preset.js";
|
||||||
|
|
||||||
/** 酒馆角色卡 / WI marker:导入后保留位置;personaDescription 由当前用户角色填。 */
|
/** 酒馆角色卡 / WI marker:导入后保留位置;personaDescription 由当前用户角色填。 */
|
||||||
export const ST_UNFILLED_MARKERS = new Set([
|
/** 酒馆的 worldInfoBefore / worldInfoAfter 现在由 pack 填,集合留空(保留判定入口)。 */
|
||||||
"worldInfoBefore",
|
export const ST_UNFILLED_MARKERS = new Set<string>([]);
|
||||||
"charDescription",
|
|
||||||
"charPersonality",
|
export const WORLD_INFO_BEFORE_MARKER = "worldInfoBefore";
|
||||||
"scenario",
|
export const WORLD_INFO_AFTER_MARKER = "worldInfoAfter";
|
||||||
"worldInfoAfter",
|
|
||||||
"dialogueExamples",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const PERSONA_DESCRIPTION_MARKER = "personaDescription";
|
export const PERSONA_DESCRIPTION_MARKER = "personaDescription";
|
||||||
|
|
||||||
|
/** 酒馆示例对话洞:new_example_chat_prompt + mes_example 切出来的多段。 */
|
||||||
|
export const DIALOGUE_EXAMPLES_MARKER = "dialogueExamples";
|
||||||
|
|
||||||
|
/** 角色卡三块:由直聊槽位填,不再当"酒馆未填洞"跳过。 */
|
||||||
|
export const CHAR_DESCRIPTION_MARKER = "charDescription";
|
||||||
|
export const CHAR_PERSONALITY_MARKER = "charPersonality";
|
||||||
|
export const SCENARIO_MARKER = "scenario";
|
||||||
|
|
||||||
export const CHAT_HISTORY_MARKER = "chatHistory";
|
export const CHAT_HISTORY_MARKER = "chatHistory";
|
||||||
export const WORLD_BOOK_BEFORE = "worldBookBefore";
|
export const WORLD_BOOK_BEFORE = "worldBookBefore";
|
||||||
export const WORLD_BOOK_AFTER = "worldBookAfter";
|
export const WORLD_BOOK_AFTER = "worldBookAfter";
|
||||||
export const CURRENT_TURN = "currentTurn";
|
export const CURRENT_TURN = "currentTurn";
|
||||||
export const POST_TURN = "postTurn";
|
export const POST_TURN = "postTurn";
|
||||||
|
|
||||||
/** 导入时插入的自有洞(不含共用的 chatHistory)。 */
|
/** 运行时填洞的四个自有洞(不含共用的 chatHistory);缺失时自动补进顺序。 */
|
||||||
export const APP_INSERTED_MARKERS = [
|
export const APP_INSERTED_MARKERS = [
|
||||||
WORLD_BOOK_BEFORE,
|
WORLD_BOOK_BEFORE,
|
||||||
WORLD_BOOK_AFTER,
|
WORLD_BOOK_AFTER,
|
||||||
@@ -31,8 +36,6 @@ export const APP_INSERTED_MARKERS = [
|
|||||||
POST_TURN,
|
POST_TURN,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const APP_INSERTED_SET = new Set<string>(APP_INSERTED_MARKERS);
|
|
||||||
|
|
||||||
type MarkerDef = {
|
type MarkerDef = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -73,8 +76,10 @@ function reindex(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 以 chatHistory 为锚点插入四个自有 marker。
|
* 以 chatHistory 为锚点补齐四个自有 marker:**只补缺的,不动已有的**。
|
||||||
* 可重复调用:先摘掉已插入的自有洞,再按规则放回。
|
* 已经排进 `prompt_order` 的洞保留作者给的顺序——工作台左轨就是真实发送顺序;
|
||||||
|
* 缺的洞按酒馆的规范位置插进来:历史前 → 对话历史 → 历史后 → 本轮对话 → … → 本轮之后。
|
||||||
|
* 可重复调用:补齐之后再调一次是无操作(之前会把这四个洞重新搅回锚点,覆盖作者排的顺序)。
|
||||||
*/
|
*/
|
||||||
export function applyExtendedMarkers(preset: PresetPackage): PresetPackage {
|
export function applyExtendedMarkers(preset: PresetPackage): PresetPackage {
|
||||||
let prompts = [...preset.prompts];
|
let prompts = [...preset.prompts];
|
||||||
@@ -82,49 +87,46 @@ export function applyExtendedMarkers(preset: PresetPackage): PresetPackage {
|
|||||||
prompts = upsertMarkerEntry(prompts, def);
|
prompts = upsertMarkerEntry(prompts, def);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawOrder = [...preset.promptOrder]
|
const enabledOf = new Map(
|
||||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
preset.promptOrder.map((item) => [item.promptId, item.enabled]),
|
||||||
.filter((item) => !APP_INSERTED_SET.has(item.promptId));
|
);
|
||||||
|
const next = [...preset.promptOrder].sort((a, b) => a.orderIndex - b.orderIndex);
|
||||||
|
const has = (id: string) => next.some((item) => item.promptId === id);
|
||||||
|
const indexOf = (id: string) => next.findIndex((item) => item.promptId === id);
|
||||||
|
const insertAt = (index: number, id: string) => {
|
||||||
|
const item: PresetPromptOrderItem = {
|
||||||
|
promptId: id,
|
||||||
|
enabled: enabledOf.get(id) ?? true,
|
||||||
|
orderIndex: 0,
|
||||||
|
};
|
||||||
|
next.splice(Math.max(0, Math.min(index, next.length)), 0, item);
|
||||||
|
};
|
||||||
|
|
||||||
const histIndex = rawOrder.findIndex(
|
// 预设自带酒馆的 worldInfoBefore / worldInfoAfter(并启用)时,不插自有的世界书洞:
|
||||||
(item) => item.promptId === CHAT_HISTORY_MARKER,
|
// 内容直接填进预设那两条,位置上才对得上酒馆。
|
||||||
|
const hasStBefore = next.some(
|
||||||
|
(item) => item.promptId === WORLD_INFO_BEFORE_MARKER && item.enabled,
|
||||||
|
);
|
||||||
|
const hasStAfter = next.some(
|
||||||
|
(item) => item.promptId === WORLD_INFO_AFTER_MARKER && item.enabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
const inserted = (id: string): PresetPromptOrderItem => ({
|
if (!has(CHAT_HISTORY_MARKER)) insertAt(next.length, CHAT_HISTORY_MARKER);
|
||||||
promptId: id,
|
if (!has(WORLD_BOOK_BEFORE) && !hasStBefore) insertAt(indexOf(CHAT_HISTORY_MARKER), WORLD_BOOK_BEFORE);
|
||||||
enabled: true,
|
if (!has(WORLD_BOOK_AFTER) && !hasStAfter) insertAt(indexOf(CHAT_HISTORY_MARKER) + 1, WORLD_BOOK_AFTER);
|
||||||
orderIndex: 0,
|
if (!has(CURRENT_TURN)) {
|
||||||
});
|
const afterWorldBook = indexOf(WORLD_BOOK_AFTER);
|
||||||
|
insertAt(
|
||||||
let next: PresetPromptOrderItem[];
|
afterWorldBook >= 0 ? afterWorldBook + 1 : indexOf(CHAT_HISTORY_MARKER) + 1,
|
||||||
if (histIndex < 0) {
|
CURRENT_TURN,
|
||||||
next = reindex([
|
);
|
||||||
...rawOrder,
|
|
||||||
inserted(WORLD_BOOK_BEFORE),
|
|
||||||
inserted(CHAT_HISTORY_MARKER),
|
|
||||||
inserted(WORLD_BOOK_AFTER),
|
|
||||||
inserted(CURRENT_TURN),
|
|
||||||
inserted(POST_TURN),
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
const before = rawOrder.slice(0, histIndex);
|
|
||||||
const history = rawOrder[histIndex]!;
|
|
||||||
const after = rawOrder.slice(histIndex + 1);
|
|
||||||
next = reindex([
|
|
||||||
...before,
|
|
||||||
inserted(WORLD_BOOK_BEFORE),
|
|
||||||
history,
|
|
||||||
inserted(WORLD_BOOK_AFTER),
|
|
||||||
inserted(CURRENT_TURN),
|
|
||||||
...after,
|
|
||||||
inserted(POST_TURN),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
if (!has(POST_TURN)) insertAt(next.length, POST_TURN);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...preset,
|
...preset,
|
||||||
prompts,
|
prompts,
|
||||||
promptOrder: next,
|
promptOrder: reindex(next),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { PresetPackage } from "../types/preset.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作台新建一份原生预设。
|
||||||
|
* 骨架给一条可编辑规则 + 完整的扩展 marker 顺序,方便直接在页面上
|
||||||
|
* 调整位置;applyExtendedMarkers 调用时仍是幂等的。
|
||||||
|
*/
|
||||||
|
export function createNativePreset(input: {
|
||||||
|
id?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
} = {}): PresetPackage {
|
||||||
|
const id = input.id?.trim() || `preset-${randomUUID().slice(0, 8)}`;
|
||||||
|
const name = input.name?.trim() || "未命名预设";
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
source: "native",
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
id: "system-rules",
|
||||||
|
name: "写作规则",
|
||||||
|
enabled: true,
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
"你是中文小说叙事者。按用户输入续写故事,保持人物、设定与前文一致;只输出正文,不解释写作过程。",
|
||||||
|
marker: false,
|
||||||
|
sourceIdentifier: "system-rules",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "charDescription",
|
||||||
|
name: "角色描述",
|
||||||
|
enabled: true,
|
||||||
|
role: "system",
|
||||||
|
content: "",
|
||||||
|
marker: true,
|
||||||
|
sourceIdentifier: "charDescription",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "personaDescription",
|
||||||
|
name: "用户人设",
|
||||||
|
enabled: true,
|
||||||
|
role: "system",
|
||||||
|
content: "",
|
||||||
|
marker: true,
|
||||||
|
sourceIdentifier: "personaDescription",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "worldBookBefore",
|
||||||
|
name: "世界书(历史前)",
|
||||||
|
enabled: true,
|
||||||
|
role: "system",
|
||||||
|
content: "",
|
||||||
|
marker: true,
|
||||||
|
sourceIdentifier: "worldBookBefore",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chatHistory",
|
||||||
|
name: "对话历史",
|
||||||
|
enabled: true,
|
||||||
|
role: "system",
|
||||||
|
content: "",
|
||||||
|
marker: true,
|
||||||
|
sourceIdentifier: "chatHistory",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "worldBookAfter",
|
||||||
|
name: "世界书(历史后)",
|
||||||
|
enabled: true,
|
||||||
|
role: "system",
|
||||||
|
content: "",
|
||||||
|
marker: true,
|
||||||
|
sourceIdentifier: "worldBookAfter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "currentTurn",
|
||||||
|
name: "本轮对话",
|
||||||
|
enabled: true,
|
||||||
|
role: "user",
|
||||||
|
content: "",
|
||||||
|
marker: true,
|
||||||
|
sourceIdentifier: "currentTurn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "postTurn",
|
||||||
|
name: "本轮之后",
|
||||||
|
enabled: true,
|
||||||
|
role: "system",
|
||||||
|
content: "",
|
||||||
|
marker: true,
|
||||||
|
sourceIdentifier: "postTurn",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
promptOrder: [
|
||||||
|
{ promptId: "system-rules", enabled: true, orderIndex: 0 },
|
||||||
|
{ promptId: "charDescription", enabled: true, orderIndex: 1 },
|
||||||
|
{ promptId: "personaDescription", enabled: true, orderIndex: 2 },
|
||||||
|
{ promptId: "worldBookBefore", enabled: true, orderIndex: 3 },
|
||||||
|
{ promptId: "chatHistory", enabled: true, orderIndex: 4 },
|
||||||
|
{ promptId: "worldBookAfter", enabled: true, orderIndex: 5 },
|
||||||
|
{ promptId: "currentTurn", enabled: true, orderIndex: 6 },
|
||||||
|
{ promptId: "postTurn", enabled: true, orderIndex: 7 },
|
||||||
|
],
|
||||||
|
generation: {},
|
||||||
|
unsupported: [],
|
||||||
|
importedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
+236
-36
@@ -1,43 +1,91 @@
|
|||||||
import { expandIdentityDirectives } from "../directives/expand.js";
|
import type { ChatMessage } from "../llm/client.js";
|
||||||
import type { ChatMessage } from "../llm/client.js";
|
import {
|
||||||
|
markSquashExempt,
|
||||||
|
mergeAdjacentSameRole,
|
||||||
|
squashMessages,
|
||||||
|
} from "../llm/message-squash.js";
|
||||||
|
import type { MessageSquashMode } from "../types/preset.js";
|
||||||
|
import { expandPresetDirectives } from "../directives/expand.js";
|
||||||
|
import { createMacroVars } from "../directives/macros.js";
|
||||||
import {
|
import {
|
||||||
getActivePersona,
|
getActivePersona,
|
||||||
type PersonaDirective,
|
type PersonaDirective,
|
||||||
} from "../persona/store.js";
|
} from "../persona/store.js";
|
||||||
import type { PresetPackage } from "../types/preset.js";
|
import type { PresetPackage } from "../types/preset.js";
|
||||||
import { assemblePresetMessages } from "./assembler.js";
|
import { assemblePresetMessages, splitPresetHistory } from "./assembler.js";
|
||||||
import {
|
import {
|
||||||
applyExtendedMarkers,
|
applyExtendedMarkers,
|
||||||
|
CHAR_DESCRIPTION_MARKER,
|
||||||
|
CHAR_PERSONALITY_MARKER,
|
||||||
|
CHAT_HISTORY_MARKER,
|
||||||
|
DIALOGUE_EXAMPLES_MARKER,
|
||||||
PERSONA_DESCRIPTION_MARKER,
|
PERSONA_DESCRIPTION_MARKER,
|
||||||
|
SCENARIO_MARKER,
|
||||||
} from "./markers.js";
|
} from "./markers.js";
|
||||||
|
import { parseExampleDialogue } from "./examples.js";
|
||||||
|
import { mergePseudoHistory, readPseudoHistory } from "./pseudo-history.js";
|
||||||
|
import {
|
||||||
|
applyRegexScripts,
|
||||||
|
readRegexScripts,
|
||||||
|
REGEX_PLACEMENT,
|
||||||
|
type PresetRegexScript,
|
||||||
|
} from "./regex-scripts.js";
|
||||||
import {
|
import {
|
||||||
resolveWorldInfoMarker,
|
resolveWorldInfoMarker,
|
||||||
type WorldInfoEntry,
|
type WorldInfoEntry,
|
||||||
type WorldInfoPack,
|
type WorldInfoPack,
|
||||||
} from "./world-info-pack.js";
|
} from "./world-info-pack.js";
|
||||||
|
|
||||||
|
/** `{{char}}` 没给值时的兜底;`{{user}}` 兜底与酒馆默认用户名一致。 */
|
||||||
|
const PRESET_CHAR_FALLBACK = "作品";
|
||||||
|
const DEFAULT_USER_NAME = "User";
|
||||||
|
|
||||||
|
/** 酒馆预设里系统提示那一条的 identifier。 */
|
||||||
|
const MAIN_PROMPT_ID = "main";
|
||||||
const TASK_SYSTEM_ENTRY_ID = "task-system";
|
const TASK_SYSTEM_ENTRY_ID = "task-system";
|
||||||
const TASK_SYSTEM_ENTRY_NAME = "任务契约";
|
const TASK_SYSTEM_ENTRY_NAME = "任务契约";
|
||||||
|
|
||||||
function withTaskSystem(
|
/**
|
||||||
|
* 任务契约(上下文壳里的系统提示)不再单独发一条消息,按酒馆的条目顺序找地方放:
|
||||||
|
* 1. 预设启用了 `main`(酒馆的系统提示条目)且已有正文 → 追加到它末尾;
|
||||||
|
* 2. 否则并进世界书(预设带 `worldInfoBefore` 时正好落在那一 条里);
|
||||||
|
* 3. 都没有才退回自有世界书洞(老行为)。
|
||||||
|
*/
|
||||||
|
function attachTaskSystem(
|
||||||
|
preset: PresetPackage,
|
||||||
pack: WorldInfoPack,
|
pack: WorldInfoPack,
|
||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
): WorldInfoPack {
|
): { preset: PresetPackage; pack: WorldInfoPack } {
|
||||||
const text = systemPrompt.trim();
|
const text = systemPrompt.trim();
|
||||||
if (!text) return pack;
|
if (!text) return { preset, pack };
|
||||||
|
|
||||||
|
const mainEnabled = preset.promptOrder.some(
|
||||||
|
(item) => item.promptId === MAIN_PROMPT_ID && item.enabled,
|
||||||
|
);
|
||||||
|
const mainEntry = preset.prompts.find((p) => p.id === MAIN_PROMPT_ID);
|
||||||
|
if (mainEnabled && mainEntry && mainEntry.content.trim()) {
|
||||||
|
const prompts = preset.prompts.map((p) =>
|
||||||
|
p.id === MAIN_PROMPT_ID
|
||||||
|
? { ...p, content: `${p.content.trim()}\n\n${text}` }
|
||||||
|
: p,
|
||||||
|
);
|
||||||
|
return { preset: { ...preset, prompts }, pack };
|
||||||
|
}
|
||||||
|
|
||||||
const entry: WorldInfoEntry = {
|
const entry: WorldInfoEntry = {
|
||||||
id: TASK_SYSTEM_ENTRY_ID,
|
id: TASK_SYSTEM_ENTRY_ID,
|
||||||
name: TASK_SYSTEM_ENTRY_NAME,
|
name: TASK_SYSTEM_ENTRY_NAME,
|
||||||
content: text,
|
content: text,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...pack,
|
preset,
|
||||||
worldBookBefore: [entry, ...pack.worldBookBefore],
|
pack: { ...pack, worldBookBefore: [entry, ...pack.worldBookBefore] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 严格按扩展 prompt_order 填洞。任务契约进 worldBookBefore,不在预设外另挂 system。
|
* 严格按扩展 prompt_order 填洞。任务契约进 worldBookBefore,不在预设外另挂 system。
|
||||||
|
* 「对话.历史」进 chatHistory 洞时展开成真多轮 message,不拍平成一段文本。
|
||||||
*/
|
*/
|
||||||
export function assemblePlayWorkerMessages(params: {
|
export function assemblePlayWorkerMessages(params: {
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
@@ -45,29 +93,159 @@ export function assemblePlayWorkerMessages(params: {
|
|||||||
pack: WorldInfoPack;
|
pack: WorldInfoPack;
|
||||||
/** 缺省用当前选用(试跑 / 未绑会话)。创作期应传入创作默认人设。 */
|
/** 缺省用当前选用(试跑 / 未绑会话)。创作期应传入创作默认人设。 */
|
||||||
persona?: PersonaDirective | null;
|
persona?: PersonaDirective | null;
|
||||||
|
/** `{{char}}` 的取值(书名 / 主角名);缺省回落「作品」。 */
|
||||||
|
charName?: string | null;
|
||||||
|
/** 装配时排除的条目(`toolPolicy = app` 时用来剔掉伪工具协议)。 */
|
||||||
|
excludeEntryIds?: ReadonlySet<string>;
|
||||||
}): ChatMessage[] {
|
}): ChatMessage[] {
|
||||||
const framed = applyExtendedMarkers(params.preset);
|
const attached = attachTaskSystem(
|
||||||
const pack = withTaskSystem(params.pack, params.systemPrompt);
|
params.preset,
|
||||||
|
params.pack,
|
||||||
|
params.systemPrompt,
|
||||||
|
);
|
||||||
|
const framed = applyExtendedMarkers(attached.preset);
|
||||||
|
const pack = attached.pack;
|
||||||
const persona =
|
const persona =
|
||||||
params.persona !== undefined ? params.persona : getActivePersona();
|
params.persona !== undefined ? params.persona : getActivePersona();
|
||||||
const dirCtx = persona
|
// 一份变量表跨本次装配全部条目:`{{setvar}}` 写在前面,`{{getvar}}` 在后面就能读到。
|
||||||
? { persona: { name: persona.name, description: persona.description } }
|
const vars = createMacroVars();
|
||||||
: {};
|
const dirCtx = {
|
||||||
|
persona: persona
|
||||||
|
? { name: persona.name, description: persona.description }
|
||||||
|
: null,
|
||||||
|
charName: params.charName ?? null,
|
||||||
|
lastUserMessage: pack.turn,
|
||||||
|
vars,
|
||||||
|
};
|
||||||
|
const charNameForFill = params.charName?.trim() || PRESET_CHAR_FALLBACK;
|
||||||
|
// 示例对话的宏要在切段前展开:`{{user}}:` 这种前缀得先变成真名字才认得出来。
|
||||||
|
const exampleUserName = persona?.name?.trim() || DEFAULT_USER_NAME;
|
||||||
|
const exampleCtx = {
|
||||||
|
...dirCtx,
|
||||||
|
charName: charNameForFill,
|
||||||
|
persona: { name: exampleUserName, description: persona?.description ?? "" },
|
||||||
|
};
|
||||||
|
|
||||||
return assemblePresetMessages(
|
return assemblePresetMessages(
|
||||||
framed,
|
framed,
|
||||||
(id) => {
|
(id) => {
|
||||||
|
// 酒馆:人设描述为空就不发这条洞(不塞「姓名:X」占位)。
|
||||||
if (id === PERSONA_DESCRIPTION_MARKER) {
|
if (id === PERSONA_DESCRIPTION_MARKER) {
|
||||||
const desc = persona?.description?.trim();
|
return persona?.description?.trim() || null;
|
||||||
if (desc) return desc;
|
}
|
||||||
const name = persona?.name?.trim();
|
if (id === CHAT_HISTORY_MARKER) {
|
||||||
return name ? `姓名:${name}` : null;
|
// 酒馆把 new_chat_prompt 放在历史最前面;没有历史时就是它单独一条。
|
||||||
|
const lead = params.preset.chatPrompts?.newChat?.trim();
|
||||||
|
const head: ChatMessage[] = lead
|
||||||
|
? [
|
||||||
|
markSquashExempt({
|
||||||
|
role: "system",
|
||||||
|
content: expandPresetDirectives(lead, dirCtx).text,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
const scripts = readRegexScripts(params.preset.raw);
|
||||||
|
// 假历史:作者手写的前情 → 真多轮,排在真实历史之前(一样要展开宏)。
|
||||||
|
const pseudo: ChatMessage[] = readPseudoHistory(params.preset).map((turn) => ({
|
||||||
|
role: turn.role,
|
||||||
|
content: expandPresetDirectives(turn.content, dirCtx).text,
|
||||||
|
}));
|
||||||
|
const history = regexHistoryMessages(
|
||||||
|
mergePseudoHistory(pseudo, pack.history),
|
||||||
|
scripts,
|
||||||
|
(text) => expandPresetDirectives(text, dirCtx).text,
|
||||||
|
);
|
||||||
|
const all = head.length > 0 ? [...head, ...history] : history;
|
||||||
|
return all.length > 0 ? all : null;
|
||||||
|
}
|
||||||
|
// 角色卡三块:性格与场景按预设的 format 包一层(酒馆的 personality_format / scenario_format)。
|
||||||
|
if (id === CHAR_DESCRIPTION_MARKER) {
|
||||||
|
return pack.card?.description?.trim() || null;
|
||||||
|
}
|
||||||
|
if (id === CHAR_PERSONALITY_MARKER) {
|
||||||
|
const text = pack.card?.personality?.trim();
|
||||||
|
if (!text) return null;
|
||||||
|
return wrapWithFormat(params.preset.formats?.personality, "personality", text);
|
||||||
|
}
|
||||||
|
if (id === SCENARIO_MARKER) {
|
||||||
|
const text = pack.card?.scenario?.trim();
|
||||||
|
if (!text) return null;
|
||||||
|
return wrapWithFormat(params.preset.formats?.scenario, "scenario", text);
|
||||||
|
}
|
||||||
|
if (id === DIALOGUE_EXAMPLES_MARKER) {
|
||||||
|
const rawExamples = (pack.exampleDialogue ?? "").trim();
|
||||||
|
if (!rawExamples) return null;
|
||||||
|
const expanded = expandPresetDirectives(rawExamples, exampleCtx).text;
|
||||||
|
const blocks = parseExampleDialogue({
|
||||||
|
text: expanded,
|
||||||
|
userName: exampleUserName,
|
||||||
|
charName: charNameForFill,
|
||||||
|
});
|
||||||
|
if (blocks.length === 0) return null;
|
||||||
|
// 酒馆每块示例前面都插一条 new_example_chat_prompt。
|
||||||
|
const lead = params.preset.chatPrompts?.newExampleChat?.trim();
|
||||||
|
const leadText = lead ? expandPresetDirectives(lead, dirCtx).text.trim() : "";
|
||||||
|
const out: ChatMessage[] = [];
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (leadText) {
|
||||||
|
out.push(markSquashExempt({ role: "system", content: leadText }));
|
||||||
|
}
|
||||||
|
out.push(...block);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
return resolveWorldInfoMarker(pack, id);
|
return resolveWorldInfoMarker(pack, id);
|
||||||
},
|
},
|
||||||
(content) => expandIdentityDirectives(content, dirCtx).text,
|
// marker 填进来的是我们的正文,也要过一遍宏(世界书条目里可能有 {{user}})。
|
||||||
|
(content) => expandPresetDirectives(content, dirCtx).text,
|
||||||
|
params.excludeEntryIds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 酒馆的"发送前正则"只作用于**对话历史**:user→placement 1、assistant→placement 2、
|
||||||
|
* 思维链→6,depth 是离历史末尾的距离。这里只处理 user / assistant。
|
||||||
|
*/
|
||||||
|
function regexHistoryMessages(
|
||||||
|
history: ChatMessage[],
|
||||||
|
scripts: PresetRegexScript[],
|
||||||
|
expand: (text: string) => string,
|
||||||
|
): ChatMessage[] {
|
||||||
|
if (scripts.length === 0 || history.length === 0) return history;
|
||||||
|
return history.map((message, index) => {
|
||||||
|
const text = typeof message.content === "string" ? message.content : "";
|
||||||
|
if (!text) return message;
|
||||||
|
const placement =
|
||||||
|
message.role === "user"
|
||||||
|
? REGEX_PLACEMENT.USER_INPUT
|
||||||
|
: message.role === "assistant"
|
||||||
|
? REGEX_PLACEMENT.AI_OUTPUT
|
||||||
|
: null;
|
||||||
|
if (placement === null) return message;
|
||||||
|
const depth = history.length - index - 1;
|
||||||
|
const next = applyRegexScripts(text, scripts, {
|
||||||
|
placement,
|
||||||
|
direction: "prompt",
|
||||||
|
depth,
|
||||||
|
expand,
|
||||||
|
});
|
||||||
|
return next === text ? message : { ...message, content: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 酒馆的 `personality_format` 之类:模板里用 `{{personality}}` 占位,没有占位就原样发。 */
|
||||||
|
function wrapWithFormat(
|
||||||
|
format: string | undefined,
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
): string {
|
||||||
|
const text = format?.trim();
|
||||||
|
if (!text) return value;
|
||||||
|
const token = `{{${key}}}`;
|
||||||
|
if (!text.includes(token)) return value;
|
||||||
|
return text.split(token).join(value);
|
||||||
|
}
|
||||||
|
|
||||||
function isToolLoopBoundary(m: ChatMessage): boolean {
|
function isToolLoopBoundary(m: ChatMessage): boolean {
|
||||||
if (m.role === "tool") return true;
|
if (m.role === "tool") return true;
|
||||||
return Boolean(
|
return Boolean(
|
||||||
@@ -75,29 +253,17 @@ function isToolLoopBoundary(m: ChatMessage): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHistoryMessages(msgs: ChatMessage[]): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
for (const m of msgs) {
|
|
||||||
if (m.role === "user" && m.content.trim()) {
|
|
||||||
parts.push(`【用户】\n${m.content.trim()}`);
|
|
||||||
} else if (m.role === "assistant") {
|
|
||||||
const text = (m.content ?? "").trim();
|
|
||||||
if (text) parts.push(`【助手】\n${text}`);
|
|
||||||
} else if (m.role === "system" && m.content.trim()) {
|
|
||||||
parts.push(`【系统】\n${m.content.trim()}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parts.join("\n\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 任务 messages → 只填四袋 + chatHistory;发出序列的首尾由预设 prompt_order 决定。
|
* 任务 messages → 只填四袋 + chatHistory;发出序列的首尾由预设 prompt_order 决定。
|
||||||
|
* 末条之前的正文按原角色进历史区(真多轮),末条 user 进 currentTurn。
|
||||||
* tool 循环尾接在夹心后。
|
* tool 循环尾接在夹心后。
|
||||||
*/
|
*/
|
||||||
export function frameMessagesWithPreset(
|
export function frameMessagesWithPreset(
|
||||||
preset: PresetPackage,
|
preset: PresetPackage,
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
persona?: PersonaDirective | null,
|
persona?: PersonaDirective | null,
|
||||||
|
charName?: string | null,
|
||||||
|
excludeEntryIds?: ReadonlySet<string>,
|
||||||
): ChatMessage[] {
|
): ChatMessage[] {
|
||||||
let splitAt = messages.length;
|
let splitAt = messages.length;
|
||||||
for (let i = 0; i < messages.length; i++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
@@ -117,14 +283,14 @@ export function frameMessagesWithPreset(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let turn = "";
|
let turn = "";
|
||||||
let history = "";
|
let history: ChatMessage[] = [];
|
||||||
if (body.length > 0) {
|
if (body.length > 0) {
|
||||||
const last = body[body.length - 1]!;
|
const last = body[body.length - 1]!;
|
||||||
if (last.role === "user") {
|
if (last.role === "user") {
|
||||||
turn = last.content.trim();
|
turn = last.content.trim();
|
||||||
history = formatHistoryMessages(body.slice(0, -1));
|
history = body.slice(0, -1);
|
||||||
} else {
|
} else {
|
||||||
history = formatHistoryMessages(body);
|
history = body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +305,41 @@ export function frameMessagesWithPreset(
|
|||||||
postTurn: [],
|
postTurn: [],
|
||||||
},
|
},
|
||||||
persona,
|
persona,
|
||||||
|
charName,
|
||||||
|
excludeEntryIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
return continuation.length > 0 ? [...framed, ...continuation] : framed;
|
return continuation.length > 0 ? [...framed, ...continuation] : framed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 历史区切片:消息整形只作用于前后两段,历史轮次原样保留。 */
|
||||||
|
export function splitFramedHistory(messages: ChatMessage[]) {
|
||||||
|
return splitPresetHistory(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息整形(squash)入口。
|
||||||
|
*
|
||||||
|
* 历史区(真多轮)也参与整形,但只用“合并相邻同角色”这一档:
|
||||||
|
* - 正常情况下历史本来就是 user/assistant 交替,不动;
|
||||||
|
* - 用户连发两条(上一轮助手没出正文)时合并成一条,不破结构;
|
||||||
|
* - `injection_depth` 插进历史的 system 条目也不会被折掉。
|
||||||
|
* 史前 / 史后两段才用完整模式(`semi` / `strict` 会把 system 并进相邻消息);
|
||||||
|
* `single-user-turn` 不能堆历史,只能堆前后两段;`system` 整条序列只做 system 合并。
|
||||||
|
*/
|
||||||
|
export function shapeFramedMessages(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
mode: MessageSquashMode,
|
||||||
|
): ChatMessage[] {
|
||||||
|
if (mode === "none") return messages;
|
||||||
|
const { head, history, tail } = splitPresetHistory(messages);
|
||||||
|
if (history.length === 0) return squashMessages(messages, mode);
|
||||||
|
if (mode === "merge" || mode === "system") return squashMessages(messages, mode);
|
||||||
|
const keepHistory =
|
||||||
|
mode === "single-user-turn" ? history : mergeAdjacentSameRole(history);
|
||||||
|
return [
|
||||||
|
...squashMessages(head, mode),
|
||||||
|
...keepHistory,
|
||||||
|
...squashMessages(tail, mode),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
+62
-8
@@ -1,6 +1,9 @@
|
|||||||
import type { ChatMessage } from "../llm/client.js";
|
import type { ChatMessage } from "../llm/client.js";
|
||||||
import type { PresetPackage } from "../types/preset.js";
|
import { parseHistoryMessages } from "../skills/dialogue-history.js";
|
||||||
import { assemblePlayWorkerMessages } from "./play-frame.js";
|
import type { MessageSquashMode, PresetPackage } from "../types/preset.js";
|
||||||
|
import { DEFAULT_PRESET_SQUASH } from "../types/preset.js";
|
||||||
|
import { assemblePlayWorkerMessages, shapeFramedMessages } from "./play-frame.js";
|
||||||
|
import { resolveToolPlan } from "./tool-policy.js";
|
||||||
import type { WorldInfoPack } from "./world-info-pack.js";
|
import type { WorldInfoPack } from "./world-info-pack.js";
|
||||||
|
|
||||||
/** 试跑窗口用的短契约,不是 play worker SKILL。 */
|
/** 试跑窗口用的短契约,不是 play worker SKILL。 */
|
||||||
@@ -34,6 +37,21 @@ export type PresetProbeResult = {
|
|||||||
completed: boolean;
|
completed: boolean;
|
||||||
skipReason?: string;
|
skipReason?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/** 本次实际采用的消息整形模式 */
|
||||||
|
squashMode?: MessageSquashMode;
|
||||||
|
/** 预设要求的工具协议(用于确认破限是否真能接上) */
|
||||||
|
toolPlan?: {
|
||||||
|
present: boolean;
|
||||||
|
targetName: string | null;
|
||||||
|
payloadFields: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PresetProbeOptions = {
|
||||||
|
/** `{{char}}` 的取值 */
|
||||||
|
charName?: string | null;
|
||||||
|
/** 消息整形模式;缺省用预设自己的 */
|
||||||
|
squash?: MessageSquashMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
function optionalEntry(
|
function optionalEntry(
|
||||||
@@ -49,13 +67,19 @@ export function worldInfoPackFromProbeInput(
|
|||||||
input: PresetProbeInput,
|
input: PresetProbeInput,
|
||||||
): WorldInfoPack {
|
): WorldInfoPack {
|
||||||
const loreBefore = input.loreBefore ?? DEFAULT_PROBE_CONTEXT.loreBefore;
|
const loreBefore = input.loreBefore ?? DEFAULT_PROBE_CONTEXT.loreBefore;
|
||||||
const history = input.history ?? DEFAULT_PROBE_CONTEXT.history;
|
const rawHistory = input.history ?? DEFAULT_PROBE_CONTEXT.history;
|
||||||
const loreAfter = input.loreAfter ?? DEFAULT_PROBE_CONTEXT.loreAfter;
|
const loreAfter = input.loreAfter ?? DEFAULT_PROBE_CONTEXT.loreAfter;
|
||||||
const postTurn = input.postTurn ?? DEFAULT_PROBE_CONTEXT.postTurn;
|
const postTurn = input.postTurn ?? DEFAULT_PROBE_CONTEXT.postTurn;
|
||||||
|
const history = parseHistoryMessages(rawHistory);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
worldBookBefore: optionalEntry("probe-before", "试跑设定", loreBefore),
|
worldBookBefore: optionalEntry("probe-before", "试跑设定", loreBefore),
|
||||||
history: history.trim(),
|
history:
|
||||||
|
history.length > 0
|
||||||
|
? history
|
||||||
|
: rawHistory.trim()
|
||||||
|
? [{ role: "system", content: rawHistory.trim() }]
|
||||||
|
: [],
|
||||||
worldBookAfter: optionalEntry("probe-after", "本轮状态", loreAfter),
|
worldBookAfter: optionalEntry("probe-after", "本轮状态", loreAfter),
|
||||||
turn: input.message.trim(),
|
turn: input.message.trim(),
|
||||||
postTurn: optionalEntry("probe-post", "尾注", postTurn),
|
postTurn: optionalEntry("probe-post", "尾注", postTurn),
|
||||||
@@ -66,12 +90,17 @@ export function worldInfoPackFromProbeInput(
|
|||||||
export function assemblePresetProbe(
|
export function assemblePresetProbe(
|
||||||
preset: PresetPackage,
|
preset: PresetPackage,
|
||||||
input: PresetProbeInput,
|
input: PresetProbeInput,
|
||||||
|
options: PresetProbeOptions = {},
|
||||||
): ChatMessage[] {
|
): ChatMessage[] {
|
||||||
return assemblePlayWorkerMessages({
|
const messages = assemblePlayWorkerMessages({
|
||||||
systemPrompt: PROBE_SYSTEM_PROMPT,
|
systemPrompt: PROBE_SYSTEM_PROMPT,
|
||||||
preset,
|
preset,
|
||||||
pack: worldInfoPackFromProbeInput(input),
|
pack: worldInfoPackFromProbeInput(input),
|
||||||
|
charName: options.charName ?? null,
|
||||||
});
|
});
|
||||||
|
const mode =
|
||||||
|
options.squash ?? preset.messagePolicy?.squash ?? DEFAULT_PRESET_SQUASH;
|
||||||
|
return shapeFramedMessages(messages, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 交给 wrapLlmForSession 的任务消息(未夹心)。 */
|
/** 交给 wrapLlmForSession 的任务消息(未夹心)。 */
|
||||||
@@ -83,7 +112,10 @@ export function buildPresetProbeTaskMessages(
|
|||||||
for (const e of pack.worldBookBefore) {
|
for (const e of pack.worldBookBefore) {
|
||||||
parts.push(`### ${e.name}\n\n${e.content}`);
|
parts.push(`### ${e.name}\n\n${e.content}`);
|
||||||
}
|
}
|
||||||
if (pack.history.trim()) parts.push(pack.history.trim());
|
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) {
|
for (const e of pack.worldBookAfter) {
|
||||||
parts.push(`### ${e.name}\n\n${e.content}`);
|
parts.push(`### ${e.name}\n\n${e.content}`);
|
||||||
}
|
}
|
||||||
@@ -112,8 +144,10 @@ export function serializeProbeMessages(
|
|||||||
export async function runPresetProbe(params: {
|
export async function runPresetProbe(params: {
|
||||||
preset: PresetPackage;
|
preset: PresetPackage;
|
||||||
input: PresetProbeInput;
|
input: PresetProbeInput;
|
||||||
|
options?: PresetProbeOptions;
|
||||||
complete?: (
|
complete?: (
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
|
pack: WorldInfoPack,
|
||||||
) => Promise<{ content: string; reasoning?: string }>;
|
) => Promise<{ content: string; reasoning?: string }>;
|
||||||
}): Promise<PresetProbeResult> {
|
}): Promise<PresetProbeResult> {
|
||||||
const message = params.input.message.trim();
|
const message = params.input.message.trim();
|
||||||
@@ -122,13 +156,28 @@ export async function runPresetProbe(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const input = { ...params.input, message };
|
const input = { ...params.input, message };
|
||||||
|
const options = params.options ?? {};
|
||||||
|
const plan = resolveToolPlan(params.preset);
|
||||||
|
const meta = {
|
||||||
|
squashMode:
|
||||||
|
options.squash ??
|
||||||
|
params.preset.messagePolicy?.squash ??
|
||||||
|
DEFAULT_PRESET_SQUASH,
|
||||||
|
toolPlan: {
|
||||||
|
present: plan.present,
|
||||||
|
targetName: plan.targetName,
|
||||||
|
payloadFields: plan.payloadFields,
|
||||||
|
},
|
||||||
|
} satisfies Partial<PresetProbeResult>;
|
||||||
|
|
||||||
// UI 仍展示「理想分袋夹心」;发模型只交任务消息,由统一包装层夹心
|
// UI 仍展示「理想分袋夹心」;发模型只交任务消息,由统一包装层夹心
|
||||||
const serialized = serializeProbeMessages(
|
const serialized = serializeProbeMessages(
|
||||||
assemblePresetProbe(params.preset, input),
|
assemblePresetProbe(params.preset, input, options),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!params.complete) {
|
if (!params.complete) {
|
||||||
return {
|
return {
|
||||||
|
...meta,
|
||||||
messages: serialized,
|
messages: serialized,
|
||||||
reply: null,
|
reply: null,
|
||||||
reasoning: null,
|
reasoning: null,
|
||||||
@@ -138,8 +187,12 @@ export async function runPresetProbe(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await params.complete(buildPresetProbeTaskMessages(input));
|
const result = await params.complete(
|
||||||
|
buildPresetProbeTaskMessages(input),
|
||||||
|
worldInfoPackFromProbeInput(input),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
|
...meta,
|
||||||
messages: serialized,
|
messages: serialized,
|
||||||
reply: result.content,
|
reply: result.content,
|
||||||
reasoning: result.reasoning ?? null,
|
reasoning: result.reasoning ?? null,
|
||||||
@@ -147,6 +200,7 @@ export async function runPresetProbe(params: {
|
|||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
|
...meta,
|
||||||
messages: serialized,
|
messages: serialized,
|
||||||
reply: null,
|
reply: null,
|
||||||
reasoning: null,
|
reasoning: null,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ChatMessage } from "../llm/client.js";
|
||||||
|
import type { PresetChatTurn, PresetPackage } from "../types/preset.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 假历史:预设作者手写的前情,按真多轮(user / assistant)进「对话.历史」区。
|
||||||
|
* 属于历史,不属于示例对话(`mes_example` → system + example_user/example_assistant)。
|
||||||
|
*
|
||||||
|
* 只认 user / assistant:这里没有 system 的语义,作者想加系统提示就写一条普通模块。
|
||||||
|
* 脏数据(角色不认、内容非字符串或空白)一律丢掉,不抛。
|
||||||
|
*/
|
||||||
|
export function normalizePseudoHistory(value: unknown): PresetChatTurn[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const out: PresetChatTurn[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
const { role, content } = item as Partial<PresetChatTurn>;
|
||||||
|
if (role !== "user" && role !== "assistant") continue;
|
||||||
|
if (typeof content !== "string" || !content.trim()) continue;
|
||||||
|
out.push({ role, content });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读预设上的假历史。 */
|
||||||
|
export function readPseudoHistory(
|
||||||
|
preset: PresetPackage | null | undefined,
|
||||||
|
): PresetChatTurn[] {
|
||||||
|
return normalizePseudoHistory(preset?.pseudoHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 假历史在前(作者写的前情),真实轮次在后。
|
||||||
|
* 返回的都是普通 message,交给「对话.历史」洞展开时再打历史区记号。
|
||||||
|
*/
|
||||||
|
export function mergePseudoHistory(
|
||||||
|
pseudo: ReadonlyArray<ChatMessage>,
|
||||||
|
history: ChatMessage[],
|
||||||
|
): ChatMessage[] {
|
||||||
|
if (pseudo.length === 0) return history;
|
||||||
|
return [...pseudo, ...history];
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* 预设自带的正则脚本(酒馆 `regex_scripts`)。
|
||||||
|
*
|
||||||
|
* 纯数据驱动:`findRegex` / `replaceString` / `placement` / `promptOnly` / `markdownOnly`
|
||||||
|
* 都写在预设里,这里只做等价执行,不跑预设里的 JS。
|
||||||
|
*
|
||||||
|
* 酒馆的三类(见 `extensions/regex/engine.js`):
|
||||||
|
* - 发送前(`isPrompt`):只跑 `promptOnly` 的脚本,作用于**对话历史**(user=1、assistant=2、思维链=6),带 depth 过滤;
|
||||||
|
* - 展示(`isMarkdown`):只跑 `markdownOnly` 的脚本,只影响界面;
|
||||||
|
* - 写入时(两个 flag 都不是 true):在消息进 chat 的那一刻改写文本本身。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `placement`:作用于哪一类文本(与酒馆一致)。 */
|
||||||
|
export const REGEX_PLACEMENT = {
|
||||||
|
USER_INPUT: 1,
|
||||||
|
AI_OUTPUT: 2,
|
||||||
|
WORLD_INFO: 5,
|
||||||
|
REASONING: 6,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** `substituteRegex`:findRegex 里的宏怎么替换。 */
|
||||||
|
export const SUBSTITUTE_FIND_REGEX = {
|
||||||
|
NONE: 0,
|
||||||
|
RAW: 1,
|
||||||
|
ESCAPED: 2,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PresetRegexScript = {
|
||||||
|
name: string;
|
||||||
|
findRegex: string;
|
||||||
|
replaceString: string;
|
||||||
|
trimStrings: string[];
|
||||||
|
placement: number[];
|
||||||
|
promptOnly: boolean;
|
||||||
|
markdownOnly: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
runOnEdit: boolean;
|
||||||
|
substituteRegex: number;
|
||||||
|
minDepth: number | null;
|
||||||
|
maxDepth: number | null;
|
||||||
|
/** `substituteRegex = 2`(转义替换)暂按 RAW 处理,记下来给诊断提示 */
|
||||||
|
approximateSubstitute: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegexDirection = "prompt" | "display" | "raw";
|
||||||
|
|
||||||
|
export type RegexApplyOptions = {
|
||||||
|
placement: number;
|
||||||
|
direction: RegexDirection;
|
||||||
|
/** 距离历史末尾的深度(酒馆 depth 语义);缺省不做 depth 过滤 */
|
||||||
|
depth?: number;
|
||||||
|
/** 宏展开(replaceString / trimStrings / substituteRegex=RAW 时用) */
|
||||||
|
expand?: (text: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === "object"
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumberOrNull(value: unknown): number | null {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter((item): item is string => typeof item === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPlacement(value: unknown): number[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((item): item is number => typeof item === "number");
|
||||||
|
}
|
||||||
|
if (typeof value === "number") return [value];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRawScripts(raw: Record<string, unknown> | null): unknown[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
const extensions = asRecord(raw.extensions);
|
||||||
|
const spreset = asRecord(extensions?.SPreset);
|
||||||
|
const binding = asRecord(spreset?.RegexBinding);
|
||||||
|
const buckets = [raw.regex_scripts, extensions?.regex_scripts, binding?.regexes];
|
||||||
|
const out: unknown[] = [];
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
if (!Array.isArray(bucket)) continue;
|
||||||
|
for (const item of bucket) {
|
||||||
|
if (item && typeof item === "object") out.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从预设 raw 里读出全部正则脚本(三处来源去重;SPreset 常把同一批存两份)。 */
|
||||||
|
export function readRegexScripts(raw: unknown): PresetRegexScript[] {
|
||||||
|
const record = asRecord(raw);
|
||||||
|
const scripts: PresetRegexScript[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of collectRawScripts(record)) {
|
||||||
|
const entry = asRecord(item);
|
||||||
|
if (!entry) continue;
|
||||||
|
const findRegex = readString(entry.findRegex);
|
||||||
|
if (!findRegex.trim()) continue;
|
||||||
|
const id = readString(entry.id);
|
||||||
|
const name = readString(entry.scriptName) || id || "(未命名正则)";
|
||||||
|
const key = id || `${name}@@${findRegex}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
const substituteRegex =
|
||||||
|
typeof entry.substituteRegex === "number" ? entry.substituteRegex : 0;
|
||||||
|
scripts.push({
|
||||||
|
name,
|
||||||
|
findRegex,
|
||||||
|
replaceString: readString(entry.replaceString),
|
||||||
|
trimStrings: readStringList(entry.trimStrings),
|
||||||
|
placement: readPlacement(entry.placement),
|
||||||
|
promptOnly: entry.promptOnly === true,
|
||||||
|
markdownOnly: entry.markdownOnly === true,
|
||||||
|
disabled: entry.disabled === true,
|
||||||
|
runOnEdit: entry.runOnEdit === true,
|
||||||
|
substituteRegex,
|
||||||
|
minDepth: readNumberOrNull(entry.minDepth),
|
||||||
|
maxDepth: readNumberOrNull(entry.maxDepth),
|
||||||
|
approximateSubstitute: substituteRegex === SUBSTITUTE_FIND_REGEX.ESCAPED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return scripts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/pattern/flags` → RegExp;不合法返回 null。 */
|
||||||
|
function parseFindRegex(source: string): RegExp | null {
|
||||||
|
const text = source.trim();
|
||||||
|
const wrapped = /^\/([\s\S]*)\/([a-z]*)$/.exec(text);
|
||||||
|
try {
|
||||||
|
if (wrapped) {
|
||||||
|
const flags = wrapped[2]!.includes("g") ? wrapped[2]! : `${wrapped[2]}g`;
|
||||||
|
return new RegExp(wrapped[1]!, flags);
|
||||||
|
}
|
||||||
|
return new RegExp(text, "g");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFindRegex(
|
||||||
|
script: PresetRegexScript,
|
||||||
|
expand?: (text: string) => string,
|
||||||
|
): RegExp | null {
|
||||||
|
const source =
|
||||||
|
script.substituteRegex === SUBSTITUTE_FIND_REGEX.RAW && expand
|
||||||
|
? expand(script.findRegex)
|
||||||
|
: script.findRegex;
|
||||||
|
return parseFindRegex(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimMatched(
|
||||||
|
text: string,
|
||||||
|
trimStrings: string[],
|
||||||
|
expand?: (text: string) => string,
|
||||||
|
): string {
|
||||||
|
if (trimStrings.length === 0) return text;
|
||||||
|
let out = text;
|
||||||
|
for (const trim of trimStrings) {
|
||||||
|
const needle = expand ? expand(trim) : trim;
|
||||||
|
if (!needle) continue;
|
||||||
|
out = out.split(needle).join("");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条脚本执行(对齐酒馆 `runRegexScript`)。 */
|
||||||
|
export function runRegexScript(
|
||||||
|
text: string,
|
||||||
|
script: PresetRegexScript,
|
||||||
|
options: { expand?: (text: string) => string } = {},
|
||||||
|
): string {
|
||||||
|
const regex = buildFindRegex(script, options.expand);
|
||||||
|
if (!regex) return text;
|
||||||
|
const template = script.replaceString.replace(/{{match}}/gi, "$0");
|
||||||
|
return text.replace(regex, (...args: unknown[]) => {
|
||||||
|
const groups =
|
||||||
|
args.length > 0 && typeof args[args.length - 1] === "object"
|
||||||
|
? (args[args.length - 1] as Record<string, string> | null)
|
||||||
|
: null;
|
||||||
|
const replaced = template.replaceAll(
|
||||||
|
/\$(\d+)|\$<([^>]+)>/g,
|
||||||
|
(_raw: string, num?: string, groupName?: string) => {
|
||||||
|
const matched =
|
||||||
|
num !== undefined
|
||||||
|
? (args[Number(num)] as string | undefined)
|
||||||
|
: groups && groupName
|
||||||
|
? groups[groupName]
|
||||||
|
: undefined;
|
||||||
|
if (matched === undefined || matched === null) return "";
|
||||||
|
return trimMatched(
|
||||||
|
String(matched),
|
||||||
|
script.trimStrings,
|
||||||
|
options.expand,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return options.expand ? options.expand(replaced) : replaced;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function directionAllows(
|
||||||
|
script: PresetRegexScript,
|
||||||
|
direction: RegexDirection,
|
||||||
|
): boolean {
|
||||||
|
if (script.disabled) return false;
|
||||||
|
if (direction === "prompt") return script.promptOnly;
|
||||||
|
if (direction === "display") return script.markdownOnly;
|
||||||
|
return !script.promptOnly && !script.markdownOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
function depthAllows(script: PresetRegexScript, depth: number | undefined): boolean {
|
||||||
|
if (typeof depth !== "number") return true;
|
||||||
|
if (script.minDepth !== null && script.minDepth >= -1 && depth < script.minDepth) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (script.maxDepth !== null && script.maxDepth >= 0 && depth > script.maxDepth) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按方向 / placement / depth 依次跑脚本(顺序与预设里一致)。 */
|
||||||
|
export function applyRegexScripts(
|
||||||
|
text: string,
|
||||||
|
scripts: PresetRegexScript[],
|
||||||
|
options: RegexApplyOptions,
|
||||||
|
): string {
|
||||||
|
if (!text || scripts.length === 0) return text;
|
||||||
|
let out = text;
|
||||||
|
for (const script of scripts) {
|
||||||
|
if (!script.placement.includes(options.placement)) continue;
|
||||||
|
if (!directionAllows(script, options.direction)) continue;
|
||||||
|
if (!depthAllows(script, options.depth)) continue;
|
||||||
|
out = runRegexScript(out, script, { expand: options.expand });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 有没有某个方向会真的跑到的脚本(装配时用来提前短路)。 */
|
||||||
|
export function hasRegexFor(
|
||||||
|
scripts: PresetRegexScript[],
|
||||||
|
direction: RegexDirection,
|
||||||
|
placement: number,
|
||||||
|
): boolean {
|
||||||
|
return scripts.some(
|
||||||
|
(script) =>
|
||||||
|
script.placement.includes(placement) &&
|
||||||
|
directionAllows(script, direction),
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-2
@@ -3,6 +3,7 @@ import path from "node:path";
|
|||||||
import type { PresetPackage } from "../types/preset.js";
|
import type { PresetPackage } from "../types/preset.js";
|
||||||
import { ensureUserDataDirs, getPresetsDir } from "../config/user-data-dir.js";
|
import { ensureUserDataDirs, getPresetsDir } from "../config/user-data-dir.js";
|
||||||
import { importSillyTavernPreset } from "./importer.js";
|
import { importSillyTavernPreset } from "./importer.js";
|
||||||
|
import { clearToolPlanCache } from "./tool-policy.js";
|
||||||
|
|
||||||
/** 旧预设里的思考强度不再使用;读到就从文件里删掉。 */
|
/** 旧预设里的思考强度不再使用;读到就从文件里删掉。 */
|
||||||
function withoutReasoningEffort(preset: PresetPackage, filePath: string): PresetPackage {
|
function withoutReasoningEffort(preset: PresetPackage, filePath: string): PresetPackage {
|
||||||
@@ -18,6 +19,11 @@ function presetPath(id: string): string {
|
|||||||
return path.join(getPresetsDir(), `${id}.json`);
|
return path.join(getPresetsDir(), `${id}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Windows 编辑器可能给 JSON 加 BOM;JSON.parse 不认,先剥掉。 */
|
||||||
|
function parsePreset(raw: string): PresetPackage {
|
||||||
|
return JSON.parse(raw.replace(/^\uFEFF/, "")) as PresetPackage;
|
||||||
|
}
|
||||||
|
|
||||||
export function listPresets(): PresetPackage[] {
|
export function listPresets(): PresetPackage[] {
|
||||||
ensureUserDataDirs();
|
ensureUserDataDirs();
|
||||||
let files: string[];
|
let files: string[];
|
||||||
@@ -32,7 +38,7 @@ export function listPresets(): PresetPackage[] {
|
|||||||
try {
|
try {
|
||||||
const filePath = path.join(getPresetsDir(), file);
|
const filePath = path.join(getPresetsDir(), file);
|
||||||
const raw = readFileSync(filePath, "utf8");
|
const raw = readFileSync(filePath, "utf8");
|
||||||
presets.push(withoutReasoningEffort(JSON.parse(raw) as PresetPackage, filePath));
|
presets.push(withoutReasoningEffort(parsePreset(raw), filePath));
|
||||||
} catch {
|
} catch {
|
||||||
/* skip corrupt files */
|
/* skip corrupt files */
|
||||||
}
|
}
|
||||||
@@ -44,7 +50,7 @@ export function getPreset(id: string): PresetPackage | null {
|
|||||||
try {
|
try {
|
||||||
const filePath = presetPath(id);
|
const filePath = presetPath(id);
|
||||||
const raw = readFileSync(filePath, "utf8");
|
const raw = readFileSync(filePath, "utf8");
|
||||||
return withoutReasoningEffort(JSON.parse(raw) as PresetPackage, filePath);
|
return withoutReasoningEffort(parsePreset(raw), filePath);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -53,10 +59,13 @@ export function getPreset(id: string): PresetPackage | null {
|
|||||||
export function savePreset(preset: PresetPackage): PresetPackage {
|
export function savePreset(preset: PresetPackage): PresetPackage {
|
||||||
ensureUserDataDirs();
|
ensureUserDataDirs();
|
||||||
writeFileSync(presetPath(preset.id), JSON.stringify(preset, null, 2), "utf8");
|
writeFileSync(presetPath(preset.id), JSON.stringify(preset, null, 2), "utf8");
|
||||||
|
// 条目/内容被改过,导入时算好的工具映射要重建
|
||||||
|
clearToolPlanCache(preset.id);
|
||||||
return preset;
|
return preset;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deletePreset(id: string): void {
|
export function deletePreset(id: string): void {
|
||||||
|
clearToolPlanCache(id);
|
||||||
try {
|
try {
|
||||||
unlinkSync(presetPath(id));
|
unlinkSync(presetPath(id));
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { getPresetsDir } from "../config/user-data-dir.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试用例:**全局,不和预设绑定**——一个用例只描述「要测的输入 + 上下文」,
|
||||||
|
* 任何预设都能拿它去跑。存在 `<data>/presets/tests/global/` 下,每组一个 JSON 文件
|
||||||
|
* (沿用 TestCaseGroup 形状,旧的按预设分目录的文件会在第一次访问时并进来)。
|
||||||
|
*
|
||||||
|
* 每个用例只包含变动项:角色名与描述({{char}})、世界书、用户输入、可选历史。
|
||||||
|
*/
|
||||||
|
export type TestCaseHistoryItem = { role: "user" | "assistant"; content: string };
|
||||||
|
|
||||||
|
export type TestCase = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
charName: string;
|
||||||
|
charDescription: string;
|
||||||
|
worldBook: string;
|
||||||
|
userInput: string;
|
||||||
|
history: TestCaseHistoryItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestCaseGroup = {
|
||||||
|
id: string;
|
||||||
|
/** 历史字段:现在恒为 GLOBAL_CASES_ID,留着只是为了旧文件能直接读。 */
|
||||||
|
presetId: string;
|
||||||
|
name: string;
|
||||||
|
updatedAt: string;
|
||||||
|
cases: TestCase[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GLOBAL_CASES_ID = "global";
|
||||||
|
|
||||||
|
const SAFE_ID = /^[\w\u4e00-\u9fff-]+$/;
|
||||||
|
|
||||||
|
function assertSafe(id: string, label: string): void {
|
||||||
|
if (!SAFE_ID.test(id)) throw new Error(`非法${label}:${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function casesRoot(): string {
|
||||||
|
return path.join(getPresetsDir(), "tests");
|
||||||
|
}
|
||||||
|
|
||||||
|
function globalDir(): string {
|
||||||
|
return path.join(casesRoot(), GLOBAL_CASES_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupFile(groupId: string): string {
|
||||||
|
assertSafe(groupId, "用例 id");
|
||||||
|
return path.join(globalDir(), `${groupId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 老版本把用例按预设分目录存(`tests/<presetId>/*.json`)。全局目录第一次被用到时,
|
||||||
|
* 把那些文件并进来(id 冲突就跳过),原文件不删,方便回退。
|
||||||
|
*/
|
||||||
|
function ensureGlobalDir(): string {
|
||||||
|
const dir = globalDir();
|
||||||
|
if (existsSync(dir)) return dir;
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const root = casesRoot();
|
||||||
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory() || entry.name === GLOBAL_CASES_ID) continue;
|
||||||
|
const legacyDir = path.join(root, entry.name);
|
||||||
|
for (const file of readdirSync(legacyDir).filter((name) => name.endsWith(".json"))) {
|
||||||
|
const target = path.join(dir, file);
|
||||||
|
if (existsSync(target)) continue;
|
||||||
|
try {
|
||||||
|
const group = JSON.parse(readFileSync(path.join(legacyDir, file), "utf8")) as TestCaseGroup;
|
||||||
|
writeFileSync(
|
||||||
|
target,
|
||||||
|
JSON.stringify({ ...group, presetId: GLOBAL_CASES_ID }, null, 2),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* 坏文件跳过 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTestGroups(): TestCaseGroup[] {
|
||||||
|
const dir = ensureGlobalDir();
|
||||||
|
const groups: TestCaseGroup[] = [];
|
||||||
|
for (const file of readdirSync(dir).filter((name) => name.endsWith(".json"))) {
|
||||||
|
try {
|
||||||
|
groups.push(JSON.parse(readFileSync(path.join(dir, file), "utf8")) as TestCaseGroup);
|
||||||
|
} catch {
|
||||||
|
/* skip corrupt */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTestGroup(groupId: string): TestCaseGroup | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(groupFile(groupId), "utf8")) as TestCaseGroup;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建或覆盖一组。cases 中缺 id 的会分配 id。 */
|
||||||
|
export function saveTestGroup(input: {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
cases: Array<Partial<TestCase>>;
|
||||||
|
}): TestCaseGroup {
|
||||||
|
const id = input.id?.trim() || randomUUID().slice(0, 8);
|
||||||
|
const text = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||||
|
const cases: TestCase[] = input.cases.map((c) => ({
|
||||||
|
id: text(c.id).trim() || randomUUID().slice(0, 8),
|
||||||
|
name: text(c.name).trim() || "未命名用例",
|
||||||
|
charName: text(c.charName).trim(),
|
||||||
|
charDescription: text(c.charDescription),
|
||||||
|
worldBook: text(c.worldBook),
|
||||||
|
userInput: text(c.userInput),
|
||||||
|
history: (Array.isArray(c.history) ? c.history : []).filter(
|
||||||
|
(h) => (h.role === "user" || h.role === "assistant") && typeof h.content === "string",
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
const group: TestCaseGroup = {
|
||||||
|
id,
|
||||||
|
presetId: GLOBAL_CASES_ID,
|
||||||
|
name: input.name.trim() || "未命名测试组",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
cases,
|
||||||
|
};
|
||||||
|
ensureGlobalDir();
|
||||||
|
writeFileSync(groupFile(id), JSON.stringify(group, null, 2), "utf8");
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTestGroup(groupId: string): void {
|
||||||
|
try {
|
||||||
|
unlinkSync(groupFile(groupId));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 用例(工作台的展示单位) ----------
|
||||||
|
* 盘上仍是一组一个文件,但界面上不出现「组」:每份用例都是一条独立记录,
|
||||||
|
* 自己存、自己运行。新建用例会为它开一个只装一份用例的组文件。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TestCaseRef = { groupId: string; case: TestCase };
|
||||||
|
|
||||||
|
/** 平铺所有用例(按组更新时间排序,组内保持原顺序)。 */
|
||||||
|
export function listTestCases(): TestCaseRef[] {
|
||||||
|
const out: TestCaseRef[] = [];
|
||||||
|
for (const group of listTestGroups()) {
|
||||||
|
for (const item of group.cases) out.push({ groupId: group.id, case: item });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存单份用例:给了 groupId 就写回那个组(组内其他用例不动),
|
||||||
|
* 没给就新建一个只装它的组。
|
||||||
|
*/
|
||||||
|
export function saveTestCase(input: {
|
||||||
|
groupId?: string;
|
||||||
|
case: Partial<TestCase>;
|
||||||
|
}): TestCaseRef {
|
||||||
|
const groupId = input.groupId?.trim();
|
||||||
|
if (!groupId) {
|
||||||
|
const name = typeof input.case.name === "string" ? input.case.name.trim() : "";
|
||||||
|
const group = saveTestGroup({
|
||||||
|
name: name || "未命名用例",
|
||||||
|
cases: [input.case],
|
||||||
|
});
|
||||||
|
return { groupId: group.id, case: group.cases[0]! };
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = getTestGroup(groupId);
|
||||||
|
if (!group) throw new Error(`用例不存在:${groupId}`);
|
||||||
|
const caseId = typeof input.case.id === "string" ? input.case.id.trim() : "";
|
||||||
|
const index = group.cases.findIndex((item) => item.id === caseId);
|
||||||
|
const cases =
|
||||||
|
index >= 0
|
||||||
|
? group.cases.map((item, i) => (i === index ? { ...item, ...input.case } : item))
|
||||||
|
: [...group.cases, input.case];
|
||||||
|
// 只剩一份用例时,组名跟着用例名走,盘上读起来不至于两个名字打架。
|
||||||
|
const single = cases.length === 1;
|
||||||
|
const caseName = typeof input.case.name === "string" ? input.case.name.trim() : "";
|
||||||
|
const saved = saveTestGroup({
|
||||||
|
id: groupId,
|
||||||
|
name: single && caseName ? caseName : group.name,
|
||||||
|
cases,
|
||||||
|
});
|
||||||
|
const wanted = index >= 0 ? caseId : saved.cases[saved.cases.length - 1]!.id;
|
||||||
|
return {
|
||||||
|
groupId,
|
||||||
|
case: saved.cases.find((item) => item.id === wanted) ?? saved.cases[0]!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除单份用例;组里没别的人就把组文件一起删掉。 */
|
||||||
|
export function deleteTestCase(groupId: string, caseId: string): void {
|
||||||
|
const group = getTestGroup(groupId);
|
||||||
|
if (!group) return;
|
||||||
|
const cases = group.cases.filter((item) => item.id !== caseId);
|
||||||
|
if (cases.length === 0) deleteTestGroup(groupId);
|
||||||
|
else saveTestGroup({ id: groupId, name: group.name, cases });
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { getApiProfile, listApiProfiles, profileToLlmConfig } from "../config/api-profiles.js";
|
||||||
|
import { OpenAiCompatibleProvider, type ChatMessage, type TokenUsage } from "../llm/client.js";
|
||||||
|
import { assemblePlayWorkerMessages, shapeFramedMessages } from "./play-frame.js";
|
||||||
|
import { resolveSquashForPreset } from "../llm/preset-wrapper.js";
|
||||||
|
import { checkAuthoringPreset, type AuthoringIssue } from "./authoring-check.js";
|
||||||
|
import type { PresetPackage } from "../types/preset.js";
|
||||||
|
import type { MessageSquashMode } from "../types/preset.js";
|
||||||
|
import type { TestCase } from "./test-cases.js";
|
||||||
|
import type { WorldInfoPack } from "./world-info-pack.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按真实发送路径组装一个用例的 messages(不调用模型)。
|
||||||
|
* 与线上一致:预设 → assemblePlayWorkerMessages → 消息整形。
|
||||||
|
* 变动的只有 char 描述、世界书、用户输入、历史。
|
||||||
|
*/
|
||||||
|
export function buildCaseMessages(preset: PresetPackage, testCase: TestCase): ChatMessage[] {
|
||||||
|
return shapeFramedMessages(
|
||||||
|
assembleCaseMessages(preset, testCase),
|
||||||
|
resolveSquashForPreset(preset, "follow-preset"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作台右侧预览:同一组装结果,多告诉页面两件事——
|
||||||
|
* 本次用的整形模式、整形前的条数。左轨是真实顺序,整形后条数变少要能看出来。
|
||||||
|
*/
|
||||||
|
export function buildCasePreview(
|
||||||
|
preset: PresetPackage,
|
||||||
|
testCase: TestCase,
|
||||||
|
): { messages: ChatMessage[]; squash: MessageSquashMode; assembledCount: number } {
|
||||||
|
const framed = assembleCaseMessages(preset, testCase);
|
||||||
|
const squash = resolveSquashForPreset(preset, "follow-preset");
|
||||||
|
return {
|
||||||
|
messages: shapeFramedMessages(framed, squash),
|
||||||
|
squash,
|
||||||
|
assembledCount: framed.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 装配那一步本身(右栏与试跑共用,整形在调用方决定)。 */
|
||||||
|
function assembleCaseMessages(
|
||||||
|
preset: PresetPackage,
|
||||||
|
testCase: TestCase,
|
||||||
|
): ChatMessage[] {
|
||||||
|
const asText = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||||
|
const historyInput = (Array.isArray(testCase.history) ? testCase.history : []).filter(
|
||||||
|
(item) => item && typeof item === "object",
|
||||||
|
);
|
||||||
|
const history: ChatMessage[] = historyInput.map((h) => ({
|
||||||
|
role: h.role === "assistant" ? "assistant" : "user",
|
||||||
|
content: typeof h.content === "string" ? h.content : "",
|
||||||
|
}));
|
||||||
|
const worldBook = asText(testCase.worldBook);
|
||||||
|
const charDescription = asText(testCase.charDescription);
|
||||||
|
const pack: WorldInfoPack = {
|
||||||
|
worldBookBefore: worldBook.trim()
|
||||||
|
? [{ id: "test-world", name: "世界书", content: worldBook }]
|
||||||
|
: [],
|
||||||
|
history,
|
||||||
|
worldBookAfter: [],
|
||||||
|
turn: asText(testCase.userInput),
|
||||||
|
postTurn: [],
|
||||||
|
card: charDescription.trim() ? { description: charDescription } : undefined,
|
||||||
|
};
|
||||||
|
const framed = assemblePlayWorkerMessages({
|
||||||
|
systemPrompt: "",
|
||||||
|
preset,
|
||||||
|
pack,
|
||||||
|
persona: null,
|
||||||
|
charName: asText(testCase.charName) || null,
|
||||||
|
});
|
||||||
|
return framed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RunStatus = "running" | "done" | "error";
|
||||||
|
|
||||||
|
export type CaseRunResult = {
|
||||||
|
profileId: string;
|
||||||
|
profileName: string;
|
||||||
|
model: string;
|
||||||
|
caseId: string;
|
||||||
|
caseName?: string;
|
||||||
|
status: RunStatus;
|
||||||
|
content?: string;
|
||||||
|
error?: string;
|
||||||
|
usage?: TokenUsage;
|
||||||
|
durationMs?: number;
|
||||||
|
/** 手动标记:null 表示未标记 */
|
||||||
|
verdict: "success" | "failure" | null;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestRun = {
|
||||||
|
id: string;
|
||||||
|
presetId: string;
|
||||||
|
presetVersionId: string;
|
||||||
|
groupId: string;
|
||||||
|
createdAt: string;
|
||||||
|
/** 本次运行时的消息快照(每个用例一份),便于事后核对发送内容 */
|
||||||
|
requests: Array<{ caseId: string; messages: ChatMessage[] }>;
|
||||||
|
results: CaseRunResult[];
|
||||||
|
issues: AuthoringIssue[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEST_RUN_TIMEOUT_MS = 180_000;
|
||||||
|
const DEFAULT_CONCURRENCY = 4;
|
||||||
|
|
||||||
|
/** 小并发池:多模型 × 多用例时不要一次全打出去。 */
|
||||||
|
async function runPool(jobs: Array<() => Promise<void>>, limit: number): Promise<void> {
|
||||||
|
const queue = [...jobs];
|
||||||
|
const width = Math.max(1, Math.min(limit, queue.length));
|
||||||
|
const workers = Array.from({ length: width }, async () => {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const job = queue.shift();
|
||||||
|
if (job) await job();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对一组用例、多个 API 配置并行运行。
|
||||||
|
* 同一用例的同一配置只发一次;各配置之间并行。
|
||||||
|
*/
|
||||||
|
export async function runTestGroup(input: {
|
||||||
|
preset: PresetPackage;
|
||||||
|
presetVersionId: string;
|
||||||
|
presetId: string;
|
||||||
|
groupId: string;
|
||||||
|
cases: TestCase[];
|
||||||
|
profileIds: string[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
concurrency?: number;
|
||||||
|
}): Promise<TestRun> {
|
||||||
|
const profileIds = [...new Set(input.profileIds.map((x) => x.trim()).filter(Boolean))];
|
||||||
|
if (profileIds.length === 0) throw new Error("请至少选择一个 API 配置");
|
||||||
|
if (input.cases.length === 0) throw new Error("测试组中没有用例");
|
||||||
|
|
||||||
|
const profiles = profileIds.map((id) => {
|
||||||
|
const profile = getApiProfile(id);
|
||||||
|
if (!profile?.apiKey?.trim()) throw new Error(`API 配置不可用:${id}`);
|
||||||
|
return profile;
|
||||||
|
});
|
||||||
|
|
||||||
|
const requests = input.cases.map((c) => ({ caseId: c.id, messages: buildCaseMessages(input.preset, c) }));
|
||||||
|
const run: TestRun = {
|
||||||
|
id: randomUUID().slice(0, 8),
|
||||||
|
presetId: input.presetId,
|
||||||
|
presetVersionId: input.presetVersionId,
|
||||||
|
groupId: input.groupId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
requests,
|
||||||
|
results: [],
|
||||||
|
issues: checkAuthoringPreset(input.preset),
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutMs = Math.max(1_000, input.timeoutMs ?? TEST_RUN_TIMEOUT_MS);
|
||||||
|
const jobs: Array<() => Promise<void>> = [];
|
||||||
|
for (const profile of profiles) {
|
||||||
|
for (const req of requests) {
|
||||||
|
const testCase = input.cases.find((c) => c.id === req.caseId);
|
||||||
|
const slot: CaseRunResult = {
|
||||||
|
profileId: profile.id,
|
||||||
|
profileName: profile.name,
|
||||||
|
model: profile.model,
|
||||||
|
caseId: req.caseId,
|
||||||
|
caseName: testCase?.name,
|
||||||
|
status: "running",
|
||||||
|
verdict: null,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
run.results.push(slot);
|
||||||
|
jobs.push(async () => {
|
||||||
|
const started = Date.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const llm = new OpenAiCompatibleProvider(profileToLlmConfig(profile));
|
||||||
|
const result = await llm.complete(req.messages, {
|
||||||
|
generation: input.preset.generation,
|
||||||
|
caller: `preset-test:${profile.id}`,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
slot.content = result.content ?? "";
|
||||||
|
if (result.model) slot.model = result.model;
|
||||||
|
if (result.usage) slot.usage = result.usage;
|
||||||
|
slot.status = "done";
|
||||||
|
} catch (err) {
|
||||||
|
slot.status = "error";
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
slot.error = `超时:${Math.round(timeoutMs / 1000)} 秒内没有完成`;
|
||||||
|
} else {
|
||||||
|
slot.error = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
slot.durationMs = Date.now() - started;
|
||||||
|
slot.finishedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await runPool(jobs, input.concurrency ?? DEFAULT_CONCURRENCY);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRunnableProfiles(): Array<{ id: string; name: string; model: string; hasKey: boolean }> {
|
||||||
|
return listApiProfiles().map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
model: p.model,
|
||||||
|
hasKey: Boolean(p.apiKey?.trim()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动标记结果:只保存标签,不做自动评分。 */
|
||||||
|
export function setVerdict(run: TestRun, profileId: string, caseId: string, verdict: "success" | "failure" | null): TestRun {
|
||||||
|
const slot = run.results.find((r) => r.profileId === profileId && r.caseId === caseId);
|
||||||
|
if (!slot) throw new Error("结果不存在");
|
||||||
|
slot.verdict = verdict;
|
||||||
|
return run;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { getPresetsDir } from "../config/user-data-dir.js";
|
||||||
|
import type { TestRun } from "./test-run.js";
|
||||||
|
|
||||||
|
/** 运行记录:presets/runs/<presetId>/<runId>.json,保存预设版本 id 以便追溯。 */
|
||||||
|
const SAFE_ID = /^[\w\u4e00-\u9fff-]+$/;
|
||||||
|
|
||||||
|
function runDir(presetId: string): string {
|
||||||
|
if (!SAFE_ID.test(presetId)) throw new Error(`非法预设 id:${presetId}`);
|
||||||
|
return path.join(getPresetsDir(), "runs", presetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFile(presetId: string, runId: string): string {
|
||||||
|
if (!SAFE_ID.test(runId)) throw new Error(`非法运行 id:${runId}`);
|
||||||
|
return path.join(runDir(presetId), `${runId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveTestRun(run: TestRun): void {
|
||||||
|
mkdirSync(runDir(run.presetId), { recursive: true });
|
||||||
|
writeFileSync(runFile(run.presetId, run.id), JSON.stringify(run, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTestRun(presetId: string, runId: string): TestRun | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(runFile(presetId, runId), "utf8")) as TestRun;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTestRuns(presetId: string): TestRun[] {
|
||||||
|
const dir = runDir(presetId);
|
||||||
|
if (!existsSync(dir)) return [];
|
||||||
|
const out: TestRun[] = [];
|
||||||
|
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json"))) {
|
||||||
|
try {
|
||||||
|
out.push(JSON.parse(readFileSync(path.join(dir, file), "utf8")) as TestRun);
|
||||||
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改某个结果的手动标记并落盘。 */
|
||||||
|
export function updateVerdict(
|
||||||
|
presetId: string,
|
||||||
|
runId: string,
|
||||||
|
profileId: string,
|
||||||
|
caseId: string,
|
||||||
|
verdict: "success" | "failure" | null,
|
||||||
|
): TestRun {
|
||||||
|
const run = getTestRun(presetId, runId);
|
||||||
|
if (!run) throw new Error("运行记录不存在");
|
||||||
|
const slot = run.results.find((r) => r.profileId === profileId && r.caseId === caseId);
|
||||||
|
if (!slot) throw new Error("结果不存在");
|
||||||
|
slot.verdict = verdict;
|
||||||
|
saveTestRun(run);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import type { ToolDefinition } from "../llm/client.js";
|
||||||
|
import { expandPresetDirectives } from "../directives/expand.js";
|
||||||
|
import type { PresetPackage } from "../types/preset.js";
|
||||||
|
import { buildPresetDiagnostics, orderedEnabledEntries } from "./diagnostics.js";
|
||||||
|
import { resolveToolPlan } from "./tool-policy.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把预设里「工具协议」的**数据**翻译成真正的 `tools` 声明(对应酒馆里脚本干活的那一层)。
|
||||||
|
*
|
||||||
|
* 只读数据、不执行脚本,所以不会为某个预设特判:
|
||||||
|
* 1. `extensions.SPreset.ToolBindings[].form`:作者直接写的 name / description / parameters;
|
||||||
|
* 2. 只有伪工具协议条目(`%TOOL_NAME%` + `display_stream` 这种占位符)时,
|
||||||
|
* 从条目正文里取工具名、说明与字段说明,拼出等价的 schema。
|
||||||
|
*/
|
||||||
|
export type PresetToolDeclaration = {
|
||||||
|
source: "bindings" | "protocol";
|
||||||
|
tools: ToolDefinition[];
|
||||||
|
toolChoice: "auto";
|
||||||
|
/** 正文会落在这些参数上,调用完要把它们折回正文 */
|
||||||
|
payloadFields: string[];
|
||||||
|
/** 已经并进 tool 描述/参数描述的条目 id:装配时要剔掉,别重复发 */
|
||||||
|
consumedEntryIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type BindingFormParam = {
|
||||||
|
name?: unknown;
|
||||||
|
type?: unknown;
|
||||||
|
description?: unknown;
|
||||||
|
required?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BindingForm = {
|
||||||
|
name?: unknown;
|
||||||
|
description?: unknown;
|
||||||
|
parameters?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BindingEntry = {
|
||||||
|
enabled?: unknown;
|
||||||
|
resolvedName?: unknown;
|
||||||
|
form?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindingMaps(preset: PresetPackage): Record<string, unknown>[] {
|
||||||
|
const extensions = asRecord(asRecord(preset.raw)?.extensions);
|
||||||
|
if (!extensions) return [];
|
||||||
|
const maps: Record<string, unknown>[] = [];
|
||||||
|
const fromPreset = asRecord(asRecord(extensions.SPreset)?.ToolBindings);
|
||||||
|
if (fromPreset) maps.push(fromPreset);
|
||||||
|
const direct = asRecord(extensions.ToolBindings);
|
||||||
|
if (direct) maps.push(direct);
|
||||||
|
return maps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaFromForm(form: BindingForm): {
|
||||||
|
schema: Record<string, unknown>;
|
||||||
|
fields: string[];
|
||||||
|
} {
|
||||||
|
const properties: Record<string, unknown> = {};
|
||||||
|
const required: string[] = [];
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params = Array.isArray(form.parameters) ? (form.parameters as BindingFormParam[]) : [];
|
||||||
|
for (const param of params) {
|
||||||
|
const name = typeof param.name === "string" ? param.name.trim() : "";
|
||||||
|
if (!name) continue;
|
||||||
|
const type = typeof param.type === "string" && param.type.trim() ? param.type.trim() : "string";
|
||||||
|
const description =
|
||||||
|
typeof param.description === "string" ? param.description.trim() : "";
|
||||||
|
properties[name] = description ? { type, description } : { type };
|
||||||
|
fields.push(name);
|
||||||
|
if (param.required !== false) required.push(name);
|
||||||
|
}
|
||||||
|
const schema: Record<string, unknown> = { type: "object", properties };
|
||||||
|
if (required.length > 0) schema.required = required;
|
||||||
|
return { schema, fields };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBindings(preset: PresetPackage): PresetToolDeclaration | null {
|
||||||
|
const tools: ToolDefinition[] = [];
|
||||||
|
const payloadFields = new Set<string>();
|
||||||
|
for (const map of bindingMaps(preset)) {
|
||||||
|
for (const value of Object.values(map)) {
|
||||||
|
const entry = asRecord(value) as BindingEntry | null;
|
||||||
|
if (!entry) continue;
|
||||||
|
if (entry.enabled === false) continue;
|
||||||
|
const form = asRecord(entry.form) as BindingForm | null;
|
||||||
|
if (!form) continue;
|
||||||
|
const name =
|
||||||
|
(typeof form.name === "string" && form.name.trim()) ||
|
||||||
|
(typeof entry.resolvedName === "string" && entry.resolvedName.trim()) ||
|
||||||
|
"";
|
||||||
|
if (!name) continue;
|
||||||
|
const description = typeof form.description === "string" ? form.description.trim() : "";
|
||||||
|
const { schema, fields } = schemaFromForm(form);
|
||||||
|
tools.push({
|
||||||
|
type: "function",
|
||||||
|
function: { name, description, parameters: schema },
|
||||||
|
});
|
||||||
|
for (const field of fields) payloadFields.add(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tools.length === 0) return null;
|
||||||
|
return {
|
||||||
|
source: "bindings",
|
||||||
|
tools,
|
||||||
|
toolChoice: "auto",
|
||||||
|
payloadFields: [...payloadFields],
|
||||||
|
consumedEntryIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 伪工具协议条目里那个"工具名":占位符 → 正文里写死的 xxx_payload_xxx → targetName → 兜底。 */
|
||||||
|
function protocolToolName(preset: PresetPackage): string {
|
||||||
|
const diagnostics = buildPresetDiagnostics(preset, preset.raw);
|
||||||
|
const named = diagnostics.toolProtocol.placeholders.find(
|
||||||
|
(item) => !/^%[A-Z0-9_]+%$/.test(item),
|
||||||
|
);
|
||||||
|
if (named) return named;
|
||||||
|
for (const text of protocolEntryTexts(preset)) {
|
||||||
|
const hit = text.match(/[a-z][a-z0-9]*(?:_[a-z0-9]+)*payload[a-z0-9_]*/i);
|
||||||
|
if (hit) return hit[0];
|
||||||
|
}
|
||||||
|
const target = resolveToolPlan(preset).targetName;
|
||||||
|
return target ?? "preset_payload";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 伪工具协议各条目(按 prompt_order 顺序)。 */
|
||||||
|
function protocolEntries(
|
||||||
|
preset: PresetPackage,
|
||||||
|
): Array<{ id: string; name: string; content: string }> {
|
||||||
|
const diagnostics = buildPresetDiagnostics(preset, preset.raw);
|
||||||
|
const ids = new Set(diagnostics.toolProtocol.entries);
|
||||||
|
const entries = orderedEnabledEntries(preset);
|
||||||
|
const picked = entries
|
||||||
|
.filter((entry) => ids.has(entry.name.trim() || entry.id) || ids.has(entry.id))
|
||||||
|
.map((entry) => ({ id: entry.id, name: entry.name.trim() || entry.id, content: entry.content }));
|
||||||
|
if (picked.length > 0) return picked;
|
||||||
|
// 诊断没认出来(命名不固定)时,按正文特征兜底
|
||||||
|
return entries
|
||||||
|
.map((entry) => ({ id: entry.id, name: entry.name.trim() || entry.id, content: entry.content }))
|
||||||
|
.filter((entry) => /%[A-Z0-9_]+%|payload|工具/i.test(entry.content));
|
||||||
|
}
|
||||||
|
|
||||||
|
function protocolEntryTexts(preset: PresetPackage): string[] {
|
||||||
|
return protocolEntries(preset).map((entry) => entry.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从协议条目里挑正文:先看条目名像不像(工具说明 / 字段说明),再看正文是否提到关键词。
|
||||||
|
*/
|
||||||
|
function pickProtocolEntry(
|
||||||
|
preset: PresetPackage,
|
||||||
|
keyword: string,
|
||||||
|
nameHints: RegExp[] = [],
|
||||||
|
): { id: string; content: string } | null {
|
||||||
|
const entries = protocolEntries(preset);
|
||||||
|
for (const hint of nameHints) {
|
||||||
|
const byName = entries.find((entry) => hint.test(entry.name));
|
||||||
|
if (byName) return byName;
|
||||||
|
}
|
||||||
|
const hit = entries.find((entry) => entry.content.includes(keyword));
|
||||||
|
return hit ?? entries[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromProtocol(preset: PresetPackage): PresetToolDeclaration | null {
|
||||||
|
const plan = resolveToolPlan(preset);
|
||||||
|
if (!plan.present) return null;
|
||||||
|
const name = protocolToolName(preset);
|
||||||
|
const fields = plan.payloadFields.length > 0 ? plan.payloadFields : ["content"];
|
||||||
|
const properties: Record<string, unknown> = {};
|
||||||
|
const consumed = new Set<string>();
|
||||||
|
for (const field of fields) {
|
||||||
|
const fieldEntry = pickProtocolEntry(preset, field, [
|
||||||
|
/字段说明|field.?desc/i,
|
||||||
|
/字段|参数|field|param/i,
|
||||||
|
]);
|
||||||
|
const description = expandPresetDirectives(fieldEntry?.content ?? "", {}).text.trim();
|
||||||
|
if (fieldEntry) consumed.add(fieldEntry.id);
|
||||||
|
properties[field] = description ? { type: "string", description } : { type: "string" };
|
||||||
|
}
|
||||||
|
const toolEntry = pickProtocolEntry(preset, name, [
|
||||||
|
/工具说明|tool.?desc/i,
|
||||||
|
/工具|tool/i,
|
||||||
|
]);
|
||||||
|
const description =
|
||||||
|
expandPresetDirectives(toolEntry?.content ?? "", {}).text.trim() || plan.description;
|
||||||
|
if (toolEntry) consumed.add(toolEntry.id);
|
||||||
|
return {
|
||||||
|
source: "protocol",
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
parameters: { type: "object", properties, required: fields },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toolChoice: "auto",
|
||||||
|
payloadFields: fields,
|
||||||
|
consumedEntryIds: [...consumed],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function declarePresetTools(
|
||||||
|
preset: PresetPackage,
|
||||||
|
): PresetToolDeclaration | null {
|
||||||
|
return fromBindings(preset) ?? fromProtocol(preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具调用结果里把正文抽回来(按声明的字段优先级)。 */
|
||||||
|
export function extractDeclaredPayload(
|
||||||
|
args: string,
|
||||||
|
payloadFields: string[],
|
||||||
|
): string | null {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(args);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const record = asRecord(parsed);
|
||||||
|
if (!record) return null;
|
||||||
|
const order = payloadFields.length > 0 ? payloadFields : ["content"];
|
||||||
|
for (const field of order) {
|
||||||
|
const value = record[field];
|
||||||
|
if (typeof value === "string" && value.trim()) return value;
|
||||||
|
}
|
||||||
|
for (const value of Object.values(record)) {
|
||||||
|
if (typeof value === "string" && value.trim()) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
/**
|
||||||
|
* 预设的工具协议适配(toolPolicy)。
|
||||||
|
*
|
||||||
|
* 两种真实形态:
|
||||||
|
* 1. `SPreset.ToolBindings` 明确声明工具(如 Reborn 的 `game_content(content)`);
|
||||||
|
* 2. 只在条目正文里写占位符(如 linux 的 `%TOOL_NAME%` + `display_stream`)。
|
||||||
|
*
|
||||||
|
* 目标:不新增业务工具,让预设要求的「调一次工具、正文放参数里」在已有工具上成立。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ToolDefinition } from "../llm/client.js";
|
||||||
|
import type { ParsedToolCall } from "../llm/client.js";
|
||||||
|
import type { PresetPackage } from "../types/preset.js";
|
||||||
|
import { buildPresetDiagnostics, orderedEnabledEntries } from "./diagnostics.js";
|
||||||
|
|
||||||
|
export type ToolPolicyMode = "adapt" | "app" | "preset";
|
||||||
|
|
||||||
|
/** 正文参数的常见别名:预设用哪个,我们都能收。 */
|
||||||
|
const COMMON_PAYLOAD_FIELDS = new Set([
|
||||||
|
"content",
|
||||||
|
"text",
|
||||||
|
"output",
|
||||||
|
"payload",
|
||||||
|
"reply",
|
||||||
|
"message",
|
||||||
|
"display_stream",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只有「被赋值」才有资格当字段名:`stream_target = display_stream`。
|
||||||
|
* 光凭正文里出现过 `content` 不算(那是 `<content>` 标签)。
|
||||||
|
*/
|
||||||
|
const ASSIGNED_FIELD_RE =
|
||||||
|
/\b(?:stream_target|payload_slot|output_channel|output_parameter|output_sink|parameter|target_sink)\s*=\s*"?([A-Za-z_][A-Za-z0-9_]*)"?/g;
|
||||||
|
|
||||||
|
/** `dispatch_stream_payload_34d9…` 这类是执行宿标识,不是参数名。 */
|
||||||
|
function looksLikeFieldName(value: string): boolean {
|
||||||
|
if (!/^[a-z][a-z0-9_]{1,40}$/.test(value)) return false;
|
||||||
|
if (/_[0-9a-f]{8,}$/i.test(value)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 看起来像「提交产物」的工具。 */
|
||||||
|
const HOST_TOOL_HINT = /submit|write|reply|present|deliver|output/i;
|
||||||
|
|
||||||
|
export type PresetToolPlan = {
|
||||||
|
/** 预设明确要求的工具名(`%TOOL_NAME%` 无法解析时为 null) */
|
||||||
|
targetName: string | null;
|
||||||
|
/** 正文可能落在哪些参数名上 */
|
||||||
|
payloadFields: string[];
|
||||||
|
/** 预设自己写的工具说明 + 字段说明,作为染色用的描述文本 */
|
||||||
|
description: string;
|
||||||
|
/** 有没有工具协议(没有就不动 tools) */
|
||||||
|
present: boolean;
|
||||||
|
/** 伪工具协议条目的 id(`toolPolicy = app` 时用来排除) */
|
||||||
|
protocolEntryIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const planCache = new Map<string, PresetToolPlan>();
|
||||||
|
|
||||||
|
function planKey(preset: PresetPackage): string {
|
||||||
|
return `${preset.id}@${preset.importedAt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预设被改写(改条目 / 改生成参数 / 删)后必须重建 plan,
|
||||||
|
* 否则导入时算好的工具映射会一直生效。
|
||||||
|
*/
|
||||||
|
export function clearToolPlanCache(id?: string): void {
|
||||||
|
if (!id) {
|
||||||
|
planCache.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const key of [...planCache.keys()]) {
|
||||||
|
if (key.startsWith(`${id}@`)) planCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveToolPlan(preset: PresetPackage): PresetToolPlan {
|
||||||
|
const key = planKey(preset);
|
||||||
|
const cached = planCache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const diagnostics = buildPresetDiagnostics(preset, preset.raw);
|
||||||
|
const bindings = diagnostics.toolProtocol.bindings;
|
||||||
|
const placeholders = diagnostics.toolProtocol.placeholders;
|
||||||
|
const protocolEntries = new Set(diagnostics.toolProtocol.entries);
|
||||||
|
const protocolText = orderedEnabledEntries(preset)
|
||||||
|
.filter((e) => protocolEntries.has(e.name.trim() || e.id))
|
||||||
|
.map((e) => e.content)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const payloadFields = new Set<string>();
|
||||||
|
for (const binding of bindings) {
|
||||||
|
for (const param of binding.params) payloadFields.add(param);
|
||||||
|
}
|
||||||
|
for (const placeholder of placeholders) {
|
||||||
|
// `%TOOL_NAME%` 是工具名占位符,其余长得像字段名的才算
|
||||||
|
if (/^%[A-Z_]+%$/.test(placeholder)) continue;
|
||||||
|
if (!looksLikeFieldName(placeholder)) continue;
|
||||||
|
if (COMMON_PAYLOAD_FIELDS.has(placeholder)) payloadFields.add(placeholder);
|
||||||
|
}
|
||||||
|
// 常见别名只在预设里真的被当成参数赋值过时才加,避免给工具塞一堆无用参数
|
||||||
|
for (const match of protocolText.matchAll(ASSIGNED_FIELD_RE)) {
|
||||||
|
const field = match[1]!;
|
||||||
|
if (looksLikeFieldName(field) && COMMON_PAYLOAD_FIELDS.has(field)) {
|
||||||
|
payloadFields.add(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payloadFields.size === 0 && diagnostics.toolProtocol.entries.length > 0) {
|
||||||
|
payloadFields.add("content");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 描述只取 ToolBindings 里作者写的 short description。
|
||||||
|
// 不把条目正文整个抄进工具描述——那会把 prompt 体积翻倍(预设正文已经在 messages 里)。
|
||||||
|
const declared = bindings
|
||||||
|
.map((b) => b.description)
|
||||||
|
.filter((d) => d.trim().length > 0);
|
||||||
|
const description =
|
||||||
|
declared.length > 0
|
||||||
|
? declared.join("\n\n")
|
||||||
|
: payloadFields.size > 0
|
||||||
|
? `把完整正文放进 ${[...payloadFields].join(" / ")} 参数。`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const plan: PresetToolPlan = {
|
||||||
|
targetName: bindings[0]?.toolName ?? null,
|
||||||
|
payloadFields: [...payloadFields],
|
||||||
|
description,
|
||||||
|
present:
|
||||||
|
bindings.length > 0 ||
|
||||||
|
diagnostics.toolProtocol.entries.length > 0 ||
|
||||||
|
placeholders.length > 0,
|
||||||
|
protocolEntryIds: collectProtocolEntryIds(preset, diagnostics),
|
||||||
|
};
|
||||||
|
planCache.set(key, plan);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectProtocolEntryIds(
|
||||||
|
preset: PresetPackage,
|
||||||
|
diagnostics: ReturnType<typeof buildPresetDiagnostics>,
|
||||||
|
): string[] {
|
||||||
|
const names = new Set<string>([
|
||||||
|
...diagnostics.toolProtocol.entries,
|
||||||
|
...diagnostics.toolProtocol.bindings.map((b) => b.entry),
|
||||||
|
]);
|
||||||
|
if (names.size === 0) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const entry of preset.prompts) {
|
||||||
|
const label = entry.name.trim() || entry.sourceIdentifier || entry.id;
|
||||||
|
if (names.has(label)) out.push(entry.id);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 伪工具协议条目的 id 集合,供 `app` 模式排除。走 plan 缓存,不重复扫正文。 */
|
||||||
|
export function toolProtocolEntryIds(preset: PresetPackage): Set<string> {
|
||||||
|
return new Set(resolveToolPlan(preset).protocolEntryIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolAlias = {
|
||||||
|
/** 预设要求的工具名(模型可能真的这么调用) */
|
||||||
|
from: string;
|
||||||
|
/** 落到我们哪个工具上 */
|
||||||
|
to: string;
|
||||||
|
/** 正文可能落在哪些参数上 */
|
||||||
|
payloadFields: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 宿主工具的字段别名:模型填了兼容字段也要能收。 */
|
||||||
|
export type PayloadAlias = {
|
||||||
|
tool: string;
|
||||||
|
primary: string;
|
||||||
|
fields: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolDecoration = {
|
||||||
|
tools: ToolDefinition[];
|
||||||
|
aliases: ToolAlias[];
|
||||||
|
payloadAliases: PayloadAlias[];
|
||||||
|
notes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function firstRequiredStringField(tool: ToolDefinition): string | null {
|
||||||
|
const params = tool.function.parameters as
|
||||||
|
| { properties?: Record<string, unknown>; required?: unknown }
|
||||||
|
| undefined;
|
||||||
|
const required = Array.isArray(params?.required) ? params!.required : [];
|
||||||
|
const properties = (params?.properties ?? {}) as Record<string, unknown>;
|
||||||
|
for (const name of required) {
|
||||||
|
if (typeof name !== "string") continue;
|
||||||
|
const prop = properties[name] as { type?: unknown } | undefined;
|
||||||
|
if (prop?.type === "string") return name;
|
||||||
|
}
|
||||||
|
for (const [name, prop] of Object.entries(properties)) {
|
||||||
|
if ((prop as { type?: unknown } | undefined)?.type === "string") return name;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickHostTool(tools: ToolDefinition[]): ToolDefinition | null {
|
||||||
|
if (tools.length === 0) return null;
|
||||||
|
return tools.find((t) => HOST_TOOL_HINT.test(t.function.name)) ?? tools[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把预设要求的字段名补进宿主工具的 schema(只加可选字段,不改 required)。 */
|
||||||
|
function withAliasFields(
|
||||||
|
tool: ToolDefinition,
|
||||||
|
payloadFields: string[],
|
||||||
|
): ToolDefinition {
|
||||||
|
const params = (tool.function.parameters ?? {}) as {
|
||||||
|
type?: string;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
additionalProperties?: boolean;
|
||||||
|
};
|
||||||
|
const properties = { ...(params.properties ?? {}) };
|
||||||
|
let changed = false;
|
||||||
|
for (const field of payloadFields) {
|
||||||
|
if (properties[field]) continue;
|
||||||
|
properties[field] = {
|
||||||
|
type: "string",
|
||||||
|
description: "正文载荷(预设要求的兼容字段名,与主字段同义)",
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!changed) return tool;
|
||||||
|
return {
|
||||||
|
...tool,
|
||||||
|
function: {
|
||||||
|
...tool.function,
|
||||||
|
parameters: { ...params, type: params.type ?? "object", properties },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadTool(
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
payloadFields: string[],
|
||||||
|
): ToolDefinition {
|
||||||
|
const properties: Record<string, unknown> = {};
|
||||||
|
for (const field of payloadFields.length > 0 ? payloadFields : ["content"]) {
|
||||||
|
properties[field] = { type: "string" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties,
|
||||||
|
additionalProperties: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按预设要求调整本次请求的工具表。
|
||||||
|
* - app:原样(预设的伪工具协议条目应在装配时排除)
|
||||||
|
* - adapt:优先染色宿主工具;宿主不存在时补一个载荷工具
|
||||||
|
* - preset:只发预设声明的那个工具
|
||||||
|
*/
|
||||||
|
export function decorateTools(params: {
|
||||||
|
mode: ToolPolicyMode;
|
||||||
|
tools: ToolDefinition[];
|
||||||
|
plan: PresetToolPlan;
|
||||||
|
}): ToolDecoration {
|
||||||
|
const { mode, tools, plan } = params;
|
||||||
|
const notes: string[] = [];
|
||||||
|
const empty = { tools, aliases: [], payloadAliases: [], notes };
|
||||||
|
|
||||||
|
if (!plan.present || mode === "app") {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "preset") {
|
||||||
|
const name = plan.targetName ?? "preset_payload";
|
||||||
|
notes.push(`preset 模式:只发预设工具 ${name}`);
|
||||||
|
return {
|
||||||
|
tools: [payloadTool(name, plan.description, plan.payloadFields)],
|
||||||
|
aliases: [],
|
||||||
|
payloadAliases: [],
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = pickHostTool(tools);
|
||||||
|
const aliases: ToolAlias[] = [];
|
||||||
|
const payloadAliases: PayloadAlias[] = [];
|
||||||
|
|
||||||
|
if (host) {
|
||||||
|
const primary = firstRequiredStringField(host);
|
||||||
|
const decorated = withAliasFields(host, plan.payloadFields);
|
||||||
|
const withDescription: ToolDefinition = plan.description
|
||||||
|
? {
|
||||||
|
...decorated,
|
||||||
|
function: {
|
||||||
|
...decorated.function,
|
||||||
|
description: `${decorated.function.description}\n\n${plan.description}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: decorated;
|
||||||
|
|
||||||
|
if (primary) {
|
||||||
|
payloadAliases.push({
|
||||||
|
tool: host.function.name,
|
||||||
|
primary,
|
||||||
|
fields: plan.payloadFields.filter((f) => f !== primary),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = tools.map((t) => (t === host ? withDescription : t));
|
||||||
|
const aliasName = plan.targetName;
|
||||||
|
if (aliasName && aliasName !== host.function.name) {
|
||||||
|
// 预设写的工具名对不上我们的:挂一个同名同参的载荷工具,
|
||||||
|
// 收到调用时再折算到宿主工具上。
|
||||||
|
next.push(payloadTool(aliasName, plan.description, plan.payloadFields));
|
||||||
|
aliases.push({
|
||||||
|
from: aliasName,
|
||||||
|
to: host.function.name,
|
||||||
|
payloadFields: plan.payloadFields,
|
||||||
|
});
|
||||||
|
notes.push(
|
||||||
|
`adapt:${aliasName} → ${host.function.name}(正文取 ${plan.payloadFields.join("/")})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
notes.push(
|
||||||
|
`adapt:染色 ${host.function.name}(正文可填 ${plan.payloadFields.join("/")})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { tools: next, aliases, payloadAliases, notes };
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = plan.targetName ?? "preset_payload";
|
||||||
|
notes.push(`adapt:没有宿主工具,单独挂 ${name}`);
|
||||||
|
return {
|
||||||
|
tools: [payloadTool(name, plan.description, plan.payloadFields)],
|
||||||
|
aliases: [],
|
||||||
|
payloadAliases: [],
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把模型对「预设工具名」的调用折算到宿主工具上:
|
||||||
|
* 别名换成宿主名,正文参数换成宿主工具的主字段,其余字段保留。
|
||||||
|
*/
|
||||||
|
export function normalizeToolCalls(
|
||||||
|
calls: ParsedToolCall[],
|
||||||
|
decoration: ToolDecoration,
|
||||||
|
tools: ToolDefinition[],
|
||||||
|
): ParsedToolCall[] {
|
||||||
|
if (decoration.aliases.length === 0 && decoration.payloadAliases.length === 0) {
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
return calls.map((call) => {
|
||||||
|
const alias = decoration.aliases.find(
|
||||||
|
(a) => a.from.toLowerCase() === call.name.toLowerCase(),
|
||||||
|
);
|
||||||
|
const targetName = alias?.to ?? call.name;
|
||||||
|
const host =
|
||||||
|
tools.find((t) => t.function.name === targetName) ??
|
||||||
|
decoration.tools.find((t) => t.function.name === targetName);
|
||||||
|
const payloadAlias = decoration.payloadAliases.find(
|
||||||
|
(a) => a.tool === targetName,
|
||||||
|
);
|
||||||
|
if (!host || !payloadAlias) {
|
||||||
|
return alias ? { ...call, name: targetName } : call;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(call.arguments || "{}") as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return alias ? { ...call, name: targetName } : call;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = parsed[payloadAlias.primary];
|
||||||
|
const hasPrimary =
|
||||||
|
typeof current === "string" && current.trim().length > 0;
|
||||||
|
const aliasField = payloadAlias.fields.find((field) => {
|
||||||
|
const value = parsed[field];
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!aliasField) {
|
||||||
|
return alias
|
||||||
|
? { ...call, name: targetName, arguments: JSON.stringify(parsed) }
|
||||||
|
: call;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: Record<string, unknown> = { ...parsed };
|
||||||
|
const value = next[aliasField];
|
||||||
|
delete next[aliasField];
|
||||||
|
if (!hasPrimary && typeof value === "string") {
|
||||||
|
next[payloadAlias.primary] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...call,
|
||||||
|
name: targetName,
|
||||||
|
arguments: JSON.stringify(next),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { getPresetsDir } from "../config/user-data-dir.js";
|
||||||
|
import type { PresetPackage } from "../types/preset.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预设版本树:每个预设一个 JSON 文件,节点是完整快照。
|
||||||
|
* - 保存:在指定父节点(缺省为当前节点)下追加子节点;
|
||||||
|
* - 分叉:父节点选历史中的任意一个,形成新分支,原分支不动;
|
||||||
|
* - 追溯:沿 parentId 往上走即为修改历史。
|
||||||
|
* 不依赖 git,不做远程同步。
|
||||||
|
*/
|
||||||
|
export type PresetVersionNode = {
|
||||||
|
id: string;
|
||||||
|
parentId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
/** 被「保存至当前版本」覆盖过的时间;没改过就不写 */
|
||||||
|
updatedAt?: string;
|
||||||
|
note: string;
|
||||||
|
preset: PresetPackage;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PresetVersionTree = {
|
||||||
|
presetId: string;
|
||||||
|
currentId: string | null;
|
||||||
|
nodes: PresetVersionNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAFE_ID = /^[\w\u4e00-\u9fff-]+$/;
|
||||||
|
|
||||||
|
function versionsDir(): string {
|
||||||
|
return path.join(getPresetsDir(), "versions");
|
||||||
|
}
|
||||||
|
|
||||||
|
function treeFile(presetId: string): string {
|
||||||
|
if (!SAFE_ID.test(presetId)) throw new Error(`非法预设 id:${presetId}`);
|
||||||
|
return path.join(versionsDir(), `${presetId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTree(tree: PresetVersionTree): void {
|
||||||
|
mkdirSync(versionsDir(), { recursive: true });
|
||||||
|
writeFileSync(treeFile(tree.presetId), JSON.stringify(tree, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadVersionTree(presetId: string): PresetVersionTree {
|
||||||
|
const file = treeFile(presetId);
|
||||||
|
if (!existsSync(file)) return { presetId, currentId: null, nodes: [] };
|
||||||
|
return JSON.parse(readFileSync(file, "utf8")) as PresetVersionTree;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVersionNode(
|
||||||
|
presetId: string,
|
||||||
|
nodeId: string,
|
||||||
|
): PresetVersionNode | null {
|
||||||
|
return loadVersionTree(presetId).nodes.find((n) => n.id === nodeId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从根到指定节点的路径(含两端),即该版本的修改历史。 */
|
||||||
|
export function versionLineage(
|
||||||
|
tree: PresetVersionTree,
|
||||||
|
nodeId: string,
|
||||||
|
): PresetVersionNode[] {
|
||||||
|
const byId = new Map(tree.nodes.map((n) => [n.id, n]));
|
||||||
|
const out: PresetVersionNode[] = [];
|
||||||
|
let cursor = byId.get(nodeId) ?? null;
|
||||||
|
const guard = new Set<string>();
|
||||||
|
while (cursor && !guard.has(cursor.id)) {
|
||||||
|
guard.add(cursor.id);
|
||||||
|
out.unshift(cursor);
|
||||||
|
cursor = cursor.parentId ? byId.get(cursor.parentId) ?? null : null;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交一个版本。
|
||||||
|
* parentId 缺省为当前节点;显式传入历史节点即为分叉。
|
||||||
|
* 树为空时 parentId 必须为 null(建根);已有根时不允许再建第二个根。
|
||||||
|
*/
|
||||||
|
export function commitVersion(input: {
|
||||||
|
presetId: string;
|
||||||
|
preset: PresetPackage;
|
||||||
|
note?: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
}): PresetVersionTree {
|
||||||
|
const tree = loadVersionTree(input.presetId);
|
||||||
|
const parentId =
|
||||||
|
input.parentId !== undefined ? input.parentId : tree.currentId;
|
||||||
|
|
||||||
|
if (parentId === null) {
|
||||||
|
if (tree.nodes.length > 0) throw new Error("已有版本,不能再新建根版本");
|
||||||
|
} else if (!tree.nodes.some((n) => n.id === parentId)) {
|
||||||
|
throw new Error(`父版本不存在:${parentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = randomUUID().slice(0, 8);
|
||||||
|
while (tree.nodes.some((n) => n.id === id)) id = randomUUID().slice(0, 8);
|
||||||
|
const node: PresetVersionNode = {
|
||||||
|
id,
|
||||||
|
parentId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
note: (input.note ?? "").trim(),
|
||||||
|
preset: structuredClone(input.preset),
|
||||||
|
};
|
||||||
|
tree.nodes.push(node);
|
||||||
|
tree.currentId = node.id;
|
||||||
|
writeTree(tree);
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存到当前版本(amend):把快照直接换掉 `currentId` 那个节点,不产生新节点。
|
||||||
|
* 还没有任何版本时退化成建根。
|
||||||
|
*/
|
||||||
|
export function amendCurrentVersion(input: {
|
||||||
|
presetId: string;
|
||||||
|
preset: PresetPackage;
|
||||||
|
note?: string;
|
||||||
|
}): PresetVersionTree {
|
||||||
|
const tree = loadVersionTree(input.presetId);
|
||||||
|
if (!tree.currentId) return commitVersion({ ...input, parentId: null });
|
||||||
|
const node = tree.nodes.find((item) => item.id === tree.currentId);
|
||||||
|
if (!node) throw new Error(`当前版本不存在:${tree.currentId}`);
|
||||||
|
node.preset = structuredClone(input.preset);
|
||||||
|
const note = (input.note ?? "").trim();
|
||||||
|
if (note) node.note = note;
|
||||||
|
node.updatedAt = new Date().toISOString();
|
||||||
|
writeTree(tree);
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换当前编辑位置(不产生新节点)。 */
|
||||||
|
export function checkoutVersion(presetId: string, nodeId: string): PresetVersionTree {
|
||||||
|
const tree = loadVersionTree(presetId);
|
||||||
|
if (!tree.nodes.some((n) => n.id === nodeId)) {
|
||||||
|
throw new Error(`版本不存在:${nodeId}`);
|
||||||
|
}
|
||||||
|
tree.currentId = nodeId;
|
||||||
|
writeTree(tree);
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteVersionTree(presetId: string): void {
|
||||||
|
try {
|
||||||
|
rmSync(treeFile(presetId), { force: true });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { Blackboard } from "../blackboard/blackboard.js";
|
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||||
import type { BlackboardInputMerge } from "../types/blackboard.js";
|
import type { BlackboardInputMerge } from "../types/blackboard.js";
|
||||||
|
import type { ChatMessage } from "../llm/client.js";
|
||||||
import {
|
import {
|
||||||
|
CURRENT_TURN_TAG,
|
||||||
|
isCurrentTurnRef,
|
||||||
isDialogueHistoryRef,
|
isDialogueHistoryRef,
|
||||||
|
parseHistoryMessages,
|
||||||
DIALOGUE_HISTORY_TAG,
|
DIALOGUE_HISTORY_TAG,
|
||||||
} from "../skills/dialogue-history.js";
|
} from "../skills/dialogue-history.js";
|
||||||
import {
|
import {
|
||||||
@@ -11,24 +15,16 @@ import {
|
|||||||
import {
|
import {
|
||||||
CHAT_HISTORY_MARKER,
|
CHAT_HISTORY_MARKER,
|
||||||
CURRENT_TURN,
|
CURRENT_TURN,
|
||||||
|
WORLD_INFO_AFTER_MARKER,
|
||||||
|
WORLD_INFO_BEFORE_MARKER,
|
||||||
POST_TURN,
|
POST_TURN,
|
||||||
WORLD_BOOK_AFTER,
|
WORLD_BOOK_AFTER,
|
||||||
WORLD_BOOK_BEFORE,
|
WORLD_BOOK_BEFORE,
|
||||||
isStUnfilledMarker,
|
isStUnfilledMarker,
|
||||||
} from "./markers.js";
|
} from "./markers.js";
|
||||||
|
|
||||||
export const CURRENT_TURN_TAG = "用户.最新输入";
|
/** 兼容旧的导入点:历史相关标签判定归 `skills/dialogue-history.ts`。 */
|
||||||
|
export { CURRENT_TURN_TAG, isCurrentTurnRef };
|
||||||
const CURRENT_TURN_ALIASES = new Set([
|
|
||||||
CURRENT_TURN_TAG,
|
|
||||||
"currentTurn",
|
|
||||||
"user.latest",
|
|
||||||
"用户最新输入",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function isCurrentTurnRef(ref: string): boolean {
|
|
||||||
return CURRENT_TURN_ALIASES.has(ref.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorldInfoEntry = {
|
export type WorldInfoEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,8 +34,17 @@ export type WorldInfoEntry = {
|
|||||||
|
|
||||||
export type WorldInfoPack = {
|
export type WorldInfoPack = {
|
||||||
worldBookBefore: WorldInfoEntry[];
|
worldBookBefore: WorldInfoEntry[];
|
||||||
history: string;
|
/** 「对话.历史」按块解析成真多轮 messages(非文本块);装配时直接进 chatHistory 洞。 */
|
||||||
|
history: ChatMessage[];
|
||||||
worldBookAfter: WorldInfoEntry[];
|
worldBookAfter: WorldInfoEntry[];
|
||||||
|
/** 酒馆 mes_example 同格式的示例对话(用 <START> 分块),装配时进 dialogueExamples 洞。 */
|
||||||
|
exampleDialogue?: string;
|
||||||
|
/** 角色卡三块(酒馆 charDescription / charPersonality / scenario 洞)。 */
|
||||||
|
card?: {
|
||||||
|
description?: string;
|
||||||
|
personality?: string;
|
||||||
|
scenario?: string;
|
||||||
|
};
|
||||||
turn: string;
|
turn: string;
|
||||||
postTurn: WorldInfoEntry[];
|
postTurn: WorldInfoEntry[];
|
||||||
};
|
};
|
||||||
@@ -47,7 +52,7 @@ export type WorldInfoPack = {
|
|||||||
function emptyPack(): WorldInfoPack {
|
function emptyPack(): WorldInfoPack {
|
||||||
return {
|
return {
|
||||||
worldBookBefore: [],
|
worldBookBefore: [],
|
||||||
history: "",
|
history: [],
|
||||||
worldBookAfter: [],
|
worldBookAfter: [],
|
||||||
turn: "",
|
turn: "",
|
||||||
postTurn: [],
|
postTurn: [],
|
||||||
@@ -103,7 +108,11 @@ export function worldInfoPackFromSegments(params: {
|
|||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
if (isHist) {
|
if (isHist) {
|
||||||
pack.history = body;
|
pack.history = parseHistoryMessages(body);
|
||||||
|
if (pack.history.length === 0 && body.trim()) {
|
||||||
|
// 没有 `## 用户/## 助手` 块头:当作一段上下文,别默默丢掉。
|
||||||
|
pack.history = [{ role: "system", content: body.trim() }];
|
||||||
|
}
|
||||||
phase = "after";
|
phase = "after";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -119,8 +128,14 @@ export function worldInfoPackFromSegments(params: {
|
|||||||
else pack.postTurn.push(entry);
|
else pack.postTurn.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pack.history.trim() && params.inputs[DIALOGUE_HISTORY_TAG]?.trim()) {
|
if (pack.history.length === 0) {
|
||||||
pack.history = params.inputs[DIALOGUE_HISTORY_TAG]!.trim();
|
const fallback = params.inputs[DIALOGUE_HISTORY_TAG]?.trim();
|
||||||
|
if (fallback) {
|
||||||
|
pack.history = parseHistoryMessages(fallback);
|
||||||
|
if (pack.history.length === 0) {
|
||||||
|
pack.history = [{ role: "system", content: fallback }];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!pack.turn.trim() && params.inputs[CURRENT_TURN_TAG]?.trim()) {
|
if (!pack.turn.trim() && params.inputs[CURRENT_TURN_TAG]?.trim()) {
|
||||||
pack.turn = params.inputs[CURRENT_TURN_TAG]!.trim();
|
pack.turn = params.inputs[CURRENT_TURN_TAG]!.trim();
|
||||||
@@ -135,13 +150,16 @@ export function resolveWorldInfoMarker(
|
|||||||
): string | null {
|
): string | null {
|
||||||
if (isStUnfilledMarker(identifier)) return null;
|
if (isStUnfilledMarker(identifier)) return null;
|
||||||
switch (identifier) {
|
switch (identifier) {
|
||||||
case WORLD_BOOK_BEFORE: {
|
case WORLD_BOOK_BEFORE:
|
||||||
|
case WORLD_INFO_BEFORE_MARKER: {
|
||||||
const text = formatWorldInfoEntries(pack.worldBookBefore);
|
const text = formatWorldInfoEntries(pack.worldBookBefore);
|
||||||
return text || null;
|
return text || null;
|
||||||
}
|
}
|
||||||
case CHAT_HISTORY_MARKER:
|
case CHAT_HISTORY_MARKER:
|
||||||
return pack.history.trim() || null;
|
// 历史是 message 序列,由 assembler 按块 splice,不走字符串填洞。
|
||||||
case WORLD_BOOK_AFTER: {
|
return null;
|
||||||
|
case WORLD_BOOK_AFTER:
|
||||||
|
case WORLD_INFO_AFTER_MARKER: {
|
||||||
const text = formatWorldInfoEntries(pack.worldBookAfter);
|
const text = formatWorldInfoEntries(pack.worldBookAfter);
|
||||||
return text || null;
|
return text || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
ToolDefinition,
|
ToolDefinition,
|
||||||
} from "../llm/client.js";
|
} from "../llm/client.js";
|
||||||
import { supportsToolStream } from "../llm/stream-complete.js";
|
import { supportsToolStream } from "../llm/stream-complete.js";
|
||||||
|
import type { WorldInfoPack } from "../preset/world-info-pack.js";
|
||||||
|
|
||||||
export type DriverToolCall = {
|
export type DriverToolCall = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,6 +31,8 @@ export type DriverRunInput = {
|
|||||||
system: string;
|
system: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
tools: ToolDefinition[];
|
tools: ToolDefinition[];
|
||||||
|
/** 真多轮分袋:原样透给包装层(不进上游请求体) */
|
||||||
|
presetPack?: WorldInfoPack;
|
||||||
caller?: string;
|
caller?: string;
|
||||||
/** 日志用短名,如「总管循环」 */
|
/** 日志用短名,如「总管循环」 */
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -99,12 +102,15 @@ async function completeToolsPreferStream(
|
|||||||
caller: string | undefined,
|
caller: string | undefined,
|
||||||
label: string | undefined,
|
label: string | undefined,
|
||||||
callbacks: StreamCallbacks,
|
callbacks: StreamCallbacks,
|
||||||
|
presetPack?: WorldInfoPack,
|
||||||
): Promise<CompleteWithToolsResult> {
|
): Promise<CompleteWithToolsResult> {
|
||||||
const options = { tools, caller, label };
|
const options = { tools, caller, label, ...(presetPack ? { presetPack } : {}) };
|
||||||
if (supportsToolStream(llm)) {
|
if (supportsToolStream(llm)) {
|
||||||
return llm.completeWithToolsStream!(messages, options, callbacks);
|
return llm.completeWithToolsStream!(messages, options, callbacks);
|
||||||
}
|
}
|
||||||
const result = await llm.completeWithTools(messages, options);
|
// `supportsToolStream` 的特性判定会把 else 分支窄成 never,这里显式退回。
|
||||||
|
const plain = llm as LlmProvider;
|
||||||
|
const result = await plain.completeWithTools(messages, options);
|
||||||
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
|
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
|
||||||
if (result.content) callbacks.onContentDelta?.(result.content);
|
if (result.content) callbacks.onContentDelta?.(result.content);
|
||||||
return result;
|
return result;
|
||||||
@@ -133,6 +139,7 @@ export function createLocalLlmDriver(llm: LlmProvider): AgentDriver {
|
|||||||
input.caller,
|
input.caller,
|
||||||
`${label} 第${iteration}轮`,
|
`${label} 第${iteration}轮`,
|
||||||
streamCallbacks,
|
streamCallbacks,
|
||||||
|
input.presetPack,
|
||||||
);
|
);
|
||||||
emitThinkingDone(input, result.reasoning);
|
emitThinkingDone(input, result.reasoning);
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ import {
|
|||||||
isInstanceStep,
|
isInstanceStep,
|
||||||
findStepByRef,
|
findStepByRef,
|
||||||
findModuleByName,
|
findModuleByName,
|
||||||
|
legacyTagsRetiredByWrite,
|
||||||
|
readModuleArtifactContent,
|
||||||
hasAcceptedCloserStep,
|
hasAcceptedCloserStep,
|
||||||
parseCreationFlow,
|
parseCreationFlow,
|
||||||
DESIGN_FLOW_WORKER_ID,
|
DESIGN_FLOW_WORKER_ID,
|
||||||
@@ -1127,15 +1129,18 @@ export class PhaseRuntime {
|
|||||||
const other = findStepByRef(flow, item);
|
const other = findStepByRef(flow, item);
|
||||||
return other?.name === step.name;
|
return other?.name === step.name;
|
||||||
}).length;
|
}).length;
|
||||||
const tag = findModuleByName(catalog, step.name)?.artifact?.trim();
|
const stored = readModuleArtifactContent(
|
||||||
if (tag) {
|
findModuleByName(catalog, step.name),
|
||||||
|
(tag) => this.blackboard.getContentByTag(tag),
|
||||||
|
);
|
||||||
|
if (stored?.content) {
|
||||||
const pruned = pruneRepeatableArtifactContent({
|
const pruned = pruneRepeatableArtifactContent({
|
||||||
raw: this.blackboard.getContentByTag(tag) ?? "",
|
raw: stored.content,
|
||||||
step,
|
step,
|
||||||
remainingSameModule,
|
remainingSameModule,
|
||||||
});
|
});
|
||||||
this.blackboard.write({
|
this.blackboard.write({
|
||||||
tag,
|
tag: stored.tag,
|
||||||
content: pruned,
|
content: pruned,
|
||||||
source: "runtime",
|
source: "runtime",
|
||||||
});
|
});
|
||||||
@@ -1150,7 +1155,9 @@ export class PhaseRuntime {
|
|||||||
[SLOT_CREATION_ACCEPTED_UNITS]: accepted,
|
[SLOT_CREATION_ACCEPTED_UNITS]: accepted,
|
||||||
[CREATION_ACCEPTED_UNITS_TAG]: acceptedRaw,
|
[CREATION_ACCEPTED_UNITS_TAG]: acceptedRaw,
|
||||||
[CREATION_ACCEPTED_CONTENT_TAG]: contentRaw,
|
[CREATION_ACCEPTED_CONTENT_TAG]: contentRaw,
|
||||||
...(tag ? { [tag]: this.blackboard.getContentByTag(tag) ?? "" } : {}),
|
...(stored
|
||||||
|
? { [stored.tag]: this.blackboard.getContentByTag(stored.tag) ?? "" }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -1710,11 +1717,12 @@ export class PhaseRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pendingModule = findModuleByName(catalog, step.name);
|
const pendingModule = findModuleByName(catalog, step.name);
|
||||||
|
const storedArtifact = readModuleArtifactContent(pendingModule, (tag) =>
|
||||||
|
this.blackboard.getContentByTag(tag),
|
||||||
|
);
|
||||||
if (reenter) {
|
if (reenter) {
|
||||||
const artTag = pendingModule?.artifact?.trim();
|
const artTag = storedArtifact?.tag;
|
||||||
const hasArt = Boolean(
|
const hasArt = Boolean(storedArtifact?.content);
|
||||||
artTag && this.blackboard.getContentByTag(artTag)?.trim(),
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
!isStepAccepted(step, accepted) &&
|
!isStepAccepted(step, accepted) &&
|
||||||
!hasArt &&
|
!hasArt &&
|
||||||
@@ -1764,7 +1772,7 @@ export class PhaseRuntime {
|
|||||||
if (reenter) {
|
if (reenter) {
|
||||||
const existing = this.ensureReenterArtifact(
|
const existing = this.ensureReenterArtifact(
|
||||||
pinned.id,
|
pinned.id,
|
||||||
pendingModule?.artifact,
|
storedArtifact?.tag ?? pendingModule?.artifact,
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.reopenArtifactForRevision(existing, pinned);
|
this.reopenArtifactForRevision(existing, pinned);
|
||||||
@@ -2719,6 +2727,11 @@ export class PhaseRuntime {
|
|||||||
content: toWrite,
|
content: toWrite,
|
||||||
source: workerId,
|
source: workerId,
|
||||||
});
|
});
|
||||||
|
if (toWrite.trim()) {
|
||||||
|
for (const oldTag of legacyTagsRetiredByWrite(tag)) {
|
||||||
|
this.blackboard.deleteByTag(oldTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
inferLifecycleStage(this.session) === "play" &&
|
inferLifecycleStage(this.session) === "play" &&
|
||||||
(tag === "输出.用户展示" || tag === "输出.开场白")
|
(tag === "输出.用户展示" || tag === "输出.开场白")
|
||||||
|
|||||||
@@ -693,6 +693,7 @@ export async function handleBooksApi(
|
|||||||
const body = JSON.parse(await readBody(req)) as { title?: string };
|
const body = JSON.parse(await readBody(req)) as { title?: string };
|
||||||
try {
|
try {
|
||||||
const book = updateBook(bookId, { title: body.title?.trim() });
|
const book = updateBook(bookId, { title: body.title?.trim() });
|
||||||
|
sessionManager.syncBookTitle(bookId, book.title);
|
||||||
json(res, 200, { book });
|
json(res, 200, { book });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
json(res, 404, {
|
json(res, 404, {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user