diff --git a/DESIGN.md b/DESIGN.md index a9b24ff..daca927 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -526,7 +526,7 @@ create index on usage_events (model_profile, created_at); > 实施细节(步骤清单 / 验收项)进 PROGRESS + git;此处只留缺口、选型与取舍。 -### 8.1 图像理解 + Seedream i2i(2026-05-29,status=design 待启动) +### 8.1 图像理解 + Seedream i2i(2026-05-29 设计;i2i ✅ 2026-06-16 落地,look_at_image 仍待做) **缺口**:DeepSeek V4 主模型纯文本无视觉;`seedream` 只 t2i;"基于已生成图二次修改" / "上传外部参考图让 agent 据此干活"两条路径未覆盖。 @@ -537,6 +537,8 @@ create index on usage_events (model_profile, created_at); **关键实测**:Seedream 5.0 `/images/generations` 接受 `image_urls` base64 data URL,200 返新图 → **内网无需对象存储中介**(排除最大工程不确定性)。约束:输出 ≥~1920²、单张参考 ≤10MB、最多 14 张。 **风险 / 边界**:v1 只支持单张参考(multi-ref 角色定义靠 prompt,留 v2);base64 ARK 未承诺长期稳定(收紧则降级走 TOS 上传换 URL)。 + +**i2i 落地实况(2026-06-16,详 PROGRESS)**:`seedream` 加 `reference_images`(v1 单图,传 >1 报错);路径解析强制落 user_root 内防越界;前端 `chat.js` 补 paste 路径注入(把粘贴图路径作 `[用户上传的参考图]` 行进正文,修了"粘贴路径到不了模型"的既有缺口)。E 路(改图)完成;C 路(`look_at_image` 看图)仍待做。 **升级到 A 的信号**:用户要"贴图同时说话模型直接读图回话",或多轮带图成高频 —— 当前假设"图是工具调用对象"而非"对话内容"。 ### 8.2 Token 优化与上下文治理(2026-06-04,✅ 已落地,详 PROGRESS) diff --git a/PROGRESS.md b/PROGRESS.md index 44b5384..56e3090 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-06-15(plot_pub 吸收 nature-figure 投稿级复合图设计纪律) +最后更新:2026-06-16(seedream 加 i2i 改图 + 前端 paste 路径注入) --- @@ -21,6 +21,13 @@ ## 已完成关键能力 +### 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 形态 `/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 路径,作 `[用户上传的参考图] ` 行注入正文 + 清 chip ——**修了既有缺口**(之前粘贴的图路径根本到不了模型)。这样"上传外部图 → 改图 / 看图"才能定位到文件。 +- 引导:`skills/imagegen/SKILL.md` 删旧「不接图像输入」结论 + 加「改图(i2i)」专段(最易踩错=该 i2i 却重新 t2i 丢构图);`agent_builder.py` 媒体 block 提 i2i + paste 注入约定;`SKILL_LIST.md` 同步。`look_at_image`(看图)仍待做。bump 0.14.0 → 0.15.0。 + ### 2026-06-16 / ask_user:回复里渲染可点击「方案确认」选项卡(Claude 式) - 需求:agent 在分叉点能像 Claude 那样抛出可点选项,用户点一个继续、或不点直接用文字讨论。设计取舍见下。 diff --git a/SKILL_LIST.md b/SKILL_LIST.md index 89eaae0..dc25f74 100644 --- a/SKILL_LIST.md +++ b/SKILL_LIST.md @@ -1,7 +1,7 @@ # zcbot Skill 清单 服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材) -最后更新:2026-06-15 +最后更新:2026-06-16 Skill 总数:15 zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。 @@ -24,7 +24,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + | 文献检索 | [documents](#documents) | 查内部 7 学科材料知识库(21W+ 论文,跨语言检索;host-side tool 持 key) | | 科研计算 | [pymatgen](#pymatgen) | 晶体结构 / XRD 模拟 / 相图 / Materials Project(host-side tool 持 key) | | 科研计算 | [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 起 / 段) | | 通用 | [analyze](#analyze) | 科学问题拆解 / 引导(模糊命题 → 子问题 + 路线图) | | 通用 | [coding](#coding) | 修代码 / 调试 / 重构 | @@ -328,9 +328,9 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S ## 内容生成 ### 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 秒出图。 @@ -346,8 +346,8 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S **何时不走本 skill**: - ⛔ 用户没主动要图 —— 别为"丰富回复"装饰性生图 -- ⛔ 用户给参考图说"按这个改" —— Seedream 5.0 是文生图,不接图像输入 -- ⛔ 已有合适素材 —— 直接 read / 引用,别重新生成 +- ✅ 用户给参考图 / 对刚生成的图说"按这个改 / 改成 X" —— 走**改图(i2i)**:`reference_images` 指那张图,**别重新文生图**(重画会丢原构图);v1 单图 +- ⛔ 已有合适素材且不改 —— 直接 read / 引用,别重新生成 **关键岔路**: - 节点-箭头-结构关系明确(技术路线 / 流程图)→ **走 mermaid**(矢量、零成本、可编辑) diff --git a/core/__init__.py b/core/__init__.py index c41a19a..2867865 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.14.0" +__version__ = "0.15.0" diff --git a/core/agent_builder.py b/core/agent_builder.py index c7e490f..e343db0 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -66,9 +66,10 @@ from core.bocha_client import BochaConfig _MEDIA_TOOLS_BLOCK = """\ ## 媒体生成工具(seedream 图 / seedance 视频) -- `seedream` —— 豆包图像生成。产物自动落 `/figures/`。每次 **¥0.22**(联网 `search=true` 加 ¥0.05)。 - - **调用前必须先 `load_skill('imagegen')`** —— skill 里有「何时该用 / 该不该用 mermaid 替代 / 用户描述模糊度诊断 / 一次性追问范式 / prompt 装配 / 失败解药」全套引导。**不要拿用户原话直接当 prompt 调 tool** —— 容易烧 ¥0.22 在错的方向上。 - - 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调。 +- `seedream` —— 豆包图像生成 / 改图。产物自动落 `/figures/`。每次 **¥0.22**(联网 `search=true` 加 ¥0.05)。 + - **文生图**(不传 `reference_images`):从零按 prompt 画。**改图 i2i**(传 `reference_images=["figures/xxx.png"]`):在已有图上做像素级修改。**用户对刚生成 / 上传的图说"改成 X / 换个颜色 / 去掉某处" → 必须走改图(reference_images 指那张图),绝不重新文生图**(重画 = 完全不同的图,丢原构图)。v1 改图仅支持单张参考。 + - **调用前必须先 `load_skill('imagegen')`** —— skill 里有「何时该用 / 该不该用 mermaid 替代 / 用户描述模糊度诊断 / 一次性追问范式 / prompt 装配 / 改图(i2i)范式 / 失败解药」全套引导。**不要拿用户原话直接当 prompt 调 tool** —— 容易烧 ¥0.22 在错的方向上。 + - 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调。用户消息里出现 `[用户上传的参考图] <路径>` = 用户贴了图,要看图 / 改图时用那个路径。 - `seedance` —— 豆包视频生成(Seedance 2.0 Fast)。异步任务,**等 30-90s 出片**;产物自动落 `/videos/`。每次 **¥1.86 起**(480p 4s)~ **¥12+**(720p 15s),比图贵 10 倍以上。触发词:视频 / 动画 / 动起来 / 做个 video / 镜头 / 短片 / 演示视频 / 动效。 - **调用前必须先 `load_skill('videogen')`** —— skill 里有「6 维诊断(含运动维必填)/ seedream/mermaid 反向选型 / prompt 装配 / 参数取舍(时长/分辨率/比例直接决定钱)/ 失败解药」全套引导。视频比图贵 10 倍且 90s 等待,绝对不要拿用户原话当 prompt 直接调。 - 兜底硬约束:用户没主动要视频就别装饰性生成(比生图更严重的红线);同一目的不满意**绝不连发**(1 次错 = ¥4+60s,连发 2 次 = ¥8+2min);phase 1 仅文生视频,**不支持** image-to-video / video-to-video。""" diff --git a/skills/imagegen/SKILL.md b/skills/imagegen/SKILL.md index 8968a2e..73f4039 100644 --- a/skills/imagegen/SKILL.md +++ b/skills/imagegen/SKILL.md @@ -1,6 +1,6 @@ --- 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 @@ -31,8 +31,9 @@ description: 用豆包 Seedream 5.0 生图(`seedream` tool)。**任何生图任 ## 何时不走本 skill(直接走通用工具) - 用户**没主动要图**(别为"丰富回复"装饰性生图 —— 这是 system prompt 红线) -- 用户给了具体参考图说"按这个改" —— Seedream 5.0 是文生图不接图像输入,告诉用户走描述 -- 已有合适素材(用户上传 / 之前生成过)—— 直接 `read` / 引用,别重新生成 +- 已有合适素材且用户**没要改**(用户上传 / 之前生成过)—— 直接 `read` / 引用,别重新生成 + +> 用户给了参考图说"按这个改" / 对刚生成的图说"改成 X" —— **这是改图(i2i),不是不能做**,走下面「改图」段用 `reference_images`,**别再走文生图从零画**。 ## 关键岔路:mermaid vs seedream @@ -174,6 +175,34 @@ seedream( 产物自动落 `/figures/<时间戳>-.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。失败模式 / 解药: diff --git a/tools/image_ref.py b/tools/image_ref.py new file mode 100644 index 0000000..a05059b --- /dev/null +++ b/tools/image_ref.py @@ -0,0 +1,92 @@ +"""共享:把模型给的图片路径解析 + 读成 base64 data URL。 + +seedream(改图参考)与 look_at_image(看图)共用同一套路径解析 + 校验: + - 三种路径形态都吃:`figures/x.png`(working_dir 相对)/ `/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), "" diff --git a/tools/seedream.py b/tools/seedream.py index 78bcd26..7a168c6 100644 --- a/tools/seedream.py +++ b/tools/seedream.py @@ -20,13 +20,18 @@ from core.ark_client import ArkClient, ArkConfig, ArkError from core.storage.usage import check_daily_quota, record_image_usage from .base import Tool +from .image_ref import load_image_as_data_url class SeedreamTool(Tool): name = "seedream" description = ( - "Generate an image with Doubao Seedream 5.0 and save to working_dir/figures/. " - "Use when the user explicitly asks for an image / illustration / cover. " + "Generate (text-to-image) OR edit (image-to-image) an image with Doubao Seedream 5.0, " + "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 — " "only when the user actually wants an image. Returns the saved relative path." ) @@ -35,11 +40,21 @@ class SeedreamTool(Tool): "properties": { "prompt": { "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": { "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": { "type": "boolean", @@ -78,6 +93,7 @@ class SeedreamTool(Tool): def execute( self, prompt: str, + reference_images: Optional[list] = None, size: Optional[str] = None, watermark: Optional[bool] = None, search: Optional[bool] = None, @@ -85,6 +101,29 @@ class SeedreamTool(Tool): if not (prompt or "").strip(): 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 不计,因为 # record_image_usage 只在成功+下载完才落库。tool 返串会进 LLM 上下文, # 模型据此向用户解释,所以**只暴露用户该看的部分**(已用/上限 + 重置时间), @@ -112,6 +151,9 @@ class SeedreamTool(Tool): "response_format": "url", "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: # 豆包 search 参数透传(YAML 注释里说明加价 ~¥0.05/张) body["search"] = True @@ -145,6 +187,8 @@ class SeedreamTool(Tool): "size": chosen_size, "watermark": chosen_watermark, "search": chosen_search, + "mode": "i2i" if is_i2i else "t2i", + "reference_images": ref_disp, # 改图时记录参考图(可追溯派生链),t2i 为空 "cost_cny": cost_cny, "elapsed_s": round(elapsed, 2), "response_id": response_id, @@ -173,10 +217,12 @@ class SeedreamTool(Tool): # 第一行 banner:前端 SPA 把这行(name===seedream 时)单独提到 details summary # 旁边显示,用户不展开就能看到 model / size / cost / 耗时 —— 透明性的关键。 # 格式严格 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 ( f"[seedream] model={model_id} · size={chosen_size} · " - f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s\n" - f"saved: {disp}\n" + f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s{mode_seg}\n" + f"saved: {disp}{ref_line}\n" f"prompt={prompt!r}\n" f"watermark={chosen_watermark} search={chosen_search}" ) diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 6b66568..e5505ef 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1065,11 +1065,29 @@ async function postMessageWithRetry(taskId, body) { // 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) { if (!state.taskId) return; if (isCurrentTaskStreaming()) return; 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; setActionMode("cancelling"); // 临时锁住,等 events_url 拿到再切 streaming $("chat-hint").textContent = "发送中…";