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:
321
static/js/main.js
Normal file
321
static/js/main.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
init();
|
||||
Reference in New Issue
Block a user