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:
@@ -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'
|
||||
|
||||
76
frontend/src/api/kbAccelerator.ts
Normal file
76
frontend/src/api/kbAccelerator.ts
Normal 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
|
||||
201
frontend/src/components/kb-accelerator/NodeCard.tsx
Normal file
201
frontend/src/components/kb-accelerator/NodeCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
130
frontend/src/components/kb-accelerator/ReviewScreen.tsx
Normal file
130
frontend/src/components/kb-accelerator/ReviewScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
frontend/src/components/kb-accelerator/SourcePanel.tsx
Normal file
42
frontend/src/components/kb-accelerator/SourcePanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
frontend/src/components/kb-accelerator/SuccessScreen.tsx
Normal file
56
frontend/src/components/kb-accelerator/SuccessScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
269
frontend/src/components/kb-accelerator/UploadScreen.tsx
Normal file
269
frontend/src/components/kb-accelerator/UploadScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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) },
|
||||
|
||||
@@ -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'
|
||||
|
||||
108
frontend/src/types/kbAccelerator.ts
Normal file
108
frontend/src/types/kbAccelerator.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user