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
322 lines
12 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
init();
|