- 新增 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 专属功能已降级处理(保留接口,返回不支持提示)
373 lines
15 KiB
Python
373 lines
15 KiB
Python
# METADATA
|
||
# {
|
||
# "name": "qwen_draw",
|
||
# "display_name": {
|
||
# "zh": "Qwen 绘图",
|
||
# "en": "Qwen Draw"
|
||
# },
|
||
# "description": {
|
||
# "zh": "使用阿里云百炼/DashScope 文生图接口(通义万相/通义千问图像)根据提示词画图(异步任务轮询),将图片保存到本地 /sdcard/Download/Operit/plugins/draw/qwen_draw/draws/ 目录,并返回 Markdown 图片提示。",
|
||
# "en": "Generate images via Alibaba Cloud Model Studio (DashScope) text-to-image API (async task polling), save to /sdcard/Download/Operit/plugins/draw/qwen_draw/draws/, and return a Markdown image reference."
|
||
# },
|
||
# "env": [
|
||
# { "name": "DASHSCOPE_API_KEY", "description": { "zh": "DashScope API Key(必填)", "en": "DashScope API key (required)" }, "required": true },
|
||
# { "name": "DASHSCOPE_API_BASE_URL", "description": { "zh": "DashScope API Base URL(可选,不填则默认 https://dashscope.aliyuncs.com;国际站可用 https://dashscope-intl.aliyuncs.com)", "en": "DashScope API base URL (optional; defaults to https://dashscope.aliyuncs.com; intl: https://dashscope-intl.aliyuncs.com)" }, "required": false },
|
||
# { "name": "QWEN_IMAGE_MODEL", "description": { "zh": "默认文生图模型(可选;当 draw_image 未传 model 时使用,例如 qwen-image-plus 或 wan2.2-t2i-flash)", "en": "Default image model (optional; used when draw_image doesn't pass model), e.g. qwen-image-plus or wan2.2-t2i-flash" }, "required": false }
|
||
# ],
|
||
# "category": "Draw",
|
||
# "tools": [
|
||
# {
|
||
# "name": "draw_image",
|
||
# "description": { "zh": "根据提示词调用 DashScope 文生图接口生成图片,保存到本地并返回 Markdown 图片提示。", "en": "Generate an image via DashScope text-to-image API using a prompt, save it locally, and return a Markdown image reference." },
|
||
# "parameters": [
|
||
# { "name": "prompt", "description": { "zh": "绘图提示词(英文或中文皆可)", "en": "Prompt for image generation (Chinese or English)" }, "type": "string", "required": true },
|
||
# { "name": "model", "description": { "zh": "模型名称(可选;不传则使用环境变量 QWEN_IMAGE_MODEL,再不行使用默认值)", "en": "Model name (optional; falls back to env QWEN_IMAGE_MODEL, then default)" }, "type": "string", "required": false },
|
||
# { "name": "size", "description": { "zh": "输出图像分辨率,如 '1664*928' 或 '1024x1024'(可选)", "en": "Output image resolution, e.g. '1664*928' or '1024x1024' (optional)" }, "type": "string", "required": false },
|
||
# { "name": "n", "description": { "zh": "生成图片数量(可选,默认 1)", "en": "Number of images (optional; default 1)" }, "type": "number", "required": false },
|
||
# { "name": "negative_prompt", "description": { "zh": "负面提示词(可选)", "en": "Negative prompt (optional)" }, "type": "string", "required": false },
|
||
# { "name": "prompt_extend", "description": { "zh": "是否开启 prompt 智能改写(可选,默认 true)", "en": "Enable prompt extension (optional; default true)" }, "type": "boolean", "required": false },
|
||
# { "name": "watermark", "description": { "zh": "是否加水印(可选,默认 false)", "en": "Enable watermark (optional; default false)" }, "type": "boolean", "required": false },
|
||
# { "name": "file_name", "description": { "zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)" }, "type": "string", "required": false },
|
||
# { "name": "api_base_url", "description": { "zh": "DashScope API Base URL(不传则取环境变量 DASHSCOPE_API_BASE_URL 或默认 https://dashscope.aliyuncs.com )", "en": "DashScope API base URL (optional; falls back to env DASHSCOPE_API_BASE_URL or https://dashscope.aliyuncs.com)" }, "type": "string", "required": false },
|
||
# { "name": "poll_interval_ms", "description": { "zh": "轮询间隔(毫秒),默认 2000", "en": "Polling interval (milliseconds), default 2000" }, "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 math
|
||
import re
|
||
import time
|
||
|
||
HTTP_TIMEOUT_MS = 600000
|
||
client = OkHttp.newBuilder().connectTimeout(HTTP_TIMEOUT_MS).readTimeout(HTTP_TIMEOUT_MS).writeTimeout(HTTP_TIMEOUT_MS).build()
|
||
|
||
DEFAULT_API_BASE_URL = "https://dashscope.aliyuncs.com"
|
||
DEFAULT_MODEL = "qwen-image-plus"
|
||
|
||
DRAW_ROOT_DIR = getPluginConfigDir("draw")
|
||
STORAGE_DIR = f"{DRAW_ROOT_DIR}/qwen_draw"
|
||
DRAWS_DIR = f"{STORAGE_DIR}/draws"
|
||
|
||
POLL_INTERVAL_MS = 2000
|
||
MAX_WAIT_TIME_MS = 600000
|
||
|
||
|
||
def _is_finite(n):
|
||
return isinstance(n, (int, float)) and not isinstance(n, bool) and math.isfinite(n)
|
||
|
||
|
||
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 traceback.format_exc()
|
||
return None
|
||
|
||
|
||
def normalizePositiveInt(value, fallback):
|
||
if value is None:
|
||
return fallback
|
||
n = value if isinstance(value, (int, float)) else int(str(value), 10)
|
||
if not _is_finite(n) or n <= 0:
|
||
return fallback
|
||
return int(n)
|
||
|
||
|
||
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 getApiKey():
|
||
apiKey = getEnv("DASHSCOPE_API_KEY")
|
||
if not apiKey:
|
||
raise Exception("DASHSCOPE_API_KEY 未配置,请在环境变量中设置 DashScope 的 API Key。")
|
||
return apiKey
|
||
|
||
|
||
def getApiBaseUrl(customBaseUrl=None):
|
||
fromParam = str(customBaseUrl or "").strip()
|
||
if fromParam:
|
||
return fromParam
|
||
|
||
fromEnv = str(getEnv("DASHSCOPE_API_BASE_URL") or "").strip()
|
||
if fromEnv:
|
||
return fromEnv
|
||
|
||
return DEFAULT_API_BASE_URL
|
||
|
||
|
||
def getTextToImageEndpoint(baseUrl):
|
||
trimmed = baseUrl.strip()
|
||
if not trimmed:
|
||
return joinUrl(DEFAULT_API_BASE_URL, "api/v1/services/aigc/text2image/image-synthesis")
|
||
|
||
if "/api/v1/services/aigc/text2image/image-synthesis" in trimmed:
|
||
return trimmed
|
||
|
||
if trimmed.endswith("/api/v1"):
|
||
return joinUrl(trimmed, "services/aigc/text2image/image-synthesis")
|
||
if trimmed.endswith("/api/v1/"):
|
||
return joinUrl(trimmed, "services/aigc/text2image/image-synthesis")
|
||
|
||
return joinUrl(trimmed, "api/v1/services/aigc/text2image/image-synthesis")
|
||
|
||
|
||
def getTaskEndpoint(baseUrl, taskId):
|
||
trimmed = baseUrl.strip()
|
||
safeBase = trimmed or DEFAULT_API_BASE_URL
|
||
if "/api/v1/tasks/" in safeBase:
|
||
idx = safeBase.index("/api/v1/tasks/")
|
||
return safeBase[:idx] + f"/api/v1/tasks/{taskId}"
|
||
if safeBase.endswith("/api/v1"):
|
||
return joinUrl(safeBase, f"tasks/{taskId}")
|
||
if safeBase.endswith("/api/v1/"):
|
||
return joinUrl(safeBase, f"tasks/{taskId}")
|
||
return joinUrl(safeBase, f"api/v1/tasks/{taskId}")
|
||
|
||
|
||
def sanitizeFileName(name):
|
||
safe = re.sub(r'[\\/:*?"<>|]', "_", str(name)).strip()
|
||
if not safe:
|
||
return f"qwen_draw_{int(time.time() * 1000)}"
|
||
return safe[:80]
|
||
|
||
|
||
def buildFileName(prompt, customName=None):
|
||
if customName and str(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}"
|
||
|
||
|
||
def normalizeSize(size=None):
|
||
raw = str(size or "").strip()
|
||
if not raw:
|
||
return None
|
||
|
||
cleaned = raw.replace("×", "*").replace("x", "*").replace("X", "*")
|
||
cleaned = re.sub(r"\s+", "", cleaned)
|
||
if not re.match(r"^\d+\*\d+$", cleaned):
|
||
return None
|
||
return cleaned
|
||
|
||
|
||
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"
|
||
|
||
|
||
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.get("successful"):
|
||
console.warn(f"创建目录失败(可能已存在): {dir_} -> {result.get('details')}")
|
||
except Exception as e:
|
||
console.warn(f"创建目录异常: {dir_} -> {getErrorMessage(e)}")
|
||
|
||
|
||
async def createTask(params):
|
||
apiKey = getApiKey()
|
||
apiBaseUrl = getApiBaseUrl(params.get("api_base_url"))
|
||
endpoint = getTextToImageEndpoint(apiBaseUrl)
|
||
|
||
modelFromParam = str(params.get("model") or "").strip()
|
||
modelFromEnv = str(getEnv("QWEN_IMAGE_MODEL") or "").strip()
|
||
effectiveModel = modelFromParam or modelFromEnv or DEFAULT_MODEL
|
||
|
||
body = {
|
||
"model": effectiveModel,
|
||
"input": {
|
||
"prompt": params["prompt"]
|
||
},
|
||
"parameters": {
|
||
"n": int(params["n"]) if isinstance(params.get("n"), (int, float)) and _is_finite(params["n"]) and params["n"] > 0 else 1,
|
||
"prompt_extend": True if params.get("prompt_extend") is None else bool(params.get("prompt_extend")),
|
||
"watermark": False if params.get("watermark") is None else bool(params.get("watermark"))
|
||
}
|
||
}
|
||
|
||
normalizedSize = normalizeSize(params.get("size"))
|
||
if normalizedSize:
|
||
body["parameters"]["size"] = normalizedSize
|
||
|
||
negativePrompt = str(params.get("negative_prompt") or "").strip()
|
||
if negativePrompt:
|
||
body["parameters"]["negative_prompt"] = negativePrompt
|
||
|
||
headers = {
|
||
"accept": "application/json",
|
||
"content-type": "application/json",
|
||
"Authorization": f"Bearer {apiKey}",
|
||
"X-DashScope-Async": "enable"
|
||
}
|
||
|
||
request = client.newRequest().url(endpoint).method("POST").headers(headers).body(json.dumps(body), "json")
|
||
|
||
response = await request.build().execute()
|
||
|
||
if not response.isSuccessful():
|
||
raise Exception(f"DashScope 文生图创建任务失败: {response.statusCode} - {response.content}")
|
||
|
||
try:
|
||
parsed = json.loads(response.content)
|
||
except Exception as e:
|
||
raise Exception(f"解析 DashScope 创建任务响应失败: {getErrorMessage(e)}")
|
||
|
||
output = parsed.get("output") if isRecord(parsed) and isRecord(parsed.get("output")) else None
|
||
taskId = output.get("task_id") if (output and isinstance(output.get("task_id"), str)) else ""
|
||
if not taskId:
|
||
raise Exception(f"DashScope 创建任务响应中未找到 output.task_id: {response.content}")
|
||
|
||
return {"task_id": taskId, "effective_model": effectiveModel}
|
||
|
||
|
||
async def pollTask(params):
|
||
apiKey = getApiKey()
|
||
apiBaseUrl = getApiBaseUrl(params.get("api_base_url"))
|
||
|
||
pollIntervalMs = normalizePositiveInt(params.get("poll_interval_ms"), POLL_INTERVAL_MS)
|
||
maxWaitTimeMs = normalizePositiveInt(params.get("max_wait_time_ms"), MAX_WAIT_TIME_MS)
|
||
|
||
startTime = int(time.time() * 1000)
|
||
attempts = 0
|
||
|
||
while int(time.time() * 1000) - startTime < maxWaitTimeMs:
|
||
attempts += 1
|
||
|
||
endpoint = getTaskEndpoint(apiBaseUrl, params["task_id"])
|
||
headers = {
|
||
"accept": "application/json",
|
||
"Authorization": f"Bearer {apiKey}"
|
||
}
|
||
|
||
request = client.newRequest().url(endpoint).method("GET").headers(headers)
|
||
|
||
response = await request.build().execute()
|
||
|
||
if not response.isSuccessful():
|
||
raise Exception(f"DashScope 查询任务失败: {response.statusCode} - {response.content}")
|
||
|
||
try:
|
||
parsed = json.loads(response.content)
|
||
except Exception as e:
|
||
raise Exception(f"解析 DashScope 查询任务响应失败: {getErrorMessage(e)}")
|
||
|
||
output = parsed.get("output") if isRecord(parsed) and isRecord(parsed.get("output")) else None
|
||
taskStatus = str(output.get("task_status")) if (output and isinstance(output.get("task_status"), str)) else ""
|
||
|
||
if taskStatus == "SUCCEEDED":
|
||
results = output.get("results") if output else None
|
||
first = results[0] if (isinstance(results, list) and len(results) > 0) else None
|
||
url = first.get("url") if isRecord(first) else None
|
||
if not ((isinstance(url, str) or isinstance(url, (int, float))) and str(url).strip()):
|
||
raise Exception(f"任务已完成但未找到图片 URL: {response.content}")
|
||
return {"image_url": str(url), "task_status": taskStatus}
|
||
|
||
if taskStatus == "FAILED":
|
||
raise Exception(f"任务失败: {response.content}")
|
||
|
||
if attempts % 5 == 0:
|
||
console.log(f"任务状态: {taskStatus or 'UNKNOWN'},继续等待...")
|
||
|
||
await Tools.System.sleep(pollIntervalMs)
|
||
|
||
raise Exception(f"任务超时: 等待超过{math.ceil(maxWaitTimeMs / 60000)}分钟仍未完成")
|
||
|
||
|
||
async def draw_image(params):
|
||
if not params or not params.get("prompt") or not str(params["prompt"]).strip():
|
||
raise Exception("参数 prompt 不能为空。")
|
||
|
||
prompt = str(params["prompt"]).strip()
|
||
|
||
await ensureDirectories()
|
||
|
||
createResult = await createTask({
|
||
"prompt": prompt,
|
||
"model": params.get("model"),
|
||
"size": params.get("size"),
|
||
"n": params.get("n"),
|
||
"negative_prompt": params.get("negative_prompt"),
|
||
"prompt_extend": params.get("prompt_extend"),
|
||
"watermark": params.get("watermark"),
|
||
"api_base_url": params.get("api_base_url")
|
||
})
|
||
|
||
pollResult = await pollTask({
|
||
"task_id": createResult["task_id"],
|
||
"api_base_url": params.get("api_base_url"),
|
||
"poll_interval_ms": params.get("poll_interval_ms"),
|
||
"max_wait_time_ms": params.get("max_wait_time_ms")
|
||
})
|
||
|
||
ext = guessExtensionFromUrl(pollResult["image_url"])
|
||
baseName = buildFileName(prompt, params.get("file_name"))
|
||
filePath = f"{DRAWS_DIR}/{baseName}.{ext}"
|
||
|
||
downloadResult = await Tools.Files.download(pollResult["image_url"], filePath)
|
||
if not downloadResult.get("successful"):
|
||
raise Exception(f"下载图片失败: {downloadResult.get('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": createResult["effective_model"],
|
||
"task_id": createResult["task_id"],
|
||
"task_status": pollResult["task_status"],
|
||
"image_url": pollResult["image_url"],
|
||
"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
|