core(files API): user-rooted /v1/files*,去掉 task_id 前置

文件操作语义上只关心"路径 + user 边界",task_id 是多余拐杖;
同时 §7.1 心智模型把 task 和 dir 定义为正交副视图,API 不该混。

- 4 路由 /v1/tasks/{id}/files* → /v1/files*(列/下载/上传/删)
- 边界从 task_dir 改 user_root (workspace/users/<uid>/)
- dotfile 一律过滤(.memory/ 等系统区不暴露)
- dev SPA:登录即拉 user_root,选 task 自动跳到其 working_dir,
  crumbs root 标"我的",新增 upload 按钮

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-18 07:59:19 +08:00
parent 0c577ba0a5
commit 9a7620f704
4 changed files with 93 additions and 81 deletions

View File

@ -229,7 +229,7 @@ state / messages 两形态都在 PG,FS 只承担 skill 产物。多 task 共享
### 7.2 资源模型(/v1) ### 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/<uid>/` 为边界(不强制选 task)。所有路由统一 `/v1` 前缀,**返 JSON**;前端 / UI 由 platform 端实现,本仓库不维护(§7.9 取舍)。本地开发用 FastAPI 自带 `/docs` Swagger UI 自查;`GET /` 302 跳 `/docs`
``` ```
Tasks Tasks
@ -253,11 +253,12 @@ Tasks
GET /v1/tasks/{id}/runs/{rid}/events SSE 流(见下) GET /v1/tasks/{id}/runs/{rid}/events SSE 流(见下)
POST /v1/tasks/{id}/runs/{rid}/cancel (待) POST /v1/tasks/{id}/runs/{rid}/cancel (待)
Files(per-task,task_dir 副视图) Files(user-rooted,不绑 task — `workspace/users/<uid>/` 为根)
GET /v1/tasks/{id}/files?path= 列子目录 {entries, crumbs, exists, root} GET /v1/files?path= 列子目录 {entries, crumbs, exists, root, current};留空 → user_root;
POST /v1/tasks/{id}/files/upload multipart;path 通过 query 或 form;严格拒含 / \\ .. 的 filename dotfile(`.memory/` 等)一律隐藏(同 /v1/folders 约定)
GET /v1/tasks/{id}/files/download?path= 下载单文件;`..` / 绝对 / symlink 越界 400 POST /v1/files/upload multipart;path 通过 form;严格拒含 / \\ .. 的 filename
POST /v1/tasks/{id}/files/delete {path} 文件或空目录;非空目录 400 GET /v1/files/download?path= 下载单文件;`..` / 绝对 / symlink 越界 400
POST /v1/files/delete {path} 文件或空目录;非空目录 400;user_root 拒
Export Export
GET /v1/tasks/{id}/export docx 临时文件下载,BackgroundTask 删 tmp GET /v1/tasks/{id}/export docx 临时文件下载,BackgroundTask 删 tmp

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 > 配合 `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/<uid>/` 为边界,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/<uid>/`);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 加 `⬆` 按钮 + 隐藏 `<input type=file multiple>`,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/<uid>/ 为边界")。
- **Q1 → 05-06 / Phase 1-4**:骨架 / 三 skill / run_python / Model Profile + Probing。ppt v3 加商务红 + apply_brand + Iconify;素材摄取改 markitdown CLI。 - **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-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/<id>/`;`.gitignore` 删 bandaid。 - **05-07 / TUI + task_dir**:rich Markdown 渲染;spinner 显实时耗时 + 累计 token;system prompt 注入 task_dir 绝对路径,产物收敛 `workspace/tasks/<id>/`;`.gitignore` 删 bandaid。
@ -99,7 +100,7 @@ db/migrations/versions/
0001_initial_schema.py 125 ← §7 B Step 1 0001_initial_schema.py 125 ← §7 B Step 1
0002_task_dir_relative.py 61 ← 现有 ROOT-prefix 绝对 → 相对 0002_task_dir_relative.py 61 ← 现有 ROOT-prefix 绝对 → 相对
web/__init__.py 5 ← Phase G G1 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/auth.py 115 ← D' 过渡:PLATFORM_KEY → JWT 兑换
web/broker.py 88 ← Phase G G4: in-process pub/sub web/broker.py 88 ← Phase G G4: in-process pub/sub
web/sinks.py 20 ← Phase G G4: WebEventSink (§7 A sink 协议) web/sinks.py 20 ← Phase G G4: WebEventSink (§7 A sink 协议)

View File

