Compare commits

..

3 Commits

45 changed files with 1263 additions and 182 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.26 — 2026-08-03
- 默认 DeepSeek Flash 已使用官方 0731 API 升级,继续展示思考过程并显式控制推理强度;不同模型的思考开关不再依赖服务端默认值,切换模型时行为更稳定。
## 0.60.25 — 2026-08-03
- 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 够用。
模型思考参数由 profile 统一表达:`thinking_enabled` 只表示开关,`thinking_transport` 只表示已验证的传输协议,`reasoning_effort` 只表示开启后的推理强度;`core/llm_params.py` 是请求构造唯一入口。DeepSeek、GLM、方舟当前共享 `extra_body` 协议,未验证网关明确用 `none`、不猜参数协议,主循环不再按 family 分支。`/v1/models` 只返回语义明确的 `thinking_enabled`
---
## 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。
**对话产物引用(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)
统一 `/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
Files GET /v1/files?path= / upload / download / delete / rename
(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)
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 软重置窗口起点
deleted_at, -- 0010 软删
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))
usage_events(pk, user_id, task_id, message_id, kind, -- chat/image/video/vision/... 自由文本
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`
最后更新: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
- **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.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 依赖变化,无需重建沙箱镜像。

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/{name}` | 返某 skill 完整 SKILL.md 正文(前端「技能」modal 点开查看);同名按 user wins | 必填 |
| `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` | 必填 |
| `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 级,基本秒退 | 必填 |
@ -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 连不上时前端的兜底通道 | 必填 |
| `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 |
| `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/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 | 必填 |

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
# 走 OpenAI 兼容协议(litellm provider 前缀 `openai/`,后段为实际 model 字段透传给 base_url)。
# 涉密任务时用户显式选 local.r1 / local.qwq 代替默认 deepseek_v4.flash;不走自动路由。
# 两个 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
# 多半不认,报 400。
family: local
@ -18,7 +18,8 @@ variants:
max_output: 8192
parallel_tools: false
tool_calling_quality: fair
thinking_mode: false
thinking_enabled: false
thinking_transport: none
reasoning_effort_levels: []
default_reasoning_effort: ""
code_quality: good
@ -41,7 +42,8 @@ variants:
max_output: 8192
parallel_tools: false
tool_calling_quality: fair
thinking_mode: false
thinking_enabled: false
thinking_transport: none
reasoning_effort_levels: []
default_reasoning_effort: ""
code_quality: good

View File

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

View File

