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

This commit is contained in:
QAyong authored and ddCat committed 2026-08-26 15:23:19 +08:00
1 parent 86fc3fda2f
commit 9bc5512140
54 files changed
+6832 -60

No files matched your search

@@ -10,6 +10,7 @@ import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
import { isCanvasFolderNode } from "@/lib/canvas/canvas-frame";
import { resolveAddNodeMenuCommands, type AddNodeMenuContext } from "@/lib/canvas/tool-registry";
import { useThemeStore } from "@/stores/use-theme-store";
import { usePluginStore } from "@/stores/use-plugin-store";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas";
type CanvasAssetCategory = NonNullable<NonNullable<CanvasNodeData["metadata"]>["assetCategory"]>;
@@ -260,10 +261,12 @@ export function CanvasNodeContextMenu({
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const installations = usePluginStore((state) => state.installations);
const left = getSubmenuLeft(parentPosition.left);
const createContext: AddNodeMenuContext = {
workspaceMode,
isProjectLinked,
enabledPluginIds: new Set(installations.filter((item) => item.enabled).map((item) => item.manifest.id)),
handlers: {
onAddText: () => onAddNode(CanvasNodeType.Text),
onAddImage: () => onAddNode(CanvasNodeType.Image),
@@ -13,6 +13,8 @@ export type CanvasNodeActionContextValue = {
updateMetadata?: (nodeId: string, patch: CanvasNodeMetadata) => void;
/** 改节点宽高;图片首次量到真实尺寸后按比例校正节点用。 */
resizeNode?: (nodeId: string, size: { width: number; height: number }) => void;
/** 打开节点级肖像排查工作台;任务生命周期由画布页面持有。 */
openPortraitClearance?: (node: CanvasNodeData) => void;
};
export const CanvasNodeActionContext = createContext<CanvasNodeActionContextValue>({});
@@ -16,6 +16,7 @@ import type { GenerationTask } from "@/services/api/task-center";
import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { PORTRAIT_CLEARANCE_NODE_TYPE } from "@/lib/portrait-clearance/contracts";
import { createDefaultSubtitleStyle } from "@/types/timeline";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { useCanvasNodeActions } from "./canvas-node-action-context";
@@ -27,8 +28,9 @@ import { ColorGradeNodeContent } from "./nodes/color-grade-node";
import { HtmlNodeContent } from "./nodes/html-node";
import { PanoramaNodeContent } from "./nodes/panorama-node";
import { SvgNodeContent } from "./nodes/svg-node";
import { PortraitClearanceNodeContent } from "./nodes/portrait-clearance-node";
type CanvasNodeContentProps = {
export type CanvasNodeContentProps = {
node: CanvasNodeData;
theme: CanvasTheme;
isEditingContent: boolean;
@@ -58,6 +60,7 @@ export function CanvasNodeContent(props: CanvasNodeContentProps) {
|| (props.node.metadata?.workflowKind === "story_input" && !props.isEditingContent)
|| (props.node.metadata?.workflowKind === "styleboard" && !props.node.metadata.content);
if (hasCustomContent && props.renderNodeContent) return props.renderNodeContent(props.node);
if (props.node.type === PORTRAIT_CLEARANCE_NODE_TYPE) return <PortraitClearanceNodeContent node={props.node} />;
if (props.isBatchRoot) return <ImageNodeContent {...props} />;
if (props.node.metadata?.status === "loading") return <LoadingContent node={props.node} theme={props.theme} onOpenTaskDetails={props.onOpenTaskDetails} />;
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} onReloadResource={props.onReloadResource} />;
+6 -2
View File
@@ -10,7 +10,9 @@ import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } f
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type Position } from "@/types/canvas";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { getNodeMinSize, shouldKeepAspectRatio } from "@/lib/canvas/node-registry";
import { PortraitClearanceIcon } from "@/components/canvas/portrait-clearance/portrait-clearance-icon";
import { PORTRAIT_CLEARANCE_NODE_TYPE } from "@/lib/portrait-clearance/contracts";
import { getNodeDefinition, getNodeMinSize, shouldKeepAspectRatio } from "@/lib/canvas/node-registry";
import { CanvasNodeContent, CanvasNodeImageInfo } from "./canvas-node-content";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
@@ -118,6 +120,7 @@ export const CanvasNode = React.memo(function CanvasNode({
const showStatusTrack = Boolean(resourceLabel || data.metadata?.locked || isBatchRoot || (isBatchChild && !readOnly) || (hasMediaContent && !readOnly));
const isActive = isConnectionTarget || isSelected || isFocusRelated;
const nodeState = isFocusRelated ? "focus" : isConnectionTarget ? "target" : isSelected ? "selected" : isRelated && !isBatchChild ? "related" : "idle";
const showOutputConnection = data.type !== PORTRAIT_CLEARANCE_NODE_TYPE && getNodeDefinition(data.type)?.showOutputConnection !== false;
const assetTags = data.metadata?.assetTags?.filter((tag) => tag.trim()) || [];
const scriptMinHeight = data.type === CanvasNodeType.Script ? storyboardMinNodeHeight(data.metadata?.storyboardComposerHeight) : null;
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -456,7 +459,7 @@ export const CanvasNode = React.memo(function CanvasNode({
</div>
{!readOnly && data.type !== CanvasNodeType.Script && (hovered || forceInputVisible) ? <ConnectionSideRail side="left" scale={scale} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && hovered ? <ConnectionSideRail side="right" scale={scale} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && showOutputConnection && hovered ? <ConnectionSideRail side="right" scale={scale} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
</div>
);
@@ -668,6 +671,7 @@ function nodeTypeIcon(type: CanvasNodeTypeId) {
if (type === CanvasNodeType.Script) return Clapperboard;
if (type === CanvasNodeType.Config) return Settings2;
if (type === CanvasNodeType.Skill) return BookOpenCheck;
if (type === PORTRAIT_CLEARANCE_NODE_TYPE) return PortraitClearanceIcon;
return Type;
}
+5 -1
View File
@@ -13,6 +13,7 @@ import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
import { defaultToolbarPrefs, readToolbarPrefs, resolveAddNodeMenuCommands, resolveToolbarEntries, type AddNodeMenuCommand, type ToolContext, type ToolbarHandlers, type ToolbarPrefs } from "@/lib/canvas/tool-registry";
import { useThemeStore } from "@/stores/use-theme-store";
import { usePluginStore } from "@/stores/use-plugin-store";
import type { CanvasNodeTypeId, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
export function CanvasToolbar({
@@ -83,6 +84,7 @@ export function CanvasToolbar({
const rootRef = useRef<HTMLDivElement>(null);
const dockRef = useRef<HTMLDivElement>(null);
const colorTheme = useThemeStore((state) => state.theme);
const installations = usePluginStore((state) => state.installations);
const setTheme = useThemeStore((state) => state.setTheme);
const theme = canvasThemes[colorTheme];
const [addOpen, setAddOpen] = useState(false);
@@ -171,10 +173,12 @@ export function CanvasToolbar({
handlers,
};
const enabledPluginIds = new Set(installations.filter((item) => item.enabled).map((item) => item.manifest.id));
const items = resolveToolbarEntries("main", ctx, prefs ?? defaultToolbarPrefs("main"));
// 解析添加节点菜单命令——onClick 绑定到 runAddAction 以在执行后关闭面板
const addNodeCommands = resolveAddNodeMenuCommands(ctx);
const addNodeCommands = resolveAddNodeMenuCommands({ ...ctx, enabledPluginIds });
const toCommand = (cmd: AddNodeMenuCommand): CanvasCreateCommand => ({
id: cmd.id,
label: cmd.label,
@@ -0,0 +1,117 @@
import { ArrowRight, CircleAlert, Image as ImageIcon, ShieldAlert, ShieldCheck } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { PORTRAIT_CLEARANCE_PLUGIN_ID, PORTRAIT_RISK_LABELS, type PortraitClearanceInputRole, type PortraitRiskLevel } from "@/lib/portrait-clearance/contracts";
import { isPortraitImageInput } from "@/lib/portrait-clearance/input-bindings";
import { usePluginStore } from "@/stores/use-plugin-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { useCanvasNodeActions } from "../canvas-node-action-context";
import { useUpstreamNodes } from "../canvas-node-graph-context";
import { PortraitClearanceIcon } from "../portrait-clearance/portrait-clearance-icon";
type PortraitClearanceNodeProps = {
node: CanvasNodeData;
};
const roleLabels: Record<PortraitClearanceInputRole, string> = {
query: "查询图",
reference: "参考图",
candidate: "手动候选",
};
export function PortraitClearanceNodeContent({ node }: PortraitClearanceNodeProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const installations = usePluginStore((state) => state.installations);
const { openPortraitClearance } = useCanvasNodeActions();
const upstream = useUpstreamNodes(node.id).filter((item) => isPortraitImageInput(item));
const state = node.metadata?.portraitClearance;
const enabled = Boolean(installations.find((item) => item.manifest.id === PORTRAIT_CLEARANCE_PLUGIN_ID)?.enabled);
const bindings = state?.inputBindings || [];
const roleByNodeId = new Map(bindings.map((binding) => [binding.nodeId, binding.role]));
const query = upstream.find((item) => roleByNodeId.get(item.id) === "query") || upstream[0];
const reference = upstream.find((item) => roleByNodeId.get(item.id) === "reference") || upstream[1];
const candidates = upstream.filter((item) => (roleByNodeId.get(item.id) || "candidate") === "candidate");
const task = state?.task;
const lastResult = state?.lastResult;
const risk = lastResult?.highestRisk;
const title = state?.mode === "network-search" ? "网络排查" : "直接比对";
return (
<div
className="flex h-full min-h-0 w-full flex-col gap-3 overflow-hidden rounded-[inherit] p-3"
style={{ background: theme.node.panel, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="flex min-w-0 items-center gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--r-md)]" style={{ background: theme.toolbar.itemHover, color: theme.accent.primary }}>
<PortraitClearanceIcon className="size-4" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[var(--fs-label)] font-semibold">{title}</div>
<div className="truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{enabled ? "本地预检 · 结果保存在本机" : "插件已停用 · 历史结果只读"}</div>
</div>
{risk ? <RiskBadge risk={risk} /> : null}
</div>
<div className="grid min-h-0 flex-1 grid-cols-2 gap-2">
<InputSlot label={roleLabels.query} node={query} />
{state?.mode === "network-search" ? (
<InputSlot label={`${roleLabels.candidate} ${candidates.length ? `· ${candidates.length}` : ""}`} node={candidates[0]} count={candidates.length} />
) : (
<InputSlot label={roleLabels.reference} node={reference} />
)}
</div>
{task && !isTerminal(task.status) ? <div className="flex min-h-9 items-center gap-2 rounded-[var(--r-md)] border px-2.5 py-2" style={{ borderColor: theme.node.edge, background: theme.node.fill }}><TaskSummary stage={task.stage} progress={task.progress} processed={task.processedCandidates} /></div> : lastResult ? null : <div className="flex min-h-9 items-center gap-2 rounded-[var(--r-md)] border px-2.5 py-2" style={{ borderColor: theme.node.edge, background: theme.node.fill }}><EmptySummary mode={state?.mode || "direct-compare"} /></div>}
<div className="flex items-center gap-2">
<button
type="button"
data-canvas-no-zoom
className="inline-flex min-h-11 min-w-0 flex-1 items-center justify-center gap-2 rounded-[var(--r-md)] px-4 text-[var(--fs-label)] font-semibold outline-none transition hover:brightness-105 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: theme.accent.primary, color: theme.accent.onPrimary, outlineColor: theme.accent.primary }}
disabled={!enabled && !lastResult}
onClick={(event) => { event.stopPropagation(); openPortraitClearance?.(node); }}
>
{lastResult ? "查看完整结果" : "打开排查工作台"}
<ArrowRight className="size-4" aria-hidden="true" />
</button>
</div>
</div>
);
}
function InputSlot({ label, node, count = 0 }: { label: string; node?: CanvasNodeData; count?: number }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const preview = node?.metadata?.previewContent || node?.metadata?.content || "";
return (
<div className="relative min-h-0 overflow-hidden rounded-[var(--r-md)] border" style={{ borderColor: theme.node.edge, background: theme.node.fill }}>
{preview && node?.type === CanvasNodeType.Image ? <img src={preview} alt="" className="absolute inset-0 size-full object-contain" draggable={false} /> : <div className="absolute inset-0 grid place-items-center"><ImageIcon className="size-6 opacity-30" aria-hidden="true" /></div>}
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-1 bg-gradient-to-t from-black/70 to-transparent px-2 pb-1.5 pt-5 text-white">
<span className="truncate text-[var(--fs-micro)] font-semibold">{label}</span>
{count > 0 ? <span className="shrink-0 text-[var(--fs-micro)]">{count}</span> : null}
</div>
</div>
);
}
function RiskBadge({ risk }: { risk: PortraitRiskLevel }) {
const tone = risk === "high" ? "var(--status-error)" : risk === "unable_to_determine" ? "var(--status-warning)" : "var(--status-success)";
const Icon = risk === "high" ? ShieldAlert : risk === "unable_to_determine" ? CircleAlert : ShieldCheck;
return <span className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[var(--fs-micro)] font-semibold" style={{ color: tone, background: `color-mix(in oklch, ${tone} 15%, transparent)` }}><Icon className="size-3" aria-hidden="true" />{PORTRAIT_RISK_LABELS[risk]}</span>;
}
function TaskSummary({ stage, progress, processed }: { stage: string; progress: number; processed: number }) {
const percent = progress >= 1 ? 100 : Math.min(99, Math.round(Math.max(0, Math.min(1, progress)) * 100));
return <div className="min-w-0 flex-1"><div className="flex items-center justify-between gap-2 text-[var(--fs-micro)]"><span className="truncate">{stage}</span><span className="shrink-0 tabular-nums">{percent}% · {processed} 张</span></div><div className="mt-1 h-1 overflow-hidden rounded-full bg-black/10 dark:bg-white/10"><div className="h-full rounded-full bg-[var(--workspace-accent)] transition-[width]" style={{ width: `${percent}%` }} /></div></div>;
}
function EmptySummary({ mode }: { mode: "direct-compare" | "network-search" }) {
return <div className="flex min-w-0 flex-1 items-center gap-2 text-[var(--fs-micro)] text-[var(--foreground-muted)]"><PortraitClearanceIcon className="size-3.5 shrink-0" aria-hidden="true" /><span className="truncate">{mode === "network-search" ? "连接查询图后可开始百度识图排查" : "连接查询图和参考图后可开始直接比对"}</span></div>;
}
function isTerminal(status: string) {
return ["partial", "completed", "failed", "cancelled"].includes(status);
}
@@ -0,0 +1,26 @@
import type { SVGProps } from "react";
/** 肖像排查插件图标:人脸轮廓 + 扫描框 + 校验标记。 */
export function PortraitClearanceIcon({ className, strokeWidth = 1.8, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
>
<path d="M8 3.75H5.5A1.75 1.75 0 0 0 3.75 5.5V8" />
<path d="M16 3.75h2.5A1.75 1.75 0 0 1 20.25 5.5V8" />
<path d="M3.75 16v2.5A1.75 1.75 0 0 0 5.5 20.25H8" />
<path d="M16 20.25h2.5a1.75 1.75 0 0 0 1.75-1.75V16" />
<path d="M8.25 11.25a3.75 3.75 0 1 1 7.5 0V12a3.75 3.75 0 1 1-7.5 0Z" />
<path d="M10 11h.01M14 11h.01M10.5 13c.9.65 2.1.65 3 0" />
<path d="m15.8 17.7 1.25 1.25 2.35-2.35" />
</svg>
);
}
@@ -0,0 +1,486 @@
import { Alert, Button, InputNumber, Modal, Popconfirm, Segmented, Select, Switch, Tag } from "antd";
import { CircleAlert, Download, History, Image as ImageIcon, Pause, Play, ScanFace, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { createDefaultPortraitClearanceState, PORTRAIT_CLEARANCE_PLUGIN_ID, PORTRAIT_RISK_LABELS, PORTRAIT_TASK_STAGE_LABELS, type PortraitClearanceAnalysisMode, type PortraitClearanceMode, type PortraitClearanceNodeState } from "@/lib/portrait-clearance/contracts";
import { isPortraitImageInput, swapPortraitClearanceDirectBindings } from "@/lib/portrait-clearance/input-bindings";
import { portraitVisionModelError, portraitVisionModelLabel, portraitVisionModels, resolvePortraitVisionModel } from "@/lib/portrait-clearance/vision";
import { usePluginStore } from "@/stores/use-plugin-store";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { cancelPortraitTask, createPortraitClearanceTask, deletePortraitTask, downloadPortraitReport, imageNodeDataUrl, installPortraitClearanceModels, listPortraitTasks, portraitOwnerScopeHash, readPortraitRuntimeStatus, readPortraitTaskResult, retryPortraitTask, type PortraitRuntimeStatus, type PortraitRuntimeTask } from "@/services/portrait-clearance-runtime";
import type { PortraitFeatureKey, PortraitPairResult, PortraitRiskLevel } from "@/lib/portrait-clearance/contracts";
type PortraitClearanceModalProps = {
projectId: string;
node: CanvasNodeData | null;
upstreamNodes: CanvasNodeData[];
open: boolean;
onClose: () => void;
onUpdateState: (nodeId: string, state: PortraitClearanceNodeState) => void;
onAddCandidate?: (candidate: { id: string; title: string; imageArtifactId: string }, dataUrl: string) => void | Promise<void>;
};
export function PortraitClearanceModal({ projectId, node, upstreamNodes, open, onClose, onUpdateState, onAddCandidate }: PortraitClearanceModalProps) {
const installations = usePluginStore((state) => state.installations);
const effectiveConfig = useEffectiveConfig();
const enabled = Boolean(installations.find((item) => item.manifest.id === PORTRAIT_CLEARANCE_PLUGIN_ID)?.enabled);
const state = node?.metadata?.portraitClearance || createDefaultPortraitClearanceState();
const [starting, setStarting] = useState(false);
const [installingModels, setInstallingModels] = useState(false);
const [runtimeStatus, setRuntimeStatus] = useState<PortraitRuntimeStatus | null>(null);
const [runtimeConnecting, setRuntimeConnecting] = useState(false);
const [runtimeRetryNonce, setRuntimeRetryNonce] = useState(0);
const [runtimeError, setRuntimeError] = useState("");
const [history, setHistory] = useState<PortraitRuntimeTask[]>([]);
const [result, setResult] = useState<PortraitResultView | null>(null);
const [reportFormat, setReportFormat] = useState<"json" | "md" | "html" | "docx">("html");
const [busyAction, setBusyAction] = useState("");
const imageInputs = useMemo(() => upstreamNodes.filter((item) => isPortraitImageInput(item)), [upstreamNodes]);
const hasQuery = Boolean(state.inputBindings.some((binding) => binding.role === "query" && imageInputs.some((item) => item.id === binding.nodeId)));
const hasReference = Boolean(state.inputBindings.some((binding) => binding.role === "reference" && imageInputs.some((item) => item.id === binding.nodeId)));
const query = imageInputs.find((item) => state.inputBindings.some((binding) => binding.nodeId === item.id && binding.role === "query")) || imageInputs[0];
const reference = imageInputs.find((item) => state.inputBindings.some((binding) => binding.nodeId === item.id && binding.role === "reference")) || imageInputs[1];
const candidateNodes = useMemo(() => imageInputs.filter((item) => {
if (item.id === query?.id) return false;
const binding = state.inputBindings.find((candidate) => candidate.nodeId === item.id);
return binding?.role === "candidate" || (!binding && state.mode === "network-search");
}), [imageInputs, query?.id, state.inputBindings, state.mode]);
const runtimeReady = Boolean(runtimeStatus?.ready);
const selectedModel = state.analysisMode === "local-plus-vision" ? resolvePortraitVisionModel(effectiveConfig, state.modelPolicy) : "";
const visionModelError = state.analysisMode === "local-plus-vision" ? portraitVisionModelError(effectiveConfig, state.modelPolicy) : "";
const canStart = enabled && runtimeReady && !visionModelError && (state.mode === "network-search" ? hasQuery : hasQuery && hasReference);
useEffect(() => {
if (!open || !enabled) return;
let disposed = false;
let retryTimer: number | undefined;
let attempt = 0;
const load = async () => {
if (disposed) return;
setRuntimeConnecting(true);
try {
const status = await readPortraitRuntimeStatus();
if (disposed) return;
setRuntimeStatus(status);
setRuntimeError("");
attempt = 0;
} catch (error) {
if (disposed) return;
setRuntimeStatus(null);
setRuntimeError(error instanceof Error ? error.message : "本机肖像引擎不可用");
attempt += 1;
retryTimer = window.setTimeout(load, Math.min(5_000, 800 + attempt * 700));
} finally {
if (!disposed) setRuntimeConnecting(false);
}
};
setRuntimeStatus(null);
setRuntimeError("");
void load();
return () => {
disposed = true;
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
};
}, [enabled, open, runtimeRetryNonce]);
useEffect(() => {
if (!open || !node) return;
let disposed = false;
void listPortraitTasks({ projectId, nodeId: node.id, limit: 40 }).then((page) => { if (!disposed) setHistory(page.tasks); }).catch(() => { if (!disposed) setHistory([]); });
return () => { disposed = true; };
}, [node, open, projectId, state.activeTaskId]);
useEffect(() => {
if (!open || !node?.metadata?.portraitClearance?.lastResult?.taskId) return;
let disposed = false;
void readPortraitTaskResult(node.metadata.portraitClearance.lastResult.taskId).then((value) => {
if (disposed) return;
const parsed = parsePortraitResult(value);
setResult(parsed);
const lastResult = node.metadata?.portraitClearance?.lastResult;
if (lastResult && (lastResult.comparedCount !== parsed.comparedCount || lastResult.highestRisk !== parsed.highestRisk)) {
onUpdateState(node.id, { ...(node.metadata?.portraitClearance || createDefaultPortraitClearanceState()), lastResult: { ...lastResult, comparedCount: parsed.comparedCount, candidateCount: parsed.candidateCount, highestRisk: parsed.highestRisk, riskCounts: parsed.riskCounts } });
}
}).catch(() => { if (!disposed) setResult(null); });
return () => { disposed = true; };
}, [node, open]);
const patchState = (patch: Partial<PortraitClearanceNodeState>) => {
if (!node) return;
onUpdateState(node.id, { ...state, ...patch, settings: { ...state.settings, ...(patch.settings || {}) } });
};
const startTask = async () => {
if (!node || !canStart || starting) return;
setStarting(true);
setRuntimeError("");
try {
const ownerScopeHash = await portraitOwnerScopeHash();
const inputNodes = imageInputs.filter((item) => {
const binding = state.inputBindings.find((candidate) => candidate.nodeId === item.id);
if (state.mode === "direct-compare") return binding?.role === "query" || binding?.role === "reference" || (!binding && (item.id === query?.id || item.id === reference?.id));
return binding?.role === "query" || binding?.role === "candidate" || (!binding && item.id === query?.id);
});
const inputs = await Promise.all(inputNodes.map(async (item) => {
const binding = state.inputBindings.find((candidate) => candidate.nodeId === item.id);
const role = binding?.role || (item.id === query?.id ? "query" : state.mode === "direct-compare" ? "reference" : "candidate");
const file = await imageNodeDataUrl(item);
return { ...file, nodeId: item.id, role };
}));
const inputBytes = inputs.reduce((total, item) => total + decodedDataUrlBytes(item.dataUrl), 0);
if (inputBytes > 20 * 1024 * 1024) throw new Error("肖像排查图片总大小不能超过 20MB");
const task = await createPortraitClearanceTask({
projectId,
nodeId: node.id,
ownerScopeHash,
clientOperationId: `portrait-${node.id}-${Date.now()}`,
mode: state.mode,
analysisMode: state.analysisMode,
...(selectedModel ? { modelRef: selectedModel } : {}),
settings: state.settings,
inputs,
});
onUpdateState(node.id, {
...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 },
});
} catch (error) {
setRuntimeError(error instanceof Error ? error.message : "本机肖像任务创建失败");
} finally {
setStarting(false);
}
};
const stopTask = async () => {
if (!state.activeTaskId || busyAction) return;
setBusyAction("stop");
try {
const task = await cancelPortraitTask(state.activeTaskId);
// Keep the durable task id after stopping so the user can retry the same
// local task without losing its downloaded candidates and audit trail.
if (node) onUpdateState(node.id, { ...state, activeTaskId: state.activeTaskId, task: { status: task.status, stage: task.stage, progress: task.progress, processedCandidates: task.processedCandidates, ...(task.errorCode ? { errorCode: task.errorCode } : {}), ...(task.errorMessage ? { errorMessage: task.errorMessage } : {}), updatedAt: task.updatedAt } });
} catch (error) {
setRuntimeError(error instanceof Error ? error.message : "停止任务失败");
} finally {
setBusyAction("");
}
};
const retryTask = async () => {
if (!node || !state.task || !state.task.errorCode || (!state.activeTaskId && !state.lastResult?.taskId)) return;
const taskId = state.activeTaskId || state.lastResult?.taskId;
if (!taskId || busyAction) return;
setBusyAction("retry");
try {
const task = await retryPortraitTask(taskId);
onUpdateState(node.id, { ...state, activeTaskId: task.taskId, task: { status: task.status, stage: task.stage, progress: task.progress, processedCandidates: task.processedCandidates, updatedAt: task.updatedAt }, lastResult: undefined });
} catch (error) {
setRuntimeError(error instanceof Error ? error.message : "重试任务失败");
} finally {
setBusyAction("");
}
};
const exportReport = async () => {
const taskId = state.lastResult?.taskId;
if (!taskId || busyAction) return;
setBusyAction("export");
try {
const artifactId = reportFormat === "json" ? "clearance-result.json" : `clearance-report.${reportFormat}` as "clearance-report.md" | "clearance-report.html" | "clearance-report.docx";
const report = await downloadPortraitReport(taskId, artifactId);
const extension = reportFormat;
const blob = new Blob([report.bytes], { type: report.mimeType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `portrait-clearance-${taskId}.${extension}`;
anchor.click();
URL.revokeObjectURL(url);
} catch (error) {
setRuntimeError(error instanceof Error ? error.message : "导出报告失败");
} finally {
setBusyAction("");
}
};
const removeHistory = async (taskId: string) => {
if (busyAction) return;
setBusyAction(`delete-${taskId}`);
try {
await deletePortraitTask(taskId);
setHistory((current) => current.filter((item) => item.taskId !== taskId));
if (state.lastResult?.taskId === taskId && node) onUpdateState(node.id, { ...state, lastResult: undefined });
} catch (error) {
setRuntimeError(error instanceof Error ? error.message : "删除本地排查数据失败");
} finally {
setBusyAction("");
}
};
const openHistoryTask = async (task: PortraitRuntimeTask) => {
if (!node || busyAction) return;
setBusyAction(`open-${task.taskId}`);
try {
const value = parsePortraitResult(await readPortraitTaskResult(task.taskId));
setResult(value);
onUpdateState(node.id, { ...state, activeTaskId: undefined, 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 }, lastResult: { taskId: task.taskId, highestRisk: value.highestRisk, riskCounts: value.riskCounts, candidateCount: value.candidateCount, comparedCount: value.comparedCount, ...(task.modelRef ? { modelRef: task.modelRef } : {}), completedAt: task.completedAt || task.updatedAt, detailsAvailable: true } });
} catch (error) {
setRuntimeError(error instanceof Error ? error.message : "读取历史排查结果失败");
} finally {
setBusyAction("");
}
};
const installModels = async () => {
if (installingModels) return;
setInstallingModels(true);
setRuntimeError("");
try {
setRuntimeStatus(await installPortraitClearanceModels());
} catch (error) {
setRuntimeError(error instanceof Error ? error.message : "本地肖像模型安装失败");
} finally {
setInstallingModels(false);
}
};
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
title={null}
closable={false}
width="min(1600px, calc(100vw - 32px))"
centered
destroyOnHidden={false}
className="portrait-clearance-modal"
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}
>
<div className="flex max-h-[calc(100dvh-32px)] min-h-[min(680px,calc(100dvh-32px))] flex-col overflow-hidden bg-background text-foreground">
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b px-5 py-4" style={{ borderColor: "var(--border)" }}>
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-foreground text-background"><ScanFace className="size-5" aria-hidden="true" /></span>
<div className="min-w-0 flex-1">
<h2 className="truncate text-base font-semibold">{node?.title || "肖像可识别性排查"}</h2>
<p className="mt-0.5 text-xs text-foreground/55">本地人脸预检、候选排查与审慎风险报告</p>
</div>
<Tag icon={enabled ? <ShieldCheck className="size-3" /> : <CircleAlert className="size-3" />} color={enabled ? "green" : "default"} style={enabled ? undefined : portraitRiskTagStyle("medium")} title={enabled ? "本地肖像排查功能可用" : "已有节点、摘要和本机历史仍可查看;重新运行前请到插件中心启用肖像排查。"}>{enabled ? "插件已启用" : "插件已停用"}</Tag>
<Button aria-label="关闭排查工作台" onClick={onClose}>关闭</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-5" data-canvas-wheel-scroll style={{ scrollPaddingBottom: 96 }}>
<div className="grid gap-5 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="flex min-w-0 flex-col gap-4">
<section className="rounded-xl border p-3" style={{ borderColor: "var(--border)" }}>
<div className="mb-2 text-xs font-semibold text-foreground/60">排查模式</div>
<Segmented
block
value={state.mode}
disabled={!enabled}
onChange={(value) => patchState({ mode: value as PortraitClearanceMode })}
options={[{ value: "direct-compare", label: "直接比对" }, { value: "network-search", label: "网络排查" }]}
/>
</section>
<section className="rounded-xl border p-3" style={{ borderColor: "var(--border)" }}>
<div className="mb-3 text-xs font-semibold text-foreground/60">输入图片</div>
<div className="space-y-2">
<PortraitInputCard label="查询图" node={query} ready={hasQuery} />
{state.mode === "direct-compare" ? <PortraitInputCard label="参考图" node={reference} ready={hasReference} /> : <PortraitCandidateStrip nodes={candidateNodes} />}
</div>
<p className="mt-3 text-xs leading-5 text-foreground/55">在画布上把图片节点连接到本节点,角色会写入节点状态;不会按渲染顺序静默交换 A/B。</p>
{state.mode === "direct-compare" ? <Button size="small" disabled={!enabled || !hasQuery || !hasReference} onClick={() => patchState({ inputBindings: swapPortraitClearanceDirectBindings(state.inputBindings) })}>交换 A / B</Button> : null}
</section>
<section className="rounded-xl border p-3" style={{ borderColor: "var(--border)" }}>
<div className="mb-3 text-xs font-semibold text-foreground/60">执行设置</div>
<label className="mb-3 block text-xs">分析方式<Select className="mt-1 w-full" value={state.analysisMode} disabled={!enabled} options={[{ value: "local-plus-vision", label: "本地 + 视觉模型" }, { value: "local-only", label: "仅本地" }]} onChange={(value) => patchState({ analysisMode: value as PortraitClearanceAnalysisMode })} /></label>
{state.analysisMode === "local-plus-vision" ? <label className="mb-3 block text-xs">视觉分析模型<Select className="mt-1 w-full" value={state.modelPolicy.mode === "pinned" ? state.modelPolicy.modelRef : "project-default"} disabled={!enabled} options={[{ value: "project-default", label: `项目默认 · ${portraitVisionModelLabel(effectiveConfig, effectiveConfig.textModel || effectiveConfig.model)}` }, ...portraitVisionModels(effectiveConfig).map((model) => ({ value: model, label: portraitVisionModelLabel(effectiveConfig, model) }))]} onChange={(value) => patchState({ modelPolicy: value === "project-default" ? { mode: "project-default" } : { mode: "pinned", modelRef: String(value) } })} /></label> : null}
<label className="mb-3 block text-xs">去重方式<Select className="mt-1 w-full" value={state.settings.dedupMode} disabled={!enabled} options={[{ value: "phash", label: "pHash 快速" }, { value: "arcface", label: "ArcFace 双条件" }]} onChange={(value) => patchState({ settings: { ...state.settings, dedupMode: value as "phash" | "arcface" } })} /></label>
{state.analysisMode === "local-plus-vision" ? <label className="mb-3 block text-xs">模型并发(最多 10)<InputNumber className="mt-1 w-full" min={1} max={10} precision={0} value={state.settings.modelConcurrency} disabled={!enabled} onChange={(value) => patchState({ settings: { ...state.settings, modelConcurrency: Math.max(1, Math.min(10, Number(value) || 2)) } })} /></label> : null}
{state.mode === "network-search" ? <><label className="mb-3 block text-xs">候选上限<InputNumber className="mt-1 w-full" min={1} max={60} value={state.settings.maxCandidates} disabled={!enabled} onChange={(value) => patchState({ settings: { ...state.settings, maxCandidates: Math.max(1, Math.min(60, Number(value) || 30)) } })} /></label><label className="mb-3 block text-xs">百度滚动次数<InputNumber className="mt-1 w-full" min={0} max={20} value={state.settings.searchScrolls} disabled={!enabled} onChange={(value) => patchState({ settings: { ...state.settings, searchScrolls: Math.max(0, Math.min(20, Number(value) || 0)) } })} /></label></> : null}
<div className="flex items-center justify-between gap-3 text-xs"><span>浏览器调试</span><Switch size="small" checked={state.settings.showBrowserForDebug} disabled={!enabled} onChange={(checked) => patchState({ settings: { ...state.settings, showBrowserForDebug: checked } })} /></div>
</section>
</aside>
<main className="min-w-0 space-y-4">
{!runtimeReady && enabled ? <Alert type={runtimeConnecting ? "info" : "warning"} showIcon message={runtimeConnecting ? "正在连接本机检测引擎" : runtimeStatus ? "本地模型未就绪" : "本机引擎连接失败"} description={runtimeConnecting ? "正在自动建立本机连接,请稍候。" : runtimeStatus ? (installingModels ? "正在下载并校验本地肖像模型,请稍候。" : runtimeError || "请下载本地肖像模型后再开始排查。") : portraitRuntimeErrorMessage(runtimeError) || "正在自动重连本机引擎,请确认 Canvas Agent 已启动。"} action={runtimeConnecting ? null : runtimeStatus ? <Button size="small" icon={<Download className="size-3.5" />} loading={installingModels} onClick={() => void installModels()}>{installingModels ? "下载中…" : "下载本地模型"}</Button> : <Button size="small" onClick={() => setRuntimeRetryNonce((value) => value + 1)}>立即重连</Button>} /> : null}
{runtimeReady && runtimeError ? <Alert type="error" showIcon message="网络排查请求失败" description={runtimeError} closable onClose={() => setRuntimeError("")} /> : null}
{state.mode === "network-search" && runtimeStatus?.browser.available === false ? <Alert type="warning" showIcon message="未检测到系统浏览器" description="网络排查需要系统 Chrome、Edge 或 Chromium;也可以先连接手动候选,任务不会静默切换搜索服务。" /> : null}
<section className="rounded-xl border p-4" style={{ borderColor: "var(--border)" }}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div><h3 className="text-sm font-semibold">当前结果</h3><p className="mt-1 text-xs text-foreground/55">完整结果会在本机任务完成后生成;节点 metadata 只保留可恢复摘要。</p></div>
{state.task ? <Tag color={state.task.status === "failed" ? "red" : state.task.status === "cancelled" ? "orange" : state.task.status === "completed" ? "green" : "blue"}>{state.task.status === "failed" ? "失败" : state.task.status === "cancelled" ? "已停止" : PORTRAIT_TASK_STAGE_LABELS[state.task.stage] || state.task.stage}</Tag> : null}
</div>
{state.task?.status === "failed" && state.task.errorMessage ? <Alert className="mt-4" type="error" showIcon message="排查任务失败" description={state.task.errorMessage} /> : null}
{state.lastResult ? <><div className="mt-4 grid gap-3 sm:grid-cols-4"><Metric label="最高风险" value={PORTRAIT_RISK_LABELS[state.lastResult.highestRisk]} tone={portraitRiskTone(state.lastResult.highestRisk)} /><Metric label="候选数" value={String(state.lastResult.candidateCount)} /><Metric label="已分析" value={String(result?.comparedCount ?? state.lastResult.comparedCount)} /><Metric label="详情" value={state.lastResult.detailsAvailable ? "可用" : "需重新运行"} /></div>{result ? <PortraitResultPanel result={result} taskId={state.lastResult.taskId} onAddCandidate={onAddCandidate} /> : <div className="mt-4 rounded-lg bg-foreground/[.03] p-3 text-xs text-foreground/55">正在读取本机详细结果,图片和完整模型响应不会写入画布 metadata。</div>}</> : <div className="mt-4 grid min-h-44 place-items-center rounded-lg border border-dashed text-center text-sm text-foreground/45" style={{ borderColor: "var(--border)" }}><div><ScanFace className="mx-auto mb-2 size-8 opacity-45" /><p>{canStart ? "准备好开始排查" : "先连接所需图片"}</p><p className="mt-1 text-xs">本地模式会在 Canvas Agent 中执行,关闭工作台不会取消任务。</p></div></div>}
</section>
<section className="rounded-xl border p-4" style={{ borderColor: "var(--border)" }}>
<div className="mb-3 flex items-center gap-2"><History className="size-4" /><h3 className="text-sm font-semibold">历史运行</h3></div>
{history.length ? <div className="space-y-2">{history.map((item) => <div key={item.taskId} className="flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-xs" style={{ borderColor: "var(--border)" }}><span className="min-w-24 font-medium">{item.mode === "direct-compare" ? "直接比对" : "网络排查"}</span><Tag color={item.status === "completed" ? "green" : item.status === "failed" ? "red" : item.status === "partial" ? "orange" : "blue"}>{PORTRAIT_TASK_STAGE_LABELS[item.stage] || item.status}</Tag><span className="text-foreground/55">{item.processedCandidates}{item.totalCandidates ? ` / ${item.totalCandidates}` : ""} 项</span><span className="ml-auto text-foreground/45">{formatDate(item.updatedAt)}</span><Button size="small" type="link" onClick={() => void openHistoryTask(item)}>查看</Button><Popconfirm title="删除本地排查数据?" description="报告、候选和本机任务将不可恢复。" onConfirm={() => void removeHistory(item.taskId)}><Button size="small" type="link" danger loading={busyAction === `delete-${item.taskId}`}>删除</Button></Popconfirm></div>)}</div> : <div className="rounded-lg bg-foreground/[.03] p-3 text-xs leading-5 text-foreground/55">暂无当前节点历史。不会建立跨项目的人脸身份索引。</div>}
</section>
</main>
</div>
</div>
<footer className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-t px-5 py-3" style={{ borderColor: "var(--border)" }}>
<div className="flex items-center gap-2 text-xs text-foreground/55"><CircleAlert className="size-3.5" />结果不能替代司法鉴定、律师意见或法院判断。</div>
<div className="flex flex-wrap items-center justify-end gap-2"><Select size="small" value={reportFormat} disabled={!state.lastResult || Boolean(busyAction)} options={[{ value: "html", label: "HTML 报告" }, { value: "md", label: "Markdown 报告" }, { value: "docx", label: "DOCX 报告" }, { value: "json", label: "JSON 结果" }]} onChange={(value) => setReportFormat(value as "json" | "md" | "html" | "docx")} /><Button icon={<Download className="size-3.5" />} loading={busyAction === "export"} disabled={!state.lastResult} onClick={() => void exportReport()}>导出</Button>{state.activeTaskId && state.task?.status !== "failed" && state.task?.status !== "cancelled" ? <Popconfirm title="停止当前排查?" description="已下载的候选和本机任务会保留,可稍后查看或重试。" onConfirm={() => void stopTask()}><Button danger icon={<Pause className="size-3.5" />} loading={busyAction === "stop"}>停止</Button></Popconfirm> : null}{state.task?.status === "failed" || state.task?.status === "cancelled" ? <Button icon={<Play className="size-3.5" />} loading={busyAction === "retry"} onClick={() => void retryTask()}>重试</Button> : null}<Button type="primary" icon={<Play className="size-3.5" />} loading={starting} disabled={!canStart} onClick={() => void startTask()}>开始排查</Button></div>
</footer>
</div>
</Modal>
);
}
function decodedDataUrlBytes(dataUrl: string) {
const payload = dataUrl.slice(dataUrl.indexOf(",") + 1).replace(/=+$/, "");
return Math.floor(payload.length * 3 / 4);
}
function PortraitInputCard({ label, node, ready }: { label: string; node?: CanvasNodeData; ready: boolean }) {
const preview = portraitNodePreview(node);
return <div className="flex items-center gap-2 rounded-lg border p-2" style={{ borderColor: "var(--border)" }}>
<div className="grid size-12 shrink-0 place-items-center overflow-hidden rounded-md bg-foreground/[.04]">
{preview ? <img src={preview} alt="" className="size-full object-cover" loading="lazy" draggable={false} /> : <ImageIcon className="size-4 text-foreground/35" aria-hidden="true" />}
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium">{label}</div>
<div className="truncate text-[11px] text-foreground/50">{node?.title || (ready ? "已连接图片" : "待连接")}</div>
</div>
<Tag color={ready ? "green" : "orange"}>{ready ? "已连接" : "待连接"}</Tag>
</div>;
}
function PortraitCandidateStrip({ nodes }: { nodes: CanvasNodeData[] }) {
return <div className="rounded-lg border p-2" style={{ borderColor: "var(--border)" }}>
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
<span className="font-medium">手动候选</span>
<Tag color={nodes.length ? "green" : "default"}>{nodes.length ? `${nodes.length} 张` : "可选"}</Tag>
</div>
{nodes.length ? <div className="grid grid-cols-4 gap-1.5">{nodes.slice(0, 4).map((item, index) => {
const preview = portraitNodePreview(item);
return <div key={item.id} className="min-w-0">
<div className="grid aspect-square place-items-center overflow-hidden rounded-md bg-foreground/[.04]">
{preview ? <img src={preview} alt="" className="size-full object-cover" loading="lazy" draggable={false} /> : <ImageIcon className="size-4 text-foreground/35" aria-hidden="true" />}
</div>
<div className="mt-1 truncate text-[10px] text-foreground/55" title={item.title || `候选 ${index + 1}`}>{item.title || `候选 ${index + 1}`}</div>
</div>;
})}</div> : <div className="rounded-md bg-foreground/[.03] px-2 py-2 text-[11px] text-foreground/50">在画布上连接候选图片后,会在这里显示缩略图。</div>}
{nodes.length > 4 ? <div className="mt-1 text-[10px] text-foreground/45">还有 {nodes.length - 4} 张候选图片</div> : null}
</div>;
}
function portraitNodePreview(node?: CanvasNodeData) {
if (!node || node.type !== CanvasNodeType.Image) return "";
return node.metadata?.previewContent || node.metadata?.content || "";
}
type PortraitResultView = {
taskId: string;
mode: "direct-compare" | "network-search";
highestRisk: PortraitRiskLevel;
riskCounts: Partial<Record<PortraitRiskLevel, number>>;
candidateCount: number;
comparedCount: number;
candidates: Array<{ id: string; title: string; imageArtifactId: string; source: "connected" | "baidu"; sourcePageUrl?: string; sourceDomain?: string; resultId?: string; originalRank: number }>;
pairs: PortraitPairResult[];
limitations: string[];
};
function PortraitResultPanel({ result, taskId, onAddCandidate }: { result: PortraitResultView; taskId: string; onAddCandidate?: PortraitClearanceModalProps["onAddCandidate"] }) {
const sortedPairs = [...result.pairs].sort((left, right) => riskRank(right.riskLevel) - riskRank(left.riskLevel) || (right.overallSimilarity || 0) - (left.overallSimilarity || 0));
return <div className="mt-4 space-y-3"><div className="flex flex-wrap gap-2">{Object.entries(result.riskCounts).map(([risk, count]) => <Tag key={risk} color="default" style={portraitRiskTagStyle(risk as PortraitRiskLevel)}>{PORTRAIT_RISK_LABELS[risk as PortraitRiskLevel]} · {count}</Tag>)}</div><div className="grid gap-3 xl:grid-cols-2">{sortedPairs.map((pair) => <ResultPairCard key={pair.id} taskId={taskId} pair={pair} candidate={result.candidates.find((candidate) => candidate.resultId === pair.id)} onAddCandidate={onAddCandidate} />)}</div>{result.limitations.length ? <div className="rounded-lg bg-foreground/[.03] p-3 text-xs leading-5 text-foreground/60"><div className="mb-1 font-semibold text-foreground/75">限制与人工复核</div>{result.limitations.map((item) => <div key={item}>· {item}</div>)}</div> : null}</div>;
}
const PORTRAIT_FEATURES: Array<{ key: PortraitFeatureKey; label: string }> = [
{ key: "face_shape", label: "脸型与下颌线" },
{ key: "facial_layout", label: "五官整体布局" },
{ key: "eyes_brows", label: "眼型与眉形" },
{ key: "nose_mouth", label: "鼻型与嘴型" },
{ key: "hair_hairline", label: "发型与发际线" },
{ key: "distinctive_features", label: "标志性特征" },
];
const PORTRAIT_FEATURE_SIMILARITY_LABELS = { high: "高度相似", medium: "中等相似", low: "低度相似", none: "无明显相似" } as const;
function ResultPairCard({ taskId, pair, candidate, onAddCandidate }: { taskId: string; pair: PortraitPairResult; candidate?: PortraitResultView["candidates"][number]; onAddCandidate?: PortraitClearanceModalProps["onAddCandidate"] }) {
const [image, setImage] = useState("");
useEffect(() => {
let disposed = false;
void import("@/services/portrait-clearance-runtime").then(({ readPortraitTaskImage }) => readPortraitTaskImage(taskId, pair.comparisonImageId)).then((value) => { if (!disposed) setImage(value.dataUrl); }).catch(() => undefined);
return () => { disposed = true; };
}, [pair.comparisonImageId, taskId]);
const vision = pair.visionComparison;
return <article className="overflow-hidden rounded-lg border" style={{ borderColor: "var(--border)" }}>
<div className="flex gap-3 p-3">
<div className="grid size-24 shrink-0 place-items-center overflow-hidden rounded-md bg-foreground/[.04]">{image ? <img src={image} alt="" className="size-full object-contain" /> : <ScanFace className="size-6 opacity-35" />}</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"><span className="truncate text-sm font-semibold">{candidate?.title || pair.comparisonImageId}</span><Tag color="default" style={portraitRiskTagStyle(pair.riskLevel)}>{PORTRAIT_RISK_LABELS[pair.riskLevel]}</Tag><Tag color={pair.status === "failed" ? "red" : pair.status === "partial" ? "orange" : "green"}>{pair.status === "failed" ? "失败/跳过" : pair.status === "partial" ? "部分完成" : "完成"}</Tag></div>
<div className="mt-1 text-xs text-foreground/55">{candidate?.sourceDomain || pair.source} · {pair.analysisPath === "unable" ? "无法形成可靠路径" : `Path ${pair.analysisPath}`}{pair.overallSimilarity === undefined ? "" : ` · 相似度 ${pair.overallSimilarity.toFixed(4)}`}</div>
<div className="mt-1 flex flex-wrap gap-2">{candidate?.sourcePageUrl ? <button type="button" className="text-xs text-[var(--workspace-accent)] underline" onClick={() => window.open(candidate.sourcePageUrl, "_blank", "noopener,noreferrer")}>打开来源页面</button> : null}{onAddCandidate && candidate && image ? <button type="button" className="text-xs text-[var(--workspace-accent)] underline" onClick={() => void onAddCandidate({ id: candidate.id, title: candidate.title, imageArtifactId: candidate.imageArtifactId }, image)}>添加到画布</button> : null}</div>
</div>
</div>
<div className="border-t px-3 py-2 text-xs leading-5" style={{ borderColor: "var(--border)" }}>
<div><span className="text-foreground/50">本地预检:</span>人脸 {pair.localPrecheck.facesA} / {pair.localPrecheck.facesB} · 质量 {pair.localPrecheck.qualityA.grade} / {pair.localPrecheck.qualityB.grade}{pair.localPrecheck.faceSimilarity === undefined ? "" : ` · ArcFace ${pair.localPrecheck.faceSimilarity.toFixed(4)}`}</div>
{pair.localPrecheck.reliabilityIssues.length ? <div className="mt-1 text-foreground/55"><span className="text-foreground/50">可靠性问题:</span>{pair.localPrecheck.reliabilityIssues.join(";")}</div> : null}
{pair.error ? <div className="mt-1 text-red-600"><span className="font-medium">处理说明:</span>{pair.error.message}</div> : null}
{vision ? <>
<div className="mt-2 flex flex-wrap items-center gap-2"><span className="font-medium">多模态面部特征分析</span><Tag color={vision.status === "success" ? "green" : "gold"}>{vision.status === "success" ? "已完成" : "无法可靠判断"}</Tag>{vision.manualReviewRecommended ? <Tag color="orange">建议人工复核</Tag> : null}</div>
<div className="mt-2 overflow-x-auto rounded-md border" style={{ borderColor: "var(--border)" }}><table className="w-full min-w-[520px] text-left text-[11px]"><thead className="bg-foreground/[.04]"><tr><th className="px-2 py-1.5 font-medium">特征维度</th><th className="px-2 py-1.5 font-medium">相似度</th><th className="px-2 py-1.5 font-medium">分析说明</th></tr></thead><tbody>{PORTRAIT_FEATURES.map(({ key, label }) => { const feature = vision.featureComparison[key]; return <tr key={key} className="border-t" style={{ borderColor: "var(--border)" }}><td className="px-2 py-1.5">{label}</td><td className="px-2 py-1.5"><Tag color="default" style={portraitFeatureSimilarityStyle(feature.similarity)}>{PORTRAIT_FEATURE_SIMILARITY_LABELS[feature.similarity]}</Tag></td><td className="px-2 py-1.5 text-foreground/60">{feature.note || "-"}</td></tr>; })}</tbody></table></div>
<div className="mt-2"><span className="text-foreground/50">视觉依据:</span>{vision.basis.join(";") || "无"}</div>
</> : <div className="mt-1 text-foreground/55">{pair.basis.join(";") || "暂无结构化依据"}</div>}
</div>
</article>;
}
function parsePortraitResult(value: unknown): PortraitResultView {
if (!isRecord(value) || typeof value.taskId !== "string" || !["direct-compare", "network-search"].includes(String(value.mode)) || typeof value.highestRisk !== "string" || !isRecord(value.riskCounts) || typeof value.candidateCount !== "number" || typeof value.comparedCount !== "number" || !Array.isArray(value.candidates) || !Array.isArray(value.pairs) || !Array.isArray(value.limitations)) throw new Error("本机肖像完整结果无效");
return { taskId: value.taskId, mode: value.mode as PortraitResultView["mode"], highestRisk: value.highestRisk as PortraitRiskLevel, riskCounts: value.riskCounts as PortraitResultView["riskCounts"], candidateCount: value.candidateCount, comparedCount: value.comparedCount, candidates: value.candidates.filter(isCandidate), pairs: value.pairs as PortraitPairResult[], limitations: value.limitations.filter((item): item is string => typeof item === "string") };
}
function isCandidate(value: unknown): value is PortraitResultView["candidates"][number] {
return isRecord(value) && typeof value.id === "string" && typeof value.title === "string" && typeof value.imageArtifactId === "string" && (value.source === "connected" || value.source === "baidu") && typeof value.originalRank === "number";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function riskRank(value: PortraitRiskLevel) {
return ({ unable_to_determine: 0, low: 1, low_to_medium: 2, medium: 3, high: 4 } as Record<PortraitRiskLevel, number>)[value];
}
function formatDate(value: string) {
const time = Date.parse(value);
return Number.isFinite(time) ? new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(time) : "刚刚";
}
function portraitRuntimeErrorMessage(message: string) {
if (!message || /会话|session|scope|权限|重新建立|尚未建立/i.test(message)) return "";
return message;
}
function portraitRiskTone(risk: PortraitRiskLevel) {
if (risk === "high") return "var(--status-error)";
if (risk === "medium" || risk === "low_to_medium") return "var(--status-warning)";
if (risk === "low") return "var(--status-success)";
return "var(--foreground-muted)";
}
function portraitRiskTagStyle(risk: PortraitRiskLevel) {
const tone = risk === "high" ? "var(--status-error)" : risk === "medium" || risk === "low_to_medium" ? "var(--status-warning)" : risk === "low" ? "var(--status-success)" : "var(--foreground-muted)";
return { color: tone, backgroundColor: `color-mix(in oklch, ${tone} 18%, transparent)`, borderColor: `color-mix(in oklch, ${tone} 42%, transparent)` };
}
function portraitFeatureSimilarityStyle(similarity: "high" | "medium" | "low" | "none") {
const tone = similarity === "high" ? "var(--status-error)" : similarity === "medium" ? "var(--status-warning)" : similarity === "low" ? "var(--status-success)" : "var(--foreground-muted)";
return { color: tone, backgroundColor: `color-mix(in oklch, ${tone} 18%, transparent)`, borderColor: `color-mix(in oklch, ${tone} 42%, transparent)` };
}
function Metric({ label, value, tone }: { label: string; value: string; tone?: string }) {
const toneStyle = tone ? { background: `color-mix(in oklch, ${tone} 10%, transparent)`, borderColor: `color-mix(in oklch, ${tone} 35%, var(--border))` } : undefined;
return <div className="rounded-lg border bg-foreground/[.04] p-3" style={toneStyle}><div className="text-xs text-foreground/50">{label}</div><div className="mt-1 text-sm font-semibold" style={tone ? { color: tone } : undefined}>{value}</div></div>;
}
@@ -9,10 +9,17 @@ import { ClientRootInit } from "@/components/layout/client-root-init";
import { getAntThemeConfig } from "@/lib/app-theme";
import { appQueryClient } from "@/lib/query-client";
import { useThemeStore } from "@/stores/use-theme-store";
import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry";
import { usePluginStore } from "@/stores/use-plugin-store";
export function AppProviders({ children }: { children: ReactNode }) {
const theme = useThemeStore((state) => state.theme);
const dark = theme === "dark";
const ensurePlugin = usePluginStore((state) => state.ensurePlugin);
useEffect(() => {
for (const plugin of listRegisteredPlugins()) ensurePlugin(plugin.manifest);
}, [ensurePlugin]);
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
+9
View File
@@ -78,5 +78,14 @@ export function isolateCopiedNodeMetadata(node: CanvasNodeData, idMap: ReadonlyM
visibleColumns: [...node.metadata.storyboard.visibleColumns],
referenceNodeIds: remapReferenceIds(node.metadata.storyboard.referenceNodeIds, idMap) || [],
} : undefined;
if (node.type === "portrait-clearance" && metadata.portraitClearance) {
metadata.portraitClearance = {
...metadata.portraitClearance,
inputBindings: metadata.portraitClearance.inputBindings.map((binding) => ({ ...binding, nodeId: idMap.get(binding.nodeId) || binding.nodeId })),
activeTaskId: undefined,
task: undefined,
lastResult: undefined,
};
}
return metadata;
}
@@ -41,6 +41,8 @@ export type CanvasNodeDefinition = {
resourceKind?: (node: CanvasNodeData) => CanvasResourceKind | null;
/** 作为生成节点时的生成模式;不设表示该类型不产生生成行为 */
generationMode?: (node: CanvasNodeData) => CanvasGenerationMode | null;
/** 是否显示右侧输出连接点;缺省为 true,消费型终点节点可关闭。 */
showOutputConnection?: boolean;
/**
* 作为上游输入被参考素材容量校验计数时的类别;
* 不设表示不参与计数(生成配置、背板)。与 resourceKind 不同,计数不看内容。
@@ -2,6 +2,8 @@ import { Clapperboard, Folder, FolderOpen, Image as ImageIcon, Layers3, Music2,
import { getNodeIcon, getNodeLabel } from "@/lib/canvas/node-registry";
import { registerAddNodeMenuCommands, type AddNodeMenuCommand } from "@/lib/canvas/tool-registry";
import { PortraitClearanceIcon } from "@/components/canvas/portrait-clearance/portrait-clearance-icon";
import { PORTRAIT_CLEARANCE_NODE_TYPE, PORTRAIT_CLEARANCE_PLUGIN_ID } from "@/lib/portrait-clearance/contracts";
import { CanvasNodeType, type CanvasNodeTypeId } from "@/types/canvas";
/** 真正创建节点的命令,文案与图标统一取自节点注册表。 */
@@ -41,6 +43,15 @@ export const addNodeMenuCommands: AddNodeMenuCommand[] = [
extensionCommand(CanvasNodeType.ColorGrade, 70),
// 云端和本地工作流共用独立配置节点,不进入基础模型节点的渠道选择。
{ id: "workflow", label: "工作流", icon: <Workflow />, section: "workflow", defaultOrder: 10, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddWorkflow() },
{
id: PORTRAIT_CLEARANCE_NODE_TYPE,
label: getNodeLabel(PORTRAIT_CLEARANCE_NODE_TYPE),
icon: <PortraitClearanceIcon />,
section: "extension",
defaultOrder: 80,
applicable: (ctx) => Boolean(ctx.enabledPluginIds?.has(PORTRAIT_CLEARANCE_PLUGIN_ID)),
run: (ctx) => ctx.handlers.onAddExtensionNode(PORTRAIT_CLEARANCE_NODE_TYPE),
},
// 导入资源
{ id: "upload", label: "上传文件", icon: <UploadCloud />, section: "resource", defaultOrder: 10, run: (ctx) => ctx.handlers.onUpload() },
{ id: "project-character", label: "添加角色卡", icon: <UserRound />, section: "resource", defaultOrder: 20, applicable: (ctx) => ctx.isProjectLinked, run: (ctx) => ctx.handlers.onOpenProjectCharacters() },
@@ -132,6 +132,8 @@ export type ToolContext = {
export type AddNodeMenuContext = {
workspaceMode: CanvasWorkspaceMode;
isProjectLinked: boolean;
/** 内置应用插件的启用状态;未声明时视为未提供 gating 信息。 */
enabledPluginIds?: ReadonlySet<string>;
handlers: Pick<ToolbarHandlers,
| "onAddText"
| "onAddImage"
+1
View File
@@ -1,3 +1,4 @@
import "./eagle";
import "./prompt-optimizer";
import "./workflows";
import "./portrait-clearance";
@@ -0,0 +1,40 @@
import { registerPlugin } from "@/lib/plugins/plugin-registry";
import type { PluginManifest, RegisteredPlugin } from "@/lib/plugins/plugin-types";
import { PORTRAIT_CLEARANCE_NODE_TYPE, PORTRAIT_CLEARANCE_PLUGIN_ID } from "@/lib/portrait-clearance/contracts";
const manifest: PluginManifest = {
apiVersion: "yingce.plugin/v1",
id: PORTRAIT_CLEARANCE_PLUGIN_ID,
name: "肖像权可识别性排查",
version: "0.1.0",
description: "对虚拟人和人物图片执行本地人脸预检、网络候选排查与审慎风险报告。",
author: "影策团队",
surfaces: ["node", "fullscreen"],
permissions: [
"canvas.read",
"canvas.write",
"asset.read",
"asset.import",
"ai.text",
"external.open",
],
trusted: true,
runtime: { backend: "trusted-backend", web: "declarative" },
contributes: {
canvasNodes: [
{
id: PORTRAIT_CLEARANCE_NODE_TYPE,
label: "肖像排查",
defaultTitle: "肖像可识别性排查",
defaultSize: { width: 560, height: 420 },
schema: { type: "object", properties: { portraitClearance: { type: "object" } } },
renderer: "declarative",
},
],
},
};
export const portraitClearancePlugin: RegisteredPlugin = { manifest };
registerPlugin(portraitClearancePlugin);
+244
View File
@@ -0,0 +1,244 @@
/**
* 肖像可识别性排查的跨组件合同。
*
* 画布只持久化这里的紧凑节点状态;图片、embedding、候选列表和完整模型响应
* 必须留在 Local Runtime 任务目录中。
*/
export const PORTRAIT_CLEARANCE_SCHEMA_VERSION = 1 as const;
export const PORTRAIT_CLEARANCE_PLUGIN_ID = "portrait-clearance" as const;
export const PORTRAIT_CLEARANCE_NODE_TYPE = "portrait-clearance" as const;
export type PortraitRiskLevel =
| "high"
| "medium"
| "low_to_medium"
| "low"
| "unable_to_determine";
export type PortraitClearanceMode = "direct-compare" | "network-search";
export type PortraitClearanceAnalysisMode = "local-plus-vision" | "local-only";
export type PortraitClearanceDedupMode = "phash" | "arcface";
export type PortraitClearanceInputRole = "query" | "reference" | "candidate";
export type PortraitClearanceInputBinding = {
nodeId: string;
role: PortraitClearanceInputRole;
};
export type PortraitClearanceModelPolicy =
| { mode: "project-default" }
| { mode: "pinned"; modelRef: string };
export type PortraitClearanceTaskStatus =
| "queued"
| "running"
| "waiting_model"
| "partial"
| "completed"
| "failed"
| "cancelled";
export type PortraitClearanceTaskStage =
| "validating-inputs"
| "checking-model-resources"
| "preparing-query"
| "searching"
| "downloading-candidates"
| "deduplicating"
| "local-comparing"
| "waiting-for-model"
| "model-comparing"
| "building-report"
| "done";
export type PortraitClearanceNodeState = {
schemaVersion: typeof PORTRAIT_CLEARANCE_SCHEMA_VERSION;
mode: PortraitClearanceMode;
analysisMode: PortraitClearanceAnalysisMode;
modelPolicy: PortraitClearanceModelPolicy;
inputBindings: PortraitClearanceInputBinding[];
settings: {
maxCandidates: number;
searchScrolls: number;
dedupMode: PortraitClearanceDedupMode;
modelConcurrency: number;
showBrowserForDebug: boolean;
};
activeTaskId?: string;
task?: {
status: PortraitClearanceTaskStatus;
stage: PortraitClearanceTaskStage;
progress: number;
processedCandidates: number;
totalCandidates?: number;
errorCode?: string;
errorMessage?: string;
updatedAt: string;
};
lastResult?: {
taskId: string;
highestRisk: PortraitRiskLevel;
riskCounts: Partial<Record<PortraitRiskLevel, number>>;
candidateCount: number;
comparedCount: number;
modelRef?: string;
completedAt: string;
detailsAvailable: boolean;
};
};
export type PortraitClearanceSettings = PortraitClearanceNodeState["settings"];
export type PortraitImageQuality = {
width: number;
height: number;
sharpness: number;
brightness: number;
contrast: number;
grade: "good" | "usable" | "poor";
};
export type PortraitLocalPrecheck = {
qualityA: PortraitImageQuality;
qualityB: PortraitImageQuality;
facesA: number;
facesB: number;
selectedFaceA?: { bbox: [number, number, number, number]; detScore: number; areaRatio: number };
selectedFaceB?: { bbox: [number, number, number, number]; detScore: number; areaRatio: number };
faceSimilarity?: number;
ssim: number;
colorHistogramCorrelation: number;
canExtractEmbedding: boolean;
reliabilityIssues: string[];
};
export type PortraitFeatureKey =
| "face_shape"
| "facial_layout"
| "eyes_brows"
| "nose_mouth"
| "hair_hairline"
| "distinctive_features";
export type PortraitVisionComparison = {
imageAType: "realistic" | "stylized";
imageBType: "realistic" | "stylized";
analysisPath: "A" | "B";
status: "success" | "unable_to_determine";
riskLevel: PortraitRiskLevel;
overallSimilarity: number;
featureComparison: Record<PortraitFeatureKey, {
similarity: "high" | "medium" | "low" | "none";
note: string;
}>;
basis: string[];
limitations: string[];
modificationSuggestions: string[];
insightfaceFusionNote: string;
manualReviewRecommended: boolean;
};
export type PortraitPairResult = {
id: string;
queryImageId: string;
comparisonImageId: string;
candidateId?: string;
source: "connected-reference" | "connected-candidate" | "baidu";
status: "success" | "partial" | "failed";
riskLevel: PortraitRiskLevel;
overallSimilarity?: number;
analysisPath: "A" | "B" | "unable";
localPrecheck: PortraitLocalPrecheck;
visionComparison?: PortraitVisionComparison;
basis: string[];
limitations: string[];
error?: { code: string; message: string; retryable: boolean };
};
export type PortraitCandidate = {
id: string;
originalRank: number;
title: string;
imageArtifactId: string;
imageUrl?: string;
sourcePageUrl?: string;
sourceDomain?: string;
source: "connected" | "baidu";
byteSize: number;
width?: number;
height?: number;
dedupGroupId?: string;
resultId?: string;
};
export type PortraitClearanceResult = {
schemaVersion: typeof PORTRAIT_CLEARANCE_SCHEMA_VERSION;
taskId: string;
ownerScopeHash: string;
projectId: string;
nodeId: string;
mode: PortraitClearanceMode;
analysisMode: PortraitClearanceAnalysisMode;
modelRef?: string;
createdAt: string;
completedAt?: string;
highestRisk: PortraitRiskLevel;
riskCounts: Partial<Record<PortraitRiskLevel, number>>;
localPrecheck?: PortraitLocalPrecheck;
candidates: PortraitCandidate[];
pairs: PortraitPairResult[];
limitations: string[];
manualReviewRecommended: boolean;
};
export const DEFAULT_PORTRAIT_CLEARANCE_SETTINGS: PortraitClearanceNodeState["settings"] = {
maxCandidates: 30,
searchScrolls: 5,
dedupMode: "phash",
modelConcurrency: 2,
showBrowserForDebug: false,
};
export function createDefaultPortraitClearanceState(): PortraitClearanceNodeState {
return {
schemaVersion: PORTRAIT_CLEARANCE_SCHEMA_VERSION,
mode: "direct-compare",
analysisMode: "local-plus-vision",
modelPolicy: { mode: "project-default" },
inputBindings: [],
settings: { ...DEFAULT_PORTRAIT_CLEARANCE_SETTINGS },
};
}
export const PORTRAIT_RISK_ORDER: readonly PortraitRiskLevel[] = ["high", "medium", "low_to_medium", "low", "unable_to_determine"];
export const PORTRAIT_RISK_LABELS: Record<PortraitRiskLevel, string> = {
high: "高风险",
medium: "中风险",
low_to_medium: "中低风险",
low: "低风险",
unable_to_determine: "无法判断",
};
export const PORTRAIT_TASK_STAGE_LABELS: Record<PortraitClearanceTaskStage, string> = {
"validating-inputs": "校验输入",
"checking-model-resources": "检查本地模型",
"preparing-query": "准备查询图",
searching: "网络搜图",
"downloading-candidates": "采集候选",
deduplicating: "候选去重",
"local-comparing": "本地比对",
"waiting-for-model": "等待视觉模型",
"model-comparing": "视觉模型分析",
"building-report": "生成报告",
done: "已完成",
};
export function isPortraitClearanceTerminalStatus(status?: PortraitClearanceTaskStatus) {
return status === "partial" || status === "completed" || status === "failed" || status === "cancelled";
}
export function comparePortraitRisk(left: PortraitRiskLevel, right: PortraitRiskLevel) {
return PORTRAIT_RISK_ORDER.indexOf(left) - PORTRAIT_RISK_ORDER.indexOf(right);
}
@@ -0,0 +1,76 @@
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
import type { PortraitClearanceInputBinding, PortraitClearanceInputRole, PortraitClearanceMode } from "./contracts";
/**
* 连接顺序只作为旧画布的迁移兜底。已有 role 永远优先保留,避免渲染顺序变化
* 导致 query/reference 被静默交换。
*/
export function reconcilePortraitClearanceInputBindings(
mode: PortraitClearanceMode,
clearanceNodeId: string,
connections: readonly CanvasConnection[],
nodes: readonly CanvasNodeData[],
existing: readonly PortraitClearanceInputBinding[] = [],
): PortraitClearanceInputBinding[] {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const connectedIds = connections
.filter((connection) => connection.toNodeId === clearanceNodeId)
.sort((left, right) => left.id.localeCompare(right.id))
.map((connection) => connection.fromNodeId)
.filter((nodeId, index, all) => all.indexOf(nodeId) === index)
.filter((nodeId) => {
const node = nodeById.get(nodeId);
return isPortraitImageInput(node);
});
const connected = new Set(connectedIds);
const result: PortraitClearanceInputBinding[] = [];
const usedRoles = new Set<PortraitClearanceInputRole>();
for (const binding of existing) {
if (!connected.has(binding.nodeId) || result.some((item) => item.nodeId === binding.nodeId)) continue;
const role = normalizeRole(mode, binding.role, usedRoles);
if (!role) continue;
result.push({ nodeId: binding.nodeId, role });
usedRoles.add(role);
}
for (const nodeId of connectedIds) {
if (result.some((item) => item.nodeId === nodeId)) continue;
const role = nextRole(mode, usedRoles);
if (!role) break;
result.push({ nodeId, role });
usedRoles.add(role);
}
return result;
}
export function swapPortraitClearanceDirectBindings(bindings: readonly PortraitClearanceInputBinding[]) {
return bindings.map((binding) => binding.role === "query"
? { ...binding, role: "reference" as const }
: binding.role === "reference"
? { ...binding, role: "query" as const }
: binding);
}
export function portraitInputBindingForRole(bindings: readonly PortraitClearanceInputBinding[], role: PortraitClearanceInputRole) {
return bindings.find((binding) => binding.role === role)?.nodeId;
}
function normalizeRole(mode: PortraitClearanceMode, role: PortraitClearanceInputRole, used: ReadonlySet<PortraitClearanceInputRole>) {
if (mode === "direct-compare" && role === "candidate") return nextRole(mode, used);
if (used.has(role)) return nextRole(mode, used);
return role;
}
function nextRole(mode: PortraitClearanceMode, used: ReadonlySet<PortraitClearanceInputRole>): PortraitClearanceInputRole | null {
if (!used.has("query")) return "query";
if (mode === "direct-compare" && !used.has("reference")) return "reference";
if (mode === "network-search") return "candidate";
return null;
}
export function isPortraitImageInput(node: CanvasNodeData | undefined) {
return Boolean(node && node.type === CanvasNodeType.Image && (node.metadata?.content || node.metadata?.storageKey));
}
+132
View File
@@ -0,0 +1,132 @@
import { modelDisplayName, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
import type { PortraitClearanceModelPolicy, PortraitRiskLevel } from "@/lib/portrait-clearance/contracts";
import type { ResponseFunctionTool, ResponseInputMessage, ToolResponseResult } from "@/services/api/image";
export type PortraitVisionFeatureKey = "face_shape" | "facial_layout" | "eyes_brows" | "nose_mouth" | "hair_hairline" | "distinctive_features";
export type PortraitVisionComparison = {
imageAType: "realistic" | "stylized";
imageBType: "realistic" | "stylized";
analysisPath: "A" | "B";
status: "success" | "unable_to_determine";
riskLevel: PortraitRiskLevel;
overallSimilarity: number;
featureComparison: Record<PortraitVisionFeatureKey, { similarity: "high" | "medium" | "low" | "none"; note: string }>;
basis: string[];
limitations: string[];
modificationSuggestions: string[];
insightfaceFusionNote: string;
manualReviewRecommended: boolean;
};
const FEATURE_KEYS: PortraitVisionFeatureKey[] = ["face_shape", "facial_layout", "eyes_brows", "nose_mouth", "hair_hairline", "distinctive_features"];
const SIMILARITIES = new Set(["high", "medium", "low", "none"]);
const RISKS = new Set<PortraitRiskLevel>(["high", "medium", "low_to_medium", "low", "unable_to_determine"]);
export const portraitVisionTool: ResponseFunctionTool = {
type: "function",
function: {
name: "submit_portrait_comparison",
description: "提交两张人物图片的结构化可识别性排查结果;不识别人名、不确认私人身份,也不作法律判断。",
strict: true,
parameters: {
type: "object",
additionalProperties: false,
required: ["imageAType", "imageBType", "analysisPath", "status", "riskLevel", "overallSimilarity", "featureComparison", "basis", "limitations", "modificationSuggestions", "insightfaceFusionNote", "manualReviewRecommended"],
properties: {
imageAType: { type: "string", enum: ["realistic", "stylized"] },
imageBType: { type: "string", enum: ["realistic", "stylized"] },
analysisPath: { type: "string", enum: ["A", "B"] },
status: { type: "string", enum: ["success", "unable_to_determine"] },
riskLevel: { type: "string", enum: ["high", "medium", "low_to_medium", "low", "unable_to_determine"] },
overallSimilarity: { type: "number", minimum: 0, maximum: 1 },
featureComparison: {
type: "object",
additionalProperties: false,
required: FEATURE_KEYS,
properties: Object.fromEntries(FEATURE_KEYS.map((key) => [key, { type: "object", additionalProperties: false, required: ["similarity", "note"], properties: { similarity: { type: "string", enum: ["high", "medium", "low", "none"] }, note: { type: "string", maxLength: 1000 } } }])),
},
basis: { type: "array", maxItems: 16, items: { type: "string", maxLength: 1000 } },
limitations: { type: "array", maxItems: 16, items: { type: "string", maxLength: 1000 } },
modificationSuggestions: { type: "array", maxItems: 16, items: { type: "string", maxLength: 1000 } },
insightfaceFusionNote: { type: "string", maxLength: 1000 },
manualReviewRecommended: { type: "boolean" },
},
},
},
};
export function portraitVisionModels(config: AiConfig) {
return selectableModelsByCapability(config, "text");
}
export function resolvePortraitVisionModel(config: AiConfig, policy: PortraitClearanceModelPolicy) {
const models = portraitVisionModels(config);
const preferred = policy.mode === "pinned" ? policy.modelRef : config.textModel || config.model;
return preferred && models.includes(preferred) ? preferred : policy.mode === "pinned" ? "" : models[0] || "";
}
export function portraitVisionModelError(config: AiConfig, policy: PortraitClearanceModelPolicy) {
if (!resolvePortraitVisionModel(config, policy)) return policy.mode === "pinned" ? "节点固定的视觉模型已失效,请重新选择" : "项目默认视觉模型不可用,请重新选择";
return "";
}
export function portraitVisionModelLabel(config: AiConfig, model: string) {
return model ? modelDisplayName(config, model) : "未选择";
}
export function portraitVisionMessages(input: { queryDataUrl: string; comparisonDataUrl: string; queryName: string; comparisonName: string; localPrecheck: unknown }): ResponseInputMessage[] {
return [
{
role: "system",
content: "你是影策中的肖像可识别性排查分析器。只做结构化视觉比较,不识别人名或私人身份,不输出确认同一人、确认侵权、绝对安全等确定性结论。两张均为写实人像时使用 Path A,任一张明显风格化时使用 Path B;请结合本地 ArcFace、检测和质量预检,但不要将本地 embedding 当作法律结论。对脸型、五官布局、眼眉、鼻口、发型发际线、标志性特征分别说明。若图片质量、多脸或风格差异使判断不可靠,返回 unable_to_determine 并说明限制。",
},
{
role: "user",
content: [
{ type: "text", text: `查询图:${input.queryName}\n参考/候选图:${input.comparisonName}\n本地预检(仅作辅助):${JSON.stringify(input.localPrecheck)}` },
{ type: "image_url", image_url: { url: input.queryDataUrl } },
{ type: "image_url", image_url: { url: input.comparisonDataUrl } },
],
},
];
}
export function parsePortraitVisionToolResponse(response: ToolResponseResult): PortraitVisionComparison {
const call = response.toolCalls.find((candidate) => candidate.function.name === "submit_portrait_comparison");
if (!call) throw new Error("portrait_vision_tool_missing");
let value: unknown;
try { value = JSON.parse(call.function.arguments); } catch { throw new Error("portrait_vision_result_invalid"); }
return parseVisionComparison(value);
}
export function parseVisionComparison(value: unknown): PortraitVisionComparison {
if (!isRecord(value) || !["realistic", "stylized"].includes(String(value.imageAType)) || !["realistic", "stylized"].includes(String(value.imageBType)) || !["A", "B"].includes(String(value.analysisPath)) || !["success", "unable_to_determine"].includes(String(value.status)) || !RISKS.has(value.riskLevel as PortraitRiskLevel) || typeof value.overallSimilarity !== "number" || !Number.isFinite(value.overallSimilarity) || value.overallSimilarity < 0 || value.overallSimilarity > 1 || !isRecord(value.featureComparison) || !Array.isArray(value.basis) || !Array.isArray(value.limitations) || !Array.isArray(value.modificationSuggestions) || typeof value.insightfaceFusionNote !== "string" || typeof value.manualReviewRecommended !== "boolean") throw new Error("portrait_vision_result_invalid");
const featureComparison = Object.fromEntries(FEATURE_KEYS.map((key) => {
const item = value.featureComparison[key];
if (!isRecord(item) || !SIMILARITIES.has(String(item.similarity)) || typeof item.note !== "string" || item.note.length > 1000) throw new Error("portrait_vision_result_invalid");
return [key, { similarity: item.similarity as "high" | "medium" | "low" | "none", note: item.note }];
})) as PortraitVisionComparison["featureComparison"];
return {
imageAType: value.imageAType as "realistic" | "stylized",
imageBType: value.imageBType as "realistic" | "stylized",
analysisPath: value.analysisPath as "A" | "B",
status: value.status as "success" | "unable_to_determine",
riskLevel: value.riskLevel as PortraitRiskLevel,
overallSimilarity: value.overallSimilarity,
featureComparison,
basis: boundedStrings(value.basis),
limitations: boundedStrings(value.limitations),
modificationSuggestions: boundedStrings(value.modificationSuggestions),
insightfaceFusionNote: value.insightfaceFusionNote,
manualReviewRecommended: value.manualReviewRecommended,
};
}
function boundedStrings(value: unknown) {
if (!Array.isArray(value) || value.length > 16 || value.some((item) => typeof item !== "string" || item.length === 0 || item.length > 1000)) throw new Error("portrait_vision_result_invalid");
return value as string[];
}
function isRecord(value: unknown): value is Record<string, any> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
+75 -1
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}
@@ -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]);
@@ -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));
}
+1
View File
@@ -44,6 +44,7 @@ const permissionLabels: Record<string, string> = {
"asset.import": "导入素材",
"asset.upload": "上传素材",
"generation.run": "调用生成",
"ai.text": "调用已配置的文本/视觉理解模型",
"external.open": "打开外部详情",
};
@@ -8,6 +8,7 @@ const permissionLabels: Record<string, string> = {
"asset.import": "导入素材",
"asset.upload": "上传素材",
"generation.run": "调用生成",
"ai.text": "调用已配置的文本/视觉理解模型",
"external.open": "打开外部详情",
};
+4 -3
View File
@@ -1,7 +1,7 @@
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
export type LocalRuntimeModuleId = "canvas-agent" | "dreamina";
export type LocalRuntimeScope = "runtime:status" | "runtime:revoke" | "canvas:connect" | "dreamina:status" | "dreamina:login" | "dreamina:logout" | "dreamina:run" | "dreamina:models" | "dreamina:generate";
export type LocalRuntimeModuleId = "canvas-agent" | "dreamina" | "portrait-clearance";
export type LocalRuntimeScope = "runtime:status" | "runtime:revoke" | "canvas:connect" | "dreamina:status" | "dreamina:login" | "dreamina:logout" | "dreamina:run" | "dreamina:models" | "dreamina:generate" | "portrait:status" | "portrait:model" | "portrait:run" | "portrait:read";
export type LocalRuntimeModuleDescriptor = {
id: LocalRuntimeModuleId;
@@ -27,6 +27,7 @@ const MAX_RESPONSE_BYTES = 64 * 1024;
const MODULE_SCOPES: Record<LocalRuntimeModuleId, ReadonlySet<LocalRuntimeScope>> = {
"canvas-agent": new Set(["canvas:connect"]),
dreamina: new Set(["dreamina:status", "dreamina:login", "dreamina:logout", "dreamina:run", "dreamina:models", "dreamina:generate"]),
"portrait-clearance": new Set(["portrait:status", "portrait:model", "portrait:run", "portrait:read"]),
};
export async function readLocalRuntimeStatus(client: LocalRuntimeTransport, signal?: AbortSignal): Promise<LocalRuntimeStatus> {
@@ -135,7 +136,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
function isModuleId(value: unknown): value is LocalRuntimeModuleId {
return value === "canvas-agent" || value === "dreamina";
return value === "canvas-agent" || value === "dreamina" || value === "portrait-clearance";
}
function invalidResponse(status: number) {
@@ -0,0 +1,297 @@
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { getActiveUserScope } from "@/lib/user-scope";
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
import type { LocalRuntimeTransport } from "@/services/local-runtime";
import { getLocalRuntimeSessionClient, useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
import type { CanvasNodeData } from "@/types/canvas";
import type { PortraitClearanceNodeState, PortraitClearanceSettings, PortraitClearanceMode, PortraitClearanceAnalysisMode, PortraitClearanceInputRole } from "@/lib/portrait-clearance/contracts";
export type PortraitRuntimeStatus = {
ok: true;
module: "portrait-clearance";
apiVersion: 1;
ready: boolean;
models: { modelPack: string; ready: boolean; detector: { installed: boolean; fileName: string }; embedding: { installed: boolean; fileName: string } };
browser: { available: boolean; reason?: string };
tasks: { active: number; recoverable: number };
};
export type PortraitRuntimeTask = {
taskId: string;
mode: PortraitClearanceMode;
analysisMode: PortraitClearanceAnalysisMode;
modelRef?: string;
status: NonNullable<PortraitClearanceNodeState["task"]>["status"];
stage: NonNullable<PortraitClearanceNodeState["task"]>["stage"];
progress: number;
processedCandidates: number;
totalCandidates?: number;
errorCode?: string;
errorMessage?: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
detailsAvailable: boolean;
};
export type PortraitRuntimeModelJob = {
jobId: string;
taskId: string;
pairId: string;
queryImageId: string;
comparisonImageId: string;
status: "pending" | "leased" | "completed" | "failed";
attempt: number;
leaseToken?: string;
leaseExpiresAt?: string;
errorCode?: string;
errorMessage?: string;
};
type PortraitTaskInput = {
nodeId: string;
role: PortraitClearanceInputRole;
fileName: string;
mimeType: "image/jpeg" | "image/png" | "image/webp";
dataUrl: string;
};
export async function readPortraitRuntimeStatus(signal?: AbortSignal) {
return parseStatus(await requestJson<unknown>("/portrait-clearance/status", { method: "GET", signal }));
}
export async function installPortraitClearanceModels(signal?: AbortSignal) {
await requestJson("/portrait-clearance/model/install", { method: "POST", body: "{}", headers: { "content-type": "application/json" }, signal });
return readPortraitRuntimeStatus(signal);
}
export async function createPortraitClearanceTask(input: {
projectId: string;
nodeId: string;
ownerScopeHash: string;
clientOperationId: string;
mode: PortraitClearanceMode;
analysisMode: PortraitClearanceAnalysisMode;
modelRef?: string;
settings: PortraitClearanceSettings;
inputs: PortraitTaskInput[];
}, signal?: AbortSignal) {
return parseTask((await requestJson<{ task?: unknown }>("/portrait-clearance/tasks", { method: "POST", body: JSON.stringify({ schemaVersion: 1, ...input }), headers: { "content-type": "application/json" }, signal })).task);
}
export async function readPortraitTask(taskId: string, signal?: AbortSignal) {
return parseTask((await requestJson<{ task?: unknown }>(`/portrait-clearance/tasks/${encodeURIComponent(taskId)}`, { method: "GET", signal })).task);
}
export async function readPortraitTaskResult(taskId: string, signal?: AbortSignal) {
return requestJson<unknown>(`/portrait-clearance/tasks/${encodeURIComponent(taskId)}/artifacts/clearance-result.json`, { method: "GET", signal });
}
export async function listPortraitTasks(query: { projectId: string; nodeId?: string; ownerScopeHash?: string; limit?: number }, signal?: AbortSignal) {
const params = new URLSearchParams({ projectId: query.projectId, ...(query.nodeId ? { nodeId: query.nodeId } : {}), ...(query.ownerScopeHash ? { ownerScopeHash: query.ownerScopeHash } : {}), limit: String(query.limit || 30) });
const value = await requestJson<unknown>(`/portrait-clearance/tasks?${params.toString()}`, { method: "GET", signal });
if (!isRecord(value) || !Array.isArray(value.tasks)) throw new LocalRuntimeClientError("runtime_response_invalid", "本机肖像历史响应无效");
return { tasks: value.tasks.map(parseTask), nextCursor: typeof value.nextCursor === "string" ? value.nextCursor : undefined };
}
export async function readPortraitTaskImage(taskId: string, imageId: string, signal?: AbortSignal) {
const { response } = await requestRuntimeResponse(`/portrait-clearance/tasks/${encodeURIComponent(taskId)}/images/${encodeURIComponent(imageId)}`, { method: "GET", signal });
if (!response.ok) throw new LocalRuntimeClientError("portrait_artifact_not_found", "本机肖像图片不可用", response.status);
const mimeType = imageMime(response.headers.get("content-type") || "", "image/jpeg");
const bytes = new Uint8Array(await response.arrayBuffer());
if (!bytes.byteLength || bytes.byteLength > 12 * 1024 * 1024) throw new LocalRuntimeClientError("runtime_response_invalid", "本机肖像图片过大", response.status);
return { mimeType, dataUrl: `data:${mimeType};base64,${bytesToBase64(bytes)}` };
}
export async function downloadPortraitReport(taskId: string, artifactId: "clearance-result.json" | "clearance-report.md" | "clearance-report.html" | "clearance-report.docx", signal?: AbortSignal) {
const { response } = await requestRuntimeResponse(`/portrait-clearance/tasks/${encodeURIComponent(taskId)}/artifacts/${artifactId}`, { method: "GET", signal });
if (!response.ok) throw new LocalRuntimeClientError("portrait_artifact_not_found", "本机肖像报告不可用", response.status);
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > 24 * 1024 * 1024) throw new LocalRuntimeClientError("runtime_response_invalid", "本机肖像报告过大", response.status);
return { bytes, mimeType: response.headers.get("content-type") || "application/octet-stream" };
}
export async function claimPortraitModelJob(taskId: string, signal?: AbortSignal) {
const value = await requestJson<unknown>(`/portrait-clearance/tasks/${encodeURIComponent(taskId)}/model-jobs/claim`, { method: "POST", body: "{}", headers: { "content-type": "application/json" }, signal });
if (!isRecord(value) || value.ok !== true || (value.job !== null && value.job !== undefined && !isRecord(value.job))) throw new LocalRuntimeClientError("runtime_response_invalid", "本机视觉模型作业响应无效");
return value.job === null || value.job === undefined ? null : parseModelJob(value.job);
}
export async function completePortraitModelJob(taskId: string, job: PortraitRuntimeModelJob, visionComparison: Record<string, unknown>, signal?: AbortSignal) {
const value = await requestJson<unknown>(`/portrait-clearance/tasks/${encodeURIComponent(taskId)}/model-jobs/${encodeURIComponent(job.jobId)}/complete`, { method: "POST", body: JSON.stringify({ attempt: job.attempt, leaseToken: job.leaseToken, visionComparison }), headers: { "content-type": "application/json" }, signal });
if (!isRecord(value) || value.ok !== true || !isRecord(value.task)) throw new LocalRuntimeClientError("runtime_response_invalid", "本机视觉模型结果响应无效");
return parseTask(value.task);
}
export async function failPortraitModelJob(taskId: string, job: PortraitRuntimeModelJob, errorCode: string, errorMessage: string, retryable: boolean, signal?: AbortSignal) {
const value = await requestJson<unknown>(`/portrait-clearance/tasks/${encodeURIComponent(taskId)}/model-jobs/${encodeURIComponent(job.jobId)}/fail`, { method: "POST", body: JSON.stringify({ attempt: job.attempt, leaseToken: job.leaseToken, errorCode, errorMessage, retryable }), headers: { "content-type": "application/json" }, signal });
if (!isRecord(value) || value.ok !== true || !isRecord(value.task)) throw new LocalRuntimeClientError("runtime_response_invalid", "本机视觉模型失败响应无效");
return parseTask(value.task);
}
export async function cancelPortraitTask(taskId: string, signal?: AbortSignal) {
return parseTask((await requestJson<{ task?: unknown }>(`/portrait-clearance/tasks/${encodeURIComponent(taskId)}/cancel`, { method: "POST", body: "{}", headers: { "content-type": "application/json" }, signal })).task);
}
export async function retryPortraitTask(taskId: string, signal?: AbortSignal) {
return parseTask((await requestJson<{ task?: unknown }>(`/portrait-clearance/tasks/${encodeURIComponent(taskId)}/retry`, { method: "POST", body: "{}", headers: { "content-type": "application/json" }, signal })).task);
}
export async function deletePortraitTask(taskId: string, signal?: AbortSignal) {
await requestJson(`/${"portrait-clearance"}/tasks/${encodeURIComponent(taskId)}/delete`, { method: "POST", body: "{}", headers: { "content-type": "application/json" }, signal });
}
export async function portraitOwnerScopeHash() {
const bytes = new TextEncoder().encode(getActiveUserScope());
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
return Array.from(digest, (value) => value.toString(16).padStart(2, "0")).join("");
}
export async function imageNodeDataUrl(node: CanvasNodeData, signal?: AbortSignal): Promise<{ dataUrl: string; mimeType: "image/jpeg" | "image/png" | "image/webp"; fileName: string }> {
const source = node.metadata?.content || node.metadata?.previewContent;
const storageId = node.metadata?.storageKey ? resourceIdFromStorageKey(node.metadata.storageKey) : undefined;
const storageUrl = storageId ? resourceFileUrl(storageId) : undefined;
const value = source || storageUrl;
if (!value) throw new Error("portrait_input_invalid");
const response = await fetchImage(value, signal);
const mimeType = imageMime(response.headers.get("content-type") || response.type, node.metadata?.mimeType);
const bytes = new Uint8Array(await response.arrayBuffer());
if (!bytes.byteLength || bytes.byteLength > 12 * 1024 * 1024) throw new Error("单张肖像排查图片不能超过 12MB");
const dataUrl = `data:${mimeType};base64,${bytesToBase64(bytes)}`;
return { dataUrl, mimeType, fileName: node.title || `${node.id}.${mimeType === "image/jpeg" ? "jpg" : mimeType.slice("image/".length)}` };
}
async function requestJson<T = unknown>(path: string, init: RequestInit = {}) {
const { response, body: responseBody } = await requestRuntimeResponse(path, init);
const body = responseBody === undefined ? await parseJsonResponse(response) : responseBody;
if (!response.ok) {
const code = isRecord(body) && typeof body.code === "string" ? body.code : "portrait_runtime_unavailable";
const message = isRecord(body) && typeof body.message === "string" ? body.message : "本机肖像引擎请求失败";
throw new LocalRuntimeClientError(code, message, response.status);
}
return body as T;
}
type RuntimeResponse = { response: Response; body?: unknown };
async function requestRuntimeResponse(path: string, init: RequestInit = {}): Promise<RuntimeResponse> {
let refreshed = false;
while (true) {
let response: Response;
try {
response = await (await ensureTransport()).request(path, init);
} catch (error) {
if (refreshed || !isSessionRefreshError(error)) throw error;
await reconnectTransport();
refreshed = true;
continue;
}
// The signed session is kept in memory. A Runtime restart can invalidate it
// while the store still reports "connected", so binary artifact reads need
// the same one-time recovery as JSON requests.
if (!refreshed && response.status === 401) {
await reconnectTransport();
refreshed = true;
continue;
}
if (response.status === 403) {
const body = await parseJsonResponse(response);
if (!refreshed && shouldRefreshSession(response, body)) {
await reconnectTransport();
refreshed = true;
continue;
}
return { response, body };
}
return { response };
}
}
async function parseJsonResponse(response: Response) {
const text = await boundedText(response, 12 * 1024 * 1024);
try { return text ? JSON.parse(text) as unknown : {}; } catch { throw new LocalRuntimeClientError("runtime_response_invalid", "本机肖像引擎响应无效", response.status); }
}
async function reconnectTransport() {
const client = getLocalRuntimeSessionClient();
client.revokeLocalSession();
await useLocalRuntimeStore.getState().connect();
const state = useLocalRuntimeStore.getState();
if (state.connection !== "connected") throw new LocalRuntimeClientError("portrait_runtime_unavailable", state.error || "本机肖像引擎不可用");
}
function isSessionRefreshError(error: unknown): error is LocalRuntimeClientError {
return error instanceof LocalRuntimeClientError && ["session_required", "session_invalid", "scope_denied"].includes(error.code);
}
function shouldRefreshSession(response: Response, body: unknown) {
return response.status === 401 || (response.status === 403 && isRecord(body) && body.code === "scope_denied");
}
async function ensureTransport(): Promise<LocalRuntimeTransport> {
const state = useLocalRuntimeStore.getState();
if (state.connection !== "connected") await state.connect();
const current = useLocalRuntimeStore.getState();
if (current.connection !== "connected") throw new LocalRuntimeClientError("portrait_runtime_unavailable", current.error || "本机肖像引擎不可用");
return getLocalRuntimeSessionClient();
}
async function boundedText(response: Response, limit: number) {
const declared = Number(response.headers.get("content-length"));
if (Number.isFinite(declared) && declared > limit) throw new LocalRuntimeClientError("runtime_response_invalid", "本机响应过大", response.status);
const text = await response.text();
if (text.length > limit) throw new LocalRuntimeClientError("runtime_response_invalid", "本机响应过大", response.status);
return text;
}
function parseStatus(value: unknown): PortraitRuntimeStatus {
if (!isRecord(value) || value.ok !== true || value.module !== "portrait-clearance" || value.apiVersion !== 1 || typeof value.ready !== "boolean" || !isRecord(value.models) || !isRecord(value.browser) || !isRecord(value.tasks)) throw new LocalRuntimeClientError("runtime_response_invalid", "本机肖像引擎状态无效");
const models = value.models;
if (typeof models.modelPack !== "string" || typeof models.ready !== "boolean" || !isRecord(models.detector) || !isRecord(models.embedding) || typeof models.detector.installed !== "boolean" || typeof models.detector.fileName !== "string" || typeof models.embedding.installed !== "boolean" || typeof models.embedding.fileName !== "string") throw new LocalRuntimeClientError("runtime_response_invalid", "本机肖像模型状态无效");
return value as PortraitRuntimeStatus;
}
function parseTask(value: unknown): PortraitRuntimeTask {
if (!isRecord(value) || typeof value.taskId !== "string" || !["direct-compare", "network-search"].includes(String(value.mode)) || !["local-only", "local-plus-vision"].includes(String(value.analysisMode)) || !["queued", "running", "waiting_model", "partial", "completed", "failed", "cancelled"].includes(String(value.status)) || typeof value.stage !== "string" || typeof value.progress !== "number" || !Number.isFinite(value.progress) || typeof value.processedCandidates !== "number" || typeof value.updatedAt !== "string" || typeof value.createdAt !== "string" || typeof value.detailsAvailable !== "boolean") throw new LocalRuntimeClientError("runtime_response_invalid", "本机肖像任务响应无效");
return value as PortraitRuntimeTask;
}
function parseModelJob(value: unknown): PortraitRuntimeModelJob {
if (!isRecord(value) || typeof value.jobId !== "string" || typeof value.taskId !== "string" || typeof value.pairId !== "string" || typeof value.queryImageId !== "string" || typeof value.comparisonImageId !== "string" || !["pending", "leased", "completed", "failed"].includes(String(value.status)) || typeof value.attempt !== "number" || !Number.isInteger(value.attempt) || (value.status === "leased" && typeof value.leaseToken !== "string")) throw new LocalRuntimeClientError("runtime_response_invalid", "本机视觉模型作业响应无效");
return value as PortraitRuntimeModelJob;
}
function imageMime(value: string, fallback?: string): "image/jpeg" | "image/png" | "image/webp" {
const mime = value.startsWith("image/") ? value.split(";", 1)[0] : fallback || "";
if (mime === "image/jpeg" || mime === "image/png" || mime === "image/webp") return mime;
throw new Error("portrait_input_invalid");
}
async function fetchImage(value: string, signal?: AbortSignal) {
if (value.startsWith("data:")) {
const match = /^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=_-]+)$/.exec(value);
if (!match) throw new Error("portrait_input_invalid");
return new Response(bytesFromBase64(match[2]!), { headers: { "content-type": match[1]! } });
}
const response = await fetch(value, { credentials: "include", redirect: "error", cache: "no-store", signal });
if (!response.ok) throw new Error("portrait_input_invalid");
return response;
}
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function bytesFromBase64(value: string) {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
+3
View File
@@ -1,6 +1,7 @@
import type { CanvasColorGrade } from "@/lib/canvas/canvas-color-grade";
import type { PortraitTextureSettings } from "@/lib/canvas/canvas-portrait-texture";
import type { StyleExecutionPlan } from "@/lib/canvas/style-profile";
import type { PortraitClearanceNodeState } from "@/lib/portrait-clearance/contracts";
import type { SrtEntry, SubtitleHighlight, SubtitleStyle } from "@/types/timeline";
export type Position = {
@@ -398,6 +399,8 @@ export type CanvasNodeMetadata = {
editMode?: "provider-mask" | "local-composite";
};
portraitTexture?: PortraitTextureSettings;
/** 肖像排查节点只保存可恢复的 UI 状态,不保存图片、embedding 或完整结果。 */
portraitClearance?: PortraitClearanceNodeState;
};
export type CanvasNodeData = {
@@ -0,0 +1,32 @@
import { describe, expect, it } from "bun:test";
import { isPortraitImageInput, reconcilePortraitClearanceInputBindings, swapPortraitClearanceDirectBindings } from "@/lib/portrait-clearance/input-bindings";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
function image(id: string, metadata: CanvasNodeData["metadata"] = {}) {
return { id, type: CanvasNodeType.Image, title: id, position: { x: 0, y: 0 }, width: 420, height: 236, metadata: { content: `data:image/png;base64,${id}`, ...metadata } } satisfies CanvasNodeData;
}
function connection(id: string, fromNodeId: string, toNodeId = "clearance") {
return { id, fromNodeId, toNodeId } satisfies CanvasConnection;
}
describe("portrait clearance input bindings", () => {
it("preserves existing roles, fills new connections deterministically, and removes stale ids", () => {
const nodes = [image("a"), image("b"), image("c")];
const bindings = reconcilePortraitClearanceInputBindings("direct-compare", "clearance", [connection("conn-2", "b"), connection("conn-1", "a"), connection("conn-3", "c")], nodes, [{ nodeId: "b", role: "reference" }, { nodeId: "missing", role: "query" }]);
expect(bindings).toEqual([{ nodeId: "b", role: "reference" }, { nodeId: "a", role: "query" }]);
});
it("uses candidate roles for additional network inputs and swaps only A/B", () => {
const nodes = [image("a"), image("b"), image("c")];
const bindings = reconcilePortraitClearanceInputBindings("network-search", "clearance", [connection("1", "a"), connection("2", "b"), connection("3", "c")], nodes);
expect(bindings).toEqual([{ nodeId: "a", role: "query" }, { nodeId: "b", role: "candidate" }, { nodeId: "c", role: "candidate" }]);
expect(swapPortraitClearanceDirectBindings([{ nodeId: "a", role: "query" }, { nodeId: "b", role: "reference" }, { nodeId: "c", role: "candidate" }])).toEqual([{ nodeId: "a", role: "reference" }, { nodeId: "b", role: "query" }, { nodeId: "c", role: "candidate" }]);
});
it("accepts a persisted storage key as usable image data but rejects empty image nodes", () => {
expect(isPortraitImageInput(image("storage", { content: undefined, storageKey: "image:user:one" }))).toBe(true);
expect(isPortraitImageInput(image("empty", { content: undefined }))).toBe(false);
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from "bun:test";
import { portraitVisionModelError, portraitVisionModels, resolvePortraitVisionModel } from "@/lib/portrait-clearance/vision";
import type { AiConfig } from "@/stores/use-config-store";
function config(): AiConfig {
return {
channelMode: "local",
baseUrl: "https://example.test",
apiKey: "key",
apiFormat: "openai",
model: "system::vision-text",
imageModel: "system::image",
videoModel: "",
textModel: "system::vision-text",
audioModel: "",
audioVoice: "",
audioFormat: "mp3",
audioSpeed: "1",
audioInstructions: "",
videoSeconds: "6",
vquality: "720",
videoGenerateAudio: "true",
videoWatermark: "false",
videoArkPrivateAssetUpload: "true",
systemPrompt: "",
models: [],
imageModels: [],
videoModels: [],
textModels: [],
audioModels: [],
quality: "auto",
size: "1:1",
transparentBackground: "false",
count: "1",
canvasImageCount: "1",
channels: [{ id: "system", name: "系统", baseUrl: "https://example.test", apiKey: "key", apiFormat: "openai", models: ["vision-text", "text-only"], modelCosts: [{ model: "vision-text", capability: "text", billingMode: "fixed_request", unitPriceMicrocredits: 1, capabilityConfig: { version: 1, text: { references: { promptMaxChars: 10000, maxImages: 2, maxImageBytes: 12_000_000, maxVideos: 0, maxVideoBytes: 0 } } } }, { model: "text-only", capability: "text", billingMode: "fixed_request", unitPriceMicrocredits: 1 }] }],
};
}
describe("portrait clearance model selection", () => {
it("uses any selectable text model without requiring a separate image capability flag", () => {
const value = config();
expect(portraitVisionModels(value)).toEqual(["system::vision-text", "system::text-only"]);
expect(resolvePortraitVisionModel(value, { mode: "project-default" })).toBe("system::vision-text");
expect(resolvePortraitVisionModel(value, { mode: "pinned", modelRef: "system::text-only" })).toBe("system::text-only");
expect(portraitVisionModelError(value, { mode: "pinned", modelRef: "system::text-only" })).toBe("");
});
});