创作降本:取消同轮retry、两轮history;听书创作/播放分层与前端配套。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+13
-2
@@ -1,4 +1,8 @@
|
||||
import type { ChatMessage } from "../llm/client.js";
|
||||
import {
|
||||
clipDictateDialogue,
|
||||
resolveDictateHistoryTurns,
|
||||
} from "./history-window.js";
|
||||
import {
|
||||
effectiveDictateOrder,
|
||||
sortDictateProducts,
|
||||
@@ -25,7 +29,7 @@ export type DictateBuiltContext = {
|
||||
/**
|
||||
* 创作请求的块顺序:固定规则、更早的对话、全部产物、本轮注入、末尾。
|
||||
* 选题不带产物正文和本轮注入。回复步只带本轮刚落盘产物与末句用户话。
|
||||
* 更早对话只追加,最后一轮用户输入放在末尾。
|
||||
* 更早对话按 historyTurns 裁剪(默认 2 轮),最后一轮用户输入放在末尾。
|
||||
*/
|
||||
export function buildDictateContext(params: {
|
||||
systemPrompt: string;
|
||||
@@ -34,12 +38,18 @@ export function buildDictateContext(params: {
|
||||
phase?: DictateContextPhase;
|
||||
injected?: string;
|
||||
tail?: string;
|
||||
/** 更早对话保留轮数;默认见 resolveDictateHistoryTurns */
|
||||
historyTurns?: number;
|
||||
}): DictateBuiltContext {
|
||||
const phase = params.phase ?? "fill";
|
||||
if (phase === "reply") {
|
||||
return buildReplyContext(params);
|
||||
}
|
||||
const { earlier, lastUser } = splitEarlierDialogue(params.dialogue);
|
||||
const clipped = clipDictateDialogue(
|
||||
params.dialogue,
|
||||
resolveDictateHistoryTurns(params.historyTurns),
|
||||
);
|
||||
const { earlier, lastUser } = splitEarlierDialogue(clipped);
|
||||
const history = formatEarlierDialogue(earlier);
|
||||
const products =
|
||||
phase === "fill" ? formatProducts(sortDictateProducts(params.products)) : "";
|
||||
@@ -138,6 +148,7 @@ export function buildDictateMessages(params: {
|
||||
phase?: DictateContextPhase;
|
||||
injected?: string;
|
||||
tail?: string;
|
||||
historyTurns?: number;
|
||||
}): ChatMessage[] {
|
||||
return buildDictateContext(params).messages;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { DictateChatTurn } from "./types.js";
|
||||
|
||||
/**
|
||||
* 创作上下文保留的更早对话轮数。
|
||||
* 1 轮 = 一次用户输入 + 一次助手输出(不含本轮正在处理的末句用户话)。
|
||||
* 默认 2;0 = 不保留更早对话;负数 = 不限制。
|
||||
* 可用环境变量 `DICTATE_HISTORY_TURNS` 或调用参数覆盖。
|
||||
*/
|
||||
export const DEFAULT_DICTATE_HISTORY_TURNS = 2;
|
||||
|
||||
export function resolveDictateHistoryTurns(override?: number): number {
|
||||
if (typeof override === "number" && Number.isFinite(override)) {
|
||||
return Math.trunc(override);
|
||||
}
|
||||
const raw = process.env.DICTATE_HISTORY_TURNS?.trim();
|
||||
if (raw) {
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n)) return Math.trunc(n);
|
||||
}
|
||||
return DEFAULT_DICTATE_HISTORY_TURNS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁剪进模型的对话:保留末句用户输入 + 最近 `historyTurns` 轮更早问答。
|
||||
* `historyTurns < 0` 时原样返回。
|
||||
*/
|
||||
export function clipDictateDialogue(
|
||||
dialogue: DictateChatTurn[],
|
||||
historyTurns: number,
|
||||
): DictateChatTurn[] {
|
||||
if (historyTurns < 0) return dialogue;
|
||||
const last = dialogue.at(-1);
|
||||
const hasCurrentUser = last?.role === "user";
|
||||
const earlier = hasCurrentUser ? dialogue.slice(0, -1) : [...dialogue];
|
||||
const current = hasCurrentUser && last ? [last] : [];
|
||||
if (historyTurns === 0) return current;
|
||||
|
||||
let userCount = 0;
|
||||
let start = 0;
|
||||
for (let i = earlier.length - 1; i >= 0; i -= 1) {
|
||||
if (earlier[i]?.role === "user") {
|
||||
userCount += 1;
|
||||
if (userCount === historyTurns) {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...earlier.slice(start), ...current];
|
||||
}
|
||||
@@ -10,6 +10,20 @@ export {
|
||||
buildDictateContext,
|
||||
extractDictateDialogue,
|
||||
} from "./context.js";
|
||||
export {
|
||||
DEFAULT_DICTATE_HISTORY_TURNS,
|
||||
clipDictateDialogue,
|
||||
resolveDictateHistoryTurns,
|
||||
} from "./history-window.js";
|
||||
export {
|
||||
DICTATE_REMEDIATE_TAG,
|
||||
DICTATE_REMEDIATE_SCHEMA,
|
||||
parseDictateRemediate,
|
||||
serializeDictateRemediate,
|
||||
formatRemediateChecklist,
|
||||
type DictateRemediateDoc,
|
||||
type DictateRemediateItem,
|
||||
} from "./remediate.js";
|
||||
export {
|
||||
CREATION_INTAKE_MODE_TAG,
|
||||
DICTATE_MODE_VALUE,
|
||||
|
||||
@@ -26,6 +26,7 @@ export function buildDictateSelectPrompt(): string {
|
||||
return `你只做选题。对照能力目录和用户这句话,调用 select_nodes,选出已经够写的节点。
|
||||
|
||||
nodes 里的 module_id 必须来自目录。通常选这段话已经说清的 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。
|
||||
若末尾 checklist 标了「失败待补」,这些节点必须再次纳入 nodes(可与新选的一起)。
|
||||
用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或实现机制。
|
||||
这一步不写产物正文,不回复用户。`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 同轮不再 fill-retry:失败项写入「待补」状态,下轮 select/fill 继续补。
|
||||
* 存 readTag/writeTag,不进产物列表,避免污染黑板正文。
|
||||
*/
|
||||
|
||||
export const DICTATE_REMEDIATE_TAG = "设计.对话落盘待补";
|
||||
export const DICTATE_REMEDIATE_SCHEMA = "dictate-remediate.v1" as const;
|
||||
|
||||
export type DictateRemediateKind = "insert" | "delete";
|
||||
|
||||
export type DictateRemediateItem = {
|
||||
tag: string;
|
||||
moduleId?: string;
|
||||
error: string;
|
||||
kind: DictateRemediateKind;
|
||||
};
|
||||
|
||||
export type DictateRemediateDoc = {
|
||||
schema: typeof DICTATE_REMEDIATE_SCHEMA;
|
||||
items: DictateRemediateItem[];
|
||||
};
|
||||
|
||||
export function parseDictateRemediate(raw: string | undefined | null): DictateRemediateDoc {
|
||||
if (!raw?.trim()) return { schema: DICTATE_REMEDIATE_SCHEMA, items: [] };
|
||||
try {
|
||||
const row = JSON.parse(raw) as Record<string, unknown>;
|
||||
const items = Array.isArray(row.items) ? row.items : [];
|
||||
const parsed: DictateRemediateItem[] = [];
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const rec = item as Record<string, unknown>;
|
||||
const tag = String(rec.tag ?? "").trim();
|
||||
if (!tag) continue;
|
||||
const kind = rec.kind === "delete" ? "delete" : "insert";
|
||||
const moduleId = String(rec.moduleId ?? rec.module_id ?? "").trim();
|
||||
const error = String(rec.error ?? "尚未通过").trim() || "尚未通过";
|
||||
parsed.push({
|
||||
tag,
|
||||
error,
|
||||
kind,
|
||||
...(moduleId ? { moduleId } : {}),
|
||||
});
|
||||
}
|
||||
return { schema: DICTATE_REMEDIATE_SCHEMA, items: dedupeRemediate(parsed) };
|
||||
} catch {
|
||||
return { schema: DICTATE_REMEDIATE_SCHEMA, items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeDictateRemediate(doc: DictateRemediateDoc): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
schema: DICTATE_REMEDIATE_SCHEMA,
|
||||
items: dedupeRemediate(doc.items),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function formatRemediateChecklist(items: readonly DictateRemediateItem[]): string {
|
||||
if (!items.length) return "";
|
||||
return [
|
||||
"失败待补(须继续写入,勿忽略):",
|
||||
...items.map((item) => {
|
||||
const kind = item.kind === "delete" ? "删除" : "写入";
|
||||
const mod = item.moduleId ? ` / ${item.moduleId}` : "";
|
||||
return `- 〔${kind}〕${item.tag}${mod}:${item.error}`;
|
||||
}),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function dedupeRemediate(items: DictateRemediateItem[]): DictateRemediateItem[] {
|
||||
const seen = new Set<string>();
|
||||
const out: DictateRemediateItem[] = [];
|
||||
for (const item of items) {
|
||||
const key = `${item.kind}:${item.tag}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+159
-43
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,
|
||||
* 再在同一条消息前缀上循环 insert。已通过的不再覆盖。
|
||||
* 再单次 fill 写入。已通过的不再覆盖。
|
||||
* 同轮不再 fill-retry:失败项记入「失败待补」,下轮继续。
|
||||
* 没有未通过项时,同一次请求里的 reply_user 才给用户。
|
||||
* 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js";
|
||||
import type { AgentDriver } from "../runtime/driver.js";
|
||||
import type { ChatMessage, LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
|
||||
import type { LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
|
||||
import { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
|
||||
import { buildDictateContext, type DictateSegmentChars } from "./context.js";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
parseCreationPlan,
|
||||
} from "./creation-plan.js";
|
||||
import { isDictateOpeningTag } from "./experience-anchor.js";
|
||||
import { resolveDictateHistoryTurns } from "./history-window.js";
|
||||
import {
|
||||
buildDictateReplyPrompt,
|
||||
buildDictateSelectPrompt,
|
||||
@@ -32,6 +34,14 @@ import {
|
||||
DICTATE_MODULE_READ_STATE_TAG,
|
||||
type DictateModuleReadPayload,
|
||||
} from "./read-module.js";
|
||||
import {
|
||||
DICTATE_REMEDIATE_SCHEMA,
|
||||
DICTATE_REMEDIATE_TAG,
|
||||
formatRemediateChecklist,
|
||||
parseDictateRemediate,
|
||||
serializeDictateRemediate,
|
||||
type DictateRemediateItem,
|
||||
} from "./remediate.js";
|
||||
import { resolveModuleForInsertTag } from "./insert-feedback.js";
|
||||
import type { DictateChatTurn, DictateProduct } from "./types.js";
|
||||
import { isAllowedProductTag } from "./types.js";
|
||||
@@ -55,6 +65,7 @@ export type PlannedRoundParams = {
|
||||
listProducts: () => DictateProduct[];
|
||||
readTag: (tag: string) => string | undefined;
|
||||
writeTag: (tag: string, content: string) => void;
|
||||
deleteTag?: (tag: string) => boolean;
|
||||
prepareOpening?: () => void;
|
||||
/** 本轮刚写入 tag 的能力回复口径(探测/原则/自评关注) */
|
||||
lookupInsertFeedback?: (tag: string) => string | undefined;
|
||||
@@ -68,6 +79,8 @@ export type PlannedRoundParams = {
|
||||
recipeName?: string;
|
||||
recipeBrief?: string;
|
||||
systemPrompt?: string;
|
||||
/** 更早对话保留轮数;默认 2,见 resolveDictateHistoryTurns */
|
||||
historyTurns?: number;
|
||||
};
|
||||
|
||||
type PlanInsert = {
|
||||
@@ -87,10 +100,7 @@ type SelectPlan = {
|
||||
};
|
||||
|
||||
|
||||
type ItemFailure = { tag: string; error: string };
|
||||
|
||||
|
||||
const INSERT_ATTEMPTS = 4;
|
||||
type ItemFailure = { tag: string; error: string; moduleId?: string; kind?: "insert" | "delete" };
|
||||
|
||||
export function createPlannedDictateDriver(params: PlannedRoundParams): AgentDriver {
|
||||
return {
|
||||
@@ -106,15 +116,23 @@ async function runPlannedDictateRound(
|
||||
apply: Apply,
|
||||
): Promise<{ text: string; modelCalls: number }> {
|
||||
const turnId = randomUUID();
|
||||
const historyTurns = resolveDictateHistoryTurns(params.historyTurns);
|
||||
const fillSystem = params.systemPrompt?.trim() || buildDictateSystemPrompt();
|
||||
const remediations = parseDictateRemediate(params.handlers.readTag(DICTATE_REMEDIATE_TAG));
|
||||
const remediateChecklist = formatRemediateChecklist(remediations.items);
|
||||
const tailOpts = tailState(params);
|
||||
const selectTail = formatDictateRoundTail({ ...tailOpts, phase: "select" });
|
||||
const selectTail = formatDictateRoundTail({
|
||||
...tailOpts,
|
||||
phase: "select",
|
||||
checklist: remediateChecklist,
|
||||
});
|
||||
const selectCtx = buildDictateContext({
|
||||
systemPrompt: buildDictateSelectPrompt(),
|
||||
products: params.handlers.listProducts(),
|
||||
dialogue: params.dialogue,
|
||||
phase: "select",
|
||||
tail: selectTail,
|
||||
historyTurns,
|
||||
});
|
||||
const selected = await params.llm.completeWithTools(selectCtx.messages, {
|
||||
caller: "dictate_agent",
|
||||
@@ -127,7 +145,12 @@ async function runPlannedDictateRound(
|
||||
if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim());
|
||||
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0];
|
||||
const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params);
|
||||
if (plan.clarify.length >= 2 && plan.inserts.length === 0 && plan.deletes.length === 0) {
|
||||
mergeRemediationsIntoPlan(plan, remediations.items, params);
|
||||
if (
|
||||
plan.clarify.length >= 2 &&
|
||||
plan.inserts.length === 0 &&
|
||||
plan.deletes.length === 0
|
||||
) {
|
||||
return {
|
||||
modelCalls: 1,
|
||||
text: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n"),
|
||||
@@ -150,10 +173,23 @@ async function runPlannedDictateRound(
|
||||
);
|
||||
if (openings.length > 0) params.handlers.prepareOpening?.();
|
||||
const injected = injectGroup(params, [...rest, ...openings]);
|
||||
const priorFailures = remediations.items.filter((item) =>
|
||||
[...rest, ...openings].some((row) => row.tag === item.tag) ||
|
||||
plan.deletes.includes(item.tag),
|
||||
);
|
||||
const tail = formatDictateRoundTail({
|
||||
...tailOpts,
|
||||
phase: "fill",
|
||||
checklist: formatChecklist([...rest, ...openings], plan.deletes, []),
|
||||
checklist: formatChecklist(
|
||||
[...rest, ...openings],
|
||||
plan.deletes,
|
||||
priorFailures.map((item) => ({
|
||||
tag: item.tag,
|
||||
error: item.error,
|
||||
moduleId: item.moduleId,
|
||||
kind: item.kind,
|
||||
})),
|
||||
),
|
||||
});
|
||||
const ctx = buildDictateContext({
|
||||
systemPrompt: fillSystem,
|
||||
@@ -162,8 +198,8 @@ async function runPlannedDictateRound(
|
||||
phase: "fill",
|
||||
injected: injected.text,
|
||||
tail,
|
||||
historyTurns,
|
||||
});
|
||||
const messages: ChatMessage[] = [...ctx.messages];
|
||||
const passed = new Set<string>();
|
||||
const pending = new Set([...rest, ...openings].map((item) => item.tag));
|
||||
const pendingDeletes = new Set(plan.deletes);
|
||||
@@ -172,40 +208,47 @@ async function runPlannedDictateRound(
|
||||
let lastProse = "";
|
||||
let modelCalls = 1;
|
||||
|
||||
for (let attempt = 0; attempt < INSERT_ATTEMPTS && (pending.size > 0 || pendingDeletes.size > 0); attempt++) {
|
||||
const step = attempt === 0 ? "fill" : "fill-retry";
|
||||
const filled = await params.llm.completeWithTools(messages, {
|
||||
// 单次 fill:失败记入待补,留给下轮,不再同轮 fill-retry。
|
||||
if (pending.size > 0 || pendingDeletes.size > 0) {
|
||||
const filled = await params.llm.completeWithTools(ctx.messages, {
|
||||
caller: "dictate_agent",
|
||||
label: attempt === 0 ? "对话落盘 填写" : "对话落盘 重写",
|
||||
label: "对话落盘 填写",
|
||||
tools: DICTATE_WRITE_LOOP_TOOLS,
|
||||
toolChoice: "required",
|
||||
usageDetail: {
|
||||
...detail(turnId, step, attempt, ctx.segmentChars),
|
||||
...detail(turnId, "fill", 0, ctx.segmentChars),
|
||||
plannedTags: [...pending, ...pendingDeletes],
|
||||
},
|
||||
usageSink: sinkFor(turnId, step),
|
||||
usageSink: sinkFor(turnId, "fill"),
|
||||
});
|
||||
modelCalls += 1;
|
||||
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
|
||||
const prose = filled.content?.trim() ?? "";
|
||||
if (prose) lastProse = prose;
|
||||
const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({
|
||||
id: call.id || `call-${attempt}-${index}`,
|
||||
id: call.id || `call-0-${index}`,
|
||||
name: call.name,
|
||||
arguments: call.arguments,
|
||||
}));
|
||||
if (calls.length === 0) break;
|
||||
const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors);
|
||||
messages.push(assistantToolMessage(filled.content, calls));
|
||||
for (const item of settled.results) {
|
||||
messages.push({ role: "tool", content: item.content, tool_call_id: item.callId });
|
||||
}
|
||||
if (pending.size === 0 && pendingDeletes.size === 0) {
|
||||
replyFromTool = settled.reply;
|
||||
break;
|
||||
if (calls.length > 0) {
|
||||
const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors);
|
||||
if (pending.size === 0 && pendingDeletes.size === 0) {
|
||||
replyFromTool = settled.reply;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const moduleByTag = new Map(
|
||||
[...rest, ...openings].map((item) => [item.tag, item.moduleId] as const),
|
||||
);
|
||||
persistRemediations(params, {
|
||||
pending,
|
||||
pendingDeletes,
|
||||
errors,
|
||||
moduleByTag,
|
||||
prior: remediations.items,
|
||||
});
|
||||
|
||||
let reply = replyFromTool;
|
||||
if (
|
||||
!replyFromTool &&
|
||||
@@ -224,7 +267,12 @@ async function runPlannedDictateRound(
|
||||
);
|
||||
const pendingLines = [...blockedLines, ...failed];
|
||||
const text = pendingLines.length
|
||||
? [reply, `尚未完成:${pendingLines.join("、")}。`].filter(Boolean).join("\n")
|
||||
? [
|
||||
reply,
|
||||
`失败待补(下轮继续):${pendingLines.join("、")}。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
: reply;
|
||||
return { text, modelCalls };
|
||||
}
|
||||
@@ -303,18 +351,6 @@ async function runLeanReply(
|
||||
return filled.content?.trim() || fallbackProse;
|
||||
}
|
||||
|
||||
function assistantToolMessage(content: string | null, calls: DriverToolCall[]): ChatMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
tool_calls: calls.map((call) => ({
|
||||
id: call.id,
|
||||
type: "function" as const,
|
||||
function: { name: call.name, arguments: call.arguments },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function settleWriteStep(
|
||||
params: PlannedRoundParams,
|
||||
apply: Apply,
|
||||
@@ -665,17 +701,97 @@ function canonicalProductTag(
|
||||
|
||||
function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string {
|
||||
const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean);
|
||||
const failedTags = new Set(errors.map((item) => item.tag));
|
||||
const pendingWrite = group.filter((item) => !failedTags.has(item.tag)).map((item) => item.tag);
|
||||
const pendingDelete = deletes.filter((tag) => !failedTags.has(tag));
|
||||
const lines = [
|
||||
nodes.length ? `第一轮选定的节点:${nodes.join("、")}` : "",
|
||||
group.length ? `本轮写入:${group.map((item) => item.tag).join("、")}` : "",
|
||||
deletes.length ? `本轮删除:${deletes.join("、")}` : "",
|
||||
nodes.length ? `本轮选定的节点:${nodes.join("、")}` : "",
|
||||
pendingWrite.length ? `待写:${pendingWrite.join("、")}` : "",
|
||||
pendingDelete.length ? `待删:${pendingDelete.join("、")}` : "",
|
||||
errors.length
|
||||
? `只重写未成功项:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}`
|
||||
? `失败待补:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}`
|
||||
: "",
|
||||
"状态说明:待写/待删=本轮目标;失败待补=上轮或本轮未通过,须补上;已落的产物以黑板为准,不要无故整份重写。",
|
||||
];
|
||||
return lines.filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function mergeRemediationsIntoPlan(
|
||||
plan: SelectPlan,
|
||||
items: readonly DictateRemediateItem[],
|
||||
params: PlannedRoundParams,
|
||||
): void {
|
||||
for (const item of items) {
|
||||
if (item.kind === "delete") {
|
||||
if (!plan.deletes.includes(item.tag) && isAllowedProductTag(item.tag)) {
|
||||
plan.deletes.push(item.tag);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (plan.inserts.some((row) => row.tag === item.tag || row.tag === `!${item.tag}`)) {
|
||||
continue;
|
||||
}
|
||||
const canonical = canonicalProductTag(item.tag, item.moduleId ?? "", params.moduleCatalog);
|
||||
const tag = canonical?.tag ?? item.tag;
|
||||
if (!isAllowedProductTag(tag) && tag !== VARIABLE_CATALOG_TAG && tag !== VALUE_MAP_TAG) {
|
||||
continue;
|
||||
}
|
||||
plan.inserts.push({
|
||||
tag,
|
||||
moduleId: canonical?.moduleId || item.moduleId || "",
|
||||
entryIds: [],
|
||||
reason: item.error ? `失败待补:${item.error}` : "失败待补",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function persistRemediations(
|
||||
params: PlannedRoundParams,
|
||||
state: {
|
||||
pending: Set<string>;
|
||||
pendingDeletes: Set<string>;
|
||||
errors: Map<string, string>;
|
||||
moduleByTag: Map<string, string>;
|
||||
prior: readonly DictateRemediateItem[];
|
||||
},
|
||||
): void {
|
||||
const priorByKey = new Map(
|
||||
state.prior.map((item) => [`${item.kind}:${item.tag}`, item] as const),
|
||||
);
|
||||
const next: DictateRemediateItem[] = [];
|
||||
for (const tag of state.pending) {
|
||||
const prev = priorByKey.get(`insert:${tag}`);
|
||||
next.push({
|
||||
tag,
|
||||
kind: "insert",
|
||||
error: state.errors.get(tag) ?? prev?.error ?? "尚未通过",
|
||||
...(state.moduleByTag.get(tag) || prev?.moduleId
|
||||
? { moduleId: state.moduleByTag.get(tag) || prev?.moduleId }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
for (const tag of state.pendingDeletes) {
|
||||
const prev = priorByKey.get(`delete:${tag}`);
|
||||
next.push({
|
||||
tag,
|
||||
kind: "delete",
|
||||
error: state.errors.get(tag) ?? prev?.error ?? "尚未通过",
|
||||
});
|
||||
}
|
||||
if (next.length === 0) {
|
||||
if (params.handlers.deleteTag) {
|
||||
params.handlers.deleteTag(DICTATE_REMEDIATE_TAG);
|
||||
} else {
|
||||
params.handlers.writeTag(DICTATE_REMEDIATE_TAG, "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
params.handlers.writeTag(
|
||||
DICTATE_REMEDIATE_TAG,
|
||||
serializeDictateRemediate({ schema: DICTATE_REMEDIATE_SCHEMA, items: next }),
|
||||
);
|
||||
}
|
||||
|
||||
function asItemList(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) return value;
|
||||
const rec = asRecord(value);
|
||||
|
||||
@@ -171,6 +171,8 @@ export async function runDictateTurn(params: {
|
||||
moduleReadIndex?: ReadonlyMap<string, DictateModuleReadPayload>;
|
||||
/** catalog 中 repeatable 能力的 artifact 基名(设计.生成规则 等) */
|
||||
repeatableFamilies?: ReadonlySet<string> | readonly string[];
|
||||
/** 更早对话保留轮数;默认 2,可用 DICTATE_HISTORY_TURNS 覆盖 */
|
||||
historyTurns?: number;
|
||||
driver?: AgentDriver;
|
||||
}): Promise<DictateTurnResult> {
|
||||
const driver =
|
||||
@@ -182,6 +184,7 @@ export async function runDictateTurn(params: {
|
||||
listProducts: params.handlers.listProducts,
|
||||
readTag: params.handlers.readTag,
|
||||
writeTag: params.handlers.writeTag,
|
||||
deleteTag: params.handlers.deleteTag,
|
||||
prepareOpening: params.handlers.prepareOpening,
|
||||
lookupInsertFeedback: params.handlers.lookupInsertFeedback,
|
||||
onThinkingDelta: params.handlers.onThinkingDelta,
|
||||
@@ -194,6 +197,7 @@ export async function runDictateTurn(params: {
|
||||
recipeName: params.recipeName,
|
||||
recipeBrief: params.recipeBrief,
|
||||
systemPrompt: params.systemPrompt,
|
||||
historyTurns: params.historyTurns,
|
||||
});
|
||||
const wroteTags: string[] = [];
|
||||
const deletedTags: string[] = [];
|
||||
|
||||
@@ -2,6 +2,7 @@ export {
|
||||
DEFAULT_BUFFER_TARGET_CHARS,
|
||||
LISTEN_SERIAL_MODE_VALUE,
|
||||
LISTEN_PHASE_TAG,
|
||||
LISTEN_PLAY_LAYER_TAG,
|
||||
LISTEN_THRILL_TAG,
|
||||
LISTEN_WORLD_TAG,
|
||||
LISTEN_STRUCTURE_TAG,
|
||||
@@ -31,10 +32,13 @@ export {
|
||||
writeListenPhase,
|
||||
writeListenSections,
|
||||
writeListenSummaries,
|
||||
isListenPlayActive,
|
||||
setListenPlayActive,
|
||||
} from "./state.js";
|
||||
|
||||
export {
|
||||
runListenSerialTurn,
|
||||
ensureListenPlayContent,
|
||||
markListenConsumed,
|
||||
type ListenChatTurn,
|
||||
type ListenSerialTurnResult,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LISTEN_CONSUMED_CHARS_TAG,
|
||||
LISTEN_OUTLINE_TAG,
|
||||
LISTEN_PHASE_TAG,
|
||||
LISTEN_PLAY_LAYER_TAG,
|
||||
LISTEN_SECTIONS_TAG,
|
||||
LISTEN_STRUCTURE_TAG,
|
||||
LISTEN_SUMMARIES_TAG,
|
||||
@@ -33,6 +34,7 @@ function parsePhase(raw: string | undefined): ListenPhase {
|
||||
if (
|
||||
v === "intake" ||
|
||||
v === "awaiting_confirm" ||
|
||||
v === "ready" ||
|
||||
v === "running" ||
|
||||
v === "soft_stop"
|
||||
) {
|
||||
@@ -41,6 +43,14 @@ function parsePhase(raw: string | undefined): ListenPhase {
|
||||
return "intake";
|
||||
}
|
||||
|
||||
export function isListenPlayActive(board: Blackboard): boolean {
|
||||
return (board.getContentByTag(LISTEN_PLAY_LAYER_TAG) ?? "").trim() === "1";
|
||||
}
|
||||
|
||||
export function setListenPlayActive(board: Blackboard, on: boolean): void {
|
||||
writeTag(board, LISTEN_PLAY_LAYER_TAG, on ? "1" : "0");
|
||||
}
|
||||
|
||||
export function readListenState(board: Blackboard): ListenSerialState {
|
||||
const bufferRaw = board.getContentByTag(LISTEN_BUFFER_TARGET_TAG);
|
||||
const bufferTargetChars = Math.max(
|
||||
@@ -122,9 +132,13 @@ export function unreadChars(state: ListenSerialState): number {
|
||||
return Math.max(0, total - state.consumedChars);
|
||||
}
|
||||
|
||||
export function toListenView(state: ListenSerialState): ListenSerialView {
|
||||
export function toListenView(
|
||||
state: ListenSerialState,
|
||||
playActive = false,
|
||||
): ListenSerialView {
|
||||
return {
|
||||
phase: state.phase,
|
||||
playActive,
|
||||
thrill: state.thrill,
|
||||
world: state.world,
|
||||
structure: state.structure,
|
||||
|
||||
+95
-22
@@ -147,19 +147,104 @@ function formatOutlineLine(state: ListenSerialState): string {
|
||||
/**
|
||||
* 听书连载单轮:开书 / 确认 / 补货 / 插嘴 / 软停续波。
|
||||
*/
|
||||
/**
|
||||
* 进入播放层时:若尚无大纲/正文,则生成到缓冲。
|
||||
*/
|
||||
export async function ensureListenPlayContent(params: {
|
||||
llm: LlmProvider;
|
||||
board: Blackboard;
|
||||
}): Promise<ListenSerialTurnResult> {
|
||||
const { llm, board } = params;
|
||||
ensureBufferTarget(board);
|
||||
let state = readListenState(board);
|
||||
if (!state.thrill || !state.world) {
|
||||
throw new Error("请先在创作里确认核心爽点与一句世界");
|
||||
}
|
||||
if (
|
||||
state.phase === "running" ||
|
||||
state.phase === "soft_stop"
|
||||
) {
|
||||
if (state.outline.length && unreadChars(state) > 0) {
|
||||
return {
|
||||
reply: [
|
||||
"继续播放。",
|
||||
formatOutlineLine(state),
|
||||
formatUnreadLine(state),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
softStop: state.phase === "soft_stop",
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!state.outline.some((o) => o.status === "pending" || o.status === "done")) {
|
||||
const outlined = await llmOutline(llm, state);
|
||||
const outline = makeOutlineItems(outlined.titles, outlined.payoffTitle);
|
||||
state = persistState(board, state, { outline, phase: "running" });
|
||||
const filled = await fillBuffer(llm, board, state);
|
||||
state = filled.state;
|
||||
return {
|
||||
reply: [
|
||||
outlined.reply,
|
||||
formatOutlineLine(state),
|
||||
filled.notes.join("\n"),
|
||||
formatUnreadLine(state),
|
||||
filled.softStop
|
||||
? "本段已兑现。可「下一波」或「收工」。"
|
||||
: "不满意就直接一句意见,我改后面的走向。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
softStop: filled.softStop,
|
||||
};
|
||||
}
|
||||
const filled = await fillBuffer(llm, board, state);
|
||||
state = filled.state;
|
||||
return {
|
||||
reply: [
|
||||
filled.notes.join("\n") || "已进入播放。",
|
||||
formatOutlineLine(state),
|
||||
formatUnreadLine(state),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
softStop: filled.softStop || state.phase === "soft_stop",
|
||||
};
|
||||
}
|
||||
|
||||
export async function runListenSerialTurn(params: {
|
||||
llm: LlmProvider;
|
||||
board: Blackboard;
|
||||
dialogue: ListenChatTurn[];
|
||||
userText: string;
|
||||
/** 是否在播放层;创作层只允许开书/确认 */
|
||||
inPlay?: boolean;
|
||||
}): Promise<ListenSerialTurnResult> {
|
||||
const { llm, board, dialogue, userText } = params;
|
||||
const inPlay = Boolean(params.inPlay);
|
||||
ensureBufferTarget(board);
|
||||
let state = readListenState(board);
|
||||
const text = userText.trim();
|
||||
|
||||
// 创作层:已进播放态的正文不在这里改;ready 时可空提或改 brief
|
||||
if (!inPlay && (state.phase === "running" || state.phase === "soft_stop")) {
|
||||
return {
|
||||
reply: "大纲与正文在「播放」里。请从目录点「播放」继续听/改向。",
|
||||
softStop: state.phase === "soft_stop",
|
||||
};
|
||||
}
|
||||
if (!inPlay && state.phase === "ready") {
|
||||
if (!text || isConfirmText(text)) {
|
||||
return {
|
||||
reply:
|
||||
"本局已确认。请点「播放」开始写大纲与正文;想改爽点直接说新需求。",
|
||||
};
|
||||
}
|
||||
// 有新需求 → 重新提案
|
||||
}
|
||||
|
||||
// —— 软停:收工 / 下一波 ——
|
||||
if (state.phase === "soft_stop") {
|
||||
if (inPlay && state.phase === "soft_stop") {
|
||||
if (isEndText(text)) {
|
||||
return {
|
||||
reply: "本局收工。想再开可以新建作品,或再说「下一波」。",
|
||||
@@ -208,33 +293,21 @@ export async function runListenSerialTurn(params: {
|
||||
structure: intake.structure,
|
||||
});
|
||||
return {
|
||||
reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${intake.structure}\n\n回复「确认」开始生成大纲与正文。`,
|
||||
reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${intake.structure}\n\n回复「确认」锁定本局;确认后点「播放」写大纲与正文。`,
|
||||
};
|
||||
}
|
||||
|
||||
const outlined = await llmOutline(llm, state);
|
||||
const outline = makeOutlineItems(outlined.titles, outlined.payoffTitle);
|
||||
state = persistState(board, state, { outline, phase: "running" });
|
||||
const filled = await fillBuffer(llm, board, state);
|
||||
state = filled.state;
|
||||
state = persistState(board, state, { phase: "ready" });
|
||||
return {
|
||||
reply: [
|
||||
outlined.reply,
|
||||
formatOutlineLine(state),
|
||||
filled.notes.join("\n"),
|
||||
formatUnreadLine(state),
|
||||
filled.softStop
|
||||
? "本段已兑现。可「下一波」或「收工」。"
|
||||
: "不满意某桥段就直接说一句意见,我会改后面的走向。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
softStop: filled.softStop,
|
||||
reply: `已确认本局。\n\n【核心爽点】${state.thrill}\n【一句世界】${state.world}\n【结构】${state.structure}\n\n请点「播放」开始生成大纲与正文。`,
|
||||
};
|
||||
}
|
||||
|
||||
// —— 连载中:空输入/继续 = 补货;有意见 = 改向 ——
|
||||
if (state.phase === "running" || state.phase === "soft_stop") {
|
||||
// —— 播放层连载:继续 = 补货;有意见 = 改向 ——
|
||||
if (inPlay && (state.phase === "running" || state.phase === "soft_stop" || state.phase === "ready")) {
|
||||
if (state.phase === "ready" || !state.outline.length) {
|
||||
return ensureListenPlayContent({ llm, board });
|
||||
}
|
||||
const isContinue =
|
||||
!text ||
|
||||
/^(继续|接着写|补货|下一节)([!!。.\s]*)$/u.test(text);
|
||||
@@ -319,7 +392,7 @@ export async function runListenSerialTurn(params: {
|
||||
structure: intake.structure || "三段式:铺垫冲突 → 对抗升级 → 爽点兑现",
|
||||
});
|
||||
return {
|
||||
reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${state.structure}\n\n回复「确认」开始生成大纲与正文。`,
|
||||
reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${state.structure}\n\n回复「确认」锁定本局;确认后点「播放」写大纲与正文。`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ export const LISTEN_SECTIONS_TAG = "听书.正文节";
|
||||
export const LISTEN_SUMMARIES_TAG = "听书.小总结";
|
||||
export const LISTEN_BUFFER_TARGET_TAG = "听书.缓冲目标字数";
|
||||
export const LISTEN_CONSUMED_CHARS_TAG = "听书.已消费字数";
|
||||
/** 播放层开关:创作只定 brief,播放层才写大纲/正文 */
|
||||
export const LISTEN_PLAY_LAYER_TAG = "听书.播放层";
|
||||
|
||||
/** 默认未读/未听缓冲目标(字)——实现默认值,可后续调 */
|
||||
export const DEFAULT_BUFFER_TARGET_CHARS = 4000;
|
||||
@@ -22,6 +24,8 @@ export const MAX_SUMMARIES_IN_CONTEXT = 8;
|
||||
export type ListenPhase =
|
||||
| "intake"
|
||||
| "awaiting_confirm"
|
||||
/** 创作已确认,等待进入播放写大纲正文 */
|
||||
| "ready"
|
||||
| "running"
|
||||
| "soft_stop";
|
||||
|
||||
@@ -59,6 +63,8 @@ export type ListenSerialState = {
|
||||
|
||||
export type ListenSerialView = {
|
||||
phase: ListenPhase;
|
||||
/** 是否在播放层(大纲/正文/TTS) */
|
||||
playActive: boolean;
|
||||
thrill: string;
|
||||
world: string;
|
||||
structure: string;
|
||||
|
||||
@@ -187,10 +187,13 @@ import {
|
||||
} from "../dictate/index.js";
|
||||
import {
|
||||
LISTEN_SERIAL_MODE_VALUE,
|
||||
ensureListenPlayContent,
|
||||
isListenPlayActive,
|
||||
isListenSerialModeValue,
|
||||
markListenConsumed,
|
||||
readListenState,
|
||||
runListenSerialTurn,
|
||||
setListenPlayActive,
|
||||
toListenView,
|
||||
type ListenSerialView,
|
||||
} from "../listen-serial/index.js";
|
||||
@@ -343,7 +346,8 @@ export type SessionView = {
|
||||
/** 正文组成数据契约里的区。格式化时按 id 填进对应 data-block。 */
|
||||
presentationSlots?: Array<{ id: string; type: string }>;
|
||||
/**
|
||||
* 程序投影块(来自 变量.当前 等)。前端灌 present 时与消息正文合并,随真值刷新。
|
||||
* 程序投影块(来自 变量.当前 等)。前端灌 present 时与消息正文合并。
|
||||
* 每轮 toView 现算:旁观副 LLM 改表后会变,供主列气泡 patch 重渲染(主世界出稿时投影必然偏旧)。
|
||||
*/
|
||||
presentationProjection?: Record<string, unknown>;
|
||||
/** 总管正在输出的思维链(流式,轮询用) */
|
||||
@@ -504,6 +508,14 @@ export class SessionManager {
|
||||
opts?: { useOpeningPersona?: boolean },
|
||||
): SessionView {
|
||||
const s = this.require(sessionId);
|
||||
if (this.isListenSerialMode(s)) {
|
||||
if (stage === "play") {
|
||||
throw new Error("听书请用「播放」入口(会生成大纲与正文)");
|
||||
}
|
||||
this.leaveListenPlay(s);
|
||||
if (s.bookId) this.persist(s);
|
||||
return this.toView(sessionId);
|
||||
}
|
||||
if (stage === "play") {
|
||||
s.runtime.prepareEnterPlay();
|
||||
if (!canEnterPlay(s.runtime.getSession())) {
|
||||
@@ -520,6 +532,62 @@ export class SessionManager {
|
||||
return this.toView(sessionId);
|
||||
}
|
||||
|
||||
/** 听书:进入播放层,必要时生成大纲与正文 */
|
||||
async enterListenPlay(sessionId: string): Promise<SessionView> {
|
||||
const s = this.require(sessionId);
|
||||
if (!this.isListenSerialMode(s)) {
|
||||
throw new Error("当前不是听书连载");
|
||||
}
|
||||
const board = s.runtime.getBlackboard();
|
||||
const state = readListenState(board);
|
||||
if (!state.thrill?.trim() || !state.world?.trim()) {
|
||||
throw new Error("请先在创作里确认核心爽点与一句世界");
|
||||
}
|
||||
if (state.phase === "intake" || state.phase === "awaiting_confirm") {
|
||||
throw new Error("请先回复「确认」锁定本局,再进入播放");
|
||||
}
|
||||
setListenPlayActive(board, true);
|
||||
s.runtime.setLifecycleStage("play");
|
||||
const llm = createDefaultMainAgentLlm(s.trackingRef, () =>
|
||||
this.personaForManaged(s),
|
||||
);
|
||||
const result = await ensureListenPlayContent({ llm, board });
|
||||
const keep = normalizeContextTraceKeepLatest(
|
||||
loadAppSettings().contextTraceKeepLatest,
|
||||
);
|
||||
const tokenUsage = s.trackingRef.current.pendingUsage;
|
||||
s.trackingRef.current.pendingUsage = undefined;
|
||||
s.trackingRef.current.pendingOutlines = undefined;
|
||||
s.trackingRef.current.pendingContextTrace = undefined;
|
||||
s.messages.push({
|
||||
id: randomUUID(),
|
||||
role: "system",
|
||||
text: result.reply,
|
||||
createdAt: new Date().toISOString(),
|
||||
kind: "listen_reply",
|
||||
actor: "orchestrator",
|
||||
title: "听书播放",
|
||||
body: result.reply,
|
||||
...(tokenUsage ? { tokenUsage } : {}),
|
||||
});
|
||||
s.messages = pruneContextTraces(s.messages, keep);
|
||||
s.runtime.enterDictateIntake(
|
||||
result.softStop
|
||||
? "本段已告一段落。输入「下一波」或「收工」,或一句意见改向。"
|
||||
: "继续听/读,或说「继续」补货;不满意直接一句意见。",
|
||||
);
|
||||
if (s.bookId) this.persist(s);
|
||||
return this.toView(sessionId);
|
||||
}
|
||||
|
||||
private leaveListenPlay(s: ManagedSession): void {
|
||||
setListenPlayActive(s.runtime.getBlackboard(), false);
|
||||
s.runtime.setLifecycleStage("design");
|
||||
s.runtime.enterDictateIntake(
|
||||
"听书创作:可继续改爽点与设定;确认后点「播放」写大纲正文。",
|
||||
);
|
||||
}
|
||||
|
||||
/** 用户手改黑板 tag(创作期改设定/表;尊重后续 worker 的表 rev 合并) */
|
||||
writeUserBoardTag(
|
||||
sessionId: string,
|
||||
@@ -892,11 +960,13 @@ export class SessionManager {
|
||||
this.personaForManaged(s),
|
||||
);
|
||||
const dialogue = this.extractListenDialogue(s.messages);
|
||||
const board = s.runtime.getBlackboard();
|
||||
const result = await runListenSerialTurn({
|
||||
llm,
|
||||
board: s.runtime.getBlackboard(),
|
||||
board,
|
||||
dialogue,
|
||||
userText,
|
||||
inPlay: isListenPlayActive(board),
|
||||
});
|
||||
const keep = normalizeContextTraceKeepLatest(
|
||||
loadAppSettings().contextTraceKeepLatest,
|
||||
@@ -4040,7 +4110,18 @@ export class SessionManager {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const lifecycleStage = inferLifecycleStage(session);
|
||||
const listenMode = this.isListenSerialMode(s);
|
||||
const listenState = listenMode
|
||||
? readListenState(s.runtime.getBlackboard())
|
||||
: null;
|
||||
const listenPlay = listenMode
|
||||
? isListenPlayActive(s.runtime.getBlackboard())
|
||||
: false;
|
||||
const lifecycleStage: LifecycleStage = listenMode
|
||||
? listenPlay
|
||||
? "play"
|
||||
: "design"
|
||||
: inferLifecycleStage(session);
|
||||
const skillPackId =
|
||||
sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined);
|
||||
const workerSetYaml = readWorkerSetYaml(s.runtime.getBlackboard());
|
||||
@@ -4056,6 +4137,13 @@ export class SessionManager {
|
||||
s.runtime.getBlackboard().getContentByTag("设计.回复格式");
|
||||
const shellAdapt = parseShellAdaptationFromReplyFormat(replyFormatRaw);
|
||||
const replyContract = parseReplyDataContract(replyFormatRaw);
|
||||
const listenPlayReady = Boolean(
|
||||
listenState?.thrill &&
|
||||
listenState.world &&
|
||||
(listenState.phase === "ready" ||
|
||||
listenState.phase === "running" ||
|
||||
listenState.phase === "soft_stop"),
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -4075,11 +4163,13 @@ export class SessionManager {
|
||||
dictateProducts: this.isDictateMode(s)
|
||||
? this.listDictateProducts(s)
|
||||
: undefined,
|
||||
listenSerial: this.isListenSerialMode(s)
|
||||
? toListenView(readListenState(s.runtime.getBlackboard()))
|
||||
listenSerial: listenState
|
||||
? toListenView(listenState, listenPlay)
|
||||
: undefined,
|
||||
playLayerActive: isPlayLayerActive(session.slots),
|
||||
playReady: canEnterPlay(session),
|
||||
playLayerActive: listenMode
|
||||
? listenPlay
|
||||
: isPlayLayerActive(session.slots),
|
||||
playReady: listenMode ? listenPlayReady : canEnterPlay(session),
|
||||
hasProduct: Boolean(
|
||||
s.bookId &&
|
||||
this.listGameSnapshots(s.bookId).some((item) => item.kind === "instance"),
|
||||
|
||||
@@ -250,6 +250,18 @@ const server = createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/listen/play") {
|
||||
try {
|
||||
const view = await sessionManager.enterListenPlay(sessionId);
|
||||
json(res, 200, view);
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : "无法进入播放",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && sub === "/listen/tts") {
|
||||
const sectionId = (url.searchParams.get("sectionId") ?? "").trim();
|
||||
if (!sectionId) {
|
||||
|
||||
Reference in New Issue
Block a user