fix(web): surface knowledge work item handoff
All checks were successful
CD Pipeline / tests (push) Successful in 1m25s
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / build-and-deploy (push) Successful in 3m53s
CD Pipeline / post-deploy-checks (push) Successful in 1m48s

This commit is contained in:
Your Name
2026-06-03 08:51:45 +08:00
parent ae645b5bc2
commit ebc272a4a8
3 changed files with 325 additions and 1 deletions

View File

@@ -14,11 +14,12 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { AppLayout } from '@/components/layout'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import {
Search, BookOpen, FileText, Shield, Cpu,
Server, Eye, Bot, ChevronRight, Plus, Sparkles, ClipboardList, Tag, TriangleAlert,
GitBranch, CheckCircle2, Clock3, Link2, FileSearch,
GitBranch, CheckCircle2, Clock3, Link2, FileSearch, ListChecks, ArrowRight, Users,
} from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -56,6 +57,69 @@ interface ListResponse {
categories: CategoryCount[]
}
interface KnowledgeStaleCandidate {
entry_id: string
title: string
priority_tier: string
priority_score: number
stale_days: number
recommended_action: string
owner_review_dispatch_id?: string | null
owner_review_status?: string | null
owner_review_stage?: string | null
related_incident_id?: string | null
related_playbook_id?: string | null
}
interface KnowledgeStaleCandidatesResponse {
total_stale: number
returned: number
threshold_days: number
writes_on_read: boolean
manual_review_required: boolean
items: KnowledgeStaleCandidate[]
}
interface KnowledgeStaleOwnerReviewInboxResponse {
total: number
returned: number
writes_on_read: boolean
manual_review_required: boolean
}
interface KnowledgeStaleOwnerReviewBurnDownResponse {
burn_down_status: string
entries_to_threshold: number
pending_owner_reviews: number
completed_owner_reviews: number
current_snapshot?: {
stale_count: number
total_count: number
stale_ratio: number
threshold: number
stale_days: number
} | null
}
interface KnowledgeStaleOwnerReviewCompletionQueueResponse {
total: number
pending_count: number
ready_count: number
blocked_count: number
completed_count: number
failed_count: number
writes_on_read: boolean
manual_review_required: boolean
batch_writes_allowed: boolean
}
interface KnowledgeGovernanceTelemetry {
staleCandidates: KnowledgeStaleCandidatesResponse | null
ownerReviews: KnowledgeStaleOwnerReviewInboxResponse | null
burnDown: KnowledgeStaleOwnerReviewBurnDownResponse | null
completionQueue: KnowledgeStaleOwnerReviewCompletionQueueResponse | null
}
// =============================================================================
// Category Config
// =============================================================================
@@ -219,6 +283,13 @@ export default function KnowledgeBasePage({
const [total, setTotal] = useState(0)
const [categories, setCategories] = useState<CategoryCount[]>([])
const [loading, setLoading] = useState(true)
const [governanceTelemetry, setGovernanceTelemetry] = useState<KnowledgeGovernanceTelemetry>({
staleCandidates: null,
ownerReviews: null,
burnDown: null,
completionQueue: null,
})
const [governanceLoading, setGovernanceLoading] = useState(true)
// Filters
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
@@ -260,6 +331,42 @@ export default function KnowledgeBasePage({
if (!semanticMode) fetchEntries()
}, [fetchEntries, semanticMode])
const fetchGovernanceTelemetry = useCallback(async () => {
setGovernanceLoading(true)
try {
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ''
const [staleCandidates, ownerReviews, burnDown, completionQueue] = await Promise.all([
fetch(`${apiBase}/api/v1/ai/governance/km-stale-candidates?project_id=awoooi&limit=5`)
.then(res => res.ok ? res.json() : null)
.catch(() => null),
fetch(`${apiBase}/api/v1/ai/governance/km-stale-owner-reviews?project_id=awoooi&dispatch_status=pending&limit=5`)
.then(res => res.ok ? res.json() : null)
.catch(() => null),
fetch(`${apiBase}/api/v1/ai/governance/km-stale-owner-review-burndown?project_id=awoooi&limit=5`)
.then(res => res.ok ? res.json() : null)
.catch(() => null),
fetch(`${apiBase}/api/v1/ai/governance/km-stale-owner-review-completion-queue?project_id=awoooi&status_bucket=all&limit=5`)
.then(res => res.ok ? res.json() : null)
.catch(() => null),
])
setGovernanceTelemetry({
staleCandidates,
ownerReviews,
burnDown,
completionQueue,
})
} catch (err) {
console.error('Failed to fetch knowledge governance telemetry', err)
} finally {
setGovernanceLoading(false)
}
}, [])
useEffect(() => {
fetchGovernanceTelemetry()
}, [fetchGovernanceTelemetry])
// KB Phase 2: 語意搜尋 (2026-04-04 Claude Code)
const fetchSemanticResults = useCallback(async (q: string) => {
if (!q.trim()) { setSemanticResults([]); return }
@@ -453,6 +560,40 @@ export default function KnowledgeBasePage({
] as const
}, [displayedEntries])
const governanceSummary = useMemo(() => {
const snapshot = governanceTelemetry.burnDown?.current_snapshot ?? null
const ratio = snapshot ? Math.round(snapshot.stale_ratio * 1000) / 10 : null
const threshold = snapshot ? Math.round(snapshot.threshold * 1000) / 10 : null
const topCandidate = governanceTelemetry.staleCandidates?.items?.[0] ?? null
const ownerReady = governanceTelemetry.completionQueue?.ready_count ?? 0
const ownerBlocked = governanceTelemetry.completionQueue?.blocked_count ?? 0
const ownerPending = governanceTelemetry.ownerReviews?.total ?? governanceTelemetry.completionQueue?.pending_count ?? 0
const completed = governanceTelemetry.completionQueue?.completed_count ?? governanceTelemetry.burnDown?.completed_owner_reviews ?? 0
const workItemsHref = topCandidate?.owner_review_dispatch_id
? `/awooop/work-items?project_id=awoooi&work_item_id=${encodeURIComponent(topCandidate.owner_review_dispatch_id)}`
: '/awooop/work-items?project_id=awoooi'
return {
snapshot,
ratio,
threshold,
topCandidate,
ownerReady,
ownerBlocked,
ownerPending,
completed,
workItemsHref,
staleTotal: governanceTelemetry.staleCandidates?.total_stale ?? snapshot?.stale_count ?? null,
entriesToThreshold: governanceTelemetry.burnDown?.entries_to_threshold ?? null,
burnDownStatus: governanceTelemetry.burnDown?.burn_down_status ?? null,
writesOnRead: [
governanceTelemetry.staleCandidates?.writes_on_read,
governanceTelemetry.ownerReviews?.writes_on_read,
governanceTelemetry.completionQueue?.writes_on_read,
].some(Boolean),
}
}, [governanceTelemetry])
const content = (
<div className="flex min-h-[calc(100vh-64px)] flex-col lg:h-[calc(100vh-64px)] lg:flex-row">
@@ -695,6 +836,119 @@ export default function KnowledgeBasePage({
</div>
</div>
</div>
<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">
<div className="flex items-center gap-1.5 text-[10px] font-label uppercase tracking-wider text-muted">
<ListChecks className="h-3.5 w-3.5 text-claw-blue" aria-hidden="true" />
{t('workItems.title')}
</div>
<p className="mt-1 max-w-3xl text-xs font-body text-secondary">
{t('workItems.subtitle')}
</p>
</div>
<Link
href={governanceSummary.workItemsHref as never}
className="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md border border-claw-blue/20 bg-claw-blue/8 px-3 py-1.5 text-xs font-label text-claw-blue transition-colors hover:bg-claw-blue/12"
>
{t('workItems.open')}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 lg:grid-cols-5">
{[
{
key: 'staleRatio',
value: governanceSummary.ratio === null ? '--' : `${governanceSummary.ratio}%`,
sub: governanceSummary.threshold === null ? t('workItems.loading') : t('workItems.threshold', { value: `${governanceSummary.threshold}%` }),
tone: 'text-status-warning',
},
{
key: 'staleTotal',
value: governanceSummary.staleTotal === null ? '--' : formatCount(governanceSummary.staleTotal),
sub: governanceSummary.entriesToThreshold === null ? t('workItems.loading') : t('workItems.entriesToThreshold', { count: formatCount(governanceSummary.entriesToThreshold) }),
tone: 'text-primary',
},
{
key: 'ownerPending',
value: formatCount(governanceSummary.ownerPending),
sub: t('workItems.ownerPendingSub'),
tone: 'text-claw-blue',
},
{
key: 'readyBlocked',
value: `${formatCount(governanceSummary.ownerReady)} / ${formatCount(governanceSummary.ownerBlocked)}`,
sub: t('workItems.readyBlockedSub'),
tone: 'text-purple-600',
},
{
key: 'completed',
value: formatCount(governanceSummary.completed),
sub: governanceSummary.burnDownStatus ? t('workItems.burndownStatus', { status: t(`workItems.status.${governanceSummary.burnDownStatus}` as never) }) : t('workItems.loading'),
tone: 'text-status-healthy',
},
].map(metric => (
<div key={metric.key} className="rounded-md border border-nothing-gray-200 bg-white/70 px-3 py-2">
<p className="text-[10px] font-label uppercase tracking-wider text-muted">{t(`workItems.metric.${metric.key}`)}</p>
<p className={cn('mt-1 text-xl font-heading font-semibold tabular-nums', metric.tone)}>{governanceLoading ? '--' : metric.value}</p>
<p className="mt-0.5 truncate text-[10px] font-body text-muted">{metric.sub}</p>
</div>
))}
</div>
<div className="mt-3 grid grid-cols-1 gap-2 lg:grid-cols-[minmax(0,1fr)_220px]">
<div className="rounded-md border border-nothing-gray-200 bg-white/60 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-[10px] font-label uppercase tracking-wider text-muted">{t('workItems.topCandidate')}</p>
<span className="shrink-0 text-[10px] font-body text-muted">{t('workItems.readOnly')}</span>
</div>
{governanceSummary.topCandidate ? (
<div className="mt-2 min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="rounded-full border border-status-warning/20 bg-status-warning/10 px-2 py-0.5 text-[10px] font-label text-status-warning">
{governanceSummary.topCandidate.priority_tier}
</span>
<span className="rounded-full border border-nothing-gray-200 bg-white px-2 py-0.5 text-[10px] font-label text-muted">
{t('workItems.staleDays', { days: governanceSummary.topCandidate.stale_days })}
</span>
<span className="rounded-full border border-claw-blue/20 bg-claw-blue/8 px-2 py-0.5 text-[10px] font-label text-claw-blue">
{t('workItems.score', { score: governanceSummary.topCandidate.priority_score })}
</span>
</div>
<p className="mt-2 truncate text-xs font-body font-semibold text-primary">
{governanceSummary.topCandidate.title}
</p>
<p className="mt-1 truncate text-[10px] font-body text-muted">
{t('workItems.topCandidateRefs', {
incident: governanceSummary.topCandidate.related_incident_id ?? '--',
playbook: governanceSummary.topCandidate.related_playbook_id ?? '--',
})}
</p>
</div>
) : (
<p className="mt-2 text-xs font-body text-muted">{t('workItems.noCandidate')}</p>
)}
</div>
<div className="rounded-md border border-nothing-gray-200 bg-white/60 px-3 py-2">
<div className="flex items-center gap-1.5 text-[10px] font-label uppercase tracking-wider text-muted">
<Users className="h-3.5 w-3.5" aria-hidden="true" />
{t('workItems.ownerSplit')}
</div>
<div className="mt-2 space-y-1 text-[10px] font-body text-secondary">
<p>{t('workItems.hermes')}</p>
<p>{t('workItems.openclaw')}</p>
<p>{t('workItems.owner')}</p>
</div>
{governanceSummary.writesOnRead ? (
<p className="mt-2 text-[10px] font-label text-status-critical">{t('workItems.unexpectedWrites')}</p>
) : (
<p className="mt-2 text-[10px] font-label text-status-healthy">{t('workItems.noWritesOnRead')}</p>
)}
</div>
</div>
</div>
</section>
{/* 結果列表 */}