import { buildApiUrl, buildAuthHeaders, 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 { projectId?: string; conversationId?: number; model?: string; prompt: string; ratio?: string; quality?: string; gridMode?: string; referenceUrls?: string[]; } export interface ImageProviderDebug { requestedModel?: string; effectiveModel?: string; primaryProvider?: string; fallbackProviders?: string[]; route?: string[]; candidates?: Array<{ provider?: string; transport?: string; model?: string; requestedModel?: string; billingProvider?: string; fallbackOf?: string; }>; } export interface ImageTaskCreateResponse { taskId: string; providerDebug?: ImageProviderDebug; } type ImageRouteDebugEntry = Record & { at: string; label: string; }; export interface VideoGenInput { projectId?: string; conversationId?: number; model: string; prompt: string; ratio?: string; duration?: number; quality?: string; resolution?: string; frameMode?: string; referenceUrls?: string[]; imageUrl?: string; audioUrl?: string; muted?: boolean; hasReferenceVideo?: boolean; style?: "speech" | "sing" | "performance" | string; } export interface VideoSuperResolveInput { projectId?: string; conversationId?: number; videoUrl: string; bitRate?: number; provider?: string; style?: number; videoFps?: number; minLen?: 540 | 720; useSR?: boolean; animateEmotion?: boolean; } export interface EraseSubtitlesInput { videoUrl: string; bx?: number; by?: number; bw?: number; bh?: number; } export interface ImageEditInput { imageUrl: string; function: string; prompt?: string; maskUrl?: string; ratio?: string; n?: number; } export interface ImageSuperResolveInput { projectId?: string; conversationId?: number; imageUrl: string; scale?: "2x" | "4x" | number; } export interface UploadAssetInput { dataUrl: string; name?: string; mimeType?: string; scope?: "profile-avatar" | "profile-background" | string; } export interface UploadAssetByUrlInput { sourceUrl: string; name?: string; mimeType?: string; scope?: string; } export type ChatMessageContent = | string | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>; export interface ChatInput { model: string; messages: Array<{ role: string; content: ChatMessageContent }>; stream?: boolean; temperature?: number; } export interface ChatUsage { promptTokens?: number; completionTokens?: number; totalTokens?: number; } export interface AiTaskStatus { taskId: string; projectId?: string; conversationId?: number | null; clientQueueId?: string | null; type: "image" | "video"; status: "pending" | "running" | "completed" | "failed" | "cancelled"; progress: number; resultUrl: string | null; error: string | null; params?: Record; createdAt: string; updatedAt: string; completedAt?: string | null; } function normalizeTaskStatus(status: AiTaskStatus["status"]): WebGenerationPreviewTask["status"] { if (status === "running" || status === "completed" || status === "failed") return status; if (status === "cancelled") return "failed"; return "queued"; } function taskTitle(task: AiTaskStatus): string { const prompt = typeof task.params?.prompt === "string" ? task.params.prompt.trim() : ""; if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt; return task.type === "video" ? "\u89c6\u9891\u751f\u6210\u4efb\u52a1" : "\u56fe\u50cf\u751f\u6210\u4efb\u52a1"; } function toPreviewTask(task: AiTaskStatus): WebGenerationPreviewTask { return { id: task.taskId, title: taskTitle(task), type: task.type, status: normalizeTaskStatus(task.status), progress: Math.max(0, Math.min(100, Math.trunc(task.progress || 0))), prompt: typeof task.params?.prompt === "string" ? task.params.prompt : taskTitle(task), createdAt: task.createdAt, projectId: task.projectId || undefined, outputUrl: task.resultUrl || undefined, source: "server", errorMessage: task.error || undefined, }; } function parseContentDispositionFilename(value: string | null): string | undefined { if (!value) return undefined; const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i); if (utf8Match?.[1]) { try { return decodeURIComponent(utf8Match[1].trim()); } catch { return utf8Match[1].trim(); } } const plainMatch = value.match(/filename="?([^";]+)"?/i); return plainMatch?.[1]?.trim() || undefined; } function getStoredSessionRole(): string { try { if (typeof window === "undefined") return ""; const raw = window.localStorage.getItem("omniai-web-session"); if (!raw) return ""; const session = JSON.parse(raw); return String(session?.user?.role || "").trim().toLowerCase(); } catch { return ""; } } function emitImageRouteDebug(label: string, payload: Record): void { // Only emit route debug for admin users; provider routing is operational data. if (getStoredSessionRole() !== "admin") return; const entry: ImageRouteDebugEntry = { at: new Date().toISOString(), label, ...payload, }; try { console.log(`${label} ${JSON.stringify(entry)}`); } catch { console.log(label, entry); } if (typeof window === "undefined") return; const debugWindow = window as Window & { __OMNIAI_IMAGE_ROUTE_DEBUG__?: ImageRouteDebugEntry[] }; const previousEntries = Array.isArray(debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__) ? debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__ : []; debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__ = [...previousEntries.slice(-19), entry]; } let taskHistoryRouteMissing = false; const TASK_SUBMIT_TIMEOUT_MS = 90_000; const TASK_STATUS_TIMEOUT_MS = 20_000; const NON_RETRYING_REQUEST = { maxRetries: 0 }; export const aiGenerationClient = { async createImageTask(input: ImageGenInput): Promise { const requestUrl = buildApiUrl("ai/image"); emitImageRouteDebug("[ai/image-request]", { url: requestUrl, model: input.model, ratio: input.ratio, quality: input.quality, gridMode: input.gridMode, referenceCount: input.referenceUrls?.length || 0, projectId: input.projectId, conversationId: input.conversationId, }); 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", }); const parsed = parseImageTaskCreateResponse(payload); if (parsed.providerDebug) { emitImageRouteDebug("[ai/image-provider-debug]", parsed.providerDebug as Record); } return parsed; }, async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> { 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 }> { 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 }> { 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 }> { 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 }> { 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 { try { await serverRequest(`ai/tasks/${taskId}/cancel`, { method: "PATCH", maxRetries: NON_RETRYING_REQUEST.maxRetries, fallbackMessage: "Task cancel failed", }); } catch (error) { if (isOptionalApiRouteMissing(error)) return; throw error; } }, async getTaskStatus(taskId: string): Promise { 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 }> { const res = await fetch(buildApiUrl(`ai/tasks/${encodeURIComponent(taskId)}/download`), { method: "GET", headers: buildAuthHeaders(), }); if (!res.ok) { await throwResponseError(res, "Task result download failed"); } const blob = await res.blob(); return { blob, filename: parseContentDispositionFilename(res.headers.get("content-disposition")), contentType: res.headers.get("content-type") || blob.type || undefined, }; }, async listTasks(params?: { limit?: number; status?: string; type?: string; projectId?: string }): Promise { if (taskHistoryRouteMissing) return []; const search = new URLSearchParams(); if (params?.limit) search.set("limit", String(params.limit)); if (params?.status) search.set("status", params.status); if (params?.type) search.set("type", params.type); if (params?.projectId) search.set("projectId", params.projectId); try { const payload = await serverRequest(`ai/tasks${search.toString() ? `?${search}` : ""}`, { fallbackMessage: "Task history request failed", }); return parseAiTaskStatusList(payload).map(toPreviewTask); } catch (error) { if (isOptionalApiRouteMissing(error)) { taskHistoryRouteMissing = true; return []; } throw error; } }, async bindTaskToConversation(taskId: string, conversationId: number): Promise { try { await serverRequest(`ai/tasks/${taskId}/conversation`, { method: "PATCH", body: { conversationId }, maxRetries: NON_RETRYING_REQUEST.maxRetries, fallbackMessage: "Task conversation binding failed", }); } catch (error) { if (isOptionalApiRouteMissing(error)) return; throw error; } }, async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload", { method: "POST", body: input, maxRetries: NON_RETRYING_REQUEST.maxRetries, fallbackMessage: "Asset upload failed", }); }, async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { const form = new FormData(); form.append("file", blob, options?.name || "upload.png"); if (options?.scope) form.append("scope", options.scope); if (options?.mimeType) form.append("mimeType", options.mimeType); // Exclude Content-Type so browser auto-sets multipart/form-data with boundary const { "Content-Type": _ct, ...authHeaders } = buildAuthHeaders(); const res = await fetch(buildApiUrl("oss/upload-binary"), { method: "POST", headers: authHeaders, body: form, }); if (!res.ok) { await throwResponseError(res, "Binary asset upload failed"); } return readJsonResponse<{ url: string; ossKey?: string }>(res, "Binary asset upload response failed"); }, async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload-by-url", { method: "POST", body: input, maxRetries: NON_RETRYING_REQUEST.maxRetries, fallbackMessage: "Asset upload by URL failed", }); }, subscribeTaskStatus( taskId: string, onUpdate: (task: Pick) => void, ): () => void { const url = buildApiUrl(`ai/tasks/${taskId}/stream`); const controller = new AbortController(); (async () => { try { const res = await fetch(url, { headers: { ...buildAuthHeaders(), Accept: "text/event-stream" }, signal: controller.signal, }); if (!res.ok || !res.body) return; const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.startsWith("data: ")) continue; try { const data = JSON.parse(line.slice(6)); onUpdate(parseSseTaskFrame(data)); } catch { /* ignore */ } } } } catch { /* aborted or network error */ } })(); return () => controller.abort(); }, async streamChat( input: ChatInput, onChunk: (text: string) => void, signal?: AbortSignal, onUsage?: (usage: ChatUsage) => void, ): Promise { const res = await fetch(buildApiUrl("ai/chat"), { method: "POST", headers: buildAuthHeaders(), body: JSON.stringify({ ...input, stream: true }), signal, }); if (!res.ok) { await throwResponseError(res, "Chat request failed"); } const reader = res.body?.getReader(); if (!reader) throw new Error("\u65e0\u6cd5\u8bfb\u53d6\u54cd\u5e94\u6d41"); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.startsWith("data: ")) continue; const payload = line.slice(6).trim(); if (!payload) continue; try { const chunk = JSON.parse(payload) as { delta?: string; done?: boolean; error?: string; usage?: ChatUsage & { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number; }; }; if (chunk.error) throw new Error(chunk.error); if (chunk.usage) { onUsage?.({ promptTokens: chunk.usage.promptTokens ?? chunk.usage.prompt_tokens, completionTokens: chunk.usage.completionTokens ?? chunk.usage.completion_tokens, totalTokens: chunk.usage.totalTokens ?? chunk.usage.total_tokens, }); } if (chunk.delta) onChunk(chunk.delta); if (chunk.done) return; } catch (e) { if (e instanceof SyntaxError) continue; throw e; } } } }, async chatCompletion(input: ChatInput, signal?: AbortSignal): Promise { const res = await fetch(buildApiUrl("ai/chat"), { method: "POST", headers: buildAuthHeaders(), body: JSON.stringify({ ...input, stream: false }), signal, }); if (!res.ok) { await throwResponseError(res, "Chat completion failed"); } const json = await readJsonResponse<{ content?: string }>(res, "Chat completion response failed"); return (json as { content?: string }).content || ""; }, };