refactor(dev): 前端模块化 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 过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-06 22:08:55 +08:00
parent 9394e065f1
commit 71bac870ed
3 changed files with 438 additions and 422 deletions

View File

@ -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 **两段非连续区**(11331459 文件列表/选入/拖拽 + 16971786 上传 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 16872048)→ `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 21227)→ `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 解)。

432
web/static/js/files.js Normal file
View File

@ -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<rel> 全程保留;切换浏览路径不清空。
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 = `<div class="empty">${escapeHtml(e.message)}</div>`;
}
}
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 `<span>${escapeHtml(label)}</span>`;
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
}).join(" ");
$("sp-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
$("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 = `<div class="empty">(目录尚未创建)</div>`;
return;
}
if (!entries.length) {
$("sp-list").innerHTML = `<div class="empty">(空目录)</div>`;
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 `
<div class="sp-row${disabled}" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">
<input type="checkbox" class="sp-cb" data-rel="${escapeHtml(e.rel)}"${checked}${sameAsDest ? " disabled" : ""} />
<span class="${cls} sp-name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">${escapeHtml(e.name)}</span>
<span class="sp-size">${humanSize(e.size)}</span>
</div>
`;
}).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 = `<span class="muted">${escapeHtml(e.message)}</span>`;
$("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 `<span>${escapeHtml(label)}</span>`;
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
}).join(" ");
$("file-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
$("file-crumbs").querySelectorAll("a").forEach((a) => {
a.onclick = (e) => { e.preventDefault(); navFiles(a.dataset.rel); };
});
if (!data.exists) {
$("file-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
state.entriesByRel = {};
return;
}
if (!data.entries.length) {
$("file-list").innerHTML = `<div class="empty">(空目录)</div>`;
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 `
<div class="file-row" data-rel="${escapeHtml(e.rel)}" title="${escapeHtml(fullTitle)}">
<span class="${cls} name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">
${escapeHtml(e.name)}
</span>
<span class="size">${humanSize(e.size)}</span>
<button class="dd-toggle file-menu" data-rel="${escapeHtml(e.rel)}" title="文件操作"></button>
</div>
`;
}).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))}<div class="bar"><span style="width:${pct}%"></span></div>`;
}
function finishPaneUploadStatus(ok, files) {
const el = $("file-upload-status");
el.classList.add("show");
el.innerHTML = ok
? `上传完成 · ${escapeHtml(uploadFilesLabel(files))}<div class="bar"><span style="width:100%"></span></div>`
: `上传失败 · ${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 = ""; // 允许重新选同名文件
}
}

View File

@ -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<rel> 全程保留;切换浏览路径不清空。
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 = `<div class="empty">${escapeHtml(e.message)}</div>`;
}
}
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 `<span>${escapeHtml(label)}</span>`;
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
}).join(" ");
$("sp-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
$("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 = `<div class="empty">(目录尚未创建)</div>`;
return;
}
if (!entries.length) {
$("sp-list").innerHTML = `<div class="empty">(空目录)</div>`;
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 `
<div class="sp-row${disabled}" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">
<input type="checkbox" class="sp-cb" data-rel="${escapeHtml(e.rel)}"${checked}${sameAsDest ? " disabled" : ""} />
<span class="${cls} sp-name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">${escapeHtml(e.name)}</span>
<span class="sp-size">${humanSize(e.size)}</span>
</div>
`;
}).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 = `<span class="muted">${escapeHtml(e.message)}</span>`;
$("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 `<span>${escapeHtml(label)}</span>`;
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
}).join(" ");
$("file-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
$("file-crumbs").querySelectorAll("a").forEach((a) => {
a.onclick = (e) => { e.preventDefault(); navFiles(a.dataset.rel); };
});
if (!data.exists) {
$("file-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
state.entriesByRel = {};
return;
}
if (!data.entries.length) {
$("file-list").innerHTML = `<div class="empty">(空目录)</div>`;
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 `
<div class="file-row" data-rel="${escapeHtml(e.rel)}" title="${escapeHtml(fullTitle)}">
<span class="${cls} name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">
${escapeHtml(e.name)}
</span>
<span class="size">${humanSize(e.size)}</span>
<button class="dd-toggle file-menu" data-rel="${escapeHtml(e.rel)}" title="文件操作"></button>
</div>
`;
}).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/<uuid>/<name>` 形态(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))}<div class="bar"><span style="width:${pct}%"></span></div>`;
}
function finishPaneUploadStatus(ok, files) {
const el = $("file-upload-status");
el.classList.add("show");
el.innerHTML = ok
? `上传完成 · ${escapeHtml(uploadFilesLabel(files))}<div class="bar"><span style="width:100%"></span></div>`
: `上传失败 · ${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 || [];