modified: services/llm_service.py modified: services/rag_service.py modified: static/js/chat.js
128 lines
4.4 KiB
JavaScript
128 lines
4.4 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();
|
|
|
|
try {
|
|
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);
|
|
|
|
let data;
|
|
try {
|
|
data = await res.json();
|
|
} catch {
|
|
this._appendBubble('assistant', 'Server error — could not parse response.', true);
|
|
this._scrollBottom();
|
|
return;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
this._appendBubble('assistant', `Error: ${data.error || 'Unknown error'}`, true);
|
|
} else {
|
|
this._appendBubble('assistant', data.reply);
|
|
}
|
|
} catch (err) {
|
|
this._removeTyping(typingId);
|
|
this._appendBubble('assistant', `Network error: ${err.message}`, true);
|
|
}
|
|
|
|
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, '"');
|
|
}
|