@ -1,3 +1,3 @@
# 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 再调用户消息里出现 `[用户上传的参考图] <路径>` = 用户贴了图,要看图 / 改图时用那个路径"""
_MEDIA_GPT_IMAGE_SEG = """\
- `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 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调"""
_MEDIA_DIAGRAM_FORK_SEG = """\
@ -412,6 +413,13 @@ def _build_system_prompt(
"完成后执行,所以调用后按“已登记、将在回复后完成”表述。\n"
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 时,仅当 host-side `office_to_pdf` 的工具说明列出该格式"
"才调用LibreOffice 在 backend host不在 Docker shell 内探测 `soffice`。\n"
@ -430,6 +438,7 @@ def _build_system_prompt(
f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n"
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n"
f"{rename_line}"
f"{publish_line}"
f"\n## 生成 Word / PDF 报告(验收 / 技术 / 评审报告等自由长文)\n"
f"**优先**把正文写成 Markdown(`<task_dir>/sections/*.md`,纯文本、零转义 / 零语法风险),"
f"再调平台渲染器直接出 docx 或 pdf —— **别在 run_python 里手撸文档转换脚本**"

View File

@ -15,7 +15,6 @@ import yaml
from core.paths import ROOT
_DOUBAO_YAML = ROOT / "config" / "media" / "doubao.yaml"
@ -75,7 +74,6 @@ class ArkClient:
base_url=cfg.base_url,
headers={
"Authorization": f"Bearer {cfg.api_key}",
"Content-Type": "application/json",
},
timeout=timeout_s,
)
@ -89,6 +87,28 @@ class ArkClient:
raise ArkTimeoutError(f"network error calling POST {path}: {e}") from e
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:
try:
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
from .llm_params import THINKING_TRANSPORTS
@dataclass
class ModelCapabilities:
@ -24,8 +26,10 @@ class ModelCapabilities:
parallel_tools: bool = False
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)
default_reasoning_effort: str = ""
@ -71,9 +75,27 @@ class ModelCapabilities:
f"档案 {path} 没有 variant={variant};可选: {list(variants)}"
)
var = variants[variant]
var = dict(variants[variant])
valid_keys = {f.name for f in fields(cls)}
kwargs = {k: v for k, v in var.items() if k in valid_keys}
kwargs["family"] = data.get("family", family)
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:
"""工具调用统一返回。
现状所有 `Tool.execute` 都返 str,docker backend 后续可能要带 stdout/stderr/
exit_code 分离这里先留单 content 字段(LLM 拿到的就是这串),exit_code
普通 `Tool.execute` str发布产物的工具可返字符串兼容的 rich result
`content` LLM 拿到的文本`artifacts` 只进事件和消息元数据exit_code
backend 内部使用 hint(0=ok / 1=tool 抛异常 / 2=参数非法 / 124=timeout ),
不影响 LLM 接口
"""
content: str
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):

View File

@ -596,7 +596,11 @@ class DockerExecutor(Executor):
def _compact_shell_like_result(self, result: ToolResult) -> ToolResult:
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):

View File

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

View File

@ -25,6 +25,7 @@ from litellm.exceptions import (
)
from .capabilities import ModelCapabilities
from .llm_params import build_thinking_kwargs
# 单次 LLM 请求超时(秒),默认与 litellm 一致(600s)但显式化 + env 可调 ──
# 长思考模型真被掐("600s 无字节 → run 标 error")时调大 ZCBOT_LLM_TIMEOUT_S 即可,
@ -64,18 +65,13 @@ class LLM:
kwargs["tools"] = tools
if self.caps.parallel_tools and parallel_tool_calls is not False:
kwargs["parallel_tool_calls"] = True
if self.caps.thinking_mode and reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort
# GLM(zai)的 thinking 网关侧默认开着 —— 重任务上把整个输出预算烧在 reasoning_content
# 上、撞满模型自带输出上限(65536)被截断,回来 content 空 + 无 tool_call,被 loop 判空
# 响应整轮丢弃 + 无效重试(task 35744bea 案:5 次 empty 全 tokens_out=65536,重试同上下文
# 再撞)。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"}
}
kwargs.update(
build_thinking_kwargs(
enabled=self.caps.thinking_enabled,
transport=self.caps.thinking_transport,
reasoning_effort=reasoning_effort,
)
)
if self.caps.prompt_caching:
kwargs["extra_headers"] = {"anthropic-beta": "prompt-caching-2024-07-31"}
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 .executor import ExecCtx, Executor
from .artifacts import MAX_ARTIFACTS_PER_MESSAGE
from .llm import LLM
from .llm_transport import (
extract_delta_content,
@ -249,6 +250,9 @@ class AgentLoop:
self._repeat_guard = _RepeatGuard()
# 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。
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:
if self.sink is not None:
@ -279,6 +283,7 @@ class AgentLoop:
return self._run(None)
def _run(self, user_message: Optional[str]) -> str:
self._pending_artifact_refs = []
self._maybe_fold_context()
if user_message is not None:
self.session.append({"role": "user", "content": user_message})
@ -299,7 +304,11 @@ class AgentLoop:
return "[cancelled]"
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))
pt, ct = usage_details["tokens_in"], usage_details["tokens_out"]
@ -339,7 +348,6 @@ class AgentLoop:
"elapsed": elapsed,
})
tool_calls = getattr(msg, "tool_calls", None) or []
# content 已通过 stream 流式 emit 过 delta,这里不再 emit 整段 text 事件。
if not tool_calls:
@ -363,7 +371,8 @@ class AgentLoop:
self._fill_cancelled_tool_results(tool_calls[i:])
self._emit({"type": "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
self.session.append(
{
@ -674,7 +683,7 @@ class AgentLoop:
pass
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,返回 (结果文本, 本次是否有净产出)。
净产出供 run loop 的全局无进展熔断判定
@ -686,7 +695,7 @@ class AgentLoop:
try:
args = json.loads(raw_args)
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)
if len(args_preview) > 200:
@ -700,7 +709,7 @@ class AgentLoop:
blocked = self._check_repeat_block(name, args)
if blocked is not None:
return blocked, False
return blocked, False, ()
ctx = ExecCtx(
user_id=self.user_id,
@ -709,7 +718,8 @@ class AgentLoop:
cancel_check=self.cancel_check,
)
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
MAX_LEN = 16_000
@ -729,8 +739,24 @@ class AgentLoop:
"result": result,
"preview": preview,
"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]:
"""执行前的两道拦截(命中返回拦截话术,未命中返 None):

