/ src / components / knowledge / knowledge-sheet.tsx
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  }