diff --git a/tests/frontend_files.test.mjs b/tests/frontend_files.test.mjs index 77335519..bca3449c 100644 --- a/tests/frontend_files.test.mjs +++ b/tests/frontend_files.test.mjs @@ -35,10 +35,29 @@ test("task navigation groups chats by project with restrained icon sizes", () => assert.match(chatJs, /task-nav-recent/); assert.match(chatJs, /task-project-head/); assert.match(chatJs, /groupedTaskListHtml/); + assert.match(chatJs, /loadProjectTasks/); + assert.match(chatJs, /working_dir: projectName/); + assert.match(chatJs, /state\.taskHasMore = filtered &&/); + assert.match(chatJs, /task-project-more/); assert.match(pageHtml, /\.task-nav-icon \{ width: 15px; height: 15px;/); assert.match(pageHtml, /\.task-project-head \.task-nav-icon \{ width: 16px; height: 16px;/); }); +test("file rows render restrained type-specific svg icons", () => { + assert.match(filesJs, /function fileIconKind/); + assert.match(filesJs, /if \(entry\.is_dir\) return "folder"/); + assert.match(filesJs, /word: new Set/); + assert.match(filesJs, /sheet: new Set/); + assert.match(filesJs, /slide: new Set/); + assert.match(filesJs, /if \(ext === "pdf"\) return "pdf"/); + assert.match(filesJs, /image: new Set/); + assert.match(filesJs, /archive: new Set/); + assert.match(filesJs, /code: new Set/); + assert.match(filesJs, /file-type-icon type-\$\{kind\}/); + assert.match(pageHtml, /\.file-type-icon svg \{ width: 16px; height: 16px;/); + assert.doesNotMatch(pageHtml, /\.ico-file::before/); +}); + test("current directory supports search, sort, and modification time", () => { assert.match(pageHtml, /id="file-search"/); assert.match(pageHtml, /id="file-sort"/); diff --git a/web/static/dev.html b/web/static/dev.html index 6036dcd4..ab6a5004 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -699,6 +699,13 @@ .task-project-count { flex: none; min-width: 20px; color: var(--muted); font-size: 11px; text-align: right; font-variant-numeric: tabular-nums; } .task-project-items { display: none; padding: 1px 0 5px; } .task-project.open .task-project-items { display: block; } + .task-project-state, .task-project-more, .task-project-retry { + min-height: 28px; margin: 2px 10px 2px 38px; padding: 4px 8px; color: var(--muted); font-size: 11px; + } + .task-project-more, .task-project-retry { width: calc(100% - 48px); border: 0; background: transparent; + text-align: left; cursor: pointer; border-radius: 4px; } + .task-project-more:hover, .task-project-retry:hover { background: var(--hover); color: var(--text); } + .task-project-retry { color: var(--danger); } .task-filter-results { padding: 5px 0 10px; } .task-row .desc { font-weight: 500; color: var(--text); margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -1449,8 +1456,17 @@ .file-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .file-row .mtime { color: var(--muted); font-size: 10px; font-family: var(--mono); } .file-row .size { font-size: 11px; color: var(--muted); font-family: var(--mono); } - .ico-dir::before { content: "▸ "; color: var(--accent); } - .ico-file::before { content: "· "; color: var(--muted); } + .file-type-icon { width: 24px; height: 24px; flex: none; display: inline-flex; align-items: center; justify-content: center; + border-radius: 5px; color: #747b84; background: #f4f5f6; } + .file-type-icon svg { width: 16px; height: 16px; display: block; } + .file-type-icon.type-folder { color: #b67812; background: #fff7e5; } + .file-type-icon.type-word { color: #3b6fb6; background: #edf4ff; } + .file-type-icon.type-sheet { color: #278257; background: #ebf7f0; } + .file-type-icon.type-slide { color: #b8642d; background: #fff1e8; } + .file-type-icon.type-pdf { color: #bd3f38; background: #fff0ef; } + .file-type-icon.type-image { color: #7c5bb3; background: #f4effc; } + .file-type-icon.type-archive { color: #80663d; background: #f6f1e9; } + .file-type-icon.type-code { color: #4c708b; background: #edf4f7; } /* 拖拽上传 overlay:hover 整个 pane-right 时铺一层提示 */ #pane-right { position: relative; } diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 97dc35a0..28262b05 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -97,6 +97,10 @@ export async function loadModels() { // loadTaskList:默认 reset(filters/refresh/写操作后),append=true 由 sentinel observer 触发 // 并发模型:append 受 taskLoading 互斥(避免观察器重复触发);reset 永远抢占,用 seq 丢弃过期响应 let _taskLoadSeq = 0; +let _globalTaskResults = []; +const _projectTaskPages = new Map(); +const _projectTaskLoading = new Set(); + export async function loadTaskList({ append = false } = {}) { if (append && (state.taskLoading || !state.taskHasMore)) return; const mySeq = ++_taskLoadSeq; @@ -123,7 +127,11 @@ export async function loadTaskList({ append = false } = {}) { const results = data.results || []; if (!append) state.taskLoaded = 0; state.taskLoaded += results.length; - state.taskHasMore = state.taskLoaded < state.taskTotal; + // 常态项目导航只需首页的最近对话;各项目在展开时独立分页。 + // 全局 sentinel 只服务搜索/筛选结果,避免折叠项目让它提前入视口而连续拉全量历史。 + const filtered = isTaskListFiltered(); + state.taskHasMore = filtered && state.taskLoaded < state.taskTotal; + if (!append && !filtered) _projectTaskPages.clear(); renderTaskList(results, append); renderTaskCount(); subscribeRunningRows(results); @@ -206,12 +214,12 @@ function syncTaskRowRunIndicator(tid) { } } -const TASK_PROJECTS_EXPANDED_KEY = "zcbot-task-projects-expanded-v1"; +const TASK_PROJECTS_EXPANDED_KEY = "zcbot-task-projects-expanded-v2"; function taskProject(t) { const path = t.working_dir || ""; const name = path.split("/").filter(Boolean).pop() || "未分组对话"; - return { key: path || "__ungrouped__", name }; + return { key: path ? name : "__ungrouped__", name }; } function taskNavIcon(kind) { @@ -272,13 +280,53 @@ function expandedTaskProjects() { } } -function groupedTaskListHtml(tasks) { - const groups = new Map(); +function taskProjectGroups(tasks) { + const loadedGroups = new Map(); for (const task of tasks) { const project = taskProject(task); - if (!groups.has(project.key)) groups.set(project.key, { ...project, tasks: [] }); - groups.get(project.key).tasks.push(task); + if (!loadedGroups.has(project.key)) loadedGroups.set(project.key, []); + loadedGroups.get(project.key).push(task); } + const groups = []; + const seen = new Set(); + for (const folder of state.folders || []) { + if (!folder.n_tasks) continue; // 空目录仍在右栏「全部项目」中管理 + groups.push({ + key: folder.name, + name: folder.name, + count: Number(folder.n_tasks) || 0, + seedTasks: loadedGroups.get(folder.name) || [], + }); + seen.add(folder.name); + } + // 容错:磁盘目录索引暂时拉不到时,已返回的 task 仍然可访问。 + for (const [key, groupTasks] of loadedGroups) { + if (!seen.has(key)) groups.push({ + key, + name: key === "__ungrouped__" ? "未分组对话" : key, + count: groupTasks.length, + seedTasks: groupTasks, + }); + } + return groups; +} + +function projectItemsHtml(group) { + const page = _projectTaskPages.get(group.key); + const tasks = page ? page.tasks : group.seedTasks; + const rows = tasks.map((t) => taskRowHtml(t, { compact: true, nested: true })).join(""); + const status = page?.error + ? `` + : (_projectTaskLoading.has(group.key) + ? `
加载中…
` + : (page?.hasMore + ? `` + : "")); + return rows || status ? `${rows}${status}` : `
暂无对话
`; +} + +function groupedTaskListHtml(tasks) { + const groups = taskProjectGroups(tasks); const expanded = expandedTaskProjects(); const activeProject = state.taskId && state.tasksById[state.taskId] ? taskProject(state.tasksById[state.taskId]).key : ""; @@ -288,21 +336,94 @@ function groupedTaskListHtml(tasks) {
${taskNavIcon("recent")}最近对话
${recent.map((t) => taskRowHtml(t, { compact: true })).join("")} ` : ""; - const projectsHtml = Array.from(groups.values()).map((group, index) => { + const projectsHtml = groups.map((group, index) => { const open = expanded === null ? (group.key === activeProject || index === 0) : expanded.has(group.key); + const cachedPage = _projectTaskPages.get(group.key); + const exactCount = cachedPage && cachedPage.page > 0 ? cachedPage.count : group.count; return `
-
${group.tasks.map((t) => taskRowHtml(t, { compact: true, nested: true })).join("")}
+
${projectItemsHtml(group)}
`; }).join(""); return `${recentHtml}
${projectsHtml}
`; } +async function loadProjectTasks(projectName, { append = false } = {}) { + if (!projectName || _projectTaskLoading.has(projectName)) return; + const current = _projectTaskPages.get(projectName); + if (append && current && !current.hasMore) return; + const nextPage = append && current ? current.page + 1 : 1; + _projectTaskLoading.add(projectName); + _projectTaskPages.set(projectName, { + tasks: append && current ? current.tasks : [], + page: append && current ? current.page : 0, + count: current?.count || 0, + hasMore: current?.hasMore ?? true, + error: "", + }); + renderTaskNavigation(); + try { + const params = new URLSearchParams({ + page: String(nextPage), + page_size: String(state.taskPageSize), + working_dir: projectName, + }); + const data = await api("GET", "/v1/tasks?" + params.toString()); + const results = data.results || []; + const previous = append && current ? current.tasks : []; + const merged = [...previous]; + const known = new Set(previous.map((task) => task.task_id)); + for (const task of results) { + state.tasksById[task.task_id] = task; + if (!known.has(task.task_id)) merged.push(task); + } + const count = Number(data.count) || 0; + _projectTaskPages.set(projectName, { + tasks: merged, + page: data.page || nextPage, + count, + hasMore: merged.length < count, + error: "", + }); + subscribeRunningRows(results); + } catch (error) { + if (error.status === 401) { logout(); return; } + _projectTaskPages.set(projectName, { + tasks: append && current ? current.tasks : [], + page: append && current ? current.page : 0, + count: current?.count || 0, + hasMore: true, + error: error.message || "加载失败", + }); + } finally { + _projectTaskLoading.delete(projectName); + renderTaskNavigation(); + } +} + +function renderTaskNavigation() { + const listEl = $("task-list"); + if (!_globalTaskResults.length) { + listEl.innerHTML = `
(暂无对话)
`; + return; + } + if (isTaskListFiltered()) { + listEl.innerHTML = `
筛选结果
${_globalTaskResults.map((t) => taskRowHtml(t)).join("")}
`; + } else { + listEl.innerHTML = groupedTaskListHtml(_globalTaskResults); + } + bindTaskListInteractions(listEl); +} + +export function refreshTaskProjectIndex() { + if (!isTaskListFiltered() && _globalTaskResults.length) renderTaskNavigation(); +} + function bindTaskListInteractions(root) { root.querySelectorAll(".task-row").forEach((el) => { el.onclick = (e) => { @@ -325,31 +446,51 @@ function bindTaskListInteractions(root) { button.onclick = () => { const section = button.closest(".task-project"); const next = !section.classList.contains("open"); + const currentOpen = new Set(Array.from(root.querySelectorAll(".task-project.open")) + .map((item) => item.dataset.projectKey)); section.classList.toggle("open", next); button.setAttribute("aria-expanded", String(next)); - const expanded = expandedTaskProjects() || new Set(); + const expanded = expandedTaskProjects() || currentOpen; if (next) expanded.add(section.dataset.projectKey); else expanded.delete(section.dataset.projectKey); localStorage.setItem(TASK_PROJECTS_EXPANDED_KEY, JSON.stringify(Array.from(expanded))); + if (next && section.dataset.projectKey !== "__ungrouped__" && !_projectTaskPages.has(section.dataset.projectKey)) { + loadProjectTasks(section.dataset.projectKey); + } }; }); + root.querySelectorAll(".task-project-more, .task-project-retry").forEach((button) => { + button.onclick = (event) => { + event.stopPropagation(); + loadProjectTasks(button.dataset.projectName, { append: button.classList.contains("task-project-more") }); + }; + }); + if (!isTaskListFiltered()) { + for (const section of root.querySelectorAll(".task-project.open")) { + if (section.dataset.projectKey !== "__ungrouped__" && !_projectTaskPages.has(section.dataset.projectKey)) { + queueMicrotask(() => loadProjectTasks(section.dataset.projectKey)); + } + } + } } function renderTaskList(tasks, append = false) { - if (!append) state.tasksById = {}; - for (const t of tasks) state.tasksById[t.task_id] = t; - const listEl = $("task-list"); - const loadedTasks = Object.values(state.tasksById); - if (!loadedTasks.length) { - listEl.innerHTML = `
(暂无对话)
`; - return; + if (!append) { + state.tasksById = {}; + _globalTaskResults = []; } - if (isTaskListFiltered()) { - listEl.innerHTML = `
筛选结果
${loadedTasks.map((t) => taskRowHtml(t)).join("")}
`; - } else { - listEl.innerHTML = groupedTaskListHtml(loadedTasks); + const positions = new Map(_globalTaskResults.map((task, index) => [task.task_id, index])); + for (const task of tasks) { + state.tasksById[task.task_id] = task; + const position = positions.get(task.task_id); + if (position === undefined) { + positions.set(task.task_id, _globalTaskResults.length); + _globalTaskResults.push(task); + } else { + _globalTaskResults[position] = task; + } } - bindTaskListInteractions(listEl); + renderTaskNavigation(); } // 渠道镜像卡片(微信 / 企业微信):左栏「新建任务」下方固定入口,收拢所有渠道交互 diff --git a/web/static/js/files.js b/web/static/js/files.js index c5d41410..88238441 100644 --- a/web/static/js/files.js +++ b/web/static/js/files.js @@ -312,6 +312,44 @@ function fileMtime(value) { }); } +const FILE_ICON_EXTENSIONS = { + word: new Set(["doc", "docx", "odt", "rtf"]), + sheet: new Set(["xls", "xlsx", "csv", "tsv"]), + slide: new Set(["ppt", "pptx", "odp"]), + image: new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "tif", "tiff"]), + archive: new Set(["zip", "rar", "7z", "tar", "gz", "bz2", "xz"]), + code: new Set(["html", "htm", "css", "js", "mjs", "ts", "tsx", "jsx", "json", "xml", "yaml", "yml", "py", "r", "sql", "ipynb"]), + text: new Set(["md", "txt", "log"]), +}; + +function fileIconKind(entry) { + if (entry.is_dir) return "folder"; + const dot = entry.name.lastIndexOf("."); + const ext = dot > -1 ? entry.name.slice(dot + 1).toLocaleLowerCase("en-US") : ""; + if (ext === "pdf") return "pdf"; + for (const [kind, extensions] of Object.entries(FILE_ICON_EXTENSIONS)) { + if (extensions.has(ext)) return kind; + } + return "file"; +} + +function fileIconHtml(entry) { + const kind = fileIconKind(entry); + const paths = { + folder: ``, + image: ``, + sheet: ``, + slide: ``, + archive: ``, + code: ``, + pdf: ``, + word: ``, + text: ``, + file: ``, + }; + return ``; +} + function renderFiles(data) { $("file-search").value = state.filesQuery; $("file-sort").value = state.filesSort; @@ -369,12 +407,12 @@ function renderFiles(data) { state.entriesByRel = {}; for (const e of visibleEntries) state.entriesByRel[e.rel] = e; $("file-list").innerHTML = visibleEntries.map((e) => { - const cls = e.is_dir ? "ico-dir" : "ico-file"; const fullTitle = e.rel || e.name; return `
+ ${fileIconHtml(e)} - ${escapeHtml(e.name)} + ${escapeHtml(e.name)} 修改 ${escapeHtml(fileMtime(e.mtime))} ${humanSize(e.size)} diff --git a/web/static/js/newtask.js b/web/static/js/newtask.js index 2ea03e30..f9d74807 100644 --- a/web/static/js/newtask.js +++ b/web/static/js/newtask.js @@ -7,7 +7,7 @@ import { state } from "./state.js"; import { api } from "./api.js"; import { escapeHtml } from "./format.js"; import { logout } from "./auth.js"; -import { loadModels, loadTaskList, selectTask } from "./chat.js"; +import { loadModels, loadTaskList, refreshTaskProjectIndex, selectTask } from "./chat.js"; // ───── new task ───── const LS_RECENT_WORKING_DIR = "zcbot.recent-working-dir"; @@ -78,6 +78,7 @@ export async function loadFolderSuggestions() { } populateFolderSelects(); renderCustomFolderOptions(); + refreshTaskProjectIndex(); } // 灌顶部 filter-wd;自定义创建弹框使用与快速新对话一致的组合框。