From 66ed1eaca8446ef4ca5c76f9dbddf136e5579346 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Wed, 7 Oct 2026 23:49:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E4=B8=8E=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=8E=A5=E4=B8=8A=E5=90=AC=E4=B9=A6=E3=80=81=E5=91=88=E7=8E=B0?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=E3=80=81=E8=AF=B7=E6=B1=82=E8=BD=AE=E5=BB=93?= =?UTF-8?q?=E5=92=8C=E6=97=81=E8=A7=82=E6=B0=94=E6=B3=A1=E5=81=8F=E5=A5=BD?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建作品可选听书连载,朗读与消费进度走专用接口。 Co-authored-by: Cursor --- README.md | 60 +-- src/book/store.ts | 29 +- src/server/agent-view.ts | 1 + src/server/book-handlers.ts | 116 ++++-- src/server/display-labels.ts | 1 + src/server/session-manager.ts | 334 +++++++++++++-- src/server/settings-handlers.ts | 16 + src/server/web-server.ts | 48 +++ src/types/book-session.ts | 15 + src/types/book.ts | 6 + tests/display-labels.test.ts | 1 + tests/feed-prefs.test.ts | 53 +++ web/agent-ui.js | 712 +++++++++++++++++++++++++++++--- web/app.js | 569 ++++++++++++++++++++----- web/display-labels.js | 1 + web/feed-prefs.js | 95 +++++ web/index.html | 29 +- web/modules.css | 70 +++- web/modules.html | 14 +- web/modules.js | 122 +++++- web/styles.css | 460 ++++++++++++++++++--- 21 files changed, 2395 insertions(+), 357 deletions(-) create mode 100644 tests/feed-prefs.test.ts create mode 100644 web/feed-prefs.js diff --git a/README.md b/README.md index 89cc563..04bef73 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ ## 它能做什么 -- **创作**:为新作品选进料方式(工序编排 / 对话落盘)与配方,由编排器调度技能,落成可复用的运行规格与开场 +- **创作**:为新作品选进料方式(工序编排 / 对话落盘(默认选项))与配方,由编排器调度技能,落成可复用的运行规格与开场 - **游玩**:在钉住的规格下回合推进;可切换呈现壳,保存游玩进度 -- **技能包**:能力以磁盘上的技能包装载(当前主包为「世界模拟器」相关配方与模块) +- **技能包**:能力以磁盘上的技能包装载(当前主包为「世界模拟器」相关配方与模块)(目前不需要手动选择) - **预设与库**:兼容 SillyTavern 风格的提示词预设;文风库 / 偏好库可注入创作节点,种子可从仓库恢复 - **多接口**:分组管理 API、嗅探型号、切换同一接口下的模型 - **本机优先**:Web(`localhost:23337`)或 `start-app.bat` 桌面壳;运维可用 `console.bat` @@ -16,7 +16,7 @@ ## 快速开始 1. 安装 [Node.js](https://nodejs.org/)(含 npm) -2. 双击 `start.bat`(桌面窗口则用 `start-app.bat`),浏览器打开 http://localhost:23337 +2. 双击 `start.bat`(桌面窗口则用 `start-app.bat`),浏览器打开 [http://localhost:23337](http://localhost:23337) 3. 在 **设置** 里新增模型接口(Base URL、API Key、型号);根目录已有 `.env` 时可跳过 4. **新建作品** → 对话写出一张简单可玩卡 → **保存定稿** → 左侧菜单选 **游玩** @@ -31,20 +31,22 @@ → 游玩(执行单元按规格上场)→ 保存游玩 ``` -术语以 [`docs/ui-glossary.md`](docs/ui-glossary.md) 为准(进料方式、配方、编排器、技能、运行规格、执行单元)。 +术语以 `[docs/ui-glossary.md](docs/ui-glossary.md)` 为准(进料方式、配方、编排器、技能、运行规格、执行单元)。 ## 仓库一览 -| 路径 | 用途 | -|------|------| -| `src/` | 运行时、会话、技能装载、Book/预设/库、HTTP API | -| `web/` | 前端静态页 | -| `skills/` | 技能包(见 `skills/README.md`) | -| `examples/presets/` | 示例预设 | -| `seeds/libraries/` | 文风/偏好库种子 | -| `electron/` | 桌面壳 | -| `docs/` | 架构与契约文档 | -| `tests/` | Vitest | + +| 路径 | 用途 | +| ------------------- | ------------------------------ | +| `src/` | 运行时、会话、技能装载、Book/预设/库、HTTP API | +| `web/` | 前端静态页 | +| `skills/` | 技能包(见 `skills/README.md`) | +| `examples/presets/` | 示例预设 | +| `seeds/libraries/` | 文风/偏好库种子 | +| `electron/` | 桌面壳 | +| `docs/` | 架构与契约文档 | +| `tests/` | Vitest | + 用户数据目录可用环境变量 `WRITING_AGENT_DATA_DIR` 覆盖;默认 `~/.writing-agent`(Windows 多为 `%USERPROFILE%\.writing-agent`)。 @@ -67,22 +69,24 @@ Windows 运维脚本:`console.bat`(查询 / 启停 / 日志)。入口实 ### 文档索引 -| 文档 | 内容 | -|------|------| -| [architecture.md](docs/architecture.md) | 系统架构与模块边界 | -| [ui-glossary.md](docs/ui-glossary.md) | 用户可见术语 | -| [implementation-guide.md](docs/implementation-guide.md) | 文档地图与实现约定 | -| [runtime-state-machine.md](docs/runtime-state-machine.md) | 相位机 | -| [tool-contracts.md](docs/tool-contracts.md) | Tool 契约 | -| [run-snapshot.md](docs/run-snapshot.md) | 存档 / 快照(以代码现状为准) | -| [play-presentation-shells.md](docs/play-presentation-shells.md) | 游玩呈现壳 | -| [preset-format.md](docs/preset-format.md) | 预设格式 | -| [skill-format.md](docs/skill-format.md) 等 | 技能 / 编排器 / Worker 格式 | -| [px-roadmap.md](docs/px-roadmap.md) | 产品路线图 | -| [skills/README.md](skills/README.md) | 技能包目录说明 | + +| 文档 | 内容 | +| --------------------------------------------------------------- | -------------------- | +| [architecture.md](docs/architecture.md) | 系统架构与模块边界 | +| [ui-glossary.md](docs/ui-glossary.md) | 用户可见术语 | +| [implementation-guide.md](docs/implementation-guide.md) | 文档地图与实现约定 | +| [runtime-state-machine.md](docs/runtime-state-machine.md) | 相位机 | +| [tool-contracts.md](docs/tool-contracts.md) | Tool 契约 | +| [run-snapshot.md](docs/run-snapshot.md) | 存档 / 快照(以代码现状为准) | +| [play-presentation-shells.md](docs/play-presentation-shells.md) | 游玩呈现壳 | +| [preset-format.md](docs/preset-format.md) | 预设格式 | +| [skill-format.md](docs/skill-format.md) 等 | 技能 / 编排器 / Worker 格式 | +| [px-roadmap.md](docs/px-roadmap.md) | 产品路线图 | +| [skills/README.md](skills/README.md) | 技能包目录说明 | + `docs/book-storage.md` 描述的是目标存储模型,**不是**当前磁盘布局;当前以 Book JSON + session + run-snapshots 为准。 ## 许可与第三方 -见 [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)。项目版本见 `package.json`(当前 `0.1.0`,`private`)。 +见 `[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)`。项目版本见 `package.json`(当前 `0.1.0`,`private`)。 \ No newline at end of file diff --git a/src/book/store.ts b/src/book/store.ts index a7b0e9f..7b7f3cd 100644 --- a/src/book/store.ts +++ b/src/book/store.ts @@ -9,7 +9,20 @@ import { writeFileSync, } from "node:fs"; import path from "node:path"; -import type { BookProject, BookSummary } from "../types/book.js"; +import type { + BookCreationMode, + BookProject, + BookSummary, +} from "../types/book.js"; + +function normalizeBookCreationMode( + raw: string | undefined | null, +): BookCreationMode { + if (raw === "listen_serial" || raw === "recipe" || raw === "dictate") { + return raw; + } + return "dictate"; +} import type { PersistedBookSession } from "../types/book-session.js"; import type { RunSnapshot } from "../types/run-snapshot.js"; import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js"; @@ -57,6 +70,7 @@ export function listBooks(): BookSummary[] { title: b.title, activeSkillId: b.activeSkillId ?? b.orchestratorId, activeSkillName: b.activeSkillName ?? b.orchestratorName, + creationMode: normalizeBookCreationMode(b.creationMode), preview: b.preview, updatedAt: b.updatedAt, orchestratorId: b.orchestratorId, @@ -67,19 +81,27 @@ export function listBooks(): BookSummary[] { export function getBook(id: string): BookProject | null { try { const raw = readFileSync(bookPath(id), "utf8"); - return JSON.parse(raw) as BookProject; + const book = JSON.parse(raw) as BookProject; + return { + ...book, + creationMode: normalizeBookCreationMode(book.creationMode), + }; } catch { return null; } } -export function createBook(input: { title?: string }): BookProject { +export function createBook(input: { + title?: string; + creationMode?: BookCreationMode; +}): BookProject { ensureUserDataDirs(); mkdirSync(booksDir(), { recursive: true }); const now = new Date().toISOString(); const book: BookProject = { id: randomUUID(), title: input.title?.trim() || "未命名作品", + creationMode: normalizeBookCreationMode(input.creationMode), preview: "新建作品,描述你想创作什么…", sessionIds: [], createdAt: now, @@ -100,6 +122,7 @@ export function updateBook( | "activeSessionId" | "activeSkillId" | "activeSkillName" + | "creationMode" | "orchestratorId" | "orchestratorName" > diff --git a/src/server/agent-view.ts b/src/server/agent-view.ts index 43cd73d..bef8f9f 100644 --- a/src/server/agent-view.ts +++ b/src/server/agent-view.ts @@ -26,6 +26,7 @@ export type AgentMessageKind = | "orchestrator_prompt" | "orchestrator_assessment" | "dictate_reply" + | "listen_reply" | "agent_tool" | "worker_running" | "worker_output" diff --git a/src/server/book-handlers.ts b/src/server/book-handlers.ts index 06c6b44..3eea711 100644 --- a/src/server/book-handlers.ts +++ b/src/server/book-handlers.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { listSkills } from "../skills/loader.js"; +import type { ModuleCatalogEntry } from "../skills/creation-flow.js"; import { createBook, deleteBook, duplicateBook, getBook, listBooks, updateBook } from "../book/store.js"; import { sessionManager } from "./session-manager.js"; @@ -39,17 +40,64 @@ function moduleStatusFromSections( return depth >= 2 ? "ready" : "partial"; } +type ModuleBrowseRow = { + id: string; + name: string; + declaration: string; + artifact: string; + hasPrompt: boolean; + sectionsPresent: string[]; + status: ModuleStatus; + group: string; + presentMode: "message" | "zero_layer" | null; + layer: "intermediate" | "final" | null; + repeatable: boolean; + closer: boolean; + priorArtifact: boolean; + programSide: boolean; + libraries: string[]; + land: string; +}; + +async function moduleBrowseFields( + entry: ModuleCatalogEntry, +): Promise< + Omit< + ModuleBrowseRow, + | "id" + | "name" + | "declaration" + | "artifact" + | "hasPrompt" + | "sectionsPresent" + | "status" + > +> { + const { dictateModuleLandHint, isDictateProgramModule } = await import( + "../skills/creation-flow.js" + ); + const { moduleBrowseGroup, modulePresentationMode } = await import( + "../dictate/creation-plan.js" + ); + const { getLibrary } = await import("../libraries/registry.js"); + return { + group: moduleBrowseGroup(entry), + presentMode: modulePresentationMode(entry.id), + layer: entry.layer ?? null, + repeatable: Boolean(entry.repeatable), + closer: Boolean(entry.closer), + priorArtifact: entry.kind === "prior-artifact", + programSide: isDictateProgramModule(entry), + libraries: (entry.libraries ?? []).map( + (id) => getLibrary(id)?.label ?? id, + ), + land: dictateModuleLandHint(entry), + }; +} + async function loadModulesPayload(skillId: string): Promise<{ - skillPackId: string; - modules: Array<{ - id: string; - name: string; - declaration: string; - artifact: string; - hasPrompt: boolean; - sectionsPresent: string[]; - status: ModuleStatus; - }>; + groups: Array<{ id: string; label: string; hint: string }>; + modules: ModuleBrowseRow[]; }> { const { loadSkill } = await import("../skills/loader.js"); const { @@ -59,13 +107,14 @@ async function loadModulesPayload(skillId: string): Promise<{ catalogModulesForIntake, MODULE_SECTION_IDS, } = await import("../skills/creation-flow.js"); + const { MODULE_BROWSE_GROUPS } = await import("../dictate/creation-plan.js"); const skill = await loadSkill(skillId); if (!skill.skillPackRoot) { - return { skillPackId: skillId, modules: [] }; + return { groups: [...MODULE_BROWSE_GROUPS], modules: [] }; } const catalog = await loadModuleCatalog(skill.skillPackRoot); - const modules = []; - for (const m of catalogModulesForIntake(catalog ?? { modules: [] }, "recipe")) { + const modules: ModuleBrowseRow[] = []; + for (const m of catalogModulesForIntake(catalog ?? { modules: [] }, "dictate")) { const prompt = await loadModulePrompt(skill.skillPackRoot, m.id); const sections = prompt ? parseModulePromptSections(prompt) @@ -83,27 +132,23 @@ async function loadModulesPayload(skillId: string): Promise<{ hasPrompt, sectionsPresent: [...sectionsPresent], status: moduleStatusFromSections(hasPrompt, sectionsPresent, taskBody), + ...(await moduleBrowseFields(m)), }); } - return { skillPackId: skillId, modules }; + return { groups: [...MODULE_BROWSE_GROUPS], modules }; } async function loadModuleDetailPayload( skillId: string, moduleId: string, -): Promise<{ - skillPackId: string; - id: string; - name: string; - declaration: string; - artifact: string; - hasPrompt: boolean; - sectionsPresent: string[]; - status: ModuleStatus; - sections: Record; - meta: Record | null; - raw: string; -} | null> { +): Promise< + | (ModuleBrowseRow & { + sections: Record; + meta: Record | null; + raw: string; + }) + | null +> { const { loadSkill } = await import("../skills/loader.js"); const { loadModuleCatalog, @@ -141,7 +186,6 @@ async function loadModuleDetailPayload( } } return { - skillPackId: skillId, id: entry.id, name: entry.name, declaration: entry.declaration, @@ -153,6 +197,7 @@ async function loadModuleDetailPayload( sectionsPresent, sections.task, ), + ...(await moduleBrowseFields(entry)), sections, meta, raw: prompt ?? "", @@ -254,7 +299,7 @@ export async function handleBooksApi( return true; } - /** 默认配方包的技能池(编排备选) */ + /** 对话落盘节点:按角色分组,不含旧配方包 */ if (pathname === "/api/modules" && req.method === "GET") { const { DEFAULT_ORCHESTRATOR_ID } = await import( "../config/default-orchestrator.js" @@ -343,8 +388,8 @@ export async function handleBooksApi( orchestratorId?: string; /** 旧客户端可传;新建作品改由创作对话判断路径 */ recipeId?: string; - /** 旧客户端兼容;新建作品统一使用 dictate */ - creationMode?: "recipe" | "dictate"; + /** 进料方式:对话落盘(默认)| 听书连载 | 旧工序编排 */ + creationMode?: "recipe" | "dictate" | "listen_serial"; }; const skills = await listSkills(); const requested = body.orchestratorId?.trim(); @@ -356,14 +401,19 @@ export async function handleBooksApi( json(res, 400, { error: "没有可用的编排器技能包" }); return true; } - const book = createBook({ title: body.title }); + const rawMode = body.creationMode; + const creationMode = + rawMode === "listen_serial" || rawMode === "recipe" || rawMode === "dictate" + ? rawMode + : "dictate"; + const book = createBook({ title: body.title, creationMode }); updateBook(book.id, { activeSkillId: director.name, activeSkillName: director.description?.split("\n")[0]?.slice(0, 80) || director.name, orchestratorId: director.name, orchestratorName: director.name, + creationMode, }); - const creationMode = "dictate"; const session = await sessionManager.createForBook( book.id, director.name, diff --git a/src/server/display-labels.ts b/src/server/display-labels.ts index 3bf5307..3097ed3 100644 --- a/src/server/display-labels.ts +++ b/src/server/display-labels.ts @@ -55,6 +55,7 @@ const PHASE_UNIT_LABELS: Record = { const CREATION_MODE_LABELS: Record = { recipe: "工序编排", dictate: "对话落盘", + listen_serial: "听书连载", }; const SKILL_PACK_LABELS: Record = { diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index e6a1266..396ec4a 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -93,6 +93,7 @@ import { sessionSkillPackId, skillPacksMatch, } from "../book/skill-id.js"; +import type { RequestOutline } from "../llm/context-outline.js"; import { pruneContextTraces, type LlmContextTrace, @@ -102,6 +103,8 @@ import type { BlackboardItem } from "../types/blackboard.js"; import { Blackboard } from "../blackboard/blackboard.js"; import { parseWorkerSetYaml } from "../skills/worker-set-parse.js"; import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.js"; +import { parseReplyDataContract } from "../skills/reply-contract.js"; +import { projectPresentBlocks } from "../skills/present-project.js"; import { CREATION_ACCEPTED_CONTENT_TAG, CREATION_ACCEPTED_UNITS_TAG, @@ -182,6 +185,16 @@ import { sortDictateProducts, type DictateProduct, } from "../dictate/index.js"; +import { + LISTEN_SERIAL_MODE_VALUE, + isListenSerialModeValue, + markListenConsumed, + readListenState, + runListenSerialTurn, + toListenView, + type ListenSerialView, +} from "../listen-serial/index.js"; +import { synthesizeSpeech, type TtsSynthesizeResult } from "../tts/index.js"; import { formatQuestionAnswersForAi, formatQuestionAnswersForDisplay, @@ -255,6 +268,8 @@ export type ChatMessage = { thinking?: string; /** 该次 LLM 请求实际携带的全量上下文(观察用;按设置只保留最新 N 条) */ contextTrace?: LlmContextTrace; + /** 本条回复对应的各次请求标签轮廓(选题、填写都在;不含正文) */ + requestOutlines?: RequestOutline[]; /** run 验收后过程讨论折叠(主 feed 隐藏);创作 messages 不删不藏 */ compressed?: boolean; tokenUsage?: MessageTokenUsage; @@ -323,6 +338,14 @@ export type SessionView = { block_labels?: Record; empty_states?: Record; }; + /** 正文组成固定前端。游玩和开场把 present.v1 灌进去,替换示例内容。 */ + presentationFrontend?: { html: string; css: string; js: string }; + /** 正文组成数据契约里的区。格式化时按 id 填进对应 data-block。 */ + presentationSlots?: Array<{ id: string; type: string }>; + /** + * 程序投影块(来自 变量.当前 等)。前端灌 present 时与消息正文合并,随真值刷新。 + */ + presentationProjection?: Record; /** 总管正在输出的思维链(流式,轮询用) */ agentThinking?: string; /** Agent / Worker 流式输出(轮询用) */ @@ -333,10 +356,12 @@ export type SessionView = { */ runActive?: boolean; resumed?: boolean; - /** 创作进料:recipe=配方节点流;dictate=转述整理直填 */ - creationMode?: "recipe" | "dictate"; + /** 创作进料:recipe=配方节点流;dictate=转述整理;listen_serial=听书连载 */ + creationMode?: "recipe" | "dictate" | "listen_serial"; /** 转述式已写入黑板的产物(按相对序) */ dictateProducts?: Array<{ tag: string; content: string; order?: number }>; + /** 听书连载投影(大纲/正文缓冲/未读字数) */ + listenSerial?: ListenSerialView; tokenStats?: { sessionTotal: number; sessionCached?: number; @@ -555,8 +580,16 @@ export class SessionManager { this.writeOpeningPersonaChoice(s, true); } + // 与进游玩一致:用完整文档(含 present.v1),勿用侧栏预览的压扁正文 + const rawOnBoard = board.getContentByTag(picked.tag)?.trim() ?? ""; + const body = + splitOpeningDocument( + rawOnBoard || picked.document || picked.text, + ).text.trim() || + picked.document?.trim() || + picked.text.trim(); const expanded = this.expandDirectivesText( - picked.text, + body, this.playDirectiveContext(s), ); board.write({ @@ -566,7 +599,13 @@ export class SessionManager { }); const openingAt = s.messages.findIndex((m) => m.title === "开场白"); if (openingAt >= 0) { - s.messages[openingAt] = this.playOpeningMessage(expanded); + // 保留 id,前端才能 patch 呈现壳 forceFill,而不是拆 iframe 后软灌样本槽 + s.messages[openingAt] = this.playOpeningMessage( + expanded, + s.messages[openingAt]!.id, + ); + } else { + s.messages.unshift(this.playOpeningMessage(expanded)); } if (s.bookId) this.persist(s); return this.toView(sessionId); @@ -613,11 +652,13 @@ export class SessionManager { const next = applyContextOrderEdit(current!, edit); const serialized = serializeContextOrder(next); + const orderMeta = bb.getLatestByTag(CONTEXT_ORDER_TAG)?.metadata; bb.write({ tag: CONTEXT_ORDER_TAG, content: serialized, source: "user", + metadata: orderMeta, }); if (workerSetRaw) { @@ -631,6 +672,7 @@ export class SessionManager { tag: targetTag, content: mergeContextOrderIntoWorkerSetJson(workerSetRaw, next), source: "user", + metadata: bb.getLatestByTag(targetTag)?.metadata, }); } } else { @@ -639,6 +681,7 @@ export class SessionManager { tag: CONTEXT_ORDER_TAG, content: serialized, source: "user", + metadata: orderMeta, }); } @@ -670,7 +713,7 @@ export class SessionManager { bookId?: string, preselectSkillId?: string, selectedRecipeId?: string, - opts?: { creationMode?: "recipe" | "dictate" }, + opts?: { creationMode?: "recipe" | "dictate" | "listen_serial" }, ): Promise { const id = randomUUID(); const messages: ChatMessage[] = []; @@ -713,18 +756,22 @@ export class SessionManager { } else { await runtime.start(); } - const useDictate = opts?.creationMode === "dictate"; + const creationMode = opts?.creationMode ?? "recipe"; + const useDictate = creationMode === "dictate"; + const useListen = creationMode === "listen_serial"; await this.refreshRecipeOptions( managed, - useDictate ? "dictate" : "recipe", + useDictate || useListen ? "dictate" : "recipe", ); if (selectedRecipeId?.trim()) { await this.applySelectedRecipe(managed, selectedRecipeId.trim(), { - seedFlow: !useDictate, + seedFlow: !useDictate && !useListen, }); } - if (useDictate) { + if (useListen) { + this.enterListenSerialMode(managed); + } else if (useDictate) { this.enterDictateMode(managed); } else { await this.maybeKickAgentAfterDemand(runtime); @@ -780,6 +827,145 @@ export class SessionManager { ); } + /** 听书连载:薄上下文连续产文,不走对话落盘工具环 */ + private enterListenSerialMode(s: ManagedSession): void { + const board = s.runtime.getBlackboard(); + board.write({ + tag: CREATION_INTAKE_MODE_TAG, + content: LISTEN_SERIAL_MODE_VALUE, + source: "runtime", + }); + s.runtime.enterDictateIntake( + "听书连载:用几句话描述题材与爽点;确认后我会生成大纲并自动补正文。不满意就说一句意见改后面。", + ); + const welcome = + "听书连载:说几句你想听的故事(题材、人设、爽点即可)。我会轻回问或给出草案;确认后自动出大纲和正文缓冲。听的过程中随时一句意见,只改后面的走向。"; + s.messages.push({ + id: randomUUID(), + role: "system", + text: welcome, + createdAt: new Date().toISOString(), + kind: "orchestrator_prompt", + actor: "orchestrator", + title: "听书连载", + body: welcome, + }); + } + + private isListenSerialMode(s: ManagedSession): boolean { + return isListenSerialModeValue( + s.runtime.getBlackboard().getContentByTag(CREATION_INTAKE_MODE_TAG), + ); + } + + private creationIntakeMode( + s: ManagedSession, + ): "recipe" | "dictate" | "listen_serial" { + if (this.isListenSerialMode(s)) return "listen_serial"; + if (this.isDictateMode(s)) return "dictate"; + return "recipe"; + } + + private extractListenDialogue( + messages: ChatMessage[], + ): Array<{ role: "user" | "assistant"; text: string }> { + const out: Array<{ role: "user" | "assistant"; text: string }> = []; + for (const m of messages) { + if (m.kind === "user_input" || m.role === "user") { + const t = (m.body ?? m.text ?? "").trim(); + if (t) out.push({ role: "user", text: t }); + continue; + } + if (m.kind === "listen_reply") { + const t = (m.body ?? m.text ?? "").trim(); + if (t) out.push({ role: "assistant", text: t }); + } + } + return out; + } + + private async runListenSerialTurnForSession( + s: ManagedSession, + userText: string, + ): Promise { + const llm = createDefaultMainAgentLlm(s.trackingRef, () => + this.personaForManaged(s), + ); + const dialogue = this.extractListenDialogue(s.messages); + const result = await runListenSerialTurn({ + llm, + board: s.runtime.getBlackboard(), + dialogue, + userText, + }); + const keep = normalizeContextTraceKeepLatest( + loadAppSettings().contextTraceKeepLatest, + ); + const requestOutlines = s.trackingRef.current.pendingOutlines; + const pendingTrace = s.trackingRef.current.pendingContextTrace; + const tokenUsage = s.trackingRef.current.pendingUsage; + s.trackingRef.current.pendingOutlines = undefined; + s.trackingRef.current.pendingContextTrace = undefined; + s.trackingRef.current.pendingUsage = undefined; + s.messages.push({ + id: randomUUID(), + role: "system", + text: result.reply, + createdAt: new Date().toISOString(), + kind: "listen_reply", + actor: "orchestrator", + title: "听书连载", + body: result.reply, + ...(tokenUsage ? { tokenUsage } : {}), + ...(keep > 0 && pendingTrace ? { contextTrace: pendingTrace } : {}), + ...(requestOutlines?.length ? { requestOutlines } : {}), + }); + s.messages = pruneContextTraces(s.messages, keep); + s.runtime.enterDictateIntake( + result.softStop + ? "本段已告一段落。输入「下一波」或「收工」,或一句意见改向。" + : "继续听/读,或说「继续」补货;不满意直接一句意见。", + ); + } + + /** 听书:标记已消费字数(阅读进度) */ + markListenSerialConsumed( + sessionId: string, + consumedChars: number, + ): SessionView { + const s = this.require(sessionId); + if (!this.isListenSerialMode(s)) { + throw new Error("当前不是听书连载模式"); + } + markListenConsumed(s.runtime.getBlackboard(), consumedChars); + if (s.bookId) this.persist(s); + return this.toView(sessionId); + } + + /** 听书:按节合成语音(默认 Edge) */ + async synthesizeListenSection( + sessionId: string, + sectionId: string, + ): Promise { + const s = this.require(sessionId); + if (!this.isListenSerialMode(s)) { + throw new Error("当前不是听书连载模式"); + } + const id = sectionId.trim(); + if (!id) throw new Error("需要 sectionId"); + const state = readListenState(s.runtime.getBlackboard()); + const section = state.sections.find((x) => x.id === id); + if (!section?.text?.trim()) { + throw new Error("未找到该节正文"); + } + const spoken = `【${section.title}】\n${section.text}`.trim(); + const result = await synthesizeSpeech({ + text: spoken, + cacheKey: `${sessionId}:${id}`, + }); + return { ...result, sectionId: id, title: section.title }; + } + private listDictateProducts(s: ManagedSession): DictateProduct[] { const board = s.runtime.getBlackboard(); const products: DictateProduct[] = []; @@ -897,11 +1083,10 @@ export class SessionManager { if (dictateProductFamily(tag) === OPENING_PRODUCT_FAMILY) { const split = splitOpeningDocument(content); if (split.persona) { - ensureProtagonistFromPersona( - board, - split.persona, - protagonistTagForOpeningSlot(tag), - ); + const personaTag = protagonistTagForOpeningSlot(tag); + ensureProtagonistFromPersona(board, split.persona, personaTag, { + overwrite: personaTag !== PROTAGONIST_TAG, + }); } // 仅有用户角色 meta、无叙事:抬到主角设定,不污染开场白 if (split.persona && !split.text.trim()) { @@ -1009,6 +1194,15 @@ export class SessionManager { s.messages.length, this.captureCheckpoint(s), ); + const keep = normalizeContextTraceKeepLatest( + loadAppSettings().contextTraceKeepLatest, + ); + const requestOutlines = s.trackingRef.current.pendingOutlines; + const pendingTrace = s.trackingRef.current.pendingContextTrace; + const tokenUsage = s.trackingRef.current.pendingUsage; + s.trackingRef.current.pendingOutlines = undefined; + s.trackingRef.current.pendingContextTrace = undefined; + s.trackingRef.current.pendingUsage = undefined; s.messages.push({ id: randomUUID(), role: "system", @@ -1019,7 +1213,11 @@ export class SessionManager { title: "转述整理", body: result.reply, ...(thinking ? { thinking } : {}), + ...(tokenUsage ? { tokenUsage } : {}), + ...(keep > 0 && pendingTrace ? { contextTrace: pendingTrace } : {}), + ...(requestOutlines?.length ? { requestOutlines } : {}), }); + s.messages = pruneContextTraces(s.messages, keep); s.runtime.enterDictateIntake(); } @@ -1494,6 +1692,7 @@ export class SessionManager { // 转述聊天常夹确认/建议,不能当开场白 if ( kind === "dictate_reply" || + kind === "listen_reply" || kind === "agent_tool" || kind === "orchestrator_thinking" || kind === "worker_questions" @@ -1830,9 +2029,9 @@ export class SessionManager { ); } - private playOpeningMessage(text: string): ChatMessage { + private playOpeningMessage(text: string, keepId?: string): ChatMessage { return { - id: randomUUID(), + id: keepId || randomUUID(), role: "system", text, createdAt: new Date().toISOString(), @@ -1917,6 +2116,29 @@ export class SessionManager { return count; } + /** + * 切换/改了全局用户角色:游玩层开场按新 @玩家 重展开,并改用全局角色卡。 + */ + rebindPlayOpeningsForActivePersona(): number { + let count = 0; + for (const [sessionId, s] of this.sessions) { + if (!isPlayLayerActive(s.runtime.getSession().slots)) continue; + if (inferLifecycleStage(s.runtime.getSession()) !== "play") continue; + const aux = buildPlayAuxView( + s.runtime.getBlackboard(), + s.runtime.getSession().slots, + ); + if (!aux.openings.length) continue; + try { + this.selectPlayOpening(sessionId, aux.selectedOpeningIndex, false); + count += 1; + } catch { + /* 单局失败不挡其它局 */ + } + } + return count; + } + async sendMessage( id: string, text: string, @@ -1983,13 +2205,15 @@ export class SessionManager { const inPlay = isPlayLayerActive(sess.slots) || inferLifecycleStage(sess) === "play"; - if ( - this.isDictateMode(s) && - reason?.kind === "input" && - !inPlay - ) { - await this.runDictateTurnForSession(s, trimmed || text); - return; + if (reason?.kind === "input" && !inPlay) { + if (this.isListenSerialMode(s)) { + await this.runListenSerialTurnForSession(s, trimmed || text); + return; + } + if (this.isDictateMode(s)) { + await this.runDictateTurnForSession(s, trimmed || text); + return; + } } } if (reason?.kind === "approve_step") { @@ -2237,13 +2461,15 @@ export class SessionManager { const inPlay = isPlayLayerActive(sess.slots) || inferLifecycleStage(sess) === "play"; - if ( - this.isDictateMode(s) && - reason?.kind === "input" && - !inPlay - ) { - await this.runDictateTurnForSession(s, trimmed); - return; + if (reason?.kind === "input" && !inPlay) { + if (this.isListenSerialMode(s)) { + await this.runListenSerialTurnForSession(s, trimmed); + return; + } + if (this.isDictateMode(s)) { + await this.runDictateTurnForSession(s, trimmed); + return; + } } await s.runtime.submitInput(trimmed); }); @@ -3100,6 +3326,7 @@ export class SessionManager { undefined; const pendingTrace = managed.trackingRef.current.pendingContextTrace; let contextTrace: LlmContextTrace | undefined; + let requestOutlines: RequestOutline[] | undefined; if ( pendingTrace && classified && @@ -3112,10 +3339,19 @@ export class SessionManager { contextTrace = pendingTrace; } managed.trackingRef.current.pendingContextTrace = undefined; + requestOutlines = managed.trackingRef.current.pendingOutlines; + managed.trackingRef.current.pendingOutlines = undefined; } managed.messages.push( - this.msg("system", text, usage, thinking, contextTrace), + this.msg( + "system", + text, + usage, + thinking, + contextTrace, + requestOutlines?.length ? requestOutlines : undefined, + ), ); managed.messages = pruneContextTraces( managed.messages, @@ -3405,6 +3641,7 @@ export class SessionManager { tokenUsage?: MessageTokenUsage, thinking?: string, contextTrace?: LlmContextTrace, + requestOutlines?: RequestOutline[], ): ChatMessage { const base = { id: randomUUID(), @@ -3414,6 +3651,7 @@ export class SessionManager { ...(tokenUsage ? { tokenUsage } : {}), ...(thinking ? { thinking } : {}), ...(contextTrace ? { contextTrace } : {}), + ...(requestOutlines?.length ? { requestOutlines } : {}), }; if (role === "user") { return { @@ -3812,10 +4050,12 @@ export class SessionManager { lifecycleStage, { workerSetYaml }, ); - const shellAdapt = parseShellAdaptationFromReplyFormat( + const replyFormatRaw = s.runtime.getBlackboard().getContentByTag("设计.正文组成") ?? - s.runtime.getBlackboard().getContentByTag("设计.回复格式"), - ); + s.runtime.getBlackboard().getContentByTag("设计.0层正文组成") ?? + s.runtime.getBlackboard().getContentByTag("设计.回复格式"); + const shellAdapt = parseShellAdaptationFromReplyFormat(replyFormatRaw); + const replyContract = parseReplyDataContract(replyFormatRaw); return { id, @@ -3831,10 +4071,13 @@ export class SessionManager { actions, pipeline: buildPipeline(session), lifecycleStage, - creationMode: this.isDictateMode(s) ? "dictate" : "recipe", + creationMode: this.creationIntakeMode(s), dictateProducts: this.isDictateMode(s) ? this.listDictateProducts(s) : undefined, + listenSerial: this.isListenSerialMode(s) + ? toListenView(readListenState(s.runtime.getBlackboard())) + : undefined, playLayerActive: isPlayLayerActive(session.slots), playReady: canEnterPlay(session), hasProduct: Boolean( @@ -3895,6 +4138,27 @@ export class SessionManager { ...shellAdapt.tweaks, } : undefined, + presentationFrontend: replyContract?.frontend, + presentationSlots: replyContract + ? [...replyContract.modelBlocks, ...replyContract.programBlocks].map((block) => ({ + id: block.id, + type: block.type, + })) + : undefined, + presentationProjection: (() => { + if (!replyContract?.programBlocks.length) return undefined; + const board = s.runtime.getBlackboard(); + const projected = projectPresentBlocks({ + contract: replyContract, + statusBarRaw: + board.getContentByTag("设计.监控栏") ?? + board.getContentByTag("设计.回复呈现"), + currentVarsRaw: board.getContentByTag("变量.当前"), + topologyRaw: board.getContentByTag("运行.拓扑"), + readTag: (tag) => board.getContentByTag(tag), + }); + return Object.keys(projected).length ? projected : undefined; + })(), liveStream: this.buildLiveStreamView(id), agentThinking: this.agentThinkingLive.get(id), runActive: Boolean(s.runAbort), diff --git a/src/server/settings-handlers.ts b/src/server/settings-handlers.ts index 6555b98..3df7763 100644 --- a/src/server/settings-handlers.ts +++ b/src/server/settings-handlers.ts @@ -613,8 +613,13 @@ export async function handleSettingsApi( activate: body.activate, creationDefault: body.creationDefault, }); + const reboundSessions = + body.activate === false + ? 0 + : sessionManager.rebindPlayOpeningsForActivePersona(); json(res, 201, { ...personasPayload({ persona }), + reboundSessions, }); } catch (e) { json(res, 400, { @@ -634,8 +639,11 @@ export async function handleSettingsApi( if (activate && req.method === "POST") { try { const persona = setActivePersonaId(id); + const reboundSessions = + sessionManager.rebindPlayOpeningsForActivePersona(); json(res, 200, { ...personasPayload({ persona, activeId: persona.id }), + reboundSessions, }); } catch (e) { json(res, 404, { @@ -653,8 +661,13 @@ export async function handleSettingsApi( creationDefault?: boolean; }; const persona = updatePersona(id, body); + const reboundSessions = + getActivePersona()?.id === persona.id + ? sessionManager.rebindPlayOpeningsForActivePersona() + : 0; json(res, 200, { ...personasPayload({ persona }), + reboundSessions, }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -666,8 +679,11 @@ export async function handleSettingsApi( if (req.method === "DELETE") { try { deletePersona(id); + const reboundSessions = + sessionManager.rebindPlayOpeningsForActivePersona(); json(res, 200, { ...personasPayload({ ok: true }), + reboundSessions, }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); diff --git a/src/server/web-server.ts b/src/server/web-server.ts index 62252d5..f28612d 100644 --- a/src/server/web-server.ts +++ b/src/server/web-server.ts @@ -230,6 +230,54 @@ const server = createServer(async (req, res) => { return; } + if (req.method === "POST" && sub === "/listen/consume") { + const body = JSON.parse(await readBody(req)) as { + consumedChars?: number; + }; + const n = Number(body.consumedChars); + if (!Number.isFinite(n) || n < 0) { + json(res, 400, { error: "需要 consumedChars ≥ 0" }); + return; + } + try { + const view = sessionManager.markListenSerialConsumed(sessionId, n); + json(res, 200, view); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "进度更新失败", + }); + } + return; + } + + if (req.method === "GET" && sub === "/listen/tts") { + const sectionId = (url.searchParams.get("sectionId") ?? "").trim(); + if (!sectionId) { + json(res, 400, { error: "需要 sectionId" }); + return; + } + try { + const audio = await sessionManager.synthesizeListenSection( + sessionId, + sectionId, + ); + res.writeHead(200, { + "Content-Type": audio.contentType, + "Content-Length": audio.audio.length, + "Cache-Control": audio.cached ? "private, max-age=86400" : "no-store", + "X-TTS-Provider": audio.provider, + "X-TTS-Voice": audio.voice, + "X-TTS-Cached": audio.cached ? "1" : "0", + }); + res.end(audio.audio); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "语音合成失败", + }); + } + return; + } + if (req.method === "POST" && sub === "/answers") { const body = JSON.parse(await readBody(req)) as { answers?: Array<{ diff --git a/src/types/book-session.ts b/src/types/book-session.ts index 6af2216..4247dfb 100644 --- a/src/types/book-session.ts +++ b/src/types/book-session.ts @@ -39,7 +39,22 @@ export type PersistedChatMessage = { messages: Array<{ role: string; content: string }>; charCount: number; model?: string; + segments?: Array<{ tag: string; chars: number; hash: string }>; }; + /** 各次请求的标签轮廓,不含正文 */ + requestOutlines?: Array<{ + at: string; + caller: string; + step?: string; + turnId?: string; + model?: string; + promptTokens?: number; + completionTokens?: number; + cachedTokens?: number; + cacheMissTokens?: number; + cacheWriteTokens?: number; + segments: Array<{ tag: string; chars: number; hash: string }>; + }>; branchGroupId?: string; branchIndex?: number; branchTotal?: number; diff --git a/src/types/book.ts b/src/types/book.ts index 8b5f0dc..c82aa14 100644 --- a/src/types/book.ts +++ b/src/types/book.ts @@ -1,9 +1,14 @@ +/** 进料/创作方式:一书一方式,目录按此分夹 */ +export type BookCreationMode = "recipe" | "dictate" | "listen_serial"; + export type BookProject = { id: string; title: string; /** 用户选定或 agent 确认的 skill 包 */ activeSkillId?: string; activeSkillName?: string; + /** 新建时选定;缺省视为 dictate(旧书) */ + creationMode?: BookCreationMode; preview: string; sessionIds: string[]; /** 最近一次持久化的 sessionId,用于续作 */ @@ -22,6 +27,7 @@ export type BookSummary = Pick< | "title" | "activeSkillId" | "activeSkillName" + | "creationMode" | "preview" | "updatedAt" | "orchestratorId" diff --git a/tests/display-labels.test.ts b/tests/display-labels.test.ts index 4ae875f..0f59b39 100644 --- a/tests/display-labels.test.ts +++ b/tests/display-labels.test.ts @@ -18,6 +18,7 @@ describe("display-labels", () => { it("maps creation intake modes and recipe display names", () => { expect(displayCreationModeLabel("recipe")).toBe("工序编排"); expect(displayCreationModeLabel("dictate")).toBe("对话落盘"); + expect(displayCreationModeLabel("listen_serial")).toBe("听书连载"); expect(displaySkillPackLabel("world-simulator")).toBe("回合推演"); expect(displaySkillPackLabel("快穿短局")).toBe("快穿短局"); }); diff --git a/tests/feed-prefs.test.ts b/tests/feed-prefs.test.ts new file mode 100644 index 0000000..cf6a2c2 --- /dev/null +++ b/tests/feed-prefs.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { + applyPlayAuxLlmBubbles, + isPlayAuxLlmBubblesEnabled, + isPlayAuxLlmOutput, +} from "../web/feed-prefs.js"; + +describe("play aux LLM bubbles", () => { + beforeEach(() => { + applyPlayAuxLlmBubbles(false); + }); + afterEach(() => { + applyPlayAuxLlmBubbles(false); + }); + + it("stores the more-menu preference", () => { + expect(isPlayAuxLlmBubblesEnabled()).toBe(false); + applyPlayAuxLlmBubbles(true); + expect(isPlayAuxLlmBubblesEnabled()).toBe(true); + }); + + it("treats auditor worker_output as aux and world-simulator as main", () => { + const play = { lifecycleStage: "play" }; + expect( + isPlayAuxLlmOutput( + { role: "assistant", kind: "worker_output", actor: "auditor" }, + play, + ), + ).toBe(true); + expect( + isPlayAuxLlmOutput( + { + role: "assistant", + kind: "worker_output", + actor: "world-simulator", + title: "主世界层 · 产出", + }, + play, + ), + ).toBe(false); + expect( + isPlayAuxLlmOutput( + { + role: "assistant", + kind: "worker_output", + actor: "narrator", + title: "叙事转述 · 产出", + }, + play, + ), + ).toBe(false); + }); +}); diff --git a/web/agent-ui.js b/web/agent-ui.js index 6ad4d4c..8360b18 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -9,7 +9,7 @@ import { renderQuestionsCard, } from "./questions-ui.js"; import { displayCreationModeLabel, displayWorkerLabel, formatWorkerDisplayTitle, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js"; -import { fillPlayAux, hidePlayAuxTabs } from "./play-aux.js"; +import { capturePlayTableEdits, fillPlayAux, hidePlayAuxTabs } from "./play-aux.js"; import { PRESENT_SHELL_IDS, parsePresentDoc, @@ -24,8 +24,10 @@ import { presentSourceBytes, previewPacket, queuePresentFrame, + updatePresentFramePacket, watchPresentFrames, } from "./present-frame.js"; +import { isPlayAuxLlmBubblesEnabled, isPlayAuxLlmOutput } from "./feed-prefs.js"; const HIDE_KINDS = new Set(["worker_stub"]); @@ -44,6 +46,7 @@ const MSG_CLASS = { orchestrator_prompt: "agent", orchestrator_assessment: "agent", dictate_reply: "agent", + listen_reply: "agent", worker_running: "skill", worker_output: "skill", worker_questions: "questions", @@ -58,6 +61,7 @@ const MSG_LABEL = { orchestrator_prompt: "编排器", orchestrator_assessment: "编排器 · 内容评价", dictate_reply: "对话落盘", + listen_reply: "听书连载", worker_running: "执行单元", worker_output: "执行单元", worker_questions: "提问", @@ -120,6 +124,7 @@ export function isCreationCloserDone(view) { export function canOfferSaveProduct(view) { if (!view || isPlayView(view)) return false; + if (view.creationMode === "listen_serial") return false; if (view.creationMode === "dictate") { const products = view.dictateProducts; return Array.isArray(products) && products.length > 0; @@ -129,6 +134,7 @@ export function canOfferSaveProduct(view) { export function canOfferEnterPlay(view) { if (!view || isPlayView(view)) return false; + if (view.creationMode === "listen_serial") return false; return canOfferSaveProduct(view) || Boolean(view.hasProduct); } @@ -773,6 +779,56 @@ function openContentPeek(payload) { document.getElementById("msg-peek-close")?.focus?.(); } +/** 模型落盘原文:尽量 pretty JSON,整块一个滚动条(走 msg-peek-body) */ +function formatRawModelOutputHtml(raw, thinking) { + const parts = []; + const think = String(thinking ?? "").trim(); + if (think) { + parts.push( + `

