Compare commits

..

2 Commits

Author SHA1 Message Date
caoqianming 0d69ae86e2 feat(media): look_at_image 图像理解(豆包 Seed 2.0 Lite vision)+ bump 0.16.0
DESIGN §8.1 C 路落地 —— 主模型 DeepSeek V4 纯文本无视觉,挂 look_at_image
工具按需读图(OCR / 描述 / 读图表),模型自决何时调。

- 选型:设计时的 Seed 1.6 vision 已过时,改用 Doubao Seed 2.0 Lite
  (doubao-seed-2-0-lite-260428,全模态 SOTA 细粒度感知)。token 计费
  输入 ¥0.6 / 输出 ¥3.6 /Mtok,一次读图 < ¥0.01
- 后端:tools/look_at_image.py(/chat/completions base64 单图+问题→文本解读);
  doubao.yaml 加 vision 段;usage.py 加 record_vision_usage(kind=vision,
  按 token,无需 migration——kind 自由文本);agent_builder 注册 + media prompt 段
- 图片路径解析与 i2i 共用 tools/image_ref.py
- 验证:scripts/smoke_look_at_image.py 真机 OCR 通过(实测 ¥0.0011)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:20:05 +08:00
caoqianming be813629b2 feat(media): seedream i2i 改图 + 前端 paste 路径注入 + bump 0.15.0
- seedream 加 reference_images(v1 单图):传已有图路径做像素级改图,
  不传=文生图行为 100% 不变(向后兼容)。路径解析抽到 tools/image_ref.py
  (三形态路径 + 强制落 user_root 内防越界 + 扩展名/大小校验)
- 前端 chat.js:sendMessage 把粘贴图路径作 [用户上传的参考图] 行注入正文,
  修了粘贴图路径到不了模型的既有缺口("上传外部图→改图"才能定位文件)
- 引导:imagegen SKILL 删旧"不接图像输入"+ 加改图(i2i)专段,纠正
  "该 i2i 却重新文生图丢原构图";agent_builder 媒体 block + SKILL_LIST 同步

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:18:18 +08:00
13 changed files with 631 additions and 24 deletions

View File

