mirror of
http://88.130.71.182:3000/BlitTech/contexta_fe.git
synced 2026-06-12 23:23:22 +00:00
741 lines
30 KiB
TypeScript
741 lines
30 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react'
|
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { chatbotsAPI, documentsAPI, modelsAPI } from '@/services/api'
|
|
import { useAuthStore } from '@/store/authStore'
|
|
import { Button, Input, Textarea, Select, Card, Badge, StatusDot, Spinner } from '@/components/ui'
|
|
import { CATEGORIES, INDUSTRIES, LANGUAGES, formatBytes, getFileIcon } from '@/lib/utils'
|
|
import { ChatInterface } from '@/components/ChatInterface'
|
|
import { useDropzone } from 'react-dropzone'
|
|
import type { ChatbotFormData, AvailableModel } from '@/types'
|
|
import {
|
|
ArrowLeft, Save, Eye, Upload, Trash2, FileText,
|
|
Bot, Sliders, Globe, Code, AlertCircle, CheckCircle,
|
|
ChevronDown, ChevronRight, Settings2, ImagePlus, X
|
|
} from 'lucide-react'
|
|
|
|
const DEFAULT_FORM: ChatbotFormData = {
|
|
name: '',
|
|
description: '',
|
|
system_prompt: '',
|
|
model: 'accounts/fireworks/models/kimi-k2-instruct-0905',
|
|
temperature: 0.7,
|
|
max_tokens: 1000,
|
|
primary_color: '#6366f1',
|
|
welcome_message: 'Hello! How can I help you today?',
|
|
logo_url: '',
|
|
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,
|
|
logo_url: existingChatbot.logo_url || '',
|
|
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)
|
|
}
|
|
|
|
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' : `Edit: ${form.name || 'Untitled'}`}
|
|
</h1>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={handleSave}
|
|
loading={createMutation.isPending || updateMutation.isPending}
|
|
>
|
|
<Save className="w-3.5 h-3.5" />
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 px-6 py-2 bg-gray-50 border-b border-gray-200 overflow-x-auto">
|
|
{([
|
|
{ key: 'settings' as Tab, label: 'Settings', icon: Sliders },
|
|
{ key: 'documents' as Tab, label: 'Documents', icon: FileText },
|
|
{ key: 'preview' as Tab, label: 'Preview', icon: Eye },
|
|
]).map(t => (
|
|
<button
|
|
key={t.key}
|
|
onClick={() => setTab(t.key)}
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
|
|
tab === t.key
|
|
? 'bg-white text-primary-700 shadow-sm'
|
|
: 'text-gray-500 hover:text-gray-700 hover:bg-white/50'
|
|
}`}
|
|
>
|
|
<t.icon className="w-4 h-4" />
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
{tab === 'settings' && (
|
|
<SettingsTab
|
|
form={form}
|
|
setForm={setForm}
|
|
userPlan={user?.plan || 'free'}
|
|
/>
|
|
)}
|
|
{tab === 'documents' && chatbotId && <DocumentsTab chatbotId={chatbotId} />}
|
|
{tab === 'documents' && !chatbotId && (
|
|
<Card className="p-8 text-center">
|
|
<AlertCircle className="w-8 h-8 text-amber-500 mx-auto mb-3" />
|
|
<p className="text-gray-600 text-sm">Save your chatbot first to upload documents.</p>
|
|
</Card>
|
|
)}
|
|
{tab === 'preview' && chatbotId && (
|
|
<div className="h-[calc(100vh-280px)] min-h-[400px] max-h-[700px]">
|
|
<ChatInterface
|
|
chatbotId={chatbotId}
|
|
chatbotName={form.name || 'Preview'}
|
|
welcomeMessage={form.welcome_message}
|
|
primaryColor={form.primary_color}
|
|
logoUrl={form.logo_url}
|
|
isPreview
|
|
/>
|
|
</div>
|
|
)}
|
|
{tab === 'preview' && !chatbotId && (
|
|
<Card className="p-8 text-center">
|
|
<Eye className="w-8 h-8 text-gray-400 mx-auto mb-3" />
|
|
<p className="text-gray-600 text-sm">Save your chatbot first to preview it.</p>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
{/* Toast */}
|
|
{toast && (
|
|
<div className={`fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg text-sm font-medium text-white ${
|
|
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>
|
|
)
|
|
}
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// SETTINGS TAB
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
interface SettingsTabProps {
|
|
form: ChatbotFormData
|
|
setForm: React.Dispatch<React.SetStateAction<ChatbotFormData>>
|
|
userPlan: string
|
|
}
|
|
|
|
const SettingsTab: React.FC<SettingsTabProps> = ({ form, setForm, userPlan }) => {
|
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
|
|
|
const set = (field: keyof ChatbotFormData) => (value: any) => {
|
|
setForm(prev => ({ ...prev, [field]: value }))
|
|
}
|
|
|
|
// ── IMPROVEMENT #3: Load models dynamically from backend ──────────────────
|
|
const { data: modelsData, isLoading: modelsLoading } = useQuery({
|
|
queryKey: ['available-models'],
|
|
queryFn: modelsAPI.available,
|
|
staleTime: 5 * 60 * 1000, // cache for 5 minutes
|
|
})
|
|
|
|
const availableModels = modelsData?.models || []
|
|
const hasPremiumAccess = modelsData?.has_premium_access || false
|
|
const upgradeLabel = modelsData?.upgrade_label || null
|
|
|
|
return (
|
|
<div className="max-w-2xl mx-auto space-y-6">
|
|
{/* Basic Info */}
|
|
<Card className="p-6 space-y-4">
|
|
<h2 className="font-semibold 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="e.g. Customer Support Bot"
|
|
/>
|
|
<Textarea
|
|
label="Description"
|
|
value={form.description}
|
|
onChange={e => set('description')(e.target.value)}
|
|
placeholder="What does this chatbot do?"
|
|
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>
|
|
|
|
{/* ── IMPROVEMENT #2: Appearance with logo upload ──────────────────────── */}
|
|
<Card className="p-6 space-y-4">
|
|
<h2 className="font-semibold text-sm uppercase tracking-wide text-gray-500">Appearance</h2>
|
|
|
|
{/* Logo upload */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Chatbot Logo</label>
|
|
<p className="text-xs text-gray-400 mb-2">Upload your company logo. It will appear in the chat header.</p>
|
|
<LogoUploader logoUrl={form.logo_url} onLogoChange={set('logo_url')} />
|
|
</div>
|
|
|
|
{/* Brand Color */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Brand Color</label>
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<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 hover:scale-110 transition-transform"
|
|
style={{ background: c }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* ── IMPROVEMENT #1: Advanced Settings (collapsible) ──────────────────── */}
|
|
<Card className="overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setAdvancedOpen(!advancedOpen)}
|
|
className="w-full flex items-center justify-between p-6 text-left hover:bg-gray-50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Settings2 className="w-4 h-4 text-gray-500" />
|
|
<h2 className="font-semibold text-sm uppercase tracking-wide text-gray-500">
|
|
Advanced Settings
|
|
</h2>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-gray-400">AI Model, Temperature, Max Tokens</span>
|
|
{advancedOpen
|
|
? <ChevronDown className="w-4 h-4 text-gray-400" />
|
|
: <ChevronRight className="w-4 h-4 text-gray-400" />
|
|
}
|
|
</div>
|
|
</button>
|
|
|
|
{advancedOpen && (
|
|
<div className="px-6 pb-6 space-y-4 border-t border-gray-100 pt-4">
|
|
{/* AI Model Selection - loaded from backend */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">AI Model</label>
|
|
|
|
{modelsLoading ? (
|
|
<div className="flex items-center gap-2 p-4 bg-gray-50 rounded-xl">
|
|
<Spinner className="w-4 h-4 text-primary-600" />
|
|
<span className="text-sm text-gray-500">Loading available models...</span>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{availableModels.map((m: AvailableModel) => (
|
|
<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 min-w-0">
|
|
<p className="text-sm font-medium text-gray-900">
|
|
{m.name}
|
|
{m.is_default && (
|
|
<span className="ml-1.5 text-xs text-primary-600 font-normal">(default)</span>
|
|
)}
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
{m.provider}
|
|
{m.description && ` · ${m.description}`}
|
|
</p>
|
|
</div>
|
|
<Badge variant={
|
|
m.badge === 'Powerful' ? 'purple'
|
|
: m.badge === 'Free' ? 'default'
|
|
: 'default'
|
|
}>
|
|
{m.badge}
|
|
</Badge>
|
|
</label>
|
|
))}
|
|
|
|
{availableModels.length === 0 && !modelsLoading && (
|
|
<div className="p-4 bg-amber-50 rounded-xl border border-amber-200 text-center">
|
|
<p className="text-sm text-amber-700">
|
|
No models available on your current plan.{' '}
|
|
<Link to="/pricing" className="text-primary-600 font-medium underline">
|
|
Upgrade
|
|
</Link>{' '}
|
|
to access AI models.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{upgradeLabel && (
|
|
<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">
|
|
{upgradeLabel}
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Temperature & Max Tokens */}
|
|
<div className="grid grid-cols-1 sm: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>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Classification */}
|
|
<Card className="p-6 space-y-4">
|
|
<h2 className="font-semibold text-sm uppercase tracking-wide text-gray-500">Classification</h2>
|
|
<div className="grid grid-cols-1 sm: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>
|
|
)
|
|
}
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// LOGO UPLOADER COMPONENT
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
interface LogoUploaderProps {
|
|
logoUrl: string
|
|
onLogoChange: (url: string) => void
|
|
}
|
|
|
|
const LogoUploader: React.FC<LogoUploaderProps> = ({ logoUrl, onLogoChange }) => {
|
|
const [uploading, setUploading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
const handleFile = useCallback(async (file: File) => {
|
|
setError('')
|
|
|
|
// Validate file type
|
|
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/svg+xml', 'image/webp']
|
|
if (!allowedTypes.includes(file.type)) {
|
|
setError('Please upload a PNG, JPG, GIF, SVG, or WebP image.')
|
|
return
|
|
}
|
|
|
|
// Validate file size (max 2MB)
|
|
if (file.size > 2 * 1024 * 1024) {
|
|
setError('Image must be under 2MB.')
|
|
return
|
|
}
|
|
|
|
setUploading(true)
|
|
try {
|
|
// Convert to base64 data URL for now.
|
|
// In production, you'd upload to Supabase Storage and get a public URL back.
|
|
// e.g.: const { data } = await supabase.storage.from('logos').upload(path, file)
|
|
// onLogoChange(supabase.storage.from('logos').getPublicUrl(data.path).data.publicUrl)
|
|
const reader = new FileReader()
|
|
reader.onloadend = () => {
|
|
onLogoChange(reader.result as string)
|
|
setUploading(false)
|
|
}
|
|
reader.onerror = () => {
|
|
setError('Failed to read file.')
|
|
setUploading(false)
|
|
}
|
|
reader.readAsDataURL(file)
|
|
} catch {
|
|
setError('Upload failed. Please try again.')
|
|
setUploading(false)
|
|
}
|
|
}, [onLogoChange])
|
|
|
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
onDrop: (files) => files[0] && handleFile(files[0]),
|
|
accept: {
|
|
'image/png': ['.png'],
|
|
'image/jpeg': ['.jpg', '.jpeg'],
|
|
'image/gif': ['.gif'],
|
|
'image/svg+xml': ['.svg'],
|
|
'image/webp': ['.webp'],
|
|
},
|
|
maxFiles: 1,
|
|
multiple: false,
|
|
})
|
|
|
|
if (logoUrl) {
|
|
return (
|
|
<div className="flex items-center gap-4">
|
|
<div className="relative group">
|
|
<img
|
|
src={logoUrl}
|
|
alt="Chatbot logo"
|
|
className="w-16 h-16 rounded-xl object-cover border border-gray-200 shadow-sm"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => onLogoChange('')}
|
|
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:bg-red-600"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-sm text-gray-700 font-medium">Logo uploaded</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => onLogoChange('')}
|
|
className="text-xs text-red-500 hover:text-red-600 mt-0.5"
|
|
>
|
|
Remove logo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
{...getRootProps()}
|
|
className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-colors ${
|
|
isDragActive
|
|
? 'border-primary-400 bg-primary-50'
|
|
: 'border-gray-300 hover:border-gray-400 bg-gray-50'
|
|
}`}
|
|
>
|
|
<input {...getInputProps()} />
|
|
{uploading ? (
|
|
<div className="flex items-center justify-center gap-2">
|
|
<Spinner className="w-4 h-4 text-primary-600" />
|
|
<span className="text-sm text-gray-500">Processing...</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<ImagePlus className="w-8 h-8 text-gray-400 mx-auto mb-2" />
|
|
<p className="text-sm text-gray-600">
|
|
{isDragActive ? 'Drop your logo here' : 'Click or drag to upload a logo'}
|
|
</p>
|
|
<p className="text-xs text-gray-400 mt-1">PNG, JPG, SVG, or WebP · Max 2MB</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
{error && (
|
|
<p className="text-xs text-red-500 mt-1.5 flex items-center gap-1">
|
|
<AlertCircle className="w-3 h-3" /> {error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// DOCUMENTS TAB (unchanged from original)
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
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: (query) => {
|
|
const data = query.state.data
|
|
if (data && Array.isArray(data)) {
|
|
const hasProcessing = data.some((d: any) => 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] }),
|
|
})
|
|
|
|
const handleUpload = async (files: File[]) => {
|
|
if (!files.length) return
|
|
setUploading(true)
|
|
setUploadProgress(0)
|
|
|
|
try {
|
|
for (const file of files) {
|
|
await documentsAPI.upload(chatbotId, file, (pct) => setUploadProgress(pct))
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ['documents', chatbotId] })
|
|
setToast('Documents uploaded successfully!')
|
|
setTimeout(() => setToast(''), 3000)
|
|
} catch (err: any) {
|
|
setToast(err.response?.data?.detail || 'Upload failed')
|
|
setTimeout(() => setToast(''), 3000)
|
|
} finally {
|
|
setUploading(false)
|
|
setUploadProgress(0)
|
|
}
|
|
}
|
|
|
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
onDrop: handleUpload,
|
|
accept: {
|
|
'application/pdf': ['.pdf'],
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
|
'text/csv': ['.csv'],
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
|
'text/plain': ['.txt'],
|
|
'text/markdown': ['.md'],
|
|
},
|
|
})
|
|
|
|
return (
|
|
<div className="max-w-2xl mx-auto space-y-6">
|
|
{/* Dropzone */}
|
|
<Card className="p-6">
|
|
<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()} />
|
|
<Upload className="w-10 h-10 text-gray-400 mx-auto mb-3" />
|
|
<p className="text-sm text-gray-600 font-medium">
|
|
{isDragActive ? 'Drop files here' : 'Click or drag files to upload'}
|
|
</p>
|
|
<p className="text-xs text-gray-400 mt-1">PDF, DOCX, CSV, XLSX, TXT, MD</p>
|
|
</div>
|
|
|
|
{uploading && (
|
|
<div className="mt-4">
|
|
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
|
|
<span>Uploading...</span>
|
|
<span>{uploadProgress}%</span>
|
|
</div>
|
|
<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>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Document list */}
|
|
{isLoading ? (
|
|
<div className="flex justify-center py-8">
|
|
<Spinner className="text-primary-600" />
|
|
</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-gray-500 text-sm">No documents yet. Upload files to build your chatbot's knowledge base.</p>
|
|
</Card>
|
|
) : (
|
|
<Card className="divide-y divide-gray-100">
|
|
{documents.map((doc: any) => (
|
|
<div key={doc.id} className="flex items-center gap-3 p-4">
|
|
<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>
|
|
<p className="text-xs text-gray-400">
|
|
{formatBytes(doc.file_size)}
|
|
{doc.chunk_count > 0 && ` · ${doc.chunk_count} chunks`}
|
|
</p>
|
|
</div>
|
|
<StatusDot
|
|
status={doc.status === 'completed' ? 'success' : doc.status === 'failed' ? 'error' : 'warning'}
|
|
label={doc.status}
|
|
/>
|
|
<button
|
|
onClick={() => deleteMutation.mutate(doc.id)}
|
|
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</Card>
|
|
)}
|
|
|
|
{/* Toast */}
|
|
{toast && (
|
|
<div className="fixed bottom-6 right-6 z-50 bg-gray-900 text-white px-4 py-2.5 rounded-xl shadow-lg text-sm">
|
|
{toast}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
} |