new file: .dockerignore

new file:   .env.example
	new file:   Dockerfile
	new file:   app.py
	new file:   blueprints/__init__.py
	new file:   blueprints/auth.py
	new file:   blueprints/chat.py
	new file:   blueprints/context.py
	new file:   blueprints/documents.py
	new file:   blueprints/main.py
	new file:   config.py
	new file:   docker-compose.yml
	new file:   models/__init__.py
	new file:   models/chat_session.py
	new file:   models/document.py
	new file:   models/user.py
	new file:   requirements.txt
	new file:   services/__init__.py
	new file:   services/document_parser.py
	new file:   services/llm_service.py
	new file:   services/rag_service.py
	new file:   services/url_scraper.py
	new file:   static/css/style.css
	new file:   static/js/chat.js
	new file:   static/js/inline_chat.js
	new file:   static/js/main.js
	new file:   templates/base.html
	new file:   templates/document_view.html
	new file:   templates/index.html
	new file:   templates/login.html
	new file:   templates/register.html
This commit is contained in:
SimolZimol
2026-05-22 16:03:50 +02:00
commit 939cc13689
31 changed files with 2025 additions and 0 deletions

115
static/js/chat.js Normal file
View File

@@ -0,0 +1,115 @@
// ── Chat UI controller ────────────────────────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
export class Chat {
constructor() {
this._container = $('#messages');
this._emptyState = $('#empty-state');
}
clear() {
this._container.innerHTML = '';
this._emptyState?.classList.remove('hidden');
}
renderHistory(messages) {
this._container.innerHTML = '';
this._emptyState?.classList.add('hidden');
messages.forEach((m) => this._appendBubble(m.role, m.content));
this._scrollBottom();
}
async send(sessionId, message, contextIds = []) {
// Immediately render user bubble
this._appendBubble('user', message);
this._emptyState?.classList.add('hidden');
// Typing indicator
const typingId = this._appendTyping();
const res = await fetch(`/api/chat/sessions/${sessionId}/ask`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, context_ids: contextIds }),
});
this._removeTyping(typingId);
const data = await res.json();
if (!res.ok) {
this._appendBubble('assistant', `Error: ${data.error || 'Unknown error'}`, true);
} else {
this._appendBubble('assistant', data.reply);
}
this._scrollBottom();
}
_appendBubble(role, content, isError = false) {
const wrap = document.createElement('div');
wrap.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'} gap-3`;
if (role === 'assistant') {
wrap.innerHTML = `
<div class="w-7 h-7 rounded-full bg-copilot-accent flex items-center justify-center shrink-0 mt-0.5">
<svg class="w-4 h-4 text-copilot-bg" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/>
</svg>
</div>
<div class="max-w-[75%] rounded-2xl rounded-tl-sm px-4 py-3 text-sm leading-relaxed
${isError ? 'bg-copilot-danger text-copilot-bg' : 'bg-copilot-panel text-copilot-text'}
shadow-sm whitespace-pre-wrap">${escapeHtml(content)}</div>`;
} else {
wrap.innerHTML = `
<div class="max-w-[75%] rounded-2xl rounded-tr-sm px-4 py-3 text-sm leading-relaxed
bg-copilot-user text-copilot-text shadow-sm whitespace-pre-wrap">
${escapeHtml(content)}
</div>
<div class="w-7 h-7 rounded-full bg-copilot-muted flex items-center justify-center shrink-0 mt-0.5 text-xs font-bold text-copilot-bg">
U
</div>`;
}
this._container.appendChild(wrap);
return wrap;
}
_appendTyping() {
const id = `typing-${Date.now()}`;
const div = document.createElement('div');
div.id = id;
div.className = 'flex justify-start gap-3';
div.innerHTML = `
<div class="w-7 h-7 rounded-full bg-copilot-accent flex items-center justify-center shrink-0 mt-0.5">
<svg class="w-4 h-4 text-copilot-bg" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/>
</svg>
</div>
<div class="bg-copilot-panel rounded-2xl rounded-tl-sm px-4 py-3 shadow-sm flex gap-1 items-center">
<span class="w-2 h-2 rounded-full bg-copilot-muted animate-bounce" style="animation-delay:0ms"></span>
<span class="w-2 h-2 rounded-full bg-copilot-muted animate-bounce" style="animation-delay:150ms"></span>
<span class="w-2 h-2 rounded-full bg-copilot-muted animate-bounce" style="animation-delay:300ms"></span>
</div>`;
this._container.appendChild(div);
this._scrollBottom();
return id;
}
_removeTyping(id) {
document.getElementById(id)?.remove();
}
_scrollBottom() {
this._container.scrollTop = this._container.scrollHeight;
}
}
function escapeHtml(str = '') {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

126
static/js/inline_chat.js Normal file
View File

@@ -0,0 +1,126 @@
// ── Inline chat for document_view.html ───────────────────────────────────────
// Uses a plain <textarea> as read-only editor (CodeMirror CDN bundle is huge;
// we use a lightweight syntax-highlighted <pre> approach instead).
const $ = (sel) => document.querySelector(sel);
const DOC_ID = window.__DOC_ID__;
let selectedText = '';
// ── Load document content ─────────────────────────────────────────────────────
async function loadDoc() {
const res = await fetch(`/api/documents/${DOC_ID}/content`);
if (!res.ok) {
$('#editor-mount').textContent = 'Failed to load document.';
return;
}
const data = await res.json();
$('#doc-name').textContent = data.name;
// Render as selectable pre
const pre = document.createElement('pre');
pre.id = 'doc-content';
pre.className = 'p-6 text-sm text-copilot-text whitespace-pre-wrap leading-relaxed outline-none min-h-full';
pre.textContent = data.content;
$('#editor-mount').appendChild(pre);
}
// ── Selection detection ───────────────────────────────────────────────────────
document.addEventListener('mouseup', (e) => {
if ($('#inline-popup')?.contains(e.target)) return;
const sel = window.getSelection();
const text = sel?.toString().trim();
if (!text || text.length < 5) {
hidePopup();
return;
}
selectedText = text;
showPopup(e.clientX, e.clientY);
});
document.addEventListener('keyup', (e) => {
if (e.key === 'Escape') hidePopup();
});
function showPopup(x, y) {
const popup = $('#inline-popup');
const preview = $('#inline-selection-preview');
const question = $('#inline-question');
const answer = $('#inline-answer');
preview.textContent = selectedText.length > 300
? selectedText.slice(0, 300) + '…'
: selectedText;
question.value = '';
answer.textContent = '';
answer.classList.add('hidden');
$('#inline-loading').classList.add('hidden');
// Position popup near the cursor, stay within viewport
const wrapper = $('#editor-wrapper').getBoundingClientRect();
const popupW = 320;
const popupH = 260;
let left = x - wrapper.left + 12;
let top = y - wrapper.top + 12;
if (left + popupW > wrapper.width) left = wrapper.width - popupW - 8;
if (top + popupH > wrapper.height) top = Math.max(0, y - wrapper.top - popupH - 12);
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
popup.classList.remove('hidden');
setTimeout(() => question.focus(), 50);
}
function hidePopup() {
$('#inline-popup').classList.add('hidden');
selectedText = '';
}
// ── Submit inline question ────────────────────────────────────────────────────
$('#inline-submit').addEventListener('click', askInline);
$('#inline-question').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
askInline();
}
});
$('#inline-close').addEventListener('click', hidePopup);
async function askInline() {
const question = $('#inline-question').value.trim();
if (!question || !selectedText) return;
const loading = $('#inline-loading');
const answer = $('#inline-answer');
const btn = $('#inline-submit');
btn.disabled = true;
loading.classList.remove('hidden');
answer.classList.add('hidden');
try {
const res = await fetch('/api/chat/inline', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selected_text: selectedText, question }),
});
const data = await res.json();
answer.textContent = res.ok ? data.reply : `Error: ${data.error}`;
answer.classList.remove('hidden');
} catch (err) {
answer.textContent = `Network error: ${err.message}`;
answer.classList.remove('hidden');
} finally {
loading.classList.add('hidden');
btn.disabled = false;
}
}
loadDoc();