View File

@ -6,7 +6,7 @@
四项探测:
- basic_chat:连通性失败则跳过其余
- 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
"""
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:
declared = caps.thinking_mode
def probe_thinking(llm: LLM, caps: ModelCapabilities) -> ProbeResult:
declared = caps.thinking_enabled
if not declared:
return ProbeResult(
name="thinking_mode",
name="thinking",
declared=False,
observed=None,
status="skip",
@ -145,7 +145,7 @@ def probe_thinking_mode(llm: LLM, caps: ModelCapabilities) -> ProbeResult:
)
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:
resp = llm.chat(
@ -162,22 +162,22 @@ def probe_thinking_mode(llm: LLM, caps: ModelCapabilities) -> ProbeResult:
)
observed = bool(rc)
return ProbeResult(
name="thinking_mode",
name="thinking",
declared=True,
observed=observed,
status="ok" if observed else "mismatch",
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")
),
)
except Exception as e:
return ProbeResult(
name="thinking_mode",
name="thinking",
declared=True,
observed=False,
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":
return report
report.add(probe_parallel_tools(llm, caps))
report.add(probe_thinking_mode(llm, caps))
report.add(probe_thinking(llm, caps))
if include_long_context:
report.add(probe_long_context(llm, caps))
return report

View File

@ -61,7 +61,12 @@ class Session:
self.messages.append({"role": "system", "content": system_prompt})
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。
前置条件:tasks 行已由 web 入口(`POST /v1/tasks` `ensure_local_task_row`)写入;
@ -96,6 +101,7 @@ class Session:
task_id=self.task_id,
idx=self._db_idx,
payload=msg_dict,
artifact_refs=artifact_refs,
)
s.add(row)
s.flush() # 触发 INSERT 拿到 server-default 生成的 message_id

View File

@ -170,6 +170,10 @@ class Message(Base):
)
idx: Mapped[int] = mapped_column(Integer, 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_out: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# 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:
from tools.publish_artifacts import PublishArtifactsTool
return [
RenameWorkingDirTool(
ctx.deferred_actions,
working_dir=ctx.working_dir_path,
**base,
)
),
PublishArtifactsTool(
working_dir=ctx.working_dir_path,
**wd_base,
),
]
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
fails = 0
from core.ark_client import ArkConfig
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")
ark = ArkConfig.load()
@ -44,6 +44,12 @@ def main() -> int:
f"{image_cfg.get('model_id') or 'MISSING'}"
)
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
cases = [
@ -66,8 +72,13 @@ def main() -> int:
and "`seedream`" not in blk.split("\n")[0]
and "size" 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
blk2 = _media_tools_block(ark is not None, "seedream")
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, /configureHtmlPreviewFrame\(frame, source/);
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 expandHandler = chatJs.indexOf('e.target.closest(".art-html-open[data-rel]")');
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 json
import struct
import tempfile
import unittest
@ -6,7 +7,9 @@ import uuid
from pathlib import Path
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
@ -16,6 +19,7 @@ def _png_stub(width: int = 1536, height: int = 864) -> bytes:
class _FakeArkClient:
json_call = None
multipart_call = None
def __init__(self, *_args, **_kwargs):
pass
@ -33,10 +37,51 @@ class _FakeArkClient:
"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):
def setUp(self):
_FakeArkClient.json_call = None
_FakeArkClient.multipart_call = None
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.working_dir = self.root / "task"
@ -44,6 +89,7 @@ class GptImageToolTests(unittest.TestCase):
self.cfg = {
"model_id": "gpt-image-2",
"endpoint": "/images/generations",
"edit_endpoint": "/images/edits",
"default_size": "auto",
"default_quality": "auto",
"request_timeout_s": 300,
@ -86,6 +132,69 @@ class GptImageToolTests(unittest.TestCase):
self.assertEqual(body["quality"], "high")
self.assertIn("size=1536x864", 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):
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 types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import patch
from uuid import uuid4
from core.loop import AgentLoop
@ -14,10 +15,12 @@ class _Session:
def __init__(self, messages=None):
self.messages = list(messages or [])
self.appended = []
self.append_artifacts = []
def append(self, message):
def append(self, message, *, artifact_refs=None):
self.messages.append(message)
self.appended.append(message)
self.append_artifacts.append(artifact_refs)
return uuid4()
@ -60,6 +63,44 @@ class PersistedTurnTests(unittest.TestCase):
[{"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__":
unittest.main()

View File

@ -111,7 +111,7 @@ class TestLoopHotSwap(unittest.TestCase):
return "glm.pro52", new_caps, new_llm
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.assertIs(loop.caps, new_caps)
@ -127,7 +127,7 @@ class TestLoopHotSwap(unittest.TestCase):
def test_no_switch_when_switcher_returns_none(self):
loop = _make_loop("[skill=ppt, dir=x]\n# PPT", lambda n, c: None)
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.llm, old_llm)
self.assertNotIn("[模型切换]", result)
@ -148,7 +148,7 @@ class TestLoopHotSwap(unittest.TestCase):
loop = _make_loop("[skill=ppt, dir=x]\n# PPT", switcher)
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.llm, old_llm)
self.assertNotIn("[模型切换]", result)

View File

@ -347,6 +347,38 @@ class FilesDbAwareTests(unittest.TestCase):
self.assertTrue(d["working_dir"].endswith("/改名后目录"))
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):
tid = self._mk_task("跑动中任务", "跑动中目录")
_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`,
tool 只装配:
- 文生图走 /images/generations JSON,gpt-image-2 支持自定义尺寸和质量档;
- 当前 unifyllm 网关的 /images/edits 不可用,暂不暴露改图参数;
- 单图改图走 /images/edits multipart/form-data;
- 响应直接返 b64_json,无需二次下载;
- 复杂图片可能需 ~2min
完成后:
@ -24,9 +24,11 @@ from typing import Optional
from uuid import UUID
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 .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
@ -34,10 +36,10 @@ class GptImageTool(Tool):
name = "gpt_image"
description = (
"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 "
"available through the current gateway; ask the user to switch to 豆包 Seedream for "
"editing an existing image. Don't generate decoratively — only when the user actually "
"wants an image. Returns the saved relative path."
"and quality, plus single-reference image editing. For editing, pass reference_images "
"with the existing image path; the original is preserved and the result is saved as a "
"new image. Complex images may take up to ~2 minutes. Don't generate decoratively — "
"only when the user actually wants an image. Returns the saved relative path."
)
parameters = {
"type": "object",
@ -46,6 +48,14 @@ class GptImageTool(Tool):
"type": "string",
"description": "中文或英文都行,详尽描述画面(主体/风格/光线/构图)。",
},
"reference_images": {
"type": "array",
"items": {"type": "string"},
"description": (
"改图(image-to-image):传 1 张 task_dir 内已存在图片的相对路径。"
"不传 = 从零文生图;当前仅支持单张参考图。"
),
},
"size": {
"type": "string",
"description": (
@ -88,12 +98,36 @@ class GptImageTool(Tool):
def execute(
self,
prompt: str,
reference_images: list | None = None,
size: Optional[str] = None,
quality: Optional[str] = None,
) -> str:
if not (prompt or "").strip():
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
chosen_size, size_err = self._normalize_size(
size if size is not None else cfg.get("default_size", "auto")
@ -114,7 +148,11 @@ class GptImageTool(Tool):
return quota_err
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))
price = float(cfg.get("price_cny_per_image", 0))
@ -129,7 +167,20 @@ class GptImageTool(Tool):
t0 = time.monotonic()
try:
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:
return f"[Error] gpt_image API: {e}"
@ -137,6 +188,10 @@ class GptImageTool(Tool):
b64 = ""
if isinstance(data, list) and data and isinstance(data[0], dict):
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:
return f"[Error] gpt_image response 缺 b64_json: {json.dumps(resp, ensure_ascii=False)[:300]}"
try:
@ -161,7 +216,8 @@ class GptImageTool(Tool):
"requested_size": chosen_size,
"quality": actual_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,
"output_tokens": output_tokens,
"elapsed_s": round(elapsed, 2),
@ -184,12 +240,20 @@ class GptImageTool(Tool):
# 首行 banner 协议同 seedream(`key=value · ` 分隔,前端 extractMediaBanner 解析);
# 价格未知(price=0)时不放 cost 段,避免"¥0.00 = 免费"的误导。
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"{cost_seg} · elapsed={elapsed:.1f}s\n"
f"saved: {disp}\n"
f"{cost_seg} · elapsed={elapsed:.1f}s{mode_seg}\n"
f"saved: {disp}{ref_line}\n"
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
def _normalize_size(raw: object) -> tuple[str, str]:

View File

@ -15,6 +15,7 @@ from pathlib import Path
from typing import Optional
from uuid import uuid4
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from tools.base import FileOutOfBounds, Tool
from web.pptx_render import SofficeNotFoundError, find_soffice
@ -147,7 +148,7 @@ class OfficeToPdfTool(Tool):
return root_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:
src = self._resolve_office_path(source, existing=True)
except FileOutOfBounds:
@ -218,4 +219,13 @@ class OfficeToPdfTool(Tool):
except OSError as 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 uuid import UUID
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import record_video_usage
@ -388,7 +389,7 @@ class SeedanceTool(Tool):
image_banner += f" · image={first_frame_display}"
# banner 协议与 seedream 一致:首行 `[tool] key=value · key=value ...`
# 前端 extractMediaBanner 已 whitelist seedance,正则抓 key=value 挂徽章
return (
result = (
f"[seedance] model={model_id} · mode={mode}{image_banner} · "
f"resolution={chosen_resolution} · ratio={chosen_ratio} · "
f"duration={chosen_duration}s · audio={chosen_generate_audio} · "
@ -397,6 +398,12 @@ class SeedanceTool(Tool):
f"prompt={prompt!r}\n"
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
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 uuid import UUID
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
from core.ark_client import ArkClient, ArkConfig, ArkError
from core.storage.usage import record_image_usage
@ -222,13 +223,19 @@ class SeedreamTool(Tool):
mode_seg = " · mode=i2i" 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 ""
return (
result = (
f"[seedream] model={model_id} · size={chosen_size} · "
f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s{mode_seg}\n"
f"saved: {disp}{ref_line}\n"
f"prompt={prompt!r}\n"
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
def _normalize_size(

View File

@ -14,7 +14,7 @@ from fastapi import Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
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.models import Task
from core.working_dirs import (
@ -45,6 +45,88 @@ def _pptx_lock_for(abs_path: str) -> asyncio.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:
@app.get("/v1/user/storage", tags=["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)。"""
root = load_user_root(user_id)
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 默认能缓数小时)
# 否则文件改了 SPA 预览还是旧内容
# (Starlette FileResponse 不实现 304, 总是 200 全量; workspace 文件小, 可接受)
# .svg 显式给 image/svg+xml: 部分部署环境 mimetypes 未注册 svg, FileResponse
# 会猜成 octet-stream, 前端 <img> 就渲染不出 SVG 预览
media_type = None
if target.suffix.lower() == ".svg":
media_type = "image/svg+xml"
return FileResponse(
path=str(target),
filename=target.name,
media_type=media_type,
headers={"Cache-Control": "no-cache"},
)
return _regular_file_response(target, path)
@app.get("/v1/tasks/{task_id}/files/download", tags=["files"])
def download_task_file(
task_id: str,
path: str,
legacy: bool = False,
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"])
async def preview_pdf(
@ -134,35 +217,22 @@ def register_file_routes(app, *, require_user) -> None:
转换跑在 backend host(不进沙盒),按需触发 + 缓存到 `.preview/`(DESIGN §8.3)
soffice 缺失 501;转换失败/超时 500;前端据此回退到下载
"""
from ..pptx_render import (
PptxConvertError,
SofficeNotFoundError,
pptx_to_pdf,
)
root = load_user_root(user_id)
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}")
if target.suffix.lower() not in (".pptx", ".ppt"):
raise HTTPException(400, f"not a pptx: {path}")
return await _pptx_preview_response(target, 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"},
)
@app.get("/v1/tasks/{task_id}/files/preview_pdf", tags=["files"])
async def preview_task_pdf(
task_id: str,
path: str,
legacy: bool = False,
user_id: UUID = Depends(require_user),
):
"""Preview a PPT 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 await _pptx_preview_response(target, path)
@app.post("/v1/files/upload", tags=["files"])
async def upload_files(

View File

@ -99,6 +99,7 @@ def register_message_routes(app, *, require_user) -> None:
cols = (
Message.idx, Message.payload, Message.tokens_in,
Message.tokens_out, Message.model_profile, Message.created_at,
Message.artifact_refs,
)
if limit is None:
# 旧行为:升序全量
@ -146,6 +147,7 @@ def register_message_routes(app, *, require_user) -> None:
"tokens_out": r.tokens_out,
"model_profile": r.model_profile, # 0006:assistant 行非空,标产生该 msg 的模型
"created_at": iso(r.created_at),
"artifact_refs": r.artifact_refs,
}
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,
"family": caps.family,
"variant": caps.variant,
"thinking_mode": caps.thinking_mode,
"thinking_enabled": caps.thinking_enabled,
"is_default": profile == default,
})
return {"models": out}

