Files
notes/static/js/main.js
SimolZimol 939cc13689 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
2026-05-22 16:03:50 +02:00

322 lines
12 KiB
JavaScript

// ── 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();