Merge 6dd2922: 收口 server/client 数据解析层
This commit is contained in:
@@ -1,12 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
buildApiUrl,
|
buildApiUrl,
|
||||||
buildAuthHeaders,
|
buildAuthHeaders,
|
||||||
isRecord,
|
|
||||||
readJsonResponse,
|
readJsonResponse,
|
||||||
serverRequest,
|
serverRequest,
|
||||||
throwResponseError,
|
throwResponseError,
|
||||||
} from "./serverConnection";
|
} from "./serverConnection";
|
||||||
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
||||||
|
import {
|
||||||
|
parseAiTaskStatus,
|
||||||
|
parseAiTaskStatusList,
|
||||||
|
parseImageTaskCreateResponse,
|
||||||
|
parseSseTaskFrame,
|
||||||
|
parseTaskCreateResponse,
|
||||||
|
} from "./dtoParsers";
|
||||||
import type { WebGenerationPreviewTask } from "../types";
|
import type { WebGenerationPreviewTask } from "../types";
|
||||||
|
|
||||||
export interface ImageGenInput {
|
export interface ImageGenInput {
|
||||||
@@ -190,13 +196,6 @@ function parseContentDispositionFilename(value: string | null): string | undefin
|
|||||||
return plainMatch?.[1]?.trim() || undefined;
|
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 {
|
function getStoredSessionRole(): string {
|
||||||
try {
|
try {
|
||||||
if (typeof window === "undefined") return "";
|
if (typeof window === "undefined") return "";
|
||||||
@@ -251,67 +250,73 @@ export const aiGenerationClient = {
|
|||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
conversationId: input.conversationId,
|
conversationId: input.conversationId,
|
||||||
});
|
});
|
||||||
const payload = await serverRequest<ImageTaskCreateResponse>("ai/image", {
|
const payload = await serverRequest<unknown>("ai/image", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: input,
|
body: input,
|
||||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
fallbackMessage: "Image generation request failed",
|
fallbackMessage: "Image generation request failed",
|
||||||
});
|
});
|
||||||
if (payload.providerDebug) {
|
const parsed = parseImageTaskCreateResponse(payload);
|
||||||
emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>);
|
if (parsed.providerDebug) {
|
||||||
|
emitImageRouteDebug("[ai/image-provider-debug]", parsed.providerDebug as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
return payload;
|
return parsed;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
|
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
|
||||||
return serverRequest<{ taskId: string }>("ai/video", {
|
const payload = await serverRequest<unknown>("ai/video", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: input,
|
body: input,
|
||||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
fallbackMessage: "Video generation request failed",
|
fallbackMessage: "Video generation request failed",
|
||||||
});
|
});
|
||||||
|
return parseTaskCreateResponse(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> {
|
async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> {
|
||||||
return serverRequest<{ taskId: string }>("ai/video/super-resolve", {
|
const payload = await serverRequest<unknown>("ai/video/super-resolve", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: input,
|
body: input,
|
||||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
fallbackMessage: "Video super-resolution request failed",
|
fallbackMessage: "Video super-resolution request failed",
|
||||||
});
|
});
|
||||||
|
return parseTaskCreateResponse(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> {
|
async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> {
|
||||||
return serverRequest<{ taskId: string }>("ai/video/erase-subtitles", {
|
const payload = await serverRequest<unknown>("ai/video/erase-subtitles", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: input,
|
body: input,
|
||||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
fallbackMessage: "Subtitle removal request failed",
|
fallbackMessage: "Subtitle removal request failed",
|
||||||
});
|
});
|
||||||
|
return parseTaskCreateResponse(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
|
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
|
||||||
return serverRequest<{ taskId: string }>("ai/image/super-resolve", {
|
const payload = await serverRequest<unknown>("ai/image/super-resolve", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: input,
|
body: input,
|
||||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
fallbackMessage: "Image super-resolution request failed",
|
fallbackMessage: "Image super-resolution request failed",
|
||||||
});
|
});
|
||||||
|
return parseTaskCreateResponse(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
|
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
|
||||||
return serverRequest<{ taskId: string }>("ai/image/edit", {
|
const payload = await serverRequest<unknown>("ai/image/edit", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: input,
|
body: input,
|
||||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
fallbackMessage: "Image edit request failed",
|
fallbackMessage: "Image edit request failed",
|
||||||
});
|
});
|
||||||
|
return parseTaskCreateResponse(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
async cancelTask(taskId: string): Promise<void> {
|
async cancelTask(taskId: string): Promise<void> {
|
||||||
@@ -328,10 +333,11 @@ export const aiGenerationClient = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
|
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
|
||||||
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
|
const payload = await serverRequest<unknown>(`ai/tasks/${taskId}`, {
|
||||||
timeoutMs: TASK_STATUS_TIMEOUT_MS,
|
timeoutMs: TASK_STATUS_TIMEOUT_MS,
|
||||||
fallbackMessage: "Task status request failed",
|
fallbackMessage: "Task status request failed",
|
||||||
});
|
});
|
||||||
|
return parseAiTaskStatus(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> {
|
async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> {
|
||||||
@@ -361,7 +367,7 @@ export const aiGenerationClient = {
|
|||||||
const payload = await serverRequest<unknown>(`ai/tasks${search.toString() ? `?${search}` : ""}`, {
|
const payload = await serverRequest<unknown>(`ai/tasks${search.toString() ? `?${search}` : ""}`, {
|
||||||
fallbackMessage: "Task history request failed",
|
fallbackMessage: "Task history request failed",
|
||||||
});
|
});
|
||||||
return extractTaskList(payload).map(toPreviewTask);
|
return parseAiTaskStatusList(payload).map(toPreviewTask);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isOptionalApiRouteMissing(error)) {
|
if (isOptionalApiRouteMissing(error)) {
|
||||||
taskHistoryRouteMissing = true;
|
taskHistoryRouteMissing = true;
|
||||||
@@ -451,7 +457,7 @@ export const aiGenerationClient = {
|
|||||||
if (!line.startsWith("data: ")) continue;
|
if (!line.startsWith("data: ")) continue;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(line.slice(6));
|
const data = JSON.parse(line.slice(6));
|
||||||
onUpdate(data);
|
onUpdate(parseSseTaskFrame(data));
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -82,9 +82,19 @@ function parseStoredSession(raw: string | null): WebUserSession | null {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
return isRecord(parsed) && typeof parsed.token === "string" && isRecord(parsed.user)
|
// Require token + a user object with at least an id, so a malformed/partial
|
||||||
? (parsed as unknown as WebUserSession)
|
// cached session does not get cast wholesale into WebUserSession and then
|
||||||
: null;
|
// 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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user