diff --git a/PROGRESS.md b/PROGRESS.md index 0345e54..5ea105c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-06-06(前端模块化 Step 2:抽出 layout.js / auth.js / preview.js) +最后更新:2026-06-06(前端模块化 Step 2:抽出 layout / auth / preview / files.js) --- @@ -23,6 +23,7 @@ ### 2026-06-06 +- **前端模块化 Step 2:抽出 `files.js`(文件面板 + 选入 + 拖拽上传)**:右栏文件列表浏览/导航/删除/重命名 + 刷新 + "选入"弹框(跨目录勾选复制/移动)+ 拖拽 overlay + 上传(XHR 带进度)+ 上传状态条。代码原分散在 main.js **两段非连续区**(1133–1459 文件列表/选入/拖拽 + 1697–1786 上传 helper,中间夹着 media 段)→ 合并进 `files.js`(433 行)。导出 `loadFiles`/`scheduleFilesRefresh`(SSE 文件事件刷新)/`closeSrcPicker`(main Esc 关栈)/`uploadFiles`(聊天区粘贴/拖拽复用);其余入口模块顶层自绑。反向 import `openFilePreview`(preview)、`logout`(auth)、main glue `downloadFile`/`selectTask`/`loadTaskList`/`loadFolderSuggestions`(后三个加 `export`,后续随 tasks/newtask 模块化再迁)。依赖分析用"段内被调标识符 − 段内定义 − 叶子/全局"全量提取,补回固定清单漏掉的 `loadFolderSuggestions`/`loadTaskList`。main.js 删至 1619 行。`node --check` 双过、main 残留 files 私有符号清零、files 无未导入 glue、静态测试 2 过。 - **前端模块化 Step 2:抽出 `preview.js`(文件预览 + mini 预览)**:文件预览主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载,docx/xlsx 走 `loadScript` 懒加载 vendor)+ 同时再开的小窗预览(原 main.js 1687–2048)→ `preview.js`(379 行)。导出 `openFilePreview`/`openPasteFilePreview`/`closeFilePreview`/`closeMiniPreview`/`_categorize`(媒体段判图/视频用)。反向 import `downloadFile`(main 媒体段,加 `export`)、`logout`(auth)。**Esc 关弹窗栈处理器留 main**(跨模块协调 chpw/选入/文件预览/小预览,加了节注释)。**一处去耦**:`deletePastedFile`(留 main)原直接读 preview 私有 `_fpCurrentRel`/`_mpCurrentRel` 判断要不要关预览 → 改为 preview 导出封装 `closePreviewIfShowing(rel)`,行为不变但不泄漏内部 current-rel 状态(模块边界更干净;唯一非纯剪切的微调)。main.js 删至 2034 行。`node --check` 双过、preview 私有符号在 main 清零、无未导入 glue 引用、静态测试 2 过。 - **前端模块化 Step 2:抽出 `auth.js`(首个 main↔模块 ES 环)**:登录(邮箱密码 / UUID+PLATFORM_KEY 两 tab)+ 管理员加用户 + 改密码三节(原 main.js 21–227)→ `auth.js`(218 行)。各入口在模块顶层自绑 onclick,只导出 `logout`(供全局 20 处 401 处理)/`closeChpwModal`(供 main 的 Esc 统一关弹窗栈)。反向 import main 的 glue `enterApp`/`embedPostToParent`/`embedShowWaiting`(main 给这三个加 `export`)——**首次引入 main↔auth 循环依赖**:三者皆 hoisted 函数声明、模块实例化即就绪,且只在运行时(点击/401)调用,绝不在顶层求值时触发 → ES live binding 下安全;这是增量拆单体的标准形态,后续 features↔glue 环同理。main.js 删至 2397 行。`node --check` 双过、auth 私有符号在 main 清零、静态测试仍 2 过。**逻辑零改动**。 - **前端模块化 Step 2(起):从 main.js 抽出 `layout.js`**:三栏布局(pane 折叠 rail + 拖拽 splitter + 手机单列视图)是 main.js 里唯一对其他功能节零出边的干净段,用它打样增量剥离。`layout.js`(121 行):import `$` + 4 个 `LS_*_COLLAPSED/WIDTH`,只导出 `mqPhone`/`setMobileView`(后者供 selectTask 在手机宽下选中任务自动切对话面板,是唯一跨模块调用);折叠/splitter/mobile-tab 的顶层事件绑定原样保留(ES module 默认 defer,import 时 DOM 已就绪)。main.js 删 114 行 → 2606 行,加 layout import 并清掉随之不再用的 4 个 `LS_*` import。**逻辑零改动,纯剪切+连线**;`node --check` 过、main 残留 layout 私有符号清零。**顺手修 Step 1 遗留测试失败**:`test_static_vendor` 第二用例原只 grep `dev.html` 找 `formatContextStats`/`context_original_chars`/`cache_hit_tokens`,模块化后这些搬进 `js/*.js` → 改为扫 `dev.html + js/*.js` 合并源,2 测试全过。后续按干净度继续剥(下一个 auth = login+加用户+改密码,会引入 main↔auth 的 ES 环,靠 live binding 解)。 diff --git a/web/static/js/files.js b/web/static/js/files.js new file mode 100644 index 0000000..9854c05 --- /dev/null +++ b/web/static/js/files.js @@ -0,0 +1,432 @@ +// 文件面板:右栏列表浏览/导航/删除/重命名、刷新、"选入"弹框(跨目录勾选复制/移动)、 +// 拖拽上传 overlay + 上传(XHR 带进度)+ 上传状态条。 +// 导出 loadFiles / scheduleFilesRefresh(SSE 文件事件触发刷新)/ closeSrcPicker(main Esc 关栈) +// / uploadFiles(聊天区粘贴或拖拽文件复用)。其余入口在本模块顶层自绑。 +// 反向依赖:openFilePreview(preview)、logout(auth)、以及 main 的 glue +// downloadFile / selectTask / loadTaskList / loadFolderSuggestions(后续随 media/tasks/newtask 模块化再迁)。 +import { state } from "./state.js"; +import { $, showMenu } from "./dom.js"; +import { api } from "./api.js"; +import { escapeHtml, humanSize } from "./format.js"; +import { openFilePreview } from "./preview.js"; +import { logout } from "./auth.js"; +import { downloadFile, selectTask, loadTaskList, loadFolderSuggestions } from "./main.js"; + +// ───── files(user-rooted,不绑 task) ───── +$("btn-refresh-files").onclick = () => loadFiles(); +$("btn-upload").onclick = () => $("upload-input").click(); +$("upload-input").addEventListener("change", uploadSelected); + +// ───── 选入 modal(勾源 → 复制 / 移动到主区当前目录)───── +// 设计:目的地永远是主区 state.filesPath。弹框内浏览的 path 跟主区独立 — 用户从 A 翻到 B +// 勾几个,再翻到 C 接着勾,跨目录 selection 用 Set 全程保留;切换浏览路径不清空。 +const srcPicker = { path: "", selected: new Set() }; + +async function openSrcPicker() { + srcPicker.path = ""; + srcPicker.selected.clear(); + const destLabel = state.filesPath ? "我的 / " + state.filesPath : "我的 (根目录)"; + $("sp-dest").textContent = destLabel; + $("sp-dest").title = destLabel; + syncSrcCount(); + $("src-picker-modal").classList.add("show"); + await loadSrcPicker(); +} + +export function closeSrcPicker() { + $("src-picker-modal").classList.remove("show"); + srcPicker.path = ""; + srcPicker.selected.clear(); +} + +async function loadSrcPicker() { + try { + const qs = srcPicker.path ? "?path=" + encodeURIComponent(srcPicker.path) : ""; + const data = await api("GET", "/v1/files" + qs); + renderSrcPicker(data); + } catch (e) { + if (e.status === 401) { logout(); return; } + $("sp-list").innerHTML = `
${escapeHtml(e.message)}
`; + } +} + +function renderSrcPicker(data) { + const cr = data.crumbs.map((c, i) => { + const label = i === 0 ? "我的" : c.label; + const isLast = i === data.crumbs.length - 1; + if (isLast) return `${escapeHtml(label)}`; + return `${escapeHtml(label)} /`; + }).join(" "); + $("sp-crumbs").innerHTML = cr || `/`; + $("sp-crumbs").querySelectorAll("a").forEach((a) => { + a.onclick = (e) => { e.preventDefault(); srcPicker.path = a.dataset.rel; loadSrcPicker(); }; + }); + const entries = data.entries || []; + if (!data.exists) { + $("sp-list").innerHTML = `
(目录尚未创建)
`; + return; + } + if (!entries.length) { + $("sp-list").innerHTML = `
(空目录)
`; + return; + } + // 闸:当前浏览路径 == 主区目的地 → 同目录内勾选无意义(同名 409),全行 disabled + const destPath = state.filesPath || ""; + const sameAsDest = srcPicker.path === destPath; + $("sp-list").innerHTML = entries.map((e) => { + const cls = e.is_dir ? "ico-dir" : "ico-file"; + const checked = srcPicker.selected.has(e.rel) ? " checked" : ""; + const disabled = sameAsDest ? " disabled" : ""; + const fullTitle = e.rel || e.name; + return ` +
+ + ${escapeHtml(e.name)} + ${humanSize(e.size)} +
+ `; + }).join(""); + $("sp-list").querySelectorAll(".sp-name").forEach((el) => { + el.onclick = () => { + if (el.dataset.isdir === "true") { + srcPicker.path = el.dataset.rel; + loadSrcPicker(); + } + }; + }); + $("sp-list").querySelectorAll(".sp-cb").forEach((cb) => { + cb.onchange = () => { + const rel = cb.dataset.rel; + if (cb.checked) srcPicker.selected.add(rel); + else srcPicker.selected.delete(rel); + syncSrcCount(); + }; + }); +} + +function syncSrcCount() { + const n = srcPicker.selected.size; + $("sp-count").textContent = String(n); + $("sp-copy").disabled = n === 0; + $("sp-move").disabled = n === 0; +} + +async function doSrcTransfer(mode) { + const sources = [...srcPicker.selected]; + if (!sources.length) return; + const endpoint = mode === "copy" ? "/v1/files/copy" : "/v1/files/move"; + const verb = mode === "copy" ? "复制" : "移动"; + try { + await api("POST", endpoint, { + paths: sources, + dest_dir: state.filesPath || "", + }); + closeSrcPicker(); + await loadFiles(); + await loadFolderSuggestions(); + } catch (e) { + if (e.status === 401) { logout(); return; } + alert(verb + "失败:" + e.message); + } +} + +$("btn-src-pick").onclick = openSrcPicker; +$("sp-cancel").onclick = closeSrcPicker; +$("sp-copy").onclick = () => doSrcTransfer("copy"); +$("sp-move").onclick = () => doSrcTransfer("move"); +$("src-picker-modal").addEventListener("click", (e) => { + if (e.target.id === "src-picker-modal") closeSrcPicker(); +}); + +// ───── 拖拽上传到主区(目的地 = state.filesPath)───── +// 用 enter/leave 计数避免子元素冒泡时 overlay 闪烁。 +let _dragDepth = 0; +function _hasFiles(ev) { + const t = ev.dataTransfer; + if (!t) return false; + if (t.types && [...t.types].includes("Files")) return true; + return false; +} +$("pane-right").addEventListener("dragenter", (e) => { + if (!_hasFiles(e)) return; + e.preventDefault(); + _dragDepth++; + $("file-droparea").classList.add("show"); +}); +$("pane-right").addEventListener("dragover", (e) => { + if (!_hasFiles(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; +}); +$("pane-right").addEventListener("dragleave", (e) => { + if (!_hasFiles(e)) return; + _dragDepth = Math.max(0, _dragDepth - 1); + if (_dragDepth === 0) $("file-droparea").classList.remove("show"); +}); +$("pane-right").addEventListener("drop", async (e) => { + if (!_hasFiles(e)) return; + e.preventDefault(); + _dragDepth = 0; + $("file-droparea").classList.remove("show"); + const files = Array.from(e.dataTransfer.files || []); + if (!files.length) return; + await uploadFilesWithPaneStatus(files); +}); + +// 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API +let _filesRefreshTimer = null; +export function scheduleFilesRefresh() { + clearTimeout(_filesRefreshTimer); + _filesRefreshTimer = setTimeout(() => { loadFiles(); }, 500); +} + +export async function loadFiles() { + try { + const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : ""; + const data = await api("GET", "/v1/files" + qs); + renderFiles(data); + } catch (e) { + if (e.status === 401) { logout(); return; } + $("file-crumbs").innerHTML = `${escapeHtml(e.message)}`; + $("file-list").innerHTML = ""; + } +} + +// 切换文件面板浏览路径 +function navFiles(newPath) { + state.filesPath = newPath || ""; + loadFiles(); +} + +function renderFiles(data) { + // 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文 + const segs = (data.current || "").split("/").filter(Boolean); + const projName = segs[0] || ""; + // 名称过长时显示前 11 字符 + …,完整名留 title 提示(避免顶栏挤压"文件"换行) + const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName; + $("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)"; + $("files-proj").title = projName || data.root || ""; + // crumbs root 标"我的"(user_root),更直观;其余原样 + const cr = data.crumbs.map((c, i) => { + const label = i === 0 ? "我的" : c.label; + const isLast = i === data.crumbs.length - 1; + if (isLast) return `${escapeHtml(label)}`; + return `${escapeHtml(label)} /`; + }).join(" "); + $("file-crumbs").innerHTML = cr || `/`; + $("file-crumbs").querySelectorAll("a").forEach((a) => { + a.onclick = (e) => { e.preventDefault(); navFiles(a.dataset.rel); }; + }); + if (!data.exists) { + $("file-list").innerHTML = `
(目录尚未创建)
`; + state.entriesByRel = {}; + return; + } + if (!data.entries.length) { + $("file-list").innerHTML = `
(空目录)
`; + state.entriesByRel = {}; + return; + } + state.entriesByRel = {}; + for (const e of data.entries) state.entriesByRel[e.rel] = e; + $("file-list").innerHTML = data.entries.map((e) => { + const cls = e.is_dir ? "ico-dir" : "ico-file"; + const fullTitle = e.rel || e.name; + return ` +
+ + ${escapeHtml(e.name)} + + ${humanSize(e.size)} + +
+ `; + }).join(""); + $("file-list").querySelectorAll(".name").forEach((el) => { + el.style.cursor = "pointer"; + el.onclick = () => { + const rel = el.dataset.rel; + if (el.dataset.isdir === "true") { navFiles(rel); } + else { openFilePreview(rel); } + }; + }); + $("file-list").querySelectorAll(".file-menu").forEach((btn) => { + btn.onclick = (ev) => { + ev.stopPropagation(); + const e = state.entriesByRel[btn.dataset.rel]; + if (!e) return; + showMenu(btn, fileMenuItems(e)); + }; + }); +} + +function fileMenuItems(e) { + const items = [ + { act: "rename", label: "重命名", cls: "act-rename", + onclick: () => renameFile(e.rel, e.name, e.is_dir) }, + ]; + if (!e.is_dir) { + items.push({ act: "download", label: "下载", cls: "act-download", + onclick: () => downloadFile(e.rel) }); + } + items.push({ act: "delete", label: "删除", cls: "act-delete", + onclick: () => deleteFile(e.rel, e.name, e.is_dir) }); + return items; +} + +async function deleteFile(rel, name, isDir) { + 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, recursive }); + await loadFiles(); + // 删的若是顶层目录,folders 列表也得跟着变;子级删除走这里也无副作用 + await loadFolderSuggestions(); + } catch (e) { + if (e.status === 401) { logout(); return; } + alert("删除失败:" + e.message); + } +} + +async function renameFile(rel, name, isDir) { + const what = isDir ? "目录" : "文件"; + const newName = prompt(`将${what} "${name}" 重命名为:`, name); + if (newName == null) return; + const trimmed = newName.trim(); + if (!trimmed || trimmed === name) return; + try { + const res = await api("POST", "/v1/files/rename", { path: rel, new_name: trimmed }); + // 面板若停在被改名的子树里,做前缀替换继续停留在等价位置 + if (state.filesPath === rel) { + state.filesPath = res.new; + } else if (state.filesPath && state.filesPath.startsWith(rel + "/")) { + state.filesPath = res.new + state.filesPath.slice(rel.length); + } + await loadFolderSuggestions(); + // 顶层目录改名 → tasks_updated>0,任务列表 / 当前 task 头里的 working_dir 都得刷 + if (res && res.tasks_updated > 0) { + await loadTaskList(); + if (state.taskId) { await selectTask(state.taskId); return; } + } + await loadFiles(); + } catch (e) { + if (e.status === 401) { logout(); return; } + alert("重命名失败:" + e.message); + } +} + +function uploadTotalBytes(files) { + return (files || []).reduce((sum, f) => sum + (f.size || 0), 0); +} +function uploadFilesLabel(files) { + if (!files || !files.length) return ""; + return files.length === 1 ? files[0].name : `${files[0].name} 等 ${files.length} 个文件`; +} +function formatUploadProgress(files, loaded, total) { + const denom = total || uploadTotalBytes(files); + const pct = denom ? Math.min(100, Math.max(0, Math.round((loaded / denom) * 100))) : 0; + const sizeText = denom ? ` · ${humanSize(Math.min(loaded, denom))}/${humanSize(denom)}` : ""; + return `上传中 ${pct}% · ${uploadFilesLabel(files)}${sizeText}`; +} +function setPaneUploadStatus(files, loaded, total) { + const el = $("file-upload-status"); + const denom = total || uploadTotalBytes(files); + const pct = denom ? Math.min(100, Math.max(0, Math.round((loaded / denom) * 100))) : 0; + el.classList.add("show"); + el.innerHTML = `${escapeHtml(formatUploadProgress(files, loaded, total))}
`; +} +function finishPaneUploadStatus(ok, files) { + const el = $("file-upload-status"); + el.classList.add("show"); + el.innerHTML = ok + ? `上传完成 · ${escapeHtml(uploadFilesLabel(files))}
` + : `上传失败 · ${escapeHtml(uploadFilesLabel(files))}`; + setTimeout(() => { + if (el.textContent.startsWith(ok ? "上传完成" : "上传失败")) { + el.classList.remove("show"); + el.innerHTML = ""; + } + }, 3500); +} +async function uploadFilesWithPaneStatus(files) { + if (!files || !files.length) return null; + setPaneUploadStatus(files, 0, uploadTotalBytes(files)); + const saved = await uploadFiles(files, { + onProgress: (loaded, total) => setPaneUploadStatus(files, loaded, total), + }); + finishPaneUploadStatus(!!(saved && saved.length), files); + return saved; +} + +export async function uploadFiles(files, opts = {}) { + if (!files || !files.length) return null; + const fd = new FormData(); + fd.append("path", state.filesPath || ""); + for (const f of files) fd.append("files", f); + try { + const data = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/v1/files/upload"); + xhr.setRequestHeader("Authorization", "Bearer " + state.token); + xhr.upload.onprogress = (ev) => { + if (opts.onProgress && ev.lengthComputable) opts.onProgress(ev.loaded, ev.total); + }; + xhr.onerror = () => reject(new Error("网络错误,上传失败")); + xhr.onload = () => { + let payload = {}; + try { payload = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } + catch (_) { payload = {}; } + if (xhr.status < 200 || xhr.status >= 300) { + const err = new Error(payload.detail || (xhr.status + " 上传失败")); + err.status = xhr.status; + reject(err); + return; + } + resolve(payload); + }; + if (opts.onProgress) opts.onProgress(0, uploadTotalBytes(files)); + xhr.send(fd); + }); + await loadFiles(); + return data.saved || []; + } catch (e) { + if (e.status === 401) { logout(); return null; } + alert("上传失败:" + e.message); + return null; + } +} + +async function uploadSelected() { + const inp = $("upload-input"); + const files = Array.from(inp.files || []); + try { + await uploadFilesWithPaneStatus(files); + } finally { + inp.value = ""; // 允许重新选同名文件 + } +} diff --git a/web/static/js/main.js b/web/static/js/main.js index 8dbe41f..21c4919 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -16,6 +16,7 @@ import { renderMd, highlightIn } from "./markdown.js"; import { mqPhone, setMobileView } from "./layout.js"; import { logout, closeChpwModal } from "./auth.js"; import { openFilePreview, openPasteFilePreview, closeFilePreview, closeMiniPreview, closePreviewIfShowing, _categorize } from "./preview.js"; +import { loadFiles, scheduleFilesRefresh, closeSrcPicker, uploadFiles } from "./files.js"; // embed 首个 task 自动定位的一次性标志(仅 embed 段使用) let _embedInitialTaskHandled = false; @@ -98,7 +99,7 @@ async function loadModels() { // loadTaskList:默认 reset(filters/refresh/写操作后),append=true 由 sentinel observer 触发 // 并发模型:append 受 taskLoading 互斥(避免观察器重复触发);reset 永远抢占,用 seq 丢弃过期响应 let _taskLoadSeq = 0; -async function loadTaskList({ append = false } = {}) { +export async function loadTaskList({ append = false } = {}) { if (append && (state.taskLoading || !state.taskHasMore)) return; const mySeq = ++_taskLoadSeq; const nextPage = append ? state.taskPage + 1 : 1; @@ -253,7 +254,7 @@ const _taskScrollObserver = new IntersectionObserver((entries) => { _taskScrollObserver.observe($("task-sentinel")); // ───── select task ───── -async function selectTask(tid) { +export async function selectTask(tid) { if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; } // 切 task 清掉上个 task 累积的 inline media blob URL — 新 task 的 rel 不同, // 旧 URL 留着只占内存。同 task 切回(tid === state.taskId)不算切换,跳过。 @@ -1130,334 +1131,6 @@ function exportTask(tid) { }); } -// ───── files(user-rooted,不绑 task) ───── -$("btn-refresh-files").onclick = () => loadFiles(); -$("btn-upload").onclick = () => $("upload-input").click(); -$("upload-input").addEventListener("change", uploadSelected); - -// ───── 选入 modal(勾源 → 复制 / 移动到主区当前目录)───── -// 设计:目的地永远是主区 state.filesPath。弹框内浏览的 path 跟主区独立 — 用户从 A 翻到 B -// 勾几个,再翻到 C 接着勾,跨目录 selection 用 Set 全程保留;切换浏览路径不清空。 -const srcPicker = { path: "", selected: new Set() }; - -async function openSrcPicker() { - srcPicker.path = ""; - srcPicker.selected.clear(); - const destLabel = state.filesPath ? "我的 / " + state.filesPath : "我的 (根目录)"; - $("sp-dest").textContent = destLabel; - $("sp-dest").title = destLabel; - syncSrcCount(); - $("src-picker-modal").classList.add("show"); - await loadSrcPicker(); -} - -function closeSrcPicker() { - $("src-picker-modal").classList.remove("show"); - srcPicker.path = ""; - srcPicker.selected.clear(); -} - -async function loadSrcPicker() { - try { - const qs = srcPicker.path ? "?path=" + encodeURIComponent(srcPicker.path) : ""; - const data = await api("GET", "/v1/files" + qs); - renderSrcPicker(data); - } catch (e) { - if (e.status === 401) { logout(); return; } - $("sp-list").innerHTML = `
${escapeHtml(e.message)}
`; - } -} - -function renderSrcPicker(data) { - const cr = data.crumbs.map((c, i) => { - const label = i === 0 ? "我的" : c.label; - const isLast = i === data.crumbs.length - 1; - if (isLast) return `${escapeHtml(label)}`; - return `${escapeHtml(label)} /`; - }).join(" "); - $("sp-crumbs").innerHTML = cr || `/`; - $("sp-crumbs").querySelectorAll("a").forEach((a) => { - a.onclick = (e) => { e.preventDefault(); srcPicker.path = a.dataset.rel; loadSrcPicker(); }; - }); - const entries = data.entries || []; - if (!data.exists) { - $("sp-list").innerHTML = `
(目录尚未创建)
`; - return; - } - if (!entries.length) { - $("sp-list").innerHTML = `
(空目录)
`; - return; - } - // 闸:当前浏览路径 == 主区目的地 → 同目录内勾选无意义(同名 409),全行 disabled - const destPath = state.filesPath || ""; - const sameAsDest = srcPicker.path === destPath; - $("sp-list").innerHTML = entries.map((e) => { - const cls = e.is_dir ? "ico-dir" : "ico-file"; - const checked = srcPicker.selected.has(e.rel) ? " checked" : ""; - const disabled = sameAsDest ? " disabled" : ""; - const fullTitle = e.rel || e.name; - return ` -
- - ${escapeHtml(e.name)} - ${humanSize(e.size)} -
- `; - }).join(""); - $("sp-list").querySelectorAll(".sp-name").forEach((el) => { - el.onclick = () => { - if (el.dataset.isdir === "true") { - srcPicker.path = el.dataset.rel; - loadSrcPicker(); - } - }; - }); - $("sp-list").querySelectorAll(".sp-cb").forEach((cb) => { - cb.onchange = () => { - const rel = cb.dataset.rel; - if (cb.checked) srcPicker.selected.add(rel); - else srcPicker.selected.delete(rel); - syncSrcCount(); - }; - }); -} - -function syncSrcCount() { - const n = srcPicker.selected.size; - $("sp-count").textContent = String(n); - $("sp-copy").disabled = n === 0; - $("sp-move").disabled = n === 0; -} - -async function doSrcTransfer(mode) { - const sources = [...srcPicker.selected]; - if (!sources.length) return; - const endpoint = mode === "copy" ? "/v1/files/copy" : "/v1/files/move"; - const verb = mode === "copy" ? "复制" : "移动"; - try { - await api("POST", endpoint, { - paths: sources, - dest_dir: state.filesPath || "", - }); - closeSrcPicker(); - await loadFiles(); - await loadFolderSuggestions(); - } catch (e) { - if (e.status === 401) { logout(); return; } - alert(verb + "失败:" + e.message); - } -} - -$("btn-src-pick").onclick = openSrcPicker; -$("sp-cancel").onclick = closeSrcPicker; -$("sp-copy").onclick = () => doSrcTransfer("copy"); -$("sp-move").onclick = () => doSrcTransfer("move"); -$("src-picker-modal").addEventListener("click", (e) => { - if (e.target.id === "src-picker-modal") closeSrcPicker(); -}); - -// ───── 拖拽上传到主区(目的地 = state.filesPath)───── -// 用 enter/leave 计数避免子元素冒泡时 overlay 闪烁。 -let _dragDepth = 0; -function _hasFiles(ev) { - const t = ev.dataTransfer; - if (!t) return false; - if (t.types && [...t.types].includes("Files")) return true; - return false; -} -$("pane-right").addEventListener("dragenter", (e) => { - if (!_hasFiles(e)) return; - e.preventDefault(); - _dragDepth++; - $("file-droparea").classList.add("show"); -}); -$("pane-right").addEventListener("dragover", (e) => { - if (!_hasFiles(e)) return; - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; -}); -$("pane-right").addEventListener("dragleave", (e) => { - if (!_hasFiles(e)) return; - _dragDepth = Math.max(0, _dragDepth - 1); - if (_dragDepth === 0) $("file-droparea").classList.remove("show"); -}); -$("pane-right").addEventListener("drop", async (e) => { - if (!_hasFiles(e)) return; - e.preventDefault(); - _dragDepth = 0; - $("file-droparea").classList.remove("show"); - const files = Array.from(e.dataTransfer.files || []); - if (!files.length) return; - await uploadFilesWithPaneStatus(files); -}); - -// 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API -let _filesRefreshTimer = null; -function scheduleFilesRefresh() { - clearTimeout(_filesRefreshTimer); - _filesRefreshTimer = setTimeout(() => { loadFiles(); }, 500); -} - -async function loadFiles() { - try { - const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : ""; - const data = await api("GET", "/v1/files" + qs); - renderFiles(data); - } catch (e) { - if (e.status === 401) { logout(); return; } - $("file-crumbs").innerHTML = `${escapeHtml(e.message)}`; - $("file-list").innerHTML = ""; - } -} - -// 切换文件面板浏览路径 -function navFiles(newPath) { - state.filesPath = newPath || ""; - loadFiles(); -} - -function renderFiles(data) { - // 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文 - const segs = (data.current || "").split("/").filter(Boolean); - const projName = segs[0] || ""; - // 名称过长时显示前 11 字符 + …,完整名留 title 提示(避免顶栏挤压"文件"换行) - const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName; - $("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)"; - $("files-proj").title = projName || data.root || ""; - // crumbs root 标"我的"(user_root),更直观;其余原样 - const cr = data.crumbs.map((c, i) => { - const label = i === 0 ? "我的" : c.label; - const isLast = i === data.crumbs.length - 1; - if (isLast) return `${escapeHtml(label)}`; - return `${escapeHtml(label)} /`; - }).join(" "); - $("file-crumbs").innerHTML = cr || `/`; - $("file-crumbs").querySelectorAll("a").forEach((a) => { - a.onclick = (e) => { e.preventDefault(); navFiles(a.dataset.rel); }; - }); - if (!data.exists) { - $("file-list").innerHTML = `
(目录尚未创建)
`; - state.entriesByRel = {}; - return; - } - if (!data.entries.length) { - $("file-list").innerHTML = `
(空目录)
`; - state.entriesByRel = {}; - return; - } - state.entriesByRel = {}; - for (const e of data.entries) state.entriesByRel[e.rel] = e; - $("file-list").innerHTML = data.entries.map((e) => { - const cls = e.is_dir ? "ico-dir" : "ico-file"; - const fullTitle = e.rel || e.name; - return ` -
- - ${escapeHtml(e.name)} - - ${humanSize(e.size)} - -
- `; - }).join(""); - $("file-list").querySelectorAll(".name").forEach((el) => { - el.style.cursor = "pointer"; - el.onclick = () => { - const rel = el.dataset.rel; - if (el.dataset.isdir === "true") { navFiles(rel); } - else { openFilePreview(rel); } - }; - }); - $("file-list").querySelectorAll(".file-menu").forEach((btn) => { - btn.onclick = (ev) => { - ev.stopPropagation(); - const e = state.entriesByRel[btn.dataset.rel]; - if (!e) return; - showMenu(btn, fileMenuItems(e)); - }; - }); -} - -function fileMenuItems(e) { - const items = [ - { act: "rename", label: "重命名", cls: "act-rename", - onclick: () => renameFile(e.rel, e.name, e.is_dir) }, - ]; - if (!e.is_dir) { - items.push({ act: "download", label: "下载", cls: "act-download", - onclick: () => downloadFile(e.rel) }); - } - items.push({ act: "delete", label: "删除", cls: "act-delete", - onclick: () => deleteFile(e.rel, e.name, e.is_dir) }); - return items; -} - -async function deleteFile(rel, name, isDir) { - 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, recursive }); - await loadFiles(); - // 删的若是顶层目录,folders 列表也得跟着变;子级删除走这里也无副作用 - await loadFolderSuggestions(); - } catch (e) { - if (e.status === 401) { logout(); return; } - alert("删除失败:" + e.message); - } -} - -async function renameFile(rel, name, isDir) { - const what = isDir ? "目录" : "文件"; - const newName = prompt(`将${what} "${name}" 重命名为:`, name); - if (newName == null) return; - const trimmed = newName.trim(); - if (!trimmed || trimmed === name) return; - try { - const res = await api("POST", "/v1/files/rename", { path: rel, new_name: trimmed }); - // 面板若停在被改名的子树里,做前缀替换继续停留在等价位置 - if (state.filesPath === rel) { - state.filesPath = res.new; - } else if (state.filesPath && state.filesPath.startsWith(rel + "/")) { - state.filesPath = res.new + state.filesPath.slice(rel.length); - } - await loadFolderSuggestions(); - // 顶层目录改名 → tasks_updated>0,任务列表 / 当前 task 头里的 working_dir 都得刷 - if (res && res.tasks_updated > 0) { - await loadTaskList(); - if (state.taskId) { await selectTask(state.taskId); return; } - } - await loadFiles(); - } catch (e) { - if (e.status === 401) { logout(); return; } - alert("重命名失败:" + e.message); - } -} - // ───── artifact 抽取(对话内 chip → 复用文件预览 modal) ───── // task.working_dir 在 DB 是 `workspace/users//` 形态(to_db_path), // 不是 user_root 相对。这里取最后一段作为 chip 抽取锚点 —— 等价于 user_root 下 @@ -1694,96 +1367,6 @@ document.addEventListener("keydown", (e) => { if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; } }); -function uploadTotalBytes(files) { - return (files || []).reduce((sum, f) => sum + (f.size || 0), 0); -} -function uploadFilesLabel(files) { - if (!files || !files.length) return ""; - return files.length === 1 ? files[0].name : `${files[0].name} 等 ${files.length} 个文件`; -} -function formatUploadProgress(files, loaded, total) { - const denom = total || uploadTotalBytes(files); - const pct = denom ? Math.min(100, Math.max(0, Math.round((loaded / denom) * 100))) : 0; - const sizeText = denom ? ` · ${humanSize(Math.min(loaded, denom))}/${humanSize(denom)}` : ""; - return `上传中 ${pct}% · ${uploadFilesLabel(files)}${sizeText}`; -} -function setPaneUploadStatus(files, loaded, total) { - const el = $("file-upload-status"); - const denom = total || uploadTotalBytes(files); - const pct = denom ? Math.min(100, Math.max(0, Math.round((loaded / denom) * 100))) : 0; - el.classList.add("show"); - el.innerHTML = `${escapeHtml(formatUploadProgress(files, loaded, total))}
`; -} -function finishPaneUploadStatus(ok, files) { - const el = $("file-upload-status"); - el.classList.add("show"); - el.innerHTML = ok - ? `上传完成 · ${escapeHtml(uploadFilesLabel(files))}
` - : `上传失败 · ${escapeHtml(uploadFilesLabel(files))}`; - setTimeout(() => { - if (el.textContent.startsWith(ok ? "上传完成" : "上传失败")) { - el.classList.remove("show"); - el.innerHTML = ""; - } - }, 3500); -} -async function uploadFilesWithPaneStatus(files) { - if (!files || !files.length) return null; - setPaneUploadStatus(files, 0, uploadTotalBytes(files)); - const saved = await uploadFiles(files, { - onProgress: (loaded, total) => setPaneUploadStatus(files, loaded, total), - }); - finishPaneUploadStatus(!!(saved && saved.length), files); - return saved; -} - -async function uploadFiles(files, opts = {}) { - if (!files || !files.length) return null; - const fd = new FormData(); - fd.append("path", state.filesPath || ""); - for (const f of files) fd.append("files", f); - try { - const data = await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open("POST", "/v1/files/upload"); - xhr.setRequestHeader("Authorization", "Bearer " + state.token); - xhr.upload.onprogress = (ev) => { - if (opts.onProgress && ev.lengthComputable) opts.onProgress(ev.loaded, ev.total); - }; - xhr.onerror = () => reject(new Error("网络错误,上传失败")); - xhr.onload = () => { - let payload = {}; - try { payload = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } - catch (_) { payload = {}; } - if (xhr.status < 200 || xhr.status >= 300) { - const err = new Error(payload.detail || (xhr.status + " 上传失败")); - err.status = xhr.status; - reject(err); - return; - } - resolve(payload); - }; - if (opts.onProgress) opts.onProgress(0, uploadTotalBytes(files)); - xhr.send(fd); - }); - await loadFiles(); - return data.saved || []; - } catch (e) { - if (e.status === 401) { logout(); return null; } - alert("上传失败:" + e.message); - return null; - } -} - -async function uploadSelected() { - const inp = $("upload-input"); - const files = Array.from(inp.files || []); - try { - await uploadFilesWithPaneStatus(files); - } finally { - inp.value = ""; // 允许重新选同名文件 - } -} // ───── new task ───── // wd 二级 input 默认跟随 name;用户一旦手改二级 input → 脱钩;清空二级 input 重置 flag @@ -1840,7 +1423,7 @@ $("nt-go").onclick = async () => { }; // 工作目录:拉数据 + 灌两个 select(顶部 filter-wd 和 modal nt-wd-sel) -async function loadFolderSuggestions() { +export async function loadFolderSuggestions() { try { const data = await api("GET", "/v1/folders"); state.folders = data.folders || [];