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;自定义创建弹框使用与快速新对话一致的组合框。