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:
69
static/css/style.css
Normal file
69
static/css/style.css
Normal file
@@ -0,0 +1,69 @@
|
||||
/* Custom overrides on top of Tailwind — Copilot-style dark theme */
|
||||
|
||||
:root {
|
||||
--scrollbar-thumb: #313244;
|
||||
--scrollbar-track: #1e1e2e;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
*::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
*::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
||||
*::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: #45475a; }
|
||||
|
||||
/* Smooth selection highlight */
|
||||
::selection { background: rgba(137, 180, 250, 0.3); }
|
||||
|
||||
/* Textarea — no resize handle */
|
||||
textarea { resize: none; }
|
||||
|
||||
/* Pre block in document view */
|
||||
#doc-content {
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Inline popup fade-in */
|
||||
#inline-popup {
|
||||
animation: fadeIn 120ms ease;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: scale(0.97) translateY(-4px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
/* Chat bubble animations */
|
||||
#messages > div {
|
||||
animation: slideIn 150ms ease;
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Fix Tailwind's accent color for checkboxes */
|
||||
input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Sidebar resize handle (visual only) */
|
||||
#sidebar {
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
|
||||
/* Flash messages */
|
||||
.flash-msg {
|
||||
animation: fadeIn 200ms ease, fadeOut 300ms 3700ms ease forwards;
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
to { opacity: 0; transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
/* Remove default focus outline on editor mount */
|
||||
#editor-mount:focus { outline: none; }
|
||||
115
static/js/chat.js
Normal file
115
static/js/chat.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
126
static/js/inline_chat.js
Normal file
126
static/js/inline_chat.js
Normal 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
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