Files
notes/static/js/chat.js
SimolZimol 8ff280d68d modified: static/css/style.css
modified:   static/js/chat.js
	modified:   templates/base.html
2026-05-22 18:01:38 +02:00

134 lines
4.6 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') {
const bodyClass = isError
? 'bg-copilot-danger text-copilot-bg'
: 'bg-copilot-panel text-copilot-text';
const renderedContent = isError
? escapeHtml(content)
: (typeof marked !== 'undefined' ? marked.parse(content) : escapeHtml(content));
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="markdown-body max-w-[75%] rounded-2xl rounded-tl-sm px-4 py-3 text-sm leading-relaxed
${bodyClass} shadow-sm">${renderedContent}</div>`;
return wrap;
} 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}