feat(portrait-clearance): 肖像排查 - 增加画布插件与本机检测链路
This commit is contained in:
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} />;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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": "打开外部详情",
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user