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

174 lines
7.1 KiB
TypeScript
Raw Normal View History

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