feat(km): surface dynamic knowledge categories
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 1m1s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled

This commit is contained in:
Your Name
2026-07-03 00:35:57 +08:00
parent f258ae4e9e
commit b97bc6f35e
7 changed files with 519 additions and 60 deletions

View File

@@ -1288,7 +1288,6 @@ const COMMANDER_INSERTED_REQUIREMENT_FALLBACK: PriorityWorkOrderResponse = {
"windows99_uptime_unknown",
],
stockplatform_public_api_runtime_status: "stockplatform_public_api_runtime_ready",
stockplatform_public_api_runtime_ready: true,
stockplatform_public_api_controlled_recovery_preflight_status:
"not_required_stockplatform_runtime_ready",
stockplatform_public_api_controlled_recovery_data_dependency: "none",

View File

@@ -17,10 +17,11 @@ import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import {
Search, BookOpen, FileText, Shield, Cpu,
type LucideIcon,
Search, BookOpen, FileText, Shield, Cpu, Globe2,
Server, Eye, Bot, ChevronRight, Plus, Sparkles, ClipboardList, Tag, TriangleAlert,
GitBranch, CheckCircle2, Clock3, Link2, FileSearch, ListChecks, ArrowRight, Users,
PlayCircle, Database, RotateCw,
PlayCircle, Database, RotateCw, Package, Wrench,
} from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -130,20 +131,58 @@ interface KnowledgeGovernanceTelemetry {
}
type AutomationAssetKey = 'km' | 'playbook' | 'script' | 'monitoring' | 'verifier' | 'rag'
type WorkspaceViewKey = 'records' | 'automation' | 'queue'
type AssetLensKey = 'project' | 'product' | 'website' | 'service' | 'package' | 'tool'
// =============================================================================
// Category Config
// =============================================================================
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
'AI自動化/Ansible受控修復': <Bot className="w-4 h-4" />,
AI治理: <Bot className="w-4 h-4" />,
alert_handling: <TriangleAlert className="w-4 h-4" />,
infrastructure: <Server className="w-4 h-4" />,
application: <FileText className="w-4 h-4" />,
ai_system: <Bot className="w-4 h-4" />,
security: <Shield className="w-4 h-4" />,
host_resource: <Cpu className="w-4 h-4" />,
database: <Database className="w-4 h-4" />,
auto_repair: <Wrench className="w-4 h-4" />,
postmortem: <FileSearch className="w-4 h-4" />,
incident_postmortem: <FileSearch className="w-4 h-4" />,
external_site: <Globe2 className="w-4 h-4" />,
backup_failure: <Database className="w-4 h-4" />,
kubernetes: <Package className="w-4 h-4" />,
flywheel_health: <RotateCw className="w-4 h-4" />,
ssl_cert: <Shield className="w-4 h-4" />,
devops_tool: <Wrench className="w-4 h-4" />,
general: <BookOpen className="w-4 h-4" />,
}
const CATEGORIES = ['infrastructure', 'application', 'ai_system', 'security'] as const
const DEFAULT_CATEGORY_ORDER = [
'alert_handling',
'AI自動化/Ansible受控修復',
'infrastructure',
'host_resource',
'postmortem',
'AI治理',
'application',
'ai_system',
'security',
'database',
'auto_repair',
'kubernetes',
'external_site',
'devops_tool',
'ssl_cert',
'backup_failure',
'flywheel_health',
'general',
]
const KNOWN_CATEGORY_KEYS = new Set([
'AI自動化/Ansible受控修復',
'AI治理',
'alert_handling',
'application',
@@ -160,8 +199,14 @@ const KNOWN_CATEGORY_KEYS = new Set([
'infrastructure',
'kubernetes',
'postmortem',
'project',
'product',
'service',
'security',
'source_control',
'ssl_cert',
'tool',
'website',
])
const CATEGORY_OVERVIEW_LIMIT = 8
@@ -222,17 +267,29 @@ const STATUS_COLORS: Record<string, string> = {
}
const CATEGORY_BAR_COLORS: Record<string, string> = {
'AI自動化/Ansible受控修復': 'bg-purple-500',
AI治理: 'bg-claw-blue',
alert_handling: 'bg-status-warning',
infrastructure: 'bg-claw-blue',
host_resource: 'bg-status-warning',
application: 'bg-status-healthy',
ai_system: 'bg-purple-500',
security: 'bg-status-warning',
database: 'bg-status-critical',
auto_repair: 'bg-purple-500',
external_site: 'bg-status-healthy',
devops_tool: 'bg-claw-blue',
kubernetes: 'bg-claw-blue',
ssl_cert: 'bg-status-warning',
backup_failure: 'bg-status-critical',
flywheel_health: 'bg-status-healthy',
general: 'bg-nothing-gray-400',
postmortem: 'bg-status-critical',
__other__: 'bg-nothing-gray-300',
}
const CATEGORY_ORDER_RANK = new Map(DEFAULT_CATEGORY_ORDER.map((category, index) => [category, index]))
const TAG_TONES: Record<string, string> = {
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',
@@ -285,6 +342,28 @@ const getSignalTags = (entry: KnowledgeEntry) => {
})
}
const entrySearchText = (entry: KnowledgeEntry) =>
[
entry.title,
entry.category,
entry.entry_type,
entry.source,
...entry.tags,
].join(' ').toLowerCase()
const entryMatchesAny = (entry: KnowledgeEntry, candidates: string[]) => {
const text = entrySearchText(entry)
return candidates.some(candidate => text.includes(candidate.toLowerCase()))
}
const mergeCategoryCounts = (rows: CategoryCount[]) => {
const merged = new Map<string, number>()
rows.forEach(row => {
merged.set(row.category, (merged.get(row.category) ?? 0) + row.count)
})
return Array.from(merged.entries()).map(([category, count]) => ({ category, count }))
}
// =============================================================================
// Component
// =============================================================================
@@ -319,6 +398,7 @@ export default function KnowledgeBasePage({
const [semanticResults, setSemanticResults] = useState<(KnowledgeEntry & { score: number })[]>([])
const [semanticLoading, setSemanticLoading] = useState(false)
const [selectedEntry, setSelectedEntry] = useState<KnowledgeEntry | null>(null)
const [workspaceView, setWorkspaceView] = useState<WorkspaceViewKey>('records')
// KB-D: approve/archive state (2026-04-03 ogt)
const [actionLoading, setActionLoading] = useState(false)
@@ -519,8 +599,8 @@ export default function KnowledgeBasePage({
const categoryRows = useMemo(() => {
const sourceRows = categories.length > 0
? [...categories].sort((a, b) => b.count - a.count)
: CATEGORIES.map(category => ({ category, count: 0 }))
? mergeCategoryCounts(categories).sort((a, b) => b.count - a.count)
: DEFAULT_CATEGORY_ORDER.map(category => ({ category, count: 0 }))
const visibleRows = sourceRows.slice(0, CATEGORY_OVERVIEW_LIMIT).map(row => {
const pct = totalCount > 0 ? Math.round((row.count / totalCount) * 100) : 0
@@ -542,6 +622,81 @@ export default function KnowledgeBasePage({
]
}, [categories, totalCount])
const categoryNavigationRows = useMemo(() => {
const sourceRows = categories.length > 0
? mergeCategoryCounts(categories).filter(row => row.count > 0)
: DEFAULT_CATEGORY_ORDER.map(category => ({ category, count: 0 }))
return [...sourceRows].sort((a, b) => {
const aRank = CATEGORY_ORDER_RANK.get(a.category) ?? Number.MAX_SAFE_INTEGER
const bRank = CATEGORY_ORDER_RANK.get(b.category) ?? Number.MAX_SAFE_INTEGER
if (aRank !== bRank) return aRank - bRank
return b.count - a.count
})
}, [categories])
const assetLensRows = useMemo(() => {
const rows: Array<{
key: AssetLensKey
icon: LucideIcon
count: number
tone: string
}> = [
{
key: 'project',
icon: GitBranch,
count: displayedEntries.filter(entry => entryMatchesAny(entry, [
'project', 'repo', 'repository', 'gitea', 'branch', 'workflow', 'source_control',
])).length,
tone: 'text-claw-blue',
},
{
key: 'product',
icon: Package,
count: displayedEntries.filter(entry => entryMatchesAny(entry, [
'product', 'awoooi', 'awooop', 'iwooos', 'vibework', 'stockplatform', 'momo', 'awooogo', 'agent-bounty', 'tsenyang',
])).length,
tone: 'text-purple-600',
},
{
key: 'website',
icon: Globe2,
count: displayedEntries.filter(entry =>
entry.category === 'external_site'
|| entryMatchesAny(entry, ['website', 'site', 'nginx', 'ssl', 'domain', 'route', 'frontend']),
).length,
tone: 'text-status-healthy',
},
{
key: 'service',
icon: Server,
count: displayedEntries.filter(entry =>
['infrastructure', 'application', 'ai_system', 'database', 'host_resource', 'kubernetes', 'alert_handling'].includes(entry.category)
|| entryMatchesAny(entry, ['service', 'daemon', 'api', 'worker', 'runtime', 'container', 'pod']),
).length,
tone: 'text-primary',
},
{
key: 'package',
icon: Package,
count: displayedEntries.filter(entry => entryMatchesAny(entry, [
'package', 'dependency', 'npm', 'pnpm', 'node', 'python', 'pip', 'prisma', 'next', 'library',
])).length,
tone: 'text-status-warning',
},
{
key: 'tool',
icon: Wrench,
count: displayedEntries.filter(entry =>
entry.category === 'devops_tool'
|| entryMatchesAny(entry, ['tool', 'ansible', 'mcp', 'playbook', 'telegram', 'wazuh', 'kali', 'sentry', 'signoz', 'runner']),
).length,
tone: 'text-status-critical',
},
]
return rows
}, [displayedEntries])
const qualityRows = useMemo(() => {
const loaded = displayedEntries.length
const now = Date.now()
@@ -887,11 +1042,20 @@ export default function KnowledgeBasePage({
] as const
}, [formatCount, governanceSummary, governanceTelemetry.ragStats, t])
const workspaceViews = useMemo(
() => [
{ key: 'records' as WorkspaceViewKey, icon: BookOpen },
{ key: 'automation' as WorkspaceViewKey, icon: Bot },
{ key: 'queue' as WorkspaceViewKey, icon: ListChecks },
],
[],
)
const content = (
<div className="flex min-h-[calc(100vh-64px)] flex-col lg:h-[calc(100vh-64px)] lg:flex-row">
{/* 左側分類導航 */}
<aside className="w-full flex-shrink-0 border-b border-nothing-gray-200/50 bg-white/50 p-3 lg:w-52 lg:border-b-0 lg:border-r">
<aside className="w-full flex-shrink-0 border-b border-nothing-gray-200/50 bg-white/60 p-3 lg:w-60 lg:border-b-0 lg:border-r">
<h2 className="font-heading text-xs font-semibold text-tertiary uppercase tracking-widest mb-3 px-2">
{t('title')}
</h2>
@@ -911,10 +1075,16 @@ export default function KnowledgeBasePage({
<span className="text-xs text-muted">{totalCount}</span>
</button>
<div className="mt-3 flex items-center justify-between px-2">
<span className="text-[10px] font-label uppercase tracking-wider text-muted">{t('rail.categoryTitle')}</span>
<span className="text-[10px] font-body tabular-nums text-muted">{categoryNavigationRows.length}</span>
</div>
{/* Category items */}
<div className="mt-1 grid grid-cols-2 gap-0.5 lg:block lg:space-y-0.5">
{CATEGORIES.map(cat => {
const count = categories.find(c => c.category === cat)?.count ?? 0
<div className="mt-1 grid max-h-60 grid-cols-2 gap-0.5 overflow-y-auto pr-1 lg:block lg:space-y-0.5">
{categoryNavigationRows.map(row => {
const cat = row.category
const icon = CATEGORY_ICONS[cat] ?? <BookOpen className="w-4 h-4" />
return (
<button
key={cat}
@@ -926,13 +1096,63 @@ export default function KnowledgeBasePage({
: 'text-secondary hover:bg-nothing-gray-100'
)}
>
{CATEGORY_ICONS[cat]}
{icon}
<span className="flex-1 text-left">{formatCategoryLabel(cat)}</span>
<span className="text-xs text-muted">{count}</span>
<span className="text-xs text-muted">{formatCount(row.count)}</span>
</button>
)
})}
</div>
<div className="mt-4 rounded-md border border-nothing-gray-200 bg-white/75 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-[10px] font-label uppercase tracking-wider text-muted">{t('assetLens.title')}</span>
<span className="shrink-0 text-[10px] font-body text-muted">{t('assetLens.scope')}</span>
</div>
<div className="grid grid-cols-2 gap-1">
{assetLensRows.map(row => {
const Icon = row.icon
return (
<div key={row.key} className="rounded border border-nothing-gray-100 bg-nothing-gray-50 px-2 py-1.5">
<div className="flex items-center justify-between gap-1">
<span className="truncate text-[10px] font-body text-secondary">{t(`assetLens.${row.key}` as never)}</span>
<Icon className={cn('h-3 w-3 shrink-0', row.tone)} aria-hidden={true} />
</div>
<p className={cn('mt-1 text-sm font-heading font-semibold tabular-nums', row.tone)}>
{formatCount(row.count)}
</p>
</div>
)
})}
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-2 lg:grid-cols-1">
{[
{ label: t('rail.total'), value: isKnowledgeListUnavailable ? '--' : formatCount(total), icon: BookOpen },
{ label: t('rail.aiExtracted'), value: formatCount(visibleSummary.aiExtracted), icon: Bot },
{
label: t('rail.controlledQueue'),
value: governanceLoading ? '--' : formatCount(governanceSummary.ownerPending),
icon: ListChecks,
},
{
label: t('rail.ragChunks'),
value: governanceLoading ? '--' : formatCount(governanceTelemetry.ragStats?.total_chunks ?? 0),
icon: FileSearch,
},
].map(item => {
const Icon = item.icon
return (
<div key={item.label} className="rounded-md border border-nothing-gray-200 bg-white/75 px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[10px] font-label uppercase tracking-wider text-muted">{item.label}</span>
<Icon className="h-3.5 w-3.5 shrink-0 text-claw-blue" aria-hidden="true" />
</div>
<p className="mt-1 text-lg font-heading font-semibold tabular-nums text-primary">{item.value}</p>
</div>
)
})}
</div>
</aside>
{/* 主區域 */}
@@ -1077,9 +1297,31 @@ export default function KnowledgeBasePage({
)
})}
</div>
<div className="mt-3 flex flex-wrap gap-1 rounded-md border border-nothing-gray-200 bg-nothing-gray-50 p-1">
{workspaceViews.map(view => {
const Icon = view.icon
return (
<button
key={view.key}
type="button"
onClick={() => setWorkspaceView(view.key)}
className={cn(
'inline-flex flex-1 items-center justify-center gap-1.5 rounded px-3 py-1.5 text-xs font-label transition-colors sm:flex-none',
workspaceView === view.key
? 'bg-white text-claw-blue shadow-sm'
: 'text-muted hover:bg-white/70 hover:text-secondary',
)}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{t(`workspace.${view.key}` as never)}
</button>
)
})}
</div>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_280px]">
{workspaceView === 'records' && (
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-[minmax(0,1fr)_280px]">
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
{[
{ label: t('overview.metricTotal'), value: formatCount(total), sub: t('overview.scopeFiltered'), tone: 'text-claw-blue' },
@@ -1120,7 +1362,9 @@ export default function KnowledgeBasePage({
</div>
</div>
</div>
)}
{workspaceView === 'automation' && (
<div className="mt-3 rounded-md border border-nothing-gray-200 bg-white/70 px-3 py-2">
<div className="mb-3 flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
@@ -1169,7 +1413,9 @@ export default function KnowledgeBasePage({
{t('assetLedger.boundary')}
</p>
</div>
)}
{workspaceView === 'records' && (
<div className="mt-3 rounded-md border border-nothing-gray-200 bg-white/70 px-3 py-2">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-[10px] font-label uppercase tracking-wider text-muted">{t('quality.title')}</p>
@@ -1194,7 +1440,9 @@ export default function KnowledgeBasePage({
))}
</div>
</div>
)}
{(workspaceView === 'records' || workspaceView === 'automation') && (
<div className="mt-3 grid grid-cols-1 gap-3 xl:grid-cols-[minmax(0,1.25fr)_minmax(260px,0.75fr)]">
<div className="rounded-md border border-nothing-gray-200 bg-white/70 px-3 py-2">
<div className="mb-3 flex items-center justify-between gap-2">
@@ -1259,7 +1507,9 @@ export default function KnowledgeBasePage({
</div>
</div>
</div>
)}
{workspaceView === 'queue' && (
<div className="mt-3 rounded-md border border-nothing-gray-200 bg-white/70 px-3 py-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
@@ -1413,6 +1663,7 @@ export default function KnowledgeBasePage({
</div>
</div>
</div>
)}
</section>
{/* 結果列表 */}