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

改预设要能一边写一边看装出来的 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
+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 */
}
}