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
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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 結果列表 */}
|
||||
|
||||
Reference in New Issue
Block a user