From 9a7620f7047962639483acf46172907ac8943102 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 18 May 2026 07:59:19 +0800 Subject: [PATCH] =?UTF-8?q?core(files=20API):=20user-rooted=20/v1/files*,?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=20task=5Fid=20=E5=89=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文件操作语义上只关心"路径 + user 边界",task_id 是多余拐杖; 同时 §7.1 心智模型把 task 和 dir 定义为正交副视图,API 不该混。 - 4 路由 /v1/tasks/{id}/files* → /v1/files*(列/下载/上传/删) - 边界从 task_dir 改 user_root (workspace/users//) - dotfile 一律过滤(.memory/ 等系统区不暴露) - dev SPA:登录即拉 user_root,选 task 自动跳到其 working_dir, crumbs root 标"我的",新增 upload 按钮 Co-Authored-By: Claude Opus 4.7 (1M context) --- DESIGN.md | 13 ++++---- PROGRESS.md | 5 +-- web/app.py | 76 ++++++++++++++++++------------------------ web/static/dev.html | 80 +++++++++++++++++++++++++++++---------------- 4 files changed, 93 insertions(+), 81 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 354be97..03e96f1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -229,7 +229,7 @@ state / messages 两形态都在 PG,FS 只承担 skill 产物。多 task 共享 ### 7.2 资源模型(/v1) -Task 一等公民,files 是其副视图(经 `task_dir` 暴露,无独立 folder 实体)。所有路由统一 `/v1` 前缀,**返 JSON**;前端 / UI 由 platform 端实现,本仓库不维护(§7.9 取舍)。本地开发用 FastAPI 自带 `/docs` Swagger UI 自查;`GET /` 302 跳 `/docs`。 +Task 一等公民(`/v1/tasks*`);files 与 task 正交(§7.1 双视图心智),走 user-rooted `/v1/files*`,以 `workspace/users//` 为边界(不强制选 task)。所有路由统一 `/v1` 前缀,**返 JSON**;前端 / UI 由 platform 端实现,本仓库不维护(§7.9 取舍)。本地开发用 FastAPI 自带 `/docs` Swagger UI 自查;`GET /` 302 跳 `/docs`。 ``` Tasks @@ -253,11 +253,12 @@ Tasks GET /v1/tasks/{id}/runs/{rid}/events SSE 流(见下) POST /v1/tasks/{id}/runs/{rid}/cancel (待) -Files(per-task,task_dir 副视图) - GET /v1/tasks/{id}/files?path= 列子目录 {entries, crumbs, exists, root} - POST /v1/tasks/{id}/files/upload multipart;path 通过 query 或 form;严格拒含 / \\ .. 的 filename - GET /v1/tasks/{id}/files/download?path= 下载单文件;`..` / 绝对 / symlink 越界 400 - POST /v1/tasks/{id}/files/delete {path} 文件或空目录;非空目录 400 +Files(user-rooted,不绑 task — `workspace/users//` 为根) + GET /v1/files?path= 列子目录 {entries, crumbs, exists, root, current};留空 → user_root; + dotfile(`.memory/` 等)一律隐藏(同 /v1/folders 约定) + POST /v1/files/upload multipart;path 通过 form;严格拒含 / \\ .. 的 filename + GET /v1/files/download?path= 下载单文件;`..` / 绝对 / symlink 越界 400 + POST /v1/files/delete {path} 文件或空目录;非空目录 400;user_root 拒 Export GET /v1/tasks/{id}/export docx 临时文件下载,BackgroundTask 删 tmp diff --git a/PROGRESS.md b/PROGRESS.md index ba2b82f..180b00f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 -最后更新:2026-05-17(`GET /v1/tasks` 分页 + 多维筛选 + ordering(DRF 风格,默认 `-created_at`):`{page, page_size, count, results}` 标准壳 + status/skill/working_dir/q 过滤 + 排序;dev SPA prev/next 翻页 + 搜索框 + 工作目录筛选 + 排序 dropdown;schema 重构:`name`(必填,显示名)+ `working_dir`(可选,留空 fallback name)解耦;`task_dir → working_dir` + `mode → skill` 列重命名) +最后更新:2026-05-17(files API 全面 user-rooted:4 个 `/v1/tasks/{id}/files*` 路由 → `/v1/files*`,以 `workspace/users//` 为边界,task_id 不再是 files 访问的前置条件;dotfile `.memory/` 一律隐藏;dev SPA 文件面板登录即拉 user_root + 选 task 自动跳到 working_dir + 加 upload 按钮) --- @@ -21,6 +21,7 @@ ## 已完成关键能力 +- **05-17 / files API 全面 user-rooted(去掉 task_id 前置)**:用户反馈"web 页应该能看到 user 的所有目录,现在只能选 task 后右侧才刷新"——根因是原 files API 用 task_id 拐杖间接拿 working_dir,迫使前端必须先选 task。语义上 files 操作只关心"路径 + user 边界",task_id 是多余的;同时 §7.1 心智模型早就把 task 和 dir 定义为正交副视图,API 不该混。**后端**:删 `_load_working_dir(task_id, user_id)`,加 `_load_user_root(user_id)`(走 `main.user_root(ws, uid)` 自动 mkdir 拿 `workspace/users//`);4 路由全换:`GET /v1/files?path=` / `GET /v1/files/download?path=` / `POST /v1/files/upload` / `POST /v1/files/delete`。`_safe_join` 边界从 task_dir 改 user_root,安全性不降低;`_enumerate_files` 加 dotfile 过滤(`if p.name.startswith(".")` 跳过 `.memory/` 等,同 `/v1/folders` 约定);`_rel_to` 把 `Path(".")` 归一为空串(避免 root 时 current="." 这种 ugly 形态)。删 `from_db_path` import(只剩 `to_db_path`)。**dev SPA**:`loadFiles()` 不再 gate on `state.taskId`,enterApp 时直接调一次拉 user_root;`selectTask` 在拿到 task meta 后 `state.filesPath = wdName`(从 working_dir 末段抽出)再 loadFiles,选 task 自动跳到对应子目录但用户可点 crumb 回 root 看其他目录;crumbs root 标签 "/" → "我的"(user_root 直观);files-proj header 从"项目名(state.taskMeta 派生)"改"路径首段(数据驱动)",空时显示 `(user root)`。**新增 upload 按钮**(原来藏在外部页面里没暴露给 SPA):pane-head 加 `⬆` 按钮 + 隐藏 ``,onchange 走 FormData POST `/v1/files/upload`,path 取当前 `state.filesPath`(空 → user_root);上传完 loadFiles 刷新。`deleteCurrentTask` 不再重置 files 面板(task 删了但 FS 文件保留,继续浏览有意义),只 reload 当前路径。`btn-refresh-files` 移除 disabled 状态(任何时候可用)。**Smoke 68 case 全绿**(in-process TestClient,跑完即删 `_smoke_files.py`):列 user_root(包含 working_dir 目录,`.memory` 被过滤) / 列子目录 2 层 / 不存在路径 200+exists=False / 路径安全 6 case(`../` / 绝对 / Windows 绝对 / `\\` 起头)/ upload 单 / multi+nested mkdir / 上传到 root / 文件名攻击 4 case(`../` `..` `/` `\\`)/ download 文件 + 深度 + 目录 400 + ghost 404 + 越界 400 / delete 文件 / 空目录 / 非空 400 / user_root 拒 / ghost 404 / 越界 400 / 跨 user 隔离 4 case(A 不见 B,B 不见 A)/ 无 token 全 401(GET list / POST upload / POST delete / GET download)/ 子目录里 dotfile 也过滤 / 新 user 首访 user_root 自动 mkdir + 列表空。**文档**:DESIGN §7.2 路由表段 + lead-in 同步("Task 一等公民,files 是其副视图(经 task_dir 暴露)" → "Task 一等公民;files 与 task 正交,走 user-rooted /v1/files*,以 workspace/users// 为边界")。 - **Q1 → 05-06 / Phase 1-4**:骨架 / 三 skill / run_python / Model Profile + Probing。ppt v3 加商务红 + apply_brand + Iconify;素材摄取改 markitdown CLI。 - **05-06 / Phase 6 部分**:task + state.json + tokens 累计;CLI `tasks` + REPL `/status /done /abandon /desc`;移除 legacy `workspace/sessions/`。 - **05-07 / TUI + task_dir**:rich Markdown 渲染;spinner 显实时耗时 + 累计 token;system prompt 注入 task_dir 绝对路径,产物收敛 `workspace/tasks//`;`.gitignore` 删 bandaid。 @@ -99,7 +100,7 @@ db/migrations/versions/ 0001_initial_schema.py 125 ← §7 B Step 1 0002_task_dir_relative.py 61 ← 现有 ROOT-prefix 绝对 → 相对 web/__init__.py 5 ← Phase G G1 -web/app.py 660 ← /v1/ JSON API + user_id 隔离(D' 过渡 auth) +web/app.py 815 ← /v1/ JSON API + user_id 隔离 + files user-rooted web/auth.py 115 ← D' 过渡:PLATFORM_KEY → JWT 兑换 web/broker.py 88 ← Phase G G4: in-process pub/sub web/sinks.py 20 ← Phase G G4: WebEventSink (§7 A sink 协议) diff --git a/web/app.py b/web/app.py index 6449b23..36534eb 100644 --- a/web/app.py +++ b/web/app.py @@ -29,7 +29,7 @@ from pydantic import BaseModel from sqlalchemy import func, select, update from starlette.background import BackgroundTask -from core.paths import from_db_path, to_db_path +from core.paths import to_db_path from core.storage import ( NoSubtaskError, check_no_subtask, @@ -109,27 +109,13 @@ def _task_dict(row: Any, *, n_messages: Optional[int] = None) -> dict: # ─────────────────────── files helpers ─────────────────────── -def _load_working_dir(task_id: str, user_id: UUID) -> tuple[UUID, Path]: - """task_id 解析 + 查 PG 拿 working_dir db form + 还原 absolute Path。 - 404 / 400 if 非 UUID / task 不存在 / 不属于 user / working_dir 空。 - 跨 user 视为 not found(不暴露 task 存在性)。 +def _load_user_root(user_id: UUID) -> Path: + """user_root = `/users//`,所有 files API 的边界。 + 若目录尚未存在自动 mkdir(空 user 首次访问也能拿到根)。 """ - try: - tid = UUID(task_id) - except ValueError: - raise HTTPException(404, f"invalid task id: {task_id!r}") - with session_scope() as s: - row = s.execute( - select(Task.working_dir).where( - Task.task_id == tid, Task.user_id == user_id - ) - ).first() - if row is None: - raise HTTPException(404, f"task not found: {tid}") - wd = row[0] or "" - if not wd: - raise HTTPException(400, f"task {tid} has no working_dir, files browsing unavailable") - return tid, from_db_path(wd) + from main import resolve_workspace, user_root + ws = resolve_workspace(None) + return user_root(ws, user_id) def _safe_join(root: Path, rel: str) -> Path: @@ -145,19 +131,22 @@ def _safe_join(root: Path, rel: str) -> Path: try: target.relative_to(root.resolve()) except ValueError: - raise HTTPException(400, f"path escapes working_dir: {rel!r}") + raise HTTPException(400, f"path escapes user_root: {rel!r}") return target def _rel_to(root: Path, target: Path) -> str: try: - return target.resolve().relative_to(root.resolve()).as_posix() + rel = target.resolve().relative_to(root.resolve()).as_posix() except ValueError: return "" + return "" if rel == "." else rel def _enumerate_files(root: Path, current: Path) -> 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` 约定)。 + """ entries: list[dict] = [] exists = current.exists() if exists and current.is_dir(): @@ -166,6 +155,8 @@ def _enumerate_files(root: Path, current: Path) -> tuple[list[dict], list[dict], except OSError: raw = [] for p in raw: + if p.name.startswith("."): + continue try: st = p.stat() except OSError: @@ -693,20 +684,20 @@ def create_app() -> FastAPI: }, ) - # ───────────── Files ───────────── + # ───────────── Files(user-rooted,不绑 task) ───────────── - @app.get("/v1/tasks/{task_id}/files", tags=["files"]) + @app.get("/v1/files", tags=["files"]) def list_files( - task_id: str, path: str = "", user_id: UUID = Depends(require_user), ): - """列子目录条目 + 面包屑。`path` 留空 → root;`../` / 绝对 → 400。""" - tid, root = _load_working_dir(task_id, user_id) + """列 user_root 下子目录条目 + 面包屑。`path` 留空 → user_root; + `../` / 绝对 → 400。dotfile(`.memory/` 等)一律隐藏。 + """ + root = _load_user_root(user_id) current = _safe_join(root, path) entries, crumbs, exists = _enumerate_files(root, current) return { - "task_id": str(tid), "root": _norm_path(str(root)), "current": _rel_to(root, current), "exists": exists, @@ -714,14 +705,13 @@ def create_app() -> FastAPI: "entries": entries, } - @app.get("/v1/tasks/{task_id}/files/download", tags=["files"]) + @app.get("/v1/files/download", tags=["files"]) def download_file( - task_id: str, path: str, user_id: UUID = Depends(require_user), ): - """下载单个 regular file(目录 → 400 / 不存在 → 404)。""" - tid, root = _load_working_dir(task_id, user_id) + """下载 user_root 下单个 regular file(目录 → 400 / 不存在 → 404)。""" + root = _load_user_root(user_id) target = _safe_join(root, path) if not target.exists(): raise HTTPException(404, f"file not found: {path}") @@ -729,18 +719,17 @@ def create_app() -> FastAPI: raise HTTPException(400, f"not a file: {path}") return FileResponse(path=str(target), filename=target.name) - @app.post("/v1/tasks/{task_id}/files/upload", tags=["files"]) + @app.post("/v1/files/upload", tags=["files"]) async def upload_files( - task_id: str, path: str = Form(""), files: list[UploadFile] = File(...), user_id: UUID = Depends(require_user), ): - """multipart 多文件上传到 `//`。 + """multipart 多文件上传到 `//`。 路径不存在自动 mkdir(parents=True);重名直接覆盖。 文件名严格校验(含 `/ \\ ..` 或为空 → 400)。 """ - tid, root = _load_working_dir(task_id, user_id) + root = _load_user_root(user_id) dest_dir = _safe_join(root, path) if dest_dir.exists() and not dest_dir.is_dir(): raise HTTPException(400, f"upload target is a file, not a directory: {path}") @@ -760,7 +749,7 @@ def create_app() -> FastAPI: try: dest.resolve().relative_to(root.resolve()) except ValueError: - raise HTTPException(400, f"path escapes task_dir: {raw_name!r}") + raise HTTPException(400, f"path escapes user_root: {raw_name!r}") data = await up.read() dest.write_bytes(data) saved.append({"name": raw_name, "size": len(data), "rel": _rel_to(root, dest)}) @@ -768,17 +757,16 @@ def create_app() -> FastAPI: raise HTTPException(400, "no files uploaded") return {"count": len(saved), "saved": saved} - @app.post("/v1/tasks/{task_id}/files/delete", tags=["files"]) + @app.post("/v1/files/delete", tags=["files"]) def delete_file( - task_id: str, body: FileDeleteRequest, user_id: UUID = Depends(require_user), ): - """删 task_dir 下文件或**空**目录。非空目录 → 400(避免误操);root → 400。""" - tid, root = _load_working_dir(task_id, user_id) + """删 user_root 下文件或**空**目录。非空目录 → 400(避免误操);root → 400。""" + root = _load_user_root(user_id) target = _safe_join(root, body.path) if target.resolve() == root.resolve(): - raise HTTPException(400, "cannot delete task_dir root") + raise HTTPException(400, "cannot delete user_root") if not target.exists(): raise HTTPException(404, f"path not found: {body.path}") try: diff --git a/web/static/dev.html b/web/static/dev.html index e865eec..2b9443c 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -322,10 +322,12 @@ files - + + -
(no task selected)
+
loading…
+ @@ -489,6 +491,7 @@ function enterApp() { $("app").classList.add("ready"); $("hd-who").textContent = state.userId; loadTaskList(); + loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root } async function loadTaskList() { @@ -607,7 +610,10 @@ async function selectTask(tid) { state.taskMeta = meta; renderChatMeta(); await loadMessages(); - state.filesPath = ""; + // 文件面板自动跳到该 task 的 working_dir(user_root 下一级子目录), + // 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录 + const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : ""; + state.filesPath = wdName || ""; await loadFiles(); } catch (e) { if (e.status === 401) { logout(); return; } @@ -636,7 +642,6 @@ function renderChatMeta() { $("btn-abandon").disabled = !active; $("btn-delete-task").disabled = false; // delete 不限 status(用户显式 confirm) $("btn-export").disabled = (t.n_messages || 0) === 0; - $("btn-refresh-files").disabled = false; } async function loadMessages() { @@ -861,23 +866,19 @@ async function deleteCurrentTask() { if (!confirm(`确认硬删除 task "${projName}" (${nMsg} 条消息)?\n\n会清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return; try { await api("DELETE", "/v1/tasks/" + state.taskId); - // 清空 chat / files 面板,回到初始态 + // 清 chat 面板,回到初始态;files 面板与 task 解耦,保留当前路径(FS 文件仍在) if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; } state.taskId = null; state.taskMeta = null; - state.filesPath = ""; $("chat-meta").innerHTML = `(no task selected)`; $("chat-stream").innerHTML = `
select a task on the left
`; $("chat-form").style.display = "none"; - $("file-crumbs").innerHTML = `(no task selected)`; - $("file-list").innerHTML = ""; - $("files-proj").textContent = ""; $("btn-done").disabled = true; $("btn-abandon").disabled = true; $("btn-delete-task").disabled = true; $("btn-export").disabled = true; - $("btn-refresh-files").disabled = true; loadTaskList(); + loadFiles(); // FS 还在,刷新当前路径(可能文件夹仍可见) } catch (e) { if (e.status === 401) { logout(); return; } alert("delete failed: " + e.message); @@ -900,36 +901,32 @@ $("btn-export").onclick = () => { }); }; -// ───── files ───── +// ───── files(user-rooted,不绑 task) ───── $("btn-refresh-files").onclick = () => loadFiles(); +$("btn-upload").onclick = () => $("upload-input").click(); +$("upload-input").addEventListener("change", uploadSelected); async function loadFiles() { - if (!state.taskId) return; try { const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : ""; - const data = await api("GET", `/v1/tasks/${state.taskId}/files` + qs); + const data = await api("GET", "/v1/files" + qs); renderFiles(data); } catch (e) { if (e.status === 401) { logout(); return; } - if (e.status === 400) { - $("file-crumbs").innerHTML = `(no working_dir bound)`; - $("file-list").innerHTML = ""; - } else { - $("file-crumbs").innerHTML = `${escapeHtml(e.message)}`; - $("file-list").innerHTML = ""; - } + $("file-crumbs").innerHTML = `${escapeHtml(e.message)}`; + $("file-list").innerHTML = ""; } } function renderFiles(data) { - // pane-head 显示项目名(working_dir 末段),让用户清楚"现在看的是哪个项目里的文件" - const projName = (state.taskMeta && state.taskMeta.working_dir) - ? state.taskMeta.working_dir.split("/").filter(Boolean).pop() : ""; - $("files-proj").textContent = projName ? "· " + projName : ""; - $("files-proj").title = (state.taskMeta && state.taskMeta.working_dir) || ""; - // crumbs root 用项目名替代 "/",更直观 + // 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文 + const segs = (data.current || "").split("/").filter(Boolean); + const projName = segs[0] || ""; + $("files-proj").textContent = projName ? "· " + projName : "· (user root)"; + $("files-proj").title = data.root || ""; + // crumbs root 标"我的"(user_root),更直观;其余原样 const cr = data.crumbs.map((c, i) => { - const label = (i === 0 && projName) ? projName : c.label; + const label = i === 0 ? "我的" : c.label; const isLast = i === data.crumbs.length - 1; if (isLast) return `${escapeHtml(label)}`; return `${escapeHtml(label)} /`; @@ -975,7 +972,7 @@ async function deleteFile(rel, name, isDir) { const what = isDir ? "目录" : "文件"; if (!confirm(`确认删除${what} "${name}"?` + (isDir ? "\n(非空目录会失败,先清里面再删)" : ""))) return; try { - await api("POST", `/v1/tasks/${state.taskId}/files/delete`, { path: rel }); + await api("POST", "/v1/files/delete", { path: rel }); await loadFiles(); } catch (e) { if (e.status === 401) { logout(); return; } @@ -984,7 +981,7 @@ async function deleteFile(rel, name, isDir) { } function downloadFile(rel) { - fetch(`/v1/tasks/${state.taskId}/files/download?path=` + encodeURIComponent(rel), { + fetch("/v1/files/download?path=" + encodeURIComponent(rel), { headers: { "Authorization": "Bearer " + state.token }, }).then(async (r) => { if (!r.ok) { alert("download failed: " + r.status); return; } @@ -997,6 +994,31 @@ function downloadFile(rel) { }); } +async function uploadSelected() { + const inp = $("upload-input"); + const files = Array.from(inp.files || []); + if (!files.length) return; + const fd = new FormData(); + fd.append("path", state.filesPath || ""); + for (const f of files) fd.append("files", f); + try { + const r = await fetch("/v1/files/upload", { + method: "POST", + headers: { "Authorization": "Bearer " + state.token }, + body: fd, + }); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + throw new Error(d.detail || (r.status + " upload failed")); + } + await loadFiles(); + } catch (e) { + alert("upload failed: " + e.message); + } finally { + inp.value = ""; // 允许重新选同名文件 + } +} + // ───── new task ───── $("hd-new").onclick = async () => { $("nt-name").value = ""; $("nt-wd").value = "";