feat(web): 按项目分组对话导航

This commit is contained in:
caoqianming 2026-09-04 13:39:26 +08:00
parent 3b299df3d2
commit d90b9cad6b
6 changed files with 174 additions and 61 deletions

View File

@ -8,6 +8,8 @@
## Unreleased ## Unreleased
- 左侧导航改为“项目→对话”分组,并保留最近对话和筛选结果;右侧文件范围改用“当前项目 / 全部项目”表达,更容易看清同一项目下的多个对话和共享资料。
- 微信和企业微信会根据当前消息是否依赖上文自动决定是否续接上下文:隔天继续修改同一对象仍可接续,短时间内提出独立问题也不会被旧话题干扰;“新话题”等重置命令保持不变。 - 微信和企业微信会根据当前消息是否依赖上文自动决定是否续接上下文:隔天继续修改同一对象仍可接续,短时间内提出独立问题也不会被旧话题干扰;“新话题”等重置命令保持不变。
## 0.72.0 — 2026-09-04 ## 0.72.0 — 2026-09-04

View File

@ -176,9 +176,9 @@ Eval 与生产 core 解耦,通过现有 `/v1` API 创建专用任务、监听
**broker 外置(Redis pub/sub,✅ 0.42)**:0.41 落地时 event/cancel broker 留在进程内,代价是切换窗口内"刷新看不到旧实例 run 直播 / 停止送达不到"两个边缘。2026-07-06 重评(用户量已非个位数)决定实施 —— ①部署时总有 in-flight run,窗口边缘从偶发变常态;②单进程逼近天花板后,最近的扩容手段是稳态双实例同时接流量,broker 外置是它的硬前提(否则 POST 与 SSE 落不同实例直播全瞎)。选 Redis 不选 PG LISTEN/NOTIFY(token 级 delta 全过主库太吵:NOTIFY 全局队列 + 8KB payload 限制,把实时路径耦到 PG)、不选 nginx sticky hash(upstream 变更时 hash 重排,恰在部署窗口失效,治标)。实现(`web/broker.py`,LocalRunBroker/RedisRunBroker 同接口鸭子类型):`ZCBOT_REDIS_URL` env 开关,不设即进程内(dev 零影响);event 走 `PUBLISH zcbot:ev:<tid>` + 单条 async pubsub reader 路由到本地订阅 queue(Redis 只管跨进程一跳,fan-out 最后一公里仍在进程内);done = SETEX key(60s)+ publish 双通道,订阅先挂 channel 再查 key 不漏;cancel 在鉴权后先置本实例 Event再 SETEX/GET/DEL key 跨实例传递loop 在 chunk 间优先 poll 本地信号(通常约 100ms 内退出,不等待 Redis 或 DB 往返)。取消另以共享 PG 中已经提交的 `tasks.run_status='cancelling'` 作可靠兜底:运行线程每秒低频读取一次,未配置 Redis 的蓝绿窗口或 Redis 运行中故障时仍可跨实例停止PG 不承载 token 直播事件。容错纪律:启动 ping 不通 fail-fast(同 sandbox init),运行中失败 10s 节流 log + 降级丢帧,reader 1s 退避重连。Redis 不解决的:run 本体仍绑进程(进程死 run 死,drain/reaper 不变)、线程池上限(调 `ZCBOT_RUN_MAX_WORKERS` 的事)。 **broker 外置(Redis pub/sub,✅ 0.42)**:0.41 落地时 event/cancel broker 留在进程内,代价是切换窗口内"刷新看不到旧实例 run 直播 / 停止送达不到"两个边缘。2026-07-06 重评(用户量已非个位数)决定实施 —— ①部署时总有 in-flight run,窗口边缘从偶发变常态;②单进程逼近天花板后,最近的扩容手段是稳态双实例同时接流量,broker 外置是它的硬前提(否则 POST 与 SSE 落不同实例直播全瞎)。选 Redis 不选 PG LISTEN/NOTIFY(token 级 delta 全过主库太吵:NOTIFY 全局队列 + 8KB payload 限制,把实时路径耦到 PG)、不选 nginx sticky hash(upstream 变更时 hash 重排,恰在部署窗口失效,治标)。实现(`web/broker.py`,LocalRunBroker/RedisRunBroker 同接口鸭子类型):`ZCBOT_REDIS_URL` env 开关,不设即进程内(dev 零影响);event 走 `PUBLISH zcbot:ev:<tid>` + 单条 async pubsub reader 路由到本地订阅 queue(Redis 只管跨进程一跳,fan-out 最后一公里仍在进程内);done = SETEX key(60s)+ publish 双通道,订阅先挂 channel 再查 key 不漏;cancel 在鉴权后先置本实例 Event再 SETEX/GET/DEL key 跨实例传递loop 在 chunk 间优先 poll 本地信号(通常约 100ms 内退出,不等待 Redis 或 DB 往返)。取消另以共享 PG 中已经提交的 `tasks.run_status='cancelling'` 作可靠兜底:运行线程每秒低频读取一次,未配置 Redis 的蓝绿窗口或 Redis 运行中故障时仍可跨实例停止PG 不承载 token 直播事件。容错纪律:启动 ping 不通 fail-fast(同 sandbox init),运行中失败 10s 节流 log + 降级丢帧,reader 1s 退避重连。Redis 不解决的:run 本体仍绑进程(进程死 run 死,drain/reaper 不变)、线程池上限(调 `ZCBOT_RUN_MAX_WORKERS` 的事)。
### 7.1 心智模型:Task 一等公民 + Dir 文件副视图 ### 7.1 心智模型:Task 一等公民 + Dir 派生项目视图
两个并列入口,正交不嵌套:**Task list**(主,"我的对话历史")+ **Dir tree**(辅,"我的文件资产")。类比 Finder + 最近使用。dir **不是 task 的父容器**、无 DB 实体、path 即标识。同 working_dir 多 task 共享 = "同一项目多对话",无需"项目"实体;前缀嵌套拒(no-subtask)。skill 产物全落 working_dir,不引 artifacts 表。空 dir 正常展示(上传本身是有效行为)。多 task 并发写由软警告兜底(§7.9)。 Task 与 Dir 仍是两个正交事实源:**Task** 保存对话历史,**Dir tree** 保存文件资产。为了让用户直观理解“同一项目多对话”,左栏把 `working_dir` 显示为派生的“项目→对话”分组,顶部另保留跨项目的最近对话,搜索/筛选时回到扁平结果右栏仍是当前项目与全部项目的文件视图。这只是展示投影dir **不是 task 的父容器**、无 DB 实体、path 即标识,也不新增“项目”实体。前缀嵌套仍拒(no-subtask)skill 产物全落 working_dir空 dir 在全部项目文件视图正常展示(上传本身是有效行为)。多 task 并发写由软警告兜底(§7.9)。
**新对话入口(0.60)**:登录未选 task 与左栏「+ 新对话」共用同一前端草稿页,先选择已有 working_dir 或输入新目录名,再直接写消息;草稿不落 DB首发时才 `POST /v1/tasks`,避免空 task 堆积。创建请求省略/留空 name 时必须显式给 working_dir后端据此判定自动命名以「新对话」占位并置一次性 `auto_title_pending`;显式 name 的旧调用继续视为人工标题working_dir 仍可省略并 fallback 到 name`auto_title` 字段只作兼容保留。首条消息并行触发短标题调用,结果只改 `tasks.name`、绝不改 working_dir人工 PATCH name 同时清 pending条件 UPDATE 保证在途标题也不能覆盖用户命名。原完整创建表单保留为「自定义」入口UI 同样要求明确选择 working_dirname 可选,并可预设 description/skill/model。标题是 UI 元数据辅助调用,记 `usage_events.kind="task_title"`;模型调用失败时以首条消息第一行生成本地兜底标题,不阻塞主 run也不把 pending 留给后续消息误命名。 **新对话入口(0.60)**:登录未选 task 与左栏「+ 新对话」共用同一前端草稿页,先选择已有 working_dir 或输入新目录名,再直接写消息;草稿不落 DB首发时才 `POST /v1/tasks`,避免空 task 堆积。创建请求省略/留空 name 时必须显式给 working_dir后端据此判定自动命名以「新对话」占位并置一次性 `auto_title_pending`;显式 name 的旧调用继续视为人工标题working_dir 仍可省略并 fallback 到 name`auto_title` 字段只作兼容保留。首条消息并行触发短标题调用,结果只改 `tasks.name`、绝不改 working_dir人工 PATCH name 同时清 pending条件 UPDATE 保证在途标题也不能覆盖用户命名。原完整创建表单保留为「自定义」入口UI 同样要求明确选择 working_dirname 可选,并可预设 description/skill/model。标题是 UI 元数据辅助调用,记 `usage_events.kind="task_title"`;模型调用失败时以首条消息第一行生成本地兜底标题,不阻塞主 run也不把 pending 留给后续消息误命名。

View File

@ -20,6 +20,8 @@
--- ---
## 已完成关键能力 ## 已完成关键能力
- **09-04 / Unreleased / 项目分组对话导航**:左栏从扁平 task 列表改为“最近对话 + `working_dir` 派生项目分组”,项目展开偏好本地保存,搜索、状态、目录和排序筛选仍使用扁平结果;右栏范围改称“当前项目 / 全部项目”以符合实际 working_dir 语义。对话/项目图标限制为 15/16px操作热区保持 28px无 API、schema 或 migration 变化。
- **09-04 / 0.72.0 / 二维 CAD 工程制图**:新增非默认 `cad.drawing.author@v1` / adapter 1.0.0 与独立 `cad2d` runtime固定完整 Recipe 覆盖直墙、门窗、区域、家具/设备/安全块、受控矩形/圆、机械尺寸和中文文字,交付 DXF R2013、SVG、PNG、PDF、Recipe、manifest、validation report 与隐藏 provenance。DXF 重开、固定 12 图层、引用/范围/相交/图框/计数/bbox/区域面积和产物摘要作为阻断门,碰撞与可读性只报 warning通过通用 Workspace head/revise 以完整 Recipe 连续修改,每版创建新 Job/Artifact不覆盖历史。Windows Node 增加 runtime-only 软件卡、10 分钟 timeout、固定哈希依赖、probe、完整/独立包和离线 acceptance小三居、两室一厅、实验室/车间、中文、A4/A3/A2、密集尺寸、失败/警告、中文与空格路径、三次确定性及取消清理自动门通过AutoCAD/中望/浩辰真机兼容仍待人工验证。 - **09-04 / 0.72.0 / 二维 CAD 工程制图**:新增非默认 `cad.drawing.author@v1` / adapter 1.0.0 与独立 `cad2d` runtime固定完整 Recipe 覆盖直墙、门窗、区域、家具/设备/安全块、受控矩形/圆、机械尺寸和中文文字,交付 DXF R2013、SVG、PNG、PDF、Recipe、manifest、validation report 与隐藏 provenance。DXF 重开、固定 12 图层、引用/范围/相交/图框/计数/bbox/区域面积和产物摘要作为阻断门,碰撞与可读性只报 warning通过通用 Workspace head/revise 以完整 Recipe 连续修改,每版创建新 Job/Artifact不覆盖历史。Windows Node 增加 runtime-only 软件卡、10 分钟 timeout、固定哈希依赖、probe、完整/独立包和离线 acceptance小三居、两室一厅、实验室/车间、中文、A4/A3/A2、密集尺寸、失败/警告、中文与空格路径、三次确定性及取消清理自动门通过AutoCAD/中望/浩辰真机兼容仍待人工验证。
- **09-04 / 0.72.0 / 简易 auto 推理强度模式**:模型档案可保留平台值 `auto`DeepSeek V4 Flash 首次调用和失败工具步后使用 high、成功工具步后使用 lowPro 的 medium 与其他固定档保持原样;纯 reasoning 超过 90 秒会清理直播推理并以 low 和请求内临时约束重试一次,第二次仍超时明确停止,取消优先且半截 assistant 不入库。正常 chat usage 留存配置值、实际档位、决策原因和保护重试标记,熔断复用 `agent_guard`;无 schema、migration、API、前端或版本变化。 - **09-04 / 0.72.0 / 简易 auto 推理强度模式**:模型档案可保留平台值 `auto`DeepSeek V4 Flash 首次调用和失败工具步后使用 high、成功工具步后使用 lowPro 的 medium 与其他固定档保持原样;纯 reasoning 超过 90 秒会清理直播推理并以 low 和请求内临时约束重试一次,第二次仍超时明确停止,取消优先且半截 assistant 不入库。正常 chat usage 留存配置值、实际档位、决策原因和保护重试标记,熔断复用 `agent_guard`;无 schema、migration、API、前端或版本变化。

