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

This commit is contained in:
QAyong
2026-08-26 15:23:01 +08:00
committed by ddCat
parent 86fc3fda2f
commit 9bc5512140
54 changed files with 6832 additions and 60 deletions

View File

@@ -87,6 +87,7 @@ import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolba
import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs";
import { CanvasProjectWorldLayers } from "./canvas-project-world-layers";
import { CanvasNodeActionContext } from "@/components/canvas/canvas-node-action-context";
import { PortraitClearanceModal } from "@/components/canvas/portrait-clearance/portrait-clearance-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";
@@ -115,6 +116,7 @@ import { useCanvasShortDrama } from "./use-canvas-short-drama";
import { useCanvasStoryboard } from "./use-canvas-storyboard";
import { useCanvasUpload } from "./use-canvas-upload";
import { useCanvasViewportController } from "./use-canvas-viewport-controller";
import { usePortraitClearanceCoordinator } from "./use-portrait-clearance-coordinator";
import {
CanvasNodeType,
type CanvasAssistantSession,
@@ -134,6 +136,9 @@ import {
type ViewportTransform,
} from "@/types/canvas";
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";
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 })));
@@ -224,6 +229,7 @@ function InfiniteCanvasPage() {
const [superResolveNodeId, setSuperResolveNodeId] = useState<string | null>(null);
const [previewNodeId, setPreviewNodeId] = useState<string | null>(null);
const [scriptEditorNodeId, setScriptEditorNodeId] = useState<string | null>(null);
const [portraitClearanceNodeId, setPortraitClearanceNodeId] = 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);
@@ -842,6 +848,7 @@ function InfiniteCanvasPage() {
setPreviewNodeId(clearDeletedId);
setRunningNodeId(clearDeletedId);
setScriptEditorNodeId(clearDeletedId);
setPortraitClearanceNodeId(clearDeletedId);
setDirectorNodeId(clearDeletedId);
setVersionCompareRootId(clearDeletedId);
setScriptScrollTopById((current) => Object.fromEntries(Object.entries(current).filter(([id]) => !removedIds.has(id))));
@@ -948,6 +955,9 @@ function InfiniteCanvasPage() {
setDialogNodeId(null);
} else if (node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Frame) {
setDialogNodeId((current) => (current === node.id ? current : null));
} else if (node.type === PORTRAIT_CLEARANCE_NODE_TYPE) {
setDialogNodeId(null);
setPortraitClearanceNodeId(node.id);
} else {
// 选择参考媒体时保留当前工作流配置面板,避免点击图片后配置“返回/消失”。
// 没有工作流配置面板时,媒体节点仍按原逻辑打开自己的面板。
@@ -1034,6 +1044,26 @@ function InfiniteCanvasPage() {
setHoveredNodeId,
});
const handlePortraitClearanceStateUpdate = useCallback((nodeId: string, state: PortraitClearanceNodeState) => {
handleConfigNodeChange(nodeId, { portraitClearance: state });
}, [handleConfigNodeChange]);
usePortraitClearanceCoordinator({ nodes, onUpdateState: handlePortraitClearanceStateUpdate });
useEffect(() => {
setNodes((current) => {
let changed = false;
const next = current.map((node) => {
if (node.type !== PORTRAIT_CLEARANCE_NODE_TYPE) return node;
const state = node.metadata?.portraitClearance || createDefaultPortraitClearanceState();
const inputBindings = reconcilePortraitClearanceInputBindings(node.metadata?.portraitClearance?.mode || state.mode, node.id, connections, current, state.inputBindings);
if (JSON.stringify(inputBindings) === JSON.stringify(state.inputBindings) && node.metadata?.portraitClearance) return node;
changed = true;
return { ...node, metadata: { ...node.metadata, portraitClearance: { ...state, inputBindings } } };
});
return changed ? next : current;
});
}, [connections, setNodes]);
const handleRemoveNodeReference = useCallback((targetNodeId: string, reference: CanvasResourceReference) => {
const referenceNodeId = reference.nodeId;
if (!referenceNodeId) return;
@@ -1184,6 +1214,30 @@ function InfiniteCanvasPage() {
const textEditorNode = textEditorNodeId ? nodeById.get(textEditorNodeId) || null : null;
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 portraitClearanceInputs = portraitClearanceNode
? connections
.filter((connection) => connection.toNodeId === portraitClearanceNode.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;
try {
const image = await uploadImage(dataUrl);
const created = createCanvasNode(CanvasNodeType.Image, { x: target.position.x + target.width + 260, y: target.position.y + target.height / 2 }, imageMetadata(image));
created.title = candidate.title.slice(0, 80) || "肖像排查候选";
const connection = { id: nanoid(), fromNodeId: created.id, toNodeId: target.id };
setNodes((current) => [...current, created]);
setConnections((current) => [...current, connection]);
setSelectedNodeIds(new Set([created.id]));
message.success("候选图片已添加到画布并连接到排查节点");
} catch (error) {
message.error(error instanceof Error ? error.message : "候选图片添加失败");
}
}, [message, portraitClearanceNodeId, setConnections, setNodes, setSelectedNodeIds]);
const pendingConnectionSourceNode = pendingConnectionCreate?.connection.handleType === "source" ? nodeById.get(pendingConnectionCreate.connection.nodeId) : null;
const canCreateDrawingFromConnection = !pendingConnectionCreate?.batchSourceNodeIds?.length && pendingConnectionSourceNode?.type === CanvasNodeType.Image && Boolean(pendingConnectionSourceNode.metadata?.content);
@@ -1210,6 +1264,16 @@ function InfiniteCanvasPage() {
setToolbarNodeId(null);
setDrawingNodeId(node.id);
}, []);
const openPortraitClearance = useCallback((node: CanvasNodeData) => {
if (node.type !== PORTRAIT_CLEARANCE_NODE_TYPE) return;
setSelectedNodeIds(new Set([node.id]));
setSelectedConnectionId(null);
setContextMenu(null);
setDialogNodeId(null);
setToolbarNodeId(null);
setPortraitClearanceNodeId(node.id);
}, []);
const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasAgentOperations({
projectId,
domainProjectId: currentProject?.projectId,
@@ -1992,7 +2056,7 @@ function InfiniteCanvasPage() {
onFileDragLeave={handleFileDragLeave}
onFileDragOver={handleFileDragOver}
>
<CanvasNodeActionContext.Provider value={{ download: downloadNodeImage, duplicate: (node) => duplicateNode(node.id), deleteNode: (node) => deleteNodes(new Set([node.id])), updateMetadata: (nodeId, patch) => setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))), resizeNode: (nodeId, size) => setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, width: size.width, height: size.height } : node))) }}>
<CanvasNodeActionContext.Provider value={{ download: downloadNodeImage, duplicate: (node) => duplicateNode(node.id), deleteNode: (node) => deleteNodes(new Set([node.id])), updateMetadata: (nodeId, patch) => setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))), resizeNode: (nodeId, size) => setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, width: size.width, height: size.height } : node))), openPortraitClearance }}>
<CanvasNodeGraphContext.Provider value={nodeGraphContext}>
<CanvasProjectWorldLayers
projectId={projectId}
@@ -2483,6 +2547,16 @@ function InfiniteCanvasPage() {
</Suspense>
) : null}
<PortraitClearanceModal
projectId={projectId}
node={portraitClearanceNode}
upstreamNodes={portraitClearanceInputs}
open={Boolean(portraitClearanceNode)}
onClose={() => setPortraitClearanceNodeId(null)}
onUpdateState={(nodeId, state: PortraitClearanceNodeState) => handleConfigNodeChange(nodeId, { portraitClearance: state })}
onAddCandidate={addPortraitCandidateToCanvas}
/>
<CanvasScriptEditor
node={activeScriptNode}
nodes={nodes}

