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