思维链

${esc(think)}
`, + ); + } + let body = String(raw ?? "").trim(); + if (!body) { + parts.push(`

(无模型正文)

`); + } else { + let klass = "review-feed-plain"; + if (body.startsWith("{") || body.startsWith("[")) { + try { + body = JSON.stringify(JSON.parse(body), null, 2); + klass = "json-pretty"; + } catch { + /* keep raw */ + } + } + parts.push( + `

模型输出

${esc(body)}
`, + ); + } + return parts.join(""); +} + +function openMessageRawPeek(card) { + if (!card) return; + const raw = + card.dataset.originalText || + card.querySelector(".msg-body:not(.msg-body-editing), .coord-text")?.textContent || + ""; + const thinking = + card.querySelector(".msg-thinking-body")?.textContent || + card.dataset.thinking || + ""; + const label = + card.querySelector(".msg-tag")?.textContent?.trim() || + card.querySelector(".coord-kind")?.textContent?.trim() || + "消息"; + openContentPeek({ + title: "查看原文", + subtitle: `${label} · 未过呈现壳的模型落盘正文`, + html: formatRawModelOutputHtml(raw, thinking), + }); +} + function formatDictatePeekHtml(content) { return renderStoredProductHtml(content); } @@ -935,6 +991,7 @@ function showMsgMenu(card, x, y) { const editBtn = document.getElementById("msg-menu-edit"); const delBtn = document.getElementById("msg-menu-delete"); const rollbackBtn = document.getElementById("msg-menu-rollback"); + const rawBtn = document.getElementById("msg-menu-raw"); const ctxBtn = document.getElementById("msg-menu-context"); const compareBtn = document.getElementById("msg-menu-compare"); if (editBtn) editBtn.toggleAttribute("hidden", !canEdit); @@ -944,6 +1001,13 @@ function showMsgMenu(card, x, y) { rollbackBtn.textContent = rollbackMode === "pending-input" ? "回到这一轮输入" : "回退到这里"; } + if (rawBtn) { + const hasBody = Boolean( + (card.dataset.originalText || "").trim() || + card.querySelector(".msg-body:not(.msg-body-editing), .coord-text")?.textContent?.trim(), + ); + rawBtn.toggleAttribute("hidden", !hasBody); + } if (ctxBtn) ctxBtn.toggleAttribute("hidden", !hasContext); if (compareBtn) compareBtn.toggleAttribute("hidden", !hasContext); msgMenuState.messageId = card.dataset.messageId ?? null; @@ -982,6 +1046,10 @@ function wireMsgActionMenu(handlers) { } return; } + if (action === "view-raw") { + openMessageRawPeek(card); + return; + } if (action === "view-context") { msgMenuState.handlers?.onViewContext?.(contextMessageId); return; @@ -1265,6 +1333,7 @@ function isPlayFinalReply(msg, view) { function isPlayProcessMessage(msg, view) { if (!isPlayView(view) || msg.role === "user") return false; + if (isPlayAuxLlmOutput(msg, view) && isPlayAuxLlmBubblesEnabled()) return false; return !isPlayFinalReply(msg, view); } @@ -1314,7 +1383,89 @@ function renderStoredProductHtml(content) { return `
${safeBodyHtml(trimmed)}
`; } -function formatPlayPresentHtml(body, view) { +function playFramePacket(trimmed, parsed, view) { + const presentView = parsed ? parsePresentDoc(parsed, "prose") : null; + let packet = null; + if (presentView?.packet?.blocks && Object.keys(presentView.packet.blocks).length) { + packet = presentView.packet; + } else { + const nested = openingPresentBlocks(parsed); + if (nested) packet = { schema: "present.v1", blocks: nested }; + else { + const scene = + parsed && typeof parsed === "object" + ? typeof parsed.正文 === "string" + ? parsed.正文 + : typeof parsed.body === "string" + ? parsed.body + : typeof parsed.brief === "string" + ? parsed.brief + : "" + : ""; + const plain = (scene || (!parsed ? trimmed : "")).trim(); + if (plain) packet = { schema: "present.v1", blocks: { body: plain } }; + } + } + if (!packet) return null; + const projected = view?.presentationProjection; + if (projected && typeof projected === "object" && !Array.isArray(projected)) { + return { + ...packet, + blocks: { ...packet.blocks, ...projected }, + }; + } + return packet; +} + +function openingPresentBlocks(parsed) { + if (!parsed || typeof parsed !== "object") return null; + const body = parsed.正文; + const direct = body && typeof body === "object" ? body.blocks : null; + if (direct && typeof direct === "object" && !Array.isArray(direct)) return direct; + const list = body && typeof body === "object" ? body.开场白候选 : parsed.开场白候选; + const first = Array.isArray(list) ? list[0] : null; + const blocks = first?.正文?.blocks; + if (blocks && typeof blocks === "object" && !Array.isArray(blocks)) return blocks; + return null; +} + +function rememberPresentation(view) { + if (!view || typeof view !== "object") return; + sessionPresentation = isFrontendBundle(view.presentationFrontend) + ? { + frontend: view.presentationFrontend, + slots: Array.isArray(view.presentationSlots) ? view.presentationSlots : [], + } + : null; +} + +let sessionPresentation = null; + +function slotsFromContractBody(body) { + const contract = body?.数据契约 || body?.data_contract; + const blocks = contract?.blocks; + if (!blocks || typeof blocks !== "object" || Array.isArray(blocks)) return []; + return Object.entries(blocks) + .map(([id, row]) => ({ + id, + type: (row && typeof row === "object" && (row.类型 || row.type)) || "text", + })) + .filter((slot) => slot.id); +} + +function renderContractPresentHtml(bundle, packet, slots, minHeight) { + if (!packet || !isFrontendBundle(bundle) || presentSourceBytes(bundle) > PRESENT_BYTE_LIMIT) { + return ""; + } + const id = queuePresentFrame(bundle, previewPacket(packet), slots); + return `
+ ${presentFrameSlotHtml(id, minHeight)} + + +
`; +} + +function formatPlayPresentHtml(body, view, messageId) { const trimmed = stripPresentSourceFences((body || "").trim()); if (!trimmed) return `

