- export_generated: session copy, copy-for-ticket, download - ai_feature_used: copilot, assistant chat, session-to-flow, KB accelerator, flow assist - psa_connected: ConnectWise integration creation - session_shared: share link creation - flow_created: troubleshooting editor, procedural editor, session-to-flow All 9 events from the product analytics plan are now fully wired. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
237 lines
7.7 KiB
TypeScript
237 lines
7.7 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { Sparkles, Loader2 } from 'lucide-react'
|
|
import { analytics } from '@/lib/analytics'
|
|
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'
|
|
|
|
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,
|
|
})
|
|
setImportId(resp.id)
|
|
analytics.aiFeatureUsed({ feature: 'kb_accelerator' })
|
|
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)
|
|
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 handleApproveAll = async () => {
|
|
if (!importId || !kbImport) return
|
|
const unapproved = kbImport.nodes.filter(n => !n.user_approved)
|
|
if (unapproved.length === 0) return
|
|
setLoading(true)
|
|
try {
|
|
await Promise.all(
|
|
unapproved.map(n => kbAcceleratorApi.editNode(importId, n.id, { operation: 'approve' }))
|
|
)
|
|
setKbImport(prev => {
|
|
if (!prev) return prev
|
|
return { ...prev, nodes: prev.nodes.map(n => ({ ...n, user_approved: true })) }
|
|
})
|
|
} catch {
|
|
toast.error('Failed to approve all nodes')
|
|
} 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 detail = (err as { response?: { data?: { detail?: string | { message?: string; validation_errors?: string[] } } } })?.response?.data?.detail
|
|
if (typeof detail === 'object' && detail !== null) {
|
|
const msg = detail.message || 'Commit failed'
|
|
const errors = detail.validation_errors
|
|
toast.error(errors?.length ? `${msg}\n${errors.join('\n')}` : msg)
|
|
} else {
|
|
toast.error(typeof detail === 'string' ? detail : 'Commit failed')
|
|
}
|
|
} 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 min-h-0 p-6">
|
|
{/* Page title */}
|
|
<div className="shrink-0 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 && (
|
|
<div className="flex-1 min-h-0">
|
|
<ReviewScreen
|
|
kbImport={kbImport}
|
|
onEditNode={handleEditNode}
|
|
onApproveAll={handleApproveAll}
|
|
onCommit={handleCommit}
|
|
onDiscard={handleDiscard}
|
|
loading={loading}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{phase === 'success' && commitResult && (
|
|
<SuccessScreen
|
|
result={commitResult}
|
|
onViewFlow={() => {
|
|
const path = getTreeEditorPath(commitResult.tree_id, commitResult.tree_type as 'troubleshooting' | 'procedural')
|
|
navigate(path)
|
|
}}
|
|
onConvertAnother={resetWizard}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|