mirror of
http://88.130.71.182:3000/BlitTech/contexta_fe.git
synced 2026-06-13 08:54:09 +00:00
Initial commit
This commit is contained in:
446
src/pages/ChatbotBuilderPage.tsx
Normal file
446
src/pages/ChatbotBuilderPage.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { chatbotsAPI, documentsAPI } from '@/services/api'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Button, Input, Textarea, Select, Card, Badge, StatusDot, Spinner } from '@/components/ui'
|
||||
import { AVAILABLE_MODELS, CATEGORIES, INDUSTRIES, LANGUAGES, formatBytes, getFileIcon } from '@/lib/utils'
|
||||
import { ChatInterface } from '@/components/ChatInterface'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import type { ChatbotFormData } from '@/types'
|
||||
import {
|
||||
ArrowLeft, Save, Eye, Upload, Trash2, FileText,
|
||||
Bot, Sliders, Globe, Code, AlertCircle, CheckCircle
|
||||
} from 'lucide-react'
|
||||
|
||||
const DEFAULT_FORM: ChatbotFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
system_prompt: '',
|
||||
model: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
primary_color: '#6366f1',
|
||||
welcome_message: 'Hello! How can I help you today?',
|
||||
category: '',
|
||||
industry: '',
|
||||
languages: ['en'],
|
||||
}
|
||||
|
||||
type Tab = 'settings' | 'documents' | 'preview'
|
||||
|
||||
export const ChatbotBuilderPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const isNew = !id || id === 'new'
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { user } = useAuthStore()
|
||||
const [tab, setTab] = useState<Tab>('settings')
|
||||
const [form, setForm] = useState<ChatbotFormData>(DEFAULT_FORM)
|
||||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null)
|
||||
const [chatbotId, setChatbotId] = useState<string | null>(isNew ? null : id || null)
|
||||
|
||||
// Load existing chatbot
|
||||
const { data: existingChatbot, isLoading: loadingChatbot } = useQuery({
|
||||
queryKey: ['chatbot', id],
|
||||
queryFn: () => chatbotsAPI.get(id!),
|
||||
enabled: !isNew,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (existingChatbot) {
|
||||
setForm({
|
||||
name: existingChatbot.name,
|
||||
description: existingChatbot.description || '',
|
||||
system_prompt: existingChatbot.system_prompt || '',
|
||||
model: existingChatbot.model,
|
||||
temperature: existingChatbot.temperature,
|
||||
max_tokens: existingChatbot.max_tokens,
|
||||
primary_color: existingChatbot.primary_color,
|
||||
welcome_message: existingChatbot.welcome_message,
|
||||
category: existingChatbot.category || '',
|
||||
industry: existingChatbot.industry || '',
|
||||
languages: existingChatbot.languages,
|
||||
})
|
||||
setChatbotId(existingChatbot.id)
|
||||
}
|
||||
}, [existingChatbot])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: chatbotsAPI.create,
|
||||
onSuccess: (data) => {
|
||||
setChatbotId(data.id)
|
||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||
navigate(`/chatbots/${data.id}/edit`, { replace: true })
|
||||
showToast('Chatbot created!', 'success')
|
||||
},
|
||||
onError: (err: any) => showToast(err.response?.data?.detail || 'Failed to create', 'error'),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Partial<ChatbotFormData>) => chatbotsAPI.update(chatbotId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['chatbot', chatbotId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||
showToast('Settings saved!', 'success')
|
||||
},
|
||||
onError: (err: any) => showToast(err.response?.data?.detail || 'Save failed', 'error'),
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.name.trim()) { showToast('Chatbot name is required', 'error'); return }
|
||||
if (isNew || !chatbotId) {
|
||||
createMutation.mutate(form)
|
||||
} else {
|
||||
updateMutation.mutate(form)
|
||||
}
|
||||
}
|
||||
|
||||
const showToast = (msg: string, type: 'success' | 'error') => {
|
||||
setToast({ msg, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
const availableModels = AVAILABLE_MODELS.filter(m =>
|
||||
m.plans.includes(user?.plan || 'free') || m.plans.includes('starter')
|
||||
)
|
||||
|
||||
if (loadingChatbot) {
|
||||
return <div className="flex items-center justify-center h-full"><Spinner className="text-primary-600" /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center gap-4 px-6 py-4 bg-white border-b border-gray-200">
|
||||
<Link to="/dashboard" className="p-1.5 hover:bg-gray-100 rounded-lg">
|
||||
<ArrowLeft className="w-4 h-4 text-gray-600" />
|
||||
</Link>
|
||||
<h1 className="font-semibold text-gray-900 flex-1">
|
||||
{isNew ? 'Create Chatbot' : form.name || 'Edit Chatbot'}
|
||||
</h1>
|
||||
{existingChatbot && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusDot status={existingChatbot.is_published ? 'published' : 'preview'} />
|
||||
<span className="text-xs text-gray-500">
|
||||
{existingChatbot.is_published ? 'Published' : 'Preview only'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-6 py-2 bg-white border-b border-gray-100">
|
||||
{([
|
||||
{ id: 'settings', label: 'Settings', icon: Sliders },
|
||||
{ id: 'documents', label: 'Documents', icon: FileText, disabled: !chatbotId },
|
||||
{ id: 'preview', label: 'Preview', icon: Eye, disabled: !chatbotId },
|
||||
] as const).map(({ id: tid, label, icon: Icon, disabled }) => (
|
||||
<button
|
||||
key={tid}
|
||||
onClick={() => !disabled && setTab(tid as Tab)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
tab === tid
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: disabled
|
||||
? 'text-gray-300 cursor-not-allowed'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{tab === 'settings' && (
|
||||
<SettingsTab form={form} setForm={setForm} availableModels={availableModels} userPlan={user?.plan || 'free'} />
|
||||
)}
|
||||
{tab === 'documents' && chatbotId && (
|
||||
<DocumentsTab chatbotId={chatbotId} />
|
||||
)}
|
||||
{tab === 'preview' && chatbotId && existingChatbot && (
|
||||
<div className="p-6 max-w-2xl mx-auto">
|
||||
<div className="h-[600px]">
|
||||
<ChatInterface
|
||||
chatbotId={chatbotId}
|
||||
chatbotName={existingChatbot.name}
|
||||
welcomeMessage={existingChatbot.welcome_message}
|
||||
primaryColor={existingChatbot.primary_color}
|
||||
isPreview
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`fixed bottom-4 right-4 px-4 py-2 rounded-lg text-sm shadow-lg z-50 text-white flex items-center gap-2 ${
|
||||
toast.type === 'success' ? 'bg-green-600' : 'bg-red-600'
|
||||
}`}>
|
||||
{toast.type === 'success' ? <CheckCircle className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const SettingsTab: React.FC<{
|
||||
form: ChatbotFormData
|
||||
setForm: React.Dispatch<React.SetStateAction<ChatbotFormData>>
|
||||
availableModels: typeof AVAILABLE_MODELS
|
||||
userPlan: string
|
||||
}> = ({ form, setForm, availableModels, userPlan }) => {
|
||||
const set = (key: keyof ChatbotFormData) => (val: any) => setForm(f => ({ ...f, [key]: val }))
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">Basic Info</h2>
|
||||
<Input label="Chatbot Name *" value={form.name} onChange={e => set('name')(e.target.value)} placeholder="My Support Bot" />
|
||||
<Textarea label="Description" value={form.description} onChange={e => set('description')(e.target.value)} placeholder="What does this chatbot help with?" rows={2} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Welcome Message</label>
|
||||
<Textarea value={form.welcome_message} onChange={e => set('welcome_message')(e.target.value)} rows={2} />
|
||||
</div>
|
||||
<Textarea label="System Prompt (optional)" value={form.system_prompt} onChange={e => set('system_prompt')(e.target.value)} placeholder="You are a helpful assistant for..." rows={3} hint="Custom instructions for the AI's behavior and personality" />
|
||||
</Card>
|
||||
|
||||
{/* Appearance */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">Appearance</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Brand Color</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="color" value={form.primary_color} onChange={e => set('primary_color')(e.target.value)}
|
||||
className="w-10 h-10 rounded-lg border border-gray-200 cursor-pointer" />
|
||||
<Input value={form.primary_color} onChange={e => set('primary_color')(e.target.value)} className="w-32" />
|
||||
<div className="flex gap-2">
|
||||
{['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'].map(c => (
|
||||
<button key={c} onClick={() => set('primary_color')(c)}
|
||||
className="w-6 h-6 rounded-full border-2 border-white shadow-sm"
|
||||
style={{ background: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* AI Model */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">AI Model</h2>
|
||||
<div className="space-y-2">
|
||||
{availableModels.map(m => (
|
||||
<label key={m.id} className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-colors ${
|
||||
form.model === m.id ? 'border-primary-400 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}>
|
||||
<input type="radio" name="model" value={m.id} checked={form.model === m.id} onChange={() => set('model')(m.id)} className="sr-only" />
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-xs font-bold text-gray-600">
|
||||
{m.provider[0]}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{m.name}</p>
|
||||
<p className="text-xs text-gray-500">{m.provider}</p>
|
||||
</div>
|
||||
<Badge variant={m.badge === 'Powerful' ? 'purple' : 'default'}>{m.badge}</Badge>
|
||||
</label>
|
||||
))}
|
||||
{userPlan === 'free' || userPlan === 'starter' ? (
|
||||
<div className="p-3 bg-gray-50 rounded-xl border border-dashed border-gray-300 text-center">
|
||||
<p className="text-xs text-gray-500">🔒 <Link to="/pricing" className="text-primary-600 font-medium">Upgrade to Pro</Link> for GPT-4o, Claude, Gemini</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Temperature</label>
|
||||
<input type="range" min="0" max="1" step="0.1" value={form.temperature}
|
||||
onChange={e => set('temperature')(parseFloat(e.target.value))}
|
||||
className="w-full accent-primary-600" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>Precise ({form.temperature})</span>
|
||||
<span>Creative</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label="Max Tokens"
|
||||
type="number"
|
||||
value={form.max_tokens}
|
||||
onChange={e => set('max_tokens')(parseInt(e.target.value))}
|
||||
min={100} max={8000}
|
||||
hint="Max response length"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Classification */}
|
||||
<Card className="p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900 text-sm uppercase tracking-wide text-gray-500">Classification</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Category"
|
||||
value={form.category}
|
||||
onChange={e => set('category')(e.target.value)}
|
||||
options={[{ value: '', label: 'Select category' }, ...CATEGORIES.map(c => ({ value: c, label: c }))]}
|
||||
/>
|
||||
<Select
|
||||
label="Industry"
|
||||
value={form.industry}
|
||||
onChange={e => set('industry')(e.target.value)}
|
||||
options={[{ value: '', label: 'Select industry' }, ...INDUSTRIES.map(i => ({ value: i, label: i }))]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const DocumentsTab: React.FC<{ chatbotId: string }> = ({ chatbotId }) => {
|
||||
const queryClient = useQueryClient()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [toast, setToast] = useState('')
|
||||
|
||||
const { data: documents = [], isLoading } = useQuery({
|
||||
queryKey: ['documents', chatbotId],
|
||||
queryFn: () => documentsAPI.list(chatbotId),
|
||||
refetchInterval: (data) => {
|
||||
if (data && Array.isArray(data)) {
|
||||
const hasProcessing = data.some(d => d.status === 'processing' || d.status === 'pending')
|
||||
return hasProcessing ? 3000 : false
|
||||
}
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (docId: string) => documentsAPI.delete(chatbotId, docId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['chatbots'] })
|
||||
},
|
||||
})
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
'text/csv': ['.csv'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'text/plain': ['.txt'],
|
||||
},
|
||||
multiple: true,
|
||||
onDrop: async (files) => {
|
||||
for (const file of files) {
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
await documentsAPI.upload(chatbotId, file, setUploadProgress)
|
||||
queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] })
|
||||
setToast(`${file.name} uploaded successfully`)
|
||||
} catch (err: any) {
|
||||
setToast(err.response?.data?.detail || `Failed to upload ${file.name}`)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto space-y-4">
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragActive ? 'border-primary-400 bg-primary-50' : 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{uploading ? (
|
||||
<div className="space-y-3">
|
||||
<Spinner className="mx-auto text-primary-600" />
|
||||
<p className="text-sm text-gray-600">Uploading & processing... {uploadProgress}%</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div className="bg-primary-600 h-1.5 rounded-full transition-all" style={{ width: `${uploadProgress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-8 h-8 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{isDragActive ? 'Drop files here' : 'Drag & drop files or click to browse'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Supports PDF, DOCX, CSV, XLSX, TXT • Max 50MB</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Documents list */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8"><Spinner /></div>
|
||||
) : documents.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<FileText className="w-8 h-8 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-gray-500">No documents yet</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Upload files to give your chatbot knowledge</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{documents.map((doc) => (
|
||||
<Card key={doc.id} className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{getFileIcon(doc.file_type)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{doc.file_name}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${
|
||||
doc.status === 'completed' ? 'bg-green-500' :
|
||||
doc.status === 'failed' ? 'bg-red-500' : 'bg-yellow-500 animate-pulse'
|
||||
}`} />
|
||||
<p className="text-xs text-gray-400 capitalize">
|
||||
{doc.status === 'completed' ? `${doc.chunk_count} chunks indexed` :
|
||||
doc.status === 'failed' ? doc.error_message || 'Processing failed' :
|
||||
'Processing...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(doc.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toast && (
|
||||
<div className="fixed bottom-4 right-4 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50">
|
||||
{toast}
|
||||
<button onClick={() => setToast('')} className="ml-3 opacity-60 hover:opacity-100">×</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user