<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Search</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Courier New', monospace;
background: #000;
color: #fff;
}
header {
padding: 20px 40px;
border-bottom: 1px solid #333;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 40px;
}
.logo {
font-size: 20px;
font-weight: normal;
color: #fff;
cursor: pointer;
}
.main-nav {
display: flex;
gap: 30px;
}
.nav-link {
color: #888;
text-decoration: none;
font-size: 14px;
transition: color 0.2s;
}
.nav-link:hover {
color: #fff;
}
.cost-display {
font-size: 12px;
color: #888;
display: flex;
gap: 15px;
}
.cost-item {
display: flex;
align-items: center;
gap: 5px;
}
.cost-value {
font-weight: 400;
color: #fff;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.tabs {
display: flex;
gap: 20px;
margin: 30px 0;
border-bottom: 1px solid #333;
}
.tab {
padding: 12px 0;
cursor: pointer;
color: #888;
font-size: 14px;
border-bottom: 2px solid transparent;
}
.tab.active {
color: #fff;
border-bottom-color: #fff;
}
.search-page {
text-align: center;
padding: 100px 0 40px;
}
.search-box {
max-width: 584px;
margin: 0 auto 30px;
position: relative;
}
.search-input {
width: 100%;
padding: 14px 50px 14px 20px;
border: 1px solid #333;
border-radius: 24px;
font-size: 16px;
outline: none;
background: #0a0a0a;
color: #fff;
font-family: 'Courier New', monospace;
}
.search-input::placeholder {
color: #555;
}
.search-input:hover, .search-input:focus {
box-shadow: 0 0 0 1px #555;
border-color: #555;
}
.search-btn {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: #fff;
font-size: 20px;
}
.ai-response {
max-width: 652px;
margin: 30px auto;
padding: 20px;
background: #0a0a0a;
border-radius: 0;
border-left: 2px solid #fff;
display: none;
}
.ai-response.visible {
display: block;
}
.ai-response.loading {
text-align: center;
color: #888;
}
.ai-title {
font-size: 14px;
font-weight: 400;
color: #fff;
margin-bottom: 12px;
}
.ai-content {
color: #fff;
line-height: 1.6;
}
.ai-meta {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #333;
font-size: 11px;
color: #888;
display: flex;
gap: 15px;
}
.results-container {
max-width: 652px;
margin: 0 auto;
text-align: left;
}
.result-item {
padding: 18px 0;
border-bottom: 1px solid #333;
cursor: pointer;
transition: background 0.2s;
}
.result-item:hover {
background: #0a0a0a;
margin: 0 -10px;
padding: 18px 10px;
}
.result-title {
color: #fff;
font-size: 18px;
margin-bottom: 4px;
}
.result-snippet {
color: #ccc;
font-size: 14px;
line-height: 1.58;
}
.result-snippet h1, .result-snippet h2, .result-snippet h3 {
margin: 8px 0 4px 0;
color: #fff;
}
.result-snippet h1 { font-size: 18px; }
.result-snippet h2 { font-size: 16px; }
.result-snippet h3 { font-size: 15px; }
.result-snippet p { margin: 4px 0; }
.result-snippet code {
background: #1a1a1a;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: #0f0;
}
.result-snippet pre {
background: #0a0a0a;
padding: 8px;
border-radius: 0;
overflow-x: auto;
margin: 4px 0;
border: 1px solid #333;
}
.result-meta {
color: #666;
font-size: 12px;
margin-top: 4px;
}
.upload-section {
max-width: 600px;
margin: 40px auto;
}
.upload-zone {
border: 2px dashed #333;
border-radius: 0;
padding: 60px 20px;
text-align: center;
background: #0a0a0a;
cursor: pointer;
transition: all 0.3s;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: #fff;
background: #111;
}
.upload-icon {
font-size: 48px;
color: #fff;
margin-bottom: 16px;
}
.upload-text {
color: #888;
font-size: 14px;
}
.file-input {
display: none;
}
.btn-primary {
background: #fff;
color: #000;
border: none;
padding: 12px 24px;
border-radius: 0;
cursor: pointer;
font-size: 14px;
font-weight: 400;
margin-top: 20px;
font-family: 'Courier New', monospace;
}
.btn-primary:hover {
background: #ccc;
}
.selected-files {
margin-top: 20px;
text-align: left;
}
.selected-file {
padding: 8px 12px;
background: #0a0a0a;
border-radius: 0;
margin-bottom: 8px;
font-size: 14px;
border: 1px solid #333;
color: #fff;
}
.processing-container {
max-width: 600px;
margin: 20px auto;
}
.processing-item {
background: #0a0a0a;
border: 1px solid #333;
border-radius: 0;
padding: 16px;
margin-bottom: 16px;
}
.processing-title {
font-size: 14px;
font-weight: 400;
margin-bottom: 8px;
color: #fff;
}
.progress-bar {
width: 100%;
height: 8px;
background: #1a1a1a;
border-radius: 0;
overflow: hidden;
margin-bottom: 8px;
}
.progress-fill {
height: 100%;
background: #fff;
transition: width 0.3s;
}
.processing-message {
font-size: 12px;
color: #888;
}
.cost-info {
margin-top: 8px;
padding: 8px;
background: #000;
border-radius: 0;
font-size: 12px;
color: #fff;
display: flex;
justify-content: space-between;
border: 1px solid #333;
}
.documents-list {
max-width: 800px;
margin: 40px auto;
}
.document-item {
background: #0a0a0a;
border: 1px solid #333;
border-radius: 0;
padding: 20px;
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.document-info {
flex: 1;
}
.document-name {
font-size: 16px;
font-weight: 400;
color: #fff;
margin-bottom: 4px;
}
.document-meta {
font-size: 12px;
color: #666;
}
.document-actions {
display: flex;
gap: 10px;
align-items: center;
color: #888;
}
.download-link {
color: #fff;
text-decoration: none;
font-size: 14px;
padding: 8px 16px;
border: 1px solid #333;
border-radius: 0;
}
.download-link:hover {
background: #1a1a1a;
}
.status-badge {
padding: 4px 8px;
border-radius: 0;
font-size: 12px;
font-weight: 400;
}
.status-completed {
background: #0a2a0a;
color: #0f0;
}
.status-processing {
background: #2a2a0a;
color: #ff0;
}
.status-failed {
background: #2a0a0a;
color: #f00;
}
.hidden {
display: none;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #888;
}
.document-viewer {
max-width: 900px;
margin: 40px auto;
padding: 40px;
background: #000;
}
.doc-title {
font-size: 32px;
font-weight: 400;
color: #fff;
margin-bottom: 30px;
}
.markdown-body {
color: #fff;
line-height: 1.6;
}
.markdown-body h1 {
font-size: 32px;
margin: 24px 0 16px 0;
border-bottom: 1px solid #333;
padding-bottom: 8px;
}
.markdown-body h2 {
font-size: 24px;
margin: 20px 0 12px 0;
}
.markdown-body h3 {
font-size: 20px;
margin: 16px 0 10px 0;
}
.markdown-body p {
margin: 12px 0;
}
.markdown-body code {
background: #1a1a1a;
padding: 2px 6px;
border-radius: 0;
font-family: 'Courier New', monospace;
font-size: 14px;
color: #0f0;
}
.markdown-body pre {
background: #0a0a0a;
padding: 16px;
border-radius: 0;
overflow-x: auto;
margin: 16px 0;
border: 1px solid #333;
}
.markdown-body pre code {
background: none;
padding: 0;
}
.markdown-body ul, .markdown-body ol {
margin: 12px 0;
padding-left: 30px;
}
</style>
</head>
<body>
<header>
<div class="header-left">
<div class="logo" onclick="navigateTo('')">Molodetz Library</div>
<nav class="main-nav">
<a href="https://molodetz.nl/log" class="nav-link">activity log</a>
<a href="https://molodetz.nl/projects" class="nav-link">projects</a>
<a href="https://molodetz.nl/products" class="nav-link">products</a>
<a href="https://molodetz.nl/contact" class="nav-link">contact</a>
</nav>
</div>
<div class="cost-display">
<div class="cost-item">
<span>in:</span>
<span class="cost-value" id="totalInputTokens">0</span>
</div>
<div class="cost-item">
<span>out:</span>
<span class="cost-value" id="totalOutputTokens">0</span>
</div>
<div class="cost-item">
<span></span>
<span class="cost-value" id="totalCost">0.0000</span>
</div>
</div>
</header>
<div class="container">
<div class="tabs">
<div class="tab active" data-tab="search">search</div>
<div class="tab" data-tab="upload">upload</div>
<div class="tab" data-tab="documents">all documents</div>
</div>
<div id="searchPage" class="page">
<div class="search-page">
<div class="search-box">
<input type="text" class="search-input" id="searchInput" placeholder="search documents...">
<button class="search-btn" id="searchBtn">🔍</button>
</div>
<div class="ai-response" id="aiResponse">
<div class="ai-title">ai response</div>
<div class="ai-content" id="aiContent"></div>
<div class="ai-meta" id="aiMeta"></div>
</div>
<div class="results-container" id="resultsContainer"></div>
</div>
</div>
<div id="uploadPage" class="page hidden">
<div class="upload-section">
<div class="upload-zone" id="uploadZone">
<div class="upload-icon">📄</div>
<div class="upload-text">drop files here or click to browse</div>
<input type="file" class="file-input" id="fileInput" accept=".pdf,.md,.txt" multiple>
</div>
<div class="selected-files" id="selectedFiles"></div>
<button class="btn-primary" id="uploadBtn">upload documents</button>
</div>
<div class="processing-container" id="processingContainer"></div>
</div>
<div id="documentsPage" class="page hidden">
<div class="documents-list" id="documentsList"></div>
</div>
<div id="documentViewerPage" class="page hidden">
<div class="document-viewer">
<div class="doc-title" id="docTitle"></div>
<div class="markdown-body" id="docBody"></div>
</div>
</div>
</div>
<script>
const API_BASE = '';
let selectedFiles = [];
let totalCosts = { inputTokens: 0, outputTokens: 0, cost: 0 };
// Router
function navigateTo(path) {
window.location.hash = path;
}
function handleRoute() {
const hash = window.location.hash.slice(1);
const [route, ...params] = hash.split('/');
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
if (route === 'search' && params[0]) {
showSearchResults(params[0]);
} else if (route === 'document' && params[0]) {
showDocument(params[0]);
} else if (route === 'upload') {
switchTab('upload');
} else if (route === 'documents') {
switchTab('documents');
} else {
switchTab('search');
}
}
window.addEventListener('hashchange', handleRoute);
window.addEventListener('load', handleRoute);
// Tabs
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
const tabName = tab.dataset.tab;
navigateTo(tabName);
});
});
function switchTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelector(`[data-tab="${tabName}"]`)?.classList.add('active');
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
document.getElementById(`${tabName}Page`)?.classList.remove('hidden');
if (tabName === 'documents') {
loadDocuments();
}
}
// Search
document.getElementById('searchBtn').addEventListener('click', performSearch);
document.getElementById('searchInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') performSearch();
});
async function performSearch() {
const query = document.getElementById('searchInput').value.trim();
if (!query) return;
try {
const response = await fetch(`${API_BASE}/api/search?query=${encodeURIComponent(query)}&page=1&page_size=10`);
const data = await response.json();
updateCostDisplay(data.tokens || 0, 0, data.cost_eur || 0);
navigateTo(`search/${data.slug}`);
// Check if it's a prompt
if (await isPrompt(query)) {
executePrompt(query, data.slug);
}
} catch (error) {
console.error('Search error:', error);
}
}
async function isPrompt(query) {
const keywords = ['make', 'create', 'list', 'summarize', 'explain', 'compare', 'analyze', 'generate', 'write', 'show me', 'give me', 'find all', 'extract', 'what are', 'how many'];
const lower = query.toLowerCase();
return keywords.some(k => lower.includes(k)) || (lower.endsWith('?') && query.split(' ').length > 3);
}
async function executePrompt(query, slug) {
const aiResponse = document.getElementById('aiResponse');
const aiContent = document.getElementById('aiContent');
const aiMeta = document.getElementById('aiMeta');
aiResponse.classList.add('visible', 'loading');
aiContent.innerHTML = 'generating response...';
try {
const response = await fetch(`${API_BASE}/api/prompt?query=${encodeURIComponent(query)}`, {
method: 'POST'
});
const data = await response.json();
aiResponse.classList.remove('loading');
aiContent.innerHTML = marked.parse(data.response);
aiContent.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
aiMeta.innerHTML = `
<span>input: ${data.input_tokens} tokens</span>
<span>output: ${data.output_tokens} tokens</span>
<span>cost: €${data.cost_eur.toFixed(4)}</span>
`;
updateCostDisplay(data.input_tokens, data.output_tokens, data.cost_eur);
} catch (error) {
console.error('Prompt error:', error);
aiResponse.classList.remove('loading');
aiContent.innerHTML = 'error generating response.';
}
}
async function showSearchResults(slug) {
switchTab('search');
try {
const response = await fetch(`${API_BASE}/api/search/${slug}`);
const data = await response.json();
document.getElementById('searchInput').value = data.query;
displayResults(data.results);
updateCostDisplay(data.tokens || 0, 0, data.cost_eur || 0);
// Check for cached prompt result
try {
const promptResponse = await fetch(`${API_BASE}/api/prompt/${slug}`);
if (promptResponse.ok) {
const promptData = await promptResponse.json();
const aiResponse = document.getElementById('aiResponse');
const aiContent = document.getElementById('aiContent');
const aiMeta = document.getElementById('aiMeta');
aiResponse.classList.add('visible');
aiContent.innerHTML = marked.parse(promptData.response);
aiContent.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
aiMeta.innerHTML = `
<span>input: ${promptData.input_tokens} tokens</span>
<span>output: ${promptData.output_tokens} tokens</span>
<span>cost: €${promptData.cost_eur.toFixed(4)}</span>
`;
}
} catch (e) {
// No cached prompt result
}
} catch (error) {
console.error('Error loading search:', error);
}
}
function displayResults(results) {
const container = document.getElementById('resultsContainer');
if (!results || results.length === 0) {
container.innerHTML = '<div class="empty-state">no results found</div>';
return;
}
container.innerHTML = results.map(result => {
const renderedSnippet = marked.parse(result.snippet);
const uploadDate = new Date(result.upload_date).toLocaleDateString();
return `
<div class="result-item" onclick="navigateTo('document/${result.document_id}')">
<div class="result-title">${escapeHtml(result.name)}</div>
<div class="result-snippet">${renderedSnippet}</div>
<div class="result-meta">
page ${result.page} • chunk ${result.chunk_index + 1}
score: ${(result.score * 100).toFixed(1)}% •
uploaded: ${uploadDate}
</div>
</div>
`;
}).join('');
container.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
}
async function showDocument(documentId) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
document.getElementById('documentViewerPage').classList.remove('hidden');
try {
const response = await fetch(`${API_BASE}/api/document/${documentId}`);
const doc = await response.json();
document.getElementById('docTitle').textContent = doc.name;
document.getElementById('docBody').innerHTML = marked.parse(doc.markdown_content);
document.querySelectorAll('#docBody pre code').forEach(block => hljs.highlightElement(block));
} catch (error) {
console.error('Error loading document:', error);
}
}
function updateCostDisplay(inputTokens, outputTokens, cost) {
totalCosts.inputTokens += inputTokens;
totalCosts.outputTokens += outputTokens;
totalCosts.cost += cost;
document.getElementById('totalInputTokens').textContent = totalCosts.inputTokens;
document.getElementById('totalOutputTokens').textContent = totalCosts.outputTokens;
document.getElementById('totalCost').textContent = totalCosts.cost.toFixed(4);
}
// Upload
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
selectedFiles = Array.from(e.dataTransfer.files);
updateSelectedFiles();
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
selectedFiles = Array.from(e.target.files);
updateSelectedFiles();
}
});
function updateSelectedFiles() {
const container = document.getElementById('selectedFiles');
if (selectedFiles.length === 0) {
container.innerHTML = '';
uploadZone.querySelector('.upload-text').textContent = 'drop files here or click to browse';
return;
}
uploadZone.querySelector('.upload-text').textContent = `${selectedFiles.length} file(s) selected`;
container.innerHTML = selectedFiles.map(f => `<div class="selected-file">${escapeHtml(f.name)}</div>`).join('');
}
document.getElementById('uploadBtn').addEventListener('click', async () => {
if (selectedFiles.length === 0) return alert('Please select files');
const formData = new FormData();
selectedFiles.forEach(file => formData.append('files', file));
try {
const response = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: formData });
const data = await response.json();
selectedFiles = [];
fileInput.value = '';
updateSelectedFiles();
data.documents.forEach(doc => connectWebSocket(doc.document_id, doc.name));
} catch (error) {
console.error('Upload error:', error);
}
});
function connectWebSocket(documentId, docName) {
const ws = new WebSocket(`ws://${window.location.host}/ws/status/${documentId}`);
const div = document.createElement('div');
div.className = 'processing-item';
div.innerHTML = `
<div class="processing-title">${escapeHtml(docName)}</div>
<div class="progress-bar"><div class="progress-fill" style="width: 0%"></div></div>
<div class="processing-message">starting...</div>
<div class="cost-info">
<div>tokens: <span class="tokens-count">0</span></div>
<div>cost: €<span class="cost-amount">0.0000</span></div>
</div>
`;
document.getElementById('processingContainer').appendChild(div);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
div.querySelector('.progress-fill').style.width = `${data.progress}%`;
div.querySelector('.processing-message').textContent = data.message;
div.querySelector('.tokens-count').textContent = data.tokens || 0;
div.querySelector('.cost-amount').textContent = (data.cost_eur || 0).toFixed(4);
if (data.step === 'completed') {
updateCostDisplay(data.tokens || 0, 0, data.cost_eur || 0);
setTimeout(() => div.remove(), 3000);
}
};
}
async function loadDocuments() {
try {
const response = await fetch(`${API_BASE}/api/documents`);
const documents = await response.json();
const container = document.getElementById('documentsList');
if (!documents || documents.length === 0) {
container.innerHTML = '<div class="empty-state">no documents uploaded yet</div>';
return;
}
container.innerHTML = documents.map(doc => `
<div class="document-item">
<div class="document-info">
<div class="document-name">${escapeHtml(doc.name)}</div>
<div class="document-meta">
Uploaded: ${new Date(doc.upload_time).toLocaleDateString()}
<span class="status-badge status-${doc.status}">${doc.status}</span>
</div>
</div>
<div class="document-actions">
<span>(${doc.downloads})</span>
<a href="${API_BASE}/api/download/${doc.id}" class="download-link">download</a>
</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading documents:', error);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>