From 770a8fdcdc4f65f27d34b14860b392a851d592c3 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Thu, 3 Sep 2026 00:58:25 +0800 Subject: [PATCH] Move theme menu to the header and group present chrome under it. Also align Token stats with the settings shell and drop leftover lifecycle toggle wiring. Co-authored-by: Cursor --- web/agent-ui.js | 17 --- web/app.js | 329 +++++++++++++++++++++++++----------------- web/index.html | 149 +++++++++++++------ web/present-shells.js | 12 +- web/shells.js | 1 + web/stats.css | 305 +++++++++++++++++++++++++++++++++++++++ web/stats.html | 167 +++++++++------------ web/stats.js | 99 ++++++++++--- web/styles.css | 293 ++++++++++++++++++++++++++++++------- web/theme.js | 116 +++++++++++++++ 10 files changed, 1119 insertions(+), 369 deletions(-) create mode 100644 web/stats.css create mode 100644 web/theme.js diff --git a/web/agent-ui.js b/web/agent-ui.js index c6542f7..efe42d5 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -890,23 +890,6 @@ function maybeSyncRail(view) { export function renderLifecycle(view) { const stage = view.lifecycleStage ?? "design"; document.body.dataset.lifecycle = stage; - const toggle = document.getElementById("lifecycle-toggle"); - if (!toggle) return; - const ready = Boolean(view?.playReady || view?.hasProduct); - toggle.hidden = !ready; - toggle.querySelectorAll("[data-stage]").forEach((btn) => { - const s = btn.getAttribute("data-stage"); - btn.classList.toggle("active", s === stage); - if (s === "play") { - btn.disabled = !ready; - btn.title = ready - ? "用已保存的产物开玩" - : "请先保存产物后再开玩"; - } else { - btn.disabled = false; - btn.title = "回到创作"; - } - }); } /** @deprecated 设计 Tab 已移除;保留空函数以免外部误调用 */ diff --git a/web/app.js b/web/app.js index 2be7270..ea07f80 100644 --- a/web/app.js +++ b/web/app.js @@ -17,6 +17,16 @@ import { isModuleOpeningWaiting, renderQuestionsCard, } from "./questions-ui.js"; +import { + applyColorTheme, + applyPresentChrome, + getStoredColorTheme, + getStoredPresentChrome, + initColorTheme, + initPresentChrome, + themeById, + chromeById, +} from "./theme.js"; let sessionId = null; let activeBookId = null; @@ -42,11 +52,11 @@ const LIVE_POLL_MS = 280; const $ = (id) => document.getElementById(id); -const PHASE = { idle: "待命", running: "执行中", waiting_user: "等待你", done: "已完成", error: "出错" }; +const PHASE = { idle: "待命", running: "执行�?, waiting_user: "等待�?, done: "已完�?, error: "出错" }; /** 用户任务三态(表层);底层 waitingReason 映射进来 */ const USER_TASK = { - speak: { id: "speak", label: "说话", title: "继续说" }, + speak: { id: "speak", label: "说话", title: "继续�? }, answer: { id: "answer", label: "答题", title: "回答问题" }, review: { id: "review", label: "验收", title: "验收产物" }, }; @@ -63,15 +73,15 @@ function isPlayView(view) { } /** - * 把 waitingReason 收成用户三态:说话 / 答题+自由发挥 / 批阅。 + * �?waitingReason 收成用户三态:说话 / 答题+自由发挥 / 批阅�? * @returns {{ id: 'speak'|'answer'|'review'|'busy'|'idle'|'error', label: string, title: string, hint: string|null }} */ function resolveUserTask(view, loading) { if (loading || isAgentBusy(view, loading)) { return { id: "busy", - label: "执行中", - title: view.focus?.action ?? "总管或 Worker 执行中…", + label: "执行�?, + title: view.focus?.action ?? "总管�?Worker 执行中�?, hint: null, }; } @@ -80,18 +90,18 @@ function resolveUserTask(view, loading) { id: "speak", label: USER_TASK.speak.label, title: "说明意见", - hint: "写完发送即可。", + hint: "写完发送即可�?, }; } if (view.phase === "done") { - return { id: "idle", label: "已完成", title: "会话已结束", hint: null }; + return { id: "idle", label: "已完�?, title: "会话已结�?, hint: null }; } if (view.phase === "error") { return { id: "error", label: "出错", - title: "说明后重试", - hint: view.hints?.[0] ?? "执行出错,可在下方重试", + title: "说明后重�?, + hint: view.hints?.[0] ?? "执行出错,可在下方重�?, }; } @@ -103,28 +113,28 @@ function resolveUserTask(view, loading) { return { id: "speak", label: USER_TASK.speak.label, - title: "继续说", - hint: "你说一句,世界推进一轮。", + title: "继续�?, + hint: "你说一句,世界推进一轮�?, }; } if (canOfferSaveProduct(view)) { return { id: "speak", - label: "可保存", + label: "可保�?, title: "落档产物", hint: view.hasProduct - ? "可再保存一份新定稿,或用已有产物开玩。补节点仍点图。" - : "点「保存定稿」拆出产物。创作流程还在,之后用产物开玩。", + ? "可再保存一份新定稿,或用已有产物开玩。补节点仍点图�? + : "点「保存定稿」拆出产物。创作流程还在,之后用产物开玩�?, }; } if (isFlowPlanReview(view) || wr?.kind === "pick_creation_step") { return { id: "speak", - label: "选节点", + label: "选节�?, title: "选要做的节点", - hint: "点可进入的节点即确认并开始;要改排在底栏写意见再发。", + hint: "点可进入的节点即确认并开始;要改排在底栏写意见再发�?, }; } @@ -138,14 +148,14 @@ function resolveUserTask(view, loading) { }; } - // 能力默认问题:对齐美学纲领开局 → 说话面,不进「答题」 + // 能力默认问题:对齐美学纲领开局 �?说话面,不进「答题�? if (moduleOpening) { const stepName = view.openingGuide?.stepName?.trim(); return { id: "speak", label: USER_TASK.speak.label, - title: stepName || "按引导先说几句", - hint: "想到什么写什么,不必整齐;写完发送即可。", + title: stepName || "按引导先说几�?, + hint: "想到什么写什么,不必整齐;写完发送即可�?, }; } @@ -154,7 +164,7 @@ function resolveUserTask(view, loading) { id: "answer", label: USER_TASK.answer.label, title: "回答问题", - hint: "先点选项作答;也可以在底栏自由补充。", + hint: "先点选项作答;也可以在底栏自由补充�?, }; } @@ -166,11 +176,11 @@ function resolveUserTask(view, loading) { title: proposed?.name ? proposed.mode === "revise" ? `回头修改 · ${proposed.name}` - : `接下来生成 · ${proposed.name}` - : "确认下一步", + : `接下来生�?· ${proposed.name}` + : "确认下一�?, hint: proposed ? proposedOutputCopy(proposed) - : view.focus?.detail ?? "确认执行,或说明意见。", + : view.focus?.detail ?? "确认执行,或说明意见�?, }; } @@ -178,8 +188,8 @@ function resolveUserTask(view, loading) { return { id: "speak", label: USER_TASK.speak.label, - title: "下一步想写什么", - hint: "说说接下来想做什么;可留空,也可说回头改某步。发送后会展示下一节点。", + title: "下一步想写什�?, + hint: "说说接下来想做什么;可留空,也可说回头改某步。发送后会展示下一节点�?, }; } @@ -188,7 +198,7 @@ function resolveUserTask(view, loading) { id: "speak", label: USER_TASK.speak.label, title: "说明修改意见", - hint: "写清楚要改哪里;发送后在现有产物上修改。", + hint: "写清楚要改哪里;发送后在现有产物上修改�?, }; } @@ -197,10 +207,10 @@ function resolveUserTask(view, loading) { return { id: "speak", label: USER_TASK.speak.label, - title: hasUser ? "继续补充" : "描述你想创作什么", + title: hasUser ? "继续补充" : "描述你想创作什�?, hint: hasUser - ? "继续说细节,或等信息齐后确认。" - : "用几句话说明题材、玩法或爽点即可。", + ? "继续说细节,或等信息齐后确认�? + : "用几句话说明题材、玩法或爽点即可�?, }; } @@ -208,8 +218,8 @@ function resolveUserTask(view, loading) { return { id: "speak", label: USER_TASK.speak.label, - title: "继续说", - hint: view.hints?.[0] ?? "直接输入你的想法或补充。", + title: "继续�?, + hint: view.hints?.[0] ?? "直接输入你的想法或补充�?, }; } @@ -217,7 +227,7 @@ function resolveUserTask(view, loading) { return { id: "speak", label: USER_TASK.speak.label, - title: "继续说", + title: "继续�?, hint: view.hints?.[0] ?? null, }; } @@ -263,17 +273,17 @@ function proposedOutputCopy(proposed) { : ""; if (proposed?.mode === "revise") { if (targetText) { - return `将在既有「${targetText}」的${proposed.name}上继续改,不是从零再生成。`; + return `将在既有�?{targetText}」的${proposed.name}上继续改,不是从零再生成。`; } - return `将在既有「${proposed?.name || "该步"}」上继续改,不是从零再生成。`; + return `将在既有�?{proposed?.name || "该步"}」上继续改,不是从零再生成。`; } if (targetText) { - return `将生成「${targetText}」的${proposed.name}。`; + return `将生成�?{targetText}」的${proposed.name}。`; } - return `接下来将生成:${proposed?.name || "下一项内容"}。`; + return `接下来将生成�?{proposed?.name || "下一项内�?}。`; } -/** 确认下一步:给人看「生成什么」;有 target 时顺带露出后台 id,便于改对象时同步。 */ +/** 确认下一步:给人看「生成什么」;�?target 时顺带露出后�?id,便于改对象时同步�?*/ function proposeVisibleFields(proposed) { if (proposed?.kind === "prior-artifact") return []; const missing = new Set(proposed?.paramsMissing || []); @@ -288,7 +298,7 @@ function proposeVisibleFields(proposed) { if (byKey.has("target")) { push("target"); - // 对象与后台 id 绑定:改女租客→丧尸时,rule_id 也要换成 zombies 一类英文 + // 对象与后�?id 绑定:改女租客→丧尸时,rule_id 也要换成 zombies 一类英�? if (byKey.has("rule_id")) push("rule_id"); } else if (byKey.has("rule_id") && (missing.has("rule_id") || proposed?.name === "具体实例")) { push("rule_id"); @@ -305,7 +315,7 @@ function proposeFieldLabel(field) { function proposeFieldHint(field, proposed) { if (field.key === "rule_id" && proposed?.paramSpecs?.some((s) => s.key === "target")) { - return "英文 kebab-case,须与上方对象对应;改对象时一并改(如丧尸→zombies)"; + return "英文 kebab-case,须与上方对象对应;改对象时一并改(如丧尸→zombies�?; } return field.hint || field.key; } @@ -314,7 +324,7 @@ function statusFor(view, loading) { const task = resolveUserTask(view, loading); if (task.id === "busy") return { cls: "running", text: task.title }; if (task.id === "error") return { cls: "", text: task.label }; - if (task.id === "idle" && view.phase === "done") return { cls: "done", text: "已完成" }; + if (task.id === "idle" && view.phase === "done") return { cls: "done", text: "已完�? }; if (task.id === "speak" || task.id === "answer" || task.id === "review") { return { cls: "waiting", text: task.label }; } @@ -334,7 +344,7 @@ function resolveComposer(view, loading) { return { mode: "waiting", text: task.title, task }; } if (view.phase === "done") { - return { mode: "idle", text: "会话已结束", task }; + return { mode: "idle", text: "会话已结�?, task }; } if (view.phase === "error") { const send = view.actions?.find((a) => a.type === "send_message"); @@ -343,7 +353,7 @@ function resolveComposer(view, loading) { task, taskTitle: task.title, taskHint: task.hint, - placeholder: send?.placeholder ?? "说明后重试,或直接发送继续…", + placeholder: send?.placeholder ?? "说明后重试,或直接发送继续�?, hint: null, }; } @@ -353,11 +363,11 @@ function resolveComposer(view, loading) { return { mode: "input", task, - taskTitle: task.title || "继续说", + taskTitle: task.title || "继续�?, taskHint: task.hint, - placeholder: send?.placeholder ?? "说你要做什么…", + placeholder: send?.placeholder ?? "说你要做什么�?, hint: null, - submitLabel: "发送", + submitLabel: "发�?, }; } @@ -374,7 +384,7 @@ function resolveComposer(view, loading) { intakePrompt: hasUser ? view.intakePrompt : null, showIntakePanel: hasUser, confirmIntake, - placeholder: send?.placeholder ?? "描述你想创作什么…", + placeholder: send?.placeholder ?? "描述你想创作什么�?, }; } @@ -408,7 +418,7 @@ function resolveComposer(view, loading) { mode: "action", task, taskTitle: "验收产物", - taskHint: "同意就接受;要改可先点「说明意见」。", + taskHint: "同意就接受;要改可先点「说明意见」�?, primary: accept, primaryType: "accept", hint: null, @@ -427,10 +437,10 @@ function resolveComposer(view, loading) { mode: "pick_step", task, taskTitle: "选要做的节点", - taskHint: "点可进入的节点即确认并开始;虚线原型点一下增殖。要改排在底栏写意见再发。", - placeholder: send?.placeholder ?? "例如:补舞台骨架、把某步改到开场白之前…", + taskHint: "点可进入的节点即确认并开始;虚线原型点一下增殖。要改排在底栏写意见再发�?, + placeholder: send?.placeholder ?? "例如:补舞台骨架、把某步改到开场白之前�?, hint: null, - submitLabel: "发送", + submitLabel: "发�?, requiresText: true, }; } @@ -467,9 +477,9 @@ function resolveComposer(view, loading) { task, taskTitle: task.title, taskHint: task.hint, - placeholder: send?.placeholder ?? "也可在此自由补充…", + placeholder: send?.placeholder ?? "也可在此自由补充�?, hint: null, - submitLabel: "补充并发送", + submitLabel: "补充并发�?, }; } @@ -483,27 +493,27 @@ function resolveComposer(view, loading) { composerForceInput ) { const wr = view.waitingReason; - let placeholder = send?.placeholder ?? "输入你想说的…"; - let submitLabel = "发送"; + let placeholder = send?.placeholder ?? "输入你想说的�?; + let submitLabel = "发�?; if (wr?.kind === "next_intent") { - placeholder = send?.placeholder ?? "下一步想写什么?(可留空)…"; + placeholder = send?.placeholder ?? "下一步想写什么?(可留空)�?; submitLabel = "继续"; } else if (wr?.kind === "approve_step" && composerForceInput) { placeholder = - "例如:补开局 NPC、改成丧尸怪物规则(后台 id 也换成 zombies)…"; - submitLabel = "发送意见"; + "例如:补开局 NPC、改成丧尸怪物规则(后�?id 也换�?zombies)�?; + submitLabel = "发送意�?; } else if (wr?.kind === "revision") { - placeholder = "说明要改哪里…"; - submitLabel = "按意见修改"; + placeholder = "说明要改哪里�?; + submitLabel = "按意见修�?; } else if (wr?.kind === "worker_questions" && !hasQuestionsCard) { placeholder = isModuleOpeningWaiting(view) - ? "想到什么写什么…" - : "直接回答问题…"; + ? "想到什么写什么�? + : "直接回答问题�?; } else if ( view.openingGuide?.text && !(view.messages ?? []).some((m) => m.role === "user") ) { - placeholder = "按上方引导写几句…"; + placeholder = "按上方引导写几句�?; } return { mode: "input", @@ -536,9 +546,10 @@ function resolveComposer(view, loading) { function composerModeChip(spec) { const id = spec.task?.id; - if (id !== "speak" && id !== "answer" && id !== "review") return ""; + /* 说话由左�?emoji「更多」承担,不再显示文字 chip */ + if (id !== "answer" && id !== "review") return ""; const label = spec.task.label || ""; - const tip = [spec.taskTitle, spec.taskHint].filter(Boolean).join(" — "); + const tip = [spec.taskTitle, spec.taskHint].filter(Boolean).join(" �?"); const tone = spec.acceptAction?.tone ? ` data-tone="${esc(spec.acceptAction.tone)}"` : ""; return `${esc(label)}`; } @@ -555,6 +566,27 @@ function composerInputShell(spec, { textareaHtml, trailing = "" } = {}) { `; } +function syncThemeMenuUi() { + const colorId = document.documentElement.getAttribute("data-color-theme") || getStoredColorTheme(); + const chromeId = + document.documentElement.getAttribute("data-present-chrome") || getStoredPresentChrome(); + const colorMeta = themeById(colorId); + const chromeMeta = chromeById(chromeId); + const sum = $("theme-menu-sum"); + if (sum) { + sum.title = `主题 · ${colorMeta.name} · ${chromeMeta.name}`; + if (!sum.querySelector(".hamburger-icon")) { + sum.innerHTML = ``; + } + } + document.querySelectorAll("[data-color-theme].more-theme-item").forEach((btn) => { + btn.classList.toggle("is-active", btn.getAttribute("data-color-theme") === colorMeta.id); + }); + document.querySelectorAll("[data-present-chrome].more-theme-item").forEach((btn) => { + btn.classList.toggle("is-active", btn.getAttribute("data-present-chrome") === chromeMeta.id); + }); +} + function leaveStepButtonHtml(spec) { if (!spec.leaveStep) return ""; return ` + `, + trailing: ` + ? ` ` : ""; const chip = composerModeChip(spec); @@ -687,23 +728,23 @@ function renderComposer(view, loading) { .join(""); const syncHint = visible.some((f) => f.key === "target") && visible.some((f) => f.key === "rule_id") - ? `

