diff --git a/docs/briefs/dictate-cost-cut-2026-10-08.md b/docs/briefs/dictate-cost-cut-2026-10-08.md new file mode 100644 index 0000000..673b11b --- /dev/null +++ b/docs/briefs/dictate-cost-cut-2026-10-08.md @@ -0,0 +1,17 @@ +# 创作对话降本:取消同轮 retry + 2 轮 history + +> 状态:已落地。承接 `token-cost-report-2026-10-06.md` / `prompt-cache-audit-2026-10-07.md`。 +> 记录:2026-10-08。 + +## 做了什么 + +1. **取消同轮 `fill-retry`。** fill 只打一枪;未通过项写入 `设计.对话落盘待补`(readTag,不进产物列表),checklist 标 **失败待补**,下轮 select/fill 自动并入。 +2. **select / fill 更早对话默认只留 2 轮**(1 轮 = 一问一答,不含本轮末句)。可用 `DICTATE_HISTORY_TURNS` 或 `historyTurns` 参数改;负数 = 不限制。 +3. **产物全黑板暂不动**(质量 / play 传导风险;内容侧优化另议)。 +4. Gemini 路径上跨轮缓存 ROI 很低;本改动主攻 **少开枪 + 砍无用 history**,不为保缓存去裁产物。 + +## 验收 + +- 同书同对话长度:`fill-retry` 调用应接近 0;每轮模型调用中位趋向 **2**(澄清 1;缺 reply_user 时 lean reply 仍可能 3)。 +- checklist 可见:待写 / 失败待补;失败回复含「失败待补(下轮继续)」。 +- `DICTATE_HISTORY_TURNS=0` 时更早对话段应接近空。 diff --git a/src/dictate/context.ts b/src/dictate/context.ts index 26c5a01..66e629b 100644 --- a/src/dictate/context.ts +++ b/src/dictate/context.ts @@ -1,4 +1,8 @@ import type { ChatMessage } from "../llm/client.js"; +import { + clipDictateDialogue, + resolveDictateHistoryTurns, +} from "./history-window.js"; import { effectiveDictateOrder, sortDictateProducts, @@ -25,7 +29,7 @@ export type DictateBuiltContext = { /** * 创作请求的块顺序:固定规则、更早的对话、全部产物、本轮注入、末尾。 * 选题不带产物正文和本轮注入。回复步只带本轮刚落盘产物与末句用户话。 - * 更早对话只追加,最后一轮用户输入放在末尾。 + * 更早对话按 historyTurns 裁剪(默认 2 轮),最后一轮用户输入放在末尾。 */ export function buildDictateContext(params: { systemPrompt: string; @@ -34,12 +38,18 @@ export function buildDictateContext(params: { phase?: DictateContextPhase; injected?: string; tail?: string; + /** 更早对话保留轮数;默认见 resolveDictateHistoryTurns */ + historyTurns?: number; }): DictateBuiltContext { const phase = params.phase ?? "fill"; if (phase === "reply") { return buildReplyContext(params); } - const { earlier, lastUser } = splitEarlierDialogue(params.dialogue); + const clipped = clipDictateDialogue( + params.dialogue, + resolveDictateHistoryTurns(params.historyTurns), + ); + const { earlier, lastUser } = splitEarlierDialogue(clipped); const history = formatEarlierDialogue(earlier); const products = phase === "fill" ? formatProducts(sortDictateProducts(params.products)) : ""; @@ -138,6 +148,7 @@ export function buildDictateMessages(params: { phase?: DictateContextPhase; injected?: string; tail?: string; + historyTurns?: number; }): ChatMessage[] { return buildDictateContext(params).messages; } diff --git a/src/dictate/history-window.ts b/src/dictate/history-window.ts new file mode 100644 index 0000000..b912d6d --- /dev/null +++ b/src/dictate/history-window.ts @@ -0,0 +1,50 @@ +import type { DictateChatTurn } from "./types.js"; + +/** + * 创作上下文保留的更早对话轮数。 + * 1 轮 = 一次用户输入 + 一次助手输出(不含本轮正在处理的末句用户话)。 + * 默认 2;0 = 不保留更早对话;负数 = 不限制。 + * 可用环境变量 `DICTATE_HISTORY_TURNS` 或调用参数覆盖。 + */ +export const DEFAULT_DICTATE_HISTORY_TURNS = 2; + +export function resolveDictateHistoryTurns(override?: number): number { + if (typeof override === "number" && Number.isFinite(override)) { + return Math.trunc(override); + } + const raw = process.env.DICTATE_HISTORY_TURNS?.trim(); + if (raw) { + const n = Number(raw); + if (Number.isFinite(n)) return Math.trunc(n); + } + return DEFAULT_DICTATE_HISTORY_TURNS; +} + +/** + * 裁剪进模型的对话:保留末句用户输入 + 最近 `historyTurns` 轮更早问答。 + * `historyTurns < 0` 时原样返回。 + */ +export function clipDictateDialogue( + dialogue: DictateChatTurn[], + historyTurns: number, +): DictateChatTurn[] { + if (historyTurns < 0) return dialogue; + const last = dialogue.at(-1); + const hasCurrentUser = last?.role === "user"; + const earlier = hasCurrentUser ? dialogue.slice(0, -1) : [...dialogue]; + const current = hasCurrentUser && last ? [last] : []; + if (historyTurns === 0) return current; + + let userCount = 0; + let start = 0; + for (let i = earlier.length - 1; i >= 0; i -= 1) { + if (earlier[i]?.role === "user") { + userCount += 1; + if (userCount === historyTurns) { + start = i; + break; + } + } + } + return [...earlier.slice(start), ...current]; +} diff --git a/src/dictate/index.ts b/src/dictate/index.ts index 57d35c1..b9e1385 100644 --- a/src/dictate/index.ts +++ b/src/dictate/index.ts @@ -10,6 +10,20 @@ export { buildDictateContext, extractDictateDialogue, } from "./context.js"; +export { + DEFAULT_DICTATE_HISTORY_TURNS, + clipDictateDialogue, + resolveDictateHistoryTurns, +} from "./history-window.js"; +export { + DICTATE_REMEDIATE_TAG, + DICTATE_REMEDIATE_SCHEMA, + parseDictateRemediate, + serializeDictateRemediate, + formatRemediateChecklist, + type DictateRemediateDoc, + type DictateRemediateItem, +} from "./remediate.js"; export { CREATION_INTAKE_MODE_TAG, DICTATE_MODE_VALUE, diff --git a/src/dictate/prompt.ts b/src/dictate/prompt.ts index cf222f8..faf044d 100644 --- a/src/dictate/prompt.ts +++ b/src/dictate/prompt.ts @@ -26,6 +26,7 @@ export function buildDictateSelectPrompt(): string { return `你只做选题。对照能力目录和用户这句话,调用 select_nodes,选出已经够写的节点。 nodes 里的 module_id 必须来自目录。通常选这段话已经说清的 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。 +若末尾 checklist 标了「失败待补」,这些节点必须再次纳入 nodes(可与新选的一起)。 用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或实现机制。 这一步不写产物正文,不回复用户。`; } diff --git a/src/dictate/remediate.ts b/src/dictate/remediate.ts new file mode 100644 index 0000000..6cd08e1 --- /dev/null +++ b/src/dictate/remediate.ts @@ -0,0 +1,83 @@ +/** + * 同轮不再 fill-retry:失败项写入「待补」状态,下轮 select/fill 继续补。 + * 存 readTag/writeTag,不进产物列表,避免污染黑板正文。 + */ + +export const DICTATE_REMEDIATE_TAG = "设计.对话落盘待补"; +export const DICTATE_REMEDIATE_SCHEMA = "dictate-remediate.v1" as const; + +export type DictateRemediateKind = "insert" | "delete"; + +export type DictateRemediateItem = { + tag: string; + moduleId?: string; + error: string; + kind: DictateRemediateKind; +}; + +export type DictateRemediateDoc = { + schema: typeof DICTATE_REMEDIATE_SCHEMA; + items: DictateRemediateItem[]; +}; + +export function parseDictateRemediate(raw: string | undefined | null): DictateRemediateDoc { + if (!raw?.trim()) return { schema: DICTATE_REMEDIATE_SCHEMA, items: [] }; + try { + const row = JSON.parse(raw) as Record; + const items = Array.isArray(row.items) ? row.items : []; + const parsed: DictateRemediateItem[] = []; + for (const item of items) { + if (!item || typeof item !== "object") continue; + const rec = item as Record; + const tag = String(rec.tag ?? "").trim(); + if (!tag) continue; + const kind = rec.kind === "delete" ? "delete" : "insert"; + const moduleId = String(rec.moduleId ?? rec.module_id ?? "").trim(); + const error = String(rec.error ?? "尚未通过").trim() || "尚未通过"; + parsed.push({ + tag, + error, + kind, + ...(moduleId ? { moduleId } : {}), + }); + } + return { schema: DICTATE_REMEDIATE_SCHEMA, items: dedupeRemediate(parsed) }; + } catch { + return { schema: DICTATE_REMEDIATE_SCHEMA, items: [] }; + } +} + +export function serializeDictateRemediate(doc: DictateRemediateDoc): string { + return JSON.stringify( + { + schema: DICTATE_REMEDIATE_SCHEMA, + items: dedupeRemediate(doc.items), + }, + null, + 2, + ); +} + +export function formatRemediateChecklist(items: readonly DictateRemediateItem[]): string { + if (!items.length) return ""; + return [ + "失败待补(须继续写入,勿忽略):", + ...items.map((item) => { + const kind = item.kind === "delete" ? "删除" : "写入"; + const mod = item.moduleId ? ` / ${item.moduleId}` : ""; + return `- 〔${kind}〕${item.tag}${mod}:${item.error}`; + }), + ].join("\n"); +} + +function dedupeRemediate(items: DictateRemediateItem[]): DictateRemediateItem[] { + const seen = new Set(); + const out: DictateRemediateItem[] = []; + for (const item of items) { + const key = `${item.kind}:${item.tag}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(item); + } + return out; +} diff --git a/src/dictate/round.ts b/src/dictate/round.ts index 94111af..5ed946f 100644 --- a/src/dictate/round.ts +++ b/src/dictate/round.ts @@ -1,13 +1,14 @@ /** * 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法, - * 再在同一条消息前缀上循环 insert。已通过的不再覆盖。 + * 再单次 fill 写入。已通过的不再覆盖。 + * 同轮不再 fill-retry:失败项记入「失败待补」,下轮继续。 * 没有未通过项时,同一次请求里的 reply_user 才给用户。 * 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。 */ import { randomUUID } from "node:crypto"; import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js"; import type { AgentDriver } from "../runtime/driver.js"; -import type { ChatMessage, LlmProvider, UsageDetail, UsageSink } from "../llm/client.js"; +import type { LlmProvider, UsageDetail, UsageSink } from "../llm/client.js"; import { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js"; import { buildDictateContext, type DictateSegmentChars } from "./context.js"; import { @@ -18,6 +19,7 @@ import { parseCreationPlan, } from "./creation-plan.js"; import { isDictateOpeningTag } from "./experience-anchor.js"; +import { resolveDictateHistoryTurns } from "./history-window.js"; import { buildDictateReplyPrompt, buildDictateSelectPrompt, @@ -32,6 +34,14 @@ import { DICTATE_MODULE_READ_STATE_TAG, type DictateModuleReadPayload, } from "./read-module.js"; +import { + DICTATE_REMEDIATE_SCHEMA, + DICTATE_REMEDIATE_TAG, + formatRemediateChecklist, + parseDictateRemediate, + serializeDictateRemediate, + type DictateRemediateItem, +} from "./remediate.js"; import { resolveModuleForInsertTag } from "./insert-feedback.js"; import type { DictateChatTurn, DictateProduct } from "./types.js"; import { isAllowedProductTag } from "./types.js"; @@ -55,6 +65,7 @@ export type PlannedRoundParams = { listProducts: () => DictateProduct[]; readTag: (tag: string) => string | undefined; writeTag: (tag: string, content: string) => void; + deleteTag?: (tag: string) => boolean; prepareOpening?: () => void; /** 本轮刚写入 tag 的能力回复口径(探测/原则/自评关注) */ lookupInsertFeedback?: (tag: string) => string | undefined; @@ -68,6 +79,8 @@ export type PlannedRoundParams = { recipeName?: string; recipeBrief?: string; systemPrompt?: string; + /** 更早对话保留轮数;默认 2,见 resolveDictateHistoryTurns */ + historyTurns?: number; }; type PlanInsert = { @@ -87,10 +100,7 @@ type SelectPlan = { }; -type ItemFailure = { tag: string; error: string }; - - -const INSERT_ATTEMPTS = 4; +type ItemFailure = { tag: string; error: string; moduleId?: string; kind?: "insert" | "delete" }; export function createPlannedDictateDriver(params: PlannedRoundParams): AgentDriver { return { @@ -106,15 +116,23 @@ async function runPlannedDictateRound( apply: Apply, ): Promise<{ text: string; modelCalls: number }> { const turnId = randomUUID(); + const historyTurns = resolveDictateHistoryTurns(params.historyTurns); const fillSystem = params.systemPrompt?.trim() || buildDictateSystemPrompt(); + const remediations = parseDictateRemediate(params.handlers.readTag(DICTATE_REMEDIATE_TAG)); + const remediateChecklist = formatRemediateChecklist(remediations.items); const tailOpts = tailState(params); - const selectTail = formatDictateRoundTail({ ...tailOpts, phase: "select" }); + const selectTail = formatDictateRoundTail({ + ...tailOpts, + phase: "select", + checklist: remediateChecklist, + }); const selectCtx = buildDictateContext({ systemPrompt: buildDictateSelectPrompt(), products: params.handlers.listProducts(), dialogue: params.dialogue, phase: "select", tail: selectTail, + historyTurns, }); const selected = await params.llm.completeWithTools(selectCtx.messages, { caller: "dictate_agent", @@ -127,7 +145,12 @@ async function runPlannedDictateRound( if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim()); const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0]; const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params); - if (plan.clarify.length >= 2 && plan.inserts.length === 0 && plan.deletes.length === 0) { + mergeRemediationsIntoPlan(plan, remediations.items, params); + if ( + plan.clarify.length >= 2 && + plan.inserts.length === 0 && + plan.deletes.length === 0 + ) { return { modelCalls: 1, text: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n"), @@ -150,10 +173,23 @@ async function runPlannedDictateRound( ); if (openings.length > 0) params.handlers.prepareOpening?.(); const injected = injectGroup(params, [...rest, ...openings]); + const priorFailures = remediations.items.filter((item) => + [...rest, ...openings].some((row) => row.tag === item.tag) || + plan.deletes.includes(item.tag), + ); const tail = formatDictateRoundTail({ ...tailOpts, phase: "fill", - checklist: formatChecklist([...rest, ...openings], plan.deletes, []), + checklist: formatChecklist( + [...rest, ...openings], + plan.deletes, + priorFailures.map((item) => ({ + tag: item.tag, + error: item.error, + moduleId: item.moduleId, + kind: item.kind, + })), + ), }); const ctx = buildDictateContext({ systemPrompt: fillSystem, @@ -162,8 +198,8 @@ async function runPlannedDictateRound( phase: "fill", injected: injected.text, tail, + historyTurns, }); - const messages: ChatMessage[] = [...ctx.messages]; const passed = new Set(); const pending = new Set([...rest, ...openings].map((item) => item.tag)); const pendingDeletes = new Set(plan.deletes); @@ -172,40 +208,47 @@ async function runPlannedDictateRound( let lastProse = ""; let modelCalls = 1; - for (let attempt = 0; attempt < INSERT_ATTEMPTS && (pending.size > 0 || pendingDeletes.size > 0); attempt++) { - const step = attempt === 0 ? "fill" : "fill-retry"; - const filled = await params.llm.completeWithTools(messages, { + // 单次 fill:失败记入待补,留给下轮,不再同轮 fill-retry。 + if (pending.size > 0 || pendingDeletes.size > 0) { + const filled = await params.llm.completeWithTools(ctx.messages, { caller: "dictate_agent", - label: attempt === 0 ? "对话落盘 填写" : "对话落盘 重写", + label: "对话落盘 填写", tools: DICTATE_WRITE_LOOP_TOOLS, toolChoice: "required", usageDetail: { - ...detail(turnId, step, attempt, ctx.segmentChars), + ...detail(turnId, "fill", 0, ctx.segmentChars), plannedTags: [...pending, ...pendingDeletes], }, - usageSink: sinkFor(turnId, step), + usageSink: sinkFor(turnId, "fill"), }); modelCalls += 1; if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim()); const prose = filled.content?.trim() ?? ""; if (prose) lastProse = prose; const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({ - id: call.id || `call-${attempt}-${index}`, + id: call.id || `call-0-${index}`, name: call.name, arguments: call.arguments, })); - if (calls.length === 0) break; - const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors); - messages.push(assistantToolMessage(filled.content, calls)); - for (const item of settled.results) { - messages.push({ role: "tool", content: item.content, tool_call_id: item.callId }); - } - if (pending.size === 0 && pendingDeletes.size === 0) { - replyFromTool = settled.reply; - break; + if (calls.length > 0) { + const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors); + if (pending.size === 0 && pendingDeletes.size === 0) { + replyFromTool = settled.reply; + } } } + const moduleByTag = new Map( + [...rest, ...openings].map((item) => [item.tag, item.moduleId] as const), + ); + persistRemediations(params, { + pending, + pendingDeletes, + errors, + moduleByTag, + prior: remediations.items, + }); + let reply = replyFromTool; if ( !replyFromTool && @@ -224,7 +267,12 @@ async function runPlannedDictateRound( ); const pendingLines = [...blockedLines, ...failed]; const text = pendingLines.length - ? [reply, `尚未完成:${pendingLines.join("、")}。`].filter(Boolean).join("\n") + ? [ + reply, + `失败待补(下轮继续):${pendingLines.join("、")}。`, + ] + .filter(Boolean) + .join("\n") : reply; return { text, modelCalls }; } @@ -303,18 +351,6 @@ async function runLeanReply( return filled.content?.trim() || fallbackProse; } -function assistantToolMessage(content: string | null, calls: DriverToolCall[]): ChatMessage { - return { - role: "assistant", - content, - tool_calls: calls.map((call) => ({ - id: call.id, - type: "function" as const, - function: { name: call.name, arguments: call.arguments }, - })), - }; -} - async function settleWriteStep( params: PlannedRoundParams, apply: Apply, @@ -665,17 +701,97 @@ function canonicalProductTag( function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string { const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean); + const failedTags = new Set(errors.map((item) => item.tag)); + const pendingWrite = group.filter((item) => !failedTags.has(item.tag)).map((item) => item.tag); + const pendingDelete = deletes.filter((tag) => !failedTags.has(tag)); const lines = [ - nodes.length ? `第一轮选定的节点:${nodes.join("、")}` : "", - group.length ? `本轮写入:${group.map((item) => item.tag).join("、")}` : "", - deletes.length ? `本轮删除:${deletes.join("、")}` : "", + nodes.length ? `本轮选定的节点:${nodes.join("、")}` : "", + pendingWrite.length ? `待写:${pendingWrite.join("、")}` : "", + pendingDelete.length ? `待删:${pendingDelete.join("、")}` : "", errors.length - ? `只重写未成功项:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}` + ? `失败待补:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}` : "", + "状态说明:待写/待删=本轮目标;失败待补=上轮或本轮未通过,须补上;已落的产物以黑板为准,不要无故整份重写。", ]; return lines.filter(Boolean).join("\n"); } +function mergeRemediationsIntoPlan( + plan: SelectPlan, + items: readonly DictateRemediateItem[], + params: PlannedRoundParams, +): void { + for (const item of items) { + if (item.kind === "delete") { + if (!plan.deletes.includes(item.tag) && isAllowedProductTag(item.tag)) { + plan.deletes.push(item.tag); + } + continue; + } + if (plan.inserts.some((row) => row.tag === item.tag || row.tag === `!${item.tag}`)) { + continue; + } + const canonical = canonicalProductTag(item.tag, item.moduleId ?? "", params.moduleCatalog); + const tag = canonical?.tag ?? item.tag; + if (!isAllowedProductTag(tag) && tag !== VARIABLE_CATALOG_TAG && tag !== VALUE_MAP_TAG) { + continue; + } + plan.inserts.push({ + tag, + moduleId: canonical?.moduleId || item.moduleId || "", + entryIds: [], + reason: item.error ? `失败待补:${item.error}` : "失败待补", + }); + } +} + +function persistRemediations( + params: PlannedRoundParams, + state: { + pending: Set; + pendingDeletes: Set; + errors: Map; + moduleByTag: Map; + prior: readonly DictateRemediateItem[]; + }, +): void { + const priorByKey = new Map( + state.prior.map((item) => [`${item.kind}:${item.tag}`, item] as const), + ); + const next: DictateRemediateItem[] = []; + for (const tag of state.pending) { + const prev = priorByKey.get(`insert:${tag}`); + next.push({ + tag, + kind: "insert", + error: state.errors.get(tag) ?? prev?.error ?? "尚未通过", + ...(state.moduleByTag.get(tag) || prev?.moduleId + ? { moduleId: state.moduleByTag.get(tag) || prev?.moduleId } + : {}), + }); + } + for (const tag of state.pendingDeletes) { + const prev = priorByKey.get(`delete:${tag}`); + next.push({ + tag, + kind: "delete", + error: state.errors.get(tag) ?? prev?.error ?? "尚未通过", + }); + } + if (next.length === 0) { + if (params.handlers.deleteTag) { + params.handlers.deleteTag(DICTATE_REMEDIATE_TAG); + } else { + params.handlers.writeTag(DICTATE_REMEDIATE_TAG, ""); + } + return; + } + params.handlers.writeTag( + DICTATE_REMEDIATE_TAG, + serializeDictateRemediate({ schema: DICTATE_REMEDIATE_SCHEMA, items: next }), + ); +} + function asItemList(value: unknown): unknown[] { if (Array.isArray(value)) return value; const rec = asRecord(value); diff --git a/src/dictate/turn.ts b/src/dictate/turn.ts index ba31393..689a2d8 100644 --- a/src/dictate/turn.ts +++ b/src/dictate/turn.ts @@ -171,6 +171,8 @@ export async function runDictateTurn(params: { moduleReadIndex?: ReadonlyMap; /** catalog 中 repeatable 能力的 artifact 基名(设计.生成规则 等) */ repeatableFamilies?: ReadonlySet | readonly string[]; + /** 更早对话保留轮数;默认 2,可用 DICTATE_HISTORY_TURNS 覆盖 */ + historyTurns?: number; driver?: AgentDriver; }): Promise { const driver = @@ -182,6 +184,7 @@ export async function runDictateTurn(params: { listProducts: params.handlers.listProducts, readTag: params.handlers.readTag, writeTag: params.handlers.writeTag, + deleteTag: params.handlers.deleteTag, prepareOpening: params.handlers.prepareOpening, lookupInsertFeedback: params.handlers.lookupInsertFeedback, onThinkingDelta: params.handlers.onThinkingDelta, @@ -194,6 +197,7 @@ export async function runDictateTurn(params: { recipeName: params.recipeName, recipeBrief: params.recipeBrief, systemPrompt: params.systemPrompt, + historyTurns: params.historyTurns, }); const wroteTags: string[] = []; const deletedTags: string[] = []; diff --git a/src/listen-serial/index.ts b/src/listen-serial/index.ts index b857169..48c2228 100644 --- a/src/listen-serial/index.ts +++ b/src/listen-serial/index.ts @@ -2,6 +2,7 @@ export { DEFAULT_BUFFER_TARGET_CHARS, LISTEN_SERIAL_MODE_VALUE, LISTEN_PHASE_TAG, + LISTEN_PLAY_LAYER_TAG, LISTEN_THRILL_TAG, LISTEN_WORLD_TAG, LISTEN_STRUCTURE_TAG, @@ -31,10 +32,13 @@ export { writeListenPhase, writeListenSections, writeListenSummaries, + isListenPlayActive, + setListenPlayActive, } from "./state.js"; export { runListenSerialTurn, + ensureListenPlayContent, markListenConsumed, type ListenChatTurn, type ListenSerialTurnResult, diff --git a/src/listen-serial/state.ts b/src/listen-serial/state.ts index 850ce40..5d20fd3 100644 --- a/src/listen-serial/state.ts +++ b/src/listen-serial/state.ts @@ -6,6 +6,7 @@ import { LISTEN_CONSUMED_CHARS_TAG, LISTEN_OUTLINE_TAG, LISTEN_PHASE_TAG, + LISTEN_PLAY_LAYER_TAG, LISTEN_SECTIONS_TAG, LISTEN_STRUCTURE_TAG, LISTEN_SUMMARIES_TAG, @@ -33,6 +34,7 @@ function parsePhase(raw: string | undefined): ListenPhase { if ( v === "intake" || v === "awaiting_confirm" || + v === "ready" || v === "running" || v === "soft_stop" ) { @@ -41,6 +43,14 @@ function parsePhase(raw: string | undefined): ListenPhase { return "intake"; } +export function isListenPlayActive(board: Blackboard): boolean { + return (board.getContentByTag(LISTEN_PLAY_LAYER_TAG) ?? "").trim() === "1"; +} + +export function setListenPlayActive(board: Blackboard, on: boolean): void { + writeTag(board, LISTEN_PLAY_LAYER_TAG, on ? "1" : "0"); +} + export function readListenState(board: Blackboard): ListenSerialState { const bufferRaw = board.getContentByTag(LISTEN_BUFFER_TARGET_TAG); const bufferTargetChars = Math.max( @@ -122,9 +132,13 @@ export function unreadChars(state: ListenSerialState): number { return Math.max(0, total - state.consumedChars); } -export function toListenView(state: ListenSerialState): ListenSerialView { +export function toListenView( + state: ListenSerialState, + playActive = false, +): ListenSerialView { return { phase: state.phase, + playActive, thrill: state.thrill, world: state.world, structure: state.structure, diff --git a/src/listen-serial/turn.ts b/src/listen-serial/turn.ts index 92a03b8..5c35fed 100644 --- a/src/listen-serial/turn.ts +++ b/src/listen-serial/turn.ts @@ -147,19 +147,104 @@ function formatOutlineLine(state: ListenSerialState): string { /** * 听书连载单轮:开书 / 确认 / 补货 / 插嘴 / 软停续波。 */ +/** + * 进入播放层时:若尚无大纲/正文,则生成到缓冲。 + */ +export async function ensureListenPlayContent(params: { + llm: LlmProvider; + board: Blackboard; +}): Promise { + const { llm, board } = params; + ensureBufferTarget(board); + let state = readListenState(board); + if (!state.thrill || !state.world) { + throw new Error("请先在创作里确认核心爽点与一句世界"); + } + if ( + state.phase === "running" || + state.phase === "soft_stop" + ) { + if (state.outline.length && unreadChars(state) > 0) { + return { + reply: [ + "继续播放。", + formatOutlineLine(state), + formatUnreadLine(state), + ] + .filter(Boolean) + .join("\n\n"), + softStop: state.phase === "soft_stop", + }; + } + } + if (!state.outline.some((o) => o.status === "pending" || o.status === "done")) { + const outlined = await llmOutline(llm, state); + const outline = makeOutlineItems(outlined.titles, outlined.payoffTitle); + state = persistState(board, state, { outline, phase: "running" }); + const filled = await fillBuffer(llm, board, state); + state = filled.state; + return { + reply: [ + outlined.reply, + formatOutlineLine(state), + filled.notes.join("\n"), + formatUnreadLine(state), + filled.softStop + ? "本段已兑现。可「下一波」或「收工」。" + : "不满意就直接一句意见,我改后面的走向。", + ] + .filter(Boolean) + .join("\n\n"), + softStop: filled.softStop, + }; + } + const filled = await fillBuffer(llm, board, state); + state = filled.state; + return { + reply: [ + filled.notes.join("\n") || "已进入播放。", + formatOutlineLine(state), + formatUnreadLine(state), + ] + .filter(Boolean) + .join("\n\n"), + softStop: filled.softStop || state.phase === "soft_stop", + }; +} + export async function runListenSerialTurn(params: { llm: LlmProvider; board: Blackboard; dialogue: ListenChatTurn[]; userText: string; + /** 是否在播放层;创作层只允许开书/确认 */ + inPlay?: boolean; }): Promise { const { llm, board, dialogue, userText } = params; + const inPlay = Boolean(params.inPlay); ensureBufferTarget(board); let state = readListenState(board); const text = userText.trim(); + // 创作层:已进播放态的正文不在这里改;ready 时可空提或改 brief + if (!inPlay && (state.phase === "running" || state.phase === "soft_stop")) { + return { + reply: "大纲与正文在「播放」里。请从目录点「播放」继续听/改向。", + softStop: state.phase === "soft_stop", + }; + } + if (!inPlay && state.phase === "ready") { + if (!text || isConfirmText(text)) { + return { + reply: + "本局已确认。请点「播放」开始写大纲与正文;想改爽点直接说新需求。", + }; + } + // 有新需求 → 重新提案 + } + // —— 软停:收工 / 下一波 —— - if (state.phase === "soft_stop") { + if (inPlay && state.phase === "soft_stop") { if (isEndText(text)) { return { reply: "本局收工。想再开可以新建作品,或再说「下一波」。", @@ -208,33 +293,21 @@ export async function runListenSerialTurn(params: { structure: intake.structure, }); return { - reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${intake.structure}\n\n回复「确认」开始生成大纲与正文。`, + reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${intake.structure}\n\n回复「确认」锁定本局;确认后点「播放」写大纲与正文。`, }; } - const outlined = await llmOutline(llm, state); - const outline = makeOutlineItems(outlined.titles, outlined.payoffTitle); - state = persistState(board, state, { outline, phase: "running" }); - const filled = await fillBuffer(llm, board, state); - state = filled.state; + state = persistState(board, state, { phase: "ready" }); return { - reply: [ - outlined.reply, - formatOutlineLine(state), - filled.notes.join("\n"), - formatUnreadLine(state), - filled.softStop - ? "本段已兑现。可「下一波」或「收工」。" - : "不满意某桥段就直接说一句意见,我会改后面的走向。", - ] - .filter(Boolean) - .join("\n\n"), - softStop: filled.softStop, + reply: `已确认本局。\n\n【核心爽点】${state.thrill}\n【一句世界】${state.world}\n【结构】${state.structure}\n\n请点「播放」开始生成大纲与正文。`, }; } - // —— 连载中:空输入/继续 = 补货;有意见 = 改向 —— - if (state.phase === "running" || state.phase === "soft_stop") { + // —— 播放层连载:继续 = 补货;有意见 = 改向 —— + if (inPlay && (state.phase === "running" || state.phase === "soft_stop" || state.phase === "ready")) { + if (state.phase === "ready" || !state.outline.length) { + return ensureListenPlayContent({ llm, board }); + } const isContinue = !text || /^(继续|接着写|补货|下一节)([!!。.\s]*)$/u.test(text); @@ -319,7 +392,7 @@ export async function runListenSerialTurn(params: { structure: intake.structure || "三段式:铺垫冲突 → 对抗升级 → 爽点兑现", }); return { - reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${state.structure}\n\n回复「确认」开始生成大纲与正文。`, + reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${state.structure}\n\n回复「确认」锁定本局;确认后点「播放」写大纲与正文。`, }; } diff --git a/src/listen-serial/types.ts b/src/listen-serial/types.ts index 87b6db9..8280dbd 100644 --- a/src/listen-serial/types.ts +++ b/src/listen-serial/types.ts @@ -11,6 +11,8 @@ export const LISTEN_SECTIONS_TAG = "听书.正文节"; export const LISTEN_SUMMARIES_TAG = "听书.小总结"; export const LISTEN_BUFFER_TARGET_TAG = "听书.缓冲目标字数"; export const LISTEN_CONSUMED_CHARS_TAG = "听书.已消费字数"; +/** 播放层开关:创作只定 brief,播放层才写大纲/正文 */ +export const LISTEN_PLAY_LAYER_TAG = "听书.播放层"; /** 默认未读/未听缓冲目标(字)——实现默认值,可后续调 */ export const DEFAULT_BUFFER_TARGET_CHARS = 4000; @@ -22,6 +24,8 @@ export const MAX_SUMMARIES_IN_CONTEXT = 8; export type ListenPhase = | "intake" | "awaiting_confirm" + /** 创作已确认,等待进入播放写大纲正文 */ + | "ready" | "running" | "soft_stop"; @@ -59,6 +63,8 @@ export type ListenSerialState = { export type ListenSerialView = { phase: ListenPhase; + /** 是否在播放层(大纲/正文/TTS) */ + playActive: boolean; thrill: string; world: string; structure: string; diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index 396ec4a..c3166c0 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -187,10 +187,13 @@ import { } from "../dictate/index.js"; import { LISTEN_SERIAL_MODE_VALUE, + ensureListenPlayContent, + isListenPlayActive, isListenSerialModeValue, markListenConsumed, readListenState, runListenSerialTurn, + setListenPlayActive, toListenView, type ListenSerialView, } from "../listen-serial/index.js"; @@ -343,7 +346,8 @@ export type SessionView = { /** 正文组成数据契约里的区。格式化时按 id 填进对应 data-block。 */ presentationSlots?: Array<{ id: string; type: string }>; /** - * 程序投影块(来自 变量.当前 等)。前端灌 present 时与消息正文合并,随真值刷新。 + * 程序投影块(来自 变量.当前 等)。前端灌 present 时与消息正文合并。 + * 每轮 toView 现算:旁观副 LLM 改表后会变,供主列气泡 patch 重渲染(主世界出稿时投影必然偏旧)。 */ presentationProjection?: Record; /** 总管正在输出的思维链(流式,轮询用) */ @@ -504,6 +508,14 @@ export class SessionManager { opts?: { useOpeningPersona?: boolean }, ): SessionView { const s = this.require(sessionId); + if (this.isListenSerialMode(s)) { + if (stage === "play") { + throw new Error("听书请用「播放」入口(会生成大纲与正文)"); + } + this.leaveListenPlay(s); + if (s.bookId) this.persist(s); + return this.toView(sessionId); + } if (stage === "play") { s.runtime.prepareEnterPlay(); if (!canEnterPlay(s.runtime.getSession())) { @@ -520,6 +532,62 @@ export class SessionManager { return this.toView(sessionId); } + /** 听书:进入播放层,必要时生成大纲与正文 */ + async enterListenPlay(sessionId: string): Promise { + const s = this.require(sessionId); + if (!this.isListenSerialMode(s)) { + throw new Error("当前不是听书连载"); + } + const board = s.runtime.getBlackboard(); + const state = readListenState(board); + if (!state.thrill?.trim() || !state.world?.trim()) { + throw new Error("请先在创作里确认核心爽点与一句世界"); + } + if (state.phase === "intake" || state.phase === "awaiting_confirm") { + throw new Error("请先回复「确认」锁定本局,再进入播放"); + } + setListenPlayActive(board, true); + s.runtime.setLifecycleStage("play"); + const llm = createDefaultMainAgentLlm(s.trackingRef, () => + this.personaForManaged(s), + ); + const result = await ensureListenPlayContent({ llm, board }); + const keep = normalizeContextTraceKeepLatest( + loadAppSettings().contextTraceKeepLatest, + ); + const tokenUsage = s.trackingRef.current.pendingUsage; + s.trackingRef.current.pendingUsage = undefined; + s.trackingRef.current.pendingOutlines = undefined; + s.trackingRef.current.pendingContextTrace = 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 } : {}), + }); + s.messages = pruneContextTraces(s.messages, keep); + s.runtime.enterDictateIntake( + result.softStop + ? "本段已告一段落。输入「下一波」或「收工」,或一句意见改向。" + : "继续听/读,或说「继续」补货;不满意直接一句意见。", + ); + if (s.bookId) this.persist(s); + return this.toView(sessionId); + } + + private leaveListenPlay(s: ManagedSession): void { + setListenPlayActive(s.runtime.getBlackboard(), false); + s.runtime.setLifecycleStage("design"); + s.runtime.enterDictateIntake( + "听书创作:可继续改爽点与设定;确认后点「播放」写大纲正文。", + ); + } + /** 用户手改黑板 tag(创作期改设定/表;尊重后续 worker 的表 rev 合并) */ writeUserBoardTag( sessionId: string, @@ -892,11 +960,13 @@ export class SessionManager { this.personaForManaged(s), ); const dialogue = this.extractListenDialogue(s.messages); + const board = s.runtime.getBlackboard(); const result = await runListenSerialTurn({ llm, - board: s.runtime.getBlackboard(), + board, dialogue, userText, + inPlay: isListenPlayActive(board), }); const keep = normalizeContextTraceKeepLatest( loadAppSettings().contextTraceKeepLatest, @@ -4040,7 +4110,18 @@ export class SessionManager { }), }), ); - const lifecycleStage = inferLifecycleStage(session); + const listenMode = this.isListenSerialMode(s); + const listenState = listenMode + ? readListenState(s.runtime.getBlackboard()) + : null; + const listenPlay = listenMode + ? isListenPlayActive(s.runtime.getBlackboard()) + : false; + const lifecycleStage: LifecycleStage = listenMode + ? listenPlay + ? "play" + : "design" + : inferLifecycleStage(session); const skillPackId = sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined); const workerSetYaml = readWorkerSetYaml(s.runtime.getBlackboard()); @@ -4056,6 +4137,13 @@ export class SessionManager { s.runtime.getBlackboard().getContentByTag("设计.回复格式"); const shellAdapt = parseShellAdaptationFromReplyFormat(replyFormatRaw); const replyContract = parseReplyDataContract(replyFormatRaw); + const listenPlayReady = Boolean( + listenState?.thrill && + listenState.world && + (listenState.phase === "ready" || + listenState.phase === "running" || + listenState.phase === "soft_stop"), + ); return { id, @@ -4075,11 +4163,13 @@ export class SessionManager { dictateProducts: this.isDictateMode(s) ? this.listDictateProducts(s) : undefined, - listenSerial: this.isListenSerialMode(s) - ? toListenView(readListenState(s.runtime.getBlackboard())) + listenSerial: listenState + ? toListenView(listenState, listenPlay) : undefined, - playLayerActive: isPlayLayerActive(session.slots), - playReady: canEnterPlay(session), + playLayerActive: listenMode + ? listenPlay + : isPlayLayerActive(session.slots), + playReady: listenMode ? listenPlayReady : canEnterPlay(session), hasProduct: Boolean( s.bookId && this.listGameSnapshots(s.bookId).some((item) => item.kind === "instance"), diff --git a/src/server/web-server.ts b/src/server/web-server.ts index f28612d..77a27e3 100644 --- a/src/server/web-server.ts +++ b/src/server/web-server.ts @@ -250,6 +250,18 @@ const server = createServer(async (req, res) => { return; } + if (req.method === "POST" && sub === "/listen/play") { + try { + const view = await sessionManager.enterListenPlay(sessionId); + 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) { diff --git a/tests/dictate-round.test.ts b/tests/dictate-round.test.ts index 40581c4..f3a4afc 100644 --- a/tests/dictate-round.test.ts +++ b/tests/dictate-round.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; import { DICTATE_MODULE_READ_STATE_TAG, + DICTATE_REMEDIATE_TAG, buildDictateContext, + clipDictateDialogue, parseDictateModuleReadState, + parseDictateRemediate, runDictateTurn, type DictateModuleReadPayload, } from "../src/dictate/index.js"; @@ -179,7 +182,37 @@ describe("dictate planned round", () => { expect(selectBody.indexOf("第一句")).toBeLessThan(selectBody.indexOf("改成细雨")); }); - it("injects the spec, stamps the read credential, and rewrites only the missing tag", async () => { + it("clips earlier dialogue to the configured history turn window", () => { + const dialogue = [ + { role: "user" as const, text: "零" }, + { role: "assistant" as const, text: "零回" }, + { role: "user" as const, text: "一" }, + { role: "assistant" as const, text: "一回" }, + { role: "user" as const, text: "二" }, + { role: "assistant" as const, text: "二回" }, + { role: "user" as const, text: "本轮" }, + ]; + expect(clipDictateDialogue(dialogue, 2).map((item) => item.text)).toEqual([ + "一", + "一回", + "二", + "二回", + "本轮", + ]); + const built = buildDictateContext({ + systemPrompt: "规则", + products: [], + dialogue, + phase: "select", + historyTurns: 2, + }); + const body = built.messages[1]?.content ?? ""; + expect(body).not.toContain("【用户】\n零"); + expect(body).toContain("一"); + expect(body).toContain("本轮"); + }); + + it("injects the spec, stamps the read credential, and writes on the single fill", async () => { const products: Array<{ tag: string; content: string }> = []; const tags = new Map(); const result = await runDictateTurn({ @@ -189,7 +222,6 @@ describe("dictate planned round", () => { inserts: [{ tag: "设计.氛围", module_id: "mood" }], deletes: [], }, - { products: [], reply: "还没写" }, { products: [{ tag: "设计.氛围", content: "细雨" }], reply: "已写成细雨", @@ -223,6 +255,7 @@ describe("dictate planned round", () => { expect(result.reply).toContain("已写成细雨"); const state = parseDictateModuleReadState(tags.get(DICTATE_MODULE_READ_STATE_TAG)); expect(state.modules.mood).toBe("v-test"); + expect(tags.get(DICTATE_REMEDIATE_TAG) ?? "").toBe(""); }); it("stops after the select step when the plan is a clarification", async () => { @@ -501,7 +534,7 @@ describe("dictate planned round", () => { expect(seen[0]).not.toContain("declare_variable"); expect(seen[1]).toContain("写成细雨"); expect(seen[1]).toContain("写氛围"); - expect(seen[1]).toContain("第一轮选定的节点:mood"); + expect(seen[1]).toContain("本轮选定的节点:mood"); expect(seen[1]).toContain("还可能缺哪些节点"); expect(result.wroteTags).toEqual(["设计.氛围"]); expect(result.reply).toContain("可能还缺主角设定"); @@ -617,16 +650,18 @@ describe("dictate planned round", () => { }, }); + expect(seen).toHaveLength(2); expect(seen[1]).toContain("self_score 维度(这次写入必填,name 必须一致):短句"); - expect(seen[2]?.startsWith(seen[1] ?? "")).toBe(true); - expect(seen[2]).toContain("还有未通过"); expect(products).toHaveLength(0); + expect(result.reply).toContain("失败待补(下轮继续)"); expect(result.reply).toContain("self_score 必填"); expect(result.reply).toContain("短句"); expect(result.reply).not.toContain("未给出"); + const remediate = parseDictateRemediate(tags.get(DICTATE_REMEDIATE_TAG)); + expect(remediate.items.map((item) => item.tag)).toEqual(["设计.氛围"]); }); - it("keeps the node table and refuses to rewrite a tag that already passed", async () => { + it("defers failed tags to the next turn instead of same-turn fill-retry", async () => { const narration: DictateModuleReadPayload = { ...modulePayload, module_id: "narration", @@ -643,24 +678,11 @@ describe("dictate planned round", () => { [ { id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨" }) }, { id: "b", name: "insert", arguments: JSON.stringify({ position: "设计.旁白", content: "旁白" }) }, - { id: "r1", name: "reply_user", arguments: JSON.stringify({ text: "都写了" }) }, + { id: "r1", name: "reply_user", arguments: JSON.stringify({ text: "氛围先落下" }) }, ], - [ - { id: "a2", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "改掉细雨" }) }, - { - id: "b2", - name: "insert", - arguments: JSON.stringify({ - position: "设计.旁白", - content: "旁白", - self_score: { dims: [{ name: "一句", score: 4 }] }, - }), - }, - ], - [{ id: "r2", name: "reply_user", arguments: JSON.stringify({ text: "旁白补上了" }) }], ]; let index = 0; - await runDictateTurn({ + const result = await runDictateTurn({ llm: { async complete() { throw new Error("对话落盘走工具,不走纯文本完成"); @@ -700,9 +722,94 @@ describe("dictate planned round", () => { }, }); - expect(seen[2]?.startsWith(seen[1] ?? "")).toBe(true); - expect(seen[2]).toContain("写旁白"); - expect(writes).toEqual(["设计.氛围:细雨", "设计.旁白:旁白"]); + expect(seen).toHaveLength(2); + expect(writes).toEqual(["设计.氛围:细雨"]); + expect(result.reply).toContain("失败待补(下轮继续)"); + expect(result.reply).toContain("设计.旁白"); + const remediate = parseDictateRemediate(tags.get(DICTATE_REMEDIATE_TAG)); + expect(remediate.items).toEqual([ + expect.objectContaining({ tag: "设计.旁白", kind: "insert" }), + ]); + }); + + it("merges prior失败待补 into the next fill even if select omits them", async () => { + const tags = new Map([ + [ + DICTATE_REMEDIATE_TAG, + JSON.stringify({ + schema: "dictate-remediate.v1", + items: [{ tag: "设计.氛围", moduleId: "mood", error: "self_score 必填", kind: "insert" }], + }), + ], + ]); + const products: Array<{ tag: string; content: string }> = []; + const seen: string[] = []; + const steps = [ + { nodes: [{ module_id: "narration" }] }, + { + products: [ + { tag: "设计.氛围", content: "细雨" }, + { tag: "设计.旁白", content: "旁白" }, + ], + reply: "都补上了", + }, + ]; + let index = 0; + const result = await runDictateTurn({ + llm: { + async complete() { + throw new Error("对话落盘走工具,不走纯文本完成"); + }, + async completeWithTools(messages) { + seen.push(messages.map((message) => message.content ?? "").join("\n")); + const body = steps[index++] ?? {}; + return { + content: null, + toolCalls: toToolCalls(body, index === 1), + usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 }, + }; + }, + }, + dialogue: [{ role: "user", text: "继续" }], + moduleCatalog: { + modules: [ + ...catalog.modules, + { id: "narration", name: "旁白", artifact: "设计.旁白", declaration: "旁白" }, + ], + } as ModuleCatalog, + moduleReadIndex: new Map([ + ["mood", modulePayload], + [ + "narration", + { + ...modulePayload, + module_id: "narration", + artifact: "设计.旁白", + task: "写旁白", + }, + ], + ]), + handlers: { + listProducts: () => products, + writeProduct: (tag, content) => { + products.push({ tag, content }); + }, + deleteProduct: () => false, + readTag: (tag) => tags.get(tag), + writeTag: (tag, content) => { + tags.set(tag, content); + }, + deleteTag: (tag) => tags.delete(tag), + clearDialogue: () => undefined, + }, + }); + + expect(seen[0]).toContain("失败待补"); + expect(seen[1]).toContain("设计.氛围"); + expect(seen[1]).toContain("写氛围"); + expect(result.wroteTags).toEqual(expect.arrayContaining(["设计.氛围", "设计.旁白"])); + expect(tags.has(DICTATE_REMEDIATE_TAG)).toBe(false); + expect(result.reply).toContain("都补上了"); }); it("puts reply_user guidance early in the fill prompt and does not mention a fallback reply step", async () => { diff --git a/tests/listen-serial.test.ts b/tests/listen-serial.test.ts index d201ccf..3dab05d 100644 --- a/tests/listen-serial.test.ts +++ b/tests/listen-serial.test.ts @@ -50,8 +50,10 @@ describe("listen-serial state", () => { expect(state.outline).toHaveLength(3); expect(state.outline[2]?.payoff).toBe(true); expect(unreadChars(state)).toBe(100); - const view = toListenView(state); + const view = toListenView(state, true); expect(view.unreadChars).toBe(100); + expect(view.playActive).toBe(true); + expect(toListenView(state).playActive).toBe(false); }); it("applyRedirectCut drops unconsumed sections and keeps consumed", () => { diff --git a/web/agent-ui.js b/web/agent-ui.js index 8360b18..c06aedd 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -134,7 +134,9 @@ export function canOfferSaveProduct(view) { export function canOfferEnterPlay(view) { if (!view || isPlayView(view)) return false; - if (view.creationMode === "listen_serial") return false; + if (view.creationMode === "listen_serial") { + return Boolean(view.playReady); + } return canOfferSaveProduct(view) || Boolean(view.hasProduct); } @@ -1618,10 +1620,23 @@ function syncCoordRailChrome() { const title = document.getElementById("coord-rail-title"); const products = coordRailMode === "products"; const listen = coordRailMode === "listen"; + const listenPlay = listen && rail?.dataset.listenPlay === "1"; document.body.classList.toggle("coord-rail-collapsed", coordRailCollapsed); document.body.dataset.coordRailMode = coordRailMode; - const shortLabel = listen ? "正文" : products ? "产物" : "对话"; - const longLabel = listen ? "大纲与正文" : products ? "已持久化" : "此前对话"; + const shortLabel = listen + ? listenPlay + ? "正文" + : "本局" + : products + ? "产物" + : "对话"; + const longLabel = listen + ? listenPlay + ? "大纲与正文" + : "本局设定" + : products + ? "已持久化" + : "此前对话"; if (toggleLabel) toggleLabel.textContent = shortLabel; if (title) title.textContent = longLabel; if (toggle) { @@ -5736,10 +5751,11 @@ function getRollbackMessageId(messages, messageId) { /** @returns {"speak"|"answer"|"review"|"busy"|"dictate"|"listen"|null} */ function resolveDesignSurface(view, loading) { + // 听书:创作/播放都用同一壳;播放层右侧才写大纲与正文 + if (view.creationMode === "listen_serial") return "listen"; 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"; @@ -5973,20 +5989,24 @@ function renderSpeakWorkspace(view) { 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 || - "说「继续」补货;不满意直接一句意见改后面。"; + if (ls?.playActive) { + if (phase === "soft_stop") { + hint = "本段已兑现。输入「下一波」或「收工」,或一句意见改向。"; + } else { + hint = + view.hints?.[0] || + wr.message || + "说「继续」补货;不满意直接一句意见改后面。"; + } + } else if (phase === "awaiting_confirm") { + hint = "回复「确认」锁定本局;确认后点「播放」写大纲与正文。"; + } else if (phase === "ready") { + hint = "本局已确认。点「播放」生成大纲与正文;也可继续改爽点。"; } else { hint = view.hints?.[0] || wr.message || - "用几句话描述题材与爽点;右侧是大纲与正文。"; + "用几句话描述题材与爽点;确认后进「播放」写大纲与正文。"; } } else if (view.creationMode === "dictate") { title = displayCreationModeLabel("dictate"); @@ -6029,7 +6049,13 @@ function renderSpeakWorkspace(view) { } + }" data-enter-play>${ + view.creationMode === "listen_serial" + ? "播放" + : view.hasProduct + ? "用产物开玩" + : "开玩" + } ` : ""; return `
@@ -6174,7 +6200,7 @@ function fillCoordRailProducts(view) { function renderListenSerialRail(view) { const ls = view.listenSerial; if (!ls) { - return `

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

`; + return `

开书后,这里显示本局爽点与世界。

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

爽点 ${esc(ls.thrill)}

` : "", @@ -6183,6 +6209,28 @@ function renderListenSerialRail(view) { ? `

结构 ${esc(ls.structure)}

` : "", ].join(""); + + // 创作层:只展示 brief,大纲/正文/TTS 在播放层 + if (!ls.playActive) { + const ready = Boolean(view.playReady); + const phaseHint = + ls.phase === "awaiting_confirm" + ? "回复「确认」锁定本局。" + : ls.phase === "ready" + ? "本局已确认,可进播放写大纲与正文。" + : "先定爽点与世界,确认后再播放。"; + return `
+

创作 · 阶段 ${esc(ls.phase)}

+ ${brief || `

尚无本局设定

`} +

${esc(phaseHint)}

+
+ +
+
`; + } + const outline = (ls.outline ?? []) .map((o) => { const mark = o.status === "done" ? "✓" : o.status === "cut" ? "×" : "·"; @@ -6206,12 +6254,13 @@ function renderListenSerialRail(view) { 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 `
+ return `

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

${brief} - ${outline ? `
    ${outline}
` : ""} + ${outline ? `
    ${outline}
` : `

正在准备大纲…

`}
${sections || `

尚无正文

`}
+ @@ -6229,12 +6278,24 @@ function fillCoordRailListen(view, handlers) { coordRailMode = "listen"; coordRailCollapsed = false; } + const playActive = Boolean(view.listenSerial?.playActive); + coordRail.dataset.listenPlay = playActive ? "1" : "0"; coordRail.hidden = false; if (drawerBody) { drawerBody.innerHTML = renderListenSerialRail(view); if (!drawerBody.dataset.listenConsumeWired) { drawerBody.dataset.listenConsumeWired = "1"; drawerBody.addEventListener("click", (e) => { + const enterBtn = e.target.closest("[data-enter-play]"); + if (enterBtn && drawerBody.contains(enterBtn) && !enterBtn.disabled) { + handlers?.onEnterPlay?.(); + return; + } + const leaveBtn = e.target.closest("[data-listen-leave-design]"); + if (leaveBtn && drawerBody.contains(leaveBtn)) { + handlers?.onLeaveListenPlay?.(); + return; + } const stopBtn = e.target.closest("[data-listen-tts-stop]"); if (stopBtn && drawerBody.contains(stopBtn)) { handlers?.onListenStop?.(); @@ -6270,8 +6331,14 @@ function fillCoordRailListen(view, handlers) { } const n = view.listenSerial?.sections?.length ?? 0; if (drawerCount) { - drawerCount.textContent = n ? String(n) : ""; - drawerCount.title = n ? `${n} 节正文` : "尚无正文"; + if (playActive) { + drawerCount.textContent = n ? String(n) : ""; + drawerCount.title = n ? `${n} 节正文` : "尚无正文"; + } else { + const hasBrief = Boolean(view.listenSerial?.thrill || view.listenSerial?.world); + drawerCount.textContent = hasBrief ? "·" : ""; + drawerCount.title = hasBrief ? "本局设定" : "尚无本局设定"; + } } syncCoordRailChrome(); } @@ -6282,17 +6349,19 @@ function fillCoordRail(visible, view, handlers) { const drawerCount = document.getElementById("coord-drawer-count"); if (!coordRail) return; - if (view.creationMode === "listen_serial" && !isPlayView(view)) { + if (view.creationMode === "listen_serial") { fillCoordRailListen(view, handlers); return; } // 转述产物栏只在创作层;游玩层与配方开玩一样走回合历史 if (view.creationMode === "dictate" && !isPlayView(view)) { + delete coordRail.dataset.listenPlay; fillCoordRailProducts(view); return; } + delete coordRail.dataset.listenPlay; if (coordRailMode !== "history") { coordRailMode = "history"; coordRailCollapsed = true; @@ -6553,9 +6622,13 @@ export function renderMessageFeed(view, loading, handlers = {}) { surface === "dictate" && view.selectedRecipe?.name ? esc(view.selectedRecipe.name) : ""; - const kicker = displayCreationModeLabel( - surface === "listen" ? "listen_serial" : "dictate", - ); + const listenPlay = surface === "listen" && Boolean(view.listenSerial?.playActive); + const kicker = + surface === "listen" + ? listenPlay + ? "听书播放" + : displayCreationModeLabel("listen_serial") + : displayCreationModeLabel("dictate"); const head = recipeName ? `
${kicker}配方 · ${recipeName}
` : `
${kicker}
`; @@ -6566,7 +6639,9 @@ export function renderMessageFeed(view, loading, handlers = {}) { appendMessagesToFeed(dictateFeed, visible, view, handlers, activeQuestions, { emptyText: surface === "listen" - ? "用几句话描述题材与爽点。确认后我会写大纲和正文;右侧可看缓冲与大纲。" + ? listenPlay + ? "右侧是大纲与正文。说「继续」补货,或一句意见改向。" + : "用几句话描述题材与爽点。确认本局后点「播放」写大纲与正文。" : "直接说你想要的体验与设定。我会整理进右侧产物,并在回复里跟你确认、建议补充。", loading, }); @@ -6578,7 +6653,11 @@ export function renderMessageFeed(view, loading, handlers = {}) { const label = live?.label ?? view.focus?.action ?? - (surface === "listen" ? "连载中" : "整理中"); + (surface === "listen" + ? listenPlay + ? "写稿中" + : "定局中" + : "整理中"); pending.innerHTML = `
${esc(label)}流式输出中${renderRetryRunButton()}
diff --git a/web/app.js b/web/app.js index c3fe243..3f34750 100644 --- a/web/app.js +++ b/web/app.js @@ -10,7 +10,12 @@ import { } from "./agent-ui.js"; import { downloadMarkdown, sessionToMarkdown } from "./export.js"; import { renderIntakePanel } from "./intake-ui.js"; -import { displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js"; +import { + displayCreationModeLabel, + displaySkillPackLabel, + isFlowPlanReview, + reviewComposerCopy, +} from "./display-labels.js"; import { clearQuestionCardState, collectQuestionAnswers, @@ -236,6 +241,33 @@ function resolveUserTask(view, loading) { const hasQuestions = Boolean(getActiveQuestions(view)?.questions?.length); const moduleOpening = isModuleOpeningWaiting(view); + if (view.creationMode === "listen_serial") { + const ls = view.listenSerial; + if (ls?.playActive) { + return { + id: "speak", + label: "播放", + title: ls.phase === "soft_stop" ? "本段已兑现" : "听书播放", + hint: + ls.phase === "soft_stop" + ? "「下一波」或「收工」,或一句意见改向。" + : "说「继续」补货;不满意直接一句意见。", + }; + } + const phase = ls?.phase; + return { + id: "speak", + label: "听书", + title: displayCreationModeLabel("listen_serial"), + hint: + phase === "awaiting_confirm" + ? "回复「确认」锁定本局;确认后点「播放」。" + : phase === "ready" + ? "点「播放」写大纲与正文;也可继续改爽点。" + : "用几句话描述题材与爽点。", + }; + } + if (isPlayView(view)) { return { id: "speak", @@ -492,6 +524,31 @@ function resolveComposer(view, loading) { }; } + if (view.creationMode === "listen_serial") { + const send = view.actions?.find((a) => a.type === "send_message"); + const ls = view.listenSerial; + let placeholder = "描述你想写什么…"; + if (ls?.playActive) { + placeholder = + ls.phase === "soft_stop" + ? "下一波 / 收工 / 一句改向…" + : "继续 / 一句意见改向…"; + } else if (ls?.phase === "awaiting_confirm") { + placeholder = "确认 / 或继续改需求…"; + } else if (ls?.phase === "ready") { + placeholder = "继续改爽点,或点右侧「播放」…"; + } + return { + mode: "input", + task, + taskTitle: task.title || displayCreationModeLabel("listen_serial"), + taskHint: task.hint, + placeholder: send?.placeholder ?? placeholder, + hint: null, + submitLabel: "发送", + }; + } + if (isPlayView(view)) { const send = view.actions?.find((a) => a.type === "send_message"); return { @@ -1990,11 +2047,22 @@ function showEnterPicker(bookId, anchorEl) { el.classList.remove("is-picking"); }); anchorEl.classList.add("is-picking"); + const book = books.find((b) => b.id === bookId); + const listen = bookCreationMode(book) === "listen_serial"; + const designBtn = pop.querySelector("[data-enter=design]"); + if (designBtn) designBtn.textContent = "创作"; const playBtn = pop.querySelector("[data-enter=play]"); const ready = bookPlayReady(bookId); if (playBtn) { - playBtn.disabled = !ready; - playBtn.title = ready ? "进入游玩" : "验收后可游玩"; + playBtn.textContent = listen ? "播放" : "游玩"; + playBtn.disabled = listen ? false : !ready; + playBtn.title = listen + ? ready + ? "进入播放,写大纲与正文" + : "确认本局后可播放(未确认会提示)" + : ready + ? "进入游玩" + : "验收后可游玩"; } pop.hidden = false; const rect = anchorEl.getBoundingClientRect(); @@ -2149,6 +2217,9 @@ function getNavBook() { function catalogBookMeta(book) { if (bookCreationMode(book) === "listen_serial") { + if (lastView?.bookId === book.id && lastView.playReady) { + return lastView.playLayerActive ? "播放中" : "可播放"; + } return "听书"; } const saves = playSavesByBook.get(book.id); @@ -2582,7 +2653,6 @@ function renderBookList() { for (const book of modeBooks) { const selected = selectedBookIds.has(book.id); - const listen = bookCreationMode(book) === "listen_serial"; list.appendChild( renderExplorerRow({ name: book.title, @@ -2597,10 +2667,6 @@ function renderBookList() { return; } e.stopPropagation(); - if (listen) { - void openBook(book.id); - return; - } if (enterPickerBookId === book.id) { hideEnterPicker(); return; @@ -2884,9 +2950,15 @@ async function openBookDesign(bookId) { async function openCurrentPlay(bookId) { hideEnterPicker(); - const instanceId = playingInstanceIdForBook({ id: bookId }); - if (instanceId) ensureSpecExpanded(bookId, instanceId); - else navigateToBook(bookId); + const book = books.find((b) => b.id === bookId); + const listen = bookCreationMode(book) === "listen_serial"; + if (!listen) { + const instanceId = playingInstanceIdForBook({ id: bookId }); + if (instanceId) ensureSpecExpanded(bookId, instanceId); + else navigateToBook(bookId); + } else { + navigateToBook(bookId); + } const alreadyHere = activeBookId === bookId && isPlayNow() && @@ -2900,6 +2972,11 @@ async function openCurrentPlay(bookId) { if (activeBookId !== bookId || !sessionId) { await openBook(bookId); } + if (listen) { + activePlaySaveId = null; + await enterListenPlay(); + return; + } if (activePlaySaveId && lastView?.playLayerActive) { try { const data = await api( @@ -2923,10 +3000,15 @@ function renderHeader(view, loading) { document.title = `${title} · Writing Agent`; $("status-dot").className = `status-dot ${st.cls}`; $("status-text").textContent = st.text; + const listenMode = view.creationMode === "listen_serial"; const showSavePlay = Boolean( - view.bookId && view.playReady && view.lifecycleStage === "play" && view.playLayerActive, + !listenMode && + view.bookId && + view.playReady && + view.lifecycleStage === "play" && + view.playLayerActive, ); - const showSaveInstance = Boolean(view.bookId && view.playReady); + const showSaveInstance = Boolean(!listenMode && view.bookId && view.playReady); const btnPlay = $("btn-save-play"); if (btnPlay) btnPlay.hidden = !showSavePlay; const btnInst = $("btn-save-instance"); @@ -3030,6 +3112,23 @@ function isStaleUiRequest(seq) { return seq !== inflightSeq; } +/** 真值 / 程序投影指纹:旁观改表后即使消息条数不变也要刷正文壳 */ +function sessionTruthSig(view) { + const rows = view?.playAux?.table?.rows; + if (Array.isArray(rows) && rows.length) { + return rows.map((r) => `${r.key}:${r.rev ?? 0}`).join(","); + } + const projected = view?.presentationProjection; + if (projected && typeof projected === "object") { + try { + return `p:${JSON.stringify(projected).length}:${Object.keys(projected).sort().join(",")}`; + } catch { + return "p:?"; + } + } + return ""; +} + function sessionStructuralSig(view) { const messages = view?.messages ?? []; const last = messages[messages.length - 1]; @@ -3042,6 +3141,8 @@ function sessionStructuralSig(view) { view?.phase ?? "", view?.waitingReason?.kind ?? "", view?.runActive ? "1" : "0", + // 副 LLM 填表落后于主世界出稿;表 rev / 投影变了必须重渲染 present + sessionTruthSig(view), ].join("|"); } @@ -3173,13 +3274,23 @@ function startLivePoll() { return; } + // 结构未变时仍并入真值投影,避免 lastView 卡在旁观改表前的旧壳数据 + const truthChanged = sessionTruthSig(view) !== sessionTruthSig(lastView); lastView = { ...lastView, liveStream: view.liveStream, agentThinking: view.agentThinking, runActive: view.runActive, dictateProducts: view.dictateProducts ?? lastView.dictateProducts, + playAux: view.playAux ?? lastView.playAux, + presentationProjection: + view.presentationProjection ?? lastView.presentationProjection, }; + if (truthChanged) { + livePollStructuralSig = sessionStructuralSig(view); + renderSession(view, true); + return; + } updateLiveStreamPanel(view); } catch { livePollFailCount += 1; @@ -3247,6 +3358,7 @@ function renderSession(view, loading = false) { onSpawnCreationStep: (moduleName) => runAction("spawn_step", { moduleName }), onLeaveCreationStep: () => runAction("leave_step"), onEnterPlay: () => enterPlayNow(), + onLeaveListenPlay: () => void setLifecycle("design"), onSaveProduct: () => void saveCurrentInstance(), onPatchTable: (tag, cells) => void patchPlayTable(tag, cells), onSelectOpening: (index, usePersona) => void selectPlayOpening(index, usePersona), @@ -3546,6 +3658,10 @@ async function runAction(action, extra = {}) { } function enterPlayNow() { + if (lastView?.creationMode === "listen_serial") { + void enterListenPlay(); + return; + } if (activeBookId) { void openCurrentPlay(activeBookId); return; @@ -3555,6 +3671,25 @@ function enterPlayNow() { })(); } +async function enterListenPlay() { + if (!sessionId) return; + const seq = beginUiRequest(); + try { + renderSession(lastView, true); + const view = await api( + `/api/sessions/${encodeURIComponent(sessionId)}/listen/play`, + { method: "POST", body: "{}" }, + ); + if (isStaleUiRequest(seq)) return; + if (activeBookId) navigateToBook(activeBookId); + renderSession(view, false); + } catch (err) { + if (isStaleUiRequest(seq)) return; + alert(err.message || "无法进入播放"); + if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); + } +} + async function patchPlayTable(tag, cells) { if (!sessionId) return; try { @@ -3598,6 +3733,13 @@ async function selectPlayOpening(index, usePersona) { async function setLifecycle(stage, opts) { if (!sessionId) return; + if (lastView?.creationMode === "listen_serial") { + if (stage === "play") { + await enterListenPlay(); + return; + } + // design:离开播放层 + } if (stage === lastView?.lifecycleStage) { if (stage === "design") return; if ( @@ -3608,7 +3750,13 @@ async function setLifecycle(stage, opts) { return; } } - if (stage === "play" && !lastView?.playReady && !canOfferEnterPlay(lastView) && !lastView?.hasProduct) { + if ( + stage === "play" && + lastView?.creationMode !== "listen_serial" && + !lastView?.playReady && + !canOfferEnterPlay(lastView) && + !lastView?.hasProduct + ) { alert("请先保存产物,或完成收口后再开玩"); return; } @@ -3629,7 +3777,9 @@ async function setLifecycle(stage, opts) { navigateToBook(activeBookId); } renderSession(view, false); - if (activeBookId) void fetchPlaySavesForBook(activeBookId, true); + if (activeBookId && lastView?.creationMode !== "listen_serial") { + void fetchPlaySavesForBook(activeBookId, true); + } } catch (err) { alert(err.message || "无法切换阶段"); if (lastView) renderSession(lastView, false); @@ -3657,26 +3807,16 @@ function resolveNewBookCreationMode(opts = {}) { function openNewBookDialog(opts = {}) { $("input-book-title").value = ""; const locked = resolveNewBookCreationMode(opts); - const modeField = $("field-creation-mode"); - const modeSel = $("input-creation-mode"); + if (!locked) { + alert("请先进入「对话落盘」或「听书连载」文件夹,再新建作品。"); + return; + } 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 = "先选创作方式,再填写名称。"; + if (lockedInput) lockedInput.value = locked; + if (desc) { + desc.textContent = `将创建到「${creationModeFolderLabel(locked)}」,填写名称即可。`; } - $("dialog-new-book").showModal(); $("input-book-title").focus(); } @@ -3684,9 +3824,9 @@ function openNewBookDialog(opts = {}) { async function createBook() { const title = $("input-book-title").value.trim() || "未命名作品"; const locked = ($("input-creation-mode-locked")?.value || "").trim(); + const fromNav = resolveNewBookCreationMode(); const creationMode = - locked === "listen_serial" || - $("input-creation-mode")?.value === "listen_serial" + locked === "listen_serial" || fromNav === "listen_serial" ? "listen_serial" : "dictate"; $("btn-create-book").disabled = true; @@ -4277,14 +4417,7 @@ $("lifecycle-toggle")?.addEventListener("click", (e) => { if (stage === "play") enterPlayNow(); else if (stage === "design") void setLifecycle("design"); }); -$("btn-new-book")?.addEventListener("click", () => { - const locked = resolveNewBookCreationMode(); - if (!locked && sidebarNav.level === "root") { - alert("请先进入「对话落盘」或「听书连载」文件夹,再新建作品。"); - return; - } - openNewBookDialog(locked ? { presetMode: locked } : {}); -}); +$("btn-new-book")?.addEventListener("click", () => openNewBookDialog()); $("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close()); $("book-enter-pop")?.addEventListener("click", (e) => { e.stopPropagation(); diff --git a/web/index.html b/web/index.html index becaf57..a9627e6 100644 --- a/web/index.html +++ b/web/index.html @@ -201,13 +201,6 @@ 名称 -
diff --git a/web/styles.css b/web/styles.css index 7496e75..0f49a99 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1457,6 +1457,12 @@ body.design-workspace[data-design-surface="dictate"] .panel-feed, body.design-workspace[data-design-surface="listen"] .panel-feed { overflow: hidden; } +.listen-rail-hint { + margin: 0.35rem 0 0; + font-size: 0.82rem; + opacity: 0.72; + line-height: 1.4; +} .listen-rail { display: flex; flex-direction: column;