feat(files): toggle task hidden directories
This commit is contained in:
parent
41cd7858a1
commit
7e544827c0
|
|
@ -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 接口因请求体结构信息缺失而反复报参数错误的问题;大查询结果不再被截成半段内容,超出对话容量时会完整暂存并支持按路径、分页和字段继续读取。
|
||||
|
|
|
|||
11
DESIGN.md
11
DESIGN.md
|
|
@ -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`。
|
||||
|
||||
|
|
|
|||
|
|
@ -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
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 中并行润色) | 必填 |
|
||||
| `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` | 必填 |
|
||||
|
|
|
|||
|
|
@ -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 / 短片 / 镜头 / 运动镜头 / 演示视频 / 动效。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.62.1"
|
||||
__version__ = "0.62.2"
|
||||
|
|
|
|||
|
|
@ -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):基于已有图做修改
|
||||
|
||||
|
|
|
|||
|
|
@ -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/` 不属于最终交付物。
|
||||
|
||||
## 失败 / 不满意后怎么办
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 → 网关价目公布后可回填对账)
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue