feat(dashboard): add pdf workspace via shared format filter
Why: - Avoid a forked PDF UI while scoping Word vs PDF workflows. Changes: - formatFilter on shared tabs, PDF Workspace accordion tab, word-only defaults elsewhere. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
11
docs/adr/0017-shared-dashboard-sections-format-filter.md
Normal file
11
docs/adr/0017-shared-dashboard-sections-format-filter.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Shared Dashboard sections with format filter
|
||||||
|
|
||||||
|
The PDF Workspace is not a second UI. Documents, Query, Benchmarks, and Chunk Preview are composed from shared section components in `index.html`, parameterized by a format filter (`word` | `pdf`). That keeps Word Tabs and the PDF Tab behaviorally identical while listing different documents, and avoids cloning the single-file Dashboard (ADR-0004).
|
||||||
|
|
||||||
|
Rejected: copy-paste PDF Tab (diverges immediately) and “filter props only with zero extraction” when it would still duplicate large JSX blocks.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Refactor existing Tab bodies to call shared panels before adding the PDF Tab
|
||||||
|
- Filtering is client-side by filename suffix; APIs stay format-agnostic
|
||||||
|
- Report generator stays shared; PDF Experiments only get a source-format badge
|
||||||
@@ -276,6 +276,35 @@ async function api(path, opts = {}) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Format filter helpers (ADR-0017) -----------------------
|
||||||
|
function isPdfFilename(name) {
|
||||||
|
return (name || '').toLowerCase().endsWith('.pdf');
|
||||||
|
}
|
||||||
|
function isWordFilename(name) {
|
||||||
|
const n = (name || '').toLowerCase();
|
||||||
|
return n.endsWith('.doc') || n.endsWith('.docx');
|
||||||
|
}
|
||||||
|
function filterDocuments(docs, format) {
|
||||||
|
if (format === 'pdf') return (docs || []).filter(d => isPdfFilename(d.filename));
|
||||||
|
if (format === 'word') return (docs || []).filter(d => isWordFilename(d.filename));
|
||||||
|
return docs || [];
|
||||||
|
}
|
||||||
|
function filterExperiments(exps, format) {
|
||||||
|
if (format === 'pdf') return (exps || []).filter(e => isPdfFilename(e.document_filename || ''));
|
||||||
|
if (format === 'word') return (exps || []).filter(e => isWordFilename(e.document_filename || ''));
|
||||||
|
return exps || [];
|
||||||
|
}
|
||||||
|
function formatAccept(format) {
|
||||||
|
if (format === 'pdf') return '.pdf';
|
||||||
|
if (format === 'word') return '.doc,.docx';
|
||||||
|
return '.doc,.docx,.pdf';
|
||||||
|
}
|
||||||
|
function formatDropLabel(format) {
|
||||||
|
if (format === 'pdf') return '.pdf';
|
||||||
|
if (format === 'word') return '.doc or .docx';
|
||||||
|
return '.doc, .docx, or .pdf';
|
||||||
|
}
|
||||||
|
|
||||||
// -- Toast system -------------------------------------------
|
// -- Toast system -------------------------------------------
|
||||||
let _toastId = 0;
|
let _toastId = 0;
|
||||||
function ToastContainer({ toasts, removeToast }) {
|
function ToastContainer({ toasts, removeToast }) {
|
||||||
@@ -329,10 +358,10 @@ function HomeTab() {
|
|||||||
React.createElement('div', { className: 'card mt-4' },
|
React.createElement('div', { className: 'card mt-4' },
|
||||||
React.createElement('div', { className: 'card-title' }, '⚡ Quick Start'),
|
React.createElement('div', { className: 'card-title' }, '⚡ Quick Start'),
|
||||||
React.createElement('ol', { style: { paddingLeft: '20px', lineHeight: '2' } },
|
React.createElement('ol', { style: { paddingLeft: '20px', lineHeight: '2' } },
|
||||||
React.createElement('li', null, 'Upload a document in the ', React.createElement('b', null, 'Documents'), ' tab'),
|
React.createElement('li', null, 'Upload a Word document in ', React.createElement('b', null, 'Documents'), ' or a Text PDF in ', React.createElement('b', null, 'PDF')),
|
||||||
React.createElement('li', null, 'Process it with chunking strategies'),
|
React.createElement('li', null, 'Process it with chunking strategies'),
|
||||||
React.createElement('li', null, 'Ask a question in the ', React.createElement('b', null, 'Query'), ' tab'),
|
React.createElement('li', null, 'Ask a question in ', React.createElement('b', null, 'Query'), ' (or inside PDF Workspace)'),
|
||||||
React.createElement('li', null, 'Run a full benchmark in the ', React.createElement('b', null, 'Benchmarks'), ' tab'),
|
React.createElement('li', null, 'Run a full benchmark in ', React.createElement('b', null, 'Benchmarks'), ' / PDF Workspace'),
|
||||||
React.createElement('li', null, 'Check system health in the ', React.createElement('b', null, 'Admin'), ' tab')
|
React.createElement('li', null, 'Check system health in the ', React.createElement('b', null, 'Admin'), ' tab')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -340,12 +369,13 @@ function HomeTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -- Tab: Documents -----------------------------------------
|
// -- Tab: Documents -----------------------------------------
|
||||||
function DocumentsTab({ documents, setDocuments, strategies, addToast }) {
|
function DocumentsTab({ documents, setDocuments, strategies, addToast, formatFilter = 'word', hideTitle = false }) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [processing, setProcessing] = useState({});
|
const [processing, setProcessing] = useState({});
|
||||||
const [selectedStrategies, setSelectedStrategies] = useState({});
|
const [selectedStrategies, setSelectedStrategies] = useState({});
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const fileRef = useRef(null);
|
const fileRef = useRef(null);
|
||||||
|
const visibleDocs = filterDocuments(documents, formatFilter);
|
||||||
|
|
||||||
const fetchDocs = useCallback(() => {
|
const fetchDocs = useCallback(() => {
|
||||||
api('/documents').then(d => setDocuments(d.items || [])).catch(() => {});
|
api('/documents').then(d => setDocuments(d.items || [])).catch(() => {});
|
||||||
@@ -355,6 +385,15 @@ function DocumentsTab({ documents, setDocuments, strategies, addToast }) {
|
|||||||
|
|
||||||
const handleUpload = async (file) => {
|
const handleUpload = async (file) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
const name = file.name || '';
|
||||||
|
if (formatFilter === 'pdf' && !isPdfFilename(name)) {
|
||||||
|
addToast('Only .pdf files are allowed here', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formatFilter === 'word' && !isWordFilename(name)) {
|
||||||
|
addToast('Only .doc / .docx files are allowed here', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -425,7 +464,7 @@ function DocumentsTab({ documents, setDocuments, strategies, addToast }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return React.createElement('div', null,
|
return React.createElement('div', null,
|
||||||
React.createElement('h1', { style: { color: 'var(--text-heading)', marginBottom: '16px', fontSize: '22px' } }, 'Documents'),
|
!hideTitle && React.createElement('h1', { style: { color: 'var(--text-heading)', marginBottom: '16px', fontSize: '22px' } }, 'Documents'),
|
||||||
|
|
||||||
// Upload zone
|
// Upload zone
|
||||||
React.createElement('div', {
|
React.createElement('div', {
|
||||||
@@ -439,9 +478,9 @@ function DocumentsTab({ documents, setDocuments, strategies, addToast }) {
|
|||||||
React.createElement('div', { className: 'text' },
|
React.createElement('div', { className: 'text' },
|
||||||
uploading
|
uploading
|
||||||
? 'Uploading...'
|
? 'Uploading...'
|
||||||
: React.createElement('span', null, 'Drag & drop a ', React.createElement('strong', null, '.doc or .docx'), ' file, or click to browse')),
|
: React.createElement('span', null, 'Drag & drop a ', React.createElement('strong', null, formatDropLabel(formatFilter)), ' file, or click to browse')),
|
||||||
React.createElement('input', {
|
React.createElement('input', {
|
||||||
ref: fileRef, type: 'file', accept: '.doc,.docx', style: { display: 'none' },
|
ref: fileRef, type: 'file', accept: formatAccept(formatFilter), style: { display: 'none' },
|
||||||
onChange: (e) => handleUpload(e.target.files[0])
|
onChange: (e) => handleUpload(e.target.files[0])
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
@@ -449,13 +488,13 @@ function DocumentsTab({ documents, setDocuments, strategies, addToast }) {
|
|||||||
// Documents table
|
// Documents table
|
||||||
React.createElement('div', { className: 'card mt-4' },
|
React.createElement('div', { className: 'card mt-4' },
|
||||||
React.createElement('div', { className: 'flex-between mb-2' },
|
React.createElement('div', { className: 'flex-between mb-2' },
|
||||||
React.createElement('div', { className: 'card-title mb-0' }, `Uploaded Documents (${documents.length})`),
|
React.createElement('div', { className: 'card-title mb-0' }, `Uploaded Documents (${visibleDocs.length})`),
|
||||||
React.createElement('button', { className: 'btn btn-secondary btn-sm', onClick: fetchDocs }, '↻ Refresh')
|
React.createElement('button', { className: 'btn btn-secondary btn-sm', onClick: fetchDocs }, '↻ Refresh')
|
||||||
),
|
),
|
||||||
documents.length === 0
|
visibleDocs.length === 0
|
||||||
? React.createElement('div', { className: 'empty-state' },
|
? React.createElement('div', { className: 'empty-state' },
|
||||||
React.createElement('div', { className: 'icon' }, '📭'),
|
React.createElement('div', { className: 'icon' }, '📭'),
|
||||||
React.createElement('div', null, 'No documents uploaded yet'))
|
React.createElement('div', null, formatFilter === 'pdf' ? 'No PDF documents uploaded yet' : 'No documents uploaded yet'))
|
||||||
: React.createElement('table', null,
|
: React.createElement('table', null,
|
||||||
React.createElement('thead', null,
|
React.createElement('thead', null,
|
||||||
React.createElement('tr', null,
|
React.createElement('tr', null,
|
||||||
@@ -468,7 +507,7 @@ function DocumentsTab({ documents, setDocuments, strategies, addToast }) {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
React.createElement('tbody', null,
|
React.createElement('tbody', null,
|
||||||
documents.map(doc =>
|
visibleDocs.map(doc =>
|
||||||
React.createElement('tr', { key: doc.id },
|
React.createElement('tr', { key: doc.id },
|
||||||
React.createElement('td', null,
|
React.createElement('td', null,
|
||||||
React.createElement('span', { className: 'truncate', style: { display: 'inline-block' } }, doc.filename)),
|
React.createElement('span', { className: 'truncate', style: { display: 'inline-block' } }, doc.filename)),
|
||||||
@@ -534,10 +573,11 @@ function DocumentsTab({ documents, setDocuments, strategies, addToast }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -- Tab: Query ---------------------------------------------
|
// -- Tab: Query ---------------------------------------------
|
||||||
function QueryTab({ documents, strategies, addToast }) {
|
function QueryTab({ documents, strategies, addToast, formatFilter = 'word', hideTitle = false }) {
|
||||||
const [form, setForm] = useState({ document_id: '', strategy: '', question: '', top_k: 5 });
|
const [form, setForm] = useState({ document_id: '', strategy: '', question: '', top_k: 5 });
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const visibleDocs = filterDocuments(documents, formatFilter);
|
||||||
|
|
||||||
const handleQuery = async () => {
|
const handleQuery = async () => {
|
||||||
if (!form.document_id || !form.strategy || !form.question.trim()) {
|
if (!form.document_id || !form.strategy || !form.question.trim()) {
|
||||||
@@ -557,7 +597,7 @@ function QueryTab({ documents, strategies, addToast }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return React.createElement('div', null,
|
return React.createElement('div', null,
|
||||||
React.createElement('h1', { style: { color: 'var(--text-heading)', marginBottom: '16px', fontSize: '22px' } }, 'Query'),
|
!hideTitle && React.createElement('h1', { style: { color: 'var(--text-heading)', marginBottom: '16px', fontSize: '22px' } }, 'Query'),
|
||||||
|
|
||||||
React.createElement('div', { className: 'card' },
|
React.createElement('div', { className: 'card' },
|
||||||
React.createElement('div', { className: 'row' },
|
React.createElement('div', { className: 'row' },
|
||||||
@@ -568,7 +608,7 @@ function QueryTab({ documents, strategies, addToast }) {
|
|||||||
onChange: e => setForm({ ...form, document_id: e.target.value })
|
onChange: e => setForm({ ...form, document_id: e.target.value })
|
||||||
},
|
},
|
||||||
React.createElement('option', { value: '' }, 'Select document...'),
|
React.createElement('option', { value: '' }, 'Select document...'),
|
||||||
documents.map(d => React.createElement('option', { key: d.id, value: d.id }, d.filename))
|
visibleDocs.map(d => React.createElement('option', { key: d.id, value: d.id }, d.filename))
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
React.createElement('div', { className: 'col' },
|
React.createElement('div', { className: 'col' },
|
||||||
@@ -868,13 +908,15 @@ function ComparisonView({ experiments, addToast, onBack }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -- Tab: Benchmarks ----------------------------------------
|
// -- Tab: Benchmarks ----------------------------------------
|
||||||
function BenchmarksTab({ documents, strategies, addToast, questionsFile }) {
|
function BenchmarksTab({ documents, strategies, addToast, questionsFile, formatFilter = 'word', hideTitle = false }) {
|
||||||
const [experiments, setExperiments] = useState([]);
|
const [experiments, setExperiments] = useState([]);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
document_id: '', strategies: strategies.map(s => s.name),
|
document_id: '', strategies: strategies.map(s => s.name),
|
||||||
questions_file: questionsFile, top_k: 5, dry_run: false
|
questions_file: questionsFile, top_k: 5, dry_run: false
|
||||||
});
|
});
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
|
const visibleDocs = filterDocuments(documents, formatFilter);
|
||||||
|
const visibleExps = filterExperiments(experiments, formatFilter);
|
||||||
|
|
||||||
// Comparison state
|
// Comparison state
|
||||||
const [selectedExps, setSelectedExps] = useState(new Set());
|
const [selectedExps, setSelectedExps] = useState(new Set());
|
||||||
@@ -914,10 +956,10 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectAllExps = () => {
|
const selectAllExps = () => {
|
||||||
if (selectedExps.size === experiments.length) {
|
if (selectedExps.size === visibleExps.length) {
|
||||||
setSelectedExps(new Set());
|
setSelectedExps(new Set());
|
||||||
} else {
|
} else {
|
||||||
setSelectedExps(new Set(experiments.map(e => e.id)));
|
setSelectedExps(new Set(visibleExps.map(e => e.id)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -975,7 +1017,7 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile }) {
|
|||||||
}
|
}
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
try {
|
try {
|
||||||
const result = await api('/benchmarks', { method: 'POST', body: form });
|
await api('/benchmarks', { method: 'POST', body: form });
|
||||||
addToast(`Benchmark ${form.dry_run ? 'estimate' : 'complete'}`, 'success');
|
addToast(`Benchmark ${form.dry_run ? 'estimate' : 'complete'}`, 'success');
|
||||||
if (!form.dry_run) fetchExperiments();
|
if (!form.dry_run) fetchExperiments();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -995,7 +1037,7 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return React.createElement('div', null,
|
return React.createElement('div', null,
|
||||||
React.createElement('h1', { style: { color: 'var(--text-heading)', marginBottom: '16px', fontSize: '22px' } }, 'Benchmarks'),
|
!hideTitle && React.createElement('h1', { style: { color: 'var(--text-heading)', marginBottom: '16px', fontSize: '22px' } }, 'Benchmarks'),
|
||||||
|
|
||||||
// Run benchmark form
|
// Run benchmark form
|
||||||
React.createElement('div', { className: 'card' },
|
React.createElement('div', { className: 'card' },
|
||||||
@@ -1008,7 +1050,7 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile }) {
|
|||||||
onChange: e => setForm({ ...form, document_id: e.target.value })
|
onChange: e => setForm({ ...form, document_id: e.target.value })
|
||||||
},
|
},
|
||||||
React.createElement('option', { value: '' }, 'Select document...'),
|
React.createElement('option', { value: '' }, 'Select document...'),
|
||||||
documents.map(d => React.createElement('option', { key: d.id, value: d.id }, d.filename))
|
visibleDocs.map(d => React.createElement('option', { key: d.id, value: d.id }, d.filename))
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
React.createElement('div', { className: 'col', style: { maxWidth: '120px' } },
|
React.createElement('div', { className: 'col', style: { maxWidth: '120px' } },
|
||||||
@@ -1070,7 +1112,7 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile }) {
|
|||||||
// Experiments list
|
// Experiments list
|
||||||
React.createElement('div', { className: 'card mt-4' },
|
React.createElement('div', { className: 'card mt-4' },
|
||||||
React.createElement('div', { className: 'flex-between mb-2' },
|
React.createElement('div', { className: 'flex-between mb-2' },
|
||||||
React.createElement('div', { className: 'card-title mb-0' }, `Experiments (${experiments.length})`),
|
React.createElement('div', { className: 'card-title mb-0' }, `Experiments (${visibleExps.length})`),
|
||||||
React.createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
|
React.createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
|
||||||
selectedExps.size >= 2 ? React.createElement('button', {
|
selectedExps.size >= 2 ? React.createElement('button', {
|
||||||
className: 'btn btn-primary btn-sm',
|
className: 'btn btn-primary btn-sm',
|
||||||
@@ -1080,17 +1122,17 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile }) {
|
|||||||
React.createElement('button', { className: 'btn btn-secondary btn-sm', onClick: fetchExperiments }, '↻ Refresh')
|
React.createElement('button', { className: 'btn btn-secondary btn-sm', onClick: fetchExperiments }, '↻ Refresh')
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
experiments.length === 0
|
visibleExps.length === 0
|
||||||
? React.createElement('div', { className: 'empty-state' },
|
? React.createElement('div', { className: 'empty-state' },
|
||||||
React.createElement('div', { className: 'icon' }, '📊'),
|
React.createElement('div', { className: 'icon' }, '📊'),
|
||||||
React.createElement('div', null, 'No experiments yet'))
|
React.createElement('div', null, formatFilter === 'pdf' ? 'No PDF experiments yet' : 'No experiments yet'))
|
||||||
: React.createElement('table', null,
|
: React.createElement('table', null,
|
||||||
React.createElement('thead', null,
|
React.createElement('thead', null,
|
||||||
React.createElement('tr', null,
|
React.createElement('tr', null,
|
||||||
React.createElement('th', { style: { width: '36px' } },
|
React.createElement('th', { style: { width: '36px' } },
|
||||||
React.createElement('input', {
|
React.createElement('input', {
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: experiments.length > 0 && selectedExps.size === experiments.length,
|
checked: visibleExps.length > 0 && selectedExps.size === visibleExps.length,
|
||||||
onChange: selectAllExps,
|
onChange: selectAllExps,
|
||||||
style: { cursor: 'pointer' }
|
style: { cursor: 'pointer' }
|
||||||
})
|
})
|
||||||
@@ -1104,7 +1146,7 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile }) {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
React.createElement('tbody', null,
|
React.createElement('tbody', null,
|
||||||
experiments.map(exp => React.createElement(React.Fragment, { key: exp.id },
|
visibleExps.map(exp => React.createElement(React.Fragment, { key: exp.id },
|
||||||
React.createElement('tr', {
|
React.createElement('tr', {
|
||||||
className: selectedExps.has(exp.id) ? 'cmp-highlight' : '',
|
className: selectedExps.has(exp.id) ? 'cmp-highlight' : '',
|
||||||
style: { cursor: 'pointer' },
|
style: { cursor: 'pointer' },
|
||||||
@@ -1228,6 +1270,165 @@ function CollapseSection({ title, icon, open, onToggle, badge, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Shared: Chunk Preview Panel ----------------------------
|
||||||
|
function ChunkPreviewPanel({ documents, strategies, addToast, formatFilter = 'all' }) {
|
||||||
|
const visibleDocs = filterDocuments(documents, formatFilter);
|
||||||
|
const [chunkDocId, setChunkDocId] = useState('');
|
||||||
|
const [chunkStrategy, setChunkStrategy] = useState('');
|
||||||
|
const [chunkData, setChunkData] = useState(null);
|
||||||
|
const [chunkLoading, setChunkLoading] = useState(false);
|
||||||
|
const [expandedChunks, setExpandedChunks] = useState({});
|
||||||
|
|
||||||
|
const loadChunks = async () => {
|
||||||
|
if (!chunkDocId) { addToast('Select a document', 'error'); return; }
|
||||||
|
setChunkLoading(true);
|
||||||
|
setChunkData(null);
|
||||||
|
setExpandedChunks({});
|
||||||
|
try {
|
||||||
|
let url = `/admin/chunks/${chunkDocId}`;
|
||||||
|
if (chunkStrategy) url += `?strategy=${chunkStrategy}`;
|
||||||
|
const data = await api(url);
|
||||||
|
setChunkData(data);
|
||||||
|
} catch (e) { addToast(`Chunk load failed: ${e.message}`, 'error'); }
|
||||||
|
finally { setChunkLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return React.createElement(React.Fragment, null,
|
||||||
|
React.createElement('div', { className: 'row mb-4' },
|
||||||
|
React.createElement('div', { className: 'col' },
|
||||||
|
React.createElement('label', null, 'Document'),
|
||||||
|
React.createElement('select', {
|
||||||
|
value: chunkDocId,
|
||||||
|
onChange: e => { setChunkDocId(e.target.value); setChunkData(null); }
|
||||||
|
},
|
||||||
|
React.createElement('option', { value: '' }, 'Select document...'),
|
||||||
|
visibleDocs.map(d => React.createElement('option', { key: d.id, value: d.id }, d.filename))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
React.createElement('div', { className: 'col' },
|
||||||
|
React.createElement('label', null, 'Strategy (optional)'),
|
||||||
|
React.createElement('select', {
|
||||||
|
value: chunkStrategy,
|
||||||
|
onChange: e => { setChunkStrategy(e.target.value); setChunkData(null); }
|
||||||
|
},
|
||||||
|
React.createElement('option', { value: '' }, 'All strategies'),
|
||||||
|
strategies.map(s => React.createElement('option', { key: s.name, value: s.name }, s.name))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
React.createElement('div', { style: { flex: 'none' } },
|
||||||
|
React.createElement('button', {
|
||||||
|
className: 'btn btn-primary', onClick: loadChunks, disabled: chunkLoading || !chunkDocId,
|
||||||
|
}, chunkLoading ? React.createElement('span', { className: 'spinner' }) : 'Load Chunks')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
chunkData && Object.entries(chunkData.strategies || {}).map(([stratName, stratData]) =>
|
||||||
|
React.createElement('div', { key: stratName, style: { marginBottom: '16px' } },
|
||||||
|
React.createElement('div', { className: 'flex-between mb-2' },
|
||||||
|
React.createElement('h3', { style: { fontSize: '14px', fontWeight: 600, color: 'var(--text-heading)' } },
|
||||||
|
`${stratName} `,
|
||||||
|
React.createElement('span', { className: 'badge badge-accent' }, `${stratData.count} chunks`)),
|
||||||
|
stratData.error ? React.createElement('span', { className: 'badge badge-danger' }, stratData.error) : null
|
||||||
|
),
|
||||||
|
stratData.count === 0
|
||||||
|
? React.createElement('div', { className: 'text-sm text-muted', style: { padding: '8px 0' } }, 'No chunks for this strategy')
|
||||||
|
: React.createElement('table', null,
|
||||||
|
React.createElement('thead', null,
|
||||||
|
React.createElement('tr', null,
|
||||||
|
React.createElement('th', { style: { width: '40px' } }, '#'),
|
||||||
|
React.createElement('th', null, 'Text'),
|
||||||
|
React.createElement('th', { style: { width: '60px' } }, 'Tokens'),
|
||||||
|
React.createElement('th', { style: { width: '60px' } }, 'Chars'),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
React.createElement('tbody', null,
|
||||||
|
(stratData.chunks || []).map((chunk, i) => {
|
||||||
|
const key = `${stratName}-${chunk.chunk_index ?? i}`;
|
||||||
|
const isExpanded = expandedChunks[key];
|
||||||
|
return React.createElement(React.Fragment, { key },
|
||||||
|
React.createElement('tr', {
|
||||||
|
className: 'chunk-row',
|
||||||
|
onClick: () => setExpandedChunks(prev => ({ ...prev, [key]: !prev[key] })),
|
||||||
|
},
|
||||||
|
React.createElement('td', { className: 'text-muted text-sm' }, chunk.chunk_index ?? i + 1),
|
||||||
|
React.createElement('td', { className: 'text-sm' },
|
||||||
|
React.createElement('span', { style: { display: '-webkit-box', WebkitLineClamp: isExpanded ? 'unset' : 1, WebkitBoxOrient: 'vertical', overflow: isExpanded ? 'visible' : 'hidden' } },
|
||||||
|
chunk.text || '')),
|
||||||
|
React.createElement('td', { className: 'text-sm text-muted' }, chunk.token_count),
|
||||||
|
React.createElement('td', { className: 'text-sm text-muted' }, chunk.character_count),
|
||||||
|
),
|
||||||
|
isExpanded ? React.createElement('tr', null,
|
||||||
|
React.createElement('td', { colSpan: 4 },
|
||||||
|
React.createElement('div', { className: 'chunk-detail' },
|
||||||
|
React.createElement('div', null, chunk.text || '(empty)'),
|
||||||
|
React.createElement('div', { className: 'meta' },
|
||||||
|
`chunk_id: ${chunk.chunk_id} | parent: ${chunk.parent_id || 'none'}`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) : null
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Tab: PDF Workspace -------------------------------------
|
||||||
|
function PdfWorkspaceTab({ documents, setDocuments, strategies, addToast, questionsFile }) {
|
||||||
|
const [openSections, setOpenSections] = useState({
|
||||||
|
upload: true, chunks: false, query: false, benchmarks: false,
|
||||||
|
});
|
||||||
|
const toggle = (key) => setOpenSections(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
const pdfCount = filterDocuments(documents, 'pdf').length;
|
||||||
|
|
||||||
|
return React.createElement('div', null,
|
||||||
|
React.createElement('h1', { style: { color: 'var(--text-heading)', marginBottom: '8px', fontSize: '22px' } }, 'PDF Workspace'),
|
||||||
|
React.createElement('p', { className: 'text-muted mb-4', style: { maxWidth: '640px' } },
|
||||||
|
'Text PDF only — upload, process, preview chunks, query, and benchmark. Scanned/image PDFs are rejected at upload.'),
|
||||||
|
|
||||||
|
React.createElement(CollapseSection, {
|
||||||
|
title: 'Upload & Process', icon: '📄', open: openSections.upload,
|
||||||
|
onToggle: () => toggle('upload'),
|
||||||
|
badge: pdfCount ? `${pdfCount} PDFs` : '0',
|
||||||
|
},
|
||||||
|
React.createElement(DocumentsTab, {
|
||||||
|
documents, setDocuments, strategies, addToast,
|
||||||
|
formatFilter: 'pdf', hideTitle: true,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
|
||||||
|
React.createElement(CollapseSection, {
|
||||||
|
title: 'Chunk Preview', icon: '📦', open: openSections.chunks,
|
||||||
|
onToggle: () => toggle('chunks'),
|
||||||
|
},
|
||||||
|
React.createElement(ChunkPreviewPanel, {
|
||||||
|
documents, strategies, addToast, formatFilter: 'pdf',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
|
||||||
|
React.createElement(CollapseSection, {
|
||||||
|
title: 'Query', icon: '⚡', open: openSections.query,
|
||||||
|
onToggle: () => toggle('query'),
|
||||||
|
},
|
||||||
|
React.createElement(QueryTab, {
|
||||||
|
documents, strategies, addToast,
|
||||||
|
formatFilter: 'pdf', hideTitle: true,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
|
||||||
|
React.createElement(CollapseSection, {
|
||||||
|
title: 'Benchmarks', icon: '📊', open: openSections.benchmarks,
|
||||||
|
onToggle: () => toggle('benchmarks'),
|
||||||
|
},
|
||||||
|
React.createElement(BenchmarksTab, {
|
||||||
|
documents, strategies, addToast, questionsFile,
|
||||||
|
formatFilter: 'pdf', hideTitle: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -- Tab: Admin ---------------------------------------------
|
// -- Tab: Admin ---------------------------------------------
|
||||||
function AdminTab({ addToast, documents, strategies, setActiveTab, setQuestionsFile }) {
|
function AdminTab({ addToast, documents, strategies, setActiveTab, setQuestionsFile }) {
|
||||||
// Collapse state for each section
|
// Collapse state for each section
|
||||||
@@ -1253,27 +1454,6 @@ function AdminTab({ addToast, documents, strategies, setActiveTab, setQuestionsF
|
|||||||
// -- Qdrant state --
|
// -- Qdrant state --
|
||||||
const [collectionsData, setCollectionsData] = useState([]);
|
const [collectionsData, setCollectionsData] = useState([]);
|
||||||
|
|
||||||
// -- Chunk Preview state --
|
|
||||||
const [chunkDocId, setChunkDocId] = useState('');
|
|
||||||
const [chunkStrategy, setChunkStrategy] = useState('');
|
|
||||||
const [chunkData, setChunkData] = useState(null);
|
|
||||||
const [chunkLoading, setChunkLoading] = useState(false);
|
|
||||||
const [expandedChunks, setExpandedChunks] = useState({});
|
|
||||||
|
|
||||||
const loadChunks = async () => {
|
|
||||||
if (!chunkDocId) { addToast('Select a document', 'error'); return; }
|
|
||||||
setChunkLoading(true);
|
|
||||||
setChunkData(null);
|
|
||||||
setExpandedChunks({});
|
|
||||||
try {
|
|
||||||
let url = `/admin/chunks/${chunkDocId}`;
|
|
||||||
if (chunkStrategy) url += `?strategy=${chunkStrategy}`;
|
|
||||||
const data = await api(url);
|
|
||||||
setChunkData(data);
|
|
||||||
} catch (e) { addToast(`Chunk load failed: ${e.message}`, 'error'); }
|
|
||||||
finally { setChunkLoading(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// -- Questions state --
|
// -- Questions state --
|
||||||
const [qFiles, setQFiles] = useState([]);
|
const [qFiles, setQFiles] = useState([]);
|
||||||
const [qLoading, setQLoading] = useState(false);
|
const [qLoading, setQLoading] = useState(false);
|
||||||
@@ -1425,85 +1605,10 @@ function AdminTab({ addToast, documents, strategies, setActiveTab, setQuestionsF
|
|||||||
React.createElement(CollapseSection, {
|
React.createElement(CollapseSection, {
|
||||||
title: '📦 Chunk Preview', icon: '', open: openSections.chunks,
|
title: '📦 Chunk Preview', icon: '', open: openSections.chunks,
|
||||||
onToggle: () => toggle('chunks'),
|
onToggle: () => toggle('chunks'),
|
||||||
badge: chunkData ? `${chunkData.strategies ? Object.values(chunkData.strategies).reduce((a, s) => a + (s.count || 0), 0) : 0} chunks` : null,
|
|
||||||
},
|
},
|
||||||
React.createElement('div', { className: 'row mb-4' },
|
React.createElement(ChunkPreviewPanel, {
|
||||||
React.createElement('div', { className: 'col' },
|
documents, strategies, addToast, formatFilter: 'all',
|
||||||
React.createElement('label', null, 'Document'),
|
})
|
||||||
React.createElement('select', {
|
|
||||||
value: chunkDocId,
|
|
||||||
onChange: e => { setChunkDocId(e.target.value); setChunkData(null); }
|
|
||||||
},
|
|
||||||
React.createElement('option', { value: '' }, 'Select document...'),
|
|
||||||
documents.map(d => React.createElement('option', { key: d.id, value: d.id }, d.filename))
|
|
||||||
)
|
|
||||||
),
|
|
||||||
React.createElement('div', { className: 'col' },
|
|
||||||
React.createElement('label', null, 'Strategy (optional)'),
|
|
||||||
React.createElement('select', {
|
|
||||||
value: chunkStrategy,
|
|
||||||
onChange: e => { setChunkStrategy(e.target.value); setChunkData(null); }
|
|
||||||
},
|
|
||||||
React.createElement('option', { value: '' }, 'All strategies'),
|
|
||||||
strategies.map(s => React.createElement('option', { key: s.name, value: s.name }, s.name))
|
|
||||||
)
|
|
||||||
),
|
|
||||||
React.createElement('div', { style: { flex: 'none' } },
|
|
||||||
React.createElement('button', {
|
|
||||||
className: 'btn btn-primary', onClick: loadChunks, disabled: chunkLoading || !chunkDocId,
|
|
||||||
}, chunkLoading ? React.createElement('span', { className: 'spinner' }) : 'Load Chunks')
|
|
||||||
)
|
|
||||||
),
|
|
||||||
chunkData && Object.entries(chunkData.strategies || {}).map(([stratName, stratData]) =>
|
|
||||||
React.createElement('div', { key: stratName, style: { marginBottom: '16px' } },
|
|
||||||
React.createElement('div', { className: 'flex-between mb-2' },
|
|
||||||
React.createElement('h3', { style: { fontSize: '14px', fontWeight: 600, color: 'var(--text-heading)' } },
|
|
||||||
`${stratName} `,
|
|
||||||
React.createElement('span', { className: 'badge badge-accent' }, `${stratData.count} chunks`)),
|
|
||||||
stratData.error ? React.createElement('span', { className: 'badge badge-danger' }, stratData.error) : null
|
|
||||||
),
|
|
||||||
stratData.count === 0
|
|
||||||
? React.createElement('div', { className: 'text-sm text-muted', style: { padding: '8px 0' } }, 'No chunks for this strategy')
|
|
||||||
: React.createElement('table', null,
|
|
||||||
React.createElement('thead', null,
|
|
||||||
React.createElement('tr', null,
|
|
||||||
React.createElement('th', { style: { width: '40px' } }, '#'),
|
|
||||||
React.createElement('th', null, 'Text'),
|
|
||||||
React.createElement('th', { style: { width: '60px' } }, 'Tokens'),
|
|
||||||
React.createElement('th', { style: { width: '60px' } }, 'Chars'),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
React.createElement('tbody', null,
|
|
||||||
(stratData.chunks || []).map((chunk, i) => {
|
|
||||||
const key = `${stratName}-${chunk.chunk_index ?? i}`;
|
|
||||||
const isExpanded = expandedChunks[key];
|
|
||||||
return React.createElement(React.Fragment, { key },
|
|
||||||
React.createElement('tr', {
|
|
||||||
className: 'chunk-row',
|
|
||||||
onClick: () => setExpandedChunks(prev => ({ ...prev, [key]: !prev[key] })),
|
|
||||||
},
|
|
||||||
React.createElement('td', { className: 'text-muted text-sm' }, chunk.chunk_index ?? i + 1),
|
|
||||||
React.createElement('td', { className: 'text-sm' },
|
|
||||||
React.createElement('span', { style: { display: '-webkit-box', WebkitLineClamp: isExpanded ? 'unset' : 1, WebkitBoxOrient: 'vertical', overflow: isExpanded ? 'visible' : 'hidden' } },
|
|
||||||
chunk.text || '')),
|
|
||||||
React.createElement('td', { className: 'text-sm text-muted' }, chunk.token_count),
|
|
||||||
React.createElement('td', { className: 'text-sm text-muted' }, chunk.character_count),
|
|
||||||
),
|
|
||||||
isExpanded ? React.createElement('tr', null,
|
|
||||||
React.createElement('td', { colSpan: 4 },
|
|
||||||
React.createElement('div', { className: 'chunk-detail' },
|
|
||||||
React.createElement('div', null, chunk.text || '(empty)'),
|
|
||||||
React.createElement('div', { className: 'meta' },
|
|
||||||
`chunk_id: ${chunk.chunk_id} | parent: ${chunk.parent_id || 'none'}`)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) : null
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// -- 4. Questions Management --
|
// -- 4. Questions Management --
|
||||||
@@ -1651,6 +1756,7 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api('/strategies').then(d => setStrategies(d.strategies || [])).catch(() => {});
|
api('/strategies').then(d => setStrategies(d.strategies || [])).catch(() => {});
|
||||||
|
api('/documents').then(d => setDocuments(d.items || [])).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addToast = useCallback((message, type = 'success') => {
|
const addToast = useCallback((message, type = 'success') => {
|
||||||
@@ -1666,6 +1772,7 @@ function App() {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'home', label: 'Home' },
|
{ id: 'home', label: 'Home' },
|
||||||
{ id: 'documents', label: 'Documents' },
|
{ id: 'documents', label: 'Documents' },
|
||||||
|
{ id: 'pdf', label: 'PDF' },
|
||||||
{ id: 'query', label: 'Query' },
|
{ id: 'query', label: 'Query' },
|
||||||
{ id: 'benchmarks', label: 'Benchmarks' },
|
{ id: 'benchmarks', label: 'Benchmarks' },
|
||||||
{ id: 'admin', label: 'Admin' },
|
{ id: 'admin', label: 'Admin' },
|
||||||
@@ -1675,11 +1782,13 @@ function App() {
|
|||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'home': return React.createElement(HomeTab);
|
case 'home': return React.createElement(HomeTab);
|
||||||
case 'documents': return React.createElement(DocumentsTab, {
|
case 'documents': return React.createElement(DocumentsTab, {
|
||||||
documents, setDocuments, strategies, addToast });
|
documents, setDocuments, strategies, addToast, formatFilter: 'word' });
|
||||||
|
case 'pdf': return React.createElement(PdfWorkspaceTab, {
|
||||||
|
documents, setDocuments, strategies, addToast, questionsFile });
|
||||||
case 'query': return React.createElement(QueryTab, {
|
case 'query': return React.createElement(QueryTab, {
|
||||||
documents, strategies, addToast });
|
documents, strategies, addToast, formatFilter: 'word' });
|
||||||
case 'benchmarks': return React.createElement(BenchmarksTab, {
|
case 'benchmarks': return React.createElement(BenchmarksTab, {
|
||||||
documents, strategies, addToast, questionsFile });
|
documents, strategies, addToast, questionsFile, formatFilter: 'word' });
|
||||||
case 'admin': return React.createElement(AdminTab, {
|
case 'admin': return React.createElement(AdminTab, {
|
||||||
addToast, documents, strategies, setActiveTab, setQuestionsFile });
|
addToast, documents, strategies, setActiveTab, setQuestionsFile });
|
||||||
default: return null;
|
default: return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user