Files
xiaoli-cli-operit-plugins/scripts/nanobanana_draw.ts
T

745 lines
31 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.
/* METADATA
{
"name": "nanobanana_draw",
"display_name": {
"zh": "Nanobanana 绘图",
"en": "Nanobanana Draw"
},
"description": {
"zh": "使用 Nano Banana API (基于Grsai的api服务/https://grsai.com/) 根据提示词画图,支持文生图和图生图(可传入参考图片URL或本地图片路径;本地图片会先上传到图床以获得公网URL),将图片保存到本地 /sdcard/Download/Operit/plugins/draw/nanobanana_draw/draws/ 目录,并返回 Markdown 图片提示。",
"en": "Generate images using the Nano Banana API (via Grsai service / https://grsai.com/). Supports text-to-image and image-to-image (you can provide reference image URLs or local image paths; local images will be uploaded first to get public URLs). Saves images to /sdcard/Download/Operit/plugins/draw/nanobanana_draw/draws/ and returns a Markdown image reference."
},
"env": [
"NANOBANANA_API_KEY",
"NANOBANANA_API_BASE_URL",
"BEEIMG_API_KEY"
],
"category": "Draw",
"tools": [
{
"name": "draw_image",
"description": {
"zh": "根据提示词调用 Nano Banana API 生成图片(支持文生图和图生图),保存到本地并返回 Markdown 图片提示。",
"en": "Generate an image via the Nano Banana API using a prompt (supports text-to-image and image-to-image), save locally, and return a Markdown image reference."
},
"parameters": [
{ "name": "prompt", "description": { "zh": "绘图提示词(英文或中文皆可)", "en": "Image prompt (Chinese or English)" }, "type": "string", "required": true },
{ "name": "model", "description": { "zh": "Nano Banana 模型名称(优先级最高)。可填 nano-banana-pro(约1800积分)或 nano-banana(约400积分)", "en": "Nano Banana model name (highest priority). e.g. nano-banana-pro (~1800 credits) or nano-banana (~400 credits)." }, "type": "string", "required": false },
{ "name": "model_variant", "description": { "zh": "模型档位(二选一,可选):pro(约1800积分)或 nano(约400积分)。未传时默认 pro", "en": "Model tier (optional): pro (~1800 credits) or nano (~400 credits). Defaults to pro." }, "type": "string", "required": false },
{ "name": "aspect_ratio", "description": { "zh": "输出图像比例,如 '1:1', '16:9', 'auto' 等,可选", "en": "Output aspect ratio, e.g. '1:1', '16:9', 'auto' (optional)" }, "type": "string", "required": false },
{ "name": "image_size", "description": { "zh": "输出图像大小,仅 nano-banana-pro 支持,如 '1K', '2K', '4K',可选", "en": "Output image size (only supported by nano-banana-pro), e.g. '1K', '2K', '4K' (optional)" }, "type": "string", "required": false },
{ "name": "image_urls", "description": { "zh": "参考图URL数组(图生图),支持格式:字符串数组['https://...'] 或 JSON字符串'[\"https://...\"]' 或逗号分隔'url1,url2',可选", "en": "Reference image URL list for img2img. Accepts: string array ['https://...'], or JSON string '[\"https://...\"]', or comma-separated 'url1,url2' (optional)." }, "type": "array", "required": false },
{ "name": "image_paths", "description": { "zh": "参考图本地路径数组(图生图,会先上传图床再进行生成),支持格式:字符串数组['/sdcard/...'] 或 JSON字符串 或 逗号分隔,可选", "en": "Reference local image path list for img2img (will be uploaded first). Accepts: string array ['/sdcard/...'], or JSON string, or comma-separated list (optional)." }, "type": "array", "required": false },
{ "name": "file_name", "description": { "zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)" }, "type": "string", "required": false },
{ "name": "poll_interval_ms", "description": { "zh": "轮询间隔(毫秒),默认 5000", "en": "Polling interval (milliseconds), default 5000" }, "type": "number", "required": false },
{ "name": "max_wait_time_ms", "description": { "zh": "最长等待时间(毫秒)。默认 10 分钟", "en": "Max wait time (milliseconds). Default 10 minutes." }, "type": "number", "required": false }
]
}
]
}*/
const nanobananaDraw = (function () {
const HTTP_TIMEOUT_MS = 600000;
const client = OkHttp.newBuilder()
.connectTimeout(HTTP_TIMEOUT_MS)
.readTimeout(HTTP_TIMEOUT_MS)
.writeTimeout(HTTP_TIMEOUT_MS)
.build();
const BEEIMG_UPLOAD_ENDPOINT = "https://beeimg.com/api/upload/file/json/";
// API配置
const DEFAULT_API_BASE_URL = "https://grsai.dakka.com.cn";
const DRAW_API_PATH = "v1/draw/nano-banana";
const RESULT_API_PATH = "v1/draw/result";
const MODEL_PRO = "nano-banana-pro";
const MODEL_NANO = "nano-banana";
const DEFAULT_MODEL = MODEL_PRO;
// Android 实际路径为 /sdcard/Download,对应系统中文名"下载"
const DRAW_ROOT_DIR = getPluginConfigDir("draw");
const STORAGE_DIR = `${DRAW_ROOT_DIR}/nanobanana_draw`;
const DRAWS_DIR = `${STORAGE_DIR}/draws`;
// 轮询配置
const POLL_INTERVAL = 5000; // 每5秒查询一次
const MAX_WAIT_TIME = 600000; // 最多等待10分钟
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
function getErrorStack(error: unknown): string | undefined {
if (error instanceof Error) return error.stack;
return undefined;
}
function resolveModel(model?: string, modelVariant?: string): string {
if (model && model.trim().length > 0) {
return model.trim();
}
if (!modelVariant || modelVariant.trim().length === 0) {
return DEFAULT_MODEL;
}
const normalizedVariant = modelVariant.trim().toLowerCase();
if (normalizedVariant === "pro") {
return MODEL_PRO;
}
if (normalizedVariant === "nano") {
return MODEL_NANO;
}
throw new Error("参数 model_variant 仅支持 'pro' 或 'nano'。");
}
function normalizePositiveInt(value: unknown, fallback: number): number {
if (value === undefined || value === null) {
return fallback;
}
const n = typeof value === "number" ? value : parseInt(String(value), 10);
if (!Number.isFinite(n) || n <= 0) {
return fallback;
}
return Math.floor(n);
}
function getApiKey(): string {
const apiKey = getEnv("NANOBANANA_API_KEY");
if (!apiKey) {
throw new Error("NANOBANANA_API_KEY 未配置,请在环境变量中设置 Nano Banana 的 API Key。");
}
return apiKey;
}
function getBeeimgApiKey(): string {
return getEnv("BEEIMG_API_KEY") || "";
}
function joinUrl(baseUrl: string, path: string): string {
const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
const normalizedPath = path.startsWith("/") ? path.slice(1) : path;
return `${normalizedBase}${normalizedPath}`;
}
function getApiBaseUrl(): string {
const fromEnv = (getEnv("NANOBANANA_API_BASE_URL") || "").trim();
if (fromEnv) return fromEnv;
return DEFAULT_API_BASE_URL;
}
function getDrawEndpoint(baseUrl: string): string {
const trimmed = baseUrl.trim();
if (!trimmed) return joinUrl(DEFAULT_API_BASE_URL, DRAW_API_PATH);
if (trimmed.includes("/v1/draw/nano-banana")) return trimmed;
if (trimmed.endsWith("/v1")) return joinUrl(trimmed, "draw/nano-banana");
if (trimmed.endsWith("/v1/")) return joinUrl(trimmed, "draw/nano-banana");
return joinUrl(trimmed, DRAW_API_PATH);
}
function getResultEndpoint(baseUrl: string): string {
const trimmed = baseUrl.trim();
if (!trimmed) return joinUrl(DEFAULT_API_BASE_URL, RESULT_API_PATH);
if (trimmed.includes("/v1/draw/result")) return trimmed;
if (trimmed.includes("/v1/draw/nano-banana")) {
return trimmed.replace("/v1/draw/nano-banana", "/v1/draw/result");
}
if (trimmed.endsWith("/v1")) return joinUrl(trimmed, "draw/result");
if (trimmed.endsWith("/v1/")) return joinUrl(trimmed, "draw/result");
return joinUrl(trimmed, RESULT_API_PATH);
}
function guessMimeTypeFromPath(filePath: string): string {
const lower = 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 "application/octet-stream";
}
function safeJsonParseLoose(text: string): unknown {
const trimmed = (text || "").trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as unknown;
} catch (e) {
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start !== -1 && end !== -1 && end > start) {
return JSON.parse(trimmed.substring(start, end + 1)) as unknown;
}
throw e;
}
}
function isApiSuccessResponse(parsed: Record<string, unknown>): boolean {
const code = parsed["code"];
return code === undefined || code === null || code === 0 || code === "0";
}
function extractTaskPayload(parsed: unknown): Record<string, unknown> | null {
if (!isRecord(parsed)) {
return null;
}
if (isRecord(parsed["data"])) {
return parsed["data"] as Record<string, unknown>;
}
return parsed;
}
function extractMessage(parsed: Record<string, unknown>, fallback: string): string {
const payload = extractTaskPayload(parsed);
const candidates: unknown[] = [
parsed["msg"],
parsed["message"],
payload ? payload["failure_reason"] : undefined,
payload ? payload["error"] : undefined,
payload ? payload["msg"] : undefined,
payload ? payload["message"] : undefined
];
for (const candidate of candidates) {
if ((typeof candidate === "string" || typeof candidate === "number") && String(candidate).trim().length > 0) {
return String(candidate).trim();
}
}
return fallback;
}
function normalizeStatus(status: unknown): string {
return typeof status === "string" ? status.trim().toLowerCase() : "";
}
function normalizeProgress(progress: unknown): number {
const normalized = typeof progress === "number" ? progress : Number(String(progress));
return Number.isFinite(normalized) ? normalized : 0;
}
function isSuccessStatus(status: string): boolean {
return status === "succeeded" || status === "success" || status === "completed" || status === "done";
}
function isFailureStatus(status: string): boolean {
return status === "failed" || status === "error" || status === "canceled" || status === "cancelled";
}
function extractImageUrlFromPayload(payload: Record<string, unknown>): string {
const directCandidates: unknown[] = [
payload["url"],
payload["image_url"],
payload["imageUrl"],
payload["file_url"],
payload["fileUrl"],
payload["result_url"]
];
for (const candidate of directCandidates) {
if ((typeof candidate === "string" || typeof candidate === "number") && String(candidate).trim().length > 0) {
return String(candidate).trim();
}
}
const results = payload["results"];
if (!Array.isArray(results) || results.length === 0) {
return "";
}
const first = results[0];
if (isRecord(first)) {
const nestedCandidates: unknown[] = [
first["url"],
first["image_url"],
first["imageUrl"],
first["file_url"],
first["fileUrl"],
first["result_url"]
];
for (const candidate of nestedCandidates) {
if ((typeof candidate === "string" || typeof candidate === "number") && String(candidate).trim().length > 0) {
return String(candidate).trim();
}
}
} else if ((typeof first === "string" || typeof first === "number") && String(first).trim().length > 0) {
return String(first).trim();
}
return "";
}
async function uploadImageToBeeimg(filePath: string): Promise<string> {
const exists = await Tools.Files.exists(filePath);
if (!exists.exists) {
throw new Error(`参考图文件不存在: ${filePath}`);
}
const apiKey = getBeeimgApiKey();
if (!apiKey) {
throw new Error("使用 image_paths 需要配置 BEEIMG_API_KEY(用于把本地图片上传到图床以获得公网URL)。");
}
const resp = await Tools.Net.uploadFile({
url: BEEIMG_UPLOAD_ENDPOINT,
method: "POST",
form_data: {
apikey: apiKey
},
files: [
{
field_name: "file",
file_path: filePath,
content_type: guessMimeTypeFromPath(filePath)
}
]
});
if (resp.statusCode < 200 || resp.statusCode >= 300) {
throw new Error(`BeeIMG 上传失败: HTTP ${resp.statusCode} - ${resp.content}`);
}
let parsed: unknown;
try {
parsed = safeJsonParseLoose(resp.content);
} catch (e: unknown) {
throw new Error(`BeeIMG 上传响应解析失败: ${getErrorMessage(e)}`);
}
const files = isRecord(parsed) && isRecord(parsed["files"]) ? (parsed["files"] as Record<string, unknown>) : null;
const ok = !!files && (files["status"] === "Success" || files["code"] === "200" || files["code"] === 200);
const url = files ? files["url"] : undefined;
if (!ok || (typeof url !== "string" && typeof url !== "number") || String(url).trim().length === 0) {
throw new Error(`BeeIMG 上传失败: ${resp.content}`);
}
return String(url);
}
function sanitizeFileName(name: string): string {
const safe = name.replace(/[\\/:*?"<>|]/g, "_").trim();
if (!safe) {
return `nano_draw_${Date.now()}`;
}
return safe.substring(0, 80);
}
function buildFileName(prompt: string, customName?: string | null): string {
if (customName && customName.trim().length > 0) {
return sanitizeFileName(customName);
}
const shortPrompt = prompt.length > 40 ? `${prompt.substring(0, 40)}...` : prompt;
const base = sanitizeFileName(shortPrompt || "image");
const timestamp = Date.now();
return `${base}_${timestamp}`;
}
async function ensureDirectories(): Promise<void> {
const dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR];
for (const dir of dirs) {
try {
const result = await Tools.Files.mkdir(dir);
if (!result.successful) {
console.warn(`创建目录失败(可能已存在): ${dir} -> ${result.details}`);
}
} catch (e: unknown) {
console.warn(`创建目录异常: ${dir} -> ${getErrorMessage(e)}`);
}
}
}
async function callNanobananaApi(params: {
prompt: string;
model: string;
aspect_ratio?: string;
image_size?: string;
image_urls?: string[];
poll_interval_ms?: number;
max_wait_time_ms?: number;
}): Promise<string> {
const apiKey = getApiKey();
const endpoint = getDrawEndpoint(getApiBaseUrl());
// 构建请求体 - 使用异步模式(webHook: "-1")
const body: Record<string, unknown> = {
model: params.model,
prompt: params.prompt,
webHook: "-1", // 关键:立即返回任务ID
shutProgress: false
};
// 添加可选参数
if (params.aspect_ratio && params.aspect_ratio.trim().length > 0) {
body.aspectRatio = params.aspect_ratio.trim();
}
if (params.image_size && params.image_size.trim().length > 0) {
body.imageSize = params.image_size.trim();
}
// 图生图:添加参考图URL数组
// 支持格式:['https://example.com/1.jpg', 'https://example.com/2.jpg']
// 或 JSON字符串:"[\"https://...\"]"
// 或 逗号分隔:"url1,url2"
if (params.image_urls && Array.isArray(params.image_urls) && params.image_urls.length > 0) {
body.urls = params.image_urls.filter(url => url && url.trim().length > 0);
}
const headers = {
"accept": "application/json",
"content-type": "application/json",
"Authorization": `Bearer ${apiKey}`
};
const request = client
.newRequest()
.url(endpoint)
.method("POST")
.headers(headers)
.body(JSON.stringify(body), "json");
console.log("步骤1/2: 提交绘图任务...");
const response = await request.build().execute();
if (!response.isSuccessful()) {
throw new Error(`Nano Banana API 调用失败: ${response.statusCode} - ${response.content}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(response.content) as unknown;
} catch (e: unknown) {
throw new Error(`解析 Nano Banana 响应失败: ${getErrorMessage(e)}`);
}
if (!isRecord(parsed)) {
throw new Error("API响应不是合法对象,请检查参数是否正确。响应: " + response.content);
}
if (!isApiSuccessResponse(parsed)) {
throw new Error(`Nano Banana API 返回错误: ${extractMessage(parsed, response.content)}`);
}
const payload = extractTaskPayload(parsed);
if (!payload || typeof payload["id"] !== "string") {
throw new Error("API响应中未找到任务ID,请检查参数是否正确。响应: " + JSON.stringify(parsed));
}
const taskId: string = payload["id"] as string;
console.log(`任务提交成功! ID: ${taskId}`);
const pollIntervalMs = params.poll_interval_ms ?? POLL_INTERVAL;
const maxWaitTimeMs = params.max_wait_time_ms ?? MAX_WAIT_TIME;
console.log(`步骤2/2: 等待任务完成(轮询中,每${pollIntervalMs / 1000}秒查询一次,最长等待${Math.ceil(maxWaitTimeMs / 60000)}分钟)...`);
return taskId;
}
async function pollForResult(taskId: string, options: { poll_interval_ms?: number; max_wait_time_ms?: number } = {}): Promise<string> {
const apiKey = getApiKey();
const endpoint = getResultEndpoint(getApiBaseUrl());
const pollIntervalMs = normalizePositiveInt(options.poll_interval_ms, POLL_INTERVAL);
const maxWaitTimeMs = normalizePositiveInt(options.max_wait_time_ms, MAX_WAIT_TIME);
const startTime = Date.now();
let attempt = 0;
const doSleep = async (ms: number): Promise<void> => {
await Tools.System.sleep(ms);
};
while (Date.now() - startTime < maxWaitTimeMs) {
attempt++;
console.log(`第${attempt}次查询任务状态...`);
try {
const request = client
.newRequest()
.url(endpoint)
.method("POST")
.headers({
"accept": "application/json",
"content-type": "application/json",
"Authorization": `Bearer ${apiKey}`
})
.body(JSON.stringify({ id: taskId }), "json");
const response = await request.build().execute();
if (!response.isSuccessful()) {
console.warn(`⚠️ 查询请求未成功 (HTTP ${response.statusCode}): ${response.content},将重试...`);
await doSleep(pollIntervalMs);
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(response.content) as unknown;
} catch (_error: unknown) {
console.warn("⚠️ 解析结果响应失败,将重试");
await doSleep(pollIntervalMs);
continue;
}
if (!isRecord(parsed)) {
console.warn(`查询响应异常: ${JSON.stringify(parsed)}`);
await doSleep(pollIntervalMs);
continue;
}
if (parsed["code"] === -22 || parsed["code"] === "-22") {
console.log("任务排队/处理中... (等待服务器生成)");
await doSleep(pollIntervalMs);
continue;
}
if (!isApiSuccessResponse(parsed)) {
console.warn(`⚠️ API 返回异常状态,将重试: ${JSON.stringify(parsed)}`);
await doSleep(pollIntervalMs);
continue;
}
const data = extractTaskPayload(parsed);
if (!data) {
console.warn(`查询响应异常 (无有效数据): ${JSON.stringify(parsed)}`);
await doSleep(pollIntervalMs);
continue;
}
const progress = normalizeProgress(data["progress"]);
const status = normalizeStatus(data["status"]);
const imageUrl = extractImageUrlFromPayload(data);
console.log(`当前进度: ${progress}% | 状态: ${status || "unknown"}`);
if (isSuccessStatus(status) || (progress >= 100 && imageUrl.length > 0)) {
console.log("✅ 任务完成!");
if (imageUrl.length === 0) {
throw new Error("任务完成但响应中未找到图片URL: " + JSON.stringify(data));
}
return imageUrl;
}
if (isFailureStatus(status)) {
throw new Error(`任务执行失败: ${JSON.stringify(data)}`);
}
if ((status === "running" || status === "processing") && progress > 0) {
console.log(`生成中... 进度: ${progress}%`);
}
} catch (error: unknown) {
console.log(`⚠️ 第${attempt}次查询发生不可预知的异常: ${getErrorMessage(error)},程序将自动进行下一次尝试...`);
}
await doSleep(pollIntervalMs);
}
throw new Error(`任务超时: 等待超过${Math.ceil(maxWaitTimeMs / 60000)}分钟仍未完成`);
}
function guessExtensionFromUrl(url: string): string {
const match = url.match(/\.(png|jpg|jpeg|webp|gif)(?:\?|#|$)/i);
if (match && match[1]) {
return match[1].toLowerCase();
}
return "png";
}
interface DrawImageParams {
prompt: string;
model?: string;
model_variant?: string;
aspect_ratio?: string;
image_size?: string;
image_urls?: string[] | string;
image_paths?: string[] | string;
file_name?: string;
poll_interval_ms?: number | string;
max_wait_time_ms?: number | string;
}
interface DrawImageResult {
file_path: string;
file_uri: string;
markdown: string;
prompt: string;
model: string;
aspect_ratio?: string;
image_size?: string;
image_urls?: string[] | string;
image_paths?: string[] | string;
hint: string;
}
async function draw_image(params: DrawImageParams): Promise<DrawImageResult> {
if (!params || !params.prompt || params.prompt.trim().length === 0) {
throw new Error("参数 prompt 不能为空。");
}
const prompt = params.prompt.trim();
const resolvedModel = resolveModel(params.model, params.model_variant);
if (params.image_size && params.image_size.trim().length > 0 && resolvedModel !== MODEL_PRO) {
throw new Error("参数 image_size 仅支持 pro 模型(model_variant='pro' 或 model='nano-banana-pro')。");
}
const pollIntervalMs = normalizePositiveInt(params.poll_interval_ms, POLL_INTERVAL);
const normalizedImageSize = params.image_size ? params.image_size.trim().toUpperCase() : "";
const defaultMaxWaitTimeMs = normalizedImageSize === "4K" ? 600000 : MAX_WAIT_TIME;
const maxWaitTimeMs = normalizePositiveInt(params.max_wait_time_ms, defaultMaxWaitTimeMs);
// 添加辅助函数来解析URL数组
function parseImageUrls(image_urls: string[] | string): string[] {
// 如果已经是数组,直接过滤空值返回
if (Array.isArray(image_urls)) {
return image_urls.filter(url => url && url.trim().length > 0);
}
// 如果是字符串,尝试解析
if (typeof image_urls === "string") {
// 方法一:尝试JSON解析
try {
const parsed: unknown = JSON.parse(image_urls) as unknown;
if (Array.isArray(parsed)) {
return parsed.filter((url) => typeof url === "string" && url.trim().length > 0);
}
} catch (e) {
// 解析失败继续方法二
}
// 方法二:按逗号分割(支持 "url1,url2" 格式)
const splitUrls = image_urls.split(",")
.map(url => url.trim())
.filter(url => url.length > 0);
if (splitUrls.length > 0) {
return splitUrls;
}
}
return [];
}
function parseImagePaths(image_paths: string[] | string): string[] {
if (Array.isArray(image_paths)) {
return image_paths.filter(p => p && String(p).trim().length > 0).map(p => String(p).trim());
}
if (typeof image_paths === "string") {
try {
const parsed: unknown = JSON.parse(image_paths) as unknown;
if (Array.isArray(parsed)) {
return parsed.filter((p) => p && String(p).trim().length > 0).map((p) => String(p).trim());
}
} catch (e) {
// ignore
}
const splitPaths = image_paths.split(",")
.map(p => p.trim())
.filter(p => p.length > 0);
if (splitPaths.length > 0) {
return splitPaths;
}
}
return [];
}
// 替换原有的验证逻辑
let imageUrlsArray: string[] = [];
if (params.image_urls) {
imageUrlsArray = parseImageUrls(params.image_urls);
if (imageUrlsArray.length === 0) {
throw new Error("参数 image_urls 必须是有效的URL数组。");
}
}
let imagePathsArray: string[] = [];
if (params.image_paths) {
imagePathsArray = parseImagePaths(params.image_paths);
if (imagePathsArray.length === 0) {
throw new Error("参数 image_paths 必须是有效的本地路径数组。");
}
}
if (imagePathsArray.length > 0) {
console.log(`检测到 ${imagePathsArray.length} 张本地参考图,开始上传以获得公网URL...`);
for (const p of imagePathsArray) {
const url = await uploadImageToBeeimg(p);
imageUrlsArray.push(url);
}
console.log("本地参考图上传完成。");
}
await ensureDirectories();
// 步骤1: 提交任务并获取任务ID
const taskId = await callNanobananaApi({
prompt,
model: resolvedModel,
aspect_ratio: params.aspect_ratio,
image_size: params.image_size,
image_urls: imageUrlsArray,
poll_interval_ms: pollIntervalMs,
max_wait_time_ms: maxWaitTimeMs
});
// 步骤2: 轮询等待任务完成
const imageUrl = await pollForResult(taskId, { poll_interval_ms: pollIntervalMs, max_wait_time_ms: maxWaitTimeMs });
const ext = guessExtensionFromUrl(imageUrl);
const baseName = buildFileName(prompt, params.file_name ?? null);
const filePath = `${DRAWS_DIR}/${baseName}.${ext}`;
const downloadResult = await Tools.Files.download(imageUrl, filePath);
if (!downloadResult.successful) {
throw new Error(`下载图片失败: ${downloadResult.details}`);
}
const fileUri = `file://${filePath}`;
const markdown = `![AI生成的图片](${fileUri})`;
const hintLines: string[] = [];
hintLines.push(`图片已生成并保存在本地 ${DRAWS_DIR}。`);
hintLines.push(`本地路径: ${filePath}`);
hintLines.push("");
hintLines.push("在后续回答中,请直接输出下面这一行 Markdown 来展示这张图片:");
hintLines.push("");
hintLines.push(markdown);
return {
file_path: filePath,
file_uri: fileUri,
markdown,
prompt,
model: resolvedModel,
aspect_ratio: params.aspect_ratio,
image_size: params.image_size,
image_urls: params.image_urls,
image_paths: params.image_paths,
hint: hintLines.join("\n")
};
}
async function draw_image_wrapper(params: DrawImageParams) {
try {
const result = await draw_image(params);
complete({
success: true,
message: `图片生成成功,已保存到 ${DRAWS_DIR},并返回 Markdown 图片提示。`,
data: result
});
} catch (error: unknown) {
console.error("draw_image 执行失败:", error);
complete({
success: false,
message: `图片生成失败: ${getErrorMessage(error)}`,
error_stack: getErrorStack(error)
});
}
}
return {
draw_image: draw_image_wrapper
};
})();
exports.draw_image = nanobananaDraw.draw_image;