import { useEffect, useRef, useState } from "react"; import { Button, Dropdown } from "antd"; import localforage from "localforage"; import type { CanvasAssistantMessage } from "@/types/canvas"; import type { AiConfig } from "@/stores/use-config-store"; import { getActiveUserScope } from "@/lib/user-scope"; import { assertCreativeBriefSpecifications, initialCreativeState, normalizeCreativeProposal, type CreativeAgentState } from "@/lib/creation/creative-agent-state"; import type { CreativeAnswers, CreativeQuote } from "@/lib/creation/creative-agent-contract"; import { CreativeAgentController, type CreativeCanvasAdapter, type CreativeControllerView } from "@/services/creative-agent-controller"; import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; import { CreativeProposalCard, CreativeQuestionCard, CreativeQuoteCard } from "@/components/creation/creative-agent-cards"; import { snapshotCreativePlan } from "@/lib/creation/creative-plan"; export type CanvasCreativeDetail = { kind: "creative-interaction"; input: Record; runId?: string; status?: string; quotes?: CreativeQuote[]; approvedProposalVersion?: number; state: CreativeAgentState; }; export function canvasCreativeDetail(message: CanvasAssistantMessage): CanvasCreativeDetail | undefined { const detail = message.detail as Partial | undefined; return detail?.kind === "creative-interaction" && detail.state?.schemaVersion === 1 ? detail as CanvasCreativeDetail : undefined; } // 历史中的失败方案也按当前模型目录校验,将真实错误反馈给原 Agent,而不是丢掉失败的工具参数。 export function creativeProposalFailure(detail: CanvasCreativeDetail, config: AiConfig): string | undefined { if (!detail.input.proposal || detail.state.proposal || detail.state.questions?.questions.length) return; try { const proposal = normalizeCreativeProposal(detail.input.proposal, "validation", 1, config, detail.state.references); assertCreativeBriefSpecifications(proposal, detail.state.brief); } catch (cause) { return cause instanceof Error ? cause.message : "方案结构未通过校验"; } } type Props = { message: CanvasAssistantMessage; sessionId: string; active: boolean; superseded?: boolean; config: AiConfig; canvas: CreativeCanvasAdapter; onUpdate: (message: CanvasAssistantMessage) => void; onContinue: (text: string) => void; onReview: () => void; onEditPrompt: (text: string) => void; onBusy: (id: string, busy: boolean) => void; }; // 嵌入原助手消息,不拥有输入框、会话列表或模型选择。detail 保存回看快照,run 保存执行事实。 export function CanvasCreativeInteraction(props: Props) { const detail = canvasCreativeDetail(props.message)!; const [view, setView] = useState({ state: detail.state, busy: false, hasControl: false }); const [error, setError] = useState(""); const [answers, setAnswers] = useState(detail.state.answers || {}); const controller = useRef(null); const latest = useRef(props); latest.current = props; const saved = useRef(""); const quoteHistory = useRef(detail.quotes || []); const answersEdited = useRef(false); const presentationAttempted = useRef(false); const scope = getActiveUserScope(); const invoke = (promise?: Promise) => { if (promise) { setError(""); void promise.catch((cause) => { if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(cause instanceof Error ? cause.message : "操作未完成"); }); } }; useEffect(() => { let live = true; presentationAttempted.current = false; const original = canvasCreativeDetail(latest.current.message)!; setView({ state: original.state, busy: false, hasControl: false }); const instance = new CreativeAgentController({ clientKey: `canvas-chat:${props.sessionId}:${props.message.id}`, config: () => latest.current.config, canvas: () => latest.current.canvas, onOpenCanvas: () => setError("当前会话仅操作所在画布,请在对应画布打开历史会话。"), onChange: (next) => { if (!live) return; setView(next); latest.current.onBusy(props.message.id, next.busy); if (!next.run || !latest.current.active) return; if (next.quote) quoteHistory.current = [...quoteHistory.current.filter((quote) => quote.id !== next.quote!.id), next.quote]; const nextDetail: CanvasCreativeDetail = { kind: "creative-interaction", input: original.input, state: next.state, runId: next.run.id, status: next.run.status, quotes: quoteHistory.current, approvedProposalVersion: next.run.approvedProposalVersion }; const signature = JSON.stringify(nextDetail); if (signature !== saved.current) { saved.current = signature; latest.current.onUpdate({ ...latest.current.message, detail: nextDetail }); } }, }); controller.current = instance; const initialize = async () => { if (!original.runId && !props.active) return; await instance.load(original.runId, original.state, !props.active); }; invoke(initialize()); return () => { live = false; instance.dispose(); latest.current.onBusy(props.message.id, false); if (controller.current === instance) controller.current = null; }; }, [props.message.id, props.sessionId, props.canvas.canvasId, props.active, scope]); useEffect(() => { if (!props.active || !view.hasControl || view.busy || view.state.externalInteractionPresented || presentationAttempted.current) return; presentationAttempted.current = true; invoke(controller.current?.presentInteraction(detail.input)); }, [props.active, view.hasControl, view.busy, view.state.externalInteractionPresented]); const question = view.state.questions; const answerKey = `canvas-creative-answers:${scope}:${props.sessionId}:${props.message.id}:${question?.interactionId || ""}`; useEffect(() => { let live = true; answersEdited.current = false; setAnswers(view.state.answers || {}); if (question?.status === "pending") void localforage.getItem(answerKey).then((value) => { if (live && !answersEdited.current && value) setAnswers(value); }).catch(() => undefined); return () => { live = false; }; }, [answerKey, question?.status]); const blocked = !props.active || !view.hasControl || view.busy; const status = view.run?.status || detail.status; const awaitingProposal = status === "waiting_proposal" && !view.state.modificationRequested && !view.state.pendingEdits; const proposalFailure = creativeProposalFailure({ ...detail, state: view.state }, props.config); const modify = (text: string) => { if (!props.active) { props.onEditPrompt(text); return; } invoke(controller.current?.requestModification().then(() => props.onEditPrompt(text))); }; const continueWork = async () => { const instance = controller.current; if (!instance) return; await instance.takeControl(); if (proposalFailure) { props.onContinue("请根据当前真实素材和校验反馈调整方案。能自行修正的请修正,需要我提供信息时请直接提问,仍须确认后才制作。"); return; } if (detail.input.proposal && !view.state.proposal) { await instance.presentInteraction(detail.input, true); return; } if (view.quote && Date.parse(view.quote.expiresAt || "") <= Date.now()) { await instance.refreshQuotes(); return; } if (["running", "paused", "waiting_canvas", "waiting_task"].includes(status || "")) { await instance.resume(); return; } props.onContinue("保留已确认的要求,在已授权范围内继续当前任务,自行处理常规选择和技术问题;确实阻塞时再询问。"); }; const rawError = proposalFailure || error || view.error; const feedback = rawError ? /恢复|连接|网络|接管|执行权|代次/.test(rawError) ? "连接暂时中断,正在恢复。已有作品会保留。" : /报价|费用|价格|过期/.test(rawError) ? "生成费用需要重新确认,继续后会显示最新价格。" : /素材|参考|引用/.test(rawError) ? "参考素材还需要核对,助手会继续处理,必要时请你选择。" : "这一步还没有完成。已有内容已保留,可以继续处理或修改要求。" : undefined; const submitAnswers = async (submitted: CreativeAnswers) => { await controller.current!.answer(submitted, false); await localforage.removeItem(answerKey).catch(() => undefined); const summary = question!.questions.map((item) => { const answer = submitted[item.id]; const options = item.type === "asset" ? view.state.references.map((ref) => ({ id: ref.id, label: ref.title })) : item.options; return `${item.title}:${[...(answer?.selected || []).map((id) => options.find((option) => option.id === id)?.label || id), answer?.custom].filter(Boolean).join("、") || "由助手建议"}`; }).join("\n"); latest.current.onContinue(summary); }; return
{question && ({ id: ref.id, label: ref.title }))} busy={view.busy} disabled={!props.active || !view.hasControl} onModify={() => modify(`我想修改之前的回答(${question.questions.map((item) => item.title).join("、")}):`)} onAnswersChange={(next) => { answersEdited.current = true; setAnswers(next); invoke(localforage.setItem(answerKey, next)); }} onSubmit={submitAnswers} />} {view.state.proposal && invoke(controller.current?.approveProposal())} onModify={() => modify(`请修改方案《${view.state.proposal!.title}》:`)} onRedirect={() => modify("保留已确认的要求和素材,换一个创意方向。")} />} {view.state.pendingEdits &&

将调整 {view.state.pendingEdits.length} 个节点的指定字段。

} {props.active && view.quote && invoke(controller.current?.approvePayment())} onRefresh={() => invoke(controller.current?.refreshQuotes())} />} {(detail.quotes || []).filter((quote) => !props.active || quote.id !== view.quote?.id).map((quote) =>
{quote.approvedQuantity ? "已确认生成" : "历史报价"} · {quote.amountLabel} · {quote.items.length} 项

{quote.basis}

    {quote.items.map((item) =>
  • {item.label} · {item.model} · {item.specification}
  • )}
)} {view.state.media.filter((item) => item.storageKey || item.error).map((item) => { const resource = resourceIdFromStorageKey(item.storageKey), url = resource ? resourceFileUrl(resource) : ""; const video = view.state.proposal?.generationItems.find((entry) => entry.ref === item.ref)?.mode === "video"; return
{view.state.proposal?.workflow.nodes.find((node) => node.ref === item.ref)?.title || item.ref} {url && (video ?
; })} {!props.active && rawError &&
这一步未完成,后续对话中可继续调整

{feedback}

} {props.active &&

{view.busy ? "正在处理,请稍候…" : !view.hasControl ? "正在恢复当前创作,请稍候…" : view.state.modificationRequested ? "在下方告诉我想改哪里,调整后再确认。" : feedback || (view.quote ? "确认费用后,就开始本批制作。" : awaitingProposal ? "看看方案是否符合你的想法,确认后继续。" : question?.status === "pending" ? "选择一个答案,也可以在下方直接补充。" : status === "completed" ? "本阶段已完成。可以查看作品,或告诉我想改哪里。" : "准备好了,可以继续。")}

{view.busy && ["running", "waiting_task", "waiting_canvas"].includes(status || "") ? : !view.busy && view.hasControl && !view.state.modificationRequested && !view.quote && !awaitingProposal && !view.state.pendingEdits && question?.status !== "pending" ? <> : null}
{status === "paused" && 已停止后续制作。已提交的生成可能仍在处理,已有作品不会删除。}
}
; } export function creativeInteractionSeed(history: CanvasAssistantMessage[]): CreativeAgentState { const previous = history.findLast((message) => canvasCreativeDetail(message)); const state = previous ? canvasCreativeDetail(previous)!.state : initialCreativeState(); return { ...initialCreativeState(), scene: state.scene, brief: state.brief, references: state.references, dynamicPlan: snapshotCreativePlan(state), messages: history.filter((message) => message.role === "user" || message.role === "assistant").slice(-30).map((message) => ({ id: message.id, role: message.role as "user" | "assistant", text: message.text })), }; }