Files
ttstd-infinite-canvas/web/src/services/portrait-clearance-runtime.ts

298 lines
18 KiB
TypeScript

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));
}