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

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

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

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

No files matched your search

+43 -9
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}