重构世界模拟器为模块化配方架构,完善创作编排、会话运行时与 Web UI,并清理过时技能。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-30 00:39:32 +08:00
parent 2b74c30d36
commit e670a5129c
167 changed files with 22955 additions and 5659 deletions

File diff suppressed because it is too large Load Diff

1029
web/app.js

File diff suppressed because it is too large Load Diff

94
web/display-labels.js Normal file
View File

@@ -0,0 +1,94 @@
/**
* 与 docs/ui-glossary.md、src/server/display-labels.ts 保持同步。
* 用户侧拍摄术语:导演 / 剧本 / 演员 / 能力。
*/
const STAGE_LABELS = {
design: "创作",
play: "游玩",
done: "已完成",
idle: "待命",
running: "执行中",
waiting_user: "等待你",
error: "出错",
};
const WORKER_LABELS = {
"design-core": "创作 · 核心",
"design-worker": "创作 · 演员规格",
"design-fixed": "创作 · 固定上下文",
"design-refine": "创作 · 细化与终稿",
"design-intake": "创作 · 综合收口",
"design-flow": "创作 · 流程编排",
"design-step": "创作 · 执行步骤",
"opening-generator": "开局 · 开场白",
orchestrator: "导演",
"agent-burst": "导演调度",
narrator: "叙事转述",
"role-decide": "角色决策",
"world-simulator": "世界推演",
"round-present": "回合呈现",
outline: "大纲 / 细纲",
"chapter-writer": "章节正文",
};
const FIXED_TOPIC_LABELS = {
"aesthetics-interaction": "美学纲领与交互范式",
interaction: "交互范式",
narrative_guide: "叙事指南",
input_protocol: "输入协议",
core_premise: "核心前提",
aesthetics: "美学纲领",
};
const PHASE_UNIT_LABELS = {
core: "核心",
refine: "细化",
};
export function displayStageLabel(id) {
if (!id) return "";
return STAGE_LABELS[id] ?? id;
}
export function displaySkillPackLabel(id) {
if (!id) return "";
const map = {
"world-simulator": "世界模拟器",
"expand-assistant": "扩写助手",
};
return map[id] ?? id;
}
export function displayWorkerLabel(id) {
if (!id) return "";
const trimmed = String(id).trim();
if (!trimmed) return "";
if (WORKER_LABELS[trimmed]) return WORKER_LABELS[trimmed];
if (trimmed.startsWith("phase:")) {
const key = trimmed.slice("phase:".length);
return `单位 · ${PHASE_UNIT_LABELS[key] ?? key}`;
}
if (trimmed.startsWith("worker:")) {
const ref = trimmed.slice("worker:".length);
return `演员 · ${displayWorkerLabel(ref)}`;
}
if (trimmed.startsWith("fixed:")) {
const topic = trimmed.slice("fixed:".length);
return `能力 · ${FIXED_TOPIC_LABELS[topic] ?? topic}`;
}
if (trimmed.startsWith("resident:")) {
return `常驻 · ${trimmed.slice("resident:".length)}`;
}
return trimmed;
}
export function formatWorkerDisplayTitle(workerId, action = null) {
const base = displayWorkerLabel(workerId) || "演员";
if (action === "output") return `${base} · 产出`;
if (action === "questions") return `${base} · 提问`;
if (action === "stub") return `${base} · 占位`;
if (action === "running") return `${base} · 执行中`;
return base;
}

View File

