2026-04-18 15:37:13 +08:00

427 lines
14 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.

// ==================== 类型定义 ====================
// 文本模型
interface TextModel {
name: string;
modelName: string;
type: "text";
think: boolean;
}
// 图像模型
interface ImageModel {
name: string;
modelName: string;
type: "image";
mode: ("text" | "singleImage" | "multiReference")[];
associationSkills?: string;
}
// 视频模型
interface VideoModel {
name: string;
modelName: string;
type: "video";
mode: (
| "singleImage"
| "startEndRequired"
| "endFrameOptional"
| "startFrameOptional"
| "text"
| ("videoReference" | "imageReference" | "audioReference" | "textReference")[]
)[];
associationSkills?: string;
audio: "optional" | false | true;
durationResolutionMap: { duration: number[]; resolution: string[] }[];
}
interface TTSModel {
name: string;
modelName: string;
type: "tts";
voices: { title: string; voice: string }[];
}
interface VendorConfig {
id: string;
author: string;
description?: string;
name: string;
icon?: string;
inputs: { key: string; label: string; type: "text" | "password" | "url"; required: boolean; placeholder?: string }[];
inputValues: Record<string, string>;
models: (TextModel | ImageModel | VideoModel)[];
}
// ==================== 全局工具函数声明 ====================
declare const zipImage: (completeBase64: string, size: number) => Promise<string>;
declare const zipImageResolution: (completeBase64: string, width: number, height: number) => Promise<string>;
declare const mergeImages: (completeBase64: string[], maxSize?: string) => Promise<string>;
declare const urlToBase64: (url: string) => Promise<string>;
declare const pollTask: (
fn: () => Promise<{ completed: boolean; data?: string; error?: string }>,
interval?: number,
timeout?: number,
) => Promise<{ completed: boolean; data?: string; error?: string }>;
declare const axios: any;
declare const createOpenAI: any;
declare const createDeepSeek: any;
declare const createZhipu: any;
declare const createQwen: any;
declare const createAnthropic: any;
declare const createOpenAICompatible: any;
declare const createXai: any;
declare const createMinimax: any;
declare const createGoogleGenerativeAI: any;
declare const logger: (logstring: string) => void;
declare const jsonwebtoken: any;
// ==================== 供应商数据 ====================
const vendor: VendorConfig = {
id: "yunwu",
author: "Toonflow",
description: "OpenAI标准格式接口您可以修改请求地址并手动添加缺失的模型。",
name: "云雾中转",
icon: "",
inputs: [
{ key: "apiKey", label: "API密钥", type: "password", required: true },
{ key: "baseUrl", label: "请求地址", type: "url", required: true, placeholder: "以v1结束示例https://yunwu.ai/v1" },
],
inputValues: {
apiKey: "",
baseUrl: "https://yunwu.ai/v1",
},
models: [
{
name: "Doubao-Seedream-5.0-lite",
type: "image",
modelName: "doubao-seedream-5-0-260128",
mode: ["text", "singleImage", "multiReference"],
},
{
name: "Gemini-3-Pro-Image-Preview",
type: "image",
modelName: "gemini-3.1-flash-image-preview",
mode: ["text", "singleImage", "multiReference"],
associationSkills: "高质量图像生成,支持文本生成图像、图像编辑",
},
{
name: "Claude-sonnet-4.6",
type: "text",
modelName: "claude-sonnet-4-6",
think: false,
},
{
name: "Claude-haiku-4.5-20251001",
type: "text",
modelName: "claude-haiku-4-5-20251001",
think: false,
},
{
name: "Grok-Video-3",
type: "video",
modelName: "grok-video-3",
mode: ["text", "singleImage"],
audio: false,
durationResolutionMap: [
{ duration: [6, 10], resolution: ["720P", "1080P"] }
],
associationSkills: "文本生成视频,支持图片垫图"
}
],
};
exports.vendor = vendor;
// ==================== 适配器函数 ====================
// 文本请求函数
const textRequest = (textModel: TextModel) => {
if (!vendor.inputValues.apiKey) throw new Error("缺少API Key");
if (!vendor.inputValues.baseUrl) throw new Error("缺少请求地址(baseUrl)");
const apiKey = vendor.inputValues.apiKey.replace(/^Bearer\s+/i, "");
const baseURL = vendor.inputValues.baseUrl;
return createOpenAI({
baseURL: baseURL,
apiKey: apiKey,
}).chat(textModel.modelName);
};
exports.textRequest = textRequest;
// 图片请求函数(修正版:使用 /v1/chat/completions 兼容接口)
interface ImageConfig {
prompt: string;
imageBase64: string[];
size: "1K" | "2K" | "4K";
aspectRatio: `${number}:${number}`;
}
const imageRequest = async (imageConfig: ImageConfig, imageModel: ImageModel) => {
const { apiKey, baseUrl } = vendor.inputValues;
if (!apiKey) throw new Error("缺少API Key");
if (!baseUrl) throw new Error("缺少请求地址(baseUrl)");
const cleanApiKey = apiKey.replace(/^Bearer\s+/i, "");
const baseURL = baseUrl.replace(/\/$/, "");
const endpoint = baseURL + "/chat/completions";
// 构建用户消息内容(支持多图垫图)
const content: any[] = [
{
type: "text",
text: imageConfig.prompt,
},
];
// 添加参考图片(垫图)
if (imageConfig.imageBase64 && imageConfig.imageBase64.length > 0) {
for (const imgBase64 of imageConfig.imageBase64) {
let dataUrl = imgBase64;
if (!imgBase64.startsWith("data:image")) {
dataUrl = `data:image/png;base64,${imgBase64}`;
}
content.push({
type: "image_url",
image_url: { url: dataUrl },
});
}
}
// 注意:云雾中转站可能支持通过额外参数传递图像尺寸/比例,
// 若不确定,可将 size 和 aspectRatio 拼接到 prompt 中(推荐)。
// 这里采用追加提示词的方式,确保模型理解期望的分辨率和比例。
let finalPrompt = imageConfig.prompt;
const sizeMap: Record<string, string> = { "1K": "1024x1024", "2K": "2048x2048", "4K": "4096x4096" };
const resolution = sizeMap[imageConfig.size] || "1024x1024";
finalPrompt += `\n请生成一张比例为 ${imageConfig.aspectRatio}、分辨率不低于 ${resolution} 的图片。`;
content[0].text = finalPrompt;
const requestBody = {
model: imageModel.modelName,
messages: [
{
role: "user",
content: content,
},
],
max_tokens: 4096, // 防止输出截断
response_format: { type: "json_object" }, // 部分中转站需要 JSON 输出
};
logger(`[图像生成] 请求URL: ${endpoint}`);
logger(`[图像生成] 模型: ${imageModel.modelName}`);
logger(`[图像生成] 参考图片数量: ${imageConfig.imageBase64?.length || 0}`);
try {
const response = await axios.post(endpoint, requestBody, {
headers: {
"Authorization": `Bearer ${cleanApiKey}`,
"Content-Type": "application/json",
},
timeout: 120000,
});
if (response.status !== 200) {
throw new Error(`HTTP ${response.status}: ${JSON.stringify(response.data)}`);
}
const assistantMessage = response.data?.choices?.[0]?.message?.content;
if (!assistantMessage) {
throw new Error("响应中没有 assistant 消息内容");
}
// 提取图像数据(支持直接返回 base64 data URL 或普通 URL
let imageBase64: string | null = null;
// 情况1消息内容本身就是 data:image 开头
if (assistantMessage.startsWith("data:image")) {
imageBase64 = assistantMessage;
}
// 情况2包含 Markdown 图片链接 ![alt](url)
else {
const markdownMatch = assistantMessage.match(/!\[.*?\]\((.*?)\)/);
if (markdownMatch && markdownMatch[1]) {
const url = markdownMatch[1];
if (url.startsWith("data:image")) {
imageBase64 = url;
} else {
imageBase64 = await urlToBase64(url);
}
}
// 情况3直接是纯文本 URL
else if (assistantMessage.match(/^https?:\/\/[^\s]+\.(png|jpg|jpeg|gif|webp)/i)) {
imageBase64 = await urlToBase64(assistantMessage);
}
}
if (!imageBase64) {
// 最后尝试:也许整个 content 就是 base64 字符串(无前缀)
if (/^[A-Za-z0-9+/=]+$/.test(assistantMessage) && assistantMessage.length > 100) {
imageBase64 = `data:image/png;base64,${assistantMessage}`;
} else {
throw new Error(`无法从响应中提取图像数据: ${assistantMessage.substring(0, 200)}`);
}
}
logger(`[图像生成] 成功,图片大小: ${(imageBase64.length / 1024).toFixed(2)} KB`);
return imageBase64;
} catch (error: any) {
logger(`[图像生成] 失败: ${error.message}`);
if (error.response) {
logger(`[图像生成] API 错误详情: ${JSON.stringify(error.response.data)}`);
throw new Error(`图像生成失败: ${error.response.data?.error?.message || error.message}`);
}
throw error;
}
};
exports.imageRequest = imageRequest;
// 视频请求函数(保持原有实现,若云雾中转站有专用视频接口可按需修改)
interface VideoConfig {
duration: number;
resolution: string;
aspectRatio: "16:9" | "9:16";
prompt: string;
imageBase64?: string[];
audio?: boolean;
mode:
| "singleImage"
| "multiImage"
| "gridImage"
| "startEndRequired"
| "endFrameOptional"
| "startFrameOptional"
| "text"
| ("video" | "image" | "audio" | "text")[];
}
const videoRequest = async (videoConfig: VideoConfig, videoModel: VideoModel) => {
const { apiKey, baseUrl: rawBaseUrl } = vendor.inputValues;
if (!apiKey) throw new Error("缺少API Key");
const baseUrl = rawBaseUrl?.trim();
if (!baseUrl) throw new Error("缺少请求地址(baseUrl)");
const createEndpoint = baseUrl.replace(/\/$/, "") + "/video/create";
const queryEndpoint = baseUrl.replace(/\/$/, "") + "/video/query";
let images: string[] | undefined;
if (videoConfig.imageBase64 && videoConfig.imageBase64.length > 0) {
logger(`[视频生成] 原始图片数组: ${JSON.stringify(videoConfig.imageBase64)}`);
images = videoConfig.imageBase64
.filter(img => img && typeof img === 'string' && img.length > 0)
.map(img => {
if (img.startsWith("data:image")) return img;
return `data:image/png;base64,${img}`;
});
if (images.length === 0) {
logger(`[视频生成] 警告: 所有图片都无效,将忽略图片参数`);
images = undefined;
} else {
logger(`[视频生成] 有效图片数量: ${images.length}`);
}
}
let aspectRatioParam: string;
switch (videoConfig.aspectRatio) {
case "16:9": aspectRatioParam = "3:2"; break;
case "9:16": aspectRatioParam = "2:3"; break;
default: aspectRatioParam = "1:1";
}
let sizeParam: string = "720P";
if (videoConfig.resolution && videoConfig.resolution.includes("1080")) sizeParam = "1080P";
const createBody: any = {
model: videoModel.modelName,
prompt: videoConfig.prompt,
aspect_ratio: aspectRatioParam,
size: sizeParam,
};
if (images && images.length > 0) createBody.images = images;
try {
logger(`[视频生成] 创建请求体: ${JSON.stringify({ ...createBody, images: images ? `${images.length}张图片` : undefined })}`);
const createResp = await axios.post(createEndpoint, createBody, {
headers: {
"Authorization": `Bearer ${apiKey.replace(/^Bearer\s+/i, "")}`,
"Content-Type": "application/json",
},
timeout: 30000,
});
if (createResp.status !== 200 || !createResp.data?.id) {
throw new Error(`创建任务失败: ${JSON.stringify(createResp.data)}`);
}
const taskId = createResp.data.id;
logger(`[视频生成] 任务已创建ID: ${taskId}`);
const pollResult = await pollTask(
async () => {
try {
const queryResp = await axios.get(queryEndpoint, {
params: { id: taskId },
headers: {
"Authorization": `Bearer ${apiKey.replace(/^Bearer\s+/i, "")}`,
"Content-Type": "application/json",
},
timeout: 15000,
});
if (queryResp.status !== 200) {
return { completed: false, error: `查询失败: HTTP ${queryResp.status}` };
}
const data = queryResp.data;
const status = data.status;
logger(`[视频生成] 任务状态: ${status}`);
if (status === "succeeded" || status === "completed" || status === "success") {
if (data.video_url) {
return { completed: true, data: data.video_url };
} else {
return { completed: false, error: "任务成功但未返回视频URL" };
}
} else if (status === "failed" || status === "error") {
return { completed: false, error: `视频生成失败: ${data.error || "未知错误"}` };
} else {
return { completed: false };
}
} catch (err: any) {
logger(`[视频生成] 轮询出错: ${err.message}`);
return { completed: false, error: err.message };
}
},
3000,
300000
);
if (!pollResult.completed) {
throw new Error(pollResult.error || "视频生成超时或失败");
}
const videoUrl = pollResult.data;
logger(`[视频生成] 成功视频URL: ${videoUrl}`);
return videoUrl;
} catch (error: any) {
logger(`[视频生成] 失败: ${error.message}`);
if (error.response) {
logger(`[视频生成] API 错误详情: ${JSON.stringify(error.response.data)}`);
throw new Error(`视频生成失败: ${error.response.data?.error?.message || error.message}`);
}
throw error;
}
};
exports.videoRequest = videoRequest;
// TTS 请求函数(占位)
interface TTSConfig {
text: string;
voice: string;
speechRate: number;
pitchRate: number;
volume: number;
}
const ttsRequest = async (ttsConfig: TTSConfig, ttsModel: TTSModel) => {
return null;
};
exports.ttsRequest = ttsRequest;