@ -29,7 +29,7 @@ from pydantic import BaseModel
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from starlette.background import BackgroundTask 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 ( from core.storage import (
NoSubtaskError, NoSubtaskError,
check_no_subtask, check_no_subtask,
@ -109,27 +109,13 @@ def _task_dict(row: Any, *, n_messages: Optional[int] = None) -> dict:
# ─────────────────────── files helpers ─────────────────────── # ─────────────────────── files helpers ───────────────────────
def _load_working_dir(task_id: str, user_id: UUID) -> tuple[UUID, Path]: def _load_user_root(user_id: UUID) -> Path:
"""task_id 解析 + 查 PG 拿 working_dir db form + 还原 absolute Path。 """user_root = `<workspace>/users/<user_id>/`,所有 files API 的边界。
404 / 400 if UUID / task 不存在 / 不属于 user / working_dir 若目录尚未存在自动 mkdir( user 首次访问也能拿到根)
user 视为 not found(不暴露 task 存在性)
""" """
try: from main import resolve_workspace, user_root
tid = UUID(task_id) ws = resolve_workspace(None)
except ValueError: return user_root(ws, user_id)
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)
def _safe_join(root: Path, rel: str) -> Path: def _safe_join(root: Path, rel: str) -> Path:
@ -145,19 +131,22 @@ def _safe_join(root: Path, rel: str) -> Path:
try: try:
target.relative_to(root.resolve()) target.relative_to(root.resolve())
except ValueError: except ValueError:
raise HTTPException(400, f"path escapes working_dir: {rel!r}") raise HTTPException(400, f"path escapes user_root: {rel!r}")
return target return target
def _rel_to(root: Path, target: Path) -> str: def _rel_to(root: Path, target: Path) -> str:
try: try:
return target.resolve().relative_to(root.resolve()).as_posix() rel = target.resolve().relative_to(root.resolve()).as_posix()
except ValueError: except ValueError:
return "" return ""
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) -> 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] = [] entries: list[dict] = []
exists = current.exists() exists = current.exists()
if exists and current.is_dir(): if exists and current.is_dir():
@ -166,6 +155,8 @@ 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("."):
continue
try: try:
st = p.stat() st = p.stat()
except OSError: 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( def list_files(
task_id: str,
path: str = "", path: str = "",
user_id: UUID = Depends(require_user), user_id: UUID = Depends(require_user),
): ):
"""列子目录条目 + 面包屑。`path` 留空 → root;`../` / 绝对 → 400。""" """列 user_root 下子目录条目 + 面包屑。`path` 留空 → user_root;
tid, root = _load_working_dir(task_id, user_id) `../` / 绝对 400dotfile(`.memory/` )一律隐藏
"""
root = _load_user_root(user_id)
current = _safe_join(root, path) current = _safe_join(root, path)
entries, crumbs, exists = _enumerate_files(root, current) entries, crumbs, exists = _enumerate_files(root, current)
return { return {
"task_id": str(tid),
"root": _norm_path(str(root)), "root": _norm_path(str(root)),
"current": _rel_to(root, current), "current": _rel_to(root, current),
"exists": exists, "exists": exists,
@ -714,14 +705,13 @@ def create_app() -> FastAPI:
"entries": entries, "entries": entries,
} }
@app.get("/v1/tasks/{task_id}/files/download", tags=["files"]) @app.get("/v1/files/download", tags=["files"])
def download_file( def download_file(
task_id: str,
path: str, path: str,
user_id: UUID = Depends(require_user), user_id: UUID = Depends(require_user),
): ):
"""下载单个 regular file(目录 → 400 / 不存在 → 404)。""" """下载 user_root 下单个 regular file(目录 → 400 / 不存在 → 404)。"""
tid, root = _load_working_dir(task_id, user_id) root = _load_user_root(user_id)
target = _safe_join(root, path) target = _safe_join(root, path)
if not target.exists(): if not target.exists():
raise HTTPException(404, f"file not found: {path}") raise HTTPException(404, f"file not found: {path}")
@ -729,18 +719,17 @@ def create_app() -> FastAPI:
raise HTTPException(400, f"not a file: {path}") raise HTTPException(400, f"not a file: {path}")
return FileResponse(path=str(target), filename=target.name) 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( async def upload_files(
task_id: str,
path: str = Form(""), path: str = Form(""),
files: list[UploadFile] = File(...), files: list[UploadFile] = File(...),
user_id: UUID = Depends(require_user), user_id: UUID = Depends(require_user),
): ):
"""multipart 多文件上传到 `<task_dir>/<path>/`。 """multipart 多文件上传到 `<user_root>/<path>/`。
路径不存在自动 mkdir(parents=True);重名直接覆盖 路径不存在自动 mkdir(parents=True);重名直接覆盖
文件名严格校验( `/ \\ ..` 或为空 400) 文件名严格校验( `/ \\ ..` 或为空 400)
""" """
tid, root = _load_working_dir(task_id, user_id) root = _load_user_root(user_id)
dest_dir = _safe_join(root, path) dest_dir = _safe_join(root, path)
if dest_dir.exists() and not dest_dir.is_dir(): if dest_dir.exists() and not dest_dir.is_dir():
raise HTTPException(400, f"upload target is a file, not a directory: {path}") raise HTTPException(400, f"upload target is a file, not a directory: {path}")
@ -760,7 +749,7 @@ def create_app() -> FastAPI:
try: try:
dest.resolve().relative_to(root.resolve()) dest.resolve().relative_to(root.resolve())
except ValueError: 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() data = await up.read()
dest.write_bytes(data) dest.write_bytes(data)
saved.append({"name": raw_name, "size": len(data), "rel": _rel_to(root, dest)}) 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") raise HTTPException(400, "no files uploaded")
return {"count": len(saved), "saved": saved} 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( def delete_file(
task_id: str,
body: FileDeleteRequest, body: FileDeleteRequest,
user_id: UUID = Depends(require_user), user_id: UUID = Depends(require_user),
): ):
"""task_dir 下文件或**空**目录。非空目录 → 400(避免误操);root → 400。""" """user_root 下文件或**空**目录。非空目录 → 400(避免误操);root → 400。"""
tid, root = _load_working_dir(task_id, user_id) root = _load_user_root(user_id)
target = _safe_join(root, body.path) target = _safe_join(root, body.path)
if target.resolve() == root.resolve(): if target.resolve() == root.resolve():
raise HTTPException(400, "cannot delete task_dir root") raise HTTPException(400, "cannot delete user_root")
if not target.exists(): if not target.exists():
raise HTTPException(404, f"path not found: {body.path}") raise HTTPException(404, f"path not found: {body.path}")
try: try:

