diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index a2310c4..6174a84 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -130,7 +130,14 @@ import { import { readPlayTurnQueue, withPlayTurnQueue, + isPlayLayerActive, } from "../skills/play-turn.js"; +import { + PRESENT_TAG, + isPlayPresentWorker, + parseShellAdaptationFromReplyFormat, + presentOutputFromFallback, +} from "../skills/present-packet.js"; import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js"; import { executeChance, @@ -1001,6 +1008,37 @@ export class PhaseRuntime { return true; } + /** + * 游玩层不走创作 HITL:转述必须交出用户可见正文,其它执行单元空产物也继续。 + */ + private settlePlayWorkerResult( + workerId: string, + result: import("../worker/executor.js").WorkerRunResult, + ): import("../worker/executor.js").WorkerRunResult { + if (!isPlayLayerActive(this.session.slots)) return result; + const outputs = { ...result.outputs }; + if (Object.keys(outputs).length === 0 && isPlayPresentWorker(workerId)) { + const shell = + parseShellAdaptationFromReplyFormat( + this.blackboard.getContentByTag("设计.正文组成") ?? + this.blackboard.getContentByTag("设计.回复格式"), + )?.shell_id ?? "prose"; + const preview = result.preview?.trim() ?? ""; + const looksAsk = + /可验收|追问|请再补|askUser/.test(preview) || /^\s*-\s/.test(preview); + outputs[PRESENT_TAG] = presentOutputFromFallback( + looksAsk ? "" : preview, + shell, + ); + } + return { + ...result, + outputs, + askUser: undefined, + askAssessment: undefined, + }; + } + /** worker 调用 ask_user 能力时,由外层触发此事件(仅无产物时阻塞) */ async workerAsk( questions: import("../types/questions.js").QuestionItem[] | string[], @@ -1802,20 +1840,22 @@ export class PhaseRuntime { }, }); - if (result.askUser?.length && Object.keys(result.outputs).length === 0) { + const settled = this.settlePlayWorkerResult(workerId, result); + + if (settled.askUser?.length && Object.keys(settled.outputs).length === 0) { // 无产物:阻塞追问(信息不足,必须补) - if (await this.restoreRevisionTargetForReview(workerId, result.askUser)) { + if (await this.restoreRevisionTargetForReview(workerId, settled.askUser)) { return; } - await this.workerAsk(result.askUser); + await this.workerAsk(settled.askUser); return; } if ( workerId === "design-step" && - Object.keys(result.outputs).length === 0 + Object.keys(settled.outputs).length === 0 ) { - const retryQuestions = result.askUser?.length - ? result.askUser + const retryQuestions = settled.askUser?.length + ? settled.askUser : [ { id: "design-step-retry", @@ -1834,7 +1874,7 @@ export class PhaseRuntime { slots = { ...this.session.slots }; const writtenTags: string[] = []; - for (const [tag, content] of Object.entries(result.outputs)) { + for (const [tag, content] of Object.entries(settled.outputs)) { if (isProgressPointerTag(tag)) continue; const written = this.writeWorkerTagContent(tag, content, workerId); slots[tag] = written; @@ -1848,16 +1888,16 @@ export class PhaseRuntime { outputTags: (writtenTags.length > 0 ? writtenTags : worker.outputTags).filter( (t) => !isProgressPointerTag(t), ), - summary: result.summary, + summary: settled.summary, }); this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] }; - const hasPlayVisible = Object.keys(result.outputs).some( + const hasPlayVisible = Object.keys(settled.outputs).some( (t) => t === "输出.用户展示" || t === "输出.开场白", ); this.onMessage( - `[Worker] ${workerId} 已完成\n\n${result.preview}${ - !hasPlayVisible && result.preview.length >= 4000 ? "\n\n…" : "" + `[Worker] ${workerId} 已完成\n\n${settled.preview}${ + !hasPlayVisible && settled.preview.length >= 4000 ? "\n\n…" : "" }`, ); @@ -1866,8 +1906,8 @@ export class PhaseRuntime { payload: { artifactId: artifact.id, // 有产物时 askUser 挂到验收态,不阻断 Accept - questions: this.freshSidecarQuestions(result.askUser), - assessment: result.askAssessment?.trim() || undefined, + questions: this.freshSidecarQuestions(settled.askUser), + assessment: settled.askAssessment?.trim() || undefined, }, }); this.maybeCompressAcceptedArtifact(artifact.id); diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index 35d044c..5d27ad4 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -851,7 +851,8 @@ export class SessionManager { kind !== "approve_step" && kind !== "revision" && kind !== "next_intent" && - kind !== "pick_creation_step" + kind !== "pick_creation_step" && + kind !== "worker_questions" ) { return; } @@ -1875,12 +1876,23 @@ export class SessionManager { this.captureCheckpoint(managed), ); const usage = managed.trackingRef.current.pendingUsage; - const thinking = managed.trackingRef.current.pendingReasoning; + const sessionId = managed.trackingRef.current.sessionId; + const liveThinking = sessionId + ? this.workerLive.get(sessionId)?.thinking?.trim() + : ""; managed.trackingRef.current.pendingUsage = undefined; + const pendingReasoning = managed.trackingRef.current.pendingReasoning; managed.trackingRef.current.pendingReasoning = undefined; const classified = text.trim().length > 0 ? classifyAgentMessage(text) : null; + const thinking = + pendingReasoning || + (classified?.kind === "worker_output" || + classified?.kind === "worker_questions" + ? liveThinking + : "") || + undefined; const pendingTrace = managed.trackingRef.current.pendingContextTrace; let contextTrace: LlmContextTrace | undefined; if ( diff --git a/src/skills/present-packet.ts b/src/skills/present-packet.ts index 5cb56f0..22f927a 100644 --- a/src/skills/present-packet.ts +++ b/src/skills/present-packet.ts @@ -197,6 +197,75 @@ export const PLAY_VISIBLE_BODY_INSTRUCTION = [ "禁止把 tag 名(如「输出.用户展示」)、压缩摘要、过程日志写进正文。", ].join("\n"); +/** 游玩期把裁决写成用户可见终稿的执行单元 */ +export function isPlayPresentWorker(workerId: string): boolean { + const id = workerId.trim(); + return id === "narrator" || id === "round-present"; +} + +/** 按能力探测走 json_schema / forced_tool 时用的 present.v1 形状(全 required,可 strict) */ +export const PRESENT_JSON_SCHEMA: Record = { + type: "object", + additionalProperties: false, + required: ["schema", "shell", "blocks", "meta"], + properties: { + schema: { type: "string" }, + shell: { type: "string" }, + blocks: { + type: "object", + additionalProperties: false, + required: ["body", "monitor", "header", "footer", "aside"], + properties: { + body: { type: "string" }, + monitor: { type: "string" }, + header: { type: "string" }, + footer: { type: "string" }, + aside: { type: "string" }, + }, + }, + meta: { + type: "object", + additionalProperties: false, + required: ["suggested_actions"], + properties: { + suggested_actions: { + type: "array", + items: { type: "string" }, + }, + }, + }, + }, +}; + +/** 游玩转述:按探测到的 structured 投递写出 present.v1,不要创作追问 */ +export function playPresentOutputInstruction(shell: PresentShellId): string { + return ` + +--- + +## 运行时输出协议(游玩正文) + +本步产物是给用户看的终稿。程序按已探测的模型能力用 schema / tool / json_object 投递,你输出 **一个 present.v1 对象**: + +{"schema":"present.v1","shell":"${shell}","blocks":{"body":"用户可读正文","monitor":"","header":"","footer":"","aside":""},"meta":{"suggested_actions":[]}} + +\`shell\` 必须是 \`${shell}\`。只填该壳已有区域,没有的键留空字符串。 +prose 壳也可直接输出 Markdown,程序会落入 body。 +材料不够也先写一版场面。不要写追问、askUser、自评或「可验收产物」。`; +} + +export function stringifyPresentPacket(packet: PresentPacket): string { + return JSON.stringify(packet); +} + +export function presentOutputFromFallback( + text: string, + shell: PresentShellId = "prose", +): string { + const body = stripPresentSourceFences(text).trim() || "(本轮场面未写完)"; + return stringifyPresentPacket(presentFromPlainText(body, shell)); +} + export function parsePresentPacket( raw: string | undefined | null, fallbackShell: PresentShellId = "prose", diff --git a/src/worker/executor.ts b/src/worker/executor.ts index 3b7b92c..fb0e4ee 100644 --- a/src/worker/executor.ts +++ b/src/worker/executor.ts @@ -25,7 +25,18 @@ import { loadAppSettings } from "../config/settings.js"; import { resolveActivePreset } from "../preset/store.js"; import { assemblePlayWorkerMessages } from "../preset/play-frame.js"; import { worldInfoPackFromSegments } from "../preset/world-info-pack.js"; -import { PRESENT_TAG } from "../skills/present-packet.js"; +import { + PRESENT_TAG, + PRESENT_JSON_SCHEMA, + isPlayPresentWorker, + isPresentLikeObject, + parsePresentPacket, + parseShellAdaptationFromReplyFormat, + playPresentOutputInstruction, + presentOutputFromFallback, + stringifyPresentPacket, + type PresentShellId, +} from "../skills/present-packet.js"; import { coerceToFlatArtifact, flatArtifactToFragmentDoc, @@ -44,6 +55,7 @@ import { extractFragmentAskSidecar, isUsableContextFragment, looksLikeFragmentDoc, + parseContextFragment, } from "../skills/context-fragment.js"; import { isProgressPointerTag, @@ -200,13 +212,66 @@ function existingOutputIsProduct(text: string | undefined): boolean { if (!text?.trim()) return false; if (isUsableContextFragment(text)) return true; const parsed = tryParseJsonDoc(text); - return looksLikeCreationFlowDoc(parsed) || looksLikeFragmentDoc(parsed); + return ( + looksLikeCreationFlowDoc(parsed) || + looksLikeFragmentDoc(parsed) || + isPresentLikeObject(parsed) + ); +} + +function usablePlayFallbackText(raw?: string): string { + if (!raw?.trim()) return ""; + if (isQuestionOnlyText(raw)) return ""; + const parsed = tryParseJsonDoc(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return ""; + } + return raw.trim(); +} + +function playVisibleTarget(outputTags: string[]): string | undefined { + return productOutputTags(outputTags).find((t) => PLAY_FEED_OUTPUT_TAGS.has(t)); +} + +function canonicalizePresentOutput( + value: unknown, + fallbackShell: PresentShellId, +): string | undefined { + if (value == null) return undefined; + if (typeof value === "string") { + const view = parsePresentPacket(value, fallbackShell); + if (!view.packet.blocks.body && view.fallbackPlain && !value.trim()) { + return undefined; + } + return stringifyPresentPacket(view.packet); + } + if (typeof value === "object") { + if (isPresentLikeObject(value)) { + const view = parsePresentPacket(JSON.stringify(value), fallbackShell); + return stringifyPresentPacket(view.packet); + } + const view = parsePresentPacket(JSON.stringify(value), fallbackShell); + if (!view.fallbackPlain) return stringifyPresentPacket(view.packet); + } + return undefined; +} + +function fragmentSceneText(doc: unknown): string | undefined { + const frag = + typeof doc === "string" + ? parseContextFragment(doc) + : parseContextFragment(JSON.stringify(doc)); + if (!frag) return undefined; + if (typeof frag.正文 === "string" && frag.正文.trim()) return frag.正文.trim(); + if (typeof frag.brief === "string" && frag.brief.trim()) return frag.brief.trim(); + return undefined; } function recoverFragmentOutputs( obj: Record, outputs: Record, outputTags: string[], + fallbackShell: PresentShellId = "prose", ): void { const target = productOutputTags(outputTags)[0]; if (!target) return; @@ -225,7 +290,8 @@ function recoverFragmentOutputs( if ( isUsableContextFragment(text) || looksLikeFragmentDoc(val) || - looksLikeCreationFlowDoc(val) + looksLikeCreationFlowDoc(val) || + isPresentLikeObject(val) ) { outputs[target] = text; return; @@ -238,6 +304,14 @@ function recoverFragmentOutputs( for (const [k, v] of Object.entries(obj)) { if (!protocolKeys.has(k)) rest[k] = v; } + if (isPresentLikeObject(obj) || isPresentLikeObject(rest)) { + const source = isPresentLikeObject(obj) ? obj : rest; + const present = canonicalizePresentOutput(source, fallbackShell); + if (present) { + outputs[target] = present; + return; + } + } if (looksLikeFragmentDoc(obj) || looksLikeFragmentDoc(rest)) { const source = looksLikeFragmentDoc(obj) ? obj : rest; outputs[target] = JSON.stringify(source); @@ -267,6 +341,7 @@ function looksLikeStructuredDraft(text: string): boolean { if (t.startsWith("{") || t.startsWith("[")) return true; if (/```(?:json)?/i.test(t) && t.includes("{")) return true; if (/"schema"\s*:/.test(t)) return true; + if (/"blocks"\s*:/.test(t) && /"body"\s*:/.test(t)) return true; if (/"技能"\s*:/.test(t) || /"正文"\s*:/.test(t)) return true; if (/"inserts"\s*:/.test(t) || /"agents"\s*:/.test(t) || /"play_slots"\s*:/.test(t)) { return true; @@ -321,10 +396,26 @@ function lookupOutputValue( function parseWorkerResponse( raw: string, outputTags: string[], + opts?: { fallbackShell?: PresentShellId }, ): WorkerRunResult { + const fallbackShell = opts?.fallbackShell ?? "prose"; + const playTarget = playVisibleTarget(outputTags); + const parsed = tryParseJsonDoc(raw); if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { - const target = productOutputTags(outputTags)[0] ?? outputTags[0]; + const target = playTarget ?? productOutputTags(outputTags)[0] ?? outputTags[0]; + if (playTarget) { + const body = + raw.trim() && !isQuestionOnlyText(raw) + ? raw + : ""; + return finalizeParsedOutputs( + { [playTarget]: presentOutputFromFallback(body, fallbackShell) }, + { summary: body.slice(0, 80) || "游玩正文" }, + outputTags, + { playVisible: true, fallbackShell }, + ); + } if (target && looksLikeStructuredDraft(raw)) { return finalizeParsedOutputs( { [target]: raw }, @@ -357,6 +448,18 @@ function parseWorkerResponse( const obj = parsed as Record; + if (playTarget && isPresentLikeObject(obj)) { + const present = canonicalizePresentOutput(obj, fallbackShell); + if (present) { + return finalizeParsedOutputs( + { [playTarget]: present }, + obj, + outputTags, + { playVisible: true, fallbackShell }, + ); + } + } + // 创作流程 DAG:根对象就是产物,不能先收成扁形(brief 会被当成稿、steps 丢掉) if (looksLikeCreationFlowDoc(obj)) { const target = productOutputTags(outputTags)[0] ?? outputTags[0]; @@ -369,8 +472,21 @@ function parseWorkerResponse( } } + // 游玩正文:片段里的场面字收成 present,不要扁形成创作稿 + if (playTarget && !isPresentLikeObject(obj)) { + const scene = fragmentSceneText(obj); + if (scene) { + return finalizeParsedOutputs( + { [playTarget]: presentOutputFromFallback(scene, fallbackShell) }, + obj, + outputTags, + { playVisible: true, fallbackShell }, + ); + } + } + // 扁形产物 → 旧 fragment 外壳。已是 context-fragment 深树则原样收下(保留自评与嵌套正文)。 - if (!looksLikeFragmentDoc(obj)) { + if (!looksLikeFragmentDoc(obj) && !isPresentLikeObject(obj) && !playTarget) { const asFlat = coerceToFlatArtifact(obj); if (asFlat) { const frag = flatArtifactToFragmentDoc(asFlat); @@ -391,69 +507,116 @@ function parseWorkerResponse( if (outputsRaw && typeof outputsRaw === "object" && !Array.isArray(outputsRaw)) { const bag = outputsRaw as Record; for (const tag of productOutputTags(outputTags)) { - assignOutput(outputs, tag, lookupOutputValue(bag, tag)); + const val = lookupOutputValue(bag, tag); + if (playTarget && tag === playTarget) { + const present = canonicalizePresentOutput(val, fallbackShell); + if (present) { + outputs[tag] = present; + continue; + } + const scene = fragmentSceneText(val); + if (scene) { + outputs[tag] = presentOutputFromFallback(scene, fallbackShell); + continue; + } + } + assignOutput(outputs, tag, val); } } - recoverFragmentOutputs(obj, outputs, outputTags); + recoverFragmentOutputs(obj, outputs, outputTags, fallbackShell); - return finalizeParsedOutputs(outputs, obj, outputTags); + if (playTarget && outputs[playTarget]) { + const present = canonicalizePresentOutput(outputs[playTarget], fallbackShell); + if (present) outputs[playTarget] = present; + } + + return finalizeParsedOutputs(outputs, obj, outputTags, { + playVisible: Boolean(playTarget), + fallbackShell, + rawFallback: raw, + }); } function finalizeParsedOutputs( outputs: Record, obj: Record, - _outputTags: string[], + outputTags: string[], + opts?: { + playVisible?: boolean; + fallbackShell?: PresentShellId; + rawFallback?: string; + }, ): WorkerRunResult { const isMetaAskToken = (s: string) => /^ask[_-]?user$/i.test(s.trim()); + const playTarget = playVisibleTarget(outputTags); + const playVisible = Boolean(opts?.playVisible || playTarget); + const fallbackShell = opts?.fallbackShell ?? "prose"; let askUser: QuestionItem[] | undefined; - if (Array.isArray(obj.askUser)) { - askUser = normalizeQuestions( - obj.askUser.filter((q) => { - if (typeof q === "string") return q.trim() && !isMetaAskToken(q); - return true; - }), - ); - } else if (typeof obj.askUser === "string" && obj.askUser.trim()) { - const q = obj.askUser.trim(); - askUser = isMetaAskToken(q) ? undefined : normalizeQuestions([q]); - } - if ((!askUser || askUser.length === 0) && Object.keys(outputs).length === 0) { - const summaryText = - typeof obj.summary === "string" ? obj.summary.trim() : ""; - if ( - summaryText && - !isMetaAskToken(summaryText) && - summaryText.length > 8 && - /[??]/.test(summaryText) - ) { - askUser = normalizeQuestions([summaryText]); - } else if (isMetaAskToken(summaryText)) { - askUser = normalizeQuestions([ - "请补充当前步骤所需的信息(情境、参数或你的具体设想)。", - ]); + if (!playVisible) { + if (Array.isArray(obj.askUser)) { + askUser = normalizeQuestions( + obj.askUser.filter((q) => { + if (typeof q === "string") return q.trim() && !isMetaAskToken(q); + return true; + }), + ); + } else if (typeof obj.askUser === "string" && obj.askUser.trim()) { + const q = obj.askUser.trim(); + askUser = isMetaAskToken(q) ? undefined : normalizeQuestions([q]); + } + if ((!askUser || askUser.length === 0) && Object.keys(outputs).length === 0) { + const summaryText = + typeof obj.summary === "string" ? obj.summary.trim() : ""; + if ( + summaryText && + !isMetaAskToken(summaryText) && + summaryText.length > 8 && + /[??]/.test(summaryText) + ) { + askUser = normalizeQuestions([summaryText]); + } else if (isMetaAskToken(summaryText)) { + askUser = normalizeQuestions([ + "请补充当前步骤所需的信息(情境、参数或你的具体设想)。", + ]); + } } } // 先抽追问;半残稿仍留在 outputs,追问挂验收卡(不因缺字段丢掉正文) let askAssessment: string | undefined; const fragQuestions: QuestionItem[] = []; - for (const content of Object.values(outputs)) { - const side = extractFragmentAskSidecar(content); - if (side.assessment && !askAssessment) askAssessment = side.assessment; - for (const q of side.questions) { - if (!fragQuestions.some((x) => x.prompt === q.prompt)) { - fragQuestions.push(q); + if (!playVisible) { + for (const content of Object.values(outputs)) { + const side = extractFragmentAskSidecar(content); + if (side.assessment && !askAssessment) askAssessment = side.assessment; + for (const q of side.questions) { + if (!fragQuestions.some((x) => x.prompt === q.prompt)) { + fragQuestions.push(q); + } } } - } - if (fragQuestions.length) { - askUser = mergeQuestionsPreferFragment(askUser, fragQuestions); + if (fragQuestions.length) { + askUser = mergeQuestionsPreferFragment(askUser, fragQuestions); + } } dropProgressPointerOutputs(outputs); canonicalizeKnownOutputs(outputs); + if (playVisible && playTarget && !outputs[playTarget]?.trim()) { + const scene = fragmentSceneText(obj); + const summaryText = + typeof obj.summary === "string" && !/[??]/.test(obj.summary) + ? obj.summary.trim() + : ""; + const fallback = + scene || + summaryText || + usablePlayFallbackText(opts?.rawFallback); + outputs[playTarget] = presentOutputFromFallback(fallback, fallbackShell); + } + if (Object.keys(outputs).length === 0 && (!askUser || askUser.length === 0)) { askUser = normalizeQuestions(INCOMPLETE_FRAGMENT_ASK); } @@ -470,8 +633,8 @@ function finalizeParsedOutputs( outputs, summary, preview, - askUser: askUser?.length ? askUser : undefined, - askAssessment, + askUser: playVisible ? undefined : askUser?.length ? askUser : undefined, + askAssessment: playVisible ? undefined : askAssessment, }); } @@ -618,9 +781,20 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise { expect(adapt?.shell_id).toBe("prose"); }); }); + +describe("play present worker", () => { + it("recognizes narrator and round-present", () => { + expect(isPlayPresentWorker("narrator")).toBe(true); + expect(isPlayPresentWorker("round-present")).toBe(true); + expect(isPlayPresentWorker("world-simulator")).toBe(false); + }); + + it("fallback packet uses the given shell", () => { + const raw = presentOutputFromFallback("她点了点头。", "chat_monitor"); + const view = parsePresentPacket(raw, "prose"); + expect(view.packet.shell).toBe("chat_monitor"); + expect(view.packet.blocks.body).toBe("她点了点头。"); + }); +}); diff --git a/tests/worker-set-sanitize.test.ts b/tests/worker-set-sanitize.test.ts index a22f4cf..5654c9b 100644 --- a/tests/worker-set-sanitize.test.ts +++ b/tests/worker-set-sanitize.test.ts @@ -9,6 +9,7 @@ import { parseWorkerResponseForTest, sanitizeWorkerSetOutputs, } from "../src/worker/executor.js"; +import { parsePresentPacket } from "../src/skills/present-packet.js"; import { selectJsonPayload } from "../src/parse/json-doc.js"; import { parseCreationFlow } from "../src/skills/creation-flow.js"; @@ -396,4 +397,52 @@ describe("sanitizeWorkerSetOutputs", () => { expect(result.preview).not.toContain("### "); expect(result.preview.length).toBeGreaterThan(4000); }); + + it("play narrator: present root without askUser", () => { + const result = parseWorkerResponseForTest( + JSON.stringify({ + schema: "present.v1", + shell: "prose", + blocks: { body: "她端起碗,热气模糊了视线。" }, + meta: { suggested_actions: [] }, + }), + ["输出.用户展示"], + { fallbackShell: "prose" }, + ); + expect(result.askUser).toBeUndefined(); + expect(result.outputs["输出.用户展示"]).toContain("她端起碗"); + const view = parsePresentPacket(result.outputs["输出.用户展示"], "prose"); + expect(view.packet.blocks.body).toContain("她端起碗"); + }); + + it("play narrator: fragment with 追问 does not surface askUser", () => { + const result = parseWorkerResponseForTest( + JSON.stringify({ + schema: "context-fragment.v1", + 技能: "叙事转述", + brief: "吃饭场面", + 正文: "他决定先吃饭。", + 追问: { + 导语: "还差体验", + 题目: [{ 问: "你要什么节奏?", 建议选项: ["慢", "快"] }], + }, + }), + ["输出.用户展示"], + { fallbackShell: "prose" }, + ); + expect(result.askUser).toBeUndefined(); + expect(result.outputs["输出.用户展示"]).toContain("他决定先吃饭"); + }); + + it("play narrator: empty output falls back to present body", () => { + const result = parseWorkerResponseForTest( + JSON.stringify({ askUser: ["这一步还没写出可验收的产物"] }), + ["输出.用户展示"], + { fallbackShell: "turn_panel" }, + ); + expect(result.askUser).toBeUndefined(); + expect(result.outputs["输出.用户展示"]).toBeTruthy(); + const view = parsePresentPacket(result.outputs["输出.用户展示"], "prose"); + expect(view.packet.shell).toBe("turn_panel"); + }); }); diff --git a/web/agent-ui.js b/web/agent-ui.js index d5d261a..c6542f7 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -647,7 +647,19 @@ function formatPlayPresentHtml(body, view) { if (presentView) { return renderPresentShellHtml(presentView.packet, esc, { tweaks }); } - return renderPresentShellHtml(presentFromPlain(trimmed, fallbackShell), esc, { + 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) return `

(无正文)

`; + return renderPresentShellHtml(presentFromPlain(plain, fallbackShell), esc, { tweaks, }); } @@ -702,6 +714,16 @@ function wireCoordRailChrome() { } } +function isWorkspaceNav() { + const level = document.body.dataset.navLevel; + return level === "book" || level === "product"; +} + +function syncExpandLabel() { + const label = document.getElementById("rail-expand-label"); + if (label) label.textContent = activeRailTab === "log" ? "志" : "书"; +} + export function setRailTab(tab, { expand = false } = {}) { activeRailTab = tab === "log" ? "log" : "books"; if (expand) railUserExpanded = true; @@ -717,6 +739,7 @@ export function setRailTab(tab, { expand = false } = {}) { panel.classList.toggle("active", active); panel.toggleAttribute("hidden", !active); }); + syncExpandLabel(); syncRailCollapse(); } @@ -728,25 +751,23 @@ function wireRailChrome() { const btn = e.target.closest(".rail-tab"); if (!btn) return; const tab = btn.getAttribute("data-rail-tab") ?? "books"; - const collapsed = document.body.classList.contains("rail-collapsed"); - setRailTab(tab, { expand: collapsed }); + setRailTab(tab); }); } - const toggle = document.getElementById("btn-rail-toggle"); - if (toggle && !toggle.dataset.wired) { - toggle.dataset.wired = "1"; - toggle.addEventListener("click", () => { - const inBook = document.body.dataset.navLevel === "book"; - const collapsed = document.body.classList.contains("rail-collapsed"); - if (inBook && !collapsed) { - window.dispatchEvent(new CustomEvent("wa:nav-root")); - return; - } - if (document.body.dataset.railCanCollapse !== "1") { - railUserExpanded = true; - } else { - railUserExpanded = !railUserExpanded; - } + const expand = document.getElementById("btn-rail-expand"); + if (expand && !expand.dataset.wired) { + expand.dataset.wired = "1"; + expand.addEventListener("click", () => { + railUserExpanded = true; + syncRailCollapse(); + }); + } + const collapse = document.getElementById("btn-rail-collapse"); + if (collapse && !collapse.dataset.wired) { + collapse.dataset.wired = "1"; + collapse.addEventListener("click", () => { + if (document.body.dataset.railCanCollapse !== "1") return; + railUserExpanded = false; syncRailCollapse(); }); } @@ -762,7 +783,7 @@ function hasStartedCreation(view) { } function syncRailCollapse(view) { - const inBook = document.body.dataset.navLevel === "book"; + const inBook = isWorkspaceNav(); if (!inBook) { document.body.dataset.railCanCollapse = "0"; railUserExpanded = true; @@ -772,18 +793,12 @@ function syncRailCollapse(view) { const canCollapse = document.body.dataset.railCanCollapse === "1"; const collapsed = canCollapse && !railUserExpanded; document.body.classList.toggle("rail-collapsed", collapsed); - const toggle = document.getElementById("btn-rail-toggle"); - if (toggle) { - if (inBook && !collapsed) { - toggle.title = "返回作品目录"; - toggle.setAttribute("aria-label", "返回作品目录"); - toggle.textContent = "‹"; - } else { - toggle.title = collapsed ? "展开侧栏" : "收起侧栏"; - toggle.setAttribute("aria-label", collapsed ? "展开侧栏" : "收起侧栏"); - toggle.textContent = collapsed ? "›" : "‹"; - } + const expand = document.getElementById("btn-rail-expand"); + if (expand) { + expand.setAttribute("aria-expanded", collapsed ? "false" : "true"); + expand.title = collapsed ? "展开侧栏" : "收起侧栏"; } + syncExpandLabel(); } export function resetRailChrome() { @@ -831,6 +846,7 @@ function shouldShowInFeed(msg, view) { if (FEED_HIDDEN_KINDS.has(kind)) return false; if (msg.compressed) return false; if (isPlayView(view) && isPlayHousekeepingMessage(msg)) return false; + if (isPlayView(view) && kind === "worker_questions") return false; if (isSkillSelectionMessage(msg)) return false; if (isReviewSidecarQuestionStub(msg)) return false; if ( @@ -852,7 +868,7 @@ function shouldShowInFeed(msg, view) { } function maybeSyncRail(view) { - if (document.body.dataset.navLevel !== "book") { + if (!isWorkspaceNav()) { railUserExpanded = true; document.body.dataset.railCanCollapse = "0"; syncRailCollapse(view); diff --git a/web/app.js b/web/app.js index 0f9dc49..2be7270 100644 --- a/web/app.js +++ b/web/app.js @@ -2,6 +2,7 @@ import { renderWorkspace, updateLiveStreamPanel, resetRailChrome, + setRailTab, fillStepArtifactDialog, canOfferSaveProduct, canOfferEnterPlay, @@ -32,7 +33,6 @@ let selectedBookIds = new Set(); let activePlaySaveId = null; const playSavesByBook = new Map(); const playWorkingInstanceByBook = new Map(); -const expandedProductsByBook = new Map(); const playSavesLoading = new Set(); let bookMenuBookId = null; let livePollTimer = null; @@ -99,7 +99,7 @@ function resolveUserTask(view, loading) { const hasQuestions = Boolean(getActiveQuestions(view)?.questions?.length); const moduleOpening = isModuleOpeningWaiting(view); - if (isPlayView(view) && wr?.kind !== "worker_questions") { + if (isPlayView(view)) { return { id: "speak", label: USER_TASK.speak.label, @@ -1019,20 +1019,6 @@ function runsForProduct(bookId, instanceId, products) { ); } -function isProductExpanded(bookId, instanceId, fallback) { - const set = expandedProductsByBook.get(bookId); - if (set) return set.has(instanceId); - return Boolean(fallback); -} - -function toggleProductExpanded(bookId, instanceId) { - const open = isProductExpanded(bookId, instanceId, false); - const next = new Set(); - if (!open) next.add(instanceId); - expandedProductsByBook.set(bookId, next); - renderBookList(); -} - function bookPlayReady(bookId) { if (lastView?.bookId === bookId && Boolean(lastView?.playReady || lastView?.hasProduct)) { return true; @@ -1070,7 +1056,7 @@ async function fetchPlaySavesForBook(bookId, force = false) { } function setBookSelectMode(on, { keepSelection = false } = {}) { - if (on && sidebarNav.level === "book") return; + if (on && sidebarNav.level !== "root") return; bookSelectMode = on; if (!on) selectedBookIds = new Set(); else if (!keepSelection) selectedBookIds = new Set(); @@ -1134,26 +1120,93 @@ function showBookMenu(bookId, x, y) { } function isCatalogNav() { - return sidebarNav.level !== "book"; + return sidebarNav.level === "root"; +} + +function isWorkspaceNav() { + return sidebarNav.level === "book" || sidebarNav.level === "product"; } function getNavBook() { - if (sidebarNav.level !== "book") return null; + if (!isWorkspaceNav() || !sidebarNav.bookId) return null; return books.find((b) => b.id === sidebarNav.bookId) ?? null; } -function syncRailContext() { - const el = $("rail-context"); - if (!el) return; +function getNavProduct() { + if (sidebarNav.level !== "product" || !sidebarNav.productId || !sidebarNav.bookId) return null; + return productInstancesForBook(sidebarNav.bookId).find((p) => p.id === sidebarNav.productId) ?? null; +} + +function pathSep() { + const li = document.createElement("li"); + li.className = "path-bar-item"; + li.setAttribute("aria-hidden", "true"); + const sep = document.createElement("span"); + sep.className = "path-sep"; + sep.textContent = "›"; + li.appendChild(sep); + return li; +} + +function pathItem({ label, title, current, onClick }) { + const li = document.createElement("li"); + li.className = current ? "path-bar-item path-bar-item-current" : "path-bar-item"; + const el = document.createElement(current || !onClick ? "span" : "button"); + el.className = current ? "path-seg current" : "path-seg"; + if (el.tagName === "BUTTON") el.type = "button"; + if (current) el.setAttribute("aria-current", "page"); + el.textContent = label; + if (title) el.title = title; + if (!current && onClick) el.addEventListener("click", onClick); + li.appendChild(el); + return li; +} + +function renderPathBar() { + const bar = $("book-path-bar"); + if (!bar) return; + bar.innerHTML = ""; const book = getNavBook(); - if (sidebarNav.level === "book" && book) { - el.textContent = book.title; - el.title = book.title; - } else { - el.textContent = "作品"; - el.removeAttribute("title"); + const product = getNavProduct(); + const atProduct = sidebarNav.level === "product" && Boolean(book); + const inBook = Boolean(book) && (sidebarNav.level === "book" || atProduct); + + $("rail-tabs")?.toggleAttribute("hidden", !isWorkspaceNav()); + + const list = document.createElement("ol"); + list.className = "path-bar-list"; + list.appendChild( + pathItem({ + label: "目录", + title: "作品目录", + current: !inBook, + onClick: () => showCatalog(), + }), + ); + if (inBook) { + const bookLabel = book.title?.trim() || "书本"; + list.appendChild(pathSep()); + list.appendChild( + pathItem({ + label: bookLabel, + title: bookLabel, + current: !atProduct, + onClick: () => navigateToBook(book.id), + }), + ); } - $("rail-tabs")?.toggleAttribute("hidden", sidebarNav.level !== "book"); + if (atProduct) { + const productLabel = product?.label?.trim() || "定稿"; + list.appendChild(pathSep()); + list.appendChild( + pathItem({ + label: productLabel, + title: productLabel, + current: true, + }), + ); + } + bar.appendChild(list); } function navigateToBook(bookId) { @@ -1162,7 +1215,20 @@ function navigateToBook(bookId) { document.body.dataset.navLevel = "book"; bookSelectMode = false; selectedBookIds = new Set(); - syncRailContext(); + setRailTab("books", { expand: true }); + renderPathBar(); + renderBookList(); + void fetchPlaySavesForBook(bookId); +} + +function navigateToProduct(bookId, productId) { + if (!bookId || !productId) return; + sidebarNav = { level: "product", bookId, productId }; + document.body.dataset.navLevel = "product"; + bookSelectMode = false; + selectedBookIds = new Set(); + setRailTab("books", { expand: true }); + renderPathBar(); renderBookList(); void fetchPlaySavesForBook(bookId); } @@ -1175,7 +1241,7 @@ function showCatalog() { bookSelectMode = false; selectedBookIds = new Set(); closeWorkspace(); - syncRailContext(); + renderPathBar(); renderBookList(); } @@ -1248,17 +1314,15 @@ function renderExplorerRow({ return row; } -function renderBookBranch(book, list) { +function playingInstanceIdForBook(book) { + const playHere = book.id === activeBookId && isPlayNow(); + if (playHere && lastView?.playInstanceId) return lastView.playInstanceId; + return playWorkingInstanceByBook.get(book.id) ?? null; +} + +function renderProductContents(book, product, list) { const loading = playSavesLoading.has(book.id); const saves = playSavesByBook.get(book.id); - const products = productInstancesForBook(book.id); - const playHere = book.id === activeBookId && isPlayNow(); - const playingInstanceId = - playHere && lastView?.playInstanceId - ? lastView.playInstanceId - : playWorkingInstanceByBook.get(book.id); - const latestId = products[0]?.id; - if (loading && saves === undefined) { const hint = document.createElement("p"); hint.className = "explorer-hint"; @@ -1267,83 +1331,51 @@ function renderBookBranch(book, list) { return; } - for (const product of products) { - const isLatest = product.id === latestId; - const isPlayingHere = playHere && playingInstanceId === product.id; - const hasWorking = playingInstanceId === product.id; - const expanded = isProductExpanded(book.id, product.id, isPlayingHere); - const runs = runsForProduct(book.id, product.id, products); - const wrap = document.createElement("div"); - wrap.className = `explorer-product${expanded ? " is-open" : ""}`; + const playHere = book.id === activeBookId && isPlayNow(); + const playingInstanceId = playingInstanceIdForBook(book); + const hasWorking = playingInstanceId === product.id; + const runs = runsForProduct(book.id, product.id, productInstancesForBook(book.id)); - const header = renderExplorerRow({ - name: product.label, - meta: isLatest ? "最新" : "", - twistie: true, - expanded, - title: "点开游玩存档,双击改名", - onTwistie: () => toggleProductExpanded(book.id, product.id), - onClick: () => toggleProductExpanded(book.id, product.id), - onRename: () => void renamePlaySave(book.id, product.id, product.label), - onDelete: () => void deletePlaySave(book.id, product.id, product.label, "落档"), - }); - header.setAttribute("aria-expanded", expanded ? "true" : "false"); - wrap.appendChild(header); - - if (expanded) { - const drop = document.createElement("div"); - drop.className = "explorer-drop"; - drop.setAttribute("role", "group"); - drop.setAttribute("aria-label", `${product.label} 的游玩存档`); - - if (hasWorking) { - drop.appendChild( - renderExplorerRow({ - name: "当前游玩", - meta: isPlayingHere && !activePlaySaveId ? "进行中" : "工作副本", - active: isPlayingHere && !activePlaySaveId, - child: true, - onClick: () => void openCurrentPlay(book.id), - }), - ); - } - - if (!runs.length && !hasWorking) { - const hint = document.createElement("p"); - hint.className = "explorer-drop-empty"; - hint.textContent = "还没有游玩存档"; - drop.appendChild(hint); - } - - for (const run of runs) { - const runWhen = formatSaveWhen(run.createdAt); - drop.appendChild( - renderExplorerRow({ - name: run.label, - meta: runWhen || "游玩进度", - active: playHere && activePlaySaveId === run.id, - child: true, - actionClass: "explorer-file", - onClick: () => void loadPlaySave(book.id, run.id), - onRename: () => void renamePlaySave(book.id, run.id, run.label), - onDelete: () => void deletePlaySave(book.id, run.id, run.label, "游玩存档"), - }), - ); - } - - drop.appendChild( - renderExplorerRow({ - name: "+ 新开一局", - actionClass: "explorer-action", - child: true, - onClick: () => void startNewPlayForBook(book.id, product.id), - }), - ); - wrap.appendChild(drop); - } - - list.appendChild(wrap); + if (hasWorking) { + list.appendChild( + renderExplorerRow({ + name: "当前游玩", + meta: playHere && !activePlaySaveId ? "进行中" : "工作副本", + active: playHere && !activePlaySaveId, + onClick: () => void openCurrentPlay(book.id), + }), + ); } + + if (!runs.length && !hasWorking) { + const hint = document.createElement("p"); + hint.className = "explorer-hint"; + hint.textContent = "还没有游玩存档"; + list.appendChild(hint); + } + + for (const run of runs) { + const runWhen = formatSaveWhen(run.createdAt); + list.appendChild( + renderExplorerRow({ + name: run.label, + meta: runWhen || "游玩进度", + active: playHere && activePlaySaveId === run.id, + actionClass: "explorer-file", + onClick: () => void loadPlaySave(book.id, run.id), + onRename: () => void renamePlaySave(book.id, run.id, run.label), + onDelete: () => void deletePlaySave(book.id, run.id, run.label, "游玩存档"), + }), + ); + } + + list.appendChild( + renderExplorerRow({ + name: "+ 新开一局", + actionClass: "explorer-action", + onClick: () => void startNewPlayForBook(book.id, product.id), + }), + ); } function renderBookContents(book, list) { @@ -1356,13 +1388,56 @@ function renderBookContents(book, list) { onClick: () => void openBookDesign(book.id), }), ); - renderBookBranch(book, list); + + const loading = playSavesLoading.has(book.id); + const saves = playSavesByBook.get(book.id); + if (loading && saves === undefined) { + const hint = document.createElement("p"); + hint.className = "explorer-hint"; + hint.textContent = "加载存档…"; + list.appendChild(hint); + return; + } + + const products = productInstancesForBook(book.id); + if (!products.length) { + const hint = document.createElement("p"); + hint.className = "explorer-hint"; + hint.textContent = bookPlayReady(book.id) + ? "还没有定稿。可在更多菜单里保存定稿。" + : "验收后可保存定稿。"; + list.appendChild(hint); + return; + } + + const latestId = products[0]?.id; + const playingInstanceId = playingInstanceIdForBook(book); + const section = document.createElement("p"); + section.className = "explorer-section-label"; + section.textContent = "定稿"; + list.appendChild(section); + + for (const product of products) { + const bits = []; + if (product.id === latestId) bits.push("最新"); + if (playingInstanceId === product.id) bits.push("游玩中"); + list.appendChild( + renderExplorerRow({ + name: product.label, + meta: bits.join(" · "), + title: "打开定稿,双击改名", + onClick: () => navigateToProduct(book.id, product.id), + onRename: () => void renamePlaySave(book.id, product.id, product.label), + onDelete: () => void deletePlaySave(book.id, product.id, product.label, "定稿"), + }), + ); + } } function renderBookList() { const list = $("book-list"); if (!list) return; - syncRailContext(); + renderPathBar(); if (!books.length) { list.innerHTML = ``; $("btn-new-inline")?.addEventListener("click", openNewBookDialog); @@ -1370,6 +1445,34 @@ function renderBookList() { } list.innerHTML = ""; + if (sidebarNav.level === "product") { + const book = getNavBook(); + if (!book) { + sidebarNav = { level: "root" }; + document.body.dataset.navLevel = "root"; + renderPathBar(); + } else { + const product = getNavProduct(); + const loading = playSavesLoading.has(book.id) && !playSavesByBook.has(book.id); + if (product) { + renderProductContents(book, product, list); + return; + } + if (loading) { + const hint = document.createElement("p"); + hint.className = "explorer-hint"; + hint.textContent = "加载存档…"; + list.appendChild(hint); + return; + } + sidebarNav = { level: "book", bookId: book.id }; + document.body.dataset.navLevel = "book"; + renderPathBar(); + renderBookContents(book, list); + return; + } + } + if (sidebarNav.level === "book") { const book = getNavBook(); if (book) { @@ -1378,7 +1481,7 @@ function renderBookList() { } sidebarNav = { level: "root" }; document.body.dataset.navLevel = "root"; - syncRailContext(); + renderPathBar(); } for (const book of books) { @@ -1396,7 +1499,7 @@ function renderBookList() { toggleBookSelection(book.id); return; } - void openBook(book.id); + navigateToBook(book.id); }, }), ); @@ -1561,7 +1664,11 @@ function setupBookBoxSelect() { async function loadPlaySave(bookId, saveId) { try { - navigateToBook(bookId); + const products = productInstancesForBook(bookId); + const run = playRunsForBook(bookId).find((r) => r.id === saveId); + const instanceId = run ? resolveRunInstanceId(run, products) : sidebarNav.productId; + if (instanceId) navigateToProduct(bookId, instanceId); + else navigateToBook(bookId); if (lastView?.bookId === bookId) renderSession(lastView, true); const data = await api( `/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}/load`, @@ -1600,6 +1707,10 @@ async function deletePlaySave(bookId, saveId, label, kindLabel = "游玩存档") { method: "DELETE" }, ); if (activePlaySaveId === saveId) activePlaySaveId = null; + if (sidebarNav.level === "product" && sidebarNav.productId === saveId) { + sidebarNav = { level: "book", bookId }; + document.body.dataset.navLevel = "book"; + } await fetchPlaySavesForBook(bookId, true); } catch (err) { alert(err.message); @@ -1613,16 +1724,14 @@ async function startNewPlayForBook(bookId, instanceId) { return; } try { - navigateToBook(bookId); + if (instanceId) navigateToProduct(bookId, instanceId); + else navigateToBook(bookId); if (lastView?.bookId === bookId) renderSession(lastView, true); const data = await api(`/api/books/${encodeURIComponent(bookId)}/play/new`, { method: "POST", body: JSON.stringify(instanceId ? { instanceId } : {}), }); activePlaySaveId = null; - if (instanceId) { - expandedProductsByBook.set(bookId, new Set([instanceId])); - } renderSession(data.session, false); void fetchPlaySavesForBook(bookId, true); } catch (err) { @@ -1645,7 +1754,9 @@ async function openBookDesign(bookId) { } async function openCurrentPlay(bookId) { - navigateToBook(bookId); + const instanceId = playingInstanceIdForBook({ id: bookId }); + if (instanceId) navigateToProduct(bookId, instanceId); + else navigateToBook(bookId); const alreadyHere = activeBookId === bookId && isPlayNow() && @@ -2307,10 +2418,7 @@ async function saveCurrentInstance() { } await fetchPlaySavesForBook(activeBookId, true); const newest = productInstancesForBook(activeBookId)[0]; - if (newest) { - expandedProductsByBook.set(activeBookId, new Set([newest.id])); - renderBookList(); - } + if (newest) navigateToProduct(activeBookId, newest.id); } catch (err) { alert(err.message); } diff --git a/web/index.html b/web/index.html index 7a2d5f2..0c717d9 100644 --- a/web/index.html +++ b/web/index.html @@ -15,36 +15,52 @@
diff --git a/web/questions-ui.js b/web/questions-ui.js index 56bca29..f1e1b30 100644 --- a/web/questions-ui.js +++ b/web/questions-ui.js @@ -81,6 +81,7 @@ function normalizeQuestions(raw) { /** @returns {{ questions: any[], workerId?: string, pageSize: number, assessment?: string, optional?: boolean, skipLabel?: string } | null} */ export function getActiveQuestions(view) { + if (view?.playLayerActive || view?.lifecycleStage === "play") return null; const wr = view?.waitingReason; if (!wr) return null; // 默认每页 1 题,左右切换;勿一次堆多题 diff --git a/web/styles.css b/web/styles.css index d277a3d..d900516 100644 --- a/web/styles.css +++ b/web/styles.css @@ -12,7 +12,7 @@ --warn: #ca8a04; --danger: #dc2626; --books-w: 216px; - --books-w-collapsed: 52px; + --books-w-collapsed: 40px; --coord-w: clamp(280px, 24vw, 340px); --coord-w-collapsed: 40px; --workspace-pad-x: clamp(24px, 4.5vw, 72px); @@ -82,46 +82,86 @@ a.link:hover { text-decoration: underline; } gap: 2px; min-width: 0; min-height: 40px; - padding: 6px 8px 6px 6px; + padding: 6px 8px 6px 10px; } -body[data-nav-level="root"] .rail-head-row { - padding-left: 10px; -} -.rail-context { - flex: 1 1 auto; - min-width: 0; - margin: 0; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.rail-toggle { +.rail-collapse { + display: none; flex-shrink: 0; width: 28px; height: 28px; padding: 0; - display: inline-flex; align-items: center; justify-content: center; font-size: 16px; line-height: 1; } -body.rail-collapsed .rail-toggle { transform: rotate(180deg); } -body[data-nav-level="root"] .rail-toggle { display: none; } -body[data-nav-level="book"] .rail-head-actions #btn-new-book, -body[data-nav-level="root"] .burst-badge { display: none; } +body[data-rail-can-collapse="1"]:not(.rail-collapsed) .rail-collapse { + display: inline-flex; +} +.rail-expand { + display: none; + flex: 1 1 auto; + width: 100%; + border: none; + border-right: 1px solid transparent; + background: var(--surface); + color: var(--muted); + cursor: pointer; + padding: 12px 0; + flex-direction: column; + align-items: center; + gap: 8px; + font: inherit; + font-size: 12px; + font-weight: 600; + line-height: 1.2; +} +.rail-expand:hover { + color: var(--accent); + background: var(--accent-soft); +} +.rail-expand:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); + outline-offset: -2px; +} +.rail-expand-label { + writing-mode: vertical-rl; + letter-spacing: 0.12em; + user-select: none; +} +.rail-body { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +body.rail-collapsed .rail-expand { display: flex; } +body.rail-collapsed .rail-body { display: none; } +.rail-path { + flex: 1 1 auto; + min-width: 0; + margin: 0; + padding: 0 4px 0 0; + background: transparent; + border-radius: 0; + overflow: hidden; +} +.rail-path .path-bar-list { + width: 100%; +} .rail-tabs { display: none; gap: 0; min-width: 0; padding: 0 8px; } -body[data-nav-level="book"] .rail-tabs { +body:not([data-nav-level="root"]) .rail-tabs { display: flex; } +body:not([data-nav-level="root"]) .rail-head-actions #btn-new-book, +body[data-nav-level="root"] .burst-badge { display: none; } .rail-tabs[hidden] { display: none !important; } .rail-tab { flex: 1 1 0; @@ -213,40 +253,6 @@ body[data-nav-level="book"] .rail-tabs { } .rail-tab-panel[hidden] { display: none !important; } -body.rail-collapsed .rail-tab-label, -body.rail-collapsed .rail-context, -body.rail-collapsed .rail-head-actions, -body.rail-collapsed .burst-badge { display: none !important; } -body.rail-collapsed .rail-tab-icon { display: block; } -body.rail-collapsed .rail-head { - flex-direction: column; - align-items: stretch; - padding: 6px 4px; - gap: 4px; -} -body.rail-collapsed .rail-head-row { - justify-content: center; - padding: 0; - min-height: 0; -} -body.rail-collapsed[data-nav-level="book"] .rail-tabs:not([hidden]) { - display: flex; - flex-direction: column; - padding: 0; -} -body.rail-collapsed .rail-tab { - flex: 0 0 auto; - width: 100%; - height: 32px; - padding: 0; - border-bottom: none; - border-radius: 6px; -} -body.rail-collapsed .rail-tab.active { - background: var(--accent-soft); - color: var(--accent); -} -body.rail-collapsed .rail-panels { display: none; } body.rail-collapsed .panel-rail { align-items: stretch; } /* composer 按内容自适应;中间区吃掉剩余高度,整页锁在视口内 */ .panel-main { @@ -1520,24 +1526,18 @@ body.design-workspace .flow-step[data-run="done"] .flow-name { .panel-head-actions { display: flex; gap: 4px; align-items: center; } .btn-sm.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); } -/* Books — pill breadcrumb + list */ +/* Books — breadcrumb + list */ .path-bar { display: flex; align-items: center; flex-wrap: nowrap; gap: 0; - margin: 8px 8px 0; - padding: 7px 10px; - border: none; - background: var(--bg); - border-radius: 10px; overflow-x: auto; font-size: 12px; line-height: 1.35; flex-shrink: 0; min-width: 0; } -.path-bar[hidden] { display: none; } .path-bar-list { display: flex; align-items: center; @@ -1579,6 +1579,9 @@ body.design-workspace .flow-step[data-run="done"] .flow-name { overflow: hidden; text-overflow: ellipsis; } +.rail-path .path-seg.current { + font-size: 13px; +} .path-seg:hover { color: var(--accent); } .path-seg:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); @@ -1737,7 +1740,7 @@ body.design-workspace .flow-step[data-run="done"] .flow-name { color: var(--muted); letter-spacing: 0.04em; } .explorer-hint { - margin: 4px 8px 4px 28px; font-size: 11px; color: var(--muted); line-height: 1.4; + margin: 4px 8px; font-size: 11px; color: var(--muted); line-height: 1.4; } .explorer-product { margin: 2px 0 6px; @@ -4750,14 +4753,6 @@ body.design-workspace .af-mosaic { .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; } .dialog-head-row { display: flex; justify-content: space-between; align-items: center; } -@media (max-width: 720px) { - .layout { grid-template-columns: var(--books-w-collapsed) 1fr; } - body:not(.rail-collapsed) { --books-w: var(--books-w-collapsed); } - body:not(.rail-collapsed) .rail-tab-label, - body:not(.rail-collapsed) .rail-context { display: none !important; } - body:not(.rail-collapsed) .rail-panels { display: none; } -} - /* —— 游玩呈现壳 present.v1 —— * 主工作面 = body(随 500~2000 字撑高)。 * 视觉:少盒子、少渐变;层级靠字号/间距/对比,不靠装饰抢注意力。