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
+
+
+
+
+
+
+
+
+
+
+
+
+
文风库
+
跨卡「怎么写」;对话 + 样本提取
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
用户偏好
+
+ 跨局硬约束条目的增删与 LLM 提取已迁到顶栏
+ 库 · 偏好库。
+ 创作期由「用户需求」节点从库选用写入本局,不再开玩时全量自动摊入。
+
+
+
0 = 关闭自动采集。默认 10。候选经审核卡收下后写入偏好库。
+
+
+
+