新建作品改为直接进入对话落盘,按本局创作方案写能力,并把楼层呈现和零层卡分开。

输入栏同时可以按分组切换模型。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-30 12:00:46 +08:00
co-authored by Cursor
parent 39fe2732fb
commit c2c9203fb0
87 changed files with 8413 additions and 3180 deletions
+130 -39
View File
@@ -188,6 +188,30 @@ function canRefreshMessage(msg) {
return true;
}
/**
* 游玩 0 层开场:多条时用 ‹ › 切换,选中的一条替换开场消息。
* 与消息版本 swipe 分开,不重 roll。
*/
function renderOpeningSwipeHtml(view, msg) {
if (!isPlayView(view)) return "";
if (String(msg?.title ?? "").trim() !== "开场白") return "";
const openings = view.playAux?.openings ?? [];
const total = openings.length;
if (total < 2) return "";
const current = Math.min(
total - 1,
Math.max(0, Number(view.playAux?.selectedOpeningIndex) || 0),
);
const id = esc(msg.id);
const prev = current - 1;
const next = current + 1;
return `<span class="msg-variant-nav" title="‹ › 切换开场,选中的一条作为 0 层">
<button type="button" class="msg-action" data-msg-action="opening-prev" data-msg-id="${id}" data-opening-index="${prev}"${prev < 0 ? " disabled" : ""} aria-label="上一条开场">‹</button>
<span class="msg-variant-count">${current + 1}/${total}</span>
<button type="button" class="msg-action" data-msg-action="opening-next" data-msg-id="${id}" data-opening-index="${next}"${next >= total ? " disabled" : ""} aria-label="下一条开场">›</button>
</span>`;
}
/** ‹ n/total › 多版切换;末页 › 与 ↻ 均可重 roll;单版只显示 ↻ */
function renderVariantNavHtml(msg) {
if (!msg?.id) return "";
@@ -741,10 +765,7 @@ function openContentPeek(payload) {
}
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>`;
return renderStoredProductHtml(content);
}
const dictateProductContentByEl = new WeakMap();
@@ -1144,6 +1165,13 @@ function wireMessageFeedActions(feed, handlers) {
return;
}
if (action === "opening-prev" || action === "opening-next") {
const index = Number(btn.getAttribute("data-opening-index"));
if (!Number.isFinite(index) || index < 0) return;
h.onSelectOpening?.(index);
return;
}
if (action === "variant-prev") {
h.onSwitchVariant?.(messageId, "prev");
return;
@@ -1231,9 +1259,64 @@ function isPlayProcessMessage(msg, view) {
return !isPlayFinalReply(msg, view);
}
/** 开场回退把多份产物拼成 `【设计.tag】` + JSON。拆开后按产物卡渲染,不把结构化原文当散文。 */
function splitBracketProductSections(text) {
const trimmed = String(text || "").trim();
const re = /^【((?:设计|用户)\.[^】]+)】[ \t]*$/gm;
const hits = [];
let m;
while ((m = re.exec(trimmed))) {
hits.push({
title: m[1].trim(),
index: m.index,
headerEnd: m.index + m[0].length,
});
}
if (!hits.length) return null;
const sections = [];
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;
sections.push({
title: h.title,
content: trimmed.slice(h.headerEnd, end).trim(),
});
}
return sections;
}
function renderStoredProductHtml(content) {
const trimmed = String(content ?? "").trim();
if (!trimmed) return `<p class="empty-sm">(无正文)</p>`;
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
const parsed = tryParseJsonDoc(trimmed);
if (parsed != null && typeof parsed === "object") {
return formatArtifactBodyHtml(trimmed, {
flat: true,
hideAskSidecar: true,
hideScores: true,
});
}
}
return `<div class="dictate-product-body">${safeBodyHtml(trimmed)}</div>`;
}
function formatPlayPresentHtml(body, view) {
const trimmed = stripPresentSourceFences((body || "").trim());
if (!trimmed) return `<p class="empty-sm">(无正文)</p>`;
const bundled = splitBracketProductSections(trimmed);
if (bundled) {
return bundled
.map((sec) => {
if (!sec.title) return `<p class="msg-bundle-lead">${esc(sec.content)}</p>`;
return renderStoredProductHtml(sec.content);
})
.join("");
}
const tweaks = view?.presentationTweaks;
const fallbackShell =
tweaks?.shell_id && PRESENT_SHELL_IDS?.includes?.(tweaks.shell_id)
@@ -2041,6 +2124,7 @@ function normalizeContextFragmentDoc(doc) {
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return doc;
const row = { ...doc };
const hasBody = row.正文 != null || row.body != null;
if (row.schema === "context-fragment-v1") row.schema = "context-fragment.v1";
const looksFrag =
row.schema === "context-fragment.v1" ||
(typeof row.技能 === "string" && row.技能.trim()) ||
@@ -2625,27 +2709,36 @@ function collectOpeningCandidates(body) {
const sharedVars = openingVariablesFrom(body);
const rows = [];
const seen = new Set();
const push = (text, persona, variables) => {
const push = (text, persona, variables, id) => {
const value = openingTextFrom(text);
if (!value || seen.has(value)) return;
seen.add(value);
rows.push({
id: String(id || "").trim(),
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, fallback, sharedVars);
else if (item && typeof item === "object") {
push(
item.正文 || item.全文 || item.text || item.开场白 || item.内容,
openingPersonaFrom(item, fallback),
openingVariablesFrom(item),
);
}
const listed = Array.isArray(body.开场白候选) ? body.开场白候选 : [];
if (!listed.length) push(body.开场白全文, fallback, sharedVars, "");
for (const item of listed) {
if (typeof item === "string") push(item, fallback, sharedVars, "");
else if (item && typeof item === "object") {
push(
item.正文 || item.全文 || item.text || item.开场白 || item.内容,
openingPersonaFrom(item, fallback),
openingVariablesFrom(item),
item.id || item.候选id,
);
}
}
const defaultId = String(body.默认候选id || body.默认候选ID || "").trim();
if (defaultId) {
const index = rows.findIndex((row) => row.id === defaultId);
if (index > 0) {
const [picked] = rows.splice(index, 1);
rows.unshift(picked);
}
}
return rows;
@@ -5690,24 +5783,22 @@ function renderDictateProductsList(view) {
.replace(/#/g, " · ");
return `<div class="dictate-product dictate-product--rail" role="listitem" data-dictate-product>
<button type="button" class="dictate-product-sum" aria-expanded="false">
<span class="dictate-product-sum-inner">
<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>
<span class="dictate-product-sum-top">
<span class="dictate-product-tag" title="${esc(tag)}">${esc(shortTag || tag || "未命名")}</span>
${
preview
? `<span class="dictate-product-preview">${esc(preview)}${
content.length > 72 ? "…" : ""
}</span>`
: `<span class="dictate-product-preview is-empty">(空)</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>`
}
</button>
<div class="dictate-product-body" hidden data-pending="1"></div>
</div>`;
@@ -5720,9 +5811,7 @@ function hydrateDictateProductBody(card) {
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>`;
body.innerHTML = renderStoredProductHtml(content);
delete body.dataset.pending;
}
@@ -6050,9 +6139,7 @@ export function renderMessageFeed(view, loading, handlers = {}) {
? `${view.uiPrompt}${
view.selectedRecipe?.name
? `\n\n已选配方:${view.selectedRecipe.name}`
: view.recipes?.length
? "\n\n(请先在新建作品时选定配方)"
: ""
: ""
}`
: view.waitingReason?.kind === "worker_questions"
? "在下方回答提问。"
@@ -6122,11 +6209,12 @@ function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, op
!label.includes(subtitle) &&
subtitle !== label;
const headInner = isUser
? `<span class="msg-time">${fmtTime(msg.createdAt)}</span>${renderMsgVariantBadge(msg)}`
? renderMsgVariantBadge(msg)
: `<div class="msg-head-main">
<span class="msg-tag">${esc(label)}</span>
${showSubtitle ? `<span class="msg-subtitle">${esc(subtitle)}</span>` : ""}
</div>
${renderOpeningSwipeHtml(view, msg)}
${renderMsgVariantBadge(msg)}
<span class="msg-time">${fmtTime(msg.createdAt)}</span>`;
const menuTrigger = `<button type="button" class="msg-menu-trigger" data-msg-menu-trigger aria-label="打开这条消息的操作菜单" title="更多操作">⋯</button>`;
@@ -6136,7 +6224,10 @@ function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, op
card.dataset.originalText = body;
const questionsActive = kind === "worker_questions" && Boolean(activeQuestions);
const showThinking = !isUser && msg.thinking && isPlayView(view);
const showThinking =
!isUser &&
Boolean(msg.thinking) &&
(isPlayView(view) || kind === "dictate_reply");
const innerBody = questionsActive
? `<p class="msg-q-index">${esc(body)}</p>`
: kind === "worker_questions"
+272 -195
View File
@@ -10,7 +10,7 @@ import {
} from "./agent-ui.js";
import { downloadMarkdown, sessionToMarkdown } from "./export.js";
import { renderIntakePanel } from "./intake-ui.js";
import { displayCreationModeLabel, displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js";
import { displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js";
import {
clearQuestionCardState,
collectQuestionAnswers,
@@ -34,6 +34,16 @@ import {
initMarkdownRender,
isMarkdownRenderEnabled,
} from "./markdown.js";
import {
filterUsedApiProfiles,
forgetApiProfileUsage,
loadApiProfileUsage,
membersUsageRank,
pruneApiProfileUsage,
recordApiProfileUsage,
seedApiProfileUsage,
sortByApiProfileUsage,
} from "./api-profile-usage.js";
import { wireDirectiveAutocomplete } from "./directive-autocomplete.js";
import {
refreshDirectiveHighlight,
@@ -79,18 +89,61 @@ let livePollFailCount = 0;
let inflightSeq = 0;
/** 底栏菜单:只切当前 API,不编辑 */
let apiProfileList = [];
let apiProfileGroups = [];
let activeApiProfileId = null;
/** idle | loading | ready | error */
let apiProfileMenuStatus = "idle";
let apiProfileMenuError = "";
let apiProfileSwitchingId = null;
let apiProfileSwitchingLabel = "";
const LIVE_POLL_MS = 280;
const WORKSPACE_SESSION_KEY = "wa-workspace-session-v1";
const $ = (id) => document.getElementById(id);
const PHASE = { idle: "待命", running: "执行中", waiting_user: "等待你", done: "已完成", error: "出错" };
function readWorkspaceSession() {
try {
const value = JSON.parse(sessionStorage.getItem(WORKSPACE_SESSION_KEY) || "null");
if (!value || typeof value !== "object" || typeof value.bookId !== "string") {
return null;
}
return value;
} catch {
return null;
}
}
function rememberWorkspaceSession() {
if (!activeBookId || !sessionId || lastView?.bookId !== activeBookId) return;
try {
const mode = isPlayView(lastView) ? "play" : "design";
sessionStorage.setItem(
WORKSPACE_SESSION_KEY,
JSON.stringify({
bookId: activeBookId,
sessionId,
mode,
playSaveId:
mode === "play" ? activePlaySaveId || "play-working" : null,
navLevel: sidebarNav.level === "book" ? "book" : "root",
}),
);
} catch {
/* sessionStorage 不可用时维持无记忆模式 */
}
}
function forgetWorkspaceSession() {
try {
sessionStorage.removeItem(WORKSPACE_SESSION_KEY);
} catch {
/* sessionStorage 不可用 */
}
}
/** 用户任务三态(表层);底层 waitingReason 映射进来 */
const USER_TASK = {
speak: { id: "speak", label: "说话", title: "继续说" },
@@ -158,7 +211,8 @@ function resolveUserTask(view, loading) {
if (canOfferSaveProduct(view)) {
return {
id: "speak",
label: "可保存",
label: USER_TASK.speak.label,
hideChip: true,
title: "落档产物",
hint: view.hasProduct
? "可再保存一份新定稿,或用已有产物开玩。"
@@ -300,6 +354,10 @@ function esc(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function attr(s) {
return esc(s).replace(/"/g, "&quot;");
}
function proposedOutputCopy(proposed) {
const params = proposed?.params;
const target =
@@ -585,8 +643,10 @@ function resolveComposer(view, loading) {
function composerModeChip(spec) {
const id = spec.task?.id;
if (spec.task?.hideChip) return "";
if (id !== "speak" && id !== "answer" && id !== "review" && id !== "busy") return "";
const label = spec.task.label || "";
if (!label) return "";
const tip = [spec.taskTitle, spec.taskHint].filter(Boolean).join(" — ");
const tone = spec.acceptAction?.tone ? ` data-tone="${esc(spec.acceptAction.tone)}"` : "";
return `<span class="composer-mode-chip" data-task="${esc(id)}"${tone} title="${esc(tip)}">${esc(label)}</span>`;
@@ -888,6 +948,7 @@ function summarizeApiProfile(profile) {
id: profile.id,
name: profile.name || "未命名",
model: profile.model || "",
groupId: profile.groupId || "",
hasKey: Boolean(String(profile.apiKey ?? "").trim()),
};
}
@@ -903,8 +964,7 @@ function composerMoreMenuTitle() {
function composerApiSubmenuMeta() {
if (apiProfileSwitchingId) {
const p = apiProfileList.find((x) => x.id === apiProfileSwitchingId);
return { desc: p ? `切换到 ${p.name}…` : "切换中…", title: "API" };
return { desc: apiProfileSwitchingLabel || "切换中…", title: "API" };
}
if (apiProfileMenuStatus === "loading" || apiProfileMenuStatus === "idle") {
return { desc: "加载中…", title: "API" };
@@ -916,12 +976,47 @@ function composerApiSubmenuMeta() {
return { desc: "未配置", title: "API" };
}
const active = activeApiProfile();
const group = apiProfileGroups.find((item) => item.id === active?.groupId);
const groupName = group?.name ? `${group.name} · ` : "";
const model = active?.model ? ` · ${active.model}` : "";
return {
desc: active?.name || "选择配置",
title: active ? `API · ${active.name}` : "API",
desc: active ? `${groupName}${active.name}${model}` : "选择配置",
title: active ? `API · ${active.name}${model}` : "API",
};
}
function composerProfileButtonHtml(profile, switching) {
const active = profile.id === activeApiProfileId;
const disabled = !profile.hasKey || switching;
const model = profile.model || profile.name || "未指定模型";
const nameNote =
profile.name && profile.name !== profile.model ? profile.name : "";
const desc = !profile.hasKey ? "未设置 Key" : nameNote;
const forget =
!active && profile.id
? `<button type="button" class="more-profile-forget" data-forget-profile="${attr(profile.id)}" title="移出曾用记录,配置仍保留">移出</button>`
: "";
return `<div class="more-profile-row">
<button
type="button"
class="more-menu-item more-theme-item more-profile-item${active ? " is-active" : ""}"
data-activate-profile="${attr(profile.id)}"
role="menuitemradio"
aria-checked="${active ? "true" : "false"}"
${disabled ? "disabled" : ""}
${profile.id === apiProfileSwitchingId ? "aria-busy=\"true\"" : ""}
title="${attr(model)}"
>
<span class="more-theme-copy">
<span class="more-theme-name">${esc(model)}</span>
${desc ? `<span class="more-theme-desc">${esc(desc)}</span>` : ""}
</span>
<span class="more-toggle-mark" aria-hidden="true"></span>
</button>
${forget}
</div>`;
}
function composerApiProfileItemsHtml() {
if (apiProfileMenuStatus === "loading" || apiProfileMenuStatus === "idle") {
return `<p class="more-menu-empty">加载 API 配置…</p>`;
@@ -933,50 +1028,60 @@ function composerApiProfileItemsHtml() {
return `<p class="more-menu-empty">还没有 API 配置。<a href="/settings.html">去设置添加</a></p>`;
}
const switching = Boolean(apiProfileSwitchingId);
return apiProfileList
.map((p) => {
const active = p.id === activeApiProfileId;
const disabled = !p.hasKey || switching;
const desc = !p.hasKey ? "未设置 Key" : p.model || "未指定模型";
return `<button
type="button"
class="more-menu-item more-theme-item more-profile-item${active ? " is-active" : ""}"
data-activate-profile="${esc(p.id)}"
role="menuitemradio"
aria-checked="${active ? "true" : "false"}"
${disabled ? "disabled" : ""}
${p.id === apiProfileSwitchingId ? "aria-busy=\"true\"" : ""}
title="${esc(p.name)}${p.model ? ` · ${esc(p.model)}` : ""}"
>
<span class="more-theme-copy">
<span class="more-theme-name">${esc(p.name)}</span>
<span class="more-theme-desc">${esc(desc)}</span>
</span>
<span class="more-toggle-mark" aria-hidden="true"></span>
</button>`;
const usage = loadApiProfileUsage();
const buckets = new Map();
for (const profile of apiProfileList) {
const key = profile.groupId || "";
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(profile);
}
for (const [key, members] of buckets) {
buckets.set(key, filterUsedApiProfiles(members, usage, activeApiProfileId));
}
const ordered = [
...apiProfileGroups.filter((group) => buckets.has(group.id)),
...[...buckets.keys()]
.filter((id) => id && !apiProfileGroups.some((group) => group.id === id))
.map((id) => ({ id, name: "未命名组" })),
];
if (buckets.has("")) ordered.push({ id: "", name: "未分组" });
const sortedGroups = sortByApiProfileUsage(ordered, (group) =>
membersUsageRank(buckets.get(group.id) || [], usage),
);
return sortedGroups
.map((group) => {
const members = buckets.get(group.id) || [];
if (!members.length) return "";
if (members.length === 1) {
return composerProfileButtonHtml(members[0], switching);
}
const containsActive = members.some((profile) => profile.id === activeApiProfileId);
const existing = document.querySelector(
`#composer-api-menu [data-api-group="${CSS.escape(group.id)}"]`,
);
const open = existing ? existing.hasAttribute("open") : containsActive;
const activeModel = members.find((profile) => profile.id === activeApiProfileId)?.model;
const desc = activeModel || `${members.length} 个曾用`;
return `<details class="api-fold" data-api-group="${attr(group.id)}"${open ? " open" : ""}>
<summary class="more-menu-item more-theme-item api-fold-sum${containsActive ? " is-active" : ""}">
<span class="more-theme-copy">
<span class="more-theme-name">${esc(group.name)}</span>
<span class="more-theme-desc">${esc(desc)}</span>
</span>
<span class="api-fold-chevron" aria-hidden="true"></span>
</summary>
<div class="api-fold-list" role="group" aria-label="${attr(group.name)}">
${members.map((profile) => composerProfileButtonHtml(profile, switching)).join("")}
</div>
</details>`;
})
.join("");
}
function composerApiSubmenuHtml() {
function composerApiSectionHtml() {
const meta = composerApiSubmenuMeta();
const wasSubOpen = $("composer-api-submenu")?.hasAttribute("open");
return `<details class="more-submenu" id="composer-api-submenu"${wasSubOpen ? " open" : ""}>
<summary
class="more-menu-item more-theme-item more-submenu-sum"
title="${esc(meta.title)}"
aria-label="${esc(meta.title)}"
>
<span class="more-theme-copy">
<span class="more-theme-name">API</span>
<span class="more-theme-desc" id="composer-api-submenu-desc">${esc(meta.desc)}</span>
</span>
<span class="more-submenu-chevron" aria-hidden="true"></span>
</summary>
<div class="more-submenu-panel" id="composer-api-menu" role="menu">
${composerApiProfileItemsHtml()}
</div>
</details>`;
return `<div class="more-menu-label" id="composer-api-label" title="${attr(meta.title)}">API</div>
<div id="composer-api-menu" role="group" aria-label="API 配置">${composerApiProfileItemsHtml()}</div>`;
}
function composerMoreMenuHtml() {
@@ -1006,7 +1111,7 @@ function composerMoreMenuHtml() {
<span class="hamburger-icon" aria-hidden="true"></span>
</summary>
<div class="more-menu-panel composer-more-panel" role="menu">
${composerApiSubmenuHtml()}
${composerApiSectionHtml()}
<div class="more-menu-sep" role="separator"></div>
<div class="more-menu-label">显示</div>
<button
@@ -1059,13 +1164,8 @@ function syncApiProfileMenuUi() {
const host = $("composer-api-menu");
if (host) host.innerHTML = composerApiProfileItemsHtml();
const meta = composerApiSubmenuMeta();
const desc = $("composer-api-submenu-desc");
if (desc) desc.textContent = meta.desc;
const subSum = document.querySelector("#composer-api-submenu > summary");
if (subSum) {
subSum.title = meta.title;
subSum.setAttribute("aria-label", meta.title);
}
const label = $("composer-api-label");
if (label) label.title = meta.title;
const sum = document.querySelector("#composer-more-menu .more-menu-sum");
if (sum) {
const title = composerMoreMenuTitle();
@@ -1081,7 +1181,10 @@ async function loadApiProfiles() {
try {
const data = await api("/api/profiles");
apiProfileList = (data.profiles || []).map(summarizeApiProfile);
apiProfileGroups = data.groups || [];
activeApiProfileId = data.activeProfileId ?? apiProfileList[0]?.id ?? null;
pruneApiProfileUsage(new Set(apiProfileList.map((p) => p.id)));
seedApiProfileUsage(activeApiProfileId);
apiProfileMenuStatus = "ready";
syncApiProfileMenuUi();
} catch (err) {
@@ -1093,7 +1196,6 @@ async function loadApiProfiles() {
function closeComposerMoreMenu(menu) {
menu?.removeAttribute("open");
menu?.querySelector("#composer-api-submenu")?.removeAttribute("open");
}
async function activateApiProfileFromMenu(id, menu) {
@@ -1104,17 +1206,21 @@ async function activateApiProfileFromMenu(id, menu) {
const profile = apiProfileList.find((p) => p.id === id);
if (!profile?.hasKey) return;
apiProfileSwitchingId = id;
apiProfileSwitchingLabel = profile ? `切换到 ${profile.name}…` : "切换中…";
syncApiProfileMenuUi();
try {
const result = await api(`/api/profiles/${encodeURIComponent(id)}/activate`, {
method: "POST",
});
activeApiProfileId = result.activeProfileId || id;
recordApiProfileUsage(activeApiProfileId);
apiProfileSwitchingId = null;
apiProfileSwitchingLabel = "";
syncApiProfileMenuUi();
closeComposerMoreMenu(menu);
} catch (err) {
apiProfileSwitchingId = null;
apiProfileSwitchingLabel = "";
syncApiProfileMenuUi();
alert(err.message || "切换失败");
}
@@ -2104,6 +2210,7 @@ function showCatalog({ closeSession = true } = {}) {
selectedBookIds = new Set();
renderPathBar();
renderBookList();
if (!closeSession) rememberWorkspaceSession();
}
function renderExplorerRow({
@@ -2230,15 +2337,15 @@ function renderSpecChildren(book, product, list) {
}
}
function appendNewPlayRow(book, list, instanceId, { child = false } = {}) {
list.appendChild(
renderExplorerRow({
name: "+ 新开一局",
actionClass: "explorer-action",
child,
onClick: () => void startNewPlayForBook(book.id, instanceId),
}),
);
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) {
@@ -2250,7 +2357,7 @@ function appendCurrentDesignRow(book, list) {
meta: live ? "进行中" : "继续",
active: live,
actionClass: "explorer-design",
title: "打开创作会话;定稿会出现在下方",
title: "继续创作;定稿会出现在下方",
onClick: () => void resumeDesignSession(book.id),
}),
);
@@ -2275,13 +2382,11 @@ function renderBookContents(book, list) {
if (!products.length) {
const hint = document.createElement("p");
hint.className = "explorer-hint";
hint.textContent = bookPlayReady(book.id) ? "还没有规格" : "定稿后会出现在这里";
hint.textContent = bookPlayReady(book.id) ? "还没有创作快照" : "定稿后会出现在这里";
list.appendChild(hint);
if (bookPlayReady(book.id)) appendNewPlayRow(book, list);
return;
}
let anyExpanded = false;
for (const product of products) {
const expanded = specIsExpanded(book.id, product.id);
const bits = [];
@@ -2290,7 +2395,7 @@ function renderBookContents(book, list) {
renderExplorerRow({
name: product.label,
meta: bits.join(" · "),
title: "展开或收起;双击改名;右键编辑产物",
title: "已保存的创作快照;展开可看游玩",
actionClass: "explorer-spec",
twistie: true,
expanded,
@@ -2303,14 +2408,8 @@ function renderBookContents(book, list) {
showSaveMenu(book.id, product.id, product.label, e.clientX, e.clientY),
}),
);
if (expanded) {
anyExpanded = true;
renderSpecChildren(book, product, list);
appendNewPlayRow(book, list, product.id, { child: true });
}
}
if (!anyExpanded) {
appendNewPlayRow(book, list, playingInstanceId || products[0]?.id);
if (expanded) renderSpecChildren(book, product, list);
appendNewPlayFromSnapshot(book, list, product.id);
}
}
@@ -2524,7 +2623,7 @@ function setupBookBoxSelect() {
});
}
async function loadPlaySave(bookId, saveId) {
async function loadPlaySave(bookId, saveId, { silent = false } = {}) {
try {
const products = productInstancesForBook(bookId);
const run = playRunsForBook(bookId).find((r) => r.id === saveId);
@@ -2538,9 +2637,11 @@ async function loadPlaySave(bookId, saveId) {
);
activePlaySaveId = saveId === "play-working" ? null : saveId;
renderSession(data.session, false);
return true;
} catch (err) {
if (lastView) renderSession(lastView, false);
alert(err.message);
if (!silent) alert(err.message);
return false;
}
}
@@ -2697,6 +2798,7 @@ function renderHeader(view, loading) {
function closeWorkspace({ emptyCopy = "" } = {}) {
hideEnterPicker();
stopLivePoll();
forgetWorkspaceSession();
sessionId = null;
lastView = null;
activeBookId = null;
@@ -3003,6 +3105,7 @@ function renderSession(view, loading = false) {
},
});
renderComposer(view, loading);
rememberWorkspaceSession();
if (loading) startLivePoll();
else stopLivePoll();
}
@@ -3386,130 +3489,20 @@ async function loadBooks() {
function openNewBookDialog() {
$("input-book-title").value = "";
void populateDirectorSelect();
$("dialog-new-book").showModal();
$("input-book-title").focus();
}
async function populateDirectorSelect() {
const sel = $("select-director");
const desc = $("director-desc");
const modeSel = $("select-creation-mode");
const modeDesc = $("creation-mode-desc");
const fieldDirector = $("field-director");
let recipeDirectors = [];
let dictateDirectors = [];
const fillOptions = (list) => {
if (!sel) return;
sel.innerHTML = "";
if (!list.length) {
sel.innerHTML = `<option value="">暂无配方</option>`;
sel.required = false;
return;
}
for (const d of list) {
const opt = document.createElement("option");
opt.value = d.id;
opt.textContent = d.name || d.id;
if (d.declaration) opt.dataset.declaration = d.declaration;
sel.appendChild(opt);
}
sel.value = list[0].id;
sel.required = true;
};
const syncDesc = () => {
if (!sel || !desc) return;
if (!modeSel?.value) {
desc.hidden = true;
desc.textContent = "";
return;
}
const list =
modeSel.value === "dictate" ? dictateDirectors : recipeDirectors;
const cur = list.find((d) => d.id === sel.value);
const text = (cur?.declaration ?? "").trim();
desc.hidden = !text;
desc.textContent = text;
};
const syncMode = () => {
const mode = modeSel?.value?.trim() || "";
if (!mode) {
if (fieldDirector) fieldDirector.hidden = true;
if (modeDesc) modeDesc.hidden = true;
if (desc) desc.hidden = true;
if (sel) {
sel.innerHTML = `<option value="">请先选择进料方式</option>`;
sel.required = false;
}
return;
}
const dictate = mode === "dictate";
if (fieldDirector) fieldDirector.hidden = false;
fillOptions(dictate ? dictateDirectors : recipeDirectors);
if (modeDesc) {
modeDesc.hidden = false;
modeDesc.textContent = dictate
? `${displayCreationModeLabel("dictate")}:对话写入产物;变量/映射用工具钉死;下方为对应配方。`
: `${displayCreationModeLabel("recipe")}:按工作流计划逐步验收技能;下方为对应配方。`;
}
syncDesc();
};
if (modeSel && !modeSel.dataset.bound) {
modeSel.dataset.bound = "1";
modeSel.addEventListener("change", syncMode);
}
if (sel && !sel.dataset.boundDesc) {
sel.dataset.boundDesc = "1";
sel.addEventListener("change", syncDesc);
}
if (!sel) return;
try {
const data = await api("/api/directors");
recipeDirectors = data.recipeDirectors ?? data.directors ?? [];
dictateDirectors = data.dictateDirectors ?? [];
// 打开对话框时重置为「先选进料」
if (modeSel) {
modeSel.value = "";
const placeholder = [...modeSel.options].find((o) => !o.value);
if (placeholder) placeholder.selected = true;
}
syncMode();
} catch (err) {
sel.innerHTML = `<option value="">加载失败</option>`;
if (desc) {
desc.hidden = false;
desc.textContent = err.message;
}
}
}
async function createBook() {
const title = $("input-book-title").value.trim() || "未命名作品";
const creationMode = $("select-creation-mode")?.value?.trim() || "";
const recipeId = $("select-director")?.value?.trim();
if (creationMode !== "recipe" && creationMode !== "dictate") {
alert("请先选择进料方式");
return;
}
if (!recipeId) {
alert("请选择配方");
return;
}
$("btn-create-book").disabled = true;
try {
const directorsMeta = await api("/api/directors").catch(() => null);
const orchestratorId = directorsMeta?.skillPackId || "world-simulator";
const data = await api("/api/books", {
method: "POST",
body: JSON.stringify({
title,
orchestratorId,
creationMode,
recipeId,
orchestratorId: "world-simulator",
creationMode: "dictate",
}),
});
$("dialog-new-book").close();
@@ -3747,8 +3740,12 @@ async function openModelComparePicker(messageId) {
<label class="compare-profile-item">
<input type="checkbox" name="compare-profile" value="${esc(p.id)}" ${i < 2 ? "checked" : ""} />
<span>
<strong>${esc(p.name)}</strong>
<div class="muted">${esc(p.model)}</div>
<strong>${esc(p.model || p.name)}</strong>
<div class="muted">${esc(
[p.name && p.name !== p.model ? p.name : "", p.baseUrl || ""]
.filter(Boolean)
.join(" · "),
)}</div>
</span>
</label>`,
)
@@ -4133,6 +4130,16 @@ $("composer")?.addEventListener("click", (e) => {
void loadApiProfiles();
return;
}
const forgetBtn = e.target.closest("[data-forget-profile]");
if (forgetBtn && menu.contains(forgetBtn)) {
e.preventDefault();
e.stopPropagation();
const id = forgetBtn.getAttribute("data-forget-profile");
if (id && id !== activeApiProfileId && forgetApiProfileUsage(id)) {
syncApiProfileMenuUi();
}
return;
}
const profileBtn = e.target.closest("[data-activate-profile]");
if (profileBtn && menu.contains(profileBtn)) {
e.preventDefault();
@@ -4155,23 +4162,93 @@ document.addEventListener("click", (e) => {
document.querySelectorAll("details.more-menu[open]").forEach((d) => {
if (!d.contains(e.target)) {
d.removeAttribute("open");
d.querySelectorAll("details.more-submenu[open]").forEach((sub) => {
d.querySelectorAll("details.more-submenu[open], details.api-fold[open]").forEach((sub) => {
sub.removeAttribute("open");
});
}
});
});
/** API 组二级菜单:同时只展开一个 */
document.addEventListener("toggle", (e) => {
const fold = e.target;
if (!(fold instanceof HTMLDetailsElement) || !fold.classList.contains("api-fold") || !fold.open) {
return;
}
const root = fold.closest("#composer-api-menu");
if (!root) return;
root.querySelectorAll("details.api-fold[open]").forEach((other) => {
if (other !== fold) other.removeAttribute("open");
});
}, true);
async function restoreWorkspaceSession() {
const saved = readWorkspaceSession();
if (!saved || !books.some((book) => book.id === saved.bookId)) {
forgetWorkspaceSession();
return false;
}
const bookId = saved.bookId;
activeBookId = bookId;
activePlaySaveId =
saved.playSaveId && saved.playSaveId !== "play-working"
? saved.playSaveId
: null;
if (saved.navLevel === "book") {
navigateToBook(bookId);
} else {
sidebarNav = { level: "root" };
document.body.dataset.navLevel = "root";
renderPathBar();
renderBookList();
}
if (saved.sessionId) {
try {
const view = await api(
`/api/sessions/${encodeURIComponent(saved.sessionId)}`,
);
if (view?.bookId === bookId) {
renderSession(view, false);
void fetchPlaySavesForBook(bookId);
return true;
}
} catch {
/* 服务端会话已失效时,从作品与存档恢复 */
}
}
try {
if (saved.mode === "play") {
await fetchPlaySavesForBook(bookId, true);
const restored = saved.playSaveId
? await loadPlaySave(bookId, saved.playSaveId, { silent: true })
: false;
if (!restored) await openCurrentPlay(bookId);
} else {
await openBookDesign(bookId);
}
if (saved.navLevel !== "book") {
showCatalog({ closeSession: false });
}
return Boolean(sessionId && activeBookId === bookId);
} catch {
forgetWorkspaceSession();
return false;
}
}
async function init() {
initColorTheme();
initUiFontScale();
initMarkdownRender();
syncThemeMenuUi();
try {
void populateDirectorSelect();
await Promise.all([loadBooks(), loadPersonas(), loadApiProfiles()]);
if (books.length) {
showCatalog();
const restored = await restoreWorkspaceSession();
if (!restored) showCatalog();
} else {
renderEmpty();
openNewBookDialog();
+67 -80
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=20260922-menu-unify" />
<link rel="stylesheet" href="/styles.css?v=20260928-shell-order" />
<script>
(function () {
try {
@@ -32,7 +32,64 @@
</head>
<body data-lifecycle="design" data-nav-level="root">
<div class="layout">
<aside class="panel panel-rail" id="panel-books">
<div class="main-chrome" id="main-chrome">
<header class="main-head" id="main-head">
<div class="main-head-left" aria-hidden="true"></div>
<nav class="main-app-nav" aria-label="应用">
<div id="persona-picker-host" class="persona-picker-host" hidden></div>
<a href="/modules.html">技能</a>
<a href="/shells.html">呈现壳</a>
<a href="/libraries.html">库</a>
<a href="/stats.html">Token</a>
<a href="/settings.html">设置</a>
</nav>
<div class="main-head-right">
<button type="button" class="btn-sm main-head-action" id="btn-save-instance" hidden>
保存定稿
</button>
<button type="button" class="btn-sm main-head-action" id="btn-save-play" hidden>
保存游玩
</button>
<button type="button" class="btn-sm main-head-action" id="btn-export" disabled>
导出
</button>
<details class="more-menu theme-menu" id="theme-menu">
<summary class="btn-sm more-menu-sum" id="theme-menu-sum" title="主题">
<span class="hamburger-icon" aria-hidden="true"></span>
</summary>
<div class="more-menu-panel" role="menu">
<div class="more-menu-label">主题</div>
<button type="button" class="more-menu-item more-theme-item" data-color-theme="paper" role="menuitem">
<span class="more-theme-icon" aria-hidden="true">📖</span>
<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-color-theme="modern" role="menuitem">
<span class="more-theme-icon" aria-hidden="true">✨</span>
<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-color-theme="dark" role="menuitem">
<span class="more-theme-icon" aria-hidden="true">🌙</span>
<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-color-theme="green" role="menuitem">
<span class="more-theme-icon" aria-hidden="true">🍃</span>
<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>
<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>
</header>
</div>
<div class="workspace-shell">
<aside class="panel panel-rail" id="panel-books">
<button
type="button"
class="rail-expand"
@@ -81,65 +138,10 @@
</div>
</div>
</div>
</aside>
</aside>
<main class="panel panel-main">
<div class="main-chrome" id="main-chrome">
<header class="main-head" id="main-head">
<div class="main-head-left" aria-hidden="true"></div>
<nav class="main-app-nav" aria-label="应用">
<div id="persona-picker-host" class="persona-picker-host" hidden></div>
<a href="/modules.html">技能</a>
<a href="/shells.html">呈现壳</a>
<a href="/libraries.html">库</a>
<a href="/stats.html">Token</a>
<a href="/settings.html">设置</a>
</nav>
<div class="main-head-right">
<button type="button" class="btn-sm main-head-action" id="btn-save-instance" hidden>
保存定稿
</button>
<button type="button" class="btn-sm main-head-action" id="btn-save-play" hidden>
保存游玩
</button>
<button type="button" class="btn-sm main-head-action" id="btn-export" disabled>
导出
</button>
<details class="more-menu theme-menu" id="theme-menu">
<summary class="btn-sm more-menu-sum" id="theme-menu-sum" title="主题">
<span class="hamburger-icon" aria-hidden="true"></span>
</summary>
<div class="more-menu-panel" role="menu">
<div class="more-menu-label">主题</div>
<button type="button" class="more-menu-item more-theme-item" data-color-theme="paper" role="menuitem">
<span class="more-theme-icon" aria-hidden="true">📖</span>
<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-color-theme="modern" role="menuitem">
<span class="more-theme-icon" aria-hidden="true">✨</span>
<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-color-theme="dark" role="menuitem">
<span class="more-theme-icon" aria-hidden="true">🌙</span>
<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-color-theme="green" role="menuitem">
<span class="more-theme-icon" aria-hidden="true">🍃</span>
<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>
<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>
</header>
</div>
<div class="main-stack">
<main class="panel panel-main">
<div class="main-stack">
<div class="main-body">
<section class="panel-feed" id="panel-feed">
<div id="skill-picker" class="skill-picker" hidden></div>
@@ -186,30 +188,15 @@
<span class="status" id="status-pill"><span class="status-dot" id="status-dot"></span><span id="status-text">—</span></span>
<div id="composer-main"></div>
</footer>
</div>
</main>
</div>
</main>
</div>
</div>
<dialog id="dialog-new-book" class="dialog">
<form method="dialog" id="form-new-book">
<h2>新建作品</h2>
<p class="dialog-desc">先选进料方式,再选对应配方(两套目录互不共享)。</p>
<label class="field">
进料方式
<select id="select-creation-mode" required>
<option value="" selected disabled>请选择…</option>
<option value="recipe">工序编排</option>
<option value="dictate">对话落盘</option>
</select>
</label>
<p class="dialog-desc" id="creation-mode-desc" hidden></p>
<label class="field" id="field-director" hidden>
配方
<select id="select-director">
<option value="">请先选择进料方式</option>
</select>
</label>
<p class="dialog-desc" id="director-desc" hidden></p>
<p class="dialog-desc">创建后直接说你想写什么;助手会在对话中判断合适的创作路径。</p>
<label class="field">
名称
<input id="input-book-title" type="text" placeholder="未命名作品" maxlength="80" autofocus />
@@ -337,6 +324,6 @@
</div>
</dialog>
<script type="module" src="/app.js?v=20260922-menu-unify"></script>
<script type="module" src="/app.js?v=20260928-session-restore"></script>
</body>
</html>
+56
View File
@@ -99,6 +99,55 @@ export function softenJsonText(s) {
return t;
}
/**
* 根对象被提前写成 `} , "下一键"` 时丢掉这个 `}`。
* 只在 JSON.parse 失败后使用;合法对象不会出现这种根级衔接。
*/
export function repairPrematureRootClose(s) {
const text = String(s || "");
let out = "";
let depth = 0;
let inString = false;
let escaped = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inString) {
out += c;
if (escaped) escaped = false;
else if (c === "\\") escaped = true;
else if (c === '"') inString = false;
continue;
}
if (c === '"') {
inString = true;
out += c;
continue;
}
if (c === "{") {
depth += 1;
out += c;
continue;
}
if (c === "}") {
if (depth === 1 && continuesAsSiblingKey(text, i + 1)) continue;
if (depth > 0) depth -= 1;
out += c;
continue;
}
out += c;
}
return out;
}
function continuesAsSiblingKey(text, from) {
let j = from;
while (j < text.length && /\s/.test(text[j])) j += 1;
if (text[j] !== ",") return false;
j += 1;
while (j < text.length && /\s/.test(text[j])) j += 1;
return text[j] === '"';
}
/** 截断的 {… 补齐引号/括号,便于验收卡仍能出 mosaic */
export function repairTruncatedJsonObject(slice) {
let s = String(slice || "").trim();
@@ -138,6 +187,10 @@ export function tryParseJsonDoc(text) {
}
const cleaned = softenJsonText(convertStructuralSmartQuotes(stripped));
const repaired = repairPrematureRootClose(cleaned);
const repairedParsed =
tryParse(repaired) ?? tryParse(softenJsonText(repaired));
if (repairedParsed != null) return repairedParsed;
if (
(cleaned.startsWith('"') && cleaned.endsWith('"')) ||
(cleaned.startsWith("'") && cleaned.endsWith("'"))
@@ -153,9 +206,12 @@ export function tryParseJsonDoc(text) {
const objEnd = cleaned.lastIndexOf("}");
if (objStart >= 0 && objEnd > objStart) {
const slice = cleaned.slice(objStart, objEnd + 1);
const repairedSlice = repairPrematureRootClose(slice);
const parsed =
tryParse(slice) ??
tryParse(softenJsonText(slice)) ??
tryParse(repairedSlice) ??
tryParse(softenJsonText(repairedSlice)) ??
tryParse(repairTruncatedJsonObject(slice)) ??
tryParse(softenJsonText(repairTruncatedJsonObject(cleaned.slice(objStart))));
if (parsed != null) return parsed;
+1 -1
View File
@@ -87,7 +87,7 @@ function setTab(tab, pushUrl = true) {
panelTitleEl.textContent = isStyle ? "文风库" : "偏好库";
panelSubtitleEl.textContent = isStyle
? "跨卡「怎么写」;对话 + 样本提取 → 叙事指南选用"
: "跨卡硬约束;对话提取 →「用户需求」节点选用";
: "跨卡硬约束;由绑定偏好库的能力按需选用";
extractTitleEl.textContent = isStyle ? "用 LLM 提取文风" : "用 LLM 提取偏好";
extractHelpEl.textContent = isStyle
? "描述想要的写法,或贴样本;助手整理成可入库草稿。点「入库」写入列表。"
+223 -34
View File
@@ -12,7 +12,7 @@
--ok: #16a34a;
--warn: #ca8a04;
--danger: #dc2626;
--books-w: 216px;
--books-w: clamp(160px, 17vw, 196px);
--books-w-collapsed: 40px;
--coord-w: clamp(280px, 24vw, 340px);
--coord-w-collapsed: 40px;
@@ -113,13 +113,21 @@ a.link:hover { text-decoration: underline; }
.layout {
display: grid;
grid-template-columns: var(--books-w) 1fr;
grid-template-rows: auto minmax(0, 1fr);
height: 100%;
max-height: 100dvh;
overflow: hidden;
background: var(--bg);
}
.workspace-shell {
display: grid;
grid-template-columns: var(--books-w) minmax(0, 1fr);
min-width: 0;
min-height: 0;
overflow: hidden;
}
.panel { background: var(--surface); border: 1px solid var(--border); min-height: 0; }
.panel-rail,
.panel-books {
@@ -324,10 +332,10 @@ body[data-nav-level="root"] .burst-badge { display: none; }
body.rail-collapsed .panel-rail { align-items: stretch; }
/* composer 按内容自适应;中间区吃掉剩余高度,整页锁在视口内 */
/* panel-main:上=常驻顶栏;下=工作区 */
/* panel-main 只承载工作区;全局顶栏位于 workspace-shell 上方 */
.panel-main {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
height: 100%;
@@ -336,6 +344,7 @@ body.rail-collapsed .panel-rail { align-items: stretch; }
}
.main-stack {
flex: 1 1 auto;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
min-width: 0;
@@ -2357,8 +2366,38 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
@media (hover: none) {
.explorer-del { opacity: 0.7; }
}
.explorer-action .explorer-name { color: var(--accent); font-weight: 500; }
.book-list > .explorer-action { margin-top: 4px; }
.explorer-add {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: 0 0 2px 8px;
padding: 0;
border: none;
background: transparent;
color: var(--muted);
font-size: 16px;
line-height: 1;
border-radius: 6px;
cursor: pointer;
flex-shrink: 0;
}
.explorer-add.child { margin-left: 28px; }
.explorer-add:hover {
background: var(--bg);
color: var(--accent);
}
.explorer-add:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.explorer-add:disabled {
color: var(--muted);
opacity: 0.45;
cursor: not-allowed;
}
.explorer-add:disabled:hover { background: transparent; color: var(--muted); }
.explorer-design .explorer-name { font-weight: 500; }
.book-list > .explorer-design + .explorer-hint { margin-top: 2px; }
.book-enter-pop {
@@ -2527,6 +2566,14 @@ body.rail-collapsed .message-feed {
font-size: 11px;
flex-wrap: wrap;
}
/* 用户气泡不再单独占一行显示时间;操作菜单叠在右下角,不挤正文 */
.msg-user-row .msg-foot {
position: absolute;
right: 4px;
bottom: 4px;
padding: 0;
z-index: 1;
}
.msg-head-main { display: flex; gap: 6px; align-items: baseline; min-width: 0; flex: 1; }
.msg-subtitle { color: var(--muted); font-weight: normal; }
.msg-variant-badge {
@@ -2615,7 +2662,7 @@ body.rail-collapsed .message-feed {
font-size: 14px;
padding: 10px 14px;
}
.msg-user-row .msg-body { padding-bottom: 4px; }
.msg-user-row .msg-body { padding-bottom: 8px; }
.msg-assistant-row .msg-head + .msg-body { padding-top: 4px; }
.msg-pending { opacity: 0.85; }
@@ -3261,8 +3308,8 @@ body[data-lifecycle="play"] .msg-assistant-row .msg-head {
padding-top: 6px;
}
body[data-lifecycle="play"] .msg-user-row .msg-bubble {
width: min(100%, 28rem);
max-width: 90%;
width: min(100%, 42rem);
max-width: 92%;
}
body[data-lifecycle="play"] .msg-play-final .msg-bubble {
width: 100%;
@@ -5812,11 +5859,33 @@ menu.context-menu[hidden],
box-shadow: 0 0 0 2px var(--accent-soft);
}
.composer-shell-actions {
position: absolute;
right: 6px;
bottom: 4px;
z-index: 3;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 2px;
gap: 6px;
margin: 0;
padding: 0;
opacity: 0;
pointer-events: none;
transition: opacity 0.14s ease;
}
.composer-input-shell:hover > .composer-shell-actions,
.composer-shell-actions:focus-within {
opacity: 0.8;
pointer-events: auto;
}
.composer-shell-actions:hover {
opacity: 0.85;
}
@media (hover: none) {
.composer-shell-actions {
opacity: 0.8;
pointer-events: auto;
}
}
.persona-picker-host {
display: flex;
@@ -6264,11 +6333,87 @@ menu.context-menu[hidden],
right: auto;
top: auto;
bottom: calc(100% + 6px);
min-width: 220px;
max-width: min(80vw, 280px);
min-width: 240px;
max-width: min(86vw, 340px);
/* visible:二级 API 菜单向右飞出,避免被 overflow:auto 裁切 */
overflow: visible;
z-index: 140;
}
.api-fold {
position: relative;
display: block;
}
.api-fold-sum {
list-style: none;
cursor: pointer;
width: 100%;
box-sizing: border-box;
}
.api-fold-sum .more-theme-copy {
flex: 1 1 auto;
min-width: 0;
}
.api-fold-sum::-webkit-details-marker { display: none; }
.api-fold-sum::marker { content: ""; }
.api-fold-chevron {
flex: 0 0 auto;
width: 1em;
text-align: center;
color: var(--muted);
}
.api-fold-chevron::before { content: "›"; }
.api-fold[open] > .api-fold-sum .api-fold-chevron,
.api-fold-sum:hover .api-fold-chevron {
color: var(--accent);
}
.api-fold-sum:hover,
.api-fold[open] > .api-fold-sum {
background: var(--bg);
color: var(--accent);
}
.api-fold-list {
position: absolute;
left: calc(100% + 4px);
top: 0;
z-index: 150;
display: flex;
flex-direction: column;
gap: 1px;
min-width: 220px;
max-width: min(80vw, 300px);
max-height: min(60vh, 420px);
overflow: auto;
margin: 0;
padding: 4px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: var(--shadow);
}
@media (max-width: 560px) {
.api-fold-list {
position: static;
left: auto;
top: auto;
margin: 2px 0 4px 8px;
padding: 2px 0 4px 6px;
min-width: 0;
max-width: none;
max-height: 240px;
border: none;
border-left: 1px solid var(--border);
border-radius: 0;
box-shadow: none;
background: transparent;
}
.api-fold-chevron::before { content: "▾"; }
.api-fold[open] > .api-fold-sum .api-fold-chevron::before { content: "▴"; }
}
.api-fold-sum:focus-visible,
.api-fold-list .more-profile-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.more-submenu {
position: relative;
display: block;
@@ -6362,6 +6507,59 @@ menu.context-menu[hidden],
background: var(--accent-soft);
color: var(--accent);
}
.more-profile-row {
display: flex;
align-items: stretch;
min-width: 0;
}
.more-profile-row .more-profile-item {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
.more-profile-forget {
flex: 0 0 auto;
margin: 0;
padding: 0 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 11px;
cursor: pointer;
}
.more-profile-forget:hover {
color: var(--accent);
background: var(--bg);
}
.more-profile-forget:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
/* more-profile-models:与 more-submenu 默认向右飞出一致,不再强制向下展开 */
.more-model-filter {
margin: 2px 4px 6px;
width: calc(100% - 8px);
box-sizing: border-box;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: inherit;
font: inherit;
font-size: 12px;
}
.more-model-filter:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.more-model-empty {
margin: 0;
padding: 6px 10px 8px;
font-size: 12px;
color: var(--muted);
}
.more-menu-empty {
margin: 0;
padding: 8px 10px 10px;
@@ -7123,10 +7321,9 @@ menu.context-menu[hidden],
min-width: 0;
width: 100%;
max-width: 100%;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
overflow: hidden;
border: none;
background: transparent;
overflow: visible;
}
.dictate-products-empty-block {
margin: 28px 16px;
@@ -7160,9 +7357,11 @@ menu.context-menu[hidden],
border-bottom: none;
}
.dictate-product--rail > .dictate-product-sum {
display: block;
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
padding: 8px 12px;
width: 100%;
box-sizing: border-box;
border: none;
@@ -7173,29 +7372,20 @@ menu.context-menu[hidden],
cursor: context-menu;
user-select: none;
min-width: 0;
overflow: hidden;
position: relative;
appearance: none;
-webkit-appearance: none;
}
.dictate-product-sum-inner {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px 8px 12px;
width: 100%;
box-sizing: border-box;
min-width: 0;
pointer-events: none; /* 整行命中落在 button 上,避免子节点吞事件 */
transition: background 0.1s ease;
}
.dictate-product--rail > .dictate-product-sum:hover .dictate-product-sum-inner {
.dictate-product--rail > .dictate-product-sum:hover {
background: color-mix(in srgb, var(--accent-soft) 50%, transparent);
}
.dictate-product--rail > .dictate-product-sum:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.dictate-product--rail.is-open > .dictate-product-sum .dictate-product-sum-inner {
.dictate-product--rail.is-open > .dictate-product-sum {
background: color-mix(in srgb, var(--bg) 70%, var(--surface));
border-bottom: 1px solid var(--border);
}
@@ -7256,7 +7446,6 @@ menu.context-menu[hidden],
white-space: nowrap;
min-width: 0;
max-width: 100%;
padding-right: 20px;
}
.dictate-product--rail .dictate-product-preview.is-empty {
font-style: italic;
@@ -7304,7 +7493,7 @@ menu.context-menu[hidden],
max-width: 100%;
}
body[data-coord-rail-mode="products"] .coord-drawer-body {
padding: 8px;
padding: 0;
overflow-x: hidden;
overflow-y: auto;
min-width: 0;