323acda6d4
- rag-api: new POST /chat/stream SSE endpoint emits live pipeline stages (embed/retrieve/context/llm/answer) and streams LLM tokens - Knowledge Chat: document chat now streams answers token-by-token and pulses each pipeline stage in real time as it executes - How-it-works panel: active stage glows/scales, completed stages settle, agent mode also drives the pulse - LangChain made visible: orchestrated-by-LangChain badge + LC markers on LangChain-native nodes (TextSplitter, Embeddings, as_retriever, ChatOpenAI, Chroma)
654 lines
28 KiB
TypeScript
654 lines
28 KiB
TypeScript
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
|
import { Binary, Bot, BookOpen, ChevronDown, Cpu, Database, FileText, Layers, Loader2, MessageSquare, RefreshCw, RotateCcw, ScanText, Scissors, Search, Send, Sparkles, Upload, Workflow, Wrench } from 'lucide-react'
|
|
import { cn } from '../../lib/utils'
|
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
|
|
|
type Collection = { name: string; documents: number; files?: number; filenames?: string[] }
|
|
type StoredDoc = {
|
|
id: string
|
|
filename: string
|
|
collection: string
|
|
chunks: number
|
|
characters?: number
|
|
ingested_at: string
|
|
bytes?: number
|
|
}
|
|
type Source = { source?: string; chunk?: number; preview?: string }
|
|
type AgentStep = { tool: string; input?: Record<string, unknown> }
|
|
type ChatMsg = { role: 'user' | 'assistant'; content: string; sources?: Source[]; steps?: AgentStep[] }
|
|
|
|
type Health = { ok: boolean; chroma: boolean; docling: boolean; llm: boolean; embed_model?: string }
|
|
|
|
type Props = { onGpuActivity?: (active: boolean) => void }
|
|
|
|
export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
|
|
const [health, setHealth] = useState<Health | null>(null)
|
|
const [collections, setCollections] = useState<Collection[]>([])
|
|
const [collection, setCollection] = useState('default')
|
|
const [newCol, setNewCol] = useState('')
|
|
const [messages, setMessages] = useState<ChatMsg[]>([])
|
|
const [input, setInput] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [ingesting, setIngesting] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [storedDocs, setStoredDocs] = useState<StoredDoc[]>([])
|
|
const [selectedDocId, setSelectedDocId] = useState<string | null>(null)
|
|
const [summarizing, setSummarizing] = useState(false)
|
|
const [reindexing, setReindexing] = useState<string | null>(null)
|
|
const [agentMode, setAgentMode] = useState(false)
|
|
const [syncing, setSyncing] = useState(false)
|
|
const [catalogDocs, setCatalogDocs] = useState<number | null>(null)
|
|
const [showArch, setShowArch] = useState(true)
|
|
const [activeStage, setActiveStage] = useState<number | null>(null)
|
|
const bottomRef = useRef<HTMLDivElement>(null)
|
|
|
|
const loadMeta = useCallback(async () => {
|
|
try {
|
|
const [h, c, d] = await Promise.all([
|
|
fetch('/rag/health'),
|
|
fetch('/rag/collections'),
|
|
fetch('/rag/documents'),
|
|
])
|
|
if (h.ok) setHealth(await h.json())
|
|
if (c.ok) {
|
|
const j = await c.json()
|
|
setCollections(j.collections || [])
|
|
}
|
|
if (d.ok) {
|
|
const j = await d.json()
|
|
setStoredDocs(j.documents || [])
|
|
}
|
|
} catch {
|
|
setHealth(null)
|
|
}
|
|
}, [])
|
|
|
|
const loadCatalog = useCallback(async () => {
|
|
try {
|
|
const r = await fetch('/rag/catalog/status')
|
|
if (r.ok) setCatalogDocs((await r.json()).documents ?? 0)
|
|
} catch {
|
|
setCatalogDocs(null)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadMeta()
|
|
loadCatalog()
|
|
}, [loadMeta, loadCatalog])
|
|
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [messages, loading])
|
|
|
|
useEffect(() => {
|
|
onGpuActivity?.(loading || ingesting || summarizing || reindexing !== null)
|
|
}, [loading, ingesting, summarizing, reindexing, onGpuActivity])
|
|
|
|
const onIngest = async (file: File) => {
|
|
setIngesting(true)
|
|
setError(null)
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
fd.append('collection', collection)
|
|
try {
|
|
const r = await fetch('/rag/ingest', { method: 'POST', body: fd })
|
|
const j = await r.json()
|
|
if (!r.ok || !j.ok) {
|
|
setError(j.error || 'Ingest failed')
|
|
return
|
|
}
|
|
setMessages((m) => [...m, {
|
|
role: 'assistant',
|
|
content: j.duplicate
|
|
? `Already indexed: ${j.filename} (${j.chunks} chunks). You can chat immediately — no re-upload needed.`
|
|
: `Indexed ${j.filename} → collection "${j.collection}" — ${j.chunks} chunks (${j.characters?.toLocaleString()} chars). Stored permanently.`,
|
|
}])
|
|
loadMeta()
|
|
} catch {
|
|
setError('RAG API unavailable')
|
|
} finally {
|
|
setIngesting(false)
|
|
}
|
|
}
|
|
|
|
const onSummarize = async (doc: StoredDoc) => {
|
|
setSummarizing(true)
|
|
setError(null)
|
|
setSelectedDocId(doc.id)
|
|
setCollection(doc.collection)
|
|
setMessages((m) => [...m, { role: 'user', content: `Summarize: ${doc.filename}` }])
|
|
try {
|
|
const r = await fetch('/rag/summarize', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ collection: doc.collection, doc_id: doc.id }),
|
|
})
|
|
const j = await r.json()
|
|
if (!r.ok || !j.ok) {
|
|
setError(j.error || 'Summarize failed')
|
|
return
|
|
}
|
|
setMessages((m) => [...m, {
|
|
role: 'assistant',
|
|
content: `Summary of ${j.filename} (${j.characters?.toLocaleString()} chars):\n\n${j.summary}`,
|
|
}])
|
|
} catch {
|
|
setError('Summarize request failed')
|
|
} finally {
|
|
setSummarizing(false)
|
|
}
|
|
}
|
|
|
|
const onReindex = async (doc: StoredDoc) => {
|
|
setReindexing(doc.id)
|
|
setError(null)
|
|
try {
|
|
const r = await fetch(`/rag/documents/${doc.id}/reindex`, { method: 'POST' })
|
|
const j = await r.json()
|
|
if (!r.ok || !j.ok) {
|
|
setError(j.error || 'Re-index failed')
|
|
return
|
|
}
|
|
setMessages((m) => [...m, {
|
|
role: 'assistant',
|
|
content: `Re-indexed ${j.filename}: ${j.chunks} clean text chunks (${j.characters?.toLocaleString()} chars). You can now chat and summarize.`,
|
|
}])
|
|
loadMeta()
|
|
} catch {
|
|
setError('Re-index request failed')
|
|
} finally {
|
|
setReindexing(null)
|
|
}
|
|
}
|
|
|
|
const onSyncCatalog = async () => {
|
|
setSyncing(true)
|
|
setError(null)
|
|
try {
|
|
const r = await fetch('/rag/catalog/sync', { method: 'POST' })
|
|
const j = await r.json()
|
|
if (!r.ok || !j.ok) {
|
|
setError(j.error || 'Catalog sync failed')
|
|
return
|
|
}
|
|
const c = j.counts || {}
|
|
setMessages((m) => [...m, {
|
|
role: 'assistant',
|
|
content: `Catalog synced — ${j.documents} entries (${c.tables ?? 0} tables, ${c.pii_datasets ?? 0} PII datasets, ${c.flows ?? 0} lineage edges, ${c.movements ?? 0} movements). Agent mode can now answer catalog/PII/lineage questions.`,
|
|
}])
|
|
loadCatalog()
|
|
} catch {
|
|
setError('Catalog sync request failed')
|
|
} finally {
|
|
setSyncing(false)
|
|
}
|
|
}
|
|
|
|
const onSendAgent = async (msg: string) => {
|
|
setLoading(true)
|
|
setActiveStage(0)
|
|
const steps: AgentStep[] = []
|
|
let answer = ''
|
|
let idx = -1
|
|
setMessages((m) => {
|
|
idx = m.length
|
|
return [...m, { role: 'assistant', content: '', steps: [] }]
|
|
})
|
|
const patch = () =>
|
|
setMessages((m) => m.map((x, i) => (i === idx ? { ...x, content: answer, steps: [...steps] } : x)))
|
|
try {
|
|
const r = await fetch('/rag/agent', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ message: msg, max_steps: 5 }),
|
|
})
|
|
if (!r.ok || !r.body) {
|
|
setError('Agent unavailable')
|
|
return
|
|
}
|
|
const reader = r.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buf = ''
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
buf += decoder.decode(value, { stream: true })
|
|
const events = buf.split('\n\n')
|
|
buf = events.pop() || ''
|
|
for (const ev of events) {
|
|
const eMatch = ev.match(/^event: (.+)$/m)
|
|
const dMatch = ev.match(/^data: (.+)$/m)
|
|
if (!eMatch || !dMatch) continue
|
|
const type = eMatch[1].trim()
|
|
let data: Record<string, unknown> = {}
|
|
try { data = JSON.parse(dMatch[1]) } catch { continue }
|
|
if (type === 'step') {
|
|
setActiveStage(2)
|
|
steps.push({ tool: String(data.tool), input: data.input as Record<string, unknown> })
|
|
patch()
|
|
} else if (type === 'token') {
|
|
setActiveStage(5)
|
|
answer += String(data.t || '')
|
|
patch()
|
|
} else if (type === 'error') {
|
|
setError(String(data.error || 'Agent error'))
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
setError('Failed to reach agent')
|
|
} finally {
|
|
patch()
|
|
setLoading(false)
|
|
setActiveStage(null)
|
|
}
|
|
}
|
|
|
|
const STAGE_INDEX: Record<string, number> = { question: 0, embed: 1, retrieve: 2, context: 3, llm: 4, answer: 5 }
|
|
|
|
const onSendChatStream = async (msg: string) => {
|
|
setLoading(true)
|
|
setActiveStage(0)
|
|
let answer = ''
|
|
let srcs: Source[] | undefined
|
|
let idx = -1
|
|
setMessages((m) => {
|
|
idx = m.length
|
|
return [...m, { role: 'assistant', content: '' }]
|
|
})
|
|
const patch = () => setMessages((m) => m.map((x, i) => (i === idx ? { ...x, content: answer, sources: srcs } : x)))
|
|
try {
|
|
const r = await fetch('/rag/chat/stream', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ message: msg, collection, top_k: 5 }),
|
|
})
|
|
if (!r.ok || !r.body) {
|
|
setError('Chat service unavailable')
|
|
return
|
|
}
|
|
const reader = r.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buf = ''
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
buf += decoder.decode(value, { stream: true })
|
|
const events = buf.split('\n\n')
|
|
buf = events.pop() || ''
|
|
for (const ev of events) {
|
|
const eMatch = ev.match(/^event: (.+)$/m)
|
|
const dMatch = ev.match(/^data: (.+)$/m)
|
|
if (!eMatch || !dMatch) continue
|
|
const type = eMatch[1].trim()
|
|
let data: Record<string, unknown> = {}
|
|
try { data = JSON.parse(dMatch[1]) } catch { continue }
|
|
if (type === 'stage') {
|
|
const si = STAGE_INDEX[String(data.stage)]
|
|
if (si != null) setActiveStage(si)
|
|
} else if (type === 'sources') {
|
|
srcs = data.sources as Source[]
|
|
patch()
|
|
} else if (type === 'token') {
|
|
setActiveStage(5)
|
|
answer += String(data.t || '')
|
|
patch()
|
|
} else if (type === 'error') {
|
|
setError(String(data.error || 'Chat error'))
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
setError('Failed to reach RAG / LLM service')
|
|
} finally {
|
|
patch()
|
|
setLoading(false)
|
|
setActiveStage(null)
|
|
}
|
|
}
|
|
|
|
const onSend = async () => {
|
|
const msg = input.trim()
|
|
if (!msg || loading) return
|
|
setInput('')
|
|
setError(null)
|
|
setMessages((m) => [...m, { role: 'user', content: msg }])
|
|
if (agentMode) {
|
|
await onSendAgent(msg)
|
|
return
|
|
}
|
|
await onSendChatStream(msg)
|
|
}
|
|
|
|
const createCollection = async () => {
|
|
if (!newCol.trim()) return
|
|
const fd = new FormData()
|
|
fd.append('name', newCol.trim())
|
|
await fetch('/rag/collections', { method: 'POST', body: fd })
|
|
setCollection(newCol.trim())
|
|
setNewCol('')
|
|
loadMeta()
|
|
}
|
|
|
|
const doclingUiUrl = `${window.location.protocol}//${window.location.hostname}:5001/ui/`
|
|
|
|
return (
|
|
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised">
|
|
<header className="shrink-0 border-b border-border bg-surface-overlay/30 px-4 py-3">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-base font-semibold text-foreground">Knowledge Chat (RAG)</h2>
|
|
<p className="text-[11px] text-foreground-muted">
|
|
LangChain + ChromaDB — chat with your ingested documents via Llama 70B
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2 text-[10px]">
|
|
{health && (
|
|
<>
|
|
<StatusPill ok={health.chroma} label="ChromaDB" />
|
|
<StatusPill ok={health.docling} label="Docling" />
|
|
<StatusPill ok={health.llm} label="LLM" />
|
|
</>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowArch((v) => !v)}
|
|
className={cn('inline-flex items-center gap-1 rounded border px-2 py-1 text-[10px] font-medium transition-colors',
|
|
showArch ? 'border-docker/50 bg-docker/15 text-docker' : 'border-border text-foreground-muted hover:bg-surface-overlay')}
|
|
>
|
|
<Workflow className="h-3.5 w-3.5" /> How it works
|
|
<ChevronDown className={cn('h-3 w-3 transition-transform', showArch && 'rotate-180')} />
|
|
</button>
|
|
<button type="button" onClick={loadMeta} className="rounded border border-border p-1.5 hover:bg-surface-overlay">
|
|
<RefreshCw className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{showArch && (
|
|
<div className="shrink-0 border-b border-border bg-surface-overlay/20 px-4 py-3">
|
|
<RagFlow loading={loading} ingesting={ingesting} activeStage={activeStage} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
|
<aside className="shrink-0 border-b border-border p-4 lg:w-72 lg:border-b-0 lg:border-r">
|
|
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Collection</h3>
|
|
<select
|
|
value={collection}
|
|
onChange={(e) => setCollection(e.target.value)}
|
|
className="mb-2 w-full rounded border border-border bg-surface-overlay px-2 py-1.5 text-[11px]"
|
|
>
|
|
{collections.length === 0 && <option value="default">default (empty)</option>}
|
|
{collections.map((c) => (
|
|
<option key={c.name} value={c.name}>{c.name} ({c.documents} docs)</option>
|
|
))}
|
|
</select>
|
|
<div className="mb-4 flex gap-1">
|
|
<input
|
|
value={newCol}
|
|
onChange={(e) => setNewCol(e.target.value)}
|
|
placeholder="New collection name"
|
|
className="min-w-0 flex-1 rounded border border-border bg-surface-overlay px-2 py-1 text-[10px]"
|
|
/>
|
|
<button type="button" onClick={createCollection} className={cn('shrink-0 rounded px-2 py-1 text-[10px]', subTabIdle)}>Add</button>
|
|
</div>
|
|
|
|
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Platform agent</h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => setAgentMode((v) => !v)}
|
|
className={cn(
|
|
'mb-1 flex w-full items-center justify-between rounded border px-2 py-1.5 text-[11px] transition-colors',
|
|
agentMode ? 'border-docker/50 bg-docker/15 text-docker' : 'border-border text-foreground-muted',
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-1"><Bot className="h-3.5 w-3.5" />Agent mode {agentMode ? 'on' : 'off'}</span>
|
|
<span className={cn('h-2 w-2 rounded-full', agentMode ? 'bg-docker' : 'bg-foreground-faint/40')} />
|
|
</button>
|
|
<p className="mb-2 text-[9px] text-foreground-faint">
|
|
Live tools: catalog, PII, CDC, movements + approval-gated triggers.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={onSyncCatalog}
|
|
disabled={syncing}
|
|
className={cn('mb-4 flex w-full items-center justify-center gap-1 rounded px-2 py-1 text-[10px]', subTabIdle)}
|
|
>
|
|
{syncing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Database className="h-3 w-3" />}
|
|
Sync catalog{catalogDocs != null ? ` (${catalogDocs})` : ''}
|
|
</button>
|
|
|
|
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Ingest documents</h3>
|
|
<label className={cn('flex cursor-pointer flex-col items-center rounded-lg border-2 border-dashed border-border px-3 py-4 text-center hover:border-docker/40', ingesting && 'opacity-50')}>
|
|
<Upload className="mb-1 h-6 w-6 text-docker opacity-60" />
|
|
<span className="text-[10px] font-medium">PDF, PPTX, DOCX, CSV, TXT, MD</span>
|
|
<span className="text-[9px] text-foreground-faint">Stored in ChromaDB + disk — upload once</span>
|
|
<input type="file" className="hidden" disabled={ingesting} accept=".pdf,.pptx,.ppt,.docx,.csv,.txt,.md,.json" onChange={(e) => e.target.files?.[0] && onIngest(e.target.files[0])} />
|
|
</label>
|
|
{ingesting && (
|
|
<p className="mt-2 flex items-center gap-1 text-[10px] text-foreground-muted">
|
|
<Loader2 className="h-3 w-3 animate-spin" /> Ingesting & embedding…
|
|
</p>
|
|
)}
|
|
|
|
<h3 className="mb-2 mt-4 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">
|
|
Document library ({storedDocs.length})
|
|
</h3>
|
|
<div className="scrollbar-thin max-h-40 space-y-1 overflow-y-auto">
|
|
{storedDocs.length === 0 ? (
|
|
<p className="text-[9px] text-foreground-faint">No documents yet — upload above.</p>
|
|
) : (
|
|
storedDocs.map((doc) => (
|
|
<div
|
|
key={doc.id}
|
|
className={cn(
|
|
'rounded border px-2 py-1.5 text-[9px] transition-colors',
|
|
selectedDocId === doc.id ? 'border-docker/40 bg-docker/10' : 'border-border',
|
|
)}
|
|
>
|
|
<button type="button" onClick={() => { setSelectedDocId(doc.id); setCollection(doc.collection) }} className="w-full text-left">
|
|
<p className="truncate font-medium text-foreground">{doc.filename}</p>
|
|
<p className="text-foreground-faint">{doc.collection} · {doc.chunks} chunks · {new Date(doc.ingested_at).toLocaleDateString()}</p>
|
|
</button>
|
|
<div className="mt-1 flex gap-1">
|
|
<button
|
|
type="button"
|
|
disabled={summarizing}
|
|
onClick={() => onSummarize(doc)}
|
|
className={cn('flex flex-1 items-center justify-center gap-0.5 rounded px-1 py-0.5', subTabIdle)}
|
|
>
|
|
{summarizing && selectedDocId === doc.id ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <FileText className="h-2.5 w-2.5" />}
|
|
Summarize
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={reindexing === doc.id}
|
|
onClick={() => onReindex(doc)}
|
|
className={cn('flex flex-1 items-center justify-center gap-0.5 rounded px-1 py-0.5', subTabIdle)}
|
|
title="Re-parse with clean text (fixes corrupted PDF index)"
|
|
>
|
|
{reindexing === doc.id ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RotateCcw className="h-2.5 w-2.5" />}
|
|
Re-index
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-4 space-y-1 text-[9px] text-foreground-faint">
|
|
<p><BookOpen className="mr-1 inline h-3 w-3" />Embed: {health?.embed_model || 'all-MiniLM-L6-v2'}</p>
|
|
<a href={doclingUiUrl} target="_blank" rel="noreferrer" className="text-docker hover:underline">Docling UI (port 5001) ↗</a>
|
|
</div>
|
|
</aside>
|
|
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
|
|
{messages.length === 0 && (
|
|
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-sm text-foreground-muted">
|
|
<MessageSquare className="h-10 w-10 opacity-30" />
|
|
<p>Upload once — documents stay in ChromaDB. Ask anytime without re-uploading.</p>
|
|
<p className="text-[11px]">Example: "What maturity gaps exist in the customer dataset?"</p>
|
|
</div>
|
|
)}
|
|
{messages.map((m, i) => (
|
|
<div key={i} className={cn('mb-3 max-w-[90%] rounded-lg px-3 py-2 text-[12px]', m.role === 'user' ? 'ml-auto bg-docker/20 text-foreground' : 'bg-surface-overlay text-foreground-muted')}>
|
|
{m.steps && m.steps.length > 0 && (
|
|
<div className="mb-2 flex flex-wrap gap-1">
|
|
{m.steps.map((s, j) => (
|
|
<span key={j} className="inline-flex items-center gap-1 rounded-full bg-docker/15 px-2 py-0.5 text-[9px] text-docker">
|
|
<Wrench className="h-2.5 w-2.5" />{s.tool}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className="whitespace-pre-wrap leading-relaxed">{m.content || (loading && i === messages.length - 1 ? '…' : '')}</p>
|
|
{m.sources && m.sources.length > 0 && (
|
|
<div className="mt-2 border-t border-border pt-2">
|
|
<p className="mb-1 text-[9px] font-semibold uppercase text-foreground-faint">Sources</p>
|
|
{m.sources.map((s, j) => (
|
|
<p key={j} className="text-[9px] text-foreground-faint">
|
|
{s.source} · chunk {s.chunk}: {s.preview?.slice(0, 120)}…
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{loading && (
|
|
<div className="flex items-center gap-2 text-[11px] text-foreground-muted">
|
|
<Loader2 className="h-4 w-4 animate-spin text-docker" /> Retrieving context & generating answer…
|
|
</div>
|
|
)}
|
|
{error && <p className="text-[11px] text-danger">{error}</p>}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
|
|
<div className="flex shrink-0 gap-2 border-t border-border p-3">
|
|
<input
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && (e.preventDefault(), onSend())}
|
|
placeholder={agentMode ? 'Ask the platform agent (catalog, PII, CDC, run a movement…)' : 'Ask a question about your ingested data…'}
|
|
className="min-w-0 flex-1 rounded-lg border border-border bg-surface-overlay px-3 py-2 text-[12px]"
|
|
disabled={loading}
|
|
/>
|
|
<button type="button" onClick={onSend} disabled={loading || !input.trim()} className={cn('rounded-lg px-3 py-2', subTabActive, 'disabled:opacity-40')}>
|
|
<Send className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const INDEX_STAGES = [
|
|
{ icon: Upload, label: 'Upload', sub: 'PDF·DOCX·MD', color: '#38bdf8', lc: false },
|
|
{ icon: ScanText, label: 'Docling', sub: 'OCR · tables', color: '#22d3ee', lc: false },
|
|
{ icon: Scissors, label: 'Split', sub: 'TextSplitter', color: '#a78bfa', lc: true },
|
|
{ icon: Binary, label: 'Embed', sub: 'MiniLM 384d', color: '#f472b6', lc: true },
|
|
{ icon: Database, label: 'ChromaDB', sub: 'vector store', color: '#34d399', lc: true },
|
|
]
|
|
|
|
const QUERY_STAGES = [
|
|
{ icon: MessageSquare, label: 'Question', sub: 'user', color: '#38bdf8', lc: false },
|
|
{ icon: Binary, label: 'Embed', sub: 'MiniLM', color: '#f472b6', lc: true },
|
|
{ icon: Search, label: 'Retrieve', sub: 'as_retriever', color: '#34d399', lc: true },
|
|
{ icon: Layers, label: 'Context', sub: 'top-5 chunks', color: '#fbbf24', lc: false },
|
|
{ icon: Cpu, label: 'LLM', sub: 'ChatOpenAI', color: '#818cf8', lc: true },
|
|
{ icon: Sparkles, label: 'Answer', sub: '+ sources', color: '#34d399', lc: false },
|
|
]
|
|
|
|
const STAGE_LABEL = ['Question', 'Embed', 'Retrieve', 'Context', 'LLM', 'Answer']
|
|
|
|
function RagFlow({ loading, ingesting, activeStage }: { loading: boolean; ingesting: boolean; activeStage: number | null }) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap items-center gap-2 text-[9px] font-semibold uppercase tracking-wider text-foreground-faint">
|
|
<Workflow className="h-3 w-3 text-docker" /> How it works — RAG pipeline
|
|
<span className="inline-flex items-center gap-1 rounded-md border border-emerald-400/40 bg-emerald-500/15 px-1.5 py-0.5 font-mono text-[8px] normal-case text-emerald-300">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" /> orchestrated by LangChain
|
|
</span>
|
|
<span className="rounded bg-docker/15 px-1.5 py-0.5 font-mono text-[8px] normal-case text-docker">ChromaDB · Docling · sentence-transformers</span>
|
|
{activeStage != null && (
|
|
<span className="ml-auto flex items-center gap-1 normal-case text-docker">
|
|
<span className="h-1.5 w-1.5 animate-ping rounded-full bg-docker" /> {STAGE_LABEL[activeStage] || 'working'}…
|
|
</span>
|
|
)}
|
|
{activeStage == null && ingesting && (
|
|
<span className="ml-auto flex items-center gap-1 normal-case text-docker">
|
|
<span className="h-1.5 w-1.5 animate-ping rounded-full bg-docker" /> ingesting…
|
|
</span>
|
|
)}
|
|
</div>
|
|
<FlowRow tag="Index" stages={INDEX_STAGES} flowing={ingesting} />
|
|
<FlowRow tag="Query" stages={QUERY_STAGES} flowing={loading} activeIndex={activeStage} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FlowRow({ tag, stages, flowing, activeIndex }: { tag: string; stages: typeof INDEX_STAGES; flowing: boolean; activeIndex?: number | null }) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-10 shrink-0 text-[8px] font-semibold uppercase text-foreground-faint">{tag}</span>
|
|
<div className={cn('scrollbar-thin flex flex-1 items-center overflow-x-auto py-0.5', flowing && 'rag-flowing')}>
|
|
{stages.map((s, i) => {
|
|
const state: 'idle' | 'active' | 'done' =
|
|
activeIndex == null ? 'idle' : i < activeIndex ? 'done' : i === activeIndex ? 'active' : 'idle'
|
|
return (
|
|
<Fragment key={`${tag}-${s.label}-${i}`}>
|
|
<FlowNode icon={s.icon} label={s.label} sub={s.sub} color={s.color} lc={s.lc} state={state} />
|
|
{i < stages.length - 1 && <FlowConnector color={s.color} delay={i * 0.18} />}
|
|
</Fragment>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FlowNode({ icon: Icon, label, sub, color, lc, state }: { icon: typeof Upload; label: string; sub: string; color: string; lc: boolean; state: 'idle' | 'active' | 'done' }) {
|
|
const cls = state === 'active' ? 'rag-node-active' : state === 'done' ? 'rag-node-done' : 'rag-node'
|
|
const glow = state === 'active' ? `${color}cc` : `${color}55`
|
|
return (
|
|
<div
|
|
className={cn('relative flex min-w-[64px] shrink-0 flex-col items-center gap-0.5 rounded-lg border border-border bg-surface-raised px-2 py-1.5 text-center transition-colors', cls)}
|
|
style={{ '--rag-glow': glow, '--rag-line': color } as React.CSSProperties}
|
|
>
|
|
{lc && (
|
|
<span className="absolute -right-1.5 -top-1.5 rounded bg-emerald-500/90 px-1 text-[7px] font-bold leading-none text-white shadow" title="LangChain component">LC</span>
|
|
)}
|
|
<Icon className="h-4 w-4" style={{ color }} />
|
|
<span className="text-[9px] font-medium leading-tight text-foreground">{label}</span>
|
|
<span className="text-[8px] leading-tight text-foreground-faint">{sub}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FlowConnector({ color, delay }: { color: string; delay: number }) {
|
|
return (
|
|
<div className="relative mx-0.5 h-5 w-7 shrink-0">
|
|
<div
|
|
className="rag-track absolute top-1/2 h-[2px] w-full -translate-y-1/2 rounded"
|
|
style={{ backgroundImage: `linear-gradient(90deg, ${color} 0 7px, transparent 7px 20px)`, backgroundSize: '20px 2px', opacity: 0.6 }}
|
|
/>
|
|
<span
|
|
className="rag-dot absolute top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full"
|
|
style={{ background: color, boxShadow: `0 0 6px ${color}`, animationDelay: `${delay}s` }}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StatusPill({ ok, label }: { ok: boolean; label: string }) {
|
|
return (
|
|
<span className={cn('rounded-full px-2 py-0.5 font-medium', ok ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger')}>
|
|
{label} {ok ? '●' : '○'}
|
|
</span>
|
|
)
|
|
}
|