Files
writing-agent/web/app.js
T
chenmoranzhiandCursor dd0a9e87d8 接通主角绑定、开场封印与游玩辅助,让世界模拟器从创作连续落到本局。
直聘插入按模块回馈,并记录请求骨架便于排查。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 07:40:08 +08:00

4138 lines
136 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
renderWorkspace,
updateLiveStreamPanel,
resetRailChrome,
setRailTab,
fillStepArtifactDialog,
canOfferSaveProduct,
canOfferEnterPlay,
} from "./agent-ui.js";
import { downloadMarkdown, sessionToMarkdown } from "./export.js";
import { renderIntakePanel } from "./intake-ui.js";
import { displayCreationModeLabel, displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js";
import {
clearQuestionCardState,
collectQuestionAnswers,
getActiveQuestions,
isModuleOpeningWaiting,
renderQuestionsCard,
} from "./questions-ui.js";
import {
applyColorTheme,
bumpUiFontScale,
getStoredColorTheme,
getStoredUiFontScale,
initColorTheme,
initUiFontScale,
themeById,
UI_FONT_SCALE_MAX,
UI_FONT_SCALE_MIN,
} from "./theme.js";
import {
applyMarkdownRender,
initMarkdownRender,
isMarkdownRenderEnabled,
} from "./markdown.js";
import { wireDirectiveAutocomplete } from "./directive-autocomplete.js";
import {
refreshDirectiveHighlight,
wireDirectiveHighlight,
} from "./directive-highlight.js";
let sessionId = null;
let activeBookId = null;
let lastView = null;
let books = [];
let composerForceInput = false;
/** 全局用户角色(不依赖会话);含 description */
let globalPersonas = [];
let globalActivePersonaId = null;
/** 面板内正在编辑的角色 id;`__new__` 表示新建 */
let personaEditingId = null;
let pendingComposerDraft = "";
let pendingComposerDraftSeq = 0;
/** 正在提交底栏:重绘时不要把已捕获的正文再塞进 pending */
let skipComposerStash = false;
let sidebarNav = { level: "root" };
let bookSelectMode = false;
let selectedBookIds = new Set();
let activePlaySaveId = null;
const playSavesByBook = new Map();
const playWorkingInstanceByBook = new Map();
const playSavesLoading = new Set();
/** @type {Map<string, Set<string>>} */
const expandedSpecsByBook = new Map();
let bookMenuBookId = null;
let enterPickerBookId = null;
let livePollTimer = null;
/** 本轮 live poll 是否已见过服务端 runActive=true(用于判断「跑完了」) */
let livePollSawRun = false;
/** 启动轮询时的结构指纹;用于漏掉 runActive 时的收尾兜底 */
let livePollBaselineSig = "";
/** 结构指纹:消息/产物变化时在 loading 下整页刷新,避免主区空白 */
let livePollStructuralSig = "";
/** 本轮轮询开始时间 */
let livePollStartedAt = 0;
/** 连续轮询失败次数(服务重启后 session 会 404) */
let livePollFailCount = 0;
let inflightSeq = 0;
/** 底栏菜单:只切当前 API,不编辑 */
let apiProfileList = [];
let activeApiProfileId = null;
/** idle | loading | ready | error */
let apiProfileMenuStatus = "idle";
let apiProfileMenuError = "";
let apiProfileSwitchingId = null;
const LIVE_POLL_MS = 280;
const $ = (id) => document.getElementById(id);
const PHASE = { idle: "待命", running: "执行中", waiting_user: "等待你", done: "已完成", error: "出错" };
/** 用户任务三态(表层);底层 waitingReason 映射进来 */
const USER_TASK = {
speak: { id: "speak", label: "说话", title: "继续说" },
answer: { id: "answer", label: "答题", title: "回答问题" },
review: { id: "review", label: "验收", title: "验收产物" },
};
function reviewCopyFromView(view) {
return reviewComposerCopy(view?.reviewArtifact?.workerId, {
hasQuestions: Boolean(getActiveQuestions(view)?.questions?.length),
outputTags: view?.reviewArtifact?.outputTags,
});
}
function isPlayView(view) {
return Boolean(view?.playLayerActive || view?.lifecycleStage === "play");
}
/**
* 把 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 执行中…",
hint: null,
};
}
if (composerForceInput) {
return {
id: "speak",
label: USER_TASK.speak.label,
title: "说明意见",
hint: "写完发送即可。",
};
}
if (view.phase === "done") {
return { id: "idle", label: "已完成", title: "会话已结束", hint: null };
}
if (view.phase === "error") {
return {
id: "error",
label: "出错",
title: "说明后重试",
hint: view.hints?.[0] ?? "执行出错,可在下方重试",
};
}
const wr = view.waitingReason;
const hasQuestions = Boolean(getActiveQuestions(view)?.questions?.length);
const moduleOpening = isModuleOpeningWaiting(view);
if (isPlayView(view)) {
return {
id: "speak",
label: USER_TASK.speak.label,
title: "继续说",
hint: "你说一句,世界推进一轮。",
};
}
if (canOfferSaveProduct(view)) {
return {
id: "speak",
label: "可保存",
title: "落档产物",
hint: view.hasProduct
? "可再保存一份新定稿,或用已有产物开玩。"
: view.creationMode === "dictate"
? "点「保存定稿」拆出产物。创作对话还在,之后用产物开玩。"
: "点「保存定稿」拆出产物。创作流程还在,之后用产物开玩。",
};
}
if (isFlowPlanReview(view) || wr?.kind === "pick_creation_step") {
return {
id: "speak",
label: "选节点",
title: "选要做的节点",
hint: "点图上可进入的节点即开始。要看技能池点上方「技能」。",
};
}
if (wr?.kind === "review_artifact") {
const copy = reviewCopyFromView(view);
return {
id: "review",
label: copy.taskLabel,
title: copy.taskTitle,
hint: copy.hint,
};
}
// 能力默认问题:对齐美学纲领开局 → 说话面,不进「答题」
if (moduleOpening) {
const stepName = view.openingGuide?.stepName?.trim();
return {
id: "speak",
label: USER_TASK.speak.label,
title: stepName || "按引导先说几句",
hint: "想到什么写什么,不必整齐;写完发送即可。",
};
}
if (hasQuestions || wr?.kind === "worker_questions") {
return {
id: "answer",
label: USER_TASK.answer.label,
title: "回答问题",
hint: "先点选项作答;也可以在底栏自由补充。",
};
}
if (wr?.kind === "approve_step") {
const proposed = view.proposedNextStep;
return {
id: "speak",
label: "确认",
title: proposed?.name
? proposed.mode === "revise"
? `回头修改 · ${proposed.name}`
: `接下来生成 · ${proposed.name}`
: "确认下一步",
hint: proposed
? proposedOutputCopy(proposed)
: view.focus?.detail ?? "确认执行,或说明意见。",
};
}
if (wr?.kind === "next_intent") {
return {
id: "speak",
label: USER_TASK.speak.label,
title: "下一步想写什么",
hint: "说说接下来想做什么;可留空,也可说回头改某步。发送后会展示下一节点。",
};
}
if (wr?.kind === "revision") {
return {
id: "speak",
label: USER_TASK.speak.label,
title: "说明修改意见",
hint: "写清楚要改哪里;发送后在现有产物上修改。",
};
}
if (wr?.kind === "intake") {
const hasUser = (view.messages ?? []).some((m) => m.role === "user");
return {
id: "speak",
label: USER_TASK.speak.label,
title: hasUser ? "继续补充" : "描述你想创作什么",
hint: hasUser
? "继续说细节,或等信息齐后确认。"
: "用几句话说明题材、玩法或爽点即可。",
};
}
if (wr?.kind === "input" || wr?.kind === "skill_selection") {
return {
id: "speak",
label: USER_TASK.speak.label,
title: "继续说",
hint: view.hints?.[0] ?? "直接输入你的想法或补充。",
};
}
if (view.phase === "waiting_user") {
return {
id: "speak",
label: USER_TASK.speak.label,
title: "继续说",
hint: view.hints?.[0] ?? null,
};
}
return {
id: "idle",
label: PHASE[view.phase] ?? view.phase,
title: PHASE[view.phase] ?? "待命",
hint: null,
};
}
function applyUserTaskChrome(task) {
document.body.dataset.userTask = task?.id || "";
}
/** @type {{ id: string, name: string, description: string }[]} */
let directors = [];
async function api(path, options = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "请求失败");
return data;
}
function esc(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function proposedOutputCopy(proposed) {
const params = proposed?.params;
const target =
params && typeof params === "object"
? params.target ?? params.object ?? params["对象"] ?? params["生成对象"]
: null;
const targetText =
typeof target === "string" || typeof target === "number"
? String(target).trim()
: "";
if (proposed?.mode === "revise") {
if (targetText) {
return `将在既有「${targetText}」的${proposed.name}上继续改,不是从零再生成。`;
}
return `将在既有「${proposed?.name || "该步"}」上继续改,不是从零再生成。`;
}
if (targetText) {
return `将生成「${targetText}」的${proposed.name}。`;
}
return `接下来将生成:${proposed?.name || "下一项内容"}。`;
}
/** 确认下一步:给人看「生成什么」;有 target 时顺带露出后台 id,便于改对象时同步。 */
function proposeVisibleFields(proposed) {
if (proposed?.kind === "prior-artifact") return [];
const missing = new Set(proposed?.paramsMissing || []);
const specs = Array.isArray(proposed?.paramSpecs) ? proposed.paramSpecs : [];
const byKey = new Map(specs.map((s) => [s.key, s]));
const out = [];
const push = (key) => {
const spec = byKey.get(key);
if (!spec || out.some((s) => s.key === key)) return;
out.push(spec);
};
if (byKey.has("target")) {
push("target");
// 对象与后台 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");
}
for (const key of missing) push(key);
return out;
}
function proposeFieldLabel(field) {
if (field.key === "rule_id" && field.label === "规则 id") return "后台 id";
return field.label || field.key;
}
function proposeFieldHint(field, proposed) {
if (field.key === "rule_id" && proposed?.paramSpecs?.some((s) => s.key === "target")) {
return "英文 kebab-case,须与上方对象对应;改对象时一并改(如丧尸→zombies)";
}
return field.hint || field.key;
}
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 === "speak" || task.id === "answer" || task.id === "review") {
return { cls: "waiting", text: task.label };
}
return { cls: "", text: task.label };
}
function isAgentBusy(view, loading) {
if (loading) return true;
return view.phase === "running" && !view.waitingReason;
}
function resolveComposer(view, loading) {
const task = resolveUserTask(view, loading);
applyUserTaskChrome(task);
if (task.id === "busy") {
return { mode: "waiting", text: task.title, task };
}
if (view.phase === "done") {
return { mode: "idle", text: "会话已结束", task };
}
if (view.phase === "error") {
const send = view.actions?.find((a) => a.type === "send_message");
return {
mode: "input",
task,
taskTitle: task.title,
taskHint: task.hint,
placeholder: send?.placeholder ?? "说明后重试,或直接发送继续…",
hint: null,
};
}
if (isPlayView(view)) {
const send = view.actions?.find((a) => a.type === "send_message");
return {
mode: "input",
task,
taskTitle: task.title || "继续说",
taskHint: task.hint,
placeholder: send?.placeholder ?? "说你要做什么…",
hint: null,
submitLabel: "发送",
};
}
const confirmIntake = view.actions?.find((a) => a.type === "confirm_intake");
if (view.waitingReason?.kind === "intake" && view.intake) {
const hasUser = (view.messages ?? []).some((m) => m.role === "user");
const send = view.actions?.find((a) => a.type === "send_message");
return {
mode: view.intake.ready && confirmIntake ? "intake_ready" : "intake",
task,
taskTitle: task.title,
taskHint: task.hint,
intake: view.intake,
intakePrompt: hasUser ? view.intakePrompt : null,
showIntakePanel: hasUser,
confirmIntake,
placeholder: send?.placeholder ?? "描述你想创作什么…",
};
}
const approve = view.actions?.find((a) => a.type === "approve");
const accept = view.actions?.find((a) => a.type === "accept");
if (approve && view.proposedNextStep && !composerForceInput) {
return {
mode: "propose_step",
task,
taskTitle: task.title,
taskHint: task.hint,
proposed: view.proposedNextStep,
primary: approve,
hint: null,
};
}
if (approve && !composerForceInput) {
return {
mode: "action",
task,
taskTitle: task.title,
taskHint: task.hint,
primary: approve,
primaryType: "approve",
hint: null,
showReject: true,
};
}
if (accept && view.waitingReason?.kind !== "review_artifact" && !composerForceInput) {
return {
mode: "action",
task,
taskTitle: "验收产物",
taskHint: "同意就接受;要改可先点「说明意见」。",
primary: accept,
primaryType: "accept",
hint: null,
showReject: true,
};
}
const send = view.actions?.find((a) => a.type === "send_message");
const hasQuestionsCard = Boolean(getActiveQuestions(view)?.questions?.length);
if (
(view.waitingReason?.kind === "pick_creation_step" || isFlowPlanReview(view)) &&
!composerForceInput
) {
return {
mode: "pick_step",
task,
taskTitle: "选要做的节点",
taskHint: "点图上可进入的节点即开始;虚线原型点一下增殖。要看技能池点上方「技能」。要改排在底栏写意见再发。",
placeholder: send?.placeholder ?? "例如:补舞台骨架、把某步改到开场白之前…",
hint: null,
submitLabel: "发送",
requiresText: true,
};
}
if (view.waitingReason?.kind === "review_artifact") {
const parseBroken = Boolean(view.reviewArtifact?.workerSetView?.parseError);
const copy = reviewCopyFromView(view);
return {
mode: "input",
task,
taskTitle: copy.taskTitle,
taskHint: copy.hint,
placeholder: send?.placeholder ?? copy.placeholder,
hint: null,
submitLabel: copy.submitLabel,
emptyEnterHint: copy.emptyEnterHint,
acceptAction: accept
? {
label: copy.acceptLabel,
title: copy.hint,
tone: copy.tone,
disabled: parseBroken,
disabledTitle: parseBroken
? "规格解析失败,请先写修改意见再改产物"
: undefined,
}
: null,
};
}
if (hasQuestionsCard && !composerForceInput) {
return {
mode: "input",
task,
taskTitle: task.title,
taskHint: task.hint,
placeholder: send?.placeholder ?? "也可在此自由补充…",
hint: null,
submitLabel: "补充并发送",
};
}
if (
send ||
view.waitingReason?.kind === "input" ||
view.waitingReason?.kind === "worker_questions" ||
view.waitingReason?.kind === "revision" ||
view.waitingReason?.kind === "next_intent" ||
view.waitingReason?.kind === "pick_creation_step" ||
composerForceInput
) {
const wr = view.waitingReason;
let placeholder = send?.placeholder ?? "输入你想说的…";
let submitLabel = "发送";
if (wr?.kind === "next_intent") {
placeholder = send?.placeholder ?? "下一步想写什么?(可留空)…";
submitLabel = "继续";
} else if (wr?.kind === "approve_step" && composerForceInput) {
placeholder =
"例如:补开局 NPC、改成丧尸怪物规则(后台 id 也换成 zombies)…";
submitLabel = "发送意见";
} else if (wr?.kind === "revision") {
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 = "按上方引导写几句…";
}
return {
mode: "input",
task,
taskTitle: task.title,
taskHint: task.hint,
placeholder,
hint: null,
submitLabel,
allowEmpty: wr?.kind === "next_intent",
};
}
const finish = view.actions?.find((a) => a.type === "finish");
if (finish) {
return {
mode: "action",
task,
taskTitle: "结束",
taskHint: null,
primary: finish,
primaryType: "finish",
hint: null,
showReject: false,
};
}
return { mode: "idle", text: "暂无可用操作", task };
}
function composerModeChip(spec) {
const id = spec.task?.id;
if (id !== "speak" && id !== "answer" && id !== "review" && id !== "busy") return "";
const label = spec.task.label || "";
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>`;
}
function activePersonaRecord() {
return (
globalPersonas.find((p) => p.id === globalActivePersonaId) ||
globalPersonas[0] ||
null
);
}
function shortPersonaDesc(text) {
const t = String(text || "").trim();
if (!t) return "(无人设描述)";
return t.length > 72 ? `${t.slice(0, 72)}…` : t;
}
function personaPickerHtml() {
const personas = globalPersonas;
const active = activePersonaRecord();
const nested = lastView?.sessionProtagonist;
const usingOpening = lastView?.openingPersonaChoice === "opening";
const activeName =
usingOpening && nested?.name ? nested.name : active?.name || "玩家";
const wasOpen = $("persona-menu")?.open === true;
const cards = personas
.map((p) => {
const activeCls = p.id === active?.id ? " is-active" : "";
if (personaEditingId === p.id) {
return `<div class="persona-edit" data-persona-edit="${esc(p.id)}">
<input type="text" name="name" value="${esc(p.name)}" placeholder="角色名" maxlength="40" />
<textarea name="description" placeholder="简单人设描述(外貌、性格、身份…)">${esc(p.description || "")}</textarea>
<div class="persona-edit-actions">
<button type="button" class="btn-sm" data-persona-act="cancel-edit">取消</button>
<button type="button" class="btn-sm btn-primary" data-persona-act="save-edit" data-id="${esc(p.id)}">保存</button>
</div>
</div>`;
}
const bindBlock =
usingOpening && nested && p.id === active?.id
? `<div class="persona-card-bind">
<div class="persona-card-bind-name">${esc(nested.name)}</div>
${
nested.description || nested.summary
? `<div class="persona-card-bind-sum">${esc(nested.description || nested.summary)}</div>`
: ""
}
</div>`
: "";
return `<div class="persona-card${activeCls}" data-persona-id="${esc(p.id)}" role="button" tabindex="0">
<div class="persona-card-name">${esc(p.name)}</div>
<div class="persona-card-desc">${esc(shortPersonaDesc(p.description))}</div>
${bindBlock}
<div class="persona-card-actions">
<button type="button" data-persona-act="edit" data-id="${esc(p.id)}">编辑</button>
${
personas.length > 1
? `<button type="button" data-persona-act="delete" data-id="${esc(p.id)}">删除</button>`
: ""
}
</div>
</div>`;
})
.join("");
const newForm =
personaEditingId === "__new__"
? `<div class="persona-edit" data-persona-edit="__new__">
<input type="text" name="name" value="" placeholder="角色名" maxlength="40" />
<textarea name="description" placeholder="简单人设描述(外貌、性格、身份…)"></textarea>
<div class="persona-edit-actions">
<button type="button" class="btn-sm" data-persona-act="cancel-edit">取消</button>
<button type="button" class="btn-sm btn-primary" data-persona-act="save-new">创建</button>
</div>
</div>`
: "";
return `<details class="more-menu persona-menu" id="persona-menu"${wasOpen ? " open" : ""}>
<summary class="persona-menu-sum" title="用户角色(@玩家)">
<span class="persona-menu-sum-label">我</span>
<span class="persona-menu-sum-name">${esc(activeName)}</span>
</summary>
<div class="persona-menu-panel" role="menu">
<div class="persona-menu-head">
<span class="persona-menu-head-title">用户角色</span>
<button type="button" class="persona-menu-add" data-persona-act="add">+ 新建</button>
</div>
${newForm}
${cards || `<p class="empty" style="margin:8px;font-size:12px">还没有角色</p>`}
</div>
</details>`;
}
function renderPersonaPicker() {
const host = $("persona-picker-host");
if (!host) return;
if (!globalPersonas.length && personaEditingId !== "__new__") {
host.hidden = false;
host.innerHTML = `<details class="more-menu persona-menu" id="persona-menu">
<summary class="persona-menu-sum" title="用户角色"><span class="persona-menu-sum-label">我</span><span class="persona-menu-sum-name">未设置</span></summary>
<div class="persona-menu-panel">
<div class="persona-menu-head">
<span class="persona-menu-head-title">用户角色</span>
<button type="button" class="persona-menu-add" data-persona-act="add">+ 新建</button>
</div>
</div>
</details>`;
wirePersonaMenu();
refreshDirectiveHighlight();
return;
}
host.hidden = false;
host.innerHTML = personaPickerHtml();
wirePersonaMenu();
refreshDirectiveHighlight();
}
async function loadPersonas() {
try {
const data = await api("/api/personas");
globalPersonas = (data.personas || []).map((p) => ({
id: p.id,
name: p.name,
description: p.description || "",
}));
globalActivePersonaId = data.activeId ?? data.active?.id ?? null;
if (lastView) {
lastView.personas = globalPersonas.map((p) => ({
id: p.id,
name: p.name,
}));
const active = activePersonaRecord();
lastView.activePersona = active
? {
id: active.id,
name: active.name,
description: active.description || "",
}
: null;
}
renderPersonaPicker();
} catch (err) {
console.error(err);
}
}
function keepPersonaMenuOpen() {
const menu = $("persona-menu");
if (menu) menu.open = true;
}
function wirePersonaMenu() {
const host = $("persona-picker-host");
if (!host) return;
host.onclick = async (e) => {
const actBtn = e.target.closest("[data-persona-act]");
if (actBtn && host.contains(actBtn)) {
e.preventDefault();
e.stopPropagation();
const act = actBtn.getAttribute("data-persona-act");
const id = actBtn.getAttribute("data-id");
try {
if (act === "add") {
personaEditingId = "__new__";
renderPersonaPicker();
keepPersonaMenuOpen();
host.querySelector(".persona-edit input[name=name]")?.focus();
return;
}
if (act === "edit") {
personaEditingId = id;
renderPersonaPicker();
keepPersonaMenuOpen();
host.querySelector(".persona-edit input[name=name]")?.focus();
return;
}
if (act === "cancel-edit") {
personaEditingId = null;
renderPersonaPicker();
keepPersonaMenuOpen();
return;
}
if (act === "save-new") {
const box = host.querySelector("[data-persona-edit='__new__']");
const name = box?.querySelector("input[name=name]")?.value?.trim() || "";
const description =
box?.querySelector("textarea[name=description]")?.value || "";
if (!name) {
host.querySelector(".persona-edit input[name=name]")?.focus();
return;
}
await api("/api/personas", {
method: "POST",
body: JSON.stringify({ name, description, activate: true }),
});
personaEditingId = null;
await loadPersonas();
keepPersonaMenuOpen();
return;
}
if (act === "save-edit") {
const box = host.querySelector(
`[data-persona-edit="${CSS.escape(id || "")}"]`,
);
const name = box?.querySelector("input[name=name]")?.value?.trim() || "";
const description =
box?.querySelector("textarea[name=description]")?.value || "";
if (!name) return;
await api(`/api/personas/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify({ name, description }),
});
personaEditingId = null;
await loadPersonas();
keepPersonaMenuOpen();
return;
}
if (act === "delete") {
if (!confirm("删除这个用户角色?")) return;
await api(`/api/personas/${encodeURIComponent(id)}`, {
method: "DELETE",
});
personaEditingId = null;
await loadPersonas();
keepPersonaMenuOpen();
return;
}
} catch (err) {
console.error(err);
alert(err.message || "操作失败");
}
return;
}
const card = e.target.closest(".persona-card[data-persona-id]");
if (card && host.contains(card) && !e.target.closest(".persona-card-actions")) {
e.preventDefault();
const pid = card.getAttribute("data-persona-id");
if (!pid || pid === globalActivePersonaId) return;
try {
const data = await api(
`/api/personas/${encodeURIComponent(pid)}/activate`,
{ method: "POST" },
);
globalActivePersonaId = data.activeId ?? pid;
if (Array.isArray(data.personas)) {
globalPersonas = data.personas.map((p) => ({
id: p.id,
name: p.name,
description: p.description || "",
}));
}
personaEditingId = null;
if (sessionId) {
const view = await api(
`/api/sessions/${encodeURIComponent(sessionId)}`,
);
renderSession(view);
} else {
renderPersonaPicker();
}
$("persona-menu")?.removeAttribute("open");
} catch (err) {
console.error(err);
}
}
};
}
function composerInputShell(spec, { textareaHtml, trailing = "" } = {}) {
const chip = composerModeChip(spec);
const tone = spec.acceptAction?.tone
? ` data-tone="${esc(spec.acceptAction.tone)}"`
: "";
return `<div class="composer-input-shell" data-task="${esc(spec.task?.id || "")}"${tone}>
${chip}
<div class="composer-input-field">
<div class="composer-input-backdrop" aria-hidden="true"></div>
${textareaHtml}
</div>
${trailing ? `<div class="composer-shell-actions">${trailing}</div>` : ""}
</div>`;
}
function wireComposerDirectiveUi(input) {
if (!input) return;
wireDirectiveAutocomplete(input);
wireDirectiveHighlight(input, {
getPersona: () => activePersonaRecord(),
});
}
function summarizeApiProfile(profile) {
return {
id: profile.id,
name: profile.name || "未命名",
model: profile.model || "",
hasKey: Boolean(String(profile.apiKey ?? "").trim()),
};
}
function activeApiProfile() {
return apiProfileList.find((p) => p.id === activeApiProfileId) || null;
}
function composerMoreMenuTitle() {
const active = activeApiProfile();
return active ? `更多选项 · ${active.name}` : "更多选项";
}
function composerApiSubmenuMeta() {
if (apiProfileSwitchingId) {
const p = apiProfileList.find((x) => x.id === apiProfileSwitchingId);
return { desc: p ? `切换到 ${p.name}…` : "切换中…", title: "API" };
}
if (apiProfileMenuStatus === "loading" || apiProfileMenuStatus === "idle") {
return { desc: "加载中…", title: "API" };
}
if (apiProfileMenuStatus === "error") {
return { desc: "加载失败", title: "API" };
}
if (!apiProfileList.length) {
return { desc: "未配置", title: "API" };
}
const active = activeApiProfile();
return {
desc: active?.name || "选择配置",
title: active ? `API · ${active.name}` : "API",
};
}
function composerApiProfileItemsHtml() {
if (apiProfileMenuStatus === "loading" || apiProfileMenuStatus === "idle") {
return `<p class="more-menu-empty">加载 API 配置…</p>`;
}
if (apiProfileMenuStatus === "error") {
return `<p class="more-menu-empty">无法加载 API 配置。${esc(apiProfileMenuError || "请重试")} <button type="button" class="more-menu-retry" data-reload-profiles>重试</button></p>`;
}
if (!apiProfileList.length) {
return `<p class="more-menu-empty">还没有 API 配置。<a href="/settings.html">去设置添加</a></p>`;
}
const switching = Boolean(apiProfileSwitchingId);
return apiProfileList
.map((p) => {
const active = p.id === activeApiProfileId;
const disabled = !p.hasKey || switching;
const desc = !p.hasKey ? "未设置 Key" : p.model || "未指定模型";
return `<button
type="button"
class="more-menu-item more-theme-item more-profile-item${active ? " is-active" : ""}"
data-activate-profile="${esc(p.id)}"
role="menuitemradio"
aria-checked="${active ? "true" : "false"}"
${disabled ? "disabled" : ""}
${p.id === apiProfileSwitchingId ? "aria-busy=\"true\"" : ""}
title="${esc(p.name)}${p.model ? ` · ${esc(p.model)}` : ""}"
>
<span class="more-theme-copy">
<span class="more-theme-name">${esc(p.name)}</span>
<span class="more-theme-desc">${esc(desc)}</span>
</span>
<span class="more-toggle-mark" aria-hidden="true"></span>
</button>`;
})
.join("");
}
function composerApiSubmenuHtml() {
const meta = composerApiSubmenuMeta();
const wasSubOpen = $("composer-api-submenu")?.hasAttribute("open");
return `<details class="more-submenu" id="composer-api-submenu"${wasSubOpen ? " open" : ""}>
<summary
class="more-menu-item more-theme-item more-submenu-sum"
title="${esc(meta.title)}"
aria-label="${esc(meta.title)}"
>
<span class="more-theme-copy">
<span class="more-theme-name">API</span>
<span class="more-theme-desc" id="composer-api-submenu-desc">${esc(meta.desc)}</span>
</span>
<span class="more-submenu-chevron" aria-hidden="true"></span>
</summary>
<div class="more-submenu-panel" id="composer-api-menu" role="menu">
${composerApiProfileItemsHtml()}
</div>
</details>`;
}
function composerMoreMenuHtml() {
const on = isMarkdownRenderEnabled();
const wasOpen = $("composer-more-menu")?.hasAttribute("open");
const title = composerMoreMenuTitle();
const showClearDictate =
lastView?.creationMode === "dictate" && !isPlayView(lastView);
const clearDictateItem = showClearDictate
? `<div class="more-menu-sep" role="separator"></div>
<div class="more-menu-label">对话</div>
<button
type="button"
class="more-menu-item"
data-act="clear-dictate"
role="menuitem"
title="清空对话,保留产物"
>
<span class="more-theme-copy">
<span class="more-theme-name">清空对话</span>
<span class="more-theme-desc">保留已落盘产物与变量</span>
</span>
</button>`
: "";
return `<details class="more-menu composer-more-menu" id="composer-more-menu"${wasOpen ? " open" : ""}>
<summary class="btn-sm more-menu-sum" title="${esc(title)}" aria-label="${esc(title)}">
<span class="hamburger-icon" aria-hidden="true"></span>
</summary>
<div class="more-menu-panel composer-more-panel" role="menu">
${composerApiSubmenuHtml()}
<div class="more-menu-sep" role="separator"></div>
<div class="more-menu-label">显示</div>
<button
type="button"
class="more-menu-item more-toggle-item${on ? " is-active" : ""}"
data-toggle-markdown
role="menuitemcheckbox"
aria-checked="${on ? "true" : "false"}"
>
<span class="more-theme-copy">
<span class="more-theme-name">Markdown 渲染</span>
<span class="more-theme-desc">消息与产物按 Markdown 显示</span>
</span>
<span class="more-toggle-mark" aria-hidden="true"></span>
</button>
${clearDictateItem}
</div>
</details>`;
}
/** 作曲区图标按钮(tooltip = title / aria-label) */
function composerIconBtn(opts) {
const {
act,
title,
primary = false,
kind = "button",
disabled = false,
icon,
extraClass = "",
} = opts;
const type = kind === "submit" ? "submit" : "button";
const cls = [
"btn",
"composer-btn-icon",
primary ? "btn-primary" : "",
extraClass,
]
.filter(Boolean)
.join(" ");
return `<button type="${type}" class="${cls}" data-act="${esc(act)}" title="${esc(
title,
)}" aria-label="${esc(title)}"${disabled ? " disabled" : ""}>${icon}</button>`;
}
const COMPOSER_ICON_SAVE = `<svg class="composer-icon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M2.5 1.5h8.2L14 4.8V13.5a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1Zm1.2 1.2v3.6h6.4V2.7H3.7Zm0 10.6h8.6V8.2H3.7v5.1Zm7.4-11.3.9.9H11v-1h.1Z"/></svg>`;
const COMPOSER_ICON_SEND = `<svg class="composer-icon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M1.4 2.1 14.6 8 1.4 13.9l.2-4.4L10 8 1.6 6.5l-.2-4.4Z"/></svg>`;
function syncApiProfileMenuUi() {
const host = $("composer-api-menu");
if (host) host.innerHTML = composerApiProfileItemsHtml();
const meta = composerApiSubmenuMeta();
const desc = $("composer-api-submenu-desc");
if (desc) desc.textContent = meta.desc;
const subSum = document.querySelector("#composer-api-submenu > summary");
if (subSum) {
subSum.title = meta.title;
subSum.setAttribute("aria-label", meta.title);
}
const sum = document.querySelector("#composer-more-menu .more-menu-sum");
if (sum) {
const title = composerMoreMenuTitle();
sum.title = title;
sum.setAttribute("aria-label", title);
}
}
async function loadApiProfiles() {
apiProfileMenuStatus = "loading";
apiProfileMenuError = "";
syncApiProfileMenuUi();
try {
const data = await api("/api/profiles");
apiProfileList = (data.profiles || []).map(summarizeApiProfile);
activeApiProfileId = data.activeProfileId ?? apiProfileList[0]?.id ?? null;
apiProfileMenuStatus = "ready";
syncApiProfileMenuUi();
} catch (err) {
apiProfileMenuStatus = "error";
apiProfileMenuError = err.message || "请求失败";
syncApiProfileMenuUi();
}
}
function closeComposerMoreMenu(menu) {
menu?.removeAttribute("open");
menu?.querySelector("#composer-api-submenu")?.removeAttribute("open");
}
async function activateApiProfileFromMenu(id, menu) {
if (!id || id === activeApiProfileId) {
closeComposerMoreMenu(menu);
return;
}
const profile = apiProfileList.find((p) => p.id === id);
if (!profile?.hasKey) return;
apiProfileSwitchingId = id;
syncApiProfileMenuUi();
try {
const result = await api(`/api/profiles/${encodeURIComponent(id)}/activate`, {
method: "POST",
});
activeApiProfileId = result.activeProfileId || id;
apiProfileSwitchingId = null;
syncApiProfileMenuUi();
closeComposerMoreMenu(menu);
} catch (err) {
apiProfileSwitchingId = null;
syncApiProfileMenuUi();
alert(err.message || "切换失败");
}
}
function composerFormHtml(shell) {
return `<form class="composer-form" id="composer-form">${composerMoreMenuHtml()}${shell}</form>`;
}
function syncMarkdownMenuUi() {
const mdOn = isMarkdownRenderEnabled();
document.querySelectorAll("[data-toggle-markdown]").forEach((btn) => {
btn.classList.toggle("is-active", mdOn);
btn.setAttribute("aria-checked", mdOn ? "true" : "false");
});
}
function syncThemeMenuUi() {
const colorId = document.documentElement.getAttribute("data-color-theme") || getStoredColorTheme();
const percent = getStoredUiFontScale();
const colorMeta = themeById(colorId);
const sum = $("theme-menu-sum");
if (sum) {
sum.title = `${colorMeta.name} · 字号 ${percent}`;
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);
});
const valueEl = $("theme-font-scale-value");
if (valueEl) valueEl.textContent = String(percent);
const minus = document.querySelector("[data-font-scale-bump='-1']");
const plus = document.querySelector("[data-font-scale-bump='1']");
if (minus) minus.disabled = percent <= UI_FONT_SCALE_MIN;
if (plus) plus.disabled = percent >= UI_FONT_SCALE_MAX;
syncMarkdownMenuUi();
}
function stashComposerDraftFromDom() {
if (skipComposerStash) {
skipComposerStash = false;
return;
}
const input = $("composer-input");
if (!input) return;
const v = String(input.value ?? "");
if (v) {
pendingComposerDraft = v;
pendingComposerDraftSeq = 0;
}
}
function keepComposerDraft(text) {
const v = String(text ?? "");
if (!v) return;
pendingComposerDraft = v;
pendingComposerDraftSeq = 0;
}
function dropComposerDraft() {
pendingComposerDraft = "";
pendingComposerDraftSeq = 0;
}
function clearComposerInput() {
const input = $("composer-input");
if (!input) return;
input.value = "";
autosizeComposerInput(input);
syncPickReplanButton(input);
refreshReviewComposerChrome();
refreshDirectiveHighlight(input);
}
/** 正文已提交:立刻清空底栏,且后续重绘不要把刚发出去的字再塞回来。 */
function beginComposerSubmit() {
dropComposerDraft();
clearComposerInput();
skipComposerStash = true;
}
function endComposerSubmit() {
skipComposerStash = false;
}
function renderComposer(view, loading) {
stashComposerDraftFromDom();
if (pendingComposerDraft) composerForceInput = true;
const host = $("composer");
const root = $("composer-main") || host;
if (!root || !host) return;
if (host._enterHandler) {
document.removeEventListener("keydown", host._enterHandler);
host._enterHandler = null;
}
const spec = resolveComposer(view, loading);
if (spec.mode === "waiting") {
const shell = composerInputShell(
{ ...spec, task: { ...(spec.task || {}), id: "busy", label: spec.task?.label || "生成中" } },
{
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="生成中…可提前停止或重roll"></textarea>`,
trailing: `<button type="button" class="btn composer-btn-icon" data-act="abort_run" title="提前停止" aria-label="提前停止">□</button>
<button type="button" class="btn composer-btn-icon" data-act="retry_run" title="重roll" aria-label="重roll">🔄</button>`,
},
);
root.innerHTML = composerFormHtml(shell);
root.querySelector("#composer-form")?.addEventListener("submit", (e) => e.preventDefault());
root.querySelector("[data-act=abort_run]")?.addEventListener("click", () => abortRun());
root.querySelector("[data-act=retry_run]")?.addEventListener("click", () => retryRun());
applyPendingComposerDraft();
const input = $("composer-input");
if (input) {
autosizeComposerInput(input);
wireComposerDirectiveUi(input);
}
return;
}
if (spec.mode === "idle") {
root.innerHTML = `<div class="composer-idle">${esc(spec.text)}</div>`;
return;
}
if (spec.mode === "intake" || spec.mode === "intake_ready") {
const intakePanel =
spec.showIntakePanel
? `<div class="intake-panel">${renderIntakePanel(spec.intake, { variant: "composer" })}</div>`
: "";
const confirm =
spec.mode === "intake_ready" && spec.confirmIntake
? `<div class="composer-actions"><button type="button" class="btn btn-primary" data-act="confirm_intake">${esc(spec.confirmIntake.label)}</button></div>`
: "";
const shell = composerInputShell(spec, {
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(spec.placeholder)}"></textarea>`,
trailing: composerIconBtn({
act: "send",
title: "发送",
primary: true,
kind: "submit",
icon: COMPOSER_ICON_SEND,
}),
});
root.innerHTML = `
${intakePanel}
${confirm}
${composerFormHtml(shell)}`;
wireComposerForm();
root.querySelector("[data-act=confirm_intake]")?.addEventListener("click", () => runAction("confirm_intake"));
applyPendingComposerDraft();
return;
}
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="force-input">说明意见</button>`
: "";
const chip = composerModeChip(spec);
root.innerHTML = `
<div class="composer-actions composer-actions-with-chip">
${chip}
<button type="button" class="btn btn-primary" data-act="${esc(spec.primaryType)}" title="Enter">${esc(spec.primary.label)}</button>
${reject}
</div>`;
root.querySelector(`[data-act="${spec.primaryType}"]`)?.addEventListener("click", () => runAction(spec.primaryType));
root.querySelector("[data-act=reject]")?.addEventListener("click", () => runAction("reject"));
root.querySelector("[data-act=force-input]")?.addEventListener("click", () => {
composerForceInput = true;
renderComposer(view, loading);
});
wireComposerActionEnter(spec.primaryType);
return;
}
if (spec.mode === "propose_step") {
const p = spec.proposed;
const missing = new Set(p.paramsMissing || []);
const originalTarget = String(p.params?.target ?? "").trim();
const originalRuleId = String(p.params?.rule_id ?? "").trim();
const visible = proposeVisibleFields(p);
const fields = visible
.map((field) => {
const val = p.params?.[field.key];
const str =
val == null ? "" : typeof val === "string" ? val : JSON.stringify(val);
const need = missing.has(field.key);
const hint = proposeFieldHint(field, p);
return `<label class="propose-field${need ? " is-missing" : ""}${
field.key === "rule_id" ? " propose-field-id" : ""
}">
<span class="propose-field-label">${esc(proposeFieldLabel(field))}${
need ? `<span class="propose-req">需补充</span>` : ""
}</span>
<input type="text" data-param-key="${esc(field.key)}" value="${esc(str)}" placeholder="${esc(hint)}" />
</label>`;
})
.join("");
const syncHint =
visible.some((f) => f.key === "target") && visible.some((f) => f.key === "rule_id")
? `<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>`
: "";
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>
<strong class="propose-name">${esc(p.name)}</strong>
</div>
<p class="propose-output" data-propose-output>${esc(proposedOutputCopy(p))}</p>
${planHint}
${closerHint}
${fields ? `<div class="propose-fields">${fields}</div>` : ""}
${syncHint}
<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>
</div>
</div>`;
const targetInput = root.querySelector('[data-param-key="target"]');
const ruleIdInput = root.querySelector('[data-param-key="rule_id"]');
const outputEl = root.querySelector("[data-propose-output]");
const refreshOutput = () => {
if (!outputEl || !targetInput) return;
const next = {
...p,
params: { ...(p.params || {}), target: String(targetInput.value ?? "").trim() },
};
outputEl.textContent = proposedOutputCopy(next);
};
const markRuleIdSync = () => {
if (!targetInput || !ruleIdInput) return;
const nextTarget = String(targetInput.value ?? "").trim();
const nextRuleId = String(ruleIdInput.value ?? "").trim();
const targetChanged = Boolean(originalTarget) && nextTarget !== originalTarget;
const ruleStale =
targetChanged && Boolean(originalRuleId) && nextRuleId === originalRuleId;
ruleIdInput.closest(".propose-field")?.classList.toggle("is-stale-id", ruleStale);
};
targetInput?.addEventListener("input", () => {
refreshOutput();
markRuleIdSync();
});
ruleIdInput?.addEventListener("input", markRuleIdSync);
const approvePropose = () => {
const stepParams = { ...(p.params || {}) };
root.querySelectorAll("[data-param-key]").forEach((input) => {
const key = input.getAttribute("data-param-key");
if (!key) return;
const v = String(input.value ?? "").trim();
if (v) stepParams[key] = v;
else delete stepParams[key];
});
const nextTarget = String(stepParams.target ?? "").trim();
const nextRuleId = String(stepParams.rule_id ?? "").trim();
if (
originalTarget &&
nextTarget &&
nextTarget !== originalTarget &&
originalRuleId &&
nextRuleId === originalRuleId
) {
alert("生成对象已改,请把后台 id 改成对应英文(例如丧尸怪物 → zombies),不要沿用旧 id。");
ruleIdInput?.focus();
markRuleIdSync();
return;
}
void runAction("approve", { stepParams });
};
root.querySelector("[data-act=approve]")?.addEventListener("click", approvePropose);
root.querySelector("[data-act=reject]")?.addEventListener("click", () => runAction("reject"));
root.querySelector("[data-act=force-input]")?.addEventListener("click", () => {
composerForceInput = true;
renderComposer(view, loading);
});
wireComposerActionEnter("approve", { onEnter: approvePropose, allowInInputs: true });
return;
}
if (spec.mode === "pick_step") {
const saveProduct = canOfferSaveProduct(view)
? composerIconBtn({
act: "save-product",
title: "保存定稿",
primary: true,
icon: COMPOSER_ICON_SAVE,
})
: "";
const sendClsPrimary = !canOfferSaveProduct(view);
const shell = composerInputShell(spec, {
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(spec.placeholder)}"></textarea>`,
trailing: `${saveProduct}${composerIconBtn({
act: "pick-replan",
title: spec.submitLabel || "发送",
primary: sendClsPrimary,
kind: "submit",
disabled: true,
icon: COMPOSER_ICON_SEND,
})}`,
});
root.innerHTML = composerFormHtml(shell);
wireComposerForm({ requiresText: true });
root.querySelector("[data-act=save-product]")?.addEventListener("click", () => {
void saveCurrentInstance();
});
applyPendingComposerDraft();
return;
}
const acceptOnEmpty = Boolean(spec.acceptAction);
const acceptTone = spec.acceptAction?.tone || "accept";
const acceptBtn = spec.acceptAction
? `<button type="button" class="btn composer-btn-accept${
acceptTone === "accept" ? " composer-btn-review" : ""
}${acceptOnEmpty ? " btn-primary" : ""}" data-act="accept" title="${esc(
spec.acceptAction.disabled
? spec.acceptAction.disabledTitle || "暂不可确认"
: spec.acceptAction.title || spec.acceptAction.label
)}" ${spec.acceptAction.disabled ? "disabled" : ""}>${esc(spec.acceptAction.label)}</button>`
: "";
const saveProduct = canOfferSaveProduct(view)
? composerIconBtn({
act: "save-product",
title: "保存定稿",
primary: true,
icon: COMPOSER_ICON_SAVE,
})
: "";
const submitLabel = spec.submitLabel || "发送";
const sendIsPrimary = !acceptOnEmpty && !saveProduct;
const placeholder = acceptOnEmpty
? `${spec.placeholder || "修改意见…"}(${spec.emptyEnterHint || "空 Enter=确认"})`
: spec.placeholder;
const sendTitle = acceptOnEmpty
? `有字或已选追问时 Enter=${submitLabel}`
: `${submitLabel}(Enter)`;
const shell = composerInputShell(spec, {
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(placeholder)}"></textarea>`,
trailing: `${saveProduct}${composerIconBtn({
act: "revise",
title: sendTitle,
primary: sendIsPrimary,
icon: COMPOSER_ICON_SEND,
})}${acceptBtn}`,
});
root.innerHTML = composerFormHtml(shell);
wireComposerForm({ acceptOnEmpty });
root.querySelector("[data-act=accept]")?.addEventListener("click", () => runAction("accept"));
root.querySelector("[data-act=save-product]")?.addEventListener("click", () => {
void saveCurrentInstance();
});
applyPendingComposerDraft();
}
function composerTextareaMaxPx(el) {
const raw = getComputedStyle(el).maxHeight;
const n = Number.parseFloat(raw);
if (Number.isFinite(n) && n > 0) return n;
return Math.min(window.innerHeight * 0.42, 360);
}
/** SillyTavern 式:高度随内容上长,触顶后框内滚动 */
function autosizeComposerInput(el) {
if (!el) return;
const max = composerTextareaMaxPx(el);
el.style.height = "auto";
el.style.overflowY = "hidden";
const line = Number.parseFloat(getComputedStyle(el).lineHeight) || 21;
const floor = Math.ceil(line);
const next = Math.min(Math.max(el.scrollHeight, floor), max);
el.style.height = `${next}px`;
el.style.overflowY = el.scrollHeight > max + 1 ? "auto" : "hidden";
}
function applyPendingComposerDraft() {
if (!pendingComposerDraft) return;
if (pendingComposerDraftSeq && pendingComposerDraftSeq !== inflightSeq) {
pendingComposerDraft = "";
pendingComposerDraftSeq = 0;
return;
}
const input = $("composer-input");
if (!input) return;
input.value = pendingComposerDraft;
pendingComposerDraft = "";
pendingComposerDraftSeq = 0;
autosizeComposerInput(input);
syncPickReplanButton(input);
input.focus();
refreshReviewComposerChrome();
refreshDirectiveHighlight(input);
}
/** 无输入框的确认态:Enter = 主按钮 */
function wireComposerActionEnter(action, opts = {}) {
const root = $("composer");
if (!root) return;
const prev = root._enterHandler;
if (prev) document.removeEventListener("keydown", prev);
const handler = (e) => {
if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.isComposing) return;
const t = e.target;
const tag = t?.tagName;
if (t?.isContentEditable || tag === "TEXTAREA") return;
if (tag === "INPUT" && !opts.allowInInputs) return;
if (t?.closest?.(".questions-card-host [contenteditable=true], .qcard-other-input, .msg-edit-input")) {
return;
}
e.preventDefault();
if (typeof opts.onEnter === "function") opts.onEnter();
else void runAction(action);
};
root._enterHandler = handler;
document.addEventListener("keydown", handler);
}
function refreshReviewComposerChrome() {
const input = $("composer-input");
const acceptBtn = $("composer")?.querySelector("[data-act=accept]");
const sendBtn = $("composer")?.querySelector("[data-act=revise]");
if (!acceptBtn || !sendBtn) return;
syncDualComposerPrimary(input, acceptBtn, sendBtn);
if (!input || lastView?.waitingReason?.kind !== "review_artifact") return;
const copy = reviewCopyFromView(lastView);
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})`;
}
function syncDualComposerPrimary(input, acceptBtn, sendBtn) {
if (!acceptBtn || !sendBtn) return;
const empty = !String(input?.value ?? "").trim();
const hasAnswers = selectedQuestionAnswers(lastView).answered.length > 0;
const revisePrimary = !empty || hasAnswers;
acceptBtn.classList.toggle("btn-primary", !revisePrimary && !acceptBtn.disabled);
sendBtn.classList.toggle("btn-primary", revisePrimary);
}
function syncPickReplanButton(input) {
const btn = $("composer-form")?.querySelector("[data-act=pick-replan]");
if (!btn) return;
const hasText = Boolean(String(input?.value ?? "").trim());
btn.disabled = !hasText;
const tip = hasText
? "发送改编排意见"
: "写下意见后再发;点图上节点即确认并进入";
btn.title = tip;
btn.setAttribute("aria-label", tip);
}
function wireComposerForm(opts = {}) {
const form = $("composer-form");
const input = $("composer-input");
const acceptOnEmpty = opts.acceptOnEmpty === true;
const requiresText = opts.requiresText === true;
const sendBtn = form?.querySelector("[data-act=revise]");
const root = $("composer");
if (root?._enterHandler) {
document.removeEventListener("keydown", root._enterHandler);
root._enterHandler = null;
}
form?.addEventListener("submit", async (e) => {
e.preventDefault();
await submitComposer(input?.value ?? "", { acceptOnEmpty, requiresText });
});
input?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
void submitComposer(input?.value ?? "", { acceptOnEmpty, requiresText });
}
});
sendBtn?.addEventListener("click", () => {
void sendText(input?.value ?? "");
});
if (acceptOnEmpty) {
const handler = (e) => {
if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.isComposing) return;
const t = e.target;
const tag = t?.tagName;
if (t?.isContentEditable || tag === "TEXTAREA" || tag === "INPUT") return;
if (t?.closest?.(".msg-edit-input, .qcard-other-input, .questions-card-host [contenteditable=true]")) {
return;
}
e.preventDefault();
void submitComposer(input?.value ?? "", { acceptOnEmpty: true });
};
root._enterHandler = handler;
document.addEventListener("keydown", handler);
}
const grow = () => {
autosizeComposerInput(input);
if (acceptOnEmpty) refreshReviewComposerChrome();
if (requiresText) syncPickReplanButton(input);
};
input?.addEventListener("input", grow);
input?.addEventListener("change", grow);
requestAnimationFrame(grow);
wireComposerDirectiveUi(input);
input?.focus();
}
function isPlayNow() {
return isPlayView(lastView);
}
function playRunsForBook(bookId) {
return (playSavesByBook.get(bookId) ?? []).filter((s) => s.kind === "run");
}
function productInstancesForBook(bookId) {
return (playSavesByBook.get(bookId) ?? []).filter((s) => s.kind === "instance");
}
function resolveRunInstanceId(run, products) {
if (run.instanceId && products.some((p) => p.id === run.instanceId)) {
return run.instanceId;
}
if (!products.length) return null;
const sorted = [...products].sort((a, b) =>
String(a.createdAt).localeCompare(String(b.createdAt)),
);
let id = sorted[0].id;
for (const product of sorted) {
if (String(product.createdAt) <= String(run.createdAt)) id = product.id;
}
return id;
}
function runsForProduct(bookId, instanceId, products) {
return playRunsForBook(bookId).filter(
(run) => resolveRunInstanceId(run, products) === instanceId,
);
}
function bookPlayReady(bookId) {
if (lastView?.bookId === bookId && Boolean(lastView?.playReady || lastView?.hasProduct)) {
return true;
}
const saves = playSavesByBook.get(bookId);
if (!Array.isArray(saves)) return false;
return saves.some((s) => s.kind === "instance" || s.kind === "run");
}
function formatSaveWhen(createdAt) {
const when = new Date(createdAt);
if (Number.isNaN(when.getTime())) return "";
return when.toLocaleString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
async function fetchPlaySavesForBook(bookId, force = false) {
if (!force && (playSavesByBook.has(bookId) || playSavesLoading.has(bookId))) return;
playSavesLoading.add(bookId);
try {
const data = await api(`/api/books/${encodeURIComponent(bookId)}/saves`);
playSavesByBook.set(bookId, data.saves ?? []);
playWorkingInstanceByBook.set(bookId, data.playWorkingInstanceId ?? null);
} catch {
playSavesByBook.set(bookId, []);
playWorkingInstanceByBook.set(bookId, null);
} finally {
playSavesLoading.delete(bookId);
renderBookList();
}
}
function setBookSelectMode(on, { keepSelection = false } = {}) {
if (on && sidebarNav.level !== "root") return;
if (on) hideEnterPicker();
bookSelectMode = on;
if (!on) selectedBookIds = new Set();
else if (!keepSelection) selectedBookIds = new Set();
renderBookList();
}
function toggleBookSelection(bookId) {
if (!bookSelectMode) setBookSelectMode(true, { keepSelection: true });
if (selectedBookIds.has(bookId)) selectedBookIds.delete(bookId);
else selectedBookIds.add(bookId);
renderBookList();
}
function hideBookMenu() {
const menu = $("book-action-menu");
if (menu) menu.hidden = true;
bookMenuBookId = null;
}
function hideEnterPicker() {
document.querySelectorAll(".explorer-folder.is-picking").forEach((el) => {
el.classList.remove("is-picking");
});
const pop = $("book-enter-pop");
if (pop) pop.hidden = true;
enterPickerBookId = null;
}
function showEnterPicker(bookId, anchorEl) {
const pop = $("book-enter-pop");
if (!pop || !anchorEl) return;
hideBookMenu();
hideSaveMenu();
enterPickerBookId = bookId;
document.querySelectorAll(".explorer-folder.is-picking").forEach((el) => {
el.classList.remove("is-picking");
});
anchorEl.classList.add("is-picking");
const playBtn = pop.querySelector("[data-enter=play]");
const ready = bookPlayReady(bookId);
if (playBtn) {
playBtn.disabled = !ready;
playBtn.title = ready ? "进入游玩" : "验收后可游玩";
}
pop.hidden = false;
const rect = anchorEl.getBoundingClientRect();
const left = rect.right + 8;
let top = rect.top;
pop.style.left = `${left}px`;
pop.style.top = `${top}px`;
const box = pop.getBoundingClientRect();
if (box.bottom > window.innerHeight - 8) {
top = Math.max(8, window.innerHeight - box.height - 8);
pop.style.top = `${top}px`;
}
if (box.right > window.innerWidth - 8) {
pop.style.left = `${Math.max(8, rect.left - box.width - 8)}px`;
}
}
let saveMenuState = { bookId: null, saveId: null, label: "" };
function hideSaveMenu() {
const menu = $("save-action-menu");
if (menu) menu.hidden = true;
saveMenuState = { bookId: null, saveId: null, label: "" };
}
function showSaveMenu(bookId, saveId, label, x, y) {
const menu = $("save-action-menu");
if (!menu) return;
hideEnterPicker();
hideBookMenu();
saveMenuState = { bookId, saveId, label: label || "" };
menu.hidden = false;
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menu.style.left = `${Math.max(4, window.innerWidth - rect.width - 4)}px`;
}
if (rect.bottom > window.innerHeight) {
menu.style.top = `${Math.max(4, window.innerHeight - rect.height - 4)}px`;
}
}
async function openSnapshotProductsEditor(bookId, saveId, label) {
const dlg = $("dialog-snapshot-products");
const title = $("snapshot-products-title");
const meta = $("snapshot-products-meta");
const body = $("snapshot-products-body");
if (!dlg || !body) return;
if (title) title.textContent = label ? `产物 · ${label}` : "快照产物";
if (meta) meta.textContent = "按创作步骤展开;点「改」手改正文后保存。";
body.innerHTML = `<p class="empty-sm">加载中…</p>`;
if (typeof dlg.showModal === "function") dlg.showModal();
else dlg.setAttribute("open", "");
try {
const data = await api(
`/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}`,
);
const products = Array.isArray(data.products) ? data.products : [];
if (!products.length) {
body.innerHTML = `<p class="empty-sm">这份快照里还没有可编辑产物。</p>`;
return;
}
body.innerHTML = products
.map((p, i) => {
const preview = String(p.content || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 160);
return `<div class="snap-product" data-idx="${i}" data-tag="${esc(p.tag)}">
<div class="snap-product-head">
<span class="snap-product-label">${esc(p.label || p.tag)}</span>
<span class="snap-product-tag">${esc(p.tag)}</span>
<button type="button" class="btn-sm snap-product-edit" data-snap-edit="${i}">改</button>
</div>
<div class="snap-product-preview" data-snap-preview="${i}">${esc(preview)}${
String(p.content || "").length > 160 ? "…" : ""
}</div>
</div>`;
})
.join("");
body.dataset.bookId = bookId;
body.dataset.saveId = saveId;
body._products = products;
} catch (err) {
body.innerHTML = `<p class="empty-sm">${esc(err.message || "读取失败")}</p>`;
}
}
async function saveSnapshotProductEdit(bookId, saveId, tag, content, rowEl, products, idx) {
const data = await api(
`/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}`,
{
method: "PATCH",
body: JSON.stringify({ tag, content }),
},
);
const next = Array.isArray(data.products) ? data.products : products;
const body = $("snapshot-products-body");
if (body) body._products = next;
const updated = next.find((p) => p.tag === tag) || { tag, content, label: tag };
products[idx] = updated;
const preview = String(updated.content || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 160);
rowEl.innerHTML = `
<div class="snap-product-head">
<span class="snap-product-label">${esc(updated.label || updated.tag)}</span>
<span class="snap-product-tag">${esc(updated.tag)}</span>
<button type="button" class="btn-sm snap-product-edit" data-snap-edit="${idx}">改</button>
</div>
<div class="snap-product-preview" data-snap-preview="${idx}">${esc(preview)}${
String(updated.content || "").length > 160 ? "…" : ""
}</div>`;
}
function refreshBookMenuLabels() {
const n = selectedBookIds.size;
const multi = bookSelectMode && n > 0;
const bookId = bookMenuBookId;
const ready = bookId ? bookPlayReady(bookId) : false;
const thisBook = bookId && bookId === activeBookId;
$("book-menu-open")?.toggleAttribute("hidden", true);
$("book-menu-play")?.toggleAttribute("hidden", true);
$("book-menu-save-instance")?.toggleAttribute("hidden", multi || !thisBook || !ready);
$("book-menu-save-play")?.toggleAttribute(
"hidden",
multi || !thisBook || !ready || !isPlayNow(),
);
$("book-menu-export")?.toggleAttribute(
"hidden",
multi || !thisBook || !(lastView?.messages?.length),
);
$("book-menu-rename")?.toggleAttribute("hidden", n > 1);
const dup = $("book-menu-duplicate");
if (dup) dup.textContent = n > 1 ? `复制 ${n} 项备份` : "复制备份";
const del = $("book-menu-delete");
if (del) del.textContent = n > 1 ? `删除 ${n} 项` : "删除";
const sel = $("book-menu-select");
if (sel) sel.textContent = bookSelectMode ? "取消多选" : "多选";
}
function showBookMenu(bookId, x, y) {
const menu = $("book-action-menu");
if (!menu) return;
hideEnterPicker();
hideSaveMenu();
bookMenuBookId = bookId;
refreshBookMenuLabels();
menu.hidden = false;
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menu.style.left = `${Math.max(4, window.innerWidth - rect.width - 4)}px`;
}
if (rect.bottom > window.innerHeight) {
menu.style.top = `${Math.max(4, window.innerHeight - rect.height - 4)}px`;
}
}
function isCatalogNav() {
return sidebarNav.level === "root";
}
function isWorkspaceNav() {
return sidebarNav.level === "book";
}
function getNavBook() {
if (!isWorkspaceNav() || !sidebarNav.bookId) return null;
return books.find((b) => b.id === sidebarNav.bookId) ?? null;
}
function catalogBookMeta(book) {
const saves = playSavesByBook.get(book.id);
if (saves === undefined) {
void fetchPlaySavesForBook(book.id);
return "";
}
if (playWorkingInstanceByBook.get(book.id)) return "游玩中";
if (saves.some((s) => s.kind === "instance" || s.kind === "run")) return "可游玩";
return "创作中";
}
function specOwnsWorkingPlay(book, specId) {
const playingId = playingInstanceIdForBook(book);
if (!playingId) return false;
const products = productInstancesForBook(book.id);
if (products.some((p) => p.id === playingId)) return playingId === specId;
return products[0]?.id === specId;
}
function defaultExpandedSpecIds(bookId) {
const products = productInstancesForBook(bookId);
const playing = playingInstanceIdForBook({ id: bookId });
const target =
playing && products.some((p) => p.id === playing) ? playing : products[0]?.id;
return target ? new Set([target]) : new Set();
}
function specIsExpanded(bookId, specId) {
const set = expandedSpecsByBook.get(bookId);
if (set) return set.has(specId);
return defaultExpandedSpecIds(bookId).has(specId);
}
function toggleSpecExpanded(bookId, specId) {
const next = new Set(expandedSpecsByBook.get(bookId) ?? defaultExpandedSpecIds(bookId));
if (next.has(specId)) next.delete(specId);
else next.add(specId);
expandedSpecsByBook.set(bookId, next);
renderBookList();
}
function ensureSpecExpanded(bookId, specId) {
if (!bookId) return;
if (specId) {
const next = new Set(expandedSpecsByBook.get(bookId) ?? defaultExpandedSpecIds(bookId));
next.add(specId);
expandedSpecsByBook.set(bookId, next);
}
navigateToBook(bookId);
}
function pathSep() {
const li = document.createElement("li");
li.className = "path-bar-item";
li.setAttribute("aria-hidden", "true");
const sep = document.createElement("span");
sep.className = "path-sep";
sep.textContent = "›";
li.appendChild(sep);
return li;
}
function pathItem({ label, title, current, onClick }) {
const li = document.createElement("li");
li.className = current ? "path-bar-item path-bar-item-current" : "path-bar-item";
const el = document.createElement(current || !onClick ? "span" : "button");
el.className = current ? "path-seg current" : "path-seg";
if (el.tagName === "BUTTON") el.type = "button";
if (current) el.setAttribute("aria-current", "page");
el.textContent = label;
if (title) el.title = title;
if (!current && onClick) el.addEventListener("click", onClick);
li.appendChild(el);
return li;
}
function renderPathBar() {
const bar = $("book-path-bar");
if (!bar) return;
bar.innerHTML = "";
const book = getNavBook();
const inBook = Boolean(book);
$("rail-tabs")?.toggleAttribute("hidden", !(isWorkspaceNav() || Boolean(activeBookId)));
const list = document.createElement("ol");
list.className = "path-bar-list";
list.appendChild(
pathItem({
label: "目录",
title: "作品目录",
current: !inBook,
onClick: () => showCatalog({ closeSession: false }),
}),
);
if (inBook) {
const bookLabel = book.title?.trim() || "作品";
list.appendChild(pathSep());
list.appendChild(
pathItem({
label: bookLabel,
title: bookLabel,
current: true,
}),
);
}
bar.appendChild(list);
}
function navigateToBook(bookId) {
if (!bookId) return;
hideEnterPicker();
sidebarNav = { level: "book", bookId };
document.body.dataset.navLevel = "book";
bookSelectMode = false;
selectedBookIds = new Set();
setRailTab("books", { expand: true });
renderPathBar();
renderBookList();
void fetchPlaySavesForBook(bookId);
}
function showCatalog({ closeSession = true } = {}) {
hideEnterPicker();
if (closeSession) {
beginUiRequest();
stopLivePoll();
closeWorkspace();
}
sidebarNav = { level: "root" };
document.body.dataset.navLevel = "root";
bookSelectMode = false;
selectedBookIds = new Set();
renderPathBar();
renderBookList();
}
function renderExplorerRow({
name,
meta,
active,
selected,
bookId,
actionClass,
onClick,
onDelete,
onRename,
onContextMenu,
child,
twistie,
expanded,
onTwistie,
title,
ariaExpanded,
}) {
const row = document.createElement("button");
row.type = "button";
row.className = [
"explorer-row",
actionClass,
active ? "active" : "",
selected ? "selected" : "",
child ? "child" : "",
twistie ? "has-twist" : "",
]
.filter(Boolean)
.join(" ");
if (bookId) row.dataset.bookId = bookId;
if (title) row.title = title;
if (typeof ariaExpanded === "boolean") {
row.setAttribute("aria-expanded", ariaExpanded ? "true" : "false");
}
row.innerHTML = `
${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>` : ""}
</span>
${onDelete ? `<span class="explorer-del" role="button" tabindex="-1" aria-label="删除">×</span>` : ""}`;
row.addEventListener("click", (e) => {
if (e.target.closest(".explorer-del") || e.target.closest("[data-twist]")) return;
onClick?.(e);
});
if (onRename) {
row.addEventListener("dblclick", (e) => {
if (e.target.closest(".explorer-del") || e.target.closest("[data-twist]")) return;
e.preventDefault();
onRename();
});
}
row.querySelector("[data-twist]")?.addEventListener("click", (e) => {
e.stopPropagation();
(onTwistie || onClick)?.();
});
row.querySelector(".explorer-del")?.addEventListener("click", (e) => {
e.stopPropagation();
onDelete?.();
});
if (onContextMenu) {
row.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e);
});
} else if (bookId && !child) {
row.addEventListener("contextmenu", (e) => {
e.preventDefault();
if (bookSelectMode && !selectedBookIds.has(bookId)) {
selectedBookIds.add(bookId);
renderBookList();
}
showBookMenu(bookId, e.clientX, e.clientY);
});
}
return row;
}
function playingInstanceIdForBook(book) {
const playHere = book.id === activeBookId && isPlayNow();
if (playHere && lastView?.playInstanceId) return lastView.playInstanceId;
return playWorkingInstanceByBook.get(book.id) ?? null;
}
function renderSpecChildren(book, product, list) {
const playHere = book.id === activeBookId && isPlayNow();
if (specOwnsWorkingPlay(book, product.id)) {
const live = playHere && !activePlaySaveId;
list.appendChild(
renderExplorerRow({
name: "当前游玩",
meta: live ? "游玩中" : "",
active: live,
actionClass: "explorer-run",
child: true,
onClick: () => void loadPlaySave(book.id, "play-working"),
onContextMenu: (e) =>
showSaveMenu(book.id, "play-working", "当前游玩", e.clientX, e.clientY),
}),
);
}
const runs = runsForProduct(book.id, product.id, productInstancesForBook(book.id));
for (const run of runs) {
const runWhen = formatSaveWhen(run.createdAt);
list.appendChild(
renderExplorerRow({
name: run.label,
meta: runWhen || "游玩进度",
active: playHere && activePlaySaveId === run.id,
actionClass: "explorer-run",
child: true,
onClick: () => void loadPlaySave(book.id, run.id),
onRename: () => void renamePlaySave(book.id, run.id, run.label),
onDelete: () => void deletePlaySave(book.id, run.id, run.label, "游玩存档"),
onContextMenu: (e) =>
showSaveMenu(book.id, run.id, run.label, e.clientX, e.clientY),
}),
);
}
}
function appendNewPlayRow(book, list, instanceId, { child = false } = {}) {
list.appendChild(
renderExplorerRow({
name: "+ 新开一局",
actionClass: "explorer-action",
child,
onClick: () => void startNewPlayForBook(book.id, instanceId),
}),
);
}
function appendCurrentDesignRow(book, list) {
const live =
book.id === activeBookId && Boolean(sessionId) && !isPlayNow();
list.appendChild(
renderExplorerRow({
name: "当前创作",
meta: live ? "进行中" : "继续",
active: live,
actionClass: "explorer-design",
title: "打开创作会话;定稿会出现在下方",
onClick: () => void resumeDesignSession(book.id),
}),
);
}
function renderBookContents(book, list) {
const playingInstanceId = playingInstanceIdForBook(book);
const products = productInstancesForBook(book.id);
appendCurrentDesignRow(book, list);
const loading = playSavesLoading.has(book.id);
const saves = playSavesByBook.get(book.id);
if (loading && saves === undefined) {
const hint = document.createElement("p");
hint.className = "explorer-hint";
hint.textContent = "加载存档…";
list.appendChild(hint);
return;
}
if (!products.length) {
const hint = document.createElement("p");
hint.className = "explorer-hint";
hint.textContent = bookPlayReady(book.id) ? "还没有规格" : "定稿后会出现在这里";
list.appendChild(hint);
if (bookPlayReady(book.id)) appendNewPlayRow(book, list);
return;
}
let anyExpanded = false;
for (const product of products) {
const expanded = specIsExpanded(book.id, product.id);
const bits = [];
if (!expanded && product.id === playingInstanceId) bits.push("游玩中");
list.appendChild(
renderExplorerRow({
name: product.label,
meta: bits.join(" · "),
title: "展开或收起;双击改名;右键编辑产物",
actionClass: "explorer-spec",
twistie: true,
expanded,
ariaExpanded: expanded,
onClick: () => toggleSpecExpanded(book.id, product.id),
onTwistie: () => toggleSpecExpanded(book.id, product.id),
onRename: () => void renamePlaySave(book.id, product.id, product.label),
onDelete: () => void deletePlaySave(book.id, product.id, product.label, "规格"),
onContextMenu: (e) =>
showSaveMenu(book.id, product.id, product.label, e.clientX, e.clientY),
}),
);
if (expanded) {
anyExpanded = true;
renderSpecChildren(book, product, list);
appendNewPlayRow(book, list, product.id, { child: true });
}
}
if (!anyExpanded) {
appendNewPlayRow(book, list, playingInstanceId || products[0]?.id);
}
}
function renderBookList() {
const list = $("book-list");
if (!list) return;
renderPathBar();
if (!books.length) {
list.innerHTML = `<p class="sidebar-empty">暂无作品<br><button type="button" class="btn-sm" id="btn-new-inline">+ 新建</button></p>`;
$("btn-new-inline")?.addEventListener("click", openNewBookDialog);
return;
}
list.innerHTML = "";
if (sidebarNav.level === "book") {
const book = getNavBook();
if (book) {
renderBookContents(book, list);
return;
}
sidebarNav = { level: "root" };
document.body.dataset.navLevel = "root";
renderPathBar();
}
for (const book of books) {
const selected = selectedBookIds.has(book.id);
list.appendChild(
renderExplorerRow({
name: book.title,
meta: catalogBookMeta(book),
active: book.id === activeBookId,
selected,
bookId: book.id,
actionClass: "explorer-folder",
onClick: (e) => {
if (bookSelectMode) {
toggleBookSelection(book.id);
return;
}
e.stopPropagation();
if (enterPickerBookId === book.id) {
hideEnterPicker();
return;
}
showEnterPicker(book.id, e.currentTarget);
},
}),
);
}
if (enterPickerBookId && sidebarNav.level === "root") {
const row = list.querySelector(`[data-book-id="${enterPickerBookId}"]`);
if (row) showEnterPicker(enterPickerBookId, row);
else hideEnterPicker();
}
}
async function renameBookById(bookId) {
const book = books.find((b) => b.id === bookId);
if (!book) return;
const title = prompt("作品名称", book.title);
if (title == null) return;
const trimmed = title.trim();
if (!trimmed) return alert("名称不能为空");
try {
const data = await api(`/api/books/${encodeURIComponent(bookId)}`, {
method: "PUT",
body: JSON.stringify({ title: trimmed }),
});
const i = books.findIndex((b) => b.id === bookId);
if (i >= 0 && data.book) books[i] = { ...books[i], ...data.book };
if (activeBookId === bookId && lastView) {
renderSession({ ...lastView, bookTitle: trimmed }, false);
} else {
renderBookList();
}
} catch (err) {
alert(err.message);
}
}
async function duplicateBookById(bookId, title) {
try {
const data = await api(`/api/books/${encodeURIComponent(bookId)}/duplicate`, {
method: "POST",
body: JSON.stringify(title ? { title } : {}),
});
if (data.book) {
books.unshift({
id: data.book.id,
title: data.book.title,
activeSkillId: data.book.activeSkillId ?? data.book.orchestratorId,
activeSkillName: data.book.activeSkillName ?? data.book.orchestratorName,
preview: data.book.preview,
updatedAt: data.book.updatedAt,
orchestratorId: data.book.orchestratorId,
orchestratorName: data.book.orchestratorName,
});
renderBookList();
}
return data.book;
} catch (err) {
alert(err.message);
return null;
}
}
async function duplicateSelectedBooks() {
const ids = bookSelectMode || selectedBookIds.size ? [...selectedBookIds] : [];
if (!ids.length) return;
for (const id of ids) {
await duplicateBookById(id);
}
setBookSelectMode(false);
}
async function deleteSelectedBooks() {
const ids = [...selectedBookIds];
if (!ids.length) return;
const label =
ids.length === 1
? `「${books.find((b) => b.id === ids[0])?.title ?? "该作品"}」`
: `${ids.length} 个作品`;
if (!confirm(`删除 ${label}?不可恢复。`)) return;
try {
const data = await api("/api/books/batch", {
method: "DELETE",
body: JSON.stringify({ ids }),
});
const deleted = new Set(data.deleted ?? ids);
books = books.filter((b) => !deleted.has(b.id));
setBookSelectMode(false);
if (activeBookId && deleted.has(activeBookId)) {
if (books.length) showCatalog();
else renderEmpty();
} else {
renderBookList();
}
} catch (err) {
alert(err.message);
}
}
function rectsIntersect(a, b) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
function setupBookBoxSelect() {
const list = $("book-list");
const overlay = $("book-box-overlay");
if (!list || !overlay) return;
let dragging = false;
let start = null;
const clearDrag = () => {
dragging = false;
start = null;
overlay.hidden = true;
};
list.addEventListener("mousedown", (e) => {
if (sidebarNav.level !== "root" || e.button !== 0) return;
if (e.target.closest("#book-action-menu")) return;
if (e.target.closest(".explorer-row")) return;
dragging = true;
start = { x: e.clientX, y: e.clientY };
overlay.hidden = true;
const onMove = (ev) => {
if (!dragging || !start) return;
const dx = Math.abs(ev.clientX - start.x);
const dy = Math.abs(ev.clientY - start.y);
if (dx < 6 && dy < 6) return;
if (!bookSelectMode) setBookSelectMode(true, { keepSelection: true });
const left = Math.min(start.x, ev.clientX);
const top = Math.min(start.y, ev.clientY);
const width = Math.abs(ev.clientX - start.x);
const height = Math.abs(ev.clientY - start.y);
overlay.hidden = false;
overlay.style.left = `${left}px`;
overlay.style.top = `${top}px`;
overlay.style.width = `${width}px`;
overlay.style.height = `${height}px`;
};
const onUp = (ev) => {
if (!dragging || !start) return;
const dx = Math.abs(ev.clientX - start.x);
const dy = Math.abs(ev.clientY - start.y);
if (dx >= 6 || dy >= 6) {
const box = {
left: Math.min(start.x, ev.clientX),
right: Math.max(start.x, ev.clientX),
top: Math.min(start.y, ev.clientY),
bottom: Math.max(start.y, ev.clientY),
};
for (const row of list.querySelectorAll("[data-book-id]")) {
const rect = row.getBoundingClientRect();
if (rectsIntersect(box, rect)) selectedBookIds.add(row.dataset.bookId);
}
renderBookList();
}
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
clearDrag();
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
}
async function loadPlaySave(bookId, saveId) {
try {
const products = productInstancesForBook(bookId);
const run = playRunsForBook(bookId).find((r) => r.id === saveId);
const instanceId = run ? resolveRunInstanceId(run, products) : null;
if (instanceId) ensureSpecExpanded(bookId, instanceId);
else navigateToBook(bookId);
if (lastView?.bookId === bookId) renderSession(lastView, true);
const data = await api(
`/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}/load`,
{ method: "POST" },
);
activePlaySaveId = saveId === "play-working" ? null : saveId;
renderSession(data.session, false);
} catch (err) {
if (lastView) renderSession(lastView, false);
alert(err.message);
}
}
async function renamePlaySave(bookId, saveId, currentLabel) {
const next = prompt("名称", currentLabel);
if (next == null) return;
const trimmed = next.trim();
if (!trimmed) return alert("名称不能为空");
if (trimmed === currentLabel) return;
try {
await api(
`/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}`,
{ method: "PATCH", body: JSON.stringify({ label: trimmed }) },
);
await fetchPlaySavesForBook(bookId, true);
} catch (err) {
alert(err.message);
}
}
async function deletePlaySave(bookId, saveId, label, kindLabel = "游玩存档") {
if (!confirm(`删除${kindLabel}「${label}」?`)) return;
try {
await api(
`/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}`,
{ method: "DELETE" },
);
if (activePlaySaveId === saveId) activePlaySaveId = null;
const expanded = expandedSpecsByBook.get(bookId);
expanded?.delete(saveId);
await fetchPlaySavesForBook(bookId, true);
} catch (err) {
alert(err.message);
}
}
async function startNewPlayForBook(bookId, instanceId) {
const thisView = lastView?.bookId === bookId ? lastView : null;
if (!bookPlayReady(bookId) && !thisView?.hasProduct && !canOfferEnterPlay(thisView)) {
alert("请先保存产物,或完成收口后再开玩");
return;
}
try {
if (instanceId) ensureSpecExpanded(bookId, instanceId);
else navigateToBook(bookId);
if (activeBookId !== bookId || !sessionId) {
await openBook(bookId);
} else if (lastView?.bookId === bookId) {
renderSession(lastView, true);
}
const data = await api(`/api/books/${encodeURIComponent(bookId)}/play/new`, {
method: "POST",
body: JSON.stringify(instanceId ? { instanceId } : {}),
});
activePlaySaveId = null;
renderSession(data.session, false);
void fetchPlaySavesForBook(bookId, true);
} catch (err) {
if (lastView) renderSession(lastView, false);
alert(err.message);
}
}
async function resumeDesignSession(bookId) {
activePlaySaveId = null;
try {
if (activeBookId !== bookId || !sessionId) {
await openBook(bookId);
}
if (isPlayNow()) {
await setLifecycle("design");
return;
}
renderPathBar();
renderBookList();
} catch (err) {
alert(err.message || "打开创作失败");
}
}
/** 点「创作」:进入该作品快照列表,并打开/恢复创作会话 */
async function openBookDesign(bookId) {
hideEnterPicker();
bookSelectMode = false;
selectedBookIds = new Set();
navigateToBook(bookId);
await resumeDesignSession(bookId);
}
async function openCurrentPlay(bookId) {
hideEnterPicker();
const instanceId = playingInstanceIdForBook({ id: bookId });
if (instanceId) ensureSpecExpanded(bookId, instanceId);
else navigateToBook(bookId);
const alreadyHere =
activeBookId === bookId &&
isPlayNow() &&
!activePlaySaveId &&
sessionId &&
lastView?.waitingReason?.kind !== "review_artifact";
if (alreadyHere) {
renderBookList();
return;
}
if (activeBookId !== bookId || !sessionId) {
await openBook(bookId);
}
if (activePlaySaveId && lastView?.playLayerActive) {
try {
const data = await api(
`/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent("play-working")}/load`,
{ method: "POST" },
);
activePlaySaveId = null;
renderSession(data.session, false);
return;
} catch {
/* 尚无工作副本则走生命周期入口 */
}
}
activePlaySaveId = null;
await setLifecycle("play");
}
function renderHeader(view, loading) {
const st = statusFor(view, loading);
const title = view.bookTitle ?? "未命名作品";
document.title = `${title} · Writing Agent`;
$("status-dot").className = `status-dot ${st.cls}`;
$("status-text").textContent = st.text;
const showSavePlay = Boolean(
view.bookId && view.playReady && view.lifecycleStage === "play" && view.playLayerActive,
);
const showSaveInstance = Boolean(view.bookId && view.playReady);
const btnPlay = $("btn-save-play");
if (btnPlay) btnPlay.hidden = !showSavePlay;
const btnInst = $("btn-save-instance");
if (btnInst) btnInst.hidden = !showSaveInstance;
const btnExport = $("btn-export");
if (btnExport) btnExport.disabled = !(view.messages?.length);
if (view.activePersona?.id) {
globalActivePersonaId = view.activePersona.id;
const hit = globalPersonas.find((p) => p.id === view.activePersona.id);
if (hit) {
hit.name = view.activePersona.name || hit.name;
if (view.activePersona.description != null) {
hit.description = view.activePersona.description;
}
}
}
renderPersonaPicker();
}
function closeWorkspace({ emptyCopy = "" } = {}) {
hideEnterPicker();
stopLivePoll();
sessionId = null;
lastView = null;
activeBookId = null;
activePlaySaveId = null;
composerForceInput = false;
document.title = "Writing Agent";
$("status-dot").className = "status-dot";
$("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) {
stage.hidden = true;
stage.innerHTML = "";
}
const drawer = $("coord-rail");
if (drawer) {
drawer.hidden = true;
drawer.dataset.collapsed = "1";
const body = $("coord-drawer-body");
if (body) body.innerHTML = "";
const count = $("coord-drawer-count");
if (count) count.textContent = "";
}
document.body.classList.remove("is-reviewing", "design-workspace", "coord-rail-collapsed");
document.body.dataset.userTask = "";
delete document.body.dataset.designSurface;
$("skill-picker").hidden = true;
const focus = $("agent-focus");
if (focus) focus.innerHTML = "";
const timeline = $("agent-timeline");
if (timeline) timeline.innerHTML = "";
const trace = $("tool-trace");
if (trace) {
trace.hidden = true;
trace.innerHTML = "";
}
resetRailChrome();
hideBookMenu();
document.body.dataset.lifecycle = "design";
const composerMain = $("composer-main");
if (composerMain) composerMain.innerHTML = `<div class="composer-idle">暂无打开的作品</div>`;
}
function renderEmpty() {
sidebarNav = { level: "root" };
document.body.dataset.navLevel = "root";
bookSelectMode = false;
selectedBookIds = new Set();
closeWorkspace({ emptyCopy: "点击左侧 + 新建作品" });
renderBookList();
}
function stopLivePoll() {
if (livePollTimer != null) {
clearInterval(livePollTimer);
livePollTimer = null;
}
livePollSawRun = false;
livePollBaselineSig = "";
livePollStructuralSig = "";
livePollStartedAt = 0;
livePollFailCount = 0;
}
function beginUiRequest() {
return ++inflightSeq;
}
function isStaleUiRequest(seq) {
return seq !== inflightSeq;
}
function sessionStructuralSig(view) {
const messages = view?.messages ?? [];
const last = messages[messages.length - 1];
const products = view?.dictateProducts ?? [];
return [
messages.length,
last?.id ?? "",
products.length,
products.map((p) => p.tag).join(","),
view?.phase ?? "",
view?.waitingReason?.kind ?? "",
view?.runActive ? "1" : "0",
].join("|");
}
/** 用服务端最新 view 结束本地「生成中」,并作废悬挂的 POST */
function finishUiLoading(view) {
beginUiRequest();
renderSession(view, false);
}
async function recoverBusySession() {
if (!sessionId) return false;
try {
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}`);
finishUiLoading(view);
return true;
} catch {
const bookId = lastView?.bookId || activeBookId;
if (!bookId) return false;
try {
const data = await api(`/api/books/${encodeURIComponent(bookId)}/open`, {
method: "POST",
});
if (data?.session) {
finishUiLoading(data.session);
return true;
}
} catch {
/* fall through */
}
}
return false;
}
async function retryRun() {
if (!sessionId) return;
const seq = beginUiRequest();
try {
renderSession(lastView, true);
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/actions`, {
method: "POST",
body: JSON.stringify({ action: "retry_run" }),
});
if (isStaleUiRequest(seq)) return;
renderSession(view, false);
} catch (err) {
if (isStaleUiRequest(seq)) return;
const recovered = await recoverBusySession();
if (!recovered && lastView) {
renderSession({ ...lastView, hints: [err.message] }, false);
}
}
}
async function abortRun() {
if (!sessionId) return;
const seq = beginUiRequest();
try {
renderSession(lastView, true);
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/actions`, {
method: "POST",
body: JSON.stringify({ action: "abort_run" }),
});
if (isStaleUiRequest(seq)) return;
renderSession(view, false);
} catch (err) {
if (isStaleUiRequest(seq)) return;
// 请求已在服务端结束、或会话因重启丢失:停止也要能退出「生成中」
const recovered = await recoverBusySession();
if (!recovered && lastView) {
renderSession(
{ ...lastView, hints: [err.message || "已停止"] },
false,
);
}
}
}
async function clearDictateDialogue() {
if (!sessionId) return;
if (!confirm("清空对话?产物会保留。")) return;
const seq = beginUiRequest();
try {
const view = await api(
`/api/sessions/${encodeURIComponent(sessionId)}/dictate/clear-dialogue`,
{ method: "POST", body: "{}" },
);
if (isStaleUiRequest(seq)) return;
renderSession(view, false);
} catch (err) {
if (isStaleUiRequest(seq)) return;
alert(err.message);
}
}
function startLivePoll() {
// 已在轮询时不要重置:loading 下因消息落盘而 renderSession(..., true) 会再次调用
if (livePollTimer != null) return;
if (!sessionId) return;
livePollSawRun = false;
livePollFailCount = 0;
livePollBaselineSig = sessionStructuralSig(lastView);
livePollStructuralSig = livePollBaselineSig;
livePollStartedAt = Date.now();
livePollTimer = setInterval(async () => {
if (!sessionId || livePollTimer == null) return;
try {
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}`);
if (!sessionId || livePollTimer == null) return;
livePollFailCount = 0;
if (view.runActive) livePollSawRun = true;
const sig = sessionStructuralSig(view);
const progressed = sig !== livePollBaselineSig;
const elapsed = Date.now() - livePollStartedAt;
// 服务端已空闲且:见过 run / 或内容相对启动时已推进
const serverDone =
!view.runActive &&
(livePollSawRun || (progressed && elapsed > 600));
if (serverDone) {
finishUiLoading(view);
return;
}
if (sig !== livePollStructuralSig) {
livePollStructuralSig = sig;
// 保持 loading 底栏,但把已写入的用户句/产物/回复刷进主区
renderSession(view, true);
return;
}
lastView = {
...lastView,
liveStream: view.liveStream,
agentThinking: view.agentThinking,
runActive: view.runActive,
dictateProducts: view.dictateProducts ?? lastView.dictateProducts,
};
updateLiveStreamPanel(view);
} catch {
livePollFailCount += 1;
// 服务重启后内存会话丢失:重开作品收尾,避免永远转圈
if (livePollFailCount >= 3) {
const ok = await recoverBusySession();
if (!ok && lastView) {
finishUiLoading({
...lastView,
hints: ["连接中断,请点左侧作品重新打开"],
});
}
}
}
}, LIVE_POLL_MS);
}
function renderSession(view, loading = false) {
lastView = view;
sessionId = view.id;
activeBookId = view.bookId ?? activeBookId;
if (
!loading &&
view.waitingReason?.kind !== "approve_step" &&
view.waitingReason?.kind !== "pick_creation_step" &&
view.waitingReason?.kind !== "review_artifact" &&
view.waitingReason?.kind !== "worker_questions" &&
!(view.waitingReason?.kind === "input" && view.waitingReason?.questions?.length)
) {
composerForceInput = false;
}
renderHeader(view, loading);
renderBookList();
renderWorkspace(view, loading, (skillId) => sendText(skillId), {
onSkipQuestions: () => runAction("skip_questions"),
onQuestionAnswersChange: () => refreshReviewComposerChrome(),
onQuestionCardEnter: () => {
const { answered } = selectedQuestionAnswers(lastView);
if (!answered.length) return;
void sendText($("composer-input")?.value ?? "");
},
onEditMessage: (messageId, text) => messageAction("edit", messageId, { text }),
onRefreshMessage: (messageId) => messageAction("refresh", messageId),
onDeleteMessage: (messageId) => messageAction("delete", messageId),
onRestartFromMessage: (messageId) => messageAction("restart", messageId),
onRollbackToInput: (messageId, draft) => {
pendingComposerDraft = String(draft ?? "");
pendingComposerDraftSeq = 0;
messageAction("delete", messageId);
},
onViewContext: (messageId) => showContextTraceDialog(messageId),
onCompareModels: (messageId) => openModelComparePicker(messageId),
onSwitchVariant: (messageId, direction) =>
messageAction("variant", messageId, { direction }),
onRetryRun: () => retryRun(),
onPickCreationStep: (stepId) => runAction("pick_step", { stepId }),
onReenterCreationStep: (stepId) =>
runAction("pick_step", { stepId, reenter: true }),
onViewStepArtifact: (stepId, name) => showStepArtifactDialog(stepId, name),
onDeleteCreationStep: (stepId, name) => {
const label = name ? `${name}` : stepId;
if (!confirm(`删除「${label}」这条产物?会从图上拿掉,不可恢复。`)) return;
void runAction("delete_step", { stepId });
},
onSpawnCreationStep: (moduleName) => runAction("spawn_step", { moduleName }),
onLeaveCreationStep: () => runAction("leave_step"),
onEnterPlay: () => enterPlayNow(),
onSaveProduct: () => void saveCurrentInstance(),
onPatchTable: (tag, cells) => void patchPlayTable(tag, cells),
onSelectOpening: (index, usePersona) => void selectPlayOpening(index, usePersona),
onRefuseOpeningPersona: () => {
const idx = lastView?.playAux?.selectedOpeningIndex ?? 0;
void selectPlayOpening(idx, false);
},
});
renderComposer(view, loading);
if (loading) startLivePoll();
else stopLivePoll();
}
async function messageAction(kind, messageId, body = {}) {
if (!sessionId) return;
const seq = beginUiRequest();
if (pendingComposerDraft && pendingComposerDraftSeq === 0 && kind === "delete") {
pendingComposerDraftSeq = seq;
} else if (pendingComposerDraft && pendingComposerDraftSeq !== seq) {
pendingComposerDraft = "";
pendingComposerDraftSeq = 0;
}
try {
renderSession(lastView, true);
const view = await api(
`/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/${kind}`,
{
method: "POST",
body: JSON.stringify(body),
},
);
if (isStaleUiRequest(seq)) return;
renderSession(view, false);
} catch (err) {
if (isStaleUiRequest(seq)) return;
pendingComposerDraft = "";
pendingComposerDraftSeq = 0;
if (lastView) renderSession({ ...lastView, hints: [err.message] }, false);
}
}
/** 询问卡已选中的追问作答(不要求卡仍标 is-open;未选则为空) */
function selectedQuestionAnswers(view) {
const qHost = $("questions-card-host");
const activeQs = getActiveQuestions(view);
if (!activeQs?.questions?.length || !qHost?._qState) {
return { collected: null, answered: [] };
}
const collected = collectQuestionAnswers(qHost, view);
const answered = collected?.ok
? collected.answers.filter((a) => a.text && a.text !== "(未答)")
: [];
return { collected, answered };
}
/** 空 Enter=接受;有字或已选追问则按意见改。点「按意见修改」不走这条。 */
async function submitComposer(text, { acceptOnEmpty, requiresText } = {}) {
const trimmed = String(text ?? "").trim();
if (requiresText && !trimmed) {
alert("点图上节点即确认并进入;要改编排请先写下意见。");
return;
}
if (acceptOnEmpty && !trimmed) {
const { answered } = selectedQuestionAnswers(lastView);
if (answered.length === 0) {
const acceptBtn = $("composer")?.querySelector("[data-act=accept]");
if (acceptBtn && !acceptBtn.disabled) {
await runAction("accept");
return;
}
}
}
await sendText(text);
}
async function sendText(text) {
if (!sessionId) return;
const seq = beginUiRequest();
const trimmed = text?.trim() ?? "";
const qHost = $("questions-card-host");
const activeQs = getActiveQuestions(lastView);
const reviewing = lastView?.waitingReason?.kind === "review_artifact";
const cardOpen = Boolean(
activeQs?.questions?.length && qHost?.classList.contains("is-open"),
);
// 能力默认问题:对齐美学纲领,须自由书写,不能空发/跳过当答复
if (isModuleOpeningWaiting(lastView) && !trimmed) {
alert("请先按主栏引导写几句再发送。");
return;
}
// 核对/验收:有选中追问或修改意见 ⇒ 写回产物;点「按意见修改」绝不等于接受
if (reviewing) {
const { collected, answered } = selectedQuestionAnswers(lastView);
if (!trimmed && answered.length === 0) {
alert(
isFlowPlanReview(lastView)
? "点图上节点即确认并进入;要改编排请先写下意见。"
: "请选择追问选项或填写修改意见;满意请点「接受」收下产物。",
);
return;
}
try {
const body = { text: trimmed };
if (answered.length && collected?.answers) {
body.answers = collected.answers;
}
clearQuestionCardState(qHost, lastView);
beginComposerSubmit();
renderSession(lastView, true);
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, {
method: "POST",
body: JSON.stringify(body),
});
if (isStaleUiRequest(seq)) return;
clearQuestionCardState(qHost);
renderSession(view, false);
} catch (err) {
if (isStaleUiRequest(seq)) return;
keepComposerDraft(trimmed);
if (qHost) qHost._qDismissed = null;
if (lastView) renderSession({ ...lastView, hints: [err.message] }, false);
} finally {
endComposerSubmit();
}
return;
}
if (cardOpen) {
const collected = collectQuestionAnswers(qHost, lastView);
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;
renderQuestionsCard(qHost, lastView, {
onSkipQuestions: () => runAction("skip_questions"),
});
}
alert(collected.error);
return;
}
// 有选中 → 问+答拼接发送,并收起卡
if (collected.ok && answered.length) {
try {
clearQuestionCardState(qHost, lastView);
beginComposerSubmit();
renderSession(lastView, true);
const body = { answers: collected.answers };
if (trimmed) body.note = trimmed;
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/answers`, {
method: "POST",
body: JSON.stringify(body),
});
if (isStaleUiRequest(seq)) return;
clearQuestionCardState(qHost);
renderSession(view, false);
} catch (err) {
if (isStaleUiRequest(seq)) return;
keepComposerDraft(trimmed);
if (qHost) qHost._qDismissed = null;
if (lastView) renderSession({ ...lastView, hints: [err.message] }, false);
} finally {
endComposerSubmit();
}
return;
}
// 未选中:不带问;点发送仍算答复 → 收起卡
if (!trimmed) {
clearQuestionCardState(qHost, lastView);
beginComposerSubmit();
try {
await runAction("skip_questions");
} finally {
endComposerSubmit();
}
return;
}
clearQuestionCardState(qHost, lastView);
}
if (!trimmed) {
if (
lastView?.waitingReason?.kind === "pick_creation_step" ||
isFlowPlanReview(lastView)
) {
alert("点图上节点即确认并进入;要改编排请先写下意见。");
return;
}
if (
lastView?.waitingReason?.kind === "next_intent" ||
resolveComposer(lastView, false)?.allowEmpty
) {
try {
beginComposerSubmit();
renderSession(lastView, true);
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, {
method: "POST",
body: JSON.stringify({ text: "" }),
});
if (isStaleUiRequest(seq)) return;
clearQuestionCardState(qHost);
renderSession(view, false);
} catch (err) {
if (isStaleUiRequest(seq)) return;
if (lastView) renderSession({ ...lastView, hints: [err.message] }, false);
} finally {
endComposerSubmit();
}
}
return;
}
try {
beginComposerSubmit();
const optimistic = lastView
? {
...lastView,
messages: [
...(lastView.messages ?? []),
{
id: `local-${Date.now()}`,
role: "user",
kind: "user_input",
text: trimmed,
body: trimmed,
createdAt: new Date().toISOString(),
},
],
}
: lastView;
renderSession(optimistic || lastView, true);
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, {
method: "POST",
body: JSON.stringify({ text: trimmed }),
});
if (isStaleUiRequest(seq)) return;
clearQuestionCardState(qHost);
renderSession(view, false);
} catch (err) {
if (isStaleUiRequest(seq)) return;
keepComposerDraft(trimmed);
if (cardOpen && qHost) qHost._qDismissed = null;
const recovered = await recoverBusySession();
if (!recovered && lastView) {
renderSession({ ...lastView, hints: [err.message] }, false);
}
} finally {
endComposerSubmit();
}
}
async function runAction(action, extra = {}) {
if (!sessionId) return;
const seq = beginUiRequest();
const qHost = $("questions-card-host");
try {
// 接受 / 跳过:先收起询问卡,避免 loading 用旧 waitingReason 再画出来
if (action === "accept" || action === "skip_questions") {
clearQuestionCardState(qHost, lastView);
}
renderSession(lastView, true);
const payload = { action, ...extra };
if (action === "accept") {
const picker = document.querySelector("[data-opening-picker]");
if (picker) {
const n = Number(picker.getAttribute("data-selected") || 0);
if (Number.isFinite(n)) payload.openingIndex = n;
}
}
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/actions`, {
method: "POST",
body: JSON.stringify(payload),
});
if (isStaleUiRequest(seq)) return;
if (action === "accept" || action === "skip_questions") {
clearQuestionCardState(qHost);
}
renderSession(view, false);
} catch (err) {
if (isStaleUiRequest(seq)) return;
if (
(action === "accept" || action === "skip_questions") &&
qHost
) {
qHost._qDismissed = null;
}
if (lastView) renderSession({ ...lastView, hints: [err.message] }, false);
}
}
function enterPlayNow() {
if (activeBookId) {
void openCurrentPlay(activeBookId);
return;
}
void (async () => {
await setLifecycle("play");
})();
}
async function patchPlayTable(tag, cells) {
if (!sessionId) return;
try {
const data = await api(`/api/sessions/${encodeURIComponent(sessionId)}/table`, {
method: "POST",
body: JSON.stringify({ tag, cells }),
});
const skipped = data.tablePatch?.skipped ?? [];
const conflicts = skipped.filter((s) => String(s.reason || "").includes("rev"));
if (conflicts.length) {
alert(
`有字段已被更新,未写入:${conflicts.map((c) => c.key).join("、")}。请再改一次。`,
);
}
renderSession(data, false);
} catch (err) {
alert(err.message || "无法改表");
if (lastView) renderSession(lastView, false);
}
}
async function selectPlayOpening(index, usePersona) {
if (!sessionId) return;
try {
const view = await api(
`/api/sessions/${encodeURIComponent(sessionId)}/play/opening`,
{
method: "POST",
body: JSON.stringify({
index,
...(typeof usePersona === "boolean" ? { usePersona } : {}),
}),
},
);
renderSession(view, false);
} catch (err) {
alert(err.message || "无法选用开场");
if (lastView) renderSession(lastView, false);
}
}
async function setLifecycle(stage, opts) {
if (!sessionId) return;
if (stage === lastView?.lifecycleStage) {
if (stage === "design") return;
if (
stage === "play" &&
lastView?.playLayerActive &&
lastView?.waitingReason?.kind !== "review_artifact"
) {
return;
}
}
if (stage === "play" && !lastView?.playReady && !canOfferEnterPlay(lastView) && !lastView?.hasProduct) {
alert("请先保存产物,或完成收口后再开玩");
return;
}
try {
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/lifecycle`, {
method: "POST",
body: JSON.stringify({
stage,
...(typeof opts?.useOpeningPersona === "boolean"
? { useOpeningPersona: opts.useOpeningPersona }
: {}),
}),
});
if (stage === "design") {
activePlaySaveId = null;
if (activeBookId) navigateToBook(activeBookId);
} else if (stage === "play" && activeBookId) {
navigateToBook(activeBookId);
}
renderSession(view, false);
if (activeBookId) void fetchPlaySavesForBook(activeBookId, true);
} catch (err) {
alert(err.message || "无法切换阶段");
if (lastView) renderSession(lastView, false);
}
}
async function loadBooks() {
const data = await api("/api/books");
books = data.books ?? [];
renderBookList();
}
function openNewBookDialog() {
$("input-book-title").value = "";
void populateDirectorSelect();
$("dialog-new-book").showModal();
$("input-book-title").focus();
}
async function populateDirectorSelect() {
const sel = $("select-director");
const desc = $("director-desc");
const modeSel = $("select-creation-mode");
const modeDesc = $("creation-mode-desc");
const fieldDirector = $("field-director");
let recipeDirectors = [];
let dictateDirectors = [];
const fillOptions = (list) => {
if (!sel) return;
sel.innerHTML = "";
if (!list.length) {
sel.innerHTML = `<option value="">暂无配方</option>`;
sel.required = false;
return;
}
for (const d of list) {
const opt = document.createElement("option");
opt.value = d.id;
opt.textContent = d.name || d.id;
if (d.declaration) opt.dataset.declaration = d.declaration;
sel.appendChild(opt);
}
sel.value = list[0].id;
sel.required = true;
};
const syncDesc = () => {
if (!sel || !desc) return;
if (!modeSel?.value) {
desc.hidden = true;
desc.textContent = "";
return;
}
const list =
modeSel.value === "dictate" ? dictateDirectors : recipeDirectors;
const cur = list.find((d) => d.id === sel.value);
const text = (cur?.declaration ?? "").trim();
desc.hidden = !text;
desc.textContent = text;
};
const syncMode = () => {
const mode = modeSel?.value?.trim() || "";
if (!mode) {
if (fieldDirector) fieldDirector.hidden = true;
if (modeDesc) modeDesc.hidden = true;
if (desc) desc.hidden = true;
if (sel) {
sel.innerHTML = `<option value="">请先选择进料方式</option>`;
sel.required = false;
}
return;
}
const dictate = mode === "dictate";
if (fieldDirector) fieldDirector.hidden = false;
fillOptions(dictate ? dictateDirectors : recipeDirectors);
if (modeDesc) {
modeDesc.hidden = false;
modeDesc.textContent = dictate
? `${displayCreationModeLabel("dictate")}:对话写入产物;变量/映射用工具钉死;下方为对应配方。`
: `${displayCreationModeLabel("recipe")}:按工作流计划逐步验收技能;下方为对应配方。`;
}
syncDesc();
};
if (modeSel && !modeSel.dataset.bound) {
modeSel.dataset.bound = "1";
modeSel.addEventListener("change", syncMode);
}
if (sel && !sel.dataset.boundDesc) {
sel.dataset.boundDesc = "1";
sel.addEventListener("change", syncDesc);
}
if (!sel) return;
try {
const data = await api("/api/directors");
recipeDirectors = data.recipeDirectors ?? data.directors ?? [];
dictateDirectors = data.dictateDirectors ?? [];
// 打开对话框时重置为「先选进料」
if (modeSel) {
modeSel.value = "";
const placeholder = [...modeSel.options].find((o) => !o.value);
if (placeholder) placeholder.selected = true;
}
syncMode();
} catch (err) {
sel.innerHTML = `<option value="">加载失败</option>`;
if (desc) {
desc.hidden = false;
desc.textContent = err.message;
}
}
}
async function createBook() {
const title = $("input-book-title").value.trim() || "未命名作品";
const creationMode = $("select-creation-mode")?.value?.trim() || "";
const recipeId = $("select-director")?.value?.trim();
if (creationMode !== "recipe" && creationMode !== "dictate") {
alert("请先选择进料方式");
return;
}
if (!recipeId) {
alert("请选择配方");
return;
}
$("btn-create-book").disabled = true;
try {
const directorsMeta = await api("/api/directors").catch(() => null);
const orchestratorId = directorsMeta?.skillPackId || "world-simulator";
const data = await api("/api/books", {
method: "POST",
body: JSON.stringify({
title,
orchestratorId,
creationMode,
recipeId,
}),
});
$("dialog-new-book").close();
if (data.book) books.unshift(data.book);
activeBookId = data.book?.id;
sidebarNav = { level: "root" };
document.body.dataset.navLevel = "root";
renderSession(data.session, false);
} catch (err) {
alert(err.message);
} finally {
$("btn-create-book").disabled = false;
}
}
async function openBook(bookId) {
const seq = beginUiRequest();
activeBookId = bookId;
if (lastView?.bookId === bookId) renderSession(lastView, true);
try {
const data = await api(`/api/books/${encodeURIComponent(bookId)}/open`, { method: "POST" });
if (isStaleUiRequest(seq)) return;
if (data.book) {
const i = books.findIndex((b) => b.id === bookId);
if (i >= 0) books[i] = { ...books[i], ...data.book };
}
renderSession(data.session, false);
} catch (err) {
if (isStaleUiRequest(seq)) return;
if (lastView?.bookId === bookId) renderSession({ ...lastView, hints: [err.message] }, false);
else alert(err.message);
}
}
async function deleteBookById(bookId) {
const book = books.find((b) => b.id === bookId);
if (!book) return;
if (!confirm(`删除「${book.title}」?不可恢复。`)) return;
try {
await api(`/api/books/${encodeURIComponent(bookId)}`, { method: "DELETE" });
books = books.filter((b) => b.id !== bookId);
playSavesByBook.delete(bookId);
selectedBookIds.delete(bookId);
if (activeBookId === bookId) activePlaySaveId = null;
hideBookMenu();
const wasHere = activeBookId === bookId || sidebarNav.bookId === bookId;
if (wasHere) {
if (books.length) showCatalog();
else renderEmpty();
} else {
renderBookList();
}
} catch (err) {
alert(err.message);
}
}
async function saveCurrentPlay() {
if (!activeBookId || !lastView?.playReady) return alert("须先验收 Worker 集");
if (lastView.lifecycleStage !== "play" || !lastView.playLayerActive) {
return alert("请先进入游玩");
}
const label = prompt("游玩进度存档名称");
if (!label?.trim()) return;
try {
await api(`/api/books/${encodeURIComponent(activeBookId)}/saves`, {
method: "POST",
body: JSON.stringify({ label: label.trim(), sessionId, kind: "run" }),
});
playSavesByBook.delete(activeBookId);
void fetchPlaySavesForBook(activeBookId, true);
} catch (err) {
alert(err.message);
}
}
function defaultArchiveLabel() {
return (
formatSaveWhen(new Date().toISOString()) ||
new Date().toLocaleString("zh-CN")
);
}
async function saveCurrentInstance() {
if (!activeBookId || (!lastView?.playReady && !canOfferSaveProduct(lastView))) {
return alert(
lastView?.creationMode === "dictate"
? "请先让 Agent 写入至少一项产物,再保存定稿"
: "须先完成收口或验收 Worker 集",
);
}
const label = prompt("落档名称", defaultArchiveLabel());
if (!label?.trim()) return;
try {
await api(`/api/books/${encodeURIComponent(activeBookId)}/saves`, {
method: "POST",
body: JSON.stringify({ label: label.trim(), sessionId, kind: "instance" }),
});
if (lastView) {
lastView = { ...lastView, hasProduct: true };
renderSession(lastView, false);
}
await fetchPlaySavesForBook(activeBookId, true);
const newest = productInstancesForBook(activeBookId)[0];
if (newest) ensureSpecExpanded(activeBookId, newest.id);
else navigateToBook(activeBookId);
} catch (err) {
alert(err.message);
}
}
async function showStepArtifactDialog(stepId, name) {
if (!sessionId || !stepId) return;
try {
const data = await api(
`/api/sessions/${encodeURIComponent(sessionId)}/step-artifact?stepId=${encodeURIComponent(stepId)}`,
);
fillStepArtifactDialog({
name: data.name || name,
content: data.content,
});
} catch (err) {
alert(err.message || "无法读取产物");
}
}
function showContextTraceDialog(messageId) {
const msg = (lastView?.messages ?? []).find((m) => m.id === messageId);
const review = lastView?.reviewArtifact;
const trace =
msg?.contextTrace ||
(review &&
(review.sourceMessageId === messageId || review.id === messageId)
? review.contextTrace
: undefined);
const dlg = $("dialog-context-trace");
const meta = $("context-trace-meta");
const body = $("context-trace-body");
if (!dlg || !meta || !body) return;
if (!trace?.messages?.length) {
alert("这条消息没有保存请求上下文(可能已被修剪,或当时未开启保存)");
return;
}
const subject = msg || review || {};
const requestChars =
trace.charCount ??
trace.messages.reduce((sum, item) => sum + String(item.content ?? "").length, 0);
const usage = subject.tokenUsage;
const responseBody = String(subject.body ?? subject.text ?? "").trim();
const thinking = String(subject.thinking ?? "").trim();
const requestLog = trace.messages.map((item, index) => {
const content = String(item.content ?? "");
const part = String(index + 1).padStart(2, "0");
return `======== REQUEST ${part}/${trace.messages.length} · ${item.role} · ${content.length.toLocaleString("zh-CN")} chars ========\n${content}`;
});
const responseLog = [
thinking
? `======== RESPONSE THINKING · ${thinking.length.toLocaleString("zh-CN")} chars ========\n${thinking}`
: "",
responseBody
? `======== RESPONSE BODY · ${responseBody.length.toLocaleString("zh-CN")} chars ========\n${responseBody}`
: "",
].filter(Boolean);
const tokenMeta = usage?.totalTokens
? ` · ${Number(usage.totalTokens).toLocaleString("zh-CN")} tokens`
: "";
meta.textContent = [
subject.kind || "message",
subject.actor || trace.caller || "unknown",
trace.model || usage?.model || "model?",
`${trace.messages.length} 段请求`,
`${(requestChars / 1024).toFixed(1)} KB`,
].join(" · ") + tokenMeta;
body.textContent = [
`MESSAGE ${messageId}`,
`KIND ${subject.kind || "unknown"}`,
`ACTOR ${subject.actor || "-"}`,
`CALLER ${trace.caller || usage?.caller || "unknown"}`,
`MODEL ${trace.model || usage?.model || "model?"}`,
`CREATED ${trace.createdAt || subject.createdAt || "-"}`,
`REQUEST ${trace.messages.length} messages / ${requestChars.toLocaleString("zh-CN")} chars`,
trace.generation && Object.keys(trace.generation).length
? `GEN ${JSON.stringify(trace.generation)}`
: "GEN (none)",
usage?.totalTokens
? `TOKENS total=${usage.totalTokens} cached=${usage.cachedTokens ?? 0} miss=${usage.cacheMissTokens ?? 0}`
: "TOKENS not recorded",
"",
...requestLog,
...(responseLog.length ? ["", ...responseLog] : []),
].join("\n\n");
dlg.showModal();
}
let comparePickerMessageId = null;
let comparePollTimer = null;
let compareActiveJobId = null;
function stopComparePoll() {
if (comparePollTimer) {
clearTimeout(comparePollTimer);
comparePollTimer = null;
}
}
async function openModelComparePicker(messageId) {
if (!sessionId) return;
const msg = (lastView?.messages ?? []).find((m) => m.id === messageId);
const review = lastView?.reviewArtifact;
const hasTrace =
msg?.contextTrace?.messages?.length ||
(review &&
(review.sourceMessageId === messageId || review.id === messageId) &&
review.contextTrace?.messages?.length);
if (!hasTrace) {
alert("这条消息没有可重放的上下文(可能已被修剪)");
return;
}
comparePickerMessageId = messageId;
const list = $("compare-profile-list");
const dlg = $("dialog-model-compare-pick");
if (!list || !dlg) return;
list.innerHTML = `<p class="dialog-desc">加载 API 配置…</p>`;
dlg.showModal();
try {
const data = await api("/api/profiles");
const profiles = (data.profiles ?? []).filter((p) => p.apiKey?.trim());
if (!profiles.length) {
list.innerHTML = `<p class="dialog-desc">没有可用的 API 配置,请先到设置页添加。</p>`;
return;
}
list.innerHTML = profiles
.map(
(p, i) => `
<label class="compare-profile-item">
<input type="checkbox" name="compare-profile" value="${esc(p.id)}" ${i < 2 ? "checked" : ""} />
<span>
<strong>${esc(p.name)}</strong>
<div class="muted">${esc(p.model)}</div>
</span>
</label>`,
)
.join("");
} catch (err) {
list.innerHTML = `<p class="dialog-desc">${esc(err.message || "加载失败")}</p>`;
}
}
async function startModelCompareFromPicker() {
if (!sessionId || !comparePickerMessageId) return;
const checked = [
...document.querySelectorAll('input[name="compare-profile"]:checked'),
].map((el) => el.value);
if (checked.length < 1) {
alert("请至少选择一个 API 配置");
return;
}
$("dialog-model-compare-pick")?.close();
try {
const job = await api(
`/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(comparePickerMessageId)}/compare`,
{
method: "POST",
body: JSON.stringify({ profileIds: checked }),
},
);
openModelCompareWindow(job);
} catch (err) {
alert(err.message || "无法开始对比");
}
}
function openModelCompareWindow(job) {
stopComparePoll();
compareActiveJobId = job.id;
const dlg = $("dialog-model-compare");
const meta = $("compare-meta");
if (!dlg || !meta) return;
meta.textContent = `任务 ${job.id.slice(0, 8)} · 原文模型 ${job.original?.model || "?"} · 共 ${job.candidates?.length ?? 0} 个候选`;
renderCompareJob(job);
dlg.showModal();
scheduleComparePoll();
}
function scheduleComparePoll() {
stopComparePoll();
if (!compareActiveJobId || !sessionId) return;
comparePollTimer = setTimeout(async () => {
try {
const job = await api(
`/api/sessions/${encodeURIComponent(sessionId)}/compare/${encodeURIComponent(compareActiveJobId)}`,
);
renderCompareJob(job);
if (job.status === "running") scheduleComparePoll();
} catch (err) {
const meta = $("compare-meta");
if (meta) meta.textContent = err.message || "轮询失败";
}
}, 800);
}
function renderCompareJob(job) {
const host = $("compare-results");
if (!host) return;
const original = job.original ?? {};
const cards = [
`<article class="compare-card">
<div class="compare-card-head">
<strong>原文</strong>
<span>${esc(original.model || "当前结果")}</span>
</div>
<div class="compare-card-status">对照 · 不会写入废案</div>
<pre class="compare-card-body">${esc(original.text || "")}</pre>
</article>`,
...(job.candidates ?? []).map((c) => {
const statusText =
c.status === "done"
? c.usage?.totalTokens
? `完成 · ${Number(c.usage.totalTokens).toLocaleString("zh-CN")} tokens`
: "完成"
: c.status === "error"
? `失败 · ${c.error || ""}`
: c.status === "running"
? "生成中…"
: "排队中";
const body =
c.status === "done"
? [
c.thinking ? `【思考】\n${c.thinking}\n\n【正文】\n` : "",
c.content || "",
].join("")
: c.error || "";
const adoptBtn =
c.status === "done"
? `<div class="compare-card-actions">
<button type="button" class="btn btn-primary" data-adopt-profile="${esc(c.profileId)}">采用此结果</button>
</div>`
: "";
return `<article class="compare-card">
<div class="compare-card-head">
<strong>${esc(c.profileName || c.profileId)}</strong>
<span>${esc(c.model || "")}</span>
</div>
<div class="compare-card-status ${c.status === "error" ? "is-error" : ""}">${esc(statusText)}</div>
<pre class="compare-card-body">${esc(body)}</pre>
${adoptBtn}
</article>`;
}),
];
host.innerHTML = cards.join("");
}
async function adoptCompareResult(profileId) {
if (!sessionId || !compareActiveJobId || !profileId) return;
const seq = beginUiRequest();
try {
const view = await api(
`/api/sessions/${encodeURIComponent(sessionId)}/compare/${encodeURIComponent(compareActiveJobId)}/adopt`,
{
method: "POST",
body: JSON.stringify({ profileId }),
},
);
if (isStaleUiRequest(seq)) return;
stopComparePoll();
$("dialog-model-compare")?.close();
compareActiveJobId = null;
renderSession(view, false);
} catch (err) {
alert(err.message || "采用失败");
}
}
$("btn-start-model-compare")?.addEventListener("click", () => {
void startModelCompareFromPicker();
});
$("btn-close-model-compare")?.addEventListener("click", () => {
stopComparePoll();
compareActiveJobId = null;
$("dialog-model-compare")?.close();
});
$("compare-results")?.addEventListener("click", (e) => {
const profileId = e.target.closest("[data-adopt-profile]")?.getAttribute("data-adopt-profile");
if (!profileId) return;
if (!confirm("采用此结果?其它成功候选会作为废案保留,可用左右切换查看。")) return;
void adoptCompareResult(profileId);
});
$("btn-copy-context")?.addEventListener("click", async () => {
const body = $("context-trace-body")?.textContent ?? "";
try {
await navigator.clipboard.writeText(body);
} catch {
alert("复制失败");
}
});
function exportSession() {
if (!lastView) return;
const name = (lastView.bookTitle ?? "session").replace(/[\\/:*?"<>|]/g, "_");
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-enter-pop")?.addEventListener("click", (e) => {
e.stopPropagation();
const btn = e.target.closest("[data-enter]");
if (!btn || btn.disabled) return;
const enter = btn.getAttribute("data-enter");
const bookId = enterPickerBookId;
hideEnterPicker();
if (!bookId) return;
if (enter === "design") void openBookDesign(bookId);
else if (enter === "play") void openCurrentPlay(bookId);
});
$("book-action-menu")?.addEventListener("click", (e) => {
const action = e.target.closest("[data-action]")?.getAttribute("data-action");
const bookId = bookMenuBookId;
hideBookMenu();
if (!bookId || !action) return;
if (action === "open") {
setBookSelectMode(false);
void openBookDesign(bookId);
} else if (action === "play") {
setBookSelectMode(false);
void openCurrentPlay(bookId);
} else if (action === "save-instance") {
if (bookId === activeBookId) void saveCurrentInstance();
} else if (action === "save-play") {
if (bookId === activeBookId) void saveCurrentPlay();
} else if (action === "export") {
if (bookId === activeBookId) exportSession();
} else if (action === "rename") {
const id = selectedBookIds.size === 1 ? [...selectedBookIds][0] : bookId;
void renameBookById(id);
} else if (action === "duplicate") {
if (selectedBookIds.size > 1) void duplicateSelectedBooks();
else void duplicateBookById(bookId).then(() => setBookSelectMode(false));
} else if (action === "select-mode") {
if (bookSelectMode) {
setBookSelectMode(false);
} else {
setBookSelectMode(true, { keepSelection: true });
selectedBookIds.add(bookId);
renderBookList();
}
} else if (action === "delete") {
if (selectedBookIds.size > 1) void deleteSelectedBooks();
else void deleteBookById(bookId);
}
});
document.addEventListener("click", (e) => {
if (!e.target.closest("#book-action-menu")) hideBookMenu();
if (!e.target.closest("#save-action-menu")) hideSaveMenu();
if (
!e.target.closest("#book-enter-pop") &&
!e.target.closest(".explorer-folder")
) {
hideEnterPicker();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
hideBookMenu();
hideSaveMenu();
hideEnterPicker();
if (bookSelectMode || selectedBookIds.size) setBookSelectMode(false);
}
});
document.addEventListener("scroll", () => {
hideBookMenu();
hideSaveMenu();
hideEnterPicker();
}, true);
$("save-action-menu")?.addEventListener("click", (e) => {
const action = e.target
.closest("[data-save-menu-action]")
?.getAttribute("data-save-menu-action");
const { bookId, saveId, label } = saveMenuState;
hideSaveMenu();
if (!bookId || !saveId || !action) return;
if (action === "edit-products") {
void openSnapshotProductsEditor(bookId, saveId, label);
}
});
$("snapshot-products-body")?.addEventListener("click", (e) => {
const body = $("snapshot-products-body");
if (!body) return;
const bookId = body.dataset.bookId;
const saveId = body.dataset.saveId;
const products = body._products;
if (!bookId || !saveId || !Array.isArray(products)) return;
const editBtn = e.target.closest("[data-snap-edit]");
if (editBtn) {
const idx = Number(editBtn.getAttribute("data-snap-edit"));
const p = products[idx];
const row = body.querySelector(`.snap-product[data-idx="${idx}"]`);
if (!p || !row) return;
row.innerHTML = `
<div class="snap-product-head">
<span class="snap-product-label">${esc(p.label || p.tag)}</span>
<span class="snap-product-tag">${esc(p.tag)}</span>
</div>
<textarea class="snap-product-ta" data-snap-ta="${idx}"></textarea>
<div class="snap-product-actions">
<button type="button" class="btn btn-primary btn-sm" data-snap-save="${idx}">保存</button>
<button type="button" class="btn btn-sm" data-snap-cancel="${idx}">取消</button>
</div>`;
const ta = row.querySelector("textarea");
if (ta) {
ta.value = p.content || "";
ta.focus();
}
return;
}
const cancelBtn = e.target.closest("[data-snap-cancel]");
if (cancelBtn) {
const idx = Number(cancelBtn.getAttribute("data-snap-cancel"));
const p = products[idx];
const row = body.querySelector(`.snap-product[data-idx="${idx}"]`);
if (!p || !row) return;
const preview = String(p.content || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 160);
row.innerHTML = `
<div class="snap-product-head">
<span class="snap-product-label">${esc(p.label || p.tag)}</span>
<span class="snap-product-tag">${esc(p.tag)}</span>
<button type="button" class="btn-sm snap-product-edit" data-snap-edit="${idx}">改</button>
</div>
<div class="snap-product-preview" data-snap-preview="${idx}">${esc(preview)}${
String(p.content || "").length > 160 ? "…" : ""
}</div>`;
return;
}
const saveBtn = e.target.closest("[data-snap-save]");
if (saveBtn) {
const idx = Number(saveBtn.getAttribute("data-snap-save"));
const p = products[idx];
const row = body.querySelector(`.snap-product[data-idx="${idx}"]`);
const ta = row?.querySelector("textarea");
if (!p || !row || !ta) return;
saveBtn.disabled = true;
void saveSnapshotProductEdit(bookId, saveId, p.tag, ta.value, row, products, idx).catch(
(err) => {
saveBtn.disabled = false;
alert(err.message || "保存失败");
},
);
}
});
$("form-new-book")?.addEventListener("submit", (e) => {
e.preventDefault();
createBook();
});
$("btn-export")?.addEventListener("click", () => {
exportSession();
});
$("btn-save-play")?.addEventListener("click", () => {
void saveCurrentPlay();
});
$("btn-save-instance")?.addEventListener("click", () => {
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 bumpBtn = e.target.closest("[data-font-scale-bump]");
if (bumpBtn && menu.contains(bumpBtn)) {
e.preventDefault();
e.stopPropagation();
bumpUiFontScale(bumpBtn.getAttribute("data-font-scale-bump"));
syncThemeMenuUi();
}
});
$("composer")?.addEventListener("click", (e) => {
const menu = e.target.closest("#composer-more-menu");
if (!menu) return;
const clearDictateBtn = e.target.closest("[data-act=clear-dictate]");
if (clearDictateBtn && menu.contains(clearDictateBtn)) {
e.preventDefault();
closeComposerMoreMenu(menu);
void clearDictateDialogue();
return;
}
const mdBtn = e.target.closest("[data-toggle-markdown]");
if (mdBtn && menu.contains(mdBtn)) {
e.preventDefault();
applyMarkdownRender(!isMarkdownRenderEnabled());
syncMarkdownMenuUi();
if (lastView && sessionId) renderSession(lastView, false);
return;
}
const reloadBtn = e.target.closest("[data-reload-profiles]");
if (reloadBtn && menu.contains(reloadBtn)) {
e.preventDefault();
void loadApiProfiles();
return;
}
const profileBtn = e.target.closest("[data-activate-profile]");
if (profileBtn && menu.contains(profileBtn)) {
e.preventDefault();
void activateApiProfileFromMenu(profileBtn.getAttribute("data-activate-profile"), menu);
}
});
window.addEventListener("wa:session-updated", (e) => {
const view = e.detail;
if (!view?.id || !sessionId) return;
if (view.id !== sessionId) return;
renderSession(view, false);
});
window.addEventListener("wa:nav-root", () => {
showCatalog();
});
document.addEventListener("click", (e) => {
document.querySelectorAll("details.more-menu[open]").forEach((d) => {
if (!d.contains(e.target)) {
d.removeAttribute("open");
d.querySelectorAll("details.more-submenu[open]").forEach((sub) => {
sub.removeAttribute("open");
});
}
});
});
async function init() {
initColorTheme();
initUiFontScale();
initMarkdownRender();
syncThemeMenuUi();
try {
void populateDirectorSelect();
await Promise.all([loadBooks(), loadPersonas(), loadApiProfiles()]);
if (books.length) {
showCatalog();
} else {
renderEmpty();
openNewBookDialog();
}
} catch {
$("status-text").textContent = "加载失败";
}
}
init();
setupBookBoxSelect();