新增听书连载进料,按消费进度连续吐出正文缓冲。
与对话落盘并列,适合边听边往后生成的路径。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Binary file not shown.
@@ -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) || "已按你的意见改后面的走向。",
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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": "给用户看的一句说明"
|
||||
}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user