Files
resolutionflow/frontend/src/pages/KBAcceleratorPage.tsx
chihlasm c44edc5088 feat: wire remaining PostHog events across all key user actions (#111)
- 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>
2026-03-16 18:49:01 -04:00

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