api+ui(files): POST /v1/files/delete 加 recursive 字段 — 顶层目录被 task 引用闸 + dev SPA 二次确认显示条目数

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-20 14:38:58 +08:00
parent 5ff09b9aca
commit e1f09547e0
5 changed files with 130 additions and 12 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`
最后更新:2026-05-20(`POST /v1/tasks/{id}/clear` 清空对话 + dev SPA 清空对话按钮)
最后更新:2026-05-20(`POST /v1/files/delete` 加 `recursive` 字段 + 顶层目录 task 引用闸 + dev SPA 二次确认)
---
@ -23,6 +23,7 @@
### 2026-05-20
- **`POST /v1/files/delete``recursive` 字段(级联删除非空目录) + 顶层目录 task 引用闸 + dev SPA 二次确认显示条目数**:用户报"文件夹内有文件就不给删除",需要级联删除。**后端**:`FileDeleteRequest` 加 `recursive: bool = False`,handler `recursive=False` 沿用 `target.rmdir()`(非空仍 400);`recursive=True` 走 `shutil.rmtree`,但**目标是顶层目录(`target.parent.resolve() == root.resolve() and is_dir()`)且被 ≥1 task 引用**(`SELECT count(*) FROM tasks WHERE user_id=uid AND working_dir=db_form`) → **409**,文案"该顶层目录正被 N 个 task 引用,不能递归删除;请先 DELETE task,再清残留文件"。这复用 `move` 接口的"working_dir = 顶层目录"invariant 守门思路 —— 允许递归删 working_dir 会让 DB 还在引用但 FS artifacts 已没了;DELETE task 流程已经 best-effort rmdir 空目录,DB 行删掉后顶层目录回到"无 task 引用"状态,这时 recursive delete 才放行。空目录(顶层或子级)两种模式都可删,task.working_dir 字段不动(沿用"FS 视图可重生"心智)。**前端**(`web/static/dev.html::deleteFile`):目录删先 `GET /v1/files?path=rel` 探子条目,空目录走原 confirm(`recursive=false`);非空目录二次确认"目录 X 含 N 项(含子目录),将递归删除全部内容,不可恢复。(若为顶层目录且仍被 task 引用,需先删 task)\n确认?"+ `recursive=true`。**没动**:`DELETE /v1/tasks/{id}` 流程(那条仍只 rmdir 空目录,保留"删 task ≠ 删素材"心智)、`POST /v1/files/move` 的顶层目录闸(那是为了维持 invariant,递归删的 409 文案对齐 move 的 409 语义)、smoke 测试(原 case 1/4/6/7 仍跑非递归路径)、DESIGN(API 字段添加非架构变更)。**Tradeoff**:UI 显示的是直接子项条目数,深层子树文件数不预报(只标"含子目录"提示);加 `count` 后端 helper 又给前端一次额外探询,体感分裂,先简单版。
- **fs tool 输出渲染为 user_root-relative 路径(根因消 chip 404 + 防 uuid/部署根泄漏) + dev SPA chip 工作目录锚点修正 + assistant 正文也挂 chip**:用户报对话内 chip 点击 404,根因不在 chip 抽取本身 —— `task.working_dir` DB 形态是 `workspace/users/<uuid>/<name>`(`to_db_path`),前端 `filesPath` 取了 `.split("/").pop()` 末段但 chip 提取器之前直接拿整串作锚点,正则吃到 `workspace/users/<uuid>/<wd>/foo.md`,backend `_safe_join` 拼出来不存在 → 404。两层修:① **tool 侧根治**:`tools/base.py::Tool` 加 `user_root` kwarg + `_display(p)` helper(p 在 user_root 内 → POSIX 相对串,外 → 原绝对),`tools/fs.py` 五个 tool(Read/Write/Edit/Glob/Grep)所有结果串里 `{p}` 替成 `{self._display(p)}` — 现在 `[wrote N chars to wd/foo.md]` 而不再 `[wrote N chars to /home/lighthouse/.../<uuid>/wd/foo.md]`。`core/agent_builder.py::build_agent` 加 `ur_path = user_root(workspace_dir, uid)` 并透传给所有 tool 构造(含 LoadSkillTool / RunPythonTool / ShellTool — base 默认接 None 不影响);`tools/skill_tool.py::LoadSkillTool.__init__` 加 `user_root` 转传 super。**附带收益**:截图分享对话不再泄 user_id + 服务器路径根;chip rel 直接就是 user_root-relative,与 `/v1/files/download` 边界吻合。② **前端 chip 锚点修正**:`web/static/dev.html` 加 `_workingDirName(workingDir)` helper —— `\``/` 后,绝对路径(`/...` 或 `C:/...`)返空(外部 --working-dir 文件不在 user_root,backend 也拒,挂 chip 无意义),否则取最后非空段。5 个 chip 抽取调用点(`renderMessages` 的 tool / assistant tool_calls + assistant 正文 + `handleSseEvent` 的 tool_call / tool_result)统一用这个 helper 代替原 `state.taskMeta.working_dir` 直取。③ **assistant 正文也挂 chip**:`renderMessages` 里 assistant `<div class="body">` 渲完后 `extractArtifactRels(p.content, wd)` 抽出助手 echo 的路径同样挂 chip 条(user 输入不抽,避免他打字过程中误触发)。流式途中不实时挂 — `fetchSse` 收尾自动 `loadMessages()` 重渲染,chip 顺势出现,降低实现复杂度。**没动**:`/v1/files/download` 后端(本来就接 user_root-relative)、ShellTool / RunPythonTool 的 stdout/stderr(subprocess 自己 print 的绝对路径无法干预,且不是 agent 工具直接吐的"系统消息")、DESIGN(无架构/schema 变化)、RUN(无对外命令变化)。**Tradeoff**:旧消息(本次改动前历史 tool result)里仍有绝对路径,但 chip 抽取以 wdName 末段为锚 → 旧路径里的 `/<wdName>/...` 子串也能匹配出正确 rel,**新旧消息 chip 都可点**(回测验证:`extractArtifactRels("/home/.../uuid/wd/foo.md", "wd")` 返 `["wd/foo.md"]`)。
- **`POST /v1/tasks/{id}/clear` 清空对话 + dev SPA「清空对话」按钮**:用户要在同一 task 内重新开始对话。后端新路由:同事务 `SELECT … FOR UPDATE` 锁 + `run_status in (running, cancelling)` → 409(先 cancel)+ `DELETE FROM messages WHERE task_id=tid` + reset `tasks.tokens_prompt/completion/cost_usd=0` + `run_status='idle'` + `run_error=None`,返回新 task dict(`n_messages=0`)。**`usage_events` 表完全不动** — 那是用户级账户账单的 source of truth,清空对话不该影响计费;`usage_events.message_id` FK 是 `ondelete=SET NULL`(models.py:128),message_id 列变 NULL,但 task_id/model_profile/units(tokens_in/out)/cost_usd 全保留,按 task_id 聚合可重建历史累计。**reset task 三列累计 vs 保留累计**:选 reset,因为顶栏「N 条 · M tok」显示"0 条 vs 50k tok"会视觉矛盾;真正账单数据在 usage_events 完整无损。dev SPA 顶栏在「导出对话记录」后插「清空对话」按钮(紫色 hover #8e44ad,区别于完成绿/废弃橙/删除红),`renderChatMeta` 里 `running||n_messages==0 → disabled`,confirm 二次确认(显示任务名 + 消息数),clear 后 `renderMessages([])` + `renderChatMeta()` + `loadTaskList()` 同步列表。**没动**:DESIGN(无架构/schema 字段语义变化)、其他 task 写路径、FS 文件(沿用 task delete 的"FS 视图可重生"心智 — 中间产物保留,模型重起对话可继续基于已有素材推进)、SSE 协议。
- **dev SPA 对话内 tool_call/result 加 artifact chip(复用文件预览 modal)**:用户反馈"中间产物只能在右栏点,对话里不能直接预览/下载"。`web/static/dev.html` 新加两个 helper:`extractArtifactRels(text, workingDir)` 把文本里 `\` 一律归 `/`,正则锚定 `<working_dir>/...`(lead 边界字符类 `[\s"'\`/=:,()<>\[\]{}|]` 避免 `multi_proj_x` 误匹配,末段必须含 `.` 把目录滤掉),Set 去重;`renderArtifactBarHtml(rels)` 渲一行 `.art-chip` 小药丸(`📄 文件名`,前缀 emoji + hover 翻品牌红)。四个渲染点都插入 chip 条:① `renderMessages``role==="tool"` 历史卡;② `renderMessages` 的 assistant `tool_calls` 历史;③ `handleSseEvent``tool_call` 流式;④ `handleSseEvent``tool_result` 流式。`chat-stream` 上加点击委托 → `openFilePreview(rel)`,modal 内已带"下载"按钮所以 chip 不另开二级图标。**取舍**:路径识别限定 `working_dir/` 前缀(skill 脚本 `cd` 后只 print 纯相对路径的情况会漏抓,v1 误判控制代价);纯目录(末段无 `.`)直接跳过。**没动**:右栏文件面板、`openFilePreview` / `downloadFile` 接口(纯复用)、后端、DESIGN、RUN(对外行为零变化,纯 UI 增量)。

2
RUN.md
View File

@ -130,7 +130,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 |
| `GET /v1/files/download?path=` | 下单文件 | 必填 |
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 |
| `POST /v1/files/delete` | `{path}`;文件或空目录;顶层目录(working_dir 视图)可独立删,task.working_dir 字段不动,下次 build_agent 按需 mkdir 重建 | 必填 |
| `POST /v1/files/delete` | `{path, recursive?=false}`;`recursive=false` 文件或空目录(非空 → 400);`recursive=true` `shutil.rmtree` —— 顶层目录被 task 引用 → 409(先 DELETE task);空目录两种模式都可删,task.working_dir 字段不动,下次 build_agent 按需 mkdir 重建 | 必填 |
| `POST /v1/files/rename` | `{path, new_name}`;sibling 已存在 → 409;**path 顶层目录** → 同事务 UPDATE tasks.working_dir + FOR UPDATE 锁;有 running/cancelling → 409;check_no_subtask 防嵌套 → 409 | 必填 |
| `GET /v1/tasks/{id}/export` | 对话导出 .docx | 必填 |

View File

@ -179,6 +179,74 @@ def main() -> int:
assert (ws / "keep_dir" / "user_file.txt").is_file()
case("DELETE task 非空 working_dir 保留", t9)
# case 10: recursive=False 删非空目录 → 400
def t10():
(ws / "to_rm_shallow" / "f.txt").parent.mkdir(parents=True, exist_ok=True)
(ws / "to_rm_shallow" / "f.txt").write_text("x", encoding="utf-8")
r = client.post(
"/v1/files/delete",
json={"path": "to_rm_shallow"},
headers=H,
)
assert r.status_code == 400, r.text
# 目录与内容应原封不动
assert (ws / "to_rm_shallow" / "f.txt").is_file()
case("delete 非空目录 recursive=False → 400", t10)
# case 11: recursive=True 删非空顶层目录(无 task 引用)→ 200,整树清
def t11():
# 沿用 t10 留下的 to_rm_shallow
r = client.post(
"/v1/files/delete",
json={"path": "to_rm_shallow", "recursive": True},
headers=H,
)
assert r.status_code == 200, r.text
assert not (ws / "to_rm_shallow").exists()
case("delete 非空顶层目录 recursive=True 无 task 引用 → 200", t11)
# case 12: recursive=True 删顶层目录但被 task 引用 → 409
def t12():
r = client.post("/v1/tasks", json={"name": "occupied_top"}, headers=H)
assert r.status_code == 201, r.text
tid = r.json()["task_id"]
(ws / "occupied_top" / "art.md").write_text("artifact", encoding="utf-8")
r = client.post(
"/v1/files/delete",
json={"path": "occupied_top", "recursive": True},
headers=H,
)
assert r.status_code == 409, r.text
assert "task 引用" in r.text
# 内容应保留
assert (ws / "occupied_top" / "art.md").is_file()
# 删 task 后再递归删 → 200
r = client.delete(f"/v1/tasks/{tid}", headers=H)
assert r.status_code == 204, r.text
r = client.post(
"/v1/files/delete",
json={"path": "occupied_top", "recursive": True},
headers=H,
)
assert r.status_code == 200, r.text
assert not (ws / "occupied_top").exists()
case("delete 顶层目录 recursive=True 有 task 引用 → 409;删 task 后放行", t12)
# case 13: recursive=True 删非空子级目录(不可能撞 task,working_dir 永远顶层)→ 200
def t13():
(ws / "proj_a2" / "deep" / "a" / "b.txt").parent.mkdir(parents=True, exist_ok=True)
(ws / "proj_a2" / "deep" / "a" / "b.txt").write_text("y", encoding="utf-8")
r = client.post(
"/v1/files/delete",
json={"path": "proj_a2/deep", "recursive": True},
headers=H,
)
assert r.status_code == 200, r.text
assert not (ws / "proj_a2" / "deep").exists()
# 父顶层目录不受影响
assert (ws / "proj_a2").is_dir()
case("delete 非空子级 recursive=True → 200", t13)
print("\n[ALL PASS]")
return 0

View File

@ -338,6 +338,7 @@ class MessageRequest(BaseModel):
class FileDeleteRequest(BaseModel):
path: str
recursive: bool = False
class FileRenameRequest(BaseModel):
@ -1103,10 +1104,14 @@ def create_app() -> FastAPI:
body: FileDeleteRequest,
user_id: UUID = Depends(require_user),
):
"""删 user_root 下文件或**空**目录。非空目录 → 400;root → 400
"""删 user_root 下文件或目录。
顶层目录(working_dir 视图)可独立删 task.working_dir 字段不动,
下次 build_agent / 写文件入口按需 mkdir 重建,FS 目录视为可重生
- `recursive=False`(默认):目录必须为空(`rmdir`),非空 400
- `recursive=True`:`shutil.rmtree`;若目标是顶层目录且被某 task.working_dir
引用 409,提示先 DELETE task(避免 DB 还引用FS artifacts 已清的错位)
- 顶层空目录 / 子级空目录无论 recursive 与否都可删:task.working_dir 字段不动,
下次 build_agent 按需 mkdir 重建,FS 目录视为可重生
- root 400;不存在 404
"""
root = _load_user_root(user_id)
target = _safe_join(root, body.path)
@ -1115,9 +1120,31 @@ def create_app() -> FastAPI:
if not target.exists():
raise HTTPException(404, f"path not found: {body.path}")
if target.is_dir() and body.recursive:
is_top_level = target.parent.resolve() == root.resolve()
if is_top_level:
db_form = to_db_path(target)
with session_scope() as s:
n = s.execute(
select(func.count()).select_from(Task).where(
Task.user_id == user_id,
Task.working_dir == db_form,
)
).scalar_one() or 0
if n:
raise HTTPException(
409,
f"该顶层目录正被 {n} 个 task 引用,不能递归删除;"
f"请先 DELETE task,再清残留文件",
)
try:
if target.is_dir():
target.rmdir() # 非空目录会触发 OSError
if body.recursive:
import shutil
shutil.rmtree(target)
else:
target.rmdir() # 非空目录会触发 OSError
else:
target.unlink()
except OSError as e:

View File

@ -1884,13 +1884,35 @@ function fileMenuItems(e) {
}
async function deleteFile(rel, name, isDir) {
const what = isDir ? "目录" : "文件";
const tip = isDir
? "\n(非空目录会失败;若为顶层目录且仍被 task 引用,需先删 task)"
: "";
if (!confirm(`确认删除${what} "${name}"?` + tip)) return;
let recursive = false;
if (!isDir) {
if (!confirm(`确认删除文件 "${name}"?`)) return;
} else {
// 探一下目录内容:空目录走普通 rmdir;非空才递归,二次确认显示条目数
let entries;
try {
const data = await api("GET", "/v1/files?path=" + encodeURIComponent(rel));
entries = data.entries || [];
} catch (e) {
if (e.status === 401) { logout(); return; }
alert("读目录失败:" + e.message);
return;
}
if (entries.length === 0) {
if (!confirm(`确认删除空目录 "${name}"?`)) return;
} else {
const hasSub = entries.some((x) => x.is_dir);
const tip = hasSub ? "(含子目录)" : "";
if (!confirm(
`目录 "${name}" 含 ${entries.length} 项${tip},` +
`将递归删除全部内容,不可恢复。\n` +
`(若为顶层目录且仍被 task 引用,需先删 task)\n确认?`
)) return;
recursive = true;
}
}
try {
await api("POST", "/v1/files/delete", { path: rel });
await api("POST", "/v1/files/delete", { path: rel, recursive });
await loadFiles();
// 删的若是顶层目录,folders 列表也得跟着变;子级删除走这里也无副作用
await loadFolderSuggestions();