改生成对象时,后台 id 也要改成对应英文(如丧尸怪物 → zombies)。

` + ? `

改生成对象时,后�?id 也要改成对应英文(如丧尸怪物 �?zombies)�?/p>` : ""; const planHint = p.kind === "prior-artifact" ? `

${ originalTarget - ? "编排器已规划对象,步骤内可修订。" - : "具体写什么在步骤内确定。" + ? "编排器已规划对象,步骤内可修订�? + : "具体写什么在步骤内确定�? }

` : ""; const closerHint = p.closer - ? `

这是收口步。若还没做 NPC 等前序节点,点「改意见」说明要补什么,会回到流程编排。

` + ? `

这是收口步。若还没�?NPC 等前序节点,点「改意见」说明要补什么,会回到流程编排�?/p>` : ""; root.innerHTML = `

- ${p.mode === "revise" ? "回头修改" : "下一步"} + ${p.mode === "revise" ? "回头修改" : "下一�?} ${esc(p.name)}

${esc(proposedOutputCopy(p))}

@@ -714,7 +755,7 @@ function renderComposer(view, loading) {
- +
`; const targetInput = root.querySelector('[data-param-key="target"]'); @@ -760,7 +801,7 @@ function renderComposer(view, loading) { originalRuleId && nextRuleId === originalRuleId ) { - alert("生成对象已改,请把后台 id 改成对应英文(例如丧尸怪物 → zombies),不要沿用旧 id。"); + alert("生成对象已改,请把后�?id 改成对应英文(例如丧尸怪物 �?zombies),不要沿用�?id�?); ruleIdInput?.focus(); markRuleIdSync(); return; @@ -783,7 +824,7 @@ function renderComposer(view, loading) { : ""; const enterPlay = canOfferEnterPlay(view) ? `` : ""; const sendCls = @@ -792,7 +833,7 @@ function renderComposer(view, loading) { : "btn btn-primary composer-btn"; const shell = composerInputShell(spec, { textareaHtml: ``, - trailing: `${saveProduct}${enterPlay}`, + trailing: `${saveProduct}${enterPlay}` : ""; - const submitLabel = spec.submitLabel || "发送"; + const submitLabel = spec.submitLabel || "发�?; const sendIsPrimary = !acceptOnEmpty; const placeholder = acceptOnEmpty - ? `${spec.placeholder || "修改意见…"}(${spec.emptyEnterHint || "空 Enter=确认"})` + ? `${spec.placeholder || "修改意见�?}�?{spec.emptyEnterHint || "�?Enter=确�?})` : spec.placeholder; const shell = composerInputShell(spec, { textareaHtml: ``, @@ -828,8 +869,8 @@ function renderComposer(view, loading) { sendIsPrimary ? " btn-primary" : "" }" data-act="revise" title="${esc( acceptOnEmpty - ? `有字或已选追问时 Enter=${submitLabel}` - : "Enter 发送" + ? `有字或已选追问时 Enter�?{submitLabel}` + : "Enter 发�? )}">${esc(submitLabel)}${acceptBtn}`, }); root.innerHTML = `
${shell}
`; @@ -846,7 +887,7 @@ function composerTextareaMaxPx(el) { return Math.min(window.innerHeight * 0.42, 360); } -/** SillyTavern 式:高度随内容上长,触顶后框内滚动 */ +/** SillyTavern 式:高度随内容上长,触顶后框内滚�?*/ function autosizeComposerInput(el) { if (!el) return; const max = composerTextareaMaxPx(el); @@ -877,7 +918,7 @@ function applyPendingComposerDraft() { refreshReviewComposerChrome(); } -/** 无输入框的确认态:Enter = 主按钮 */ +/** 无输入框的确认态:Enter = 主按�?*/ function wireComposerActionEnter(action, opts = {}) { const root = $("composer"); if (!root) return; @@ -912,8 +953,8 @@ function refreshReviewComposerChrome() { const empty = !String(input.value ?? "").trim(); const hasAnswers = selectedQuestionAnswers(lastView).answered.length > 0; input.placeholder = hasAnswers && empty - ? `${copy.placeholder}(Enter=${copy.submitLabel})` - : `${copy.placeholder}(${copy.emptyEnterHint})`; + ? `${copy.placeholder}(Enter�?{copy.submitLabel})` + : `${copy.placeholder}�?{copy.emptyEnterHint})`; } function syncDualComposerPrimary(input, acceptBtn, sendBtn) { @@ -932,7 +973,7 @@ function syncPickReplanButton(input) { btn.disabled = !hasText; btn.title = hasText ? "发送改编排意见" - : "写下意见后再发;点图上节点即确认并进入"; + : "写下意见后再发;点图上节点即确认并进�?; } function wireComposerForm(opts = {}) { @@ -1099,7 +1140,7 @@ function refreshBookMenuLabels() { const del = $("book-menu-delete"); if (del) del.textContent = n > 1 ? `删除 ${n} 项` : "删除"; const sel = $("book-menu-select"); - if (sel) sel.textContent = bookSelectMode ? "取消多选" : "多选"; + if (sel) sel.textContent = bookSelectMode ? "取消多�? : "多�?; } function showBookMenu(bookId, x, y) { @@ -1143,7 +1184,7 @@ function pathSep() { li.setAttribute("aria-hidden", "true"); const sep = document.createElement("span"); sep.className = "path-sep"; - sep.textContent = "›"; + sep.textContent = "�?; li.appendChild(sep); return li; } @@ -1276,7 +1317,7 @@ function renderExplorerRow({ if (bookId) row.dataset.bookId = bookId; if (title) row.title = title; row.innerHTML = ` - ${twistie ? `${expanded ? "▾" : "▸"}` : ""} + ${twistie ? `${expanded ? "�? : "�?}` : ""} ${esc(name)} ${meta ? `${esc(meta)}` : ""} @@ -1326,7 +1367,7 @@ function renderProductContents(book, product, list) { if (loading && saves === undefined) { const hint = document.createElement("p"); hint.className = "explorer-hint"; - hint.textContent = "加载存档…"; + hint.textContent = "加载存档�?; list.appendChild(hint); return; } @@ -1340,7 +1381,7 @@ function renderProductContents(book, product, list) { list.appendChild( renderExplorerRow({ name: "当前游玩", - meta: playHere && !activePlaySaveId ? "进行中" : "工作副本", + meta: playHere && !activePlaySaveId ? "进行�? : "工作副本", active: playHere && !activePlaySaveId, onClick: () => void openCurrentPlay(book.id), }), @@ -1350,7 +1391,7 @@ function renderProductContents(book, product, list) { if (!runs.length && !hasWorking) { const hint = document.createElement("p"); hint.className = "explorer-hint"; - hint.textContent = "还没有游玩存档"; + hint.textContent = "还没有游玩存�?; list.appendChild(hint); } @@ -1383,7 +1424,7 @@ function renderBookContents(book, list) { list.appendChild( renderExplorerRow({ name: "继续创作", - meta: designing ? "进行中" : "", + meta: designing ? "进行�? : "", active: designing, onClick: () => void openBookDesign(book.id), }), @@ -1394,7 +1435,7 @@ function renderBookContents(book, list) { if (loading && saves === undefined) { const hint = document.createElement("p"); hint.className = "explorer-hint"; - hint.textContent = "加载存档…"; + hint.textContent = "加载存档�?; list.appendChild(hint); return; } @@ -1404,8 +1445,8 @@ function renderBookContents(book, list) { const hint = document.createElement("p"); hint.className = "explorer-hint"; hint.textContent = bookPlayReady(book.id) - ? "还没有定稿。可在更多菜单里保存定稿。" - : "验收后可保存定稿。"; + ? "还没有定稿。可在更多菜单里保存定稿�? + : "验收后可保存定稿�?; list.appendChild(hint); return; } @@ -1419,13 +1460,13 @@ function renderBookContents(book, list) { for (const product of products) { const bits = []; - if (product.id === latestId) bits.push("最新"); - if (playingInstanceId === product.id) bits.push("游玩中"); + if (product.id === latestId) bits.push("最�?); + if (playingInstanceId === product.id) bits.push("游玩�?); list.appendChild( renderExplorerRow({ name: product.label, meta: bits.join(" · "), - title: "打开定稿,双击改名", + title: "打开定稿,双击改�?, onClick: () => navigateToProduct(book.id, product.id), onRename: () => void renamePlaySave(book.id, product.id, product.label), onDelete: () => void deletePlaySave(book.id, product.id, product.label, "定稿"), @@ -1461,7 +1502,7 @@ function renderBookList() { if (loading) { const hint = document.createElement("p"); hint.className = "explorer-hint"; - hint.textContent = "加载存档…"; + hint.textContent = "加载存档�?; list.appendChild(hint); return; } @@ -1570,7 +1611,7 @@ async function deleteSelectedBooks() { if (!ids.length) return; const label = ids.length === 1 - ? `「${books.find((b) => b.id === ids[0])?.title ?? "该作品"}」` + ? `�?{books.find((b) => b.id === ids[0])?.title ?? "该作�?}」` : `${ids.length} 个作品`; if (!confirm(`删除 ${label}?不可恢复。`)) return; try { @@ -1700,7 +1741,7 @@ async function renamePlaySave(bookId, saveId, currentLabel) { } async function deletePlaySave(bookId, saveId, label, kindLabel = "游玩存档") { - if (!confirm(`删除${kindLabel}「${label}」?`)) return; + if (!confirm(`删除${kindLabel}�?{label}」?`)) return; try { await api( `/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}`, @@ -1720,7 +1761,7 @@ async function deletePlaySave(bookId, saveId, label, kindLabel = "游玩存档") async function startNewPlayForBook(bookId, instanceId) { const thisView = lastView?.bookId === bookId ? lastView : null; if (!bookPlayReady(bookId) && !thisView?.hasProduct && !canOfferEnterPlay(thisView)) { - alert("请先保存产物,或完成收口后再开玩"); + alert("请先保存产物,或完成收口后再开�?); return; } try { @@ -1789,7 +1830,7 @@ async function openCurrentPlay(bookId) { function renderHeader(view, loading) { const st = statusFor(view, loading); - const title = view.bookTitle ?? "未命名作品"; + const title = view.bookTitle ?? "未命名作�?; document.title = `${title} · Writing Agent`; $("status-dot").className = `status-dot ${st.cls}`; $("status-text").textContent = st.text; @@ -1814,15 +1855,13 @@ function closeWorkspace({ emptyCopy = "" } = {}) { composerForceInput = false; document.title = "Writing Agent"; $("status-dot").className = "status-dot"; - $("status-text").textContent = "—"; + $("status-text").textContent = "�?; const btnPlay = $("btn-save-play"); if (btnPlay) btnPlay.hidden = true; const btnInst = $("btn-save-instance"); if (btnInst) btnInst.hidden = true; const btnExport = $("btn-export"); if (btnExport) btnExport.disabled = true; - const toggle = $("lifecycle-toggle"); - if (toggle) toggle.hidden = true; $("message-feed").innerHTML = emptyCopy ? `

${emptyCopy}

` : ""; const stage = $("workspace-stage"); if (stage) { @@ -1855,7 +1894,7 @@ function closeWorkspace({ emptyCopy = "" } = {}) { hideBookMenu(); document.body.dataset.lifecycle = "design"; const composerMain = $("composer-main"); - if (composerMain) composerMain.innerHTML = `
暂无打开的作品
`; + if (composerMain) composerMain.innerHTML = `
暂无打开的作�?/div>`; } function renderEmpty() { @@ -1993,7 +2032,7 @@ async function messageAction(kind, messageId, body = {}) { } } -/** 询问卡已选中的追问作答(不要求卡仍标 is-open;未选则为空) */ +/** 询问卡已选中的追问作答(不要求卡仍标 is-open;未选则为空�?*/ function selectedQuestionAnswers(view) { const qHost = $("questions-card-host"); const activeQs = getActiveQuestions(view); @@ -2007,11 +2046,11 @@ function selectedQuestionAnswers(view) { return { collected, answered }; } -/** 空 Enter=接受;有字或已选追问则按意见改。点「按意见修改」不走这条。 */ +/** �?Enter=接受;有字或已选追问则按意见改。点「按意见修改」不走这条�?*/ async function submitComposer(text, { acceptOnEmpty, requiresText } = {}) { const trimmed = String(text ?? "").trim(); if (requiresText && !trimmed) { - alert("点图上节点即确认并进入;要改编排请先写下意见。"); + alert("点图上节点即确认并进入;要改编排请先写下意见�?); return; } if (acceptOnEmpty && !trimmed) { @@ -2038,20 +2077,20 @@ async function sendText(text) { activeQs?.questions?.length && qHost?.classList.contains("is-open"), ); - // 能力默认问题:对齐美学纲领,须自由书写,不能空发/跳过当答复 + // 能力默认问题:对齐美学纲领,须自由书写,不能空发/跳过当答�? if (isModuleOpeningWaiting(lastView) && !trimmed) { - alert("请先按主栏引导写几句再发送。"); + alert("请先按主栏引导写几句再发送�?); return; } - // 核对/验收:有选中追问或修改意见 ⇒ 写回产物;点「按意见修改」绝不等于接受 + // 核对/验收:有选中追问或修改意�?�?写回产物;点「按意见修改」绝不等于接�? if (reviewing) { const { collected, answered } = selectedQuestionAnswers(lastView); if (!trimmed && answered.length === 0) { alert( isFlowPlanReview(lastView) - ? "点图上节点即确认并进入;要改编排请先写下意见。" - : "请选择追问选项或填写修改意见;满意请点「接受」收下产物。", + ? "点图上节点即确认并进入;要改编排请先写下意见�? + : "请选择追问选项或填写修改意见;满意请点「接受」收下产物�?, ); return; } @@ -2061,6 +2100,7 @@ async function sendText(text) { body.answers = collected.answers; } clearQuestionCardState(qHost, lastView); + clearComposerInput(); renderSession(lastView, true); const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { method: "POST", @@ -2071,6 +2111,7 @@ async function sendText(text) { renderSession(view, false); } catch (err) { if (isStaleUiRequest(seq)) return; + if (trimmed) keepComposerDraft(trimmed); if (qHost) qHost._qDismissed = null; if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); } @@ -2082,7 +2123,7 @@ async function sendText(text) { const answered = collected.ok ? collected.answers.filter((a) => a.text && a.text !== "(未答)") : []; - // 必答题未选:拦住发送 + // 必答题未选:拦住发�? if (!collected.ok && !activeQs.optional) { if (typeof collected.page === "number" && qHost._qState) { qHost._qState.page = collected.page; @@ -2093,10 +2134,11 @@ async function sendText(text) { alert(collected.error); return; } - // 有选中 → 问+答拼接发送,并收起卡 + // 有选中 �?�?答拼接发送,并收起卡 if (collected.ok && answered.length) { try { clearQuestionCardState(qHost, lastView); + clearComposerInput(); renderSession(lastView, true); const body = { answers: collected.answers }; if (trimmed) body.note = trimmed; @@ -2109,12 +2151,13 @@ async function sendText(text) { renderSession(view, false); } catch (err) { if (isStaleUiRequest(seq)) return; + if (trimmed) keepComposerDraft(trimmed); if (qHost) qHost._qDismissed = null; if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); } return; } - // 未选中:不带问;点发送仍算答复 → 收起卡 + // 未选中:不带问;点发送仍算答�?�?收起�? if (!trimmed) { clearQuestionCardState(qHost, lastView); await runAction("skip_questions"); @@ -2128,7 +2171,7 @@ async function sendText(text) { lastView?.waitingReason?.kind === "pick_creation_step" || isFlowPlanReview(lastView) ) { - alert("点图上节点即确认并进入;要改编排请先写下意见。"); + alert("点图上节点即确认并进入;要改编排请先写下意见�?); return; } if ( @@ -2136,6 +2179,7 @@ async function sendText(text) { resolveComposer(lastView, false)?.allowEmpty ) { try { + clearComposerInput(); renderSession(lastView, true); const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { method: "POST", @@ -2152,6 +2196,7 @@ async function sendText(text) { return; } try { + clearComposerInput(); renderSession(lastView, true); const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { method: "POST", @@ -2162,6 +2207,7 @@ async function sendText(text) { renderSession(view, false); } catch (err) { if (isStaleUiRequest(seq)) return; + keepComposerDraft(trimmed); if (cardOpen && qHost) qHost._qDismissed = null; if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); } @@ -2227,7 +2273,7 @@ async function setLifecycle(stage) { } } if (stage === "play" && !lastView?.playReady && !canOfferEnterPlay(lastView) && !lastView?.hasProduct) { - alert("请先保存产物,或完成收口后再开玩"); + alert("请先保存产物,或完成收口后再开�?); return; } try { @@ -2269,7 +2315,7 @@ async function populateDirectorSelect() { sel.innerHTML = ``; if (desc) { desc.hidden = false; - desc.textContent = "尚未配置配方选项(recipes/catalog.yaml)。"; + desc.textContent = "尚未配置配方选项(recipes/catalog.yaml)�?; } return; } @@ -2302,7 +2348,7 @@ async function populateDirectorSelect() { } async function createBook() { - const title = $("input-book-title").value.trim() || "未命名作品"; + const title = $("input-book-title").value.trim() || "未命名作�?; const recipeId = $("select-director")?.value?.trim(); if (!recipeId) { alert("请选择配方"); @@ -2355,7 +2401,7 @@ async function openBook(bookId) { async function deleteBookById(bookId) { const book = books.find((b) => b.id === bookId); if (!book) return; - if (!confirm(`删除「${book.title}」?不可恢复。`)) return; + if (!confirm(`删除�?{book.title}」?不可恢复。`)) return; try { await api(`/api/books/${encodeURIComponent(bookId)}`, { method: "DELETE" }); books = books.filter((b) => b.id !== bookId); @@ -2376,7 +2422,7 @@ async function deleteBookById(bookId) { } async function saveCurrentPlay() { - if (!activeBookId || !lastView?.playReady) return alert("须先验收 Worker 集"); + if (!activeBookId || !lastView?.playReady) return alert("须先验收 Worker �?); if (lastView.lifecycleStage !== "play" || !lastView.playLayerActive) { return alert("请先进入游玩"); } @@ -2403,7 +2449,7 @@ function defaultArchiveLabel() { async function saveCurrentInstance() { if (!activeBookId || (!lastView?.playReady && !canOfferSaveProduct(lastView))) { - return alert("须先完成收口或验收 Worker 集"); + return alert("须先完成收口或验�?Worker �?); } const label = prompt("落档名称", defaultArchiveLabel()); if (!label?.trim()) return; @@ -2522,11 +2568,6 @@ function exportSession() { downloadMarkdown(`${name}.md`, sessionToMarkdown(lastView)); } -$("lifecycle-toggle")?.addEventListener("click", (e) => { - const stage = e.target.closest("[data-stage]")?.getAttribute("data-stage"); - if (stage === "play") enterPlayNow(); - else if (stage === "design") void setLifecycle("design"); -}); $("btn-new-book")?.addEventListener("click", openNewBookDialog); $("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close()); $("book-action-menu")?.addEventListener("click", (e) => { @@ -2580,18 +2621,35 @@ $("form-new-book")?.addEventListener("submit", (e) => { createBook(); }); $("btn-export")?.addEventListener("click", () => { - document.querySelector("details.more-menu")?.removeAttribute("open"); exportSession(); }); $("btn-save-play")?.addEventListener("click", () => { - document.querySelector("details.more-menu")?.removeAttribute("open"); void saveCurrentPlay(); }); $("btn-save-instance")?.addEventListener("click", () => { - document.querySelector("details.more-menu")?.removeAttribute("open"); void saveCurrentInstance(); }); +$("theme-menu")?.addEventListener("click", (e) => { + const menu = $("theme-menu"); + if (!menu) return; + const colorBtn = e.target.closest("[data-color-theme]"); + if (colorBtn && menu.contains(colorBtn)) { + e.preventDefault(); + applyColorTheme(colorBtn.getAttribute("data-color-theme")); + syncThemeMenuUi(); + menu.removeAttribute("open"); + return; + } + const chromeBtn = e.target.closest("[data-present-chrome]"); + if (chromeBtn && menu.contains(chromeBtn)) { + e.preventDefault(); + applyPresentChrome(chromeBtn.getAttribute("data-present-chrome")); + syncThemeMenuUi(); + menu.removeAttribute("open"); + } +}); + window.addEventListener("wa:session-updated", (e) => { const view = e.detail; if (!view?.id || isCatalogNav() || !sessionId) return; @@ -2610,6 +2668,9 @@ document.addEventListener("click", (e) => { }); async function init() { + initColorTheme(); + initPresentChrome(); + syncThemeMenuUi(); try { void populateDirectorSelect(); await loadBooks(); diff --git a/web/index.html b/web/index.html index 0c717d9..d78683d 100644 --- a/web/index.html +++ b/web/index.html @@ -11,6 +11,19 @@ rel="stylesheet" /> +
@@ -66,54 +79,100 @@
-
-
- -
- - -
- +
- -
- — -
+ +
+ — +
+
+
diff --git a/web/present-shells.js b/web/present-shells.js index 95b86c6..0b82dec 100644 --- a/web/present-shells.js +++ b/web/present-shells.js @@ -267,10 +267,20 @@ export function renderPresentShellHtml(packet, esc, opts = {}) { typeof tweaks.show_suggested_actions === "boolean" ? tweaks.show_suggested_actions : ACTIONS_DEFAULT_ON.has(shell); - const tone = + let tone = typeof tweaks.tone_chrome === "string" && tweaks.tone_chrome.trim() ? tweaks.tone_chrome.trim() : "default"; + if (opts.applyUserChrome !== false) { + try { + const pref = document.documentElement.getAttribute("data-present-chrome"); + if (pref === "messenger" || pref === "book" || pref === "terminal") { + tone = pref; + } + } catch { + /* ignore */ + } + } const blocks = packet.blocks ?? {}; const regions = shellDefaultRegions(shell); diff --git a/web/shells.js b/web/shells.js index 34c415c..1bca6ce 100644 --- a/web/shells.js +++ b/web/shells.js @@ -256,6 +256,7 @@ function render() { preview.dataset.shell = state.shell; preview.innerHTML = renderPresentShellHtml(packet, esc, { tweaks: buildTweaks(), + applyUserChrome: false, }); document.querySelectorAll("[data-shell]").forEach((btn) => { diff --git a/web/stats.css b/web/stats.css new file mode 100644 index 0000000..4c04993 --- /dev/null +++ b/web/stats.css @@ -0,0 +1,305 @@ +/* Token 统计 — 沿用 settings 壳,表格为扫描主面 */ + +.stats-shell { + grid-template-columns: minmax(0, 1fr); + max-width: 1120px; +} + +.stats-main { + padding: 0 0 40px; + min-width: 0; +} + +.stats-main .st-main-head { + margin-bottom: 4px; + padding-left: 24px; + padding-right: 24px; +} + +.stats-toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px 12px; + padding: 12px 14px; + margin: 0 24px 16px; + background: var(--st-surface); + border: 1px solid var(--st-border); + border-radius: var(--st-radius); +} + +.stats-toolbar-fields { + display: flex; + flex-wrap: wrap; + gap: 10px 12px; + flex: 1 1 auto; + min-width: 0; +} + +.stats-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + font-weight: 500; + color: var(--st-muted); +} + +.stats-field input { + min-width: 11.5rem; + height: 32px; + padding: 0 8px; + border: 1px solid var(--st-border-strong); + border-radius: 6px; + background: var(--st-surface); + color: var(--st-text); +} + +.stats-field input[type="number"] { + min-width: 5.5rem; + width: 5.5rem; +} + +.stats-field input:focus { + outline: none; + border-color: var(--st-accent); + box-shadow: 0 0 0 3px var(--st-accent-soft); +} + +.stats-toolbar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.stats-range { + display: inline-flex; + padding: 2px; + border-radius: 7px; + background: #f4f4f5; + border: 1px solid var(--st-border); +} + +.stats-range .btn-secondary { + height: 28px; + padding: 0 10px; + border: none; + background: transparent; + box-shadow: none; + color: var(--st-muted); + font-size: 12px; +} + +.stats-range .btn-secondary:hover { + background: transparent; + color: var(--st-text); +} + +.stats-range .btn-secondary.is-active { + background: var(--st-surface); + color: var(--st-text); + box-shadow: 0 0 0 1px var(--st-border-strong); +} + +.stats-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin: 0 24px 12px; +} + +.stats-metric { + padding: 12px 14px; + background: var(--st-surface); + border: 1px solid var(--st-border); + border-radius: var(--st-radius); + min-width: 0; +} + +.stats-metric-label { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--st-muted); + margin-bottom: 4px; +} + +.stats-metric-value { + display: block; + font-size: 1.35rem; + font-weight: 650; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + color: var(--st-text); + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stats-metric[data-emphasis="total"] { + border-color: color-mix(in srgb, var(--st-accent) 28%, var(--st-border)); + background: color-mix(in srgb, var(--st-accent-soft) 55%, var(--st-surface)); +} + +.stats-metric[data-emphasis="total"] .stats-metric-value { + color: var(--st-accent); +} + +.stats-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 1.4em; + margin: 0 24px 10px; + font-size: 12px; + color: var(--st-muted); +} + +.stats-status[data-state="error"] { + color: var(--st-danger); +} + +.stats-status[data-state="loading"] { + color: var(--st-faint); +} + +.stats-table-wrap { + background: var(--st-surface); + border: 1px solid var(--st-border); + border-radius: var(--st-radius); + overflow: auto; + max-height: min(70vh, 720px); + margin: 0 24px; +} + +.stats-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.stats-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: #fafafa; + border-bottom: 1px solid var(--st-border); + padding: 8px 12px; + text-align: left; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--st-muted); + white-space: nowrap; +} + +.stats-table tbody td { + padding: 8px 12px; + border-bottom: 1px solid var(--st-border); + vertical-align: top; + color: var(--st-text); +} + +.stats-table tbody tr:last-child td { + border-bottom: none; +} + +.stats-table tbody tr:hover td { + background: #fafafa; +} + +.stats-table .num { + text-align: right; + font-variant-numeric: tabular-nums; + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + font-size: 12px; +} + +.stats-table th.num { + text-align: right; +} + +.stats-table .col-time { + white-space: nowrap; + color: var(--st-muted); + font-variant-numeric: tabular-nums; + font-size: 12px; +} + +.stats-table .col-caller, +.stats-table .col-model { + max-width: 14rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + font-size: 12px; +} + +.stats-table .col-total { + font-weight: 600; +} + +.stats-empty { + padding: 36px 20px; + text-align: center; + color: var(--st-muted); + font-size: 13px; +} + +.stats-empty strong { + display: block; + color: var(--st-text); + font-size: 14px; + margin-bottom: 4px; +} + +@media (max-width: 900px) { + .stats-summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .stats-main .st-main-head, + .stats-toolbar, + .stats-summary, + .stats-status, + .stats-table-wrap { + margin-left: 14px; + margin-right: 14px; + } + + .stats-main .st-main-head { + padding-left: 14px; + padding-right: 14px; + margin-left: 0; + margin-right: 0; + } + + .stats-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .stats-field input { + min-width: 0; + width: 100%; + } + + .stats-toolbar-fields { + width: 100%; + } + + .stats-toolbar-fields .stats-field { + flex: 1 1 8rem; + } + + .stats-toolbar-actions { + width: 100%; + justify-content: space-between; + } +} diff --git a/web/stats.html b/web/stats.html index 0b55ace..c65740e 100644 --- a/web/stats.html +++ b/web/stats.html @@ -4,109 +4,80 @@ Token 统计 · Writing Agent - - + - -
-
-

← 对话 · 技能 · 呈现壳 · 设置

-

Token 统计

-

按时间戳筛选调用记录(存于本地 stats/*.jsonl)

-
+ +
+ Writing Agent + +
-
- - - - - - -
+
+
+
+
+

Token 统计

+

按时间筛选本机调用记录(stats/*.jsonl)

+
+
-
-

- - - - - - - - - - - - - -
时间callermodelpromptcompletiontotalcached
+ + +
+

+ +
+ + + + + + + + + + + + + +
时间callermodelpromptcompletiontotalcached
+ +
+
+ diff --git a/web/stats.js b/web/stats.js index 6d61c91..ab7fa04 100644 --- a/web/stats.js +++ b/web/stats.js @@ -13,7 +13,31 @@ function formatAt(iso) { if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; - return d.toLocaleString(); + return d.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); +} + +function formatNum(n) { + if (n == null || Number.isNaN(Number(n))) return "—"; + return Number(n).toLocaleString("zh-CN"); +} + +function setStatus(text, state = "idle") { + const status = document.getElementById("status"); + status.textContent = text; + status.dataset.state = state; +} + +function setRangeActive(id) { + for (const btn of document.querySelectorAll(".stats-range .btn-secondary")) { + btn.classList.toggle("is-active", btn.id === id); + } } async function loadStats() { @@ -25,17 +49,26 @@ async function loadStats() { if (to) qs.set("to", to); qs.set("limit", String(limit)); - const status = document.getElementById("status"); - status.textContent = "加载中…"; + setStatus("加载中…", "loading"); try { const res = await fetch(`/api/stats/tokens?${qs}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); + const records = data.records || []; renderSummary(data.summary); - renderRows(data.records || []); - status.textContent = `共 ${data.summary?.totalCalls ?? 0} 次调用 · 显示 ${(data.records || []).length} 条`; + renderRows(records); + const totalCalls = data.summary?.totalCalls ?? 0; + setStatus( + `共 ${formatNum(totalCalls)} 次调用 · 显示 ${formatNum(records.length)} 条`, + "idle", + ); } catch (err) { - status.textContent = `加载失败:${err instanceof Error ? err.message : String(err)}`; + renderSummary(null); + renderRows([]); + setStatus( + `加载失败:${err instanceof Error ? err.message : String(err)}`, + "error", + ); } } @@ -46,32 +79,47 @@ function renderSummary(summary) { return; } const metrics = [ - ["调用次数", summary.totalCalls], - ["总 token", summary.totalTokens], - ["prompt", summary.promptTokens], - ["completion", summary.completionTokens], - ["cached", summary.totalCached], + ["调用次数", summary.totalCalls, null], + ["总 token", summary.totalTokens, "total"], + ["prompt", summary.promptTokens, null], + ["completion", summary.completionTokens, null], + ["cached", summary.totalCached, null], ]; el.innerHTML = metrics .map( - ([label, value]) => - `
${label}${value ?? 0}
`, + ([label, value, emphasis]) => + `
+ ${label} + ${formatNum(value ?? 0)} +
`, ) .join(""); } function renderRows(records) { const tbody = document.getElementById("rows"); + const empty = document.getElementById("empty"); + const table = tbody.closest("table"); + + if (!records.length) { + tbody.innerHTML = ""; + if (table) table.hidden = true; + empty.hidden = false; + return; + } + + if (table) table.hidden = false; + empty.hidden = true; tbody.innerHTML = records .map( (r) => ` - ${formatAt(r.at)} - ${escapeHtml(r.caller || "")} - ${escapeHtml(r.model || "")} - ${r.promptTokens ?? 0} - ${r.completionTokens ?? 0} - ${r.totalTokens ?? 0} - ${r.cachedTokens ?? "—"} + ${formatAt(r.at)} + ${escapeHtml(r.caller || "—")} + ${escapeHtml(r.model || "—")} + ${formatNum(r.promptTokens ?? 0)} + ${formatNum(r.completionTokens ?? 0)} + ${formatNum(r.totalTokens ?? 0)} + ${r.cachedTokens == null ? "—" : formatNum(r.cachedTokens)} `, ) .join(""); @@ -90,24 +138,33 @@ function setRange(fromDate, toDate) { document.getElementById("to").value = toLocalInputValue(toDate); } -document.getElementById("btn-load").addEventListener("click", () => loadStats()); +document.getElementById("btn-load").addEventListener("click", () => { + setRangeActive(""); + 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); + setRangeActive("btn-today"); 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); + setRangeActive("btn-7d"); loadStats(); }); +document.getElementById("from").addEventListener("change", () => setRangeActive("")); +document.getElementById("to").addEventListener("change", () => setRangeActive("")); + // 默认:今天 (() => { const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); setRange(start, now); + setRangeActive("btn-today"); loadStats(); })(); diff --git a/web/styles.css b/web/styles.css index d900516..47f1632 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,13 +1,14 @@ /* Functional layout — agent-first, no production-line chrome */ :root { - --bg: #f4f4f5; + /* Default = modern;实际以色主题 data-color-theme 为准 */ + --bg: #f8f8f8; --surface: #fff; - --border: #d4d4d8; - --text: #18181b; - --muted: #71717a; - --accent: #2563eb; - --accent-soft: #dbeafe; + --border: #e0e0e0; + --text: #2c2c2c; + --muted: #999999; + --accent: #4a9fb0; + --accent-soft: rgba(74, 159, 176, 0.12); --ok: #16a34a; --warn: #ca8a04; --danger: #dc2626; @@ -22,11 +23,65 @@ --composer-control-h: 36px; --font-ui: "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI", system-ui, sans-serif; --font-read: "Noto Serif SC", "Songti SC", "STSong", "Source Han Serif SC", Georgia, serif; + --shadow: 0 8px 24px rgba(0, 0, 0, 0.08); font-family: var(--font-ui); font-size: 14px; color: var(--text); } +/* 仿纸暖黄(护眼)*/ +html[data-color-theme="paper"] { + --bg: #f5ecd7; + --surface: #fbf8f2; + --border: #d9d0c1; + --text: #3e3a35; + --muted: #8b8580; + --accent: #8b9a6b; + --accent-soft: rgba(139, 154, 107, 0.15); + --shadow: 0 8px 24px rgba(62, 58, 53, 0.1); +} + +/* 现代清爽 */ +html[data-color-theme="modern"] { + --bg: #f8f8f8; + --surface: #fff; + --border: #e0e0e0; + --text: #2c2c2c; + --muted: #999999; + --accent: #4a9fb0; + --accent-soft: rgba(74, 159, 176, 0.12); + --shadow: 0 8px 24px rgba(0, 0, 0, 0.08); +} + +/* 夜间暗色 */ +html[data-color-theme="dark"] { + --bg: #1e1e1e; + --surface: #2d2d30; + --border: #3e3e40; + --text: #bdbbb6; + --muted: #757575; + --accent: #d4a76a; + --accent-soft: rgba(212, 167, 106, 0.15); + --shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} + +/* 豆沙绿 */ +html[data-color-theme="green"] { + --bg: #dce5d9; + --surface: #f4f9f2; + --border: #c0cfbb; + --text: #2b3330; + --muted: #7a8580; + --accent: #7d9b7a; + --accent-soft: rgba(125, 155, 122, 0.15); + --shadow: 0 8px 24px rgba(43, 51, 48, 0.1); +} + +html { + background: var(--bg); + color: var(--text); +} + /* 开创作后缩进左侧栏 */ body.rail-collapsed { --books-w: var(--books-w-collapsed); @@ -37,6 +92,8 @@ html, body { height: 100%; margin: 0; overflow: hidden; /* 浏览器层永不出现页面滚动条 */ + background: var(--bg); + color: var(--text); } button, input, textarea { font: inherit; } a.link { color: var(--accent); text-decoration: none; } @@ -255,7 +312,18 @@ body[data-nav-level="root"] .burst-badge { display: none; } body.rail-collapsed .panel-rail { align-items: stretch; } /* composer 按内容自适应;中间区吃掉剩余高度,整页锁在视口内 */ +/* panel-main:上=悬停遮盖 chrome;下=工作区(左中右在 main-body / head 内) */ .panel-main { + display: grid; + grid-template-rows: minmax(0, 1fr); + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; + position: relative; +} + +.main-stack { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-width: 0; @@ -264,19 +332,105 @@ body.rail-collapsed .panel-rail { align-items: stretch; } overflow: hidden; } +/* 顶缘热区 + 遮盖条:不占文档流,悬停/聚焦才展开 */ +.main-chrome { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 30; + height: 10px; + pointer-events: auto; +} .main-head { display: grid; - grid-template-columns: 1fr auto 1fr; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; - gap: 12px; - padding: 0 12px; + gap: 8px 12px; + min-height: 40px; + padding: 4px 12px; border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--bg) 92%, transparent); + backdrop-filter: blur(8px); + box-shadow: 0 4px 16px color-mix(in srgb, var(--text) 8%, transparent); + transform: translateY(calc(-100% + 10px)); + opacity: 0; + pointer-events: none; + transition: + transform 0.18s ease, + opacity 0.14s ease; +} +.main-chrome:hover, +.main-chrome:focus-within, +.main-chrome:has(.theme-menu[open]) { + height: auto; + min-height: 40px; +} +.main-chrome:hover .main-head, +.main-chrome:focus-within .main-head, +.main-chrome:has(.theme-menu[open]) .main-head { + transform: translateY(0); + opacity: 1; + pointer-events: auto; } .main-head-left { min-width: 0; } -.main-head-right { display: flex; align-items: center; gap: 8px; justify-content: flex-end; flex-wrap: wrap; } +.main-app-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 2px; + flex-wrap: wrap; +} +.main-app-nav a { + padding: 5px 10px; + border-radius: 6px; + text-decoration: none; + color: var(--muted); + font-size: 13px; + font-weight: 500; + line-height: 1.2; + white-space: nowrap; +} +.main-app-nav a:hover { + color: var(--text); + background: var(--accent-soft); +} +.main-app-nav a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +.main-head-right { + display: flex; + align-items: center; + gap: 6px; + justify-content: flex-end; + flex-wrap: wrap; + min-width: 0; +} +.main-head-action[hidden] { display: none !important; } .work-title { margin: 0; font-size: 16px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .work-meta { font-size: 12px; color: var(--muted); } +@media (max-width: 720px) { + .main-head { + grid-template-columns: 1fr; + justify-items: stretch; + min-height: 0; + padding: 6px 10px; + gap: 6px; + } + .main-head-left { display: none; } + .main-app-nav { justify-content: flex-start; } + .main-head-right { justify-content: flex-start; } +} + +@media (hover: none) { + /* 触控无悬停:顶缘仍可点开,聚焦后保持 */ + .main-chrome { + height: 12px; + } +} + .main-body { display: grid; grid-template-columns: minmax(0, 1fr); @@ -1774,30 +1928,6 @@ body.design-workspace .flow-step[data-run="done"] .flow-name { } .sidebar-empty { padding: 12px; color: var(--muted); font-size: 13px; text-align: center; } -/* Lifecycle */ -.lifecycle-toggle { - display: inline-flex; - flex: 0 0 auto; - align-self: center; - border: 1px solid var(--border); - border-radius: 8px; - padding: 2px; - background: var(--surface); -} -.lifecycle-toggle[hidden] { display: none; } -.lifecycle-btn { - border: none; background: transparent; padding: 4px 10px; border-radius: 6px; - font-size: 12px; cursor: pointer; color: var(--muted); font-weight: 500; -} -.lifecycle-btn.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } -.lifecycle-btn:disabled { opacity: 0.4; cursor: not-allowed; } -.lifecycle-btn:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; -} -body[data-lifecycle="play"] { --accent: #16a34a; --accent-soft: #dcfce7; --bg: #f0fdf4; } -body[data-lifecycle="play"] .panel-rail { background: #f7fef9; } - /* Skill picker — primary action when starting */ .skill-picker { padding: 16px; @@ -2201,18 +2331,36 @@ body.rail-collapsed .message-feed { .msg-review .ws-focus-hint { margin-bottom: 0; font-size: 11px; } .msg-review .review-worker-set { margin: 0 0 6px; } -/* 顶栏「更多」:冷入口收纳 */ +/* 顶栏主题菜单:色主题 + 选壳 */ .more-menu { position: relative; display: inline-block; + flex: 0 0 auto; } .more-menu-sum { list-style: none; cursor: pointer; user-select: none; + line-height: 1; + height: 28px; + min-width: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding-inline: 6px; } .more-menu-sum::-webkit-details-marker { display: none; } .more-menu-sum::marker { content: ""; } +.hamburger-icon { + display: block; + width: 14px; + height: 11px; + background: + linear-gradient(currentColor, currentColor) 0 0 / 100% 1.5px no-repeat, + linear-gradient(currentColor, currentColor) 0 50% / 100% 1.5px no-repeat, + linear-gradient(currentColor, currentColor) 0 100% / 100% 1.5px no-repeat; +} .more-menu[open] .more-menu-sum { background: var(--accent-soft); border-color: var(--accent); @@ -2220,22 +2368,34 @@ body.rail-collapsed .message-feed { } .more-menu-panel { position: absolute; + left: auto; right: 0; top: calc(100% + 4px); + bottom: auto; z-index: 80; - min-width: 132px; + min-width: 188px; + max-height: min(70vh, 480px); + overflow: auto; padding: 4px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); + box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 1px; } -.composer .more-menu-panel { - top: auto; - bottom: calc(100% + 4px); +.more-menu-sep { + height: 1px; + margin: 4px 6px; + background: var(--border); +} +.more-menu-label { + padding: 6px 10px 2px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--muted); } .more-menu-item { display: block; @@ -2260,6 +2420,43 @@ body.rail-collapsed .message-feed { background: var(--bg); color: var(--accent); } +.more-theme-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; +} +.more-theme-item.is-active { + background: var(--accent-soft); + color: var(--accent); +} +.more-theme-icon { + flex: 0 0 auto; + width: 1.25em; + text-align: center; + font-size: 15px; + line-height: 1; +} +.more-theme-copy { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} +.more-theme-name { + font-size: 13px; + font-weight: 600; + line-height: 1.2; +} +.more-theme-desc { + font-size: 11px; + color: var(--muted); + font-weight: 400; + line-height: 1.2; +} +.more-theme-item.is-active .more-theme-desc { + color: color-mix(in srgb, var(--accent) 70%, var(--muted)); +} /* 提问索引气泡:决策在下方询问卡 */ .msg-bubble-index { @@ -4487,7 +4684,7 @@ body.design-workspace .af-mosaic { .timeline-item { padding: 6px 0; border-bottom: 1px solid var(--border); } .timeline-empty { color: var(--muted); } -/* Composer — 单行:状态 / 输入 / 更多;菜单向上弹出不被裁切 */ +/* Composer — emoji 更多在左 / 输入 / 发送;菜单向上弹出不被裁切 */ .composer { border-top: 1px solid var(--border); padding: 6px var(--workspace-pad-x, 16px) 8px; @@ -4507,7 +4704,8 @@ body.design-workspace .af-mosaic { align-self: center; min-width: 0; } -.composer:has(.composer-mode-chip) > .status { +.composer:has(.composer-mode-chip) > .status, +.composer:has(.composer-input-shell) > .status { display: none; } #composer-main { @@ -4517,17 +4715,6 @@ body.design-workspace .af-mosaic { max-height: min(50vh, 420px); overflow: auto; } -.composer > .more-menu { - flex: 0 0 auto; - align-self: flex-end; - margin-bottom: 2px; -} -.composer .more-menu-sum { - height: calc(var(--composer-control-h) - 8px); - display: inline-flex; - align-items: center; - box-sizing: border-box; -} /* 唯一描边:输入 + 操作同框 */ .composer-input-shell { flex: 1 1 auto; diff --git a/web/theme.js b/web/theme.js new file mode 100644 index 0000000..b054ba1 --- /dev/null +++ b/web/theme.js @@ -0,0 +1,116 @@ +/** Color themes + present chrome — aligned with llm_workflow_engine ThemeToggle. */ + +export const COLOR_THEMES = [ + { + id: "paper", + name: "仿纸暖黄", + description: "经典护眼,像 Kindle", + icon: "📖", + }, + { + id: "modern", + name: "现代清爽", + description: "干净明快,适合网文", + icon: "✨", + }, + { + id: "dark", + name: "夜间暗色", + description: "黑底灰字,夜间必备", + icon: "🌙", + }, + { + id: "green", + name: "豆沙绿", + description: "自然疗愈,绿色经典", + icon: "🍃", + }, +]; + +/** 呈现壳气质(tone_chrome),与色主题同属「主题」菜单 */ +export const PRESENT_CHROMES = [ + { id: "default", name: "默认", description: "跟随壳本身" }, + { id: "messenger", name: "讯息", description: "气泡对话感" }, + { id: "book", name: "书页", description: "阅读排版感" }, + { id: "terminal", name: "终端", description: "等宽终端感" }, +]; + +const STORAGE_KEY = "wa-color-theme"; +const CHROME_STORAGE_KEY = "wa-present-chrome"; +const THEME_IDS = new Set(COLOR_THEMES.map((t) => t.id)); +const CHROME_IDS = new Set(PRESENT_CHROMES.map((t) => t.id)); +const DEFAULT_THEME = "modern"; +const DEFAULT_CHROME = "default"; + +export function normalizeColorTheme(id) { + const v = String(id || "").trim(); + return THEME_IDS.has(v) ? v : DEFAULT_THEME; +} + +export function getStoredColorTheme() { + try { + return normalizeColorTheme(localStorage.getItem(STORAGE_KEY)); + } catch { + return DEFAULT_THEME; + } +} + +export function themeById(id) { + const tid = normalizeColorTheme(id); + return COLOR_THEMES.find((t) => t.id === tid) || COLOR_THEMES[1]; +} + +export function applyColorTheme(id) { + const theme = normalizeColorTheme(id); + document.documentElement.setAttribute("data-color-theme", theme); + document.documentElement.setAttribute("data-theme", theme); + try { + localStorage.setItem(STORAGE_KEY, theme); + } catch { + /* ignore quota / private mode */ + } + return theme; +} + +export function initColorTheme() { + return applyColorTheme(getStoredColorTheme()); +} + +export function normalizePresentChrome(id) { + const v = String(id || "").trim(); + return CHROME_IDS.has(v) ? v : DEFAULT_CHROME; +} + +export function getStoredPresentChrome() { + try { + return normalizePresentChrome(localStorage.getItem(CHROME_STORAGE_KEY)); + } catch { + return DEFAULT_CHROME; + } +} + +export function chromeById(id) { + const cid = normalizePresentChrome(id); + return PRESENT_CHROMES.find((t) => t.id === cid) || PRESENT_CHROMES[0]; +} + +export function applyPresentChrome(id) { + const chrome = normalizePresentChrome(id); + document.documentElement.setAttribute("data-present-chrome", chrome); + try { + localStorage.setItem(CHROME_STORAGE_KEY, chrome); + } catch { + /* ignore */ + } + document.querySelectorAll(".present-shell").forEach((el) => { + for (const cls of [...el.classList]) { + if (cls.startsWith("present-tone--")) el.classList.remove(cls); + } + el.classList.add(`present-tone--${chrome}`); + }); + return chrome; +} + +export function initPresentChrome() { + return applyPresentChrome(getStoredPresentChrome()); +}