feat(plugin): AI 审美批改 - 增加结构化视觉分析与精准标注 (#387)

* feat(portrait-clearance): 肖像排查 - 增加画布插件与本机检测链路

* fix(web): 插件中心 - 修复应用插件筛选与自动定位

* feat(plugin): AI审美批改 - 完成分析报告与问题标注闭环
This commit is contained in:
QAyong
2026-09-02 20:32:58 +08:00
committed by GitHub
parent 29f7a5c0e5
commit a6504c0e00
29 changed files with 4909 additions and 15 deletions

View File

@@ -95,6 +95,7 @@ import { CanvasProjectWorldLayers } from "./canvas-project-world-layers";
import { CanvasNodeActionContext, type CanvasNodeActionContextValue } from "@/components/canvas/canvas-node-action-context";
import { bringCanvasNodeToFront, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
import { PortraitClearanceModal } from "@/components/canvas/portrait-clearance/portrait-clearance-modal";
import { AiArtCritiqueModal } from "@/components/canvas/art-critique/ai-art-critique-modal";
import { CanvasNodeGraphContext, type CanvasNodeGraphContextValue } from "@/components/canvas/canvas-node-graph-context";
import { CanvasRefreshShell } from "./canvas-refresh-shell";
import { queryGenerationTask } from "@/services/api/task-center";
@@ -147,6 +148,7 @@ import type { ReferenceImage } from "@/types/image";
import type { PortraitClearanceNodeState } from "@/lib/portrait-clearance/contracts";
import { createDefaultPortraitClearanceState, PORTRAIT_CLEARANCE_NODE_TYPE } from "@/lib/portrait-clearance/contracts";
import { reconcilePortraitClearanceInputBindings } from "@/lib/portrait-clearance/input-bindings";
import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts";
const CanvasDirectorWorkbench = lazy(() => import("@/components/canvas/director/canvas-director-workbench").then((module) => ({ default: module.CanvasDirectorWorkbench })));
const CanvasDrawingEditorModal = lazy(() => import("@/components/canvas/canvas-drawing-editor-modal").then((module) => ({ default: module.CanvasDrawingEditorModal })));
@@ -287,6 +289,7 @@ function InfiniteCanvasPage() {
const [previewNodeId, setPreviewNodeId] = useState<string | null>(null);
const [scriptEditorNodeId, setScriptEditorNodeId] = useState<string | null>(null);
const [portraitClearanceNodeId, setPortraitClearanceNodeId] = useState<string | null>(null);
const [artCritiqueNodeId, setArtCritiqueNodeId] = useState<string | null>(null);
const [scriptScrollTopById, setScriptScrollTopById] = useState<Record<string, number>>({});
const [directorNodeId, setDirectorNodeId] = useState<string | null>(null);
const [versionCompareRootId, setVersionCompareRootId] = useState<string | null>(null);
@@ -918,6 +921,7 @@ function InfiniteCanvasPage() {
setRunningNodeId(clearDeletedId);
setScriptEditorNodeId(clearDeletedId);
setPortraitClearanceNodeId(clearDeletedId);
setArtCritiqueNodeId(clearDeletedId);
setDirectorNodeId(clearDeletedId);
setVersionCompareRootId(clearDeletedId);
setScriptScrollTopById((current) => Object.fromEntries(Object.entries(current).filter(([id]) => !removedIds.has(id))));
@@ -928,7 +932,7 @@ function InfiniteCanvasPage() {
}
cleanupCanvasFiles({ projectId, nodes: nextNodes, chatSessions });
},
[chatSessions, cleanupCanvasFiles, message, projectId, setAngleNodeId, setAnnotationNodeId, setCropNodeId, setEmotionNodeId, setFrameDialogNodeId, setMaskEditNodeId, setSegmentDialogNodeId, setSplitNodeId, setUpscaleNodeId, setRunningNodeId],
[chatSessions, cleanupCanvasFiles, message, projectId, setAngleNodeId, setAnnotationNodeId, setArtCritiqueNodeId, setCropNodeId, setEmotionNodeId, setFrameDialogNodeId, setMaskEditNodeId, setSegmentDialogNodeId, setSplitNodeId, setUpscaleNodeId, setRunningNodeId],
);
const {
@@ -1030,6 +1034,9 @@ function InfiniteCanvasPage() {
} else if (node.type === PORTRAIT_CLEARANCE_NODE_TYPE) {
setDialogNodeId(null);
setPortraitClearanceNodeId(node.id);
} else if (node.type === ART_CRITIQUE_NODE_TYPE) {
setDialogNodeId(null);
setArtCritiqueNodeId(node.id);
} else {
// 选择参考媒体时保留当前工作流配置面板,避免点击图片后配置“返回/消失”。
// 没有工作流配置面板时,媒体节点仍按原逻辑打开自己的面板。
@@ -1047,7 +1054,7 @@ function InfiniteCanvasPage() {
const handleNodeDragEnd = useCallback((nodeId: string) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === PORTRAIT_CLEARANCE_NODE_TYPE) {
if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === PORTRAIT_CLEARANCE_NODE_TYPE || node.type === ART_CRITIQUE_NODE_TYPE) {
setDialogNodeId(null);
return;
}
@@ -1328,6 +1335,7 @@ function InfiniteCanvasPage() {
const characterReferenceNode = characterReferenceNodeId ? nodeById.get(characterReferenceNodeId) || null : null;
const drawingNode = drawingNodeId ? nodeById.get(drawingNodeId) || null : null;
const portraitClearanceNode = portraitClearanceNodeId ? nodeById.get(portraitClearanceNodeId) || null : null;
const artCritiqueNode = artCritiqueNodeId ? nodeById.get(artCritiqueNodeId) || null : null;
const portraitClearanceInputs = portraitClearanceNode
? connections
.filter((connection) => connection.toNodeId === portraitClearanceNode.id)
@@ -1335,6 +1343,13 @@ function InfiniteCanvasPage() {
.map((connection) => nodeById.get(connection.fromNodeId))
.filter((node): node is CanvasNodeData => Boolean(node))
: [];
const artCritiqueInputs = artCritiqueNode
? connections
.filter((connection) => connection.toNodeId === artCritiqueNode.id)
.sort((left, right) => left.id.localeCompare(right.id))
.map((connection) => nodeById.get(connection.fromNodeId))
.filter((node): node is CanvasNodeData => Boolean(node))
: [];
const addPortraitCandidateToCanvas = useCallback(async (candidate: { id: string; title: string; imageArtifactId: string }, dataUrl: string) => {
const target = portraitClearanceNodeId ? nodesRef.current.find((node) => node.id === portraitClearanceNodeId) : undefined;
if (!target) return;
@@ -1389,6 +1404,15 @@ function InfiniteCanvasPage() {
setToolbarNodeId(null);
setPortraitClearanceNodeId(node.id);
}, []);
const openArtCritique = useCallback((node: CanvasNodeData) => {
if (node.type !== ART_CRITIQUE_NODE_TYPE) return;
setSelectedNodeIds(new Set([node.id]));
setSelectedConnectionId(null);
setContextMenu(null);
setDialogNodeId(null);
setToolbarNodeId(null);
setArtCritiqueNodeId(node.id);
}, []);
const duplicateNodeFromContent = useCallback((node: CanvasNodeData) => duplicateNode(node.id), [duplicateNode]);
const deleteNodeFromContent = useCallback((node: CanvasNodeData) => deleteNodes(new Set([node.id])), [deleteNodes]);
const updateNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => {
@@ -1427,7 +1451,8 @@ function InfiniteCanvasPage() {
updateNode: updateNodeFromContent,
updateMediaNode: updateMediaNodeFromContent,
openPortraitClearance,
}), [deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openPortraitClearance, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
openArtCritique,
}), [deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, openPortraitClearance, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasAgentOperations({
projectId,
domainProjectId: currentProject?.projectId,
@@ -1523,6 +1548,7 @@ function InfiniteCanvasPage() {
setEmotionNodeId(null);
setPreviewNodeId(null);
setRunningNodeId(null);
setArtCritiqueNodeId(null);
deselectCanvas();
setClearConfirmOpen(false);
clearCanvasFiles();
@@ -2757,12 +2783,20 @@ function InfiniteCanvasPage() {
<PortraitClearanceModal
projectId={projectId}
node={portraitClearanceNode}
upstreamNodes={portraitClearanceInputs}
open={Boolean(portraitClearanceNode)}
onClose={() => setPortraitClearanceNodeId(null)}
onUpdateState={(nodeId, state: PortraitClearanceNodeState) => handleConfigNodeChange(nodeId, { portraitClearance: state })}
onAddCandidate={addPortraitCandidateToCanvas}
/>
upstreamNodes={portraitClearanceInputs}
open={Boolean(portraitClearanceNode)}
onClose={() => setPortraitClearanceNodeId(null)}
onUpdateState={(nodeId, state: PortraitClearanceNodeState) => handleConfigNodeChange(nodeId, { portraitClearance: state })}
onAddCandidate={addPortraitCandidateToCanvas}
/>
<AiArtCritiqueModal
node={artCritiqueNode}
upstreamNodes={artCritiqueInputs}
open={Boolean(artCritiqueNode)}
onClose={() => setArtCritiqueNodeId(null)}
onUpdateState={(nodeId, state) => handleConfigNodeChange(nodeId, { artCritique: state })}
/>
<CanvasScriptEditor
node={activeScriptNode}

View File

@@ -8,6 +8,7 @@ import "@/lib/plugins/builtin";
import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle";
import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
import { COMFYUI_PLUGIN_ID, RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows";
import { ART_CRITIQUE_PLUGIN_ID } from "@/lib/art-critique/contracts";
import type { PluginManifest, RegisteredPlugin } from "@/lib/plugins/plugin-types";
import { getEagleLibrary, type EagleFolder } from "@/services/api/eagle";
import { fetchPlugins, setUserPluginEnabled, type BackendPlugin, type PluginState } from "@/services/api/plugins";
@@ -46,6 +47,7 @@ const permissionLabels: Record<string, string> = {
"asset.upload": "上传素材",
"generation.run": "调用生成",
"ai.text": "调用已配置的文本/视觉理解模型",
"media.read": "读取输入媒体",
"external.open": "打开外部详情",
};
@@ -617,7 +619,7 @@ function toRegisteredPlugin(plugin: BackendPlugin): RegisteredPlugin {
}
function isOfficialApplicationPlugin(pluginId: string) {
return [RUNNINGHUB_PLUGIN_ID, COMFYUI_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID, "portrait-clearance"].includes(pluginId);
return [RUNNINGHUB_PLUGIN_ID, COMFYUI_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID, "portrait-clearance", ART_CRITIQUE_PLUGIN_ID].includes(pluginId);
}
function pluginSourceLabel(plugin: RegisteredPlugin, state?: PluginState) {