ai-chatbot/lib/ai/providers/atlascloud.ts

455 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import "server-only";
import sharp from "sharp";
import { ATLASCLOUD_BASE_URL } from "@/lib/ai/atlascloud-models";
import {
createAtlasCloudRequestBody,
type AtlasCloudImageParams,
type AtlasCloudModelConfig,
} from "@/lib/ai/atlascloud-request";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
interface AtlasCloudConfig {
apiKey: string;
baseURL?: string;
}
interface UploadedImage {
thumbnail: string;
original: string;
}
const POLL_INTERVAL_MS = 3000;
const POLL_TIMEOUT_MS = 20 * 60 * 1000;
const DOWNLOAD_TIMEOUT_MS = 120_000;
const TRANSIENT_FAILURE_LIMIT = 8;
function normalizeBaseUrl(baseURL?: string) {
return String(baseURL || ATLASCLOUD_BASE_URL).replace(/\/+$/, "");
}
function joinApiPath(baseURL: string | undefined, path: string) {
const base = normalizeBaseUrl(baseURL);
const cleanPath = path.startsWith("/") ? path : `/${path}`;
if (base.endsWith("/api/v1") && cleanPath.startsWith("/api/v1/")) {
return `${base}${cleanPath.slice("/api/v1".length)}`;
}
return `${base}${cleanPath}`;
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseJsonResponse(response: Response) {
return response.text().then((text) => {
if (!text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
});
}
function extractErrorMessage(payload: any, fallback = "Atlas Cloud 请求失败") {
if (typeof payload === "string" && payload.trim()) return payload.trim();
const value =
payload?.data?.error?.message ||
payload?.data?.error ||
payload?.error?.message ||
payload?.error ||
payload?.message ||
fallback;
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
function unwrapPrediction(payload: any) {
return payload?.data && typeof payload.data === "object" ? payload.data : payload;
}
function extractTaskId(payload: any) {
const prediction = unwrapPrediction(payload);
return String(
prediction?.id || prediction?.request_id || prediction?.task_id || ""
).trim();
}
function extractOutputUrls(payload: any) {
const prediction = unwrapPrediction(payload);
const candidates = [
prediction?.outputs,
prediction?.output,
prediction?.images,
prediction?.urls?.outputs,
];
const urls: string[] = [];
for (const candidate of candidates) {
const values = Array.isArray(candidate) ? candidate : candidate ? [candidate] : [];
for (const value of values) {
const url =
typeof value === "string"
? value
: String(value?.url || value?.image_url || value?.src || "");
if (/^(?:https?:\/\/|data:image\/)/i.test(url) && !urls.includes(url)) {
urls.push(url);
}
}
}
return urls;
}
function extractPromptAndMedia(
messages: Array<{ role: string; content?: any; parts?: any[] }>
) {
const message = messages.filter((item) => item.role === "user").pop();
if (!message) throw new Error("未找到用户消息");
const texts: string[] = [];
const imageUrls: string[] = [];
const videoUrls: string[] = [];
const pushText = (value: unknown) => {
if (typeof value === "string" && value.trim()) texts.push(value.trim());
};
const pushMedia = (value: unknown, mediaType?: unknown) => {
const url = String(
typeof value === "string"
? value
: (value as any)?.url ||
(value as any)?.imageUrl ||
(value as any)?.fileUrl ||
(value as any)?.src ||
""
).trim();
if (!url || !/^(?:https?:\/\/|data:)/i.test(url)) return;
const normalizedMediaType = String(mediaType || "").toLowerCase();
const isVideo =
normalizedMediaType.startsWith("video/") ||
url.startsWith("data:video/") ||
/\.(?:mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(url);
const target = isVideo ? videoUrls : imageUrls;
if (!target.includes(url)) target.push(url);
};
const visitPart = (part: any) => {
if (!part) return;
if (typeof part === "string") return pushText(part);
if (part.type === "text" || typeof part.text === "string") pushText(part.text);
if (part.type === "image_url") pushMedia(part.image_url, "image/jpeg");
if (part.type === "image") pushMedia(part.image || part.url, "image/jpeg");
if (part.type === "file") {
pushMedia(
part.url || part.file || part.data,
part.mediaType || part.contentType || part.mimeType
);
}
if (part.inlineData?.data) {
pushMedia(
`data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}`,
part.inlineData.mimeType
);
}
};
if (typeof message.content === "string") pushText(message.content);
else if (Array.isArray(message.content)) message.content.forEach(visitPart);
if (Array.isArray(message.parts)) message.parts.forEach(visitPart);
const prompt = texts.join("\n").trim();
if (!prompt) throw new Error("请输入图片生成或编辑要求");
return { prompt, imageUrls, videoUrls };
}
function parseDataUrl(value: string) {
const match = value.match(/^data:([^;,]+);base64,(.+)$/s);
if (!match) throw new Error("Atlas Cloud 参考素材 Base64 格式无效");
return {
mimeType: match[1] || "image/png",
buffer: Buffer.from(match[2] || "", "base64"),
};
}
async function loadMediaSource(mediaUrl: string) {
if (mediaUrl.startsWith("data:")) return parseDataUrl(mediaUrl);
const response = await fetch(mediaUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
headers: {
Accept: "image/*,video/*,*/*;q=0.8",
"User-Agent": "yedir-ai-atlascloud/1.0",
},
});
if (!response.ok) {
throw new Error(`Atlas Cloud 参考素材下载失败(HTTP ${response.status})`);
}
return {
mimeType: response.headers.get("content-type")?.split(";")[0] || "image/png",
buffer: Buffer.from(await response.arrayBuffer()),
};
}
function detectExtension(mimeType: string) {
if (/jpe?g/i.test(mimeType)) return "jpg";
if (/webp/i.test(mimeType)) return "webp";
if (/gif/i.test(mimeType)) return "gif";
if (/quicktime/i.test(mimeType)) return "mov";
if (/webm/i.test(mimeType)) return "webm";
if (/mp4/i.test(mimeType)) return "mp4";
return "png";
}
async function uploadReferenceMedia(
baseURL: string | undefined,
apiKey: string,
mediaUrl: string
) {
if (/^https:\/\//i.test(mediaUrl)) return mediaUrl;
const source = await loadMediaSource(mediaUrl);
const form = new FormData();
form.append(
"file",
new Blob([source.buffer], { type: source.mimeType }),
`reference-${Date.now()}.${detectExtension(source.mimeType)}`
);
const response = await fetch(joinApiPath(baseURL, "/api/v1/model/uploadMedia"), {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
});
const payload = await parseJsonResponse(response);
if (!response.ok) {
throw new Error(
`${extractErrorMessage(payload, "Atlas Cloud 参考素材上传失败")}(HTTP ${response.status})`
);
}
const data = payload?.data && typeof payload.data === "object" ? payload.data : payload;
const url = String(
data?.url || data?.file_url || data?.fileUrl || data?.media_url || data?.mediaUrl || ""
).trim();
if (!url) throw new Error("Atlas Cloud 参考素材上传成功但未返回文件地址");
return url;
}
async function submitTask(params: {
apiKey: string;
baseURL?: string;
endpoint: string;
body: Record<string, unknown>;
}) {
const response = await fetch(joinApiPath(params.baseURL, params.endpoint), {
method: "POST",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(params.body),
signal: AbortSignal.timeout(120_000),
});
const payload = await parseJsonResponse(response);
if (!response.ok) {
throw new Error(
`${extractErrorMessage(payload)}(HTTP ${response.status})`
);
}
const immediateOutputs = extractOutputUrls(payload);
if (immediateOutputs.length > 0) return { outputs: immediateOutputs, taskId: "" };
const taskId = extractTaskId(payload);
if (!taskId) throw new Error("Atlas Cloud 未返回任务 ID");
return { outputs: [] as string[], taskId };
}
async function waitForTask(params: {
apiKey: string;
baseURL?: string;
endpoint: string;
taskId: string;
}) {
const startedAt = Date.now();
let transientFailures = 0;
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
await delay(POLL_INTERVAL_MS);
try {
const endpoint = params.endpoint.replace(
/\{(?:request_id|id|task_id)\}/g,
encodeURIComponent(params.taskId)
);
const response = await fetch(joinApiPath(params.baseURL, endpoint), {
cache: "no-store",
headers: { Authorization: `Bearer ${params.apiKey}` },
signal: AbortSignal.timeout(60_000),
});
const payload = await parseJsonResponse(response);
if (!response.ok) {
if (response.status >= 500 || response.status === 429) {
throw new Error(`HTTP ${response.status}`);
}
throw new Error(extractErrorMessage(payload));
}
transientFailures = 0;
const prediction = unwrapPrediction(payload);
const status = String(prediction?.status || "").toLowerCase();
const outputs = extractOutputUrls(payload);
if (status === "completed" || status === "succeeded" || status === "success") {
if (outputs.length === 0) {
throw new Error("Atlas Cloud 任务完成但未返回图片");
}
return outputs;
}
if (["failed", "error", "timeout", "canceled", "cancelled"].includes(status)) {
throw new Error(extractErrorMessage(payload, "Atlas Cloud 图片生成失败"));
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/^HTTP (?:429|5\d\d)$/.test(message) && !/fetch|network|abort/i.test(message)) {
throw error;
}
transientFailures += 1;
if (transientFailures >= TRANSIENT_FAILURE_LIMIT) throw error;
}
}
throw new Error("Atlas Cloud 图片生成超时,任务可能仍在处理,请稍后重试");
}
function toArrayBuffer(buffer: Buffer) {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
async function uploadImageBuffer(buffer: Buffer, mimeType: string) {
const extension = detectExtension(mimeType);
const thumbnailBuffer = await sharp(buffer, { failOn: "none" })
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 82 })
.toBuffer()
.catch(() => buffer);
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const [original, thumbnail] = await Promise.all([
uploadFile(`atlascloud-orig-${suffix}.${extension}`, toArrayBuffer(buffer)),
uploadFile(`atlascloud-thumb-${suffix}.jpg`, toArrayBuffer(thumbnailBuffer)),
]);
return { original: original.url, thumbnail: thumbnail.url };
}
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
if (imageUrl.startsWith("data:")) {
const parsed = parseDataUrl(imageUrl);
return uploadImageBuffer(parsed.buffer, parsed.mimeType);
}
const deferred = await createDeferredUploadedImage(imageUrl, {
providerType: "atlascloud",
});
if (deferred) return deferred;
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
headers: { "User-Agent": "yedir-ai-atlascloud/1.0" },
});
if (!response.ok) {
throw new Error(`Atlas Cloud 结果图片下载失败(HTTP ${response.status})`);
}
return uploadImageBuffer(
Buffer.from(await response.arrayBuffer()),
response.headers.get("content-type") || "image/png"
);
}
function formatMarkdown(images: UploadedImage[], imageSize: string) {
return images
.map((image) => `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`)
.join("\n\n");
}
export function createAtlasCloud({ apiKey, baseURL }: AtlasCloudConfig) {
return {
provider: "atlascloud",
chat: (modelId: string, defaultModelConfig?: AtlasCloudModelConfig | null) => ({
provider: "atlascloud",
doGenerate: async (options: {
messages: Array<{ role: string; content?: any; parts?: any[] }>;
imageParams?: AtlasCloudImageParams;
imageGenConfig?: AtlasCloudModelConfig | null;
overrideModelId?: string;
}) => {
const runtimeModelId = String(options.overrideModelId || modelId || "").trim();
if (!runtimeModelId) throw new Error("Atlas Cloud 模型 ID 未配置");
const modelConfig = options.imageGenConfig || defaultModelConfig || {};
if (!modelConfig.atlasCloud) {
throw new Error("Atlas Cloud 模型能力配置缺失,请在系统设置中重新同步模型");
}
const { prompt, imageUrls, videoUrls } = extractPromptAndMedia(
options.messages
);
const maxReferenceImages = Math.max(
0,
Math.min(14, modelConfig.maxReferenceImages ?? modelConfig.atlasCloud.maxReferenceImages)
);
const referenceImages = await Promise.all(
imageUrls
.slice(0, maxReferenceImages)
.map((url) => uploadReferenceMedia(baseURL, apiKey, url))
);
const maxVideoReferences = Math.max(
0,
Math.min(
4,
modelConfig.maxVideoReferences ??
modelConfig.atlasCloud.maxVideoReferences ??
0
)
);
const referenceVideos = await Promise.all(
videoUrls
.slice(0, maxVideoReferences)
.map((url) => uploadReferenceMedia(baseURL, apiKey, url))
);
const { body, imageSize } = createAtlasCloudRequestBody({
modelId: runtimeModelId,
prompt,
referenceImages,
referenceVideos,
imageParams: options.imageParams,
modelConfig,
});
const submitted = await submitTask({
apiKey,
baseURL,
endpoint: modelConfig.atlasCloud.submitEndpoint,
body,
});
const outputUrls = submitted.outputs.length
? submitted.outputs
: await waitForTask({
apiKey,
baseURL,
endpoint: modelConfig.atlasCloud.pollEndpoint,
taskId: submitted.taskId,
});
const requestedCount = Math.max(1, options.imageParams?.imageCount || 1);
const archived = await Promise.all(
outputUrls.slice(0, requestedCount).map(archiveOutputImage)
);
const text = formatMarkdown(archived, imageSize);
return {
text,
finishReason: "stop" as const,
usage: { promptTokens: 0, completionTokens: text.length },
};
},
doStream: async function* (options: {
messages: Array<{ role: string; content?: any; parts?: any[] }>;
imageParams?: AtlasCloudImageParams;
imageGenConfig?: AtlasCloudModelConfig | null;
overrideModelId?: string;
}) {
const result = await this.doGenerate(options);
yield { type: "text-delta", textDelta: result.text };
},
}),
};
}