(无正文)

`; const bundled = splitBracketProductSections(trimmed); @@ -1326,6 +1477,14 @@ function formatPlayPresentHtml(body, view) { }) .join(""); } + const parsed = tryParseJsonDoc(trimmed); + const contractHtml = renderContractPresentHtml( + view?.presentationFrontend, + playFramePacket(trimmed, parsed, view), + view?.presentationSlots, + messageId ? presentMinHeightByMessage.get(messageId) : 0, + ); + if (contractHtml) return contractHtml; const tweaks = view?.presentationTweaks; const fallbackShell = tweaks?.shell_id && PRESENT_SHELL_IDS?.includes?.(tweaks.shell_id) @@ -1342,7 +1501,6 @@ function formatPlayPresentHtml(body, view) { ].includes(tweaks.shell_id) ? tweaks.shell_id : "prose"; - const parsed = tryParseJsonDoc(trimmed); const presentView = parsed ? parsePresentDoc(parsed, fallbackShell) : null; if (presentView) { return renderPresentShellHtml(presentView.packet, esc, { tweaks }); @@ -1364,6 +1522,76 @@ function formatPlayPresentHtml(body, view) { }); } +function messageFeedSig(msg, view) { + const openingSwipe = + isPlayView(view) && String(msg?.title ?? "").trim() === "开场白" + ? `o${view.playAux?.selectedOpeningIndex ?? 0}` + : ""; + const projectionSig = + shouldRenderPlayPresent(msg, view) && view?.presentationProjection + ? JSON.stringify(view.presentationProjection) + : ""; + const auxSig = + isPlayAuxLlmOutput(msg, view) && isPlayAuxLlmBubblesEnabled() ? "aux1" : "aux0"; + return [ + msg.role || "", + msg.kind || "", + msg.actor || "", + msg.title || "", + msg.thinking ? "1" : "0", + msg.contextTrace ? "t" : "", + msg.requestOutlines?.length ? "o" : "", + msgBody(msg, view), + openingSwipe, + projectionSig, + auxSig, + ].join("\u0001"); +} + +function playPresentPacketForUpdate(body, view) { + const trimmed = stripPresentSourceFences((body || "").trim()); + if (!trimmed || splitBracketProductSections(trimmed)) return null; + if (!isFrontendBundle(view?.presentationFrontend)) return null; + return playFramePacket(trimmed, tryParseJsonDoc(trimmed), view); +} + +/** 同一气泡里的呈现窗还在,就只灌新正文,不拆 iframe。 */ +function patchPresentWindow(card, msg, view, sig) { + const frame = card.querySelector("iframe.present-preview-frame"); + if (!frame || !shouldRenderPlayPresent(msg, view)) return false; + const body = msgBody(msg, view); + const packet = playPresentPacketForUpdate(body, view); + if (!packet) return false; + if (!frame.contentWindow) return true; + updatePresentFramePacket(frame, packet); + card.dataset.originalText = body; + card.dataset.feedSig = sig; + const think = card.querySelector(".msg-thinking-body"); + const thinking = String(msg.thinking ?? "").trim(); + if (think && thinking && think.textContent !== thinking) think.textContent = thinking; + // 开场切换:计数 ‹ n/total › 也要跟着改 + if (String(msg?.title ?? "").trim() === "开场白") { + const head = card.querySelector(".msg-head, .msg-foot"); + const existing = head?.querySelector(".msg-variant-nav"); + const html = renderOpeningSwipeHtml(view, msg); + if (html) { + const wrap = document.createElement("div"); + wrap.innerHTML = html; + const next = wrap.firstElementChild; + if (existing && next) existing.replaceWith(next); + else if (head && next && !existing) { + const before = + head.querySelector(".msg-variant-badge, .msg-time, .msg-menu-trigger") || + null; + head.insertBefore(next, before); + } + } else if (existing) { + existing.remove(); + } + } + return true; +} + /** 旧会话 skill 选择提示,新流程不再展示 */ const SKILL_SELECTION_RE = /请选择创作 skill|请选择创作类型/; @@ -1389,23 +1617,20 @@ function syncCoordRailChrome() { const toggleLabel = document.getElementById("coord-rail-toggle-label"); const title = document.getElementById("coord-rail-title"); const products = coordRailMode === "products"; + const listen = coordRailMode === "listen"; document.body.classList.toggle("coord-rail-collapsed", coordRailCollapsed); document.body.dataset.coordRailMode = coordRailMode; - if (toggleLabel) toggleLabel.textContent = products ? "产物" : "对话"; - if (title) title.textContent = products ? "已持久化" : "此前对话"; + const shortLabel = listen ? "正文" : products ? "产物" : "对话"; + const longLabel = listen ? "大纲与正文" : products ? "已持久化" : "此前对话"; + if (toggleLabel) toggleLabel.textContent = shortLabel; + if (title) title.textContent = longLabel; if (toggle) { toggle.setAttribute("aria-expanded", coordRailCollapsed ? "false" : "true"); - toggle.title = coordRailCollapsed - ? products - ? "展开产物" - : "展开此前对话" - : products - ? "收起产物" - : "收起此前对话"; + toggle.title = coordRailCollapsed ? `展开${longLabel}` : `收起${longLabel}`; } if (rail) { rail.dataset.collapsed = coordRailCollapsed ? "1" : "0"; - rail.setAttribute("aria-label", products ? "已持久化产物" : "此前对话"); + rail.setAttribute("aria-label", longLabel); } } @@ -1576,6 +1801,13 @@ function shouldShowInFeed(msg, view) { if (msg.compressed) return false; if (isPlayView(view) && isPlayHousekeepingMessage(msg)) return false; if (isPlayView(view) && kind === "worker_questions") return false; + if ( + isPlayView(view) && + isPlayAuxLlmOutput(msg, view) && + !isPlayAuxLlmBubblesEnabled() + ) { + return false; + } if (isSkillSelectionMessage(msg)) return false; if (isReviewSidecarQuestionStub(msg)) return false; if ( @@ -2415,6 +2647,14 @@ function renderKnownArtifactHtml(doc, opts = {}) { const schema = prepared.schema; const presentView = parsePresentDoc(prepared); if (presentView) { + const framed = sessionPresentation + ? renderContractPresentHtml( + sessionPresentation.frontend, + presentView.packet, + sessionPresentation.slots, + ) + : ""; + if (framed) return framed; return renderPresentShellHtml(presentView.packet, esc, { tweaks: opts.shellTweaks, }); @@ -2788,6 +3028,18 @@ function openingPresentPacket(text) { return presentFromPlain(trimmed, "prose"); } +function renderOpeningPresentHtml(packet) { + if (sessionPresentation) { + const framed = renderContractPresentHtml( + sessionPresentation.frontend, + packet, + sessionPresentation.slots, + ); + if (framed) return framed; + } + return renderPresentShellHtml(packet, esc); +} + function renderOpeningSetupBodyHtml(body) { if (!body || typeof body !== "object" || Array.isArray(body)) return ""; const candidates = collectOpeningCandidates(body); @@ -2798,9 +3050,8 @@ function renderOpeningSetupBodyHtml(body) { .map((row, i) => { const hidden = i === 0 ? "" : " hidden"; const selected = i === 0 ? " is-selected" : ""; - return `
${renderPresentShellHtml( + return `
${renderOpeningPresentHtml( openingPresentPacket(row.text), - esc, )}${renderOpeningPersonaHtml(row.persona)}${renderOpeningVarsHtml(row.variables)}
`; }) .join(""); @@ -2929,7 +3180,12 @@ function renderSpecialtyBodyHtml(body, skill) { { key: "美学纲领", missing: "(未写)" }, { key: "交互范式", missing: "(未写)" }, ]; - const evalKeys = [{ key: "设定逻辑", missing: "(未写)" }]; + const evalKeys = [ + { + key: body.背景与规则 == null && body.设定逻辑 != null ? "设定逻辑" : "背景与规则", + missing: "(未写)", + }, + ]; const used = new Set([...productKeys, ...evalKeys].map((b) => b.key)); const renderBlock = (b, cls) => { @@ -4123,7 +4379,7 @@ function renderObjectSectionsHtml(obj, depth) { if (k === "自评") return renderSelfScoreHtml(v); if (k === "追问") return renderProbeHtml(v); if (k === "frontend" && isFrontendBundle(v)) { - return renderFrontendPreviewBlock(v, obj["示例灌数"]); + return renderFrontendPreviewBlock(v, obj["示例灌数"], slotsFromContractBody(obj)); } return `

${esc(k)}

${renderStructuredValueHtml(v, depth + 1)}
`; }) @@ -4232,7 +4488,7 @@ function renderObjectKvHtml(obj, depth) { const rows = entries .map(([k, v]) => { if (k === "frontend" && isFrontendBundle(v)) { - return renderFrontendPreviewBlock(v, obj["示例灌数"]); + return renderFrontendPreviewBlock(v, obj["示例灌数"], slotsFromContractBody(obj)); } const isComplex = v != null && typeof v === "object"; if (isComplex && isDiagnosticNode(v)) { @@ -5371,12 +5627,14 @@ function renderCoordLine(msg, view, opts = {}) { const rollbackAttr = rollbackMessageId ? ` data-rollback-message-id="${esc(rollbackMessageId)}"` : ""; - const contextAttr = msg.contextTrace ? ` data-has-context="1"` : ""; + const contextAttr = + msg.contextTrace || msg.requestOutlines?.length ? ` data-has-context="1"` : ""; const readOnlyAttr = opts.traceOnly ? ` data-read-only="1"` : ""; const originalAttr = full ? ` data-original-text="${escAttr(full)}"` : ""; const menu = inPack ? "" : ``; + const outlines = Array.isArray(msg.requestOutlines) ? msg.requestOutlines : []; const trace = msg.contextTrace; const variantTotal = Math.max(1, Number(msg.branchTotal) || 1); const variantIndex = Math.min(variantTotal, Math.max(1, (msg.branchIndex ?? 0) + 1)); @@ -5385,20 +5643,25 @@ function renderCoordLine(msg, view, opts = {}) { variantTotal > 1 ? `${variantIndex}/${variantTotal} 版` : "", ].filter(Boolean); const requestHint = requestBits.length ? requestBits.join(" · ") : "本次请求"; - const traceMeta = trace - ? `
- ${esc(trace.caller || msg.tokenUsage?.caller || "unknown")} - ${esc(trace.model || msg.tokenUsage?.model || "model?")} + ${esc(trace?.caller || outlines[0]?.caller || msg.tokenUsage?.caller || "unknown")} + ${esc(trace?.model || outlines[0]?.model || msg.tokenUsage?.model || "model?")} ${msg.tokenUsage?.totalTokens ? `${Number(msg.tokenUsage.totalTokens).toLocaleString("zh-CN")} tok` : ""}
` : ""; - const traceClass = trace ? " coord-line--trace" : ""; + const traceClass = trace || outlines.length ? " coord-line--trace" : ""; return `
${menu}
${esc(who)}${esc(kind)}${fmtTime(msg.createdAt)}${nav}
@@ -5471,11 +5734,12 @@ function getRollbackMessageId(messages, messageId) { return messageIndex >= 0 ? messages[messageIndex + 1]?.id ?? null : null; } -/** @returns {"speak"|"answer"|"review"|"busy"|"dictate"|null} */ +/** @returns {"speak"|"answer"|"review"|"busy"|"dictate"|"listen"|null} */ function resolveDesignSurface(view, loading) { if (isPlayView(view)) return null; if (view.phase === "done") return null; - // 转述:主面始终是对话流;右侧才是产物 + // 听书 / 转述:主面始终是对话流;右侧为大纲正文或产物 + if (view.creationMode === "listen_serial") return "listen"; if (view.creationMode === "dictate") return "dictate"; const busy = Boolean(loading || (view.phase === "running" && !view.waitingReason)); if (busy) return "busy"; @@ -5705,6 +5969,25 @@ function renderSpeakWorkspace(view) { view.hints?.[0] || wr.message || "描述本轮行动、对话或想说的话;由游玩管线推进。"; + } else if (view.creationMode === "listen_serial") { + title = displayCreationModeLabel("listen_serial"); + const ls = view.listenSerial; + const phase = ls?.phase; + if (phase === "awaiting_confirm") { + hint = "回复「确认」开始生成大纲与正文;或继续改需求。"; + } else if (phase === "soft_stop") { + hint = "本段已兑现。输入「下一波」或「收工」,或一句意见改向。"; + } else if (phase === "running") { + hint = + view.hints?.[0] || + wr.message || + "说「继续」补货;不满意直接一句意见改后面。"; + } else { + hint = + view.hints?.[0] || + wr.message || + "用几句话描述题材与爽点;右侧是大纲与正文。"; + } } else if (view.creationMode === "dictate") { title = displayCreationModeLabel("dictate"); hint = @@ -5888,12 +6171,122 @@ function fillCoordRailProducts(view) { syncCoordRailChrome(); } +function renderListenSerialRail(view) { + const ls = view.listenSerial; + if (!ls) { + return `

确认开书后,这里显示大纲与正文缓冲。

`; + } + const brief = [ + ls.thrill ? `

爽点 ${esc(ls.thrill)}

` : "", + ls.world ? `

世界 ${esc(ls.world)}

` : "", + ls.structure + ? `

结构 ${esc(ls.structure)}

` + : "", + ].join(""); + const outline = (ls.outline ?? []) + .map((o) => { + const mark = o.status === "done" ? "✓" : o.status === "cut" ? "×" : "·"; + const pay = o.payoff ? " · 兑现" : ""; + return `
  • ${mark} ${esc(o.title)}${pay}
  • `; + }) + .join(""); + const sections = (ls.sections ?? []) + .map( + (s) => `
    +
    +

    ${esc(s.title)}

    + +
    +
    ${esc(s.text).replace(/\n/g, "
    ")}
    +
    `, + ) + .join(""); + const unread = Number(ls.unreadChars) || 0; + const target = Number(ls.bufferTargetChars) || 0; + const consumed = Number(ls.consumedChars) || 0; + const total = (ls.sections ?? []).reduce((n, s) => n + String(s.text || "").length, 0); + const hasSections = (ls.sections ?? []).length > 0; + return `
    +

    未读约 ${unread} 字 / 缓冲目标 ${target} 字 · 阶段 ${esc(ls.phase)} · Edge TTS

    + ${brief} + ${outline ? `
      ${outline}
    ` : ""} +
    ${sections || `

    尚无正文

    `}
    +
    + + + + +
    +
    `; +} + +function fillCoordRailListen(view, handlers) { + const coordRail = document.getElementById("coord-rail"); + const drawerBody = document.getElementById("coord-drawer-body"); + const drawerCount = document.getElementById("coord-drawer-count"); + if (!coordRail) return; + if (coordRailMode !== "listen") { + coordRailMode = "listen"; + coordRailCollapsed = false; + } + coordRail.hidden = false; + if (drawerBody) { + drawerBody.innerHTML = renderListenSerialRail(view); + if (!drawerBody.dataset.listenConsumeWired) { + drawerBody.dataset.listenConsumeWired = "1"; + drawerBody.addEventListener("click", (e) => { + const stopBtn = e.target.closest("[data-listen-tts-stop]"); + if (stopBtn && drawerBody.contains(stopBtn)) { + handlers?.onListenStop?.(); + return; + } + const chainBtn = e.target.closest("[data-listen-tts-chain]"); + if (chainBtn && drawerBody.contains(chainBtn)) { + const ls = handlers?.getListenSerial?.() ?? view.listenSerial; + const id = ls?.sections?.[0]?.id; + if (id) handlers?.onListenPlay?.(id, { continuous: true }); + return; + } + const playBtn = e.target.closest("[data-listen-tts]"); + if (playBtn && drawerBody.contains(playBtn)) { + const id = playBtn.getAttribute("data-listen-tts") || ""; + if (id) handlers?.onListenPlay?.(id, { continuous: false }); + return; + } + const btn = e.target.closest("[data-listen-consume]"); + if (!btn || !drawerBody.contains(btn)) return; + const mode = btn.getAttribute("data-listen-consume"); + const ls = handlers?.getListenSerial?.() ?? view.listenSerial; + if (!ls) return; + const total = (ls.sections ?? []).reduce( + (n, s) => n + String(s.text || "").length, + 0, + ); + const next = + mode === "all" ? total : Math.max(Number(ls.consumedChars) || 0, total); + handlers?.onListenConsume?.(next); + }); + } + } + const n = view.listenSerial?.sections?.length ?? 0; + if (drawerCount) { + drawerCount.textContent = n ? String(n) : ""; + drawerCount.title = n ? `${n} 节正文` : "尚无正文"; + } + syncCoordRailChrome(); +} + function fillCoordRail(visible, view, handlers) { const coordRail = document.getElementById("coord-rail"); const drawerBody = document.getElementById("coord-drawer-body"); const drawerCount = document.getElementById("coord-drawer-count"); if (!coordRail) return; + if (view.creationMode === "listen_serial" && !isPlayView(view)) { + fillCoordRailListen(view, handlers); + return; + } + // 转述产物栏只在创作层;游玩层与配方开玩一样走回合历史 if (view.creationMode === "dictate" && !isPlayView(view)) { fillCoordRailProducts(view); @@ -5969,7 +6362,91 @@ function mountReviewWorkspace(stage, view, handlers, opts = {}) { } } +/** 上一轮呈现框高度。重绘时先占住,避免框从 1px 长开把阅读位置顶到最上方。 */ +const presentMinHeightByMessage = new Map(); +const scrollFollow = new WeakMap(); +const SCROLL_PIN_SLACK = 120; +let applyingScroll = 0; + +function capturePresentMinHeights(root) { + if (!root) return; + for (const frame of root.querySelectorAll(".present-preview-frame")) { + const id = frame.closest("[data-message-id]")?.dataset?.messageId; + if (!id) continue; + const h = Math.ceil(frame.getBoundingClientRect().height || Number.parseFloat(frame.style.height) || 0); + if (h > 8) presentMinHeightByMessage.set(id, h); + } +} + +function scrollerIsPinned(el) { + const overflow = el.scrollHeight - el.clientHeight; + if (overflow <= 1) return true; + return el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_PIN_SLACK; +} + +function messageAnchorSelector(id) { + const safe = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(id) : String(id).replace(/"/g, '\\"'); + return `[data-message-id="${safe}"]`; +} + +function readScrollAnchor(el) { + if (!el) return { pin: true }; + if (scrollerIsPinned(el)) return { pin: true }; + const rootTop = el.getBoundingClientRect().top; + let anchor = null; + for (const node of el.querySelectorAll("[data-message-id]")) { + const rect = node.getBoundingClientRect(); + if (rect.bottom > rootTop + 4) { + anchor = { id: node.dataset.messageId, offset: rect.top - rootTop }; + break; + } + } + return { pin: false, top: el.scrollTop, anchor }; +} + +function isFollowedScroller(el) { + return el.id === "message-feed" || el.id === "dictate-feed" || el.classList?.contains("dictate-feed"); +} + +function applyScrollAnchor(el, saved) { + if (!el || !saved || !el.isConnected) return; + applyingScroll += 1; + try { + if (saved.pin) { + el.scrollTop = el.scrollHeight; + return; + } + if (saved.anchor?.id) { + const node = el.querySelector(messageAnchorSelector(saved.anchor.id)); + if (node) { + const delta = + node.getBoundingClientRect().top - el.getBoundingClientRect().top - saved.anchor.offset; + el.scrollTop += delta; + return; + } + } + if (typeof saved.top === "number") el.scrollTop = saved.top; + } finally { + requestAnimationFrame(() => { + applyingScroll = Math.max(0, applyingScroll - 1); + }); + } +} + +function settleScroller(el, saved) { + if (!el || !saved) return; + scrollFollow.set(el, saved); + applyScrollAnchor(el, saved); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!el.isConnected) return; + applyScrollAnchor(el, scrollFollow.get(el) || saved); + }); + }); +} + export function renderMessageFeed(view, loading, handlers = {}) { + rememberPresentation(view); const feed = document.getElementById("message-feed"); if (!feed) return; @@ -6014,7 +6491,29 @@ export function renderMessageFeed(view, loading, handlers = {}) { view.waitingReason?.kind === "intake" && view.intake?.fields?.length; const activeQuestions = getActiveQuestions(view); - feed.innerHTML = ""; + const showIntake = intake && hasUserMessages(view); + const feedMode = `${view?.id || ""}|${designWorkspace ? `design:${surface}` : "chat"}|${showIntake ? "in" : ""}`; + const canKeepFeed = + !designWorkspace && + feed.dataset.feedMode === feedMode && + feed.childElementCount > 0; + + const dictateEl = document.getElementById("dictate-feed"); + let feedScroll = { pin: true }; + let dictateScroll = { pin: true }; + if (!canKeepFeed) { + capturePresentMinHeights(feed); + capturePresentMinHeights(stage); + capturePresentMinHeights(dictateEl); + feedScroll = readScrollAnchor(feed); + dictateScroll = readScrollAnchor(dictateEl); + scrollFollow.set(feed, feedScroll); + feed.dataset.rebuilding = "1"; + feed.innerHTML = ""; + delete feed.dataset.rebuilding; + feed.dataset.feedMode = feedMode; + } + if (stage) { stage.hidden = true; stage.innerHTML = ""; @@ -6023,6 +6522,7 @@ export function renderMessageFeed(view, loading, handlers = {}) { coordRail.hidden = true; const drawerBody = document.getElementById("coord-drawer-body"); const drawerCount = document.getElementById("coord-drawer-count"); + capturePlayTableEdits(drawerBody); if (drawerBody) drawerBody.innerHTML = ""; if (drawerCount) drawerCount.textContent = ""; } @@ -6031,8 +6531,7 @@ export function renderMessageFeed(view, loading, handlers = {}) { // 默认把询问卡停回主列底部槽位 if (panelFeed) ensureQuestionsHostIn(panelFeed); - const showIntake = intake && hasUserMessages(view); - if (showIntake && !designWorkspace) { + if (showIntake && !designWorkspace && !feed.querySelector(":scope > .intake-panel")) { const box = document.createElement("div"); box.className = "intake-panel"; box.innerHTML = renderIntakePanel(view.intake, { variant: "feed" }); @@ -6047,23 +6546,28 @@ export function renderMessageFeed(view, loading, handlers = {}) { if (!stage) return; - // 转述:对话画在 stage 内(design-workspace 会藏掉 #message-feed) - if (surface === "dictate") { + // 转述 / 听书:对话画在 stage 内(design-workspace 会藏掉 #message-feed) + if (surface === "dictate" || surface === "listen") { stage.hidden = false; - const recipeName = view.selectedRecipe?.name - ? esc(view.selectedRecipe.name) - : ""; - const dictateKicker = displayCreationModeLabel("dictate"); + const recipeName = + surface === "dictate" && view.selectedRecipe?.name + ? esc(view.selectedRecipe.name) + : ""; + const kicker = displayCreationModeLabel( + surface === "listen" ? "listen_serial" : "dictate", + ); const head = recipeName - ? `
    ${dictateKicker}配方 · ${recipeName}
    ` - : `
    ${dictateKicker}
    `; + ? `
    ${kicker}配方 · ${recipeName}
    ` + : `
    ${kicker}
    `; stage.innerHTML = `${head}
    `; const dictateFeed = stage.querySelector("#dictate-feed"); if (panelFeed) ensureQuestionsHostIn(panelFeed); if (dictateFeed) { appendMessagesToFeed(dictateFeed, visible, view, handlers, activeQuestions, { emptyText: - "直接说你想要的体验与设定。我会整理进右侧产物,并在回复里跟你确认、建议补充。", + surface === "listen" + ? "用几句话描述题材与爽点。确认后我会写大纲和正文;右侧可看缓冲与大纲。" + : "直接说你想要的体验与设定。我会整理进右侧产物,并在回复里跟你确认、建议补充。", loading, }); if (loading) { @@ -6071,7 +6575,10 @@ export function renderMessageFeed(view, loading, handlers = {}) { pending.className = "msg agent msg-assistant-row msg-pending"; pending.id = "msg-live-pending"; const live = view.liveStream; - const label = live?.label ?? view.focus?.action ?? "整理中"; + const label = + live?.label ?? + view.focus?.action ?? + (surface === "listen" ? "连载中" : "整理中"); pending.innerHTML = `
    ${esc(label)}流式输出中${renderRetryRunButton()}
    @@ -6080,7 +6587,7 @@ export function renderMessageFeed(view, loading, handlers = {}) { dictateFeed.appendChild(pending); wireRetryRun(pending, handlers); } - dictateFeed.scrollTop = dictateFeed.scrollHeight; + settleScroller(dictateFeed, dictateScroll); } return; } @@ -6163,6 +6670,7 @@ export function renderMessageFeed(view, loading, handlers = {}) { : "在下方继续对话。", loading, emptyClass: isPlayView(view) ? "empty" : "empty empty-intake", + reconcile: canKeepFeed, }); if (reviewingNow && view.reviewArtifact && !loading && stage && !isPlayView(view)) { @@ -6174,38 +6682,75 @@ export function renderMessageFeed(view, loading, handlers = {}) { } if (loading) { - const pending = document.createElement("article"); - pending.className = "msg agent msg-assistant-row msg-pending"; - pending.id = "msg-live-pending"; + let pending = feed.querySelector(":scope > #msg-live-pending"); + if (!pending) { + pending = document.createElement("article"); + pending.className = "msg agent msg-assistant-row msg-pending"; + pending.id = "msg-live-pending"; + feed.appendChild(pending); + wireRetryRun(pending, handlers); + } const live = view.liveStream; const label = live?.label ?? view.focus?.action ?? "处理中"; - pending.innerHTML = ` -
    -
    ${esc(label)}流式输出中${renderRetryRunButton()}
    -
    ${renderLiveStreamBody(live, view)}
    -
    `; + const body = pending.querySelector(".msg-live-body"); + if (!body) { + pending.innerHTML = ` +
    +
    ${esc(label)}流式输出中${renderRetryRunButton()}
    +
    ${renderLiveStreamBody(live, view)}
    +
    `; + wireRetryRun(pending, handlers); + } else { + updateLiveStreamPanel(view); + } feed.appendChild(pending); - wireRetryRun(pending, handlers); + } else { + feed.querySelector(":scope > #msg-live-pending")?.remove(); } - feed.scrollTop = feed.scrollHeight; + if (!canKeepFeed) settleScroller(feed, feedScroll); if (stage && !stage.hidden) stage.scrollTop = 0; } function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, opts = {}) { const loading = Boolean(opts.loading); + const reconcile = Boolean(opts.reconcile); if (!visible.length && !loading) { - const p = document.createElement("p"); - p.className = opts.emptyClass || "empty empty-intake"; - p.textContent = opts.emptyText || "在下方继续对话。"; - feed.appendChild(p); + if (reconcile) { + for (const card of [...feed.querySelectorAll(":scope > article.msg")]) card.remove(); + } + let empty = reconcile ? feed.querySelector(":scope > .empty") : null; + if (!empty) { + empty = document.createElement("p"); + feed.appendChild(empty); + } + empty.className = opts.emptyClass || "empty empty-intake"; + empty.textContent = opts.emptyText || "在下方继续对话。"; wireMessageFeedActions(feed, handlers); wireMessageContextMenu(feed, handlers); return; } + if (reconcile) feed.querySelector(":scope > .empty")?.remove(); + + const byId = new Map(); + if (reconcile) { + for (const card of feed.querySelectorAll(":scope > article[data-message-id]")) { + byId.set(card.dataset.messageId, card); + } + } + const seen = new Set(); + for (const msg of visible) { try { + if (!msg?.id) continue; + seen.add(msg.id); + const existing = byId.get(msg.id); + const sig = messageFeedSig(msg, view); + if (existing?.isConnected && existing.dataset.feedSig === sig) continue; + if (existing?.isConnected && patchPresentWindow(existing, msg, view, sig)) continue; + if (existing?.isConnected) existing.remove(); + const isUser = msg.role === "user"; const kind = msg.kind ?? (isUser ? "user_input" : "system_info"); const body = msgBody(msg, view); @@ -6235,26 +6780,30 @@ function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, op const menuTrigger = ``; card.dataset.canEdit = canEditMessage(msg) ? "1" : "0"; - card.dataset.hasContext = msg.contextTrace ? "1" : "0"; + card.dataset.hasContext = + msg.contextTrace || msg.requestOutlines?.length ? "1" : "0"; card.dataset.originalText = body; + card.dataset.feedSig = messageFeedSig(msg, view); const questionsActive = kind === "worker_questions" && Boolean(activeQuestions); const showThinking = !isUser && Boolean(msg.thinking) && - (isPlayView(view) || kind === "dictate_reply"); + (isPlayView(view) || kind === "dictate_reply" || kind === "listen_reply"); const innerBody = questionsActive ? `

    ${esc(body)}

    ` : kind === "worker_questions" ? formatQuestionsHtml(body) : shouldRenderPlayPresent(msg, view) - ? formatPlayPresentHtml(body, view) + ? formatPlayPresentHtml(body, view, msg.id) : kind === "worker_output" ? formatArtifactBodyHtml(body) : safeBodyHtml(body); const process = !isUser && isPlayProcessMessage(msg, view); const playFinal = !isUser && isPlayFinalReply(msg, view); + const playAux = + !isUser && isPlayAuxLlmOutput(msg, view) && isPlayAuxLlmBubblesEnabled(); if (process) { card.classList.add("msg-play-process-row"); card.innerHTML = ` @@ -6269,8 +6818,9 @@ function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, op `; } else { if (playFinal) card.classList.add("msg-play-final"); + if (playAux) card.classList.add("msg-play-aux"); card.innerHTML = ` -
    +
    ${isUser ? "" : `
    ${headInner}${menuTrigger}
    `} ${showThinking ? renderThinkingBlock(msg.thinking) : ""}
    ${innerBody}
    @@ -6288,6 +6838,25 @@ function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, op feed.appendChild(fallback); } } + if (reconcile) { + for (const [id, card] of byId) { + if (!seen.has(id) && card.isConnected) card.remove(); + } + const wanted = visible.map((m) => m.id).filter(Boolean); + const ids = [...feed.querySelectorAll(":scope > article[data-message-id]")].map( + (el) => el.dataset.messageId, + ); + if (ids.join("\0") !== wanted.join("\0")) { + const tail = + feed.querySelector(":scope > #msg-live-pending") || + feed.querySelector(":scope > #questions-card-host"); + for (const id of wanted) { + const safe = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(id) : id; + const card = feed.querySelector(`:scope > article[data-message-id="${safe}"]`); + if (card) feed.insertBefore(card, tail); + } + } + } wireMessageFeedActions(feed, handlers); wireMessageContextMenu(feed, handlers); } @@ -6394,6 +6963,7 @@ export function renderBoardPanel(view) { } export function renderWorkspace(view, loading, _onPickSkill, handlers = {}) { + rememberPresentation(view); renderLifecycle(view); renderSkillPicker(view); // 先铺主柱(可能把询问卡移进答题槽),再渲染卡 @@ -6410,7 +6980,7 @@ export function renderWorkspace(view, loading, _onPickSkill, handlers = {}) { } /** 正文组成的 frontend:同一位置渲染隔离预览,源码收在后面 */ -function renderFrontendPreviewBlock(bundle, sample) { +function renderFrontendPreviewBlock(bundle, sample, slots) { const source = renderFrontendSourceDetails(bundle); if (presentSourceBytes(bundle) > PRESENT_BYTE_LIMIT) { return `
    @@ -6419,7 +6989,7 @@ function renderFrontendPreviewBlock(bundle, sample) { ${source}
    `; } - const id = queuePresentFrame(bundle, previewPacket(sample)); + const id = queuePresentFrame(bundle, previewPacket(sample), slots); const note = sample ? "按示例灌数渲染,不是正史正文" : "骨架预览,还没有示例灌数"; return `

    frontend

    @@ -6463,6 +7033,14 @@ document.addEventListener("keydown", (e) => { } }); document.addEventListener("scroll", (e) => { + if ( + applyingScroll === 0 && + e.target instanceof Element && + isFollowedScroller(e.target) && + e.target.dataset?.rebuilding !== "1" + ) { + scrollFollow.set(e.target, readScrollAnchor(e.target)); + } // 打开菜单后短时间内忽略(避免焦点/布局引起的误关) if (Date.now() - dictateProductMenuState.openedAt < 250) return; // 菜单自身或菜单内滚动不关 @@ -6475,4 +7053,12 @@ document.addEventListener("scroll", (e) => { hideFlowNodeMenu(); }, true); +document.addEventListener("present-frame-resize", (e) => { + const scroller = e.target?.closest?.("#message-feed, #dictate-feed, .dictate-feed"); + if (!scroller || scroller.dataset?.rebuilding === "1") return; + const saved = scrollFollow.get(scroller); + if (!saved?.pin) return; + applyScrollAnchor(scroller, saved); +}); + watchPresentFrames(); diff --git a/web/app.js b/web/app.js index 452814d..c3fe243 100644 --- a/web/app.js +++ b/web/app.js @@ -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() { + ${clearDictateItem}
    `; @@ -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 + ? `+` + : ""; + const delHtml = onDelete + ? `×` + : ""; row.innerHTML = ` ${twistie ? `${expanded ? "▾" : "▸"}` : ""} ${esc(name)} ${meta ? `${esc(meta)}` : ""} - ${onDelete ? `×` : ""}`; + ${addHtml || delHtml ? `${addHtml}${delHtml}` : ""}`; 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 = ``; - $("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 = `此方式下暂无作品
    `; + 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 = "加载失败"; diff --git a/web/display-labels.js b/web/display-labels.js index 885391a..6650438 100644 --- a/web/display-labels.js +++ b/web/display-labels.js @@ -60,6 +60,7 @@ export function displayCreationModeLabel(id) { const map = { recipe: "工序编排", dictate: "对话落盘", + listen_serial: "听书连载", }; return map[id] ?? id; } diff --git a/web/feed-prefs.js b/web/feed-prefs.js new file mode 100644 index 0000000..548a3b6 --- /dev/null +++ b/web/feed-prefs.js @@ -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; +} diff --git a/web/index.html b/web/index.html index 3721543..becaf57 100644 --- a/web/index.html +++ b/web/index.html @@ -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" /> - + + diff --git a/web/modules.css b/web/modules.css index dba5017..c9b09ba 100644 --- a/web/modules.css +++ b/web/modules.css @@ -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 { diff --git a/web/modules.html b/web/modules.html index f3d250a..2e78e32 100644 --- a/web/modules.html +++ b/web/modules.html @@ -12,7 +12,7 @@ } catch (e) {} })(); - 技能池 · Writing Agent + 节点 · Writing Agent
    -

    技能池

    -

    选择左侧一项查看方法块

    +

    节点

    +

    选择左侧一项查看方法

    @@ -76,7 +76,7 @@
    -

    从左侧选择一项技能

    +

    从左侧选择一个节点

    diff --git a/web/modules.js b/web/modules.js index 651a662..3637d9a 100644 --- a/web/modules.js +++ b/web/modules.js @@ -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} */ 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 = `

    无匹配项

    `; + list.innerHTML = `

    没有符合条件的节点

    `; return; } - list.innerHTML = items - .map( - (m) => ``, - ) +

    ${escapeHtml(module.declaration)}

    + ${tagHtml} + `; + }) + .join(""); + return `
    +

    ${escapeHtml(group.label)}

    +

    ${escapeHtml(group.hint)}

    + ${buttons} +
    `; + }) .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(`何时选用 ${escapeHtml(when).replace(/\n/g, "
    ")}`); - if (whenNot) bits.push(`何时不选 ${escapeHtml(whenNot).replace(/\n/g, "
    ")}`); - if (boundary) bits.push(`边界 ${escapeHtml(boundary).replace(/\n/g, "
    ")}`); - metaBar.innerHTML = bits.join("
    "); + if (when) bits.push(["何时选用", when]); + if (whenNot) bits.push(["何时不选", whenNot]); + if (boundary) bits.push(["边界", boundary]); + metaBar.innerHTML = bits + .map( + ([label, text]) => + `
    ${escapeHtml(label)}

    ${escapeHtml(text).replace(/\n/g, "
    ")}

    `, + ) + .join(""); } const present = SECTION_ORDER.filter((id) => detail.sections?.[id]); @@ -135,12 +200,28 @@ function renderDetail(detail) { }) .join(""); + const facts = [ + ["产物", `${escapeHtml(detail.artifact)}`], + ["节点", `${escapeHtml(detail.id)}`], + ]; + 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 = `

    ${escapeHtml(detail.declaration)}

    -
    产物 tag
    ${escapeHtml(detail.artifact)}
    -
    已有块
    ${present.map((id) => SECTION_LABELS[id] ?? id).join(" · ") || "—"}
    + ${facts.map(([label, value]) => `
    ${escapeHtml(label)}
    ${value}
    `).join("")}
    ${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 = + `

    节点目录没有加载出来

    `; } } diff --git a/web/styles.css b/web/styles.css index 3d92635..7496e75 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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;