新增听书连载进料,按消费进度连续吐出正文缓冲。

与对话落盘并列,适合边听边往后生成的路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 23:49:25 +08:00
co-authored by Cursor
parent 820397e578
commit ff5c77bfb1
8 changed files with 1145 additions and 0 deletions
Binary file not shown.
+173
View File
@@ -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<Record<string, unknown>> {
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<string, unknown>;
}
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<IntakeLlmResult> {
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<OutlineLlmResult> {
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<OutlineLlmResult> {
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<SectionLlmResult> {
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<RedirectLlmResult> {
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) || "已按你的意见改后面的走向。",
};
}
+41
View File
@@ -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";
+139
View File
@@ -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": "给用户看的一句说明"
}`;
}
+251
View File
@@ -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<T>(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<OutlineItem[]>(board.getContentByTag(LISTEN_OUTLINE_TAG), []),
sections: parseJson<SectionItem[]>(
board.getContentByTag(LISTEN_SECTIONS_TAG),
[],
),
summaries: parseJson<string[]>(
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<ListenSerialState, "outline" | "sections" | "summaries" | "consumedChars"> {
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);
}
+343
View File
@@ -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>,
): 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<ListenSerialTurnResult> {
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 };
}
+81
View File
@@ -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;
}
+117
View File
@@ -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);
});
});