2026-06-02 12:38:01 +08:00
|
|
|
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<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 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<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" ? "视频生成任务" : "图像生成任务";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
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 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<ImageTaskCreateResponse>(res, "Image generation response failed");
|
|
|
|
|
if (payload.providerDebug) {
|
|
|
|
|
emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>);
|
|
|
|
|
}
|
|
|
|
|
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<void> {
|
|
|
|
|
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<AiTaskStatus> {
|
|
|
|
|
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}`), {
|
|
|
|
|
method: "GET",
|
|
|
|
|
headers: buildAuthHeaders(),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
await throwResponseError(res, "Task status request failed");
|
|
|
|
|
}
|
|
|
|
|
return readJsonResponse<AiTaskStatus>(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<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);
|
|
|
|
|
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<unknown>(res, "Task history response failed");
|
|
|
|
|
return extractTaskList(payload).map(toPreviewTask);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async bindTaskToConversation(taskId: string, conversationId: number): Promise<void> {
|
|
|
|
|
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");
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-02 16:03:50 +08:00
|
|
|
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);
|
2026-06-02 16:58:59 +08:00
|
|
|
// Exclude Content-Type so browser auto-sets multipart/form-data with boundary
|
|
|
|
|
const { "Content-Type": _ct, ...authHeaders } = buildAuthHeaders();
|
2026-06-02 16:03:50 +08:00
|
|
|
const res = await fetch(buildApiUrl("oss/upload-binary"), {
|
|
|
|
|
method: "POST",
|
2026-06-02 16:58:59 +08:00
|
|
|
headers: authHeaders,
|
2026-06-02 16:03:50 +08:00
|
|
|
body: form,
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
await throwResponseError(res, "Binary asset upload failed");
|
|
|
|
|
}
|
|
|
|
|
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Binary asset upload response failed");
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-02 12:38:01 +08:00
|
|
|
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<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,
|
|
|
|
|
): 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("无法读取响应流");
|
|
|
|
|
|
|
|
|
|
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<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 || "";
|
|
|
|
|
},
|
|
|
|
|
};
|