会话与前端接上听书、呈现投影、请求轮廓和旁观气泡偏好。

新建作品可选听书连载,朗读与消费进度走专用接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 23:49:44 +08:00
co-authored by Cursor
parent cf6cb68429
commit 66ed1eaca8
21 changed files with 2395 additions and 357 deletions
+649 -63
View File
File diff suppressed because it is too large Load Diff
+473 -96
View File
@@ -34,6 +34,11 @@ import {
initMarkdownRender,
isMarkdownRenderEnabled,
} from "./markdown.js";
import {
applyPlayAuxLlmBubbles,
initPlayAuxLlmBubbles,
isPlayAuxLlmBubblesEnabled,
} from "./feed-prefs.js";
import {
filterUsedApiProfiles,
forgetApiProfileUsage,
@@ -64,7 +69,35 @@ let pendingComposerDraft = "";
let pendingComposerDraftSeq = 0;
/** 正在提交底栏:重绘时不要把已捕获的正文再塞进 pending */
let skipComposerStash = false;
/** @type {{ level: "root" } | { level: "mode"; mode: string } | { level: "book"; mode: string; bookId: string }} */
let sidebarNav = { level: "root" };
const CREATION_MODE_FOLDERS = [
{ id: "dictate", name: "对话落盘" },
{ id: "listen_serial", name: "听书连载" },
{ id: "recipe", name: "工序编排" },
];
function bookCreationMode(book) {
const m = book?.creationMode;
if (m === "listen_serial" || m === "recipe" || m === "dictate") return m;
return "dictate";
}
function creationModeFolderLabel(modeId) {
return CREATION_MODE_FOLDERS.find((f) => f.id === modeId)?.name ?? modeId;
}
function booksInCreationMode(modeId) {
return books.filter((b) => bookCreationMode(b) === modeId);
}
function visibleCreationModeFolders() {
return CREATION_MODE_FOLDERS.filter((f) => {
if (f.id === "recipe") return booksInCreationMode("recipe").length > 0;
return true;
});
}
let bookSelectMode = false;
let selectedBookIds = new Set();
let activePlaySaveId = null;
@@ -128,7 +161,11 @@ function rememberWorkspaceSession() {
mode,
playSaveId:
mode === "play" ? activePlaySaveId || "play-working" : null,
navLevel: sidebarNav.level === "book" ? "book" : "root",
navLevel: sidebarNav.level,
creationMode:
sidebarNav.level === "mode" || sidebarNav.level === "book"
? sidebarNav.mode
: undefined,
}),
);
} catch {
@@ -796,6 +833,21 @@ async function loadPersonas() {
}
}
/** 换/改用户角色后重拉会话(服务端已按新 @玩家 重展开开场白) */
async function refreshSessionAfterPersonaChange() {
if (!sessionId) {
renderPersonaPicker();
return;
}
try {
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}`);
renderSession(view);
} catch (err) {
console.error(err);
renderPersonaPicker();
}
}
function keepPersonaMenuOpen() {
const menu = $("persona-menu");
if (menu) menu.open = true;
@@ -848,6 +900,7 @@ function wirePersonaMenu() {
});
personaEditingId = null;
await loadPersonas();
await refreshSessionAfterPersonaChange();
keepPersonaMenuOpen();
return;
}
@@ -865,6 +918,7 @@ function wirePersonaMenu() {
});
personaEditingId = null;
await loadPersonas();
await refreshSessionAfterPersonaChange();
keepPersonaMenuOpen();
return;
}
@@ -875,6 +929,7 @@ function wirePersonaMenu() {
});
personaEditingId = null;
await loadPersonas();
await refreshSessionAfterPersonaChange();
keepPersonaMenuOpen();
return;
}
@@ -905,10 +960,7 @@ function wirePersonaMenu() {
}
personaEditingId = null;
if (sessionId) {
const view = await api(
`/api/sessions/${encodeURIComponent(sessionId)}`,
);
renderSession(view);
await refreshSessionAfterPersonaChange();
} else {
renderPersonaPicker();
}
@@ -1086,6 +1138,7 @@ function composerApiSectionHtml() {
function composerMoreMenuHtml() {
const on = isMarkdownRenderEnabled();
const auxOn = isPlayAuxLlmBubblesEnabled();
const wasOpen = $("composer-more-menu")?.hasAttribute("open");
const title = composerMoreMenuTitle();
const showClearDictate =
@@ -1127,6 +1180,19 @@ function composerMoreMenuHtml() {
</span>
<span class="more-toggle-mark" aria-hidden="true"></span>
</button>
<button
type="button"
class="more-menu-item more-toggle-item${auxOn ? " is-active" : ""}"
data-toggle-play-aux-llm
role="menuitemcheckbox"
aria-checked="${auxOn ? "true" : "false"}"
>
<span class="more-theme-copy">
<span class="more-theme-name">非主世界层气泡</span>
<span class="more-theme-desc">旁观等 LLM 产出,便于查上下文与原文</span>
</span>
<span class="more-toggle-mark" aria-hidden="true"></span>
</button>
${clearDictateItem}
</div>
</details>`;
@@ -1236,6 +1302,11 @@ function syncMarkdownMenuUi() {
btn.classList.toggle("is-active", mdOn);
btn.setAttribute("aria-checked", mdOn ? "true" : "false");
});
const auxOn = isPlayAuxLlmBubblesEnabled();
document.querySelectorAll("[data-toggle-play-aux-llm]").forEach((btn) => {
btn.classList.toggle("is-active", auxOn);
btn.setAttribute("aria-checked", auxOn ? "true" : "false");
});
}
function syncThemeMenuUi() {
@@ -1878,7 +1949,8 @@ async function fetchPlaySavesForBook(bookId, force = false) {
}
function setBookSelectMode(on, { keepSelection = false } = {}) {
if (on && sidebarNav.level !== "root") return;
// 多选只在「方式夹内的作品列表」
if (on && sidebarNav.level !== "mode") return;
if (on) hideEnterPicker();
bookSelectMode = on;
if (!on) selectedBookIds = new Set();
@@ -2076,6 +2148,9 @@ function getNavBook() {
}
function catalogBookMeta(book) {
if (bookCreationMode(book) === "listen_serial") {
return "听书";
}
const saves = playSavesByBook.get(book.id);
if (saves === undefined) {
void fetchPlaySavesForBook(book.id);
@@ -2156,7 +2231,14 @@ function renderPathBar() {
if (!bar) return;
bar.innerHTML = "";
const book = getNavBook();
const inBook = Boolean(book);
const modeId =
sidebarNav.level === "mode" || sidebarNav.level === "book"
? sidebarNav.mode
: book
? bookCreationMode(book)
: null;
const inMode = Boolean(modeId);
const inBook = Boolean(book) && sidebarNav.level === "book";
$("rail-tabs")?.toggleAttribute("hidden", !(isWorkspaceNav() || Boolean(activeBookId)));
@@ -2165,12 +2247,24 @@ function renderPathBar() {
list.appendChild(
pathItem({
label: "目录",
title: "作品目录",
current: !inBook,
title: "创作方式",
current: sidebarNav.level === "root",
onClick: () => showCatalog({ closeSession: false }),
}),
);
if (inBook) {
if (inMode && modeId) {
const modeLabel = creationModeFolderLabel(modeId);
list.appendChild(pathSep());
list.appendChild(
pathItem({
label: modeLabel,
title: modeLabel,
current: sidebarNav.level === "mode",
onClick: () => navigateToCreationMode(modeId, { closeSession: false }),
}),
);
}
if (inBook && book) {
const bookLabel = book.title?.trim() || "作品";
list.appendChild(pathSep());
list.appendChild(
@@ -2184,10 +2278,30 @@ function renderPathBar() {
bar.appendChild(list);
}
function navigateToCreationMode(modeId, { closeSession = false } = {}) {
if (!modeId) return;
hideEnterPicker();
if (closeSession) {
beginUiRequest();
stopLivePoll();
closeWorkspace();
}
sidebarNav = { level: "mode", mode: modeId };
document.body.dataset.navLevel = "mode";
bookSelectMode = false;
selectedBookIds = new Set();
setRailTab("books", { expand: true });
renderPathBar();
renderBookList();
if (!closeSession) rememberWorkspaceSession();
}
function navigateToBook(bookId) {
if (!bookId) return;
hideEnterPicker();
sidebarNav = { level: "book", bookId };
const book = books.find((b) => b.id === bookId);
const mode = bookCreationMode(book);
sidebarNav = { level: "book", mode, bookId };
document.body.dataset.navLevel = "book";
bookSelectMode = false;
selectedBookIds = new Set();
@@ -2222,6 +2336,7 @@ function renderExplorerRow({
actionClass,
onClick,
onDelete,
onAdd,
onRename,
onContextMenu,
child,
@@ -2248,20 +2363,26 @@ function renderExplorerRow({
if (typeof ariaExpanded === "boolean") {
row.setAttribute("aria-expanded", ariaExpanded ? "true" : "false");
}
const addHtml = onAdd
? `<span class="explorer-add" role="button" tabindex="-1" title="${esc(onAdd.title || "新建")}" aria-label="${esc(onAdd.label || "新建")}">+</span>`
: "";
const delHtml = onDelete
? `<span class="explorer-del" role="button" tabindex="-1" aria-label="删除">×</span>`
: "";
row.innerHTML = `
${twistie ? `<span class="explorer-twist" data-twist>${expanded ? "▾" : "▸"}</span>` : ""}
<span class="explorer-main">
<span class="explorer-name" title="${esc(name)}">${esc(name)}</span>
${meta ? `<span class="explorer-meta" title="${esc(meta)}">${esc(meta)}</span>` : ""}
</span>
${onDelete ? `<span class="explorer-del" role="button" tabindex="-1" aria-label="删除">×</span>` : ""}`;
${addHtml || delHtml ? `<span class="explorer-actions">${addHtml}${delHtml}</span>` : ""}`;
row.addEventListener("click", (e) => {
if (e.target.closest(".explorer-del") || e.target.closest("[data-twist]")) return;
if (e.target.closest(".explorer-actions") || 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;
if (e.target.closest(".explorer-actions") || e.target.closest("[data-twist]")) return;
e.preventDefault();
onRename();
});
@@ -2270,6 +2391,10 @@ function renderExplorerRow({
e.stopPropagation();
(onTwistie || onClick)?.();
});
row.querySelector(".explorer-add")?.addEventListener("click", (e) => {
e.stopPropagation();
onAdd?.onClick?.();
});
row.querySelector(".explorer-del")?.addEventListener("click", (e) => {
e.stopPropagation();
onDelete?.();
@@ -2337,17 +2462,6 @@ function renderSpecChildren(book, product, list) {
}
}
function appendNewPlayFromSnapshot(book, list, instanceId) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "explorer-add child";
btn.textContent = "+";
btn.title = "从这份创作快照新开一局";
btn.setAttribute("aria-label", "新开一局");
btn.addEventListener("click", () => void startNewPlayForBook(book.id, instanceId));
list.appendChild(btn);
}
function appendCurrentDesignRow(book, list) {
const live =
book.id === activeBookId && Boolean(sessionId) && !isPlayNow();
@@ -2404,12 +2518,16 @@ function renderBookContents(book, list) {
onTwistie: () => toggleSpecExpanded(book.id, product.id),
onRename: () => void renamePlaySave(book.id, product.id, product.label),
onDelete: () => void deletePlaySave(book.id, product.id, product.label, "规格"),
onAdd: {
label: "新开一局",
title: "从这份创作快照新开一局",
onClick: () => void startNewPlayForBook(book.id, product.id),
},
onContextMenu: (e) =>
showSaveMenu(book.id, product.id, product.label, e.clientX, e.clientY),
}),
);
if (expanded) renderSpecChildren(book, product, list);
appendNewPlayFromSnapshot(book, list, product.id);
}
}
@@ -2417,11 +2535,6 @@ function renderBookList() {
const list = $("book-list");
if (!list) return;
renderPathBar();
if (!books.length) {
list.innerHTML = `<p class="sidebar-empty">暂无作品<br><button type="button" class="btn-sm" id="btn-new-inline">+ 新建</button></p>`;
$("btn-new-inline")?.addEventListener("click", openNewBookDialog);
return;
}
list.innerHTML = "";
if (sidebarNav.level === "book") {
@@ -2430,13 +2543,46 @@ function renderBookList() {
renderBookContents(book, list);
return;
}
sidebarNav = { level: "root" };
document.body.dataset.navLevel = "root";
const fallbackMode = sidebarNav.mode || "dictate";
sidebarNav = { level: "mode", mode: fallbackMode };
document.body.dataset.navLevel = "mode";
renderPathBar();
}
for (const book of books) {
if (sidebarNav.level === "root") {
for (const folder of visibleCreationModeFolders()) {
const count = booksInCreationMode(folder.id).length;
list.appendChild(
renderExplorerRow({
name: folder.name,
meta: count ? `${count} 部` : "空",
active: false,
selected: false,
actionClass: "explorer-folder",
onClick: () => navigateToCreationMode(folder.id),
}),
);
}
return;
}
// mode:该方式下的作品
const modeId = sidebarNav.mode || "dictate";
const modeBooks = booksInCreationMode(modeId);
if (!modeBooks.length) {
const empty = document.createElement("p");
empty.className = "sidebar-empty";
empty.innerHTML = `此方式下暂无作品<br><button type="button" class="btn-sm" id="btn-new-inline">+ 新建</button>`;
list.appendChild(empty);
$("btn-new-inline")?.addEventListener("click", () => {
openNewBookDialog({ presetMode: modeId === "recipe" ? "dictate" : modeId });
});
return;
}
for (const book of modeBooks) {
const selected = selectedBookIds.has(book.id);
const listen = bookCreationMode(book) === "listen_serial";
list.appendChild(
renderExplorerRow({
name: book.title,
@@ -2451,6 +2597,10 @@ function renderBookList() {
return;
}
e.stopPropagation();
if (listen) {
void openBook(book.id);
return;
}
if (enterPickerBookId === book.id) {
hideEnterPicker();
return;
@@ -2460,7 +2610,7 @@ function renderBookList() {
}),
);
}
if (enterPickerBookId && sidebarNav.level === "root") {
if (enterPickerBookId && sidebarNav.level === "mode") {
const row = list.querySelector(`[data-book-id="${enterPickerBookId}"]`);
if (row) showEnterPicker(enterPickerBookId, row);
else hideEnterPicker();
@@ -2503,6 +2653,7 @@ async function duplicateBookById(bookId, title) {
title: data.book.title,
activeSkillId: data.book.activeSkillId ?? data.book.orchestratorId,
activeSkillName: data.book.activeSkillName ?? data.book.orchestratorName,
creationMode: bookCreationMode(data.book),
preview: data.book.preview,
updatedAt: data.book.updatedAt,
orchestratorId: data.book.orchestratorId,
@@ -2572,7 +2723,7 @@ function setupBookBoxSelect() {
};
list.addEventListener("mousedown", (e) => {
if (sidebarNav.level !== "root" || e.button !== 0) return;
if (sidebarNav.level !== "mode" || e.button !== 0) return;
if (e.target.closest("#book-action-menu")) return;
if (e.target.closest(".explorer-row")) return;
dragging = true;
@@ -2855,7 +3006,7 @@ function renderEmpty() {
document.body.dataset.navLevel = "root";
bookSelectMode = false;
selectedBookIds = new Set();
closeWorkspace({ emptyCopy: "点击左侧 + 新建作品" });
closeWorkspace({ emptyCopy: "先选左侧创作方式,再新建作品" });
renderBookList();
}
@@ -3103,6 +3254,10 @@ function renderSession(view, loading = false) {
const idx = lastView?.playAux?.selectedOpeningIndex ?? 0;
void selectPlayOpening(idx, false);
},
getListenSerial: () => lastView?.listenSerial,
onListenConsume: (consumedChars) => void markListenConsumed(consumedChars),
onListenPlay: (sectionId, opts) => void playListenSection(sectionId, opts),
onListenStop: () => stopListenPlayback(),
});
renderComposer(view, loading);
rememberWorkspaceSession();
@@ -3487,14 +3642,53 @@ async function loadBooks() {
renderBookList();
}
function openNewBookDialog() {
function resolveNewBookCreationMode(opts = {}) {
if (opts.presetMode === "listen_serial" || opts.presetMode === "dictate") {
return opts.presetMode;
}
if (sidebarNav.level === "mode" || sidebarNav.level === "book") {
const m = sidebarNav.mode;
if (m === "listen_serial") return "listen_serial";
if (m === "dictate" || m === "recipe") return "dictate";
}
return null;
}
function openNewBookDialog(opts = {}) {
$("input-book-title").value = "";
const locked = resolveNewBookCreationMode(opts);
const modeField = $("field-creation-mode");
const modeSel = $("input-creation-mode");
const lockedInput = $("input-creation-mode-locked");
const desc = $("dialog-new-book-desc");
if (locked) {
if (modeField) modeField.hidden = true;
if (lockedInput) lockedInput.value = locked;
if (modeSel) modeSel.value = locked;
if (desc) {
desc.textContent = `将创建到「${creationModeFolderLabel(locked)}」,填写名称即可。`;
}
} else {
// 仍在目录根:须先选方式,或临时露出下拉
if (modeField) modeField.hidden = false;
if (lockedInput) lockedInput.value = "";
if (modeSel) modeSel.value = "dictate";
if (desc) desc.textContent = "先选创作方式,再填写名称。";
}
$("dialog-new-book").showModal();
$("input-book-title").focus();
}
async function createBook() {
const title = $("input-book-title").value.trim() || "未命名作品";
const locked = ($("input-creation-mode-locked")?.value || "").trim();
const creationMode =
locked === "listen_serial" ||
$("input-creation-mode")?.value === "listen_serial"
? "listen_serial"
: "dictate";
$("btn-create-book").disabled = true;
try {
const data = await api("/api/books", {
@@ -3502,15 +3696,20 @@ async function createBook() {
body: JSON.stringify({
title,
orchestratorId: "world-simulator",
creationMode: "dictate",
creationMode,
}),
});
$("dialog-new-book").close();
if (data.book) books.unshift(data.book);
activeBookId = data.book?.id;
sidebarNav = { level: "root" };
document.body.dataset.navLevel = "root";
sidebarNav = {
level: "book",
mode: creationMode,
bookId: data.book?.id,
};
document.body.dataset.navLevel = "book";
renderSession(data.session, false);
renderBookList();
} catch (err) {
alert(err.message);
} finally {
@@ -3520,6 +3719,7 @@ async function createBook() {
async function openBook(bookId) {
const seq = beginUiRequest();
navigateToBook(bookId);
activeBookId = bookId;
if (lastView?.bookId === bookId) renderSession(lastView, true);
try {
@@ -3549,8 +3749,11 @@ async function deleteBookById(bookId) {
if (activeBookId === bookId) activePlaySaveId = null;
hideBookMenu();
const wasHere = activeBookId === bookId || sidebarNav.bookId === bookId;
const mode = bookCreationMode(book);
if (wasHere) {
if (books.length) showCatalog();
if (booksInCreationMode(mode).length) {
navigateToCreationMode(mode, { closeSession: true });
} else if (books.length) showCatalog();
else renderEmpty();
} else {
renderBookList();
@@ -3586,6 +3789,113 @@ function defaultArchiveLabel() {
);
}
async function markListenConsumed(consumedChars) {
if (!sessionId) return;
try {
const view = await api(
`/api/sessions/${encodeURIComponent(sessionId)}/listen/consume`,
{
method: "POST",
body: JSON.stringify({ consumedChars }),
},
);
renderSession(view, false);
} catch (err) {
alert(err.message || "更新阅读进度失败");
}
}
/** @type {HTMLAudioElement | null} */
let listenAudioEl = null;
let listenPlayToken = 0;
function stopListenPlayback() {
listenPlayToken += 1;
if (listenAudioEl) {
listenAudioEl.pause();
listenAudioEl.removeAttribute("src");
listenAudioEl.load();
listenAudioEl = null;
}
document
.querySelectorAll(".listen-section.is-playing")
.forEach((el) => el.classList.remove("is-playing"));
}
function listenTtsUrl(sectionId) {
return `/api/sessions/${encodeURIComponent(sessionId)}/listen/tts?sectionId=${encodeURIComponent(sectionId)}`;
}
function markListenSectionPlaying(sectionId) {
document
.querySelectorAll(".listen-section.is-playing")
.forEach((el) => el.classList.remove("is-playing"));
const el = document.querySelector(
`.listen-section[data-section-id="${CSS.escape(sectionId)}"]`,
);
if (el) {
el.classList.add("is-playing");
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}
/**
* @param {string} sectionId
* @param {{ continuous?: boolean }} [opts]
*/
async function playListenSection(sectionId, opts = {}) {
if (!sessionId || !sectionId) return;
const continuous = Boolean(opts.continuous);
stopListenPlayback();
const token = listenPlayToken;
const sections = lastView?.listenSerial?.sections ?? [];
let index = sections.findIndex((s) => s.id === sectionId);
if (index < 0) {
alert("未找到该节");
return;
}
const playOne = (id) =>
new Promise((resolve, reject) => {
if (token !== listenPlayToken) {
resolve("stopped");
return;
}
markListenSectionPlaying(id);
const audio = new Audio(listenTtsUrl(id));
listenAudioEl = audio;
audio.onended = () => resolve("ended");
audio.onerror = () => reject(new Error("播放失败(合成或网络错误)"));
void audio.play().catch((err) => reject(err));
});
try {
while (index >= 0 && index < sections.length) {
if (token !== listenPlayToken) return;
const cur = sections[index];
const result = await playOne(cur.id);
if (result !== "ended" || token !== listenPlayToken) return;
// 播完一节:把消费指针推到本节末尾
let endChars = 0;
for (let i = 0; i <= index; i += 1) {
endChars += String(sections[i]?.text || "").length;
}
await markListenConsumed(endChars);
if (!continuous) return;
index += 1;
}
} catch (err) {
if (token === listenPlayToken) {
alert(err?.message || "朗读失败");
}
} finally {
if (token === listenPlayToken) stopListenPlayback();
}
}
async function saveCurrentInstance() {
if (!activeBookId || (!lastView?.playReady && !canOfferSaveProduct(lastView))) {
return alert(
@@ -3629,6 +3939,49 @@ async function showStepArtifactDialog(stepId, name) {
}
}
function firstCacheBreak(prev, next) {
const shared = Math.min(prev.length, next.length);
for (let i = 0; i < shared; i += 1) {
if (prev[i]?.tag !== next[i]?.tag || prev[i]?.hash !== next[i]?.hash) return i;
}
if (next.length > prev.length) return prev.length;
return -1;
}
function formatOutline(outline, prev, index, total) {
const segments = outline.segments || [];
const breakAt = prev ? firstCacheBreak(prev, segments) : null;
const lines = segments.map((seg, i) => {
let mark = " ";
if (breakAt != null) {
if (breakAt < 0 || i < breakAt) mark = "已接上";
else if (i === breakAt) mark = "断开 ";
else mark = "之后 ";
}
const tag = seg.tag === "工具" ? `${seg.tag}〔tools 字段,不在消息正文〕` : seg.tag;
return `${String(i + 1).padStart(2, "0")} ${mark} ${tag} ${Number(seg.chars || 0).toLocaleString("zh-CN")}字 ${seg.hash}`;
});
let note = "这条消息里没有更早的请求可比。跨轮断点在用量页,按同一会话往上一条看。";
if (breakAt != null && breakAt < 0) {
note = "与上一次请求的前缀整段相同。";
} else if (breakAt != null) {
const tag = segments[breakAt]?.tag || "(上一条更长,本条到此结束)";
note = `前 ${breakAt} 段与上一次请求相同,从「${tag}」起前缀不再一致。`;
}
const cached = Number(outline.cachedTokens ?? 0);
if (breakAt != null && cached === 0 && (breakAt < 0 || breakAt > 0)) {
note += " 账单缓存是 0:前面的标签对得上,但上游没有算进缓存。";
}
const step = outline.step ? ` · ${outline.step}` : "";
return [
`======== ${index}/${total}${step} · ${outline.caller || "unknown"} · ${outline.model || "model?"} ========`,
`prompt ${outline.promptTokens ?? "—"} · 缓存 ${outline.cachedTokens ?? "—"} · 未缓存 ${outline.cacheMissTokens ?? "—"} · 写出 ${outline.completionTokens ?? "—"}`,
note,
"",
...lines,
].join("\n");
}
function showContextTraceDialog(messageId) {
const msg = (lastView?.messages ?? []).find((m) => m.id === messageId);
const review = lastView?.reviewArtifact;
@@ -3642,59 +3995,63 @@ function showContextTraceDialog(messageId) {
const meta = $("context-trace-meta");
const body = $("context-trace-body");
if (!dlg || !meta || !body) return;
if (!trace?.messages?.length) {
alert("这条消息没有保存请求上下文(可能已被修剪,或当时未开启保存)");
const subject = msg || review || {};
const stored = msg?.requestOutlines?.length
? msg.requestOutlines
: trace?.segments?.length
? [{
at: trace.createdAt,
caller: trace.caller,
model: trace.model,
segments: trace.segments,
promptTokens: subject.tokenUsage?.promptTokens,
completionTokens: subject.tokenUsage?.completionTokens,
cachedTokens: subject.tokenUsage?.cachedTokens,
cacheMissTokens: subject.tokenUsage?.cacheMissTokens,
}]
: [];
const full = formatTraceMessages(trace);
if (!stored.length && !full) {
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}`;
let prev = null;
const blocks = stored.map((outline, index) => {
const text = formatOutline(outline, prev, index + 1, stored.length);
prev = outline.segments || [];
return text;
});
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`
: "";
const calls = stored.length;
const segments = stored.reduce((sum, item) => sum + (item.segments?.length || 0), 0);
const title = $("context-trace-title");
if (title) title.textContent = full ? "完整上下文" : "请求分段";
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");
subject.actor || stored[0]?.caller || trace?.caller || "unknown",
`${calls} 次请求`,
`${segments} 段`,
full ? "下方是发给模型的消息" : "这次只留下分段标签",
].join(" · ");
const outline = $("context-trace-outline");
const messages = $("context-trace-messages");
if (outline) outline.textContent = blocks.join("\n\n");
body.textContent = full;
if (messages) messages.hidden = !full;
dlg.showModal();
const scroll = $("context-trace-scroll");
if (scroll) scroll.scrollTop = 0;
}
function formatTraceMessages(trace) {
const messages = trace?.messages;
if (!Array.isArray(messages) || !messages.length) return "";
return messages
.map((message, index) => {
const role = message?.role || "message";
const content = typeof message?.content === "string" ? message.content : "";
return `----- ${index + 1}. ${role} -----\n${content}`;
})
.join("\n\n");
}
let comparePickerMessageId = null;
@@ -3896,9 +4253,14 @@ $("compare-results")?.addEventListener("click", (e) => {
});
$("btn-copy-context")?.addEventListener("click", async () => {
const outline = $("context-trace-outline")?.textContent ?? "";
const body = $("context-trace-body")?.textContent ?? "";
const text = [
outline ? `【系统信息 · 对照给你看,不发给模型】\n${outline}` : "",
body ? `【发给模型的消息】\n${body}` : "",
].filter(Boolean).join("\n\n");
try {
await navigator.clipboard.writeText(body);
await navigator.clipboard.writeText(text);
} catch {
alert("复制失败");
}
@@ -3915,7 +4277,14 @@ $("lifecycle-toggle")?.addEventListener("click", (e) => {
if (stage === "play") enterPlayNow();
else if (stage === "design") void setLifecycle("design");
});
$("btn-new-book")?.addEventListener("click", openNewBookDialog);
$("btn-new-book")?.addEventListener("click", () => {
const locked = resolveNewBookCreationMode();
if (!locked && sidebarNav.level === "root") {
alert("请先进入「对话落盘」或「听书连载」文件夹,再新建作品。");
return;
}
openNewBookDialog(locked ? { presetMode: locked } : {});
});
$("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close());
$("book-enter-pop")?.addEventListener("click", (e) => {
e.stopPropagation();
@@ -4124,6 +4493,14 @@ $("composer")?.addEventListener("click", (e) => {
if (lastView && sessionId) renderSession(lastView, false);
return;
}
const auxBtn = e.target.closest("[data-toggle-play-aux-llm]");
if (auxBtn && menu.contains(auxBtn)) {
e.preventDefault();
applyPlayAuxLlmBubbles(!isPlayAuxLlmBubblesEnabled());
syncMarkdownMenuUi();
if (lastView && sessionId) renderSession(lastView, false);
return;
}
const reloadBtn = e.target.closest("[data-reload-profiles]");
if (reloadBtn && menu.contains(reloadBtn)) {
e.preventDefault();
@@ -4197,11 +4574,11 @@ async function restoreWorkspaceSession() {
: null;
if (saved.navLevel === "book") {
navigateToBook(bookId);
} else if (saved.navLevel === "mode" && saved.creationMode) {
navigateToCreationMode(saved.creationMode, { closeSession: false });
} else {
sidebarNav = { level: "root" };
document.body.dataset.navLevel = "root";
renderPathBar();
renderBookList();
const mode = bookCreationMode(books.find((b) => b.id === bookId));
navigateToCreationMode(mode, { closeSession: false });
}
if (saved.sessionId) {
@@ -4243,6 +4620,7 @@ async function init() {
initColorTheme();
initUiFontScale();
initMarkdownRender();
initPlayAuxLlmBubbles();
syncThemeMenuUi();
try {
await Promise.all([loadBooks(), loadPersonas(), loadApiProfiles()]);
@@ -4251,7 +4629,6 @@ async function init() {
if (!restored) showCatalog();
} else {
renderEmpty();
openNewBookDialog();
}
} catch {
$("status-text").textContent = "加载失败";
+1
View File
@@ -60,6 +60,7 @@ export function displayCreationModeLabel(id) {
const map = {
recipe: "工序编排",
dictate: "对话落盘",
listen_serial: "听书连载",
};
return map[id] ?? id;
}
+95
View File
@@ -0,0 +1,95 @@
/** 游玩主列:非主世界层 LLM 气泡(旁观 / 其它执行单元)显示偏好 */
const AUX_LLM_STORAGE_KEY = "wa-play-aux-llm-bubbles";
const AUX_LLM_ATTR = "data-play-aux-llm-bubbles";
/** @type {boolean | null} */
let auxLlmMemory = null;
export function getStoredPlayAuxLlmBubbles() {
if (auxLlmMemory != null) return auxLlmMemory;
try {
return localStorage.getItem(AUX_LLM_STORAGE_KEY) === "1";
} catch {
return false;
}
}
export function isPlayAuxLlmBubblesEnabled() {
if (auxLlmMemory != null) return auxLlmMemory;
if (typeof document !== "undefined") {
const attr = document.documentElement?.getAttribute(AUX_LLM_ATTR);
if (attr === "1" || attr === "0") return attr === "1";
}
return getStoredPlayAuxLlmBubbles();
}
export function applyPlayAuxLlmBubbles(enabled) {
const on = Boolean(enabled);
auxLlmMemory = on;
if (typeof document !== "undefined") {
document.documentElement?.setAttribute(AUX_LLM_ATTR, on ? "1" : "0");
}
try {
localStorage.setItem(AUX_LLM_STORAGE_KEY, on ? "1" : "0");
} catch {
/* ignore */
}
return on;
}
export function initPlayAuxLlmBubbles() {
auxLlmMemory = null;
return applyPlayAuxLlmBubbles(getStoredPlayAuxLlmBubbles());
}
function isPlayView(view) {
return Boolean(view?.playLayerActive || view?.lifecycleStage === "play");
}
function isPlayHousekeepingMessage(msg) {
const title = String(msg?.title ?? "");
const text = String(msg?.text ?? msg?.body ?? "").trim();
if (title === "上下文已压缩" || title === "阶段机") return true;
if (/^\[上下文已压缩\]/.test(text)) return true;
if (/^\[裁决合并\]/.test(text)) return true;
if (/^\[旁观维护\]/.test(text)) return true;
if (/归档\s*\d+\s*个过程 tag/.test(text)) return true;
return false;
}
function shouldRenderPlayPresent(msg, view) {
if (!isPlayView(view)) return false;
if (msg?.role === "user") return false;
const kind = msg?.kind ?? "";
if (kind === "worker_questions" || kind === "orchestrator_thinking") return false;
if (isPlayHousekeepingMessage(msg)) return false;
const actor = String(msg?.actor ?? "");
const title = String(msg?.title ?? "");
return (
actor === "narrator" ||
actor === "round-present" ||
actor === "world-simulator" ||
/用户展示|开场白/.test(title)
);
}
function isMainWorldPlayActor(actor) {
const id = String(actor ?? "").trim();
return id === "world-simulator" || id === "gm";
}
/** 主世界层以外的游玩 LLM 产出(旁观维护等),可进主列气泡查上下文 / 原文 */
export function isPlayAuxLlmOutput(msg, view) {
if (!isPlayView(view) || msg?.role === "user") return false;
if (isPlayHousekeepingMessage(msg)) return false;
if (shouldRenderPlayPresent(msg, view)) return false;
const kind = msg?.kind ?? "";
if (kind === "worker_output") {
return !isMainWorldPlayActor(msg.actor);
}
if (msg?.contextTrace || msg?.requestOutlines?.length) {
return !isMainWorldPlayActor(msg.actor);
}
return false;
}
+24 -5
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?v=20260930-present-preview" />
<link rel="stylesheet" href="/styles.css?v=20261002-context-system" />
<script>
(function () {
try {
@@ -196,11 +196,19 @@
<dialog id="dialog-new-book" class="dialog">
<form method="dialog" id="form-new-book">
<h2>新建作品</h2>
<p class="dialog-desc">创建后直接说你想写什么;助手会在对话中判断合适的创作路径。</p>
<p class="dialog-desc" id="dialog-new-book-desc">填写名称即可创建。</p>
<label class="field">
名称
<input id="input-book-title" type="text" placeholder="未命名作品" maxlength="80" autofocus />
</label>
<label class="field" id="field-creation-mode" hidden>
创作方式
<select id="input-creation-mode">
<option value="dictate" selected>对话落盘(可玩规格)</option>
<option value="listen_serial">听书连载(连续消费)</option>
</select>
</label>
<input type="hidden" id="input-creation-mode-locked" value="" />
<div class="dialog-actions">
<button type="button" class="btn" value="cancel" id="btn-cancel-new">取消</button>
<button type="submit" class="btn btn-primary" id="btn-create-book">创建</button>
@@ -235,6 +243,7 @@
</menu>
<menu id="msg-action-menu" class="context-menu" hidden>
<button type="button" class="context-menu-item" id="msg-menu-raw" data-msg-menu-action="view-raw">查看原文</button>
<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>
<button type="button" class="context-menu-item" id="msg-menu-edit" data-msg-menu-action="edit">编辑</button>
@@ -291,9 +300,19 @@
<dialog id="dialog-context-trace" class="dialog dialog-wide" aria-labelledby="context-trace-title">
<form method="dialog" class="context-trace-form">
<h2 id="context-trace-title">上下文调用日志</h2>
<h2 id="context-trace-title">请求分段</h2>
<p class="dialog-desc" id="context-trace-meta"></p>
<pre id="context-trace-body" class="context-trace-body"></pre>
<div class="context-trace-scroll" id="context-trace-scroll">
<section class="context-trace-system" id="context-trace-system">
<p class="context-trace-kicker">系统信息</p>
<p class="dialog-desc" id="context-trace-system-note">用量、断点、分段标签和哈希只给你对照,这段文字本身不会发给模型。「工具」是单独的 tools 字段,不在消息正文里。</p>
<pre id="context-trace-outline" class="context-trace-body context-trace-outline"></pre>
</section>
<section id="context-trace-messages">
<p class="context-trace-kicker">发给模型的消息</p>
<pre id="context-trace-body" class="context-trace-body"></pre>
</section>
</div>
<div class="dialog-actions">
<button type="button" class="btn" id="btn-copy-context">复制全文</button>
<button type="submit" class="btn btn-primary" value="close">关闭</button>
@@ -324,6 +343,6 @@
</div>
</dialog>
<script type="module" src="/app.js?v=20260928-session-restore"></script>
<script type="module" src="/app.js?v=20261007-view-raw"></script>
</body>
</html>
+68 -2
View File
@@ -30,11 +30,48 @@ html[data-embed="1"] .st-main {
overflow: hidden;
}
.mod-pack {
.mod-count {
margin: 0 8px 10px;
font-size: 12px;
color: var(--st-muted);
word-break: break-all;
}
.mod-group {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0 0 12px;
}
.mod-group-label {
margin: 8px 8px 0;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.04em;
color: var(--st-faint);
}
.mod-group-hint {
margin: 2px 8px 6px;
font-size: 11px;
line-height: 1.4;
color: var(--st-faint);
}
.mod-item-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.mod-tag {
font-size: 10px;
line-height: 1.4;
padding: 0 6px;
border-radius: 4px;
border: 1px solid var(--st-border);
color: var(--st-muted);
}
.mod-filter {
@@ -78,6 +115,15 @@ html[data-embed="1"] .st-main {
border-color: transparent;
}
.mod-item:focus-visible,
.mod-chip:focus-visible,
.mod-raw-toggle:focus-visible,
.mod-section-nav a:focus-visible,
.mod-filter input:focus-visible {
outline: 2px solid var(--st-accent);
outline-offset: 2px;
}
.mod-list {
display: flex;
flex-direction: column;
@@ -192,6 +238,26 @@ html[data-embed="1"] .st-main {
.mod-read > .st-status {
margin: 16px 28px 0;
flex-direction: column;
align-items: stretch;
gap: 0;
}
.mod-meta-row + .mod-meta-row {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--st-border);
}
.mod-meta-row strong {
display: block;
margin-bottom: 4px;
font-size: 12px;
}
.mod-meta-row p {
margin: 0;
line-height: 1.5;
}
.mod-read > .st-body {
+7 -7
View File
@@ -12,7 +12,7 @@
} catch (e) {}
})();
</script>
<title>技能池 · Writing Agent</title>
<title>节点 · Writing Agent</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
@@ -38,8 +38,8 @@
<div class="st-shell mod-shell">
<aside class="st-rail mod-rail" aria-label="技能列表">
<p class="st-rail-label">备选技能</p>
<p class="mod-pack" id="pack-label">加载中…</p>
<p class="st-rail-label">节点</p>
<p class="mod-count" id="pack-label">加载中…</p>
<div class="mod-filter">
<input
type="search"
@@ -56,15 +56,15 @@
</div>
<nav class="mod-list" id="mod-list"></nav>
<p class="st-rail-note">
创作助手根据本局设计清单从这里取用能力;本页只读浏览方法正文。
创作按本局方案从这些节点取方法。普通楼层和 0 层卡各用自己的呈现链;落档程序在开玩前由程序编译。本页只读。
</p>
</aside>
<main class="st-main mod-main">
<header class="st-main-head">
<div>
<h1 id="panel-title">技能池</h1>
<p class="st-sub" id="panel-subtitle">选择左侧一项查看方法块</p>
<h1 id="panel-title">节点</h1>
<p class="st-sub" id="panel-subtitle">选择左侧一项查看方法</p>
</div>
<div class="st-main-actions">
<span class="mod-badge" id="status-badge" hidden></span>
@@ -76,7 +76,7 @@
<div class="mod-read">
<div class="st-status" id="meta-bar" hidden></div>
<div class="st-body mod-body" id="mod-detail">
<p class="mod-empty">从左侧选择一项技能</p>
<p class="mod-empty">从左侧选择一个节点</p>
</div>
</div>
</main>
+103 -19
View File
@@ -5,6 +5,7 @@ const SECTION_LABELS = {
principles: "原则",
probe: "追问",
output: "产物形状",
score: "评分",
checklist: "自检",
examples: "示例",
};
@@ -16,6 +17,7 @@ const SECTION_ORDER = [
"principles",
"probe",
"output",
"score",
"checklist",
"examples",
];
@@ -27,8 +29,20 @@ const STATUS_LABELS = {
missing: "缺文件",
};
const PRESENT_LABELS = {
message: "普通楼层",
zero_layer: "0 层卡",
};
const LAYER_LABELS = {
intermediate: "中间产物",
final: "最终产物",
};
/** @type {Array<any>} */
let modulesCache = [];
/** @type {Array<{id: string, label: string, hint: string}>} */
let groupsCache = [];
let activeId = "";
let statusFilter = "";
let searchQuery = "";
@@ -62,23 +76,64 @@ function filteredModules() {
});
}
function rowTags(module) {
const tags = [];
const mode = PRESENT_LABELS[module.presentMode];
if (mode) tags.push(mode);
if (module.repeatable) tags.push("可反复");
if (module.closer) tags.push("收口");
if (module.priorArtifact) tags.push("先验产物");
return tags;
}
function renderList() {
const list = document.getElementById("mod-list");
const items = filteredModules();
const count = document.getElementById("pack-label");
count.textContent =
items.length === modulesCache.length
? `${modulesCache.length} 个节点`
: `显示 ${items.length} / ${modulesCache.length}`;
if (items.length === 0) {
list.innerHTML = `<p class="mod-empty" style="padding:8px">无匹配项</p>`;
list.innerHTML = `<p class="mod-empty">没有符合条件的节点</p>`;
return;
}
list.innerHTML = items
.map(
(m) => `<button type="button" class="mod-item${m.id === activeId ? " active" : ""}" data-id="${escapeHtml(m.id)}">
const byGroup = new Map(groupsCache.map((group) => [group.id, []]));
for (const module of items) {
const bucket = byGroup.get(module.group);
if (bucket) bucket.push(module);
}
list.innerHTML = groupsCache
.map((group) => {
const rows = byGroup.get(group.id) ?? [];
if (rows.length === 0) return "";
const buttons = rows
.map((module) => {
const tags = rowTags(module);
const tagHtml = tags.length
? `<span class="mod-item-tags">${tags
.map((tag) => `<span class="mod-tag">${escapeHtml(tag)}</span>`)
.join("")}</span>`
: "";
return `<button type="button" class="mod-item${module.id === activeId ? " active" : ""}" data-id="${escapeHtml(module.id)}">
<span class="mod-item-name">
<span>${escapeHtml(m.name)}</span>
<span class="mod-dot ${escapeHtml(m.status)}" title="${escapeHtml(STATUS_LABELS[m.status] ?? m.status)}"></span>
<span>${escapeHtml(module.name)}</span>
<span class="mod-dot ${escapeHtml(module.status)}" title="${escapeHtml(STATUS_LABELS[module.status] ?? module.status)}"></span>
</span>
<p class="mod-item-decl">${escapeHtml(m.declaration)}</p>
</button>`,
)
<p class="mod-item-decl">${escapeHtml(module.declaration)}</p>
${tagHtml}
</button>`;
})
.join("");
return `<section class="mod-group" aria-labelledby="grp-${escapeHtml(group.id)}">
<h2 class="mod-group-label" id="grp-${escapeHtml(group.id)}">${escapeHtml(group.label)}</h2>
<p class="mod-group-hint">${escapeHtml(group.hint)}</p>
${buttons}
</section>`;
})
.join("");
}
@@ -88,6 +143,10 @@ function metaField(meta, key) {
return typeof v === "string" ? v.trim() : String(v);
}
function groupLabel(id) {
return groupsCache.find((group) => group.id === id)?.label ?? "";
}
function renderDetail(detail) {
const title = document.getElementById("panel-title");
const sub = document.getElementById("panel-subtitle");
@@ -96,7 +155,8 @@ function renderDetail(detail) {
const host = document.getElementById("mod-detail");
title.textContent = detail.name;
sub.textContent = `${detail.id} · ${detail.artifact}`;
const group = groupLabel(detail.group);
sub.textContent = group ? `${group} · ${detail.artifact}` : detail.artifact;
badge.hidden = false;
badge.className = `mod-badge ${detail.status}`;
@@ -108,10 +168,15 @@ function renderDetail(detail) {
metaBar.hidden = !(when || whenNot || boundary);
if (!metaBar.hidden) {
const bits = [];
if (when) bits.push(`<strong>何时选用</strong> ${escapeHtml(when).replace(/\n/g, "<br>")}`);
if (whenNot) bits.push(`<strong>何时不选</strong> ${escapeHtml(whenNot).replace(/\n/g, "<br>")}`);
if (boundary) bits.push(`<strong>边界</strong> ${escapeHtml(boundary).replace(/\n/g, "<br>")}`);
metaBar.innerHTML = bits.join("<hr style='border:none;border-top:1px solid var(--st-border);margin:10px 0'>");
if (when) bits.push(["何时选用", when]);
if (whenNot) bits.push(["何时不选", whenNot]);
if (boundary) bits.push(["边界", boundary]);
metaBar.innerHTML = bits
.map(
([label, text]) =>
`<div class="mod-meta-row"><strong>${escapeHtml(label)}</strong><p>${escapeHtml(text).replace(/\n/g, "<br>")}</p></div>`,
)
.join("");
}
const present = SECTION_ORDER.filter((id) => detail.sections?.[id]);
@@ -135,12 +200,28 @@ function renderDetail(detail) {
})
.join("");
const facts = [
["产物", `<code>${escapeHtml(detail.artifact)}</code>`],
["节点", `<code>${escapeHtml(detail.id)}</code>`],
];
if (LAYER_LABELS[detail.layer]) facts.push(["产物层", escapeHtml(LAYER_LABELS[detail.layer])]);
const tags = rowTags(detail);
if (detail.programSide) tags.push("程序编译");
if (tags.length) facts.push(["标记", escapeHtml(tags.join(" · "))]);
if (detail.libraries?.length) {
facts.push(["绑定库", escapeHtml(detail.libraries.join("、"))]);
}
if (detail.land) facts.push(["落盘", escapeHtml(detail.land)]);
facts.push([
"已有块",
escapeHtml(present.map((id) => SECTION_LABELS[id] ?? id).join(" · ") || "—"),
]);
host.innerHTML = `
<div class="mod-summary">
<p class="mod-decl">${escapeHtml(detail.declaration)}</p>
<dl>
<dt>产物 tag</dt><dd><code>${escapeHtml(detail.artifact)}</code></dd>
<dt>已有块</dt><dd>${present.map((id) => SECTION_LABELS[id] ?? id).join(" · ") || "—"}</dd>
${facts.map(([label, value]) => `<dt>${escapeHtml(label)}</dt><dd>${value}</dd>`).join("")}
</dl>
</div>
${nav}
@@ -192,19 +273,22 @@ async function boot() {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
modulesCache = data.modules || [];
document.getElementById("pack-label").textContent =
`配方包 · ${data.skillPackId || "—"} · ${modulesCache.length} 项`;
groupsCache = data.groups || [];
renderList();
const fromHash = decodeURIComponent((location.hash || "").replace(/^#/, ""));
const initial =
(fromHash && modulesCache.find((m) => m.id === fromHash)?.id) ||
modulesCache.find((m) => m.status === "ready")?.id ||
modulesCache.find((m) => m.group === "content" && m.status === "ready")?.id ||
modulesCache.find((m) => m.group === "content")?.id ||
modulesCache[0]?.id;
if (initial) await selectModule(initial);
else document.getElementById("pack-label").textContent = "没有节点";
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
document.getElementById("pack-label").textContent = "加载失败";
document.getElementById("mod-list").innerHTML =
`<p class="mod-empty">节点目录没有加载出来</p>`;
}
}
+394 -66
View File
@@ -692,91 +692,270 @@ body.play-workspace .coord-rail-title { display: none; }
line-height: 1.45;
text-align: left;
}
.play-table {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.play-table-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.play-table-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px;
align-items: start;
padding: 4px 0;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
}
.play-table-hit {
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
.play-table-row:hover {
background: color-mix(in srgb, var(--text) 3.5%, transparent);
}
.play-table-row:focus-within {
background: color-mix(in srgb, var(--accent-soft) 70%, transparent);
}
.play-table-row.is-dirty {
box-shadow: inset 2px 0 0 var(--accent);
}
.play-table-row.is-invalid {
box-shadow: inset 2px 0 0 var(--danger);
}
.play-table-field,
.play-table-reveal {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(4.75rem, 46%);
gap: 2px 10px;
align-items: center;
width: 100%;
margin: 0;
padding: 6px 4px 6px 8px;
min-width: 0;
}
.play-table-row.is-stack .play-table-field,
.play-table-row.is-wide .play-table-field {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
gap: 4px;
}
.play-table-row.is-wide .play-table-input:not([type="checkbox"]) {
width: 100% !important;
max-width: none;
text-align: left;
justify-self: stretch;
}
.play-table-reveal {
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
padding: 0;
cursor: default;
min-width: 0;
width: 100%;
cursor: pointer;
border-radius: 4px;
}
.play-table-row.is-secret .play-table-hit { cursor: pointer; }
.play-table-hit:focus-visible {
.play-table-reveal:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
outline-offset: -2px;
}
.play-table-key {
min-width: 0;
font-size: 12px;
line-height: 1.35;
color: var(--muted);
font-weight: 600;
overflow-wrap: anywhere;
}
.play-table-val {
min-width: 0;
font-size: 13px;
line-height: 1.4;
color: var(--text);
text-align: right;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.play-table-row.is-stack .play-table-val,
.play-table-val.is-code {
text-align: left;
white-space: pre-wrap;
}
.play-table-val.is-code {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 12px;
line-height: 1.45;
padding: 2px 0 4px;
}
.play-table-nested,
.play-table-tree,
.play-table-json {
grid-column: 1 / -1;
min-width: 0;
margin: 2px 0 4px;
}
.play-table-json {
margin: 0;
padding: 6px 8px;
border-radius: 4px;
background: color-mix(in srgb, var(--text) 5%, transparent);
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 11px;
line-height: 1.4;
white-space: pre-wrap;
overflow: auto;
max-height: 220px;
}
.play-nested-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
line-height: 1.35;
}
.play-nested-table th,
.play-nested-table td {
border: 1px solid color-mix(in srgb, var(--border, var(--text)) 35%, transparent);
padding: 3px 6px;
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
}
.play-nested-table th {
color: var(--muted);
font-weight: 600;
background: color-mix(in srgb, var(--text) 4%, transparent);
}
.play-table-tree {
display: grid;
gap: 4px;
padding: 4px 0 2px;
}
.play-nested-pair {
display: grid;
gap: 2px;
padding: 3px 0 3px 8px;
border-left: 2px solid color-mix(in srgb, var(--text) 14%, transparent);
}
.play-nested-key {
font-size: 11px;
color: var(--muted);
font-weight: 600;
}
.play-table-val {
font-size: 13px;
.play-nested-leaf {
font-size: 12px;
color: var(--text);
word-break: break-word;
overflow-wrap: anywhere;
}
.play-table-row.is-secret:not(.is-open) .play-table-val {
letter-spacing: 0.12em;
letter-spacing: 0.14em;
color: var(--muted);
font-size: 12px;
}
.play-table-fix {
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 11px;
padding: 2px 4px;
cursor: pointer;
}
.play-table-fix:hover { color: var(--text); }
.play-table-edit {
grid-column: 1 / -1;
display: flex;
gap: 4px;
align-items: center;
}
.play-table-edit input {
flex: 1;
.play-table-input {
width: 100%;
min-width: 0;
margin: 0;
border: 1px solid transparent;
border-radius: 4px;
background: color-mix(in srgb, var(--text) 4%, transparent);
color: var(--text);
font: inherit;
font-size: 13px;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
line-height: 1.35;
font-variant-numeric: tabular-nums;
text-align: right;
padding: 3px 6px;
}
.play-table-secrets { margin-top: 10px; }
.play-table-row.is-inline .play-table-input:not([type="checkbox"]) {
width: auto;
max-width: 100%;
justify-self: end;
}
.play-table-row.is-stack .play-table-input,
textarea.play-table-input {
text-align: left;
}
textarea.play-table-input {
display: block;
resize: none;
overflow: auto;
white-space: pre-wrap;
min-height: 32px;
max-height: 148px;
}
textarea.play-table-input.is-code {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 12px;
line-height: 1.45;
}
.play-table-input:hover {
border-color: var(--border);
}
.play-table-input:focus {
outline: 2px solid var(--accent-soft);
outline-offset: 0;
border-color: var(--accent);
background: var(--surface);
}
.play-table-input:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.play-table-input[type="checkbox"] {
width: 16px;
height: 16px;
margin: 0 6px 0 auto;
padding: 0;
justify-self: end;
accent-color: var(--accent);
background: transparent;
border: 0;
}
.play-table-input[type="checkbox"]:focus {
outline-offset: 2px;
}
.play-table-row.is-dirty .play-table-input {
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
background: var(--surface);
}
.play-table-row.is-invalid .play-table-input {
border-color: color-mix(in srgb, var(--danger) 60%, var(--border));
}
.play-table-field-error {
grid-column: 1 / -1;
margin: 0;
font-size: 11px;
line-height: 1.35;
color: var(--danger);
}
.play-table-field-error[hidden] { display: none; }
.play-table-bar {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
align-items: center;
gap: 6px;
margin: 8px -8px -8px;
padding: 8px;
background: var(--surface);
border-top: 1px solid var(--border);
}
.play-table-bar[hidden] { display: none; }
.play-table-bar-note {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--muted);
}
.play-table-bar .btn-primary { flex: 0 0 auto; }
.play-table-secrets { margin-top: 8px; }
.play-table-secrets > summary {
cursor: pointer;
font-size: 12px;
color: var(--muted);
font-weight: 600;
user-select: none;
padding: 4px 0;
padding: 6px 2px;
}
.play-table-secrets > summary:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.play-open {
display: flex;
@@ -1254,11 +1433,13 @@ body.design-workspace[data-design-surface="answer"] .qcard-pager,
right: 0;
}
/* 转述:对话在 stage 内的 .dictate-feed;#message-feed 保持隐藏 */
body.design-workspace[data-design-surface="dictate"] .message-feed {
/* 转述 / 听书:对话在 stage 内的 .dictate-feed;#message-feed 保持隐藏 */
body.design-workspace[data-design-surface="dictate"] .message-feed,
body.design-workspace[data-design-surface="listen"] .message-feed {
display: none !important;
}
body.design-workspace[data-design-surface="dictate"] .workspace-stage {
body.design-workspace[data-design-surface="dictate"] .workspace-stage,
body.design-workspace[data-design-surface="listen"] .workspace-stage {
order: 0;
flex: 1 1 auto;
min-height: 0;
@@ -1268,12 +1449,76 @@ body.design-workspace[data-design-surface="dictate"] .workspace-stage {
display: flex;
flex-direction: column;
}
body.design-workspace[data-design-surface="dictate"] {
body.design-workspace[data-design-surface="dictate"],
body.design-workspace[data-design-surface="listen"] {
--coord-w: clamp(300px, 28vw, 380px);
}
body.design-workspace[data-design-surface="dictate"] .panel-feed {
body.design-workspace[data-design-surface="dictate"] .panel-feed,
body.design-workspace[data-design-surface="listen"] .panel-feed {
overflow: hidden;
}
.listen-rail {
display: flex;
flex-direction: column;
gap: 10px;
padding: 8px 10px 16px;
font-size: 13px;
line-height: 1.45;
}
.listen-meta {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.listen-brief {
margin: 0;
}
.listen-outline {
margin: 0;
padding-left: 1.1em;
}
.listen-outline-item {
margin: 2px 0;
}
.listen-outline-item.status-cut {
opacity: 0.45;
text-decoration: line-through;
}
.listen-sections {
display: flex;
flex-direction: column;
gap: 12px;
}
.listen-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.listen-section-title {
margin: 0;
font-size: 13px;
font-weight: 600;
}
.listen-section.is-playing {
outline: 1px solid color-mix(in srgb, var(--accent, #6af) 55%, transparent);
border-radius: 6px;
padding: 6px;
}
.listen-section-body {
white-space: normal;
color: var(--text);
}
.listen-rail-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
position: sticky;
bottom: 0;
padding-top: 8px;
background: linear-gradient(transparent, var(--panel, var(--bg)) 40%);
}
.dictate-stage-bar {
display: flex;
align-items: baseline;
@@ -1295,6 +1540,7 @@ body.design-workspace[data-design-surface="dictate"] .panel-feed {
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overflow-anchor: none;
display: flex;
flex-direction: column;
gap: 10px;
@@ -2274,7 +2520,8 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
user-select: none;
}
.explorer-row.has-twist .explorer-main { grid-column: 2; }
.explorer-row.has-twist .explorer-del { grid-column: 3; }
.explorer-row.has-twist .explorer-del,
.explorer-row.has-twist .explorer-actions { grid-column: 3; }
.explorer-row.explorer-process {
min-height: 28px;
padding-top: 5px;
@@ -2341,6 +2588,19 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
line-height: 1.3;
color: var(--muted);
}
.explorer-actions {
grid-column: 2;
grid-row: 1 / span 2;
align-self: center;
display: inline-flex;
align-items: center;
gap: 2px;
}
.explorer-actions .explorer-del,
.explorer-actions .explorer-add {
grid-column: auto;
grid-row: auto;
}
.explorer-del {
grid-column: 2;
grid-row: 1 / span 2;
@@ -2370,20 +2630,19 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: 0 0 2px 8px;
width: 22px;
height: 22px;
margin: 0;
padding: 0;
border: none;
background: transparent;
color: var(--muted);
font-size: 16px;
line-height: 1;
border-radius: 6px;
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
}
.explorer-add.child { margin-left: 28px; }
.explorer-add:hover {
background: var(--bg);
color: var(--accent);
@@ -2495,6 +2754,7 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
.message-feed {
flex: 1;
overflow-y: auto;
overflow-anchor: none;
padding: 12px 14px;
min-height: 0;
display: flex;
@@ -2725,6 +2985,18 @@ body.rail-collapsed .message-feed {
max-height: 320px;
overflow: auto;
}
body[data-lifecycle="play"] .msg-play-aux .msg-bubble-aux {
border-style: dashed;
opacity: 0.96;
}
body[data-lifecycle="play"] .msg-play-aux .msg-tag {
color: var(--muted);
}
body[data-lifecycle="play"] .msg-play-aux .msg-body {
font-size: 0.92em;
max-height: min(42vh, 28rem);
overflow: auto;
}
.msg-live-indicator {
margin-left: 8px;
@@ -3912,8 +4184,9 @@ body.design-workspace .af-mosaic {
.present-preview-frame {
display: block;
width: 100%;
height: 220px;
height: 1px;
border: 0;
overflow: hidden;
background: transparent;
}
.present-preview-fallback {
@@ -5712,6 +5985,18 @@ body.design-workspace .af-mosaic {
.msg-peek-body .dictate-product-body {
max-height: none;
}
.msg-raw-section {
margin: 0 0 1rem;
}
.msg-raw-section:last-child {
margin-bottom: 0;
}
.msg-raw-kicker {
margin: 0 0 0.4rem;
font-size: 12px;
font-weight: 650;
color: var(--muted, #6b7280);
}
.msg-peek-body .artifact-compact-body {
max-height: none;
overflow: visible;
@@ -6717,14 +7002,57 @@ menu.context-menu[hidden],
flex-direction: column;
gap: 10px;
max-height: min(84vh, 860px);
min-height: 0;
}
.context-trace-scroll {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding-right: 2px;
}
.context-trace-kicker {
margin: 0;
font-size: 12px;
font-weight: 650;
color: var(--muted);
}
.context-trace-system {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
border: 1px dashed var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--muted, #888) 9%, transparent);
}
.context-trace-system .dialog-desc {
margin: 0;
}
#context-trace-messages {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
}
#context-trace-messages[hidden] {
display: none;
}
.context-trace-outline {
min-height: 0;
max-height: none;
background: transparent;
}
.context-trace-body {
margin: 0;
padding: 12px;
overflow: auto;
flex: 1;
min-height: 240px;
max-height: min(60vh, 640px);
overflow: visible;
flex: none;
min-height: 0;
max-height: none;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;