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 ## Unreleased
- 新增专业软件 Job 中心Agent 可将当前对话目录内的数据登记为稳定输入,提交、查询和停止 Windows Node 上的受控软件任务;用户可在右下角跨对话查看进度、收到完成通知,并回到原对话分析结果。 - 新增专业软件 Job 中心Agent 可将当前对话目录内的数据登记为稳定输入,提交、查询和停止 Windows Node 上的受控软件任务;文件栏底部固定展示任务状态,点击可从右侧打开按开启时间倒序、滚动加载的跨对话任务列表,并可收到完成通知或回到原对话分析结果。
- 专业软件任务完成后,输出文件会立即显示在当前对话的文件面板;任务中心收起时也不会再遮挡发送按钮。
## 0.65.2 — 2026-08-13 ## 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 / 专业软件输入 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。 - **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 hashlib import sha256
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import desc, select from sqlalchemy import and_, desc, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from core.software_nodes import SUPPORTED_CAPABILITIES from core.software_nodes import SUPPORTED_CAPABILITIES
@ -163,9 +163,11 @@ def list_jobs(
task_id: UUID | None = None, task_id: UUID | None = None,
active_only: bool = False, active_only: bool = False,
limit: int = 50, limit: int = 50,
before: tuple[datetime, UUID] | None = None,
) -> list[dict]: ) -> list[dict]:
"""列出用户的软件任务;用于全局 Job 中心和 Agent 查询。""" """列出用户的软件任务;用于全局 Job 中心和 Agent 查询。"""
limit = max(1, min(int(limit), 100)) # Web 分页会多取一条判断是否还有下一页;公开接口仍把页大小限制为 100。
limit = max(1, min(int(limit), 101))
with session_scope() as session: with session_scope() as session:
statement = ( statement = (
select(SoftwareJob, Task.name, SoftwareNode.name) 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( rows = session.execute(
statement.order_by( statement.order_by(
desc(SoftwareJob.created_at), desc(SoftwareJob.job_id) 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: def test_dev_console_has_software_job_center(self) -> None:
html = DEV_HTML.read_text(encoding="utf-8") html = DEV_HTML.read_text(encoding="utf-8")
source = (JS_DIR / "software_jobs.js").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-center"', html)
self.assertIn('id="software-job-toggle"', 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("/cancel`", source)
self.assertIn("分析结果", 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: def test_admin_can_create_windows_node_enrollment_code(self) -> None:
html = ADMIN_HTML.read_text(encoding="utf-8") html = ADMIN_HTML.read_text(encoding="utf-8")

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
from datetime import datetime
from hashlib import sha256 from hashlib import sha256
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
@ -501,12 +502,33 @@ def register_software_node_routes(app, *, require_user, require_admin) -> None:
task_id: UUID | None = None, task_id: UUID | None = None,
active_only: bool = False, active_only: bool = False,
limit: int = 50, limit: int = 50,
before_created_at: datetime | None = None,
before_job_id: UUID | None = None,
user_id: UUID = Depends(require_user), # noqa: B008 user_id: UUID = Depends(require_user), # noqa: B008
): ):
return { if (before_created_at is None) != (before_job_id is None):
"results": list_jobs( raise HTTPException(
user_id, task_id=task_id, active_only=active_only, limit=limit 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"]) @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; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px;
} }
.proc-toast.fail { border-color: rgba(192,57,43,0.5); } .proc-toast.fail { border-color: rgba(192,57,43,0.5); }
#software-job-center { #software-job-center { display: block; flex-shrink: 0; font-size: 12px; }
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-toggle { #software-job-toggle {
margin-left: auto; display: flex; align-items: center; gap: 8px; width: 100%; display: grid; grid-template-columns: 30px minmax(0, 1fr) auto;
border-radius: 999px; padding: 8px 13px; background: #fff; align-items: center; gap: 8px; padding: 9px 12px; text-align: left;
border: 1px solid var(--border); box-shadow: 0 4px 16px rgba(0,0,0,.16); cursor: pointer; 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 { #software-job-panel {
max-height: min(520px, calc(100vh - 110px)); overflow: auto; background: #fff; position: fixed; z-index: 130; top: 0; right: 0; bottom: 0;
border: 1px solid var(--border); border-bottom: 0; border-radius: 10px 10px 0 0; width: min(420px, 92vw); display: flex; flex-direction: column; background: #fff;
box-shadow: 0 -4px 20px rgba(0,0,0,.16); padding: 8px; 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); } #software-job-panel.open { transform: translateX(0); visibility: visible; }
.sj-card:last-child { border-bottom: 0; } .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-title { display: flex; justify-content: space-between; gap: 10px; } .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-title span { color: var(--muted); white-space: nowrap; } .sj-panel-head strong { font-size: 15px; }
.sj-card.failed .sj-title span { color: var(--danger); } .sj-panel-head .spacer { flex: 1; }
.sj-card.disconnected .sj-title span, .sj-card.cancelling .sj-title span { color: #a66514; } #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, .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 { 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-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); } .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,折叠态也可见) */ /* media tool 摘要 banner(model / size / cost / elapsed,折叠态也可见) */
.tool-banner { .tool-banner {
display: inline-flex; flex-wrap: wrap; gap: 6px; display: inline-flex; flex-wrap: wrap; gap: 6px;
@ -2094,6 +2127,16 @@
<div id="file-upload-status" class="upload-status"></div> <div id="file-upload-status" class="upload-status"></div>
<div id="file-crumbs" class="crumbs muted">加载中…</div> <div id="file-crumbs" class="crumbs muted">加载中…</div>
<div id="file-list"></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=""> <div id="storage-foot" class="storage-foot" title="">
<span id="app-version" title="版本号"></span> <span id="app-version" title="版本号"></span>
<span class="lbl">存储</span> <span class="lbl">存储</span>
@ -2202,12 +2245,15 @@
</div> </div>
</div> </div>
<div id="software-job-center" aria-live="polite"> <div id="software-job-backdrop"></div>
<div id="software-job-panel" hidden></div> <aside id="software-job-panel" aria-hidden="true" aria-label="专业软件任务列表">
<button id="software-job-toggle" type="button" title="展开专业软件任务"> <div class="sj-panel-head">
<span>专业软件任务</span><strong id="software-job-count">0 条</strong> <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>
</button> <strong>专业软件任务</strong><span class="spacer"></span>
<button id="software-job-close" class="small" type="button" title="关闭">×</button>
</div> </div>
<div id="software-job-list"></div>
</aside>
<script type="module" src="js/main.js"></script> <script type="module" src="js/main.js"></script>
</body> </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() { function chatAction() {
if (isCurrentTaskStreaming()) { cancelCurrentTask(); return; } if (isCurrentTaskStreaming()) { cancelCurrentTask(); return; }
if (hasRunningProc(state.taskId)) { killTaskProcs(state.taskId); return; } if (hasRunningProc(state.taskId)) { killTaskProcs(state.taskId); return; }

View File

@ -1,19 +1,22 @@
// 专业软件任务中心:用户级轮询、跨对话状态、取消与终态通知。 // 专业软件任务中心:右栏固定摘要、抽屉列表、游标分页和终态通知。
import { api } from "./api.js"; import { api } from "./api.js";
import { state } from "./state.js"; import { state } from "./state.js";
import { $ } from "./dom.js"; import { $ } from "./dom.js";
import { escapeHtml } from "./format.js"; import { escapeHtml } from "./format.js";
import { selectTask } from "./chat.js"; import { refreshCurrentTaskFiles, selectTask } from "./chat.js";
import { dialogConfirm, message } from "./dialog.js"; import { dialogConfirm, message } from "./dialog.js";
const ACTIVE = new Set(["queued", "offered", "dispatched", "running", "disconnected", "cancelling"]); const ACTIVE = new Set(["queued", "offered", "dispatched", "running", "disconnected", "cancelling"]);
const TERMINAL = new Set(["succeeded", "failed", "cancelled"]); const TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
const PAGE_SIZE = 20;
const POLL_ACTIVE_MS = 4000; const POLL_ACTIVE_MS = 4000;
const POLL_IDLE_MS = 30000; const POLL_IDLE_MS = 30000;
let timer = null; let timer = null;
let known = new Map(); let known = new Map();
let jobs = []; let jobs = [];
let nextCursor = null;
let expanded = false; let expanded = false;
let loadingMore = false;
let initialized = false; let initialized = false;
const statusLabel = { const statusLabel = {
@ -27,33 +30,86 @@ const stageLabel = {
cancel_requested: "停止请求已发送", terminal: "任务已结束", 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() { export function initSoftwareJobs() {
if (initialized || !$("software-job-center")) return; if (initialized || !$("software-job-center")) return;
initialized = true; initialized = true;
$("software-job-toggle").onclick = () => { expanded = !expanded; render(); }; $("software-job-toggle").onclick = openDrawer;
document.addEventListener("click", (event) => { $("software-job-close").onclick = closeDrawer;
const center = $("software-job-center"); $("software-job-backdrop").onclick = closeDrawer;
if (expanded && center && !center.contains(event.target)) { expanded = false; render(); } $("software-job-list").addEventListener("scroll", onListScroll);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && expanded) closeDrawer();
}); });
refreshSoftwareJobs(); 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() { export async function refreshSoftwareJobs() {
if (!state.token) return; if (!state.token) return;
try { try {
const data = await api("GET", "/v1/software-jobs?limit=50"); const data = await api("GET", pagePath());
const next = data.results || []; const next = data.results || [];
next.forEach((job) => { next.forEach((job) => {
const previous = known.get(job.job_id); const previous = known.get(job.job_id);
if (previous && ACTIVE.has(previous) && TERMINAL.has(job.status)) notifyTerminal(job); if (previous && ACTIVE.has(previous) && TERMINAL.has(job.status)) notifyTerminal(job);
known.set(job.job_id, job.status); 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(); render();
} catch (_) { /* 后台刷新失败静默,下轮恢复 */ } } catch (_) { /* 后台刷新失败静默,下轮恢复 */ }
schedule(); 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() { function schedule() {
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
const delay = jobs.some((job) => ACTIVE.has(job.status)) ? POLL_ACTIVE_MS : POLL_IDLE_MS; 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(); if (!document.hidden && state.token) refreshSoftwareJobs(); else schedule();
}); });
function openDrawer() {
expanded = true;
render();
}
function closeDrawer() {
expanded = false;
render();
}
function render() { function render() {
const center = $("software-job-center");
const panel = $("software-job-panel");
const active = jobs.filter((job) => ACTIVE.has(job.status)); const active = jobs.filter((job) => ACTIVE.has(job.status));
const recent = jobs.slice(0, 10); const latest = jobs[0];
center.classList.toggle("show", active.length > 0 || recent.length > 0); const entryDot = $("software-job-entry-dot");
center.classList.toggle("expanded", expanded); entryDot.className = "sj-entry-dot";
$("software-job-count").textContent = active.length ? `${active.length} 运行中` : `${jobs.length}`; if (active.length) entryDot.classList.add("active");
panel.hidden = !expanded; else if (latest?.status === "succeeded") entryDot.classList.add("succeeded");
if (!expanded) return; else if (latest?.status === "failed") entryDot.classList.add("failed");
panel.innerHTML = recent.length $("software-job-count").textContent = active.length
? recent.map(jobCard).join("") ? `${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>'; : '<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); 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) { function jobCard(job) {
const summary = job.request_summary || {}; const summary = job.request_summary || {};
const input = job.input || {}; const input = job.input || {};
@ -89,16 +169,18 @@ function jobCard(job) {
const progress = Math.max(0, Math.min(100, Number(job.progress || 0))); const progress = Math.max(0, Math.min(100, Number(job.progress || 0)));
const detail = stageLabel[job.stage] || statusLabel[job.status] || "正在处理"; const detail = stageLabel[job.stage] || statusLabel[job.status] || "正在处理";
const error = job.error && (job.error.detail || job.error.code); 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)}"> 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> <div class="sj-title"><div class="sj-title-main"><strong>${escapeHtml(summary.display_name || job.capability)}</strong></div>
<span>${escapeHtml(statusLabel[job.status] || job.status)}</span></div> <span class="sj-status">${statusIcon(job.status)}${escapeHtml(statusLabel[job.status] || job.status)}</span></div>
<div class="sj-sub">${escapeHtml(detail)}${error ? ` · ${escapeHtml(error)}` : ""}</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>` : ""} ${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"> <div class="sj-actions">
<button class="small" data-job-action="open" data-task-id="${escapeHtml(job.task_id)}">打开对话</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)}">分析结果</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">停止</button>' : ""} ${active && job.status !== "cancelling" ? `<button class="small danger" data-job-action="cancel">${icons.cancel}停止</button>` : ""}
</div> </div>
</article>`; </article>`;
} }
@ -122,8 +204,7 @@ async function handleAction(event, button) {
} }
const taskId = button.dataset.taskId; const taskId = button.dataset.taskId;
if (taskId) await selectTask(taskId); if (taskId) await selectTask(taskId);
expanded = false; closeDrawer();
render();
if (action === "analyze") { if (action === "analyze") {
setTimeout(() => { setTimeout(() => {
const input = $("chat-input"); const input = $("chat-input");
@ -140,5 +221,5 @@ function notifyTerminal(job) {
const label = ok ? "已完成" : (job.status === "cancelled" ? "已取消" : "失败"); const label = ok ? "已完成" : (job.status === "cancelled" ? "已取消" : "失败");
const summary = job.request_summary || {}; const summary = job.request_summary || {};
message(`${summary.display_name || "专业软件任务"}${label}`, ok ? "success" : "error", 6000); message(`${summary.display_name || "专业软件任务"}${label}`, ok ? "success" : "error", 6000);
expanded = true; refreshCurrentTaskFiles(job.task_id);
} }