knowledge-sheet.tsx
1 'use client' 2 3 import { useCallback, useEffect, useRef, useState } from 'react' 4 import { api } from '@/lib/app/api-client' 5 import { useAppStore } from '@/stores/use-app-store' 6 import { BottomSheet } from '@/components/shared/bottom-sheet' 7 import { AgentAvatar } from '@/components/agents/agent-avatar' 8 import type { KnowledgeSourceDetail, KnowledgeSourceKind } from '@/types' 9 import { toast } from 'sonner' 10 11 const ACCEPTED_TYPES = '.txt,.md,.csv,.json,.jsonl,.html,.xml,.yaml,.yml,.toml,.py,.js,.ts,.tsx,.jsx,.go,.rs,.java,.c,.cpp,.h,.rb,.php,.sh,.sql,.log,.pdf' 12 13 interface UploadResult { 14 title: string 15 content: string 16 filePath: string 17 url: string 18 filename: string 19 size: number 20 } 21 22 export function KnowledgeSheet() { 23 const open = useAppStore((state) => state.knowledgeSheetOpen) 24 const setOpen = useAppStore((state) => state.setKnowledgeSheetOpen) 25 const editingId = useAppStore((state) => state.editingKnowledgeId) 26 const setEditingKnowledgeId = useAppStore((state) => state.setEditingKnowledgeId) 27 const setSelectedKnowledgeSourceId = useAppStore((state) => state.setSelectedKnowledgeSourceId) 28 const triggerKnowledgeRefresh = useAppStore((state) => state.triggerKnowledgeRefresh) 29 const agents = useAppStore((state) => state.agents) 30 const loadAgents = useAppStore((state) => state.loadAgents) 31 32 const [kind, setKind] = useState<KnowledgeSourceKind>('manual') 33 const [title, setTitle] = useState('') 34 const [content, setContent] = useState('') 35 const [tags, setTags] = useState('') 36 const [scope, setScope] = useState<'global' | 'agent'>('global') 37 const [agentIds, setAgentIds] = useState<string[]>([]) 38 const [sourceUrl, setSourceUrl] = useState('') 39 const [sourcePath, setSourcePath] = useState('') 40 const [sourceLabel, setSourceLabel] = useState('') 41 const [saving, setSaving] = useState(false) 42 const [uploading, setUploading] = useState(false) 43 const [isDragging, setIsDragging] = useState(false) 44 const [uploadedFile, setUploadedFile] = useState<{ name: string; url: string; size: number | null } | null>(null) 45 46 const dragCounter = useRef(0) 47 const fileInputRef = useRef<HTMLInputElement>(null) 48 const agentList = Object.values(agents) 49 50 useEffect(() => { 51 if (open) loadAgents() 52 }, [loadAgents, open]) 53 54 const resetForm = useCallback(() => { 55 setKind('manual') 56 setTitle('') 57 setContent('') 58 setTags('') 59 setScope('global') 60 setAgentIds([]) 61 setSourceUrl('') 62 setSourcePath('') 63 setSourceLabel('') 64 setUploadedFile(null) 65 setIsDragging(false) 66 dragCounter.current = 0 67 }, []) 68 69 useEffect(() => { 70 if (!open) return 71 if (!editingId) { 72 resetForm() 73 return 74 } 75 76 resetForm() 77 void api<KnowledgeSourceDetail>('GET', `/knowledge/sources/${editingId}`).then((detail) => { 78 const { source } = detail 79 setKind(source.kind) 80 setTitle(source.title) 81 setContent(source.content || '') 82 setTags(source.tags.join(', ')) 83 setScope(source.scope) 84 setAgentIds(source.agentIds) 85 setSourceUrl(source.sourceUrl || '') 86 setSourcePath(source.sourcePath || '') 87 setSourceLabel(source.sourceLabel || '') 88 setUploadedFile(source.kind === 'file' 89 ? { name: source.sourceLabel || source.title, url: source.sourceUrl || '', size: null } 90 : null) 91 }).catch(() => { 92 toast.error('Unable to load this knowledge source') 93 setOpen(false) 94 }) 95 }, [editingId, open, resetForm, setOpen]) 96 97 const onClose = useCallback(() => { 98 setOpen(false) 99 setEditingKnowledgeId(null) 100 resetForm() 101 }, [resetForm, setEditingKnowledgeId, setOpen]) 102 103 const parseTags = (raw: string): string[] => 104 raw.split(',').map((tag) => tag.trim()).filter(Boolean) 105 106 const toggleAgent = (id: string) => { 107 setAgentIds((current) => current.includes(id) ? current.filter((agentId) => agentId !== id) : [...current, id]) 108 } 109 110 const handleUpload = useCallback(async (file: File) => { 111 setUploading(true) 112 try { 113 const response = await fetch('/api/knowledge/upload', { 114 method: 'POST', 115 headers: { 'X-Filename': file.name }, 116 body: file, 117 }) 118 119 if (!response.ok) { 120 const payload = await response.json().catch(() => ({ error: 'Upload failed' })) 121 toast.error(payload.error || 'Upload failed') 122 return 123 } 124 125 const result: UploadResult = await response.json() 126 setKind('file') 127 setTitle((current) => current.trim() || result.title) 128 setContent(result.content) 129 setSourcePath(result.filePath) 130 setSourceUrl(result.url) 131 setSourceLabel(result.filename) 132 setUploadedFile({ name: result.filename, url: result.url, size: result.size }) 133 toast.success('Document content extracted') 134 135 const ext = file.name.split('.').pop()?.toLowerCase() || '' 136 if (ext) { 137 setTags((current) => current.includes(ext) ? current : current ? `${current}, ${ext}` : ext) 138 } 139 } catch (error) { 140 toast.error(error instanceof Error ? error.message : 'Upload failed') 141 } finally { 142 setUploading(false) 143 } 144 }, []) 145 146 const handleFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { 147 const file = event.target.files?.[0] 148 if (file) void handleUpload(file) 149 event.target.value = '' 150 }, [handleUpload]) 151 152 const handleDragOver = useCallback((event: React.DragEvent) => { 153 event.preventDefault() 154 event.stopPropagation() 155 }, []) 156 157 const handleDragEnter = useCallback((event: React.DragEvent) => { 158 event.preventDefault() 159 event.stopPropagation() 160 dragCounter.current += 1 161 if (event.dataTransfer.types.includes('Files')) setIsDragging(true) 162 }, []) 163 164 const handleDragLeave = useCallback((event: React.DragEvent) => { 165 event.preventDefault() 166 event.stopPropagation() 167 dragCounter.current -= 1 168 if (dragCounter.current === 0) setIsDragging(false) 169 }, []) 170 171 const handleDrop = useCallback((event: React.DragEvent) => { 172 event.preventDefault() 173 event.stopPropagation() 174 dragCounter.current = 0 175 setIsDragging(false) 176 const file = event.dataTransfer.files?.[0] 177 if (file) void handleUpload(file) 178 }, [handleUpload]) 179 180 const handleSave = async () => { 181 if (kind === 'manual' && !content.trim()) { 182 toast.error('Manual knowledge needs content') 183 return 184 } 185 if (kind === 'file' && !sourcePath && !content.trim()) { 186 toast.error('Upload a file or provide extracted content') 187 return 188 } 189 if (kind === 'url' && !sourceUrl.trim()) { 190 toast.error('A URL is required for URL knowledge') 191 return 192 } 193 194 setSaving(true) 195 try { 196 const payload = { 197 kind, 198 title: title.trim(), 199 content, 200 tags: parseTags(tags), 201 scope, 202 agentIds: scope === 'agent' ? agentIds : [], 203 sourceUrl: sourceUrl.trim() || undefined, 204 sourcePath: sourcePath.trim() || undefined, 205 sourceLabel: sourceLabel.trim() || undefined, 206 metadata: uploadedFile?.size != null 207 ? { fileSize: uploadedFile.size } 208 : undefined, 209 } 210 211 const detail = editingId 212 ? await api<KnowledgeSourceDetail>('PUT', `/knowledge/sources/${editingId}`, payload) 213 : await api<KnowledgeSourceDetail>('POST', '/knowledge/sources', payload) 214 215 setSelectedKnowledgeSourceId(detail.source.id) 216 triggerKnowledgeRefresh() 217 toast.success(editingId ? 'Knowledge source updated' : 'Knowledge source created') 218 onClose() 219 } catch (error) { 220 toast.error(error instanceof Error ? error.message : 'Failed to save knowledge') 221 } finally { 222 setSaving(false) 223 } 224 } 225 226 const formatSize = (bytes: number | null) => { 227 if (bytes == null) return null 228 if (bytes < 1024) return `${bytes} B` 229 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` 230 return `${(bytes / (1024 * 1024)).toFixed(1)} MB` 231 } 232 233 const inputClass = 'w-full px-4 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[15px] outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow' 234 const scopeHelperText = scope === 'global' 235 ? 'This source will be searchable across the whole fleet' 236 : agentIds.length === 0 237 ? 'Select which agents should receive this source during retrieval' 238 : `${agentIds.length} agent(s) selected` 239 240 const canSave = kind === 'manual' 241 ? Boolean(content.trim()) 242 : kind === 'file' 243 ? Boolean(sourcePath || content.trim()) 244 : Boolean(sourceUrl.trim()) 245 246 return ( 247 <BottomSheet open={open} onClose={onClose}> 248 <div className="mb-10"> 249 <h2 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-2"> 250 {editingId ? 'Edit Knowledge Source' : 'New Knowledge Source'} 251 </h2> 252 <p className="text-[14px] text-text-3"> 253 Manual notes, uploaded files, and imported URLs all index into the same knowledge library. 254 </p> 255 </div> 256 257 <div className="mb-8"> 258 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Source Type</label> 259 <div className="grid grid-cols-3 gap-2"> 260 {(['manual', 'file', 'url'] as const).map((sourceKind) => ( 261 <button 262 key={sourceKind} 263 onClick={() => setKind(sourceKind)} 264 className={`py-3 rounded-[14px] text-[13px] font-600 border transition-all cursor-pointer ${ 265 kind === sourceKind 266 ? 'border-accent-bright/25 bg-accent-soft text-accent-bright' 267 : 'border-white/[0.08] bg-white/[0.02] text-text-3 hover:text-text-2' 268 }`} 269 style={{ fontFamily: 'inherit' }} 270 > 271 {sourceKind === 'manual' ? 'Manual' : sourceKind === 'file' ? 'File' : 'URL'} 272 </button> 273 ))} 274 </div> 275 </div> 276 277 {kind === 'file' && ( 278 <div className="mb-8"> 279 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Upload Document</label> 280 281 {uploadedFile ? ( 282 <div className="flex items-center gap-3 px-4 py-3 rounded-[14px] border border-emerald-500/20 bg-emerald-500/[0.04]"> 283 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-emerald-400 shrink-0"> 284 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> 285 <polyline points="14 2 14 8 20 8" /> 286 <polyline points="9 15 12 12 15 15" /> 287 </svg> 288 <div className="flex-1 min-w-0"> 289 <p className="text-[13px] text-text font-500 truncate">{uploadedFile.name}</p> 290 <p className="text-[11px] text-text-3/60"> 291 {formatSize(uploadedFile.size) ? `${formatSize(uploadedFile.size)} • ` : ''}content extracted 292 </p> 293 </div> 294 <button 295 onClick={() => { 296 setUploadedFile(null) 297 setSourcePath('') 298 setSourceUrl('') 299 setSourceLabel('') 300 setContent('') 301 }} 302 className="p-1.5 rounded-[8px] text-text-3 hover:text-red-400 hover:bg-red-400/10 border-none bg-transparent cursor-pointer transition-colors" 303 aria-label="Remove uploaded file" 304 > 305 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 306 <line x1="18" y1="6" x2="6" y2="18" /> 307 <line x1="6" y1="6" x2="18" y2="18" /> 308 </svg> 309 </button> 310 </div> 311 ) : ( 312 <div 313 onDragOver={handleDragOver} 314 onDragEnter={handleDragEnter} 315 onDragLeave={handleDragLeave} 316 onDrop={handleDrop} 317 onClick={() => fileInputRef.current?.click()} 318 className={`flex flex-col items-center gap-3 px-6 py-8 rounded-[14px] border-2 border-dashed cursor-pointer transition-all duration-200 ${ 319 isDragging 320 ? 'border-accent-bright/50 bg-accent-soft/20' 321 : 'border-white/[0.08] bg-white/[0.02] hover:border-white/[0.15] hover:bg-white/[0.03]' 322 } ${uploading ? 'opacity-60 pointer-events-none' : ''}`} 323 > 324 {uploading ? ( 325 <> 326 <div className="w-8 h-8 border-2 border-accent-bright/30 border-t-accent-bright rounded-full" style={{ animation: 'spin 0.8s linear infinite' }} /> 327 <p className="text-[13px] text-text-3">Extracting content...</p> 328 </> 329 ) : ( 330 <> 331 <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3/50"> 332 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> 333 <polyline points="17 8 12 3 7 8" /> 334 <line x1="12" y1="3" x2="12" y2="15" /> 335 </svg> 336 <div className="text-center"> 337 <p className="text-[14px] text-text-2 font-500"> 338 {isDragging ? 'Drop document here' : 'Drop a document or click to browse'} 339 </p> 340 <p className="text-[11px] text-text-3/50 mt-1"> 341 Supports text, code, structured files, and PDFs 342 </p> 343 </div> 344 </> 345 )} 346 </div> 347 )} 348 349 <input 350 ref={fileInputRef} 351 type="file" 352 accept={ACCEPTED_TYPES} 353 onChange={handleFileChange} 354 className="hidden" 355 /> 356 </div> 357 )} 358 359 {kind === 'url' && ( 360 <div className="mb-8"> 361 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Source URL</label> 362 <input 363 type="url" 364 value={sourceUrl} 365 onChange={(event) => setSourceUrl(event.target.value)} 366 placeholder="https://example.com/docs/article" 367 className={inputClass} 368 style={{ fontFamily: 'inherit' }} 369 /> 370 <p className="text-[11px] text-text-3/55 mt-1.5 pl-1"> 371 Save to fetch, clean, and index the page. You can also edit the extracted text below before saving again. 372 </p> 373 </div> 374 )} 375 376 <div className="mb-8"> 377 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Title</label> 378 <input 379 type="text" 380 value={title} 381 onChange={(event) => setTitle(event.target.value)} 382 placeholder="Knowledge title" 383 className={inputClass} 384 style={{ fontFamily: 'inherit' }} 385 /> 386 </div> 387 388 <div className="mb-8"> 389 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3"> 390 Indexed Content 391 {content.length > 0 && ( 392 <span className="ml-2 text-text-3/40 font-mono text-[10px] normal-case tracking-normal"> 393 {content.length.toLocaleString()} chars 394 </span> 395 )} 396 </label> 397 <textarea 398 value={content} 399 onChange={(event) => setContent(event.target.value)} 400 placeholder={kind === 'manual' ? 'Knowledge content...' : 'Extracted content appears here after import'} 401 rows={8} 402 className={`${inputClass} resize-y min-h-[180px]`} 403 style={{ fontFamily: 'inherit' }} 404 /> 405 </div> 406 407 <div className="mb-8"> 408 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Tags</label> 409 <input 410 type="text" 411 value={tags} 412 onChange={(event) => setTags(event.target.value)} 413 placeholder="api, docs, internal (comma-separated)" 414 className={inputClass} 415 style={{ fontFamily: 'inherit' }} 416 /> 417 </div> 418 419 <div className="mb-8"> 420 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Scope</label> 421 <div className="flex p-1 rounded-[12px] bg-bg border border-white/[0.06]"> 422 {(['global', 'agent'] as const).map((nextScope) => ( 423 <button 424 key={nextScope} 425 onClick={() => setScope(nextScope)} 426 className={`flex-1 py-2.5 rounded-[10px] text-center cursor-pointer transition-all text-[13px] font-600 border-none ${ 427 scope === nextScope ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2' 428 }`} 429 style={{ fontFamily: 'inherit' }} 430 > 431 {nextScope === 'global' ? 'Global' : 'Specific'} 432 </button> 433 ))} 434 </div> 435 <p className="text-[11px] text-text-3/60 mt-1.5 pl-1">{scopeHelperText}</p> 436 </div> 437 438 {scope === 'agent' && ( 439 <div className="mb-8"> 440 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Agents</label> 441 <div className="max-h-[240px] overflow-y-auto rounded-[12px] border border-white/[0.06] bg-white/[0.03]"> 442 {agentList.length === 0 ? ( 443 <p className="p-3 text-[12px] text-text-3">No agents available</p> 444 ) : ( 445 agentList.map((agent) => { 446 const selected = agentIds.includes(agent.id) 447 return ( 448 <button 449 key={agent.id} 450 onClick={() => toggleAgent(agent.id)} 451 className={`w-full flex items-center gap-2.5 px-3 py-2 text-left transition-all cursor-pointer ${ 452 selected ? 'bg-accent-soft/40' : 'hover:bg-white/[0.04]' 453 }`} 454 style={{ fontFamily: 'inherit' }} 455 > 456 <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={24} /> 457 <span className="text-[13px] text-text flex-1 truncate">{agent.name}</span> 458 {selected && ( 459 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="text-accent-bright shrink-0"> 460 <polyline points="20 6 9 17 4 12" /> 461 </svg> 462 )} 463 </button> 464 ) 465 }) 466 )} 467 </div> 468 </div> 469 )} 470 471 <div className="flex gap-3 pt-2 border-t border-white/[0.04]"> 472 <button 473 onClick={onClose} 474 className="flex-1 py-3.5 rounded-[14px] border border-white/[0.08] bg-transparent text-text-2 text-[15px] font-600 cursor-pointer hover:bg-surface-2 transition-all" 475 style={{ fontFamily: 'inherit' }} 476 > 477 Cancel 478 </button> 479 <button 480 onClick={() => { void handleSave() }} 481 disabled={!canSave || saving} 482 className="flex-1 py-3.5 rounded-[14px] border-none bg-accent-bright text-white text-[15px] font-600 cursor-pointer active:scale-[0.97] disabled:opacity-30 transition-all shadow-[0_4px_20px_rgba(99,102,241,0.25)] hover:brightness-110" 483 style={{ fontFamily: 'inherit' }} 484 > 485 {saving ? 'Saving...' : 'Save'} 486 </button> 487 </div> 488 </BottomSheet> 489 ) 490 }