@ -526,17 +526,23 @@ create index on usage_events (model_profile, created_at);
> 实施细节(步骤清单 / 验收项)进 PROGRESS + git;此处只留缺口、选型与取舍。 > 实施细节(步骤清单 / 验收项)进 PROGRESS + git;此处只留缺口、选型与取舍。
### 8.1 图像理解 + Seedream i2i(2026-05-29,status=design 待启动) ### 8.1 图像理解 + Seedream i2i(2026-05-29 设计;✅ 2026-06-16 i2i + look_at_image 双双落地)
**缺口**:DeepSeek V4 主模型纯文本无视觉;`seedream` 只 t2i;"基于已生成图二次修改" / "上传外部参考图让 agent 据此干活"两条路径未覆盖。 **缺口**:DeepSeek V4 主模型纯文本无视觉;`seedream` 只 t2i;"基于已生成图二次修改" / "上传外部参考图让 agent 据此干活"两条路径未覆盖。
**选 E + C 组合**:`seedream` 加 `reference_images` 走 i2i(改已生成图,像素级)+ 新增 `look_at_image` 走豆包 Seed 1.6 vision 单图理解(读外部图,DeepSeek 自决何时调)。改动面=2 tool + 1 prompt 段 + 1 yaml 段,不动 loop / llm / capabilities / DB / 前端。 **选 E + C 组合**:`seedream` 加 `reference_images` 走 i2i(改已生成图,像素级)+ 新增 `look_at_image` 走豆包视觉单图理解(读外部图,DeepSeek 自决何时调)。改动面=2 tool + 1 prompt 段 + 1 yaml 段,不动 loop / llm / capabilities / DB / 前端。
> **模型选型更新(2026-06-16)**:设计时写的 Seed 1.6 vision 已过时,落地用 **Doubao Seed 2.0 Lite**(`doubao-seed-2-0-lite-260428`,2026-02 发布、05 升级为全模态理解 SOTA 细粒度感知)。Seed 2.0 全系文本模型已原生支持图片输入 → A 路(主模型换多模态)门槛降低,但主模型 DeepSeek V4 的 code/tool-calling 仍是核心,**维持 C 路(vision 当工具)不变**。token 计费(输入 ¥0.6 / 输出 ¥3.6 / Mtok),一次读图 < ¥0.01。
- **不选 A(主模型换多模态)**:V4 的 code / tool calling 是主路径核心,换豆包当主 chat 降能力 + 要改 loop/memory 引 multimodal,工程 5× 且破坏架构。 - **不选 A(主模型换多模态)**:V4 的 code / tool calling 是主路径核心,换豆包当主 chat 降能力 + 要改 loop/memory 引 multimodal,工程 5× 且破坏架构。
- **不选 B(后台 vision 路由)**:每条消息隐式 vision 描述 = 多烧 token + 1 跳延迟 + 失去 agentic 控制权 + debug 难。 - **不选 B(后台 vision 路由)**:每条消息隐式 vision 描述 = 多烧 token + 1 跳延迟 + 失去 agentic 控制权 + debug 难。
**关键实测**:Seedream 5.0 `/images/generations` 接受 `image_urls` base64 data URL,200 返新图 → **内网无需对象存储中介**(排除最大工程不确定性)。约束:输出 ≥~1920²、单张参考 ≤10MB、最多 14 张。 **关键实测**:Seedream 5.0 `/images/generations` 接受 `image_urls` base64 data URL,200 返新图 → **内网无需对象存储中介**(排除最大工程不确定性)。约束:输出 ≥~1920²、单张参考 ≤10MB、最多 14 张。
**风险 / 边界**:v1 只支持单张参考(multi-ref 角色定义靠 prompt,留 v2);base64 ARK 未承诺长期稳定(收紧则降级走 TOS 上传换 URL)。 **风险 / 边界**:v1 只支持单张参考(multi-ref 角色定义靠 prompt,留 v2);base64 ARK 未承诺长期稳定(收紧则降级走 TOS 上传换 URL)。
**落地实况(2026-06-16,详 PROGRESS)**:
- **E 路(改图)**:`seedream` 加 `reference_images`(v1 单图,传 >1 报错);路径解析强制落 user_root 内防越界;前端 `chat.js` 补 paste 路径注入(把粘贴图路径作 `[用户上传的参考图]` 行进正文,修了"粘贴路径到不了模型"的既有缺口)。
- **C 路(看图)**:`look_at_image` tool(`tools/look_at_image.py`)走 Seed 2.0 Lite `/chat/completions`,base64 单图 + 问题 → 文本解读;`doubao.yaml` 加 `vision:` 段;`usage.py` 加 `record_vision_usage`(kind="vision",token 计费);agent_builder 注册 + media prompt 段。图片解析与 i2i 共用 `tools/image_ref.py`。真机 smoke(`scripts/smoke_look_at_image.py`)OCR 验证通过。
- 两路均不动 loop / llm / DB schema(`usage_events.kind` 自由文本,vision 无需 migration)。
**升级到 A 的信号**:用户要"贴图同时说话模型直接读图回话",或多轮带图成高频 —— 当前假设"图是工具调用对象"而非"对话内容"。 **升级到 A 的信号**:用户要"贴图同时说话模型直接读图回话",或多轮带图成高频 —— 当前假设"图是工具调用对象"而非"对话内容"。
### 8.2 Token 优化与上下文治理(2026-06-04,✅ 已落地,详 PROGRESS) ### 8.2 Token 优化与上下文治理(2026-06-04,✅ 已落地,详 PROGRESS)

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-15(plot_pub 吸收 nature-figure 投稿级复合图设计纪律) 最后更新:2026-06-16(图像理解 look_at_image + seedream i2i 改图,DESIGN §8.1 双路落地)
--- ---
@ -21,6 +21,21 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-06-16 / look_at_image 图像理解(DESIGN §8.1 C 路落地)
- 需求:DeepSeek V4 主模型纯文本无视觉,挂 `look_at_image` 工具按需"借眼睛"读图(OCR / 描述 / 读图表),模型自决何时调。
- **模型选型**:设计时的 Seed 1.6 vision 已过时(联网核实),改用 **Doubao Seed 2.0 Lite**(`doubao-seed-2-0-lite-260428`,全模态 SOTA 细粒度感知)。token 计费输入 ¥0.6 / 输出 ¥3.6 / Mtok,一次读图 < ¥0.01。否决「换主模型走 A 路」——DeepSeek 的 code/tool-calling 仍是核心,vision 当工具更稳。
- 后端:新建 `tools/look_at_image.py`(`/chat/completions` OpenAI 兼容,base64 单图 + question → 文本解读,默认 question 覆盖描述+OCR+图表读数);`config/media/doubao.yaml` 加 `vision:` 段;`core/storage/usage.py` 加 `record_vision_usage`(kind="vision",按 token,单价 snapshot 进 units);`agent_builder.py` 注册(yaml 有 vision 段才挂)+ media prompt 段教「何时调 / 何时别调」。`usage_events.kind` 自由文本,vision **无需 migration**
- 重构:图片路径解析 + base64 抽到 `tools/image_ref.py`,seedream(i2i)与 look_at_image 共用(三形态路径 + user_root 边界 + 扩展名/大小校验)。
- 验证:真机 smoke `scripts/smoke_look_at_image.py` 合成含已知文字图 → OCR 准确读出 + usage_events 落 kind=vision(实测 ¥0.0011)。bump 0.15.0 → 0.16.0。
### 2026-06-16 / seedream i2i 改图(DESIGN §8.1 E 路落地)+ 前端 paste 路径注入
- 需求:覆盖「基于已生成 / 上传的图做修改」(像素级),核心循环=文生图 → 用户"改成 X" → i2i 改那张(不重画)。base64 通路 probe 2026-05-29 已验,本次落 tool。
- 后端 `tools/seedream.py`:加 `reference_images` 数组参数(**v1 单图**,传 >1 直接报错不静默截断)。路径解析走共享 `tools/image_ref.py`(与 look_at_image 同一套)——依次试 `working_dir/rel``user_root/rel` → 绝对,**强制结果落在 user_root 子树内**(防越界读任意文件),吃三种路径形态(`figures/x.png` / saved 形态 `<taskname>/figures/x.png` / 绝对);校验存在 + 图片扩展名(png/jpg/jpeg/webp/gif)+ ≤10MB;读 base64 → data URL → ARK body `image_urls`。**不传 reference_images = 文生图,行为 100% 不变(向后兼容)**。banner 加 `· mode=i2i` + `reference=` 行(前端正则兼容),meta.json 记 `mode` / `reference_images`(派生链可追溯)。
- 前端 `web/static/js/chat.js`:`sendMessage` 发送时 `takePastedRels()` 收集 `chat-hint` 的 paste-chip 路径,作 `[用户上传的参考图] <rel>` 行注入正文 + 清 chip ——**修了既有缺口**(之前粘贴的图路径根本到不了模型)。这样"上传外部图 → 改图 / 看图"才能定位到文件。
- 引导:`skills/imagegen/SKILL.md` 删旧「不接图像输入」结论 + 加「改图(i2i)」专段(最易踩错=该 i2i 却重新 t2i 丢构图);`agent_builder.py` 媒体 block 提 i2i + paste 注入约定;`SKILL_LIST.md` 同步。bump 0.14.0 → 0.15.0(看图 look_at_image 同日落地见上一条)。
### 2026-06-16 / ask_user:回复里渲染可点击「方案确认」选项卡(Claude 式) ### 2026-06-16 / ask_user:回复里渲染可点击「方案确认」选项卡(Claude 式)
- 需求:agent 在分叉点能像 Claude 那样抛出可点选项,用户点一个继续、或不点直接用文字讨论。设计取舍见下。 - 需求:agent 在分叉点能像 Claude 那样抛出可点选项,用户点一个继续、或不点直接用文字讨论。设计取舍见下。

View File

