const KNOWLEDGE_INGEST_JOB_TYPES = new Set(['knowledge_index_sync', 'llm_wiki_sync']) const STATUS_META = { queued: { label: '等待处理', tone: 'muted' }, running: { label: '处理中', tone: 'warning' }, succeeded: { label: '已完成', tone: 'success' }, failed: { label: '失败', tone: 'danger' }, skipped: { label: '已跳过', tone: 'muted' } } const PHASE_LABELS = { queued: '进入队列', indexing: '解析与索引', indexed: '索引完成', failed: '处理失败', completed: '任务完成' } export function isKnowledgeIngestRun(run) { const routeJson = asObject(run?.route_json) return KNOWLEDGE_INGEST_JOB_TYPES.has(String(routeJson.job_type || '').trim()) } export function buildKnowledgeIngestLogModel(run) { const routeJson = asObject(run?.route_json) const ingest = asObject(routeJson.knowledge_ingest) const toolDocuments = extractToolDocuments(run) const sourceDocuments = normalizeSourceDocuments( ingest.documents, toolDocuments, routeJson.requested_document_ids ) const documents = sourceDocuments.map(normalizeDocument) const graph = normalizeGraph(ingest.graph, documents) const progress = normalizeProgress(routeJson.progress, documents) const currentDocumentId = String(ingest.current_document_id || '').trim() return { available: isKnowledgeIngestRun(run), folder: String(routeJson.folder || '').trim(), phase: String(ingest.phase || routeJson.phase || '').trim(), phaseLabel: PHASE_LABELS[ingest.phase] || PHASE_LABELS[routeJson.phase] || '运行中', status: String(ingest.status || run?.status || '').trim(), statusLabel: resolveStatusMeta(ingest.status || run?.status).label, statusTone: resolveStatusMeta(ingest.status || run?.status).tone, progress, currentDocumentId, documents, selectedDocumentId: resolveDefaultDocumentId(documents, currentDocumentId), graph, metrics: [ { label: '文件', value: `${progress.completedDocuments}/${progress.totalDocuments}`, hint: `失败 ${progress.failedDocuments}` }, { label: 'Chunk', value: formatNumber(graph.chunkCount), hint: '已解析块' }, { label: '实体', value: formatNumber(graph.entityCount), hint: '图谱节点' }, { label: '关系', value: formatNumber(graph.relationCount), hint: '图谱边' } ] } } export function formatKnowledgeMetric(value) { return formatNumber(value) } function normalizeSourceDocuments(ingestDocuments, toolDocuments, requestedDocumentIds) { if (Array.isArray(ingestDocuments) && ingestDocuments.length) { return ingestDocuments } if (Array.isArray(toolDocuments) && toolDocuments.length) { return toolDocuments } if (Array.isArray(requestedDocumentIds)) { return requestedDocumentIds .map((documentId) => String(documentId || '').trim()) .filter(Boolean) .map((documentId) => ({ document_id: documentId, name: documentId, status: 'queued' })) } return [] } function extractToolDocuments(run) { const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] for (const toolCall of [...toolCalls].reverse()) { const responseJson = asObject(toolCall?.response_json) if (Array.isArray(responseJson.documents) && responseJson.documents.length) { return responseJson.documents } } return [] } function normalizeDocument(rawDocument) { const document = asObject(rawDocument) const documentId = String(document.document_id || document.id || '').trim() const status = String(document.status || 'queued').trim() const phase = String(document.phase || status).trim() const chunks = normalizeChunks(document.chunks) const sections = normalizeSections(document.sections) const entities = normalizeEntities(document.entities) const relations = normalizeRelations(document.relations) return { documentId, name: String(document.name || document.original_name || documentId || '未命名文件').trim(), folder: String(document.folder || '').trim(), extension: String(document.extension || '').trim(), mimeType: String(document.mime_type || '').trim(), status, statusLabel: resolveStatusMeta(status).label, statusTone: resolveStatusMeta(status).tone, phase, phaseLabel: PHASE_LABELS[phase] || PHASE_LABELS[status] || phase || '未开始', startedAt: String(document.started_at || '').trim(), finishedAt: String(document.finished_at || '').trim(), error: String(document.error || '').trim(), textChars: toNumber(document.text_chars), indexedTextChars: toNumber(document.indexed_text_chars), sectionCount: toNumber(document.section_count || sections.length), sections, chunkCount: toNumber(document.chunk_count || chunks.length), chunkIds: normalizeTextList(document.chunk_ids), chunks, entityCount: toNumber(document.entity_count || entities.length), relationCount: toNumber(document.relation_count || relations.length), entities, entityChunks: normalizeEntityChunks(document.entity_chunks || document.entityChunks), relations, events: normalizeEvents(document.events) } } function normalizeProgress(rawProgress, documents) { const progress = asObject(rawProgress) const totalDocuments = toNumber(progress.total_documents || documents.length) const completedDocuments = toNumber( progress.completed_documents || documents.filter((item) => item.status === 'succeeded').length ) const failedDocuments = toNumber( progress.failed_documents || documents.filter((item) => item.status === 'failed').length ) const skippedDocuments = toNumber(progress.skipped_documents) const percent = clampPercent( progress.percent ?? calculatePercent(totalDocuments, completedDocuments + failedDocuments) ) return { totalDocuments, completedDocuments, failedDocuments, skippedDocuments, percent } } function normalizeGraph(rawGraph, documents) { const graph = asObject(rawGraph) const graphEntities = normalizeEntities(graph.entities) const fallbackEntities = dedupeEntities(documents.flatMap((item) => item.entities)) const graphRelations = normalizeRelations(graph.relations) const fallbackRelations = dedupeRelations(documents.flatMap((item) => item.relations)) return { chunkCount: toNumber( graph.chunk_count || documents.reduce((total, item) => total + item.chunkCount, 0) ), entityCount: toNumber( graph.entity_count || documents.reduce((total, item) => total + item.entityCount, 0) ), relationCount: toNumber( graph.relation_count || documents.reduce((total, item) => total + item.relationCount, 0) ), entities: graphEntities.length ? graphEntities : fallbackEntities, relations: graphRelations.length ? graphRelations : fallbackRelations } } function normalizeChunks(rawChunks) { if (!Array.isArray(rawChunks)) return [] return rawChunks .map((chunk, index) => { const item = asObject(chunk) return { id: String(item.id || item._id || `chunk-${index + 1}`).trim(), order: toNumber(item.order ?? item.chunk_order_index ?? index), tokens: toNumber(item.tokens), summary: String(item.summary || item.content || '').trim(), excerpt: String(item.excerpt || item.content_preview || item.summary || item.content || '').trim() } }) .sort((left, right) => left.order - right.order) } function normalizeEntityChunks(rawItems) { if (!Array.isArray(rawItems)) return [] const result = [] const seen = new Set() for (const rawItem of rawItems) { const item = asObject(rawItem) const entity = String(item.entity || item.name || '').trim() const chunkIds = normalizeTextList(item.chunk_ids || item.chunkIds) if (!entity || !chunkIds.length || seen.has(entity)) continue seen.add(entity) result.push({ entity, chunkIds }) } return result } function normalizeSections(rawSections) { if (!Array.isArray(rawSections)) return [] return rawSections.map((section, index) => { const item = asObject(section) return { title: String(item.title || `章节 ${index + 1}`).trim(), excerpt: String(item.excerpt || '').trim() } }) } function normalizeEvents(rawEvents) { if (!Array.isArray(rawEvents)) return [] return rawEvents.map((event) => { const item = asObject(event) return { at: String(item.at || '').trim(), level: String(item.level || 'info').trim(), message: String(item.message || '').trim() } }) } function normalizeEntities(rawEntities) { if (!Array.isArray(rawEntities)) return [] return dedupeEntities(rawEntities) } function normalizeRelations(rawRelations) { if (!Array.isArray(rawRelations)) return [] return rawRelations .map((relation) => { const item = asObject(relation) return { source: String(item.source || item.from || '').trim(), target: String(item.target || item.to || '').trim(), type: String(item.type || '关联').trim(), description: String(item.description || '').trim(), keywords: normalizeTextList(item.keywords), weight: toNumber(item.weight || item.confidence || 1), properties: asObject(item.properties) } }) .filter((item) => item.source && item.target) } function resolveDefaultDocumentId(documents, currentDocumentId) { if (currentDocumentId && documents.some((item) => item.documentId === currentDocumentId)) { return currentDocumentId } return ( documents.find((item) => item.status === 'running')?.documentId || documents.find((item) => item.status === 'failed')?.documentId || documents[0]?.documentId || '' ) } function resolveStatusMeta(status) { return STATUS_META[String(status || '').trim()] || STATUS_META.queued } function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {} } function normalizeTextList(value) { if (Array.isArray(value)) return dedupeTextList(value) return dedupeTextList( String(value || '') .split('') .filter(Boolean) ) } function dedupeTextList(items) { const result = [] const seen = new Set() for (const item of items) { const text = typeof item === 'string' ? item.trim() : String(item?.name || item?.entity || item?.title || item?.id || '').trim() if (!text || seen.has(text)) continue seen.add(text) result.push(text) } return result } function dedupeEntities(items) { const result = [] const seen = new Set() for (const rawItem of items) { const item = asObject(rawItem) const name = typeof rawItem === 'string' ? rawItem.trim() : String( item.name || item.entity || item.entity_id || item.title || item.id || '' ).trim() if (!name || seen.has(name)) continue seen.add(name) const description = String(item.description || '').trim() const descriptions = normalizeTextList(item.descriptions).length ? normalizeTextList(item.descriptions) : description ? [description] : [] result.push({ ...item, name, type: String(item.type || item.entity_type || item.category || item.kind || '实体').trim(), description, descriptions, properties: asObject(item.properties), labels: normalizeTextList(item.labels) }) } return result } function dedupeRelations(items) { const result = [] const seen = new Set() for (const item of items) { const source = String(item?.source || '').trim() const target = String(item?.target || '').trim() const type = String(item?.type || '关联').trim() const key = `${source}::${target}::${type}` if (!source || !target || seen.has(key)) continue seen.add(key) result.push({ ...item, source, target, type }) } return result } function calculatePercent(total, done) { if (!total) return 0 return Math.round((done / total) * 100) } function clampPercent(value) { const numericValue = toNumber(value) return Math.max(0, Math.min(100, numericValue)) } function formatNumber(value) { const numericValue = toNumber(value) return Number.isFinite(numericValue) ? numericValue.toLocaleString('zh-CN') : '0' } function toNumber(value) { const numericValue = Number(value) return Number.isFinite(numericValue) ? numericValue : 0 }