feat(media): 接入豆包 Seedream 5.0 图像生成 tool + 0007 cost_usd→cost_cny 全表统一币种

- 新 tools/seedream.py:调 ark /images/generations 同步生成,产物落 figures/<ts>-<rand>.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) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-20 15:20:34 +08:00
parent e1f09547e0
commit c04b8ba05e
15 changed files with 684 additions and 29 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff` > 配合 `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 ### 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"` 两路都在 `<details>``<summary>` 旁挂一行徽章(`.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)→ 立刻下载到 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.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 又给前端一次额外探询,体感分裂,先简单版。 - **`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/<uuid>/<name>`(`to_db_path`),前端 `filesPath` 取了 `.split("/").pop()` 末段但 chip 提取器之前直接拿整串作锚点,正则吃到 `workspace/users/<uuid>/<wd>/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/.../<uuid>/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 `<div class="body">` 渲完后 `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 末段为锚 → 旧路径里的 `/<wdName>/...` 子串也能匹配出正确 rel,**新旧消息 chip 都可点**(回测验证:`extractArtifactRels("/home/.../uuid/wd/foo.md", "wd")` 返 `["wd/foo.md"]`)。 - **fs tool 输出渲染为 user_root-relative 路径(根因消 chip 404 + 防 uuid/部署根泄漏) + dev SPA chip 工作目录锚点修正 + assistant 正文也挂 chip**:用户报对话内 chip 点击 404,根因不在 chip 抽取本身 —— `task.working_dir` DB 形态是 `workspace/users/<uuid>/<name>`(`to_db_path`),前端 `filesPath` 取了 `.split("/").pop()` 末段但 chip 提取器之前直接拿整串作锚点,正则吃到 `workspace/users/<uuid>/<wd>/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/.../<uuid>/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 `<div class="body">` 渲完后 `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 末段为锚 → 旧路径里的 `/<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 协议。 - **`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/export_docx.py 383
core/storage/__init__.py 29 ← record_chat_usage 出口(0006) core/storage/__init__.py 29 ← record_chat_usage 出口(0006)
core/storage/engine.py 80 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/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 70 ← 0006:record_chat_usage(litellm cost map + 双写 messages + insert usage_events) 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/storage/utils.py 136
core/agent_builder.py 307 ← 装配 lib(原 main.py 内容,05-18 改名归位) core/ark_client.py 105 ← 火山方舟 HTTP 客户端(共享给 seedream / 后续 seedance)
tools/{base,fs,shell,run_python,skill_tool}.py ~440 行 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) main.py ~210 ← 入口:web / db / probe / user(05-19 加 user)
db/migrations/env.py 61 db/migrations/env.py 61
db/migrations/versions/ db/migrations/versions/
@ -149,6 +154,7 @@ db/migrations/versions/
0004_drop_runs_usage_events.py 77 0004_drop_runs_usage_events.py 77
0005_users_email_unique.py 28 ← 0005 一日游 invites 已撤,接 users.email UNIQUE 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) 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/__init__.py 5
web/app.py ~1320 ← /v1 JSON API + user_id 隔离 + run lock + cancel + files copy/move web/app.py ~1320 ← /v1 JSON API + user_id 隔离 + run lock + cancel + files copy/move
web/auth.py ~190 ← D' 过渡:邮箱密码 + platform_key → JWT 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) 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 行。
--- ---

8
RUN.md
View File

