From f3303038fe05b523be32b75409bb4062469d8623 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Mon, 26 Oct 2026 01:09:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=A2=84=E8=AE=BE=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=EF=BC=9A=E7=BC=96=E8=BE=91=E3=80=81=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=A0=91=E3=80=81=E7=BB=93=E6=9E=84=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E3=80=81=E7=94=A8=E4=BE=8B=E8=AF=95=E8=B7=91=E6=94=BE=E5=9C=A8?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E9=A1=B5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改预设要能一边写一边看装出来的 messages,还要多模型对照,所以把编辑与试跑拆成两个视图塞进一个页面;版本存完整快照、可分叉,用例全局共用一份、运行记录按预设存。顺带让读预设文件先剥 BOM,静态资源改成不缓存,免得改了脚本还要强刷。 --- README.md | 2 + docs/preset-format.md | 2 + docs/preset-workbench.md | 183 +++ src/preset/authoring-check.ts | 116 ++ src/preset/lab-state.ts | 71 + src/preset/new-preset.ts | 108 ++ src/preset/store.ts | 9 +- src/preset/test-cases.ts | 209 +++ src/preset/test-run.ts | 224 +++ src/preset/test-runs.ts | 61 + src/preset/versions.ts | 150 ++ src/server/preset-lab-handlers.ts | 405 +++++ src/server/web-server.ts | 33 +- tests/preset-authoring.test.ts | 105 ++ tests/preset-lab-api.test.ts | 146 ++ tests/preset-lab-workbench.test.ts | 221 +++ tests/preset-test-cases.test.ts | 177 ++ web/presets.css | 1410 ++++++++++++++++ web/presets.html | 182 +++ web/presets.js | 2455 ++++++++++++++++++++++++++++ 20 files changed, 6263 insertions(+), 6 deletions(-) create mode 100644 docs/preset-workbench.md create mode 100644 src/preset/authoring-check.ts create mode 100644 src/preset/lab-state.ts create mode 100644 src/preset/new-preset.ts create mode 100644 src/preset/test-cases.ts create mode 100644 src/preset/test-run.ts create mode 100644 src/preset/test-runs.ts create mode 100644 src/preset/versions.ts create mode 100644 src/server/preset-lab-handlers.ts create mode 100644 tests/preset-authoring.test.ts create mode 100644 tests/preset-lab-api.test.ts create mode 100644 tests/preset-lab-workbench.test.ts create mode 100644 tests/preset-test-cases.test.ts create mode 100644 web/presets.css create mode 100644 web/presets.html create mode 100644 web/presets.js diff --git a/README.md b/README.md index 8021742..616f759 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - **游玩**:在钉住的规格下回合推进;可切换呈现壳,保存游玩进度 - **技能包**:能力以磁盘上的技能包装载(当前主包为「世界模拟器」相关配方与模块)(目前不需要手动选择) - **预设与库**:兼容 SillyTavern 风格的提示词预设;文风库 / 偏好库可注入创作节点,种子可从仓库恢复 +- **预设工作台**:`/presets.html` 内编辑模块与生成参数、版本树分叉、结构检查、用例组与多模型试跑(见 [preset-workbench.md](docs/preset-workbench.md)) - **多接口**:分组管理 API、嗅探型号、切换同一接口下的模型 - **本机优先**:Web(`localhost:23337`)或 `start-app.bat` 桌面壳;运维可用 `console.bat` @@ -105,6 +106,7 @@ Windows 运维脚本:`console.bat`(查询 / 启停 / 日志)。入口实 | [run-snapshot.md](docs/run-snapshot.md) | 存档 / 快照(以代码现状为准) | | [play-presentation-shells.md](docs/play-presentation-shells.md) | 游玩呈现壳 | | [preset-format.md](docs/preset-format.md) | 预设格式 | +| [preset-workbench.md](docs/preset-workbench.md) | 预设工作台(编辑 / 试跑) | | [skill-format.md](docs/skill-format.md) 等 | 技能 / 编排器 / Worker 格式 | | [px-roadmap.md](docs/px-roadmap.md) | 产品路线图 | | [skills/README.md](skills/README.md) | 技能包目录说明 | diff --git a/docs/preset-format.md b/docs/preset-format.md index d699eee..659f266 100644 --- a/docs/preset-format.md +++ b/docs/preset-format.md @@ -1,5 +1,7 @@ # 预设格式 +> 编辑、版本树、用例组与多模型试跑见 [preset-workbench.md](preset-workbench.md)。 + ## 1. 定位 Preset 是一次 LLM 请求的上下文编排资产。它负责描述 prompt manager 中有哪些条目、这些条目的启用状态与排列顺序,以及本次生成使用哪些模型参数。 diff --git a/docs/preset-workbench.md b/docs/preset-workbench.md new file mode 100644 index 0000000..9f5baec --- /dev/null +++ b/docs/preset-workbench.md @@ -0,0 +1,183 @@ +# 预设工作台 + +本仓库自用的预设编辑 / 试跑页面:`/presets.html`。它只编辑本仓库的原生 `PresetPackage`, +不做 SillyTavern 兼容导入(导入仍在设置页);目标是把「改一版 → 看结构 → 看实际 messages → +多模型试跑 → 标记好坏」压在同一页里。 + +## 1. 页面分工 + +页面是「一条身份条 + 两个视图 + 两个抽屉」。编辑与试跑是两件不同任务:编辑要连续写,试跑要宽度做对照, +因此不挤在同一个视口里。 + +```text +身份条 预设选择 / 新建 / 改名 / 脏状态 / 重新载入 / 试跑入口 / 版本树入口 / 保存两档 / 应用到对话页 + 唯一脏状态、唯一 primary(应用到对话页)都在这里,任何视图都能看到。 + 保存两档:「保存至当前版本」覆盖当前节点,「保存为新版本」追加子节点。 + 版本 id 不上条(只做 title),要看序列去版本树。 + +编辑视图 左轨(240)发送顺序大纲:序号 1..N、启用开关、位置标记、错误红点; + 行可直接拖动改顺序(落点画一条强调色横线),标记洞也能拖——左轨顺序就是装配顺序; + 未加入顺序的模块折叠在底部;最后两栏是「生成参数」与「假历史」。 + 中区 当前模块正文(一次只编辑一个;标记洞只显示说明,不能改名/删除,但能移顺序)。 + 「假历史」中区是逐条编辑:每条一个角色下拉 + 一整句正文 + 加一句 / 删除 / 上下移。 + 右栏(380)实际发出的 messages:顶部是结构检查状态带 + 用例选择 + 随时重算。 + 改正文、切模块、切用例都会本地重算预览(不调模型)。 + +试跑视图 顶部运行栏一行:当前用例名 · 模型(下拉多选:勾选不关菜单,可全选可用 / 清空) + · 已选条(只占一行,超出可左右拖,点一项取消) · 「运行此用例」。 + 左侧用例列表(一条 = 一个用例,点开进大浮窗编辑),右侧全宽结果网格(同一用例 × 多模型对照)。 + 低频入口:身份条上的「试跑 · 上次结果摘要」按钮,进入后按钮变成「← 编辑」。 + +抽屉 版本是右侧抽屉(版本树 / 说明 / 保存 / 载入 / 分叉); + 用例表单是**大浮窗**(左列 5 个字段,右列历史条目编辑器),字段多、历史要宽度, + 不再挤在窄侧栏里。两者都是低频或临时的,不常驻占内容宽度。 +``` + +编辑视图一屏不滚:大纲、正文、预览各自滚动。预览在 ≤1180px 退成右侧浮层,≤860px 大纲也退成浮层。 + +## 2. 草稿、版本与应用 + +```text +草稿 页面内存里的 state.preset;每次编辑只改这里。 +重新载入 丢掉草稿,从 presets/.json 重读;不动版本树。 +保存至当前版本 覆盖当前节点(amend),不新增节点。 +保存为新版本 把草稿作为子节点追加到版本树,当前节点移到新节点。 +载入选中 把选中节点的完整快照载入草稿,并把当前节点切过去。 +从选中分叉 用当前草稿在选中节点下建子节点,原分支不动。 +应用到对话页 先保存为新版本,再写 presets/.json;对话页从这里读。 +``` + +版本节点保存的是**完整 PresetPackage 快照**,不是 diff;因此任意节点都可独立载入。 +版本树文件:`/presets/versions/.json`。新建预设会同时落预设文件和根版本。 + +版本抽屉里是 git 式的树:竖线是分支,圆点是节点(实心 = 当前编辑位置),分叉另开一条线。 +点任一节点弹菜单:「读取」把这个快照换进编辑器,「从它分叉」用当前草稿在它下面开新分支, +再往右是「复制 id」。底部四个按钮(保存为新版本 / 保存至当前版本 / 载入选中 / 从选中分叉)保留给键盘流。 + +两种保存别搞混: + +```text +保存为新版本(Ctrl+S) 在当前位置下追加子节点;历史留着,能回滚。 +保存至当前版本(Ctrl+Shift+S) 直接覆盖当前节点,不产生新节点。适合“这条还没定,先改它”。 +应用到对话页 先保存为新版本,再覆盖 presets/.json。对话页读的是这个文件。 +``` + +装配只看 `prompt_order.enabled`;`prompts[].enabled` 是酒馆遗留字段。工作台的模块开关 +写的是顺序开关,因此和真实发送一致。 + +### 2.1 左轨定死顺序 + +```text +左轨顺序 = 装配顺序 = 真实发送顺序。 + +装配(src/preset/markers.ts: applyExtendedMarkers)只补缺不重排: +缺哪个洞就按锚点插进来(历史前 → 对话历史 → 历史后 → 本轮对话 → … → 本轮之后), +已经在 prompt_order 里的条目一个不动,作者拖到哪儿就发在哪儿。 + +右侧预览只是展示:同一条装配路径 + 预设的「消息整形」。整形会合并消息(`strict` 默认), +条数变少时预览元信息会写「整形 strict(装配 N 条 → 发出 M 条)」; +想逐条原样发,把「生成参数 → 消息整形」改成 `none`。 +``` + +## 3. 结构检查 + +不调用模型,只查会在装配 / 发送时出问题的结构: + +- 顺序引用了不存在的模块、同一模块在顺序里重复、顺序号重复; +- 未启用 `chatHistory`、模块为空、全角 `{{`; +- 未闭合的 `{{`(`{{{...}}` 的首个 `{` 是字面量,不算未闭合); +- 未识别宏(会原样外发); +- 标记位置重复启用。 + +`error` 只阻止「应用到对话页」,不阻止保存版本 / 预览;需要时可以强制写回。 + +## 4. 用例 + +一个用例就是一条记录:自己存、自己运行。页面上不出现「组」这个层级。 + +**用例是全局的,不和预设绑定**:一个用例只描述「要测的输入 + 上下文」,任何预设都能拿它去跑。 +盘上是 `/presets/tests/global/*.json`(沿用旧 `TestCaseGroup` 形状,每份用例一个只装它的组文件); +旧的按预设分目录存的(`tests//*.json`)会在第一次访问时并进 global,原文件不删。 +删掉最后一份用例时组文件一并收掉。 + +「这个预设上次打开的是哪条用例」另外存:`/presets/lab-state/.json`, +记 `selectedCaseId`(运行视图当前用例)与 `previewCaseId`(右栏预览用例)。 +写盘而不是塞浏览器缓存 —— 换浏览器、重开页面都还是同一条;切回别的预设又回到它自己那条。 + +用例列表的点击分工:**点卡片 = 选中(设为当前用例)**,只有卡片右边的「编辑」才打开用例编辑浮窗。 + +一个用例只包含变动项: + +```text +用例名 / 角色名({{char}}) / 角色描述 / 世界书文本 / 用户输入 / 可选历史 +``` + +历史是**逐条编辑**的:每条一个角色下拉(`user` / `assistant`)加一整句正文,正文里的换行就是真换行, +不再用「每行 `user: 内容`」的文本行和 `\n` 转义(旧写法会把不带头部的续行吞掉)。 +落盘仍是 `{role, content}[]`,装配时一条一个 message,接在预设假历史之后。 +组装走线上同一条路径:`assemblePlayWorkerMessages` → `shapeFramedMessages`。 + +## 4.1 假历史与示例对话是两回事 + +```text +假历史 预设级、真多轮(user / assistant),进「对话.历史」区,排在真实历史之前。 + 模型会把它当成已经说过的话,会占轮次;编辑器里正文换行原样保留。 + 存储:PresetPackage.pseudoHistory,装配见 src/preset/pseudo-history.ts。 + +示例对话 酒馆 mes_example( 分块),发给模型时是 system + example_user / + example_assistant,不占轮次,只做 few-shot;正文仍是整段文本,保持酒馆兼容。 +``` + +两者互不覆盖:要「让模型以为前情已经发生」用假历史,要「示范写法但不冒充轮次」用示例对话。 +三者(预设假历史 / 用例历史 / 直聊种子历史)用的是同一个控件:`web/history-editor.js` + `web/history-editor.css`。 +每行左侧的 `⠿` 是拖动手柄:按住拖到另一行的上半 / 下半即插到它前面 / 后面,松手就到位; +↑↓ 按钮保留(精确微调 / 不用鼠标拖时)。 + +## 5. 预览与多模型运行 + +```text +预览 不调用模型;显示当前草稿 + 当前用例实际发出的 messages。 +运行 每个 用例 × 模型 发一次;默认最多 4 路并发、单次 180 秒超时。 + 运行使用请求里的草稿,所以不必先保存版本。 +``` + +运行记录:`/presets/runs//.json`,保存 `presetVersionId`、每次运行的 +`messages` 快照和每个模型的结果。结果不自动评分,人工标记成功 / 失败;标记会落盘。 +历史运行可在结果卡上方切回,并展开「发送内容」核对实际请求。 + +## 6. HTTP 接口 + +前缀 `/api/preset-lab/`: + +```text +GET presets 预设列表 +POST presets/create 新建原生预设 + 根版本 +GET preset?presetId= 读预设文件 +POST preset/apply 应用到对话页({force:true} 跳过 error) +GET versions?presetId= 版本树 +POST versions/commit 追加节点(缺省父节点=当前;指定历史节点=分叉) +POST versions/amend 覆盖当前节点(保存至当前版本) +POST versions/checkout 切换当前节点 +POST check 结构检查 +POST preview 按用例组装 messages +POST run 多模型运行(可带 preset 草稿;一次跑一个用例) +GET cases 使用例列表(全局,不看 presetId) +POST cases/save 存单份用例(带 groupId 就写回原文件) +POST cases/delete 删单份用例(空文件自动收掉) +GET lab-state?presetId= 这个预设上次打开的用例 +POST lab-state 写 { presetId, selectedCaseId?, previewCaseId? } +GET groups 旧接口:测试组列表 +POST groups/save 旧接口:整组覆盖 +POST groups/delete 旧接口:删组 +GET runs?presetId= 运行记录列表 +GET run?presetId=&runId= 单次运行 +POST verdict 修改人工标记 +GET profiles 可选 API 配置 +``` + +## 7. 已知边界 + +- 版本树不做自动合并;分叉后各分支独立。 +- 运行是普通 HTTP 请求,页面会一直等到全部结果返回;没有逐条流式进度。 +- 「应用到对话页」只改预设文件;要让已有会话立刻使用,去设置页点「启用」。 +- 测试用例不会自动生成,也不会自动评分。 \ No newline at end of file diff --git a/src/preset/authoring-check.ts b/src/preset/authoring-check.ts new file mode 100644 index 0000000..b539236 --- /dev/null +++ b/src/preset/authoring-check.ts @@ -0,0 +1,116 @@ +import { collectMacroUsages, findUnclosedMacro } from "../directives/macros.js"; +import type { PresetPackage } from "../types/preset.js"; +import { CHAT_HISTORY_MARKER } from "./markers.js"; + +/** + * 预设编辑器的结构检查(不调用模型)。 + * error:会导致装配或发送异常;warn:会让预设行为与预期不符,但仍可发送。 + */ +export type AuthoringIssue = { + level: "error" | "warn"; + entryId: string | null; + entryName: string; + message: string; +}; + +const VALID_ROLES = new Set(["system", "user", "assistant"]); +const FULLWIDTH_OPEN = "{{"; + +export function checkAuthoringPreset(preset: PresetPackage): AuthoringIssue[] { + const issues: AuthoringIssue[] = []; + const byId = new Map(); + const push = ( + level: AuthoringIssue["level"], + entryId: string | null, + entryName: string, + message: string, + ) => issues.push({ level, entryId, entryName, message }); + + if (preset.prompts.length === 0) { + push("error", null, "", "预设没有任何模块"); + return issues; + } + + for (const prompt of preset.prompts) { + if (byId.has(prompt.id)) { + push("error", prompt.id, prompt.name || prompt.id, "模块 id 重复"); + continue; + } + byId.set(prompt.id, prompt); + } + + const enabledOrder = preset.promptOrder.filter((o) => o.enabled); + if (!enabledOrder.some((o) => o.promptId === CHAT_HISTORY_MARKER)) { + push("warn", null, "", "没有启用「对话历史」位置,真实聊天记录不会进入请求"); + } + + const inOrder = new Set(preset.promptOrder.map((o) => o.promptId)); + for (const prompt of preset.prompts) { + if (!inOrder.has(prompt.id)) { + push("warn", prompt.id, prompt.name, "未加入顺序,不会发送"); + } + } + + const seenOrder = new Set(); + const seenOrderIndex = new Set(); + const seenMarkers = new Set(); + for (const item of preset.promptOrder) { + if (seenOrder.has(item.promptId)) { + push("error", item.promptId, item.promptId, "顺序中重复引用了同一模块"); + } + seenOrder.add(item.promptId); + if (seenOrderIndex.has(item.orderIndex)) { + push("warn", item.promptId, item.promptId, `顺序号重复:${item.orderIndex}`); + } + seenOrderIndex.add(item.orderIndex); + if (!byId.has(item.promptId)) { + push("error", item.promptId, item.promptId, "顺序中引用了不存在的模块"); + } + } + + for (const item of enabledOrder) { + const entry = byId.get(item.promptId); + if (!entry) continue; + const name = entry.name || entry.id; + + if (entry.marker) { + if (seenMarkers.has(entry.id)) { + push("warn", entry.id, name, "位置在同一顺序中启用了多次,会重复填充"); + } + seenMarkers.add(entry.id); + continue; + } + + if (!VALID_ROLES.has(entry.role)) { + push("error", entry.id, name, `角色非法:${String(entry.role)}`); + } + + const body = entry.content ?? ""; + if (!body.trim()) { + push("warn", entry.id, name, "模块正文为空,将被跳过"); + continue; + } + + if (body.includes(FULLWIDTH_OPEN)) { + push( + "warn", + entry.id, + name, + "含全角 {{,运行时不会展开,请改为半角 {{", + ); + } + + const unclosed = findUnclosedMacro(body); + if (unclosed) { + push("error", entry.id, name, `存在未闭合的 {{ 宏:${unclosed}`); + } + + for (const usage of collectMacroUsages(body)) { + if (!usage.known) { + push("warn", entry.id, name, `未识别宏 ${usage.name},将原样发出`); + } + } + } + + return issues; +} \ No newline at end of file diff --git a/src/preset/lab-state.ts b/src/preset/lab-state.ts new file mode 100644 index 0000000..146ff5d --- /dev/null +++ b/src/preset/lab-state.ts @@ -0,0 +1,71 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { getPresetsDir } from "../config/user-data-dir.js"; + +/** + * 每个预设在试跑视图里的「上次打开过啥」。 + * + * 用例本身是全局的(`presets/tests/global`),但「这个预设上次用的是哪条用例」 + * 得按预设记,否则切换预设时选择会跳。存盘而不是塞浏览器缓存:换浏览器、 + * 重开页面都还是同一条。 + * + * 文件:`/presets/lab-state/.json` + */ +export type PresetLabState = { + presetId: string; + /** 试跑视图的当前用例(`TestCase.id`) */ + selectedCaseId: string | null; + /** 编辑视图右栏预览用的用例 */ + previewCaseId: string | null; + updatedAt: string; +}; + +const SAFE_ID = /^[\w\u4e00-\u9fff-]+$/; + +function stateDir(): string { + return path.join(getPresetsDir(), "lab-state"); +} + +function stateFile(presetId: string): string { + if (!SAFE_ID.test(presetId)) throw new Error(`非法预设 id:${presetId}`); + return path.join(stateDir(), `${presetId}.json`); +} + +function emptyState(presetId: string): PresetLabState { + return { presetId, selectedCaseId: null, previewCaseId: null, updatedAt: new Date().toISOString() }; +} + +export function loadPresetLabState(presetId: string): PresetLabState { + const file = stateFile(presetId); + if (!existsSync(file)) return emptyState(presetId); + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial; + return { + presetId, + selectedCaseId: typeof parsed.selectedCaseId === "string" ? parsed.selectedCaseId : null, + previewCaseId: typeof parsed.previewCaseId === "string" ? parsed.previewCaseId : null, + updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString(), + }; + } catch { + return emptyState(presetId); + } +} + +/** 只写传进来的字段,没传的保持原样。 */ +export function savePresetLabState( + presetId: string, + patch: { selectedCaseId?: string | null; previewCaseId?: string | null }, +): PresetLabState { + const current = loadPresetLabState(presetId); + const next: PresetLabState = { + ...current, + selectedCaseId: + patch.selectedCaseId === undefined ? current.selectedCaseId : patch.selectedCaseId, + previewCaseId: + patch.previewCaseId === undefined ? current.previewCaseId : patch.previewCaseId, + updatedAt: new Date().toISOString(), + }; + mkdirSync(stateDir(), { recursive: true }); + writeFileSync(stateFile(presetId), JSON.stringify(next, null, 2), "utf8"); + return next; +} diff --git a/src/preset/new-preset.ts b/src/preset/new-preset.ts new file mode 100644 index 0000000..7962bd0 --- /dev/null +++ b/src/preset/new-preset.ts @@ -0,0 +1,108 @@ +import { randomUUID } from "node:crypto"; +import type { PresetPackage } from "../types/preset.js"; + +/** + * 工作台新建一份原生预设。 + * 骨架给一条可编辑规则 + 完整的扩展 marker 顺序,方便直接在页面上 + * 调整位置;applyExtendedMarkers 调用时仍是幂等的。 + */ +export function createNativePreset(input: { + id?: string | null; + name?: string | null; +} = {}): PresetPackage { + const id = input.id?.trim() || `preset-${randomUUID().slice(0, 8)}`; + const name = input.name?.trim() || "未命名预设"; + return { + id, + name, + source: "native", + prompts: [ + { + id: "system-rules", + name: "写作规则", + enabled: true, + role: "system", + content: + "你是中文小说叙事者。按用户输入续写故事,保持人物、设定与前文一致;只输出正文,不解释写作过程。", + marker: false, + sourceIdentifier: "system-rules", + }, + { + id: "charDescription", + name: "角色描述", + enabled: true, + role: "system", + content: "", + marker: true, + sourceIdentifier: "charDescription", + }, + { + id: "personaDescription", + name: "用户人设", + enabled: true, + role: "system", + content: "", + marker: true, + sourceIdentifier: "personaDescription", + }, + { + id: "worldBookBefore", + name: "世界书(历史前)", + enabled: true, + role: "system", + content: "", + marker: true, + sourceIdentifier: "worldBookBefore", + }, + { + id: "chatHistory", + name: "对话历史", + enabled: true, + role: "system", + content: "", + marker: true, + sourceIdentifier: "chatHistory", + }, + { + id: "worldBookAfter", + name: "世界书(历史后)", + enabled: true, + role: "system", + content: "", + marker: true, + sourceIdentifier: "worldBookAfter", + }, + { + id: "currentTurn", + name: "本轮对话", + enabled: true, + role: "user", + content: "", + marker: true, + sourceIdentifier: "currentTurn", + }, + { + id: "postTurn", + name: "本轮之后", + enabled: true, + role: "system", + content: "", + marker: true, + sourceIdentifier: "postTurn", + }, + ], + promptOrder: [ + { promptId: "system-rules", enabled: true, orderIndex: 0 }, + { promptId: "charDescription", enabled: true, orderIndex: 1 }, + { promptId: "personaDescription", enabled: true, orderIndex: 2 }, + { promptId: "worldBookBefore", enabled: true, orderIndex: 3 }, + { promptId: "chatHistory", enabled: true, orderIndex: 4 }, + { promptId: "worldBookAfter", enabled: true, orderIndex: 5 }, + { promptId: "currentTurn", enabled: true, orderIndex: 6 }, + { promptId: "postTurn", enabled: true, orderIndex: 7 }, + ], + generation: {}, + unsupported: [], + importedAt: new Date().toISOString(), + }; +} \ No newline at end of file diff --git a/src/preset/store.ts b/src/preset/store.ts index 45f8e28..13689ba 100644 --- a/src/preset/store.ts +++ b/src/preset/store.ts @@ -19,6 +19,11 @@ function presetPath(id: string): string { return path.join(getPresetsDir(), `${id}.json`); } +/** Windows 编辑器可能给 JSON 加 BOM;JSON.parse 不认,先剥掉。 */ +function parsePreset(raw: string): PresetPackage { + return JSON.parse(raw.replace(/^\uFEFF/, "")) as PresetPackage; +} + export function listPresets(): PresetPackage[] { ensureUserDataDirs(); let files: string[]; @@ -33,7 +38,7 @@ export function listPresets(): PresetPackage[] { try { const filePath = path.join(getPresetsDir(), file); const raw = readFileSync(filePath, "utf8"); - presets.push(withoutReasoningEffort(JSON.parse(raw) as PresetPackage, filePath)); + presets.push(withoutReasoningEffort(parsePreset(raw), filePath)); } catch { /* skip corrupt files */ } @@ -45,7 +50,7 @@ export function getPreset(id: string): PresetPackage | null { try { const filePath = presetPath(id); const raw = readFileSync(filePath, "utf8"); - return withoutReasoningEffort(JSON.parse(raw) as PresetPackage, filePath); + return withoutReasoningEffort(parsePreset(raw), filePath); } catch { return null; } diff --git a/src/preset/test-cases.ts b/src/preset/test-cases.ts new file mode 100644 index 0000000..dcb96f4 --- /dev/null +++ b/src/preset/test-cases.ts @@ -0,0 +1,209 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { getPresetsDir } from "../config/user-data-dir.js"; + +/** + * 测试用例:**全局,不和预设绑定**——一个用例只描述「要测的输入 + 上下文」, + * 任何预设都能拿它去跑。存在 `/presets/tests/global/` 下,每组一个 JSON 文件 + * (沿用 TestCaseGroup 形状,旧的按预设分目录的文件会在第一次访问时并进来)。 + * + * 每个用例只包含变动项:角色名与描述({{char}})、世界书、用户输入、可选历史。 + */ +export type TestCaseHistoryItem = { role: "user" | "assistant"; content: string }; + +export type TestCase = { + id: string; + name: string; + charName: string; + charDescription: string; + worldBook: string; + userInput: string; + history: TestCaseHistoryItem[]; +}; + +export type TestCaseGroup = { + id: string; + /** 历史字段:现在恒为 GLOBAL_CASES_ID,留着只是为了旧文件能直接读。 */ + presetId: string; + name: string; + updatedAt: string; + cases: TestCase[]; +}; + +export const GLOBAL_CASES_ID = "global"; + +const SAFE_ID = /^[\w\u4e00-\u9fff-]+$/; + +function assertSafe(id: string, label: string): void { + if (!SAFE_ID.test(id)) throw new Error(`非法${label}:${id}`); +} + +function casesRoot(): string { + return path.join(getPresetsDir(), "tests"); +} + +function globalDir(): string { + return path.join(casesRoot(), GLOBAL_CASES_ID); +} + +function groupFile(groupId: string): string { + assertSafe(groupId, "用例 id"); + return path.join(globalDir(), `${groupId}.json`); +} + +/** + * 老版本把用例按预设分目录存(`tests//*.json`)。全局目录第一次被用到时, + * 把那些文件并进来(id 冲突就跳过),原文件不删,方便回退。 + */ +function ensureGlobalDir(): string { + const dir = globalDir(); + if (existsSync(dir)) return dir; + mkdirSync(dir, { recursive: true }); + const root = casesRoot(); + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === GLOBAL_CASES_ID) continue; + const legacyDir = path.join(root, entry.name); + for (const file of readdirSync(legacyDir).filter((name) => name.endsWith(".json"))) { + const target = path.join(dir, file); + if (existsSync(target)) continue; + try { + const group = JSON.parse(readFileSync(path.join(legacyDir, file), "utf8")) as TestCaseGroup; + writeFileSync( + target, + JSON.stringify({ ...group, presetId: GLOBAL_CASES_ID }, null, 2), + "utf8", + ); + } catch { + /* 坏文件跳过 */ + } + } + } + return dir; +} + +export function listTestGroups(): TestCaseGroup[] { + const dir = ensureGlobalDir(); + const groups: TestCaseGroup[] = []; + for (const file of readdirSync(dir).filter((name) => name.endsWith(".json"))) { + try { + groups.push(JSON.parse(readFileSync(path.join(dir, file), "utf8")) as TestCaseGroup); + } catch { + /* skip corrupt */ + } + } + return groups.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); +} + +export function getTestGroup(groupId: string): TestCaseGroup | null { + try { + return JSON.parse(readFileSync(groupFile(groupId), "utf8")) as TestCaseGroup; + } catch { + return null; + } +} + +/** 新建或覆盖一组。cases 中缺 id 的会分配 id。 */ +export function saveTestGroup(input: { + id?: string; + name: string; + cases: Array>; +}): TestCaseGroup { + const id = input.id?.trim() || randomUUID().slice(0, 8); + const text = (value: unknown): string => (typeof value === "string" ? value : ""); + const cases: TestCase[] = input.cases.map((c) => ({ + id: text(c.id).trim() || randomUUID().slice(0, 8), + name: text(c.name).trim() || "未命名用例", + charName: text(c.charName).trim(), + charDescription: text(c.charDescription), + worldBook: text(c.worldBook), + userInput: text(c.userInput), + history: (Array.isArray(c.history) ? c.history : []).filter( + (h) => (h.role === "user" || h.role === "assistant") && typeof h.content === "string", + ), + })); + const group: TestCaseGroup = { + id, + presetId: GLOBAL_CASES_ID, + name: input.name.trim() || "未命名测试组", + updatedAt: new Date().toISOString(), + cases, + }; + ensureGlobalDir(); + writeFileSync(groupFile(id), JSON.stringify(group, null, 2), "utf8"); + return group; +} + +export function deleteTestGroup(groupId: string): void { + try { + unlinkSync(groupFile(groupId)); + } catch { + /* ignore */ + } +} + +/* ---------- 用例(工作台的展示单位) ---------- + * 盘上仍是一组一个文件,但界面上不出现「组」:每份用例都是一条独立记录, + * 自己存、自己运行。新建用例会为它开一个只装一份用例的组文件。 + */ + +export type TestCaseRef = { groupId: string; case: TestCase }; + +/** 平铺所有用例(按组更新时间排序,组内保持原顺序)。 */ +export function listTestCases(): TestCaseRef[] { + const out: TestCaseRef[] = []; + for (const group of listTestGroups()) { + for (const item of group.cases) out.push({ groupId: group.id, case: item }); + } + return out; +} + +/** + * 保存单份用例:给了 groupId 就写回那个组(组内其他用例不动), + * 没给就新建一个只装它的组。 + */ +export function saveTestCase(input: { + groupId?: string; + case: Partial; +}): TestCaseRef { + const groupId = input.groupId?.trim(); + if (!groupId) { + const name = typeof input.case.name === "string" ? input.case.name.trim() : ""; + const group = saveTestGroup({ + name: name || "未命名用例", + cases: [input.case], + }); + return { groupId: group.id, case: group.cases[0]! }; + } + + const group = getTestGroup(groupId); + if (!group) throw new Error(`用例不存在:${groupId}`); + const caseId = typeof input.case.id === "string" ? input.case.id.trim() : ""; + const index = group.cases.findIndex((item) => item.id === caseId); + const cases = + index >= 0 + ? group.cases.map((item, i) => (i === index ? { ...item, ...input.case } : item)) + : [...group.cases, input.case]; + // 只剩一份用例时,组名跟着用例名走,盘上读起来不至于两个名字打架。 + const single = cases.length === 1; + const caseName = typeof input.case.name === "string" ? input.case.name.trim() : ""; + const saved = saveTestGroup({ + id: groupId, + name: single && caseName ? caseName : group.name, + cases, + }); + const wanted = index >= 0 ? caseId : saved.cases[saved.cases.length - 1]!.id; + return { + groupId, + case: saved.cases.find((item) => item.id === wanted) ?? saved.cases[0]!, + }; +} + +/** 删除单份用例;组里没别的人就把组文件一起删掉。 */ +export function deleteTestCase(groupId: string, caseId: string): void { + const group = getTestGroup(groupId); + if (!group) return; + const cases = group.cases.filter((item) => item.id !== caseId); + if (cases.length === 0) deleteTestGroup(groupId); + else saveTestGroup({ id: groupId, name: group.name, cases }); +} diff --git a/src/preset/test-run.ts b/src/preset/test-run.ts new file mode 100644 index 0000000..acf2ac7 --- /dev/null +++ b/src/preset/test-run.ts @@ -0,0 +1,224 @@ +import { randomUUID } from "node:crypto"; +import { getApiProfile, listApiProfiles, profileToLlmConfig } from "../config/api-profiles.js"; +import { OpenAiCompatibleProvider, type ChatMessage, type TokenUsage } from "../llm/client.js"; +import { assemblePlayWorkerMessages, shapeFramedMessages } from "./play-frame.js"; +import { resolveSquashForPreset } from "../llm/preset-wrapper.js"; +import { checkAuthoringPreset, type AuthoringIssue } from "./authoring-check.js"; +import type { PresetPackage } from "../types/preset.js"; +import type { MessageSquashMode } from "../types/preset.js"; +import type { TestCase } from "./test-cases.js"; +import type { WorldInfoPack } from "./world-info-pack.js"; + +/** + * 按真实发送路径组装一个用例的 messages(不调用模型)。 + * 与线上一致:预设 → assemblePlayWorkerMessages → 消息整形。 + * 变动的只有 char 描述、世界书、用户输入、历史。 + */ +export function buildCaseMessages(preset: PresetPackage, testCase: TestCase): ChatMessage[] { + return shapeFramedMessages( + assembleCaseMessages(preset, testCase), + resolveSquashForPreset(preset, "follow-preset"), + ); +} + +/** + * 工作台右侧预览:同一组装结果,多告诉页面两件事—— + * 本次用的整形模式、整形前的条数。左轨是真实顺序,整形后条数变少要能看出来。 + */ +export function buildCasePreview( + preset: PresetPackage, + testCase: TestCase, +): { messages: ChatMessage[]; squash: MessageSquashMode; assembledCount: number } { + const framed = assembleCaseMessages(preset, testCase); + const squash = resolveSquashForPreset(preset, "follow-preset"); + return { + messages: shapeFramedMessages(framed, squash), + squash, + assembledCount: framed.length, + }; +} + +/** 装配那一步本身(右栏与试跑共用,整形在调用方决定)。 */ +function assembleCaseMessages( + preset: PresetPackage, + testCase: TestCase, +): ChatMessage[] { + const asText = (value: unknown): string => (typeof value === "string" ? value : ""); + const historyInput = (Array.isArray(testCase.history) ? testCase.history : []).filter( + (item) => item && typeof item === "object", + ); + const history: ChatMessage[] = historyInput.map((h) => ({ + role: h.role === "assistant" ? "assistant" : "user", + content: typeof h.content === "string" ? h.content : "", + })); + const worldBook = asText(testCase.worldBook); + const charDescription = asText(testCase.charDescription); + const pack: WorldInfoPack = { + worldBookBefore: worldBook.trim() + ? [{ id: "test-world", name: "世界书", content: worldBook }] + : [], + history, + worldBookAfter: [], + turn: asText(testCase.userInput), + postTurn: [], + card: charDescription.trim() ? { description: charDescription } : undefined, + }; + const framed = assemblePlayWorkerMessages({ + systemPrompt: "", + preset, + pack, + persona: null, + charName: asText(testCase.charName) || null, + }); + return framed; +} + +export type RunStatus = "running" | "done" | "error"; + +export type CaseRunResult = { + profileId: string; + profileName: string; + model: string; + caseId: string; + caseName?: string; + status: RunStatus; + content?: string; + error?: string; + usage?: TokenUsage; + durationMs?: number; + /** 手动标记:null 表示未标记 */ + verdict: "success" | "failure" | null; + startedAt: string; + finishedAt?: string; +}; + +export type TestRun = { + id: string; + presetId: string; + presetVersionId: string; + groupId: string; + createdAt: string; + /** 本次运行时的消息快照(每个用例一份),便于事后核对发送内容 */ + requests: Array<{ caseId: string; messages: ChatMessage[] }>; + results: CaseRunResult[]; + issues: AuthoringIssue[]; +}; + +export const TEST_RUN_TIMEOUT_MS = 180_000; +const DEFAULT_CONCURRENCY = 4; + +/** 小并发池:多模型 × 多用例时不要一次全打出去。 */ +async function runPool(jobs: Array<() => Promise>, limit: number): Promise { + const queue = [...jobs]; + const width = Math.max(1, Math.min(limit, queue.length)); + const workers = Array.from({ length: width }, async () => { + while (queue.length > 0) { + const job = queue.shift(); + if (job) await job(); + } + }); + await Promise.all(workers); +} + +/** + * 对一组用例、多个 API 配置并行运行。 + * 同一用例的同一配置只发一次;各配置之间并行。 + */ +export async function runTestGroup(input: { + preset: PresetPackage; + presetVersionId: string; + presetId: string; + groupId: string; + cases: TestCase[]; + profileIds: string[]; + timeoutMs?: number; + concurrency?: number; +}): Promise { + const profileIds = [...new Set(input.profileIds.map((x) => x.trim()).filter(Boolean))]; + if (profileIds.length === 0) throw new Error("请至少选择一个 API 配置"); + if (input.cases.length === 0) throw new Error("测试组中没有用例"); + + const profiles = profileIds.map((id) => { + const profile = getApiProfile(id); + if (!profile?.apiKey?.trim()) throw new Error(`API 配置不可用:${id}`); + return profile; + }); + + const requests = input.cases.map((c) => ({ caseId: c.id, messages: buildCaseMessages(input.preset, c) })); + const run: TestRun = { + id: randomUUID().slice(0, 8), + presetId: input.presetId, + presetVersionId: input.presetVersionId, + groupId: input.groupId, + createdAt: new Date().toISOString(), + requests, + results: [], + issues: checkAuthoringPreset(input.preset), + }; + + const timeoutMs = Math.max(1_000, input.timeoutMs ?? TEST_RUN_TIMEOUT_MS); + const jobs: Array<() => Promise> = []; + for (const profile of profiles) { + for (const req of requests) { + const testCase = input.cases.find((c) => c.id === req.caseId); + const slot: CaseRunResult = { + profileId: profile.id, + profileName: profile.name, + model: profile.model, + caseId: req.caseId, + caseName: testCase?.name, + status: "running", + verdict: null, + startedAt: new Date().toISOString(), + }; + run.results.push(slot); + jobs.push(async () => { + const started = Date.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const llm = new OpenAiCompatibleProvider(profileToLlmConfig(profile)); + const result = await llm.complete(req.messages, { + generation: input.preset.generation, + caller: `preset-test:${profile.id}`, + signal: controller.signal, + }); + slot.content = result.content ?? ""; + if (result.model) slot.model = result.model; + if (result.usage) slot.usage = result.usage; + slot.status = "done"; + } catch (err) { + slot.status = "error"; + if (controller.signal.aborted) { + slot.error = `超时:${Math.round(timeoutMs / 1000)} 秒内没有完成`; + } else { + slot.error = err instanceof Error ? err.message : String(err); + } + } finally { + clearTimeout(timer); + slot.durationMs = Date.now() - started; + slot.finishedAt = new Date().toISOString(); + } + }); + } + } + await runPool(jobs, input.concurrency ?? DEFAULT_CONCURRENCY); + return run; +} + +export function listRunnableProfiles(): Array<{ id: string; name: string; model: string; hasKey: boolean }> { + return listApiProfiles().map((p) => ({ + id: p.id, + name: p.name, + model: p.model, + hasKey: Boolean(p.apiKey?.trim()), + })); +} + +/** 手动标记结果:只保存标签,不做自动评分。 */ +export function setVerdict(run: TestRun, profileId: string, caseId: string, verdict: "success" | "failure" | null): TestRun { + const slot = run.results.find((r) => r.profileId === profileId && r.caseId === caseId); + if (!slot) throw new Error("结果不存在"); + slot.verdict = verdict; + return run; +} \ No newline at end of file diff --git a/src/preset/test-runs.ts b/src/preset/test-runs.ts new file mode 100644 index 0000000..1b8a95e --- /dev/null +++ b/src/preset/test-runs.ts @@ -0,0 +1,61 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { getPresetsDir } from "../config/user-data-dir.js"; +import type { TestRun } from "./test-run.js"; + +/** 运行记录:presets/runs//.json,保存预设版本 id 以便追溯。 */ +const SAFE_ID = /^[\w\u4e00-\u9fff-]+$/; + +function runDir(presetId: string): string { + if (!SAFE_ID.test(presetId)) throw new Error(`非法预设 id:${presetId}`); + return path.join(getPresetsDir(), "runs", presetId); +} + +function runFile(presetId: string, runId: string): string { + if (!SAFE_ID.test(runId)) throw new Error(`非法运行 id:${runId}`); + return path.join(runDir(presetId), `${runId}.json`); +} + +export function saveTestRun(run: TestRun): void { + mkdirSync(runDir(run.presetId), { recursive: true }); + writeFileSync(runFile(run.presetId, run.id), JSON.stringify(run, null, 2), "utf8"); +} + +export function getTestRun(presetId: string, runId: string): TestRun | null { + try { + return JSON.parse(readFileSync(runFile(presetId, runId), "utf8")) as TestRun; + } catch { + return null; + } +} + +export function listTestRuns(presetId: string): TestRun[] { + const dir = runDir(presetId); + if (!existsSync(dir)) return []; + const out: TestRun[] = []; + for (const file of readdirSync(dir).filter((f) => f.endsWith(".json"))) { + try { + out.push(JSON.parse(readFileSync(path.join(dir, file), "utf8")) as TestRun); + } catch { + /* skip */ + } + } + return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + +/** 修改某个结果的手动标记并落盘。 */ +export function updateVerdict( + presetId: string, + runId: string, + profileId: string, + caseId: string, + verdict: "success" | "failure" | null, +): TestRun { + const run = getTestRun(presetId, runId); + if (!run) throw new Error("运行记录不存在"); + const slot = run.results.find((r) => r.profileId === profileId && r.caseId === caseId); + if (!slot) throw new Error("结果不存在"); + slot.verdict = verdict; + saveTestRun(run); + return run; +} diff --git a/src/preset/versions.ts b/src/preset/versions.ts new file mode 100644 index 0000000..2edf95f --- /dev/null +++ b/src/preset/versions.ts @@ -0,0 +1,150 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { getPresetsDir } from "../config/user-data-dir.js"; +import type { PresetPackage } from "../types/preset.js"; + +/** + * 预设版本树:每个预设一个 JSON 文件,节点是完整快照。 + * - 保存:在指定父节点(缺省为当前节点)下追加子节点; + * - 分叉:父节点选历史中的任意一个,形成新分支,原分支不动; + * - 追溯:沿 parentId 往上走即为修改历史。 + * 不依赖 git,不做远程同步。 + */ +export type PresetVersionNode = { + id: string; + parentId: string | null; + createdAt: string; + /** 被「保存至当前版本」覆盖过的时间;没改过就不写 */ + updatedAt?: string; + note: string; + preset: PresetPackage; +}; + +export type PresetVersionTree = { + presetId: string; + currentId: string | null; + nodes: PresetVersionNode[]; +}; + +const SAFE_ID = /^[\w\u4e00-\u9fff-]+$/; + +function versionsDir(): string { + return path.join(getPresetsDir(), "versions"); +} + +function treeFile(presetId: string): string { + if (!SAFE_ID.test(presetId)) throw new Error(`非法预设 id:${presetId}`); + return path.join(versionsDir(), `${presetId}.json`); +} + +function writeTree(tree: PresetVersionTree): void { + mkdirSync(versionsDir(), { recursive: true }); + writeFileSync(treeFile(tree.presetId), JSON.stringify(tree, null, 2), "utf8"); +} + +export function loadVersionTree(presetId: string): PresetVersionTree { + const file = treeFile(presetId); + if (!existsSync(file)) return { presetId, currentId: null, nodes: [] }; + return JSON.parse(readFileSync(file, "utf8")) as PresetVersionTree; +} + +export function getVersionNode( + presetId: string, + nodeId: string, +): PresetVersionNode | null { + return loadVersionTree(presetId).nodes.find((n) => n.id === nodeId) ?? null; +} + +/** 从根到指定节点的路径(含两端),即该版本的修改历史。 */ +export function versionLineage( + tree: PresetVersionTree, + nodeId: string, +): PresetVersionNode[] { + const byId = new Map(tree.nodes.map((n) => [n.id, n])); + const out: PresetVersionNode[] = []; + let cursor = byId.get(nodeId) ?? null; + const guard = new Set(); + while (cursor && !guard.has(cursor.id)) { + guard.add(cursor.id); + out.unshift(cursor); + cursor = cursor.parentId ? byId.get(cursor.parentId) ?? null : null; + } + return out; +} + +/** + * 提交一个版本。 + * parentId 缺省为当前节点;显式传入历史节点即为分叉。 + * 树为空时 parentId 必须为 null(建根);已有根时不允许再建第二个根。 + */ +export function commitVersion(input: { + presetId: string; + preset: PresetPackage; + note?: string; + parentId?: string | null; +}): PresetVersionTree { + const tree = loadVersionTree(input.presetId); + const parentId = + input.parentId !== undefined ? input.parentId : tree.currentId; + + if (parentId === null) { + if (tree.nodes.length > 0) throw new Error("已有版本,不能再新建根版本"); + } else if (!tree.nodes.some((n) => n.id === parentId)) { + throw new Error(`父版本不存在:${parentId}`); + } + + let id = randomUUID().slice(0, 8); + while (tree.nodes.some((n) => n.id === id)) id = randomUUID().slice(0, 8); + const node: PresetVersionNode = { + id, + parentId, + createdAt: new Date().toISOString(), + note: (input.note ?? "").trim(), + preset: structuredClone(input.preset), + }; + tree.nodes.push(node); + tree.currentId = node.id; + writeTree(tree); + return tree; +} + +/** + * 保存到当前版本(amend):把快照直接换掉 `currentId` 那个节点,不产生新节点。 + * 还没有任何版本时退化成建根。 + */ +export function amendCurrentVersion(input: { + presetId: string; + preset: PresetPackage; + note?: string; +}): PresetVersionTree { + const tree = loadVersionTree(input.presetId); + if (!tree.currentId) return commitVersion({ ...input, parentId: null }); + const node = tree.nodes.find((item) => item.id === tree.currentId); + if (!node) throw new Error(`当前版本不存在:${tree.currentId}`); + node.preset = structuredClone(input.preset); + const note = (input.note ?? "").trim(); + if (note) node.note = note; + node.updatedAt = new Date().toISOString(); + writeTree(tree); + return tree; +} + +/** 切换当前编辑位置(不产生新节点)。 */ +export function checkoutVersion(presetId: string, nodeId: string): PresetVersionTree { + const tree = loadVersionTree(presetId); + if (!tree.nodes.some((n) => n.id === nodeId)) { + throw new Error(`版本不存在:${nodeId}`); + } + tree.currentId = nodeId; + writeTree(tree); + return tree; +} + +export function deleteVersionTree(presetId: string): void { + try { + rmSync(treeFile(presetId), { force: true }); + } catch { + /* ignore */ + } +} diff --git a/src/server/preset-lab-handlers.ts b/src/server/preset-lab-handlers.ts new file mode 100644 index 0000000..df026c3 --- /dev/null +++ b/src/server/preset-lab-handlers.ts @@ -0,0 +1,405 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { getPreset, listPresets, savePreset } from "../preset/store.js"; +import { checkAuthoringPreset } from "../preset/authoring-check.js"; +import { createNativePreset } from "../preset/new-preset.js"; +import { amendCurrentVersion, checkoutVersion, commitVersion, getVersionNode, loadVersionTree } from "../preset/versions.js"; +import { deleteTestGroup, listTestCases, listTestGroups, saveTestCase, saveTestGroup, deleteTestCase } from "../preset/test-cases.js"; +import { loadPresetLabState, savePresetLabState } from "../preset/lab-state.js"; +import { buildCasePreview, listRunnableProfiles, runTestGroup } from "../preset/test-run.js"; +import { getTestRun, listTestRuns, saveTestRun, updateVerdict } from "../preset/test-runs.js"; +import { setActivePresetId } from "../config/settings.js"; +import type { PresetPackage } from "../types/preset.js"; +import type { TestCase } from "../preset/test-cases.js"; + +const SAFE_ID = /^[\w\u4e00-\u9fff-]+$/; + +function assertSafeId(id: string, label: string): string { + const trimmed = id.trim(); + if (!SAFE_ID.test(trimmed)) throw new Error(`非法${label}:${id}`); + return trimmed; +} + +function presetOf(body: Record): PresetPackage { + const preset = body.preset; + if (!preset || typeof preset !== "object") throw new Error("缺少 preset"); + const typed = preset as PresetPackage; + assertSafeId(typed.id, "预设 id"); + return typed; +} + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function json(res: ServerResponse, status: number, data: unknown): void { + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(JSON.stringify(data)); +} + +async function readJson(req: IncomingMessage): Promise> { + const raw = await readBody(req); + if (!raw.trim()) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("请求体必须是 JSON 对象"); + } + return parsed as Record; +} + +function str(body: Record, key: string): string { + const value = body[key]; + return typeof value === "string" ? value : ""; +} + +function strList(body: Record, key: string): string[] { + const value = body[key]; + if (!Array.isArray(value)) return []; + return value.filter((x): x is string => typeof x === "string"); +} + +function numberOrUndefined(value: unknown): number | undefined { + const n = typeof value === "number" ? value : Number(value); + return Number.isFinite(n) ? n : undefined; +} + +/** 取运行时用的预设:优先版本节点快照,其次预设文件。 */ +function resolveLabPreset(presetId: string, versionId: string): PresetPackage { + const id = assertSafeId(presetId, "预设 id"); + if (versionId) { + const node = getVersionNode(id, versionId); + if (node) return node.preset; + throw new Error(`版本不存在:${versionId}`); + } + const preset = getPreset(id); + if (!preset) throw new Error(`预设不存在:${id}`); + return preset; +} + +/** + * 预设工作台 API。前缀 /api/preset-lab/。 + * 只做编辑、检查、预览、测试;不做自动评分。 + */ +export async function handlePresetLabApi( + req: IncomingMessage, + res: ServerResponse, + pathname: string, + searchParams: URLSearchParams, +): Promise { + if (!pathname.startsWith("/api/preset-lab/")) return false; + const route = pathname.slice("/api/preset-lab/".length); + const method = req.method ?? "GET"; + + try { + if (method === "GET" && route === "presets") { + json(res, 200, { + presets: listPresets().map((p) => ({ + id: p.id, + name: p.name, + source: p.source, + promptCount: p.prompts.length, + })), + }); + return true; + } + + if (method === "POST" && route === "presets/create") { + const body = await readJson(req); + const requestedId = str(body, "id").trim(); + if (requestedId) assertSafeId(requestedId, "预设 id"); + const preset = createNativePreset({ + id: requestedId || null, + name: str(body, "name") || null, + }); + if (getPreset(preset.id)) throw new Error(`预设已存在:${preset.id}`); + if (loadVersionTree(preset.id).nodes.length > 0) { + throw new Error(`版本树已存在:${preset.id}`); + } + savePreset(preset); + const tree = commitVersion({ + presetId: preset.id, + preset, + note: str(body, "note") || "新建预设", + }); + json(res, 201, { preset, tree }); + return true; + } + + if (method === "GET" && route === "profiles") { + json(res, 200, { profiles: listRunnableProfiles() }); + return true; + } + + if (method === "GET" && route === "preset") { + const presetId = assertSafeId(searchParams.get("presetId") ?? "", "预设 id"); + const preset = getPreset(presetId); + if (!preset) { + json(res, 404, { error: `预设不存在:${presetId}` }); + return true; + } + json(res, 200, { preset }); + return true; + } + + if (method === "POST" && route === "preset") { + const body = await readJson(req); + const preset = presetOf(body); + const saved = commitVersion({ + presetId: preset.id, + preset, + note: str(body, "note"), + parentId: body.parentId === null ? null : typeof body.parentId === "string" ? body.parentId : undefined, + }); + json(res, 200, saved); + return true; + } + + if (method === "POST" && route === "preset/apply") { + const body = await readJson(req); + const preset = presetOf(body); + const issues = checkAuthoringPreset(preset); + const errors = issues.filter((issue) => issue.level === "error"); + if (errors.length > 0 && body.force !== true) { + const head = errors + .slice(0, 3) + .map((issue) => `${issue.entryName ? issue.entryName + ":" : ""}${issue.message}`) + .join(";"); + throw new Error(`结构检查有 ${errors.length} 个错误,先修正或强制写回:${head}`); + } + const saved = savePreset(structuredClone(preset)); + const activate = body.activate === true; + if (activate) setActivePresetId(saved.id); + json(res, 200, { + ok: true, + preset: saved, + issues, + activePresetId: activate ? saved.id : null, + }); + return true; + } + + if (method === "GET" && route === "versions") { + const presetId = assertSafeId(searchParams.get("presetId") ?? "", "预设 id"); + json(res, 200, loadVersionTree(presetId)); + return true; + } + + if (method === "POST" && route === "versions/commit") { + const body = await readJson(req); + const preset = presetOf(body); + const bodyPresetId = str(body, "presetId"); + if (bodyPresetId && bodyPresetId !== preset.id) { + throw new Error(`presetId 与 preset.id 不一致:${bodyPresetId} / ${preset.id}`); + } + const tree = commitVersion({ + presetId: preset.id, + preset, + note: str(body, "note"), + parentId: body.parentId === null ? null : typeof body.parentId === "string" ? body.parentId : undefined, + }); + json(res, 200, tree); + return true; + } + + if (method === "POST" && route === "versions/amend") { + const body = await readJson(req); + const preset = presetOf(body); + const tree = amendCurrentVersion({ + presetId: preset.id, + preset, + note: str(body, "note"), + }); + json(res, 200, tree); + return true; + } + + if (method === "POST" && route === "versions/checkout") { + const body = await readJson(req); + const tree = checkoutVersion( + assertSafeId(str(body, "presetId"), "预设 id"), + assertSafeId(str(body, "nodeId"), "版本 id"), + ); + json(res, 200, tree); + return true; + } + + if (method === "POST" && route === "check") { + const body = await readJson(req); + json(res, 200, { issues: checkAuthoringPreset(presetOf(body)) }); + return true; + } + + if (method === "GET" && route === "groups") { + json(res, 200, { groups: listTestGroups() }); + return true; + } + + if (method === "POST" && route === "groups/save") { + const body = await readJson(req); + const cases = Array.isArray(body.cases) ? (body.cases as Array>) : []; + const group = saveTestGroup({ + id: str(body, "id") || undefined, + name: str(body, "name"), + cases, + }); + json(res, 200, group); + return true; + } + + if (method === "POST" && route === "groups/delete") { + const body = await readJson(req); + deleteTestGroup(assertSafeId(str(body, "id"), "测试组 id")); + json(res, 200, { ok: true }); + return true; + } + + if (method === "GET" && route === "cases") { + // 用例是全局的,不看 presetId;带上也无害 + json(res, 200, { cases: listTestCases() }); + return true; + } + + if (method === "GET" && route === "lab-state") { + const presetId = assertSafeId(searchParams.get("presetId") ?? "", "预设 id"); + json(res, 200, loadPresetLabState(presetId)); + return true; + } + + if (method === "POST" && route === "lab-state") { + const body = await readJson(req); + const presetId = assertSafeId(str(body, "presetId"), "预设 id"); + const asId = (value: unknown): string | null | undefined => { + if (value === undefined) return undefined; + if (value === null) return null; + return typeof value === "string" && value.trim() ? value.trim() : null; + }; + json( + res, + 200, + savePresetLabState(presetId, { + selectedCaseId: asId(body.selectedCaseId), + previewCaseId: asId(body.previewCaseId), + }), + ); + return true; + } + + if (method === "POST" && route === "cases/save") { + const body = await readJson(req); + const testCase = body.case; + if (!testCase || typeof testCase !== "object") throw new Error("缺少 case"); + const saved = saveTestCase({ + groupId: str(body, "groupId") || undefined, + case: testCase as Partial, + }); + json(res, 200, saved); + return true; + } + + if (method === "POST" && route === "cases/delete") { + const body = await readJson(req); + deleteTestCase( + assertSafeId(str(body, "groupId"), "用例 id"), + str(body, "caseId"), + ); + json(res, 200, { ok: true }); + return true; + } + + if (method === "POST" && route === "preview") { + const body = await readJson(req); + const preset = presetOf(body); + const testCase = body.testCase as TestCase | undefined; + if (!testCase || typeof testCase !== "object") throw new Error("缺少 testCase"); + json(res, 200, buildCasePreview(preset, testCase)); + return true; + } + + if (method === "POST" && route === "run") { + const body = await readJson(req); + const presetId = assertSafeId(str(body, "presetId"), "预设 id"); + const versionId = str(body, "presetVersionId"); + const groupId = assertSafeId(str(body, "groupId"), "测试组 id"); + const caseIds = strList(body, "caseIds"); + const profileIds = strList(body, "profileIds"); + // 工作台允许直接跑当前草稿;不传 preset 时才退回已保存的版本/文件。 + const draft = body.preset; + const preset = + draft && typeof draft === "object" + ? (() => { + const typed = draft as PresetPackage; + assertSafeId(typed.id, "预设 id"); + if (typed.id !== presetId) throw new Error("preset.id 与 presetId 不一致"); + return typed; + })() + : resolveLabPreset(presetId, versionId); + + const groups = listTestGroups(); + const group = groups.find((g) => g.id === groupId); + if (!group) throw new Error(`测试组不存在:${groupId}`); + const cases = caseIds.length > 0 ? group.cases.filter((c) => caseIds.includes(c.id)) : group.cases; + if (cases.length === 0) throw new Error("没有选中任何用例"); + + const run = await runTestGroup({ + preset, + presetId, + presetVersionId: versionId, + groupId, + cases, + profileIds, + timeoutMs: numberOrUndefined(body.timeoutMs), + concurrency: numberOrUndefined(body.concurrency), + }); + saveTestRun(run); + json(res, 200, run); + return true; + } + + if (method === "GET" && route === "runs") { + json(res, 200, { + runs: listTestRuns(assertSafeId(searchParams.get("presetId") ?? "", "预设 id")), + }); + return true; + } + + if (method === "GET" && route === "run") { + const run = getTestRun( + assertSafeId(searchParams.get("presetId") ?? "", "预设 id"), + assertSafeId(searchParams.get("runId") ?? "", "运行 id"), + ); + if (!run) { + json(res, 404, { error: "运行记录不存在" }); + return true; + } + json(res, 200, run); + return true; + } + + if (method === "POST" && route === "verdict") { + const body = await readJson(req); + const raw = body.verdict; + const verdict = raw === "success" || raw === "failure" ? raw : null; + const run = updateVerdict( + assertSafeId(str(body, "presetId"), "预设 id"), + assertSafeId(str(body, "runId"), "运行 id"), + str(body, "profileId"), + str(body, "caseId"), + verdict, + ); + json(res, 200, run); + return true; + } + + json(res, 404, { error: `未知接口:${pathname}` }); + return true; + } catch (err) { + json(res, 400, { error: err instanceof Error ? err.message : String(err) }); + return true; + } +} \ No newline at end of file diff --git a/src/server/web-server.ts b/src/server/web-server.ts index 7b13512..5c29c2d 100644 --- a/src/server/web-server.ts +++ b/src/server/web-server.ts @@ -8,6 +8,7 @@ import { handleSettingsApi } from "./settings-handlers.js"; import { handleBooksApi } from "./book-handlers.js"; import { handleStatsApi } from "./stats-handlers.js"; import { handleDirectChatApi } from "./direct-chat-handlers.js"; +import { handlePresetLabApi } from "./preset-lab-handlers.js"; import { syncOpenRouterPrices, type PriceWatchItem } from "../stats/pricing.js"; import { ensureConsoleUtf8, getRuntimeLogPath } from "../log.js"; @@ -42,10 +43,11 @@ async function serveStatic(res: ServerResponse, filePath: string): Promise const headers: Record = { "Content-Type": types[ext] ?? "application/octet-stream", }; - // 开发期:避免 ES module(agent-ui.js 等)被强缓存,改完 import 仍吃旧脚本导致主区空白 - if (ext === ".html" || ext === ".js" || ext === ".css") { - headers["Cache-Control"] = "no-cache, must-revalidate"; - } + // 开发期:页面与脚本一律不缓存。`no-cache` 还允许浏览器拿着旧副本去问一下, + // 换版本后容易继续吃旧 JS;这里直接 `no-store`,刷新即最新。 + headers["Cache-Control"] = "no-store, must-revalidate"; + headers["Pragma"] = "no-cache"; + headers["Expires"] = "0"; res.writeHead(200, headers); res.end(content); } @@ -81,6 +83,25 @@ const server = createServer(async (req, res) => { "GET /api/modules", "GET /api/direct-chat/shells", "POST /api/direct-chat/ephemeral/complete", + "GET /api/preset-lab/presets", + "POST /api/preset-lab/presets/create", + "GET /api/preset-lab/preset", + "POST /api/preset-lab/preset/apply", + "GET /api/preset-lab/versions", + "POST /api/preset-lab/versions/commit", + "POST /api/preset-lab/versions/checkout", + "POST /api/preset-lab/check", + "POST /api/preset-lab/preview", + "GET /api/preset-lab/groups", + "POST /api/preset-lab/groups/save", + "POST /api/preset-lab/groups/delete", + "GET /api/preset-lab/cases", + "POST /api/preset-lab/cases/save", + "POST /api/preset-lab/cases/delete", + "POST /api/preset-lab/run", + "GET /api/preset-lab/runs", + "GET /api/preset-lab/run", + "POST /api/preset-lab/verdict", "GET /api/stats/tokens", "GET /api/stats/pricing", "GET /api/stats/pricing/openrouter", @@ -104,6 +125,10 @@ const server = createServer(async (req, res) => { return; } + if (await handlePresetLabApi(req, res, url.pathname, url.searchParams)) { + return; + } + if (await handleStatsApi(req, res, url.pathname, url.searchParams)) { return; } diff --git a/tests/preset-authoring.test.ts b/tests/preset-authoring.test.ts new file mode 100644 index 0000000..e22de69 --- /dev/null +++ b/tests/preset-authoring.test.ts @@ -0,0 +1,105 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { PresetPackage } from "../src/types/preset.js"; +import { checkAuthoringPreset } from "../src/preset/authoring-check.js"; +import { + checkoutVersion, + commitVersion, + loadVersionTree, + versionLineage, +} from "../src/preset/versions.js"; + +function preset(id: string, content = "正文"): PresetPackage { + return { + id, + name: id, + source: "native", + prompts: [ + { id: "sys", name: "规则", enabled: true, role: "system", content, marker: false, sourceIdentifier: "sys" }, + { id: "chatHistory", name: "对话历史", enabled: true, role: "system", content: "", marker: true, sourceIdentifier: "chatHistory" }, + ], + promptOrder: [ + { promptId: "sys", enabled: true, orderIndex: 0 }, + { promptId: "chatHistory", enabled: true, orderIndex: 1 }, + ], + generation: {}, + unsupported: [], + importedAt: new Date().toISOString(), + }; +} + +let dir = ""; +beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), "preset-versions-")); + process.env.WRITING_AGENT_DATA_DIR = dir; +}); +afterEach(() => { + delete process.env.WRITING_AGENT_DATA_DIR; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("版本树", () => { + it("线性保存并可追溯", () => { + commitVersion({ presetId: "p1", preset: preset("p1", "v1") }); + const tree = commitVersion({ presetId: "p1", preset: preset("p1", "v2") }); + const lineage = versionLineage(tree, tree.currentId!); + expect(lineage.map((n) => n.preset.prompts[0]!.content)).toEqual(["v1", "v2"]); + }); + + it("从历史节点分叉,原分支保持不变", () => { + const first = commitVersion({ presetId: "p2", preset: preset("p2", "a") }); + const rootId = first.currentId!; + commitVersion({ presetId: "p2", preset: preset("p2", "b") }); + const forked = commitVersion({ presetId: "p2", preset: preset("p2", "c"), parentId: rootId }); + expect(forked.nodes).toHaveLength(3); + const branch = forked.nodes.filter((n) => n.parentId === rootId); + expect(branch).toHaveLength(2); + expect(versionLineage(forked, forked.currentId!)[0]!.id).toBe(rootId); + }); + + it("不允许再建第二个根,也不允许挂到不存在的父节点", () => { + commitVersion({ presetId: "p3", preset: preset("p3") }); + expect(() => commitVersion({ presetId: "p3", preset: preset("p3"), parentId: null })).toThrow(); + expect(() => commitVersion({ presetId: "p3", preset: preset("p3"), parentId: "missing" })).toThrow(); + }); + + it("checkout 只移动当前位置,不新增节点", () => { + const tree = commitVersion({ presetId: "p4", preset: preset("p4") }); + const rootId = tree.currentId!; + commitVersion({ presetId: "p4", preset: preset("p4", "x") }); + const moved = checkoutVersion("p4", rootId); + expect(moved.currentId).toBe(rootId); + expect(loadVersionTree("p4").nodes).toHaveLength(2); + }); + + it("拒绝非法预设 id(防路径穿越)", () => { + expect(() => loadVersionTree("../evil")).toThrow(); + }); +}); + +describe("结构检查", () => { + it("正常预设没有 error", () => { + const issues = checkAuthoringPreset(preset("ok")); + expect(issues.filter((i) => i.level === "error")).toHaveLength(0); + }); + + it("全角 {{ 给 warn 而不是 error", () => { + const issues = checkAuthoringPreset(preset("fw", "你好 {{user{{")); + expect(issues.some((i) => i.level === "warn" && i.message.includes("全角"))).toBe(true); + expect(issues.some((i) => i.level === "error")).toBe(false); + }); + + it("未闭合的 {{ 为 error", () => { + const issues = checkAuthoringPreset(preset("open", "{{setvar::a::1")); + expect(issues.some((i) => i.level === "error" && i.message.includes("未闭合"))).toBe(true); + }); + + it("未启用对话历史位置给 warn", () => { + const p = preset("nohist"); + p.promptOrder = p.promptOrder.filter((o) => o.promptId !== "chatHistory"); + const issues = checkAuthoringPreset(p); + expect(issues.some((i) => i.message.includes("对话历史"))).toBe(true); + }); +}); diff --git a/tests/preset-lab-api.test.ts b/tests/preset-lab-api.test.ts new file mode 100644 index 0000000..39a8ba9 --- /dev/null +++ b/tests/preset-lab-api.test.ts @@ -0,0 +1,146 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { Readable } from "node:stream"; +import os from "node:os"; +import path from "node:path"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { savePreset } from "../src/preset/store.js"; +import { handlePresetLabApi } from "../src/server/preset-lab-handlers.js"; +import type { PresetPackage } from "../src/types/preset.js"; + +function preset(id = "lab"): PresetPackage { + return { + id, + name: "工作台预设", + source: "native", + prompts: [ + { id: "sys", name: "规则", enabled: true, role: "system", content: "\u4f60\u662f{{char}}", marker: false, sourceIdentifier: "sys" }, + { id: "chatHistory", name: "历史", enabled: true, role: "system", content: "", marker: true, sourceIdentifier: "chatHistory" }, + ], + promptOrder: [ + { promptId: "sys", enabled: true, orderIndex: 0 }, + { promptId: "chatHistory", enabled: true, orderIndex: 1 }, + ], + generation: {}, + unsupported: [], + importedAt: new Date().toISOString(), + }; +} + +type Captured = { status: number; body: any }; + +async function call( + pathname: string, + init: { method?: string; body?: unknown; query?: string } = {}, +): Promise { + const raw = init.body === undefined ? "" : JSON.stringify(init.body); + const req = Readable.from(raw ? [Buffer.from(raw, "utf8")] : []) as unknown as IncomingMessage; + (req as any).method = init.method ?? "GET"; + let status = 0; + let payload = ""; + const res = { + writeHead(code: number) { + status = code; + }, + end(chunk: string) { + payload = chunk ?? ""; + }, + } as unknown as ServerResponse; + const handled = await handlePresetLabApi(req, res, pathname, new URLSearchParams(init.query ?? "")); + expect(handled).toBe(true); + return { status, body: payload ? JSON.parse(payload) : null }; +} + +let dir = ""; +beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), "preset-lab-api-")); + process.env.WRITING_AGENT_DATA_DIR = dir; + savePreset(preset()); +}); +afterEach(() => { + delete process.env.WRITING_AGENT_DATA_DIR; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("预设工作台 API", () => { + it("非本模块路径不接管", async () => { + const req = Readable.from([]) as unknown as IncomingMessage; + (req as any).method = "GET"; + const res = { writeHead() {}, end() {} } as unknown as ServerResponse; + expect(await handlePresetLabApi(req, res, "/api/settings/x", new URLSearchParams())).toBe(false); + }); + + it("列出预设", async () => { + const { status, body } = await call("/api/preset-lab/presets"); + expect(status).toBe(200); + expect(body.presets.some((p: any) => p.id === "lab")).toBe(true); + }); + + it("读取单个预设与结构检查", async () => { + const one = await call("/api/preset-lab/preset", { query: "presetId=lab" }); + expect(one.body.preset.id).toBe("lab"); + const checked = await call("/api/preset-lab/check", { method: "POST", body: { preset: one.body.preset } }); + expect(checked.body.issues.filter((i: any) => i.level === "error")).toHaveLength(0); + }); + + it("提交版本后能读回版本树", async () => { + const committed = await call("/api/preset-lab/versions/commit", { + method: "POST", + body: { preset: preset(), note: "第一次" }, + }); + expect(committed.body.nodes).toHaveLength(1); + const tree = await call("/api/preset-lab/versions", { query: "presetId=lab" }); + expect(tree.body.currentId).toBe(committed.body.nodes[0].id); + }); + + it("保存至当前版本只覆盖节点,不新增", async () => { + const first = await call("/api/preset-lab/versions/commit", { + method: "POST", + body: { preset: preset(), note: "建根" }, + }); + const nodeId = first.body.currentId; + const edited = { ...preset(), name: "改过的名字" }; + const amended = await call("/api/preset-lab/versions/amend", { + method: "POST", + body: { preset: edited, note: "就地改" }, + }); + expect(amended.body.nodes).toHaveLength(1); + expect(amended.body.currentId).toBe(nodeId); + const node = amended.body.nodes[0]; + expect(node.id).toBe(nodeId); + expect(node.parentId).toBe(null); + expect(node.preset.name).toBe("改过的名字"); + expect(node.note).toBe("就地改"); + expect(typeof node.updatedAt).toBe("string"); + }); + + it("预览按用例组装消息,{{char}} 被替换", async () => { + const { status, body } = await call("/api/preset-lab/preview", { + method: "POST", + body: { + preset: preset(), + testCase: { id: "c", name: "c", charName: "林", charDescription: "", worldBook: "", userInput: "开门", history: [] }, + }, + }); + expect(status).toBe(200); + const text = body.messages.map((m: any) => m.content).join("\n"); + expect(text).toContain("你是林"); + expect(text).toContain("开门"); + }); + + it("测试组保存与列出", async () => { + const saved = await call("/api/preset-lab/groups/save", { + method: "POST", + body: { presetId: "lab", name: "组一", cases: [{ name: "a", userInput: "hi" }] }, + }); + expect(saved.body.cases[0].id).toBeTruthy(); + const groups = await call("/api/preset-lab/groups", { query: "presetId=lab" }); + expect(groups.body.groups).toHaveLength(1); + }); + + it("缺少 preset 时返回 400 而不是崩溃", async () => { + const { status, body } = await call("/api/preset-lab/check", { method: "POST", body: {} }); + expect(status).toBe(400); + expect(body.error).toContain("preset"); + }); +}); diff --git a/tests/preset-lab-workbench.test.ts b/tests/preset-lab-workbench.test.ts new file mode 100644 index 0000000..513af1b --- /dev/null +++ b/tests/preset-lab-workbench.test.ts @@ -0,0 +1,221 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { Readable } from "node:stream"; +import os from "node:os"; +import path from "node:path"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getPreset } from "../src/preset/store.js"; +import { createNativePreset } from "../src/preset/new-preset.js"; +import type { PresetPackage } from "../src/types/preset.js"; + +vi.mock("../src/llm/client.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + OpenAiCompatibleProvider: class { + async complete(messages: unknown[], options?: { caller?: string }) { + return { + content: `mock:${options?.caller ?? "unknown"}`, + usage: { promptTokens: 1, completionTokens: 2, totalTokens: 3 }, + model: "mock-model", + messages, + }; + } + }, + }; +}); + +import { handlePresetLabApi } from "../src/server/preset-lab-handlers.js"; + +type Captured = { status: number; body: any }; + +async function call( + pathname: string, + init: { method?: string; body?: unknown; query?: string } = {}, +): Promise { + const raw = init.body === undefined ? "" : JSON.stringify(init.body); + const req = Readable.from(raw ? [Buffer.from(raw, "utf8")] : []) as unknown as IncomingMessage; + (req as any).method = init.method ?? "GET"; + let status = 0; + let payload = ""; + const res = { + writeHead(code: number) { + status = code; + }, + end(chunk: string) { + payload = chunk ?? ""; + }, + } as unknown as ServerResponse; + const handled = await handlePresetLabApi(req, res, pathname, new URLSearchParams(init.query ?? "")); + expect(handled).toBe(true); + return { status, body: payload ? JSON.parse(payload) : null }; +} + +function writeProfiles(dir: string): void { + mkdirSync(dir, { recursive: true }); + const now = new Date().toISOString(); + writeFileSync( + path.join(dir, "profiles.json"), + JSON.stringify({ + version: 1, + profiles: [ + { + id: "p1", + name: "本地模型", + baseUrl: "https://example.invalid/v1", + apiKey: "test-key", + model: "mock-model", + createdAt: now, + updatedAt: now, + }, + ], + }), + "utf8", + ); +} + +let dir = ""; +beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), "preset-lab-workbench-")); + process.env.WRITING_AGENT_DATA_DIR = dir; + writeProfiles(dir); +}); +afterEach(() => { + delete process.env.WRITING_AGENT_DATA_DIR; + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("工作台新建与写回", () => { + it("读取带 BOM 的预设文件", () => { + const presetsDir = path.join(dir, "presets"); + mkdirSync(presetsDir, { recursive: true }); + const preset = createNativePreset({ id: "bom", name: "BOM" }); + writeFileSync(path.join(presetsDir, "bom.json"), `\uFEFF${JSON.stringify(preset)}`, "utf8"); + expect(getPreset("bom")?.name).toBe("BOM"); + }); + + it("新建原生预设并生成根版本", async () => { + const { status, body } = await call("/api/preset-lab/presets/create", { + method: "POST", + body: { name: "新预设", id: "fresh" }, + }); + expect(status).toBe(201); + expect(body.preset.id).toBe("fresh"); + expect(body.preset.source).toBe("native"); + expect(body.tree.nodes).toHaveLength(1); + expect(body.preset.prompts.some((p: any) => p.id === "chatHistory" && p.marker)).toBe(true); + expect(body.preset.prompts.some((p: any) => p.id === "currentTurn" && p.marker)).toBe(true); + expect(getPreset("fresh")?.name).toBe("新预设"); + }); + + it("写回前拦截结构 error,force 才落盘", async () => { + const created = await call("/api/preset-lab/presets/create", { + method: "POST", + body: { name: "写回", id: "apply-me" }, + }); + const preset = created.body.preset as PresetPackage; + preset.prompts[0]!.content = "{{setvar::a::1"; + + const blocked = await call("/api/preset-lab/preset/apply", { + method: "POST", + body: { preset }, + }); + expect(blocked.status).toBe(400); + expect(blocked.body.error).toContain("结构检查"); + + const forced = await call("/api/preset-lab/preset/apply", { + method: "POST", + body: { preset, force: true }, + }); + expect(forced.status).toBe(200); + expect(getPreset("apply-me")?.prompts[0]!.content).toContain("setvar"); + }); + + it("三花括号是合法的字面量花括号,不算未闭合宏", async () => { + const created = await call("/api/preset-lab/presets/create", { + method: "POST", + body: { name: "三花括号", id: "triple" }, + }); + const preset = created.body.preset as PresetPackage; + preset.prompts[0]!.content = "{{{setvar::SEMOD1::}}正文"; + const checked = await call("/api/preset-lab/check", { + method: "POST", + body: { preset }, + }); + expect(checked.body.issues.filter((i: any) => i.level === "error")).toHaveLength(0); + }); + + it("重复引用同一模块是 error", async () => { + const created = await call("/api/preset-lab/presets/create", { + method: "POST", + body: { name: "重复", id: "dup-order" }, + }); + const preset = created.body.preset as PresetPackage; + preset.promptOrder.push({ ...preset.promptOrder[0]!, orderIndex: 99 }); + const checked = await call("/api/preset-lab/check", { + method: "POST", + body: { preset }, + }); + expect(checked.body.issues.some((i: any) => i.level === "error" && i.message.includes("重复"))).toBe(true); + }); +}); + +describe("草稿运行", () => { + it("run 使用请求里的草稿而不是旧版本文件", async () => { + const created = await call("/api/preset-lab/presets/create", { + method: "POST", + body: { name: "草稿", id: "draft-run" }, + }); + const preset = created.body.preset as PresetPackage; + preset.prompts[0]!.content = "草稿规则 {{char}}"; + + const group = await call("/api/preset-lab/groups/save", { + method: "POST", + body: { + presetId: preset.id, + name: "组", + cases: [{ name: "开门", charName: "林", userInput: "我推门" }], + }, + }); + + const run = await call("/api/preset-lab/run", { + method: "POST", + body: { + presetId: preset.id, + groupId: group.body.id, + profileIds: ["p1"], + preset, + }, + }); + + expect(run.status).toBe(200); + expect(run.body.results).toHaveLength(1); + expect(run.body.results[0].status).toBe("done"); + expect(run.body.results[0].caseName).toBe("开门"); + expect(run.body.results[0].content).toContain("mock:preset-test:p1"); + expect(run.body.requests[0].messages.map((m: any) => m.content).join("\n")).toContain("草稿规则 林"); + expect(run.body.requests[0].messages.map((m: any) => m.content).join("\n")).toContain("我推门"); + + const runs = await call("/api/preset-lab/runs", { query: `presetId=${preset.id}` }); + expect(runs.body.runs).toHaveLength(1); + expect(runs.body.runs[0].id).toBe(run.body.id); + + const one = await call("/api/preset-lab/run", { + query: `presetId=${preset.id}&runId=${run.body.id}`, + }); + expect(one.body.results[0].caseName).toBe("开门"); + + const marked = await call("/api/preset-lab/verdict", { + method: "POST", + body: { + presetId: preset.id, + runId: run.body.id, + profileId: "p1", + caseId: run.body.results[0].caseId, + verdict: "success", + }, + }); + expect(marked.body.results[0].verdict).toBe("success"); + }); +}); \ No newline at end of file diff --git a/tests/preset-test-cases.test.ts b/tests/preset-test-cases.test.ts new file mode 100644 index 0000000..79c7b6e --- /dev/null +++ b/tests/preset-test-cases.test.ts @@ -0,0 +1,177 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { PresetPackage } from "../src/types/preset.js"; +import { buildCaseMessages } from "../src/preset/test-run.js"; +import { getTestRun, saveTestRun, updateVerdict } from "../src/preset/test-runs.js"; +import { deleteTestGroup, getTestGroup, listTestGroups, saveTestGroup } from "../src/preset/test-cases.js"; +import { deleteTestCase, listTestCases, saveTestCase } from "../src/preset/test-cases.js"; +import { loadPresetLabState, savePresetLabState } from "../src/preset/lab-state.js"; + +function preset(): PresetPackage { + return { + id: "pp", + name: "pp", + source: "native", + prompts: [ + { id: "sys", name: "规则", enabled: true, role: "system", content: "你是{{char}}的叙事者", marker: false, sourceIdentifier: "sys" }, + { id: "worldInfoBefore", name: "世界书", enabled: true, role: "system", content: "", marker: true, sourceIdentifier: "worldInfoBefore" }, + { id: "charDescription", name: "角色描述", enabled: true, role: "system", content: "", marker: true, sourceIdentifier: "charDescription" }, + { id: "chatHistory", name: "历史", enabled: true, role: "system", content: "", marker: true, sourceIdentifier: "chatHistory" }, + { id: "currentTurn", name: "本轮", enabled: true, role: "user", content: "", marker: true, sourceIdentifier: "currentTurn" }, + ], + promptOrder: [ + { promptId: "sys", enabled: true, orderIndex: 0 }, + { promptId: "worldInfoBefore", enabled: true, orderIndex: 1 }, + { promptId: "charDescription", enabled: true, orderIndex: 2 }, + { promptId: "chatHistory", enabled: true, orderIndex: 3 }, + { promptId: "currentTurn", enabled: true, orderIndex: 4 }, + ], + generation: {}, + unsupported: [], + importedAt: new Date().toISOString(), + }; +} + +let dir = ""; +beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), "preset-tests-")); + process.env.WRITING_AGENT_DATA_DIR = dir; +}); +afterEach(() => { + delete process.env.WRITING_AGENT_DATA_DIR; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("测试用例组", () => { + it("保存、读取、列出、删除", () => { + const g = saveTestGroup({ + name: "基础", + cases: [{ name: "c1", charName: "林", charDescription: "冷静", worldBook: "古镇", userInput: "进门" }], + }); + expect(g.cases[0]!.id).toBeTruthy(); + expect(getTestGroup(g.id)?.cases[0]!.charName).toBe("林"); + expect(listTestGroups()).toHaveLength(1); + deleteTestGroup(g.id); + expect(listTestGroups()).toHaveLength(0); + }); +}); + +describe("用例(工作台的展示单位)", () => { + it("新用例落成只装它的一份组文件,平铺列表能读回", () => { + const saved = saveTestCase({ + case: { name: "开门", userInput: "我推开门" }, + }); + expect(saved.groupId).toBeTruthy(); + expect(saved.case.id).toBeTruthy(); + expect(listTestCases().map((item) => item.case.name)).toEqual(["开门"]); + expect(listTestGroups()).toHaveLength(1); + }); + + it("用例全局共享:换预设也能读到同一套", () => { + saveTestCase({ case: { name: "全局用例", userInput: "甲" } }); + // 没有任何 presetId 参与,读取时也不需要 + expect(listTestCases().map((item) => item.case.name)).toEqual(["全局用例"]); + expect(listTestGroups()[0]!.presetId).toBe("global"); + }); + + it("旧版按预设分目录的用例会被并进全局目录", () => { + const legacyDir = path.join(dir, "presets", "tests", "pp"); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync( + path.join(legacyDir, "old12345.json"), + JSON.stringify({ + id: "old12345", + presetId: "pp", + name: "旧组", + updatedAt: "2024-01-01T00:00:00.000Z", + cases: [{ id: "c1", name: "老用例", charName: "", charDescription: "", worldBook: "", userInput: "x", history: [] }], + }), + "utf8", + ); + expect(listTestCases().map((item) => item.case.name)).toEqual(["老用例"]); + expect(getTestGroup("old12345")!.presetId).toBe("global"); + }); + + it("存回旧组里的用例时不动同组其他用例", () => { + const group = saveTestGroup({ + name: "旧组", + cases: [{ name: "A", userInput: "甲" }, { name: "B", userInput: "乙" }], + }); + const target = group.cases[1]!; + saveTestCase({ + groupId: group.id, + case: { ...target, userInput: "乙改" }, + }); + const after = getTestGroup(group.id)!.cases; + expect(after.map((item) => item.userInput)).toEqual(["甲", "乙改"]); + }); + + it("删除最后一份用例时把空组文件一起收掉", () => { + const saved = saveTestCase({ case: { name: "单条" } }); + deleteTestCase(saved.groupId, saved.case.id); + expect(listTestCases()).toEqual([]); + expect(listTestGroups()).toEqual([]); + }); +}); + +describe("预设的试跑状态", () => { + it("按预设记住打开的是哪条用例,重读还在", () => { + expect(loadPresetLabState("pp").selectedCaseId).toBe(null); + savePresetLabState("pp", { selectedCaseId: "c1" }); + expect(loadPresetLabState("pp").selectedCaseId).toBe("c1"); + // 另一个预设不受影响 + expect(loadPresetLabState("qq").selectedCaseId).toBe(null); + // 只传 previewCaseId 时不抹掉 selectedCaseId + savePresetLabState("pp", { previewCaseId: "c2" }); + const state = loadPresetLabState("pp"); + expect(state.selectedCaseId).toBe("c1"); + expect(state.previewCaseId).toBe("c2"); + }); +}); + +describe("用例消息组装", () => { + it("char 描述、世界书、历史、用户输入按顺序进入 messages,且 {{char}} 被替换", () => { + const msgs = buildCaseMessages(preset(), { + id: "c", name: "c", charName: "林", charDescription: "冷静的侦探", worldBook: "古镇夜雨", + userInput: "我推门", history: [{ role: "assistant", content: "门开了。" }], + }); + const text = msgs.map((m) => `${m.role}:${m.content}`); + expect(text[0]).toContain("你是林的叙事者"); + expect(text.join("\n")).toContain("古镇夜雨"); + expect(text.join("\n")).toContain("冷静的侦探"); + expect(text[text.length - 1]).toBe("user:我推门"); + expect(text).toContain("assistant:门开了。"); + }); + + it("关掉 currentTurn 位置后,用户输入不再进入 messages", () => { + const p = preset(); + const turn = p.promptOrder.find((item) => item.promptId === "currentTurn"); + if (turn) turn.enabled = false; + const msgs = buildCaseMessages(p, { + id: "c", name: "c", charName: "林", charDescription: "", worldBook: "", + userInput: "这句不该出现", history: [], + }); + expect(msgs.map((m) => m.content).join("\n")).not.toContain("这句不该出现"); + }); + + it("只替换变动项:同一预设、不同用例的 system 规则保持一致", () => { + const a = buildCaseMessages(preset(), { id: "a", name: "a", charName: "甲", charDescription: "", worldBook: "", userInput: "x", history: [] }); + const b = buildCaseMessages(preset(), { id: "b", name: "b", charName: "甲", charDescription: "", worldBook: "", userInput: "y", history: [] }); + // 默认整形为 strict:system 会并入相邻 user,故只比较规则文本是否都在首条里\n expect(a[0]!.content.startsWith("你是甲的叙事者")).toBe(true);\n expect(b[0]!.content.startsWith("你是甲的叙事者")).toBe(true);\n expect(a[0]!.content.replace("x", "")).toBe(b[0]!.content.replace("y", "")); + }); +}); + +describe("运行记录与手动标记", () => { + it("保存后可以修改标记并落盘", () => { + saveTestRun({ + id: "r1", presetId: "pp", presetVersionId: "v1", groupId: "g", createdAt: new Date().toISOString(), + requests: [], issues: [], + results: [{ profileId: "p", profileName: "P", model: "m", caseId: "c", status: "done", verdict: null, startedAt: "" }], + }); + updateVerdict("pp", "r1", "p", "c", "success"); + expect(getTestRun("pp", "r1")!.results[0]!.verdict).toBe("success"); + }); +}); + diff --git a/web/presets.css b/web/presets.css new file mode 100644 index 0000000..c124bac --- /dev/null +++ b/web/presets.css @@ -0,0 +1,1410 @@ +/* 预设工作台:身份条 + 两个视图(编辑 / 试跑)+ 两个抽屉 + 层级只用「列 + 1px 分隔线 + 一个面板底色」,不再嵌套卡片。 */ + +:root { + --px-outline: 240px; + --px-preview: 380px; + --px-rail: 30px; +} + +.px-page { + overflow: hidden; +} + +/* —— 身份条 —— */ +.px-idbar { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 12px; + min-height: 46px; + padding: 7px 16px; + border-bottom: 1px solid var(--st-border); + background: var(--st-surface); +} +.px-id-main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; +} +.px-id-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} +.px-name { + width: 180px; + font-weight: 600; +} +.px-idmeta { + font-size: 12px; + color: var(--st-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.px-dirty { + flex-shrink: 0; + font-size: 11.5px; + padding: 1px 8px; + border-radius: 999px; + border: 1px solid var(--st-border); + color: var(--st-muted); + white-space: nowrap; +} +.px-dirty[data-dirty="1"] { + color: var(--st-text); + border-color: color-mix(in srgb, #d97706 45%, var(--st-border)); + background: color-mix(in srgb, #d97706 12%, var(--st-surface)); +} +.px-notice { + font-size: 11.5px; + color: var(--st-muted); + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* —— 基础控件 —— */ +.px-btn { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--st-border-strong); + border-radius: 6px; + background: var(--st-surface); + color: var(--st-text); + padding: 5px 10px; + font-size: 12.5px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; +} +.px-btn:hover { + background: color-mix(in srgb, var(--st-surface) 55%, var(--st-bg)); +} +.px-btn[disabled] { + opacity: 0.5; + cursor: not-allowed; +} +.px-btn-primary { + background: var(--st-accent); + border-color: var(--st-accent); + color: #fff; +} +.px-btn-primary:hover { + background: color-mix(in srgb, var(--st-accent) 84%, var(--st-text)); + border-color: color-mix(in srgb, var(--st-accent) 84%, var(--st-text)); +} +.px-btn-primary .px-btn-meta { + color: color-mix(in srgb, #fff 72%, transparent); +} +.px-btn-danger { + color: var(--st-danger); + border-color: color-mix(in srgb, var(--st-danger) 35%, var(--st-border)); + background: transparent; +} +.px-btn-danger:hover { + background: color-mix(in srgb, var(--st-danger) 8%, transparent); +} +.px-btn-mini { + padding: 2px 7px; + font-size: 11.5px; +} +.px-btn-meta { + color: var(--st-faint); + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.px-input, +.px-select { + min-width: 0; + border: 1px solid var(--st-border-strong); + border-radius: 6px; + background: var(--st-surface); + color: var(--st-text); + padding: 5px 8px; + font-family: inherit; + font-size: 12.5px; +} +.px-input[disabled], +.px-select[disabled] { + opacity: 0.55; +} +.px-btn:focus-visible, +.px-input:focus-visible, +.px-select:focus-visible, +.px-content:focus-visible, +.px-issue:focus-visible { + outline: 2px solid color-mix(in srgb, var(--st-accent) 65%, transparent); + outline-offset: 1px; +} +.px-hint { + margin: 0; + font-size: 11.5px; + line-height: 1.55; + color: var(--st-faint); +} +.px-row { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.px-label { + font-size: 11.5px; + color: var(--st-muted); +} +.px-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 11.5px; + color: var(--st-muted); +} +.px-field input, +.px-field select, +.px-field textarea { + width: 100%; + border: 1px solid var(--st-border-strong); + border-radius: 6px; + background: var(--st-surface); + color: var(--st-text); + padding: 5px 8px; + font-family: inherit; + font-size: 12.5px; +} +.px-field textarea { + resize: vertical; + line-height: 1.6; +} + +/* —— 全局错误条 —— */ +.px-alert { + flex-shrink: 0; + display: flex; + align-items: flex-start; + gap: 8px; + margin: 8px 16px 0; + padding: 7px 10px; + border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--st-danger) 35%, var(--st-border)); + background: color-mix(in srgb, var(--st-danger) 8%, var(--st-surface)); + color: var(--st-danger); + font-size: 12.5px; + line-height: 1.5; +} +.px-alert[hidden] { + display: none; +} +.px-alert-text { + flex: 1; + min-width: 0; + word-break: break-word; +} + +/* —— 视图容器 —— */ +.px-view { + flex: 1; + min-height: 0; + display: grid; +} +.px-view[hidden] { + display: none; +} +.px-view-edit { + grid-template-columns: var(--px-outline) minmax(0, 1fr) var(--px-preview); +} + +/* —— 大纲(导航,不是内容) —— */ +.px-outline { + min-height: 0; + overflow: auto; + padding-bottom: 16px; + border-right: 1px solid var(--st-border); + background: color-mix(in srgb, var(--st-surface) 45%, var(--st-bg)); +} +.px-outline-head { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-bottom: 1px solid var(--st-border); + background: color-mix(in srgb, var(--st-bg) 92%, transparent); + backdrop-filter: blur(6px); +} +.px-outline-title { + flex: 1; + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--st-muted); +} +.px-outline-count { + font-size: 11px; + color: var(--st-faint); + font-variant-numeric: tabular-nums; +} +.px-outline-list { + position: relative; + padding: 2px 0 4px; +} +/* 发送顺序导轨:这些模块按序拼成一条请求 */ +.px-outline-list::before { + content: ""; + position: absolute; + top: 4px; + bottom: 6px; + left: calc(10px + 20px); + width: 1px; + background: color-mix(in srgb, var(--st-border-strong) 60%, transparent); +} +.px-row { + border-left: 2px solid transparent; +} +.px-outline .px-row { + flex-wrap: nowrap; + gap: 5px; + padding: 0 8px 0 10px; +} +.px-outline .px-row:hover { + background: color-mix(in srgb, var(--st-text) 4%, transparent); +} +/* 拖动排序:行可抓,落点用一条强调色横线标出 */ +.px-outline .px-row { + position: relative; +} +.px-outline .px-row[data-drag="1"], +.px-outline .px-row[data-drag="1"] .px-row-main { + cursor: grab; +} +.px-outline .px-row[data-dragging="1"] { + opacity: 0.4; +} +.px-outline .px-row[data-drop]::after { + content: ""; + position: absolute; + left: 8px; + right: 8px; + height: 2px; + border-radius: 1px; + background: var(--st-accent); + pointer-events: none; +} +.px-outline .px-row[data-drop="before"]::after { + top: -1px; +} +.px-outline .px-row[data-drop="after"]::after { + bottom: -1px; +} +.px-row[data-active="1"] { + border-left-color: var(--st-accent); + background: var(--st-accent-soft); +} +.px-row[data-enabled="0"] .px-row-name { + color: var(--st-faint); +} +.px-switch { + flex-shrink: 0; + width: 12px; + height: 12px; + margin: 0; + accent-color: var(--st-accent); + cursor: pointer; +} +.px-switch[disabled] { + cursor: default; + opacity: 0.45; +} +.px-row-main { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 5px; + padding: 6px 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-size: 12.5px; + text-align: left; + cursor: pointer; +} +.px-row-index { + flex-shrink: 0; + width: 20px; + text-align: right; + font-size: 10.5px; + color: var(--st-faint); + font-variant-numeric: tabular-nums; +} +.px-row-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.px-row-tag { + flex-shrink: 0; + font-size: 10.5px; + line-height: 1.5; + padding: 0 4px; + border: 1px solid var(--st-border); + border-radius: 3px; + color: var(--st-faint); +} +.px-row-dot { + flex-shrink: 0; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--st-danger); + display: none; +} +.px-row[data-error="1"] .px-row-dot { + display: block; +} +.px-outline-group { + margin: 6px 8px 0; + border-top: 1px solid var(--st-border); + padding-top: 4px; +} +.px-outline-group > summary { + padding: 5px 2px; + font-size: 11.5px; + color: var(--st-muted); + cursor: pointer; + list-style: none; +} +.px-outline-group > summary::before { + content: "▸ "; + color: var(--st-faint); +} +.px-outline-group[open] > summary::before { + content: "▾ "; +} +.px-row-gen { + margin: 6px 8px 0; + border-top: 1px solid var(--st-border); + padding-top: 4px; +} +.px-row-gen .px-row-index { + font-size: 12px; +} + +/* —— 编辑器(主工作面) —— */ +.px-editor { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.px-editor-head { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 8px 14px; + border-bottom: 1px solid var(--st-border); +} +.px-editor-name { + width: 220px; + font-weight: 600; +} +.px-editor-status { + font-size: 11.5px; + color: var(--st-faint); + font-variant-numeric: tabular-nums; +} +.px-editor-actions { + margin-left: auto; + display: flex; + gap: 4px; + flex-wrap: wrap; +} +.px-editor-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 12px 16px 10px; + overflow: auto; +} +.px-content { + flex: 1; + min-height: 180px; + width: 100%; + margin: 0; + padding: 12px 14px; + border: 1px solid var(--st-border); + border-radius: 8px; + background: var(--st-surface); + color: var(--st-text); + font-family: inherit; + font-size: 13.5px; + line-height: 1.75; + resize: none; +} +.px-editor-foot { + flex-shrink: 0; + display: flex; + gap: 12px; + width: 100%; + margin: 6px 0 0; + font-size: 11.5px; + color: var(--st-faint); + font-variant-numeric: tabular-nums; +} +.px-marker { + margin: 0; + padding: 14px 16px; + border: 1px dashed var(--st-border-strong); + border-radius: 8px; + background: color-mix(in srgb, var(--st-surface) 60%, var(--st-bg)); +} +.px-marker-title { + margin: 0 0 6px; + font-size: 12.5px; + font-weight: 600; +} +.px-field-history { + margin-top: 4px; +} +.px-field-history > span { + color: var(--st-muted); +} + +.px-gen-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px 12px; + width: 100%; + margin: 0; +} +.px-gen-extra { + width: 100%; + margin: 14px 0 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 10px 12px; +} +.px-empty { + margin: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 24px; + font-size: 13px; + color: var(--st-muted); + text-align: center; +} + +/* —— 预览(实际消息,与正文并排对照) —— */ +.px-preview { + min-height: 0; + display: flex; + flex-direction: column; + border-left: 1px solid var(--st-border); + background: color-mix(in srgb, var(--st-surface) 40%, var(--st-bg)); +} +.px-preview-head { + flex-shrink: 0; + display: flex; + gap: 6px; + padding: 8px 10px; + border-bottom: 1px solid var(--st-border); +} +.px-preview-head .px-select { + flex: 1; +} +.px-band { + flex-shrink: 0; + margin: 8px 10px 0; + border: 1px solid var(--st-border); + border-radius: 8px; + overflow: hidden; + background: var(--st-surface); +} +.px-band[data-level="error"] { + border-color: color-mix(in srgb, var(--st-danger) 40%, var(--st-border)); +} +.px-band[data-level="warn"] { + border-color: color-mix(in srgb, #d97706 40%, var(--st-border)); +} +.px-band[data-level="ok"] { + border-color: color-mix(in srgb, var(--st-ok) 30%, var(--st-border)); +} +.px-band-summary { + width: 100%; + display: flex; + align-items: center; + gap: 7px; + padding: 6px 9px; + border: 0; + background: none; + color: var(--st-muted); + font: inherit; + font-size: 11.5px; + text-align: left; + cursor: pointer; +} +.px-band-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--st-faint); + flex-shrink: 0; +} +.px-band[data-level="error"] .px-band-dot { + background: var(--st-danger); +} +.px-band[data-level="warn"] .px-band-dot { + background: #d97706; +} +.px-band[data-level="ok"] .px-band-dot { + background: var(--st-ok); +} +.px-band-caret { + margin-left: auto; + color: var(--st-faint); +} +.px-issue-list { + max-height: 200px; + overflow: auto; + border-top: 1px solid var(--st-border); +} +.px-issue { + display: block; + width: 100%; + padding: 5px 9px; + border: 0; + border-left: 2px solid transparent; + background: none; + color: var(--st-text); + font: inherit; + font-size: 11.5px; + line-height: 1.5; + text-align: left; + cursor: pointer; +} +.px-issue:hover { + background: color-mix(in srgb, var(--st-text) 5%, transparent); +} +.px-issue[data-focus="1"] { + cursor: pointer; +} +.px-issue[data-level="error"] { + border-left-color: var(--st-danger); +} +.px-issue[data-level="warn"] { + border-left-color: #d97706; +} +.px-issue b { + font-weight: 600; +} +.px-preview-meta { + flex-shrink: 0; + margin: 8px 10px 0; + font-size: 11.5px; + color: var(--st-faint); + font-variant-numeric: tabular-nums; +} +.px-messages { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: 9px; + padding: 8px 10px 20px; +} +.px-msg { + border-left: 2px solid var(--st-border); + padding-left: 8px; +} +.px-msg[data-role="system"] { + border-color: color-mix(in srgb, var(--st-accent) 60%, var(--st-border)); +} +.px-msg[data-role="user"] { + border-color: color-mix(in srgb, #2f7de1 60%, var(--st-border)); +} +.px-msg[data-role="assistant"] { + border-color: color-mix(in srgb, #3f9d6b 60%, var(--st-border)); +} +.px-msg[data-role="tool"] { + border-color: color-mix(in srgb, #b07a2a 60%, var(--st-border)); +} +.px-msg-role { + display: block; + margin-bottom: 2px; + font-size: 10.5px; + letter-spacing: 0.03em; + color: var(--st-faint); + font-variant-numeric: tabular-nums; +} +.px-msg-body { + font-size: 12.5px; + line-height: 1.65; + white-space: pre-wrap; + word-break: break-word; +} +.px-msg-body[data-clamped="1"] { + max-height: 10em; + overflow: hidden; + mask-image: linear-gradient(to bottom, #000 70%, transparent); + -webkit-mask-image: linear-gradient(to bottom, #000 70%, transparent); +} +.px-msg-more { + margin-top: 2px; + padding: 0; + border: 0; + background: none; + color: var(--st-muted); + font: inherit; + font-size: 11px; + cursor: pointer; + text-decoration: underline dotted; +} + +/* —— 试跑视图 —— */ +.px-view-run { + grid-template-columns: 320px minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); +} +.px-runbar { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 8px; + padding: 9px 16px; + border-bottom: 1px solid var(--st-border); + background: var(--st-surface); +} +.px-runbar-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.px-runbar-sep { + width: 1px; + height: 16px; + flex-shrink: 0; + background: var(--st-border); +} +.px-runbar-case { + font-size: 12.5px; + font-weight: 600; + color: var(--st-text); + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.px-runbar-run { + flex-shrink: 0; + margin-left: auto; +} +/* —— 模型选择:下拉多选 + 右侧已选条 —— */ +.px-picker { + position: relative; +} +.px-picker-trigger { + display: inline-flex; + align-items: center; + gap: 4px; +} +.px-picker-count { + font-variant-numeric: tabular-nums; + color: var(--st-faint); +} +.px-picker-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 40; + width: min(420px, 82vw); + max-height: 320px; + overflow: auto; + padding: 4px; + border: 1px solid var(--st-border-strong); + border-radius: 8px; + background: var(--st-surface); + box-shadow: 0 12px 28px color-mix(in srgb, #000 16%, transparent); +} +.px-picker-menu[hidden] { + display: none; +} +.px-pick-empty { + margin: 6px 8px; + font-size: 12px; + color: var(--st-muted); +} +.px-pick-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 5px 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--st-text); + font-family: var(--st-font-ui); + font-size: 12px; + text-align: left; + cursor: pointer; +} +.px-pick-item:hover:not(:disabled) { + background: color-mix(in srgb, var(--st-accent) 8%, transparent); +} +.px-pick-item[data-active="1"] { + background: var(--st-accent-soft); +} +.px-pick-item:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.px-pick-check { + width: 12px; + flex-shrink: 0; + color: var(--st-accent); +} +.px-pick-name { + flex-shrink: 0; + font-weight: 500; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.px-pick-model { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--st-muted); + font-size: 11.5px; +} +.px-pick-warn { + margin-left: auto; + flex-shrink: 0; + color: var(--st-danger); + font-size: 11px; +} +.px-pick-actions { + display: flex; + gap: 6px; + padding: 6px 4px 2px; + margin-top: 4px; + border-top: 1px solid var(--st-border); +} + +/* 已选条:只占一行,超出就左右拖;文字不可选,点一项取消 */ +.px-picked { + display: flex; + align-items: center; + gap: 6px; + flex: 1 1 auto; + min-width: 160px; + min-height: 26px; + padding: 2px 6px; + border: 1px solid var(--st-border); + border-radius: 6px; + background: var(--st-surface); + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + user-select: none; + -webkit-user-select: none; + cursor: grab; + scrollbar-width: thin; +} +.px-picked[data-dragging="1"] { + cursor: grabbing; +} +.px-picked-empty { + font-size: 11.5px; + color: var(--st-faint); +} +.px-picked-chip { + flex: 0 0 auto; + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 2px 8px; + border: 1px solid var(--st-border-strong); + border-radius: 999px; + background: var(--st-accent-soft); + color: var(--st-text); + font-family: var(--st-font-ui); + font-size: 11.5px; + cursor: pointer; +} +.px-picked-chip:hover { + border-color: var(--st-danger); + color: var(--st-danger); +} +.px-picked-x { + margin-left: 6px; + opacity: 0.55; +} +.px-caselist { + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: 3px; + padding: 8px; + border-right: 1px solid var(--st-border); + background: color-mix(in srgb, var(--st-surface) 45%, var(--st-bg)); +} +.px-case { + display: flex; + align-items: center; + gap: 4px; + border-radius: 6px; +} +.px-case[data-active="1"] { + background: var(--st-accent-soft); +} +.px-case-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; + padding: 5px 8px; + border: 0; + border-radius: 6px; + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.px-case-name { + font-size: 12.5px; +} +.px-case-snippet { + font-size: 11px; + color: var(--st-faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.px-results-pane { + min-height: 0; + display: flex; + flex-direction: column; +} +.px-results-head { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 8px 16px; + border-bottom: 1px solid var(--st-border); +} +.px-results-hint { + margin-left: auto; +} +.px-results { + flex: 1; + min-height: 0; + overflow: auto; + align-content: start; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 12px; + padding: 12px 16px 24px; +} +.px-result { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border: 1px solid var(--st-border); + border-radius: 10px; + background: var(--st-surface); +} +.px-result[data-verdict="success"] { + border-color: color-mix(in srgb, var(--st-ok) 55%, var(--st-border)); +} +.px-result[data-verdict="failure"] { + border-color: color-mix(in srgb, var(--st-danger) 55%, var(--st-border)); +} +.px-result-head { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-size: 12px; +} +.px-result-head b { + font-weight: 600; +} +.px-verdict { + margin-left: auto; + display: flex; + gap: 4px; +} +.px-verdict .px-btn[data-active="1"] { + border-color: var(--st-accent); + background: var(--st-accent-soft); +} +.px-result-body { + font-size: 12.5px; + line-height: 1.65; + white-space: pre-wrap; + word-break: break-word; + max-height: 22em; + overflow: auto; + padding-left: 8px; + border-left: 2px solid color-mix(in srgb, var(--st-border-strong) 70%, transparent); +} +.px-details { + margin-top: 2px; +} +.px-details > summary { + font-size: 11.5px; + color: var(--st-muted); + cursor: pointer; +} +.px-details .px-messages { + max-height: 260px; + margin-top: 6px; + padding: 6px 0 0; + border-top: 1px solid var(--st-border); +} + +/* —— 大浮窗(用例表单) —— */ +body[data-modal="on"] { + overflow: hidden; +} +.px-modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 90; + width: min(1080px, 94vw); + height: min(78vh, 860px); + display: flex; + flex-direction: column; + background: var(--st-surface); + border: 1px solid var(--st-border-strong); + border-radius: 12px; + box-shadow: 0 18px 50px color-mix(in srgb, #000 22%, transparent); + overflow: hidden; +} +.px-modal[hidden] { + display: none; +} +.px-modal-head { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--st-border); +} +.px-modal-head h2 { + margin: 0; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.02em; +} +.px-modal-body { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px 16px 12px; +} +.px-case-grid { + display: grid; + grid-template-columns: minmax(0, 360px) minmax(0, 1fr); + gap: 16px; + align-items: start; +} +.px-case-col { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} +.px-case-col-history { + padding-right: 2px; +} +.px-modal-foot { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + padding-top: 10px; + border-top: 1px solid var(--st-border); +} +.px-modal-foot .px-hint { + margin: 0; +} +.px-modal-foot-actions { + display: flex; + align-items: center; + gap: 8px; +} + +/* —— 遮罩与右侧抽屉(版本) —— */ +.px-scrim { + position: fixed; + inset: 0; + z-index: 80; + background: color-mix(in srgb, #000 20%, transparent); +} +.px-scrim[hidden] { + display: none; +} +.px-drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 90; + width: min(390px, 94vw); + display: flex; + flex-direction: column; + background: var(--st-surface); + border-left: 1px solid var(--st-border-strong); + box-shadow: -10px 0 28px color-mix(in srgb, #000 14%, transparent); +} +.px-drawer[hidden] { + display: none; +} +.px-drawer-head { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--st-border); +} +.px-drawer-head h2 { + margin: 0; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.02em; +} +.px-drawer-body { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px 24px; +} +.px-drawer-body .px-row { + margin-top: 2px; +} + +/* —— 版本树 —— */ +#version-tree { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 46vh; + overflow: auto; +} +.px-ver { + position: relative; + display: flex; + align-items: stretch; + gap: 6px; + width: 100%; + padding: 4px 8px 4px 6px; + border: 1px solid transparent; + border-radius: 6px; + background: none; + color: inherit; + font: inherit; + font-size: 12px; + line-height: 1.45; + text-align: left; + cursor: pointer; +} +.px-ver:hover { + background: color-mix(in srgb, var(--st-text) 5%, transparent); +} +.px-ver[data-current="1"] { + border-color: color-mix(in srgb, var(--st-accent) 55%, var(--st-border)); + background: var(--st-accent-soft); +} +.px-ver[data-selected="1"] { + box-shadow: inset 3px 0 0 var(--st-accent); +} +.px-ver small { + display: block; + font-size: 11px; + color: var(--st-faint); +} + +/* —— 版本树图线 —— */ +.px-ver-graph { + position: relative; + flex-shrink: 0; +} +.px-ver-line, +.px-ver-join { + position: absolute; + background: color-mix(in srgb, var(--st-border-strong) 85%, transparent); +} +.px-ver-line { + width: 2px; +} +.px-ver-join { + top: 50%; + height: 2px; + margin-top: -1px; +} +.px-ver-dot { + position: absolute; + top: 50%; + width: 7px; + height: 7px; + margin-top: -3.5px; + border-radius: 50%; + border: 1.5px solid color-mix(in srgb, var(--st-border-strong) 90%, transparent); + background: var(--st-surface); +} +.px-ver[data-current="1"] .px-ver-dot, +.px-ver-dot[data-head="1"] { + border-color: var(--st-accent); + background: var(--st-accent); +} +.px-ver-main { + min-width: 0; + flex: 1; +} +.px-ver-title { + display: flex; + align-items: center; + gap: 6px; + font-variant-numeric: tabular-nums; +} +.px-ver-badge { + flex-shrink: 0; + padding: 0 4px; + border-radius: 3px; + background: var(--st-accent); + color: #fff; + font-size: 10px; + font-style: normal; + line-height: 15px; +} + +/* —— 节点操作菜单 —— */ +.px-ver-menu { + position: fixed; + z-index: 120; + min-width: 210px; + max-width: 300px; + padding: 4px; + border: 1px solid var(--st-border-strong); + border-radius: 8px; + background: var(--st-surface); + box-shadow: 0 10px 26px color-mix(in srgb, #000 18%, transparent); +} +.px-ver-menu-head { + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 8px; + border-bottom: 1px solid var(--st-border); + margin-bottom: 4px; +} +.px-ver-menu-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.px-ver-menu-note { + font-size: 11.5px; + color: var(--st-text); +} +.px-ver-menu-item { + display: block; + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: 5px; + background: none; + color: inherit; + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} +.px-ver-menu-item:hover { + background: var(--st-accent-soft); +} + +/* —— 宏联想 —— */ +.px-suggest { + position: absolute; + z-index: 95; + max-width: 300px; + padding: 4px; + border: 1px solid var(--st-border-strong); + border-radius: 8px; + background: var(--st-surface); + box-shadow: 0 8px 24px color-mix(in srgb, #000 16%, transparent); + font-size: 12px; +} +.px-suggest-row { + padding: 4px 8px; + border-radius: 6px; + cursor: pointer; +} +.px-suggest-row:hover { + background: color-mix(in srgb, var(--st-text) 6%, transparent); +} +.px-suggest-row span { + margin-left: 6px; + color: var(--st-faint); +} + +/* —— 响应式 —— */ +@media (max-width: 1500px) { + :root { + --px-outline: 220px; + --px-preview: 330px; + } +} + +/* 预览退成浮层:编辑区优先保留连续宽度 */ +@media (max-width: 1180px) { + .px-view-edit { + grid-template-columns: var(--px-outline) minmax(0, 1fr); + } + .px-preview { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 70; + width: min(420px, 92vw); + border-left: 1px solid var(--st-border-strong); + box-shadow: -10px 0 28px color-mix(in srgb, #000 14%, transparent); + } + body[data-preview="off"] .px-preview { + display: none; + } +} + +/* 窄窗:大浮窗退回单列,改上下排 */ +@media (max-width: 900px) { + .px-modal { + width: 96vw; + height: 88vh; + } + .px-case-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +/* 窄窗:大纲退成浮层,单列编辑 */ +@media (max-width: 860px) { + .px-view-edit { + grid-template-columns: minmax(0, 1fr); + } + .px-outline { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 72; + width: min(320px, 88vw); + background: var(--st-surface); + border-right: 1px solid var(--st-border-strong); + box-shadow: 10px 0 28px color-mix(in srgb, #000 14%, transparent); + } + body[data-outline="off"] .px-outline { + display: none; + } + .px-idbar { + flex-wrap: wrap; + } + .px-id-main { + order: 1; + width: 100%; + } + .px-id-actions { + order: 2; + width: 100%; + overflow-x: auto; + } + .px-view-run { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr); + } + .px-caselist { + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + border-right: 0; + border-bottom: 1px solid var(--st-border); + padding: 6px 8px; + } + .px-case { + flex-shrink: 0; + max-width: 200px; + } + .px-results { + grid-template-columns: minmax(0, 1fr); + } +} + +/* 窄窗下才出现「顺序」入口 */ +.px-only-narrow { + display: none; +} +@media (max-width: 860px) { + .px-only-narrow { + display: inline-flex; + } +} + +/* 预览退成浮层后,需要在编辑器里留一个打开入口 */ +.px-preview-launcher { + display: none; +} +@media (max-width: 1180px) { + .px-preview-launcher { + display: inline-flex; + } +} diff --git a/web/presets.html b/web/presets.html new file mode 100644 index 0000000..8d74df2 --- /dev/null +++ b/web/presets.html @@ -0,0 +1,182 @@ + + + + + + 预设工作台 · Writing Agent + + + + + + + + + +
+ Writing Agent + +
+ + +
+
+ + + + + 已同步 +
+
+ + + + + + + +
+
+ + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + diff --git a/web/presets.js b/web/presets.js new file mode 100644 index 0000000..f80e042 --- /dev/null +++ b/web/presets.js @@ -0,0 +1,2455 @@ +const BASE = "/api/preset-lab/"; + +import { createHistoryEditor } from "/history-editor.js"; + +async function api(path, body) { + const res = await fetch(BASE + path, body === undefined ? undefined : { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { error: text.slice(0, 200) }; + } + if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`); + return data; +} + +const GENERATION = "__generation__"; +/** 预设级「假历史」:不是模块,不占 prompt_order,固定进历史区。 */ +const PSEUDO_HISTORY = "__pseudo_history__"; + +const state = { + presets: [], + preset: null, + presetId: "", + presetDirty: false, + /** 大纲里选中的对象:模块 id 或 GENERATION */ + selected: null, + tree: null, + selectedVersionId: null, + /** 用例(每条就是一份 CompleteCase,带 groupId 指向盘上文件):组只是容器,界面不展示 */ + cases: [], + activeCaseIndex: -1, + previewCaseId: "", + messages: [], + /** 右侧预览的整形情况:`squash` 模式 + 整形前条数(左轨顺序 = 装配顺序,整形会改条数)。 */ + previewShape: { squash: null, assembledCount: 0 }, + issues: [], + issuesOpen: false, + profiles: [], + selectedProfileIds: new Set(), + runs: [], + run: null, + view: "edit", + drawer: null, + /** 用户手动开合过预览 / 大纲后,resize 不再自动改它的开合 */ + previewTouched: false, + outlineTouched: false, +}; + +const $ = (id) => document.getElementById(id); + +function el(tag, attrs = {}, children = []) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(attrs)) { + if (value === null || value === undefined || value === false) continue; + if (key === "text") node.textContent = value; + else if (key === "html") node.innerHTML = value; + else if (key === "class") node.className = value; + else if (key.startsWith("on")) node.addEventListener(key.slice(2), value); + else node.setAttribute(key, value === true ? "" : value); + } + for (const child of children) { + if (child === null || child === undefined || child === false) continue; + node.append(typeof child === "string" ? document.createTextNode(child) : child); + } + return node; +} + +/* ---------- 全局提示 ---------- */ + +function err(text) { + const host = $("alert"); + if (!host) return; + host.hidden = false; + host.replaceChildren( + el("span", { class: "px-alert-text", text: String(text) }), + el("button", { + type: "button", + class: "px-btn px-btn-mini", + text: "关闭", + onclick: () => { + host.hidden = true; + host.replaceChildren(); + }, + }), + ); +} + +let noticeTimer = null; +function notice(text) { + const host = $("notice"); + if (!host) return; + host.textContent = text || ""; + // 成功提示顺带收掉上一次的错误条,避免旧错误一直挂着。 + const alertBox = $("alert"); + if (alertBox) { + alertBox.hidden = true; + alertBox.replaceChildren(); + } + window.clearTimeout(noticeTimer); + if (text) noticeTimer = window.setTimeout(() => { host.textContent = ""; }, 4000); +} + +function setBusy(button, busy, text) { + if (!button) return; + if (busy) { + button.dataset.label = button.textContent; + button.disabled = true; + button.textContent = text; + } else { + button.disabled = false; + if (button.dataset.label) button.textContent = button.dataset.label; + } +} + +function confirmDiscard() { + return !state.presetDirty || window.confirm("当前草稿还没保存版本,继续会丢掉这些修改。继续?"); +} + +/* ---------- 脏状态 ---------- */ + +function updateDirtyHint() { + const pill = $("dirty-pill"); + if (pill) { + pill.dataset.dirty = state.presetDirty ? "1" : "0"; + pill.textContent = state.presetDirty ? "未保存" : "已同步"; + } + const hint = $("dirty-hint"); + if (hint) { + hint.textContent = state.presetDirty + ? "草稿有未保存的修改 · 「保存至当前版本」覆盖当前节点,「保存为新版本」追加子节点" + : "草稿与当前版本一致"; + } +} + +function markDirty() { + state.presetDirty = true; + updateDirtyHint(); + scheduleRefresh(); +} + +/* ---------- 实时预览(本地组装,不调模型) ---------- */ + +let refreshTimer = null; + +function scheduleRefresh(delay = 450) { + window.clearTimeout(refreshTimer); + refreshTimer = window.setTimeout(() => { void refreshAll(); }, delay); +} + +async function refreshAll() { + if (!state.preset) return; + window.clearTimeout(refreshTimer); + await Promise.all([runCheck(), runPreview()]); +} + +/* ---------- 预设列表与元信息 ---------- */ + +function renderPresetOptions() { + const select = $("preset-select"); + select.replaceChildren(); + if (state.presets.length === 0) { + select.append(el("option", { value: "", text: "(无预设)" })); + return; + } + for (const preset of state.presets) { + select.append(el("option", { + value: preset.id, + text: `${preset.name}(${preset.promptCount})`, + })); + } + if (state.presetId) select.value = state.presetId; +} + +async function refreshPresetList(selectedId) { + const data = await api("presets"); + state.presets = data.presets || []; + if (selectedId) state.presetId = selectedId; + renderPresetOptions(); +} + +function renderIdbar() { + const preset = state.preset; + $("preset-name").disabled = !preset; + $("preset-select").disabled = !preset; + $("preset-name").value = preset ? preset.name || "" : ""; + if (preset) { + const option = [...$("preset-select").options].find((item) => item.value === preset.id); + if (option) option.textContent = `${preset.name || preset.id}(${preset.prompts.length})`; + if ($("preset-select").value !== preset.id) $("preset-select").value = preset.id; + const enabled = preset.promptOrder.filter((item) => item.enabled).length; + const source = preset.source === "sillytavern" ? "SillyTavern 导入" : "原生"; + $("preset-meta").textContent = + `${preset.id} · ${source} · ${preset.prompts.length} 模块 / 启用 ${enabled}`; + } else { + $("preset-meta").textContent = ""; + } + updateVersionsButton(); + renderRunEntry(); + updateDirtyHint(); +} + +/** 版本 id 不占位了:只在「版本树」按钮的 title 里露。 */ +function updateVersionsButton() { + const button = $("btn-versions"); + if (!button) return; + const current = state.tree?.currentId; + button.title = current ? `打开版本树(当前 ${current})` : "打开版本树"; +} + +/* ---------- 模块模型 ---------- */ + +/** 洞(marker):运行时填内容,但位置就是左轨顺序,随便拖。 */ +const MARKER_HINTS = { + worldBookBefore: "历史前的世界书 / 任务契约", + worldInfoBefore: "酒馆世界书位置(历史前)", + chatHistory: "展开成真多轮消息", + worldBookAfter: "历史后、本轮前的插入", + worldInfoAfter: "酒馆世界书位置(历史后)", + currentTurn: "用户本轮输入", + postTurn: "本轮之后的插入", + personaDescription: "用户人设正文", + charDescription: "角色描述", + charPersonality: "角色性格", + scenario: "场景", + dialogueExamples: "示例对话", +}; + +function orderedModules(preset) { + const byId = new Map(preset.prompts.map((p) => [p.id, p])); + const order = preset.promptOrder.slice().sort((a, b) => a.orderIndex - b.orderIndex); + const seen = new Set(); + const rows = []; + for (const item of order) { + const prompt = byId.get(item.promptId); + if (!prompt) continue; + seen.add(prompt.id); + rows.push({ prompt, item }); + } + for (const prompt of preset.prompts) { + if (seen.has(prompt.id)) continue; + rows.push({ prompt, item: null }); + } + return rows; +} + +function findModule(promptId) { + const preset = state.preset; + if (!preset || !promptId) return null; + const rows = orderedModules(preset); + const row = rows.find((entry) => entry.prompt.id === promptId) || null; + if (!row) return null; + const orderedRows = rows.filter((entry) => entry.item); + return { + prompt: row.prompt, + item: row.item, + orderIndex: orderedRows.indexOf(row), + orderedCount: orderedRows.length, + }; +} + +function firstSelectable() { + if (!state.preset) return GENERATION; + const rows = orderedModules(state.preset); + const first = rows.find((row) => row.item && row.item.enabled) || rows[0]; + return first ? first.prompt.id : GENERATION; +} + +function moduleEnabled(prompt, item) { + // 装配只看 prompt_order.enabled;prompts[].enabled 是酒馆遗留字段。 + return Boolean(item && item.enabled); +} + +/* ---------- 大纲 ---------- */ + +function renderOutline() { + const host = $("outline"); + host.replaceChildren(); + if (!state.preset) return; + const rows = orderedModules(state.preset); + const ordered = rows.filter((row) => row.item); + const unordered = rows.filter((row) => !row.item); + const enabledCount = ordered.filter((row) => row.item.enabled).length; + + host.append(el("div", { class: "px-outline-head" }, [ + el("span", { class: "px-outline-title", text: "发送顺序" }), + el("span", { class: "px-outline-count", text: `${enabledCount}/${ordered.length}` }), + el("button", { + type: "button", + class: "px-btn px-btn-mini", + text: "+", + title: "新增模块", + onclick: addModule, + }), + ])); + + const list = el("div", { class: "px-outline-list" }); + ordered.forEach((row, index) => list.append(outlineRow(row, index + 1))); + attachOutlineDrag(list); + host.append(list); + + if (unordered.length > 0) { + host.append(el("details", { class: "px-outline-group" }, [ + el("summary", { text: `未加入顺序(${unordered.length})` }), + ...unordered.map((row) => outlineRow(row, null)), + ])); + } + + host.append(generationRow()); + host.append(pseudoHistoryRow()); + markActiveOutline(); +} + +function outlineRow({ prompt, item }, index) { + const enabled = moduleEnabled(prompt, item); + // 左轨顺序就是真实发送顺序(装配不再重排),所以每一行都能拖。 + const draggable = index != null && Boolean(item); + const toggle = el("input", { + type: "checkbox", + class: "px-switch", + checked: enabled, + disabled: item ? null : "disabled", + title: item ? (enabled ? "已启用,点击停用" : "已停用,点击启用") : "未加入顺序", + "aria-label": `是否发送 ${prompt.name || prompt.id}`, + onchange: (e) => { + if (!item) return; + item.enabled = e.target.checked; + prompt.enabled = e.target.checked; + markDirty(); + const row = document.querySelector(`.px-row[data-prompt="${CSS.escape(prompt.id)}"]`); + if (row) row.dataset.enabled = e.target.checked ? "1" : "0"; + syncOutlineHead(); + syncEditorStatus(); + }, + }); + + const main = el("button", { + type: "button", + class: "px-row-main", + title: draggable ? `${prompt.name || prompt.id}(拖动调整发送顺序)` : null, + onclick: () => selectModule(prompt.id), + }, [ + el("span", { class: "px-row-index", text: index ? String(index) : "–" }), + el("span", { class: "px-row-name", text: prompt.name || prompt.id }), + prompt.marker + ? el("span", { + class: "px-row-tag", + text: "位置", + title: MARKER_HINTS[prompt.id] || "运行时填洞", + }) + : (prompt.role && prompt.role !== "system" + ? el("span", { class: "px-row-tag", text: prompt.role }) + : null), + el("span", { class: "px-row-dot" }), + ]); + + return el("div", { + class: "px-row", + "data-prompt": prompt.id, + "data-enabled": enabled ? "1" : "0", + "data-order": index == null ? null : String(index - 1), + "data-drag": draggable ? "1" : null, + draggable: draggable ? "true" : null, + }, [toggle, main]); +} + +function generationRow() { + const gen = state.preset?.generation || {}; + const count = Object.keys(gen).length; + return el("div", { + class: "px-row px-row-gen", + "data-prompt": GENERATION, + "data-active": state.selected === GENERATION ? "1" : "0", + }, [ + el("span", { class: "px-row-index", text: "⚙" }), + el("button", { type: "button", class: "px-row-main", onclick: selectGeneration }, [ + el("span", { class: "px-row-name", text: "生成参数" }), + el("span", { class: "px-row-tag", text: count ? `${count} 项` : "默认" }), + ]), + ]); +} + +function pseudoHistoryRow() { + const turns = pseudoTurns(); + return el("div", { + class: "px-row px-row-gen", + "data-prompt": PSEUDO_HISTORY, + "data-active": state.selected === PSEUDO_HISTORY ? "1" : "0", + }, [ + el("span", { class: "px-row-index", text: "◧" }), + el("button", { type: "button", class: "px-row-main", onclick: selectPseudoHistory }, [ + el("span", { class: "px-row-name", text: "假历史" }), + el("span", { + class: "px-row-tag", + text: turns.length ? `${turns.length} 句` : "空", + title: "真多轮 user / assistant,排在真实历史之前", + }), + ]), + ]); +} + +function syncOutlineHead() { + if (!state.preset) return; + const ordered = state.preset.promptOrder; + const enabled = ordered.filter((item) => item.enabled).length; + const count = document.querySelector(".px-outline-count"); + if (count) count.textContent = `${enabled}/${ordered.length}`; +} + +function markActiveOutline() { + for (const row of document.querySelectorAll(".px-outline .px-row[data-prompt]")) { + row.dataset.active = row.dataset.prompt === state.selected ? "1" : "0"; + } +} + +function selectModule(promptId) { + state.selected = promptId; + markActiveOutline(); + renderEditor(); + document.body.dataset.outline = "off"; +} + +function selectGeneration() { + state.selected = GENERATION; + markActiveOutline(); + renderEditor(); + document.body.dataset.outline = "off"; +} + +function selectPseudoHistory() { + state.selected = PSEUDO_HISTORY; + markActiveOutline(); + renderEditor(); + document.body.dataset.outline = "off"; +} + +/* ---------- 模块操作 ---------- */ + +function moveModule(promptId, delta) { + const preset = state.preset; + const order = preset.promptOrder.slice().sort((a, b) => a.orderIndex - b.orderIndex); + const index = order.findIndex((item) => item.promptId === promptId); + const target = index + delta; + if (index < 0 || target < 0 || target >= order.length) return; + const tmp = order[index]; + order[index] = order[target]; + order[target] = tmp; + order.forEach((item, i) => { item.orderIndex = i; }); + markDirty(); + renderOutline(); + renderEditor(); +} + +/* ---------- 拖动排序 ---------- */ + +/** 正在拖的模块 id;不在拖动中时为 null。 */ +let dragPromptId = null; + +/** 拖动排序只改 promptOrder,序号重排后和 ↑↓ / 装配完全等价。 */ +function movePromptToGap(promptId, gap) { + const preset = state.preset; + if (!preset) return; + const order = preset.promptOrder.slice().sort((a, b) => a.orderIndex - b.orderIndex); + const from = order.findIndex((item) => item.promptId === promptId); + if (from < 0) return; + // gap 是原生数组里的空隙位置(0..len),拔掉自己后它之前的空隙不变 + const to = gap > from ? gap - 1 : gap; + if (to === from) return; + const [moved] = order.splice(from, 1); + order.splice(to, 0, moved); + order.forEach((item, i) => { item.orderIndex = i; }); + markDirty(); + renderOutline(); + renderEditor(); +} + +function outlineRows(list) { + return [...list.querySelectorAll(".px-row[data-order]")]; +} + +/** 指针 y 落在哪个空隙:行上半 = 行前,下半 = 行后,越过末行 = 末尾。 */ +function dropGap(list, clientY) { + const rows = outlineRows(list); + if (rows.length === 0) return null; + for (let i = 0; i < rows.length; i += 1) { + const rect = rows[i].getBoundingClientRect(); + if (clientY < rect.top + rect.height / 2) return i; + } + return rows.length; +} + +function markDropGap(list, gap) { + clearDropMarks(); + const rows = outlineRows(list); + if (!rows.length) return; + if (gap >= rows.length) rows[rows.length - 1].dataset.drop = "after"; + else rows[gap].dataset.drop = "before"; +} + +function clearDropMarks() { + for (const row of document.querySelectorAll(".px-outline .px-row[data-drop]")) { + delete row.dataset.drop; + } +} + +/** 拖到标题栏或底部时自动滚,大纲长了一屏装不下。 */ +function autoScrollOutline(list, clientY) { + const box = list.closest(".px-outline"); + if (!box) return; + const rect = box.getBoundingClientRect(); + if (clientY < rect.top + 28) box.scrollTop -= 14; + else if (clientY > rect.bottom - 28) box.scrollTop += 14; +} + +function attachOutlineDrag(list) { + list.addEventListener("dragstart", (event) => { + const row = event.target instanceof Element + ? event.target.closest(".px-row[data-drag='1']") + : null; + if (!row || !row.dataset.prompt) return; + dragPromptId = row.dataset.prompt; + row.dataset.dragging = "1"; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + // Firefox 不设 data 就不肯开始拖;这里只当占位 + event.dataTransfer.setData("text/plain", dragPromptId); + } + }); + + list.addEventListener("dragover", (event) => { + if (!dragPromptId) return; + const gap = dropGap(list, event.clientY); + if (gap == null) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + markDropGap(list, gap); + autoScrollOutline(list, event.clientY); + }); + + list.addEventListener("dragleave", (event) => { + const to = event.relatedTarget; + if (to instanceof Node && list.contains(to)) return; + clearDropMarks(); + }); + + list.addEventListener("drop", (event) => { + if (!dragPromptId) return; + event.preventDefault(); + const gap = dropGap(list, event.clientY); + const promptId = dragPromptId; + dragPromptId = null; + clearDropMarks(); + if (gap == null) return; + movePromptToGap(promptId, gap); + }); + + list.addEventListener("dragend", () => { + dragPromptId = null; + clearDropMarks(); + for (const row of list.querySelectorAll(".px-row[data-dragging]")) { + delete row.dataset.dragging; + } + }); +} + +function addModule() { + const preset = state.preset; + if (!preset) return; + const used = new Set(preset.prompts.map((p) => p.id)); + let n = preset.prompts.filter((p) => !p.marker).length + 1; + let id = `custom-${n}`; + while (used.has(id)) { + n += 1; + id = `custom-${n}`; + } + preset.prompts.push({ + id, + name: `新模块 ${n}`, + enabled: true, + role: "system", + content: "", + marker: false, + sourceIdentifier: id, + }); + const max = preset.promptOrder.reduce((m, item) => Math.max(m, item.orderIndex), -1); + preset.promptOrder.push({ promptId: id, enabled: true, orderIndex: max + 1 }); + markDirty(); + state.selected = id; + renderOutline(); + renderEditor(); + const box = document.querySelector(".px-content"); + if (box) box.focus(); +} + +function addToOrder(promptId) { + const preset = state.preset; + const prompt = preset.prompts.find((p) => p.id === promptId); + if (!prompt) return; + const max = preset.promptOrder.reduce((m, item) => Math.max(m, item.orderIndex), -1); + preset.promptOrder.push({ promptId, enabled: true, orderIndex: max + 1 }); + prompt.enabled = true; + markDirty(); + renderOutline(); + renderEditor(); +} + +function removeFromOrder(promptId) { + const preset = state.preset; + preset.promptOrder = preset.promptOrder.filter((item) => item.promptId !== promptId); + markDirty(); + renderOutline(); + renderEditor(); +} + +function removeModule(promptId) { + const preset = state.preset; + const prompt = preset.prompts.find((p) => p.id === promptId); + if (!prompt || prompt.marker) return; + if (!window.confirm(`删除模块「${prompt.name || prompt.id}」?`)) return; + preset.prompts = preset.prompts.filter((p) => p.id !== promptId); + preset.promptOrder = preset.promptOrder.filter((item) => item.promptId !== promptId); + markDirty(); + state.selected = firstSelectable(); + renderOutline(); + renderEditor(); +} + +/* ---------- 编辑器 ---------- */ + +function renderEditor() { + const host = $("editor"); + host.replaceChildren(); + if (!state.preset) { + host.append(el("div", { class: "px-empty" }, [ + el("p", { text: "还没有预设。" }), + el("button", { + type: "button", + class: "px-btn px-btn-primary", + text: "新建预设", + onclick: createPreset, + }), + ])); + return; + } + if (!state.selected) state.selected = firstSelectable(); + if (state.selected === GENERATION) { + renderGenerationEditor(host); + return; + } + if (state.selected === PSEUDO_HISTORY) { + renderPseudoHistoryEditor(host); + return; + } + const found = findModule(state.selected); + if (!found) { + state.selected = firstSelectable(); + if (state.selected === GENERATION) return renderGenerationEditor(host); + return renderEditor(); + } + renderModuleEditor(host, found); +} + +function renderModuleEditor(host, { prompt, item, orderIndex, orderedCount }) { + const enabled = moduleEnabled(prompt, item); + const canMove = Boolean(item); + + const head = el("div", { class: "px-editor-head" }, [ + el("button", { + type: "button", + class: "px-btn px-btn-mini px-only-narrow", + text: "顺序", + title: "打开发送顺序", + onclick: () => { + state.outlineTouched = true; + document.body.dataset.outline = "on"; + }, + }), + el("button", { + type: "button", + class: "px-btn px-btn-mini px-preview-launcher", + text: "预览", + title: "打开实际发送内容", + onclick: openPreviewPane, + }), + el("input", { + type: "text", + class: "px-input px-editor-name", + value: prompt.name || prompt.id, + "aria-label": "模块名称", + oninput: (e) => { + prompt.name = e.target.value; + markDirty(); + syncOutlineName(prompt.id, e.target.value); + }, + }), + prompt.marker + ? el("span", { class: "px-row-tag", text: "位置模块", title: MARKER_HINTS[prompt.id] || "" }) + : roleSelect(prompt), + el("span", { class: "px-editor-status", id: "editor-status", text: moduleStatusText(item, enabled, orderIndex, orderedCount) }), + el("div", { class: "px-editor-actions" }, [ + el("button", { + type: "button", + class: "px-btn px-btn-mini", + text: "↑", + title: "上移", + disabled: canMove && orderIndex > 0 ? null : "disabled", + onclick: () => moveModule(prompt.id, -1), + }), + el("button", { + type: "button", + class: "px-btn px-btn-mini", + text: "↓", + title: "下移", + disabled: canMove && orderIndex < orderedCount - 1 ? null : "disabled", + onclick: () => moveModule(prompt.id, 1), + }), + !item + ? el("button", { type: "button", class: "px-btn px-btn-mini", text: "加入顺序", onclick: () => addToOrder(prompt.id) }) + : (prompt.marker + ? null + : el("button", { type: "button", class: "px-btn px-btn-mini", text: "移出顺序", onclick: () => removeFromOrder(prompt.id) })), + prompt.marker + ? null + : el("button", { type: "button", class: "px-btn px-btn-mini px-btn-danger", text: "删除", onclick: () => removeModule(prompt.id) }), + ]), + ]); + + const body = el("div", { class: "px-editor-body" }); + if (prompt.marker) { + body.append(el("div", { class: "px-marker" }, [ + el("p", { class: "px-marker-title", text: "位置模块:运行时往这个位置填内容" }), + el("p", { class: "px-hint", text: MARKER_HINTS[prompt.id] || "只能启停,不能删除。" }), + ])); + } else { + const area = el("textarea", { + class: "px-content", + spellcheck: "false", + placeholder: "这个模块的正文…", + "aria-label": "模块正文", + }); + area.value = prompt.content || ""; + area.addEventListener("input", () => { + prompt.content = area.value; + markDirty(); + updateContentFoot(area.value); + }); + body.append(area); + body.append(el("div", { class: "px-editor-foot", id: "content-foot" }, contentFootItems(prompt.content || ""))); + attachMacroSuggest(area); + } + + host.append(head, body); +} + +function roleSelect(prompt) { + const select = el("select", { + class: "px-select", + "aria-label": "消息角色", + onchange: (e) => { + prompt.role = e.target.value; + markDirty(); + renderOutline(); + }, + }, ["system", "user", "assistant"].map((role) => + el("option", { value: role, text: role, selected: prompt.role === role }), + )); + select.value = prompt.role || "system"; + return select; +} + +function moduleStatusText(item, enabled, orderIndex, orderedCount) { + if (!item) return "未加入顺序"; + const position = `${orderIndex + 1}/${orderedCount}`; + return enabled ? `启用 · 第 ${position}` : `已停用 · 第 ${position}`; +} + +function syncEditorStatus() { + const found = state.selected ? findModule(state.selected) : null; + const host = $("editor-status"); + if (!found || !host) return; + host.textContent = moduleStatusText(found.item, moduleEnabled(found.prompt, found.item), found.orderIndex, found.orderedCount); +} + +function countMacros(text) { + const matches = String(text).match(/\{\{[^{}]*\}\}/g); + return matches ? matches.length : 0; +} + +function contentFootItems(text) { + const unknown = (String(text).match(/\{\{[^{}]*\}\}/g) || []); + const unrecognized = unknown.filter((raw) => { + const name = raw.slice(2, -2).split("::")[0].trim(); + return !MACROS.some(([macro]) => macro.slice(2, -2).split("::")[0].trim() === name) + && !/^(\/|trim|roll)/i.test(name); + }).length; + return [ + el("span", { text: `${String(text).length} 字` }), + el("span", { text: `${countMacros(text)} 个宏` }), + unrecognized ? el("span", { text: `${unrecognized} 个未识别宏,会原样发出` }) : null, + ]; +} + +function updateContentFoot(text) { + const host = $("content-foot"); + if (!host) return; + host.replaceChildren(...contentFootItems(text).filter(Boolean)); +} + +function syncOutlineName(promptId, name) { + const row = document.querySelector(`.px-outline .px-row[data-prompt="${CSS.escape(promptId)}"] .px-row-name`); + if (row) row.textContent = name || promptId; +} + +/* ---------- 生成参数 ---------- */ + +const GEN_FIELDS = [ + ["temperature", "temperature", "0.7", "0.01"], + ["topP", "top_p", "1", "0.01"], + ["topK", "top_k", "0", "1"], + ["minP", "min_p", "0", "0.01"], + ["frequencyPenalty", "frequency_penalty", "0", "0.1"], + ["presencePenalty", "presence_penalty", "0", "0.1"], + ["repetitionPenalty", "repetition_penalty", "1", "0.01"], + ["maxContextTokens", "max context tokens", "32768", "1"], + ["maxOutputTokens", "max output tokens", "4096", "1"], + ["seed", "seed", "随机", "1"], +]; + +const SQUASH_MODES = [ + ["", "默认(strict)"], + ["none", "none · 原样"], + ["merge", "merge · 合并相邻同角色"], + ["system", "system · 只合并 system"], + ["semi", "semi · system 折进相邻"], + ["strict", "strict · 强制首条 user"], + ["single-user-turn", "single-user-turn · 全并一条 user"], +]; + +function renderGenerationEditor(host) { + const preset = state.preset; + const gen = preset.generation || (preset.generation = {}); + + host.append(el("div", { class: "px-editor-head" }, [ + el("button", { + type: "button", + class: "px-btn px-btn-mini px-only-narrow", + text: "顺序", + onclick: () => { + state.outlineTouched = true; + document.body.dataset.outline = "on"; + }, + }), + el("button", { + type: "button", + class: "px-btn px-btn-mini px-preview-launcher", + text: "预览", + title: "打开实际发送内容", + onclick: openPreviewPane, + }), + el("strong", { style: "font-size:13px", text: "生成参数" }), + el("span", { class: "px-hint", text: "空字段表示不发送,跟预设默认走。" }), + ])); + + const body = el("div", { class: "px-editor-body" }); + const grid = el("div", { class: "px-gen-grid" }); + for (const [key, label, placeholder, step] of GEN_FIELDS) { + const input = el("input", { + type: "number", + step, + placeholder, + "aria-label": label, + oninput: (e) => { + const raw = e.target.value.trim(); + if (!raw) delete gen[key]; + else { + const n = Number(raw); + if (Number.isFinite(n)) gen[key] = n; + else delete gen[key]; + } + markDirty(); + syncGenTag(); + }, + }); + if (typeof gen[key] === "number") input.value = String(gen[key]); + grid.append(el("label", { class: "px-field" }, [el("span", { text: label }), input])); + } + body.append(grid); + + const stream = el("select", { + "aria-label": "stream", + onchange: (e) => { + if (e.target.value === "1") gen.stream = true; + else if (e.target.value === "0") gen.stream = false; + else delete gen.stream; + markDirty(); + syncGenTag(); + }, + }, [ + el("option", { value: "", text: "不指定" }), + el("option", { value: "1", text: "开" }), + el("option", { value: "0", text: "关" }), + ]); + stream.value = gen.stream === true ? "1" : gen.stream === false ? "0" : ""; + + const squash = el("select", { + "aria-label": "消息整形", + onchange: (e) => { + const policy = preset.messagePolicy || {}; + if (e.target.value) policy.squash = e.target.value; + else delete policy.squash; + if (Object.keys(policy).length > 0) preset.messagePolicy = policy; + else delete preset.messagePolicy; + markDirty(); + syncGenTag(); + }, + }, SQUASH_MODES.map(([value, text]) => + el("option", { value, text, selected: (preset.messagePolicy?.squash || "") === value }), + )); + squash.value = preset.messagePolicy?.squash || ""; + + body.append(el("div", { class: "px-gen-extra" }, [ + el("label", { class: "px-field" }, [el("span", { text: "stream" }), stream]), + el("label", { class: "px-field" }, [el("span", { text: "消息整形" }), squash]), + ])); + body.append(el("p", { class: "px-hint", style: "margin-top:12px", text: "消息整形留空时按预设默认(strict);这里改的值会随预设一起保存。" })); + host.append(body); +} + +function syncGenTag() { + const tag = document.querySelector(".px-row-gen .px-row-tag"); + if (!tag || !state.preset) return; + const count = Object.keys(state.preset.generation || {}).length; + tag.textContent = count ? `${count} 项` : "默认"; +} + +/* ---------- 假历史(预设级,真多轮) ---------- */ + +/** 读预设上的假历史;不在这里凭空创建数组,免得空预设也被写出一个空字段。 */ +function pseudoTurns() { + const raw = state.preset?.pseudoHistory; + return Array.isArray(raw) ? raw : []; +} + +function syncPseudoHistoryTag() { + const tag = document.querySelector(`.px-row[data-prompt="${PSEUDO_HISTORY}"] .px-row-tag`); + if (!tag) return; + const count = pseudoTurns().length; + tag.textContent = count ? `${count} 句` : "空"; +} + +function renderPseudoHistoryEditor(host) { + const preset = state.preset; + if (!Array.isArray(preset.pseudoHistory)) preset.pseudoHistory = []; + + host.append(el("div", { class: "px-editor-head" }, [ + el("button", { + type: "button", + class: "px-btn px-btn-mini px-only-narrow", + text: "顺序", + onclick: () => { + state.outlineTouched = true; + document.body.dataset.outline = "on"; + }, + }), + el("button", { + type: "button", + class: "px-btn px-btn-mini px-preview-launcher", + text: "预览", + title: "打开实际发送内容", + onclick: openPreviewPane, + }), + el("strong", { style: "font-size:13px", text: "假历史" }), + el("span", { class: "px-hint", text: "真多轮 user / assistant,排在真实历史之前。" }), + ])); + + const body = el("div", { class: "px-editor-body" }); + body.append(createHistoryEditor({ + items: preset.pseudoHistory, + onChange: () => { + markDirty(); + syncPseudoHistoryTag(); + }, + }).el); + body.append(el("p", { + class: "px-hint", + style: "margin-top:12px", + text: "假历史占真实轮次,模型会当成已经说过的话;要不占轮次的 few-shot,用「示例对话」模块(酒馆 mes_example,按 system + example_* 发出)。", + })); + host.append(body); +} + +/* ---------- 宏联想 ---------- */ + +const MACROS = [ + ["{{user}}", "当前用户名称"], + ["{{char}}", "当前作品 / 角色名"], + ["{{persona}}", "用户人设正文"], + ["{{lastUserMessage}}", "最近一条用户消息"], + ["{{mesExamples}}", "示例对话整段文本"], + ["{{getvar::key}}", "读取变量"], + ["{{setvar::key::value}}", "写入变量"], + ["{{addvar::key::value}}", "追加变量"], + ["{{roll::1d6}}", "掷骰"], + ["{{trim}}", "命中后对结果首尾去空白"], + ["{{// 注释}}", "注释,不发送"], +]; + +let suggestBox = null; + +function closeSuggest() { + if (suggestBox) { + suggestBox.remove(); + suggestBox = null; + } +} + +function attachMacroSuggest(textarea) { + textarea.addEventListener("input", () => updateSuggest(textarea)); + textarea.addEventListener("blur", () => window.setTimeout(closeSuggest, 120)); + textarea.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeSuggest(); + }); +} + +function updateSuggest(textarea) { + const caret = textarea.selectionStart; + const before = textarea.value.slice(0, caret); + const match = /({{|\{\{)([^\n{}{}]{0,20})$/.exec(before); + if (!match) { + closeSuggest(); + return; + } + const query = match[2].trim().toLowerCase(); + const hits = MACROS.filter(([name]) => !query || name.toLowerCase().includes(query)).slice(0, 8); + if (hits.length === 0) { + closeSuggest(); + return; + } + closeSuggest(); + suggestBox = el("div", { class: "px-suggest" }); + for (const [name, desc] of hits) { + suggestBox.append( + el("div", { + class: "px-suggest-row", + onmousedown: (e) => { + e.preventDefault(); + const start = caret - match[0].length; + textarea.value = textarea.value.slice(0, start) + name + textarea.value.slice(caret); + textarea.selectionStart = textarea.selectionEnd = start + name.length; + textarea.dispatchEvent(new Event("input")); + closeSuggest(); + textarea.focus(); + }, + }, [el("code", { text: name }), el("span", { text: desc })]), + ); + } + document.body.append(suggestBox); + const rect = textarea.getBoundingClientRect(); + suggestBox.style.left = `${rect.left + window.scrollX + 12}px`; + suggestBox.style.top = `${rect.top + window.scrollY + 30}px`; +} + +/* ---------- 结构检查 ---------- */ + +async function runCheck() { + if (!state.preset) return; + try { + const data = await api("check", { preset: state.preset }); + state.issues = data.issues || []; + renderIssues(); + renderOutlineDots(); + } catch (e) { + err(e.message); + } +} + +function renderIssues() { + const band = $("issues-band"); + band.replaceChildren(); + const issues = state.issues; + const errors = issues.filter((issue) => issue.level === "error").length; + const warns = issues.length - errors; + band.dataset.level = errors ? "error" : warns ? "warn" : "ok"; + + const text = errors + ? `${errors} 个错误${warns ? ` · ${warns} 个提醒` : ""}` + : warns + ? `${warns} 个提醒` + : "结构检查通过"; + + band.append(el("button", { + type: "button", + class: "px-band-summary", + disabled: issues.length ? null : "disabled", + title: issues.length ? "展开问题列表" : "", + onclick: () => { + state.issuesOpen = !state.issuesOpen; + renderIssues(); + }, + }, [ + el("span", { class: "px-band-dot" }), + el("span", { text }), + issues.length ? el("span", { class: "px-band-caret", text: state.issuesOpen ? "▴" : "▾" }) : null, + ])); + + if (state.issuesOpen && issues.length) { + const list = el("div", { class: "px-issue-list" }); + for (const issue of issues) { + list.append(el("button", { + type: "button", + class: "px-issue", + "data-level": issue.level, + "data-focus": issue.entryId ? "1" : "0", + title: issue.entryId ? "定位到模块" : "", + onclick: () => { if (issue.entryId) focusEntry(issue.entryId); }, + }, [ + el("b", { text: issue.level === "error" ? "错误" : "提醒" }), + ` ${issue.entryName ? issue.entryName + ":" : ""}${issue.message}`, + ])); + } + band.append(list); + } +} + +function renderOutlineDots() { + const bad = new Set(); + for (const issue of state.issues) { + if (issue.level === "error" && issue.entryId) bad.add(issue.entryId); + } + for (const row of document.querySelectorAll(".px-outline .px-row[data-prompt]")) { + row.dataset.error = bad.has(row.dataset.prompt) ? "1" : "0"; + } +} + +function focusEntry(entryId) { + selectModule(entryId); + const box = document.querySelector(".px-content"); + if (box) box.focus(); +} + +/* ---------- 预览 ---------- */ + +function casePayload(item) { + return { + id: item.id || "", + name: item.name || "", + charName: item.charName || "", + charDescription: item.charDescription || "", + worldBook: item.worldBook || "", + userInput: item.userInput || "", + history: (item.history || []).map((h) => ({ role: h.role, content: h.content })), + }; +} + +function currentPreviewCase() { + return state.cases.find((item) => item.id === state.previewCaseId) || state.cases[0] || null; +} + +function renderPreviewCases() { + const select = $("preview-case"); + select.replaceChildren(); + if (state.cases.length === 0) { + state.previewCaseId = ""; + select.append(el("option", { value: "", text: "(无用例,空输入)" })); + return; + } + if (!state.cases.some((item) => item.id === state.previewCaseId)) { + state.previewCaseId = state.cases[0].id || ""; + } + state.cases.forEach((item, index) => { + select.append(el("option", { + value: item.id || "", + text: item.name || `用例 ${index + 1}`, + selected: (item.id || "") === state.previewCaseId, + })); + }); + select.value = state.previewCaseId; +} + +async function runPreview() { + if (!state.preset) return; + const item = currentPreviewCase(); + const payload = item ? casePayload(item) : { + id: "preview", + name: "预览", + charName: "", + charDescription: "", + worldBook: "", + userInput: "", + history: [], + }; + if (!payload.id) payload.id = "preview"; + try { + const data = await api("preview", { preset: state.preset, testCase: payload }); + state.messages = data.messages || []; + state.previewShape = { + squash: data.squash || null, + assembledCount: typeof data.assembledCount === "number" ? data.assembledCount : state.messages.length, + }; + renderPreview(item); + } catch (e) { + err(e.message); + } +} + +function renderPreview(item) { + const host = $("preview-out"); + const keep = host.scrollTop; + host.replaceChildren(); + const messages = state.messages; + messages.forEach((message, index) => host.append(messageNode(message, index))); + if (messages.length === 0) { + host.append(el("p", { class: "px-hint", text: "没有产生消息:检查 chatHistory 是否启用、模块是否为空。" })); + } + host.scrollTop = keep; + const chars = messages.reduce((sum, message) => sum + String(message.content || "").length, 0); + const caseName = item ? item.name || "未命名用例" : "空输入"; + // 左轨顺序就是装配顺序;整形可能把若干条并成一条,这里得说清楚,否则看着像右侧没跟左轨。 + const squash = state.previewShape.squash; + const shapeNote = squash && squash !== "none" && messages.length > 0 + ? ` · 整形 ${squash}(装配 ${state.previewShape.assembledCount} 条 → 发出 ${messages.length} 条)` + : ""; + const meta = $("preview-meta"); + meta.textContent = `${caseName} · ${messages.length} 条 · ${chars.toLocaleString("zh-CN")} 字${shapeNote}`; + meta.title = shapeNote + ? "左轨顺序决定装配顺序;整形按预设的「消息整形」设置合并消息,可在「生成参数」里改成 none 原样发出。" + : "左轨顺序决定了这里每一条的位置。"; +} + +function messageNode(message, index) { + const text = message.content == null ? "" : String(message.content); + const long = text.length > 800; + const body = el("div", { + class: "px-msg-body", + "data-clamped": long ? "1" : "0", + text, + }); + const node = el("div", { class: "px-msg", "data-role": message.role }, [ + el("span", { class: "px-msg-role", text: `${index + 1} · ${message.role}` }), + body, + ]); + if (long) { + node.append(el("button", { + type: "button", + class: "px-msg-more", + text: `展开(${text.length.toLocaleString("zh-CN")} 字)`, + onclick: (e) => { + body.dataset.clamped = "0"; + e.target.remove(); + }, + })); + } + return node; +} + +function openPreviewPane() { + state.previewTouched = true; + document.body.dataset.preview = "on"; + renderPreviewToggle(); +} + +function renderPreviewToggle() { + const on = document.body.dataset.preview !== "off"; + const button = $("btn-toggle-preview"); + if (button) button.textContent = on ? "收起" : "预览"; +} + +/* ---------- 版本 ---------- */ + +const LANE_W = 11; +const LANE_PAD = 5; + +/** + * 版本树的 git 式布局:DFS 预序,先出生的是主干(子节点继承父的 lane), + * 后面的兄弟开新 lane。返回每行的 lane、父 lane、以及这一行要竖着画哪几条线。 + */ +function versionGraphRows(tree) { + const byParent = new Map(); + for (const node of tree.nodes) { + const key = node.parentId || ""; + if (!byParent.has(key)) byParent.set(key, []); + byParent.get(key).push(node); + } + for (const list of byParent.values()) { + list.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))); + } + const known = new Set(tree.nodes.map((node) => node.id)); + const roots = [...(byParent.get("") || [])]; + for (const node of tree.nodes) { + // 父节点丢了的孤儿也当根显示,不然整支看不见 + if (node.parentId && !known.has(node.parentId)) roots.push(node); + } + + const rows = []; + const seen = new Set(); + const walk = (node, lane, ancestorLanes) => { + if (seen.has(node.id)) return; + seen.add(node.id); + // 主干上的子代继承父的 lane:已经在这条线上就别再叠一条 + const openLanes = ancestorLanes.includes(lane) ? [...ancestorLanes] : [...ancestorLanes, lane]; + const children = (byParent.get(node.id) || []).filter((child) => !seen.has(child.id)); + rows.push({ + node, + lane, + parentLane: ancestorLanes.length > 0 ? ancestorLanes[ancestorLanes.length - 1] : null, + openLanes, + continuesBelow: children.length > 0, + }); + // 兄弟分叉各自占一条空 lane;子代共享这份占用表,互不叠线 + const reserved = new Set(openLanes); + children.forEach((child, index) => { + let childLane = lane; + if (index > 0) { + childLane = 0; + while (reserved.has(childLane)) childLane += 1; + reserved.add(childLane); + } + walk(child, childLane, openLanes); + }); + }; + for (const root of roots) walk(root, 0, []); + for (const node of tree.nodes) walk(node, 0, []); + + const laneCount = rows.reduce((max, row) => Math.max(max, ...row.openLanes) + 1, 1); + return { rows, laneCount }; +} + +function versionGraph(row, laneCount, node) { + const width = laneCount * LANE_W + LANE_PAD; + const gutter = el("span", { class: "px-ver-graph", style: `width:${width}px` }); + const center = (lane) => LANE_W / 2 + lane * LANE_W; + for (const lane of row.openLanes) { + const own = lane === row.lane; + // 自己那条线到节点/分叉就收住;祖先的线整列贯通 + const full = !own || row.continuesBelow; + gutter.append(el("i", { + class: "px-ver-line", + style: `left:${center(lane) - 1}px;${full ? "top:0;bottom:0" : "top:0;height:50%"}`, + })); + } + const parentLane = row.parentLane; + if (parentLane != null && parentLane !== row.lane) { + const from = Math.min(center(parentLane), center(row.lane)); + const to = Math.max(center(parentLane), center(row.lane)); + gutter.append(el("i", { class: "px-ver-join", style: `left:${from}px;width:${to - from}px` })); + } + gutter.append(el("i", { + class: "px-ver-dot", + "data-head": node.id === state.tree?.currentId ? "1" : "0", + style: `left:${center(row.lane) - 3.5}px`, + })); + return gutter; +} + +function renderVersions() { + const host = $("version-tree"); + host.replaceChildren(); + closeVersionMenu(); + const tree = state.tree; + if (!tree || tree.nodes.length === 0) { + $("version-hint").textContent = "还没有版本,点「保存为新版本」建根。"; + updateDirtyHint(); + return; + } + if (!state.selectedVersionId || !tree.nodes.some((node) => node.id === state.selectedVersionId)) { + state.selectedVersionId = tree.currentId || tree.nodes[0]?.id || null; + } + + const { rows, laneCount } = versionGraphRows(tree); + for (const row of rows) { + const { node } = row; + const when = node.createdAt.replace("T", " ").slice(5, 16); + const isHead = node.id === tree.currentId; + host.append(el("button", { + type: "button", + class: "px-ver", + role: "treeitem", + "data-current": isHead ? "1" : "0", + "data-selected": node.id === state.selectedVersionId ? "1" : "0", + "data-version-id": node.id, + onclick: (event) => { + event.stopPropagation(); + selectVersionNode(node.id); + openVersionMenu(node.id, event.currentTarget); + }, + }, [ + versionGraph(row, laneCount, node), + el("span", { class: "px-ver-main" }, [ + el("span", { class: "px-ver-title" }, [ + `${node.id} · ${when}`, + isHead ? el("em", { class: "px-ver-badge", text: "当前" }) : null, + ]), + el("small", { text: node.note || "(无说明)" }), + ]), + ])); + } + $("version-hint").textContent = + `共 ${tree.nodes.length} 个版本 · 当前 ${tree.currentId || "无"} · 点节点看操作`; + updateDirtyHint(); +} + +/** 点节点弹出的操作菜单:读是显式动作,不连带。 */ +function openVersionMenu(nodeId, anchor) { + closeVersionMenu(); + const tree = state.tree; + const node = tree?.nodes.find((item) => item.id === nodeId); + if (!node) return; + const isHead = node.id === tree.currentId; + const menu = el("div", { class: "px-ver-menu", role: "menu" }, [ + el("div", { class: "px-ver-menu-head" }, [ + el("span", { class: "px-ver-menu-title" }, [ + node.id, + isHead ? el("em", { class: "px-ver-badge", text: "当前" }) : null, + ]), + el("span", { class: "px-hint", text: `创建 ${node.createdAt.replace("T", " ").slice(0, 16)}` }), + node.updatedAt + ? el("span", { class: "px-hint", text: `改动 ${node.updatedAt.replace("T", " ").slice(0, 16)}` }) + : null, + node.note ? el("span", { class: "px-ver-menu-note", text: node.note }) : null, + ]), + el("button", { + type: "button", + class: "px-ver-menu-item", + text: "读取这个版本", + title: "把这个快照换进编辑器(当前草稿会被覆盖)", + onclick: () => { + closeVersionMenu(); + state.selectedVersionId = node.id; + void loadSelectedVersion(); + }, + }), + el("button", { + type: "button", + class: "px-ver-menu-item", + text: "从它分叉", + title: "以当前草稿为内容,在这个节点下开一条新分支", + onclick: () => { + closeVersionMenu(); + void saveVersion(node.id); + }, + }), + el("button", { + type: "button", + class: "px-ver-menu-item", + text: "复制 id", + onclick: async () => { + try { + await navigator.clipboard.writeText(node.id); + notice(`已复制 ${node.id}`); + } catch { + err(`复制失败,id 是 ${node.id}`); + } + closeVersionMenu(); + }, + }), + ]); + menu.dataset.versionId = node.id; + document.body.append(menu); + const rect = anchor.getBoundingClientRect(); + const left = Math.max(8, Math.min(rect.left, window.innerWidth - menu.offsetWidth - 8)); + const top = Math.min(rect.bottom + 4, window.innerHeight - menu.offsetHeight - 8); + menu.style.left = `${left}px`; + menu.style.top = `${Math.max(8, top)}px`; + // 延迟挂,避免这一下 click 立刻把它关掉 + setTimeout(() => { + document.addEventListener("click", onVersionMenuOutsideClick, true); + window.addEventListener("scroll", closeVersionMenu, true); + }, 0); +} + +function onVersionMenuOutsideClick(event) { + const menu = document.querySelector(".px-ver-menu"); + if (menu && menu.contains(event.target)) return; + closeVersionMenu(); +} + +function closeVersionMenu() { + document.removeEventListener("click", onVersionMenuOutsideClick, true); + window.removeEventListener("scroll", closeVersionMenu, true); + for (const menu of document.querySelectorAll(".px-ver-menu")) menu.remove(); +} + +/** 只切选中态,不重建版本树:避免列表滚动位置看起来跳变。 */ +function selectVersionNode(nodeId) { + state.selectedVersionId = nodeId; + for (const button of document.querySelectorAll("#version-tree .px-ver")) { + button.dataset.selected = button.dataset.versionId === nodeId ? "1" : "0"; + } + const tree = state.tree; + if (tree) { + $("version-hint").textContent = + `共 ${tree.nodes.length} 个版本 · 当前 ${tree.currentId || "无"} · 选中 ${state.selectedVersionId || "无"}`; + } +} + +async function loadSelectedVersion() { + if (!state.selectedVersionId) { + err("先在版本树里选一个版本。"); + return; + } + if (!confirmDiscard()) return; + try { + const tree = await api("versions/checkout", { + presetId: state.presetId, + nodeId: state.selectedVersionId, + }); + state.tree = tree; + const node = tree.nodes.find((n) => n.id === state.selectedVersionId); + if (!node) throw new Error("版本节点不存在"); + state.preset = structuredClone(node.preset); + state.presetDirty = false; + state.run = null; + state.selected = firstSelectable(); + renderAll(); + notice(`已载入版本 ${node.id}`); + await refreshAll(); + } catch (e) { + err(e.message); + } +} + +async function saveVersion(parentId) { + if (!state.preset) return null; + try { + const body = { preset: state.preset, note: $("note-input").value }; + if (typeof parentId === "string" && parentId) body.parentId = parentId; + const tree = await api("versions/commit", body); + state.tree = tree; + state.selectedVersionId = tree.currentId; + state.presetDirty = false; + $("note-input").value = ""; + updateVersionsButton(); + renderVersions(); + updateDirtyHint(); + notice(`已保存为新版本 ${tree.currentId}`); + return tree; + } catch (e) { + err(e.message); + return null; + } +} + +/** 保存至当前版本:覆盖 currentId 那个节点,不产生新节点。 */ +async function saveVersionToCurrent() { + if (!state.preset) return null; + const currentId = state.tree?.currentId; + const target = currentId + ? `版本 ${currentId}` + : "(还没有版本,会建根)"; + if (!window.confirm(`把当前草稿覆盖到${target}?不会新建节点,之前的快照会丢。`)) return null; + try { + const tree = await api("versions/amend", { + preset: state.preset, + note: $("note-input").value, + }); + state.tree = tree; + state.selectedVersionId = tree.currentId; + state.presetDirty = false; + $("note-input").value = ""; + updateVersionsButton(); + renderVersions(); + updateDirtyHint(); + notice(`已覆盖版本 ${tree.currentId}`); + return tree; + } catch (e) { + err(e.message); + return null; + } +} + +/* ---------- 用例 ---------- + * 一个用例就是一条记录:自己存、自己运行。盘上仍是一组一文件,但界面不出现「组」。 + */ + +const CASE_FIELDS = [ + ["name", "用例名", "input", "例:开门 / 拒绝"], + ["charName", "角色名 {{char}}", "input", ""], + ["charDescription", "角色描述(进 charDescription 位置)", "textarea", "这个用例里的角色是谁"], + ["worldBook", "世界书文本(进历史前的世界书位置)", "textarea", ""], + ["userInput", "用户输入(进 currentTurn 位置)", "textarea", ""], +]; + +function newCaseId() { + return crypto.randomUUID().slice(0, 8); +} + +function renderCaseList() { + const host = $("cases"); + host.replaceChildren(); + if (!state.preset) return; + if (state.cases.length === 0) { + host.append(el("p", { class: "px-hint", text: "还没有用例。用例全预设共用:一份要测的输入 + 期望的上下文。" })); + } + state.cases.forEach((item, index) => { + const active = index === state.activeCaseIndex; + host.append(el("div", { class: "px-case", "data-active": active ? "1" : "0" }, [ + el("button", { + type: "button", + class: "px-case-main", + title: active ? "当前用例(点卡片切换)" : "选中这个用例作为当前用例", + onclick: () => selectCase(index), + }, [ + el("span", { class: "px-case-name", text: item.name || `用例 ${index + 1}` }), + el("span", { class: "px-case-snippet", text: (item.userInput || "").replace(/\s+/g, " ").slice(0, 48) || "(无输入)" }), + ]), + el("button", { + type: "button", + class: "px-btn px-btn-mini", + text: "编辑", + title: "编辑这个用例", + onclick: (event) => { + event.stopPropagation(); + openCaseModal(index); + }, + }), + ])); + }); + host.append(el("button", { + type: "button", + class: "px-btn", + style: "margin-top:4px", + text: "+ 新增用例", + onclick: addCase, + })); + renderRunCaseLabel(); +} + +/** 运行栏上显示当前用例名 */ +function renderRunCaseLabel() { + const label = $("run-case-label"); + if (!label) return; + const item = state.cases[state.activeCaseIndex]; + label.textContent = item ? item.name || "未命名用例" : "先在左侧选一个用例"; +} + +/** 点用例卡片 = 选中(不弹编辑器);只有「编辑」按钮才打开用例编辑。 */ +function selectCase(index) { + if (index < 0 || index >= state.cases.length) return; + if (index === state.activeCaseIndex) return; + state.activeCaseIndex = index; + renderCaseList(); + rememberCaseState(); +} + +/** + * 记住「这个预设上次打开了哪条用例」。写盘(`presets/lab-state/.json`), + * 不放浏览器缓存:换浏览器、重开页面都还是同一条。 + */ +let caseStateTimer = null; +function rememberCaseState() { + if (!state.presetId) return; + window.clearTimeout(caseStateTimer); + caseStateTimer = window.setTimeout(() => { + const selected = state.cases[state.activeCaseIndex]; + void api("lab-state", { + presetId: state.presetId, + selectedCaseId: selected?.id ?? null, + previewCaseId: state.previewCaseId || null, + }).catch(() => { + /* 记不住不影响用 */ + }); + }, 200); +} + +function openCaseModal(index) { + const item = state.cases[index]; + if (!item) return; + const changed = state.activeCaseIndex !== index; + state.activeCaseIndex = index; + renderCaseList(); + if (changed) rememberCaseState(); + $("case-drawer-title").textContent = item.name || `用例 ${index + 1}`; + const form = $("case-form"); + form.replaceChildren(); + + // 大浮窗里分两列:左边用例自己的字段,右边历史(一句一行,需要宽度)。 + const grid = el("div", { class: "px-case-grid" }); + const fieldsCol = el("div", { class: "px-case-col px-case-col-fields" }); + const historyCol = el("div", { class: "px-case-col px-case-col-history" }); + + for (const [key, label, kind, placeholder] of CASE_FIELDS) { + const commitField = (value) => { + item[key] = value; + if (key === "name") { + $("case-drawer-title").textContent = value || `用例 ${index + 1}`; + renderCaseList(); + renderPreviewCases(); + } + scheduleRefresh(600); + }; + let input; + if (kind === "textarea") { + input = el("textarea", { + rows: 3, + placeholder, + oninput: (e) => commitField(e.target.value), + }); + } else { + input = el("input", { + type: "text", + placeholder, + oninput: (e) => commitField(e.target.value), + }); + } + input.value = item[key] || ""; + fieldsCol.append(el("label", { class: "px-field" }, [el("span", { text: label }), input])); + } + + if (!Array.isArray(item.history)) item.history = []; + historyCol.append(el("div", { class: "px-field px-field-history" }, [ + el("span", { text: "历史(真多轮 user / assistant,接在预设假历史之后)" }), + createHistoryEditor({ + items: item.history, + onChange: () => scheduleRefresh(600), + }).el, + ])); + + grid.append(fieldsCol, historyCol); + form.append(grid); + + form.append(el("div", { class: "px-modal-foot" }, [ + el("p", { class: "px-hint", text: "「完成」会自动保存;「运行此用例」也会先存再跑。" }), + el("div", { class: "px-modal-foot-actions" }, [ + el("button", { + type: "button", + class: "px-btn px-btn-mini px-btn-danger", + text: "删除此用例", + onclick: () => { + void deleteCase(index); + }, + }), + el("button", { + type: "button", + class: "px-btn px-btn-primary", + text: "完成", + onclick: () => { + void saveCase(index).then(() => closeDrawer()); + }, + }), + ]), + ])); + openDrawer("case"); +} + +function addCase() { + const id = newCaseId(); + state.cases.push({ + groupId: "", + id, + name: `用例 ${state.cases.length + 1}`, + charName: "", + charDescription: "", + worldBook: "", + userInput: "", + history: [], + }); + state.previewCaseId = id; + renderCaseList(); + renderPreviewCases(); + openCaseModal(state.cases.length - 1); +} + +/** 保存单份用例:没有 groupId 就新建一个只装它的组(盘上容器)。 */ +async function saveCase(index) { + const item = state.cases[index]; + if (!item || !state.presetId) return null; + try { + const data = await api("cases/save", { + groupId: item.groupId || "", + case: casePayload(item), + }); + item.groupId = data.groupId; + Object.assign(item, casePayload(data.case)); + renderCaseList(); + renderPreviewCases(); + return item; + } catch (e) { + err(e.message); + return null; + } +} + +async function deleteCase(index) { + const item = state.cases[index]; + if (!item) return; + if (!window.confirm(`删除用例「${item.name || `用例 ${index + 1}`}」?运行记录不会删。`)) return; + try { + if (item.groupId) { + await api("cases/delete", { + groupId: item.groupId, + caseId: item.id, + }); + } + state.cases.splice(index, 1); + if (state.activeCaseIndex === index) state.activeCaseIndex = -1; + else if (state.activeCaseIndex > index) state.activeCaseIndex -= 1; + if (state.previewCaseId === item.id) state.previewCaseId = state.cases[0]?.id || ""; + renderCaseList(); + renderPreviewCases(); + rememberCaseState(); + closeDrawer(); + scheduleRefresh(0); + notice("已删除用例"); + } catch (e) { + err(e.message); + } +} + +async function loadCases() { + // 用例是全局的,不分预设;预设只影响「上次打开的是哪条」这个记录 + const data = await api("cases"); + state.cases = (data.cases || []).map((entry) => ({ + groupId: entry.groupId, + ...casePayload(entry.case), + })); + let lab = null; + if (state.presetId) { + try { + lab = await api(`lab-state?presetId=${encodeURIComponent(state.presetId)}`); + } catch { + lab = null; + } + } + const wantedIndex = lab?.selectedCaseId + ? state.cases.findIndex((item) => item.id === lab.selectedCaseId) + : -1; + state.activeCaseIndex = wantedIndex >= 0 ? wantedIndex : (state.cases.length > 0 ? 0 : -1); + const wantedPreview = lab?.previewCaseId; + state.previewCaseId = state.cases.some((item) => item.id === wantedPreview) + ? wantedPreview + : (state.cases[0]?.id || ""); + renderCaseList(); + renderPreviewCases(); +} + +/* ---------- 模型与运行 ---------- */ + +/** 已选条:按住左右拖动即横向滚动;拖完那一下的 click 吃掉,不当成取消选择 */ +function attachDragScroll(box) { + if (!box) return; + let startX = 0; + let startLeft = 0; + let dragging = false; + let moved = false; + + box.addEventListener("pointerdown", (event) => { + if (event.button !== 0) return; + dragging = true; + moved = false; + startX = event.clientX; + startLeft = box.scrollLeft; + }); + + // 不用 pointer capture:那会把 click 重定向到容器,chip 的取消点击就收不到了。 + const onMove = (event) => { + if (!dragging) return; + const dx = event.clientX - startX; + if (!moved && Math.abs(dx) < 4) return; + moved = true; + box.dataset.dragging = "1"; + box.scrollLeft = startLeft - dx; + event.preventDefault(); + }; + + const onUp = () => { + if (!dragging) return; + dragging = false; + delete box.dataset.dragging; + if (!moved) return; + box.dataset.dragged = "1"; + window.setTimeout(() => { + delete box.dataset.dragged; + }, 0); + }; + + window.addEventListener("pointermove", onMove, { passive: false }); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + + // 捕获阶段先于 chip 的 onclick:拖完松手那下不触发删除 + box.addEventListener("click", (event) => { + if (box.dataset.dragged !== "1") return; + event.preventDefault(); + event.stopPropagation(); + }, true); +} + +/** 模型显示名:`配置名 · 模型名`(两者常常一样,名字短一点更像标签) */ +function profileLabel(profile) { + return profile.model && profile.model !== profile.name + ? `${profile.name} · ${profile.model}` + : profile.name; +} + +function setModelMenu(open) { + const menu = $("model-picker-menu"); + const trigger = $("model-picker-btn"); + if (!menu || !trigger) return; + menu.hidden = !open; + trigger.setAttribute("aria-expanded", open ? "true" : "false"); +} + +/** + * 模型选择:一个下拉多选(勾选不关菜单)+ 右侧一条已选展示。 + * 已选条只占一行,可横向拖动滚动,点一项取消,文字不可选(拖完的点击会吃掉)。 + */ +function renderProfiles() { + const menu = $("model-picker-menu"); + const picked = $("picked-models"); + const count = $("model-picker-count"); + if (!menu || !picked) return; + + // 缺 Key / 已被删掉的配置不留在选中集里 + const valid = new Set(state.profiles.filter((profile) => profile.hasKey).map((profile) => profile.id)); + for (const id of [...state.selectedProfileIds]) { + if (!valid.has(id)) state.selectedProfileIds.delete(id); + } + const selected = state.profiles.filter((profile) => state.selectedProfileIds.has(profile.id)); + if (count) count.textContent = selected.length ? ` ${selected.length}` : ""; + + // 菜单:多选,勾选后不关 + menu.replaceChildren(); + if (state.profiles.length === 0) { + menu.append(el("p", { class: "px-pick-empty", text: "没有可用的 API 配置,去设置页加一个。" })); + } else { + for (const profile of state.profiles) { + const active = state.selectedProfileIds.has(profile.id); + menu.append(el("button", { + type: "button", + class: "px-pick-item", + role: "option", + "aria-selected": active ? "true" : "false", + "data-active": active ? "1" : "0", + disabled: profile.hasKey ? null : "disabled", + title: profile.hasKey ? profileLabel(profile) : `${profile.name} 缺少 Key`, + onclick: () => { + if (state.selectedProfileIds.has(profile.id)) state.selectedProfileIds.delete(profile.id); + else state.selectedProfileIds.add(profile.id); + renderProfiles(); + }, + }, [ + el("span", { class: "px-pick-check", text: active ? "✓" : "" }), + el("span", { class: "px-pick-name", text: profile.name }), + el("span", { class: "px-pick-model", text: profile.model }), + profile.hasKey ? null : el("span", { class: "px-pick-warn", text: "缺 Key" }), + ])); + } + menu.append(el("div", { class: "px-pick-actions" }, [ + el("button", { + type: "button", + class: "px-btn px-btn-mini", + text: "全选可用", + onclick: () => { + for (const profile of state.profiles) { + if (profile.hasKey) state.selectedProfileIds.add(profile.id); + } + renderProfiles(); + }, + }), + el("button", { + type: "button", + class: "px-btn px-btn-mini", + text: "清空", + onclick: () => { + state.selectedProfileIds.clear(); + renderProfiles(); + }, + }), + ])); + } + + // 已选:只占一行,超出就横向拖 + picked.replaceChildren(); + if (selected.length === 0) { + picked.append(el("span", { class: "px-picked-empty", text: "未选模型" })); + return; + } + for (const profile of selected) { + picked.append(el("button", { + type: "button", + class: "px-picked-chip", + title: `点击取消:${profileLabel(profile)}`, + onclick: () => { + state.selectedProfileIds.delete(profile.id); + renderProfiles(); + }, + }, [ + profileLabel(profile), + el("span", { class: "px-picked-x", "aria-hidden": "true", text: "×" }), + ])); + } +} + +async function runTests() { + if (!state.preset) return; + if (state.cases.length === 0) { + err("先添加一个用例。"); + return; + } + const index = state.activeCaseIndex >= 0 ? state.activeCaseIndex : 0; + const item = state.cases[index]; + if (!item) { + err("先在左侧选一个用例。"); + return; + } + const profileIds = [...state.selectedProfileIds]; + if (profileIds.length === 0) { + err("请先勾选至少一个模型。"); + return; + } + const btn = $("btn-run"); + setBusy(btn, true, "运行中…"); + try { + const saved = await saveCase(index); + if (!saved) return; + const run = await api("run", { + presetId: state.presetId, + // 草稿运行不要冒用旧版本号,结果卡上要能看出这是哪一版。 + presetVersionId: state.presetDirty ? "" : (state.tree?.currentId || ""), + groupId: saved.groupId, + caseIds: [saved.id], + profileIds, + preset: state.preset, + }); + state.run = run; + await loadRuns(run.id); + notice(`运行完成:${run.results.length} 条`); + } catch (e) { + err(e.message); + } finally { + setBusy(btn, false); + } +} + +function renderRunSelect() { + const select = $("run-select"); + select.replaceChildren(); + if (state.runs.length === 0) { + select.append(el("option", { value: "", text: "(暂无运行记录)" })); + return; + } + for (const run of state.runs) { + const when = run.createdAt.replace("T", " ").slice(5, 16); + const version = run.presetVersionId || "草稿"; + select.append(el("option", { + value: run.id, + text: `${run.id} · ${when} · ${version} · ${run.results.length} 条`, + selected: state.run?.id === run.id, + })); + } + if (state.run) select.value = state.run.id; +} + +async function loadRuns(selectId) { + try { + const data = await api(`runs?presetId=${encodeURIComponent(state.presetId)}`); + state.runs = data.runs || []; + const wanted = selectId || state.run?.id; + state.run = state.runs.find((run) => run.id === wanted) || state.runs[0] || null; + } catch { + state.runs = []; + state.run = null; + } + renderRunSelect(); + renderResults(); + renderRunEntry(); +} + +function relativeTime(iso) { + const then = new Date(iso).getTime(); + if (!Number.isFinite(then)) return "刚刚"; + const minutes = Math.max(0, Math.round((Date.now() - then) / 60000)); + if (minutes < 1) return "刚刚"; + if (minutes < 60) return `${minutes} 分钟前`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours} 小时前`; + return `${Math.round(hours / 24)} 天前`; +} + +function renderRunEntry() { + const btn = $("btn-run-entry"); + const label = btn.querySelector(".px-btn-label"); + const meta = $("run-entry-meta"); + if (state.view === "run") { + label.textContent = "← 编辑"; + meta.textContent = ""; + return; + } + label.textContent = "试跑"; + const run = state.runs[0]; + if (!run) { + meta.textContent = ""; + return; + } + const fails = run.results.filter((result) => result.verdict === "failure").length; + meta.textContent = ` · ${relativeTime(run.createdAt)} · ${run.results.length} 条${fails ? ` · ⚑${fails}` : ""}`; +} + +function renderResults() { + const host = $("results"); + host.replaceChildren(); + const run = state.run; + if (!run) { + host.append(el("p", { class: "px-hint", text: "还没有运行记录。选好模型后点「运行」,结果会出现在这里。" })); + return; + } + const caseNames = new Map(state.cases.map((item) => [item.id, item.name])); + for (const result of run.results) { + const verdictRow = el("div", { class: "px-verdict" }); + for (const [value, label] of [["success", "成功"], ["failure", "失败"], ["", "清除"]]) { + verdictRow.append(el("button", { + type: "button", + class: "px-btn px-btn-mini", + text: label, + "data-active": result.verdict === (value || null) ? "1" : "0", + onclick: () => setVerdict(result, value || null), + })); + } + const statusText = result.status === "done" ? "完成" : result.status === "error" ? "错误" : "运行中"; + const duration = typeof result.durationMs === "number" ? ` · ${(result.durationMs / 1000).toFixed(1)}s` : ""; + const card = el("div", { + class: "px-result", + "data-verdict": result.verdict || "", + }, [ + el("div", { class: "px-result-head" }, [ + el("b", { text: result.profileName }), + el("span", { class: "px-row-tag", text: result.caseName || caseNames.get(result.caseId) || result.caseId }), + el("span", { class: "px-row-tag", text: `${result.model} · ${statusText}${duration}` }), + verdictRow, + ]), + ]); + if (result.error) { + card.append(el("div", { class: "px-issue", "data-level": "error", text: result.error })); + } else if (result.content) { + card.append(el("div", { class: "px-result-body", text: result.content })); + } else { + card.append(el("p", { class: "px-hint", text: "(空回复)" })); + } + if (result.usage) { + card.append(el("p", { class: "px-hint", text: + `tokens:prompt ${result.usage.promptTokens} · completion ${result.usage.completionTokens} · total ${result.usage.totalTokens}`, + })); + } + const request = (run.requests || []).find((item) => item.caseId === result.caseId); + if (request) { + const box = el("div", { class: "px-messages" }); + request.messages.forEach((message, index) => box.append(messageNode(message, index))); + card.append(el("details", { class: "px-details" }, [ + el("summary", { text: `发送内容 · ${request.messages.length} 条` }), + box, + ])); + } + host.append(card); + } + if ((run.issues || []).length > 0) { + const list = el("div", { class: "px-issue-list" }); + for (const issue of run.issues) { + list.append(el("div", { class: "px-issue", "data-level": issue.level }, [ + el("b", { text: issue.level === "error" ? "错误" : "提醒" }), + ` ${issue.entryName ? issue.entryName + ":" : ""}${issue.message}`, + ])); + } + host.append(el("details", { class: "px-details" }, [ + el("summary", { text: `本次运行的结构提醒 · ${run.issues.length} 条` }), + list, + ])); + } +} + +async function setVerdict(result, verdict) { + try { + const run = await api("verdict", { + presetId: state.presetId, + runId: state.run.id, + profileId: result.profileId, + caseId: result.caseId, + verdict, + }); + state.run = run; + const index = state.runs.findIndex((item) => item.id === run.id); + if (index >= 0) state.runs[index] = run; + renderResults(); + renderRunEntry(); + } catch (e) { + err(e.message); + } +} + +/* ---------- 视图 / 抽屉 ---------- */ + +function setView(view) { + state.view = view; + $("view-edit").hidden = view !== "edit"; + $("view-run").hidden = view !== "run"; + renderRunEntry(); + if (view === "run") { + renderProfiles(); + renderCaseList(); + renderRunSelect(); + renderResults(); + } +} + +function openDrawer(name) { + state.drawer = name; + $("scrim").hidden = false; + $("version-drawer").hidden = name !== "version"; + $("case-modal").hidden = name !== "case"; + document.body.dataset.modal = name === "case" ? "on" : "off"; + if (name === "version") renderVersions(); +} + +function closeDrawer() { + state.drawer = null; + closeVersionMenu(); + $("scrim").hidden = true; + $("version-drawer").hidden = true; + $("case-modal").hidden = true; + document.body.dataset.modal = "off"; +} + +/* ---------- 载入与动作 ---------- */ + +function renderAll() { + renderIdbar(); + renderOutline(); + renderEditor(); + renderVersions(); + renderCaseList(); + renderPreviewCases(); + updateDirtyHint(); +} + +async function loadPreset(presetId) { + if (!presetId) return false; + if (!confirmDiscard()) return false; + const previousId = state.presetId; + state.presetId = presetId; + state.selectedVersionId = null; + state.run = null; + state.runs = []; + state.previewCaseId = ""; + state.activeCaseIndex = -1; + state.selected = null; + state.messages = []; + state.issues = []; + $("preview-out").replaceChildren(); + $("preview-meta").textContent = ""; + $("results").replaceChildren(); + renderIssues(); + try { + const data = await api(`preset?presetId=${encodeURIComponent(presetId)}`); + const tree = await api(`versions?presetId=${encodeURIComponent(presetId)}`); + state.preset = data.preset; + state.tree = tree; + state.selectedVersionId = tree.currentId || tree.nodes[0]?.id || null; + if (tree.currentId) { + const node = tree.nodes.find((item) => item.id === tree.currentId); + if (node) state.preset = structuredClone(node.preset); + } + state.presetDirty = false; + state.selected = firstSelectable(); + renderAll(); + try { + await Promise.all([loadCases(), loadRuns()]); + } catch (e) { + // 预设本体已经载入;用例组坏掉只提示,不要把 select 退回去。 + err(e.message); + } + await refreshAll(); + return true; + } catch (e) { + state.presetId = previousId; + err(e.message); + return false; + } +} + +async function createPreset() { + if (!confirmDiscard()) return; + // 不弹 prompt:桌面壳里 window.prompt 不可靠;建完直接在名称框里改。 + const name = `新预设 ${state.presets.length + 1}`; + try { + const data = await api("presets/create", { name }); + state.presetDirty = false; + await refreshPresetList(data.preset.id); + await loadPreset(data.preset.id); + notice("已新建预设,可直接改名称"); + const input = $("preset-name"); + input.focus(); + input.select(); + } catch (e) { + err(e.message); + } +} + +async function applyPreset() { + if (!state.preset) return; + if (!window.confirm(`「应用到对话页」会覆盖 ${state.presetId}.json,继续?`)) return; + try { + const tree = await saveVersion(); + if (!tree) return; + let applied = null; + try { + applied = await api("preset/apply", { preset: state.preset }); + } catch (e) { + if (String(e.message).includes("结构检查") && + window.confirm(`${e.message}\n\n仍要强制写回?`)) { + applied = await api("preset/apply", { preset: state.preset, force: true }); + } else { + throw e; + } + } + await refreshPresetList(state.presetId); + notice(`已应用到对话页${applied?.activePresetId ? "并启用" : ""}`); + } catch (e) { + err(e.message); + } +} + +/* ---------- 事件绑定 / 启动 ---------- */ + +function bindEvents() { + $("preset-select").onchange = async (e) => { + const ok = await loadPreset(e.target.value); + if (!ok) e.target.value = state.presetId; + }; + $("btn-new-preset").onclick = createPreset; + $("btn-reload").onclick = () => loadPreset(state.presetId); + $("btn-apply").onclick = applyPreset; + $("preset-name").oninput = (e) => { + if (!state.preset) return; + state.preset.name = e.target.value; + const option = [...$("preset-select").options].find((item) => item.value === state.preset.id); + if (option) option.textContent = `${e.target.value || state.preset.id}(${state.preset.prompts.length})`; + markDirty(); + }; + + $("btn-run-entry").onclick = () => setView(state.view === "run" ? "edit" : "run"); + $("btn-versions").onclick = () => openDrawer("version"); + $("btn-close-versions").onclick = closeDrawer; + $("btn-close-case").onclick = closeDrawer; + $("scrim").onclick = closeDrawer; + + $("btn-preview").onclick = () => refreshAll(); + $("preview-case").onchange = (e) => { + state.previewCaseId = e.target.value; + rememberCaseState(); + void refreshAll(); + }; + $("btn-toggle-preview").onclick = () => { + state.previewTouched = true; + document.body.dataset.preview = document.body.dataset.preview === "off" ? "on" : "off"; + renderPreviewToggle(); + }; + + $("btn-save-version-current").onclick = () => saveVersionToCurrent(); + $("btn-save-version").onclick = () => saveVersion(); + $("btn-save-current").onclick = () => saveVersionToCurrent(); + $("btn-save-new").onclick = () => saveVersion(); + $("btn-load-version").onclick = loadSelectedVersion; + $("btn-fork-version").onclick = () => { + if (!state.selectedVersionId) { + err("先在版本树里选一个版本再分叉。"); + return; + } + saveVersion(state.selectedVersionId); + }; + + $("model-picker-btn").onclick = (event) => { + event.stopPropagation(); + setModelMenu($("model-picker-menu").hidden); + }; + document.addEventListener("click", (event) => { + const picker = document.querySelector(".px-picker"); + if (picker && picker.contains(event.target)) return; + setModelMenu(false); + }); + attachDragScroll($("picked-models")); + + $("btn-run").onclick = runTests; + $("btn-refresh-runs").onclick = () => loadRuns(); + $("run-select").onchange = (e) => { + state.run = state.runs.find((run) => run.id === e.target.value) || null; + renderResults(); + }; + + window.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { + e.preventDefault(); + if (e.shiftKey) saveVersionToCurrent(); + else saveVersion(); + return; + } + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + void refreshAll(); + return; + } + if (e.key === "Escape") { + setModelMenu(false); + if (document.querySelector(".px-ver-menu")) { + closeVersionMenu(); + return; + } + closeDrawer(); + } + }); + + window.addEventListener("resize", () => { + if (!state.previewTouched) { + document.body.dataset.preview = window.innerWidth > 1180 ? "on" : "off"; + } + if (!state.outlineTouched && window.innerWidth > 860) { + document.body.dataset.outline = "off"; + } + renderPreviewToggle(); + }); + + window.addEventListener("beforeunload", (e) => { + if (!state.presetDirty) return; + e.preventDefault(); + e.returnValue = ""; + }); +} + +async function boot() { + // 窄窗默认收起预览 / 大纲,避免一进来就被浮层盖住。 + if (window.innerWidth <= 1180) document.body.dataset.preview = "off"; + if (window.innerWidth <= 860) document.body.dataset.outline = "off"; + renderPreviewToggle(); + bindEvents(); + try { + const [presets, profiles] = await Promise.all([api("presets"), api("profiles")]); + state.presets = presets.presets || []; + state.profiles = profiles.profiles || []; + renderProfiles(); + renderPresetOptions(); + if (state.presets.length > 0) { + await loadPreset(state.presets[0].id); + } else { + renderAll(); + err("还没有预设,点「+」建一份。"); + } + } catch (e) { + err(e.message); + } +} + +boot();