fix(web): surface knowledge work item handoff
This commit is contained in:
@@ -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>
|
||||
|
||||
{/* 結果列表 */}
|
||||
|
||||
Reference in New Issue
Block a user