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 0000000..afd0755 Binary files /dev/null and b/docs/briefs/listen-serial-path-2026-10-07.docx differ diff --git a/src/listen-serial/generate.ts b/src/listen-serial/generate.ts new file mode 100644 index 0000000..8ef6241 --- /dev/null +++ b/src/listen-serial/generate.ts @@ -0,0 +1,173 @@ +import type { ChatMessage, LlmProvider } from "../llm/client.js"; +import { tryParseJsonDoc } from "../parse/json-doc.js"; +import { + intakeSystemPrompt, + nextWaveSystemPrompt, + outlineSystemPrompt, + redirectSystemPrompt, + sectionSystemPrompt, +} from "./prompt.js"; +import type { ListenSerialState } from "./types.js"; + +export type IntakeLlmResult = + | { type: "ask"; reply: string } + | { + type: "propose"; + reply: string; + thrill: string; + world: string; + structure: string; + }; + +export type OutlineLlmResult = { + titles: string[]; + payoffTitle: string; + reply: string; + thrill?: string; +}; + +export type SectionLlmResult = { + text: string; + summary: string; + fulfilled: boolean; +}; + +export type RedirectLlmResult = { + titles: string[]; + payoffTitle: string; + thrillOverride: string; + reply: string; +}; + +async function completeJson( + llm: LlmProvider, + system: string, + user: string, + caller: string, +): Promise> { + 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); + }); +});