View File

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

View File

@ -35,6 +35,8 @@ export function toolActivityLabel(name, args) {
case "web_search": return `联网搜索: ${clip(a.query, 60)}`;
case "load_skill": return `加载技能: ${clip(a.name, 40)}`;
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 "gpt_image": 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。
// 产物工具传 trueassistant 正文传 "html",避免重复内联图片/视频但让通用工具生成的
// HTML 有展示位;普通工具结果传 false引用到的文件仍走 chip避免把旧产物铺满消息。
export function renderArtifactBarHtml(rels, inlineMode = true) {
export function renderArtifactBarHtml(rels, inlineMode = true, taskId = "", legacy = false) {
if (!rels || !rels.length) return "";
const items = rels.map((rel) => {
const name = rel.split("/").pop() || rel;
const taskAttr = taskId ? ` data-task-id="${escapeHtml(taskId)}"` : "";
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);
if ((inlineMode === true || inlineMode === "html") && cat === "html") {
return `<section class="art-html" data-rel="${escapeHtml(rel)}" 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>
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)}"${taskAttr}${legacyAttr}></button></div>
<div class="art-html-viewport"><span class="art-media-loading">进入可视区域后加载</span></div>
</section>`;
}
if (inlineMode === true && (cat === "image" || cat === "video")) {
// 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。
// 不在这里发请求避免 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("");
return `<div class="artifact-bar">${items}</div>`;
}
@ -196,22 +203,31 @@ const _mediaArtifactCache = new Map();
const _htmlArtifactCache = new Map();
const INLINE_HTML_MAX = 2 * 1024 * 1024;
function _fetchMediaBlobUrl(rel) {
if (_mediaArtifactCache.has(rel)) return _mediaArtifactCache.get(rel);
const p = fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
function _artifactDownloadUrl(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 _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 },
}).then(async (r) => {
if (!r.ok) throw new Error("HTTP " + r.status);
const blob = await r.blob();
return URL.createObjectURL(blob);
});
_mediaArtifactCache.set(rel, p);
_mediaArtifactCache.set(key, p);
return p;
}
function _fetchHtmlArtifact(rel) {
if (_htmlArtifactCache.has(rel)) return _htmlArtifactCache.get(rel);
const p = fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
function _fetchHtmlArtifact(rel, taskId = "", legacy = false) {
const key = `${taskId}:${legacy ? "legacy:" : ""}${rel}`;
if (_htmlArtifactCache.has(key)) return _htmlArtifactCache.get(key);
const p = fetch(_artifactDownloadUrl(rel, taskId, legacy), {
headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => {
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("文件过大,请点击放大预览");
return blob.text();
});
_htmlArtifactCache.set(rel, p);
_htmlArtifactCache.set(key, p);
return p;
}
@ -228,8 +244,10 @@ function _loadInlineHtml(node) {
if (!node || node.dataset.loaded) return;
node.dataset.loaded = "1";
const rel = node.dataset.rel;
const taskId = node.dataset.taskId || "";
const legacy = node.dataset.legacyPath === "1";
const viewport = node.querySelector(".art-html-viewport");
_fetchHtmlArtifact(rel).then((source) => {
_fetchHtmlArtifact(rel, taskId, legacy).then((source) => {
if (!node.isConnected || !viewport) return;
viewport.innerHTML = "";
const frame = document.createElement("iframe");
@ -276,8 +294,10 @@ export function upgradeMediaArtifacts(root) {
nodes.forEach((node) => {
node.dataset.upgraded = "1";
const rel = node.dataset.rel;
const taskId = node.dataset.taskId || "";
const legacy = node.dataset.legacyPath === "1";
const cat = node.dataset.cat;
_fetchMediaBlobUrl(rel).then((url) => {
_fetchMediaBlobUrl(rel, taskId, legacy).then((url) => {
node.innerHTML = "";
if (cat === "image") {
const img = document.createElement("img");
@ -303,8 +323,11 @@ export function upgradeMediaArtifacts(root) {
});
}
export function downloadFile(rel) {
fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
export function downloadFile(rel, taskId = "", legacy = false) {
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 },
}).then(async (r) => {
if (!r.ok) { message("下载失败:" + r.status, "error"); return; }

View File

@ -63,6 +63,8 @@ export function _categorize(rel) {
}
let _fpCurrentRel = null;
let _fpCurrentTaskId = "";
let _fpCurrentLegacy = false;
// Markdown / HTML 共用“预览 / 源文件”切换。HTML 在 opaque-origin sandbox iframe
// 中运行:可执行脚本、加载 HTTPS 资源,但不能读取 zcbot 页面或发起表单/顶层跳转。
@ -234,8 +236,24 @@ function _bindBodyWheel(bodyEl) {
}, { 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;
_fpCurrentTaskId = taskId;
_fpCurrentLegacy = legacy;
const name = rel.split("/").pop() || rel;
$("fp-name").textContent = name;
$("fp-meta").textContent = "";
@ -254,9 +272,9 @@ export async function openFilePreview(rel) {
const cat = _categorize(rel);
// 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 {
const r = await fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
const r = await fetch(_fileDownloadUrl(rel, taskId, legacy), {
headers: { "Authorization": "Bearer " + state.token },
});
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。
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.innerHTML = `<div class="ph"><div class="preview-spinner"></div>由 PPT 转换为 PDF · 首次稍候…</div>`;
if (metaEl) metaEl.textContent = "";
let r;
try {
r = await fetch("/v1/files/preview_pdf?path=" + encodeURIComponent(rel), {
r = await fetch(_pptPreviewUrl(rel, taskId, legacy), {
headers: { "Authorization": "Bearer " + state.token },
});
} catch (e) {
@ -437,7 +455,7 @@ function _showFallback(msg) {
dl.className = "primary";
dl.textContent = "下载原文件";
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(br);
ph.appendChild(dl);
@ -453,6 +471,8 @@ export function closeFilePreview() {
$("fp-body").innerHTML = "";
_flushBlobUrls();
_fpCurrentRel = null;
_fpCurrentTaskId = "";
_fpCurrentLegacy = false;
}
let _mpCurrentRel = null;
@ -572,7 +592,7 @@ _bindBodyWheel($("fp-body"));
_bindBodyWheel($("mp-body"));
$("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-source").onclick = () => _renderTextMode("fp", "source");
$("file-preview-modal").addEventListener("click", (e) => {