feat(agent): 审美分析 - 接入费用确认与阶段任务恢复

This commit is contained in:
王志伟
2026-09-08 21:20:36 +08:00
parent 757d1dd2c7
commit d0024fbbc6
13 changed files with 432 additions and 27 deletions

View File

@@ -7,7 +7,7 @@ description: 面向图片创作的 AI 视觉分析、问题定位和修改建议
> 状态:MVP 已实现,当前审美标准为 `2026-08-v1`。
执行入口已统一到 `art-critique-execution`:手动分析各阶段通过后台文本任务提交,原图使用资源引用,节点记录阶段任务 ID。一次分析最多 9 个不同模型阶段,不自动重复提交。停止观察不等于取消后台任务,已提交阶段仍可在任务中心查看;自动费用审批及整条管线刷新恢复仍待接入。Agent 当前支持准备输入和读取已有报告,尚未开放自动启动收费分析。
执行入口已统一到 `art-critique-execution`:手动分析和 Agent 发起分析共用原插件面板。各阶段先取得后台真实报价,用户确认当前费用卡后才提交;后续阶段按已有结果另行报价。原图使用资源引用,一轮最多 9 个不同模型阶段。节点记录报价运行、模型及阶段任务 ID,图片和模型未变时可“继续上次分析”,复用已提交任务和结果,不自动付费重试失败阶段。停止观察不等于取消后台任务;关页后尚未提交的阶段不会自动运行。Agent 可通过 `canvas_start_art_critique` 准备报价,通过 `canvas_read_plugin_node` 读取报告;完整合同见[Agent 能力接入](../../../design/agent-capabilities.mdx)。
## 1. 先说结论
@@ -41,6 +41,7 @@ description: 面向图片创作的 AI 视觉分析、问题定位和修改建议
图片节点
-> 创建或连接“AI 审美批改”节点
-> 点击“开始批改”
-> 核对本批费用并确认(后续阶段按实际结果另行报价)
-> AI 看图并返回结构化结果
-> 本地校验和整理结果
-> 在原图上绘制标注

View File

