Initial commit: import Operit protocol plugins from xiaoli-cli
This commit is contained in:
commit
c6600fc47d
52 files changed
+31492
No files matched your search
@@ -0,0 +1,668 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "siliconflow_draw",
|
||||
"display_name": {
|
||||
"zh": "硅基流动绘图",
|
||||
"en": "SiliconFlow Draw"
|
||||
},
|
||||
"description": {
|
||||
"zh": "使用 SiliconFlow 官方图像与视频接口生成图片和视频。图片走 /v1/images/generations,视频走 /v1/video/submit + /v1/video/status;生成结果会立即下载到本地,避免官方临时链接过期。",
|
||||
"en": "Generate images and videos with SiliconFlow official image and video APIs. Images use /v1/images/generations; videos use /v1/video/submit + /v1/video/status. Generated assets are downloaded locally immediately before temporary URLs expire."
|
||||
},
|
||||
"category": "Draw",
|
||||
"env": [
|
||||
{
|
||||
"name": "SILICONFLOW_API_KEY",
|
||||
"description": {
|
||||
"zh": "SiliconFlow API Key(必填)",
|
||||
"en": "SiliconFlow API key (required)"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "SILICONFLOW_API_BASE_URL",
|
||||
"description": {
|
||||
"zh": "SiliconFlow API Base URL(可选,不填默认 https://api.siliconflow.cn )",
|
||||
"en": "SiliconFlow API base URL (optional; defaults to https://api.siliconflow.cn)"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "SILICONFLOW_IMAGE_MODEL",
|
||||
"description": {
|
||||
"zh": "默认生图模型(可选;例如 Kwai-Kolors/Kolors、Qwen/Qwen-Image)",
|
||||
"en": "Default image model (optional), e.g. Kwai-Kolors/Kolors or Qwen/Qwen-Image"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "SILICONFLOW_VIDEO_MODEL",
|
||||
"description": {
|
||||
"zh": "默认生视频模型(可选;例如 Wan-AI/Wan2.2-T2V-A14B 或 Wan-AI/Wan2.2-I2V-A14B)",
|
||||
"en": "Default video model (optional), e.g. Wan-AI/Wan2.2-T2V-A14B or Wan-AI/Wan2.2-I2V-A14B"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "draw_image",
|
||||
"description": {
|
||||
"zh": "调用 SiliconFlow 官方图片生成接口生图,下载到本地并返回 Markdown 图片提示。",
|
||||
"en": "Generate images with SiliconFlow official image API, download locally, and return Markdown image hints."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "prompt", "description": { "zh": "生图提示词", "en": "Image prompt" }, "type": "string", "required": true },
|
||||
{ "name": "model", "description": { "zh": "模型名;不传则优先取 SILICONFLOW_IMAGE_MODEL,再取默认值 Kwai-Kolors/Kolors", "en": "Model name; falls back to SILICONFLOW_IMAGE_MODEL, then Kwai-Kolors/Kolors" }, "type": "string", "required": false },
|
||||
{ "name": "image_size", "description": { "zh": "图片尺寸,例如 1024x1024;未传时默认 1024x1024", "en": "Image size, e.g. 1024x1024; defaults to 1024x1024" }, "type": "string", "required": false },
|
||||
{ "name": "negative_prompt", "description": { "zh": "负面提示词(可选)", "en": "Negative prompt (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "batch_size", "description": { "zh": "一次生成多少张图(可选)", "en": "Number of images to generate (optional)" }, "type": "number", "required": false },
|
||||
{ "name": "num_inference_steps", "description": { "zh": "推理步数(可选)", "en": "Inference steps (optional)" }, "type": "number", "required": false },
|
||||
{ "name": "guidance_scale", "description": { "zh": "提示词引导强度(可选)", "en": "Guidance scale (optional)" }, "type": "number", "required": false },
|
||||
{ "name": "seed", "description": { "zh": "随机种子(可选)", "en": "Seed (optional)" }, "type": "number", "required": false },
|
||||
{ "name": "file_name", "description": { "zh": "本地保存文件名(不含扩展名)", "en": "Local output filename without extension" }, "type": "string", "required": false },
|
||||
{ "name": "api_base_url", "description": { "zh": "自定义 API Base URL(可选)", "en": "Custom API base URL (optional)" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "draw_video",
|
||||
"description": {
|
||||
"zh": "调用 SiliconFlow 官方视频生成接口生视频,轮询状态,下载到本地并返回本地视频链接提示。",
|
||||
"en": "Generate videos with SiliconFlow official video API, poll status, download locally, and return local video link hints."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "prompt", "description": { "zh": "生视频提示词", "en": "Video prompt" }, "type": "string", "required": true },
|
||||
{ "name": "model", "description": { "zh": "模型名;不传则优先取 SILICONFLOW_VIDEO_MODEL,若传了 image_url/image_path 默认走 Wan-AI/Wan2.2-I2V-A14B,否则默认 Wan-AI/Wan2.2-T2V-A14B", "en": "Model name; falls back to SILICONFLOW_VIDEO_MODEL. If image_url/image_path is provided, defaults to Wan-AI/Wan2.2-I2V-A14B; otherwise Wan-AI/Wan2.2-T2V-A14B" }, "type": "string", "required": false },
|
||||
{ "name": "image_size", "description": { "zh": "视频尺寸,可选 1280x720、720x1280、960x960;默认 1280x720", "en": "Video size: 1280x720, 720x1280, or 960x960; defaults to 1280x720" }, "type": "string", "required": false },
|
||||
{ "name": "negative_prompt", "description": { "zh": "负面提示词(可选)", "en": "Negative prompt (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "image_url", "description": { "zh": "图生视频输入图 URL(可选)", "en": "Input image URL for image-to-video (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "image_path", "description": { "zh": "图生视频输入图本地路径(可选,会转成 data URL 上传)", "en": "Local input image path for image-to-video (optional; converted to data URL)" }, "type": "string", "required": false },
|
||||
{ "name": "seed", "description": { "zh": "随机种子(可选)", "en": "Seed (optional)" }, "type": "number", "required": false },
|
||||
{ "name": "file_name", "description": { "zh": "本地保存文件名(不含扩展名)", "en": "Local output filename without extension" }, "type": "string", "required": false },
|
||||
{ "name": "api_base_url", "description": { "zh": "自定义 API Base URL(可选)", "en": "Custom API base URL (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "poll_interval_ms", "description": { "zh": "轮询间隔毫秒数,默认 5000", "en": "Polling interval in ms, default 5000" }, "type": "number", "required": false },
|
||||
{ "name": "max_wait_time_ms", "description": { "zh": "最大等待毫秒数,默认 600000", "en": "Maximum wait time in ms, default 600000" }, "type": "number", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
/// <reference path="./types/index.d.ts" />
|
||||
|
||||
const siliconflowDraw = (function () {
|
||||
const HTTP_TIMEOUT_MS = 600000;
|
||||
const client = OkHttp.newBuilder()
|
||||
.connectTimeout(HTTP_TIMEOUT_MS)
|
||||
.readTimeout(HTTP_TIMEOUT_MS)
|
||||
.writeTimeout(HTTP_TIMEOUT_MS)
|
||||
.build();
|
||||
|
||||
const DEFAULT_API_BASE_URL = "https://api.siliconflow.cn";
|
||||
const DEFAULT_IMAGE_MODEL = "Kwai-Kolors/Kolors";
|
||||
const DEFAULT_IMAGE_SIZE = "1024x1024";
|
||||
const DEFAULT_VIDEO_TEXT_MODEL = "Wan-AI/Wan2.2-T2V-A14B";
|
||||
const DEFAULT_VIDEO_IMAGE_MODEL = "Wan-AI/Wan2.2-I2V-A14B";
|
||||
const DEFAULT_VIDEO_SIZE = "1280x720";
|
||||
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
||||
const DEFAULT_MAX_WAIT_TIME_MS = 600000;
|
||||
const VIDEO_SIZE_OPTIONS = ["1280x720", "720x1280", "960x960"];
|
||||
|
||||
const DRAW_ROOT_DIR = getPluginConfigDir("draw");
|
||||
const STORAGE_DIR = `${DRAW_ROOT_DIR}/siliconflow_draw`;
|
||||
const DRAWS_DIR = `${STORAGE_DIR}/draws`;
|
||||
const VIDEOS_DIR = `${STORAGE_DIR}/videos`;
|
||||
|
||||
type JsonMap = Record<string, any>;
|
||||
|
||||
type ImageParams = {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
image_size?: string;
|
||||
negative_prompt?: string;
|
||||
batch_size?: number;
|
||||
num_inference_steps?: number;
|
||||
guidance_scale?: number;
|
||||
seed?: number;
|
||||
file_name?: string;
|
||||
api_base_url?: string;
|
||||
};
|
||||
|
||||
type VideoParams = {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
image_size?: string;
|
||||
negative_prompt?: string;
|
||||
image_url?: string;
|
||||
image_path?: string;
|
||||
seed?: number;
|
||||
file_name?: string;
|
||||
api_base_url?: string;
|
||||
poll_interval_ms?: number;
|
||||
max_wait_time_ms?: number;
|
||||
};
|
||||
|
||||
type ImageApiResult = {
|
||||
effective_model: string;
|
||||
image_urls: string[];
|
||||
seed: any;
|
||||
};
|
||||
|
||||
type VideoTaskResult = {
|
||||
api_base_url: string;
|
||||
effective_model: string;
|
||||
request_id: string;
|
||||
seed: any;
|
||||
};
|
||||
|
||||
type VideoStatusResult = {
|
||||
status: string;
|
||||
reason: string;
|
||||
video_url: string;
|
||||
seed: any;
|
||||
};
|
||||
|
||||
type ImageFileItem = {
|
||||
file_path: string;
|
||||
file_uri: string;
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
function getApiKey() {
|
||||
const apiKey = getEnv("SILICONFLOW_API_KEY");
|
||||
if (!apiKey) {
|
||||
throw new Error("SILICONFLOW_API_KEY 未配置,请先在环境变量中设置硅基流动 API Key。");
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function getApiBaseUrl(customBaseUrl) {
|
||||
const fromParam = String(customBaseUrl || "").trim();
|
||||
if (fromParam) return fromParam;
|
||||
|
||||
const fromEnv = String(getEnv("SILICONFLOW_API_BASE_URL") || "").trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
return DEFAULT_API_BASE_URL;
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl, path) {
|
||||
const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
const normalizedPath = path.startsWith("/") ? path.slice(1) : path;
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function getImageEndpoint(baseUrl) {
|
||||
const trimmed = String(baseUrl || "").trim();
|
||||
if (!trimmed) return joinUrl(DEFAULT_API_BASE_URL, "v1/images/generations");
|
||||
if (trimmed.includes("/v1/images/generations")) return trimmed;
|
||||
if (trimmed.endsWith("/v1")) return joinUrl(trimmed, "images/generations");
|
||||
if (trimmed.endsWith("/v1/")) return joinUrl(trimmed, "images/generations");
|
||||
return joinUrl(trimmed, "v1/images/generations");
|
||||
}
|
||||
|
||||
function getVideoSubmitEndpoint(baseUrl) {
|
||||
const trimmed = String(baseUrl || "").trim();
|
||||
if (!trimmed) return joinUrl(DEFAULT_API_BASE_URL, "v1/video/submit");
|
||||
if (trimmed.includes("/v1/video/submit")) return trimmed;
|
||||
if (trimmed.endsWith("/v1")) return joinUrl(trimmed, "video/submit");
|
||||
if (trimmed.endsWith("/v1/")) return joinUrl(trimmed, "video/submit");
|
||||
return joinUrl(trimmed, "v1/video/submit");
|
||||
}
|
||||
|
||||
function getVideoStatusEndpoint(baseUrl) {
|
||||
const trimmed = String(baseUrl || "").trim();
|
||||
if (!trimmed) return joinUrl(DEFAULT_API_BASE_URL, "v1/video/status");
|
||||
if (trimmed.includes("/v1/video/status")) return trimmed;
|
||||
if (trimmed.endsWith("/v1")) return joinUrl(trimmed, "video/status");
|
||||
if (trimmed.endsWith("/v1/")) return joinUrl(trimmed, "video/status");
|
||||
return joinUrl(trimmed, "v1/video/status");
|
||||
}
|
||||
|
||||
function sanitizeFileName(name, prefix) {
|
||||
const safe = String(name || "").replace(/[\\/:*?"<>|]/g, "_").trim();
|
||||
if (!safe) return `${prefix}_${Date.now()}`;
|
||||
return safe.substring(0, 80);
|
||||
}
|
||||
|
||||
function buildFileBaseName(prompt, customName, prefix) {
|
||||
if (customName && String(customName).trim().length > 0) {
|
||||
return sanitizeFileName(customName, prefix);
|
||||
}
|
||||
const rawPrompt = String(prompt || "").trim();
|
||||
const shortPrompt = rawPrompt.length > 40 ? `${rawPrompt.substring(0, 40)}...` : rawPrompt;
|
||||
return `${sanitizeFileName(shortPrompt || prefix, prefix)}_${Date.now()}`;
|
||||
}
|
||||
|
||||
function getErrorMessage(error) {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function guessMimeTypeFromPath(filePath) {
|
||||
const lower = String(filePath || "").toLowerCase();
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
function guessExtensionFromUrl(url, fallbackExtension) {
|
||||
const match = String(url || "").match(/\.([a-z0-9]{2,5})(?:[?#].*)?$/i);
|
||||
if (match && match[1]) return match[1].toLowerCase();
|
||||
return fallbackExtension;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, fieldName) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isFinite(numberValue) || numberValue <= 0) {
|
||||
throw new Error(`${fieldName} 必须是正整数。`);
|
||||
}
|
||||
return Math.floor(numberValue);
|
||||
}
|
||||
|
||||
function parseFiniteNumber(value, fieldName) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
throw new Error(`${fieldName} 必须是有效数字。`);
|
||||
}
|
||||
return numberValue;
|
||||
}
|
||||
|
||||
function parseSeed(value) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
throw new Error("seed 必须是有效数字。");
|
||||
}
|
||||
return Math.floor(numberValue);
|
||||
}
|
||||
|
||||
function normalizeImageSize(imageSize, model) {
|
||||
const trimmedModel = String(model || "").trim().toLowerCase();
|
||||
const rawSize = String(imageSize || "").trim();
|
||||
const isQwenEditModel = trimmedModel.startsWith("qwen/qwen-image-edit");
|
||||
if (isQwenEditModel) {
|
||||
if (rawSize) {
|
||||
throw new Error("当前模型为 Qwen 图像编辑模型,官方文档标注不支持 image_size 参数,请去掉 image_size。");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!rawSize) return DEFAULT_IMAGE_SIZE;
|
||||
if (!/^\d+x\d+$/i.test(rawSize)) {
|
||||
throw new Error("image_size 格式必须是 widthxheight,例如 1024x1024。");
|
||||
}
|
||||
return rawSize.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeVideoSize(imageSize) {
|
||||
const rawSize = String(imageSize || "").trim();
|
||||
if (!rawSize) return DEFAULT_VIDEO_SIZE;
|
||||
if (!VIDEO_SIZE_OPTIONS.includes(rawSize)) {
|
||||
throw new Error(`image_size 仅支持 ${VIDEO_SIZE_OPTIONS.join("、")}。`);
|
||||
}
|
||||
return rawSize;
|
||||
}
|
||||
|
||||
function isProbablyUrl(value) {
|
||||
return /^https?:\/\//i.test(String(value || "").trim());
|
||||
}
|
||||
|
||||
async function ensureDirectories() {
|
||||
const dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR, VIDEOS_DIR];
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
const result = await Tools.Files.mkdir(dir);
|
||||
if (!result.successful) {
|
||||
console.warn(`创建目录失败(可能已存在): ${dir} -> ${result.details}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`创建目录异常: ${dir} -> ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readLocalImageAsDataUrl(filePath) {
|
||||
const trimmedPath = String(filePath || "").trim();
|
||||
if (!trimmedPath) {
|
||||
throw new Error("image_path 不能为空。");
|
||||
}
|
||||
const existsResult = await Tools.Files.exists(trimmedPath);
|
||||
if (!existsResult.exists) {
|
||||
throw new Error(`本地图片不存在: ${trimmedPath}`);
|
||||
}
|
||||
const binaryResult = await Tools.Files.readBinary(trimmedPath);
|
||||
const base64Content = binaryResult && binaryResult.contentBase64 ? String(binaryResult.contentBase64).trim() : "";
|
||||
if (!base64Content) {
|
||||
throw new Error(`读取本地图片失败: ${trimmedPath}`);
|
||||
}
|
||||
return `data:${guessMimeTypeFromPath(trimmedPath)};base64,${base64Content}`;
|
||||
}
|
||||
|
||||
async function resolveVideoImageInput(imageUrl, imagePath) {
|
||||
const trimmedUrl = String(imageUrl || "").trim();
|
||||
const trimmedPath = String(imagePath || "").trim();
|
||||
if (trimmedUrl && trimmedPath) {
|
||||
throw new Error("image_url 和 image_path 只能二选一,请不要同时传。");
|
||||
}
|
||||
if (trimmedUrl) {
|
||||
if (!isProbablyUrl(trimmedUrl)) {
|
||||
throw new Error("image_url 必须是 http 或 https 链接。");
|
||||
}
|
||||
return trimmedUrl;
|
||||
}
|
||||
if (trimmedPath) {
|
||||
return await readLocalImageAsDataUrl(trimmedPath);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function executeJsonRequest(url: string, body: JsonMap): Promise<any> {
|
||||
const apiKey = getApiKey();
|
||||
const request = client
|
||||
.newRequest()
|
||||
.url(url)
|
||||
.method("POST")
|
||||
.headers({
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`
|
||||
})
|
||||
.body(JSON.stringify(body), "json");
|
||||
|
||||
const response = await request.build().execute();
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`HTTP ${response.statusCode}: ${response.content}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(response.content);
|
||||
} catch (error) {
|
||||
throw new Error(`解析响应 JSON 失败: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function callImageApi(params: ImageParams): Promise<ImageApiResult> {
|
||||
const apiBaseUrl = getApiBaseUrl(params.api_base_url);
|
||||
const endpoint = getImageEndpoint(apiBaseUrl);
|
||||
|
||||
const modelFromParam = String(params.model || "").trim();
|
||||
const modelFromEnv = String(getEnv("SILICONFLOW_IMAGE_MODEL") || "").trim();
|
||||
const effectiveModel = modelFromParam || modelFromEnv || DEFAULT_IMAGE_MODEL;
|
||||
|
||||
const body: JsonMap = {
|
||||
model: effectiveModel,
|
||||
prompt: String(params.prompt || "").trim()
|
||||
};
|
||||
|
||||
const imageSize = normalizeImageSize(params.image_size, effectiveModel);
|
||||
if (imageSize) body.image_size = imageSize;
|
||||
|
||||
const negativePrompt = String(params.negative_prompt || "").trim();
|
||||
if (negativePrompt) body.negative_prompt = negativePrompt;
|
||||
|
||||
const batchSize = parsePositiveInteger(params.batch_size, "batch_size");
|
||||
if (batchSize !== undefined) body.batch_size = batchSize;
|
||||
|
||||
const numInferenceSteps = parsePositiveInteger(params.num_inference_steps, "num_inference_steps");
|
||||
if (numInferenceSteps !== undefined) body.num_inference_steps = numInferenceSteps;
|
||||
|
||||
const guidanceScale = parseFiniteNumber(params.guidance_scale, "guidance_scale");
|
||||
if (guidanceScale !== undefined) body.guidance_scale = guidanceScale;
|
||||
|
||||
const seed = parseSeed(params.seed);
|
||||
if (seed !== undefined) body.seed = seed;
|
||||
|
||||
const parsed: any = await executeJsonRequest(endpoint, body);
|
||||
const images = Array.isArray(parsed && parsed.images) ? parsed.images : [];
|
||||
const imageUrls = images
|
||||
.map(item => item && item.url ? String(item.url).trim() : "")
|
||||
.filter(item => item.length > 0);
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
throw new Error("图片接口返回中未找到 images[].url,请检查模型与参数是否匹配。");
|
||||
}
|
||||
|
||||
return {
|
||||
effective_model: effectiveModel,
|
||||
image_urls: imageUrls,
|
||||
seed: parsed && parsed.seed !== undefined ? parsed.seed : (seed !== undefined ? seed : null)
|
||||
};
|
||||
}
|
||||
|
||||
async function createVideoTask(params: VideoParams, imageInput?: string): Promise<VideoTaskResult> {
|
||||
const apiBaseUrl = getApiBaseUrl(params.api_base_url);
|
||||
const endpoint = getVideoSubmitEndpoint(apiBaseUrl);
|
||||
|
||||
const modelFromParam = String(params.model || "").trim();
|
||||
const modelFromEnv = String(getEnv("SILICONFLOW_VIDEO_MODEL") || "").trim();
|
||||
const defaultVideoModel = imageInput ? DEFAULT_VIDEO_IMAGE_MODEL : DEFAULT_VIDEO_TEXT_MODEL;
|
||||
const effectiveModel = modelFromParam || modelFromEnv || defaultVideoModel;
|
||||
|
||||
if (effectiveModel === DEFAULT_VIDEO_IMAGE_MODEL && !imageInput) {
|
||||
throw new Error(`当前模型 ${DEFAULT_VIDEO_IMAGE_MODEL} 为图生视频模型,必须传 image_url 或 image_path。`);
|
||||
}
|
||||
|
||||
const body: JsonMap = {
|
||||
model: effectiveModel,
|
||||
prompt: String(params.prompt || "").trim(),
|
||||
image_size: normalizeVideoSize(params.image_size)
|
||||
};
|
||||
|
||||
const negativePrompt = String(params.negative_prompt || "").trim();
|
||||
if (negativePrompt) body.negative_prompt = negativePrompt;
|
||||
|
||||
if (imageInput) body.image = imageInput;
|
||||
|
||||
const seed = parseSeed(params.seed);
|
||||
if (seed !== undefined) body.seed = seed;
|
||||
|
||||
const parsed: any = await executeJsonRequest(endpoint, body);
|
||||
const requestId = parsed && parsed.requestId ? String(parsed.requestId).trim() : "";
|
||||
if (!requestId) {
|
||||
throw new Error("视频接口返回中未找到 requestId。");
|
||||
}
|
||||
|
||||
return {
|
||||
api_base_url: apiBaseUrl,
|
||||
effective_model: effectiveModel,
|
||||
request_id: requestId,
|
||||
seed: seed !== undefined ? seed : null
|
||||
};
|
||||
}
|
||||
|
||||
async function queryVideoStatus(apiBaseUrl: string, requestId: string): Promise<VideoStatusResult> {
|
||||
const endpoint = getVideoStatusEndpoint(apiBaseUrl);
|
||||
const parsed: any = await executeJsonRequest(endpoint, { requestId });
|
||||
const status = parsed && parsed.status ? String(parsed.status).trim() : "";
|
||||
const reason = parsed && parsed.reason ? String(parsed.reason).trim() : "";
|
||||
const videos: any[] = parsed && parsed.results && Array.isArray(parsed.results.videos) ? parsed.results.videos : [];
|
||||
const videoUrl = videos.length > 0 && videos[0] && videos[0].url ? String(videos[0].url).trim() : "";
|
||||
const seed = parsed && parsed.results && parsed.results.seed !== undefined ? parsed.results.seed : null;
|
||||
return {
|
||||
status,
|
||||
reason,
|
||||
video_url: videoUrl,
|
||||
seed
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVideoStatus(status) {
|
||||
return String(status || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
async function draw_image(params: ImageParams) {
|
||||
const prompt = String(params && params.prompt ? params.prompt : "").trim();
|
||||
if (!prompt) {
|
||||
throw new Error("prompt 不能为空。");
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
|
||||
const apiResult = await callImageApi(params);
|
||||
const baseName = buildFileBaseName(prompt, params.file_name, "siliconflow_image");
|
||||
const files: ImageFileItem[] = [];
|
||||
|
||||
for (let index = 0; index < apiResult.image_urls.length; index += 1) {
|
||||
const imageUrl = apiResult.image_urls[index];
|
||||
const extension = guessExtensionFromUrl(imageUrl, "png");
|
||||
const suffix = apiResult.image_urls.length > 1 ? `_${index + 1}` : "";
|
||||
const filePath = `${DRAWS_DIR}/${baseName}${suffix}.${extension}`;
|
||||
const downloadResult = await Tools.Files.download(imageUrl, filePath);
|
||||
if (!downloadResult.successful) {
|
||||
throw new Error(`下载图片失败: ${downloadResult.details}`);
|
||||
}
|
||||
const fileUri = `file://${filePath}`;
|
||||
files.push({
|
||||
file_path: filePath,
|
||||
file_uri: fileUri,
|
||||
markdown: ``
|
||||
});
|
||||
}
|
||||
|
||||
const hintLines: string[] = [];
|
||||
hintLines.push(`图片已生成并下载到 ${DRAWS_DIR}。`);
|
||||
hintLines.push(`共生成 ${files.length} 张。`);
|
||||
hintLines.push("");
|
||||
hintLines.push("后续回答如果需要展示图片,请直接输出下面这些 Markdown:");
|
||||
hintLines.push("");
|
||||
for (const fileItem of files) {
|
||||
hintLines.push(fileItem.markdown);
|
||||
}
|
||||
|
||||
return {
|
||||
prompt,
|
||||
model: apiResult.effective_model,
|
||||
seed: apiResult.seed,
|
||||
file_path: files[0].file_path,
|
||||
file_uri: files[0].file_uri,
|
||||
markdown: files[0].markdown,
|
||||
files,
|
||||
hint: hintLines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
async function draw_video(params: VideoParams) {
|
||||
const prompt = String(params && params.prompt ? params.prompt : "").trim();
|
||||
if (!prompt) {
|
||||
throw new Error("prompt 不能为空。");
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
|
||||
const imageInput = await resolveVideoImageInput(params.image_url, params.image_path);
|
||||
const createdTask = await createVideoTask(params, imageInput);
|
||||
const pollIntervalMs = parsePositiveInteger(params.poll_interval_ms, "poll_interval_ms") || DEFAULT_POLL_INTERVAL_MS;
|
||||
const maxWaitTimeMs = parsePositiveInteger(params.max_wait_time_ms, "max_wait_time_ms") || DEFAULT_MAX_WAIT_TIME_MS;
|
||||
const deadline = Date.now() + maxWaitTimeMs;
|
||||
|
||||
let latestStatus = "";
|
||||
let latestReason = "";
|
||||
let remoteVideoUrl = "";
|
||||
let resultSeed = createdTask.seed;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
const statusResult = await queryVideoStatus(createdTask.api_base_url, createdTask.request_id);
|
||||
latestStatus = statusResult.status;
|
||||
latestReason = statusResult.reason;
|
||||
resultSeed = statusResult.seed !== null && statusResult.seed !== undefined ? statusResult.seed : resultSeed;
|
||||
|
||||
const normalizedStatus = normalizeVideoStatus(statusResult.status);
|
||||
if (normalizedStatus === "succeed") {
|
||||
remoteVideoUrl = statusResult.video_url;
|
||||
if (!remoteVideoUrl) {
|
||||
throw new Error("视频任务已成功,但返回中未找到 results.videos[0].url。");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (normalizedStatus === "failed") {
|
||||
throw new Error(`视频生成失败: ${latestReason || "接口未返回失败原因"}`);
|
||||
}
|
||||
|
||||
await Tools.System.sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
if (!remoteVideoUrl) {
|
||||
throw new Error(`视频生成超时,最后状态为 ${latestStatus || "未知"}${latestReason ? `,原因:${latestReason}` : ""}`);
|
||||
}
|
||||
|
||||
const baseName = buildFileBaseName(prompt, params.file_name, "siliconflow_video");
|
||||
const extension = guessExtensionFromUrl(remoteVideoUrl, "mp4");
|
||||
const filePath = `${VIDEOS_DIR}/${baseName}.${extension}`;
|
||||
const downloadResult = await Tools.Files.download(remoteVideoUrl, filePath);
|
||||
if (!downloadResult.successful) {
|
||||
throw new Error(`下载视频失败: ${downloadResult.details}`);
|
||||
}
|
||||
|
||||
const fileUri = `file://${filePath}`;
|
||||
const markdownLink = `[点击查看生成的视频](${fileUri})`;
|
||||
const htmlVideo = `<video controls src="${fileUri}"></video>`;
|
||||
const hintLines: string[] = [];
|
||||
hintLines.push(`视频已生成并下载到 ${VIDEOS_DIR}。`);
|
||||
hintLines.push(`本地路径: ${filePath}`);
|
||||
hintLines.push("");
|
||||
hintLines.push("后续回答如果要给出视频,请优先返回这个本地链接:");
|
||||
hintLines.push(markdownLink);
|
||||
hintLines.push("");
|
||||
hintLines.push("如果当前渲染环境支持 HTML 视频标签,也可以使用:");
|
||||
hintLines.push(htmlVideo);
|
||||
|
||||
return {
|
||||
prompt,
|
||||
model: createdTask.effective_model,
|
||||
request_id: createdTask.request_id,
|
||||
seed: resultSeed,
|
||||
file_path: filePath,
|
||||
file_uri: fileUri,
|
||||
remote_video_url: remoteVideoUrl,
|
||||
markdown_link: markdownLink,
|
||||
html_video: htmlVideo,
|
||||
hint: hintLines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
async function draw_image_wrapper(params: ImageParams) {
|
||||
try {
|
||||
const result = await draw_image(params || {});
|
||||
complete({
|
||||
success: true,
|
||||
message: "SiliconFlow 图片生成成功,已下载到本地。",
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("draw_image 执行失败:", error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `SiliconFlow 图片生成失败: ${getErrorMessage(error)}`,
|
||||
error_stack: error && error.stack ? error.stack : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function draw_video_wrapper(params: VideoParams) {
|
||||
try {
|
||||
const result = await draw_video(params || {});
|
||||
complete({
|
||||
success: true,
|
||||
message: "SiliconFlow 视频生成成功,已下载到本地。",
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("draw_video 执行失败:", error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `SiliconFlow 视频生成失败: ${getErrorMessage(error)}`,
|
||||
error_stack: error && error.stack ? error.stack : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
draw_image: draw_image_wrapper,
|
||||
draw_video: draw_video_wrapper
|
||||
};
|
||||
})();
|
||||
|
||||
exports.draw_image = siliconflowDraw.draw_image;
|
||||
exports.draw_video = siliconflowDraw.draw_video;
|
||||
Reference in new issue
Block a user