创作降本:取消同轮retry、两轮history;听书创作/播放分层与前端配套。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-08 02:17:25 +08:00
co-authored by Cursor
parent 7185f25dec
commit e0d9db7ec1
20 changed files with 991 additions and 176 deletions
+13 -2
View File
@@ -1,4 +1,8 @@
import type { ChatMessage } from "../llm/client.js";
import {
clipDictateDialogue,
resolveDictateHistoryTurns,
} from "./history-window.js";
import {
effectiveDictateOrder,
sortDictateProducts,
@@ -25,7 +29,7 @@ export type DictateBuiltContext = {
/**
* 创作请求的块顺序:固定规则、更早的对话、全部产物、本轮注入、末尾。
* 选题不带产物正文和本轮注入。回复步只带本轮刚落盘产物与末句用户话。
* 更早对话只追加,最后一轮用户输入放在末尾。
* 更早对话按 historyTurns 裁剪(默认 2 轮),最后一轮用户输入放在末尾。
*/
export function buildDictateContext(params: {
systemPrompt: string;
@@ -34,12 +38,18 @@ export function buildDictateContext(params: {
phase?: DictateContextPhase;
injected?: string;
tail?: string;
/** 更早对话保留轮数;默认见 resolveDictateHistoryTurns */
historyTurns?: number;
}): DictateBuiltContext {
const phase = params.phase ?? "fill";
if (phase === "reply") {
return buildReplyContext(params);
}
const { earlier, lastUser } = splitEarlierDialogue(params.dialogue);
const clipped = clipDictateDialogue(
params.dialogue,
resolveDictateHistoryTurns(params.historyTurns),
);
const { earlier, lastUser } = splitEarlierDialogue(clipped);
const history = formatEarlierDialogue(earlier);
const products =
phase === "fill" ? formatProducts(sortDictateProducts(params.products)) : "";
@@ -138,6 +148,7 @@ export function buildDictateMessages(params: {
phase?: DictateContextPhase;
injected?: string;
tail?: string;
historyTurns?: number;
}): ChatMessage[] {
return buildDictateContext(params).messages;
}
+50
View File
@@ -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];
}
+14
View File
@@ -10,6 +10,20 @@ export {
buildDictateContext,
extractDictateDialogue,
} from "./context.js";
export {
DEFAULT_DICTATE_HISTORY_TURNS,
clipDictateDialogue,
resolveDictateHistoryTurns,
} from "./history-window.js";
export {
DICTATE_REMEDIATE_TAG,
DICTATE_REMEDIATE_SCHEMA,
parseDictateRemediate,
serializeDictateRemediate,
formatRemediateChecklist,
type DictateRemediateDoc,
type DictateRemediateItem,
} from "./remediate.js";
export {
CREATION_INTAKE_MODE_TAG,
DICTATE_MODE_VALUE,
+1
View File
@@ -26,6 +26,7 @@ export function buildDictateSelectPrompt(): string {
return `你只做选题。对照能力目录和用户这句话,调用 select_nodes,选出已经够写的节点。
nodes 里的 module_id 必须来自目录。通常选这段话已经说清的 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。
若末尾 checklist 标了「失败待补」,这些节点必须再次纳入 nodes(可与新选的一起)。
用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或实现机制。
这一步不写产物正文,不回复用户。`;
}
+83
View File
@@ -0,0 +1,83 @@
/**
* 同轮不再 fill-retry:失败项写入「待补」状态,下轮 select/fill 继续补。
* 存 readTag/writeTag,不进产物列表,避免污染黑板正文。
*/
export const DICTATE_REMEDIATE_TAG = "设计.对话落盘待补";
export const DICTATE_REMEDIATE_SCHEMA = "dictate-remediate.v1" as const;
export type DictateRemediateKind = "insert" | "delete";
export type DictateRemediateItem = {
tag: string;
moduleId?: string;
error: string;
kind: DictateRemediateKind;
};
export type DictateRemediateDoc = {
schema: typeof DICTATE_REMEDIATE_SCHEMA;
items: DictateRemediateItem[];
};
export function parseDictateRemediate(raw: string | undefined | null): DictateRemediateDoc {
if (!raw?.trim()) return { schema: DICTATE_REMEDIATE_SCHEMA, items: [] };
try {
const row = JSON.parse(raw) as Record<string, unknown>;
const items = Array.isArray(row.items) ? row.items : [];
const parsed: DictateRemediateItem[] = [];
for (const item of items) {
if (!item || typeof item !== "object") continue;
const rec = item as Record<string, unknown>;
const tag = String(rec.tag ?? "").trim();
if (!tag) continue;
const kind = rec.kind === "delete" ? "delete" : "insert";
const moduleId = String(rec.moduleId ?? rec.module_id ?? "").trim();
const error = String(rec.error ?? "尚未通过").trim() || "尚未通过";
parsed.push({
tag,
error,
kind,
...(moduleId ? { moduleId } : {}),
});
}
return { schema: DICTATE_REMEDIATE_SCHEMA, items: dedupeRemediate(parsed) };
} catch {
return { schema: DICTATE_REMEDIATE_SCHEMA, items: [] };
}
}
export function serializeDictateRemediate(doc: DictateRemediateDoc): string {
return JSON.stringify(
{
schema: DICTATE_REMEDIATE_SCHEMA,
items: dedupeRemediate(doc.items),
},
null,
2,
);
}
export function formatRemediateChecklist(items: readonly DictateRemediateItem[]): string {
if (!items.length) return "";
return [
"失败待补(须继续写入,勿忽略):",
...items.map((item) => {
const kind = item.kind === "delete" ? "删除" : "写入";
const mod = item.moduleId ? ` / ${item.moduleId}` : "";
return `- 〔${kind}〕${item.tag}${mod}:${item.error}`;
}),
].join("\n");
}
function dedupeRemediate(items: DictateRemediateItem[]): DictateRemediateItem[] {
const seen = new Set<string>();
const out: DictateRemediateItem[] = [];
for (const item of items) {
const key = `${item.kind}:${item.tag}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
+159 -43
View File
@@ -1,13 +1,14 @@
/**
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,
* 再在同一条消息前缀上循环 insert。已通过的不再覆盖。
* 再单次 fill 写入。已通过的不再覆盖。
* 同轮不再 fill-retry:失败项记入「失败待补」,下轮继续。
* 没有未通过项时,同一次请求里的 reply_user 才给用户。
* 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。
*/
import { randomUUID } from "node:crypto";
import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js";
import type { AgentDriver } from "../runtime/driver.js";
import type { ChatMessage, LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
import type { LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
import { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
import { buildDictateContext, type DictateSegmentChars } from "./context.js";
import {
@@ -18,6 +19,7 @@ import {
parseCreationPlan,
} from "./creation-plan.js";
import { isDictateOpeningTag } from "./experience-anchor.js";
import { resolveDictateHistoryTurns } from "./history-window.js";
import {
buildDictateReplyPrompt,
buildDictateSelectPrompt,
@@ -32,6 +34,14 @@ import {
DICTATE_MODULE_READ_STATE_TAG,
type DictateModuleReadPayload,
} from "./read-module.js";
import {
DICTATE_REMEDIATE_SCHEMA,
DICTATE_REMEDIATE_TAG,
formatRemediateChecklist,
parseDictateRemediate,
serializeDictateRemediate,
type DictateRemediateItem,
} from "./remediate.js";
import { resolveModuleForInsertTag } from "./insert-feedback.js";
import type { DictateChatTurn, DictateProduct } from "./types.js";
import { isAllowedProductTag } from "./types.js";
@@ -55,6 +65,7 @@ export type PlannedRoundParams = {
listProducts: () => DictateProduct[];
readTag: (tag: string) => string | undefined;
writeTag: (tag: string, content: string) => void;
deleteTag?: (tag: string) => boolean;
prepareOpening?: () => void;
/** 本轮刚写入 tag 的能力回复口径(探测/原则/自评关注) */
lookupInsertFeedback?: (tag: string) => string | undefined;
@@ -68,6 +79,8 @@ export type PlannedRoundParams = {
recipeName?: string;
recipeBrief?: string;
systemPrompt?: string;
/** 更早对话保留轮数;默认 2,见 resolveDictateHistoryTurns */
historyTurns?: number;
};
type PlanInsert = {
@@ -87,10 +100,7 @@ type SelectPlan = {
};
type ItemFailure = { tag: string; error: string };
const INSERT_ATTEMPTS = 4;
type ItemFailure = { tag: string; error: string; moduleId?: string; kind?: "insert" | "delete" };
export function createPlannedDictateDriver(params: PlannedRoundParams): AgentDriver {
return {
@@ -106,15 +116,23 @@ async function runPlannedDictateRound(
apply: Apply,
): Promise<{ text: string; modelCalls: number }> {
const turnId = randomUUID();
const historyTurns = resolveDictateHistoryTurns(params.historyTurns);
const fillSystem = params.systemPrompt?.trim() || buildDictateSystemPrompt();
const remediations = parseDictateRemediate(params.handlers.readTag(DICTATE_REMEDIATE_TAG));
const remediateChecklist = formatRemediateChecklist(remediations.items);
const tailOpts = tailState(params);
const selectTail = formatDictateRoundTail({ ...tailOpts, phase: "select" });
const selectTail = formatDictateRoundTail({
...tailOpts,
phase: "select",
checklist: remediateChecklist,
});
const selectCtx = buildDictateContext({
systemPrompt: buildDictateSelectPrompt(),
products: params.handlers.listProducts(),
dialogue: params.dialogue,
phase: "select",
tail: selectTail,
historyTurns,
});
const selected = await params.llm.completeWithTools(selectCtx.messages, {
caller: "dictate_agent",
@@ -127,7 +145,12 @@ async function runPlannedDictateRound(
if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim());
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0];
const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params);
if (plan.clarify.length >= 2 && plan.inserts.length === 0 && plan.deletes.length === 0) {
mergeRemediationsIntoPlan(plan, remediations.items, params);
if (
plan.clarify.length >= 2 &&
plan.inserts.length === 0 &&
plan.deletes.length === 0
) {
return {
modelCalls: 1,
text: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n"),
@@ -150,10 +173,23 @@ async function runPlannedDictateRound(
);
if (openings.length > 0) params.handlers.prepareOpening?.();
const injected = injectGroup(params, [...rest, ...openings]);
const priorFailures = remediations.items.filter((item) =>
[...rest, ...openings].some((row) => row.tag === item.tag) ||
plan.deletes.includes(item.tag),
);
const tail = formatDictateRoundTail({
...tailOpts,
phase: "fill",
checklist: formatChecklist([...rest, ...openings], plan.deletes, []),
checklist: formatChecklist(
[...rest, ...openings],
plan.deletes,
priorFailures.map((item) => ({
tag: item.tag,
error: item.error,
moduleId: item.moduleId,
kind: item.kind,
})),
),
});
const ctx = buildDictateContext({
systemPrompt: fillSystem,
@@ -162,8 +198,8 @@ async function runPlannedDictateRound(
phase: "fill",
injected: injected.text,
tail,
historyTurns,
});
const messages: ChatMessage[] = [...ctx.messages];
const passed = new Set<string>();
const pending = new Set([...rest, ...openings].map((item) => item.tag));
const pendingDeletes = new Set(plan.deletes);
@@ -172,40 +208,47 @@ async function runPlannedDictateRound(
let lastProse = "";
let modelCalls = 1;
for (let attempt = 0; attempt < INSERT_ATTEMPTS && (pending.size > 0 || pendingDeletes.size > 0); attempt++) {
const step = attempt === 0 ? "fill" : "fill-retry";
const filled = await params.llm.completeWithTools(messages, {
// 单次 fill:失败记入待补,留给下轮,不再同轮 fill-retry。
if (pending.size > 0 || pendingDeletes.size > 0) {
const filled = await params.llm.completeWithTools(ctx.messages, {
caller: "dictate_agent",
label: attempt === 0 ? "对话落盘 填写" : "对话落盘 重写",
label: "对话落盘 填写",
tools: DICTATE_WRITE_LOOP_TOOLS,
toolChoice: "required",
usageDetail: {
...detail(turnId, step, attempt, ctx.segmentChars),
...detail(turnId, "fill", 0, ctx.segmentChars),
plannedTags: [...pending, ...pendingDeletes],
},
usageSink: sinkFor(turnId, step),
usageSink: sinkFor(turnId, "fill"),
});
modelCalls += 1;
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
const prose = filled.content?.trim() ?? "";
if (prose) lastProse = prose;
const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({
id: call.id || `call-${attempt}-${index}`,
id: call.id || `call-0-${index}`,
name: call.name,
arguments: call.arguments,
}));
if (calls.length === 0) break;
const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors);
messages.push(assistantToolMessage(filled.content, calls));
for (const item of settled.results) {
messages.push({ role: "tool", content: item.content, tool_call_id: item.callId });
}
if (pending.size === 0 && pendingDeletes.size === 0) {
replyFromTool = settled.reply;
break;
if (calls.length > 0) {
const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors);
if (pending.size === 0 && pendingDeletes.size === 0) {
replyFromTool = settled.reply;
}
}
}
const moduleByTag = new Map(
[...rest, ...openings].map((item) => [item.tag, item.moduleId] as const),
);
persistRemediations(params, {
pending,
pendingDeletes,
errors,
moduleByTag,
prior: remediations.items,
});
let reply = replyFromTool;
if (
!replyFromTool &&
@@ -224,7 +267,12 @@ async function runPlannedDictateRound(
);
const pendingLines = [...blockedLines, ...failed];
const text = pendingLines.length
? [reply, `尚未完成:${pendingLines.join("、")}。`].filter(Boolean).join("\n")
? [
reply,
`失败待补(下轮继续):${pendingLines.join("、")}。`,
]
.filter(Boolean)
.join("\n")
: reply;
return { text, modelCalls };
}
@@ -303,18 +351,6 @@ async function runLeanReply(
return filled.content?.trim() || fallbackProse;
}
function assistantToolMessage(content: string | null, calls: DriverToolCall[]): ChatMessage {
return {
role: "assistant",
content,
tool_calls: calls.map((call) => ({
id: call.id,
type: "function" as const,
function: { name: call.name, arguments: call.arguments },
})),
};
}
async function settleWriteStep(
params: PlannedRoundParams,
apply: Apply,
@@ -665,17 +701,97 @@ function canonicalProductTag(
function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string {
const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean);
const failedTags = new Set(errors.map((item) => item.tag));
const pendingWrite = group.filter((item) => !failedTags.has(item.tag)).map((item) => item.tag);
const pendingDelete = deletes.filter((tag) => !failedTags.has(tag));
const lines = [
nodes.length ? `第一轮选定的节点:${nodes.join("、")}` : "",
group.length ? `本轮写入:${group.map((item) => item.tag).join("、")}` : "",
deletes.length ? `本轮删除:${deletes.join("、")}` : "",
nodes.length ? `本轮选定的节点:${nodes.join("、")}` : "",
pendingWrite.length ? `待写:${pendingWrite.join("、")}` : "",
pendingDelete.length ? `待删:${pendingDelete.join("、")}` : "",
errors.length
? `只重写未成功项:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}`
? `失败待补:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}`
: "",
"状态说明:待写/待删=本轮目标;失败待补=上轮或本轮未通过,须补上;已落的产物以黑板为准,不要无故整份重写。",
];
return lines.filter(Boolean).join("\n");
}
function mergeRemediationsIntoPlan(
plan: SelectPlan,
items: readonly DictateRemediateItem[],
params: PlannedRoundParams,
): void {
for (const item of items) {
if (item.kind === "delete") {
if (!plan.deletes.includes(item.tag) && isAllowedProductTag(item.tag)) {
plan.deletes.push(item.tag);
}
continue;
}
if (plan.inserts.some((row) => row.tag === item.tag || row.tag === `!${item.tag}`)) {
continue;
}
const canonical = canonicalProductTag(item.tag, item.moduleId ?? "", params.moduleCatalog);
const tag = canonical?.tag ?? item.tag;
if (!isAllowedProductTag(tag) && tag !== VARIABLE_CATALOG_TAG && tag !== VALUE_MAP_TAG) {
continue;
}
plan.inserts.push({
tag,
moduleId: canonical?.moduleId || item.moduleId || "",
entryIds: [],
reason: item.error ? `失败待补:${item.error}` : "失败待补",
});
}
}
function persistRemediations(
params: PlannedRoundParams,
state: {
pending: Set<string>;
pendingDeletes: Set<string>;
errors: Map<string, string>;
moduleByTag: Map<string, string>;
prior: readonly DictateRemediateItem[];
},
): void {
const priorByKey = new Map(
state.prior.map((item) => [`${item.kind}:${item.tag}`, item] as const),
);
const next: DictateRemediateItem[] = [];
for (const tag of state.pending) {
const prev = priorByKey.get(`insert:${tag}`);
next.push({
tag,
kind: "insert",
error: state.errors.get(tag) ?? prev?.error ?? "尚未通过",
...(state.moduleByTag.get(tag) || prev?.moduleId
? { moduleId: state.moduleByTag.get(tag) || prev?.moduleId }
: {}),
});
}
for (const tag of state.pendingDeletes) {
const prev = priorByKey.get(`delete:${tag}`);
next.push({
tag,
kind: "delete",
error: state.errors.get(tag) ?? prev?.error ?? "尚未通过",
});
}
if (next.length === 0) {
if (params.handlers.deleteTag) {
params.handlers.deleteTag(DICTATE_REMEDIATE_TAG);
} else {
params.handlers.writeTag(DICTATE_REMEDIATE_TAG, "");
}
return;
}
params.handlers.writeTag(
DICTATE_REMEDIATE_TAG,
serializeDictateRemediate({ schema: DICTATE_REMEDIATE_SCHEMA, items: next }),
);
}
function asItemList(value: unknown): unknown[] {
if (Array.isArray(value)) return value;
const rec = asRecord(value);
+4
View File
@@ -171,6 +171,8 @@ export async function runDictateTurn(params: {
moduleReadIndex?: ReadonlyMap<string, DictateModuleReadPayload>;
/** catalog 中 repeatable 能力的 artifact 基名(设计.生成规则 等) */
repeatableFamilies?: ReadonlySet<string> | readonly string[];
/** 更早对话保留轮数;默认 2,可用 DICTATE_HISTORY_TURNS 覆盖 */
historyTurns?: number;
driver?: AgentDriver;
}): Promise<DictateTurnResult> {
const driver =
@@ -182,6 +184,7 @@ export async function runDictateTurn(params: {
listProducts: params.handlers.listProducts,
readTag: params.handlers.readTag,
writeTag: params.handlers.writeTag,
deleteTag: params.handlers.deleteTag,
prepareOpening: params.handlers.prepareOpening,
lookupInsertFeedback: params.handlers.lookupInsertFeedback,
onThinkingDelta: params.handlers.onThinkingDelta,
@@ -194,6 +197,7 @@ export async function runDictateTurn(params: {
recipeName: params.recipeName,
recipeBrief: params.recipeBrief,
systemPrompt: params.systemPrompt,
historyTurns: params.historyTurns,
});
const wroteTags: string[] = [];
const deletedTags: string[] = [];