改预设要能一边写一边看装出来的 messages,还要多模型对照,所以把编辑与试跑拆成两个视图塞进一个页面;版本存完整快照、可分叉,用例全局共用一份、运行记录按预设存。顺带让读预设文件先剥 BOM,静态资源改成不缓存,免得改了脚本还要强刷。
2456 lines
78 KiB
JavaScript
2456 lines
78 KiB
JavaScript
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();
|