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:
Michael Chihlas
2026-03-10 20:56:28 -04:00
parent c65aa4f0b7
commit 71ff4a8c35
27 changed files with 4426 additions and 2 deletions

View File

@@ -20,3 +20,4 @@ export { default as aiBuilderApi } from './aiBuilder'
export { copilotApi } from './copilot'
export { assistantChatApi } from './assistantChat'
export { flowTransferApi } from './flowTransfer'
export { kbAcceleratorApi } from './kbAccelerator'

View File

@@ -0,0 +1,76 @@
import apiClient from './client'
import type {
KBUploadResponse,
KBImport,
KBImportListResponse,
KBImportNode,
KBCommitResponse,
KBQuotaResponse,
KBListParams,
KBNodeEditRequest,
KBCommitRequest,
} from '@/types/kbAccelerator'
export const kbAcceleratorApi = {
async uploadText(data: { content: string; title?: string; target_type?: string }): Promise<KBUploadResponse> {
const formData = new FormData()
formData.append('content', data.content)
if (data.title) formData.append('title', data.title)
if (data.target_type) formData.append('target_type', data.target_type)
const response = await apiClient.post<KBUploadResponse>('/kb-accelerator/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
},
async uploadFile(file: File, targetType?: string): Promise<KBUploadResponse> {
const formData = new FormData()
formData.append('file', file)
if (targetType) formData.append('target_type', targetType)
const response = await apiClient.post<KBUploadResponse>('/kb-accelerator/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
},
async get(id: string): Promise<KBImport> {
const response = await apiClient.get<KBImport>(`/kb-accelerator/${id}`)
return response.data
},
async list(params?: KBListParams): Promise<KBImportListResponse> {
const response = await apiClient.get<KBImportListResponse>('/kb-accelerator', { params })
return response.data
},
async convert(id: string): Promise<{ status: string }> {
const response = await apiClient.post<{ status: string }>(`/kb-accelerator/${id}/convert`)
return response.data
},
async editNode(importId: string, nodeId: string, data: KBNodeEditRequest): Promise<KBImportNode> {
const response = await apiClient.patch<KBImportNode>(
`/kb-accelerator/${importId}/nodes/${nodeId}`,
data,
)
return response.data
},
async commit(id: string, data?: KBCommitRequest): Promise<KBCommitResponse> {
const response = await apiClient.post<KBCommitResponse>(`/kb-accelerator/${id}/commit`, data)
return response.data
},
async delete(id: string): Promise<void> {
await apiClient.delete(`/kb-accelerator/${id}`)
},
async getQuota(): Promise<KBQuotaResponse> {
const response = await apiClient.get<KBQuotaResponse>('/kb-accelerator/quota')
return response.data
},
}
export default kbAcceleratorApi

View File

@@ -0,0 +1,201 @@
import { useState } from 'react'
import { Check, X, Pencil, Trash2, RotateCcw, Plus, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { KBImportNode, KBNodeEditRequest } from '@/types/kbAccelerator'
interface NodeCardProps {
node: KBImportNode
onEdit: (nodeId: string, data: KBNodeEditRequest) => Promise<void>
onHighlight: (excerpt: string | null) => void
}
function confidenceColor(score: number): string {
if (score >= 0.85) return 'border-emerald-400/40'
if (score >= 0.65) return 'border-amber-400/40'
return 'border-rose-500/40'
}
function confidenceLabel(score: number): string {
if (score >= 0.85) return 'High'
if (score >= 0.65) return 'Medium'
return 'Low'
}
function confidenceTextColor(score: number): string {
if (score >= 0.85) return 'text-emerald-400'
if (score >= 0.65) return 'text-amber-400'
return 'text-rose-500'
}
export function NodeCard({ node, onEdit, onHighlight }: NodeCardProps) {
const [expanded, setExpanded] = useState(false)
const [editMode, setEditMode] = useState(false)
const [editContent, setEditContent] = useState('')
const [busy, setBusy] = useState(false)
const question = (node.content?.question as string) ?? ''
const options = (node.content?.options as Array<{ label: string; next_node_id?: string }>) ?? []
const stepContent = (node.content?.content as string) ?? question
const handleAction = async (operation: KBNodeEditRequest['operation'], extra?: Partial<KBNodeEditRequest>) => {
setBusy(true)
try {
await onEdit(node.id, { operation, ...extra })
if (operation === 'edit') setEditMode(false)
} finally {
setBusy(false)
}
}
const startEdit = () => {
setEditContent(stepContent || question)
setEditMode(true)
}
return (
<div
className={cn(
'glass-card-static border-l-4 p-4 transition-all',
confidenceColor(node.confidence_score),
node.user_approved && 'opacity-75',
)}
onMouseEnter={() => onHighlight(node.source_excerpt)}
onMouseLeave={() => onHighlight(null)}
>
{/* Header */}
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
{node.node_type}
</span>
<span className={cn('font-label text-[0.625rem]', confidenceTextColor(node.confidence_score))}>
{confidenceLabel(node.confidence_score)} ({Math.round(node.confidence_score * 100)}%)
</span>
{node.user_approved && (
<span className="font-label text-[0.625rem] text-emerald-400">Approved</span>
)}
{node.user_edited && (
<span className="font-label text-[0.625rem] text-blue-400">Edited</span>
)}
</div>
{editMode ? (
<div className="space-y-2">
<textarea
value={editContent}
onChange={e => setEditContent(e.target.value)}
className="w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-primary/30 focus:outline-hidden"
rows={3}
/>
<div className="flex gap-2">
<button
onClick={() => handleAction('edit', { content: { ...node.content, question: editContent, content: editContent } })}
disabled={busy}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-gradient-brand text-[#101114] hover:opacity-90"
>
Save
</button>
<button
onClick={() => setEditMode(false)}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground"
>
Cancel
</button>
</div>
</div>
) : (
<p className="text-sm text-foreground">{stepContent || question}</p>
)}
</div>
{/* Actions */}
{!editMode && (
<div className="flex items-center gap-1 shrink-0">
{!node.user_approved && (
<button
onClick={() => handleAction('approve')}
disabled={busy}
className="p-1.5 rounded-md text-muted-foreground hover:text-emerald-400 hover:bg-emerald-400/10 transition-colors"
title="Approve"
>
<Check size={14} />
</button>
)}
{node.user_approved && (
<button
onClick={() => handleAction('reject')}
disabled={busy}
className="p-1.5 rounded-md text-muted-foreground hover:text-amber-400 hover:bg-amber-400/10 transition-colors"
title="Unapprove"
>
<X size={14} />
</button>
)}
<button
onClick={startEdit}
disabled={busy}
className="p-1.5 rounded-md text-muted-foreground hover:text-blue-400 hover:bg-blue-400/10 transition-colors"
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleAction('regenerate')}
disabled={busy}
className="p-1.5 rounded-md text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Regenerate"
>
<RotateCcw size={14} />
</button>
<button
onClick={() => handleAction('insert_after', { content: { question: 'New node', type: node.node_type } })}
disabled={busy}
className="p-1.5 rounded-md text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Insert after"
>
<Plus size={14} />
</button>
<button
onClick={() => handleAction('delete')}
disabled={busy}
className="p-1.5 rounded-md text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 transition-colors"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
{/* Options (troubleshooting) */}
{options.length > 0 && (
<div className="mt-3">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{options.length} option{options.length !== 1 ? 's' : ''}
</button>
{expanded && (
<div className="mt-2 space-y-1 pl-3 border-l border-border">
{options.map((opt, i) => (
<p key={i} className="text-xs text-muted-foreground">
{opt.label} {opt.next_node_id && <span className="text-[#5a6170]"> {opt.next_node_id}</span>}
</p>
))}
</div>
)}
</div>
)}
{/* Source excerpt */}
{node.source_excerpt && (
<p className="mt-2 text-xs text-[#5a6170] italic truncate" title={node.source_excerpt}>
Source: {node.source_excerpt}
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,130 @@
import { useState } from 'react'
import { CheckCircle2, AlertTriangle, BarChart3 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { NodeCard } from './NodeCard'
import { SourcePanel } from './SourcePanel'
import type { KBImport, KBNodeEditRequest, KBCommitRequest } from '@/types/kbAccelerator'
interface ReviewScreenProps {
kbImport: KBImport
onEditNode: (nodeId: string, data: KBNodeEditRequest) => Promise<void>
onCommit: (data?: KBCommitRequest) => Promise<void>
onDiscard: () => Promise<void>
loading: boolean
}
export function ReviewScreen({ kbImport, onEditNode, onCommit, onDiscard, loading }: ReviewScreenProps) {
const [highlightExcerpt, setHighlightExcerpt] = useState<string | null>(null)
const nodes = kbImport.nodes
const approvedCount = nodes.filter(n => n.user_approved).length
const lowConfidenceCount = nodes.filter(n => n.confidence_score < 0.65).length
const avgConfidence = kbImport.confidence_avg ?? 0
const title = (kbImport.source_metadata as Record<string, unknown> | null)
?._conversion as Record<string, unknown> | undefined
const flowTitle = (title?.title as string) ?? 'Untitled Flow'
const flowDescription = (title?.description as string) ?? ''
return (
<div className="flex flex-col h-full gap-4">
{/* Header */}
<div className="glass-card-static p-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-heading font-semibold text-foreground">{flowTitle}</h2>
{flowDescription && (
<p className="text-sm text-muted-foreground mt-0.5">{flowDescription}</p>
)}
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm">
<BarChart3 size={14} className="text-muted-foreground" />
<span className="text-muted-foreground">
Avg confidence: <span className="text-foreground font-medium">{Math.round(avgConfidence * 100)}%</span>
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 size={14} className="text-emerald-400" />
<span className="text-muted-foreground">
{approvedCount}/{nodes.length} approved
</span>
</div>
{lowConfidenceCount > 0 && (
<div className="flex items-center gap-2 text-sm">
<AlertTriangle size={14} className="text-amber-400" />
<span className="text-amber-400">
{lowConfidenceCount} low confidence
</span>
</div>
)}
</div>
</div>
{/* Two-panel layout */}
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-0">
{/* Source panel */}
<div className="overflow-hidden">
<SourcePanel
sourceText={kbImport.source_text}
sourceFormat={kbImport.source_format}
highlightExcerpt={highlightExcerpt}
/>
</div>
{/* Nodes panel */}
<div className="flex flex-col glass-card-static overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 border-b" style={{ borderColor: 'var(--glass-border)' }}>
<span className="text-sm font-medium text-foreground">Generated Flow</span>
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground ml-auto">
{kbImport.target_type === 'troubleshooting' ? 'Troubleshooting' : 'Project'}
</span>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{nodes.map(node => (
<NodeCard
key={node.id}
node={node}
onEdit={onEditNode}
onHighlight={setHighlightExcerpt}
/>
))}
{nodes.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No nodes generated. Try converting again.
</p>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between gap-3">
<button
onClick={onDiscard}
disabled={loading}
className={cn(
'px-4 py-2.5 rounded-[10px] text-sm font-medium transition-colors',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-muted-foreground',
'hover:text-foreground hover:border-[rgba(255,255,255,0.12)]',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
Discard
</button>
<button
onClick={() => onCommit()}
disabled={loading || nodes.length === 0}
className={cn(
'flex items-center gap-2 px-6 py-2.5 rounded-[10px] text-sm font-semibold transition-all',
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20',
'hover:opacity-90 active:scale-[0.97]',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<CheckCircle2 size={16} />
{loading ? 'Committing...' : 'Commit to Library'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { useMemo } from 'react'
import { FileText } from 'lucide-react'
interface SourcePanelProps {
sourceText: string
sourceFormat: string
highlightExcerpt: string | null
}
export function SourcePanel({ sourceText, sourceFormat, highlightExcerpt }: SourcePanelProps) {
const renderedText = useMemo(() => {
if (!highlightExcerpt || !sourceText.includes(highlightExcerpt)) {
return <span>{sourceText}</span>
}
const idx = sourceText.indexOf(highlightExcerpt)
return (
<>
<span>{sourceText.slice(0, idx)}</span>
<mark className="bg-primary/20 text-foreground rounded px-0.5">{highlightExcerpt}</mark>
<span>{sourceText.slice(idx + highlightExcerpt.length)}</span>
</>
)
}, [sourceText, highlightExcerpt])
return (
<div className="glass-card-static flex flex-col h-full">
<div className="flex items-center gap-2 px-4 py-3 border-b" style={{ borderColor: 'var(--glass-border)' }}>
<FileText size={16} className="text-muted-foreground" />
<span className="text-sm font-medium text-foreground">Source Document</span>
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground ml-auto">
{sourceFormat}
</span>
</div>
<div className="flex-1 overflow-y-auto p-4">
<pre className="text-sm text-muted-foreground whitespace-pre-wrap font-sans leading-relaxed">
{renderedText}
</pre>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { CheckCircle2, ArrowRight, Plus } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { KBCommitResponse } from '@/types/kbAccelerator'
interface SuccessScreenProps {
result: KBCommitResponse
onViewFlow: () => void
onConvertAnother: () => void
}
export function SuccessScreen({ result, onViewFlow, onConvertAnother }: SuccessScreenProps) {
return (
<div className="max-w-lg mx-auto text-center space-y-6 py-12">
<div className="flex justify-center">
<div className="w-16 h-16 rounded-full bg-emerald-400/10 flex items-center justify-center">
<CheckCircle2 size={32} className="text-emerald-400" />
</div>
</div>
<div>
<h2 className="text-xl font-heading font-semibold text-foreground">
Flow Created Successfully
</h2>
<p className="text-sm text-muted-foreground mt-2">
Your KB article has been converted into a {result.tree_type === 'troubleshooting' ? 'troubleshooting' : 'project'} flow
and added to your library.
</p>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<button
onClick={onViewFlow}
className={cn(
'flex items-center gap-2 px-6 py-2.5 rounded-[10px] text-sm font-semibold transition-all',
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20',
'hover:opacity-90 active:scale-[0.97]'
)}
>
View Flow
<ArrowRight size={16} />
</button>
<button
onClick={onConvertAnother}
className={cn(
'flex items-center gap-2 px-6 py-2.5 rounded-[10px] text-sm font-medium transition-colors',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
'hover:border-[rgba(255,255,255,0.12)]'
)}
>
<Plus size={16} />
Convert Another
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,269 @@
import { useState, useCallback, useRef } from 'react'
import { Upload, FileText, ClipboardPaste, FileUp, Sparkles, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Textarea } from '@/components/ui/Textarea'
import { Input } from '@/components/ui/Input'
import type { KBQuotaResponse } from '@/types/kbAccelerator'
type TargetType = 'troubleshooting' | 'procedural' | 'auto'
interface UploadScreenProps {
quota: KBQuotaResponse | null
onSubmitText: (content: string, title: string, targetType: TargetType) => void
onSubmitFile: (file: File, targetType: TargetType) => void
loading: boolean
}
const TARGET_TYPES = [
{
value: 'troubleshooting' as const,
label: 'Troubleshooting Flow',
description: 'Decision tree with diagnostic questions and resolutions',
},
{
value: 'procedural' as const,
label: 'Project Flow',
description: 'Step-by-step procedure with warnings and variables',
},
{
value: 'auto' as const,
label: 'Let AI Decide',
description: 'AI will determine the best flow type from your content',
},
]
const FORMAT_LABELS: Record<string, string> = {
txt: 'TXT',
paste: 'Paste',
docx: 'DOCX',
pdf: 'PDF',
html: 'HTML',
md: 'Markdown',
}
export function UploadScreen({ quota, onSubmitText, onSubmitFile, loading }: UploadScreenProps) {
const [mode, setMode] = useState<'paste' | 'file'>('paste')
const [content, setContent] = useState('')
const [title, setTitle] = useState('')
const [targetType, setTargetType] = useState<TargetType>('auto')
const [file, setFile] = useState<File | null>(null)
const [dragOver, setDragOver] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const canSubmit = mode === 'paste'
? content.trim().length >= 10
: file !== null
const handleSubmit = () => {
if (loading) return
if (mode === 'paste') {
onSubmitText(content, title, targetType)
} else if (file) {
onSubmitFile(file, targetType)
}
}
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
const droppedFile = e.dataTransfer.files[0]
if (droppedFile) {
setFile(droppedFile)
setMode('file')
}
}, [])
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0]
if (selected) {
setFile(selected)
}
}
const allowedFormats = quota?.allowed_formats ?? ['txt', 'paste']
const fileFormats = allowedFormats.filter(f => f !== 'paste')
return (
<div className="max-w-3xl mx-auto space-y-6">
{/* Quota info */}
{quota && (
<div className="glass-card-static p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Sparkles size={18} className="text-primary" />
<div>
<p className="text-sm font-medium text-foreground">
{quota.lifetime_conversions_limit
? `${quota.lifetime_conversions_limit - quota.lifetime_conversions_used} conversions remaining`
: 'Unlimited conversions'}
</p>
<p className="text-xs text-muted-foreground">
{quota.plan.charAt(0).toUpperCase() + quota.plan.slice(1)} plan
</p>
</div>
</div>
{!quota.can_convert && (
<div className="flex items-center gap-2 text-amber-400 text-sm">
<AlertCircle size={16} />
<span>Conversion limit reached</span>
</div>
)}
</div>
)}
{/* Mode toggle */}
<div className="flex gap-2">
<button
onClick={() => setMode('paste')}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-[10px] text-sm font-medium transition-colors',
mode === 'paste'
? 'bg-primary/10 text-foreground border border-primary/30'
: 'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)]'
)}
>
<ClipboardPaste size={16} />
Paste Text
</button>
{fileFormats.length > 0 && (
<button
onClick={() => setMode('file')}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-[10px] text-sm font-medium transition-colors',
mode === 'file'
? 'bg-primary/10 text-foreground border border-primary/30'
: 'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)]'
)}
>
<FileUp size={16} />
Upload File
</button>
)}
</div>
{/* Content area */}
<div className="glass-card-static p-5 space-y-4">
{mode === 'paste' ? (
<>
<div>
<label htmlFor="kb-title" className="block font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5">
Title (optional)
</label>
<Input
id="kb-title"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="e.g., Outlook Connectivity Troubleshooting"
maxLength={255}
/>
</div>
<div>
<label htmlFor="kb-content" className="block font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5">
KB Article Content
</label>
<Textarea
id="kb-content"
value={content}
onChange={e => setContent(e.target.value)}
placeholder="Paste your KB article text here..."
rows={12}
maxLength={500000}
/>
<p className="mt-1 text-xs text-muted-foreground">
{content.length.toLocaleString()} / 500,000 characters
</p>
</div>
</>
) : (
<div
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={cn(
'flex flex-col items-center justify-center gap-3 p-10 rounded-xl border-2 border-dashed cursor-pointer transition-colors',
dragOver
? 'border-primary/50 bg-primary/5'
: 'border-[rgba(255,255,255,0.08)] hover:border-[rgba(255,255,255,0.15)]'
)}
>
{file ? (
<>
<FileText size={32} className="text-primary" />
<div className="text-center">
<p className="text-sm font-medium text-foreground">{file.name}</p>
<p className="text-xs text-muted-foreground mt-1">
{(file.size / 1024).toFixed(1)} KB
</p>
<button
onClick={e => { e.stopPropagation(); setFile(null) }}
className="mt-2 text-xs text-primary hover:underline"
>
Remove
</button>
</div>
</>
) : (
<>
<Upload size={32} className="text-muted-foreground" />
<div className="text-center">
<p className="text-sm text-foreground">
Drop a file here or <span className="text-primary">browse</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
Supported: {fileFormats.map(f => FORMAT_LABELS[f] || f.toUpperCase()).join(', ')}
</p>
</div>
</>
)}
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={fileFormats.map(f => `.${f}`).join(',')}
onChange={handleFileSelect}
/>
</div>
)}
</div>
{/* Target type selector */}
<div>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-2">
Target Flow Type
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{TARGET_TYPES.map(t => (
<button
key={t.value}
onClick={() => setTargetType(t.value)}
className={cn(
'glass-card-static p-4 text-left transition-all',
targetType === t.value
? 'border-primary/30 bg-primary/5'
: 'hover:border-[rgba(255,255,255,0.12)]'
)}
>
<p className="text-sm font-medium text-foreground">{t.label}</p>
<p className="text-xs text-muted-foreground mt-1">{t.description}</p>
</button>
))}
</div>
</div>
{/* Submit */}
<button
onClick={handleSubmit}
disabled={!canSubmit || loading || (quota != null && !quota.can_convert)}
className={cn(
'w-full flex items-center justify-center gap-2 px-6 py-3 rounded-[10px] text-sm font-semibold transition-all',
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20',
'hover:opacity-90 active:scale-[0.97]',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<Sparkles size={16} />
{loading ? 'Converting...' : 'Convert to Flow'}
</button>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, BotMessageSquare, BookOpen } from 'lucide-react'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, BotMessageSquare, BookOpen, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
@@ -83,6 +83,7 @@ export function Sidebar() {
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" collapsed />
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
<NavItem href="/kb-accelerator" icon={Sparkles} label="KB Accelerator" collapsed />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
<NavItem href="/guides" icon={BookOpen} label="User Guides" collapsed />
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" collapsed />
@@ -115,6 +116,7 @@ export function Sidebar() {
<NavItem href="/shares" icon={FileText} label="Exports" />
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" />
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
<NavItem href="/kb-accelerator" icon={Sparkles} label="KB Accelerator" />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
</div>
</>

View 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>
)
}

View File

@@ -41,6 +41,7 @@ const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage'))
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
@@ -153,6 +154,7 @@ export const router = sentryCreateBrowserRouter([
{ path: 'analytics/me', element: page(MyAnalyticsPage) },
{ path: 'feedback', element: page(FeedbackPage) },
{ path: 'step-library', element: page(StepLibraryPage) },
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
{ path: 'assistant', element: page(AssistantChatPage) },
{ path: 'guides', element: page(GuidesHubPage) },
{ path: 'guides/:slug', element: page(GuideDetailPage) },

View File

@@ -73,3 +73,17 @@ export type {
ContextMenuPosition,
SuggestionMarker,
} from './editor-ai'
export type {
KBUploadTextRequest,
KBNodeEditRequest,
KBCommitRequest,
KBListParams,
KBImportNode,
KBUploadResponse,
KBImport,
KBImportSummary,
KBImportListResponse,
KBCommitResponse,
KBQuotaResponse,
} from './kbAccelerator'

View File

@@ -0,0 +1,108 @@
/**
* KB Accelerator types — converts KB articles into interactive flows.
*/
// ── Requests ──
export interface KBUploadTextRequest {
content: string
title?: string
target_type?: 'troubleshooting' | 'procedural'
}
export interface KBNodeEditRequest {
operation: 'approve' | 'reject' | 'edit' | 'delete' | 'regenerate' | 'insert_after'
content?: Record<string, unknown>
guidance?: string
}
export interface KBCommitRequest {
name?: string
description?: string
category_id?: string
}
export interface KBListParams {
skip?: number
limit?: number
status?: string
}
// ── Responses ──
export interface KBImportNode {
id: string
kb_import_id: string
node_order: number
node_type: string
content: Record<string, unknown>
parent_node_id: string | null
source_excerpt: string | null
confidence_score: number
user_edited: boolean
user_approved: boolean
}
export interface KBUploadResponse {
id: string
status: string
source_format: string
}
export interface KBImport {
id: string
account_id: string
created_by: string
source_filename: string | null
source_format: string
source_text: string
source_metadata: Record<string, unknown> | null
target_type: string
status: string
confidence_avg: number | null
error_message: string | null
processing_time_ms: number | null
ai_tokens_input: number | null
ai_tokens_output: number | null
tree_id: string | null
nodes: KBImportNode[]
created_at: string
updated_at: string
}
export interface KBImportSummary {
id: string
source_filename: string | null
source_format: string
target_type: string
status: string
confidence_avg: number | null
node_count: number
created_at: string
}
export interface KBImportListResponse {
items: KBImportSummary[]
total: number
skip: number
limit: number
}
export interface KBCommitResponse {
tree_id: string
import_id: string
tree_type: string
}
export interface KBQuotaResponse {
plan: string
kb_accelerator_enabled: boolean
lifetime_conversions_used: number
lifetime_conversions_limit: number | null
allowed_formats: string[]
detailed_analysis: boolean
conversational_refinement: boolean
step_library_matching: boolean
history_limit: number | null
can_convert: boolean
}