新增预设工作台:编辑、版本树、结构检查、用例试跑放在同一页。

改预设要能一边写一边看装出来的 messages,还要多模型对照,所以把编辑与试跑拆成两个视图塞进一个页面;版本存完整快照、可分叉,用例全局共用一份、运行记录按预设存。顺带让读预设文件先剥 BOM,静态资源改成不缓存,免得改了脚本还要强刷。
This commit is contained in:
2026-10-26 01:09:40 +08:00
parent 98e51f9c6c
commit f3303038fe
20 changed files with 6263 additions and 6 deletions
+2
View File
@@ -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) | 技能包目录说明 |
+2
View File
@@ -1,5 +1,7 @@
# 预设格式
> 编辑、版本树、用例组与多模型试跑见 [preset-workbench.md](preset-workbench.md)。
## 1. 定位
Preset 是一次 LLM 请求的上下文编排资产。它负责描述 prompt manager 中有哪些条目、这些条目的启用状态与排列顺序,以及本次生成使用哪些模型参数。
+183
View File
@@ -0,0 +1,183 @@
# 预设工作台
本仓库自用的预设编辑 / 试跑页面:`/presets.html`。它只编辑本仓库的原生 `PresetPackage`,
不做 SillyTavern 兼容导入(导入仍在设置页);目标是把「改一版 → 看结构 → 看实际 messages →
多模型试跑 → 标记好坏」压在同一页里。
## 1. 页面分工
页面是「一条身份条 + 两个视图 + 两个抽屉」。编辑与试跑是两件不同任务:编辑要连续写,试跑要宽度做对照,
因此不挤在同一个视口里。
```text
身份条 预设选择 / 新建 / 改名 / 脏状态 / 重新载入 / 试跑入口 / 版本树入口 / 保存两档 / 应用到对话页
唯一脏状态、唯一 primary(应用到对话页)都在这里,任何视图都能看到。
保存两档:「保存至当前版本」覆盖当前节点,「保存为新版本」追加子节点。
版本 id 不上条(只做 title),要看序列去版本树。
编辑视图 左轨(240)发送顺序大纲:序号 1..N、启用开关、位置标记、错误红点;
行可直接拖动改顺序(落点画一条强调色横线),标记洞也能拖——左轨顺序就是装配顺序;
未加入顺序的模块折叠在底部;最后两栏是「生成参数」与「假历史」。
中区 当前模块正文(一次只编辑一个;标记洞只显示说明,不能改名/删除,但能移顺序)。
「假历史」中区是逐条编辑:每条一个角色下拉 + 一整句正文 + 加一句 / 删除 / 上下移。
右栏(380)实际发出的 messages:顶部是结构检查状态带 + 用例选择 + 随时重算。
改正文、切模块、切用例都会本地重算预览(不调模型)。
试跑视图 顶部运行栏一行:当前用例名 · 模型(下拉多选:勾选不关菜单,可全选可用 / 清空)
· 已选条(只占一行,超出可左右拖,点一项取消) · 「运行此用例」。
左侧用例列表(一条 = 一个用例,点开进大浮窗编辑),右侧全宽结果网格(同一用例 × 多模型对照)。
低频入口:身份条上的「试跑 · 上次结果摘要」按钮,进入后按钮变成「← 编辑」。
抽屉 版本是右侧抽屉(版本树 / 说明 / 保存 / 载入 / 分叉);
用例表单是**大浮窗**(左列 5 个字段,右列历史条目编辑器),字段多、历史要宽度,
不再挤在窄侧栏里。两者都是低频或临时的,不常驻占内容宽度。
```
编辑视图一屏不滚:大纲、正文、预览各自滚动。预览在 ≤1180px 退成右侧浮层,≤860px 大纲也退成浮层。
## 2. 草稿、版本与应用
```text
草稿 页面内存里的 state.preset;每次编辑只改这里。
重新载入 丢掉草稿,从 presets/<presetId>.json 重读;不动版本树。
保存至当前版本 覆盖当前节点(amend),不新增节点。
保存为新版本 把草稿作为子节点追加到版本树,当前节点移到新节点。
载入选中 把选中节点的完整快照载入草稿,并把当前节点切过去。
从选中分叉 用当前草稿在选中节点下建子节点,原分支不动。
应用到对话页 先保存为新版本,再写 presets/<presetId>.json;对话页从这里读。
```
版本节点保存的是**完整 PresetPackage 快照**,不是 diff;因此任意节点都可独立载入。
版本树文件:`<data>/presets/versions/<presetId>.json`。新建预设会同时落预设文件和根版本。
版本抽屉里是 git 式的树:竖线是分支,圆点是节点(实心 = 当前编辑位置),分叉另开一条线。
点任一节点弹菜单:「读取」把这个快照换进编辑器,「从它分叉」用当前草稿在它下面开新分支,
再往右是「复制 id」。底部四个按钮(保存为新版本 / 保存至当前版本 / 载入选中 / 从选中分叉)保留给键盘流。
两种保存别搞混:
```text
保存为新版本(Ctrl+S) 在当前位置下追加子节点;历史留着,能回滚。
保存至当前版本(Ctrl+Shift+S) 直接覆盖当前节点,不产生新节点。适合“这条还没定,先改它”。
应用到对话页 先保存为新版本,再覆盖 presets/<presetId>.json。对话页读的是这个文件。
```
装配只看 `prompt_order.enabled`;`prompts[].enabled` 是酒馆遗留字段。工作台的模块开关
写的是顺序开关,因此和真实发送一致。
### 2.1 左轨定死顺序
```text
左轨顺序 = 装配顺序 = 真实发送顺序。
装配(src/preset/markers.ts: applyExtendedMarkers)只补缺不重排:
缺哪个洞就按锚点插进来(历史前 → 对话历史 → 历史后 → 本轮对话 → … → 本轮之后),
已经在 prompt_order 里的条目一个不动,作者拖到哪儿就发在哪儿。
右侧预览只是展示:同一条装配路径 + 预设的「消息整形」。整形会合并消息(`strict` 默认),
条数变少时预览元信息会写「整形 strict(装配 N 条 → 发出 M 条)」;
想逐条原样发,把「生成参数 → 消息整形」改成 `none`。
```
## 3. 结构检查
不调用模型,只查会在装配 / 发送时出问题的结构:
- 顺序引用了不存在的模块、同一模块在顺序里重复、顺序号重复;
- 未启用 `chatHistory`、模块为空、全角 `{{`;
- 未闭合的 `{{`(`{{{...}}` 的首个 `{` 是字面量,不算未闭合);
- 未识别宏(会原样外发);
- 标记位置重复启用。
`error` 只阻止「应用到对话页」,不阻止保存版本 / 预览;需要时可以强制写回。
## 4. 用例
一个用例就是一条记录:自己存、自己运行。页面上不出现「组」这个层级。
**用例是全局的,不和预设绑定**:一个用例只描述「要测的输入 + 上下文」,任何预设都能拿它去跑。
盘上是 `<data>/presets/tests/global/*.json`(沿用旧 `TestCaseGroup` 形状,每份用例一个只装它的组文件);
旧的按预设分目录存的(`tests/<presetId>/*.json`)会在第一次访问时并进 global,原文件不删。
删掉最后一份用例时组文件一并收掉。
「这个预设上次打开的是哪条用例」另外存:`<data>/presets/lab-state/<presetId>.json`,
记 `selectedCaseId`(运行视图当前用例)与 `previewCaseId`(右栏预览用例)。
写盘而不是塞浏览器缓存 —— 换浏览器、重开页面都还是同一条;切回别的预设又回到它自己那条。
用例列表的点击分工:**点卡片 = 选中(设为当前用例)**,只有卡片右边的「编辑」才打开用例编辑浮窗。
一个用例只包含变动项:
```text
用例名 / 角色名({{char}}) / 角色描述 / 世界书文本 / 用户输入 / 可选历史
```
历史是**逐条编辑**的:每条一个角色下拉(`user` / `assistant`)加一整句正文,正文里的换行就是真换行,
不再用「每行 `user: 内容`」的文本行和 `\n` 转义(旧写法会把不带头部的续行吞掉)。
落盘仍是 `{role, content}[]`,装配时一条一个 message,接在预设假历史之后。
组装走线上同一条路径:`assemblePlayWorkerMessages` → `shapeFramedMessages`。
## 4.1 假历史与示例对话是两回事
```text
假历史 预设级、真多轮(user / assistant),进「对话.历史」区,排在真实历史之前。
模型会把它当成已经说过的话,会占轮次;编辑器里正文换行原样保留。
存储:PresetPackage.pseudoHistory,装配见 src/preset/pseudo-history.ts。
示例对话 酒馆 mes_example(<START> 分块),发给模型时是 system + example_user /
example_assistant,不占轮次,只做 few-shot;正文仍是整段文本,保持酒馆兼容。
```
两者互不覆盖:要「让模型以为前情已经发生」用假历史,要「示范写法但不冒充轮次」用示例对话。
三者(预设假历史 / 用例历史 / 直聊种子历史)用的是同一个控件:`web/history-editor.js` + `web/history-editor.css`。
每行左侧的 `⠿` 是拖动手柄:按住拖到另一行的上半 / 下半即插到它前面 / 后面,松手就到位;
↑↓ 按钮保留(精确微调 / 不用鼠标拖时)。
## 5. 预览与多模型运行
```text
预览 不调用模型;显示当前草稿 + 当前用例实际发出的 messages。
运行 每个 用例 × 模型 发一次;默认最多 4 路并发、单次 180 秒超时。
运行使用请求里的草稿,所以不必先保存版本。
```
运行记录:`<data>/presets/runs/<presetId>/<runId>.json`,保存 `presetVersionId`、每次运行的
`messages` 快照和每个模型的结果。结果不自动评分,人工标记成功 / 失败;标记会落盘。
历史运行可在结果卡上方切回,并展开「发送内容」核对实际请求。
## 6. HTTP 接口
前缀 `/api/preset-lab/`:
```text
GET presets 预设列表
POST presets/create 新建原生预设 + 根版本
GET preset?presetId= 读预设文件
POST preset/apply 应用到对话页({force:true} 跳过 error)
GET versions?presetId= 版本树
POST versions/commit 追加节点(缺省父节点=当前;指定历史节点=分叉)
POST versions/amend 覆盖当前节点(保存至当前版本)
POST versions/checkout 切换当前节点
POST check 结构检查
POST preview 按用例组装 messages
POST run 多模型运行(可带 preset 草稿;一次跑一个用例)
GET cases 使用例列表(全局,不看 presetId)
POST cases/save 存单份用例(带 groupId 就写回原文件)
POST cases/delete 删单份用例(空文件自动收掉)
GET lab-state?presetId= 这个预设上次打开的用例
POST lab-state 写 { presetId, selectedCaseId?, previewCaseId? }
GET groups 旧接口:测试组列表
POST groups/save 旧接口:整组覆盖
POST groups/delete 旧接口:删组
GET runs?presetId= 运行记录列表
GET run?presetId=&runId= 单次运行
POST verdict 修改人工标记
GET profiles 可选 API 配置
```
## 7. 已知边界
- 版本树不做自动合并;分叉后各分支独立。
- 运行是普通 HTTP 请求,页面会一直等到全部结果返回;没有逐条流式进度。
- 「应用到对话页」只改预设文件;要让已有会话立刻使用,去设置页点「启用」。
- 测试用例不会自动生成,也不会自动评分。
+116
View File
@@ -0,0 +1,116 @@
import { collectMacroUsages, findUnclosedMacro } from "../directives/macros.js";
import type { PresetPackage } from "../types/preset.js";
import { CHAT_HISTORY_MARKER } from "./markers.js";
/**
* 预设编辑器的结构检查(不调用模型)。
* error:会导致装配或发送异常;warn:会让预设行为与预期不符,但仍可发送。
*/
export type AuthoringIssue = {
level: "error" | "warn";
entryId: string | null;
entryName: string;
message: string;
};
const VALID_ROLES = new Set(["system", "user", "assistant"]);
const FULLWIDTH_OPEN = "{{";
export function checkAuthoringPreset(preset: PresetPackage): AuthoringIssue[] {
const issues: AuthoringIssue[] = [];
const byId = new Map<string, PresetPackage["prompts"][number]>();
const push = (
level: AuthoringIssue["level"],
entryId: string | null,
entryName: string,
message: string,
) => issues.push({ level, entryId, entryName, message });
if (preset.prompts.length === 0) {
push("error", null, "", "预设没有任何模块");
return issues;
}
for (const prompt of preset.prompts) {
if (byId.has(prompt.id)) {
push("error", prompt.id, prompt.name || prompt.id, "模块 id 重复");
continue;
}
byId.set(prompt.id, prompt);
}
const enabledOrder = preset.promptOrder.filter((o) => o.enabled);
if (!enabledOrder.some((o) => o.promptId === CHAT_HISTORY_MARKER)) {
push("warn", null, "", "没有启用「对话历史」位置,真实聊天记录不会进入请求");
}
const inOrder = new Set(preset.promptOrder.map((o) => o.promptId));
for (const prompt of preset.prompts) {
if (!inOrder.has(prompt.id)) {
push("warn", prompt.id, prompt.name, "未加入顺序,不会发送");
}
}
const seenOrder = new Set<string>();
const seenOrderIndex = new Set<number>();
const seenMarkers = new Set<string>();
for (const item of preset.promptOrder) {
if (seenOrder.has(item.promptId)) {
push("error", item.promptId, item.promptId, "顺序中重复引用了同一模块");
}
seenOrder.add(item.promptId);
if (seenOrderIndex.has(item.orderIndex)) {
push("warn", item.promptId, item.promptId, `顺序号重复:${item.orderIndex}`);
}
seenOrderIndex.add(item.orderIndex);
if (!byId.has(item.promptId)) {
push("error", item.promptId, item.promptId, "顺序中引用了不存在的模块");
}
}
for (const item of enabledOrder) {
const entry = byId.get(item.promptId);
if (!entry) continue;
const name = entry.name || entry.id;
if (entry.marker) {
if (seenMarkers.has(entry.id)) {
push("warn", entry.id, name, "位置在同一顺序中启用了多次,会重复填充");
}
seenMarkers.add(entry.id);
continue;
}
if (!VALID_ROLES.has(entry.role)) {
push("error", entry.id, name, `角色非法:${String(entry.role)}`);
}
const body = entry.content ?? "";
if (!body.trim()) {
push("warn", entry.id, name, "模块正文为空,将被跳过");
continue;
}
if (body.includes(FULLWIDTH_OPEN)) {
push(
"warn",
entry.id,
name,
"含全角 {{,运行时不会展开,请改为半角 {{",
);
}
const unclosed = findUnclosedMacro(body);
if (unclosed) {
push("error", entry.id, name, `存在未闭合的 {{ 宏:${unclosed}`);
}
for (const usage of collectMacroUsages(body)) {
if (!usage.known) {
push("warn", entry.id, name, `未识别宏 ${usage.name},将原样发出`);
}
}
}
return issues;
}
+71
View File
@@ -0,0 +1,71 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { getPresetsDir } from "../config/user-data-dir.js";
/**
* 每个预设在试跑视图里的「上次打开过啥」。
*
* 用例本身是全局的(`presets/tests/global`),但「这个预设上次用的是哪条用例」
* 得按预设记,否则切换预设时选择会跳。存盘而不是塞浏览器缓存:换浏览器、
* 重开页面都还是同一条。
*
* 文件:`<data>/presets/lab-state/<presetId>.json`
*/
export type PresetLabState = {
presetId: string;
/** 试跑视图的当前用例(`TestCase.id`) */
selectedCaseId: string | null;
/** 编辑视图右栏预览用的用例 */
previewCaseId: string | null;
updatedAt: string;
};
const SAFE_ID = /^[\w\u4e00-\u9fff-]+$/;
function stateDir(): string {
return path.join(getPresetsDir(), "lab-state");
}
function stateFile(presetId: string): string {
if (!SAFE_ID.test(presetId)) throw new Error(`非法预设 id:${presetId}`);
return path.join(stateDir(), `${presetId}.json`);
}
function emptyState(presetId: string): PresetLabState {
return { presetId, selectedCaseId: null, previewCaseId: null, updatedAt: new Date().toISOString() };
}
export function loadPresetLabState(presetId: string): PresetLabState {
const file = stateFile(presetId);
if (!existsSync(file)) return emptyState(presetId);
try {
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<PresetLabState>;
return {
presetId,
selectedCaseId: typeof parsed.selectedCaseId === "string" ? parsed.selectedCaseId : null,
previewCaseId: typeof parsed.previewCaseId === "string" ? parsed.previewCaseId : null,
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString(),
};
} catch {
return emptyState(presetId);
}
}
/** 只写传进来的字段,没传的保持原样。 */
export function savePresetLabState(
presetId: string,
patch: { selectedCaseId?: string | null; previewCaseId?: string | null },
): PresetLabState {
const current = loadPresetLabState(presetId);
const next: PresetLabState = {
...current,
selectedCaseId:
patch.selectedCaseId === undefined ? current.selectedCaseId : patch.selectedCaseId,
previewCaseId:
patch.previewCaseId === undefined ? current.previewCaseId : patch.previewCaseId,
updatedAt: new Date().toISOString(),
};
mkdirSync(stateDir(), { recursive: true });
writeFileSync(stateFile(presetId), JSON.stringify(next, null, 2), "utf8");
return next;
}
+108
View File
@@ -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(),
};
}
+7 -2
View File
@@ -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;
}
+209
View File
@@ -0,0 +1,209 @@
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { getPresetsDir } from "../config/user-data-dir.js";
/**
* 测试用例:**全局,不和预设绑定**——一个用例只描述「要测的输入 + 上下文」,
* 任何预设都能拿它去跑。存在 `<data>/presets/tests/global/` 下,每组一个 JSON 文件
* (沿用 TestCaseGroup 形状,旧的按预设分目录的文件会在第一次访问时并进来)。
*
* 每个用例只包含变动项:角色名与描述({{char}})、世界书、用户输入、可选历史。
*/
export type TestCaseHistoryItem = { role: "user" | "assistant"; content: string };
export type TestCase = {
id: string;
name: string;
charName: string;
charDescription: string;
worldBook: string;
userInput: string;
history: TestCaseHistoryItem[];
};
export type TestCaseGroup = {
id: string;
/** 历史字段:现在恒为 GLOBAL_CASES_ID,留着只是为了旧文件能直接读。 */
presetId: string;
name: string;
updatedAt: string;
cases: TestCase[];
};
export const GLOBAL_CASES_ID = "global";
const SAFE_ID = /^[\w\u4e00-\u9fff-]+$/;
function assertSafe(id: string, label: string): void {
if (!SAFE_ID.test(id)) throw new Error(`非法${label}:${id}`);
}
function casesRoot(): string {
return path.join(getPresetsDir(), "tests");
}
function globalDir(): string {
return path.join(casesRoot(), GLOBAL_CASES_ID);
}
function groupFile(groupId: string): string {
assertSafe(groupId, "用例 id");
return path.join(globalDir(), `${groupId}.json`);
}
/**
* 老版本把用例按预设分目录存(`tests/<presetId>/*.json`)。全局目录第一次被用到时,
* 把那些文件并进来(id 冲突就跳过),原文件不删,方便回退。
*/
function ensureGlobalDir(): string {
const dir = globalDir();
if (existsSync(dir)) return dir;
mkdirSync(dir, { recursive: true });
const root = casesRoot();
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === GLOBAL_CASES_ID) continue;
const legacyDir = path.join(root, entry.name);
for (const file of readdirSync(legacyDir).filter((name) => name.endsWith(".json"))) {
const target = path.join(dir, file);
if (existsSync(target)) continue;
try {
const group = JSON.parse(readFileSync(path.join(legacyDir, file), "utf8")) as TestCaseGroup;
writeFileSync(
target,
JSON.stringify({ ...group, presetId: GLOBAL_CASES_ID }, null, 2),
"utf8",
);
} catch {
/* 坏文件跳过 */
}
}
}
return dir;
}
export function listTestGroups(): TestCaseGroup[] {
const dir = ensureGlobalDir();
const groups: TestCaseGroup[] = [];
for (const file of readdirSync(dir).filter((name) => name.endsWith(".json"))) {
try {
groups.push(JSON.parse(readFileSync(path.join(dir, file), "utf8")) as TestCaseGroup);
} catch {
/* skip corrupt */
}
}
return groups.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export function getTestGroup(groupId: string): TestCaseGroup | null {
try {
return JSON.parse(readFileSync(groupFile(groupId), "utf8")) as TestCaseGroup;
} catch {
return null;
}
}
/** 新建或覆盖一组。cases 中缺 id 的会分配 id。 */
export function saveTestGroup(input: {
id?: string;
name: string;
cases: Array<Partial<TestCase>>;
}): TestCaseGroup {
const id = input.id?.trim() || randomUUID().slice(0, 8);
const text = (value: unknown): string => (typeof value === "string" ? value : "");
const cases: TestCase[] = input.cases.map((c) => ({
id: text(c.id).trim() || randomUUID().slice(0, 8),
name: text(c.name).trim() || "未命名用例",
charName: text(c.charName).trim(),
charDescription: text(c.charDescription),
worldBook: text(c.worldBook),
userInput: text(c.userInput),
history: (Array.isArray(c.history) ? c.history : []).filter(
(h) => (h.role === "user" || h.role === "assistant") && typeof h.content === "string",
),
}));
const group: TestCaseGroup = {
id,
presetId: GLOBAL_CASES_ID,
name: input.name.trim() || "未命名测试组",
updatedAt: new Date().toISOString(),
cases,
};
ensureGlobalDir();
writeFileSync(groupFile(id), JSON.stringify(group, null, 2), "utf8");
return group;
}
export function deleteTestGroup(groupId: string): void {
try {
unlinkSync(groupFile(groupId));
} catch {
/* ignore */
}
}
/* ---------- 用例(工作台的展示单位) ----------
* 盘上仍是一组一个文件,但界面上不出现「组」:每份用例都是一条独立记录,
* 自己存、自己运行。新建用例会为它开一个只装一份用例的组文件。
*/
export type TestCaseRef = { groupId: string; case: TestCase };
/** 平铺所有用例(按组更新时间排序,组内保持原顺序)。 */
export function listTestCases(): TestCaseRef[] {
const out: TestCaseRef[] = [];
for (const group of listTestGroups()) {
for (const item of group.cases) out.push({ groupId: group.id, case: item });
}
return out;
}
/**
* 保存单份用例:给了 groupId 就写回那个组(组内其他用例不动),
* 没给就新建一个只装它的组。
*/
export function saveTestCase(input: {
groupId?: string;
case: Partial<TestCase>;
}): TestCaseRef {
const groupId = input.groupId?.trim();
if (!groupId) {
const name = typeof input.case.name === "string" ? input.case.name.trim() : "";
const group = saveTestGroup({
name: name || "未命名用例",
cases: [input.case],
});
return { groupId: group.id, case: group.cases[0]! };
}
const group = getTestGroup(groupId);
if (!group) throw new Error(`用例不存在:${groupId}`);
const caseId = typeof input.case.id === "string" ? input.case.id.trim() : "";
const index = group.cases.findIndex((item) => item.id === caseId);
const cases =
index >= 0
? group.cases.map((item, i) => (i === index ? { ...item, ...input.case } : item))
: [...group.cases, input.case];
// 只剩一份用例时,组名跟着用例名走,盘上读起来不至于两个名字打架。
const single = cases.length === 1;
const caseName = typeof input.case.name === "string" ? input.case.name.trim() : "";
const saved = saveTestGroup({
id: groupId,
name: single && caseName ? caseName : group.name,
cases,
});
const wanted = index >= 0 ? caseId : saved.cases[saved.cases.length - 1]!.id;
return {
groupId,
case: saved.cases.find((item) => item.id === wanted) ?? saved.cases[0]!,
};
}
/** 删除单份用例;组里没别的人就把组文件一起删掉。 */
export function deleteTestCase(groupId: string, caseId: string): void {
const group = getTestGroup(groupId);
if (!group) return;
const cases = group.cases.filter((item) => item.id !== caseId);
if (cases.length === 0) deleteTestGroup(groupId);
else saveTestGroup({ id: groupId, name: group.name, cases });
}
+224
View File
@@ -0,0 +1,224 @@
import { randomUUID } from "node:crypto";
import { getApiProfile, listApiProfiles, profileToLlmConfig } from "../config/api-profiles.js";
import { OpenAiCompatibleProvider, type ChatMessage, type TokenUsage } from "../llm/client.js";
import { assemblePlayWorkerMessages, shapeFramedMessages } from "./play-frame.js";
import { resolveSquashForPreset } from "../llm/preset-wrapper.js";
import { checkAuthoringPreset, type AuthoringIssue } from "./authoring-check.js";
import type { PresetPackage } from "../types/preset.js";
import type { MessageSquashMode } from "../types/preset.js";
import type { TestCase } from "./test-cases.js";
import type { WorldInfoPack } from "./world-info-pack.js";
/**
* 按真实发送路径组装一个用例的 messages(不调用模型)。
* 与线上一致:预设 → assemblePlayWorkerMessages → 消息整形。
* 变动的只有 char 描述、世界书、用户输入、历史。
*/
export function buildCaseMessages(preset: PresetPackage, testCase: TestCase): ChatMessage[] {
return shapeFramedMessages(
assembleCaseMessages(preset, testCase),
resolveSquashForPreset(preset, "follow-preset"),
);
}
/**
* 工作台右侧预览:同一组装结果,多告诉页面两件事——
* 本次用的整形模式、整形前的条数。左轨是真实顺序,整形后条数变少要能看出来。
*/
export function buildCasePreview(
preset: PresetPackage,
testCase: TestCase,
): { messages: ChatMessage[]; squash: MessageSquashMode; assembledCount: number } {
const framed = assembleCaseMessages(preset, testCase);
const squash = resolveSquashForPreset(preset, "follow-preset");
return {
messages: shapeFramedMessages(framed, squash),
squash,
assembledCount: framed.length,
};
}
/** 装配那一步本身(右栏与试跑共用,整形在调用方决定)。 */
function assembleCaseMessages(
preset: PresetPackage,
testCase: TestCase,
): ChatMessage[] {
const asText = (value: unknown): string => (typeof value === "string" ? value : "");
const historyInput = (Array.isArray(testCase.history) ? testCase.history : []).filter(
(item) => item && typeof item === "object",
);
const history: ChatMessage[] = historyInput.map((h) => ({
role: h.role === "assistant" ? "assistant" : "user",
content: typeof h.content === "string" ? h.content : "",
}));
const worldBook = asText(testCase.worldBook);
const charDescription = asText(testCase.charDescription);
const pack: WorldInfoPack = {
worldBookBefore: worldBook.trim()
? [{ id: "test-world", name: "世界书", content: worldBook }]
: [],
history,
worldBookAfter: [],
turn: asText(testCase.userInput),
postTurn: [],
card: charDescription.trim() ? { description: charDescription } : undefined,
};
const framed = assemblePlayWorkerMessages({
systemPrompt: "",
preset,
pack,
persona: null,
charName: asText(testCase.charName) || null,
});
return framed;
}
export type RunStatus = "running" | "done" | "error";
export type CaseRunResult = {
profileId: string;
profileName: string;
model: string;
caseId: string;
caseName?: string;
status: RunStatus;
content?: string;
error?: string;
usage?: TokenUsage;
durationMs?: number;
/** 手动标记:null 表示未标记 */
verdict: "success" | "failure" | null;
startedAt: string;
finishedAt?: string;
};
export type TestRun = {
id: string;
presetId: string;
presetVersionId: string;
groupId: string;
createdAt: string;
/** 本次运行时的消息快照(每个用例一份),便于事后核对发送内容 */
requests: Array<{ caseId: string; messages: ChatMessage[] }>;
results: CaseRunResult[];
issues: AuthoringIssue[];
};
export const TEST_RUN_TIMEOUT_MS = 180_000;
const DEFAULT_CONCURRENCY = 4;
/** 小并发池:多模型 × 多用例时不要一次全打出去。 */
async function runPool(jobs: Array<() => Promise<void>>, limit: number): Promise<void> {
const queue = [...jobs];
const width = Math.max(1, Math.min(limit, queue.length));
const workers = Array.from({ length: width }, async () => {
while (queue.length > 0) {
const job = queue.shift();
if (job) await job();
}
});
await Promise.all(workers);
}
/**
* 对一组用例、多个 API 配置并行运行。
* 同一用例的同一配置只发一次;各配置之间并行。
*/
export async function runTestGroup(input: {
preset: PresetPackage;
presetVersionId: string;
presetId: string;
groupId: string;
cases: TestCase[];
profileIds: string[];
timeoutMs?: number;
concurrency?: number;
}): Promise<TestRun> {
const profileIds = [...new Set(input.profileIds.map((x) => x.trim()).filter(Boolean))];
if (profileIds.length === 0) throw new Error("请至少选择一个 API 配置");
if (input.cases.length === 0) throw new Error("测试组中没有用例");
const profiles = profileIds.map((id) => {
const profile = getApiProfile(id);
if (!profile?.apiKey?.trim()) throw new Error(`API 配置不可用:${id}`);
return profile;
});
const requests = input.cases.map((c) => ({ caseId: c.id, messages: buildCaseMessages(input.preset, c) }));
const run: TestRun = {
id: randomUUID().slice(0, 8),
presetId: input.presetId,
presetVersionId: input.presetVersionId,
groupId: input.groupId,
createdAt: new Date().toISOString(),
requests,
results: [],
issues: checkAuthoringPreset(input.preset),
};
const timeoutMs = Math.max(1_000, input.timeoutMs ?? TEST_RUN_TIMEOUT_MS);
const jobs: Array<() => Promise<void>> = [];
for (const profile of profiles) {
for (const req of requests) {
const testCase = input.cases.find((c) => c.id === req.caseId);
const slot: CaseRunResult = {
profileId: profile.id,
profileName: profile.name,
model: profile.model,
caseId: req.caseId,
caseName: testCase?.name,
status: "running",
verdict: null,
startedAt: new Date().toISOString(),
};
run.results.push(slot);
jobs.push(async () => {
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const llm = new OpenAiCompatibleProvider(profileToLlmConfig(profile));
const result = await llm.complete(req.messages, {
generation: input.preset.generation,
caller: `preset-test:${profile.id}`,
signal: controller.signal,
});
slot.content = result.content ?? "";
if (result.model) slot.model = result.model;
if (result.usage) slot.usage = result.usage;
slot.status = "done";
} catch (err) {
slot.status = "error";
if (controller.signal.aborted) {
slot.error = `超时:${Math.round(timeoutMs / 1000)} 秒内没有完成`;
} else {
slot.error = err instanceof Error ? err.message : String(err);
}
} finally {
clearTimeout(timer);
slot.durationMs = Date.now() - started;
slot.finishedAt = new Date().toISOString();
}
});
}
}
await runPool(jobs, input.concurrency ?? DEFAULT_CONCURRENCY);
return run;
}
export function listRunnableProfiles(): Array<{ id: string; name: string; model: string; hasKey: boolean }> {
return listApiProfiles().map((p) => ({
id: p.id,
name: p.name,
model: p.model,
hasKey: Boolean(p.apiKey?.trim()),
}));
}
/** 手动标记结果:只保存标签,不做自动评分。 */
export function setVerdict(run: TestRun, profileId: string, caseId: string, verdict: "success" | "failure" | null): TestRun {
const slot = run.results.find((r) => r.profileId === profileId && r.caseId === caseId);
if (!slot) throw new Error("结果不存在");
slot.verdict = verdict;
return run;
}
+61
View File
@@ -0,0 +1,61 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { getPresetsDir } from "../config/user-data-dir.js";
import type { TestRun } from "./test-run.js";
/** 运行记录:presets/runs/<presetId>/<runId>.json,保存预设版本 id 以便追溯。 */
const SAFE_ID = /^[\w\u4e00-\u9fff-]+$/;
function runDir(presetId: string): string {
if (!SAFE_ID.test(presetId)) throw new Error(`非法预设 id:${presetId}`);
return path.join(getPresetsDir(), "runs", presetId);
}
function runFile(presetId: string, runId: string): string {
if (!SAFE_ID.test(runId)) throw new Error(`非法运行 id:${runId}`);
return path.join(runDir(presetId), `${runId}.json`);
}
export function saveTestRun(run: TestRun): void {
mkdirSync(runDir(run.presetId), { recursive: true });
writeFileSync(runFile(run.presetId, run.id), JSON.stringify(run, null, 2), "utf8");
}
export function getTestRun(presetId: string, runId: string): TestRun | null {
try {
return JSON.parse(readFileSync(runFile(presetId, runId), "utf8")) as TestRun;
} catch {
return null;
}
}
export function listTestRuns(presetId: string): TestRun[] {
const dir = runDir(presetId);
if (!existsSync(dir)) return [];
const out: TestRun[] = [];
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json"))) {
try {
out.push(JSON.parse(readFileSync(path.join(dir, file), "utf8")) as TestRun);
} catch {
/* skip */
}
}
return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
/** 修改某个结果的手动标记并落盘。 */
export function updateVerdict(
presetId: string,
runId: string,
profileId: string,
caseId: string,
verdict: "success" | "failure" | null,
): TestRun {
const run = getTestRun(presetId, runId);
if (!run) throw new Error("运行记录不存在");
const slot = run.results.find((r) => r.profileId === profileId && r.caseId === caseId);
if (!slot) throw new Error("结果不存在");
slot.verdict = verdict;
saveTestRun(run);
return run;
}
+150
View File
@@ -0,0 +1,150 @@
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { getPresetsDir } from "../config/user-data-dir.js";
import type { PresetPackage } from "../types/preset.js";
/**
* 预设版本树:每个预设一个 JSON 文件,节点是完整快照。
* - 保存:在指定父节点(缺省为当前节点)下追加子节点;
* - 分叉:父节点选历史中的任意一个,形成新分支,原分支不动;
* - 追溯:沿 parentId 往上走即为修改历史。
* 不依赖 git,不做远程同步。
*/
export type PresetVersionNode = {
id: string;
parentId: string | null;
createdAt: string;
/** 被「保存至当前版本」覆盖过的时间;没改过就不写 */
updatedAt?: string;
note: string;
preset: PresetPackage;
};
export type PresetVersionTree = {
presetId: string;
currentId: string | null;
nodes: PresetVersionNode[];
};
const SAFE_ID = /^[\w\u4e00-\u9fff-]+$/;
function versionsDir(): string {
return path.join(getPresetsDir(), "versions");
}
function treeFile(presetId: string): string {
if (!SAFE_ID.test(presetId)) throw new Error(`非法预设 id:${presetId}`);
return path.join(versionsDir(), `${presetId}.json`);
}
function writeTree(tree: PresetVersionTree): void {
mkdirSync(versionsDir(), { recursive: true });
writeFileSync(treeFile(tree.presetId), JSON.stringify(tree, null, 2), "utf8");
}
export function loadVersionTree(presetId: string): PresetVersionTree {
const file = treeFile(presetId);
if (!existsSync(file)) return { presetId, currentId: null, nodes: [] };
return JSON.parse(readFileSync(file, "utf8")) as PresetVersionTree;
}
export function getVersionNode(
presetId: string,
nodeId: string,
): PresetVersionNode | null {
return loadVersionTree(presetId).nodes.find((n) => n.id === nodeId) ?? null;
}
/** 从根到指定节点的路径(含两端),即该版本的修改历史。 */
export function versionLineage(
tree: PresetVersionTree,
nodeId: string,
): PresetVersionNode[] {
const byId = new Map(tree.nodes.map((n) => [n.id, n]));
const out: PresetVersionNode[] = [];
let cursor = byId.get(nodeId) ?? null;
const guard = new Set<string>();
while (cursor && !guard.has(cursor.id)) {
guard.add(cursor.id);
out.unshift(cursor);
cursor = cursor.parentId ? byId.get(cursor.parentId) ?? null : null;
}
return out;
}
/**
* 提交一个版本。
* parentId 缺省为当前节点;显式传入历史节点即为分叉。
* 树为空时 parentId 必须为 null(建根);已有根时不允许再建第二个根。
*/
export function commitVersion(input: {
presetId: string;
preset: PresetPackage;
note?: string;
parentId?: string | null;
}): PresetVersionTree {
const tree = loadVersionTree(input.presetId);
const parentId =
input.parentId !== undefined ? input.parentId : tree.currentId;
if (parentId === null) {
if (tree.nodes.length > 0) throw new Error("已有版本,不能再新建根版本");
} else if (!tree.nodes.some((n) => n.id === parentId)) {
throw new Error(`父版本不存在:${parentId}`);
}
let id = randomUUID().slice(0, 8);
while (tree.nodes.some((n) => n.id === id)) id = randomUUID().slice(0, 8);
const node: PresetVersionNode = {
id,
parentId,
createdAt: new Date().toISOString(),
note: (input.note ?? "").trim(),
preset: structuredClone(input.preset),
};
tree.nodes.push(node);
tree.currentId = node.id;
writeTree(tree);
return tree;
}
/**
* 保存到当前版本(amend):把快照直接换掉 `currentId` 那个节点,不产生新节点。
* 还没有任何版本时退化成建根。
*/
export function amendCurrentVersion(input: {
presetId: string;
preset: PresetPackage;
note?: string;
}): PresetVersionTree {
const tree = loadVersionTree(input.presetId);
if (!tree.currentId) return commitVersion({ ...input, parentId: null });
const node = tree.nodes.find((item) => item.id === tree.currentId);
if (!node) throw new Error(`当前版本不存在:${tree.currentId}`);
node.preset = structuredClone(input.preset);
const note = (input.note ?? "").trim();
if (note) node.note = note;
node.updatedAt = new Date().toISOString();
writeTree(tree);
return tree;
}
/** 切换当前编辑位置(不产生新节点)。 */
export function checkoutVersion(presetId: string, nodeId: string): PresetVersionTree {
const tree = loadVersionTree(presetId);
if (!tree.nodes.some((n) => n.id === nodeId)) {
throw new Error(`版本不存在:${nodeId}`);
}
tree.currentId = nodeId;
writeTree(tree);
return tree;
}
export function deleteVersionTree(presetId: string): void {
try {
rmSync(treeFile(presetId), { force: true });
} catch {
/* ignore */
}
}
+405
View File
@@ -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<string, unknown>): 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<string> {
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<Record<string, unknown>> {
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<string, unknown>;
}
function str(body: Record<string, unknown>, key: string): string {
const value = body[key];
return typeof value === "string" ? value : "";
}
function strList(body: Record<string, unknown>, 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<boolean> {
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<Partial<TestCase>>) : [];
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<TestCase>,
});
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;
}
}
+29 -4
View File
@@ -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<void>
const headers: Record<string, string> = {
"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;
}
+105
View File
@@ -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);
});
});
+146
View File
@@ -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<Captured> {
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");
});
});
+221
View File
@@ -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<typeof import("../src/llm/client.js")>();
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<Captured> {
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");
});
});
+177
View File
@@ -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");
});
});
+1410
View File
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>预设工作台 · Writing Agent</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Serif+SC:wght@500;600;700&display=swap"
rel="stylesheet"
/>
<script src="/theme-init.js"></script>
<link rel="stylesheet" href="/settings.css" />
<link rel="stylesheet" href="/history-editor.css" />
<link rel="stylesheet" href="/presets.css" />
</head>
<body class="settings-page px-page" data-preview="on" data-outline="off">
<header class="st-top">
<a class="st-brand" href="/">Writing Agent</a>
<nav class="st-nav" aria-label="主导航">
<a href="/">对话</a>
<a href="/chat.html">直聊</a>
<a href="/modules.html">技能</a>
<a href="/libraries.html">库</a>
<a href="/stats.html">Token</a>
<a href="/presets.html" aria-current="page">预设</a>
<a href="/settings.html">设置</a>
</nav>
</header>
<!-- 预设身份条:唯一脏状态 + 唯一主行动 -->
<div class="px-idbar">
<div class="px-id-main">
<select id="preset-select" class="px-select" aria-label="选择预设"></select>
<button type="button" id="btn-new-preset" class="px-btn px-btn-mini" title="新建预设">+</button>
<input
id="preset-name"
class="px-input px-name"
type="text"
aria-label="预设名称"
placeholder="预设名称"
/>
<span class="px-idmeta" id="preset-meta"></span>
<span class="px-dirty" id="dirty-pill" data-dirty="0">已同步</span>
</div>
<div class="px-id-actions">
<span class="px-notice" id="notice" aria-live="polite"></span>
<button type="button" class="px-btn" id="btn-reload" title="从磁盘重读预设文件,未保存的草稿会丢">重新载入</button>
<button type="button" class="px-btn" id="btn-run-entry">
<span class="px-btn-label">试跑</span><span class="px-btn-meta" id="run-entry-meta"></span>
</button>
<button type="button" class="px-btn" id="btn-versions" title="打开版本树">版本树</button>
<button type="button" class="px-btn" id="btn-save-current" title="覆盖当前版本节点,不新建(Ctrl+Shift+S)">
保存至当前版本
</button>
<button type="button" class="px-btn" id="btn-save-new" title="在当前节点下追加一个新版本(Ctrl+S)">
保存为新版本
</button>
<button type="button" class="px-btn px-btn-primary" id="btn-apply" title="先保存为新版本,再覆盖预设文件——对话页读的是那个文件">应用到对话页</button>
</div>
</div>
<div class="px-alert" id="alert" hidden></div>
<!-- 视图 A:编辑 -->
<div class="px-view px-view-edit" id="view-edit">
<nav class="px-outline" id="outline" aria-label="发送顺序"></nav>
<main class="px-editor" id="editor"></main>
<aside class="px-preview" id="preview-pane" aria-label="实际发送内容">
<div class="px-preview-head">
<select id="preview-case" class="px-select" aria-label="预览用例"></select>
<button type="button" class="px-btn px-btn-mini" id="btn-preview" title="Ctrl+Enter">重算</button>
<button type="button" class="px-btn px-btn-mini" id="btn-toggle-preview" title="显示 / 收起预览">
收起
</button>
</div>
<div class="px-band" id="issues-band" data-level="ok"></div>
<p class="px-preview-meta" id="preview-meta"></p>
<div class="px-messages" id="preview-out"></div>
</aside>
</div>
<!-- 视图 B:试跑 -->
<div class="px-view px-view-run" id="view-run" hidden>
<div class="px-runbar">
<div class="px-runbar-row">
<span class="px-label">当前用例</span>
<span class="px-runbar-case" id="run-case-label">先在左侧选一个用例</span>
<span class="px-runbar-sep" aria-hidden="true"></span>
<span class="px-label">模型</span>
<div class="px-picker">
<button
type="button"
class="px-btn px-btn-mini px-picker-trigger"
id="model-picker-btn"
aria-haspopup="listbox"
aria-expanded="false"
>
选择模型<span class="px-picker-count" id="model-picker-count"></span>
</button>
<div
class="px-picker-menu"
id="model-picker-menu"
role="listbox"
aria-multiselectable="true"
hidden
></div>
</div>
<div class="px-picked" id="picked-models" aria-label="已选模型"></div>
<button type="button" class="px-btn px-btn-primary px-runbar-run" id="btn-run">运行此用例</button>
</div>
</div>
<nav class="px-caselist" id="cases" aria-label="用例" title="用例全局共用:任何预设都能拿这些用例去跑"></nav>
<main class="px-results-pane">
<div class="px-results-head">
<select id="run-select" class="px-select" aria-label="历史运行"></select>
<button type="button" class="px-btn px-btn-mini" id="btn-refresh-runs">刷新记录</button>
<p class="px-hint px-results-hint">不自动评分,逐条标记成功 / 失败;标记会落盘。</p>
</div>
<div class="px-results" id="results"></div>
</main>
</div>
<!-- 版本抽屉(低频,从身份条进入) -->
<div class="px-scrim" id="scrim" hidden></div>
<aside class="px-drawer" id="version-drawer" hidden aria-label="版本">
<div class="px-drawer-head">
<h2>版本</h2>
<button type="button" class="px-btn px-btn-mini" id="btn-close-versions">关闭</button>
</div>
<div class="px-drawer-body">
<div id="version-tree" role="tree"></div>
<p class="px-hint" id="version-hint">还没有版本。</p>
<label class="px-field">
<span>本次修改说明</span>
<input type="text" id="note-input" placeholder="例:收紧叙事视角" />
</label>
<div class="px-row">
<button type="button" class="px-btn px-btn-primary" id="btn-save-version">保存为新版本</button>
<button type="button" class="px-btn" id="btn-save-version-current">保存至当前版本</button>
</div>
<div class="px-row">
<button type="button" class="px-btn" id="btn-load-version">载入选中</button>
<button type="button" class="px-btn" id="btn-fork-version">从选中分叉</button>
</div>
<p class="px-hint" id="dirty-hint"></p>
<p class="px-hint">
点节点弹菜单:「读取」换进编辑器,「从它分叉」用当前草稿在它下面开新分支。
圆点是当前编辑位置,分叉会另开一条线。
</p>
<p class="px-hint">
「保存为新版本」(Ctrl+S)在当前节点下追加子节点;「保存至当前版本」直接覆盖当前节点。
「应用到对话页」会先保存为新版本,再覆盖 <code>presets/&lt;id&gt;.json</code>——对话页读的是那个文件。
「重新载入」从磁盘重读预设文件,未保存的草稿会丢。
</p>
</div>
</aside>
<!-- 用例浮窗(字段多、还要写历史,用大浮窗而不是窄侧栏) -->
<section
class="px-modal"
id="case-modal"
role="dialog"
aria-modal="true"
aria-labelledby="case-drawer-title"
hidden
>
<div class="px-modal-head">
<h2 id="case-drawer-title">用例</h2>
<button type="button" class="px-btn px-btn-mini" id="btn-close-case">完成</button>
</div>
<div class="px-modal-body" id="case-form"></div>
</section>
<script type="module" src="/presets.js"></script>
</body>
</html>
+2455
View File
File diff suppressed because it is too large Load Diff