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:
parent
0c577ba0a5
commit
9a7620f704
13
DESIGN.md
13
DESIGN.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 协议)
|
||||||
|
|
|
||||||
76
web/app.py
76
web/app.py
|
|
@ -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)
|
`../` / 绝对 → 400。dotfile(`.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:
|
||||||
|
|
|
||||||
|
|
@ -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 = "";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue