diff --git a/index.js b/index.js index 542d0fe..fa3d751 100644 --- a/index.js +++ b/index.js @@ -372,6 +372,7 @@ import { import { buildAuthorityJobIdempotencyKey, createAuthorityJobAdapter, + mergeAuthorityRecentJobs, normalizeAuthorityJobConfig, } from "./maintenance/authority-job-adapter.js"; import { trackAuthorityJobUntilTerminal } from "./maintenance/authority-job-tracker.js"; @@ -1267,6 +1268,7 @@ const HISTORY_MUTATION_RETRY_DELAYS_MS = [80, 220, 500, 900]; const GRAPH_LOAD_RETRY_DELAYS_MS = [120, 450, 1200, 2500]; const AUTO_EXTRACTION_DEFER_RETRY_DELAYS_MS = [120, 320, 800, 1600, 2800]; const AUTO_EXTRACTION_HOST_SETTLE_MS = 120; +const AUTHORITY_RECENT_JOBS_LIMIT = 8; let runtimeStatus = createUiStatus("待命", "准备就绪", "idle"); let lastExtractionStatus = createUiStatus("待命", "尚未执行提取", "idle"); let lastVectorStatus = createUiStatus("待命", "尚未执行向量任务", "idle"); @@ -1797,6 +1799,38 @@ function getGraphPersistenceLiveState() { authorityTriviumPrimaryReady: Boolean( authorityRuntime.capability.triviumPrimaryReady, ), + authorityJobsReady: Boolean(authorityRuntime.capability.jobsReady), + authorityJobQueueState: String(graphPersistenceState.authorityJobQueueState || "idle"), + authorityLastJob: cloneRuntimeDebugValue( + graphPersistenceState.authorityLastJob, + null, + ), + authorityLastJobId: String(graphPersistenceState.authorityLastJobId || ""), + authorityLastJobKind: String(graphPersistenceState.authorityLastJobKind || ""), + authorityLastJobStatus: String(graphPersistenceState.authorityLastJobStatus || ""), + authorityLastJobProgress: Number( + graphPersistenceState.authorityLastJobProgress || 0, + ), + authorityLastJobError: String(graphPersistenceState.authorityLastJobError || ""), + authorityLastJobUpdatedAt: String( + graphPersistenceState.authorityLastJobUpdatedAt || "", + ), + authorityRecentJobs: cloneRuntimeDebugValue( + graphPersistenceState.authorityRecentJobs, + [], + ), + authorityRecentJobsUpdatedAt: String( + graphPersistenceState.authorityRecentJobsUpdatedAt || "", + ), + authorityRecentJobsError: String( + graphPersistenceState.authorityRecentJobsError || "", + ), + authorityRecentJobsNextCursor: String( + graphPersistenceState.authorityRecentJobsNextCursor || "", + ), + authorityRecentJobsHasMore: Boolean( + graphPersistenceState.authorityRecentJobsHasMore, + ), authorityBrowserCacheMode: String( authorityRuntime.browserState.mode || "minimal", ), @@ -1962,12 +1996,108 @@ function shouldUseAuthorityJobs(config = null) { ); } +function mergeAuthorityRecentJobsIntoState(incomingJobs = [], options = {}) { + const updatedAt = String(options.updatedAt || new Date().toISOString()); + const nextRecentJobs = mergeAuthorityRecentJobs( + options.replace === true ? [] : graphPersistenceState.authorityRecentJobs, + incomingJobs, + { + limit: Number.isFinite(Number(options.limit)) + ? Math.max(1, Math.floor(Number(options.limit))) + : AUTHORITY_RECENT_JOBS_LIMIT, + updatedAt, + }, + ); + updateGraphPersistenceState({ + authorityRecentJobs: cloneRuntimeDebugValue(nextRecentJobs, []), + authorityRecentJobsUpdatedAt: updatedAt, + authorityRecentJobsError: + options.error !== undefined + ? String(options.error || "") + : String(graphPersistenceState.authorityRecentJobsError || ""), + authorityRecentJobsNextCursor: + options.nextCursor !== undefined + ? String(options.nextCursor || "") + : String(graphPersistenceState.authorityRecentJobsNextCursor || ""), + authorityRecentJobsHasMore: + options.hasMore !== undefined + ? Boolean(options.hasMore) + : Boolean(graphPersistenceState.authorityRecentJobsHasMore), + }); + return nextRecentJobs; +} + +async function refreshAuthorityRecentJobs(options = {}) { + const settings = getSettings(); + const { capability } = getAuthorityRuntimeSnapshot(settings); + const updatedAt = new Date().toISOString(); + const currentChatId = normalizeChatIdCandidate( + options.chatId || getCurrentChatId() || graphPersistenceState.chatId, + ); + const limit = Number.isFinite(Number(options.limit)) + ? Math.max(1, Math.floor(Number(options.limit))) + : AUTHORITY_RECENT_JOBS_LIMIT; + if (!capability.jobsReady || settings.authorityJobsEnabled === false) { + updateGraphPersistenceState({ + authorityRecentJobsError: "Authority Jobs unavailable", + authorityRecentJobsUpdatedAt: updatedAt, + }); + refreshPanelLiveState(); + return { + success: false, + reason: "authority-jobs-unavailable", + error: "Authority Jobs unavailable", + }; + } + try { + const adapter = getAuthorityJobAdapter(); + const filter = + options.filter && typeof options.filter === "object" && !Array.isArray(options.filter) + ? { ...options.filter } + : {}; + if (currentChatId && !String(filter.chatId || "").trim()) { + filter.chatId = currentChatId; + } + const page = await adapter.listPage({ + limit, + cursor: String(options.cursor || ""), + filter, + signal: options.signal, + }); + const jobs = mergeAuthorityRecentJobsIntoState(page.jobs, { + replace: options.replace === true, + limit, + updatedAt, + error: "", + nextCursor: page.nextCursor, + hasMore: page.hasMore, + }); + refreshPanelLiveState(); + return { + success: true, + jobs, + nextCursor: page.nextCursor, + hasMore: page.hasMore, + }; + } catch (error) { + const message = + error?.message || String(error) || "Authority Jobs 列表刷新失败"; + updateGraphPersistenceState({ + authorityRecentJobsError: message, + authorityRecentJobsUpdatedAt: updatedAt, + }); + refreshPanelLiveState(); + return { success: false, error: message }; + } +} + function recordAuthorityJobSnapshot(job = null, options = {}) { const normalizedJob = job && typeof job === "object" && !Array.isArray(job) ? job : {}; const progress = Number(normalizedJob.progress || 0); const status = String(normalizedJob.status || options.status || ""); const error = String(normalizedJob.error || options.error || ""); + const updatedAt = new Date().toISOString(); const queueState = options.queueState || (error @@ -1979,6 +2109,42 @@ function recordAuthorityJobSnapshot(job = null, options = {}) { : normalizedJob.id ? "running" : "idle"); + const recentJobsPatch = normalizedJob.id + ? { + authorityRecentJobs: cloneRuntimeDebugValue( + mergeAuthorityRecentJobs( + graphPersistenceState.authorityRecentJobs, + [ + { + ...normalizedJob, + kind: normalizedJob.kind || options.kind || "", + status, + progress: Number.isFinite(progress) + ? Math.max(0, Math.min(1, progress)) + : 0, + error, + updatedAt, + }, + ], + { + limit: AUTHORITY_RECENT_JOBS_LIMIT, + updatedAt, + }, + ), + [], + ), + authorityRecentJobsUpdatedAt: updatedAt, + authorityRecentJobsError: + options.recentJobsError !== undefined + ? String(options.recentJobsError || "") + : String(graphPersistenceState.authorityRecentJobsError || ""), + } + : options.recentJobsError !== undefined + ? { + authorityRecentJobsError: String(options.recentJobsError || ""), + authorityRecentJobsUpdatedAt: updatedAt, + } + : {}; updateGraphPersistenceState({ authorityJobQueueState: queueState, authorityLastJob: cloneRuntimeDebugValue(normalizedJob, null), @@ -1989,7 +2155,8 @@ function recordAuthorityJobSnapshot(job = null, options = {}) { ? Math.max(0, Math.min(1, progress)) : 0, authorityLastJobError: error, - authorityLastJobUpdatedAt: new Date().toISOString(), + authorityLastJobUpdatedAt: updatedAt, + ...recentJobsPatch, }); } @@ -2271,6 +2438,7 @@ async function submitAuthorityVectorRebuildJob({ "running", { syncRuntime: true }, ); + void refreshAuthorityRecentJobs({ reason: "authority-job-submitted" }); void startTrackingAuthorityJob(job, { kind, chatId }); return { submitted: true, @@ -2416,6 +2584,11 @@ async function startTrackingAuthorityJob(job = null, options = {}) { normalizedNextJob.success ? "success" : "error", { syncRuntime: true }, ); + void refreshAuthorityRecentJobs({ + reason: normalizedNextJob.success + ? "authority-job-completed" + : "authority-job-failed", + }); const activeChatId = normalizeChatIdCandidate(getCurrentChatId()) || normalizeChatIdCandidate(graphPersistenceState.chatId); @@ -2501,6 +2674,7 @@ async function requeueAuthorityJob(jobId, options = {}) { recordAuthorityJobSnapshot(job, { queueState: "running" }); syncAuthorityVectorJobState(job); saveGraphToChat({ reason: "authority-vector-rebuild-job-requeued" }); + void refreshAuthorityRecentJobs({ reason: "authority-job-requeued" }); void startTrackingAuthorityJob(job, { kind: job?.kind || graphPersistenceState.authorityLastJobKind, chatId: getCurrentChatId(), @@ -20748,6 +20922,13 @@ async function onRebuildVectorIndex(range = null) { ); } +async function onRefreshAuthorityJobs() { + return await refreshAuthorityRecentJobs({ + replace: true, + reason: "panel-authority-jobs-refresh", + }); +} + async function onReembedDirect() { return await onReembedDirectController({ getEmbeddingConfig, @@ -21326,6 +21507,7 @@ async function onCompactLukerSidecar() { rebuildVectorIndex: () => onRebuildVectorIndex(), rebuildVectorRange: (range) => onRebuildVectorIndex(range), requeueAuthorityJob: async (jobId) => await requeueAuthorityJob(jobId), + refreshAuthorityJobs: onRefreshAuthorityJobs, reembedDirect: onReembedDirect, reroll: onReroll, clearGraph: onClearGraph, diff --git a/maintenance/authority-job-adapter.js b/maintenance/authority-job-adapter.js index ef73bda..6c8e593 100644 --- a/maintenance/authority-job-adapter.js +++ b/maintenance/authority-job-adapter.js @@ -114,6 +114,56 @@ export function normalizeAuthorityJobList(payload = null) { }; } +export function normalizeAuthorityRecentJobRecord(input = null, options = {}) { + const normalized = normalizeAuthorityJobRecord(input); + const queueState = String(options.queueState || "").trim() || + (normalized.error + ? "error" + : normalized.terminal + ? normalized.success + ? "success" + : "failed" + : normalized.id + ? "running" + : "idle"); + return { + ...normalized, + updatedAt: String(normalized.updatedAt || options.updatedAt || ""), + queueState, + }; +} + +export function mergeAuthorityRecentJobs(existingJobs = [], incomingJobs = [], options = {}) { + const limit = normalizeInteger(options.limit, 8, 1, 100); + const updatedAt = String(options.updatedAt || ""); + const normalizedExisting = Array.isArray(existingJobs) ? existingJobs : []; + const normalizedIncoming = Array.isArray(incomingJobs) ? incomingJobs : [incomingJobs]; + const seen = new Set(); + const merged = []; + + for (const item of normalizedIncoming) { + const job = normalizeAuthorityRecentJobRecord(item, { updatedAt }); + if (!job.id || seen.has(job.id)) continue; + seen.add(job.id); + merged.push(job); + if (merged.length >= limit) { + return merged; + } + } + + for (const item of normalizedExisting) { + const job = normalizeAuthorityRecentJobRecord(item); + if (!job.id || seen.has(job.id)) continue; + seen.add(job.id); + merged.push(job); + if (merged.length >= limit) { + break; + } + } + + return merged; +} + export function buildAuthorityJobIdempotencyKey({ kind = "job", chatId = "", diff --git a/tests/authority-jobs.mjs b/tests/authority-jobs.mjs index 57d6f2e..6581cd8 100644 --- a/tests/authority-jobs.mjs +++ b/tests/authority-jobs.mjs @@ -4,8 +4,10 @@ import { AUTHORITY_JOB_STATUS_SUCCESS, buildAuthorityJobIdempotencyKey, createAuthorityJobAdapter, + mergeAuthorityRecentJobs, normalizeAuthorityJobList, normalizeAuthorityJobRecord, + normalizeAuthorityRecentJobRecord, } from "../maintenance/authority-job-adapter.js"; import { trackAuthorityJobUntilTerminal } from "../maintenance/authority-job-tracker.js"; import { onRebuildVectorIndexController } from "../ui/ui-actions-controller.js"; @@ -78,6 +80,33 @@ assert.equal(list.jobs.length, 1); assert.equal(list.jobs[0].progress, 0.33); assert.equal(list.nextCursor, "cursor-2"); +const recentJob = normalizeAuthorityRecentJobRecord( + { id: "job-recent", status: "completed", progress: 1 }, + { updatedAt: "2026-04-28T08:00:00.000Z" }, +); +assert.equal(recentJob.queueState, "success"); +assert.equal(recentJob.updatedAt, "2026-04-28T08:00:00.000Z"); + +const mergedRecentJobs = mergeAuthorityRecentJobs( + [ + { id: "job-old", status: "failed", updatedAt: "2026-04-28T07:50:00.000Z" }, + { id: "job-dup", status: "queued", updatedAt: "2026-04-28T07:45:00.000Z" }, + ], + [ + { id: "job-new", status: "running", progress: 0.4 }, + { id: "job-dup", status: "completed", progress: 1 }, + ], + { limit: 3, updatedAt: "2026-04-28T08:10:00.000Z" }, +); +assert.deepEqual(mergedRecentJobs.map((job) => job.id), [ + "job-new", + "job-dup", + "job-old", +]); +assert.equal(mergedRecentJobs[0].queueState, "running"); +assert.equal(mergedRecentJobs[1].queueState, "success"); +assert.equal(mergedRecentJobs[1].updatedAt, "2026-04-28T08:10:00.000Z"); + const idempotencyKey = buildAuthorityJobIdempotencyKey({ kind: "authority.vector.rebuild", chatId: "chat-a", diff --git a/ui/panel.js b/ui/panel.js index 17d5393..47f6438 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -1582,6 +1582,61 @@ function _buildAuthorityJobUiState(loadInfo = _getGraphPersistenceSnapshot()) { }; } +function _buildAuthorityRecentJobsUiState(loadInfo = _getGraphPersistenceSnapshot()) { + const snapshot = + loadInfo && typeof loadInfo === "object" && !Array.isArray(loadInfo) + ? loadInfo + : {}; + const jobs = Array.isArray(snapshot.authorityRecentJobs) + ? snapshot.authorityRecentJobs + : []; + return { + jobs: jobs.map((job) => { + const normalizedJob = + job && typeof job === "object" && !Array.isArray(job) ? job : {}; + const jobId = String(normalizedJob.id || "").trim(); + const kind = String(normalizedJob.kind || "").trim(); + const status = String(normalizedJob.status || "").trim(); + const error = String(normalizedJob.error || "").trim(); + const progressRaw = Number(normalizedJob.progress); + const progress = Number.isFinite(progressRaw) + ? Math.max(0, Math.min(1, progressRaw)) + : 0; + const queueState = String(normalizedJob.queueState || "").trim() || + (error + ? "error" + : status === "completed" || status === "succeeded" || status === "success" + ? "success" + : status === "failed" || status === "error" || status === "timeout" + ? "failed" + : jobId + ? "running" + : "idle"); + const progressText = progress > 0 ? `${Math.round(progress * 100)}%` : ""; + return { + jobId, + kind, + status, + error, + progress, + queueState, + updatedAt: String(normalizedJob.updatedAt || ""), + updatedAtLabel: _formatTaskProfileTime(normalizedJob.updatedAt), + detail: [kind, status || queueState, progressText, error] + .filter(Boolean) + .join(" · "), + }; + }), + updatedAt: String(snapshot.authorityRecentJobsUpdatedAt || ""), + updatedAtLabel: snapshot.authorityRecentJobsUpdatedAt + ? _formatTaskProfileTime(snapshot.authorityRecentJobsUpdatedAt) + : "未刷新", + error: String(snapshot.authorityRecentJobsError || "").trim(), + hasMore: Boolean(snapshot.authorityRecentJobsHasMore), + nextCursor: String(snapshot.authorityRecentJobsNextCursor || ""), + }; +} + function _readPersistenceDiagnosticObject(snapshot = null) { if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { return null; @@ -2027,6 +2082,7 @@ function _refreshTaskPipelineOverview() { const historyState = graph.runtimeState?.historyState || graph.historyState || {}; const loadInfo = _getGraphPersistenceSnapshot(); const authorityJobUi = _buildAuthorityJobUiState(loadInfo); + const authorityRecentJobsUi = _buildAuthorityRecentJobsUiState(loadInfo); const extraction = _resolvePipelineStatus(_getLastExtractionStatus?.()); const vector = _resolvePipelineStatus(_getLastVectorStatus?.()); @@ -2053,23 +2109,6 @@ function _refreshTaskPipelineOverview() { if (pipelinePersistDeltaMeta) { persistenceMetaParts.push(pipelinePersistDeltaMeta); } - const authorityJob = loadInfo.authorityLastJob || {}; - const authorityJobParts = [ - authorityJob.id || loadInfo.authorityLastJobId - ? `job ${authorityJob.id || loadInfo.authorityLastJobId}` - : "", - authorityJob.kind || loadInfo.authorityLastJobKind || "", - authorityJob.status || loadInfo.authorityLastJobStatus || "", - ].filter(Boolean); - const authorityJobProgress = Number( - authorityJob.progress ?? loadInfo.authorityLastJobProgress, - ); - if (Number.isFinite(authorityJobProgress) && authorityJobProgress > 0) { - authorityJobParts.push(`${Math.round(authorityJobProgress * 100)}%`); - } - if (loadInfo.authorityLastJobError) { - authorityJobParts.push(`error ${loadInfo.authorityLastJobError}`); - } const persistence = _resolvePipelineStatus({ text: loadInfo.loadState || "unknown", meta: persistenceMetaParts.join(" · "), @@ -2154,11 +2193,53 @@ function _refreshTaskPipelineOverview() { : authorityJobUi.queueState === "running" ? Math.max(8, Math.round(authorityJobUi.progress * 100)) : Math.round(authorityJobUi.progress * 100); - const authorityJobActions = authorityJobUi.canRequeue && typeof _actionHandlers.requeueAuthorityJob === "function" + const authorityJobActions = [ + typeof _actionHandlers.refreshAuthorityJobs === "function" + ? `` + : "", + authorityJobUi.canRequeue && typeof _actionHandlers.requeueAuthorityJob === "function" + ? `` + : "", + ].filter(Boolean).join(""); + const authorityRecentJobsHtml = authorityRecentJobsUi.jobs.length ? ` -
- +
+ ${authorityRecentJobsUi.jobs.map((job) => { + const dotColor = job.queueState === "success" + ? "#2ecc71" + : job.queueState === "failed" || job.queueState === "error" + ? "#e74c3c" + : job.queueState === "running" + ? "#00d4ff" + : "#7f8c8d"; + return ` +
+
+
${_escHtml(job.jobId ? `job ${job.jobId}` : job.kind || "job")}
+
${_escHtml(job.updatedAtLabel)}
+
+
${_escHtml(job.detail || job.queueState || "—")}
+
`; + }).join("")}
` + : `
${_escHtml( + authorityRecentJobsUi.error + ? `最近任务刷新失败:${authorityRecentJobsUi.error}` + : authorityRecentJobsUi.updatedAt + ? `最近任务为空 · ${authorityRecentJobsUi.updatedAtLabel}` + : "尚未拉取 recent jobs" + )}
`; + const authorityRecentJobsFooter = authorityRecentJobsUi.jobs.length || authorityRecentJobsUi.hasMore || authorityRecentJobsUi.error + ? `
${_escHtml( + authorityRecentJobsUi.error + ? authorityRecentJobsUi.error + : authorityRecentJobsUi.hasMore + ? `仅展示最近 ${authorityRecentJobsUi.jobs.length} 条,服务端仍有更多记录` + : `最近刷新:${authorityRecentJobsUi.updatedAtLabel}` + )}
` + : ""; + const authorityJobActionsRow = authorityJobActions + ? `
${authorityJobActions}
` : ""; el.innerHTML = ` @@ -2195,33 +2276,55 @@ function _refreshTaskPipelineOverview() {
- ${authorityJobActions} + ${authorityJobActionsRow} + ${authorityRecentJobsHtml} + ${authorityRecentJobsFooter}
`; el - .querySelector('[data-authority-job-action="requeue"]') - ?.addEventListener("click", async (event) => { + .querySelectorAll('[data-authority-job-action]') + .forEach((buttonEl) => buttonEl.addEventListener("click", async (event) => { const button = event.currentTarget; + const action = String(button?.dataset?.authorityJobAction || "").trim(); const jobId = String(button?.dataset?.jobId || "").trim(); - if (!jobId || typeof _actionHandlers.requeueAuthorityJob !== "function") return; if (button.disabled) return; button.disabled = true; try { - toastr.info("Authority Job 重试中…", "ST-BME", { timeOut: 2000 }); - const result = await _actionHandlers.requeueAuthorityJob(jobId); - if (result?.success) { - toastr.success(`Authority Job 已重试:${result?.job?.id || jobId}`, "ST-BME"); + if (action === "refresh") { + if (typeof _actionHandlers.refreshAuthorityJobs !== "function") return; + toastr.info("Authority Jobs 刷新中…", "ST-BME", { timeOut: 2000 }); + const result = await _actionHandlers.refreshAuthorityJobs(); + if (result?.success) { + toastr.success(`Authority Jobs 已刷新:${(result.jobs || []).length} 条`, "ST-BME"); + } else { + toastr.warning(`Authority Jobs 刷新失败:${result?.error || result?.reason || "unknown"}`, "ST-BME"); + } + } else if (action === "requeue") { + if (!jobId || typeof _actionHandlers.requeueAuthorityJob !== "function") return; + toastr.info("Authority Job 重试中…", "ST-BME", { timeOut: 2000 }); + const result = await _actionHandlers.requeueAuthorityJob(jobId); + if (result?.success) { + toastr.success(`Authority Job 已重试:${result?.job?.id || jobId}`, "ST-BME"); + } else { + toastr.warning(`Authority Job 重试失败:${result?.error || "unknown"}`, "ST-BME"); + } } else { - toastr.warning(`Authority Job 重试失败:${result?.error || "unknown"}`, "ST-BME"); + return; } } catch (error) { - toastr.error(`Authority Job 重试失败: ${error?.message || error}`, "ST-BME"); + toastr.error( + action === "refresh" + ? `Authority Jobs 刷新失败: ${error?.message || error}` + : `Authority Job 重试失败: ${error?.message || error}`, + "ST-BME", + ); } finally { + button.disabled = false; _refreshDashboard(); _refreshTaskMonitor(); } - }); + })); } // ---------- Task Timeline ---------- diff --git a/ui/ui-status.js b/ui/ui-status.js index 2b984ca..8304770 100644 --- a/ui/ui-status.js +++ b/ui/ui-status.js @@ -156,6 +156,11 @@ export function createGraphPersistenceState() { authorityLastJobProgress: 0, authorityLastJobError: "", authorityLastJobUpdatedAt: "", + authorityRecentJobs: [], + authorityRecentJobsUpdatedAt: "", + authorityRecentJobsError: "", + authorityRecentJobsNextCursor: "", + authorityRecentJobsHasMore: false, authorityBlobState: "idle", authorityLastBlobEvent: null, authorityLastBlobAction: "",