Files
X-Financial/web/src/utils/agentRunMonitor.js

272 lines
7.1 KiB
JavaScript
Raw Normal View History

const KNOWLEDGE_JOB_TYPES = new Set(['knowledge_index_sync', 'llm_wiki_sync'])
const STATUS_LABELS = {
running: '运行中',
succeeded: '已完成',
failed: '失败',
blocked: '待确认'
}
const STATUS_TONES = {
running: 'warning',
succeeded: 'success',
failed: 'danger',
blocked: 'muted'
}
const PHASE_LABELS = {
queued: '排队中',
indexing: '归纳中',
completed: '已完成',
failed: '失败',
stale_failed: '超时失败'
}
export const AGENT_RUN_POLL_INTERVAL_MS = 5000
export const AGENT_RUN_HEARTBEAT_DELAY_MS = 60 * 1000
export const AGENT_RUN_HEARTBEAT_STUCK_MS = 5 * 60 * 1000
function toDate(value) {
if (!value) {
return null
}
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function resolveNowMs(now = Date.now()) {
if (now instanceof Date) {
return now.getTime()
}
const numeric = Number(now)
return Number.isFinite(numeric) ? numeric : Date.now()
}
export function isKnowledgeIndexRun(run) {
const jobType = String(run?.route_json?.job_type || '').trim()
return KNOWLEDGE_JOB_TYPES.has(jobType)
}
export function getAgentRunPhase(run) {
return String(run?.route_json?.phase || '').trim()
}
export function formatDurationShort(valueMs) {
const numeric = Number(valueMs)
if (!Number.isFinite(numeric) || numeric < 0) {
return '—'
}
const totalSeconds = Math.max(0, Math.round(numeric / 1000))
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours}小时${minutes}`
}
if (minutes > 0) {
return seconds > 0 ? `${minutes}${seconds}` : `${minutes}`
}
return `${seconds}`
}
export function formatAgentRunElapsed(run, now = Date.now()) {
const startedAt = toDate(run?.started_at)
if (startedAt === null) {
return '—'
}
const finishedAt = toDate(run?.finished_at)
const endMs = finishedAt ? finishedAt.getTime() : resolveNowMs(now)
return formatDurationShort(endMs - startedAt.getTime())
}
export function resolveAgentRunPhaseLabel(run) {
const phase = getAgentRunPhase(run)
return PHASE_LABELS[phase] || STATUS_LABELS[String(run?.status || '').trim()] || '未知'
}
export function resolveAgentRunHeartbeat(run, now = Date.now()) {
const heartbeatAt = toDate(run?.route_json?.heartbeat_at)
const startedAt = toDate(run?.started_at)
const nowMs = resolveNowMs(now)
const phase = getAgentRunPhase(run)
const isRunning = String(run?.status || '').trim() === 'running'
const heartbeatAgeMs = heartbeatAt ? Math.max(0, nowMs - heartbeatAt.getTime()) : null
const startedAgeMs = startedAt ? Math.max(0, nowMs - startedAt.getTime()) : null
if (heartbeatAt) {
if (heartbeatAgeMs >= AGENT_RUN_HEARTBEAT_STUCK_MS) {
return {
at: heartbeatAt,
ageMs: heartbeatAgeMs,
text: `${formatDurationShort(heartbeatAgeMs)}`,
label: '疑似中断',
tone: 'danger'
}
}
if (heartbeatAgeMs >= AGENT_RUN_HEARTBEAT_DELAY_MS) {
return {
at: heartbeatAt,
ageMs: heartbeatAgeMs,
text: `${formatDurationShort(heartbeatAgeMs)}`,
label: '心跳延迟',
tone: 'warning'
}
}
return {
at: heartbeatAt,
ageMs: heartbeatAgeMs,
text: `${formatDurationShort(heartbeatAgeMs)}`,
label: '心跳正常',
tone: 'success'
}
}
if (!isKnowledgeIndexRun(run) || !isRunning) {
return {
at: null,
ageMs: null,
text: '—',
label: '无心跳',
tone: 'muted'
}
}
if (phase === 'queued') {
return {
at: null,
ageMs: startedAgeMs,
text: '尚未开始',
label: '等待执行',
tone: 'muted'
}
}
if ((startedAgeMs || 0) >= AGENT_RUN_HEARTBEAT_DELAY_MS) {
return {
at: null,
ageMs: startedAgeMs,
text: '尚未收到',
label: '无心跳',
tone: 'warning'
}
}
return {
at: null,
ageMs: startedAgeMs,
text: '等待首个心跳',
label: '等待心跳',
tone: 'muted'
}
}
export function resolveAgentRunStatus(run, now = Date.now()) {
const status = String(run?.status || '').trim()
const phase = getAgentRunPhase(run)
const heartbeat = resolveAgentRunHeartbeat(run, now)
let label = STATUS_LABELS[status] || status || '未知'
let tone = STATUS_TONES[status] || 'muted'
let note = ''
let isSuspicious = false
if (status === 'failed' && phase === 'stale_failed') {
return {
label: '已超时',
tone: 'danger',
note: '系统已按长时间无心跳自动判定失败',
phase,
phaseLabel: resolveAgentRunPhaseLabel(run),
heartbeat,
isSuspicious: true
}
}
if (status === 'running' && isKnowledgeIndexRun(run)) {
if (phase === 'queued') {
label = '排队中'
tone = 'muted'
note = '等待后台线程接管'
} else if (phase === 'indexing') {
if (heartbeat.at === null && heartbeat.label === '无心跳') {
label = '无心跳'
tone = 'warning'
note = '已进入归纳流程,但还没有收到心跳'
isSuspicious = true
} else if (heartbeat.tone === 'danger') {
label = '疑似卡住'
tone = 'danger'
note = `最后心跳在 ${heartbeat.text}`
isSuspicious = true
} else if (heartbeat.tone === 'warning') {
label = '心跳延迟'
tone = 'warning'
note = `最后心跳在 ${heartbeat.text}`
isSuspicious = true
} else if (heartbeat.at === null) {
label = '归纳启动中'
tone = 'warning'
note = '任务已启动,等待首个心跳'
} else {
label = '归纳中'
tone = 'warning'
note = `最后心跳在 ${heartbeat.text}`
}
}
}
if (!note && status === 'failed' && run?.error_message) {
note = String(run.error_message).trim()
}
return {
label,
tone,
note,
phase,
phaseLabel: resolveAgentRunPhaseLabel(run),
heartbeat,
isSuspicious
}
}
export function formatAgentRunProgress(run) {
const progress = run?.route_json?.progress || {}
const percent = Number(progress.percent || 0)
const completed = Number(progress.completed_documents || 0)
const total = Number(progress.total_documents || 0)
const failed = Number(progress.failed_documents || 0)
const stage = String(progress.current_stage || '').trim()
const stageLabelMap = {
document_started: '文档启动',
text_extracted: '文本已提取',
candidate_chunks_selected: '已筛正文',
extracting_candidates: '候选提炼中',
candidate_extraction_completed: '候选提炼完成',
document_completed: '文档完成',
skipped: '跳过'
}
const stageLabel = stageLabelMap[stage] || stage
if (total > 0) {
const parts = [`${percent}%`, `${completed}/${total} 文档`]
if (failed > 0) {
parts.push(`失败 ${failed}`)
}
if (stageLabel) {
parts.push(stageLabel)
}
return parts.join(' · ')
}
if (stageLabel) {
return `${percent}% · ${stageLabel}`
}
return `${percent}%`
}