Files
writing-agent/web/presets.js
chenmoranzhi f3303038fe 新增预设工作台:编辑、版本树、结构检查、用例试跑放在同一页。
改预设要能一边写一边看装出来的 messages,还要多模型对照,所以把编辑与试跑拆成两个视图塞进一个页面;版本存完整快照、可分叉,用例全局共用一份、运行记录按预设存。顺带让读预设文件先剥 BOM,静态资源改成不缓存,免得改了脚本还要强刷。
2026-10-26 01:09:40 +08:00

2456 lines
78 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const BASE = "/api/preset-lab/";
import { createHistoryEditor } from "/history-editor.js";
async function api(path, body) {
const res = await fetch(BASE + path, body === undefined ? undefined : {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { error: text.slice(0, 200) };
}
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
return data;
}
const GENERATION = "__generation__";
/** 预设级「假历史」:不是模块,不占 prompt_order,固定进历史区。 */
const PSEUDO_HISTORY = "__pseudo_history__";
const state = {
presets: [],
preset: null,
presetId: "",
presetDirty: false,
/** 大纲里选中的对象:模块 id 或 GENERATION */
selected: null,
tree: null,
selectedVersionId: null,
/** 用例(每条就是一份 CompleteCase,带 groupId 指向盘上文件):组只是容器,界面不展示 */
cases: [],
activeCaseIndex: -1,
previewCaseId: "",
messages: [],
/** 右侧预览的整形情况:`squash` 模式 + 整形前条数(左轨顺序 = 装配顺序,整形会改条数)。 */
previewShape: { squash: null, assembledCount: 0 },
issues: [],
issuesOpen: false,
profiles: [],
selectedProfileIds: new Set(),
runs: [],
run: null,
view: "edit",
drawer: null,
/** 用户手动开合过预览 / 大纲后,resize 不再自动改它的开合 */
previewTouched: false,
outlineTouched: false,
};
const $ = (id) => document.getElementById(id);
function el(tag, attrs = {}, children = []) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (value === null || value === undefined || value === false) continue;
if (key === "text") node.textContent = value;
else if (key === "html") node.innerHTML = value;
else if (key === "class") node.className = value;
else if (key.startsWith("on")) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value === true ? "" : value);
}
for (const child of children) {
if (child === null || child === undefined || child === false) continue;
node.append(typeof child === "string" ? document.createTextNode(child) : child);
}
return node;
}
/* ---------- 全局提示 ---------- */
function err(text) {
const host = $("alert");
if (!host) return;
host.hidden = false;
host.replaceChildren(
el("span", { class: "px-alert-text", text: String(text) }),
el("button", {
type: "button",
class: "px-btn px-btn-mini",
text: "关闭",
onclick: () => {
host.hidden = true;
host.replaceChildren();
},
}),
);
}
let noticeTimer = null;
function notice(text) {
const host = $("notice");
if (!host) return;
host.textContent = text || "";
// 成功提示顺带收掉上一次的错误条,避免旧错误一直挂着。
const alertBox = $("alert");
if (alertBox) {
alertBox.hidden = true;
alertBox.replaceChildren();
}
window.clearTimeout(noticeTimer);
if (text) noticeTimer = window.setTimeout(() => { host.textContent = ""; }, 4000);
}
function setBusy(button, busy, text) {
if (!button) return;
if (busy) {
button.dataset.label = button.textContent;
button.disabled = true;
button.textContent = text;
} else {
button.disabled = false;
if (button.dataset.label) button.textContent = button.dataset.label;
}
}
function confirmDiscard() {
return !state.presetDirty || window.confirm("当前草稿还没保存版本,继续会丢掉这些修改。继续?");
}
/* ---------- 脏状态 ---------- */
function updateDirtyHint() {
const pill = $("dirty-pill");
if (pill) {
pill.dataset.dirty = state.presetDirty ? "1" : "0";
pill.textContent = state.presetDirty ? "未保存" : "已同步";
}
const hint = $("dirty-hint");
if (hint) {
hint.textContent = state.presetDirty
? "草稿有未保存的修改 · 「保存至当前版本」覆盖当前节点,「保存为新版本」追加子节点"
: "草稿与当前版本一致";
}
}
function markDirty() {
state.presetDirty = true;
updateDirtyHint();
scheduleRefresh();
}
/* ---------- 实时预览(本地组装,不调模型) ---------- */
let refreshTimer = null;
function scheduleRefresh(delay = 450) {
window.clearTimeout(refreshTimer);
refreshTimer = window.setTimeout(() => { void refreshAll(); }, delay);
}
async function refreshAll() {
if (!state.preset) return;
window.clearTimeout(refreshTimer);
await Promise.all([runCheck(), runPreview()]);
}
/* ---------- 预设列表与元信息 ---------- */
function renderPresetOptions() {
const select = $("preset-select");
select.replaceChildren();
if (state.presets.length === 0) {
select.append(el("option", { value: "", text: "(无预设)" }));
return;
}
for (const preset of state.presets) {
select.append(el("option", {
value: preset.id,
text: `${preset.name}(${preset.promptCount})`,
}));
}
if (state.presetId) select.value = state.presetId;
}
async function refreshPresetList(selectedId) {
const data = await api("presets");
state.presets = data.presets || [];
if (selectedId) state.presetId = selectedId;
renderPresetOptions();
}
function renderIdbar() {
const preset = state.preset;
$("preset-name").disabled = !preset;
$("preset-select").disabled = !preset;
$("preset-name").value = preset ? preset.name || "" : "";
if (preset) {
const option = [...$("preset-select").options].find((item) => item.value === preset.id);
if (option) option.textContent = `${preset.name || preset.id}(${preset.prompts.length})`;
if ($("preset-select").value !== preset.id) $("preset-select").value = preset.id;
const enabled = preset.promptOrder.filter((item) => item.enabled).length;
const source = preset.source === "sillytavern" ? "SillyTavern 导入" : "原生";
$("preset-meta").textContent =
`${preset.id} · ${source} · ${preset.prompts.length} 模块 / 启用 ${enabled}`;
} else {
$("preset-meta").textContent = "";
}
updateVersionsButton();
renderRunEntry();
updateDirtyHint();
}
/** 版本 id 不占位了:只在「版本树」按钮的 title 里露。 */
function updateVersionsButton() {
const button = $("btn-versions");
if (!button) return;
const current = state.tree?.currentId;
button.title = current ? `打开版本树(当前 ${current})` : "打开版本树";
}
/* ---------- 模块模型 ---------- */
/** 洞(marker):运行时填内容,但位置就是左轨顺序,随便拖。 */
const MARKER_HINTS = {
worldBookBefore: "历史前的世界书 / 任务契约",
worldInfoBefore: "酒馆世界书位置(历史前)",
chatHistory: "展开成真多轮消息",
worldBookAfter: "历史后、本轮前的插入",
worldInfoAfter: "酒馆世界书位置(历史后)",
currentTurn: "用户本轮输入",
postTurn: "本轮之后的插入",
personaDescription: "用户人设正文",
charDescription: "角色描述",
charPersonality: "角色性格",
scenario: "场景",
dialogueExamples: "示例对话",
};
function orderedModules(preset) {
const byId = new Map(preset.prompts.map((p) => [p.id, p]));
const order = preset.promptOrder.slice().sort((a, b) => a.orderIndex - b.orderIndex);
const seen = new Set();
const rows = [];
for (const item of order) {
const prompt = byId.get(item.promptId);
if (!prompt) continue;
seen.add(prompt.id);
rows.push({ prompt, item });
}
for (const prompt of preset.prompts) {
if (seen.has(prompt.id)) continue;
rows.push({ prompt, item: null });
}
return rows;
}
function findModule(promptId) {
const preset = state.preset;
if (!preset || !promptId) return null;
const rows = orderedModules(preset);
const row = rows.find((entry) => entry.prompt.id === promptId) || null;
if (!row) return null;
const orderedRows = rows.filter((entry) => entry.item);
return {
prompt: row.prompt,
item: row.item,
orderIndex: orderedRows.indexOf(row),
orderedCount: orderedRows.length,
};
}
function firstSelectable() {
if (!state.preset) return GENERATION;
const rows = orderedModules(state.preset);
const first = rows.find((row) => row.item && row.item.enabled) || rows[0];
return first ? first.prompt.id : GENERATION;
}
function moduleEnabled(prompt, item) {
// 装配只看 prompt_order.enabled;prompts[].enabled 是酒馆遗留字段。
return Boolean(item && item.enabled);
}
/* ---------- 大纲 ---------- */
function renderOutline() {
const host = $("outline");
host.replaceChildren();
if (!state.preset) return;
const rows = orderedModules(state.preset);
const ordered = rows.filter((row) => row.item);
const unordered = rows.filter((row) => !row.item);
const enabledCount = ordered.filter((row) => row.item.enabled).length;
host.append(el("div", { class: "px-outline-head" }, [
el("span", { class: "px-outline-title", text: "发送顺序" }),
el("span", { class: "px-outline-count", text: `${enabledCount}/${ordered.length}` }),
el("button", {
type: "button",
class: "px-btn px-btn-mini",
text: "+",
title: "新增模块",
onclick: addModule,
}),
]));
const list = el("div", { class: "px-outline-list" });
ordered.forEach((row, index) => list.append(outlineRow(row, index + 1)));
attachOutlineDrag(list);
host.append(list);
if (unordered.length > 0) {
host.append(el("details", { class: "px-outline-group" }, [
el("summary", { text: `未加入顺序(${unordered.length})` }),
...unordered.map((row) => outlineRow(row, null)),
]));
}
host.append(generationRow());
host.append(pseudoHistoryRow());
markActiveOutline();
}
function outlineRow({ prompt, item }, index) {
const enabled = moduleEnabled(prompt, item);
// 左轨顺序就是真实发送顺序(装配不再重排),所以每一行都能拖。
const draggable = index != null && Boolean(item);
const toggle = el("input", {
type: "checkbox",
class: "px-switch",
checked: enabled,
disabled: item ? null : "disabled",
title: item ? (enabled ? "已启用,点击停用" : "已停用,点击启用") : "未加入顺序",
"aria-label": `是否发送 ${prompt.name || prompt.id}`,
onchange: (e) => {
if (!item) return;
item.enabled = e.target.checked;
prompt.enabled = e.target.checked;
markDirty();
const row = document.querySelector(`.px-row[data-prompt="${CSS.escape(prompt.id)}"]`);
if (row) row.dataset.enabled = e.target.checked ? "1" : "0";
syncOutlineHead();
syncEditorStatus();
},
});
const main = el("button", {
type: "button",
class: "px-row-main",
title: draggable ? `${prompt.name || prompt.id}(拖动调整发送顺序)` : null,
onclick: () => selectModule(prompt.id),
}, [
el("span", { class: "px-row-index", text: index ? String(index) : "–" }),
el("span", { class: "px-row-name", text: prompt.name || prompt.id }),
prompt.marker
? el("span", {
class: "px-row-tag",
text: "位置",
title: MARKER_HINTS[prompt.id] || "运行时填洞",
})
: (prompt.role && prompt.role !== "system"
? el("span", { class: "px-row-tag", text: prompt.role })
: null),
el("span", { class: "px-row-dot" }),
]);
return el("div", {
class: "px-row",
"data-prompt": prompt.id,
"data-enabled": enabled ? "1" : "0",
"data-order": index == null ? null : String(index - 1),
"data-drag": draggable ? "1" : null,
draggable: draggable ? "true" : null,
}, [toggle, main]);
}
function generationRow() {
const gen = state.preset?.generation || {};
const count = Object.keys(gen).length;
return el("div", {
class: "px-row px-row-gen",
"data-prompt": GENERATION,
"data-active": state.selected === GENERATION ? "1" : "0",
}, [
el("span", { class: "px-row-index", text: "⚙" }),
el("button", { type: "button", class: "px-row-main", onclick: selectGeneration }, [
el("span", { class: "px-row-name", text: "生成参数" }),
el("span", { class: "px-row-tag", text: count ? `${count} 项` : "默认" }),
]),
]);
}
function pseudoHistoryRow() {
const turns = pseudoTurns();
return el("div", {
class: "px-row px-row-gen",
"data-prompt": PSEUDO_HISTORY,
"data-active": state.selected === PSEUDO_HISTORY ? "1" : "0",
}, [
el("span", { class: "px-row-index", text: "◧" }),
el("button", { type: "button", class: "px-row-main", onclick: selectPseudoHistory }, [
el("span", { class: "px-row-name", text: "假历史" }),
el("span", {
class: "px-row-tag",
text: turns.length ? `${turns.length} 句` : "空",
title: "真多轮 user / assistant,排在真实历史之前",
}),
]),
]);
}
function syncOutlineHead() {
if (!state.preset) return;
const ordered = state.preset.promptOrder;
const enabled = ordered.filter((item) => item.enabled).length;
const count = document.querySelector(".px-outline-count");
if (count) count.textContent = `${enabled}/${ordered.length}`;
}
function markActiveOutline() {
for (const row of document.querySelectorAll(".px-outline .px-row[data-prompt]")) {
row.dataset.active = row.dataset.prompt === state.selected ? "1" : "0";
}
}
function selectModule(promptId) {
state.selected = promptId;
markActiveOutline();
renderEditor();
document.body.dataset.outline = "off";
}
function selectGeneration() {
state.selected = GENERATION;
markActiveOutline();
renderEditor();
document.body.dataset.outline = "off";
}
function selectPseudoHistory() {
state.selected = PSEUDO_HISTORY;
markActiveOutline();
renderEditor();
document.body.dataset.outline = "off";
}
/* ---------- 模块操作 ---------- */
function moveModule(promptId, delta) {
const preset = state.preset;
const order = preset.promptOrder.slice().sort((a, b) => a.orderIndex - b.orderIndex);
const index = order.findIndex((item) => item.promptId === promptId);
const target = index + delta;
if (index < 0 || target < 0 || target >= order.length) return;
const tmp = order[index];
order[index] = order[target];
order[target] = tmp;
order.forEach((item, i) => { item.orderIndex = i; });
markDirty();
renderOutline();
renderEditor();
}
/* ---------- 拖动排序 ---------- */
/** 正在拖的模块 id;不在拖动中时为 null。 */
let dragPromptId = null;
/** 拖动排序只改 promptOrder,序号重排后和 ↑↓ / 装配完全等价。 */
function movePromptToGap(promptId, gap) {
const preset = state.preset;
if (!preset) return;
const order = preset.promptOrder.slice().sort((a, b) => a.orderIndex - b.orderIndex);
const from = order.findIndex((item) => item.promptId === promptId);
if (from < 0) return;
// gap 是原生数组里的空隙位置(0..len),拔掉自己后它之前的空隙不变
const to = gap > from ? gap - 1 : gap;
if (to === from) return;
const [moved] = order.splice(from, 1);
order.splice(to, 0, moved);
order.forEach((item, i) => { item.orderIndex = i; });
markDirty();
renderOutline();
renderEditor();
}
function outlineRows(list) {
return [...list.querySelectorAll(".px-row[data-order]")];
}
/** 指针 y 落在哪个空隙:行上半 = 行前,下半 = 行后,越过末行 = 末尾。 */
function dropGap(list, clientY) {
const rows = outlineRows(list);
if (rows.length === 0) return null;
for (let i = 0; i < rows.length; i += 1) {
const rect = rows[i].getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) return i;
}
return rows.length;
}
function markDropGap(list, gap) {
clearDropMarks();
const rows = outlineRows(list);
if (!rows.length) return;
if (gap >= rows.length) rows[rows.length - 1].dataset.drop = "after";
else rows[gap].dataset.drop = "before";
}
function clearDropMarks() {
for (const row of document.querySelectorAll(".px-outline .px-row[data-drop]")) {
delete row.dataset.drop;
}
}
/** 拖到标题栏或底部时自动滚,大纲长了一屏装不下。 */
function autoScrollOutline(list, clientY) {
const box = list.closest(".px-outline");
if (!box) return;
const rect = box.getBoundingClientRect();
if (clientY < rect.top + 28) box.scrollTop -= 14;
else if (clientY > rect.bottom - 28) box.scrollTop += 14;
}
function attachOutlineDrag(list) {
list.addEventListener("dragstart", (event) => {
const row = event.target instanceof Element
? event.target.closest(".px-row[data-drag='1']")
: null;
if (!row || !row.dataset.prompt) return;
dragPromptId = row.dataset.prompt;
row.dataset.dragging = "1";
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
// Firefox 不设 data 就不肯开始拖;这里只当占位
event.dataTransfer.setData("text/plain", dragPromptId);
}
});
list.addEventListener("dragover", (event) => {
if (!dragPromptId) return;
const gap = dropGap(list, event.clientY);
if (gap == null) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
markDropGap(list, gap);
autoScrollOutline(list, event.clientY);
});
list.addEventListener("dragleave", (event) => {
const to = event.relatedTarget;
if (to instanceof Node && list.contains(to)) return;
clearDropMarks();
});
list.addEventListener("drop", (event) => {
if (!dragPromptId) return;
event.preventDefault();
const gap = dropGap(list, event.clientY);
const promptId = dragPromptId;
dragPromptId = null;
clearDropMarks();
if (gap == null) return;
movePromptToGap(promptId, gap);
});
list.addEventListener("dragend", () => {
dragPromptId = null;
clearDropMarks();
for (const row of list.querySelectorAll(".px-row[data-dragging]")) {
delete row.dataset.dragging;
}
});
}
function addModule() {
const preset = state.preset;
if (!preset) return;
const used = new Set(preset.prompts.map((p) => p.id));
let n = preset.prompts.filter((p) => !p.marker).length + 1;
let id = `custom-${n}`;
while (used.has(id)) {
n += 1;
id = `custom-${n}`;
}
preset.prompts.push({
id,
name: `新模块 ${n}`,
enabled: true,
role: "system",
content: "",
marker: false,
sourceIdentifier: id,
});
const max = preset.promptOrder.reduce((m, item) => Math.max(m, item.orderIndex), -1);
preset.promptOrder.push({ promptId: id, enabled: true, orderIndex: max + 1 });
markDirty();
state.selected = id;
renderOutline();
renderEditor();
const box = document.querySelector(".px-content");
if (box) box.focus();
}
function addToOrder(promptId) {
const preset = state.preset;
const prompt = preset.prompts.find((p) => p.id === promptId);
if (!prompt) return;
const max = preset.promptOrder.reduce((m, item) => Math.max(m, item.orderIndex), -1);
preset.promptOrder.push({ promptId, enabled: true, orderIndex: max + 1 });
prompt.enabled = true;
markDirty();
renderOutline();
renderEditor();
}
function removeFromOrder(promptId) {
const preset = state.preset;
preset.promptOrder = preset.promptOrder.filter((item) => item.promptId !== promptId);
markDirty();
renderOutline();
renderEditor();
}
function removeModule(promptId) {
const preset = state.preset;
const prompt = preset.prompts.find((p) => p.id === promptId);
if (!prompt || prompt.marker) return;
if (!window.confirm(`删除模块「${prompt.name || prompt.id}」?`)) return;
preset.prompts = preset.prompts.filter((p) => p.id !== promptId);
preset.promptOrder = preset.promptOrder.filter((item) => item.promptId !== promptId);
markDirty();
state.selected = firstSelectable();
renderOutline();
renderEditor();
}
/* ---------- 编辑器 ---------- */
function renderEditor() {
const host = $("editor");
host.replaceChildren();
if (!state.preset) {
host.append(el("div", { class: "px-empty" }, [
el("p", { text: "还没有预设。" }),
el("button", {
type: "button",
class: "px-btn px-btn-primary",
text: "新建预设",
onclick: createPreset,
}),
]));
return;
}
if (!state.selected) state.selected = firstSelectable();
if (state.selected === GENERATION) {
renderGenerationEditor(host);
return;
}
if (state.selected === PSEUDO_HISTORY) {
renderPseudoHistoryEditor(host);
return;
}
const found = findModule(state.selected);
if (!found) {
state.selected = firstSelectable();
if (state.selected === GENERATION) return renderGenerationEditor(host);
return renderEditor();
}
renderModuleEditor(host, found);
}
function renderModuleEditor(host, { prompt, item, orderIndex, orderedCount }) {
const enabled = moduleEnabled(prompt, item);
const canMove = Boolean(item);
const head = el("div", { class: "px-editor-head" }, [
el("button", {
type: "button",
class: "px-btn px-btn-mini px-only-narrow",
text: "顺序",
title: "打开发送顺序",
onclick: () => {
state.outlineTouched = true;
document.body.dataset.outline = "on";
},
}),
el("button", {
type: "button",
class: "px-btn px-btn-mini px-preview-launcher",
text: "预览",
title: "打开实际发送内容",
onclick: openPreviewPane,
}),
el("input", {
type: "text",
class: "px-input px-editor-name",
value: prompt.name || prompt.id,
"aria-label": "模块名称",
oninput: (e) => {
prompt.name = e.target.value;
markDirty();
syncOutlineName(prompt.id, e.target.value);
},
}),
prompt.marker
? el("span", { class: "px-row-tag", text: "位置模块", title: MARKER_HINTS[prompt.id] || "" })
: roleSelect(prompt),
el("span", { class: "px-editor-status", id: "editor-status", text: moduleStatusText(item, enabled, orderIndex, orderedCount) }),
el("div", { class: "px-editor-actions" }, [
el("button", {
type: "button",
class: "px-btn px-btn-mini",
text: "↑",
title: "上移",
disabled: canMove && orderIndex > 0 ? null : "disabled",
onclick: () => moveModule(prompt.id, -1),
}),
el("button", {
type: "button",
class: "px-btn px-btn-mini",
text: "↓",
title: "下移",
disabled: canMove && orderIndex < orderedCount - 1 ? null : "disabled",
onclick: () => moveModule(prompt.id, 1),
}),
!item
? el("button", { type: "button", class: "px-btn px-btn-mini", text: "加入顺序", onclick: () => addToOrder(prompt.id) })
: (prompt.marker
? null
: el("button", { type: "button", class: "px-btn px-btn-mini", text: "移出顺序", onclick: () => removeFromOrder(prompt.id) })),
prompt.marker
? null
: el("button", { type: "button", class: "px-btn px-btn-mini px-btn-danger", text: "删除", onclick: () => removeModule(prompt.id) }),
]),
]);
const body = el("div", { class: "px-editor-body" });
if (prompt.marker) {
body.append(el("div", { class: "px-marker" }, [
el("p", { class: "px-marker-title", text: "位置模块:运行时往这个位置填内容" }),
el("p", { class: "px-hint", text: MARKER_HINTS[prompt.id] || "只能启停,不能删除。" }),
]));
} else {
const area = el("textarea", {
class: "px-content",
spellcheck: "false",
placeholder: "这个模块的正文…",
"aria-label": "模块正文",
});
area.value = prompt.content || "";
area.addEventListener("input", () => {
prompt.content = area.value;
markDirty();
updateContentFoot(area.value);
});
body.append(area);
body.append(el("div", { class: "px-editor-foot", id: "content-foot" }, contentFootItems(prompt.content || "")));
attachMacroSuggest(area);
}
host.append(head, body);
}
function roleSelect(prompt) {
const select = el("select", {
class: "px-select",
"aria-label": "消息角色",
onchange: (e) => {
prompt.role = e.target.value;
markDirty();
renderOutline();
},
}, ["system", "user", "assistant"].map((role) =>
el("option", { value: role, text: role, selected: prompt.role === role }),
));
select.value = prompt.role || "system";
return select;
}
function moduleStatusText(item, enabled, orderIndex, orderedCount) {
if (!item) return "未加入顺序";
const position = `${orderIndex + 1}/${orderedCount}`;
return enabled ? `启用 · 第 ${position}` : `已停用 · 第 ${position}`;
}
function syncEditorStatus() {
const found = state.selected ? findModule(state.selected) : null;
const host = $("editor-status");
if (!found || !host) return;
host.textContent = moduleStatusText(found.item, moduleEnabled(found.prompt, found.item), found.orderIndex, found.orderedCount);
}
function countMacros(text) {
const matches = String(text).match(/\{\{[^{}]*\}\}/g);
return matches ? matches.length : 0;
}
function contentFootItems(text) {
const unknown = (String(text).match(/\{\{[^{}]*\}\}/g) || []);
const unrecognized = unknown.filter((raw) => {
const name = raw.slice(2, -2).split("::")[0].trim();
return !MACROS.some(([macro]) => macro.slice(2, -2).split("::")[0].trim() === name)
&& !/^(\/|trim|roll)/i.test(name);
}).length;
return [
el("span", { text: `${String(text).length} 字` }),
el("span", { text: `${countMacros(text)} 个宏` }),
unrecognized ? el("span", { text: `${unrecognized} 个未识别宏,会原样发出` }) : null,
];
}
function updateContentFoot(text) {
const host = $("content-foot");
if (!host) return;
host.replaceChildren(...contentFootItems(text).filter(Boolean));
}
function syncOutlineName(promptId, name) {
const row = document.querySelector(`.px-outline .px-row[data-prompt="${CSS.escape(promptId)}"] .px-row-name`);
if (row) row.textContent = name || promptId;
}
/* ---------- 生成参数 ---------- */
const GEN_FIELDS = [
["temperature", "temperature", "0.7", "0.01"],
["topP", "top_p", "1", "0.01"],
["topK", "top_k", "0", "1"],
["minP", "min_p", "0", "0.01"],
["frequencyPenalty", "frequency_penalty", "0", "0.1"],
["presencePenalty", "presence_penalty", "0", "0.1"],
["repetitionPenalty", "repetition_penalty", "1", "0.01"],
["maxContextTokens", "max context tokens", "32768", "1"],
["maxOutputTokens", "max output tokens", "4096", "1"],
["seed", "seed", "随机", "1"],
];
const SQUASH_MODES = [
["", "默认(strict)"],
["none", "none · 原样"],
["merge", "merge · 合并相邻同角色"],
["system", "system · 只合并 system"],
["semi", "semi · system 折进相邻"],
["strict", "strict · 强制首条 user"],
["single-user-turn", "single-user-turn · 全并一条 user"],
];
function renderGenerationEditor(host) {
const preset = state.preset;
const gen = preset.generation || (preset.generation = {});
host.append(el("div", { class: "px-editor-head" }, [
el("button", {
type: "button",
class: "px-btn px-btn-mini px-only-narrow",
text: "顺序",
onclick: () => {
state.outlineTouched = true;
document.body.dataset.outline = "on";
},
}),
el("button", {
type: "button",
class: "px-btn px-btn-mini px-preview-launcher",
text: "预览",
title: "打开实际发送内容",
onclick: openPreviewPane,
}),
el("strong", { style: "font-size:13px", text: "生成参数" }),
el("span", { class: "px-hint", text: "空字段表示不发送,跟预设默认走。" }),
]));
const body = el("div", { class: "px-editor-body" });
const grid = el("div", { class: "px-gen-grid" });
for (const [key, label, placeholder, step] of GEN_FIELDS) {
const input = el("input", {
type: "number",
step,
placeholder,
"aria-label": label,
oninput: (e) => {
const raw = e.target.value.trim();
if (!raw) delete gen[key];
else {
const n = Number(raw);
if (Number.isFinite(n)) gen[key] = n;
else delete gen[key];
}
markDirty();
syncGenTag();
},
});
if (typeof gen[key] === "number") input.value = String(gen[key]);
grid.append(el("label", { class: "px-field" }, [el("span", { text: label }), input]));
}
body.append(grid);
const stream = el("select", {
"aria-label": "stream",
onchange: (e) => {
if (e.target.value === "1") gen.stream = true;
else if (e.target.value === "0") gen.stream = false;
else delete gen.stream;
markDirty();
syncGenTag();
},
}, [
el("option", { value: "", text: "不指定" }),
el("option", { value: "1", text: "开" }),
el("option", { value: "0", text: "关" }),
]);
stream.value = gen.stream === true ? "1" : gen.stream === false ? "0" : "";
const squash = el("select", {
"aria-label": "消息整形",
onchange: (e) => {
const policy = preset.messagePolicy || {};
if (e.target.value) policy.squash = e.target.value;
else delete policy.squash;
if (Object.keys(policy).length > 0) preset.messagePolicy = policy;
else delete preset.messagePolicy;
markDirty();
syncGenTag();
},
}, SQUASH_MODES.map(([value, text]) =>
el("option", { value, text, selected: (preset.messagePolicy?.squash || "") === value }),
));
squash.value = preset.messagePolicy?.squash || "";
body.append(el("div", { class: "px-gen-extra" }, [
el("label", { class: "px-field" }, [el("span", { text: "stream" }), stream]),
el("label", { class: "px-field" }, [el("span", { text: "消息整形" }), squash]),
]));
body.append(el("p", { class: "px-hint", style: "margin-top:12px", text: "消息整形留空时按预设默认(strict);这里改的值会随预设一起保存。" }));
host.append(body);
}
function syncGenTag() {
const tag = document.querySelector(".px-row-gen .px-row-tag");
if (!tag || !state.preset) return;
const count = Object.keys(state.preset.generation || {}).length;
tag.textContent = count ? `${count} 项` : "默认";
}
/* ---------- 假历史(预设级,真多轮) ---------- */
/** 读预设上的假历史;不在这里凭空创建数组,免得空预设也被写出一个空字段。 */
function pseudoTurns() {
const raw = state.preset?.pseudoHistory;
return Array.isArray(raw) ? raw : [];
}
function syncPseudoHistoryTag() {
const tag = document.querySelector(`.px-row[data-prompt="${PSEUDO_HISTORY}"] .px-row-tag`);
if (!tag) return;
const count = pseudoTurns().length;
tag.textContent = count ? `${count} 句` : "空";
}
function renderPseudoHistoryEditor(host) {
const preset = state.preset;
if (!Array.isArray(preset.pseudoHistory)) preset.pseudoHistory = [];
host.append(el("div", { class: "px-editor-head" }, [
el("button", {
type: "button",
class: "px-btn px-btn-mini px-only-narrow",
text: "顺序",
onclick: () => {
state.outlineTouched = true;
document.body.dataset.outline = "on";
},
}),
el("button", {
type: "button",
class: "px-btn px-btn-mini px-preview-launcher",
text: "预览",
title: "打开实际发送内容",
onclick: openPreviewPane,
}),
el("strong", { style: "font-size:13px", text: "假历史" }),
el("span", { class: "px-hint", text: "真多轮 user / assistant,排在真实历史之前。" }),
]));
const body = el("div", { class: "px-editor-body" });
body.append(createHistoryEditor({
items: preset.pseudoHistory,
onChange: () => {
markDirty();
syncPseudoHistoryTag();
},
}).el);
body.append(el("p", {
class: "px-hint",
style: "margin-top:12px",
text: "假历史占真实轮次,模型会当成已经说过的话;要不占轮次的 few-shot,用「示例对话」模块(酒馆 mes_example,按 system + example_* 发出)。",
}));
host.append(body);
}
/* ---------- 宏联想 ---------- */
const MACROS = [
["{{user}}", "当前用户名称"],
["{{char}}", "当前作品 / 角色名"],
["{{persona}}", "用户人设正文"],
["{{lastUserMessage}}", "最近一条用户消息"],
["{{mesExamples}}", "示例对话整段文本"],
["{{getvar::key}}", "读取变量"],
["{{setvar::key::value}}", "写入变量"],
["{{addvar::key::value}}", "追加变量"],
["{{roll::1d6}}", "掷骰"],
["{{trim}}", "命中后对结果首尾去空白"],
["{{// 注释}}", "注释,不发送"],
];
let suggestBox = null;
function closeSuggest() {
if (suggestBox) {
suggestBox.remove();
suggestBox = null;
}
}
function attachMacroSuggest(textarea) {
textarea.addEventListener("input", () => updateSuggest(textarea));
textarea.addEventListener("blur", () => window.setTimeout(closeSuggest, 120));
textarea.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeSuggest();
});
}
function updateSuggest(textarea) {
const caret = textarea.selectionStart;
const before = textarea.value.slice(0, caret);
const match = /({{|\{\{)([^\n{}{}]{0,20})$/.exec(before);
if (!match) {
closeSuggest();
return;
}
const query = match[2].trim().toLowerCase();
const hits = MACROS.filter(([name]) => !query || name.toLowerCase().includes(query)).slice(0, 8);
if (hits.length === 0) {
closeSuggest();
return;
}
closeSuggest();
suggestBox = el("div", { class: "px-suggest" });
for (const [name, desc] of hits) {
suggestBox.append(
el("div", {
class: "px-suggest-row",
onmousedown: (e) => {
e.preventDefault();
const start = caret - match[0].length;
textarea.value = textarea.value.slice(0, start) + name + textarea.value.slice(caret);
textarea.selectionStart = textarea.selectionEnd = start + name.length;
textarea.dispatchEvent(new Event("input"));
closeSuggest();
textarea.focus();
},
}, [el("code", { text: name }), el("span", { text: desc })]),
);
}
document.body.append(suggestBox);
const rect = textarea.getBoundingClientRect();
suggestBox.style.left = `${rect.left + window.scrollX + 12}px`;
suggestBox.style.top = `${rect.top + window.scrollY + 30}px`;
}
/* ---------- 结构检查 ---------- */
async function runCheck() {
if (!state.preset) return;
try {
const data = await api("check", { preset: state.preset });
state.issues = data.issues || [];
renderIssues();
renderOutlineDots();
} catch (e) {
err(e.message);
}
}
function renderIssues() {
const band = $("issues-band");
band.replaceChildren();
const issues = state.issues;
const errors = issues.filter((issue) => issue.level === "error").length;
const warns = issues.length - errors;
band.dataset.level = errors ? "error" : warns ? "warn" : "ok";
const text = errors
? `${errors} 个错误${warns ? ` · ${warns} 个提醒` : ""}`
: warns
? `${warns} 个提醒`
: "结构检查通过";
band.append(el("button", {
type: "button",
class: "px-band-summary",
disabled: issues.length ? null : "disabled",
title: issues.length ? "展开问题列表" : "",
onclick: () => {
state.issuesOpen = !state.issuesOpen;
renderIssues();
},
}, [
el("span", { class: "px-band-dot" }),
el("span", { text }),
issues.length ? el("span", { class: "px-band-caret", text: state.issuesOpen ? "▴" : "▾" }) : null,
]));
if (state.issuesOpen && issues.length) {
const list = el("div", { class: "px-issue-list" });
for (const issue of issues) {
list.append(el("button", {
type: "button",
class: "px-issue",
"data-level": issue.level,
"data-focus": issue.entryId ? "1" : "0",
title: issue.entryId ? "定位到模块" : "",
onclick: () => { if (issue.entryId) focusEntry(issue.entryId); },
}, [
el("b", { text: issue.level === "error" ? "错误" : "提醒" }),
` ${issue.entryName ? issue.entryName + ":" : ""}${issue.message}`,
]));
}
band.append(list);
}
}
function renderOutlineDots() {
const bad = new Set();
for (const issue of state.issues) {
if (issue.level === "error" && issue.entryId) bad.add(issue.entryId);
}
for (const row of document.querySelectorAll(".px-outline .px-row[data-prompt]")) {
row.dataset.error = bad.has(row.dataset.prompt) ? "1" : "0";
}
}
function focusEntry(entryId) {
selectModule(entryId);
const box = document.querySelector(".px-content");
if (box) box.focus();
}
/* ---------- 预览 ---------- */
function casePayload(item) {
return {
id: item.id || "",
name: item.name || "",
charName: item.charName || "",
charDescription: item.charDescription || "",
worldBook: item.worldBook || "",
userInput: item.userInput || "",
history: (item.history || []).map((h) => ({ role: h.role, content: h.content })),
};
}
function currentPreviewCase() {
return state.cases.find((item) => item.id === state.previewCaseId) || state.cases[0] || null;
}
function renderPreviewCases() {
const select = $("preview-case");
select.replaceChildren();
if (state.cases.length === 0) {
state.previewCaseId = "";
select.append(el("option", { value: "", text: "(无用例,空输入)" }));
return;
}
if (!state.cases.some((item) => item.id === state.previewCaseId)) {
state.previewCaseId = state.cases[0].id || "";
}
state.cases.forEach((item, index) => {
select.append(el("option", {
value: item.id || "",
text: item.name || `用例 ${index + 1}`,
selected: (item.id || "") === state.previewCaseId,
}));
});
select.value = state.previewCaseId;
}
async function runPreview() {
if (!state.preset) return;
const item = currentPreviewCase();
const payload = item ? casePayload(item) : {
id: "preview",
name: "预览",
charName: "",
charDescription: "",
worldBook: "",
userInput: "",
history: [],
};
if (!payload.id) payload.id = "preview";
try {
const data = await api("preview", { preset: state.preset, testCase: payload });
state.messages = data.messages || [];
state.previewShape = {
squash: data.squash || null,
assembledCount: typeof data.assembledCount === "number" ? data.assembledCount : state.messages.length,
};
renderPreview(item);
} catch (e) {
err(e.message);
}
}
function renderPreview(item) {
const host = $("preview-out");
const keep = host.scrollTop;
host.replaceChildren();
const messages = state.messages;
messages.forEach((message, index) => host.append(messageNode(message, index)));
if (messages.length === 0) {
host.append(el("p", { class: "px-hint", text: "没有产生消息:检查 chatHistory 是否启用、模块是否为空。" }));
}
host.scrollTop = keep;
const chars = messages.reduce((sum, message) => sum + String(message.content || "").length, 0);
const caseName = item ? item.name || "未命名用例" : "空输入";
// 左轨顺序就是装配顺序;整形可能把若干条并成一条,这里得说清楚,否则看着像右侧没跟左轨。
const squash = state.previewShape.squash;
const shapeNote = squash && squash !== "none" && messages.length > 0
? ` · 整形 ${squash}(装配 ${state.previewShape.assembledCount} 条 → 发出 ${messages.length} 条)`
: "";
const meta = $("preview-meta");
meta.textContent = `${caseName} · ${messages.length} 条 · ${chars.toLocaleString("zh-CN")} 字${shapeNote}`;
meta.title = shapeNote
? "左轨顺序决定装配顺序;整形按预设的「消息整形」设置合并消息,可在「生成参数」里改成 none 原样发出。"
: "左轨顺序决定了这里每一条的位置。";
}
function messageNode(message, index) {
const text = message.content == null ? "" : String(message.content);
const long = text.length > 800;
const body = el("div", {
class: "px-msg-body",
"data-clamped": long ? "1" : "0",
text,
});
const node = el("div", { class: "px-msg", "data-role": message.role }, [
el("span", { class: "px-msg-role", text: `${index + 1} · ${message.role}` }),
body,
]);
if (long) {
node.append(el("button", {
type: "button",
class: "px-msg-more",
text: `展开(${text.length.toLocaleString("zh-CN")} 字)`,
onclick: (e) => {
body.dataset.clamped = "0";
e.target.remove();
},
}));
}
return node;
}
function openPreviewPane() {
state.previewTouched = true;
document.body.dataset.preview = "on";
renderPreviewToggle();
}
function renderPreviewToggle() {
const on = document.body.dataset.preview !== "off";
const button = $("btn-toggle-preview");
if (button) button.textContent = on ? "收起" : "预览";
}
/* ---------- 版本 ---------- */
const LANE_W = 11;
const LANE_PAD = 5;
/**
* 版本树的 git 式布局:DFS 预序,先出生的是主干(子节点继承父的 lane),
* 后面的兄弟开新 lane。返回每行的 lane、父 lane、以及这一行要竖着画哪几条线。
*/
function versionGraphRows(tree) {
const byParent = new Map();
for (const node of tree.nodes) {
const key = node.parentId || "";
if (!byParent.has(key)) byParent.set(key, []);
byParent.get(key).push(node);
}
for (const list of byParent.values()) {
list.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
}
const known = new Set(tree.nodes.map((node) => node.id));
const roots = [...(byParent.get("") || [])];
for (const node of tree.nodes) {
// 父节点丢了的孤儿也当根显示,不然整支看不见
if (node.parentId && !known.has(node.parentId)) roots.push(node);
}
const rows = [];
const seen = new Set();
const walk = (node, lane, ancestorLanes) => {
if (seen.has(node.id)) return;
seen.add(node.id);
// 主干上的子代继承父的 lane:已经在这条线上就别再叠一条
const openLanes = ancestorLanes.includes(lane) ? [...ancestorLanes] : [...ancestorLanes, lane];
const children = (byParent.get(node.id) || []).filter((child) => !seen.has(child.id));
rows.push({
node,
lane,
parentLane: ancestorLanes.length > 0 ? ancestorLanes[ancestorLanes.length - 1] : null,
openLanes,
continuesBelow: children.length > 0,
});
// 兄弟分叉各自占一条空 lane;子代共享这份占用表,互不叠线
const reserved = new Set(openLanes);
children.forEach((child, index) => {
let childLane = lane;
if (index > 0) {
childLane = 0;
while (reserved.has(childLane)) childLane += 1;
reserved.add(childLane);
}
walk(child, childLane, openLanes);
});
};
for (const root of roots) walk(root, 0, []);
for (const node of tree.nodes) walk(node, 0, []);
const laneCount = rows.reduce((max, row) => Math.max(max, ...row.openLanes) + 1, 1);
return { rows, laneCount };
}
function versionGraph(row, laneCount, node) {
const width = laneCount * LANE_W + LANE_PAD;
const gutter = el("span", { class: "px-ver-graph", style: `width:${width}px` });
const center = (lane) => LANE_W / 2 + lane * LANE_W;
for (const lane of row.openLanes) {
const own = lane === row.lane;
// 自己那条线到节点/分叉就收住;祖先的线整列贯通
const full = !own || row.continuesBelow;
gutter.append(el("i", {
class: "px-ver-line",
style: `left:${center(lane) - 1}px;${full ? "top:0;bottom:0" : "top:0;height:50%"}`,
}));
}
const parentLane = row.parentLane;
if (parentLane != null && parentLane !== row.lane) {
const from = Math.min(center(parentLane), center(row.lane));
const to = Math.max(center(parentLane), center(row.lane));
gutter.append(el("i", { class: "px-ver-join", style: `left:${from}px;width:${to - from}px` }));
}
gutter.append(el("i", {
class: "px-ver-dot",
"data-head": node.id === state.tree?.currentId ? "1" : "0",
style: `left:${center(row.lane) - 3.5}px`,
}));
return gutter;
}
function renderVersions() {
const host = $("version-tree");
host.replaceChildren();
closeVersionMenu();
const tree = state.tree;
if (!tree || tree.nodes.length === 0) {
$("version-hint").textContent = "还没有版本,点「保存为新版本」建根。";
updateDirtyHint();
return;
}
if (!state.selectedVersionId || !tree.nodes.some((node) => node.id === state.selectedVersionId)) {
state.selectedVersionId = tree.currentId || tree.nodes[0]?.id || null;
}
const { rows, laneCount } = versionGraphRows(tree);
for (const row of rows) {
const { node } = row;
const when = node.createdAt.replace("T", " ").slice(5, 16);
const isHead = node.id === tree.currentId;
host.append(el("button", {
type: "button",
class: "px-ver",
role: "treeitem",
"data-current": isHead ? "1" : "0",
"data-selected": node.id === state.selectedVersionId ? "1" : "0",
"data-version-id": node.id,
onclick: (event) => {
event.stopPropagation();
selectVersionNode(node.id);
openVersionMenu(node.id, event.currentTarget);
},
}, [
versionGraph(row, laneCount, node),
el("span", { class: "px-ver-main" }, [
el("span", { class: "px-ver-title" }, [
`${node.id} · ${when}`,
isHead ? el("em", { class: "px-ver-badge", text: "当前" }) : null,
]),
el("small", { text: node.note || "(无说明)" }),
]),
]));
}
$("version-hint").textContent =
`共 ${tree.nodes.length} 个版本 · 当前 ${tree.currentId || "无"} · 点节点看操作`;
updateDirtyHint();
}
/** 点节点弹出的操作菜单:读是显式动作,不连带。 */
function openVersionMenu(nodeId, anchor) {
closeVersionMenu();
const tree = state.tree;
const node = tree?.nodes.find((item) => item.id === nodeId);
if (!node) return;
const isHead = node.id === tree.currentId;
const menu = el("div", { class: "px-ver-menu", role: "menu" }, [
el("div", { class: "px-ver-menu-head" }, [
el("span", { class: "px-ver-menu-title" }, [
node.id,
isHead ? el("em", { class: "px-ver-badge", text: "当前" }) : null,
]),
el("span", { class: "px-hint", text: `创建 ${node.createdAt.replace("T", " ").slice(0, 16)}` }),
node.updatedAt
? el("span", { class: "px-hint", text: `改动 ${node.updatedAt.replace("T", " ").slice(0, 16)}` })
: null,
node.note ? el("span", { class: "px-ver-menu-note", text: node.note }) : null,
]),
el("button", {
type: "button",
class: "px-ver-menu-item",
text: "读取这个版本",
title: "把这个快照换进编辑器(当前草稿会被覆盖)",
onclick: () => {
closeVersionMenu();
state.selectedVersionId = node.id;
void loadSelectedVersion();
},
}),
el("button", {
type: "button",
class: "px-ver-menu-item",
text: "从它分叉",
title: "以当前草稿为内容,在这个节点下开一条新分支",
onclick: () => {
closeVersionMenu();
void saveVersion(node.id);
},
}),
el("button", {
type: "button",
class: "px-ver-menu-item",
text: "复制 id",
onclick: async () => {
try {
await navigator.clipboard.writeText(node.id);
notice(`已复制 ${node.id}`);
} catch {
err(`复制失败,id 是 ${node.id}`);
}
closeVersionMenu();
},
}),
]);
menu.dataset.versionId = node.id;
document.body.append(menu);
const rect = anchor.getBoundingClientRect();
const left = Math.max(8, Math.min(rect.left, window.innerWidth - menu.offsetWidth - 8));
const top = Math.min(rect.bottom + 4, window.innerHeight - menu.offsetHeight - 8);
menu.style.left = `${left}px`;
menu.style.top = `${Math.max(8, top)}px`;
// 延迟挂,避免这一下 click 立刻把它关掉
setTimeout(() => {
document.addEventListener("click", onVersionMenuOutsideClick, true);
window.addEventListener("scroll", closeVersionMenu, true);
}, 0);
}
function onVersionMenuOutsideClick(event) {
const menu = document.querySelector(".px-ver-menu");
if (menu && menu.contains(event.target)) return;
closeVersionMenu();
}
function closeVersionMenu() {
document.removeEventListener("click", onVersionMenuOutsideClick, true);
window.removeEventListener("scroll", closeVersionMenu, true);
for (const menu of document.querySelectorAll(".px-ver-menu")) menu.remove();
}
/** 只切选中态,不重建版本树:避免列表滚动位置看起来跳变。 */
function selectVersionNode(nodeId) {
state.selectedVersionId = nodeId;
for (const button of document.querySelectorAll("#version-tree .px-ver")) {
button.dataset.selected = button.dataset.versionId === nodeId ? "1" : "0";
}
const tree = state.tree;
if (tree) {
$("version-hint").textContent =
`共 ${tree.nodes.length} 个版本 · 当前 ${tree.currentId || "无"} · 选中 ${state.selectedVersionId || "无"}`;
}
}
async function loadSelectedVersion() {
if (!state.selectedVersionId) {
err("先在版本树里选一个版本。");
return;
}
if (!confirmDiscard()) return;
try {
const tree = await api("versions/checkout", {
presetId: state.presetId,
nodeId: state.selectedVersionId,
});
state.tree = tree;
const node = tree.nodes.find((n) => n.id === state.selectedVersionId);
if (!node) throw new Error("版本节点不存在");
state.preset = structuredClone(node.preset);
state.presetDirty = false;
state.run = null;
state.selected = firstSelectable();
renderAll();
notice(`已载入版本 ${node.id}`);
await refreshAll();
} catch (e) {
err(e.message);
}
}
async function saveVersion(parentId) {
if (!state.preset) return null;
try {
const body = { preset: state.preset, note: $("note-input").value };
if (typeof parentId === "string" && parentId) body.parentId = parentId;
const tree = await api("versions/commit", body);
state.tree = tree;
state.selectedVersionId = tree.currentId;
state.presetDirty = false;
$("note-input").value = "";
updateVersionsButton();
renderVersions();
updateDirtyHint();
notice(`已保存为新版本 ${tree.currentId}`);
return tree;
} catch (e) {
err(e.message);
return null;
}
}
/** 保存至当前版本:覆盖 currentId 那个节点,不产生新节点。 */
async function saveVersionToCurrent() {
if (!state.preset) return null;
const currentId = state.tree?.currentId;
const target = currentId
? `版本 ${currentId}`
: "(还没有版本,会建根)";
if (!window.confirm(`把当前草稿覆盖到${target}?不会新建节点,之前的快照会丢。`)) return null;
try {
const tree = await api("versions/amend", {
preset: state.preset,
note: $("note-input").value,
});
state.tree = tree;
state.selectedVersionId = tree.currentId;
state.presetDirty = false;
$("note-input").value = "";
updateVersionsButton();
renderVersions();
updateDirtyHint();
notice(`已覆盖版本 ${tree.currentId}`);
return tree;
} catch (e) {
err(e.message);
return null;
}
}
/* ---------- 用例 ----------
* 一个用例就是一条记录:自己存、自己运行。盘上仍是一组一文件,但界面不出现「组」。
*/
const CASE_FIELDS = [
["name", "用例名", "input", "例:开门 / 拒绝"],
["charName", "角色名 {{char}}", "input", ""],
["charDescription", "角色描述(进 charDescription 位置)", "textarea", "这个用例里的角色是谁"],
["worldBook", "世界书文本(进历史前的世界书位置)", "textarea", ""],
["userInput", "用户输入(进 currentTurn 位置)", "textarea", ""],
];
function newCaseId() {
return crypto.randomUUID().slice(0, 8);
}
function renderCaseList() {
const host = $("cases");
host.replaceChildren();
if (!state.preset) return;
if (state.cases.length === 0) {
host.append(el("p", { class: "px-hint", text: "还没有用例。用例全预设共用:一份要测的输入 + 期望的上下文。" }));
}
state.cases.forEach((item, index) => {
const active = index === state.activeCaseIndex;
host.append(el("div", { class: "px-case", "data-active": active ? "1" : "0" }, [
el("button", {
type: "button",
class: "px-case-main",
title: active ? "当前用例(点卡片切换)" : "选中这个用例作为当前用例",
onclick: () => selectCase(index),
}, [
el("span", { class: "px-case-name", text: item.name || `用例 ${index + 1}` }),
el("span", { class: "px-case-snippet", text: (item.userInput || "").replace(/\s+/g, " ").slice(0, 48) || "(无输入)" }),
]),
el("button", {
type: "button",
class: "px-btn px-btn-mini",
text: "编辑",
title: "编辑这个用例",
onclick: (event) => {
event.stopPropagation();
openCaseModal(index);
},
}),
]));
});
host.append(el("button", {
type: "button",
class: "px-btn",
style: "margin-top:4px",
text: "+ 新增用例",
onclick: addCase,
}));
renderRunCaseLabel();
}
/** 运行栏上显示当前用例名 */
function renderRunCaseLabel() {
const label = $("run-case-label");
if (!label) return;
const item = state.cases[state.activeCaseIndex];
label.textContent = item ? item.name || "未命名用例" : "先在左侧选一个用例";
}
/** 点用例卡片 = 选中(不弹编辑器);只有「编辑」按钮才打开用例编辑。 */
function selectCase(index) {
if (index < 0 || index >= state.cases.length) return;
if (index === state.activeCaseIndex) return;
state.activeCaseIndex = index;
renderCaseList();
rememberCaseState();
}
/**
* 记住「这个预设上次打开了哪条用例」。写盘(`presets/lab-state/<presetId>.json`),
* 不放浏览器缓存:换浏览器、重开页面都还是同一条。
*/
let caseStateTimer = null;
function rememberCaseState() {
if (!state.presetId) return;
window.clearTimeout(caseStateTimer);
caseStateTimer = window.setTimeout(() => {
const selected = state.cases[state.activeCaseIndex];
void api("lab-state", {
presetId: state.presetId,
selectedCaseId: selected?.id ?? null,
previewCaseId: state.previewCaseId || null,
}).catch(() => {
/* 记不住不影响用 */
});
}, 200);
}
function openCaseModal(index) {
const item = state.cases[index];
if (!item) return;
const changed = state.activeCaseIndex !== index;
state.activeCaseIndex = index;
renderCaseList();
if (changed) rememberCaseState();
$("case-drawer-title").textContent = item.name || `用例 ${index + 1}`;
const form = $("case-form");
form.replaceChildren();
// 大浮窗里分两列:左边用例自己的字段,右边历史(一句一行,需要宽度)。
const grid = el("div", { class: "px-case-grid" });
const fieldsCol = el("div", { class: "px-case-col px-case-col-fields" });
const historyCol = el("div", { class: "px-case-col px-case-col-history" });
for (const [key, label, kind, placeholder] of CASE_FIELDS) {
const commitField = (value) => {
item[key] = value;
if (key === "name") {
$("case-drawer-title").textContent = value || `用例 ${index + 1}`;
renderCaseList();
renderPreviewCases();
}
scheduleRefresh(600);
};
let input;
if (kind === "textarea") {
input = el("textarea", {
rows: 3,
placeholder,
oninput: (e) => commitField(e.target.value),
});
} else {
input = el("input", {
type: "text",
placeholder,
oninput: (e) => commitField(e.target.value),
});
}
input.value = item[key] || "";
fieldsCol.append(el("label", { class: "px-field" }, [el("span", { text: label }), input]));
}
if (!Array.isArray(item.history)) item.history = [];
historyCol.append(el("div", { class: "px-field px-field-history" }, [
el("span", { text: "历史(真多轮 user / assistant,接在预设假历史之后)" }),
createHistoryEditor({
items: item.history,
onChange: () => scheduleRefresh(600),
}).el,
]));
grid.append(fieldsCol, historyCol);
form.append(grid);
form.append(el("div", { class: "px-modal-foot" }, [
el("p", { class: "px-hint", text: "「完成」会自动保存;「运行此用例」也会先存再跑。" }),
el("div", { class: "px-modal-foot-actions" }, [
el("button", {
type: "button",
class: "px-btn px-btn-mini px-btn-danger",
text: "删除此用例",
onclick: () => {
void deleteCase(index);
},
}),
el("button", {
type: "button",
class: "px-btn px-btn-primary",
text: "完成",
onclick: () => {
void saveCase(index).then(() => closeDrawer());
},
}),
]),
]));
openDrawer("case");
}
function addCase() {
const id = newCaseId();
state.cases.push({
groupId: "",
id,
name: `用例 ${state.cases.length + 1}`,
charName: "",
charDescription: "",
worldBook: "",
userInput: "",
history: [],
});
state.previewCaseId = id;
renderCaseList();
renderPreviewCases();
openCaseModal(state.cases.length - 1);
}
/** 保存单份用例:没有 groupId 就新建一个只装它的组(盘上容器)。 */
async function saveCase(index) {
const item = state.cases[index];
if (!item || !state.presetId) return null;
try {
const data = await api("cases/save", {
groupId: item.groupId || "",
case: casePayload(item),
});
item.groupId = data.groupId;
Object.assign(item, casePayload(data.case));
renderCaseList();
renderPreviewCases();
return item;
} catch (e) {
err(e.message);
return null;
}
}
async function deleteCase(index) {
const item = state.cases[index];
if (!item) return;
if (!window.confirm(`删除用例「${item.name || `用例 ${index + 1}`}」?运行记录不会删。`)) return;
try {
if (item.groupId) {
await api("cases/delete", {
groupId: item.groupId,
caseId: item.id,
});
}
state.cases.splice(index, 1);
if (state.activeCaseIndex === index) state.activeCaseIndex = -1;
else if (state.activeCaseIndex > index) state.activeCaseIndex -= 1;
if (state.previewCaseId === item.id) state.previewCaseId = state.cases[0]?.id || "";
renderCaseList();
renderPreviewCases();
rememberCaseState();
closeDrawer();
scheduleRefresh(0);
notice("已删除用例");
} catch (e) {
err(e.message);
}
}
async function loadCases() {
// 用例是全局的,不分预设;预设只影响「上次打开的是哪条」这个记录
const data = await api("cases");
state.cases = (data.cases || []).map((entry) => ({
groupId: entry.groupId,
...casePayload(entry.case),
}));
let lab = null;
if (state.presetId) {
try {
lab = await api(`lab-state?presetId=${encodeURIComponent(state.presetId)}`);
} catch {
lab = null;
}
}
const wantedIndex = lab?.selectedCaseId
? state.cases.findIndex((item) => item.id === lab.selectedCaseId)
: -1;
state.activeCaseIndex = wantedIndex >= 0 ? wantedIndex : (state.cases.length > 0 ? 0 : -1);
const wantedPreview = lab?.previewCaseId;
state.previewCaseId = state.cases.some((item) => item.id === wantedPreview)
? wantedPreview
: (state.cases[0]?.id || "");
renderCaseList();
renderPreviewCases();
}
/* ---------- 模型与运行 ---------- */
/** 已选条:按住左右拖动即横向滚动;拖完那一下的 click 吃掉,不当成取消选择 */
function attachDragScroll(box) {
if (!box) return;
let startX = 0;
let startLeft = 0;
let dragging = false;
let moved = false;
box.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
dragging = true;
moved = false;
startX = event.clientX;
startLeft = box.scrollLeft;
});
// 不用 pointer capture:那会把 click 重定向到容器,chip 的取消点击就收不到了。
const onMove = (event) => {
if (!dragging) return;
const dx = event.clientX - startX;
if (!moved && Math.abs(dx) < 4) return;
moved = true;
box.dataset.dragging = "1";
box.scrollLeft = startLeft - dx;
event.preventDefault();
};
const onUp = () => {
if (!dragging) return;
dragging = false;
delete box.dataset.dragging;
if (!moved) return;
box.dataset.dragged = "1";
window.setTimeout(() => {
delete box.dataset.dragged;
}, 0);
};
window.addEventListener("pointermove", onMove, { passive: false });
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
// 捕获阶段先于 chip 的 onclick:拖完松手那下不触发删除
box.addEventListener("click", (event) => {
if (box.dataset.dragged !== "1") return;
event.preventDefault();
event.stopPropagation();
}, true);
}
/** 模型显示名:`配置名 · 模型名`(两者常常一样,名字短一点更像标签) */
function profileLabel(profile) {
return profile.model && profile.model !== profile.name
? `${profile.name} · ${profile.model}`
: profile.name;
}
function setModelMenu(open) {
const menu = $("model-picker-menu");
const trigger = $("model-picker-btn");
if (!menu || !trigger) return;
menu.hidden = !open;
trigger.setAttribute("aria-expanded", open ? "true" : "false");
}
/**
* 模型选择:一个下拉多选(勾选不关菜单)+ 右侧一条已选展示。
* 已选条只占一行,可横向拖动滚动,点一项取消,文字不可选(拖完的点击会吃掉)。
*/
function renderProfiles() {
const menu = $("model-picker-menu");
const picked = $("picked-models");
const count = $("model-picker-count");
if (!menu || !picked) return;
// 缺 Key / 已被删掉的配置不留在选中集里
const valid = new Set(state.profiles.filter((profile) => profile.hasKey).map((profile) => profile.id));
for (const id of [...state.selectedProfileIds]) {
if (!valid.has(id)) state.selectedProfileIds.delete(id);
}
const selected = state.profiles.filter((profile) => state.selectedProfileIds.has(profile.id));
if (count) count.textContent = selected.length ? ` ${selected.length}` : "";
// 菜单:多选,勾选后不关
menu.replaceChildren();
if (state.profiles.length === 0) {
menu.append(el("p", { class: "px-pick-empty", text: "没有可用的 API 配置,去设置页加一个。" }));
} else {
for (const profile of state.profiles) {
const active = state.selectedProfileIds.has(profile.id);
menu.append(el("button", {
type: "button",
class: "px-pick-item",
role: "option",
"aria-selected": active ? "true" : "false",
"data-active": active ? "1" : "0",
disabled: profile.hasKey ? null : "disabled",
title: profile.hasKey ? profileLabel(profile) : `${profile.name} 缺少 Key`,
onclick: () => {
if (state.selectedProfileIds.has(profile.id)) state.selectedProfileIds.delete(profile.id);
else state.selectedProfileIds.add(profile.id);
renderProfiles();
},
}, [
el("span", { class: "px-pick-check", text: active ? "✓" : "" }),
el("span", { class: "px-pick-name", text: profile.name }),
el("span", { class: "px-pick-model", text: profile.model }),
profile.hasKey ? null : el("span", { class: "px-pick-warn", text: "缺 Key" }),
]));
}
menu.append(el("div", { class: "px-pick-actions" }, [
el("button", {
type: "button",
class: "px-btn px-btn-mini",
text: "全选可用",
onclick: () => {
for (const profile of state.profiles) {
if (profile.hasKey) state.selectedProfileIds.add(profile.id);
}
renderProfiles();
},
}),
el("button", {
type: "button",
class: "px-btn px-btn-mini",
text: "清空",
onclick: () => {
state.selectedProfileIds.clear();
renderProfiles();
},
}),
]));
}
// 已选:只占一行,超出就横向拖
picked.replaceChildren();
if (selected.length === 0) {
picked.append(el("span", { class: "px-picked-empty", text: "未选模型" }));
return;
}
for (const profile of selected) {
picked.append(el("button", {
type: "button",
class: "px-picked-chip",
title: `点击取消:${profileLabel(profile)}`,
onclick: () => {
state.selectedProfileIds.delete(profile.id);
renderProfiles();
},
}, [
profileLabel(profile),
el("span", { class: "px-picked-x", "aria-hidden": "true", text: "×" }),
]));
}
}
async function runTests() {
if (!state.preset) return;
if (state.cases.length === 0) {
err("先添加一个用例。");
return;
}
const index = state.activeCaseIndex >= 0 ? state.activeCaseIndex : 0;
const item = state.cases[index];
if (!item) {
err("先在左侧选一个用例。");
return;
}
const profileIds = [...state.selectedProfileIds];
if (profileIds.length === 0) {
err("请先勾选至少一个模型。");
return;
}
const btn = $("btn-run");
setBusy(btn, true, "运行中…");
try {
const saved = await saveCase(index);
if (!saved) return;
const run = await api("run", {
presetId: state.presetId,
// 草稿运行不要冒用旧版本号,结果卡上要能看出这是哪一版。
presetVersionId: state.presetDirty ? "" : (state.tree?.currentId || ""),
groupId: saved.groupId,
caseIds: [saved.id],
profileIds,
preset: state.preset,
});
state.run = run;
await loadRuns(run.id);
notice(`运行完成:${run.results.length} 条`);
} catch (e) {
err(e.message);
} finally {
setBusy(btn, false);
}
}
function renderRunSelect() {
const select = $("run-select");
select.replaceChildren();
if (state.runs.length === 0) {
select.append(el("option", { value: "", text: "(暂无运行记录)" }));
return;
}
for (const run of state.runs) {
const when = run.createdAt.replace("T", " ").slice(5, 16);
const version = run.presetVersionId || "草稿";
select.append(el("option", {
value: run.id,
text: `${run.id} · ${when} · ${version} · ${run.results.length} 条`,
selected: state.run?.id === run.id,
}));
}
if (state.run) select.value = state.run.id;
}
async function loadRuns(selectId) {
try {
const data = await api(`runs?presetId=${encodeURIComponent(state.presetId)}`);
state.runs = data.runs || [];
const wanted = selectId || state.run?.id;
state.run = state.runs.find((run) => run.id === wanted) || state.runs[0] || null;
} catch {
state.runs = [];
state.run = null;
}
renderRunSelect();
renderResults();
renderRunEntry();
}
function relativeTime(iso) {
const then = new Date(iso).getTime();
if (!Number.isFinite(then)) return "刚刚";
const minutes = Math.max(0, Math.round((Date.now() - then) / 60000));
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
return `${Math.round(hours / 24)} 天前`;
}
function renderRunEntry() {
const btn = $("btn-run-entry");
const label = btn.querySelector(".px-btn-label");
const meta = $("run-entry-meta");
if (state.view === "run") {
label.textContent = "← 编辑";
meta.textContent = "";
return;
}
label.textContent = "试跑";
const run = state.runs[0];
if (!run) {
meta.textContent = "";
return;
}
const fails = run.results.filter((result) => result.verdict === "failure").length;
meta.textContent = ` · ${relativeTime(run.createdAt)} · ${run.results.length} 条${fails ? ` · ⚑${fails}` : ""}`;
}
function renderResults() {
const host = $("results");
host.replaceChildren();
const run = state.run;
if (!run) {
host.append(el("p", { class: "px-hint", text: "还没有运行记录。选好模型后点「运行」,结果会出现在这里。" }));
return;
}
const caseNames = new Map(state.cases.map((item) => [item.id, item.name]));
for (const result of run.results) {
const verdictRow = el("div", { class: "px-verdict" });
for (const [value, label] of [["success", "成功"], ["failure", "失败"], ["", "清除"]]) {
verdictRow.append(el("button", {
type: "button",
class: "px-btn px-btn-mini",
text: label,
"data-active": result.verdict === (value || null) ? "1" : "0",
onclick: () => setVerdict(result, value || null),
}));
}
const statusText = result.status === "done" ? "完成" : result.status === "error" ? "错误" : "运行中";
const duration = typeof result.durationMs === "number" ? ` · ${(result.durationMs / 1000).toFixed(1)}s` : "";
const card = el("div", {
class: "px-result",
"data-verdict": result.verdict || "",
}, [
el("div", { class: "px-result-head" }, [
el("b", { text: result.profileName }),
el("span", { class: "px-row-tag", text: result.caseName || caseNames.get(result.caseId) || result.caseId }),
el("span", { class: "px-row-tag", text: `${result.model} · ${statusText}${duration}` }),
verdictRow,
]),
]);
if (result.error) {
card.append(el("div", { class: "px-issue", "data-level": "error", text: result.error }));
} else if (result.content) {
card.append(el("div", { class: "px-result-body", text: result.content }));
} else {
card.append(el("p", { class: "px-hint", text: "(空回复)" }));
}
if (result.usage) {
card.append(el("p", { class: "px-hint", text:
`tokens:prompt ${result.usage.promptTokens} · completion ${result.usage.completionTokens} · total ${result.usage.totalTokens}`,
}));
}
const request = (run.requests || []).find((item) => item.caseId === result.caseId);
if (request) {
const box = el("div", { class: "px-messages" });
request.messages.forEach((message, index) => box.append(messageNode(message, index)));
card.append(el("details", { class: "px-details" }, [
el("summary", { text: `发送内容 · ${request.messages.length} 条` }),
box,
]));
}
host.append(card);
}
if ((run.issues || []).length > 0) {
const list = el("div", { class: "px-issue-list" });
for (const issue of run.issues) {
list.append(el("div", { class: "px-issue", "data-level": issue.level }, [
el("b", { text: issue.level === "error" ? "错误" : "提醒" }),
` ${issue.entryName ? issue.entryName + ":" : ""}${issue.message}`,
]));
}
host.append(el("details", { class: "px-details" }, [
el("summary", { text: `本次运行的结构提醒 · ${run.issues.length} 条` }),
list,
]));
}
}
async function setVerdict(result, verdict) {
try {
const run = await api("verdict", {
presetId: state.presetId,
runId: state.run.id,
profileId: result.profileId,
caseId: result.caseId,
verdict,
});
state.run = run;
const index = state.runs.findIndex((item) => item.id === run.id);
if (index >= 0) state.runs[index] = run;
renderResults();
renderRunEntry();
} catch (e) {
err(e.message);
}
}
/* ---------- 视图 / 抽屉 ---------- */
function setView(view) {
state.view = view;
$("view-edit").hidden = view !== "edit";
$("view-run").hidden = view !== "run";
renderRunEntry();
if (view === "run") {
renderProfiles();
renderCaseList();
renderRunSelect();
renderResults();
}
}
function openDrawer(name) {
state.drawer = name;
$("scrim").hidden = false;
$("version-drawer").hidden = name !== "version";
$("case-modal").hidden = name !== "case";
document.body.dataset.modal = name === "case" ? "on" : "off";
if (name === "version") renderVersions();
}
function closeDrawer() {
state.drawer = null;
closeVersionMenu();
$("scrim").hidden = true;
$("version-drawer").hidden = true;
$("case-modal").hidden = true;
document.body.dataset.modal = "off";
}
/* ---------- 载入与动作 ---------- */
function renderAll() {
renderIdbar();
renderOutline();
renderEditor();
renderVersions();
renderCaseList();
renderPreviewCases();
updateDirtyHint();
}
async function loadPreset(presetId) {
if (!presetId) return false;
if (!confirmDiscard()) return false;
const previousId = state.presetId;
state.presetId = presetId;
state.selectedVersionId = null;
state.run = null;
state.runs = [];
state.previewCaseId = "";
state.activeCaseIndex = -1;
state.selected = null;
state.messages = [];
state.issues = [];
$("preview-out").replaceChildren();
$("preview-meta").textContent = "";
$("results").replaceChildren();
renderIssues();
try {
const data = await api(`preset?presetId=${encodeURIComponent(presetId)}`);
const tree = await api(`versions?presetId=${encodeURIComponent(presetId)}`);
state.preset = data.preset;
state.tree = tree;
state.selectedVersionId = tree.currentId || tree.nodes[0]?.id || null;
if (tree.currentId) {
const node = tree.nodes.find((item) => item.id === tree.currentId);
if (node) state.preset = structuredClone(node.preset);
}
state.presetDirty = false;
state.selected = firstSelectable();
renderAll();
try {
await Promise.all([loadCases(), loadRuns()]);
} catch (e) {
// 预设本体已经载入;用例组坏掉只提示,不要把 select 退回去。
err(e.message);
}
await refreshAll();
return true;
} catch (e) {
state.presetId = previousId;
err(e.message);
return false;
}
}
async function createPreset() {
if (!confirmDiscard()) return;
// 不弹 prompt:桌面壳里 window.prompt 不可靠;建完直接在名称框里改。
const name = `新预设 ${state.presets.length + 1}`;
try {
const data = await api("presets/create", { name });
state.presetDirty = false;
await refreshPresetList(data.preset.id);
await loadPreset(data.preset.id);
notice("已新建预设,可直接改名称");
const input = $("preset-name");
input.focus();
input.select();
} catch (e) {
err(e.message);
}
}
async function applyPreset() {
if (!state.preset) return;
if (!window.confirm(`「应用到对话页」会覆盖 ${state.presetId}.json,继续?`)) return;
try {
const tree = await saveVersion();
if (!tree) return;
let applied = null;
try {
applied = await api("preset/apply", { preset: state.preset });
} catch (e) {
if (String(e.message).includes("结构检查") &&
window.confirm(`${e.message}\n\n仍要强制写回?`)) {
applied = await api("preset/apply", { preset: state.preset, force: true });
} else {
throw e;
}
}
await refreshPresetList(state.presetId);
notice(`已应用到对话页${applied?.activePresetId ? "并启用" : ""}`);
} catch (e) {
err(e.message);
}
}
/* ---------- 事件绑定 / 启动 ---------- */
function bindEvents() {
$("preset-select").onchange = async (e) => {
const ok = await loadPreset(e.target.value);
if (!ok) e.target.value = state.presetId;
};
$("btn-new-preset").onclick = createPreset;
$("btn-reload").onclick = () => loadPreset(state.presetId);
$("btn-apply").onclick = applyPreset;
$("preset-name").oninput = (e) => {
if (!state.preset) return;
state.preset.name = e.target.value;
const option = [...$("preset-select").options].find((item) => item.value === state.preset.id);
if (option) option.textContent = `${e.target.value || state.preset.id}(${state.preset.prompts.length})`;
markDirty();
};
$("btn-run-entry").onclick = () => setView(state.view === "run" ? "edit" : "run");
$("btn-versions").onclick = () => openDrawer("version");
$("btn-close-versions").onclick = closeDrawer;
$("btn-close-case").onclick = closeDrawer;
$("scrim").onclick = closeDrawer;
$("btn-preview").onclick = () => refreshAll();
$("preview-case").onchange = (e) => {
state.previewCaseId = e.target.value;
rememberCaseState();
void refreshAll();
};
$("btn-toggle-preview").onclick = () => {
state.previewTouched = true;
document.body.dataset.preview = document.body.dataset.preview === "off" ? "on" : "off";
renderPreviewToggle();
};
$("btn-save-version-current").onclick = () => saveVersionToCurrent();
$("btn-save-version").onclick = () => saveVersion();
$("btn-save-current").onclick = () => saveVersionToCurrent();
$("btn-save-new").onclick = () => saveVersion();
$("btn-load-version").onclick = loadSelectedVersion;
$("btn-fork-version").onclick = () => {
if (!state.selectedVersionId) {
err("先在版本树里选一个版本再分叉。");
return;
}
saveVersion(state.selectedVersionId);
};
$("model-picker-btn").onclick = (event) => {
event.stopPropagation();
setModelMenu($("model-picker-menu").hidden);
};
document.addEventListener("click", (event) => {
const picker = document.querySelector(".px-picker");
if (picker && picker.contains(event.target)) return;
setModelMenu(false);
});
attachDragScroll($("picked-models"));
$("btn-run").onclick = runTests;
$("btn-refresh-runs").onclick = () => loadRuns();
$("run-select").onchange = (e) => {
state.run = state.runs.find((run) => run.id === e.target.value) || null;
renderResults();
};
window.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
if (e.shiftKey) saveVersionToCurrent();
else saveVersion();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
void refreshAll();
return;
}
if (e.key === "Escape") {
setModelMenu(false);
if (document.querySelector(".px-ver-menu")) {
closeVersionMenu();
return;
}
closeDrawer();
}
});
window.addEventListener("resize", () => {
if (!state.previewTouched) {
document.body.dataset.preview = window.innerWidth > 1180 ? "on" : "off";
}
if (!state.outlineTouched && window.innerWidth > 860) {
document.body.dataset.outline = "off";
}
renderPreviewToggle();
});
window.addEventListener("beforeunload", (e) => {
if (!state.presetDirty) return;
e.preventDefault();
e.returnValue = "";
});
}
async function boot() {
// 窄窗默认收起预览 / 大纲,避免一进来就被浮层盖住。
if (window.innerWidth <= 1180) document.body.dataset.preview = "off";
if (window.innerWidth <= 860) document.body.dataset.outline = "off";
renderPreviewToggle();
bindEvents();
try {
const [presets, profiles] = await Promise.all([api("presets"), api("profiles")]);
state.presets = presets.presets || [];
state.profiles = profiles.profiles || [];
renderProfiles();
renderPresetOptions();
if (state.presets.length > 0) {
await loadPreset(state.presets[0].id);
} else {
renderAll();
err("还没有预设,点「+」建一份。");
}
} catch (e) {
err(e.message);
}
}
boot();