@@ -13,8 +13,8 @@ function formatExportTime(iso) {
const KIND_LABELS = {
user_input: "用户输入",
orchestrator_decision: "总管决策",
orchestrator_prompt: "总管询问",
orchestrator_decision: "导演决策",
orchestrator_prompt: "导演询问",
agent_tool: "Agent Tool",
worker_running: "Worker 执行",
worker_output: "Worker 产出",

View File

@@ -11,9 +11,13 @@
<aside class="panel panel-books" id="panel-books">
<header class="panel-head">
<strong>作品</strong>
<button type="button" class="btn-sm" id="btn-new-book" title="新建">+</button>
<div class="panel-head-actions">
<button type="button" class="btn-sm" id="btn-new-book" title="新建">+</button>
</div>
</header>
<nav class="path-bar" id="book-path-bar" aria-label="作品路径"></nav>
<div class="book-list" id="book-list"></div>
<div id="book-box-overlay" class="book-box-overlay" hidden aria-hidden="true"></div>
</aside>
<main class="panel panel-main">
@@ -29,10 +33,12 @@
</div>
</div>
<div class="main-head-right">
<a class="link" href="/modules.html">能力</a>
<a class="link" href="/stats.html">Token</a>
<a class="link" href="/settings.html">设置</a>
<button type="button" class="btn-sm" id="btn-saves" hidden>存档</button>
<button type="button" class="btn-sm" id="btn-save-instance" hidden>保存定稿</button>
<button type="button" class="btn-sm" id="btn-save-play" hidden>保存游玩</button>
<button type="button" class="btn-sm" id="btn-export" disabled>导出</button>
<button type="button" class="btn-sm btn-danger" id="btn-delete" hidden>删除</button>
<span class="status" id="status-pill"><span class="status-dot" id="status-dot"></span><span id="status-text"></span></span>
</div>
</header>
@@ -43,10 +49,7 @@
<div id="message-feed" class="message-feed">
<p class="empty">点击左侧 + 新建作品</p>
</div>
<div id="skill-guide" class="skill-guide" hidden>
<header class="skill-guide-head">计划中的 skill占位</header>
<div id="skill-guide-list"></div>
</div>
<div id="questions-card-host" class="questions-card-host" hidden></div>
</section>
<aside class="panel-agent" id="panel-agent">
@@ -56,7 +59,25 @@
</header>
<div id="agent-focus" class="agent-focus"></div>
<div id="tool-trace" class="tool-trace" hidden></div>
<div id="agent-timeline" class="agent-timeline"></div>
<nav class="agent-tabs" id="agent-tabs" role="tablist">
<button type="button" class="agent-tab active" data-tab="timeline" role="tab">历史</button>
<button type="button" class="agent-tab" data-tab="review" role="tab" id="agent-tab-btn-review" hidden>验收</button>
<button type="button" class="agent-tab" data-tab="progress" role="tab" id="agent-tab-btn-progress" hidden>设计</button>
<button type="button" class="agent-tab" data-tab="board" role="tab" id="agent-tab-btn-board">上下文</button>
</nav>
<div class="agent-tab-panel active" id="agent-panel-timeline" role="tabpanel">
<div id="agent-timeline" class="agent-timeline"></div>
</div>
<div class="agent-tab-panel" id="agent-panel-review" role="tabpanel" hidden>
<div id="review-artifact-panel" class="review-artifact-panel"></div>
</div>
<div class="agent-tab-panel" id="agent-panel-progress" role="tabpanel" hidden>
<div id="worker-set-user-panel" class="worker-set-user-panel"></div>
<div id="skill-guide-list" class="skill-guide-list"></div>
</div>
<div class="agent-tab-panel" id="agent-panel-board" role="tabpanel" hidden>
<div id="board-panel" class="board-panel"></div>
</div>
</aside>
</div>
@@ -67,7 +88,14 @@
<dialog id="dialog-new-book" class="dialog">
<form method="dialog" id="form-new-book">
<h2>新建作品</h2>
<p class="dialog-desc">只需起名。打开后由 Agent 引导你选择 skill 包并开始实例化</p>
<p class="dialog-desc">选择导演,再起名。导演是本局方法起点,之后按对话谈成剧本(可调味)</p>
<label class="field">
导演
<select id="select-director" required>
<option value="">加载中…</option>
</select>
</label>
<p class="dialog-desc" id="director-desc" hidden></p>
<label class="field">
名称
<input id="input-book-title" type="text" placeholder="未命名作品" maxlength="80" autofocus />
@@ -79,19 +107,31 @@
</form>
</dialog>
<dialog id="dialog-saves" class="dialog dialog-wide">
<header class="dialog-head-row">
<h2>存档</h2>
<button type="button" class="btn-sm" id="btn-close-saves">关闭</button>
</header>
<p class="dialog-desc">实例 = 设计完成后的设定;进度 = 含运行状态的完整快照。</p>
<div class="saves-new">
<input id="input-save-label" type="text" placeholder="存档名称" maxlength="60" />
<label><input type="radio" name="save-kind" value="instance" /> 实例</label>
<label><input type="radio" name="save-kind" value="run" checked /> 进度</label>
<button type="button" class="btn btn-primary" id="btn-save-create">保存</button>
</div>
<div id="saves-list" class="saves-list"></div>
<menu id="book-action-menu" class="context-menu" hidden>
<button type="button" class="context-menu-item" id="book-menu-open" data-action="open">打开</button>
<button type="button" class="context-menu-item" id="book-menu-rename" data-action="rename">重命名</button>
<button type="button" class="context-menu-item" id="book-menu-duplicate" data-action="duplicate">复制备份</button>
<button type="button" class="context-menu-item" id="book-menu-select" data-action="select-mode">多选</button>
<button type="button" class="context-menu-item context-menu-danger" id="book-menu-delete" data-action="delete">删除</button>
</menu>
<menu id="msg-action-menu" class="context-menu" hidden>
<button type="button" class="context-menu-item" id="msg-menu-context" data-msg-menu-action="view-context" hidden>查看上下文</button>
<button type="button" class="context-menu-item" id="msg-menu-edit" data-msg-menu-action="edit">编辑</button>
<button type="button" class="context-menu-item" id="msg-menu-copy" data-msg-menu-action="copy">复制</button>
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="delete">删除</button>
</menu>
<dialog id="dialog-context-trace" class="dialog dialog-wide">
<form method="dialog" class="context-trace-form">
<h2>请求上下文</h2>
<p class="dialog-desc" id="context-trace-meta"></p>
<pre id="context-trace-body" class="context-trace-body"></pre>
<div class="dialog-actions">
<button type="button" class="btn" id="btn-copy-context">复制全文</button>
<button type="submit" class="btn btn-primary" value="close">关闭</button>
</div>
</form>
</dialog>
<script type="module" src="/app.js"></script>

318
web/modules.css Normal file
View File

@@ -0,0 +1,318 @@
/* 能力池浏览页 — 叠在 settings.css 壳上 */
.mod-shell {
max-width: 1280px;
--st-rail-w: 260px;
}
.mod-pack {
margin: 0 8px 10px;
font-size: 12px;
color: var(--st-muted);
word-break: break-all;
}
.mod-filter {
padding: 0 8px 8px;
}
.mod-filter input {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--st-border);
border-radius: 6px;
background: var(--st-bg);
}
.mod-status-filters {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 0 8px 12px;
}
.mod-chip {
border: 1px solid var(--st-border);
background: var(--st-surface);
color: var(--st-muted);
border-radius: 999px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
}
.mod-chip:hover {
color: var(--st-text);
border-color: var(--st-border-strong);
}
.mod-chip.active {
color: var(--st-accent);
background: var(--st-accent-soft);
border-color: transparent;
}
.mod-list {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
overflow: auto;
padding-bottom: 12px;
}
.mod-item {
display: block;
width: 100%;
text-align: left;
border: none;
background: transparent;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
color: inherit;
}
.mod-item:hover {
background: #f4f4f5;
}
.mod-item.active {
background: var(--st-accent-soft);
}
.mod-item-name {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
font-weight: 600;
font-size: 13px;
}
.mod-item-decl {
margin: 4px 0 0;
font-size: 11px;
color: var(--st-muted);
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.mod-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.mod-dot.ready {
background: var(--st-ok);
}
.mod-dot.partial {
background: #ca8a04;
}
.mod-dot.skeleton,
.mod-dot.missing {
background: var(--st-faint);
}
.mod-badge {
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 999px;
background: #f4f4f5;
color: var(--st-muted);
}
.mod-badge.ready {
background: #dcfce7;
color: var(--st-ok);
}
.mod-badge.partial {
background: #fef9c3;
color: #a16207;
}
.mod-badge.skeleton,
.mod-badge.missing {
background: #f4f4f5;
color: var(--st-muted);
}
.mod-error {
margin: 0 0 12px;
color: var(--st-danger);
font-size: 13px;
}
.mod-empty {
color: var(--st-muted);
margin: 2rem 0;
}
.mod-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.mod-summary {
display: grid;
gap: 10px;
padding: 14px 16px;
background: var(--st-surface);
border: 1px solid var(--st-border);
border-radius: var(--st-radius);
}
.mod-summary dl {
display: grid;
grid-template-columns: 6.5rem minmax(0, 1fr);
gap: 6px 12px;
margin: 0;
font-size: 13px;
}
.mod-summary dt {
color: var(--st-muted);
font-weight: 500;
}
.mod-summary dd {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.mod-decl {
margin: 0;
font-size: 14px;
line-height: 1.5;
}
.mod-section-nav {
display: flex;
flex-wrap: wrap;
gap: 6px;
position: sticky;
top: calc(var(--st-top-h) + 8px);
z-index: 5;
background: var(--st-bg);
padding: 6px 0;
}
.mod-section-nav a {
font-size: 12px;
text-decoration: none;
color: var(--st-muted);
padding: 4px 8px;
border-radius: 6px;
border: 1px solid var(--st-border);
background: var(--st-surface);
}
.mod-section-nav a:hover {
color: var(--st-accent);
border-color: var(--st-accent);
}
.mod-section {
background: var(--st-surface);
border: 1px solid var(--st-border);
border-radius: var(--st-radius);
overflow: hidden;
}
.mod-section > summary {
list-style: none;
cursor: pointer;
padding: 12px 16px;
font-weight: 650;
font-size: 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
user-select: none;
}
.mod-section > summary::-webkit-details-marker {
display: none;
}
.mod-section > summary::after {
content: "▸";
color: var(--st-faint);
font-size: 12px;
}
.mod-section[open] > summary::after {
content: "▾";
}
.mod-section > summary:hover {
background: #fafafa;
}
.mod-section-body {
border-top: 1px solid var(--st-border);
padding: 12px 16px 16px;
}
.mod-section pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 12.5px;
line-height: 1.55;
color: #27272a;
}
.mod-raw-toggle {
align-self: flex-start;
border: 1px solid var(--st-border);
background: var(--st-surface);
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
color: var(--st-muted);
}
.mod-raw-toggle:hover {
color: var(--st-text);
}
.mod-raw {
background: #18181b;
color: #e4e4e7;
border-radius: var(--st-radius);
padding: 14px 16px;
max-height: 60vh;
overflow: auto;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 820px) {
.mod-shell {
grid-template-columns: 1fr;
}
.mod-rail {
min-height: auto;
border-right: none;
border-bottom: 1px solid var(--st-border);
max-height: 42vh;
}
}

67
web/modules.html Normal file
View File

@@ -0,0 +1,67 @@
<!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="/modules.css" />
</head>
<body class="settings-page modules-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" aria-current="page">能力</a>
<a href="/stats.html">Token</a>
<a href="/settings.html">设置</a>
</nav>
</header>
<div class="st-shell mod-shell">
<aside class="st-rail mod-rail" aria-label="能力列表">
<p class="st-rail-label">备选能力</p>
<p class="mod-pack" id="pack-label">加载中…</p>
<div class="mod-filter">
<input
type="search"
id="mod-search"
placeholder="搜索名称 / 声明…"
autocomplete="off"
/>
</div>
<div class="mod-status-filters" id="status-filters" role="group" aria-label="按状态筛选">
<button type="button" class="mod-chip active" data-status="">全部</button>
<button type="button" class="mod-chip" data-status="ready">已写</button>
<button type="button" class="mod-chip" data-status="partial">部分</button>
<button type="button" class="mod-chip" data-status="skeleton">骨架</button>
</div>
<nav class="mod-list" id="mod-list"></nav>
<p class="st-rail-note">
导演编排剧本时从这里选型;本页只读浏览方法正文。
</p>
</aside>
<main class="st-main mod-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">
<span class="mod-badge" id="status-badge" hidden></span>
</div>
</header>
<div class="st-status" id="meta-bar" hidden></div>
<p class="mod-error" id="mod-error" hidden></p>
<div class="st-body mod-body" id="mod-detail">
<p class="mod-empty">从左侧选择一项能力</p>
</div>
</main>
</div>
<script type="module" src="/modules.js"></script>
</body>
</html>

232
web/modules.js Normal file
View File

@@ -0,0 +1,232 @@
const SECTION_LABELS = {
meta: "元数据",
opening: "默认开场",
task: "任务",
principles: "原则",
probe: "追问",
output: "产物形状",
checklist: "自检",
examples: "示例",
};
const SECTION_ORDER = [
"meta",
"opening",
"task",
"principles",
"probe",
"output",
"checklist",
"examples",
];
const STATUS_LABELS = {
ready: "已写",
partial: "部分",
skeleton: "骨架",
missing: "缺文件",
};
/** @type {Array<any>} */
let modulesCache = [];
let activeId = "";
let statusFilter = "";
let searchQuery = "";
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function setError(msg) {
const el = document.getElementById("mod-error");
if (!msg) {
el.hidden = true;
el.textContent = "";
return;
}
el.hidden = false;
el.textContent = msg;
}
function filteredModules() {
const q = searchQuery.trim().toLowerCase();
return modulesCache.filter((m) => {
if (statusFilter && m.status !== statusFilter) return false;
if (!q) return true;
const hay = `${m.name} ${m.id} ${m.declaration} ${m.artifact}`.toLowerCase();
return hay.includes(q);
});
}
function renderList() {
const list = document.getElementById("mod-list");
const items = filteredModules();
if (items.length === 0) {
list.innerHTML = `<p class="mod-empty" style="padding:8px">无匹配项</p>`;
return;
}
list.innerHTML = items
.map(
(m) => `<button type="button" class="mod-item${m.id === activeId ? " active" : ""}" data-id="${escapeHtml(m.id)}">
<span class="mod-item-name">
<span>${escapeHtml(m.name)}</span>
<span class="mod-dot ${escapeHtml(m.status)}" title="${escapeHtml(STATUS_LABELS[m.status] ?? m.status)}"></span>
</span>
<p class="mod-item-decl">${escapeHtml(m.declaration)}</p>
</button>`,
)
.join("");
}
function metaField(meta, key) {
if (!meta || meta[key] == null) return "";
const v = meta[key];
return typeof v === "string" ? v.trim() : String(v);
}
function renderDetail(detail) {
const title = document.getElementById("panel-title");
const sub = document.getElementById("panel-subtitle");
const badge = document.getElementById("status-badge");
const metaBar = document.getElementById("meta-bar");
const host = document.getElementById("mod-detail");
title.textContent = detail.name;
sub.textContent = `${detail.id} · ${detail.artifact}`;
badge.hidden = false;
badge.className = `mod-badge ${detail.status}`;
badge.textContent = STATUS_LABELS[detail.status] ?? detail.status;
const when = metaField(detail.meta, "when");
const whenNot = metaField(detail.meta, "when_not");
const boundary = metaField(detail.meta, "boundary");
metaBar.hidden = !(when || whenNot || boundary);
if (!metaBar.hidden) {
const bits = [];
if (when) bits.push(`<strong>何时选用</strong> ${escapeHtml(when).replace(/\n/g, "<br>")}`);
if (whenNot) bits.push(`<strong>何时不选</strong> ${escapeHtml(whenNot).replace(/\n/g, "<br>")}`);
if (boundary) bits.push(`<strong>边界</strong> ${escapeHtml(boundary).replace(/\n/g, "<br>")}`);
metaBar.innerHTML = bits.join("<hr style='border:none;border-top:1px solid var(--st-border);margin:10px 0'>");
}
const present = SECTION_ORDER.filter((id) => detail.sections?.[id]);
const nav =
present.length > 0
? `<nav class="mod-section-nav">${present
.map(
(id) =>
`<a href="#sec-${id}">${escapeHtml(SECTION_LABELS[id] ?? id)}</a>`,
)
.join("")}</nav>`
: "";
const sectionsHtml = present
.map((id) => {
const open = id === "task" || id === "output" || id === "opening" ? " open" : "";
return `<details class="mod-section"${open} id="sec-${id}">
<summary>${escapeHtml(SECTION_LABELS[id] ?? id)}</summary>
<div class="mod-section-body"><pre>${escapeHtml(detail.sections[id])}</pre></div>
</details>`;
})
.join("");
host.innerHTML = `
<div class="mod-summary">
<p class="mod-decl">${escapeHtml(detail.declaration)}</p>
<dl>
<dt>产物 tag</dt><dd><code>${escapeHtml(detail.artifact)}</code></dd>
<dt>已有块</dt><dd>${present.map((id) => SECTION_LABELS[id] ?? id).join(" · ") || "—"}</dd>
</dl>
</div>
${nav}
${sectionsHtml || `<p class="mod-empty">尚无可切割的方法块(可能仍是骨架)。</p>`}
<button type="button" class="mod-raw-toggle" id="btn-raw">显示原始 prompt.md</button>
<pre class="mod-raw" id="raw-view" hidden></pre>
`;
const btn = document.getElementById("btn-raw");
const raw = document.getElementById("raw-view");
btn.addEventListener("click", () => {
const showing = !raw.hidden;
raw.hidden = showing;
if (!showing) {
raw.textContent = detail.raw || "(空)";
btn.textContent = "隐藏原始 prompt.md";
} else {
btn.textContent = "显示原始 prompt.md";
}
});
}
async function selectModule(id) {
if (!id) return;
activeId = id;
renderList();
setError("");
const host = document.getElementById("mod-detail");
host.innerHTML = `<p class="mod-empty">加载中…</p>`;
try {
const res = await fetch(`/api/modules/${encodeURIComponent(id)}`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${res.status}`);
}
const detail = await res.json();
renderDetail(detail);
history.replaceState(null, "", `#${encodeURIComponent(id)}`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
host.innerHTML = `<p class="mod-empty">加载失败</p>`;
}
}
async function boot() {
setError("");
try {
const res = await fetch("/api/modules");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
modulesCache = data.modules || [];
document.getElementById("pack-label").textContent =
`导演包 · ${data.skillPackId || "—"} · ${modulesCache.length}`;
renderList();
const fromHash = decodeURIComponent((location.hash || "").replace(/^#/, ""));
const initial =
(fromHash && modulesCache.find((m) => m.id === fromHash)?.id) ||
modulesCache.find((m) => m.status === "ready")?.id ||
modulesCache[0]?.id;
if (initial) await selectModule(initial);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
document.getElementById("pack-label").textContent = "加载失败";
}
}
document.getElementById("mod-list").addEventListener("click", (e) => {
const btn = e.target.closest(".mod-item");
if (!btn) return;
selectModule(btn.dataset.id);
});
document.getElementById("mod-search").addEventListener("input", (e) => {
searchQuery = e.target.value || "";
renderList();
});
document.getElementById("status-filters").addEventListener("click", (e) => {
const chip = e.target.closest(".mod-chip");
if (!chip) return;
statusFilter = chip.dataset.status || "";
for (const c of document.querySelectorAll(".mod-chip")) {
c.classList.toggle("active", c === chip);
}
renderList();
});
boot();

391
web/questions-ui.js Normal file
View File

@@ -0,0 +1,391 @@
/**
* 结构化询问卡:点字母选中,点文案编辑;左右分页。
* 普通提问:提交走底栏 composer问+答拼进上下文),卡上无独立发送钮。
* 产物验收挂载题:底栏有字优先按「改产物」发送;无字时才提交所选答案。
* 分页 / 跳过为悬浮控件,不占独立 header/footer 行。
*/
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function normalizeQuestions(raw) {
if (!Array.isArray(raw)) return [];
return raw
.map((item, i) => {
if (typeof item === "string") {
const prompt = item.trim();
if (!prompt) return null;
return { id: `q${i + 1}`, prompt, allowOther: true, required: false, options: [] };
}
if (!item || typeof item !== "object") return null;
const prompt = String(item.prompt ?? item.question ?? item.text ?? "").trim();
if (!prompt) return null;
const options = Array.isArray(item.options)
? item.options
.map((o, j) => {
if (typeof o === "string") {
const label = o.trim();
return label
? { id: String.fromCharCode(65 + (j % 26)), label, editable: true }
: null;
}
if (!o || typeof o !== "object") return null;
const label = String(o.label ?? o.text ?? "").trim();
if (!label) return null;
return {
id: String(o.id ?? String.fromCharCode(65 + (j % 26))),
label,
editable: o.editable === false ? false : true,
};
})
.filter(Boolean)
: [];
return {
id: String(item.id ?? `q${i + 1}`),
prompt,
options,
allowOther: item.allowOther === false ? false : true,
required: item.required === true ? true : false,
};
})
.filter(Boolean);
}
/** @returns {{ questions: any[], workerId?: string, pageSize: number, assessment?: string, optional?: boolean, skipLabel?: string } | null} */
export function getActiveQuestions(view) {
const wr = view?.waitingReason;
if (!wr) return null;
// 默认每页 1 题,左右切换;勿一次堆多题
const pageSize = Math.max(1, Number(wr.pageSize) || 1);
if (wr.kind === "worker_questions" && wr.questions?.length) {
return {
questions: normalizeQuestions(wr.questions),
workerId: wr.workerId,
pageSize,
optional: true,
skipLabel: "跳过",
};
}
if (wr.kind === "review_artifact" && wr.questions?.length) {
return {
questions: normalizeQuestions(wr.questions).map((q) => ({
...q,
required: false,
})),
workerId: view.reviewArtifact?.workerId,
pageSize,
optional: true,
skipLabel: "跳过",
dismissOnAccept: true,
};
}
if (wr.kind === "input" && wr.questions?.length) {
const assessment =
typeof wr.message === "string" && wr.message.trim()
? wr.message.trim()
: "";
// 短调度句不当作内容评价展示
const showAssessment =
assessment.length > 80 ||
/完备度|核心体验|已知|待探|#\s/.test(assessment);
return {
questions: normalizeQuestions(wr.questions).map((q) => ({
...q,
required: q.required === true ? true : false,
})),
workerId: "orchestrator",
pageSize,
assessment: showAssessment ? assessment : "",
optional: true,
skipLabel: "跳过",
};
}
return null;
}
function persistDraftsFromDom(host, state) {
host.querySelectorAll("[data-draft]").forEach((el) => {
const key = el.getAttribute("data-draft");
if (key) state.drafts[key] = el.textContent ?? "";
});
host.querySelectorAll("[data-other]").forEach((el) => {
const qid = el.getAttribute("data-other");
if (!qid) return;
const prev = state.answers[qid] ?? {};
state.answers[qid] = { ...prev, otherText: el.value };
});
}
/**
* 从询问卡收集并校验全部作答(供 composer 发送时调用)。
* @returns {{ ok: true, answers: Array<{questionId:string, optionId?:string, text:string}> } | { ok: false, error: string, page?: number }}
*/
export function collectQuestionAnswers(host, view) {
const active = getActiveQuestions(view);
if (!active?.questions?.length) {
return { ok: false, error: "当前没有待回答的追问" };
}
if (!host?._qState) {
return { ok: false, error: "请先在询问卡中选择选项" };
}
const state = host._qState;
persistDraftsFromDom(host, state);
const { questions, pageSize } = active;
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
if (q.required === false) continue;
const ans = state.answers[q.id];
if (!ans?.optionId) {
return {
ok: false,
error: `请先选择第 ${i + 1} 题的选项(点字母选中)`,
page: Math.floor(i / pageSize),
};
}
if (ans.optionId === "other") {
const text = (ans.otherText ?? ans.text ?? "").trim();
if (!text) {
return {
ok: false,
error: `请填写第 ${i + 1} 题的 Other`,
page: Math.floor(i / pageSize),
};
}
state.answers[q.id] = { optionId: "other", text, otherText: text };
} else {
const draftKey = `${q.id}:${ans.optionId}`;
const text = (state.drafts[draftKey] ?? ans.text ?? "").trim();
if (!text) {
return {
ok: false,
error: `${i + 1} 题选项文案不能为空`,
page: Math.floor(i / pageSize),
};
}
state.answers[q.id] = { ...ans, text };
}
}
const answers = questions.map((q) => {
const ans = state.answers[q.id];
if (!ans?.optionId) {
return { questionId: q.id, text: "(未答)" };
}
if (ans.optionId === "other") {
return {
questionId: q.id,
optionId: "other",
text: (ans.otherText ?? ans.text ?? "").trim() || "(未答)",
};
}
const draftKey = `${q.id}:${ans.optionId}`;
const text = (state.drafts[draftKey] ?? ans.text ?? "").trim();
return {
questionId: q.id,
optionId: ans.optionId,
text: text || "(未答)",
};
});
return { ok: true, answers };
}
export function clearQuestionCardState(host) {
if (host) host._qState = null;
}
/**
* @param {HTMLElement} host
* @param {object} view
* @param {{}} [_handlers]
*/
export function renderQuestionsCard(host, view, _handlers = {}) {
if (!host) return false;
const active = getActiveQuestions(view);
if (!active?.questions?.length) {
host.hidden = true;
host.innerHTML = "";
host.classList.remove("is-open");
return false;
}
host.hidden = false;
host.classList.add("is-open");
const state = host._qState?.sessionId === view.id && host._qState?.key === JSON.stringify(active.questions.map((q) => q.id))
? host._qState
: {
sessionId: view.id,
key: JSON.stringify(active.questions.map((q) => q.id)),
page: 0,
// questionId -> { optionId?, text, otherText? }
answers: {},
drafts: {}, // option key -> edited label
};
host._qState = state;
const { questions, pageSize, assessment, optional, skipLabel, dismissOnAccept } =
active;
const pageCount = Math.max(1, Math.ceil(questions.length / pageSize));
if (state.page >= pageCount) state.page = pageCount - 1;
if (state.page < 0) state.page = 0;
const start = state.page * pageSize;
const pageQs = questions.slice(start, start + pageSize);
const assessmentHtml =
assessment && state.page === 0
? `<div class="qcard-assessment">${esc(assessment)}</div>`
: "";
const dismissNote =
dismissOnAccept && state.page === 0
? `<p class="qcard-optional-note">可选 · 接受产物即收起(无需再完善)</p>`
: optional && state.page === 0
? `<p class="qcard-optional-note">可选 · 可跳过继续</p>`
: "";
const pagerHtml =
pageCount > 1
? `<div class="qcard-pager" aria-label="问题切换">
<button type="button" class="qcard-nav" data-qnav="prev" ${state.page <= 0 ? "disabled" : ""} aria-label="上一题"></button>
<span class="qcard-page">${state.page + 1}/${pageCount}</span>
<button type="button" class="qcard-nav" data-qnav="next" ${state.page >= pageCount - 1 ? "disabled" : ""} aria-label="下一题"></button>
</div>`
: "";
const skipHtml = optional
? `<button type="button" class="qcard-skip" data-qskip title="Esc">${esc(skipLabel || "跳过")}</button>`
: "";
const chromeClass = [
"qcard",
pageCount > 1 ? "has-pager" : "",
optional ? "has-skip" : "",
]
.filter(Boolean)
.join(" ");
host.innerHTML = `
<div class="${chromeClass}" role="dialog" aria-label="询问">
<div class="qcard-float">
${pagerHtml}
${skipHtml}
</div>
<div class="qcard-body">
${dismissNote}
${assessmentHtml}
${pageQs
.map((q, qi) => {
const globalIdx = start + qi + 1;
const ans = state.answers[q.id] ?? {};
const opts = q.options?.length
? q.options
: q.allowOther
? []
: [{ id: "A", label: q.prompt, editable: true }];
return `
<section class="qcard-q" data-qid="${esc(q.id)}">
<div class="qcard-prompt">${globalIdx}. ${esc(q.prompt)}</div>
<ul class="qcard-options">
${opts
.map((opt, oi) => {
const letter = String.fromCharCode(65 + (oi % 26));
const draftKey = `${q.id}:${opt.id}`;
const label = state.drafts[draftKey] ?? opt.label;
const selected = ans.optionId === opt.id;
const editable = opt.editable !== false;
return `
<li class="qcard-opt${selected ? " is-selected" : ""}" data-oid="${esc(opt.id)}">
<button type="button" class="qcard-letter" data-select="${esc(opt.id)}" title="选中">${letter}</button>
${
editable
? `<div class="qcard-label" contenteditable="true" data-draft="${esc(draftKey)}" spellcheck="false">${esc(label)}</div>`
: `<div class="qcard-label is-readonly">${esc(label)}</div>`
}
</li>`;
})
.join("")}
${
q.allowOther !== false
? `
<li class="qcard-opt qcard-other${ans.optionId === "other" ? " is-selected" : ""}" data-oid="other">
<button type="button" class="qcard-letter" data-select="other" title="选中">+</button>
<input class="qcard-other-input" type="text" placeholder="Other…" data-other="${esc(q.id)}" value="${esc(ans.otherText ?? "")}" />
</li>`
: ""
}
</ul>
</section>`;
})
.join("")}
</div>
</div>`;
host.querySelector("[data-qskip]")?.addEventListener("click", () => {
_handlers.onSkipQuestions?.();
});
host.querySelectorAll("[data-qnav]").forEach((btn) => {
btn.addEventListener("click", () => {
persistDraftsFromDom(host, state);
const dir = btn.getAttribute("data-qnav");
if (dir === "prev" && state.page > 0) state.page -= 1;
if (dir === "next" && state.page < pageCount - 1) state.page += 1;
renderQuestionsCard(host, view, _handlers);
});
});
host.querySelectorAll("[data-select]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
persistDraftsFromDom(host, state);
const section = btn.closest(".qcard-q");
const qid = section?.getAttribute("data-qid");
const oid = btn.getAttribute("data-select");
if (!qid || !oid) return;
let text = "";
if (oid === "other") {
const input = section.querySelector(`[data-other="${qid}"]`);
text = (input?.value ?? "").trim();
state.answers[qid] = { optionId: "other", text, otherText: text };
} else {
const draftKey = `${qid}:${oid}`;
const labelEl = section.querySelector(`[data-draft="${draftKey}"]`);
text = (labelEl?.textContent ?? state.drafts[draftKey] ?? "").trim();
state.answers[qid] = { optionId: oid, text };
}
renderQuestionsCard(host, view, _handlers);
});
});
host.querySelectorAll("[data-draft]").forEach((el) => {
el.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
el.blur();
}
});
el.addEventListener("blur", () => {
const key = el.getAttribute("data-draft");
if (key) state.drafts[key] = el.textContent ?? "";
const section = el.closest(".qcard-q");
const qid = section?.getAttribute("data-qid");
const oid = el.closest(".qcard-opt")?.getAttribute("data-oid");
if (qid && oid && state.answers[qid]?.optionId === oid) {
state.answers[qid] = {
...state.answers[qid],
text: (el.textContent ?? "").trim(),
};
}
});
});
return true;
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,80 +4,85 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>设置 · Writing Agent</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/styles.css" />
<link rel="stylesheet" href="/settings.css" />
</head>
<body>
<div class="app-shell">
<header class="site-header">
<div class="header-left">
<a href="/" class="brand" aria-label="Writing Agent 首页">
<span class="brand-logo" aria-hidden="true"></span>
<span class="brand-name">Writing Agent</span>
</a>
<nav class="header-nav" aria-label="主导航">
<a class="header-nav-link" href="/">对话</a>
<a class="header-nav-link active" href="/settings.html" aria-current="page">设置</a>
</nav>
<body class="settings-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="/stats.html">Token</a>
<a href="/settings.html" aria-current="page">设置</a>
</nav>
</header>
<div class="st-shell">
<aside class="st-rail" aria-label="设置分类">
<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" data-section="preset">预设</button>
<button type="button" class="st-rail-item" data-section="storage">上下文</button>
<p class="st-rail-note">保存在本机 <code>~/.writing-agent/</code>,明文、不同步。</p>
</aside>
<main class="st-main">
<header class="st-main-head">
<div>
<h1 id="panel-title">API</h1>
<p class="st-sub" id="panel-subtitle">管理 LLM 连接</p>
</div>
<div class="st-main-actions" id="panel-actions"></div>
</header>
<div class="st-status" id="active-settings-bar"></div>
<div class="settings-toast" id="settings-toast" hidden></div>
<div class="st-body">
<section class="settings-section" id="section-api">
<div id="profiles-list" class="card-list"></div>
</section>
<section class="settings-section hidden" id="section-preset">
<div id="presets-list" class="card-list"></div>
<pre id="import-report" class="import-report" hidden></pre>
</section>
<section class="settings-section hidden" id="section-storage">
<div class="settings-card storage-card">
<h3>LLM 请求上下文</h3>
<p class="settings-help">
每次模型调用可保存完整 prompt。默认每个对话只保留最新若干条更早消息仍在但去掉上下文痕迹。可在对话里查看上下文。
</p>
<label class="field">
每个对话保留最新条数
<input
type="number"
id="context-trace-keep"
min="0"
max="50"
step="1"
value="5"
/>
</label>
<p class="settings-help muted">0 = 不保存;最大 50。保存后对新回复立即生效。</p>
<div class="storage-actions">
<button type="button" class="btn-primary" id="btn-save-context-keep">保存</button>
<button type="button" class="btn-secondary" id="btn-prune-context">修剪已打开会话</button>
<button type="button" class="btn-secondary danger-outline" id="btn-clear-context">清除全部痕迹</button>
</div>
</div>
</section>
</div>
</header>
<div class="app-body">
<aside class="sidebar settings-sidebar">
<div class="sidebar-head">
<h2>设置</h2>
</div>
<button type="button" class="sidebar-nav-item active" data-section="api">
<span class="sidebar-nav-icon"></span> API 配置
</button>
<button type="button" class="sidebar-nav-item" data-section="preset">
<span class="sidebar-nav-icon">📋</span> 预设 Preset
</button>
<p class="sidebar-note">
保存在本地 <code>~/.writing-agent/</code><br />明文存储,不同步
</p>
</aside>
<main class="settings-panel">
<header class="panel-header">
<div>
<h1 id="panel-title">API 配置</h1>
<span class="panel-subtitle" id="panel-subtitle">管理 LLM 连接</span>
</div>
<div class="panel-header-actions" id="panel-actions"></div>
</header>
<div class="active-settings-bar" id="active-settings-bar"></div>
<div class="settings-toast" id="settings-toast" hidden></div>
<div class="panel-body">
<div class="settings-content">
<section class="settings-section" id="section-api">
<div id="profiles-list" class="card-list"></div>
</section>
<section class="settings-section hidden" id="section-preset">
<div id="presets-list" class="card-list"></div>
<pre id="import-report" class="import-report" hidden></pre>
</section>
</div>
</div>
</main>
</div>
</main>
</div>
<dialog id="profile-dialog" class="profile-dialog">
<form id="profile-form" method="dialog">
<h3 id="profile-dialog-title">新增 API 配置</h3>
<h3 id="profile-dialog-title">新增 API</h3>
<label>
名称
<input name="name" required placeholder="例如DeepSeek" />
<input name="name" required placeholder="例如 DeepSeek" autocomplete="off" />
</label>
<label>
Base URL
@@ -85,11 +90,11 @@
</label>
<label>
API Key
<input name="apiKey" type="password" placeholder="sk-..." />
<input name="apiKey" type="password" placeholder="sk-…" autocomplete="off" />
</label>
<label>
Model
<input name="model" required placeholder="deepseek-v4-pro" />
<input name="model" required placeholder="deepseek-chat" />
</label>
<div class="dialog-actions">
<button type="button" id="profile-cancel" class="btn-secondary">取消</button>