@ -14,6 +14,8 @@
DEEPSEEK_API_KEY=sk-... DEEPSEEK_API_KEY=sk-...
# 用 GLM 的话再加一条;国际站 z.ai 用 ZAI_API_KEY,国内站 bigmodel.cn 用 ZHIPUAI_API_KEY(对应 config/models/glm.yaml 的 api_key_env 字段) # 用 GLM 的话再加一条;国际站 z.ai 用 ZAI_API_KEY,国内站 bigmodel.cn 用 ZHIPUAI_API_KEY(对应 config/models/glm.yaml 的 api_key_env 字段)
ZHIPUAI_API_KEY=... ZHIPUAI_API_KEY=...
# 豆包(火山方舟)图像/视频生成:可选。设了就挂上 seedream tool(0.22 元/张);未设 tool 不出现
ARK_API_KEY=...
ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot
# main.py web 必填(probe/db/user 不验) # main.py web 必填(probe/db/user 不验)
PLATFORM_KEY=<≥16 字符随机串,platform 机器对机器入口校验> PLATFORM_KEY=<≥16 字符随机串,platform 机器对机器入口校验>
@ -40,7 +42,7 @@ python -m venv .venv
# 3) DB schema 上车 # 3) DB schema 上车
.venv/Scripts/python.exe main.py db upgrade head .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` | 必填 | | `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: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 | | `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 |
| `POST /v1/tasks/{id}/cancel` | 协作式 cancel;`run_status != running` → 409;LLM 同步 call 不可中断,最坏等当前一轮跑完 | 必填 | | `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?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 |
| `GET /v1/files/download?path=` | 下单文件 | 必填 | | `GET /v1/files/download?path=` | 下单文件 | 必填 |
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 | | `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 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 再清空 | | `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 | | 点 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 级,无需处理 | | `[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 <pid>``/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])'` | | `kill -HUP <pid>``/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` | | `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 | | `POST /v1/files/rename` 返 409 `folder has active run(s)` | 顶层目录被某 running/cancelling 的 task 占用;先 cancel 等流式 done 再 rename |

33
config/media/doubao.yaml Normal file
View File

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

View File

@ -37,9 +37,12 @@ from core.storage import check_no_subtask
from core.task import TaskState from core.task import TaskState
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
from tools.run_python import RunPythonTool from tools.run_python import RunPythonTool
from tools.seedream import SeedreamTool
from tools.shell import ShellTool from tools.shell import ShellTool
from tools.skill_tool import LoadSkillTool from tools.skill_tool import LoadSkillTool
from core.ark_client import ArkConfig
def load_config() -> dict: def load_config() -> dict:
return yaml.safe_load((ROOT / "config" / "agent.yaml").read_text(encoding="utf-8")) or {} 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) rp = RunPythonTool(base_dir=tool_base, user_root=ur_path)
tools[rp.name] = rp 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 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) agent = AgentLoop(llm, tools, session, caps, user_id=uid, sink=sink)
return agent, session, sid, task_state, working_dir_path return agent, session, sid, task_state, working_dir_path

126
core/ark_client.py Normal file
View File

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

View File

@ -66,7 +66,7 @@ class Task(Base):
reasoning_effort: Mapped[str] = mapped_column(Text, nullable=False, default="") reasoning_effort: Mapped[str] = mapped_column(Text, nullable=False, default="")
tokens_prompt: Mapped[int] = mapped_column(Integer, nullable=False, default=0) tokens_prompt: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
tokens_completion: 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): # 当前 in-flight 状态(原 runs 表合并入,DESIGN §7.4 简化 / 0004 migration):
# idle / running / cancelling / error;ok / cancelled 收尾直接回 idle, # idle / running / cancelling / error;ok / cancelled 收尾直接回 idle,
# 只有 error 是持久终态(下次起新 run 时由 post_message 清掉) # 只有 error 是持久终态(下次起新 run 时由 post_message 清掉)
@ -131,7 +131,7 @@ class UsageEvent(Base):
kind: Mapped[str] = mapped_column(Text, nullable=False) # chat / image / video / audio / ... 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 / ... 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) 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )

View File

@ -1,15 +1,16 @@
"""用量记账(0006):一次产生成本的调用 = 一行 usage_events + 双写 messages 列。 """用量记账(0006 + 0007):一次产生成本的调用 = 一行 usage_events + 双写 messages 列。
chat 类型的入口由 loop.py assistant message 入库后调用;未来的媒体工具 chat 类型的入口由 loop.py assistant message 入库后调用;媒体工具(image/video/audio)
(image/video/audio) tool execute 后由 loop 顺手记账 tool execute 完后由 tool 直接调用对应入口(record_image_usage )
成本计算依赖 litellm cost map(litellm.cost_calculator.completion_cost) 币种(0007):全表统一 CNY(`cost_cny` )chat 路径走 litellm USD cost_map 内部
未知 model map 缺失时 cost=0(不阻塞主流程),emit warn sink ×USD_TO_CNY 折算落库;媒体路径价格本身就是 CNY,直接落units jsonb snapshot 当时
的关键价格参数(chat 没有,media price_cny_per_image ),便于跨调价对账
""" """
from __future__ import annotations from __future__ import annotations
from decimal import Decimal from decimal import Decimal
from typing import Any, Optional from typing import Any, Mapping, Optional
from uuid import UUID from uuid import UUID
from sqlalchemy import update from sqlalchemy import update
@ -18,11 +19,16 @@ from .engine import session_scope
from .models import Message, UsageEvent 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。 """litellm.completion_cost(response) 包一层:任何异常都吞掉返 0。
未知 model / cost map 没收录 / response 结构变都不影响主流程 usage_events 未知 model / cost map 没收录 / response 结构变都不影响主流程 usage_events
仍写入,只是 cost_usd=0,后续人工补算 OK 仍写入,只是 cost=0,后续人工补算 OK USD, caller 折算
""" """
try: try:
from litellm import completion_cost # type: ignore[import-not-found] from litellm import completion_cost # type: ignore[import-not-found]
@ -49,10 +55,16 @@ def record_chat_usage(
`message_id` 来自 `Session.append` 的返回值;若为 None(系统消息 / 旧路径未拿到) `message_id` 来自 `Session.append` 的返回值;若为 None(系统消息 / 旧路径未拿到)
usage_events 仍写但 message_id=NULL,messages 列不回填 usage_events 仍写但 message_id=NULL,messages 列不回填
`model_profile` 形如 `"deepseek_v4.pro"`(family.variant) `model_profile` 形如 `"deepseek_v4.pro"`(family.variant)
返回算出的 cost_usd(已落库),调用方可用作 SSE 显示 返回算出的 cost_cny(已落库),调用方可用作 SSE 显示
""" """
cost = _safe_chat_cost(response) cost_usd = _safe_chat_cost_usd(response)
units = {"tokens_in": int(prompt_tokens), "tokens_out": int(completion_tokens)} 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: with session_scope() as s:
s.add(UsageEvent( s.add(UsageEvent(
@ -62,7 +74,7 @@ def record_chat_usage(
kind="chat", kind="chat",
model_profile=model_profile, model_profile=model_profile,
units=units, units=units,
cost_usd=cost, cost_cny=cost_cny,
)) ))
if message_id is not None: if message_id is not None:
s.execute( s.execute(
@ -74,4 +86,47 @@ def record_chat_usage(
model_profile=model_profile, 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

View File

@ -61,7 +61,7 @@ def upsert_task(
"""INSERT ... ON CONFLICT DO UPDATE —— TaskState.save 的落地点。 """INSERT ... ON CONFLICT DO UPDATE —— TaskState.save 的落地点。
fields 可包含 tasks 表任意可写列(name/working_dir/skill/description/status/model/ 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 时走 ORM 默认值,UPDATE 时不动
INSERT 路径需要 name(NOT NULL)+ working_dir; UPDATE 路径(行已存在)不强制 INSERT 路径需要 name(NOT NULL)+ working_dir; UPDATE 路径(行已存在)不强制
""" """

View File

@ -36,7 +36,7 @@ class TaskState:
reasoning_effort: str = "" reasoning_effort: str = ""
tokens_prompt: int = 0 tokens_prompt: int = 0
tokens_completion: int = 0 tokens_completion: int = 0
cost_usd: float = 0.0 cost_cny: float = 0.0
created_at: str = "" # PG server_default 填,Python 侧只读 created_at: str = "" # PG server_default 填,Python 侧只读
updated_at: str = "" updated_at: str = ""
@ -76,7 +76,7 @@ class TaskState:
reasoning_effort=row.reasoning_effort, reasoning_effort=row.reasoning_effort,
tokens_prompt=row.tokens_prompt, tokens_prompt=row.tokens_prompt,
tokens_completion=row.tokens_completion, 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), created_at=_iso(row.created_at),
updated_at=_iso(row.updated_at), updated_at=_iso(row.updated_at),
) )

View File

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

View File

@ -7,6 +7,13 @@
- `run_python` —— 在子进程里跑 Python (数据处理、生成 .pptx/.docx、画图等) - `run_python` —— 在子进程里跑 Python (数据处理、生成 .pptx/.docx、画图等)
- `load_skill` —— 加载某个 skill 的完整指引 - `load_skill` —— 加载某个 skill 的完整指引
## 媒体生成工具(按需可用,未配置 ARK_API_KEY 时该工具不会出现)
- `seedream` —— 豆包图像生成。产物自动落 `<task_dir>/figures/`
- **何时调用**:用户明确要"生成 / 画 / 出 / 来张"图、配图、封面、概念图、效果图、示意图等
- **何时不调用**:用户没主动要图(别为了"丰富对话"装饰性生成);流程图 / 结构图等"逻辑图"优先用 mermaid(skill 内已有管线),seedream 适合写实 / 概念 / 艺术风格的图
- 每次 ¥0.22(联网 search=true 加 ¥0.05);出图慢于此判超时,**不要为同一目的连发多次** —— 一张不满意先调整 prompt 再生成
- prompt 直接传用户描述即可,不必加"高质量 4K"之类废话
## Skill 机制 ## Skill 机制
你启动时只看到下方 skill 的"名字 + 描述"。Skill 是**可选辅助** —— 任务明确落在 你启动时只看到下方 skill 的"名字 + 描述"。Skill 是**可选辅助** —— 任务明确落在
某个 skill 领域(用户要做 PPT、写申报书等)时,先 `load_skill(name)` 拿完整指引 某个 skill 领域(用户要做 PPT、写申报书等)时,先 `load_skill(name)` 拿完整指引

114
scripts/smoke_seedream.py Normal file
View File

@ -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/<ts>-<rand>.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())

206
tools/seedream.py Normal file
View File

@ -0,0 +1,206 @@
"""seedream: 调豆包 Seedream 图像生成 API,产物落 working_dir/figures/。
模型 ID + 单价 + 默认参数全在 `config/media/doubao.yaml`, tool 只装配
完成后:
- 图片落到 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.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/<ts>-<rand>.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

View File

@ -960,7 +960,7 @@ def create_app() -> FastAPI:
update(Task).where(Task.task_id == tid).values( update(Task).where(Task.task_id == tid).values(
tokens_prompt=0, tokens_prompt=0,
tokens_completion=0, tokens_completion=0,
cost_usd=0, cost_cny=0,
run_status="idle", run_status="idle",
run_error=None, run_error=None,
) )

View File

@ -329,6 +329,17 @@
margin: 4px 0 0; padding: 8px; background: var(--code-bg); border-radius: 3px; margin: 4px 0 0; padding: 8px; background: var(--code-bg); border-radius: 3px;
overflow-x: auto; max-height: 300px; white-space: pre-wrap; 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 chips(对话内点产物预览/下载) ───── */
.artifact-bar { .artifact-bar {
margin-top: 4px; display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; display: flex; flex-wrap: wrap; gap: 4px;
@ -1305,9 +1316,10 @@ function renderMessages(msgs) {
card.className = "msg tool"; card.className = "msg tool";
const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content); const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
const banner = extractMediaBanner(p.name || "", txt || "");
card.innerHTML = ` card.innerHTML = `
<div class="role">工具调用 · ${escapeHtml(p.name || "")}</div> <div class="role">工具调用 · ${escapeHtml(p.name || "")}</div>
<details class="tool-call"><summary>结果(${(txt || "").length} 字符)</summary><pre>${escapeHtml(txt || "")}</pre></details> <details class="tool-call"><summary>结果(${(txt || "").length} 字符)${banner}</summary><pre>${escapeHtml(txt || "")}</pre></details>
${renderArtifactBarHtml(extractArtifactRels(txt || "", wd))} ${renderArtifactBarHtml(extractArtifactRels(txt || "", wd))}
`; `;
wrap.appendChild(card); wrap.appendChild(card);
@ -1510,9 +1522,11 @@ function handleSseEvent(ev, asstCard, ctx) {
} else if (t === "tool_result") { } else if (t === "tool_result") {
const txt = (ev.data && ev.data.result) || ""; const txt = (ev.data && ev.data.result) || "";
const txtStr = typeof txt === "string" ? txt : JSON.stringify(txt, null, 2); 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"); const det = document.createElement("details");
det.className = "tool-call"; det.className = "tool-call";
det.innerHTML = `<summary>工具结果</summary><pre>${escapeHtml(txtStr)}</pre>`; det.innerHTML = `<summary>工具结果${banner}</summary><pre>${escapeHtml(txtStr)}</pre>`;
asstCard.appendChild(det); asstCard.appendChild(det);
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
const barHtml = renderArtifactBarHtml(extractArtifactRels(txtStr, wd)); const barHtml = renderArtifactBarHtml(extractArtifactRels(txtStr, wd));
@ -1964,6 +1978,31 @@ function _workingDirName(workingDir) {
// 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。 // 从 tool args / result / assistant 正文里抓 working_dir 下的文件路径,归一为 user_root 相对。
// 启发式:把 \ 一律归 /,然后找以 `<wdName>/` 打头的串,要求最后一段含 . (像文件)。 // 启发式:把 \ 一律归 /,然后找以 `<wdName>/` 打头的串,要求最后一段含 . (像文件)。
// 从 seedream/seedance tool_result 第一行 banner 抽 model/size/cost/elapsed,
// 拼一行 .tool-banner HTML 挂在 details summary 旁。匹配失败返 ""(不渲染)。
// 协议:tool 返回串首行格式 `[<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(`<span class="kv model">${escapeHtml(model)}</span>`);
if (kvs.size) parts.push(`<span class="kv">${escapeHtml(kvs.size)}</span>`);
if (kvs.cost) parts.push(`<span class="kv cost">${escapeHtml(kvs.cost)}</span>`);
if (kvs.elapsed) parts.push(`<span class="kv">${escapeHtml(kvs.elapsed)}</span>`);
return parts.length ? `<span class="tool-banner">${parts.join("")}</span>` : "";
}
function extractArtifactRels(text, workingDir) { function extractArtifactRels(text, workingDir) {
if (!text || !workingDir) return []; if (!text || !workingDir) return [];
const wd = String(workingDir).replace(/\\+/g, "/").replace(/^\/+|\/+$/g, ""); const wd = String(workingDir).replace(/\\+/g, "/").replace(/^\/+|\/+$/g, "");