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 "应用项目画风";
|
||||
|
||||
Reference in New Issue
Block a user