创作降本:取消同轮retry、两轮history;听书创作/播放分层与前端配套。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
# 创作对话降本:取消同轮 retry + 2 轮 history
|
||||||
|
|
||||||
|
> 状态:已落地。承接 `token-cost-report-2026-10-06.md` / `prompt-cache-audit-2026-10-07.md`。
|
||||||
|
> 记录:2026-10-08。
|
||||||
|
|
||||||
|
## 做了什么
|
||||||
|
|
||||||
|
1. **取消同轮 `fill-retry`。** fill 只打一枪;未通过项写入 `设计.对话落盘待补`(readTag,不进产物列表),checklist 标 **失败待补**,下轮 select/fill 自动并入。
|
||||||
|
2. **select / fill 更早对话默认只留 2 轮**(1 轮 = 一问一答,不含本轮末句)。可用 `DICTATE_HISTORY_TURNS` 或 `historyTurns` 参数改;负数 = 不限制。
|
||||||
|
3. **产物全黑板暂不动**(质量 / play 传导风险;内容侧优化另议)。
|
||||||
|
4. Gemini 路径上跨轮缓存 ROI 很低;本改动主攻 **少开枪 + 砍无用 history**,不为保缓存去裁产物。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- 同书同对话长度:`fill-retry` 调用应接近 0;每轮模型调用中位趋向 **2**(澄清 1;缺 reply_user 时 lean reply 仍可能 3)。
|
||||||
|
- checklist 可见:待写 / 失败待补;失败回复含「失败待补(下轮继续)」。
|
||||||
|
- `DICTATE_HISTORY_TURNS=0` 时更早对话段应接近空。
|
||||||
+13
-2
@@ -1,4 +1,8 @@
|
|||||||
import type { ChatMessage } from "../llm/client.js";
|
import type { ChatMessage } from "../llm/client.js";
|
||||||
|
import {
|
||||||
|
clipDictateDialogue,
|
||||||
|
resolveDictateHistoryTurns,
|
||||||
|
} from "./history-window.js";
|
||||||
import {
|
import {
|
||||||
effectiveDictateOrder,
|
effectiveDictateOrder,
|
||||||
sortDictateProducts,
|
sortDictateProducts,
|
||||||
@@ -25,7 +29,7 @@ export type DictateBuiltContext = {
|
|||||||
/**
|
/**
|
||||||
* 创作请求的块顺序:固定规则、更早的对话、全部产物、本轮注入、末尾。
|
* 创作请求的块顺序:固定规则、更早的对话、全部产物、本轮注入、末尾。
|
||||||
* 选题不带产物正文和本轮注入。回复步只带本轮刚落盘产物与末句用户话。
|
* 选题不带产物正文和本轮注入。回复步只带本轮刚落盘产物与末句用户话。
|
||||||
* 更早对话只追加,最后一轮用户输入放在末尾。
|
* 更早对话按 historyTurns 裁剪(默认 2 轮),最后一轮用户输入放在末尾。
|
||||||
*/
|
*/
|
||||||
export function buildDictateContext(params: {
|
export function buildDictateContext(params: {
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
@@ -34,12 +38,18 @@ export function buildDictateContext(params: {
|
|||||||
phase?: DictateContextPhase;
|
phase?: DictateContextPhase;
|
||||||
injected?: string;
|
injected?: string;
|
||||||
tail?: string;
|
tail?: string;
|
||||||
|
/** 更早对话保留轮数;默认见 resolveDictateHistoryTurns */
|
||||||
|
historyTurns?: number;
|
||||||
}): DictateBuiltContext {
|
}): DictateBuiltContext {
|
||||||
const phase = params.phase ?? "fill";
|
const phase = params.phase ?? "fill";
|
||||||
if (phase === "reply") {
|
if (phase === "reply") {
|
||||||
return buildReplyContext(params);
|
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 history = formatEarlierDialogue(earlier);
|
||||||
const products =
|
const products =
|
||||||
phase === "fill" ? formatProducts(sortDictateProducts(params.products)) : "";
|
phase === "fill" ? formatProducts(sortDictateProducts(params.products)) : "";
|
||||||
@@ -138,6 +148,7 @@ export function buildDictateMessages(params: {
|
|||||||
phase?: DictateContextPhase;
|
phase?: DictateContextPhase;
|
||||||
injected?: string;
|
injected?: string;
|
||||||
tail?: string;
|
tail?: string;
|
||||||
|
historyTurns?: number;
|
||||||
}): ChatMessage[] {
|
}): ChatMessage[] {
|
||||||
return buildDictateContext(params).messages;
|
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,
|
buildDictateContext,
|
||||||
extractDictateDialogue,
|
extractDictateDialogue,
|
||||||
} from "./context.js";
|
} 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 {
|
export {
|
||||||
CREATION_INTAKE_MODE_TAG,
|
CREATION_INTAKE_MODE_TAG,
|
||||||
DICTATE_MODE_VALUE,
|
DICTATE_MODE_VALUE,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function buildDictateSelectPrompt(): string {
|
|||||||
return `你只做选题。对照能力目录和用户这句话,调用 select_nodes,选出已经够写的节点。
|
return `你只做选题。对照能力目录和用户这句话,调用 select_nodes,选出已经够写的节点。
|
||||||
|
|
||||||
nodes 里的 module_id 必须来自目录。通常选这段话已经说清的 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 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;
|
||||||
|
}
|
||||||
+156
-40
@@ -1,13 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,
|
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,
|
||||||
* 再在同一条消息前缀上循环 insert。已通过的不再覆盖。
|
* 再单次 fill 写入。已通过的不再覆盖。
|
||||||
|
* 同轮不再 fill-retry:失败项记入「失败待补」,下轮继续。
|
||||||
* 没有未通过项时,同一次请求里的 reply_user 才给用户。
|
* 没有未通过项时,同一次请求里的 reply_user 才给用户。
|
||||||
* 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。
|
* 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。
|
||||||
*/
|
*/
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js";
|
import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js";
|
||||||
import type { AgentDriver } 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 { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
|
||||||
import { buildDictateContext, type DictateSegmentChars } from "./context.js";
|
import { buildDictateContext, type DictateSegmentChars } from "./context.js";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
parseCreationPlan,
|
parseCreationPlan,
|
||||||
} from "./creation-plan.js";
|
} from "./creation-plan.js";
|
||||||
import { isDictateOpeningTag } from "./experience-anchor.js";
|
import { isDictateOpeningTag } from "./experience-anchor.js";
|
||||||
|
import { resolveDictateHistoryTurns } from "./history-window.js";
|
||||||
import {
|
import {
|
||||||
buildDictateReplyPrompt,
|
buildDictateReplyPrompt,
|
||||||
buildDictateSelectPrompt,
|
buildDictateSelectPrompt,
|
||||||
@@ -32,6 +34,14 @@ import {
|
|||||||
DICTATE_MODULE_READ_STATE_TAG,
|
DICTATE_MODULE_READ_STATE_TAG,
|
||||||
type DictateModuleReadPayload,
|
type DictateModuleReadPayload,
|
||||||
} from "./read-module.js";
|
} 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 { resolveModuleForInsertTag } from "./insert-feedback.js";
|
||||||
import type { DictateChatTurn, DictateProduct } from "./types.js";
|
import type { DictateChatTurn, DictateProduct } from "./types.js";
|
||||||
import { isAllowedProductTag } from "./types.js";
|
import { isAllowedProductTag } from "./types.js";
|
||||||
@@ -55,6 +65,7 @@ export type PlannedRoundParams = {
|
|||||||
listProducts: () => DictateProduct[];
|
listProducts: () => DictateProduct[];
|
||||||
readTag: (tag: string) => string | undefined;
|
readTag: (tag: string) => string | undefined;
|
||||||
writeTag: (tag: string, content: string) => void;
|
writeTag: (tag: string, content: string) => void;
|
||||||
|
deleteTag?: (tag: string) => boolean;
|
||||||
prepareOpening?: () => void;
|
prepareOpening?: () => void;
|
||||||
/** 本轮刚写入 tag 的能力回复口径(探测/原则/自评关注) */
|
/** 本轮刚写入 tag 的能力回复口径(探测/原则/自评关注) */
|
||||||
lookupInsertFeedback?: (tag: string) => string | undefined;
|
lookupInsertFeedback?: (tag: string) => string | undefined;
|
||||||
@@ -68,6 +79,8 @@ export type PlannedRoundParams = {
|
|||||||
recipeName?: string;
|
recipeName?: string;
|
||||||
recipeBrief?: string;
|
recipeBrief?: string;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
/** 更早对话保留轮数;默认 2,见 resolveDictateHistoryTurns */
|
||||||
|
historyTurns?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PlanInsert = {
|
type PlanInsert = {
|
||||||
@@ -87,10 +100,7 @@ type SelectPlan = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
type ItemFailure = { tag: string; error: string };
|
type ItemFailure = { tag: string; error: string; moduleId?: string; kind?: "insert" | "delete" };
|
||||||
|
|
||||||
|
|
||||||
const INSERT_ATTEMPTS = 4;
|
|
||||||
|
|
||||||
export function createPlannedDictateDriver(params: PlannedRoundParams): AgentDriver {
|
export function createPlannedDictateDriver(params: PlannedRoundParams): AgentDriver {
|
||||||
return {
|
return {
|
||||||
@@ -106,15 +116,23 @@ async function runPlannedDictateRound(
|
|||||||
apply: Apply,
|
apply: Apply,
|
||||||
): Promise<{ text: string; modelCalls: number }> {
|
): Promise<{ text: string; modelCalls: number }> {
|
||||||
const turnId = randomUUID();
|
const turnId = randomUUID();
|
||||||
|
const historyTurns = resolveDictateHistoryTurns(params.historyTurns);
|
||||||
const fillSystem = params.systemPrompt?.trim() || buildDictateSystemPrompt();
|
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 tailOpts = tailState(params);
|
||||||
const selectTail = formatDictateRoundTail({ ...tailOpts, phase: "select" });
|
const selectTail = formatDictateRoundTail({
|
||||||
|
...tailOpts,
|
||||||
|
phase: "select",
|
||||||
|
checklist: remediateChecklist,
|
||||||
|
});
|
||||||
const selectCtx = buildDictateContext({
|
const selectCtx = buildDictateContext({
|
||||||
systemPrompt: buildDictateSelectPrompt(),
|
systemPrompt: buildDictateSelectPrompt(),
|
||||||
products: params.handlers.listProducts(),
|
products: params.handlers.listProducts(),
|
||||||
dialogue: params.dialogue,
|
dialogue: params.dialogue,
|
||||||
phase: "select",
|
phase: "select",
|
||||||
tail: selectTail,
|
tail: selectTail,
|
||||||
|
historyTurns,
|
||||||
});
|
});
|
||||||
const selected = await params.llm.completeWithTools(selectCtx.messages, {
|
const selected = await params.llm.completeWithTools(selectCtx.messages, {
|
||||||
caller: "dictate_agent",
|
caller: "dictate_agent",
|
||||||
@@ -127,7 +145,12 @@ async function runPlannedDictateRound(
|
|||||||
if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim());
|
if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim());
|
||||||
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0];
|
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0];
|
||||||
const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params);
|
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 {
|
return {
|
||||||
modelCalls: 1,
|
modelCalls: 1,
|
||||||
text: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n"),
|
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?.();
|
if (openings.length > 0) params.handlers.prepareOpening?.();
|
||||||
const injected = injectGroup(params, [...rest, ...openings]);
|
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({
|
const tail = formatDictateRoundTail({
|
||||||
...tailOpts,
|
...tailOpts,
|
||||||
phase: "fill",
|
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({
|
const ctx = buildDictateContext({
|
||||||
systemPrompt: fillSystem,
|
systemPrompt: fillSystem,
|
||||||
@@ -162,8 +198,8 @@ async function runPlannedDictateRound(
|
|||||||
phase: "fill",
|
phase: "fill",
|
||||||
injected: injected.text,
|
injected: injected.text,
|
||||||
tail,
|
tail,
|
||||||
|
historyTurns,
|
||||||
});
|
});
|
||||||
const messages: ChatMessage[] = [...ctx.messages];
|
|
||||||
const passed = new Set<string>();
|
const passed = new Set<string>();
|
||||||
const pending = new Set([...rest, ...openings].map((item) => item.tag));
|
const pending = new Set([...rest, ...openings].map((item) => item.tag));
|
||||||
const pendingDeletes = new Set(plan.deletes);
|
const pendingDeletes = new Set(plan.deletes);
|
||||||
@@ -172,39 +208,46 @@ async function runPlannedDictateRound(
|
|||||||
let lastProse = "";
|
let lastProse = "";
|
||||||
let modelCalls = 1;
|
let modelCalls = 1;
|
||||||
|
|
||||||
for (let attempt = 0; attempt < INSERT_ATTEMPTS && (pending.size > 0 || pendingDeletes.size > 0); attempt++) {
|
// 单次 fill:失败记入待补,留给下轮,不再同轮 fill-retry。
|
||||||
const step = attempt === 0 ? "fill" : "fill-retry";
|
if (pending.size > 0 || pendingDeletes.size > 0) {
|
||||||
const filled = await params.llm.completeWithTools(messages, {
|
const filled = await params.llm.completeWithTools(ctx.messages, {
|
||||||
caller: "dictate_agent",
|
caller: "dictate_agent",
|
||||||
label: attempt === 0 ? "对话落盘 填写" : "对话落盘 重写",
|
label: "对话落盘 填写",
|
||||||
tools: DICTATE_WRITE_LOOP_TOOLS,
|
tools: DICTATE_WRITE_LOOP_TOOLS,
|
||||||
toolChoice: "required",
|
toolChoice: "required",
|
||||||
usageDetail: {
|
usageDetail: {
|
||||||
...detail(turnId, step, attempt, ctx.segmentChars),
|
...detail(turnId, "fill", 0, ctx.segmentChars),
|
||||||
plannedTags: [...pending, ...pendingDeletes],
|
plannedTags: [...pending, ...pendingDeletes],
|
||||||
},
|
},
|
||||||
usageSink: sinkFor(turnId, step),
|
usageSink: sinkFor(turnId, "fill"),
|
||||||
});
|
});
|
||||||
modelCalls += 1;
|
modelCalls += 1;
|
||||||
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
|
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
|
||||||
const prose = filled.content?.trim() ?? "";
|
const prose = filled.content?.trim() ?? "";
|
||||||
if (prose) lastProse = prose;
|
if (prose) lastProse = prose;
|
||||||
const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({
|
const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({
|
||||||
id: call.id || `call-${attempt}-${index}`,
|
id: call.id || `call-0-${index}`,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
arguments: call.arguments,
|
arguments: call.arguments,
|
||||||
}));
|
}));
|
||||||
if (calls.length === 0) break;
|
if (calls.length > 0) {
|
||||||
const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors);
|
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) {
|
if (pending.size === 0 && pendingDeletes.size === 0) {
|
||||||
replyFromTool = settled.reply;
|
replyFromTool = settled.reply;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
let reply = replyFromTool;
|
||||||
if (
|
if (
|
||||||
@@ -224,7 +267,12 @@ async function runPlannedDictateRound(
|
|||||||
);
|
);
|
||||||
const pendingLines = [...blockedLines, ...failed];
|
const pendingLines = [...blockedLines, ...failed];
|
||||||
const text = pendingLines.length
|
const text = pendingLines.length
|
||||||
? [reply, `尚未完成:${pendingLines.join("、")}。`].filter(Boolean).join("\n")
|
? [
|
||||||
|
reply,
|
||||||
|
`失败待补(下轮继续):${pendingLines.join("、")}。`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
: reply;
|
: reply;
|
||||||
return { text, modelCalls };
|
return { text, modelCalls };
|
||||||
}
|
}
|
||||||
@@ -303,18 +351,6 @@ async function runLeanReply(
|
|||||||
return filled.content?.trim() || fallbackProse;
|
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(
|
async function settleWriteStep(
|
||||||
params: PlannedRoundParams,
|
params: PlannedRoundParams,
|
||||||
apply: Apply,
|
apply: Apply,
|
||||||
@@ -665,17 +701,97 @@ function canonicalProductTag(
|
|||||||
|
|
||||||
function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string {
|
function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string {
|
||||||
const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean);
|
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 = [
|
const lines = [
|
||||||
nodes.length ? `第一轮选定的节点:${nodes.join("、")}` : "",
|
nodes.length ? `本轮选定的节点:${nodes.join("、")}` : "",
|
||||||
group.length ? `本轮写入:${group.map((item) => item.tag).join("、")}` : "",
|
pendingWrite.length ? `待写:${pendingWrite.join("、")}` : "",
|
||||||
deletes.length ? `本轮删除:${deletes.join("、")}` : "",
|
pendingDelete.length ? `待删:${pendingDelete.join("、")}` : "",
|
||||||
errors.length
|
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");
|
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[] {
|
function asItemList(value: unknown): unknown[] {
|
||||||
if (Array.isArray(value)) return value;
|
if (Array.isArray(value)) return value;
|
||||||
const rec = asRecord(value);
|
const rec = asRecord(value);
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ export async function runDictateTurn(params: {
|
|||||||
moduleReadIndex?: ReadonlyMap<string, DictateModuleReadPayload>;
|
moduleReadIndex?: ReadonlyMap<string, DictateModuleReadPayload>;
|
||||||
/** catalog 中 repeatable 能力的 artifact 基名(设计.生成规则 等) */
|
/** catalog 中 repeatable 能力的 artifact 基名(设计.生成规则 等) */
|
||||||
repeatableFamilies?: ReadonlySet<string> | readonly string[];
|
repeatableFamilies?: ReadonlySet<string> | readonly string[];
|
||||||
|
/** 更早对话保留轮数;默认 2,可用 DICTATE_HISTORY_TURNS 覆盖 */
|
||||||
|
historyTurns?: number;
|
||||||
driver?: AgentDriver;
|
driver?: AgentDriver;
|
||||||
}): Promise<DictateTurnResult> {
|
}): Promise<DictateTurnResult> {
|
||||||
const driver =
|
const driver =
|
||||||
@@ -182,6 +184,7 @@ export async function runDictateTurn(params: {
|
|||||||
listProducts: params.handlers.listProducts,
|
listProducts: params.handlers.listProducts,
|
||||||
readTag: params.handlers.readTag,
|
readTag: params.handlers.readTag,
|
||||||
writeTag: params.handlers.writeTag,
|
writeTag: params.handlers.writeTag,
|
||||||
|
deleteTag: params.handlers.deleteTag,
|
||||||
prepareOpening: params.handlers.prepareOpening,
|
prepareOpening: params.handlers.prepareOpening,
|
||||||
lookupInsertFeedback: params.handlers.lookupInsertFeedback,
|
lookupInsertFeedback: params.handlers.lookupInsertFeedback,
|
||||||
onThinkingDelta: params.handlers.onThinkingDelta,
|
onThinkingDelta: params.handlers.onThinkingDelta,
|
||||||
@@ -194,6 +197,7 @@ export async function runDictateTurn(params: {
|
|||||||
recipeName: params.recipeName,
|
recipeName: params.recipeName,
|
||||||
recipeBrief: params.recipeBrief,
|
recipeBrief: params.recipeBrief,
|
||||||
systemPrompt: params.systemPrompt,
|
systemPrompt: params.systemPrompt,
|
||||||
|
historyTurns: params.historyTurns,
|
||||||
});
|
});
|
||||||
const wroteTags: string[] = [];
|
const wroteTags: string[] = [];
|
||||||
const deletedTags: string[] = [];
|
const deletedTags: string[] = [];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export {
|
|||||||
DEFAULT_BUFFER_TARGET_CHARS,
|
DEFAULT_BUFFER_TARGET_CHARS,
|
||||||
LISTEN_SERIAL_MODE_VALUE,
|
LISTEN_SERIAL_MODE_VALUE,
|
||||||
LISTEN_PHASE_TAG,
|
LISTEN_PHASE_TAG,
|
||||||
|
LISTEN_PLAY_LAYER_TAG,
|
||||||
LISTEN_THRILL_TAG,
|
LISTEN_THRILL_TAG,
|
||||||
LISTEN_WORLD_TAG,
|
LISTEN_WORLD_TAG,
|
||||||
LISTEN_STRUCTURE_TAG,
|
LISTEN_STRUCTURE_TAG,
|
||||||
@@ -31,10 +32,13 @@ export {
|
|||||||
writeListenPhase,
|
writeListenPhase,
|
||||||
writeListenSections,
|
writeListenSections,
|
||||||
writeListenSummaries,
|
writeListenSummaries,
|
||||||
|
isListenPlayActive,
|
||||||
|
setListenPlayActive,
|
||||||
} from "./state.js";
|
} from "./state.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
runListenSerialTurn,
|
runListenSerialTurn,
|
||||||
|
ensureListenPlayContent,
|
||||||
markListenConsumed,
|
markListenConsumed,
|
||||||
type ListenChatTurn,
|
type ListenChatTurn,
|
||||||
type ListenSerialTurnResult,
|
type ListenSerialTurnResult,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
LISTEN_CONSUMED_CHARS_TAG,
|
LISTEN_CONSUMED_CHARS_TAG,
|
||||||
LISTEN_OUTLINE_TAG,
|
LISTEN_OUTLINE_TAG,
|
||||||
LISTEN_PHASE_TAG,
|
LISTEN_PHASE_TAG,
|
||||||
|
LISTEN_PLAY_LAYER_TAG,
|
||||||
LISTEN_SECTIONS_TAG,
|
LISTEN_SECTIONS_TAG,
|
||||||
LISTEN_STRUCTURE_TAG,
|
LISTEN_STRUCTURE_TAG,
|
||||||
LISTEN_SUMMARIES_TAG,
|
LISTEN_SUMMARIES_TAG,
|
||||||
@@ -33,6 +34,7 @@ function parsePhase(raw: string | undefined): ListenPhase {
|
|||||||
if (
|
if (
|
||||||
v === "intake" ||
|
v === "intake" ||
|
||||||
v === "awaiting_confirm" ||
|
v === "awaiting_confirm" ||
|
||||||
|
v === "ready" ||
|
||||||
v === "running" ||
|
v === "running" ||
|
||||||
v === "soft_stop"
|
v === "soft_stop"
|
||||||
) {
|
) {
|
||||||
@@ -41,6 +43,14 @@ function parsePhase(raw: string | undefined): ListenPhase {
|
|||||||
return "intake";
|
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 {
|
export function readListenState(board: Blackboard): ListenSerialState {
|
||||||
const bufferRaw = board.getContentByTag(LISTEN_BUFFER_TARGET_TAG);
|
const bufferRaw = board.getContentByTag(LISTEN_BUFFER_TARGET_TAG);
|
||||||
const bufferTargetChars = Math.max(
|
const bufferTargetChars = Math.max(
|
||||||
@@ -122,9 +132,13 @@ export function unreadChars(state: ListenSerialState): number {
|
|||||||
return Math.max(0, total - state.consumedChars);
|
return Math.max(0, total - state.consumedChars);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toListenView(state: ListenSerialState): ListenSerialView {
|
export function toListenView(
|
||||||
|
state: ListenSerialState,
|
||||||
|
playActive = false,
|
||||||
|
): ListenSerialView {
|
||||||
return {
|
return {
|
||||||
phase: state.phase,
|
phase: state.phase,
|
||||||
|
playActive,
|
||||||
thrill: state.thrill,
|
thrill: state.thrill,
|
||||||
world: state.world,
|
world: state.world,
|
||||||
structure: state.structure,
|
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: {
|
export async function runListenSerialTurn(params: {
|
||||||
llm: LlmProvider;
|
llm: LlmProvider;
|
||||||
board: Blackboard;
|
board: Blackboard;
|
||||||
dialogue: ListenChatTurn[];
|
dialogue: ListenChatTurn[];
|
||||||
userText: string;
|
userText: string;
|
||||||
|
/** 是否在播放层;创作层只允许开书/确认 */
|
||||||
|
inPlay?: boolean;
|
||||||
}): Promise<ListenSerialTurnResult> {
|
}): Promise<ListenSerialTurnResult> {
|
||||||
const { llm, board, dialogue, userText } = params;
|
const { llm, board, dialogue, userText } = params;
|
||||||
|
const inPlay = Boolean(params.inPlay);
|
||||||
ensureBufferTarget(board);
|
ensureBufferTarget(board);
|
||||||
let state = readListenState(board);
|
let state = readListenState(board);
|
||||||
const text = userText.trim();
|
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)) {
|
if (isEndText(text)) {
|
||||||
return {
|
return {
|
||||||
reply: "本局收工。想再开可以新建作品,或再说「下一波」。",
|
reply: "本局收工。想再开可以新建作品,或再说「下一波」。",
|
||||||
@@ -208,33 +293,21 @@ export async function runListenSerialTurn(params: {
|
|||||||
structure: intake.structure,
|
structure: intake.structure,
|
||||||
});
|
});
|
||||||
return {
|
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);
|
state = persistState(board, state, { phase: "ready" });
|
||||||
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 {
|
return {
|
||||||
reply: [
|
reply: `已确认本局。\n\n【核心爽点】${state.thrill}\n【一句世界】${state.world}\n【结构】${state.structure}\n\n请点「播放」开始生成大纲与正文。`,
|
||||||
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") {
|
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 =
|
const isContinue =
|
||||||
!text ||
|
!text ||
|
||||||
/^(继续|接着写|补货|下一节)([!!。.\s]*)$/u.test(text);
|
/^(继续|接着写|补货|下一节)([!!。.\s]*)$/u.test(text);
|
||||||
@@ -319,7 +392,7 @@ export async function runListenSerialTurn(params: {
|
|||||||
structure: intake.structure || "三段式:铺垫冲突 → 对抗升级 → 爽点兑现",
|
structure: intake.structure || "三段式:铺垫冲突 → 对抗升级 → 爽点兑现",
|
||||||
});
|
});
|
||||||
return {
|
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_SUMMARIES_TAG = "听书.小总结";
|
||||||
export const LISTEN_BUFFER_TARGET_TAG = "听书.缓冲目标字数";
|
export const LISTEN_BUFFER_TARGET_TAG = "听书.缓冲目标字数";
|
||||||
export const LISTEN_CONSUMED_CHARS_TAG = "听书.已消费字数";
|
export const LISTEN_CONSUMED_CHARS_TAG = "听书.已消费字数";
|
||||||
|
/** 播放层开关:创作只定 brief,播放层才写大纲/正文 */
|
||||||
|
export const LISTEN_PLAY_LAYER_TAG = "听书.播放层";
|
||||||
|
|
||||||
/** 默认未读/未听缓冲目标(字)——实现默认值,可后续调 */
|
/** 默认未读/未听缓冲目标(字)——实现默认值,可后续调 */
|
||||||
export const DEFAULT_BUFFER_TARGET_CHARS = 4000;
|
export const DEFAULT_BUFFER_TARGET_CHARS = 4000;
|
||||||
@@ -22,6 +24,8 @@ export const MAX_SUMMARIES_IN_CONTEXT = 8;
|
|||||||
export type ListenPhase =
|
export type ListenPhase =
|
||||||
| "intake"
|
| "intake"
|
||||||
| "awaiting_confirm"
|
| "awaiting_confirm"
|
||||||
|
/** 创作已确认,等待进入播放写大纲正文 */
|
||||||
|
| "ready"
|
||||||
| "running"
|
| "running"
|
||||||
| "soft_stop";
|
| "soft_stop";
|
||||||
|
|
||||||
@@ -59,6 +63,8 @@ export type ListenSerialState = {
|
|||||||
|
|
||||||
export type ListenSerialView = {
|
export type ListenSerialView = {
|
||||||
phase: ListenPhase;
|
phase: ListenPhase;
|
||||||
|
/** 是否在播放层(大纲/正文/TTS) */
|
||||||
|
playActive: boolean;
|
||||||
thrill: string;
|
thrill: string;
|
||||||
world: string;
|
world: string;
|
||||||
structure: string;
|
structure: string;
|
||||||
|
|||||||
@@ -187,10 +187,13 @@ import {
|
|||||||
} from "../dictate/index.js";
|
} from "../dictate/index.js";
|
||||||
import {
|
import {
|
||||||
LISTEN_SERIAL_MODE_VALUE,
|
LISTEN_SERIAL_MODE_VALUE,
|
||||||
|
ensureListenPlayContent,
|
||||||
|
isListenPlayActive,
|
||||||
isListenSerialModeValue,
|
isListenSerialModeValue,
|
||||||
markListenConsumed,
|
markListenConsumed,
|
||||||
readListenState,
|
readListenState,
|
||||||
runListenSerialTurn,
|
runListenSerialTurn,
|
||||||
|
setListenPlayActive,
|
||||||
toListenView,
|
toListenView,
|
||||||
type ListenSerialView,
|
type ListenSerialView,
|
||||||
} from "../listen-serial/index.js";
|
} from "../listen-serial/index.js";
|
||||||
@@ -343,7 +346,8 @@ export type SessionView = {
|
|||||||
/** 正文组成数据契约里的区。格式化时按 id 填进对应 data-block。 */
|
/** 正文组成数据契约里的区。格式化时按 id 填进对应 data-block。 */
|
||||||
presentationSlots?: Array<{ id: string; type: string }>;
|
presentationSlots?: Array<{ id: string; type: string }>;
|
||||||
/**
|
/**
|
||||||
* 程序投影块(来自 变量.当前 等)。前端灌 present 时与消息正文合并,随真值刷新。
|
* 程序投影块(来自 变量.当前 等)。前端灌 present 时与消息正文合并。
|
||||||
|
* 每轮 toView 现算:旁观副 LLM 改表后会变,供主列气泡 patch 重渲染(主世界出稿时投影必然偏旧)。
|
||||||
*/
|
*/
|
||||||
presentationProjection?: Record<string, unknown>;
|
presentationProjection?: Record<string, unknown>;
|
||||||
/** 总管正在输出的思维链(流式,轮询用) */
|
/** 总管正在输出的思维链(流式,轮询用) */
|
||||||
@@ -504,6 +508,14 @@ export class SessionManager {
|
|||||||
opts?: { useOpeningPersona?: boolean },
|
opts?: { useOpeningPersona?: boolean },
|
||||||
): SessionView {
|
): SessionView {
|
||||||
const s = this.require(sessionId);
|
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") {
|
if (stage === "play") {
|
||||||
s.runtime.prepareEnterPlay();
|
s.runtime.prepareEnterPlay();
|
||||||
if (!canEnterPlay(s.runtime.getSession())) {
|
if (!canEnterPlay(s.runtime.getSession())) {
|
||||||
@@ -520,6 +532,62 @@ export class SessionManager {
|
|||||||
return this.toView(sessionId);
|
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 合并) */
|
/** 用户手改黑板 tag(创作期改设定/表;尊重后续 worker 的表 rev 合并) */
|
||||||
writeUserBoardTag(
|
writeUserBoardTag(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -892,11 +960,13 @@ export class SessionManager {
|
|||||||
this.personaForManaged(s),
|
this.personaForManaged(s),
|
||||||
);
|
);
|
||||||
const dialogue = this.extractListenDialogue(s.messages);
|
const dialogue = this.extractListenDialogue(s.messages);
|
||||||
|
const board = s.runtime.getBlackboard();
|
||||||
const result = await runListenSerialTurn({
|
const result = await runListenSerialTurn({
|
||||||
llm,
|
llm,
|
||||||
board: s.runtime.getBlackboard(),
|
board,
|
||||||
dialogue,
|
dialogue,
|
||||||
userText,
|
userText,
|
||||||
|
inPlay: isListenPlayActive(board),
|
||||||
});
|
});
|
||||||
const keep = normalizeContextTraceKeepLatest(
|
const keep = normalizeContextTraceKeepLatest(
|
||||||
loadAppSettings().contextTraceKeepLatest,
|
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 =
|
const skillPackId =
|
||||||
sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined);
|
sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined);
|
||||||
const workerSetYaml = readWorkerSetYaml(s.runtime.getBlackboard());
|
const workerSetYaml = readWorkerSetYaml(s.runtime.getBlackboard());
|
||||||
@@ -4056,6 +4137,13 @@ export class SessionManager {
|
|||||||
s.runtime.getBlackboard().getContentByTag("设计.回复格式");
|
s.runtime.getBlackboard().getContentByTag("设计.回复格式");
|
||||||
const shellAdapt = parseShellAdaptationFromReplyFormat(replyFormatRaw);
|
const shellAdapt = parseShellAdaptationFromReplyFormat(replyFormatRaw);
|
||||||
const replyContract = parseReplyDataContract(replyFormatRaw);
|
const replyContract = parseReplyDataContract(replyFormatRaw);
|
||||||
|
const listenPlayReady = Boolean(
|
||||||
|
listenState?.thrill &&
|
||||||
|
listenState.world &&
|
||||||
|
(listenState.phase === "ready" ||
|
||||||
|
listenState.phase === "running" ||
|
||||||
|
listenState.phase === "soft_stop"),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -4075,11 +4163,13 @@ export class SessionManager {
|
|||||||
dictateProducts: this.isDictateMode(s)
|
dictateProducts: this.isDictateMode(s)
|
||||||
? this.listDictateProducts(s)
|
? this.listDictateProducts(s)
|
||||||
: undefined,
|
: undefined,
|
||||||
listenSerial: this.isListenSerialMode(s)
|
listenSerial: listenState
|
||||||
? toListenView(readListenState(s.runtime.getBlackboard()))
|
? toListenView(listenState, listenPlay)
|
||||||
: undefined,
|
: undefined,
|
||||||
playLayerActive: isPlayLayerActive(session.slots),
|
playLayerActive: listenMode
|
||||||
playReady: canEnterPlay(session),
|
? listenPlay
|
||||||
|
: isPlayLayerActive(session.slots),
|
||||||
|
playReady: listenMode ? listenPlayReady : canEnterPlay(session),
|
||||||
hasProduct: Boolean(
|
hasProduct: Boolean(
|
||||||
s.bookId &&
|
s.bookId &&
|
||||||
this.listGameSnapshots(s.bookId).some((item) => item.kind === "instance"),
|
this.listGameSnapshots(s.bookId).some((item) => item.kind === "instance"),
|
||||||
|
|||||||
@@ -250,6 +250,18 @@ const server = createServer(async (req, res) => {
|
|||||||
return;
|
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") {
|
if (req.method === "GET" && sub === "/listen/tts") {
|
||||||
const sectionId = (url.searchParams.get("sectionId") ?? "").trim();
|
const sectionId = (url.searchParams.get("sectionId") ?? "").trim();
|
||||||
if (!sectionId) {
|
if (!sectionId) {
|
||||||
|
|||||||
+131
-24
@@ -1,8 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
DICTATE_MODULE_READ_STATE_TAG,
|
DICTATE_MODULE_READ_STATE_TAG,
|
||||||
|
DICTATE_REMEDIATE_TAG,
|
||||||
buildDictateContext,
|
buildDictateContext,
|
||||||
|
clipDictateDialogue,
|
||||||
parseDictateModuleReadState,
|
parseDictateModuleReadState,
|
||||||
|
parseDictateRemediate,
|
||||||
runDictateTurn,
|
runDictateTurn,
|
||||||
type DictateModuleReadPayload,
|
type DictateModuleReadPayload,
|
||||||
} from "../src/dictate/index.js";
|
} from "../src/dictate/index.js";
|
||||||
@@ -179,7 +182,37 @@ describe("dictate planned round", () => {
|
|||||||
expect(selectBody.indexOf("第一句")).toBeLessThan(selectBody.indexOf("改成细雨"));
|
expect(selectBody.indexOf("第一句")).toBeLessThan(selectBody.indexOf("改成细雨"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("injects the spec, stamps the read credential, and rewrites only the missing tag", async () => {
|
it("clips earlier dialogue to the configured history turn window", () => {
|
||||||
|
const dialogue = [
|
||||||
|
{ role: "user" as const, text: "零" },
|
||||||
|
{ role: "assistant" as const, text: "零回" },
|
||||||
|
{ role: "user" as const, text: "一" },
|
||||||
|
{ role: "assistant" as const, text: "一回" },
|
||||||
|
{ role: "user" as const, text: "二" },
|
||||||
|
{ role: "assistant" as const, text: "二回" },
|
||||||
|
{ role: "user" as const, text: "本轮" },
|
||||||
|
];
|
||||||
|
expect(clipDictateDialogue(dialogue, 2).map((item) => item.text)).toEqual([
|
||||||
|
"一",
|
||||||
|
"一回",
|
||||||
|
"二",
|
||||||
|
"二回",
|
||||||
|
"本轮",
|
||||||
|
]);
|
||||||
|
const built = buildDictateContext({
|
||||||
|
systemPrompt: "规则",
|
||||||
|
products: [],
|
||||||
|
dialogue,
|
||||||
|
phase: "select",
|
||||||
|
historyTurns: 2,
|
||||||
|
});
|
||||||
|
const body = built.messages[1]?.content ?? "";
|
||||||
|
expect(body).not.toContain("【用户】\n零");
|
||||||
|
expect(body).toContain("一");
|
||||||
|
expect(body).toContain("本轮");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects the spec, stamps the read credential, and writes on the single fill", async () => {
|
||||||
const products: Array<{ tag: string; content: string }> = [];
|
const products: Array<{ tag: string; content: string }> = [];
|
||||||
const tags = new Map<string, string>();
|
const tags = new Map<string, string>();
|
||||||
const result = await runDictateTurn({
|
const result = await runDictateTurn({
|
||||||
@@ -189,7 +222,6 @@ describe("dictate planned round", () => {
|
|||||||
inserts: [{ tag: "设计.氛围", module_id: "mood" }],
|
inserts: [{ tag: "设计.氛围", module_id: "mood" }],
|
||||||
deletes: [],
|
deletes: [],
|
||||||
},
|
},
|
||||||
{ products: [], reply: "还没写" },
|
|
||||||
{
|
{
|
||||||
products: [{ tag: "设计.氛围", content: "细雨" }],
|
products: [{ tag: "设计.氛围", content: "细雨" }],
|
||||||
reply: "已写成细雨",
|
reply: "已写成细雨",
|
||||||
@@ -223,6 +255,7 @@ describe("dictate planned round", () => {
|
|||||||
expect(result.reply).toContain("已写成细雨");
|
expect(result.reply).toContain("已写成细雨");
|
||||||
const state = parseDictateModuleReadState(tags.get(DICTATE_MODULE_READ_STATE_TAG));
|
const state = parseDictateModuleReadState(tags.get(DICTATE_MODULE_READ_STATE_TAG));
|
||||||
expect(state.modules.mood).toBe("v-test");
|
expect(state.modules.mood).toBe("v-test");
|
||||||
|
expect(tags.get(DICTATE_REMEDIATE_TAG) ?? "").toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stops after the select step when the plan is a clarification", async () => {
|
it("stops after the select step when the plan is a clarification", async () => {
|
||||||
@@ -501,7 +534,7 @@ describe("dictate planned round", () => {
|
|||||||
expect(seen[0]).not.toContain("declare_variable");
|
expect(seen[0]).not.toContain("declare_variable");
|
||||||
expect(seen[1]).toContain("写成细雨");
|
expect(seen[1]).toContain("写成细雨");
|
||||||
expect(seen[1]).toContain("写氛围");
|
expect(seen[1]).toContain("写氛围");
|
||||||
expect(seen[1]).toContain("第一轮选定的节点:mood");
|
expect(seen[1]).toContain("本轮选定的节点:mood");
|
||||||
expect(seen[1]).toContain("还可能缺哪些节点");
|
expect(seen[1]).toContain("还可能缺哪些节点");
|
||||||
expect(result.wroteTags).toEqual(["设计.氛围"]);
|
expect(result.wroteTags).toEqual(["设计.氛围"]);
|
||||||
expect(result.reply).toContain("可能还缺主角设定");
|
expect(result.reply).toContain("可能还缺主角设定");
|
||||||
@@ -617,16 +650,18 @@ describe("dictate planned round", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(2);
|
||||||
expect(seen[1]).toContain("self_score 维度(这次写入必填,name 必须一致):短句");
|
expect(seen[1]).toContain("self_score 维度(这次写入必填,name 必须一致):短句");
|
||||||
expect(seen[2]?.startsWith(seen[1] ?? "")).toBe(true);
|
|
||||||
expect(seen[2]).toContain("还有未通过");
|
|
||||||
expect(products).toHaveLength(0);
|
expect(products).toHaveLength(0);
|
||||||
|
expect(result.reply).toContain("失败待补(下轮继续)");
|
||||||
expect(result.reply).toContain("self_score 必填");
|
expect(result.reply).toContain("self_score 必填");
|
||||||
expect(result.reply).toContain("短句");
|
expect(result.reply).toContain("短句");
|
||||||
expect(result.reply).not.toContain("未给出");
|
expect(result.reply).not.toContain("未给出");
|
||||||
|
const remediate = parseDictateRemediate(tags.get(DICTATE_REMEDIATE_TAG));
|
||||||
|
expect(remediate.items.map((item) => item.tag)).toEqual(["设计.氛围"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the node table and refuses to rewrite a tag that already passed", async () => {
|
it("defers failed tags to the next turn instead of same-turn fill-retry", async () => {
|
||||||
const narration: DictateModuleReadPayload = {
|
const narration: DictateModuleReadPayload = {
|
||||||
...modulePayload,
|
...modulePayload,
|
||||||
module_id: "narration",
|
module_id: "narration",
|
||||||
@@ -643,24 +678,11 @@ describe("dictate planned round", () => {
|
|||||||
[
|
[
|
||||||
{ id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨" }) },
|
{ id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨" }) },
|
||||||
{ id: "b", name: "insert", arguments: JSON.stringify({ position: "设计.旁白", content: "旁白" }) },
|
{ id: "b", name: "insert", arguments: JSON.stringify({ position: "设计.旁白", content: "旁白" }) },
|
||||||
{ id: "r1", name: "reply_user", arguments: JSON.stringify({ text: "都写了" }) },
|
{ id: "r1", name: "reply_user", arguments: JSON.stringify({ text: "氛围先落下" }) },
|
||||||
],
|
],
|
||||||
[
|
|
||||||
{ id: "a2", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "改掉细雨" }) },
|
|
||||||
{
|
|
||||||
id: "b2",
|
|
||||||
name: "insert",
|
|
||||||
arguments: JSON.stringify({
|
|
||||||
position: "设计.旁白",
|
|
||||||
content: "旁白",
|
|
||||||
self_score: { dims: [{ name: "一句", score: 4 }] },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[{ id: "r2", name: "reply_user", arguments: JSON.stringify({ text: "旁白补上了" }) }],
|
|
||||||
];
|
];
|
||||||
let index = 0;
|
let index = 0;
|
||||||
await runDictateTurn({
|
const result = await runDictateTurn({
|
||||||
llm: {
|
llm: {
|
||||||
async complete() {
|
async complete() {
|
||||||
throw new Error("对话落盘走工具,不走纯文本完成");
|
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||||
@@ -700,9 +722,94 @@ describe("dictate planned round", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(seen[2]?.startsWith(seen[1] ?? "")).toBe(true);
|
expect(seen).toHaveLength(2);
|
||||||
expect(seen[2]).toContain("写旁白");
|
expect(writes).toEqual(["设计.氛围:细雨"]);
|
||||||
expect(writes).toEqual(["设计.氛围:细雨", "设计.旁白:旁白"]);
|
expect(result.reply).toContain("失败待补(下轮继续)");
|
||||||
|
expect(result.reply).toContain("设计.旁白");
|
||||||
|
const remediate = parseDictateRemediate(tags.get(DICTATE_REMEDIATE_TAG));
|
||||||
|
expect(remediate.items).toEqual([
|
||||||
|
expect.objectContaining({ tag: "设计.旁白", kind: "insert" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges prior失败待补 into the next fill even if select omits them", async () => {
|
||||||
|
const tags = new Map<string, string>([
|
||||||
|
[
|
||||||
|
DICTATE_REMEDIATE_TAG,
|
||||||
|
JSON.stringify({
|
||||||
|
schema: "dictate-remediate.v1",
|
||||||
|
items: [{ tag: "设计.氛围", moduleId: "mood", error: "self_score 必填", kind: "insert" }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const products: Array<{ tag: string; content: string }> = [];
|
||||||
|
const seen: string[] = [];
|
||||||
|
const steps = [
|
||||||
|
{ nodes: [{ module_id: "narration" }] },
|
||||||
|
{
|
||||||
|
products: [
|
||||||
|
{ tag: "设计.氛围", content: "细雨" },
|
||||||
|
{ tag: "设计.旁白", content: "旁白" },
|
||||||
|
],
|
||||||
|
reply: "都补上了",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let index = 0;
|
||||||
|
const result = await runDictateTurn({
|
||||||
|
llm: {
|
||||||
|
async complete() {
|
||||||
|
throw new Error("对话落盘走工具,不走纯文本完成");
|
||||||
|
},
|
||||||
|
async completeWithTools(messages) {
|
||||||
|
seen.push(messages.map((message) => message.content ?? "").join("\n"));
|
||||||
|
const body = steps[index++] ?? {};
|
||||||
|
return {
|
||||||
|
content: null,
|
||||||
|
toolCalls: toToolCalls(body, index === 1),
|
||||||
|
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dialogue: [{ role: "user", text: "继续" }],
|
||||||
|
moduleCatalog: {
|
||||||
|
modules: [
|
||||||
|
...catalog.modules,
|
||||||
|
{ id: "narration", name: "旁白", artifact: "设计.旁白", declaration: "旁白" },
|
||||||
|
],
|
||||||
|
} as ModuleCatalog,
|
||||||
|
moduleReadIndex: new Map([
|
||||||
|
["mood", modulePayload],
|
||||||
|
[
|
||||||
|
"narration",
|
||||||
|
{
|
||||||
|
...modulePayload,
|
||||||
|
module_id: "narration",
|
||||||
|
artifact: "设计.旁白",
|
||||||
|
task: "写旁白",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
handlers: {
|
||||||
|
listProducts: () => products,
|
||||||
|
writeProduct: (tag, content) => {
|
||||||
|
products.push({ tag, content });
|
||||||
|
},
|
||||||
|
deleteProduct: () => false,
|
||||||
|
readTag: (tag) => tags.get(tag),
|
||||||
|
writeTag: (tag, content) => {
|
||||||
|
tags.set(tag, content);
|
||||||
|
},
|
||||||
|
deleteTag: (tag) => tags.delete(tag),
|
||||||
|
clearDialogue: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seen[0]).toContain("失败待补");
|
||||||
|
expect(seen[1]).toContain("设计.氛围");
|
||||||
|
expect(seen[1]).toContain("写氛围");
|
||||||
|
expect(result.wroteTags).toEqual(expect.arrayContaining(["设计.氛围", "设计.旁白"]));
|
||||||
|
expect(tags.has(DICTATE_REMEDIATE_TAG)).toBe(false);
|
||||||
|
expect(result.reply).toContain("都补上了");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("puts reply_user guidance early in the fill prompt and does not mention a fallback reply step", async () => {
|
it("puts reply_user guidance early in the fill prompt and does not mention a fallback reply step", async () => {
|
||||||
|
|||||||
@@ -50,8 +50,10 @@ describe("listen-serial state", () => {
|
|||||||
expect(state.outline).toHaveLength(3);
|
expect(state.outline).toHaveLength(3);
|
||||||
expect(state.outline[2]?.payoff).toBe(true);
|
expect(state.outline[2]?.payoff).toBe(true);
|
||||||
expect(unreadChars(state)).toBe(100);
|
expect(unreadChars(state)).toBe(100);
|
||||||
const view = toListenView(state);
|
const view = toListenView(state, true);
|
||||||
expect(view.unreadChars).toBe(100);
|
expect(view.unreadChars).toBe(100);
|
||||||
|
expect(view.playActive).toBe(true);
|
||||||
|
expect(toListenView(state).playActive).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applyRedirectCut drops unconsumed sections and keeps consumed", () => {
|
it("applyRedirectCut drops unconsumed sections and keeps consumed", () => {
|
||||||
|
|||||||
+103
-24
@@ -134,7 +134,9 @@ export function canOfferSaveProduct(view) {
|
|||||||
|
|
||||||
export function canOfferEnterPlay(view) {
|
export function canOfferEnterPlay(view) {
|
||||||
if (!view || isPlayView(view)) return false;
|
if (!view || isPlayView(view)) return false;
|
||||||
if (view.creationMode === "listen_serial") return false;
|
if (view.creationMode === "listen_serial") {
|
||||||
|
return Boolean(view.playReady);
|
||||||
|
}
|
||||||
return canOfferSaveProduct(view) || Boolean(view.hasProduct);
|
return canOfferSaveProduct(view) || Boolean(view.hasProduct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1618,10 +1620,23 @@ function syncCoordRailChrome() {
|
|||||||
const title = document.getElementById("coord-rail-title");
|
const title = document.getElementById("coord-rail-title");
|
||||||
const products = coordRailMode === "products";
|
const products = coordRailMode === "products";
|
||||||
const listen = coordRailMode === "listen";
|
const listen = coordRailMode === "listen";
|
||||||
|
const listenPlay = listen && rail?.dataset.listenPlay === "1";
|
||||||
document.body.classList.toggle("coord-rail-collapsed", coordRailCollapsed);
|
document.body.classList.toggle("coord-rail-collapsed", coordRailCollapsed);
|
||||||
document.body.dataset.coordRailMode = coordRailMode;
|
document.body.dataset.coordRailMode = coordRailMode;
|
||||||
const shortLabel = listen ? "正文" : products ? "产物" : "对话";
|
const shortLabel = listen
|
||||||
const longLabel = listen ? "大纲与正文" : products ? "已持久化" : "此前对话";
|
? listenPlay
|
||||||
|
? "正文"
|
||||||
|
: "本局"
|
||||||
|
: products
|
||||||
|
? "产物"
|
||||||
|
: "对话";
|
||||||
|
const longLabel = listen
|
||||||
|
? listenPlay
|
||||||
|
? "大纲与正文"
|
||||||
|
: "本局设定"
|
||||||
|
: products
|
||||||
|
? "已持久化"
|
||||||
|
: "此前对话";
|
||||||
if (toggleLabel) toggleLabel.textContent = shortLabel;
|
if (toggleLabel) toggleLabel.textContent = shortLabel;
|
||||||
if (title) title.textContent = longLabel;
|
if (title) title.textContent = longLabel;
|
||||||
if (toggle) {
|
if (toggle) {
|
||||||
@@ -5736,10 +5751,11 @@ function getRollbackMessageId(messages, messageId) {
|
|||||||
|
|
||||||
/** @returns {"speak"|"answer"|"review"|"busy"|"dictate"|"listen"|null} */
|
/** @returns {"speak"|"answer"|"review"|"busy"|"dictate"|"listen"|null} */
|
||||||
function resolveDesignSurface(view, loading) {
|
function resolveDesignSurface(view, loading) {
|
||||||
|
// 听书:创作/播放都用同一壳;播放层右侧才写大纲与正文
|
||||||
|
if (view.creationMode === "listen_serial") return "listen";
|
||||||
if (isPlayView(view)) return null;
|
if (isPlayView(view)) return null;
|
||||||
if (view.phase === "done") return null;
|
if (view.phase === "done") return null;
|
||||||
// 听书 / 转述:主面始终是对话流;右侧为大纲正文或产物
|
// 转述:主面始终是对话流;右侧为产物
|
||||||
if (view.creationMode === "listen_serial") return "listen";
|
|
||||||
if (view.creationMode === "dictate") return "dictate";
|
if (view.creationMode === "dictate") return "dictate";
|
||||||
const busy = Boolean(loading || (view.phase === "running" && !view.waitingReason));
|
const busy = Boolean(loading || (view.phase === "running" && !view.waitingReason));
|
||||||
if (busy) return "busy";
|
if (busy) return "busy";
|
||||||
@@ -5973,20 +5989,24 @@ function renderSpeakWorkspace(view) {
|
|||||||
title = displayCreationModeLabel("listen_serial");
|
title = displayCreationModeLabel("listen_serial");
|
||||||
const ls = view.listenSerial;
|
const ls = view.listenSerial;
|
||||||
const phase = ls?.phase;
|
const phase = ls?.phase;
|
||||||
if (phase === "awaiting_confirm") {
|
if (ls?.playActive) {
|
||||||
hint = "回复「确认」开始生成大纲与正文;或继续改需求。";
|
if (phase === "soft_stop") {
|
||||||
} else if (phase === "soft_stop") {
|
|
||||||
hint = "本段已兑现。输入「下一波」或「收工」,或一句意见改向。";
|
hint = "本段已兑现。输入「下一波」或「收工」,或一句意见改向。";
|
||||||
} else if (phase === "running") {
|
|
||||||
hint =
|
|
||||||
view.hints?.[0] ||
|
|
||||||
wr.message ||
|
|
||||||
"说「继续」补货;不满意直接一句意见改后面。";
|
|
||||||
} else {
|
} else {
|
||||||
hint =
|
hint =
|
||||||
view.hints?.[0] ||
|
view.hints?.[0] ||
|
||||||
wr.message ||
|
wr.message ||
|
||||||
"用几句话描述题材与爽点;右侧是大纲与正文。";
|
"说「继续」补货;不满意直接一句意见改后面。";
|
||||||
|
}
|
||||||
|
} else if (phase === "awaiting_confirm") {
|
||||||
|
hint = "回复「确认」锁定本局;确认后点「播放」写大纲与正文。";
|
||||||
|
} else if (phase === "ready") {
|
||||||
|
hint = "本局已确认。点「播放」生成大纲与正文;也可继续改爽点。";
|
||||||
|
} else {
|
||||||
|
hint =
|
||||||
|
view.hints?.[0] ||
|
||||||
|
wr.message ||
|
||||||
|
"用几句话描述题材与爽点;确认后进「播放」写大纲与正文。";
|
||||||
}
|
}
|
||||||
} else if (view.creationMode === "dictate") {
|
} else if (view.creationMode === "dictate") {
|
||||||
title = displayCreationModeLabel("dictate");
|
title = displayCreationModeLabel("dictate");
|
||||||
@@ -6029,7 +6049,13 @@ function renderSpeakWorkspace(view) {
|
|||||||
}
|
}
|
||||||
<button type="button" class="btn${
|
<button type="button" class="btn${
|
||||||
canOfferSaveProduct(view) ? "" : " btn-primary"
|
canOfferSaveProduct(view) ? "" : " btn-primary"
|
||||||
}" data-enter-play>${view.hasProduct ? "用产物开玩" : "开玩"}</button>
|
}" data-enter-play>${
|
||||||
|
view.creationMode === "listen_serial"
|
||||||
|
? "播放"
|
||||||
|
: view.hasProduct
|
||||||
|
? "用产物开玩"
|
||||||
|
: "开玩"
|
||||||
|
}</button>
|
||||||
</div>`
|
</div>`
|
||||||
: "";
|
: "";
|
||||||
return `<section class="workspace-intent">
|
return `<section class="workspace-intent">
|
||||||
@@ -6174,7 +6200,7 @@ function fillCoordRailProducts(view) {
|
|||||||
function renderListenSerialRail(view) {
|
function renderListenSerialRail(view) {
|
||||||
const ls = view.listenSerial;
|
const ls = view.listenSerial;
|
||||||
if (!ls) {
|
if (!ls) {
|
||||||
return `<p class="coord-empty">确认开书后,这里显示大纲与正文缓冲。</p>`;
|
return `<p class="coord-empty">开书后,这里显示本局爽点与世界。</p>`;
|
||||||
}
|
}
|
||||||
const brief = [
|
const brief = [
|
||||||
ls.thrill ? `<p class="listen-brief"><strong>爽点</strong> ${esc(ls.thrill)}</p>` : "",
|
ls.thrill ? `<p class="listen-brief"><strong>爽点</strong> ${esc(ls.thrill)}</p>` : "",
|
||||||
@@ -6183,6 +6209,28 @@ function renderListenSerialRail(view) {
|
|||||||
? `<p class="listen-brief"><strong>结构</strong> ${esc(ls.structure)}</p>`
|
? `<p class="listen-brief"><strong>结构</strong> ${esc(ls.structure)}</p>`
|
||||||
: "",
|
: "",
|
||||||
].join("");
|
].join("");
|
||||||
|
|
||||||
|
// 创作层:只展示 brief,大纲/正文/TTS 在播放层
|
||||||
|
if (!ls.playActive) {
|
||||||
|
const ready = Boolean(view.playReady);
|
||||||
|
const phaseHint =
|
||||||
|
ls.phase === "awaiting_confirm"
|
||||||
|
? "回复「确认」锁定本局。"
|
||||||
|
: ls.phase === "ready"
|
||||||
|
? "本局已确认,可进播放写大纲与正文。"
|
||||||
|
: "先定爽点与世界,确认后再播放。";
|
||||||
|
return `<div class="listen-rail listen-rail--design">
|
||||||
|
<p class="listen-meta">创作 · 阶段 ${esc(ls.phase)}</p>
|
||||||
|
${brief || `<p class="coord-empty">尚无本局设定</p>`}
|
||||||
|
<p class="listen-rail-hint">${esc(phaseHint)}</p>
|
||||||
|
<div class="listen-rail-actions">
|
||||||
|
<button type="button" class="btn-sm btn-primary" data-enter-play ${
|
||||||
|
ready ? "" : "disabled"
|
||||||
|
} title="${ready ? "进入播放,生成大纲与正文" : "确认本局后可播放"}">播放</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
const outline = (ls.outline ?? [])
|
const outline = (ls.outline ?? [])
|
||||||
.map((o) => {
|
.map((o) => {
|
||||||
const mark = o.status === "done" ? "✓" : o.status === "cut" ? "×" : "·";
|
const mark = o.status === "done" ? "✓" : o.status === "cut" ? "×" : "·";
|
||||||
@@ -6206,12 +6254,13 @@ function renderListenSerialRail(view) {
|
|||||||
const consumed = Number(ls.consumedChars) || 0;
|
const consumed = Number(ls.consumedChars) || 0;
|
||||||
const total = (ls.sections ?? []).reduce((n, s) => n + String(s.text || "").length, 0);
|
const total = (ls.sections ?? []).reduce((n, s) => n + String(s.text || "").length, 0);
|
||||||
const hasSections = (ls.sections ?? []).length > 0;
|
const hasSections = (ls.sections ?? []).length > 0;
|
||||||
return `<div class="listen-rail">
|
return `<div class="listen-rail listen-rail--play">
|
||||||
<p class="listen-meta">未读约 ${unread} 字 / 缓冲目标 ${target} 字 · 阶段 ${esc(ls.phase)} · Edge TTS</p>
|
<p class="listen-meta">未读约 ${unread} 字 / 缓冲目标 ${target} 字 · 阶段 ${esc(ls.phase)} · Edge TTS</p>
|
||||||
${brief}
|
${brief}
|
||||||
${outline ? `<ul class="listen-outline">${outline}</ul>` : ""}
|
${outline ? `<ul class="listen-outline">${outline}</ul>` : `<p class="coord-empty">正在准备大纲…</p>`}
|
||||||
<div class="listen-sections">${sections || `<p class="coord-empty">尚无正文</p>`}</div>
|
<div class="listen-sections">${sections || `<p class="coord-empty">尚无正文</p>`}</div>
|
||||||
<div class="listen-rail-actions">
|
<div class="listen-rail-actions">
|
||||||
|
<button type="button" class="btn-sm" data-listen-leave-design="1" title="回到创作改爽点">回创作</button>
|
||||||
<button type="button" class="btn-sm" data-listen-tts-chain="1" ${hasSections ? "" : "disabled"} title="从第一节连续播放">连续听</button>
|
<button type="button" class="btn-sm" data-listen-tts-chain="1" ${hasSections ? "" : "disabled"} title="从第一节连续播放">连续听</button>
|
||||||
<button type="button" class="btn-sm" data-listen-tts-stop="1" title="停止播放">停</button>
|
<button type="button" class="btn-sm" data-listen-tts-stop="1" title="停止播放">停</button>
|
||||||
<button type="button" class="btn-sm" data-listen-consume="all" ${total <= consumed ? "disabled" : ""}>标记全部已读</button>
|
<button type="button" class="btn-sm" data-listen-consume="all" ${total <= consumed ? "disabled" : ""}>标记全部已读</button>
|
||||||
@@ -6229,12 +6278,24 @@ function fillCoordRailListen(view, handlers) {
|
|||||||
coordRailMode = "listen";
|
coordRailMode = "listen";
|
||||||
coordRailCollapsed = false;
|
coordRailCollapsed = false;
|
||||||
}
|
}
|
||||||
|
const playActive = Boolean(view.listenSerial?.playActive);
|
||||||
|
coordRail.dataset.listenPlay = playActive ? "1" : "0";
|
||||||
coordRail.hidden = false;
|
coordRail.hidden = false;
|
||||||
if (drawerBody) {
|
if (drawerBody) {
|
||||||
drawerBody.innerHTML = renderListenSerialRail(view);
|
drawerBody.innerHTML = renderListenSerialRail(view);
|
||||||
if (!drawerBody.dataset.listenConsumeWired) {
|
if (!drawerBody.dataset.listenConsumeWired) {
|
||||||
drawerBody.dataset.listenConsumeWired = "1";
|
drawerBody.dataset.listenConsumeWired = "1";
|
||||||
drawerBody.addEventListener("click", (e) => {
|
drawerBody.addEventListener("click", (e) => {
|
||||||
|
const enterBtn = e.target.closest("[data-enter-play]");
|
||||||
|
if (enterBtn && drawerBody.contains(enterBtn) && !enterBtn.disabled) {
|
||||||
|
handlers?.onEnterPlay?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const leaveBtn = e.target.closest("[data-listen-leave-design]");
|
||||||
|
if (leaveBtn && drawerBody.contains(leaveBtn)) {
|
||||||
|
handlers?.onLeaveListenPlay?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const stopBtn = e.target.closest("[data-listen-tts-stop]");
|
const stopBtn = e.target.closest("[data-listen-tts-stop]");
|
||||||
if (stopBtn && drawerBody.contains(stopBtn)) {
|
if (stopBtn && drawerBody.contains(stopBtn)) {
|
||||||
handlers?.onListenStop?.();
|
handlers?.onListenStop?.();
|
||||||
@@ -6270,8 +6331,14 @@ function fillCoordRailListen(view, handlers) {
|
|||||||
}
|
}
|
||||||
const n = view.listenSerial?.sections?.length ?? 0;
|
const n = view.listenSerial?.sections?.length ?? 0;
|
||||||
if (drawerCount) {
|
if (drawerCount) {
|
||||||
|
if (playActive) {
|
||||||
drawerCount.textContent = n ? String(n) : "";
|
drawerCount.textContent = n ? String(n) : "";
|
||||||
drawerCount.title = n ? `${n} 节正文` : "尚无正文";
|
drawerCount.title = n ? `${n} 节正文` : "尚无正文";
|
||||||
|
} else {
|
||||||
|
const hasBrief = Boolean(view.listenSerial?.thrill || view.listenSerial?.world);
|
||||||
|
drawerCount.textContent = hasBrief ? "·" : "";
|
||||||
|
drawerCount.title = hasBrief ? "本局设定" : "尚无本局设定";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
syncCoordRailChrome();
|
syncCoordRailChrome();
|
||||||
}
|
}
|
||||||
@@ -6282,17 +6349,19 @@ function fillCoordRail(visible, view, handlers) {
|
|||||||
const drawerCount = document.getElementById("coord-drawer-count");
|
const drawerCount = document.getElementById("coord-drawer-count");
|
||||||
if (!coordRail) return;
|
if (!coordRail) return;
|
||||||
|
|
||||||
if (view.creationMode === "listen_serial" && !isPlayView(view)) {
|
if (view.creationMode === "listen_serial") {
|
||||||
fillCoordRailListen(view, handlers);
|
fillCoordRailListen(view, handlers);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转述产物栏只在创作层;游玩层与配方开玩一样走回合历史
|
// 转述产物栏只在创作层;游玩层与配方开玩一样走回合历史
|
||||||
if (view.creationMode === "dictate" && !isPlayView(view)) {
|
if (view.creationMode === "dictate" && !isPlayView(view)) {
|
||||||
|
delete coordRail.dataset.listenPlay;
|
||||||
fillCoordRailProducts(view);
|
fillCoordRailProducts(view);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delete coordRail.dataset.listenPlay;
|
||||||
if (coordRailMode !== "history") {
|
if (coordRailMode !== "history") {
|
||||||
coordRailMode = "history";
|
coordRailMode = "history";
|
||||||
coordRailCollapsed = true;
|
coordRailCollapsed = true;
|
||||||
@@ -6553,9 +6622,13 @@ export function renderMessageFeed(view, loading, handlers = {}) {
|
|||||||
surface === "dictate" && view.selectedRecipe?.name
|
surface === "dictate" && view.selectedRecipe?.name
|
||||||
? esc(view.selectedRecipe.name)
|
? esc(view.selectedRecipe.name)
|
||||||
: "";
|
: "";
|
||||||
const kicker = displayCreationModeLabel(
|
const listenPlay = surface === "listen" && Boolean(view.listenSerial?.playActive);
|
||||||
surface === "listen" ? "listen_serial" : "dictate",
|
const kicker =
|
||||||
);
|
surface === "listen"
|
||||||
|
? listenPlay
|
||||||
|
? "听书播放"
|
||||||
|
: displayCreationModeLabel("listen_serial")
|
||||||
|
: displayCreationModeLabel("dictate");
|
||||||
const head = recipeName
|
const head = recipeName
|
||||||
? `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">${kicker}</span><span class="dictate-stage-recipe">配方 · ${recipeName}</span></header>`
|
? `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">${kicker}</span><span class="dictate-stage-recipe">配方 · ${recipeName}</span></header>`
|
||||||
: `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">${kicker}</span></header>`;
|
: `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">${kicker}</span></header>`;
|
||||||
@@ -6566,7 +6639,9 @@ export function renderMessageFeed(view, loading, handlers = {}) {
|
|||||||
appendMessagesToFeed(dictateFeed, visible, view, handlers, activeQuestions, {
|
appendMessagesToFeed(dictateFeed, visible, view, handlers, activeQuestions, {
|
||||||
emptyText:
|
emptyText:
|
||||||
surface === "listen"
|
surface === "listen"
|
||||||
? "用几句话描述题材与爽点。确认后我会写大纲和正文;右侧可看缓冲与大纲。"
|
? listenPlay
|
||||||
|
? "右侧是大纲与正文。说「继续」补货,或一句意见改向。"
|
||||||
|
: "用几句话描述题材与爽点。确认本局后点「播放」写大纲与正文。"
|
||||||
: "直接说你想要的体验与设定。我会整理进右侧产物,并在回复里跟你确认、建议补充。",
|
: "直接说你想要的体验与设定。我会整理进右侧产物,并在回复里跟你确认、建议补充。",
|
||||||
loading,
|
loading,
|
||||||
});
|
});
|
||||||
@@ -6578,7 +6653,11 @@ export function renderMessageFeed(view, loading, handlers = {}) {
|
|||||||
const label =
|
const label =
|
||||||
live?.label ??
|
live?.label ??
|
||||||
view.focus?.action ??
|
view.focus?.action ??
|
||||||
(surface === "listen" ? "连载中" : "整理中");
|
(surface === "listen"
|
||||||
|
? listenPlay
|
||||||
|
? "写稿中"
|
||||||
|
: "定局中"
|
||||||
|
: "整理中");
|
||||||
pending.innerHTML = `
|
pending.innerHTML = `
|
||||||
<div class="msg-bubble">
|
<div class="msg-bubble">
|
||||||
<header class="msg-head"><span class="msg-tag">${esc(label)}</span><span class="msg-live-indicator">流式输出中</span>${renderRetryRunButton()}</header>
|
<header class="msg-head"><span class="msg-tag">${esc(label)}</span><span class="msg-live-indicator">流式输出中</span>${renderRetryRunButton()}</header>
|
||||||
|
|||||||
+169
-36
@@ -10,7 +10,12 @@ import {
|
|||||||
} from "./agent-ui.js";
|
} from "./agent-ui.js";
|
||||||
import { downloadMarkdown, sessionToMarkdown } from "./export.js";
|
import { downloadMarkdown, sessionToMarkdown } from "./export.js";
|
||||||
import { renderIntakePanel } from "./intake-ui.js";
|
import { renderIntakePanel } from "./intake-ui.js";
|
||||||
import { displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js";
|
import {
|
||||||
|
displayCreationModeLabel,
|
||||||
|
displaySkillPackLabel,
|
||||||
|
isFlowPlanReview,
|
||||||
|
reviewComposerCopy,
|
||||||
|
} from "./display-labels.js";
|
||||||
import {
|
import {
|
||||||
clearQuestionCardState,
|
clearQuestionCardState,
|
||||||
collectQuestionAnswers,
|
collectQuestionAnswers,
|
||||||
@@ -236,6 +241,33 @@ function resolveUserTask(view, loading) {
|
|||||||
const hasQuestions = Boolean(getActiveQuestions(view)?.questions?.length);
|
const hasQuestions = Boolean(getActiveQuestions(view)?.questions?.length);
|
||||||
const moduleOpening = isModuleOpeningWaiting(view);
|
const moduleOpening = isModuleOpeningWaiting(view);
|
||||||
|
|
||||||
|
if (view.creationMode === "listen_serial") {
|
||||||
|
const ls = view.listenSerial;
|
||||||
|
if (ls?.playActive) {
|
||||||
|
return {
|
||||||
|
id: "speak",
|
||||||
|
label: "播放",
|
||||||
|
title: ls.phase === "soft_stop" ? "本段已兑现" : "听书播放",
|
||||||
|
hint:
|
||||||
|
ls.phase === "soft_stop"
|
||||||
|
? "「下一波」或「收工」,或一句意见改向。"
|
||||||
|
: "说「继续」补货;不满意直接一句意见。",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const phase = ls?.phase;
|
||||||
|
return {
|
||||||
|
id: "speak",
|
||||||
|
label: "听书",
|
||||||
|
title: displayCreationModeLabel("listen_serial"),
|
||||||
|
hint:
|
||||||
|
phase === "awaiting_confirm"
|
||||||
|
? "回复「确认」锁定本局;确认后点「播放」。"
|
||||||
|
: phase === "ready"
|
||||||
|
? "点「播放」写大纲与正文;也可继续改爽点。"
|
||||||
|
: "用几句话描述题材与爽点。",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (isPlayView(view)) {
|
if (isPlayView(view)) {
|
||||||
return {
|
return {
|
||||||
id: "speak",
|
id: "speak",
|
||||||
@@ -492,6 +524,31 @@ function resolveComposer(view, loading) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (view.creationMode === "listen_serial") {
|
||||||
|
const send = view.actions?.find((a) => a.type === "send_message");
|
||||||
|
const ls = view.listenSerial;
|
||||||
|
let placeholder = "描述你想写什么…";
|
||||||
|
if (ls?.playActive) {
|
||||||
|
placeholder =
|
||||||
|
ls.phase === "soft_stop"
|
||||||
|
? "下一波 / 收工 / 一句改向…"
|
||||||
|
: "继续 / 一句意见改向…";
|
||||||
|
} else if (ls?.phase === "awaiting_confirm") {
|
||||||
|
placeholder = "确认 / 或继续改需求…";
|
||||||
|
} else if (ls?.phase === "ready") {
|
||||||
|
placeholder = "继续改爽点,或点右侧「播放」…";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: "input",
|
||||||
|
task,
|
||||||
|
taskTitle: task.title || displayCreationModeLabel("listen_serial"),
|
||||||
|
taskHint: task.hint,
|
||||||
|
placeholder: send?.placeholder ?? placeholder,
|
||||||
|
hint: null,
|
||||||
|
submitLabel: "发送",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (isPlayView(view)) {
|
if (isPlayView(view)) {
|
||||||
const send = view.actions?.find((a) => a.type === "send_message");
|
const send = view.actions?.find((a) => a.type === "send_message");
|
||||||
return {
|
return {
|
||||||
@@ -1990,11 +2047,22 @@ function showEnterPicker(bookId, anchorEl) {
|
|||||||
el.classList.remove("is-picking");
|
el.classList.remove("is-picking");
|
||||||
});
|
});
|
||||||
anchorEl.classList.add("is-picking");
|
anchorEl.classList.add("is-picking");
|
||||||
|
const book = books.find((b) => b.id === bookId);
|
||||||
|
const listen = bookCreationMode(book) === "listen_serial";
|
||||||
|
const designBtn = pop.querySelector("[data-enter=design]");
|
||||||
|
if (designBtn) designBtn.textContent = "创作";
|
||||||
const playBtn = pop.querySelector("[data-enter=play]");
|
const playBtn = pop.querySelector("[data-enter=play]");
|
||||||
const ready = bookPlayReady(bookId);
|
const ready = bookPlayReady(bookId);
|
||||||
if (playBtn) {
|
if (playBtn) {
|
||||||
playBtn.disabled = !ready;
|
playBtn.textContent = listen ? "播放" : "游玩";
|
||||||
playBtn.title = ready ? "进入游玩" : "验收后可游玩";
|
playBtn.disabled = listen ? false : !ready;
|
||||||
|
playBtn.title = listen
|
||||||
|
? ready
|
||||||
|
? "进入播放,写大纲与正文"
|
||||||
|
: "确认本局后可播放(未确认会提示)"
|
||||||
|
: ready
|
||||||
|
? "进入游玩"
|
||||||
|
: "验收后可游玩";
|
||||||
}
|
}
|
||||||
pop.hidden = false;
|
pop.hidden = false;
|
||||||
const rect = anchorEl.getBoundingClientRect();
|
const rect = anchorEl.getBoundingClientRect();
|
||||||
@@ -2149,6 +2217,9 @@ function getNavBook() {
|
|||||||
|
|
||||||
function catalogBookMeta(book) {
|
function catalogBookMeta(book) {
|
||||||
if (bookCreationMode(book) === "listen_serial") {
|
if (bookCreationMode(book) === "listen_serial") {
|
||||||
|
if (lastView?.bookId === book.id && lastView.playReady) {
|
||||||
|
return lastView.playLayerActive ? "播放中" : "可播放";
|
||||||
|
}
|
||||||
return "听书";
|
return "听书";
|
||||||
}
|
}
|
||||||
const saves = playSavesByBook.get(book.id);
|
const saves = playSavesByBook.get(book.id);
|
||||||
@@ -2582,7 +2653,6 @@ function renderBookList() {
|
|||||||
|
|
||||||
for (const book of modeBooks) {
|
for (const book of modeBooks) {
|
||||||
const selected = selectedBookIds.has(book.id);
|
const selected = selectedBookIds.has(book.id);
|
||||||
const listen = bookCreationMode(book) === "listen_serial";
|
|
||||||
list.appendChild(
|
list.appendChild(
|
||||||
renderExplorerRow({
|
renderExplorerRow({
|
||||||
name: book.title,
|
name: book.title,
|
||||||
@@ -2597,10 +2667,6 @@ function renderBookList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (listen) {
|
|
||||||
void openBook(book.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (enterPickerBookId === book.id) {
|
if (enterPickerBookId === book.id) {
|
||||||
hideEnterPicker();
|
hideEnterPicker();
|
||||||
return;
|
return;
|
||||||
@@ -2884,9 +2950,15 @@ async function openBookDesign(bookId) {
|
|||||||
|
|
||||||
async function openCurrentPlay(bookId) {
|
async function openCurrentPlay(bookId) {
|
||||||
hideEnterPicker();
|
hideEnterPicker();
|
||||||
|
const book = books.find((b) => b.id === bookId);
|
||||||
|
const listen = bookCreationMode(book) === "listen_serial";
|
||||||
|
if (!listen) {
|
||||||
const instanceId = playingInstanceIdForBook({ id: bookId });
|
const instanceId = playingInstanceIdForBook({ id: bookId });
|
||||||
if (instanceId) ensureSpecExpanded(bookId, instanceId);
|
if (instanceId) ensureSpecExpanded(bookId, instanceId);
|
||||||
else navigateToBook(bookId);
|
else navigateToBook(bookId);
|
||||||
|
} else {
|
||||||
|
navigateToBook(bookId);
|
||||||
|
}
|
||||||
const alreadyHere =
|
const alreadyHere =
|
||||||
activeBookId === bookId &&
|
activeBookId === bookId &&
|
||||||
isPlayNow() &&
|
isPlayNow() &&
|
||||||
@@ -2900,6 +2972,11 @@ async function openCurrentPlay(bookId) {
|
|||||||
if (activeBookId !== bookId || !sessionId) {
|
if (activeBookId !== bookId || !sessionId) {
|
||||||
await openBook(bookId);
|
await openBook(bookId);
|
||||||
}
|
}
|
||||||
|
if (listen) {
|
||||||
|
activePlaySaveId = null;
|
||||||
|
await enterListenPlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (activePlaySaveId && lastView?.playLayerActive) {
|
if (activePlaySaveId && lastView?.playLayerActive) {
|
||||||
try {
|
try {
|
||||||
const data = await api(
|
const data = await api(
|
||||||
@@ -2923,10 +3000,15 @@ function renderHeader(view, loading) {
|
|||||||
document.title = `${title} · Writing Agent`;
|
document.title = `${title} · Writing Agent`;
|
||||||
$("status-dot").className = `status-dot ${st.cls}`;
|
$("status-dot").className = `status-dot ${st.cls}`;
|
||||||
$("status-text").textContent = st.text;
|
$("status-text").textContent = st.text;
|
||||||
|
const listenMode = view.creationMode === "listen_serial";
|
||||||
const showSavePlay = Boolean(
|
const showSavePlay = Boolean(
|
||||||
view.bookId && view.playReady && view.lifecycleStage === "play" && view.playLayerActive,
|
!listenMode &&
|
||||||
|
view.bookId &&
|
||||||
|
view.playReady &&
|
||||||
|
view.lifecycleStage === "play" &&
|
||||||
|
view.playLayerActive,
|
||||||
);
|
);
|
||||||
const showSaveInstance = Boolean(view.bookId && view.playReady);
|
const showSaveInstance = Boolean(!listenMode && view.bookId && view.playReady);
|
||||||
const btnPlay = $("btn-save-play");
|
const btnPlay = $("btn-save-play");
|
||||||
if (btnPlay) btnPlay.hidden = !showSavePlay;
|
if (btnPlay) btnPlay.hidden = !showSavePlay;
|
||||||
const btnInst = $("btn-save-instance");
|
const btnInst = $("btn-save-instance");
|
||||||
@@ -3030,6 +3112,23 @@ function isStaleUiRequest(seq) {
|
|||||||
return seq !== inflightSeq;
|
return seq !== inflightSeq;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 真值 / 程序投影指纹:旁观改表后即使消息条数不变也要刷正文壳 */
|
||||||
|
function sessionTruthSig(view) {
|
||||||
|
const rows = view?.playAux?.table?.rows;
|
||||||
|
if (Array.isArray(rows) && rows.length) {
|
||||||
|
return rows.map((r) => `${r.key}:${r.rev ?? 0}`).join(",");
|
||||||
|
}
|
||||||
|
const projected = view?.presentationProjection;
|
||||||
|
if (projected && typeof projected === "object") {
|
||||||
|
try {
|
||||||
|
return `p:${JSON.stringify(projected).length}:${Object.keys(projected).sort().join(",")}`;
|
||||||
|
} catch {
|
||||||
|
return "p:?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function sessionStructuralSig(view) {
|
function sessionStructuralSig(view) {
|
||||||
const messages = view?.messages ?? [];
|
const messages = view?.messages ?? [];
|
||||||
const last = messages[messages.length - 1];
|
const last = messages[messages.length - 1];
|
||||||
@@ -3042,6 +3141,8 @@ function sessionStructuralSig(view) {
|
|||||||
view?.phase ?? "",
|
view?.phase ?? "",
|
||||||
view?.waitingReason?.kind ?? "",
|
view?.waitingReason?.kind ?? "",
|
||||||
view?.runActive ? "1" : "0",
|
view?.runActive ? "1" : "0",
|
||||||
|
// 副 LLM 填表落后于主世界出稿;表 rev / 投影变了必须重渲染 present
|
||||||
|
sessionTruthSig(view),
|
||||||
].join("|");
|
].join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3173,13 +3274,23 @@ function startLivePoll() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 结构未变时仍并入真值投影,避免 lastView 卡在旁观改表前的旧壳数据
|
||||||
|
const truthChanged = sessionTruthSig(view) !== sessionTruthSig(lastView);
|
||||||
lastView = {
|
lastView = {
|
||||||
...lastView,
|
...lastView,
|
||||||
liveStream: view.liveStream,
|
liveStream: view.liveStream,
|
||||||
agentThinking: view.agentThinking,
|
agentThinking: view.agentThinking,
|
||||||
runActive: view.runActive,
|
runActive: view.runActive,
|
||||||
dictateProducts: view.dictateProducts ?? lastView.dictateProducts,
|
dictateProducts: view.dictateProducts ?? lastView.dictateProducts,
|
||||||
|
playAux: view.playAux ?? lastView.playAux,
|
||||||
|
presentationProjection:
|
||||||
|
view.presentationProjection ?? lastView.presentationProjection,
|
||||||
};
|
};
|
||||||
|
if (truthChanged) {
|
||||||
|
livePollStructuralSig = sessionStructuralSig(view);
|
||||||
|
renderSession(view, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateLiveStreamPanel(view);
|
updateLiveStreamPanel(view);
|
||||||
} catch {
|
} catch {
|
||||||
livePollFailCount += 1;
|
livePollFailCount += 1;
|
||||||
@@ -3247,6 +3358,7 @@ function renderSession(view, loading = false) {
|
|||||||
onSpawnCreationStep: (moduleName) => runAction("spawn_step", { moduleName }),
|
onSpawnCreationStep: (moduleName) => runAction("spawn_step", { moduleName }),
|
||||||
onLeaveCreationStep: () => runAction("leave_step"),
|
onLeaveCreationStep: () => runAction("leave_step"),
|
||||||
onEnterPlay: () => enterPlayNow(),
|
onEnterPlay: () => enterPlayNow(),
|
||||||
|
onLeaveListenPlay: () => void setLifecycle("design"),
|
||||||
onSaveProduct: () => void saveCurrentInstance(),
|
onSaveProduct: () => void saveCurrentInstance(),
|
||||||
onPatchTable: (tag, cells) => void patchPlayTable(tag, cells),
|
onPatchTable: (tag, cells) => void patchPlayTable(tag, cells),
|
||||||
onSelectOpening: (index, usePersona) => void selectPlayOpening(index, usePersona),
|
onSelectOpening: (index, usePersona) => void selectPlayOpening(index, usePersona),
|
||||||
@@ -3546,6 +3658,10 @@ async function runAction(action, extra = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enterPlayNow() {
|
function enterPlayNow() {
|
||||||
|
if (lastView?.creationMode === "listen_serial") {
|
||||||
|
void enterListenPlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (activeBookId) {
|
if (activeBookId) {
|
||||||
void openCurrentPlay(activeBookId);
|
void openCurrentPlay(activeBookId);
|
||||||
return;
|
return;
|
||||||
@@ -3555,6 +3671,25 @@ function enterPlayNow() {
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enterListenPlay() {
|
||||||
|
if (!sessionId) return;
|
||||||
|
const seq = beginUiRequest();
|
||||||
|
try {
|
||||||
|
renderSession(lastView, true);
|
||||||
|
const view = await api(
|
||||||
|
`/api/sessions/${encodeURIComponent(sessionId)}/listen/play`,
|
||||||
|
{ method: "POST", body: "{}" },
|
||||||
|
);
|
||||||
|
if (isStaleUiRequest(seq)) return;
|
||||||
|
if (activeBookId) navigateToBook(activeBookId);
|
||||||
|
renderSession(view, false);
|
||||||
|
} catch (err) {
|
||||||
|
if (isStaleUiRequest(seq)) return;
|
||||||
|
alert(err.message || "无法进入播放");
|
||||||
|
if (lastView) renderSession({ ...lastView, hints: [err.message] }, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function patchPlayTable(tag, cells) {
|
async function patchPlayTable(tag, cells) {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
try {
|
try {
|
||||||
@@ -3598,6 +3733,13 @@ async function selectPlayOpening(index, usePersona) {
|
|||||||
|
|
||||||
async function setLifecycle(stage, opts) {
|
async function setLifecycle(stage, opts) {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
if (lastView?.creationMode === "listen_serial") {
|
||||||
|
if (stage === "play") {
|
||||||
|
await enterListenPlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// design:离开播放层
|
||||||
|
}
|
||||||
if (stage === lastView?.lifecycleStage) {
|
if (stage === lastView?.lifecycleStage) {
|
||||||
if (stage === "design") return;
|
if (stage === "design") return;
|
||||||
if (
|
if (
|
||||||
@@ -3608,7 +3750,13 @@ async function setLifecycle(stage, opts) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (stage === "play" && !lastView?.playReady && !canOfferEnterPlay(lastView) && !lastView?.hasProduct) {
|
if (
|
||||||
|
stage === "play" &&
|
||||||
|
lastView?.creationMode !== "listen_serial" &&
|
||||||
|
!lastView?.playReady &&
|
||||||
|
!canOfferEnterPlay(lastView) &&
|
||||||
|
!lastView?.hasProduct
|
||||||
|
) {
|
||||||
alert("请先保存产物,或完成收口后再开玩");
|
alert("请先保存产物,或完成收口后再开玩");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3629,7 +3777,9 @@ async function setLifecycle(stage, opts) {
|
|||||||
navigateToBook(activeBookId);
|
navigateToBook(activeBookId);
|
||||||
}
|
}
|
||||||
renderSession(view, false);
|
renderSession(view, false);
|
||||||
if (activeBookId) void fetchPlaySavesForBook(activeBookId, true);
|
if (activeBookId && lastView?.creationMode !== "listen_serial") {
|
||||||
|
void fetchPlaySavesForBook(activeBookId, true);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message || "无法切换阶段");
|
alert(err.message || "无法切换阶段");
|
||||||
if (lastView) renderSession(lastView, false);
|
if (lastView) renderSession(lastView, false);
|
||||||
@@ -3657,26 +3807,16 @@ function resolveNewBookCreationMode(opts = {}) {
|
|||||||
function openNewBookDialog(opts = {}) {
|
function openNewBookDialog(opts = {}) {
|
||||||
$("input-book-title").value = "";
|
$("input-book-title").value = "";
|
||||||
const locked = resolveNewBookCreationMode(opts);
|
const locked = resolveNewBookCreationMode(opts);
|
||||||
const modeField = $("field-creation-mode");
|
if (!locked) {
|
||||||
const modeSel = $("input-creation-mode");
|
alert("请先进入「对话落盘」或「听书连载」文件夹,再新建作品。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const lockedInput = $("input-creation-mode-locked");
|
const lockedInput = $("input-creation-mode-locked");
|
||||||
const desc = $("dialog-new-book-desc");
|
const desc = $("dialog-new-book-desc");
|
||||||
|
|
||||||
if (locked) {
|
|
||||||
if (modeField) modeField.hidden = true;
|
|
||||||
if (lockedInput) lockedInput.value = locked;
|
if (lockedInput) lockedInput.value = locked;
|
||||||
if (modeSel) modeSel.value = locked;
|
|
||||||
if (desc) {
|
if (desc) {
|
||||||
desc.textContent = `将创建到「${creationModeFolderLabel(locked)}」,填写名称即可。`;
|
desc.textContent = `将创建到「${creationModeFolderLabel(locked)}」,填写名称即可。`;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// 仍在目录根:须先选方式,或临时露出下拉
|
|
||||||
if (modeField) modeField.hidden = false;
|
|
||||||
if (lockedInput) lockedInput.value = "";
|
|
||||||
if (modeSel) modeSel.value = "dictate";
|
|
||||||
if (desc) desc.textContent = "先选创作方式,再填写名称。";
|
|
||||||
}
|
|
||||||
|
|
||||||
$("dialog-new-book").showModal();
|
$("dialog-new-book").showModal();
|
||||||
$("input-book-title").focus();
|
$("input-book-title").focus();
|
||||||
}
|
}
|
||||||
@@ -3684,9 +3824,9 @@ function openNewBookDialog(opts = {}) {
|
|||||||
async function createBook() {
|
async function createBook() {
|
||||||
const title = $("input-book-title").value.trim() || "未命名作品";
|
const title = $("input-book-title").value.trim() || "未命名作品";
|
||||||
const locked = ($("input-creation-mode-locked")?.value || "").trim();
|
const locked = ($("input-creation-mode-locked")?.value || "").trim();
|
||||||
|
const fromNav = resolveNewBookCreationMode();
|
||||||
const creationMode =
|
const creationMode =
|
||||||
locked === "listen_serial" ||
|
locked === "listen_serial" || fromNav === "listen_serial"
|
||||||
$("input-creation-mode")?.value === "listen_serial"
|
|
||||||
? "listen_serial"
|
? "listen_serial"
|
||||||
: "dictate";
|
: "dictate";
|
||||||
$("btn-create-book").disabled = true;
|
$("btn-create-book").disabled = true;
|
||||||
@@ -4277,14 +4417,7 @@ $("lifecycle-toggle")?.addEventListener("click", (e) => {
|
|||||||
if (stage === "play") enterPlayNow();
|
if (stage === "play") enterPlayNow();
|
||||||
else if (stage === "design") void setLifecycle("design");
|
else if (stage === "design") void setLifecycle("design");
|
||||||
});
|
});
|
||||||
$("btn-new-book")?.addEventListener("click", () => {
|
$("btn-new-book")?.addEventListener("click", () => openNewBookDialog());
|
||||||
const locked = resolveNewBookCreationMode();
|
|
||||||
if (!locked && sidebarNav.level === "root") {
|
|
||||||
alert("请先进入「对话落盘」或「听书连载」文件夹,再新建作品。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openNewBookDialog(locked ? { presetMode: locked } : {});
|
|
||||||
});
|
|
||||||
$("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close());
|
$("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close());
|
||||||
$("book-enter-pop")?.addEventListener("click", (e) => {
|
$("book-enter-pop")?.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -201,13 +201,6 @@
|
|||||||
名称
|
名称
|
||||||
<input id="input-book-title" type="text" placeholder="未命名作品" maxlength="80" autofocus />
|
<input id="input-book-title" type="text" placeholder="未命名作品" maxlength="80" autofocus />
|
||||||
</label>
|
</label>
|
||||||
<label class="field" id="field-creation-mode" hidden>
|
|
||||||
创作方式
|
|
||||||
<select id="input-creation-mode">
|
|
||||||
<option value="dictate" selected>对话落盘(可玩规格)</option>
|
|
||||||
<option value="listen_serial">听书连载(连续消费)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<input type="hidden" id="input-creation-mode-locked" value="" />
|
<input type="hidden" id="input-creation-mode-locked" value="" />
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<button type="button" class="btn" value="cancel" id="btn-cancel-new">取消</button>
|
<button type="button" class="btn" value="cancel" id="btn-cancel-new">取消</button>
|
||||||
|
|||||||
@@ -1457,6 +1457,12 @@ body.design-workspace[data-design-surface="dictate"] .panel-feed,
|
|||||||
body.design-workspace[data-design-surface="listen"] .panel-feed {
|
body.design-workspace[data-design-surface="listen"] .panel-feed {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.listen-rail-hint {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
opacity: 0.72;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
.listen-rail {
|
.listen-rail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user