feat(files): toggle task hidden directories

This commit is contained in:
caoqianming 2026-08-06 12:05:36 +08:00
parent 41cd7858a1
commit 7e544827c0
20 changed files with 196 additions and 22 deletions

View File

@ -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 接口因请求体结构信息缺失而反复报参数错误的问题;大查询结果不再被截成半段内容,超出对话容量时会完整暂存并支持按路径、分页和字段继续读取。

View File

@ -56,7 +56,16 @@ 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 化只换外层根目录,布局不变。
**输出生命周期**(2026-08-06):真实文件仍是事实源,不引入统一产物框架,也不迁移各工具已经稳定的目录。仅对已出现的两类噪声建立局部规则:可过期的外部大响应进用户根目录隐藏缓存 `.zcbot_cache/<task_id>/external_results/`,用户明确要求留存时再 export 到 `<working_dir>/data/external/`;图片/视频的 prompt/model/cost 等技术 sidecar 进产物目录下隐藏 `.meta/`。报告、图片、视频和各 skill 工作数据仍由对应工具按既有语义选目录;既有可见文件和旧 sidecar 不搬迁、不删除,避免破坏线上引用。等至少三类能力出现完全相同的生命周期需求后再提炼公共抽象。
**输出生命周期**(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`

View File

@ -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

2
RUN.md
View File

@ -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 中并行润色) | 必填 |
| `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 连不上时前端的兜底通道 | 必填 |
| `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` | 必填 |

View File

@ -1,7 +1,7 @@
# zcbot Skill 清单
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
最后更新:2026-08-04(ppt 默认机构候选包含总院红,明确外部机构除外,建材集团别名直达)
最后更新:2026-08-06(imagegen/videogen 溯源信息进入默认隐藏、可按 task 查看 .meta/)
Skill 总数:18
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 秒出图。
**典型产物**:`figures/<时间戳>-<rand>.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/<时间戳>-<rand>.mp4`prompt / 参数 / 成本等溯源信息在默认隐藏、可按 task 开启查看的 `videos/.meta/`
**⛔ 比 imagegen 还严的强制门**:视频单价 ¥4 起,**比图贵 10 倍以上**;一次失败 ≈ 18 张错图;等待代价也高。**装配 prompt 不等于授权调用**。
**触发词**:视频 / 动画 / 动起来 / 做个 video / 做段视频 / 出段视频 / 生成视频 / mov / mp4 / 短片 / 镜头 / 运动镜头 / 演示视频 / 动效。

View File

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

View File

@ -175,7 +175,7 @@ seedream(
返回串首行是 `[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):基于已有图做修改

View File

@ -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: <相对路径>` 是产物路径,告诉用户。
产物自动落 `<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/` 不属于最终交付物。
## 失败 / 不满意后怎么办

View File

@ -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"

View File

@ -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)

View File

@ -7,7 +7,7 @@
- 响应直接返 b64_json,无需二次下载;
- 复杂图片可能需 ~2min
完成后:
- 图片落 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.png`技术元数据进隐藏 `.meta/`
- 图片落 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.png`技术元数据进默认隐藏 `.meta/`
- usage_events kind="image" 一行(model_profile="unifyllm.<variant>",
usage tokens 记进 units 网关价目公布后可回填对账)
"""

View File

@ -2,7 +2,7 @@
五处同构析出,2026-07-23)
收进来的判据:**逐字或参数化后逐字**的重复 每日配额闸`<ts>-<rand6>` 落盘命名
隐藏技术元数据写入记账 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,

View File

@ -12,7 +12,7 @@ W×H 由 resolution + ratio 推算(横版 height=resolution_num,竖版 width=res
完成后:
- 视频落到 `<wd>/videos/<YYYYMMDD-HHMMSS>-<rand6>.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

View File

@ -3,7 +3,7 @@
模型 ID + 单价 + 默认参数全在 `config/media/doubao.yaml`, tool 只装配
完成后:
- 图片落到 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.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/<ts>-<rand>.png技术元数据进入 figures/.meta/
# 落盘 figures/<ts>-<rand>.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:

View File

@ -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;
`../` / 绝对 400dotfile(`.memory/` )一律隐藏;根目录层再隐藏
系统工作目录(定时任务 / 渠道对话, system_wd_names)
`../` / 绝对 400dotfile/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"])

View File

@ -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 @@
<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 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-upload" class="small" title="上传文件到当前目录(也可直接把文件拖到本面板)"></button>
<button id="btn-refresh-files" class="small"></button>

View File

@ -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;

View File

@ -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,

View File

@ -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);
}

View File

@ -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()