接通偏好与文风库的接口与库页,并把采集间隔放进设置。
让用户能手写、提取入库,后续节点绑定才有内容可注入。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,10 +13,15 @@ export type AppSettings = {
|
|||||||
* 更早的消息仍保留,但去掉 contextTrace 正文。默认 5;0 表示不存痕迹。
|
* 更早的消息仍保留,但去掉 contextTrace 正文。默认 5;0 表示不存痕迹。
|
||||||
*/
|
*/
|
||||||
contextTraceKeepLatest?: number;
|
contextTraceKeepLatest?: number;
|
||||||
|
/**
|
||||||
|
* 游玩期每隔多少个用户回合尝试采集偏好候选。默认 10;0 = 关闭。
|
||||||
|
*/
|
||||||
|
preferenceCollectEveryTurns?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FILE_NAME = "settings.json";
|
const FILE_NAME = "settings.json";
|
||||||
const DEFAULT_CONTEXT_TRACE_KEEP = 5;
|
const DEFAULT_CONTEXT_TRACE_KEEP = 5;
|
||||||
|
const DEFAULT_PREF_COLLECT_EVERY = 10;
|
||||||
|
|
||||||
function settingsPath(): string {
|
function settingsPath(): string {
|
||||||
return path.join(getUserDataDir(), FILE_NAME);
|
return path.join(getUserDataDir(), FILE_NAME);
|
||||||
@@ -28,6 +33,7 @@ function defaultSettings(): AppSettings {
|
|||||||
activeProfileId: null,
|
activeProfileId: null,
|
||||||
activePresetId: null,
|
activePresetId: null,
|
||||||
contextTraceKeepLatest: DEFAULT_CONTEXT_TRACE_KEEP,
|
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));
|
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 {
|
export function loadAppSettings(): AppSettings {
|
||||||
ensureUserDataDirs();
|
ensureUserDataDirs();
|
||||||
try {
|
try {
|
||||||
@@ -50,6 +62,9 @@ export function loadAppSettings(): AppSettings {
|
|||||||
contextTraceKeepLatest: normalizeContextTraceKeepLatest(
|
contextTraceKeepLatest: normalizeContextTraceKeepLatest(
|
||||||
parsed.contextTraceKeepLatest ?? DEFAULT_CONTEXT_TRACE_KEEP,
|
parsed.contextTraceKeepLatest ?? DEFAULT_CONTEXT_TRACE_KEEP,
|
||||||
),
|
),
|
||||||
|
preferenceCollectEveryTurns: normalizePreferenceCollectEveryTurns(
|
||||||
|
parsed.preferenceCollectEveryTurns ?? DEFAULT_PREF_COLLECT_EVERY,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return defaultSettings();
|
return defaultSettings();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ensureActiveProfileDefault,
|
ensureActiveProfileDefault,
|
||||||
loadAppSettings,
|
loadAppSettings,
|
||||||
normalizeContextTraceKeepLatest,
|
normalizeContextTraceKeepLatest,
|
||||||
|
normalizePreferenceCollectEveryTurns,
|
||||||
saveAppSettings,
|
saveAppSettings,
|
||||||
setActivePresetId,
|
setActivePresetId,
|
||||||
setActiveProfileId,
|
setActiveProfileId,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
} from "../preset/store.js";
|
} from "../preset/store.js";
|
||||||
import type { GenerationParameters } from "../types/preset.js";
|
import type { GenerationParameters } from "../types/preset.js";
|
||||||
import {
|
import {
|
||||||
|
createDefaultMainAgentLlm,
|
||||||
createLlmForPreset,
|
createLlmForPreset,
|
||||||
hasRealLlmConfig,
|
hasRealLlmConfig,
|
||||||
} from "../runtime/llm-factory.js";
|
} from "../runtime/llm-factory.js";
|
||||||
@@ -48,6 +50,24 @@ import {
|
|||||||
setActivePersonaId,
|
setActivePersonaId,
|
||||||
updatePersona,
|
updatePersona,
|
||||||
} from "../persona/store.js";
|
} 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 { listDirectiveCatalog } from "../directives/expand.js";
|
||||||
import { sessionManager } from "./session-manager.js";
|
import { sessionManager } from "./session-manager.js";
|
||||||
|
|
||||||
@@ -104,6 +124,7 @@ export async function handleSettingsApi(
|
|||||||
activeProfileId?: string | null;
|
activeProfileId?: string | null;
|
||||||
activePresetId?: string | null;
|
activePresetId?: string | null;
|
||||||
contextTraceKeepLatest?: number;
|
contextTraceKeepLatest?: number;
|
||||||
|
preferenceCollectEveryTurns?: number;
|
||||||
};
|
};
|
||||||
const settings = loadAppSettings();
|
const settings = loadAppSettings();
|
||||||
if (body.activeProfileId !== undefined) {
|
if (body.activeProfileId !== undefined) {
|
||||||
@@ -117,6 +138,11 @@ export async function handleSettingsApi(
|
|||||||
body.contextTraceKeepLatest,
|
body.contextTraceKeepLatest,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (body.preferenceCollectEveryTurns !== undefined) {
|
||||||
|
settings.preferenceCollectEveryTurns = normalizePreferenceCollectEveryTurns(
|
||||||
|
body.preferenceCollectEveryTurns,
|
||||||
|
);
|
||||||
|
}
|
||||||
saveAppSettings(settings);
|
saveAppSettings(settings);
|
||||||
json(res, 200, { settings });
|
json(res, 200, { settings });
|
||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>库 · Writing Agent</title>
|
||||||
|
<link rel="stylesheet" href="/settings.css" />
|
||||||
|
<link rel="stylesheet" href="/libraries.css" />
|
||||||
|
</head>
|
||||||
|
<body class="settings-page libraries-page">
|
||||||
|
<header class="st-top">
|
||||||
|
<a class="st-brand" href="/">Writing Agent</a>
|
||||||
|
<nav class="st-nav" aria-label="主导航">
|
||||||
|
<a href="/">对话</a>
|
||||||
|
<a href="/modules.html">技能</a>
|
||||||
|
<a href="/shells.html">呈现壳</a>
|
||||||
|
<a href="/libraries.html" aria-current="page">库</a>
|
||||||
|
<a href="/stats.html">Token</a>
|
||||||
|
<a href="/settings.html">设置</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="st-shell lib-shell">
|
||||||
|
<aside class="st-rail" aria-label="库分类">
|
||||||
|
<p class="st-rail-label">全局库</p>
|
||||||
|
<button type="button" class="st-rail-item active" data-tab="style">文风库</button>
|
||||||
|
<button type="button" class="st-rail-item" data-tab="preferences">偏好库</button>
|
||||||
|
<p class="st-rail-note">
|
||||||
|
文风绑叙事指南;偏好绑「用户需求」。存
|
||||||
|
<code>~/.writing-agent/</code>
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="st-main lib-main">
|
||||||
|
<header class="st-main-head">
|
||||||
|
<div>
|
||||||
|
<h1 id="panel-title">文风库</h1>
|
||||||
|
<p class="st-sub" id="panel-subtitle">跨卡「怎么写」;对话 + 样本提取</p>
|
||||||
|
</div>
|
||||||
|
<div class="st-main-actions">
|
||||||
|
<button type="button" class="btn-secondary" id="btn-new-entry">手写新建</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="settings-toast" id="lib-toast" hidden></div>
|
||||||
|
|
||||||
|
<div class="lib-body">
|
||||||
|
<section class="lib-list-pane" aria-label="条目列表">
|
||||||
|
<div id="entry-list" class="card-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="lib-work-pane" aria-label="提取工作台">
|
||||||
|
<div class="lib-work-card" id="extract-panel">
|
||||||
|
<h3 id="extract-title">用 LLM 提取文风</h3>
|
||||||
|
<p class="settings-help" id="extract-help">
|
||||||
|
描述想要的写法,或贴样本;助手会整理成可入库草稿。点「入库」写入列表。
|
||||||
|
</p>
|
||||||
|
<label class="field lib-samples-field" id="samples-field">
|
||||||
|
样本(可选)
|
||||||
|
<textarea id="extract-samples" rows="4" placeholder="贴一段你想模仿的正文或对话…"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="lib-chat" id="extract-chat" aria-live="polite"></div>
|
||||||
|
<div class="lib-composer">
|
||||||
|
<textarea
|
||||||
|
id="extract-input"
|
||||||
|
rows="3"
|
||||||
|
placeholder="说说文风要求,或回答助手的追问…"
|
||||||
|
></textarea>
|
||||||
|
<div class="lib-composer-actions">
|
||||||
|
<button type="button" class="btn-primary" id="btn-extract-send">发送</button>
|
||||||
|
<button type="button" class="btn-secondary" id="btn-extract-reset">清空对话</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lib-draft" id="draft-panel" hidden>
|
||||||
|
<h4>草稿</h4>
|
||||||
|
<label class="field" id="draft-name-field">
|
||||||
|
名称
|
||||||
|
<input type="text" id="draft-name" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
正文
|
||||||
|
<textarea id="draft-content" rows="8"></textarea>
|
||||||
|
</label>
|
||||||
|
<div id="pref-candidates" class="lib-candidates" hidden></div>
|
||||||
|
<div class="storage-actions">
|
||||||
|
<button type="button" class="btn-primary" id="btn-save-draft" disabled>
|
||||||
|
入库
|
||||||
|
</button>
|
||||||
|
<span class="lib-ready-hint" id="draft-ready-hint"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="entry-dialog" class="profile-dialog">
|
||||||
|
<form id="entry-form" method="dialog">
|
||||||
|
<h3 id="entry-dialog-title">新建</h3>
|
||||||
|
<label class="field" id="entry-name-wrap">
|
||||||
|
名称
|
||||||
|
<input type="text" name="name" id="entry-name" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
正文
|
||||||
|
<textarea name="content" id="entry-content" rows="8" required></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="check-label">
|
||||||
|
<input type="checkbox" name="archived" id="entry-archived" />
|
||||||
|
<span>归档(节点注入时跳过)</span>
|
||||||
|
</label>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" id="entry-cancel" class="btn-secondary">取消</button>
|
||||||
|
<button type="submit" class="btn-primary">保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script type="module" src="/libraries.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -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, ">")
|
||||||
|
.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 = `<p class="lib-chat-empty">还没有对话。先发一句要求。</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
extractChatEl.innerHTML = state.messages
|
||||||
|
.map(
|
||||||
|
(m) => `<div class="lib-msg">
|
||||||
|
<div class="lib-msg-role">${m.role === "user" ? "你" : "助手"}</div>
|
||||||
|
<div class="lib-msg-body">${escapeHtml(m.content)}</div>
|
||||||
|
</div>`,
|
||||||
|
)
|
||||||
|
.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) => `<div class="lib-candidate${c.ready ? " is-ready" : ""}" data-i="${i}">
|
||||||
|
<div class="lib-msg-body">${escapeHtml(c.content)}</div>
|
||||||
|
<div class="lib-candidate-actions">
|
||||||
|
<button type="button" class="btn-primary" data-action="accept-cand" data-i="${i}" ${
|
||||||
|
c.ready && c.content.trim() ? "" : "disabled"
|
||||||
|
}>收下</button>
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const isStyle = state.tab === "style";
|
||||||
|
const items = isStyle ? state.stylePacks : state.preferences;
|
||||||
|
if (!items.length) {
|
||||||
|
entryListEl.innerHTML = `<p class="empty-hint">${
|
||||||
|
isStyle ? "还没有文风包。用手写新建或右侧提取。" : "还没有偏好条目。用手写新建或右侧提取。"
|
||||||
|
}</p>`;
|
||||||
|
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 = `
|
||||||
|
<div class="config-card-head">
|
||||||
|
<div>
|
||||||
|
<div class="config-card-title">${
|
||||||
|
archived ? `<span class="badge">已归档</span> ` : ""
|
||||||
|
}${escapeHtml(title)}</div>
|
||||||
|
<div class="config-card-meta">${
|
||||||
|
text
|
||||||
|
? escapeHtml(text.slice(0, 140)) + (text.length > 140 ? "…" : "")
|
||||||
|
: "(空)"
|
||||||
|
}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-card-actions">
|
||||||
|
<button type="button" class="btn-secondary" data-action="edit" data-id="${escapeHtml(p.id)}">编辑</button>
|
||||||
|
<button type="button" class="btn-secondary" data-action="toggle" data-id="${escapeHtml(p.id)}" data-status="${
|
||||||
|
archived ? "active" : "archived"
|
||||||
|
}">${archived ? "启用" : "归档"}</button>
|
||||||
|
<button type="button" class="btn-secondary danger-outline" data-action="delete" data-id="${escapeHtml(
|
||||||
|
p.id,
|
||||||
|
)}">删除</button>
|
||||||
|
</div>`;
|
||||||
|
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));
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
<a href="/">对话</a>
|
<a href="/">对话</a>
|
||||||
<a href="/modules.html" aria-current="page">技能</a>
|
<a href="/modules.html" aria-current="page">技能</a>
|
||||||
<a href="/shells.html">呈现壳</a>
|
<a href="/shells.html">呈现壳</a>
|
||||||
|
<a href="/libraries.html">库</a>
|
||||||
<a href="/stats.html">Token</a>
|
<a href="/stats.html">Token</a>
|
||||||
<a href="/settings.html">设置</a>
|
<a href="/settings.html">设置</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<a href="/">对话</a>
|
<a href="/">对话</a>
|
||||||
<a href="/modules.html">技能</a>
|
<a href="/modules.html">技能</a>
|
||||||
<a href="/shells.html">呈现壳</a>
|
<a href="/shells.html">呈现壳</a>
|
||||||
|
<a href="/libraries.html">库</a>
|
||||||
<a href="/stats.html">Token</a>
|
<a href="/stats.html">Token</a>
|
||||||
<a href="/settings.html" aria-current="page">设置</a>
|
<a href="/settings.html" aria-current="page">设置</a>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
<p class="st-rail-label">设置</p>
|
<p class="st-rail-label">设置</p>
|
||||||
<button type="button" class="st-rail-item active" data-section="api">API</button>
|
<button type="button" class="st-rail-item active" data-section="api">API</button>
|
||||||
<button type="button" class="st-rail-item" data-section="persona">用户角色</button>
|
<button type="button" class="st-rail-item" data-section="persona">用户角色</button>
|
||||||
|
<button type="button" class="st-rail-item" data-section="preference">用户偏好</button>
|
||||||
<button type="button" class="st-rail-item" data-section="preset">预设</button>
|
<button type="button" class="st-rail-item" data-section="preset">预设</button>
|
||||||
<button type="button" class="st-rail-item" data-section="storage">上下文</button>
|
<button type="button" class="st-rail-item" data-section="storage">上下文</button>
|
||||||
<p class="st-rail-note">保存在本机 <code>~/.writing-agent/</code>,明文、不同步。</p>
|
<p class="st-rail-note">保存在本机 <code>~/.writing-agent/</code>,明文、不同步。</p>
|
||||||
@@ -58,6 +60,33 @@
|
|||||||
<div id="personas-list" class="card-list"></div>
|
<div id="personas-list" class="card-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section hidden" id="section-preference">
|
||||||
|
<div class="settings-card storage-card">
|
||||||
|
<h3>用户偏好</h3>
|
||||||
|
<p class="settings-help">
|
||||||
|
跨局硬约束条目的增删与 LLM 提取已迁到顶栏
|
||||||
|
<a href="/libraries.html?tab=preferences">库 · 偏好库</a>。
|
||||||
|
创作期由「用户需求」节点从库选用写入本局,不再开玩时全量自动摊入。
|
||||||
|
</p>
|
||||||
|
<label class="field">
|
||||||
|
每隔多少游玩回合采集一次
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="preference-collect-every"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
value="10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p class="settings-help muted">0 = 关闭自动采集。默认 10。候选经审核卡收下后写入偏好库。</p>
|
||||||
|
<div class="storage-actions">
|
||||||
|
<button type="button" class="btn-primary" id="btn-save-pref-every">保存间隔</button>
|
||||||
|
<a class="btn-secondary" href="/libraries.html?tab=preferences">打开偏好库</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings-section hidden" id="section-preset">
|
<section class="settings-section hidden" id="section-preset">
|
||||||
<div id="presets-list" class="card-list"></div>
|
<div id="presets-list" class="card-list"></div>
|
||||||
<pre id="import-report" class="import-report" hidden></pre>
|
<pre id="import-report" class="import-report" hidden></pre>
|
||||||
@@ -207,6 +236,10 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="preference-dialog" class="profile-dialog" hidden>
|
||||||
|
<!-- 偏好 CRUD 已迁至 /libraries.html -->
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<input type="file" id="preset-file" accept=".json,application/json" hidden />
|
<input type="file" id="preset-file" accept=".json,application/json" hidden />
|
||||||
<script src="/settings.js" type="module"></script>
|
<script src="/settings.js" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ const panelSubtitleEl = document.getElementById("panel-subtitle");
|
|||||||
const panelActionsEl = document.getElementById("panel-actions");
|
const panelActionsEl = document.getElementById("panel-actions");
|
||||||
const sectionApiEl = document.getElementById("section-api");
|
const sectionApiEl = document.getElementById("section-api");
|
||||||
const sectionPersonaEl = document.getElementById("section-persona");
|
const sectionPersonaEl = document.getElementById("section-persona");
|
||||||
|
const sectionPreferenceEl = document.getElementById("section-preference");
|
||||||
const sectionPresetEl = document.getElementById("section-preset");
|
const sectionPresetEl = document.getElementById("section-preset");
|
||||||
const sectionStorageEl = document.getElementById("section-storage");
|
const sectionStorageEl = document.getElementById("section-storage");
|
||||||
|
const preferenceCollectEveryEl = document.getElementById("preference-collect-every");
|
||||||
const activeSettingsBarEl = document.getElementById("active-settings-bar");
|
const activeSettingsBarEl = document.getElementById("active-settings-bar");
|
||||||
const settingsToastEl = document.getElementById("settings-toast");
|
const settingsToastEl = document.getElementById("settings-toast");
|
||||||
const contextTraceKeepEl = document.getElementById("context-trace-keep");
|
const contextTraceKeepEl = document.getElementById("context-trace-keep");
|
||||||
@@ -125,6 +127,7 @@ function switchSection(section) {
|
|||||||
});
|
});
|
||||||
sectionApiEl.classList.toggle("hidden", section !== "api");
|
sectionApiEl.classList.toggle("hidden", section !== "api");
|
||||||
sectionPersonaEl?.classList.toggle("hidden", section !== "persona");
|
sectionPersonaEl?.classList.toggle("hidden", section !== "persona");
|
||||||
|
sectionPreferenceEl?.classList.toggle("hidden", section !== "preference");
|
||||||
sectionPresetEl.classList.toggle("hidden", section !== "preset");
|
sectionPresetEl.classList.toggle("hidden", section !== "preset");
|
||||||
sectionStorageEl?.classList.toggle("hidden", section !== "storage");
|
sectionStorageEl?.classList.toggle("hidden", section !== "storage");
|
||||||
renderPanelHeader();
|
renderPanelHeader();
|
||||||
@@ -151,6 +154,9 @@ function renderPanelHeader() {
|
|||||||
btn.textContent = "新增";
|
btn.textContent = "新增";
|
||||||
btn.addEventListener("click", () => openPersonaDialog());
|
btn.addEventListener("click", () => openPersonaDialog());
|
||||||
panelActionsEl.appendChild(btn);
|
panelActionsEl.appendChild(btn);
|
||||||
|
} else if (activeSection === "preference") {
|
||||||
|
panelTitleEl.textContent = "用户偏好";
|
||||||
|
panelSubtitleEl.textContent = "采集间隔;条目在「库 · 偏好库」管理";
|
||||||
} else if (activeSection === "preset") {
|
} else if (activeSection === "preset") {
|
||||||
panelTitleEl.textContent = "预设";
|
panelTitleEl.textContent = "预设";
|
||||||
panelSubtitleEl.textContent =
|
panelSubtitleEl.textContent =
|
||||||
@@ -554,6 +560,18 @@ async function loadAll() {
|
|||||||
state.personas = [];
|
state.personas = [];
|
||||||
state.activePersonaId = null;
|
state.activePersonaId = null;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const prefData = await api("/api/preferences");
|
||||||
|
if (preferenceCollectEveryEl) {
|
||||||
|
preferenceCollectEveryEl.value = String(
|
||||||
|
prefData.preferenceCollectEveryTurns ??
|
||||||
|
state.settings.preferenceCollectEveryTurns ??
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
if (contextTraceKeepEl) {
|
if (contextTraceKeepEl) {
|
||||||
contextTraceKeepEl.value = String(
|
contextTraceKeepEl.value = String(
|
||||||
state.settings.contextTraceKeepLatest ?? 5,
|
state.settings.contextTraceKeepLatest ?? 5,
|
||||||
@@ -723,6 +741,27 @@ document.getElementById("persona-cancel")?.addEventListener("click", () => {
|
|||||||
personaDialog?.close();
|
personaDialog?.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-save-pref-every")?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const n = Number(preferenceCollectEveryEl?.value ?? 10);
|
||||||
|
const data = await api("/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ preferenceCollectEveryTurns: n }),
|
||||||
|
});
|
||||||
|
state.settings = data.settings;
|
||||||
|
if (preferenceCollectEveryEl) {
|
||||||
|
preferenceCollectEveryEl.value = String(
|
||||||
|
data.settings.preferenceCollectEveryTurns ?? 10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
showToast(
|
||||||
|
`已保存:每隔 ${data.settings.preferenceCollectEveryTurns} 回合采集偏好`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
personaForm?.addEventListener("submit", async (e) => {
|
personaForm?.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<a href="/">对话</a>
|
<a href="/">对话</a>
|
||||||
<a href="/modules.html">技能</a>
|
<a href="/modules.html">技能</a>
|
||||||
<a href="/shells.html" aria-current="page">呈现壳</a>
|
<a href="/shells.html" aria-current="page">呈现壳</a>
|
||||||
|
<a href="/libraries.html">库</a>
|
||||||
<a href="/stats.html">Token</a>
|
<a href="/stats.html">Token</a>
|
||||||
<a href="/settings.html">设置</a>
|
<a href="/settings.html">设置</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<a href="/">对话</a>
|
<a href="/">对话</a>
|
||||||
<a href="/modules.html">技能</a>
|
<a href="/modules.html">技能</a>
|
||||||
<a href="/shells.html">呈现壳</a>
|
<a href="/shells.html">呈现壳</a>
|
||||||
|
<a href="/libraries.html">库</a>
|
||||||
<a href="/stats.html" aria-current="page">Token</a>
|
<a href="/stats.html" aria-current="page">Token</a>
|
||||||
<a href="/settings.html">设置</a>
|
<a href="/settings.html">设置</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
Reference in New Issue
Block a user