View File

@@ -7,7 +7,10 @@ let state = {
let editingProfileId = null;
let activeSection = "api";
let lastImportedPresetId = null;
/** presetId → entries API payload */
const expandedPresetEntries = new Map();
/** presetId → 当前展开编辑的 entry id一次一条 */
const openPresetEntryId = new Map();
const profilesListEl = document.getElementById("profiles-list");
const presetsListEl = document.getElementById("presets-list");
@@ -21,8 +24,10 @@ const panelSubtitleEl = document.getElementById("panel-subtitle");
const panelActionsEl = document.getElementById("panel-actions");
const sectionApiEl = document.getElementById("section-api");
const sectionPresetEl = document.getElementById("section-preset");
const sectionStorageEl = document.getElementById("section-storage");
const activeSettingsBarEl = document.getElementById("active-settings-bar");
const settingsToastEl = document.getElementById("settings-toast");
const contextTraceKeepEl = document.getElementById("context-trace-keep");
async function api(path, options = {}) {
const res = await fetch(path, {
@@ -79,34 +84,39 @@ function renderActiveBar() {
function switchSection(section) {
activeSection = section;
document.querySelectorAll(".sidebar-nav-item").forEach((el) => {
document.querySelectorAll(".st-rail-item").forEach((el) => {
el.classList.toggle("active", el.dataset.section === section);
});
sectionApiEl.classList.toggle("hidden", section !== "api");
sectionPresetEl.classList.toggle("hidden", section !== "preset");
sectionStorageEl?.classList.toggle("hidden", section !== "storage");
renderPanelHeader();
}
function renderPanelHeader() {
panelActionsEl.innerHTML = "";
if (activeSection === "api") {
panelTitleEl.textContent = "API 配置";
panelSubtitleEl.textContent = "保存后点「选用」或「保存并选用」立即生效";
panelTitleEl.textContent = "API";
panelSubtitleEl.textContent = "保存后点「选用」才会用于对话";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn-primary";
btn.textContent = "新增配置";
btn.textContent = "新增";
btn.addEventListener("click", () => openProfileDialog());
panelActionsEl.appendChild(btn);
} else {
panelTitleEl.textContent = "预设 Preset";
panelSubtitleEl.textContent = "导入后点击「选用此预设」注入所有 LLM 请求";
} else if (activeSection === "preset") {
panelTitleEl.textContent = "预设";
panelSubtitleEl.textContent =
"导入后可启用/编辑条目;选用后注入全部 LLM 请求";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn-primary";
btn.textContent = "导入 JSON";
btn.addEventListener("click", () => presetFileEl.click());
panelActionsEl.appendChild(btn);
} else {
panelTitleEl.textContent = "上下文";
panelSubtitleEl.textContent = "控制每个对话保留多少条完整 prompt 痕迹";
}
}
@@ -174,8 +184,8 @@ function renderPresets() {
${active ? '<span class="badge">当前</span>' : ""}
</div>
<div class="config-card-meta">
${escapeHtml(p.source)} · 启用 ${p.enabledCount}
· 注入 ${p.injectingCount ?? "?"}
${escapeHtml(p.source)} · ${p.entryCount ?? "?"}
· 启用 ${p.enabledCount} · 注入 ${p.injectingCount ?? "?"}
· ${escapeHtml(p.importedAt?.slice(0, 10) ?? "")}
</div>
</div>
@@ -183,7 +193,7 @@ function renderPresets() {
<div class="config-card-actions">
${activateButtonHtml(active, p.id, "activate-preset")}
<button type="button" data-action="toggle-preset-entries" data-id="${p.id}">
${expanded ? "收起条目" : "查看启用条目"}
${expanded ? "收起条目" : "编辑条目"}
</button>
<button type="button" class="btn-danger" data-action="delete-preset" data-id="${p.id}">删除</button>
</div>
@@ -204,46 +214,111 @@ function renderPresetEntriesPanel(presetId, data) {
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${k}: ${v}`);
const openId = openPresetEntryId.get(presetId) ?? null;
const entriesHtml = data.entries
.map((entry, idx) => {
const roleClass = `role-${entry.role}`;
const status = entry.willInject
? ""
const open = openId === entry.id;
const preview = (entry.content || "").trim();
const previewText = preview
? preview.length > 72
? `${preview.slice(0, 72)}`
: preview
: entry.marker
? '<span class="entry-skip">marker · 无内容</span>'
: '<span class="entry-skip">空内容 · 不注入</span>';
? "marker · 无正文)"
: "(空)";
const status = entry.enabled
? entry.willInject
? '<span class="entry-skip ok">将注入</span>'
: '<span class="entry-skip">已启用 · 不注入</span>'
: '<span class="entry-skip">未启用</span>';
return `
<article class="preset-entry ${entry.willInject ? "injecting" : "skipped"}">
<header class="preset-entry-head">
<article class="preset-entry ${entry.enabled ? "" : "disabled"} ${open ? "is-open" : ""}" data-entry-id="${escapeHtml(entry.id)}">
<header class="preset-entry-head" data-action="expand-entry" data-id="${escapeHtml(entry.id)}" role="button" tabindex="0" aria-expanded="${open}">
<label class="entry-enable" title="启用后才会注入请求" data-stop-expand>
<input type="checkbox" data-action="toggle-entry" data-id="${escapeHtml(entry.id)}" ${entry.enabled ? "checked" : ""} />
<span>启用</span>
</label>
<span class="entry-index">${idx + 1}</span>
<span class="entry-role ${roleClass}">${escapeHtml(entry.role)}</span>
<span class="entry-name">${escapeHtml(entry.name)}</span>
${status}
<span class="entry-chevron" aria-hidden="true">${open ? "▾" : "▸"}</span>
</header>
${entry.content
? `<pre class="preset-entry-content">${escapeHtml(entry.content)}</pre>`
: `<p class="preset-entry-empty">(无文本内容)</p>`}
${open
? ""
: `<p class="entry-preview" data-action="expand-entry" data-id="${escapeHtml(entry.id)}">${escapeHtml(previewText)}</p>`}
<div class="preset-entry-body" ${open ? "" : "hidden"}>
<label class="entry-field">
名称
<input class="entry-name-input" type="text" data-field="name" value="${escapeHtml(entry.name)}" />
</label>
<label class="entry-field">
正文
<textarea class="preset-entry-editor" data-field="content" rows="${Math.min(14, Math.max(4, (entry.content || "").split("\n").length + 1))}" placeholder="空则即使启用也不注入">${escapeHtml(entry.content || "")}</textarea>
</label>
<div class="preset-entry-actions">
<button type="button" class="btn-secondary" data-action="collapse-entry" data-id="${escapeHtml(entry.id)}">收起</button>
<button type="button" class="btn-primary" data-action="save-entry" data-id="${escapeHtml(entry.id)}">保存此条</button>
</div>
</div>
</article>`;
})
.join("");
panel.innerHTML = `
<div class="preset-entries-inner">
<div class="preset-entries-inner" data-preset-id="${escapeHtml(presetId)}">
${genLines.length ? `<div class="preset-gen-params"><strong>生成参数</strong> ${escapeHtml(genLines.join(" · "))}</div>` : ""}
<p class="preset-entries-summary">共 ${data.entries.length}启用顺序,${data.injectingCount} 条会注入请求</p>
${entriesHtml || '<p class="empty-hint">无启用条目</p>'}
<p class="preset-entries-summary">共 ${data.entries.length} · 启用 ${data.enabledCount ?? 0} · 注入 ${data.injectingCount ?? 0}。点条目展开编辑;启用开关即时保存。</p>
${entriesHtml || '<p class="empty-hint">无条目</p>'}
</div>`;
}
async function refreshPresetEntries(presetId) {
const data = await api(`/api/presets/${encodeURIComponent(presetId)}/entries`);
expandedPresetEntries.set(presetId, data);
// 同步卡片上的计数
const cardMeta = state.presets.find((p) => p.id === presetId);
if (cardMeta) {
cardMeta.enabledCount = data.enabledCount;
cardMeta.injectingCount = data.injectingCount;
cardMeta.entryCount = data.entries?.length;
}
renderPresets();
}
async function togglePresetEntries(presetId) {
if (expandedPresetEntries.has(presetId)) {
expandedPresetEntries.delete(presetId);
openPresetEntryId.delete(presetId);
renderPresets();
return;
}
const data = await api(`/api/presets/${encodeURIComponent(presetId)}/entries`);
await refreshPresetEntries(presetId);
}
async function patchPresetEntry(presetId, patch) {
const data = await api(`/api/presets/${encodeURIComponent(presetId)}/entries`, {
method: "PATCH",
body: JSON.stringify({ entry: patch }),
});
expandedPresetEntries.set(presetId, data);
const cardMeta = state.presets.find((p) => p.id === presetId);
if (cardMeta) {
cardMeta.enabledCount = data.enabledCount;
cardMeta.injectingCount = data.injectingCount;
cardMeta.entryCount = data.entries?.length;
}
renderPresets();
const n = data.reloadedSessions ?? 0;
showToast(
n > 0
? `已保存,并热更新 ${n} 个会话`
: "已保存(选用此预设后才会进入请求)",
);
return data;
}
async function loadAll() {
@@ -251,6 +326,11 @@ async function loadAll() {
state.settings = data.settings;
state.profiles = data.profiles;
state.presets = data.presets;
if (contextTraceKeepEl) {
contextTraceKeepEl.value = String(
state.settings.contextTraceKeepLatest ?? 5,
);
}
renderActiveBar();
renderProfiles();
renderPresets();
@@ -321,7 +401,7 @@ async function activatePreset(id) {
showToast(`预设已生效${n > 0 ? `${n} 个会话已更新)` : ""}`);
}
document.querySelectorAll(".sidebar-nav-item").forEach((btn) => {
document.querySelectorAll(".st-rail-item").forEach((btn) => {
btn.addEventListener("click", () => switchSection(btn.dataset.section));
});
@@ -372,6 +452,22 @@ profilesListEl.addEventListener("click", async (e) => {
});
presetsListEl.addEventListener("click", async (e) => {
if (e.target.closest("[data-stop-expand]")) return;
const expandHead = e.target.closest('[data-action="expand-entry"]');
if (expandHead) {
const panel = expandHead.closest(".preset-entries-inner");
const presetId = panel?.dataset.presetId;
const entryId = expandHead.dataset.id;
if (!presetId || !entryId) return;
const cur = openPresetEntryId.get(presetId);
if (cur === entryId) openPresetEntryId.delete(presetId);
else openPresetEntryId.set(presetId, entryId);
const data = expandedPresetEntries.get(presetId);
if (data) renderPresetEntriesPanel(presetId, data);
return;
}
const btn = e.target.closest("[data-action]");
if (!btn) return;
const id = btn.dataset.id;
@@ -385,14 +481,56 @@ presetsListEl.addEventListener("click", async (e) => {
} else if (action === "delete-preset") {
if (!confirm("确定删除此预设?")) return;
await api(`/api/presets/${id}`, { method: "DELETE" });
expandedPresetEntries.delete(id);
openPresetEntryId.delete(id);
await loadAll();
showToast("已删除");
} else if (action === "collapse-entry") {
const panel = btn.closest(".preset-entries-inner");
const presetId = panel?.dataset.presetId;
if (!presetId) return;
openPresetEntryId.delete(presetId);
const data = expandedPresetEntries.get(presetId);
if (data) renderPresetEntriesPanel(presetId, data);
} else if (action === "save-entry") {
const article = btn.closest(".preset-entry");
const panel = btn.closest(".preset-entries-inner");
const presetId = panel?.dataset.presetId;
if (!article || !presetId) return;
const name = article.querySelector('[data-field="name"]')?.value ?? "";
const content = article.querySelector('[data-field="content"]')?.value ?? "";
openPresetEntryId.set(presetId, id);
await patchPresetEntry(presetId, { id, name, content });
}
} catch (err) {
showToast(err.message, true);
}
});
presetsListEl.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
const head = e.target.closest?.('[data-action="expand-entry"]');
if (!head) return;
e.preventDefault();
head.click();
});
presetsListEl.addEventListener("change", async (e) => {
const input = e.target.closest('input[data-action="toggle-entry"]');
if (!input) return;
const article = input.closest(".preset-entry");
const panel = input.closest(".preset-entries-inner");
const presetId = panel?.dataset.presetId;
const id = input.dataset.id;
if (!presetId || !id) return;
try {
await patchPresetEntry(presetId, { id, enabled: input.checked });
} catch (err) {
input.checked = !input.checked;
showToast(err.message, true);
}
});
presetFileEl.addEventListener("change", async () => {
const file = presetFileEl.files?.[0];
if (!file) return;
@@ -435,6 +573,46 @@ presetFileEl.addEventListener("change", async () => {
}
});
document.getElementById("btn-save-context-keep")?.addEventListener("click", async () => {
try {
const n = Number(contextTraceKeepEl?.value ?? 5);
const data = await api("/api/settings", {
method: "PUT",
body: JSON.stringify({ contextTraceKeepLatest: n }),
});
state.settings = data.settings;
if (contextTraceKeepEl) {
contextTraceKeepEl.value = String(data.settings.contextTraceKeepLatest ?? 5);
}
showToast(`已保存:每个对话保留最新 ${data.settings.contextTraceKeepLatest} 条上下文`);
} catch (err) {
showToast(err.message, true);
}
});
document.getElementById("btn-prune-context")?.addEventListener("click", async () => {
try {
const r = await api("/api/settings/context-traces/prune", { method: "POST" });
showToast(
`已修剪 ${r.sessions} 个会话:保留 ${r.kept} 条痕迹,清除 ${r.cleared}`,
);
} catch (err) {
showToast(err.message, true);
}
});
document.getElementById("btn-clear-context")?.addEventListener("click", async () => {
if (!confirm("清除所有已打开会话中保存的 LLM 请求上下文?消息正文不受影响。")) {
return;
}
try {
const r = await api("/api/settings/context-traces/clear", { method: "POST" });
showToast(`已清除 ${r.sessions} 个会话中的 ${r.cleared} 条上下文痕迹`);
} catch (err) {
showToast(err.message, true);
}
});
renderPanelHeader();
loadAll().catch((err) => {
showToast(`加载设置失败: ${err.message}`, true);

112
web/stats.html Normal file
View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Token 统计 · Writing Agent</title>
<link rel="stylesheet" href="/styles.css" />
<link rel="stylesheet" href="/settings.css" />
<style>
.stats-page {
max-width: 920px;
margin: 0 auto;
padding: 1.5rem 1.25rem 3rem;
}
.stats-filters {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1rem;
align-items: end;
margin: 1rem 0 1.25rem;
}
.stats-filters label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.stats-filters input {
min-width: 11rem;
padding: 0.35rem 0.5rem;
}
.stats-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.stats-metric {
border: 1px solid var(--border, #ddd);
padding: 0.75rem;
}
.stats-metric strong {
display: block;
font-size: 1.25rem;
}
.stats-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.stats-table th,
.stats-table td {
border-bottom: 1px solid var(--border, #ddd);
padding: 0.4rem 0.5rem;
text-align: left;
vertical-align: top;
}
.stats-table th {
font-weight: 600;
}
.stats-muted {
color: var(--muted, #666);
font-size: 0.85rem;
}
</style>
</head>
<body>
<div class="stats-page">
<header>
<p><a href="/">← 对话</a> · <a href="/modules.html">能力</a> · <a href="/settings.html">设置</a></p>
<h1>Token 统计</h1>
<p class="stats-muted">按时间戳筛选调用记录(存于本地 stats/*.jsonl</p>
</header>
<div class="stats-filters">
<label>
<input type="datetime-local" id="from" />
</label>
<label>
<input type="datetime-local" id="to" />
</label>
<label>
上限条数
<input type="number" id="limit" value="200" min="10" max="5000" step="10" />
</label>
<button type="button" class="btn-sm" id="btn-load">查询</button>
<button type="button" class="btn-sm" id="btn-today">今天</button>
<button type="button" class="btn-sm" id="btn-7d">近 7 天</button>
</div>
<div class="stats-summary" id="summary"></div>
<p class="stats-muted" id="status"></p>
<table class="stats-table">
<thead>
<tr>
<th>时间</th>
<th>caller</th>
<th>model</th>
<th>prompt</th>
<th>completion</th>
<th>total</th>
<th>cached</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
<script type="module" src="/stats.js"></script>
</body>
</html>

113
web/stats.js Normal file
View File

@@ -0,0 +1,113 @@
function toLocalInputValue(d) {
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function localInputToIso(value) {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
function formatAt(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString();
}
async function loadStats() {
const from = localInputToIso(document.getElementById("from").value);
const to = localInputToIso(document.getElementById("to").value);
const limit = Number(document.getElementById("limit").value) || 200;
const qs = new URLSearchParams();
if (from) qs.set("from", from);
if (to) qs.set("to", to);
qs.set("limit", String(limit));
const status = document.getElementById("status");
status.textContent = "加载中…";
try {
const res = await fetch(`/api/stats/tokens?${qs}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
renderSummary(data.summary);
renderRows(data.records || []);
status.textContent = `${data.summary?.totalCalls ?? 0} 次调用 · 显示 ${(data.records || []).length}`;
} catch (err) {
status.textContent = `加载失败:${err instanceof Error ? err.message : String(err)}`;
}
}
function renderSummary(summary) {
const el = document.getElementById("summary");
if (!summary) {
el.innerHTML = "";
return;
}
const metrics = [
["调用次数", summary.totalCalls],
["总 token", summary.totalTokens],
["prompt", summary.promptTokens],
["completion", summary.completionTokens],
["cached", summary.totalCached],
];
el.innerHTML = metrics
.map(
([label, value]) =>
`<div class="stats-metric"><span class="stats-muted">${label}</span><strong>${value ?? 0}</strong></div>`,
)
.join("");
}
function renderRows(records) {
const tbody = document.getElementById("rows");
tbody.innerHTML = records
.map(
(r) => `<tr>
<td>${formatAt(r.at)}</td>
<td>${escapeHtml(r.caller || "")}</td>
<td>${escapeHtml(r.model || "")}</td>
<td>${r.promptTokens ?? 0}</td>
<td>${r.completionTokens ?? 0}</td>
<td>${r.totalTokens ?? 0}</td>
<td>${r.cachedTokens ?? "—"}</td>
</tr>`,
)
.join("");
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function setRange(fromDate, toDate) {
document.getElementById("from").value = toLocalInputValue(fromDate);
document.getElementById("to").value = toLocalInputValue(toDate);
}
document.getElementById("btn-load").addEventListener("click", () => loadStats());
document.getElementById("btn-today").addEventListener("click", () => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
setRange(start, now);
loadStats();
});
document.getElementById("btn-7d").addEventListener("click", () => {
const now = new Date();
const start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
setRange(start, now);
loadStats();
});
// 默认:今天
(() => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
setRange(start, now);
loadStats();
})();

File diff suppressed because it is too large Load Diff