重构世界模拟器为模块化配方架构,完善创作编排、会话运行时与 Web UI,并清理过时技能。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1214
web/agent-ui.js
1214
web/agent-ui.js
File diff suppressed because it is too large
Load Diff
1029
web/app.js
1029
web/app.js
File diff suppressed because it is too large
Load Diff
94
web/display-labels.js
Normal file
94
web/display-labels.js
Normal 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;
|
||||
}
|
||||
@@ -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 产出",
|
||||
|
||||
@@ -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
318
web/modules.css
Normal 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
67
web/modules.html
Normal 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
232
web/modules.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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
391
web/questions-ui.js
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* 结构化询问卡:点字母选中,点文案编辑;左右分页。
|
||||
* 普通提问:提交走底栏 composer(问+答拼进上下文),卡上无独立发送钮。
|
||||
* 产物验收挂载题:底栏有字优先按「改产物」发送;无字时才提交所选答案。
|
||||
* 分页 / 跳过为悬浮控件,不占独立 header/footer 行。
|
||||
*/
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
812
web/settings.css
812
web/settings.css
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
226
web/settings.js
226
web/settings.js
@@ -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
112
web/stats.html
Normal 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
113
web/stats.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
1048
web/styles.css
1048
web/styles.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user