Main merge work #19

Merged
stringadmin merged 33 commits from main-merge-work into main 2026-06-16 06:38:21 +00:00
4 changed files with 396 additions and 23 deletions
Showing only changes of commit 0958a9870e - Show all commits
+26 -20
View File
@@ -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<ImageTaskCreateResponse>("ai/image", {
const payload = await serverRequest<unknown>("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<string, unknown>);
const parsed = parseImageTaskCreateResponse(payload);
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 }> {
return serverRequest<{ taskId: string }>("ai/video", {
const payload = await serverRequest<unknown>("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<unknown>("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<unknown>("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<unknown>("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<unknown>("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<void> {
@@ -328,10 +333,11 @@ export const aiGenerationClient = {
},
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,
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<unknown>(`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 */ }
}
}
+184
View File
@@ -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);
});
});
+173
View File
@@ -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),
};
}
+13 -3
View File
@@ -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;
}