Files
omniai-web/src/api/aiGenerationClient.ts
T

560 lines
17 KiB
TypeScript

import {
buildApiUrl,
buildAuthHeaders,
isRecord,
readJsonResponse,
serverRequest,
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<string, unknown> & {
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 VideoEditInput {
projectId?: string;
conversationId?: number;
videoUrl: string;
referenceUrls: string[];
prompt?: string;
model?: string;
ratio?: string;
resolution?: 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 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<string, unknown>;
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 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<string, unknown>): 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;
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<ImageTaskCreateResponse> {
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<ImageTaskCreateResponse>("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>);
}
return payload;
},
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/video", {
method: "POST",
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video generation request failed",
});
},
async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/video/super-resolve", {
method: "POST",
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video super-resolution request failed",
});
},
async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/video/erase-subtitles", {
method: "POST",
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Subtitle removal request failed",
});
},
async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/video/edit", {
method: "POST",
body: { ...input, model: input.model || "happyhorse-1.0-video-edit" },
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video edit request failed",
});
},
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/image/super-resolve", {
method: "POST",
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Image super-resolution request failed",
});
},
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/image/edit", {
method: "POST",
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Image edit request failed",
});
},
async cancelTask(taskId: string): Promise<void> {
try {
await serverRequest<void>(`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<AiTaskStatus> {
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
timeoutMs: TASK_STATUS_TIMEOUT_MS,
fallbackMessage: "Task status request 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<WebGenerationPreviewTask[]> {
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<unknown>(`ai/tasks${search.toString() ? `?${search}` : ""}`, {
fallbackMessage: "Task history request failed",
});
return extractTaskList(payload).map(toPreviewTask);
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
taskHistoryRouteMissing = true;
return [];
}
throw error;
}
},
async bindTaskToConversation(taskId: string, conversationId: number): Promise<void> {
try {
await serverRequest<void>(`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<AiTaskStatus, "taskId" | "status" | "progress" | "resultUrl" | "error">) => 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,
onUsage?: (usage: ChatUsage) => void,
): Promise<void> {
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<string> {
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 || "";
},
};