feat(agent): 创作助手 - 完成第一期交互与执行基线

This commit is contained in:
王志伟 committed 2026-09-09 13:00:42 +08:00
1 parent 83d9622fd0
commit e782009db5
54 files changed
+6953 -323

No files matched your search

@@ -0,0 +1,68 @@
import { nanoid } from "nanoid";
import type { AiConfig } from "@/stores/use-config-store";
import type { ResponseInputMessage, ResponseToolCall } from "./api/image";
import { CREATIVE_SCENARIOS, normalizeCreativeQuestions, type CreativeScenarioId } from "@/lib/creation/creative-agent-contract";
import { assertCreativeBriefSpecifications, mergeCreativeBrief, normalizeCreativeProposal, type CreativeAgentState } from "@/lib/creation/creative-agent-state";
import { parseCreativeToolArguments } from "@/lib/creation/creative-agent-tools";
type Reply = { content: string; toolCalls: ResponseToolCall[] };
type Context = { config: AiConfig; state: CreativeAgentState; latestInput: string };
function validate(reply: Reply, context: Context) {
const call = reply.toolCalls.find((item) => item.function.name === "creative_respond");
if (!call) return;
const input = parseCreativeToolArguments(call.function.arguments);
const scene = typeof input.scenario === "string" && Object.hasOwn(CREATIVE_SCENARIOS, input.scenario) ? input.scenario as CreativeScenarioId : context.state.scene;
const brief = mergeCreativeBrief(context.state.brief, input.brief, scene, context.latestInput);
const questions = normalizeCreativeQuestions(input.questions, scene, brief, { interactionId: "validation", revision: 1 }, context.state.references.map((asset) => ({ id: asset.id, label: asset.title })));
if (questions.questions.length) return;
if (Array.isArray(input.questions) && input.questions.length && !input.proposal) throw new Error("问题均为已知或无效字段。请使用已知答案继续规划,不要重复提问;若缺少其他关键信息,请提出具体的新问题。");
if (input.proposal) {
const proposal = normalizeCreativeProposal(input.proposal, "validation", 1, context.config, context.state.references);
assertCreativeBriefSpecifications(proposal, brief);
}
}
function recoveryQuestion(context: Context, error: string): Reply {
const assetIssue = /引用|素材|图片节点/.test(error);
const modelIssue = /模型|能力|尺寸|比例|时长/.test(error);
const field = `recovery.choice-${nanoid(8)}`;
const message = assetIssue ? "这份方案的素材依赖仍未能可靠绑定。已有作品保留,请选择下一步,我会按你的选择继续规划。"
: modelIssue ? "当前可用生成能力还不能可靠满足这份方案。请决定接下来如何安排,已确认的目标会保留。"
: "我尝试修正了方案,但它仍未达到可执行条件。已有内容保留,你可以先完善创意,或补充调整要求后继续。";
const choices = assetIssue
? [{ id: "choose", label: "先让我选择参考素材", description: "助手下一轮列出当前真实可用素材供选择。" }, { id: "text", label: "先完善文字方案", description: "先讨论内容,不创建或生成媒体。" }]
: [{ id: "text", label: "先完善文字方案", description: "保留创意和已确认需求,暂不进入媒体制作。" }, { id: "alternatives", label: "说明可行替代方案", description: "列出差异,由我决定是否修改目标。" }];
const input = { scenario: context.state.scene, message, brief: [], questions: [{ field, type: "single", title: "接下来希望怎样推进?", required: true, allowCustom: true, options: choices }] };
return { content: message, toolCalls: [{ id: `recovery-${nanoid()}`, type: "function", function: { name: "creative_respond", arguments: JSON.stringify(input) } }] };
}
// Correct rejected arguments before presentation. This loop never executes canvas or media tools.
export async function recoverCreativeResponse(initial: Reply, messages: ResponseInputMessage[], context: Context,
request: (messages: ResponseInputMessage[]) => Promise<Reply>, onProgress: (attempt: number, error: string) => void): Promise<Reply> {
let reply = initial;
let protocol = [...messages];
for (let attempt = 0; attempt <= 2; attempt++) {
let failure: string;
try { validate(reply, context); return reply; }
catch (error) { failure = error instanceof Error ? error.message : "交互格式无效"; }
onProgress(attempt + 1, failure);
if (attempt === 2) return recoveryQuestion(context, failure);
protocol = [...protocol,
...reply.toolCalls.map((call): ResponseInputMessage => ({ type: "function_call", call_id: call.id, name: call.function.name, arguments: call.function.arguments, ...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}) })),
...reply.toolCalls.map((call): ResponseInputMessage => ({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ status: call.function.name === "creative_respond" ? "validation_failed" : "cancelled", error: failure, executed: false }) })),
{ role: "user", content: "这是程序校验反馈,不是用户修改需求。未创建节点或消费媒体报价。请自行修复参数,保留已知要求与真实引用;不让用户填写技术字段。缺少真实素材、事实或能力时,用creative_respond.questions主动提问,选项来自当前上下文且允许自由补充。不再仅说‘已修复’或‘请确认’,必须返回可校验的完整方案或可回答问题。仍无法组成媒体方案时可先提供纯文字创意并提问下一步,不虚构媒体配置。" },
];
// Only proposal/question tools are supplied to the repair model; there are no writable tools.
try {
reply = await request(protocol);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw error;
onProgress(3, error instanceof Error ? error.message : "修正请求失败");
return recoveryQuestion(context, failure);
}
reply = { ...reply, toolCalls: reply.toolCalls.filter((call) => call.function.name === "creative_respond") };
if (!reply.toolCalls.length && !reply.content.trim()) return recoveryQuestion(context, failure);
}
return reply;
}