feat(software): move job center into file pane

This commit is contained in:
caoqianming 2026-08-14 08:28:52 +08:00
parent 40f8899cc1
commit 2560eb4ece
8 changed files with 244 additions and 63 deletions

View File

@ -8,7 +8,8 @@
## Unreleased
- 新增专业软件 Job 中心Agent 可将当前对话目录内的数据登记为稳定输入,提交、查询和停止 Windows Node 上的受控软件任务;用户可在右下角跨对话查看进度、收到完成通知,并回到原对话分析结果。
- 新增专业软件 Job 中心Agent 可将当前对话目录内的数据登记为稳定输入,提交、查询和停止 Windows Node 上的受控软件任务;文件栏底部固定展示任务状态,点击可从右侧打开按开启时间倒序、滚动加载的跨对话任务列表,并可收到完成通知或回到原对话分析结果。
- 专业软件任务完成后,输出文件会立即显示在当前对话的文件面板;任务中心收起时也不会再遮挡发送按钮。
## 0.65.2 — 2026-08-13

View File

@ -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。

View File

@ -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)

View File

@ -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")

View File

@ -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"])

View File

@ -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 @@
<div id="file-upload-status" class="upload-status"></div>
<div id="file-crumbs" class="crumbs muted">加载中…</div>
<div id="file-list"></div>
<div id="software-job-center" aria-live="polite">
<button id="software-job-toggle" type="button" title="查看专业软件任务">
<span class="sj-entry-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="3"></rect><path d="M8 9h8M8 13h5"></path><path d="M9 5V3m6 2V3"></path></svg><i id="software-job-entry-dot" class="sj-entry-dot"></i></span>
<span class="sj-entry-copy">
<span class="sj-entry-title">专业软件任务</span>
<span id="software-job-count">暂无任务</span>
</span>
<span class="sj-entry-arrow" aria-hidden="true"></span>
</button>
</div>
<div id="storage-foot" class="storage-foot" title="">
<span id="app-version" title="版本号"></span>
<span class="lbl">存储</span>
@ -2202,12 +2245,15 @@
</div>
</div>
<div id="software-job-center" aria-live="polite">
<div id="software-job-panel" hidden></div>
<button id="software-job-toggle" type="button" title="展开专业软件任务">
<span>专业软件任务</span><strong id="software-job-count">0 条</strong>
</button>
</div>
<div id="software-job-backdrop"></div>
<aside id="software-job-panel" aria-hidden="true" aria-label="专业软件任务列表">
<div class="sj-panel-head">
<span class="sj-panel-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="3"></rect><path d="M8 9h8M8 13h5"></path><path d="M9 5V3m6 2V3"></path></svg></span>
<strong>专业软件任务</strong><span class="spacer"></span>
<button id="software-job-close" class="small" type="button" title="关闭">×</button>
</div>
<div id="software-job-list"></div>
</aside>
<script type="module" src="js/main.js"></script>
</body>

View File

@ -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; }

View File

@ -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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2-6 4 12 2-6h6"/></svg>',
analyze: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19V9m6 10V5m6 14v-7m4 7H2"/></svg>',
cancel: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>',
cancelled: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6m0-6-6 6"/></svg>',
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
failed: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5m0 3h.01"/></svg>',
open: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 5h5v5m0-5-8 8"/><path d="M19 13v5a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/></svg>',
pending: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
running: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/></svg>',
succeeded: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/></svg>',
};
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 ? `<div class="sj-loading">${loadingMore ? "正在加载…" : "继续向下滚动加载"}</div>` : "")
: '<div class="sj-empty">暂无专业软件任务</div>';
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 `<article class="sj-card ${escapeHtml(job.status)}" data-job-id="${escapeHtml(job.job_id)}">
<div class="sj-title"><strong>${escapeHtml(summary.display_name || job.capability)}</strong>
<span>${escapeHtml(statusLabel[job.status] || job.status)}</span></div>
<div class="sj-sub">${escapeHtml(detail)}${error ? ` · ${escapeHtml(error)}` : ""}</div>
<div class="sj-title"><div class="sj-title-main"><strong>${escapeHtml(summary.display_name || job.capability)}</strong></div>
<span class="sj-status">${statusIcon(job.status)}${escapeHtml(statusLabel[job.status] || job.status)}</span></div>
<div class="sj-sub">${icons.activity}<span>${escapeHtml(detail)}${error ? ` · ${escapeHtml(error)}` : ""}</span></div>
${active ? `<div class="sj-progress"><i style="width:${progress}%"></i></div>` : ""}
<div class="sj-meta">${escapeHtml(job.task_name || "未命名对话")}${input.filename ? ` · ${escapeHtml(input.filename)}` : ""}</div>
<div class="sj-meta">${icons.clock}${escapeHtml(job.task_name || "未命名对话")}${input.filename ? ` · ${escapeHtml(input.filename)}` : ""}${openedText ? ` · 开启于 ${escapeHtml(openedText)}` : ""}</div>
<div class="sj-actions">
<button class="small" data-job-action="open" data-task-id="${escapeHtml(job.task_id)}">打开对话</button>
${job.status === "succeeded" ? `<button class="small primary" data-job-action="analyze" data-task-id="${escapeHtml(job.task_id)}">分析结果</button>` : ""}
${active && job.status !== "cancelling" ? '<button class="small danger" data-job-action="cancel">停止</button>' : ""}
<button class="small" data-job-action="open" data-task-id="${escapeHtml(job.task_id)}">${icons.open}打开对话</button>
${job.status === "succeeded" ? `<button class="small primary" data-job-action="analyze" data-task-id="${escapeHtml(job.task_id)}">${icons.analyze}分析结果</button>` : ""}
${active && job.status !== "cancelling" ? `<button class="small danger" data-job-action="cancel">${icons.cancel}停止</button>` : ""}
</div>
</article>`;
}
@ -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);
}