From ff5c77bfb141bd4b2cf295a77457a5e63bfaeb21 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Wed, 7 Oct 2026 23:49:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=90=AC=E4=B9=A6=E8=BF=9E?= =?UTF-8?q?=E8=BD=BD=E8=BF=9B=E6=96=99=EF=BC=8C=E6=8C=89=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E8=BF=9E=E7=BB=AD=E5=90=90=E5=87=BA=E6=AD=A3?= =?UTF-8?q?=E6=96=87=E7=BC=93=E5=86=B2=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与对话落盘并列,适合边听边往后生成的路径。 Co-authored-by: Cursor --- .../briefs/listen-serial-path-2026-10-07.docx | Bin 0 -> 40539 bytes src/listen-serial/generate.ts | 173 +++++++++ src/listen-serial/index.ts | 41 +++ src/listen-serial/prompt.ts | 139 +++++++ src/listen-serial/state.ts | 251 +++++++++++++ src/listen-serial/turn.ts | 343 ++++++++++++++++++ src/listen-serial/types.ts | 81 +++++ tests/listen-serial.test.ts | 117 ++++++ 8 files changed, 1145 insertions(+) create mode 100644 docs/briefs/listen-serial-path-2026-10-07.docx create mode 100644 src/listen-serial/generate.ts create mode 100644 src/listen-serial/index.ts create mode 100644 src/listen-serial/prompt.ts create mode 100644 src/listen-serial/state.ts create mode 100644 src/listen-serial/turn.ts create mode 100644 src/listen-serial/types.ts create mode 100644 tests/listen-serial.test.ts diff --git a/docs/briefs/listen-serial-path-2026-10-07.docx b/docs/briefs/listen-serial-path-2026-10-07.docx new file mode 100644 index 0000000000000000000000000000000000000000..afd0755949a91fbe8b927a6e479f2632a2b51f5a GIT binary patch literal 40539 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 { + const messages: ChatMessage[] = [ + { role: "system", content: system }, + { role: "user", content: user }, + ]; + const result = await llm.complete(messages, { + responseFormat: "json_object", + caller, + label: "听书连载", + }); + const parsed = tryParseJsonDoc(result.content); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("听书连载:模型未返回合法 JSON 对象"); + } + return parsed as Record; +} + +function str(v: unknown): string { + return typeof v === "string" ? v.trim() : v == null ? "" : String(v).trim(); +} + +function strList(v: unknown): string[] { + if (!Array.isArray(v)) return []; + return v.map((x) => str(x)).filter(Boolean); +} + +export async function llmIntake( + llm: LlmProvider, + dialogue: Array<{ role: "user" | "assistant"; text: string }>, +): Promise { + const transcript = dialogue + .map((t) => `${t.role === "user" ? "用户" : "助手"}:${t.text}`) + .join("\n\n"); + const obj = await completeJson( + llm, + intakeSystemPrompt(), + transcript || "(用户尚未说话)", + "listen_serial_intake", + ); + const type = str(obj.type) === "propose" ? "propose" : "ask"; + const reply = str(obj.reply) || (type === "ask" ? "还需要一点材料。" : "请确认本局草案。"); + if (type === "ask") return { type, reply }; + return { + type: "propose", + reply, + thrill: str(obj.thrill), + world: str(obj.world), + structure: str(obj.structure), + }; +} + +export async function llmOutline( + llm: LlmProvider, + state: ListenSerialState, + userNote?: string, +): Promise { + const obj = await completeJson( + llm, + outlineSystemPrompt(state), + userNote?.trim() || "请生成本段大纲。", + "listen_serial_outline", + ); + const titles = strList(obj.titles); + const payoffTitle = str(obj.payoff_title) || titles[titles.length - 1] || ""; + return { + titles, + payoffTitle, + reply: str(obj.reply) || "大纲已就绪,开始写正文。", + }; +} + +export async function llmNextWave( + llm: LlmProvider, + state: ListenSerialState, +): Promise { + const obj = await completeJson( + llm, + nextWaveSystemPrompt(state), + "请开下一波爽点大纲。", + "listen_serial_next_wave", + ); + const titles = strList(obj.titles); + return { + titles, + payoffTitle: str(obj.payoff_title) || titles[titles.length - 1] || "", + reply: str(obj.reply) || "下一波大纲已就绪。", + thrill: str(obj.thrill) || undefined, + }; +} + +export async function llmSection( + llm: LlmProvider, + state: ListenSerialState, + nextTitle: string, +): Promise { + const obj = await completeJson( + llm, + sectionSystemPrompt(state, nextTitle), + `请写标题「${nextTitle}」的正文。`, + "listen_serial_section", + ); + return { + text: str(obj.text), + summary: str(obj.summary), + fulfilled: Boolean(obj.fulfilled), + }; +} + +export async function llmRedirect( + llm: LlmProvider, + state: ListenSerialState, + opinion: string, +): Promise { + const obj = await completeJson( + llm, + redirectSystemPrompt(state, opinion), + opinion, + "listen_serial_redirect", + ); + const titles = strList(obj.titles); + return { + titles, + payoffTitle: str(obj.payoff_title) || titles[titles.length - 1] || "", + thrillOverride: str(obj.thrill_override), + reply: str(obj.reply) || "已按你的意见改后面的走向。", + }; +} diff --git a/src/listen-serial/index.ts b/src/listen-serial/index.ts new file mode 100644 index 0000000..b857169 --- /dev/null +++ b/src/listen-serial/index.ts @@ -0,0 +1,41 @@ +export { + DEFAULT_BUFFER_TARGET_CHARS, + LISTEN_SERIAL_MODE_VALUE, + LISTEN_PHASE_TAG, + LISTEN_THRILL_TAG, + LISTEN_WORLD_TAG, + LISTEN_STRUCTURE_TAG, + LISTEN_OUTLINE_TAG, + LISTEN_SECTIONS_TAG, + LISTEN_SUMMARIES_TAG, + isListenSerialModeValue, + type ListenPhase, + type ListenSerialState, + type ListenSerialView, + type OutlineItem, + type SectionItem, +} from "./types.js"; + +export { + readListenState, + toListenView, + unreadChars, + ensureBufferTarget, + isConfirmText, + isNextWaveText, + isEndText, + makeOutlineItems, + applyRedirectCut, + writeListenBrief, + writeListenOutline, + writeListenPhase, + writeListenSections, + writeListenSummaries, +} from "./state.js"; + +export { + runListenSerialTurn, + markListenConsumed, + type ListenChatTurn, + type ListenSerialTurnResult, +} from "./turn.js"; diff --git a/src/listen-serial/prompt.ts b/src/listen-serial/prompt.ts new file mode 100644 index 0000000..74573ad --- /dev/null +++ b/src/listen-serial/prompt.ts @@ -0,0 +1,139 @@ +import type { ListenSerialState } from "./types.js"; +import { MAX_SUMMARIES_IN_CONTEXT } from "./types.js"; + +const ROLE = `你是「听书连载」写手:快速产出打发时间的廉价爽文(短剧/短篇量级)。 +文笔可以平、设定可以薄、前后可以略矛盾;但核心爽点必须兑现,节奏要直接。 +不要写成长篇网文铺垫;允许跳过前史,直接切到打脸/兑现段。 +输出必须是单个 JSON 对象,不要 markdown 围栏。`; + +export function intakeSystemPrompt(): string { + return `${ROLE} + +当前阶段:开书收集。根据用户几句话,要么轻回问(最多 1~2 个关键缺口),要么给出可确认的本局草案。 + +JSON schema: +{ + "type": "ask" | "propose", + "reply": "给用户看的短回复(中文)", + "thrill": "核心爽点一句话(propose 必填)", + "world": "一句世界:基础世界+变造(propose 必填)", + "structure": "结构调味,如起承转合/三段式+本局怎么用(propose 必填)" +} + +规则: +- type=ask 时不要编造 thrill/world/structure。 +- type=propose 时三项都要具体、短;爽点要一眼能懂(身份反差/法律制裁/天道报应等)。 +- 用户材料已够就直接 propose,别为问而问。`; +} + +export function outlineSystemPrompt(state: ListenSerialState): string { + return `${ROLE} + +当前阶段:为本段生成大纲标题链,规划到「爽点兑现」为止(不要一次规划整本人生)。 + +已钉死: +- 核心爽点:${state.thrill} +- 一句世界:${state.world} +- 结构:${state.structure} + +JSON schema: +{ + "titles": ["标题1", "标题2", "..."], + "payoff_title": "其中作为本段兑现的那条标题(通常是最后一条)", + "reply": "给用户看的一句说明" +} + +规则: +- titles 6~12 条为宜,每条是短标题,不是正文。 +- 允许跳过最低境界等前史,直接切到兑现相关桥段。 +- payoff_title 必须是 titles 中的一项。`; +} + +export function sectionSystemPrompt(state: ListenSerialState, nextTitle: string): string { + const summaries = state.summaries.slice(-MAX_SUMMARIES_IN_CONTEXT); + return `${ROLE} + +当前阶段:写下一节正文,并附小总结。 + +已钉死: +- 核心爽点:${state.thrill} +- 一句世界:${state.world} +- 结构:${state.structure} + +近期小总结(仅此记忆,勿要求全文一致): +${summaries.length ? summaries.map((s, i) => `${i + 1}. ${s}`).join("\n") : "(尚无)"} + +本节大纲标题:${nextTitle} +本节是否兑现节点:${ + state.outline.find((o) => o.title === nextTitle && o.status === "pending") + ?.payoff + ? "是——写到爽点落地" + : "否——推进并留下钩子" + } + +JSON schema: +{ + "text": "本节正文(约 600~1200 字,中文)", + "summary": "本节小总结(一两句,供后文上下文)", + "fulfilled": true/false +} + +规则: +- fulfilled=true 仅当本节已兑现核心爽点(通常是兑现标题那节)。 +- 文笔廉价直白即可;不要长篇内心独白。`; +} + +export function redirectSystemPrompt( + state: ListenSerialState, + opinion: string, +): string { + const pending = state.outline + .filter((o) => o.status === "pending") + .map((o) => o.title); + const done = state.outline + .filter((o) => o.status === "done") + .map((o) => o.title); + return `${ROLE} + +当前阶段:用户插嘴改向。只重排「尚未消费」的后续大纲;已听过的情节可不动,允许前后矛盾。 + +已钉死爽点(尽量仍朝它收,除非用户意见明确改爽点):${state.thrill} +一句世界:${state.world} +用户意见:${opinion} + +已完成标题:${done.length ? done.join(" / ") : "(无)"} +原未写标题(将被替换):${pending.length ? pending.join(" / ") : "(无)"} + +JSON schema: +{ + "titles": ["新的后续标题1", "..."], + "payoff_title": "新的兑现标题", + "thrill_override": "若用户改了爽点则给新爽点,否则空字符串", + "reply": "给用户看的短确认" +} + +规则: +- titles 规划到新的兑现为止;短、直接、服务用户意见。 +- 不要复述已完成标题。`; +} + +export function nextWaveSystemPrompt(state: ListenSerialState): string { + const recent = state.summaries.slice(-MAX_SUMMARIES_IN_CONTEXT); + return `${ROLE} + +上一波爽点已兑现,用户要「下一波」。在同一句世界下开新的兑现段大纲。 + +核心爽点(可沿用或小幅升级):${state.thrill} +一句世界:${state.world} +结构:${state.structure} +近期小总结: +${recent.length ? recent.map((s, i) => `${i + 1}. ${s}`).join("\n") : "(无)"} + +JSON schema: +{ + "titles": ["标题1", "..."], + "payoff_title": "本波兑现标题", + "thrill": "本波核心爽点(可与上波相同或升级)", + "reply": "给用户看的一句说明" +}`; +} diff --git a/src/listen-serial/state.ts b/src/listen-serial/state.ts new file mode 100644 index 0000000..850ce40 --- /dev/null +++ b/src/listen-serial/state.ts @@ -0,0 +1,251 @@ +import { randomUUID } from "node:crypto"; +import type { Blackboard } from "../blackboard/blackboard.js"; +import { + DEFAULT_BUFFER_TARGET_CHARS, + LISTEN_BUFFER_TARGET_TAG, + LISTEN_CONSUMED_CHARS_TAG, + LISTEN_OUTLINE_TAG, + LISTEN_PHASE_TAG, + LISTEN_SECTIONS_TAG, + LISTEN_STRUCTURE_TAG, + LISTEN_SUMMARIES_TAG, + LISTEN_THRILL_TAG, + LISTEN_WORLD_TAG, + MAX_SUMMARIES_IN_CONTEXT, + type ListenPhase, + type ListenSerialState, + type ListenSerialView, + type OutlineItem, + type SectionItem, +} from "./types.js"; + +function parseJson(raw: string | undefined, fallback: T): T { + if (!raw?.trim()) return fallback; + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +function parsePhase(raw: string | undefined): ListenPhase { + const v = (raw ?? "").trim(); + if ( + v === "intake" || + v === "awaiting_confirm" || + v === "running" || + v === "soft_stop" + ) { + return v; + } + return "intake"; +} + +export function readListenState(board: Blackboard): ListenSerialState { + const bufferRaw = board.getContentByTag(LISTEN_BUFFER_TARGET_TAG); + const bufferTargetChars = Math.max( + 500, + Number.parseInt(bufferRaw ?? "", 10) || DEFAULT_BUFFER_TARGET_CHARS, + ); + const consumedRaw = board.getContentByTag(LISTEN_CONSUMED_CHARS_TAG); + const consumedChars = Math.max(0, Number.parseInt(consumedRaw ?? "", 10) || 0); + return { + phase: parsePhase(board.getContentByTag(LISTEN_PHASE_TAG)), + thrill: (board.getContentByTag(LISTEN_THRILL_TAG) ?? "").trim(), + world: (board.getContentByTag(LISTEN_WORLD_TAG) ?? "").trim(), + structure: (board.getContentByTag(LISTEN_STRUCTURE_TAG) ?? "").trim(), + outline: parseJson(board.getContentByTag(LISTEN_OUTLINE_TAG), []), + sections: parseJson( + board.getContentByTag(LISTEN_SECTIONS_TAG), + [], + ), + summaries: parseJson( + board.getContentByTag(LISTEN_SUMMARIES_TAG), + [], + ), + bufferTargetChars, + consumedChars, + }; +} + +function writeTag(board: Blackboard, tag: string, content: string): void { + board.write({ tag, content, source: "agent" }); +} + +export function writeListenPhase(board: Blackboard, phase: ListenPhase): void { + writeTag(board, LISTEN_PHASE_TAG, phase); +} + +export function writeListenBrief( + board: Blackboard, + brief: { thrill: string; world: string; structure: string }, +): void { + writeTag(board, LISTEN_THRILL_TAG, brief.thrill.trim()); + writeTag(board, LISTEN_WORLD_TAG, brief.world.trim()); + writeTag(board, LISTEN_STRUCTURE_TAG, brief.structure.trim()); +} + +export function writeListenOutline(board: Blackboard, outline: OutlineItem[]): void { + writeTag(board, LISTEN_OUTLINE_TAG, JSON.stringify(outline)); +} + +export function writeListenSections(board: Blackboard, sections: SectionItem[]): void { + writeTag(board, LISTEN_SECTIONS_TAG, JSON.stringify(sections)); +} + +export function writeListenSummaries(board: Blackboard, summaries: string[]): void { + const trimmed = summaries + .map((s) => s.trim()) + .filter(Boolean) + .slice(-MAX_SUMMARIES_IN_CONTEXT); + writeTag(board, LISTEN_SUMMARIES_TAG, JSON.stringify(trimmed)); +} + +export function writeConsumedChars(board: Blackboard, n: number): void { + writeTag(board, LISTEN_CONSUMED_CHARS_TAG, String(Math.max(0, Math.floor(n)))); +} + +export function ensureBufferTarget(board: Blackboard): number { + const existing = board.getContentByTag(LISTEN_BUFFER_TARGET_TAG); + if (!existing?.trim()) { + writeTag(board, LISTEN_BUFFER_TARGET_TAG, String(DEFAULT_BUFFER_TARGET_CHARS)); + return DEFAULT_BUFFER_TARGET_CHARS; + } + return Math.max( + 500, + Number.parseInt(existing, 10) || DEFAULT_BUFFER_TARGET_CHARS, + ); +} + +export function unreadChars(state: ListenSerialState): number { + const total = state.sections.reduce((n, s) => n + s.text.length, 0); + return Math.max(0, total - state.consumedChars); +} + +export function toListenView(state: ListenSerialState): ListenSerialView { + return { + phase: state.phase, + thrill: state.thrill, + world: state.world, + structure: state.structure, + outline: state.outline.map((o) => ({ + id: o.id, + title: o.title, + status: o.status, + payoff: o.payoff, + })), + sections: state.sections.map((s) => ({ + id: s.id, + outlineId: s.outlineId, + title: s.title, + text: s.text, + summary: s.summary, + consumedChars: s.consumedChars, + })), + unreadChars: unreadChars(state), + bufferTargetChars: state.bufferTargetChars, + consumedChars: state.consumedChars, + }; +} + +/** 未写完的大纲项 */ +export function nextPendingOutline(state: ListenSerialState): OutlineItem | undefined { + return state.outline.find((o) => o.status === "pending"); +} + +export function makeOutlineItems( + titles: string[], + payoffTitle?: string, +): OutlineItem[] { + const payoff = (payoffTitle ?? titles[titles.length - 1] ?? "").trim(); + return titles + .map((t) => t.trim()) + .filter(Boolean) + .map((title) => ({ + id: randomUUID(), + title, + status: "pending" as const, + payoff: payoff ? title === payoff : false, + })); +} + +/** 插嘴:丢掉未消费正文尾部,并把对应大纲改回 pending / 用新标题替换未完成段 */ +export function applyRedirectCut( + state: ListenSerialState, + newPendingTitles: string[], +): Pick { + let keptChars = 0; + const keptSections: SectionItem[] = []; + for (const sec of state.sections) { + const remaining = sec.text.length - sec.consumedChars; + if (sec.consumedChars <= 0 && remaining === sec.text.length) { + // 整节未消费:丢弃 + continue; + } + if (sec.consumedChars >= sec.text.length) { + keptSections.push(sec); + keptChars += sec.text.length; + continue; + } + // 部分消费:截断到已消费 + const cutText = sec.text.slice(0, sec.consumedChars); + keptSections.push({ + ...sec, + text: cutText, + summary: sec.summary, + consumedChars: cutText.length, + }); + keptChars += cutText.length; + } + + const doneOutlineIds = new Set( + keptSections.map((s) => s.outlineId).filter(Boolean), + ); + const keptOutline = state.outline + .filter((o) => o.status === "done" && doneOutlineIds.has(o.id)) + .map((o) => ({ ...o, status: "done" as const })); + + const newItems = makeOutlineItems(newPendingTitles); + // 最后一节标为兑现 + if (newItems.length) { + for (const item of newItems) item.payoff = false; + newItems[newItems.length - 1]!.payoff = true; + } + + const summaries = keptSections + .map((s) => s.summary) + .filter(Boolean) + .slice(-MAX_SUMMARIES_IN_CONTEXT); + + return { + outline: [...keptOutline, ...newItems], + sections: keptSections, + summaries, + consumedChars: keptChars, + }; +} + +export function markOutlineDone( + outline: OutlineItem[], + outlineId: string, +): OutlineItem[] { + return outline.map((o) => + o.id === outlineId ? { ...o, status: "done" as const } : o, + ); +} + +export function isConfirmText(text: string): boolean { + const t = text.trim(); + if (!t) return false; + return /^(确认|就这样|开始|可以|好的|没问题|开写|写吧)([!!。.\s]*)$/u.test(t); +} + +export function isNextWaveText(text: string): boolean { + const t = text.trim(); + return /下一波|再来一波|继续下一|开下一波/u.test(t); +} + +export function isEndText(text: string): boolean { + const t = text.trim(); + return /^(收工|结束|完结|停)([!!。.\s]*)$/u.test(t); +} diff --git a/src/listen-serial/turn.ts b/src/listen-serial/turn.ts new file mode 100644 index 0000000..92a03b8 --- /dev/null +++ b/src/listen-serial/turn.ts @@ -0,0 +1,343 @@ +import { randomUUID } from "node:crypto"; +import type { Blackboard } from "../blackboard/blackboard.js"; +import type { LlmProvider } from "../llm/client.js"; +import { + llmIntake, + llmNextWave, + llmOutline, + llmRedirect, + llmSection, +} from "./generate.js"; +import { + applyRedirectCut, + ensureBufferTarget, + isConfirmText, + isEndText, + isNextWaveText, + makeOutlineItems, + markOutlineDone, + nextPendingOutline, + readListenState, + unreadChars, + writeConsumedChars, + writeListenBrief, + writeListenOutline, + writeListenPhase, + writeListenSections, + writeListenSummaries, +} from "./state.js"; +import { MAX_SECTIONS_PER_BURST, type ListenSerialState } from "./types.js"; + +export type ListenChatTurn = { role: "user" | "assistant"; text: string }; + +export type ListenSerialTurnResult = { + reply: string; + /** 本轮是否进入/保持软停 */ + softStop?: boolean; +}; + +function persistState( + board: Blackboard, + state: ListenSerialState, + patch: Partial, +): ListenSerialState { + const next: ListenSerialState = { ...state, ...patch }; + if (patch.phase) writeListenPhase(board, patch.phase); + if (patch.thrill != null || patch.world != null || patch.structure != null) { + writeListenBrief(board, { + thrill: next.thrill, + world: next.world, + structure: next.structure, + }); + } + if (patch.outline) writeListenOutline(board, next.outline); + if (patch.sections) writeListenSections(board, next.sections); + if (patch.summaries) writeListenSummaries(board, next.summaries); + if (patch.consumedChars != null) writeConsumedChars(board, next.consumedChars); + return next; +} + +async function fillBuffer( + llm: LlmProvider, + board: Blackboard, + state: ListenSerialState, +): Promise<{ state: ListenSerialState; notes: string[]; softStop: boolean }> { + let cur = state; + const notes: string[] = []; + let softStop = false; + let wrote = 0; + + while ( + unreadChars(cur) < cur.bufferTargetChars && + wrote < MAX_SECTIONS_PER_BURST + ) { + const pending = nextPendingOutline(cur); + if (!pending) { + softStop = true; + break; + } + const section = await llmSection(llm, cur, pending.title); + if (!section.text) { + notes.push(`「${pending.title}」正文为空,已跳过。`); + cur = persistState(board, cur, { + outline: markOutlineDone(cur.outline, pending.id), + }); + wrote += 1; + continue; + } + const item = { + id: randomUUID(), + outlineId: pending.id, + title: pending.title, + text: section.text, + summary: section.summary || pending.title, + consumedChars: 0, + }; + const summaries = [...cur.summaries, item.summary]; + let outline = markOutlineDone(cur.outline, pending.id); + cur = persistState(board, cur, { + outline, + sections: [...cur.sections, item], + summaries, + phase: "running", + }); + notes.push(`已写:${pending.title}(${section.text.length} 字)`); + wrote += 1; + + if (pending.payoff || section.fulfilled) { + // 兑现后把剩余 pending 标 cut,进入软停(缓冲里已有兑现节可听完) + outline = cur.outline.map((o) => + o.status === "pending" ? { ...o, status: "cut" as const } : o, + ); + cur = persistState(board, cur, { outline, phase: "soft_stop" }); + softStop = true; + notes.push("本段爽点已兑现,听完缓冲后可「下一波」或「收工」。"); + break; + } + } + + if (!softStop && !nextPendingOutline(cur) && unreadChars(cur) > 0) { + cur = persistState(board, cur, { phase: "soft_stop" }); + softStop = true; + } + + return { state: cur, notes, softStop }; +} + +function formatUnreadLine(state: ListenSerialState): string { + const n = unreadChars(state); + return `目前约有 ${n} 字未读/未听(目标缓冲 ${state.bufferTargetChars} 字)。`; +} + +function formatOutlineLine(state: ListenSerialState): string { + if (!state.outline.length) return ""; + return ( + "大纲:\n" + + state.outline + .map((o) => { + const mark = + o.status === "done" ? "✓" : o.status === "cut" ? "×" : "·"; + const pay = o.payoff ? "(兑现)" : ""; + return `${mark} ${o.title}${pay}`; + }) + .join("\n") + ); +} + +/** + * 听书连载单轮:开书 / 确认 / 补货 / 插嘴 / 软停续波。 + */ +export async function runListenSerialTurn(params: { + llm: LlmProvider; + board: Blackboard; + dialogue: ListenChatTurn[]; + userText: string; +}): Promise { + const { llm, board, dialogue, userText } = params; + ensureBufferTarget(board); + let state = readListenState(board); + const text = userText.trim(); + + // —— 软停:收工 / 下一波 —— + if (state.phase === "soft_stop") { + if (isEndText(text)) { + return { + reply: "本局收工。想再开可以新建作品,或再说「下一波」。", + softStop: true, + }; + } + if (isNextWaveText(text) || /继续|再来/u.test(text)) { + const wave = await llmNextWave(llm, state); + if (wave.thrill) { + state = persistState(board, state, { thrill: wave.thrill }); + } + const outline = makeOutlineItems(wave.titles, wave.payoffTitle); + state = persistState(board, state, { + outline, + phase: "running", + }); + const filled = await fillBuffer(llm, board, state); + state = filled.state; + const parts = [ + wave.reply, + formatOutlineLine(state), + filled.notes.join("\n"), + formatUnreadLine(state), + ].filter(Boolean); + if (filled.softStop) { + parts.push("本波已兑现。可输入「下一波」或「收工」。"); + } + return { reply: parts.join("\n\n"), softStop: filled.softStop }; + } + // 软停时其他话当作插嘴改向再开波 + } + + // —— 等待确认 —— + if (state.phase === "awaiting_confirm") { + if (!isConfirmText(text) && text && !/确认|开始|开写/u.test(text)) { + // 用户继续改需求 → 回到 intake 再提案 + const intake = await llmIntake(llm, dialogue); + if (intake.type === "ask") { + state = persistState(board, state, { phase: "intake" }); + return { reply: intake.reply }; + } + state = persistState(board, state, { + phase: "awaiting_confirm", + thrill: intake.thrill, + world: intake.world, + structure: intake.structure, + }); + return { + 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; + return { + reply: [ + outlined.reply, + formatOutlineLine(state), + filled.notes.join("\n"), + formatUnreadLine(state), + filled.softStop + ? "本段已兑现。可「下一波」或「收工」。" + : "不满意某桥段就直接说一句意见,我会改后面的走向。", + ] + .filter(Boolean) + .join("\n\n"), + softStop: filled.softStop, + }; + } + + // —— 连载中:空输入/继续 = 补货;有意见 = 改向 —— + if (state.phase === "running" || state.phase === "soft_stop") { + const isContinue = + !text || + /^(继续|接着写|补货|下一节)([!!。.\s]*)$/u.test(text); + + if (!isContinue && text) { + const redirect = await llmRedirect(llm, state, text); + const cut = applyRedirectCut(state, redirect.titles); + // 重新标记 payoff + const outline = cut.outline.map((o, i, arr) => ({ + ...o, + payoff: + o.status === "pending" && + (o.title === redirect.payoffTitle || i === arr.length - 1), + })); + // 只让最后一个 pending 为 payoff + let lastPending = -1; + outline.forEach((o, i) => { + if (o.status === "pending") lastPending = i; + if (o.status === "pending") o.payoff = false; + }); + if (lastPending >= 0) outline[lastPending]!.payoff = true; + + state = persistState(board, state, { + ...cut, + outline, + phase: "running", + ...(redirect.thrillOverride + ? { thrill: redirect.thrillOverride } + : {}), + }); + const filled = await fillBuffer(llm, board, state); + state = filled.state; + return { + reply: [ + redirect.reply, + formatOutlineLine(state), + filled.notes.join("\n"), + formatUnreadLine(state), + ] + .filter(Boolean) + .join("\n\n"), + softStop: filled.softStop, + }; + } + + const filled = await fillBuffer(llm, board, state); + state = filled.state; + if (!filled.notes.length && unreadChars(state) >= state.bufferTargetChars) { + return { + reply: `${formatUnreadLine(state)}\n缓冲仍充足。听/读一些后再说「继续」,或直接一句意见改向。`, + softStop: state.phase === "soft_stop", + }; + } + return { + reply: [ + filled.notes.join("\n") || "已尝试补货。", + formatOutlineLine(state), + formatUnreadLine(state), + filled.softStop ? "本段已兑现。可「下一波」或「收工」。" : "", + ] + .filter(Boolean) + .join("\n\n"), + softStop: filled.softStop, + }; + } + + // —— intake —— + const intake = await llmIntake(llm, dialogue); + if (intake.type === "ask") { + state = persistState(board, state, { phase: "intake" }); + return { reply: intake.reply }; + } + if (!intake.thrill || !intake.world) { + return { + reply: intake.reply || "还需要核心爽点和一句世界,请再补一句。", + }; + } + state = persistState(board, state, { + phase: "awaiting_confirm", + thrill: intake.thrill, + world: intake.world, + structure: intake.structure || "三段式:铺垫冲突 → 对抗升级 → 爽点兑现", + }); + return { + reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${state.structure}\n\n回复「确认」开始生成大纲与正文。`, + }; +} + +/** 前端标记已读:推进消费指针 */ +export function markListenConsumed( + board: Blackboard, + absoluteConsumedChars: number, +): ListenSerialState { + const state = readListenState(board); + const total = state.sections.reduce((n, s) => n + s.text.length, 0); + const next = Math.max(0, Math.min(total, Math.floor(absoluteConsumedChars))); + let remaining = next; + const sections = state.sections.map((sec) => { + const take = Math.min(sec.text.length, Math.max(0, remaining)); + remaining -= take; + return { ...sec, consumedChars: take }; + }); + writeListenSections(board, sections); + writeConsumedChars(board, next); + return { ...state, sections, consumedChars: next }; +} diff --git a/src/listen-serial/types.ts b/src/listen-serial/types.ts new file mode 100644 index 0000000..87b6db9 --- /dev/null +++ b/src/listen-serial/types.ts @@ -0,0 +1,81 @@ +/** 听书连载 · 类型与常量 */ + +export const LISTEN_SERIAL_MODE_VALUE = "listen_serial"; + +export const LISTEN_PHASE_TAG = "听书.阶段"; +export const LISTEN_THRILL_TAG = "听书.核心爽点"; +export const LISTEN_WORLD_TAG = "听书.一句世界"; +export const LISTEN_STRUCTURE_TAG = "听书.结构"; +export const LISTEN_OUTLINE_TAG = "听书.大纲"; +export const LISTEN_SECTIONS_TAG = "听书.正文节"; +export const LISTEN_SUMMARIES_TAG = "听书.小总结"; +export const LISTEN_BUFFER_TARGET_TAG = "听书.缓冲目标字数"; +export const LISTEN_CONSUMED_CHARS_TAG = "听书.已消费字数"; + +/** 默认未读/未听缓冲目标(字)——实现默认值,可后续调 */ +export const DEFAULT_BUFFER_TARGET_CHARS = 4000; +/** 单次用户触发最多连写几节,避免一次请求过长 */ +export const MAX_SECTIONS_PER_BURST = 3; +/** 进模型的滚动小总结条数上限 */ +export const MAX_SUMMARIES_IN_CONTEXT = 8; + +export type ListenPhase = + | "intake" + | "awaiting_confirm" + | "running" + | "soft_stop"; + +export type OutlineItem = { + id: string; + title: string; + /** pending=未写;done=已有正文;cut=被插嘴砍掉 */ + status: "pending" | "done" | "cut"; + /** 本段爽点兑现节点 */ + payoff?: boolean; +}; + +export type SectionItem = { + id: string; + outlineId: string; + title: string; + text: string; + summary: string; + /** 用户已听/读过的字数(相对本节);改向时整节未消费可丢 */ + consumedChars: number; +}; + +export type ListenSerialState = { + phase: ListenPhase; + thrill: string; + world: string; + structure: string; + outline: OutlineItem[]; + sections: SectionItem[]; + summaries: string[]; + bufferTargetChars: number; + /** 全局已消费正文字数(用于 UI「已听」) */ + consumedChars: number; +}; + +export type ListenSerialView = { + phase: ListenPhase; + thrill: string; + world: string; + structure: string; + outline: Array<{ id: string; title: string; status: string; payoff?: boolean }>; + sections: Array<{ + id: string; + outlineId: string; + title: string; + text: string; + summary: string; + consumedChars: number; + }>; + unreadChars: number; + bufferTargetChars: number; + consumedChars: number; +}; + +export function isListenSerialModeValue(raw: string | undefined | null): boolean { + return (raw ?? "").trim() === LISTEN_SERIAL_MODE_VALUE; +} diff --git a/tests/listen-serial.test.ts b/tests/listen-serial.test.ts new file mode 100644 index 0000000..d201ccf --- /dev/null +++ b/tests/listen-serial.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { + applyRedirectCut, + isConfirmText, + isListenSerialModeValue, + LISTEN_SERIAL_MODE_VALUE, + makeOutlineItems, + markListenConsumed, + readListenState, + toListenView, + unreadChars, + writeListenBrief, + writeListenOutline, + writeListenPhase, + writeListenSections, +} from "../src/listen-serial/index.js"; +import { ensureBufferTarget } from "../src/listen-serial/state.js"; + +describe("listen-serial state", () => { + it("recognizes mode value", () => { + expect(isListenSerialModeValue(LISTEN_SERIAL_MODE_VALUE)).toBe(true); + expect(isListenSerialModeValue("dictate")).toBe(false); + }); + + it("round-trips brief/outline/sections on blackboard", () => { + const board = new Blackboard(); + ensureBufferTarget(board); + writeListenPhase(board, "running"); + writeListenBrief(board, { + thrill: "身份反差打脸", + world: "现代都市;主角是隐退龙王", + structure: "三段式", + }); + const outline = makeOutlineItems(["被嘲", "亮身份", "全员后悔"], "全员后悔"); + writeListenOutline(board, outline); + writeListenSections(board, [ + { + id: "s1", + outlineId: outline[0]!.id, + title: "被嘲", + text: "甲".repeat(100), + summary: "被嘲讽", + consumedChars: 0, + }, + ]); + const state = readListenState(board); + expect(state.phase).toBe("running"); + expect(state.thrill).toContain("身份反差"); + expect(state.outline).toHaveLength(3); + expect(state.outline[2]?.payoff).toBe(true); + expect(unreadChars(state)).toBe(100); + const view = toListenView(state); + expect(view.unreadChars).toBe(100); + }); + + it("applyRedirectCut drops unconsumed sections and keeps consumed", () => { + const outline = makeOutlineItems(["A", "B", "C"], "C"); + outline[0]!.status = "done"; + const state = { + phase: "running" as const, + thrill: "打脸", + world: "都市", + structure: "三段式", + outline, + sections: [ + { + id: "s1", + outlineId: outline[0]!.id, + title: "A", + text: "已读完的一节", + summary: "A小结", + consumedChars: 6, + }, + { + id: "s2", + outlineId: "pending-x", + title: "B", + text: "整节未读应丢掉", + summary: "B小结", + consumedChars: 0, + }, + ], + summaries: ["A小结", "B小结"], + bufferTargetChars: 4000, + consumedChars: 6, + }; + const cut = applyRedirectCut(state, ["主角反杀", "对面后悔"]); + expect(cut.sections).toHaveLength(1); + expect(cut.sections[0]?.title).toBe("A"); + expect(cut.outline.filter((o) => o.status === "pending")).toHaveLength(2); + expect(cut.outline.some((o) => o.title === "主角反杀")).toBe(true); + }); + + it("markListenConsumed advances pointer", () => { + const board = new Blackboard(); + writeListenSections(board, [ + { + id: "s1", + outlineId: "o1", + title: "一", + text: "一二三四五", + summary: "一", + consumedChars: 0, + }, + ]); + const next = markListenConsumed(board, 3); + expect(next.consumedChars).toBe(3); + expect(next.sections[0]?.consumedChars).toBe(3); + }); + + it("detects confirm phrases", () => { + expect(isConfirmText("确认")).toBe(true); + expect(isConfirmText("开始!")).toBe(true); + expect(isConfirmText("主角反过来打脸")).toBe(false); + }); +});