- 新增 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 专属功能已降级处理(保留接口,返回不支持提示)
304 lines
11 KiB
Python
304 lines
11 KiB
Python
# METADATA
|
||
# {
|
||
# "name": "openai_draw",
|
||
#
|
||
# "display_name": {
|
||
# "zh": "OpenAI 绘图",
|
||
# "en": "OpenAI Draw"
|
||
# },
|
||
# "description": {
|
||
# "zh": "使用 OpenAI 格式的图像生成 API (/v1/images/generations) 根据提示词画图,将图片保存到本地 /sdcard/Download/Operit/plugins/draw/openai_draw/draws/ 目录,并返回 Markdown 图片提示。",
|
||
# "en": "Generate images via an OpenAI-compatible image generation API (/v1/images/generations) from a prompt, save to /sdcard/Download/Operit/plugins/draw/openai_draw/draws/, and return a Markdown image reference."
|
||
# },
|
||
# "category": "Draw",
|
||
# "env": [
|
||
# {
|
||
# "name": "OPENAI_API_KEY",
|
||
# "description": {
|
||
# "zh": "OpenAI API Key(必填)",
|
||
# "en": "OpenAI API key (required)"
|
||
# },
|
||
# "required": true
|
||
# },
|
||
# {
|
||
# "name": "OPENAI_API_BASE_URL",
|
||
# "description": {
|
||
# "zh": "OpenAI API Base URL(可选,不填则默认 https://api.openai.com )",
|
||
# "en": "OpenAI API base URL (optional; defaults to https://api.openai.com)"
|
||
# },
|
||
# "required": false
|
||
# },
|
||
# {
|
||
# "name": "OPENAI_IMAGE_MODEL",
|
||
# "description": {
|
||
# "zh": "默认绘图模型(可选;当 draw_image 未传 model 时使用)",
|
||
# "en": "Default image model (optional; used when draw_image doesn't pass model)"
|
||
# },
|
||
# "required": false
|
||
# }
|
||
# ],
|
||
# "tools": [
|
||
# {
|
||
# "name": "draw_image",
|
||
# "description": {
|
||
# "zh": "根据提示词调用 OpenAI 格式图像生成接口生成图片,保存到本地并返回 Markdown 图片提示。",
|
||
# "en": "Generate an image via an OpenAI-compatible image generation endpoint 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": "模型名称(可选;不传则使用环境变量 OPENAI_IMAGE_MODEL,再不行使用默认值)", "en": "Model name (optional; falls back to env OPENAI_IMAGE_MODEL, then default)" }, "type": "string", "required": false },
|
||
# { "name": "size", "description": { "zh": "图片尺寸,例如 '1024x1024',可选", "en": "Image size, e.g. '1024x1024' (optional)" }, "type": "string", "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": "OpenAI API Base URL(不传则取环境变量 OPENAI_API_BASE_URL 或默认 https://api.openai.com )", "en": "OpenAI API base URL (optional; falls back to env OPENAI_API_BASE_URL or https://api.openai.com)" }, "type": "string", "required": false }
|
||
# ]
|
||
# }
|
||
# ]
|
||
# }
|
||
|
||
import json
|
||
import re
|
||
import time
|
||
import random
|
||
import traceback
|
||
|
||
HTTP_TIMEOUT_MS = 600000
|
||
client = OkHttp.newBuilder()
|
||
client = client.connectTimeout(HTTP_TIMEOUT_MS)
|
||
client = client.readTimeout(HTTP_TIMEOUT_MS)
|
||
client = client.writeTimeout(HTTP_TIMEOUT_MS)
|
||
client = client.build()
|
||
DEFAULT_API_BASE_URL = "https://api.openai.com"
|
||
DEFAULT_MODEL = "gpt-image-1"
|
||
|
||
DRAW_ROOT_DIR = getPluginConfigDir("draw")
|
||
STORAGE_DIR = f"{DRAW_ROOT_DIR}/openai_draw"
|
||
DRAWS_DIR = f"{STORAGE_DIR}/draws"
|
||
|
||
|
||
def getApiKey():
|
||
apiKey = getEnv("OPENAI_API_KEY")
|
||
if not apiKey:
|
||
raise Exception("OPENAI_API_KEY 未配置,请在环境变量中设置 OpenAI 的 API Key。")
|
||
return apiKey
|
||
|
||
|
||
def joinUrl(baseUrl, path):
|
||
normalizedBase = baseUrl if baseUrl.endswith("/") else baseUrl + "/"
|
||
normalizedPath = path[1:] if path.startswith("/") else path
|
||
return normalizedBase + normalizedPath
|
||
|
||
|
||
def getApiBaseUrl(customBaseUrl=None):
|
||
fromParam = (customBaseUrl or "").strip()
|
||
if fromParam:
|
||
return fromParam
|
||
|
||
fromEnv = (getEnv("OPENAI_API_BASE_URL") or "").strip()
|
||
if fromEnv:
|
||
return fromEnv
|
||
|
||
return DEFAULT_API_BASE_URL
|
||
|
||
|
||
def getImageEndpoint(baseUrl):
|
||
trimmed = baseUrl.strip()
|
||
if not trimmed:
|
||
return joinUrl(DEFAULT_API_BASE_URL, "v1/images/generations")
|
||
|
||
if "/v1/images/generations" in trimmed:
|
||
return trimmed
|
||
|
||
# If user passes .../v1, we should append images/generations
|
||
if trimmed.endswith("/v1") or trimmed.endswith("/v1/"):
|
||
return joinUrl(trimmed, "images/generations")
|
||
|
||
# Otherwise append v1/images/generations
|
||
return joinUrl(trimmed, "v1/images/generations")
|
||
|
||
|
||
def sanitizeFileName(name):
|
||
safe = re.sub(r'[\\/:*?"<>|]', '_', name).strip()
|
||
if not safe:
|
||
return f"openai_draw_{int(time.time() * 1000)}"
|
||
return safe[:80]
|
||
|
||
|
||
def buildFileName(prompt, customName=None):
|
||
if customName and len(customName.strip()) > 0:
|
||
return sanitizeFileName(customName)
|
||
shortPrompt = (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.get("successful"):
|
||
console.warn(f"创建目录失败(可能已存在): {dir_} -> {result.get('details')}")
|
||
except Exception as e:
|
||
console.warn(f"创建目录异常: {dir_} -> {getattr(e, 'message', e)}")
|
||
|
||
|
||
def normalizeBase64(base64):
|
||
raw = str(base64 or "").strip()
|
||
if not raw:
|
||
return raw
|
||
|
||
# Some providers may return: data:image/png;base64,xxxx
|
||
prefixIndex = raw.find("base64,")
|
||
if raw.startswith("data:") and prefixIndex >= 0:
|
||
return raw[prefixIndex + len("base64,"):].strip()
|
||
|
||
return raw
|
||
|
||
|
||
async def callOpenAIImageApi(params):
|
||
apiKey = getApiKey()
|
||
apiBaseUrl = getApiBaseUrl(params.get("api_base_url"))
|
||
endpoint = getImageEndpoint(apiBaseUrl)
|
||
|
||
modelFromParam = (params.get("model") or "").strip()
|
||
modelFromEnv = (getEnv("OPENAI_IMAGE_MODEL") or "").strip()
|
||
effectiveModel = modelFromParam or modelFromEnv or DEFAULT_MODEL
|
||
|
||
body = {
|
||
"model": effectiveModel,
|
||
"prompt": params["prompt"],
|
||
"response_format": "b64_json"
|
||
}
|
||
|
||
if params.get("size") and len(params["size"].strip()) > 0:
|
||
body["size"] = params["size"].strip()
|
||
|
||
headers = {
|
||
"accept": "application/json",
|
||
"content-type": "application/json",
|
||
"Authorization": f"Bearer {apiKey}"
|
||
}
|
||
|
||
request = client.newRequest()
|
||
request = request.url(endpoint)
|
||
request = request.method("POST")
|
||
request = request.headers(headers)
|
||
request = request.body(json.dumps(body), "json")
|
||
|
||
response = await request.build().execute()
|
||
|
||
if not response.isSuccessful():
|
||
raise Exception(f"OpenAI 图片 API 调用失败: {response.statusCode} - {response.content}")
|
||
|
||
try:
|
||
parsed = json.loads(response.content)
|
||
except Exception as e:
|
||
msg = str(getattr(e, "message", e))
|
||
raise Exception(f"解析 OpenAI 响应失败: {msg}")
|
||
|
||
data = parsed.get("data") if isinstance(parsed, dict) else None
|
||
item = data[0] if (data and len(data) > 0) else None
|
||
if not item:
|
||
raise Exception("OpenAI 响应中未找到 data[0],请检查接口返回格式。")
|
||
|
||
if item.get("b64_json") and len(str(item["b64_json"]).strip()) > 0:
|
||
return {
|
||
"b64_json": str(item["b64_json"]),
|
||
"revised_prompt": str(item["revised_prompt"]) if item.get("revised_prompt") else None,
|
||
"effective_model": effectiveModel
|
||
}
|
||
|
||
if item.get("url") and len(str(item["url"]).strip()) > 0:
|
||
# Some OpenAI-compatible APIs may return url only. We download it, then read as base64.
|
||
tmpName = f"openai_tmp_{int(time.time() * 1000)}"
|
||
tmpPath = f"{DRAWS_DIR}/{tmpName}.png"
|
||
downloadResult = await Tools.Files.download(str(item["url"]), tmpPath)
|
||
if not downloadResult.get("successful"):
|
||
raise Exception(f"下载图片失败: {downloadResult.get('details')}")
|
||
|
||
readBinary = await Tools.Files.readBinary(tmpPath)
|
||
contentBase64 = readBinary.get("contentBase64") if isinstance(readBinary, dict) else None
|
||
if not contentBase64 or len(str(contentBase64).strip()) == 0:
|
||
raise Exception("读取下载图片失败: contentBase64 为空")
|
||
|
||
try:
|
||
await Tools.Files.deleteFile(tmpPath)
|
||
except Exception:
|
||
# ignore
|
||
pass
|
||
|
||
return {
|
||
"b64_json": str(contentBase64),
|
||
"revised_prompt": str(item["revised_prompt"]) if item.get("revised_prompt") else None,
|
||
"effective_model": effectiveModel
|
||
}
|
||
|
||
raise Exception("OpenAI 响应中未找到 b64_json 或 url,请检查模型/参数以及接口兼容性。")
|
||
|
||
|
||
async def draw_image(params):
|
||
if not params or not params.get("prompt") or len(params.get("prompt", "").strip()) == 0:
|
||
raise Exception("参数 prompt 不能为空。")
|
||
|
||
prompt = params["prompt"].strip()
|
||
|
||
await ensureDirectories()
|
||
|
||
apiResult = await callOpenAIImageApi({
|
||
"prompt": prompt,
|
||
"model": params.get("model"),
|
||
"size": params.get("size"),
|
||
"api_base_url": params.get("api_base_url")
|
||
})
|
||
|
||
baseName = buildFileName(prompt, params.get("file_name"))
|
||
filePath = f"{DRAWS_DIR}/{baseName}.png"
|
||
|
||
writeResult = await Tools.Files.writeBinary(filePath, normalizeBase64(apiResult["b64_json"]))
|
||
if not writeResult.get("successful"):
|
||
raise Exception(f"保存图片失败: {writeResult.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,
|
||
"revised_prompt": apiResult.get("revised_prompt") or None,
|
||
"model": apiResult["effective_model"],
|
||
"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)
|
||
msg = str(getattr(error, "message", error))
|
||
stack = traceback.format_exc() if isinstance(error, Exception) else None
|
||
complete({
|
||
"success": False,
|
||
"message": f"图片生成失败: {msg}",
|
||
"error_stack": stack
|
||
})
|
||
|
||
|
||
exports.draw_image = draw_image_wrapper
|