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:
2026-09-03 00:58:25 +08:00
co-authored by Cursor
parent 92b2d88738
commit 770a8fdcdc
10 changed files with 1119 additions and 369 deletions
-17
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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());
}