feat: KB Accelerator — convert KB articles into interactive flows
Full-stack implementation of the KB Accelerator feature that converts static MSP knowledge base articles into interactive troubleshooting and procedural flows using AI. Backend: - Migrations 054/055: kb_imports, kb_import_nodes tables + plan_limits KB columns - SQLAlchemy models with relationships and self-referential node hierarchy - Text extraction service (txt, paste, docx with structural metadata) - AI conversion service with MSP-specialist prompts for both flow types - 8 API endpoints: upload, get, list, convert, edit node, commit, delete, quota - Tier-gated access via plan_limits (free: 3 lifetime, pro/team: unlimited) - 8 integration tests covering upload, get/list, quota, commit, delete Frontend: - TypeScript types and API client for all KB Accelerator endpoints - Multi-step wizard page: upload → processing → review → success - Upload screen with paste/file tabs, drag-drop, target type selector - Two-panel review screen with source highlighting and node cards - Per-node actions: approve, edit, regenerate, insert, delete - Confidence color indicators (green/amber/red) - Sidebar navigation with Sparkles icon - Code-split lazy-loaded route at /kb-accelerator Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
205
frontend/src/pages/KBAcceleratorPage.tsx
Normal file
205
frontend/src/pages/KBAcceleratorPage.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Sparkles, Loader2 } from 'lucide-react'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { kbAcceleratorApi } from '@/api'
|
||||
import { UploadScreen } from '@/components/kb-accelerator/UploadScreen'
|
||||
import { ReviewScreen } from '@/components/kb-accelerator/ReviewScreen'
|
||||
import { SuccessScreen } from '@/components/kb-accelerator/SuccessScreen'
|
||||
import { getTreeEditorPath } from '@/lib/routing'
|
||||
import type { KBImport, KBQuotaResponse, KBCommitResponse, KBNodeEditRequest } from '@/types/kbAccelerator'
|
||||
|
||||
type Phase = 'upload' | 'processing' | 'review' | 'success'
|
||||
type TargetType = 'troubleshooting' | 'procedural' | 'auto'
|
||||
|
||||
export default function KBAcceleratorPage() {
|
||||
const navigate = useNavigate()
|
||||
const [phase, setPhase] = useState<Phase>('upload')
|
||||
const [quota, setQuota] = useState<KBQuotaResponse | null>(null)
|
||||
const [importId, setImportId] = useState<string | null>(null)
|
||||
const [kbImport, setKbImport] = useState<KBImport | null>(null)
|
||||
const [commitResult, setCommitResult] = useState<KBCommitResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Load quota on mount
|
||||
useEffect(() => {
|
||||
kbAcceleratorApi.getQuota().then(setQuota).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Poll for processing status
|
||||
const startPolling = useCallback((id: string) => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const data = await kbAcceleratorApi.get(id)
|
||||
if (data.status === 'ready') {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
setKbImport(data)
|
||||
setPhase('review')
|
||||
} else if (data.status === 'failed') {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
toast.error(data.error_message || 'Conversion failed')
|
||||
setPhase('upload')
|
||||
}
|
||||
} catch {
|
||||
// Keep polling on transient errors
|
||||
}
|
||||
}, 2000)
|
||||
}, [])
|
||||
|
||||
// Cleanup polling on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmitText = async (content: string, title: string, targetType: TargetType) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const resp = await kbAcceleratorApi.uploadText({
|
||||
content,
|
||||
title: title || undefined,
|
||||
target_type: targetType === 'auto' ? undefined : targetType,
|
||||
})
|
||||
setImportId(resp.id)
|
||||
setPhase('processing')
|
||||
startPolling(resp.id)
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Upload failed'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitFile = async (file: File, targetType: TargetType) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const resp = await kbAcceleratorApi.uploadFile(file, targetType === 'auto' ? undefined : targetType)
|
||||
setImportId(resp.id)
|
||||
setPhase('processing')
|
||||
startPolling(resp.id)
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Upload failed'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditNode = async (nodeId: string, data: KBNodeEditRequest) => {
|
||||
if (!importId) return
|
||||
const updatedNode = await kbAcceleratorApi.editNode(importId, nodeId, data)
|
||||
setKbImport(prev => {
|
||||
if (!prev) return prev
|
||||
if (data.operation === 'delete') {
|
||||
return { ...prev, nodes: prev.nodes.filter(n => n.id !== nodeId) }
|
||||
}
|
||||
if (data.operation === 'insert_after') {
|
||||
const idx = prev.nodes.findIndex(n => n.id === nodeId)
|
||||
const newNodes = [...prev.nodes]
|
||||
newNodes.splice(idx + 1, 0, updatedNode)
|
||||
return { ...prev, nodes: newNodes }
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
nodes: prev.nodes.map(n => n.id === updatedNode.id ? updatedNode : n),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!importId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await kbAcceleratorApi.commit(importId)
|
||||
setCommitResult(result)
|
||||
setPhase('success')
|
||||
// Refresh quota
|
||||
kbAcceleratorApi.getQuota().then(setQuota).catch(() => {})
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Commit failed'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiscard = async () => {
|
||||
if (!importId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
await kbAcceleratorApi.delete(importId)
|
||||
resetWizard()
|
||||
} catch {
|
||||
toast.error('Failed to discard')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetWizard = () => {
|
||||
setPhase('upload')
|
||||
setImportId(null)
|
||||
setKbImport(null)
|
||||
setCommitResult(null)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-6">
|
||||
{/* Page title */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Sparkles size={24} className="text-primary" />
|
||||
<h1 className="text-2xl font-heading font-bold text-foreground">KB Accelerator</h1>
|
||||
</div>
|
||||
|
||||
{/* Phases */}
|
||||
{phase === 'upload' && (
|
||||
<UploadScreen
|
||||
quota={quota}
|
||||
onSubmitText={handleSubmitText}
|
||||
onSubmitFile={handleSubmitFile}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'processing' && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4">
|
||||
<Loader2 size={40} className="text-primary animate-spin" />
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-heading font-semibold text-foreground">
|
||||
Converting your KB article...
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
AI is analyzing your content and generating an interactive flow.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'review' && kbImport && (
|
||||
<ReviewScreen
|
||||
kbImport={kbImport}
|
||||
onEditNode={handleEditNode}
|
||||
onCommit={handleCommit}
|
||||
onDiscard={handleDiscard}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'success' && commitResult && (
|
||||
<SuccessScreen
|
||||
result={commitResult}
|
||||
onViewFlow={() => {
|
||||
const path = getTreeEditorPath(commitResult.tree_id, commitResult.tree_type as 'troubleshooting' | 'procedural')
|
||||
navigate(path)
|
||||
}}
|
||||
onConvertAnother={resetWizard}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user