View File

@@ -13,6 +13,7 @@ import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
import { useUserStore } from "@/stores/use-user-store";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { createDefaultPortraitClearanceState, PORTRAIT_CLEARANCE_NODE_TYPE } from "@/lib/portrait-clearance/contracts";
type CanvasClipboard = {
nodes: CanvasNodeData[];
@@ -137,12 +138,14 @@ export function useCanvasNodeOperations({
? { drawingEngine: defaultDrawingEngine }
: type === CanvasNodeType.Config
? { generationMode: "image", workflowProvider: selectedWorkflowProvider || "model" }
: undefined;
: type === PORTRAIT_CLEARANCE_NODE_TYPE
? { portraitClearance: createDefaultPortraitClearanceState() }
: undefined;
const node = createCanvasNode(type, position || getCanvasCenter(), metadata);
if (workflowTitle) node.title = workflowTitle;
commitNodes([...nodesRef.current, node]);
selectNodes(new Set([node.id]));
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing) setDialogNodeId(node.id);
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing && type !== PORTRAIT_CLEARANCE_NODE_TYPE) setDialogNodeId(node.id);
}, [commitNodes, defaultDrawingEngine, effectiveConfig.comfyBridge.enabled, effectiveConfig.comfyBridge.workflows.length, effectiveConfig.runningHub.enabled, effectiveConfig.runningHub.workflows.length, getCanvasCenter, message, nodesRef, selectNodes, setDialogNodeId, tldrawLicenseKey]);
const createFolder = useCallback((position?: Position, linked?: { id: string; projectId: string; title: string; style: CanvasFolderStyle; theme: CanvasFolderTheme; createdAt: string }) => {
@@ -383,7 +386,7 @@ export function useCanvasNodeOperations({
const sourceNode = sourceByTargetId.get(targetNode.id);
if (sourceNode) cloneDrawingForNode(sourceNode, targetNode, "绘图副本保存失败,请重新复制");
});
if (!isFrameNode(source) && source.type !== CanvasNodeType.Drawing) setDialogNodeId(id);
if (!isFrameNode(source) && source.type !== CanvasNodeType.Drawing && source.type !== PORTRAIT_CLEARANCE_NODE_TYPE) setDialogNodeId(id);
}, [cloneDrawingForNode, commitConnections, commitNodes, connectionsRef, nodesRef, selectNodes, setDialogNodeId]);
const setPrimaryVersion = useCallback((nodeId: string) => {
@@ -494,7 +497,7 @@ export function useCanvasNodeOperations({
selectNodes(topLevelIds);
setContextMenu(null);
const primaryNode = nextNodes.find((node) => !node.parentId);
setDialogNodeId(primaryNode && !isFrameNode(primaryNode) && primaryNode.type !== CanvasNodeType.Drawing ? primaryNode.id : null);
setDialogNodeId(primaryNode && !isFrameNode(primaryNode) && primaryNode.type !== CanvasNodeType.Drawing && primaryNode.type !== PORTRAIT_CLEARANCE_NODE_TYPE ? primaryNode.id : null);
return true;
}, [cloneDrawingForNode, commitConnections, commitNodes, connectionsRef, getCanvasCenter, nodesRef, selectNodes, setContextMenu, setDialogNodeId]);

View File

@@ -0,0 +1,162 @@
import { useEffect, useRef } from "react";
import { readPortraitTask, readPortraitTaskImage, readPortraitTaskResult, claimPortraitModelJob, completePortraitModelJob, failPortraitModelJob, type PortraitRuntimeTask } from "@/services/portrait-clearance-runtime";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { PORTRAIT_CLEARANCE_NODE_TYPE, type PortraitClearanceNodeState, type PortraitRiskLevel } from "@/lib/portrait-clearance/contracts";
import { portraitVisionMessages, portraitVisionModelError, resolvePortraitVisionModel, parsePortraitVisionToolResponse, portraitVisionTool } from "@/lib/portrait-clearance/vision";
import { requestToolResponse } from "@/services/api/image";
import { resolveModelChannel, useEffectiveConfig } from "@/stores/use-config-store";
type PortraitClearanceCoordinatorOptions = {
nodes: CanvasNodeData[];
onUpdateState: (nodeId: string, state: PortraitClearanceNodeState) => void;
};
const PORTRAIT_MODEL_JOB_TIMEOUT_MS = 75_000;
export function usePortraitClearanceCoordinator({ nodes, onUpdateState }: PortraitClearanceCoordinatorOptions) {
const runningRef = useRef(false);
const abortRef = useRef<Set<AbortController>>(new Set());
const nodesRef = useRef(nodes);
nodesRef.current = nodes;
const effectiveConfig = useEffectiveConfig();
useEffect(() => {
let disposed = false;
const poll = async () => {
if (disposed || runningRef.current) return;
const active = nodesRef.current.filter((node) => {
if (node.type !== PORTRAIT_CLEARANCE_NODE_TYPE) return false;
const task = node.metadata?.portraitClearance;
return Boolean(task?.activeTaskId && task.task?.status !== "completed" && task.task?.status !== "partial" && task.task?.status !== "failed" && task.task?.status !== "cancelled");
});
if (!active.length) return;
runningRef.current = true;
try {
for (const node of active) {
if (disposed) break;
const state = node.metadata?.portraitClearance;
const taskId = state?.activeTaskId;
if (!state || !taskId) continue;
try {
const task = await readPortraitTask(taskId);
let next = mergeTaskState(state, task);
if (task.status === "waiting_model" && state.analysisMode === "local-plus-vision") {
const modelError = portraitVisionModelError(effectiveConfig, state.modelPolicy);
if (!modelError) {
await runPortraitModelJobs(taskId, state, effectiveConfig, abortRef, task.modelRef);
const refreshed = await readPortraitTask(taskId);
next = mergeTaskState(state, refreshed);
} else {
next = { ...next, task: { ...next.task!, errorCode: state.modelPolicy.mode === "pinned" ? "portrait_vision_model_removed" : "portrait_vision_model_unavailable", errorMessage: modelError } };
}
}
const effectiveTask = task.status === "waiting_model" && state.analysisMode === "local-plus-vision" ? await readPortraitTask(taskId).catch(() => task) : task;
if ((effectiveTask.status === "completed" || effectiveTask.status === "partial") && effectiveTask.detailsAvailable) {
try {
const result = await readPortraitTaskResult(taskId);
if (isResult(result)) next = { ...next, lastResult: { taskId, highestRisk: result.highestRisk, riskCounts: result.riskCounts, candidateCount: result.candidateCount, comparedCount: result.comparedCount, ...(effectiveTask.modelRef ? { modelRef: effectiveTask.modelRef } : {}), completedAt: effectiveTask.completedAt || effectiveTask.updatedAt, detailsAvailable: true } };
} catch {
// The task summary remains useful if the large result is temporarily unavailable.
}
}
if (effectiveTask.status === "completed" || effectiveTask.status === "partial") next = { ...next, activeTaskId: undefined };
onUpdateState(node.id, next);
} catch {
// Runtime reconnects are expected; the next poll resumes from the durable task id.
}
}
} finally {
runningRef.current = false;
}
};
void poll();
const timer = window.setInterval(() => void poll(), 1_500);
return () => {
disposed = true;
window.clearInterval(timer);
for (const controller of abortRef.current) controller.abort();
abortRef.current.clear();
};
}, [effectiveConfig, onUpdateState]);
}
async function runPortraitModelJobs(taskId: string, state: PortraitClearanceNodeState, config: ReturnType<typeof useEffectiveConfig>, abortRef: { current: Set<AbortController> }, frozenModelRef?: string) {
const model = frozenModelRef || resolvePortraitVisionModel(config, state.modelPolicy);
const channel = resolveModelChannel(config, model);
const channelLimit = Number.isInteger(channel.concurrencyLimit) && (channel.concurrencyLimit || 0) > 0 ? channel.concurrencyLimit! : 10;
const limit = Math.min(10, Math.max(1, state.settings.modelConcurrency), channelLimit);
const jobs = [];
for (let index = 0; index < limit; index += 1) {
const job = await claimPortraitModelJob(taskId);
if (!job?.leaseToken) break;
jobs.push(job);
}
await Promise.all(jobs.map((job) => runPortraitModelJob(taskId, state, config, abortRef, job, model)));
}
async function runPortraitModelJob(taskId: string, state: PortraitClearanceNodeState, config: ReturnType<typeof useEffectiveConfig>, abortRef: { current: Set<AbortController> }, job: Awaited<ReturnType<typeof claimPortraitModelJob>>, model: string) {
if (!job?.leaseToken) return;
const controller = new AbortController();
let timedOut = false;
let timeoutId: number | undefined;
abortRef.current.add(controller);
try {
const timeout = new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(() => {
timedOut = true;
controller.abort();
reject(new Error("portrait_model_timeout"));
}, PORTRAIT_MODEL_JOB_TIMEOUT_MS);
});
const work = (async () => {
const result = await readPortraitTaskResult(taskId, controller.signal);
const pair = isResult(result) ? result.pairs.find((candidate) => candidate.id === job.pairId) : undefined;
if (!pair) {
await failPortraitModelJob(taskId, job, "portrait_model_job_conflict", "模型作业对应的比对结果不存在", false, controller.signal);
return;
}
const [query, comparison] = await Promise.all([readPortraitTaskImage(taskId, job.queryImageId, controller.signal), readPortraitTaskImage(taskId, job.comparisonImageId, controller.signal)]);
const response = await requestToolResponse({ ...config, model }, portraitVisionMessages({ queryDataUrl: query.dataUrl, comparisonDataUrl: comparison.dataUrl, queryName: job.queryImageId, comparisonName: job.comparisonImageId, localPrecheck: pair.localPrecheck }), [portraitVisionTool], { type: "function", name: "submit_portrait_comparison" }, undefined, { signal: controller.signal });
if (timedOut) throw new Error("portrait_model_timeout");
const vision = parsePortraitVisionToolResponse(response);
await completePortraitModelJob(taskId, job, vision as unknown as Record<string, unknown>, controller.signal);
})();
await Promise.race([work, timeout]);
} catch (error) {
if (controller.signal.aborted && !timedOut) return;
const rawMessage = error instanceof Error ? error.message : "视觉模型分析失败";
const message = timedOut ? "视觉模型请求超过 75 秒,已跳过该候选" : rawMessage;
const errorCode = timedOut ? "portrait_model_timeout" : rawMessage === "portrait_vision_result_invalid" || rawMessage === "portrait_vision_tool_missing" ? "portrait_vision_result_invalid" : "portrait_model_rate_limited";
const retryable = !timedOut && rawMessage !== "portrait_vision_result_invalid" && rawMessage !== "portrait_vision_tool_missing";
try {
await failPortraitModelJob(taskId, job, errorCode, message, retryable);
} catch {
// The lease may have expired or the task may have been stopped; the next poll can reclaim it safely.
}
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
abortRef.current.delete(controller);
}
}
function mergeTaskState(state: PortraitClearanceNodeState, task: PortraitRuntimeTask): PortraitClearanceNodeState {
return {
...state,
activeTaskId: task.taskId,
task: {
status: task.status,
stage: task.stage,
progress: task.progress,
processedCandidates: task.processedCandidates,
...(task.totalCandidates === undefined ? {} : { totalCandidates: task.totalCandidates }),
...(task.errorCode ? { errorCode: task.errorCode } : {}),
...(task.errorMessage ? { errorMessage: task.errorMessage } : {}),
updatedAt: task.updatedAt,
},
};
}
function isResult(value: unknown): value is { highestRisk: PortraitRiskLevel; riskCounts: Partial<Record<PortraitRiskLevel, number>>; candidateCount: number; comparedCount: number; pairs: Array<{ id: string; queryImageId: string; comparisonImageId: string; localPrecheck: unknown }> } {
return Boolean(value && typeof value === "object" && "highestRisk" in value && typeof (value as { highestRisk?: unknown }).highestRisk === "string" && "riskCounts" in value && typeof (value as { riskCounts?: unknown }).riskCounts === "object" && "candidateCount" in value && typeof (value as { candidateCount?: unknown }).candidateCount === "number" && "comparedCount" in value && typeof (value as { comparedCount?: unknown }).comparedCount === "number" && Array.isArray((value as { pairs?: unknown }).pairs));
}

View File

@@ -44,6 +44,7 @@ const permissionLabels: Record<string, string> = {
"asset.import": "导入素材",
"asset.upload": "上传素材",
"generation.run": "调用生成",
"ai.text": "调用已配置的文本/视觉理解模型",
"external.open": "打开外部详情",
};

View File

@@ -8,6 +8,7 @@ const permissionLabels: Record<string, string> = {
"asset.import": "导入素材",
"asset.upload": "上传素材",
"generation.run": "调用生成",
"ai.text": "调用已配置的文本/视觉理解模型",
"external.open": "打开外部详情",
};