Files
omniai-ds-code-package/src/api/dtoParsers.ts
T

174 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string> = new Set(["pending", "running", "completed", "failed", "cancelled"]);
const TASK_TYPE_VALUES: ReadonlySet<string> = 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),
};
}