import { buildApiUrl, buildAuthHeaders, isRecord, readJsonResponse, throwResponseError, } from "./serverConnection"; import { isOptionalApiRouteMissing } from "./apiErrorUtils"; 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; 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 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" ? "视频生成任务" : "图像生成任务"; } 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 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 ""; 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 console logs for admin users — hides enterprise routing details if (getStoredSessionRole() === "admin") { 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__ : []; const entry: ImageRouteDebugEntry = { at: new Date().toISOString(), label, ...payload }; debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__ = [...previousEntries.slice(-19), entry]; } let taskHistoryRouteMissing = false; 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 res = await fetch(requestUrl, { method: "POST", headers: buildAuthHeaders(), body: JSON.stringify(input), }); if (!res.ok) { await throwResponseError(res, "Image generation request failed"); } const payload = await readJsonResponse(res, "Image generation response failed"); if (payload.providerDebug) { emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record); } return payload; }, async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> { const res = await fetch(buildApiUrl("ai/video"), { method: "POST", headers: buildAuthHeaders(), body: JSON.stringify(input), }); if (!res.ok) { await throwResponseError(res, "Video generation request failed"); } return readJsonResponse<{ taskId: string }>(res, "Video generation response failed"); }, async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> { const res = await fetch(buildApiUrl("ai/video/super-resolve"), { method: "POST", headers: buildAuthHeaders(), body: JSON.stringify(input), }); if (!res.ok) { await throwResponseError(res, "Video super-resolution request failed"); } return readJsonResponse<{ taskId: string }>(res, "Video super-resolution response failed"); }, async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> { const res = await fetch(buildApiUrl("ai/video/erase-subtitles"), { method: "POST", headers: buildAuthHeaders(), body: JSON.stringify(input), }); if (!res.ok) { await throwResponseError(res, "Subtitle removal request failed"); } return readJsonResponse<{ taskId: string }>(res, "Subtitle removal response failed"); }, async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> { const res = await fetch(buildApiUrl("ai/image/super-resolve"), { method: "POST", headers: buildAuthHeaders(), body: JSON.stringify(input), }); if (!res.ok) { await throwResponseError(res, "Image super-resolution request failed"); } return readJsonResponse<{ taskId: string }>(res, "Image super-resolution response failed"); }, async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> { const res = await fetch(buildApiUrl("ai/image/edit"), { method: "POST", headers: buildAuthHeaders(), body: JSON.stringify(input), }); if (!res.ok) { await throwResponseError(res, "Image edit request failed"); } return readJsonResponse<{ taskId: string }>(res, "Image edit response failed"); }, async cancelTask(taskId: string): Promise { const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/cancel`), { method: "PATCH", headers: buildAuthHeaders(), }); if (!res.ok && res.status !== 404) { await throwResponseError(res, "Task cancel failed"); } }, async getTaskStatus(taskId: string): Promise { const res = await fetch(buildApiUrl(`ai/tasks/${taskId}`), { method: "GET", headers: buildAuthHeaders(), }); if (!res.ok) { await throwResponseError(res, "Task status request failed"); } return readJsonResponse(res, "Task status response failed"); }, 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); const res = await fetch(buildApiUrl(`ai/tasks${search.toString() ? `?${search}` : ""}`), { method: "GET", headers: buildAuthHeaders(), }); if (!res.ok) { try { await throwResponseError(res, "Task history request failed"); } catch (error) { if (isOptionalApiRouteMissing(error)) { taskHistoryRouteMissing = true; return []; } throw error; } } const payload = await readJsonResponse(res, "Task history response failed"); return extractTaskList(payload).map(toPreviewTask); }, async bindTaskToConversation(taskId: string, conversationId: number): Promise { const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/conversation`), { method: "PATCH", headers: buildAuthHeaders(), body: JSON.stringify({ conversationId }), }); if (res.status === 404) { return; } if (!res.ok) { await throwResponseError(res, "Task conversation binding failed"); } }, async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { const res = await fetch(buildApiUrl("oss/upload"), { method: "POST", headers: buildAuthHeaders(), body: JSON.stringify(input), }); if (!res.ok) { await throwResponseError(res, "Asset upload failed"); } return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload response 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 }> { const res = await fetch(buildApiUrl("oss/upload-by-url"), { method: "POST", headers: buildAuthHeaders(), body: JSON.stringify(input), }); if (!res.ok) { await throwResponseError(res, "Asset upload by URL failed"); } return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload by URL response 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(data); } catch { /* ignore */ } } } } catch { /* aborted or network error */ } })(); return () => controller.abort(); }, async streamChat( input: ChatInput, onChunk: (text: string) => void, signal?: AbortSignal, ): 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("无法读取响应流"); 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 }; if (chunk.error) throw new Error(chunk.error); 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 || ""; }, };