View File

@ -322,10 +322,12 @@
<span class="label">files</span> <span class="label">files</span>
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px;"></span> <span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px;"></span>
<span class="spacer"></span> <span class="spacer"></span>
<button id="btn-refresh-files" class="small" disabled></button> <button id="btn-upload" class="small" title="上传文件到当前目录"></button>
<button id="btn-refresh-files" class="small"></button>
</div> </div>
<div id="file-crumbs" class="crumbs muted">(no task selected)</div> <div id="file-crumbs" class="crumbs muted">loading…</div>
<div id="file-list"></div> <div id="file-list"></div>
<input type="file" id="upload-input" multiple style="display:none;" />
</div> </div>
</div> </div>
@ -489,6 +491,7 @@ function enterApp() {
$("app").classList.add("ready"); $("app").classList.add("ready");
$("hd-who").textContent = state.userId; $("hd-who").textContent = state.userId;
loadTaskList(); loadTaskList();
loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root
} }
async function loadTaskList() { async function loadTaskList() {
@ -607,7 +610,10 @@ async function selectTask(tid) {
state.taskMeta = meta; state.taskMeta = meta;
renderChatMeta(); renderChatMeta();
await loadMessages(); 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(); await loadFiles();
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
@ -636,7 +642,6 @@ function renderChatMeta() {
$("btn-abandon").disabled = !active; $("btn-abandon").disabled = !active;
$("btn-delete-task").disabled = false; // delete 不限 status(用户显式 confirm) $("btn-delete-task").disabled = false; // delete 不限 status(用户显式 confirm)
$("btn-export").disabled = (t.n_messages || 0) === 0; $("btn-export").disabled = (t.n_messages || 0) === 0;
$("btn-refresh-files").disabled = false;
} }
async function loadMessages() { async function loadMessages() {
@ -861,23 +866,19 @@ async function deleteCurrentTask() {
if (!confirm(`确认硬删除 task "${projName}" (${nMsg} 条消息)?\n\n会清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return; if (!confirm(`确认硬删除 task "${projName}" (${nMsg} 条消息)?\n\n会清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return;
try { try {
await api("DELETE", "/v1/tasks/" + state.taskId); await api("DELETE", "/v1/tasks/" + state.taskId);
// 清空 chat / files 面板,回到初始态 // 清 chat 面板,回到初始态;files 面板与 task 解耦,保留当前路径(FS 文件仍在)
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; } if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
state.taskId = null; state.taskId = null;
state.taskMeta = null; state.taskMeta = null;
state.filesPath = "";
$("chat-meta").innerHTML = `<span class="muted">(no task selected)</span>`; $("chat-meta").innerHTML = `<span class="muted">(no task selected)</span>`;
$("chat-stream").innerHTML = `<div class="empty">select a task on the left</div>`; $("chat-stream").innerHTML = `<div class="empty">select a task on the left</div>`;
$("chat-form").style.display = "none"; $("chat-form").style.display = "none";
$("file-crumbs").innerHTML = `<span class="muted">(no task selected)</span>`;
$("file-list").innerHTML = "";
$("files-proj").textContent = "";
$("btn-done").disabled = true; $("btn-done").disabled = true;
$("btn-abandon").disabled = true; $("btn-abandon").disabled = true;
$("btn-delete-task").disabled = true; $("btn-delete-task").disabled = true;
$("btn-export").disabled = true; $("btn-export").disabled = true;
$("btn-refresh-files").disabled = true;
loadTaskList(); loadTaskList();
loadFiles(); // FS 还在,刷新当前路径(可能文件夹仍可见)
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
alert("delete failed: " + e.message); alert("delete failed: " + e.message);
@ -900,36 +901,32 @@ $("btn-export").onclick = () => {
}); });
}; };
// ───── files ───── // ───── files(user-rooted,不绑 task) ─────
$("btn-refresh-files").onclick = () => loadFiles(); $("btn-refresh-files").onclick = () => loadFiles();
$("btn-upload").onclick = () => $("upload-input").click();
$("upload-input").addEventListener("change", uploadSelected);
async function loadFiles() { async function loadFiles() {
if (!state.taskId) return;
try { try {
const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : ""; 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); renderFiles(data);
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
if (e.status === 400) {
$("file-crumbs").innerHTML = `<span class="muted">(no working_dir bound)</span>`;
$("file-list").innerHTML = "";
} else {
$("file-crumbs").innerHTML = `<span class="muted">${escapeHtml(e.message)}</span>`; $("file-crumbs").innerHTML = `<span class="muted">${escapeHtml(e.message)}</span>`;
$("file-list").innerHTML = ""; $("file-list").innerHTML = "";
} }
}
} }
function renderFiles(data) { function renderFiles(data) {
// pane-head 显示项目名(working_dir 末段),让用户清楚"现在看的是哪个项目里的文件" // 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文
const projName = (state.taskMeta && state.taskMeta.working_dir) const segs = (data.current || "").split("/").filter(Boolean);
? state.taskMeta.working_dir.split("/").filter(Boolean).pop() : ""; const projName = segs[0] || "";
$("files-proj").textContent = projName ? "· " + projName : ""; $("files-proj").textContent = projName ? "· " + projName : "· (user root)";
$("files-proj").title = (state.taskMeta && state.taskMeta.working_dir) || ""; $("files-proj").title = data.root || "";
// crumbs root 用项目名替代 "/",更直观 // crumbs root 标"我的"(user_root),更直观;其余原样
const cr = data.crumbs.map((c, i) => { 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; const isLast = i === data.crumbs.length - 1;
if (isLast) return `<span>${escapeHtml(label)}</span>`; if (isLast) return `<span>${escapeHtml(label)}</span>`;
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`; return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
@ -975,7 +972,7 @@ async function deleteFile(rel, name, isDir) {
const what = isDir ? "目录" : "文件"; const what = isDir ? "目录" : "文件";
if (!confirm(`确认删除${what} "${name}"?` + (isDir ? "\n(非空目录会失败,先清里面再删)" : ""))) return; if (!confirm(`确认删除${what} "${name}"?` + (isDir ? "\n(非空目录会失败,先清里面再删)" : ""))) return;
try { try {
await api("POST", `/v1/tasks/${state.taskId}/files/delete`, { path: rel }); await api("POST", "/v1/files/delete", { path: rel });
await loadFiles(); await loadFiles();
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
@ -984,7 +981,7 @@ async function deleteFile(rel, name, isDir) {
} }
function downloadFile(rel) { 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 }, headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => { }).then(async (r) => {
if (!r.ok) { alert("download failed: " + r.status); return; } 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 ───── // ───── new task ─────
$("hd-new").onclick = async () => { $("hd-new").onclick = async () => {
$("nt-name").value = ""; $("nt-wd").value = ""; $("nt-name").value = ""; $("nt-wd").value = "";