预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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` 由服务端持久化)→ 仍是默认折叠。
|
||||||
@@ -50,6 +50,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 会提示一行)。
|
||||||
|
|
||||||
## 维护者
|
## 维护者
|
||||||
|
|
||||||
开发与排障常用命令:
|
开发与排障常用命令:
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ Worker / skill 执行时,Runtime 将黑板 tag 与固定体裁说明拼成 LLM
|
|||||||
「对话.历史」是排序表中的普通标签,按 projection 动态裁剪后插入
|
「对话.历史」是排序表中的普通标签,按 projection 动态裁剪后插入
|
||||||
```
|
```
|
||||||
|
|
||||||
|
「对话.历史」不只是文本块:装配时它被切成**真多轮 message[]**(user / assistant 轮)进预设的 `chatHistory` 洞;
|
||||||
|
分袋时它之前的块进 `worldBookBefore`、之后的进 `worldBookAfter`。
|
||||||
|
|
||||||
`order 0` 建议为槽位人设(`worker.persona`)。
|
`order 0` 建议为槽位人设(`worker.persona`)。
|
||||||
`tier` 仅缓存提示;权威顺序是投影排序表。
|
`tier` 仅缓存提示;权威顺序是投影排序表。
|
||||||
|
|
||||||
@@ -183,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 可编排
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
- 标题栏「新增窗口」:中间按模型分栏(LMSYS Arena 式 side-by-side),每列独立对话流。
|
- 标题栏「新增窗口」:中间按模型分栏(LMSYS Arena 式 side-by-side),每列独立对话流。
|
||||||
- 同一用户句在各列各显一份;发送时所有列并行 completion。
|
- 同一用户句在各列各显一份;发送时所有列并行 completion。
|
||||||
- 列内「保留此回复」:写入该轮 `selectedWindowId`;之后各列发下一轮时,本轮助手侧都以该回复进历史。
|
- 列内「保留此回复」:写入该轮 `selectedWindowId`;之后各列发下一轮时,本轮助手侧都以该回复进历史。保留后该轮界面只显示被保留的那张卡(再点「已保留 · 取消」恢复全部),下一轮仍是每个模型分别请求。
|
||||||
- 未保留:各列继续用自己的回复拼 prior。
|
- 未保留:各列继续用自己的回复拼 prior。
|
||||||
|
|
||||||
## 请求
|
## 请求
|
||||||
|
|||||||
+182
-15
@@ -4,7 +4,11 @@
|
|||||||
|
|
||||||
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 +27,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 +109,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 +237,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 +263,6 @@ postTurn
|
|||||||
社区预设里包着 WI 洞的装饰分隔会留在历史前段,中间可能是空的。第一版接受。
|
社区预设里包着 WI 洞的装饰分隔会留在历史前段,中间可能是空的。第一版接受。
|
||||||
|
|
||||||
### 4.3 六段夹心(扫 order 后的效果)
|
### 4.3 六段夹心(扫 order 后的效果)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. 酒馆内、历史前(ST 普通条目)
|
1. 酒馆内、历史前(ST 普通条目)
|
||||||
2. 我们写的、历史前(worldBookBefore)
|
2. 我们写的、历史前(worldBookBefore)
|
||||||
@@ -276,11 +281,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 +426,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
|
||||||
|
```
|
||||||
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。
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+69
-1
@@ -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,24 @@ export type AppSettings = {
|
|||||||
* 游玩期每隔多少个用户回合尝试采集偏好候选。默认 10;0 = 关闭。
|
* 游玩期每隔多少个用户回合尝试采集偏好候选。默认 10;0 = 关闭。
|
||||||
*/
|
*/
|
||||||
preferenceCollectEveryTurns?: number;
|
preferenceCollectEveryTurns?: number;
|
||||||
|
/**
|
||||||
|
* 直聊卡片拖动灵敏度:鼠标移 1px 实际滚动 N px。默认 1.5;范围 0.5–3。
|
||||||
|
*/
|
||||||
|
chatDragGain?: number;
|
||||||
/**
|
/**
|
||||||
* 对话落盘同一轮里,第一次填写失败后最多再补写几次。
|
* 对话落盘同一轮里,第一次填写失败后最多再补写几次。
|
||||||
* 0 = 只填一次,失败留给下一句用户消息。默认 0。
|
* 0 = 只填一次,失败留给下一句用户消息。默认 0。
|
||||||
*/
|
*/
|
||||||
dictateFillRetries?: number;
|
dictateFillRetries?: number;
|
||||||
|
/**
|
||||||
|
* 发出前的消息整形。默认跟随预设(`follow-preset`)。
|
||||||
|
* 选具体值时覆盖预设自己的 squash_system_messages。
|
||||||
|
*/
|
||||||
|
messageSquash?: MessageSquashSetting;
|
||||||
|
/**
|
||||||
|
* 预设工具协议怎么处理。默认 adapt:把预设要求的工具折算到已有工具上。
|
||||||
|
*/
|
||||||
|
presetToolPolicy?: ToolPolicyMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FILE_NAME = "settings.json";
|
const FILE_NAME = "settings.json";
|
||||||
@@ -29,6 +55,12 @@ 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 DEFAULT_DICTATE_FILL_RETRIES = 0;
|
||||||
const MAX_DICTATE_FILL_RETRIES = 12;
|
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);
|
||||||
@@ -42,9 +74,31 @@ function defaultSettings(): AppSettings {
|
|||||||
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,
|
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;
|
||||||
@@ -57,7 +111,15 @@ export function normalizePreferenceCollectEveryTurns(raw: unknown): number {
|
|||||||
return Math.min(100, Math.floor(n));
|
return Math.min(100, Math.floor(n));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 同轮补写次数。0 = 只填一次;非法值回落到 0。 */
|
/** 拖动增益 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 {
|
export function normalizeDictateFillRetries(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_DICTATE_FILL_RETRIES;
|
if (!Number.isFinite(n) || n < 0) return DEFAULT_DICTATE_FILL_RETRIES;
|
||||||
@@ -83,6 +145,11 @@ export function loadAppSettings(): AppSettings {
|
|||||||
dictateFillRetries: normalizeDictateFillRetries(
|
dictateFillRetries: normalizeDictateFillRetries(
|
||||||
parsed.dictateFillRetries ?? DEFAULT_DICTATE_FILL_RETRIES,
|
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();
|
||||||
@@ -149,3 +216,4 @@ export function ensureActiveProfileDefault(): AppSettings {
|
|||||||
}
|
}
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./types.js";
|
export * from "./types.js";
|
||||||
export * from "./store.js";
|
export * from "./store.js";
|
||||||
export * from "./turn.js";
|
export * from "./turn.js";
|
||||||
|
export * from "./turn-variants.js";
|
||||||
|
|||||||
+137
-26
@@ -147,6 +147,70 @@ function parseWindow(raw: unknown): DirectChatWindow | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function parseTurn(raw: unknown): DirectChatTurn | null {
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||||
const row = raw as Record<string, unknown>;
|
const row = raw as Record<string, unknown>;
|
||||||
@@ -158,32 +222,13 @@ function parseTurn(raw: unknown): DirectChatTurn | null {
|
|||||||
const userRow = userRaw as Record<string, unknown>;
|
const userRow = userRaw as Record<string, unknown>;
|
||||||
const userContent = asString(userRow.content);
|
const userContent = asString(userRow.content);
|
||||||
if (userContent == null) return null;
|
if (userContent == null) return null;
|
||||||
const replies: DirectChatTurn["replies"] = [];
|
const replies = parseReplies(row.replies);
|
||||||
if (Array.isArray(row.replies)) {
|
const selected = parseSelected(row.selectedWindowId);
|
||||||
for (const r of row.replies) {
|
const variants = parseVariants(row.variants);
|
||||||
if (!r || typeof r !== "object" || Array.isArray(r)) continue;
|
const variantIndex =
|
||||||
const rr = r as Record<string, unknown>;
|
typeof row.variantIndex === "number" && Number.isFinite(row.variantIndex)
|
||||||
const windowId = asString(rr.windowId)?.trim();
|
? Math.max(0, Math.floor(row.variantIndex))
|
||||||
const content = asString(rr.content);
|
: undefined;
|
||||||
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"] }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const selected =
|
|
||||||
row.selectedWindowId === null
|
|
||||||
? null
|
|
||||||
: asString(row.selectedWindowId)?.trim() || undefined;
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
user: {
|
user: {
|
||||||
@@ -193,6 +238,12 @@ function parseTurn(raw: unknown): DirectChatTurn | null {
|
|||||||
replies,
|
replies,
|
||||||
...(selected !== undefined ? { selectedWindowId: selected } : {}),
|
...(selected !== undefined ? { selectedWindowId: selected } : {}),
|
||||||
createdAt: asString(row.createdAt) ?? nowIso(),
|
createdAt: asString(row.createdAt) ?? nowIso(),
|
||||||
|
...(variants
|
||||||
|
? {
|
||||||
|
variants,
|
||||||
|
variantIndex: Math.min(variantIndex ?? 0, variants.length - 1),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,6 +497,66 @@ export function deleteConversation(id: string): void {
|
|||||||
if (existsSync(file)) unlinkSync(file);
|
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 {
|
export function saveConversation(conv: DirectChatConversation): DirectChatConversation {
|
||||||
const next = { ...conv, updatedAt: nowIso() };
|
const next = { ...conv, updatedAt: nowIso() };
|
||||||
ensureDirectChatDirs();
|
ensureDirectChatDirs();
|
||||||
|
|||||||
@@ -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)];
|
||||||
|
}
|
||||||
+63
-18
@@ -13,6 +13,8 @@ import {
|
|||||||
import { wrapLlmForSession } from "../llm/preset-wrapper.js";
|
import { wrapLlmForSession } from "../llm/preset-wrapper.js";
|
||||||
import type { LlmTrackingContext } from "../llm/token-tracker.js";
|
import type { LlmTrackingContext } from "../llm/token-tracker.js";
|
||||||
import { assemblePlayWorkerMessages } from "../preset/play-frame.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 { getPreset, resolveActivePreset } from "../preset/store.js";
|
||||||
import type { WorldInfoPack } from "../preset/world-info-pack.js";
|
import type { WorldInfoPack } from "../preset/world-info-pack.js";
|
||||||
import { hasRealLlmConfig } from "../runtime/llm-factory.js";
|
import { hasRealLlmConfig } from "../runtime/llm-factory.js";
|
||||||
@@ -21,8 +23,8 @@ import {
|
|||||||
type LlmContextTrace,
|
type LlmContextTrace,
|
||||||
} from "../types/context-trace.js";
|
} from "../types/context-trace.js";
|
||||||
import type { PresetPackage } from "../types/preset.js";
|
import type { PresetPackage } from "../types/preset.js";
|
||||||
|
import { parseHistoryMessages } from "../skills/dialogue-history.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_DIRECT_CHAT_SYSTEM,
|
|
||||||
buildPriorForWindow,
|
buildPriorForWindow,
|
||||||
type DirectChatAssistantReply,
|
type DirectChatAssistantReply,
|
||||||
type DirectChatMessage,
|
type DirectChatMessage,
|
||||||
@@ -92,17 +94,36 @@ export function worldInfoPackFromDirectChat(input: {
|
|||||||
slots: DirectChatShellSlots;
|
slots: DirectChatShellSlots;
|
||||||
priorMessages: Array<{ role: "user" | "assistant"; content: string }>;
|
priorMessages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||||
}): WorldInfoPack {
|
}): WorldInfoPack {
|
||||||
const history = buildChatHistory(
|
// 种子前情:带对话块头的当真的历史轮,不带的当史前读料(不污染历史区)。
|
||||||
input.slots.seedHistory,
|
const seedTurns = parseHistoryMessages(input.slots.seedHistory);
|
||||||
input.priorMessages,
|
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 {
|
return {
|
||||||
worldBookBefore: optionalEntry(
|
worldBookBefore: [
|
||||||
"direct-before",
|
...optionalEntry(
|
||||||
"直聊设定",
|
"direct-seed",
|
||||||
input.slots.loreBefore,
|
"前情",
|
||||||
),
|
seedIsStructured ? "" : input.slots.seedHistory,
|
||||||
|
),
|
||||||
|
...optionalEntry("direct-before", "直聊设定", input.slots.loreBefore),
|
||||||
|
],
|
||||||
history,
|
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(
|
worldBookAfter: optionalEntry(
|
||||||
"direct-after",
|
"direct-after",
|
||||||
"本轮状态",
|
"本轮状态",
|
||||||
@@ -129,7 +150,8 @@ export function resolveDirectChatPreset(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function systemPromptForSlots(slots: DirectChatShellSlots): string {
|
export function systemPromptForSlots(slots: DirectChatShellSlots): string {
|
||||||
return slots.systemPrompt.trim() || DEFAULT_DIRECT_CHAT_SYSTEM;
|
// 不填就不发:酒馆没有"隐式系统提示"这回事,缺省内容会把请求撑成多一条。
|
||||||
|
return slots.systemPrompt.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI / 对照用的理想夹心 */
|
/** UI / 对照用的理想夹心 */
|
||||||
@@ -145,6 +167,7 @@ export function assembleDirectChatMessages(
|
|||||||
systemPrompt: systemPromptForSlots(input.slots),
|
systemPrompt: systemPromptForSlots(input.slots),
|
||||||
preset,
|
preset,
|
||||||
pack: worldInfoPackFromDirectChat(input),
|
pack: worldInfoPackFromDirectChat(input),
|
||||||
|
charName: input.slots.charName ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +182,10 @@ export function buildDirectChatTaskMessages(input: {
|
|||||||
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}`);
|
||||||
}
|
}
|
||||||
@@ -168,8 +194,9 @@ export function buildDirectChatTaskMessages(input: {
|
|||||||
}
|
}
|
||||||
const ctx = parts.join("\n\n").trim();
|
const ctx = parts.join("\n\n").trim();
|
||||||
const user = ctx ? `${ctx}\n\n## 本轮输入\n${pack.turn}` : pack.turn;
|
const user = ctx ? `${ctx}\n\n## 本轮输入\n${pack.turn}` : pack.turn;
|
||||||
|
const systemPrompt = systemPromptForSlots(input.slots);
|
||||||
return [
|
return [
|
||||||
{ role: "system", content: systemPromptForSlots(input.slots) },
|
...(systemPrompt ? [{ role: "system" as const, content: systemPrompt }] : []),
|
||||||
{ role: "user", content: user },
|
{ role: "user", content: user },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -187,6 +214,7 @@ function createTrackedLlm(
|
|||||||
preset: PresetPackage,
|
preset: PresetPackage,
|
||||||
profileId: string | null | undefined,
|
profileId: string | null | undefined,
|
||||||
trackingRef: { current: LlmTrackingContext },
|
trackingRef: { current: LlmTrackingContext },
|
||||||
|
charName: string | null | undefined,
|
||||||
): LlmProvider {
|
): LlmProvider {
|
||||||
if (profileId?.trim()) {
|
if (profileId?.trim()) {
|
||||||
const profile = getApiProfile(profileId.trim());
|
const profile = getApiProfile(profileId.trim());
|
||||||
@@ -194,7 +222,7 @@ function createTrackedLlm(
|
|||||||
throw new Error(`API 配置不可用:${profileId}`);
|
throw new Error(`API 配置不可用:${profileId}`);
|
||||||
}
|
}
|
||||||
const inner = new OpenAiCompatibleProvider(profileToLlmConfig(profile));
|
const inner = new OpenAiCompatibleProvider(profileToLlmConfig(profile));
|
||||||
return wrapLlmForSession(inner, trackingRef, () => preset);
|
return wrapLlmForSession(inner, trackingRef, () => preset, undefined, () => charName);
|
||||||
}
|
}
|
||||||
ensureActiveProfileDefault();
|
ensureActiveProfileDefault();
|
||||||
const profile = resolveActiveProfile();
|
const profile = resolveActiveProfile();
|
||||||
@@ -202,7 +230,7 @@ function createTrackedLlm(
|
|||||||
throw new Error("未配置 API Key");
|
throw new Error("未配置 API Key");
|
||||||
}
|
}
|
||||||
const inner = new OpenAiCompatibleProvider(profileToLlmConfig(profile));
|
const inner = new OpenAiCompatibleProvider(profileToLlmConfig(profile));
|
||||||
return wrapLlmForSession(inner, trackingRef, () => preset);
|
return wrapLlmForSession(inner, trackingRef, () => preset, undefined, () => charName);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runDirectChatTurn(
|
export async function runDirectChatTurn(
|
||||||
@@ -244,19 +272,36 @@ export async function runDirectChatTurn(
|
|||||||
|
|
||||||
const trackingRef = { current: {} as LlmTrackingContext };
|
const trackingRef = { current: {} as LlmTrackingContext };
|
||||||
try {
|
try {
|
||||||
const llm = createTrackedLlm(preset, input.profileId, trackingRef);
|
const llm = createTrackedLlm(preset, input.profileId, trackingRef, input.slots.charName);
|
||||||
const messages = buildDirectChatTaskMessages(turnInput);
|
const messages = buildDirectChatTaskMessages(turnInput);
|
||||||
const callbacks = {
|
const callbacks = {
|
||||||
onReasoningDelta: (delta: string) => input.onDelta?.("thinking", delta),
|
onReasoningDelta: (delta: string) => input.onDelta?.("thinking", delta),
|
||||||
onContentDelta: (delta: string) => input.onDelta?.("content", delta),
|
onContentDelta: (delta: string) => input.onDelta?.("content", delta),
|
||||||
};
|
};
|
||||||
|
// 真多轮:历史轮次以 message 序列交给夹心层,不再拍平成一段。
|
||||||
|
const completeOptions = {
|
||||||
|
caller: "direct-chat",
|
||||||
|
presetPack: worldInfoPackFromDirectChat(turnInput),
|
||||||
|
};
|
||||||
const result = llm.completeStream
|
const result = llm.completeStream
|
||||||
? await llm.completeStream(messages, { caller: "direct-chat" }, callbacks)
|
? await llm.completeStream(messages, completeOptions, callbacks)
|
||||||
: await llm.complete(messages, { caller: "direct-chat" });
|
: await llm.complete(messages, completeOptions);
|
||||||
const contextTrace = trackingRef.current.pendingContextTrace;
|
const contextTrace = trackingRef.current.pendingContextTrace;
|
||||||
const reasoning = result.reasoning ?? null;
|
const reasoning = result.reasoning ?? null;
|
||||||
let reply = result.content ?? "";
|
let reply = result.content ?? "";
|
||||||
if (reasoning && reply === reasoning) reply = "";
|
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 {
|
return {
|
||||||
messages: assembled,
|
messages: assembled,
|
||||||
reply,
|
reply,
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ export type DirectChatShellSlots = {
|
|||||||
loreAfter: string;
|
loreAfter: string;
|
||||||
/** 尾注 */
|
/** 尾注 */
|
||||||
postTurn: 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 & {
|
export type DirectChatShell = DirectChatShellSlots & {
|
||||||
@@ -40,6 +50,17 @@ export type DirectChatAssistantReply = {
|
|||||||
contextTrace?: LlmContextTrace;
|
contextTrace?: LlmContextTrace;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一轮的一个版本:编辑原话或直接重 roll 都会新开一版。
|
||||||
|
* `after` 是切回这一版时,接在本轮后面的对话。
|
||||||
|
*/
|
||||||
|
export type DirectChatTurnVariant = {
|
||||||
|
user: { content: string; createdAt: string };
|
||||||
|
replies: DirectChatAssistantReply[];
|
||||||
|
selectedWindowId?: string | null;
|
||||||
|
after: DirectChatTurn[];
|
||||||
|
};
|
||||||
|
|
||||||
/** 一轮:共用用户消息 + 各窗口助手回复 */
|
/** 一轮:共用用户消息 + 各窗口助手回复 */
|
||||||
export type DirectChatTurn = {
|
export type DirectChatTurn = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -51,6 +72,12 @@ export type DirectChatTurn = {
|
|||||||
*/
|
*/
|
||||||
selectedWindowId?: string | null;
|
selectedWindowId?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/**
|
||||||
|
* 重 roll / 编辑原话后的版本。
|
||||||
|
* 当前 `user` 与 `replies` 对应 `variantIndex`。
|
||||||
|
*/
|
||||||
|
variants?: DirectChatTurnVariant[];
|
||||||
|
variantIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 旧版扁平消息(读盘兼容;新写入用 turns) */
|
/** 旧版扁平消息(读盘兼容;新写入用 turns) */
|
||||||
|
|||||||
+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,288 @@
|
|||||||
|
/**
|
||||||
|
* 酒馆双括号宏 `{{...}}`。与 `@` 指令共用 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;
|
||||||
|
/** setvar / getvar 容器。同一次装配共享一份。 */
|
||||||
|
vars?: Map<string, string>;
|
||||||
|
/** false 时 `{{roll}}` 原样保留。默认 true。 */
|
||||||
|
allowDice?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MacroKind =
|
||||||
|
| "comment"
|
||||||
|
| "trim"
|
||||||
|
| "user"
|
||||||
|
| "char"
|
||||||
|
| "persona"
|
||||||
|
| "last_user_message"
|
||||||
|
| "getvar"
|
||||||
|
| "setvar"
|
||||||
|
| "addvar"
|
||||||
|
| "roll";
|
||||||
|
|
||||||
|
export type MacroHit = {
|
||||||
|
raw: string;
|
||||||
|
kind: MacroKind;
|
||||||
|
replacement: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MacroResult = {
|
||||||
|
text: string;
|
||||||
|
hits: MacroHit[];
|
||||||
|
/** 命中 `{{trim}}`;调用方应对结果首尾去空白 */
|
||||||
|
trim: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 非贪婪:同一行多个宏能逐个匹配,跨行注释也能吃下。 */
|
||||||
|
const MACRO_RE = /\{\{([\s\S]*?)\}\}/g;
|
||||||
|
const COMMENT_HEAD_RE = /^\{\{\s*\/\//;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 先整体啃掉 `{{// ... }}` 注释。
|
||||||
|
* 酒馆的注释里常嵌 `{{user}}` 这类宏,非贪婪的 MACRO_RE 会在内层 `}}` 处提前收尾,
|
||||||
|
* 把注释尾巴漏成正文(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" }
|
||||||
|
| { 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.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 text = stripped.text.replace(MACRO_RE, (raw: string, inner: 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 "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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { text, hits, trim };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只列出文本里出现过的宏名(含未知),用于导入报告。 */
|
||||||
|
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 match of text.matchAll(MACRO_RE)) {
|
||||||
|
const inner = match[1] ?? "";
|
||||||
|
const parsed = parseInner(inner);
|
||||||
|
const known = parsed.kind !== "unknown";
|
||||||
|
const name = known
|
||||||
|
? `{{${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);
|
||||||
|
}
|
||||||
+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,203 @@
|
|||||||
|
/**
|
||||||
|
* 发出前的消息序列整形(对应酒馆的 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把所有 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] = prefixInto(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;
|
||||||
|
}
|
||||||
@@ -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,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-13
@@ -6,17 +6,22 @@ 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";
|
||||||
@@ -89,20 +94,30 @@ export function applyExtendedMarkers(preset: PresetPackage): PresetPackage {
|
|||||||
const histIndex = rawOrder.findIndex(
|
const histIndex = rawOrder.findIndex(
|
||||||
(item) => item.promptId === CHAT_HISTORY_MARKER,
|
(item) => item.promptId === CHAT_HISTORY_MARKER,
|
||||||
);
|
);
|
||||||
|
// 预设自带酒馆的 worldInfoBefore / worldInfoAfter(并启用)时,不插自有的世界书洞:
|
||||||
|
// 内容直接填进预设那两条,位置上才对得上酒馆。
|
||||||
|
const hasStBefore = rawOrder.some(
|
||||||
|
(item) => item.promptId === WORLD_INFO_BEFORE_MARKER && item.enabled,
|
||||||
|
);
|
||||||
|
const hasStAfter = rawOrder.some(
|
||||||
|
(item) => item.promptId === WORLD_INFO_AFTER_MARKER && item.enabled,
|
||||||
|
);
|
||||||
const inserted = (id: string): PresetPromptOrderItem => ({
|
const inserted = (id: string): PresetPromptOrderItem => ({
|
||||||
promptId: id,
|
promptId: id,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const beforeMarkers = hasStBefore ? [] : [inserted(WORLD_BOOK_BEFORE)];
|
||||||
|
const afterMarkers = hasStAfter ? [] : [inserted(WORLD_BOOK_AFTER)];
|
||||||
|
|
||||||
let next: PresetPromptOrderItem[];
|
let next: PresetPromptOrderItem[];
|
||||||
if (histIndex < 0) {
|
if (histIndex < 0) {
|
||||||
next = reindex([
|
next = reindex([
|
||||||
...rawOrder,
|
...rawOrder,
|
||||||
inserted(WORLD_BOOK_BEFORE),
|
...beforeMarkers,
|
||||||
inserted(CHAT_HISTORY_MARKER),
|
inserted(CHAT_HISTORY_MARKER),
|
||||||
inserted(WORLD_BOOK_AFTER),
|
...afterMarkers,
|
||||||
inserted(CURRENT_TURN),
|
inserted(CURRENT_TURN),
|
||||||
inserted(POST_TURN),
|
inserted(POST_TURN),
|
||||||
]);
|
]);
|
||||||
@@ -112,9 +127,9 @@ export function applyExtendedMarkers(preset: PresetPackage): PresetPackage {
|
|||||||
const after = rawOrder.slice(histIndex + 1);
|
const after = rawOrder.slice(histIndex + 1);
|
||||||
next = reindex([
|
next = reindex([
|
||||||
...before,
|
...before,
|
||||||
inserted(WORLD_BOOK_BEFORE),
|
...beforeMarkers,
|
||||||
history,
|
history,
|
||||||
inserted(WORLD_BOOK_AFTER),
|
...afterMarkers,
|
||||||
inserted(CURRENT_TURN),
|
inserted(CURRENT_TURN),
|
||||||
...after,
|
...after,
|
||||||
inserted(POST_TURN),
|
inserted(POST_TURN),
|
||||||
|
|||||||
+228
-36
@@ -1,43 +1,90 @@
|
|||||||
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 {
|
||||||
|
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 +92,152 @@ 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 history = regexHistoryMessages(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 +245,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 +275,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 +297,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,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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -53,10 +54,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,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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -1155,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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import {
|
import {
|
||||||
|
abortReroll,
|
||||||
|
beginReroll,
|
||||||
createConversation,
|
createConversation,
|
||||||
createShell,
|
createShell,
|
||||||
deleteConversation,
|
deleteConversation,
|
||||||
deleteShell,
|
deleteShell,
|
||||||
|
duplicateConversation,
|
||||||
EMPTY_SHELL_SLOTS,
|
EMPTY_SHELL_SLOTS,
|
||||||
getConversation,
|
getConversation,
|
||||||
getShell,
|
getShell,
|
||||||
listConversations,
|
listConversations,
|
||||||
listShells,
|
listShells,
|
||||||
|
mergeTurnReplies,
|
||||||
promoteTemporary,
|
promoteTemporary,
|
||||||
runDirectChatMultiTurn,
|
runDirectChatMultiTurn,
|
||||||
saveConversation,
|
saveConversation,
|
||||||
|
switchTurnVariant,
|
||||||
updateConversation,
|
updateConversation,
|
||||||
updateShell,
|
updateShell,
|
||||||
|
writeRerollReplies,
|
||||||
type DirectChatAssistantReply,
|
type DirectChatAssistantReply,
|
||||||
type DirectChatConversation,
|
type DirectChatConversation,
|
||||||
type DirectChatMessage,
|
type DirectChatMessage,
|
||||||
@@ -40,6 +46,27 @@ function json(res: ServerResponse, status: number, data: unknown): void {
|
|||||||
res.end(JSON.stringify(data));
|
res.end(JSON.stringify(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readWindows(raw: unknown): DirectChatWindow[] | undefined {
|
||||||
|
if (!Array.isArray(raw)) return undefined;
|
||||||
|
const out: DirectChatWindow[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
const id = typeof row.id === "string" ? row.id.trim() : "";
|
||||||
|
const profileId = typeof row.profileId === "string" ? row.profileId.trim() : "";
|
||||||
|
if (!id || !profileId) continue;
|
||||||
|
const window: DirectChatWindow = { id, profileId };
|
||||||
|
if (typeof row.profileName === "string" && row.profileName.trim()) {
|
||||||
|
window.profileName = row.profileName.trim();
|
||||||
|
}
|
||||||
|
if (typeof row.model === "string" && row.model.trim()) {
|
||||||
|
window.model = row.model.trim();
|
||||||
|
}
|
||||||
|
out.push(window);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function wantsNdjson(req: IncomingMessage): boolean {
|
function wantsNdjson(req: IncomingMessage): boolean {
|
||||||
return String(req.headers.accept || "").includes("application/x-ndjson");
|
return String(req.headers.accept || "").includes("application/x-ndjson");
|
||||||
}
|
}
|
||||||
@@ -68,11 +95,14 @@ function createStreamingSaver(
|
|||||||
windows: DirectChatWindow[],
|
windows: DirectChatWindow[],
|
||||||
text: string,
|
text: string,
|
||||||
turnId: string,
|
turnId: string,
|
||||||
|
baseTurns?: DirectChatTurn[],
|
||||||
) {
|
) {
|
||||||
const ts = new Date().toISOString();
|
const ts = new Date().toISOString();
|
||||||
const replies: DirectChatAssistantReply[] = [];
|
const replies: DirectChatAssistantReply[] = [];
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
|
let produced = false;
|
||||||
|
let turnsState = baseTurns ? structuredClone(baseTurns) : null;
|
||||||
|
|
||||||
const saveNow = () => {
|
const saveNow = () => {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
@@ -80,6 +110,19 @@ function createStreamingSaver(
|
|||||||
timer = null;
|
timer = null;
|
||||||
}
|
}
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
|
if (turnsState) {
|
||||||
|
turnsState = writeRerollReplies(
|
||||||
|
turnsState,
|
||||||
|
turnId,
|
||||||
|
replies.map((r) => ({ ...r })),
|
||||||
|
);
|
||||||
|
saveConversation({
|
||||||
|
...conversation,
|
||||||
|
windows,
|
||||||
|
turns: turnsState,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const turn: DirectChatTurn = {
|
const turn: DirectChatTurn = {
|
||||||
id: turnId,
|
id: turnId,
|
||||||
user: { content: text, createdAt: ts },
|
user: { content: text, createdAt: ts },
|
||||||
@@ -108,6 +151,7 @@ function createStreamingSaver(
|
|||||||
reply = { windowId: event.windowId, content: "" };
|
reply = { windowId: event.windowId, content: "" };
|
||||||
replies.push(reply);
|
replies.push(reply);
|
||||||
}
|
}
|
||||||
|
if (event.delta) produced = true;
|
||||||
if (event.field === "thinking") {
|
if (event.field === "thinking") {
|
||||||
reply.thinking = `${reply.thinking || ""}${event.delta}`;
|
reply.thinking = `${reply.thinking || ""}${event.delta}`;
|
||||||
} else {
|
} else {
|
||||||
@@ -117,11 +161,18 @@ function createStreamingSaver(
|
|||||||
},
|
},
|
||||||
onWindow(result: DirectChatMultiWindowResult) {
|
onWindow(result: DirectChatMultiWindowResult) {
|
||||||
const next = replyFromWindowResult(result);
|
const next = replyFromWindowResult(result);
|
||||||
|
if (next.content || next.thinking || next.error) produced = true;
|
||||||
const idx = replies.findIndex((r) => r.windowId === result.windowId);
|
const idx = replies.findIndex((r) => r.windowId === result.windowId);
|
||||||
if (idx >= 0) replies[idx] = next;
|
if (idx >= 0) replies[idx] = next;
|
||||||
else replies.push(next);
|
else replies.push(next);
|
||||||
saveNow();
|
saveNow();
|
||||||
},
|
},
|
||||||
|
sawOutput() {
|
||||||
|
return produced;
|
||||||
|
},
|
||||||
|
currentTurns() {
|
||||||
|
return turnsState;
|
||||||
|
},
|
||||||
close() {
|
close() {
|
||||||
closed = true;
|
closed = true;
|
||||||
if (timer) {
|
if (timer) {
|
||||||
@@ -136,7 +187,10 @@ async function writeMultiStream(
|
|||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
run: (hooks: MultiStreamHooks) => Promise<DirectChatMultiTurnResult>,
|
run: (hooks: MultiStreamHooks) => Promise<DirectChatMultiTurnResult>,
|
||||||
finish: (multi: DirectChatMultiTurnResult) => Record<string, unknown>,
|
finish: (multi: DirectChatMultiTurnResult) => Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<
|
||||||
|
| { ok: true; multi: DirectChatMultiTurnResult }
|
||||||
|
| { ok: false; error: string }
|
||||||
|
> {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "application/x-ndjson; charset=utf-8",
|
"Content-Type": "application/x-ndjson; charset=utf-8",
|
||||||
"Cache-Control": "no-cache, no-transform",
|
"Cache-Control": "no-cache, no-transform",
|
||||||
@@ -152,6 +206,7 @@ async function writeMultiStream(
|
|||||||
});
|
});
|
||||||
writeEvent({ type: "done", multi, ...finish(multi) });
|
writeEvent({ type: "done", multi, ...finish(multi) });
|
||||||
res.end();
|
res.end();
|
||||||
|
return { ok: true, multi };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
try {
|
try {
|
||||||
@@ -160,6 +215,7 @@ async function writeMultiStream(
|
|||||||
} catch {
|
} catch {
|
||||||
res.destroy();
|
res.destroy();
|
||||||
}
|
}
|
||||||
|
return { ok: false, error };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +241,14 @@ function slotsFromBody(body: Record<string, unknown>): DirectChatShellSlots {
|
|||||||
typeof body.postTurn === "string"
|
typeof body.postTurn === "string"
|
||||||
? body.postTurn
|
? body.postTurn
|
||||||
: EMPTY_SHELL_SLOTS.postTurn,
|
: EMPTY_SHELL_SLOTS.postTurn,
|
||||||
|
charName: typeof body.charName === "string" ? body.charName : undefined,
|
||||||
|
exampleDialogue:
|
||||||
|
typeof body.exampleDialogue === "string" ? body.exampleDialogue : undefined,
|
||||||
|
charDescription:
|
||||||
|
typeof body.charDescription === "string" ? body.charDescription : undefined,
|
||||||
|
charPersonality:
|
||||||
|
typeof body.charPersonality === "string" ? body.charPersonality : undefined,
|
||||||
|
scenario: typeof body.scenario === "string" ? body.scenario : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,12 +364,16 @@ export async function handleDirectChatApi(
|
|||||||
const body = JSON.parse(await readBody(req)) as {
|
const body = JSON.parse(await readBody(req)) as {
|
||||||
name?: string;
|
name?: string;
|
||||||
messages?: DirectChatMessage[];
|
messages?: DirectChatMessage[];
|
||||||
|
windows?: DirectChatWindow[];
|
||||||
|
turns?: DirectChatTurn[];
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const conversation = createConversation({
|
const conversation = createConversation({
|
||||||
shellId,
|
shellId,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
messages: body.messages,
|
messages: body.messages,
|
||||||
|
windows: readWindows(body.windows),
|
||||||
|
turns: Array.isArray(body.turns) ? body.turns : undefined,
|
||||||
});
|
});
|
||||||
json(res, 201, { conversation });
|
json(res, 201, { conversation });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -342,11 +410,13 @@ export async function handleDirectChatApi(
|
|||||||
const body = JSON.parse(await readBody(req)) as {
|
const body = JSON.parse(await readBody(req)) as {
|
||||||
name?: string;
|
name?: string;
|
||||||
messages?: DirectChatMessage[];
|
messages?: DirectChatMessage[];
|
||||||
|
windows?: DirectChatWindow[];
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const conversation = updateConversation(conversationId, {
|
const conversation = updateConversation(conversationId, {
|
||||||
name: body.name,
|
name: body.name,
|
||||||
messages: body.messages,
|
messages: body.messages,
|
||||||
|
windows: readWindows(body.windows),
|
||||||
});
|
});
|
||||||
json(res, 200, { conversation });
|
json(res, 200, { conversation });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -369,6 +439,18 @@ export async function handleDirectChatApi(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && sub === "/duplicate") {
|
||||||
|
try {
|
||||||
|
const conversation = duplicateConversation(conversationId);
|
||||||
|
json(res, 201, { conversation });
|
||||||
|
} catch (err) {
|
||||||
|
json(res, 404, {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && sub === "/messages") {
|
if (req.method === "POST" && sub === "/messages") {
|
||||||
const body = JSON.parse(await readBody(req)) as {
|
const body = JSON.parse(await readBody(req)) as {
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -463,6 +545,278 @@ export async function handleDirectChatApi(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rerollMatch = sub.match(/^\/turns\/([^/]+)\/reroll$/);
|
||||||
|
if (rerollMatch && req.method === "POST") {
|
||||||
|
const turnId = decodeURIComponent(rerollMatch[1]!);
|
||||||
|
const body = JSON.parse(await readBody(req)) as {
|
||||||
|
text?: string;
|
||||||
|
windows?: DirectChatWindow[];
|
||||||
|
complete?: boolean;
|
||||||
|
};
|
||||||
|
const text = typeof body.text === "string" ? body.text.trim() : "";
|
||||||
|
if (!text) {
|
||||||
|
json(res, 400, { error: "text 不能为空" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const conversation = getConversation(conversationId);
|
||||||
|
if (!conversation) {
|
||||||
|
json(res, 404, { error: "会话不存在" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const shell = getShell(conversation.shellId);
|
||||||
|
if (!shell) {
|
||||||
|
json(res, 404, { error: "上下文壳不存在" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const windows =
|
||||||
|
Array.isArray(body.windows) && body.windows.length
|
||||||
|
? body.windows
|
||||||
|
: conversation.windows;
|
||||||
|
if (!windows.length) {
|
||||||
|
json(res, 400, { error: "请先新增至少一个模型窗口" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const prepared = beginReroll(conversation.turns, turnId, text);
|
||||||
|
if (!prepared) {
|
||||||
|
json(res, 404, { error: "回合不存在" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const preparedConversation = saveConversation({
|
||||||
|
...conversation,
|
||||||
|
windows,
|
||||||
|
turns: prepared.turns,
|
||||||
|
});
|
||||||
|
const saver = createStreamingSaver(
|
||||||
|
preparedConversation,
|
||||||
|
windows,
|
||||||
|
text,
|
||||||
|
turnId,
|
||||||
|
prepared.turns,
|
||||||
|
);
|
||||||
|
const run = (hooks?: MultiStreamHooks) =>
|
||||||
|
runDirectChatMultiTurn({
|
||||||
|
message: text,
|
||||||
|
slots: {
|
||||||
|
systemPrompt: shell.systemPrompt,
|
||||||
|
loreBefore: shell.loreBefore,
|
||||||
|
seedHistory: shell.seedHistory,
|
||||||
|
loreAfter: shell.loreAfter,
|
||||||
|
postTurn: shell.postTurn,
|
||||||
|
},
|
||||||
|
presetId: shell.presetId,
|
||||||
|
windows,
|
||||||
|
turns: prepared.prior,
|
||||||
|
complete: body.complete,
|
||||||
|
turnId,
|
||||||
|
onDelta: (event) => {
|
||||||
|
saver.onDelta(event);
|
||||||
|
hooks?.onDelta(event);
|
||||||
|
},
|
||||||
|
onWindow: (result) => {
|
||||||
|
saver.onWindow(result);
|
||||||
|
hooks?.onWindow(result);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const restoreIfEmpty = () => {
|
||||||
|
saver.close();
|
||||||
|
if (saver.sawOutput()) return;
|
||||||
|
const restored = abortReroll(prepared.turns, turnId);
|
||||||
|
if (!restored) return;
|
||||||
|
saveConversation({
|
||||||
|
...preparedConversation,
|
||||||
|
windows,
|
||||||
|
turns: restored,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (wantsNdjson(req)) {
|
||||||
|
const outcome = await writeMultiStream(res, run, (multi) => {
|
||||||
|
saver.close();
|
||||||
|
const turns = writeRerollReplies(
|
||||||
|
saver.currentTurns() ?? prepared.turns,
|
||||||
|
turnId,
|
||||||
|
multi.turn.replies,
|
||||||
|
);
|
||||||
|
const updated = saveConversation({
|
||||||
|
...preparedConversation,
|
||||||
|
windows,
|
||||||
|
turns,
|
||||||
|
});
|
||||||
|
return { conversation: updated, shell };
|
||||||
|
});
|
||||||
|
if (!outcome.ok) restoreIfEmpty();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const multi = await run();
|
||||||
|
saver.close();
|
||||||
|
const turns = writeRerollReplies(
|
||||||
|
saver.currentTurns() ?? prepared.turns,
|
||||||
|
turnId,
|
||||||
|
multi.turn.replies,
|
||||||
|
);
|
||||||
|
const updated = saveConversation({
|
||||||
|
...preparedConversation,
|
||||||
|
windows,
|
||||||
|
turns,
|
||||||
|
});
|
||||||
|
json(res, 200, { conversation: updated, shell, multi });
|
||||||
|
} catch (err) {
|
||||||
|
restoreIfEmpty();
|
||||||
|
json(res, 400, {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryMatch = sub.match(/^\/turns\/([^/]+)\/retry$/);
|
||||||
|
if (retryMatch && req.method === "POST") {
|
||||||
|
const turnId = decodeURIComponent(retryMatch[1]!);
|
||||||
|
const body = JSON.parse(await readBody(req)) as {
|
||||||
|
windowIds?: string[];
|
||||||
|
complete?: boolean;
|
||||||
|
};
|
||||||
|
const requested = Array.isArray(body.windowIds)
|
||||||
|
? body.windowIds.filter(
|
||||||
|
(x): x is string => typeof x === "string" && Boolean(x.trim()),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (!requested.length) {
|
||||||
|
json(res, 400, { error: "缺少 windowIds" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const conversation = getConversation(conversationId);
|
||||||
|
if (!conversation) {
|
||||||
|
json(res, 404, { error: "会话不存在" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const shell = getShell(conversation.shellId);
|
||||||
|
if (!shell) {
|
||||||
|
json(res, 404, { error: "上下文壳不存在" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const turnIndex = conversation.turns.findIndex((t) => t.id === turnId);
|
||||||
|
const turn = turnIndex >= 0 ? conversation.turns[turnIndex] : undefined;
|
||||||
|
if (!turn) {
|
||||||
|
json(res, 404, { error: "回合不存在" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const text = turn.user.content.trim();
|
||||||
|
if (!text) {
|
||||||
|
json(res, 400, { error: "本轮缺少用户输入" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const wanted = new Set(requested);
|
||||||
|
const windows = conversation.windows.filter((w) => wanted.has(w.id));
|
||||||
|
if (!windows.length) {
|
||||||
|
json(res, 400, { error: "没有可重试的窗口" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 关键:不调 beginReroll。重试只补选中的窗口,其余回复原地保留。
|
||||||
|
const runRetry = (hooks?: Partial<MultiStreamHooks>) =>
|
||||||
|
runDirectChatMultiTurn({
|
||||||
|
message: text,
|
||||||
|
slots: {
|
||||||
|
systemPrompt: shell.systemPrompt,
|
||||||
|
loreBefore: shell.loreBefore,
|
||||||
|
seedHistory: shell.seedHistory,
|
||||||
|
loreAfter: shell.loreAfter,
|
||||||
|
postTurn: shell.postTurn,
|
||||||
|
},
|
||||||
|
presetId: shell.presetId,
|
||||||
|
windows,
|
||||||
|
turns: conversation.turns.slice(0, turnIndex),
|
||||||
|
complete: body.complete,
|
||||||
|
onDelta: hooks?.onDelta,
|
||||||
|
onWindow: hooks?.onWindow,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 每完成一个窗口就落盘:多个窗口是并行的,先好的先写入,
|
||||||
|
// 前端也能逐张刷新,不用等最慢的那个。
|
||||||
|
const persistOne = (result: DirectChatMultiWindowResult) => {
|
||||||
|
const latest = getConversation(conversationId);
|
||||||
|
if (!latest) return;
|
||||||
|
const reply: DirectChatAssistantReply = {
|
||||||
|
windowId: result.windowId,
|
||||||
|
content: result.reply ?? "",
|
||||||
|
...(result.reasoning ? { thinking: result.reasoning } : {}),
|
||||||
|
...(result.model ? { model: result.model } : {}),
|
||||||
|
...(result.error ? { error: result.error } : {}),
|
||||||
|
...(result.contextTrace
|
||||||
|
? { contextTrace: result.contextTrace }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
saveConversation({
|
||||||
|
...latest,
|
||||||
|
turns: mergeTurnReplies(latest.turns, turnId, [reply]),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (wantsNdjson(req)) {
|
||||||
|
const outcome = await writeMultiStream(
|
||||||
|
res,
|
||||||
|
(hooks) =>
|
||||||
|
runRetry({
|
||||||
|
onDelta: hooks.onDelta,
|
||||||
|
onWindow: (result) => {
|
||||||
|
persistOne(result);
|
||||||
|
hooks.onWindow(result);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
() => ({
|
||||||
|
conversation: getConversation(conversationId) ?? conversation,
|
||||||
|
shell,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!outcome.ok) return true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const multi = await runRetry({
|
||||||
|
onWindow: (result) => persistOne(result),
|
||||||
|
});
|
||||||
|
const latest = getConversation(conversationId) ?? conversation;
|
||||||
|
const updated = saveConversation({
|
||||||
|
...latest,
|
||||||
|
turns: mergeTurnReplies(latest.turns, turnId, multi.turn.replies),
|
||||||
|
});
|
||||||
|
json(res, 200, { conversation: updated, shell, multi });
|
||||||
|
} catch (err) {
|
||||||
|
json(res, 400, {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantMatch = sub.match(/^\/turns\/([^/]+)\/variant$/);
|
||||||
|
if (variantMatch && req.method === "POST") {
|
||||||
|
const turnId = decodeURIComponent(variantMatch[1]!);
|
||||||
|
const body = JSON.parse(await readBody(req)) as { direction?: string };
|
||||||
|
if (body.direction !== "prev" && body.direction !== "next") {
|
||||||
|
json(res, 400, { error: "direction 须为 prev 或 next" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const conversation = getConversation(conversationId);
|
||||||
|
if (!conversation) {
|
||||||
|
json(res, 404, { error: "会话不存在" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const turns = switchTurnVariant(
|
||||||
|
conversation.turns,
|
||||||
|
turnId,
|
||||||
|
body.direction,
|
||||||
|
);
|
||||||
|
if (!turns) {
|
||||||
|
json(res, 400, { error: "没有更多版本" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const updated = saveConversation({ ...conversation, turns });
|
||||||
|
json(res, 200, { conversation: updated });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const selectMatch = sub.match(/^\/turns\/([^/]+)\/select$/);
|
const selectMatch = sub.match(/^\/turns\/([^/]+)\/select$/);
|
||||||
if (selectMatch && req.method === "POST") {
|
if (selectMatch && req.method === "POST") {
|
||||||
const turnId = decodeURIComponent(selectMatch[1]!);
|
const turnId = decodeURIComponent(selectMatch[1]!);
|
||||||
|
|||||||
@@ -2179,6 +2179,13 @@ export class SessionManager {
|
|||||||
this.activeBookSessions.delete(bookId);
|
this.activeBookSessions.delete(bookId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 作品改名后同步到活跃会话(预设 `{{char}}` 取这个书名)。 */
|
||||||
|
syncBookTitle(bookId: string, title: string): void {
|
||||||
|
for (const s of this.sessions.values()) {
|
||||||
|
if (s.bookId === bookId) s.trackingRef.current.bookTitle = title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 将当前 API / 预设设置应用到所有活跃会话 */
|
/** 将当前 API / 预设设置应用到所有活跃会话 */
|
||||||
reloadAllLlms(): number {
|
reloadAllLlms(): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
ensureActiveProfileDefault,
|
ensureActiveProfileDefault,
|
||||||
loadAppSettings,
|
loadAppSettings,
|
||||||
|
normalizeChatDragGain,
|
||||||
normalizeContextTraceKeepLatest,
|
normalizeContextTraceKeepLatest,
|
||||||
normalizeDictateFillRetries,
|
normalizeDictateFillRetries,
|
||||||
|
normalizeMessageSquash,
|
||||||
normalizePreferenceCollectEveryTurns,
|
normalizePreferenceCollectEveryTurns,
|
||||||
|
normalizePresetToolPolicy,
|
||||||
saveAppSettings,
|
saveAppSettings,
|
||||||
setActivePresetId,
|
setActivePresetId,
|
||||||
setActiveProfileId,
|
setActiveProfileId,
|
||||||
@@ -41,6 +44,9 @@ import {
|
|||||||
listPresets,
|
listPresets,
|
||||||
} from "../preset/store.js";
|
} from "../preset/store.js";
|
||||||
import type { GenerationParameters } from "../types/preset.js";
|
import type { GenerationParameters } from "../types/preset.js";
|
||||||
|
import {
|
||||||
|
resolvePresetRuntime,
|
||||||
|
} from "../llm/preset-wrapper.js";
|
||||||
import {
|
import {
|
||||||
createDefaultMainAgentLlm,
|
createDefaultMainAgentLlm,
|
||||||
createLlmForPreset,
|
createLlmForPreset,
|
||||||
@@ -140,6 +146,9 @@ export async function handleSettingsApi(
|
|||||||
contextTraceKeepLatest?: number;
|
contextTraceKeepLatest?: number;
|
||||||
preferenceCollectEveryTurns?: number;
|
preferenceCollectEveryTurns?: number;
|
||||||
dictateFillRetries?: number;
|
dictateFillRetries?: number;
|
||||||
|
chatDragGain?: number;
|
||||||
|
messageSquash?: string;
|
||||||
|
presetToolPolicy?: string;
|
||||||
};
|
};
|
||||||
const settings = loadAppSettings();
|
const settings = loadAppSettings();
|
||||||
if (body.activeProfileId !== undefined) {
|
if (body.activeProfileId !== undefined) {
|
||||||
@@ -161,6 +170,15 @@ export async function handleSettingsApi(
|
|||||||
if (body.dictateFillRetries !== undefined) {
|
if (body.dictateFillRetries !== undefined) {
|
||||||
settings.dictateFillRetries = normalizeDictateFillRetries(body.dictateFillRetries);
|
settings.dictateFillRetries = normalizeDictateFillRetries(body.dictateFillRetries);
|
||||||
}
|
}
|
||||||
|
if (body.chatDragGain !== undefined) {
|
||||||
|
settings.chatDragGain = normalizeChatDragGain(body.chatDragGain);
|
||||||
|
}
|
||||||
|
if (body.messageSquash !== undefined) {
|
||||||
|
settings.messageSquash = normalizeMessageSquash(body.messageSquash);
|
||||||
|
}
|
||||||
|
if (body.presetToolPolicy !== undefined) {
|
||||||
|
settings.presetToolPolicy = normalizePresetToolPolicy(body.presetToolPolicy);
|
||||||
|
}
|
||||||
saveAppSettings(settings);
|
saveAppSettings(settings);
|
||||||
json(res, 200, { settings });
|
json(res, 200, { settings });
|
||||||
return true;
|
return true;
|
||||||
@@ -441,6 +459,7 @@ export async function handleSettingsApi(
|
|||||||
const wantComplete = body.complete !== false;
|
const wantComplete = body.complete !== false;
|
||||||
const canComplete = wantComplete && hasRealLlmConfig();
|
const canComplete = wantComplete && hasRealLlmConfig();
|
||||||
const llm = canComplete ? createLlmForPreset(preset) : null;
|
const llm = canComplete ? createLlmForPreset(preset) : null;
|
||||||
|
const presetRuntime = resolvePresetRuntime(preset);
|
||||||
const result = await runPresetProbe({
|
const result = await runPresetProbe({
|
||||||
preset,
|
preset,
|
||||||
input: {
|
input: {
|
||||||
@@ -450,6 +469,10 @@ export async function handleSettingsApi(
|
|||||||
loreAfter: body.loreAfter,
|
loreAfter: body.loreAfter,
|
||||||
postTurn: body.postTurn,
|
postTurn: body.postTurn,
|
||||||
},
|
},
|
||||||
|
options: {
|
||||||
|
charName: presetRuntime.charName,
|
||||||
|
squash: presetRuntime.squash,
|
||||||
|
},
|
||||||
complete: llm
|
complete: llm
|
||||||
? (messages) =>
|
? (messages) =>
|
||||||
llm.complete(messages, { caller: "preset-probe" })
|
llm.complete(messages, { caller: "preset-probe" })
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
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 { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js";
|
import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js";
|
||||||
import {
|
import {
|
||||||
CREATION_ACCEPTED_CONTENT_TAG,
|
CREATION_ACCEPTED_CONTENT_TAG,
|
||||||
@@ -15,7 +16,9 @@ import { projectFragmentContent } from "./context-fragment.js";
|
|||||||
import { tagReadFallbacks } from "./creation-flow.js";
|
import { tagReadFallbacks } from "./creation-flow.js";
|
||||||
import {
|
import {
|
||||||
buildHistoryFallback,
|
buildHistoryFallback,
|
||||||
|
isCurrentTurnRef,
|
||||||
isDialogueHistoryRef,
|
isDialogueHistoryRef,
|
||||||
|
parseHistoryMessages,
|
||||||
projectDialogueHistory,
|
projectDialogueHistory,
|
||||||
DIALOGUE_HISTORY_TAG,
|
DIALOGUE_HISTORY_TAG,
|
||||||
} from "./dialogue-history.js";
|
} from "./dialogue-history.js";
|
||||||
@@ -45,7 +48,9 @@ export function parseContextSegments(raw: unknown): ContextSegmentDef[] {
|
|||||||
const id = typeof r.id === "string" ? r.id.trim() : "";
|
const id = typeof r.id === "string" ? r.id.trim() : "";
|
||||||
const tier = r.tier === "dynamic" ? "dynamic" : "static";
|
const tier = r.tier === "dynamic" ? "dynamic" : "static";
|
||||||
const tags = Array.isArray(r.tags)
|
const tags = Array.isArray(r.tags)
|
||||||
? r.tags.filter((t): t is string => typeof t === "string" && t.trim()).map((t) => t.trim())
|
? r.tags
|
||||||
|
.filter((t): t is string => typeof t === "string" && t.trim().length > 0)
|
||||||
|
.map((t) => t.trim())
|
||||||
: [];
|
: [];
|
||||||
const inline = typeof r.inline === "string" ? r.inline.trim() : undefined;
|
const inline = typeof r.inline === "string" ? r.inline.trim() : undefined;
|
||||||
if (!id || (tags.length === 0 && !inline)) continue;
|
if (!id || (tags.length === 0 && !inline)) continue;
|
||||||
@@ -249,11 +254,25 @@ function collectUserRequirementBlock(params: {
|
|||||||
].join("\n\n");
|
].join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 分段后的 worker 上下文:历史是 message 序列,其它是文本块。 */
|
||||||
|
export type WorkerContextChunk =
|
||||||
|
| { kind: "block"; text: string }
|
||||||
|
| { kind: "history"; text: string; messages: ChatMessage[] }
|
||||||
|
| { kind: "turn"; text: string };
|
||||||
|
|
||||||
|
export type WorkerContextSections = {
|
||||||
|
chunks: WorkerContextChunk[];
|
||||||
|
/** 本步任务 / 定稿摘要 / 待改底稿等尾块 */
|
||||||
|
tail: string[];
|
||||||
|
/** 旧扁平拼接结果(与历史一同拼在 user 消息里) */
|
||||||
|
flat: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按 contextSegments 拼装 user 侧上下文。
|
* 按 contextSegments 拼装,并额外给出历史分段(真多轮用)。
|
||||||
* 无 segments 时回退为 JSON inputs(兼容旧 skill)。
|
* 无 segments 时回退为 JSON inputs(兼容旧 skill)。
|
||||||
*/
|
*/
|
||||||
export function assembleWorkerContext(params: {
|
export function assembleWorkerContextSections(params: {
|
||||||
inputs: Record<string, string>;
|
inputs: Record<string, string>;
|
||||||
segments?: ContextSegmentDef[] | null;
|
segments?: ContextSegmentDef[] | null;
|
||||||
blackboard: Blackboard;
|
blackboard: Blackboard;
|
||||||
@@ -261,7 +280,7 @@ export function assembleWorkerContext(params: {
|
|||||||
workerId: string;
|
workerId: string;
|
||||||
workerName: string;
|
workerName: string;
|
||||||
outputTags: string[];
|
outputTags: string[];
|
||||||
}): string {
|
}): WorkerContextSections {
|
||||||
const inputMerge = params.inputMerge ?? "latest";
|
const inputMerge = params.inputMerge ?? "latest";
|
||||||
const segments = params.segments ?? [];
|
const segments = params.segments ?? [];
|
||||||
|
|
||||||
@@ -270,7 +289,7 @@ export function assembleWorkerContext(params: {
|
|||||||
params.inputs["用户.修订说明"] ??
|
params.inputs["用户.修订说明"] ??
|
||||||
""
|
""
|
||||||
).trim();
|
).trim();
|
||||||
return JSON.stringify(
|
const flat = JSON.stringify(
|
||||||
{
|
{
|
||||||
workerId: params.workerId,
|
workerId: params.workerId,
|
||||||
workerName: params.workerName,
|
workerName: params.workerName,
|
||||||
@@ -284,6 +303,7 @@ export function assembleWorkerContext(params: {
|
|||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
|
return { chunks: [{ kind: "block", text: flat }], tail: [], flat };
|
||||||
}
|
}
|
||||||
|
|
||||||
const revisionNote = readTagContent(
|
const revisionNote = readTagContent(
|
||||||
@@ -317,7 +337,8 @@ export function assembleWorkerContext(params: {
|
|||||||
for (const tag of USER_REQUIREMENT_TAGS) skipTags.add(tag);
|
for (const tag of USER_REQUIREMENT_TAGS) skipTags.add(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocks: string[] = [];
|
const chunks: WorkerContextChunk[] = [];
|
||||||
|
const tail: string[] = [];
|
||||||
|
|
||||||
const render = (seg: ContextSegmentDef) => {
|
const render = (seg: ContextSegmentDef) => {
|
||||||
if (inheritMode && seg.id === "module-opening") return;
|
if (inheritMode && seg.id === "module-opening") return;
|
||||||
@@ -330,11 +351,18 @@ export function assembleWorkerContext(params: {
|
|||||||
skipTags,
|
skipTags,
|
||||||
);
|
);
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
if (seg.label) {
|
const text = seg.label ? `${seg.label}\n\n${body}` : body;
|
||||||
blocks.push(`${seg.label}\n\n${body}`);
|
const tags = seg.tags ?? [];
|
||||||
} else {
|
if (tags.some((t) => isDialogueHistoryRef(t))) {
|
||||||
blocks.push(body);
|
// 历史:既保留文本(旧扁平路径),又留下真多轮 message 序列
|
||||||
|
chunks.push({ kind: "history", text, messages: parseHistoryMessages(body) });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (tags.some((t) => isCurrentTurnRef(t))) {
|
||||||
|
chunks.push({ kind: "turn", text });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push({ kind: "block", text });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 严格按 segments 数组顺序(投影排序表顺序);不再按 static/dynamic 重排
|
// 严格按 segments 数组顺序(投影排序表顺序);不再按 static/dynamic 重排
|
||||||
@@ -346,18 +374,18 @@ export function assembleWorkerContext(params: {
|
|||||||
blackboard: params.blackboard,
|
blackboard: params.blackboard,
|
||||||
inputMerge,
|
inputMerge,
|
||||||
});
|
});
|
||||||
if (reqBlock) blocks.push(reqBlock);
|
if (reqBlock) tail.push(reqBlock);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定稿摘要:若未在 segments 中声明,仍附在末尾
|
// 定稿摘要:若未在 segments 中声明,仍附在末尾
|
||||||
const brief = params.inputs[CONTEXT_BRIEF_TAG]?.trim();
|
const brief = params.inputs[CONTEXT_BRIEF_TAG]?.trim();
|
||||||
const briefInSegments = segments.some((s) => s.tags.includes(CONTEXT_BRIEF_TAG));
|
const briefInSegments = segments.some((s) => s.tags.includes(CONTEXT_BRIEF_TAG));
|
||||||
if (brief && !briefInSegments) {
|
if (brief && !briefInSegments) {
|
||||||
blocks.push(`## 上下文.定稿摘要\n\n${brief}`);
|
tail.push(`## 上下文.定稿摘要\n\n${brief}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inheritMode && draftBlocks.length) {
|
if (inheritMode && draftBlocks.length) {
|
||||||
blocks.push(
|
tail.push(
|
||||||
[
|
[
|
||||||
"## 【待改底稿】",
|
"## 【待改底稿】",
|
||||||
"",
|
"",
|
||||||
@@ -390,7 +418,63 @@ export function assembleWorkerContext(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
taskLines.push("设计.worker集 / 草稿须为 JSON 对象文本(以 `{` 开头)。");
|
taskLines.push("设计.worker集 / 草稿须为 JSON 对象文本(以 `{` 开头)。");
|
||||||
blocks.push(taskLines.join("\n"));
|
tail.push(taskLines.join("\n"));
|
||||||
|
|
||||||
return blocks.join("\n\n---\n\n");
|
const parts = [...chunks.map((chunk) => chunk.text), ...tail].filter(
|
||||||
|
(text) => text.trim().length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chunks,
|
||||||
|
tail,
|
||||||
|
flat: parts.join("\n\n---\n\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧签名包装:返回扁平 user 上下文(历史含在文本里)。
|
||||||
|
* 需要真多轮时改用 `assembleWorkerContextSections()` + `workerContextToBags()`。
|
||||||
|
*/
|
||||||
|
export function assembleWorkerContext(params: {
|
||||||
|
inputs: Record<string, string>;
|
||||||
|
segments?: ContextSegmentDef[] | null;
|
||||||
|
blackboard: Blackboard;
|
||||||
|
inputMerge?: BlackboardInputMerge;
|
||||||
|
workerId: string;
|
||||||
|
workerName: string;
|
||||||
|
outputTags: string[];
|
||||||
|
}): string {
|
||||||
|
return assembleWorkerContextSections(params).flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把分段结果收成 preset 四袋(真多轮)。
|
||||||
|
* 历史之前 / 之后的块各自合并成一段,本轮输入单独一袋,本步任务等尾块放 postTurn。
|
||||||
|
*/
|
||||||
|
export function workerContextToBags(sections: WorkerContextSections) {
|
||||||
|
const before: string[] = [];
|
||||||
|
const after: string[] = [];
|
||||||
|
let turn = "";
|
||||||
|
let seenHistory = false;
|
||||||
|
for (const chunk of sections.chunks) {
|
||||||
|
if (chunk.kind === "history") {
|
||||||
|
seenHistory = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.kind === "turn") {
|
||||||
|
turn = chunk.text.trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seenHistory) after.push(chunk.text);
|
||||||
|
else before.push(chunk.text);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
before: before.join("\n\n").trim(),
|
||||||
|
history: sections.chunks.flatMap((chunk) =>
|
||||||
|
chunk.kind === "history" ? chunk.messages : [],
|
||||||
|
),
|
||||||
|
after: after.join("\n\n").trim(),
|
||||||
|
turn,
|
||||||
|
postTurn: sections.tail.join("\n\n").trim(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ChatMessage } from "../llm/client.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对话.历史:排序表中的可投影「标签」,不是硬锚点分区。
|
* 对话.历史:排序表中的可投影「标签」,不是硬锚点分区。
|
||||||
* 规范:docs/context-fragment-design.md
|
* 规范:docs/context-fragment-design.md
|
||||||
@@ -19,12 +21,36 @@ export function isDialogueHistoryRef(ref: string): boolean {
|
|||||||
|
|
||||||
export type HistoryProjection = "fixed" | "full" | "summary" | "fields";
|
export type HistoryProjection = "fixed" | "full" | "summary" | "fields";
|
||||||
|
|
||||||
|
/** 「本轮用户输入」标签及其别名(投影排序表 / 旧 skill 写法)。 */
|
||||||
|
export const CURRENT_TURN_TAG = "用户.最新输入";
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滞回系数:超过 2n 段才裁,一裁到 n。
|
||||||
|
* 作用:前缀每 n 段才位移一次,而不是每轮都位移。
|
||||||
|
* (每轮都裁的话,历史 message 数组的第一条每轮都变,整段前缀缓存全部失效)
|
||||||
|
*/
|
||||||
|
const PROJECTION_HYSTERESIS = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按投影级别裁剪历史正文。
|
* 按投影级别裁剪历史正文。
|
||||||
* - fixed:几乎不注入(占位一句)
|
* - fixed:几乎不注入(占位一句)
|
||||||
* - fields:最近约 5 段
|
* - fields:最近约 5 段
|
||||||
* - summary:最近约 20 段
|
* - summary:最近约 20 段
|
||||||
* - full:最近约 80 段(或整段若更短)
|
* - full:最近约 80 段(或整段若更短)
|
||||||
|
*
|
||||||
|
* 裁剪是「块级 + 滞回」的:段数在 n~2n 之间原样全给,超过 2n 才裁回 n。
|
||||||
|
* 这样前缀不是每轮滑一格,而是每 n 段才断一次,缓存命中率与上下文开销取得了折中。
|
||||||
*/
|
*/
|
||||||
export function projectDialogueHistory(
|
export function projectDialogueHistory(
|
||||||
raw: string,
|
raw: string,
|
||||||
@@ -40,12 +66,69 @@ export function projectDialogueHistory(
|
|||||||
const blocks = splitHistoryBlocks(text);
|
const blocks = splitHistoryBlocks(text);
|
||||||
const n =
|
const n =
|
||||||
mode === "fields" ? 5 : mode === "summary" || mode === "brief" ? 20 : 80;
|
mode === "fields" ? 5 : mode === "summary" || mode === "brief" ? 20 : 80;
|
||||||
const sliced = blocks.length > n ? blocks.slice(-n) : blocks;
|
const highWater = n * PROJECTION_HYSTERESIS;
|
||||||
const body = sliced.join("\n\n");
|
if (blocks.length <= highWater) return blocks.join("\n\n");
|
||||||
if (blocks.length > n) {
|
|
||||||
return `(仅最近 ${n} 段,更早已省略)\n\n${body}`;
|
const sliced = blocks.slice(-n);
|
||||||
|
return `(仅最近 ${n} 段,更早已省略)\n\n${sliced.join("\n\n")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 块头只认独占一行的 `## 用户` / `【用户】` / `用户:`(含同行带正文的写法)。 */
|
||||||
|
const HISTORY_ROLE_HEADER = /^(?:##[ \t]*|【)(用户|助手|系统)(?:】|[ \t]*)$/u;
|
||||||
|
const HISTORY_ROLE_INLINE = /^(用户|助手|系统)[::][ \t]*(.*)$/u;
|
||||||
|
|
||||||
|
type HistoryRole = "user" | "assistant" | "system";
|
||||||
|
|
||||||
|
const HISTORY_ROLE: Record<string, HistoryRole> = {
|
||||||
|
用户: "user",
|
||||||
|
助手: "assistant",
|
||||||
|
系统: "system",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「对话.历史」正文 → 真 message 序列(真多轮装配的入口)。
|
||||||
|
* 首个块头之前的文字(如「仅最近 20 段,更早已省略」)并入首条。
|
||||||
|
* 没有块头时返回空数组:那是无结构文本,由调用方当普通上下文处理。
|
||||||
|
*/
|
||||||
|
export function parseHistoryMessages(raw: string): ChatMessage[] {
|
||||||
|
const text = (raw || "").replace(/\r\n/g, "\n").trim();
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
const blocks: Array<{ role: HistoryRole; lines: string[] }> = [];
|
||||||
|
const preamble: string[] = [];
|
||||||
|
let current: { role: HistoryRole; lines: string[] } | null = null;
|
||||||
|
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
const matched = HISTORY_ROLE_HEADER.exec(trimmed);
|
||||||
|
const role = matched ? HISTORY_ROLE[matched[1]!] : undefined;
|
||||||
|
if (role) {
|
||||||
|
current = { role, lines: [] };
|
||||||
|
blocks.push(current);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 单行写法:`用户:我推门`
|
||||||
|
const inline = HISTORY_ROLE_INLINE.exec(trimmed);
|
||||||
|
const inlineRole = inline ? HISTORY_ROLE[inline[1]!] : undefined;
|
||||||
|
if (inline && inlineRole) {
|
||||||
|
current = { role: inlineRole, lines: [inline[2] ?? ""] };
|
||||||
|
blocks.push(current);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current) current.lines.push(line);
|
||||||
|
else preamble.push(line);
|
||||||
}
|
}
|
||||||
return body;
|
|
||||||
|
if (blocks.length === 0) return [];
|
||||||
|
|
||||||
|
const head = preamble.join("\n").trim();
|
||||||
|
return blocks
|
||||||
|
.map((block, index) => {
|
||||||
|
const body = block.lines.join("\n").trim();
|
||||||
|
const content = index === 0 && head ? `${head}\n\n${body}` : body;
|
||||||
|
return { role: block.role, content };
|
||||||
|
})
|
||||||
|
.filter((message) => message.content.trim().length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitHistoryBlocks(text: string): string[] {
|
function splitHistoryBlocks(text: string): string[] {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
|
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
|
||||||
|
import { ensureNetworkProxy } from "../config/network.js";
|
||||||
import { recentDistinctModels } from "./token-store.js";
|
import { recentDistinctModels } from "./token-store.js";
|
||||||
import {
|
import {
|
||||||
parsePricingDocument,
|
parsePricingDocument,
|
||||||
@@ -375,6 +376,7 @@ export async function fetchAndParsePricing(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPricingDocument(url: string): Promise<FetchedDoc> {
|
export async function fetchPricingDocument(url: string): Promise<FetchedDoc> {
|
||||||
|
await ensureNetworkProxy();
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
+5
-1
@@ -1,4 +1,6 @@
|
|||||||
import { MsEdgeTTS, OUTPUT_FORMAT } from "msedge-tts";
|
import { MsEdgeTTS, OUTPUT_FORMAT } from "msedge-tts";
|
||||||
|
|
||||||
|
import { ensureNetworkProxy, networkProxyAgent } from "../config/network.js";
|
||||||
import { readTtsCache, ttsCacheKey, writeTtsCache } from "./cache.js";
|
import { readTtsCache, ttsCacheKey, writeTtsCache } from "./cache.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_EDGE_VOICE,
|
DEFAULT_EDGE_VOICE,
|
||||||
@@ -60,7 +62,9 @@ async function streamToBuffer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function synthesizeChunk(voice: string, text: string): Promise<Buffer> {
|
async function synthesizeChunk(voice: string, text: string): Promise<Buffer> {
|
||||||
const tts = new MsEdgeTTS();
|
await ensureNetworkProxy();
|
||||||
|
// ws 不走 undici dispatcher,需要单独传 agent
|
||||||
|
const tts = new MsEdgeTTS({ agent: networkProxyAgent() });
|
||||||
await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3);
|
await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3);
|
||||||
const { audioStream } = tts.toStream(escapeXml(text));
|
const { audioStream } = tts.toStream(escapeXml(text));
|
||||||
return streamToBuffer(audioStream);
|
return streamToBuffer(audioStream);
|
||||||
|
|||||||
@@ -43,6 +43,118 @@ export type UnsupportedPresetSection = {
|
|||||||
reason: string;
|
reason: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 导入诊断:告诉用户“这份预设真的能跑吗、哪一块会散”。 */
|
||||||
|
export type PresetDiagnostics = {
|
||||||
|
/** 启用条目里出现的宏;known=false 的会原样外发 */
|
||||||
|
macros: Array<{
|
||||||
|
name: string;
|
||||||
|
known: boolean;
|
||||||
|
count: number;
|
||||||
|
entries: string[];
|
||||||
|
}>;
|
||||||
|
/** 伪工具协议:靠“真的调一次工具”输出正文的那类条目 */
|
||||||
|
toolProtocol: {
|
||||||
|
entries: string[];
|
||||||
|
/** 请求组装时需要映射的占位符 / 接口名 */
|
||||||
|
placeholders: string[];
|
||||||
|
/** 预设自带的工具类扩展配置 */
|
||||||
|
declarations: string[];
|
||||||
|
/** `SPreset.ToolBindings` 明确声明的工具:名字 + 参数可直接用 */
|
||||||
|
bindings: Array<{
|
||||||
|
entry: string;
|
||||||
|
toolName: string;
|
||||||
|
params: string[];
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
/** 标签配对:跨条目开合在合并消息后会散 */
|
||||||
|
tags: {
|
||||||
|
crossEntry: Array<{ tag: string; openIn: string; closeIn: string }>;
|
||||||
|
unclosed: Array<{ tag: string; openIn: string }>;
|
||||||
|
orphanClose: Array<{ tag: string; closeIn: string }>;
|
||||||
|
};
|
||||||
|
/** 每次请求都会变的宏,以及它相对 chatHistory 的位置 */
|
||||||
|
randomMacros: Array<{
|
||||||
|
entry: string;
|
||||||
|
macro: string;
|
||||||
|
count: number;
|
||||||
|
afterHistory: boolean;
|
||||||
|
}>;
|
||||||
|
regexes: Array<{
|
||||||
|
name: string;
|
||||||
|
placement: number[];
|
||||||
|
promptOnly: boolean;
|
||||||
|
markdownOnly: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
}>;
|
||||||
|
/** 预设携带的脚本:一律不执行 */
|
||||||
|
scripts: Array<{
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
bytes: number;
|
||||||
|
/** 脚本里检测到的能力(用于提示哪些没被替代) */
|
||||||
|
features: string[];
|
||||||
|
/** 大量 DOM 操作:多半是界面面板,不是运行时钩子 */
|
||||||
|
isUiPanel: boolean;
|
||||||
|
}>;
|
||||||
|
/** 会被静默忽略、导致预设变弱的项 */
|
||||||
|
silentGaps: {
|
||||||
|
/** `injection_position=1` 的启用条目:深度插入未实现 */
|
||||||
|
deepInjection: string[];
|
||||||
|
/** 脚本里检测到、但本版本没有替代的能力 */
|
||||||
|
unportedScriptFeatures: Array<{ script: string; features: string[] }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageSquashMode =
|
||||||
|
| "none"
|
||||||
|
| "merge"
|
||||||
|
| "system"
|
||||||
|
| "semi"
|
||||||
|
| "strict"
|
||||||
|
| "single-user-turn";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预设没写 `squash_system_messages` 时的缺省整形。
|
||||||
|
* 取 strict(system 折进相邻消息、强制交替、首条为 user)——兼容性优先:
|
||||||
|
* 各家上游对「首条 system / 连续同角色」的容忍度不一。
|
||||||
|
* 想要更贴酒馆的行为:`system`(只合并相邻 system,= 酒馆勾上 squash);想原样发出:`none`。
|
||||||
|
* 历史区(真多轮)只做「合并相邻同角色」,见 `shapeFramedMessages()`。
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PRESET_SQUASH: MessageSquashMode = "strict";
|
||||||
|
|
||||||
|
/** 预设对“发出前消息序列”的要求。 */
|
||||||
|
export type PresetMessagePolicy = {
|
||||||
|
/**
|
||||||
|
* 合并相邻同角色消息。
|
||||||
|
* none 原样
|
||||||
|
* merge 合并相邻同角色(比酒馆激进,会把 user 也合掉)
|
||||||
|
* system 只合并相邻 system(= 酒馆 squash_system_messages 勾上的等价物)
|
||||||
|
* semi 合并后把 system 并入相邻消息,强制角色交替
|
||||||
|
* strict 同 semi,并保证首条为 user
|
||||||
|
* single-user-turn 全部合成一条 user
|
||||||
|
* 请求里带工具组时后三档退化为 merge。
|
||||||
|
* 缺省(预设没写)为 strict,见 DEFAULT_PRESET_SQUASH。
|
||||||
|
* 历史区只做 merge 这一档:真多轮结构不会被揉散。
|
||||||
|
*/
|
||||||
|
squash?: MessageSquashMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 酒馆里跟聊天位置绑定的几条提示(new_chat_prompt 等),按位置插进历史/示例位。 */
|
||||||
|
export type PresetChatPrompts = {
|
||||||
|
newChat?: string;
|
||||||
|
newGroupChat?: string;
|
||||||
|
newExampleChat?: string;
|
||||||
|
continueNudge?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 酒馆的几条包裹模板:`personality_format` / `scenario_format` / `wi_format`。 */
|
||||||
|
export type PresetFormats = {
|
||||||
|
personality?: string;
|
||||||
|
scenario?: string;
|
||||||
|
worldInfo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PresetPackage = {
|
export type PresetPackage = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -50,6 +162,12 @@ export type PresetPackage = {
|
|||||||
prompts: PresetPromptEntry[];
|
prompts: PresetPromptEntry[];
|
||||||
promptOrder: PresetPromptOrderItem[];
|
promptOrder: PresetPromptOrderItem[];
|
||||||
generation: GenerationParameters;
|
generation: GenerationParameters;
|
||||||
|
/** 缺省视为 none(跟预设走:预设不要求整形就原样发)。 */
|
||||||
|
messagePolicy?: PresetMessagePolicy;
|
||||||
|
/** 酒馆的 new_chat_prompt 之类;缺省不发。 */
|
||||||
|
chatPrompts?: PresetChatPrompts;
|
||||||
|
/** 角色卡字段的包裹模板;缺省原样发。 */
|
||||||
|
formats?: PresetFormats;
|
||||||
unsupported: UnsupportedPresetSection[];
|
unsupported: UnsupportedPresetSection[];
|
||||||
importedAt: string;
|
importedAt: string;
|
||||||
raw?: unknown;
|
raw?: unknown;
|
||||||
@@ -63,4 +181,9 @@ export type PresetImportReport = {
|
|||||||
missingIdentifiers: string[];
|
missingIdentifiers: string[];
|
||||||
generationFields: string[];
|
generationFields: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
|
/** 选中的 prompt_order 属于哪个 character_id */
|
||||||
|
orderCharacterId: number | null;
|
||||||
|
/** 选中的顺序缺了哪些核心位置(main / chatHistory / jailbreak) */
|
||||||
|
missingCoreIdentifiers: string[];
|
||||||
|
diagnostics: PresetDiagnostics;
|
||||||
};
|
};
|
||||||
|
|||||||
+47
-13
@@ -19,7 +19,15 @@ import {
|
|||||||
selectJsonPayload,
|
selectJsonPayload,
|
||||||
tryParseJsonDoc,
|
tryParseJsonDoc,
|
||||||
} from "../parse/json-doc.js";
|
} from "../parse/json-doc.js";
|
||||||
import { assembleWorkerContext } from "../skills/context-segments.js";
|
import {
|
||||||
|
assembleWorkerContextSections,
|
||||||
|
workerContextToBags,
|
||||||
|
type WorkerContextSections,
|
||||||
|
} from "../skills/context-segments.js";
|
||||||
|
import type {
|
||||||
|
WorldInfoEntry,
|
||||||
|
WorldInfoPack,
|
||||||
|
} from "../preset/world-info-pack.js";
|
||||||
import { isPlayLayerActive } from "../skills/play-turn.js";
|
import { isPlayLayerActive } from "../skills/play-turn.js";
|
||||||
import { isNarratorEnabled } from "../skills/play-slots.js";
|
import { isNarratorEnabled } from "../skills/play-slots.js";
|
||||||
import {
|
import {
|
||||||
@@ -865,21 +873,21 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
|||||||
(useGmHarness ? GM_CHANCE_HARNESS_INSTRUCTION : "");
|
(useGmHarness ? GM_CHANCE_HARNESS_INSTRUCTION : "");
|
||||||
|
|
||||||
// 任务 messages 即可;preset 夹心由 wrapLlmForSession / PresetLlmProvider 统一装配
|
// 任务 messages 即可;preset 夹心由 wrapLlmForSession / PresetLlmProvider 统一装配
|
||||||
|
const sections = assembleWorkerContextSections({
|
||||||
|
inputs,
|
||||||
|
segments: worker.contextSegments,
|
||||||
|
blackboard: params.blackboard,
|
||||||
|
inputMerge,
|
||||||
|
workerId: worker.id,
|
||||||
|
workerName: worker.name,
|
||||||
|
outputTags: worker.outputTags,
|
||||||
|
});
|
||||||
const messages: ChatMessage[] = [
|
const messages: ChatMessage[] = [
|
||||||
{ role: "system", content: systemContent },
|
{ role: "system", content: systemContent },
|
||||||
{
|
{ role: "user", content: sections.flat },
|
||||||
role: "user",
|
|
||||||
content: assembleWorkerContext({
|
|
||||||
inputs,
|
|
||||||
segments: worker.contextSegments,
|
|
||||||
blackboard: params.blackboard,
|
|
||||||
inputMerge,
|
|
||||||
workerId: worker.id,
|
|
||||||
workerName: worker.name,
|
|
||||||
outputTags: worker.outputTags,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
// 含「对话.历史」的 worker:历史给真多轮 message,由预设的 chatHistory 洞展开。
|
||||||
|
const presetPack = workerPackFromSections(sections);
|
||||||
|
|
||||||
if (useGmHarness) {
|
if (useGmHarness) {
|
||||||
const systemMsg = messages.find((m) => m.role === "system");
|
const systemMsg = messages.find((m) => m.role === "system");
|
||||||
@@ -888,6 +896,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
|||||||
llm: params.llm,
|
llm: params.llm,
|
||||||
system: systemMsg?.content ?? systemContent,
|
system: systemMsg?.content ?? systemContent,
|
||||||
messages: rest,
|
messages: rest,
|
||||||
|
...(presetPack ? { presetPack } : {}),
|
||||||
stream: params.stream,
|
stream: params.stream,
|
||||||
caller: `worker:${worker.id}`,
|
caller: `worker:${worker.id}`,
|
||||||
label: worker.name?.trim() || worker.id,
|
label: worker.name?.trim() || worker.id,
|
||||||
@@ -911,6 +920,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
|||||||
caller: `worker:${worker.id}`,
|
caller: `worker:${worker.id}`,
|
||||||
label: worker.name?.trim() || worker.id,
|
label: worker.name?.trim() || worker.id,
|
||||||
responseFormat: "text" as const,
|
responseFormat: "text" as const,
|
||||||
|
...(presetPack ? { presetPack } : {}),
|
||||||
};
|
};
|
||||||
const result =
|
const result =
|
||||||
streamCallbacks && params.llm.completeStream
|
streamCallbacks && params.llm.completeStream
|
||||||
@@ -940,6 +950,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
|||||||
caller: `worker:${worker.id}`,
|
caller: `worker:${worker.id}`,
|
||||||
label: worker.name?.trim() || worker.id,
|
label: worker.name?.trim() || worker.id,
|
||||||
stream: streamCallbacks,
|
stream: streamCallbacks,
|
||||||
|
...(presetPack ? { presetPack } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const raw =
|
const raw =
|
||||||
@@ -953,6 +964,29 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分段上下文 → preset 四袋。
|
||||||
|
* 只当 contextSegments 里真有「对话.历史」时启用(否则保持旧的扁平单包)。
|
||||||
|
* 袋名留空,避免在正文里多出 `### 上下文` 这类标题。
|
||||||
|
*/
|
||||||
|
function workerPackFromSections(
|
||||||
|
sections: WorkerContextSections,
|
||||||
|
): WorldInfoPack | undefined {
|
||||||
|
if (!sections.chunks.some((chunk) => chunk.kind === "history")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const bags = workerContextToBags(sections);
|
||||||
|
const bag = (id: string, content: string): WorldInfoEntry[] =>
|
||||||
|
content ? [{ id, name: "", content }] : [];
|
||||||
|
return {
|
||||||
|
worldBookBefore: bag("ctx-before", bags.before),
|
||||||
|
history: bags.history,
|
||||||
|
worldBookAfter: bag("ctx-after", bags.after),
|
||||||
|
turn: bags.turn,
|
||||||
|
postTurn: bag("ctx-post", bags.postTurn),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** @internal 供单测 */
|
/** @internal 供单测 */
|
||||||
export function parseWorkerResponseForTest(
|
export function parseWorkerResponseForTest(
|
||||||
raw: string,
|
raw: string,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export type GmHarnessParams = {
|
|||||||
llm: LlmProvider;
|
llm: LlmProvider;
|
||||||
system: string;
|
system: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
|
/** 真多轮分袋(转给包装层,使历史以 message[] 进 chatHistory 洞) */
|
||||||
|
presetPack?: import("../preset/world-info-pack.js").WorldInfoPack;
|
||||||
stream?: GmHarnessStreamCallbacks;
|
stream?: GmHarnessStreamCallbacks;
|
||||||
maxIterations?: number;
|
maxIterations?: number;
|
||||||
caller?: string;
|
caller?: string;
|
||||||
@@ -50,6 +52,7 @@ export async function runGmHarness(
|
|||||||
tools: GM_TOOL_DEFINITIONS,
|
tools: GM_TOOL_DEFINITIONS,
|
||||||
caller: params.caller ?? "worker:world-simulator",
|
caller: params.caller ?? "worker:world-simulator",
|
||||||
label,
|
label,
|
||||||
|
presetPack: params.presetPack,
|
||||||
maxIterations: params.maxIterations ?? DEFAULT_GM_HARNESS_MAX_ITERATIONS,
|
maxIterations: params.maxIterations ?? DEFAULT_GM_HARNESS_MAX_ITERATIONS,
|
||||||
onThinkingDelta: (delta) => params.stream?.onThinkingDelta?.(delta),
|
onThinkingDelta: (delta) => params.stream?.onThinkingDelta?.(delta),
|
||||||
onOutputDelta: (delta) => params.stream?.onOutputDelta?.(delta),
|
onOutputDelta: (delta) => params.stream?.onOutputDelta?.(delta),
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
bumpKeepStats,
|
||||||
|
clearKeepStats,
|
||||||
|
copyKeepStats,
|
||||||
|
keepStatPercent,
|
||||||
|
keepStatText,
|
||||||
|
keepStatTotal,
|
||||||
|
readKeepStats,
|
||||||
|
sortKeepStats,
|
||||||
|
} from "../web/chat-keep-stats.js";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "wa-direct-chat-keep-stats-v1";
|
||||||
|
|
||||||
|
function fakeStorage() {
|
||||||
|
const map = new Map();
|
||||||
|
return {
|
||||||
|
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
||||||
|
setItem: (k, v) => map.set(k, String(v)),
|
||||||
|
removeItem: (k) => map.delete(k),
|
||||||
|
clear: () => map.clear(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpt = { profileId: "p-gpt", profileName: "主配置", model: "gpt-4o" };
|
||||||
|
const claude = { profileId: "p-claude", profileName: "备用", model: "claude-3-7" };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.localStorage = fakeStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chat keep stats", () => {
|
||||||
|
it("counts keeps per conversation and sorts them", () => {
|
||||||
|
bumpKeepStats("conv-a", gpt);
|
||||||
|
bumpKeepStats("conv-a", claude);
|
||||||
|
bumpKeepStats("conv-a", claude);
|
||||||
|
|
||||||
|
const stats = readKeepStats("conv-a");
|
||||||
|
expect(stats.map((s) => s.label)).toEqual(["备用", "主配置"]);
|
||||||
|
expect(stats[0].count).toBe(2);
|
||||||
|
expect(keepStatTotal(stats)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps conversations apart", () => {
|
||||||
|
bumpKeepStats("conv-a", gpt);
|
||||||
|
bumpKeepStats("conv-b", claude);
|
||||||
|
expect(readKeepStats("conv-a").map((s) => s.label)).toEqual(["主配置"]);
|
||||||
|
expect(readKeepStats("conv-b").map((s) => s.label)).toEqual(["备用"]);
|
||||||
|
clearKeepStats("conv-a");
|
||||||
|
expect(readKeepStats("conv-a")).toEqual([]);
|
||||||
|
expect(readKeepStats("conv-b")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts on unkeep and drops the model at zero", () => {
|
||||||
|
bumpKeepStats("conv-a", gpt);
|
||||||
|
bumpKeepStats("conv-a", gpt);
|
||||||
|
expect(readKeepStats("conv-a")[0].count).toBe(2);
|
||||||
|
bumpKeepStats("conv-a", gpt, -1);
|
||||||
|
expect(readKeepStats("conv-a")[0].count).toBe(1);
|
||||||
|
bumpKeepStats("conv-a", gpt, -1);
|
||||||
|
expect(readKeepStats("conv-a")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats the same profile + model as the same model even if the window id changes", () => {
|
||||||
|
bumpKeepStats("conv-a", { ...gpt, id: "w1" });
|
||||||
|
bumpKeepStats("conv-a", { ...gpt, id: "w2" });
|
||||||
|
expect(readKeepStats("conv-a")).toHaveLength(1);
|
||||||
|
expect(readKeepStats("conv-a")[0].count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the profile name when the model is the same string", () => {
|
||||||
|
const text = keepStatText({ profileName: "本地", model: "本地" });
|
||||||
|
expect(text.label).toBe("本地");
|
||||||
|
expect(text.model).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the counts when a conversation is copied or promoted", () => {
|
||||||
|
bumpKeepStats("conv-a", gpt);
|
||||||
|
bumpKeepStats("conv-a", claude);
|
||||||
|
bumpKeepStats("conv-a", claude);
|
||||||
|
copyKeepStats("conv-a", "conv-copy");
|
||||||
|
expect(readKeepStats("conv-copy").map((s) => s.count)).toEqual([2, 1]);
|
||||||
|
expect(readKeepStats("conv-a")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a broken storage payload", () => {
|
||||||
|
globalThis.localStorage.setItem(STORAGE_KEY, "{not json");
|
||||||
|
expect(readKeepStats("conv-a")).toEqual([]);
|
||||||
|
bumpKeepStats("conv-a", gpt);
|
||||||
|
expect(readKeepStats("conv-a")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts equal counts by label and scales bar widths", () => {
|
||||||
|
const sorted = sortKeepStats([
|
||||||
|
{ id: "b", label: "乙", model: "", count: 1 },
|
||||||
|
{ id: "a", label: "甲", model: "", count: 1 },
|
||||||
|
{ id: "c", label: "丙", model: "", count: 3 },
|
||||||
|
]);
|
||||||
|
expect(sorted.map((s) => s.id)).toEqual(["c", "a", "b"]);
|
||||||
|
expect(keepStatPercent(3, 3)).toBe(100);
|
||||||
|
expect(keepStatPercent(1, 3)).toBe(33);
|
||||||
|
expect(keepStatPercent(1, 100)).toBe(6);
|
||||||
|
expect(keepStatPercent(0, 3)).toBe(0);
|
||||||
|
expect(keepStatPercent(1, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { planWheelScroll } from "../web/chat-scroll.js";
|
||||||
|
|
||||||
|
describe("planWheelScroll", () => {
|
||||||
|
it("rolls the card first and leaves the big scroller alone", () => {
|
||||||
|
const plan = planWheelScroll({
|
||||||
|
cardScrollTop: 0,
|
||||||
|
cardScrollMax: 400,
|
||||||
|
deltaY: 100,
|
||||||
|
streamScrollTop: 0,
|
||||||
|
streamScrollMax: 2000,
|
||||||
|
});
|
||||||
|
expect(plan.cardDelta).toBe(100);
|
||||||
|
expect(plan.streamDelta).toBe(0);
|
||||||
|
expect(plan.rest).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands the leftover to the big scroller once the card hits its bottom", () => {
|
||||||
|
const plan = planWheelScroll({
|
||||||
|
cardScrollTop: 380,
|
||||||
|
cardScrollMax: 400,
|
||||||
|
deltaY: 100,
|
||||||
|
streamScrollTop: 0,
|
||||||
|
streamScrollMax: 2000,
|
||||||
|
});
|
||||||
|
expect(plan.cardDelta).toBe(20);
|
||||||
|
expect(plan.streamDelta).toBe(80);
|
||||||
|
expect(plan.rest).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the card when it is already at the bottom", () => {
|
||||||
|
const plan = planWheelScroll({
|
||||||
|
cardScrollTop: 400,
|
||||||
|
cardScrollMax: 400,
|
||||||
|
deltaY: 100,
|
||||||
|
streamScrollTop: 0,
|
||||||
|
streamScrollMax: 2000,
|
||||||
|
});
|
||||||
|
expect(plan.cardDelta).toBe(0);
|
||||||
|
expect(plan.streamDelta).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scrolls up the card first, then the big scroller", () => {
|
||||||
|
const plan = planWheelScroll({
|
||||||
|
cardScrollTop: 10,
|
||||||
|
cardScrollMax: 400,
|
||||||
|
deltaY: -100,
|
||||||
|
streamScrollTop: 500,
|
||||||
|
streamScrollMax: 2000,
|
||||||
|
});
|
||||||
|
expect(plan.cardDelta).toBe(-10);
|
||||||
|
expect(plan.streamDelta).toBe(-90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a card that cannot scroll as no card at all", () => {
|
||||||
|
const plan = planWheelScroll({
|
||||||
|
cardScrollTop: 0,
|
||||||
|
cardScrollMax: 0,
|
||||||
|
deltaY: 120,
|
||||||
|
streamScrollTop: 0,
|
||||||
|
streamScrollMax: 2000,
|
||||||
|
});
|
||||||
|
expect(plan.cardDelta).toBe(0);
|
||||||
|
expect(plan.streamDelta).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at the ends of both scrollers", () => {
|
||||||
|
const down = planWheelScroll({
|
||||||
|
cardScrollTop: 400,
|
||||||
|
cardScrollMax: 400,
|
||||||
|
deltaY: 100,
|
||||||
|
streamScrollTop: 2000,
|
||||||
|
streamScrollMax: 2000,
|
||||||
|
});
|
||||||
|
expect(down.cardDelta).toBe(0);
|
||||||
|
expect(down.streamDelta).toBe(0);
|
||||||
|
expect(down.rest).toBe(100);
|
||||||
|
|
||||||
|
const up = planWheelScroll({
|
||||||
|
cardScrollTop: 0,
|
||||||
|
cardScrollMax: 400,
|
||||||
|
deltaY: -100,
|
||||||
|
streamScrollTop: 0,
|
||||||
|
streamScrollMax: 2000,
|
||||||
|
});
|
||||||
|
expect(up.rest).toBe(-100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps fractional trackpad deltas", () => {
|
||||||
|
const plan = planWheelScroll({
|
||||||
|
cardScrollTop: 12.5,
|
||||||
|
cardScrollMax: 400,
|
||||||
|
deltaY: 7.25,
|
||||||
|
});
|
||||||
|
expect(plan.cardDelta).toBeCloseTo(7.25, 5);
|
||||||
|
expect(plan.rest).toBeCloseTo(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing without a delta", () => {
|
||||||
|
const plan = planWheelScroll({ cardScrollMax: 400, streamScrollMax: 800 });
|
||||||
|
expect(plan).toEqual({ cardDelta: 0, streamDelta: 0, rest: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
appendDialogueHistoryTurn,
|
appendDialogueHistoryTurn,
|
||||||
|
parseHistoryMessages,
|
||||||
projectDialogueHistory,
|
projectDialogueHistory,
|
||||||
buildHistoryFallback,
|
buildHistoryFallback,
|
||||||
isDialogueHistoryRef,
|
isDialogueHistoryRef,
|
||||||
@@ -14,18 +15,78 @@ import {
|
|||||||
synthesizeContextOrderFromWorkers,
|
synthesizeContextOrderFromWorkers,
|
||||||
} from "../src/skills/context-order.js";
|
} from "../src/skills/context-order.js";
|
||||||
|
|
||||||
|
describe("parseHistoryMessages", () => {
|
||||||
|
it("turns ## 用户 / ## 助手 blocks into real turns", () => {
|
||||||
|
const raw = appendDialogueHistoryTurn(
|
||||||
|
appendDialogueHistoryTurn("", { role: "助手", text: "门开了。" }),
|
||||||
|
{ role: "用户", text: "我推门。" },
|
||||||
|
);
|
||||||
|
expect(parseHistoryMessages(raw)).toEqual([
|
||||||
|
{ role: "assistant", content: "门开了。" },
|
||||||
|
{ role: "user", content: "我推门。" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts 【用户】 and 用户:single-line forms", () => {
|
||||||
|
expect(parseHistoryMessages("【用户】\n你好\n\n【助手】\n在。")).toEqual([
|
||||||
|
{ role: "user", content: "你好" },
|
||||||
|
{ role: "assistant", content: "在。" },
|
||||||
|
]);
|
||||||
|
expect(parseHistoryMessages("用户:你好\n助手:在。")).toEqual([
|
||||||
|
{ role: "user", content: "你好" },
|
||||||
|
{ role: "assistant", content: "在。" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the projection note on the first turn", () => {
|
||||||
|
const turns = parseHistoryMessages(
|
||||||
|
"(仅最近 20 段,更早已省略)\n\n## 用户\n\n你好",
|
||||||
|
);
|
||||||
|
expect(turns).toHaveLength(1);
|
||||||
|
expect(turns[0]!.content).toContain("仅最近");
|
||||||
|
expect(turns[0]!.content).toContain("你好");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] for text without role markers", () => {
|
||||||
|
expect(parseHistoryMessages("一段无结构的前情提要")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a body line that looks like markdown heading", () => {
|
||||||
|
const raw = "## 用户\n\n我说## 这不是块头\n\n## 助手\n\n嗯";
|
||||||
|
const turns = parseHistoryMessages(raw);
|
||||||
|
expect(turns).toHaveLength(2);
|
||||||
|
expect(turns[0]!.content).toContain("这不是块头");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("dialogue history projection", () => {
|
describe("dialogue history projection", () => {
|
||||||
it("recognizes history refs and projects by level", () => {
|
it("recognizes history refs and projects by level", () => {
|
||||||
expect(isDialogueHistoryRef("对话.历史")).toBe(true);
|
expect(isDialogueHistoryRef("对话.历史")).toBe(true);
|
||||||
expect(isDialogueHistoryRef("history")).toBe(true);
|
expect(isDialogueHistoryRef("history")).toBe(true);
|
||||||
const long = Array.from({ length: 30 }, (_, i) => `## 用户\n\n第${i}句`).join(
|
const long = Array.from({ length: 60 }, (_, i) => `## 用户\n\n第${i}句`).join(
|
||||||
"\n\n",
|
"\n\n",
|
||||||
);
|
);
|
||||||
const summary = projectDialogueHistory(long, "summary");
|
const summary = projectDialogueHistory(long, "summary");
|
||||||
expect(summary).toContain("仅最近");
|
expect(summary).toContain("仅最近");
|
||||||
|
// 裁到 n 段(summary = 20)
|
||||||
|
expect(summary.split("## 用户").length - 1).toBe(20);
|
||||||
expect(projectDialogueHistory(long, "fixed")).toContain("不注入");
|
expect(projectDialogueHistory(long, "fixed")).toContain("不注入");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the whole window until it doubles(滞回,不每轮位移)", () => {
|
||||||
|
const turn = (n: number) =>
|
||||||
|
Array.from({ length: n }, (_, i) => `## 用户\n\n第${i}句`).join("\n\n");
|
||||||
|
// 20~40 段(summary 的 n=20,高水位=40)原样全给,前缀稳定
|
||||||
|
expect(projectDialogueHistory(turn(30), "summary")).not.toContain("仅最近");
|
||||||
|
expect(projectDialogueHistory(turn(30), "summary").split("## 用户").length - 1).toBe(
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
// 超过高水位才裁,且一次裁到 20
|
||||||
|
expect(projectDialogueHistory(turn(41), "summary").split("## 用户").length - 1).toBe(
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("assembles history tag in flat segment order", () => {
|
it("assembles history tag in flat segment order", () => {
|
||||||
const bb = new Blackboard();
|
const bb = new Blackboard();
|
||||||
bb.write({
|
bb.write({
|
||||||
|
|||||||
+223
-2
@@ -8,18 +8,25 @@ import {
|
|||||||
assembleDirectChatMessages,
|
assembleDirectChatMessages,
|
||||||
buildChatHistory,
|
buildChatHistory,
|
||||||
buildDirectChatTaskMessages,
|
buildDirectChatTaskMessages,
|
||||||
|
abortReroll,
|
||||||
|
beginReroll,
|
||||||
buildPriorForWindow,
|
buildPriorForWindow,
|
||||||
createConversation,
|
createConversation,
|
||||||
createShell,
|
createShell,
|
||||||
DEFAULT_DIRECT_CHAT_SYSTEM,
|
DEFAULT_DIRECT_CHAT_SYSTEM,
|
||||||
|
duplicateConversation,
|
||||||
EMPTY_SHELL_SLOTS,
|
EMPTY_SHELL_SLOTS,
|
||||||
getConversation,
|
getConversation,
|
||||||
|
mergeTurnReplies,
|
||||||
|
switchTurnVariant,
|
||||||
|
writeRerollReplies,
|
||||||
getShell,
|
getShell,
|
||||||
listConversations,
|
listConversations,
|
||||||
listShells,
|
listShells,
|
||||||
promoteTemporary,
|
promoteTemporary,
|
||||||
resolveDirectChatPreset,
|
resolveDirectChatPreset,
|
||||||
runDirectChatTurn,
|
runDirectChatTurn,
|
||||||
|
type DirectChatTurn,
|
||||||
} from "../src/direct-chat/index.js";
|
} from "../src/direct-chat/index.js";
|
||||||
|
|
||||||
function samplePreset() {
|
function samplePreset() {
|
||||||
@@ -83,13 +90,13 @@ describe("direct-chat history assembly", () => {
|
|||||||
expect(contents.some((c) => String(c).includes("契约"))).toBe(true);
|
expect(contents.some((c) => String(c).includes("契约"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses default system prompt when slot empty", () => {
|
it("emits no system message when the slot is empty (酒馆没有隐式系统提示)", () => {
|
||||||
const task = buildDirectChatTaskMessages({
|
const task = buildDirectChatTaskMessages({
|
||||||
message: "hi",
|
message: "hi",
|
||||||
slots: EMPTY_SHELL_SLOTS,
|
slots: EMPTY_SHELL_SLOTS,
|
||||||
priorMessages: [],
|
priorMessages: [],
|
||||||
});
|
});
|
||||||
expect(task[0]?.content).toBe(DEFAULT_DIRECT_CHAT_SYSTEM);
|
expect(task.some((m) => m.role === "system")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,6 +154,40 @@ describe("direct-chat store", () => {
|
|||||||
expect(conversation.name).toBe("升格会话");
|
expect(conversation.name).toBe("升格会话");
|
||||||
expect(conversation.messages).toHaveLength(2);
|
expect(conversation.messages).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("duplicates a conversation under the same shell", () => {
|
||||||
|
const shell = createShell({ name: "壳" });
|
||||||
|
const conv = createConversation({
|
||||||
|
shellId: shell.id,
|
||||||
|
name: "原会话 副本",
|
||||||
|
windows: [
|
||||||
|
{ id: "w1", profileId: "p1", profileName: "甲", model: "m-a" },
|
||||||
|
],
|
||||||
|
turns: [
|
||||||
|
{
|
||||||
|
id: "t1",
|
||||||
|
user: { content: "问", createdAt: "1" },
|
||||||
|
replies: [{ windowId: "w1", content: "答" }],
|
||||||
|
selectedWindowId: "w1",
|
||||||
|
createdAt: "1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const copy = duplicateConversation(conv.id);
|
||||||
|
expect(copy.id).not.toBe(conv.id);
|
||||||
|
expect(copy.shellId).toBe(shell.id);
|
||||||
|
expect(copy.name).toBe("原会话 副本");
|
||||||
|
expect(copy.windows).toHaveLength(1);
|
||||||
|
expect(copy.windows[0]?.id).not.toBe("w1");
|
||||||
|
expect(copy.windows[0]?.profileId).toBe("p1");
|
||||||
|
expect(copy.turns[0]?.user.content).toBe("问");
|
||||||
|
expect(copy.turns[0]?.replies[0]?.windowId).toBe(copy.windows[0]?.id);
|
||||||
|
expect(copy.turns[0]?.selectedWindowId).toBe(copy.windows[0]?.id);
|
||||||
|
const original = getConversation(conv.id);
|
||||||
|
expect(original?.windows[0]?.id).toBe("w1");
|
||||||
|
expect(original?.turns).toHaveLength(1);
|
||||||
|
expect(listConversations(shell.id)).toHaveLength(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("direct-chat turn gating", () => {
|
describe("direct-chat turn gating", () => {
|
||||||
@@ -230,3 +271,183 @@ describe("buildPriorForWindow", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function sampleTurn(
|
||||||
|
id: string,
|
||||||
|
content: string,
|
||||||
|
reply: string,
|
||||||
|
): {
|
||||||
|
id: string;
|
||||||
|
user: { content: string; createdAt: string };
|
||||||
|
replies: Array<{ windowId: string; content: string }>;
|
||||||
|
createdAt: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
user: { content, createdAt: "2026-01-01T00:00:00.000Z" },
|
||||||
|
replies: [{ windowId: "w1", content: reply }],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("direct-chat turn variants", () => {
|
||||||
|
const turns = () => [sampleTurn("t1", "问1", "答1"), sampleTurn("t2", "问2", "答2")];
|
||||||
|
|
||||||
|
it("edits the original line, rerolls, and keeps later turns on the previous version", () => {
|
||||||
|
const begun = beginReroll(turns(), "t1", "问1改");
|
||||||
|
expect(begun).not.toBeNull();
|
||||||
|
expect(begun!.turns.map((t) => t.id)).toEqual(["t1"]);
|
||||||
|
expect(begun!.turns[0]?.user.content).toBe("问1改");
|
||||||
|
expect(begun!.turns[0]?.replies).toEqual([]);
|
||||||
|
expect(begun!.turns[0]?.variantIndex).toBe(1);
|
||||||
|
expect(begun!.turns[0]?.variants?.[0]?.user.content).toBe("问1");
|
||||||
|
expect(begun!.turns[0]?.variants?.[0]?.replies[0]?.content).toBe("答1");
|
||||||
|
expect(begun!.turns[0]?.variants?.[0]?.after[0]?.user.content).toBe("问2");
|
||||||
|
expect(begun!.prior).toEqual([]);
|
||||||
|
|
||||||
|
const withReply = writeRerollReplies(begun!.turns, "t1", [
|
||||||
|
{ windowId: "w1", content: "新答" },
|
||||||
|
]);
|
||||||
|
const back = switchTurnVariant(withReply, "t1", "prev");
|
||||||
|
expect(back).not.toBeNull();
|
||||||
|
expect(back!.map((t) => t.user.content)).toEqual(["问1", "问2"]);
|
||||||
|
expect(back![0]?.replies[0]?.content).toBe("答1");
|
||||||
|
|
||||||
|
const again = switchTurnVariant(back!, "t1", "next");
|
||||||
|
expect(again).not.toBeNull();
|
||||||
|
expect(again!).toHaveLength(1);
|
||||||
|
expect(again![0]?.user.content).toBe("问1改");
|
||||||
|
expect(again![0]?.replies[0]?.content).toBe("新答");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("direct reroll keeps the same user text and a new version", () => {
|
||||||
|
const begun = beginReroll(turns(), "t2", "问2");
|
||||||
|
expect(begun!.turns.map((t) => t.user.content)).toEqual(["问1", "问2"]);
|
||||||
|
expect(begun!.turns[1]?.variantIndex).toBe(1);
|
||||||
|
expect(begun!.turns[1]?.variants?.[0]?.replies[0]?.content).toBe("答2");
|
||||||
|
expect(begun!.prior.map((t) => t.id)).toEqual(["t1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops an empty reroll when generation produced nothing", () => {
|
||||||
|
const begun = beginReroll(turns(), "t1", "问1");
|
||||||
|
const aborted = abortReroll(begun!.turns, "t1");
|
||||||
|
expect(aborted).not.toBeNull();
|
||||||
|
expect(aborted!.map((t) => t.user.content)).toEqual(["问1", "问2"]);
|
||||||
|
expect(aborted![0]?.replies[0]?.content).toBe("答1");
|
||||||
|
expect(aborted![0]?.variants).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects blank text and unknown turns", () => {
|
||||||
|
expect(beginReroll(turns(), "t1", " ")).toBeNull();
|
||||||
|
expect(beginReroll(turns(), "missing", "x")).toBeNull();
|
||||||
|
expect(switchTurnVariant(turns(), "t1", "next")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries only the failed windows and keeps the rest in place", () => {
|
||||||
|
const base: DirectChatTurn[] = [
|
||||||
|
{
|
||||||
|
id: "t1",
|
||||||
|
user: { content: "问1", createdAt: "2026-01-01T00:00:00.000Z" },
|
||||||
|
replies: [
|
||||||
|
{ windowId: "w1", content: "好的回复" },
|
||||||
|
{ windowId: "w2", error: "上游 403" },
|
||||||
|
{ windowId: "w3", error: "超时" },
|
||||||
|
],
|
||||||
|
selectedWindowId: "w1",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const merged = mergeTurnReplies(base, "t1", [
|
||||||
|
{ windowId: "w2", content: "重试后的回复" },
|
||||||
|
{ windowId: "w3", error: "还是超时" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
const turn = merged[0]!;
|
||||||
|
// 只重试 w2/w3:w1 原样保留,w3 的错误被新错误覆盖
|
||||||
|
expect(turn.replies.find((r) => r.windowId === "w1")?.content).toBe(
|
||||||
|
"好的回复",
|
||||||
|
);
|
||||||
|
expect(turn.replies.find((r) => r.windowId === "w2")?.content).toBe(
|
||||||
|
"重试后的回复",
|
||||||
|
);
|
||||||
|
expect(turn.replies.find((r) => r.windowId === "w2")?.error).toBeUndefined();
|
||||||
|
expect(turn.replies.find((r) => r.windowId === "w3")?.error).toBe(
|
||||||
|
"还是超时",
|
||||||
|
);
|
||||||
|
// 不新开版本,且不动「保留此回复」
|
||||||
|
expect(turn.variants ?? []).toHaveLength(0);
|
||||||
|
expect(turn.variantIndex).toBe(0);
|
||||||
|
expect(turn.selectedWindowId).toBe("w1");
|
||||||
|
// 原数据不被就地修改
|
||||||
|
expect(base[0]!.replies.find((r) => r.windowId === "w2")?.error).toBe(
|
||||||
|
"上游 403",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges a retry into the active version without dropping other versions", () => {
|
||||||
|
const begun = beginReroll(turns(), "t1", "问1改");
|
||||||
|
const withReply = writeRerollReplies(begun!.turns, "t1", [
|
||||||
|
{ windowId: "w1", content: "新答" },
|
||||||
|
]);
|
||||||
|
const merged = mergeTurnReplies(withReply, "t1", [
|
||||||
|
{ windowId: "w1", content: "重试后" },
|
||||||
|
]);
|
||||||
|
expect(merged[0]?.replies[0]?.content).toBe("重试后");
|
||||||
|
expect(merged[0]?.variants).toHaveLength(2);
|
||||||
|
expect(merged[0]?.variants?.[1]?.replies[0]?.content).toBe("重试后");
|
||||||
|
expect(merged[0]?.variants?.[0]?.replies[0]?.content).toBe("答1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves turns untouched when the target turn is gone", () => {
|
||||||
|
const base = turns();
|
||||||
|
expect(mergeTurnReplies(base, "missing", [{ windowId: "w1", content: "x" }])).toBe(
|
||||||
|
base,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("roundtrips versions and remaps window ids when copying", () => {
|
||||||
|
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
|
||||||
|
path.join(tmpdir(), "wa-direct-chat-variants-"),
|
||||||
|
);
|
||||||
|
const shell = createShell({ name: "壳" });
|
||||||
|
const conv = createConversation({
|
||||||
|
shellId: shell.id,
|
||||||
|
windows: [{ id: "w1", profileId: "p1", profileName: "甲" }],
|
||||||
|
turns: [
|
||||||
|
{
|
||||||
|
...sampleTurn("t1", "问", "新"),
|
||||||
|
variantIndex: 1,
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
user: { content: "问", createdAt: "2026-01-01T00:00:00.000Z" },
|
||||||
|
replies: [{ windowId: "w1", content: "旧" }],
|
||||||
|
selectedWindowId: "w1",
|
||||||
|
after: [sampleTurn("t2", "后", "续")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user: { content: "问", createdAt: "2026-01-01T00:00:00.000Z" },
|
||||||
|
replies: [{ windowId: "w1", content: "新" }],
|
||||||
|
after: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const loaded = getConversation(conv.id);
|
||||||
|
expect(loaded?.turns[0]?.variantIndex).toBe(1);
|
||||||
|
expect(loaded?.turns[0]?.variants?.[0]?.replies[0]?.content).toBe("旧");
|
||||||
|
expect(loaded?.turns[0]?.variants?.[0]?.selectedWindowId).toBe("w1");
|
||||||
|
expect(loaded?.turns[0]?.variants?.[0]?.after[0]?.user.content).toBe("后");
|
||||||
|
|
||||||
|
const copy = duplicateConversation(conv.id);
|
||||||
|
const wid = copy.windows[0]?.id;
|
||||||
|
expect(wid).toBeTruthy();
|
||||||
|
expect(wid).not.toBe("w1");
|
||||||
|
expect(copy.turns[0]?.replies[0]?.windowId).toBe(wid);
|
||||||
|
expect(copy.turns[0]?.variants?.[0]?.replies[0]?.windowId).toBe(wid);
|
||||||
|
expect(copy.turns[0]?.variants?.[0]?.selectedWindowId).toBe(wid);
|
||||||
|
expect(copy.turns[0]?.variants?.[0]?.after[0]?.replies[0]?.windowId).toBe(wid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ChatMessage } from "../src/llm/client.js";
|
||||||
|
import { squashMessages } from "../src/llm/message-squash.js";
|
||||||
|
|
||||||
|
const sys = (content: string): ChatMessage => ({ role: "system", content });
|
||||||
|
const user = (content: string): ChatMessage => ({ role: "user", content });
|
||||||
|
const asst = (content: string): ChatMessage => ({ role: "assistant", content });
|
||||||
|
|
||||||
|
const toolCall: ChatMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
tool_calls: [
|
||||||
|
{ id: "1", type: "function", function: { name: "t", arguments: "{}" } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const toolResult: ChatMessage = { role: "tool", content: "{}", tool_call_id: "1" };
|
||||||
|
|
||||||
|
describe("squashMessages", () => {
|
||||||
|
it("none returns the list untouched", () => {
|
||||||
|
const input = [sys("a"), sys("b")];
|
||||||
|
expect(squashMessages(input, "none")).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merge joins adjacent same-role messages with a blank line", () => {
|
||||||
|
const out = squashMessages([sys("a"), sys("b"), user("c")], "merge");
|
||||||
|
expect(out).toEqual([sys("a\n\nb"), user("c")]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merge keeps role changes intact", () => {
|
||||||
|
const out = squashMessages([sys("a"), user("b"), asst("c")], "merge");
|
||||||
|
expect(out.map((m) => m.role)).toEqual(["system", "user", "assistant"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never merges tool groups", () => {
|
||||||
|
const out = squashMessages(
|
||||||
|
[sys("a"), sys("b"), toolCall, toolResult, asst("x"), asst("y")],
|
||||||
|
"merge",
|
||||||
|
);
|
||||||
|
expect(out.map((m) => m.role)).toEqual([
|
||||||
|
"system",
|
||||||
|
"assistant",
|
||||||
|
"tool",
|
||||||
|
"assistant",
|
||||||
|
]);
|
||||||
|
// 工具组原样保留
|
||||||
|
expect(out[1]).toBe(toolCall);
|
||||||
|
expect(out[2]).toBe(toolResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("semi folds system into neighbouring messages", () => {
|
||||||
|
const out = squashMessages(
|
||||||
|
[sys("规则"), user("问题"), sys("补充"), asst("回答")],
|
||||||
|
"semi",
|
||||||
|
);
|
||||||
|
expect(out).toEqual([user("规则\n\n问题"), asst("补充\n\n回答")]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strict makes the first message a user", () => {
|
||||||
|
const out = squashMessages([asst("前言"), user("问题")], "strict");
|
||||||
|
expect(out[0]?.role).toBe("user");
|
||||||
|
expect(out[0]?.content).toBe("前言\n\n问题");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("single-user-turn collapses everything into one user message", () => {
|
||||||
|
const out = squashMessages(
|
||||||
|
[sys("规则"), user("问题"), asst("回答")],
|
||||||
|
"single-user-turn",
|
||||||
|
);
|
||||||
|
expect(out).toEqual([user("规则\n\n问题\n\n回答")]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to merge when a tool group is present", () => {
|
||||||
|
const out = squashMessages(
|
||||||
|
[sys("a"), sys("b"), toolCall, toolResult],
|
||||||
|
"single-user-turn",
|
||||||
|
);
|
||||||
|
expect(out.map((m) => m.role)).toEqual(["system", "assistant", "tool"]);
|
||||||
|
expect(out[0]?.content).toBe("a\n\nb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty input", () => {
|
||||||
|
expect(squashMessages([], "strict")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("system 档(对齐酒馆 squashSystemMessages)", () => {
|
||||||
|
it("只合并相邻 system,user/assistant 不动", () => {
|
||||||
|
const out = squashMessages(
|
||||||
|
[sys("a"), sys("b"), user("u1"), user("u2"), asst("c1"), asst("c2"), sys("d"), sys("e")],
|
||||||
|
"system",
|
||||||
|
);
|
||||||
|
expect(out).toEqual([
|
||||||
|
sys("a\nb"),
|
||||||
|
user("u1"),
|
||||||
|
user("u2"),
|
||||||
|
asst("c1"),
|
||||||
|
asst("c2"),
|
||||||
|
sys("d\ne"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("不碰工具组", () => {
|
||||||
|
const out = squashMessages(
|
||||||
|
[sys("a"), sys("b"), toolCall, toolResult, sys("c"), sys("d")],
|
||||||
|
"system",
|
||||||
|
);
|
||||||
|
expect(out.map((m) => m.role)).toEqual(["system", "assistant", "tool", "system"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,11 +20,11 @@ describe("persona store", () => {
|
|||||||
} = await import("../src/persona/store.js");
|
} = await import("../src/persona/store.js");
|
||||||
|
|
||||||
const list = listPersonas();
|
const list = listPersonas();
|
||||||
expect(list.map((p) => p.name)).toEqual(["玩家", CREATION_PLACEHOLDER_NAME]);
|
expect(list.map((p) => p.name)).toEqual(["User", CREATION_PLACEHOLDER_NAME]);
|
||||||
expect(getActivePersona()?.name).toBe("玩家");
|
expect(getActivePersona()?.name).toBe("User");
|
||||||
expect(getCreationDefaultPersona()?.name).toBe(CREATION_PLACEHOLDER_NAME);
|
expect(getCreationDefaultPersona()?.name).toBe(CREATION_PLACEHOLDER_NAME);
|
||||||
expect(personaForLifecycle("design")?.name).toBe(CREATION_PLACEHOLDER_NAME);
|
expect(personaForLifecycle("design")?.name).toBe(CREATION_PLACEHOLDER_NAME);
|
||||||
expect(personaForLifecycle("play")?.name).toBe("玩家");
|
expect(personaForLifecycle("play")?.name).toBe("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creation default is exclusive and used only in design", async () => {
|
it("creation default is exclusive and used only in design", async () => {
|
||||||
|
|||||||
+113
-11
@@ -1,12 +1,19 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ChatMessage } from "../src/llm/client.js";
|
||||||
import { importSillyTavernPreset } from "../src/preset/importer.js";
|
import { importSillyTavernPreset } from "../src/preset/importer.js";
|
||||||
import { assemblePlayWorkerMessages } from "../src/preset/play-frame.js";
|
import {
|
||||||
import { assemblePresetMessages } from "../src/preset/assembler.js";
|
assemblePlayWorkerMessages,
|
||||||
|
shapeFramedMessages,
|
||||||
|
} from "../src/preset/play-frame.js";
|
||||||
|
import { assemblePresetMessages, PRESET_HISTORY_MARK } from "../src/preset/assembler.js";
|
||||||
import type { WorldInfoPack } from "../src/preset/world-info-pack.js";
|
import type { WorldInfoPack } from "../src/preset/world-info-pack.js";
|
||||||
|
|
||||||
const pack: WorldInfoPack = {
|
const pack: WorldInfoPack = {
|
||||||
worldBookBefore: [{ id: "lore", name: "舞台", content: "古镇夜雨" }],
|
worldBookBefore: [{ id: "lore", name: "舞台", content: "古镇夜雨" }],
|
||||||
history: "用户:昨天来过",
|
history: [
|
||||||
|
{ role: "assistant", content: "昨夜灯灭" },
|
||||||
|
{ role: "user", content: "昨天来过" },
|
||||||
|
],
|
||||||
worldBookAfter: [{ id: "vars", name: "变量", content: "HP 3" }],
|
worldBookAfter: [{ id: "vars", name: "变量", content: "HP 3" }],
|
||||||
turn: "我推门",
|
turn: "我推门",
|
||||||
postTurn: [{ id: "tail", name: "尾注", content: "勿剧透" }],
|
postTurn: [{ id: "tail", name: "尾注", content: "勿剧透" }],
|
||||||
@@ -52,12 +59,14 @@ describe("assemblePlayWorkerMessages", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(messages.map((m) => [m.role, m.content])).toEqual([
|
expect(messages.map((m) => [m.role, m.content])).toEqual([
|
||||||
["system", "MAIN"],
|
// 任务契约并进 main,不再单独一条
|
||||||
|
["system", "MAIN\n\nSKILL"],
|
||||||
[
|
[
|
||||||
"system",
|
"system",
|
||||||
"### 任务契约\n\nSKILL\n\n### 舞台\n\n古镇夜雨",
|
"### 舞台\n\n古镇夜雨",
|
||||||
],
|
],
|
||||||
["system", "用户:昨天来过"],
|
["assistant", "昨夜灯灭"],
|
||||||
|
["user", "昨天来过"],
|
||||||
["system", "### 变量\n\nHP 3"],
|
["system", "### 变量\n\nHP 3"],
|
||||||
["user", "我推门"],
|
["user", "我推门"],
|
||||||
["system", "PHI"],
|
["system", "PHI"],
|
||||||
@@ -103,7 +112,7 @@ describe("assemblePlayWorkerMessages", () => {
|
|||||||
preset: report.preset,
|
preset: report.preset,
|
||||||
pack: {
|
pack: {
|
||||||
worldBookBefore: [{ id: "lore", name: "设定", content: "港口" }],
|
worldBookBefore: [{ id: "lore", name: "设定", content: "港口" }],
|
||||||
history: "昨日对话",
|
history: [{ role: "user", content: "昨日对话" }],
|
||||||
worldBookAfter: [],
|
worldBookAfter: [],
|
||||||
turn: "本轮",
|
turn: "本轮",
|
||||||
postTurn: [],
|
postTurn: [],
|
||||||
@@ -117,7 +126,7 @@ describe("assemblePlayWorkerMessages", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not fill ST charDescription even if resolver returns content", () => {
|
it("passes ST card markers to the resolver (play-frame gates filling on the card slot)", () => {
|
||||||
const report = importSillyTavernPreset({
|
const report = importSillyTavernPreset({
|
||||||
prompts: [
|
prompts: [
|
||||||
{
|
{
|
||||||
@@ -148,7 +157,8 @@ describe("assemblePlayWorkerMessages", () => {
|
|||||||
if (id === "chatHistory") return "HIST";
|
if (id === "chatHistory") return "HIST";
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
expect(messages.map((m) => m.content)).toEqual(["HIST"]);
|
// 这里测的是装配器本身:marker 会交给 resolver,填不填由 play-frame 的槽位决定。
|
||||||
|
expect(messages.map((m) => m.content)).toEqual(["CARD", "HIST"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fills personaDescription and expands @玩家 with the given persona", () => {
|
it("fills personaDescription and expands @玩家 with the given persona", () => {
|
||||||
@@ -189,7 +199,7 @@ describe("assemblePlayWorkerMessages", () => {
|
|||||||
preset: report.preset,
|
preset: report.preset,
|
||||||
pack: {
|
pack: {
|
||||||
worldBookBefore: [],
|
worldBookBefore: [],
|
||||||
history: "昨日",
|
history: [{ role: "assistant", content: "昨日" }],
|
||||||
worldBookAfter: [],
|
worldBookAfter: [],
|
||||||
turn: "本轮",
|
turn: "本轮",
|
||||||
postTurn: [],
|
postTurn: [],
|
||||||
@@ -197,6 +207,98 @@ describe("assemblePlayWorkerMessages", () => {
|
|||||||
persona: { name: "@玩家", description: "占位人设" },
|
persona: { name: "@玩家", description: "占位人设" },
|
||||||
});
|
});
|
||||||
expect(messages.some((m) => m.content === "占位人设")).toBe(true);
|
expect(messages.some((m) => m.content === "占位人设")).toBe(true);
|
||||||
expect(messages.some((m) => m.content === "对 @玩家 说话")).toBe(true);
|
expect(
|
||||||
|
messages.some((m) => String(m.content).startsWith("对 @玩家 说话")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects injection_position=1 entries into the history at injection_depth", () => {
|
||||||
|
const report = importSillyTavernPreset({
|
||||||
|
prompts: [
|
||||||
|
{ identifier: "main", name: "Main", role: "system", content: "MAIN" },
|
||||||
|
{
|
||||||
|
identifier: "chatHistory",
|
||||||
|
name: "History",
|
||||||
|
role: "system",
|
||||||
|
marker: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: "deep",
|
||||||
|
name: "深度破限",
|
||||||
|
role: "system",
|
||||||
|
content: "DEEP",
|
||||||
|
injection_position: 1,
|
||||||
|
injection_depth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prompt_order: [
|
||||||
|
{
|
||||||
|
character_id: 1,
|
||||||
|
order: [
|
||||||
|
{ identifier: "main", enabled: true },
|
||||||
|
{ identifier: "chatHistory", enabled: true },
|
||||||
|
{ identifier: "deep", enabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = assemblePlayWorkerMessages({
|
||||||
|
systemPrompt: "",
|
||||||
|
preset: report.preset,
|
||||||
|
pack: {
|
||||||
|
worldBookBefore: [],
|
||||||
|
history: [
|
||||||
|
{ role: "assistant", content: "昨夜灯灭" },
|
||||||
|
{ role: "user", content: "昨天来过" },
|
||||||
|
],
|
||||||
|
worldBookAfter: [],
|
||||||
|
turn: "我推门",
|
||||||
|
postTurn: [],
|
||||||
|
},
|
||||||
|
persona: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages.map((m) => [m.role, m.content])).toEqual([
|
||||||
|
["system", "MAIN"],
|
||||||
|
["assistant", "昨夜灯灭"],
|
||||||
|
["system", "DEEP"],
|
||||||
|
["user", "昨天来过"],
|
||||||
|
["user", "我推门"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges consecutive same-role history turns but keeps them as turns", () => {
|
||||||
|
const messages: ChatMessage[] = [
|
||||||
|
{ role: "system", content: "A" },
|
||||||
|
{ role: "system", content: "B" },
|
||||||
|
{ role: "assistant", content: "旧1" },
|
||||||
|
{ role: "user", content: "旧2" },
|
||||||
|
{ role: "user", content: "旧3(助手当时没出正文)" },
|
||||||
|
{ role: "system", content: "DEEP" },
|
||||||
|
{ role: "assistant", content: "旧4" },
|
||||||
|
{ role: "system", content: "C" },
|
||||||
|
{ role: "user", content: "本轮" },
|
||||||
|
];
|
||||||
|
for (const index of [2, 3, 4, 5, 6]) {
|
||||||
|
Object.assign(messages[index] as object, { [PRESET_HISTORY_MARK]: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const shaped = shapeFramedMessages(messages, "strict");
|
||||||
|
const roles = shaped.map((m) => m.role);
|
||||||
|
|
||||||
|
// 历史还是多轮(没被揉成一条),且连发的两条用户合并为一条
|
||||||
|
expect(roles).toContain("assistant");
|
||||||
|
expect(shaped.filter((m) => m.role === "user" && m.content.includes("旧2")))
|
||||||
|
.toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
shaped.some(
|
||||||
|
(m) => m.role === "user" && m.content.includes("旧2") && m.content.includes("旧3"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
// 深度插入的 system 不被折掉
|
||||||
|
expect(shaped.some((m) => m.content === "DEEP")).toBe(true);
|
||||||
|
// 史前 system 仍按 strict 处理(没出现第二条 user 以外的奇形)
|
||||||
|
expect(shaped.at(-1)!.role).toBe("user");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { importSillyTavernPreset } from "../src/preset/importer.js";
|
||||||
|
import { assemblePlayWorkerMessages } from "../src/preset/play-frame.js";
|
||||||
|
import { parseExampleDialogue } from "../src/preset/examples.js";
|
||||||
|
|
||||||
|
const rawPreset = {
|
||||||
|
new_chat_prompt: "[Start a new Chat]",
|
||||||
|
new_example_chat_prompt: "[Example Chat]",
|
||||||
|
prompts: [
|
||||||
|
{ identifier: "main", name: "main", role: "system", content: "MAIN" },
|
||||||
|
{
|
||||||
|
identifier: "dialogueExamples",
|
||||||
|
name: "examples",
|
||||||
|
role: "system",
|
||||||
|
marker: true,
|
||||||
|
content: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: "chatHistory",
|
||||||
|
name: "history",
|
||||||
|
role: "system",
|
||||||
|
marker: true,
|
||||||
|
content: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prompt_order: [
|
||||||
|
{
|
||||||
|
character_id: 100001,
|
||||||
|
order: [
|
||||||
|
{ identifier: "main", enabled: true },
|
||||||
|
{ identifier: "dialogueExamples", enabled: true },
|
||||||
|
{ identifier: "chatHistory", enabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyPack = {
|
||||||
|
worldBookBefore: [],
|
||||||
|
history: [],
|
||||||
|
worldBookAfter: [],
|
||||||
|
turn: "",
|
||||||
|
postTurn: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function assemble(overrides: Record<string, unknown> = {}) {
|
||||||
|
const report = importSillyTavernPreset(rawPreset, { name: "t" });
|
||||||
|
return assemblePlayWorkerMessages({
|
||||||
|
systemPrompt: "",
|
||||||
|
preset: report.preset,
|
||||||
|
pack: { ...emptyPack, ...overrides },
|
||||||
|
persona: null,
|
||||||
|
charName: "ProbeCard",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("酒馆包装层:new_chat_prompt 与示例对话", () => {
|
||||||
|
it("chatHistory 空时插一条 new_chat_prompt", () => {
|
||||||
|
const messages = assemble();
|
||||||
|
const history = messages.filter((m) => m.content === "[Start a new Chat]");
|
||||||
|
expect(history).toHaveLength(1);
|
||||||
|
expect(history[0]?.role).toBe("system");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("有历史时 new_chat_prompt 仍排在历史最前", () => {
|
||||||
|
const messages = assemble({
|
||||||
|
history: [
|
||||||
|
{ role: "user", content: "第一句" },
|
||||||
|
{ role: "assistant", content: "第二句" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const texts = messages.map((m) => m.content);
|
||||||
|
const head = texts.indexOf("[Start a new Chat]");
|
||||||
|
expect(head).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(texts.indexOf("第一句")).toBe(head + 1);
|
||||||
|
expect(texts.indexOf("第二句")).toBe(head + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("示例对话按 <START> 切段,带 example_user / example_assistant", () => {
|
||||||
|
const messages = assemble({
|
||||||
|
exampleDialogue:
|
||||||
|
"<START>\n{{user}}: 今天做什么?\n{{char}}: 读书。\n更多解释",
|
||||||
|
});
|
||||||
|
expect(messages.map((m) => m.content)).toContain("[Example Chat]");
|
||||||
|
const user = messages.find((m) => m.name === "example_user");
|
||||||
|
const assistant = messages.find((m) => m.name === "example_assistant");
|
||||||
|
expect(user?.role).toBe("system");
|
||||||
|
expect(user?.content).toBe("今天做什么?");
|
||||||
|
expect(assistant?.role).toBe("system");
|
||||||
|
expect(assistant?.content).toBe("读书。\n更多解释");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("没有示例对话就不发 new_example_chat_prompt", () => {
|
||||||
|
const texts = assemble().map((m) => m.content);
|
||||||
|
expect(texts).not.toContain("[Example Chat]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("人设描述为空时不塞「姓名:X」", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
{
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
identifier: "personaDescription",
|
||||||
|
name: "persona",
|
||||||
|
role: "system",
|
||||||
|
marker: true,
|
||||||
|
content: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prompt_order: [
|
||||||
|
{ character_id: 100001, order: [{ identifier: "personaDescription", enabled: true }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: "t2" },
|
||||||
|
);
|
||||||
|
const messages = assemblePlayWorkerMessages({
|
||||||
|
systemPrompt: "",
|
||||||
|
preset: report.preset,
|
||||||
|
pack: emptyPack,
|
||||||
|
persona: { name: "User", description: "" },
|
||||||
|
charName: "ProbeCard",
|
||||||
|
});
|
||||||
|
expect(messages.some((m) => String(m.content).startsWith("姓名:"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseExampleDialogue", () => {
|
||||||
|
it("多块示例各自成组", () => {
|
||||||
|
const blocks = parseExampleDialogue({
|
||||||
|
text: "<START>\nA: 一\nB: 二\n<START>\nA: 三\nB: 四",
|
||||||
|
userName: "A",
|
||||||
|
charName: "B",
|
||||||
|
});
|
||||||
|
expect(blocks.map((block) => block.length)).toEqual([2, 2]);
|
||||||
|
expect(blocks[1]?.map((m) => m.content)).toEqual(["三", "四"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("续行并入上一条", () => {
|
||||||
|
const blocks = parseExampleDialogue({
|
||||||
|
text: "A: 第一行\n第二行\nB: 回复",
|
||||||
|
userName: "A",
|
||||||
|
charName: "B",
|
||||||
|
});
|
||||||
|
expect(blocks[0]?.map((m) => m.content)).toEqual(["第一行\n第二行", "回复"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("角色卡三块(槽位填)", () => {
|
||||||
|
const rawCardPreset = {
|
||||||
|
personality_format: "<character_personality>\n{{personality}}\n</character_personality>",
|
||||||
|
scenario_format: "<scenario>\n{{scenario}}\n</scenario>",
|
||||||
|
prompts: [
|
||||||
|
{ identifier: "charDescription", name: "desc", role: "system", marker: true, content: "" },
|
||||||
|
{ identifier: "charPersonality", name: "pers", role: "system", marker: true, content: "" },
|
||||||
|
{ identifier: "scenario", name: "scen", role: "system", marker: true, content: "" },
|
||||||
|
],
|
||||||
|
prompt_order: [
|
||||||
|
{
|
||||||
|
character_id: 100001,
|
||||||
|
order: [
|
||||||
|
{ identifier: "charDescription", enabled: true },
|
||||||
|
{ identifier: "charPersonality", enabled: true },
|
||||||
|
{ identifier: "scenario", enabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function build(card: unknown) {
|
||||||
|
const report = importSillyTavernPreset(rawCardPreset, { name: "card" });
|
||||||
|
return assemblePlayWorkerMessages({
|
||||||
|
systemPrompt: "",
|
||||||
|
preset: report.preset,
|
||||||
|
pack: { ...emptyPack, card },
|
||||||
|
persona: null,
|
||||||
|
charName: "ProbeCard",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("描述原样发,性格与场景套预设模板", () => {
|
||||||
|
const messages = build({
|
||||||
|
description: "DESC",
|
||||||
|
personality: "PERS",
|
||||||
|
scenario: "SCEN",
|
||||||
|
});
|
||||||
|
const texts = messages.map((m) => m.content);
|
||||||
|
expect(texts).toContain("DESC");
|
||||||
|
expect(texts).toContain("<character_personality>\nPERS\n</character_personality>");
|
||||||
|
expect(texts).toContain("<scenario>\nSCEN\n</scenario>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("槽位为空就整条不发", () => {
|
||||||
|
expect(build(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* 预设保真度(conformance):预设声明什么,发出去的 messages[] 就得是什么。
|
||||||
|
*
|
||||||
|
* 每个用例对应一条「预设是否真的实现了」的断言:
|
||||||
|
* 顺序 / 双过滤 / 未引用条目 / 角色 / 宏 / 历史多轮 / 深度插入 / 生成参数 / squash
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type {
|
||||||
|
ChatMessage,
|
||||||
|
CompleteResult,
|
||||||
|
LlmProvider,
|
||||||
|
} from "../src/llm/client.js";
|
||||||
|
import { wrapLlmForSession, resolveSquashForPreset } from "../src/llm/preset-wrapper.js";
|
||||||
|
import { importSillyTavernPreset, readSquashMode } from "../src/preset/importer.js";
|
||||||
|
import { assemblePlayWorkerMessages } from "../src/preset/play-frame.js";
|
||||||
|
import type { WorldInfoPack } from "../src/preset/world-info-pack.js";
|
||||||
|
|
||||||
|
type RawPreset = Record<string, unknown>;
|
||||||
|
|
||||||
|
/** 一份「把该有的都放上」的 ST 预设:顺序、双过滤、宏、深度插入、未引用条目。 */
|
||||||
|
function richPreset(extra: RawPreset = {}): RawPreset {
|
||||||
|
return {
|
||||||
|
name: "保真度样本",
|
||||||
|
temperature: 0.77,
|
||||||
|
top_p: 0.9,
|
||||||
|
openai_max_tokens: 777,
|
||||||
|
squash_system_messages: false,
|
||||||
|
prompts: [
|
||||||
|
{ identifier: "main", name: "主提示", role: "system", content: "MAIN {{char}}" },
|
||||||
|
{ identifier: "style", name: "文风", role: "system", content: "文风:克制" },
|
||||||
|
{
|
||||||
|
identifier: "personaDescription",
|
||||||
|
name: "用户角色",
|
||||||
|
role: "system",
|
||||||
|
marker: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: "chatHistory",
|
||||||
|
name: "历史",
|
||||||
|
role: "system",
|
||||||
|
marker: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: "deep",
|
||||||
|
name: "历史里的破限",
|
||||||
|
role: "system",
|
||||||
|
content: "DEEP",
|
||||||
|
injection_position: 1,
|
||||||
|
injection_depth: 1,
|
||||||
|
injection_order: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: "jailbreak",
|
||||||
|
name: "越狱",
|
||||||
|
role: "system",
|
||||||
|
content: "PHI {{user}} / {{persona}}",
|
||||||
|
},
|
||||||
|
{ identifier: "vars", name: "变量", role: "system", content: "{{setvar::hp::3}}" },
|
||||||
|
{
|
||||||
|
identifier: "readvar",
|
||||||
|
name: "读变量",
|
||||||
|
role: "system",
|
||||||
|
content: "HP={{getvar::hp}}",
|
||||||
|
},
|
||||||
|
{ identifier: "off-entry", name: "关掉的条目", role: "system", content: "OFF" },
|
||||||
|
{
|
||||||
|
identifier: "order-off",
|
||||||
|
name: "顺序里关掉",
|
||||||
|
role: "system",
|
||||||
|
content: "ORDER_OFF",
|
||||||
|
},
|
||||||
|
{ identifier: "orphan", name: "没被顺序引用", role: "system", content: "ORPHAN" },
|
||||||
|
],
|
||||||
|
prompt_order: [
|
||||||
|
{
|
||||||
|
character_id: 1,
|
||||||
|
order: [
|
||||||
|
{ identifier: "main", enabled: true },
|
||||||
|
{ identifier: "style", enabled: true },
|
||||||
|
{ identifier: "personaDescription", enabled: true },
|
||||||
|
{ identifier: "chatHistory", enabled: true },
|
||||||
|
{ identifier: "deep", enabled: true },
|
||||||
|
{ identifier: "jailbreak", enabled: true },
|
||||||
|
{ identifier: "vars", enabled: true },
|
||||||
|
{ identifier: "readvar", enabled: true },
|
||||||
|
{ identifier: "off-entry", enabled: true },
|
||||||
|
{ identifier: "order-off", enabled: false },
|
||||||
|
// orphan 不在这里 → 必须不出现
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pack: WorldInfoPack = {
|
||||||
|
worldBookBefore: [{ id: "lore", name: "", content: "舞台:古镇夜雨" }],
|
||||||
|
history: [
|
||||||
|
{ role: "assistant", content: "门开了。" },
|
||||||
|
{ role: "user", content: "我推门。" },
|
||||||
|
],
|
||||||
|
worldBookAfter: [{ id: "state", name: "", content: "HP 3" }],
|
||||||
|
turn: "我抬头",
|
||||||
|
postTurn: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function build(raw: RawPreset, opts: { charName?: string } = {}): ChatMessage[] {
|
||||||
|
const report = importSillyTavernPreset(raw);
|
||||||
|
return assemblePlayWorkerMessages({
|
||||||
|
systemPrompt: "",
|
||||||
|
preset: report.preset,
|
||||||
|
pack,
|
||||||
|
persona: { name: "林晚", description: "二十岁,缄默" },
|
||||||
|
charName: opts.charName ?? "长夜行",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("预设保真度:结构", () => {
|
||||||
|
it("按 prompt_order 顺序、按条目 role 发消息(squash=none 时原样)", () => {
|
||||||
|
const messages = build(richPreset());
|
||||||
|
expect(messages.map((m) => [m.role, m.content])).toEqual([
|
||||||
|
["system", "MAIN 长夜行"],
|
||||||
|
["system", "文风:克制"],
|
||||||
|
["system", "二十岁,缄默"],
|
||||||
|
["system", "舞台:古镇夜雨"],
|
||||||
|
["assistant", "门开了。"],
|
||||||
|
["system", "DEEP"], // depth=1 → 插在历史倒数第一条之前
|
||||||
|
["user", "我推门。"],
|
||||||
|
["system", "HP 3"],
|
||||||
|
["user", "我抬头"],
|
||||||
|
["system", "PHI 林晚 / 二十岁,缄默"],
|
||||||
|
["system", "HP=3"],
|
||||||
|
["system", "OFF"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("order.enabled=false 才不发;prompt.enabled=false 照发(跟酒馆一致)", () => {
|
||||||
|
const raw = richPreset();
|
||||||
|
const prompts = raw.prompts as Array<Record<string, unknown>>;
|
||||||
|
prompts.find((p) => p.identifier === "off-entry")!.enabled = false;
|
||||||
|
const text = build(raw)
|
||||||
|
.map((m) => m.content)
|
||||||
|
.join("\n");
|
||||||
|
expect(text).toContain("OFF");
|
||||||
|
expect(text).not.toContain("ORDER_OFF");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompt_order 没引用的条目一律不发", () => {
|
||||||
|
const text = build(richPreset())
|
||||||
|
.map((m) => m.content)
|
||||||
|
.join("\n");
|
||||||
|
expect(text).not.toContain("ORPHAN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("宏在装配期展开,跨条目共享一份变量表", () => {
|
||||||
|
const text = build(richPreset())
|
||||||
|
.map((m) => m.content)
|
||||||
|
.join("\n");
|
||||||
|
expect(text).toContain("MAIN 长夜行");
|
||||||
|
expect(text).toContain("PHI 林晚 / 二十岁,缄默");
|
||||||
|
// setvar 在 vars 条目、getvar 在后面的 readvar 条目
|
||||||
|
expect(text).toContain("HP=3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("历史洞展开成真多轮,不是一段文本", () => {
|
||||||
|
const messages = build(richPreset());
|
||||||
|
// 两轮各自一条 message,且没有哪一条把两轮揉在一起
|
||||||
|
expect(messages.some((m) => m.role === "assistant" && m.content === "门开了。"))
|
||||||
|
.toBe(true);
|
||||||
|
expect(messages.some((m) => m.role === "user" && m.content === "我推门。"))
|
||||||
|
.toBe(true);
|
||||||
|
expect(messages.some((m) => m.content.includes("门开了") && m.content.includes("我推门")))
|
||||||
|
.toBe(false);
|
||||||
|
// 变成一条 system 文本也不算:历史里的轮次必须保留 user / assistant
|
||||||
|
const assistantTurns = messages.filter((m) => m.role === "assistant");
|
||||||
|
expect(assistantTurns.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injection_position=1 按 depth 从历史底部插", () => {
|
||||||
|
const messages = build(richPreset());
|
||||||
|
const index = messages.findIndex((m) => m.content === "DEEP");
|
||||||
|
const histStart = messages.findIndex((m) => m.content === "门开了。");
|
||||||
|
const histEnd = messages.findIndex((m) => m.content === "我推门。");
|
||||||
|
expect(index).toBeGreaterThan(histStart);
|
||||||
|
expect(index).toBeLessThan(histEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("depth=0 贴在历史末尾(本轮输入之前)", () => {
|
||||||
|
const raw = richPreset();
|
||||||
|
const prompts = raw.prompts as Array<Record<string, unknown>>;
|
||||||
|
prompts.find((p) => p.identifier === "deep")!.injection_depth = 0;
|
||||||
|
const messages = build(raw);
|
||||||
|
const order = messages.map((m) => m.content);
|
||||||
|
expect(order.indexOf("DEEP")).toBe(order.indexOf("我推门。") + 1);
|
||||||
|
expect(order.indexOf("我抬头")).toBeGreaterThan(order.indexOf("DEEP"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("预设保真度:squash 与生成参数", () => {
|
||||||
|
it("读 squash_system_messages:bool / 枚举 / 缺失", () => {
|
||||||
|
expect(readSquashMode(true)).toBe("system");
|
||||||
|
expect(readSquashMode(false)).toBe("none");
|
||||||
|
expect(readSquashMode("strict")).toBe("strict");
|
||||||
|
expect(readSquashMode(2)).toBe("semi");
|
||||||
|
expect(readSquashMode(undefined)).toBeUndefined();
|
||||||
|
expect(readSquashMode("weird")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("预设写了 squash 就跟预设;没写落回缺省 strict", () => {
|
||||||
|
const withNone = importSillyTavernPreset(richPreset()).preset;
|
||||||
|
expect(withNone.messagePolicy?.squash).toBe("none");
|
||||||
|
expect(resolveSquashForPreset(withNone, "follow-preset")).toBe("none");
|
||||||
|
|
||||||
|
const withTrue = importSillyTavernPreset(
|
||||||
|
richPreset({ squash_system_messages: true }),
|
||||||
|
).preset;
|
||||||
|
expect(resolveSquashForPreset(withTrue, "follow-preset")).toBe("system");
|
||||||
|
|
||||||
|
const rawDefault = richPreset();
|
||||||
|
delete rawDefault.squash_system_messages;
|
||||||
|
const byDefault = importSillyTavernPreset(rawDefault).preset;
|
||||||
|
expect(byDefault.messagePolicy).toBeUndefined();
|
||||||
|
expect(resolveSquashForPreset(byDefault, "follow-preset")).toBe("strict");
|
||||||
|
|
||||||
|
// 设置页显式选的优先于预设
|
||||||
|
expect(resolveSquashForPreset(withTrue, "semi")).toBe("semi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("生成参数从预设进请求", async () => {
|
||||||
|
const seen: Array<Record<string, unknown>> = [];
|
||||||
|
const provider: LlmProvider = {
|
||||||
|
async complete(_messages, options): Promise<CompleteResult> {
|
||||||
|
seen.push((options?.generation ?? {}) as Record<string, unknown>);
|
||||||
|
return { content: "ok", model: "m" };
|
||||||
|
},
|
||||||
|
async completeWithTools() {
|
||||||
|
return { content: "ok", toolCalls: [], model: "m" };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const preset = importSillyTavernPreset(richPreset()).preset;
|
||||||
|
await wrapLlmForSession(provider, undefined, () => preset).complete([
|
||||||
|
{ role: "user", content: "TASK" },
|
||||||
|
]);
|
||||||
|
expect(seen[0]).toMatchObject({ temperature: 0.77, topP: 0.9, maxOutputTokens: 777 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildPresetDiagnostics,
|
||||||
|
diagnosticsWarnings,
|
||||||
|
} from "../src/preset/diagnostics.js";
|
||||||
|
import { importSillyTavernPreset } from "../src/preset/importer.js";
|
||||||
|
|
||||||
|
function entry(
|
||||||
|
id: string,
|
||||||
|
content: string,
|
||||||
|
extra: Partial<Record<string, unknown>> = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
identifier: id,
|
||||||
|
name: id,
|
||||||
|
role: "system",
|
||||||
|
content,
|
||||||
|
enabled: true,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawPreset(prompts: unknown[], extras: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
prompts,
|
||||||
|
prompt_order: [
|
||||||
|
{
|
||||||
|
character_id: 100001,
|
||||||
|
order: (prompts as Array<{ identifier: string }>).map((p) => ({
|
||||||
|
identifier: p.identifier,
|
||||||
|
enabled: true,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...extras,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("导入诊断:跨条目标签", () => {
|
||||||
|
it("detects tags opened in one entry and closed in another", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset([
|
||||||
|
entry("a", "<system>开局"),
|
||||||
|
entry("b", "中间"),
|
||||||
|
entry("c", "</system>收尾"),
|
||||||
|
]),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
const cross = report.diagnostics.tags.crossEntry;
|
||||||
|
expect(cross).toEqual([{ tag: "system", openIn: "a", closeIn: "c" }]);
|
||||||
|
expect(
|
||||||
|
report.warnings.some((w) => w.includes("跨条目标签配对")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports CJK tag names", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset([entry("a", "以下是<对话历史>"), entry("b", "</对话历史>结束")]),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
expect(report.diagnostics.tags.crossEntry).toEqual([
|
||||||
|
{ tag: "对话历史", openIn: "a", closeIn: "b" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports tags never closed", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset([entry("a", "<game>正文")]),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
expect(report.diagnostics.tags.unclosed).toEqual([
|
||||||
|
{ tag: "game", openIn: "a" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores self-closing tags", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset([entry("a", "换行<br/>结束")]),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
expect(report.diagnostics.tags.unclosed).toEqual([]);
|
||||||
|
expect(report.diagnostics.tags.orphanClose).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("导入诊断:伪工具协议", () => {
|
||||||
|
it("finds placeholder-style protocols", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset([
|
||||||
|
entry(
|
||||||
|
"sys",
|
||||||
|
"[Interface]\nactive_interface = %TOOL_NAME%\noutput_channel = %TOOL_NAME%",
|
||||||
|
),
|
||||||
|
entry("tool", '[contract]\nstream_target = "display_stream"'),
|
||||||
|
]),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
const proto = report.diagnostics.toolProtocol;
|
||||||
|
expect(proto.entries).toEqual(["sys", "tool"]);
|
||||||
|
expect(proto.placeholders).toContain("%TOOL_NAME%");
|
||||||
|
expect(report.warnings.some((w) => w.includes("伪工具协议"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads SPreset.ToolBindings form into tool name and params", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset([entry("bound", "所有输出必须调用 game_content 工具")], {
|
||||||
|
extensions: {
|
||||||
|
SPreset: {
|
||||||
|
ToolBindings: {
|
||||||
|
bound: {
|
||||||
|
enabled: true,
|
||||||
|
form: {
|
||||||
|
name: "game_content",
|
||||||
|
description: "全部输出在此工具内",
|
||||||
|
parameters: [{ name: "content", type: "string", required: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
expect(report.diagnostics.toolProtocol.bindings).toEqual([
|
||||||
|
{
|
||||||
|
entry: "bound",
|
||||||
|
toolName: "game_content",
|
||||||
|
params: ["content"],
|
||||||
|
description: "全部输出在此工具内",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("导入诊断:宏与随机宏", () => {
|
||||||
|
it("separates known from unknown macros", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset([
|
||||||
|
entry("a", "{{user}} {{setvar::k::v}}{{getvar::k}}{{turnPlus1}}"),
|
||||||
|
]),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
const macros = report.diagnostics.macros;
|
||||||
|
expect(macros.find((m) => m.name === "{{user}}")?.known).toBe(true);
|
||||||
|
expect(macros.find((m) => m.name === "{{turnPlus1}}")?.known).toBe(false);
|
||||||
|
expect(
|
||||||
|
report.warnings.some((w) => w.includes("未识别宏")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks random macros before chatHistory as cache-breaking", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset([
|
||||||
|
entry("noise", "{{roll 999}}"),
|
||||||
|
entry("chatHistory", "", { marker: true }),
|
||||||
|
]),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
const noise = report.diagnostics.randomMacros.find(
|
||||||
|
(r) => r.entry === "noise",
|
||||||
|
);
|
||||||
|
expect(noise?.afterHistory).toBe(false);
|
||||||
|
expect(
|
||||||
|
report.warnings.some((w) => w.includes("提示缓存每轮失效")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks random macros after chatHistory as harmless", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset([
|
||||||
|
entry("chatHistory", "", { marker: true }),
|
||||||
|
entry("noise", "{{roll 999}}"),
|
||||||
|
]),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
const noise = report.diagnostics.randomMacros.find(
|
||||||
|
(r) => r.entry === "noise",
|
||||||
|
);
|
||||||
|
expect(noise?.afterHistory).toBe(true);
|
||||||
|
expect(
|
||||||
|
report.warnings.some((w) => w.includes("提示缓存每轮失效")),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("导入诊断:squash_system_messages 映射", () => {
|
||||||
|
it.each([
|
||||||
|
[true, "system"],
|
||||||
|
[false, "none"],
|
||||||
|
["semi", "semi"],
|
||||||
|
["STRICT", "strict"],
|
||||||
|
[2, "semi"],
|
||||||
|
[undefined, undefined],
|
||||||
|
] as const)("%s → %s", (input, expected) => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset([entry("a", "x")], { squash_system_messages: input }),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
expect(report.preset.messagePolicy?.squash).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("导入诊断:静默失效告警", () => {
|
||||||
|
it("reports injection_position=1 entries as depth insertions", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset([
|
||||||
|
entry("main", "主线"),
|
||||||
|
entry("deepjail", "深度注入的破限", { injection_position: 1 }),
|
||||||
|
]),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
expect(report.diagnostics.silentGaps.deepInjection).toEqual(["deepjail"]);
|
||||||
|
expect(
|
||||||
|
report.warnings.some((w) => w.includes("injection_depth")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores depth-injection entries that prompt_order leaves off", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset(
|
||||||
|
[entry("main", "主线"), entry("off", "关掉的", { injection_position: 1 })],
|
||||||
|
{
|
||||||
|
prompt_order: [
|
||||||
|
{
|
||||||
|
character_id: 100001,
|
||||||
|
order: [
|
||||||
|
{ identifier: "main", enabled: true },
|
||||||
|
{ identifier: "off", enabled: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
expect(report.diagnostics.silentGaps.deepInjection).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags script capabilities that have no native replacement", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset([entry("main", "主线")], {
|
||||||
|
extensions: {
|
||||||
|
tavern_helper: {
|
||||||
|
scripts: [
|
||||||
|
{
|
||||||
|
type: "script",
|
||||||
|
name: "防截断",
|
||||||
|
content:
|
||||||
|
"const r = await fetch(url); const reader = res.body.getReader(); // text/event-stream",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
const script = report.diagnostics.scripts[0];
|
||||||
|
expect(script?.features).toContain("改写请求 / 拦截 fetch");
|
||||||
|
expect(script?.features).toContain("流式解包 / 防截断");
|
||||||
|
expect(report.diagnostics.silentGaps.unportedScriptFeatures[0]?.script).toBe(
|
||||||
|
"防截断",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
report.warnings.some((w) => w.includes("本版本没有替代")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mentions display-layer regexes leaking their tags", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset([entry("main", "主线")], {
|
||||||
|
regex_scripts: [
|
||||||
|
{ id: "r1", scriptName: "隐藏选项", markdownOnly: true, placement: [2] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
report.warnings.some((w) => w.includes("显示层规则")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("diagnosticsWarnings", () => {
|
||||||
|
it("keeps quiet for a clean preset", () => {
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
rawPreset([
|
||||||
|
entry("main", "主线"),
|
||||||
|
entry("chatHistory", "", { marker: true }),
|
||||||
|
entry("jailbreak", "收尾"),
|
||||||
|
]),
|
||||||
|
{ name: "t" },
|
||||||
|
);
|
||||||
|
expect(diagnosticsWarnings(report.diagnostics)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { expandAtDirectives, expandPresetDirectives } from "../src/directives/expand.js";
|
||||||
|
import { collectMacroNames, createMacroVars, expandMacros } from "../src/directives/macros.js";
|
||||||
|
import { assemblePresetMessages } from "../src/preset/assembler.js";
|
||||||
|
import type { PresetPackage } from "../src/types/preset.js";
|
||||||
|
|
||||||
|
describe("expandMacros", () => {
|
||||||
|
it("strips {{// }} comments, including multi-line ones", () => {
|
||||||
|
const r = expandMacros("前{{// \n声明:禁止外传\n\n}}后");
|
||||||
|
expect(r.text).toBe("前后");
|
||||||
|
expect(r.hits.map((h) => h.kind)).toEqual(["comment"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expand @ tokens inside a comment", () => {
|
||||||
|
const r = expandAtDirectives("{{// 不要动 @玩家 }}", {
|
||||||
|
persona: { name: "阿泽" },
|
||||||
|
});
|
||||||
|
expect(r.text).toBe("");
|
||||||
|
expect(r.hits.map((h) => h.kind)).toEqual(["comment"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("{{trim}} expands to empty and asks caller to trim", () => {
|
||||||
|
const r = expandMacros(" {{// 备注 }}{{trim}}\n正文 ");
|
||||||
|
expect(r.text).toBe(" \n正文 ");
|
||||||
|
expect(r.trim).toBe(true);
|
||||||
|
expect(expandAtDirectives(" {{trim}}\n正文 ").text).toBe("正文");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("{{user}} and {{char}}", () => {
|
||||||
|
const r = expandMacros("{{user}} 与 {{char}}", {
|
||||||
|
user: "林晚",
|
||||||
|
char: "长夜行",
|
||||||
|
});
|
||||||
|
expect(r.text).toBe("林晚 与 长夜行");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("{{char}} falls back to 作品", () => {
|
||||||
|
expect(expandMacros("{{char}}").text).toBe("作品");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("{{persona}} expands to the persona description", () => {
|
||||||
|
expect(expandMacros("{{persona}}", { persona: "二十岁,缄默" }).text).toBe(
|
||||||
|
"二十岁,缄默",
|
||||||
|
);
|
||||||
|
// 没设人设 → 空串(与酒馆一致)
|
||||||
|
expect(expandMacros("[{{persona}}]").text).toBe("[]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats {{persona}} as a known macro in the import report", () => {
|
||||||
|
expect(collectMacroNames("{{persona}} {{charPrompt}}")).toContain(
|
||||||
|
"{{persona}}",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setvar then getvar in the same scope", () => {
|
||||||
|
const vars = createMacroVars();
|
||||||
|
expect(expandMacros("{{setvar::a::你好}}", { vars }).text).toBe("");
|
||||||
|
expect(expandMacros("{{getvar::a}}!", { vars }).text).toBe("你好!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getvar on unset variable is empty", () => {
|
||||||
|
expect(expandMacros("[{{getvar::nope}}]").text).toBe("[]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps whitespace inside setvar values", () => {
|
||||||
|
const vars = createMacroVars();
|
||||||
|
expandMacros("{{setvar::set1:: }}", { vars });
|
||||||
|
expect(vars.get("set1")).toBe(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addvar appends", () => {
|
||||||
|
const vars = createMacroVars();
|
||||||
|
expandMacros("{{setvar::k::a}}{{addvar::k::b}}", { vars });
|
||||||
|
expect(vars.get("k")).toBe("ab");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("{{roll N}} yields an integer in range", () => {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const value = Number(expandMacros("{{roll 60}}").text);
|
||||||
|
expect(Number.isInteger(value)).toBe(true);
|
||||||
|
expect(value).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(value).toBeLessThanOrEqual(60);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("{{roll:1d6}} and {{roll::d9}} roll dice", () => {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const six = Number(expandMacros("{{roll:1d6}}").text);
|
||||||
|
expect(six).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(six).toBeLessThanOrEqual(6);
|
||||||
|
const nine = Number(expandMacros("{{roll::d9}}").text);
|
||||||
|
expect(nine).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(nine).toBeLessThanOrEqual(9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not roll when allowDice is false", () => {
|
||||||
|
expect(expandMacros("{{roll 60}}", { allowDice: false }).text).toBe(
|
||||||
|
"{{roll 60}}",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves unknown macros untouched", () => {
|
||||||
|
const r = expandMacros("{{turnPlus1}} {{MEMORY_TABLE}} {{ }}");
|
||||||
|
expect(r.text).toBe("{{turnPlus1}} {{MEMORY_TABLE}} {{ }}");
|
||||||
|
expect(r.hits).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collectMacroNames reports unknown macros by raw text", () => {
|
||||||
|
const names = collectMacroNames(
|
||||||
|
"{{// c}}{{user}}{{setvar::a::b}}{{turnPlus1}}{{roll 9}}",
|
||||||
|
);
|
||||||
|
expect(names).toContain("{{comment}}");
|
||||||
|
expect(names).toContain("{{user}}");
|
||||||
|
expect(names).toContain("{{setvar}}");
|
||||||
|
expect(names).toContain("{{roll}}");
|
||||||
|
expect(names.some((n) => n.includes("turnPlus1"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function presetWith(prompts: Array<{ id: string; content: string }>): PresetPackage {
|
||||||
|
return {
|
||||||
|
id: "p",
|
||||||
|
name: "p",
|
||||||
|
source: "sillytavern",
|
||||||
|
prompts: prompts.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.id,
|
||||||
|
enabled: true,
|
||||||
|
role: "system" as const,
|
||||||
|
content: p.content,
|
||||||
|
marker: false,
|
||||||
|
sourceIdentifier: p.id,
|
||||||
|
})),
|
||||||
|
promptOrder: prompts.map((p, i) => ({
|
||||||
|
promptId: p.id,
|
||||||
|
enabled: true,
|
||||||
|
orderIndex: i,
|
||||||
|
})),
|
||||||
|
generation: {},
|
||||||
|
unsupported: [],
|
||||||
|
importedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("preset assembly macro scope", () => {
|
||||||
|
it("setvar in an earlier entry is visible to getvar in a later entry", () => {
|
||||||
|
const preset = presetWith([
|
||||||
|
{ id: "a", content: "{{setvar::set1:: }}{{// 预设说明 }}" },
|
||||||
|
{ id: "b", content: "规则{{getvar::set1}}结束" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const vars = createMacroVars();
|
||||||
|
const messages = assemblePresetMessages(
|
||||||
|
preset,
|
||||||
|
() => null,
|
||||||
|
(content) => expandPresetDirectives(content, { vars, user: "阿泽" }).text,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(messages.map((m) => m.content)).toEqual(["规则 结束"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands {{char}} from assembly context", () => {
|
||||||
|
const preset = presetWith([{ id: "a", content: "<{{char}}>正文</{{char}}>" }]);
|
||||||
|
const messages = assemblePresetMessages(
|
||||||
|
preset,
|
||||||
|
() => null,
|
||||||
|
(content) => expandPresetDirectives(content, { charName: "长夜行" }).text,
|
||||||
|
);
|
||||||
|
expect(messages[0]?.content).toBe("<长夜行>正文</长夜行>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("{{// 注释 }} 配平", () => {
|
||||||
|
it("注释里嵌套宏不会漏出尾巴", () => {
|
||||||
|
const text = expandMacros("{{// 注释:把 {{user}} 换成别的\n}}|用户:{{user}}|", {
|
||||||
|
user: "User",
|
||||||
|
}).text;
|
||||||
|
expect(text).toBe("|用户:User|");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("普通注释照旧吃掉", () => {
|
||||||
|
expect(expandMacros("A{{// 说明 }}B", {}).text).toBe("AB");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { importSillyTavernPreset } from "../src/preset/importer.js";
|
||||||
|
import { assemblePresetProbe, runPresetProbe } from "../src/preset/probe.js";
|
||||||
|
|
||||||
|
function linuxLikePreset() {
|
||||||
|
return importSillyTavernPreset(
|
||||||
|
{
|
||||||
|
prompts: [
|
||||||
|
{ identifier: "main", name: "主体", role: "system", content: "{{user}} 写" },
|
||||||
|
{ identifier: "chatHistory", name: "历史", role: "system", content: "", marker: true },
|
||||||
|
{
|
||||||
|
identifier: "guide",
|
||||||
|
name: "工具说明",
|
||||||
|
role: "system",
|
||||||
|
content: "active_interface = %TOOL_NAME%\nstream_target = display_stream",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prompt_order: [
|
||||||
|
{
|
||||||
|
character_id: 100001,
|
||||||
|
order: [
|
||||||
|
{ identifier: "main", enabled: true },
|
||||||
|
{ identifier: "chatHistory", enabled: true },
|
||||||
|
{ identifier: "guide", enabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
squash_system_messages: true,
|
||||||
|
},
|
||||||
|
{ name: "linux-like" },
|
||||||
|
).preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("probe 展示实际发出的形态", () => {
|
||||||
|
it("reports the squash mode and the tool plan", async () => {
|
||||||
|
const preset = linuxLikePreset();
|
||||||
|
const result = await runPresetProbe({
|
||||||
|
preset,
|
||||||
|
input: { message: "继续" },
|
||||||
|
options: { charName: "长夜行" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.squashMode).toBe("system");
|
||||||
|
expect(result.toolPlan?.present).toBe(true);
|
||||||
|
expect(result.toolPlan?.payloadFields).toContain("display_stream");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands {{user}} and drops entries that become empty", () => {
|
||||||
|
const preset = linuxLikePreset();
|
||||||
|
const messages = assemblePresetProbe(
|
||||||
|
preset,
|
||||||
|
{ message: "继续" },
|
||||||
|
{ charName: "长夜行" },
|
||||||
|
);
|
||||||
|
const all = messages.map((m) => m.content).join("\n");
|
||||||
|
expect(all).not.toContain("{{user}}");
|
||||||
|
// 换成当前人设名(探针缺省用选中人设)
|
||||||
|
expect(all).toMatch(/\S+ 写/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges adjacent system messages in the displayed sequence", () => {
|
||||||
|
const preset = linuxLikePreset();
|
||||||
|
const messages = assemblePresetProbe(
|
||||||
|
preset,
|
||||||
|
{ message: "继续" },
|
||||||
|
{ squash: "merge" },
|
||||||
|
);
|
||||||
|
const roles = messages.map((m) => m.role);
|
||||||
|
for (let i = 1; i < roles.length; i++) {
|
||||||
|
if (roles[i] === "system") {
|
||||||
|
expect(roles[i - 1]).not.toBe("system");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+24
-18
@@ -41,16 +41,16 @@ function samplePreset() {
|
|||||||
|
|
||||||
describe("assemblePresetProbe", () => {
|
describe("assemblePresetProbe", () => {
|
||||||
it("fills the sandwich with sample lore, history, and the current turn", () => {
|
it("fills the sandwich with sample lore, history, and the current turn", () => {
|
||||||
const messages = assemblePresetProbe(samplePreset(), {
|
const messages = assemblePresetProbe(
|
||||||
message: "我推开门。",
|
samplePreset(),
|
||||||
});
|
{ message: "我推开门。" },
|
||||||
|
{ squash: "none" },
|
||||||
|
);
|
||||||
|
|
||||||
expect(messages.map((m) => [m.role, m.content])).toEqual([
|
expect(messages.map((m) => [m.role, m.content])).toEqual([
|
||||||
["system", "MAIN"],
|
// 任务契约并进预设自有的 main 条目,不再单独一条
|
||||||
[
|
["system", `MAIN\n\n${PROBE_SYSTEM_PROMPT}`],
|
||||||
"system",
|
["system", `### 试跑设定\n\n${DEFAULT_PROBE_CONTEXT.loreBefore}`],
|
||||||
`### 任务契约\n\n${PROBE_SYSTEM_PROMPT}\n\n### 试跑设定\n\n${DEFAULT_PROBE_CONTEXT.loreBefore}`,
|
|
||||||
],
|
|
||||||
["system", DEFAULT_PROBE_CONTEXT.history],
|
["system", DEFAULT_PROBE_CONTEXT.history],
|
||||||
["user", "我推开门。"],
|
["user", "我推开门。"],
|
||||||
["system", "PHI"],
|
["system", "PHI"],
|
||||||
@@ -58,17 +58,20 @@ describe("assemblePresetProbe", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("omits empty optional bags", () => {
|
it("omits empty optional bags", () => {
|
||||||
const messages = assemblePresetProbe(samplePreset(), {
|
const messages = assemblePresetProbe(
|
||||||
message: "你好",
|
samplePreset(),
|
||||||
loreBefore: "",
|
{
|
||||||
history: "",
|
message: "你好",
|
||||||
loreAfter: "",
|
loreBefore: "",
|
||||||
postTurn: "",
|
history: "",
|
||||||
});
|
loreAfter: "",
|
||||||
|
postTurn: "",
|
||||||
|
},
|
||||||
|
{ squash: "none" },
|
||||||
|
);
|
||||||
|
|
||||||
expect(messages.map((m) => m.content)).toEqual([
|
expect(messages.map((m) => m.content)).toEqual([
|
||||||
"MAIN",
|
`MAIN\n\n${PROBE_SYSTEM_PROMPT}`,
|
||||||
`### 任务契约\n\n${PROBE_SYSTEM_PROMPT}`,
|
|
||||||
"你好",
|
"你好",
|
||||||
"PHI",
|
"PHI",
|
||||||
]);
|
]);
|
||||||
@@ -84,7 +87,10 @@ describe("runPresetProbe", () => {
|
|||||||
|
|
||||||
expect(result.completed).toBe(false);
|
expect(result.completed).toBe(false);
|
||||||
expect(result.reply).toBeNull();
|
expect(result.reply).toBeNull();
|
||||||
expect(result.messages.some((m) => m.content === "我推开门。")).toBe(true);
|
// 缺省 strict:system 段会折进 user,所以只断言正文没丢
|
||||||
|
expect(
|
||||||
|
result.messages.map((m) => m.content).join("\n"),
|
||||||
|
).toContain("我推开门。");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the model reply when complete is provided", async () => {
|
it("returns the model reply when complete is provided", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyRegexScripts,
|
||||||
|
hasRegexFor,
|
||||||
|
readRegexScripts,
|
||||||
|
runRegexScript,
|
||||||
|
type PresetRegexScript,
|
||||||
|
} from "../src/preset/regex-scripts.js";
|
||||||
|
|
||||||
|
const base: PresetRegexScript = {
|
||||||
|
name: "t",
|
||||||
|
findRegex: "/<choices>[\\s\\S]*?<\\/choices>/g",
|
||||||
|
replaceString: "",
|
||||||
|
trimStrings: [],
|
||||||
|
placement: [2],
|
||||||
|
promptOnly: true,
|
||||||
|
markdownOnly: false,
|
||||||
|
disabled: false,
|
||||||
|
runOnEdit: false,
|
||||||
|
substituteRegex: 0,
|
||||||
|
minDepth: null,
|
||||||
|
maxDepth: null,
|
||||||
|
approximateSubstitute: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("readRegexScripts", () => {
|
||||||
|
it("从 raw.regex_scripts / extensions.regex_scripts / SPreset.RegexBinding 读并去重", () => {
|
||||||
|
const raw = {
|
||||||
|
regex_scripts: [
|
||||||
|
{ id: "a", scriptName: "A", findRegex: "/x/g", placement: [1], promptOnly: true },
|
||||||
|
],
|
||||||
|
extensions: {
|
||||||
|
regex_scripts: [
|
||||||
|
{ id: "a", scriptName: "A", findRegex: "/x/g", placement: [1], promptOnly: true },
|
||||||
|
{ id: "b", scriptName: "B", findRegex: "/y/g", placement: [2], markdownOnly: true },
|
||||||
|
],
|
||||||
|
SPreset: {
|
||||||
|
RegexBinding: {
|
||||||
|
regexes: [
|
||||||
|
{ id: "c", scriptName: "C", findRegex: "/z/g", placement: [2] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const scripts = readRegexScripts(raw);
|
||||||
|
expect(scripts.map((s) => s.name)).toEqual(["A", "B", "C"]);
|
||||||
|
expect(scripts[1]?.markdownOnly).toBe(true);
|
||||||
|
expect(scripts[2]?.promptOnly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("没有 findRegex 的条目跳过", () => {
|
||||||
|
expect(readRegexScripts({ regex_scripts: [{ id: "x" }] })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runRegexScript(对齐酒馆 runRegexScript)", () => {
|
||||||
|
it("支持 {{match}} 与 $1 / 命名组", () => {
|
||||||
|
expect(
|
||||||
|
runRegexScript("前言<choices>a</choices>后记", {
|
||||||
|
...base,
|
||||||
|
replaceString: "[$1]",
|
||||||
|
findRegex: "/<choices>(.*?)<\\/choices>/g",
|
||||||
|
}),
|
||||||
|
).toBe("前言[a]后记");
|
||||||
|
expect(
|
||||||
|
runRegexScript("abc", {
|
||||||
|
...base,
|
||||||
|
replaceString: "<<$<x>>>",
|
||||||
|
findRegex: "/(?<x>b)/g",
|
||||||
|
}),
|
||||||
|
).toBe("a<<b>>c");
|
||||||
|
expect(
|
||||||
|
runRegexScript("打勾", { ...base, replaceString: "✓{{match}}", findRegex: "/打/g" }),
|
||||||
|
).toBe("✓打勾");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trimStrings 从匹配片段里剔掉", () => {
|
||||||
|
expect(
|
||||||
|
runRegexScript("前((去掉))后", {
|
||||||
|
...base,
|
||||||
|
findRegex: "/\\(\\((.*?)\\)\\)/g",
|
||||||
|
replaceString: "$1",
|
||||||
|
trimStrings: ["去掉"],
|
||||||
|
}),
|
||||||
|
).toBe("前后");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("坏正则原样返回", () => {
|
||||||
|
expect(runRegexScript("x", { ...base, findRegex: "/([/g" })).toBe("x");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyRegexScripts(方向 / placement / depth)", () => {
|
||||||
|
it("prompt 只跑 promptOnly,display 只跑 markdownOnly,raw 只跑两者都不是的", () => {
|
||||||
|
const scripts: PresetRegexScript[] = [
|
||||||
|
{ ...base, name: "p", promptOnly: true, findRegex: "/A/g", replaceString: "P" },
|
||||||
|
{ ...base, name: "d", promptOnly: false, markdownOnly: true, findRegex: "/A/g", replaceString: "D" },
|
||||||
|
{ ...base, name: "r", promptOnly: false, markdownOnly: false, findRegex: "/A/g", replaceString: "R" },
|
||||||
|
];
|
||||||
|
expect(applyRegexScripts("A", scripts, { placement: 2, direction: "prompt" })).toBe("P");
|
||||||
|
expect(applyRegexScripts("A", scripts, { placement: 2, direction: "display" })).toBe("D");
|
||||||
|
expect(applyRegexScripts("A", scripts, { placement: 2, direction: "raw" })).toBe("R");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("placement 不匹配就不跑", () => {
|
||||||
|
const scripts: PresetRegexScript[] = [{ ...base, findRegex: "/A/g", replaceString: "P" }];
|
||||||
|
expect(applyRegexScripts("A", scripts, { placement: 1, direction: "prompt" })).toBe("A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("depth 超出 min/max 就跳过", () => {
|
||||||
|
const scripts: PresetRegexScript[] = [
|
||||||
|
{ ...base, findRegex: "/A/g", replaceString: "P", minDepth: 2 },
|
||||||
|
];
|
||||||
|
expect(applyRegexScripts("A", scripts, { placement: 2, direction: "prompt", depth: 1 })).toBe("A");
|
||||||
|
expect(applyRegexScripts("A", scripts, { placement: 2, direction: "prompt", depth: 3 })).toBe("P");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disabled 的脚本不跑", () => {
|
||||||
|
const scripts: PresetRegexScript[] = [{ ...base, disabled: true, findRegex: "/A/g", replaceString: "P" }];
|
||||||
|
expect(applyRegexScripts("A", scripts, { placement: 2, direction: "prompt" })).toBe("A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("substituteRegex=RAW 时先展开 findRegex 里的宏", () => {
|
||||||
|
const scripts: PresetRegexScript[] = [
|
||||||
|
{ ...base, findRegex: "/{{char}}/g", replaceString: "X", substituteRegex: 1 },
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
applyRegexScripts("角色名", scripts, {
|
||||||
|
placement: 2,
|
||||||
|
direction: "prompt",
|
||||||
|
expand: (text) => text.replaceAll("{{char}}", "角色名"),
|
||||||
|
}),
|
||||||
|
).toBe("X");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasRegexFor 快速判断", () => {
|
||||||
|
const scripts: PresetRegexScript[] = [{ ...base }];
|
||||||
|
expect(hasRegexFor(scripts, "prompt", 2)).toBe(true);
|
||||||
|
expect(hasRegexFor(scripts, "display", 2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("装配时对对话历史跑发送前正则", () => {
|
||||||
|
it("user/assistant 分别按 placement 1/2 处理,depth 生效", async () => {
|
||||||
|
const { importSillyTavernPreset } = await import("../src/preset/importer.js");
|
||||||
|
const { assemblePlayWorkerMessages } = await import("../src/preset/play-frame.js");
|
||||||
|
const report = importSillyTavernPreset(
|
||||||
|
{
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
identifier: "chatHistory",
|
||||||
|
name: "History",
|
||||||
|
role: "system",
|
||||||
|
marker: true,
|
||||||
|
content: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prompt_order: [
|
||||||
|
{ character_id: 100001, order: [{ identifier: "chatHistory", enabled: true }] },
|
||||||
|
],
|
||||||
|
regex_scripts: [
|
||||||
|
{
|
||||||
|
id: "strip",
|
||||||
|
scriptName: "去选项",
|
||||||
|
findRegex: "/<choices>[\\s\\S]*?<\\/choices>/g",
|
||||||
|
replaceString: "",
|
||||||
|
placement: [2],
|
||||||
|
promptOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user-only",
|
||||||
|
scriptName: "改用户输入",
|
||||||
|
findRegex: "/你好/g",
|
||||||
|
replaceString: "您好",
|
||||||
|
placement: [1],
|
||||||
|
promptOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: "regex-history" },
|
||||||
|
);
|
||||||
|
const messages = assemblePlayWorkerMessages({
|
||||||
|
systemPrompt: "",
|
||||||
|
preset: report.preset,
|
||||||
|
pack: {
|
||||||
|
worldBookBefore: [],
|
||||||
|
history: [
|
||||||
|
{ role: "user", content: "你好" },
|
||||||
|
{ role: "assistant", content: "回答<choices>ABC</choices>" },
|
||||||
|
],
|
||||||
|
worldBookAfter: [],
|
||||||
|
turn: "",
|
||||||
|
postTurn: [],
|
||||||
|
},
|
||||||
|
persona: null,
|
||||||
|
charName: null,
|
||||||
|
});
|
||||||
|
expect(messages.map((m) => m.content)).toEqual(["您好", "回答"]);
|
||||||
|
expect(messages.map((m) => m.role)).toEqual(["user", "assistant"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { importSillyTavernPreset } from "../src/preset/importer.js";
|
||||||
|
import {
|
||||||
|
declarePresetTools,
|
||||||
|
extractDeclaredPayload,
|
||||||
|
} from "../src/preset/tool-declaration.js";
|
||||||
|
|
||||||
|
function importRaw(raw: unknown, name: string) {
|
||||||
|
return importSillyTavernPreset(raw, { name }).preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("声明式工具(预设数据 → 真 tools)", () => {
|
||||||
|
it("从 ToolBindings 的 form 生成 tools", () => {
|
||||||
|
const preset = importRaw(
|
||||||
|
{
|
||||||
|
prompts: [{ identifier: "main", name: "main", role: "system", content: "MAIN" }],
|
||||||
|
prompt_order: [
|
||||||
|
{ character_id: 100001, order: [{ identifier: "main", enabled: true }] },
|
||||||
|
],
|
||||||
|
extensions: {
|
||||||
|
SPreset: {
|
||||||
|
ToolBindings: {
|
||||||
|
aaa: {
|
||||||
|
enabled: true,
|
||||||
|
resolvedName: "game_content",
|
||||||
|
form: {
|
||||||
|
name: "game_content",
|
||||||
|
description: "# 你的全部输出应当在此工具内。",
|
||||||
|
parameters: [{ name: "content", type: "string", required: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"bindings",
|
||||||
|
);
|
||||||
|
const declared = declarePresetTools(preset);
|
||||||
|
expect(declared?.source).toBe("bindings");
|
||||||
|
expect(declared?.tools).toHaveLength(1);
|
||||||
|
const fn = declared!.tools[0]!.function;
|
||||||
|
expect(fn.name).toBe("game_content");
|
||||||
|
expect(fn.description).toContain("全部输出");
|
||||||
|
expect(fn.parameters).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: { content: { type: "string" } },
|
||||||
|
required: ["content"],
|
||||||
|
});
|
||||||
|
expect(declared?.payloadFields).toEqual(["content"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("只有伪工具协议条目时从正文取工具名与字段", () => {
|
||||||
|
const preset = importRaw(
|
||||||
|
{
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
identifier: "guide-sys",
|
||||||
|
name: "【ToolCall引导】系统声明",
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
"调用 %TOOL_NAME% 才能输出正文;执行宿 dispatch_stream_payload_34d98628087f3798。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: "guide-field",
|
||||||
|
name: "【ToolCall引导】字段说明",
|
||||||
|
role: "system",
|
||||||
|
content: "stream_target = \"display_stream\"\n正文写在这里。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prompt_order: [
|
||||||
|
{
|
||||||
|
character_id: 100001,
|
||||||
|
order: [
|
||||||
|
{ identifier: "guide-sys", enabled: true },
|
||||||
|
{ identifier: "guide-field", enabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"protocol",
|
||||||
|
);
|
||||||
|
const declared = declarePresetTools(preset);
|
||||||
|
expect(declared?.source).toBe("protocol");
|
||||||
|
const fn = declared!.tools[0]!.function;
|
||||||
|
expect(fn.name).toBe("dispatch_stream_payload_34d98628087f3798");
|
||||||
|
expect(declared?.payloadFields).toContain("display_stream");
|
||||||
|
const params = fn.parameters as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(params.properties)).toContain("display_stream");
|
||||||
|
expect(params.required).toContain("display_stream");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("没有工具协议就返回 null", () => {
|
||||||
|
const preset = importRaw(
|
||||||
|
{
|
||||||
|
prompts: [{ identifier: "main", name: "main", role: "system", content: "MAIN" }],
|
||||||
|
prompt_order: [
|
||||||
|
{ character_id: 100001, order: [{ identifier: "main", enabled: true }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"plain",
|
||||||
|
);
|
||||||
|
expect(declarePresetTools(preset)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractDeclaredPayload", () => {
|
||||||
|
it("按字段优先级取正文", () => {
|
||||||
|
expect(
|
||||||
|
extractDeclaredPayload(JSON.stringify({ display_stream: "正文" }), ["display_stream"]),
|
||||||
|
).toBe("正文");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("字段不在时退回任意字符串字段", () => {
|
||||||
|
expect(extractDeclaredPayload(JSON.stringify({ other: "兜底" }), ["content"])).toBe("兜底");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("坏 JSON 返回 null", () => {
|
||||||
|
expect(extractDeclaredPayload("{oops", ["content"])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ToolDefinition } from "../src/llm/client.js";
|
||||||
|
import { importSillyTavernPreset } from "../src/preset/importer.js";
|
||||||
|
import {
|
||||||
|
clearToolPlanCache,
|
||||||
|
decorateTools,
|
||||||
|
normalizeToolCalls,
|
||||||
|
resolveToolPlan,
|
||||||
|
} from "../src/preset/tool-policy.js";
|
||||||
|
|
||||||
|
const submitTool: ToolDefinition = {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "submit",
|
||||||
|
description: "提交产物",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
content: { type: "string" },
|
||||||
|
outputTags: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
required: ["content", "outputTags"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const readTool: ToolDefinition = {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "read_blackboard",
|
||||||
|
description: "读正文",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: { tags: { type: "array" } },
|
||||||
|
required: ["tags"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function presetWithBindings() {
|
||||||
|
return importSillyTavernPreset(
|
||||||
|
{
|
||||||
|
prompts: [
|
||||||
|
{ identifier: "main", name: "main", role: "system", content: "主线" },
|
||||||
|
{
|
||||||
|
identifier: "bound",
|
||||||
|
name: "防截断2.0",
|
||||||
|
role: "system",
|
||||||
|
content: "所有输出必须调用 game_content 工具,放在 content 参数中",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prompt_order: [
|
||||||
|
{
|
||||||
|
character_id: 100001,
|
||||||
|
order: [
|
||||||
|
{ identifier: "main", enabled: true },
|
||||||
|
{ identifier: "bound", enabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
extensions: {
|
||||||
|
SPreset: {
|
||||||
|
ToolBindings: {
|
||||||
|
bound: {
|
||||||
|
enabled: true,
|
||||||
|
form: {
|
||||||
|
name: "game_content",
|
||||||
|
description: "# 你的全部输出应当在此工具内。",
|
||||||
|
parameters: [{ name: "content", type: "string", required: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: "Reborn-like" },
|
||||||
|
).preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function presetWithPlaceholders() {
|
||||||
|
return importSillyTavernPreset(
|
||||||
|
{
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
identifier: "guide",
|
||||||
|
name: "【ToolCall引导】工具说明",
|
||||||
|
role: "system",
|
||||||
|
content: "[Interface]\nactive_interface = %TOOL_NAME%\nstream_target = display_stream",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prompt_order: [
|
||||||
|
{
|
||||||
|
character_id: 100001,
|
||||||
|
order: [{ identifier: "guide", enabled: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: "linux-like" },
|
||||||
|
).preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveToolPlan", () => {
|
||||||
|
it("prefers SPreset.ToolBindings for the target tool and payload field", () => {
|
||||||
|
const plan = resolveToolPlan(presetWithBindings());
|
||||||
|
expect(plan.present).toBe(true);
|
||||||
|
expect(plan.targetName).toBe("game_content");
|
||||||
|
expect(plan.payloadFields).toEqual(["content"]);
|
||||||
|
// 描述只取作者写在 ToolBindings 里的那句,不抄条目正文
|
||||||
|
expect(plan.description).toBe("# 你的全部输出应当在此工具内。");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to placeholders when no binding exists", () => {
|
||||||
|
const plan = resolveToolPlan(presetWithPlaceholders());
|
||||||
|
expect(plan.present).toBe(true);
|
||||||
|
expect(plan.targetName).toBeNull();
|
||||||
|
expect(plan.payloadFields).toContain("display_stream");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is quiet when the preset has no tool protocol", () => {
|
||||||
|
const preset = importSillyTavernPreset(
|
||||||
|
{
|
||||||
|
prompts: [{ identifier: "main", name: "main", role: "system", content: "只写正文" }],
|
||||||
|
prompt_order: [
|
||||||
|
{ character_id: 100001, order: [{ identifier: "main", enabled: true }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: "plain" },
|
||||||
|
).preset;
|
||||||
|
expect(resolveToolPlan(preset).present).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tool plan 缓存", () => {
|
||||||
|
it("returns a stale plan until the cache is cleared", () => {
|
||||||
|
const preset = presetWithPlaceholders();
|
||||||
|
expect(resolveToolPlan(preset).present).toBe(true);
|
||||||
|
|
||||||
|
// 模拟用户把所有条目关掉后保存(id 与 importedAt 不变)
|
||||||
|
const edited = {
|
||||||
|
...preset,
|
||||||
|
promptOrder: preset.promptOrder.map((o) => ({ ...o, enabled: false })),
|
||||||
|
};
|
||||||
|
expect(resolveToolPlan(edited).present).toBe(true);
|
||||||
|
|
||||||
|
clearToolPlanCache(edited.id);
|
||||||
|
expect(resolveToolPlan(edited).present).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decorateTools", () => {
|
||||||
|
it("leaves tools alone when the preset has no tool protocol", () => {
|
||||||
|
const preset = importSillyTavernPreset(
|
||||||
|
{
|
||||||
|
prompts: [{ identifier: "main", name: "main", role: "system", content: "写" }],
|
||||||
|
prompt_order: [
|
||||||
|
{ character_id: 100001, order: [{ identifier: "main", enabled: true }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: "plain" },
|
||||||
|
).preset;
|
||||||
|
const out = decorateTools({
|
||||||
|
mode: "adapt",
|
||||||
|
tools: [submitTool],
|
||||||
|
plan: resolveToolPlan(preset),
|
||||||
|
});
|
||||||
|
expect(out.tools).toEqual([submitTool]);
|
||||||
|
expect(out.aliases).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("app mode never changes the tool table", () => {
|
||||||
|
const out = decorateTools({
|
||||||
|
mode: "app",
|
||||||
|
tools: [submitTool],
|
||||||
|
plan: resolveToolPlan(presetWithBindings()),
|
||||||
|
});
|
||||||
|
expect(out.tools).toEqual([submitTool]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adapt adds the declared tool and aliases it onto the host tool", () => {
|
||||||
|
const out = decorateTools({
|
||||||
|
mode: "adapt",
|
||||||
|
tools: [readTool, submitTool],
|
||||||
|
plan: resolveToolPlan(presetWithBindings()),
|
||||||
|
});
|
||||||
|
expect(out.tools.map((t) => t.function.name)).toEqual([
|
||||||
|
"read_blackboard",
|
||||||
|
"submit",
|
||||||
|
"game_content",
|
||||||
|
]);
|
||||||
|
expect(out.aliases).toEqual([
|
||||||
|
{ from: "game_content", to: "submit", payloadFields: ["content"] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adapt folds placeholder payload fields into the host schema", () => {
|
||||||
|
const out = decorateTools({
|
||||||
|
mode: "adapt",
|
||||||
|
tools: [readTool, submitTool],
|
||||||
|
plan: resolveToolPlan(presetWithPlaceholders()),
|
||||||
|
});
|
||||||
|
expect(out.tools).toHaveLength(2);
|
||||||
|
const submit = out.tools.find((t) => t.function.name === "submit")!;
|
||||||
|
const properties = (submit.function.parameters as {
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
}).properties;
|
||||||
|
expect(Object.keys(properties)).toContain("display_stream");
|
||||||
|
expect(submit.function.description).toContain("display_stream");
|
||||||
|
expect(out.aliases).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adapt creates a payload tool when there is no host tool", () => {
|
||||||
|
const out = decorateTools({
|
||||||
|
mode: "adapt",
|
||||||
|
tools: [],
|
||||||
|
plan: resolveToolPlan(presetWithBindings()),
|
||||||
|
});
|
||||||
|
expect(out.tools.map((t) => t.function.name)).toEqual(["game_content"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preset mode sends only the preset tool", () => {
|
||||||
|
const out = decorateTools({
|
||||||
|
mode: "preset",
|
||||||
|
tools: [readTool, submitTool],
|
||||||
|
plan: resolveToolPlan(presetWithBindings()),
|
||||||
|
});
|
||||||
|
expect(out.tools.map((t) => t.function.name)).toEqual(["game_content"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeToolCalls", () => {
|
||||||
|
it("rewrites an aliased call onto the host tool and its primary field", () => {
|
||||||
|
const decoration = decorateTools({
|
||||||
|
mode: "adapt",
|
||||||
|
tools: [submitTool],
|
||||||
|
plan: resolveToolPlan(presetWithBindings()),
|
||||||
|
});
|
||||||
|
const calls = normalizeToolCalls(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "game_content",
|
||||||
|
arguments: JSON.stringify({ content: "第一章", extra: 1 }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
decoration,
|
||||||
|
decoration.tools,
|
||||||
|
);
|
||||||
|
expect(calls[0]?.name).toBe("submit");
|
||||||
|
const args = JSON.parse(calls[0]!.arguments) as Record<string, unknown>;
|
||||||
|
// content 已经落到宿主工具的主字段上,不再重复
|
||||||
|
expect(args.content).toBe("第一章");
|
||||||
|
expect(args.extra).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves unrelated calls untouched", () => {
|
||||||
|
const decoration = decorateTools({
|
||||||
|
mode: "adapt",
|
||||||
|
tools: [submitTool],
|
||||||
|
plan: resolveToolPlan(presetWithBindings()),
|
||||||
|
});
|
||||||
|
const call = { id: "2", name: "submit", arguments: '{"content":"x"}' };
|
||||||
|
expect(normalizeToolCalls([call], decoration, decoration.tools)).toEqual([
|
||||||
|
call,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renames an aliased call even without a payload", () => {
|
||||||
|
const decoration = decorateTools({
|
||||||
|
mode: "adapt",
|
||||||
|
tools: [submitTool],
|
||||||
|
plan: resolveToolPlan(presetWithBindings()),
|
||||||
|
});
|
||||||
|
const call = { id: "3", name: "game_content", arguments: "{}" };
|
||||||
|
expect(normalizeToolCalls([call], decoration, decoration.tools)).toEqual([
|
||||||
|
{ ...call, name: "submit" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds a payload field onto the host primary field", () => {
|
||||||
|
const decoration = decorateTools({
|
||||||
|
mode: "adapt",
|
||||||
|
tools: [submitTool],
|
||||||
|
plan: resolveToolPlan(presetWithPlaceholders()),
|
||||||
|
});
|
||||||
|
const calls = normalizeToolCalls(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
name: "submit",
|
||||||
|
arguments: JSON.stringify({ display_stream: "正文" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
decoration,
|
||||||
|
decoration.tools,
|
||||||
|
);
|
||||||
|
const args = JSON.parse(calls[0]!.arguments) as Record<string, unknown>;
|
||||||
|
expect(args.content).toBe("正文");
|
||||||
|
expect(args).not.toHaveProperty("display_stream");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,19 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
// 这些用例只测夹心与整形,不该被本机 ~/.writing-agent/settings.json 里的
|
||||||
|
// messageSquash / presetToolPolicy 覆盖,跑的时候给一份干净的临时数据目录。
|
||||||
|
let isolatedDataDir = "";
|
||||||
|
beforeAll(() => {
|
||||||
|
isolatedDataDir = mkdtempSync(path.join(os.tmpdir(), "wa-preset-wrapper-"));
|
||||||
|
process.env.WRITING_AGENT_DATA_DIR = isolatedDataDir;
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.WRITING_AGENT_DATA_DIR;
|
||||||
|
if (isolatedDataDir) rmSync(isolatedDataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
CompleteResult,
|
CompleteResult,
|
||||||
@@ -25,6 +40,8 @@ const samplePreset: PresetPackage = {
|
|||||||
],
|
],
|
||||||
promptOrder: [{ promptId: "sys", enabled: true, orderIndex: 0 }],
|
promptOrder: [{ promptId: "sys", enabled: true, orderIndex: 0 }],
|
||||||
generation: { temperature: 0.91, maxOutputTokens: 1234, reasoningEffort: "max" },
|
generation: { temperature: 0.91, maxOutputTokens: 1234, reasoningEffort: "max" },
|
||||||
|
// 这组用例测的是夹心,不是整形;整形单独有用例。
|
||||||
|
messagePolicy: { squash: "none" },
|
||||||
unsupported: [],
|
unsupported: [],
|
||||||
importedAt: "2026-01-01T00:00:00.000Z",
|
importedAt: "2026-01-01T00:00:00.000Z",
|
||||||
};
|
};
|
||||||
@@ -163,4 +180,18 @@ describe("wrapLlmForSession", () => {
|
|||||||
await llm.complete([{ role: "user", content: "TASK" }]);
|
await llm.complete([{ role: "user", content: "TASK" }]);
|
||||||
expect(received[0].messages[0].content).toBe("称呼 @玩家");
|
expect(received[0].messages[0].content).toBe("称呼 @玩家");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults to strict squash when the preset says nothing", async () => {
|
||||||
|
const preset: PresetPackage = { ...samplePreset };
|
||||||
|
delete (preset as { messagePolicy?: unknown }).messagePolicy;
|
||||||
|
const { provider, received } = recordingProvider();
|
||||||
|
const llm = wrapLlmForSession(provider, undefined, () => preset);
|
||||||
|
|
||||||
|
await llm.complete([{ role: "user", content: "TASK" }]);
|
||||||
|
|
||||||
|
// strict:system 折进 user,首条为 user
|
||||||
|
expect(received[0].messages.map((m) => m.role)).toEqual(["user"]);
|
||||||
|
expect(received[0].messages[0]!.content).toContain("PRESET_RULES");
|
||||||
|
expect(received[0].messages[0]!.content).toContain("TASK");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isCompleteJson,
|
||||||
|
salvageTruncatedPayload,
|
||||||
|
} from "../src/llm/truncation.js";
|
||||||
|
|
||||||
|
describe("isCompleteJson", () => {
|
||||||
|
it("accepts complete objects and empty", () => {
|
||||||
|
expect(isCompleteJson('{"content":"x"}')).toBe(true);
|
||||||
|
expect(isCompleteJson("")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects truncated objects", () => {
|
||||||
|
expect(isCompleteJson('{"content":"第一章')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("salvageTruncatedPayload", () => {
|
||||||
|
it("returns null when the field never started", () => {
|
||||||
|
expect(salvageTruncatedPayload('{"content', ["content"])).toBeNull();
|
||||||
|
expect(salvageTruncatedPayload('{"other":"x"}', ["content"])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the generated text before the cut", () => {
|
||||||
|
const raw = '{"content":"第一章\\n她推开门';
|
||||||
|
expect(salvageTruncatedPayload(raw, ["content"])).toEqual({
|
||||||
|
field: "content",
|
||||||
|
text: "第一章\n她推开门",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at the closing quote when the value is complete", () => {
|
||||||
|
const raw = '{"content":"正文","outputTags":["a"';
|
||||||
|
expect(salvageTruncatedPayload(raw, ["content"])?.text).toBe("正文");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tries fields in order", () => {
|
||||||
|
const raw = '{"display_stream":"走了","content":"没到"';
|
||||||
|
expect(salvageTruncatedPayload(raw, ["content", "display_stream"])?.field).toBe(
|
||||||
|
"content",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
salvageTruncatedPayload('{"display_stream":"走了', [
|
||||||
|
"content",
|
||||||
|
"display_stream",
|
||||||
|
])?.field,
|
||||||
|
).toBe("display_stream");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes unicode escapes and skips dangling escapes", () => {
|
||||||
|
expect(
|
||||||
|
salvageTruncatedPayload('{"content":"\\u7b2c\\u4e00\\u7ae0', [
|
||||||
|
"content",
|
||||||
|
])?.text,
|
||||||
|
).toBe("第一章");
|
||||||
|
expect(
|
||||||
|
salvageTruncatedPayload('{"content":"收尾\\', ["content"])?.text,
|
||||||
|
).toBe("收尾");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,7 +58,7 @@ describe("worldInfoPackFromSegments", () => {
|
|||||||
blackboard: bb,
|
blackboard: bb,
|
||||||
});
|
});
|
||||||
expect(pack.worldBookBefore.map((e) => e.content)).toEqual(["古镇夜雨"]);
|
expect(pack.worldBookBefore.map((e) => e.content)).toEqual(["古镇夜雨"]);
|
||||||
expect(pack.history).toContain("你好");
|
expect(pack.history.map((m) => m.content).join("\n")).toContain("你好");
|
||||||
expect(pack.worldBookAfter.map((e) => e.content)).toEqual(["HP 3"]);
|
expect(pack.worldBookAfter.map((e) => e.content)).toEqual(["HP 3"]);
|
||||||
expect(pack.turn).toBe("我推门进去");
|
expect(pack.turn).toBe("我推门进去");
|
||||||
expect(pack.postTurn.map((e) => e.content)).toEqual(["尾注"]);
|
expect(pack.postTurn.map((e) => e.content)).toEqual(["尾注"]);
|
||||||
|
|||||||
@@ -53,15 +53,48 @@
|
|||||||
## 已确认的差距(按优先级)
|
## 已确认的差距(按优先级)
|
||||||
|
|
||||||
1. **脚本/正则层**(最贵):本仓库导入时明确不执行 ST 正则与前端脚本;linux_1.3 的 tools 升格、Reborn2.4 的 `game_content` 都在这层。
|
1. **脚本/正则层**(最贵):本仓库导入时明确不执行 ST 正则与前端脚本;linux_1.3 的 tools 升格、Reborn2.4 的 `game_content` 都在这层。
|
||||||
2. **`prompts[].enabled` 双闸**(确定 bug):酒馆只用 `prompt_order.enabled`(`openai.js` 从不读 `prompts[].enabled`),本仓库要求两者都开 → linux_1.3 少发 `<世界设定与背景>`、`作者简介 古典武侠`、`结尾` 共约 1 万字。
|
2. **`prompts[].enabled` 双闸**(已修:装配只认 `prompt_order.enabled`):酒馆只用 `prompt_order.enabled`(`openai.js` 从不读 `prompts[].enabled`),本仓库要求两者都开 → linux_1.3 少发 `<世界设定与背景>`、`作者简介 古典武侠`、`结尾` 共约 1 万字。
|
||||||
3. **`squash_system_messages` 语义**(确定 bug):酒馆只合并**相邻 system**(跳过带 `name` 的、跳过 `newMainChat/newChat/groupNudge`),本仓库按"同角色合并"把 `结尾(框架闭合)`、`/历史记录`、`你好` 三条 user 合成一条。
|
3. **`squash_system_messages` 语义**(已修:新增 `system` 档,只合并相邻 system):酒馆只合并**相邻 system**(跳过带 `name` 的、跳过 `newMainChat/newChat/groupNudge`),本仓库按"同角色合并"把 `结尾(框架闭合)`、`/历史记录`、`你好` 三条 user 合成一条。
|
||||||
4. **角色卡插槽 + 格式模板**:`charDescription/charPersonality/scenario` 及 `personality_format/scenario_format` 包装(如 `<character_personality>…</character_personality>`)。
|
4. **角色卡插槽 + 格式模板**:`charDescription/charPersonality/scenario` 及 `personality_format/scenario_format` 包装(如 `<character_personality>…</character_personality>`)。
|
||||||
5. **示例对话**:`new_example_chat_prompt` 打头,`mes_example` 按 `<START>` 切段成多条 system,并带 `name=example_user/example_assistant`。
|
5. **示例对话**:`new_example_chat_prompt` 打头,`mes_example` 按 `<START>` 切段成多条 system,并带 `name=example_user/example_assistant`。
|
||||||
6. **`new_chat_prompt`**:空聊天时插一条 `[Start a new Chat]` / `[NVWA chat history begins below.]`。
|
6. **`new_chat_prompt`**:空聊天时插一条 `[Start a new Chat]` / `[NVWA chat history begins below.]`。
|
||||||
7. 本仓库多发的:`姓名:玩家`(persona 无描述时的兜底)、`### 任务契约 …`、`stream_options:{include_usage:true}`。
|
7. 本仓库多发的:`姓名:玩家`(persona 无描述时的兜底)、`### 任务契约 …`、`stream_options:{include_usage:true}`。
|
||||||
|
|
||||||
|
## 工具协议:声明式映射(不跑脚本)
|
||||||
|
|
||||||
|
预设里的「伪工具协议」有两种数据形态,`src/preset/tool-declaration.ts` 都直接读数据翻译成真 `tools`:
|
||||||
|
|
||||||
|
1. `extensions.SPreset.ToolBindings[].form` → name / description / parameters(Reborn2.4 的 `game_content`);
|
||||||
|
2. 只有协议条目、没有 ToolBindings 时 → 从条目正文取工具名(`dispatch_stream_payload_…`)、
|
||||||
|
「工具说明」条目当 description、「字段说明」条目当参数的 description(linux_1.3 的 `display_stream`)。
|
||||||
|
|
||||||
|
开关:设置页「预设工具协议」= `preset`(只发预设要求的工具)。默认仍是 `adapt`,不动现有行为。
|
||||||
|
命中后 `complete` / `completeStream` 会改用带 tools 的请求,并把工具调用里的正文按声明字段折回正文。
|
||||||
|
|
||||||
|
实测(linux_1.3):酒馆与我们的 `tool_choice` 都是 `auto`、都只有 1 个工具、参数都是 `display_stream`;
|
||||||
|
差别只剩工具名后缀(酒馆每次导入随机生成 hash)与 description 覆盖范围(酒馆把「系统声明」也并进去了)。
|
||||||
|
|
||||||
|
## 正则脚本:三类,只做数据驱动的三类
|
||||||
|
|
||||||
|
酒馆 `regex_scripts` 的语义(`extensions/regex/engine.js` + `getRegexedString` 调用点):
|
||||||
|
|
||||||
|
| 类别 | 条件 | 作用对象 | 我们做了吗 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ① 发送前 | `promptOnly === true` | **对话历史**(user=placement 1、assistant=2、思维链=6),带 `minDepth/maxDepth` | ✅ `src/preset/regex-scripts.ts` + `play-frame` 的 chatHistory 装配 |
|
||||||
|
| ② 展示 | `markdownOnly === true` | 只影响界面渲染 | ❌ 暂不做(纯 UI) |
|
||||||
|
| ③ 写入时 | 两个 flag 都不是 true | 消息进 chat 那一刻改写文本 | ✅ 回复落盘前(`direct-chat/turn.ts`) |
|
||||||
|
|
||||||
|
支持的脚本字段:`findRegex`、`replaceString`(含 `{{match}}`、`$1`、`$<name>`)、`trimStrings`、
|
||||||
|
`placement`、`promptOnly`/`markdownOnly`、`minDepth`/`maxDepth`、`disabled`、`substituteRegex`(0/1;
|
||||||
|
2 按 RAW 处理并标记)。
|
||||||
|
|
||||||
|
实测(linux_1.3,两轮对话):上一轮助手回复 `回答正文<choices>A、B、C</choices>` →
|
||||||
|
发出的请求里变成 `回答正文`(预设的「去<choices>」promptOnly 正则生效);
|
||||||
|
上一轮用户消息被预设的「userinput」正则改写成 `【HISTORICAL_USER_INPUT】【LAST_USER_INPUT】…`。
|
||||||
|
|
||||||
## 决策记录
|
## 决策记录
|
||||||
|
|
||||||
- **采样参数跟默认走**:预设缺 `temperature/top_p/max_tokens` 时,本仓库回落到 API 配置默认即可(酒馆是"缺就不改,沿用上次的值",不做对齐)。所以 `NVWA-Galaxia-ST` 报告里那行参数差异算已知取舍。
|
- **采样参数跟默认走**:预设缺 `temperature/top_p/max_tokens` 时,本仓库回落到 API 配置默认即可(酒馆是"缺就不改,沿用上次的值",不做对齐)。所以 `NVWA-Galaxia-ST` 报告里那行参数差异算已知取舍。
|
||||||
- `names_behavior`(`NONE:-1/DEFAULT:0/COMPLETION:1/CONTENT:2`)只影响**聊天历史**是否加 `名字: ` 前缀,三个预设都是 0,单角色下无效果 → 暂不实现。
|
- `names_behavior`(`NONE:-1/DEFAULT:0/COMPLETION:1/CONTENT:2`)只影响**聊天历史**是否加 `名字: ` 前缀,三个预设都是 0,单角色下无效果 → 暂不实现。
|
||||||
- `system_prompt:true` 标的是 ST 的**内置槽位**(不可删/不在用户列表/不参与深度注入,落位由 `openai.js` 里 `systemPrompts=['nsfw','jailbreak']` 等硬编码决定),不是位置规则;当前三份抓包里两边位置一致 → 不是当前核心差距。
|
- `system_prompt:true` 标的是 ST 的**内置槽位**(不可删/不在用户列表/不参与深度注入,落位由 `openai.js` 里 `systemPrompts=['nsfw','jailbreak']` 等硬编码决定),不是位置规则;当前三份抓包里两边位置一致 → 不是当前核心差距。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// TavernHelper 只对"白名单里的预设"执行预设内置脚本。这个脚本把指定预设加进白名单并重载页面。
|
||||||
|
// 用法: node allow-preset-scripts.mjs --presets linux_1.3,Reborn2.4,NVWA-Galaxia-ST [--reload]
|
||||||
|
import { CdpSession, evaluate, sleep } from "./cdp.mjs";
|
||||||
|
import { waitStReady } from "./lib.mjs";
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const arg = (name, dflt) => {
|
||||||
|
const i = argv.indexOf("--" + name);
|
||||||
|
return i >= 0 ? String(argv[i + 1]) : dflt;
|
||||||
|
};
|
||||||
|
const presets = arg("presets", "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
const reload = argv.includes("--reload");
|
||||||
|
const browserPort = Number(arg("browser-port", "8223"));
|
||||||
|
const stHint = arg("st-url", "127.0.0.1:8123");
|
||||||
|
|
||||||
|
const version = await (await fetch("http://127.0.0.1:" + browserPort + "/json/version")).json();
|
||||||
|
const cdp = await CdpSession.connect(version.webSocketDebuggerUrl, "browser");
|
||||||
|
const { targetInfos } = await cdp.send("Target.getTargets");
|
||||||
|
const target = targetInfos.find((t) => t.type === "page" && String(t.url).includes(stHint));
|
||||||
|
if (!target) throw new Error("没找到酒馆页面");
|
||||||
|
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId: target.targetId, flatten: true });
|
||||||
|
await cdp.send("Runtime.enable", {}, sessionId);
|
||||||
|
const page = { cdp, sessionId };
|
||||||
|
|
||||||
|
const before = await evaluate(page, `(() => {
|
||||||
|
const s = window.SillyTavern.getContext().extensionSettings.tavern_helper.script;
|
||||||
|
return s.enabled;
|
||||||
|
})()`);
|
||||||
|
console.log("[scripts] 改前: " + JSON.stringify(before));
|
||||||
|
|
||||||
|
await evaluate(page, `(() => {
|
||||||
|
const ctx = window.SillyTavern.getContext();
|
||||||
|
const s = ctx.extensionSettings.tavern_helper.script;
|
||||||
|
const list = new Set(s.enabled.presets || []);
|
||||||
|
for (const name of ${JSON.stringify(presets)}) list.add(name);
|
||||||
|
s.enabled = { global: true, presets: [...list], characters: s.enabled.characters || [] };
|
||||||
|
ctx.saveSettingsDebounced();
|
||||||
|
return s.enabled;
|
||||||
|
})()`);
|
||||||
|
await sleep(1200);
|
||||||
|
|
||||||
|
const after = await evaluate(page, `(() => window.SillyTavern.getContext().extensionSettings.tavern_helper.script.enabled)()`);
|
||||||
|
console.log("[scripts] 改后: " + JSON.stringify(after));
|
||||||
|
|
||||||
|
if (reload) {
|
||||||
|
await cdp.send("Page.enable", {}, sessionId);
|
||||||
|
await cdp.send("Page.reload", {}, sessionId);
|
||||||
|
await sleep(1500);
|
||||||
|
await waitStReady(page, 120000, (msg) => console.log("[scripts] " + msg));
|
||||||
|
console.log("[scripts] 页面已重载并就绪");
|
||||||
|
const now = await evaluate(page, `(() => window.SillyTavern.getContext().extensionSettings.tavern_helper.script.enabled)()`);
|
||||||
|
console.log("[scripts] 重载后: " + JSON.stringify(now));
|
||||||
|
}
|
||||||
|
cdp.close();
|
||||||
@@ -29,6 +29,20 @@ const out = await evaluate(page, `(async () => {
|
|||||||
jq("#settings_preset_openai").val(String(names[want])).trigger("change");
|
jq("#settings_preset_openai").val(String(names[want])).trigger("change");
|
||||||
await new Promise((r) => setTimeout(r, 900));
|
await new Promise((r) => setTimeout(r, 900));
|
||||||
}
|
}
|
||||||
|
// 页面重载后可能没选角色:先把探针卡选上
|
||||||
|
if (window.SillyTavern.getContext().name2 !== "ProbeCard") {
|
||||||
|
await window.SillyTavern.getContext().getCharacters();
|
||||||
|
const list = window.SillyTavern.getContext().characters || [];
|
||||||
|
const idx = list.findIndex((c) => c.name === "ProbeCard");
|
||||||
|
const other = list.findIndex((c) => c.name !== "ProbeCard");
|
||||||
|
if (idx >= 0) {
|
||||||
|
for (let t = 0; t < 40 && window.SillyTavern.getContext().name2 !== "ProbeCard"; t++) {
|
||||||
|
if (t === 0 && other >= 0) await window.SillyTavern.getContext().selectCharacterById(other);
|
||||||
|
await window.SillyTavern.getContext().selectCharacterById(idx);
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
await window.SillyTavern.getContext().clearChat({ clearData: true });
|
await window.SillyTavern.getContext().clearChat({ clearData: true });
|
||||||
const before = window.SillyTavern.getContext().chatCompletionSettings;
|
const before = window.SillyTavern.getContext().chatCompletionSettings;
|
||||||
const state = { preset: before.preset_settings_openai, temp: before.temp_openai, maxTokens: before.openai_max_tokens, functionCalling: before.function_calling, squash: before.squash_system_messages };
|
const state = { preset: before.preset_settings_openai, temp: before.temp_openai, maxTokens: before.openai_max_tokens, functionCalling: before.function_calling, squash: before.squash_system_messages };
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// 逐字对比两份抓包里指定下标的消息,打印差异位置。
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
const [a, b, idx] = process.argv.slice(2);
|
||||||
|
const get = (file) => JSON.parse(JSON.parse(readFileSync(file, "utf8")).body).messages[Number(idx)].content;
|
||||||
|
const left = get(a);
|
||||||
|
const right = get(b);
|
||||||
|
console.log("ST " + left.length + " 字 / WA " + right.length + " 字");
|
||||||
|
let i = 0;
|
||||||
|
while (i < Math.min(left.length, right.length) && left[i] === right[i]) i++;
|
||||||
|
console.log("首个差异在 " + i);
|
||||||
|
console.log("ST: " + JSON.stringify(left.slice(Math.max(0, i - 40), i + 60)));
|
||||||
|
console.log("WA: " + JSON.stringify(right.slice(Math.max(0, i - 40), i + 60)));
|
||||||
@@ -86,7 +86,20 @@ const fmt = (v) => {
|
|||||||
if (v === undefined) return "(无)";
|
if (v === undefined) return "(无)";
|
||||||
if (v === null) return "null";
|
if (v === null) return "null";
|
||||||
if (typeof v === "string") return v.length > 60 ? JSON.stringify(v.slice(0, 60)) + "…" : JSON.stringify(v);
|
if (typeof v === "string") return v.length > 60 ? JSON.stringify(v.slice(0, 60)) + "…" : JSON.stringify(v);
|
||||||
if (Array.isArray(v)) { const names = v.map((x) => (x && x.function && x.function.name) || (x && x.name) || "").filter(Boolean); return "[" + v.length + " 项" + (names.length ? ": " + names.join(", ") : "") + "]"; }
|
if (Array.isArray(v)) {
|
||||||
|
const tools = v.filter((x) => x && x.function && x.function.name);
|
||||||
|
if (tools.length) {
|
||||||
|
return tools
|
||||||
|
.map((t) => {
|
||||||
|
const fn = t.function;
|
||||||
|
const props = Object.keys((fn.parameters && fn.parameters.properties) || {});
|
||||||
|
return fn.name + "(参数: " + (props.join(",") || "-") + "; 描述 " + String(fn.description || "").length + " 字)";
|
||||||
|
})
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
const names = v.map((x) => (x && x.function && x.function.name) || (x && x.name) || "").filter(Boolean);
|
||||||
|
return "[" + v.length + " 项" + (names.length ? ": " + names.join(", ") : "") + "]";
|
||||||
|
}
|
||||||
if (typeof v === "object") return JSON.stringify(v).slice(0, 200);
|
if (typeof v === "object") return JSON.stringify(v).slice(0, 200);
|
||||||
return String(v);
|
return String(v);
|
||||||
};
|
};
|
||||||
@@ -166,6 +179,72 @@ function unattributed(entries, messages) {
|
|||||||
return out.length ? out.join("\n") : "(没有)";
|
return out.length ? out.join("\n") : "(没有)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onlyOnOneSide(stMsgs, waMsgs) {
|
||||||
|
const key = (m) => stripWs(m.content).slice(0, 40);
|
||||||
|
const stKeys = new Set(stMsgs.map(key));
|
||||||
|
const waKeys = new Set(waMsgs.map(key));
|
||||||
|
const onlySt = stMsgs.filter((m) => !waKeys.has(key(m)));
|
||||||
|
const onlyWa = waMsgs.filter((m) => !stKeys.has(key(m)));
|
||||||
|
const fmtList = (list) =>
|
||||||
|
list.length
|
||||||
|
? list.map((m, i) => "- [" + m.role + "] " + m.content.length + " 字:" + stripWs(m.content).slice(0, 60)).join("\n")
|
||||||
|
: "(没有)";
|
||||||
|
return { onlySt, onlyWa, text: "**只在酒馆出现**:" + onlySt.length + " 条\n\n" + fmtList(onlySt) + "\n\n**只在本仓库出现**:" + onlyWa.length + " 条\n\n" + fmtList(onlyWa) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把某一侧的最终 messages 原文落盘,方便逐字比对。 */
|
||||||
|
function writeMessageDump(outDir, label, msgs) {
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
const lines = [];
|
||||||
|
msgs.forEach((m, i) => {
|
||||||
|
const name = m.name ? " name=" + m.name : "";
|
||||||
|
lines.push("#" + i + " [" + m.role + "]" + name + " " + m.content.length + " 字");
|
||||||
|
lines.push(m.content);
|
||||||
|
lines.push("");
|
||||||
|
});
|
||||||
|
writeFileSync(outDir + "/messages-" + label + ".txt", lines.join("\n"), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按正文前 40 字把两边消息配对,并排打印。 */
|
||||||
|
function sideBySide(stMsgs, waMsgs) {
|
||||||
|
const key = (m) => stripWs(m.content).slice(0, 40);
|
||||||
|
const waIndex = new Map();
|
||||||
|
const used = new Set();
|
||||||
|
waMsgs.forEach((m, j) => {
|
||||||
|
const k = key(m);
|
||||||
|
if (!waIndex.has(k)) waIndex.set(k, []);
|
||||||
|
waIndex.get(k).push(j);
|
||||||
|
});
|
||||||
|
const rows = [];
|
||||||
|
const pairedWa = new Set();
|
||||||
|
stMsgs.forEach((m, i) => {
|
||||||
|
const candidates = (waIndex.get(key(m)) || []).filter((j) => !pairedWa.has(j));
|
||||||
|
const j = candidates.length ? candidates[0] : null;
|
||||||
|
if (j !== null) pairedWa.add(j);
|
||||||
|
const left = "#" + i + " [" + m.role + "] " + m.content.length + " 字: " + stripWs(m.content).slice(0, 50);
|
||||||
|
const right =
|
||||||
|
j === null
|
||||||
|
? "—"
|
||||||
|
: "#" + j + " [" + waMsgs[j].role + "] " + waMsgs[j].content.length + " 字: " + stripWs(waMsgs[j].content).slice(0, 50);
|
||||||
|
let verdict = "对上了";
|
||||||
|
if (j === null) verdict = "**只在酒馆**";
|
||||||
|
else if (stMsgs[i].role !== waMsgs[j].role) verdict = "**角色不同**";
|
||||||
|
else if (stMsgs[i].content.trim() !== waMsgs[j].content.trim()) verdict = "内容有差";
|
||||||
|
rows.push("| " + left + " | " + right + " | " + verdict + " |");
|
||||||
|
});
|
||||||
|
waMsgs.forEach((m, j) => {
|
||||||
|
if (pairedWa.has(j)) return;
|
||||||
|
rows.push(
|
||||||
|
"| — | #" + j + " [" + m.role + "] " + m.content.length + " 字: " + stripWs(m.content).slice(0, 50) + " | **只在本仓库** |",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
"| 酒馆 | 本仓库 | 结果 |",
|
||||||
|
"| --- | --- | --- |",
|
||||||
|
...rows,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const args = readArgs(process.argv.slice(2));
|
const args = readArgs(process.argv.slice(2));
|
||||||
if (!args.st || !args.wa) throw new Error("缺少 --st / --wa");
|
if (!args.st || !args.wa) throw new Error("缺少 --st / --wa");
|
||||||
@@ -209,6 +288,13 @@ function main() {
|
|||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(messageTable("本仓库", waMsgs, entries));
|
lines.push(messageTable("本仓库", waMsgs, entries));
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
const side = onlyOnOneSide(stMsgs, waMsgs);
|
||||||
|
writeMessageDump(args.out, "st", stMsgs);
|
||||||
|
writeMessageDump(args.out, "wa", waMsgs);
|
||||||
|
lines.push("## 4. 只在一侧出现的消息(按正文前 40 字配对)");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(side.text);
|
||||||
|
lines.push("");
|
||||||
lines.push("## 4. 开头对不上任何预设条目的消息");
|
lines.push("## 4. 开头对不上任何预设条目的消息");
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("酒馆:");
|
lines.push("酒馆:");
|
||||||
@@ -218,6 +304,10 @@ function main() {
|
|||||||
lines.push("本仓库:");
|
lines.push("本仓库:");
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(unattributed(entries, waMsgs));
|
lines.push(unattributed(entries, waMsgs));
|
||||||
|
lines.push("## 5. 最终 messages 并排(全文见同目录 messages-st.txt / messages-wa.txt)");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(sideBySide(stMsgs, waMsgs));
|
||||||
|
lines.push("");
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
mkdirSync(args.out, { recursive: true });
|
mkdirSync(args.out, { recursive: true });
|
||||||
@@ -232,6 +322,7 @@ function main() {
|
|||||||
console.log("[diff] 报告: " + reportFile);
|
console.log("[diff] 报告: " + reportFile);
|
||||||
console.log("[diff] 字段: " + fields.length + " 项, 仅酒馆 " + onlySt + ", 仅本仓库 " + onlyWa + ", 不同 " + diffCount);
|
console.log("[diff] 字段: " + fields.length + " 项, 仅酒馆 " + onlySt + ", 仅本仓库 " + onlyWa + ", 不同 " + diffCount);
|
||||||
console.log("[diff] 消息: 酒馆 " + stMsgs.length + " 条 / 本仓库 " + waMsgs.length + " 条");
|
console.log("[diff] 消息: 酒馆 " + stMsgs.length + " 条 / 本仓库 " + waMsgs.length + " 条");
|
||||||
|
console.log("[diff] 只在酒馆 " + side.onlySt.length + " 条,只在本仓库 " + side.onlyWa.length + " 条");
|
||||||
if (entriesOnlySt.length) console.log("[diff] 只在酒馆出现的预设条目: " + entriesOnlySt.map((e) => e.name).join("、"));
|
if (entriesOnlySt.length) console.log("[diff] 只在酒馆出现的预设条目: " + entriesOnlySt.map((e) => e.name).join("、"));
|
||||||
if (entriesOnlyWa.length) console.log("[diff] 只在本仓库出现的预设条目: " + entriesOnlyWa.map((e) => e.name).join("、"));
|
if (entriesOnlyWa.length) console.log("[diff] 只在本仓库出现的预设条目: " + entriesOnlyWa.map((e) => e.name).join("、"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ const DEFAULTS = {
|
|||||||
port: 23377,
|
port: 23377,
|
||||||
out: path.join(HERE, "captures"),
|
out: path.join(HERE, "captures"),
|
||||||
dataRoot: path.join(HERE, ".wa-data"),
|
dataRoot: path.join(HERE, ".wa-data"),
|
||||||
|
charName: "ProbeCard",
|
||||||
|
toolPolicy: "adapt",
|
||||||
|
exampleDialogue:
|
||||||
|
"<START>\n{{user}}: EX_USER_TOKEN 今天做什么?\n{{char}}: EX_CHAR_TOKEN 读书。",
|
||||||
|
// 跟酒馆那张探针卡的三块字段一致
|
||||||
|
charDescription: "DESC_TOKEN 这是一张测试角色卡的描述,用来确认酒馆把它放在哪一段。",
|
||||||
|
charPersonality: "PERS_TOKEN 冷静、话少、只答问题。",
|
||||||
|
scenario: "SCEN_TOKEN 一间安静的书房,只有两个人在说话。",
|
||||||
timeoutMs: 120000,
|
timeoutMs: 120000,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,6 +41,10 @@ async function postJson(url, body) {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = parseArgs(process.argv.slice(2), DEFAULTS);
|
const args = parseArgs(process.argv.slice(2), DEFAULTS);
|
||||||
|
const priorIndex = process.argv.indexOf("--prior");
|
||||||
|
if (priorIndex > 0) args.prior = String(process.argv[priorIndex + 1] ?? "");
|
||||||
|
const policyIndex = process.argv.indexOf("--tool-policy");
|
||||||
|
if (policyIndex > 0) args.toolPolicy = String(process.argv[policyIndex + 1] ?? "adapt");
|
||||||
if (!args.preset) throw new Error("缺少 --preset");
|
if (!args.preset) throw new Error("缺少 --preset");
|
||||||
const presetName = path.basename(args.preset, ".json");
|
const presetName = path.basename(args.preset, ".json");
|
||||||
const raw = JSON.parse(readFileSync(args.preset, "utf8"));
|
const raw = JSON.parse(readFileSync(args.preset, "utf8"));
|
||||||
@@ -56,8 +68,26 @@ async function main() {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
writeFileSync(path.join(args.dataRoot, "profiles.json"), JSON.stringify(profiles, null, 2), "utf8");
|
writeFileSync(path.join(args.dataRoot, "profiles.json"), JSON.stringify(profiles, null, 2), "utf8");
|
||||||
|
// 预设工具协议:preset = 按预设数据声明真 tools
|
||||||
|
writeFileSync(
|
||||||
|
path.join(args.dataRoot, "settings.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
activeProfileId: null,
|
||||||
|
activePresetId: null,
|
||||||
|
messageSquash: "follow-preset",
|
||||||
|
presetToolPolicy: args.toolPolicy,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
try { rmSync(path.join(args.dataRoot, "presets"), { recursive: true, force: true }); } catch (e) {} const known = new Set(listCaptures(args.out, "wa"));
|
try { rmSync(path.join(args.dataRoot, "presets"), { recursive: true, force: true }); } catch (e) {}
|
||||||
|
// 人设也用默认的(名字 User,跟酒馆一致),免得复用上一次跑留下的「玩家」
|
||||||
|
try { rmSync(path.join(args.dataRoot, "personas.json"), { force: true }); } catch (e) {} const known = new Set(listCaptures(args.out, "wa"));
|
||||||
const env = { ...process.env, PORT: String(args.port), WRITING_AGENT_DATA_DIR: args.dataRoot };
|
const env = { ...process.env, PORT: String(args.port), WRITING_AGENT_DATA_DIR: args.dataRoot };
|
||||||
const server = spawn(process.execPath, [path.join(REPO, "node_modules/tsx/dist/cli.mjs"), path.join(REPO, "src/server/web-server.ts")], { cwd: REPO, env, stdio: ["ignore", "pipe", "pipe"] });
|
const server = spawn(process.execPath, [path.join(REPO, "node_modules/tsx/dist/cli.mjs"), path.join(REPO, "src/server/web-server.ts")], { cwd: REPO, env, stdio: ["ignore", "pipe", "pipe"] });
|
||||||
const logs = [];
|
const logs = [];
|
||||||
@@ -77,13 +107,28 @@ async function main() {
|
|||||||
text: args.message,
|
text: args.message,
|
||||||
presetId,
|
presetId,
|
||||||
complete: true,
|
complete: true,
|
||||||
turns: [],
|
turns: args.prior
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "t1",
|
||||||
|
user: { content: "上一轮用户", createdAt: now },
|
||||||
|
replies: [{ windowId: "w1", content: args.prior }],
|
||||||
|
createdAt: now,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
windows: [{ id: "w1", profileId: "probe-mock" }],
|
windows: [{ id: "w1", profileId: "probe-mock" }],
|
||||||
systemPrompt: "",
|
systemPrompt: "",
|
||||||
loreBefore: "",
|
loreBefore: "",
|
||||||
seedHistory: "",
|
seedHistory: "",
|
||||||
loreAfter: "",
|
loreAfter: "",
|
||||||
postTurn: "",
|
postTurn: "",
|
||||||
|
// 跟酒馆那张探针卡对齐:角色名 + 卡里的示例对话原文
|
||||||
|
charName: args.charName,
|
||||||
|
exampleDialogue: args.exampleDialogue,
|
||||||
|
charDescription: args.charDescription,
|
||||||
|
charPersonality: args.charPersonality,
|
||||||
|
scenario: args.scenario,
|
||||||
};
|
};
|
||||||
const result = await postJson(base + "/api/direct-chat/ephemeral/complete", body);
|
const result = await postJson(base + "/api/direct-chat/ephemeral/complete", body);
|
||||||
const first = result.multi && result.multi.results && result.multi.results[0];
|
const first = result.multi && result.multi.results && result.multi.results[0];
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// 用本仓库的声明式工具代码看某个预设会声明出什么 tools(不发请求)。
|
||||||
|
// 用法: node node_modules/tsx/dist/cli.mjs tools/st-request-diff/inspect-tools-decl.mjs <preset.json>
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { importSillyTavernPreset } from "../../src/preset/importer.js";
|
||||||
|
import { declarePresetTools } from "../../src/preset/tool-declaration.js";
|
||||||
|
|
||||||
|
const file = process.argv[2];
|
||||||
|
const raw = JSON.parse(readFileSync(file, "utf8"));
|
||||||
|
const report = importSillyTavernPreset(raw, { name: path.basename(file, ".json") });
|
||||||
|
const declared = declarePresetTools(report.preset);
|
||||||
|
if (!declared) {
|
||||||
|
console.log("没有声明工具(declarePresetTools = null)");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.log("来源: " + declared.source + ",toolChoice: " + declared.toolChoice + ",正文参数: " + declared.payloadFields.join(", "));
|
||||||
|
for (const tool of declared.tools) {
|
||||||
|
const fn = tool.function;
|
||||||
|
const params = fn.parameters;
|
||||||
|
console.log(" 工具名: " + fn.name);
|
||||||
|
console.log(" 描述字数: " + fn.description.length + " 开头: " + fn.description.replace(/\s+/g, " ").slice(0, 60));
|
||||||
|
console.log(" 参数: " + JSON.stringify(Object.keys(params.properties ?? {})) + " required=" + JSON.stringify(params.required ?? []));
|
||||||
|
for (const [key, value] of Object.entries(params.properties ?? {})) {
|
||||||
|
const desc = String(value.description ?? "");
|
||||||
|
console.log(" - " + key + " (" + (value.type ?? "") + ") 说明字数 " + desc.length + " 开头: " + desc.replace(/\s+/g, " ").slice(0, 50));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// 看工具模式下两边的消息条数/字数,以及"工具说明/字段说明"有没有既进消息又进 tools 描述(重复)。
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
for (const file of process.argv.slice(2)) {
|
||||||
|
const body = JSON.parse(JSON.parse(readFileSync(file, "utf8")).body);
|
||||||
|
const msgs = body.messages || [];
|
||||||
|
const total = msgs.reduce((n, m) => n + String(m.content ?? "").length, 0);
|
||||||
|
const desc = (body.tools || []).map((t) => String(t.function?.description ?? "").length);
|
||||||
|
const paramDesc = (body.tools || []).flatMap((t) => Object.values(t.function?.parameters?.properties ?? {}).map((p) => String(p.description ?? "").length));
|
||||||
|
const hitDesc = msgs.some((m) => String(m.content ?? "").includes("POSIX Terminal Screen Kernel Configuration"));
|
||||||
|
const hitParam = msgs.some((m) => String(m.content ?? "").includes("terminal_payload_contract"));
|
||||||
|
console.log(file.split("/").pop());
|
||||||
|
console.log(" messages=" + msgs.length + " 正文合计=" + total + " 字; tools=" + (body.tools || []).length + " 工具描述=" + desc.join(",") + " 参数描述=" + paramDesc.join(","));
|
||||||
|
console.log(" 工具说明也在 messages 里? " + hitDesc + "; 字段说明也在 messages 里? " + hitParam);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// 看预设里的 personality_format / scenario_format / wi_format 长什么样。
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
for (const file of process.argv.slice(2)) {
|
||||||
|
const raw = JSON.parse(readFileSync(file, "utf8"));
|
||||||
|
console.log("=== " + file.split("/").pop());
|
||||||
|
for (const key of ["personality_format", "scenario_format", "wi_format"]) {
|
||||||
|
const value = raw[key];
|
||||||
|
console.log(" " + key + ": " + (value === undefined ? "(缺)" : JSON.stringify(value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
const body = JSON.parse(JSON.parse(readFileSync(process.argv[2], "utf8")).body);
|
||||||
|
const t = (body.tools || [])[0];
|
||||||
|
console.log("name=" + t?.function?.name);
|
||||||
|
console.log("parameters=" + JSON.stringify(t?.function?.parameters, null, 1));
|
||||||
|
console.log("description 字数=" + (t?.function?.description || "").length);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// 看酒馆工具模式请求里的 tools 定义全文,以及 Reborn 预设里的 ToolBindings 结构。
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const capture = process.argv[2];
|
||||||
|
const body = JSON.parse(JSON.parse(readFileSync(capture, "utf8")).body);
|
||||||
|
console.log("=== 抓包里的 tools ===");
|
||||||
|
console.log(JSON.stringify(body.tools, null, 1).slice(0, 3000));
|
||||||
|
console.log("=== tool_choice: " + JSON.stringify(body.tool_choice));
|
||||||
|
|
||||||
|
const presetFile = process.argv[3];
|
||||||
|
if (presetFile) {
|
||||||
|
const raw = JSON.parse(readFileSync(presetFile, "utf8"));
|
||||||
|
const spreset = raw.extensions?.SPreset ?? raw.extensions?.spreset ?? null;
|
||||||
|
console.log("=== 预设 extensions 键: " + Object.keys(raw.extensions || {}).join(", "));
|
||||||
|
const bindings = spreset?.ToolBindings ?? raw.extensions?.ToolBindings ?? null;
|
||||||
|
console.log("=== ToolBindings ===");
|
||||||
|
console.log(bindings ? JSON.stringify(bindings, null, 1).slice(0, 2000) : "(无)");
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// 一个预设到底有多少内容、哪些进了请求:启用条目正文总量 / 最大条目 / 会改请求的正则。
|
||||||
|
// 用法: node node_modules/tsx/dist/cli.mjs tools/st-request-diff/preset-accounting.mjs <preset.json>
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { importSillyTavernPreset } from "../../src/preset/importer.js";
|
||||||
|
import { buildPresetDiagnostics } from "../../src/preset/diagnostics.js";
|
||||||
|
|
||||||
|
const file = process.argv[2];
|
||||||
|
const raw = JSON.parse(readFileSync(file, "utf8"));
|
||||||
|
const report = importSillyTavernPreset(raw, { name: path.basename(file, ".json") });
|
||||||
|
const preset = report.preset;
|
||||||
|
const orderEnabled = new Map(preset.promptOrder.map((o) => [o.promptId, o.enabled]));
|
||||||
|
const entries = preset.prompts
|
||||||
|
.filter((p) => orderEnabled.get(p.id) === true)
|
||||||
|
.map((p) => ({ id: p.id, name: p.name, marker: p.marker, len: p.content.length }))
|
||||||
|
.sort((a, b) => b.len - a.len);
|
||||||
|
const total = entries.reduce((n, e) => n + e.len, 0);
|
||||||
|
console.log("=== " + path.basename(file) + ":order 启用条目 " + entries.length + " 条,正文合计 " + total + " 字");
|
||||||
|
console.log(" 最大的 8 条:");
|
||||||
|
for (const entry of entries.slice(0, 8)) {
|
||||||
|
console.log(" " + String(entry.len).padStart(6) + " 字 " + (entry.marker ? "[洞] " : "") + entry.name + " (" + entry.id.slice(0, 12) + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
const diagnostics = buildPresetDiagnostics(preset, preset.raw);
|
||||||
|
const promptRegexes = diagnostics.regexes.filter((r) => r.promptOnly || r.markdownOnly === false);
|
||||||
|
console.log("=== 正则 " + diagnostics.regexes.length + " 条,其中 promptOnly=" + diagnostics.regexes.filter((r) => r.promptOnly).length + " 条,会影响发出的请求");
|
||||||
|
for (const regex of diagnostics.regexes.slice(0, 8)) {
|
||||||
|
console.log(" " + regex.name + " placement=" + JSON.stringify(regex.placement) + " promptOnly=" + regex.promptOnly + " markdownOnly=" + regex.markdownOnly + " disabled=" + regex.disabled);
|
||||||
|
}
|
||||||
|
console.log("=== 脚本 " + diagnostics.scripts.length + " 个:" + diagnostics.scripts.map((s) => s.name + "(" + Math.round(s.bytes / 1024) + "KB" + (s.isUiPanel ? ",界面" : "") + ")").join("、"));
|
||||||
|
console.log("=== 工具协议 bindings=" + diagnostics.toolProtocol.bindings.length + " entries=" + diagnostics.toolProtocol.entries.length + " placeholders=" + diagnostics.toolProtocol.placeholders.length);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
const body = JSON.parse(JSON.parse(readFileSync(process.argv[2], "utf8")).body);
|
||||||
|
const from = Number(process.argv[3] ?? 0);
|
||||||
|
const to = Number(process.argv[4] ?? body.messages.length);
|
||||||
|
body.messages.slice(from, to).forEach((m, i) => {
|
||||||
|
const text = String(m.content ?? "");
|
||||||
|
console.log("#" + (from + i) + " [" + m.role + "] " + text.length + " 字: " + JSON.stringify(text.slice(0, 90)));
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* 直聊「保留」计数器:
|
||||||
|
* 按会话记下每个模型被保留(点「保留此回复」)了多少次,存在浏览器缓存里。
|
||||||
|
* 只算数,不碰 DOM。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "wa-direct-chat-keep-stats-v1";
|
||||||
|
|
||||||
|
function storage() {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAll() {
|
||||||
|
const ls = storage();
|
||||||
|
if (!ls) return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(ls.getItem(STORAGE_KEY) || "{}");
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? parsed
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAll(all) {
|
||||||
|
const ls = storage();
|
||||||
|
if (!ls) return;
|
||||||
|
try {
|
||||||
|
ls.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||||
|
} catch {
|
||||||
|
/* 存不进缓存也不影响当前会话 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBucket(value) {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型身份:同一配置同一型号算同一个模型,
|
||||||
|
* 窗口被删掉再加回来(窗口 id 变了)也还认作它。
|
||||||
|
*/
|
||||||
|
export function keepStatId(w) {
|
||||||
|
const model = String(w?.model || "");
|
||||||
|
const key = String(w?.profileId || w?.profileName || model || "unknown");
|
||||||
|
return model && model !== key ? `${key}::${model}` : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展示名与型号:配置名 · 型号,重复时只留一个 */
|
||||||
|
export function keepStatText(w) {
|
||||||
|
const model = String(w?.model || "");
|
||||||
|
const label = String(
|
||||||
|
w?.profileName || w?.profileId || model || "未知模型",
|
||||||
|
);
|
||||||
|
return { id: keepStatId(w), label, model: model === label ? "" : model };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按次数降序,同次数再按名字排 */
|
||||||
|
export function sortKeepStats(entries) {
|
||||||
|
return [...entries].sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(b.count) - Number(a.count) ||
|
||||||
|
String(a.label).localeCompare(String(b.label), "zh-CN"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 条状图宽度百分比:独占一行时满格,次数少的也至少留一点可见宽度 */
|
||||||
|
export function keepStatPercent(count, max) {
|
||||||
|
const top = Number(max) || 0;
|
||||||
|
const value = Number(count) || 0;
|
||||||
|
if (top <= 0 || value <= 0) return 0;
|
||||||
|
return Math.max(6, Math.min(100, Math.round((value / top) * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readKeepStats(conversationId) {
|
||||||
|
if (!conversationId) return [];
|
||||||
|
const bucket = readAll()[conversationId];
|
||||||
|
if (!isBucket(bucket)) return [];
|
||||||
|
const entries = Object.entries(bucket).map(([id, value]) => ({
|
||||||
|
id,
|
||||||
|
label: String(value?.label || id),
|
||||||
|
model: String(value?.model || ""),
|
||||||
|
count: Math.max(1, Number(value?.count) || 0),
|
||||||
|
}));
|
||||||
|
return sortKeepStats(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keepStatTotal(entries) {
|
||||||
|
return entries.reduce((sum, entry) => sum + (Number(entry.count) || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加减一次保留次数;减到 0 就把这个模型从记录里删掉。
|
||||||
|
* @param {string} conversationId
|
||||||
|
* @param {{profileId?: string, profileName?: string, model?: string}} window
|
||||||
|
* @param {number} [delta] 保留 +1、取消保留 -1
|
||||||
|
*/
|
||||||
|
export function bumpKeepStats(conversationId, window, delta = 1) {
|
||||||
|
if (!conversationId || !window) return readKeepStats(conversationId);
|
||||||
|
const step = Number(delta) || 0;
|
||||||
|
if (!step) return readKeepStats(conversationId);
|
||||||
|
const all = readAll();
|
||||||
|
const bucket = isBucket(all[conversationId])
|
||||||
|
? { ...all[conversationId] }
|
||||||
|
: {};
|
||||||
|
const { id, label, model } = keepStatText(window);
|
||||||
|
const before = isBucket(bucket[id]) ? Number(bucket[id].count) || 0 : 0;
|
||||||
|
const count = Math.max(0, before + step);
|
||||||
|
if (count > 0) bucket[id] = { label, model, count };
|
||||||
|
else delete bucket[id];
|
||||||
|
if (Object.keys(bucket).length) all[conversationId] = bucket;
|
||||||
|
else delete all[conversationId];
|
||||||
|
writeAll(all);
|
||||||
|
return readKeepStats(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清掉一个会话的计数(会话被删时用) */
|
||||||
|
export function clearKeepStats(conversationId) {
|
||||||
|
if (!conversationId) return;
|
||||||
|
const all = readAll();
|
||||||
|
if (!(conversationId in all)) return;
|
||||||
|
delete all[conversationId];
|
||||||
|
writeAll(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 临时会话存成持久会话时,把计数跟着搬到新会话上 */
|
||||||
|
export function copyKeepStats(fromId, toId) {
|
||||||
|
if (!fromId || !toId || fromId === toId) return readKeepStats(toId);
|
||||||
|
const all = readAll();
|
||||||
|
const bucket = all[fromId];
|
||||||
|
if (!isBucket(bucket)) return readKeepStats(toId);
|
||||||
|
all[toId] = { ...(isBucket(all[toId]) ? all[toId] : {}), ...bucket };
|
||||||
|
delete all[fromId];
|
||||||
|
writeAll(all);
|
||||||
|
return readKeepStats(toId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* 直聊的滚轮分配:指针在哪张卡片上,就先滚那张卡片的正文;
|
||||||
|
* 卡片到顶 / 到底后,这一格滚轮剩下的距离交给外面的大滚动条。
|
||||||
|
* 只算数,不碰 DOM,方便单测。
|
||||||
|
*/
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} input
|
||||||
|
* @param {number} [input.cardScrollTop] 卡片正文当前滚动位置
|
||||||
|
* @param {number} [input.cardScrollMax] 卡片正文最多还能滚多少(0 = 不可滚)
|
||||||
|
* @param {number} [input.deltaY] 这一格滚轮的距离,向下为正
|
||||||
|
* @param {number} [input.streamScrollTop] 大滚动条当前位置
|
||||||
|
* @param {number} [input.streamScrollMax] 大滚动条最多还能滚多少
|
||||||
|
* @returns {{ cardDelta: number, streamDelta: number, rest: number }}
|
||||||
|
* `cardDelta` / `streamDelta` 是各自实际该滚的距离,`rest` 是两边都到头后没用掉的距离。
|
||||||
|
*/
|
||||||
|
export function planWheelScroll({
|
||||||
|
cardScrollTop = 0,
|
||||||
|
cardScrollMax = 0,
|
||||||
|
deltaY = 0,
|
||||||
|
streamScrollTop = 0,
|
||||||
|
streamScrollMax = 0,
|
||||||
|
} = {}) {
|
||||||
|
const plan = { cardDelta: 0, streamDelta: 0, rest: 0 };
|
||||||
|
let rest = Number(deltaY) || 0;
|
||||||
|
if (!rest) return plan;
|
||||||
|
|
||||||
|
if (cardScrollMax > 0) {
|
||||||
|
const next = clamp(cardScrollTop + rest, 0, cardScrollMax);
|
||||||
|
const applied = next - cardScrollTop;
|
||||||
|
if (applied) {
|
||||||
|
plan.cardDelta = applied;
|
||||||
|
rest -= applied;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rest && streamScrollMax > 0) {
|
||||||
|
const next = clamp(streamScrollTop + rest, 0, streamScrollMax);
|
||||||
|
const applied = next - streamScrollTop;
|
||||||
|
if (applied) {
|
||||||
|
plan.streamDelta = applied;
|
||||||
|
rest -= applied;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.rest = rest;
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
+570
-18
@@ -1,11 +1,85 @@
|
|||||||
.chat-shell {
|
.chat-shell {
|
||||||
|
--rail-left: minmax(200px, 240px);
|
||||||
|
--rail-right: minmax(260px, 320px);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr) minmax(260px, 320px);
|
grid-template-columns: var(--rail-left) minmax(0, 1fr) var(--rail-right);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 侧栏收起:宽度归零,主区仍固定在第 2 列,卡片直接变宽 */
|
||||||
|
.chat-shell.is-left-hidden {
|
||||||
|
--rail-left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell.is-left-hidden .chat-rail {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell.is-right-hidden {
|
||||||
|
--rail-right: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell.is-right-hidden .chat-context {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 侧栏边缘的收起/展开小把手 */
|
||||||
|
.chat-rail-toggle,
|
||||||
|
.chat-rail-reopen {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
z-index: 4;
|
||||||
|
width: 18px;
|
||||||
|
height: 42px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--st-border-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--st-surface);
|
||||||
|
color: var(--st-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-rail-toggle:hover,
|
||||||
|
.chat-rail-toggle:focus-visible,
|
||||||
|
.chat-rail-reopen:hover,
|
||||||
|
.chat-rail-reopen:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--st-accent);
|
||||||
|
border-color: var(--st-accent);
|
||||||
|
background: var(--st-accent-soft);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-rail-reopen[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-rail-toggle-left {
|
||||||
|
right: -9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-rail-toggle-right {
|
||||||
|
left: -9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-rail-reopen-left {
|
||||||
|
left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-rail-reopen-right {
|
||||||
|
right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-rail {
|
.chat-rail {
|
||||||
|
grid-column: 1;
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -123,6 +197,14 @@
|
|||||||
color: var(--st-muted);
|
color: var(--st-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-rail-item-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-rail-item-copy,
|
||||||
.chat-rail-item-del {
|
.chat-rail-item-del {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -130,13 +212,28 @@
|
|||||||
color: var(--st-faint);
|
color: var(--st-faint);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-rail-item-del:hover {
|
.chat-rail-item-copy:hover,
|
||||||
|
.chat-rail-item-copy:focus-visible {
|
||||||
|
color: var(--st-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-rail-item-del:hover,
|
||||||
|
.chat-rail-item-del:focus-visible {
|
||||||
color: var(--st-danger);
|
color: var(--st-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-rail-item-main:focus-visible,
|
||||||
|
.chat-rail-item-copy:focus-visible,
|
||||||
|
.chat-rail-item-del:focus-visible,
|
||||||
|
.st-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--st-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-rail-note {
|
.chat-rail-note {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -145,6 +242,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-main {
|
.chat-main {
|
||||||
|
grid-column: 2;
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -202,6 +301,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-context {
|
.chat-context {
|
||||||
|
grid-column: 3;
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -212,18 +313,12 @@
|
|||||||
|
|
||||||
.chat-context-head {
|
.chat-context-head {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 12px 14px 8px;
|
padding: 10px 14px 8px;
|
||||||
border-bottom: 1px solid var(--st-border);
|
border-bottom: 1px solid var(--st-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-context-head h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-context-sub {
|
.chat-context-sub {
|
||||||
margin: 4px 0 0;
|
margin: 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--st-muted);
|
color: var(--st-muted);
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
@@ -266,9 +361,139 @@
|
|||||||
.chat-shell-editor-actions {
|
.chat-shell-editor-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 右侧面板的两个页签:上下文 / 保留统计 */
|
||||||
|
.chat-context-tabs {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 14px 0;
|
||||||
|
border-bottom: 1px solid var(--st-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-context-tab {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--st-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 7px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-context-tab:hover,
|
||||||
|
.chat-context-tab:focus-visible {
|
||||||
|
color: var(--st-text);
|
||||||
|
background: color-mix(in srgb, var(--st-muted) 10%, transparent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-context-tab.is-active {
|
||||||
|
border-color: var(--st-border);
|
||||||
|
background: var(--st-surface);
|
||||||
|
color: var(--st-text);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell-editor[hidden],
|
||||||
|
.chat-keep-stats[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 保留统计:条状图,按次数降序 */
|
||||||
|
.chat-keep-stats {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-keep-stats-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--st-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-keep-stats-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-keep-bar {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-keep-bar-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-keep-bar-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--st-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-keep-bar-count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--st-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-keep-bar-track {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: color-mix(in srgb, var(--st-muted) 14%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-keep-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: color-mix(in srgb, var(--st-accent) 72%, var(--st-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-keep-bar-model {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--st-faint);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-keep-stats-foot {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-keep-stats-foot .st-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* —— 模型竞技场:用户气泡 + 横向回复卡片 —— */
|
/* —— 模型竞技场:用户气泡 + 横向回复卡片 —— */
|
||||||
.chat-arena {
|
.chat-arena {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -487,6 +712,100 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-user-bubble.is-editing {
|
||||||
|
width: min(92%, 42rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-user-foot,
|
||||||
|
.chat-user-edit-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-turn-variants {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-turn-variant-count {
|
||||||
|
min-width: 2.2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-user-foot .chat-nav-btn {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-user-act {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #475569;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-user-act:hover,
|
||||||
|
.chat-user-act:focus-visible {
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-user-act.is-primary {
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-user-act:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-user-edit {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 4.5rem;
|
||||||
|
max-height: 320px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid #93c5fd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1e293b;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-user-edit:focus {
|
||||||
|
outline: 2px solid #bfdbfe;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-user-edit-hint {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-bubble-time {
|
.chat-bubble-time {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@@ -506,6 +825,32 @@
|
|||||||
color: var(--st-faint);
|
color: var(--st-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 本轮有多个失败时,一键重跑全部失败窗口;已成功的卡片不动 */
|
||||||
|
.chat-retry-all {
|
||||||
|
margin-left: auto;
|
||||||
|
border: 1px solid var(--st-border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--st-surface);
|
||||||
|
color: var(--st-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 3px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-retry-all:hover,
|
||||||
|
.chat-retry-all:focus-visible {
|
||||||
|
border-color: var(--st-accent);
|
||||||
|
background: var(--st-accent-soft);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-retry-all:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-strip-nav {
|
.chat-strip-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -590,17 +935,18 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-reply-card.is-collapsed {
|
.chat-reply-card.is-collapsed,
|
||||||
|
.chat-reply-card.is-capped {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-reply-card.is-collapsed .chat-reply-card-body {
|
.chat-reply-card:is(.is-collapsed, .is-capped) .chat-reply-card-body {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-reply-card.is-collapsed .chat-reply-card-text {
|
.chat-reply-card:is(.is-collapsed, .is-capped) .chat-reply-card-text {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -609,7 +955,7 @@
|
|||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-reply-card.is-collapsed .chat-reply-card-text::-webkit-scrollbar {
|
.chat-reply-card:is(.is-collapsed, .is-capped) .chat-reply-card-text::-webkit-scrollbar {
|
||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,6 +976,24 @@
|
|||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 分卡片:正文没在顶端就一直露着滚动条,滚回顶端才收 */
|
||||||
|
.chat-reply-card:is(.is-collapsed, .is-capped) .chat-reply-card-text.is-scrolled,
|
||||||
|
.chat-reply-card:is(.is-collapsed, .is-capped) .chat-reply-card-text.is-vscroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(in srgb, var(--st-muted) 42%, transparent) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-card:is(.is-collapsed, .is-capped) .chat-reply-card-text.is-scrolled::-webkit-scrollbar,
|
||||||
|
.chat-reply-card:is(.is-collapsed, .is-capped) .chat-reply-card-text.is-vscroll::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-card:is(.is-collapsed, .is-capped) .chat-reply-card-text.is-scrolled::-webkit-scrollbar-thumb,
|
||||||
|
.chat-reply-card:is(.is-collapsed, .is-capped) .chat-reply-card-text.is-vscroll::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, var(--st-muted) 45%, transparent);
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-reply-card.is-selected {
|
.chat-reply-card.is-selected {
|
||||||
border-color: var(--st-accent);
|
border-color: var(--st-accent);
|
||||||
background: color-mix(in srgb, var(--st-accent-soft) 55%, var(--st-surface));
|
background: color-mix(in srgb, var(--st-accent-soft) 55%, var(--st-surface));
|
||||||
@@ -653,6 +1017,13 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-reply-card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-reply-card-name {
|
.chat-reply-card-name {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -673,6 +1044,44 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 盲测:编号当按钮用,点一下只揭示这一张卡 */
|
||||||
|
.chat-reply-card-name.is-blind {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border: 1px solid var(--st-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--st-muted) 8%, transparent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--st-muted);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-card-name.is-blind:hover {
|
||||||
|
color: var(--st-text);
|
||||||
|
border-color: color-mix(in srgb, var(--st-accent) 50%, var(--st-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-card-name.is-blind.is-revealed {
|
||||||
|
color: var(--st-text);
|
||||||
|
border-color: color-mix(in srgb, var(--st-accent) 50%, var(--st-border));
|
||||||
|
background: var(--st-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-card-name.is-blind:focus-visible {
|
||||||
|
outline: 2px solid var(--st-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-card-model.is-revealed {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--st-text);
|
||||||
|
}
|
||||||
|
|
||||||
.chat-reply-card-body {
|
.chat-reply-card-body {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -694,6 +1103,25 @@
|
|||||||
|
|
||||||
.chat-reply-card-text {
|
.chat-reply-card-text {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
cursor: text;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
touch-action: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 关闭复制:卡片任意处都能拖动滚动,正文不再选中 */
|
||||||
|
.chat-arena.is-copy-off .chat-reply-card-text,
|
||||||
|
.chat-arena.is-copy-off .chat-reply-card-text *,
|
||||||
|
.chat-arena.is-copy-off .chat-reply-card-body {
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-track.is-dragging .chat-reply-card-text,
|
||||||
|
.chat-reply-track.is-dragging .chat-reply-card-text * {
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-reply-card-text .md-p {
|
.chat-reply-card-text .md-p {
|
||||||
@@ -824,7 +1252,10 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-copy-btn,
|
||||||
.chat-remove-btn,
|
.chat-remove-btn,
|
||||||
|
.chat-retry-btn,
|
||||||
|
.chat-regen-btn,
|
||||||
.chat-select-btn {
|
.chat-select-btn {
|
||||||
border: 1px solid var(--st-border-strong);
|
border: 1px solid var(--st-border-strong);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -837,15 +1268,38 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-copy-btn:hover,
|
||||||
.chat-remove-btn:hover,
|
.chat-remove-btn:hover,
|
||||||
|
.chat-retry-btn:hover,
|
||||||
|
.chat-regen-btn:hover,
|
||||||
.chat-select-btn:hover,
|
.chat-select-btn:hover,
|
||||||
|
.chat-copy-btn:focus-visible,
|
||||||
.chat-remove-btn:focus-visible,
|
.chat-remove-btn:focus-visible,
|
||||||
|
.chat-retry-btn:focus-visible,
|
||||||
|
.chat-regen-btn:focus-visible,
|
||||||
.chat-select-btn:focus-visible {
|
.chat-select-btn:focus-visible {
|
||||||
border-color: var(--st-accent);
|
border-color: var(--st-accent);
|
||||||
background: var(--st-accent-soft);
|
background: var(--st-accent-soft);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-regen-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-regen-btn:disabled:hover {
|
||||||
|
border-color: var(--st-border-strong);
|
||||||
|
background: var(--st-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 失败卡片上的重试按钮:用强调色标出来 */
|
||||||
|
.chat-retry-btn {
|
||||||
|
border-color: var(--st-accent);
|
||||||
|
color: var(--st-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-select-btn.is-on {
|
.chat-select-btn.is-on {
|
||||||
border-color: var(--st-accent);
|
border-color: var(--st-accent);
|
||||||
background: var(--st-accent-soft);
|
background: var(--st-accent-soft);
|
||||||
@@ -853,15 +1307,44 @@
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-bubble-thinking {
|
/* 思维链:默认折叠成一行,点开才铺开正文 */
|
||||||
|
.chat-think {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
padding: 8px;
|
border: 1px dashed var(--st-border-strong);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: color-mix(in srgb, var(--st-bg) 80%, transparent);
|
background: color-mix(in srgb, var(--st-bg) 80%, transparent);
|
||||||
color: var(--st-muted);
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-think-summary {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 8px;
|
||||||
|
color: var(--st-muted);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-think-summary:hover,
|
||||||
|
.chat-think-summary:focus-visible {
|
||||||
|
color: var(--st-text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-think[open] .chat-think-summary {
|
||||||
|
border-bottom: 1px dashed var(--st-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-think-body {
|
||||||
|
padding: 8px;
|
||||||
|
color: var(--st-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 关闭复制时整张卡都能拖,但折叠条仍要能点 */
|
||||||
|
.chat-arena.is-copy-off .chat-think-summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-composer {
|
.chat-composer {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -1062,6 +1545,33 @@
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-dialog input[type="checkbox"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
accent-color: var(--st-accent);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-model-pick {
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-model-pick legend {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--st-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.chat-dialog-actions {
|
.chat-dialog-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -1079,8 +1589,30 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
color: var(--st-text);
|
color: var(--st-text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
padding: 4px 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-compare-list label:hover {
|
||||||
|
background: var(--st-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-compare-list label:has(input:disabled) {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-compare-list label:has(input:disabled):hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-compare-list label span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-compare-result-head {
|
.chat-compare-result-head {
|
||||||
@@ -1122,7 +1654,8 @@
|
|||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.chat-shell {
|
.chat-shell {
|
||||||
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr) minmax(220px, 280px);
|
--rail-left: minmax(180px, 220px);
|
||||||
|
--rail-right: minmax(220px, 280px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,6 +1666,25 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-rail,
|
||||||
|
.chat-main,
|
||||||
|
.chat-context {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单列时按可见的块重排行高 */
|
||||||
|
.chat-shell.is-left-hidden {
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell.is-right-hidden {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell.is-left-hidden.is-right-hidden {
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.chat-rail {
|
.chat-rail {
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-bottom: 1px solid var(--st-border);
|
border-bottom: 1px solid var(--st-border);
|
||||||
|
|||||||
+131
-7
@@ -45,9 +45,32 @@
|
|||||||
<p class="chat-rail-note">
|
<p class="chat-rail-note">
|
||||||
未选存档即为快速对话(浏览器缓存,最多 2 条),可保存为持久会话。
|
未选存档即为快速对话(浏览器缓存,最多 2 条),可保存为持久会话。
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chat-rail-toggle chat-rail-toggle-left"
|
||||||
|
id="btn-hide-left"
|
||||||
|
title="收起对话列表"
|
||||||
|
aria-label="收起对话列表"
|
||||||
|
>‹</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="chat-main" id="chat-main">
|
<main class="chat-main" id="chat-main">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chat-rail-reopen chat-rail-reopen-left"
|
||||||
|
id="btn-show-left"
|
||||||
|
title="展开对话列表"
|
||||||
|
aria-label="展开对话列表"
|
||||||
|
hidden
|
||||||
|
>›</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chat-rail-reopen chat-rail-reopen-right"
|
||||||
|
id="btn-show-right"
|
||||||
|
title="展开上下文"
|
||||||
|
aria-label="展开上下文"
|
||||||
|
hidden
|
||||||
|
>‹</button>
|
||||||
<section class="chat-room" id="chat-room">
|
<section class="chat-room" id="chat-room">
|
||||||
<p class="chat-temp-mark" id="chat-temp-mark" hidden>临时会话</p>
|
<p class="chat-temp-mark" id="chat-temp-mark" hidden>临时会话</p>
|
||||||
<div class="chat-arena" id="chat-arena" aria-live="polite" aria-label="模型竞技场"></div>
|
<div class="chat-arena" id="chat-arena" aria-live="polite" aria-label="模型竞技场"></div>
|
||||||
@@ -87,10 +110,40 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside class="chat-context" id="chat-context" aria-label="上下文配置">
|
<aside class="chat-context" id="chat-context" aria-label="上下文配置">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chat-rail-toggle chat-rail-toggle-right"
|
||||||
|
id="btn-hide-right"
|
||||||
|
title="收起上下文"
|
||||||
|
aria-label="收起上下文"
|
||||||
|
>›</button>
|
||||||
<header class="chat-context-head">
|
<header class="chat-context-head">
|
||||||
<h2>上下文</h2>
|
|
||||||
<p class="chat-context-sub" id="context-sub">选择或新建后编辑</p>
|
<p class="chat-context-sub" id="context-sub">选择或新建后编辑</p>
|
||||||
</header>
|
</header>
|
||||||
|
<div class="chat-context-tabs" role="tablist" aria-label="上下文面板">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chat-context-tab is-active"
|
||||||
|
id="tab-shell"
|
||||||
|
data-context-tab="shell"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="true"
|
||||||
|
aria-controls="shell-editor"
|
||||||
|
>
|
||||||
|
上下文
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chat-context-tab"
|
||||||
|
id="tab-keep"
|
||||||
|
data-context-tab="keep"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="false"
|
||||||
|
aria-controls="keep-stats"
|
||||||
|
>
|
||||||
|
保留统计
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="chat-shell-editor" id="shell-editor">
|
<div class="chat-shell-editor" id="shell-editor">
|
||||||
<label>
|
<label>
|
||||||
名称
|
名称
|
||||||
@@ -126,12 +179,58 @@
|
|||||||
尾注
|
尾注
|
||||||
<textarea id="shell-post-turn" rows="2" disabled></textarea>
|
<textarea id="shell-post-turn" rows="2" disabled></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
角色名(对应预设里的 {{char}})
|
||||||
|
<input type="text" id="shell-char-name" placeholder="空则用「作品」" disabled />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
角色描述(酒馆 Char Description 洞)
|
||||||
|
<textarea
|
||||||
|
id="shell-char-desc"
|
||||||
|
rows="3"
|
||||||
|
placeholder="空则这一条不发"
|
||||||
|
disabled
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
角色性格(酒馆 Char Personality 洞,会套预设的性格模板)
|
||||||
|
<textarea id="shell-char-pers" rows="2" disabled></textarea>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
场景(酒馆 Scenario 洞,会套预设的场景模板)
|
||||||
|
<textarea id="shell-scenario" rows="2" disabled></textarea>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
示例对话(酒馆 mes_example 格式,用 <START> 分块)
|
||||||
|
<textarea
|
||||||
|
id="shell-examples"
|
||||||
|
rows="3"
|
||||||
|
placeholder="例:<START> {{user}}: 你好 {{char}}: 你也好"
|
||||||
|
disabled
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
<div class="chat-shell-editor-actions">
|
<div class="chat-shell-editor-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="st-btn"
|
||||||
|
id="btn-clear-shell"
|
||||||
|
disabled
|
||||||
|
title="清空提示和设定,保留名称与已选模型"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
<button type="button" class="st-btn st-btn-primary" id="btn-save-shell" disabled>
|
<button type="button" class="st-btn st-btn-primary" id="btn-save-shell" disabled>
|
||||||
应用
|
应用
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="chat-keep-stats" id="keep-stats" role="tabpanel" aria-labelledby="tab-keep" hidden>
|
||||||
|
<p class="chat-keep-stats-hint" id="keep-stats-hint"></p>
|
||||||
|
<div class="chat-keep-stats-list" id="keep-stats-list"></div>
|
||||||
|
<div class="chat-keep-stats-foot">
|
||||||
|
<button type="button" class="st-btn" id="btn-clear-keep" hidden>清空统计</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -153,17 +252,42 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog class="chat-dialog" id="dialog-new-conv">
|
||||||
|
<form method="dialog" id="form-new-conv">
|
||||||
|
<h3>新建会话</h3>
|
||||||
|
<p class="chat-dialog-hint">勾选要同时回复的模型,可以多选。未勾选也能创建,之后再加。</p>
|
||||||
|
<label>
|
||||||
|
会话名称
|
||||||
|
<input type="text" id="new-conv-name" value="新会话" required />
|
||||||
|
</label>
|
||||||
|
<fieldset class="chat-model-pick">
|
||||||
|
<legend>模型</legend>
|
||||||
|
<div class="chat-compare-list" id="new-conv-models"></div>
|
||||||
|
<p class="chat-dialog-hint" id="new-conv-models-empty" hidden>
|
||||||
|
没有带 Key 的 API 配置。请先到设置里添加。
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
<div class="chat-dialog-actions">
|
||||||
|
<button type="submit" class="st-btn st-btn-primary" value="ok">创建</button>
|
||||||
|
<button type="submit" class="st-btn" value="cancel" formnovalidate>取消</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog class="chat-dialog" id="dialog-add-window">
|
<dialog class="chat-dialog" id="dialog-add-window">
|
||||||
<form method="dialog" id="form-add-window">
|
<form method="dialog" id="form-add-window">
|
||||||
<h3>新增窗口</h3>
|
<h3>新增窗口</h3>
|
||||||
<p class="chat-dialog-hint">选一个 API 配置,新增对照模型;发送时各模型同时回复。</p>
|
<p class="chat-dialog-hint">勾选要加入的模型,可以多选。已经在会话里的不能再选。</p>
|
||||||
<label>
|
<fieldset class="chat-model-pick">
|
||||||
模型配置
|
<legend>模型</legend>
|
||||||
<select id="add-window-profile"></select>
|
<div class="chat-compare-list" id="add-window-models"></div>
|
||||||
</label>
|
<p class="chat-dialog-hint" id="add-window-models-empty" hidden>
|
||||||
|
请至少再勾选一个模型。
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
<div class="chat-dialog-actions">
|
<div class="chat-dialog-actions">
|
||||||
<button type="submit" class="st-btn st-btn-primary" value="ok">添加</button>
|
<button type="submit" class="st-btn st-btn-primary" value="ok">添加</button>
|
||||||
<button type="submit" class="st-btn" value="cancel">取消</button>
|
<button type="submit" class="st-btn" value="cancel" formnovalidate>取消</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
+1611
-77
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,649 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" data-color-theme="green">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>预览 · 气泡下的模型卡片</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--st-bg: #dce5d9;
|
||||||
|
--st-surface: #f4f9f2;
|
||||||
|
--st-border: #c0cfbb;
|
||||||
|
--st-border-strong: #a8bca2;
|
||||||
|
--st-text: #2b3330;
|
||||||
|
--st-muted: #65716c;
|
||||||
|
--st-faint: #7a8580;
|
||||||
|
--st-accent: #6f8e6c;
|
||||||
|
--st-accent-soft: rgba(125, 155, 122, 0.15);
|
||||||
|
--st-danger: #9a4038;
|
||||||
|
--st-font-ui: "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--st-bg);
|
||||||
|
color: var(--st-text);
|
||||||
|
font-family: var(--st-font-ui);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room {
|
||||||
|
height: 100%;
|
||||||
|
max-width: 920px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
background: color-mix(in srgb, var(--st-bg) 72%, var(--st-surface));
|
||||||
|
border-left: 1px solid var(--st-border);
|
||||||
|
border-right: 1px solid var(--st-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-bar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--st-border);
|
||||||
|
background: var(--st-surface);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--st-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-bar strong {
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--st-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 16px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--st-accent) 28%, var(--st-border));
|
||||||
|
background: color-mix(in srgb, var(--st-accent-soft) 70%, var(--st-surface));
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-time {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--st-faint);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--st-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idx {
|
||||||
|
min-width: 3.2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--st-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--st-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover,
|
||||||
|
.nav-btn:focus-visible {
|
||||||
|
color: var(--st-accent);
|
||||||
|
background: var(--st-accent-soft);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--st-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
--card-gap: 10px;
|
||||||
|
--card-peek: 36px;
|
||||||
|
--card-min-h: 120px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--card-gap);
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scroll-padding-inline: 0;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
cursor: grab;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track.is-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
scroll-snap-type: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
flex: 0 0 calc((100% - var(--card-gap) - var(--card-peek)) / 2);
|
||||||
|
width: calc((100% - var(--card-gap) - var(--card-peek)) / 2);
|
||||||
|
scroll-snap-align: start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: var(--card-min-h);
|
||||||
|
border: 1px solid var(--st-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--st-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track[data-count="1"] .card {
|
||||||
|
flex-basis: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.is-collapsed .card-text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.is-selected {
|
||||||
|
border-color: var(--st-accent);
|
||||||
|
background: color-mix(in srgb, var(--st-accent-soft) 55%, var(--st-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.is-error {
|
||||||
|
border-color: color-mix(in srgb, var(--st-danger) 45%, var(--st-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--st-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-titles { min-width: 0; }
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-model {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--st-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body.is-error { color: var(--st-danger); }
|
||||||
|
.card-body.is-pending { color: var(--st-faint); }
|
||||||
|
|
||||||
|
.card-foot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 10px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-foot:not(:has(button:not([hidden]))) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn {
|
||||||
|
margin-right: auto;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--st-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn:hover,
|
||||||
|
.expand-btn:focus-visible {
|
||||||
|
color: var(--st-accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn,
|
||||||
|
.remove-btn {
|
||||||
|
border: 1px solid var(--st-border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--st-surface);
|
||||||
|
color: var(--st-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn:hover,
|
||||||
|
.remove-btn:hover,
|
||||||
|
.select-btn:focus-visible,
|
||||||
|
.remove-btn:focus-visible {
|
||||||
|
border-color: var(--st-accent);
|
||||||
|
background: var(--st-accent-soft);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn.is-on {
|
||||||
|
border-color: var(--st-accent);
|
||||||
|
background: var(--st-accent-soft);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px 14px;
|
||||||
|
border-top: 1px solid var(--st-border);
|
||||||
|
background: var(--st-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer textarea {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 38px;
|
||||||
|
max-height: 120px;
|
||||||
|
resize: none;
|
||||||
|
border: 1px solid var(--st-border-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--st-bg);
|
||||||
|
color: var(--st-faint);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--st-accent);
|
||||||
|
color: #f4f9f2;
|
||||||
|
font: inherit;
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="room">
|
||||||
|
<div class="preview-bar">
|
||||||
|
<span><strong>临时会话</strong> · 预览,回复都是字符串</span>
|
||||||
|
<span>同一条消息只出现一次,卡片可左右拖</span>
|
||||||
|
</div>
|
||||||
|
<div class="transcript" id="transcript"></div>
|
||||||
|
<footer class="composer">
|
||||||
|
<textarea rows="1" readonly placeholder="预览不发送。正式页仍是底部这一条输入。"></textarea>
|
||||||
|
<button type="button" class="send" disabled aria-label="发送">↑</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const windows = [
|
||||||
|
{ id: "w1", name: "claude-haiku-5.5", model: "anthropic/claude-haiku-5.5" },
|
||||||
|
{ id: "w2", name: "gemini-3.8-flash", model: "google/gemini-3.8-flash" },
|
||||||
|
{ id: "w3", name: "gpt-5.4-mini", model: "openai/gpt-5.4-mini" },
|
||||||
|
{ id: "w4", name: "deepseek-v4", model: "deepseek/deepseek-v4" },
|
||||||
|
{ id: "w5", name: "claude-sonnet-5", model: "anthropic/claude-sonnet-5" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const turns = [
|
||||||
|
{
|
||||||
|
id: "t1",
|
||||||
|
time: "21:06",
|
||||||
|
user: "开篇别写天气。林晚推门进来,屋里已经有人。收短,留下一句她没问出口的话。",
|
||||||
|
selected: "w2",
|
||||||
|
replies: {
|
||||||
|
w1: "门轴响了一下。林晚进门,外套还挂在臂上,肩上的水珠顺着袖口往下掉。桌边坐着的人抬了抬眼,又低下去,手指停在杯沿上。她把钥匙放进碟子,金属碰了一声。那句「你怎么在这」到了舌尖,她用舌头顶回去,只伸手去摘围巾。围巾结打得紧,她解了两次才松开,穗子扫过桌面,拂开一张折过的纸。纸角朝上,她看见自己的名字,没有去拿。",
|
||||||
|
w2: "林晚推门时灯已经亮着。那人坐在桌边,面前一只空杯子,杯底还沾着一圈茶渍。她把门带上,声音比想的轻,锁舌却还是响了。钥匙落进碟子里。她站在门边,鞋跟没有再往前。话到了嘴边,她侧过身去烧水,壶盖磕在灶沿上,那句话就停在水壶的响声里。水开之前谁都没说话。她背对着那人,手指扣着灶台的边,扣得指节发白。",
|
||||||
|
w3: "林晚推开门。屋里的灯亮着,桌边已经坐了一个人,面前是一只空杯子。她把钥匙放进门口的碟子,金属碰了一声,手指在碟沿上停了一停。那句问话到了嘴边,没有出口。她解下围巾,搭到椅背上,手还按着木茬,人站在椅子侧面,没有往桌边走。那人把杯子转了半圈。林晚看着杯底画出的水印,终于把另一只手也放上椅背,围巾的穗子从指缝里滑下去。",
|
||||||
|
w4: "门开着一条缝,光从里面漏出来。林晚侧身进去,看见那人的背影对着窗。她张了张嘴,喉咙里那句话转了一圈,最后只变成解围巾的动作。围巾搭上椅背,她的手还按着,没有拿开。椅背是凉的。那人没有回头,只把空杯子转了半圈。林晚看着那只杯子,又看着自己按在椅背上的手,终于把另一只手也放上去,像是要说什么,到底只把围巾的穗子理齐。",
|
||||||
|
w5: { error: "上游超时,这条没有回复。" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "t2",
|
||||||
|
time: "21:11",
|
||||||
|
user: "她没问出口的那句,换成一个动作。别解释她在想什么。",
|
||||||
|
selected: null,
|
||||||
|
replies: {
|
||||||
|
w1: { pending: true },
|
||||||
|
w2: "她把那只空杯子转了半圈,又转回去,杯底在桌面上刮出一声很轻的响。林晚的手还按在椅背上,围巾的穗子从指缝里漏下去。她没有坐。那人这才抬眼,目光停在她的手上,又移开。林晚抽回手,把围巾从椅背上拿起来,重新搭好,动作慢得像在把一句已经到了嘴边的话按回去。",
|
||||||
|
w3: "林晚拉开椅子,椅腿在地板上拖出短短一声。她没有坐下去。手离开椅背,转到水槽边,拧开水龙头。水声起来以后,那人把空杯子往她这边推了推,杯底刮过桌面。她接过杯子,冲了一下,倒扣在沥水架上。水还在流。她伸手去关,指节在龙头上停住,又拧紧,全程没有回头看他。围巾还搭在椅背上,穗子垂到座位上。",
|
||||||
|
w4: "她把围巾搭上椅背,手还按着,没有拿开。指腹压住一块被磨毛的木茬。那人的杯子停在手边,她用空着的那只手把杯子转了半圈,又转回去,水渍在桌上画出一个不完整的圆。圆没有合上,她的手也没有离开椅背。",
|
||||||
|
w5: { error: "上游超时,这条没有回复。" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const alive = new Set(windows.map((w) => w.id));
|
||||||
|
const expanded = new Set();
|
||||||
|
const transcript = document.getElementById("transcript");
|
||||||
|
const MIN_CARD_H = 120;
|
||||||
|
|
||||||
|
/** 至少两张都能撑到的最大高度:两张即最短,更多即次短。 */
|
||||||
|
function sharedReachHeight(heights) {
|
||||||
|
if (!heights.length) return MIN_CARD_H;
|
||||||
|
const sorted = [...heights].sort((a, b) => a - b);
|
||||||
|
const index = Math.min(1, Math.max(0, sorted.length - 2));
|
||||||
|
return Math.max(MIN_CARD_H, sorted[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyOf(reply) {
|
||||||
|
if (!reply) return { cls: "is-pending", text: "本轮无回复" };
|
||||||
|
if (reply.error) return { cls: "is-error", text: reply.error };
|
||||||
|
if (reply.pending) return { cls: "is-pending", text: "正在回复…" };
|
||||||
|
return { cls: "", text: reply };
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const list = windows.filter((w) => alive.has(w.id));
|
||||||
|
transcript.innerHTML = turns
|
||||||
|
.map((turn) => {
|
||||||
|
const kept = turn.selected && list.some((w) => w.id === turn.selected && !bodyOf(turn.replies[w.id]).cls) ? turn.selected : null;
|
||||||
|
const shown = kept ? list.filter((w) => w.id === kept) : list;
|
||||||
|
const cards = shown
|
||||||
|
.map((w) => {
|
||||||
|
const body = bodyOf(turn.replies[w.id]);
|
||||||
|
const selected = turn.selected === w.id && !body.cls;
|
||||||
|
const keep =
|
||||||
|
list.length > 1 && body.cls !== "is-error" && body.cls !== "is-pending"
|
||||||
|
? `<button type="button" class="select-btn ${selected ? "is-on" : ""}" data-action="keep" data-turn="${turn.id}" data-window="${w.id}">${selected ? "已保留 · 取消" : "保留此回复"}</button>`
|
||||||
|
: "";
|
||||||
|
const remove =
|
||||||
|
list.length > 1
|
||||||
|
? `<button type="button" class="remove-btn" data-action="remove" data-window="${w.id}">移除</button>`
|
||||||
|
: "";
|
||||||
|
return `<article class="card ${selected ? "is-selected" : ""} ${body.cls === "is-error" ? "is-error" : ""}" data-window="${w.id}">
|
||||||
|
<header class="card-head">
|
||||||
|
<div class="card-titles">
|
||||||
|
<span class="card-name">${esc(w.name)}</span>
|
||||||
|
<span class="card-model">${esc(w.model)}</span>
|
||||||
|
</div>
|
||||||
|
${remove}
|
||||||
|
</header>
|
||||||
|
<div class="card-body ${body.cls}" data-empty="${body.cls ? "1" : "0"}"><div class="card-text">${esc(body.text)}</div></div>
|
||||||
|
<div class="card-foot">
|
||||||
|
<button type="button" class="expand-btn" data-action="expand" data-turn="${turn.id}" data-window="${w.id}" hidden>展开</button>
|
||||||
|
${keep}
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const stripBar = kept
|
||||||
|
? ""
|
||||||
|
: `<div class="strip-bar">
|
||||||
|
<span class="strip-hint">左右拖动</span>
|
||||||
|
<div class="strip-nav">
|
||||||
|
<button type="button" class="nav-btn" data-action="prev" data-turn="${turn.id}" aria-label="上一张">‹</button>
|
||||||
|
<span class="idx" data-idx="${turn.id}">1 / ${list.length}</span>
|
||||||
|
<button type="button" class="nav-btn" data-action="next" data-turn="${turn.id}" aria-label="下一张">›</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
return `<section class="turn" data-turn="${turn.id}">
|
||||||
|
<div class="user-row">
|
||||||
|
<article class="bubble">
|
||||||
|
<div>${esc(turn.user)}</div>
|
||||||
|
<div class="bubble-time">${esc(turn.time)}</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
${stripBar}
|
||||||
|
<div class="track" data-turn="${turn.id}" data-count="${shown.length}" tabindex="0">${cards}</div>
|
||||||
|
</section>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
for (const track of transcript.querySelectorAll(".track")) bindTrack(track);
|
||||||
|
collapseTracks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseTracks() {
|
||||||
|
for (const track of transcript.querySelectorAll(".track")) {
|
||||||
|
const cards = [...track.querySelectorAll(".card")];
|
||||||
|
if (cards.length < 2) {
|
||||||
|
for (const card of cards) card.style.height = "auto";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const measured = cards.map((card) => {
|
||||||
|
card.style.height = "auto";
|
||||||
|
const empty = card.querySelector(".card-body")?.dataset.empty === "1";
|
||||||
|
return { card, h: card.offsetHeight, empty };
|
||||||
|
});
|
||||||
|
const substantive = measured.filter((item) => !item.empty).map((item) => item.h);
|
||||||
|
const target = sharedReachHeight(substantive);
|
||||||
|
for (const { card, h } of measured) {
|
||||||
|
const key = `${track.dataset.turn}:${card.dataset.window}`;
|
||||||
|
const btn = card.querySelector("[data-action='expand']");
|
||||||
|
const open = expanded.has(key);
|
||||||
|
if (open) {
|
||||||
|
card.style.height = "auto";
|
||||||
|
card.classList.remove("is-collapsed");
|
||||||
|
if (btn) {
|
||||||
|
btn.hidden = false;
|
||||||
|
btn.textContent = "收起";
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
card.style.height = `${target}px`;
|
||||||
|
card.classList.add("is-collapsed");
|
||||||
|
if (btn) {
|
||||||
|
const overflow = h > target + 1;
|
||||||
|
btn.hidden = !overflow;
|
||||||
|
btn.textContent = "展开";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardStep(track) {
|
||||||
|
const card = track.querySelector(".card");
|
||||||
|
if (!card) return track.clientWidth;
|
||||||
|
const gap = 10;
|
||||||
|
return card.getBoundingClientRect().width + gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIdx(track) {
|
||||||
|
const cards = [...track.querySelectorAll(".card")];
|
||||||
|
const turnId = track.dataset.turn;
|
||||||
|
const idx = transcript.querySelector(`[data-idx="${turnId}"]`);
|
||||||
|
if (!idx || !cards.length) return;
|
||||||
|
const step = cardStep(track);
|
||||||
|
const start = Math.min(cards.length - 1, Math.max(0, Math.round(track.scrollLeft / step)));
|
||||||
|
const end = Math.min(cards.length, start + 2);
|
||||||
|
idx.textContent = end > start + 1 ? `${start + 1}–${end} / ${cards.length}` : `${start + 1} / ${cards.length}`;
|
||||||
|
const prev = transcript.querySelector(`[data-action="prev"][data-turn="${turnId}"]`);
|
||||||
|
const next = transcript.querySelector(`[data-action="next"][data-turn="${turnId}"]`);
|
||||||
|
const lastPage = Math.max(0, cards.length - 2);
|
||||||
|
if (prev) prev.disabled = start <= 0;
|
||||||
|
if (next) next.disabled = start >= lastPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToIndex(track, index) {
|
||||||
|
const cards = [...track.querySelectorAll(".card")];
|
||||||
|
const card = cards[index];
|
||||||
|
if (!card) return;
|
||||||
|
track.scrollTo({ left: card.offsetLeft - track.offsetLeft, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindTrack(track) {
|
||||||
|
let dragging = false;
|
||||||
|
let axis = null;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let startScroll = 0;
|
||||||
|
|
||||||
|
track.addEventListener("pointerdown", (e) => {
|
||||||
|
if (e.button !== 0 || e.target.closest("button")) return;
|
||||||
|
dragging = true;
|
||||||
|
axis = null;
|
||||||
|
startX = e.clientX;
|
||||||
|
startY = e.clientY;
|
||||||
|
startScroll = track.scrollLeft;
|
||||||
|
});
|
||||||
|
|
||||||
|
track.addEventListener("pointermove", (e) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const dx = e.clientX - startX;
|
||||||
|
const dy = e.clientY - startY;
|
||||||
|
if (!axis) {
|
||||||
|
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
|
||||||
|
axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
|
||||||
|
if (axis === "x") {
|
||||||
|
track.classList.add("is-dragging");
|
||||||
|
track.setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (axis === "x") {
|
||||||
|
track.scrollLeft = startScroll - dx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const end = () => {
|
||||||
|
dragging = false;
|
||||||
|
axis = null;
|
||||||
|
track.classList.remove("is-dragging");
|
||||||
|
};
|
||||||
|
track.addEventListener("pointerup", end);
|
||||||
|
track.addEventListener("pointercancel", end);
|
||||||
|
track.addEventListener("scroll", () => updateIdx(track), { passive: true });
|
||||||
|
updateIdx(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
transcript.addEventListener("click", (e) => {
|
||||||
|
const btn = e.target.closest("button");
|
||||||
|
if (!btn) return;
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
if (action === "expand") {
|
||||||
|
const key = `${btn.dataset.turn}:${btn.dataset.window}`;
|
||||||
|
if (expanded.has(key)) expanded.delete(key);
|
||||||
|
else expanded.add(key);
|
||||||
|
collapseTracks();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "remove") {
|
||||||
|
alive.delete(btn.dataset.window);
|
||||||
|
for (const turn of turns) {
|
||||||
|
if (turn.selected === btn.dataset.window) turn.selected = null;
|
||||||
|
}
|
||||||
|
for (const key of [...expanded]) {
|
||||||
|
if (key.endsWith(`:${btn.dataset.window}`)) expanded.delete(key);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "keep") {
|
||||||
|
const turn = turns.find((t) => t.id === btn.dataset.turn);
|
||||||
|
if (!turn) return;
|
||||||
|
turn.selected = turn.selected === btn.dataset.window ? null : btn.dataset.window;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "prev" || action === "next") {
|
||||||
|
const track = transcript.querySelector(`.track[data-turn="${btn.dataset.turn}"]`);
|
||||||
|
if (!track) return;
|
||||||
|
const step = cardStep(track);
|
||||||
|
const i = Math.round(track.scrollLeft / step);
|
||||||
|
const lastPage = Math.max(0, track.querySelectorAll(".card").length - 2);
|
||||||
|
const nextIndex = action === "next" ? Math.min(lastPage, i + 1) : Math.max(0, i - 1);
|
||||||
|
scrollToIndex(track, nextIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
render();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -503,6 +503,20 @@ code {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 宽屏下不要把表单和卡片拉到整屏宽 */
|
||||||
|
.st-main-head {
|
||||||
|
max-width: calc(1080px + 28px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-body > .settings-section {
|
||||||
|
max-width: 1080px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-settings-bar,
|
||||||
|
.st-status {
|
||||||
|
max-width: 1080px;
|
||||||
|
}
|
||||||
|
|
||||||
/* —— Buttons —— */
|
/* —— Buttons —— */
|
||||||
.btn-primary,
|
.btn-primary,
|
||||||
.btn-secondary,
|
.btn-secondary,
|
||||||
@@ -1073,6 +1087,22 @@ code {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-report-warnings {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-report-warnings li {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-report-warnings li + li {
|
||||||
|
border-top: 1px solid var(--st-border);
|
||||||
|
padding-top: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.preset-entries-panel {
|
.preset-entries-panel {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
padding-left: 48px;
|
padding-left: 48px;
|
||||||
@@ -1427,6 +1457,101 @@ code {
|
|||||||
color: var(--st-faint);
|
color: var(--st-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 左标签、右控件的表单行:比竖排 label 更短、也不拉满整卡 */
|
||||||
|
.settings-form .form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 104px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 4px 16px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid var(--st-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form .form-row:first-child {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form .form-label {
|
||||||
|
padding-top: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--st-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form .form-label code {
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f4f4f5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form .form-control {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--st-border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--st-surface);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
box-shadow: 0 0 0 3px var(--st-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form .form-hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
max-width: 560px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--st-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form .form-static {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 560px;
|
||||||
|
padding-top: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--st-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form .form-static strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--st-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form .no-break {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.settings-form .form-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form .form-label,
|
||||||
|
.settings-form .form-static {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form select {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.storage-card .field {
|
.storage-card .field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
<button type="button" class="st-rail-item" data-section="preference">用户偏好</button>
|
<button type="button" class="st-rail-item" data-section="preference">用户偏好</button>
|
||||||
<button type="button" class="st-rail-item" data-section="preset">预设</button>
|
<button type="button" class="st-rail-item" data-section="preset">预设</button>
|
||||||
<button type="button" class="st-rail-item" data-section="storage">上下文</button>
|
<button type="button" class="st-rail-item" data-section="storage">上下文</button>
|
||||||
|
<button type="button" class="st-rail-item" data-section="chat">直聊</button>
|
||||||
<p class="st-rail-note">保存在本机 <code>~/.writing-agent/</code>,明文、不同步。</p>
|
<p class="st-rail-note">保存在本机 <code>~/.writing-agent/</code>,明文、不同步。</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -106,6 +107,53 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-section hidden" id="section-preset">
|
<section class="settings-section hidden" id="section-preset">
|
||||||
|
<div class="settings-card storage-card">
|
||||||
|
<h3>发出前的消息整形</h3>
|
||||||
|
<p class="settings-help">
|
||||||
|
预设(如酒馆的 <code>squash_system_messages</code>)会要求把相邻同角色的消息合并。
|
||||||
|
这里默认「跟随预设」,也可以强制覆盖。
|
||||||
|
</p>
|
||||||
|
<div class="settings-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label" id="preset-squash-label">合并模式</span>
|
||||||
|
<div class="form-control">
|
||||||
|
<select id="message-squash" aria-labelledby="preset-squash-label">
|
||||||
|
<option value="follow-preset">跟随预设</option>
|
||||||
|
<option value="none">不合并</option>
|
||||||
|
<option value="merge">合并相同角色连续的发言</option>
|
||||||
|
<option value="semi">半严格(强制对话角色交替)</option>
|
||||||
|
<option value="strict">严格(强制对话角色交替、用户最先)</option>
|
||||||
|
<option value="single-user-turn">单一用户消息(无工具)</option>
|
||||||
|
</select>
|
||||||
|
<p class="form-hint">
|
||||||
|
请求里带工具调用时,后三档会退化为「只合并相邻同角色」,避免拆散工具调用。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label" id="preset-tool-label">预设工具协议</span>
|
||||||
|
<div class="form-control">
|
||||||
|
<select id="preset-tool-policy" aria-labelledby="preset-tool-label">
|
||||||
|
<option value="adapt">适配(把预设要求的工具折算到已有工具上)</option>
|
||||||
|
<option value="app">只用本应用工具(排除预设的伪工具条目)</option>
|
||||||
|
<option value="preset">只发预设要求的工具</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label"><code>{{char}}</code> 取值</span>
|
||||||
|
<div class="form-control">
|
||||||
|
<p class="form-static">
|
||||||
|
优先用直聊上下文壳里的<strong>角色名</strong>;创作期取<strong>当前作品名</strong>
|
||||||
|
(对话落盘时给快照起的名字)。都没填时用「作品」。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="storage-actions">
|
||||||
|
<button type="button" class="btn-primary" id="btn-save-preset-runtime">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="presets-list" class="card-list"></div>
|
<div id="presets-list" class="card-list"></div>
|
||||||
<pre id="import-report" class="import-report" hidden></pre>
|
<pre id="import-report" class="import-report" hidden></pre>
|
||||||
</section>
|
</section>
|
||||||
@@ -156,6 +204,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section hidden" id="section-chat">
|
||||||
|
<div class="settings-card storage-card">
|
||||||
|
<h3>直聊卡片拖动</h3>
|
||||||
|
<p class="settings-help">
|
||||||
|
在直聊里按住回复卡片拖动(左右切卡片、上下滚正文)时,指针移动 1px 实际滚动多少 px。
|
||||||
|
</p>
|
||||||
|
<label class="field">
|
||||||
|
拖动倍率
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="chat-drag-gain"
|
||||||
|
min="0.5"
|
||||||
|
max="3"
|
||||||
|
step="0.1"
|
||||||
|
value="1.5"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p class="settings-help muted">
|
||||||
|
默认 1.5(拖 100px 走 150px);1 = 跟手;范围 0.5–3。保存后回到直聊刷新即生效。
|
||||||
|
</p>
|
||||||
|
<div class="storage-actions">
|
||||||
|
<button type="button" class="btn-primary" id="btn-save-drag-gain">保存倍率</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+71
-7
@@ -58,11 +58,15 @@ const sectionPersonaEl = document.getElementById("section-persona");
|
|||||||
const sectionPreferenceEl = document.getElementById("section-preference");
|
const sectionPreferenceEl = document.getElementById("section-preference");
|
||||||
const sectionPresetEl = document.getElementById("section-preset");
|
const sectionPresetEl = document.getElementById("section-preset");
|
||||||
const sectionStorageEl = document.getElementById("section-storage");
|
const sectionStorageEl = document.getElementById("section-storage");
|
||||||
|
const sectionChatEl = document.getElementById("section-chat");
|
||||||
const preferenceCollectEveryEl = document.getElementById("preference-collect-every");
|
const preferenceCollectEveryEl = document.getElementById("preference-collect-every");
|
||||||
const activeSettingsBarEl = document.getElementById("active-settings-bar");
|
const activeSettingsBarEl = document.getElementById("active-settings-bar");
|
||||||
const settingsToastEl = document.getElementById("settings-toast");
|
const settingsToastEl = document.getElementById("settings-toast");
|
||||||
const contextTraceKeepEl = document.getElementById("context-trace-keep");
|
const contextTraceKeepEl = document.getElementById("context-trace-keep");
|
||||||
const dictateFillRetriesEl = document.getElementById("dictate-fill-retries");
|
const dictateFillRetriesEl = document.getElementById("dictate-fill-retries");
|
||||||
|
const chatDragGainEl = document.getElementById("chat-drag-gain");
|
||||||
|
const messageSquashEl = document.getElementById("message-squash");
|
||||||
|
const presetToolPolicyEl = document.getElementById("preset-tool-policy");
|
||||||
const probeDialog = document.getElementById("preset-probe-dialog");
|
const probeDialog = document.getElementById("preset-probe-dialog");
|
||||||
const probePresetNameEl = document.getElementById("probe-preset-name");
|
const probePresetNameEl = document.getElementById("probe-preset-name");
|
||||||
const probeMessageEl = document.getElementById("probe-message");
|
const probeMessageEl = document.getElementById("probe-message");
|
||||||
@@ -154,6 +158,7 @@ function switchSection(section) {
|
|||||||
sectionPreferenceEl?.classList.toggle("hidden", section !== "preference");
|
sectionPreferenceEl?.classList.toggle("hidden", section !== "preference");
|
||||||
sectionPresetEl.classList.toggle("hidden", section !== "preset");
|
sectionPresetEl.classList.toggle("hidden", section !== "preset");
|
||||||
sectionStorageEl?.classList.toggle("hidden", section !== "storage");
|
sectionStorageEl?.classList.toggle("hidden", section !== "storage");
|
||||||
|
sectionChatEl?.classList.toggle("hidden", section !== "chat");
|
||||||
renderPanelHeader();
|
renderPanelHeader();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +197,9 @@ function renderPanelHeader() {
|
|||||||
btn.textContent = "导入 JSON";
|
btn.textContent = "导入 JSON";
|
||||||
btn.addEventListener("click", () => presetFileEl.click());
|
btn.addEventListener("click", () => presetFileEl.click());
|
||||||
panelActionsEl.appendChild(btn);
|
panelActionsEl.appendChild(btn);
|
||||||
|
} else if (activeSection === "chat") {
|
||||||
|
panelTitleEl.textContent = "直聊";
|
||||||
|
panelSubtitleEl.textContent = "卡片拖动的跟手倍率";
|
||||||
} else {
|
} else {
|
||||||
panelTitleEl.textContent = "上下文";
|
panelTitleEl.textContent = "上下文";
|
||||||
panelSubtitleEl.textContent = "上下文痕迹,以及对话落盘同轮补写次数";
|
panelSubtitleEl.textContent = "上下文痕迹,以及对话落盘同轮补写次数";
|
||||||
@@ -780,6 +788,15 @@ async function loadAll() {
|
|||||||
if (dictateFillRetriesEl) {
|
if (dictateFillRetriesEl) {
|
||||||
dictateFillRetriesEl.value = String(state.settings.dictateFillRetries ?? 0);
|
dictateFillRetriesEl.value = String(state.settings.dictateFillRetries ?? 0);
|
||||||
}
|
}
|
||||||
|
if (chatDragGainEl) {
|
||||||
|
chatDragGainEl.value = String(state.settings.chatDragGain ?? 1.5);
|
||||||
|
}
|
||||||
|
if (messageSquashEl) {
|
||||||
|
messageSquashEl.value = state.settings.messageSquash ?? "follow-preset";
|
||||||
|
}
|
||||||
|
if (presetToolPolicyEl) {
|
||||||
|
presetToolPolicyEl.value = state.settings.presetToolPolicy ?? "adapt";
|
||||||
|
}
|
||||||
renderActiveBar();
|
renderActiveBar();
|
||||||
renderProfiles();
|
renderProfiles();
|
||||||
renderPersonas();
|
renderPersonas();
|
||||||
@@ -1659,14 +1676,23 @@ presetFileEl.addEventListener("change", async () => {
|
|||||||
});
|
});
|
||||||
lastImportedPresetId = report.preset.id;
|
lastImportedPresetId = report.preset.id;
|
||||||
importReportEl.hidden = false;
|
importReportEl.hidden = false;
|
||||||
|
const escapeHtml = (text) =>
|
||||||
|
String(text).replace(/[&<>"']/g, (c) =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
||||||
|
);
|
||||||
|
const core = report.missingCoreIdentifiers?.length
|
||||||
|
? `缺少 ${escapeHtml(report.missingCoreIdentifiers.join(" / "))}`
|
||||||
|
: "框架完整";
|
||||||
importReportEl.innerHTML = `
|
importReportEl.innerHTML = `
|
||||||
<div>${[
|
<div>已导入: ${escapeHtml(report.preset.name)}</div>
|
||||||
`已导入: ${report.preset.name}`,
|
<div>条目 ${report.promptCount},启用 ${report.enabledCount};顺序块 character_id=${escapeHtml(report.orderCharacterId ?? "?")}(${core})</div>
|
||||||
`条目: ${report.promptCount},启用: ${report.enabledCount}`,
|
${
|
||||||
report.warnings.length ? `警告: ${report.warnings.join("; ")}` : "",
|
report.warnings?.length
|
||||||
]
|
? `<ul class="import-report-warnings">${report.warnings
|
||||||
.filter(Boolean)
|
.map((w) => `<li>${escapeHtml(w)}</li>`)
|
||||||
.join("<br>")}</div>
|
.join("")}</ul>`
|
||||||
|
: `<div>没有需要注意的项目</div>`
|
||||||
|
}
|
||||||
<div class="import-report-actions">
|
<div class="import-report-actions">
|
||||||
<button type="button" class="btn-activate" id="activate-imported-preset">选用此预设并生效</button>
|
<button type="button" class="btn-activate" id="activate-imported-preset">选用此预设并生效</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -1820,6 +1846,44 @@ document.getElementById("btn-save-fill-retries")?.addEventListener("click", asyn
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-save-preset-runtime")?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
messageSquash: messageSquashEl?.value ?? "follow-preset",
|
||||||
|
presetToolPolicy: presetToolPolicyEl?.value ?? "adapt",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
state.settings = data.settings;
|
||||||
|
if (messageSquashEl) {
|
||||||
|
messageSquashEl.value = data.settings.messageSquash ?? "follow-preset";
|
||||||
|
}
|
||||||
|
if (presetToolPolicyEl) {
|
||||||
|
presetToolPolicyEl.value = data.settings.presetToolPolicy ?? "adapt";
|
||||||
|
}
|
||||||
|
showToast("已保存预设运行设置");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-save-drag-gain")?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const n = Number(chatDragGainEl?.value ?? 1.5);
|
||||||
|
const data = await api("/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ chatDragGain: n }),
|
||||||
|
});
|
||||||
|
state.settings = data.settings;
|
||||||
|
const saved = data.settings.chatDragGain ?? 1.5;
|
||||||
|
if (chatDragGainEl) chatDragGainEl.value = String(saved);
|
||||||
|
showToast(`已保存:拖 100px 走 ${Math.round(saved * 100)}px`);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("btn-save-context-keep")?.addEventListener("click", async () => {
|
document.getElementById("btn-save-context-keep")?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const n = Number(contextTraceKeepEl?.value ?? 5);
|
const n = Number(contextTraceKeepEl?.value ?? 5);
|
||||||
|
|||||||
Reference in New Issue
Block a user