/** * 全局库页:文风库 + 偏好库(列表 CRUD + LLM 提取) */ const state = { tab: "style", // style | preferences stylePacks: [], preferences: [], messages: [], /** @type {{ name: string, content: string, ready: boolean } | null} */ styleDraft: null, /** @type {Array<{ content: string, ready: boolean }>} */ prefCandidates: [], editingId: null, busy: false, }; const $ = (id) => document.getElementById(id); const panelTitleEl = $("panel-title"); const panelSubtitleEl = $("panel-subtitle"); const entryListEl = $("entry-list"); const toastEl = $("lib-toast"); const extractTitleEl = $("extract-title"); const extractHelpEl = $("extract-help"); const samplesFieldEl = $("samples-field"); const extractSamplesEl = $("extract-samples"); const extractChatEl = $("extract-chat"); const extractInputEl = $("extract-input"); const draftPanelEl = $("draft-panel"); const draftNameFieldEl = $("draft-name-field"); const draftNameEl = $("draft-name"); const draftContentEl = $("draft-content"); const prefCandidatesEl = $("pref-candidates"); const btnSaveDraftEl = $("btn-save-draft"); const draftReadyHintEl = $("draft-ready-hint"); const entryDialog = $("entry-dialog"); const entryForm = $("entry-form"); const entryDialogTitle = $("entry-dialog-title"); const entryNameWrap = $("entry-name-wrap"); const entryNameEl = $("entry-name"); const entryContentEl = $("entry-content"); const entryArchivedEl = $("entry-archived"); function escapeHtml(s) { return String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } async function api(path, opts = {}) { const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...opts, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || res.statusText || "请求失败"); return data; } function showToast(msg, isError = false) { if (!toastEl) return; toastEl.hidden = false; toastEl.textContent = msg; toastEl.classList.toggle("is-error", Boolean(isError)); clearTimeout(showToast._t); showToast._t = setTimeout(() => { toastEl.hidden = true; }, 3200); } function tabFromUrl() { const q = new URLSearchParams(location.search).get("tab"); if (q === "preferences" || q === "preference") return "preferences"; return "style"; } function setTab(tab, pushUrl = true) { state.tab = tab === "preferences" ? "preferences" : "style"; document.querySelectorAll(".st-rail-item[data-tab]").forEach((btn) => { const on = btn.dataset.tab === state.tab; btn.classList.toggle("active", on); }); const isStyle = state.tab === "style"; panelTitleEl.textContent = isStyle ? "文风库" : "偏好库"; panelSubtitleEl.textContent = isStyle ? "跨卡「怎么写」;对话 + 样本提取 → 叙事指南选用" : "跨卡硬约束;由绑定偏好库的能力按需选用"; extractTitleEl.textContent = isStyle ? "用 LLM 提取文风" : "用 LLM 提取偏好"; extractHelpEl.textContent = isStyle ? "描述想要的写法,或贴样本;助手整理成可入库草稿。点「入库」写入列表。" : "描述禁区、纠偏、默认取向;助手拆成短条目。可逐条收下入库。"; samplesFieldEl.hidden = !isStyle; draftNameFieldEl.hidden = !isStyle; extractInputEl.placeholder = isStyle ? "说说文风要求,或回答助手的追问…" : "说说偏好/禁区要求,或回答助手的追问…"; resetExtract(false); if (pushUrl) { const url = new URL(location.href); url.searchParams.set("tab", state.tab); history.replaceState(null, "", url); } renderList(); } function resetExtract(clearSamples = true) { state.messages = []; state.styleDraft = null; state.prefCandidates = []; if (clearSamples) extractSamplesEl.value = ""; extractInputEl.value = ""; draftNameEl.value = ""; draftContentEl.value = ""; renderChat(); renderDraft(); } function renderChat() { if (!state.messages.length) { extractChatEl.innerHTML = `

还没有对话。先发一句要求。

`; return; } extractChatEl.innerHTML = state.messages .map( (m) => `
${m.role === "user" ? "你" : "助手"}
${escapeHtml(m.content)}
`, ) .join(""); extractChatEl.scrollTop = extractChatEl.scrollHeight; } function renderDraft() { const isStyle = state.tab === "style"; if (isStyle) { prefCandidatesEl.hidden = true; prefCandidatesEl.innerHTML = ""; btnSaveDraftEl.hidden = false; draftContentEl.closest("label")?.removeAttribute("hidden"); const d = state.styleDraft; if (!d || (!d.name && !d.content)) { draftPanelEl.hidden = true; return; } draftPanelEl.hidden = false; draftNameEl.value = d.name || ""; draftContentEl.value = d.content || ""; btnSaveDraftEl.disabled = !d.ready || !d.content.trim() || !d.name.trim(); draftReadyHintEl.textContent = d.ready ? "草稿已够入库" : "继续对话补齐遣词/示范/禁忌后再入库"; return; } draftNameFieldEl.hidden = true; const cands = state.prefCandidates; if (!cands.length) { draftPanelEl.hidden = true; return; } draftPanelEl.hidden = false; draftContentEl.closest("label")?.setAttribute("hidden", ""); draftContentEl.value = ""; btnSaveDraftEl.hidden = true; draftReadyHintEl.textContent = "下方逐条收下入库"; prefCandidatesEl.hidden = false; prefCandidatesEl.innerHTML = cands .map( (c, i) => `
${escapeHtml(c.content)}
`, ) .join(""); } function renderList() { const isStyle = state.tab === "style"; const items = isStyle ? state.stylePacks : state.preferences; if (!items.length) { entryListEl.innerHTML = `

${ isStyle ? "还没有文风包。用手写新建或右侧提取。" : "还没有偏好条目。用手写新建或右侧提取。" }

`; return; } entryListEl.innerHTML = ""; for (const p of items) { const archived = p.status === "archived"; const title = isStyle ? p.name || "未命名" : "偏好"; const text = (p.content || "").trim(); const card = document.createElement("div"); card.className = `config-card${archived ? "" : " is-active"}`; card.innerHTML = `
${ archived ? `已归档 ` : "" }${escapeHtml(title)}
${ text ? escapeHtml(text.slice(0, 140)) + (text.length > 140 ? "…" : "") : "(空)" }
`; entryListEl.appendChild(card); } } async function loadAll() { const [styles, prefs] = await Promise.all([ api("/api/style-packs"), api("/api/preferences"), ]); state.stylePacks = styles.stylePacks ?? []; state.preferences = prefs.preferences ?? []; renderList(); } function openEntryDialog(entry = null) { const isStyle = state.tab === "style"; state.editingId = entry?.id ?? null; entryDialogTitle.textContent = entry ? isStyle ? "编辑文风" : "编辑偏好" : isStyle ? "新建文风" : "新建偏好"; entryNameWrap.hidden = !isStyle; entryNameEl.required = isStyle; entryNameEl.value = isStyle ? entry?.name ?? "" : ""; entryContentEl.value = entry?.content ?? ""; entryArchivedEl.checked = entry?.status === "archived"; entryDialog.showModal(); } async function saveEntry() { const isStyle = state.tab === "style"; const status = entryArchivedEl.checked ? "archived" : "active"; if (isStyle) { const payload = { name: entryNameEl.value, content: entryContentEl.value, status, }; if (state.editingId) { await api(`/api/style-packs/${encodeURIComponent(state.editingId)}`, { method: "PUT", body: JSON.stringify(payload), }); } else { await api("/api/style-packs", { method: "POST", body: JSON.stringify(payload), }); } } else { const payload = { content: entryContentEl.value, status }; if (state.editingId) { await api(`/api/preferences/${encodeURIComponent(state.editingId)}`, { method: "PUT", body: JSON.stringify(payload), }); } else { await api("/api/preferences", { method: "POST", body: JSON.stringify(payload), }); } } entryDialog.close(); await loadAll(); showToast("已保存"); } async function sendExtract() { if (state.busy) return; const text = extractInputEl.value.trim(); if (!text) { showToast("请先输入内容", true); return; } state.messages.push({ role: "user", content: text }); extractInputEl.value = ""; renderChat(); state.busy = true; $("btn-extract-send").disabled = true; try { if (state.tab === "style") { const result = await api("/api/style-packs/extract", { method: "POST", body: JSON.stringify({ messages: state.messages, samples: extractSamplesEl.value, draftName: state.styleDraft?.name ?? "", draftContent: state.styleDraft?.content ?? "", }), }); state.messages.push({ role: "assistant", content: result.reply || "…" }); state.styleDraft = result.draft ?? { name: "", content: "", ready: false }; btnSaveDraftEl.hidden = false; } else { const result = await api("/api/preferences/extract", { method: "POST", body: JSON.stringify({ messages: state.messages, draftCandidates: state.prefCandidates, }), }); state.messages.push({ role: "assistant", content: result.reply || "…" }); state.prefCandidates = Array.isArray(result.candidates) ? result.candidates : []; } renderChat(); renderDraft(); } catch (err) { state.messages.pop(); renderChat(); showToast(err.message || String(err), true); } finally { state.busy = false; $("btn-extract-send").disabled = false; } } async function saveStyleDraft() { const name = draftNameEl.value.trim(); const content = draftContentEl.value.trim(); if (!name || !content) { showToast("名称与正文不能为空", true); return; } await api("/api/style-packs", { method: "POST", body: JSON.stringify({ name, content, samples: extractSamplesEl.value.trim() || undefined, status: "active", }), }); await loadAll(); showToast("文风已入库"); resetExtract(false); } document.querySelectorAll(".st-rail-item[data-tab]").forEach((btn) => { btn.addEventListener("click", () => setTab(btn.dataset.tab)); }); $("btn-new-entry")?.addEventListener("click", () => openEntryDialog()); $("entry-cancel")?.addEventListener("click", () => entryDialog.close()); entryForm?.addEventListener("submit", async (e) => { e.preventDefault(); try { await saveEntry(); } catch (err) { showToast(err.message, true); } }); $("btn-extract-send")?.addEventListener("click", () => { sendExtract().catch((err) => showToast(err.message, true)); }); $("btn-extract-reset")?.addEventListener("click", () => resetExtract(true)); $("btn-save-draft")?.addEventListener("click", () => { saveStyleDraft().catch((err) => showToast(err.message, true)); }); draftNameEl?.addEventListener("input", () => { if (state.styleDraft) state.styleDraft.name = draftNameEl.value; }); draftContentEl?.addEventListener("input", () => { if (state.styleDraft) state.styleDraft.content = draftContentEl.value; }); prefCandidatesEl?.addEventListener("click", async (e) => { const btn = e.target.closest("[data-action=accept-cand]"); if (!btn) return; const i = Number(btn.dataset.i); const c = state.prefCandidates[i]; if (!c?.content?.trim()) return; try { await api("/api/preferences", { method: "POST", body: JSON.stringify({ content: c.content, status: "active" }), }); state.prefCandidates.splice(i, 1); renderDraft(); await loadAll(); showToast("偏好已入库"); } catch (err) { showToast(err.message, true); } }); entryListEl?.addEventListener("click", async (e) => { const btn = e.target.closest("[data-action]"); if (!btn) return; const id = btn.dataset.id; const action = btn.dataset.action; const isStyle = state.tab === "style"; const items = isStyle ? state.stylePacks : state.preferences; const entry = items.find((x) => x.id === id); try { if (action === "edit") { openEntryDialog(entry); } else if (action === "toggle") { const status = btn.dataset.status === "archived" ? "archived" : "active"; const path = isStyle ? `/api/style-packs/${encodeURIComponent(id)}` : `/api/preferences/${encodeURIComponent(id)}`; await api(path, { method: "PUT", body: JSON.stringify({ status }) }); await loadAll(); showToast(status === "archived" ? "已归档" : "已启用"); } else if (action === "delete") { if (!confirm(isStyle ? "删除这个文风包?" : "删除这条偏好?")) return; const path = isStyle ? `/api/style-packs/${encodeURIComponent(id)}` : `/api/preferences/${encodeURIComponent(id)}`; await api(path, { method: "DELETE" }); await loadAll(); showToast("已删除"); } } catch (err) { showToast(err.message, true); } }); extractInputEl?.addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendExtract().catch((err) => showToast(err.message, true)); } }); setTab(tabFromUrl(), false); loadAll().catch((err) => showToast(err.message, true));