接通主角绑定、开场封印与游玩辅助,让世界模拟器从创作连续落到本局。

直聘插入按模块回馈,并记录请求骨架便于排查。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 07:40:08 +08:00
co-authored by Cursor
parent 9306c8e659
commit dd0a9e87d8
88 changed files with 6869 additions and 786 deletions
+642 -84
View File
@@ -1,5 +1,6 @@
import { renderIntakePanel } from "./intake-ui.js";
import { tryParseJsonDoc } from "./json-doc.js";
import { formatBodyHtml } from "./markdown.js";
import {
getActiveQuestions,
getModuleOpeningPrompt,
@@ -7,8 +8,8 @@ import {
isQuestionCardDismissed,
renderQuestionsCard,
} from "./questions-ui.js";
import { displayWorkerLabel, formatWorkerDisplayTitle, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js";
import { formatBodyHtml } from "./markdown.js";
import { displayCreationModeLabel, displayWorkerLabel, formatWorkerDisplayTitle, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js";
import { fillPlayAux, hidePlayAuxTabs } from "./play-aux.js";
import {
PRESENT_SHELL_IDS,
parsePresentDoc,
@@ -47,7 +48,7 @@ const MSG_LABEL = {
orchestrator_thinking: "编排器 · 思考",
orchestrator_prompt: "编排器",
orchestrator_assessment: "编排器 · 内容评价",
dictate_reply: "Boss直聘",
dictate_reply: "对话落盘",
worker_running: "执行单元",
worker_output: "执行单元",
worker_questions: "提问",
@@ -62,6 +63,16 @@ function esc(s) {
.replace(/>/g, "&gt;");
}
/** 正文 HTML;缺导入或渲染失败时退回纯文本,避免整页对话空白 */
function safeBodyHtml(text) {
try {
if (typeof formatBodyHtml === "function") return formatBodyHtml(text);
} catch {
/* fall through */
}
return esc(text).replace(/\n/g, "<br>");
}
function escAttr(s) {
return esc(s).replace(/"/g, "&quot;");
}
@@ -314,6 +325,367 @@ const msgMenuState = {
handlers: null,
};
const MSG_PEEK_SIZE_KEY = "wa.msg-peek.size";
/** 拖出后仍须留在视口内的侧/底条带(px) */
const MSG_PEEK_KEEP_VISIBLE = 48;
/** 标题栏必须可见的顶边距(px)——保证可拖 */
const MSG_PEEK_TOP_MARGIN = 8;
const MSG_PEEK_MIN_W = 280;
const MSG_PEEK_MIN_H = 180;
const msgPeekState = {
wired: false,
dragging: false,
resizing: false,
offsetX: 0,
offsetY: 0,
resizeStartX: 0,
resizeStartY: 0,
resizeStartW: 0,
resizeStartH: 0,
lastLeft: null,
lastTop: null,
};
function hideMsgPeek() {
const panel = document.getElementById("msg-peek-panel");
if (!panel || panel.hidden) return;
panel.hidden = true;
panel.classList.remove("is-dragging", "is-resizing");
msgPeekState.dragging = false;
msgPeekState.resizing = false;
const body = document.getElementById("msg-peek-body");
if (body) body.innerHTML = "";
}
function loadMsgPeekSize() {
try {
const raw = localStorage.getItem(MSG_PEEK_SIZE_KEY);
if (!raw) return null;
const o = JSON.parse(raw);
const width = Number(o?.width);
const height = Number(o?.height);
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
return { width, height };
} catch {
return null;
}
}
function saveMsgPeekSize(width, height) {
try {
localStorage.setItem(
MSG_PEEK_SIZE_KEY,
JSON.stringify({
width: Math.round(width),
height: Math.round(height),
}),
);
} catch {
/* quota / private mode */
}
}
function defaultMsgPeekSize() {
return {
width: Math.min(680, Math.max(MSG_PEEK_MIN_W, window.innerWidth * 0.92)),
height: Math.min(760, Math.max(MSG_PEEK_MIN_H, window.innerHeight * 0.82)),
};
}
function clampMsgPeekSize(width, height) {
const maxW = Math.max(MSG_PEEK_MIN_W, window.innerWidth - 16);
const maxH = Math.max(MSG_PEEK_MIN_H, window.innerHeight - MSG_PEEK_TOP_MARGIN * 2);
return {
width: Math.min(Math.max(MSG_PEEK_MIN_W, width), maxW),
height: Math.min(Math.max(MSG_PEEK_MIN_H, height), maxH),
};
}
function applyMsgPeekSize(panel) {
const saved = loadMsgPeekSize();
const base = saved ?? defaultMsgPeekSize();
const size = clampMsgPeekSize(base.width, base.height);
panel.style.width = `${size.width}px`;
panel.style.height = `${size.height}px`;
panel.style.maxHeight = "none";
return size;
}
/**
* 顶边:标题栏始终留在视口内(可拖)。
* 左右/底:允许部分拖出,但至少 KEEP 像素相交。
*/
function clampMsgPeekPosition(panel, left, top) {
const rect = panel.getBoundingClientRect();
const w = rect.width || panel.offsetWidth;
const h = rect.height || panel.offsetHeight;
const keep = MSG_PEEK_KEEP_VISIBLE;
const minLeft = keep - w;
const maxLeft = window.innerWidth - keep;
const minTop = MSG_PEEK_TOP_MARGIN;
const maxTop = Math.max(minTop, window.innerHeight - keep);
return {
left: Math.min(Math.max(minLeft, left), maxLeft),
top: Math.min(Math.max(minTop, top), maxTop),
};
}
function isMsgPeekFullyOffscreen(panel) {
const r = panel.getBoundingClientRect();
return (
r.right <= 0 ||
r.left >= window.innerWidth ||
r.bottom <= 0 ||
r.top >= window.innerHeight
);
}
/** 每次打开:记忆尺寸 + 屏幕居中(标题栏保证可见) */
function placeMsgPeek(panel) {
const size = applyMsgPeekSize(panel);
panel.style.visibility = "hidden";
panel.hidden = false;
const centerLeft = (window.innerWidth - size.width) / 2;
const centerTop = (window.innerHeight - size.height) / 2;
const pos = clampMsgPeekPosition(panel, centerLeft, centerTop);
panel.style.left = `${pos.left}px`;
panel.style.top = `${pos.top}px`;
msgPeekState.lastLeft = pos.left;
msgPeekState.lastTop = pos.top;
panel.style.visibility = "";
}
function wireMsgPeekPanel() {
if (msgPeekState.wired) return;
const panel = document.getElementById("msg-peek-panel");
if (!panel) return;
msgPeekState.wired = true;
document.getElementById("msg-peek-close")?.addEventListener("click", () => {
hideMsgPeek();
});
const head = panel.querySelector("[data-peek-drag]");
head?.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
if (e.target.closest(".msg-peek-close")) return;
if (msgPeekState.resizing) return;
const rect = panel.getBoundingClientRect();
msgPeekState.dragging = true;
msgPeekState.offsetX = e.clientX - rect.left;
msgPeekState.offsetY = e.clientY - rect.top;
panel.classList.add("is-dragging");
head.setPointerCapture?.(e.pointerId);
e.preventDefault();
});
const resizeHandle = panel.querySelector("[data-peek-resize]");
resizeHandle?.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
if (msgPeekState.dragging) return;
const rect = panel.getBoundingClientRect();
msgPeekState.resizing = true;
msgPeekState.resizeStartX = e.clientX;
msgPeekState.resizeStartY = e.clientY;
msgPeekState.resizeStartW = rect.width;
msgPeekState.resizeStartH = rect.height;
panel.classList.add("is-resizing");
resizeHandle.setPointerCapture?.(e.pointerId);
e.preventDefault();
e.stopPropagation();
});
const onMove = (e) => {
if (msgPeekState.dragging) {
const next = clampMsgPeekPosition(
panel,
e.clientX - msgPeekState.offsetX,
e.clientY - msgPeekState.offsetY,
);
panel.style.left = `${next.left}px`;
panel.style.top = `${next.top}px`;
msgPeekState.lastLeft = next.left;
msgPeekState.lastTop = next.top;
return;
}
if (msgPeekState.resizing) {
const dw = e.clientX - msgPeekState.resizeStartX;
const dh = e.clientY - msgPeekState.resizeStartY;
const size = clampMsgPeekSize(
msgPeekState.resizeStartW + dw,
msgPeekState.resizeStartH + dh,
);
panel.style.width = `${size.width}px`;
panel.style.height = `${size.height}px`;
}
};
const onUp = (e) => {
if (msgPeekState.dragging) {
msgPeekState.dragging = false;
panel.classList.remove("is-dragging");
head?.releasePointerCapture?.(e.pointerId);
if (!panel.hidden && isMsgPeekFullyOffscreen(panel)) {
hideMsgPeek();
return;
}
}
if (msgPeekState.resizing) {
msgPeekState.resizing = false;
panel.classList.remove("is-resizing");
resizeHandle?.releasePointerCapture?.(e.pointerId);
if (!panel.hidden) {
const rect = panel.getBoundingClientRect();
const size = clampMsgPeekSize(rect.width, rect.height);
panel.style.width = `${size.width}px`;
panel.style.height = `${size.height}px`;
saveMsgPeekSize(size.width, size.height);
const pos = clampMsgPeekPosition(
panel,
Number.parseFloat(panel.style.left) || rect.left,
Number.parseFloat(panel.style.top) || rect.top,
);
panel.style.left = `${pos.left}px`;
panel.style.top = `${pos.top}px`;
msgPeekState.lastLeft = pos.left;
msgPeekState.lastTop = pos.top;
}
}
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
}
function openContentPeek(payload) {
wireMsgPeekPanel();
const panel = document.getElementById("msg-peek-panel");
const titleEl = document.getElementById("msg-peek-title");
const subEl = document.getElementById("msg-peek-subtitle");
const bodyEl = document.getElementById("msg-peek-body");
if (!panel || !titleEl || !bodyEl) return;
titleEl.textContent = payload?.title || "完整内容";
if (subEl) {
const subtitle = String(payload?.subtitle || "").trim();
if (subtitle) {
subEl.hidden = false;
subEl.textContent = subtitle;
} else {
subEl.hidden = true;
subEl.textContent = "";
}
}
bodyEl.innerHTML = payload?.html || `<p class="empty-sm">(无正文)</p>`;
placeMsgPeek(panel);
bodyEl.scrollTop = 0;
document.getElementById("msg-peek-close")?.focus?.();
}
function formatDictatePeekHtml(content) {
const trimmed = String(content ?? "").trim();
if (!trimmed) return `<p class="empty-sm">(无正文)</p>`;
// 与侧栏同一路径:勿走 formatArtifactBodyHtml(## 分段会丢掉前置 JSON/正文)
return `<div class="dictate-product-body">${safeBodyHtml(trimmed)}</div>`;
}
const dictateProductContentByEl = new WeakMap();
function rememberDictateProductContent(el, content) {
if (!el) return;
const text = String(content ?? "");
dictateProductContentByEl.set(el, text);
// 短文仍写 dataset,便于调试;长文只放 WeakMap,避免属性截断
if (text.length <= 4000) el.dataset.originalText = text;
else delete el.dataset.originalText;
}
function readDictateProductContent(card) {
if (!card) return "";
if (dictateProductContentByEl.has(card)) {
return dictateProductContentByEl.get(card) ?? "";
}
return card.dataset.originalText || "";
}
const dictateProductMenuState = {
card: null,
wired: false,
};
function hideDictateProductMenu() {
const menu = document.getElementById("dictate-product-menu");
if (menu) menu.hidden = true;
dictateProductMenuState.card = null;
}
function showDictateProductMenu(card, x, y) {
const menu = document.getElementById("dictate-product-menu");
if (!menu || !card) return;
hideMsgMenu();
hideDictateProductMenu();
dictateProductMenuState.card = card;
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 wireDictateProductMenu() {
if (dictateProductMenuState.wired) return;
const menu = document.getElementById("dictate-product-menu");
if (!menu) return;
dictateProductMenuState.wired = true;
menu.addEventListener("click", async (e) => {
const action = e.target
.closest("[data-dictate-product-action]")
?.getAttribute("data-dictate-product-action");
const card = dictateProductMenuState.card;
hideDictateProductMenu();
if (!action || !card) return;
const content = readDictateProductContent(card);
const tagEl = card.querySelector(".dictate-product-tag");
const title =
tagEl?.getAttribute("title")?.trim() ||
tagEl?.textContent?.trim() ||
"产物";
if (action === "peek") {
openContentPeek({
title,
html: formatDictatePeekHtml(content),
});
return;
}
if (action === "copy") {
try {
await navigator.clipboard.writeText(content);
} catch {
/* ignore */
}
}
});
}
function wireDictateProductContextMenu(root) {
if (!root || root.dataset.dictateProductMenuWired) return;
root.dataset.dictateProductMenuWired = "1";
wireDictateProductMenu();
root.addEventListener("contextmenu", (e) => {
const card = e.target.closest(".dictate-product");
if (!card || !root.contains(card)) return;
e.preventDefault();
e.stopPropagation();
showDictateProductMenu(card, e.clientX, e.clientY);
});
}
function showMsgMenu(card, x, y) {
const menu = document.getElementById("msg-action-menu");
if (!menu || !card) return;
@@ -510,7 +882,7 @@ function cancelInlineEdit(card) {
card.querySelector(".msg-foot")?.removeAttribute("hidden");
const editing = card.querySelector(".msg-body-editing");
if (editing) {
editing.outerHTML = `<div class="msg-body">${formatBodyHtml(original)}</div>`;
editing.outerHTML = `<div class="msg-body">${safeBodyHtml(original)}</div>`;
}
}
@@ -777,7 +1149,7 @@ function isWorkspaceNav() {
function syncExpandLabel() {
const label = document.getElementById("rail-expand-label");
if (label) label.textContent = activeRailTab === "log" ? "志" : "书";
if (label) label.textContent = activeRailTab === "log" ? "调度" : "作品";
}
export function setRailTab(tab, { expand = false } = {}) {
@@ -862,7 +1234,11 @@ export function resetRailChrome() {
railUserExpanded = true;
coordRailCollapsed = true;
document.body.dataset.railCanCollapse = "0";
document.body.classList.remove("rail-collapsed", "coord-rail-collapsed");
document.body.classList.remove(
"rail-collapsed",
"coord-rail-collapsed",
"play-workspace",
);
const rail = document.getElementById("coord-rail");
if (rail) {
rail.hidden = true;
@@ -1418,10 +1794,21 @@ function splitTaggedArtifactSections(text) {
hits.push({ title: m[1].trim(), index: m.index, headerEnd: m.index + m[0].length });
}
if (!hits.length) return [{ title: "", content: trimmed }];
return hits.map((h, i) => {
const sections = [];
// 保留首个 ## 之前的前置正文(常见:JSON / 散文 + ## 小节)
if (hits[0].index > 0) {
const preamble = trimmed.slice(0, hits[0].index).trim();
if (preamble) sections.push({ title: "", content: preamble });
}
for (let i = 0; i < hits.length; i++) {
const h = hits[i];
const end = i + 1 < hits.length ? hits[i + 1].index : trimmed.length;
return { title: h.title, content: trimmed.slice(h.headerEnd, end).trim() };
});
sections.push({
title: h.title,
content: trimmed.slice(h.headerEnd, end).trim(),
});
}
return sections;
}
const FRAGMENT_HEADER_KEYS = new Set([
@@ -1638,24 +2025,25 @@ function renderPlainCompactCard(title, content, opts = {}) {
return "";
}
const preview = clampPreviewText(content, 160);
const looksJson = /^\s*[{\[]/.test(String(content || ""));
if (opts.flat) {
const body = looksJson
? `<p class="ws-muted artifact-parse-hint">未能解析为可展示产物。</p>
<pre class="review-feed-plain">${esc(content)}</pre>`
: `<div class="dictate-product-body">${safeBodyHtml(content)}</div>`;
return `<article class="artifact-flat-review">
<header class="artifact-flat-meta">
<nav class="artifact-crumb-path artifact-flat-path" aria-label="产物">
<span class="artifact-crumb-root">${esc(title || "产物")}</span>
</nav>
</header>
<div class="artifact-flat-body">
<p class="ws-muted artifact-parse-hint">未能解析为可展示产物。</p>
<pre class="review-feed-plain">${esc(content)}</pre>
</div>
<div class="artifact-flat-body">${body}</div>
</article>`;
}
const openAttr = opts.open ? " open" : "";
const looksJson = /^\s*[{\[]/.test(String(content || ""));
const body = looksJson
? `<p class="ws-muted artifact-parse-hint">未能解析为 JSON,以下为原文(可展开原始块排查)。</p><pre class="review-feed-plain">${esc(content)}</pre>`
: `<pre class="review-feed-plain">${esc(content)}</pre>`;
: `<div class="dictate-product-body">${safeBodyHtml(content)}</div>`;
return `<details class="artifact-compact"${openAttr}>
<summary class="artifact-compact-sum">
<div class="artifact-compact-row">
@@ -1987,37 +2375,126 @@ function renderContextFragmentHtml(doc, opts = {}) {
}
/** 开场白终节点:1~多条候选用呈现壳展示,选定后由程序落库 */
function collectOpeningCandidateTexts(body) {
const texts = [];
const push = (value) => {
const text = String(value ?? "").trim();
if (text && !texts.includes(text)) texts.push(text);
function openingPersonaFrom(row, fallback) {
const p = row?.用户角色 || row?.meta?.用户角色 || fallback || null;
if (!p || typeof p !== "object") return null;
const name = String(p.名字 || p.name || "").trim();
if (!name) return null;
return {
名字: name,
简介: String(p.简介 || p.description || "").trim(),
};
push(body.开场白全文);
}
function openingTextFrom(raw) {
if (typeof raw === "string") return raw.trim();
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
if (
raw.schema === "present.v1" ||
raw.blocks != null ||
raw.shell ||
raw.shell_id
) {
try {
return JSON.stringify(raw);
} catch {
return "";
}
}
return String(raw.正文 || raw.全文 || raw.text || raw.开场白 || raw.内容 || "").trim();
}
return "";
}
function openingVariablesFrom(row) {
const list = Array.isArray(row?.开场变量)
? row.开场变量
: Array.isArray(row?.meta?.开场变量)
? row.meta.开场变量
: [];
return list.filter((item) => item && typeof item === "object");
}
function collectOpeningCandidates(body) {
const fallback = openingPersonaFrom(body, null);
const sharedVars = openingVariablesFrom(body);
const rows = [];
const seen = new Set();
const push = (text, persona, variables) => {
const value = openingTextFrom(text);
if (!value || seen.has(value)) return;
seen.add(value);
rows.push({
text: value,
persona: persona || fallback,
variables: variables?.length ? variables : sharedVars,
});
};
push(body.开场白全文, fallback, sharedVars);
if (Array.isArray(body.开场白候选)) {
for (const item of body.开场白候选) {
if (typeof item === "string") push(item);
if (typeof item === "string") push(item, fallback, sharedVars);
else if (item && typeof item === "object") {
push(item.全文 || item.text || item.开场白 || item.内容);
push(
item.正文 || item.全文 || item.text || item.开场白 || item.内容,
openingPersonaFrom(item, fallback),
openingVariablesFrom(item),
);
}
}
}
return texts;
return rows;
}
function renderOpeningPersonaHtml(persona) {
if (!persona?.名字) return "";
return `<section class="artifact-block"><h4>用户角色</h4><p><strong>${esc(
String(persona.名字),
)}</strong>${
persona.简介 ? ` · ${esc(String(persona.简介))}` : ""
}</p></section>`;
}
function renderOpeningVarsHtml(vars) {
if (!Array.isArray(vars) || !vars.length) return "";
return `<section class="artifact-block"><h4>开场变量</h4><ul class="artifact-list">${vars
.map((row) => {
const name = row?.名 || row?.name || row?.key || "";
const val = row?.值 !== undefined ? row.值 : row?.value;
const note = row?.依据 || row?.note || "";
const noteHtml = note
? ` <span class="muted">(${esc(String(note))})</span>`
: "";
return `<li><strong>${esc(String(name))}</strong>:${esc(String(val ?? ""))}${noteHtml}</li>`;
})
.join("")}</ul></section>`;
}
function openingPresentPacket(text) {
const trimmed = String(text || "").trim();
if (!trimmed) return presentFromPlain("", "prose");
if (trimmed.startsWith("{")) {
const parsed = tryParseJsonDoc(trimmed);
const view = parsed ? parsePresentDoc(parsed) : null;
if (view) return view.packet;
}
return presentFromPlain(trimmed, "prose");
}
function renderOpeningSetupBodyHtml(body) {
if (!body || typeof body !== "object" || Array.isArray(body)) return "";
const candidates = collectOpeningCandidateTexts(body);
const candidates = collectOpeningCandidates(body);
if (!candidates.length) return "";
const total = candidates.length;
const anyCandidateVars = candidates.some((row) => row.variables?.length);
const slides = candidates
.map((text, i) => {
.map((row, i) => {
const hidden = i === 0 ? "" : " hidden";
const selected = i === 0 ? " is-selected" : "";
return `<article class="opening-candidate${selected}" data-opening-index="${i}"${hidden}>${renderPresentShellHtml(
presentFromPlain(text, "prose"),
openingPresentPacket(row.text),
esc,
)}</article>`;
)}${renderOpeningPersonaHtml(row.persona)}${renderOpeningVarsHtml(row.variables)}</article>`;
})
.join("");
const nav =
@@ -2028,20 +2505,10 @@ function renderOpeningSetupBodyHtml(body) {
<button type="button" class="opening-picker-btn" data-opening-delta="1">下一版</button>
</div>`
: "";
const vars = Array.isArray(body.开场变量) ? body.开场变量 : [];
const varsHtml = vars.length
? `<section class="artifact-block"><h4>开场变量</h4><ul class="artifact-list">${vars
.map((row) => {
const name = row?.名 || row?.name || row?.key || "";
const val = row?.值 !== undefined ? row.值 : row?.value;
const note = row?.依据 || row?.note || "";
const noteHtml = note
? ` <span class="muted">(${esc(String(note))})</span>`
: "";
return `<li><strong>${esc(String(name))}</strong>:${esc(String(val ?? ""))}${noteHtml}</li>`;
})
.join("")}</ul></section>`
: "";
const varsHtml =
!anyCandidateVars && Array.isArray(body.开场变量) && body.开场变量.length
? renderOpeningVarsHtml(body.开场变量)
: "";
const space = body.用户可行动空间
? `<section class="artifact-block"><h4>可行动空间</h4>${renderProseHtml(
body.用户可行动空间,
@@ -2117,7 +2584,8 @@ function renderSpecialtyBodyHtml(body, skill) {
if (
body.风格与写法 != null ||
body.推进与决策 != null ||
skillName.includes("叙事指南")
skillName.includes("叙事指南") ||
skillName.includes("故事推进")
) {
const html = renderNarrativeBodyHtml(body);
if (html) return html;
@@ -4925,7 +5393,7 @@ function renderSpeakWorkspace(view) {
wr.message ||
"描述本轮行动、对话或想说的话;由游玩管线推进。";
} else if (view.creationMode === "dictate") {
title = "Boss 直聘";
title = displayCreationModeLabel("dictate");
hint =
view.hints?.[0] ||
wr.message ||
@@ -4976,7 +5444,7 @@ function renderSpeakWorkspace(view) {
? workspaceViewSwitchHtml("flow")
: `<span class="workspace-surface-kicker">${
view.creationMode === "dictate"
? "Boss直聘"
? displayCreationModeLabel("dictate")
: canOfferSaveProduct(view)
? "落档"
: "说话"
@@ -4993,34 +5461,79 @@ function renderSpeakWorkspace(view) {
</section>`;
}
/** 转述式:产物列表(右侧栏) */
/** 转述式:产物列表(右侧栏)— 默认折叠,点开再渲染正文 */
function renderDictateProductsList(view) {
const items = Array.isArray(view.dictateProducts) ? view.dictateProducts : [];
if (!items.length) {
return `<p class="coord-empty">尚无写入。Agent 落盘产物后会出现在这里。</p>`;
return `<div class="dictate-products-empty-block">
<p class="dictate-products-empty-title">尚无产物</p>
<p class="dictate-products-empty-desc">Agent 落盘后会出现在这里。点击行展开,右键可查看全文或复制。</p>
</div>`;
}
return `<div class="dictate-products-list">${items
return `<div class="dictate-products-list" role="list">${items
.map((p) => {
const tag = String(p.tag ?? "").trim();
const content = String(p.content ?? "");
const preview = content.replace(/\s+/g, " ").trim().slice(0, 72);
return `<details class="dictate-product dictate-product--rail" open>
<summary>
<span class="dictate-product-tag">${esc(tag)}${
p.order != null && Number.isFinite(Number(p.order))
? ` · ${Number(p.order)}`
: ""
}</span>
<span class="dictate-product-preview">${esc(preview)}${
content.length > 72 ? "…" : ""
}</span>
const order =
p.order != null && Number.isFinite(Number(p.order))
? Number(p.order)
: null;
const shortTag = tag
.replace(/^设计\./, "")
.replace(/^用户\./, "")
.replace(/#/g, " · ");
return `<details class="dictate-product dictate-product--rail" role="listitem">
<summary class="dictate-product-sum">
<span class="dictate-product-sum-top">
<span class="dictate-product-tag" title="${esc(tag)}">${esc(shortTag || tag || "未命名")}</span>
${
order != null
? `<span class="dictate-product-order" title="相对序">${order}</span>`
: ""
}
<span class="dictate-product-chevron" aria-hidden="true"></span>
</span>
${
preview
? `<span class="dictate-product-preview">${esc(preview)}${
content.length > 72 ? "…" : ""
}</span>`
: `<span class="dictate-product-preview is-empty">(空)</span>`
}
</summary>
<div class="dictate-product-body">${formatBodyHtml(content)}</div>
<div class="dictate-product-body" data-pending="1"></div>
</details>`;
})
.join("")}</div>`;
}
function hydrateDictateProductBody(card) {
if (!card) return;
const body = card.querySelector(".dictate-product-body");
if (!body || body.dataset.pending !== "1") return;
const content = readDictateProductContent(card);
body.innerHTML = content.trim()
? safeBodyHtml(content)
: `<p class="empty-sm">(无正文)</p>`;
delete body.dataset.pending;
}
function wireDictateProductAccordion(root) {
if (!root || root.dataset.dictateProductAccordionWired) return;
root.dataset.dictateProductAccordionWired = "1";
root.addEventListener("toggle", (e) => {
const card = e.target;
if (!(card instanceof HTMLDetailsElement)) return;
if (!card.classList.contains("dictate-product--rail")) return;
if (!card.open) return;
hydrateDictateProductBody(card);
root.querySelectorAll("details.dictate-product--rail[open]").forEach((other) => {
if (other !== card) other.open = false;
});
}, true);
}
function fillCoordRailProducts(view) {
const coordRail = document.getElementById("coord-rail");
const drawerBody = document.getElementById("coord-drawer-body");
@@ -5032,7 +5545,15 @@ function fillCoordRailProducts(view) {
coordRailCollapsed = false;
}
coordRail.hidden = false;
if (drawerBody) drawerBody.innerHTML = renderDictateProductsList(view);
if (drawerBody) {
drawerBody.innerHTML = renderDictateProductsList(view);
const cards = drawerBody.querySelectorAll(".dictate-product");
cards.forEach((el, i) => {
rememberDictateProductContent(el, items[i]?.content ?? "");
});
wireDictateProductContextMenu(drawerBody);
wireDictateProductAccordion(drawerBody);
}
if (drawerCount) {
drawerCount.textContent = String(items.length);
drawerCount.title = items.length
@@ -5108,6 +5629,10 @@ function mountReviewWorkspace(stage, view, handlers, opts = {}) {
sourceMessage: resolveReviewSourceMessage(view),
leaveStepHtml: leaveStepControlHtml(view),
}) + askSlot;
const reviewEl = stage.querySelector(".workspace-review");
if (reviewEl && view.reviewArtifact?.body) {
reviewEl.dataset.originalText = String(view.reviewArtifact.body);
}
wireMessageFeedActions(stage, handlers);
wireMessageContextMenu(stage, handlers);
wireCreationFlowGraph(stage, handlers);
@@ -5176,6 +5701,8 @@ export function renderMessageFeed(view, loading, handlers = {}) {
if (drawerBody) drawerBody.innerHTML = "";
if (drawerCount) drawerCount.textContent = "";
}
document.body.classList.toggle("play-workspace", false);
hidePlayAuxTabs();
// 默认把询问卡停回主列底部槽位
if (panelFeed) ensureQuestionsHostIn(panelFeed);
@@ -5195,36 +5722,41 @@ export function renderMessageFeed(view, loading, handlers = {}) {
if (!stage) return;
// 转述:主柱对话流;右侧已持久化;stage 仅一行条
// 转述:对话画在 stage 内(design-workspace 会藏掉 #message-feed)
if (surface === "dictate") {
stage.hidden = false;
const recipeName = view.selectedRecipe?.name
? esc(view.selectedRecipe.name)
: "";
stage.innerHTML = recipeName
? `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">Boss直聘</span><span class="dictate-stage-recipe">配方 · ${recipeName}</span></header>`
: `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">Boss直聘</span></header>`;
const dictateKicker = displayCreationModeLabel("dictate");
const head = recipeName
? `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">${dictateKicker}</span><span class="dictate-stage-recipe">配方 · ${recipeName}</span></header>`
: `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">${dictateKicker}</span></header>`;
stage.innerHTML = `${head}<div class="dictate-feed" id="dictate-feed"></div>`;
const dictateFeed = stage.querySelector("#dictate-feed");
if (panelFeed) ensureQuestionsHostIn(panelFeed);
appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, {
emptyText:
"直接说你想要的体验与设定。我会整理进右侧产物,并在回复里跟你确认、建议补充。",
loading,
});
if (loading) {
const pending = document.createElement("article");
pending.className = "msg agent msg-assistant-row msg-pending";
pending.id = "msg-live-pending";
const live = view.liveStream;
const label = live?.label ?? view.focus?.action ?? "整理中";
pending.innerHTML = `
<div class="msg-bubble">
<header class="msg-head"><span class="msg-tag">${esc(label)}</span><span class="msg-live-indicator">流式输出中</span>${renderRetryRunButton()}</header>
<div class="msg-body msg-live-body">${renderLiveStreamBody(live, view)}</div>
</div>`;
feed.appendChild(pending);
wireRetryRun(pending, handlers);
if (dictateFeed) {
appendMessagesToFeed(dictateFeed, visible, view, handlers, activeQuestions, {
emptyText:
"直接说你想要的体验与设定。我会整理进右侧产物,并在回复里跟你确认、建议补充。",
loading,
});
if (loading) {
const pending = document.createElement("article");
pending.className = "msg agent msg-assistant-row msg-pending";
pending.id = "msg-live-pending";
const live = view.liveStream;
const label = live?.label ?? view.focus?.action ?? "整理中";
pending.innerHTML = `
<div class="msg-bubble">
<header class="msg-head"><span class="msg-tag">${esc(label)}</span><span class="msg-live-indicator">流式输出中</span>${renderRetryRunButton()}</header>
<div class="msg-body msg-live-body">${renderLiveStreamBody(live, view)}</div>
</div>`;
dictateFeed.appendChild(pending);
wireRetryRun(pending, handlers);
}
dictateFeed.scrollTop = dictateFeed.scrollHeight;
}
feed.scrollTop = feed.scrollHeight;
return;
}
@@ -5282,6 +5814,14 @@ export function renderMessageFeed(view, loading, handlers = {}) {
}
// —— 游玩 / 转述对话流 ——
if (isPlayView(view) && coordRail) {
document.body.classList.add("play-workspace");
coordRailCollapsed = false;
document.body.classList.remove("coord-rail-collapsed");
fillPlayAux(view, handlers, { busy: loading });
syncCoordRailChrome();
}
appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, {
emptyText: isPlayView(view)
? "开场已就绪。在下方说你要做什么。"
@@ -5342,6 +5882,7 @@ function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, op
}
for (const msg of visible) {
try {
const isUser = msg.role === "user";
const kind = msg.kind ?? (isUser ? "user_input" : "system_info");
const body = msgBody(msg, view);
@@ -5383,7 +5924,7 @@ function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, op
? formatPlayPresentHtml(body, view)
: kind === "worker_output"
? formatArtifactBodyHtml(body)
: formatBodyHtml(body);
: safeBodyHtml(body);
const process = !isUser && isPlayProcessMessage(msg, view);
const playFinal = !isUser && isPlayFinalReply(msg, view);
@@ -5410,6 +5951,15 @@ function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, op
</div>`;
}
feed.appendChild(card);
} catch (err) {
console.error("render message failed", msg?.id, err);
const fallback = document.createElement("article");
fallback.className = "msg system msg-assistant-row";
fallback.innerHTML = `<div class="msg-bubble"><div class="msg-body">${esc(
msgBody(msg, view) || "(这条消息渲染失败)",
).replace(/\n/g, "<br>")}</div></div>`;
feed.appendChild(fallback);
}
}
wireMessageFeedActions(feed, handlers);
wireMessageContextMenu(feed, handlers);
@@ -5534,17 +6084,25 @@ export function renderWorkspace(view, loading, _onPickSkill, handlers = {}) {
document.addEventListener("click", (e) => {
if (!e.target.closest("#msg-action-menu")) hideMsgMenu();
if (!e.target.closest("#dictate-product-menu")) hideDictateProductMenu();
if (!e.target.closest("#flow-node-menu") && !e.target.closest("[data-flow-done]")) {
hideFlowNodeMenu();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
const peek = document.getElementById("msg-peek-panel");
if (peek && !peek.hidden) {
hideMsgPeek();
return;
}
hideMsgMenu();
hideDictateProductMenu();
hideFlowNodeMenu();
}
});
document.addEventListener("scroll", () => {
hideMsgMenu();
hideDictateProductMenu();
hideFlowNodeMenu();
}, true);
+786 -235
View File
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -1,6 +1,6 @@
/**
* 与 docs/ui-glossary.md、src/server/display-labels.ts 保持同步。
* 用户侧口径:配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元。
* 用户侧口径:进料方式 / 配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元。
*/
const STAGE_LABELS = {
@@ -37,9 +37,12 @@ const FIXED_TOPIC_LABELS = {
"aesthetics-interaction": "美学纲领与交互范式",
interaction: "交互范式",
narrative_guide: "叙事指南与故事推进",
"narrative-guide": "叙事指南",
"story-progression": "故事推进",
input_protocol: "输入协议",
core_premise: "核心前提",
aesthetics: "美学纲领",
protagonist: "主角设定",
};
const PHASE_UNIT_LABELS = {
@@ -52,11 +55,21 @@ export function displayStageLabel(id) {
return STAGE_LABELS[id] ?? id;
}
export function displayCreationModeLabel(id) {
if (!id) return "";
const map = {
recipe: "工序编排",
dictate: "对话落盘",
};
return map[id] ?? id;
}
export function displaySkillPackLabel(id) {
if (!id) return "";
const map = {
"world-simulator": "世界模拟器",
"world-simulator": "回合推演",
"expand-assistant": "扩写助手",
快穿短局: "快穿短局",
文本生成器: "文本生成器",
"text-generator": "文本生成器",
交互式长文生成器: "交互式长文生成器",
+44 -24
View File
@@ -10,7 +10,7 @@
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Serif+SC:wght@500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/styles.css" />
<link rel="stylesheet" href="/styles.css?v=20260921-repeatable-split" />
<script>
(function () {
try {
@@ -18,9 +18,14 @@
if (!/^(paper|modern|dark|green)$/.test(t)) t = "modern";
document.documentElement.setAttribute("data-color-theme", t);
document.documentElement.setAttribute("data-theme", t);
var c = localStorage.getItem("wa-present-chrome") || "default";
if (!/^(default|messenger|book|terminal)$/.test(c)) c = "default";
document.documentElement.setAttribute("data-present-chrome", c);
var fs = localStorage.getItem("wa-ui-font-scale") || "100";
var legacy = { sm: 88, md: 100, lg: 112, xl: 125 };
var percent = legacy[fs] != null ? legacy[fs] : Number(fs);
if (!isFinite(percent)) percent = 100;
if (percent > 0 && percent <= 2.5) percent = Math.round(percent * 100);
percent = Math.min(150, Math.max(80, Math.round(percent / 5) * 5));
document.documentElement.setAttribute("data-ui-font-scale", String(percent));
document.documentElement.style.setProperty("--ui-font-scale", String(percent / 100));
} catch (e) {}
})();
</script>
@@ -36,7 +41,7 @@
aria-controls="rail-body"
title="展开侧栏"
>
<span class="rail-expand-label" id="rail-expand-label">书</span>
<span class="rail-expand-label" id="rail-expand-label">作品</span>
</button>
<div class="rail-body" id="rail-body">
<header class="panel-head rail-head">
@@ -54,9 +59,9 @@
<button type="button" class="btn-sm rail-collapse" id="btn-rail-collapse" title="收起侧栏" aria-label="收起侧栏">‹</button>
</div>
</div>
<nav class="rail-tabs" id="rail-tabs" role="tablist" aria-label="本书侧栏" hidden>
<button type="button" class="rail-tab active" data-rail-tab="books" role="tab" aria-selected="true" title="本书">
<span class="rail-tab-label">本书</span>
<nav class="rail-tabs" id="rail-tabs" role="tablist" aria-label="作品侧栏" hidden>
<button type="button" class="rail-tab active" data-rail-tab="books" role="tab" aria-selected="true" title="实例">
<span class="rail-tab-label">实例</span>
</button>
<button type="button" class="rail-tab" data-rail-tab="log" role="tab" aria-selected="false" title="调度">
<span class="rail-tab-label">调度</span>
@@ -122,19 +127,12 @@
<span class="more-theme-copy"><span class="more-theme-name">豆沙绿</span><span class="more-theme-desc">自然疗愈</span></span>
</button>
<div class="more-menu-sep" role="separator"></div>
<div class="more-menu-label">选壳</div>
<button type="button" class="more-menu-item more-theme-item" data-present-chrome="default" role="menuitem">
<span class="more-theme-copy"><span class="more-theme-name">默认</span><span class="more-theme-desc">跟随壳本身</span></span>
</button>
<button type="button" class="more-menu-item more-theme-item" data-present-chrome="messenger" role="menuitem">
<span class="more-theme-copy"><span class="more-theme-name">讯息</span><span class="more-theme-desc">气泡对话感</span></span>
</button>
<button type="button" class="more-menu-item more-theme-item" data-present-chrome="book" role="menuitem">
<span class="more-theme-copy"><span class="more-theme-name">书页</span><span class="more-theme-desc">阅读排版感</span></span>
</button>
<button type="button" class="more-menu-item more-theme-item" data-present-chrome="terminal" role="menuitem">
<span class="more-theme-copy"><span class="more-theme-name">终端</span><span class="more-theme-desc">等宽终端感</span></span>
</button>
<div class="more-menu-label">字号</div>
<div class="more-font-scale" role="group" aria-label="字号">
<button type="button" class="more-font-btn" data-font-scale-bump="-1" title="缩小" aria-label="缩小">−</button>
<span class="more-font-value" id="theme-font-scale-value">100</span>
<button type="button" class="more-font-btn" data-font-scale-bump="1" title="放大" aria-label="放大">+</button>
</div>
</div>
</details>
</div>
@@ -198,8 +196,8 @@
进料方式
<select id="select-creation-mode" required>
<option value="" selected disabled>请选择…</option>
<option value="recipe">Worker 式(工序图)</option>
<option value="dictate">Boss 直聘(对话直填)</option>
<option value="recipe">工序编排</option>
<option value="dictate">对话落盘</option>
</select>
</label>
<p class="dialog-desc" id="creation-mode-desc" hidden></p>
@@ -221,6 +219,11 @@
</form>
</dialog>
<div id="book-enter-pop" class="book-enter-pop" hidden role="menu" aria-label="打开作品">
<button type="button" class="book-enter-item" data-enter="design" role="menuitem">创作</button>
<button type="button" class="book-enter-item" data-enter="play" role="menuitem">游玩</button>
</div>
<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>
@@ -237,6 +240,11 @@
<button type="button" class="context-menu-item" data-save-menu-action="edit-products">编辑产物</button>
</menu>
<menu id="dictate-product-menu" class="context-menu" hidden>
<button type="button" class="context-menu-item" data-dictate-product-action="peek">查看完整内容</button>
<button type="button" class="context-menu-item" data-dictate-product-action="copy">复制全文</button>
</menu>
<menu id="msg-action-menu" class="context-menu" hidden>
<button type="button" class="context-menu-item" id="msg-menu-context" data-msg-menu-action="view-context" hidden>查看上下文</button>
<button type="button" class="context-menu-item" id="msg-menu-compare" data-msg-menu-action="compare" hidden>对比</button>
@@ -246,6 +254,18 @@
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="restart">从这里重开</button>
</menu>
<div id="msg-peek-panel" class="msg-peek-panel" hidden role="dialog" aria-modal="false" aria-labelledby="msg-peek-title">
<header class="msg-peek-head" data-peek-drag>
<div class="msg-peek-head-text">
<h2 class="msg-peek-title" id="msg-peek-title">完整内容</h2>
<p class="msg-peek-subtitle" id="msg-peek-subtitle" hidden></p>
</div>
<button type="button" class="msg-peek-close" id="msg-peek-close" aria-label="关闭">×</button>
</header>
<div class="msg-peek-body" id="msg-peek-body"></div>
<div class="msg-peek-resize" data-peek-resize="se" title="拖动调整大小" aria-hidden="true"></div>
</div>
<menu id="flow-node-menu" class="context-menu" hidden>
<button type="button" class="context-menu-item" id="flow-menu-view" data-flow-menu-action="view">查看产物</button>
<button type="button" class="context-menu-item" id="flow-menu-reenter" data-flow-menu-action="reenter">进入继续修改</button>
@@ -308,6 +328,6 @@
</div>
</dialog>
<script type="module" src="/app.js"></script>
<script type="module" src="/app.js?v=20260921-repeatable-split"></script>
</body>
</html>
+283
View File
@@ -0,0 +1,283 @@
/**
* 游玩辅助 Tab:表格 / 开场与角色 / 对话。
* 隐藏格默认挡住,点字段才露;手改带格子 rev。
*/
import { parsePresentDoc } from "./present-shells.js";
let playAuxTab = "table";
let revealedHidden = new Set();
let editingKey = null;
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatValue(value) {
if (value === null || value === undefined) return "";
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
}
function coerceValue(prev, raw) {
const text = String(raw ?? "").trim();
if (typeof prev === "boolean") {
if (text === "true" || text === "是" || text === "1") return true;
if (text === "false" || text === "否" || text === "0") return false;
return prev;
}
if (typeof prev === "number") {
const n = Number(text);
return Number.isFinite(n) ? n : prev;
}
return text;
}
function previewOpening(text) {
let source = String(text || "").trim();
if (source.startsWith("{")) {
try {
const view = parsePresentDoc(JSON.parse(source));
const body = view?.packet?.blocks?.body;
if (typeof body === "string" && body.trim()) source = body.trim();
} catch {
/* 保持原文 */
}
}
const flat = source.replace(/\s+/g, " ").trim();
if (flat.length <= 72) return flat;
return `${flat.slice(0, 72)}…`;
}
function rowHtml(row, { hidden, revealed, editing, busy }) {
const shown = !hidden || revealed;
const val = shown ? formatValue(row.value) : "••••••";
const edit =
shown && row.editable && editing
? `<form class="play-table-edit" data-table-edit="${esc(row.key)}">
<input type="text" name="value" value="${esc(formatValue(row.value))}" ${busy ? "disabled" : ""} />
<button type="submit" class="btn-sm btn-primary" ${busy ? "disabled" : ""}>保存</button>
<button type="button" class="btn-sm" data-table-cancel>取消</button>
</form>`
: "";
const action =
shown && row.editable && !editing
? `<button type="button" class="play-table-fix" data-table-fix="${esc(row.key)}" ${busy ? "disabled" : ""}>改</button>`
: "";
return `<li class="play-table-row${hidden ? " is-secret" : ""}${revealed ? " is-open" : ""}" data-table-key="${esc(row.key)}" data-hidden="${hidden ? "1" : "0"}">
<button type="button" class="play-table-hit" data-table-reveal="${esc(row.key)}" ${hidden && !revealed ? "" : hidden ? "" : "tabindex='-1'"}>
<span class="play-table-key">${esc(row.key)}</span>
<span class="play-table-val">${esc(val)}</span>
</button>
${action}
${edit}
</li>`;
}
function tablePanelHtml(table, busy) {
if (!table?.rows?.length) {
return `<p class="play-aux-empty">本局没有状态表。</p>`;
}
const visible = table.rows.filter((r) => r.visibility !== "hidden");
const hidden = table.rows.filter((r) => r.visibility === "hidden");
const vis =
visible.length > 0
? `<ul class="play-table-list">${visible
.map((row) =>
rowHtml(row, {
hidden: false,
revealed: true,
editing: editingKey === row.key,
busy,
}),
)
.join("")}</ul>`
: `<p class="play-aux-empty">没有可见字段。</p>`;
const hid = hidden.length
? `<details class="play-table-secrets">
<summary>不可见 ${hidden.length}</summary>
<ul class="play-table-list">${hidden
.map((row) =>
rowHtml(row, {
hidden: true,
revealed: revealedHidden.has(row.key),
editing: editingKey === row.key,
busy,
}),
)
.join("")}</ul>
</details>`
: "";
return `<div class="play-table" data-table-tag="${esc(table.tag)}">${vis}${hid}</div>`;
}
function openingPanelHtml(aux) {
const openings = aux?.openings ?? [];
if (!openings.length) {
return `<p class="play-aux-empty">没有可选开场。使用当前用户角色卡。</p>`;
}
const cards = openings
.map((item) => {
const selected = item.index === aux.selectedOpeningIndex;
const persona = item.persona;
return `<article class="play-open-card${selected ? " is-selected" : ""}" data-opening-index="${item.index}" tabindex="0" role="button">
<p class="play-open-text">${esc(previewOpening(item.text))}</p>
${
persona
? `<p class="play-open-persona"><strong>${esc(persona.name)}</strong>${
persona.description
? ` · ${esc(persona.description)}`
: ""
}</p>`
: `<p class="play-open-persona muted">无预设角色</p>`
}
</article>`;
})
.join("");
const usingOpening = aux.openingPersonaChoice === "opening";
return `<div class="play-open">
${cards}
<button type="button" class="btn-sm play-open-refuse"${usingOpening ? "" : " disabled"}>不用预设角色</button>
</div>`;
}
function tabBtn(id, label, current) {
return `<button type="button" class="play-aux-tab${current === id ? " is-current" : ""}" data-play-aux-tab="${id}" ${current === id ? 'aria-current="page"' : ""}>${esc(label)}</button>`;
}
export function fillPlayAux(view, handlers = {}, extra = {}) {
const rail = document.getElementById("coord-rail");
const drawerBody = document.getElementById("coord-drawer-body");
const title = document.getElementById("coord-rail-title");
const drawerCount = document.getElementById("coord-drawer-count");
if (!rail || !drawerBody) return;
rail.hidden = false;
const aux = view.playAux;
const head = rail.querySelector(".coord-rail-head");
if (head) {
let tabs = head.querySelector(".play-aux-tabs");
if (!tabs) {
tabs = document.createElement("nav");
tabs.className = "play-aux-tabs";
tabs.setAttribute("aria-label", "游玩辅助");
const collapse = head.querySelector(".coord-rail-collapse");
if (collapse) head.insertBefore(tabs, collapse);
else head.appendChild(tabs);
}
tabs.hidden = false;
tabs.innerHTML = [
tabBtn("table", "表格", playAuxTab),
tabBtn("opening", "开场", playAuxTab),
].join("");
tabs.onclick = (e) => {
const btn = e.target.closest("[data-play-aux-tab]");
if (!btn) return;
const id = btn.getAttribute("data-play-aux-tab");
if (!id || id === playAuxTab) return;
playAuxTab = id;
fillPlayAux(view, handlers, extra);
};
}
if (title) title.textContent = "游玩";
if (drawerCount) drawerCount.textContent = "";
const busy = Boolean(extra.busy);
if (playAuxTab === "table") {
drawerBody.innerHTML = tablePanelHtml(aux?.table, busy);
wireTable(drawerBody, aux?.table, handlers, busy, () =>
fillPlayAux(view, handlers, extra),
);
return;
}
if (playAuxTab === "opening") {
drawerBody.innerHTML = openingPanelHtml(aux);
wireOpening(drawerBody, handlers, busy);
}
}
export function currentPlayAuxTab() {
return playAuxTab;
}
export function hidePlayAuxTabs() {
const tabs = document.querySelector(".play-aux-tabs");
if (tabs) tabs.hidden = true;
}
function wireTable(root, table, handlers, busy, rerender) {
root.querySelectorAll("[data-table-reveal]").forEach((btn) => {
btn.addEventListener("click", () => {
const key = btn.getAttribute("data-table-reveal");
const row = btn.closest(".play-table-row");
if (!key || !row) return;
if (row.getAttribute("data-hidden") === "1" && !revealedHidden.has(key)) {
revealedHidden.add(key);
rerender?.();
}
});
});
root.querySelectorAll("[data-table-fix]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
editingKey = btn.getAttribute("data-table-fix");
rerender?.();
});
});
root.querySelectorAll("[data-table-cancel]").forEach((btn) => {
btn.addEventListener("click", () => {
editingKey = null;
rerender?.();
});
});
root.querySelectorAll("[data-table-edit]").forEach((form) => {
form.addEventListener("submit", (e) => {
e.preventDefault();
if (busy) return;
const key = form.getAttribute("data-table-edit");
const row = table?.rows?.find((r) => r.key === key);
if (!key || !row) return;
const next = coerceValue(
row.value,
form.querySelector("input[name=value]")?.value,
);
editingKey = null;
handlers.onPatchTable?.(table.tag, [
{ key, value: next, expectedRev: row.rev },
]);
});
});
}
function wireOpening(root, handlers, busy) {
root.querySelectorAll("[data-opening-index]").forEach((card) => {
const go = () => {
if (busy) return;
const index = Number(card.getAttribute("data-opening-index"));
if (!Number.isFinite(index)) return;
const hasPersona = Boolean(card.querySelector(".play-open-persona strong"));
handlers.onSelectOpening?.(index, hasPersona ? true : undefined);
};
card.addEventListener("click", go);
card.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
go();
}
});
});
root.querySelector(".play-open-refuse")?.addEventListener("click", () => {
if (busy) return;
handlers.onRefuseOpeningPersona?.();
});
}
+1 -10
View File
@@ -277,16 +277,7 @@ export function renderPresentShellHtml(packet, esc, opts = {}) {
typeof tweaks.tone_chrome === "string" && tweaks.tone_chrome.trim()
? tweaks.tone_chrome.trim()
: "default";
if (opts.applyUserChrome !== false) {
try {
const pref = document.documentElement.getAttribute("data-present-chrome");
if (pref === "messenger" || pref === "book" || pref === "terminal") {
tone = pref;
}
} catch {
/* ignore */
}
}
// 用户「选壳」菜单已移除;气质跟呈现壳 / tweaks 本身,不再读全局 data-present-chrome
const blocks = packet.blocks ?? {};
const regions = shellDefaultRegions(shell);
+821 -92
View File
File diff suppressed because it is too large Load Diff
+85 -5
View File
@@ -1,4 +1,4 @@
/** Color themes + present chrome — aligned with llm_workflow_engine ThemeToggle. */
/** Color themes + UI font scale — aligned with llm_workflow_engine ThemeToggle. */
export const COLOR_THEMES = [
{
@@ -27,7 +27,22 @@ export const COLOR_THEMES = [
},
];
/** 呈现壳气质(tone_chrome),与色主题同属「主题」菜单 */
/** 全局字号:百分比(100 = 默认),+/- 步进调节 */
export const UI_FONT_SCALE_MIN = 80;
export const UI_FONT_SCALE_MAX = 150;
export const UI_FONT_SCALE_STEP = 5;
export const UI_FONT_SCALE_DEFAULT = 100;
const LEGACY_FONT_SCALE_IDS = {
sm: 88,
md: 100,
lg: 112,
xl: 125,
};
/**
* @deprecated 选壳已从主题菜单移除;壳气质跟呈现壳本身。保留常量以免旧引用报错。
*/
export const PRESENT_CHROMES = [
{ id: "default", name: "默认", description: "跟随壳本身" },
{ id: "messenger", name: "讯息", description: "气泡对话感" },
@@ -36,6 +51,7 @@ export const PRESENT_CHROMES = [
];
const STORAGE_KEY = "wa-color-theme";
const FONT_SCALE_STORAGE_KEY = "wa-ui-font-scale";
const CHROME_STORAGE_KEY = "wa-present-chrome";
const THEME_IDS = new Set(COLOR_THEMES.map((t) => t.id));
const CHROME_IDS = new Set(PRESENT_CHROMES.map((t) => t.id));
@@ -76,11 +92,72 @@ export function initColorTheme() {
return applyColorTheme(getStoredColorTheme());
}
function clampFontPercent(n) {
const stepped = Math.round(n / UI_FONT_SCALE_STEP) * UI_FONT_SCALE_STEP;
return Math.min(
UI_FONT_SCALE_MAX,
Math.max(UI_FONT_SCALE_MIN, stepped),
);
}
/** 解析为百分比整数;兼容旧档 sm/md/lg/xl 与 0.x~2 倍缩放 */
export function normalizeUiFontScalePercent(raw) {
if (raw == null) return UI_FONT_SCALE_DEFAULT;
const key = String(raw).trim();
if (Object.prototype.hasOwnProperty.call(LEGACY_FONT_SCALE_IDS, key)) {
return LEGACY_FONT_SCALE_IDS[key];
}
let n = Number(key);
if (!Number.isFinite(n)) return UI_FONT_SCALE_DEFAULT;
if (n > 0 && n <= 2.5) n = Math.round(n * 100);
return clampFontPercent(n);
}
export function getStoredUiFontScale() {
try {
return normalizeUiFontScalePercent(localStorage.getItem(FONT_SCALE_STORAGE_KEY));
} catch {
return UI_FONT_SCALE_DEFAULT;
}
}
/** @deprecated 用 getStoredUiFontScale / applyUiFontScale(percent) */
export function fontScaleById(id) {
const percent = normalizeUiFontScalePercent(id);
return { id: String(percent), scale: percent / 100, name: `${percent}`, description: "" };
}
export function applyUiFontScale(raw) {
const percent = normalizeUiFontScalePercent(raw);
const root = document.documentElement;
root.setAttribute("data-ui-font-scale", String(percent));
root.style.setProperty("--ui-font-scale", String(percent / 100));
try {
localStorage.setItem(FONT_SCALE_STORAGE_KEY, String(percent));
} catch {
/* ignore */
}
return percent;
}
export function bumpUiFontScale(deltaSteps) {
const cur = getStoredUiFontScale();
const steps = Number(deltaSteps);
const delta = Number.isFinite(steps) ? steps : 0;
return applyUiFontScale(cur + delta * UI_FONT_SCALE_STEP);
}
export function initUiFontScale() {
return applyUiFontScale(getStoredUiFontScale());
}
/** @deprecated */
export function normalizePresentChrome(id) {
const v = String(id || "").trim();
return CHROME_IDS.has(v) ? v : DEFAULT_CHROME;
}
/** @deprecated */
export function getStoredPresentChrome() {
try {
return normalizePresentChrome(localStorage.getItem(CHROME_STORAGE_KEY));
@@ -89,13 +166,15 @@ export function getStoredPresentChrome() {
}
}
/** @deprecated */
export function chromeById(id) {
const cid = normalizePresentChrome(id);
return PRESENT_CHROMES.find((t) => t.id === cid) || PRESENT_CHROMES[0];
}
export function applyPresentChrome(id) {
const chrome = normalizePresentChrome(id);
/** @deprecated 菜单已移除;调用仍把属性钉为 default,避免旧存档残留 */
export function applyPresentChrome(_id) {
const chrome = DEFAULT_CHROME;
document.documentElement.setAttribute("data-present-chrome", chrome);
try {
localStorage.setItem(CHROME_STORAGE_KEY, chrome);
@@ -111,6 +190,7 @@ export function applyPresentChrome(id) {
return chrome;
}
/** @deprecated */
export function initPresentChrome() {
return applyPresentChrome(getStoredPresentChrome());
return applyPresentChrome(DEFAULT_CHROME);
}