Compare commits

...

3 Commits

Author SHA1 Message Date
caoqianming 98713bb7fa feat(seedance): support image-to-video references 2026-07-27 10:50:13 +08:00
caoqianming d4965e196c Add Codex project guidance and memory 2026-07-27 10:38:31 +08:00
caoqianming eab623d5b5 fix(web): kb 条目与单篇页补显原件文件名
已入库条目此前只把 source 用于查看/下载按钮,原件名(含后缀)无处显示。
标题后加灰色小字原件名(悬停提示同步带上),单篇页头部加「原件: xxx」,
新增 .kb-src-name 样式两处共用;不破坏单行精简版式。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:17:39 +08:00
14 changed files with 421 additions and 26 deletions

64
.agents/MEMORY.md Normal file
View File

@ -0,0 +1,64 @@
# zcbot 项目记忆Codex
更新时间2026-07-27。内容从 Claude 项目 memory 迁移并按 Codex 使用方式压缩。`AGENTS.md` 存放每次任务都适用的强约束;本文件存放遇到相关问题时再读取的历史结论。若历史结论与当前代码、`DESIGN.md`、`PROGRESS.md` 或 `RUN.md` 冲突,以当前仓库事实为准并指出差异。
## 用户偏好与长期原则
- 一律使用中文与用户沟通。
- Windows Python 脚本 stdout 可能是 GBK使用 ASCII 状态标签,不用 emoji 或特殊装饰字符。
- `CHANGELOG.md` 提到海外模型时只用“国际旗舰模型”等泛称;具体型号只放在 `PROGRESS.md`、配置和 git log。
- 版本、CHANGELOG、PROGRESS 在 push 前统一更新DESIGN 跟随产生架构或决策变化的 commit。
- 真实文件是事实源,不引入向量机制作为事实源;索引只能是可从 Markdown 重建的派生缓存。
- skill 禁令不写违规配方;脚本错误不向 agent 推荐安装命令或替代管线;真正硬约束检查产物。
## 生产与环境
- 本机 `.env``ZCBOT_DB_URL=127.0.0.1:6012...` 是生产数据库隧道。数据库测试必须显式使用 `ZCBOT_TEST_DB_URL`,绝不能默认读取 `.env` 后向库中插入会被调度器执行的任务。
- 生产机 `/data` 是独立的 `/dev/vdb1`、ext4、约 1 TB 数据盘。若执行 Stage C project quota既定方案是短停服停服务、卸载 `/data`、为 ext4 开启 project/quota、fstab 加 `prjquota`、重新挂载;每用户限额取 `config/agent.yaml`
## 已落地机制
- 知识库于 2026-07-22 以 0.59.0 落地,设计见 `DESIGN.md` §3.8。它是机制而非 skill个人小库使用 `<user_root>/.kb/<库名>/` 下的纯文件、INDEX、docs 和 sources共享大库走院检索服务。agent 通过文件工具检索,不新增向量事实源。
- 方舟文档理解已落地到 `tools/read_document.py``doubao-seed-2-0-lite-260428` 可通过 `file_data``data:application/pdf;base64,...` 读取扫描 PDF单页约 3600 万像素上限,约 100 页上下文上限。相关六个 skill 已有扫描件兜底。探针在 `scripts/probe_ark_doc.py`
- paper_server 的历史 handoff 已完成:当前已有 `skills/research/SKILL.md`、`skills/research/paper.py`,不要把 `.claude/HANDOFF_paper_skill.md` 当作待办。
## 智能体稳定性历史结论
### 高轮数与重复调用
2026-06 的真实任务诊断确认三类根因:
1. 畸形 tool arguments 退化为合法空 `{}`,旧 malformed 检查未拦截;
2. 工具报错后原样重复调用;
3. 检索 query 不断微调但没有停止条件。
已通过批量工具与 `core/loop.py::_RepeatGuard` 缓解:同一工具与参数在无产出时累计,软阈值提示、硬阈值拦截。相关回归测试是 `tests/test_loop_repeat_guard.py`,诊断脚本位于 `scripts/diag_tool_repeat.py`、`diag_search_args.py`、`diag_error_retry.py`。
### 流式畸形 tool_call
DeepSeek 及部分网关模型曾把 arguments 流切片乱序,属于 provider wire 问题而非本地 builder 拼接。0.58.24 已在 `core/salvage.py::salvage_tool_arguments` 与 loop 中落地全有或全无的 salvage从后缀寻找可完整解析的 JSON并以工具 schema 顶层 key 白名单保护;无法恢复时继续走非流式重试。线上复盘显示 salvage 命中率约 85%,残差可由重试自愈,不应再次改 builder。观测事件为 `tool_salvaged` / `tool_malformed`,测试见 `tests/test_salvage.py`
### GLM 空响应烧满输出
GLM 5.2 的空响应根因是网关默认开启 thinking推理耗尽模型 65536 输出上限,返回 `finish_reason=length` 且无 content/tool_call。0.58.49 已按 GLM family 透传 `extra_body.thinking.type`,当前配置关闭 thinking并记录空响应 finish_reason。
不要重新引入全局 `max_tokens` 窗口约束:该方案已探针验证后撤销,未解决根因且可能截断合法大 write。若未来需要成本闸应设计任务级 token budget。
### unifyllm Claude tool_use 漏为正文
曾出现复杂请求下 Anthropic `tool_use` 未转换为 OpenAI `tool_calls`、直接漏成 Markdown 正文loop 因 `tool_calls=[]` 误判完成。诊断脚本 `scripts/diag_narrated_toolcall_2a1bc25d.py` 曾稳定复现,但 2026-07-15 当日复测 6/6 已恢复,判断为网关瞬态或已修。复测时必须显式传 profile不能依赖 task 当前模型。它与 arguments 畸形是不同问题salvage 无法处理没有结构化 tool_call 的正文泄漏。
## 沙箱与 Chromium
Mermaid/Chromium 的历史故障最终有三个根因:
1. `init.sh``127.0.0.0/8 DROP` 阻断容器内 Puppeteer 到 Chromium DevTools表现为恒定约 2 分 15 秒超时且 CPU 接近零;已改为优先允许 loopback。
2. Chromium 150.0.7871.46 点版本启动即崩;已增加刷新旋钮和 build canary。
3. `--pids-limit=256` 过低;已调到 1024。
排查同类问题必须用默认 entrypoint 和生产一致的 network/limits 启容器后再 `docker exec`。使用 `--entrypoint bash` 会绕过 `init.sh` 与 iptables不能代表线上。build canary 也没有运行时 iptables只能覆盖浏览器和字体问题。探针位于 `deploy/sandbox/probe_mermaid.sh`、`probe_chromium_bisect.sh`、`probe_chromium_round3.sh`。
## 领域
用户单位是中国建筑材料科学研究总院。代码、库、模板和示例默认服务于水泥/混凝土、玻璃、陶瓷、耐火和新型建材的材料研发、表征分析、实验建模与科研写作不是建筑施工、BIM 或结构设计语境。

62
AGENTS.md Normal file
View File

