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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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 已移除;保留空函数以免外部误调用 */
|
||||
|
||||
+195
-134
@@ -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 `<span class="composer-mode-chip" data-task="${esc(id)}"${tone} title="${esc(tip)}">${esc(label)}</span>`;
|
||||
}
|
||||
@@ -555,6 +566,27 @@ function composerInputShell(spec, { textareaHtml, trailing = "" } = {}) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `<span class="hamburger-icon" aria-hidden="true"></span>`;
|
||||
}
|
||||
}
|
||||
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 `<button type="button" class="btn" data-act="leave_step" title="${esc(
|
||||
@@ -585,6 +617,15 @@ function dropComposerDraft() {
|
||||
pendingComposerDraftSeq = 0;
|
||||
}
|
||||
|
||||
/** 发送成功前调用,避�?loading 重绘把已发内容又填回输入�?*/
|
||||
function clearComposerInput() {
|
||||
dropComposerDraft();
|
||||
const input = $("composer-input");
|
||||
if (!input) return;
|
||||
input.value = "";
|
||||
autosizeComposerInput(input);
|
||||
}
|
||||
|
||||
function renderComposer(view, loading) {
|
||||
stashComposerDraftFromDom();
|
||||
if (pendingComposerDraft) composerForceInput = true;
|
||||
@@ -607,7 +648,7 @@ function renderComposer(view, loading) {
|
||||
if (spec.mode === "waiting") {
|
||||
root.innerHTML = `<div class="composer-waiting">
|
||||
<span>${esc(spec.text)}</span>
|
||||
<button type="button" class="btn" data-act="retry_run" title="停止当前生成并重试">停止并重试</button>
|
||||
<button type="button" class="btn" data-act="retry_run" title="停止当前生成并重�?>停止并重�?/button>
|
||||
</div>`;
|
||||
root.querySelector("[data-act=retry_run]")?.addEventListener("click", () => retryRun());
|
||||
return;
|
||||
@@ -628,7 +669,7 @@ function renderComposer(view, loading) {
|
||||
: "";
|
||||
const shell = composerInputShell(spec, {
|
||||
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(spec.placeholder)}"></textarea>`,
|
||||
trailing: `<button type="submit" class="btn btn-primary composer-btn">发送</button>`,
|
||||
trailing: `<button type="submit" class="btn btn-primary composer-btn">发�?/button>`,
|
||||
});
|
||||
root.innerHTML = `
|
||||
${intakePanel}
|
||||
@@ -642,7 +683,7 @@ function renderComposer(view, loading) {
|
||||
|
||||
if (spec.mode === "action") {
|
||||
const reject = spec.showReject
|
||||
? `<button type="button" class="btn" data-act="reject">${spec.primaryType === "approve" ? "返回节点" : "不接受"}</button>
|
||||
? `<button type="button" class="btn" data-act="reject">${spec.primaryType === "approve" ? "返回节点" : "不接�?}</button>
|
||||
<button type="button" class="btn" data-act="force-input">说明意见</button>`
|
||||
: "";
|
||||
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")
|
||||
? `<p class="propose-sync-hint">改生成对象时,后台 id 也要改成对应英文(如丧尸怪物 → zombies)。</p>`
|
||||
? `<p class="propose-sync-hint">改生成对象时,后�?id 也要改成对应英文(如丧尸怪物 �?zombies)�?/p>`
|
||||
: "";
|
||||
const planHint =
|
||||
p.kind === "prior-artifact"
|
||||
? `<p class="propose-plan-hint">${
|
||||
originalTarget
|
||||
? "编排器已规划对象,步骤内可修订。"
|
||||
: "具体写什么在步骤内确定。"
|
||||
? "编排器已规划对象,步骤内可修订�?
|
||||
: "具体写什么在步骤内确定�?
|
||||
}</p>`
|
||||
: "";
|
||||
const closerHint = p.closer
|
||||
? `<p class="propose-plan-hint">这是收口步。若还没做 NPC 等前序节点,点「改意见」说明要补什么,会回到流程编排。</p>`
|
||||
? `<p class="propose-plan-hint">这是收口步。若还没�?NPC 等前序节点,点「改意见」说明要补什么,会回到流程编排�?/p>`
|
||||
: "";
|
||||
root.innerHTML = `
|
||||
<div class="propose-step" id="propose-step" data-step-id="${esc(p.stepId)}">
|
||||
<div class="propose-step-head">
|
||||
<span class="propose-kicker">${p.mode === "revise" ? "回头修改" : "下一步"}</span>
|
||||
<span class="propose-kicker">${p.mode === "revise" ? "回头修改" : "下一�?}</span>
|
||||
<strong class="propose-name">${esc(p.name)}</strong>
|
||||
</div>
|
||||
<p class="propose-output" data-propose-output>${esc(proposedOutputCopy(p))}</p>
|
||||
@@ -714,7 +755,7 @@ function renderComposer(view, loading) {
|
||||
<div class="composer-actions">
|
||||
<button type="button" class="btn btn-primary" data-act="approve">${esc(spec.primary.label)}</button>
|
||||
<button type="button" class="btn" data-act="reject" title="先不写这一步,回到节点选择">返回节点</button>
|
||||
<button type="button" class="btn" data-act="force-input">改意见</button>
|
||||
<button type="button" class="btn" data-act="force-input">改意�?/button>
|
||||
</div>
|
||||
</div>`;
|
||||
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)
|
||||
? `<button type="button" class="btn" data-act="enter-play">${
|
||||
view.hasProduct ? "用产物开玩" : "开玩"
|
||||
view.hasProduct ? "用产物开�? : "开�?
|
||||
}</button>`
|
||||
: "";
|
||||
const sendCls =
|
||||
@@ -792,7 +833,7 @@ function renderComposer(view, loading) {
|
||||
: "btn btn-primary composer-btn";
|
||||
const shell = composerInputShell(spec, {
|
||||
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(spec.placeholder)}"></textarea>`,
|
||||
trailing: `${saveProduct}${enterPlay}<button type="submit" class="${sendCls}" data-act="pick-replan" disabled title="写下意见后再发;点图上节点即确认并进入">${esc(spec.submitLabel || "发送")}</button>`,
|
||||
trailing: `${saveProduct}${enterPlay}<button type="submit" class="${sendCls}" data-act="pick-replan" disabled title="写下意见后再发;点图上节点即确认并进�?>${esc(spec.submitLabel || "发�?)}</button>`,
|
||||
});
|
||||
root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`;
|
||||
wireComposerForm({ requiresText: true });
|
||||
@@ -813,14 +854,14 @@ function renderComposer(view, loading) {
|
||||
acceptTone === "accept" ? " composer-btn-review" : ""
|
||||
}${acceptOnEmpty ? " btn-primary" : ""}" data-act="accept" title="${esc(
|
||||
spec.acceptAction.disabled
|
||||
? spec.acceptAction.disabledTitle || "暂不可确认"
|
||||
? spec.acceptAction.disabledTitle || "暂不可确�?
|
||||
: spec.acceptAction.title || spec.acceptAction.label
|
||||
)}" ${spec.acceptAction.disabled ? "disabled" : ""}>${esc(spec.acceptAction.label)}</button>`
|
||||
: "";
|
||||
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: `<textarea id="composer-input" rows="1" placeholder="${esc(placeholder)}"></textarea>`,
|
||||
@@ -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)}</button>${acceptBtn}`,
|
||||
});
|
||||
root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`;
|
||||
@@ -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 ? `<span class="explorer-twist" data-twist>${expanded ? "▾" : "▸"}</span>` : ""}
|
||||
${twistie ? `<span class="explorer-twist" data-twist>${expanded ? "�? : "�?}</span>` : ""}
|
||||
<span class="explorer-main">
|
||||
<span class="explorer-name" title="${esc(name)}">${esc(name)}</span>
|
||||
${meta ? `<span class="explorer-meta" title="${esc(meta)}">${esc(meta)}</span>` : ""}
|
||||
@@ -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 ? `<p class="empty">${emptyCopy}</p>` : "";
|
||||
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 = `<div class="composer-idle">暂无打开的作品</div>`;
|
||||
if (composerMain) composerMain.innerHTML = `<div class="composer-idle">暂无打开的作�?/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 = `<option value="">暂无配方</option>`;
|
||||
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();
|
||||
|
||||
+104
-45
@@ -11,6 +11,19 @@
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem("wa-color-theme") || "modern";
|
||||
if (!/^(paper|modern|dark|green)$/.test(t)) t = "modern";
|
||||
document.documentElement.setAttribute("data-color-theme", t);
|
||||
document.documentElement.setAttribute("data-theme", t);
|
||||
var c = localStorage.getItem("wa-present-chrome") || "default";
|
||||
if (!/^(default|messenger|book|terminal)$/.test(c)) c = "default";
|
||||
document.documentElement.setAttribute("data-present-chrome", c);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body data-lifecycle="design" data-nav-level="root">
|
||||
<div class="layout">
|
||||
@@ -66,54 +79,100 @@
|
||||
</aside>
|
||||
|
||||
<main class="panel panel-main">
|
||||
<div class="main-body">
|
||||
<section class="panel-feed" id="panel-feed">
|
||||
<div id="skill-picker" class="skill-picker" hidden></div>
|
||||
<div id="message-feed" class="message-feed"></div>
|
||||
<div id="workspace-stage" class="workspace-stage" hidden aria-label="验收工作面"></div>
|
||||
<div id="questions-card-host" class="questions-card-host" hidden></div>
|
||||
</section>
|
||||
<aside id="coord-rail" class="coord-rail" hidden aria-label="此前对话">
|
||||
<button
|
||||
type="button"
|
||||
class="coord-rail-toggle"
|
||||
id="btn-coord-toggle"
|
||||
aria-expanded="false"
|
||||
title="展开 / 收起此前对话"
|
||||
>
|
||||
<span class="coord-rail-toggle-label">对话</span>
|
||||
<span id="coord-drawer-count" class="coord-drawer-count"></span>
|
||||
</button>
|
||||
<div class="coord-rail-panel">
|
||||
<header class="coord-rail-head">
|
||||
<span class="coord-rail-title">此前对话</span>
|
||||
<button type="button" class="btn-sm coord-rail-collapse" id="btn-coord-collapse" title="收起" aria-label="收起此前对话">›</button>
|
||||
</header>
|
||||
<div id="coord-drawer-body" class="coord-drawer-body"></div>
|
||||
<div class="main-chrome" id="main-chrome">
|
||||
<header class="main-head" id="main-head">
|
||||
<div class="main-head-left" aria-hidden="true"></div>
|
||||
<nav class="main-app-nav" aria-label="应用">
|
||||
<a href="/modules.html">技能</a>
|
||||
<a href="/shells.html">呈现壳</a>
|
||||
<a href="/stats.html">Token</a>
|
||||
<a href="/settings.html">设置</a>
|
||||
</nav>
|
||||
<div class="main-head-right">
|
||||
<button type="button" class="btn-sm main-head-action" id="btn-save-instance" hidden>
|
||||
保存定稿
|
||||
</button>
|
||||
<button type="button" class="btn-sm main-head-action" id="btn-save-play" hidden>
|
||||
保存游玩
|
||||
</button>
|
||||
<button type="button" class="btn-sm main-head-action" id="btn-export" disabled>
|
||||
导出
|
||||
</button>
|
||||
<details class="more-menu theme-menu" id="theme-menu">
|
||||
<summary class="btn-sm more-menu-sum" id="theme-menu-sum" title="主题">
|
||||
<span class="hamburger-icon" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="more-menu-panel" role="menu">
|
||||
<div class="more-menu-label">主题</div>
|
||||
<button type="button" class="more-menu-item more-theme-item" data-color-theme="paper" role="menuitem">
|
||||
<span class="more-theme-icon" aria-hidden="true">📖</span>
|
||||
<span class="more-theme-copy"><span class="more-theme-name">仿纸暖黄</span><span class="more-theme-desc">经典护眼</span></span>
|
||||
</button>
|
||||
<button type="button" class="more-menu-item more-theme-item" data-color-theme="modern" role="menuitem">
|
||||
<span class="more-theme-icon" aria-hidden="true">✨</span>
|
||||
<span class="more-theme-copy"><span class="more-theme-name">现代清爽</span><span class="more-theme-desc">干净明快</span></span>
|
||||
</button>
|
||||
<button type="button" class="more-menu-item more-theme-item" data-color-theme="dark" role="menuitem">
|
||||
<span class="more-theme-icon" aria-hidden="true">🌙</span>
|
||||
<span class="more-theme-copy"><span class="more-theme-name">夜间暗色</span><span class="more-theme-desc">黑底灰字</span></span>
|
||||
</button>
|
||||
<button type="button" class="more-menu-item more-theme-item" data-color-theme="green" role="menuitem">
|
||||
<span class="more-theme-icon" aria-hidden="true">🍃</span>
|
||||
<span class="more-theme-copy"><span class="more-theme-name">豆沙绿</span><span class="more-theme-desc">自然疗愈</span></span>
|
||||
</button>
|
||||
<div class="more-menu-sep" role="separator"></div>
|
||||
<div class="more-menu-label">选壳</div>
|
||||
<button type="button" class="more-menu-item more-theme-item" data-present-chrome="default" role="menuitem">
|
||||
<span class="more-theme-copy"><span class="more-theme-name">默认</span><span class="more-theme-desc">跟随壳本身</span></span>
|
||||
</button>
|
||||
<button type="button" class="more-menu-item more-theme-item" data-present-chrome="messenger" role="menuitem">
|
||||
<span class="more-theme-copy"><span class="more-theme-name">讯息</span><span class="more-theme-desc">气泡对话感</span></span>
|
||||
</button>
|
||||
<button type="button" class="more-menu-item more-theme-item" data-present-chrome="book" role="menuitem">
|
||||
<span class="more-theme-copy"><span class="more-theme-name">书页</span><span class="more-theme-desc">阅读排版感</span></span>
|
||||
</button>
|
||||
<button type="button" class="more-menu-item more-theme-item" data-present-chrome="terminal" role="menuitem">
|
||||
<span class="more-theme-copy"><span class="more-theme-name">终端</span><span class="more-theme-desc">等宽终端感</span></span>
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</aside>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<footer class="composer" id="composer">
|
||||
<span class="status" id="status-pill"><span class="status-dot" id="status-dot"></span><span id="status-text">—</span></span>
|
||||
<div class="lifecycle-toggle" id="lifecycle-toggle" hidden role="group" aria-label="创作与游玩">
|
||||
<button type="button" class="lifecycle-btn" data-stage="design">创作</button>
|
||||
<button type="button" class="lifecycle-btn" data-stage="play">游玩</button>
|
||||
<div class="main-stack">
|
||||
<div class="main-body">
|
||||
<section class="panel-feed" id="panel-feed">
|
||||
<div id="skill-picker" class="skill-picker" hidden></div>
|
||||
<div id="message-feed" class="message-feed"></div>
|
||||
<div id="workspace-stage" class="workspace-stage" hidden aria-label="验收工作面"></div>
|
||||
<div id="questions-card-host" class="questions-card-host" hidden></div>
|
||||
</section>
|
||||
<aside id="coord-rail" class="coord-rail" hidden aria-label="此前对话">
|
||||
<button
|
||||
type="button"
|
||||
class="coord-rail-toggle"
|
||||
id="btn-coord-toggle"
|
||||
aria-expanded="false"
|
||||
title="展开 / 收起此前对话"
|
||||
>
|
||||
<span class="coord-rail-toggle-label">对话</span>
|
||||
<span id="coord-drawer-count" class="coord-drawer-count"></span>
|
||||
</button>
|
||||
<div class="coord-rail-panel">
|
||||
<header class="coord-rail-head">
|
||||
<span class="coord-rail-title">此前对话</span>
|
||||
<button type="button" class="btn-sm coord-rail-collapse" id="btn-coord-collapse" title="收起" aria-label="收起此前对话">›</button>
|
||||
</header>
|
||||
<div id="coord-drawer-body" class="coord-drawer-body"></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div id="composer-main"></div>
|
||||
<details class="more-menu">
|
||||
<summary class="btn-sm more-menu-sum" title="更多">更多</summary>
|
||||
<div class="more-menu-panel" role="menu">
|
||||
<button type="button" class="more-menu-item" id="btn-save-instance" hidden role="menuitem">保存定稿</button>
|
||||
<button type="button" class="more-menu-item" id="btn-save-play" hidden role="menuitem">保存游玩</button>
|
||||
<button type="button" class="more-menu-item" id="btn-export" disabled role="menuitem">导出</button>
|
||||
<a class="more-menu-item" href="/modules.html" role="menuitem">技能</a>
|
||||
<a class="more-menu-item" href="/shells.html" role="menuitem">呈现壳</a>
|
||||
<a class="more-menu-item" href="/stats.html" role="menuitem">Token</a>
|
||||
<a class="more-menu-item" href="/settings.html" role="menuitem">设置</a>
|
||||
</div>
|
||||
</details>
|
||||
</footer>
|
||||
|
||||
<footer class="composer" id="composer">
|
||||
<span class="status" id="status-pill"><span class="status-dot" id="status-dot"></span><span id="status-text">—</span></span>
|
||||
<div id="composer-main"></div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
+11
-1
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+305
@@ -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;
|
||||
}
|
||||
}
|
||||
+69
-98
@@ -4,109 +4,80 @@
|
||||
<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>
|
||||
<link rel="stylesheet" href="/stats.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="stats-page">
|
||||
<header>
|
||||
<p><a href="/">← 对话</a> · <a href="/modules.html">技能</a> · <a href="/shells.html">呈现壳</a> · <a href="/settings.html">设置</a></p>
|
||||
<h1>Token 统计</h1>
|
||||
<p class="stats-muted">按时间戳筛选调用记录(存于本地 stats/*.jsonl)</p>
|
||||
</header>
|
||||
<body class="settings-page stats-page">
|
||||
<header class="st-top">
|
||||
<a class="st-brand" href="/">Writing Agent</a>
|
||||
<nav class="st-nav" aria-label="主导航">
|
||||
<a href="/">对话</a>
|
||||
<a href="/modules.html">技能</a>
|
||||
<a href="/shells.html">呈现壳</a>
|
||||
<a href="/stats.html" aria-current="page">Token</a>
|
||||
<a href="/settings.html">设置</a>
|
||||
</nav>
|
||||
</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="st-shell stats-shell">
|
||||
<main class="st-main stats-main">
|
||||
<header class="st-main-head">
|
||||
<div>
|
||||
<h1>Token 统计</h1>
|
||||
<p class="st-sub">按时间筛选本机调用记录(<code>stats/*.jsonl</code>)</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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 class="stats-toolbar" role="search" aria-label="筛选条件">
|
||||
<div class="stats-toolbar-fields">
|
||||
<label class="stats-field">
|
||||
从
|
||||
<input type="datetime-local" id="from" />
|
||||
</label>
|
||||
<label class="stats-field">
|
||||
到
|
||||
<input type="datetime-local" id="to" />
|
||||
</label>
|
||||
<label class="stats-field">
|
||||
上限条数
|
||||
<input type="number" id="limit" value="200" min="10" max="5000" step="10" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="stats-toolbar-actions">
|
||||
<div class="stats-range" role="group" aria-label="快捷区间">
|
||||
<button type="button" class="btn-secondary" id="btn-today">今天</button>
|
||||
<button type="button" class="btn-secondary" id="btn-7d">近 7 天</button>
|
||||
</div>
|
||||
<button type="button" class="btn-primary" id="btn-load">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-summary" id="summary" aria-live="polite"></div>
|
||||
<p class="stats-status" id="status" data-state="idle" aria-live="polite"></p>
|
||||
|
||||
<div class="stats-table-wrap">
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">时间</th>
|
||||
<th scope="col">caller</th>
|
||||
<th scope="col">model</th>
|
||||
<th scope="col" class="num">prompt</th>
|
||||
<th scope="col" class="num">completion</th>
|
||||
<th scope="col" class="num">total</th>
|
||||
<th scope="col" class="num">cached</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
<div class="stats-empty" id="empty" hidden>
|
||||
<strong>这段时间没有调用记录</strong>
|
||||
换个区间,或先在对话里跑一轮再回来。
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/stats.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+78
-21
@@ -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]) =>
|
||||
`<div class="stats-metric"><span class="stats-muted">${label}</span><strong>${value ?? 0}</strong></div>`,
|
||||
([label, value, emphasis]) =>
|
||||
`<div class="stats-metric"${emphasis ? ` data-emphasis="${emphasis}"` : ""}>
|
||||
<span class="stats-metric-label">${label}</span>
|
||||
<strong class="stats-metric-value">${formatNum(value ?? 0)}</strong>
|
||||
</div>`,
|
||||
)
|
||||
.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) => `<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>
|
||||
<td class="col-time">${formatAt(r.at)}</td>
|
||||
<td class="col-caller" title="${escapeHtml(r.caller || "")}">${escapeHtml(r.caller || "—")}</td>
|
||||
<td class="col-model" title="${escapeHtml(r.model || "")}">${escapeHtml(r.model || "—")}</td>
|
||||
<td class="num">${formatNum(r.promptTokens ?? 0)}</td>
|
||||
<td class="num">${formatNum(r.completionTokens ?? 0)}</td>
|
||||
<td class="num col-total">${formatNum(r.totalTokens ?? 0)}</td>
|
||||
<td class="num">${r.cachedTokens == null ? "—" : formatNum(r.cachedTokens)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.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();
|
||||
})();
|
||||
|
||||
+240
-53
@@ -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;
|
||||
|
||||
+116
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user