174 lines
7.1 KiB
TypeScript
174 lines
7.1 KiB
TypeScript
// 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),
|
||
};
|
||
}
|