@ -0,0 +1,62 @@
# zcbot 开发约定Codex
本文件是 Codex 的项目级入口,由 `CLAUDE.md` 与 Claude 项目 memory 迁移而来。进入项目后先读本文件;涉及历史故障、架构取舍、生产环境或用户偏好时,再读 `.agents/MEMORY.md` 中对应条目。详细设计、进度和运行方式分别以 `DESIGN.md`、`PROGRESS.md`、`RUN.md` 为准。
## 沟通
- 面向用户的回复、方案和解释一律使用中文;代码、命令和标识符保持原样。
- 非平凡改动(改动多个文件、改变行为或存在明显方案取舍)实施前先用自然语言对齐方案。说明具体定位、至少一个替代方案与选择理由;涉及性能、兼容或数据迁移时主动说明。
- 一次性 bug 修复、字面量修改、样式微调或加日志等无歧义动作可直接实施。
## 环境与 Shell
- Python 虚拟环境固定为项目根目录 `.venv/`,脚本和测试一律使用 `.venv/Scripts/python.exe ...`,不要使用全局 `python`
- 依赖以 `requirements.txt` 为准。
- 当前默认 shell 是 PowerShell。PowerShell here-string `@'...'@` 不能交给 BashBash 的 heredoc 或 `$'...\n...'` 也不要照搬到 PowerShell。
- 多行文本优先交给项目编辑工具;确需传给命令时使用明确的临时文件,避免跨 shell 引号污染。
- Windows 控制台可能使用 GBK。项目 CLI/脚本的 stdout 使用 ASCII 标签(`[OK]`、`[WARN]`、`[ERR]`、`[INFO]`),不要输出 emoji、特殊项目符号或装饰线文档正文不受此限制。
## 公测期兼容原则
项目已有真实用户、真实数据和线上会话。对外契约必须向后兼容,纯内部实现可按最优方案重构。
- 用户数据:不得 truncate、`DELETE FROM` 清库或重置现有表。
- DB schema变更必须有干净 migration并平滑兼容存量数据删除字段前先 backfill、确认无引用。
- 字段语义:迁移旧值,并考虑线上旧请求与新代码并行期间的兼容。
- HTTP API不删除既有字段、不改变字段语义、不直接改 URL先增加新字段或端点并为旧接口保留废弃窗口。
- CLI、REPL、环境变量和文件布局改名或删除前保留 deprecated 别名至少一个版本,并在 `RUN.md` 标注。
- 纯内部模块、函数和私有数据流可直接重写,不保留无意义的 `legacy_*``*_v2` 双轨。
- 无法判断是否属于外部契约时,按外部契约处理并先对齐方案;仅在用户明确允许 break 时做破坏性变更。
## 数据库安全
- 本机 `.env` 中的 `ZCBOT_DB_URL``127.0.0.1:6012`)通过隧道连接生产 PostgreSQL不是开发库。
- DB 测试只能使用显式的 `ZCBOT_TEST_DB_URL`,不得回退到 `.env`
- 不得向该生产库写入启用状态且已到执行时间的 scheduled job任何连库写操作前都要再次确认目标。
## 文档与版本
- 版本号、`CHANGELOG.md`、`PROGRESS.md` 在 push 前统一更新一次,不按每个 commit 更新。
- push 前更新 `PROGRESS.md`:补“已完成关键能力”条目,状态表变化随之更新,新增或删除模块时同步文件清单。
- 版本号唯一事实源是 `core/__init__.py::__version__`
- patchbug 修复、重构、调参、新 skill、样式
- minor成批新功能或明显对外行为变化
- major正式 1.0 或不兼容大重构。
- 当前公测期保持 `0.x`1.0 留给正式 GA 和对外契约冻结。
- 用户可感知的版本变化才写 `CHANGELOG.md`,措辞面向用户,不写内部模块、环境变量或部署细节。涉及海外模型时使用“国际旗舰模型”等泛称,不写具体型号。
- `DESIGN.md` 仅在架构、心智模型、取舍决策、API/schema 语义变化,或设计与代码发生偏离时更新,并随触发该变化的 commit 提交。普通 bug 修复、重构、调参、新 skill 不更新 DESIGN。
- 对外 CLI、REPL、env、文件布局或 migration 步骤变化时同步更新 `RUN.md`;真实踩坑补入其“故障兜底”。
- 文档边界:`DESIGN.md` 解释为什么,`PROGRESS.md` 记录做到哪,`RUN.md` 说明怎么运行。
- 新增、修改或删除 `skills/<name>/SKILL.md` 时同步更新 `SKILL_LIST.md` 的日期、总数、能力说明和必要的跨 skill 协作关系。
## 设计偏好
- 真实文件是事实源。检索和知识功能优先采用 Markdown 文件、人可读索引与 glob/grep/read 的 agentic search。
- 不把向量库或 embedding 作为事实源。若未来确有规模与频率数据支持,只能添加可从 Markdown 全量重建的派生索引。
- 大规模、高并发共享检索使用院内外部检索服务,不在 zcbot 内重复建设检索服务。
- 编写 skill 和提示词时,使用正面的唯一入口约束;禁令不要附带可执行的违规配方。脚本报错不要给 agent 输出 `pip install` 或替代工具指令;硬约束优先在平台层检查最终产物。
## 领域语境
主要使用方是中国建筑材料科学研究总院核心语境是无机非金属材料研发与生产包括水泥、混凝土、玻璃、陶瓷、耐火材料和新型建材。典型任务是配方研发、性能测试、XRD/SEM/热分析、实验数据建模,以及申报书、调研报告、专利和论文写作。默认按材料研发而非建筑施工、结构计算或 BIM 理解需求。

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.59.5 — 2026-07-27
- 视频生成现在支持让已有图片直接“动起来”:既可以指定一张图片作为视频开场画面,也可以同时提供多张人物、产品、场景或风格参考图(最多 9 张),生成时更容易保持主体和视觉风格一致。
## 0.59.4 — 2026-07-24 ## 0.59.4 — 2026-07-24
- 对话里直接说「把这份资料放进我的知识库」现在可靠了:即使你还没建过任何库,助手也会帮你建库并入库(此前会被误存成「记忆」);同时更明确了分工——成篇资料进知识库,关于你本人的偏好等短事实才记进记忆。 - 对话里直接说「把这份资料放进我的知识库」现在可靠了:即使你还没建过任何库,助手也会帮你建库并入库(此前会被误存成「记忆」);同时更明确了分工——成篇资料进知识库,关于你本人的偏好等短事实才记进记忆。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-23(架构审查落地:15 commit 内部重构大扫除 + 测试 286→351 + 测试库基建,bump 0.59.1) 最后更新:2026-07-27(Seedance 图生视频:单图首帧 + 最多 9 张多图参考,bump 0.59.5)
--- ---
@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-27 / 0.59.5 / Seedance 图生视频(单图首帧 + 最多 9 张多图参考)**:`seedance` 在原文生视频契约上向后兼容增加 `image`(唯一 `first_frame`)与 `reference_images`(0-9 张 `reference_image`),两者合计最多 9 张;复用 image_ref 的 task/user_root 三形态路径解析、越界防护与 base64 内联,新增单图 10MB / 合计 30MB 请求体闸。请求按有无图片切图生/文生单价banner/meta/usage snapshot 记录 mode、首帧及参考图videogen skill、工具系统提示、RUN 与能力清单同步。5 个离线单测覆盖单图、多图、文生兼容、越界与超量拒绝。
- **07-24 / 0.59.4 / kb 冷启动契约(修「放进知识库」误存记忆)+ 查看原件 + 条目精简**:①真实用户事故——说「放进我的知识库」被 agent 写进 `.memory/`:根因 `kb_block` 零库返回空串,模型不知道 KB 机制存在(memory 空契约常驻恰是为解冷启动,kb 当时没照抄这课)。修 = 零库注 ~百 token 冷启动契约(建库步骤 / INDEX 行格式 / 「成篇资料进 KB、短事实进记忆」分工;仅用户明确要求才建库),建库落盘后下轮 build_agent 自然切全量注入;skill 方案再否(召回同为概率召回还多一层间接,且违背「机制而非 skill」定稿)。DESIGN §3.8 取舍随 commit 更新,`tests/test_kb_block.py` 4 用例锁两分支。②前端「查看原件」三入口(已入库条目行 👁 / 单篇页头 / 待入库列表),复用 `openFilePreview``.kb/<库>/sources/` 相对路径零后端;关键层级修正:file-preview-modal z 90→114 盖过 kb/skills/memory 等 112 层 modal(仍低于 app-dialog 130,删除确认在预览上),fp-open chat-form 95→115、mini 96→116,main.js Esc 关栈顺序同步先关预览再关 modal。「像文件目录浏览 .kb」提议对齐后定维持现状:.kb 继续对文件面板隐藏,守住删单篇「doc+原件+INDEX 行」三件联动判据。③已入库条目压两行:标题 + 单行省略摘要(原 `.sk-desc` 省略规则只覆盖左栏 `.sk-item`,右栏整段换行铺开),关键词挪进悬停 title 不丢信息。 - **07-24 / 0.59.4 / kb 冷启动契约(修「放进知识库」误存记忆)+ 查看原件 + 条目精简**:①真实用户事故——说「放进我的知识库」被 agent 写进 `.memory/`:根因 `kb_block` 零库返回空串,模型不知道 KB 机制存在(memory 空契约常驻恰是为解冷启动,kb 当时没照抄这课)。修 = 零库注 ~百 token 冷启动契约(建库步骤 / INDEX 行格式 / 「成篇资料进 KB、短事实进记忆」分工;仅用户明确要求才建库),建库落盘后下轮 build_agent 自然切全量注入;skill 方案再否(召回同为概率召回还多一层间接,且违背「机制而非 skill」定稿)。DESIGN §3.8 取舍随 commit 更新,`tests/test_kb_block.py` 4 用例锁两分支。②前端「查看原件」三入口(已入库条目行 👁 / 单篇页头 / 待入库列表),复用 `openFilePreview``.kb/<库>/sources/` 相对路径零后端;关键层级修正:file-preview-modal z 90→114 盖过 kb/skills/memory 等 112 层 modal(仍低于 app-dialog 130,删除确认在预览上),fp-open chat-form 95→115、mini 96→116,main.js Esc 关栈顺序同步先关预览再关 modal。「像文件目录浏览 .kb」提议对齐后定维持现状:.kb 继续对文件面板隐藏,守住删单篇「doc+原件+INDEX 行」三件联动判据。③已入库条目压两行:标题 + 单行省略摘要(原 `.sk-desc` 省略规则只覆盖左栏 `.sk-item`,右栏整段换行铺开),关键词挪进悬停 title 不丢信息。
- **07-23 / 0.59.3 / 知识库下载入口(原件 + 解析 Markdown,纯前端)**:kb 面板此前只能在线看不能取回。关键发现:`.kb/` 就在 user_root 下且 `/v1/files/download` 的 safe_join 只挡越界不挡 dotfile → 零后端改动,`kb.js` 复用 `media.js::downloadFile`(Bearer + blob)拼 `.kb/<库>/{sources,docs}/…` 路径即可。入口三处:已入库条目行「⬇ 下载原件」(agent 手写无原件的条目不显示)、单篇查看页头「下载原件 / 下载 Markdown」(doc→source 映射渲染详情时记 `_docSources`)、待入库列表逐项「下载」。弃选方案:新增 `/v1/kb/*/sources` 专属下载端点(与 files 下载全量重复,公测期白养一个对外契约)。改动仅 kb.js + dev.html 三行 CSS。 - **07-23 / 0.59.3 / 知识库下载入口(原件 + 解析 Markdown,纯前端)**:kb 面板此前只能在线看不能取回。关键发现:`.kb/` 就在 user_root 下且 `/v1/files/download` 的 safe_join 只挡越界不挡 dotfile → 零后端改动,`kb.js` 复用 `media.js::downloadFile`(Bearer + blob)拼 `.kb/<库>/{sources,docs}/…` 路径即可。入口三处:已入库条目行「⬇ 下载原件」(agent 手写无原件的条目不显示)、单篇查看页头「下载原件 / 下载 Markdown」(doc→source 映射渲染详情时记 `_docSources`)、待入库列表逐项「下载」。弃选方案:新增 `/v1/kb/*/sources` 专属下载端点(与 files 下载全量重复,公测期白养一个对外契约)。改动仅 kb.js + dev.html 三行 CSS。
- **07-23 / 0.59.2 / 移动端对话附件入口(📎 拍照 / 照片库 / 选文件)**:chat 输入区此前仅粘贴 / 拖拽两条附件路(移动端双双不可用,手机没有任何传文件入口)。加 📎 按钮:触屏(`pointer: coarse`)弹 showMenu 三项菜单——拍照(`capture="environment"` 直起后置相机)/ 照片库(`accept="image/*,video/*"`)/ 选取文件(无 accept);桌面端点击直开系统文件选择器(拖拽 / 粘贴照旧)。三个隐藏 input 的 change 统一走既有 `uploadAttachFiles()`(chip 托盘 / 发送注入行零改动);change 后清 value 允许连选同一文件;微信 / 企业微信只读镜像随 `applyChannelComposerLock` 禁用按钮 + 点击侧 `_composerLocked()` 双保险。纯前端(dev.html / chat.js),无后端改动。裸 file input 方案(靠 iOS 原生三选单)因 Android 行为不确定被弃,三项菜单跨平台确定。 - **07-23 / 0.59.2 / 移动端对话附件入口(📎 拍照 / 照片库 / 选文件)**:chat 输入区此前仅粘贴 / 拖拽两条附件路(移动端双双不可用,手机没有任何传文件入口)。加 📎 按钮:触屏(`pointer: coarse`)弹 showMenu 三项菜单——拍照(`capture="environment"` 直起后置相机)/ 照片库(`accept="image/*,video/*"`)/ 选取文件(无 accept);桌面端点击直开系统文件选择器(拖拽 / 粘贴照旧)。三个隐藏 input 的 change 统一走既有 `uploadAttachFiles()`(chip 托盘 / 发送注入行零改动);change 后清 value 允许连选同一文件;微信 / 企业微信只读镜像随 `applyChannelComposerLock` 禁用按钮 + 点击侧 `_composerLocked()` 双保险。纯前端(dev.html / chat.js),无后端改动。裸 file input 方案(靠 iOS 原生三选单)因 Android 行为不确定被弃,三项菜单跨平台确定。

