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 { displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js";
import {
clearQuestionCardState,
collectQuestionAnswers,
getActiveQuestions,
isModuleOpeningWaiting,
renderQuestionsCard,
} from "./questions-ui.js";
import {
applyColorTheme,
applyPresentChrome,
getStoredColorTheme,
getStoredPresentChrome,
initColorTheme,
initPresentChrome,
themeById,
chromeById,
} from "./theme.js";
import {
applyMarkdownRender,
initMarkdownRender,
isMarkdownRenderEnabled,
} from "./markdown.js";
let sessionId = null;
let activeBookId = null;
let lastView = null;
let books = [];
let composerForceInput = false;
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();
let bookMenuBookId = null;
let livePollTimer = null;
let inflightSeq = 0;
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, "&").replace(//g, ">");
}
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 `${esc(label)}`;
}
function composerInputShell(spec, { textareaHtml, trailing = "" } = {}) {
const chip = composerModeChip(spec);
const tone = spec.acceptAction?.tone
? ` data-tone="${esc(spec.acceptAction.tone)}"`
: "";
return `
`;
}
function composerMoreMenuHtml() {
const on = isMarkdownRenderEnabled();
return ``;
}
function composerFormHtml(shell) {
return ``;
}
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 chromeId =
document.documentElement.getAttribute("data-present-chrome") || getStoredPresentChrome();
const colorMeta = themeById(colorId);
const chromeMeta = chromeById(chromeId);
const sum = $("theme-menu-sum");
if (sum) {
sum.title = `${colorMeta.name} · ${chromeMeta.name}`;
if (!sum.querySelector(".hamburger-icon")) {
sum.innerHTML = ``;
}
}
document.querySelectorAll("[data-color-theme].more-theme-item").forEach((btn) => {
btn.classList.toggle("is-active", btn.getAttribute("data-color-theme") === colorMeta.id);
});
document.querySelectorAll("[data-present-chrome].more-theme-item").forEach((btn) => {
btn.classList.toggle("is-active", btn.getAttribute("data-present-chrome") === chromeMeta.id);
});
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();
}
/** 正文已提交:立刻清空底栏,且后续重绘不要把刚发出去的字再塞回来。 */
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: ``,
trailing: `
`,
},
);
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);
return;
}
if (spec.mode === "idle") {
root.innerHTML = `${esc(spec.text)}
`;
return;
}
if (spec.mode === "intake" || spec.mode === "intake_ready") {
const intakePanel =
spec.showIntakePanel
? `${renderIntakePanel(spec.intake, { variant: "composer" })}
`
: "";
const confirm =
spec.mode === "intake_ready" && spec.confirmIntake
? ``
: "";
const shell = composerInputShell(spec, {
textareaHtml: ``,
trailing: ``,
});
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
? `
`
: "";
const chip = composerModeChip(spec);
root.innerHTML = `
${chip}
${reject}
`;
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 ``;
})
.join("");
const syncHint =
visible.some((f) => f.key === "target") && visible.some((f) => f.key === "rule_id")
? `改生成对象时,后台 id 也要改成对应英文(如丧尸怪物 → zombies)。
`
: "";
const planHint =
p.kind === "prior-artifact"
? `${
originalTarget
? "编排器已规划对象,步骤内可修订。"
: "具体写什么在步骤内确定。"
}
`
: "";
const closerHint = p.closer
? `这是收口步。若还没做 NPC 等前序节点,点「改意见」说明要补什么,会回到流程编排。
`
: "";
root.innerHTML = `
${p.mode === "revise" ? "回头修改" : "下一步"}
${esc(p.name)}
${esc(proposedOutputCopy(p))}
${planHint}
${closerHint}
${fields ? `
${fields}
` : ""}
${syncHint}
`;
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)
? ``
: "";
const enterPlay = canOfferEnterPlay(view)
? ``
: "";
const sendCls =
canOfferSaveProduct(view) || canOfferEnterPlay(view)
? "btn composer-btn"
: "btn btn-primary composer-btn";
const shell = composerInputShell(spec, {
textareaHtml: ``,
trailing: `${saveProduct}${enterPlay}`,
});
root.innerHTML = composerFormHtml(shell);
wireComposerForm({ requiresText: true });
root.querySelector("[data-act=save-product]")?.addEventListener("click", () => {
void saveCurrentInstance();
});
root.querySelector("[data-act=enter-play]")?.addEventListener("click", () => {
enterPlayNow();
});
applyPendingComposerDraft();
return;
}
const acceptOnEmpty = Boolean(spec.acceptAction);
const acceptTone = spec.acceptAction?.tone || "accept";
const acceptBtn = spec.acceptAction
? ``
: "";
const clearDictate =
view?.creationMode === "dictate" && !isPlayView(view)
? ``
: "";
const saveProduct = canOfferSaveProduct(view)
? ``
: "";
const enterPlay = canOfferEnterPlay(view)
? ``
: "";
const submitLabel = spec.submitLabel || "发送";
const sendIsPrimary = !acceptOnEmpty && !saveProduct && !enterPlay;
const placeholder = acceptOnEmpty
? `${spec.placeholder || "修改意见…"}(${spec.emptyEnterHint || "空 Enter=确认"})`
: spec.placeholder;
const shell = composerInputShell(spec, {
textareaHtml: ``,
trailing: `${clearDictate}${saveProduct}${enterPlay}${acceptBtn}`,
});
root.innerHTML = composerFormHtml(shell);
wireComposerForm({ acceptOnEmpty });
root.querySelector("[data-act=accept]")?.addEventListener("click", () => runAction("accept"));
root.querySelector("[data-act=clear-dictate]")?.addEventListener("click", () => {
void clearDictateDialogue();
});
root.querySelector("[data-act=save-product]")?.addEventListener("click", () => {
void saveCurrentInstance();
});
root.querySelector("[data-act=enter-play]")?.addEventListener("click", () => {
enterPlayNow();
});
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();
}
/** 无输入框的确认态: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;
btn.title = hasText
? "发送改编排意见"
: "写下意见后再发;点图上节点即确认并进入";
}
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);
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;
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 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", multi);
$("book-menu-play")?.toggleAttribute("hidden", multi || !ready);
$("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;
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" || sidebarNav.level === "product";
}
function getNavBook() {
if (!isWorkspaceNav() || !sidebarNav.bookId) return null;
return books.find((b) => b.id === sidebarNav.bookId) ?? null;
}
function getNavProduct() {
if (sidebarNav.level !== "product" || !sidebarNav.productId || !sidebarNav.bookId) return null;
return productInstancesForBook(sidebarNav.bookId).find((p) => p.id === sidebarNav.productId) ?? null;
}
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 product = getNavProduct();
const atProduct = sidebarNav.level === "product" && Boolean(book);
const inBook = Boolean(book) && (sidebarNav.level === "book" || atProduct);
$("rail-tabs")?.toggleAttribute("hidden", !isWorkspaceNav());
const list = document.createElement("ol");
list.className = "path-bar-list";
list.appendChild(
pathItem({
label: "目录",
title: "作品目录",
current: !inBook,
onClick: () => showCatalog(),
}),
);
if (inBook) {
const bookLabel = book.title?.trim() || "书本";
list.appendChild(pathSep());
list.appendChild(
pathItem({
label: bookLabel,
title: bookLabel,
current: !atProduct,
onClick: () => navigateToBook(book.id),
}),
);
}
if (atProduct) {
const productLabel = product?.label?.trim() || "定稿";
list.appendChild(pathSep());
list.appendChild(
pathItem({
label: productLabel,
title: productLabel,
current: true,
}),
);
}
bar.appendChild(list);
}
function navigateToBook(bookId) {
if (!bookId) return;
sidebarNav = { level: "book", bookId };
document.body.dataset.navLevel = "book";
bookSelectMode = false;
selectedBookIds = new Set();
setRailTab("books", { expand: true });
renderPathBar();
renderBookList();
void fetchPlaySavesForBook(bookId);
}
function navigateToProduct(bookId, productId) {
if (!bookId || !productId) return;
sidebarNav = { level: "product", bookId, productId };
document.body.dataset.navLevel = "product";
bookSelectMode = false;
selectedBookIds = new Set();
setRailTab("books", { expand: true });
renderPathBar();
renderBookList();
void fetchPlaySavesForBook(bookId);
}
function showCatalog() {
beginUiRequest();
stopLivePoll();
sidebarNav = { level: "root" };
document.body.dataset.navLevel = "root";
bookSelectMode = false;
selectedBookIds = new Set();
closeWorkspace();
renderPathBar();
renderBookList();
}
function renderExplorerRow({
name,
meta,
active,
selected,
bookId,
actionClass,
onClick,
onDelete,
onRename,
child,
twistie,
expanded,
onTwistie,
title,
}) {
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;
row.innerHTML = `
${twistie ? `${expanded ? "▾" : "▸"}` : ""}
${esc(name)}
${meta ? `${esc(meta)}` : ""}
${onDelete ? `×` : ""}`;
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 (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 renderProductContents(book, product, 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;
}
const playHere = book.id === activeBookId && isPlayNow();
const playingInstanceId = playingInstanceIdForBook(book);
const hasWorking = playingInstanceId === product.id;
const runs = runsForProduct(book.id, product.id, productInstancesForBook(book.id));
if (hasWorking) {
list.appendChild(
renderExplorerRow({
name: "当前游玩",
meta: playHere && !activePlaySaveId ? "进行中" : "工作副本",
active: playHere && !activePlaySaveId,
onClick: () => void openCurrentPlay(book.id),
}),
);
}
if (!runs.length && !hasWorking) {
const hint = document.createElement("p");
hint.className = "explorer-hint";
hint.textContent = "还没有游玩存档";
list.appendChild(hint);
}
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-file",
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, "游玩存档"),
}),
);
}
list.appendChild(
renderExplorerRow({
name: "+ 新开一局",
actionClass: "explorer-action",
onClick: () => void startNewPlayForBook(book.id, product.id),
}),
);
}
function renderBookContents(book, list) {
const designing = book.id === activeBookId && !isPlayNow();
list.appendChild(
renderExplorerRow({
name: "继续创作",
meta: designing ? "进行中" : "",
active: designing,
onClick: () => void openBookDesign(book.id),
}),
);
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;
}
const products = productInstancesForBook(book.id);
if (!products.length) {
const hint = document.createElement("p");
hint.className = "explorer-hint";
hint.textContent = bookPlayReady(book.id)
? "还没有定稿。可在更多菜单里保存定稿。"
: "验收后可保存定稿。";
list.appendChild(hint);
return;
}
const latestId = products[0]?.id;
const playingInstanceId = playingInstanceIdForBook(book);
const section = document.createElement("p");
section.className = "explorer-section-label";
section.textContent = "定稿";
list.appendChild(section);
for (const product of products) {
const bits = [];
if (product.id === latestId) bits.push("最新");
if (playingInstanceId === product.id) bits.push("游玩中");
list.appendChild(
renderExplorerRow({
name: product.label,
meta: bits.join(" · "),
title: "打开定稿,双击改名",
onClick: () => navigateToProduct(book.id, product.id),
onRename: () => void renamePlaySave(book.id, product.id, product.label),
onDelete: () => void deletePlaySave(book.id, product.id, product.label, "定稿"),
}),
);
}
}
function renderBookList() {
const list = $("book-list");
if (!list) return;
renderPathBar();
if (!books.length) {
list.innerHTML = ``;
$("btn-new-inline")?.addEventListener("click", openNewBookDialog);
return;
}
list.innerHTML = "";
if (sidebarNav.level === "product") {
const book = getNavBook();
if (!book) {
sidebarNav = { level: "root" };
document.body.dataset.navLevel = "root";
renderPathBar();
} else {
const product = getNavProduct();
const loading = playSavesLoading.has(book.id) && !playSavesByBook.has(book.id);
if (product) {
renderProductContents(book, product, list);
return;
}
if (loading) {
const hint = document.createElement("p");
hint.className = "explorer-hint";
hint.textContent = "加载存档…";
list.appendChild(hint);
return;
}
sidebarNav = { level: "book", bookId: book.id };
document.body.dataset.navLevel = "book";
renderPathBar();
renderBookContents(book, list);
return;
}
}
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 skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "实例设计";
const selected = selectedBookIds.has(book.id);
list.appendChild(
renderExplorerRow({
name: book.title,
meta: skill,
selected,
bookId: book.id,
actionClass: "explorer-folder",
onClick: () => {
if (bookSelectMode) {
toggleBookSelection(book.id);
return;
}
navigateToBook(book.id);
},
}),
);
}
}
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) : sidebarNav.productId;
if (instanceId) navigateToProduct(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;
if (sidebarNav.level === "product" && sidebarNav.productId === saveId) {
sidebarNav = { level: "book", bookId };
document.body.dataset.navLevel = "book";
}
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) navigateToProduct(bookId, instanceId);
else navigateToBook(bookId);
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 openBookDesign(bookId) {
navigateToBook(bookId);
activePlaySaveId = null;
if (activeBookId !== bookId || !sessionId) {
await openBook(bookId);
}
if (isPlayNow()) {
await setLifecycle("design");
return;
}
renderBookList();
}
async function openCurrentPlay(bookId) {
const instanceId = playingInstanceIdForBook({ id: bookId });
if (instanceId) navigateToProduct(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);
}
function closeWorkspace({ emptyCopy = "" } = {}) {
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 ? `${emptyCopy}
` : "";
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 = `暂无打开的作品
`;
}
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;
}
}
function beginUiRequest() {
return ++inflightSeq;
}
function isStaleUiRequest(seq) {
return seq !== inflightSeq;
}
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;
if (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;
if (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() {
stopLivePoll();
if (!sessionId) return;
livePollTimer = setInterval(async () => {
if (!sessionId) return;
try {
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}`);
lastView = { ...lastView, liveStream: view.liveStream, agentThinking: view.agentThinking };
updateLiveStreamPanel(view);
} catch {
/* ignore transient poll errors */
}
}, LIVE_POLL_MS);
}
function renderSession(view, loading = false) {
if (isCatalogNav()) return;
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),
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(),
});
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();
renderSession(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;
if (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 setLifecycle("play");
}
async function setLifecycle(stage) {
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 }),
});
if (stage === "design") activePlaySaveId = null;
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 = ``;
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 = ``;
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
? "Boss 直聘:对话写入产物;变量/映射用工具钉死;下方为直聘专用配方。"
: "Worker 式:按工序图逐步验收;下方为编排专用配方。";
}
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 = ``;
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;
navigateToBook(data.book.id);
renderSession(data.session, false);
} catch (err) {
alert(err.message);
} finally {
$("btn-create-book").disabled = false;
}
}
async function openBook(bookId) {
const seq = beginUiRequest();
activeBookId = bookId;
navigateToBook(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) navigateToProduct(activeBookId, newest.id);
} 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();
}
$("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-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();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
hideBookMenu();
if (bookSelectMode || selectedBookIds.size) setBookSelectMode(false);
}
});
document.addEventListener("scroll", hideBookMenu, true);
$("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 chromeBtn = e.target.closest("[data-present-chrome]");
if (chromeBtn && menu.contains(chromeBtn)) {
e.preventDefault();
applyPresentChrome(chromeBtn.getAttribute("data-present-chrome"));
syncThemeMenuUi();
menu.removeAttribute("open");
}
});
$("composer")?.addEventListener("click", (e) => {
const menu = e.target.closest("#composer-more-menu");
const mdBtn = e.target.closest("[data-toggle-markdown]");
if (!mdBtn || !menu?.contains(mdBtn)) return;
e.preventDefault();
applyMarkdownRender(!isMarkdownRenderEnabled());
syncMarkdownMenuUi();
if (lastView && sessionId) renderSession(lastView, false);
});
window.addEventListener("wa:session-updated", (e) => {
const view = e.detail;
if (!view?.id || isCatalogNav() || !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");
});
});
async function init() {
initColorTheme();
initPresentChrome();
initMarkdownRender();
syncThemeMenuUi();
try {
void populateDirectorSelect();
await loadBooks();
if (books.length) {
showCatalog();
} else {
renderEmpty();
openNewBookDialog();
}
} catch {
$("status-text").textContent = "加载失败";
}
}
init();
setupBookBoxSelect();