diff --git a/CHANGELOG.md b/CHANGELOG.md index bb6540a..53626e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.58.0 — 2026-07-10 + +- Pro 档图像生成新增国际模型后端「GPT 生图」:顶栏图像模型下拉可切换。仅支持文字描述生图(改图、指定尺寸/比例请切回豆包 Seedream),单张约 1 分钟,与豆包共用每日图片配额。 + ## 0.57.0 — 2026-07-10 - Pro 档新增多个国际旗舰模型,覆盖不同任务场景,模型选单中直接选用。 diff --git a/PROGRESS.md b/PROGRESS.md index fcd829a..516e777 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 2026-07 +- **07-10 / 0.58.0**:**第二图像后端 gpt_image(unifyllm 网关,pro 档)**——媒体 provider 从豆包单家泛化为多家:新 `config/media/unifyllm.yaml`(image 段 `gpt_image` variant,model=gpt-5.6-sol 渠道)+ 新 tool `tools/gpt_image.py`(仅 prompt 参数——网关实测忽略 size/quality,尺寸上游自动定,不支持 i2i;b64 直落 `figures/`,banner 协议同 seedream,价目未知期 cost 记 0、usage tokens 进 units 留对账)。装配泛化:`ArkConfig.load` 支持通用键名 `api_key_env`/`base_url`(ark_* 旧键兜底);`agent_builder._choose_image_variant` 跨 provider 选 image variant(豆包在前 = 默认仍 seedream_5,本 run 只挂选中的一个图像工具),`_MEDIA_TOOLS_BLOCK` 拆分段按实挂工具拼装(`_media_tools_block`,gpt_image 段自带"不支持改图/尺寸→建议切回 Seedream"红线);web `_list_media_variants` 扫 `config/media/*.yaml`(image/video 清单、admin 门控 catalog 同步泛化,variant key 跨文件唯一、撞名先读生效);`media.js` 三处白名单加 gpt_image(banner/产物 chip/摘要行);prompt 润色 hint 无 default_size 时不再谎报 2048。`gpt_image` 进 pro 档(与 seedream 共享 images_per_day 配额,kind=image 同口径)。手工验证 `scripts/test_gpt_image_manual.py`(variant 选择 4 例 + prompt 块 3 例 + 真调网关落图)全过,unittest 全量 201 过。 - **07-10 / 0.57.0**:**unifyllm 网关接入(5 个国际旗舰模型进 pro 档)**:新档案 `config/models/unifyllm.yaml`(fable5/opus48/sonnet46/gpt56_sol/gemini31_pro,litellm `openai/` 前缀 + `api_base=https://unifyllm.ai/v1` + `UNIFYLLM_API_KEY`,零代码改动),`agent.yaml model_tiers.pro` 加 5 条。实测坑(全记在 yaml 头注释):gpt-5.x 被 litellm 硬拦 temp≠1、gemini-3.1-pro temp=0.3 返回空 → 两者 `optimal_temperature: 1.0`;`parallel_tool_calls` 参数网关接受(claude 实测 2 并行);流式 + include_usage 5 模型全通。新 `scripts/diag_unifyllm.py`(--stream/--temp,列模型 GET /v1/models)。**部署注意:服务器直连 unifyllm.ai TLS 握手失败,需代理出口(HTTPS_PROXY),部署前先跑 diag 验证**。价格字段暂 0(网关价目未知,成本先记 0)。 - **07-10 / 0.56.0**:**后台进程 bg proc(DESIGN §8.12)**——解掉"长脚本被工具超时掐死 / 占死 run / 蓝绿切换陪葬"三连:`shell`/`run_python` 加 `background=true`(模型按预计时长自选,前台超时报错里提示改走后台;默认上限 7200s、cap 86400s、每用户并发 3 个 `ZCBOT_MAX_BG_PROCS`),新 `check_process` 工具查状态/日志尾部/kill。状态协议纯文件(`/.zcbot_procs///{proc.json,output.log,exit_code}`,dotfile 用户不可见,exit_code 出现即终态),无 DB 无队列组件。host 模式 detach 独立 wrapper(`core/proc_wrapper.py`,stdlib-only,限时+杀树+截尾 10MB);docker 模式**专用容器** `zcbot-proc-`(`pool.run_proc_container`,同款硬化/iptables init,product=proc + 无 instance label → 与 sandbox 的 idle reaper/shutdown_all 生命周期解耦,dockerd 托管扛蓝绿)。回收:check_process 见终态顺手 rm 容器 + web lifespan 每小时 `procs.sweep`(终态目录 7d TTL、exited 孤儿容器)。**前端可视 + 通知**:`GET /v1/procs`(用户级)+ `POST /v1/tasks/{id}/procs/{pid}/kill` 两端点(纯文件读取无 DB);`procs.js` 把 `[Background]` 工具结果卡活化(spinner+summary 跳秒+停止按钮,与前台工具卡同体验;历史重渲同样恢复活态/终态定格 exit·耗时)+ running→终态弹 toast(右下角,跨 task 也提醒、点击跳转对应任务)+ **对话锁**(bg proc 运行期 composer 发送→停止、Enter 拦截,观感与前台执行一致,完成的那次轮询解锁;锁仅前端,服务端不 409——停止入口须可达);轮询式(仅有 running proc 时 5s 一拉,触发点:登录/选 task/run 收尾/[Background] 工具结果)——不走 SSE,proc 完成时刻往往没有活跃 run。LLM 超时最终定为**显式化默认 600s + env 可调**(长工具已走后台,LLM 单调用无正当 600s 静默;被掐再调 `ZCBOT_LLM_TIMEOUT_S`)。手工验证 `scripts/test_bgproc_manual.py` 全过(win host 模式:启动/探活/exit 0/日志/kill 137/sweep),unittest 全量 201 过。同批带上:**seedance `resume_task_id` 续查**(超时/中断后拿 cgt_id 跳过提交直接续轮询,不过配额闸不重复计费;fast/pro poll_timeout 统一 1200s)+ **LLM 超时显式化**(`core/llm.py` 显式传 litellm timeout,env `ZCBOT_LLM_TIMEOUT_S` 可调,见后)。 - **07-10 / 0.55.2**:文件面板根目录隐藏系统工作目录:`/v1/files`(仅根层)与 `/v1/folders`(新建任务目录候选)过滤定时任务执行目录 / 渠道镜像对话目录(`web/app.py::_system_wd_names`,DB 回查 `scheduled_job_id`/`channel` 判定而非硬编码名字前缀 —— 孤儿目录(task 已删)无任务入口,刻意留在根目录可见)。只是列表降噪非权限拦截:带 path 直接访问照常放行,点定时任务运行历史 / 微信卡片时文件面板自动跳入该目录不受影响(chat.js 既有逻辑,前端零改动)。 diff --git a/RUN.md b/RUN.md index 926398d..db6a4d0 100644 --- a/RUN.md +++ b/RUN.md @@ -42,8 +42,10 @@ # 共享同一台推理服务 http://182.54.21.126:9000/v1)。涉密任务用户显式选 `local.r1` / `local.qwq` # 代替默认 deepseek_v4.flash;未设 env 时这两条 variant 调用即抛 RuntimeError(其他模型不影响) LOCAL_LLM_API_KEY=... - # unifyllm 网关(config/models/unifyllm.yaml,Claude/GPT/Gemini 国际旗舰,pro 档):可选。 - # 未设:unifyllm.* 5 个 variant 调用即抛 RuntimeError(其他模型不影响)。 + # unifyllm 网关(config/models/unifyllm.yaml 文本模型 + config/media/unifyllm.yaml + # 图像后端 gpt_image,均 pro 档):可选。 + # 未设:unifyllm.* 文本 variant 调用即抛 RuntimeError;gpt_image tool 不注册、 + # 图像模型下拉仍列出但选了也 fallback 回 seedream(其他模型不影响)。 # ★ 服务器直连 unifyllm.ai TLS 握手失败,需代理出口(HTTPS_PROXY); # 部署前 `.venv/Scripts/python.exe scripts/diag_unifyllm.py` 冒烟验证(5 模型 tool-calling) UNIFYLLM_API_KEY=sk-... @@ -863,6 +865,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_" /opt | 点 stop 后流式没立刻停 | streaming 改造后正常路径秒退;若仍卡可能是 ① httpx 连接 close 没立刻关(GC 时机)/ ② 模型 thinking 阶段长时间不吐 chunk,等下一个 chunk 到达才能 poll cancel(罕见) | | `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 | | `seedream` tool 没出现在对话里 | `.env` 没设 `ARK_API_KEY`,build_agent 跳过注册。设了重启 web 即可;无需迁移、无需 DB 改动 | +| 图像模型选了「GPT 生图」却仍走 seedream / 报错 | `.env` 没设 `UNIFYLLM_API_KEY`(gpt_image tool 未注册,静默 fallback 豆包);或服务器没代理出口(直连 unifyllm.ai TLS 失败)。跑 `scripts/diag_unifyllm.py` 验证连通后重启 web | | `document_*` tool 没出现在对话里 | `.env` 没设 `DOCUMENT_SEARCH_API_KEY`,build_agent 跳过注册。设了重启 web 即可;key 不进入 sandbox。 | | 文件区点 `.pptx` 弹"服务器未装 LibreOffice"/ 直接回退下载 | web host(非 sandbox)没装 soffice。`sudo apt-get install -y --no-install-recommends libreoffice-impress fonts-noto-cjk` 后**重启 web**。dev(Windows)`winget install TheDocumentFoundation.LibreOffice`。验:`soffice --version` 或 `python -c "from web.pptx_render import find_soffice; print(find_soffice())"` | | `.pptx` 预览首次慢几秒 | 正常 —— soffice 冷启 + 转换 ~2-4s,转完缓存到源同目录 `.preview/..pdf`,再点即时。源文件一改(mtime/size 变)hash 变、自动重转 | diff --git a/config/agent.yaml b/config/agent.yaml index 3d0966e..fc2a739 100644 --- a/config/agent.yaml +++ b/config/agent.yaml @@ -34,6 +34,7 @@ model_tiers: - unifyllm.gpt56_sol - unifyllm.gemini31_pro - seedream_5 + - gpt_image # 图(config/media/unifyllm.yaml image 段,GPT 生图) - seedance_2_fast - seedance_2_pro skills_dir: skills diff --git a/config/media/unifyllm.yaml b/config/media/unifyllm.yaml new file mode 100644 index 0000000..8c95884 --- /dev/null +++ b/config/media/unifyllm.yaml @@ -0,0 +1,26 @@ +# unifyllm 网关媒体生成档案(第二图像后端;文本模型档案见 config/models/unifyllm.yaml)。 +# +# 接入方式:OpenAI Images API /images/generations,tools/gpt_image.py 直调 +# (复用 core/ark_client.py 的通用 HTTP 封装)。响应返 b64_json,无需二次下载。 +# +# 实测行为(2026-07-10,gpt-5.6-sol 渠道): +# - images 端点按**渠道模型名**路由:model 必须传网关上已开通的名字(gpt-5.6-sol 可, +# gpt-image-1 → 503 model_not_found) +# - size / quality 参数被网关忽略(传 1024x1536 / quality=medium 均不生效),实际尺寸由 +# 上游模型按画面自动决定(实测出过 1254x1254 / 1536x1024),quality 恒 low —— 所以 +# tool 只暴露 prompt,不暴露尺寸/质量(暴露了也不生效,还误导模型) +# - 不支持改图(i2i 需 /images/edits multipart,v1 不接);改图场景切回豆包 Seedream +# - 单张耗时 ~35-40s(慢于 seedream 的 3-5s) +# - 价格:网关未公布价目,price 暂 0(usage tokens 记进 units,拿到价目后回填对账) +# - 服务器需代理出口(直连 unifyllm.ai TLS 失败),同文本模型 + +api_key_env: UNIFYLLM_API_KEY +base_url: https://unifyllm.ai/v1 + +image: + gpt_image: + model_id: gpt-5.6-sol + display_name: GPT 生图 + endpoint: /images/generations + price_cny_per_image: 0 # 网关价目未知,成本先记 0;拿到价目改这里 + 重启 + request_timeout_s: 300 # 实测 ~40s/张,给足余量 diff --git a/core/__init__.py b/core/__init__.py index 69b1624..e3f6a91 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.57.1" +__version__ = "0.58.0" diff --git a/core/agent_builder.py b/core/agent_builder.py index 595de49..3c804ec 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -20,7 +20,7 @@ from __future__ import annotations import os from datetime import datetime from pathlib import Path -from typing import Callable, Optional, Tuple +from typing import Any, Callable, Optional, Tuple from uuid import UUID, uuid4 import yaml @@ -49,6 +49,7 @@ from tools.look_at_image import LookAtImageTool from tools.check_process import CheckProcessTool from tools.run_python import RunPythonTool from tools.seedance import SeedanceTool +from tools.gpt_image import GptImageTool from tools.seedream import SeedreamTool from tools.shell import ShellTool from tools.skill_authoring import ForkSkillTool, SaveSkillTool @@ -69,24 +70,78 @@ from core.asr_lfasr import is_configured as lfasr_configured from core.bocha_client import BochaConfig -# 媒体工具(seedream / seedance)指引:仅当本 run 真的挂了媒体工具(ARK_API_KEY 存在, -# ArkConfig.load() 非 None)才追加进 system prompt —— 没 key 的用户不会看到永远报错的工具, -# 也不该背这段红线。文案与 base 模板里其余工具表平级,放在 _build_system_prompt 里按需拼。 -_MEDIA_TOOLS_BLOCK = """\ - -## 媒体工具(seedream 图 / seedance 视频 / look_at_image 看图) +# 媒体工具指引:仅当本 run 真的挂了对应媒体工具才追加进 system prompt —— 没 key 的 +# 用户不会看到永远报错的工具,也不该背这段红线。分段拼装(_media_tools_block): +# look_at_image / seedance 跟 ARK_API_KEY 走;图像段按本 run 选中的 image variant +# 二选一(seedream=豆包 / gpt_image=unifyllm 网关),同一 run 只挂一个图像工具。 +_MEDIA_LOOK_SEG = """\ - `look_at_image` —— 看图 / 读图(豆包 Seed 2.0 Lite 视觉)。**你(主模型)是纯文本看不见图,要"看"图就调它**:OCR 文字、描述画面、读图表/表格/示意图、识别物体。每次很便宜(按 token,通常 < ¥0.01)。 - **何时调**:用户消息里出现 `[用户上传的参考图] <路径>` 且需要据图内容回答(问"这图里写了啥 / 是什么 / 表格数据多少");或要基于 task 内某张图(`figures/xxx.png`)的**实际内容**做事(不是改图,改图走 seedream)。传 `image=<路径>` + 可选 `question`。 - - **何时不调**:用户只是要改图(走 seedream i2i)/ 只要文件名不关心内容 / 图是你自己刚生成的且 prompt 已知(无需再读)。别对同一张图无意义反复看(每次都烧 token)。 + - **何时不调**:用户只是要改图(走 seedream i2i)/ 只要文件名不关心内容 / 图是你自己刚生成的且 prompt 已知(无需再读)。别对同一张图无意义反复看(每次都烧 token)。""" +_MEDIA_SEEDREAM_SEG = """\ - `seedream` —— 豆包图像生成 / 改图。产物自动落 `/figures/`。每次 **¥0.22**(联网 `search=true` 加 ¥0.05)。 - **文生图**(不传 `reference_images`):从零按 prompt 画。**改图 i2i**(传 `reference_images=["figures/xxx.png"]`):在已有图上做像素级修改。**用户对刚生成 / 上传的图说"改成 X / 换个颜色 / 去掉某处" → 必须走改图(reference_images 指那张图),绝不重新文生图**(重画 = 完全不同的图,丢原构图)。v1 改图仅支持单张参考。 - **调用前必须先 `load_skill('imagegen')`** —— skill 里有「何时该用 / 该不该用 mermaid 替代 / 用户描述模糊度诊断 / 一次性追问范式 / prompt 装配 / 改图(i2i)范式 / 失败解药」全套引导。**不要拿用户原话直接当 prompt 调 tool** —— 容易烧 ¥0.22 在错的方向上。 - - 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调。用户消息里出现 `[用户上传的参考图] <路径>` = 用户贴了图,要看图 / 改图时用那个路径。 + - 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调。用户消息里出现 `[用户上传的参考图] <路径>` = 用户贴了图,要看图 / 改图时用那个路径。""" +_MEDIA_GPT_IMAGE_SEG = """\ +- `gpt_image` —— GPT 图像生成(本 run 用户在顶栏选了「GPT 生图」,seedream 不可用;其他地方提到 seedream 的指引按 gpt_image 理解)。产物自动落 `/figures/`,单张 **~40s**(慢,调用前告知用户稍等)。 + - **只支持文生图**:不支持改图(i2i)、不支持指定尺寸/比例(尺寸由模型按画面自动决定,常见 1024-1536 边长)。用户要改已有图 / 要精确尺寸或宽高比 → 直接说明并建议顶栏把图像模型切回「豆包 Seedream」,**不要硬用文生图凑**。 + - **调用前必须先 `load_skill('imagegen')`** —— 其中「何时该用 / mermaid 反向选型 / 模糊度诊断 / prompt 装配 / 先给用户过目再调」的流程完全适用;但参数以本工具 schema 为准(只有 prompt,size/watermark/search 段忽略)。 + - 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调。""" +_MEDIA_SEEDANCE_SEG = """\ - `seedance` —— 豆包视频生成(Seedance 2.0 Fast)。异步任务,**等 30-90s 出片**;产物自动落 `/videos/`。每次 **¥1.86 起**(480p 4s)~ **¥12+**(720p 15s),比图贵 10 倍以上。触发词:视频 / 动画 / 动起来 / 做个 video / 镜头 / 短片 / 演示视频 / 动效。 - **调用前必须先 `load_skill('videogen')`** —— skill 里有「6 维诊断(含运动维必填)/ seedream/mermaid 反向选型 / prompt 装配 / 参数取舍(时长/分辨率/比例直接决定钱)/ 失败解药」全套引导。视频比图贵 10 倍且 90s 等待,绝对不要拿用户原话当 prompt 直接调。 - 兜底硬约束:用户没主动要视频就别装饰性生成(比生图更严重的红线);同一目的不满意**绝不连发**(1 次错 = ¥4+60s,连发 2 次 = ¥8+2min);phase 1 仅文生视频,**不支持** image-to-video / video-to-video。""" +def _media_tools_block(has_ark: bool, image_tool: str) -> str: + """按本 run 实际挂载的媒体工具拼指引段;一个都没挂 → 返 ""(prompt 不出现媒体段)。 + + image_tool: "seedream" / "gpt_image" / ""(无图像工具)。look_at_image 与 seedance + 只跟豆包(ARK_API_KEY)走;图像工具二选一,prompt 只描述实际存在的那个。 + """ + segs: list[str] = [] + if has_ark: + segs.append(_MEDIA_LOOK_SEG) + if image_tool == "seedream": + segs.append(_MEDIA_SEEDREAM_SEG) + elif image_tool == "gpt_image": + segs.append(_MEDIA_GPT_IMAGE_SEG) + if has_ark: + segs.append(_MEDIA_SEEDANCE_SEG) + if not segs: + return "" + return "## 媒体工具(图像生成 / 视频生成 / 看图)\n" + "\n".join(segs) + + +def _choose_image_variant( + ark_cfg, gw_cfg, image_variant: str +) -> tuple[str, str, Optional[dict], Any]: + """跨 provider 选本 run 的 image variant → (provider, variant_key, variant_cfg, provider_cfg)。 + + provider: "doubao"(→ SeedreamTool)/ "unifyllm"(→ GptImageTool)。variant key 跨 + provider 全局唯一(seedream_5 / gpt_image),web 入口按同一命名空间校验+门控。 + 优先级:显式 image_variant 命中谁就用谁;空/不认 → 第一个有 image 段的 provider 的 + 第一个 variant(豆包在前 = 默认仍 seedream,沿用原行为)。都没有 → (\"\", \"\", None, None)。 + """ + sources = [] + if ark_cfg is not None: + sources.append(("doubao", ark_cfg)) + if gw_cfg is not None: + sources.append(("unifyllm", gw_cfg)) + if image_variant: + for provider, pcfg in sources: + v = (pcfg.raw.get("image") or {}).get(image_variant) + if isinstance(v, dict): + return provider, image_variant, v, pcfg + # 不认的 variant 静默退 fallback —— web 入口已校验过;留兜底防 yaml 改动 + for provider, pcfg in sources: + for variant_key, variant_cfg in (pcfg.raw.get("image") or {}).items(): + if isinstance(variant_cfg, dict): + return provider, variant_key, variant_cfg, pcfg + return "", "", None, None + + # 运行环境段(按 backend 注入,general_v1.md 的「平台」段指向这里)。环境事实(在哪 / # 能否联网 / 装了啥)是全局不变量,放 system 比塞进某个 skill 高杠杆 —— 一句话省掉一整类 # 试错(外网试错 / 平台命令试错)。docker = 线上真实形态(Ubuntu 容器,无外网);host = @@ -269,7 +324,7 @@ def _build_system_prompt( task_id: UUID, task_name: str, task_skill: str = "", - media_enabled: bool = False, + media_block: str = "", ) -> str: """拼 system prompt: 模板 + skill 列表 + memory + 工作目录段 + task 上下文 + 命名约定。 @@ -296,8 +351,8 @@ def _build_system_prompt( user_root(workspace_dir, user_id) / ".memory" ) prompt += memory_block(workspace_dir, user_id, mem_dir_display) - if media_enabled: - prompt += "\n\n" + _MEDIA_TOOLS_BLOCK + if media_block: + prompt += "\n\n" + media_block wd_abs = working_dir.resolve() if is_docker: try: @@ -434,9 +489,16 @@ def build_agent( is_docker = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker" skills = build_skill_registry(cfg, workspace_dir, uid, docker=is_docker) - # 媒体配置提前 load 一次:既决定 system prompt 要不要追加媒体段(media_enabled), - # 也复用给下方 seedream/seedance 注册(避免重复读 doubao.yaml)。无 ARK_API_KEY → None。 + # 媒体配置提前 load 一次:既决定 system prompt 的媒体段拼装,也复用给下方媒体工具 + # 注册(避免重复读 yaml)。ark(豆包)缺 ARK_API_KEY → None;unifyllm 网关同理。 + # image variant 跨 provider 选择(seedream_5=豆包 / gpt_image=unifyllm),本 run 只挂 + # 选中的那一个图像工具;video / vision 仍豆包独有。 ark_cfg = ArkConfig.load() + gw_cfg = ArkConfig.load(ROOT / "config" / "media" / "unifyllm.yaml") + img_provider, img_key, img_cfg, img_provider_cfg = _choose_image_variant( + ark_cfg, gw_cfg, image_variant + ) + image_tool_name = {"doubao": "seedream", "unifyllm": "gpt_image"}.get(img_provider, "") now_iso = datetime.now().isoformat(timespec="seconds") # meta["working_dir"] 是 db 形态(相对 ROOT 或绝对);Session.append → ensure_local_task_row @@ -470,7 +532,7 @@ def build_agent( system_prompt = _build_system_prompt( cfg, skills, workspace_dir, tool_base, working_dir_path, uid, task_id, task_state.name, task_state.skill, - media_enabled=ark_cfg is not None, + media_block=_media_tools_block(ark_cfg is not None, image_tool_name), ) meta = { @@ -601,30 +663,15 @@ def build_agent( images_per_day = int(quotas.get("images_per_day", 0)) videos_per_day = int(quotas.get("videos_per_day", 0)) - # 媒体生成 tool(豆包 seedream / 后续 seedance):仅当 ARK_API_KEY 设了才挂 —— - # 没 key 的用户无感知,不至于看到 schema 里突然多个永远报错的工具。 - # image_variant 由 caller 传(web 入口随消息 POST 带);空 → 取 yaml 第一个 variant - # (fallback,沿用原行为)。本次 run 装的 SeedreamTool 锁定该 variant,本 run 内的 - # 多次 tool call 全用同一个;下一条消息可以重选。 - # ark_cfg 已在函数上半部 load 过(复用,顺带决定 system prompt 的 media 段)。 - if ark_cfg is not None: - image_cfg = (ark_cfg.raw.get("image") or {}) - chosen_key, chosen_cfg = "", None - if image_variant: - v = image_cfg.get(image_variant) - if isinstance(v, dict): - chosen_key, chosen_cfg = image_variant, v - # 不认的 variant 静默退到 fallback —— web 入口已校验过;留兜底防 yaml 改动 - if chosen_cfg is None: - for variant_key, variant_cfg in image_cfg.items(): - if isinstance(variant_cfg, dict): - chosen_key, chosen_cfg = variant_key, variant_cfg - break - if chosen_cfg is not None: - seedream_tool = SeedreamTool( - ark_cfg=ark_cfg, - image_variant_cfg=chosen_cfg, - variant_key=chosen_key, + # 图像生成 tool:跨 provider 二选一(选择已在函数上半部 _choose_image_variant 定好, + # 与 system prompt 媒体段同源)。没任何 provider key → 不挂,用户无感知。 + # 本次 run 锁定该 variant,run 内多次 tool call 全用同一个;下一条消息可重选。 + if img_cfg is not None: + if img_provider == "doubao": + image_tool: Any = SeedreamTool( + ark_cfg=img_provider_cfg, + image_variant_cfg=img_cfg, + variant_key=img_key, working_dir=working_dir_path, task_id=task_id, user_id=uid, @@ -632,9 +679,23 @@ def build_agent( user_root=ur_path, daily_limit=images_per_day, ) - tools[seedream_tool.name] = seedream_tool + else: # unifyllm + image_tool = GptImageTool( + gw_cfg=img_provider_cfg, + image_variant_cfg=img_cfg, + variant_key=img_key, + working_dir=working_dir_path, + task_id=task_id, + user_id=uid, + base_dir=tool_base, + user_root=ur_path, + daily_limit=images_per_day, + ) + tools[image_tool.name] = image_tool - # 视频 variant 选择(同上 image_variant 范式):video_variant 由 caller 传, + # 视频 / 看图 tool 仍豆包独有:仅当 ARK_API_KEY 设了才挂。 + if ark_cfg is not None: + # 视频 variant 选择(同 image_variant 范式):video_variant 由 caller 传, # 空 → 取 yaml 第一个 video variant。本 run 的 SeedanceTool 锁定该 variant。 # cancel_check 是 web 入口构造的 `lambda: broker.is_cancelled(task_id)` —— 轮询 # 期间(典型 30-90s)拿来响应用户停止按钮;远端 cgt 任务无 cancel API,best-effort 不动远端 diff --git a/core/ark_client.py b/core/ark_client.py index b66e654..d84d9fc 100644 --- a/core/ark_client.py +++ b/core/ark_client.py @@ -39,8 +39,10 @@ class ArkConfig: @classmethod def load(cls, path: Optional[Path] = None) -> Optional["ArkConfig"]: - """读 doubao.yaml + 解析 env 拿 api_key。 + """读媒体 provider yaml(默认 doubao.yaml)+ 解析 env 拿 api_key。 + 配置键通用名 `api_key_env` / `base_url`(unifyllm.yaml 等新 provider 用); + `ark_api_key_env` / `ark_base_url` 是 doubao.yaml 的历史键名,兜底兼容。 api_key env 未设 → 返 None(caller 据此决定是否注册 tool;无 key 用户无感知)。 yaml 不存在 → 返 None。 """ @@ -48,15 +50,16 @@ class ArkConfig: if not p.exists(): return None data = yaml.safe_load(p.read_text(encoding="utf-8")) or {} - env = data.get("ark_api_key_env") or "ARK_API_KEY" + env = data.get("api_key_env") or data.get("ark_api_key_env") or "ARK_API_KEY" key = os.environ.get(env, "").strip() if not key: return None - return cls( - api_key=key, - base_url=str(data.get("ark_base_url") or "https://ark.cn-beijing.volces.com/api/v3").rstrip("/"), - raw=data, + base = ( + data.get("base_url") + or data.get("ark_base_url") + or "https://ark.cn-beijing.volces.com/api/v3" ) + return cls(api_key=key, base_url=str(base).rstrip("/"), raw=data) class ArkClient: diff --git a/scripts/test_gpt_image_manual.py b/scripts/test_gpt_image_manual.py new file mode 100644 index 0000000..5a7da9f --- /dev/null +++ b/scripts/test_gpt_image_manual.py @@ -0,0 +1,100 @@ +"""gpt_image 第二图像后端手工验证(host 模式,真实调网关,烧 1 张图额度)。 + +用法(.env 在仓库根, 含 UNIFYLLM_API_KEY): + .venv/Scripts/python.exe scripts/test_gpt_image_manual.py [--dry] + +--dry: 只验证配置装配 / variant 选择 / prompt 块,不真调 API。 +输出一律 ASCII(Windows 控制台 GBK)。 +""" +import os +import sys +import tempfile +import uuid +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +env_file = ROOT / ".env" +if env_file.exists(): + for line in env_file.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) + + +def main() -> int: + dry = "--dry" in sys.argv + fails = 0 + + from core.ark_client import ArkConfig + from core.agent_builder import _choose_image_variant, _media_tools_block + + gw = ArkConfig.load(ROOT / "config" / "media" / "unifyllm.yaml") + ark = ArkConfig.load() + print(f"[info] ark_cfg={'set' if ark else 'None'} gw_cfg={'set' if gw else 'None'}") + if gw is None: + print("[FAIL] unifyllm.yaml 未装配(UNIFYLLM_API_KEY 缺失?)") + return 1 + + # variant 选择:显式 gpt_image / 显式 seedream_5 / 空 fallback + cases = [ + ("gpt_image", "unifyllm", "gpt_image"), + ("seedream_5", "doubao" if ark else "unifyllm", "seedream_5" if ark else "gpt_image"), + ("", "doubao" if ark else "unifyllm", "seedream_5" if ark else "gpt_image"), + ("nonsense", "doubao" if ark else "unifyllm", "seedream_5" if ark else "gpt_image"), + ] + for variant, want_prov, want_key in cases: + prov, key, cfg, pcfg = _choose_image_variant(ark, gw, variant) + ok = prov == want_prov and key == want_key and cfg is not None and pcfg is not None + print(f"[{'OK' if ok else 'FAIL'}] choose({variant!r}) -> {prov}.{key}") + fails += 0 if ok else 1 + + # prompt 媒体段拼装 + blk = _media_tools_block(ark is not None, "gpt_image") + ok = "`gpt_image`" in blk and "`seedream`" not in blk.split("\n")[0] and "reference_images" not in blk.split("- `gpt_image`")[1].split("- `")[0] + print(f"[{'OK' if ok else 'FAIL'}] media block(gpt_image) mentions gpt_image, no i2i params") + fails += 0 if ok else 1 + blk2 = _media_tools_block(ark is not None, "seedream") + ok = ("- `seedream`" in blk2) and ("- `gpt_image`" not in blk2) + print(f"[{'OK' if ok else 'FAIL'}] media block(seedream) unchanged shape") + fails += 0 if ok else 1 + blk3 = _media_tools_block(False, "") + ok = blk3 == "" + print(f"[{'OK' if ok else 'FAIL'}] media block(none) empty") + fails += 0 if ok else 1 + + if dry: + return 1 if fails else 0 + + # 真实调用:临时 working_dir,不限额(daily_limit=0),无 DB(record 失败仅打印) + from tools.gpt_image import GptImageTool + + prov, key, cfg, pcfg = _choose_image_variant(ark, gw, "gpt_image") + with tempfile.TemporaryDirectory() as td: + wd = Path(td) / "wd" + wd.mkdir() + tool = GptImageTool( + gw_cfg=pcfg, image_variant_cfg=cfg, variant_key=key, + working_dir=wd, task_id=uuid.uuid4(), user_id=uuid.uuid4(), + base_dir=Path(td), user_root=Path(td), daily_limit=0, + ) + result = tool.execute(prompt="A minimalist flat-design icon of a cement mixer truck, orange and grey") + first = result.split("\n")[0] + print(f"[info] result first line: {first}") + pngs = list(wd.glob("figures/*.png")) + metas = list(wd.glob("figures/*.meta.json")) + ok = result.startswith("[gpt_image]") and len(pngs) == 1 and len(metas) == 1 and pngs[0].stat().st_size > 10000 + print(f"[{'OK' if ok else 'FAIL'}] png saved: {pngs[0].name if pngs else 'NONE'} " + f"({pngs[0].stat().st_size if pngs else 0} bytes), meta: {len(metas)}") + fails += 0 if ok else 1 + ok = "saved: " in result and "prompt=" in result + print(f"[{'OK' if ok else 'FAIL'}] banner protocol lines present") + fails += 0 if ok else 1 + + print(f"[{'OK' if not fails else 'FAIL'}] total fails={fails}") + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/gpt_image.py b/tools/gpt_image.py new file mode 100644 index 0000000..733a861 --- /dev/null +++ b/tools/gpt_image.py @@ -0,0 +1,163 @@ +"""gpt_image: 调 unifyllm 网关的 OpenAI Images API 生图,产物落 working_dir/figures/。 + +第二图像后端(第一个是豆包 seedream):模型 ID + 单价全在 `config/media/unifyllm.yaml`, +本 tool 只装配。与 seedream 的差异(网关实测,见 yaml 头注释): +- 只支持文生图,**不支持改图(i2i)/ 自定尺寸 / 质量档**(网关忽略 size/quality 参数, + 实际尺寸由上游模型按画面自动决定),所以参数只有 prompt; +- 响应直接返 b64_json,无需二次下载; +- 单张 ~35-40s(慢于 seedream 3-5s)。 +完成后: +- 图片落 `/figures/-.png` + 同名 `.meta.json` +- usage_events 写 kind="image" 一行(model_profile="unifyllm.", + usage tokens 记进 units → 网关价目公布后可回填对账) +""" +from __future__ import annotations + +import base64 +import json +import secrets +import time +from datetime import datetime +from pathlib import Path +from typing import Optional +from uuid import UUID + +from core.ark_client import ArkClient, ArkConfig, ArkError +from core.storage.usage import check_daily_quota, record_image_usage + +from .base import Tool + + +class GptImageTool(Tool): + name = "gpt_image" + description = ( + "Generate an image (text-to-image only) via the GPT image backend, " + "saved to working_dir/figures/. Slower than seedream (~40-60s/image); output size is " + "chosen automatically by the model and CANNOT be specified. Does NOT support " + "image-to-image editing — if the user wants to modify an existing image or needs an " + "exact size/aspect ratio, tell them to switch the image model back to 豆包 Seedream " + "(顶栏图像模型下拉). Don't generate decoratively — only when the user actually " + "wants an image. Returns the saved relative path." + ) + parameters = { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "中文或英文都行,详尽描述画面(主体/风格/光线/构图)。尺寸/比例不可控,别在 prompt 里承诺具体分辨率。", + }, + }, + "required": ["prompt"], + } + + def __init__( + self, + *, + gw_cfg: ArkConfig, + image_variant_cfg: dict, + variant_key: str, + working_dir: Path, + task_id: UUID, + user_id: UUID, + base_dir: Optional[Path] = None, + user_root: Optional[Path] = None, + daily_limit: int = 0, + ) -> None: + super().__init__(base_dir, user_root=user_root) + self.gw_cfg = gw_cfg + self.cfg = image_variant_cfg + self.variant_key = variant_key # 'gpt_image' → usage_events.model_profile = "unifyllm.gpt_image" + self.working_dir = Path(working_dir) + self.task_id = task_id + self.user_id = user_id + self.daily_limit = int(daily_limit) # 0 / 负 = 不限;与 seedream 共享 kind="image" 每日配额 + + def execute(self, prompt: str) -> str: + if not (prompt or "").strip(): + return "[Error] prompt 不能为空" + + # 每账号每日配额(kind="image" 与 seedream 同口径合计;失败不落库故 retry 不计) + if self.daily_limit > 0: + used, over = check_daily_quota(user_id=self.user_id, kind="image", limit=self.daily_limit) + if over: + return ( + f"[Error] 已达每日图片生成上限({used}/{self.daily_limit} 张)," + f"次日 00:00 重置。" + ) + + cfg = self.cfg + model_id = cfg["model_id"] + endpoint = cfg.get("endpoint", "/images/generations") + timeout_s = float(cfg.get("request_timeout_s", 300)) + price = float(cfg.get("price_cny_per_image", 0)) + + body = {"model": model_id, "prompt": prompt, "n": 1} + + t0 = time.monotonic() + try: + with ArkClient(self.gw_cfg, timeout_s=timeout_s) as client: + resp = client.post_json(endpoint, body, timeout_s=timeout_s) + except ArkError as e: + return f"[Error] gpt_image API: {e}" + + data = resp.get("data") + b64 = "" + if isinstance(data, list) and data and isinstance(data[0], dict): + b64 = data[0].get("b64_json") or "" + if not b64: + return f"[Error] gpt_image response 缺 b64_json: {json.dumps(resp, ensure_ascii=False)[:300]}" + try: + img_bytes = base64.b64decode(b64) + except Exception as e: + return f"[Error] gpt_image b64 解码失败: {e}" + + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + short = secrets.token_hex(3) + dest_png = self.working_dir / "figures" / f"{ts}-{short}.png" + dest_png.parent.mkdir(parents=True, exist_ok=True) + dest_png.write_bytes(img_bytes) + + elapsed = time.monotonic() - t0 + # 网关回填的实际尺寸/质量/tokens(价目未知期成本记 price snapshot,tokens 留对账) + actual_size = str(resp.get("size") or "") + quality = str(resp.get("quality") or "") + usage = resp.get("usage") or {} + output_tokens = int(usage.get("output_tokens") or 0) + + meta = { + "prompt": prompt, + "model_id": model_id, + "size": actual_size, + "quality": quality, + "mode": "t2i", + "cost_cny": price, + "output_tokens": output_tokens, + "elapsed_s": round(elapsed, 2), + "ts": datetime.now().isoformat(timespec="seconds"), + } + dest_png.with_suffix(".meta.json").write_text( + json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + try: + record_image_usage( + task_id=self.task_id, + user_id=self.user_id, + model_profile=f"unifyllm.{self.variant_key}", + n_images=1, + size=actual_size, + price_cny_per_image=price, + extra_units={"output_tokens": output_tokens, "quality": quality}, + ) + except Exception as e: + print(f"[gpt_image] record_image_usage failed: {type(e).__name__}: {e}", flush=True) + + disp = self._display(dest_png) + # 首行 banner 协议同 seedream(`key=value · ` 分隔,前端 extractMediaBanner 解析); + # 价格未知(price=0)时不放 cost 段,避免"¥0.00 = 免费"的误导。 + cost_seg = f" · cost=¥{price:.2f}" if price > 0 else "" + return ( + f"[gpt_image] model={model_id} · size={actual_size}{cost_seg} · elapsed={elapsed:.1f}s\n" + f"saved: {disp}\n" + f"prompt={prompt!r}" + ) diff --git a/web/admin.py b/web/admin.py index 65df07c..dc16c86 100644 --- a/web/admin.py +++ b/web/admin.py @@ -285,7 +285,7 @@ def _storage_page(s: Any, page: int, page_size: int) -> dict: def _model_catalog() -> list[dict]: """全部可门控模型清单 [{id, display_name, kind}]:文本(config/models/*.yaml)+ - 图/视频(config/media/doubao.yaml)。给档位编辑 UI 画图例(id → 显示名)。 + 图/视频(config/media/*.yaml,跨 provider)。给档位编辑 UI 画图例(id → 显示名)。 """ from core.capabilities import ModelCapabilities from core.paths import ROOT @@ -307,16 +307,19 @@ def _model_catalog() -> list[dict]: except (ValueError, FileNotFoundError): continue out.append({"id": profile, "display_name": caps.display_name or profile, "kind": "text"}) - media = ROOT / "config" / "media" / "doubao.yaml" - if media.exists(): - try: - mdata = _yaml.safe_load(media.read_text(encoding="utf-8")) or {} - except Exception: - mdata = {} - for kind in ("image", "video"): - for key, cfg in (mdata.get(kind) or {}).items(): - if isinstance(cfg, dict): - out.append({"id": key, "display_name": cfg.get("display_name") or key, "kind": kind}) + media_dir = ROOT / "config" / "media" + seen: set[str] = set() + if media_dir.is_dir(): + for path in sorted(media_dir.glob("*.yaml")): + try: + mdata = _yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception: + continue + for kind in ("image", "video"): + for key, cfg in (mdata.get(kind) or {}).items(): + if isinstance(cfg, dict) and key not in seen: + seen.add(key) + out.append({"id": key, "display_name": cfg.get("display_name") or key, "kind": kind}) return out diff --git a/web/app.py b/web/app.py index fed70b5..6246390 100644 --- a/web/app.py +++ b/web/app.py @@ -674,32 +674,45 @@ async def _transcribe_wecom_voice(media_id: str) -> str: return await audio.transcribe_voice(data) -def _list_image_variants() -> list[tuple[str, dict]]: - """扫 config/media/doubao.yaml image 段 → [(variant_key, variant_cfg), ...]。 +def _list_media_variants(kind: str) -> list[tuple[str, dict]]: + """扫 config/media/*.yaml 的 段 → [(variant_key, variant_cfg), ...]。 - yaml 不存在或 image 段空 / 仅注释 → 返 []。不要求 ARK_API_KEY 已设 —— 仅纯 - 元数据列举,UI 拉这个画下拉。真正调用 seedream 时 agent_builder 那边再过 - `ArkConfig.load()`(没 key → tool 不注册)。 + 多 provider(doubao / unifyllm)合并列举,文件名排序保证豆包在前(→ is_default + 仍落 seedream)。variant key 约定跨文件唯一;万一撞名先读的生效(后者跳过)。 + 目录不存在或段空 / 仅注释 → 返 []。不要求对应 API key 已设 —— 仅纯元数据列举, + UI 拉这个画下拉。真正调用时 agent_builder 那边再过 `ArkConfig.load()` + (没 key → tool 不注册)。 """ from core.paths import ROOT import yaml as _yaml - p = ROOT / "config" / "media" / "doubao.yaml" - if not p.exists(): + media_dir = ROOT / "config" / "media" + if not media_dir.is_dir(): return [] - try: - data = _yaml.safe_load(p.read_text(encoding="utf-8")) or {} - except Exception: - return [] - image_cfg = data.get("image") or {} - return [(k, v) for k, v in image_cfg.items() if isinstance(v, dict)] + out: list[tuple[str, dict]] = [] + seen: set[str] = set() + for p in sorted(media_dir.glob("*.yaml")): + try: + data = _yaml.safe_load(p.read_text(encoding="utf-8")) or {} + except Exception: + continue + for k, v in (data.get(kind) or {}).items(): + if isinstance(v, dict) and k not in seen: + seen.add(k) + out.append((k, v)) + return out + + +def _list_image_variants() -> list[tuple[str, dict]]: + """图像 variant 清单(跨 provider,见 _list_media_variants)。""" + return _list_media_variants("image") def _resolve_image_model(variant: str, user_id: Optional[UUID] = None) -> str: """校验 image_model variant key。 传空 → 返空(agent_builder fallback 到第一个 variant);传非空 → 必须存在 - 于 config/media/doubao.yaml image 段,否则 400。user_id 非空 → 额外过档位门控。 + 于 config/media/*.yaml 的 image 段,否则 400。user_id 非空 → 额外过档位门控。 """ name = (variant or "").strip() if not name: @@ -712,22 +725,8 @@ def _resolve_image_model(variant: str, user_id: Optional[UUID] = None) -> str: def _list_video_variants() -> list[tuple[str, dict]]: - """扫 config/media/doubao.yaml video 段 → [(variant_key, variant_cfg), ...]。 - - 与 _list_image_variants 同范式;空 video 段(未上线 / 注释掉)→ 返 [],UI 隐藏下拉。 - """ - from core.paths import ROOT - import yaml as _yaml - - p = ROOT / "config" / "media" / "doubao.yaml" - if not p.exists(): - return [] - try: - data = _yaml.safe_load(p.read_text(encoding="utf-8")) or {} - except Exception: - return [] - video_cfg = data.get("video") or {} - return [(k, v) for k, v in video_cfg.items() if isinstance(v, dict)] + """视频 variant 清单(跨 provider,见 _list_media_variants);空 → UI 隐藏下拉。""" + return _list_media_variants("video") def _resolve_video_model(variant: str, user_id: Optional[UUID] = None) -> str: @@ -1848,7 +1847,7 @@ def create_app() -> FastAPI: @app.get("/v1/image_models", tags=["misc"]) def list_image_models(user_id: UUID = Depends(require_user)): - """图像生成模型清单(扫 config/media/doubao.yaml image 段)。 + """图像生成模型清单(扫 config/media/*.yaml image 段,跨 provider)。 前端顶栏第二个下拉拉这个;空列表 → 没配 image variant 或本档无授权,UI 隐藏下拉。 按用户档位过滤;`is_default` 标过滤后第一个 variant。开发期不缓存,改 YAML 立即生效。 @@ -3032,9 +3031,10 @@ def create_app() -> FastAPI: for k, v in _list_image_variants(): if k == img_variant: name = v.get("display_name") or k - sz = v.get("default_size") or "2048x2048" + sz = v.get("default_size") + size_seg = f"默认尺寸 {sz}" if sz else "输出尺寸固定不可调" image_variant_hint = ( - f"\n下游生图工具:{name}(默认尺寸 {sz},支持中英文 prompt," + f"\n下游生图工具:{name}({size_seg},支持中英文 prompt," f"擅长写实/插画/构图描述)。若用户意图涉及画面/封面/插图," f"润色后的文本要给出适合该模型的画面细节(主体/风格/光线/构图)。" ) diff --git a/web/static/js/media.js b/web/static/js/media.js index 77b972f..4ceae3f 100644 --- a/web/static/js/media.js +++ b/web/static/js/media.js @@ -33,6 +33,7 @@ export function toolActivityLabel(name, args) { case "web_search": return `联网搜索: ${clip(a.query, 60)}`; case "load_skill": return `加载技能: ${clip(a.name, 40)}`; case "seedream": return `生成图像: ${clip(a.prompt, 60)}`; + case "gpt_image": return `生成图像: ${clip(a.prompt, 60)}`; case "seedance": return `生成视频: ${clip(a.prompt, 60)}`; default: { const p = clip(JSON.stringify(a), 80); @@ -58,7 +59,7 @@ export function _workingDirName(workingDir) { // 可发现性入口,小图标无视觉污染。 // 注:与 extractMediaBanner 的"媒体 banner"白名单是不同维度 —— 将来若新增 // "生成 docx 的工具",入这里但不入 banner 白名单。 -export const ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "seedance"]); +export const ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "gpt_image", "seedance"]); // 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。 // 启发式:把 \ 一律归 /,然后找以 `/` 打头的串,要求最后一段含 . (像文件)。 @@ -67,7 +68,7 @@ export const ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "seedance"]); // 协议:tool 返回串首行格式 `[] key=value · key=value · ...` export function extractMediaBanner(toolName, resultText) { if (!resultText) return ""; - if (toolName !== "seedream" && toolName !== "seedance") return ""; + if (toolName !== "seedream" && toolName !== "gpt_image" && toolName !== "seedance") return ""; const firstLine = String(resultText).split("\n", 1)[0] || ""; // 抓 key=value(value 可含空格 / : / ., 用 · 或行尾结束) const re = /(\w+)=([^·\n]+?)(?=\s*·|\s*$)/g;