feat(agent): 审美分析 - 接入费用确认与阶段任务恢复
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 "应用项目画风";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 准备分析报价,用户确认后执行。" };
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
169
web/src/services/approved-tool-execution.ts
Normal file
169
web/src/services/approved-tool-execution.ts
Normal 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:/, "")] || "分析阶段";
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user