182 lines
15 KiB
TypeScript
182 lines
15 KiB
TypeScript
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<string, unknown>;
|
||
runId?: string;
|
||
status?: string;
|
||
quotes?: CreativeQuote[];
|
||
approvedProposalVersion?: number;
|
||
state: CreativeAgentState;
|
||
};
|
||
export function canvasCreativeDetail(message: CanvasAssistantMessage): CanvasCreativeDetail | undefined {
|
||
const detail = message.detail as Partial<CanvasCreativeDetail> | 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<CreativeControllerView>({ state: detail.state, busy: false, hasControl: false });
|
||
const [error, setError] = useState("");
|
||
const [answers, setAnswers] = useState<CreativeAnswers>(detail.state.answers || {});
|
||
const controller = useRef<CreativeAgentController | null>(null);
|
||
const latest = useRef(props); latest.current = props;
|
||
const saved = useRef("");
|
||
const quoteHistory = useRef<CreativeQuote[]>(detail.quotes || []);
|
||
const answersEdited = useRef(false);
|
||
const presentationAttempted = useRef(false);
|
||
const scope = getActiveUserScope();
|
||
const invoke = (promise?: Promise<unknown>) => { 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<CreativeAnswers>(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 <div className="space-y-3" data-canvas-no-zoom data-canvas-wheel-scroll>
|
||
{question && <CreativeQuestionCard request={props.superseded && question.status === "pending" ? { ...question, status: "superseded" } : question} answers={answers} assets={view.state.references.map((ref) => ({ 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 && <CreativeProposalCard proposal={view.state.proposal} archived={!props.active || !awaitingProposal} modifiable={!view.busy} disabled={props.active ? blocked : false} busy={view.busy} onApprove={() => invoke(controller.current?.approveProposal())} onModify={() => modify(`请修改方案《${view.state.proposal!.title}》:`)} onRedirect={() => modify("保留已确认的要求和素材,换一个创意方向。")} />}
|
||
{view.state.pendingEdits && <div className="creative-agent-card"><p>将调整 {view.state.pendingEdits.length} 个节点的指定字段。</p><Button disabled={blocked} onClick={() => invoke(controller.current?.approveEdits())}>确认节点调整</Button></div>}
|
||
{props.active && view.quote && <CreativeQuoteCard quote={view.quote} busy={view.busy} disabled={blocked} onApprove={() => invoke(controller.current?.approvePayment())} onRefresh={() => invoke(controller.current?.refreshQuotes())} />}
|
||
{(detail.quotes || []).filter((quote) => !props.active || quote.id !== view.quote?.id).map((quote) => <details key={quote.id} className="creative-agent-receipt"><summary>{quote.approvedQuantity ? "已确认生成" : "历史报价"} · {quote.amountLabel} · {quote.items.length} 项</summary><p>{quote.basis}</p><ul>{quote.items.map((item) => <li key={item.id}>{item.label} · {item.model} · {item.specification}</li>)}</ul></details>)}
|
||
{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 <div className="creative-agent-card" key={item.ref}>
|
||
<strong>{view.state.proposal?.workflow.nodes.find((node) => node.ref === item.ref)?.title || item.ref}</strong>
|
||
{url && (video ? <video className="w-full max-h-80 object-contain" src={url} controls preload="metadata" /> : <img className="w-full max-h-80 object-contain" src={url} alt={item.ref} />)}
|
||
{item.error && <p className="creative-agent-muted">这项作品还需要处理,已有结果会保留。</p>}
|
||
<div className="creative-agent-actions">{url && <a href={url} target="_blank" rel="noreferrer">查看作品</a>}{props.active && <Dropdown trigger={["click"]} disabled={blocked} menu={{ items: [{ key: "adjust", label: "调整作品" }, { key: "redo", label: "重新生成(费用另行确认)" }], onClick: ({ key }) => key === "redo" ? invoke(controller.current?.redo(item.ref)) : modify(`请调整《${view.state.proposal?.workflow.nodes.find((entry) => entry.ref === item.ref)?.title || item.ref}》,保留其他作品:`) }}><Button type="text" disabled={blocked}>更多</Button></Dropdown>}</div>
|
||
</div>;
|
||
})}
|
||
{!props.active && rawError && <details className="creative-agent-receipt"><summary>这一步未完成,后续对话中可继续调整</summary><p>{feedback}</p></details>}
|
||
{props.active && <div className="creative-agent-next" role="status">
|
||
<p>{view.busy ? "正在处理,请稍候…" : !view.hasControl ? "正在恢复当前创作,请稍候…" : view.state.modificationRequested ? "在下方告诉我想改哪里,调整后再确认。" : feedback || (view.quote ? "确认费用后,就开始本批制作。" : awaitingProposal ? "看看方案是否符合你的想法,确认后继续。" : question?.status === "pending" ? "选择一个答案,也可以在下方直接补充。" : status === "completed" ? "本阶段已完成。可以查看作品,或告诉我想改哪里。" : "准备好了,可以继续。")}</p>
|
||
<div className="creative-agent-actions">
|
||
{view.busy && ["running", "waiting_task", "waiting_canvas"].includes(status || "") ? <Button onClick={() => invoke(controller.current?.pause())}>停止后续制作</Button>
|
||
: !view.busy && view.hasControl && !view.state.modificationRequested && !view.quote && !awaitingProposal && !view.state.pendingEdits && question?.status !== "pending" ? <>
|
||
<Button type="primary" onClick={() => status === "completed" && !feedback ? props.onReview() : invoke(continueWork())}>{status === "completed" && !feedback ? "继续创作" : "继续处理"}</Button>
|
||
<Button onClick={() => modify("我想调整当前创作要求:")}>修改</Button>
|
||
</> : null}
|
||
</div>
|
||
{status === "paused" && <small className="creative-agent-muted">已停止后续制作。已提交的生成可能仍在处理,已有作品不会删除。</small>}
|
||
</div>}
|
||
</div>;
|
||
}
|
||
|
||
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 })),
|
||
};
|
||
}
|