854 lines
29 KiB
TypeScript
854 lines
29 KiB
TypeScript
import "server-only";
|
||
|
||
import { uploadFile } from "@/lib/storage/service";
|
||
import { getDefaultStorageConfig } from "@/lib/db/queries";
|
||
|
||
interface VolcanoVisionConfig {
|
||
apiKey: string;
|
||
baseURL: string;
|
||
}
|
||
|
||
type VolcanoUrlRewriteConfig = {
|
||
publicOrigin: string;
|
||
endpointBase: string;
|
||
bucket: string;
|
||
};
|
||
|
||
let cachedRewriteConfig: VolcanoUrlRewriteConfig | null | undefined;
|
||
const VOLCANO_IMAGE_FETCH_TIMEOUT_MS = 20_000;
|
||
const VOLCANO_INLINE_MAX_SIDE = 1024;
|
||
const DEFAULT_VOLCANO_IMAGE_SIZE = "2K";
|
||
const SEEDREAM_MODEL_ID_PATTERN = /doubao-seedream-4-5/i;
|
||
|
||
function normalizeVolcanoImageSize(imageSize?: string): "1K" | "2K" | "4K" {
|
||
const normalized = String(imageSize || "").trim().toUpperCase();
|
||
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
|
||
return normalized;
|
||
}
|
||
return DEFAULT_VOLCANO_IMAGE_SIZE;
|
||
}
|
||
|
||
function resolveVolcanoImageSizeForModel(
|
||
modelId: string,
|
||
imageSize?: string
|
||
): "1K" | "2K" | "4K" {
|
||
const normalized = normalizeVolcanoImageSize(imageSize);
|
||
if (SEEDREAM_MODEL_ID_PATTERN.test(modelId)) {
|
||
// doubao-seedream-4-5 在当前链路下 1K 会返回 InvalidParameter,强制回落到 2K
|
||
if (normalized === "1K") {
|
||
console.log(
|
||
`[Volcano Vision] Size normalized for ${modelId}: 1K -> 2K (model constraint)`
|
||
);
|
||
return "2K";
|
||
}
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function guessImageMimeTypeFromUrl(url: string): string {
|
||
const cleanUrl = url.split("?")[0]?.toLowerCase() || "";
|
||
if (cleanUrl.endsWith(".png")) return "image/png";
|
||
if (cleanUrl.endsWith(".webp")) return "image/webp";
|
||
if (cleanUrl.endsWith(".gif")) return "image/gif";
|
||
if (cleanUrl.endsWith(".bmp")) return "image/bmp";
|
||
return "image/jpeg";
|
||
}
|
||
|
||
function shouldRetryVolcanoWithInlineImage(errorText: string): boolean {
|
||
if (!errorText) return false;
|
||
return /timeout while downloading url|download multimodal file timed out|invalidparameter/i.test(
|
||
errorText
|
||
);
|
||
}
|
||
|
||
async function fetchImageAsDataUri(url: string): Promise<string | null> {
|
||
if (!/^https?:\/\//i.test(url)) return null;
|
||
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), VOLCANO_IMAGE_FETCH_TIMEOUT_MS);
|
||
|
||
try {
|
||
const response = await fetch(url, { signal: controller.signal });
|
||
if (!response.ok) {
|
||
console.warn("[Volcano Vision] Failed to fetch source image for inline retry:", response.status, url);
|
||
return null;
|
||
}
|
||
|
||
const arrayBuffer = await response.arrayBuffer();
|
||
const buffer = Buffer.from(arrayBuffer);
|
||
if (buffer.length === 0) return null;
|
||
|
||
const inputMime = response.headers.get("content-type") || guessImageMimeTypeFromUrl(url);
|
||
|
||
// 优先保留原图,避免 base64 回退路径带来明显画质损失
|
||
if (buffer.length <= 6 * 1024 * 1024) {
|
||
return `data:${inputMime};base64,${buffer.toString("base64")}`;
|
||
}
|
||
|
||
try {
|
||
const sharp = await import("sharp");
|
||
const optimized = await sharp.default(buffer)
|
||
.rotate()
|
||
.resize(VOLCANO_INLINE_MAX_SIDE, VOLCANO_INLINE_MAX_SIDE, {
|
||
fit: "inside",
|
||
withoutEnlargement: true,
|
||
})
|
||
.jpeg({ quality: 90, mozjpeg: true })
|
||
.toBuffer();
|
||
return `data:image/jpeg;base64,${optimized.toString("base64")}`;
|
||
} catch {
|
||
return `data:${inputMime};base64,${buffer.toString("base64")}`;
|
||
}
|
||
} catch (error) {
|
||
console.warn("[Volcano Vision] Inline image fetch failed:", error);
|
||
return null;
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
}
|
||
|
||
async function convertImageFieldToInlineDataUri(
|
||
image: string | string[] | undefined
|
||
): Promise<string | string[] | undefined> {
|
||
if (!image) return image;
|
||
const list = Array.isArray(image) ? image : [image];
|
||
const converted = await Promise.all(
|
||
list.map(async (item) => {
|
||
if (typeof item !== "string") return null;
|
||
if (item.startsWith("data:image")) return item;
|
||
return fetchImageAsDataUri(item);
|
||
})
|
||
);
|
||
|
||
const ready = converted.filter((item): item is string => typeof item === "string" && item.length > 0);
|
||
if (ready.length === 0) return undefined;
|
||
return Array.isArray(image) ? ready : ready[0];
|
||
}
|
||
|
||
async function getVolcanoUrlRewriteConfig(): Promise<VolcanoUrlRewriteConfig | null> {
|
||
if (cachedRewriteConfig !== undefined) {
|
||
return cachedRewriteConfig;
|
||
}
|
||
|
||
try {
|
||
const storageConfig = await getDefaultStorageConfig();
|
||
if (
|
||
!storageConfig ||
|
||
storageConfig.type !== "s3" ||
|
||
!storageConfig.endpoint ||
|
||
!storageConfig.bucket ||
|
||
!storageConfig.publicUrl
|
||
) {
|
||
cachedRewriteConfig = null;
|
||
return null;
|
||
}
|
||
|
||
const publicOrigin = new URL(storageConfig.publicUrl).origin;
|
||
const endpointBase = storageConfig.endpoint.replace(/\/+$/, "");
|
||
|
||
cachedRewriteConfig = {
|
||
publicOrigin,
|
||
endpointBase,
|
||
bucket: storageConfig.bucket,
|
||
};
|
||
return cachedRewriteConfig;
|
||
} catch {
|
||
cachedRewriteConfig = null;
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function rewriteUploadPublicUrlForVolcano(url: string): Promise<string> {
|
||
if (!/^https?:\/\//i.test(url)) return url;
|
||
|
||
const config = await getVolcanoUrlRewriteConfig();
|
||
if (!config) return url;
|
||
|
||
try {
|
||
const parsed = new URL(url);
|
||
if (parsed.origin !== config.publicOrigin) return url;
|
||
if (!parsed.pathname.startsWith("/uploads/")) return url;
|
||
|
||
const rewritten = `${config.endpointBase}/${config.bucket}${parsed.pathname}${parsed.search}`;
|
||
console.log("[Volcano Vision] Rewrote upload URL for provider fetch:", rewritten);
|
||
return rewritten;
|
||
} catch {
|
||
return url;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 压缩图片生成缩略图(使用 sharp)
|
||
*/
|
||
async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer> {
|
||
try {
|
||
const sharp = await import("sharp");
|
||
|
||
// 创建缩略图(宽度 500px,高度按比例,质量 80%)
|
||
const thumbnailBuffer = await sharp.default(buffer)
|
||
.resize(500, null, {
|
||
fit: "inside",
|
||
withoutEnlargement: true,
|
||
})
|
||
.jpeg({ quality: 80 })
|
||
.toBuffer();
|
||
|
||
console.log(`[Volcano Vision] Thumbnail created, size: ${thumbnailBuffer.length} bytes (original: ${buffer.length} bytes)`);
|
||
return thumbnailBuffer;
|
||
} catch (error) {
|
||
console.error("[Volcano Vision] Failed to create thumbnail:", error);
|
||
// 如果失败,返回原图
|
||
return buffer;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 将图片 URL 或 base64 上传到 S3,返回缩略图和原图的 URL
|
||
*/
|
||
async function uploadImageToS3(imageData: string): Promise<{ thumbnail: string; original: string }> {
|
||
try {
|
||
let buffer: Buffer;
|
||
let mimeType = "image/jpeg";
|
||
|
||
// 检查是否是 base64
|
||
if (imageData.startsWith("data:")) {
|
||
const matches = imageData.match(/^data:([^;]+);base64,(.+)$/);
|
||
if (matches) {
|
||
mimeType = matches[1];
|
||
buffer = Buffer.from(matches[2], "base64");
|
||
} else {
|
||
throw new Error("Invalid base64 format");
|
||
}
|
||
} else {
|
||
// 是 URL,下载图片
|
||
console.log(`[Volcano Vision] Downloading image from URL...`);
|
||
const response = await fetch(imageData);
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to download image: ${response.statusText}`);
|
||
}
|
||
const arrayBuffer = await response.arrayBuffer();
|
||
buffer = Buffer.from(arrayBuffer);
|
||
mimeType = response.headers.get("content-type") || "image/jpeg";
|
||
}
|
||
|
||
// 生成文件名
|
||
const ext = mimeType.split("/")[1] || "jpg";
|
||
const timestamp = Date.now();
|
||
const randomStr = Math.random().toString(36).slice(2, 8);
|
||
const thumbnailFilename = `volcano-thumb-${timestamp}-${randomStr}.${ext}`;
|
||
const originalFilename = `volcano-orig-${timestamp}-${randomStr}.${ext}`;
|
||
|
||
console.log(`[Volcano Vision] Original size: ${buffer.length} bytes`);
|
||
|
||
// 生成缩略图
|
||
const thumbnailBuffer = await createThumbnail(buffer, mimeType);
|
||
|
||
// 转换为 ArrayBuffer
|
||
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
|
||
thumbnailBuffer.byteOffset,
|
||
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
|
||
) as ArrayBuffer;
|
||
const originalArrayBuffer = buffer.buffer.slice(
|
||
buffer.byteOffset,
|
||
buffer.byteOffset + buffer.byteLength
|
||
) as ArrayBuffer;
|
||
|
||
// 并行上传缩略图和原图到 S3
|
||
console.log(`[Volcano Vision] Uploading thumbnail and original to S3...`);
|
||
const [thumbnailResult, originalResult] = await Promise.all([
|
||
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
|
||
uploadFile(originalFilename, originalArrayBuffer),
|
||
]);
|
||
|
||
console.log(`[Volcano Vision] Uploaded to S3:`);
|
||
console.log(` - Thumbnail: ${thumbnailResult.url}`);
|
||
console.log(` - Original: ${originalResult.url}`);
|
||
|
||
return {
|
||
thumbnail: thumbnailResult.url,
|
||
original: originalResult.url,
|
||
};
|
||
} catch (error) {
|
||
console.error("[Volcano Vision] Failed to upload to S3:", error);
|
||
throw new Error(`图片上传到存储桶失败: ${error instanceof Error ? error.message : "未知错误"}。请检查系统设置中的存储配置。`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 火山引擎 API 响应格式
|
||
*/
|
||
interface VolcanoVisionResponse {
|
||
model: string;
|
||
created: number;
|
||
data: Array<{
|
||
url?: string;
|
||
b64_json?: string;
|
||
size: string;
|
||
}>;
|
||
usage: {
|
||
generated_images: number;
|
||
output_tokens: number;
|
||
total_tokens: number;
|
||
};
|
||
error?: {
|
||
code: string;
|
||
message: string;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 火山引擎流式响应事件
|
||
*/
|
||
interface VolcanoVisionStreamEvent {
|
||
type: "image_generation.partial_succeeded" | "image_generation.completed";
|
||
model: string;
|
||
created: number;
|
||
image_index?: number;
|
||
url?: string;
|
||
size?: string;
|
||
usage?: {
|
||
generated_images: number;
|
||
output_tokens: number;
|
||
total_tokens: number;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 从 API 响应中提取图片并上传到 S3
|
||
*/
|
||
async function extractAndUploadImages(response: VolcanoVisionResponse): Promise<Array<{ thumbnail: string; original: string }>> {
|
||
const images: Array<{ thumbnail: string; original: string }> = [];
|
||
const uploadPromises: Promise<void>[] = [];
|
||
|
||
if (response.data && response.data.length > 0) {
|
||
for (const item of response.data) {
|
||
if (item.url) {
|
||
// URL 格式
|
||
const uploadPromise = uploadImageToS3(item.url)
|
||
.then(urls => { images.push(urls); });
|
||
uploadPromises.push(uploadPromise);
|
||
} else if (item.b64_json) {
|
||
// Base64 格式
|
||
const base64Data = `data:image/jpeg;base64,${item.b64_json}`;
|
||
const uploadPromise = uploadImageToS3(base64Data)
|
||
.then(urls => { images.push(urls); });
|
||
uploadPromises.push(uploadPromise);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 等待所有上传完成
|
||
await Promise.all(uploadPromises);
|
||
|
||
console.log("[Volcano Vision] Extracted and uploaded", images.length, "images (thumbnail + original)");
|
||
return images;
|
||
}
|
||
|
||
/**
|
||
* 将图片数据转换为 Markdown 格式
|
||
* 使用特殊格式传递缩略图和原图 URL:
|
||
*/
|
||
function formatImageResponse(
|
||
images: Array<{ thumbnail: string; original: string }>,
|
||
imageSize: string = DEFAULT_VOLCANO_IMAGE_SIZE
|
||
): string {
|
||
if (images.length === 0) {
|
||
return "生成图片失败,未找到图片数据";
|
||
}
|
||
|
||
// 🆕 统一使用 ||| 分隔符格式(与 Gemini 保持一致)
|
||
// 格式: 
|
||
const normalizedSize = normalizeVolcanoImageSize(imageSize);
|
||
return images
|
||
.map((img) => ``)
|
||
.join("\n\n");
|
||
}
|
||
|
||
/**
|
||
* 将消息转换为火山引擎 API 格式
|
||
*/
|
||
async function convertMessagesToVolcanoFormat(messages: any[]): Promise<{ prompt: string; image?: string | string[] }> {
|
||
const lastMessage = messages[messages.length - 1];
|
||
let prompt = "";
|
||
const images: string[] = [];
|
||
|
||
console.log("[Volcano Vision] Converting messages, last message:", JSON.stringify(lastMessage).substring(0, 500));
|
||
|
||
// 提取文本和图片
|
||
if (lastMessage.content) {
|
||
if (typeof lastMessage.content === "string") {
|
||
prompt = lastMessage.content;
|
||
} else if (Array.isArray(lastMessage.content)) {
|
||
for (const part of lastMessage.content) {
|
||
console.log("[Volcano Vision] Processing part:", JSON.stringify(part).substring(0, 150));
|
||
|
||
if (part.type === "text") {
|
||
prompt += part.text;
|
||
} else if (part.type === "image_url") {
|
||
// OpenAI 格式:{ type: "image_url", image_url: { url: "..." } }
|
||
if (part.image_url && part.image_url.url) {
|
||
images.push(part.image_url.url);
|
||
console.log("[Volcano Vision] ✅ Found image_url:", part.image_url.url.substring(0, 100) + "...");
|
||
}
|
||
} else if (part.type === "file") {
|
||
// 文件格式:{ type: "file", mediaType: "image/...", data: "url" }
|
||
console.log("[Volcano Vision] File part - mediaType:", part.mediaType, "data exists:", !!part.data);
|
||
if (part.data && part.mediaType && part.mediaType.startsWith("image/")) {
|
||
images.push(part.data);
|
||
console.log("[Volcano Vision] ✅ Found file (image):", part.data.substring(0, 100) + "...");
|
||
} else {
|
||
console.log("[Volcano Vision] ❌ File part skipped - mediaType:", part.mediaType, "data:", !!part.data);
|
||
}
|
||
} else if (part.type === "image") {
|
||
// 其他格式:{ type: "image", image: "..." } 或 { type: "image", url: "..." }
|
||
if (part.image) {
|
||
images.push(part.image);
|
||
console.log("[Volcano Vision] ✅ Found image:", part.image.substring(0, 100) + "...");
|
||
} else if (part.url) {
|
||
images.push(part.url);
|
||
console.log("[Volcano Vision] ✅ Found image url:", part.url.substring(0, 100) + "...");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log("[Volcano Vision] Extracted prompt:", prompt.substring(0, 100));
|
||
console.log("[Volcano Vision] Extracted images count:", images.length);
|
||
|
||
// 🆕 直接使用图片URL,不再转换Base64(既梦API支持URL格式)
|
||
// 只有当图片是Base64格式时才保持原样
|
||
const processedImages: string[] = [];
|
||
for (const imageUrl of images) {
|
||
if (imageUrl.startsWith('data:image')) {
|
||
// Base64格式:需要先上传到S3获取URL
|
||
console.log("[Volcano Vision] Found Base64 image, uploading to S3...");
|
||
const uploaded = await uploadImageToS3(imageUrl);
|
||
const rewritten = await rewriteUploadPublicUrlForVolcano(uploaded.original);
|
||
processedImages.push(rewritten);
|
||
} else {
|
||
// URL格式:直接使用
|
||
const rewritten = await rewriteUploadPublicUrlForVolcano(imageUrl);
|
||
processedImages.push(rewritten);
|
||
console.log("[Volcano Vision] Using image URL directly:", rewritten.substring(0, 80) + "...");
|
||
}
|
||
}
|
||
|
||
// 不在这里添加"生成多个变体"的提示,由调用方根据maxImages决定
|
||
// 这样可以避免在场景裂变等只需要1张图片的场景中生成多张图片
|
||
const enhancedPrompt = prompt;
|
||
console.log("[Volcano Vision] Prompt:", enhancedPrompt.substring(0, 150));
|
||
|
||
// 构建请求体
|
||
const result: { prompt: string; image?: string | string[] } = { prompt: enhancedPrompt };
|
||
|
||
if (processedImages.length === 1) {
|
||
result.image = processedImages[0];
|
||
} else if (processedImages.length > 1) {
|
||
result.image = processedImages; // 多图直接传数组
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 发送火山引擎图像生成请求(非流式)
|
||
*/
|
||
async function sendVolcanoRequest(
|
||
baseURL: string,
|
||
apiKey: string,
|
||
messages: any[],
|
||
modelId: string,
|
||
maxImages: number = 3, // 默认 3 张,故障转移时传 1
|
||
imageSize: string = DEFAULT_VOLCANO_IMAGE_SIZE
|
||
): Promise<VolcanoVisionResponse> {
|
||
const url = `${baseURL}/api/v3/images/generations`;
|
||
|
||
console.log("[Volcano Vision] Sending non-stream request to:", url);
|
||
console.log("[Volcano Vision] Using model:", modelId);
|
||
|
||
// 转换消息格式(直接使用URL)
|
||
let { prompt, image } = await convertMessagesToVolcanoFormat(messages);
|
||
|
||
// 根据 maxImages 参数优化提示词
|
||
if (maxImages === 1) {
|
||
// 只生成1张图片时,明确指示只生成1张,避免生成多张
|
||
if (!prompt.includes("只生成") && !prompt.includes("生成1张") && !prompt.includes("生成一张")) {
|
||
prompt = prompt.trim();
|
||
// 移除末尾的句号(如果有),然后添加明确指令
|
||
if (prompt.endsWith("。") || prompt.endsWith(".")) {
|
||
prompt = prompt.slice(0, -1);
|
||
}
|
||
prompt += "。只生成1张图片。";
|
||
}
|
||
} else if (maxImages > 1) {
|
||
// 生成多张图片时,明确要求生成多个变体
|
||
if (!prompt.includes("生成") || (!prompt.includes("变体") && !prompt.includes("不同"))) {
|
||
prompt = prompt.trim();
|
||
if (prompt.endsWith("。") || prompt.endsWith(".")) {
|
||
prompt = prompt.slice(0, -1);
|
||
}
|
||
prompt += `。请生成 ${maxImages} 个不同的变体。`;
|
||
}
|
||
}
|
||
|
||
console.log("[Volcano Vision] Prompt:", prompt.substring(0, 100) + "...");
|
||
console.log("[Volcano Vision] Image count:", image ? (Array.isArray(image) ? image.length : 1) : 0);
|
||
|
||
// 构建请求体
|
||
const normalizedSize = resolveVolcanoImageSizeForModel(modelId, imageSize);
|
||
const requestBody: any = {
|
||
model: modelId,
|
||
prompt,
|
||
size: normalizedSize,
|
||
watermark: false,
|
||
response_format: "url",
|
||
stream: false,
|
||
};
|
||
|
||
// 添加图片(如果有)
|
||
if (image) {
|
||
requestBody.image = image;
|
||
}
|
||
|
||
// 根据 maxImages 参数配置组图功能
|
||
if (maxImages > 1) {
|
||
requestBody.sequential_image_generation = "auto";
|
||
requestBody.sequential_image_generation_options = {
|
||
max_images: maxImages,
|
||
};
|
||
console.log(`[Volcano Vision] Generating up to ${maxImages} images (mode: ${image ? "image-to-image" : "text-to-image"})`);
|
||
} else {
|
||
requestBody.sequential_image_generation = "disabled";
|
||
console.log(`[Volcano Vision] Generating 1 image (mode: ${image ? "image-to-image" : "text-to-image"})`);
|
||
}
|
||
|
||
console.log("[Volcano Vision] Request body:", JSON.stringify(requestBody).substring(0, 500) + "...");
|
||
console.log("[Volcano Vision] Full request body (prompt + image info):");
|
||
console.log(" - Prompt:", requestBody.prompt);
|
||
console.log(" - Image field exists:", !!requestBody.image);
|
||
console.log(" - Image type:", typeof requestBody.image);
|
||
console.log(" - Image is array:", Array.isArray(requestBody.image));
|
||
console.log(" - Image count:", Array.isArray(requestBody.image) ? requestBody.image.length : (requestBody.image ? 1 : 0));
|
||
if (requestBody.image) {
|
||
if (Array.isArray(requestBody.image)) {
|
||
requestBody.image.forEach((img: string, idx: number) => {
|
||
console.log(` - Image ${idx}: ${img.substring(0, 100)}...`);
|
||
});
|
||
} else {
|
||
console.log(` - Image: ${requestBody.image.substring(0, 100)}...`);
|
||
}
|
||
}
|
||
|
||
const executeRequest = async (payload: any, tag: string): Promise<VolcanoVisionResponse> => {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 600000);
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${apiKey}`,
|
||
},
|
||
body: JSON.stringify(payload),
|
||
signal: controller.signal,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
console.error(`[Volcano Vision] Request failed (${tag}):`, response.status, errorText);
|
||
const err = new Error(
|
||
`Volcano Vision API error: ${response.status} ${response.statusText} - ${errorText}`
|
||
) as Error & { status?: number; body?: string };
|
||
err.status = response.status;
|
||
err.body = errorText;
|
||
throw err;
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.log(
|
||
`[Volcano Vision] Response received (${tag}):`,
|
||
JSON.stringify(data).substring(0, 200) + "..."
|
||
);
|
||
|
||
if (data.error) {
|
||
throw new Error(`API Error: ${data.error.message || data.error.code}`);
|
||
}
|
||
|
||
return data as VolcanoVisionResponse;
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
};
|
||
|
||
try {
|
||
return await executeRequest(requestBody, "primary");
|
||
} catch (error) {
|
||
if (error instanceof Error && error.name === "AbortError") {
|
||
console.error("[Volcano Vision] Request timeout after 10 minutes");
|
||
throw new Error("请求超时,图片处理时间过长,请稍后重试");
|
||
}
|
||
|
||
const errorBody = (error as any)?.body || String(error || "");
|
||
if (requestBody.image && shouldRetryVolcanoWithInlineImage(errorBody)) {
|
||
console.warn("[Volcano Vision] Retrying with inline base64 image payload...");
|
||
const inlineImage = await convertImageFieldToInlineDataUri(requestBody.image);
|
||
if (inlineImage) {
|
||
const retryBody = { ...requestBody, image: inlineImage };
|
||
return await executeRequest(retryBody, "inline-retry");
|
||
}
|
||
console.warn("[Volcano Vision] Inline retry skipped: failed to convert source image(s).");
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送火山引擎图像生成请求(流式)
|
||
*/
|
||
async function* sendVolcanoStreamRequest(
|
||
baseURL: string,
|
||
apiKey: string,
|
||
messages: any[],
|
||
modelId: string,
|
||
imageSize: string = DEFAULT_VOLCANO_IMAGE_SIZE
|
||
): AsyncGenerator<VolcanoVisionStreamEvent> {
|
||
const url = `${baseURL}/api/v3/images/generations`;
|
||
|
||
console.log("[Volcano Vision] Sending stream request to:", url);
|
||
console.log("[Volcano Vision] Using model:", modelId);
|
||
|
||
// 转换消息格式(直接使用URL)
|
||
const { prompt, image } = await convertMessagesToVolcanoFormat(messages);
|
||
|
||
console.log("[Volcano Vision] Prompt:", prompt.substring(0, 100) + "...");
|
||
console.log("[Volcano Vision] Image count:", image ? (Array.isArray(image) ? image.length : 1) : 0);
|
||
|
||
// 构建请求体
|
||
const normalizedSize = normalizeVolcanoImageSize(imageSize);
|
||
const requestBody: any = {
|
||
model: modelId,
|
||
prompt,
|
||
size: normalizedSize,
|
||
watermark: false,
|
||
response_format: "url",
|
||
stream: true,
|
||
};
|
||
|
||
// 添加图片(如果有)
|
||
if (image) {
|
||
requestBody.image = image;
|
||
}
|
||
|
||
console.log("[Volcano Vision] Stream request body:", JSON.stringify(requestBody).substring(0, 500) + "...");
|
||
console.log("[Volcano Vision] Stream - Full request info:");
|
||
console.log(" - Prompt:", requestBody.prompt);
|
||
console.log(" - Image field exists:", !!requestBody.image);
|
||
console.log(" - Image type:", typeof requestBody.image);
|
||
console.log(" - Image is array:", Array.isArray(requestBody.image));
|
||
console.log(" - Image count:", Array.isArray(requestBody.image) ? requestBody.image.length : (requestBody.image ? 1 : 0));
|
||
|
||
// 设置 10 分钟超时
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 600000);
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${apiKey}`,
|
||
},
|
||
body: JSON.stringify(requestBody),
|
||
signal: controller.signal,
|
||
});
|
||
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
console.error("[Volcano Vision] Stream request failed:", response.status, errorText);
|
||
throw new Error(
|
||
`Volcano Vision API error: ${response.status} ${response.statusText}`
|
||
);
|
||
}
|
||
|
||
// 解析 SSE 流
|
||
const reader = response.body?.getReader();
|
||
if (!reader) {
|
||
throw new Error("Failed to get response reader");
|
||
}
|
||
|
||
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) {
|
||
const trimmedLine = line.trim();
|
||
if (!trimmedLine || trimmedLine === "data: [DONE]") continue;
|
||
|
||
if (trimmedLine.startsWith("data: ")) {
|
||
const jsonStr = trimmedLine.slice(6);
|
||
try {
|
||
const event = JSON.parse(jsonStr) as VolcanoVisionStreamEvent;
|
||
console.log("[Volcano Vision] Stream event:", event.type, event.image_index);
|
||
yield event;
|
||
} catch (error) {
|
||
console.error("[Volcano Vision] Failed to parse stream event:", jsonStr);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
clearTimeout(timeoutId);
|
||
if (error instanceof Error && error.name === "AbortError") {
|
||
console.error("[Volcano Vision] Stream request timeout after 10 minutes");
|
||
throw new Error("请求超时,图片处理时间过长,请稍后重试");
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建火山引擎视觉 Provider
|
||
*/
|
||
export function createVolcanoVision({ apiKey, baseURL }: VolcanoVisionConfig) {
|
||
return {
|
||
chat: (modelId: string) => ({
|
||
doGenerate: async (options: any) => {
|
||
console.log("[Volcano Vision] doGenerate called!");
|
||
console.log("[Volcano Vision] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "...");
|
||
const activeModelId = options.overrideModelId || modelId;
|
||
console.log("[Volcano Vision] Using modelId:", activeModelId);
|
||
console.log("[Volcano Vision] Image params from options:", options.imageParams);
|
||
const imageSize = resolveVolcanoImageSizeForModel(
|
||
activeModelId,
|
||
options.imageParams?.imageSize
|
||
);
|
||
|
||
// 🆕 优先使用用户选择的图片数量,其次是 maxImages 参数,默认 1 张
|
||
const imageCount = options.imageParams?.imageCount || options.maxImages || 1;
|
||
const maxImages = Math.min(Math.max(imageCount, 1), 3); // 限制 1-3 张
|
||
console.log("[Volcano Vision] Max images:", maxImages);
|
||
|
||
try {
|
||
// 发送请求
|
||
const response = await sendVolcanoRequest(
|
||
baseURL,
|
||
apiKey,
|
||
options.messages,
|
||
activeModelId,
|
||
maxImages,
|
||
imageSize
|
||
);
|
||
|
||
// 提取图片并上传到 S3
|
||
const imageUrls = await extractAndUploadImages(response);
|
||
const markdownText = formatImageResponse(imageUrls, imageSize);
|
||
|
||
console.log(
|
||
`[Volcano Vision] Generated ${imageUrls.length} images (uploaded to S3)`
|
||
);
|
||
|
||
// 返回 AI SDK v1 格式
|
||
return {
|
||
text: markdownText,
|
||
finishReason: "stop" as const,
|
||
usage: {
|
||
promptTokens: 0,
|
||
completionTokens: markdownText.length,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
console.error("[Volcano Vision] Generation failed:", error);
|
||
|
||
// 返回错误信息
|
||
const errorMessage =
|
||
error instanceof Error
|
||
? `图片生成失败: ${error.message}`
|
||
: "图片生成失败,请稍后重试";
|
||
|
||
return {
|
||
text: errorMessage,
|
||
finishReason: "error" as const,
|
||
usage: { promptTokens: 0, completionTokens: 0 },
|
||
};
|
||
}
|
||
},
|
||
|
||
doStream: async function* (options: any) {
|
||
console.log("[Volcano Vision] ===== doStream ENTERED! =====");
|
||
console.log("[Volcano Vision] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "...");
|
||
console.log("[Volcano Vision] Using modelId:", modelId);
|
||
|
||
try {
|
||
const allImages: Array<{ thumbnail: string; original: string }> = [];
|
||
|
||
// 使用流式请求
|
||
const imageSize = resolveVolcanoImageSizeForModel(
|
||
modelId,
|
||
options.imageParams?.imageSize
|
||
);
|
||
for await (const event of sendVolcanoStreamRequest(baseURL, apiKey, options.messages, modelId, imageSize)) {
|
||
if (event.type === "image_generation.partial_succeeded" && event.url) {
|
||
// 每张图片生成完成后立即上传并发送
|
||
try {
|
||
const imageUrls = await uploadImageToS3(event.url);
|
||
allImages.push(imageUrls);
|
||
|
||
const markdown = ``;
|
||
console.log(`[Volcano Vision Stream] Image ${event.image_index} uploaded, sending...`);
|
||
|
||
yield { type: "text-delta" as const, textDelta: markdown + "\n\n" };
|
||
} catch (error) {
|
||
console.error(`[Volcano Vision Stream] Failed to upload image ${event.image_index}:`, error);
|
||
}
|
||
} else if (event.type === "image_generation.completed") {
|
||
console.log(`[Volcano Vision Stream] Generation completed, total images: ${allImages.length}`);
|
||
|
||
// 发送完成信号
|
||
yield {
|
||
type: "finish" as const,
|
||
finishReason: "stop" as const,
|
||
usage: {
|
||
promptTokens: 0,
|
||
completionTokens: allImages.length * 50, // 估算
|
||
},
|
||
};
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("[Volcano Vision Stream] Generation failed:", error);
|
||
|
||
const errorMessage =
|
||
error instanceof Error
|
||
? `图片生成失败: ${error.message}`
|
||
: "图片生成失败,请稍后重试";
|
||
|
||
// 发送错误信息
|
||
yield { type: "text-delta" as const, textDelta: errorMessage };
|
||
|
||
yield {
|
||
type: "finish" as const,
|
||
finishReason: "error" as const,
|
||
usage: { promptTokens: 0, completionTokens: 0 },
|
||
};
|
||
}
|
||
},
|
||
|
||
modelId,
|
||
provider: "volcano-vision",
|
||
specificationVersion: "v1" as const,
|
||
defaultObjectGenerationMode: "json" as const,
|
||
}),
|
||
};
|
||
}
|