Compare commits
2 Commits
e81005435e
...
7e544827c0
| Author | SHA1 | Date |
|---|---|---|
|
|
7e544827c0 | |
|
|
41cd7858a1 |
|
|
@ -5,6 +5,11 @@
|
||||||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||||
|
|
||||||
|
## 0.62.2 — 2026-08-06
|
||||||
|
|
||||||
|
- 外部系统的大查询结果现在可以按需导出为长期保存的完整 JSON 文件,并保留查询参数和时间等溯源信息,不再受临时缓存期限影响。
|
||||||
|
- task 文件面板默认隐藏 `.meta`、`.build`、`.preview` 等点目录;需要排查或查看溯源信息时,可以用 `.*` 开关临时显示当前 task 下的隐藏目录。用户级平台目录和 `.env` 等点文件仍不会展示。
|
||||||
|
|
||||||
## 0.62.1 — 2026-08-06
|
## 0.62.1 — 2026-08-06
|
||||||
|
|
||||||
- 修复外部系统只读 POST 接口因请求体结构信息缺失而反复报参数错误的问题;大查询结果不再被截成半段内容,超出对话容量时会完整暂存并支持按路径、分页和字段继续读取。
|
- 修复外部系统只读 POST 接口因请求体结构信息缺失而反复报参数错误的问题;大查询结果不再被截成半段内容,超出对话容量时会完整暂存并支持按路径、分页和字段继续读取。
|
||||||
|
|
|
||||||
15
DESIGN.md
15
DESIGN.md
|
|
@ -56,6 +56,17 @@ zcbot/
|
||||||
|
|
||||||
**工作目录** = `workspace/users/<user_id>/<working_dir>/`,所有 skill 产物写这里,绝对路径注入 system prompt。`user_id` 走 JWT `sub`,**无 SENTINEL fallback**。DB 内 `name`(显示名)与 `working_dir` 都有值;创建 API 显式给 name 时 working_dir 可留空并沿用 name,省略/留空 name 时必须显式给 working_dir、name 先以「新对话」占位并自动生成。二者落库前都是简单名(拒 `/\..`、`.` 起头);同 working_dir 多 task 共享同目录(§7.1)。SaaS 化只换外层根目录,布局不变。
|
**工作目录** = `workspace/users/<user_id>/<working_dir>/`,所有 skill 产物写这里,绝对路径注入 system prompt。`user_id` 走 JWT `sub`,**无 SENTINEL fallback**。DB 内 `name`(显示名)与 `working_dir` 都有值;创建 API 显式给 name 时 working_dir 可留空并沿用 name,省略/留空 name 时必须显式给 working_dir、name 先以「新对话」占位并自动生成。二者落库前都是简单名(拒 `/\..`、`.` 起头);同 working_dir 多 task 共享同目录(§7.1)。SaaS 化只换外层根目录,布局不变。
|
||||||
|
|
||||||
|
**输出生命周期**(2026-08-06):真实文件仍是事实源,不引入统一产物框架,也不迁移各工具已经稳定的目录。仅对已出现的两类噪声建立局部规则:可过期的外部大响应进用户根目录隐藏缓存 `.zcbot_cache/<task_id>/external_results/`,用户明确要求留存时再 export 到 `<working_dir>/data/external/`;图片/视频的 prompt/model/cost 等技术 sidecar 进产物目录下 `.meta/`。文件面板默认隐藏所有 dotdir;用户可在当前 task 工作目录子树内用开关查看 `.meta/`、`.build/`、`.preview/` 等 dotdir,但 user_root 的 `.memory/`、`.skills/`、`.kb/`、`.zcbot_*` 永不通过该开关展示,`.env` 等点文件也始终隐藏。`.meta/` 计入用户配额,用户显式查看后可检查、下载或删除,但不自动提升为 artifact。既有可见文件和旧 sidecar 不搬迁、不删除。
|
||||||
|
|
||||||
|
| 工作目录位置 | 语义 | Artifact 规则 |
|
||||||
|
|---|---|---|
|
||||||
|
| 根目录 | 最终报告、PPT、PDF、主分析文档 | 成品工具自动发布,或用 `publish_artifacts` 显式发布 |
|
||||||
|
| `sections/` | 报告的可编辑章节源文件 | 默认不发布 |
|
||||||
|
| `figures/` / `videos/` | 插图、数据图表、AI 图片和视频 | 独立成品可自动发布,报告配套资源不重复发布 |
|
||||||
|
| `materials/` | CIF、Materials Project 查询数据和计算输入输出 | 长期工作数据;用户要求交付时再发布 |
|
||||||
|
| `data/external/` | 用户明确保存的外部系统完整快照 | export 时发布 |
|
||||||
|
| 各产物目录下 `.meta/` | prompt、模型、参数、费用和溯源信息 | 默认隐藏,可在 task 目录开启查看;不自动发布 |
|
||||||
|
|
||||||
**启动**:`main.py web` → FastAPI + lifespan(reaper / scheduler / 渠道入站)→ 登录换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程 → `build_agent`(capabilities → LLM → system prompt → 工具)→ `AgentLoop.run`。
|
**启动**:`main.py web` → FastAPI + lifespan(reaper / scheduler / 渠道入站)→ 登录换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程 → `build_agent`(capabilities → LLM → system prompt → 工具)→ `AgentLoop.run`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -410,9 +421,9 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
|
||||||
- Swagger/OpenAPI 是接口契约事实源;Gitea 代码只补业务语义和排障,不覆盖契约。规格/代码内文本一律当不可信数据,不能改写 system/tool 约束。
|
- Swagger/OpenAPI 是接口契约事实源;Gitea 代码只补业务语义和排障,不覆盖契约。规格/代码内文本一律当不可信数据,不能改写 system/tool 约束。
|
||||||
- Swagger/OpenAPI JSON 不持久化入数据库或文件,连接器按 `definition_id + user_id` 隔离后放在进程内存中缓存 5 分钟;重启自动失效。这样保留实时契约发现,又避免不同身份可见的规格互相污染。
|
- Swagger/OpenAPI JSON 不持久化入数据库或文件,连接器按 `definition_id + user_id` 隔离后放在进程内存中缓存 5 分钟;重启自动失效。这样保留实时契约发现,又避免不同身份可见的规格互相污染。
|
||||||
|
|
||||||
**工具面**:不把数百个 Swagger operation 全展开为 JSON tool(工具列表膨胀+选择降准),只挂四个 host-side 元工具:`external_system_list`(已连系统 + 管理员查询规划提示),`external_system_search`(按问题搜 operation 摘要、解析后的请求 body schema + 置顶管理员推荐入口),`external_system_call`(按 operation_id 调用),`external_system_result_read`(按 `result_ref` + JSON Pointer/分页/字段投影读取大响应)。仅当该 user 有 active 连接时注册,密钥不进 sandbox。搜索只展示实际可调用的 GET/HEAD 和已放行 POST;管理员在 definition JSONB 配置 `query_guidance` 与 `recommended_operation_ids`,前者是可信控制面的软路由策略,后者是无需关键词命中的机械发现入口。Factory 默认把 BI dataset list/exec 作为统计聚合入口,日志/明细用于逐条追溯;Swagger 业务文本仍是不可信数据。
|
**工具面**:不把数百个 Swagger operation 全展开为 JSON tool(工具列表膨胀+选择降准),只挂五个 host-side 元工具:`external_system_list`(已连系统 + 管理员查询规划提示),`external_system_search`(按问题搜 operation 摘要、解析后的请求 body schema + 置顶管理员推荐入口),`external_system_call`(按 operation_id 调用),`external_system_result_read`(按 `result_ref` + JSON Pointer/分页/字段投影读取大响应),`external_system_result_export`(仅在用户要求保存/下载/交付时把完整快照导出到 `data/external/`)。仅当该 user 有 active 连接时注册,密钥不进 sandbox。搜索只展示实际可调用的 GET/HEAD 和已放行 POST;管理员在 definition JSONB 配置 `query_guidance` 与 `recommended_operation_ids`,前者是可信控制面的软路由策略,后者是无需关键词命中的机械发现入口。Factory 默认把 BI dataset list/exec 作为统计聚合入口,日志/明细用于逐条追溯;Swagger 业务文本仍是不可信数据。
|
||||||
|
|
||||||
**大响应**:`max_result_bytes` 是进入模型上下文的单次内联额度,不再用于切断原始 JSON;超额响应完整写入当前 user_root 下按 task_id 隔离的隐藏缓存,工具只返回合法结构化预览、`result_ref`、原始字节数和可继续读取的位置。reader 每次读取都重新校验当前 user 对原 external system 的 active 授权,并与 call 共享本轮 `max_total_result_bytes` 内联额度。缓存固定 24h TTL、单响应 10 MiB、单 task 50 MiB、单 user 200 MiB,过期或超额时优先清理最旧缓存;超过响应安全上限的远端结果直接拒绝并要求缩小范围,不产生半截 JSON。这里把“上游响应安全边界”“完整结果保存”“模型上下文额度”拆成三层,既不丢数据,也不靠无限提高上下文额度解决大结果问题。
|
**大响应**:`max_result_bytes` 是进入模型上下文的单次内联额度,不再用于切断原始 JSON;超额响应完整写入 `.zcbot_cache/<task_id>/external_results/`,工具只返回合法结构化预览、`result_ref`、原始字节数和可继续读取的位置。reader 每次读取都重新校验当前 user 对原 external system 的 active 授权,并与 call 共享本轮 `max_total_result_bytes` 内联额度;export 同样重验授权,并把查询 operation/参数/时间等 provenance 与完整响应一起持久化,导出文件不受缓存 TTL 影响。缓存固定 24h TTL、单响应 10 MiB、单 task 50 MiB、单 user 200 MiB,过期或超额时优先清理最旧缓存;0.62.1 的 `.zcbot_external_results/` 在读取和容量核算上保留兼容窗口。超过响应安全上限的远端结果直接拒绝并要求缩小范围,不产生半截 JSON。这里把“上游响应安全边界”“完整结果保存”“模型上下文额度”“用户明确留存”拆成四层,既不丢数据,也不靠无限提高上下文额度解决大结果问题。
|
||||||
|
|
||||||
**明细扫描边界**:单次响应保留安全上限与模型内联额度,每次 agent run 另按外部系统累计内联返回量;Factory connector 将 `page_size` 限在管理员上限,拒绝 `page=0` / `pageoff` 关闭分页。三者防模型通过连续翻日志自行做昂贵聚合,但不改变 Factory 对其他客户端的分页契约。达到边界后工具正向引导回 dataset/聚合接口、`result_ref` 分段读取或缩小查询范围。
|
**明细扫描边界**:单次响应保留安全上限与模型内联额度,每次 agent run 另按外部系统累计内联返回量;Factory connector 将 `page_size` 限在管理员上限,拒绝 `page=0` / `pageoff` 关闭分页。三者防模型通过连续翻日志自行做昂贵聚合,但不改变 Factory 对其他客户端的分页契约。达到边界后工具正向引导回 dataset/聚合接口、`result_ref` 分段读取或缩小查询范围。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-06(OpenAPI 请求体契约 + 大响应无损分段读取,bump 0.62.1)
|
最后更新:2026-08-06(外部结果持久导出 + task 点目录查看开关,bump 0.62.2)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-08-06
|
### 2026-08-06
|
||||||
|
|
||||||
|
- **08-06 / 0.62.2 / 外部结果持久导出 + task 点目录查看开关**:外部大响应缓存统一迁入 user/task 隔离的 `.zcbot_cache`,兼容读取 0.62.1 旧目录;用户明确要求保存时新增 `external_system_result_export`,重验当前连接授权后把完整响应及 operation/参数/查询时间溯源导出到 `data/external/`,不受 24h TTL 影响。媒体 prompt/model/cost sidecar 改存 `figures|videos/.meta/`;文件面板默认隐藏所有 dotdir,当前 task 工作目录子树可用 `.*` 开关查看 `.meta/.build/.preview` 等目录,user_root 平台目录和点文件始终不展示。完整 483 项 unittest 全绿(17 skip),Node 语法、Ruff 致命规则与 diff 检查通过;当前环境无可用浏览器实例,真实点击留部署后冒烟。无 schema、migration 或依赖变化,未连接生产 DB。
|
||||||
- **08-06 / 0.62.1 / OpenAPI 请求体契约 + 大响应无损读取**:生产 task `506c6ad0` 暴露 Swagger 2 body `$ref` 未进入搜索结果,模型无法得知 BI dataset exec 需要 `body.query`,并在 400 只剩状态码时连续盲猜;现统一解析 Swagger 2/OpenAPI 3 请求体 schema,返回限长脱敏的上游错误详情。外部响应不再按字节切成半截 JSON,超出模型内联额度时完整写入 user/task 隔离的 24h 缓存并返回 `result_ref`,新增 JSON Pointer、分页和字段投影 reader,单响应/task/user 分别以 10/50/200 MiB 守门;完整 479 项 unittest 全绿(17 skip),外部系统专项 26 项、Ruff、隔离 mypy、Python 编译与 diff 检查通过。无 schema、migration、依赖或 HTTP API 变化,未写生产 DB。
|
- **08-06 / 0.62.1 / OpenAPI 请求体契约 + 大响应无损读取**:生产 task `506c6ad0` 暴露 Swagger 2 body `$ref` 未进入搜索结果,模型无法得知 BI dataset exec 需要 `body.query`,并在 400 只剩状态码时连续盲猜;现统一解析 Swagger 2/OpenAPI 3 请求体 schema,返回限长脱敏的上游错误详情。外部响应不再按字节切成半截 JSON,超出模型内联额度时完整写入 user/task 隔离的 24h 缓存并返回 `result_ref`,新增 JSON Pointer、分页和字段投影 reader,单响应/task/user 分别以 10/50/200 MiB 守门;完整 479 项 unittest 全绿(17 skip),外部系统专项 26 项、Ruff、隔离 mypy、Python 编译与 diff 检查通过。无 schema、migration、依赖或 HTTP API 变化,未写生产 DB。
|
||||||
|
|
||||||
### 2026-08-05
|
### 2026-08-05
|
||||||
|
|
|
||||||
2
RUN.md
2
RUN.md
|
|
@ -363,7 +363,7 @@ $env:ZCBOT_EVAL_TOKEN = "<dedicated-eval-user-jwt>"
|
||||||
| `POST /v1/tasks/{id}/optimize_prompt` | body `{text(req, ≤4000), image_model?=""}`;同步调当前 task model 润色草稿,返 `{optimized, model_profile, tokens_in, tokens_out, cost_cny}`;**不**写 messages、**不**累计 task 三列(顶栏数字不污染),只在 `usage_events` 写一行 `kind="prompt_optimize"`(对账可见);不与主对话 run 互斥(允许 streaming 中并行润色) | 必填 |
|
| `POST /v1/tasks/{id}/optimize_prompt` | body `{text(req, ≤4000), image_model?=""}`;同步调当前 task model 润色草稿,返 `{optimized, model_profile, tokens_in, tokens_out, cost_cny}`;**不**写 messages、**不**累计 task 三列(顶栏数字不污染),只在 `usage_events` 写一行 `kind="prompt_optimize"`(对账可见);不与主对话 run 互斥(允许 streaming 中并行润色) | 必填 |
|
||||||
| `WS /v1/asr/stream` | 流式语音转写(前端「🎙 语音」主通道):连上后首条文本帧发 `{"token":"<jwt>"}`(WS 塞不了 header,token 不走 query 防进 access log,失败 close 4401)→ 之后二进制帧 = PCM 分片(16k/16bit/mono)实时转发讯飞;任意文本帧(约定 `{"type":"end"}`)= 说完。服务端回推 `{"text","final"}` 增量全文(wpgs 动态修正已合并),final=true 为最终结果;错误回 `{"error"}` 后关闭。nginx 反代该路径需支持 WS Upgrade(`proxy_set_header Upgrade/Connection`) | 首消息 |
|
| `WS /v1/asr/stream` | 流式语音转写(前端「🎙 语音」主通道):连上后首条文本帧发 `{"token":"<jwt>"}`(WS 塞不了 header,token 不走 query 防进 access log,失败 close 4401)→ 之后二进制帧 = PCM 分片(16k/16bit/mono)实时转发讯飞;任意文本帧(约定 `{"type":"end"}`)= 说完。服务端回推 `{"text","final"}` 增量全文(wpgs 动态修正已合并),final=true 为最终结果;错误回 `{"error"}` 后关闭。nginx 反代该路径需支持 WS Upgrade(`proxy_set_header Upgrade/Connection`) | 首消息 |
|
||||||
| `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=&include_hidden=&task_id=` | 列 user_root 下条目 + 面包屑;dotfile/dotdir 默认隐藏。`include_hidden=true` 仅在同时提供当前用户的 `task_id` 且 `path` 位于该 task working_dir 子树时展示点目录;user_root 平台目录与点文件始终不展示 | 必填 |
|
||||||
| `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/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` | 必填 |
|
| `GET /v1/tasks/{id}/files/preview_pdf?path=` | PPT/PPTX 结构化产物按 task-relative 路径转 PDF 预览;状态码与旧 `/v1/files/preview_pdf` 一致;旧卡片可同样传 `legacy=true` | 必填 |
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# zcbot Skill 清单
|
# zcbot Skill 清单
|
||||||
|
|
||||||
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
|
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
|
||||||
最后更新:2026-08-04(ppt 默认机构候选包含总院红,明确外部机构除外,建材集团别名直达)
|
最后更新:2026-08-06(imagegen/videogen 溯源信息进入默认隐藏、可按 task 查看 .meta/)
|
||||||
Skill 总数:18
|
Skill 总数:18
|
||||||
|
|
||||||
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
|
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
|
||||||
|
|
@ -416,6 +416,8 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
|
||||||
|
|
||||||
**成本**:每次 ¥0.22(`search=true` 加 ¥0.05),3-5 秒出图。
|
**成本**:每次 ¥0.22(`search=true` 加 ¥0.05),3-5 秒出图。
|
||||||
|
|
||||||
|
**典型产物**:`figures/<时间戳>-<rand>.png`;prompt / 参数 / 成本等溯源信息在默认隐藏、可按 task 开启查看的 `figures/.meta/`。
|
||||||
|
|
||||||
**⛔ 调 tool 前的强制门(铁律)**:
|
**⛔ 调 tool 前的强制门(铁律)**:
|
||||||
任何情况下,`seedream` tool call 发出去之前,**必须**先把最终装配好的 prompt(含 size / watermark / search 参数)用对话消息明文展示给用户,问"这样画?要改什么?"并 **BLOCKING 等明确确认**。
|
任何情况下,`seedream` tool call 发出去之前,**必须**先把最终装配好的 prompt(含 size / watermark / search 参数)用对话消息明文展示给用户,问"这样画?要改什么?"并 **BLOCKING 等明确确认**。
|
||||||
|
|
||||||
|
|
@ -448,6 +450,8 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
|
||||||
- 720p / 15s:¥12+
|
- 720p / 15s:¥12+
|
||||||
- 等待:Fast 30-90s / Pro 2-3min
|
- 等待:Fast 30-90s / Pro 2-3min
|
||||||
|
|
||||||
|
**典型产物**:`videos/<时间戳>-<rand>.mp4`;prompt / 参数 / 成本等溯源信息在默认隐藏、可按 task 开启查看的 `videos/.meta/`。
|
||||||
|
|
||||||
**⛔ 比 imagegen 还严的强制门**:视频单价 ¥4 起,**比图贵 10 倍以上**;一次失败 ≈ 18 张错图;等待代价也高。**装配 prompt 不等于授权调用**。
|
**⛔ 比 imagegen 还严的强制门**:视频单价 ¥4 起,**比图贵 10 倍以上**;一次失败 ≈ 18 张错图;等待代价也高。**装配 prompt 不等于授权调用**。
|
||||||
|
|
||||||
**触发词**:视频 / 动画 / 动起来 / 做个 video / 做段视频 / 出段视频 / 生成视频 / mov / mp4 / 短片 / 镜头 / 运动镜头 / 演示视频 / 动效。
|
**触发词**:视频 / 动画 / 动起来 / 做个 video / 做段视频 / 出段视频 / 生成视频 / mov / mp4 / 短片 / 镜头 / 运动镜头 / 演示视频 / 动效。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.62.1"
|
__version__ = "0.62.2"
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from uuid import uuid4
|
||||||
from core.file_store import atomic_write_text
|
from core.file_store import atomic_write_text
|
||||||
|
|
||||||
RESULT_CACHE_SUBDIR = ".zcbot_external_results"
|
RESULT_CACHE_SUBDIR = ".zcbot_external_results"
|
||||||
|
RESULT_CACHE_ROOT = ".zcbot_cache"
|
||||||
RESULT_TTL_SECONDS = 24 * 60 * 60
|
RESULT_TTL_SECONDS = 24 * 60 * 60
|
||||||
MAX_STORED_RESULT_BYTES = 10 * 1024 * 1024
|
MAX_STORED_RESULT_BYTES = 10 * 1024 * 1024
|
||||||
MAX_TASK_CACHE_BYTES = 50 * 1024 * 1024
|
MAX_TASK_CACHE_BYTES = 50 * 1024 * 1024
|
||||||
|
|
@ -130,19 +131,30 @@ def resolve_json_pointer(value: Any, pointer: str) -> Any:
|
||||||
|
|
||||||
class ExternalResultStore:
|
class ExternalResultStore:
|
||||||
def __init__(self, user_root: Path, task_id: str):
|
def __init__(self, user_root: Path, task_id: str):
|
||||||
self.cache_root = Path(user_root) / RESULT_CACHE_SUBDIR
|
task_id = str(task_id).strip()
|
||||||
self.root = self.cache_root / str(task_id)
|
if not task_id or task_id in {".", ".."} or not re.fullmatch(r"[\w.-]+", task_id):
|
||||||
|
raise ExternalResultError("task_id 无效")
|
||||||
|
self.user_root = Path(user_root).resolve()
|
||||||
|
self.cache_root = self.user_root / RESULT_CACHE_ROOT
|
||||||
|
self.root = self.cache_root / task_id / "external_results"
|
||||||
|
# 0.62.1 曾把 24h 缓存写在该目录;reader 保留一版兼容窗口。
|
||||||
|
self.legacy_cache_root = self.user_root / RESULT_CACHE_SUBDIR
|
||||||
|
self.legacy_root = self.legacy_cache_root / task_id
|
||||||
|
|
||||||
def _path(self, result_ref: str) -> Path:
|
def _path(self, result_ref: str) -> Path:
|
||||||
if not _REF_RE.fullmatch(result_ref or ""):
|
if not _REF_RE.fullmatch(result_ref or ""):
|
||||||
raise ExternalResultError("result_ref 无效")
|
raise ExternalResultError("result_ref 无效")
|
||||||
return self.root / f"{result_ref}.json"
|
return self.root / f"{result_ref}.json"
|
||||||
|
|
||||||
|
def _candidate_paths(self, result_ref: str) -> list[Path]:
|
||||||
|
current = self._path(result_ref)
|
||||||
|
return [current, self.legacy_root / current.name]
|
||||||
|
|
||||||
def _sweep(self) -> None:
|
def _sweep(self) -> None:
|
||||||
if not self.cache_root.is_dir():
|
|
||||||
return
|
|
||||||
cutoff = time.time() - RESULT_TTL_SECONDS
|
cutoff = time.time() - RESULT_TTL_SECONDS
|
||||||
for path in self.cache_root.glob("*/extres_*.json"):
|
paths = list(self.cache_root.glob("*/external_results/extres_*.json"))
|
||||||
|
paths.extend(self.legacy_cache_root.glob("*/extres_*.json"))
|
||||||
|
for path in paths:
|
||||||
try:
|
try:
|
||||||
if path.stat().st_mtime < cutoff:
|
if path.stat().st_mtime < cutoff:
|
||||||
path.unlink()
|
path.unlink()
|
||||||
|
|
@ -153,7 +165,13 @@ class ExternalResultStore:
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
def store(self, system_id: str, result: dict[str, Any]) -> StoredExternalResult:
|
def store(
|
||||||
|
self,
|
||||||
|
system_id: str,
|
||||||
|
result: dict[str, Any],
|
||||||
|
*,
|
||||||
|
provenance: dict[str, Any] | None = None,
|
||||||
|
) -> StoredExternalResult:
|
||||||
encoded = _json_bytes(result)
|
encoded = _json_bytes(result)
|
||||||
if len(encoded) > MAX_STORED_RESULT_BYTES:
|
if len(encoded) > MAX_STORED_RESULT_BYTES:
|
||||||
raise ExternalResultError(
|
raise ExternalResultError(
|
||||||
|
|
@ -161,10 +179,9 @@ class ExternalResultStore:
|
||||||
)
|
)
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
self._sweep()
|
self._sweep()
|
||||||
existing = sorted(
|
existing = list(self.root.glob("extres_*.json"))
|
||||||
self.root.glob("extres_*.json"),
|
existing.extend(self.legacy_root.glob("extres_*.json"))
|
||||||
key=lambda path: path.stat().st_mtime,
|
existing.sort(key=lambda path: path.stat().st_mtime)
|
||||||
)
|
|
||||||
total = sum(path.stat().st_size for path in existing)
|
total = sum(path.stat().st_size for path in existing)
|
||||||
while existing and total + len(encoded) > MAX_TASK_CACHE_BYTES:
|
while existing and total + len(encoded) > MAX_TASK_CACHE_BYTES:
|
||||||
oldest = existing.pop(0)
|
oldest = existing.pop(0)
|
||||||
|
|
@ -176,10 +193,9 @@ class ExternalResultStore:
|
||||||
continue
|
continue
|
||||||
if total + len(encoded) > MAX_TASK_CACHE_BYTES:
|
if total + len(encoded) > MAX_TASK_CACHE_BYTES:
|
||||||
raise ExternalResultError("当前任务的外部结果缓存已达上限")
|
raise ExternalResultError("当前任务的外部结果缓存已达上限")
|
||||||
user_files = sorted(
|
user_files = list(self.cache_root.glob("*/external_results/extres_*.json"))
|
||||||
self.cache_root.glob("*/extres_*.json"),
|
user_files.extend(self.legacy_cache_root.glob("*/extres_*.json"))
|
||||||
key=lambda path: path.stat().st_mtime,
|
user_files.sort(key=lambda path: path.stat().st_mtime)
|
||||||
)
|
|
||||||
user_total = sum(path.stat().st_size for path in user_files)
|
user_total = sum(path.stat().st_size for path in user_files)
|
||||||
while user_files and user_total + len(encoded) > MAX_USER_CACHE_BYTES:
|
while user_files and user_total + len(encoded) > MAX_USER_CACHE_BYTES:
|
||||||
oldest = user_files.pop(0)
|
oldest = user_files.pop(0)
|
||||||
|
|
@ -196,11 +212,13 @@ class ExternalResultStore:
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"created_at": int(time.time()),
|
"created_at": int(time.time()),
|
||||||
"system_id": str(system_id),
|
"system_id": str(system_id),
|
||||||
|
"provenance": provenance or {},
|
||||||
"result": result,
|
"result": result,
|
||||||
}
|
}
|
||||||
atomic_write_text(self._path(result_ref), json.dumps(
|
atomic_write_text(
|
||||||
envelope, ensure_ascii=False, default=str
|
self._path(result_ref),
|
||||||
))
|
json.dumps(envelope, ensure_ascii=False, default=str),
|
||||||
|
)
|
||||||
return StoredExternalResult(
|
return StoredExternalResult(
|
||||||
result_ref=result_ref,
|
result_ref=result_ref,
|
||||||
original_bytes=len(encoded),
|
original_bytes=len(encoded),
|
||||||
|
|
@ -209,7 +227,8 @@ class ExternalResultStore:
|
||||||
|
|
||||||
def load(self, result_ref: str) -> dict[str, Any]:
|
def load(self, result_ref: str) -> dict[str, Any]:
|
||||||
self._sweep()
|
self._sweep()
|
||||||
path = self._path(result_ref)
|
candidates = self._candidate_paths(result_ref)
|
||||||
|
path = next((candidate for candidate in candidates if candidate.is_file()), candidates[0])
|
||||||
try:
|
try:
|
||||||
if time.time() - path.stat().st_mtime > RESULT_TTL_SECONDS:
|
if time.time() - path.stat().st_mtime > RESULT_TTL_SECONDS:
|
||||||
path.unlink()
|
path.unlink()
|
||||||
|
|
|
||||||
|
|
@ -61,13 +61,18 @@ def parse_bytes(value) -> Optional[int]:
|
||||||
|
|
||||||
|
|
||||||
# 扫描跳过的 dotfile 顶层名(节省 IO,且 /v1/files API 也隐藏)
|
# 扫描跳过的 dotfile 顶层名(节省 IO,且 /v1/files API 也隐藏)
|
||||||
_SKIP_TOPLEVEL = frozenset({".zcbot_tmp", ".memory"})
|
_SKIP_TOPLEVEL = frozenset({
|
||||||
|
".zcbot_cache",
|
||||||
|
".zcbot_external_results",
|
||||||
|
".zcbot_tmp",
|
||||||
|
".memory",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def scan_user_dir(user_root: Path) -> Tuple[int, int]:
|
def scan_user_dir(user_root: Path) -> Tuple[int, int]:
|
||||||
"""os.walk 累加 user_root 下所有文件大小,返 (bytes, count)。
|
"""os.walk 累加 user_root 下所有文件大小,返 (bytes, count)。
|
||||||
|
|
||||||
跳过顶层 .zcbot_tmp / .memory(开发期临时 + 用户记忆 dotfile,不算入产品配额);
|
跳过顶层平台缓存/临时区与 .memory(均有独立生命周期,不算入产品配额);
|
||||||
follow_symlinks=False 防 symlink 循环爆。
|
follow_symlinks=False 防 symlink 循环爆。
|
||||||
"""
|
"""
|
||||||
if not user_root.exists() or not user_root.is_dir():
|
if not user_root.exists() or not user_root.is_dir():
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ from tools.documents import DocumentDownloadTool, DocumentListKbTool, DocumentSe
|
||||||
from tools.external_systems import (
|
from tools.external_systems import (
|
||||||
ExternalSystemCallTool,
|
ExternalSystemCallTool,
|
||||||
ExternalSystemListTool,
|
ExternalSystemListTool,
|
||||||
|
ExternalSystemResultExportTool,
|
||||||
ExternalSystemResultReadTool,
|
ExternalSystemResultReadTool,
|
||||||
ExternalSystemSearchTool,
|
ExternalSystemSearchTool,
|
||||||
)
|
)
|
||||||
|
|
@ -171,6 +172,11 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
|
||||||
result_budget=result_budget,
|
result_budget=result_budget,
|
||||||
**wd_base,
|
**wd_base,
|
||||||
),
|
),
|
||||||
|
ExternalSystemResultExportTool(
|
||||||
|
ctx.uid,
|
||||||
|
task_id=ctx.task_id,
|
||||||
|
**wd_base,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def _load_skill() -> list:
|
def _load_skill() -> list:
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ seedream(
|
||||||
|
|
||||||
返回串首行是 `[seedream] model=... · size=... · cost=¥... · elapsed=...s` —— 把这行原样保留给用户(前端 SPA 会 parse 挂徽章)。第二行 `saved: <相对路径>` 是产物路径,告诉用户。
|
返回串首行是 `[seedream] model=... · size=... · cost=¥... · elapsed=...s` —— 把这行原样保留给用户(前端 SPA 会 parse 挂徽章)。第二行 `saved: <相对路径>` 是产物路径,告诉用户。
|
||||||
|
|
||||||
产物自动落 `<task_dir>/figures/<时间戳>-<rand>.png` + 同名 `.meta.json`(prompt / 参数 / 成本 / response_id)。
|
产物自动落 `<task_dir>/figures/<时间戳>-<rand>.png`;对应溯源信息写入默认隐藏的 `figures/.meta/<图片文件名>.json`(prompt / 参数 / 成本 / response_id)。用户可在 task 文件面板开启“显示点目录”后检查、下载或删除;`.meta/` 不属于最终交付物。
|
||||||
|
|
||||||
## 改图(i2i):基于已有图做修改
|
## 改图(i2i):基于已有图做修改
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,7 @@ seedance(
|
||||||
|
|
||||||
返回串首行是 `[seedance] model=... · mode=text_to_video|image_to_video · images=N · image=... · resolution=... · ratio=... · duration=Xs · cost=¥... · elapsed=...s` —— `images` 是图片总数,`image` 仅固定首帧时出现;原样保留给用户(SPA 会 parse 挂徽章)。第二行 `saved: <相对路径>` 是产物路径,告诉用户。
|
返回串首行是 `[seedance] model=... · mode=text_to_video|image_to_video · images=N · image=... · resolution=... · ratio=... · duration=Xs · cost=¥... · elapsed=...s` —— `images` 是图片总数,`image` 仅固定首帧时出现;原样保留给用户(SPA 会 parse 挂徽章)。第二行 `saved: <相对路径>` 是产物路径,告诉用户。
|
||||||
|
|
||||||
产物自动落 `<task_dir>/videos/<时间戳>-<rand>.mp4` + 同名 `.meta.json`(prompt / 参数 / cost / tokens / cgt_id 全 snapshot)。
|
产物自动落 `<task_dir>/videos/<时间戳>-<rand>.mp4`;对应溯源信息写入默认隐藏的 `videos/.meta/<视频文件名>.json`(prompt / 参数 / cost / tokens / cgt_id 全 snapshot)。用户可在 task 文件面板开启“显示点目录”后检查、下载或删除;`.meta/` 不属于最终交付物。
|
||||||
|
|
||||||
## 失败 / 不满意后怎么办
|
## 失败 / 不满意后怎么办
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,14 +68,18 @@ class TestScanUserDir(unittest.TestCase):
|
||||||
self.assertEqual(c, 3)
|
self.assertEqual(c, 3)
|
||||||
|
|
||||||
def test_skip_dotfile_toplevel(self):
|
def test_skip_dotfile_toplevel(self):
|
||||||
"""顶层 .zcbot_tmp / .memory 被跳过(开发期临时 + 用户记忆,不算配额)。"""
|
"""平台隐藏缓存/临时区与用户记忆不计入产品文件配额。"""
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
root = Path(d)
|
root = Path(d)
|
||||||
(root / "a.txt").write_bytes(b"counted") # 7
|
(root / "a.txt").write_bytes(b"counted") # 7
|
||||||
(root / ".zcbot_tmp").mkdir()
|
for hidden in (
|
||||||
(root / ".zcbot_tmp" / "skipped.py").write_bytes(b"x" * 99999)
|
".zcbot_cache",
|
||||||
(root / ".memory").mkdir()
|
".zcbot_external_results",
|
||||||
(root / ".memory" / "core.md").write_bytes(b"x" * 99999)
|
".zcbot_tmp",
|
||||||
|
".memory",
|
||||||
|
):
|
||||||
|
(root / hidden).mkdir()
|
||||||
|
(root / hidden / "skipped.bin").write_bytes(b"x" * 99999)
|
||||||
b, c = scan_user_dir(root)
|
b, c = scan_user_dir(root)
|
||||||
self.assertEqual(b, 7)
|
self.assertEqual(b, 7)
|
||||||
self.assertEqual(c, 1)
|
self.assertEqual(c, 1)
|
||||||
|
|
|
||||||
|
|
@ -577,6 +577,7 @@ class ExternalSystemToolSafetyTests(unittest.TestCase):
|
||||||
def test_large_call_spills_and_result_reader_pages_without_data_loss(self):
|
def test_large_call_spills_and_result_reader_pages_without_data_loss(self):
|
||||||
from tools.external_systems import (
|
from tools.external_systems import (
|
||||||
ExternalSystemCallTool,
|
ExternalSystemCallTool,
|
||||||
|
ExternalSystemResultExportTool,
|
||||||
ExternalSystemResultReadTool,
|
ExternalSystemResultReadTool,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -608,6 +609,14 @@ class ExternalSystemToolSafetyTests(unittest.TestCase):
|
||||||
self.assertFalse(spilled["truncated"])
|
self.assertFalse(spilled["truncated"])
|
||||||
self.assertRegex(spilled["result_ref"], r"^extres_[0-9a-f]{32}$")
|
self.assertRegex(spilled["result_ref"], r"^extres_[0-9a-f]{32}$")
|
||||||
self.assertNotIn("x" * 100, json.dumps(spilled))
|
self.assertNotIn("x" * 100, json.dumps(spilled))
|
||||||
|
cache_path = (
|
||||||
|
Path(tmp)
|
||||||
|
/ ".zcbot_cache"
|
||||||
|
/ str(task_id)
|
||||||
|
/ "external_results"
|
||||||
|
/ f"{spilled['result_ref']}.json"
|
||||||
|
)
|
||||||
|
self.assertTrue(cache_path.is_file())
|
||||||
|
|
||||||
read_tool = ExternalSystemResultReadTool(
|
read_tool = ExternalSystemResultReadTool(
|
||||||
uid,
|
uid,
|
||||||
|
|
@ -628,10 +637,46 @@ class ExternalSystemToolSafetyTests(unittest.TestCase):
|
||||||
base_dir=Path(tmp),
|
base_dir=Path(tmp),
|
||||||
)
|
)
|
||||||
cross_task = other_task.execute(spilled["result_ref"])
|
cross_task = other_task.execute(spilled["result_ref"])
|
||||||
|
|
||||||
|
export_tool = ExternalSystemResultExportTool(
|
||||||
|
uid,
|
||||||
|
task_id=task_id,
|
||||||
|
base_dir=Path(tmp),
|
||||||
|
user_root=Path(tmp),
|
||||||
|
)
|
||||||
|
exported = export_tool.execute(
|
||||||
|
spilled["result_ref"],
|
||||||
|
filename="detail_snapshot.json",
|
||||||
|
)
|
||||||
|
export_path = Path(tmp) / "data" / "external" / "detail_snapshot.json"
|
||||||
|
export_payload = json.loads(export_path.read_text(encoding="utf-8"))
|
||||||
self.assertEqual(page["total_items"], 20)
|
self.assertEqual(page["total_items"], 20)
|
||||||
self.assertEqual(page["data"], [{"id": 5}, {"id": 6}])
|
self.assertEqual(page["data"], [{"id": 5}, {"id": 6}])
|
||||||
self.assertTrue(page["has_more"])
|
self.assertTrue(page["has_more"])
|
||||||
self.assertIn("不存在或已过期", cross_task)
|
self.assertIn("不存在或已过期", cross_task)
|
||||||
|
self.assertEqual(exported.artifacts[0].path, "data/external/detail_snapshot.json")
|
||||||
|
self.assertEqual(export_payload["_zcbot"]["result_ref"], spilled["result_ref"])
|
||||||
|
self.assertEqual(
|
||||||
|
export_payload["_zcbot"]["provenance"]["operation_id"],
|
||||||
|
"detail_list",
|
||||||
|
)
|
||||||
|
self.assertEqual(len(export_payload["response"]["data"]), 20)
|
||||||
|
|
||||||
|
def test_result_store_reads_legacy_cache_location(self):
|
||||||
|
from core.external_systems.results import ExternalResultStore
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
store = ExternalResultStore(Path(tmp), task_id)
|
||||||
|
stored = store.store("system-1", {"data": [1, 2, 3]})
|
||||||
|
current = store.root / f"{stored.result_ref}.json"
|
||||||
|
legacy = store.legacy_root / current.name
|
||||||
|
legacy.parent.mkdir(parents=True)
|
||||||
|
current.replace(legacy)
|
||||||
|
|
||||||
|
loaded = store.load(stored.result_ref)
|
||||||
|
|
||||||
|
self.assertEqual(loaded["result"], {"data": [1, 2, 3]})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ class GptImageToolTests(unittest.TestCase):
|
||||||
self.assertIn("mode=i2i", result)
|
self.assertIn("mode=i2i", result)
|
||||||
self.assertIn("reference=", result)
|
self.assertIn("reference=", result)
|
||||||
|
|
||||||
meta_path = next((self.working_dir / "figures").glob("*.meta.json"))
|
meta_path = next((self.working_dir / "figures" / ".meta").glob("*.json"))
|
||||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||||
self.assertEqual(meta["mode"], "i2i")
|
self.assertEqual(meta["mode"], "i2i")
|
||||||
self.assertEqual(meta["reference_images"], ["task/reference.png"])
|
self.assertEqual(meta["reference_images"], ["task/reference.png"])
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,15 @@ class StampedPathTests(unittest.TestCase):
|
||||||
self.assertEqual(p.parent, d)
|
self.assertEqual(p.parent, d)
|
||||||
self.assertRegex(p.name, r"^\d{8}-\d{6}-[0-9a-f]{6}\.png$")
|
self.assertRegex(p.name, r"^\d{8}-\d{6}-[0-9a-f]{6}\.png$")
|
||||||
|
|
||||||
def test_write_meta_alongside(self):
|
def test_write_meta_in_hidden_directory(self):
|
||||||
with TemporaryDirectory() as td:
|
with TemporaryDirectory() as td:
|
||||||
dest = Path(td) / "a.mp4"
|
dest = Path(td) / "a.mp4"
|
||||||
mc.write_meta(dest, {"prompt": "早安", "cost_cny": 0.22})
|
mc.write_meta(dest, {"prompt": "早安", "cost_cny": 0.22})
|
||||||
meta = json.loads((Path(td) / "a.meta.json").read_text(encoding="utf-8"))
|
meta_path = Path(td) / ".meta" / "a.mp4.json"
|
||||||
|
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||||
self.assertEqual(meta["prompt"], "早安")
|
self.assertEqual(meta["prompt"], "早安")
|
||||||
self.assertEqual(meta["cost_cny"], 0.22)
|
self.assertEqual(meta["cost_cny"], 0.22)
|
||||||
|
self.assertFalse((Path(td) / "a.meta.json").exists())
|
||||||
|
|
||||||
|
|
||||||
class QuotaGateTests(unittest.TestCase):
|
class QuotaGateTests(unittest.TestCase):
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,44 @@ 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_hidden_directory_toggle_is_scoped_to_task_working_dir(self):
|
||||||
|
tid = self._mk_task("隐藏目录任务", "隐藏目录")
|
||||||
|
wd = _user_root() / "隐藏目录"
|
||||||
|
(wd / ".meta").mkdir()
|
||||||
|
(wd / ".build").mkdir()
|
||||||
|
(wd / ".env").write_text("SECRET=x", encoding="utf-8")
|
||||||
|
(wd / "visible.txt").write_text("ok", encoding="utf-8")
|
||||||
|
(_user_root() / ".root-secret").mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
default = _client.get(
|
||||||
|
"/v1/files", params={"path": "隐藏目录"}, headers=_AUTH
|
||||||
|
).json()
|
||||||
|
self.assertEqual({entry["name"] for entry in default["entries"]}, {"visible.txt"})
|
||||||
|
self.assertFalse(default["hidden_dirs_included"])
|
||||||
|
|
||||||
|
shown = _client.get(
|
||||||
|
"/v1/files",
|
||||||
|
params={
|
||||||
|
"path": "隐藏目录",
|
||||||
|
"include_hidden": "true",
|
||||||
|
"task_id": tid,
|
||||||
|
},
|
||||||
|
headers=_AUTH,
|
||||||
|
).json()
|
||||||
|
self.assertEqual(
|
||||||
|
{entry["name"] for entry in shown["entries"]},
|
||||||
|
{".build", ".meta", "visible.txt"},
|
||||||
|
)
|
||||||
|
self.assertTrue(shown["hidden_dirs_included"])
|
||||||
|
|
||||||
|
root = _client.get(
|
||||||
|
"/v1/files",
|
||||||
|
params={"include_hidden": "true", "task_id": tid},
|
||||||
|
headers=_AUTH,
|
||||||
|
).json()
|
||||||
|
self.assertNotIn(".root-secret", {entry["name"] for entry in root["entries"]})
|
||||||
|
self.assertFalse(root["hidden_dirs_included"])
|
||||||
|
|
||||||
def test_task_relative_download_survives_working_dir_rename(self):
|
def test_task_relative_download_survives_working_dir_rename(self):
|
||||||
tid = self._mk_task("稳定产物任务", "产物旧目录")
|
tid = self._mk_task("稳定产物任务", "产物旧目录")
|
||||||
artifact = _user_root() / "产物旧目录" / "reports" / "result.txt"
|
artifact = _user_root() / "产物旧目录" / "reports" / "result.txt"
|
||||||
|
|
|
||||||
|
|
@ -242,8 +242,15 @@ class FilesRoutesTests(unittest.TestCase):
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
cls.wd = _user_root() / "route-test-wd"
|
cls.wd = _user_root() / "route-test-wd"
|
||||||
(cls.wd / "sub").mkdir(parents=True, exist_ok=True)
|
(cls.wd / "sub").mkdir(parents=True, exist_ok=True)
|
||||||
|
(cls.wd / "figures" / ".meta").mkdir(parents=True, exist_ok=True)
|
||||||
|
(cls.wd / "figures" / ".private").mkdir(parents=True, exist_ok=True)
|
||||||
(cls.wd / "a.txt").write_text("hello", encoding="utf-8")
|
(cls.wd / "a.txt").write_text("hello", encoding="utf-8")
|
||||||
(cls.wd / "sub" / "b.txt").write_text("world", encoding="utf-8")
|
(cls.wd / "sub" / "b.txt").write_text("world", encoding="utf-8")
|
||||||
|
(cls.wd / "figures" / ".meta" / "image.png.json").write_text(
|
||||||
|
"{}", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(cls.wd / "figures" / ".env").write_text("SECRET=x", encoding="utf-8")
|
||||||
|
(_user_root() / ".root-private").mkdir(exist_ok=True)
|
||||||
|
|
||||||
def test_list_subdir(self):
|
def test_list_subdir(self):
|
||||||
r = _client.get("/v1/files", params={"path": "route-test-wd"}, headers=_AUTH)
|
r = _client.get("/v1/files", params={"path": "route-test-wd"}, headers=_AUTH)
|
||||||
|
|
@ -251,9 +258,64 @@ class FilesRoutesTests(unittest.TestCase):
|
||||||
body = r.json()
|
body = r.json()
|
||||||
self.assertTrue(body["exists"])
|
self.assertTrue(body["exists"])
|
||||||
names = {e["name"] for e in body["entries"]}
|
names = {e["name"] for e in body["entries"]}
|
||||||
self.assertEqual(names, {"sub", "a.txt"})
|
self.assertEqual(names, {"sub", "figures", "a.txt"})
|
||||||
self.assertEqual(body["current"], "route-test-wd")
|
self.assertEqual(body["current"], "route-test-wd")
|
||||||
|
|
||||||
|
def test_dot_directories_are_hidden_by_default(self):
|
||||||
|
r = _client.get(
|
||||||
|
"/v1/files",
|
||||||
|
params={"path": "route-test-wd/figures"},
|
||||||
|
headers=_AUTH,
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.json()["entries"], [])
|
||||||
|
|
||||||
|
def test_enumerator_can_include_dot_directories_but_never_dotfiles(self):
|
||||||
|
from web.userfiles import enumerate_files
|
||||||
|
|
||||||
|
entries, _crumbs, _exists = enumerate_files(
|
||||||
|
_user_root(),
|
||||||
|
self.wd / "figures",
|
||||||
|
include_hidden_dirs=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{entry["name"] for entry in entries},
|
||||||
|
{".meta", ".private"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_hidden_toggle_is_limited_to_selected_task_tree(self):
|
||||||
|
task_id = uuid.uuid4()
|
||||||
|
with patch(
|
||||||
|
"web.routers.files._task_working_dir",
|
||||||
|
return_value=(task_id, self.wd),
|
||||||
|
):
|
||||||
|
shown = _client.get(
|
||||||
|
"/v1/files",
|
||||||
|
params={
|
||||||
|
"path": "route-test-wd/figures",
|
||||||
|
"include_hidden": "true",
|
||||||
|
"task_id": str(task_id),
|
||||||
|
},
|
||||||
|
headers=_AUTH,
|
||||||
|
).json()
|
||||||
|
self.assertEqual(
|
||||||
|
{entry["name"] for entry in shown["entries"]},
|
||||||
|
{".meta", ".private"},
|
||||||
|
)
|
||||||
|
self.assertTrue(shown["hidden_dirs_included"])
|
||||||
|
|
||||||
|
with patch("web.routers.files.system_wd_names", return_value=set()):
|
||||||
|
root = _client.get(
|
||||||
|
"/v1/files",
|
||||||
|
params={"include_hidden": "true", "task_id": str(task_id)},
|
||||||
|
headers=_AUTH,
|
||||||
|
).json()
|
||||||
|
self.assertNotIn(
|
||||||
|
".root-private",
|
||||||
|
{entry["name"] for entry in root["entries"]},
|
||||||
|
)
|
||||||
|
self.assertFalse(root["hidden_dirs_included"])
|
||||||
|
|
||||||
def test_download_and_errors(self):
|
def test_download_and_errors(self):
|
||||||
r = _client.get("/v1/files/download", params={"path": "route-test-wd/a.txt"}, headers=_AUTH)
|
r = _client.get("/v1/files/download", params={"path": "route-test-wd/a.txt"}, headers=_AUTH)
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,12 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from uuid import UUID
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
|
||||||
from core.external_systems.factory import FactoryMesError
|
from core.external_systems.factory import FactoryMesError
|
||||||
from core.external_systems.results import (
|
from core.external_systems.results import (
|
||||||
ExternalResultError,
|
ExternalResultError,
|
||||||
|
|
@ -18,6 +22,7 @@ from core.external_systems.service import (
|
||||||
get_external_system,
|
get_external_system,
|
||||||
list_external_systems,
|
list_external_systems,
|
||||||
)
|
)
|
||||||
|
from core.file_store import atomic_write_text
|
||||||
|
|
||||||
from .base import Tool
|
from .base import Tool
|
||||||
|
|
||||||
|
|
@ -138,6 +143,7 @@ class ExternalSystemCallTool(Tool):
|
||||||
*,
|
*,
|
||||||
per_result_limit: int,
|
per_result_limit: int,
|
||||||
total_limit: int,
|
total_limit: int,
|
||||||
|
provenance: dict | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
rendered = _json(result)
|
rendered = _json(result)
|
||||||
used = self._result_bytes.get(system_id, 0)
|
used = self._result_bytes.get(system_id, 0)
|
||||||
|
|
@ -146,7 +152,11 @@ class ExternalSystemCallTool(Tool):
|
||||||
self._result_bytes[system_id] = used + len(rendered.encode("utf-8"))
|
self._result_bytes[system_id] = used + len(rendered.encode("utf-8"))
|
||||||
return rendered
|
return rendered
|
||||||
|
|
||||||
stored = self._result_store.store(system_id, result)
|
stored = self._result_store.store(
|
||||||
|
system_id,
|
||||||
|
result,
|
||||||
|
provenance=provenance,
|
||||||
|
)
|
||||||
preview, reads = build_result_preview(result)
|
preview, reads = build_result_preview(result)
|
||||||
envelope = {
|
envelope = {
|
||||||
"operation_id": result.get("operation_id"),
|
"operation_id": result.get("operation_id"),
|
||||||
|
|
@ -196,6 +206,12 @@ class ExternalSystemCallTool(Tool):
|
||||||
result,
|
result,
|
||||||
per_result_limit=client.cfg.max_result_bytes,
|
per_result_limit=client.cfg.max_result_bytes,
|
||||||
total_limit=client.cfg.max_total_result_bytes,
|
total_limit=client.cfg.max_total_result_bytes,
|
||||||
|
provenance={
|
||||||
|
"operation_id": operation_id,
|
||||||
|
"arguments": arguments or {},
|
||||||
|
"body": body,
|
||||||
|
"queried_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
except (ExternalSystemError, FactoryMesError, ExternalResultError) as exc:
|
except (ExternalSystemError, FactoryMesError, ExternalResultError) as exc:
|
||||||
print(f"[WARN] external system call failed: {type(exc).__name__}")
|
print(f"[WARN] external system call failed: {type(exc).__name__}")
|
||||||
|
|
@ -300,3 +316,100 @@ class ExternalSystemResultReadTool(Tool):
|
||||||
) as exc:
|
) as exc:
|
||||||
print(f"[WARN] external system result read failed: {type(exc).__name__}")
|
print(f"[WARN] external system result read failed: {type(exc).__name__}")
|
||||||
return f"[Error] {exc}"
|
return f"[Error] {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalSystemResultExportTool(Tool):
|
||||||
|
name = "external_system_result_export"
|
||||||
|
description = (
|
||||||
|
"将 result_ref 对应的完整外部系统结果显式导出为当前任务的持久 JSON 文件。"
|
||||||
|
"仅在用户要求保存、下载或交付数据时调用;普通分析继续使用 result_read。"
|
||||||
|
)
|
||||||
|
parameters = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"result_ref": {"type": "string"},
|
||||||
|
"filename": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "可选 JSON 文件名;留空时按 operation 和时间自动命名",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["result_ref"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user_id: UUID,
|
||||||
|
*,
|
||||||
|
task_id: UUID | str = "default",
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.user_id = user_id
|
||||||
|
self.task_id = str(task_id)
|
||||||
|
root = self.user_root or self.base_dir
|
||||||
|
self._result_store = ExternalResultStore(root, self.task_id)
|
||||||
|
self._user_root = Path(root).resolve()
|
||||||
|
self._working_dir = self.base_dir.resolve()
|
||||||
|
try:
|
||||||
|
self._working_dir.relative_to(self._user_root)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("base_dir 必须位于 user_root 内") from exc
|
||||||
|
|
||||||
|
def execute(self, result_ref: str, filename: str = "", **kwargs) -> str:
|
||||||
|
try:
|
||||||
|
stored = self._result_store.load(result_ref)
|
||||||
|
system_id = str(stored.get("system_id") or "")
|
||||||
|
_row_and_client(self.user_id, system_id)
|
||||||
|
result = stored["result"]
|
||||||
|
operation_id = str(result.get("operation_id") or "external_result")
|
||||||
|
safe_operation = re.sub(r"[^\w.-]+", "_", operation_id).strip("._")
|
||||||
|
safe_operation = safe_operation or "external_result"
|
||||||
|
if filename:
|
||||||
|
filename = filename.strip()
|
||||||
|
if not filename.lower().endswith(".json"):
|
||||||
|
filename += ".json"
|
||||||
|
if (
|
||||||
|
filename in {".", ".."}
|
||||||
|
or not re.fullmatch(r"[\w.-]+", filename)
|
||||||
|
):
|
||||||
|
raise ExternalResultError("filename 包含非法路径字符")
|
||||||
|
target = self._working_dir / "data" / "external" / filename
|
||||||
|
if target.exists():
|
||||||
|
raise ExternalResultError(f"文件已存在: data/external/{filename}")
|
||||||
|
else:
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
target = (
|
||||||
|
self._working_dir
|
||||||
|
/ "data"
|
||||||
|
/ "external"
|
||||||
|
/ f"{safe_operation}_{stamp}_{uuid4().hex[:6]}.json"
|
||||||
|
)
|
||||||
|
exported = {
|
||||||
|
"_zcbot": {
|
||||||
|
"kind": "external_system_snapshot",
|
||||||
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"result_ref": result_ref,
|
||||||
|
"system_id": system_id,
|
||||||
|
"provenance": stored.get("provenance") or {},
|
||||||
|
},
|
||||||
|
"response": result,
|
||||||
|
}
|
||||||
|
atomic_write_text(
|
||||||
|
target,
|
||||||
|
json.dumps(exported, ensure_ascii=False, default=str, indent=2),
|
||||||
|
)
|
||||||
|
_, rel = resolve_artifact_path(
|
||||||
|
str(target),
|
||||||
|
working_dir=self.base_dir,
|
||||||
|
user_root=self._user_root,
|
||||||
|
)
|
||||||
|
content = f"saved: {rel}\n完整外部系统结果已持久导出;该文件不受缓存 TTL 影响。"
|
||||||
|
return ToolExecutionResult(content, artifacts=(ArtifactRef(path=rel),))
|
||||||
|
except (
|
||||||
|
ExternalSystemError,
|
||||||
|
FactoryMesError,
|
||||||
|
ExternalResultError,
|
||||||
|
OSError,
|
||||||
|
) as exc:
|
||||||
|
print(f"[WARN] external system result export failed: {type(exc).__name__}")
|
||||||
|
return f"[Error] {exc}"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
- 响应直接返 b64_json,无需二次下载;
|
- 响应直接返 b64_json,无需二次下载;
|
||||||
- 复杂图片可能需 ~2min。
|
- 复杂图片可能需 ~2min。
|
||||||
完成后:
|
完成后:
|
||||||
- 图片落 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.png` + 同名 `.meta.json`
|
- 图片落 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.png`,技术元数据进默认隐藏的 `.meta/`
|
||||||
- usage_events 写 kind="image" 一行(model_profile="unifyllm.<variant>",
|
- usage_events 写 kind="image" 一行(model_profile="unifyllm.<variant>",
|
||||||
usage tokens 记进 units → 网关价目公布后可回填对账)
|
usage tokens 记进 units → 网关价目公布后可回填对账)
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
五处同构析出,2026-07-23)。
|
五处同构析出,2026-07-23)。
|
||||||
|
|
||||||
收进来的判据:**逐字或参数化后逐字**的重复 —— 每日配额闸、`<ts>-<rand6>` 落盘命名、
|
收进来的判据:**逐字或参数化后逐字**的重复 —— 每日配额闸、`<ts>-<rand6>` 落盘命名、
|
||||||
meta.json 写入、记账 try/except 兜底、Ark 超时透明重试、chat 答案提取、响应递归找 URL。
|
产物溯源元数据写入、记账 try/except 兜底、Ark 超时透明重试、chat 答案提取、响应递归找 URL。
|
||||||
各工具的首行 banner 格式 / 请求 body 组装 / seedance 轮询等有意各自不同,**不**收。
|
各工具的首行 banner 格式 / 请求 body 组装 / seedance 轮询等有意各自不同,**不**收。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -16,6 +16,7 @@ from typing import Any, Callable, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from core.ark_client import ArkClient, ArkConfig, ArkError, ArkTimeoutError
|
from core.ark_client import ArkClient, ArkConfig, ArkError, ArkTimeoutError
|
||||||
|
from core.file_store import atomic_write_text
|
||||||
from core.storage.usage import check_daily_quota
|
from core.storage.usage import check_daily_quota
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -46,9 +47,11 @@ def stamped_path(directory: Path, ext: str) -> Path:
|
||||||
|
|
||||||
|
|
||||||
def write_meta(dest: Path, meta: dict) -> None:
|
def write_meta(dest: Path, meta: dict) -> None:
|
||||||
"""产物旁写同名 `.meta.json`(prompt/model/cost 等溯源信息)。"""
|
"""把 prompt/model/cost 等技术溯源信息写入产物旁默认隐藏的 `.meta/`。"""
|
||||||
dest.with_suffix(".meta.json").write_text(
|
meta_path = dest.parent / ".meta" / f"{dest.name}.json"
|
||||||
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
|
atomic_write_text(
|
||||||
|
meta_path,
|
||||||
|
json.dumps(meta, ensure_ascii=False, default=str, indent=2),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ W×H 由 resolution + ratio 推算(横版 height=resolution_num,竖版 width=res
|
||||||
|
|
||||||
完成后:
|
完成后:
|
||||||
- 视频落到 `<wd>/videos/<YYYYMMDD-HHMMSS>-<rand6>.mp4`
|
- 视频落到 `<wd>/videos/<YYYYMMDD-HHMMSS>-<rand6>.mp4`
|
||||||
- 同名 `.meta.json` 写 prompt / model / 参数 / cost_cny / tokens / cgt_id / ts
|
- 默认隐藏的 `.meta/` 写 prompt / model / 参数 / cost_cny / tokens / cgt_id / ts
|
||||||
- usage_events 写 kind="video" 一行(单价 + 分辨率 + 时长 snapshot 进 units)
|
- usage_events 写 kind="video" 一行(单价 + 分辨率 + 时长 snapshot 进 units)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
模型 ID + 单价 + 默认参数全在 `config/media/doubao.yaml`,本 tool 只装配。
|
模型 ID + 单价 + 默认参数全在 `config/media/doubao.yaml`,本 tool 只装配。
|
||||||
完成后:
|
完成后:
|
||||||
- 图片落到 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.png`
|
- 图片落到 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.png`
|
||||||
- 同名 `.meta.json` 写 prompt / model / size / search / cost_cny / response_id / ts
|
- 默认隐藏的 `.meta/` 写 prompt / model / size / search / cost_cny / response_id / ts
|
||||||
- usage_events 写 kind="image" 一行(单价 snapshot 进 units → 跨调价对账)
|
- usage_events 写 kind="image" 一行(单价 snapshot 进 units → 跨调价对账)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -177,7 +177,7 @@ class SeedreamTool(Tool):
|
||||||
if not image_url:
|
if not image_url:
|
||||||
return f"[Error] seedream response 缺 image url: {json.dumps(resp, ensure_ascii=False)[:300]}"
|
return f"[Error] seedream response 缺 image url: {json.dumps(resp, ensure_ascii=False)[:300]}"
|
||||||
|
|
||||||
# 落盘 figures/<ts>-<rand>.png + .meta.json
|
# 落盘 figures/<ts>-<rand>.png;技术元数据进入默认隐藏的 figures/.meta/
|
||||||
dest_png = stamped_path(self.working_dir / "figures", ".png")
|
dest_png = stamped_path(self.working_dir / "figures", ".png")
|
||||||
client.download(image_url, dest_png, timeout_s=120.0)
|
client.download(image_url, dest_png, timeout_s=120.0)
|
||||||
except ArkError as e:
|
except ArkError as e:
|
||||||
|
|
|
||||||
|
|
@ -155,15 +155,31 @@ def register_file_routes(app, *, require_user) -> None:
|
||||||
@app.get("/v1/files", tags=["files"])
|
@app.get("/v1/files", tags=["files"])
|
||||||
def list_files(
|
def list_files(
|
||||||
path: str = "",
|
path: str = "",
|
||||||
|
include_hidden: bool = False,
|
||||||
|
task_id: str = "",
|
||||||
user_id: UUID = Depends(require_user),
|
user_id: UUID = Depends(require_user),
|
||||||
):
|
):
|
||||||
"""列 user_root 下子目录条目 + 面包屑。`path` 留空 → user_root;
|
"""列 user_root 下子目录条目 + 面包屑。`path` 留空 → user_root;
|
||||||
`../` / 绝对 → 400。dotfile(`.memory/` 等)一律隐藏;根目录层再隐藏
|
`../` / 绝对 → 400。dotfile/dotdir 默认隐藏;`include_hidden=true`
|
||||||
系统工作目录(定时任务 / 渠道对话,见 system_wd_names)。
|
仅在 `task_id` 属于当前用户且 current 位于该 task 工作目录子树时展示
|
||||||
|
dotdir,永不从 user_root 展示平台目录。根目录层再隐藏系统工作目录
|
||||||
|
(定时任务 / 渠道对话,见 system_wd_names)。点文件始终隐藏。
|
||||||
"""
|
"""
|
||||||
root = load_user_root(user_id)
|
root = load_user_root(user_id)
|
||||||
current = safe_join(root, path)
|
current = safe_join(root, path)
|
||||||
entries, crumbs, exists = enumerate_files(root, current)
|
include_task_hidden = False
|
||||||
|
if include_hidden and task_id:
|
||||||
|
_tid, working_dir = _task_working_dir(task_id, user_id, root)
|
||||||
|
try:
|
||||||
|
current.relative_to(working_dir)
|
||||||
|
include_task_hidden = True
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
entries, crumbs, exists = enumerate_files(
|
||||||
|
root,
|
||||||
|
current,
|
||||||
|
include_hidden_dirs=include_task_hidden,
|
||||||
|
)
|
||||||
if not rel_to(root, current):
|
if not rel_to(root, current):
|
||||||
hidden = system_wd_names(user_id)
|
hidden = system_wd_names(user_id)
|
||||||
if hidden:
|
if hidden:
|
||||||
|
|
@ -177,6 +193,7 @@ def register_file_routes(app, *, require_user) -> None:
|
||||||
"exists": exists,
|
"exists": exists,
|
||||||
"crumbs": crumbs,
|
"crumbs": crumbs,
|
||||||
"entries": entries,
|
"entries": entries,
|
||||||
|
"hidden_dirs_included": include_task_hidden,
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/v1/files/download", tags=["files"])
|
@app.get("/v1/files/download", tags=["files"])
|
||||||
|
|
|
||||||
|
|
@ -582,6 +582,9 @@
|
||||||
}
|
}
|
||||||
.pane-head .label { font-weight: 600; font-size: 13px; white-space: nowrap; flex-shrink: 0; }
|
.pane-head .label { font-weight: 600; font-size: 13px; white-space: nowrap; flex-shrink: 0; }
|
||||||
.pane-head .spacer { flex: 1; }
|
.pane-head .spacer { flex: 1; }
|
||||||
|
#btn-toggle-hidden[aria-pressed="true"] {
|
||||||
|
color: var(--accent); border-color: var(--accent); background: var(--accent-soft);
|
||||||
|
}
|
||||||
/* 左 pane:title 行(#fafafa)下面的 filter / sort 子行换成白底 + 极淡分隔,弱化层级 */
|
/* 左 pane:title 行(#fafafa)下面的 filter / sort 子行换成白底 + 极淡分隔,弱化层级 */
|
||||||
#pane-left .pane-head + .pane-head {
|
#pane-left .pane-head + .pane-head {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|
@ -1991,6 +1994,7 @@
|
||||||
<span class="label">文件</span>
|
<span class="label">文件</span>
|
||||||
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto;" title=""></span>
|
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto;" title=""></span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
<button id="btn-toggle-hidden" class="small" title="显示点目录" aria-label="显示点目录" aria-pressed="false" hidden>.*</button>
|
||||||
<button id="btn-src-pick" class="small" title="选入:从其他目录勾选文件 / 目录,复制或移动到当前主目录">⊕</button>
|
<button id="btn-src-pick" class="small" title="选入:从其他目录勾选文件 / 目录,复制或移动到当前主目录">⊕</button>
|
||||||
<button id="btn-upload" class="small" title="上传文件到当前目录(也可直接把文件拖到本面板)">⬆</button>
|
<button id="btn-upload" class="small" title="上传文件到当前目录(也可直接把文件拖到本面板)">⬆</button>
|
||||||
<button id="btn-refresh-files" class="small">↻</button>
|
<button id="btn-refresh-files" class="small">↻</button>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ import { dialogConfirm, dialogPrompt, message } from "./dialog.js";
|
||||||
|
|
||||||
// ───── files(user-rooted,不绑 task) ─────
|
// ───── files(user-rooted,不绑 task) ─────
|
||||||
$("btn-refresh-files").onclick = () => loadFiles();
|
$("btn-refresh-files").onclick = () => loadFiles();
|
||||||
|
$("btn-toggle-hidden").onclick = () => {
|
||||||
|
state.showHiddenDirs = !state.showHiddenDirs;
|
||||||
|
loadFiles();
|
||||||
|
};
|
||||||
$("btn-upload").onclick = () => $("upload-input").click();
|
$("btn-upload").onclick = () => $("upload-input").click();
|
||||||
$("upload-input").addEventListener("change", uploadSelected);
|
$("upload-input").addEventListener("change", uploadSelected);
|
||||||
|
|
||||||
|
|
@ -191,8 +195,14 @@ export function scheduleFilesRefresh() {
|
||||||
|
|
||||||
export async function loadFiles() {
|
export async function loadFiles() {
|
||||||
try {
|
try {
|
||||||
const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : "";
|
const params = new URLSearchParams();
|
||||||
const data = await api("GET", "/v1/files" + qs);
|
if (state.filesPath) params.set("path", state.filesPath);
|
||||||
|
if (state.showHiddenDirs && state.taskId) {
|
||||||
|
params.set("include_hidden", "true");
|
||||||
|
params.set("task_id", state.taskId);
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
const data = await api("GET", "/v1/files" + (qs ? "?" + qs : ""));
|
||||||
renderFiles(data);
|
renderFiles(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 401) { logout(); return; }
|
if (e.status === 401) { logout(); return; }
|
||||||
|
|
@ -215,6 +225,17 @@ function renderFiles(data) {
|
||||||
const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName;
|
const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName;
|
||||||
$("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)";
|
$("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)";
|
||||||
$("files-proj").title = projName || data.root || "";
|
$("files-proj").title = projName || data.root || "";
|
||||||
|
const taskWd = state.taskMeta && state.taskMeta.working_dir
|
||||||
|
? state.taskMeta.working_dir.split("/").filter(Boolean).pop()
|
||||||
|
: "";
|
||||||
|
const inTaskTree = !!taskWd && (
|
||||||
|
data.current === taskWd || (data.current || "").startsWith(taskWd + "/")
|
||||||
|
);
|
||||||
|
const hiddenToggle = $("btn-toggle-hidden");
|
||||||
|
hiddenToggle.hidden = !inTaskTree;
|
||||||
|
hiddenToggle.setAttribute("aria-pressed", state.showHiddenDirs ? "true" : "false");
|
||||||
|
hiddenToggle.title = state.showHiddenDirs ? "隐藏点目录" : "显示点目录";
|
||||||
|
hiddenToggle.setAttribute("aria-label", hiddenToggle.title);
|
||||||
// crumbs root 标"我的"(user_root),更直观;其余原样
|
// crumbs root 标"我的"(user_root),更直观;其余原样
|
||||||
const cr = data.crumbs.map((c, i) => {
|
const cr = data.crumbs.map((c, i) => {
|
||||||
const label = i === 0 ? "我的" : c.label;
|
const label = i === 0 ? "我的" : c.label;
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ export const state = {
|
||||||
draftWorkingDir: "",
|
draftWorkingDir: "",
|
||||||
draftNewDirMode: false,
|
draftNewDirMode: false,
|
||||||
filesPath: "",
|
filesPath: "",
|
||||||
|
showHiddenDirs: false, // 仅当前 task 工作目录子树生效;user_root 永不展示
|
||||||
// 同 wd 内除自己外其他活跃 task(run_status in running/cancelling),供 banner 显示
|
// 同 wd 内除自己外其他活跃 task(run_status in running/cancelling),供 banner 显示
|
||||||
concurrentWarnings: [],
|
concurrentWarnings: [],
|
||||||
evtSrc: null,
|
evtSrc: null,
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,12 @@ header .who {
|
||||||
letter-spacing: 0.2px;
|
letter-spacing: 0.2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#btn-toggle-hidden[aria-pressed="true"] {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
#pane-left .pane-head + .pane-head {
|
#pane-left .pane-head + .pane-head {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,15 @@ def rel_to(root: Path, target: Path) -> str:
|
||||||
return "" if rel == "." else rel
|
return "" if rel == "." else rel
|
||||||
|
|
||||||
|
|
||||||
def enumerate_files(root: Path, current: Path) -> tuple[list[dict], list[dict], bool]:
|
def enumerate_files(
|
||||||
|
root: Path,
|
||||||
|
current: Path,
|
||||||
|
*,
|
||||||
|
include_hidden_dirs: bool = False,
|
||||||
|
) -> tuple[list[dict], list[dict], bool]:
|
||||||
"""枚举 current 下条目 + 拼面包屑。size raw bytes,mtime ISO 串(前端 humanize)。
|
"""枚举 current 下条目 + 拼面包屑。size raw bytes,mtime ISO 串(前端 humanize)。
|
||||||
Dotfile 一律隐藏(`.memory/` 等系统区不暴露给 UI,同 `/v1/folders` 约定)。
|
dotfile 始终隐藏;调用方仅在已验证的 task 工作目录子树内允许展示 dotdir。
|
||||||
|
即使开关开启,`.env` 等点文件仍不展示。
|
||||||
"""
|
"""
|
||||||
entries: list[dict] = []
|
entries: list[dict] = []
|
||||||
exists = current.exists()
|
exists = current.exists()
|
||||||
|
|
@ -65,7 +71,7 @@ def enumerate_files(root: Path, current: Path) -> tuple[list[dict], list[dict],
|
||||||
except OSError:
|
except OSError:
|
||||||
raw = []
|
raw = []
|
||||||
for p in raw:
|
for p in raw:
|
||||||
if p.name.startswith("."):
|
if p.name.startswith(".") and not (include_hidden_dirs and p.is_dir()):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
st = p.stat()
|
st = p.stat()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue