From d0024fbbc6b1c5e390f83a3057b685801231e8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BF=97=E4=BC=9F?= Date: Tue, 8 Sep 2026 21:20:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E5=AE=A1=E7=BE=8E=E5=88=86?= =?UTF-8?q?=E6=9E=90=20-=20=E6=8E=A5=E5=85=A5=E8=B4=B9=E7=94=A8=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E4=B8=8E=E9=98=B6=E6=AE=B5=E4=BB=BB=E5=8A=A1=E6=81=A2?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/docs/plugins/ai-art-critique.mdx | 3 +- docs/design/agent-capabilities.mdx | 12 +- .../art-critique/ai-art-critique-modal.tsx | 60 +++++-- .../canvas/canvas-assistant-panel.tsx | 31 +++- web/src/lib/art-critique/contracts.ts | 2 + web/src/lib/art-critique/pipeline.ts | 2 +- .../lib/plugins/builtin/ai-art-critique.ts | 4 +- web/src/pages/canvas/project.tsx | 10 ++ web/src/services/approved-tool-execution.ts | 169 ++++++++++++++++++ web/src/services/art-critique-execution.ts | 3 +- web/test/approved-tool-execution.test.ts | 145 +++++++++++++++ web/test/art-critique-execution.test.ts | 11 +- web/test/creative-agent-controller.test.ts | 7 +- 13 files changed, 432 insertions(+), 27 deletions(-) create mode 100644 web/src/services/approved-tool-execution.ts create mode 100644 web/test/approved-tool-execution.test.ts diff --git a/docs/content/docs/plugins/ai-art-critique.mdx b/docs/content/docs/plugins/ai-art-critique.mdx index a16657a94..3090275c5 100644 --- a/docs/content/docs/plugins/ai-art-critique.mdx +++ b/docs/content/docs/plugins/ai-art-critique.mdx @@ -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 看图并返回结构化结果 -> 本地校验和整理结果 -> 在原图上绘制标注 diff --git a/docs/design/agent-capabilities.mdx b/docs/design/agent-capabilities.mdx index 8aba19f0f..e61c0ea97 100644 --- a/docs/design/agent-capabilities.mdx +++ b/docs/design/agent-capabilities.mdx @@ -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` 更新替代具有业务校验和费用语义的执行接口。 diff --git a/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx b/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx index a89d1e4c8..f24803ce0 100644 --- a/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx +++ b/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx @@ -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(null); const [draftReportVisible, setDraftReportVisible] = useState(false); const abortRef = useRef(null); + const approvalRef = useRef(null); + const [payment, setPayment] = useState({ busy: false }); + const startedRequestRef = useRef(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 = {}; + const stageTaskIds: Record = 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