diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 5569b99c..8b57f659 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1446,6 +1446,26 @@ "ai_extracted": "AI 萃取", "human": "人工建立" }, + "signals": { + "label": "訊號", + "detailTitle": "證據訊號", + "rawTitle": "原始技術標籤", + "more": "+{count}" + }, + "tag": { + "ai_extracted": "AI 萃取", + "auto_runbook": "自動 Runbook", + "critical": "嚴重", + "execution": "執行紀錄", + "execution_failed": "執行失敗", + "failure": "失敗", + "human_approved": "人工批准", + "human_intervention": "人工介入", + "incident": "事故", + "postmortem": "事後檢討", + "telegram": "Telegram", + "warning": "警告" + }, "category": { "AI治理": "AI 治理", "alert_handling": "告警處理", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 5569b99c..8b57f659 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1446,6 +1446,26 @@ "ai_extracted": "AI 萃取", "human": "人工建立" }, + "signals": { + "label": "訊號", + "detailTitle": "證據訊號", + "rawTitle": "原始技術標籤", + "more": "+{count}" + }, + "tag": { + "ai_extracted": "AI 萃取", + "auto_runbook": "自動 Runbook", + "critical": "嚴重", + "execution": "執行紀錄", + "execution_failed": "執行失敗", + "failure": "失敗", + "human_approved": "人工批准", + "human_intervention": "人工介入", + "incident": "事故", + "postmortem": "事後檢討", + "telegram": "Telegram", + "warning": "警告" + }, "category": { "AI治理": "AI 治理", "alert_handling": "告警處理", diff --git a/apps/web/src/app/[locale]/knowledge-base/page.tsx b/apps/web/src/app/[locale]/knowledge-base/page.tsx index 3f2266fe..975140a3 100644 --- a/apps/web/src/app/[locale]/knowledge-base/page.tsx +++ b/apps/web/src/app/[locale]/knowledge-base/page.tsx @@ -17,7 +17,7 @@ import { useTranslations } from 'next-intl' import { cn } from '@/lib/utils' import { Search, BookOpen, FileText, Shield, Cpu, - Server, Eye, Bot, ChevronRight, Plus, Sparkles, ClipboardList, TriangleAlert, + Server, Eye, Bot, ChevronRight, Plus, Sparkles, ClipboardList, Tag, TriangleAlert, } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -89,6 +89,34 @@ const KNOWN_CATEGORY_KEYS = new Set([ ]) const CATEGORY_OVERVIEW_LIMIT = 8 +const KNOWN_TAG_KEYS = new Set([ + 'ai_extracted', + 'auto_runbook', + 'critical', + 'execution', + 'execution_failed', + 'failure', + 'human_approved', + 'human_intervention', + 'incident', + 'postmortem', + 'telegram', + 'warning', +]) + +const REDUNDANT_SIGNAL_TAGS = new Set([ + 'AI治理', + 'alert_handling', + 'application', + 'ai_system', + 'database', + 'general', + 'host_resource', + 'incident_case', + 'infrastructure', + 'postmortem', + 'security', +]) // ============================================================================= // Type Badge Colors @@ -122,6 +150,20 @@ const CATEGORY_BAR_COLORS: Record = { __other__: 'bg-nothing-gray-300', } +const TAG_TONES: Record = { + critical: 'border-status-critical/20 bg-status-critical/10 text-status-critical', + execution_failed: 'border-status-critical/20 bg-status-critical/10 text-status-critical', + failure: 'border-status-critical/20 bg-status-critical/10 text-status-critical', + human_intervention: 'border-status-warning/20 bg-status-warning/10 text-status-warning', + warning: 'border-status-warning/20 bg-status-warning/10 text-status-warning', + human_approved: 'border-status-healthy/20 bg-status-healthy/10 text-status-healthy', + ai_extracted: 'border-claw-blue/20 bg-claw-blue/10 text-claw-blue', + auto_runbook: 'border-purple-200 bg-purple-100 text-purple-600', + incident: 'border-nothing-gray-200 bg-white text-secondary', + postmortem: 'border-status-critical/20 bg-white text-status-critical', + telegram: 'border-claw-blue/20 bg-white text-claw-blue', +} + const humanizeCategory = (category: string) => { if (!category.includes('_')) return category return category @@ -135,6 +177,31 @@ const humanizeCategory = (category: string) => { .join(' ') } +const humanizeSignalTag = (tag: string) => { + if (!tag.includes('_')) return tag + return tag + .split('_') + .filter(Boolean) + .map(part => { + const upper = part.toUpperCase() + if (['AI', 'CPU', 'DB', 'DNS', 'HTTP', 'IP', 'KB', 'P0', 'P1', 'P2', 'P3', 'SSL', 'SLO', 'SSH', 'TYPE'].includes(upper)) return upper + return `${part.charAt(0).toUpperCase()}${part.slice(1)}` + }) + .join(' ') +} + +const getSignalTags = (entry: KnowledgeEntry) => { + const seen = new Set() + return entry.tags + .filter(tag => tag && tag !== entry.category && tag !== entry.entry_type && tag !== entry.source) + .filter(tag => !REDUNDANT_SIGNAL_TAGS.has(tag)) + .filter(tag => { + if (seen.has(tag)) return false + seen.add(tag) + return true + }) +} + // ============================================================================= // Component // ============================================================================= @@ -276,6 +343,19 @@ export default function KnowledgeBasePage({ }, [t], ) + const formatDate = useCallback( + (value: string) => new Date(value).toLocaleDateString(localeCode), + [localeCode], + ) + const formatSignalTag = useCallback( + (tag: string) => { + if (KNOWN_TAG_KEYS.has(tag)) { + return t(`tag.${tag}`) + } + return humanizeSignalTag(tag) + }, + [t], + ) const visibleSummary = useMemo(() => { const loaded = displayedEntries.length @@ -488,7 +568,12 @@ export default function KnowledgeBasePage({ ) : (
- {displayedEntries.map(entry => ( + {displayedEntries.map(entry => { + const signalTags = getSignalTags(entry) + const visibleSignals = signalTags.slice(0, 3) + const hiddenSignalCount = Math.max(0, signalTags.length - visibleSignals.length) + + return (
- {/* Tags */} - {entry.tags.length > 0 && ( -
- {entry.tags.slice(0, 4).map(tag => ( - - {tag} + {/* Evidence signals */} + {visibleSignals.length > 0 && ( +
+ + + {visibleSignals.map(tag => ( + + {formatSignalTag(tag)} ))} + {hiddenSignalCount > 0 && ( + + {t('signals.more', { count: hiddenSignalCount })} + + )}
)}
@@ -556,7 +657,8 @@ export default function KnowledgeBasePage({ - ))} + ) + })} )} @@ -603,7 +705,7 @@ export default function KnowledgeBasePage({ · {selectedEntry.view_count} {t('viewCount')} · - {new Date(selectedEntry.updated_at).toLocaleDateString()} + {formatDate(selectedEntry.updated_at)} {/* Content — react-markdown (KB-D 2026-04-03 ogt) */} @@ -617,17 +719,46 @@ export default function KnowledgeBasePage({ - {/* Tags */} - {selectedEntry.tags.length > 0 && ( -
- {selectedEntry.tags.map(tag => ( - - {tag} - - ))} + {/* Evidence signals */} + {getSignalTags(selectedEntry).length > 0 && ( +
+
+
+
+ {getSignalTags(selectedEntry).map(tag => ( + + {formatSignalTag(tag)} + + ))} +
)} + {/* Raw technical tags */} + {selectedEntry.tags.length > 0 && ( +
+ + {t('signals.rawTitle')} + +
+ {selectedEntry.tags.map(tag => ( + + {tag} + + ))} +
+
+ )} + {/* Related links */} {selectedEntry.related_playbook_id && (