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