- 新增 operit_runtime.py: 跨平台 Python 运行时 - 实现 Tools.System/Files/Net/UI/Memory/FFmpeg/Workflow/Chat/SoftwareSettings/Tasker - 实现 OkHttp 链式 HTTP 客户端兼容层 - 实现 complete()/exports/console 全局对象 - 实现 OperitScriptLoader 脚本加载器 - 支持 Windows/macOS/Linux 三平台 - 新增 operit_py/ 目录: 47 个 Python 翻译脚本 - 搜索类: duckduckgo, google_search, tavily, zhipu_search, various_search, crossref - 绘图类: openai_draw, qwen_draw, xai_draw, minimax_draw, siliconflow_draw, zhipu_draw, nanobanana_draw - 媒体类: ffmpeg, file_converter, douyin_download, bilibili_tools, jmcomic - 文件类: extended_file_tools, hex_editor, reader, beeimg_image_uploader - 网络类: extended_http_tools, network_test - 自动化类: browser, automatic_ui_base, automatic_ui_subagent, automatic_bilibili_assistant, automatic_xiaohongshu_assistant, automatic_baidu_map_assistant - 系统类: system_tools, super_admin, daily_life, operit_editor - 对话类: ai_chat, extended_chat, qq_intelligent - 工作流类: workflow, vflow_trigger, tasker - 工具类: time, code_runner, quick_start, extended_memory_tools, baidu_map, 12306, pdf_vision_parser - 测试结果: 47/47 语法检查通过, 38 脚本加载成功, 280 个工具注册 - Android 专属功能已降级处理(保留接口,返回不支持提示)
637 lines
25 KiB
Python
637 lines
25 KiB
Python
# 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}
|
||
# ]}
|
||
# ]
|
||
# }
|
||
|
||
import json
|
||
import re
|
||
import time
|
||
import math
|
||
from urllib.parse import quote, unquote
|
||
|
||
Error = Exception
|
||
|
||
HTTP_TIMEOUT_MS = 600000
|
||
client = (
|
||
OkHttp.newBuilder()
|
||
.connectTimeout(HTTP_TIMEOUT_MS)
|
||
.readTimeout(HTTP_TIMEOUT_MS)
|
||
.writeTimeout(HTTP_TIMEOUT_MS)
|
||
.build()
|
||
)
|
||
|
||
BEEIMG_UPLOAD_ENDPOINT = "https://beeimg.com/api/upload/file/json/"
|
||
|
||
# API配置
|
||
DEFAULT_API_BASE_URL = "https://grsai.dakka.com.cn"
|
||
DRAW_API_PATH = "v1/draw/nano-banana"
|
||
RESULT_API_PATH = "v1/draw/result"
|
||
MODEL_PRO = "nano-banana-pro"
|
||
MODEL_NANO = "nano-banana"
|
||
DEFAULT_MODEL = MODEL_PRO
|
||
# Android 实际路径为 /sdcard/Download,对应系统中文名"下载" (Android-only)
|
||
DRAW_ROOT_DIR = getPluginConfigDir("draw")
|
||
STORAGE_DIR = f"{DRAW_ROOT_DIR}/nanobanana_draw"
|
||
DRAWS_DIR = f"{STORAGE_DIR}/draws"
|
||
|
||
# 轮询配置
|
||
POLL_INTERVAL = 5000 # 每5秒查询一次
|
||
MAX_WAIT_TIME = 600000 # 最多等待10分钟
|
||
|
||
|
||
def isRecord(value):
|
||
return isinstance(value, dict)
|
||
|
||
|
||
def getErrorMessage(error):
|
||
if isinstance(error, Exception):
|
||
return str(error)
|
||
return str(error)
|
||
|
||
|
||
def getErrorStack(error):
|
||
if isinstance(error, Exception):
|
||
import traceback
|
||
return "".join(traceback.format_exception(type(error), error, error.__traceback__))
|
||
return None
|
||
|
||
|
||
def resolveModel(model=None, modelVariant=None):
|
||
if model and model.strip():
|
||
return model.strip()
|
||
|
||
if not modelVariant or not modelVariant.strip():
|
||
return DEFAULT_MODEL
|
||
|
||
normalizedVariant = modelVariant.strip().lower()
|
||
if normalizedVariant == "pro":
|
||
return MODEL_PRO
|
||
if normalizedVariant == "nano":
|
||
return MODEL_NANO
|
||
|
||
raise Error("参数 model_variant 仅支持 'pro' 或 'nano'。")
|
||
|
||
|
||
def normalizePositiveInt(value, fallback):
|
||
if value is None:
|
||
return fallback
|
||
n = value if isinstance(value, (int, float)) else int(str(value), 10)
|
||
if not math.isfinite(n) or n <= 0:
|
||
return fallback
|
||
return int(n)
|
||
|
||
|
||
def getApiKey():
|
||
apiKey = getEnv("NANOBANANA_API_KEY")
|
||
if not apiKey:
|
||
raise Error("NANOBANANA_API_KEY 未配置,请在环境变量中设置 Nano Banana 的 API Key。")
|
||
return apiKey
|
||
|
||
|
||
def getBeeimgApiKey():
|
||
return getEnv("BEEIMG_API_KEY") or ""
|
||
|
||
|
||
def joinUrl(baseUrl, path):
|
||
normalizedBase = baseUrl if baseUrl.endswith("/") else f"{baseUrl}/"
|
||
normalizedPath = path[1:] if path.startswith("/") else path
|
||
return f"{normalizedBase}{normalizedPath}"
|
||
|
||
|
||
def getApiBaseUrl():
|
||
fromEnv = str(getEnv("NANOBANANA_API_BASE_URL") or "").strip()
|
||
if fromEnv:
|
||
return fromEnv
|
||
return DEFAULT_API_BASE_URL
|
||
|
||
|
||
def getDrawEndpoint(baseUrl):
|
||
trimmed = baseUrl.strip()
|
||
if not trimmed:
|
||
return joinUrl(DEFAULT_API_BASE_URL, DRAW_API_PATH)
|
||
if "/v1/draw/nano-banana" in trimmed:
|
||
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)
|
||
|
||
|
||
def getResultEndpoint(baseUrl):
|
||
trimmed = baseUrl.strip()
|
||
if not trimmed:
|
||
return joinUrl(DEFAULT_API_BASE_URL, RESULT_API_PATH)
|
||
if "/v1/draw/result" in trimmed:
|
||
return trimmed
|
||
if "/v1/draw/nano-banana" in trimmed:
|
||
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)
|
||
|
||
|
||
def guessMimeTypeFromPath(filePath):
|
||
lower = filePath.lower()
|
||
if lower.endswith(".png"):
|
||
return "image/png"
|
||
if lower.endswith(".jpg") or lower.endswith(".jpeg"):
|
||
return "image/jpeg"
|
||
if lower.endswith(".webp"):
|
||
return "image/webp"
|
||
if lower.endswith(".gif"):
|
||
return "image/gif"
|
||
return "application/octet-stream"
|
||
|
||
|
||
def safeJsonParseLoose(text):
|
||
trimmed = str(text or "").strip()
|
||
if not trimmed:
|
||
return None
|
||
try:
|
||
return json.loads(trimmed)
|
||
except Exception as e:
|
||
start = trimmed.find("{")
|
||
end = trimmed.rfind("}")
|
||
if start != -1 and end != -1 and end > start:
|
||
return json.loads(trimmed[start:end + 1])
|
||
raise e
|
||
|
||
|
||
def isApiSuccessResponse(parsed):
|
||
code = parsed.get("code")
|
||
return code is None or code == 0 or code == "0"
|
||
|
||
|
||
def extractTaskPayload(parsed):
|
||
if not isRecord(parsed):
|
||
return None
|
||
if isRecord(parsed.get("data")):
|
||
return parsed.get("data")
|
||
return parsed
|
||
|
||
|
||
def extractMessage(parsed, fallback):
|
||
payload = extractTaskPayload(parsed)
|
||
candidates = [
|
||
parsed.get("msg"),
|
||
parsed.get("message"),
|
||
payload.get("failure_reason") if payload else None,
|
||
payload.get("error") if payload else None,
|
||
payload.get("msg") if payload else None,
|
||
payload.get("message") if payload else None,
|
||
]
|
||
|
||
for candidate in candidates:
|
||
if (isinstance(candidate, str) or isinstance(candidate, (int, float))) and str(candidate).strip():
|
||
return str(candidate).strip()
|
||
|
||
return fallback
|
||
|
||
|
||
def normalizeStatus(status):
|
||
return status.strip().lower() if isinstance(status, str) else ""
|
||
|
||
|
||
def normalizeProgress(progress):
|
||
normalized = progress if isinstance(progress, (int, float)) else float(str(progress))
|
||
return normalized if math.isfinite(normalized) else 0
|
||
|
||
|
||
def isSuccessStatus(status):
|
||
return status in ("succeeded", "success", "completed", "done")
|
||
|
||
|
||
def isFailureStatus(status):
|
||
return status in ("failed", "error", "canceled", "cancelled")
|
||
|
||
|
||
def extractImageUrlFromPayload(payload):
|
||
directCandidates = [
|
||
payload.get("url"),
|
||
payload.get("image_url"),
|
||
payload.get("imageUrl"),
|
||
payload.get("file_url"),
|
||
payload.get("fileUrl"),
|
||
payload.get("result_url"),
|
||
]
|
||
|
||
for candidate in directCandidates:
|
||
if (isinstance(candidate, str) or isinstance(candidate, (int, float))) and str(candidate).strip():
|
||
return str(candidate).strip()
|
||
|
||
results = payload.get("results")
|
||
if not isinstance(results, list) or len(results) == 0:
|
||
return ""
|
||
|
||
first = results[0]
|
||
if isRecord(first):
|
||
nestedCandidates = [
|
||
first.get("url"),
|
||
first.get("image_url"),
|
||
first.get("imageUrl"),
|
||
first.get("file_url"),
|
||
first.get("fileUrl"),
|
||
first.get("result_url"),
|
||
]
|
||
for candidate in nestedCandidates:
|
||
if (isinstance(candidate, str) or isinstance(candidate, (int, float))) and str(candidate).strip():
|
||
return str(candidate).strip()
|
||
elif (isinstance(first, str) or isinstance(first, (int, float))) and str(first).strip():
|
||
return str(first).strip()
|
||
|
||
return ""
|
||
|
||
|
||
async def uploadImageToBeeimg(filePath):
|
||
exists = await Tools.Files.exists(filePath)
|
||
if not exists.exists:
|
||
raise Error(f"参考图文件不存在: {filePath}")
|
||
|
||
apiKey = getBeeimgApiKey()
|
||
if not apiKey:
|
||
raise Error("使用 image_paths 需要配置 BEEIMG_API_KEY(用于把本地图片上传到图床以获得公网URL)。")
|
||
|
||
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 or resp.statusCode >= 300:
|
||
raise Error(f"BeeIMG 上传失败: HTTP {resp.statusCode} - {resp.content}")
|
||
|
||
parsed = None
|
||
try:
|
||
parsed = safeJsonParseLoose(resp.content)
|
||
except Exception as e:
|
||
raise Error(f"BeeIMG 上传响应解析失败: {getErrorMessage(e)}")
|
||
|
||
files = parsed.get("files") if isRecord(parsed) and isRecord(parsed.get("files")) else None
|
||
ok = bool(files) and (files.get("status") == "Success" or files.get("code") == "200" or files.get("code") == 200)
|
||
url = files.get("url") if files else None
|
||
if not ok or (not isinstance(url, str) and not isinstance(url, (int, float))) or not str(url).strip():
|
||
raise Error(f"BeeIMG 上传失败: {resp.content}")
|
||
return str(url)
|
||
|
||
|
||
def sanitizeFileName(name):
|
||
safe = re.sub(r'[\\/:*?"<>|]', "_", name).strip()
|
||
if not safe:
|
||
return f"nano_draw_{int(time.time() * 1000)}"
|
||
return safe[:80]
|
||
|
||
|
||
def buildFileName(prompt, customName=None):
|
||
if customName and customName.strip():
|
||
return sanitizeFileName(customName)
|
||
shortPrompt = f"{prompt[:40]}..." if len(prompt) > 40 else prompt
|
||
base = sanitizeFileName(shortPrompt or "image")
|
||
timestamp = int(time.time() * 1000)
|
||
return f"{base}_{timestamp}"
|
||
|
||
|
||
async def ensureDirectories():
|
||
dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR]
|
||
for dir_ in dirs:
|
||
try:
|
||
result = await Tools.Files.mkdir(dir_)
|
||
if not result.successful:
|
||
console.warn(f"创建目录失败(可能已存在): {dir_} -> {result.details}")
|
||
except Exception as e:
|
||
console.warn(f"创建目录异常: {dir_} -> {getErrorMessage(e)}")
|
||
|
||
|
||
async def callNanobananaApi(params):
|
||
apiKey = getApiKey()
|
||
endpoint = getDrawEndpoint(getApiBaseUrl())
|
||
|
||
# 构建请求体 - 使用异步模式(webHook: "-1")
|
||
body = {
|
||
"model": params.get("model"),
|
||
"prompt": params.get("prompt"),
|
||
"webHook": "-1", # 关键:立即返回任务ID
|
||
"shutProgress": False,
|
||
}
|
||
|
||
# 添加可选参数
|
||
if params.get("aspect_ratio") and params.get("aspect_ratio").strip():
|
||
body["aspectRatio"] = params.get("aspect_ratio").strip()
|
||
|
||
if params.get("image_size") and params.get("image_size").strip():
|
||
body["imageSize"] = params.get("image_size").strip()
|
||
|
||
# 图生图:添加参考图URL数组
|
||
if params.get("image_urls") and isinstance(params.get("image_urls"), list) and len(params.get("image_urls")) > 0:
|
||
body["urls"] = [url for url in params.get("image_urls") if url and url.strip()]
|
||
|
||
headers = {
|
||
"accept": "application/json",
|
||
"content-type": "application/json",
|
||
"Authorization": f"Bearer {apiKey}",
|
||
}
|
||
|
||
request = (
|
||
client.newRequest()
|
||
.url(endpoint)
|
||
.method("POST")
|
||
.headers(headers)
|
||
.body(json.dumps(body), "json")
|
||
)
|
||
|
||
console.log("步骤1/2: 提交绘图任务...")
|
||
response = await request.build().execute()
|
||
|
||
if not response.isSuccessful():
|
||
raise Error(f"Nano Banana API 调用失败: {response.statusCode} - {response.content}")
|
||
|
||
parsed = None
|
||
try:
|
||
parsed = json.loads(response.content)
|
||
except Exception as e:
|
||
raise Error(f"解析 Nano Banana 响应失败: {getErrorMessage(e)}")
|
||
|
||
if not isRecord(parsed):
|
||
raise Error("API响应不是合法对象,请检查参数是否正确。响应: " + response.content)
|
||
if not isApiSuccessResponse(parsed):
|
||
raise Error(f"Nano Banana API 返回错误: {extractMessage(parsed, response.content)}")
|
||
|
||
payload = extractTaskPayload(parsed)
|
||
if not payload or not isinstance(payload.get("id"), str):
|
||
raise Error("API响应中未找到任务ID,请检查参数是否正确。响应: " + json.dumps(parsed))
|
||
|
||
taskId = payload.get("id")
|
||
console.log(f"任务提交成功! ID: {taskId}")
|
||
pollIntervalMs = params.get("poll_interval_ms") if params.get("poll_interval_ms") is not None else POLL_INTERVAL
|
||
maxWaitTimeMs = params.get("max_wait_time_ms") if params.get("max_wait_time_ms") is not None else MAX_WAIT_TIME
|
||
console.log(f"步骤2/2: 等待任务完成(轮询中,每{pollIntervalMs / 1000}秒查询一次,最长等待{math.ceil(maxWaitTimeMs / 60000)}分钟)...")
|
||
|
||
return taskId
|
||
|
||
|
||
async def pollForResult(taskId, options=None):
|
||
options = options or {}
|
||
apiKey = getApiKey()
|
||
endpoint = getResultEndpoint(getApiBaseUrl())
|
||
pollIntervalMs = normalizePositiveInt(options.get("poll_interval_ms"), POLL_INTERVAL)
|
||
maxWaitTimeMs = normalizePositiveInt(options.get("max_wait_time_ms"), MAX_WAIT_TIME)
|
||
startTime = time.time() * 1000
|
||
attempt = 0
|
||
|
||
async def doSleep(ms):
|
||
await Tools.System.sleep(ms)
|
||
|
||
while time.time() * 1000 - startTime < maxWaitTimeMs:
|
||
attempt += 1
|
||
console.log(f"第{attempt}次查询任务状态...")
|
||
|
||
try:
|
||
request = (
|
||
client.newRequest()
|
||
.url(endpoint)
|
||
.method("POST")
|
||
.headers({
|
||
"accept": "application/json",
|
||
"content-type": "application/json",
|
||
"Authorization": f"Bearer {apiKey}",
|
||
})
|
||
.body(json.dumps({"id": taskId}), "json")
|
||
)
|
||
|
||
response = await request.build().execute()
|
||
|
||
if not response.isSuccessful():
|
||
console.warn(f"⚠️ 查询请求未成功 (HTTP {response.statusCode}): {response.content},将重试...")
|
||
await doSleep(pollIntervalMs)
|
||
continue
|
||
|
||
parsed = None
|
||
try:
|
||
parsed = json.loads(response.content)
|
||
except Exception:
|
||
console.warn("⚠️ 解析结果响应失败,将重试")
|
||
await doSleep(pollIntervalMs)
|
||
continue
|
||
|
||
if not isRecord(parsed):
|
||
console.warn(f"查询响应异常: {json.dumps(parsed)}")
|
||
await doSleep(pollIntervalMs)
|
||
continue
|
||
|
||
if parsed.get("code") == -22 or parsed.get("code") == "-22":
|
||
console.log("任务排队/处理中... (等待服务器生成)")
|
||
await doSleep(pollIntervalMs)
|
||
continue
|
||
|
||
if not isApiSuccessResponse(parsed):
|
||
console.warn(f"⚠️ API 返回异常状态,将重试: {json.dumps(parsed)}")
|
||
await doSleep(pollIntervalMs)
|
||
continue
|
||
|
||
data = extractTaskPayload(parsed)
|
||
if not data:
|
||
console.warn(f"查询响应异常 (无有效数据): {json.dumps(parsed)}")
|
||
await doSleep(pollIntervalMs)
|
||
continue
|
||
|
||
progress = normalizeProgress(data.get("progress"))
|
||
status = normalizeStatus(data.get("status"))
|
||
imageUrl = extractImageUrlFromPayload(data)
|
||
|
||
console.log(f"当前进度: {progress}% | 状态: {status or 'unknown'}")
|
||
|
||
if isSuccessStatus(status) or (progress >= 100 and len(imageUrl) > 0):
|
||
console.log("✅ 任务完成!")
|
||
if len(imageUrl) == 0:
|
||
raise Error("任务完成但响应中未找到图片URL: " + json.dumps(data))
|
||
return imageUrl
|
||
if isFailureStatus(status):
|
||
raise Error(f"任务执行失败: {json.dumps(data)}")
|
||
if (status == "running" or status == "processing") and progress > 0:
|
||
console.log(f"生成中... 进度: {progress}%")
|
||
except Exception as error:
|
||
console.log(f"⚠️ 第{attempt}次查询发生不可预知的异常: {getErrorMessage(error)},程序将自动进行下一次尝试...")
|
||
|
||
await doSleep(pollIntervalMs)
|
||
|
||
raise Error(f"任务超时: 等待超过{math.ceil(maxWaitTimeMs / 60000)}分钟仍未完成")
|
||
|
||
|
||
def guessExtensionFromUrl(url):
|
||
match = re.search(r"\.(png|jpg|jpeg|webp|gif)(?:\?|#|$)", url, re.IGNORECASE)
|
||
if match and match.group(1):
|
||
return match.group(1).lower()
|
||
return "png"
|
||
|
||
|
||
def parseImageUrls(image_urls):
|
||
# 如果已经是数组,直接过滤空值返回
|
||
if isinstance(image_urls, list):
|
||
return [url for url in image_urls if url and str(url).strip()]
|
||
|
||
# 如果是字符串,尝试解析
|
||
if isinstance(image_urls, str):
|
||
# 方法一:尝试JSON解析
|
||
try:
|
||
parsed = json.loads(image_urls)
|
||
if isinstance(parsed, list):
|
||
return [url for url in parsed if isinstance(url, str) and url.strip()]
|
||
except Exception:
|
||
pass
|
||
|
||
# 方法二:按逗号分割(支持 "url1,url2" 格式)
|
||
splitUrls = [url.strip() for url in image_urls.split(",")]
|
||
splitUrls = [url for url in splitUrls if url]
|
||
if splitUrls:
|
||
return splitUrls
|
||
|
||
return []
|
||
|
||
|
||
def parseImagePaths(image_paths):
|
||
if isinstance(image_paths, list):
|
||
return [str(p).strip() for p in image_paths if p and str(p).strip()]
|
||
if isinstance(image_paths, str):
|
||
try:
|
||
parsed = json.loads(image_paths)
|
||
if isinstance(parsed, list):
|
||
return [str(p).strip() for p in parsed if p and str(p).strip()]
|
||
except Exception:
|
||
pass
|
||
|
||
splitPaths = [p.strip() for p in image_paths.split(",")]
|
||
splitPaths = [p for p in splitPaths if p]
|
||
if splitPaths:
|
||
return splitPaths
|
||
return []
|
||
|
||
|
||
async def draw_image(params):
|
||
if not params or not params.get("prompt") or not str(params.get("prompt")).strip():
|
||
raise Error("参数 prompt 不能为空。")
|
||
|
||
prompt = str(params.get("prompt")).strip()
|
||
resolvedModel = resolveModel(params.get("model"), params.get("model_variant"))
|
||
|
||
if params.get("image_size") and params.get("image_size").strip() and resolvedModel != MODEL_PRO:
|
||
raise Error("参数 image_size 仅支持 pro 模型(model_variant='pro' 或 model='nano-banana-pro')。")
|
||
|
||
pollIntervalMs = normalizePositiveInt(params.get("poll_interval_ms"), POLL_INTERVAL)
|
||
normalizedImageSize = params.get("image_size").strip().upper() if params.get("image_size") else ""
|
||
defaultMaxWaitTimeMs = 600000 if normalizedImageSize == "4K" else MAX_WAIT_TIME
|
||
maxWaitTimeMs = normalizePositiveInt(params.get("max_wait_time_ms"), defaultMaxWaitTimeMs)
|
||
|
||
imageUrlsArray = []
|
||
if params.get("image_urls"):
|
||
imageUrlsArray = parseImageUrls(params.get("image_urls"))
|
||
if len(imageUrlsArray) == 0:
|
||
raise Error("参数 image_urls 必须是有效的URL数组。")
|
||
|
||
imagePathsArray = []
|
||
if params.get("image_paths"):
|
||
imagePathsArray = parseImagePaths(params.get("image_paths"))
|
||
if len(imagePathsArray) == 0:
|
||
raise Error("参数 image_paths 必须是有效的本地路径数组。")
|
||
|
||
if len(imagePathsArray) > 0:
|
||
console.log(f"检测到 {len(imagePathsArray)} 张本地参考图,开始上传以获得公网URL...")
|
||
for p in imagePathsArray:
|
||
url = await uploadImageToBeeimg(p)
|
||
imageUrlsArray.append(url)
|
||
console.log("本地参考图上传完成。")
|
||
|
||
await ensureDirectories()
|
||
|
||
# 步骤1: 提交任务并获取任务ID
|
||
taskId = await callNanobananaApi({
|
||
"prompt": prompt,
|
||
"model": resolvedModel,
|
||
"aspect_ratio": params.get("aspect_ratio"),
|
||
"image_size": params.get("image_size"),
|
||
"image_urls": imageUrlsArray,
|
||
"poll_interval_ms": pollIntervalMs,
|
||
"max_wait_time_ms": maxWaitTimeMs,
|
||
})
|
||
|
||
# 步骤2: 轮询等待任务完成
|
||
imageUrl = await pollForResult(taskId, {"poll_interval_ms": pollIntervalMs, "max_wait_time_ms": maxWaitTimeMs})
|
||
|
||
ext = guessExtensionFromUrl(imageUrl)
|
||
baseName = buildFileName(prompt, params.get("file_name"))
|
||
filePath = f"{DRAWS_DIR}/{baseName}.{ext}"
|
||
|
||
downloadResult = await Tools.Files.download(imageUrl, filePath)
|
||
if not downloadResult.successful:
|
||
raise Error(f"下载图片失败: {downloadResult.details}")
|
||
|
||
fileUri = f"file://{filePath}"
|
||
markdown = f""
|
||
|
||
hintLines = []
|
||
hintLines.append(f"图片已生成并保存在本地 {DRAWS_DIR}。")
|
||
hintLines.append(f"本地路径: {filePath}")
|
||
hintLines.append("")
|
||
hintLines.append("在后续回答中,请直接输出下面这一行 Markdown 来展示这张图片:")
|
||
hintLines.append("")
|
||
hintLines.append(markdown)
|
||
|
||
return {
|
||
"file_path": filePath,
|
||
"file_uri": fileUri,
|
||
"markdown": markdown,
|
||
"prompt": prompt,
|
||
"model": resolvedModel,
|
||
"aspect_ratio": params.get("aspect_ratio"),
|
||
"image_size": params.get("image_size"),
|
||
"image_urls": params.get("image_urls"),
|
||
"image_paths": params.get("image_paths"),
|
||
"hint": "\n".join(hintLines),
|
||
}
|
||
|
||
|
||
async def draw_image_wrapper(params):
|
||
try:
|
||
result = await draw_image(params)
|
||
complete({
|
||
"success": True,
|
||
"message": f"图片生成成功,已保存到 {DRAWS_DIR},并返回 Markdown 图片提示。",
|
||
"data": result,
|
||
})
|
||
except Exception as error:
|
||
console.error("draw_image 执行失败:", error)
|
||
complete({
|
||
"success": False,
|
||
"message": f"图片生成失败: {getErrorMessage(error)}",
|
||
"error_stack": getErrorStack(error),
|
||
})
|
||
|
||
|
||
exports.draw_image = draw_image_wrapper
|