diff --git a/src/config/settings.ts b/src/config/settings.ts index f0745ff..add1c98 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -13,10 +13,15 @@ export type AppSettings = { * 更早的消息仍保留,但去掉 contextTrace 正文。默认 5;0 表示不存痕迹。 */ contextTraceKeepLatest?: number; + /** + * 游玩期每隔多少个用户回合尝试采集偏好候选。默认 10;0 = 关闭。 + */ + preferenceCollectEveryTurns?: number; }; const FILE_NAME = "settings.json"; const DEFAULT_CONTEXT_TRACE_KEEP = 5; +const DEFAULT_PREF_COLLECT_EVERY = 10; function settingsPath(): string { return path.join(getUserDataDir(), FILE_NAME); @@ -28,6 +33,7 @@ function defaultSettings(): AppSettings { activeProfileId: null, activePresetId: null, contextTraceKeepLatest: DEFAULT_CONTEXT_TRACE_KEEP, + preferenceCollectEveryTurns: DEFAULT_PREF_COLLECT_EVERY, }; } @@ -37,6 +43,12 @@ export function normalizeContextTraceKeepLatest(raw: unknown): number { return Math.min(50, Math.floor(n)); } +export function normalizePreferenceCollectEveryTurns(raw: unknown): number { + const n = typeof raw === "number" ? raw : Number(raw); + if (!Number.isFinite(n) || n < 0) return DEFAULT_PREF_COLLECT_EVERY; + return Math.min(100, Math.floor(n)); +} + export function loadAppSettings(): AppSettings { ensureUserDataDirs(); try { @@ -50,6 +62,9 @@ export function loadAppSettings(): AppSettings { contextTraceKeepLatest: normalizeContextTraceKeepLatest( parsed.contextTraceKeepLatest ?? DEFAULT_CONTEXT_TRACE_KEEP, ), + preferenceCollectEveryTurns: normalizePreferenceCollectEveryTurns( + parsed.preferenceCollectEveryTurns ?? DEFAULT_PREF_COLLECT_EVERY, + ), }; } catch { return defaultSettings(); diff --git a/src/server/settings-handlers.ts b/src/server/settings-handlers.ts index 409de49..8b95d71 100644 --- a/src/server/settings-handlers.ts +++ b/src/server/settings-handlers.ts @@ -15,6 +15,7 @@ import { ensureActiveProfileDefault, loadAppSettings, normalizeContextTraceKeepLatest, + normalizePreferenceCollectEveryTurns, saveAppSettings, setActivePresetId, setActiveProfileId, @@ -36,6 +37,7 @@ import { } from "../preset/store.js"; import type { GenerationParameters } from "../types/preset.js"; import { + createDefaultMainAgentLlm, createLlmForPreset, hasRealLlmConfig, } from "../runtime/llm-factory.js"; @@ -48,6 +50,24 @@ import { setActivePersonaId, updatePersona, } from "../persona/store.js"; +import { + createPreference, + deletePreference, + ensureStarterPreferences, + extractPreferenceTurn, + listPreferences, + updatePreference, + type PreferenceExtractCandidate, + type PreferenceExtractMessage, +} from "../preference/index.js"; +import { + createStylePack, + deleteStylePack, + extractStylePackTurn, + listStylePacks, + updateStylePack, + type StyleExtractMessage, +} from "../style-pack/index.js"; import { listDirectiveCatalog } from "../directives/expand.js"; import { sessionManager } from "./session-manager.js"; @@ -104,6 +124,7 @@ export async function handleSettingsApi( activeProfileId?: string | null; activePresetId?: string | null; contextTraceKeepLatest?: number; + preferenceCollectEveryTurns?: number; }; const settings = loadAppSettings(); if (body.activeProfileId !== undefined) { @@ -117,6 +138,11 @@ export async function handleSettingsApi( body.contextTraceKeepLatest, ); } + if (body.preferenceCollectEveryTurns !== undefined) { + settings.preferenceCollectEveryTurns = normalizePreferenceCollectEveryTurns( + body.preferenceCollectEveryTurns, + ); + } saveAppSettings(settings); json(res, 200, { settings }); return true; @@ -537,5 +563,182 @@ export async function handleSettingsApi( } } + if (pathname === "/api/preferences" && req.method === "GET") { + ensureStarterPreferences(); + json(res, 200, { + preferences: listPreferences(), + preferenceCollectEveryTurns: normalizePreferenceCollectEveryTurns( + loadAppSettings().preferenceCollectEveryTurns, + ), + }); + return true; + } + + if (pathname === "/api/preferences" && req.method === "POST") { + try { + const body = JSON.parse(await readBody(req)) as { + content?: string; + status?: "active" | "archived"; + }; + const entry = createPreference({ + content: body.content ?? "", + status: body.status, + }); + json(res, 201, { preference: entry, preferences: listPreferences() }); + } catch (e) { + json(res, 400, { + error: e instanceof Error ? e.message : String(e), + }); + } + return true; + } + + if (pathname === "/api/preferences/extract" && req.method === "POST") { + try { + if (!hasRealLlmConfig()) { + json(res, 400, { error: "未配置 API Key,无法提取偏好" }); + return true; + } + const body = JSON.parse(await readBody(req)) as { + messages?: PreferenceExtractMessage[]; + draftCandidates?: PreferenceExtractCandidate[]; + }; + const messages = Array.isArray(body.messages) ? body.messages : []; + const draftCandidates = Array.isArray(body.draftCandidates) + ? body.draftCandidates + : []; + const llm = createDefaultMainAgentLlm(); + const result = await extractPreferenceTurn({ + llm, + messages, + draftCandidates, + }); + json(res, 200, result); + } catch (e) { + json(res, 400, { + error: e instanceof Error ? e.message : String(e), + }); + } + return true; + } + + const prefMatch = pathname.match(/^\/api\/preferences\/([^/]+)$/); + if (prefMatch) { + const id = decodeURIComponent(prefMatch[1]); + if (req.method === "PUT") { + try { + const body = JSON.parse(await readBody(req)) as { + content?: string; + status?: "active" | "archived"; + }; + const preference = updatePreference(id, body); + json(res, 200, { preference, preferences: listPreferences() }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + json(res, msg.includes("不存在") ? 404 : 400, { error: msg }); + } + return true; + } + if (req.method === "DELETE") { + try { + deletePreference(id); + json(res, 200, { ok: true, preferences: listPreferences() }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + json(res, msg.includes("不存在") ? 404 : 400, { error: msg }); + } + return true; + } + } + + if (pathname === "/api/style-packs" && req.method === "GET") { + json(res, 200, { stylePacks: listStylePacks() }); + return true; + } + + if (pathname === "/api/style-packs" && req.method === "POST") { + try { + const body = JSON.parse(await readBody(req)) as { + name?: string; + content?: string; + samples?: string; + status?: "active" | "archived"; + }; + const entry = createStylePack({ + name: body.name ?? "", + content: body.content ?? "", + samples: body.samples, + status: body.status, + }); + json(res, 201, { stylePack: entry, stylePacks: listStylePacks() }); + } catch (e) { + json(res, 400, { + error: e instanceof Error ? e.message : String(e), + }); + } + return true; + } + + if (pathname === "/api/style-packs/extract" && req.method === "POST") { + try { + if (!hasRealLlmConfig()) { + json(res, 400, { error: "未配置 API Key,无法提取文风" }); + return true; + } + const body = JSON.parse(await readBody(req)) as { + messages?: StyleExtractMessage[]; + samples?: string; + draftName?: string; + draftContent?: string; + }; + const messages = Array.isArray(body.messages) ? body.messages : []; + const llm = createDefaultMainAgentLlm(); + const result = await extractStylePackTurn({ + llm, + messages, + samples: body.samples, + draftName: body.draftName, + draftContent: body.draftContent, + }); + json(res, 200, result); + } catch (e) { + json(res, 400, { + error: e instanceof Error ? e.message : String(e), + }); + } + return true; + } + + const styleMatch = pathname.match(/^\/api\/style-packs\/([^/]+)$/); + if (styleMatch) { + const id = decodeURIComponent(styleMatch[1]); + if (req.method === "PUT") { + try { + const body = JSON.parse(await readBody(req)) as { + name?: string; + content?: string; + samples?: string | null; + status?: "active" | "archived"; + }; + const stylePack = updateStylePack(id, body); + json(res, 200, { stylePack, stylePacks: listStylePacks() }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + json(res, msg.includes("不存在") ? 404 : 400, { error: msg }); + } + return true; + } + if (req.method === "DELETE") { + try { + deleteStylePack(id); + json(res, 200, { ok: true, stylePacks: listStylePacks() }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + json(res, msg.includes("不存在") ? 404 : 400, { error: msg }); + } + return true; + } + } + return false; } diff --git a/web/libraries.css b/web/libraries.css new file mode 100644 index 0000000..7b769ef --- /dev/null +++ b/web/libraries.css @@ -0,0 +1,129 @@ +/* 库页:列表 + 提取工作台 */ + +.lib-shell { + grid-template-columns: var(--st-rail-w) minmax(0, 1fr); +} + +.lib-body { + display: grid; + grid-template-columns: minmax(240px, 340px) minmax(0, 1fr); + gap: 16px; + align-items: start; + padding: 0 4px 24px; +} + +.lib-list-pane, +.lib-work-pane { + min-width: 0; +} + +.lib-work-card { + background: var(--st-surface); + border: 1px solid var(--st-border); + border-radius: var(--st-radius); + padding: 16px 18px 20px; +} + +.lib-work-card h3 { + margin: 0 0 6px; + font-size: 15px; +} + +.lib-work-card h4 { + margin: 16px 0 8px; + font-size: 13px; + color: var(--st-muted); + font-weight: 600; +} + +.lib-chat { + max-height: 280px; + overflow: auto; + border: 1px solid var(--st-border); + border-radius: 6px; + padding: 10px 12px; + background: #fafafa; + margin: 12px 0; + font-size: 13px; +} + +.lib-chat-empty { + color: var(--st-faint); + margin: 0; +} + +.lib-msg { + margin: 0 0 10px; +} + +.lib-msg:last-child { + margin-bottom: 0; +} + +.lib-msg-role { + font-size: 11px; + font-weight: 600; + color: var(--st-muted); + margin-bottom: 2px; +} + +.lib-msg-body { + white-space: pre-wrap; + word-break: break-word; +} + +.lib-composer textarea, +.lib-draft textarea, +.lib-samples-field textarea { + width: 100%; + resize: vertical; + border: 1px solid var(--st-border); + border-radius: 6px; + padding: 8px 10px; +} + +.lib-composer-actions, +.storage-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 8px; +} + +.lib-ready-hint { + font-size: 12px; + color: var(--st-muted); +} + +.lib-candidates { + display: flex; + flex-direction: column; + gap: 8px; + margin: 8px 0 4px; +} + +.lib-candidate { + border: 1px solid var(--st-border); + border-radius: 6px; + padding: 8px 10px; + background: #fafafa; +} + +.lib-candidate-actions { + display: flex; + gap: 6px; + margin-top: 6px; +} + +.lib-candidate.is-ready { + border-color: #86efac; + background: #f0fdf4; +} + +@media (max-width: 900px) { + .lib-body { + grid-template-columns: 1fr; + } +} + diff --git a/web/libraries.html b/web/libraries.html new file mode 100644 index 0000000..514c614 --- /dev/null +++ b/web/libraries.html @@ -0,0 +1,123 @@ + + + + + + 库 · Writing Agent + + + + +
+ Writing Agent + +
+ +
+ + +
+
+
+

文风库

+

跨卡「怎么写」;对话 + 样本提取

+
+
+ +
+
+ + + +
+
+
+
+ +
+
+

用 LLM 提取文风

+

+ 描述想要的写法,或贴样本;助手会整理成可入库草稿。点「入库」写入列表。 +

+ +
+
+ +
+ + +
+
+ +
+
+
+
+
+ + +
+

新建

+ + + +
+ + +
+
+
+ + + + + diff --git a/web/libraries.js b/web/libraries.js new file mode 100644 index 0000000..facb661 --- /dev/null +++ b/web/libraries.js @@ -0,0 +1,458 @@ +/** + * 全局库页:文风库 + 偏好库(列表 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)); + diff --git a/web/modules.html b/web/modules.html index 31d81d3..c9d103f 100644 --- a/web/modules.html +++ b/web/modules.html @@ -23,6 +23,7 @@ 对话 技能 呈现壳 + 库 Token 设置 diff --git a/web/settings.html b/web/settings.html index 13ecf96..59244f1 100644 --- a/web/settings.html +++ b/web/settings.html @@ -13,6 +13,7 @@ 对话 技能 呈现壳 + 库 Token 设置 @@ -23,6 +24,7 @@

设置

+

保存在本机 ~/.writing-agent/,明文、不同步。

@@ -58,6 +60,33 @@
+ +