@ -1,7 +1,7 @@
# zcbot Skill 清单 # zcbot Skill 清单
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材) 服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
最后更新:2026-06-15 最后更新:2026-06-16
Skill 总数:15 Skill 总数:15
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。 zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
@ -24,7 +24,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
| 文献检索 | [documents](#documents) | 查内部 7 学科材料知识库(21W+ 论文,跨语言检索;host-side tool 持 key) | | 文献检索 | [documents](#documents) | 查内部 7 学科材料知识库(21W+ 论文,跨语言检索;host-side tool 持 key) |
| 科研计算 | [pymatgen](#pymatgen) | 晶体结构 / XRD 模拟 / 相图 / Materials Project(host-side tool 持 key) | | 科研计算 | [pymatgen](#pymatgen) | 晶体结构 / XRD 模拟 / 相图 / Materials Project(host-side tool 持 key) |
| 科研计算 | [stats_ml](#stats_ml) | 配方-性能建模与机器学习(三库分工) | | 科研计算 | [stats_ml](#stats_ml) | 配方-性能建模与机器学习(三库分工) |
| 内容生成 | [imagegen](#imagegen) | 豆包 Seedream 5.0 文生图(¥0.22 / 张) | | 内容生成 | [imagegen](#imagegen) | 豆包 Seedream 5.0 文生图 + 改图 i2i(¥0.22 / 张) |
| 内容生成 | [videogen](#videogen) | 豆包 Seedance 2.0 文生视频(¥1.86 起 / 段) | | 内容生成 | [videogen](#videogen) | 豆包 Seedance 2.0 文生视频(¥1.86 起 / 段) |
| 通用 | [analyze](#analyze) | 科学问题拆解 / 引导(模糊命题 → 子问题 + 路线图) | | 通用 | [analyze](#analyze) | 科学问题拆解 / 引导(模糊命题 → 子问题 + 路线图) |
| 通用 | [coding](#coding) | 修代码 / 调试 / 重构 | | 通用 | [coding](#coding) | 修代码 / 调试 / 重构 |
@ -328,9 +328,9 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
## 内容生成 ## 内容生成
### imagegen ### imagegen
**用豆包 Seedream 5.0 生图(`seedream` tool)。** **用豆包 Seedream 5.0 生图 / 改图(`seedream` tool,文生图 + image-to-image)。**
把"我想要张图"变成一张能用的图。流程:**诊断模糊度(六维)→ 给推断 + 待确认项 → 用户拍板 → 装配最终 prompt → 把 prompt 完整贴给用户确认 → 调 tool**。 把"我想要张图"变成一张能用的图,或在已有图上做像素级修改。流程:**诊断模糊度(六维)→ 给推断 + 待确认项 → 用户拍板 → 装配最终 prompt → 把 prompt 完整贴给用户确认 → 调 tool**。改图(i2i)额外传 `reference_images` 指向要改的那张图。
**成本**:每次 ¥0.22(`search=true` 加 ¥0.05),3-5 秒出图。 **成本**:每次 ¥0.22(`search=true` 加 ¥0.05),3-5 秒出图。
@ -346,8 +346,8 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
**何时不走本 skill**: **何时不走本 skill**:
- ⛔ 用户没主动要图 —— 别为"丰富回复"装饰性生图 - ⛔ 用户没主动要图 —— 别为"丰富回复"装饰性生图
- ⛔ 用户给参考图说"按这个改" —— Seedream 5.0 是文生图,不接图像输入 - ✅ 用户给参考图 / 对刚生成的图说"按这个改 / 改成 X" —— 走**改图(i2i)**:`reference_images` 指那张图,**别重新文生图**(重画会丢原构图);v1 单图
- ⛔ 已有合适素材 —— 直接 read / 引用,别重新生成 - ⛔ 已有合适素材且不改 —— 直接 read / 引用,别重新生成
**关键岔路**: **关键岔路**:
- 节点-箭头-结构关系明确(技术路线 / 流程图)→ **走 mermaid**(矢量、零成本、可编辑) - 节点-箭头-结构关系明确(技术路线 / 流程图)→ **走 mermaid**(矢量、零成本、可编辑)

View File

@ -25,6 +25,23 @@ image:
default_search: false # web search 额外加价 ~¥0.05/张;默认关 default_search: false # web search 额外加价 ~¥0.05/张;默认关
request_timeout_s: 60 # 出图慢于此判超时 request_timeout_s: 60 # 出图慢于此判超时
vision:
# 图像理解(看图 / OCR / 读图表)。DeepSeek V4 主模型纯文本无视觉,挂 `look_at_image`
# 工具走豆包 Seed 2.0 Lite(全模态理解)按需读图。OpenAI 兼容 /chat/completions,
# 单图走 base64 data URL(同 seedream i2i,内网无需对象存储)。
# 价格 last_updated: 2026-06-16,源 https://www.volcengine.com/docs/82379/1544106
seed_2_lite:
model_id: doubao-seed-2-0-lite-260428
display_name: 豆包 Seed 2.0 Lite(视觉理解)
endpoint: /chat/completions
# token 计费(元/百万 tokens):输入 0.6 / 输出 3.6 / 缓存命中 0.12。
# 一次读图 ≈ 图 1-2K + 输出几百 token → 成本 < ¥0.01,故不设每日配额(够便宜)。
price_cny_per_mtoken_input: 0.6
price_cny_per_mtoken_output: 3.6
price_cny_per_mtoken_cache_hit: 0.12
max_image_mb: 10 # 单图上限(超出 tool 侧直接报错,不发请求)
request_timeout_s: 60 # 读图慢于此判超时
video: video:
# fast 放第一个 → 默认 variant(成本敏感场景优先);开通了 Pro 的用户从顶栏下拉切。 # fast 放第一个 → 默认 variant(成本敏感场景优先);开通了 Pro 的用户从顶栏下拉切。
seedance_2_fast: seedance_2_fast:

View File

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

View File

@ -45,6 +45,7 @@ from tools.materials_project import (
MaterialsProjectGetStructureTool, MaterialsProjectGetStructureTool,
MaterialsProjectSearchSummaryTool, MaterialsProjectSearchSummaryTool,
) )
from tools.look_at_image import LookAtImageTool
from tools.run_python import RunPythonTool from tools.run_python import RunPythonTool
from tools.seedance import SeedanceTool from tools.seedance import SeedanceTool
from tools.seedream import SeedreamTool from tools.seedream import SeedreamTool
@ -65,10 +66,14 @@ from core.bocha_client import BochaConfig
# 也不该背这段红线。文案与 base 模板里其余工具表平级,放在 _build_system_prompt 里按需拼。 # 也不该背这段红线。文案与 base 模板里其余工具表平级,放在 _build_system_prompt 里按需拼。
_MEDIA_TOOLS_BLOCK = """\ _MEDIA_TOOLS_BLOCK = """\
## 媒体生成工具(seedream 图 / seedance 视频) ## 媒体工具(seedream 图 / seedance 视频 / look_at_image 看图)
- `seedream` 豆包图像生成产物自动落 `<task_dir>/figures/`每次 **¥0.22**(联网 `search=true` ¥0.05) - `look_at_image` 看图 / 读图(豆包 Seed 2.0 Lite 视觉)**(主模型)是纯文本看不见图,""图就调它**:OCR 文字描述画面读图表/表格/示意图识别物体每次很便宜( token,通常 < ¥0.01)
- **调用前必须先 `load_skill('imagegen')`** skill 里有何时该用 / 该不该用 mermaid 替代 / 用户描述模糊度诊断 / 一次性追问范式 / prompt 装配 / 失败解药全套引导**不要拿用户原话直接当 prompt tool** 容易烧 ¥0.22 在错的方向上 - **何时调**:用户消息里出现 `[用户上传的参考图] <路径>` 且需要据图内容回答("这图里写了啥 / 是什么 / 表格数据多少");或要基于 task 内某张图(`figures/xxx.png`)**实际内容**做事(不是改图,改图走 seedream) `image=<路径>` + 可选 `question`
- 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调 - **何时不调**:用户只是要改图( seedream i2i)/ 只要文件名不关心内容 / 图是你自己刚生成的且 prompt 已知(无需再读)别对同一张图无意义反复看(每次都烧 token)
- `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 再调用户消息里出现 `[用户上传的参考图] <路径>` = 用户贴了图,要看图 / 改图时用那个路径
- `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"""
@ -614,6 +619,27 @@ def build_agent(
) )
tools[seedance_tool.name] = seedance_tool tools[seedance_tool.name] = seedance_tool
# 图像理解 tool(look_at_image / 豆包 Seed 2.0 Lite vision):仅当 yaml 有 vision 段才挂。
# 无 variant 选择维度(读图不分档,固定第一个 variant),与 image/video 的"用户可切档"不同。
vision_cfg = (ark_cfg.raw.get("vision") or {})
vis_key, vis_variant = "", None
for variant_key, variant_cfg in vision_cfg.items():
if isinstance(variant_cfg, dict):
vis_key, vis_variant = variant_key, variant_cfg
break
if vis_variant is not None:
look_tool = LookAtImageTool(
ark_cfg=ark_cfg,
vision_variant_cfg=vis_variant,
variant_key=vis_key,
working_dir=working_dir_path,
task_id=task_id,
user_id=uid,
base_dir=tool_base,
user_root=ur_path,
)
tools[look_tool.name] = look_tool
# 博查联网搜索:仅当 BOCHA_API_KEY 设了才挂 # 博查联网搜索:仅当 BOCHA_API_KEY 设了才挂
bocha_cfg = BochaConfig.load() bocha_cfg = BochaConfig.load()
if bocha_cfg is not None: if bocha_cfg is not None:

View File

@ -249,6 +249,54 @@ def record_video_usage(
return cost_cny return cost_cny
def record_vision_usage(
*,
task_id: UUID,
user_id: UUID,
model_profile: str,
prompt_tokens: int,
completion_tokens: int,
input_cny_per_mtoken: float,
output_cny_per_mtoken: float,
extra_units: Optional[Mapping[str, Any]] = None,
) -> Decimal:
"""记一次图像理解(看图 / OCR):写 usage_events(kind=vision)。
vision token 计费( chat,不是 per-call),`cost_cny = in*in_price/1e6 + out*out_price/1e6`
tokens caller ARK /chat/completions 响应的 usage 字段取(没有则传 0,cost=0 不阻塞)
单价(CNY/Mtok)同步 snapshot units jsonb,日后调价不影响历史对账
`model_profile` 形如 `"doubao.seed_2_lite"`(family.variant 风格, chat/image 对齐)
**失败任务不要走这里** ARK 失败不计费,失败的 tool 调用直接返 [Error] 不写 usage
"""
inp = Decimal(str(input_cny_per_mtoken or 0))
out = Decimal(str(output_cny_per_mtoken or 0))
cost_cny = (
Decimal(str(int(prompt_tokens))) * inp / Decimal("1000000")
+ Decimal(str(int(completion_tokens))) * out / Decimal("1000000")
).quantize(Decimal("0.000001"))
units: dict[str, Any] = {
"tokens_in": int(prompt_tokens),
"tokens_out": int(completion_tokens),
"input_cny_per_mtoken": float(input_cny_per_mtoken or 0),
"output_cny_per_mtoken": float(output_cny_per_mtoken or 0),
}
if extra_units:
units.update(extra_units)
with session_scope() as s:
s.add(UsageEvent(
user_id=user_id,
task_id=task_id,
message_id=None, # vision tool 在 tool execute 时调用,message 还未落库
kind="vision",
model_profile=model_profile,
units=units,
cost_cny=cost_cny,
))
return cost_cny
def check_daily_quota(*, user_id: UUID, kind: str, limit: int) -> tuple[int, bool]: def check_daily_quota(*, user_id: UUID, kind: str, limit: int) -> tuple[int, bool]:
"""每账号每日 kind=image/video 调用配额检查。返回 (今日已用次数, 是否超额)。 """每账号每日 kind=image/video 调用配额检查。返回 (今日已用次数, 是否超额)。

View File

@ -0,0 +1,130 @@
"""Smoke: look_at_image(豆包 Seed 2.0 Lite 视觉)端到端走通 + OCR 验证。
跑法: .venv/Scripts/python.exe scripts/smoke_look_at_image.py
依赖 .env ARK_API_KEY / ZCBOT_DB_URL**会真的调豆包 vision API,产生 < ¥0.01 费用**
校验:
1. ArkConfig.load() + yaml vision 段存在
2. 合成一张含已知文字 "ZCBOT-VISION-8848" PNG LookAtImageTool.execute OCR 出该串
3. 返回串首行 banner model/tokens/cost
4. usage_events 多出一行 kind="vision",units tokens_in/out + 单价 snapshot
"""
from __future__ import annotations
import os
import sys
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# Windows 控制台默认 GBK,打印 ¥ / 中文结果会崩 → 强制 stdout UTF-8(只影响本脚本打印)
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except Exception:
pass
# 读 .env
env_file = ROOT / ".env"
if env_file.exists():
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
os.environ.setdefault(k.strip(), v.strip())
from PIL import Image, ImageDraw
from sqlalchemy import text
from core.ark_client import ArkConfig
from core.storage import session_scope
from core.storage.models import Task, User
from tools.look_at_image import LookAtImageTool
MAGIC = "ZCBOT-VISION-8848"
def make_text_png(dest: Path) -> None:
"""白底大号黑字 PNG(放大 4x 让默认位图字体也清晰可 OCR)。"""
small = Image.new("RGB", (260, 60), (255, 255, 255))
d = ImageDraw.Draw(small)
d.text((10, 22), MAGIC, fill=(0, 0, 0))
big = small.resize((260 * 4, 60 * 4), Image.NEAREST)
dest.parent.mkdir(parents=True, exist_ok=True)
big.save(dest)
def main() -> int:
cfg = ArkConfig.load()
if cfg is None:
print("[SKIP] ARK_API_KEY 未设(或 doubao.yaml 缺失),无法测真接口")
return 0
vision_cfg = (cfg.raw.get("vision") or {})
if not vision_cfg:
print("[SKIP] doubao.yaml 无 vision 段")
return 0
variant_key, variant_cfg = next(iter(vision_cfg.items()))
print(f"[setup] variant={variant_key} model={variant_cfg.get('model_id')} "
f"price_in={variant_cfg.get('price_cny_per_mtoken_input')} "
f"price_out={variant_cfg.get('price_cny_per_mtoken_output')}")
uid = uuid.uuid4()
tid = uuid.uuid4()
ws_user = ROOT / "workspace" / "users" / str(uid)
wd = ws_user / "smoke_vision"
img = wd / "figures" / "magic.png"
make_text_png(img)
print(f"[setup] 合成测试图 {img.name}(含文字 {MAGIC!r})")
with session_scope() as s: # User 先单独落库,再建 Task(FK 顺序保险)
s.add(User(user_id=uid))
with session_scope() as s:
s.add(Task(task_id=tid, user_id=uid, name="smoke_vision", working_dir=str(wd)))
tool = LookAtImageTool(
ark_cfg=cfg,
vision_variant_cfg=variant_cfg,
variant_key=variant_key,
working_dir=wd,
task_id=tid,
user_id=uid,
base_dir=wd,
user_root=ws_user,
)
print("[call] question='把图中的文字逐字 OCR 出来'")
result = tool.execute(image="figures/magic.png", question="把图中的文字逐字 OCR 出来。")
print(f"[tool result]\n{result}\n")
if result.startswith("[Error]"):
print("[FAIL] tool 返回错误")
return 2
# OCR 命中(容忍模型加空格/大小写差异,去掉分隔比对)
norm = result.replace(" ", "").replace("\n", "").upper()
if MAGIC.replace("-", "") in norm.replace("-", ""):
print(f"[OK] OCR 命中魔术串 {MAGIC}")
else:
print(f"[WARN] 未在结果里精确匹配 {MAGIC} —— 人工核对上面 result(模型可能换了排版)")
with session_scope() as s:
rows = s.execute(text(
"SELECT kind, model_profile, units, cost_cny FROM usage_events "
"WHERE task_id = :tid"
), {"tid": str(tid)}).all()
assert len(rows) == 1, f"usage_events 行数应 1,实际 {len(rows)}"
row = rows[0]
assert row.kind == "vision", f"kind 应 vision,实际 {row.kind}"
assert row.model_profile == f"doubao.{variant_key}", f"model_profile={row.model_profile}"
for k in ("tokens_in", "tokens_out", "input_cny_per_mtoken", "output_cny_per_mtoken"):
assert k in row.units, f"units 缺 {k}"
print(f"[OK] usage_events: kind={row.kind} model={row.model_profile} "
f"cost_cny={row.cost_cny} units={row.units}")
print("\n[PASS] smoke_look_at_image 全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,6 +1,6 @@
--- ---
name: imagegen name: imagegen
description: 用豆包 Seedream 5.0 生图(`seedream` tool)。**任何生图任务调 tool 前必须 load 本 skill**。触发词:画 / 绘制 / 出图 / 来张 / 生成图 / 做张 + 图 / 图片 / 图像 / 配图 / 封面 / 概念图 / 效果图 / 示意图 / 场景图 / 艺术图 / 写实图 / 海报 / 插画 / 插图 / 封皮 / 头图。核心是把用户模糊一句话**问清楚再画**,不要上来就烧 ¥0.22。 description: 用豆包 Seedream 5.0 生图 / 改图(`seedream` tool,文生图 + image-to-image 改图)。**任何生图 / 改图任务调 tool 前必须 load 本 skill**。触发词:画 / 绘制 / 出图 / 来张 / 生成图 / 做张 + 图 / 图片 / 图像 / 配图 / 封面 / 概念图 / 效果图 / 示意图 / 场景图 / 艺术图 / 写实图 / 海报 / 插画 / 插图 / 封皮 / 头图;**改图触发词**:改这张图 / 把图里的 X 改成 Y / 基于刚那张图 / 按这张参考图改 / 换个颜色·背景·风格(针对已有图)。核心是把用户模糊一句话**问清楚再画**,不要上来就烧 ¥0.22。
--- ---
# Imagegen # Imagegen
@ -31,8 +31,9 @@ description: 用豆包 Seedream 5.0 生图(`seedream` tool)。**任何生图任
## 何时不走本 skill(直接走通用工具) ## 何时不走本 skill(直接走通用工具)
- 用户**没主动要图**(别为"丰富回复"装饰性生图 —— 这是 system prompt 红线) - 用户**没主动要图**(别为"丰富回复"装饰性生图 —— 这是 system prompt 红线)
- 用户给了具体参考图说"按这个改" —— Seedream 5.0 是文生图不接图像输入,告诉用户走描述 - 已有合适素材且用户**没要改**(用户上传 / 之前生成过)—— 直接 `read` / 引用,别重新生成
- 已有合适素材(用户上传 / 之前生成过)—— 直接 `read` / 引用,别重新生成
> 用户给了参考图说"按这个改" / 对刚生成的图说"改成 X" —— **这是改图(i2i),不是不能做**,走下面「改图」段用 `reference_images`,**别再走文生图从零画**。
## 关键岔路:mermaid vs seedream ## 关键岔路:mermaid vs seedream
@ -174,6 +175,34 @@ seedream(
产物自动落 `<task_dir>/figures/<时间戳>-<rand>.png` + 同名 `.meta.json`(prompt / 参数 / 成本 / response_id)。 产物自动落 `<task_dir>/figures/<时间戳>-<rand>.png` + 同名 `.meta.json`(prompt / 参数 / 成本 / response_id)。
## 改图(i2i):基于已有图做修改
**核心场景**:用户对**刚生成的图**(或自己上传的参考图)说"把天空改成黄昏" / "颜色换成蓝色" / "去掉左下角那个人" —— 这是**像素级改图**,要在原图基础上改,**不是重新文生图**。
> ⚠️ 最容易踩的错:用户说"改一下刚那张图",模型却拿新 prompt **重新文生图** —— 结果是一张**完全不同构图**的新图,原图的布局/主体全丢了,用户要的"只动某处"变成"全推翻"。**只要是基于某张已有图改,一律走 `reference_images`。**
**怎么调**:把要改的那张图路径传 `reference_images`(数组,**v1 只放 1 张**),prompt 只写**改成什么**(不用重述整张图):
```
seedream(
prompt="保持构图和主体不变,把背景天空从正午改成金色黄昏,光线偏暖",
reference_images=["figures/20260616-153022-a1b2c3.png"], # 上次 seedream 返回的 saved 路径,原样照抄
size="2048x2048", # 改图建议 ≥1920²(ARK i2i 最小输出约束),默认方图即可
)
```
参考图路径从哪来:
- **改刚生成的图** → 上一次 `seedream` 返回的 `saved:` 那行路径,**原样照抄**进 `reference_images`
- **改用户上传的图** → 用户消息里会带 `[用户上传的参考图] <路径>` 行(前端粘贴注入),把那个路径放进去
**和文生图一样守 ⛔ 铁律**:改图也烧 **¥0.22**,调 tool 前同样把「参考哪张图 + 改成什么 prompt」贴给用户确认再发。
**约束 / 边界**:
- **v1 单图**:`reference_images` 只放 1 张,传 2 张及以上会 `[Error]`。多图合成 / 角色定义留 v2,现在靠 prompt 描述
- 参考图 ≤10MB,扩展名 png/jpg/jpeg/webp/gif;路径必须在 task_dir 内
- 改图 size 别低于 ~1920²(如 1024² 会被 ARK 拒),保持默认 2048² 最稳
- 改不满意**不要原样重发** —— 同文生图,先口头对齐"还要再改哪一处",再发新调用
## 失败 / 不满意后怎么办 ## 失败 / 不满意后怎么办
**不要原 prompt 重发**!那是浪费 ¥0.22。失败模式 / 解药: **不要原 prompt 重发**!那是浪费 ¥0.22。失败模式 / 解药:

92
tools/image_ref.py Normal file
View File

@ -0,0 +1,92 @@
"""共享:把模型给的图片路径解析 + 读成 base64 data URL。
seedream(改图参考) look_at_image(看图)共用同一套路径解析 + 校验:
- 三种路径形态都吃:`figures/x.png`(working_dir 相对)/ `<taskname>/figures/x.png`
(user_root 相对,= tool 上次 saved: 行形态)/ 绝对路径
- **强制最终落在 user_root 子树内**(防模型借参考图越界读任意文件)
- 校验存在 + 图片扩展名 + 大小上限, base64 编码成 data URL
"""
from __future__ import annotations
import base64
from pathlib import Path
from typing import Callable, Optional
# 支持的扩展名 → MIME(其余拒绝,避免把非图当 base64 喂进模型)
REF_MIME = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
}
MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 单图 10MB(ARK 约束)
def resolve_in_root(
rel: str, working_dir: Path, user_root: Optional[Path]
) -> Optional[Path]:
"""三形态解析 + user_root 边界校验。命中返回解析后的绝对 Path,否则 None。"""
p = Path(rel)
candidates: list[Path] = []
if p.is_absolute():
candidates.append(p)
else:
candidates.append(working_dir / rel)
if user_root is not None:
candidates.append(user_root / rel)
root = (user_root or working_dir).resolve()
for c in candidates:
try:
rc = c.resolve()
except OSError:
continue
try:
rc.relative_to(root) # 越界(.. 逃逸 / 软链外指)直接跳过
except ValueError:
continue
if rc.is_file():
return rc
return None
def load_image_as_data_url(
rel: str,
*,
working_dir: Path,
user_root: Optional[Path],
display_fn: Callable[[Path], str],
max_bytes: int = MAX_IMAGE_BYTES,
) -> tuple[str, str, str]:
"""返回 (data_url, display_path, error)。
error 非空时前两者无意义,caller 直接把 error tool 结果返回(已是 `[Error] ...` 形态)
display_fn Tool._display,把解析路径渲成对外相对串(不泄漏部署绝对路径)
"""
resolved = resolve_in_root(rel, working_dir, user_root)
if resolved is None:
return "", "", (
f"[Error] 图片找不到或越界: {rel!r}。请传 task_dir 内已存在图片的相对路径"
f"(如 'figures/xxx.png',或工具上次返回的 saved 路径)。"
)
ext = resolved.suffix.lower()
mime = REF_MIME.get(ext)
if mime is None:
return "", "", (
f"[Error] 图片扩展名不支持: {ext or '(无)'}"
f"仅支持 {'/'.join(sorted(REF_MIME))}"
)
try:
raw = resolved.read_bytes()
except OSError as e:
return "", "", f"[Error] 读取图片失败: {type(e).__name__}: {e}"
if len(raw) > max_bytes:
mb = len(raw) / 1024 / 1024
return "", "", (
f"[Error] 图片 {mb:.1f}MB 超过 {max_bytes // 1024 // 1024}MB 上限。先压缩 / 缩小再传。"
)
b64 = base64.b64encode(raw).decode("ascii")
return f"data:{mime};base64,{b64}", display_fn(resolved), ""

180
tools/look_at_image.py Normal file
View File

@ -0,0 +1,180 @@
"""look_at_image: 给 DeepSeek V4 主模型(纯文本)借一双眼睛。
主模型无视觉,这个 tool 走豆包 Seed 2.0 Lite(全模态理解)读单图 OCR / 描述画面 /
读图表表格 / 识别物体模型自决何时调(用户贴了图问"这写的啥" / "图里是什么")
模型 ID + 单价全在 `config/media/doubao.yaml` vision , tool 只装配
计费:token 计费( chat),成功后写一行 usage_events(kind="vision")
图片路径解析 + base64 复用 tools/image_ref( seedream i2i 同一套边界/校验)
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
from uuid import UUID
from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import record_vision_usage
from .base import Tool, compact_tool_output
from .image_ref import load_image_as_data_url
# 不带 question 时的默认提问:全覆盖(描述 + OCR + 图表读数),让模型一次把图里能用的信息都吐出来
_DEFAULT_QUESTION = (
"请仔细看这张图并回答:"
"1) 完整描述画面内容(主体/场景/关键元素);"
"2) 如果图中有任何文字,逐字准确 OCR 出来,尽量保留原排版与换行;"
"3) 如果是图表/表格/示意图,把其中的数据、坐标轴、图例、结构关系读出来。"
)
class LookAtImageTool(Tool):
name = "look_at_image"
description = (
"Read/understand an image using Doubao Seed 2.0 Lite vision (the main model is text-only). "
"Use to OCR text, describe a picture, read charts/tables/diagrams, or identify objects in an "
"image the user uploaded (look for a `[用户上传的参考图] <path>` line in their message) or that "
"was generated/saved in the task. Pass the image path; optionally a specific question "
"(default: describe + OCR everything). Cheap (token-based, usually < ¥0.01). "
"Returns the model's textual reading of the image."
)
parameters = {
"type": "object",
"properties": {
"image": {
"type": "string",
"description": (
"要看的图片相对路径(task_dir 内,如 'figures/xxx.png',或用户消息里 "
"`[用户上传的参考图]` 行给的路径,或某工具上次返回的 saved 路径)。"
),
},
"question": {
"type": "string",
"description": (
"想从图里知道什么(可选)。如「这张图里的表格数据是多少」「图中仪表读数」"
"「把这页文字 OCR 出来」。不传则默认全面描述 + OCR 全部文字。"
),
},
},
"required": ["image"],
}
def __init__(
self,
*,
ark_cfg: ArkConfig,
vision_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,
) -> None:
super().__init__(base_dir, user_root=user_root)
self.ark_cfg = ark_cfg
self.cfg = vision_variant_cfg
self.variant_key = variant_key # 'seed_2_lite' → usage_events.model_profile = "doubao.seed_2_lite"
self.working_dir = Path(working_dir)
self.task_id = task_id
self.user_id = user_id
def execute(self, image: str, question: Optional[str] = None) -> str:
if not (image or "").strip():
return "[Error] image(图片路径)不能为空"
cfg = self.cfg
max_bytes = int(float(cfg.get("max_image_mb", 10)) * 1024 * 1024)
data_url, disp, err = load_image_as_data_url(
image.strip(),
working_dir=self.working_dir,
user_root=self.user_root,
display_fn=self._display,
max_bytes=max_bytes,
)
if err:
return err
q = (question or "").strip() or _DEFAULT_QUESTION
model_id = cfg["model_id"]
timeout_s = float(cfg.get("request_timeout_s", 60))
endpoint = cfg.get("endpoint", "/chat/completions")
body: dict[str, Any] = {
"model": model_id,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": q},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
],
}
try:
with ArkClient(self.ark_cfg, timeout_s=timeout_s) as client:
resp = client.post_json(endpoint, body, timeout_s=timeout_s)
except ArkError as e:
return f"[Error] look_at_image API: {e}"
answer = self._extract_answer(resp)
if not answer:
return (
"[Error] vision 响应缺内容(模型未返回文本)。"
"可能图片格式异常或模型暂不可用,稍后重试。"
)
usage = resp.get("usage") or {}
tin = int(usage.get("prompt_tokens", 0) or 0)
tout = int(usage.get("completion_tokens", 0) or 0)
# 记账;失败不阻塞 tool 返回(沿用 seedream 兜底)
cost_cny = 0.0
try:
cost = record_vision_usage(
task_id=self.task_id,
user_id=self.user_id,
model_profile=f"doubao.{self.variant_key}",
prompt_tokens=tin,
completion_tokens=tout,
input_cny_per_mtoken=float(cfg.get("price_cny_per_mtoken_input", 0)),
output_cny_per_mtoken=float(cfg.get("price_cny_per_mtoken_output", 0)),
extra_units={"image": disp},
)
cost_cny = float(cost)
except Exception as e:
print(f"[look_at_image] record_vision_usage failed: {type(e).__name__}: {e}", flush=True)
# 第一行 banner(key=value · 分隔,与 seedream/seedance 同协议,便于前端/对账)
banner = (
f"[look_at_image] model={model_id} · image={disp} · "
f"tokens={tin}+{tout} · cost=¥{cost_cny:.4f}"
)
# 图片解读正文可能很长(整页 OCR),压一下防爆上下文(保头尾)
return f"{banner}\n\n{compact_tool_output(answer)}"
@staticmethod
def _extract_answer(resp: dict) -> str:
"""OpenAI 兼容 chat 响应取文本: choices[0].message.content。
content 可能是 str,也可能是 list[{type:text,text:...}](多模态返回形态),都兜住
"""
choices = resp.get("choices")
if not (isinstance(choices, list) and choices):
return ""
msg = choices[0].get("message") if isinstance(choices[0], dict) else None
if not isinstance(msg, dict):
return ""
content = msg.get("content")
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts = [
c.get("text", "")
for c in content
if isinstance(c, dict) and c.get("type") == "text"
]
return "\n".join(p for p in parts if p).strip()
return ""

View File

@ -20,13 +20,18 @@ from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import check_daily_quota, record_image_usage from core.storage.usage import check_daily_quota, record_image_usage
from .base import Tool from .base import Tool
from .image_ref import load_image_as_data_url
class SeedreamTool(Tool): class SeedreamTool(Tool):
name = "seedream" name = "seedream"
description = ( description = (
"Generate an image with Doubao Seedream 5.0 and save to working_dir/figures/. " "Generate (text-to-image) OR edit (image-to-image) an image with Doubao Seedream 5.0, "
"Use when the user explicitly asks for an image / illustration / cover. " "saved to working_dir/figures/. Text-to-image: describe what to draw. "
"Image-to-image (改图): pass `reference_images` with an existing image path to modify it "
"at pixel level — use this when the user wants to tweak an already-generated/uploaded image "
"(e.g. '把刚才那张图的天空改成黄昏'), NOT a fresh text-to-image (which would lose the original). "
"Use when the user explicitly asks for / to change an image. "
"Each call costs ¥0.22 (¥0.05 extra if search=true). Don't generate decoratively — " "Each call costs ¥0.22 (¥0.05 extra if search=true). Don't generate decoratively — "
"only when the user actually wants an image. Returns the saved relative path." "only when the user actually wants an image. Returns the saved relative path."
) )
@ -35,11 +40,21 @@ class SeedreamTool(Tool):
"properties": { "properties": {
"prompt": { "prompt": {
"type": "string", "type": "string",
"description": "中文或英文都行,详尽描述画面(主体/风格/光线/构图)。直接传用户意图即可,模型自己理解。", "description": "中文或英文都行,详尽描述画面(主体/风格/光线/构图)。改图(reference_images)时只描述「要改成什么」即可。",
},
"reference_images": {
"type": "array",
"items": {"type": "string"},
"description": (
"改图(image-to-image):传 1 张已存在图片的相对路径(task_dir 内,如 "
"'figures/xxx.png',或 seedream 上次返回的 saved 路径)做像素级修改。"
"不传 = 从零文生图。**v1 只支持 1 张**(传多张会报错)。"
"基于刚生成/用户上传的图做局部修改,务必走这里指那张图,不要重新文生图。"
),
}, },
"size": { "size": {
"type": "string", "type": "string",
"description": "Image size like '2048x2048' / '1024x1024' / '3072x3072'. Defaults to config (2048x2048).", "description": "Image size like '2048x2048' / '1024x1024' / '3072x3072'. Defaults to config (2048x2048). 改图时建议保持 ≥1920²(ARK i2i 最小输出约束)。",
}, },
"watermark": { "watermark": {
"type": "boolean", "type": "boolean",
@ -78,6 +93,7 @@ class SeedreamTool(Tool):
def execute( def execute(
self, self,
prompt: str, prompt: str,
reference_images: Optional[list] = None,
size: Optional[str] = None, size: Optional[str] = None,
watermark: Optional[bool] = None, watermark: Optional[bool] = None,
search: Optional[bool] = None, search: Optional[bool] = None,
@ -85,6 +101,29 @@ class SeedreamTool(Tool):
if not (prompt or "").strip(): if not (prompt or "").strip():
return "[Error] prompt 不能为空" return "[Error] prompt 不能为空"
# 改图(i2i)分支:把参考图读成 base64 data URL → ARK body image_urls。
# 不传 / 空 → 走文生图(t2i),与历史行为完全一致(向后兼容)。
refs = [str(r).strip() for r in (reference_images or []) if str(r).strip()]
ref_data_urls: list[str] = []
ref_disp: list[str] = []
if len(refs) > 1:
return (
"[Error] reference_images v1 仅支持单张参考图(传了 "
f"{len(refs)} 张)。多图合成/角色定义留 v2,当前请只传 1 张。"
)
if refs:
data_url, disp, err = load_image_as_data_url(
refs[0],
working_dir=self.working_dir,
user_root=self.user_root,
display_fn=self._display,
)
if err:
return err
ref_data_urls.append(data_url)
ref_disp.append(disp)
is_i2i = bool(ref_data_urls)
# 每账号每日配额(yaml quotas.images_per_day)。失败 retry 不计,因为 # 每账号每日配额(yaml quotas.images_per_day)。失败 retry 不计,因为
# record_image_usage 只在成功+下载完才落库。tool 返串会进 LLM 上下文, # record_image_usage 只在成功+下载完才落库。tool 返串会进 LLM 上下文,
# 模型据此向用户解释,所以**只暴露用户该看的部分**(已用/上限 + 重置时间), # 模型据此向用户解释,所以**只暴露用户该看的部分**(已用/上限 + 重置时间),
@ -112,6 +151,9 @@ class SeedreamTool(Tool):
"response_format": "url", "response_format": "url",
"watermark": chosen_watermark, "watermark": chosen_watermark,
} }
if is_i2i:
# ARK /images/generations 接受 base64 data URL 作 image_urls(probe 2026-05-29 实测通)
body["image_urls"] = ref_data_urls
if chosen_search: if chosen_search:
# 豆包 search 参数透传(YAML 注释里说明加价 ~¥0.05/张) # 豆包 search 参数透传(YAML 注释里说明加价 ~¥0.05/张)
body["search"] = True body["search"] = True
@ -145,6 +187,8 @@ class SeedreamTool(Tool):
"size": chosen_size, "size": chosen_size,
"watermark": chosen_watermark, "watermark": chosen_watermark,
"search": chosen_search, "search": chosen_search,
"mode": "i2i" if is_i2i else "t2i",
"reference_images": ref_disp, # 改图时记录参考图(可追溯派生链),t2i 为空
"cost_cny": cost_cny, "cost_cny": cost_cny,
"elapsed_s": round(elapsed, 2), "elapsed_s": round(elapsed, 2),
"response_id": response_id, "response_id": response_id,
@ -173,10 +217,12 @@ class SeedreamTool(Tool):
# 第一行 banner:前端 SPA 把这行(name===seedream 时)单独提到 details summary # 第一行 banner:前端 SPA 把这行(name===seedream 时)单独提到 details summary
# 旁边显示,用户不展开就能看到 model / size / cost / 耗时 —— 透明性的关键。 # 旁边显示,用户不展开就能看到 model / size / cost / 耗时 —— 透明性的关键。
# 格式严格 key=value · 分隔,parse 用正则 `key=([^·\n]+)` 抓。 # 格式严格 key=value · 分隔,parse 用正则 `key=([^·\n]+)` 抓。
mode_seg = " · mode=i2i" if is_i2i else ""
ref_line = f"\nreference={ref_disp[0]}" if is_i2i else ""
return ( return (
f"[seedream] model={model_id} · size={chosen_size} · " f"[seedream] model={model_id} · size={chosen_size} · "
f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s\n" f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s{mode_seg}\n"
f"saved: {disp}\n" f"saved: {disp}{ref_line}\n"
f"prompt={prompt!r}\n" f"prompt={prompt!r}\n"
f"watermark={chosen_watermark} search={chosen_search}" f"watermark={chosen_watermark} search={chosen_search}"
) )

View File

@ -1065,11 +1065,29 @@ async function postMessageWithRetry(taskId, body) {
// overrideText:点 ask_user 选项时传入选项 label,直接作为用户消息发出(不读输入框、 // overrideText:点 ask_user 选项时传入选项 label,直接作为用户消息发出(不读输入框、
// 不清空输入框 —— 用户可能正在输入框打讨论草稿)。无参数则走输入框(正常发送)。 // 不清空输入框 —— 用户可能正在输入框打讨论草稿)。无参数则走输入框(正常发送)。
// 收集 chat-hint 里的粘贴附件路径(粘贴图片已上传到 task_dir,chip 带 data-rel)。
// 返回路径数组并清掉 chip —— 这些路径要随消息正文发给模型,否则模型不知道用户贴了哪张图
// (改图 / 看图都靠它定位)。只在「从输入框发送」时取,ask_user 选项点击(overrideText)不带附件。
function takePastedRels() {
const hint = $("chat-hint");
const wraps = hint ? Array.from(hint.querySelectorAll(".paste-chip-wrap[data-rel]")) : [];
const rels = wraps.map((w) => w.dataset.rel).filter(Boolean);
wraps.forEach((w) => w.remove());
return rels;
}
async function sendMessage(overrideText) { async function sendMessage(overrideText) {
if (!state.taskId) return; if (!state.taskId) return;
if (isCurrentTaskStreaming()) return; if (isCurrentTaskStreaming()) return;
const fromInput = typeof overrideText !== "string"; const fromInput = typeof overrideText !== "string";
const content = (fromInput ? $("chat-input").value : overrideText).trim(); let content = (fromInput ? $("chat-input").value : overrideText).trim();
// 粘贴附件路径注入正文:用户贴图后发的消息往往是「按这张改 / 看看这张图」,
// 模型只有拿到路径才能传给 seedream(reference_images)/ 未来的 look_at_image。
const pastedRels = fromInput ? takePastedRels() : [];
if (pastedRels.length) {
const lines = pastedRels.map((r) => `[用户上传的参考图] ${r}`).join("\n");
content = content ? `${content}\n\n${lines}` : lines;
}
if (!content) return; if (!content) return;
setActionMode("cancelling"); // 临时锁住,等 events_url 拿到再切 streaming setActionMode("cancelling"); // 临时锁住,等 events_url 拿到再切 streaming
$("chat-hint").textContent = "发送中…"; $("chat-hint").textContent = "发送中…";