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