diff --git a/.env.example b/.env.example index 2308146..2569731 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ OPENAI_API_KEY= OPENAI_BASE_URL=https://api.deepseek.com OPENAI_MODEL=deepseek-v4-pro + +# 出网代理(可选)。不填则自动探测 127.0.0.1:7890,探不到就直连。 +# 注意:本文件仅在 Web / 桌面壳启动时加载;CLI(npm run dev)请直接在 shell 里设变量。 +# WRITING_AGENT_PROXY=http://127.0.0.1:7890 +# WRITING_AGENT_PROXY=off + diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..d8671c0 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,95 @@ +# 直聊(chat.html)思维链默认折叠,点击展开 + +## Context + +直聊页面的多模型回复卡(`.chat-reply-card`)把模型的思维链整段铺在正文上方,一张卡的可视高度被思维链吃掉,正文(真正要看的内容)被挤到下面。截图现场:盲测模式下的「模型 1」卡,头部下面整块浅色区域就是思维链,之后才是终端流水线正文。 + +目标:思维链收成一行折叠条,默认收起,点一下展开;流式期间模型只吐思考时自动展开,正文一开始到达就自动收起。 + +## 已定决策(用户) + +1. 流式阶段:**自动展开,正文开始后自动收起**(不是"始终保持折叠")。 +2. 折叠条文案:**带字数**,形如 `思考过程 · 1234 字`。 +3. 右上角「复制」:**仍然包含思维链**(`replyPlainText` 不动)。 + +## 现状(已确认) + +- `web/chat.js` + - `replyBodyHtml(body)` §902-908:思维链 `
` + 正文,永远展开。 + - `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()` 是主对话的同类实现(`
`,默认折叠),样式在 `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 +
+ 思考过程 · N 字 +
…renderMarkdownToHtml(thinking)…
+
+ ``` + + 字数取 `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)` 改成 `
` 结构,字数进 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:/chat.html`(端口见 server 启动日志): + - 普通对话,模型有思维链 → 卡片顶部只有一行 `思考过程 · N 字`,正文直接从顶部开始;点开后思维链以 markdown 展开、再点收起。 + - 打开浏览器 DevTools Network 慢速/用长思考模型,观察流式:正文未到时折叠条自动展开、文本在增长;第一段正文到达后自动收起;此时再手动点开应一直保持展开直到本轮结束、且不被后续流式刷新冲掉。 + - 双模型并排 + 盲测(「模型 1」标签):折叠/展开两张卡互不影响;展开后卡片内部可滚动,「展开/收起」按钮行为不变。 + - 右上角「复制」→ 粘贴检查内容仍含思维链 + 正文。 + - 「移除」某模型、「重试」失败卡片后,展开状态不串卡。 + - 关闭复制模式(copy-off)点折叠条仍能正常开合、不触发拖动。 +3. 刷新页面重新加载历史会话(`thinking` 由服务端持久化)→ 仍是默认折叠。 diff --git a/README.md b/README.md index 04bef73..8021742 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,31 @@ 用户数据目录可用环境变量 `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 会提示一行)。 + ## 维护者 开发与排障常用命令: diff --git a/docs/context-assembly.md b/docs/context-assembly.md index c0f44a4..0578694 100644 --- a/docs/context-assembly.md +++ b/docs/context-assembly.md @@ -46,6 +46,9 @@ Worker / skill 执行时,Runtime 将黑板 tag 与固定体裁说明拼成 LLM 「对话.历史」是排序表中的普通标签,按 projection 动态裁剪后插入 ``` +「对话.历史」不只是文本块:装配时它被切成**真多轮 message[]**(user / assistant 轮)进预设的 `chatHistory` 洞; +分袋时它之前的块进 `worldBookBefore`、之后的进 `worldBookAfter`。 + `order 0` 建议为槽位人设(`worker.persona`)。 `tier` 仅缓存提示;权威顺序是投影排序表。 @@ -183,19 +186,41 @@ contextIsolation: none | role_pov | blind_review --- -## 9. 拼接结果形态(目标) +## 9. 拼接结果形态 + +worker 先给出**分段结果**,再由预设决定最终 `messages[]` 的形状: ```text -system: - {shared-context} - {worker SKILL body} - {固定输出协议} +assembleWorkerContextSections() + ├─ chunks: block|history|turn (按投影排序表顺序) + └─ tail: 本步任务 / 定稿摘要 / 待改底稿 … -user: - {按 segment 顺序格式化的 Markdown 或结构化块} +workerContextToBags() + ├─ before → presetPack.worldBookBefore + ├─ history → presetPack.history【真多轮 message[]】 + ├─ after → presetPack.worldBookAfter + ├─ turn → presetPack.currentTurn + └─ postTurn → presetPack.postTurn + +PresetLlmProvider(options.presetPack)→ assemblePlayWorkerMessages() ``` -实现:`src/skills/context-segments.ts` → `assembleWorkerContext()`;由 `src/worker/executor.ts` 调用。 +发出去的形式(有历史时): + +```text +system 预设条目(main / 文风 / 破限) +system worldBookBefore:契约 + 史前资料 +system worldBookAfter:变量 / 真值 +assistant 历史轮… +user 历史轮… +user currentTurn:用户.最新输入 +system postTurn:本步任务 +``` + +`flat`(`assembleWorkerContext()` 的返回值)仍保留,作为无 historical 分段时的单包形式; +`contextSegments` 里没有 `对话.历史` 的 worker(创作期产出 JSON 那些)继续走单包。 + +实现:`src/skills/context-segments.ts` 与 `src/preset/play-frame.ts`;由 `src/worker/executor.ts` 调用。 --- diff --git a/docs/context-fragment-design.md b/docs/context-fragment-design.md index e9bc300..80bedbe 100644 --- a/docs/context-fragment-design.md +++ b/docs/context-fragment-design.md @@ -119,10 +119,14 @@ order 4: 用户.最新输入(full) - 排序表里出现该 ref 时,按 **projection** 动态裁剪后注入(不是整段永远塞满)。 - 若尚未写入历史,可用 `运行.事件流` + `用户.最新输入` 拼兜底。 - 投影建议:`summary`(默认)/ `fields`(更短)/ `full`(更长)/ `fixed`(几乎不注入)。 +- 裁剪是**块级 + 滞回**的(`projectDialogueHistory()`):段数在 n~2n 之间原样全给,超过 2n 才裁回 n。 + 这样历史 message 数组的首条不是每轮都变,前缀缓存每 n 轮才断一次。 + **不要**改回「每轮取最近 n 段」——那会让整段前缀每轮全价重发。 +- 投影后的正文会被 `parseHistoryMessages()` 切成真多轮 message,进预设的 `chatHistory` 洞。 ### 4.2 拼装 -Runtime 按 inserts 数组顺序拼进单条 user 上下文(`assembleWorkerContext` **不再**按 static/dynamic 重排)。 +Runtime 按 inserts 数组顺序拼块,再由 `workerContextToBags()` 分袋(before / history / after / turn / postTurn)。 `tier` 仅作缓存提示(历史标签之前偏 static,之后偏 dynamic)。 ### 4.3 可编排 diff --git a/docs/direct-chat.md b/docs/direct-chat.md index 47fcf8f..9b5f3f9 100644 --- a/docs/direct-chat.md +++ b/docs/direct-chat.md @@ -16,7 +16,7 @@ - 标题栏「新增窗口」:中间按模型分栏(LMSYS Arena 式 side-by-side),每列独立对话流。 - 同一用户句在各列各显一份;发送时所有列并行 completion。 -- 列内「保留此回复」:写入该轮 `selectedWindowId`;之后各列发下一轮时,本轮助手侧都以该回复进历史。 +- 列内「保留此回复」:写入该轮 `selectedWindowId`;之后各列发下一轮时,本轮助手侧都以该回复进历史。保留后该轮界面只显示被保留的那张卡(再点「已保留 · 取消」恢复全部),下一轮仍是每个模型分别请求。 - 未保留:各列继续用自己的回复拼 prior。 ## 请求 diff --git a/docs/preset-format.md b/docs/preset-format.md index 64d14e5..d699eee 100644 --- a/docs/preset-format.md +++ b/docs/preset-format.md @@ -4,7 +4,11 @@ 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 regex_scripts - 正则隐藏、正文提取、格式美化等后处理脚本。 + 正则隐藏、正文提取、格式美化等后处理脚本(导入时只做诊断,不执行)。 extension scripts - 预设内携带的前端脚本、按钮、插件配置。 + 预设内携带的前端脚本、按钮、插件配置(一律不执行,导入报告点名)。 UI-only fields 只影响 SillyTavern 界面展示、不影响 LLM 请求组装的字段。 @@ -105,12 +109,14 @@ type ImportedPromptOrder = Array<{ 第一版采用的规则: ```text -1. 以 prompt_order[0].order 作为主顺序。 +1. prompt_order 可能有多套(按 character_id 分)。优先 character_id=100001(酒馆里通用于所有角色的那套); + 它不存在时才按「启用内容最多」打分。 2. 按 order 数组顺序遍历 identifier。 3. 找到对应 prompts 条目。 4. 只有 order.enabled 和 prompt.enabled 都为 true 时,条目才参与本轮上下文装配。 5. 如果 prompt_order 引用了不存在的 identifier,导入时记录 warning,但不阻断导入。 6. 如果 prompts 中存在但 prompt_order 未引用,默认不参与请求,但保留在 preset 中。 +7. 选中的那一套若缺 main / chatHistory / jailbreak,导入时告警(框架可能不完整)。 ``` `prompt_order` 比 `prompts` 中的排列更重要。`prompts` 是条目仓库,`prompt_order` 才是实际启用的编排表。 @@ -231,7 +237,7 @@ scenario worldInfoAfter dialogueExamples chatHistory | identifier | 填什么 | |---|---| | `worldBookBefore` | `对话.历史` 之前的 inserts,每条为标题+正文 | -| `chatHistory` | 共用酒馆 id;填 `对话.历史` | +| `chatHistory` | 共用酒馆 id;填 `对话.历史`——**展开成真多轮 message[]**,不是一段文本 | | `worldBookAfter` | 历史之后、本轮之前(变量、工单等)。**不是** `worldInfoAfter` | | `currentTurn` | `用户.最新输入`,role=user | | `postTurn` | 本轮输入之后的 inserts(可空) | @@ -257,7 +263,6 @@ postTurn 社区预设里包着 WI 洞的装饰分隔会留在历史前段,中间可能是空的。第一版接受。 ### 4.3 六段夹心(扫 order 后的效果) - ```text 1. 酒馆内、历史前(ST 普通条目) 2. 我们写的、历史前(worldBookBefore) @@ -276,11 +281,122 @@ postTurn 4. 再过滤 entry.enabled=false 的条目。 5. 对 marker:用 WorldInfoPack 解析我们的四个洞 + chatHistory;酒馆角色卡/WI 恒为空。 6. 普通 content 条目原样插入。 -7. 按条目 role 合成 LLM messages。 -8. 应用 generation 参数。 +7. 按条目 role 合成 LLM messages(chatHistory 洞展开成多条)。 +8. injection_position=1 的条目插进历史区(见 4.4.1)。 +9. 应用 generation 参数。 ``` -不实现:世界书关键词、概率、sticky、Depth 插进历史中间、outlet、正则、插件脚本。 +不实现:世界书关键词、概率、sticky、outlet、ST 正则引擎、插件脚本。 + +#### 4.4.1 深度插入 + +`injection_position = 1` 的条目**不进线性位置**,而是按 `injection_depth` 从历史底部往上插: + +```text +depth 0 历史最后一条之后(紧贴 currentTurn 之前) +depth N 倒数第 N 条之前 +``` + +同深度按 `injection_order` 升序排列(数值大的更靠后)。插进去的 message 与历史同等待遇: +`injection_order` 默认 0,`role` 取自条目自身。无历史区时(单包请求)按收集顺序附在末尾。 + +### 4.5 双括号宏 + +预设正文在装配前过一遍宏层(`src/directives/macros.ts`),与 `@` 指令共用 persona 与骰子引擎。 + +| 宏 | 行为 | +|---|---| +| `{{// … }}` | 注释,展开为空(多行也吃) | +| `{{trim}}` | 展开为空,并让本条首尾去空白 | +| `{{user}}` | 当前用户角色名 | +| `{{persona}}` | 当前用户角色(`@人设`)的详细人设正文;未设 = 空 | +| `{{char}}` | 预设角色名;取当前作品名(对话落盘时的快照名),没绑作品时用「作品」 | +| `{{lastUserMessage}}` | 最近一条用户输入 | +| `{{getvar::k}}` | 变量值;未设 = 空 | +| `{{setvar::k::v}}` / `{{addvar::k::v}}` | 写入变量,展开为空 | +| `{{roll:1d6}}` / `{{roll N}}` | 掷骰 / 1..N 随机 | +| 其它(如 `{{charPrompt}}`、`{{description}}`) | **原样保留**,导入报告点名 | + +变量作用域是**单次装配**一份 Map,不落盘、不跨轮。依据:6 份真实预设里所有 `setvar` 要么在每次请求都执行的条目里,要么在关闭的条目里 —— 单次作用域与原行为等价。 + +展开后变空的条目不入 messages(只写了 `{{setvar}}` 或纯注释的条目会被跳过)。 + +### 4.6 消息整形(squash) + +对应酒馆的 Squash System Messages,在夹心完成后、发请求前执行(`src/llm/message-squash.ts`)。 + +#### 酒馆到底提供了什么(核过源码) + +```text +squash_system_messages bool,默认 false + true = 把相邻的、没有 name 的 system 消息合并成一条 + (exception: newMainChat / newChat / groupNudge) + 出处:public/scripts/openai.js → ChatCompletion.squashSystemMessages() + +custom_prompt_post_processing none / merge / strict(claude 已弃用 → merge) + 管的是「自定义 system prompt 那几段是合成一条,还是分开发」, + 与 squash 是两个设置,不碰 user/assistant 轮次。 +``` + +所以酒馆侧只有两档真实语义:**不合并** / **合并相邻 system**。 +我们多出的 `semi` / `strict` / `single-user-turn` 是为「上游拒收 system 或连续同角色」自造的降级档。 + +#### 我们的五档 + +| 档 | 行为 | 对历史区 | +|---|---|---| +| `none` | 原样(= 酒馆默认) | 不动 | +| `merge` | 只合并相邻同角色(= 酒馆勾上 squash) | 全序列合并 | +| `semi` | 合并后把 system 折进相邻普通消息,强制 user/assistant 交替 | 只合并相邻同角色 | +| `strict` | 同 semi,并保证首条为 user | 只合并相邻同角色 | +| `single-user-turn` | 全部合成一条 user | 原样保留 | + +约束: + +```text +工具调用组(assistant(tool_calls) + tool)永不参与合并与重排。 +请求里出现工具组时,semi / strict / single-user-turn 一律退化为 merge。 + +历史区(真多轮)最多只做「合并相邻同角色」: + 用户连发两条(上一轮助手没出正文)→ 合成一条,结构不破; + injection_depth 插进历史的 system → 不被折掉; + single-user-turn 不会把历史堆成一条。 +``` + +来源:预设的 `squash_system_messages`(bool 或枚举)→ `PresetPackage.messagePolicy.squash`; +字段缺失时不写 `messagePolicy`,运行时按 **`strict`** 处理(`DEFAULT_PRESET_SQUASH`)—— +兼容性优先:各家上游对「首条 system + 连续同角色」的容忍度不一,默认取最保守的一档。 +想只合并 system(更贴酒馆)选 `merge`,想原样发出选 `none`;单个预设可在预设里自己写。 + +### 4.7 工具协议适配 + +两份真实预设把破限写在工具协议里,形态有两种: + +```text +SPreset.ToolBindings 明确声明(含结构化 form.name / form.parameters) +条目正文占位符 %TOOL_NAME% + stream_target = display_stream 之类 +``` + +导入时只做诊断(`src/preset/diagnostics.ts`):列出伪工具协议条目、待映射占位符、已声明工具。 +运行时按 `toolPolicy` 处理(`src/preset/tool-policy.ts`): + +| 模式 | 行为 | +|---|---| +| `adapt`(默认) | 把预设要求的工具折算到已有工具:给宿主工具追加描述与兼容字段;工具名对不上时另挂一个同名载荷工具,收到调用再折算回宿主 | +| `app` | 只发本应用工具,并把伪工具协议条目从装配中剔除 | +| `preset` | 只发预设声明的那个工具(直聊/纯 RP) | + +正文字段从预设里反推:只认「被赋值」的名字(`stream_target = display_stream`)与 ToolBindings 的参数名, +不把正文里偶然出现的词当字段名。工具描述只取 ToolBindings 里作者写的那句, +**不把条目正文抄进工具描述**(那会把 prompt 体积翻倍)。 + +### 4.8 截断保留 + +正文走工具参数,`maxOutputTokens` 到顶时 `arguments` 是半截 JSON, +直接 parse 会把已经写好的正文整段丢掉。`src/llm/truncation.ts` 在参数无法解析时 +把目标字段已生成的部分拨出来(只比截断处少一截),并记一行日志。 + +它在工具调用、消息整形、宏层全部完成后才执行,所以只救正文,不改变协议。 ## 5. 与创作流程的关系 @@ -310,22 +426,73 @@ Runtime Session ```text 保留 prompts。 -保留 prompt_order。 +保留 prompt_order(多套时优先 100001)。 保留生成参数。 -记录但忽略 regex_scripts。 -记录但忽略 extension scripts。 +读取 squash_system_messages → messagePolicy。 +记录但忽略 regex_scripts(只做诊断)。 +记录但忽略 extension scripts(只做诊断)。 记录未知字段,不丢弃原始 JSON。 ``` -导入结果应该给用户可读的报告: +导入结果应该给用户可读的报告(`PresetImportReport`): ```text -导入 prompt 条目数量 +prompt 条目数量 启用条目数量 未被 prompt_order 引用的条目数量 缺失 identifier 的 order 项 +选中的 order 属于哪个 character_id;缺哪些核心位置(main / chatHistory / jailbreak) 已读取的生成参数 -被忽略的扩展字段 +诊断五项: + · 伪工具协议条目 + 待映射占位符 + 已声明的工具 + · 跨条目标签配对(合并消息会打散的那些) + · 宏清单:哪些会展开、哪些会原样外发 + · 随机宏相对 chatHistory 的位置(在历史前会每轮击穿前缀缓存) + · 静默失效:injection_position=1 的启用条目、脚本里没被替代的能力 ``` -这个报告比静默导入更重要。预设文件经常很大,且混有脚本、正则、UI 配置和模型参数,必须让用户知道哪些内容真正进入了我们的运行时。 \ No newline at end of file +这个报告比静默导入更重要。预设文件经常很大,且混有脚本、正则、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 +``` \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 10be224..ac925f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "writing-agent", "version": "0.1.0", "dependencies": { + "https-proxy-agent": "^9.1.0", "msedge-tts": "^2.0.9", + "undici": "^8.12.2", "yaml": "^2.9.0" }, "devDependencies": { @@ -1048,15 +1050,12 @@ } }, "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==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", "license": "MIT", - "dependencies": { - "debug": "4" - }, "engines": { - "node": ">= 6.0.0" + "node": ">= 20" } }, "node_modules/assertion-error": { @@ -1087,6 +1086,31 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1894,16 +1918,17 @@ } }, "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==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", "license": "MIT", "dependencies": { - "agent-base": "6", - "debug": "4" + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { - "node": ">= 6" + "node": ">= 20" } }, "node_modules/ieee754": { @@ -2213,6 +2238,23 @@ "node": ">=0.4.0" } }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -2561,6 +2603,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", + "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index cc16e56..b49461c 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "web:watch": "tsx watch --clear-screen=false --include \"./src/**/*.ts\" --include \"./skills/**\" src/server/web-server.ts", "electron": "electron .", "libraries:restore": "tsx src/cli/restore-library-seeds.ts", - "libraries:export": "tsx src/cli/restore-library-seeds.ts --export" + "libraries:export": "tsx src/cli/restore-library-seeds.ts --export", + "stats:cache": "tsx src/cli/cache-report.ts" }, "devDependencies": { "@types/node": "^22.10.0", @@ -25,7 +26,9 @@ "vitest": "^2.1.9" }, "dependencies": { + "https-proxy-agent": "^9.1.0", "msedge-tts": "^2.0.9", + "undici": "^8.12.2", "yaml": "^2.9.0" } } diff --git a/plans/preset-message-array.md b/plans/preset-message-array.md new file mode 100644 index 0000000..c4b8f4f --- /dev/null +++ b/plans/preset-message-array.md @@ -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。 diff --git a/src/cli/cache-report.ts b/src/cli/cache-report.ts new file mode 100644 index 0000000..eab6409 --- /dev/null +++ b/src/cli/cache-report.ts @@ -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(); +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(); +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 之前、" + + "上游是否支持隐式缓存。", +); diff --git a/src/config/network.ts b/src/config/network.ts new file mode 100644 index 0000000..6851197 --- /dev/null +++ b/src/config/network.ts @@ -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 | 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 { + 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((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 { + 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 { + 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; +} diff --git a/src/config/settings.ts b/src/config/settings.ts index 09e9e6f..b67bb44 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -3,6 +3,19 @@ import path from "node:path"; import { listApiProfiles } from "./api-profiles.js"; import { loadLlmConfigOptional } from "./env.js"; import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js"; +import type { MessageSquashMode } from "../types/preset.js"; +import type { ToolPolicyMode } from "../preset/tool-policy.js"; + +export type MessageSquashSetting = "follow-preset" | MessageSquashMode; + +const MESSAGE_SQUASH_MODES: MessageSquashMode[] = [ + "none", + "merge", + "semi", + "strict", + "single-user-turn", + "system", +]; export type AppSettings = { version: 1; @@ -17,11 +30,24 @@ export type AppSettings = { * 游玩期每隔多少个用户回合尝试采集偏好候选。默认 10;0 = 关闭。 */ preferenceCollectEveryTurns?: number; + /** + * 直聊卡片拖动灵敏度:鼠标移 1px 实际滚动 N px。默认 1.5;范围 0.5–3。 + */ + chatDragGain?: number; /** * 对话落盘同一轮里,第一次填写失败后最多再补写几次。 * 0 = 只填一次,失败留给下一句用户消息。默认 0。 */ dictateFillRetries?: number; + /** + * 发出前的消息整形。默认跟随预设(`follow-preset`)。 + * 选具体值时覆盖预设自己的 squash_system_messages。 + */ + messageSquash?: MessageSquashSetting; + /** + * 预设工具协议怎么处理。默认 adapt:把预设要求的工具折算到已有工具上。 + */ + presetToolPolicy?: ToolPolicyMode; }; const FILE_NAME = "settings.json"; @@ -29,6 +55,12 @@ const DEFAULT_CONTEXT_TRACE_KEEP = 5; const DEFAULT_PREF_COLLECT_EVERY = 10; const DEFAULT_DICTATE_FILL_RETRIES = 0; const MAX_DICTATE_FILL_RETRIES = 12; +const DEFAULT_CHAT_DRAG_GAIN = 1.5; +const MIN_CHAT_DRAG_GAIN = 0.5; +const MAX_CHAT_DRAG_GAIN = 3; +export const DEFAULT_MESSAGE_SQUASH: MessageSquashSetting = "follow-preset"; +export const DEFAULT_PRESET_TOOL_POLICY: ToolPolicyMode = "adapt"; +const TOOL_POLICY_MODES: ToolPolicyMode[] = ["adapt", "app", "preset"]; function settingsPath(): string { return path.join(getUserDataDir(), FILE_NAME); @@ -42,9 +74,31 @@ function defaultSettings(): AppSettings { contextTraceKeepLatest: DEFAULT_CONTEXT_TRACE_KEEP, preferenceCollectEveryTurns: DEFAULT_PREF_COLLECT_EVERY, dictateFillRetries: DEFAULT_DICTATE_FILL_RETRIES, + chatDragGain: DEFAULT_CHAT_DRAG_GAIN, + messageSquash: DEFAULT_MESSAGE_SQUASH, + presetToolPolicy: DEFAULT_PRESET_TOOL_POLICY, }; } +/** 预设工具策略;非法值回落到 adapt。 */ +export function normalizePresetToolPolicy(raw: unknown): ToolPolicyMode { + if (typeof raw === "string") { + const hit = TOOL_POLICY_MODES.find((m) => m === raw); + if (hit) return hit; + } + return DEFAULT_PRESET_TOOL_POLICY; +} + +/** 消息整形:"follow-preset" 或 6 个模式之一;非法值回落到跟随预设。 */ +export function normalizeMessageSquash(raw: unknown): MessageSquashSetting { + if (raw === "follow-preset") return "follow-preset"; + if (typeof raw === "string") { + const hit = MESSAGE_SQUASH_MODES.find((m) => m === raw); + if (hit) return hit; + } + return DEFAULT_MESSAGE_SQUASH; +} + export function normalizeContextTraceKeepLatest(raw: unknown): number { const n = typeof raw === "number" ? raw : Number(raw); if (!Number.isFinite(n) || n < 0) return DEFAULT_CONTEXT_TRACE_KEEP; @@ -57,7 +111,15 @@ export function normalizePreferenceCollectEveryTurns(raw: unknown): number { 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 { const n = typeof raw === "number" ? raw : Number(raw); if (!Number.isFinite(n) || n < 0) return DEFAULT_DICTATE_FILL_RETRIES; @@ -83,6 +145,11 @@ export function loadAppSettings(): AppSettings { dictateFillRetries: normalizeDictateFillRetries( parsed.dictateFillRetries ?? DEFAULT_DICTATE_FILL_RETRIES, ), + chatDragGain: normalizeChatDragGain( + parsed.chatDragGain ?? DEFAULT_CHAT_DRAG_GAIN, + ), + messageSquash: normalizeMessageSquash(parsed.messageSquash), + presetToolPolicy: normalizePresetToolPolicy(parsed.presetToolPolicy), }; } catch { return defaultSettings(); @@ -149,3 +216,4 @@ export function ensureActiveProfileDefault(): AppSettings { } return settings; } + diff --git a/src/dictate/history-window.ts b/src/dictate/history-window.ts index b912d6d..9f05a3b 100644 --- a/src/dictate/history-window.ts +++ b/src/dictate/history-window.ts @@ -3,10 +3,11 @@ import type { DictateChatTurn } from "./types.js"; /** * 创作上下文保留的更早对话轮数。 * 1 轮 = 一次用户输入 + 一次助手输出(不含本轮正在处理的末句用户话)。 - * 默认 2;0 = 不保留更早对话;负数 = 不限制。 + * 负数 = 不限制。(当前缺省即不限制:块级滑窗会让整段前缀位移、击穿缓存, + * 先关掉以便量真多轮之后的首填命中,见 docs/briefs/prompt-cache-audit-2026-10-07.md) * 可用环境变量 `DICTATE_HISTORY_TURNS` 或调用参数覆盖。 */ -export const DEFAULT_DICTATE_HISTORY_TURNS = 2; +export const DEFAULT_DICTATE_HISTORY_TURNS = -1; export function resolveDictateHistoryTurns(override?: number): number { if (typeof override === "number" && Number.isFinite(override)) { @@ -22,7 +23,7 @@ export function resolveDictateHistoryTurns(override?: number): number { /** * 裁剪进模型的对话:保留末句用户输入 + 最近 `historyTurns` 轮更早问答。 - * `historyTurns < 0` 时原样返回。 + * `historyTurns < 0` 时原样返回(= 不限制,当前缺省)。 */ export function clipDictateDialogue( dialogue: DictateChatTurn[], diff --git a/src/direct-chat/index.ts b/src/direct-chat/index.ts index 24e2141..19696ab 100644 --- a/src/direct-chat/index.ts +++ b/src/direct-chat/index.ts @@ -1,3 +1,4 @@ export * from "./types.js"; export * from "./store.js"; export * from "./turn.js"; +export * from "./turn-variants.js"; diff --git a/src/direct-chat/store.ts b/src/direct-chat/store.ts index 4ec0053..97d57b9 100644 --- a/src/direct-chat/store.ts +++ b/src/direct-chat/store.ts @@ -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; + 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 = []; + for (const item of raw) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const row = item as Record; + const userRaw = row.user; + if (!userRaw || typeof userRaw !== "object" || Array.isArray(userRaw)) continue; + const userRow = userRaw as Record; + const userContent = asString(userRow.content); + if (userContent == null) continue; + const after: DirectChatTurn[] = []; + if (Array.isArray(row.after)) { + for (const child of row.after) { + const parsed = parseTurn(child); + if (parsed) after.push(parsed); + } + } + const selected = parseSelected(row.selectedWindowId); + variants.push({ + user: { + content: userContent, + createdAt: asString(userRow.createdAt) ?? nowIso(), + }, + replies: parseReplies(row.replies), + ...(selected !== undefined ? { selectedWindowId: selected } : {}), + after, + }); + } + return variants.length ? variants : undefined; +} + function parseTurn(raw: unknown): DirectChatTurn | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const row = raw as Record; @@ -158,32 +222,13 @@ function parseTurn(raw: unknown): DirectChatTurn | null { const userRow = userRaw as Record; const userContent = asString(userRow.content); if (userContent == null) return null; - const replies: DirectChatTurn["replies"] = []; - if (Array.isArray(row.replies)) { - for (const r of row.replies) { - if (!r || typeof r !== "object" || Array.isArray(r)) continue; - const rr = r as Record; - 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"] } - : {}), - }); - } - } - const selected = - row.selectedWindowId === null - ? null - : asString(row.selectedWindowId)?.trim() || undefined; + const replies = parseReplies(row.replies); + const selected = parseSelected(row.selectedWindowId); + const variants = parseVariants(row.variants); + const variantIndex = + typeof row.variantIndex === "number" && Number.isFinite(row.variantIndex) + ? Math.max(0, Math.floor(row.variantIndex)) + : undefined; return { id, user: { @@ -193,6 +238,12 @@ function parseTurn(raw: unknown): DirectChatTurn | null { replies, ...(selected !== undefined ? { selectedWindowId: selected } : {}), 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); } +function copyName(name: string): string { + const base = name.replace(/(?: 副本)+$/, "").trim() || "会话"; + return `${base} 副本`; +} + +function remapWindowId( + id: string | null | undefined, + idMap: Map, +): string | null | undefined { + if (!id) return id; + return idMap.get(id) ?? id; +} + +function remapTurn(turn: DirectChatTurn, idMap: Map): 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(); + const windows = existing.windows.map((w) => { + const nextId = randomUUID(); + idMap.set(w.id, nextId); + return { ...w, id: nextId }; + }); + const turns = existing.turns.map((t) => remapTurn(t, idMap)); + return createConversation({ + shellId: existing.shellId, + name: copyName(existing.name), + windows, + turns, + messages: [], + }); +} + export function saveConversation(conv: DirectChatConversation): DirectChatConversation { const next = { ...conv, updatedAt: nowIso() }; ensureDirectChatDirs(); diff --git a/src/direct-chat/turn-variants.ts b/src/direct-chat/turn-variants.ts new file mode 100644 index 0000000..a537969 --- /dev/null +++ b/src/direct-chat/turn-variants.ts @@ -0,0 +1,222 @@ +import type { + DirectChatAssistantReply, + DirectChatTurn, + DirectChatTurnVariant, +} from "./types.js"; + +function clone(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(); + 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)]; +} diff --git a/src/direct-chat/turn.ts b/src/direct-chat/turn.ts index 961d4da..44a6f99 100644 --- a/src/direct-chat/turn.ts +++ b/src/direct-chat/turn.ts @@ -13,6 +13,8 @@ import { import { wrapLlmForSession } from "../llm/preset-wrapper.js"; import type { LlmTrackingContext } from "../llm/token-tracker.js"; import { assemblePlayWorkerMessages } from "../preset/play-frame.js"; +import { applyRegexScripts, readRegexScripts, REGEX_PLACEMENT } from "../preset/regex-scripts.js"; +import { expandPresetDirectives } from "../directives/expand.js"; import { getPreset, resolveActivePreset } from "../preset/store.js"; import type { WorldInfoPack } from "../preset/world-info-pack.js"; import { hasRealLlmConfig } from "../runtime/llm-factory.js"; @@ -21,8 +23,8 @@ import { type LlmContextTrace, } from "../types/context-trace.js"; import type { PresetPackage } from "../types/preset.js"; +import { parseHistoryMessages } from "../skills/dialogue-history.js"; import { - DEFAULT_DIRECT_CHAT_SYSTEM, buildPriorForWindow, type DirectChatAssistantReply, type DirectChatMessage, @@ -92,17 +94,36 @@ export function worldInfoPackFromDirectChat(input: { slots: DirectChatShellSlots; priorMessages: Array<{ role: "user" | "assistant"; content: string }>; }): WorldInfoPack { - const history = buildChatHistory( - input.slots.seedHistory, - input.priorMessages, - ); + // 种子前情:带对话块头的当真的历史轮,不带的当史前读料(不污染历史区)。 + const seedTurns = parseHistoryMessages(input.slots.seedHistory); + const seedIsStructured = seedTurns.length > 0; + const history: ChatMessage[] = [ + ...seedTurns, + ...input.priorMessages + .map((m) => ({ + role: m.role, + content: (m.content ?? "").trim(), + })) + .filter((m) => m.content.length > 0), + ]; return { - worldBookBefore: optionalEntry( - "direct-before", - "直聊设定", - input.slots.loreBefore, - ), + worldBookBefore: [ + ...optionalEntry( + "direct-seed", + "前情", + seedIsStructured ? "" : input.slots.seedHistory, + ), + ...optionalEntry("direct-before", "直聊设定", input.slots.loreBefore), + ], history, + ...(input.slots.exampleDialogue?.trim() + ? { exampleDialogue: input.slots.exampleDialogue.trim() } + : {}), + card: { + description: input.slots.charDescription, + personality: input.slots.charPersonality, + scenario: input.slots.scenario, + }, worldBookAfter: optionalEntry( "direct-after", "本轮状态", @@ -129,7 +150,8 @@ export function resolveDirectChatPreset( } export function systemPromptForSlots(slots: DirectChatShellSlots): string { - return slots.systemPrompt.trim() || DEFAULT_DIRECT_CHAT_SYSTEM; + // 不填就不发:酒馆没有"隐式系统提示"这回事,缺省内容会把请求撑成多一条。 + return slots.systemPrompt.trim(); } /** UI / 对照用的理想夹心 */ @@ -145,6 +167,7 @@ export function assembleDirectChatMessages( systemPrompt: systemPromptForSlots(input.slots), preset, pack: worldInfoPackFromDirectChat(input), + charName: input.slots.charName ?? null, }); } @@ -159,7 +182,10 @@ export function buildDirectChatTaskMessages(input: { for (const e of pack.worldBookBefore) { 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) { parts.push(`### ${e.name}\n\n${e.content}`); } @@ -168,8 +194,9 @@ export function buildDirectChatTaskMessages(input: { } const ctx = parts.join("\n\n").trim(); const user = ctx ? `${ctx}\n\n## 本轮输入\n${pack.turn}` : pack.turn; + const systemPrompt = systemPromptForSlots(input.slots); return [ - { role: "system", content: systemPromptForSlots(input.slots) }, + ...(systemPrompt ? [{ role: "system" as const, content: systemPrompt }] : []), { role: "user", content: user }, ]; } @@ -187,6 +214,7 @@ function createTrackedLlm( preset: PresetPackage, profileId: string | null | undefined, trackingRef: { current: LlmTrackingContext }, + charName: string | null | undefined, ): LlmProvider { if (profileId?.trim()) { const profile = getApiProfile(profileId.trim()); @@ -194,7 +222,7 @@ function createTrackedLlm( throw new Error(`API 配置不可用:${profileId}`); } const inner = new OpenAiCompatibleProvider(profileToLlmConfig(profile)); - return wrapLlmForSession(inner, trackingRef, () => preset); + return wrapLlmForSession(inner, trackingRef, () => preset, undefined, () => charName); } ensureActiveProfileDefault(); const profile = resolveActiveProfile(); @@ -202,7 +230,7 @@ function createTrackedLlm( throw new Error("未配置 API Key"); } const inner = new OpenAiCompatibleProvider(profileToLlmConfig(profile)); - return wrapLlmForSession(inner, trackingRef, () => preset); + return wrapLlmForSession(inner, trackingRef, () => preset, undefined, () => charName); } export async function runDirectChatTurn( @@ -244,19 +272,36 @@ export async function runDirectChatTurn( const trackingRef = { current: {} as LlmTrackingContext }; try { - const llm = createTrackedLlm(preset, input.profileId, trackingRef); + const llm = createTrackedLlm(preset, input.profileId, trackingRef, input.slots.charName); const messages = buildDirectChatTaskMessages(turnInput); const callbacks = { onReasoningDelta: (delta: string) => input.onDelta?.("thinking", delta), onContentDelta: (delta: string) => input.onDelta?.("content", delta), }; + // 真多轮:历史轮次以 message 序列交给夹心层,不再拍平成一段。 + const completeOptions = { + caller: "direct-chat", + presetPack: worldInfoPackFromDirectChat(turnInput), + }; const result = llm.completeStream - ? await llm.completeStream(messages, { caller: "direct-chat" }, callbacks) - : await llm.complete(messages, { caller: "direct-chat" }); + ? await llm.completeStream(messages, completeOptions, callbacks) + : await llm.complete(messages, completeOptions); const contextTrace = trackingRef.current.pendingContextTrace; const reasoning = result.reasoning ?? null; let reply = result.content ?? ""; if (reasoning && reply === reasoning) reply = ""; + // 酒馆在"收到回复那一刻"就跑一遍既非 promptOnly 也非 markdownOnly 的正则, + // 改的是存进 chat 的文本;这里同样在落盘前跑。 + if (reply) { + const scripts = readRegexScripts(preset.raw); + if (scripts.length > 0) { + reply = applyRegexScripts(reply, scripts, { + placement: REGEX_PLACEMENT.AI_OUTPUT, + direction: "raw", + expand: (text) => expandPresetDirectives(text, {}).text, + }); + } + } return { messages: assembled, reply, diff --git a/src/direct-chat/types.ts b/src/direct-chat/types.ts index e64a5f8..a8800b0 100644 --- a/src/direct-chat/types.ts +++ b/src/direct-chat/types.ts @@ -12,6 +12,16 @@ export type DirectChatShellSlots = { loreAfter: string; /** 尾注 */ postTurn: string; + /** 角色名(`{{char}}` 取值);空则回落「作品」 */ + charName?: string; + /** 示例对话(酒馆 `mes_example` 同格式,用 `` 分块) */ + exampleDialogue?: string; + /** 角色描述(酒馆 charDescription 洞) */ + charDescription?: string; + /** 角色性格(酒馆 charPersonality 洞,会套预设的 personality_format) */ + charPersonality?: string; + /** 场景(酒馆 scenario 洞,会套预设的 scenario_format) */ + scenario?: string; }; export type DirectChatShell = DirectChatShellSlots & { @@ -40,6 +50,17 @@ export type DirectChatAssistantReply = { contextTrace?: LlmContextTrace; }; +/** + * 同一轮的一个版本:编辑原话或直接重 roll 都会新开一版。 + * `after` 是切回这一版时,接在本轮后面的对话。 + */ +export type DirectChatTurnVariant = { + user: { content: string; createdAt: string }; + replies: DirectChatAssistantReply[]; + selectedWindowId?: string | null; + after: DirectChatTurn[]; +}; + /** 一轮:共用用户消息 + 各窗口助手回复 */ export type DirectChatTurn = { id: string; @@ -51,6 +72,12 @@ export type DirectChatTurn = { */ selectedWindowId?: string | null; createdAt: string; + /** + * 重 roll / 编辑原话后的版本。 + * 当前 `user` 与 `replies` 对应 `variantIndex`。 + */ + variants?: DirectChatTurnVariant[]; + variantIndex?: number; }; /** 旧版扁平消息(读盘兼容;新写入用 turns) */ diff --git a/src/directives/expand.ts b/src/directives/expand.ts index f8d113b..782f8f8 100644 --- a/src/directives/expand.ts +++ b/src/directives/expand.ts @@ -1,9 +1,11 @@ /** - * `@` 指令展开:匹配已注册指令即替换;未注册保留原文。 + * `@` 指令 + 酒馆双括号宏 `{{...}}` 展开: + * 匹配已注册形式即替换;未注册一律保留原文。 * 身份类可复用于预设/开场白;骰子类仅在发送/进游玩时展开。 */ import { executeChance } from "../skills/chance-tools.js"; +import { expandMacros, type MacroKind } from "./macros.js"; export type DirectivePersona = { name: string; @@ -12,6 +14,12 @@ export type DirectivePersona = { export type DirectiveContext = { persona?: DirectivePersona | null; + /** `{{char}}` → 预设角色名(我们这边是书名 / 主角名)。缺省回落「作品」。 */ + charName?: string | null; + /** `{{lastUserMessage}}` */ + lastUserMessage?: string | null; + /** `{{setvar}}` / `{{getvar}}` 容器;同一次装配传同一份。 */ + vars?: Map; }; export type ExpandOptions = { @@ -19,9 +27,11 @@ export type ExpandOptions = { allowDice?: boolean; }; +export type DirectiveKind = "persona_name" | "persona_desc" | "dice" | MacroKind; + export type DirectiveHit = { raw: string; - kind: "persona_name" | "persona_desc" | "dice" | "st_user"; + kind: DirectiveKind; replacement: string; }; @@ -141,13 +151,30 @@ export function expandAtDirectives( options: ExpandOptions = {}, ): ExpandResult { const allowDice = options.allowDice !== false; - const hits: DirectiveHit[] = []; + + // 先展开双括号宏:注释在这里就被吃掉,`@` 不会误伤注释里的内容。 + const macros = expandMacros(input, { + user: personaName(ctx), + char: ctx.charName ?? null, + persona: personaDesc(ctx) || null, + lastUserMessage: ctx.lastUserMessage ?? null, + vars: ctx.vars, + allowDice, + }); + + const hits: DirectiveHit[] = macros.hits.map((h) => ({ + raw: h.raw, + kind: h.kind, + replacement: h.replacement, + })); + + const src = macros.text; let i = 0; let out = ""; - while (i < input.length) { - if (input[i] === "@") { - const tried = tryExpandAt(input.slice(i), ctx, allowDice); + while (i < src.length) { + if (src[i] === "@") { + const tried = tryExpandAt(src.slice(i), ctx, allowDice); if (tried) { out += tried.hit.replacement; hits.push(tried.hit); @@ -156,21 +183,12 @@ export function expandAtDirectives( } } - // 兼容 SillyTavern {{user}} - if (input.startsWith("{{user}}", i)) { - const replacement = personaName(ctx); - out += replacement; - hits.push({ raw: "{{user}}", kind: "st_user", replacement }); - i += "{{user}}".length; - continue; - } - - out += input[i]; + out += src[i]; i += 1; } return { - text: out, + text: macros.trim ? out.trim() : out, hits, changed: hits.length > 0, }; @@ -184,6 +202,17 @@ export function expandIdentityDirectives( return expandAtDirectives(input, ctx, { allowDice: false }); } +/** + * 预设装配用:身份 + 双括号宏,`{{roll}}` 照常掷(酒馆预设的随机填充依赖它)。 + * 与 `expandIdentityDirectives` 的区别只在 allowDice。 + */ +export function expandPresetDirectives( + input: string, + ctx: DirectiveContext = {}, +): ExpandResult { + return expandAtDirectives(input, ctx, { allowDice: true }); +} + export type DirectiveCatalogItem = { insert: string; label: string; diff --git a/src/directives/macros.ts b/src/directives/macros.ts new file mode 100644 index 0000000..bbf83d3 --- /dev/null +++ b/src/directives/macros.ts @@ -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; + /** 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 { + return new Map(); +} + +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 { + 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(); + 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); +} diff --git a/src/llm/client.ts b/src/llm/client.ts index 77348ad..17b7ab8 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -1,5 +1,7 @@ import type { LlmConfig } from "../config/env.js"; +import { ensureNetworkProxy } from "../config/network.js"; import type { GenerationParameters } from "../types/preset.js"; +import type { WorldInfoPack as PresetPackForFrame } from "../preset/world-info-pack.js"; import { applyOpenAiGeneration, fetchWithGenerationCompat, @@ -24,13 +26,14 @@ export type ToolCallPayload = { }; export type ChatMessage = - | { role: "system" | "user"; content: string } + | { role: "system" | "user"; content: string; name?: string } | { role: "assistant"; content: string | null; tool_calls?: ToolCallPayload[]; + name?: string; } - | { role: "tool"; content: string; tool_call_id: string }; + | { role: "tool"; content: string; tool_call_id: string; name?: string }; export type ToolDefinition = { type: "function"; @@ -117,6 +120,11 @@ export type CompleteOptions = { /** 统计补充。不进上游请求体。 */ usageDetail?: UsageDetail; usageSink?: UsageSink; + /** + * 内部:play 真多轮装配的分袋上下文(由 worker 从 contextSegments 切成四袋 + 历史轮次)。 + * 不进上游请求体;由 PresetLlmProvider 取用后丢弃。 + */ + presetPack?: PresetPackForFrame; }; export type ToolChoice = @@ -223,6 +231,33 @@ export function ensureJsonObjectPrompt(messages: ChatMessage[]): ChatMessage[] { return [{ role: "system", content: JSON_OBJECT_HINT }, ...messages]; } +/** 空正文时报得能定位:是限额吃光了,还是模型真没给。 */ +function emptyToolResponseMessage(params: { + stream: boolean; + finishReason?: string; + reasoningChars?: number; + usage?: TokenUsage; +}): string { + const bits: string[] = []; + if (params.finishReason) bits.push(`finish_reason=${params.finishReason}`); + if (params.reasoningChars) bits.push(`思维链=${params.reasoningChars}字`); + if (params.usage) { + bits.push( + `prompt=${params.usage.promptTokens}/completion=${params.usage.completionTokens}`, + ); + } + const hint = + params.finishReason === "length" + ? "(输出上限被思考/正文吃光,调大 maxOutputTokens)" + : params.reasoningChars + ? "(只产出思维链没有正文,多半是输出上限不足)" + : ""; + const head = params.stream + ? "LLM 流式返回空正文且没有工具调用" + : "LLM 返回空正文且没有工具调用"; + return `${head}${bits.length ? `:${bits.join(" ")}` : ""}${hint}`; +} + export function buildRequestBody( config: LlmConfig, messages: ChatMessage[], @@ -431,6 +466,7 @@ async function llmFetch( ): Promise { return fetchWithGenerationCompat(async (payload) => { throwIfAborted(options?.signal); + await ensureNetworkProxy(); return fetch(url, { method: "POST", headers: { @@ -533,17 +569,29 @@ export class OpenAiCompatibleProvider implements LlmProvider { const data = (await response.json()) as { model?: string; usage?: unknown; - choices?: Array<{ message?: Record }>; + choices?: Array<{ + message?: Record; + finish_reason?: string; + }>; }; - const parts = extractMessageParts(data.choices?.[0]?.message); + const choice = data.choices?.[0]; + const parts = extractMessageParts(choice?.message); + const usage = parseUsage(data.usage); if (!parts.content && parts.toolCalls.length === 0) { - throw new Error("LLM returned empty content and no tool calls"); + throw new Error( + emptyToolResponseMessage({ + stream: false, + finishReason: choice?.finish_reason, + reasoningChars: parts.reasoning?.length ?? 0, + usage, + }), + ); } return { content: parts.content, toolCalls: parts.toolCalls, reasoning: parts.reasoning, - usage: parseUsage(data.usage), + usage, model: data.model ?? this.config.model, }; } @@ -576,7 +624,12 @@ export class OpenAiCompatibleProvider implements LlmProvider { const parts = await consumeOpenAiToolStream(response.body, callbacks); if (!parts.content && parts.toolCalls.length === 0 && !parts.reasoning) { - throw new Error("LLM stream returned empty content and no tool calls"); + throw new Error( + emptyToolResponseMessage({ + stream: true, + usage: parts.usage, + }), + ); } const content = parts.content ?? diff --git a/src/llm/list-models.ts b/src/llm/list-models.ts index ded68b8..d456d90 100644 --- a/src/llm/list-models.ts +++ b/src/llm/list-models.ts @@ -2,6 +2,8 @@ * 拉取 OpenAI 兼容的 GET {base}/models,供同一 API 配置下快捷切换模型。 */ +import { ensureNetworkProxy } from "../config/network.js"; + const MAX_BODY_CHARS = 2_000_000; const MAX_MODELS = 4000; @@ -70,6 +72,7 @@ type FetchOutcome = async function fetchModelList(url: string, apiKey: string): Promise { let response: Response; + await ensureNetworkProxy(); try { response = await fetch(url, { method: "GET", diff --git a/src/llm/message-squash.ts b/src/llm/message-squash.ts new file mode 100644 index 0000000..7e6392b --- /dev/null +++ b/src/llm/message-squash.ts @@ -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)[SQUASH_EXEMPT_MARK] = true; + return message; +} + +function isSquashExempt(message: ChatMessage): boolean { + if (message.name) return true; + return Boolean((message as unknown as Record)[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( + (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)); +} diff --git a/src/llm/preset-wrapper.ts b/src/llm/preset-wrapper.ts index b2f20e5..82841c3 100644 --- a/src/llm/preset-wrapper.ts +++ b/src/llm/preset-wrapper.ts @@ -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 { frameMessagesWithPreset } from "../preset/play-frame.js"; +import { frameMessagesWithPreset, assemblePlayWorkerMessages, shapeFramedMessages } from "../preset/play-frame.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 { ChatMessage, CompleteOptions, @@ -10,8 +32,10 @@ import type { CompleteWithToolsOptions, CompleteWithToolsResult, LlmProvider, + ParsedToolCall, StreamCallbacks, } from "./client.js"; +import { debugLog } from "../log.js"; import { TokenTrackingProvider, type LlmTrackingContext, @@ -19,12 +43,73 @@ import { export type LlmTrackingRef = { current: LlmTrackingContext }; +/** 预设声明工具的结果缓存(key = 预设 id@importedAt)。 */ +const declaredToolCache = new Map< + string, + ReturnType +>(); + function readActivePreset(): PresetPackage | null { return resolveActivePreset(loadAppSettings().activePresetId); } -/** 思考强度只跟 API 配置,不跟预设。 */ -function generationWithoutReasoningEffort( +/** 预设里 `{{char}}` 的取值:优先当前作品名;没有作品时(如试跑)用这个。 */ +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; +}; + +/** 单次解析:设置优先,否则跟预设,都未定义时落回 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, ): GenerationParameters | undefined { if (!generation || generation.reasoningEffort === undefined) return generation; @@ -33,6 +118,40 @@ function generationWithoutReasoningEffort( 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 就是发给模型的那一份。 * 有选用预设时:在此按 prompt_order 夹心包裹,并合并采样参数。 @@ -43,11 +162,19 @@ export function wrapLlmForSession( trackingRef?: LlmTrackingRef, getPreset: () => PresetPackage | null = readActivePreset, getPersona?: () => PersonaDirective | null, + /** `{{char}}` 的取值来源;缺省用存档名(bookTitle) */ + getCharName?: () => string | null | undefined, ): LlmProvider { const tracked = trackingRef ? new TokenTrackingProvider(inner, () => trackingRef.current) : inner; - return new PresetLlmProvider(tracked, getPreset, getPersona); + return new PresetLlmProvider( + tracked, + getPreset, + getPersona, + getCharName ?? + (trackingRef ? () => trackingRef.current.bookTitle : undefined), + ); } /** 所有 LLM 请求:preset 夹心 + 生成参数。无选用预设则原样转发。 */ @@ -56,23 +183,107 @@ export class PresetLlmProvider implements LlmProvider { private readonly inner: LlmProvider, private readonly getPreset: () => PresetPackage | 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 { + 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( messages: ChatMessage[], options?: CompleteOptions, - ): { messages: ChatMessage[]; options: CompleteOptions | undefined } { + ): { + messages: ChatMessage[]; + options: CompleteOptions | undefined; + preset: PresetPackage | null; + runtime: PresetRuntime | null; + } { 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 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 { - messages: frameMessagesWithPreset(preset, messages, persona), + messages: shapeFramedMessages(framed, runtime.squash), options: { ...options, + presetPack: undefined, generation: generationWithoutReasoningEffort( options?.generation ?? preset.generation, ), }, + preset, + runtime, + }; + } + + /** 预设声明了工具时,用带 tools 的请求,并把工具调用折回正文。 */ + private async completeWithDeclaredTools( + prepared: ReturnType, + declared: NonNullable>, + callbacks: StreamCallbacks, + ): Promise { + 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, ): Promise { 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); } @@ -96,6 +311,10 @@ export class PresetLlmProvider implements LlmProvider { return result; } 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( prepared.messages, prepared.options, @@ -108,12 +327,26 @@ export class PresetLlmProvider implements LlmProvider { options: CompleteWithToolsOptions, ): Promise { 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, ...prepared.options, - tools: options.tools, + tools: decoration.tools, toolChoice: options.toolChoice, }); + return { + ...result, + toolCalls: salvageTruncatedCalls( + normalizeToolCalls(result.toolCalls, decoration, decoration.tools), + decoration, + ), + }; } async completeWithToolsStream( @@ -125,15 +358,29 @@ export class PresetLlmProvider implements LlmProvider { return this.completeWithTools(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, { ...options, ...prepared.options, - tools: options.tools, + tools: decoration.tools, toolChoice: options.toolChoice, }, callbacks, ); + return { + ...result, + toolCalls: salvageTruncatedCalls( + normalizeToolCalls(result.toolCalls, decoration, decoration.tools), + decoration, + ), + }; } } diff --git a/src/llm/truncation.ts b/src/llm/truncation.ts new file mode 100644 index 0000000..9131f5b --- /dev/null +++ b/src/llm/truncation.ts @@ -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; +} diff --git a/src/persona/store.ts b/src/persona/store.ts index 63f673d..af5591e 100644 --- a/src/persona/store.ts +++ b/src/persona/store.ts @@ -53,7 +53,7 @@ function defaultStore(): PersonaStoreFile { personas: [ { id: playId, - name: "玩家", + name: "User", // 与酒馆默认用户名一致,{{user}} 才能对得上 description: "", createdAt: t, updatedAt: t, diff --git a/src/preset/assembler.ts b/src/preset/assembler.ts index 608d2dd..9e6a31f 100644 --- a/src/preset/assembler.ts +++ b/src/preset/assembler.ts @@ -2,12 +2,76 @@ import type { ChatMessage } from "../llm/client.js"; import type { PresetPackage } from "../types/preset.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; const defaultMarkerResolver: MarkerResolver = () => null; 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)[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)[PRESET_HISTORY_MARK] = true; + messages.splice(item.at + inserted, 0, next); + inserted += 1; + } +} + /** * 按 prompt_order 装配 preset 消息。 * 酒馆角色卡 / WI marker 不走 resolver(空洞跳过);自有洞由 resolver 填。 @@ -17,9 +81,12 @@ export function assemblePresetMessages( preset: PresetPackage, resolveMarker: MarkerResolver = defaultMarkerResolver, contentExpander: ContentExpander = defaultContentExpander, + excludeEntryIds?: ReadonlySet, ): ChatMessage[] { const promptById = new Map(preset.prompts.map((p) => [p.id, p])); const messages: ChatMessage[] = []; + const depthItems: DepthInjection[] = []; + let seq = 0; const ordered = [...preset.promptOrder].sort( (a, b) => a.orderIndex - b.orderIndex, @@ -27,29 +94,79 @@ export function assemblePresetMessages( for (const orderItem of ordered) { if (!orderItem.enabled) continue; + if (excludeEntryIds?.has(orderItem.promptId)) continue; 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(); const markerId = entry.sourceIdentifier || entry.id; const skipFill = isStUnfilledMarker(markerId); + let fill: MarkerFill = content; if (!content && entry.marker && !skipFill) { - const resolved = resolveMarker(markerId); - if (resolved?.trim()) content = resolved.trim(); + fill = resolveMarker(markerId); } - if (!content) continue; - content = contentExpander(content); + if (isMessageSequence(fill)) { + // 历史洞:一条条目展开成多轮 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)[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, 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; } +/** 把历史区从装配结果里切出来(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( presetMessages: ChatMessage[], taskMessages: ChatMessage[], diff --git a/src/preset/diagnostics.ts b/src/preset/diagnostics.ts new file mode 100644 index 0000000..4493d23 --- /dev/null +++ b/src/preset/diagnostics.ts @@ -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; + +/** 标签:`` / ``;支持中文标签名(`<对话历史>`)。自闭合与 `->` `=>` 不算。 */ +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 | null): ParsedToolBinding[] { + const extensions = raw?.extensions as Record | undefined; + const spreset = extensions?.SPreset as Record | undefined; + const bindings = spreset?.ToolBindings; + if (!bindings || typeof bindings !== "object") return []; + + const out: ParsedToolBinding[] = []; + for (const [entryId, value] of Object.entries( + bindings as Record, + )) { + if (!value || typeof value !== "object") continue; + const binding = value as Record; + const form = binding.form as Record | 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).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 | null, +): PresetDiagnostics["toolProtocol"] { + const entries: string[] = []; + const placeholders = new Set(); + const entryLabels = new Map(); + + 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 + | undefined; + const spreset = extensions?.SPreset as Record | 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 + | undefined; + if (preprocessing?.enabled === true) { + declarations.push("SPreset.OutputPreprocessing(改写工具调用)"); + } + const formatter = preprocessing?.toolCallFormatter as + | Record + | 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 | null): RawRegex[] { + if (!raw) return []; + const out: RawRegex[] = []; + const extensions = raw.extensions as Record | 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 | undefined; + const binding = spreset?.RegexBinding as Record | undefined; + push(binding?.regexes); + + // 去重:SPreset 常把同一批正则存两份 + const seen = new Set(); + 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 | null, +): PresetDiagnostics["scripts"] { + const extensions = raw?.extensions as Record | 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; + 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 | 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; + 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) + : 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; +} diff --git a/src/preset/entries.ts b/src/preset/entries.ts index 18e0537..4bb4438 100644 --- a/src/preset/entries.ts +++ b/src/preset/entries.ts @@ -33,7 +33,8 @@ function toView( orderEnabled: boolean, ): PresetEntryView { const content = entry.content ?? ""; - const enabled = orderEnabled && entry.enabled; + // 跟酒馆一致:只看 prompt_order 的开关;prompts[].enabled 不参与。 + const enabled = orderEnabled; const trimmed = content.trim(); return { orderIndex, diff --git a/src/preset/examples.ts b/src/preset/examples.ts new file mode 100644 index 0000000..32e81fd --- /dev/null +++ b/src/preset/examples.ts @@ -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`): + * - 按 `` 分块; + * - 块内以 `用户名:` / `角色名:` 开头的行切换说话人,其余行算续行; + * - 每条消息 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(//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; +} diff --git a/src/preset/importer.ts b/src/preset/importer.ts index d466b29..bbec457 100644 --- a/src/preset/importer.ts +++ b/src/preset/importer.ts @@ -1,6 +1,9 @@ import { randomUUID } from "node:crypto"; import type { GenerationParameters, + MessageSquashMode, + PresetChatPrompts, + PresetFormats, PresetImportReport, PresetPackage, PresetPromptEntry, @@ -8,6 +11,7 @@ import type { UnsupportedPresetSection, } from "../types/preset.js"; import { applyExtendedMarkers } from "./markers.js"; +import { buildPresetDiagnostics, diagnosticsWarnings } from "./diagnostics.js"; type StPrompt = { identifier?: string; @@ -45,11 +49,67 @@ type SillyTavernPreset = { n?: number; prompts?: StPrompt[]; prompt_order?: StPromptOrderBlock[]; + squash_system_messages?: unknown; regex_scripts?: unknown; extensions?: 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 { return name .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, +): 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( raw: SillyTavernPreset, promptMap: Map, @@ -91,18 +180,15 @@ function selectPromptOrder( if (orders.length === 0) return { order: [] }; 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 bestScore = -1; for (const block of orders) { - let score = 0; - 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; - } + const score = scoreOrderBlock(block, promptMap); if (score > bestScore) { bestScore = score; best = block; @@ -111,6 +197,17 @@ function selectPromptOrder( return best; } +/** 选中的那套顺序里缺了哪些核心位置。 */ +export function missingCoreOrderIdentifiers( + block: StPromptOrderBlock, +): string[] { + const present = new Set(); + 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[] { const unsupported: UnsupportedPresetSection[] = []; if (raw.regex_scripts) { @@ -148,7 +245,13 @@ export function importSillyTavernPreset( const selectedOrder = selectPromptOrder(raw, promptMap); if ((raw.prompt_order?.length ?? 0) > 1) { 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 { id: sourceId, name: p.name ?? sourceId, - enabled: p.enabled !== false, + enabled: true, // 酒馆装配只看 prompt_order.enabled;prompts[].enabled 是历史遗留字段 role: normalizeRole(p.role), content: p.content ?? "", marker: Boolean(p.marker), @@ -200,6 +303,9 @@ export function importSillyTavernPreset( ? (rawInput as { name: string }).name : "导入的预设"); + const squashMode = readSquashMode(raw.squash_system_messages); + const chatPrompts = readChatPrompts(raw); + const formats = readFormats(raw); const preset: PresetPackage = applyExtendedMarkers({ id: options.id ?? `${slugify(presetName) || "preset"}-${randomUUID().slice(0, 8)}`, name: presetName, @@ -207,6 +313,9 @@ export function importSillyTavernPreset( prompts, promptOrder, generation, + ...(chatPrompts ? { chatPrompts } : {}), + ...(formats ? { formats } : {}), + ...(squashMode ? { messagePolicy: { squash: squashMode } } : {}), unsupported: collectUnsupported(raw), importedAt: new Date().toISOString(), raw: rawInput, @@ -219,9 +328,12 @@ export function importSillyTavernPreset( const framedEnabled = preset.promptOrder.filter((o) => { if (!o.enabled) return false; const entry = preset.prompts.find((p) => p.id === o.promptId); - return entry?.enabled !== false; + return Boolean(entry); }).length; + const diagnostics = buildPresetDiagnostics(preset, rawInput); + warnings.push(...diagnosticsWarnings(diagnostics)); + return { preset, promptCount: preset.prompts.length, @@ -230,5 +342,9 @@ export function importSillyTavernPreset( missingIdentifiers, generationFields, warnings, + orderCharacterId: selectedOrder.character_id ?? null, + missingCoreIdentifiers: missingCore, + diagnostics, }; } + diff --git a/src/preset/markers.ts b/src/preset/markers.ts index 17e835e..38307b5 100644 --- a/src/preset/markers.ts +++ b/src/preset/markers.ts @@ -6,17 +6,22 @@ import type { } from "../types/preset.js"; /** 酒馆角色卡 / WI marker:导入后保留位置;personaDescription 由当前用户角色填。 */ -export const ST_UNFILLED_MARKERS = new Set([ - "worldInfoBefore", - "charDescription", - "charPersonality", - "scenario", - "worldInfoAfter", - "dialogueExamples", -]); +/** 酒馆的 worldInfoBefore / worldInfoAfter 现在由 pack 填,集合留空(保留判定入口)。 */ +export const ST_UNFILLED_MARKERS = new Set([]); + +export const WORLD_INFO_BEFORE_MARKER = "worldInfoBefore"; +export const WORLD_INFO_AFTER_MARKER = "worldInfoAfter"; 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 WORLD_BOOK_BEFORE = "worldBookBefore"; export const WORLD_BOOK_AFTER = "worldBookAfter"; @@ -89,20 +94,30 @@ export function applyExtendedMarkers(preset: PresetPackage): PresetPackage { const histIndex = rawOrder.findIndex( (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 => ({ promptId: id, enabled: true, orderIndex: 0, }); + const beforeMarkers = hasStBefore ? [] : [inserted(WORLD_BOOK_BEFORE)]; + const afterMarkers = hasStAfter ? [] : [inserted(WORLD_BOOK_AFTER)]; + let next: PresetPromptOrderItem[]; if (histIndex < 0) { next = reindex([ ...rawOrder, - inserted(WORLD_BOOK_BEFORE), + ...beforeMarkers, inserted(CHAT_HISTORY_MARKER), - inserted(WORLD_BOOK_AFTER), + ...afterMarkers, inserted(CURRENT_TURN), inserted(POST_TURN), ]); @@ -112,9 +127,9 @@ export function applyExtendedMarkers(preset: PresetPackage): PresetPackage { const after = rawOrder.slice(histIndex + 1); next = reindex([ ...before, - inserted(WORLD_BOOK_BEFORE), + ...beforeMarkers, history, - inserted(WORLD_BOOK_AFTER), + ...afterMarkers, inserted(CURRENT_TURN), ...after, inserted(POST_TURN), diff --git a/src/preset/play-frame.ts b/src/preset/play-frame.ts index 1843132..212129c 100644 --- a/src/preset/play-frame.ts +++ b/src/preset/play-frame.ts @@ -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 { getActivePersona, type PersonaDirective, } from "../persona/store.js"; import type { PresetPackage } from "../types/preset.js"; -import { assemblePresetMessages } from "./assembler.js"; +import { assemblePresetMessages, splitPresetHistory } from "./assembler.js"; import { applyExtendedMarkers, + CHAR_DESCRIPTION_MARKER, + CHAR_PERSONALITY_MARKER, + CHAT_HISTORY_MARKER, + DIALOGUE_EXAMPLES_MARKER, PERSONA_DESCRIPTION_MARKER, + SCENARIO_MARKER, } from "./markers.js"; +import { parseExampleDialogue } from "./examples.js"; +import { + applyRegexScripts, + readRegexScripts, + REGEX_PLACEMENT, + type PresetRegexScript, +} from "./regex-scripts.js"; import { resolveWorldInfoMarker, type WorldInfoEntry, type WorldInfoPack, } 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_NAME = "任务契约"; -function withTaskSystem( +/** + * 任务契约(上下文壳里的系统提示)不再单独发一条消息,按酒馆的条目顺序找地方放: + * 1. 预设启用了 `main`(酒馆的系统提示条目)且已有正文 → 追加到它末尾; + * 2. 否则并进世界书(预设带 `worldInfoBefore` 时正好落在那一 条里); + * 3. 都没有才退回自有世界书洞(老行为)。 + */ +function attachTaskSystem( + preset: PresetPackage, pack: WorldInfoPack, systemPrompt: string, -): WorldInfoPack { +): { preset: PresetPackage; pack: WorldInfoPack } { 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 = { id: TASK_SYSTEM_ENTRY_ID, name: TASK_SYSTEM_ENTRY_NAME, content: text, }; return { - ...pack, - worldBookBefore: [entry, ...pack.worldBookBefore], + preset, + pack: { ...pack, worldBookBefore: [entry, ...pack.worldBookBefore] }, }; } /** * 严格按扩展 prompt_order 填洞。任务契约进 worldBookBefore,不在预设外另挂 system。 + * 「对话.历史」进 chatHistory 洞时展开成真多轮 message,不拍平成一段文本。 */ export function assemblePlayWorkerMessages(params: { systemPrompt: string; @@ -45,29 +92,152 @@ export function assemblePlayWorkerMessages(params: { pack: WorldInfoPack; /** 缺省用当前选用(试跑 / 未绑会话)。创作期应传入创作默认人设。 */ persona?: PersonaDirective | null; + /** `{{char}}` 的取值(书名 / 主角名);缺省回落「作品」。 */ + charName?: string | null; + /** 装配时排除的条目(`toolPolicy = app` 时用来剔掉伪工具协议)。 */ + excludeEntryIds?: ReadonlySet; }): ChatMessage[] { - const framed = applyExtendedMarkers(params.preset); - const pack = withTaskSystem(params.pack, params.systemPrompt); + const attached = attachTaskSystem( + params.preset, + params.pack, + params.systemPrompt, + ); + const framed = applyExtendedMarkers(attached.preset); + const pack = attached.pack; const persona = params.persona !== undefined ? params.persona : getActivePersona(); - const dirCtx = persona - ? { persona: { name: persona.name, description: persona.description } } - : {}; + // 一份变量表跨本次装配全部条目:`{{setvar}}` 写在前面,`{{getvar}}` 在后面就能读到。 + 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( framed, (id) => { + // 酒馆:人设描述为空就不发这条洞(不塞「姓名:X」占位)。 if (id === PERSONA_DESCRIPTION_MARKER) { - const desc = persona?.description?.trim(); - if (desc) return desc; - const name = persona?.name?.trim(); - return name ? `姓名:${name}` : null; + return persona?.description?.trim() || null; + } + if (id === CHAT_HISTORY_MARKER) { + // 酒馆把 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); }, - (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 { if (m.role === "tool") return true; 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 决定。 + * 末条之前的正文按原角色进历史区(真多轮),末条 user 进 currentTurn。 * tool 循环尾接在夹心后。 */ export function frameMessagesWithPreset( preset: PresetPackage, messages: ChatMessage[], persona?: PersonaDirective | null, + charName?: string | null, + excludeEntryIds?: ReadonlySet, ): ChatMessage[] { let splitAt = messages.length; for (let i = 0; i < messages.length; i++) { @@ -117,14 +275,14 @@ export function frameMessagesWithPreset( } let turn = ""; - let history = ""; + let history: ChatMessage[] = []; if (body.length > 0) { const last = body[body.length - 1]!; if (last.role === "user") { turn = last.content.trim(); - history = formatHistoryMessages(body.slice(0, -1)); + history = body.slice(0, -1); } else { - history = formatHistoryMessages(body); + history = body; } } @@ -139,7 +297,41 @@ export function frameMessagesWithPreset( postTurn: [], }, persona, + charName, + excludeEntryIds, }); 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), + ]; +} diff --git a/src/preset/probe.ts b/src/preset/probe.ts index dae81bb..0d1874d 100644 --- a/src/preset/probe.ts +++ b/src/preset/probe.ts @@ -1,6 +1,9 @@ import type { ChatMessage } from "../llm/client.js"; -import type { PresetPackage } from "../types/preset.js"; -import { assemblePlayWorkerMessages } from "./play-frame.js"; +import { parseHistoryMessages } from "../skills/dialogue-history.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"; /** 试跑窗口用的短契约,不是 play worker SKILL。 */ @@ -34,6 +37,21 @@ export type PresetProbeResult = { completed: boolean; skipReason?: string; error?: string; + /** 本次实际采用的消息整形模式 */ + squashMode?: MessageSquashMode; + /** 预设要求的工具协议(用于确认破限是否真能接上) */ + toolPlan?: { + present: boolean; + targetName: string | null; + payloadFields: string[]; + }; +}; + +export type PresetProbeOptions = { + /** `{{char}}` 的取值 */ + charName?: string | null; + /** 消息整形模式;缺省用预设自己的 */ + squash?: MessageSquashMode; }; function optionalEntry( @@ -49,13 +67,19 @@ export function worldInfoPackFromProbeInput( input: PresetProbeInput, ): WorldInfoPack { 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 postTurn = input.postTurn ?? DEFAULT_PROBE_CONTEXT.postTurn; + const history = parseHistoryMessages(rawHistory); return { 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), turn: input.message.trim(), postTurn: optionalEntry("probe-post", "尾注", postTurn), @@ -66,12 +90,17 @@ export function worldInfoPackFromProbeInput( export function assemblePresetProbe( preset: PresetPackage, input: PresetProbeInput, + options: PresetProbeOptions = {}, ): ChatMessage[] { - return assemblePlayWorkerMessages({ + const messages = assemblePlayWorkerMessages({ systemPrompt: PROBE_SYSTEM_PROMPT, preset, pack: worldInfoPackFromProbeInput(input), + charName: options.charName ?? null, }); + const mode = + options.squash ?? preset.messagePolicy?.squash ?? DEFAULT_PRESET_SQUASH; + return shapeFramedMessages(messages, mode); } /** 交给 wrapLlmForSession 的任务消息(未夹心)。 */ @@ -83,7 +112,10 @@ export function buildPresetProbeTaskMessages( for (const e of pack.worldBookBefore) { 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) { parts.push(`### ${e.name}\n\n${e.content}`); } @@ -112,8 +144,10 @@ export function serializeProbeMessages( export async function runPresetProbe(params: { preset: PresetPackage; input: PresetProbeInput; + options?: PresetProbeOptions; complete?: ( messages: ChatMessage[], + pack: WorldInfoPack, ) => Promise<{ content: string; reasoning?: string }>; }): Promise { const message = params.input.message.trim(); @@ -122,13 +156,28 @@ export async function runPresetProbe(params: { } 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; + // UI 仍展示「理想分袋夹心」;发模型只交任务消息,由统一包装层夹心 const serialized = serializeProbeMessages( - assemblePresetProbe(params.preset, input), + assemblePresetProbe(params.preset, input, options), ); if (!params.complete) { return { + ...meta, messages: serialized, reply: null, reasoning: null, @@ -138,8 +187,12 @@ export async function runPresetProbe(params: { } try { - const result = await params.complete(buildPresetProbeTaskMessages(input)); + const result = await params.complete( + buildPresetProbeTaskMessages(input), + worldInfoPackFromProbeInput(input), + ); return { + ...meta, messages: serialized, reply: result.content, reasoning: result.reasoning ?? null, @@ -147,6 +200,7 @@ export async function runPresetProbe(params: { }; } catch (err) { return { + ...meta, messages: serialized, reply: null, reasoning: null, diff --git a/src/preset/regex-scripts.ts b/src/preset/regex-scripts.ts new file mode 100644 index 0000000..7f5a707 --- /dev/null +++ b/src/preset/regex-scripts.ts @@ -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 | null { + return value && typeof value === "object" + ? (value as Record) + : 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 | 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(); + 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 | 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), + ); +} diff --git a/src/preset/store.ts b/src/preset/store.ts index 4392d9d..45f8e28 100644 --- a/src/preset/store.ts +++ b/src/preset/store.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { PresetPackage } from "../types/preset.js"; import { ensureUserDataDirs, getPresetsDir } from "../config/user-data-dir.js"; import { importSillyTavernPreset } from "./importer.js"; +import { clearToolPlanCache } from "./tool-policy.js"; /** 旧预设里的思考强度不再使用;读到就从文件里删掉。 */ function withoutReasoningEffort(preset: PresetPackage, filePath: string): PresetPackage { @@ -53,10 +54,13 @@ export function getPreset(id: string): PresetPackage | null { export function savePreset(preset: PresetPackage): PresetPackage { ensureUserDataDirs(); writeFileSync(presetPath(preset.id), JSON.stringify(preset, null, 2), "utf8"); + // 条目/内容被改过,导入时算好的工具映射要重建 + clearToolPlanCache(preset.id); return preset; } export function deletePreset(id: string): void { + clearToolPlanCache(id); try { unlinkSync(presetPath(id)); } catch { diff --git a/src/preset/tool-declaration.ts b/src/preset/tool-declaration.ts new file mode 100644 index 0000000..8e0681a --- /dev/null +++ b/src/preset/tool-declaration.ts @@ -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 | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function bindingMaps(preset: PresetPackage): Record[] { + const extensions = asRecord(asRecord(preset.raw)?.extensions); + if (!extensions) return []; + const maps: Record[] = []; + 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; + fields: string[]; +} { + const properties: Record = {}; + 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 = { 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(); + 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 = {}; + const consumed = new Set(); + 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; +} diff --git a/src/preset/tool-policy.ts b/src/preset/tool-policy.ts new file mode 100644 index 0000000..ae51b7f --- /dev/null +++ b/src/preset/tool-policy.ts @@ -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` 不算(那是 `` 标签)。 + */ +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(); + +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(); + 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, +): string[] { + const names = new Set([ + ...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 { + 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; required?: unknown } + | undefined; + const required = Array.isArray(params?.required) ? params!.required : []; + const properties = (params?.properties ?? {}) as Record; + 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; + 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 = {}; + 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; + try { + parsed = JSON.parse(call.arguments || "{}") as Record; + } 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 = { ...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), + }; + }); +} diff --git a/src/preset/world-info-pack.ts b/src/preset/world-info-pack.ts index ef5f7c7..60526b0 100644 --- a/src/preset/world-info-pack.ts +++ b/src/preset/world-info-pack.ts @@ -1,7 +1,11 @@ import type { Blackboard } from "../blackboard/blackboard.js"; import type { BlackboardInputMerge } from "../types/blackboard.js"; +import type { ChatMessage } from "../llm/client.js"; import { + CURRENT_TURN_TAG, + isCurrentTurnRef, isDialogueHistoryRef, + parseHistoryMessages, DIALOGUE_HISTORY_TAG, } from "../skills/dialogue-history.js"; import { @@ -11,24 +15,16 @@ import { import { CHAT_HISTORY_MARKER, CURRENT_TURN, + WORLD_INFO_AFTER_MARKER, + WORLD_INFO_BEFORE_MARKER, POST_TURN, WORLD_BOOK_AFTER, WORLD_BOOK_BEFORE, isStUnfilledMarker, } from "./markers.js"; -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()); -} +/** 兼容旧的导入点:历史相关标签判定归 `skills/dialogue-history.ts`。 */ +export { CURRENT_TURN_TAG, isCurrentTurnRef }; export type WorldInfoEntry = { id: string; @@ -38,8 +34,17 @@ export type WorldInfoEntry = { export type WorldInfoPack = { worldBookBefore: WorldInfoEntry[]; - history: string; + /** 「对话.历史」按块解析成真多轮 messages(非文本块);装配时直接进 chatHistory 洞。 */ + history: ChatMessage[]; worldBookAfter: WorldInfoEntry[]; + /** 酒馆 mes_example 同格式的示例对话(用 分块),装配时进 dialogueExamples 洞。 */ + exampleDialogue?: string; + /** 角色卡三块(酒馆 charDescription / charPersonality / scenario 洞)。 */ + card?: { + description?: string; + personality?: string; + scenario?: string; + }; turn: string; postTurn: WorldInfoEntry[]; }; @@ -47,7 +52,7 @@ export type WorldInfoPack = { function emptyPack(): WorldInfoPack { return { worldBookBefore: [], - history: "", + history: [], worldBookAfter: [], turn: "", postTurn: [], @@ -103,7 +108,11 @@ export function worldInfoPackFromSegments(params: { ).trim(); 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"; continue; } @@ -119,8 +128,14 @@ export function worldInfoPackFromSegments(params: { else pack.postTurn.push(entry); } - if (!pack.history.trim() && params.inputs[DIALOGUE_HISTORY_TAG]?.trim()) { - pack.history = params.inputs[DIALOGUE_HISTORY_TAG]!.trim(); + if (pack.history.length === 0) { + 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()) { pack.turn = params.inputs[CURRENT_TURN_TAG]!.trim(); @@ -135,13 +150,16 @@ export function resolveWorldInfoMarker( ): string | null { if (isStUnfilledMarker(identifier)) return null; switch (identifier) { - case WORLD_BOOK_BEFORE: { + case WORLD_BOOK_BEFORE: + case WORLD_INFO_BEFORE_MARKER: { const text = formatWorldInfoEntries(pack.worldBookBefore); return text || null; } case CHAT_HISTORY_MARKER: - return pack.history.trim() || null; - case WORLD_BOOK_AFTER: { + // 历史是 message 序列,由 assembler 按块 splice,不走字符串填洞。 + return null; + case WORLD_BOOK_AFTER: + case WORLD_INFO_AFTER_MARKER: { const text = formatWorldInfoEntries(pack.worldBookAfter); return text || null; } diff --git a/src/runtime/driver.ts b/src/runtime/driver.ts index a68041c..e43425f 100644 --- a/src/runtime/driver.ts +++ b/src/runtime/driver.ts @@ -14,6 +14,7 @@ import type { ToolDefinition, } from "../llm/client.js"; import { supportsToolStream } from "../llm/stream-complete.js"; +import type { WorldInfoPack } from "../preset/world-info-pack.js"; export type DriverToolCall = { id: string; @@ -30,6 +31,8 @@ export type DriverRunInput = { system: string; messages: ChatMessage[]; tools: ToolDefinition[]; + /** 真多轮分袋:原样透给包装层(不进上游请求体) */ + presetPack?: WorldInfoPack; caller?: string; /** 日志用短名,如「总管循环」 */ label?: string; @@ -99,12 +102,15 @@ async function completeToolsPreferStream( caller: string | undefined, label: string | undefined, callbacks: StreamCallbacks, + presetPack?: WorldInfoPack, ): Promise { - const options = { tools, caller, label }; + const options = { tools, caller, label, ...(presetPack ? { presetPack } : {}) }; if (supportsToolStream(llm)) { 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.content) callbacks.onContentDelta?.(result.content); return result; @@ -133,6 +139,7 @@ export function createLocalLlmDriver(llm: LlmProvider): AgentDriver { input.caller, `${label} 第${iteration}轮`, streamCallbacks, + input.presetPack, ); emitThinkingDone(input, result.reasoning); diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index 8fd7d6f..00e3148 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -1155,7 +1155,9 @@ export class PhaseRuntime { [SLOT_CREATION_ACCEPTED_UNITS]: accepted, [CREATION_ACCEPTED_UNITS_TAG]: acceptedRaw, [CREATION_ACCEPTED_CONTENT_TAG]: contentRaw, - ...(tag ? { [tag]: this.blackboard.getContentByTag(tag) ?? "" } : {}), + ...(stored + ? { [stored.tag]: this.blackboard.getContentByTag(stored.tag) ?? "" } + : {}), }, updatedAt: now, }; diff --git a/src/server/book-handlers.ts b/src/server/book-handlers.ts index 3eea711..7bb5ff9 100644 --- a/src/server/book-handlers.ts +++ b/src/server/book-handlers.ts @@ -693,6 +693,7 @@ export async function handleBooksApi( const body = JSON.parse(await readBody(req)) as { title?: string }; try { const book = updateBook(bookId, { title: body.title?.trim() }); + sessionManager.syncBookTitle(bookId, book.title); json(res, 200, { book }); } catch (err) { json(res, 404, { diff --git a/src/server/direct-chat-handlers.ts b/src/server/direct-chat-handlers.ts index f9cb97b..287b40a 100644 --- a/src/server/direct-chat-handlers.ts +++ b/src/server/direct-chat-handlers.ts @@ -1,20 +1,26 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import { + abortReroll, + beginReroll, createConversation, createShell, deleteConversation, deleteShell, + duplicateConversation, EMPTY_SHELL_SLOTS, getConversation, getShell, listConversations, listShells, + mergeTurnReplies, promoteTemporary, runDirectChatMultiTurn, saveConversation, + switchTurnVariant, updateConversation, updateShell, + writeRerollReplies, type DirectChatAssistantReply, type DirectChatConversation, type DirectChatMessage, @@ -40,6 +46,27 @@ function json(res: ServerResponse, status: number, data: unknown): void { 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; + 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 { return String(req.headers.accept || "").includes("application/x-ndjson"); } @@ -68,11 +95,14 @@ function createStreamingSaver( windows: DirectChatWindow[], text: string, turnId: string, + baseTurns?: DirectChatTurn[], ) { const ts = new Date().toISOString(); const replies: DirectChatAssistantReply[] = []; let timer: ReturnType | null = null; let closed = false; + let produced = false; + let turnsState = baseTurns ? structuredClone(baseTurns) : null; const saveNow = () => { if (timer) { @@ -80,6 +110,19 @@ function createStreamingSaver( timer = null; } if (closed) return; + if (turnsState) { + turnsState = writeRerollReplies( + turnsState, + turnId, + replies.map((r) => ({ ...r })), + ); + saveConversation({ + ...conversation, + windows, + turns: turnsState, + }); + return; + } const turn: DirectChatTurn = { id: turnId, user: { content: text, createdAt: ts }, @@ -108,6 +151,7 @@ function createStreamingSaver( reply = { windowId: event.windowId, content: "" }; replies.push(reply); } + if (event.delta) produced = true; if (event.field === "thinking") { reply.thinking = `${reply.thinking || ""}${event.delta}`; } else { @@ -117,11 +161,18 @@ function createStreamingSaver( }, onWindow(result: DirectChatMultiWindowResult) { const next = replyFromWindowResult(result); + if (next.content || next.thinking || next.error) produced = true; const idx = replies.findIndex((r) => r.windowId === result.windowId); if (idx >= 0) replies[idx] = next; else replies.push(next); saveNow(); }, + sawOutput() { + return produced; + }, + currentTurns() { + return turnsState; + }, close() { closed = true; if (timer) { @@ -136,7 +187,10 @@ async function writeMultiStream( res: ServerResponse, run: (hooks: MultiStreamHooks) => Promise, finish: (multi: DirectChatMultiTurnResult) => Record, -): Promise { +): Promise< + | { ok: true; multi: DirectChatMultiTurnResult } + | { ok: false; error: string } +> { res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache, no-transform", @@ -152,6 +206,7 @@ async function writeMultiStream( }); writeEvent({ type: "done", multi, ...finish(multi) }); res.end(); + return { ok: true, multi }; } catch (err) { const error = err instanceof Error ? err.message : String(err); try { @@ -160,6 +215,7 @@ async function writeMultiStream( } catch { res.destroy(); } + return { ok: false, error }; } } @@ -185,6 +241,14 @@ function slotsFromBody(body: Record): DirectChatShellSlots { typeof body.postTurn === "string" ? body.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 { name?: string; messages?: DirectChatMessage[]; + windows?: DirectChatWindow[]; + turns?: DirectChatTurn[]; }; try { const conversation = createConversation({ shellId, name: body.name, messages: body.messages, + windows: readWindows(body.windows), + turns: Array.isArray(body.turns) ? body.turns : undefined, }); json(res, 201, { conversation }); } catch (err) { @@ -342,11 +410,13 @@ export async function handleDirectChatApi( const body = JSON.parse(await readBody(req)) as { name?: string; messages?: DirectChatMessage[]; + windows?: DirectChatWindow[]; }; try { const conversation = updateConversation(conversationId, { name: body.name, messages: body.messages, + windows: readWindows(body.windows), }); json(res, 200, { conversation }); } catch (err) { @@ -369,6 +439,18 @@ export async function handleDirectChatApi( 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") { const body = JSON.parse(await readBody(req)) as { text?: string; @@ -463,6 +545,278 @@ export async function handleDirectChatApi( 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) => + 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$/); if (selectMatch && req.method === "POST") { const turnId = decodeURIComponent(selectMatch[1]!); diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index afd1419..2a7c11e 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -2179,6 +2179,13 @@ export class SessionManager { 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 / 预设设置应用到所有活跃会话 */ reloadAllLlms(): number { let count = 0; diff --git a/src/server/settings-handlers.ts b/src/server/settings-handlers.ts index e9b277e..da2915f 100644 --- a/src/server/settings-handlers.ts +++ b/src/server/settings-handlers.ts @@ -18,9 +18,12 @@ import { import { ensureActiveProfileDefault, loadAppSettings, + normalizeChatDragGain, normalizeContextTraceKeepLatest, normalizeDictateFillRetries, + normalizeMessageSquash, normalizePreferenceCollectEveryTurns, + normalizePresetToolPolicy, saveAppSettings, setActivePresetId, setActiveProfileId, @@ -41,6 +44,9 @@ import { listPresets, } from "../preset/store.js"; import type { GenerationParameters } from "../types/preset.js"; +import { + resolvePresetRuntime, +} from "../llm/preset-wrapper.js"; import { createDefaultMainAgentLlm, createLlmForPreset, @@ -140,6 +146,9 @@ export async function handleSettingsApi( contextTraceKeepLatest?: number; preferenceCollectEveryTurns?: number; dictateFillRetries?: number; + chatDragGain?: number; + messageSquash?: string; + presetToolPolicy?: string; }; const settings = loadAppSettings(); if (body.activeProfileId !== undefined) { @@ -161,6 +170,15 @@ export async function handleSettingsApi( if (body.dictateFillRetries !== undefined) { 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); json(res, 200, { settings }); return true; @@ -441,6 +459,7 @@ export async function handleSettingsApi( const wantComplete = body.complete !== false; const canComplete = wantComplete && hasRealLlmConfig(); const llm = canComplete ? createLlmForPreset(preset) : null; + const presetRuntime = resolvePresetRuntime(preset); const result = await runPresetProbe({ preset, input: { @@ -450,6 +469,10 @@ export async function handleSettingsApi( loreAfter: body.loreAfter, postTurn: body.postTurn, }, + options: { + charName: presetRuntime.charName, + squash: presetRuntime.squash, + }, complete: llm ? (messages) => llm.complete(messages, { caller: "preset-probe" }) diff --git a/src/skills/context-segments.ts b/src/skills/context-segments.ts index 1dcc731..c259633 100644 --- a/src/skills/context-segments.ts +++ b/src/skills/context-segments.ts @@ -4,6 +4,7 @@ */ import type { Blackboard } from "../blackboard/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 { CREATION_ACCEPTED_CONTENT_TAG, @@ -15,7 +16,9 @@ import { projectFragmentContent } from "./context-fragment.js"; import { tagReadFallbacks } from "./creation-flow.js"; import { buildHistoryFallback, + isCurrentTurnRef, isDialogueHistoryRef, + parseHistoryMessages, projectDialogueHistory, DIALOGUE_HISTORY_TAG, } from "./dialogue-history.js"; @@ -45,7 +48,9 @@ export function parseContextSegments(raw: unknown): ContextSegmentDef[] { const id = typeof r.id === "string" ? r.id.trim() : ""; const tier = r.tier === "dynamic" ? "dynamic" : "static"; 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; if (!id || (tags.length === 0 && !inline)) continue; @@ -249,11 +254,25 @@ function collectUserRequirementBlock(params: { ].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)。 */ -export function assembleWorkerContext(params: { +export function assembleWorkerContextSections(params: { inputs: Record; segments?: ContextSegmentDef[] | null; blackboard: Blackboard; @@ -261,7 +280,7 @@ export function assembleWorkerContext(params: { workerId: string; workerName: string; outputTags: string[]; -}): string { +}): WorkerContextSections { const inputMerge = params.inputMerge ?? "latest"; const segments = params.segments ?? []; @@ -270,7 +289,7 @@ export function assembleWorkerContext(params: { params.inputs["用户.修订说明"] ?? "" ).trim(); - return JSON.stringify( + const flat = JSON.stringify( { workerId: params.workerId, workerName: params.workerName, @@ -284,6 +303,7 @@ export function assembleWorkerContext(params: { null, 2, ); + return { chunks: [{ kind: "block", text: flat }], tail: [], flat }; } const revisionNote = readTagContent( @@ -317,7 +337,8 @@ export function assembleWorkerContext(params: { for (const tag of USER_REQUIREMENT_TAGS) skipTags.add(tag); } - const blocks: string[] = []; + const chunks: WorkerContextChunk[] = []; + const tail: string[] = []; const render = (seg: ContextSegmentDef) => { if (inheritMode && seg.id === "module-opening") return; @@ -330,11 +351,18 @@ export function assembleWorkerContext(params: { skipTags, ); if (!body) return; - if (seg.label) { - blocks.push(`${seg.label}\n\n${body}`); - } else { - blocks.push(body); + const text = seg.label ? `${seg.label}\n\n${body}` : body; + const tags = seg.tags ?? []; + if (tags.some((t) => isDialogueHistoryRef(t))) { + // 历史:既保留文本(旧扁平路径),又留下真多轮 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 重排 @@ -346,18 +374,18 @@ export function assembleWorkerContext(params: { blackboard: params.blackboard, inputMerge, }); - if (reqBlock) blocks.push(reqBlock); + if (reqBlock) tail.push(reqBlock); } // 定稿摘要:若未在 segments 中声明,仍附在末尾 const brief = params.inputs[CONTEXT_BRIEF_TAG]?.trim(); const briefInSegments = segments.some((s) => s.tags.includes(CONTEXT_BRIEF_TAG)); if (brief && !briefInSegments) { - blocks.push(`## 上下文.定稿摘要\n\n${brief}`); + tail.push(`## 上下文.定稿摘要\n\n${brief}`); } if (inheritMode && draftBlocks.length) { - blocks.push( + tail.push( [ "## 【待改底稿】", "", @@ -390,7 +418,63 @@ export function assembleWorkerContext(params: { ); } 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; + 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(), + }; } diff --git a/src/skills/dialogue-history.ts b/src/skills/dialogue-history.ts index 4b7f3e4..79dd40f 100644 --- a/src/skills/dialogue-history.ts +++ b/src/skills/dialogue-history.ts @@ -1,3 +1,5 @@ +import type { ChatMessage } from "../llm/client.js"; + /** * 对话.历史:排序表中的可投影「标签」,不是硬锚点分区。 * 规范:docs/context-fragment-design.md @@ -19,12 +21,36 @@ export function isDialogueHistoryRef(ref: string): boolean { 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:几乎不注入(占位一句) * - fields:最近约 5 段 * - summary:最近约 20 段 * - full:最近约 80 段(或整段若更短) + * + * 裁剪是「块级 + 滞回」的:段数在 n~2n 之间原样全给,超过 2n 才裁回 n。 + * 这样前缀不是每轮滑一格,而是每 n 段才断一次,缓存命中率与上下文开销取得了折中。 */ export function projectDialogueHistory( raw: string, @@ -40,12 +66,69 @@ export function projectDialogueHistory( const blocks = splitHistoryBlocks(text); const n = mode === "fields" ? 5 : mode === "summary" || mode === "brief" ? 20 : 80; - const sliced = blocks.length > n ? blocks.slice(-n) : blocks; - const body = sliced.join("\n\n"); - if (blocks.length > n) { - return `(仅最近 ${n} 段,更早已省略)\n\n${body}`; + const highWater = n * PROJECTION_HYSTERESIS; + if (blocks.length <= highWater) return blocks.join("\n\n"); + + 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 = { + 用户: "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[] { diff --git a/src/stats/pricing.ts b/src/stats/pricing.ts index 46f8a4a..6bd0333 100644 --- a/src/stats/pricing.ts +++ b/src/stats/pricing.ts @@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js"; +import { ensureNetworkProxy } from "../config/network.js"; import { recentDistinctModels } from "./token-store.js"; import { parsePricingDocument, @@ -375,6 +376,7 @@ export async function fetchAndParsePricing( } export async function fetchPricingDocument(url: string): Promise { + await ensureNetworkProxy(); const res = await fetch(url, { redirect: "follow", headers: { diff --git a/src/tts/edge.ts b/src/tts/edge.ts index 5c80c41..d2faffc 100644 --- a/src/tts/edge.ts +++ b/src/tts/edge.ts @@ -1,4 +1,6 @@ import { MsEdgeTTS, OUTPUT_FORMAT } from "msedge-tts"; + +import { ensureNetworkProxy, networkProxyAgent } from "../config/network.js"; import { readTtsCache, ttsCacheKey, writeTtsCache } from "./cache.js"; import { DEFAULT_EDGE_VOICE, @@ -60,7 +62,9 @@ async function streamToBuffer( } async function synthesizeChunk(voice: string, text: string): Promise { - 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); const { audioStream } = tts.toStream(escapeXml(text)); return streamToBuffer(audioStream); diff --git a/src/types/preset.ts b/src/types/preset.ts index 65212f9..1c7bd6d 100644 --- a/src/types/preset.ts +++ b/src/types/preset.ts @@ -43,6 +43,118 @@ export type UnsupportedPresetSection = { 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 = { id: string; name: string; @@ -50,6 +162,12 @@ export type PresetPackage = { prompts: PresetPromptEntry[]; promptOrder: PresetPromptOrderItem[]; generation: GenerationParameters; + /** 缺省视为 none(跟预设走:预设不要求整形就原样发)。 */ + messagePolicy?: PresetMessagePolicy; + /** 酒馆的 new_chat_prompt 之类;缺省不发。 */ + chatPrompts?: PresetChatPrompts; + /** 角色卡字段的包裹模板;缺省原样发。 */ + formats?: PresetFormats; unsupported: UnsupportedPresetSection[]; importedAt: string; raw?: unknown; @@ -63,4 +181,9 @@ export type PresetImportReport = { missingIdentifiers: string[]; generationFields: string[]; warnings: string[]; + /** 选中的 prompt_order 属于哪个 character_id */ + orderCharacterId: number | null; + /** 选中的顺序缺了哪些核心位置(main / chatHistory / jailbreak) */ + missingCoreIdentifiers: string[]; + diagnostics: PresetDiagnostics; }; diff --git a/src/worker/executor.ts b/src/worker/executor.ts index ebdd8fd..f1e7c80 100644 --- a/src/worker/executor.ts +++ b/src/worker/executor.ts @@ -19,7 +19,15 @@ import { selectJsonPayload, tryParseJsonDoc, } 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 { isNarratorEnabled } from "../skills/play-slots.js"; import { @@ -865,21 +873,21 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise m.role === "system"); @@ -888,6 +896,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise 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 供单测 */ export function parseWorkerResponseForTest( raw: string, diff --git a/src/worker/gm-harness.ts b/src/worker/gm-harness.ts index 23437ac..636d079 100644 --- a/src/worker/gm-harness.ts +++ b/src/worker/gm-harness.ts @@ -24,6 +24,8 @@ export type GmHarnessParams = { llm: LlmProvider; system: string; messages: ChatMessage[]; + /** 真多轮分袋(转给包装层,使历史以 message[] 进 chatHistory 洞) */ + presetPack?: import("../preset/world-info-pack.js").WorldInfoPack; stream?: GmHarnessStreamCallbacks; maxIterations?: number; caller?: string; @@ -50,6 +52,7 @@ export async function runGmHarness( tools: GM_TOOL_DEFINITIONS, caller: params.caller ?? "worker:world-simulator", label, + presetPack: params.presetPack, maxIterations: params.maxIterations ?? DEFAULT_GM_HARNESS_MAX_ITERATIONS, onThinkingDelta: (delta) => params.stream?.onThinkingDelta?.(delta), onOutputDelta: (delta) => params.stream?.onOutputDelta?.(delta), diff --git a/tests/chat-keep-stats.test.ts b/tests/chat-keep-stats.test.ts new file mode 100644 index 0000000..3d0f38c --- /dev/null +++ b/tests/chat-keep-stats.test.ts @@ -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); + }); +}); diff --git a/tests/chat-scroll.test.ts b/tests/chat-scroll.test.ts new file mode 100644 index 0000000..2edaf23 --- /dev/null +++ b/tests/chat-scroll.test.ts @@ -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 }); + }); +}); diff --git a/tests/dialogue-history.test.ts b/tests/dialogue-history.test.ts index 30c3f28..c820773 100644 --- a/tests/dialogue-history.test.ts +++ b/tests/dialogue-history.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { appendDialogueHistoryTurn, + parseHistoryMessages, projectDialogueHistory, buildHistoryFallback, isDialogueHistoryRef, @@ -14,18 +15,78 @@ import { synthesizeContextOrderFromWorkers, } 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", () => { it("recognizes history refs and projects by level", () => { expect(isDialogueHistoryRef("对话.历史")).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", ); const summary = projectDialogueHistory(long, "summary"); expect(summary).toContain("仅最近"); + // 裁到 n 段(summary = 20) + expect(summary.split("## 用户").length - 1).toBe(20); 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", () => { const bb = new Blackboard(); bb.write({ diff --git a/tests/direct-chat.test.ts b/tests/direct-chat.test.ts index adcd924..23f2c8b 100644 --- a/tests/direct-chat.test.ts +++ b/tests/direct-chat.test.ts @@ -8,18 +8,25 @@ import { assembleDirectChatMessages, buildChatHistory, buildDirectChatTaskMessages, + abortReroll, + beginReroll, buildPriorForWindow, createConversation, createShell, DEFAULT_DIRECT_CHAT_SYSTEM, + duplicateConversation, EMPTY_SHELL_SLOTS, getConversation, + mergeTurnReplies, + switchTurnVariant, + writeRerollReplies, getShell, listConversations, listShells, promoteTemporary, resolveDirectChatPreset, runDirectChatTurn, + type DirectChatTurn, } from "../src/direct-chat/index.js"; function samplePreset() { @@ -83,13 +90,13 @@ describe("direct-chat history assembly", () => { 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({ message: "hi", slots: EMPTY_SHELL_SLOTS, 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.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", () => { @@ -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); + }); +}); diff --git a/tests/message-squash.test.ts b/tests/message-squash.test.ts new file mode 100644 index 0000000..72bc44e --- /dev/null +++ b/tests/message-squash.test.ts @@ -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"]); + }); +}); diff --git a/tests/persona-store.test.ts b/tests/persona-store.test.ts index da7dd66..812e998 100644 --- a/tests/persona-store.test.ts +++ b/tests/persona-store.test.ts @@ -20,11 +20,11 @@ describe("persona store", () => { } = await import("../src/persona/store.js"); const list = listPersonas(); - expect(list.map((p) => p.name)).toEqual(["玩家", CREATION_PLACEHOLDER_NAME]); - expect(getActivePersona()?.name).toBe("玩家"); + expect(list.map((p) => p.name)).toEqual(["User", CREATION_PLACEHOLDER_NAME]); + expect(getActivePersona()?.name).toBe("User"); expect(getCreationDefaultPersona()?.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 () => { diff --git a/tests/play-frame.test.ts b/tests/play-frame.test.ts index 49c946f..4cb9882 100644 --- a/tests/play-frame.test.ts +++ b/tests/play-frame.test.ts @@ -1,12 +1,19 @@ import { describe, expect, it } from "vitest"; +import type { ChatMessage } from "../src/llm/client.js"; import { importSillyTavernPreset } from "../src/preset/importer.js"; -import { assemblePlayWorkerMessages } from "../src/preset/play-frame.js"; -import { assemblePresetMessages } from "../src/preset/assembler.js"; +import { + 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"; const pack: WorldInfoPack = { worldBookBefore: [{ id: "lore", name: "舞台", content: "古镇夜雨" }], - history: "用户:昨天来过", + history: [ + { role: "assistant", content: "昨夜灯灭" }, + { role: "user", content: "昨天来过" }, + ], worldBookAfter: [{ id: "vars", name: "变量", content: "HP 3" }], turn: "我推门", postTurn: [{ id: "tail", name: "尾注", content: "勿剧透" }], @@ -52,12 +59,14 @@ describe("assemblePlayWorkerMessages", () => { }); expect(messages.map((m) => [m.role, m.content])).toEqual([ - ["system", "MAIN"], + // 任务契约并进 main,不再单独一条 + ["system", "MAIN\n\nSKILL"], [ "system", - "### 任务契约\n\nSKILL\n\n### 舞台\n\n古镇夜雨", + "### 舞台\n\n古镇夜雨", ], - ["system", "用户:昨天来过"], + ["assistant", "昨夜灯灭"], + ["user", "昨天来过"], ["system", "### 变量\n\nHP 3"], ["user", "我推门"], ["system", "PHI"], @@ -103,7 +112,7 @@ describe("assemblePlayWorkerMessages", () => { preset: report.preset, pack: { worldBookBefore: [{ id: "lore", name: "设定", content: "港口" }], - history: "昨日对话", + history: [{ role: "user", content: "昨日对话" }], worldBookAfter: [], turn: "本轮", postTurn: [], @@ -117,7 +126,7 @@ describe("assemblePlayWorkerMessages", () => { ).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({ prompts: [ { @@ -148,7 +157,8 @@ describe("assemblePlayWorkerMessages", () => { if (id === "chatHistory") return "HIST"; 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", () => { @@ -189,7 +199,7 @@ describe("assemblePlayWorkerMessages", () => { preset: report.preset, pack: { worldBookBefore: [], - history: "昨日", + history: [{ role: "assistant", content: "昨日" }], worldBookAfter: [], turn: "本轮", postTurn: [], @@ -197,6 +207,98 @@ describe("assemblePlayWorkerMessages", () => { persona: { name: "@玩家", description: "占位人设" }, }); 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"); }); }); diff --git a/tests/preset-chat-prompts.test.ts b/tests/preset-chat-prompts.test.ts new file mode 100644 index 0000000..43821c1 --- /dev/null +++ b/tests/preset-chat-prompts.test.ts @@ -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 = {}) { + 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("示例对话按 切段,带 example_user / example_assistant", () => { + const messages = assemble({ + exampleDialogue: + "\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: "\nA: 一\nB: 二\n\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: "\n{{personality}}\n", + scenario_format: "\n{{scenario}}\n", + 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("\nPERS\n"); + expect(texts).toContain("\nSCEN\n"); + }); + + it("槽位为空就整条不发", () => { + expect(build(undefined)).toEqual([]); + }); +}); diff --git a/tests/preset-conformance.test.ts b/tests/preset-conformance.test.ts new file mode 100644 index 0000000..2353029 --- /dev/null +++ b/tests/preset-conformance.test.ts @@ -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; + +/** 一份「把该有的都放上」的 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>; + 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>; + 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> = []; + const provider: LlmProvider = { + async complete(_messages, options): Promise { + seen.push((options?.generation ?? {}) as Record); + 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 }); + }); +}); diff --git a/tests/preset-diagnostics.test.ts b/tests/preset-diagnostics.test.ts new file mode 100644 index 0000000..17261f6 --- /dev/null +++ b/tests/preset-diagnostics.test.ts @@ -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> = {}, +) { + return { + identifier: id, + name: id, + role: "system", + content, + enabled: true, + ...extra, + }; +} + +function rawPreset(prompts: unknown[], extras: Record = {}) { + 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", "开局"), + entry("b", "中间"), + entry("c", "收尾"), + ]), + { 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", "正文")]), + { name: "t" }, + ); + expect(report.diagnostics.tags.unclosed).toEqual([ + { tag: "game", openIn: "a" }, + ]); + }); + + it("ignores self-closing tags", () => { + const report = importSillyTavernPreset( + rawPreset([entry("a", "换行
结束")]), + { 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([]); + }); +}); diff --git a/tests/preset-macros.test.ts b/tests/preset-macros.test.ts new file mode 100644 index 0000000..3c968f6 --- /dev/null +++ b/tests/preset-macros.test.ts @@ -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}}>正文" }]); + 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"); + }); +}); diff --git a/tests/preset-probe-tools.test.ts b/tests/preset-probe-tools.test.ts new file mode 100644 index 0000000..0c10a9e --- /dev/null +++ b/tests/preset-probe-tools.test.ts @@ -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"); + } + } + }); +}); diff --git a/tests/preset-probe.test.ts b/tests/preset-probe.test.ts index f46f6b9..4542810 100644 --- a/tests/preset-probe.test.ts +++ b/tests/preset-probe.test.ts @@ -41,16 +41,16 @@ function samplePreset() { describe("assemblePresetProbe", () => { it("fills the sandwich with sample lore, history, and the current turn", () => { - const messages = assemblePresetProbe(samplePreset(), { - message: "我推开门。", - }); + const messages = assemblePresetProbe( + samplePreset(), + { message: "我推开门。" }, + { squash: "none" }, + ); expect(messages.map((m) => [m.role, m.content])).toEqual([ - ["system", "MAIN"], - [ - "system", - `### 任务契约\n\n${PROBE_SYSTEM_PROMPT}\n\n### 试跑设定\n\n${DEFAULT_PROBE_CONTEXT.loreBefore}`, - ], + // 任务契约并进预设自有的 main 条目,不再单独一条 + ["system", `MAIN\n\n${PROBE_SYSTEM_PROMPT}`], + ["system", `### 试跑设定\n\n${DEFAULT_PROBE_CONTEXT.loreBefore}`], ["system", DEFAULT_PROBE_CONTEXT.history], ["user", "我推开门。"], ["system", "PHI"], @@ -58,17 +58,20 @@ describe("assemblePresetProbe", () => { }); it("omits empty optional bags", () => { - const messages = assemblePresetProbe(samplePreset(), { - message: "你好", - loreBefore: "", - history: "", - loreAfter: "", - postTurn: "", - }); + const messages = assemblePresetProbe( + samplePreset(), + { + message: "你好", + loreBefore: "", + history: "", + loreAfter: "", + postTurn: "", + }, + { squash: "none" }, + ); expect(messages.map((m) => m.content)).toEqual([ - "MAIN", - `### 任务契约\n\n${PROBE_SYSTEM_PROMPT}`, + `MAIN\n\n${PROBE_SYSTEM_PROMPT}`, "你好", "PHI", ]); @@ -84,7 +87,10 @@ describe("runPresetProbe", () => { expect(result.completed).toBe(false); 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 () => { diff --git a/tests/preset-regex.test.ts b/tests/preset-regex.test.ts new file mode 100644 index 0000000..d7f368f --- /dev/null +++ b/tests/preset-regex.test.ts @@ -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: "/[\\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("前言a后记", { + ...base, + replaceString: "[$1]", + findRegex: "/(.*?)<\\/choices>/g", + }), + ).toBe("前言[a]后记"); + expect( + runRegexScript("abc", { + ...base, + replaceString: "<<$>>", + findRegex: "/(?b)/g", + }), + ).toBe("a<>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: "/[\\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: "回答ABC" }, + ], + worldBookAfter: [], + turn: "", + postTurn: [], + }, + persona: null, + charName: null, + }); + expect(messages.map((m) => m.content)).toEqual(["您好", "回答"]); + expect(messages.map((m) => m.role)).toEqual(["user", "assistant"]); + }); +}); diff --git a/tests/preset-tool-declaration.test.ts b/tests/preset-tool-declaration.test.ts new file mode 100644 index 0000000..06cbf7f --- /dev/null +++ b/tests/preset-tool-declaration.test.ts @@ -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; 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(); + }); +}); diff --git a/tests/preset-tool-policy.test.ts b/tests/preset-tool-policy.test.ts new file mode 100644 index 0000000..b58ea48 --- /dev/null +++ b/tests/preset-tool-policy.test.ts @@ -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; + }).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; + // 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; + expect(args.content).toBe("正文"); + expect(args).not.toHaveProperty("display_stream"); + }); +}); diff --git a/tests/preset-wrapper.test.ts b/tests/preset-wrapper.test.ts index 1151212..9f66df8 100644 --- a/tests/preset-wrapper.test.ts +++ b/tests/preset-wrapper.test.ts @@ -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 { ChatMessage, CompleteResult, @@ -25,6 +40,8 @@ const samplePreset: PresetPackage = { ], promptOrder: [{ promptId: "sys", enabled: true, orderIndex: 0 }], generation: { temperature: 0.91, maxOutputTokens: 1234, reasoningEffort: "max" }, + // 这组用例测的是夹心,不是整形;整形单独有用例。 + messagePolicy: { squash: "none" }, unsupported: [], importedAt: "2026-01-01T00:00:00.000Z", }; @@ -163,4 +180,18 @@ describe("wrapLlmForSession", () => { await llm.complete([{ role: "user", content: "TASK" }]); 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"); + }); }); diff --git a/tests/truncation.test.ts b/tests/truncation.test.ts new file mode 100644 index 0000000..8413c98 --- /dev/null +++ b/tests/truncation.test.ts @@ -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("收尾"); + }); +}); diff --git a/tests/world-info-pack.test.ts b/tests/world-info-pack.test.ts index add791f..4d358b5 100644 --- a/tests/world-info-pack.test.ts +++ b/tests/world-info-pack.test.ts @@ -58,7 +58,7 @@ describe("worldInfoPackFromSegments", () => { blackboard: bb, }); 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.turn).toBe("我推门进去"); expect(pack.postTurn.map((e) => e.content)).toEqual(["尾注"]); diff --git a/tools/st-request-diff/README.md b/tools/st-request-diff/README.md index 635448a..e3b68c8 100644 --- a/tools/st-request-diff/README.md +++ b/tools/st-request-diff/README.md @@ -53,15 +53,48 @@ ## 已确认的差距(按优先级) 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 万字。 -3. **`squash_system_messages` 语义**(确定 bug):酒馆只合并**相邻 system**(跳过带 `name` 的、跳过 `newMainChat/newChat/groupNudge`),本仓库按"同角色合并"把 `结尾(框架闭合)`、`/历史记录`、`你好` 三条 user 合成一条。 +2. **`prompts[].enabled` 双闸**(已修:装配只认 `prompt_order.enabled`):酒馆只用 `prompt_order.enabled`(`openai.js` 从不读 `prompts[].enabled`),本仓库要求两者都开 → linux_1.3 少发 `<世界设定与背景>`、`作者简介 古典武侠`、`结尾` 共约 1 万字。 +3. **`squash_system_messages` 语义**(已修:新增 `system` 档,只合并相邻 system):酒馆只合并**相邻 system**(跳过带 `name` 的、跳过 `newMainChat/newChat/groupNudge`),本仓库按"同角色合并"把 `结尾(框架闭合)`、`/历史记录`、`你好` 三条 user 合成一条。 4. **角色卡插槽 + 格式模板**:`charDescription/charPersonality/scenario` 及 `personality_format/scenario_format` 包装(如 `…`)。 5. **示例对话**:`new_example_chat_prompt` 打头,`mes_example` 按 `` 切段成多条 system,并带 `name=example_user/example_assistant`。 6. **`new_chat_prompt`**:空聊天时插一条 `[Start a new Chat]` / `[NVWA chat history begins below.]`。 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`、`$`)、`trimStrings`、 +`placement`、`promptOnly`/`markdownOnly`、`minDepth`/`maxDepth`、`disabled`、`substituteRegex`(0/1; +2 按 RAW 处理并标记)。 + +实测(linux_1.3,两轮对话):上一轮助手回复 `回答正文A、B、C` → +发出的请求里变成 `回答正文`(预设的「去」promptOnly 正则生效); +上一轮用户消息被预设的「userinput」正则改写成 `【HISTORICAL_USER_INPUT】【LAST_USER_INPUT】…`。 + ## 决策记录 - **采样参数跟默认走**:预设缺 `temperature/top_p/max_tokens` 时,本仓库回落到 API 配置默认即可(酒馆是"缺就不改,沿用上次的值",不做对齐)。所以 `NVWA-Galaxia-ST` 报告里那行参数差异算已知取舍。 - `names_behavior`(`NONE:-1/DEFAULT:0/COMPLETION:1/CONTENT:2`)只影响**聊天历史**是否加 `名字: ` 前缀,三个预设都是 0,单角色下无效果 → 暂不实现。 - `system_prompt:true` 标的是 ST 的**内置槽位**(不可删/不在用户列表/不参与深度注入,落位由 `openai.js` 里 `systemPrompts=['nsfw','jailbreak']` 等硬编码决定),不是位置规则;当前三份抓包里两边位置一致 → 不是当前核心差距。 + diff --git a/tools/st-request-diff/allow-preset-scripts.mjs b/tools/st-request-diff/allow-preset-scripts.mjs new file mode 100644 index 0000000..ac1e677 --- /dev/null +++ b/tools/st-request-diff/allow-preset-scripts.mjs @@ -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(); diff --git a/tools/st-request-diff/attach-send.mjs b/tools/st-request-diff/attach-send.mjs index 11c10af..914d2d1 100644 --- a/tools/st-request-diff/attach-send.mjs +++ b/tools/st-request-diff/attach-send.mjs @@ -29,6 +29,20 @@ const out = await evaluate(page, `(async () => { jq("#settings_preset_openai").val(String(names[want])).trigger("change"); 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 }); 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 }; diff --git a/tools/st-request-diff/cmp-msg.mjs b/tools/st-request-diff/cmp-msg.mjs new file mode 100644 index 0000000..8d02a56 --- /dev/null +++ b/tools/st-request-diff/cmp-msg.mjs @@ -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))); diff --git a/tools/st-request-diff/diff.mjs b/tools/st-request-diff/diff.mjs index cb33e58..ffa83d0 100644 --- a/tools/st-request-diff/diff.mjs +++ b/tools/st-request-diff/diff.mjs @@ -86,7 +86,20 @@ const fmt = (v) => { if (v === undefined) return "(无)"; if (v === null) return "null"; 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); return String(v); }; @@ -166,6 +179,72 @@ function unattributed(entries, messages) { 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() { const args = readArgs(process.argv.slice(2)); if (!args.st || !args.wa) throw new Error("缺少 --st / --wa"); @@ -209,6 +288,13 @@ function main() { lines.push(""); lines.push(messageTable("本仓库", waMsgs, entries)); 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(""); lines.push("酒馆:"); @@ -218,6 +304,10 @@ function main() { lines.push("本仓库:"); lines.push(""); 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(""); mkdirSync(args.out, { recursive: true }); @@ -232,6 +322,7 @@ function main() { console.log("[diff] 报告: " + reportFile); console.log("[diff] 字段: " + fields.length + " 项, 仅酒馆 " + onlySt + ", 仅本仓库 " + onlyWa + ", 不同 " + diffCount); 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 (entriesOnlyWa.length) console.log("[diff] 只在本仓库出现的预设条目: " + entriesOnlyWa.map((e) => e.name).join("、")); } diff --git a/tools/st-request-diff/drive-wa.mjs b/tools/st-request-diff/drive-wa.mjs index 239b49d..0ee29a0 100644 --- a/tools/st-request-diff/drive-wa.mjs +++ b/tools/st-request-diff/drive-wa.mjs @@ -19,6 +19,14 @@ const DEFAULTS = { port: 23377, out: path.join(HERE, "captures"), dataRoot: path.join(HERE, ".wa-data"), + charName: "ProbeCard", + toolPolicy: "adapt", + exampleDialogue: + "\n{{user}}: EX_USER_TOKEN 今天做什么?\n{{char}}: EX_CHAR_TOKEN 读书。", + // 跟酒馆那张探针卡的三块字段一致 + charDescription: "DESC_TOKEN 这是一张测试角色卡的描述,用来确认酒馆把它放在哪一段。", + charPersonality: "PERS_TOKEN 冷静、话少、只答问题。", + scenario: "SCEN_TOKEN 一间安静的书房,只有两个人在说话。", timeoutMs: 120000, }; @@ -33,6 +41,10 @@ async function postJson(url, body) { async function main() { 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"); const presetName = path.basename(args.preset, ".json"); 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"); + // 预设工具协议: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 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 = []; @@ -77,13 +107,28 @@ async function main() { text: args.message, presetId, 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" }], systemPrompt: "", loreBefore: "", seedHistory: "", loreAfter: "", 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 first = result.multi && result.multi.results && result.multi.results[0]; diff --git a/tools/st-request-diff/inspect-tools-decl.mjs b/tools/st-request-diff/inspect-tools-decl.mjs new file mode 100644 index 0000000..1fb64b1 --- /dev/null +++ b/tools/st-request-diff/inspect-tools-decl.mjs @@ -0,0 +1,27 @@ +// 用本仓库的声明式工具代码看某个预设会声明出什么 tools(不发请求)。 +// 用法: node node_modules/tsx/dist/cli.mjs tools/st-request-diff/inspect-tools-decl.mjs +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)); + } +} diff --git a/tools/st-request-diff/peek-dup.mjs b/tools/st-request-diff/peek-dup.mjs new file mode 100644 index 0000000..0148538 --- /dev/null +++ b/tools/st-request-diff/peek-dup.mjs @@ -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); +} diff --git a/tools/st-request-diff/peek-formats.mjs b/tools/st-request-diff/peek-formats.mjs new file mode 100644 index 0000000..35d55cf --- /dev/null +++ b/tools/st-request-diff/peek-formats.mjs @@ -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))); + } +} diff --git a/tools/st-request-diff/peek-tool-params.mjs b/tools/st-request-diff/peek-tool-params.mjs new file mode 100644 index 0000000..57cec85 --- /dev/null +++ b/tools/st-request-diff/peek-tool-params.mjs @@ -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); diff --git a/tools/st-request-diff/peek-tools-shape.mjs b/tools/st-request-diff/peek-tools-shape.mjs new file mode 100644 index 0000000..b0f35fa --- /dev/null +++ b/tools/st-request-diff/peek-tools-shape.mjs @@ -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) : "(无)"); +} diff --git a/tools/st-request-diff/preset-accounting.mjs b/tools/st-request-diff/preset-accounting.mjs new file mode 100644 index 0000000..357e26a --- /dev/null +++ b/tools/st-request-diff/preset-accounting.mjs @@ -0,0 +1,31 @@ +// 一个预设到底有多少内容、哪些进了请求:启用条目正文总量 / 最大条目 / 会改请求的正则。 +// 用法: node node_modules/tsx/dist/cli.mjs tools/st-request-diff/preset-accounting.mjs +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); diff --git a/tools/st-request-diff/show-msgs.mjs b/tools/st-request-diff/show-msgs.mjs new file mode 100644 index 0000000..e217b89 --- /dev/null +++ b/tools/st-request-diff/show-msgs.mjs @@ -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))); +}); diff --git a/web/chat-keep-stats.js b/web/chat-keep-stats.js new file mode 100644 index 0000000..b0ef0e6 --- /dev/null +++ b/web/chat-keep-stats.js @@ -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); +} diff --git a/web/chat-scroll.js b/web/chat-scroll.js new file mode 100644 index 0000000..c2dbfd1 --- /dev/null +++ b/web/chat-scroll.js @@ -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; +} diff --git a/web/chat.css b/web/chat.css index 5d90fc5..a7d33a5 100644 --- a/web/chat.css +++ b/web/chat.css @@ -1,11 +1,85 @@ .chat-shell { + --rail-left: minmax(200px, 240px); + --rail-right: minmax(260px, 320px); 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; 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 { + grid-column: 1; + position: relative; display: flex; flex-direction: column; gap: 10px; @@ -123,6 +197,14 @@ 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 { flex-shrink: 0; border: 0; @@ -130,13 +212,28 @@ color: var(--st-faint); cursor: pointer; padding: 2px 4px; + font: inherit; 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); } +.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 { margin: 0; font-size: 11px; @@ -145,6 +242,8 @@ } .chat-main { + grid-column: 2; + position: relative; display: flex; flex-direction: column; min-width: 0; @@ -202,6 +301,8 @@ } .chat-context { + grid-column: 3; + position: relative; display: flex; flex-direction: column; min-width: 0; @@ -212,18 +313,12 @@ .chat-context-head { flex-shrink: 0; - padding: 12px 14px 8px; + padding: 10px 14px 8px; border-bottom: 1px solid var(--st-border); } -.chat-context-head h2 { - margin: 0; - font-size: 14px; - font-weight: 650; -} - .chat-context-sub { - margin: 4px 0 0; + margin: 0; font-size: 11px; color: var(--st-muted); line-height: 1.35; @@ -266,9 +361,139 @@ .chat-shell-editor-actions { display: flex; justify-content: flex-end; + gap: 8px; 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 { position: relative; @@ -487,6 +712,100 @@ 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 { font-size: 10px; line-height: 1.2; @@ -506,6 +825,32 @@ 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 { display: flex; align-items: center; @@ -590,17 +935,18 @@ width: 100%; } -.chat-reply-card.is-collapsed { +.chat-reply-card.is-collapsed, +.chat-reply-card.is-capped { 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; min-height: 0; 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; min-height: 0; height: 100%; @@ -609,7 +955,7 @@ 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; } @@ -630,6 +976,24 @@ 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 { border-color: var(--st-accent); background: color-mix(in srgb, var(--st-accent-soft) 55%, var(--st-surface)); @@ -653,6 +1017,13 @@ min-width: 0; } +.chat-reply-card-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + .chat-reply-card-name { display: block; font-size: 13px; @@ -673,6 +1044,44 @@ 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 { flex: 1 1 auto; min-height: 0; @@ -694,6 +1103,25 @@ .chat-reply-card-text { 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 { @@ -824,7 +1252,10 @@ outline: none; } +.chat-copy-btn, .chat-remove-btn, +.chat-retry-btn, +.chat-regen-btn, .chat-select-btn { border: 1px solid var(--st-border-strong); border-radius: 6px; @@ -837,15 +1268,38 @@ cursor: pointer; } +.chat-copy-btn:hover, .chat-remove-btn:hover, +.chat-retry-btn:hover, +.chat-regen-btn:hover, .chat-select-btn:hover, +.chat-copy-btn:focus-visible, .chat-remove-btn:focus-visible, +.chat-retry-btn:focus-visible, +.chat-regen-btn:focus-visible, .chat-select-btn:focus-visible { border-color: var(--st-accent); background: var(--st-accent-soft); 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 { border-color: var(--st-accent); background: var(--st-accent-soft); @@ -853,15 +1307,44 @@ font-weight: 650; } -.chat-bubble-thinking { +/* 思维链:默认折叠成一行,点开才铺开正文 */ +.chat-think { margin: 0 0 8px; - padding: 8px; + border: 1px dashed var(--st-border-strong); border-radius: 6px; background: color-mix(in srgb, var(--st-bg) 80%, transparent); - color: var(--st-muted); 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 { position: relative; z-index: 1; @@ -1062,6 +1545,33 @@ 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 { display: flex; justify-content: flex-end; @@ -1079,8 +1589,30 @@ display: flex; align-items: center; gap: 8px; + min-width: 0; color: var(--st-text); 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 { @@ -1122,7 +1654,8 @@ @media (max-width: 1100px) { .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; } + .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 { border-right: 0; border-bottom: 1px solid var(--st-border); diff --git a/web/chat.html b/web/chat.html index 912d134..75596d7 100644 --- a/web/chat.html +++ b/web/chat.html @@ -45,9 +45,32 @@

未选存档即为快速对话(浏览器缓存,最多 2 条),可保存为持久会话。

+
+ +
@@ -87,10 +110,40 @@
@@ -153,17 +252,42 @@ + +
+

新建会话

+

勾选要同时回复的模型,可以多选。未勾选也能创建,之后再加。

+ +
+ 模型 +
+ +
+
+ + +
+
+
+

新增窗口

-

选一个 API 配置,新增对照模型;发送时各模型同时回复。

- +

勾选要加入的模型,可以多选。已经在会话里的不能再选。

+
+ 模型 +
+ +
- +
diff --git a/web/chat.js b/web/chat.js index 3927cbc..9560b5a 100644 --- a/web/chat.js +++ b/web/chat.js @@ -1,6 +1,26 @@ import { renderMarkdownToHtml } from "./markdown.js"; +import { planWheelScroll } from "./chat-scroll.js"; +import { + bumpKeepStats, + clearKeepStats, + copyKeepStats, + keepStatPercent, + keepStatTotal, + readKeepStats, +} from "./chat-keep-stats.js"; const TEMP_STORAGE_KEY = "wa-direct-chat-temps-v2"; +const COPY_MODE_STORAGE_KEY = "wa-direct-chat-copy-mode-v1"; +const RAILS_STORAGE_KEY = "wa-direct-chat-hide-rails-v1"; +/** + * 上次打开的对话:只放 sessionStorage。刷新页面回来还在原对话, + * 重启应用(新的 run)就从头开始,不把旧位置带过去。 + */ +const OPEN_CONVERSATION_KEY = "wa-direct-chat-open-conversation-v1"; +/** 右侧面板上次看的页签:shell = 上下文,keep = 保留统计 */ +const CONTEXT_TAB_STORAGE_KEY = "wa-direct-chat-context-tab-v1"; +/** 拖动增益:鼠标移 100px 实际滚动多少 px;可在「设置 · 直聊」里改 */ +let dragGain = 1.5; const MAX_TEMPS = 2; const EMPTY_SLOTS = { systemPrompt: "", @@ -8,6 +28,11 @@ const EMPTY_SLOTS = { seedHistory: "", loreAfter: "", postTurn: "", + charName: "", + charDescription: "", + charPersonality: "", + scenario: "", + exampleDialogue: "", }; const $ = (id) => document.getElementById(id); @@ -18,30 +43,53 @@ const els = { room: $("chat-room"), tempMark: $("chat-temp-mark"), arena: $("chat-arena"), + shell: document.querySelector(".chat-shell"), + btnHideLeft: $("btn-hide-left"), + btnHideRight: $("btn-hide-right"), + btnShowLeft: $("btn-show-left"), + btnShowRight: $("btn-show-right"), more: $("chat-more"), morePanel: $("chat-more-panel"), input: $("chat-input"), status: $("chat-status"), shellEditor: $("shell-editor"), contextSub: $("context-sub"), + tabShell: $("tab-shell"), + tabKeep: $("tab-keep"), + keepPanel: $("keep-stats"), + keepHint: $("keep-stats-hint"), + keepList: $("keep-stats-list"), + btnClearKeep: $("btn-clear-keep"), btnNewShell: $("btn-new-shell"), btnSaveTemp: $("btn-save-temp"), btnNewConv: $("btn-new-conv"), btnSend: $("btn-send"), btnSaveShell: $("btn-save-shell"), + btnClearShell: $("btn-clear-shell"), shellName: $("shell-name"), shellSystem: $("shell-system"), shellLoreBefore: $("shell-lore-before"), shellSeedHistory: $("shell-seed-history"), shellLoreAfter: $("shell-lore-after"), shellPostTurn: $("shell-post-turn"), + shellCharName: $("shell-char-name"), + shellCharDesc: $("shell-char-desc"), + shellCharPers: $("shell-char-pers"), + shellScenario: $("shell-scenario"), + shellExamples: $("shell-examples"), dialogPromote: $("dialog-promote"), formPromote: $("form-promote"), promoteShellName: $("promote-shell-name"), promoteConvName: $("promote-conv-name"), dialogAddWindow: $("dialog-add-window"), formAddWindow: $("form-add-window"), - addWindowProfile: $("add-window-profile"), + addWindowModels: $("add-window-models"), + addWindowModelsEmpty: $("add-window-models-empty"), + dialogNewConv: $("dialog-new-conv"), + formNewConv: $("form-new-conv"), + newConvName: $("new-conv-name"), + newConvModels: $("new-conv-models"), + newConvModelsEmpty: $("new-conv-models-empty"), }; /** @type {"root"|"shell"|"conversation"} */ @@ -65,14 +113,42 @@ let conversations = []; /** @type {any[]} */ let profileList = []; let busy = false; +/** 正在编辑的用户原话;重 roll 前可改 */ +let editingTurnId = null; +let editingDraft = ""; +/** 复制模式:开=回复可选中、显示复制按钮;关=整张卡片都能拖动(禁用复制) */ +let copyMode = loadCopyMode(); +/** 侧栏收起状态:left = 对话列表,right = 上下文面板 */ +let railLeftHidden = false; +let railRightHidden = false; +/** 右侧面板当前页签:shell = 上下文,keep = 保留统计 */ +let contextTab = loadContextTab(); /** 盲测:藏起模型名,并用另一套左右顺序展示 */ let blindModels = false; /** @type {string[]} */ let blindOrder = []; +/** 名字已全部显示,但位置仍是打乱后的顺序(复原只露名字,不还原位置) */ +let blindNamesShown = false; +/** 盲测下点了「揭示」的卡片:`turnId:windowId`,只作用于这一张 */ +const revealedCards = new Set(); /** @type {Set} */ const expandedCards = new Set(); +/** 思维链折叠条:`turnId:windowId` → 用户手动开合(true 展开 / false 收起) */ +const thinkingToggles = new Map(); const MIN_CARD_H = 120; +/** + * 一行的折叠高度:至少两张都能撑到的最大高度。 + * 从小到大排好后取第 min(2, n−1) 短:两张即最短,更多即次短。 + * 单独更矮的一张拉不低整行。 + */ +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]); +} + async function api(path, options = {}) { const res = await fetch(path, { headers: { "Content-Type": "application/json", ...(options.headers || {}) }, @@ -91,6 +167,122 @@ async function api(path, options = {}) { return data; } +function loadCopyMode() { + try { + return localStorage.getItem(COPY_MODE_STORAGE_KEY) !== "0"; + } catch { + return true; + } +} + +function saveCopyMode(enabled) { + try { + localStorage.setItem(COPY_MODE_STORAGE_KEY, enabled ? "1" : "0"); + } catch { + /* 存不进 localStorage 也不影响当前会话 */ + } +} + +function loadContextTab() { + try { + return localStorage.getItem(CONTEXT_TAB_STORAGE_KEY) === "keep" + ? "keep" + : "shell"; + } catch { + return "shell"; + } +} + +function saveContextTab(tab) { + try { + localStorage.setItem(CONTEXT_TAB_STORAGE_KEY, tab); + } catch { + /* 存不进 localStorage 也不影响当前会话 */ + } +} + +function loadRailsState() { + try { + const raw = JSON.parse(localStorage.getItem(RAILS_STORAGE_KEY) || "{}"); + return { left: raw?.left === true, right: raw?.right === true }; + } catch { + return { left: false, right: false }; + } +} + +const railsState = loadRailsState(); +railLeftHidden = railsState.left; +railRightHidden = railsState.right; + +function saveRailsState() { + try { + localStorage.setItem( + RAILS_STORAGE_KEY, + JSON.stringify({ left: railLeftHidden, right: railRightHidden }), + ); + } catch { + /* 忽略持久化失败 */ + } +} + +/** 把收起状态同步到网格与把手按钮上 */ +function syncRails() { + if (els.shell) { + els.shell.classList.toggle("is-left-hidden", railLeftHidden); + els.shell.classList.toggle("is-right-hidden", railRightHidden); + } + if (els.btnShowLeft) els.btnShowLeft.hidden = !railLeftHidden; + if (els.btnShowRight) els.btnShowRight.hidden = !railRightHidden; +} + +function toggleRail(side) { + const left = side === "left"; + if (left) railLeftHidden = !railLeftHidden; + else railRightHidden = !railRightHidden; + const hidden = left ? railLeftHidden : railRightHidden; + const name = left ? "对话列表" : "上下文"; + saveRailsState(); + syncRails(); + setStatus(`${hidden ? "已收起" : "已展开"}${name}`); + if (navLevel === "conversation") renderArena(); + else syncRoomChrome(); +} + +/** 记下当前打开的对话(不跨 run:只写 sessionStorage) */ +function rememberOpenConversation(conversationId) { + try { + if (conversationId) { + sessionStorage.setItem(OPEN_CONVERSATION_KEY, conversationId); + } else { + sessionStorage.removeItem(OPEN_CONVERSATION_KEY); + } + } catch { + /* 存不进也不影响当前会话 */ + } +} + +function loadOpenConversation() { + try { + return sessionStorage.getItem(OPEN_CONVERSATION_KEY) || ""; + } catch { + return ""; + } +} + +/** 刷新页后回到上次那个对话;没记录或打不开就直接回首屏 */ +async function restoreOpenTarget() { + const conversationId = loadOpenConversation(); + if (conversationId) { + try { + await openConversation(conversationId); + return; + } catch { + rememberOpenConversation(null); + } + } + await goRoot(); +} + function loadTemps() { try { const raw = localStorage.getItem(TEMP_STORAGE_KEY); @@ -200,6 +392,11 @@ function slotsFromEditor() { seedHistory: els.shellSeedHistory.value, loreAfter: els.shellLoreAfter.value, postTurn: els.shellPostTurn.value, + charName: els.shellCharName.value, + charDescription: els.shellCharDesc.value, + charPersonality: els.shellCharPers.value, + scenario: els.shellScenario.value, + exampleDialogue: els.shellExamples.value, }; } @@ -211,6 +408,11 @@ function fillEditor(shellLike) { els.shellSeedHistory.value = s.seedHistory || ""; els.shellLoreAfter.value = s.loreAfter || ""; els.shellPostTurn.value = s.postTurn || ""; + els.shellCharName.value = s.charName || ""; + els.shellCharDesc.value = s.charDescription || ""; + els.shellCharPers.value = s.charPersonality || ""; + els.shellScenario.value = s.scenario || ""; + els.shellExamples.value = s.exampleDialogue || ""; } function setContextEditorEnabled(enabled, subtitle) { @@ -221,7 +423,13 @@ function setContextEditorEnabled(enabled, subtitle) { els.shellSeedHistory, els.shellLoreAfter, els.shellPostTurn, + els.shellCharName, + els.shellCharDesc, + els.shellCharPers, + els.shellScenario, + els.shellExamples, els.btnSaveShell, + els.btnClearShell, ]; for (const el of fields) el.disabled = !enabled; if (els.contextSub) { @@ -234,23 +442,78 @@ function syncContextPanel() { if (activeIsTemp) { fillEditor(activeShell || { name: "临时会话", ...EMPTY_SLOTS }); setContextEditorEnabled(true, "点应用后写入本机"); + renderKeepStats(); return; } if (activeShell) { fillEditor(activeShell); setContextEditorEnabled(true, "持久上下文 · 点应用写入本机"); + renderKeepStats(); return; } fillEditor({ name: "", ...EMPTY_SLOTS }); setContextEditorEnabled(false, "选择或新建后编辑"); + renderKeepStats(); +} + +/** 保留计数:按次数降序的条状图,只算当前会话 */ +function renderKeepStats() { + if (!els.keepList) return; + const conversationId = activeConversationId || ""; + const entries = readKeepStats(conversationId); + const total = keepStatTotal(entries); + const max = entries.length ? entries[0].count : 0; + els.keepList.innerHTML = entries.length + ? entries + .map( + (entry) => `
+
+ ${escapeHtml(entry.label)} + ${entry.count} 次 +
+
+ ${entry.model ? `

${escapeHtml(entry.model)}

` : ""} +
`, + ) + .join("") + : `

${ + conversationId + ? "这个会话还没保留过回复。卡片上点「保留此回复」就会开始计数。" + : "打开一个会话后,这里按次数显示各模型被保留了多少次。" + }

`; + if (els.keepHint) { + els.keepHint.textContent = entries.length + ? `共保留 ${total} 次 · 按次数从多到少排` + : "只统计当前会话,记在浏览器缓存里。"; + } + if (els.btnClearKeep) els.btnClearKeep.hidden = !entries.length; +} + +function syncContextTabs() { + const shellTab = contextTab !== "keep"; + if (els.tabShell) { + els.tabShell.classList.toggle("is-active", shellTab); + els.tabShell.setAttribute("aria-selected", shellTab ? "true" : "false"); + } + if (els.tabKeep) { + els.tabKeep.classList.toggle("is-active", !shellTab); + els.tabKeep.setAttribute("aria-selected", shellTab ? "false" : "true"); + } + if (els.shellEditor) els.shellEditor.hidden = !shellTab; + if (els.keepPanel) els.keepPanel.hidden = shellTab; + if (!shellTab) renderKeepStats(); +} + +function setContextTab(next) { + contextTab = next === "keep" ? "keep" : "shell"; + saveContextTab(contextTab); + syncContextTabs(); } function windowLabel(w) { return w.profileName || w.model || w.profileId || "窗口"; } -const BLIND_MARKS = ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"]; - function shuffleIds(ids) { const next = ids.slice(); for (let i = next.length - 1; i > 0; i -= 1) { @@ -279,25 +542,74 @@ function displayWindows() { return blindOrder.map((id) => byId.get(id)).filter(Boolean); } +/** + * 盲测时的名字:按本轮实际显示顺序编号(模型 1、模型 2……)。 + * 编号只看当前这一行,保留后只剩一张卡时就是「模型 1」, + * 不会把这张卡在打乱顺序里占第几位漏出去。 + */ function blindLabel(index) { - const mark = BLIND_MARKS[index] || String(index + 1); - return `模型${mark}`; + return `模型 ${index + 1}`; } -function publicWindowLabel(w) { - if (!blindModels || activeWindows.length < 2) return windowLabel(w); - const index = displayWindows().findIndex((item) => item.id === w.id); +/** 本轮实际显示的窗口(保留后只剩被保留的那一个)。 */ +function shownWindowEntries(turn) { + const entries = displayWindows(); + const keptId = keptWindowId(turn); + return keptId ? entries.filter((w) => w.id === keptId) : entries; +} + +/** 揭示后显示的真实名字:配置名 · 型号,重复时只留一个。 */ +function revealedNameText(w) { + const name = windowLabel(w); + const model = String(w.model || ""); + return model && model !== name ? `${name} · ${model}` : name; +} + +function publicWindowLabel(w, turn) { + if (!blindModels || blindNamesShown || activeWindows.length < 2) { + return windowLabel(w); + } + const index = shownWindowEntries(turn).findIndex((item) => item.id === w.id); return index >= 0 ? blindLabel(index) : "该回复"; } +function toggleCopyMode() { + copyMode = !copyMode; + saveCopyMode(copyMode); + setStatus( + copyMode + ? "已开启复制:回复可选中复制,拖动只在卡片空白处生效" + : "已关闭复制:可按住卡片任意位置拖动", + ); + renderArena(); +} + +/** + * 现在看得到真实模型名:从没盲测过、已全部显示、或已经揭示了其中几张。 + * 只要露出一个名字,菜单就该回到「隐藏并打乱」。 + */ +function modelNamesShown() { + return !blindModels || blindNamesShown || revealedCards.size > 0; +} + function toggleBlindModels() { if (activeWindows.length < 2) { setStatus("至少两个模型才能打乱位置", true); return; } - blindModels = !blindModels; - blindOrder = blindModels ? shuffleIds(activeWindows.map((w) => w.id)) : []; - setStatus(blindModels ? "已隐藏模型名并打乱位置" : "已恢复模型名和原位置"); + if (!modelNamesShown()) { + // 只把名字露出来,位置保持这一轮打乱后的顺序 + blindNamesShown = true; + revealedCards.clear(); + setStatus("已显示模型名 · 位置仍是打乱后的顺序"); + } else { + // 藏起名字并重新打乱一次 + blindModels = true; + blindNamesShown = false; + revealedCards.clear(); + blindOrder = shuffleIds(activeWindows.map((w) => w.id)); + setStatus("已隐藏模型名并打乱位置 · 点卡片上的编号可揭示这一个"); + } renderArena(); } @@ -316,6 +628,55 @@ function cardExpandKey(turnId, windowId) { return `${turnId}:${windowId}`; } +/** + * 思维链默认折叠;只有思考、还没正文时(流式头一段)自动展开。 + * 用户点过就听用户的,不被后续流式刷新冲掉。 + */ +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(); +} + +function thinkingSummaryText(thinking) { + return `思考过程 · ${String(thinking ?? "").trim().length} 字`; +} + +/** 只重写这一张卡的标题区,不整页重绘(保留滚动位置)。 */ +function applyCardReveal(turnId, windowId, revealed) { + const card = [...els.arena.querySelectorAll(".chat-reply-card")].find( + (el) => + el.getAttribute("data-turn-id") === turnId && + el.getAttribute("data-window-id") === windowId, + ); + const titles = card?.querySelector(".chat-reply-card-titles"); + if (!titles) return; + const btn = titles.querySelector(".chat-reply-card-name.is-blind"); + const w = activeWindows.find((item) => item.id === windowId); + let model = titles.querySelector(".chat-reply-card-model"); + if (revealed && w) { + if (!model) { + model = document.createElement("span"); + model.className = "chat-reply-card-model is-revealed"; + titles.appendChild(model); + } + model.textContent = revealedNameText(w); + } else { + model?.remove(); + } + if (btn) { + btn.classList.toggle("is-revealed", revealed); + btn.setAttribute("aria-pressed", revealed ? "true" : "false"); + btn.setAttribute( + "title", + revealed ? "点击重新隐藏这一个模型名" : "点击揭示这一个回答的模型名", + ); + } + // 露出一张卡的名字后,菜单里的按钮也要跟着换成「隐藏并打乱」 + syncComposerMenu(); +} + function replyView(reply) { if (!reply) { return { cls: "is-pending", empty: true, thinking: "", text: "本轮无回复" }; @@ -410,6 +771,7 @@ function renderRootRail() { meta: `${t.turns?.length || 0} 轮 · 临时`, temp: true, active: activeIsTemp && activeConversationId === t.id, + copyable: true, }), ); } @@ -450,6 +812,7 @@ function renderRail() { meta: `${c.messageCount} 条消息`, temp: false, active: activeConversationId === c.id, + copyable: true, }), ); } @@ -464,18 +827,25 @@ function renderRail() { meta: `${c.messageCount} 条消息`, temp: false, active: activeConversationId === c.id, + copyable: true, }), ) .join(""); } -function railItemHtml({ id, title, meta, temp, active }) { +function railItemHtml({ id, title, meta, temp, active, copyable }) { + const copy = copyable + ? `` + : ""; return `
- +
+ ${copy} + +
`; } @@ -506,7 +876,7 @@ function syncComposerMenu() { } if (activeWindows.length > 1) { items.push( - ``, + ``, ); } if (activeIsTemp) { @@ -514,9 +884,157 @@ function syncComposerMenu() { ``, ); } + items.push( + ``, + ``, + ); els.morePanel.innerHTML = items.join(""); } +function cloneData(value) { + return structuredClone(value); +} + +function selectedFields(id) { + if (id === undefined) return {}; + return { selectedWindowId: id }; +} + +function snapshotVariant(turn, after) { + return { + user: { content: turn.user.content, createdAt: turn.user.createdAt }, + replies: cloneData(turn.replies || []), + ...selectedFields(turn.selectedWindowId), + after: cloneData(after), + }; +} + +function applyVariant(turn, variant, index, variants) { + return { + id: turn.id, + user: { content: variant.user.content, createdAt: variant.user.createdAt }, + replies: cloneData(variant.replies || []), + createdAt: turn.createdAt, + variants, + variantIndex: index, + ...selectedFields(variant.selectedWindowId), + }; +} + +function commitActiveVariant(turns, index) { + 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, + ); + return [ + ...turns.slice(0, index), + { ...turn, variants: existing, variantIndex: idx }, + ...after, + ]; +} + +function beginReroll(turns, turnId, text) { + const trimmed = String(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 variants = [ + ...(turn.variants || []), + { + user: { content: trimmed, createdAt: turn.user.createdAt }, + replies: [], + after: [], + }, + ]; + const nextTurn = { + ...turn, + user: { content: trimmed, createdAt: turn.user.createdAt }, + replies: [], + variants, + variantIndex: variants.length - 1, + }; + delete nextTurn.selectedWindowId; + return { + turns: [...committed.slice(0, index), nextTurn], + prior: committed.slice(0, index), + index, + }; +} + +function writeRerollReplies(turns, turnId, replies) { + const index = turns.findIndex((t) => t.id === turnId); + if (index < 0) return turns; + const turn = turns[index]; + 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 = cloneData(replies || []); + if (variants[idx]) { + variants[idx] = { + user: { ...turn.user }, + replies: cloned, + after: [], + }; + } + const next = { + ...turn, + replies: cloneData(cloned), + variants, + variantIndex: idx, + }; + delete next.selectedWindowId; + return [...turns.slice(0, index), next, ...turns.slice(index + 1)]; +} + +function switchTurnVariant(turns, turnId, direction) { + 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 nextIndex = direction === "next" ? cur + 1 : cur - 1; + if (nextIndex < 0 || nextIndex >= variants.length) return null; + const variant = variants[nextIndex]; + const restored = applyVariant(turn, variant, nextIndex, variants); + return [ + ...committed.slice(0, index), + restored, + ...cloneData(variant.after || []), + ]; +} + +function renderUserActions(turn) { + const total = (turn.variants || []).length; + const index = Math.min(total, Math.max(1, (turn.variantIndex ?? 0) + 1)); + const pager = + total > 1 + ? ` + + ${index}/${total} + + ` + : ""; + return `
+ ${pager} + + +
`; +} + function renderReplyCard(turn, w, multi, displayIndex) { const reply = turn ? (turn.replies || []).find((r) => r.windowId === w.id) @@ -536,53 +1054,185 @@ function renderReplyCard(turn, w, multi, displayIndex) { activeWindows.length > 1 ? `` : ""; + const copy = + copyMode && turn && !body.empty + ? `` + : ""; + const retrying = Boolean(turn) && retryingWindows.has(retryKey(turn.id, w.id)); + const actions = + copy || remove + ? `
${copy}${remove}
` + : ""; + // 重新生成:成功也能再跑一次,就放在「保留」旁边 + const regen = turn + ? retrying + ? `` + : body.empty + ? "" + : `` + : ""; + const blind = blindModels && !blindNamesShown && multi; + const revealed = blind && revealedCards.has(cardExpandKey(turnId, w.id)); + const titles = blind + ? `
+ + ${revealed ? `${escapeHtml(revealedNameText(w))}` : ""} +
` + : `
+ ${escapeHtml(windowLabel(w))} + ${escapeHtml(w.model || w.profileId || "")} +
`; return `
-
- ${escapeHtml(blindModels && multi ? blindLabel(displayIndex) : windowLabel(w))} - ${blindModels && multi ? "" : `${escapeHtml(w.model || w.profileId || "")}`} -
- ${remove} + ${titles} + ${actions}
-
${replyBodyHtml(body)}
+
${replyBodyHtml(body, cardExpandKey(turnId, w.id))}
+ ${regen} ${keep}
`; } -function replyBodyHtml(body) { +function replyBodyHtml(body, key = "") { if (body.empty) return escapeHtml(body.text); - const thinkingHtml = body.thinking - ? `
${renderMarkdownToHtml(body.thinking)}
` + const thinking = String(body.thinking ?? "").trim(); + const thinkingHtml = thinking + ? `
+ ${escapeHtml(thinkingSummaryText(thinking))} +
${renderMarkdownToHtml(thinking)}
+
` : ""; return `${thinkingHtml}${renderMarkdownToHtml(body.text)}`; } +function replyPlainText(reply) { + const body = replyView(reply); + if (body.empty) return ""; + return [body.thinking, body.text] + .map((part) => part.trim()) + .filter(Boolean) + .join("\n\n"); +} + +async function writeClipboard(text) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + /* fall through to the textarea path */ + } + } + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + ta.remove(); + if (!ok) throw new Error("复制失败"); +} + +async function copyReplyText(turnId, windowId, button) { + const turn = activeTurns.find((t) => t.id === turnId); + const reply = (turn?.replies || []).find((r) => r.windowId === windowId); + const text = replyPlainText(reply); + if (!text) { + setStatus("没有可复制的回复", true); + return; + } + await writeClipboard(text); + if (button) { + const prev = button.textContent; + button.textContent = "已复制"; + window.setTimeout(() => { + if (button.isConnected) button.textContent = prev || "复制"; + }, 1200); + } + setStatus("已复制这条回复"); +} + +/** 该轮已保留且回复可用的窗口;没有则返回 null(全部并排显示)。 */ +function keptWindowId(turn) { + const id = turn?.selectedWindowId; + if (!id) return null; + const reply = (turn.replies || []).find((r) => r.windowId === id); + if (!reply || replyView(reply).empty) return null; + return id; +} + +/** 本轮里失败的窗口 id(按当前展示顺序)。 */ +function failedWindowIds(turn) { + if (!turn) return []; + const shown = new Set(displayWindows().map((w) => w.id)); + return (turn.replies || []) + .filter((r) => !r.pending && r.error && shown.has(r.windowId)) + .map((r) => r.windowId); +} + +/** 把重试拿到的回复按 windowId 合并回本轮,不动其它窗口。 */ +function mergeRepliesIntoTurn(turns, turnId, replies) { + return turns.map((t) => { + if (t.id !== turnId) return t; + const byWindow = new Map((t.replies || []).map((r) => [r.windowId, r])); + for (const reply of replies) byWindow.set(reply.windowId, reply); + const nextReplies = cloneData([...byWindow.values()]); + const variants = (t.variants || []).slice(); + const idx = Math.min( + Math.max(0, t.variantIndex ?? Math.max(0, variants.length - 1)), + Math.max(0, variants.length - 1), + ); + if (variants[idx]) { + variants[idx] = { ...variants[idx], replies: cloneData(nextReplies) }; + } + return { ...t, replies: nextReplies, variants, variantIndex: idx }; + }); +} + function renderTurnStrip(turn) { const multi = activeWindows.length > 1; - const count = activeWindows.length; - const cards = displayWindows() + const entries = displayWindows().map((w) => w); + const keptId = keptWindowId(turn); + // 保留后只留被保留的那一张卡;其余窗口仍会在下一轮分别请求。 + const shown = keptId ? entries.filter((w) => w.id === keptId) : entries; + const count = shown.length; + // 编号按本行显示顺序重排,不跟着打乱后的全局位次走 + const cards = shown .map((w, index) => renderReplyCard(turn, w, multi, index)) .join(""); const turnId = turn?.id || "idle"; const userBlock = turn ? (() => { const time = formatBubbleTime(turn.user?.createdAt || turn.createdAt); + const editing = editingTurnId === turn.id; + const body = editing + ? ` +
+ + +
+

Ctrl/⌘ + Enter 保存 · Esc 取消

` + : `
${escapeHtml(turn.user?.content || "")}
+ ${renderUserActions(turn)} + ${time ? `` : ""}`; return `
-
-
${escapeHtml(turn.user?.content || "")}
- ${time ? `` : ""} +
+ ${body}
`; })() : ""; - const stripBar = multi + const stripBar = multi && !keptId ? `
左右切换,上下阅读 + ${failedWindowIds(turn).length > 1 ? `` : ""}
1 / ${count} @@ -669,6 +1319,34 @@ function clearTextSelection() { if (sel && sel.rangeCount && !sel.isCollapsed) sel.removeAllRanges(); } +/** 滚动时把滚动条露一下,停下来 700ms 再藏回去 */ +function flashScrollBar(el) { + el.classList.add("is-vscroll"); + window.clearTimeout(el._wheelHide); + el._wheelHide = window.setTimeout(() => { + el.classList.remove("is-vscroll"); + }, 700); +} + +/** 卡片正文没在顶端就一直露着滚动条,滚回顶端才收起来 */ +function syncCardScrollMark(text) { + text.classList.toggle("is-scrolled", text.scrollTop > 1); +} + +/** 给每张分卡片的正文挂上滚动条标记(同一元素只挂一次) */ +function bindCardScrollMarks() { + if (!els.arena) return; + for (const text of els.arena.querySelectorAll(".chat-reply-card-text")) { + if (!text._scrollMarkBound) { + text._scrollMarkBound = true; + text.addEventListener("scroll", () => syncCardScrollMark(text), { + passive: true, + }); + } + syncCardScrollMark(text); + } +} + function bindReplyTrack(track) { let dragging = false; let axis = null; @@ -679,7 +1357,13 @@ function bindReplyTrack(track) { let startVScroll = 0; track.addEventListener("pointerdown", (e) => { - if (e.button !== 0 || e.target.closest("button")) return; + if ( + e.button !== 0 || + e.target.closest("button, a, textarea, input, summary") + ) + return; + // 复制模式下正文留给选中复制;关闭复制后整张卡片都能拖 + if (copyMode && e.target.closest(".chat-reply-card-text")) return; dragging = true; axis = null; vTarget = null; @@ -689,7 +1373,7 @@ function bindReplyTrack(track) { }); track.addEventListener("selectstart", (e) => { - if (axis) e.preventDefault(); + if (axis || !copyMode) e.preventDefault(); }); track.addEventListener("pointermove", (e) => { @@ -700,7 +1384,11 @@ function bindReplyTrack(track) { if (Math.abs(dx) < DRAG_SLOP && Math.abs(dy) < DRAG_SLOP) return; axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y"; track.classList.add("is-dragging"); - track.setPointerCapture(e.pointerId); + try { + track.setPointerCapture(e.pointerId); + } catch { + /* 指针已经不在或不可捕获时忽略,拖动靠 pointermove 也能继续 */ + } if (e.cancelable) e.preventDefault(); clearTextSelection(); if (axis === "y") { @@ -713,10 +1401,10 @@ function bindReplyTrack(track) { } if (e.cancelable) e.preventDefault(); if (axis === "x") { - track.scrollLeft = startScroll - dx; + track.scrollLeft = startScroll - dx * dragGain; return; } - if (vTarget) vTarget.scrollTop = startVScroll - dy; + if (vTarget) vTarget.scrollTop = startVScroll - dy * dragGain; }); const end = () => { @@ -734,8 +1422,28 @@ function bindReplyTrack(track) { if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; const stream = els.arena?.querySelector(".chat-arena-stream"); if (!stream) return; + // 指针下的那张卡片优先;卡片到顶 / 到底后,剩下的距离交给大滚动条 + const card = e.target.closest?.(".chat-reply-card") || null; + const text = card?.querySelector(".chat-reply-card-text") || null; + const plan = planWheelScroll({ + cardScrollTop: text?.scrollTop ?? 0, + cardScrollMax: text + ? Math.max(0, text.scrollHeight - text.clientHeight) + : 0, + deltaY: e.deltaY, + streamScrollTop: stream.scrollTop, + streamScrollMax: Math.max(0, stream.scrollHeight - stream.clientHeight), + }); + if (!plan.cardDelta && !plan.streamDelta) return; e.preventDefault(); - stream.scrollTop += e.deltaY; + if (plan.cardDelta && text) { + text.scrollTop += plan.cardDelta; + flashScrollBar(text); + } + if (plan.streamDelta) { + stream.scrollTop += plan.streamDelta; + flashScrollBar(stream); + } }, { passive: false }, ); @@ -762,8 +1470,21 @@ function bindReplyTrack(track) { updateStripIdx(track); } +/** + * 卡片最高只到容器可见高度:叠在折叠高度上面, + * 再长的回复也一屏装得下,剩下的靠卡片正文自己滚。 + */ +function cardHeightCap() { + const stream = els.arena?.querySelector(".chat-arena-stream"); + const visible = stream?.clientHeight || 0; + if (visible <= 0) return Number.POSITIVE_INFINITY; + // 减去容器内边距和一点余量,行与行之间还能看出边界 + return Math.max(MIN_CARD_H, visible - 40); +} + function collapseReplyTracks() { if (!els.arena) return; + const cap = cardHeightCap(); for (const track of els.arena.querySelectorAll(".chat-reply-track")) { const cards = [...track.querySelectorAll(".chat-reply-card")]; if (cards.length < 2) { @@ -781,15 +1502,17 @@ function collapseReplyTracks() { return { card, h: card.offsetHeight, empty }; }); const substantive = measured.filter((item) => !item.empty).map((item) => item.h); - const shortest = substantive.length ? Math.min(...substantive) : MIN_CARD_H; - const target = Math.max(MIN_CARD_H, shortest); + const target = Math.min(sharedReachHeight(substantive), cap); for (const { card, h } of measured) { const turnId = card.getAttribute("data-turn-id") || ""; const windowId = card.getAttribute("data-window-id") || ""; const key = cardExpandKey(turnId, windowId); const btn = card.querySelector("[data-action='expand-card']"); if (expandedCards.has(key)) { - card.style.height = "auto"; + // 展开也只是比折叠高一点,超过一屏就封顶,剩下的内部滚 + const capped = h > cap; + card.style.height = capped ? `${cap}px` : "auto"; + card.classList.toggle("is-capped", capped); card.classList.remove("is-collapsed"); if (btn) { btn.hidden = false; @@ -798,6 +1521,7 @@ function collapseReplyTracks() { continue; } card.style.height = `${target}px`; + card.classList.remove("is-capped"); card.classList.add("is-collapsed"); if (btn) { btn.hidden = !(h > target + 1); @@ -805,10 +1529,12 @@ function collapseReplyTracks() { } } } + bindCardScrollMarks(); } function renderArena() { if (!els.arena) return; + syncRails(); const scrollLefts = new Map(); for (const track of els.arena.querySelectorAll(".chat-reply-track")) { scrollLefts.set(track.getAttribute("data-turn-id") || "", track.scrollLeft); @@ -821,12 +1547,25 @@ function renderArena() { return; } els.arena.classList.remove("is-empty"); + els.arena.classList.toggle("is-copy-off", !copyMode); const body = activeTurns.length ? activeTurns.map((turn) => renderTurnStrip(turn)).join("") : `

在下方输入,各模型会同时回复同一条消息。

${renderTurnStrip(null)}`; els.arena.innerHTML = `
${body}
`; + const stream = els.arena.querySelector(".chat-arena-stream"); + if (stream) { + // 指针不在卡片上时(行间空隙、提示文字)浏览器自己滚,这里只把滚动条露一下 + stream.addEventListener( + "wheel", + (e) => { + if (e.defaultPrevented || !Math.abs(e.deltaY)) return; + flashScrollBar(stream); + }, + { passive: true }, + ); + } for (const track of els.arena.querySelectorAll(".chat-reply-track")) { bindReplyTrack(track); } @@ -841,7 +1580,18 @@ function renderArena() { updateStripIdx(track); } const stream = els.arena.querySelector(".chat-arena-stream"); - if (stream) stream.scrollTop = stream.scrollHeight; + const editing = els.arena.querySelector(".chat-user-edit"); + if (editing instanceof HTMLTextAreaElement) { + sizeUserEdit(editing); + if (document.activeElement !== editing) { + editing.focus(); + const len = editing.value.length; + editing.setSelectionRange(len, len); + } + editing.closest(".chat-user-bubble")?.scrollIntoView({ block: "nearest" }); + } else if (stream) { + stream.scrollTop = stream.scrollHeight; + } }; // 等 flex 宽度落地后再量卡片与折叠高度 requestAnimationFrame(() => requestAnimationFrame(finalize)); @@ -885,6 +1635,7 @@ function openDraftTemp() { } function syncRoomChrome() { + syncRails(); els.btnNewConv.hidden = !( navLevel === "shell" || (navLevel === "conversation" && !activeIsTemp) @@ -945,9 +1696,23 @@ async function loadProfiles() { } } +/** 设置页里的直聊偏好(目前只有拖动倍率);取不到就用默认值 */ +async function loadChatPrefs() { + try { + const data = await api("/api/settings"); + const gain = Number(data?.settings?.chatDragGain); + if (Number.isFinite(gain) && gain > 0) { + dragGain = Math.min(3, Math.max(0.5, gain)); + } + } catch { + /* 用默认倍率 */ + } +} + async function goRoot() { activeShellId = null; conversations = []; + rememberOpenConversation(null); await refreshShells(); openDraftTemp(); renderPath(); @@ -961,6 +1726,7 @@ async function openShell(shellId) { activeTurns = []; activeWindows = []; navLevel = "shell"; + rememberOpenConversation(null); await refreshConversations(shellId); renderPath(); renderRail(); @@ -978,6 +1744,7 @@ async function openConversation(conversationId) { activeWindows = data.conversation.windows || []; activeTurns = data.conversation.turns || []; navLevel = "conversation"; + rememberOpenConversation(conversationId); if (activeShellId) { const shellData = await api( `/api/direct-chat/shells/${encodeURIComponent(activeShellId)}`, @@ -1000,6 +1767,7 @@ function openTemp(tempId) { activeWindows = temp.windows || []; activeTurns = temp.turns || []; navLevel = "conversation"; + rememberOpenConversation(null); renderPath(); renderRail(); syncRoomChrome(); @@ -1016,22 +1784,198 @@ async function createShellAndOpen() { await openShell(data.shell.id); } -async function createConversationUnderShell() { +function copySessionName(name) { + const base = String(name || "会话").replace(/(?: 副本)+$/u, "").trim() || "会话"; + return `${base} 副本`; +} + +function remapCopiedTurn(turn, idMap) { + const remapReply = (r) => ({ + ...r, + windowId: idMap.get(r.windowId) || r.windowId, + }); + const remapSelected = (id) => + id ? idMap.get(id) || null : (id ?? null); + const next = { + ...turn, + id: newId(), + user: turn.user ? { ...turn.user } : turn.user, + replies: (turn.replies || []).map(remapReply), + selectedWindowId: remapSelected(turn.selectedWindowId), + }; + if (turn.variants?.length) { + next.variants = turn.variants.map((v) => ({ + ...v, + replies: (v.replies || []).map(remapReply), + selectedWindowId: remapSelected(v.selectedWindowId), + after: (v.after || []).map((child) => remapCopiedTurn(child, idMap)), + })); + } + return next; +} + +function cloneWindowsAndTurns(windows, turns) { + const idMap = new Map(); + const nextWindows = (windows || []).map((w) => { + const id = newId(); + idMap.set(w.id, id); + return { ...w, id }; + }); + const nextTurns = (turns || []).map((t) => remapCopiedTurn(t, idMap)); + return { windows: nextWindows, turns: nextTurns }; +} + +async function copyConversation(id, isTemp) { + if (busy) { + setStatus("回复进行中,请稍后再复制", true); + return; + } + if (isTemp) { + const source = loadTemps().find((t) => t.id === id); + if (!source) return; + if (loadTemps().length >= MAX_TEMPS) { + setStatus("快速对话最多保留 2 条,请先保存或删除一条再复制", true); + return; + } + const cloned = cloneWindowsAndTurns(source.windows, source.turns); + const temp = { + id: newId(), + name: copySessionName(source.name), + slots: { ...EMPTY_SLOTS, ...(source.slots || {}) }, + windows: cloned.windows, + turns: cloned.turns, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + upsertTemp(temp); + copyKeepStats(id, temp.id); + openTemp(temp.id); + setStatus("已复制会话"); + return; + } + const data = await api( + `/api/direct-chat/conversations/${encodeURIComponent(id)}/duplicate`, + { method: "POST", body: "{}" }, + ); + copyKeepStats(id, data.conversation.id); + await openConversation(data.conversation.id); + setStatus("已复制会话"); +} + +function contextFieldsEmpty() { + return ( + !els.shellSystem.value.trim() && + !els.shellLoreBefore.value.trim() && + !els.shellSeedHistory.value.trim() && + !els.shellLoreAfter.value.trim() && + !els.shellPostTurn.value.trim() + ); +} + +async function clearContextSlots() { + if (!els.btnClearShell || els.btnClearShell.disabled) return; + if (busy) { + setStatus("回复进行中,请稍后再清空", true); + return; + } + if (contextFieldsEmpty()) { + setStatus("上下文已经是空的,已选模型仍保留"); + return; + } + const shared = + !activeIsTemp && conversations.length > 1 + ? `此上下文下的 ${conversations.length} 个会话都会改用清空后的设定。` + : ""; + if ( + !window.confirm( + `清空系统提示和设定?名称会保留,已选模型也不会动。${shared}`, + ) + ) { + return; + } + if (activeIsTemp && activeConversationId) { + const temp = loadTemps().find((t) => t.id === activeConversationId); + if (!temp) return; + temp.slots = { ...EMPTY_SLOTS }; + temp.windows = activeWindows; + temp.turns = activeTurns; + temp.updatedAt = nowIso(); + upsertTemp(temp); + activeShell = { + name: els.shellName.value || temp.name || "临时会话", + ...EMPTY_SLOTS, + }; + fillEditor(activeShell); + if (els.contextSub) els.contextSub.textContent = "已清空,模型仍保留"; + setStatus("已清空上下文,模型仍保留"); + return; + } + if (!activeShellId) return; + const name = activeShell?.name || els.shellName.value.trim() || "未命名上下文"; + const data = await api( + `/api/direct-chat/shells/${encodeURIComponent(activeShellId)}`, + { + method: "PATCH", + body: JSON.stringify({ name, ...EMPTY_SLOTS }), + }, + ); + activeShell = data.shell; + fillEditor(activeShell); + await refreshShells(); + renderPath(); + renderRail(); + if (els.contextSub) els.contextSub.textContent = "已清空,模型仍保留"; + setStatus("已清空上下文,模型仍保留"); +} + +function modelPickHtml(profiles, { checkedIds, lockedIds } = {}) { + const checked = checkedIds || new Set(); + const locked = lockedIds || new Set(); + return profiles + .map((p) => { + const isLocked = locked.has(p.id); + const isChecked = isLocked || checked.has(p.id); + const model = p.model ? ` · ${escapeHtml(p.model)}` : ""; + return ``; + }) + .join(""); +} + +function openNewConvDialog() { + if (!activeShellId || !els.dialogNewConv) return; + els.newConvName.value = "新会话"; + const selected = new Set(activeWindows.map((w) => w.profileId)); + const available = profileList.filter((p) => p.hasKey); + if (!available.length) { + els.newConvModels.innerHTML = ""; + els.newConvModelsEmpty.hidden = false; + } else { + els.newConvModelsEmpty.hidden = true; + els.newConvModels.innerHTML = modelPickHtml(available, { checkedIds: selected }); + } + els.dialogNewConv.showModal(); +} + +async function createConversationUnderShell(name, windows) { if (!activeShellId) return; - const name = window.prompt("会话名称", "新会话"); - if (name == null) return; const data = await api( `/api/direct-chat/shells/${encodeURIComponent(activeShellId)}/conversations`, { method: "POST", body: JSON.stringify({ name: name.trim() || "新会话", - windows: activeWindows, + windows, turns: [], }), }, ); await openConversation(data.conversation.id); + setStatus( + windows.length ? `已新建会话,${windows.length} 个模型` : "已新建会话", + ); } async function saveShellEditor() { @@ -1128,8 +2072,8 @@ async function readNdjson(res, onEvent) { } async function postMulti(path, body, hooks) { - const onWindow = hooks.onWindow || (() => {}); - const onDelta = hooks.onDelta || (() => {}); + const onWindow = hooks?.onWindow || (() => {}); + const onDelta = hooks?.onDelta || (() => {}); const res = await fetch(path, { method: "POST", headers: { @@ -1184,9 +2128,9 @@ function paintStreamingReply(turn, windowId) { bodyEl.classList.toggle("is-pending", body.cls === "is-pending"); bodyEl.classList.toggle("is-error", body.cls === "is-error"); bodyEl.dataset.empty = body.empty ? "1" : "0"; - textEl.innerHTML = replyBodyHtml(body); + textEl.innerHTML = replyBodyHtml(body, cardExpandKey(turn.id, windowId)); card.style.height = "auto"; - card.classList.remove("is-collapsed"); + card.classList.remove("is-collapsed", "is-capped"); const stream = els.arena?.querySelector(".chat-arena-stream"); if (stream) { const gap = stream.scrollHeight - stream.scrollTop - stream.clientHeight; @@ -1205,17 +2149,30 @@ function scheduleStreamingPaint(turn, windowId) { let rememberTimer = 0; +function stripPendingTurn(turn) { + const stripReply = (r) => { + const { pending: _pending, ...rest } = r; + return rest; + }; + const next = { + ...turn, + replies: (turn.replies || []).map(stripReply), + }; + if (turn.variants?.length) { + next.variants = turn.variants.map((v) => ({ + ...v, + replies: (v.replies || []).map(stripReply), + after: (v.after || []).map(stripPendingTurn), + })); + } + return next; +} + function writeStreamingTurn() { if (!activeIsTemp || !activeConversationId) return; const temp = loadTemps().find((t) => t.id === activeConversationId); if (!temp) return; - temp.turns = activeTurns.map((t) => ({ - ...t, - replies: (t.replies || []).map((r) => { - const { pending: _pending, ...rest } = r; - return rest; - }), - })); + temp.turns = activeTurns.map(stripPendingTurn); temp.windows = activeWindows; temp.updatedAt = nowIso(); upsertTemp(temp); @@ -1357,25 +2314,374 @@ async function sendMessage() { } finally { busy = false; els.btnSend.disabled = false; + renderArena(); + } +} + +function sizeUserEdit(ta) { + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 320)}px`; +} + +function startEditTurn(turnId) { + if (busy) return; + const turn = activeTurns.find((t) => t.id === turnId); + if (!turn) return; + editingTurnId = turnId; + editingDraft = turn.user?.content || ""; + renderArena(); +} + +function cancelEditTurn() { + editingTurnId = null; + editingDraft = ""; + renderArena(); +} + +async function rerollTurn(turnId, text) { + if (busy) return; + const trimmed = String(text || "").trim(); + if (!trimmed) { + setStatus("内容不能为空", true); + return; + } + if (navLevel !== "conversation" || !activeConversationId) { + setStatus("请先打开一个会话", true); + return; + } + if (!activeWindows.length) { + setStatus("请先新增至少一个模型窗口", true); + return; + } + const index = activeTurns.findIndex((t) => t.id === turnId); + if (index < 0) return; + const later = activeTurns.length - index - 1; + const snapshot = cloneData(activeTurns); + const begun = beginReroll(snapshot, turnId, trimmed); + if (!begun) return; + const pending = begun.turns[begun.index]; + pending.replies = activeWindows.map((w) => ({ + windowId: w.id, + content: "", + pending: true, + })); + const variant = pending.variants?.[pending.variantIndex]; + if (variant) variant.replies = pending.replies; + const priorTurns = begun.prior; + // 重roll 的思维链是全新的,之前手动开合的记录作废 + for (const key of [...thinkingToggles.keys()]) { + if (key.startsWith(`${turnId}:`)) thinkingToggles.delete(key); + } + editingTurnId = null; + editingDraft = ""; + activeTurns = begun.turns; + const total = activeWindows.length; + let arrived = 0; + let sawText = false; + busy = true; + els.btnSend.disabled = true; + setStatus(total > 1 ? `重roll 0/${total}` : "重roll…"); + renderArena(); + const onDelta = (event) => { + let reply = pending.replies.find((r) => r.windowId === event.windowId); + if (!reply) { + reply = { windowId: event.windowId, content: "", thinking: "" }; + pending.replies.push(reply); + } + reply.pending = false; + if (event.field === "thinking") { + reply.thinking = `${reply.thinking || ""}${event.delta}`; + } else { + reply.content = `${reply.content || ""}${event.delta}`; + } + sawText = true; + scheduleStreamingPaint(pending, event.windowId); + rememberStreamingTurn(); + }; + const onWindow = (result) => { + const idx = pending.replies.findIndex((r) => r.windowId === result.windowId); + const reply = replyFromWindowResult(result); + if (idx >= 0) pending.replies[idx] = reply; + else pending.replies.push(reply); + if (variant) variant.replies = pending.replies; + if (reply.content || reply.thinking) sawText = true; + arrived += 1; + setStatus(total > 1 ? `重roll ${arrived}/${total}` : "重roll…"); + rememberStreamingTurn(); + renderArena(); + }; + const hooks = { onDelta, onWindow }; + const note = + later > 0 ? "之后的对话收在上一版,‹ › 可切回" : ""; + try { + if (activeIsTemp) { + const temp = loadTemps().find((t) => t.id === activeConversationId); + if (!temp) throw new Error("临时会话不存在"); + const slots = { ...EMPTY_SLOTS, ...temp.slots }; + const data = await postMulti( + "/api/direct-chat/ephemeral/complete", + { + text: trimmed, + ...slots, + windows: activeWindows, + turns: priorTurns, + }, + hooks, + ); + activeTurns = writeRerollReplies( + activeTurns, + turnId, + data.multi?.turn?.replies || pending.replies, + ); + temp.turns = activeTurns.map(stripPendingTurn); + temp.windows = activeWindows; + temp.slots = slots; + temp.updatedAt = nowIso(); + upsertTemp(temp); + const failed = (data.multi?.results || []).filter((r) => r.error); + setStatus( + failed.length + ? `${failed.length} 个窗口失败,其余已写入` + : note, + failed.length > 0, + ); + renderArena(); + renderRail(); + return; + } + + const data = await postMulti( + `/api/direct-chat/conversations/${encodeURIComponent(activeConversationId)}/turns/${encodeURIComponent(turnId)}/reroll`, + { text: trimmed, windows: activeWindows }, + hooks, + ); + activeTurns = data.conversation?.turns || activeTurns; + activeWindows = data.conversation?.windows || activeWindows; + if (data.shell) activeShell = data.shell; + const failed = (data.multi?.results || []).filter((r) => r.error); + setStatus( + failed.length ? `${failed.length} 个窗口失败,其余已写入` : note, + failed.length > 0, + ); + renderArena(); + } catch (err) { + if (!sawText && arrived === 0) { + activeTurns = snapshot; + if (activeIsTemp) { + const temp = loadTemps().find((t) => t.id === activeConversationId); + if (temp) { + temp.turns = snapshot; + temp.updatedAt = nowIso(); + upsertTemp(temp); + } + } + renderArena(); + } else { + for (const reply of pending.replies) { + if (reply.pending) { + reply.pending = false; + reply.error = reply.error || "未返回"; + } + } + if (variant) variant.replies = pending.replies; + if (rememberTimer) { + clearTimeout(rememberTimer); + rememberTimer = 0; + } + writeStreamingTurn(); + renderArena(); + } + setStatus(err instanceof Error ? err.message : String(err), true); + } finally { + busy = false; + els.btnSend.disabled = false; + renderArena(); + } +} + +/** 正在重试的窗口键 `turnId:windowId`;允许同一轮多张卡同时重试 */ +const retryingWindows = new Set(); + +function retryKey(turnId, windowId) { + return `${turnId}:${windowId}`; +} + +/** 重试跟发送互斥,但重试之间不互斥 */ +function syncComposerState() { + els.btnSend.disabled = busy || retryingWindows.size > 0; +} + +function saveActiveTemp(slots) { + const temp = loadTemps().find((t) => t.id === activeConversationId); + if (!temp) return; + if (slots) temp.slots = slots; + temp.turns = activeTurns.map(stripPendingTurn); + temp.windows = activeWindows; + temp.updatedAt = nowIso(); + upsertTemp(temp); + renderRail(); +} + +async function retryReplies(turnId, windowIds) { + if (busy || editingTurnId) return; + const requested = (windowIds || []).filter(Boolean); + if (!requested.length) return; + if (navLevel !== "conversation" || !activeConversationId) { + setStatus("请先打开一个会话", true); + return; + } + const index = activeTurns.findIndex((t) => t.id === turnId); + if (index < 0) return; + const turn = activeTurns[index]; + const text = String(turn.user?.content || "").trim(); + if (!text) { + setStatus("本轮缺少用户输入", true); + return; + } + + // 已经在跑的那几张跳过,其余照跑 —— 连续点多张卡就是并行 + const ids = requested.filter( + (id) => !retryingWindows.has(retryKey(turnId, id)), + ); + const targets = activeWindows.filter((w) => ids.includes(w.id)); + if (!targets.length) return; + + const snapshot = cloneData(activeTurns); + for (const reply of turn.replies || []) { + if (!ids.includes(reply.windowId)) continue; + delete reply.error; + delete reply.content; + delete reply.thinking; + reply.pending = true; + // 重试用的是全新回复,之前手动展开的记录作废 + thinkingToggles.delete(cardExpandKey(turnId, reply.windowId)); + } + const priorTurns = activeTurns.slice(0, index); + for (const id of ids) retryingWindows.add(retryKey(turnId, id)); + + syncComposerState(); + const total = ids.length; + let arrived = 0; + const progress = () => + total > 1 ? `重试中… ${arrived}/${total}` : "重试中…"; + setStatus(progress()); + renderArena(); + + // 服务端是并列跑的,好的先回来先刷这张卡 + const onWindow = (result) => { + const reply = replyFromWindowResult(result); + activeTurns = mergeRepliesIntoTurn(activeTurns, turnId, [reply]); + retryingWindows.delete(retryKey(turnId, reply.windowId)); + arrived += 1; + setStatus(progress()); + if (activeIsTemp) saveActiveTemp(); + renderArena(); + }; + + try { + if (activeIsTemp) { + const temp = loadTemps().find((t) => t.id === activeConversationId); + if (!temp) throw new Error("临时会话不存在"); + const slots = { ...EMPTY_SLOTS, ...temp.slots }; + const data = await postMulti( + "/api/direct-chat/ephemeral/complete", + { + text, + ...slots, + windows: targets, + turns: priorTurns, + }, + { onWindow }, + ); + const replies = (data.multi?.results || []).map(replyFromWindowResult); + activeTurns = mergeRepliesIntoTurn(activeTurns, turnId, replies); + saveActiveTemp(slots); + } else { + const data = await postMulti( + `/api/direct-chat/conversations/${encodeURIComponent(activeConversationId)}/turns/${encodeURIComponent(turnId)}/retry`, + { windowIds: targets.map((w) => w.id) }, + { onWindow }, + ); + activeTurns = data.conversation?.turns || activeTurns; + } + + const stillFailed = ( + activeTurns.find((t) => t.id === turnId)?.replies || [] + ).filter((r) => ids.includes(r.windowId) && r.error); + setStatus( + stillFailed.length ? `${stillFailed.length} 个仍然失败` : "重试完成", + stillFailed.length > 0, + ); + renderArena(); + } catch (err) { + // 只回滚本次涉及的窗口,别人正在跑的不管 + const before = snapshot.find((t) => t.id === turnId)?.replies || []; + for (const id of ids) { + const old = before.find((r) => r.windowId === id); + if (old) activeTurns = mergeRepliesIntoTurn(activeTurns, turnId, [old]); + } + renderArena(); + setStatus(err instanceof Error ? err.message : String(err), true); + } finally { + for (const id of ids) retryingWindows.delete(retryKey(turnId, id)); + syncComposerState(); + renderArena(); + } +} + +async function switchVariant(turnId, direction) { + if (busy || editingTurnId) return; + const snapshot = cloneData(activeTurns); + const next = switchTurnVariant(activeTurns, turnId, direction); + if (!next) return; + activeTurns = next; + setStatus(""); + renderArena(); + if (activeIsTemp) { + persistActiveSessionMeta(); + renderRail(); + return; + } + try { + const data = await api( + `/api/direct-chat/conversations/${encodeURIComponent(activeConversationId)}/turns/${encodeURIComponent(turnId)}/variant`, + { + method: "POST", + body: JSON.stringify({ direction }), + }, + ); + activeTurns = data.conversation?.turns || activeTurns; + renderArena(); + } catch (err) { + activeTurns = snapshot; + renderArena(); + setStatus(err instanceof Error ? err.message : String(err), true); } } async function selectReply(turnId, windowId) { const turn = activeTurns.find((t) => t.id === turnId); if (!turn) return; + const previous = turn.selectedWindowId; const next = turn.selectedWindowId === windowId ? null : windowId; turn.selectedWindowId = next; activeTurns = activeTurns.map((t) => t.id === turnId ? { ...t, selectedWindowId: next } : t, ); + // 计数器:保留 +1,取消保留 −1(同一张卡切换就是旧模型 −1、新模型 +1) + const counted = activeWindows.find((w) => w.id === (next || previous)); + if (counted && activeConversationId) { + bumpKeepStats(activeConversationId, counted, next ? 1 : -1); + } renderArena(); + renderKeepStats(); if (activeIsTemp) { persistActiveSessionMeta(); setStatus( next - ? `已保留「${publicWindowLabel(activeWindows.find((w) => w.id === next) || {})}」为后续共用基准` + ? `已保留「${publicWindowLabel(activeWindows.find((w) => w.id === next) || {}, turn)}」为后续共用基准` : "已取消保留,各窗口继续用各自回复", ); return; @@ -1392,7 +2698,7 @@ async function selectReply(turnId, windowId) { renderArena(); setStatus( next - ? `已保留「${publicWindowLabel(activeWindows.find((w) => w.id === next) || {})}」为后续共用基准` + ? `已保留「${publicWindowLabel(activeWindows.find((w) => w.id === next) || {}, turn)}」为后续共用基准` : "已取消保留,各窗口继续用各自回复", ); } catch (err) { @@ -1402,8 +2708,9 @@ async function selectReply(turnId, windowId) { function openAddWindowDialog() { const used = new Set(activeWindows.map((w) => w.profileId)); - const available = profileList.filter((p) => p.hasKey && !used.has(p.id)); - if (!available.length) { + const available = profileList.filter((p) => p.hasKey); + const fresh = available.filter((p) => !used.has(p.id)); + if (!fresh.length) { setStatus( used.size ? "没有更多可用的 API 配置" @@ -1412,12 +2719,8 @@ function openAddWindowDialog() { ); return; } - els.addWindowProfile.innerHTML = available - .map( - (p) => - ``, - ) - .join(""); + els.addWindowModelsEmpty.hidden = true; + els.addWindowModels.innerHTML = modelPickHtml(available, { lockedIds: used }); els.dialogAddWindow.showModal(); } @@ -1425,26 +2728,33 @@ els.formAddWindow.addEventListener("submit", (e) => { const submitter = e.submitter; if (submitter?.value === "cancel") return; e.preventDefault(); - const profileId = els.addWindowProfile.value; - const p = profileList.find((x) => x.id === profileId); - if (!p) return; - if (activeWindows.some((w) => w.profileId === profileId)) { - setStatus("该配置已在窗口中", true); + const ids = [ + ...els.addWindowModels.querySelectorAll("input[name='model']:checked:not(:disabled)"), + ].map((el) => el.value); + if (!ids.length) { + els.addWindowModelsEmpty.hidden = false; return; } - activeWindows = [ - ...activeWindows, - { + const added = []; + for (const id of ids) { + const p = profileList.find((x) => x.id === id); + if (!p || activeWindows.some((w) => w.profileId === p.id)) continue; + added.push({ id: newId(), profileId: p.id, profileName: p.name, model: p.model, - }, - ]; + }); + } + if (!added.length) { + els.addWindowModelsEmpty.hidden = false; + return; + } + activeWindows = [...activeWindows, ...added]; persistActiveSessionMeta(); els.dialogAddWindow.close(); renderArena(); - setStatus(`已新增窗口:${p.name}`); + setStatus(added.length === 1 ? `已新增窗口:${added[0].profileName}` : `已新增 ${added.length} 个模型`); }); function removeWindow(windowId) { @@ -1458,6 +2768,12 @@ function removeWindow(windowId) { for (const key of [...expandedCards]) { if (key.endsWith(`:${windowId}`)) expandedCards.delete(key); } + for (const key of [...revealedCards]) { + if (key.endsWith(`:${windowId}`)) revealedCards.delete(key); + } + for (const key of [...thinkingToggles.keys()]) { + if (key.endsWith(`:${windowId}`)) thinkingToggles.delete(key); + } persistActiveSessionMeta(); renderArena(); } @@ -1485,6 +2801,9 @@ els.formPromote.addEventListener("submit", async (e) => { deleteTemp(temp.id); els.dialogPromote.close(); await openConversation(data.conversation.id); + // 计数跟着搬过去,别因为存成持久会话就丢了 + copyKeepStats(temp.id, data.conversation.id); + renderKeepStats(); setStatus("已保存为持久会话"); } catch (err) { setStatus(err instanceof Error ? err.message : String(err), true); @@ -1496,6 +2815,7 @@ async function deleteRailItem(id, isTemp) { try { if (isTemp) { deleteTemp(id); + clearKeepStats(id); if (activeConversationId === id) await goRoot(); else { renderRail(); @@ -1517,6 +2837,7 @@ async function deleteRailItem(id, isTemp) { await api(`/api/direct-chat/conversations/${encodeURIComponent(id)}`, { method: "DELETE", }); + clearKeepStats(id); if (activeConversationId === id) { if (activeShellId) await openShell(activeShellId); else await goRoot(); @@ -1536,7 +2857,39 @@ els.btnNewShell.addEventListener("click", () => { ); }); els.btnNewConv.addEventListener("click", () => { - createConversationUnderShell().catch((err) => + if (busy) { + setStatus("回复进行中,请稍后再新建", true); + return; + } + openNewConvDialog(); +}); +els.btnClearShell?.addEventListener("click", () => { + clearContextSlots().catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); +}); +els.formNewConv?.addEventListener("submit", (e) => { + const submitter = e.submitter; + if (submitter?.value === "cancel") return; + e.preventDefault(); + if (!activeShellId) return; + const name = els.newConvName.value.trim() || "新会话"; + const ids = [...els.newConvModels.querySelectorAll("input[name='model']:checked")].map( + (el) => el.value, + ); + const windows = []; + for (const id of ids) { + const p = profileList.find((x) => x.id === id); + if (!p) continue; + windows.push({ + id: newId(), + profileId: p.id, + profileName: p.name, + model: p.model, + }); + } + els.dialogNewConv.close(); + createConversationUnderShell(name, windows).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); }); @@ -1545,6 +2898,15 @@ els.btnSaveShell.addEventListener("click", () => { setStatus(err instanceof Error ? err.message : String(err), true), ); }); +els.tabShell?.addEventListener("click", () => setContextTab("shell")); +els.tabKeep?.addEventListener("click", () => setContextTab("keep")); +els.btnClearKeep?.addEventListener("click", () => { + if (!activeConversationId) return; + if (!window.confirm("清空这个会话的保留统计?")) return; + clearKeepStats(activeConversationId); + renderKeepStats(); + setStatus("已清空这个会话的保留统计"); +}); function openPromoteDialog() { if (!activeIsTemp || !activeConversationId) return; const temp = loadTemps().find((t) => t.id === activeConversationId); @@ -1586,11 +2948,18 @@ els.pathList.addEventListener("click", (e) => { els.railList.addEventListener("click", (e) => { const del = e.target.closest("[data-action='delete']"); + const copy = e.target.closest("[data-action='copy']"); const open = e.target.closest("[data-action='open']"); const row = e.target.closest(".chat-rail-item"); if (!row) return; const id = row.getAttribute("data-id"); const isTemp = row.getAttribute("data-temp") === "1"; + if (copy) { + copyConversation(id, isTemp).catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + return; + } if (del) { deleteRailItem(id, isTemp).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), @@ -1613,6 +2982,11 @@ els.railList.addEventListener("click", (e) => { ); }); +els.btnHideLeft?.addEventListener("click", () => toggleRail("left")); +els.btnShowLeft?.addEventListener("click", () => toggleRail("left")); +els.btnHideRight?.addEventListener("click", () => toggleRail("right")); +els.btnShowRight?.addEventListener("click", () => toggleRail("right")); + els.morePanel?.addEventListener("click", (e) => { const btn = e.target.closest("[data-action]"); if (!btn) return; @@ -1626,21 +3000,145 @@ els.morePanel?.addEventListener("click", (e) => { openPromoteDialog(); return; } + if (action === "copy-session" && activeConversationId) { + copyConversation(activeConversationId, activeIsTemp).catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + return; + } if (action === "blind-models") { toggleBlindModels(); return; } + if (action === "toggle-copy") { + toggleCopyMode(); + return; + } if (action === "remove-window") { const id = btn.getAttribute("data-window-id"); if (id) removeWindow(id); } }); +els.arena?.addEventListener("input", (e) => { + const ta = e.target.closest?.(".chat-user-edit"); + if (!(ta instanceof HTMLTextAreaElement)) return; + editingDraft = ta.value; + sizeUserEdit(ta); +}); + +els.arena?.addEventListener("keydown", (e) => { + const ta = e.target.closest?.(".chat-user-edit"); + if (!(ta instanceof HTMLTextAreaElement)) return; + if (e.key === "Escape") { + e.preventDefault(); + cancelEditTurn(); + return; + } + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + const turnId = ta.getAttribute("data-turn-id"); + if (!turnId) return; + const turn = activeTurns.find((t) => t.id === turnId); + const next = ta.value.trim(); + editingTurnId = null; + editingDraft = ""; + if (!next || next === (turn?.user?.content || "").trim()) { + renderArena(); + return; + } + rerollTurn(turnId, next).catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + } +}); + els.arena?.addEventListener("click", (e) => { + const editBtn = e.target.closest( + "[data-action='edit-turn'], [data-action='edit-cancel'], [data-action='edit-save'], [data-action='reroll-turn'], [data-action='variant-prev'], [data-action='variant-next']", + ); + if (editBtn) { + if (editBtn.disabled) return; + const action = editBtn.getAttribute("data-action"); + const turnId = editBtn.getAttribute("data-turn-id") || ""; + if (action === "edit-turn") { + startEditTurn(turnId); + return; + } + if (action === "edit-cancel") { + cancelEditTurn(); + return; + } + if (action === "edit-save") { + const ta = editBtn.closest(".chat-user-bubble")?.querySelector(".chat-user-edit"); + const turn = activeTurns.find((t) => t.id === turnId); + const next = (ta?.value ?? editingDraft).trim(); + editingTurnId = null; + editingDraft = ""; + if (!next || next === (turn?.user?.content || "").trim()) { + renderArena(); + if (!next) setStatus("内容不能为空", true); + return; + } + rerollTurn(turnId, next).catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + return; + } + if (action === "reroll-turn") { + const turn = activeTurns.find((t) => t.id === turnId); + if (!turn) return; + rerollTurn(turnId, turn.user?.content || "").catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + return; + } + if (action === "variant-prev" || action === "variant-next") { + switchVariant( + turnId, + action === "variant-next" ? "next" : "prev", + ).catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + } + return; + } + const retryBtn = e.target.closest( + "[data-action='retry-reply'], [data-action='retry-failed']", + ); + if (retryBtn) { + if (retryBtn.disabled) return; + const turnId = retryBtn.getAttribute("data-turn-id") || ""; + const windowIds = + retryBtn.getAttribute("data-action") === "retry-failed" + ? failedWindowIds(activeTurns.find((t) => t.id === turnId)) + : [retryBtn.getAttribute("data-window-id") || ""]; + retryReplies(turnId, windowIds).catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + return; + } if (e.target.closest("[data-action='add-window']")) { openAddWindowDialog(); return; } + const thinkSummary = e.target.closest?.("summary.chat-think-summary"); + if (thinkSummary) { + const details = thinkSummary.closest("details.chat-think"); + const card = thinkSummary.closest(".chat-reply-card"); + if (!details || !card) return; + e.preventDefault(); + const open = !details.open; + details.open = open; + thinkingToggles.set( + cardExpandKey( + card.getAttribute("data-turn-id") || "", + card.getAttribute("data-window-id") || "", + ), + open, + ); + return; + } const expandBtn = e.target.closest("[data-action='expand-card']"); if (expandBtn) { const turnId = expandBtn.getAttribute("data-turn-id") || ""; @@ -1674,6 +3172,15 @@ els.arena?.addEventListener("click", (e) => { scrollTrackToIndex(track, nextIndex); return; } + const copyBtn = copyMode ? e.target.closest("[data-action='copy-reply']") : null; + if (copyBtn) { + const turnId = copyBtn.getAttribute("data-turn-id") || ""; + const windowId = copyBtn.getAttribute("data-window-id") || ""; + copyReplyText(turnId, windowId, copyBtn).catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + return; + } const removeBtn = e.target.closest("[data-action='remove-window']"); if (removeBtn) { const id = @@ -1682,6 +3189,17 @@ els.arena?.addEventListener("click", (e) => { if (id) removeWindow(id); return; } + const revealBtn = e.target.closest("[data-action='reveal-card']"); + if (revealBtn) { + const turnId = revealBtn.getAttribute("data-turn-id") || ""; + const windowId = revealBtn.getAttribute("data-window-id") || ""; + const key = cardExpandKey(turnId, windowId); + const reveal = !revealedCards.has(key); + if (reveal) revealedCards.add(key); + else revealedCards.delete(key); + applyCardReveal(turnId, windowId, reveal); + return; + } const selectBtn = e.target.closest("[data-action='select-reply']"); if (!selectBtn) return; const turnId = selectBtn.getAttribute("data-turn-id"); @@ -1693,8 +3211,24 @@ els.arena?.addEventListener("click", (e) => { } }); +// 容器变高变矮后重新量一次折叠高度(上限跟着可见高度走) +let relayoutTimer = 0;window.addEventListener( + "resize", + () => { + window.clearTimeout(relayoutTimer); + relayoutTimer = window.setTimeout(() => { + if (navLevel === "conversation") collapseReplyTracks(); + }, 120); + }, + { passive: true }, +); + loadProfiles() - .then(() => goRoot()) + .then(() => loadChatPrefs()) + .then(() => { + syncContextTabs(); + }) + .then(() => restoreOpenTarget()) .catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); diff --git a/web/preview-arena-cards.html b/web/preview-arena-cards.html new file mode 100644 index 0000000..6c9f814 --- /dev/null +++ b/web/preview-arena-cards.html @@ -0,0 +1,649 @@ + + + + + + 预览 · 气泡下的模型卡片 + + + +
+
+ 临时会话 · 预览,回复都是字符串 + 同一条消息只出现一次,卡片可左右拖 +
+
+
+ + +
+
+ + + diff --git a/web/settings.css b/web/settings.css index aad2e98..3e4cf7b 100644 --- a/web/settings.css +++ b/web/settings.css @@ -503,6 +503,20 @@ code { 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 —— */ .btn-primary, .btn-secondary, @@ -1073,6 +1087,22 @@ code { 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 { margin-top: 4px; padding-left: 48px; @@ -1427,6 +1457,101 @@ code { 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 { display: flex; flex-direction: column; diff --git a/web/settings.html b/web/settings.html index 0ffc0db..e6d361c 100644 --- a/web/settings.html +++ b/web/settings.html @@ -34,6 +34,7 @@ +

保存在本机 ~/.writing-agent/,明文、不同步。

@@ -106,6 +107,53 @@ @@ -156,6 +204,32 @@
+ + diff --git a/web/settings.js b/web/settings.js index 3df9271..1457114 100644 --- a/web/settings.js +++ b/web/settings.js @@ -58,11 +58,15 @@ const sectionPersonaEl = document.getElementById("section-persona"); const sectionPreferenceEl = document.getElementById("section-preference"); const sectionPresetEl = document.getElementById("section-preset"); const sectionStorageEl = document.getElementById("section-storage"); +const sectionChatEl = document.getElementById("section-chat"); const preferenceCollectEveryEl = document.getElementById("preference-collect-every"); const activeSettingsBarEl = document.getElementById("active-settings-bar"); const settingsToastEl = document.getElementById("settings-toast"); const contextTraceKeepEl = document.getElementById("context-trace-keep"); 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 probePresetNameEl = document.getElementById("probe-preset-name"); const probeMessageEl = document.getElementById("probe-message"); @@ -154,6 +158,7 @@ function switchSection(section) { sectionPreferenceEl?.classList.toggle("hidden", section !== "preference"); sectionPresetEl.classList.toggle("hidden", section !== "preset"); sectionStorageEl?.classList.toggle("hidden", section !== "storage"); + sectionChatEl?.classList.toggle("hidden", section !== "chat"); renderPanelHeader(); } @@ -192,6 +197,9 @@ function renderPanelHeader() { btn.textContent = "导入 JSON"; btn.addEventListener("click", () => presetFileEl.click()); panelActionsEl.appendChild(btn); + } else if (activeSection === "chat") { + panelTitleEl.textContent = "直聊"; + panelSubtitleEl.textContent = "卡片拖动的跟手倍率"; } else { panelTitleEl.textContent = "上下文"; panelSubtitleEl.textContent = "上下文痕迹,以及对话落盘同轮补写次数"; @@ -780,6 +788,15 @@ async function loadAll() { if (dictateFillRetriesEl) { 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(); renderProfiles(); renderPersonas(); @@ -1659,14 +1676,23 @@ presetFileEl.addEventListener("change", async () => { }); lastImportedPresetId = report.preset.id; importReportEl.hidden = false; + const escapeHtml = (text) => + String(text).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], + ); + const core = report.missingCoreIdentifiers?.length + ? `缺少 ${escapeHtml(report.missingCoreIdentifiers.join(" / "))}` + : "框架完整"; importReportEl.innerHTML = ` -
${[ - `已导入: ${report.preset.name}`, - `条目: ${report.promptCount},启用: ${report.enabledCount}`, - report.warnings.length ? `警告: ${report.warnings.join("; ")}` : "", - ] - .filter(Boolean) - .join("
")}
+
已导入: ${escapeHtml(report.preset.name)}
+
条目 ${report.promptCount},启用 ${report.enabledCount};顺序块 character_id=${escapeHtml(report.orderCharacterId ?? "?")}(${core})
+ ${ + report.warnings?.length + ? `` + : `
没有需要注意的项目
` + }
`; @@ -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 () => { try { const n = Number(contextTraceKeepEl?.value ?? 5);