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
116 lines
4.0 KiB
JavaScript
116 lines
4.0 KiB
JavaScript
// ── 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, '"');
|
|
}
|