Add Command Center v2: DQ/RAG integration, S3 browser, Jupyter, GPU matrix.

Mirror mo/atc-GPU layout with config/, docs/, scripts/ for Gitea deploy.
This commit is contained in:
mo
2026-06-25 00:28:23 +00:00
parent fb9cc21c9a
commit a11621b21f
110 changed files with 14622 additions and 529 deletions
@@ -0,0 +1,358 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { BookOpen, FileText, Loader2, MessageSquare, RefreshCw, RotateCcw, Send, Upload } 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 ChatMsg = { role: 'user' | 'assistant'; content: string; sources?: Source[] }
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 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)
}
}, [])
useEffect(() => {
loadMeta()
}, [loadMeta])
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 onSend = async () => {
const msg = input.trim()
if (!msg || loading) return
setInput('')
setError(null)
setMessages((m) => [...m, { role: 'user', content: msg }])
setLoading(true)
try {
const r = await fetch('/rag/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msg, collection, top_k: 5 }),
})
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Chat failed')
setLoading(false)
return
}
setMessages((m) => [...m, { role: 'assistant', content: j.answer, sources: j.sources }])
} catch {
setError('Failed to reach RAG / LLM service')
} finally {
setLoading(false)
}
}
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={loadMeta} className="rounded border border-border p-1.5 hover:bg-surface-overlay">
<RefreshCw className="h-4 w-4" />
</button>
</div>
</div>
</header>
<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">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: &quot;What maturity gaps exist in the customer dataset?&quot;</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')}>
<p className="whitespace-pre-wrap leading-relaxed">{m.content}</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="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>
)
}
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>
)
}