2
RUN.md
View File

@ -17,7 +17,7 @@
# 豆包(火山方舟)统一 key,三处共用:可选。 # 豆包(火山方舟)统一 key,三处共用:可选。
# 1) 文本/Agent 模型 config/models/doubao.yaml(Seed 2.1 turbo/pro、自进化 evolving)—— 走 Ark OpenAI 兼容端点 # 1) 文本/Agent 模型 config/models/doubao.yaml(Seed 2.1 turbo/pro、自进化 evolving)—— 走 Ark OpenAI 兼容端点
# 2) 图像生成 seedream tool(0.22 元/张) # 2) 图像生成 seedream tool(0.22 元/张)
# 3) 视频生成 seedance tool(Seedance 2.0 Fast,文生视频,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s) # 3) 视频生成 seedance tool(Seedance 2.0 Fast,文生/单图首帧/最多9张多图参考,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s)
# 未设:豆包文本模型选不了,seedream/seedance 两个 tool 都不出现 # 未设:豆包文本模型选不了,seedream/seedance 两个 tool 都不出现
ARK_API_KEY=... ARK_API_KEY=...
# documents skill(内部知识库 document_search API):可选。设了后注册 # documents skill(内部知识库 document_search API):可选。设了后注册

View File

@ -1,7 +1,7 @@
# zcbot Skill 清单 # zcbot Skill 清单
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材) 服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
最后更新:2026-07-08(新增 rebuttal 审稿回复;paper 补图表纪律与投稿前审计;review 补英文 SCI 润色子模式) 最后更新:2026-07-27(videogen 新增 Seedance 单图首帧与最多 9 张多图参考输入)
Skill 总数:18 Skill 总数:18
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。 zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
@ -455,13 +455,13 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
**何时用**: **何时用**:
- ✅ 用户明确说"做个视频" / "出段视频" / "动画" / "动起来" / "镜头扫过" - ✅ 用户明确说"做个视频" / "出段视频" / "动画" / "动起来" / "镜头扫过"
- ✅ 用户原本要 ppt,主动问能不能配段视频(介绍价格 + 时长引导决策) - ✅ 用户原本要 ppt,主动问能不能配段视频(介绍价格 + 时长引导决策)
- ✅ 拿到 seedream 静态图后说"想动起来"(注意:Phase 1 仅支持 t2v 文生视频,**不支持 i2v** 图生视频) - ✅ 拿到 seedream 静态图或上传图片后说"想动起来"(首帧传 `image`,多图参考传 `reference_images`)
**何时不走本 skill**: **何时不走本 skill**:
- ⛔ 用户没主动要视频 - ⛔ 用户没主动要视频
- ⛔ 流程 / 结构动效(节点-箭头逐次出现)→ 是 ppt 动画 / mermaid 的事 - ⛔ 流程 / 结构动效(节点-箭头逐次出现)→ 是 ppt 动画 / mermaid 的事
- ⛔ 要实拍素材 / 已有视频剪辑 → seedance 是 AI 生成,告诉用户走 unsplash / pexels - ⛔ 要实拍素材 / 已有视频剪辑 → seedance 是 AI 生成,告诉用户走 unsplash / pexels
- ⛔ 用户有具体参考视频说"按这个改" → Phase 1 不支持 i2v / v2v - ⛔ 用户有具体参考视频说"按这个改" → 当前支持图片首帧输入,但不支持视频编辑(v2v)
**关键岔路**:静态图够用就别上动态(seedream ¥0.22 vs seedance ¥4 起)。 **关键岔路**:静态图够用就别上动态(seedream ¥0.22 vs seedance ¥4 起)。

