diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index db00e1f..bfdc128 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -1,12 +1,18 @@ import { buildApiUrl, buildAuthHeaders, - isRecord, readJsonResponse, serverRequest, throwResponseError, } from "./serverConnection"; import { isOptionalApiRouteMissing } from "./apiErrorUtils"; +import { + parseAiTaskStatus, + parseAiTaskStatusList, + parseImageTaskCreateResponse, + parseSseTaskFrame, + parseTaskCreateResponse, +} from "./dtoParsers"; import type { WebGenerationPreviewTask } from "../types"; export interface ImageGenInput { @@ -190,13 +196,6 @@ function parseContentDispositionFilename(value: string | null): string | undefin return plainMatch?.[1]?.trim() || undefined; } -function extractTaskList(payload: unknown): AiTaskStatus[] { - if (Array.isArray(payload)) return payload as AiTaskStatus[]; - if (!isRecord(payload)) return []; - const rows = payload.tasks ?? payload.items; - return Array.isArray(rows) ? (rows as AiTaskStatus[]) : []; -} - function getStoredSessionRole(): string { try { if (typeof window === "undefined") return ""; @@ -251,67 +250,73 @@ export const aiGenerationClient = { projectId: input.projectId, conversationId: input.conversationId, }); - const payload = await serverRequest("ai/image", { + const payload = await serverRequest("ai/image", { method: "POST", body: input, timeoutMs: TASK_SUBMIT_TIMEOUT_MS, maxRetries: NON_RETRYING_REQUEST.maxRetries, fallbackMessage: "Image generation request failed", }); - if (payload.providerDebug) { - emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record); + const parsed = parseImageTaskCreateResponse(payload); + if (parsed.providerDebug) { + emitImageRouteDebug("[ai/image-provider-debug]", parsed.providerDebug as Record); } - return payload; + return parsed; }, async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> { - return serverRequest<{ taskId: string }>("ai/video", { + const payload = await serverRequest("ai/video", { method: "POST", body: input, timeoutMs: TASK_SUBMIT_TIMEOUT_MS, maxRetries: NON_RETRYING_REQUEST.maxRetries, fallbackMessage: "Video generation request failed", }); + return parseTaskCreateResponse(payload); }, async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> { - return serverRequest<{ taskId: string }>("ai/video/super-resolve", { + const payload = await serverRequest("ai/video/super-resolve", { method: "POST", body: input, timeoutMs: TASK_SUBMIT_TIMEOUT_MS, maxRetries: NON_RETRYING_REQUEST.maxRetries, fallbackMessage: "Video super-resolution request failed", }); + return parseTaskCreateResponse(payload); }, async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> { - return serverRequest<{ taskId: string }>("ai/video/erase-subtitles", { + const payload = await serverRequest("ai/video/erase-subtitles", { method: "POST", body: input, timeoutMs: TASK_SUBMIT_TIMEOUT_MS, maxRetries: NON_RETRYING_REQUEST.maxRetries, fallbackMessage: "Subtitle removal request failed", }); + return parseTaskCreateResponse(payload); }, async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> { - return serverRequest<{ taskId: string }>("ai/image/super-resolve", { + const payload = await serverRequest("ai/image/super-resolve", { method: "POST", body: input, timeoutMs: TASK_SUBMIT_TIMEOUT_MS, maxRetries: NON_RETRYING_REQUEST.maxRetries, fallbackMessage: "Image super-resolution request failed", }); + return parseTaskCreateResponse(payload); }, async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> { - return serverRequest<{ taskId: string }>("ai/image/edit", { + const payload = await serverRequest("ai/image/edit", { method: "POST", body: input, timeoutMs: TASK_SUBMIT_TIMEOUT_MS, maxRetries: NON_RETRYING_REQUEST.maxRetries, fallbackMessage: "Image edit request failed", }); + return parseTaskCreateResponse(payload); }, async cancelTask(taskId: string): Promise { @@ -328,10 +333,11 @@ export const aiGenerationClient = { }, async getTaskStatus(taskId: string): Promise { - return serverRequest(`ai/tasks/${taskId}`, { + const payload = await serverRequest(`ai/tasks/${taskId}`, { timeoutMs: TASK_STATUS_TIMEOUT_MS, fallbackMessage: "Task status request failed", }); + return parseAiTaskStatus(payload); }, async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> { @@ -361,7 +367,7 @@ export const aiGenerationClient = { const payload = await serverRequest(`ai/tasks${search.toString() ? `?${search}` : ""}`, { fallbackMessage: "Task history request failed", }); - return extractTaskList(payload).map(toPreviewTask); + return parseAiTaskStatusList(payload).map(toPreviewTask); } catch (error) { if (isOptionalApiRouteMissing(error)) { taskHistoryRouteMissing = true; @@ -451,7 +457,7 @@ export const aiGenerationClient = { if (!line.startsWith("data: ")) continue; try { const data = JSON.parse(line.slice(6)); - onUpdate(data); + onUpdate(parseSseTaskFrame(data)); } catch { /* ignore */ } } } diff --git a/src/api/dtoParsers.test.ts b/src/api/dtoParsers.test.ts new file mode 100644 index 0000000..6d406eb --- /dev/null +++ b/src/api/dtoParsers.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from "vitest"; +import { + parseAiTaskStatus, + parseTaskCreateResponse, + parseImageTaskCreateResponse, + parseAiTaskStatusList, + parseSseTaskFrame, +} from "./dtoParsers"; + +describe("parseAiTaskStatus", () => { + it("parses a well-formed camelCase DTO", () => { + const result = parseAiTaskStatus({ + taskId: "task-1", + type: "video", + status: "running", + progress: 42, + resultUrl: "https://example.com/r.mp4", + error: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:01:00Z", + }); + expect(result.taskId).toBe("task-1"); + expect(result.type).toBe("video"); + expect(result.status).toBe("running"); + expect(result.progress).toBe(42); + expect(result.resultUrl).toBe("https://example.com/r.mp4"); + }); + + it("tolerates snake_case field names", () => { + const result = parseAiTaskStatus({ + task_id: "task-2", + result_url: "https://example.com/x.png", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }); + expect(result.taskId).toBe("task-2"); + expect(result.resultUrl).toBe("https://example.com/x.png"); + }); + + it("falls back to safe defaults for missing fields", () => { + const result = parseAiTaskStatus({}); + expect(result.taskId).toBe(""); + expect(result.type).toBe("image"); + expect(result.status).toBe("failed"); + expect(result.progress).toBe(0); + expect(result.resultUrl).toBeNull(); + expect(result.error).toBeNull(); + }); + + it("rejects unknown status/type values", () => { + const result = parseAiTaskStatus({ status: "weird", type: "audio" }); + expect(result.status).toBe("failed"); + expect(result.type).toBe("image"); + }); + + it("clamps progress to [0, 100]", () => { + expect(parseAiTaskStatus({ progress: 150 }).progress).toBe(100); + expect(parseAiTaskStatus({ progress: -10 }).progress).toBe(0); + expect(parseAiTaskStatus({ progress: "not-a-number" }).progress).toBe(0); + }); + + it("preserves numeric conversationId and nulls others", () => { + expect(parseAiTaskStatus({ conversationId: 7 }).conversationId).toBe(7); + expect(parseAiTaskStatus({ conversation_id: 9 }).conversationId).toBe(9); + expect(parseAiTaskStatus({ conversationId: "nope" }).conversationId).toBeNull(); + expect(parseAiTaskStatus({}).conversationId).toBeNull(); + }); + + it("returns empty string for a non-record payload", () => { + const result = parseAiTaskStatus("garbage"); + expect(result.taskId).toBe(""); + expect(result.status).toBe("failed"); + }); +}); + +describe("parseTaskCreateResponse", () => { + it("extracts taskId from a create response", () => { + expect(parseTaskCreateResponse({ taskId: "abc" }).taskId).toBe("abc"); + expect(parseTaskCreateResponse({ task_id: "def" }).taskId).toBe("def"); + expect(parseTaskCreateResponse({ id: "ghi" }).taskId).toBe("ghi"); + }); + + it("throws when taskId is missing", () => { + expect(() => parseTaskCreateResponse({})).toThrow(); + expect(() => parseTaskCreateResponse({ taskId: "" })).toThrow(); + expect(() => parseTaskCreateResponse({ taskId: " " })).toThrow(); + }); +}); + +describe("parseImageTaskCreateResponse", () => { + it("includes providerDebug when present", () => { + const result = parseImageTaskCreateResponse({ + taskId: "img-1", + providerDebug: { + requestedModel: "gpt-image", + effectiveModel: "dall-e-3", + route: ["primary", "fallback"], + candidates: [{ provider: "openai", model: "dall-e-3" }], + }, + }); + expect(result.taskId).toBe("img-1"); + expect(result.providerDebug?.effectiveModel).toBe("dall-e-3"); + expect(result.providerDebug?.route).toEqual(["primary", "fallback"]); + expect(result.providerDebug?.candidates?.[0]?.model).toBe("dall-e-3"); + }); + + it("omits providerDebug when absent", () => { + const result = parseImageTaskCreateResponse({ taskId: "img-2" }); + expect(result.taskId).toBe("img-2"); + expect(result.providerDebug).toBeUndefined(); + }); + + it("tolerates snake_case providerDebug fields", () => { + const result = parseImageTaskCreateResponse({ + taskId: "img-3", + provider_debug: { requested_model: "x", primary_provider: "openai" }, + }); + expect(result.providerDebug?.requestedModel).toBe("x"); + expect(result.providerDebug?.primaryProvider).toBe("openai"); + }); + + it("throws when taskId missing even if providerDebug present", () => { + expect(() => parseImageTaskCreateResponse({ providerDebug: {} })).toThrow(); + }); +}); + +describe("parseAiTaskStatusList", () => { + it("parses a bare array", () => { + const result = parseAiTaskStatusList([{ taskId: "a" }, { taskId: "b" }]); + expect(result).toHaveLength(2); + expect(result[0].taskId).toBe("a"); + }); + + it("parses an envelope { tasks: [...] }", () => { + const result = parseAiTaskStatusList({ tasks: [{ taskId: "a" }, { task_id: "b" }] }); + expect(result).toHaveLength(2); + expect(result[1].taskId).toBe("b"); + }); + + it("parses an envelope { items: [...] }", () => { + const result = parseAiTaskStatusList({ items: [{ taskId: "a" }] }); + expect(result).toHaveLength(1); + }); + + it("drops rows with no taskId rather than crashing", () => { + const result = parseAiTaskStatusList([{ taskId: "keep" }, { status: "running" }, {}]); + expect(result).toHaveLength(1); + expect(result[0].taskId).toBe("keep"); + }); + + it("returns empty array for non-array non-record payload", () => { + expect(parseAiTaskStatusList(null)).toEqual([]); + expect(parseAiTaskStatusList("nope")).toEqual([]); + expect(parseAiTaskStatusList({})).toEqual([]); + }); +}); + +describe("parseSseTaskFrame", () => { + it("parses a well-formed SSE frame", () => { + const frame = parseSseTaskFrame({ + taskId: "sse-1", + status: "completed", + progress: 100, + resultUrl: "https://example.com/done.png", + }); + expect(frame.taskId).toBe("sse-1"); + expect(frame.status).toBe("completed"); + expect(frame.progress).toBe(100); + expect(frame.resultUrl).toBe("https://example.com/done.png"); + }); + + it("clamps progress and rejects unknown status", () => { + const frame = parseSseTaskFrame({ taskId: "sse-2", status: "oops", progress: 999 }); + expect(frame.status).toBe("failed"); + expect(frame.progress).toBe(100); + }); + + it("handles a non-object payload", () => { + const frame = parseSseTaskFrame("garbage"); + expect(frame.taskId).toBe(""); + expect(frame.status).toBe("failed"); + expect(frame.progress).toBe(0); + }); +}); diff --git a/src/api/dtoParsers.ts b/src/api/dtoParsers.ts new file mode 100644 index 0000000..66d3a03 --- /dev/null +++ b/src/api/dtoParsers.ts @@ -0,0 +1,173 @@ +// 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), + }; +} diff --git a/src/api/serverConnection.ts b/src/api/serverConnection.ts index 2316f72..78f5650 100644 --- a/src/api/serverConnection.ts +++ b/src/api/serverConnection.ts @@ -82,9 +82,19 @@ function parseStoredSession(raw: string | null): WebUserSession | null { try { const parsed = JSON.parse(raw) as unknown; - return isRecord(parsed) && typeof parsed.token === "string" && isRecord(parsed.user) - ? (parsed as unknown as WebUserSession) - : null; + // Require token + a user object with at least an id, so a malformed/partial + // cached session does not get cast wholesale into WebUserSession and then + // crash UI code that reads user.id / user.username. + if (!isRecord(parsed) || typeof parsed.token !== "string" || !isRecord(parsed.user)) { + return null; + } + const user = parsed.user; + const userId = user.id ?? user.userId ?? user.user_id; + const username = user.username ?? user.name; + if (userId === undefined || typeof username !== "string" || !username.trim()) { + return null; + } + return parsed as unknown as WebUserSession; } catch { return null; }