@@ -49,10 +49,16 @@ agentActions: [{
## 当前边界与后续接入
审美分析的手动入口已接入 `art-critique-execution` 统一执行边界。各阶段通过现有后台文本任务运行,原图先准备为资源引用,任务正文不内嵌媒体。最多提交 9 个不同阶段,不自动重复提交同一阶段;节点保存 `stageTaskIds`,任务元数据记录节点、执行和阶段标识。底层 pipeline 支持注入阶段执行器,费用审批可在这一入口接入,不复制审美判断与解析逻辑。
审美分析的手动入口和 `canvas_start_art_critique` 共用原插件面板及 `art-critique-execution`。Agent 准备输入后可打开分析并准备报价;已有当前图片的完整报告直接复用。只有用户明确要求重新分析时传 `retry=true`。打开面板后暂停本轮工具循环,同批后续操作不执行,避免等待费用确认期间重复调用模型;分析完成后用户可让 Agent 读取报告继续。这次接入使用原插件弹窗展示费用卡,尚未把费用卡嵌入聊天消息或完成后自动唤醒会话。
停止观察不代表后台任务取消或未收费。已提交任务保留在任务中心;本批没有自动恢复整条管线或重新提交失败阶段。画布输入改变后停止后续观察,不将旧结果写到新输入。Agent 目前仍不能自动启动该收费分析,须先接通具体阶段报价、用户批准和任务接续。
`ApprovedToolExecution` 复用已有 `creation-runs` 报价、批准和幂等执行接口。各阶段先准备真实请求,再展示费用;确认按钮只批准当时费用卡列出的提交 ID,后来到达的阶段继续等待。首批为场景理解及四个独立视觉分析,后续聚合、定位、复核和提示词按前面的实际结果报价,不预先虚构后续输入。按用量收费的金额是估算,不是封顶;自定义渠道另行标注外部费用。
各阶段通过现有后台文本任务运行,原图先准备为资源引用,任务正文不内嵌媒体。每轮最多 9 个不同阶段;节点保存 `billingRunId`、`lastRunId`、`lastRunModel` 和 `stageTaskIds`,任务元数据记录节点、执行和阶段标识。底层 pipeline 注入批准后的阶段执行器,不复制审美判断与解析逻辑。工具调用仍进入原会话历史;费用记录由服务端创作运行保存,节点保存恢复入口。
关闭面板或停止观察不代表后台任务取消或未收费。重新打开后,在图片、节点和模型未变时可选择“继续上次分析”:同阶段相同请求找回原报价或任务,已提交阶段沿用原任务 ID,已完成结果重新参与本地管线计算,未批准阶段继续等确认。报价过期且未提交时更新报价并重新确认;刷新后旧费用卡不能批准新报价。请求内容变化时后端拒绝复用,需重新开始,不静默新增收费阶段。
执行控制或报价错误会中止其他待确认阶段,不能被当作部分模型结果降级后宣称成功。已提交阶段的模型失败保留原管线的部分报告处理,不自动付费重试;整轮重新分析走新报价。当前仍是浏览器驱动的管线,关页后已提交后台任务继续,尚未提交的后续阶段不会自动运行。画布输入或账号变化、插件停用会阻止继续提交或回写旧结果。
本批接入在线画布助手。首页尚未改造成通用工具循环,本机 Agent 仍使用原技能包交付机制。现有插件只有声明而没有 `agentActions` 时,可发现贡献并创建节点,不宣称能自动执行它的专用分析、编辑或导出。
已补齐画风预设的模型选择与项目同步、分镜专用增删改。审美批改与肖像排查的新分析仍通过插件自己的入口执行:前者涉及多次模型调用,后者涉及本机任务,尚未接入统一 Agent 费用和任务编排。后续继续接通这些执行器,以及首页与本机端的同一能力目录。不得以任意 `metadata` 更新替代具有业务校验和费用语义的执行接口。
已补齐画风预设的模型选择与项目同步、分镜专用增删改,以及 Agent 发起审美分析报价。肖像排查涉及本机任务,仍使用插件自己的执行入口;后续接通该执行器,以及首页与本机端的同一能力目录。不得以任意 `metadata` 更新替代具有业务校验和费用语义的执行接口。

View File

@@ -1,3 +1,4 @@
import { useCanvasTheme } from "@/hooks/use-canvas-theme";
import { Button, Modal, Tag } from "antd";
import { EmptyState } from "@/components/ui/product/empty-state";
import { ArrowLeft, ArrowRight, CheckCircle2, ChevronDown, ChevronRight, Copy, FileText, Image as ImageIcon, LoaderCircle, RefreshCw, Sparkles, Target, X } from "lucide-react";
@@ -22,6 +23,8 @@ import {
} from "@/lib/art-critique/contracts";
import { ART_CRITIQUE_CATEGORY_COLORS, ART_CRITIQUE_SEVERITY_COLORS, layoutArtCritiqueLabels, repairIssueTarget, targetBounds } from "@/lib/art-critique/annotation";
import { executeArtCritique } from "@/services/art-critique-execution";
import { ApprovedToolExecution, type ApprovedToolExecutionState } from "@/services/approved-tool-execution";
import { CreativeQuoteCard } from "@/components/creation/creative-agent-cards";
import { getActiveUserScope } from "@/lib/user-scope";
import { useCopyText } from "@/hooks/use-copy-text";
import { resolveImageUrl } from "@/services/image-storage";
@@ -33,6 +36,9 @@ import { IconButton } from "@/components/ui/base/buttons";
import { Callout } from "@/components/ui/product/callout";
type AiArtCritiqueModalProps = {
startRequestId?: string;
restartRequested?: boolean;
onRunningChange?: (running: boolean) => void;
node: CanvasNodeData | null;
upstreamNodes: CanvasNodeData[];
open: boolean;
@@ -56,16 +62,14 @@ type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
type AiArtCritiqueView = "overview" | "detail";
type ArtCritiquePromptStatus = "ready" | "pending" | "unavailable";
export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdateState }: AiArtCritiqueModalProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdateState, startRequestId, restartRequested, onRunningChange }: AiArtCritiqueModalProps) {
const theme = useCanvasTheme();
const effectiveConfig = useEffectiveConfig();
const selectedCritiqueModel = effectiveConfig.textModel.trim();
const configuredCritiqueModelLabel = selectedCritiqueModel ? modelOptionLabel(effectiveConfig, selectedCritiqueModel) : "未配置文本/视觉理解模型";
const installations = usePluginStore((state) => state.installations);
const input = useMemo(() => upstreamNodes.find(isArtCritiqueImageInput), [upstreamNodes]);
const enabled = usePluginStore((state) => state.pluginStates[ART_CRITIQUE_PLUGIN_ID]?.effectiveEnabled ?? (ART_CRITIQUE_PLUGIN_ID in state.runtimeStatuses ? state.runtimeStatuses[ART_CRITIQUE_PLUGIN_ID] === "enabled" : Boolean(state.installations.find((item) => item.manifest.id === ART_CRITIQUE_PLUGIN_ID)?.enabled)));
const input = useMemo(() => { const images = upstreamNodes.filter(isArtCritiqueImageInput); return images.length === 1 ? images[0] : undefined; }, [upstreamNodes]);
const state = node?.metadata?.artCritique || createDefaultArtCritiqueState();
const installation = installations.find((item) => item.manifest.id === ART_CRITIQUE_PLUGIN_ID);
const enabled = installation?.enabled === true;
const currentFingerprint = input ? artCritiqueSourceFingerprint(input) : "";
const stale = Boolean(state.report && currentFingerprint && state.report.sourceFingerprint !== currentFingerprint);
const visibleState: ArtCritiqueNodeState = stale && state.status !== "running" ? { ...state, status: "stale" } : state;
@@ -79,6 +83,9 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
const [hoveredIssueId, setHoveredIssueId] = useState<string | null>(null);
const [draftReportVisible, setDraftReportVisible] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const approvalRef = useRef<ApprovedToolExecution | null>(null);
const [payment, setPayment] = useState<ApprovedToolExecutionState>({ busy: false });
const startedRequestRef = useRef<string | undefined>(undefined);
const currentRunTargetRef = useRef("");
currentRunTargetRef.current = `${getActiveUserScope()}:${node?.id || ""}:${currentFingerprint}`;
@@ -113,6 +120,7 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
useEffect(() => () => abortRef.current?.abort(), []);
useEffect(() => () => abortRef.current?.abort(), [node?.id, currentFingerprint]);
useEffect(() => { if (!enabled) abortRef.current?.abort(); }, [enabled]);
const issues = visibleState.report?.issues || [];
const reportOptions = visibleState.report?.options || [];
@@ -146,27 +154,34 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
setView("overview");
};
const runReview = async () => {
const runReview = async (resume = false) => {
if (abortRef.current) return;
if (!node || !input) {
setLocalError("请先连接一张已有图片");
setLocalError("请连接且仅连接一张已有图片");
return;
}
if (!enabled) {
setLocalError("插件尚未启用,请先到插件管理中开启 AI 审美批改");
return;
}
if (resume && (!state.billingRunId || state.sourceFingerprint !== artCritiqueSourceFingerprint(input) || state.lastRunModel !== selectedCritiqueModel)) {
setLocalError("原分析的图片或模型已变化,请开始新的分析");
return;
}
setView("overview");
setSelectedIssueId(null);
setHoveredIssueId(null);
setDraftReportVisible(false);
const sourceFingerprint = artCritiqueSourceFingerprint(input);
const runId = `art-critique-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const runId = resume && state.lastRunId ? state.lastRunId : `art-critique-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
let billingRunId = resume ? state.billingRunId : undefined;
const controller = new AbortController();
abortRef.current = controller;
onRunningChange?.(true);
const runTarget = currentRunTargetRef.current;
const runScope = getActiveUserScope();
const publishState = (next: ArtCritiqueNodeState) => {
if (getActiveUserScope() === runScope && currentRunTargetRef.current === runTarget && abortRef.current === controller) onUpdateState(node.id, next);
if (getActiveUserScope() === runScope && currentRunTargetRef.current === runTarget && abortRef.current === controller) onUpdateState(node.id, { ...next, billingRunId, lastRunModel: selectedCritiqueModel });
};
const baseState: ArtCritiqueNodeState = {
...state,
@@ -176,7 +191,7 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
sourceNodeId: input.id,
sourceFingerprint,
lastRunId: runId,
stageTaskIds: {},
stageTaskIds: resume ? state.stageTaskIds : {},
errorCode: undefined,
errorMessage: undefined,
updatedAt: new Date().toISOString(),
@@ -187,11 +202,20 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
setLocalError("");
let latestStage: ArtCritiquePipelineStage = "preparing";
let runningReport: ArtCritiqueReport | undefined = state.report;
const stageTaskIds: Record<string, string> = {};
const stageTaskIds: Record<string, string> = resume ? { ...state.stageTaskIds } : {};
setPayment({ busy: false });
const approval = new ApprovedToolExecution({
clientKey: `art-critique:${runId}`, runId: billingRunId,
identity: { kind: "art-critique", nodeId: node.id, sourceFingerprint, model: selectedCritiqueModel }, signal: controller.signal,
onRun: (id) => { billingRunId = id; publishState({ ...baseState, stageTaskIds: { ...stageTaskIds } }); },
onState: (next) => { if (currentRunTargetRef.current === runTarget) setPayment(next); },
});
approvalRef.current = approval;
try {
const report = await executeArtCritique({
nodeId: node.id, runId, source: input, config: effectiveConfig,
signal: controller.signal,
submitStage: approval.execute,
onTaskCreated: (stage, taskId) => {
stageTaskIds[stage] = taskId;
publishState({ ...baseState, stageTaskIds: { ...stageTaskIds }, analysisStage: latestStage, updatedAt: new Date().toISOString() });
@@ -244,13 +268,22 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
setLocalError(errorMessage);
publishState({ ...baseState, stageTaskIds: { ...stageTaskIds }, status: "failed", analysisStage: "failed", errorCode: error instanceof Error ? error.message : "art_critique_failed", errorMessage, updatedAt: new Date().toISOString() });
} finally {
approval.dispose();
if (approvalRef.current === approval) { approvalRef.current = null; setPayment({ busy: false }); }
if (abortRef.current === controller) abortRef.current = null;
setRunning(false);
onRunningChange?.(false);
}
};
const progressStage = activeStage || visibleState.analysisStage || "preparing";
const displayedModelLabel = visibleState.report?.modelLabel || configuredCritiqueModelLabel;
useEffect(() => {
if (!open || !node || !startRequestId || running || startedRequestRef.current === startRequestId) return;
startedRequestRef.current = startRequestId;
void runReview(Boolean(!restartRequested && state.billingRunId && state.status !== "completed" && state.sourceFingerprint === currentFingerprint && state.lastRunModel === selectedCritiqueModel));
}, [open, node?.id, startRequestId, running]);
const modelStatusLabel = running ? "正在使用" : visibleState.report?.modelLabel ? "报告使用" : selectedCritiqueModel ? "待使用" : "需要配置";
const modelStatusColor = selectedCritiqueModel || visibleState.report?.modelLabel ? theme.node.muted : "var(--status-warning)";
const modelInlineLabel = selectedCritiqueModel || visibleState.report?.modelLabel ? `${modelStatusLabel} · ${displayedModelLabel}` : "请到设置中选择支持图片理解的文本模型";
@@ -335,6 +368,8 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
<aside className="min-h-0 flex-1 overflow-y-auto" data-canvas-wheel-scroll>
<div className="flex min-h-full flex-col gap-4 p-4">
{payment.quote ? <CreativeQuoteCard quote={payment.quote} busy={payment.busy} onApprove={() => void approvalRef.current?.approve(payment.quote!.items.map((item) => item.id))} onRefresh={() => void approvalRef.current?.refresh()} /> : null}
{payment.error ? <Callout tone="warning" title="费用确认未完成">{payment.error}</Callout> : null}
{running || visibleState.status === "running" ? <ArtCritiqueProgress stage={progressStage} theme={theme} /> : null}
{draftReportVisible && visibleState.status === "running" && visibleState.report ? (
<Callout tone="info" title="报告初稿已生成">
@@ -448,6 +483,7 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
取消分析
</Button>
) : null}
{!running && state.billingRunId && state.status !== "completed" && !stale && state.lastRunModel === selectedCritiqueModel ? <Button size="small" disabled={!input || !enabled} onClick={() => void runReview(true)}>继续上次分析</Button> : null}
<Button
type="primary"
size="small"

View File

@@ -136,6 +136,7 @@ function generationToolDefinition(name: string, description: string, mode?: "tex
}
const ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [
toolDefinition("canvas_start_art_critique", "打开真实审美节点并准备分析报价,费用卡确认后才提交模型;不会由本工具批准费用。已有当前图片报告直接复用,只有用户明确要求重新分析才传 retry=true。", { nodeId: { type: "string" }, retry: { type: "boolean" } }, ["nodeId"]),
toolDefinition("canvas_read_plugin_node", "读取已启用插件提供的真实节点状态与已有分析结果,不启动新任务。", { nodeId: { type: "string" } }, ["nodeId"]),
toolDefinition("canvas_list_styles", "读取真实项目画风目录;source=system 为系统预设,source=user 为当前用户保存的风格。返回 ID 和说明,不能编造 ID。", { source: { type: "string", enum: ["system", "user"] }, query: { type: "string" } }),
toolDefinition("canvas_apply_style", "应用从画风目录发现的真实风格,复用页面画风保存逻辑;关联项目时同步项目设置。已有画风且无需改变时直接沿用。", { source: { type: "string", enum: ["system", "user"] }, id: { type: "string" } }, ["source", "id"]),
@@ -271,7 +272,7 @@ type OnlineAgentTab = "chat" | "history";
type OnlineAgentLog = { id: string; time: string; title: string; data?: unknown };
type OnlineAgentLogContext = { model: string; running: boolean; confirmTools: boolean; messages: number; nodes: number; connections: number };
type OnlineLoopContext = { step: number };
type OnlineToolResult = { ok: true; message: string; data?: unknown } | { ok: false; message: string };
type OnlineToolResult = { ok: true; message: string; data?: unknown; waitForUser?: boolean } | { ok: false; message: string };
type OnlineExecutedToolCall = { toolCallId: string; name: string; result: OnlineToolResult };
type PendingOnlineToolContext = { messages: ResponseInputMessage[]; toolCalls: ResponseToolCall[]; assistantId: string; step: number };
@@ -286,6 +287,7 @@ type CanvasAssistantPanelProps = {
onSessionsChange: (sessions: CanvasAssistantSession[], activeSessionId: string | null) => void;
onApplyOps: (ops?: CanvasAgentOp[], context?: { conversationId?: string; messageId?: string; source?: "online" | "local" }) => Promise<CanvasAgentSnapshot>;
onApplyStyle: (preset: CanvasStylePreset) => Promise<void>;
onStartArtCritique: (nodeId: string, restart: boolean) => void;
canUndoOps: boolean;
undoOpsCount: number;
onUndoOps: () => CanvasAgentSnapshot | null;
@@ -378,6 +380,7 @@ export function CanvasAssistantPanel({
onSessionsChange,
onApplyOps,
onApplyStyle,
onStartArtCritique,
canUndoOps,
undoOpsCount,
onUndoOps,
@@ -790,6 +793,11 @@ export function CanvasAssistantPanel({
inspectedImagesRef.current.delete(item.toolCallId);
return image ? [image] : [];
});
const waiting = toolResults.find((item) => item.result.ok && item.result.waitForUser);
if (waiting) {
upsertMessage(sessionId, { id: assistantId, role: "assistant", text: waiting.result.message });
return;
}
// 工具结果只持久化读取事实,图片仅进入下一次模型请求;旧像素不随每轮重复发送。
const prior = images.length ? messages.map((message): ResponseInputMessage => {
if ("type" in message || message.role === "tool" || !Array.isArray(message.content)) return message;
@@ -858,6 +866,17 @@ export function CanvasAssistantPanel({
const currentSkills = skillRuntime.agentToolNames("onlineAgent").has(name) ? (await listAddedSkills()).skills : composerSkills;
const skillToolResult = await skillRuntime.executeAgentTool("onlineAgent", name, args, currentSkills);
if (skillToolResult) return skillToolResult;
if (name === "canvas_start_art_critique") {
const nodeId = requireString(args.nodeId, "nodeId");
const node = current.nodes.find((item) => item.id === nodeId && item.type === "ai-art-critique");
if (!node || !isPluginEffectivelyEnabled("ai-art-critique")) throw new Error("审美插件未启用或节点不存在");
const sources = current.nodes.filter((item) => item.type === "image" && current.connections.some((edge) => edge.fromNodeId === item.id && edge.toNodeId === nodeId));
if (sources.length !== 1) throw new Error("审美分析需要明确连接一张图片,请先整理输入连线");
const result = readAgentPluginNode(current, nodeId);
if (args.retry !== true && result.data.status === "completed") return { ok: true, message: "已复用当前图片的审美报告。", data: result };
onStartArtCritique(nodeId, args.retry === true);
return { ok: true, waitForUser: true, message: "已打开审美分析,请在面板中核对并确认费用。分析完成后,可让我读取报告并继续处理。", data: { nodeId, status: "analysis_requested", paymentApproved: false } };
}
if (name === "canvas_read_plugin_node") return { ok: true, message: "已读取插件节点状态。", data: readAgentPluginNode(current, requireString(args.nodeId, "nodeId")) };
if (name === "canvas_read_storyboard") {
const { node, rows } = readAgentStoryboard(current, requireString(args.nodeId, "nodeId"));
@@ -964,15 +983,16 @@ export function CanvasAssistantPanel({
const executeOnlineToolCalls = async (sessionId: string, toolCalls: ResponseToolCall[]) => {
const results: OnlineExecutedToolCall[] = [];
let stopped = false;
let stopReason = "";
for (const toolCall of toolCalls) {
if (stopped) {
results.push({ toolCallId: toolCall.id, name: toolCall.function.name, result: { ok: false, message: "前一个工具调用失败,未继续执行。" } });
if (stopReason) {
results.push({ toolCallId: toolCall.id, name: toolCall.function.name, result: { ok: false, message: stopReason } });
continue;
}
const result = await executeOnlineToolCall(sessionId, toolCall);
results.push(result);
if (!result.result.ok) stopped = true;
if (!result.result.ok) stopReason = "前一个工具调用失败,未继续执行。";
else if (result.result.waitForUser) stopReason = "已打开待确认面板,同批后续操作暂不执行。";
}
return results;
};
@@ -1761,6 +1781,7 @@ function previewOnlineToolCalls(calls: ResponseToolCall[], snapshot: CanvasAgent
}
function toolCallLabel(name: string) {
if (name === "canvas_start_art_critique") return "准备审美分析";
if (name === "canvas_read_plugin_node") return "读取插件结果";
if (name === "canvas_list_styles") return "查找项目画风";
if (name === "canvas_apply_style") return "应用项目画风";

View File

@@ -126,6 +126,8 @@ export type ArtCritiqueNodeStatus = "idle" | "running" | "completed" | "failed"
export type ArtCritiqueNodeState = {
stageTaskIds?: Record<string, string>;
billingRunId?: string;
lastRunModel?: string;
schemaVersion: typeof ART_CRITIQUE_SCHEMA_VERSION;
status: ArtCritiqueNodeStatus;
sourceNodeId?: string;

View File

@@ -1116,7 +1116,7 @@ function emitStage(options: ArtCritiquePipelineOptions, stage: ArtCritiquePipeli
}
function rethrowIfAborted(error: unknown, signal?: AbortSignal): asserts error is Error {
if (signal?.aborted) throw error;
if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw error;
}
function throwIfAborted(signal?: AbortSignal) {

View File

@@ -9,7 +9,7 @@ const manifest: PluginManifest = {
id: ART_CRITIQUE_PLUGIN_ID,
name: "AI 审美批改",
version: "0.1.0",
description: "分析图片的构图、色彩、光线和比例问题,输出结构化批改报告与可视化标注。",
description: "分析图片的构图、色彩、光线和比例问题,输出结构化批改报告与可视化标注。prepare-input 准备节点;canvas_start_art_critique 打开分析并准备真实报价,用户确认费用后执行;canvas_read_plugin_node 读取结果。",
author: "内置工具",
surfaces: ["node", "fullscreen"],
permissions: ["canvas.read", "canvas.write", "media.read", "ai.text"],
@@ -38,7 +38,7 @@ export const artCritiquePlugin: RegisteredPlugin = {
const state = node.metadata?.artCritique;
const sources = snapshot.nodes.filter((item) => item.type === "image" && snapshot.connections.some((edge) => edge.fromNodeId === item.id && edge.toNodeId === node.id));
const currentReport = state?.report && sources.length === 1 && state.report.sourceFingerprint === artCritiqueSourceFingerprint(sources[0]) ? state.report : undefined;
return { status: state?.report && !currentReport ? "stale" : state?.status || "idle", stage: state?.analysisStage, stageTaskIds: state?.stageTaskIds, sourceNodeId: state?.sourceNodeId, summary: currentReport?.summary, strengths: currentReport?.strengths, issues: currentReport?.issues, options: currentReport?.options, error: state?.errorMessage, execution: "已有报告仅在输入指纹一致时返回;新分析请使用节点的分析入口,当前 Agent 动作仅准备输入。" };
return { status: state?.report && !currentReport ? "stale" : state?.status || "idle", stage: state?.analysisStage, stageTaskIds: state?.stageTaskIds, sourceNodeId: state?.sourceNodeId, summary: currentReport?.summary, strengths: currentReport?.strengths, issues: currentReport?.issues, options: currentReport?.options, error: state?.errorMessage, execution: "已有报告仅在输入指纹一致时返回;canvas_start_art_critique 准备分析报价,用户确认后执行。" };
},
};

View File

@@ -293,6 +293,8 @@ function InfiniteCanvasPage() {
const [scriptEditorNodeId, setScriptEditorNodeId] = useState<string | null>(null);
const [portraitClearanceNodeId, setPortraitClearanceNodeId] = useState<string | null>(null);
const [artCritiqueNodeId, setArtCritiqueNodeId] = useState<string | null>(null);
const artCritiqueRunningRef = useRef(false);
const [artCritiqueStartRequest, setArtCritiqueStartRequest] = useState<{ nodeId: string; id: string; restart: boolean } | null>(null);
const [scriptScrollTopById, setScriptScrollTopById] = useState<Record<string, number>>({});
const [directorNodeId, setDirectorNodeId] = useState<string | null>(null);
const [versionCompareRootId, setVersionCompareRootId] = useState<string | null>(null);
@@ -2486,6 +2488,11 @@ function InfiniteCanvasPage() {
onSessionsChange={handleAssistantSessionsChange}
onApplyOps={applyAgentOps}
onApplyStyle={applyCanvasStyleAsync}
onStartArtCritique={(nodeId, restart) => {
if (artCritiqueRunningRef.current) throw new Error("已有审美分析正在进行,请先完成或停止当前分析");
setArtCritiqueStartRequest({ nodeId, id: nanoid(), restart });
setArtCritiqueNodeId(nodeId);
}}
canUndoOps={canUndoAgentOps}
undoOpsCount={agentUndoCount}
onUndoOps={undoAgentOps}
@@ -2854,6 +2861,9 @@ function InfiniteCanvasPage() {
/>
<AiArtCritiqueModal
startRequestId={artCritiqueStartRequest && artCritiqueStartRequest.nodeId === artCritiqueNode?.id ? artCritiqueStartRequest.id : undefined}
restartRequested={artCritiqueStartRequest?.nodeId === artCritiqueNode?.id && artCritiqueStartRequest?.restart}
onRunningChange={(running) => { artCritiqueRunningRef.current = running; }}
node={artCritiqueNode}
upstreamNodes={artCritiqueInputs}
open={Boolean(artCritiqueNode)}

View File

@@ -0,0 +1,169 @@
import { nanoid } from "nanoid";
import { creationRuns, type CreationRun, type CreationSubmission } from "./api/creation-runs";
import { prepareBackendToolGenerationTask, parseBackendGenerationResult, runBackendToolGenerationTask } from "./api/generation-task";
import { waitForGenerationTask } from "./api/task-center";
import type { CreativeQuote } from "@/lib/creation/creative-agent-contract";
import { getActiveUserScope } from "@/lib/user-scope";
import { logicalModelIDForConfig, resolveModelRequestConfig } from "@/stores/use-config-store";
type Request = Parameters<typeof runBackendToolGenerationTask>[0];
type Pending = { submission: CreationSubmission; externalBilling: boolean; resolve: () => void; reject: (error: Error) => void };
export type ApprovedToolExecutionState = { quote?: CreativeQuote; busy: boolean; error?: string };
// Only this boundary approves and executes quoted tasks. Pipeline code never grants approval.
export class ApprovedToolExecution {
private run?: CreationRun;
private owner = `plugin:${nanoid()}`;
private scope = getActiveUserScope();
private pending = new Map<string, Pending>();
private heartbeat?: ReturnType<typeof setInterval>;
private disposed = false;
private busy = false;
private error?: string;
private stopReason?: string;
private ready?: Promise<void>;
private controller = new AbortController();
constructor(private options: {
clientKey: string; runId?: string; identity: Record<string, string>;
signal: AbortSignal; onRun: (id: string) => void;
onState: (state: ApprovedToolExecutionState) => void;
}, private api = creationRuns, private waitTask = waitForGenerationTask) {
options.signal.addEventListener("abort", this.abort, { once: true });
}
private assertLive() {
if (this.disposed || this.options.signal.aborted || this.scope !== getActiveUserScope() || this.controller.signal.aborted) throw new DOMException(this.stopReason || "分析已停止,请重新打开后继续", "AbortError");
}
private guard() { return { owner: this.owner, executionEpoch: this.run!.executionEpoch }; }
private initialize() {
return this.ready ||= (async () => {
this.assertLive();
const detail = this.options.runId ? await this.api.get(this.options.runId, this.controller.signal) : await this.api.create({ clientKey: this.options.clientKey, state: this.options.identity }, this.controller.signal);
this.assertLive();
if (Object.entries(this.options.identity).some(([key, value]) => detail.run.state[key] !== value)) throw new Error("原分析的图片、节点或模型已变化,请开始新的分析");
this.run = await this.api.claim(detail.run.id, { expectedEpoch: detail.run.executionEpoch, owner: this.owner }, this.controller.signal);
this.assertLive();
this.options.onRun(this.run.id);
this.heartbeat = setInterval(() => {
if (this.disposed) return;
void (async () => { this.assertLive(); await this.api.heartbeat(this.run!.id, this.guard(), this.controller.signal); })().catch(() => this.abort(new Error("分析连接已中断,请重新打开后继续")));
}, 15_000);
})();
}
execute = async (request: Request) => {
// A control/quote failure must also unblock sibling stages waiting for approval.
// Model task failures below keep the pipeline's existing partial-result handling.
const task = await this.submit(request).catch((error: unknown) => {
this.abort(error);
throw new DOMException(error instanceof Error ? error.message : "分析提交失败,请重新打开后继续", "AbortError");
});
if (this.scope === getActiveUserScope()) request.onTaskCreated?.(task);
this.assertLive();
const completed = await this.waitTask(task.id, { initialTask: task, signal: this.controller.signal, onTextDelta: request.onDelta });
this.assertLive();
const result = parseBackendGenerationResult(completed);
return { content: result.text || "", toolCalls: result.toolCalls || [], ...(result.reasoning ? { reasoning: result.reasoning } : {}) };
};
private async submit(request: Request) {
await this.initialize();
this.assertLive();
const stage = request.metadata?.stage;
if (!stage) throw new Error("分析阶段缺少稳定标识");
let submission = await this.api.prepare(this.run!.id, { ...this.guard(), itemKey: `stage:${stage}`, request: prepareBackendToolGenerationTask(request) }, this.controller.signal);
this.assertLive();
if (submission.revokedAt) {
const detail = await this.api.get(this.run!.id, this.controller.signal);
submission = detail.submissions.find((item) => item.requestHash === submission.requestHash && !item.revokedAt) || submission;
if (submission.revokedAt) throw new Error("原报价已失效,无法恢复此阶段");
}
if (!submission.taskId && Date.parse(submission.quote.expiresAt) <= Date.now()) submission = await this.api.refreshQuote(this.run!.id, { ...this.guard(), submissionId: submission.id }, this.controller.signal);
this.assertLive();
if (!submission.taskId && !submission.approvedAt) {
await new Promise<void>((resolve, reject) => {
const externalBilling = !logicalModelIDForConfig(request.config) && !resolveModelRequestConfig(request.config, request.config.model).channelId;
this.pending.set(stage, { submission, externalBilling, resolve, reject });
this.emit();
});
submission = this.pending.get(stage)!.submission;
this.pending.delete(stage);
this.emit();
}
this.assertLive();
return this.api.execute(this.run!.id, { ...this.guard(), submissionId: submission.id }, this.controller.signal);
}
async approve(submissionIds: string[]) {
if (this.busy || this.disposed || this.controller.signal.aborted) return;
const ids = new Set(submissionIds);
const batch = [...this.pending.values()].filter((item) => ids.has(item.submission.id) && !item.submission.approvedAt);
if (batch.length !== ids.size) { this.error = "报价已变化,请核对当前费用卡后确认"; this.emit(); return; }
if (!batch.length) return;
this.busy = true; this.error = undefined; this.emit();
try {
this.assertLive();
const result = await this.api.approve(this.run!.id, { ...this.guard(), submissionIds: batch.map((item) => item.submission.id) }, this.controller.signal);
this.assertLive();
if (batch.some((item) => !result.submissions.some((candidate) => candidate.id === item.submission.id && candidate.approvedAt))) {
const error = new Error("费用确认回执不完整,请继续上次分析以读取实际状态");
this.abort(error);
throw error;
}
for (const item of batch) {
const approved = result.submissions.find((candidate) => candidate.id === item.submission.id && candidate.approvedAt);
if (!approved) throw new Error("费用确认结果不完整,请重新核对");
item.submission = approved;
}
batch.forEach((item) => item.resolve());
} catch (error) { this.error = error instanceof Error ? error.message : "费用确认失败"; }
finally { this.busy = false; this.emit(); }
}
async refresh() {
if (this.busy || this.disposed || this.controller.signal.aborted) return;
this.busy = true; this.error = undefined; this.emit();
try {
this.assertLive();
for (const item of [...this.pending.values()]) {
if (item.submission.approvedAt) continue;
item.submission = await this.api.refreshQuote(this.run!.id, { ...this.guard(), submissionId: item.submission.id }, this.controller.signal);
this.assertLive();
}
} catch (error) { this.error = error instanceof Error ? error.message : "报价刷新失败"; }
finally { this.busy = false; this.emit(); }
}
private emit() {
if (this.disposed || this.scope !== getActiveUserScope()) return;
const items = [...this.pending.entries()].filter(([, item]) => !item.submission.approvedAt).map(([stage, item]) => ({ ...item.submission, stage }));
const externalBilling = [...this.pending.values()].some((item) => !item.submission.approvedAt && item.externalBilling);
const estimated = items.some((item) => item.quote.estimated);
this.options.onState({ busy: this.busy, error: this.error, quote: items.length ? {
id: items.map((item) => item.id).join(":"), title: "确认本批分析费用",
items: items.map((item) => ({ id: item.id, label: stageLabel(item.stage), model: item.quote.model, quantity: item.quote.quantity, specification: `${item.quote.estimated ? "预计" : ""} ${item.quote.amountMicrocredits / 1_000_000} 积分` })),
amountLabel: `${externalBilling ? "平台 " : ""}${estimated ? "预计 " : ""}${items.reduce((sum, item) => sum + item.quote.amountMicrocredits, 0) / 1_000_000} 积分${externalBilling ? " + 外部渠道费用" : ""}`,
externalBilling,
basis: "仅批准本批列出的模型调用;按用量计费时按实际结算,估算不是费用上限。后续阶段根据分析结果另行报价。",
expiresAt: items.map((item) => item.quote.expiresAt).sort()[0],
} : undefined });
}
private abort = (reason?: unknown) => {
if (!this.controller.signal.aborted && reason instanceof Error) this.stopReason = reason.message;
this.controller.abort();
if (this.heartbeat) clearInterval(this.heartbeat);
for (const item of this.pending.values()) item.reject(new DOMException(this.stopReason || "分析已停止", "AbortError"));
};
dispose() {
this.disposed = true; this.abort();
if (this.heartbeat) clearInterval(this.heartbeat);
this.options.signal.removeEventListener("abort", this.abort);
if (this.run && this.scope === getActiveUserScope()) void this.api.release(this.run.id, this.guard()).catch(() => { /* Lease expiry releases an offline owner. */ });
}
}
function stageLabel(key: string) {
return ({ analyze_art_scene: "理解画面", review_art_composition: "分析构图", review_art_color: "分析色彩", review_art_lighting: "分析光线", review_art_structure: "分析结构", aggregate_art_critique: "整理问题", ground_art_critique_issues: "定位问题", verify_art_critique: "复核结论", generate_art_edit_prompts: "编写修改建议" } as Record<string, string>)[key.replace(/^stage:/, "")] || "分析阶段";
}

View File

@@ -19,6 +19,7 @@ export async function executeArtCritique(input: {
onTaskCreated: (stage: string, taskId: string) => void;
onStage?: ArtCritiquePipelineOptions["onStage"];
onDraftReport?: ArtCritiquePipelineOptions["onDraftReport"];
submitStage?: typeof runBackendToolGenerationTask;
}, dependencies = { inspectImage: inspectAgentImage, runTask: runBackendToolGenerationTask, runPipeline: runArtCritiquePipeline }) {
if (!input.nodeId || !input.runId || !isArtCritiqueImageInput(input.source)) throw new Error("分析需要真实目标节点、执行标识和已有图片");
const scope = getActiveUserScope();
@@ -38,7 +39,7 @@ export async function executeArtCritique(input: {
assertLive();
if (calledStages.has(toolName) || calledStages.size >= ART_CRITIQUE_MAX_MODEL_CALLS) throw new Error("分析阶段重复或超过本次调用数量上限");
calledStages.add(toolName);
const result = await dependencies.runTask({
const result = await (input.submitStage || dependencies.runTask)({
config, messages, tools: [tool], toolChoice: { type: "function", name: toolName },
prompt: `图片审美分析:${toolName}`, signal,
metadata: { source: "art-critique", nodeId: input.nodeId, runId: input.runId, stage: toolName },

View File

@@ -0,0 +1,145 @@
import { expect, test } from "bun:test";
import { ApprovedToolExecution, type ApprovedToolExecutionState } from "../src/services/approved-tool-execution";
import { creationRuns, type CreationRun, type CreationSubmission } from "../src/services/api/creation-runs";
import { createModelChannel, defaultConfig } from "../src/stores/use-config-store";
import type { GenerationTask } from "../src/services/api/task-center";
const identity = { kind: "art-critique", nodeId: "node", sourceFingerprint: "image", model: "model" };
const config = { ...defaultConfig, model: "system::model", channels: [createModelChannel({ id: "system", scope: "system", models: ["model"], interfaceType: "chat-completion" })] };
const request = (stage: string) => ({ config, prompt: stage, messages: [{ role: "user" as const, content: "review" }], tools: [], toolChoice: "auto" as const, metadata: { source: "art-critique", stage } });
const quote = (id: string, stage: string, taskId?: string): CreationSubmission => ({ id, runId: "run", itemKey: `stage:${stage}`, requestHash: stage, taskId, quote: { model: "model", billingMode: "fixed_request", quantity: 1, amountMicrocredits: 1_000_000, estimated: false, expiresAt: "2099-01-01", quoteHash: id } });
function harness(submissions: CreationSubmission[] = [], state = identity) {
let run: CreationRun = { id: "run", userId: "user", revision: 1, executionEpoch: 0, executionOwner: "", status: "idle", state, createdAt: "", updatedAt: "" };
let view: ApprovedToolExecutionState = { busy: false };
const listeners: Array<{ matches: (view: ApprovedToolExecutionState) => boolean; resolve: (view: ApprovedToolExecutionState) => void }> = [];
const approvals: string[][] = [], executions: string[] = [], waits: string[] = [];
let creates = 0, refreshes = 0;
const api = { ...creationRuns,
create: async () => { creates++; return { run, submissions }; },
get: async () => ({ run, submissions }),
claim: async (_id: string, input: { owner: string }) => (run = { ...run, executionEpoch: run.executionEpoch + 1, executionOwner: input.owner }),
release: async () => ({ released: true }),
prepare: async (_id: string, input: { itemKey: string }) => {
const existing = submissions.find((item) => item.itemKey === input.itemKey);
if (existing) return existing;
const created = quote(`quote-${submissions.length}`, input.itemKey.replace("stage:", "")); submissions.push(created); return created;
},
approve: async (_id: string, input: { submissionIds: string[] }) => {
approvals.push(input.submissionIds);
submissions = submissions.map((item) => input.submissionIds.includes(item.id) ? { ...item, approvedAt: "2026-01-01" } : item);
return { submissions: submissions.filter((item) => input.submissionIds.includes(item.id)) };
},
refreshQuote: async (_id: string, input: { submissionId: string }) => {
refreshes++;
const item = submissions.find((entry) => entry.id === input.submissionId)!;
item.revokedAt = "2026-01-01";
const fresh = { ...item, id: `fresh-${refreshes}`, itemKey: `requote:${item.id}`, revokedAt: undefined, approvedAt: undefined, quote: { ...item.quote, expiresAt: "2099-01-01" } };
submissions.push(fresh); return fresh;
},
execute: async (_id: string, input: { submissionId: string }) => {
const item = submissions.find((entry) => entry.id === input.submissionId)!;
expect(Boolean(item.taskId || item.approvedAt)).toBe(true);
executions.push(input.submissionId);
return { id: item.taskId || `task-${item.id}` } as GenerationTask;
},
} as typeof creationRuns;
const controller = new AbortController();
const execution = new ApprovedToolExecution({ clientKey: "test", ...(submissions.length ? { runId: "run" } : {}), identity, signal: controller.signal, onRun: () => {}, onState: (next) => {
view = next;
for (const listener of [...listeners]) if (listener.matches(view)) { listeners.splice(listeners.indexOf(listener), 1); listener.resolve(view); }
} }, api, async (id) => { waits.push(id); return { id, status: "succeeded", resultJson: JSON.stringify({ text: "report" }) } as GenerationTask; });
return { execution, controller, api, approvals, executions, waits, counters: () => ({ creates, refreshes }), view: () => view,
until: (matches: (view: ApprovedToolExecutionState) => boolean) => matches(view) ? Promise.resolve(view) : new Promise<ApprovedToolExecutionState>((resolve) => listeners.push({ matches, resolve })),
};
}
test("用户只批准费用卡已展示的项目,后来加入的阶段继续等待", async () => {
const h = harness();
try {
const first = h.execution.execute(request("first"));
const shown = await h.until((view) => view.quote?.items.length === 1);
const second = h.execution.execute(request("second"));
await h.until((view) => view.quote?.items.length === 2);
expect(h.executions).toEqual([]);
await h.execution.approve(shown.quote!.items.map((item) => item.id));
expect((await first).content).toBe("report");
expect(h.executions).toHaveLength(1);
expect(h.view().quote?.items).toHaveLength(1);
await h.execution.approve(h.view().quote!.items.map((item) => item.id)); await second;
expect(h.approvals).toEqual([["quote-0"], ["quote-1"]]);
} finally { h.execution.dispose(); }
});
test("恢复已提交阶段沿用原任务,即使原报价过期也不新增批准", async () => {
const item = quote("existing", "stage", "original-task"); item.quote.expiresAt = "2000-01-01";
const h = harness([item]);
try {
await h.execution.execute(request("stage"));
expect(h.waits).toEqual(["original-task"]); expect(h.approvals).toEqual([]);
expect(h.counters()).toEqual({ creates: 0, refreshes: 0 });
} finally { h.execution.dispose(); }
});
test("等待费用确认时取消,不提交模型;迟到按钮调用不抛未处理异常", async () => {
const h = harness();
try {
const result = h.execution.execute(request("stage")).catch((error: Error) => error);
await h.until((view) => Boolean(view.quote)); h.controller.abort();
expect((await result as Error).name).toBe("AbortError");
await h.execution.approve(["quote-0"]); await h.execution.refresh();
expect(h.executions).toEqual([]); expect(h.approvals).toEqual([]);
} finally { h.execution.dispose(); }
});
test("已批准但未提交的过期报价必须刷新并重新确认", async () => {
const item = { ...quote("expired", "stage"), approvedAt: "2000-01-01" }; item.quote.expiresAt = "2000-01-01";
const h = harness([item]);
try {
const result = h.execution.execute(request("stage"));
await h.until((view) => Boolean(view.quote));
expect(h.executions).toEqual([]); expect(h.counters().refreshes).toBe(1);
await h.execution.approve(["expired"]); expect(h.executions).toEqual([]);
await h.execution.approve(h.view().quote!.items.map((entry) => entry.id)); await result;
expect(h.executions).toEqual(["fresh-1"]);
} finally { h.execution.dispose(); }
});
test("刷新后的报价可重新打开恢复,找回未撤销的替代条目", async () => {
const old = { ...quote("old", "stage"), revokedAt: "2026-01-01" };
const fresh = { ...quote("new", "stage", "original-task"), itemKey: "requote:old" };
const h = harness([old, fresh]);
try { await h.execution.execute(request("stage")); expect(h.executions).toEqual(["new"]); expect(h.waits).toEqual(["original-task"]); }
finally { h.execution.dispose(); }
});
test("图片或模型身份改变时拒绝恢复", async () => {
const h = harness([quote("old", "stage")], { ...identity, model: "different" });
try { await expect(h.execution.execute(request("stage"))).rejects.toThrow("已变化"); expect(h.executions).toEqual([]); }
finally { h.execution.dispose(); }
});
test("并行阶段报价失败会停止其他等待中的阶段,不永久挂起", async () => {
const h = harness();
try {
const first = h.execution.execute(request("first")).catch((error: Error) => error);
await h.until((view) => Boolean(view.quote));
h.api.prepare = async () => { throw new Error("报价服务不可用"); };
await expect(h.execution.execute(request("second"))).rejects.toThrow("报价服务不可用");
expect((await first as Error).name).toBe("AbortError"); expect(h.executions).toEqual([]);
} finally { h.execution.dispose(); }
});
test("刷新费用卡后旧按钮不能批准新报价", async () => {
const h = harness();
try {
const result = h.execution.execute(request("analyze_art_scene"));
const shown = await h.until((view) => Boolean(view.quote));
await h.execution.refresh();
expect(h.view().quote?.items[0]?.label).toBe("理解画面");
await h.execution.approve(shown.quote!.items.map((item) => item.id));
expect(h.executions).toEqual([]); expect(h.view().error).toContain("报价已变化");
await h.execution.approve(h.view().quote!.items.map((item) => item.id)); await result;
expect(h.approvals).toEqual([["fresh-1"]]);
} finally { h.execution.dispose(); }
});

View File

@@ -3,7 +3,7 @@ import { executeArtCritique } from "../src/services/art-critique-execution";
import { defaultConfig } from "../src/stores/use-config-store";
import { createCanvasNode } from "../src/lib/canvas/canvas-project-domain";
import { CanvasNodeType } from "../src/types/canvas";
import type { ArtCritiquePipelineOptions } from "../src/lib/art-critique/pipeline";
import { runArtCritiquePipeline, type ArtCritiquePipelineOptions } from "../src/lib/art-critique/pipeline";
const source = createCanvasNode(CanvasNodeType.Image, { x: 0, y: 0 }, { storageKey: "resource:original" });
const options = () => ({ nodeId: "analysis", runId: "run", source, config: defaultConfig, signal: new AbortController().signal, onTaskCreated: (_stage: string, _id: string) => {} });
@@ -44,3 +44,12 @@ test("准备素材期间取消不会提交后台模型任务", async () => {
})).rejects.toThrow();
expect(submitted).toBe(false);
});
test("审批执行器中止不能被管线降级为已完成报告", async () => {
await expect(runArtCritiquePipeline({ ...defaultConfig, textModel: "model" }, { dataUrl: "resource:image", title: "image", sourceFingerprint: "image" }, {
requestStage: async ({ toolName }) => {
if (toolName === "analyze_art_scene") throw new DOMException("报价服务不可用", "AbortError");
return { content: "", toolCalls: [{ id: toolName, type: "function", function: { name: toolName, arguments: JSON.stringify({ candidates: [] }) } }] };
},
})).rejects.toThrow("报价服务不可用");
});

View File

@@ -8,7 +8,7 @@ import type { GenerationTask } from "../src/services/api/task-center";
import { CanvasNodeType } from "../src/types/canvas";
function harness(state: CreativeAgentState, status: CreationRun["status"] = "paused", submissions: CreationSubmission[] = [], waitTask?: ConstructorParameters<typeof CreativeAgentController>[0]["waitTask"]) {
let run: CreationRun = { id: "run", userId: "user", canvasId: "canvas", revision: 1, executionEpoch: 0, executionOwner: "", status, state: structuredClone(state) as unknown as Record<string, unknown>, approvedProposalVersion: state.proposal?.version, createdAt: "", updatedAt: "" };
let run: CreationRun = { id: "run", userId: "user", canvasId: "canvas", revision: 1, executionEpoch: 0, executionOwner: "", status, state: structuredClone(state) as unknown as Record<string, unknown>, approvedProposalVersion: state.proposal?.version, approvedProposalHash: state.operations ? "approved-proposal-hash" : undefined, createdAt: "", updatedAt: "" };
let snapshot: CanvasAgentSnapshot = { projectId: "canvas", title: "canvas", nodes: state.media.map((media) => ({ id: media.nodeId, type: CanvasNodeType.Image, title: media.ref, position: { x: 0, y: 0 }, width: 100, height: 100, metadata: {} })), connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
let view: CreativeControllerView | undefined;
let commits = 0, prepares = 0, executions = 0;
@@ -68,6 +68,11 @@ describe("创作控制器恢复", () => {
const h = harness({ ...initialCreativeState(), planning: { itemKey: "planning:key", protocol: [], model: "model" } }, "running", [submission("existing", "planning:key")]);
try { await h.controller.load("run"); await h.controller.resume(); expect(h.view().state.planning?.submissionId).toBe("existing"); expect(h.view().quote).toBeDefined(); expect(h.counters().prepares).toBe(0); } finally { h.controller.dispose(); }
});
test("未批准的方案恢复时仍等待用户确认,不创建画布节点", async () => {
const h = harness({ ...initialCreativeState(), proposal, canvasApplied: false });
try { await h.controller.load("run"); await h.controller.resume(); expect(h.view().run?.status).toBe("waiting_proposal"); expect(h.snapshot().nodes).toHaveLength(0); expect(h.counters()).toEqual({ commits: 0, prepares: 0, executions: 0 }); }
finally { h.controller.dispose(); }
});
test("第一项失败不阻止同批成功结果回写", async () => {
const media = ["a", "b"].map((ref) => ({ ref, nodeId: ref, attempt: 1, submissionId: ref, taskId: ref, status: "queued" as const }));
const h = harness({ ...initialCreativeState(), proposal, canvasApplied: true, media }, "paused", [submission("a", "a", "a"), submission("b", "b", "b")], async (id) => { if (id === "a") throw new Error("A生成失败"); return { id, status: "succeeded", resultJson: JSON.stringify({ images: [{ storageKey: "resource:output", dataUrl: "/api/resources/output/file" }] }) } as GenerationTask; });