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}%` }