View File

@ -23,12 +23,22 @@ test("ordinary uploads default to rename conflict policy", () => {
}); });
test("file workspace exposes explicit task and all-files contexts", () => { test("file workspace exposes explicit task and all-files contexts", () => {
assert.match(pageHtml, /id="files-scope-task"[^>]*>当前对话</); assert.match(pageHtml, /id="files-scope-task"[^>]*>当前项目</);
assert.match(pageHtml, /id="files-scope-all"[^>]*>全部文件</); assert.match(pageHtml, /id="files-scope-all"[^>]*>全部项目</);
assert.match(filesJs, /state\.filesScope = "task"/); assert.match(filesJs, /state\.filesScope = "task"/);
assert.match(filesJs, /state\.filesScope = "all"/); assert.match(filesJs, /state\.filesScope = "all"/);
}); });
test("task navigation groups chats by project with restrained icon sizes", () => {
const chatJs = fs.readFileSync("web/static/js/chat.js", "utf8");
assert.match(pageHtml, />项目与对话</);
assert.match(chatJs, /task-nav-recent/);
assert.match(chatJs, /task-project-head/);
assert.match(chatJs, /groupedTaskListHtml/);
assert.match(pageHtml, /\.task-nav-icon \{ width: 15px; height: 15px;/);
assert.match(pageHtml, /\.task-project-head \.task-nav-icon \{ width: 16px; height: 16px;/);
});
test("current directory supports search, sort, and modification time", () => { test("current directory supports search, sort, and modification time", () => {
assert.match(pageHtml, /id="file-search"/); assert.match(pageHtml, /id="file-search"/);
assert.match(pageHtml, /id="file-sort"/); assert.match(pageHtml, /id="file-sort"/);

View File

@ -666,9 +666,40 @@
/* ───── task list ───── */ /* ───── task list ───── */
.task-row { .task-row {
padding: 8px 12px; border-bottom: 1px solid var(--border); cursor: pointer; padding: 8px 12px; border-bottom: 1px solid var(--border); cursor: pointer;
display: flex; align-items: flex-start; gap: 7px;
} }
.task-row:hover { background: var(--hover); } .task-row:hover { background: var(--hover); }
.task-row.active { background: var(--accent-soft); border-left: 3px solid var(--accent); padding-left: 9px; } .task-row.active { background: var(--accent-soft); border-left: 3px solid var(--accent); padding-left: 9px; }
.task-main { flex: 1; min-width: 0; }
.task-row-icon { width: 16px; height: 20px; display: inline-flex; align-items: center; justify-content: center;
flex: none; color: #7b8087; }
.task-nav-icon { width: 15px; height: 15px; display: block; flex: none; }
.task-row .desc .badge, .task-row .desc .run-ind { margin-right: 5px; vertical-align: middle; }
.task-row .task-desc, .task-row .wd-line { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-row-compact { padding: 6px 10px; border-bottom: 0; border-radius: 4px; margin: 1px 8px; }
.task-row-compact.active { padding-left: 7px; }
.task-row-compact .desc { margin: 0; font-size: 12.5px; }
.task-row-compact .meta.stats { justify-content: flex-end; margin-top: 1px; }
.task-row-compact .task-menu { width: 28px; height: 28px; padding: 0; align-items: center; justify-content: center; }
.task-row-nested { margin-left: 22px; }
.task-nav-recent { padding: 8px 0 6px; border-bottom: 1px solid var(--border-soft); }
.task-nav-section-title { min-height: 28px; padding: 4px 12px; display: flex; align-items: center; gap: 7px;
color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .02em; }
.task-projects { padding: 6px 0 10px; }
.task-project { margin: 1px 0; }
.task-project-head { width: 100%; min-height: 34px; padding: 4px 12px 4px 8px; border: 0; border-radius: 0;
background: transparent; color: var(--text); display: flex; align-items: center; gap: 6px; cursor: pointer; text-align: left; }
.task-project-head:hover { background: var(--hover); }
.task-project-chevron { width: 12px; height: 18px; display: inline-flex; align-items: center; justify-content: center;
flex: none; color: var(--muted); font-size: 15px; line-height: 1; transition: transform .14s ease; }
.task-project.open .task-project-chevron { transform: rotate(90deg); }
.task-project-head .task-nav-icon { width: 16px; height: 16px; color: #c89022; }
.task-project-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 13px; font-weight: 600; }
.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-filter-results { padding: 5px 0 10px; }
.task-row .desc { font-weight: 500; color: var(--text); margin-bottom: 2px; .task-row .desc { font-weight: 500; color: var(--text); margin-bottom: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* meta 行:flex nowrap + 每个子项 nowrap,防 CJK 字符在窄 pane(320px)被 shrink 后断行 */ /* meta 行:flex nowrap + 每个子项 nowrap,防 CJK 字符在窄 pane(320px)被 shrink 后断行 */
@ -691,7 +722,7 @@
/* 运行态标识(run_status):纯脉冲圆点,running 绿、cancelling 橙、error 红,文案在 hover title /* 运行态标识(run_status):纯脉冲圆点,running 绿、cancelling 橙、error 红,文案在 hover title
(meta 行拥挤,不放文字)。数据源 = /v1/tasks 行 run_status + 本地 liveRuns 叠加; (meta 行拥挤,不放文字)。数据源 = /v1/tasks 行 run_status + 本地 liveRuns 叠加;
run 开始/停止就地 patch,结束随列表重拉清掉 */ run 开始/停止就地 patch,结束随列表重拉清掉 */
.task-row .meta .run-ind { flex-shrink: 0; display: inline-flex; align-self: center; } .task-row .run-ind { flex-shrink: 0; display: inline-flex; align-items: center; vertical-align: middle; }
.run-ind.running { color: var(--c-green); } .run-ind.running { color: var(--c-green); }
.run-ind.cancelling { color: var(--c-orange); } .run-ind.cancelling { color: var(--c-orange); }
.run-ind.error { color: var(--c-red); } .run-ind.error { color: var(--c-red); }
@ -2176,7 +2207,7 @@
<!-- left --> <!-- left -->
<div class="pane" id="pane-left"> <div class="pane" id="pane-left">
<div class="pane-head"> <div class="pane-head">
<span class="label">任务</span> <span class="label">项目与对话</span>
<span class="small muted" id="task-count" style="font-size:11px;"></span> <span class="small muted" id="task-count" style="font-size:11px;"></span>
<span class="spacer"></span> <span class="spacer"></span>
<button id="filter-toggle" class="small" title="展开/收起筛选">筛选 ▾</button> <button id="filter-toggle" class="small" title="展开/收起筛选">筛选 ▾</button>
@ -2331,8 +2362,8 @@
</div> </div>
<div id="right-files-panel" class="right-workspace-panel" role="tabpanel" aria-labelledby="right-tab-files"> <div id="right-files-panel" class="right-workspace-panel" role="tabpanel" aria-labelledby="right-tab-files">
<div id="file-context" class="file-context"> <div id="file-context" class="file-context">
<button id="files-scope-task" type="button" class="active">当前对话</button> <button id="files-scope-task" type="button" class="active">当前项目</button>
<button id="files-scope-all" type="button">全部文件</button> <button id="files-scope-all" type="button">全部项目</button>
<span id="files-proj" class="muted small" title=""></span> <span id="files-proj" class="muted small" title=""></span>
</div> </div>
<div class="file-tools"> <div class="file-tools">

View File

@ -192,34 +192,43 @@ function runIndicatorHtml(t) {
// 会把滚动加载的分页收回第一页);run 结束由 fetchSse 收尾的 loadTaskList() 全量重拉兜底。 // 会把滚动加载的分页收回第一页);run 结束由 fetchSse 收尾的 loadTaskList() 全量重拉兜底。
// 行不在当前列表窗口(被筛掉 / 未滚动加载到)则跳过,下次重拉自然带出。 // 行不在当前列表窗口(被筛掉 / 未滚动加载到)则跳过,下次重拉自然带出。
function syncTaskRowRunIndicator(tid) { function syncTaskRowRunIndicator(tid) {
const row = document.querySelector(`.task-row[data-tid="${CSS.escape(tid)}"]`); const rows = document.querySelectorAll(`.task-row[data-tid="${CSS.escape(tid)}"]`);
if (!row) return; for (const row of rows) {
// 圆点 host 与渲染时同规则:优先文件夹行,无文件夹行回落底部数据行 // 分组视图中同一 task 可同时出现在「最近对话」和项目内,两处状态灯同步刷新。
const host = row.querySelector(".wd-line") || row.querySelector(".meta.stats"); const host = row.querySelector(".desc") || row.querySelector(".meta.stats") || row.querySelector(".task-main");
if (!host) return; if (!host) continue;
const old = host.querySelector(".run-ind"); row.querySelectorAll(".run-ind").forEach((old) => old.remove());
if (old) old.remove();
const html = runIndicatorHtml((state.tasksById || {})[tid] || { task_id: tid }); const html = runIndicatorHtml((state.tasksById || {})[tid] || { task_id: tid });
if (!html) return; if (!html) continue;
const badge = host.querySelector(".badge"); const badge = host.querySelector(".badge");
if (badge) badge.insertAdjacentHTML("afterend", html); if (badge) badge.insertAdjacentHTML("afterend", html);
else host.insertAdjacentHTML("afterbegin", html); else host.insertAdjacentHTML("afterbegin", html);
} }
function renderTaskList(tasks, append = false) {
if (!append) state.tasksById = {};
for (const t of tasks) state.tasksById[t.task_id] = t;
if (!append && !tasks.length) {
$("task-list").innerHTML = `<div class="empty">(暂无任务)</div>`;
return;
} }
if (append && !tasks.length) return; // 末页空 batch,不动 DOM
const TASK_PROJECTS_EXPANDED_KEY = "zcbot-task-projects-expanded-v1";
function taskProject(t) {
const path = t.working_dir || "";
const name = path.split("/").filter(Boolean).pop() || "未分组对话";
return { key: path || "__ungrouped__", name };
}
function taskNavIcon(kind) {
if (kind === "folder") {
return `<svg class="task-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H10l2 2h6.5A2.5 2.5 0 0 1 21 8.5v9A2.5 2.5 0 0 1 18.5 20h-13A2.5 2.5 0 0 1 3 17.5z"/></svg>`;
}
if (kind === "recent") {
return `<svg class="task-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>`;
}
return `<svg class="task-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 5.5h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H11l-4.5 3v-3H5a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2z"/></svg>`;
}
function taskRowHtml(t, { compact = false, nested = false } = {}) {
// 默认态静默:active 不挂徽章(列表主体都是 active,重复徽章是纯噪音), // 默认态静默:active 不挂徽章(列表主体都是 active,重复徽章是纯噪音),
// 终态(completed/abandoned)才着色 + 整行淡化(st-* class),瞬时态交给运行圆点 // 终态(completed/abandoned)才着色 + 整行淡化(st-* class),瞬时态交给运行圆点
const statusLabels = { completed: "已完成", abandoned: "已废弃" }; const statusLabels = { completed: "已完成", abandoned: "已废弃" };
const html = tasks.map((t) => {
const active = state.taskId === t.task_id ? " active" : ""; const active = state.taskId === t.task_id ? " active" : "";
// 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示)
const taskName = t.name || "(未命名)"; const taskName = t.name || "(未命名)";
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : ""; const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
const desc = t.description || ""; const desc = t.description || "";
@ -230,58 +239,117 @@ function renderTaskList(tasks, append = false) {
// 状态(终态徽章 + 运行圆点)放文件夹行左侧——行首左上区最先被扫到; // 状态(终态徽章 + 运行圆点)放文件夹行左侧——行首左上区最先被扫到;
// 无文件夹行的 task 回落到底部数据行行首(syncTaskRowRunIndicator 同规则找 host) // 无文件夹行的 task 回落到底部数据行行首(syncTaskRowRunIndicator 同规则找 host)
const indHtml = `${statusLabel ? `<span class="badge ${t.status}">${statusLabel}</span>` : ""}${runIndicatorHtml(t)}`; const indHtml = `${statusLabel ? `<span class="badge ${t.status}">${statusLabel}</span>` : ""}${runIndicatorHtml(t)}`;
// 渠道镜像 task(微信 / 企业微信)不进此列表 —— 后端 /v1/tasks 已排除,改由左栏卡片承载(loadChannelCards)
return ` return `
<div class="task-row${active}${stCls}" data-tid="${t.task_id}" title="${escapeHtml(rowTitle)}" style="display:flex;align-items:flex-start;gap:6px;"> <div class="task-row${compact ? " task-row-compact" : ""}${nested ? " task-row-nested" : ""}${active}${stCls}" data-tid="${t.task_id}" title="${escapeHtml(rowTitle)}">
<div style="flex:1;min-width:0;"> <span class="task-row-icon">${taskNavIcon("chat")}</span>
<div class="desc">${escapeHtml(taskName)}</div> <div class="task-main">
${wdName ? `<div class="meta muted wd-line" title="${escapeHtml(t.working_dir || "")}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${indHtml}📁 ${escapeHtml(wdName)}</div>` : ""} <div class="desc">${indHtml}${escapeHtml(taskName)}</div>
${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(desc)}</div>` : ""} ${!compact && wdName ? `<div class="meta muted wd-line" title="${escapeHtml(t.working_dir || "")}">${escapeHtml(wdName)}</div>` : ""}
${!compact && desc ? `<div class="meta muted task-desc" title="${escapeHtml(desc)}">${escapeHtml(desc)}</div>` : ""}
<div class="meta stats"> <div class="meta stats">
${wdName ? "" : indHtml} ${wdName ? "" : indHtml}
${t.skill ? `<span class="muted" title="${escapeHtml(t.skill)}">${escapeHtml(t.skill)}</span>` : ""} ${t.skill ? `<span class="muted" title="${escapeHtml(t.skill)}">${escapeHtml(t.skill)}</span>` : ""}
<span class="num">${t.n_messages || 0} </span> ${compact ? "" : `<span class="num">${t.n_messages || 0} 条</span><span class="num" title="${escapeHtml(taskUsageTooltip(t))}">${fmtTokens(t.tokens)} tok</span>`}
<span class="num" title="${escapeHtml(taskUsageTooltip(t))}">${fmtTokens(t.tokens)} tok</span>
<span class="muted time-ago" title="${escapeHtml(fmtTime(t.updated_at))}">${escapeHtml(fmtTimeAgo(t.updated_at))}</span> <span class="muted time-ago" title="${escapeHtml(fmtTime(t.updated_at))}">${escapeHtml(fmtTimeAgo(t.updated_at))}</span>
</div> </div>
</div> </div>
<button class="dd-toggle task-menu" data-tid="${t.task_id}" title="任务操作"></button> <button class="dd-toggle task-menu" data-tid="${t.task_id}" title="任务操作"></button>
</div> </div>
`; `;
}).join("");
const listEl = $("task-list");
let newRows;
if (append) {
const tmp = document.createElement("div");
tmp.innerHTML = html;
newRows = Array.from(tmp.children);
newRows.forEach((el) => listEl.appendChild(el));
} else {
listEl.innerHTML = html;
newRows = Array.from(listEl.querySelectorAll(".task-row"));
} }
newRows.forEach((el) => {
if (!el.classList || !el.classList.contains("task-row")) return; function isTaskListFiltered() {
return Boolean($("filter-q").value.trim() || $("filter-status").value || $("filter-wd").value ||
($("filter-order").value && $("filter-order").value !== "-updated_at"));
}
function expandedTaskProjects() {
try {
const raw = localStorage.getItem(TASK_PROJECTS_EXPANDED_KEY);
return raw === null ? null : new Set(JSON.parse(raw));
} catch (_) {
return null;
}
}
function groupedTaskListHtml(tasks) {
const groups = 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);
}
const expanded = expandedTaskProjects();
const activeProject = state.taskId && state.tasksById[state.taskId]
? taskProject(state.tasksById[state.taskId]).key : "";
const recent = tasks.slice(0, Math.min(4, tasks.length));
const recentHtml = recent.length ? `
<section class="task-nav-recent">
<div class="task-nav-section-title">${taskNavIcon("recent")}<span>最近对话</span></div>
${recent.map((t) => taskRowHtml(t, { compact: true })).join("")}
</section>` : "";
const projectsHtml = Array.from(groups.values()).map((group, index) => {
const open = expanded === null ? (group.key === activeProject || index === 0) : expanded.has(group.key);
return `<section class="task-project${open ? " open" : ""}" data-project-key="${escapeHtml(group.key)}">
<button class="task-project-head" type="button" aria-expanded="${open}">
<span class="task-project-chevron" aria-hidden="true"></span>
${taskNavIcon("folder")}
<span class="task-project-name" title="${escapeHtml(group.key === "__ungrouped__" ? group.name : group.key)}">${escapeHtml(group.name)}</span>
<span class="task-project-count">${group.tasks.length}</span>
</button>
<div class="task-project-items">${group.tasks.map((t) => taskRowHtml(t, { compact: true, nested: true })).join("")}</div>
</section>`;
}).join("");
return `${recentHtml}<div class="task-projects">${projectsHtml}</div>`;
}
function bindTaskListInteractions(root) {
root.querySelectorAll(".task-row").forEach((el) => {
el.onclick = (e) => { el.onclick = (e) => {
if (e.target.closest(".dd-toggle")) return; // 菜单按钮点击不触发选中 if (e.target.closest(".dd-toggle")) return;
selectTask(el.dataset.tid); selectTask(el.dataset.tid);
}; };
el.oncontextmenu = (e) => { el.oncontextmenu = (e) => {
e.preventDefault(); e.preventDefault();
const t = state.tasksById[el.dataset.tid]; const t = state.tasksById[el.dataset.tid];
if (!t) return; if (t) showMenuAtPoint(e.clientX, e.clientY, taskMenuItems(t));
showMenuAtPoint(e.clientX, e.clientY, taskMenuItems(t));
}; };
const btn = el.querySelector(".task-menu"); const btn = el.querySelector(".task-menu");
if (btn) { if (btn) btn.onclick = (e) => {
btn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
const t = state.tasksById[btn.dataset.tid]; const t = state.tasksById[btn.dataset.tid];
if (!t) return; if (t) showMenu(btn, taskMenuItems(t));
showMenu(btn, taskMenuItems(t));
}; };
}
}); });
root.querySelectorAll(".task-project-head").forEach((button) => {
button.onclick = () => {
const section = button.closest(".task-project");
const next = !section.classList.contains("open");
section.classList.toggle("open", next);
button.setAttribute("aria-expanded", String(next));
const expanded = expandedTaskProjects() || new Set();
if (next) expanded.add(section.dataset.projectKey);
else expanded.delete(section.dataset.projectKey);
localStorage.setItem(TASK_PROJECTS_EXPANDED_KEY, JSON.stringify(Array.from(expanded)));
};
});
}
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 = `<div class="empty">(暂无对话)</div>`;
return;
}
if (isTaskListFiltered()) {
listEl.innerHTML = `<div class="task-filter-results"><div class="task-nav-section-title">筛选结果</div>${loadedTasks.map((t) => taskRowHtml(t)).join("")}</div>`;
} else {
listEl.innerHTML = groupedTaskListHtml(loadedTasks);
}
bindTaskListInteractions(listEl);
} }
// 渠道镜像卡片(微信 / 企业微信):左栏「新建任务」下方固定入口,收拢所有渠道交互 // 渠道镜像卡片(微信 / 企业微信):左栏「新建任务」下方固定入口,收拢所有渠道交互