From c04b8ba05e3b251a324ef47babb2a339dcb7dbcc Mon Sep 17 00:00:00 2001 From: caoqianming Date: Wed, 20 May 2026 15:20:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(media):=20=E6=8E=A5=E5=85=A5=E8=B1=86?= =?UTF-8?q?=E5=8C=85=20Seedream=205.0=20=E5=9B=BE=E5=83=8F=E7=94=9F?= =?UTF-8?q?=E6=88=90=20tool=20+=200007=20cost=5Fusd=E2=86=92cost=5Fcny=20?= =?UTF-8?q?=E5=85=A8=E8=A1=A8=E7=BB=9F=E4=B8=80=E5=B8=81=E7=A7=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新 tools/seedream.py:调 ark /images/generations 同步生成,产物落 figures/-.png + 同名 .meta.json - 新 core/ark_client.py:火山方舟 HTTP 封装(base URL + bearer auth + 异常翻译 + download),共享给后续 seedance - 新 config/media/doubao.yaml:独立命名空间;价格表注释 last_updated + 调价路径说明 - core/storage/usage.py 加 record_image_usage:单价 snapshot 进 units jsonb,防调价污染历史 - agent_builder.py 注册 SeedreamTool:仅当 ARK_API_KEY 设了才挂(无 key 用户无感) - 0007 migration:tasks/usage_events 双 rename cost_usd → cost_cny,×7.2 一次性折算; record_chat_usage 内部把 litellm USD 同样 ×7.2 落 CNY,免分类汇总 - prompts/system/general_v1.md 加「媒体生成工具」段,提示按需调用、不主动装饰 - dev SPA tool_result 折叠态显示 banner(model/size/cost/elapsed 徽章),不展开就透明 - scripts/smoke_seedream.py:端到端走通(待 ARK_API_KEY 配齐真跑会产生 ~¥0.22) Co-Authored-By: Claude Opus 4.7 (1M context) --- PROGRESS.md | 18 +- RUN.md | 8 +- config/media/doubao.yaml | 33 +++ core/agent_builder.py | 25 +++ core/ark_client.py | 126 +++++++++++ core/storage/models.py | 4 +- core/storage/usage.py | 81 +++++-- core/storage/utils.py | 2 +- core/task.py | 4 +- .../20260520_1800_0007_cost_usd_to_cny.py | 40 ++++ prompts/system/general_v1.md | 7 + scripts/smoke_seedream.py | 114 ++++++++++ tools/seedream.py | 206 ++++++++++++++++++ web/app.py | 2 +- web/static/dev.html | 43 +++- 15 files changed, 684 insertions(+), 29 deletions(-) create mode 100644 config/media/doubao.yaml create mode 100644 core/ark_client.py create mode 100644 db/migrations/versions/20260520_1800_0007_cost_usd_to_cny.py create mode 100644 scripts/smoke_seedream.py create mode 100644 tools/seedream.py diff --git a/PROGRESS.md b/PROGRESS.md index ef31ef2..0b736a3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。 -最后更新:2026-05-20(`POST /v1/files/delete` 加 `recursive` 字段 + 顶层目录 task 引用闸 + dev SPA 二次确认) +最后更新:2026-05-20(豆包 Seedream 5.0 图像生成 tool 接入 + cost_usd → cost_cny 全表统一币种) --- @@ -23,6 +23,10 @@ ### 2026-05-20 +- **dev SPA seedream tool 透明性 banner(model/size/cost/elapsed)**:用户问"实际生图用哪个模型 / 价格区别 / 前端要不要给用户选";seedream 现仅一个 variant(5.0),无选择空间 — 但用户**能看到**用了什么模型、花了多少是基本透明度。最小路径:SeedreamTool 返回串首行改成 `[seedream] model=... · size=... · cost=¥... · elapsed=...s` 结构化 banner(用 `·` 分隔 + `key=value` 严格格式,正则 parse);dev SPA 新加 `extractMediaBanner(toolName, resultText)` helper,流式 `tool_result` 与历史回放 `role==="tool"` 两路都在 `
` 的 `` 旁挂一行徽章(`.tool-banner .kv`,model 红字 / cost 暗红 / 其他灰色);model 文本去 `doubao-` 前缀与 `-260128` 日期后缀截短显示 `seedream-5-0`;**折叠态可见,无需展开**。LLM 看到的完整文本不丢(banner 同条第一行就是字符串)。**没动**:tool schema(不加 model 参数 — 单 variant 没意义,等 seedance 二期 pro/fast 真有价差时统一加 task 级下拉 + `tasks.image_model_profile` 列设计)、artifact chip 抽取(figures/*.png 现有逻辑无变化)、DB / 后端。**Tradeoff**:走文本 banner 而非从 .meta.json fetch — 简单 + 即时,代价是 tool 返回串格式成"前端约定"(改格式要同步前端 regex)。 + +- **豆包 Seedream 5.0 图像生成 tool 接入(seedance/视频留 Phase 2)+ 0007 migration `cost_usd` → `cost_cny` 全表统一币种**:用户要接 doubao-seedream-5-0-260128 + doubao-seedance-2-0-260128 + doubao-seedance-2-0-fast-260128,先做 seedream(同步 API 简单,跑通整条管线);seedance 异步 + token 计费复杂,留二期。**架构判断**:seedream/seedance 不是 chat LLM 范式(litellm 不覆盖,异步 task 形态,价格 per-image/per-second),**不进 chat 顶栏 model 下拉,做成 agent 可调 tool**;`config/media/doubao.yaml` 独立命名空间(`ark_api_key_env=ARK_API_KEY` + `ark_base_url=https://ark.cn-beijing.volces.com/api/v3` + image variants);**不复用 `ModelCapabilities`**(chat 长上下文/thinking schema 不适用)。**新文件**:① `core/ark_client.py`(httpx 封装 base URL + bearer auth + 异常翻译 + `download(url, dest)` 流式下载产物 — 复用给后续 seedance);② `tools/seedream.py::SeedreamTool`(prompt 必填 / size / watermark / search 可选 → POST `/images/generations` → 响应解析 `_extract_url`(三种 shape 兜底:OpenAI `data[].url` / 豆包 `data.images[].url` / 递归扫第一个 http url)→ 立刻下载到 `/figures/-.png` + 同名 `.meta.json`(prompt/model_id/size/cost_cny/elapsed/response_id/ts)→ `record_image_usage` 写 `kind="image"` 行)。**计费**:`record_image_usage` 接 CNY 直落,**`price_cny_per_image` snapshot 进 units jsonb**(`{"n_images":1, "size":"2048x2048", "search":false, "price_cny_per_image":0.22}`)—— 这是**调价防漂移**关键:豆包改价改 YAML 重启即可,历史 usage_events 自带快照不受污染,跨调价对账 `SELECT units->>'price_cny_per_image', cost_cny ... GROUP BY` 能拉出不同价位累计。**币种统一(0007 migration)**:`tasks.cost_usd` + `usage_events.cost_usd` 双 rename → `cost_cny`,现有数据 `×7.2` 一次性折算(开发期数据小且 chat 多用国产模型 litellm cost map 不收录原本就是 0),`record_chat_usage` 内部把 litellm USD `×7.2` 落 CNY,全表统一币种免按 user 总账单分类汇总。**注册策略**:`agent_builder.py::build_agent` 调 `ArkConfig.load()`,**仅当 `ARK_API_KEY` env 设了才挂 tool**(无 key 用户感知零变化,不会看到 schema 里多个永远报错的工具);构造时注入 task_id / user_id / working_dir / ark_cfg(沿用 `user_root=` 注入范式)。**system prompt**(`prompts/system/general_v1.md`):加「媒体生成工具」段提示按需调用、不主动装饰生成、流程图优先 mermaid (skill 已有管线) — seedream 适合写实/概念/艺术风格图。**没动**:`ModelCapabilities`(避免 schema 污染)、dev SPA(图预览 modal 已支持 png,artifact chip 已识别 figures/*.png 自动渲染缩略图)、`tasks.cost_cny` 列读写路径(record_chat_usage / record_image_usage 都只写 usage_events,task 级累计列仍由后续 sync 补)。**Tradeoff**:① CNY 折算用固定汇率 7.2,涨跌 ±5% 误差开发期接受,真精算应按调用时刻汇率但太重;② 涨价瞬间到 YAML 改完的窗口期记账偏低(豆包不会无预警调价,且 units snapshot 让历史数据可还原)。**待办**:① smoke 真调豆包接口走通(等用户配 `ARK_API_KEY`);② Phase 2 接 seedance(异步 task + polling + 进度 SSE 事件,复用 ark_client.download)。 + - **`POST /v1/files/delete` 加 `recursive` 字段(级联删除非空目录) + 顶层目录 task 引用闸 + dev SPA 二次确认显示条目数**:用户报"文件夹内有文件就不给删除",需要级联删除。**后端**:`FileDeleteRequest` 加 `recursive: bool = False`,handler `recursive=False` 沿用 `target.rmdir()`(非空仍 400);`recursive=True` 走 `shutil.rmtree`,但**目标是顶层目录(`target.parent.resolve() == root.resolve() and is_dir()`)且被 ≥1 task 引用**(`SELECT count(*) FROM tasks WHERE user_id=uid AND working_dir=db_form`) → **409**,文案"该顶层目录正被 N 个 task 引用,不能递归删除;请先 DELETE task,再清残留文件"。这复用 `move` 接口的"working_dir = 顶层目录"invariant 守门思路 —— 允许递归删 working_dir 会让 DB 还在引用但 FS artifacts 已没了;DELETE task 流程已经 best-effort rmdir 空目录,DB 行删掉后顶层目录回到"无 task 引用"状态,这时 recursive delete 才放行。空目录(顶层或子级)两种模式都可删,task.working_dir 字段不动(沿用"FS 视图可重生"心智)。**前端**(`web/static/dev.html::deleteFile`):目录删先 `GET /v1/files?path=rel` 探子条目,空目录走原 confirm(`recursive=false`);非空目录二次确认"目录 X 含 N 项(含子目录),将递归删除全部内容,不可恢复。(若为顶层目录且仍被 task 引用,需先删 task)\n确认?"+ `recursive=true`。**没动**:`DELETE /v1/tasks/{id}` 流程(那条仍只 rmdir 空目录,保留"删 task ≠ 删素材"心智)、`POST /v1/files/move` 的顶层目录闸(那是为了维持 invariant,递归删的 409 文案对齐 move 的 409 语义)、smoke 测试(原 case 1/4/6/7 仍跑非递归路径)、DESIGN(API 字段添加非架构变更)。**Tradeoff**:UI 显示的是直接子项条目数,深层子树文件数不预报(只标"含子目录"提示);加 `count` 后端 helper 又给前端一次额外探询,体感分裂,先简单版。 - **fs tool 输出渲染为 user_root-relative 路径(根因消 chip 404 + 防 uuid/部署根泄漏) + dev SPA chip 工作目录锚点修正 + assistant 正文也挂 chip**:用户报对话内 chip 点击 404,根因不在 chip 抽取本身 —— `task.working_dir` DB 形态是 `workspace/users//`(`to_db_path`),前端 `filesPath` 取了 `.split("/").pop()` 末段但 chip 提取器之前直接拿整串作锚点,正则吃到 `workspace/users///foo.md`,backend `_safe_join` 拼出来不存在 → 404。两层修:① **tool 侧根治**:`tools/base.py::Tool` 加 `user_root` kwarg + `_display(p)` helper(p 在 user_root 内 → POSIX 相对串,外 → 原绝对),`tools/fs.py` 五个 tool(Read/Write/Edit/Glob/Grep)所有结果串里 `{p}` 替成 `{self._display(p)}` — 现在 `[wrote N chars to wd/foo.md]` 而不再 `[wrote N chars to /home/lighthouse/...//wd/foo.md]`。`core/agent_builder.py::build_agent` 加 `ur_path = user_root(workspace_dir, uid)` 并透传给所有 tool 构造(含 LoadSkillTool / RunPythonTool / ShellTool — base 默认接 None 不影响);`tools/skill_tool.py::LoadSkillTool.__init__` 加 `user_root` 转传 super。**附带收益**:截图分享对话不再泄 user_id + 服务器路径根;chip rel 直接就是 user_root-relative,与 `/v1/files/download` 边界吻合。② **前端 chip 锚点修正**:`web/static/dev.html` 加 `_workingDirName(workingDir)` helper —— `\` 归 `/` 后,绝对路径(`/...` 或 `C:/...`)返空(外部 --working-dir 文件不在 user_root,backend 也拒,挂 chip 无意义),否则取最后非空段。5 个 chip 抽取调用点(`renderMessages` 的 tool / assistant tool_calls + assistant 正文 + `handleSseEvent` 的 tool_call / tool_result)统一用这个 helper 代替原 `state.taskMeta.working_dir` 直取。③ **assistant 正文也挂 chip**:`renderMessages` 里 assistant `
` 渲完后 `extractArtifactRels(p.content, wd)` 抽出助手 echo 的路径同样挂 chip 条(user 输入不抽,避免他打字过程中误触发)。流式途中不实时挂 — `fetchSse` 收尾自动 `loadMessages()` 重渲染,chip 顺势出现,降低实现复杂度。**没动**:`/v1/files/download` 后端(本来就接 user_root-relative)、ShellTool / RunPythonTool 的 stdout/stderr(subprocess 自己 print 的绝对路径无法干预,且不是 agent 工具直接吐的"系统消息")、DESIGN(无架构/schema 变化)、RUN(无对外命令变化)。**Tradeoff**:旧消息(本次改动前历史 tool result)里仍有绝对路径,但 chip 抽取以 wdName 末段为锚 → 旧路径里的 `//...` 子串也能匹配出正确 rel,**新旧消息 chip 都可点**(回测验证:`extractArtifactRels("/home/.../uuid/wd/foo.md", "wd")` 返 `["wd/foo.md"]`)。 - **`POST /v1/tasks/{id}/clear` 清空对话 + dev SPA「清空对话」按钮**:用户要在同一 task 内重新开始对话。后端新路由:同事务 `SELECT … FOR UPDATE` 锁 + `run_status in (running, cancelling)` → 409(先 cancel)+ `DELETE FROM messages WHERE task_id=tid` + reset `tasks.tokens_prompt/completion/cost_usd=0` + `run_status='idle'` + `run_error=None`,返回新 task dict(`n_messages=0`)。**`usage_events` 表完全不动** — 那是用户级账户账单的 source of truth,清空对话不该影响计费;`usage_events.message_id` FK 是 `ondelete=SET NULL`(models.py:128),message_id 列变 NULL,但 task_id/model_profile/units(tokens_in/out)/cost_usd 全保留,按 task_id 聚合可重建历史累计。**reset task 三列累计 vs 保留累计**:选 reset,因为顶栏「N 条 · M tok」显示"0 条 vs 50k tok"会视觉矛盾;真正账单数据在 usage_events 完整无损。dev SPA 顶栏在「导出对话记录」后插「清空对话」按钮(紫色 hover #8e44ad,区别于完成绿/废弃橙/删除红),`renderChatMeta` 里 `running||n_messages==0 → disabled`,confirm 二次确认(显示任务名 + 消息数),clear 后 `renderMessages([])` + `renderChatMeta()` + `loadTaskList()` 同步列表。**没动**:DESIGN(无架构/schema 字段语义变化)、其他 task 写路径、FS 文件(沿用 task delete 的"FS 视图可重生"心智 — 中间产物保留,模型重起对话可继续基于已有素材推进)、SSE 协议。 @@ -135,11 +139,12 @@ core/memory.py 81 ← per-user `.memory/` dotfile core/export_docx.py 383 core/storage/__init__.py 29 ← record_chat_usage 出口(0006) core/storage/engine.py 80 -core/storage/models.py 130 ← 4 表(0004 删 runs;0005 email UNIQUE;0006 加 usage_events v2 + messages.model_profile) -core/storage/usage.py 70 ← 0006:record_chat_usage(litellm cost map + 双写 messages + insert usage_events) +core/storage/models.py 130 ← 4 表(0004 删 runs;0005 email UNIQUE;0006 加 usage_events v2 + messages.model_profile;0007 cost_usd → cost_cny) +core/storage/usage.py 125 ← record_chat_usage(USD→CNY ×7.2)+ record_image_usage(media tool 入口,单价 snapshot 进 units) core/storage/utils.py 136 -core/agent_builder.py 307 ← 装配 lib(原 main.py 内容,05-18 改名归位) -tools/{base,fs,shell,run_python,skill_tool}.py ~440 行 +core/ark_client.py 105 ← 火山方舟 HTTP 客户端(共享给 seedream / 后续 seedance) +core/agent_builder.py 325 ← 装配 lib(05-20 加 SeedreamTool 注册,有 ARK_API_KEY 才挂) +tools/{base,fs,shell,run_python,skill_tool,seedream}.py ~640 行 main.py ~210 ← 入口:web / db / probe / user(05-19 加 user) db/migrations/env.py 61 db/migrations/versions/ @@ -149,6 +154,7 @@ db/migrations/versions/ 0004_drop_runs_usage_events.py 77 0005_users_email_unique.py 28 ← 0005 一日游 invites 已撤,接 users.email UNIQUE 0006_usage_events_v2_and_message_model.py 60 ← messages.model_profile 列 + usage_events v2 表(多态 units jsonb) + 0007_cost_usd_to_cny.py 40 ← tasks/usage_events 双 rename cost_usd→cost_cny + ×7.2 backfill web/__init__.py 5 web/app.py ~1320 ← /v1 JSON API + user_id 隔离 + run lock + cancel + files copy/move web/auth.py ~190 ← D' 过渡:邮箱密码 + platform_key → JWT @@ -160,7 +166,7 @@ web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx(office 预览) Python 合计 ~3400 行(+ dev.html 1700 静态 + vendor 1MB) ``` -加 `skills/ppt|proposal|coding/` 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。 +加 `skills/ppt|proposal|coding/` 脚本 ~600 行 + SKILL.md / references / config / prompts(含 `config/media/doubao.yaml`)+ alembic.ini,总仓库约 3700 行。 --- diff --git a/RUN.md b/RUN.md index 405622c..b5ce9f4 100644 --- a/RUN.md +++ b/RUN.md @@ -14,6 +14,8 @@ DEEPSEEK_API_KEY=sk-... # 用 GLM 的话再加一条;国际站 z.ai 用 ZAI_API_KEY,国内站 bigmodel.cn 用 ZHIPUAI_API_KEY(对应 config/models/glm.yaml 的 api_key_env 字段) ZHIPUAI_API_KEY=... + # 豆包(火山方舟)图像/视频生成:可选。设了就挂上 seedream tool(0.22 元/张);未设 tool 不出现 + ARK_API_KEY=... ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot # main.py web 必填(probe/db/user 不验) PLATFORM_KEY=<≥16 字符随机串,platform 机器对机器入口校验> @@ -40,7 +42,7 @@ python -m venv .venv # 3) DB schema 上车 .venv/Scripts/python.exe main.py db upgrade head -.venv/Scripts/python.exe main.py db current # 应输出 0005 (head) +.venv/Scripts/python.exe main.py db current # 应输出 0007 (head) ``` --- @@ -126,7 +128,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta | `POST /v1/tasks/{id}/messages` | `{content}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);UI 应 disable send 直到 SSE `done` | 必填 | | `GET /v1/tasks/{id}/events` | SSE 流(`event: ` + `data: `);订阅 task 当前活动 | 必填 | | `POST /v1/tasks/{id}/cancel` | 协作式 cancel;`run_status != running` → 409;LLM 同步 call 不可中断,最坏等当前一轮跑完 | 必填 | -| `POST /v1/tasks/{id}/clear` | 清空当前 task 全部 messages + reset `tasks.tokens_prompt/completion/cost_usd` 三列累计 + `run_status='idle'`;`usage_events`(账单记账)**不动**,只 `message_id` 列变 NULL;run 活跃中(running/cancelling)→ 409(先 cancel);FS 文件保留 | 必填 | +| `POST /v1/tasks/{id}/clear` | 清空当前 task 全部 messages + reset `tasks.tokens_prompt/completion/cost_cny` 三列累计 + `run_status='idle'`;`usage_events`(账单记账)**不动**,只 `message_id` 列变 NULL;run 活跃中(running/cancelling)→ 409(先 cancel);FS 文件保留 | 必填 | | `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 | | `GET /v1/files/download?path=` | 下单文件 | 必填 | | `POST /v1/files/upload` | multipart 上传到 `//`;路径不存在自动 mkdir,重名覆盖 | 必填 | @@ -255,6 +257,8 @@ sudo journalctl -u zcbot -n 50 # 看新进程起没起干 | `POST /v1/tasks/{id}/clear` 返 409 `task has an active run` | 当前 run 还没跑完;先点停止 / `POST .../cancel` 等流式 done 再清空 | | 点 stop 后流式没立刻停 | LLM 同步 call 不可中断,最坏等当前一轮跑完(几十秒);loop 进入下个 check 点(每轮 LLM 前 / 每个 tool_call 前)就退,emit `cancelled` → SSE `done` → UI 收回 stop | | `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 | +| `seedream` tool 没出现在对话里 | `.env` 没设 `ARK_API_KEY`,build_agent 跳过注册。设了重启 web 即可;无需迁移、无需 DB 改动 | +| 豆包调价了 | 改 `config/media/doubao.yaml` 的 `price_cny_per_image` 一行 → 重启 web。**历史 usage_events 不受影响**(units jsonb 里有当时单价 snapshot,聚合查仍按旧价);新写入按新价。涨价瞬间到改 YAML 中间这段记账偏低,开发期接受 | | `kill -HUP ` 后 `/openapi.json` 没新接口 | uvicorn **不响应 SIGHUP**(没装 handler,落 Python 默认终止;Windows 上信号本身无效)。Ubuntu 上用 `systemctl restart zcbot`,或 unit 加 `--reload` 让 uvicorn 监听文件自动重起(见"部署"段)。验证:`curl -s http://127.0.0.1:8765/openapi.json \| python3 -c 'import sys,json;print([p for p in json.load(sys.stdin)["paths"] if "auth" in p])'` | | `systemctl restart zcbot` 卡 10s 才退 | 有 SSE 长连接,uvicorn graceful shutdown 等 in-flight。unit 已设 `TimeoutStopSec=10` 兜 SIGKILL,正常现象;真急用 `systemctl kill -s KILL zcbot` | | `POST /v1/files/rename` 返 409 `folder has active run(s)` | 顶层目录被某 running/cancelling 的 task 占用;先 cancel 等流式 done 再 rename | diff --git a/config/media/doubao.yaml b/config/media/doubao.yaml new file mode 100644 index 0000000..22dcea1 --- /dev/null +++ b/config/media/doubao.yaml @@ -0,0 +1,33 @@ +# 豆包(火山方舟 Ark)媒体生成模型档案。 +# +# 价格表 last_updated: 2026-05-20 +# 源: https://www.volcengine.com/docs/82379/1544106 +# 豆包调价时手动更新本文件 + 重启 web。历史 usage_events 自带 snapshot 不受影响 +# (record_image_usage 把 price_cny_per_image 写进 units jsonb 列)。 +# +# 接入方式:走 ark 原生 HTTP(litellm 不覆盖图像/视频),core/ark_client.py 封装统一调用。 +# image (seedream) 同步返 URL;video (seedance) 异步 task + polling — 本期仅落地 image。 + +ark_api_key_env: ARK_API_KEY +ark_base_url: https://ark.cn-beijing.volces.com/api/v3 + +image: + seedream_5: + model_id: doubao-seedream-5-0-260128 + display_name: 豆包 Seedream 5.0 + # 同步生成,3-5 秒出图。OpenAI Images API 兼容路径 /images/generations。 + endpoint: /images/generations + price_cny_per_image: 0.22 # 计费单位:成功输出张数;调价改这里 + 重启 + default_size: 2048x2048 # 原生最高 3072x3072;2K 兼顾质量/体积 + default_watermark: false # 默认无水印(申报/PPT 场景反需求) + default_search: false # web search 额外加价 ~¥0.05/张;默认关 + request_timeout_s: 60 # 出图慢于此判超时 + +# video (seedance) 待 Phase 2: +# video: +# seedance_2: +# model_id: doubao-seedance-2-0-260128 +# ... +# seedance_2_fast: +# model_id: doubao-seedance-2-0-fast-260128 +# ... diff --git a/core/agent_builder.py b/core/agent_builder.py index 2539b29..23f0a8a 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -37,9 +37,12 @@ from core.storage import check_no_subtask from core.task import TaskState from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool from tools.run_python import RunPythonTool +from tools.seedream import SeedreamTool from tools.shell import ShellTool from tools.skill_tool import LoadSkillTool +from core.ark_client import ArkConfig + def load_config() -> dict: return yaml.safe_load((ROOT / "config" / "agent.yaml").read_text(encoding="utf-8")) or {} @@ -341,6 +344,28 @@ def build_agent( rp = RunPythonTool(base_dir=tool_base, user_root=ur_path) tools[rp.name] = rp + # 媒体生成 tool(豆包 seedream / 后续 seedance):仅当 ARK_API_KEY 设了才挂 —— + # 没 key 的用户无感知,不至于看到 schema 里突然多个永远报错的工具。 + ark_cfg = ArkConfig.load() + if ark_cfg is not None: + image_cfg = (ark_cfg.raw.get("image") or {}) + # 取第一个 image variant 作 seedream 主入口(目前只有 seedream_5) + for variant_key, variant_cfg in image_cfg.items(): + if not isinstance(variant_cfg, dict): + continue + seedream_tool = SeedreamTool( + ark_cfg=ark_cfg, + image_variant_cfg=variant_cfg, + variant_key=variant_key, + working_dir=working_dir_path, + task_id=task_id, + user_id=uid, + base_dir=tool_base, + user_root=ur_path, + ) + tools[seedream_tool.name] = seedream_tool + break # 一个 image tool 入口足够;variants 暂不并存 + sink = ConsoleEventSink(console, token_counter=lambda: llm.token_counter.total) if console else None agent = AgentLoop(llm, tools, session, caps, user_id=uid, sink=sink) return agent, session, sid, task_state, working_dir_path diff --git a/core/ark_client.py b/core/ark_client.py new file mode 100644 index 0000000..db98bf8 --- /dev/null +++ b/core/ark_client.py @@ -0,0 +1,126 @@ +"""火山方舟 (Ark) 通用 HTTP 客户端,共享给 seedream / 未来 seedance 等媒体工具。 + +litellm 不覆盖豆包的图像/视频生成端点,这里自己用 httpx 直调 OpenAI 兼容路径 +/images/generations 与异步任务 /contents/generations/tasks。 +""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +import httpx +import yaml + +from core.paths import ROOT + + +_DOUBAO_YAML = ROOT / "config" / "media" / "doubao.yaml" + + +class ArkError(RuntimeError): + """ark API 调用失败的统一异常。""" + + +@dataclass +class ArkConfig: + api_key: str + base_url: str + raw: dict # 完整 yaml 内容(便于 caller 按 image/video 子键再取) + + @classmethod + def load(cls, path: Optional[Path] = None) -> Optional["ArkConfig"]: + """读 doubao.yaml + 解析 env 拿 api_key。 + + api_key env 未设 → 返 None(caller 据此决定是否注册 tool;无 key 用户无感知)。 + yaml 不存在 → 返 None。 + """ + p = path or _DOUBAO_YAML + if not p.exists(): + return None + data = yaml.safe_load(p.read_text(encoding="utf-8")) or {} + env = data.get("ark_api_key_env") or "ARK_API_KEY" + key = os.environ.get(env, "").strip() + if not key: + return None + return cls( + api_key=key, + base_url=str(data.get("ark_base_url") or "https://ark.cn-beijing.volces.com/api/v3").rstrip("/"), + raw=data, + ) + + +class ArkClient: + """轻量 httpx 封装:统一 base_url + bearer auth + 异常翻译。 + + 成功返 dict(JSON 已解析);非 2xx / 网络异常 / JSON 不可解析都抛 ArkError。 + """ + + def __init__(self, cfg: ArkConfig, timeout_s: float = 60.0) -> None: + self.cfg = cfg + self.timeout_s = timeout_s + self._client = httpx.Client( + base_url=cfg.base_url, + headers={ + "Authorization": f"Bearer {cfg.api_key}", + "Content-Type": "application/json", + }, + timeout=timeout_s, + ) + + def post_json(self, path: str, body: dict, *, timeout_s: Optional[float] = None) -> dict: + try: + resp = self._client.post(path, json=body, timeout=timeout_s or self.timeout_s) + except httpx.TimeoutException as e: + raise ArkError(f"timeout calling POST {path}: {e}") from e + except httpx.HTTPError as e: + raise ArkError(f"network error calling POST {path}: {e}") from e + return self._parse(resp, f"POST {path}") + + def get_json(self, path: str, *, timeout_s: Optional[float] = None) -> dict: + try: + resp = self._client.get(path, timeout=timeout_s or self.timeout_s) + except httpx.TimeoutException as e: + raise ArkError(f"timeout calling GET {path}: {e}") from e + except httpx.HTTPError as e: + raise ArkError(f"network error calling GET {path}: {e}") from e + return self._parse(resp, f"GET {path}") + + @staticmethod + def _parse(resp: httpx.Response, label: str) -> dict: + if resp.status_code >= 400: + # ark 错误 body 一般是 {"error": {"code": ..., "message": ...}};能解就解 + try: + err = resp.json().get("error") or {} + msg = err.get("message") or resp.text[:300] + code = err.get("code") or resp.status_code + raise ArkError(f"{label} → HTTP {resp.status_code} ({code}): {msg}") + except ValueError: + raise ArkError(f"{label} → HTTP {resp.status_code}: {resp.text[:300]}") + try: + return resp.json() + except ValueError as e: + raise ArkError(f"{label} → invalid JSON response: {e}") from e + + def download(self, url: str, dest: Path, *, timeout_s: float = 120.0) -> None: + """跨域下载产物(image/video URL 是火山 CDN,不带 ark auth)。""" + try: + with httpx.stream("GET", url, timeout=timeout_s) as r: + if r.status_code >= 400: + raise ArkError(f"download {url} → HTTP {r.status_code}") + dest.parent.mkdir(parents=True, exist_ok=True) + with open(dest, "wb") as f: + for chunk in r.iter_bytes(chunk_size=64 * 1024): + f.write(chunk) + except httpx.HTTPError as e: + raise ArkError(f"download {url} failed: {e}") from e + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> "ArkClient": + return self + + def __exit__(self, *_exc: Any) -> None: + self.close() diff --git a/core/storage/models.py b/core/storage/models.py index 7702055..9fa9650 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -66,7 +66,7 @@ class Task(Base): reasoning_effort: Mapped[str] = mapped_column(Text, nullable=False, default="") tokens_prompt: Mapped[int] = mapped_column(Integer, nullable=False, default=0) tokens_completion: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - cost_usd: Mapped[Decimal] = mapped_column(Numeric(12, 6), nullable=False, default=0) + cost_cny: Mapped[Decimal] = mapped_column(Numeric(12, 6), nullable=False, default=0) # 当前 in-flight 状态(原 runs 表合并入,DESIGN §7.4 简化 / 0004 migration): # idle / running / cancelling / error;ok / cancelled 收尾直接回 idle, # 只有 error 是持久终态(下次起新 run 时由 post_message 清掉) @@ -131,7 +131,7 @@ class UsageEvent(Base): kind: Mapped[str] = mapped_column(Text, nullable=False) # chat / image / video / audio / ... model_profile: Mapped[str] = mapped_column(Text, nullable=False) # deepseek_v4.pro / dall-e-3 / ... units: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) - cost_usd: Mapped[Decimal] = mapped_column(Numeric(12, 6), nullable=False, default=0) + cost_cny: Mapped[Decimal] = mapped_column(Numeric(12, 6), nullable=False, default=0) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/core/storage/usage.py b/core/storage/usage.py index ba54b76..b9bd3df 100644 --- a/core/storage/usage.py +++ b/core/storage/usage.py @@ -1,15 +1,16 @@ -"""用量记账(0006):一次产生成本的调用 = 一行 usage_events + 双写 messages 列。 +"""用量记账(0006 + 0007):一次产生成本的调用 = 一行 usage_events + 双写 messages 列。 -chat 类型的入口由 loop.py 在 assistant message 入库后调用;未来的媒体工具 -(image/video/audio)在 tool execute 后由 loop 顺手记账。 +chat 类型的入口由 loop.py 在 assistant message 入库后调用;媒体工具(image/video/audio) +在 tool execute 完后由 tool 直接调用对应入口(record_image_usage 等)。 -成本计算依赖 litellm 的 cost map(litellm.cost_calculator.completion_cost)。 -未知 model 或 map 缺失时 cost=0(不阻塞主流程),emit warn 给 sink。 +币种(0007):全表统一 CNY(`cost_cny` 列)。chat 路径走 litellm 的 USD cost_map → 内部 +×USD_TO_CNY 折算落库;媒体路径价格本身就是 CNY,直接落。units jsonb 里 snapshot 当时 +的关键价格参数(chat 没有,media 存 price_cny_per_image 等),便于跨调价对账。 """ from __future__ import annotations from decimal import Decimal -from typing import Any, Optional +from typing import Any, Mapping, Optional from uuid import UUID from sqlalchemy import update @@ -18,11 +19,16 @@ from .engine import session_scope from .models import Message, UsageEvent -def _safe_chat_cost(response: Any) -> Decimal: +# litellm 的 cost map 给的是 USD,落库前折成 CNY。汇率近似(每年看一次,实质偏差不大); +# 真要精算的话应该按调用时刻的汇率,但开发期/个人用接受。 +USD_TO_CNY = Decimal("7.2") + + +def _safe_chat_cost_usd(response: Any) -> Decimal: """litellm.completion_cost(response) 包一层:任何异常都吞掉返 0。 未知 model / cost map 没收录 / response 结构变都不影响主流程 —— usage_events - 仍写入,只是 cost_usd=0,后续人工补算 OK。 + 仍写入,只是 cost=0,后续人工补算 OK。返 USD,由 caller 折算。 """ try: from litellm import completion_cost # type: ignore[import-not-found] @@ -49,10 +55,16 @@ def record_chat_usage( `message_id` 来自 `Session.append` 的返回值;若为 None(系统消息 / 旧路径未拿到) 则 usage_events 仍写但 message_id=NULL,messages 列不回填。 `model_profile` 形如 `"deepseek_v4.pro"`(family.variant)。 - 返回算出的 cost_usd(已落库),调用方可用作 SSE 显示。 + 返回算出的 cost_cny(已落库),调用方可用作 SSE 显示。 """ - cost = _safe_chat_cost(response) - units = {"tokens_in": int(prompt_tokens), "tokens_out": int(completion_tokens)} + cost_usd = _safe_chat_cost_usd(response) + cost_cny = (cost_usd * USD_TO_CNY).quantize(Decimal("0.000001")) + units = { + "tokens_in": int(prompt_tokens), + "tokens_out": int(completion_tokens), + # snapshot 折算系数,便于历史对账(汇率/价格涨跌后仍能还原当时折算逻辑) + "usd_to_cny": float(USD_TO_CNY), + } with session_scope() as s: s.add(UsageEvent( @@ -62,7 +74,7 @@ def record_chat_usage( kind="chat", model_profile=model_profile, units=units, - cost_usd=cost, + cost_cny=cost_cny, )) if message_id is not None: s.execute( @@ -74,4 +86,47 @@ def record_chat_usage( model_profile=model_profile, ) ) - return cost + return cost_cny + + +def record_image_usage( + *, + task_id: UUID, + user_id: UUID, + model_profile: str, + n_images: int, + size: str, + price_cny_per_image: float, + search: bool = False, + extra_units: Optional[Mapping[str, Any]] = None, +) -> Decimal: + """记一次图像生成:写 usage_events(kind=image)。 + + 单价(CNY/张)由 caller 从配置文件读出后传入,**同步 snapshot 进 units jsonb** —— + 将来豆包调价改 YAML 即可,历史记录不动且仍能完整还原当时单价。 + `model_profile` 形如 `"doubao.seedream_5"`(family.variant 风格,跟 chat 对齐)。 + `extra_units` 给将来扩展(如 quality / style 等额外加价维度)预留。 + 返回 cost_cny(已落库,可作 SSE / tool 返回串显示用)。 + """ + price = Decimal(str(price_cny_per_image)) + cost_cny = (price * n_images).quantize(Decimal("0.000001")) + units: dict[str, Any] = { + "n_images": int(n_images), + "size": size, + "search": bool(search), + "price_cny_per_image": float(price_cny_per_image), + } + 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, # image tool 在 tool execute 时调用,message 还未落库 + kind="image", + model_profile=model_profile, + units=units, + cost_cny=cost_cny, + )) + return cost_cny diff --git a/core/storage/utils.py b/core/storage/utils.py index 8af0677..d1f0057 100644 --- a/core/storage/utils.py +++ b/core/storage/utils.py @@ -61,7 +61,7 @@ def upsert_task( """INSERT ... ON CONFLICT DO UPDATE —— TaskState.save 的落地点。 fields 可包含 tasks 表任意可写列(name/working_dir/skill/description/status/model/ - model_profile/reasoning_effort/tokens_prompt/tokens_completion/cost_usd)。 + model_profile/reasoning_effort/tokens_prompt/tokens_completion/cost_cny)。 不传的字段在 INSERT 时走 ORM 默认值,UPDATE 时不动。 INSERT 路径需要 name(NOT NULL)+ working_dir;纯 UPDATE 路径(行已存在)不强制。 """ diff --git a/core/task.py b/core/task.py index 0110fe1..9764062 100644 --- a/core/task.py +++ b/core/task.py @@ -36,7 +36,7 @@ class TaskState: reasoning_effort: str = "" tokens_prompt: int = 0 tokens_completion: int = 0 - cost_usd: float = 0.0 + cost_cny: float = 0.0 created_at: str = "" # PG server_default 填,Python 侧只读 updated_at: str = "" @@ -76,7 +76,7 @@ class TaskState: reasoning_effort=row.reasoning_effort, tokens_prompt=row.tokens_prompt, tokens_completion=row.tokens_completion, - cost_usd=float(row.cost_usd or 0), + cost_cny=float(row.cost_cny or 0), created_at=_iso(row.created_at), updated_at=_iso(row.updated_at), ) diff --git a/db/migrations/versions/20260520_1800_0007_cost_usd_to_cny.py b/db/migrations/versions/20260520_1800_0007_cost_usd_to_cny.py new file mode 100644 index 0000000..9608f20 --- /dev/null +++ b/db/migrations/versions/20260520_1800_0007_cost_usd_to_cny.py @@ -0,0 +1,40 @@ +"""tasks.cost_usd / usage_events.cost_usd rename → cost_cny (×7.2 backfill). + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-05-20 + +按 PROGRESS / DESIGN:接入豆包(seedream/seedance)按张/按 token 计费,价格单位是人民币, +不再走 litellm USD cost map → 全表统一币种 CNY,免 chat USD / media CNY 分类汇总。 + +CLAUDE.md "开发期不写兼容":直接 rename 列 + ×7.2 一次性折算开发期 chat 数据 +(汇率近似,误差可接受;后续新写入 record_chat_usage 内部把 litellm USD ×7.2 落 CNY)。 +""" +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0007" +down_revision: Union[str, None] = "0006" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +_USD_TO_CNY = 7.2 + + +def upgrade() -> None: + # 先 rename(保留已有数据值,只换列名),再 ×7.2 折算 + op.alter_column("tasks", "cost_usd", new_column_name="cost_cny") + op.alter_column("usage_events", "cost_usd", new_column_name="cost_cny") + + op.execute(f"UPDATE tasks SET cost_cny = cost_cny * {_USD_TO_CNY}") + op.execute(f"UPDATE usage_events SET cost_cny = cost_cny * {_USD_TO_CNY}") + + +def downgrade() -> None: + op.execute(f"UPDATE usage_events SET cost_cny = cost_cny / {_USD_TO_CNY}") + op.execute(f"UPDATE tasks SET cost_cny = cost_cny / {_USD_TO_CNY}") + op.alter_column("usage_events", "cost_cny", new_column_name="cost_usd") + op.alter_column("tasks", "cost_cny", new_column_name="cost_usd") diff --git a/prompts/system/general_v1.md b/prompts/system/general_v1.md index 7727deb..2325340 100644 --- a/prompts/system/general_v1.md +++ b/prompts/system/general_v1.md @@ -7,6 +7,13 @@ - `run_python` —— 在子进程里跑 Python (数据处理、生成 .pptx/.docx、画图等) - `load_skill` —— 加载某个 skill 的完整指引 +## 媒体生成工具(按需可用,未配置 ARK_API_KEY 时该工具不会出现) +- `seedream` —— 豆包图像生成。产物自动落 `/figures/`。 + - **何时调用**:用户明确要"生成 / 画 / 出 / 来张"图、配图、封面、概念图、效果图、示意图等 + - **何时不调用**:用户没主动要图(别为了"丰富对话"装饰性生成);流程图 / 结构图等"逻辑图"优先用 mermaid(skill 内已有管线),seedream 适合写实 / 概念 / 艺术风格的图 + - 每次 ¥0.22(联网 search=true 加 ¥0.05);出图慢于此判超时,**不要为同一目的连发多次** —— 一张不满意先调整 prompt 再生成 + - prompt 直接传用户描述即可,不必加"高质量 4K"之类废话 + ## Skill 机制 你启动时只看到下方 skill 的"名字 + 描述"。Skill 是**可选辅助** —— 任务明确落在 某个 skill 领域(用户要做 PPT、写申报书等)时,先 `load_skill(name)` 拿完整指引 diff --git a/scripts/smoke_seedream.py b/scripts/smoke_seedream.py new file mode 100644 index 0000000..dd4b819 --- /dev/null +++ b/scripts/smoke_seedream.py @@ -0,0 +1,114 @@ +"""Smoke: 豆包 Seedream 图像生成 tool 端到端走通。 + +跑法: .venv/Scripts/python.exe scripts/smoke_seedream.py +依赖 .env 里 ARK_API_KEY / ZCBOT_DB_URL。**会真的调豆包 API,产生 ~¥0.22 费用**。 + +校验: + 1. ArkConfig.load() 拿到 cfg + 2. SeedreamTool.execute(prompt=...) 返回 [image saved: ...] 文案 + 3. figures/-.png 落盘且大于 0 字节 + 4. 同名 .meta.json 存在 + 含 prompt/model_id/cost_cny 字段 + 5. usage_events 多出一行 kind="image",单价 snapshot 在 units jsonb +""" +from __future__ import annotations + +import json +import os +import sys +import uuid +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +# 读 .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 sqlalchemy import text + +from core.ark_client import ArkConfig +from core.storage import session_scope +from core.storage.models import Task, User +from tools.seedream import SeedreamTool + + +def main() -> int: + cfg = ArkConfig.load() + if cfg is None: + print("[SKIP] ARK_API_KEY 未设(或 config/media/doubao.yaml 缺失),无法测真接口") + return 0 + + image_cfg = (cfg.raw.get("image") or {}) + variant_key, variant_cfg = next(iter(image_cfg.items())) + print(f"[setup] variant={variant_key} model={variant_cfg.get('model_id')} price={variant_cfg.get('price_cny_per_image')}") + + # 准备一次性 user + task 行(usage_events FK 校验) + uid = uuid.uuid4() + tid = uuid.uuid4() + ws_user = ROOT / "workspace" / "users" / str(uid) + wd = ws_user / "smoke_seedream" + wd.mkdir(parents=True, exist_ok=True) + with session_scope() as s: + s.add(User(user_id=uid)) + s.add(Task(task_id=tid, user_id=uid, name="smoke_seedream", working_dir=str(wd))) + + tool = SeedreamTool( + ark_cfg=cfg, + image_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(f"[call] prompt='一只橙色的小猫坐在窗台上望向远方,水彩风格'") + result = tool.execute(prompt="一只橙色的小猫坐在窗台上望向远方,水彩风格") + print(f"[tool result]\n{result}\n") + + if result.startswith("[Error]"): + print(f"[FAIL] tool 返回错误") + return 2 + + # 校验 figures 目录与 meta + figs = list((wd / "figures").glob("*.png")) + assert len(figs) == 1, f"figures/*.png 应当 1 个,实际 {len(figs)}" + png = figs[0] + assert png.stat().st_size > 0, f"{png} 大小为 0" + print(f"[OK] png 落盘 {png.name} ({png.stat().st_size} bytes)") + + meta_path = png.with_suffix(".meta.json") + assert meta_path.exists(), f"meta 文件不存在 {meta_path}" + meta = json.loads(meta_path.read_text(encoding="utf-8")) + for k in ("prompt", "model_id", "size", "cost_cny", "ts"): + assert k in meta, f"meta 缺字段 {k}" + print(f"[OK] meta 字段齐全: {list(meta.keys())}") + + # 校验 usage_events + 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 == "image", f"kind 应 image,实际 {row.kind}" + assert row.model_profile == f"doubao.{variant_key}", f"model_profile = {row.model_profile}" + assert "price_cny_per_image" in row.units, "units 缺 price_cny_per_image snapshot" + print(f"[OK] usage_events: kind={row.kind} model={row.model_profile} cost_cny={row.cost_cny}") + print(f" units snapshot: {row.units}") + + print("\n[PASS] smoke_seedream 全部通过") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/seedream.py b/tools/seedream.py new file mode 100644 index 0000000..2795ef0 --- /dev/null +++ b/tools/seedream.py @@ -0,0 +1,206 @@ +"""seedream: 调豆包 Seedream 图像生成 API,产物落 working_dir/figures/。 + +模型 ID + 单价 + 默认参数全在 `config/media/doubao.yaml`,本 tool 只装配。 +完成后: +- 图片落到 `/figures/-.png` +- 同名 `.meta.json` 写 prompt / model / size / search / cost_cny / response_id / ts +- usage_events 写 kind="image" 一行(单价 snapshot 进 units → 跨调价对账) +""" +from __future__ import annotations + +import json +import secrets +import time +from datetime import datetime +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_image_usage + +from .base import Tool + + +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. " + "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." + ) + parameters = { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "中文或英文都行,详尽描述画面(主体/风格/光线/构图)。直接传用户意图即可,模型自己理解。", + }, + "size": { + "type": "string", + "description": "Image size like '2048x2048' / '1024x1024' / '3072x3072'. Defaults to config (2048x2048).", + }, + "watermark": { + "type": "boolean", + "description": "是否打豆包水印。默认 false(申报/PPT 场景不需要)。", + }, + "search": { + "type": "boolean", + "description": "是否启用联网搜索辅助生成(适合时事/特定品牌等)。默认 false,启用会加价约 ¥0.05/张。", + }, + }, + "required": ["prompt"], + } + + def __init__( + self, + *, + ark_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, + ) -> None: + super().__init__(base_dir, user_root=user_root) + self.ark_cfg = ark_cfg + self.cfg = image_variant_cfg + self.variant_key = variant_key # 'seedream_5' → usage_events.model_profile = "doubao.seedream_5" + self.working_dir = Path(working_dir) + self.task_id = task_id + self.user_id = user_id + + def execute( + self, + prompt: str, + size: Optional[str] = None, + watermark: Optional[bool] = None, + search: Optional[bool] = None, + ) -> str: + if not (prompt or "").strip(): + return "[Error] prompt 不能为空" + + cfg = self.cfg + model_id = cfg["model_id"] + chosen_size = size or cfg.get("default_size", "2048x2048") + chosen_watermark = bool(cfg.get("default_watermark", False)) if watermark is None else bool(watermark) + chosen_search = bool(cfg.get("default_search", False)) if search is None else bool(search) + timeout_s = float(cfg.get("request_timeout_s", 60)) + price = float(cfg.get("price_cny_per_image", 0)) + + body: dict[str, Any] = { + "model": model_id, + "prompt": prompt, + "size": chosen_size, + "response_format": "url", + "watermark": chosen_watermark, + } + if chosen_search: + # 豆包 search 参数透传(YAML 注释里说明加价 ~¥0.05/张) + body["search"] = True + + endpoint = cfg.get("endpoint", "/images/generations") + t0 = time.monotonic() + try: + with ArkClient(self.ark_cfg, timeout_s=timeout_s) as client: + resp = client.post_json(endpoint, body, timeout_s=timeout_s) + image_url, response_id = self._extract_url(resp) + if not image_url: + return f"[Error] seedream response 缺 image url: {json.dumps(resp, ensure_ascii=False)[:300]}" + + # 落盘 figures/-.png + .meta.json + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + short = secrets.token_hex(3) + figures_dir = self.working_dir / "figures" + dest_png = figures_dir / f"{ts}-{short}.png" + client.download(image_url, dest_png, timeout_s=120.0) + except ArkError as e: + return f"[Error] seedream API: {e}" + + elapsed = time.monotonic() - t0 + # 估算成本(单价 snapshot 在 record_image_usage 里同步落库) + extra_cny = 0.05 if chosen_search else 0.0 # 搜索加价的粗略值,仅供 user 提示 + cost_cny = float(price) + extra_cny + + meta = { + "prompt": prompt, + "model_id": model_id, + "size": chosen_size, + "watermark": chosen_watermark, + "search": chosen_search, + "cost_cny": cost_cny, + "elapsed_s": round(elapsed, 2), + "response_id": response_id, + "ts": datetime.now().isoformat(timespec="seconds"), + } + meta_path = dest_png.with_suffix(".meta.json") + meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + + # usage_events 记账;失败不阻塞 tool 返回,但 emit 一条 warn 给 sink 的事走不到这里 + # (tool 层没 sink 引用),先 print 兜底;后续可改成 sink 注入。 + try: + record_image_usage( + task_id=self.task_id, + user_id=self.user_id, + model_profile=f"doubao.{self.variant_key}", + n_images=1, + size=chosen_size, + price_cny_per_image=float(price), + search=chosen_search, + extra_units={"search_extra_cny": extra_cny} if chosen_search else None, + ) + except Exception as e: + print(f"[seedream] record_image_usage failed: {type(e).__name__}: {e}", flush=True) + + disp = self._display(dest_png) + # 第一行 banner:前端 SPA 把这行(name===seedream 时)单独提到 details summary + # 旁边显示,用户不展开就能看到 model / size / cost / 耗时 —— 透明性的关键。 + # 格式严格 key=value · 分隔,parse 用正则 `key=([^·\n]+)` 抓。 + return ( + f"[seedream] model={model_id} · size={chosen_size} · " + f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s\n" + f"saved: {disp}\n" + f"prompt={prompt!r}\n" + f"watermark={chosen_watermark} search={chosen_search}" + ) + + @staticmethod + def _extract_url(resp: dict) -> tuple[str, str]: + """ark images/generations 响应解析,容忍几种已知 shape: + - OpenAI 兼容: {"data":[{"url":"..."}], "id":"..."} + - 豆包自有: {"data":{"images":[{"url":"..."}]}} + - 兜底: 任意位置出现的第一个 .url 字符串 + """ + rid = str(resp.get("id") or resp.get("request_id") or "") + data = resp.get("data") + if isinstance(data, list) and data: + first = data[0] + if isinstance(first, dict): + u = first.get("url") or first.get("image_url") + if isinstance(u, str): + return u, rid + if isinstance(data, dict): + imgs = data.get("images") + if isinstance(imgs, list) and imgs: + u = imgs[0].get("url") if isinstance(imgs[0], dict) else None + if isinstance(u, str): + return u, rid + # 兜底:递归搜 + def _find_url(o: Any) -> Optional[str]: + if isinstance(o, dict): + for k, v in o.items(): + if k in ("url", "image_url") and isinstance(v, str) and v.startswith("http"): + return v + r = _find_url(v) + if r: + return r + elif isinstance(o, list): + for x in o: + r = _find_url(x) + if r: + return r + return None + return (_find_url(resp) or ""), rid diff --git a/web/app.py b/web/app.py index 23fd44d..488c32f 100644 --- a/web/app.py +++ b/web/app.py @@ -960,7 +960,7 @@ def create_app() -> FastAPI: update(Task).where(Task.task_id == tid).values( tokens_prompt=0, tokens_completion=0, - cost_usd=0, + cost_cny=0, run_status="idle", run_error=None, ) diff --git a/web/static/dev.html b/web/static/dev.html index 1a88bed..5fb233c 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -329,6 +329,17 @@ margin: 4px 0 0; padding: 8px; background: var(--code-bg); border-radius: 3px; overflow-x: auto; max-height: 300px; white-space: pre-wrap; } + /* media tool 摘要 banner(model / size / cost / elapsed,折叠态也可见) */ + .tool-banner { + display: inline-flex; flex-wrap: wrap; gap: 6px; + margin-left: 8px; font-size: 11px; vertical-align: middle; + } + .tool-banner .kv { + padding: 1px 6px; border-radius: 3px; background: #fff; + border: 1px solid var(--border); color: #555; + } + .tool-banner .kv.cost { color: #b34a4a; border-color: #e0c4c4; } + .tool-banner .kv.model { color: var(--accent); border-color: #e0c4c4; } /* ───── artifact chips(对话内点产物预览/下载) ───── */ .artifact-bar { margin-top: 4px; display: flex; flex-wrap: wrap; gap: 4px; @@ -1305,9 +1316,10 @@ function renderMessages(msgs) { card.className = "msg tool"; const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content); const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); + const banner = extractMediaBanner(p.name || "", txt || ""); card.innerHTML = `
工具调用 · ${escapeHtml(p.name || "")}
-
结果(${(txt || "").length} 字符)
${escapeHtml(txt || "")}
+
结果(${(txt || "").length} 字符)${banner}
${escapeHtml(txt || "")}
${renderArtifactBarHtml(extractArtifactRels(txt || "", wd))} `; wrap.appendChild(card); @@ -1510,9 +1522,11 @@ function handleSseEvent(ev, asstCard, ctx) { } else if (t === "tool_result") { const txt = (ev.data && ev.data.result) || ""; const txtStr = typeof txt === "string" ? txt : JSON.stringify(txt, null, 2); + const toolName = (ev.data && ev.data.name) || ""; + const banner = extractMediaBanner(toolName, txtStr); const det = document.createElement("details"); det.className = "tool-call"; - det.innerHTML = `工具结果
${escapeHtml(txtStr)}
`; + det.innerHTML = `工具结果${banner}
${escapeHtml(txtStr)}
`; asstCard.appendChild(det); const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); const barHtml = renderArtifactBarHtml(extractArtifactRels(txtStr, wd)); @@ -1964,6 +1978,31 @@ function _workingDirName(workingDir) { // 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。 // 启发式:把 \ 一律归 /,然后找以 `/` 打头的串,要求最后一段含 . (像文件)。 +// 从 seedream/seedance tool_result 第一行 banner 抽 model/size/cost/elapsed, +// 拼一行 .tool-banner HTML 挂在 details summary 旁。匹配失败返 ""(不渲染)。 +// 协议:tool 返回串首行格式 `[] key=value · key=value · ...` +function extractMediaBanner(toolName, resultText) { + if (!resultText) return ""; + if (toolName !== "seedream" && toolName !== "seedance") return ""; + const firstLine = String(resultText).split("\n", 1)[0] || ""; + // 抓 key=value(value 可含空格 / : / ., 用 · 或行尾结束) + const re = /(\w+)=([^·\n]+?)(?=\s*·|\s*$)/g; + const kvs = {}; + let m; + while ((m = re.exec(firstLine)) !== null) { + kvs[m[1]] = m[2].trim(); + } + if (!kvs.model && !kvs.cost) return ""; + // model 文本太长(`doubao-seedream-5-0-260128`)→ 截短易读形式 + const model = (kvs.model || "").replace(/^doubao-/, "").replace(/-\d{6,}$/, ""); + const parts = []; + if (model) parts.push(`${escapeHtml(model)}`); + if (kvs.size) parts.push(`${escapeHtml(kvs.size)}`); + if (kvs.cost) parts.push(`${escapeHtml(kvs.cost)}`); + if (kvs.elapsed) parts.push(`${escapeHtml(kvs.elapsed)}`); + return parts.length ? `${parts.join("")}` : ""; +} + function extractArtifactRels(text, workingDir) { if (!text || !workingDir) return []; const wd = String(workingDir).replace(/\\+/g, "/").replace(/^\/+|\/+$/g, "");