321
static/js/main.js Normal file
View File

@@ -0,0 +1,321 @@
// ── Sidebar & main page controller ───────────────────────────────────────────
import { Chat } from './chat.js';
const $ = (sel) => document.querySelector(sel);
// ── State ─────────────────────────────────────────────────────────────────────
const state = {
sessions: [],
activeSessionId: null,
docs: [],
urls: [],
/** Map<string, {id, type, name}> */
selectedContext: new Map(),
};
const chat = new Chat();
// ── Boot ──────────────────────────────────────────────────────────────────────
async function init() {
await Promise.all([loadDocs(), loadUrls(), loadSessions()]);
renderContext();
}
// ── Documents ─────────────────────────────────────────────────────────────────
async function loadDocs() {
const res = await fetch('/api/documents/');
state.docs = await res.json();
renderDocs();
}
function renderDocs() {
const list = $('#doc-list');
list.innerHTML = '';
if (!state.docs.length) {
list.innerHTML = '<li class="text-xs text-copilot-muted py-1">No documents yet</li>';
return;
}
state.docs.forEach((doc) => {
const key = `doc_${doc.id}`;
const checked = state.selectedContext.has(key);
const li = document.createElement('li');
li.className = 'flex items-center gap-2 group py-0.5 hover:bg-copilot-panel rounded px-1';
li.innerHTML = `
<input type="checkbox" data-id="${doc.id}" data-type="doc"
class="ctx-checkbox accent-[#89b4fa] rounded shrink-0" ${checked ? 'checked' : ''} />
<a href="/document/${doc.id}"
class="flex-1 text-xs text-copilot-text truncate hover:text-copilot-accent transition"
title="${esc(doc.original_name)}">${esc(doc.original_name)}</a>
${doc.indexed
? '<span class="text-[10px] text-copilot-success shrink-0" title="Indexed">●</span>'
: '<span class="text-[10px] text-copilot-warning shrink-0 animate-pulse" title="Indexing…">●</span>'}
<button data-delete-doc="${doc.id}"
class="opacity-0 group-hover:opacity-100 text-copilot-muted hover:text-copilot-danger transition shrink-0"
title="Delete">
<svg class="w-3.5 h-3.5 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>`;
list.appendChild(li);
});
}
async function uploadFiles(files) {
const indicator = $('#doc-uploading');
indicator.classList.remove('hidden');
for (const file of files) {
const fd = new FormData();
fd.append('file', file);
await fetch('/api/documents/upload', { method: 'POST', body: fd });
}
await loadDocs();
indicator.classList.add('hidden');
}
// ── URLs ──────────────────────────────────────────────────────────────────────
async function loadUrls() {
const res = await fetch('/api/context/urls');
state.urls = await res.json();
renderUrls();
}
function renderUrls() {
const list = $('#url-list');
list.innerHTML = '';
if (!state.urls.length) {
list.innerHTML = '<li class="text-xs text-copilot-muted py-1">No URLs yet</li>';
return;
}
state.urls.forEach((u) => {
const key = `url_${u.id}`;
const checked = state.selectedContext.has(key);
const li = document.createElement('li');
li.className = 'flex items-center gap-2 group py-0.5 hover:bg-copilot-panel rounded px-1';
li.innerHTML = `
<input type="checkbox" data-id="${u.id}" data-type="url"
class="ctx-checkbox accent-[#89b4fa] shrink-0" ${checked ? 'checked' : ''} />
<span class="flex-1 text-xs text-copilot-text truncate" title="${esc(u.url)}">${esc(u.title)}</span>
${u.indexed
? '<span class="text-[10px] text-copilot-success shrink-0" title="Indexed">●</span>'
: '<span class="text-[10px] text-copilot-warning shrink-0 animate-pulse" title="Scraping…">●</span>'}
<button data-delete-url="${u.id}"
class="opacity-0 group-hover:opacity-100 text-copilot-muted hover:text-copilot-danger transition shrink-0"
title="Remove">
<svg class="w-3.5 h-3.5 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>`;
list.appendChild(li);
});
}
// ── Sessions ──────────────────────────────────────────────────────────────────
async function loadSessions() {
const res = await fetch('/api/chat/sessions');
state.sessions = await res.json();
renderSessions();
}
function renderSessions() {
const list = $('#session-list');
list.innerHTML = '';
if (!state.sessions.length) {
list.innerHTML = '<li class="text-xs text-copilot-muted py-1">No chats yet</li>';
return;
}
state.sessions.forEach((s) => {
const li = document.createElement('li');
const isActive = s.id === state.activeSessionId;
li.className = `flex items-center gap-1 group py-0.5 rounded px-1 cursor-pointer
${isActive ? 'bg-copilot-panel text-copilot-accent' : 'hover:bg-copilot-panel text-copilot-text'}`;
li.innerHTML = `
<span class="flex-1 text-xs truncate">${esc(s.title)}</span>
<button data-delete-session="${s.id}"
class="opacity-0 group-hover:opacity-100 text-copilot-muted hover:text-copilot-danger transition shrink-0"
title="Delete chat">
<svg class="w-3.5 h-3.5 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>`;
li.addEventListener('click', (e) => {
if (e.target.closest('[data-delete-session]')) return;
selectSession(s.id, s.title);
});
list.appendChild(li);
});
}
async function selectSession(id, title) {
state.activeSessionId = id;
$('#chat-title').textContent = title;
$('#empty-state').classList.add('hidden');
renderSessions();
const res = await fetch(`/api/chat/sessions/${id}/messages`);
const msgs = await res.json();
chat.renderHistory(msgs);
}
// ── Context chips ─────────────────────────────────────────────────────────────
function renderContext() {
const chips = $('#context-chips');
chips.innerHTML = '';
state.selectedContext.forEach((item, key) => {
const chip = document.createElement('span');
chip.className = 'inline-flex items-center gap-1 bg-copilot-panel border border-copilot-border rounded-full px-2 py-0.5 text-xs text-copilot-accent';
chip.innerHTML = `${esc(item.name)}
<button data-remove-ctx="${key}" class="text-copilot-muted hover:text-copilot-danger ml-0.5 leading-none">✕</button>`;
chips.appendChild(chip);
});
const badge = $('#context-badge');
badge.textContent = state.selectedContext.size
? `${state.selectedContext.size} context source${state.selectedContext.size > 1 ? 's' : ''} active`
: '';
}
// ── Event delegation ──────────────────────────────────────────────────────────
document.addEventListener('change', (e) => {
if (e.target.matches('.ctx-checkbox')) {
const { id, type } = e.target.dataset;
const key = `${type}_${id}`;
if (e.target.checked) {
const name =
type === 'doc'
? state.docs.find((d) => d.id == id)?.original_name ?? `Doc ${id}`
: state.urls.find((u) => u.id == id)?.title ?? `URL ${id}`;
state.selectedContext.set(key, { id: parseInt(id), type, name });
} else {
state.selectedContext.delete(key);
}
renderContext();
}
});
document.addEventListener('click', async (e) => {
const delDoc = e.target.closest('[data-delete-doc]');
if (delDoc) {
const id = delDoc.dataset.deleteDoc;
await fetch(`/api/documents/${id}`, { method: 'DELETE' });
state.selectedContext.delete(`doc_${id}`);
renderContext();
await loadDocs();
return;
}
const delUrl = e.target.closest('[data-delete-url]');
if (delUrl) {
const id = delUrl.dataset.deleteUrl;
await fetch(`/api/context/urls/${id}`, { method: 'DELETE' });
state.selectedContext.delete(`url_${id}`);
renderContext();
await loadUrls();
return;
}
const delSession = e.target.closest('[data-delete-session]');
if (delSession) {
e.stopPropagation();
const id = delSession.dataset.deleteSession;
await fetch(`/api/chat/sessions/${id}`, { method: 'DELETE' });
if (state.activeSessionId == id) {
state.activeSessionId = null;
chat.clear();
$('#chat-title').textContent = 'Select or start a chat';
$('#empty-state').classList.remove('hidden');
}
await loadSessions();
return;
}
const removeCtx = e.target.closest('[data-remove-ctx]');
if (removeCtx) {
const key = removeCtx.dataset.removeCtx;
state.selectedContext.delete(key);
// Uncheck corresponding checkbox
const [type, id] = key.split('_');
const cb = document.querySelector(`.ctx-checkbox[data-id="${id}"][data-type="${type}"]`);
if (cb) cb.checked = false;
renderContext();
}
});
// File upload
$('#file-input').addEventListener('change', (e) => {
if (e.target.files.length) uploadFiles(Array.from(e.target.files));
e.target.value = '';
});
// URL add
$('#url-form').addEventListener('submit', async (e) => {
e.preventDefault();
const input = $('#url-input');
const url = input.value.trim();
if (!url) return;
input.value = '';
await fetch('/api/context/urls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
await loadUrls();
});
// New chat
$('#btn-new-chat').addEventListener('click', async () => {
const res = await fetch('/api/chat/sessions', { method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'New Chat' }) });
const session = await res.json();
state.sessions.unshift(session);
await loadSessions();
selectSession(session.id, session.title);
});
// Send message
$('#chat-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!state.activeSessionId) {
alert('Please start or select a chat first.');
return;
}
const input = $('#chat-input');
const msg = input.value.trim();
if (!msg) return;
input.value = '';
input.style.height = '42px';
const contextIds = Array.from(state.selectedContext.values()).map((c) => ({
id: c.id,
type: c.type,
}));
await chat.send(state.activeSessionId, msg, contextIds);
// Refresh session title
await loadSessions();
});
// Auto-grow textarea
$('#chat-input').addEventListener('input', function () {
this.style.height = '42px';
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
});
// Submit on Enter (Shift+Enter = newline)
$('#chat-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
$('#chat-form').dispatchEvent(new Event('submit'));
}
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function esc(str = '') {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
init();