feat(software): move job center into file pane
This commit is contained in:
parent
40f8899cc1
commit
2560eb4ece
|
|
@ -8,7 +8,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- 新增专业软件 Job 中心:Agent 可将当前对话目录内的数据登记为稳定输入,提交、查询和停止 Windows Node 上的受控软件任务;用户可在右下角跨对话查看进度、收到完成通知,并回到原对话分析结果。
|
||||
- 新增专业软件 Job 中心:Agent 可将当前对话目录内的数据登记为稳定输入,提交、查询和停止 Windows Node 上的受控软件任务;文件栏底部固定展示任务状态,点击可从右侧打开按开启时间倒序、滚动加载的跨对话任务列表,并可收到完成通知或回到原对话分析结果。
|
||||
- 专业软件任务完成后,输出文件会立即显示在当前对话的文件面板;任务中心收起时也不会再遮挡发送按钮。
|
||||
|
||||
## 0.65.2 — 2026-08-13
|
||||
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue