Compare commits

..

3 Commits

45 changed files with 1263 additions and 182 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.26 — 2026-08-03
- 默认 DeepSeek Flash 已使用官方 0731 API 升级,继续展示思考过程并显式控制推理强度;不同模型的思考开关不再依赖服务端默认值,切换模型时行为更稳定。
## 0.60.25 — 2026-08-03 ## 0.60.25 — 2026-08-03
- HTML 产物现在可以在对话中直接显示进入可视区域后自动加载交互内容也可放大到弹窗查看HTML 与 Markdown 预览均可在渲染结果和源文件之间切换,常见 HTTPS 图表、地图及网页资源可正常加载。 - HTML 产物现在可以在对话中直接显示进入可视区域后自动加载交互内容也可放大到弹窗查看HTML 与 Markdown 预览均可在渲染结果和源文件之间切换,常见 HTTPS 图表、地图及网页资源可正常加载。

View File

@ -108,6 +108,8 @@ Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM
默认 `deepseek_v4.flash`;复杂 bug / 终稿升 pro + reasoning_effort=max;fallback 手动切 Claude。成本量级:修 bug flash ~$0.01 / 完整申报书 flash ~$0.30(pro-max ~$1.5,Opus ~$10+)。99% 任务 flash 够用。 默认 `deepseek_v4.flash`;复杂 bug / 终稿升 pro + reasoning_effort=max;fallback 手动切 Claude。成本量级:修 bug flash ~$0.01 / 完整申报书 flash ~$0.30(pro-max ~$1.5,Opus ~$10+)。99% 任务 flash 够用。
模型思考参数由 profile 统一表达:`thinking_enabled` 只表示开关,`thinking_transport` 只表示已验证的传输协议,`reasoning_effort` 只表示开启后的推理强度;`core/llm_params.py` 是请求构造唯一入口。DeepSeek、GLM、方舟当前共享 `extra_body` 协议,未验证网关明确用 `none`、不猜参数协议,主循环不再按 family 分支。`/v1/models` 只返回语义明确的 `thinking_enabled`
--- ---
## 5. 设计哲学 ## 5. 设计哲学
@ -152,6 +154,8 @@ Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM
**新对话入口(0.60)**:登录未选 task 与左栏「+ 新对话」共用同一前端草稿页,先选择已有 working_dir 或输入新目录名,再直接写消息;草稿不落 DB首发时才 `POST /v1/tasks`,避免空 task 堆积。创建请求省略/留空 name 时必须显式给 working_dir后端据此判定自动命名以「新对话」占位并置一次性 `auto_title_pending`;显式 name 的旧调用继续视为人工标题working_dir 仍可省略并 fallback 到 name`auto_title` 字段只作兼容保留。首条消息并行触发短标题调用,结果只改 `tasks.name`、绝不改 working_dir人工 PATCH name 同时清 pending条件 UPDATE 保证在途标题也不能覆盖用户命名。原完整创建表单保留为「自定义」入口UI 同样要求明确选择 working_dirname 可选,并可预设 description/skill/model。标题是 UI 元数据辅助调用,记 `usage_events.kind="task_title"`,失败只保留占位名、不阻塞主 run。 **新对话入口(0.60)**:登录未选 task 与左栏「+ 新对话」共用同一前端草稿页,先选择已有 working_dir 或输入新目录名,再直接写消息;草稿不落 DB首发时才 `POST /v1/tasks`,避免空 task 堆积。创建请求省略/留空 name 时必须显式给 working_dir后端据此判定自动命名以「新对话」占位并置一次性 `auto_title_pending`;显式 name 的旧调用继续视为人工标题working_dir 仍可省略并 fallback 到 name`auto_title` 字段只作兼容保留。首条消息并行触发短标题调用,结果只改 `tasks.name`、绝不改 working_dir人工 PATCH name 同时清 pending条件 UPDATE 保证在途标题也不能覆盖用户命名。原完整创建表单保留为「自定义」入口UI 同样要求明确选择 working_dirname 可选,并可预设 description/skill/model。标题是 UI 元数据辅助调用,记 `usage_events.kind="task_title"`,失败只保留占位名、不阻塞主 run。
**对话产物引用(0025)**:真实文件仍是事实源,不建 artifacts 表;`messages.artifact_refs` 只保存可重建的轻量 UI 元数据,规范路径以该 task 的**当前 working_dir 为根**,形如 `{version:1, scope:"working_dir", path:"reports/a.pdf", label?:"最终报告"}`。预览/下载走 task-scoped 文件 API服务端用 task 当前 `working_dir` 解析因此顶层工作目录改名后历史卡片仍有效。普通源码树、中间文件和配套资源只留文件面板agent 仅用 `publish_artifacts` 显式提升少量最终文件,单条消息最多 10 个,图像/视频/Office 转 PDF 等成品工具可自动提升。`NULL` 表示迁移前旧消息,前端继续使用正文路径抽取,并在 task-scoped API 上启用只读兼容链(旧 user-root 含义→原样 task-relative→去掉旧目录前缀新消息写 `[]` 或结构化列表,停止启发式抽取,避免重复卡片与误识别。文件在 working_dir 内再次移动或删除后引用可失效,这是 FS 事实源语义,不复制文件、不引不可变对象存储。
### 7.2 资源模型(/v1) ### 7.2 资源模型(/v1)
统一 `/v1` 前缀返 JSON;UI 由 platform 实现(§7.9),本地 dev SPA dogfood。要点(细节见 `web/app.py`): 统一 `/v1` 前缀返 JSON;UI 由 platform 实现(§7.9),本地 dev SPA dogfood。要点(细节见 `web/app.py`):
@ -168,6 +172,8 @@ Tasks POST/GET/PATCH/DELETE /v1/tasks*(POST 可选 auto_title;分页+筛选+
Auth POST /v1/auth/login(platform_key)/ login_password / change_password;GET /v1/me Auth POST /v1/auth/login(platform_key)/ login_password / change_password;GET /v1/me
Files GET /v1/files?path= / upload / download / delete / rename Files GET /v1/files?path= / upload / download / delete / rename
(user-rooted;dotfile 隐藏;越界 400;顶层目录 DB-aware,见 §7.4) (user-rooted;dotfile 隐藏;越界 400;顶层目录 DB-aware,见 §7.4)
GET /v1/tasks/{id}/files/download|preview_pdf?path=
(working_dir-rooted;结构化产物入口,保留旧 user-rooted API)
Admin GET /v1/admin/*(require_admin;overview + usage/models|users + storage/users) Admin GET /v1/admin/*(require_admin;overview + usage/models|users + storage/users)
Export GET /v1/tasks/{id}/export(docx) Export GET /v1/tasks/{id}/export(docx)
``` ```
@ -196,7 +202,8 @@ tasks(task_id pk, user_id fk, name NOT NULL, auto_title_pending default false,
context_base_idx, -- 0019 §8.8 软重置窗口起点 context_base_idx, -- 0019 §8.8 软重置窗口起点
deleted_at, -- 0010 软删 deleted_at, -- 0010 软删
created_at, updated_at) created_at, updated_at)
messages(pk, task_id fk, idx, payload jsonb, tokens_in/out, model_profile, kind, -- kind=push 等 messages(pk, task_id fk, idx, payload jsonb, artifact_refs jsonb null, -- 0025,task-relative UI 元数据
tokens_in/out, model_profile, kind, -- kind=push 等
unique(task_id, idx); gin(payload)) unique(task_id, idx); gin(payload))
usage_events(pk, user_id, task_id, message_id, kind, -- chat/image/video/vision/... 自由文本 usage_events(pk, user_id, task_id, message_id, kind, -- chat/image/video/vision/... 自由文本
model_profile, units jsonb, cost numeric, created_at) -- 多态用量,加媒体不动 schema model_profile, units jsonb, cost numeric, created_at) -- 多态用量,加媒体不动 schema

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-08-03(交互式 HTML 预览 + 对话内嵌,bump 0.60.25) 最后更新:2026-08-03(DeepSeek Flash-0731 + thinking 参数统一,bump 0.60.26)
--- ---
@ -23,6 +23,7 @@
### 2026-08-03 ### 2026-08-03
- **08-03 / 0.60.26 / DeepSeek Flash-0731 + thinking 参数统一**:默认 `deepseek-v4-flash` 无需换模型 ID 即接入官方 0731 后训练升级Flash 改为显式开启 thinking 并透传 `reasoning_effort=high`,校准当前常规时段 token 成本但保留 8K 稳定输出预算。模型档案统一用 `thinking_enabled`(开关)+`thinking_transport`(协议)+`reasoning_effort`(强度)DeepSeek/GLM/方舟共用纯函数请求构造,移除 family 分支与旧 `thinking_mode` 字段方舟保持既有思考开启GLM 保持生产验证过的显式关闭,未验证网关标记 `none`。`/v1/models` 同步只返回新字段445 项 unittest 全绿(17 skip)Ruff 与 diff 检查通过;未连生产 DB、未发真实模型请求无 schema/migration/依赖变化。
- **08-03 / 0.60.25 / 交互式 HTML 预览 + 对话内嵌**:文件预览将 HTML 从普通源码提升为可切换“预览 / 源文件”的 sandbox iframe允许脚本与 HTTPS CDN/接口但保持 opaque origin禁止宿主权限、表单和顶层跳转助手最终答复中的 HTML 产物改为进入可视区才加载的内嵌卡片并可放大复用完整预览Markdown 同步补源文件切换。Node 14 项、Python 27 项、JavaScript 语法及 diff 检查通过;当前环境无可用浏览器实例,真实页面点击/截图留部署后冒烟;无 schema、migration、HTTP API 或依赖变化。 - **08-03 / 0.60.25 / 交互式 HTML 预览 + 对话内嵌**:文件预览将 HTML 从普通源码提升为可切换“预览 / 源文件”的 sandbox iframe允许脚本与 HTTPS CDN/接口但保持 opaque origin禁止宿主权限、表单和顶层跳转助手最终答复中的 HTML 产物改为进入可视区才加载的内嵌卡片并可放大复用完整预览Markdown 同步补源文件切换。Node 14 项、Python 27 项、JavaScript 语法及 diff 检查通过;当前环境无可用浏览器实例,真实页面点击/截图留部署后冒烟;无 schema、migration、HTTP API 或依赖变化。
- **08-03 / 0.60.24 / Web Mermaid 直出 + Markdown 围栏容错**:模型偶发用同长度围栏嵌套 Markdown/Mermaid 示例CommonMark 会把后续正文吞进未闭合代码块;新增仅针对该明确形态的前后端确定性修复,提示词统一要求外层使用更长异类围栏,历史上下文加载时同样修正且不批量回写生产数据。聊天页本地 vendoring Mermaid 11.16.0,仅在助手文字段定稿后顺序渲染 `language-mermaid`,采用 strict 安全级别、文本/边数上限,语法错误或组件不可用时保留源码并提示;真实 Edge 冒烟确认中文流程图与 XYChart 柱线组合图可生成 SVG。Python 27 项、Node 9 项、Ruff、JS/Python 语法及 diff 检查通过;无 schema、migration、HTTP API 或 Python 依赖变化。 - **08-03 / 0.60.24 / Web Mermaid 直出 + Markdown 围栏容错**:模型偶发用同长度围栏嵌套 Markdown/Mermaid 示例CommonMark 会把后续正文吞进未闭合代码块;新增仅针对该明确形态的前后端确定性修复,提示词统一要求外层使用更长异类围栏,历史上下文加载时同样修正且不批量回写生产数据。聊天页本地 vendoring Mermaid 11.16.0,仅在助手文字段定稿后顺序渲染 `language-mermaid`,采用 strict 安全级别、文本/边数上限,语法错误或组件不可用时保留源码并提示;真实 Edge 冒烟确认中文流程图与 XYChart 柱线组合图可生成 SVG。Python 27 项、Node 9 项、Ruff、JS/Python 语法及 diff 检查通过;无 schema、migration、HTTP API 或 Python 依赖变化。
- **08-03 / 0.60.23 / Office→PDF 组件感知 + 展示路径兼容**:生产 task `92ac20cf` 暴露两层问题:上传消息给出 user-root 相对的 `测试pdf/x.doc`host tool 又按 task_dir 拼接导致首次找不到改用裸文件名后host 仅安装 `libreoffice-impress` 却因只检测 `soffice` 而错误宣称支持 DOC最终 Writer 导入返回 `source file could not be loaded`。现 `office_to_pdf` 同时解析 task 相对、user-root 展示路径与 `/workspace` 路径Debian/Ubuntu 按 Writer/Calc/Impress 实际安装包缓存支持后缀,工具 schema 只声明可用格式缺组件在启动转换前给出明确管理员提示。RUN bootstrap 与故障表同步要求 host 安装三组件;相关 30 项 unittest、Python 编译及 diff 格式检查通过,本机无 LibreOffice真实 `.doc/.xlsx/.pptx` 冒烟留部署 host无 schema、migration、HTTP API 或 Python 依赖变化,无需重建沙箱镜像。 - **08-03 / 0.60.23 / Office→PDF 组件感知 + 展示路径兼容**:生产 task `92ac20cf` 暴露两层问题:上传消息给出 user-root 相对的 `测试pdf/x.doc`host tool 又按 task_dir 拼接导致首次找不到改用裸文件名后host 仅安装 `libreoffice-impress` 却因只检测 `soffice` 而错误宣称支持 DOC最终 Writer 导入返回 `source file could not be loaded`。现 `office_to_pdf` 同时解析 task 相对、user-root 展示路径与 `/workspace` 路径Debian/Ubuntu 按 Writer/Calc/Impress 实际安装包缓存支持后缀,工具 schema 只声明可用格式缺组件在启动转换前给出明确管理员提示。RUN bootstrap 与故障表同步要求 host 安装三组件;相关 30 项 unittest、Python 编译及 diff 格式检查通过,本机无 LibreOffice真实 `.doc/.xlsx/.pptx` 冒烟留部署 host无 schema、migration、HTTP API 或 Python 依赖变化,无需重建沙箱镜像。

4
RUN.md
View File

@ -274,7 +274,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `GET /v1/skills` | 列当前 user 可用 skill(内置 + 自己的);每项带 `source`(builtin/user)/`overrides_builtin`;另返 `load_errors`(用户 skill 因 frontmatter 坏未加载的) | 必填 | | `GET /v1/skills` | 列当前 user 可用 skill(内置 + 自己的);每项带 `source`(builtin/user)/`overrides_builtin`;另返 `load_errors`(用户 skill 因 frontmatter 坏未加载的) | 必填 |
| `GET /v1/skills/{name}` | 返某 skill 完整 SKILL.md 正文(前端「技能」modal 点开查看);同名按 user wins | 必填 | | `GET /v1/skills/{name}` | 返某 skill 完整 SKILL.md 正文(前端「技能」modal 点开查看);同名按 user wins | 必填 |
| `DELETE /v1/skills/{name}` | 删当前 user 私有 skill(`.skills/<name>/` 整目录);只删 user 源,内置不可删 → 404;`.skills` 文件面板隐藏,这是 UI 上删自己 skill 的唯一入口 | 必填 | | `DELETE /v1/skills/{name}` | 删当前 user 私有 skill(`.skills/<name>/` 整目录);只删 user 源,内置不可删 → 404;`.skills` 文件面板隐藏,这是 UI 上删自己 skill 的唯一入口 | 必填 |
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 | | `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传0025 起每条另带 `artifact_refs``null`=旧消息、`[]`=新消息无产物、非空数组=相对该 task 当前 working_dir 的结构化产物引用 | 必填 |
| `POST /v1/tasks/{id}/messages` | `{content, image_model?=""}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);`image_model` 是 `config/media/doubao.yaml` image 段的 variant key(空 → 沿用 yaml 第一个),仅本 run 装配 SeedreamTool 时使用,不入 DB;UI 应 disable send 直到 SSE `done` | 必填 | | `POST /v1/tasks/{id}/messages` | `{content, image_model?=""}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);`image_model` 是 `config/media/doubao.yaml` image 段的 variant key(空 → 沿用 yaml 第一个),仅本 run 装配 SeedreamTool 时使用,不入 DB;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 走 streaming,chunk 间 poll cancel — 延迟 100ms 级,基本秒退 | 必填 | | `POST /v1/tasks/{id}/cancel` | 协作式 cancel;`run_status != running` → 409;LLM 走 streaming,chunk 间 poll cancel — 延迟 100ms 级,基本秒退 | 必填 |
@ -286,6 +286,8 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `POST /v1/asr/transcribe` | body 为裸 PCM(16kHz/16bit/单声道/小端,`application/octet-stream`)→ 讯飞 IAT 整段转写,返 `{text}`;>60s → 413;`XFYUN_*` env 未配 → 501;讯飞侧错误 → 502(带错误码提示)。流式 WS 连不上时前端的兜底通道 | 必填 | | `POST /v1/asr/transcribe` | body 为裸 PCM(16kHz/16bit/单声道/小端,`application/octet-stream`)→ 讯飞 IAT 整段转写,返 `{text}`;>60s → 413;`XFYUN_*` env 未配 → 501;讯飞侧错误 → 502(带错误码提示)。流式 WS 连不上时前端的兜底通道 | 必填 |
| `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 | | `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 |
| `GET /v1/files/download?path=` | 下单文件 | 必填 | | `GET /v1/files/download?path=` | 下单文件 | 必填 |
| `GET /v1/tasks/{id}/files/download?path=` | 下载结构化产物;`path` 以该 task 当前 working_dir 为根,顶层目录改名后无需改历史消息;跨用户/越界/不存在均拒绝。`legacy=true` 仅供前端读取迁移前卡片,按旧 user-root→两种 task-relative 含义顺序兼容 | 必填 |
| `GET /v1/tasks/{id}/files/preview_pdf?path=` | PPT/PPTX 结构化产物按 task-relative 路径转 PDF 预览;状态码与旧 `/v1/files/preview_pdf` 一致;旧卡片可同样传 `legacy=true` | 必填 |
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 | | `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 |
| `POST /v1/files/delete` | `{path, recursive?=false}`;`recursive=false` 文件或空目录(非空 → 400);`recursive=true` `shutil.rmtree` —— 顶层目录被 task 引用 → 409(先 DELETE task);空目录两种模式都可删,task.working_dir 字段不动,下次 build_agent 按需 mkdir 重建 | 必填 | | `POST /v1/files/delete` | `{path, recursive?=false}`;`recursive=false` 文件或空目录(非空 → 400);`recursive=true` `shutil.rmtree` —— 顶层目录被 task 引用 → 409(先 DELETE task);空目录两种模式都可删,task.working_dir 字段不动,下次 build_agent 按需 mkdir 重建 | 必填 |
| `POST /v1/files/rename` | `{path, new_name}`;sibling 已存在 → 409;**path 顶层目录** → 同事务 UPDATE tasks.working_dir + FOR UPDATE 锁;有 running/cancelling → 409;check_no_subtask 防嵌套 → 409 | 必填 | | `POST /v1/files/rename` | `{path, new_name}`;sibling 已存在 → 409;**path 顶层目录** → 同事务 UPDATE tasks.working_dir + FOR UPDATE 锁;有 running/cancelling → 409;check_no_subtask 防嵌套 → 409 | 必填 |

View File

@ -9,9 +9,8 @@
# HTTP 400 "images endpoint requires an image model"。 # HTTP 400 "images endpoint requires an image model"。
# - gpt-image-2 支持 size / quality:尺寸可用 auto 或满足约束的 WIDTHxHEIGHT, # - gpt-image-2 支持 size / quality:尺寸可用 auto 或满足约束的 WIDTHxHEIGHT,
# quality 可用 auto / low / medium / high。输出固定 PNG,透明背景当前模型不支持。 # quality 可用 auto / low / medium / high。输出固定 PNG,透明背景当前模型不支持。
# - 2026-07-31 实测网关 /images/edits 暂不可用:官方 JSON 返回 # - 2026-08-03 实测 gpt-image-2 已可通过 /images/edits multipart 单图改图,
# convert_request_failed,官方 multipart(image[] / image)均返回 NextPart: EOF; # 响应同时含 b64_json 与 data URL;当前账号未开放 gpt-image-2-ad。
# Responses API 也静默丢弃强制 image_generation tool。网关修复前不暴露改图参数。
# - 复杂图片可能需 ~2min(慢于 seedream) # - 复杂图片可能需 ~2min(慢于 seedream)
# - 价格:网关未公布价目,price 暂 0(usage tokens 记进 units,拿到价目后回填对账) # - 价格:网关未公布价目,price 暂 0(usage tokens 记进 units,拿到价目后回填对账)
# - 服务器需代理出口(直连 unifyllm.ai TLS 失败),同文本模型 # - 服务器需代理出口(直连 unifyllm.ai TLS 失败),同文本模型
@ -24,6 +23,7 @@ image:
model_id: gpt-image-2 model_id: gpt-image-2
display_name: GPT 生图 display_name: GPT 生图
endpoint: /images/generations endpoint: /images/generations
edit_endpoint: /images/edits
default_size: auto default_size: auto
default_quality: auto default_quality: auto
price_cny_per_image: 0 # 网关价目未知,成本先记 0;拿到价目改这里 + 重启 price_cny_per_image: 0 # 网关价目未知,成本先记 0;拿到价目改这里 + 重启

View File

@ -9,21 +9,23 @@ variants:
api_key_env: DEEPSEEK_API_KEY api_key_env: DEEPSEEK_API_KEY
max_context: 1048576 max_context: 1048576
reliable_context: 262144 reliable_context: 262144
max_output: 8192 max_output: 8192 # 官方上限 384Kzcbot 先保留稳定输出预算,压测后再放大
parallel_tools: false parallel_tools: false
tool_calling_quality: good tool_calling_quality: good
thinking_mode: false thinking_enabled: true
reasoning_effort_levels: [] thinking_transport: extra_body
default_reasoning_effort: "" reasoning_effort_levels: [low, high, max]
default_reasoning_effort: high
code_quality: good code_quality: good
enable_run_python: true enable_run_python: true
max_iterations: 120 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard max_iterations: 120 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard
optimal_temperature: 0.3 optimal_temperature: 0.3
prompt_caching: false prompt_caching: false
extended_thinking: false extended_thinking: false
# Flash-0731 官方美元价按 1 USD ~= 7.2 CNY 折算;峰谷价正式生效后再同步。
input_cny_per_mtoken: 1.0 input_cny_per_mtoken: 1.0
output_cny_per_mtoken: 12.0 output_cny_per_mtoken: 2.0
cache_hit_cny_per_mtoken: 0.1 # DeepSeek 前缀缓存命中价(input 的 ~0.1x) cache_hit_cny_per_mtoken: 0.02 # 官方 $0.0028 / M tokens
pro: pro:
display_name: DeepSeek V4 Pro display_name: DeepSeek V4 Pro
@ -35,7 +37,8 @@ variants:
max_output: 8192 max_output: 8192
parallel_tools: true parallel_tools: true
tool_calling_quality: excellent tool_calling_quality: excellent
thinking_mode: true thinking_enabled: true
thinking_transport: extra_body
reasoning_effort_levels: [low, medium, high, max] reasoning_effort_levels: [low, medium, high, max]
default_reasoning_effort: medium default_reasoning_effort: medium
code_quality: excellent code_quality: excellent

View File

@ -3,10 +3,8 @@
# 与 config/models/local.yaml 同范式(避免 litellm volcengine provider 的版本/字段差异)。 # 与 config/models/local.yaml 同范式(避免 litellm volcengine provider 的版本/字段差异)。
# api_key 复用媒体侧的 ARK_API_KEY(同一火山账号),env 见 RUN.md。 # api_key 复用媒体侧的 ARK_API_KEY(同一火山账号),env 见 RUN.md。
# #
# thinking_mode 暂设 false:Seed 2.1 是深度思考模型,但开关走 Ark body `thinking:{type:enabled}`, # Seed 2.1 的 thinking 走统一 extra_body 协议;保持既有深度思考行为,但由服务端默认
# 与 OpenAI/DeepSeek 的 `reasoning_effort` 等级协议不同 —— 同 glm.yaml 的处理,要 core/llm.py # 改为显式 enabled。方舟未声明 effort 等级,故只传 thinking、不猜 reasoning_effort。
# 加 family 分支才能透传等级,留 TODO。设 false 只是不发 reasoning_effort 字段;模型默认仍会
# 深度思考并返回 reasoning_content,不影响调用。
# 单价见各 variant(元/百万 tokens,来源:火山方舟 2026-06 发布价)。 # 单价见各 variant(元/百万 tokens,来源:火山方舟 2026-06 发布价)。
family: doubao family: doubao
@ -21,7 +19,8 @@ variants:
max_output: 16384 # 模型上限 128K(含思考),这里保守取值,需要长输出可调高 max_output: 16384 # 模型上限 128K(含思考),这里保守取值,需要长输出可调高
parallel_tools: true # Ark 兼容 parallel_tool_calls,默认 true parallel_tools: true # Ark 兼容 parallel_tool_calls,默认 true
tool_calling_quality: good tool_calling_quality: good
thinking_mode: false thinking_enabled: true
thinking_transport: extra_body
reasoning_effort_levels: [] reasoning_effort_levels: []
default_reasoning_effort: "" default_reasoning_effort: ""
code_quality: good code_quality: good
@ -44,7 +43,8 @@ variants:
max_output: 16384 # 模型上限 128K(含思考),这里保守取值,需要长输出可调高 max_output: 16384 # 模型上限 128K(含思考),这里保守取值,需要长输出可调高
parallel_tools: true parallel_tools: true
tool_calling_quality: excellent tool_calling_quality: excellent
thinking_mode: false thinking_enabled: true
thinking_transport: extra_body
reasoning_effort_levels: [] reasoning_effort_levels: []
default_reasoning_effort: "" default_reasoning_effort: ""
code_quality: excellent code_quality: excellent
@ -69,7 +69,8 @@ variants:
max_output: 16384 max_output: 16384
parallel_tools: true parallel_tools: true
tool_calling_quality: excellent tool_calling_quality: excellent
thinking_mode: false thinking_enabled: true
thinking_transport: extra_body
reasoning_effort_levels: [] reasoning_effort_levels: []
default_reasoning_effort: "" default_reasoning_effort: ""
code_quality: excellent code_quality: excellent

View File

@ -1,9 +1,7 @@
# 智谱 GLM 模型档案 # 智谱 GLM 模型档案
# 走 litellm 原生 zai provider(1.83+ 内置)。litellm 默认 api_base 是国际站 api.z.ai; # 走 litellm 原生 zai provider(1.83+ 内置)。litellm 默认 api_base 是国际站 api.z.ai;
# 国内站 bigmodel.cn 通过 yaml 的 api_base 字段覆盖。两站 API key 不通用,env 也分开命名。 # 国内站 bigmodel.cn 通过 yaml 的 api_base 字段覆盖。两站 API key 不通用,env 也分开命名。
# thinking 已接(core/llm.py _build_kwargs 的 family=="glm" 分支):GLM 协议是 body # thinking 走统一 extra_body 协议,由本档 `thinking_enabled` 决定开关。当前均 false=禁用 ——
# `{"thinking":{"type":"enabled|disabled"}}`(与 OpenAI/DeepSeek 的 `reasoning_effort`
# 等级不同族),走 extra_body 透传,由本档 `thinking_mode` 决定开关。当前均 false=禁用 ——
# 因网关侧默认开 thinking,重任务上会把输出预算烧在 reasoning_content 上撞满 65536 上限 # 因网关侧默认开 thinking,重任务上会把输出预算烧在 reasoning_content 上撞满 65536 上限
# 被截断→空响应(task 35744bea 案);禁用后线上探针实测 reasoning_content 归零、正文照常。 # 被截断→空响应(task 35744bea 案);禁用后线上探针实测 reasoning_content 归零、正文照常。
family: glm family: glm
@ -19,7 +17,8 @@ variants:
max_output: 8192 max_output: 8192
parallel_tools: false parallel_tools: false
tool_calling_quality: good tool_calling_quality: good
thinking_mode: false thinking_enabled: false
thinking_transport: extra_body
reasoning_effort_levels: [] reasoning_effort_levels: []
default_reasoning_effort: "" default_reasoning_effort: ""
code_quality: good code_quality: good
@ -41,7 +40,8 @@ variants:
max_output: 8192 max_output: 8192
parallel_tools: false parallel_tools: false
tool_calling_quality: good tool_calling_quality: good
thinking_mode: false thinking_enabled: false
thinking_transport: extra_body
reasoning_effort_levels: [] reasoning_effort_levels: []
default_reasoning_effort: "" default_reasoning_effort: ""
code_quality: excellent code_quality: excellent

View File

@ -2,7 +2,7 @@
# 走 OpenAI 兼容协议(litellm provider 前缀 `openai/`,后段为实际 model 字段透传给 base_url)。 # 走 OpenAI 兼容协议(litellm provider 前缀 `openai/`,后段为实际 model 字段透传给 base_url)。
# 涉密任务时用户显式选 local.r1 / local.qwq 代替默认 deepseek_v4.flash;不走自动路由。 # 涉密任务时用户显式选 local.r1 / local.qwq 代替默认 deepseek_v4.flash;不走自动路由。
# 两个 variant 共用同一台推理服务器(api_base 同),api_key_env 也共用 LOCAL_LLM_API_KEY。 # 两个 variant 共用同一台推理服务器(api_base 同),api_key_env 也共用 LOCAL_LLM_API_KEY。
# thinking_mode=false:R1 / QwQ 是天生推理模型,默认就思考,不通过 reasoning_effort 等级控制 # thinking_enabled=false:R1 / QwQ 是天生推理模型,默认就思考,不通过 reasoning_effort 等级控制
# (那是 OpenAI / DeepSeek V4 风格);设 true 会发 reasoning_effort 字段,本地 vLLM / sglang # (那是 OpenAI / DeepSeek V4 风格);设 true 会发 reasoning_effort 字段,本地 vLLM / sglang
# 多半不认,报 400。 # 多半不认,报 400。
family: local family: local
@ -18,7 +18,8 @@ variants:
max_output: 8192 max_output: 8192
parallel_tools: false parallel_tools: false
tool_calling_quality: fair tool_calling_quality: fair
thinking_mode: false thinking_enabled: false
thinking_transport: none
reasoning_effort_levels: [] reasoning_effort_levels: []
default_reasoning_effort: "" default_reasoning_effort: ""
code_quality: good code_quality: good
@ -41,7 +42,8 @@ variants:
max_output: 8192 max_output: 8192
parallel_tools: false parallel_tools: false
tool_calling_quality: fair tool_calling_quality: fair
thinking_mode: false thinking_enabled: false
thinking_transport: none
reasoning_effort_levels: [] reasoning_effort_levels: []
default_reasoning_effort: "" default_reasoning_effort: ""
code_quality: good code_quality: good

View File

@ -23,7 +23,8 @@ variants:
max_output: 8192 max_output: 8192
parallel_tools: true parallel_tools: true
tool_calling_quality: excellent tool_calling_quality: excellent
thinking_mode: false thinking_enabled: false
thinking_transport: none
reasoning_effort_levels: [] reasoning_effort_levels: []
default_reasoning_effort: "" default_reasoning_effort: ""
code_quality: excellent code_quality: excellent
@ -43,7 +44,8 @@ variants:
max_output: 8192 max_output: 8192
parallel_tools: true parallel_tools: true
tool_calling_quality: excellent tool_calling_quality: excellent
thinking_mode: false thinking_enabled: false
thinking_transport: none
reasoning_effort_levels: [] reasoning_effort_levels: []
default_reasoning_effort: "" default_reasoning_effort: ""
code_quality: excellent code_quality: excellent
@ -63,7 +65,8 @@ variants:
max_output: 8192 max_output: 8192
parallel_tools: true parallel_tools: true
tool_calling_quality: excellent tool_calling_quality: excellent
thinking_mode: false thinking_enabled: false
thinking_transport: none
reasoning_effort_levels: [] reasoning_effort_levels: []
default_reasoning_effort: "" default_reasoning_effort: ""
code_quality: excellent code_quality: excellent
@ -83,7 +86,8 @@ variants:
max_output: 8192 max_output: 8192
parallel_tools: true parallel_tools: true
tool_calling_quality: excellent tool_calling_quality: excellent
thinking_mode: false thinking_enabled: false
thinking_transport: none
reasoning_effort_levels: [] reasoning_effort_levels: []
default_reasoning_effort: "" default_reasoning_effort: ""
code_quality: excellent code_quality: excellent
@ -103,7 +107,8 @@ variants:
max_output: 8192 max_output: 8192
parallel_tools: true parallel_tools: true
tool_calling_quality: excellent tool_calling_quality: excellent
thinking_mode: false thinking_enabled: false
thinking_transport: none
reasoning_effort_levels: [] reasoning_effort_levels: []
default_reasoning_effort: "" default_reasoning_effort: ""
code_quality: excellent code_quality: excellent
@ -123,7 +128,8 @@ variants:
max_output: 8192 max_output: 8192
parallel_tools: true parallel_tools: true
tool_calling_quality: good tool_calling_quality: good
thinking_mode: false thinking_enabled: false
thinking_transport: none
reasoning_effort_levels: [] reasoning_effort_levels: []
default_reasoning_effort: "" default_reasoning_effort: ""
code_quality: good code_quality: good
@ -143,7 +149,8 @@ variants:
max_output: 8192 max_output: 8192
parallel_tools: false # gemini 走网关未实测该参数,保守关闭 parallel_tools: false # gemini 走网关未实测该参数,保守关闭
tool_calling_quality: good tool_calling_quality: good
thinking_mode: false thinking_enabled: false
thinking_transport: none
reasoning_effort_levels: [] reasoning_effort_levels: []
default_reasoning_effort: "" default_reasoning_effort: ""
code_quality: excellent code_quality: excellent

View File

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

View File

@ -74,7 +74,8 @@ _MEDIA_SEEDREAM_SEG = """\
- 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调用户消息里出现 `[用户上传的参考图] <路径>` = 用户贴了图,要看图 / 改图时用那个路径""" - 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调用户消息里出现 `[用户上传的参考图] <路径>` = 用户贴了图,要看图 / 改图时用那个路径"""
_MEDIA_GPT_IMAGE_SEG = """\ _MEDIA_GPT_IMAGE_SEG = """\
- `gpt_image` GPT 图像生成( run 用户在顶栏选了GPT 生图,seedream 不可用;其他地方提到 seedream 的指引按 gpt_image 理解)产物自动落 `<task_dir>/figures/`,复杂图可能需 **~2min**(,调用前告知用户稍等) - `gpt_image` GPT 图像生成( run 用户在顶栏选了GPT 生图,seedream 不可用;其他地方提到 seedream 的指引按 gpt_image 理解)产物自动落 `<task_dir>/figures/`,复杂图可能需 **~2min**(,调用前告知用户稍等)
- **仅文生图**:支持 `size`(`auto` 或合法 `WIDTHxHEIGHT`) `quality`(`auto/low/medium/high`)当前网关改图端点不可用;用户要修改已有图片时明确说明,并建议在顶栏切回豆包 Seedream - **文生图**(不传 `reference_images`):从零按 prompt **改图 i2i**( `reference_images=["图片路径"]`):基于单张已有图片修改,原图保留结果另存用户要修改刚生成/上传的图时必须走改图,不要重新文生图
- **改图调用前必须明确提示用户**:将基于参考图文件名编辑,会消耗 1 次图片额度,预计 12 分钟,原图保留且结果另存;提示后再调工具支持 `size` `quality`(`auto/low/medium/high`),当前仅支持单张参考图
- **调用前必须先 `load_skill('imagegen')`** 其中何时该用 / mermaid 反向选型 / 模糊度诊断 / prompt 装配 / 先给用户过目再调的流程完全适用;参数以本工具 schema 为准 - **调用前必须先 `load_skill('imagegen')`** 其中何时该用 / mermaid 反向选型 / 模糊度诊断 / prompt 装配 / 先给用户过目再调的流程完全适用;参数以本工具 schema 为准
- 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调""" - 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调"""
_MEDIA_DIAGRAM_FORK_SEG = """\ _MEDIA_DIAGRAM_FORK_SEG = """\
@ -412,6 +413,13 @@ def _build_system_prompt(
"完成后执行,所以调用后按“已登记、将在回复后完成”表述。\n" "完成后执行,所以调用后按“已登记、将在回复后完成”表述。\n"
if allow_working_dir_rename else "" if allow_working_dir_rename else ""
) )
publish_line = (
"完成任务后,仅把用户真正需要打开、下载或继续使用的少量最终文件调用 "
"`publish_artifacts` 发布到聊天path 相对 task_dir不带工作目录名前缀。"
"源码树、中间文件、临时脚本和配套资源留在右侧文件区,不逐个发布;"
"office_to_pdf、图像和视频工具会自动发布其成品无需重复调用。\n"
if allow_working_dir_rename else ""
)
office_pdf_hint = ( office_pdf_hint = (
"已有 Office 文件需要转 PDF 时,仅当 host-side `office_to_pdf` 的工具说明列出该格式" "已有 Office 文件需要转 PDF 时,仅当 host-side `office_to_pdf` 的工具说明列出该格式"
"才调用LibreOffice 在 backend host不在 Docker shell 内探测 `soffice`。\n" "才调用LibreOffice 在 backend host不在 Docker shell 内探测 `soffice`。\n"
@ -430,6 +438,7 @@ def _build_system_prompt(
f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n" f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n"
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n" f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n"
f"{rename_line}" f"{rename_line}"
f"{publish_line}"
f"\n## 生成 Word / PDF 报告(验收 / 技术 / 评审报告等自由长文)\n" f"\n## 生成 Word / PDF 报告(验收 / 技术 / 评审报告等自由长文)\n"
f"**优先**把正文写成 Markdown(`<task_dir>/sections/*.md`,纯文本、零转义 / 零语法风险)," f"**优先**把正文写成 Markdown(`<task_dir>/sections/*.md`,纯文本、零转义 / 零语法风险),"
f"再调平台渲染器直接出 docx 或 pdf —— **别在 run_python 里手撸文档转换脚本**" f"再调平台渲染器直接出 docx 或 pdf —— **别在 run_python 里手撸文档转换脚本**"

View File

@ -15,7 +15,6 @@ import yaml
from core.paths import ROOT from core.paths import ROOT
_DOUBAO_YAML = ROOT / "config" / "media" / "doubao.yaml" _DOUBAO_YAML = ROOT / "config" / "media" / "doubao.yaml"
@ -75,7 +74,6 @@ class ArkClient:
base_url=cfg.base_url, base_url=cfg.base_url,
headers={ headers={
"Authorization": f"Bearer {cfg.api_key}", "Authorization": f"Bearer {cfg.api_key}",
"Content-Type": "application/json",
}, },
timeout=timeout_s, timeout=timeout_s,
) )
@ -89,6 +87,28 @@ class ArkClient:
raise ArkTimeoutError(f"network error calling POST {path}: {e}") from e raise ArkTimeoutError(f"network error calling POST {path}: {e}") from e
return self._parse(resp, f"POST {path}") return self._parse(resp, f"POST {path}")
def post_multipart(
self,
path: str,
data: dict[str, str],
files: dict[str, tuple[str, bytes, str]],
*,
timeout_s: float | None = None,
) -> dict:
"""POST multipart/form-data边界与 Content-Type 交给 httpx 生成。"""
try:
resp = self._client.post(
path,
data=data,
files=files,
timeout=timeout_s or self.timeout_s,
)
except httpx.TimeoutException as e:
raise ArkTimeoutError(f"timeout calling POST {path}: {e}") from e
except httpx.HTTPError as e:
raise ArkTimeoutError(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: def get_json(self, path: str, *, timeout_s: Optional[float] = None) -> dict:
try: try:
resp = self._client.get(path, timeout=timeout_s or self.timeout_s) resp = self._client.get(path, timeout=timeout_s or self.timeout_s)

150
core/artifacts.py Normal file
View File

@ -0,0 +1,150 @@
"""Task artifact references and working-dir scoped path resolution.
Files remain the source of truth. Artifact refs are small, rebuildable UI metadata:
they identify a user-facing deliverable relative to a task's mutable working_dir.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional
ARTIFACT_REF_VERSION = 1
MAX_ARTIFACTS_PER_MESSAGE = 10
_CONTAINER_ROOT = Path("/workspace")
class ArtifactPathError(ValueError):
"""A proposed artifact path is invalid or outside the current working_dir."""
@dataclass(frozen=True)
class ArtifactRef:
path: str
label: str = ""
scope: str = "working_dir"
version: int = ARTIFACT_REF_VERSION
def as_dict(self) -> dict:
out = {
"version": self.version,
"scope": self.scope,
"path": self.path,
}
if self.label:
out["label"] = self.label
return out
class ToolExecutionResult(str):
"""String-compatible rich result for tools that publish deliverables.
A few internal scripts and tests call tools directly and historically received a
plain string. Subclassing ``str`` preserves that contract while executors can
still consume the structured artifact metadata.
"""
content: str
artifacts: tuple[ArtifactRef, ...]
def __new__(
cls,
content: str,
artifacts: Iterable[ArtifactRef] = (),
) -> "ToolExecutionResult":
obj = super().__new__(cls, content)
obj.content = content
obj.artifacts = tuple(artifacts)
return obj
def _relative_parts(path: Path) -> tuple[str, ...]:
return tuple(part for part in path.parts if part not in ("", "."))
def resolve_artifact_path(
raw_path: str,
*,
working_dir: Path,
user_root: Path,
require_file: bool = True,
allow_legacy_user_relative: bool = True,
) -> tuple[Path, str]:
"""Resolve legacy/canonical input and return (absolute, task-relative POSIX path).
Canonical input is relative to working_dir (``reports/a.pdf``). For compatibility,
user-root-relative paths (``<wd>/reports/a.pdf``), container absolute paths under
``/workspace`` and host absolute paths inside working_dir are accepted too.
"""
raw = str(raw_path or "").strip().replace("\\", "/")
if not raw or "\x00" in raw:
raise ArtifactPathError("artifact path is empty or contains NUL")
wd = Path(working_dir).resolve()
root = Path(user_root).resolve()
try:
wd_rel = wd.relative_to(root)
except ValueError as exc:
raise ArtifactPathError("working_dir is outside user_root") from exc
explicit_task_relative = raw.startswith("./")
p = Path(raw[2:] if explicit_task_relative else raw)
if raw == "/workspace" or raw.startswith("/workspace/"):
rest = raw[len("/workspace"):].lstrip("/")
candidate = root / Path(rest)
elif p.is_absolute():
candidate = p
else:
parts = _relative_parts(p)
wd_parts = _relative_parts(wd_rel)
if (
allow_legacy_user_relative
and not explicit_task_relative
and wd_parts
and parts[:len(wd_parts)] == wd_parts
):
candidate = root.joinpath(*parts)
else:
candidate = wd.joinpath(*parts)
resolved = candidate.resolve()
try:
rel = resolved.relative_to(wd)
except ValueError as exc:
raise ArtifactPathError("artifact path escapes working_dir") from exc
if rel == Path("."):
raise ArtifactPathError("artifact path must reference a file")
if require_file and not resolved.is_file():
raise ArtifactPathError(f"artifact file not found: {rel.as_posix()}")
return resolved, rel.as_posix()
def normalize_artifact_refs(refs: Iterable[ArtifactRef]) -> list[dict]:
"""Deduplicate validated refs while preserving order and enforcing the UI limit."""
out: list[dict] = []
seen: set[tuple[str, str]] = set()
for ref in refs:
if ref.scope != "working_dir" or ref.version != ARTIFACT_REF_VERSION:
continue
key = (ref.scope, ref.path)
if key in seen:
continue
seen.add(key)
out.append(ref.as_dict())
if len(out) >= MAX_ARTIFACTS_PER_MESSAGE:
break
return out
def artifact_ref_for_file(
path: Path,
*,
working_dir: Path,
user_root: Path,
label: Optional[str] = None,
) -> ArtifactRef:
_, rel = resolve_artifact_path(
str(path), working_dir=working_dir, user_root=user_root, require_file=True,
)
return ArtifactRef(path=rel, label=(label or "").strip())

View File

@ -7,6 +7,8 @@ from typing import List
import yaml import yaml
from .llm_params import THINKING_TRANSPORTS
@dataclass @dataclass
class ModelCapabilities: class ModelCapabilities:
@ -24,8 +26,10 @@ class ModelCapabilities:
parallel_tools: bool = False parallel_tools: bool = False
tool_calling_quality: str = "good" tool_calling_quality: str = "good"
# 思考模式 # 思考开关
thinking_mode: bool = False thinking_enabled: bool = False
# none=不猜 provider 默认值extra_body=显式发送 thinking.typeeffort 同体透传。
thinking_transport: str = "none"
reasoning_effort_levels: List[str] = field(default_factory=list) reasoning_effort_levels: List[str] = field(default_factory=list)
default_reasoning_effort: str = "" default_reasoning_effort: str = ""
@ -71,9 +75,27 @@ class ModelCapabilities:
f"档案 {path} 没有 variant={variant};可选: {list(variants)}" f"档案 {path} 没有 variant={variant};可选: {list(variants)}"
) )
var = variants[variant] var = dict(variants[variant])
valid_keys = {f.name for f in fields(cls)} valid_keys = {f.name for f in fields(cls)}
kwargs = {k: v for k, v in var.items() if k in valid_keys} kwargs = {k: v for k, v in var.items() if k in valid_keys}
kwargs["family"] = data.get("family", family) kwargs["family"] = data.get("family", family)
kwargs["variant"] = variant kwargs["variant"] = variant
return cls(**kwargs) caps = cls(**kwargs)
if caps.thinking_transport not in THINKING_TRANSPORTS:
raise ValueError(
f"档案 {path} 的 thinking_transport={caps.thinking_transport!r} 无效;"
f"可选: {sorted(THINKING_TRANSPORTS)}"
)
if caps.thinking_enabled and caps.thinking_transport == "none":
raise ValueError(
f"档案 {path} 开启 thinking 时必须声明可验证的 thinking_transport"
)
if (
caps.default_reasoning_effort
and caps.default_reasoning_effort not in caps.reasoning_effort_levels
):
raise ValueError(
f"档案 {path} 的 default_reasoning_effort="
f"{caps.default_reasoning_effort!r} 不在 reasoning_effort_levels 中"
)
return caps

View File

@ -41,13 +41,16 @@ class ExecCtx:
class ToolResult: class ToolResult:
"""工具调用统一返回。 """工具调用统一返回。
现状所有 `Tool.execute` 都返 str,docker backend 后续可能要带 stdout/stderr/ 普通 `Tool.execute` str发布产物的工具可返字符串兼容的 rich result
exit_code 分离这里先留单 content 字段(LLM 拿到的就是这串),exit_code `content` LLM 拿到的文本`artifacts` 只进事件和消息元数据exit_code
backend 内部使用 hint(0=ok / 1=tool 抛异常 / 2=参数非法 / 124=timeout ), backend 内部使用 hint(0=ok / 1=tool 抛异常 / 2=参数非法 / 124=timeout ),
不影响 LLM 接口 不影响 LLM 接口
""" """
content: str content: str
exit_code: int = 0 exit_code: int = 0
# Optional structured, task-relative deliverables. Existing executors/tools that only
# return text remain fully compatible.
artifacts: tuple[dict, ...] = ()
class Executor(ABC): class Executor(ABC):

View File

@ -596,7 +596,11 @@ class DockerExecutor(Executor):
def _compact_shell_like_result(self, result: ToolResult) -> ToolResult: def _compact_shell_like_result(self, result: ToolResult) -> ToolResult:
content = compact_tool_output(result.content) content = compact_tool_output(result.content)
return ToolResult(content=content, exit_code=result.exit_code) return ToolResult(
content=content,
exit_code=result.exit_code,
artifacts=result.artifacts,
)
def _check_user_disk_quota(user_id: UUID): def _check_user_disk_quota(user_id: UUID):

View File

@ -15,6 +15,7 @@ from __future__ import annotations
from typing import Any, Dict, List from typing import Any, Dict, List
from .executor import ExecCtx, Executor, ToolResult from .executor import ExecCtx, Executor, ToolResult
from .artifacts import ToolExecutionResult, normalize_artifact_refs
from tools.base import Tool from tools.base import Tool
@ -52,6 +53,9 @@ class HostExecutor(Executor):
content=f"[Error executing {name}] {type(e).__name__}: {e}", content=f"[Error executing {name}] {type(e).__name__}: {e}",
exit_code=1, exit_code=1,
) )
if isinstance(result, ToolExecutionResult):
refs = tuple(normalize_artifact_refs(result.artifacts))
return ToolResult(content=result.content, exit_code=0, artifacts=refs)
if not isinstance(result, str): if not isinstance(result, str):
result = str(result) result = str(result)
return ToolResult(content=result, exit_code=0) return ToolResult(content=result, exit_code=0)

View File

@ -25,6 +25,7 @@ from litellm.exceptions import (
) )
from .capabilities import ModelCapabilities from .capabilities import ModelCapabilities
from .llm_params import build_thinking_kwargs
# 单次 LLM 请求超时(秒),默认与 litellm 一致(600s)但显式化 + env 可调 ── # 单次 LLM 请求超时(秒),默认与 litellm 一致(600s)但显式化 + env 可调 ──
# 长思考模型真被掐("600s 无字节 → run 标 error")时调大 ZCBOT_LLM_TIMEOUT_S 即可, # 长思考模型真被掐("600s 无字节 → run 标 error")时调大 ZCBOT_LLM_TIMEOUT_S 即可,
@ -64,18 +65,13 @@ class LLM:
kwargs["tools"] = tools kwargs["tools"] = tools
if self.caps.parallel_tools and parallel_tool_calls is not False: if self.caps.parallel_tools and parallel_tool_calls is not False:
kwargs["parallel_tool_calls"] = True kwargs["parallel_tool_calls"] = True
if self.caps.thinking_mode and reasoning_effort: kwargs.update(
kwargs["reasoning_effort"] = reasoning_effort build_thinking_kwargs(
# GLM(zai)的 thinking 网关侧默认开着 —— 重任务上把整个输出预算烧在 reasoning_content enabled=self.caps.thinking_enabled,
# 上、撞满模型自带输出上限(65536)被截断,回来 content 空 + 无 tool_call,被 loop 判空 transport=self.caps.thinking_transport,
# 响应整轮丢弃 + 无效重试(task 35744bea 案:5 次 empty 全 tokens_out=65536,重试同上下文 reasoning_effort=reasoning_effort,
# 再撞)。GLM 的 thinking 协议是 body {"thinking":{"type":...}}(与 OpenAI 的 )
# reasoning_effort 等级协议不同族),走 extra_body 透传 —— 线上探针实测 disabled 后 )
# reasoning_content 归零、正文/工具照常。config thinking_mode 决定开关(当前 glm 档均 false)。
if self.caps.family == "glm":
kwargs["extra_body"] = {
"thinking": {"type": "enabled" if self.caps.thinking_mode else "disabled"}
}
if self.caps.prompt_caching: if self.caps.prompt_caching:
kwargs["extra_headers"] = {"anthropic-beta": "prompt-caching-2024-07-31"} kwargs["extra_headers"] = {"anthropic-beta": "prompt-caching-2024-07-31"}
return kwargs return kwargs

30
core/llm_params.py Normal file
View File

@ -0,0 +1,30 @@
"""跨 provider 的可选 LLM 请求参数构造。"""
from __future__ import annotations
from typing import Any
THINKING_TRANSPORTS = {"none", "extra_body"}
def build_thinking_kwargs(
*, enabled: bool, transport: str, reasoning_effort: str | None
) -> dict[str, Any]:
"""把统一的 thinking 配置转换为 LiteLLM 调用参数。
``none`` 表示该模型没有经过验证的显式控制协议不猜 provider 默认值
``extra_body`` 对应当前 DeepSeekGLM 与方舟 ChatCompletions 的共同协议
effort 仅在开启且档案提供非空值时发送
"""
if transport == "none":
return {}
if transport != "extra_body":
raise ValueError(
f"不支持的 thinking_transport={transport!r};可选: {sorted(THINKING_TRANSPORTS)}"
)
body: dict[str, Any] = {
"thinking": {"type": "enabled" if enabled else "disabled"}
}
if enabled and reasoning_effort:
body["reasoning_effort"] = reasoning_effort
return {"extra_body": body}

View File

@ -31,6 +31,7 @@ from .context import (
) )
from .context_fold import maybe_fold from .context_fold import maybe_fold
from .executor import ExecCtx, Executor from .executor import ExecCtx, Executor
from .artifacts import MAX_ARTIFACTS_PER_MESSAGE
from .llm import LLM from .llm import LLM
from .llm_transport import ( from .llm_transport import (
extract_delta_content, extract_delta_content,
@ -249,6 +250,9 @@ class AgentLoop:
self._repeat_guard = _RepeatGuard() self._repeat_guard = _RepeatGuard()
# 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。 # 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。
self._stall = 0 self._stall = 0
# Structured deliverables accumulated across tool steps in the current user turn.
# They are persisted on the final assistant message, not mixed into provider payloads.
self._pending_artifact_refs: list[dict] = []
def _emit(self, event: dict) -> None: def _emit(self, event: dict) -> None:
if self.sink is not None: if self.sink is not None:
@ -279,6 +283,7 @@ class AgentLoop:
return self._run(None) return self._run(None)
def _run(self, user_message: Optional[str]) -> str: def _run(self, user_message: Optional[str]) -> str:
self._pending_artifact_refs = []
self._maybe_fold_context() self._maybe_fold_context()
if user_message is not None: if user_message is not None:
self.session.append({"role": "user", "content": user_message}) self.session.append({"role": "user", "content": user_message})
@ -299,7 +304,11 @@ class AgentLoop:
return "[cancelled]" return "[cancelled]"
msg = response.choices[0].message msg = response.choices[0].message
asst_msg_id = self.session.append(msg) tool_calls = getattr(msg, "tool_calls", None) or []
asst_msg_id = self.session.append(
msg,
artifact_refs=(list(self._pending_artifact_refs) if not tool_calls else None),
)
usage_details = extract_usage_details(getattr(response, "usage", None)) usage_details = extract_usage_details(getattr(response, "usage", None))
pt, ct = usage_details["tokens_in"], usage_details["tokens_out"] pt, ct = usage_details["tokens_in"], usage_details["tokens_out"]
@ -339,7 +348,6 @@ class AgentLoop:
"elapsed": elapsed, "elapsed": elapsed,
}) })
tool_calls = getattr(msg, "tool_calls", None) or []
# content 已通过 stream 流式 emit 过 delta,这里不再 emit 整段 text 事件。 # content 已通过 stream 流式 emit 过 delta,这里不再 emit 整段 text 事件。
if not tool_calls: if not tool_calls:
@ -363,7 +371,8 @@ class AgentLoop:
self._fill_cancelled_tool_results(tool_calls[i:]) self._fill_cancelled_tool_results(tool_calls[i:])
self._emit({"type": "cancelled"}) self._emit({"type": "cancelled"})
return "[cancelled]" return "[cancelled]"
result, productive = self._execute_tool_call(tc) result, productive, artifacts = self._execute_tool_call(tc)
self._remember_artifacts(artifacts)
step_productive = step_productive or productive step_productive = step_productive or productive
self.session.append( self.session.append(
{ {
@ -674,7 +683,7 @@ class AgentLoop:
pass pass
return response return response
def _execute_tool_call(self, tc: Any) -> Tuple[str, bool]: def _execute_tool_call(self, tc: Any) -> Tuple[str, bool, tuple[dict, ...]]:
"""执行一次 tool_call,返回 (结果文本, 本次是否有净产出)。 """执行一次 tool_call,返回 (结果文本, 本次是否有净产出)。
净产出供 run loop 的全局无进展熔断判定 净产出供 run loop 的全局无进展熔断判定
@ -686,7 +695,7 @@ class AgentLoop:
try: try:
args = json.loads(raw_args) args = json.loads(raw_args)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return f"[Error] invalid JSON arguments for {name}: {e}", False return f"[Error] invalid JSON arguments for {name}: {e}", False, ()
args_preview = json.dumps(args, ensure_ascii=False) args_preview = json.dumps(args, ensure_ascii=False)
if len(args_preview) > 200: if len(args_preview) > 200:
@ -700,7 +709,7 @@ class AgentLoop:
blocked = self._check_repeat_block(name, args) blocked = self._check_repeat_block(name, args)
if blocked is not None: if blocked is not None:
return blocked, False return blocked, False, ()
ctx = ExecCtx( ctx = ExecCtx(
user_id=self.user_id, user_id=self.user_id,
@ -709,7 +718,8 @@ class AgentLoop:
cancel_check=self.cancel_check, cancel_check=self.cancel_check,
) )
tool_started_at = time.time() tool_started_at = time.time()
result = self.executor.call_tool(name, args, ctx).content tool_result = self.executor.call_tool(name, args, ctx)
result = tool_result.content
# 控制返回给模型的 tool 结果体量,避免炸 context # 控制返回给模型的 tool 结果体量,避免炸 context
MAX_LEN = 16_000 MAX_LEN = 16_000
@ -729,8 +739,24 @@ class AgentLoop:
"result": result, "result": result,
"preview": preview, "preview": preview,
"truncated": truncated, "truncated": truncated,
"artifacts": list(getattr(tool_result, "artifacts", ()) or ()),
}) })
return result, productive return result, productive, tuple(getattr(tool_result, "artifacts", ()) or ())
def _remember_artifacts(self, refs: tuple[dict, ...]) -> None:
"""Accumulate a bounded, ordered set for the final assistant message."""
seen = {
(str(ref.get("scope") or ""), str(ref.get("path") or ""))
for ref in self._pending_artifact_refs
}
for ref in refs:
key = (str(ref.get("scope") or ""), str(ref.get("path") or ""))
if not key[1] or key in seen:
continue
self._pending_artifact_refs.append(dict(ref))
seen.add(key)
if len(self._pending_artifact_refs) >= MAX_ARTIFACTS_PER_MESSAGE:
break
def _check_repeat_block(self, name: str, args: Any) -> Optional[str]: def _check_repeat_block(self, name: str, args: Any) -> Optional[str]:
"""执行前的两道拦截(命中返回拦截话术,未命中返 None): """执行前的两道拦截(命中返回拦截话术,未命中返 None):

View File

@ -6,7 +6,7 @@
四项探测: 四项探测:
- basic_chat:连通性失败则跳过其余 - basic_chat:连通性失败则跳过其余
- parallel_tools:给两个独立工具, single response tool_calls 数量 - parallel_tools:给两个独立工具, single response tool_calls 数量
- thinking_mode: declared=True 的模型传 reasoning_effort, API 是否接受 + 是否产出 thinking - thinking: enabled=True 的模型传统一参数, API 是否接受 + 是否产出 thinking
- long_context(opt-in):needle-in-haystack 简化版,默认探 reliable_context 1/8 - long_context(opt-in):needle-in-haystack 简化版,默认探 reliable_context 1/8
""" """
from __future__ import annotations from __future__ import annotations
@ -133,11 +133,11 @@ def probe_parallel_tools(llm: LLM, caps: ModelCapabilities) -> ProbeResult:
) )
def probe_thinking_mode(llm: LLM, caps: ModelCapabilities) -> ProbeResult: def probe_thinking(llm: LLM, caps: ModelCapabilities) -> ProbeResult:
declared = caps.thinking_mode declared = caps.thinking_enabled
if not declared: if not declared:
return ProbeResult( return ProbeResult(
name="thinking_mode", name="thinking",
declared=False, declared=False,
observed=None, observed=None,
status="skip", status="skip",
@ -145,7 +145,7 @@ def probe_thinking_mode(llm: LLM, caps: ModelCapabilities) -> ProbeResult:
) )
effort = ( effort = (
caps.default_reasoning_effort caps.default_reasoning_effort
or (caps.reasoning_effort_levels[0] if caps.reasoning_effort_levels else "medium") or (caps.reasoning_effort_levels[0] if caps.reasoning_effort_levels else None)
) )
try: try:
resp = llm.chat( resp = llm.chat(
@ -162,22 +162,22 @@ def probe_thinking_mode(llm: LLM, caps: ModelCapabilities) -> ProbeResult:
) )
observed = bool(rc) observed = bool(rc)
return ProbeResult( return ProbeResult(
name="thinking_mode", name="thinking",
declared=True, declared=True,
observed=observed, observed=observed,
status="ok" if observed else "mismatch", status="ok" if observed else "mismatch",
detail=( detail=(
f"reasoning_effort={effort} accepted; " (f"reasoning_effort={effort} accepted; " if effort else "thinking enabled; ")
+ ("thinking content returned" if observed else "no thinking content in response") + ("thinking content returned" if observed else "no thinking content in response")
), ),
) )
except Exception as e: except Exception as e:
return ProbeResult( return ProbeResult(
name="thinking_mode", name="thinking",
declared=True, declared=True,
observed=False, observed=False,
status="mismatch", status="mismatch",
detail=f"reasoning_effort rejected: {type(e).__name__}: {e}", detail=f"thinking parameters rejected: {type(e).__name__}: {e}",
) )
@ -237,7 +237,7 @@ def probe_capabilities(
if report.results[0].status == "error": if report.results[0].status == "error":
return report return report
report.add(probe_parallel_tools(llm, caps)) report.add(probe_parallel_tools(llm, caps))
report.add(probe_thinking_mode(llm, caps)) report.add(probe_thinking(llm, caps))
if include_long_context: if include_long_context:
report.add(probe_long_context(llm, caps)) report.add(probe_long_context(llm, caps))
return report return report

View File

@ -61,7 +61,12 @@ class Session:
self.messages.append({"role": "system", "content": system_prompt}) self.messages.append({"role": "system", "content": system_prompt})
self._n_head = 1 self._n_head = 1
def append(self, msg: Any) -> Optional[UUID]: def append(
self,
msg: Any,
*,
artifact_refs: Optional[list[dict]] = None,
) -> Optional[UUID]:
"""追加消息;非 system 落 DB,system 仅内存。返回新落库行的 message_id。 """追加消息;非 system 落 DB,system 仅内存。返回新落库行的 message_id。
前置条件:tasks 行已由 web 入口(`POST /v1/tasks` `ensure_local_task_row`)写入; 前置条件:tasks 行已由 web 入口(`POST /v1/tasks` `ensure_local_task_row`)写入;
@ -96,6 +101,7 @@ class Session:
task_id=self.task_id, task_id=self.task_id,
idx=self._db_idx, idx=self._db_idx,
payload=msg_dict, payload=msg_dict,
artifact_refs=artifact_refs,
) )
s.add(row) s.add(row)
s.flush() # 触发 INSERT 拿到 server-default 生成的 message_id s.flush() # 触发 INSERT 拿到 server-default 生成的 message_id

View File

@ -170,6 +170,10 @@ class Message(Base):
) )
idx: Mapped[int] = mapped_column(Integer, nullable=False) idx: Mapped[int] = mapped_column(Integer, nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
# Optional structured user-facing deliverables. NULL means legacy message (frontend may
# fall back to path extraction); [] means a new message explicitly published no artifacts.
# Kept outside payload so provider-bound conversation messages remain protocol-clean.
artifact_refs: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(JSONB, nullable=True)
tokens_in: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) tokens_in: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
tokens_out: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) tokens_out: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# 0006:产生该 message 的模型(只在 assistant 行有值;user/tool/system 为 NULL)。 # 0006:产生该 message 的模型(只在 assistant 行有值;user/tool/system 为 NULL)。

View File

@ -121,12 +121,17 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
] ]
def _task_actions() -> list: def _task_actions() -> list:
from tools.publish_artifacts import PublishArtifactsTool
return [ return [
RenameWorkingDirTool( RenameWorkingDirTool(
ctx.deferred_actions, ctx.deferred_actions,
working_dir=ctx.working_dir_path, working_dir=ctx.working_dir_path,
**base, **base,
) ),
PublishArtifactsTool(
working_dir=ctx.working_dir_path,
**wd_base,
),
] ]
def _document_search() -> list: def _document_search() -> list:

View File

@ -0,0 +1,28 @@
"""Add structured task-relative artifact references to messages.
Revision ID: 0025
Revises: 0024
Create Date: 2026-08-03
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0025"
down_revision: Union[str, None] = "0024"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"messages",
sa.Column("artifact_refs", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
)
def downgrade() -> None:
op.drop_column("messages", "artifact_refs")

View File

@ -28,8 +28,8 @@ def main() -> int:
dry = "--dry" in sys.argv dry = "--dry" in sys.argv
fails = 0 fails = 0
from core.ark_client import ArkConfig
from core.agent_builder import _choose_image_variant, _media_tools_block from core.agent_builder import _choose_image_variant, _media_tools_block
from core.ark_client import ArkConfig
gw = ArkConfig.load(ROOT / "config" / "media" / "unifyllm.yaml") gw = ArkConfig.load(ROOT / "config" / "media" / "unifyllm.yaml")
ark = ArkConfig.load() ark = ArkConfig.load()
@ -44,6 +44,12 @@ def main() -> int:
f"{image_cfg.get('model_id') or 'MISSING'}" f"{image_cfg.get('model_id') or 'MISSING'}"
) )
fails += 0 if model_ok else 1 fails += 0 if model_ok else 1
edit_endpoint_ok = image_cfg.get("edit_endpoint") == "/images/edits"
print(
f"[{'OK' if edit_endpoint_ok else 'FAIL'}] gpt_image edit_endpoint="
f"{image_cfg.get('edit_endpoint') or 'MISSING'}"
)
fails += 0 if edit_endpoint_ok else 1
# variant 选择:显式 gpt_image / 显式 seedream_5 / 空 fallback # variant 选择:显式 gpt_image / 显式 seedream_5 / 空 fallback
cases = [ cases = [
@ -66,8 +72,13 @@ def main() -> int:
and "`seedream`" not in blk.split("\n")[0] and "`seedream`" not in blk.split("\n")[0]
and "size" in gpt_seg and "size" in gpt_seg
and "quality" in gpt_seg and "quality" in gpt_seg
and "reference_images" in gpt_seg
and "1 次图片额度" in gpt_seg
)
print(
f"[{'OK' if ok else 'FAIL'}] media block(gpt_image) includes "
"size/quality/edit guidance"
) )
print(f"[{'OK' if ok else 'FAIL'}] media block(gpt_image) includes size/quality guidance")
fails += 0 if ok else 1 fails += 0 if ok else 1
blk2 = _media_tools_block(ark is not None, "seedream") blk2 = _media_tools_block(ark is not None, "seedream")
ok = ("- `seedream`" in blk2) and ("- `gpt_image`" not in blk2) ok = ("- `seedream`" in blk2) and ("- `gpt_image`" not in blk2)

View File

@ -56,7 +56,12 @@ test("assistant HTML artifacts render inline with lazy loading and an expand act
assert.match(mediaJs, /new IntersectionObserver/); assert.match(mediaJs, /new IntersectionObserver/);
assert.match(mediaJs, /configureHtmlPreviewFrame\(frame, source/); assert.match(mediaJs, /configureHtmlPreviewFrame\(frame, source/);
assert.match(pageHtml, /\.art-html-frame/); assert.match(pageHtml, /\.art-html-frame/);
assert.match(chatJs, /renderArtifactBarHtml\(extractArtifactRels\(p\.content, wd\), "html"\)/); assert.match(chatJs, /renderArtifactBarHtml\(extractArtifactRels\(p\.content, wd\), "html", state\.taskId/);
assert.match(chatJs, /Array\.isArray\(m\.artifact_refs\)/);
assert.match(chatJs, /renderArtifactBarHtml\(m\.artifact_refs, true, state\.taskId/);
assert.match(previewJs, /\/v1\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/files\/download/);
assert.match(previewJs, /downloadFile\(_fpCurrentRel, _fpCurrentTaskId, _fpCurrentLegacy\)/);
assert.match(chatJs, /dataset\.legacyPath === "1"/);
const clickHandler = chatJs.indexOf('$("chat-stream").addEventListener("click"'); const clickHandler = chatJs.indexOf('$("chat-stream").addEventListener("click"');
const expandHandler = chatJs.indexOf('e.target.closest(".art-html-open[data-rel]")'); const expandHandler = chatJs.indexOf('e.target.closest(".art-html-open[data-rel]")');
const sendMessage = chatJs.indexOf("async function sendMessage"); const sendMessage = chatJs.indexOf("async function sendMessage");

114
tests/test_artifacts.py Normal file
View File

@ -0,0 +1,114 @@
import tempfile
import unittest
from pathlib import Path
from core.artifacts import ArtifactPathError, ToolExecutionResult, resolve_artifact_path
from core.executor import ExecCtx
from core.executor_host import HostExecutor
from tools.publish_artifacts import PublishArtifactsTool
from web.routers.files import _task_file_target
class ArtifactPathTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.wd = self.root / "技术讨论"
self.wd.mkdir()
(self.wd / "report.pdf").write_bytes(b"pdf")
def tearDown(self) -> None:
self.tmp.cleanup()
def test_canonical_and_legacy_paths_resolve_to_same_file(self) -> None:
expected = self.wd / "report.pdf"
for raw in (
"report.pdf",
"技术讨论/report.pdf",
str(expected),
"/workspace/技术讨论/report.pdf",
):
with self.subTest(raw=raw):
actual, rel = resolve_artifact_path(
raw, working_dir=self.wd, user_root=self.root,
)
self.assertEqual(actual, expected.resolve())
self.assertEqual(rel, "report.pdf")
def test_explicit_dot_slash_disambiguates_same_named_subdirectory(self) -> None:
nested = self.wd / "技术讨论" / "nested.html"
nested.parent.mkdir()
nested.write_text("ok", encoding="utf-8")
actual, rel = resolve_artifact_path(
"./技术讨论/nested.html", working_dir=self.wd, user_root=self.root,
)
self.assertEqual(actual, nested.resolve())
self.assertEqual(rel, "技术讨论/nested.html")
def test_escape_and_directory_are_rejected(self) -> None:
for raw in ("../outside.txt", "."):
with self.subTest(raw=raw), self.assertRaises(ArtifactPathError):
resolve_artifact_path(
raw, working_dir=self.wd, user_root=self.root,
)
def test_publish_artifacts_is_explicit_bounded_and_deduplicated(self) -> None:
tool = PublishArtifactsTool(
working_dir=self.wd,
base_dir=self.wd,
user_root=self.root,
)
result = tool.execute({"not": "a list"})
self.assertIsInstance(result, str)
published = tool.execute([
{"path": "report.pdf", "label": "最终报告"},
{"path": "./report.pdf"},
])
self.assertIsInstance(published, ToolExecutionResult)
self.assertEqual(len(published.artifacts), 1)
self.assertEqual(published.artifacts[0].path, "report.pdf")
self.assertEqual(published.artifacts[0].label, "最终报告")
executed = HostExecutor({tool.name: tool}).call_tool(
tool.name,
{"artifacts": [{"path": "report.pdf"}]},
ExecCtx(user_id="u", task_id="t", working_dir=self.wd),
)
self.assertEqual(executed.content, "[OK] published 1 artifact(s): report.pdf")
self.assertEqual(executed.artifacts[0]["path"], "report.pdf")
def test_publish_path_is_strictly_task_relative_when_names_repeat(self) -> None:
nested = self.wd / "技术讨论" / "nested.html"
nested.parent.mkdir()
nested.write_text("ok", encoding="utf-8")
tool = PublishArtifactsTool(
working_dir=self.wd,
base_dir=self.wd,
user_root=self.root,
)
published = tool.execute([{"path": "技术讨论/nested.html"}])
self.assertIsInstance(published, ToolExecutionResult)
self.assertEqual(published.artifacts[0].path, "技术讨论/nested.html")
def test_legacy_card_resolution_covers_both_known_shapes_and_rename(self) -> None:
direct = self.wd / "manual.pdf"
direct.write_bytes(b"direct")
nested = self.wd / "技术讨论" / "nested.html"
nested.parent.mkdir()
nested.write_text("nested", encoding="utf-8")
# 92ac20cf shape: old user-root path already points at the correct file.
self.assertEqual(
_task_file_target(self.root, self.wd, "技术讨论/manual.pdf", True),
direct.resolve(),
)
# 9b4502aa shape: the same text was actually task-relative into a repeated dir.
self.assertEqual(
_task_file_target(self.root, self.wd, "技术讨论/nested.html", True),
nested.resolve(),
)
# After a top-level rename, dropping the obsolete first component finds the file.
self.assertEqual(
_task_file_target(self.root, self.wd, "旧目录/manual.pdf", True),
direct.resolve(),
)

View File

@ -1,4 +1,5 @@
import base64 import base64
import json
import struct import struct
import tempfile import tempfile
import unittest import unittest
@ -6,7 +7,9 @@ import uuid
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from core.ark_client import ArkConfig import httpx
from core.ark_client import ArkClient, ArkConfig
from tools.gpt_image import GptImageTool from tools.gpt_image import GptImageTool
@ -16,6 +19,7 @@ def _png_stub(width: int = 1536, height: int = 864) -> bytes:
class _FakeArkClient: class _FakeArkClient:
json_call = None json_call = None
multipart_call = None
def __init__(self, *_args, **_kwargs): def __init__(self, *_args, **_kwargs):
pass pass
@ -33,10 +37,51 @@ class _FakeArkClient:
"usage": {"output_tokens": 123}, "usage": {"output_tokens": 123},
} }
def post_multipart(self, endpoint, data, files, *, timeout_s=None):
type(self).multipart_call = (endpoint, data, files, timeout_s)
return {
"data": [{"b64_json": base64.b64encode(_png_stub()).decode("ascii")}],
}
class ArkClientMultipartTests(unittest.TestCase):
def test_json_and_multipart_set_their_own_content_types(self):
seen = []
def handler(request):
seen.append(request)
return httpx.Response(200, json={"ok": True})
client = ArkClient(
ArkConfig(api_key="test", base_url="https://example.test/v1", raw={})
)
client._client.close()
client._client = httpx.Client(
base_url="https://example.test/v1",
headers={"Authorization": "Bearer test"},
transport=httpx.MockTransport(handler),
)
try:
client.post_json("/images/generations", {"prompt": "draw"})
client.post_multipart(
"/images/edits",
{"prompt": "edit"},
{"image": ("reference.png", b"png-bytes", "image/png")},
)
finally:
client.close()
self.assertEqual(seen[0].headers["content-type"], "application/json")
self.assertTrue(seen[1].headers["content-type"].startswith("multipart/form-data;"))
self.assertIn(b'name="prompt"', seen[1].content)
self.assertIn(b'filename="reference.png"', seen[1].content)
self.assertIn(b"png-bytes", seen[1].content)
class GptImageToolTests(unittest.TestCase): class GptImageToolTests(unittest.TestCase):
def setUp(self): def setUp(self):
_FakeArkClient.json_call = None _FakeArkClient.json_call = None
_FakeArkClient.multipart_call = None
self.tmp = tempfile.TemporaryDirectory() self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name) self.root = Path(self.tmp.name)
self.working_dir = self.root / "task" self.working_dir = self.root / "task"
@ -44,6 +89,7 @@ class GptImageToolTests(unittest.TestCase):
self.cfg = { self.cfg = {
"model_id": "gpt-image-2", "model_id": "gpt-image-2",
"endpoint": "/images/generations", "endpoint": "/images/generations",
"edit_endpoint": "/images/edits",
"default_size": "auto", "default_size": "auto",
"default_quality": "auto", "default_quality": "auto",
"request_timeout_s": 300, "request_timeout_s": 300,
@ -86,6 +132,69 @@ class GptImageToolTests(unittest.TestCase):
self.assertEqual(body["quality"], "high") self.assertEqual(body["quality"], "high")
self.assertIn("size=1536x864", result) self.assertIn("size=1536x864", result)
self.assertIn("quality=high", result) self.assertIn("quality=high", result)
self.assertIsNone(_FakeArkClient.multipart_call)
def test_image_edit_uses_multipart_and_records_derivation(self):
reference = self.working_dir / "reference.png"
reference.write_bytes(_png_stub(180, 252))
result = self._execute(
prompt="add a blue border",
reference_images=["reference.png"],
size="1024x1024",
quality="low",
)
self.assertIsNone(_FakeArkClient.json_call)
endpoint, form, files, timeout = _FakeArkClient.multipart_call
self.assertEqual(endpoint, "/images/edits")
self.assertEqual(timeout, 300)
self.assertEqual(form["model"], "gpt-image-2")
self.assertEqual(form["prompt"], "add a blue border")
self.assertEqual(form["n"], "1")
self.assertEqual(form["size"], "1024x1024")
self.assertEqual(form["quality"], "low")
self.assertEqual(form["response_format"], "b64_json")
filename, raw, mime = files["image"]
self.assertEqual(filename, "reference.png")
self.assertEqual(raw, reference.read_bytes())
self.assertEqual(mime, "image/png")
self.assertIn("mode=i2i", result)
self.assertIn("reference=", result)
meta_path = next((self.working_dir / "figures").glob("*.meta.json"))
meta = json.loads(meta_path.read_text(encoding="utf-8"))
self.assertEqual(meta["mode"], "i2i")
self.assertEqual(meta["reference_images"], ["task/reference.png"])
def test_image_edit_rejects_multiple_or_missing_references(self):
result = self._execute(
prompt="edit",
reference_images=["one.png", "two.png"],
)
self.assertIn("仅支持单张", result)
self.assertIsNone(_FakeArkClient.multipart_call)
result = self._execute(prompt="edit", reference_images=["missing.png"])
self.assertIn("图片找不到或越界", result)
self.assertIsNone(_FakeArkClient.multipart_call)
def test_image_edit_accepts_data_url_response_fallback(self):
reference = self.working_dir / "reference.png"
reference.write_bytes(_png_stub(180, 252))
encoded = base64.b64encode(_png_stub()).decode("ascii")
with patch.object(
_FakeArkClient,
"post_multipart",
return_value={"data": [{"url": f"data:image/png;base64,{encoded}"}]},
):
result = self._execute(
prompt="edit",
reference_images=["reference.png"],
)
self.assertTrue(result.startswith("[gpt_image]"))
def test_size_validation(self): def test_size_validation(self):
self.assertEqual(self.tool._normalize_size("auto"), ("auto", "")) self.assertEqual(self.tool._normalize_size("auto"), ("auto", ""))

115
tests/test_llm_kwargs.py Normal file
View File

@ -0,0 +1,115 @@
import os
import unittest
from pathlib import Path
from unittest.mock import patch
from core.capabilities import ModelCapabilities
from core.llm import LLM
class LLMKwargsTests(unittest.TestCase):
def _llm(
self, *, family: str, thinking_enabled: bool, thinking_transport: str
) -> LLM:
caps = ModelCapabilities(
family=family,
model_id=f"{family}/model",
api_key_env="TEST_LLM_API_KEY",
thinking_enabled=thinking_enabled,
thinking_transport=thinking_transport,
optimal_temperature=0.3,
)
with patch.dict(os.environ, {"TEST_LLM_API_KEY": "test-key"}):
return LLM(caps)
def test_deepseek_explicitly_enables_thinking_and_sets_effort(self) -> None:
llm = self._llm(
family="deepseek_v4", thinking_enabled=True, thinking_transport="extra_body"
)
kwargs = llm._build_kwargs(
[{"role": "user", "content": "hello"}], None, None, "high"
)
self.assertNotIn("reasoning_effort", kwargs)
self.assertEqual(
kwargs["extra_body"],
{"thinking": {"type": "enabled"}, "reasoning_effort": "high"},
)
def test_deepseek_explicitly_disables_thinking_without_effort(self) -> None:
llm = self._llm(
family="deepseek_v4", thinking_enabled=False, thinking_transport="extra_body"
)
kwargs = llm._build_kwargs(
[{"role": "user", "content": "hello"}], None, None, "high"
)
self.assertNotIn("reasoning_effort", kwargs)
self.assertEqual(
kwargs["extra_body"], {"thinking": {"type": "disabled"}}
)
def test_other_openai_compatible_provider_gets_no_thinking_body(self) -> None:
llm = self._llm(
family="unifyllm", thinking_enabled=False, thinking_transport="none"
)
kwargs = llm._build_kwargs(
[{"role": "user", "content": "hello"}], None, None, None
)
self.assertNotIn("extra_body", kwargs)
def test_extra_body_transport_enables_thinking_without_effort(self) -> None:
llm = self._llm(
family="doubao", thinking_enabled=True, thinking_transport="extra_body"
)
kwargs = llm._build_kwargs(
[{"role": "user", "content": "hello"}], None, None, None
)
self.assertEqual(
kwargs["extra_body"], {"thinking": {"type": "enabled"}}
)
def test_invalid_transport_is_rejected_before_request(self) -> None:
llm = self._llm(
family="test", thinking_enabled=True, thinking_transport="unknown"
)
with self.assertRaisesRegex(ValueError, "thinking_transport"):
llm._build_kwargs(
[{"role": "user", "content": "hello"}], None, None, "high"
)
def test_flash_profile_matches_0731_capabilities(self) -> None:
caps = ModelCapabilities.load(
"deepseek_v4.flash", Path(__file__).resolve().parents[1] / "config" / "models"
)
self.assertTrue(caps.thinking_enabled)
self.assertEqual(caps.reasoning_effort_levels, ["low", "high", "max"])
self.assertEqual(caps.default_reasoning_effort, "high")
self.assertEqual(caps.max_output, 8192)
self.assertEqual(caps.output_cny_per_mtoken, 2.0)
self.assertEqual(caps.cache_hit_cny_per_mtoken, 0.02)
self.assertEqual(caps.thinking_transport, "extra_body")
def test_other_controllable_profiles_declare_transport(self) -> None:
models_dir = Path(__file__).resolve().parents[1] / "config" / "models"
glm = ModelCapabilities.load("glm.pro52", models_dir)
doubao = ModelCapabilities.load("doubao.turbo", models_dir)
self.assertFalse(glm.thinking_enabled)
self.assertEqual(glm.thinking_transport, "extra_body")
self.assertTrue(doubao.thinking_enabled)
self.assertEqual(doubao.thinking_transport, "extra_body")
self.assertEqual(doubao.default_reasoning_effort, "")
if __name__ == "__main__":
unittest.main()

View File

@ -5,6 +5,7 @@ import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
from unittest.mock import patch
from uuid import uuid4 from uuid import uuid4
from core.loop import AgentLoop from core.loop import AgentLoop
@ -14,10 +15,12 @@ class _Session:
def __init__(self, messages=None): def __init__(self, messages=None):
self.messages = list(messages or []) self.messages = list(messages or [])
self.appended = [] self.appended = []
self.append_artifacts = []
def append(self, message): def append(self, message, *, artifact_refs=None):
self.messages.append(message) self.messages.append(message)
self.appended.append(message) self.appended.append(message)
self.append_artifacts.append(artifact_refs)
return uuid4() return uuid4()
@ -60,6 +63,44 @@ class PersistedTurnTests(unittest.TestCase):
[{"role": "user", "content": "新消息"}], [{"role": "user", "content": "新消息"}],
) )
def test_final_assistant_persists_explicit_artifact_list(self) -> None:
session = _Session([{"role": "user", "content": "生成报告"}])
loop = AgentLoop(
llm=MagicMock(),
executor=MagicMock(),
session=session,
capabilities=SimpleNamespace(
max_iterations=1, family="test", variant="model",
input_cny_per_mtoken=0, output_cny_per_mtoken=0,
cache_hit_cny_per_mtoken=0,
),
user_id=uuid4(),
working_dir=Path("."),
)
loop._maybe_fold_context = MagicMock()
loop._pending_artifact_refs = [{
"version": 1, "scope": "working_dir", "path": "report.pdf",
}]
# _run resets turn state; emulate a published tool by restoring the pending ref when
# the final model response is received.
response = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(
content="已完成", tool_calls=None,
))],
usage=None,
)
loop._stream_llm = MagicMock(return_value=(response, False))
original_fold = loop._maybe_fold_context
original_fold.side_effect = lambda: loop._pending_artifact_refs.append({
"version": 1, "scope": "working_dir", "path": "report.pdf",
})
with patch("core.loop.record_chat_usage"):
result = loop.run_persisted_turn()
self.assertEqual(result, "已完成")
self.assertEqual(session.append_artifacts[-1], [{
"version": 1, "scope": "working_dir", "path": "report.pdf",
}])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -111,7 +111,7 @@ class TestLoopHotSwap(unittest.TestCase):
return "glm.pro52", new_caps, new_llm return "glm.pro52", new_caps, new_llm
loop = _make_loop("[skill=ppt, dir=x]\n# PPT", switcher) loop = _make_loop("[skill=ppt, dir=x]\n# PPT", switcher)
result, _ = loop._execute_tool_call(_load_skill_tc()) result, _, _artifacts = loop._execute_tool_call(_load_skill_tc())
self.assertEqual(calls, [("ppt", "deepseek_v4.flash")]) self.assertEqual(calls, [("ppt", "deepseek_v4.flash")])
self.assertIs(loop.caps, new_caps) self.assertIs(loop.caps, new_caps)
@ -127,7 +127,7 @@ class TestLoopHotSwap(unittest.TestCase):
def test_no_switch_when_switcher_returns_none(self): def test_no_switch_when_switcher_returns_none(self):
loop = _make_loop("[skill=ppt, dir=x]\n# PPT", lambda n, c: None) loop = _make_loop("[skill=ppt, dir=x]\n# PPT", lambda n, c: None)
old_caps, old_llm = loop.caps, loop.llm old_caps, old_llm = loop.caps, loop.llm
result, _ = loop._execute_tool_call(_load_skill_tc()) result, _, _artifacts = loop._execute_tool_call(_load_skill_tc())
self.assertIs(loop.caps, old_caps) self.assertIs(loop.caps, old_caps)
self.assertIs(loop.llm, old_llm) self.assertIs(loop.llm, old_llm)
self.assertNotIn("[模型切换]", result) self.assertNotIn("[模型切换]", result)
@ -148,7 +148,7 @@ class TestLoopHotSwap(unittest.TestCase):
loop = _make_loop("[skill=ppt, dir=x]\n# PPT", switcher) loop = _make_loop("[skill=ppt, dir=x]\n# PPT", switcher)
old_caps, old_llm = loop.caps, loop.llm old_caps, old_llm = loop.caps, loop.llm
result, _ = loop._execute_tool_call(_load_skill_tc()) result, _, _artifacts = loop._execute_tool_call(_load_skill_tc())
self.assertIs(loop.caps, old_caps) self.assertIs(loop.caps, old_caps)
self.assertIs(loop.llm, old_llm) self.assertIs(loop.llm, old_llm)
self.assertNotIn("[模型切换]", result) self.assertNotIn("[模型切换]", result)

View File

@ -347,6 +347,38 @@ class FilesDbAwareTests(unittest.TestCase):
self.assertTrue(d["working_dir"].endswith("/改名后目录")) self.assertTrue(d["working_dir"].endswith("/改名后目录"))
self.assertTrue((_user_root() / "改名后目录").is_dir()) self.assertTrue((_user_root() / "改名后目录").is_dir())
def test_task_relative_download_survives_working_dir_rename(self):
tid = self._mk_task("稳定产物任务", "产物旧目录")
artifact = _user_root() / "产物旧目录" / "reports" / "result.txt"
artifact.parent.mkdir(parents=True, exist_ok=True)
artifact.write_text("stable", encoding="utf-8")
url = f"/v1/tasks/{tid}/files/download"
r = _client.get(url, params={"path": "reports/result.txt"}, headers=_AUTH)
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(r.content, b"stable")
r = _client.get(
url,
params={"path": "产物旧目录/reports/result.txt", "legacy": "true"},
headers=_AUTH,
)
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(r.content, b"stable")
r = _client.post(
"/v1/files/rename",
json={"path": "产物旧目录", "new_name": "产物新目录"},
headers=_AUTH,
)
self.assertEqual(r.status_code, 200, r.text)
r = _client.get(url, params={"path": "reports/result.txt"}, headers=_AUTH)
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(r.content, b"stable")
self.assertEqual(
_client.get(url, params={"path": "../escape.txt"}, headers=_AUTH).status_code,
400,
)
def test_toplevel_rename_blocked_while_running(self): def test_toplevel_rename_blocked_while_running(self):
tid = self._mk_task("跑动中任务", "跑动中目录") tid = self._mk_task("跑动中任务", "跑动中目录")
_set_run_status(tid, "running") _set_run_status(tid, "running")

View File

@ -1,9 +1,9 @@
"""gpt_image: 调 unifyllm 网关的 OpenAI Images API 生图,产物落 working_dir/figures/ """gpt_image: 调 unifyllm 网关的 OpenAI Images API 生图 / 改图
第二图像后端(第一个是豆包 seedream):模型 ID + 单价全在 `config/media/unifyllm.yaml`, 第二图像后端(第一个是豆包 seedream):模型 ID + 单价全在 `config/media/unifyllm.yaml`,
tool 只装配: tool 只装配:
- 文生图走 /images/generations JSON,gpt-image-2 支持自定义尺寸和质量档; - 文生图走 /images/generations JSON,gpt-image-2 支持自定义尺寸和质量档;
- 当前 unifyllm 网关的 /images/edits 不可用,暂不暴露改图参数; - 单图改图走 /images/edits multipart/form-data;
- 响应直接返 b64_json,无需二次下载; - 响应直接返 b64_json,无需二次下载;
- 复杂图片可能需 ~2min - 复杂图片可能需 ~2min
完成后: 完成后:
@ -24,9 +24,11 @@ from typing import Optional
from uuid import UUID from uuid import UUID
from core.ark_client import ArkClient, ArkConfig, ArkError from core.ark_client import ArkClient, ArkConfig, ArkError
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from core.storage.usage import record_image_usage from core.storage.usage import record_image_usage
from .base import Tool from .base import Tool
from .image_ref import load_image_as_data_url
from .media_common import quota_gate, record_usage_safe, stamped_path, write_meta from .media_common import quota_gate, record_usage_safe, stamped_path, write_meta
@ -34,10 +36,10 @@ class GptImageTool(Tool):
name = "gpt_image" name = "gpt_image"
description = ( description = (
"Generate an image via GPT Image, saved to working_dir/figures/. Supports custom size " "Generate an image via GPT Image, saved to working_dir/figures/. Supports custom size "
"and quality. Complex images may take up to ~2 minutes. Image-to-image editing is not " "and quality, plus single-reference image editing. For editing, pass reference_images "
"available through the current gateway; ask the user to switch to 豆包 Seedream for " "with the existing image path; the original is preserved and the result is saved as a "
"editing an existing image. Don't generate decoratively — only when the user actually " "new image. Complex images may take up to ~2 minutes. Don't generate decoratively — "
"wants an image. Returns the saved relative path." "only when the user actually wants an image. Returns the saved relative path."
) )
parameters = { parameters = {
"type": "object", "type": "object",
@ -46,6 +48,14 @@ class GptImageTool(Tool):
"type": "string", "type": "string",
"description": "中文或英文都行,详尽描述画面(主体/风格/光线/构图)。", "description": "中文或英文都行,详尽描述画面(主体/风格/光线/构图)。",
}, },
"reference_images": {
"type": "array",
"items": {"type": "string"},
"description": (
"改图(image-to-image):传 1 张 task_dir 内已存在图片的相对路径。"
"不传 = 从零文生图;当前仅支持单张参考图。"
),
},
"size": { "size": {
"type": "string", "type": "string",
"description": ( "description": (
@ -88,12 +98,36 @@ class GptImageTool(Tool):
def execute( def execute(
self, self,
prompt: str, prompt: str,
reference_images: list | None = None,
size: Optional[str] = None, size: Optional[str] = None,
quality: Optional[str] = None, quality: Optional[str] = None,
) -> str: ) -> str:
if not (prompt or "").strip(): if not (prompt or "").strip():
return "[Error] prompt 不能为空" return "[Error] prompt 不能为空"
refs = [str(r).strip() for r in (reference_images or []) if str(r).strip()]
if len(refs) > 1:
return (
"[Error] reference_images 当前仅支持单张参考图(传了 "
f"{len(refs)} 张)。请只传 1 张。"
)
ref_bytes = b""
ref_mime = ""
ref_disp = ""
if refs:
data_url, ref_disp, ref_err = load_image_as_data_url(
refs[0],
working_dir=self.working_dir,
user_root=self.user_root,
display_fn=self._display,
)
if ref_err:
return ref_err
header, encoded = data_url.split(",", 1)
ref_mime = header.removeprefix("data:").removesuffix(";base64")
ref_bytes = base64.b64decode(encoded)
is_i2i = bool(ref_bytes)
cfg = self.cfg cfg = self.cfg
chosen_size, size_err = self._normalize_size( chosen_size, size_err = self._normalize_size(
size if size is not None else cfg.get("default_size", "auto") size if size is not None else cfg.get("default_size", "auto")
@ -114,7 +148,11 @@ class GptImageTool(Tool):
return quota_err return quota_err
model_id = cfg["model_id"] model_id = cfg["model_id"]
endpoint = cfg.get("endpoint", "/images/generations") endpoint = (
cfg.get("edit_endpoint", "/images/edits")
if is_i2i
else cfg.get("endpoint", "/images/generations")
)
timeout_s = float(cfg.get("request_timeout_s", 300)) timeout_s = float(cfg.get("request_timeout_s", 300))
price = float(cfg.get("price_cny_per_image", 0)) price = float(cfg.get("price_cny_per_image", 0))
@ -129,7 +167,20 @@ class GptImageTool(Tool):
t0 = time.monotonic() t0 = time.monotonic()
try: try:
with ArkClient(self.gw_cfg, timeout_s=timeout_s) as client: with ArkClient(self.gw_cfg, timeout_s=timeout_s) as client:
resp = client.post_json(endpoint, body, timeout_s=timeout_s) if is_i2i:
form = {
key: str(value).lower() if isinstance(value, bool) else str(value)
for key, value in body.items()
}
form["response_format"] = "b64_json"
resp = client.post_multipart(
endpoint,
form,
{"image": (Path(refs[0]).name or "reference.png", ref_bytes, ref_mime)},
timeout_s=timeout_s,
)
else:
resp = client.post_json(endpoint, body, timeout_s=timeout_s)
except ArkError as e: except ArkError as e:
return f"[Error] gpt_image API: {e}" return f"[Error] gpt_image API: {e}"
@ -137,6 +188,10 @@ class GptImageTool(Tool):
b64 = "" b64 = ""
if isinstance(data, list) and data and isinstance(data[0], dict): if isinstance(data, list) and data and isinstance(data[0], dict):
b64 = data[0].get("b64_json") or "" b64 = data[0].get("b64_json") or ""
if not b64:
image_url = str(data[0].get("url") or "")
if image_url.startswith("data:") and ";base64," in image_url:
b64 = image_url.split(",", 1)[1]
if not b64: if not b64:
return f"[Error] gpt_image response 缺 b64_json: {json.dumps(resp, ensure_ascii=False)[:300]}" return f"[Error] gpt_image response 缺 b64_json: {json.dumps(resp, ensure_ascii=False)[:300]}"
try: try:
@ -161,7 +216,8 @@ class GptImageTool(Tool):
"requested_size": chosen_size, "requested_size": chosen_size,
"quality": actual_quality, "quality": actual_quality,
"requested_quality": chosen_quality, "requested_quality": chosen_quality,
"mode": "t2i", "mode": "i2i" if is_i2i else "t2i",
"reference_images": [ref_disp] if is_i2i else [],
"cost_cny": price, "cost_cny": price,
"output_tokens": output_tokens, "output_tokens": output_tokens,
"elapsed_s": round(elapsed, 2), "elapsed_s": round(elapsed, 2),
@ -184,12 +240,20 @@ class GptImageTool(Tool):
# 首行 banner 协议同 seedream(`key=value · ` 分隔,前端 extractMediaBanner 解析); # 首行 banner 协议同 seedream(`key=value · ` 分隔,前端 extractMediaBanner 解析);
# 价格未知(price=0)时不放 cost 段,避免"¥0.00 = 免费"的误导。 # 价格未知(price=0)时不放 cost 段,避免"¥0.00 = 免费"的误导。
cost_seg = f" · cost=¥{price:.2f}" if price > 0 else "" cost_seg = f" · cost=¥{price:.2f}" if price > 0 else ""
return ( mode_seg = " · mode=i2i" if is_i2i else ""
ref_line = f"\nreference={ref_disp}" if is_i2i else ""
result = (
f"[gpt_image] model={model_id} · size={actual_size} · quality={actual_quality}" f"[gpt_image] model={model_id} · size={actual_size} · quality={actual_quality}"
f"{cost_seg} · elapsed={elapsed:.1f}s\n" f"{cost_seg} · elapsed={elapsed:.1f}s{mode_seg}\n"
f"saved: {disp}\n" f"saved: {disp}{ref_line}\n"
f"prompt={prompt!r}" f"prompt={prompt!r}"
) )
if self.user_root is None:
return result
_, rel = resolve_artifact_path(
str(dest_png), working_dir=self.working_dir, user_root=self.user_root,
)
return ToolExecutionResult(content=result, artifacts=(ArtifactRef(path=rel),))
@staticmethod @staticmethod
def _normalize_size(raw: object) -> tuple[str, str]: def _normalize_size(raw: object) -> tuple[str, str]:

View File

@ -15,6 +15,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
from uuid import uuid4 from uuid import uuid4
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from tools.base import FileOutOfBounds, Tool from tools.base import FileOutOfBounds, Tool
from web.pptx_render import SofficeNotFoundError, find_soffice from web.pptx_render import SofficeNotFoundError, find_soffice
@ -147,7 +148,7 @@ class OfficeToPdfTool(Tool):
return root_candidate return root_candidate
return base_candidate return base_candidate
def execute(self, source: str, output: Optional[str] = None) -> str: def execute(self, source: str, output: Optional[str] = None) -> str | ToolExecutionResult:
try: try:
src = self._resolve_office_path(source, existing=True) src = self._resolve_office_path(source, existing=True)
except FileOutOfBounds: except FileOutOfBounds:
@ -218,4 +219,13 @@ class OfficeToPdfTool(Tool):
except OSError as e: except OSError as e:
return f"[Error] failed to publish PDF: {type(e).__name__}: {e}" return f"[Error] failed to publish PDF: {type(e).__name__}: {e}"
return f"[OK] PDF created: {self._display(out)} ({out.stat().st_size} bytes)" content = f"[OK] PDF created: {self._display(out)} ({out.stat().st_size} bytes)"
if self.user_root is None:
return content
try:
_, rel = resolve_artifact_path(
str(out), working_dir=self.base_dir, user_root=self.user_root,
)
except ValueError:
return content
return ToolExecutionResult(content=content, artifacts=(ArtifactRef(path=rel),))

View File

@ -0,0 +1,95 @@
"""Explicitly promote a small set of workspace files to user-facing artifacts."""
from __future__ import annotations
from pathlib import Path
from core.artifacts import (
MAX_ARTIFACTS_PER_MESSAGE,
ArtifactPathError,
ArtifactRef,
ToolExecutionResult,
resolve_artifact_path,
)
from .base import Tool
class PublishArtifactsTool(Tool):
name = "publish_artifacts"
description = (
"Publish a small set of final deliverable files to the chat. Ordinary source, "
"temporary, intermediate, and project support files should stay in the file panel "
"and must not be published. Paths are relative to the current task working directory."
)
parameters = {
"type": "object",
"properties": {
"artifacts": {
"type": "array",
"minItems": 1,
"maxItems": MAX_ARTIFACTS_PER_MESSAGE,
"items": {
"type": "object",
"properties": {
"path": {
"type": "string",
"minLength": 1,
"maxLength": 1000,
"description": "File path relative to the current task working directory.",
},
"label": {
"type": "string",
"maxLength": 120,
"description": "Optional short user-facing label.",
},
},
"required": ["path"],
},
}
},
"required": ["artifacts"],
}
def __init__(self, working_dir: Path, **kwargs) -> None:
super().__init__(**kwargs)
self.working_dir = Path(working_dir)
def execute(self, artifacts: list[dict]) -> ToolExecutionResult | str:
if not isinstance(artifacts, list) or not artifacts:
return "[Error] artifacts must be a non-empty list"
if len(artifacts) > MAX_ARTIFACTS_PER_MESSAGE:
return f"[Error] at most {MAX_ARTIFACTS_PER_MESSAGE} artifacts may be published at once"
if self.user_root is None:
return "[Error] publish_artifacts requires a user workspace"
refs: list[ArtifactRef] = []
seen: set[str] = set()
for item in artifacts:
if not isinstance(item, dict):
return "[Error] every artifact must be an object with path and optional label"
try:
raw_path = str(item.get("path") or "")
if len(raw_path) > 1000:
return "[Error] artifact path is too long"
label = str(item.get("label") or "").strip()
if len(label) > 120:
return "[Error] artifact label is too long"
_, rel = resolve_artifact_path(
raw_path,
working_dir=self.working_dir,
user_root=self.user_root,
require_file=True,
allow_legacy_user_relative=False,
)
except ArtifactPathError as exc:
return f"[Error] cannot publish artifact: {exc}"
if rel in seen:
continue
seen.add(rel)
refs.append(ArtifactRef(path=rel, label=label))
names = ", ".join(ref.label or ref.path for ref in refs)
return ToolExecutionResult(
content=f"[OK] published {len(refs)} artifact(s): {names}",
artifacts=tuple(refs),
)

View File

@ -24,6 +24,7 @@ from pathlib import Path
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
from uuid import UUID from uuid import UUID
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from core.ark_client import ArkClient, ArkConfig, ArkError from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import record_video_usage from core.storage.usage import record_video_usage
@ -388,7 +389,7 @@ class SeedanceTool(Tool):
image_banner += f" · image={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 ( result = (
f"[seedance] model={model_id} · mode={mode}{image_banner} · " f"[seedance] model={model_id} · mode={mode}{image_banner} · "
f"resolution={chosen_resolution} · ratio={chosen_ratio} · " 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} · "
@ -397,6 +398,12 @@ class SeedanceTool(Tool):
f"prompt={prompt!r}\n" f"prompt={prompt!r}\n"
f"watermark={chosen_watermark} cgt_id={cgt_id}" f"watermark={chosen_watermark} cgt_id={cgt_id}"
) )
if self.user_root is None:
return result
_, rel = resolve_artifact_path(
str(dest_mp4), working_dir=self.working_dir, user_root=self.user_root,
)
return ToolExecutionResult(content=result, artifacts=(ArtifactRef(path=rel),))
@staticmethod @staticmethod
def _rough_cost(resolution: str, ratio: str, duration_s: int, fps: int, price_per_mtoken: float) -> float: def _rough_cost(resolution: str, ratio: str, duration_s: int, fps: int, price_per_mtoken: float) -> float:

View File

@ -15,6 +15,7 @@ from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from uuid import UUID from uuid import UUID
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from core.ark_client import ArkClient, ArkConfig, ArkError from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import record_image_usage from core.storage.usage import record_image_usage
@ -222,13 +223,19 @@ class SeedreamTool(Tool):
mode_seg = " · mode=i2i" if is_i2i else "" mode_seg = " · mode=i2i" if is_i2i else ""
ref_line = f"\nreference={ref_disp[0]}" if is_i2i else "" ref_line = f"\nreference={ref_disp[0]}" if is_i2i else ""
note_line = f"\n{size_note}" if size_note else "" note_line = f"\n{size_note}" if size_note else ""
return ( result = (
f"[seedream] model={model_id} · size={chosen_size} · " f"[seedream] model={model_id} · size={chosen_size} · "
f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s{mode_seg}\n" f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s{mode_seg}\n"
f"saved: {disp}{ref_line}\n" f"saved: {disp}{ref_line}\n"
f"prompt={prompt!r}\n" f"prompt={prompt!r}\n"
f"watermark={chosen_watermark} search={chosen_search}{note_line}" f"watermark={chosen_watermark} search={chosen_search}{note_line}"
) )
if self.user_root is None:
return result
_, rel = resolve_artifact_path(
str(dest_png), working_dir=self.working_dir, user_root=self.user_root,
)
return ToolExecutionResult(content=result, artifacts=(ArtifactRef(path=rel),))
@staticmethod @staticmethod
def _normalize_size( def _normalize_size(

View File

@ -14,7 +14,7 @@ from fastapi import Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy import func, select from sqlalchemy import func, select
from core.paths import to_db_path from core.paths import from_db_path, to_db_path
from core.storage import session_scope from core.storage import session_scope
from core.storage.models import Task from core.storage.models import Task
from core.working_dirs import ( from core.working_dirs import (
@ -45,6 +45,88 @@ def _pptx_lock_for(abs_path: str) -> asyncio.Lock:
return lock return lock
def _regular_file_response(target: Path, display_path: str) -> FileResponse:
if not target.exists():
raise HTTPException(404, f"file not found: {display_path}")
if not target.is_file():
raise HTTPException(400, f"not a file: {display_path}")
media_type = "image/svg+xml" if target.suffix.lower() == ".svg" else None
return FileResponse(
path=str(target),
filename=target.name,
media_type=media_type,
headers={"Cache-Control": "no-cache"},
)
def _task_working_dir(task_id: str, user_id: UUID, root: Path) -> tuple[UUID, Path]:
try:
tid = UUID(task_id)
except ValueError:
raise HTTPException(404, f"invalid task id: {task_id!r}")
with session_scope() as s:
db_path = s.execute(
select(Task.working_dir).where(Task.task_id == tid, Task.user_id == user_id)
).scalar_one_or_none()
if not db_path:
raise HTTPException(404, "task not found")
working_dir = from_db_path(db_path).resolve()
try:
working_dir.relative_to(root.resolve())
except ValueError:
raise HTTPException(400, "task working_dir is outside user workspace")
return tid, working_dir
def _task_file_target(root: Path, working_dir: Path, path: str, legacy: bool) -> Path:
"""Resolve canonical task refs, with a read-only fallback chain for old cards.
Historical messages used user-root paths, while one known failure accidentally emitted
a task-relative path with the same leading directory name. Old cards therefore try the
original user-root meaning first, then both task-relative interpretations. New refs never
use this branch and remain unambiguous.
"""
if not legacy:
return safe_join(working_dir, path)
candidates = [safe_join(root, path), safe_join(working_dir, path)]
normalized = str(path or "").replace("\\", "/")
if "/" in normalized:
candidates.append(safe_join(working_dir, normalized.split("/", 1)[1]))
for candidate in candidates:
if candidate.is_file():
return candidate
return candidates[0]
async def _pptx_preview_response(target: Path, display_path: str) -> FileResponse:
from ..pptx_render import (
PptxConvertError,
SofficeNotFoundError,
pptx_to_pdf,
)
if not target.exists():
raise HTTPException(404, f"file not found: {display_path}")
if not target.is_file():
raise HTTPException(400, f"not a file: {display_path}")
if target.suffix.lower() not in (".pptx", ".ppt"):
raise HTTPException(400, f"not a pptx: {display_path}")
abs_path = str(target.resolve())
loop = asyncio.get_event_loop()
async with _pptx_lock_for(abs_path):
try:
pdf_path = await loop.run_in_executor(None, pptx_to_pdf, target)
except SofficeNotFoundError as e:
raise HTTPException(501, str(e))
except PptxConvertError as e:
raise HTTPException(500, str(e))
return FileResponse(
path=str(pdf_path),
media_type="application/pdf",
headers={"Cache-Control": "no-cache"},
)
def register_file_routes(app, *, require_user) -> None: def register_file_routes(app, *, require_user) -> None:
@app.get("/v1/user/storage", tags=["user"]) @app.get("/v1/user/storage", tags=["user"])
def user_storage(user_id: UUID = Depends(require_user)): def user_storage(user_id: UUID = Depends(require_user)):
@ -105,24 +187,25 @@ def register_file_routes(app, *, require_user) -> None:
"""下载 user_root 下单个 regular file(目录 → 400 / 不存在 → 404)。""" """下载 user_root 下单个 regular file(目录 → 400 / 不存在 → 404)。"""
root = load_user_root(user_id) root = load_user_root(user_id)
target = safe_join(root, path) target = safe_join(root, path)
if not target.exists():
raise HTTPException(404, f"file not found: {path}")
if not target.is_file():
raise HTTPException(400, f"not a file: {path}")
# workspace 文件可变, 禁浏览器启发式缓存 (RFC 7234 默认能缓数小时) # workspace 文件可变, 禁浏览器启发式缓存 (RFC 7234 默认能缓数小时)
# 否则文件改了 SPA 预览还是旧内容 # 否则文件改了 SPA 预览还是旧内容
# (Starlette FileResponse 不实现 304, 总是 200 全量; workspace 文件小, 可接受) # (Starlette FileResponse 不实现 304, 总是 200 全量; workspace 文件小, 可接受)
# .svg 显式给 image/svg+xml: 部分部署环境 mimetypes 未注册 svg, FileResponse # .svg 显式给 image/svg+xml: 部分部署环境 mimetypes 未注册 svg, FileResponse
# 会猜成 octet-stream, 前端 <img> 就渲染不出 SVG 预览 # 会猜成 octet-stream, 前端 <img> 就渲染不出 SVG 预览
media_type = None return _regular_file_response(target, path)
if target.suffix.lower() == ".svg":
media_type = "image/svg+xml" @app.get("/v1/tasks/{task_id}/files/download", tags=["files"])
return FileResponse( def download_task_file(
path=str(target), task_id: str,
filename=target.name, path: str,
media_type=media_type, legacy: bool = False,
headers={"Cache-Control": "no-cache"}, user_id: UUID = Depends(require_user),
) ):
"""Download a file addressed relative to the task's current working_dir."""
root = load_user_root(user_id)
_tid, working_dir = _task_working_dir(task_id, user_id, root)
target = _task_file_target(root, working_dir, path, legacy)
return _regular_file_response(target, path)
@app.get("/v1/files/preview_pdf", tags=["files"]) @app.get("/v1/files/preview_pdf", tags=["files"])
async def preview_pdf( async def preview_pdf(
@ -134,35 +217,22 @@ def register_file_routes(app, *, require_user) -> None:
转换跑在 backend host(不进沙盒),按需触发 + 缓存到 `.preview/`(DESIGN §8.3) 转换跑在 backend host(不进沙盒),按需触发 + 缓存到 `.preview/`(DESIGN §8.3)
soffice 缺失 501;转换失败/超时 500;前端据此回退到下载 soffice 缺失 501;转换失败/超时 500;前端据此回退到下载
""" """
from ..pptx_render import (
PptxConvertError,
SofficeNotFoundError,
pptx_to_pdf,
)
root = load_user_root(user_id) root = load_user_root(user_id)
target = safe_join(root, path) target = safe_join(root, path)
if not target.exists(): return await _pptx_preview_response(target, path)
raise HTTPException(404, f"file not found: {path}")
if not target.is_file():
raise HTTPException(400, f"not a file: {path}")
if target.suffix.lower() not in (".pptx", ".ppt"):
raise HTTPException(400, f"not a pptx: {path}")
abs_path = str(target.resolve()) @app.get("/v1/tasks/{task_id}/files/preview_pdf", tags=["files"])
loop = asyncio.get_event_loop() async def preview_task_pdf(
async with _pptx_lock_for(abs_path): task_id: str,
try: path: str,
pdf_path = await loop.run_in_executor(None, pptx_to_pdf, target) legacy: bool = False,
except SofficeNotFoundError as e: user_id: UUID = Depends(require_user),
raise HTTPException(501, str(e)) ):
except PptxConvertError as e: """Preview a PPT addressed relative to the task's current working_dir."""
raise HTTPException(500, str(e)) root = load_user_root(user_id)
return FileResponse( _tid, working_dir = _task_working_dir(task_id, user_id, root)
path=str(pdf_path), target = _task_file_target(root, working_dir, path, legacy)
media_type="application/pdf", return await _pptx_preview_response(target, path)
headers={"Cache-Control": "no-cache"},
)
@app.post("/v1/files/upload", tags=["files"]) @app.post("/v1/files/upload", tags=["files"])
async def upload_files( async def upload_files(

View File

@ -99,6 +99,7 @@ def register_message_routes(app, *, require_user) -> None:
cols = ( cols = (
Message.idx, Message.payload, Message.tokens_in, Message.idx, Message.payload, Message.tokens_in,
Message.tokens_out, Message.model_profile, Message.created_at, Message.tokens_out, Message.model_profile, Message.created_at,
Message.artifact_refs,
) )
if limit is None: if limit is None:
# 旧行为:升序全量 # 旧行为:升序全量
@ -146,6 +147,7 @@ def register_message_routes(app, *, require_user) -> None:
"tokens_out": r.tokens_out, "tokens_out": r.tokens_out,
"model_profile": r.model_profile, # 0006:assistant 行非空,标产生该 msg 的模型 "model_profile": r.model_profile, # 0006:assistant 行非空,标产生该 msg 的模型
"created_at": iso(r.created_at), "created_at": iso(r.created_at),
"artifact_refs": r.artifact_refs,
} }
for r in rows for r in rows
] ]

View File

@ -54,7 +54,7 @@ def register_model_routes(app, *, require_user) -> None:
"display_name": caps.display_name or profile, "display_name": caps.display_name or profile,
"family": caps.family, "family": caps.family,
"variant": caps.variant, "variant": caps.variant,
"thinking_mode": caps.thinking_mode, "thinking_enabled": caps.thinking_enabled,
"is_default": profile == default, "is_default": profile == default,
}) })
return {"models": out} return {"models": out}

View File

@ -1482,6 +1482,17 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
// chip 去重:同一路径在 tool 结果里挂过 inline 图后,assistant 正文 echo 同路径不再重挂。 // chip 去重:同一路径在 tool 结果里挂过 inline 图后,assistant 正文 echo 同路径不再重挂。
// chronological 遍历,首次出现保留(tool 结果常在前),后续重复过滤掉。 // chronological 遍历,首次出现保留(tool 结果常在前),后续重复过滤掉。
const seenRels = new Set(); const seenRels = new Set();
// New messages explicitly carry artifact_refs (including []). Suppress legacy tool-result
// path extraction for that user turn so auto-published media is not shown twice.
const msgTurn = new Map();
const structuredTurns = new Set();
let turnNo = -1;
for (const item of msgs) {
const payload = item.payload || {};
if (payload.role === "user") turnNo += 1;
msgTurn.set(item.idx, turnNo);
if (Array.isArray(item.artifact_refs)) structuredTurns.add(turnNo);
}
// 历史态把 assistant tool_call 与紧随其后的 tool result 合成一条活动项。 // 历史态把 assistant tool_call 与紧随其后的 tool result 合成一条活动项。
// tool result 才是最终可追溯记录;已得到结果的 call 不再额外渲一条“准备调用”,避免双行噪声。 // tool result 才是最终可追溯记录;已得到结果的 call 不再额外渲一条“准备调用”,避免双行噪声。
const toolCallsById = new Map(); const toolCallsById = new Map();
@ -1548,14 +1559,15 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
const failed = isToolResultFailure(txt); const failed = isToolResultFailure(txt);
// 工具结果只有产物工具(seedream/seedance)挂 chip + inline 大图;通用工具 // 工具结果只有产物工具(seedream/seedance)挂 chip + inline 大图;通用工具
// (grep/read/glob/shell)echo 的路径是"引用"不是"产物",不挂以免噪声。 // (grep/read/glob/shell)echo 的路径是"引用"不是"产物",不挂以免噪声。
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(p.name || ""); const isProducer = ARTIFACT_PRODUCING_TOOLS.has(p.name || "")
&& !structuredTurns.has(msgTurn.get(m.idx));
const rels = isProducer ? pickFresh(extractArtifactRels(txt || "", wd)) : []; const rels = isProducer ? pickFresh(extractArtifactRels(txt || "", wd)) : [];
card.innerHTML = ` card.innerHTML = `
<details class="tool-call activity${failed ? " failed" : ""}"> <details class="tool-call activity${failed ? " failed" : ""}">
<summary><span class="tool-state" aria-hidden="true">${failed ? "!" : "✓"}</span><span class="tool-label">${escapeHtml(activityLabel)}</span><span class="tool-result-meta">${(txt || "").length} </span>${banner}</summary> <summary><span class="tool-state" aria-hidden="true">${failed ? "!" : "✓"}</span><span class="tool-label">${escapeHtml(activityLabel)}</span><span class="tool-result-meta">${(txt || "").length} </span>${banner}</summary>
<pre>${escapeHtml(txt || "")}</pre> <pre>${escapeHtml(txt || "")}</pre>
</details> </details>
${renderArtifactBarHtml(rels, isProducer)} ${renderArtifactBarHtml(rels, isProducer, state.taskId || "", true)}
`; `;
// bg proc 启动结果 → 卡片活化(spinner/跳秒/停止,与直播态同构;真实状态 // bg proc 启动结果 → 卡片活化(spinner/跳秒/停止,与直播态同构;真实状态
// 由 selectTask 尾部的 refreshProcs 校正,终态卡显示 exit/耗时定格) // 由 selectTask 尾部的 refreshProcs 校正,终态卡显示 exit/耗时定格)
@ -1584,11 +1596,15 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
// assistant 正文里 echo 的 <wd>/... 路径**永远**展示(绕开 seenRels)。图片/视频 // assistant 正文里 echo 的 <wd>/... 路径**永远**展示(绕开 seenRels)。图片/视频
// 已可能在产物工具结果中内联,仍用 chip 防重复HTML 通常由 write/shell 产出, // 已可能在产物工具结果中内联,仍用 chip 防重复HTML 通常由 write/shell 产出,
// 没有 producer 工具卡可承载,故在最终答复处直接升级为懒加载内嵌卡片。 // 没有 producer 工具卡可承载,故在最终答复处直接升级为懒加载内嵌卡片。
if (role === "assistant") { if (role === "assistant" && !Array.isArray(m.artifact_refs)) {
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
html += renderArtifactBarHtml(extractArtifactRels(p.content, wd), "html"); html += renderArtifactBarHtml(extractArtifactRels(p.content, wd), "html", state.taskId || "", true);
} }
} }
if (role === "assistant" && Array.isArray(m.artifact_refs)) {
html += renderArtifactBarHtml(m.artifact_refs, true, state.taskId || "");
if (m.artifact_refs.length) hasVisibleAssistantContent = true;
}
if (Array.isArray(p.tool_calls) && p.tool_calls.length) { if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
const progressResult = progressActionsFromToolCalls(p.tool_calls, currentProgressSteps); const progressResult = progressActionsFromToolCalls(p.tool_calls, currentProgressSteps);
@ -1624,7 +1640,7 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : []; const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : [];
html += ` html += `
<details class="tool-call activity"><summary><span class="tool-state" aria-hidden="true"></span><span class="tool-label">${escapeHtml(label)}</span></summary><pre>${escapeHtml(args)}</pre></details> <details class="tool-call activity"><summary><span class="tool-state" aria-hidden="true"></span><span class="tool-label">${escapeHtml(label)}</span></summary><pre>${escapeHtml(args)}</pre></details>
${renderArtifactBarHtml(rels, isProducer)} ${renderArtifactBarHtml(rels, isProducer, state.taskId || "", true)}
`; `;
hasVisibleAssistantContent = true; hasVisibleAssistantContent = true;
} }
@ -2391,19 +2407,19 @@ $("chat-stream").addEventListener("click", (e) => {
const chip = e.target.closest && e.target.closest(".art-chip"); const chip = e.target.closest && e.target.closest(".art-chip");
if (chip) { if (chip) {
const rel = chip.dataset.rel; const rel = chip.dataset.rel;
if (rel) openFilePreview(rel); if (rel) openFilePreview(rel, chip.dataset.taskId || "", chip.dataset.legacyPath === "1");
return; return;
} }
const htmlOpen = e.target.closest && e.target.closest(".art-html-open[data-rel]"); const htmlOpen = e.target.closest && e.target.closest(".art-html-open[data-rel]");
if (htmlOpen) { if (htmlOpen) {
const rel = htmlOpen.dataset.rel; const rel = htmlOpen.dataset.rel;
if (rel) openFilePreview(rel); if (rel) openFilePreview(rel, htmlOpen.dataset.taskId || "", htmlOpen.dataset.legacyPath === "1");
return; return;
} }
const inlineImg = e.target.closest && e.target.closest(".art-media-image[data-rel]"); const inlineImg = e.target.closest && e.target.closest(".art-media-image[data-rel]");
if (inlineImg) { if (inlineImg) {
const rel = inlineImg.dataset.rel; const rel = inlineImg.dataset.rel;
if (rel) openFilePreview(rel); if (rel) openFilePreview(rel, inlineImg.dataset.taskId || "", inlineImg.dataset.legacyPath === "1");
return; return;
} }
// 正文里的 markdown 链接:模型常把工作区相对路径写成 [<rel>](<rel>),renderMd 出 <a>。 // 正文里的 markdown 链接:模型常把工作区相对路径写成 [<rel>](<rel>),renderMd 出 <a>。
@ -2886,7 +2902,7 @@ function handleSseEvent(ev, asstCard, ctx) {
? extractArtifactRels(argsStr, wd).filter(r => !ctx.seenRels.has(r)) ? extractArtifactRels(argsStr, wd).filter(r => !ctx.seenRels.has(r))
: []; : [];
fresh.forEach(r => ctx.seenRels.add(r)); fresh.forEach(r => ctx.seenRels.add(r));
const barHtml = renderArtifactBarHtml(fresh, isProducer); const barHtml = renderArtifactBarHtml(fresh, isProducer, ctx.taskId || "", true);
if (barHtml) { if (barHtml) {
asstCard.insertAdjacentHTML("beforeend", barHtml); asstCard.insertAdjacentHTML("beforeend", barHtml);
if (isProducer) upgradeMediaArtifacts(asstCard); if (isProducer) upgradeMediaArtifacts(asstCard);
@ -2917,11 +2933,17 @@ function handleSseEvent(ev, asstCard, ctx) {
if (summary && banner) summary.insertAdjacentHTML("beforeend", banner); if (summary && banner) summary.insertAdjacentHTML("beforeend", banner);
const wd = _workingDirName(ctx.workingDir); const wd = _workingDirName(ctx.workingDir);
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(toolName); const isProducer = ARTIFACT_PRODUCING_TOOLS.has(toolName);
const fresh = isProducer const structured = Array.isArray(ev.data && ev.data.artifacts) ? ev.data.artifacts : [];
const fresh = structured.length ? structured : (isProducer
? extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r)) ? extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r))
: []; : []);
fresh.forEach(r => ctx.seenRels.add(r)); fresh.forEach(r => ctx.seenRels.add(typeof r === "string" ? r : r.path));
const barHtml = renderArtifactBarHtml(fresh, isProducer); const barHtml = renderArtifactBarHtml(
fresh,
isProducer || structured.length > 0,
ctx.taskId || "",
structured.length === 0,
);
if (barHtml) { if (barHtml) {
asstCard.insertAdjacentHTML("beforeend", barHtml); asstCard.insertAdjacentHTML("beforeend", barHtml);
if (isProducer) upgradeMediaArtifacts(asstCard); if (isProducer) upgradeMediaArtifacts(asstCard);

View File

@ -35,6 +35,8 @@ export function toolActivityLabel(name, args) {
case "web_search": return `联网搜索: ${clip(a.query, 60)}`; case "web_search": return `联网搜索: ${clip(a.query, 60)}`;
case "load_skill": return `加载技能: ${clip(a.name, 40)}`; case "load_skill": return `加载技能: ${clip(a.name, 40)}`;
case "rename_working_dir": return `重命名工作目录: ${clip(a.new_name, 60)}`; case "rename_working_dir": return `重命名工作目录: ${clip(a.new_name, 60)}`;
case "publish_artifacts": return `发布最终产物: ${clip(JSON.stringify(a.artifacts || []), 80)}`;
case "office_to_pdf": return `转换 PDF: ${clip(a.source, 80)}`;
case "seedream": return `生成图像: ${clip(a.prompt, 60)}`; case "seedream": return `生成图像: ${clip(a.prompt, 60)}`;
case "gpt_image": return `生成图像: ${clip(a.prompt, 60)}`; case "gpt_image": return `生成图像: ${clip(a.prompt, 60)}`;
case "seedance": return `生成视频: ${clip(a.prompt, 60)}`; case "seedance": return `生成视频: ${clip(a.prompt, 60)}`;
@ -168,23 +170,28 @@ export function extractArtifactRels(text, workingDir) {
// inlineMode 控制升级范围:true=图片/视频/HTML"html"=仅 HTMLfalse=全走 chip。 // inlineMode 控制升级范围:true=图片/视频/HTML"html"=仅 HTMLfalse=全走 chip。
// 产物工具传 trueassistant 正文传 "html",避免重复内联图片/视频但让通用工具生成的 // 产物工具传 trueassistant 正文传 "html",避免重复内联图片/视频但让通用工具生成的
// HTML 有展示位;普通工具结果传 false引用到的文件仍走 chip避免把旧产物铺满消息。 // HTML 有展示位;普通工具结果传 false引用到的文件仍走 chip避免把旧产物铺满消息。
export function renderArtifactBarHtml(rels, inlineMode = true) { export function renderArtifactBarHtml(rels, inlineMode = true, taskId = "", legacy = false) {
if (!rels || !rels.length) return ""; if (!rels || !rels.length) return "";
const items = rels.map((rel) => { const taskAttr = taskId ? ` data-task-id="${escapeHtml(taskId)}"` : "";
const name = rel.split("/").pop() || rel; const legacyAttr = legacy ? ` data-legacy-path="1"` : "";
const items = rels.map((item) => {
const ref = (item && typeof item === "object") ? item : { path: item };
const rel = String(ref.path || "");
if (!rel) return "";
const name = String(ref.label || rel.split("/").pop() || rel);
const cat = _categorize(rel); const cat = _categorize(rel);
if ((inlineMode === true || inlineMode === "html") && cat === "html") { if ((inlineMode === true || inlineMode === "html") && cat === "html") {
return `<section class="art-html" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)}"> return `<section class="art-html" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr} title="${escapeHtml(rel)}">
<div class="art-html-head"><span>${escapeHtml(name)}</span><button type="button" class="art-html-open" data-rel="${escapeHtml(rel)}"></button></div> <div class="art-html-head"><span>${escapeHtml(name)}</span><button type="button" class="art-html-open" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr}></button></div>
<div class="art-html-viewport"><span class="art-media-loading">进入可视区域后加载</span></div> <div class="art-html-viewport"><span class="art-media-loading">进入可视区域后加载</span></div>
</section>`; </section>`;
} }
if (inlineMode === true && (cat === "image" || cat === "video")) { if (inlineMode === true && (cat === "image" || cat === "video")) {
// 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。 // 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。
// 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。 // 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}" title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`; return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}"${taskAttr}${legacyAttr} title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
} }
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览(可下载)">${escapeHtml(name)}</button>`; return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}"${taskAttr}${legacyAttr} title="${escapeHtml(rel)} · 点击预览(可下载)">${escapeHtml(name)}</button>`;
}).join(""); }).join("");
return `<div class="artifact-bar">${items}</div>`; return `<div class="artifact-bar">${items}</div>`;
} }
@ -196,22 +203,31 @@ const _mediaArtifactCache = new Map();
const _htmlArtifactCache = new Map(); const _htmlArtifactCache = new Map();
const INLINE_HTML_MAX = 2 * 1024 * 1024; const INLINE_HTML_MAX = 2 * 1024 * 1024;
function _fetchMediaBlobUrl(rel) { function _artifactDownloadUrl(rel, taskId = "", legacy = false) {
if (_mediaArtifactCache.has(rel)) return _mediaArtifactCache.get(rel); const base = taskId
const p = fetch("/v1/files/download?path=" + encodeURIComponent(rel), { ? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
: "/v1/files/download";
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "");
}
function _fetchMediaBlobUrl(rel, taskId = "", legacy = false) {
const key = `${taskId}:${legacy ? "legacy:" : ""}${rel}`;
if (_mediaArtifactCache.has(key)) return _mediaArtifactCache.get(key);
const p = fetch(_artifactDownloadUrl(rel, taskId, legacy), {
headers: { "Authorization": "Bearer " + state.token }, headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => { }).then(async (r) => {
if (!r.ok) throw new Error("HTTP " + r.status); if (!r.ok) throw new Error("HTTP " + r.status);
const blob = await r.blob(); const blob = await r.blob();
return URL.createObjectURL(blob); return URL.createObjectURL(blob);
}); });
_mediaArtifactCache.set(rel, p); _mediaArtifactCache.set(key, p);
return p; return p;
} }
function _fetchHtmlArtifact(rel) { function _fetchHtmlArtifact(rel, taskId = "", legacy = false) {
if (_htmlArtifactCache.has(rel)) return _htmlArtifactCache.get(rel); const key = `${taskId}:${legacy ? "legacy:" : ""}${rel}`;
const p = fetch("/v1/files/download?path=" + encodeURIComponent(rel), { if (_htmlArtifactCache.has(key)) return _htmlArtifactCache.get(key);
const p = fetch(_artifactDownloadUrl(rel, taskId, legacy), {
headers: { "Authorization": "Bearer " + state.token }, headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => { }).then(async (r) => {
if (!r.ok) throw new Error("HTTP " + r.status); if (!r.ok) throw new Error("HTTP " + r.status);
@ -219,7 +235,7 @@ function _fetchHtmlArtifact(rel) {
if (blob.size > INLINE_HTML_MAX) throw new Error("文件过大,请点击放大预览"); if (blob.size > INLINE_HTML_MAX) throw new Error("文件过大,请点击放大预览");
return blob.text(); return blob.text();
}); });
_htmlArtifactCache.set(rel, p); _htmlArtifactCache.set(key, p);
return p; return p;
} }
@ -228,8 +244,10 @@ function _loadInlineHtml(node) {
if (!node || node.dataset.loaded) return; if (!node || node.dataset.loaded) return;
node.dataset.loaded = "1"; node.dataset.loaded = "1";
const rel = node.dataset.rel; const rel = node.dataset.rel;
const taskId = node.dataset.taskId || "";
const legacy = node.dataset.legacyPath === "1";
const viewport = node.querySelector(".art-html-viewport"); const viewport = node.querySelector(".art-html-viewport");
_fetchHtmlArtifact(rel).then((source) => { _fetchHtmlArtifact(rel, taskId, legacy).then((source) => {
if (!node.isConnected || !viewport) return; if (!node.isConnected || !viewport) return;
viewport.innerHTML = ""; viewport.innerHTML = "";
const frame = document.createElement("iframe"); const frame = document.createElement("iframe");
@ -276,8 +294,10 @@ export function upgradeMediaArtifacts(root) {
nodes.forEach((node) => { nodes.forEach((node) => {
node.dataset.upgraded = "1"; node.dataset.upgraded = "1";
const rel = node.dataset.rel; const rel = node.dataset.rel;
const taskId = node.dataset.taskId || "";
const legacy = node.dataset.legacyPath === "1";
const cat = node.dataset.cat; const cat = node.dataset.cat;
_fetchMediaBlobUrl(rel).then((url) => { _fetchMediaBlobUrl(rel, taskId, legacy).then((url) => {
node.innerHTML = ""; node.innerHTML = "";
if (cat === "image") { if (cat === "image") {
const img = document.createElement("img"); const img = document.createElement("img");
@ -303,8 +323,11 @@ export function upgradeMediaArtifacts(root) {
}); });
} }
export function downloadFile(rel) { export function downloadFile(rel, taskId = "", legacy = false) {
fetch("/v1/files/download?path=" + encodeURIComponent(rel), { const base = taskId
? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
: "/v1/files/download";
fetch(base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : ""), {
headers: { "Authorization": "Bearer " + state.token }, headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => { }).then(async (r) => {
if (!r.ok) { message("下载失败:" + r.status, "error"); return; } if (!r.ok) { message("下载失败:" + r.status, "error"); return; }

View File

@ -63,6 +63,8 @@ export function _categorize(rel) {
} }
let _fpCurrentRel = null; let _fpCurrentRel = null;
let _fpCurrentTaskId = "";
let _fpCurrentLegacy = false;
// Markdown / HTML 共用“预览 / 源文件”切换。HTML 在 opaque-origin sandbox iframe // Markdown / HTML 共用“预览 / 源文件”切换。HTML 在 opaque-origin sandbox iframe
// 中运行:可执行脚本、加载 HTTPS 资源,但不能读取 zcbot 页面或发起表单/顶层跳转。 // 中运行:可执行脚本、加载 HTTPS 资源,但不能读取 zcbot 页面或发起表单/顶层跳转。
@ -234,8 +236,24 @@ function _bindBodyWheel(bodyEl) {
}, { passive: false }); }, { passive: false });
} }
export async function openFilePreview(rel) { function _fileDownloadUrl(rel, taskId = "", legacy = false) {
const base = taskId
? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
: "/v1/files/download";
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "");
}
function _pptPreviewUrl(rel, taskId = "", legacy = false) {
const base = taskId
? `/v1/tasks/${encodeURIComponent(taskId)}/files/preview_pdf`
: "/v1/files/preview_pdf";
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "");
}
export async function openFilePreview(rel, taskId = "", legacy = false) {
_fpCurrentRel = rel; _fpCurrentRel = rel;
_fpCurrentTaskId = taskId;
_fpCurrentLegacy = legacy;
const name = rel.split("/").pop() || rel; const name = rel.split("/").pop() || rel;
$("fp-name").textContent = name; $("fp-name").textContent = name;
$("fp-meta").textContent = ""; $("fp-meta").textContent = "";
@ -254,9 +272,9 @@ export async function openFilePreview(rel) {
const cat = _categorize(rel); const cat = _categorize(rel);
// pptx/ppt:后端转 PDF 再复用现成 PDF iframe(非下载原文件),首次稍候 + 失败回退下载。 // pptx/ppt:后端转 PDF 再复用现成 PDF iframe(非下载原文件),首次稍候 + 失败回退下载。
if (cat === "ppt") { await _showPptAsPdf(rel, $("fp-body"), $("fp-meta"), _showFallback); return; } if (cat === "ppt") { await _showPptAsPdf(rel, $("fp-body"), $("fp-meta"), _showFallback, _trackBlobUrl, taskId, legacy); return; }
try { try {
const r = await fetch("/v1/files/download?path=" + encodeURIComponent(rel), { const r = await fetch(_fileDownloadUrl(rel, taskId, legacy), {
headers: { "Authorization": "Bearer " + state.token }, headers: { "Authorization": "Bearer " + state.token },
}); });
if (!r.ok) throw new Error("HTTP " + r.status); if (!r.ok) throw new Error("HTTP " + r.status);
@ -326,13 +344,13 @@ function _showPdf(blob) {
} }
// pptx/ppt → 后端转 PDF → iframe。main / mini 共用:传各自 body / meta / fallback / 追踪 blob 的 fn。 // pptx/ppt → 后端转 PDF → iframe。main / mini 共用:传各自 body / meta / fallback / 追踪 blob 的 fn。
async function _showPptAsPdf(rel, body, metaEl, fallbackFn, trackFn = _trackBlobUrl) { async function _showPptAsPdf(rel, body, metaEl, fallbackFn, trackFn = _trackBlobUrl, taskId = "", legacy = false) {
body.className = "body center"; body.className = "body center";
body.innerHTML = `<div class="ph"><div class="preview-spinner"></div>由 PPT 转换为 PDF · 首次稍候…</div>`; body.innerHTML = `<div class="ph"><div class="preview-spinner"></div>由 PPT 转换为 PDF · 首次稍候…</div>`;
if (metaEl) metaEl.textContent = ""; if (metaEl) metaEl.textContent = "";
let r; let r;
try { try {
r = await fetch("/v1/files/preview_pdf?path=" + encodeURIComponent(rel), { r = await fetch(_pptPreviewUrl(rel, taskId, legacy), {
headers: { "Authorization": "Bearer " + state.token }, headers: { "Authorization": "Bearer " + state.token },
}); });
} catch (e) { } catch (e) {
@ -437,7 +455,7 @@ function _showFallback(msg) {
dl.className = "primary"; dl.className = "primary";
dl.textContent = "下载原文件"; dl.textContent = "下载原文件";
dl.style.marginTop = "12px"; dl.style.marginTop = "12px";
dl.onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); }; dl.onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel, _fpCurrentTaskId, _fpCurrentLegacy); };
ph.appendChild(document.createElement("br")); ph.appendChild(document.createElement("br"));
ph.appendChild(br); ph.appendChild(br);
ph.appendChild(dl); ph.appendChild(dl);
@ -453,6 +471,8 @@ export function closeFilePreview() {
$("fp-body").innerHTML = ""; $("fp-body").innerHTML = "";
_flushBlobUrls(); _flushBlobUrls();
_fpCurrentRel = null; _fpCurrentRel = null;
_fpCurrentTaskId = "";
_fpCurrentLegacy = false;
} }
let _mpCurrentRel = null; let _mpCurrentRel = null;
@ -572,7 +592,7 @@ _bindBodyWheel($("fp-body"));
_bindBodyWheel($("mp-body")); _bindBodyWheel($("mp-body"));
$("fp-close").onclick = closeFilePreview; $("fp-close").onclick = closeFilePreview;
$("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); }; $("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel, _fpCurrentTaskId, _fpCurrentLegacy); };
$("fp-mode-preview").onclick = () => _renderTextMode("fp", "preview"); $("fp-mode-preview").onclick = () => _renderTextMode("fp", "preview");
$("fp-mode-source").onclick = () => _renderTextMode("fp", "source"); $("fp-mode-source").onclick = () => _renderTextMode("fp", "source");
$("file-preview-modal").addEventListener("click", (e) => { $("file-preview-modal").addEventListener("click", (e) => {