View File

@ -66,8 +66,8 @@ video:
endpoint_poll: /contents/generations/tasks # 实际路径 = base + "/{cgt_id}" endpoint_poll: /contents/generations/tasks # 实际路径 = base + "/{cgt_id}"
# 计费(per-token,token = (in_dur+out_dur) × W × H × fps / 1024): # 计费(per-token,token = (in_dur+out_dur) × W × H × fps / 1024):
# 文生视频(无视频输入,本期主力路径): ¥37 / 百万 tokens # 文生视频(无视觉输入): ¥37 / 百万 tokens
# 图生视频(有视频输入,phase 2): ¥22 / 百万 tokens # 图生视频(有首帧图片输入): ¥22 / 百万 tokens
# 实测档位(fast, 5s, 文生视频, 24fps,源: ArcReel 费用参考表): # 实测档位(fast, 5s, 文生视频, 24fps,源: ArcReel 费用参考表):
# 480p 16:9 → ¥1.86 # 480p 16:9 → ¥1.86
# 720p 16:9 → ¥4.00 # 720p 16:9 → ¥4.00
@ -86,6 +86,9 @@ video:
# 开启会增加 token 消耗(模型还要算音轨),cost 比纯视频高;默认关闭让 cost 可预测, # 开启会增加 token 消耗(模型还要算音轨),cost 比纯视频高;默认关闭让 cost 可预测,
# 用户要带音的视频(广告 / 角色对白)时显式传 true。 # 用户要带音的视频(广告 / 角色对白)时显式传 true。
default_generate_audio: false default_generate_audio: false
max_image_mb: 10 # 图生视频每张本地图片上限(base64 内联)
max_reference_images: 9 # image 首帧 + reference_images 合计上限
max_total_image_mb: 30 # 多图原始体积合计上限,防 base64 请求体失控
# 轮询参数 # 轮询参数
request_timeout_s: 60 # submit POST 超时(异步,只是提交) request_timeout_s: 60 # submit POST 超时(异步,只是提交)
@ -115,6 +118,9 @@ video:
default_duration: 5 default_duration: 5
default_watermark: false default_watermark: false
default_generate_audio: false default_generate_audio: false
max_image_mb: 10
max_reference_images: 9
max_total_image_mb: 30
# 轮询参数:Pro 出片慢于 Fast(更精细),拉长超时 # 轮询参数:Pro 出片慢于 Fast(更精细),拉长超时
request_timeout_s: 60 request_timeout_s: 60

View File

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

View File

@ -69,9 +69,9 @@ _MEDIA_GPT_IMAGE_SEG = """\
_MEDIA_DIAGRAM_FORK_SEG = """\ _MEDIA_DIAGRAM_FORK_SEG = """\
- **""先分岔(mermaid vs 生图)**:用户要**流程图 / 架构图 / 技术路线图 / 时序图**这类结构图时,**不要默默替他选路线** 先用 `ask_user` 让用户在两条路线里点选:mermaid 矢量图(结构清晰文字准可编辑零成本);生图模型视觉版(有质感 / 视觉冲击,但中文标签易乱码位图不可后期改字,seedream ¥0.22 / GPT 生图 ~40s)选②后 `load_skill('imagegen')` 再走**免问直走的例外**:用户已点名工具("用 mermaid" / "用生图画" / "用 GPT 画") 照办;本次对话里用户已选过路线 沿用不再问;paper / proposal skill 管线内部要求 mermaid 配图的 skill 规定走""" - **""先分岔(mermaid vs 生图)**:用户要**流程图 / 架构图 / 技术路线图 / 时序图**这类结构图时,**不要默默替他选路线** 先用 `ask_user` 让用户在两条路线里点选:mermaid 矢量图(结构清晰文字准可编辑零成本);生图模型视觉版(有质感 / 视觉冲击,但中文标签易乱码位图不可后期改字,seedream ¥0.22 / GPT 生图 ~40s)选②后 `load_skill('imagegen')` 再走**免问直走的例外**:用户已点名工具("用 mermaid" / "用生图画" / "用 GPT 画") 照办;本次对话里用户已选过路线 沿用不再问;paper / proposal skill 管线内部要求 mermaid 配图的 skill 规定走"""
_MEDIA_SEEDANCE_SEG = """\ _MEDIA_SEEDANCE_SEG = """\
- `seedance` 豆包视频生成(Seedance 2.0 Fast)异步任务,** 30-90s 出片**;产物自动落 `<task_dir>/videos/`每次 **¥1.86 **(480p 4s)~ **¥12+**(720p 15s),比图贵 10 倍以上触发词:视频 / 动画 / 动起来 / 做个 video / 镜头 / 短片 / 演示视频 / 动效 - `seedance` 豆包视频生成(Seedance 2.0 Fast),支持文生视频单图首帧和最多 9 张多图参考生视频异步任务,** 30-90s 出片**;产物自动落 `<task_dir>/videos/`每次 **¥1.86 **(480p 4s)~ **¥12+**(720p 15s),比图贵 10 倍以上触发词:视频 / 动画 / 动起来 / 做个 video / 镜头 / 短片 / 演示视频 / 动效
- **调用前必须先 `load_skill('videogen')`** skill 里有6 维诊断(含运动维必填)/ seedream/mermaid 反向选型 / prompt 装配 / 参数取舍(时长/分辨率/比例直接决定钱)/ 失败解药全套引导视频比图贵 10 倍且 90s 等待,绝对不要拿用户原话当 prompt 直接调 - **调用前必须先 `load_skill('videogen')`** skill 里有6 维诊断(含运动维必填)/ seedream/mermaid 反向选型 / prompt 装配 / 参数取舍(时长/分辨率/比例直接决定钱)/ 失败解药全套引导视频比图贵 10 倍且 90s 等待,绝对不要拿用户原话当 prompt 直接调
- 兜底硬约束:用户没主动要视频就别装饰性生成(比生图更严重的红线);同一目的不满意**绝不连发**(1 次错 = ¥4+60s,连发 2 = ¥8+2min);phase 1 仅文生视频,**不支持** image-to-video / video-to-video""" - 兜底硬约束:用户没主动要视频就别装饰性生成(比生图更严重的红线);同一目的不满意**绝不连发**(1 次错 = ¥4+60s,连发 2 = ¥8+2min);固定开场图传 `image`,多张主体/产品/场景参考图传 `reference_images`当前不支持 video-to-video"""
def _media_tools_block(has_ark: bool, image_tool: str) -> str: def _media_tools_block(has_ark: bool, image_tool: str) -> str:

View File

@ -26,14 +26,14 @@ description: 用豆包 Seedance 2.0 Fast 生视频(`seedance` tool)。**任何
- 用户**明确说**"做个视频" / "出段视频" / "动画" / "动起来" / "镜头扫过" / "演示视频" - 用户**明确说**"做个视频" / "出段视频" / "动画" / "动起来" / "镜头扫过" / "演示视频"
- 用户原本要 ppt / 海报 / 申报书,**主动**问能不能配段视频(此时介绍价格 + 时长引导决策) - 用户原本要 ppt / 海报 / 申报书,**主动**问能不能配段视频(此时介绍价格 + 时长引导决策)
- 用户拿到 seedream 生的静态图后说"想动起来" / "加点运动" — 注意 phase 1 只支持 t2v 文生视频,**不支持**从已有图生视频(i2v),要告诉用户这点 - 用户拿到 seedream 生的静态图或上传图片后说"想动起来" / "加点运动" → 走图生视频,把该图路径传给 `image`
## 何时不走本 skill ## 何时不走本 skill
- 用户**没主动要视频**(别为"丰富回复"装饰性生视频 —— 比图更严重的浪费红线) - 用户**没主动要视频**(别为"丰富回复"装饰性生视频 —— 比图更严重的浪费红线)
- 用户要的是**流程/结构动效**(节点-箭头-步骤逐次出现)→ 这是 ppt 动画 / mermaid + ppt 转场的事,不是视频 - 用户要的是**流程/结构动效**(节点-箭头-步骤逐次出现)→ 这是 ppt 动画 / mermaid + ppt 转场的事,不是视频
- 用户要的是**实拍素材** / **已有视频剪辑** → seedance 是 AI 生成,不是素材库;告诉用户走 unsplash / pexels 等 - 用户要的是**实拍素材** / **已有视频剪辑** → seedance 是 AI 生成,不是素材库;告诉用户走 unsplash / pexels 等
- 用户**有具体参考视频说"按这个改"** → phase 1 不支持 i2v / v2v,告诉用户先用文字描述 - 用户**有具体参考视频说"按这个改"** → 当前只支持图片首帧输入,不支持视频编辑(v2v),告诉用户先截取关键帧或用文字描述
## 关键岔路:seedream vs seedance vs mermaid ## 关键岔路:seedream vs seedance vs mermaid
@ -111,6 +111,8 @@ description: 用豆包 Seedance 2.0 Fast 生视频(`seedance` tool)。**任何
> prompt: 工地上正在浇筑混凝土的楼板,混凝土从泵车软管流出注入模板, > prompt: 工地上正在浇筑混凝土的楼板,混凝土从泵车软管流出注入模板,
> 工人手持振动棒来回插入,固定俯视镜头缓慢推近模板中心, > 工人手持振动棒来回插入,固定俯视镜头缓慢推近模板中心,
> 写实工程纪录片风格,正午阳光 > 写实工程纪录片风格,正午阳光
> image: 不传(文生视频);需要固定首帧时填用户上传图片或 seedream 的 saved 路径
> reference_images: 不传;多图参考时填图片路径列表(image + reference_images 最多 9 张)
> resolution: 720p > resolution: 720p
> ratio: 16:9(ppt 横版) > ratio: 16:9(ppt 横版)
> duration: 5 秒 > duration: 5 秒
@ -194,6 +196,8 @@ prompt 写法变化:开 `generate_audio=true` 时,prompt 里要描述**声音是
``` ```
seedance( seedance(
prompt="工地上正在浇筑混凝土的楼板,混凝土从泵车软管流出注入模板,工人手持振动棒来回插入,固定俯视镜头缓慢推近模板中心,写实工程纪录片风格,正午阳光", prompt="工地上正在浇筑混凝土的楼板,混凝土从泵车软管流出注入模板,工人手持振动棒来回插入,固定俯视镜头缓慢推近模板中心,写实工程纪录片风格,正午阳光",
image="figures/reference.png", # 可省;传入时作为首帧进行图生视频
reference_images=["figures/worker.png", "figures/site.png"], # 可省;多图参考
resolution="720p", # 可省,走默认 resolution="720p", # 可省,走默认
ratio="16:9", # 可省 ratio="16:9", # 可省
duration=5, # 可省 duration=5, # 可省
@ -201,9 +205,17 @@ seedance(
) )
``` ```
图生视频有两个兼容入口:
- `image`:单张 `first_frame`,视频必须从这张画面开始。
- `reference_images`:0-9 张 `reference_image`,用于约束人物、产品、场景或风格,不强制作为首帧。
两者都支持 task_dir 相对路径、用户上传图片路径,以及 seedream 上次返回的 `saved`
路径,合计最多 9 张。prompt 用“参考图片1/2”明确每张图的用途。续查图生视频任务时
仍带上原 `image` / `reference_images`,用于正确恢复计费模式和产物元数据。
**调用是同步阻塞 Fast 30-90s / Pro 2-3min** —— tool 内部 submit 后轮询直到 succeeded,期间 LLM 卡住。这不是 bug,告诉用户"提交了,等 30-90 秒"再耐心等返回。 **调用是同步阻塞 Fast 30-90s / Pro 2-3min** —— tool 内部 submit 后轮询直到 succeeded,期间 LLM 卡住。这不是 bug,告诉用户"提交了,等 30-90 秒"再耐心等返回。
返回串首行是 `[seedance] model=... · resolution=... · ratio=... · duration=Xs · cost=¥... · elapsed=...s` —— 原样保留给用户(SPA 会 parse 挂徽章)。第二行 `saved: <相对路径>` 是产物路径,告诉用户。 返回串首行是 `[seedance] model=... · mode=text_to_video|image_to_video · images=N · image=... · resolution=... · ratio=... · duration=Xs · cost=¥... · elapsed=...s` —— `images` 是图片总数,`image` 仅固定首帧时出现;原样保留给用户(SPA 会 parse 挂徽章)。第二行 `saved: <相对路径>` 是产物路径,告诉用户。
产物自动落 `<task_dir>/videos/<时间戳>-<rand>.mp4` + 同名 `.meta.json`(prompt / 参数 / cost / tokens / cgt_id 全 snapshot)。 产物自动落 `<task_dir>/videos/<时间戳>-<rand>.mp4` + 同名 `.meta.json`(prompt / 参数 / cost / tokens / cgt_id 全 snapshot)。

165
tests/test_seedance.py Normal file
View File

@ -0,0 +1,165 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from uuid import uuid4
import tools.seedance as seedance_module
from tools.seedance import SeedanceTool
class _FakeArkClient:
submitted_body = None
def __init__(self, *_args, **_kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def post_json(self, _endpoint, body, **_kwargs):
type(self).submitted_body = body
return {"id": "cgt-test"}
def get_json(self, _url, **_kwargs):
return {
"status": "succeeded",
"content": {"video_url": "https://example.com/result.mp4"},
}
def download(self, _url, dest, **_kwargs):
Path(dest).write_bytes(b"video")
class SeedanceToolTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.root = Path(self._tmp.name) / "user"
self.root.mkdir()
_FakeArkClient.submitted_body = None
def tearDown(self):
self._tmp.cleanup()
def make_tool(self) -> SeedanceTool:
return SeedanceTool(
ark_cfg=object(),
video_variant_cfg={
"model_id": "doubao-seedance-test",
"default_resolution": "720p",
"default_ratio": "16:9",
"default_duration": 5,
"default_watermark": False,
"default_generate_audio": False,
"price_cny_per_mtoken_text2video": 37.0,
"price_cny_per_mtoken_video2video": 22.0,
"fps": 24,
"poll_interval_s": 0,
},
variant_key="seedance_test",
working_dir=self.root,
user_root=self.root,
task_id=uuid4(),
user_id=uuid4(),
)
def test_image_to_video_builds_first_frame_and_uses_i2v_price(self):
(self.root / "source.png").write_bytes(b"\x89PNG\r\n\x1a\n")
usage = {}
with (
patch.object(seedance_module, "ArkClient", _FakeArkClient),
patch.object(
seedance_module,
"record_usage_safe",
side_effect=lambda _name, _fn, **kw: usage.update(kw),
),
):
result = self.make_tool().execute(prompt="让水流动起来", image="source.png")
content = _FakeArkClient.submitted_body["content"]
self.assertEqual(content[0], {"type": "text", "text": "让水流动起来"})
self.assertEqual(content[1]["type"], "image_url")
self.assertEqual(content[1]["role"], "first_frame")
self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/png;base64,"))
self.assertIs(usage["has_video_input"], True)
self.assertEqual(usage["price_cny_per_mtoken"], 22.0)
self.assertIn("mode=image_to_video", result)
self.assertIn("image=source.png", result)
def test_text_to_video_remains_backward_compatible(self):
usage = {}
with (
patch.object(seedance_module, "ArkClient", _FakeArkClient),
patch.object(
seedance_module,
"record_usage_safe",
side_effect=lambda _name, _fn, **kw: usage.update(kw),
),
):
result = self.make_tool().execute(prompt="水泥浆缓慢流动")
self.assertEqual(
_FakeArkClient.submitted_body["content"],
[{"type": "text", "text": "水泥浆缓慢流动"}],
)
self.assertIs(usage["has_video_input"], False)
self.assertEqual(usage["price_cny_per_mtoken"], 37.0)
self.assertIn("mode=text_to_video", result)
def test_multi_image_references_use_reference_role(self):
for name in ("product-front.png", "product-side.png"):
(self.root / name).write_bytes(b"\x89PNG\r\n\x1a\n")
usage = {}
with (
patch.object(seedance_module, "ArkClient", _FakeArkClient),
patch.object(
seedance_module,
"record_usage_safe",
side_effect=lambda _name, _fn, **kw: usage.update(kw),
),
):
result = self.make_tool().execute(
prompt="参考图片1和图片2生成产品环绕展示",
reference_images=["product-front.png", "product-side.png"],
)
content = _FakeArkClient.submitted_body["content"]
self.assertEqual([part.get("role") for part in content[1:]], [
"reference_image",
"reference_image",
])
self.assertEqual(
usage["extra_units"]["reference_images"],
["product-front.png", "product-side.png"],
)
self.assertIn("mode=image_to_video", result)
self.assertIn("images=2", result)
def test_rejects_more_than_nine_images(self):
result = self.make_tool().execute(
prompt="多图参考",
reference_images=[f"image-{i}.png" for i in range(10)],
)
self.assertEqual(
result,
"[Error] Seedance 最多支持 9 张输入图片(image + reference_images 合计)。",
)
def test_rejects_image_outside_user_root(self):
outside = self.root.parent / "outside.png"
outside.write_bytes(b"\x89PNG\r\n\x1a\n")
result = self.make_tool().execute(prompt="动起来", image=str(outside))
self.assertTrue(result.startswith("[Error] 图片找不到或越界:"))
if __name__ == "__main__":
unittest.main()

View File

@ -1,4 +1,4 @@
"""seedance: 调豆包 Seedance 2.0 Fast 视频生成 API,产物落 working_dir/videos/。 """seedance: 调豆包 Seedance 2.0 视频生成 API,支持文生/图生视频,产物落 working_dir/videos/。
异步任务: 异步任务:
1. POST /contents/generations/tasks `{"id": "cgt-..."}` 1. POST /contents/generations/tasks `{"id": "cgt-..."}`
@ -7,7 +7,7 @@
3. succeeded content.video_url download 到本地 + meta + usage_events 3. succeeded content.video_url download 到本地 + meta + usage_events
模型 ID + 单价 + 默认参数全在 `config/media/doubao.yaml`, tool 只装配 模型 ID + 单价 + 默认参数全在 `config/media/doubao.yaml`, tool 只装配
计费按 token 公式 `(in_dur+out_dur) × W × H × fps / 1024`,文生视频 in_dur=0; 计费按 token 公式 `(in_dur+out_dur) × W × H × fps / 1024`;
W×H resolution + ratio 推算(横版 height=resolution_num,竖版 width=resolution_num) W×H resolution + ratio 推算(横版 height=resolution_num,竖版 width=resolution_num)
完成后: 完成后:
@ -28,6 +28,7 @@ from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import record_video_usage from core.storage.usage import record_video_usage
from .base import Tool from .base import Tool
from .image_ref import load_image_as_data_url
from .media_common import ( from .media_common import (
find_first_url, find_first_url,
quota_gate, quota_gate,
@ -76,7 +77,9 @@ def _estimate_tokens(width: int, height: int, duration_s: int, fps: int, in_dur_
class SeedanceTool(Tool): class SeedanceTool(Tool):
name = "seedance" name = "seedance"
description = ( description = (
"Generate a short video with Doubao Seedance 2.0 Fast and save to working_dir/videos/. " "Generate a short video with Doubao Seedance 2.0 and save to working_dir/videos/. "
"Supports text-to-video, first-frame video via `image`, and multi-image reference video "
"via `reference_images` (up to 9 images total). "
"Use only when the user explicitly asks for a video / 视频 / 动画 / 动起来. " "Use only when the user explicitly asks for a video / 视频 / 动画 / 动起来. "
"Async: takes 30-90s to render. Costs ~¥1.86 (480p, 5s) ~ ¥4.00 (720p, 5s); " "Async: takes 30-90s to render. Costs ~¥1.86 (480p, 5s) ~ ¥4.00 (720p, 5s); "
"longer / higher-resolution scales up. Returns the saved relative path." "longer / higher-resolution scales up. Returns the saved relative path."
@ -88,6 +91,22 @@ class SeedanceTool(Tool):
"type": "string", "type": "string",
"description": "中文或英文都行,详尽描述画面 + 运动 + 镜头(主体在做什么 / 镜头怎么动 / 场景 / 风格)。", "description": "中文或英文都行,详尽描述画面 + 运动 + 镜头(主体在做什么 / 镜头怎么动 / 场景 / 风格)。",
}, },
"image": {
"type": "string",
"description": (
"图生视频的首帧图片路径(可选)。支持 task_dir 相对路径、用户上传图片路径,"
"或工具上次返回的 saved 路径;不传则不固定首帧。"
),
},
"reference_images": {
"type": "array",
"description": (
"多图参考路径列表(可选),用于保持人物、产品、场景或风格一致;"
"每项支持与 image 相同的路径形态。与 image 合计最多 9 张。"
),
"items": {"type": "string"},
"maxItems": 9,
},
"resolution": { "resolution": {
"type": "string", "type": "string",
"description": "Video resolution. fast 版仅支持 '480p' / '720p'(默认 720p);1080p+ 仅 pro 可用。", "description": "Video resolution. fast 版仅支持 '480p' / '720p'(默认 720p);1080p+ 仅 pro 可用。",
@ -154,6 +173,8 @@ class SeedanceTool(Tool):
def execute( def execute(
self, self,
prompt: str, prompt: str,
image: Optional[str] = None,
reference_images: Optional[list[str]] = None,
resolution: Optional[str] = None, resolution: Optional[str] = None,
ratio: Optional[str] = None, ratio: Optional[str] = None,
duration: Optional[int] = None, duration: Optional[int] = None,
@ -164,6 +185,43 @@ class SeedanceTool(Tool):
if not (prompt or "").strip(): if not (prompt or "").strip():
return "[Error] prompt 不能为空" return "[Error] prompt 不能为空"
resume_id = (resume_task_id or "").strip() resume_id = (resume_task_id or "").strip()
image_arg = (image or "").strip()
reference_args = [str(v).strip() for v in (reference_images or []) if str(v).strip()]
max_images = int(self.cfg.get("max_reference_images", 9))
if len(reference_args) + bool(image_arg) > max_images:
return f"[Error] Seedance 最多支持 {max_images} 张输入图片(image + reference_images 合计)。"
max_image_bytes = int(float(self.cfg.get("max_image_mb", 10)) * 1024 * 1024)
max_total_bytes = int(float(self.cfg.get("max_total_image_mb", 30)) * 1024 * 1024)
loaded_images: list[tuple[str, str, str]] = []
image_specs = ([("first_frame", image_arg)] if image_arg else []) + [
("reference_image", path) for path in reference_args
]
total_image_bytes = 0
for role, path in image_specs:
data_url, display, image_err = load_image_as_data_url(
path,
working_dir=self.working_dir,
user_root=self.user_root,
display_fn=self._display,
max_bytes=max_image_bytes,
)
if image_err:
return image_err
total_image_bytes += len(data_url.rsplit(",", 1)[-1]) * 3 // 4
if total_image_bytes > max_total_bytes:
return (
f"[Error] 输入图片合计超过 {max_total_bytes // 1024 // 1024}MB 上限。"
"请减少图片数量或先压缩图片。"
)
loaded_images.append((role, data_url, display))
has_image_input = bool(loaded_images)
first_frame_display = next(
(display for role, _url, display in loaded_images if role == "first_frame"), ""
)
reference_displays = [
display for role, _url, display in loaded_images if role == "reference_image"
]
# 每账号每日配额(yaml quotas.videos_per_day,细节见 media_common.quota_gate)。 # 每账号每日配额(yaml quotas.videos_per_day,细节见 media_common.quota_gate)。
# resume 不过配额闸:原次提交已经占过额度,续查不是新生成。 # resume 不过配额闸:原次提交已经占过额度,续查不是新生成。
@ -189,13 +247,23 @@ class SeedanceTool(Tool):
poll_interval = float(cfg.get("poll_interval_s", 5)) poll_interval = float(cfg.get("poll_interval_s", 5))
poll_timeout = float(cfg.get("poll_timeout_s", 600)) poll_timeout = float(cfg.get("poll_timeout_s", 600))
price_t2v = float(cfg.get("price_cny_per_mtoken_text2video", 37.0)) price_t2v = float(cfg.get("price_cny_per_mtoken_text2video", 37.0))
price_i2v = float(cfg.get("price_cny_per_mtoken_video2video", price_t2v))
chosen_price = price_i2v if has_image_input else price_t2v
submit_endpoint = cfg.get("endpoint_submit", "/contents/generations/tasks") submit_endpoint = cfg.get("endpoint_submit", "/contents/generations/tasks")
poll_endpoint_base = cfg.get("endpoint_poll", "/contents/generations/tasks") poll_endpoint_base = cfg.get("endpoint_poll", "/contents/generations/tasks")
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
for role, data_url, _display in loaded_images:
content.append({
"type": "image_url",
"image_url": {"url": data_url},
"role": role,
})
body: dict[str, Any] = { body: dict[str, Any] = {
"model": model_id, "model": model_id,
"content": [{"type": "text", "text": prompt}], "content": content,
"ratio": chosen_ratio, "ratio": chosen_ratio,
"resolution": chosen_resolution, "resolution": chosen_resolution,
"duration": chosen_duration, "duration": chosen_duration,
@ -226,7 +294,7 @@ class SeedanceTool(Tool):
return ( return (
f"[Cancelled] seedance task {cgt_id} 等待被中断(远端任务仍在跑," f"[Cancelled] seedance task {cgt_id} 等待被中断(远端任务仍在跑,"
f"24h 内可用 resume_task_id=\"{cgt_id}\" 继续等待/取回结果,不重复计费;" f"24h 内可用 resume_task_id=\"{cgt_id}\" 继续等待/取回结果,不重复计费;"
f"Volcengine 失败/成功才计费,若仍出片可能产生 ~¥{self._rough_cost(chosen_resolution, chosen_ratio, chosen_duration, fps, price_t2v):.2f})" f"Volcengine 失败/成功才计费,若仍出片可能产生 ~¥{self._rough_cost(chosen_resolution, chosen_ratio, chosen_duration, fps, chosen_price):.2f})"
) )
if time.monotonic() > deadline: if time.monotonic() > deadline:
return ( return (
@ -262,10 +330,13 @@ class SeedanceTool(Tool):
width, height = _resolve_dimensions(chosen_resolution, chosen_ratio) width, height = _resolve_dimensions(chosen_resolution, chosen_ratio)
tokens_estimated = _estimate_tokens(width, height, chosen_duration, fps, in_dur_s=0) tokens_estimated = _estimate_tokens(width, height, chosen_duration, fps, in_dur_s=0)
tokens_actual = self._extract_tokens(final_resp) or tokens_estimated tokens_actual = self._extract_tokens(final_resp) or tokens_estimated
cost_cny = tokens_actual * price_t2v / 1_000_000.0 cost_cny = tokens_actual * chosen_price / 1_000_000.0
meta = { meta = {
"prompt": prompt, "prompt": prompt,
"mode": "image_to_video" if has_image_input else "text_to_video",
"input_image": first_frame_display or None,
"reference_images": reference_displays,
"model_id": model_id, "model_id": model_id,
"resolution": chosen_resolution, "resolution": chosen_resolution,
"ratio": chosen_ratio, "ratio": chosen_ratio,
@ -277,7 +348,7 @@ class SeedanceTool(Tool):
"generate_audio": chosen_generate_audio, "generate_audio": chosen_generate_audio,
"tokens": tokens_actual, "tokens": tokens_actual,
"tokens_estimated": tokens_estimated, "tokens_estimated": tokens_estimated,
"price_cny_per_mtoken": price_t2v, "price_cny_per_mtoken": chosen_price,
"cost_cny": round(cost_cny, 4), "cost_cny": round(cost_cny, 4),
"elapsed_s": round(elapsed, 1), "elapsed_s": round(elapsed, 1),
"cgt_id": cgt_id, "cgt_id": cgt_id,
@ -297,21 +368,29 @@ class SeedanceTool(Tool):
width=width, width=width,
height=height, height=height,
tokens=tokens_actual, tokens=tokens_actual,
price_cny_per_mtoken=price_t2v, price_cny_per_mtoken=chosen_price,
has_video_input=False, # phase 1 仅 t2v;i2v 接入后这里读 body 判断 # usage schema 沿用历史字段名;其语义已包含图片/视频类视觉输入。
has_video_input=has_image_input,
watermark=chosen_watermark, watermark=chosen_watermark,
extra_units={ extra_units={
"cgt_id": cgt_id, "cgt_id": cgt_id,
"elapsed_s": round(elapsed, 1), "elapsed_s": round(elapsed, 1),
"generate_audio": chosen_generate_audio, "generate_audio": chosen_generate_audio,
"input_image": first_frame_display or None,
"reference_images": reference_displays,
}, },
) )
disp = self._display(dest_mp4) disp = self._display(dest_mp4)
mode = "image_to_video" if has_image_input else "text_to_video"
image_banner = f" · images={len(loaded_images)}" if loaded_images else ""
if first_frame_display:
image_banner += f" · image={first_frame_display}"
# banner 协议与 seedream 一致:首行 `[tool] key=value · key=value ...` # banner 协议与 seedream 一致:首行 `[tool] key=value · key=value ...`
# 前端 extractMediaBanner 已 whitelist seedance,正则抓 key=value 挂徽章 # 前端 extractMediaBanner 已 whitelist seedance,正则抓 key=value 挂徽章
return ( return (
f"[seedance] model={model_id} · resolution={chosen_resolution} · ratio={chosen_ratio} · " f"[seedance] model={model_id} · mode={mode}{image_banner} · "
f"resolution={chosen_resolution} · ratio={chosen_ratio} · "
f"duration={chosen_duration}s · audio={chosen_generate_audio} · " f"duration={chosen_duration}s · audio={chosen_generate_audio} · "
f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s\n" f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s\n"
f"saved: {disp}\n" f"saved: {disp}\n"

View File

@ -506,6 +506,7 @@
} }
.kb-ingest-err { margin-top: 8px; font-size: 12px; color: #b45309; } .kb-ingest-err { margin-top: 8px; font-size: 12px; color: #b45309; }
.kb-pending { padding: 3px 0; font-size: 12px; color: var(--muted); } .kb-pending { padding: 3px 0; font-size: 12px; color: var(--muted); }
.kb-src-name { font-weight: 400; font-size: 11px; color: var(--muted); margin-left: 6px; }
.kb-entry { display: flex; align-items: flex-start; gap: 6px; padding: 6px 0; border-bottom: 1px dashed var(--border); } .kb-entry { display: flex; align-items: flex-start; gap: 6px; padding: 6px 0; border-bottom: 1px dashed var(--border); }
.kb-entry-main { flex: 1; min-width: 0; cursor: pointer; } .kb-entry-main { flex: 1; min-width: 0; cursor: pointer; }
/* 标题/摘要各压一行省略(sk-item 的省略规则只覆盖左栏,这里单列);全文在悬停 title */ /* 标题/摘要各压一行省略(sk-item 的省略规则只覆盖左栏,这里单列);全文在悬停 title */

View File

@ -135,11 +135,11 @@ function renderDetail(d) {
const doc = e.doc.replace(/^docs\//, ""); const doc = e.doc.replace(/^docs\//, "");
const src = (e.source || "").replace(/^sources\//, ""); const src = (e.source || "").replace(/^sources\//, "");
if (src) _docSources[doc] = src; if (src) _docSources[doc] = src;
// 行内只留标题 + 单行摘要(CSS 省略);完整摘要/关键词进 title 悬停提示,不占行高 // 行内只留标题(+原件名小字)+ 单行摘要(CSS 省略);完整摘要/关键词进 title 悬停提示,不占行高
const tip = e.summary + (e.keywords ? "\n关键词: " + e.keywords : ""); const tip = e.summary + (e.keywords ? "\n关键词: " + e.keywords : "") + (src ? "\n原件: " + src : "");
return `<div class="kb-entry"> return `<div class="kb-entry">
<div class="kb-entry-main" data-doc="${escapeHtml(doc)}" title="${escapeHtml(tip)}"> <div class="kb-entry-main" data-doc="${escapeHtml(doc)}" title="${escapeHtml(tip)}">
<div class="sk-name">${escapeHtml(e.title)}</div> <div class="sk-name">${escapeHtml(e.title)}${src ? `<span class="kb-src-name">${escapeHtml(src)}</span>` : ""}</div>
<div class="sk-desc">${escapeHtml(e.summary)}</div> <div class="sk-desc">${escapeHtml(e.summary)}</div>
</div> </div>
${src ? `<button class="small kb-src-view" data-src="${escapeHtml(src)}" title="查看原件">👁</button> ${src ? `<button class="small kb-src-view" data-src="${escapeHtml(src)}" title="查看原件">👁</button>
@ -166,6 +166,7 @@ async function showDoc(filename) {
detail.innerHTML = detail.innerHTML =
`<div class="sk-d-head"><button id="kb-back" class="small">← 返回</button> `<div class="sk-d-head"><button id="kb-back" class="small">← 返回</button>
<span class="sk-d-name">${escapeHtml(filename)}</span> <span class="sk-d-name">${escapeHtml(filename)}</span>
${src ? `<span class="kb-src-name">原件: ${escapeHtml(src)}</span>` : ""}
<span class="spacer"></span> <span class="spacer"></span>
${src ? `<button id="kb-doc-src-view" class="small" data-src="${escapeHtml(src)}">查看原件</button> ${src ? `<button id="kb-doc-src-view" class="small" data-src="${escapeHtml(src)}">查看原件</button>
<button id="kb-doc-src-dl" class="small" data-src="${escapeHtml(src)}">下载原件</button>` : ""} <button id="kb-doc-src-dl" class="small" data-src="${escapeHtml(src)}">下载原件</button>` : ""}