From 2560eb4ecebc8987c7d000433e390919605298ca Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 14 Aug 2026 08:28:52 +0800 Subject: [PATCH] feat(software): move job center into file pane --- CHANGELOG.md | 3 +- PROGRESS.md | 2 +- core/software_jobs.py | 17 +++- tests/test_static_vendor.py | 14 +++- web/routers/software_nodes.py | 28 ++++++- web/static/dev.html | 98 +++++++++++++++++------ web/static/js/chat.js | 6 ++ web/static/js/software_jobs.js | 139 ++++++++++++++++++++++++++------- 8 files changed, 244 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab565b..6680554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ ## Unreleased -- 新增专业软件 Job 中心:Agent 可将当前对话目录内的数据登记为稳定输入,提交、查询和停止 Windows Node 上的受控软件任务;用户可在右下角跨对话查看进度、收到完成通知,并回到原对话分析结果。 +- 新增专业软件 Job 中心:Agent 可将当前对话目录内的数据登记为稳定输入,提交、查询和停止 Windows Node 上的受控软件任务;文件栏底部固定展示任务状态,点击可从右侧打开按开启时间倒序、滚动加载的跨对话任务列表,并可收到完成通知或回到原对话分析结果。 +- 专业软件任务完成后,输出文件会立即显示在当前对话的文件面板;任务中心收起时也不会再遮挡发送按钮。 ## 0.65.2 — 2026-08-13 diff --git a/PROGRESS.md b/PROGRESS.md index 1fa66ef..779aede 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -24,7 +24,7 @@ - **08-13 / Unreleased / 专业软件输入 artifact 契约**:新增 `register_artifact`,将 task 内普通文件登记为稳定输入但不发布聊天交付卡片;`software_job_submit` 改为模型可见的完整 Origin schema,并强制使用 artifact UUID,已有 artifact 可直接复用,内部完整 request 仅保留兼容。同步校准当前三种图形类型与运行/设计文档;相关 51 项 unittest、Python 编译和 diff 检查通过,未连接或写入生产 DB。 -- **08-13 / Unreleased / 专业软件 Job 中心**:Agent 新增固定能力发现、提交、状态和取消工具,后端提供用户级跨对话任务列表与协作取消,Windows Node 可终止固定 Worker 进程树并幂等回报取消终态;Web 右下角展示活动/最近任务、进度、完成通知、停止与回到原对话分析入口。相关 Python 78 项、JavaScript 语法、Python 编译、.NET build 与 diff 检查通过;数据库保持在 0031,未连库、未执行 migration。 +- **08-13 / Unreleased / 专业软件 Job 中心**:Agent 新增固定能力发现、提交、状态和取消工具,后端提供用户级跨对话任务列表、稳定游标分页与协作取消,Windows Node 可终止固定 Worker 进程树并幂等回报取消终态;Web 在文件栏存储区上方固定展示任务摘要,右侧抽屉按开启时间倒序滚动加载任务,并提供进度、完成通知、停止与回到原对话分析入口。相关 Python 78 项、JavaScript 语法、Python 编译、.NET build 与 diff 检查通过;数据库保持在 0031,未连库、未执行 migration。 - **08-13 / 0.65.2 / 用户消息结构化附件 + 对话内图片预览**:新增 0031 `messages.attachment_refs`,新客户端将附件作为结构化字段发送,数据库正文仅保留用户自然语言;后端按 task working_dir 校验路径并在内存模型上下文中补附件提示,旧客户端与历史正文标记继续兼容。用户消息即时态和历史态统一展示附件 chip,图片额外显示可点击缩略图;相关 Python 34 项、全部前端 Node 26 项、Python/JavaScript 语法、Alembic 单 head 与 diff 检查通过,未连接或迁移生产 DB。 diff --git a/core/software_jobs.py b/core/software_jobs.py index 5c796c4..647b3b1 100644 --- a/core/software_jobs.py +++ b/core/software_jobs.py @@ -8,7 +8,7 @@ from datetime import datetime, timedelta, timezone from hashlib import sha256 from uuid import UUID, uuid4 -from sqlalchemy import desc, select +from sqlalchemy import and_, desc, or_, select from sqlalchemy.exc import IntegrityError from core.software_nodes import SUPPORTED_CAPABILITIES @@ -163,9 +163,11 @@ def list_jobs( task_id: UUID | None = None, active_only: bool = False, limit: int = 50, + before: tuple[datetime, UUID] | None = None, ) -> list[dict]: """列出用户的软件任务;用于全局 Job 中心和 Agent 查询。""" - limit = max(1, min(int(limit), 100)) + # Web 分页会多取一条判断是否还有下一页;公开接口仍把页大小限制为 100。 + limit = max(1, min(int(limit), 101)) with session_scope() as session: statement = ( select(SoftwareJob, Task.name, SoftwareNode.name) @@ -188,6 +190,17 @@ def list_jobs( } ) ) + if before is not None: + before_created_at, before_job_id = before + statement = statement.where( + or_( + SoftwareJob.created_at < before_created_at, + and_( + SoftwareJob.created_at == before_created_at, + SoftwareJob.job_id < before_job_id, + ), + ) + ) rows = session.execute( statement.order_by( desc(SoftwareJob.created_at), desc(SoftwareJob.job_id) diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index 606607a..939e8d7 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -29,11 +29,23 @@ class StaticVendorTests(unittest.TestCase): def test_dev_console_has_software_job_center(self) -> None: html = DEV_HTML.read_text(encoding="utf-8") source = (JS_DIR / "software_jobs.js").read_text(encoding="utf-8") + chat_source = (JS_DIR / "chat.js").read_text(encoding="utf-8") self.assertIn('id="software-job-center"', html) self.assertIn('id="software-job-toggle"', html) - self.assertIn('"/v1/software-jobs?limit=50"', source) + self.assertIn("#software-job-center {", html) + self.assertIn("#software-job-toggle {", html) + self.assertLess( + html.index('id="software-job-center"'), + html.index('id="storage-foot"'), + ) + self.assertIn('id="software-job-panel"', html) + self.assertIn("before_created_at", source) + self.assertIn("before_job_id", source) + self.assertIn("onListScroll", source) self.assertIn("/cancel`", source) self.assertIn("分析结果", source) + self.assertIn("refreshCurrentTaskFiles(job.task_id)", source) + self.assertIn("export function refreshCurrentTaskFiles(taskId)", chat_source) def test_admin_can_create_windows_node_enrollment_code(self) -> None: html = ADMIN_HTML.read_text(encoding="utf-8") diff --git a/web/routers/software_nodes.py b/web/routers/software_nodes.py index 8f7ebbe..4f0b23b 100644 --- a/web/routers/software_nodes.py +++ b/web/routers/software_nodes.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import os +from datetime import datetime from hashlib import sha256 from pathlib import Path from uuid import UUID @@ -501,12 +502,33 @@ def register_software_node_routes(app, *, require_user, require_admin) -> None: task_id: UUID | None = None, active_only: bool = False, limit: int = 50, + before_created_at: datetime | None = None, + before_job_id: UUID | None = None, user_id: UUID = Depends(require_user), # noqa: B008 ): - return { - "results": list_jobs( - user_id, task_id=task_id, active_only=active_only, limit=limit + if (before_created_at is None) != (before_job_id is None): + raise HTTPException( + 400, "before_created_at and before_job_id must be provided together" ) + page_limit = max(1, min(int(limit), 100)) + before = ( + (before_created_at, before_job_id) + if before_created_at is not None and before_job_id is not None + else None + ) + results = list_jobs( + user_id, task_id=task_id, active_only=active_only, + limit=page_limit + 1, before=before, + ) + has_more = len(results) > page_limit + results = results[:page_limit] + last = results[-1] if has_more else None + return { + "results": results, + "next_cursor": ( + {"created_at": last["created_at"], "job_id": last["job_id"]} + if last is not None else None + ), } @app.post("/v1/software-jobs/{job_id}/cancel", tags=["software-jobs"]) diff --git a/web/static/dev.html b/web/static/dev.html index 85cd211..577aa4c 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -1069,34 +1069,67 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; } .proc-toast.fail { border-color: rgba(192,57,43,0.5); } - #software-job-center { - position: fixed; right: 16px; bottom: 16px; z-index: 115; display: none; - width: min(380px, calc(100vw - 24px)); font-size: 13px; - } - #software-job-center.show { display: block; } + #software-job-center { display: block; flex-shrink: 0; font-size: 12px; } #software-job-toggle { - margin-left: auto; display: flex; align-items: center; gap: 8px; - border-radius: 999px; padding: 8px 13px; background: #fff; - border: 1px solid var(--border); box-shadow: 0 4px 16px rgba(0,0,0,.16); cursor: pointer; + width: 100%; display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; + align-items: center; gap: 8px; padding: 9px 12px; text-align: left; + border: 0; border-top: 1px solid var(--border); background: #fff; cursor: pointer; } - #software-job-center.expanded #software-job-toggle { border-radius: 0 0 10px 10px; } + #software-job-toggle:hover { background: #fafafa; } + .sj-entry-icon { + position: relative; width: 30px; height: 30px; display: grid; place-items: center; border-radius: 8px; + color: var(--accent); background: var(--accent-soft); border: 1px solid rgba(192,57,43,.14); + } + .sj-entry-dot { position: absolute; right: -2px; bottom: -2px; width: 9px; height: 9px; border: 2px solid #fff; border-radius: 50%; background: #9ca3af; } + .sj-entry-dot.active { background: #e28a2b; box-shadow: 0 0 0 2px rgba(226,138,43,.14); } + .sj-entry-dot.succeeded { background: #35a164; } + .sj-entry-dot.failed { background: var(--danger); } + .sj-entry-icon svg, .sj-panel-icon svg { width: 17px; height: 17px; } + .sj-entry-copy { min-width: 0; display: flex; flex-direction: column; gap: 2px; } + .sj-entry-title { font-weight: 600; color: var(--text); } + #software-job-count { color: var(--muted); font-size: 11px; font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .sj-entry-arrow { color: var(--muted); font-size: 18px; } + #software-job-backdrop { + position: fixed; inset: 0; z-index: 129; background: rgba(0,0,0,.18); + opacity: 0; visibility: hidden; transition: opacity .18s ease, visibility .18s ease; + } + #software-job-backdrop.open { opacity: 1; visibility: visible; } #software-job-panel { - max-height: min(520px, calc(100vh - 110px)); overflow: auto; background: #fff; - border: 1px solid var(--border); border-bottom: 0; border-radius: 10px 10px 0 0; - box-shadow: 0 -4px 20px rgba(0,0,0,.16); padding: 8px; + position: fixed; z-index: 130; top: 0; right: 0; bottom: 0; + width: min(420px, 92vw); display: flex; flex-direction: column; background: #fff; + box-shadow: -8px 0 28px rgba(0,0,0,.16); transform: translateX(105%); + visibility: hidden; transition: transform .2s ease, visibility .2s ease; } - .sj-card { padding: 10px; border-bottom: 1px solid var(--border); } - .sj-card:last-child { border-bottom: 0; } - .sj-title { display: flex; justify-content: space-between; gap: 10px; } - .sj-title span { color: var(--muted); white-space: nowrap; } - .sj-card.failed .sj-title span { color: var(--danger); } - .sj-card.disconnected .sj-title span, .sj-card.cancelling .sj-title span { color: #a66514; } + #software-job-panel.open { transform: translateX(0); visibility: visible; } + .sj-panel-head { flex-shrink: 0; display: flex; align-items: center; gap: 10px; padding: 13px 16px; border-bottom: 1px solid var(--border); background: linear-gradient(180deg, #fff, #fffafa); } + .sj-panel-icon { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 9px; color: #fff; background: linear-gradient(135deg, var(--accent), #8e2a20); box-shadow: 0 3px 8px rgba(192,57,43,.2); } + .sj-panel-head strong { font-size: 15px; } + .sj-panel-head .spacer { flex: 1; } + #software-job-list { flex: 1; min-height: 0; overflow: auto; padding: 6px 10px 18px; } + .sj-card { margin: 8px 0; padding: 11px 12px; border: 1px solid var(--border-soft); border-radius: 9px; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.035); transition: border-color .15s ease, box-shadow .15s ease; } + .sj-card:hover { border-color: var(--border); box-shadow: 0 3px 10px rgba(0,0,0,.055); } + .sj-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; } + .sj-title-main { min-width: 0; display: flex; align-items: center; gap: 7px; } + .sj-title-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .sj-status { flex-shrink: 0; display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; border-radius: 999px; color: #6b7280; background: #f3f4f6; font-size: 11px; white-space: nowrap; } + .sj-status svg { width: 12px; height: 12px; } + .sj-card.running .sj-status, .sj-card.dispatched .sj-status, .sj-card.offered .sj-status, .sj-card.queued .sj-status { color: #9b4316; background: #fff3e7; } + .sj-card.running .sj-status svg, .sj-card.offered .sj-status svg { animation: sj-spin 1.2s linear infinite; } + .sj-card.succeeded .sj-status { color: #237a46; background: #eaf7ef; } + .sj-card.failed .sj-status { color: var(--danger); background: #fff0ee; } + .sj-card.disconnected .sj-status, .sj-card.cancelling .sj-status { color: #9b6818; background: #fff8df; } + .sj-card.cancelled .sj-status { color: #70757d; background: #f1f2f4; } + @keyframes sj-spin { to { transform: rotate(360deg); } } .sj-sub, .sj-meta { margin-top: 4px; color: var(--muted); font-size: 11px; overflow-wrap: anywhere; } + .sj-sub { display: flex; align-items: center; gap: 5px; } + .sj-sub svg, .sj-meta svg { width: 12px; height: 12px; flex-shrink: 0; vertical-align: -2px; } .sj-progress { height: 4px; margin-top: 7px; border-radius: 4px; background: var(--panel-muted); overflow: hidden; } .sj-progress i { display: block; height: 100%; background: var(--accent); transition: width .25s ease; } - .sj-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 8px; } + .sj-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 9px; } + .sj-actions button { display: inline-flex; align-items: center; gap: 4px; } + .sj-actions svg { width: 12px; height: 12px; } .sj-empty { padding: 22px; text-align: center; color: var(--muted); } - #software-job-center.show ~ #proc-toasts { bottom: 62px; } + .sj-loading { padding: 14px; text-align: center; color: var(--muted); font-size: 11px; } /* media tool 摘要 banner(model / size / cost / elapsed,折叠态也可见) */ .tool-banner { display: inline-flex; flex-wrap: wrap; gap: 6px; @@ -2094,6 +2127,16 @@
加载中…
+
+ +
存储 @@ -2202,12 +2245,15 @@
-
- - -
+
+ diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 2ce6c4e..5fd3f85 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1871,6 +1871,12 @@ export function syncBgprocLock() { } } +// 专业软件任务在对话 run 之外发布产物,没有 SSE tool_result 可触发文件面板刷新。 +// 仅当前 task 完成时做一次 debounce 刷新,让已落盘输出立即可见。 +export function refreshCurrentTaskFiles(taskId) { + if (taskId && state.taskId === taskId) scheduleFilesRefresh(); +} + function chatAction() { if (isCurrentTaskStreaming()) { cancelCurrentTask(); return; } if (hasRunningProc(state.taskId)) { killTaskProcs(state.taskId); return; } diff --git a/web/static/js/software_jobs.js b/web/static/js/software_jobs.js index 0988809..f060683 100644 --- a/web/static/js/software_jobs.js +++ b/web/static/js/software_jobs.js @@ -1,19 +1,22 @@ -// 专业软件任务中心:用户级轮询、跨对话状态、取消与终态通知。 +// 专业软件任务中心:右栏固定摘要、抽屉列表、游标分页和终态通知。 import { api } from "./api.js"; import { state } from "./state.js"; import { $ } from "./dom.js"; import { escapeHtml } from "./format.js"; -import { selectTask } from "./chat.js"; +import { refreshCurrentTaskFiles, selectTask } from "./chat.js"; import { dialogConfirm, message } from "./dialog.js"; const ACTIVE = new Set(["queued", "offered", "dispatched", "running", "disconnected", "cancelling"]); const TERMINAL = new Set(["succeeded", "failed", "cancelled"]); +const PAGE_SIZE = 20; const POLL_ACTIVE_MS = 4000; const POLL_IDLE_MS = 30000; let timer = null; let known = new Map(); let jobs = []; +let nextCursor = null; let expanded = false; +let loadingMore = false; let initialized = false; const statusLabel = { @@ -27,33 +30,86 @@ const stageLabel = { cancel_requested: "停止请求已发送", terminal: "任务已结束", }; +const icons = { + activity: '', + analyze: '', + cancel: '', + cancelled: '', + clock: '', + failed: '', + open: '', + pending: '', + running: '', + succeeded: '', +}; + +function statusIcon(status) { + if (status === "succeeded") return icons.succeeded; + if (status === "failed") return icons.failed; + if (status === "cancelled") return icons.cancelled; + if (status === "queued" || status === "dispatched") return icons.pending; + return icons.running; +} + export function initSoftwareJobs() { if (initialized || !$("software-job-center")) return; initialized = true; - $("software-job-toggle").onclick = () => { expanded = !expanded; render(); }; - document.addEventListener("click", (event) => { - const center = $("software-job-center"); - if (expanded && center && !center.contains(event.target)) { expanded = false; render(); } + $("software-job-toggle").onclick = openDrawer; + $("software-job-close").onclick = closeDrawer; + $("software-job-backdrop").onclick = closeDrawer; + $("software-job-list").addEventListener("scroll", onListScroll); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && expanded) closeDrawer(); }); refreshSoftwareJobs(); } +function pagePath(cursor = null) { + const params = new URLSearchParams({ limit: String(PAGE_SIZE) }); + if (cursor) { + params.set("before_created_at", cursor.created_at); + params.set("before_job_id", cursor.job_id); + } + return `/v1/software-jobs?${params}`; +} + export async function refreshSoftwareJobs() { if (!state.token) return; try { - const data = await api("GET", "/v1/software-jobs?limit=50"); + const data = await api("GET", pagePath()); const next = data.results || []; next.forEach((job) => { const previous = known.get(job.job_id); if (previous && ACTIVE.has(previous) && TERMINAL.has(job.status)) notifyTerminal(job); known.set(job.job_id, job.status); }); - jobs = next; + const previousJobs = jobs; + const hadLoadedMore = previousJobs.length > PAGE_SIZE; + const firstPageIds = new Set(next.map((job) => job.job_id)); + jobs = [...next, ...previousJobs.filter((job) => !firstPageIds.has(job.job_id))]; + if (!hadLoadedMore) nextCursor = data.next_cursor; render(); } catch (_) { /* 后台刷新失败静默,下轮恢复 */ } schedule(); } +async function loadMore() { + if (loadingMore || !nextCursor) return; + loadingMore = true; + renderList(); + try { + const data = await api("GET", pagePath(nextCursor)); + const ids = new Set(jobs.map((job) => job.job_id)); + jobs.push(...(data.results || []).filter((job) => !ids.has(job.job_id))); + nextCursor = data.next_cursor; + } catch (error) { + message(error.message || "加载专业软件任务失败", "error"); + } finally { + loadingMore = false; + renderList(); + } +} + function schedule() { if (timer) clearTimeout(timer); const delay = jobs.some((job) => ACTIVE.has(job.status)) ? POLL_ACTIVE_MS : POLL_IDLE_MS; @@ -64,24 +120,48 @@ document.addEventListener("visibilitychange", () => { if (!document.hidden && state.token) refreshSoftwareJobs(); else schedule(); }); +function openDrawer() { + expanded = true; + render(); +} + +function closeDrawer() { + expanded = false; + render(); +} + function render() { - const center = $("software-job-center"); - const panel = $("software-job-panel"); const active = jobs.filter((job) => ACTIVE.has(job.status)); - const recent = jobs.slice(0, 10); - center.classList.toggle("show", active.length > 0 || recent.length > 0); - center.classList.toggle("expanded", expanded); - $("software-job-count").textContent = active.length ? `${active.length} 运行中` : `${jobs.length} 条`; - panel.hidden = !expanded; - if (!expanded) return; - panel.innerHTML = recent.length - ? recent.map(jobCard).join("") + const latest = jobs[0]; + const entryDot = $("software-job-entry-dot"); + entryDot.className = "sj-entry-dot"; + if (active.length) entryDot.classList.add("active"); + else if (latest?.status === "succeeded") entryDot.classList.add("succeeded"); + else if (latest?.status === "failed") entryDot.classList.add("failed"); + $("software-job-count").textContent = active.length + ? `${active.length} 个进行中 · ${statusLabel[active[0].status]}` + : (latest ? `最近:${statusLabel[latest.status] || latest.status}` : "暂无任务"); + $("software-job-panel").classList.toggle("open", expanded); + $("software-job-panel").setAttribute("aria-hidden", String(!expanded)); + $("software-job-backdrop").classList.toggle("open", expanded); + if (expanded) renderList(); +} + +function renderList() { + const list = $("software-job-list"); + list.innerHTML = jobs.length + ? jobs.map(jobCard).join("") + (nextCursor ? `
${loadingMore ? "正在加载…" : "继续向下滚动加载"}
` : "") : '
暂无专业软件任务
'; - panel.querySelectorAll("[data-job-action]").forEach((button) => { + list.querySelectorAll("[data-job-action]").forEach((button) => { button.onclick = (event) => handleAction(event, button); }); } +function onListScroll(event) { + const list = event.currentTarget; + if (list.scrollHeight - list.scrollTop - list.clientHeight < 160) loadMore(); +} + function jobCard(job) { const summary = job.request_summary || {}; const input = job.input || {}; @@ -89,16 +169,18 @@ function jobCard(job) { const progress = Math.max(0, Math.min(100, Number(job.progress || 0))); const detail = stageLabel[job.stage] || statusLabel[job.status] || "正在处理"; const error = job.error && (job.error.detail || job.error.code); + const opened = job.created_at; + const openedText = opened ? new Date(opened).toLocaleString("zh-CN", { hour12: false }) : ""; return `
-
${escapeHtml(summary.display_name || job.capability)} - ${escapeHtml(statusLabel[job.status] || job.status)}
-
${escapeHtml(detail)}${error ? ` · ${escapeHtml(error)}` : ""}
+
${escapeHtml(summary.display_name || job.capability)}
+ ${statusIcon(job.status)}${escapeHtml(statusLabel[job.status] || job.status)}
+
${icons.activity}${escapeHtml(detail)}${error ? ` · ${escapeHtml(error)}` : ""}
${active ? `
` : ""} -
${escapeHtml(job.task_name || "未命名对话")}${input.filename ? ` · ${escapeHtml(input.filename)}` : ""}
+
${icons.clock}${escapeHtml(job.task_name || "未命名对话")}${input.filename ? ` · ${escapeHtml(input.filename)}` : ""}${openedText ? ` · 开启于 ${escapeHtml(openedText)}` : ""}
- - ${job.status === "succeeded" ? `` : ""} - ${active && job.status !== "cancelling" ? '' : ""} + + ${job.status === "succeeded" ? `` : ""} + ${active && job.status !== "cancelling" ? `` : ""}
`; } @@ -122,8 +204,7 @@ async function handleAction(event, button) { } const taskId = button.dataset.taskId; if (taskId) await selectTask(taskId); - expanded = false; - render(); + closeDrawer(); if (action === "analyze") { setTimeout(() => { const input = $("chat-input"); @@ -140,5 +221,5 @@ function notifyTerminal(job) { const label = ok ? "已完成" : (job.status === "cancelled" ? "已取消" : "失败"); const summary = job.request_summary || {}; message(`${summary.display_name || "专业软件任务"}${label}`, ok ? "success" : "error", 6000); - expanded = true; + refreshCurrentTaskFiles(job.task_id); }