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:
parent
9ce992597b
commit
5a8f5bd930
|
|
@ -5,6 +5,10 @@
|
||||||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||||
|
|
||||||
|
## 0.58.0 — 2026-07-10
|
||||||
|
|
||||||
|
- Pro 档图像生成新增国际模型后端「GPT 生图」:顶栏图像模型下拉可切换。仅支持文字描述生图(改图、指定尺寸/比例请切回豆包 Seedream),单张约 1 分钟,与豆包共用每日图片配额。
|
||||||
|
|
||||||
## 0.57.0 — 2026-07-10
|
## 0.57.0 — 2026-07-10
|
||||||
|
|
||||||
- Pro 档新增多个国际旗舰模型,覆盖不同任务场景,模型选单中直接选用。
|
- Pro 档新增多个国际旗舰模型,覆盖不同任务场景,模型选单中直接选用。
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-07
|
### 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.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.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 既有逻辑,前端零改动)。
|
- **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
7
RUN.md
|
|
@ -42,8 +42,10 @@
|
||||||
# 共享同一台推理服务 http://182.54.21.126:9000/v1)。涉密任务用户显式选 `local.r1` / `local.qwq`
|
# 共享同一台推理服务 http://182.54.21.126:9000/v1)。涉密任务用户显式选 `local.r1` / `local.qwq`
|
||||||
# 代替默认 deepseek_v4.flash;未设 env 时这两条 variant 调用即抛 RuntimeError(其他模型不影响)
|
# 代替默认 deepseek_v4.flash;未设 env 时这两条 variant 调用即抛 RuntimeError(其他模型不影响)
|
||||||
LOCAL_LLM_API_KEY=...
|
LOCAL_LLM_API_KEY=...
|
||||||
# unifyllm 网关(config/models/unifyllm.yaml,Claude/GPT/Gemini 国际旗舰,pro 档):可选。
|
# unifyllm 网关(config/models/unifyllm.yaml 文本模型 + config/media/unifyllm.yaml
|
||||||
# 未设:unifyllm.* 5 个 variant 调用即抛 RuntimeError(其他模型不影响)。
|
# 图像后端 gpt_image,均 pro 档):可选。
|
||||||
|
# 未设:unifyllm.* 文本 variant 调用即抛 RuntimeError;gpt_image tool 不注册、
|
||||||
|
# 图像模型下拉仍列出但选了也 fallback 回 seedream(其他模型不影响)。
|
||||||
# ★ 服务器直连 unifyllm.ai TLS 握手失败,需代理出口(HTTPS_PROXY);
|
# ★ 服务器直连 unifyllm.ai TLS 握手失败,需代理出口(HTTPS_PROXY);
|
||||||
# 部署前 `.venv/Scripts/python.exe scripts/diag_unifyllm.py` 冒烟验证(5 模型 tool-calling)
|
# 部署前 `.venv/Scripts/python.exe scripts/diag_unifyllm.py` 冒烟验证(5 模型 tool-calling)
|
||||||
UNIFYLLM_API_KEY=sk-...
|
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(罕见) |
|
| 点 stop 后流式没立刻停 | streaming 改造后正常路径秒退;若仍卡可能是 ① httpx 连接 close 没立刻关(GC 时机)/ ② 模型 thinking 阶段长时间不吐 chunk,等下一个 chunk 到达才能 poll cancel(罕见) |
|
||||||
| `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 |
|
| `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 |
|
||||||
| `seedream` tool 没出现在对话里 | `.env` 没设 `ARK_API_KEY`,build_agent 跳过注册。设了重启 web 即可;无需迁移、无需 DB 改动 |
|
| `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。 |
|
| `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` 弹"服务器未装 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 变、自动重转 |
|
| `.pptx` 预览首次慢几秒 | 正常 —— soffice 冷启 + 转换 ~2-4s,转完缓存到源同目录 `.preview/<stem>.<hash>.pdf`,再点即时。源文件一改(mtime/size 变)hash 变、自动重转 |
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ model_tiers:
|
||||||
- unifyllm.gpt56_sol
|
- unifyllm.gpt56_sol
|
||||||
- unifyllm.gemini31_pro
|
- unifyllm.gemini31_pro
|
||||||
- seedream_5
|
- seedream_5
|
||||||
|
- gpt_image # 图(config/media/unifyllm.yaml image 段,GPT 生图)
|
||||||
- seedance_2_fast
|
- seedance_2_fast
|
||||||
- seedance_2_pro
|
- seedance_2_pro
|
||||||
skills_dir: skills
|
skills_dir: skills
|
||||||
|
|
|
||||||
|
|
@ -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/张,给足余量
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.57.1"
|
__version__ = "0.58.0"
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional, Tuple
|
from typing import Any, Callable, Optional, Tuple
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
@ -49,6 +49,7 @@ from tools.look_at_image import LookAtImageTool
|
||||||
from tools.check_process import CheckProcessTool
|
from tools.check_process import CheckProcessTool
|
||||||
from tools.run_python import RunPythonTool
|
from tools.run_python import RunPythonTool
|
||||||
from tools.seedance import SeedanceTool
|
from tools.seedance import SeedanceTool
|
||||||
|
from tools.gpt_image import GptImageTool
|
||||||
from tools.seedream import SeedreamTool
|
from tools.seedream import SeedreamTool
|
||||||
from tools.shell import ShellTool
|
from tools.shell import ShellTool
|
||||||
from tools.skill_authoring import ForkSkillTool, SaveSkillTool
|
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
|
from core.bocha_client import BochaConfig
|
||||||
|
|
||||||
|
|
||||||
# 媒体工具(seedream / seedance)指引:仅当本 run 真的挂了媒体工具(ARK_API_KEY 存在,
|
# 媒体工具指引:仅当本 run 真的挂了对应媒体工具才追加进 system prompt —— 没 key 的
|
||||||
# ArkConfig.load() 非 None)才追加进 system prompt —— 没 key 的用户不会看到永远报错的工具,
|
# 用户不会看到永远报错的工具,也不该背这段红线。分段拼装(_media_tools_block):
|
||||||
# 也不该背这段红线。文案与 base 模板里其余工具表平级,放在 _build_system_prompt 里按需拼。
|
# look_at_image / seedance 跟 ARK_API_KEY 走;图像段按本 run 选中的 image variant
|
||||||
_MEDIA_TOOLS_BLOCK = """\
|
# 二选一(seedream=豆包 / gpt_image=unifyllm 网关),同一 run 只挂一个图像工具。
|
||||||
|
_MEDIA_LOOK_SEG = """\
|
||||||
## 媒体工具(seedream 图 / seedance 视频 / look_at_image 看图)
|
|
||||||
- `look_at_image` —— 看图 / 读图(豆包 Seed 2.0 Lite 视觉)。**你(主模型)是纯文本看不见图,要"看"图就调它**:OCR 文字、描述画面、读图表/表格/示意图、识别物体。每次很便宜(按 token,通常 < ¥0.01)。
|
- `look_at_image` —— 看图 / 读图(豆包 Seed 2.0 Lite 视觉)。**你(主模型)是纯文本看不见图,要"看"图就调它**:OCR 文字、描述画面、读图表/表格/示意图、识别物体。每次很便宜(按 token,通常 < ¥0.01)。
|
||||||
- **何时调**:用户消息里出现 `[用户上传的参考图] <路径>` 且需要据图内容回答(问"这图里写了啥 / 是什么 / 表格数据多少");或要基于 task 内某张图(`figures/xxx.png`)的**实际内容**做事(不是改图,改图走 seedream)。传 `image=<路径>` + 可选 `question`。
|
- **何时调**:用户消息里出现 `[用户上传的参考图] <路径>` 且需要据图内容回答(问"这图里写了啥 / 是什么 / 表格数据多少");或要基于 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)。
|
- `seedream` —— 豆包图像生成 / 改图。产物自动落 `<task_dir>/figures/`。每次 **¥0.22**(联网 `search=true` 加 ¥0.05)。
|
||||||
- **文生图**(不传 `reference_images`):从零按 prompt 画。**改图 i2i**(传 `reference_images=["figures/xxx.png"]`):在已有图上做像素级修改。**用户对刚生成 / 上传的图说"改成 X / 换个颜色 / 去掉某处" → 必须走改图(reference_images 指那张图),绝不重新文生图**(重画 = 完全不同的图,丢原构图)。v1 改图仅支持单张参考。
|
- **文生图**(不传 `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('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 / 镜头 / 短片 / 演示视频 / 动效。
|
- `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 直接调。
|
- **调用前必须先 `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。"""
|
- 兜底硬约束:用户没主动要视频就别装饰性生成(比生图更严重的红线);同一目的不满意**绝不连发**(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 的「平台」段指向这里)。环境事实(在哪 /
|
# 运行环境段(按 backend 注入,general_v1.md 的「平台」段指向这里)。环境事实(在哪 /
|
||||||
# 能否联网 / 装了啥)是全局不变量,放 system 比塞进某个 skill 高杠杆 —— 一句话省掉一整类
|
# 能否联网 / 装了啥)是全局不变量,放 system 比塞进某个 skill 高杠杆 —— 一句话省掉一整类
|
||||||
# 试错(外网试错 / 平台命令试错)。docker = 线上真实形态(Ubuntu 容器,无外网);host =
|
# 试错(外网试错 / 平台命令试错)。docker = 线上真实形态(Ubuntu 容器,无外网);host =
|
||||||
|
|
@ -269,7 +324,7 @@ def _build_system_prompt(
|
||||||
task_id: UUID,
|
task_id: UUID,
|
||||||
task_name: str,
|
task_name: str,
|
||||||
task_skill: str = "",
|
task_skill: str = "",
|
||||||
media_enabled: bool = False,
|
media_block: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""拼 system prompt: 模板 + skill 列表 + memory + 工作目录段 + task 上下文 + 命名约定。
|
"""拼 system prompt: 模板 + skill 列表 + memory + 工作目录段 + task 上下文 + 命名约定。
|
||||||
|
|
||||||
|
|
@ -296,8 +351,8 @@ def _build_system_prompt(
|
||||||
user_root(workspace_dir, user_id) / ".memory"
|
user_root(workspace_dir, user_id) / ".memory"
|
||||||
)
|
)
|
||||||
prompt += memory_block(workspace_dir, user_id, mem_dir_display)
|
prompt += memory_block(workspace_dir, user_id, mem_dir_display)
|
||||||
if media_enabled:
|
if media_block:
|
||||||
prompt += "\n\n" + _MEDIA_TOOLS_BLOCK
|
prompt += "\n\n" + media_block
|
||||||
wd_abs = working_dir.resolve()
|
wd_abs = working_dir.resolve()
|
||||||
if is_docker:
|
if is_docker:
|
||||||
try:
|
try:
|
||||||
|
|
@ -434,9 +489,16 @@ def build_agent(
|
||||||
is_docker = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker"
|
is_docker = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker"
|
||||||
skills = build_skill_registry(cfg, workspace_dir, uid, docker=is_docker)
|
skills = build_skill_registry(cfg, workspace_dir, uid, docker=is_docker)
|
||||||
|
|
||||||
# 媒体配置提前 load 一次:既决定 system prompt 要不要追加媒体段(media_enabled),
|
# 媒体配置提前 load 一次:既决定 system prompt 的媒体段拼装,也复用给下方媒体工具
|
||||||
# 也复用给下方 seedream/seedance 注册(避免重复读 doubao.yaml)。无 ARK_API_KEY → None。
|
# 注册(避免重复读 yaml)。ark(豆包)缺 ARK_API_KEY → None;unifyllm 网关同理。
|
||||||
|
# image variant 跨 provider 选择(seedream_5=豆包 / gpt_image=unifyllm),本 run 只挂
|
||||||
|
# 选中的那一个图像工具;video / vision 仍豆包独有。
|
||||||
ark_cfg = ArkConfig.load()
|
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")
|
now_iso = datetime.now().isoformat(timespec="seconds")
|
||||||
# meta["working_dir"] 是 db 形态(相对 ROOT 或绝对);Session.append → ensure_local_task_row
|
# meta["working_dir"] 是 db 形态(相对 ROOT 或绝对);Session.append → ensure_local_task_row
|
||||||
|
|
@ -470,7 +532,7 @@ def build_agent(
|
||||||
system_prompt = _build_system_prompt(
|
system_prompt = _build_system_prompt(
|
||||||
cfg, skills, workspace_dir, tool_base, working_dir_path, uid,
|
cfg, skills, workspace_dir, tool_base, working_dir_path, uid,
|
||||||
task_id, task_state.name, task_state.skill,
|
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 = {
|
meta = {
|
||||||
|
|
@ -601,30 +663,15 @@ def build_agent(
|
||||||
images_per_day = int(quotas.get("images_per_day", 0))
|
images_per_day = int(quotas.get("images_per_day", 0))
|
||||||
videos_per_day = int(quotas.get("videos_per_day", 0))
|
videos_per_day = int(quotas.get("videos_per_day", 0))
|
||||||
|
|
||||||
# 媒体生成 tool(豆包 seedream / 后续 seedance):仅当 ARK_API_KEY 设了才挂 ——
|
# 图像生成 tool:跨 provider 二选一(选择已在函数上半部 _choose_image_variant 定好,
|
||||||
# 没 key 的用户无感知,不至于看到 schema 里突然多个永远报错的工具。
|
# 与 system prompt 媒体段同源)。没任何 provider key → 不挂,用户无感知。
|
||||||
# image_variant 由 caller 传(web 入口随消息 POST 带);空 → 取 yaml 第一个 variant
|
# 本次 run 锁定该 variant,run 内多次 tool call 全用同一个;下一条消息可重选。
|
||||||
# (fallback,沿用原行为)。本次 run 装的 SeedreamTool 锁定该 variant,本 run 内的
|
if img_cfg is not None:
|
||||||
# 多次 tool call 全用同一个;下一条消息可以重选。
|
if img_provider == "doubao":
|
||||||
# ark_cfg 已在函数上半部 load 过(复用,顺带决定 system prompt 的 media 段)。
|
image_tool: Any = SeedreamTool(
|
||||||
if ark_cfg is not None:
|
ark_cfg=img_provider_cfg,
|
||||||
image_cfg = (ark_cfg.raw.get("image") or {})
|
image_variant_cfg=img_cfg,
|
||||||
chosen_key, chosen_cfg = "", None
|
variant_key=img_key,
|
||||||
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,
|
|
||||||
working_dir=working_dir_path,
|
working_dir=working_dir_path,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
user_id=uid,
|
user_id=uid,
|
||||||
|
|
@ -632,9 +679,23 @@ def build_agent(
|
||||||
user_root=ur_path,
|
user_root=ur_path,
|
||||||
daily_limit=images_per_day,
|
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。
|
# 空 → 取 yaml 第一个 video variant。本 run 的 SeedanceTool 锁定该 variant。
|
||||||
# cancel_check 是 web 入口构造的 `lambda: broker.is_cancelled(task_id)` —— 轮询
|
# cancel_check 是 web 入口构造的 `lambda: broker.is_cancelled(task_id)` —— 轮询
|
||||||
# 期间(典型 30-90s)拿来响应用户停止按钮;远端 cgt 任务无 cancel API,best-effort 不动远端
|
# 期间(典型 30-90s)拿来响应用户停止按钮;远端 cgt 任务无 cancel API,best-effort 不动远端
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,10 @@ class ArkConfig:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, path: Optional[Path] = None) -> Optional["ArkConfig"]:
|
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 用户无感知)。
|
api_key env 未设 → 返 None(caller 据此决定是否注册 tool;无 key 用户无感知)。
|
||||||
yaml 不存在 → 返 None。
|
yaml 不存在 → 返 None。
|
||||||
"""
|
"""
|
||||||
|
|
@ -48,15 +50,16 @@ class ArkConfig:
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
return None
|
return None
|
||||||
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
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()
|
key = os.environ.get(env, "").strip()
|
||||||
if not key:
|
if not key:
|
||||||
return None
|
return None
|
||||||
return cls(
|
base = (
|
||||||
api_key=key,
|
data.get("base_url")
|
||||||
base_url=str(data.get("ark_base_url") or "https://ark.cn-beijing.volces.com/api/v3").rstrip("/"),
|
or data.get("ark_base_url")
|
||||||
raw=data,
|
or "https://ark.cn-beijing.volces.com/api/v3"
|
||||||
)
|
)
|
||||||
|
return cls(api_key=key, base_url=str(base).rstrip("/"), raw=data)
|
||||||
|
|
||||||
|
|
||||||
class ArkClient:
|
class ArkClient:
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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}"
|
||||||
|
)
|
||||||
25
web/admin.py
25
web/admin.py
|
|
@ -285,7 +285,7 @@ def _storage_page(s: Any, page: int, page_size: int) -> dict:
|
||||||
|
|
||||||
def _model_catalog() -> list[dict]:
|
def _model_catalog() -> list[dict]:
|
||||||
"""全部可门控模型清单 [{id, display_name, kind}]:文本(config/models/*.yaml)+
|
"""全部可门控模型清单 [{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.capabilities import ModelCapabilities
|
||||||
from core.paths import ROOT
|
from core.paths import ROOT
|
||||||
|
|
@ -307,16 +307,19 @@ def _model_catalog() -> list[dict]:
|
||||||
except (ValueError, FileNotFoundError):
|
except (ValueError, FileNotFoundError):
|
||||||
continue
|
continue
|
||||||
out.append({"id": profile, "display_name": caps.display_name or profile, "kind": "text"})
|
out.append({"id": profile, "display_name": caps.display_name or profile, "kind": "text"})
|
||||||
media = ROOT / "config" / "media" / "doubao.yaml"
|
media_dir = ROOT / "config" / "media"
|
||||||
if media.exists():
|
seen: set[str] = set()
|
||||||
try:
|
if media_dir.is_dir():
|
||||||
mdata = _yaml.safe_load(media.read_text(encoding="utf-8")) or {}
|
for path in sorted(media_dir.glob("*.yaml")):
|
||||||
except Exception:
|
try:
|
||||||
mdata = {}
|
mdata = _yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
for kind in ("image", "video"):
|
except Exception:
|
||||||
for key, cfg in (mdata.get(kind) or {}).items():
|
continue
|
||||||
if isinstance(cfg, dict):
|
for kind in ("image", "video"):
|
||||||
out.append({"id": key, "display_name": cfg.get("display_name") or key, "kind": kind})
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
66
web/app.py
66
web/app.py
|
|
@ -674,32 +674,45 @@ async def _transcribe_wecom_voice(media_id: str) -> str:
|
||||||
return await audio.transcribe_voice(data)
|
return await audio.transcribe_voice(data)
|
||||||
|
|
||||||
|
|
||||||
def _list_image_variants() -> list[tuple[str, dict]]:
|
def _list_media_variants(kind: str) -> list[tuple[str, dict]]:
|
||||||
"""扫 config/media/doubao.yaml image 段 → [(variant_key, variant_cfg), ...]。
|
"""扫 config/media/*.yaml 的 <kind> 段 → [(variant_key, variant_cfg), ...]。
|
||||||
|
|
||||||
yaml 不存在或 image 段空 / 仅注释 → 返 []。不要求 ARK_API_KEY 已设 —— 仅纯
|
多 provider(doubao / unifyllm)合并列举,文件名排序保证豆包在前(→ is_default
|
||||||
元数据列举,UI 拉这个画下拉。真正调用 seedream 时 agent_builder 那边再过
|
仍落 seedream)。variant key 约定跨文件唯一;万一撞名先读的生效(后者跳过)。
|
||||||
`ArkConfig.load()`(没 key → tool 不注册)。
|
目录不存在或段空 / 仅注释 → 返 []。不要求对应 API key 已设 —— 仅纯元数据列举,
|
||||||
|
UI 拉这个画下拉。真正调用时 agent_builder 那边再过 `ArkConfig.load()`
|
||||||
|
(没 key → tool 不注册)。
|
||||||
"""
|
"""
|
||||||
from core.paths import ROOT
|
from core.paths import ROOT
|
||||||
import yaml as _yaml
|
import yaml as _yaml
|
||||||
|
|
||||||
p = ROOT / "config" / "media" / "doubao.yaml"
|
media_dir = ROOT / "config" / "media"
|
||||||
if not p.exists():
|
if not media_dir.is_dir():
|
||||||
return []
|
return []
|
||||||
try:
|
out: list[tuple[str, dict]] = []
|
||||||
data = _yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
seen: set[str] = set()
|
||||||
except Exception:
|
for p in sorted(media_dir.glob("*.yaml")):
|
||||||
return []
|
try:
|
||||||
image_cfg = data.get("image") or {}
|
data = _yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||||||
return [(k, v) for k, v in image_cfg.items() if isinstance(v, dict)]
|
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:
|
def _resolve_image_model(variant: str, user_id: Optional[UUID] = None) -> str:
|
||||||
"""校验 image_model variant key。
|
"""校验 image_model variant key。
|
||||||
|
|
||||||
传空 → 返空(agent_builder fallback 到第一个 variant);传非空 → 必须存在
|
传空 → 返空(agent_builder fallback 到第一个 variant);传非空 → 必须存在
|
||||||
于 config/media/doubao.yaml image 段,否则 400。user_id 非空 → 额外过档位门控。
|
于 config/media/*.yaml 的 image 段,否则 400。user_id 非空 → 额外过档位门控。
|
||||||
"""
|
"""
|
||||||
name = (variant or "").strip()
|
name = (variant or "").strip()
|
||||||
if not name:
|
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]]:
|
def _list_video_variants() -> list[tuple[str, dict]]:
|
||||||
"""扫 config/media/doubao.yaml video 段 → [(variant_key, variant_cfg), ...]。
|
"""视频 variant 清单(跨 provider,见 _list_media_variants);空 → UI 隐藏下拉。"""
|
||||||
|
return _list_media_variants("video")
|
||||||
与 _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)]
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_video_model(variant: str, user_id: Optional[UUID] = None) -> str:
|
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"])
|
@app.get("/v1/image_models", tags=["misc"])
|
||||||
def list_image_models(user_id: UUID = Depends(require_user)):
|
def list_image_models(user_id: UUID = Depends(require_user)):
|
||||||
"""图像生成模型清单(扫 config/media/doubao.yaml image 段)。
|
"""图像生成模型清单(扫 config/media/*.yaml image 段,跨 provider)。
|
||||||
|
|
||||||
前端顶栏第二个下拉拉这个;空列表 → 没配 image variant 或本档无授权,UI 隐藏下拉。
|
前端顶栏第二个下拉拉这个;空列表 → 没配 image variant 或本档无授权,UI 隐藏下拉。
|
||||||
按用户档位过滤;`is_default` 标过滤后第一个 variant。开发期不缓存,改 YAML 立即生效。
|
按用户档位过滤;`is_default` 标过滤后第一个 variant。开发期不缓存,改 YAML 立即生效。
|
||||||
|
|
@ -3032,9 +3031,10 @@ def create_app() -> FastAPI:
|
||||||
for k, v in _list_image_variants():
|
for k, v in _list_image_variants():
|
||||||
if k == img_variant:
|
if k == img_variant:
|
||||||
name = v.get("display_name") or k
|
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 = (
|
image_variant_hint = (
|
||||||
f"\n下游生图工具:{name}(默认尺寸 {sz},支持中英文 prompt,"
|
f"\n下游生图工具:{name}({size_seg},支持中英文 prompt,"
|
||||||
f"擅长写实/插画/构图描述)。若用户意图涉及画面/封面/插图,"
|
f"擅长写实/插画/构图描述)。若用户意图涉及画面/封面/插图,"
|
||||||
f"润色后的文本要给出适合该模型的画面细节(主体/风格/光线/构图)。"
|
f"润色后的文本要给出适合该模型的画面细节(主体/风格/光线/构图)。"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export function toolActivityLabel(name, args) {
|
||||||
case "web_search": return `联网搜索: ${clip(a.query, 60)}`;
|
case "web_search": return `联网搜索: ${clip(a.query, 60)}`;
|
||||||
case "load_skill": return `加载技能: ${clip(a.name, 40)}`;
|
case "load_skill": return `加载技能: ${clip(a.name, 40)}`;
|
||||||
case "seedream": return `生成图像: ${clip(a.prompt, 60)}`;
|
case "seedream": return `生成图像: ${clip(a.prompt, 60)}`;
|
||||||
|
case "gpt_image": return `生成图像: ${clip(a.prompt, 60)}`;
|
||||||
case "seedance": return `生成视频: ${clip(a.prompt, 60)}`;
|
case "seedance": return `生成视频: ${clip(a.prompt, 60)}`;
|
||||||
default: {
|
default: {
|
||||||
const p = clip(JSON.stringify(a), 80);
|
const p = clip(JSON.stringify(a), 80);
|
||||||
|
|
@ -58,7 +59,7 @@ export function _workingDirName(workingDir) {
|
||||||
// 可发现性入口,小图标无视觉污染。
|
// 可发现性入口,小图标无视觉污染。
|
||||||
// 注:与 extractMediaBanner 的"媒体 banner"白名单是不同维度 —— 将来若新增
|
// 注:与 extractMediaBanner 的"媒体 banner"白名单是不同维度 —— 将来若新增
|
||||||
// "生成 docx 的工具",入这里但不入 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 相对。
|
// 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。
|
||||||
// 启发式:把 \ 一律归 /,然后找以 `<wdName>/` 打头的串,要求最后一段含 . (像文件)。
|
// 启发式:把 \ 一律归 /,然后找以 `<wdName>/` 打头的串,要求最后一段含 . (像文件)。
|
||||||
|
|
@ -67,7 +68,7 @@ export const ARTIFACT_PRODUCING_TOOLS = new Set(["seedream", "seedance"]);
|
||||||
// 协议:tool 返回串首行格式 `[<tool>] key=value · key=value · ...`
|
// 协议:tool 返回串首行格式 `[<tool>] key=value · key=value · ...`
|
||||||
export function extractMediaBanner(toolName, resultText) {
|
export function extractMediaBanner(toolName, resultText) {
|
||||||
if (!resultText) return "";
|
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] || "";
|
const firstLine = String(resultText).split("\n", 1)[0] || "";
|
||||||
// 抓 key=value(value 可含空格 / : / ., 用 · 或行尾结束)
|
// 抓 key=value(value 可含空格 / : / ., 用 · 或行尾结束)
|
||||||
const re = /(\w+)=([^·\n]+?)(?=\s*·|\s*$)/g;
|
const re = /(\w+)=([^·\n]+?)(?=\s*·|\s*$)/g;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue