272 lines
7.1 KiB
JavaScript
272 lines
7.1 KiB
JavaScript
|
|
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}%`
|
||
|
|
}
|