feat(media): 第二图像后端 gpt_image(unifyllm 网关,pro 档)(bump 0.58.0)

- config/media/unifyllm.yaml + tools/gpt_image.py:仅 prompt 参数(网关忽略
  size/quality,尺寸上游自动定,不支持 i2i),b64 直落 figures/,banner 协议
  同 seedream,价目未知期 cost 记 0、usage tokens 进 units 留对账
- 媒体装配泛化:ArkConfig 通用键名(api_key_env/base_url,ark_* 兜底);
  _choose_image_variant 跨 provider 选 image variant(默认仍 seedream_5,
  本 run 只挂一个图像工具);_MEDIA_TOOLS_BLOCK 拆分段按实挂工具拼装
- web:_list_media_variants 扫 config/media/*.yaml(image/video 清单 +
  admin 门控 catalog);media.js 三处白名单加 gpt_image;prompt 润色 hint
  无 default_size 不再谎报 2048
- gpt_image 进 pro 档,与 seedream 共享 images_per_day 配额
- 手工验证 scripts/test_gpt_image_manual.py 全过(含真调网关落图),
  unittest 201 过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-10 20:47:06 +08:00
parent 9ce992597b
commit 5a8f5bd930
13 changed files with 462 additions and 96 deletions

View File

@ -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 档新增多个国际旗舰模型,覆盖不同任务场景,模型选单中直接选用。

View File

@ -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。状态协议纯文件(`<user_root>/.zcbot_procs/<task_id>/<proc_id>/{proc.json,output.log,exit_code}`,dotfile 用户不可见,exit_code 出现即终态),无 DB 无队列组件。host 模式 detach 独立 wrapper(`core/proc_wrapper.py`,stdlib-only,限时+杀树+截尾 10MB);docker 模式**专用容器** `zcbot-proc-<id>`(`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 既有逻辑,前端零改动)。

7
RUN.md
View File

@ -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_<user_uuid>" /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/<stem>.<hash>.pdf`,再点即时。源文件一改(mtime/size 变)hash 变、自动重转 |

View File

@ -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

View File

@ -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/张,给足余量

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.57.1"
__version__ = "0.58.0"

View File

@ -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` 豆包图像生成 / 改图产物自动落 `<task_dir>/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 理解)产物自动落 `<task_dir>/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 出片**;产物自动落 `<task_dir>/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 不动远端

View File

@ -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:

View File

@ -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())

163
tools/gpt_image.py Normal file
View File

@ -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)
完成后:
- 图片落 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.png` + 同名 `.meta.json`
- usage_events kind="image" 一行(model_profile="unifyllm.<variant>",
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}"
)

View File

@ -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

View File

@ -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 的 <kind> 段 → [(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 ,否则 400user_id 非空 额外过档位门控
config/media/*.yaml image ,否则 400user_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"润色后的文本要给出适合该模型的画面细节(主体/风格/光线/构图)。"
)

View File

@ -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 相对。
// 启发式:把 \ 一律归 /,然后找以 `<wdName>/` 打头的串,要求最后一段含 . (像文件)。
@ -67,7 +68,7 @@ export const ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "seedance"]);
// 协议:tool 返回串首行格式 `[<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;