diff --git a/CHANGELOG.md b/CHANGELOG.md index ed089a5..35602fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.62.2 — 2026-08-06 + +- 外部系统的大查询结果现在可以按需导出为长期保存的完整 JSON 文件,并保留查询参数和时间等溯源信息,不再受临时缓存期限影响。 +- task 文件面板默认隐藏 `.meta`、`.build`、`.preview` 等点目录;需要排查或查看溯源信息时,可以用 `.*` 开关临时显示当前 task 下的隐藏目录。用户级平台目录和 `.env` 等点文件仍不会展示。 + ## 0.62.1 — 2026-08-06 - 修复外部系统只读 POST 接口因请求体结构信息缺失而反复报参数错误的问题;大查询结果不再被截成半段内容,超出对话容量时会完整暂存并支持按路径、分页和字段继续读取。 diff --git a/DESIGN.md b/DESIGN.md index bbfbb7f..faf3d29 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -56,7 +56,16 @@ zcbot/ **工作目录** = `workspace/users///`,所有 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//external_results/`,用户明确要求留存时再 export 到 `/data/external/`;图片/视频的 prompt/model/cost 等技术 sidecar 进产物目录下隐藏 `.meta/`。报告、图片、视频和各 skill 工作数据仍由对应工具按既有语义选目录;既有可见文件和旧 sidecar 不搬迁、不删除,避免破坏线上引用。等至少三类能力出现完全相同的生命周期需求后再提炼公共抽象。 +**输出生命周期**(2026-08-06):真实文件仍是事实源,不引入统一产物框架,也不迁移各工具已经稳定的目录。仅对已出现的两类噪声建立局部规则:可过期的外部大响应进用户根目录隐藏缓存 `.zcbot_cache//external_results/`,用户明确要求留存时再 export 到 `/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`。 diff --git a/PROGRESS.md b/PROGRESS.md index 99f20d8..3feeabe 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `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 +- **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。 ### 2026-08-05 diff --git a/RUN.md b/RUN.md index 9525ca1..2927216 100644 --- a/RUN.md +++ b/RUN.md @@ -363,7 +363,7 @@ $env:ZCBOT_EVAL_TOKEN = "" | `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":""}`(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 连不上时前端的兜底通道 | 必填 | -| `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/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` | 必填 | diff --git a/SKILL_LIST.md b/SKILL_LIST.md index c9f8161..060dab0 100644 --- a/SKILL_LIST.md +++ b/SKILL_LIST.md @@ -1,7 +1,7 @@ # zcbot Skill 清单 服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材) -最后更新:2026-08-04(ppt 默认机构候选包含总院红,明确外部机构除外,建材集团别名直达) +最后更新:2026-08-06(imagegen/videogen 溯源信息进入默认隐藏、可按 task 查看 .meta/) Skill 总数:18 zcbot 的"skill"是一份可加载的工作流脚本(`skills//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 秒出图。 +**典型产物**:`figures/<时间戳>-.png`;prompt / 参数 / 成本等溯源信息在默认隐藏、可按 task 开启查看的 `figures/.meta/`。 + **⛔ 调 tool 前的强制门(铁律)**: 任何情况下,`seedream` tool call 发出去之前,**必须**先把最终装配好的 prompt(含 size / watermark / search 参数)用对话消息明文展示给用户,问"这样画?要改什么?"并 **BLOCKING 等明确确认**。 @@ -448,6 +450,8 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S - 720p / 15s:¥12+ - 等待:Fast 30-90s / Pro 2-3min +**典型产物**:`videos/<时间戳>-.mp4`;prompt / 参数 / 成本等溯源信息在默认隐藏、可按 task 开启查看的 `videos/.meta/`。 + **⛔ 比 imagegen 还严的强制门**:视频单价 ¥4 起,**比图贵 10 倍以上**;一次失败 ≈ 18 张错图;等待代价也高。**装配 prompt 不等于授权调用**。 **触发词**:视频 / 动画 / 动起来 / 做个 video / 做段视频 / 出段视频 / 生成视频 / mov / mp4 / 短片 / 镜头 / 运动镜头 / 演示视频 / 动效。 diff --git a/core/__init__.py b/core/__init__.py index 499b09a..c98d864 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.62.1" +__version__ = "0.62.2" diff --git a/skills/imagegen/SKILL.md b/skills/imagegen/SKILL.md index 445743e..1cdfa6f 100644 --- a/skills/imagegen/SKILL.md +++ b/skills/imagegen/SKILL.md @@ -175,7 +175,7 @@ seedream( 返回串首行是 `[seedream] model=... · size=... · cost=¥... · elapsed=...s` —— 把这行原样保留给用户(前端 SPA 会 parse 挂徽章)。第二行 `saved: <相对路径>` 是产物路径,告诉用户。 -产物自动落 `/figures/<时间戳>-.png` + 同名 `.meta.json`(prompt / 参数 / 成本 / response_id)。 +产物自动落 `/figures/<时间戳>-.png`;对应溯源信息写入默认隐藏的 `figures/.meta/<图片文件名>.json`(prompt / 参数 / 成本 / response_id)。用户可在 task 文件面板开启“显示点目录”后检查、下载或删除;`.meta/` 不属于最终交付物。 ## 改图(i2i):基于已有图做修改 diff --git a/skills/videogen/SKILL.md b/skills/videogen/SKILL.md index 268aec3..92986ef 100644 --- a/skills/videogen/SKILL.md +++ b/skills/videogen/SKILL.md @@ -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: <相对路径>` 是产物路径,告诉用户。 -产物自动落 `/videos/<时间戳>-.mp4` + 同名 `.meta.json`(prompt / 参数 / cost / tokens / cgt_id 全 snapshot)。 +产物自动落 `/videos/<时间戳>-.mp4`;对应溯源信息写入默认隐藏的 `videos/.meta/<视频文件名>.json`(prompt / 参数 / cost / tokens / cgt_id 全 snapshot)。用户可在 task 文件面板开启“显示点目录”后检查、下载或删除;`.meta/` 不属于最终交付物。 ## 失败 / 不满意后怎么办 diff --git a/tests/test_web_routes_db.py b/tests/test_web_routes_db.py index be4e829..f72dd2c 100644 --- a/tests/test_web_routes_db.py +++ b/tests/test_web_routes_db.py @@ -347,6 +347,44 @@ class FilesDbAwareTests(unittest.TestCase): self.assertTrue(d["working_dir"].endswith("/改名后目录")) 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): tid = self._mk_task("稳定产物任务", "产物旧目录") artifact = _user_root() / "产物旧目录" / "reports" / "result.txt" diff --git a/tests/test_web_routes_nodb.py b/tests/test_web_routes_nodb.py index 4748c17..d974f9e 100644 --- a/tests/test_web_routes_nodb.py +++ b/tests/test_web_routes_nodb.py @@ -242,8 +242,15 @@ class FilesRoutesTests(unittest.TestCase): def setUpClass(cls): cls.wd = _user_root() / "route-test-wd" (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 / "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): r = _client.get("/v1/files", params={"path": "route-test-wd"}, headers=_AUTH) @@ -251,9 +258,64 @@ class FilesRoutesTests(unittest.TestCase): body = r.json() self.assertTrue(body["exists"]) 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") + 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): r = _client.get("/v1/files/download", params={"path": "route-test-wd/a.txt"}, headers=_AUTH) self.assertEqual(r.status_code, 200) diff --git a/tools/gpt_image.py b/tools/gpt_image.py index 3e930f7..5831160 100644 --- a/tools/gpt_image.py +++ b/tools/gpt_image.py @@ -7,7 +7,7 @@ - 响应直接返 b64_json,无需二次下载; - 复杂图片可能需 ~2min。 完成后: -- 图片落 `/figures/-.png`,技术元数据进隐藏 `.meta/` +- 图片落 `/figures/-.png`,技术元数据进默认隐藏的 `.meta/` - usage_events 写 kind="image" 一行(model_profile="unifyllm.", usage tokens 记进 units → 网关价目公布后可回填对账) """ diff --git a/tools/media_common.py b/tools/media_common.py index d05b25e..182d12d 100644 --- a/tools/media_common.py +++ b/tools/media_common.py @@ -2,7 +2,7 @@ 五处同构析出,2026-07-23)。 收进来的判据:**逐字或参数化后逐字**的重复 —— 每日配额闸、`-` 落盘命名、 -隐藏技术元数据写入、记账 try/except 兜底、Ark 超时透明重试、chat 答案提取、响应递归找 URL。 +产物溯源元数据写入、记账 try/except 兜底、Ark 超时透明重试、chat 答案提取、响应递归找 URL。 各工具的首行 banner 格式 / 请求 body 组装 / seedance 轮询等有意各自不同,**不**收。 """ from __future__ import annotations @@ -47,7 +47,7 @@ def stamped_path(directory: Path, ext: str) -> Path: def write_meta(dest: Path, meta: dict) -> None: - """把 prompt/model/cost 等技术溯源信息写入产物旁的隐藏 `.meta/`。""" + """把 prompt/model/cost 等技术溯源信息写入产物旁默认隐藏的 `.meta/`。""" meta_path = dest.parent / ".meta" / f"{dest.name}.json" atomic_write_text( meta_path, diff --git a/tools/seedance.py b/tools/seedance.py index bc5870f..3d6dffa 100644 --- a/tools/seedance.py +++ b/tools/seedance.py @@ -12,7 +12,7 @@ W×H 由 resolution + ratio 推算(横版 height=resolution_num,竖版 width=res 完成后: - 视频落到 `/videos/-.mp4` -- 隐藏 `.meta/` 写 prompt / model / 参数 / cost_cny / tokens / cgt_id / ts +- 默认隐藏的 `.meta/` 写 prompt / model / 参数 / cost_cny / tokens / cgt_id / ts - usage_events 写 kind="video" 一行(单价 + 分辨率 + 时长 snapshot 进 units) """ from __future__ import annotations diff --git a/tools/seedream.py b/tools/seedream.py index 26ec248..481d442 100644 --- a/tools/seedream.py +++ b/tools/seedream.py @@ -3,7 +3,7 @@ 模型 ID + 单价 + 默认参数全在 `config/media/doubao.yaml`,本 tool 只装配。 完成后: - 图片落到 `/figures/-.png` -- 隐藏 `.meta/` 写 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 → 跨调价对账) """ from __future__ import annotations @@ -177,7 +177,7 @@ class SeedreamTool(Tool): if not image_url: return f"[Error] seedream response 缺 image url: {json.dumps(resp, ensure_ascii=False)[:300]}" - # 落盘 figures/-.png;技术元数据进入 figures/.meta/ + # 落盘 figures/-.png;技术元数据进入默认隐藏的 figures/.meta/ dest_png = stamped_path(self.working_dir / "figures", ".png") client.download(image_url, dest_png, timeout_s=120.0) except ArkError as e: diff --git a/web/routers/files.py b/web/routers/files.py index 305e7c4..79ac66e 100644 --- a/web/routers/files.py +++ b/web/routers/files.py @@ -155,15 +155,31 @@ def register_file_routes(app, *, require_user) -> None: @app.get("/v1/files", tags=["files"]) def list_files( path: str = "", + include_hidden: bool = False, + task_id: str = "", user_id: UUID = Depends(require_user), ): """列 user_root 下子目录条目 + 面包屑。`path` 留空 → user_root; - `../` / 绝对 → 400。dotfile(`.memory/` 等)一律隐藏;根目录层再隐藏 - 系统工作目录(定时任务 / 渠道对话,见 system_wd_names)。 + `../` / 绝对 → 400。dotfile/dotdir 默认隐藏;`include_hidden=true` + 仅在 `task_id` 属于当前用户且 current 位于该 task 工作目录子树时展示 + dotdir,永不从 user_root 展示平台目录。根目录层再隐藏系统工作目录 + (定时任务 / 渠道对话,见 system_wd_names)。点文件始终隐藏。 """ root = load_user_root(user_id) 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): hidden = system_wd_names(user_id) if hidden: @@ -177,6 +193,7 @@ def register_file_routes(app, *, require_user) -> None: "exists": exists, "crumbs": crumbs, "entries": entries, + "hidden_dirs_included": include_task_hidden, } @app.get("/v1/files/download", tags=["files"]) diff --git a/web/static/dev.html b/web/static/dev.html index bb80d3c..386790f 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -582,6 +582,9 @@ } .pane-head .label { font-weight: 600; font-size: 13px; white-space: nowrap; flex-shrink: 0; } .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-left .pane-head + .pane-head { background: #fff; @@ -1991,6 +1994,7 @@ 文件 + diff --git a/web/static/js/files.js b/web/static/js/files.js index dbc79ae..bff5a6f 100644 --- a/web/static/js/files.js +++ b/web/static/js/files.js @@ -17,6 +17,10 @@ import { dialogConfirm, dialogPrompt, message } from "./dialog.js"; // ───── files(user-rooted,不绑 task) ───── $("btn-refresh-files").onclick = () => loadFiles(); +$("btn-toggle-hidden").onclick = () => { + state.showHiddenDirs = !state.showHiddenDirs; + loadFiles(); +}; $("btn-upload").onclick = () => $("upload-input").click(); $("upload-input").addEventListener("change", uploadSelected); @@ -191,8 +195,14 @@ export function scheduleFilesRefresh() { export async function loadFiles() { try { - const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : ""; - const data = await api("GET", "/v1/files" + qs); + const params = new URLSearchParams(); + 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); } catch (e) { if (e.status === 401) { logout(); return; } @@ -215,6 +225,17 @@ function renderFiles(data) { const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName; $("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)"; $("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),更直观;其余原样 const cr = data.crumbs.map((c, i) => { const label = i === 0 ? "我的" : c.label; diff --git a/web/static/js/state.js b/web/static/js/state.js index 141f9ea..6991744 100644 --- a/web/static/js/state.js +++ b/web/static/js/state.js @@ -47,6 +47,7 @@ export const state = { draftWorkingDir: "", draftNewDirMode: false, filesPath: "", + showHiddenDirs: false, // 仅当前 task 工作目录子树生效;user_root 永不展示 // 同 wd 内除自己外其他活跃 task(run_status in running/cancelling),供 banner 显示 concurrentWarnings: [], evtSrc: null, diff --git a/web/static/ui.css b/web/static/ui.css index 7e6caad..242b78a 100644 --- a/web/static/ui.css +++ b/web/static/ui.css @@ -219,6 +219,12 @@ header .who { 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 { background: var(--panel); } diff --git a/web/userfiles.py b/web/userfiles.py index f010ad9..262cc0e 100644 --- a/web/userfiles.py +++ b/web/userfiles.py @@ -53,9 +53,15 @@ def rel_to(root: Path, target: Path) -> str: 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)。 - Dotfile 一律隐藏(`.memory/` 等系统区不暴露给 UI,同 `/v1/folders` 约定)。 + dotfile 始终隐藏;调用方仅在已验证的 task 工作目录子树内允许展示 dotdir。 + 即使开关开启,`.env` 等点文件仍不展示。 """ entries: list[dict] = [] exists = current.exists() @@ -65,7 +71,7 @@ def enumerate_files(root: Path, current: Path) -> tuple[list[dict], list[dict], except OSError: raw = [] for p in raw: - if p.name.startswith("."): + if p.name.startswith(".") and not (include_hidden_dirs and p.is_dir()): continue try: st = p.stat()