560 lines
17 KiB
TypeScript
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 || "";
|
|
},
|
|
};
|