qmd-web / src /App.tsx
shreyask's picture
Deploy qmd-web
8a8f6ee verified
import { useState, useEffect, useCallback, useRef } from 'react';
import type { Document, Chunk, EmbeddedChunk, ModelState } from './types';
import { loadAllModels, isAllModelsReady } from './pipeline/models';
import { chunkDocument, extractTitle } from './pipeline/chunking';
import { embedDocChunksBatch } from './pipeline/embeddings';
import { BM25Index } from './pipeline/bm25';
import { runPipeline } from './pipeline/orchestrator';
import type { PipelineState } from './components/PipelineView';
import QueryInput from './components/QueryInput';
import ModelStatus from './components/ModelStatus';
import PipelineView from './components/PipelineView';
import DocumentManager from './components/DocumentManager';
const SAMPLE_DOCS = [
'api-design-principles.md',
'distributed-systems-overview.md',
'machine-learning-primer.md',
'history-of-coffee.md',
];
const SHOWCASE_CARDS = [
{
title: 'Faithful to qmd',
body: 'BM25, vector search, query expansion, RRF fusion, and reranking follow the upstream retrieval recipe instead of flattening everything into one model call.',
},
{
title: 'Browser-native bits',
body: 'Transformers.js and WebGPU run the pipeline locally, cache model weights in the browser, and expose each stage so the search system stays inspectable.',
},
];
const INDEX_BATCH_SIZE = 8;
const INITIAL_PIPELINE: PipelineState = {
expansion: { status: 'idle' },
search: { status: 'idle' },
rrf: { status: 'idle' },
rerank: { status: 'idle' },
blend: { status: 'idle' },
};
function upsertDocuments(current: Document[], incoming: Document[]): Document[] {
const merged = new Map(current.map((doc) => [doc.id, doc]));
for (const doc of incoming) {
merged.set(doc.id, doc);
}
return [...merged.values()];
}
function ShowcaseCard({ title, body }: { title: string; body: string }) {
return (
<div
style={{
padding: '0.9rem 1rem',
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: '10px',
boxShadow: '0 2px 12px var(--shadow)',
}}
>
<div
style={{
marginBottom: '0.35rem',
fontSize: '0.74rem',
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#4285F4',
}}
>
{title}
</div>
<p
style={{
margin: 0,
fontSize: '0.84rem',
lineHeight: 1.6,
color: 'var(--text-secondary)',
}}
>
{body}
</p>
</div>
);
}
function App() {
const [models, setModels] = useState<ModelState[]>([
{ name: 'embedding', status: 'pending', progress: 0 },
{ name: 'reranker', status: 'pending', progress: 0 },
{ name: 'expansion', status: 'pending', progress: 0 },
]);
const [documents, setDocuments] = useState<Document[]>([]);
const [chunks, setChunks] = useState<Chunk[]>([]);
const [embeddedChunks, setEmbeddedChunks] = useState<EmbeddedChunk[]>([]);
const [bm25Index, setBm25Index] = useState<BM25Index | null>(null);
const [pipeline, setPipeline] = useState<PipelineState>(INITIAL_PIPELINE);
const [indexing, setIndexing] = useState(false);
const [indexingProgress, setIndexingProgress] = useState({ completed: 0, total: 0 });
const [query, setQuery] = useState('');
const [intent, setIntent] = useState<string | undefined>();
const [dark, setDark] = useState(() =>
document.documentElement.getAttribute('data-theme') === 'dark',
);
const searchRunIdRef = useRef(0);
const embeddingReady = models.find((model) => model.name === 'embedding')?.status === 'ready';
useEffect(() => {
loadAllModels((state) => {
setModels((prev) => prev.map((model) => (
model.name === state.name ? state : model
)));
}).catch(console.error);
}, []);
useEffect(() => {
async function loadSampleDocs() {
try {
const loadedDocs = await Promise.all(
SAMPLE_DOCS.map(async (filename) => {
const response = await fetch(`/eval-docs/${filename}`);
const body = await response.text();
const title = extractTitle(body, filename);
return { id: filename, title, body, filepath: filename };
}),
);
setDocuments((prev) => upsertDocuments(prev, loadedDocs));
} catch (error) {
console.error(error);
}
}
loadSampleDocs();
}, []);
useEffect(() => {
if (documents.length === 0) {
setChunks([]);
setEmbeddedChunks([]);
setBm25Index(null);
setIndexing(false);
setIndexingProgress({ completed: 0, total: 0 });
return;
}
const nextChunks = documents.flatMap((doc) => chunkDocument(doc));
setChunks(nextChunks);
setBm25Index(new BM25Index(nextChunks));
}, [documents]);
useEffect(() => {
let cancelled = false;
if (!embeddingReady || chunks.length === 0) {
setEmbeddedChunks([]);
setIndexing(false);
setIndexingProgress({ completed: 0, total: chunks.length });
return () => {
cancelled = true;
};
}
async function embedChunks() {
setIndexing(true);
setIndexingProgress({ completed: 0, total: chunks.length });
const embedded: EmbeddedChunk[] = [];
for (let i = 0; i < chunks.length; i += INDEX_BATCH_SIZE) {
const batch = chunks.slice(i, i + INDEX_BATCH_SIZE);
const embeddings = await embedDocChunksBatch(
batch.map((chunk) => ({ title: chunk.title, text: chunk.text })),
);
if (cancelled) return;
for (let j = 0; j < batch.length; j++) {
const chunk = batch[j];
const embedding = embeddings[j];
if (!chunk || !embedding) continue;
embedded.push({ ...chunk, embedding });
}
setIndexingProgress({
completed: Math.min(i + batch.length, chunks.length),
total: chunks.length,
});
}
if (cancelled) return;
setEmbeddedChunks(embedded);
setIndexing(false);
}
embedChunks().catch((error) => {
if (cancelled) return;
console.error(error);
setEmbeddedChunks([]);
setIndexing(false);
});
return () => {
cancelled = true;
};
}, [chunks, embeddingReady]);
const handleUpload = useCallback(async (files: FileList) => {
const uploadedDocs = await Promise.all(
Array.from(files).map(async (file) => {
const body = await file.text();
const title = extractTitle(body, file.name);
return { id: file.name, title, body, filepath: file.name };
}),
);
setDocuments((prev) => upsertDocuments(prev, uploadedDocs));
}, []);
const handlePaste = useCallback((text: string, filename: string) => {
const title = extractTitle(text, filename);
setDocuments((prev) => upsertDocuments(prev, [
{ id: filename, title, body: text, filepath: filename },
]));
}, []);
const handleSearch = useCallback(async (searchQuery: string, searchIntent?: string) => {
if (!bm25Index || embeddedChunks.length === 0) return;
const runId = ++searchRunIdRef.current;
setQuery(searchQuery);
setIntent(searchIntent);
setPipeline(INITIAL_PIPELINE);
const generator = runPipeline({
query: searchQuery,
intent: searchIntent,
embeddedChunks,
bm25Index,
});
for await (const event of generator) {
if (searchRunIdRef.current !== runId) return;
setPipeline((prev) => ({
...prev,
[event.stage]: {
status: event.status,
...('data' in event ? { data: event.data } : {}),
...('error' in event ? { error: event.error } : {}),
},
}));
}
}, [bm25Index, embeddedChunks]);
const allReady = isAllModelsReady() && embeddedChunks.length > 0 && !indexing;
const toggleTheme = useCallback(() => {
setDark((prev) => {
const next = !prev;
document.documentElement.setAttribute('data-theme', next ? 'dark' : 'light');
localStorage.setItem('qmd-theme', next ? 'dark' : 'light');
return next;
});
}, []);
return (
<div
style={{
fontFamily: 'system-ui, -apple-system, sans-serif',
maxWidth: 1400,
margin: '0 auto',
padding: '1.25rem 1rem 2rem',
}}
>
<style>{`
.showcase-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
margin-top: 1rem;
}
@media (max-width: 900px) {
.showcase-grid {
grid-template-columns: 1fr;
}
}
`}</style>
<header style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
<div style={{ flex: 1 }}>
<div
style={{
marginBottom: '0.4rem',
fontSize: '0.74rem',
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#4285F4',
}}
>
QMD in the browser
</div>
<h1 style={{ margin: 0, fontSize: '1.7rem', color: 'var(--text)' }}>
QMD Web Sandbox
</h1>
<p style={{ margin: '0.45rem 0 0', color: 'var(--text-secondary)', fontSize: '0.9rem', lineHeight: 1.65, maxWidth: 860 }}>
A browser-native sandbox that recreates the core{' '}
<a href="https://github.com/tobi/qmd" target="_blank" rel="noopener noreferrer" style={{ color: '#4285F4', textDecoration: 'none' }}>qmd</a>
{' '}retrieval pipeline with Transformers.js, while making the local WebGPU execution path visible.
Documents are chunked, embedded, searched, fused, reranked, and inspected entirely in the browser.
</p>
<div
style={{
marginTop: '0.7rem',
display: 'inline-flex',
alignItems: 'center',
gap: '0.35rem',
flexWrap: 'wrap',
}}
>
{[
{ label: 'WebGPU', color: '#4285F4' },
{ label: 'Local cache', color: '#34a853' },
{ label: 'Transparent pipeline', color: '#00897b' },
].map(badge => (
<span
key={badge.label}
style={{
padding: '0.25rem 0.55rem',
borderRadius: '999px',
border: `1px solid ${badge.color}30`,
background: `${badge.color}10`,
color: badge.color,
fontSize: '0.72rem',
fontWeight: 600,
fontFamily: 'system-ui, -apple-system, sans-serif',
whiteSpace: 'nowrap',
}}
>
{badge.label}
</span>
))}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexShrink: 0 }}>
<a
href="https://github.com/tobi/qmd"
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: '0.78rem',
color: 'var(--text-secondary)',
textDecoration: 'none',
padding: '0.35rem 0.7rem',
border: '1px solid var(--border)',
borderRadius: '999px',
fontFamily: 'system-ui, -apple-system, sans-serif',
background: 'var(--bg-card)',
}}
onMouseEnter={(event) => { event.currentTarget.style.color = '#4285F4'; }}
onMouseLeave={(event) => { event.currentTarget.style.color = 'var(--text-secondary)'; }}
>
Original qmd
</a>
<button
onClick={toggleTheme}
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: '999px',
padding: '0.35rem 0.6rem',
cursor: 'pointer',
fontSize: '1rem',
lineHeight: 1,
color: 'var(--text)',
}}
>
{dark ? '\u2600' : '\u263E'}
</button>
</div>
</div>
<div className="showcase-grid">
{SHOWCASE_CARDS.map((card) => (
<ShowcaseCard key={card.title} title={card.title} body={card.body} />
))}
</div>
</header>
<ModelStatus models={models} />
{indexing && (
<div
style={{
padding: '0.6rem 1rem',
background: 'var(--indexing-bg)',
borderRadius: 8,
marginBottom: '1rem',
fontSize: '0.84rem',
color: 'var(--text)',
border: '1px solid var(--border)',
}}
>
Indexing local chunks in the browser ({indexingProgress.completed}/{indexingProgress.total})...
</div>
)}
<QueryInput onSearch={handleSearch} disabled={!allReady} />
{query && <PipelineView state={pipeline} query={query} intent={intent} />}
<DocumentManager
documents={documents.map((doc) => ({ id: doc.id, title: doc.title, filepath: doc.filepath }))}
onUpload={handleUpload}
onPaste={handlePaste}
/>
</div>
);
}
export default App;