Keep creation as a living DAG and archive playable products separately.
Opening accept no longer locks the graph; session.json stays creation-only while play starts from an instance snapshot. Sidebar products nest run saves, and play reading fills the feed at a denser type size. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+65
-13
@@ -76,6 +76,34 @@ function isPlayView(view) {
|
||||
return Boolean(view?.playLayerActive || view?.lifecycleStage === "play");
|
||||
}
|
||||
|
||||
/** 开场已选定、创作收口,仍停在创作层等用户保存产物 */
|
||||
export function isCreationSealedWaiting(view) {
|
||||
if (!view || isPlayView(view)) return false;
|
||||
const msg = view.waitingReason?.message;
|
||||
return (
|
||||
view.waitingReason?.kind === "input" &&
|
||||
typeof msg === "string" &&
|
||||
/创作已收口|可保存为产物/.test(msg)
|
||||
);
|
||||
}
|
||||
|
||||
/** 收口节点已验收(图上「已完成」),即使还停在选节点 */
|
||||
export function isCreationCloserDone(view) {
|
||||
if (!view || isPlayView(view)) return false;
|
||||
const steps = view.creationFlowView?.steps;
|
||||
if (!Array.isArray(steps)) return false;
|
||||
return steps.some((s) => s?.closer && s.runState === "done");
|
||||
}
|
||||
|
||||
export function canOfferSaveProduct(view) {
|
||||
return isCreationSealedWaiting(view) || isCreationCloserDone(view);
|
||||
}
|
||||
|
||||
export function canOfferEnterPlay(view) {
|
||||
if (!view || isPlayView(view)) return false;
|
||||
return canOfferSaveProduct(view) || Boolean(view.hasProduct);
|
||||
}
|
||||
|
||||
function renderLiveStreamBody(live, view) {
|
||||
const thinking = (live?.thinking || view?.agentThinking || "").trim();
|
||||
const output = (live?.output ?? "").trim();
|
||||
@@ -848,16 +876,19 @@ export function renderLifecycle(view) {
|
||||
document.body.dataset.lifecycle = stage;
|
||||
const toggle = document.getElementById("lifecycle-toggle");
|
||||
if (!toggle) return;
|
||||
const ready = Boolean(view?.playReady || view?.hasProduct);
|
||||
toggle.hidden = !ready;
|
||||
toggle.querySelectorAll("[data-stage]").forEach((btn) => {
|
||||
const s = btn.getAttribute("data-stage");
|
||||
btn.classList.toggle("active", s === stage);
|
||||
if (s === "play") {
|
||||
btn.disabled = !view.playReady;
|
||||
btn.title = view.playReady
|
||||
? "进入游玩 / 写作"
|
||||
: "请先验收 Worker 集(创作定稿)后再切换";
|
||||
btn.disabled = !ready;
|
||||
btn.title = ready
|
||||
? "用已保存的产物开玩"
|
||||
: "请先保存产物后再开玩";
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.title = "回到创作";
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4312,7 +4343,12 @@ function renderSpeakWorkspace(view) {
|
||||
let hint = "在底栏输入你的想法。";
|
||||
let guideHtml = "";
|
||||
|
||||
if (wr?.kind === "pick_creation_step" || isFlowPlanReview(view)) {
|
||||
if (canOfferSaveProduct(view)) {
|
||||
title = "可以落档了";
|
||||
hint = view.hasProduct
|
||||
? "产物已拆出,可开玩;创作流程还在,补节点或再保存一份新定稿。"
|
||||
: "保存后创作流程仍在;拆出的产物可以单独开玩、存档。";
|
||||
} else if (wr?.kind === "pick_creation_step" || isFlowPlanReview(view)) {
|
||||
title = "选要做的节点";
|
||||
hint = "点可进入的节点即确认并开始;虚线原型点一下增殖再进去。要改排在底栏写意见再发。";
|
||||
} else if (showOpening) {
|
||||
@@ -4350,13 +4386,8 @@ function renderSpeakWorkspace(view) {
|
||||
? proposedOutputCopy(view.proposedNextStep)
|
||||
: view.focus?.detail || "确认执行,或在底栏说明意见。";
|
||||
} else if (wr?.kind === "input") {
|
||||
if (wr.message && /创作已收口/.test(wr.message)) {
|
||||
title = "创作已收口";
|
||||
hint = wr.message;
|
||||
} else {
|
||||
title = "继续说";
|
||||
hint = view.hints?.[0] || wr.message || "直接输入你的想法或补充。";
|
||||
}
|
||||
title = "继续说";
|
||||
hint = view.hints?.[0] || wr.message || "直接输入你的想法或补充。";
|
||||
} else if (view.uiPrompt && !hasUserMessages(view)) {
|
||||
title = "描述你想创作什么";
|
||||
hint = String(view.uiPrompt).trim().slice(0, 280);
|
||||
@@ -4377,9 +4408,23 @@ function renderSpeakWorkspace(view) {
|
||||
interactive: flowInteractive,
|
||||
})
|
||||
: "";
|
||||
const enterPlay = canOfferEnterPlay(view)
|
||||
? `<div class="workspace-intent-actions">
|
||||
${
|
||||
canOfferSaveProduct(view)
|
||||
? `<button type="button" class="btn btn-primary" data-save-product>保存定稿</button>`
|
||||
: ""
|
||||
}
|
||||
<button type="button" class="btn${
|
||||
canOfferSaveProduct(view) ? "" : " btn-primary"
|
||||
}" data-enter-play>${view.hasProduct ? "用产物开玩" : "开玩"}</button>
|
||||
</div>`
|
||||
: "";
|
||||
return `<section class="workspace-intent">
|
||||
<div class="workspace-intent-head">
|
||||
<span class="workspace-surface-kicker">${flowInteractive ? "流程" : "说话"}</span>
|
||||
<span class="workspace-surface-kicker">${
|
||||
canOfferSaveProduct(view) ? "落档" : flowInteractive ? "流程" : "说话"
|
||||
}</span>
|
||||
${leaveStepControlHtml(view)}
|
||||
</div>
|
||||
<h2 class="workspace-intent-title">${esc(title)}</h2>
|
||||
@@ -4388,6 +4433,7 @@ function renderSpeakWorkspace(view) {
|
||||
${guideHtml}
|
||||
${intake}
|
||||
${flowHtml}
|
||||
${enterPlay}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
@@ -4586,6 +4632,12 @@ export function renderMessageFeed(view, loading, handlers = {}) {
|
||||
stage.innerHTML = renderSpeakWorkspace(view);
|
||||
wireCreationFlowGraph(stage, handlers);
|
||||
wireLeaveStepControl(stage, handlers);
|
||||
stage.querySelector("[data-enter-play]")?.addEventListener("click", () => {
|
||||
handlers.onEnterPlay?.();
|
||||
});
|
||||
stage.querySelector("[data-save-product]")?.addEventListener("click", () => {
|
||||
handlers.onSaveProduct?.();
|
||||
});
|
||||
if (panelFeed) ensureQuestionsHostIn(panelFeed);
|
||||
return;
|
||||
}
|
||||
|
||||
+256
-60
@@ -1,4 +1,11 @@
|
||||
import { renderWorkspace, updateLiveStreamPanel, resetRailChrome, fillStepArtifactDialog } from "./agent-ui.js";
|
||||
import {
|
||||
renderWorkspace,
|
||||
updateLiveStreamPanel,
|
||||
resetRailChrome,
|
||||
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";
|
||||
@@ -24,6 +31,8 @@ let bookSelectMode = false;
|
||||
let selectedBookIds = new Set();
|
||||
let activePlaySaveId = null;
|
||||
const playSavesByBook = new Map();
|
||||
const playWorkingInstanceByBook = new Map();
|
||||
const expandedProductsByBook = new Map();
|
||||
const playSavesLoading = new Set();
|
||||
let bookMenuBookId = null;
|
||||
let livePollTimer = null;
|
||||
@@ -99,6 +108,17 @@ function resolveUserTask(view, loading) {
|
||||
};
|
||||
}
|
||||
|
||||
if (canOfferSaveProduct(view)) {
|
||||
return {
|
||||
id: "speak",
|
||||
label: "可保存",
|
||||
title: "落档产物",
|
||||
hint: view.hasProduct
|
||||
? "可再保存一份新定稿,或用已有产物开玩。补节点仍点图。"
|
||||
: "点「保存定稿」拆出产物。创作流程还在,之后用产物开玩。",
|
||||
};
|
||||
}
|
||||
|
||||
if (isFlowPlanReview(view) || wr?.kind === "pick_creation_step") {
|
||||
return {
|
||||
id: "speak",
|
||||
@@ -758,12 +778,30 @@ function renderComposer(view, loading) {
|
||||
}
|
||||
|
||||
if (spec.mode === "pick_step") {
|
||||
const saveProduct = canOfferSaveProduct(view)
|
||||
? `<button type="button" class="btn btn-primary" data-act="save-product">保存定稿</button>`
|
||||
: "";
|
||||
const enterPlay = canOfferEnterPlay(view)
|
||||
? `<button type="button" class="btn" data-act="enter-play">${
|
||||
view.hasProduct ? "用产物开玩" : "开玩"
|
||||
}</button>`
|
||||
: "";
|
||||
const sendCls =
|
||||
canOfferSaveProduct(view) || canOfferEnterPlay(view)
|
||||
? "btn composer-btn"
|
||||
: "btn btn-primary composer-btn";
|
||||
const shell = composerInputShell(spec, {
|
||||
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(spec.placeholder)}"></textarea>`,
|
||||
trailing: `<button type="submit" class="btn btn-primary composer-btn" data-act="pick-replan" disabled title="写下意见后再发;点图上节点即确认并进入">${esc(spec.submitLabel || "发送")}</button>`,
|
||||
trailing: `${saveProduct}${enterPlay}<button type="submit" class="${sendCls}" data-act="pick-replan" disabled title="写下意见后再发;点图上节点即确认并进入">${esc(spec.submitLabel || "发送")}</button>`,
|
||||
});
|
||||
root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`;
|
||||
wireComposerForm({ requiresText: true });
|
||||
root.querySelector("[data-act=save-product]")?.addEventListener("click", () => {
|
||||
void saveCurrentInstance();
|
||||
});
|
||||
root.querySelector("[data-act=enter-play]")?.addEventListener("click", () => {
|
||||
enterPlayNow();
|
||||
});
|
||||
applyPendingComposerDraft();
|
||||
return;
|
||||
}
|
||||
@@ -956,8 +994,49 @@ 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 isProductExpanded(bookId, instanceId, fallback) {
|
||||
const set = expandedProductsByBook.get(bookId);
|
||||
if (set) return set.has(instanceId);
|
||||
return Boolean(fallback);
|
||||
}
|
||||
|
||||
function toggleProductExpanded(bookId, instanceId) {
|
||||
const open = isProductExpanded(bookId, instanceId, false);
|
||||
const next = new Set();
|
||||
if (!open) next.add(instanceId);
|
||||
expandedProductsByBook.set(bookId, next);
|
||||
renderBookList();
|
||||
}
|
||||
|
||||
function bookPlayReady(bookId) {
|
||||
if (lastView?.bookId === bookId && Boolean(lastView?.playReady)) return true;
|
||||
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");
|
||||
@@ -980,8 +1059,10 @@ async function fetchPlaySavesForBook(bookId, force = false) {
|
||||
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();
|
||||
@@ -1107,10 +1188,12 @@ function renderExplorerRow({
|
||||
actionClass,
|
||||
onClick,
|
||||
onDelete,
|
||||
onRename,
|
||||
child,
|
||||
twistie,
|
||||
expanded,
|
||||
onTwistie,
|
||||
title,
|
||||
}) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
@@ -1125,6 +1208,7 @@ function renderExplorerRow({
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (bookId) row.dataset.bookId = bookId;
|
||||
if (title) row.title = title;
|
||||
row.innerHTML = `
|
||||
${twistie ? `<span class="explorer-twist" data-twist>${expanded ? "▾" : "▸"}</span>` : ""}
|
||||
<span class="explorer-main">
|
||||
@@ -1136,10 +1220,17 @@ function renderExplorerRow({
|
||||
if (e.target.closest(".explorer-del") || e.target.closest("[data-twist]")) return;
|
||||
onClick?.(e);
|
||||
});
|
||||
row.querySelector("[data-twist]")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onTwistie?.();
|
||||
});
|
||||
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?.();
|
||||
@@ -1157,11 +1248,16 @@ function renderExplorerRow({
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderBookBranch(book, list, { nested = false } = {}) {
|
||||
function renderBookBranch(book, list) {
|
||||
const loading = playSavesLoading.has(book.id);
|
||||
const saves = playSavesByBook.get(book.id);
|
||||
const ready = bookPlayReady(book.id);
|
||||
const products = productInstancesForBook(book.id);
|
||||
const playHere = book.id === activeBookId && isPlayNow();
|
||||
const playingInstanceId =
|
||||
playHere && lastView?.playInstanceId
|
||||
? lastView.playInstanceId
|
||||
: playWorkingInstanceByBook.get(book.id);
|
||||
const latestId = products[0]?.id;
|
||||
|
||||
if (loading && saves === undefined) {
|
||||
const hint = document.createElement("p");
|
||||
@@ -1171,56 +1267,96 @@ function renderBookBranch(book, list, { nested = false } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ready) {
|
||||
list.appendChild(
|
||||
renderExplorerRow({
|
||||
name: "当前",
|
||||
meta: playHere && !activePlaySaveId ? "进行中" : "工作副本",
|
||||
active: playHere && !activePlaySaveId,
|
||||
child: nested,
|
||||
onClick: () => void openCurrentPlay(book.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const product of products) {
|
||||
const isLatest = product.id === latestId;
|
||||
const isPlayingHere = playHere && playingInstanceId === product.id;
|
||||
const hasWorking = playingInstanceId === product.id;
|
||||
const expanded = isProductExpanded(book.id, product.id, isPlayingHere);
|
||||
const runs = runsForProduct(book.id, product.id, products);
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `explorer-product${expanded ? " is-open" : ""}`;
|
||||
|
||||
for (const s of playRunsForBook(book.id)) {
|
||||
const when = formatSaveWhen(s.createdAt);
|
||||
list.appendChild(
|
||||
renderExplorerRow({
|
||||
name: s.label,
|
||||
meta: when ? `游玩 · ${when}` : "游玩进度",
|
||||
active: playHere && activePlaySaveId === s.id,
|
||||
child: nested,
|
||||
actionClass: "explorer-file",
|
||||
onClick: () => void loadPlaySave(book.id, s.id),
|
||||
onDelete: () => void deletePlaySave(book.id, s.id, s.label),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const header = renderExplorerRow({
|
||||
name: product.label,
|
||||
meta: isLatest ? "最新" : "",
|
||||
twistie: true,
|
||||
expanded,
|
||||
title: "点开游玩存档,双击改名",
|
||||
onTwistie: () => toggleProductExpanded(book.id, product.id),
|
||||
onClick: () => toggleProductExpanded(book.id, product.id),
|
||||
onRename: () => void renamePlaySave(book.id, product.id, product.label),
|
||||
onDelete: () => void deletePlaySave(book.id, product.id, product.label, "落档"),
|
||||
});
|
||||
header.setAttribute("aria-expanded", expanded ? "true" : "false");
|
||||
wrap.appendChild(header);
|
||||
|
||||
if (ready) {
|
||||
list.appendChild(
|
||||
renderExplorerRow({
|
||||
name: "+ 新开一局",
|
||||
actionClass: "explorer-action",
|
||||
child: nested,
|
||||
onClick: () => void startNewPlayForBook(book.id),
|
||||
}),
|
||||
);
|
||||
if (expanded) {
|
||||
const drop = document.createElement("div");
|
||||
drop.className = "explorer-drop";
|
||||
drop.setAttribute("role", "group");
|
||||
drop.setAttribute("aria-label", `${product.label} 的游玩存档`);
|
||||
|
||||
if (hasWorking) {
|
||||
drop.appendChild(
|
||||
renderExplorerRow({
|
||||
name: "当前游玩",
|
||||
meta: isPlayingHere && !activePlaySaveId ? "进行中" : "工作副本",
|
||||
active: isPlayingHere && !activePlaySaveId,
|
||||
child: true,
|
||||
onClick: () => void openCurrentPlay(book.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!runs.length && !hasWorking) {
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "explorer-drop-empty";
|
||||
hint.textContent = "还没有游玩存档";
|
||||
drop.appendChild(hint);
|
||||
}
|
||||
|
||||
for (const run of runs) {
|
||||
const runWhen = formatSaveWhen(run.createdAt);
|
||||
drop.appendChild(
|
||||
renderExplorerRow({
|
||||
name: run.label,
|
||||
meta: runWhen || "游玩进度",
|
||||
active: playHere && activePlaySaveId === run.id,
|
||||
child: true,
|
||||
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, "游玩存档"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
drop.appendChild(
|
||||
renderExplorerRow({
|
||||
name: "+ 新开一局",
|
||||
actionClass: "explorer-action",
|
||||
child: true,
|
||||
onClick: () => void startNewPlayForBook(book.id, product.id),
|
||||
}),
|
||||
);
|
||||
wrap.appendChild(drop);
|
||||
}
|
||||
|
||||
list.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBookContents(book, list) {
|
||||
const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "实例设计";
|
||||
const designing = book.id === activeBookId && !isPlayNow();
|
||||
list.appendChild(
|
||||
renderExplorerRow({
|
||||
name: "创作",
|
||||
meta: skill,
|
||||
active: book.id === activeBookId && !isPlayNow(),
|
||||
name: "继续创作",
|
||||
meta: designing ? "进行中" : "",
|
||||
active: designing,
|
||||
onClick: () => void openBookDesign(book.id),
|
||||
}),
|
||||
);
|
||||
renderBookBranch(book, list, { nested: false });
|
||||
renderBookBranch(book, list);
|
||||
}
|
||||
|
||||
function renderBookList() {
|
||||
@@ -1439,8 +1575,25 @@ async function loadPlaySave(bookId, saveId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlaySave(bookId, saveId, label) {
|
||||
if (!confirm(`删除游玩存档「${label}」?`)) return;
|
||||
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)}`,
|
||||
@@ -1453,17 +1606,25 @@ async function deletePlaySave(bookId, saveId, label) {
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewPlayForBook(bookId) {
|
||||
if (!bookPlayReady(bookId)) {
|
||||
alert("须先验收 Worker 集");
|
||||
async function startNewPlayForBook(bookId, instanceId) {
|
||||
const thisView = lastView?.bookId === bookId ? lastView : null;
|
||||
if (!bookPlayReady(bookId) && !thisView?.hasProduct && !canOfferEnterPlay(thisView)) {
|
||||
alert("请先保存产物,或完成收口后再开玩");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
navigateToBook(bookId);
|
||||
if (lastView?.bookId === bookId) renderSession(lastView, true);
|
||||
const data = await api(`/api/books/${encodeURIComponent(bookId)}/play/new`, { method: "POST" });
|
||||
const data = await api(`/api/books/${encodeURIComponent(bookId)}/play/new`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(instanceId ? { instanceId } : {}),
|
||||
});
|
||||
activePlaySaveId = null;
|
||||
if (instanceId) {
|
||||
expandedProductsByBook.set(bookId, new Set([instanceId]));
|
||||
}
|
||||
renderSession(data.session, false);
|
||||
void fetchPlaySavesForBook(bookId, true);
|
||||
} catch (err) {
|
||||
if (lastView) renderSession(lastView, false);
|
||||
alert(err.message);
|
||||
@@ -1549,6 +1710,8 @@ function closeWorkspace({ emptyCopy = "" } = {}) {
|
||||
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) {
|
||||
@@ -1683,6 +1846,8 @@ function renderSession(view, loading = false) {
|
||||
onViewStepArtifact: (stepId, name) => showStepArtifactDialog(stepId, name),
|
||||
onSpawnCreationStep: (moduleName) => runAction("spawn_step", { moduleName }),
|
||||
onLeaveCreationStep: () => runAction("leave_step"),
|
||||
onEnterPlay: () => enterPlayNow(),
|
||||
onSaveProduct: () => void saveCurrentInstance(),
|
||||
});
|
||||
renderComposer(view, loading);
|
||||
if (loading) startLivePoll();
|
||||
@@ -1930,6 +2095,14 @@ async function runAction(action, extra = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function enterPlayNow() {
|
||||
if (activeBookId) {
|
||||
void openCurrentPlay(activeBookId);
|
||||
return;
|
||||
}
|
||||
void setLifecycle("play");
|
||||
}
|
||||
|
||||
async function setLifecycle(stage) {
|
||||
if (!sessionId) return;
|
||||
if (stage === lastView?.lifecycleStage) {
|
||||
@@ -1942,8 +2115,8 @@ async function setLifecycle(stage) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (stage === "play" && !lastView?.playReady) {
|
||||
alert("请先验收 Worker 集(创作定稿)后再进入游玩");
|
||||
if (stage === "play" && !lastView?.playReady && !canOfferEnterPlay(lastView) && !lastView?.hasProduct) {
|
||||
alert("请先保存产物,或完成收口后再开玩");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -1953,6 +2126,7 @@ async function setLifecycle(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);
|
||||
@@ -2109,17 +2283,34 @@ async function saveCurrentPlay() {
|
||||
}
|
||||
}
|
||||
|
||||
function defaultArchiveLabel() {
|
||||
return (
|
||||
formatSaveWhen(new Date().toISOString()) ||
|
||||
new Date().toLocaleString("zh-CN")
|
||||
);
|
||||
}
|
||||
|
||||
async function saveCurrentInstance() {
|
||||
if (!activeBookId || !lastView?.playReady) return alert("须先验收 Worker 集");
|
||||
const label = prompt("创作定稿存档名称");
|
||||
if (!activeBookId || (!lastView?.playReady && !canOfferSaveProduct(lastView))) {
|
||||
return alert("须先完成收口或验收 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" }),
|
||||
});
|
||||
playSavesByBook.delete(activeBookId);
|
||||
void fetchPlaySavesForBook(activeBookId, true);
|
||||
if (lastView) {
|
||||
lastView = { ...lastView, hasProduct: true };
|
||||
renderSession(lastView, false);
|
||||
}
|
||||
await fetchPlaySavesForBook(activeBookId, true);
|
||||
const newest = productInstancesForBook(activeBookId)[0];
|
||||
if (newest) {
|
||||
expandedProductsByBook.set(activeBookId, new Set([newest.id]));
|
||||
renderBookList();
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
@@ -2223,6 +2414,11 @@ function exportSession() {
|
||||
downloadMarkdown(`${name}.md`, sessionToMarkdown(lastView));
|
||||
}
|
||||
|
||||
$("lifecycle-toggle")?.addEventListener("click", (e) => {
|
||||
const stage = e.target.closest("[data-stage]")?.getAttribute("data-stage");
|
||||
if (stage === "play") enterPlayNow();
|
||||
else if (stage === "design") void setLifecycle("design");
|
||||
});
|
||||
$("btn-new-book")?.addEventListener("click", openNewBookDialog);
|
||||
$("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close());
|
||||
$("book-action-menu")?.addEventListener("click", (e) => {
|
||||
|
||||
+5
-1
@@ -80,6 +80,10 @@
|
||||
|
||||
<footer class="composer" id="composer">
|
||||
<span class="status" id="status-pill"><span class="status-dot" id="status-dot"></span><span id="status-text">—</span></span>
|
||||
<div class="lifecycle-toggle" id="lifecycle-toggle" hidden role="group" aria-label="创作与游玩">
|
||||
<button type="button" class="lifecycle-btn" data-stage="design">创作</button>
|
||||
<button type="button" class="lifecycle-btn" data-stage="play">游玩</button>
|
||||
</div>
|
||||
<div id="composer-main"></div>
|
||||
<details class="more-menu">
|
||||
<summary class="btn-sm more-menu-sum" title="更多">更多</summary>
|
||||
@@ -121,7 +125,7 @@
|
||||
|
||||
<menu id="book-action-menu" class="context-menu" hidden>
|
||||
<button type="button" class="context-menu-item" id="book-menu-open" data-action="open">打开创作</button>
|
||||
<button type="button" class="context-menu-item" id="book-menu-play" data-action="play">进入游玩</button>
|
||||
<button type="button" class="context-menu-item" id="book-menu-play" data-action="play">用产物开玩</button>
|
||||
<button type="button" class="context-menu-item" id="book-menu-save-instance" data-action="save-instance">保存定稿</button>
|
||||
<button type="button" class="context-menu-item" id="book-menu-save-play" data-action="save-play">保存游玩</button>
|
||||
<button type="button" class="context-menu-item" id="book-menu-export" data-action="export">导出</button>
|
||||
|
||||
+23
-17
@@ -3,7 +3,7 @@
|
||||
* 契约:docs/play-presentation-shells.md · present.v1
|
||||
*
|
||||
* 信息层级约定(所有壳共用):
|
||||
* P0 body — 最大字号与面积,阅读主轴
|
||||
* P0 body — 阅读主轴:字号大于辅助栏,但不做成讲稿大字
|
||||
* P1 actions — 需要决策时醒目,但不压过正文
|
||||
* P2 monitor — 芯片密排,一眼扫完
|
||||
* P3 header / footer / aside — 辅助,紧凑
|
||||
@@ -66,20 +66,20 @@ export function shellDefaultRegions(shell) {
|
||||
}
|
||||
|
||||
function formatBlockContent(value) {
|
||||
let text = "";
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
if (typeof value === "string") text = value;
|
||||
else if (Array.isArray(value)) {
|
||||
text = value
|
||||
.map((x) => (typeof x === "string" ? x : JSON.stringify(x)))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return Object.entries(value)
|
||||
} else if (typeof value === "object") {
|
||||
text = Object.entries(value)
|
||||
.map(([k, v]) => `${k}:${typeof v === "string" ? v : JSON.stringify(v)}`)
|
||||
.join("\n");
|
||||
}
|
||||
return String(value);
|
||||
} else text = String(value);
|
||||
return text.replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
/** 模型常把 tag 名写进正文开头;展示前剥掉 */
|
||||
@@ -132,7 +132,11 @@ function regionInnerHtml(region, rawValue, esc) {
|
||||
if (chips) return chips;
|
||||
}
|
||||
const text = formatBlockContent(rawValue).trim();
|
||||
return `<div class="present-region-body">${esc(text).replace(/\n/g, "<br>")}</div>`;
|
||||
const paras = text.split(/\n\n+/).filter(Boolean);
|
||||
const inner = paras
|
||||
.map((p) => `<p>${esc(p).replace(/\n/g, "<br>")}</p>`)
|
||||
.join("");
|
||||
return `<div class="present-region-body">${inner}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,26 +304,28 @@ export function renderPresentShellHtml(packet, esc, opts = {}) {
|
||||
${actionsHtml}
|
||||
</div>`;
|
||||
break;
|
||||
case "turn_panel":
|
||||
case "turn_panel": {
|
||||
const boardSide = `${by.aside || ""}${by.footer || ""}`;
|
||||
layoutHtml = `
|
||||
<div class="present-board">
|
||||
<div class="present-board-top">${by.monitor || ""}${by.header || ""}</div>
|
||||
<div class="present-board-mid">
|
||||
<div class="present-board-mid${boardSide ? "" : " is-solo"}">
|
||||
<div class="present-stage">${by.body || ""}</div>
|
||||
<div class="present-board-side">${by.aside || ""}${by.footer || ""}</div>
|
||||
${boardSide ? `<div class="present-board-side">${boardSide}</div>` : ""}
|
||||
</div>
|
||||
${actionsHtml}
|
||||
</div>`;
|
||||
break;
|
||||
}
|
||||
case "split_board":
|
||||
layoutHtml = `
|
||||
<div class="present-split">
|
||||
<div class="present-split${by.aside ? "" : " is-solo"}">
|
||||
<div class="present-split-main">
|
||||
${by.header || ""}
|
||||
<div class="present-stage">${by.body || ""}</div>
|
||||
${by.footer || ""}
|
||||
</div>
|
||||
<div class="present-split-side">${by.aside || ""}</div>
|
||||
${by.aside ? `<div class="present-split-side">${by.aside}</div>` : ""}
|
||||
${actionsHtml}
|
||||
</div>`;
|
||||
break;
|
||||
@@ -334,13 +340,13 @@ export function renderPresentShellHtml(packet, esc, opts = {}) {
|
||||
break;
|
||||
case "chapter_reader":
|
||||
layoutHtml = `
|
||||
<div class="present-reader">
|
||||
<div class="present-reader${by.aside ? "" : " is-solo"}">
|
||||
<div class="present-reader-main">
|
||||
${by.header || ""}
|
||||
<div class="present-stage present-stage--read">${by.body || ""}</div>
|
||||
${by.footer || ""}
|
||||
</div>
|
||||
<div class="present-reader-aside">${by.aside || ""}</div>
|
||||
${by.aside ? `<div class="present-reader-aside">${by.aside}</div>` : ""}
|
||||
</div>`;
|
||||
break;
|
||||
case "prose":
|
||||
|
||||
+117
-18
@@ -849,6 +849,13 @@ body.design-workspace[data-design-surface="answer"] .workspace-answer {
|
||||
color: var(--muted);
|
||||
max-width: 46rem;
|
||||
}
|
||||
.workspace-intent-actions {
|
||||
margin-top: 22px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.workspace-intent-meta {
|
||||
margin: 14px 0 0;
|
||||
font-size: 12px;
|
||||
@@ -1666,6 +1673,10 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
|
||||
}
|
||||
.explorer-row.child .explorer-name { font-size: 12px; font-weight: 400; }
|
||||
.explorer-row:hover { background: var(--bg); }
|
||||
.explorer-row:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.explorer-row.active { background: var(--accent-soft); }
|
||||
.explorer-row.active .explorer-name { color: var(--accent); font-weight: 600; }
|
||||
.explorer-row.selected { background: #dbeafe; }
|
||||
@@ -1728,15 +1739,59 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
|
||||
.explorer-hint {
|
||||
margin: 4px 8px 4px 28px; font-size: 11px; color: var(--muted); line-height: 1.4;
|
||||
}
|
||||
.explorer-product {
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
.explorer-product.is-open {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.explorer-product.is-open > .explorer-row {
|
||||
border-radius: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
.explorer-product.is-open > .explorer-row:hover {
|
||||
background: #ececee;
|
||||
}
|
||||
.explorer-drop {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 4px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.explorer-drop .explorer-row.child {
|
||||
padding-left: 12px;
|
||||
}
|
||||
.explorer-drop-empty {
|
||||
margin: 4px 8px 8px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--muted);
|
||||
}
|
||||
.sidebar-empty { padding: 12px; color: var(--muted); font-size: 13px; text-align: center; }
|
||||
|
||||
/* Lifecycle */
|
||||
.lifecycle-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; padding: 2px; }
|
||||
.lifecycle-toggle {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 2px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.lifecycle-toggle[hidden] { display: none; }
|
||||
.lifecycle-btn {
|
||||
border: none; background: transparent; padding: 4px 14px; border-radius: 6px;
|
||||
font-size: 13px; cursor: pointer; color: var(--muted);
|
||||
border: none; background: transparent; padding: 4px 10px; border-radius: 6px;
|
||||
font-size: 12px; cursor: pointer; color: var(--muted); font-weight: 500;
|
||||
}
|
||||
.lifecycle-btn.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
.lifecycle-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.lifecycle-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
body[data-lifecycle="play"] { --accent: #16a34a; --accent-soft: #dcfce7; --bg: #f0fdf4; }
|
||||
body[data-lifecycle="play"] .panel-rail { background: #f7fef9; }
|
||||
|
||||
@@ -2227,10 +2282,10 @@ body.rail-collapsed .message-feed {
|
||||
.msg-q-list li { margin: 0.35rem 0; }
|
||||
.msg-q-lead { margin: 0; line-height: 1.55; font-size: 14px; }
|
||||
|
||||
/* 游玩:每轮终稿仍是气泡(方便扫上下文),正文在气泡里撑高 */
|
||||
/* 游玩:终稿铺满阅读区;字号跟正文阅读走,不跟创作验收的大字稿走 */
|
||||
body[data-lifecycle="play"] .message-feed {
|
||||
padding: 16px 18px 22px;
|
||||
gap: 16px;
|
||||
padding: 10px 12px 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
body[data-lifecycle="play"] .msg-assistant-row .msg-bubble {
|
||||
width: min(100%, 44rem);
|
||||
@@ -2246,7 +2301,12 @@ body[data-lifecycle="play"] .msg-user-row .msg-bubble {
|
||||
max-width: 90%;
|
||||
}
|
||||
body[data-lifecycle="play"] .msg-play-final .msg-bubble {
|
||||
width: min(100%, 46rem);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
body[data-lifecycle="play"] .msg-play-final .msg-body {
|
||||
padding: 6px 14px 12px;
|
||||
white-space: normal;
|
||||
}
|
||||
body[data-lifecycle="play"] .msg-play-final .present-shell {
|
||||
border: none;
|
||||
@@ -2258,14 +2318,25 @@ body[data-lifecycle="play"] .msg-play-final .present-shell {
|
||||
body[data-lifecycle="play"] .msg-play-final .present-shell-badge {
|
||||
display: none;
|
||||
}
|
||||
body[data-lifecycle="play"] .msg-play-final .present-stack,
|
||||
body[data-lifecycle="play"] .msg-play-final .present-board,
|
||||
body[data-lifecycle="play"] .msg-play-final .present-split,
|
||||
body[data-lifecycle="play"] .msg-play-final .present-reader {
|
||||
gap: 10px;
|
||||
}
|
||||
body[data-lifecycle="play"] .msg-play-final .present-stage .present-region--body .present-region-body,
|
||||
body[data-lifecycle="play"] .msg-play-final .present-stage.present-region--body .present-region-body {
|
||||
font-size: 18.5px;
|
||||
line-height: 1.88;
|
||||
body[data-lifecycle="play"] .msg-play-final .present-stage.present-region--body .present-region-body,
|
||||
body[data-lifecycle="play"] .msg-play-final .present-stage--read .present-region-body {
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
body[data-lifecycle="play"] .msg-play-final .present-tone--book .present-stage--read .present-region-body,
|
||||
body[data-lifecycle="play"] .msg-play-final .present-tone--book .present-stage .present-region-body {
|
||||
max-width: none;
|
||||
}
|
||||
body[data-lifecycle="play"] .msg-play-final .present-shell--chat_monitor .present-stage {
|
||||
padding: 0;
|
||||
border: none;
|
||||
@@ -4692,15 +4763,16 @@ body.design-workspace .af-mosaic {
|
||||
* 视觉:少盒子、少渐变;层级靠字号/间距/对比,不靠装饰抢注意力。
|
||||
*/
|
||||
.present-shell {
|
||||
white-space: normal;
|
||||
--present-ink: #1c1917;
|
||||
--present-muted: #78716c;
|
||||
--present-line: #e7e5e4;
|
||||
--present-soft: #f5f5f4;
|
||||
--present-accent: #0f766e;
|
||||
--present-body: 17.5px;
|
||||
--present-body-lh: 1.78;
|
||||
--present-body-hero: 19px;
|
||||
--present-body-read: 18.5px;
|
||||
--present-body: 15px;
|
||||
--present-body-lh: 1.62;
|
||||
--present-body-hero: 16.5px;
|
||||
--present-body-read: 15.5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
@@ -4749,9 +4821,15 @@ body.design-workspace .af-mosaic {
|
||||
.present-region-body {
|
||||
margin: 0;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
white-space: normal;
|
||||
font-size: 14px;
|
||||
}
|
||||
.present-region-body > p {
|
||||
margin: 0 0 0.7em;
|
||||
}
|
||||
.present-region-body > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* P0:正文舞台 —— 随字数伸展(500~2000 靠外层滚动,不塞进小框) */
|
||||
.present-stage {
|
||||
flex: 1 1 auto;
|
||||
@@ -4778,8 +4856,8 @@ body.design-workspace .af-mosaic {
|
||||
.present-stage--read .present-region-body {
|
||||
font-family: var(--font-read);
|
||||
font-size: var(--present-body-read);
|
||||
line-height: 1.9;
|
||||
max-width: 42rem;
|
||||
line-height: 1.7;
|
||||
max-width: none;
|
||||
}
|
||||
/* 双栏:侧栏钉住,正文再长也能对照 */
|
||||
.present-board-side,
|
||||
@@ -4893,6 +4971,13 @@ body.design-workspace .af-mosaic {
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.present-board-mid.is-solo,
|
||||
.present-board-mid:not(:has(.present-board-side)) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.present-board-side:empty {
|
||||
display: none;
|
||||
}
|
||||
.present-board-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -4912,6 +4997,13 @@ body.design-workspace .af-mosaic {
|
||||
gap: 16px 20px;
|
||||
align-items: start;
|
||||
}
|
||||
.present-split.is-solo,
|
||||
.present-split:not(:has(.present-split-side)) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.present-split-side:empty {
|
||||
display: none;
|
||||
}
|
||||
.present-split-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -4963,6 +5055,13 @@ body.design-workspace .af-mosaic {
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
.present-reader.is-solo,
|
||||
.present-reader:not(:has(.present-reader-aside)) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.present-reader-aside:empty {
|
||||
display: none;
|
||||
}
|
||||
.present-reader-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -5015,7 +5114,7 @@ body.design-workspace .af-mosaic {
|
||||
.present-tone--book .present-stage--read .present-region-body,
|
||||
.present-tone--book .present-stage .present-region-body {
|
||||
font-family: var(--font-read);
|
||||
max-width: 38rem;
|
||||
max-width: none;
|
||||
}
|
||||
.present-tone--terminal {
|
||||
--present-ink: #d6d3d1;
|
||||
|
||||
Reference in New Issue
Block a user