feat(files): create folders from file panel
This commit is contained in:
parent
e73521b3c9
commit
02e7d2cb14
|
|
@ -5,6 +5,11 @@
|
|||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||
|
||||
## 0.62.7 — 2026-08-06
|
||||
|
||||
- 文件面板的隐藏目录开关现在始终可见,用户根目录中的平台隐藏目录仍不会展示。
|
||||
- 文件面板现在可以在根目录或当前目录新建文件夹,并在创建后直接进入,方便先上传或选入资料,再使用该目录开启新对话。
|
||||
|
||||
## 0.62.6 — 2026-08-06
|
||||
|
||||
- 修复对话区宽度较窄时模型标签被拆成两行的问题。
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
|
|||
- `auto_title_pending`(0023)只是一轮 UI 命名闸,不是 task 状态机;旧创建入口/存量行恒 false,快速入口首发后消费,人工改名优先清闸。
|
||||
- **0004 简化**:runs 表只写不读、run_id 单活 run 下全冗余 → 合并 `run_status/run_error` 入 tasks。**0006**:`tasks.model_profile` 为 source-of-truth(PATCH 切、下条 send 生效);usage_events 重建 v2 多态形态,统计 source-of-truth;tasks 三列保留作粗概览。run_status 终态:ok 收回 idle,error(出错)与 cancelled(用户停止)是持久终态 —— 前端 `renderPersistedRunTerminal` 据此在每次重渲后补持久卡(扛过收尾 loadMessages 整屏重建),刷新/切任务仍在;下次起新 run(post_message 写 running)覆盖清掉。
|
||||
- **No-subtask**:同 user 下前缀互含即拒(归一 posix 后 Python 端比对);同 working_dir 允许。
|
||||
- **文件面板先备料**:user_root 与普通目录都可显式新建直接子目录;创建成功后前端进入该目录,用户可先上传/选入资料,再把顶层空目录选作新对话 working_dir。目录 leaf 复用 `validate_task_name`,不允许借 UI 创建点目录或路径式名称。
|
||||
- **DB-aware service 是顶层 working_dir mutation 的唯一原语,DB-FS 一致性服务端内化**:文件面板 rename 与对话 `rename_working_dir` 共用同一服务(事务锁关联 task、running→409、DB UPDATE 先于 FS);对话工具只登记本轮内存动作,等 agent 正常回复完、当前 task 退出 running 后执行,避免 executor/system prompt 仍握旧 cwd。服务在收尾前退出时动作丢失但目录不变,不引持久队列或 migration。delete 仍仅走 files API,被 task 引用时 409。
|
||||
- **单一 PG ORM**(本地 + SaaS 共用):一份 schema 一份查询,无 adapter,alembic 管 migration。
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-08-06(模型标签禁止断行,bump 0.62.6)
|
||||
最后更新:2026-08-06(文件面板建目录与隐藏开关常驻,bump 0.62.7)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
|
||||
### 2026-08-06
|
||||
|
||||
- **08-06 / 0.62.7 / 文件面板先备料 + 隐藏开关常驻**:文件栏 `.*` 开关不再随浏览路径消失,但后端继续保证 user_root 平台点目录永不展示;新增“+”入口和 `POST /v1/files/mkdir`,可在根目录或当前目录创建直接子目录,创建成功后自动进入并刷新新对话目录候选,形成“建目录→上传/选入→开新对话”流程。目录名复用 working_dir 校验,点目录、路径式名称与重名分别拒绝;相关静态前端及无 DB 路由共 33 项 unittest、JavaScript 语法、Ruff 致命规则和 diff 检查通过。无 schema、migration 或依赖变化,未连接生产 DB。
|
||||
- **08-06 / 0.62.6 / 模型标签禁止断行**:对话顶部模型控件增加不可断行约束,中栏宽度收紧时优先压缩下拉框,不再把“模型”两个汉字拆成两行;静态前端与更新日志共 12 项 unittest、diff 检查通过。纯样式修复,无 schema、migration、HTTP API、依赖或运行方式变化,未连接生产 DB。
|
||||
- **08-06 / 0.62.5 / 新对话模型控件单行 + 媒体入口恢复**:新对话顶部改用专用的 `draft-controls` 单行布局,不再复用历史 task meta 的强制换行 spacer;模型清单异步加载完成后统一重绘草稿 meta,同时恢复既有“⚙ 媒体”入口及生图/生视频选择弹层。相关静态前端、无 DB 路由及更新日志共 34 项 unittest、JavaScript 语法与 diff 检查通过;当前环境无可用浏览器实例,未连接生产 DB。无 schema、migration、HTTP API、依赖或运行方式变化。
|
||||
- **08-06 / 0.62.4 / 新对话创建前选择模型 + 目录浮层优化**:快速新对话草稿在顶部复用既有模型选择位置,只展示当前用户可用模型并在首次发送或上传附件懒创建 task 时持久化所选档位;清单失败继续由后端默认模型兜底。工作目录候选改为视口级浮层,按可用空间自动向上或向下展开,不再扩大 `chat-stream` 滚动范围;窄屏保持标题与模型同排。相关静态前端、无 DB 路由及更新日志共 34 项 unittest、JavaScript 语法与 diff 检查通过;当前环境无可用浏览器实例,未连接生产 DB。无 schema、migration、HTTP API、依赖或运行方式变化。
|
||||
|
|
|
|||
1
RUN.md
1
RUN.md
|
|
@ -368,6 +368,7 @@ $env:ZCBOT_EVAL_TOKEN = "<dedicated-eval-user-jwt>"
|
|||
| `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` | 必填 |
|
||||
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 |
|
||||
| `POST /v1/files/mkdir` | `{path?="", name}`;在现有 `<user_root>/<path>/` 下新建直接子目录,禁止点目录和路径式名称;重名 → 409 | 必填 |
|
||||
| `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 | 必填 |
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.62.6"
|
||||
__version__ = "0.62.7"
|
||||
|
|
|
|||
|
|
@ -60,6 +60,21 @@ class StaticVendorTests(unittest.TestCase):
|
|||
self.assertIn(":focus-visible", css)
|
||||
self.assertIn("@media (max-width: 640px)", css)
|
||||
|
||||
def test_hidden_directory_toggle_stays_visible(self) -> None:
|
||||
html = DEV_HTML.read_text(encoding="utf-8")
|
||||
files_js = (JS_DIR / "files.js").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn('id="btn-toggle-hidden" class="small"', html)
|
||||
self.assertNotIn(
|
||||
'id="btn-toggle-hidden" class="small" title="显示点目录" '
|
||||
'aria-label="显示点目录" aria-pressed="false" hidden',
|
||||
html,
|
||||
)
|
||||
self.assertNotIn("hiddenToggle.hidden", files_js)
|
||||
self.assertIn('id="btn-new-folder" class="small"', html)
|
||||
self.assertIn('$("btn-new-folder").onclick = createFolder;', files_js)
|
||||
self.assertIn('await api("POST", "/v1/files/mkdir"', files_js)
|
||||
|
||||
def test_ui_polish_keeps_mobile_composer_and_reading_rail_contracts(self) -> None:
|
||||
html = DEV_HTML.read_text(encoding="utf-8")
|
||||
css = UI_CSS.read_text(encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -316,6 +316,46 @@ class FilesRoutesTests(unittest.TestCase):
|
|||
)
|
||||
self.assertFalse(root["hidden_dirs_included"])
|
||||
|
||||
def test_create_directory_at_root_and_in_current_directory(self):
|
||||
root_name = "mkdir-api-root"
|
||||
root_dir = _user_root() / root_name
|
||||
try:
|
||||
created = _client.post(
|
||||
"/v1/files/mkdir",
|
||||
json={"path": "", "name": f" {root_name} "},
|
||||
headers=_AUTH,
|
||||
)
|
||||
self.assertEqual(created.status_code, 200)
|
||||
self.assertEqual(created.json()["path"], root_name)
|
||||
self.assertTrue(root_dir.is_dir())
|
||||
|
||||
child = _client.post(
|
||||
"/v1/files/mkdir",
|
||||
json={"path": root_name, "name": "资料"},
|
||||
headers=_AUTH,
|
||||
)
|
||||
self.assertEqual(child.status_code, 200)
|
||||
self.assertEqual(child.json()["path"], f"{root_name}/资料")
|
||||
self.assertTrue((root_dir / "资料").is_dir())
|
||||
|
||||
duplicate = _client.post(
|
||||
"/v1/files/mkdir",
|
||||
json={"path": root_name, "name": "资料"},
|
||||
headers=_AUTH,
|
||||
)
|
||||
self.assertEqual(duplicate.status_code, 409)
|
||||
|
||||
for name in (".hidden", "../escape", "a/b"):
|
||||
with self.subTest(name=name):
|
||||
invalid = _client.post(
|
||||
"/v1/files/mkdir",
|
||||
json={"path": root_name, "name": name},
|
||||
headers=_AUTH,
|
||||
)
|
||||
self.assertEqual(invalid.status_code, 400)
|
||||
finally:
|
||||
shutil.rmtree(root_dir, ignore_errors=True)
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,12 @@ from core.working_dirs import (
|
|||
)
|
||||
|
||||
from ..common import norm_path
|
||||
from ..schemas import FileDeleteRequest, FileRenameRequest, FileTransferRequest
|
||||
from ..schemas import (
|
||||
FileDeleteRequest,
|
||||
FileMkdirRequest,
|
||||
FileRenameRequest,
|
||||
FileTransferRequest,
|
||||
)
|
||||
from ..userfiles import (
|
||||
enumerate_files,
|
||||
load_user_root,
|
||||
|
|
@ -299,6 +304,42 @@ def register_file_routes(app, *, require_user) -> None:
|
|||
raise HTTPException(400, "no files uploaded")
|
||||
return {"count": len(saved), "saved": saved}
|
||||
|
||||
@app.post("/v1/files/mkdir", tags=["files"])
|
||||
def create_directory(
|
||||
body: FileMkdirRequest,
|
||||
user_id: UUID = Depends(require_user),
|
||||
):
|
||||
"""在 `<user_root>/<path>/` 下创建一个直接子目录。
|
||||
|
||||
name 与 working_dir 共用 leaf 校验,禁止路径分隔符、NUL 和点目录;
|
||||
parent 必须已存在且为目录,目标已存在返回 409,不递归补建 parent。
|
||||
"""
|
||||
from core.agent_builder import InvalidTaskName, validate_task_name
|
||||
|
||||
root = load_user_root(user_id)
|
||||
parent = safe_join(root, body.path)
|
||||
if not parent.exists():
|
||||
raise HTTPException(404, f"parent directory not found: {body.path!r}")
|
||||
if not parent.is_dir():
|
||||
raise HTTPException(400, f"parent is not a directory: {body.path!r}")
|
||||
try:
|
||||
name = validate_task_name(body.name)
|
||||
except InvalidTaskName as e:
|
||||
raise HTTPException(400, f"name 不合法: {e}")
|
||||
|
||||
target = parent / name
|
||||
if target.exists():
|
||||
raise HTTPException(409, f"target already exists: {rel_to(root, target)!r}")
|
||||
try:
|
||||
target.mkdir()
|
||||
except FileExistsError:
|
||||
raise HTTPException(
|
||||
409, f"target already exists: {rel_to(root, target)!r}"
|
||||
)
|
||||
except OSError as e:
|
||||
raise HTTPException(400, f"mkdir failed: {e}")
|
||||
return {"ok": True, "path": rel_to(root, target), "name": name}
|
||||
|
||||
@app.post("/v1/files/delete", tags=["files"])
|
||||
def delete_file(
|
||||
body: FileDeleteRequest,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,11 @@ class FileRenameRequest(BaseModel):
|
|||
new_name: str # 新的 leaf 名(不是路径),不含 / \ ..
|
||||
|
||||
|
||||
class FileMkdirRequest(BaseModel):
|
||||
path: str = "" # 父目录,相对 user_root;空 → user_root
|
||||
name: str # 新目录 leaf 名(不是路径)
|
||||
|
||||
|
||||
class FileTransferRequest(BaseModel):
|
||||
paths: list[str] # 多源,均相对 user_root
|
||||
dest_dir: str = "" # 目标目录,相对 user_root,空 → user_root
|
||||
|
|
|
|||
|
|
@ -2006,7 +2006,8 @@
|
|||
<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-toggle-hidden" class="small" title="显示点目录" aria-label="显示点目录" aria-pressed="false">.*</button>
|
||||
<button id="btn-new-folder" class="small" title="在当前目录新建文件夹" aria-label="新建文件夹">+</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>
|
||||
|
|
|
|||
|
|
@ -21,9 +21,35 @@ $("btn-toggle-hidden").onclick = () => {
|
|||
state.showHiddenDirs = !state.showHiddenDirs;
|
||||
loadFiles();
|
||||
};
|
||||
$("btn-new-folder").onclick = createFolder;
|
||||
$("btn-upload").onclick = () => $("upload-input").click();
|
||||
$("upload-input").addEventListener("change", uploadSelected);
|
||||
|
||||
async function createFolder() {
|
||||
const rawName = await dialogPrompt({
|
||||
title: "新建文件夹",
|
||||
label: "文件夹名称",
|
||||
placeholder: "输入文件夹名称",
|
||||
okText: "创建并进入",
|
||||
maxLength: 255,
|
||||
});
|
||||
if (rawName == null) return;
|
||||
const name = rawName.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await api("POST", "/v1/files/mkdir", {
|
||||
path: state.filesPath || "",
|
||||
name,
|
||||
});
|
||||
state.filesPath = res.path;
|
||||
await loadFolderSuggestions();
|
||||
await loadFiles();
|
||||
} catch (e) {
|
||||
if (e.status === 401) { logout(); return; }
|
||||
message("创建文件夹失败:" + e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ───── 选入 modal(勾源 → 复制 / 移动到主区当前目录)─────
|
||||
// 设计:目的地永远是主区 state.filesPath。弹框内浏览的 path 跟主区独立 — 用户从 A 翻到 B
|
||||
// 勾几个,再翻到 C 接着勾,跨目录 selection 用 Set<rel> 全程保留;切换浏览路径不清空。
|
||||
|
|
@ -225,14 +251,7 @@ 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);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// 新建任务弹框:可选任务名 / 可搜索工作目录组合框(复用“开始一段新对话”交互)/
|
||||
// 描述 / 智能体(skill)/ 模型 select,提交 POST /v1/tasks。
|
||||
// 顶层自绑 hd-new-custom 打开、nt-go 提交、各 input 联动;唯一对外导出 loadFolderSuggestions
|
||||
//(供 main enterApp 初始化顶部 filter-wd、files 复制/移动后刷新目录列表)。
|
||||
//(供 main enterApp 初始化顶部 filter-wd、files 新建/复制/移动后刷新目录列表)。
|
||||
import { $ } from "./dom.js";
|
||||
import { state } from "./state.js";
|
||||
import { api } from "./api.js";
|
||||
|
|
|
|||
Loading…
Reference in New Issue