// DTO 解析层:把后端返回的 unknown 安全地解析成强类型 view model。 // 所有从 serverRequest / SSE / localStorage 进入前端状态的 DTO 都应经过这里的 parser, // 避免 as unknown as / as T 这类静默断言在后端变形时把 undefined/错误类型传到 UI。 // // helper 与 keyServerClient 里的 toNumber/toStringValue 同构,为避免改动 keyServerClient // 暂在此自带一份;后续可统一到共享 dtoHelpers 模块。 import { isRecord } from "./serverConnection"; import type { AiTaskStatus, ImageTaskCreateResponse, ImageProviderDebug } from "./aiGenerationClient"; function toNumber(value: unknown, fallback = 0): number { const numberValue = typeof value === "number" ? value : Number(value); return Number.isFinite(numberValue) ? numberValue : fallback; } function toNullableString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed || null; } const TASK_STATUS_VALUES: ReadonlySet = new Set(["pending", "running", "completed", "failed", "cancelled"]); const TASK_TYPE_VALUES: ReadonlySet = new Set(["image", "video"]); function normalizeTaskStatusValue(value: unknown): AiTaskStatus["status"] { return typeof value === "string" && TASK_STATUS_VALUES.has(value) ? (value as AiTaskStatus["status"]) : "failed"; } function normalizeTaskTypeValue(value: unknown): AiTaskStatus["type"] { return typeof value === "string" && TASK_TYPE_VALUES.has(value) ? (value as AiTaskStatus["type"]) : "image"; } interface ProviderDebugCandidate { provider?: string; transport?: string; model?: string; requestedModel?: string; billingProvider?: string; fallbackOf?: string; } function normalizeProviderDebugCandidate(raw: unknown): ProviderDebugCandidate { if (!isRecord(raw)) return {}; return { provider: toNullableString(raw.provider) ?? undefined, transport: toNullableString(raw.transport) ?? undefined, model: toNullableString(raw.model) ?? undefined, requestedModel: toNullableString(raw.requestedModel ?? raw.requested_model) ?? undefined, billingProvider: toNullableString(raw.billingProvider ?? raw.billing_provider) ?? undefined, fallbackOf: toNullableString(raw.fallbackOf ?? raw.fallback_of) ?? undefined, }; } function toStringArray(raw: unknown): string[] | undefined { if (!Array.isArray(raw)) return undefined; return (raw as unknown[]) .map((item) => toNullableString(item)) .filter((item): item is string => item !== null); } function normalizeProviderDebug(raw: unknown): ImageProviderDebug | undefined { if (!isRecord(raw)) return undefined; const hasAny = (raw.requestedModel ?? raw.requested_model) !== undefined || (raw.effectiveModel ?? raw.effective_model) !== undefined || (raw.primaryProvider ?? raw.primary_provider) !== undefined || (raw.fallbackProviders ?? raw.fallback_providers) !== undefined || raw.route !== undefined || raw.candidates !== undefined; if (!hasAny) return undefined; const fallbackProviders = toStringArray(raw.fallbackProviders ?? raw.fallback_providers); const route = toStringArray(raw.route); const candidates = Array.isArray(raw.candidates) ? (raw.candidates as unknown[]).map(normalizeProviderDebugCandidate) : undefined; return { requestedModel: toNullableString(raw.requestedModel ?? raw.requested_model) ?? undefined, effectiveModel: toNullableString(raw.effectiveModel ?? raw.effective_model) ?? undefined, primaryProvider: toNullableString(raw.primaryProvider ?? raw.primary_provider) ?? undefined, fallbackProviders, route, candidates, }; } /** * Parse a single task status DTO. Returns a well-formed AiTaskStatus with safe * defaults for any missing/malformed field, so downstream code never sees * undefined where it expects a value. */ export function parseAiTaskStatus(payload: unknown): AiTaskStatus { const task = isRecord(payload) ? payload : {}; return { taskId: toNullableString(task.taskId ?? task.task_id ?? task.id) ?? "", projectId: toNullableString(task.projectId ?? task.project_id) ?? undefined, conversationId: typeof task.conversationId === "number" || typeof task.conversation_id === "number" ? ((task.conversationId ?? task.conversation_id) as number) : null, clientQueueId: toNullableString(task.clientQueueId ?? task.client_queue_id), type: normalizeTaskTypeValue(task.type), status: normalizeTaskStatusValue(task.status), progress: Math.max(0, Math.min(100, toNumber(task.progress))), resultUrl: toNullableString(task.resultUrl ?? task.result_url), error: toNullableString(task.error), params: isRecord(task.params) ? task.params : undefined, createdAt: toNullableString(task.createdAt ?? task.created_at) ?? "", updatedAt: toNullableString(task.updatedAt ?? task.updated_at) ?? "", completedAt: toNullableString(task.completedAt ?? task.completed_at), }; } /** * Parse a task-create response ({ taskId }). Throws if taskId is missing, * rather than silently returning { taskId: undefined }. */ export function parseTaskCreateResponse(payload: unknown): { taskId: string } { const body = isRecord(payload) ? payload : {}; const taskId = toNullableString(body.taskId ?? body.task_id ?? body.id); if (!taskId) { throw new Error("任务创建失败:服务端未返回任务 ID"); } return { taskId }; } /** * Parse an image task-create response, including optional provider debug info. */ export function parseImageTaskCreateResponse(payload: unknown): ImageTaskCreateResponse { const base = parseTaskCreateResponse(payload); const body = isRecord(payload) ? payload : {}; const providerDebug = normalizeProviderDebug(body.providerDebug ?? body.provider_debug); return providerDebug ? { ...base, providerDebug } : base; } /** * Parse a task list payload that may be a bare array or an envelope * ({ tasks | items: [...] }). Malformed elements are dropped, not coerced, * because a single bad row should not corrupt the whole history list. */ export function parseAiTaskStatusList(payload: unknown): AiTaskStatus[] { let rows: unknown[]; if (Array.isArray(payload)) { rows = payload; } else if (isRecord(payload)) { const nested = payload.tasks ?? payload.items; rows = Array.isArray(nested) ? nested : []; } else { rows = []; } // Keep only rows that have a non-empty taskId — empty-id rows are useless // to the UI and indicate a malformed DTO. return rows.map(parseAiTaskStatus).filter((task) => task.taskId); } /** * Parse an SSE task frame. SSE data is untyped JSON from the server stream; * this validates the subset of fields that subscribeTaskStatus forwards. */ export function parseSseTaskFrame(payload: unknown): Pick< AiTaskStatus, "taskId" | "status" | "progress" | "resultUrl" | "error" > { const frame = isRecord(payload) ? payload : {}; return { taskId: toNullableString(frame.taskId ?? frame.task_id) ?? "", status: normalizeTaskStatusValue(frame.status), progress: Math.max(0, Math.min(100, toNumber(frame.progress))), resultUrl: toNullableString(frame.resultUrl ?? frame.result_url), error: toNullableString(frame.error), }; }