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:
parent
e1f09547e0
commit
c04b8ba05e
18
PROGRESS.md
18
PROGRESS.md
|
|
@ -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
8
RUN.md
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
# ...
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 路径(行已存在)不强制。
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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)` 拿完整指引
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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, "");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue