Files
jobbi-bewerbung/public/js/chat.js
T
thomasandClaude 0c7502cfd7 KI-Chat: Bewerbungsdaten per Tool-Calling nachschlagen statt in Prompt stopfen
Der Systemprompt enthielt früher nur die 12 jüngsten Bewerbungen (LIMIT 12),
daher fand die KI ältere Firmen wie QBS KLIMTAX nicht. Alle Bewerbungen in den
Prompt zu laden machte den Chat sehr langsam, da der Prompt bei jedem Turn
voll neu verarbeitet wird.

Jetzt nutzt der Assistent Ollama-Tool-Calling, um Datenbankdaten on demand
nachzuschlagen. Der Systemprompt bleibt klein und konstant (nur Profil +
Datum), unabhängig von der Anzahl der Bewerbungen.

- lib/chat.js: streamChat mit tools + runChat-Tool-Schleife (max 4 Runden)
- server.js: Tools suche_bewerbungen/list_bewerbungen/bewerbung_detail/
  kommende_termine mit SQL-Queries; gatherChatContext auf Kern reduziert
- public/js/chat.js: Tool-Indikator ("durchsucht Bewerbungen…") im UI

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 05:41:14 +02:00

429 lines
17 KiB
JavaScript

// KI-Chat client: renders messages (markdown for assistant), sends messages,
// streams assistant tokens over SSE, and handles "new chat" reset.
// Tailwind CDN is loaded via the head partial; this file only handles logic.
(function () {
const boot = window.__CHAT_BOOT__ || {};
let activeId = boot.activeId || null;
const log = document.getElementById('chatLog');
const form = document.getElementById('chatForm');
const input = document.getElementById('chatInput');
const sendBtn = document.getElementById('sendBtn');
const newThreadBtn = document.getElementById('newThreadBtn');
const threadList = document.getElementById('threadList');
let sending = false;
// ----- helpers -----
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function scrollBottom() {
if (log) log.scrollTop = log.scrollHeight;
}
// Minimal markdown → HTML. HTML is escaped first, so model output can never
// inject markup; only the markdown tokens below are turned into tags.
function inlineFmt(t) {
return t
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
}
function renderMarkdown(src) {
const esc = escapeHtml(src == null ? '' : String(src)).replace(/\r\n/g, '\n');
const lines = esc.split('\n');
let html = '';
let i = 0;
let para = [];
const flushPara = () => {
if (para.length) html += '<p>' + inlineFmt(para.join(' ')) + '</p>';
para = [];
};
while (i < lines.length) {
const line = lines[i];
if (/^```/.test(line)) { // fenced code block
flushPara();
const code = [];
i++;
while (i < lines.length && !/^```/.test(lines[i])) { code.push(lines[i]); i++; }
i++; // closing fence
html += '<pre><code>' + code.join('\n') + '</code></pre>';
continue;
}
const hm = line.match(/^(#{1,6})\s+(.*)$/);
if (hm) {
flushPara();
const lvl = Math.min(hm[1].length, 3);
html += '<h' + lvl + '>' + inlineFmt(hm[2].trim()) + '</h' + lvl + '>';
i++; continue;
}
if (/^\s*[-*]\s+/.test(line)) { // bullet list
flushPara();
const items = [];
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
items.push('<li>' + inlineFmt(lines[i].replace(/^\s*[-*]\s+/, '')) + '</li>');
i++;
}
html += '<ul>' + items.join('') + '</ul>';
continue;
}
if (/^\s*\d+\.\s+/.test(line)) { // numbered list
flushPara();
const items = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push('<li>' + inlineFmt(lines[i].replace(/^\s*\d+\.\s+/, '')) + '</li>');
i++;
}
html += '<ol>' + items.join('') + '</ol>';
continue;
}
if (/^\s*$/.test(line)) { flushPara(); i++; continue; }
para.push(line.trim());
i++;
}
flushPara();
return html;
}
function clearLogToEmpty(msg) {
if (!log) return;
log.innerHTML =
'<div class="h-full flex items-center justify-center text-gray-400 text-sm px-6">' +
escapeHtml(msg) + '</div>';
}
// Build a message row. Assistant content is markdown-rendered + left aligned;
// user content is escaped plain text with preserved line breaks.
function buildRow(role, contentHtml) {
const wrap = document.createElement('div');
wrap.className = 'flex ' + (role === 'user' ? 'justify-end' : 'justify-start');
const bubble = document.createElement('div');
bubble.className = 'max-w-[85%] rounded-lg px-3 py-2 text-sm text-left ' +
(role === 'user'
? 'bg-blue-600 text-white whitespace-pre-wrap break-words'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100 chat-md');
bubble.innerHTML = contentHtml;
wrap.appendChild(bubble);
return { wrap, bubble };
}
function addMessage(role, content) {
if (log && log.querySelector('.h-full')) log.innerHTML = '';
const html = role === 'user' ? escapeHtml(content) : renderMarkdown(content);
const { wrap, bubble } = buildRow(role, html);
log.appendChild(wrap);
scrollBottom();
return bubble;
}
function typingIndicator() {
if (log && log.querySelector('.h-full')) log.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'flex justify-start';
wrap.id = 'typing';
wrap.innerHTML = '<div class="rounded-lg px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 flex gap-1">' +
'<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span></div>';
log.appendChild(wrap);
scrollBottom();
return wrap;
}
function prependThread(id, titel) {
if (!threadList) return;
const empty = threadList.querySelector('li.text-center');
if (empty) empty.remove();
const li = document.createElement('li');
li.setAttribute('data-thread-li', id);
li.innerHTML =
'<div class="flex items-center group">' +
'<a href="/chat?thread=' + encodeURIComponent(id) + '" data-thread="' + id +
'" class="threadLink flex-1 min-w-0 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/40">' +
'<div class="truncate font-medium">' + escapeHtml(titel || '(neuer Chat)') + '</div>' +
'<div class="text-[11px] text-gray-400">gerade</div>' +
'</a>' +
'<button type="button" data-rename-thread="' + id +
'" class="threadRename shrink-0 p-1.5 rounded text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 opacity-0 group-hover:opacity-100 focus:opacity-100" title="Namen ändern" aria-label="Namen ändern">' +
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">' +
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>' +
'</svg>' +
'</button>' +
'<button type="button" data-delete-thread="' + id +
'" class="threadDelete shrink-0 mr-2 p-1.5 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 opacity-0 group-hover:opacity-100 focus:opacity-100" title="Verlauf löschen" aria-label="Verlauf löschen">' +
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">' +
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"></path>' +
'</svg>' +
'</button>' +
'</div>';
threadList.insertBefore(li, threadList.firstChild);
}
// ----- "Neuer Chat": reset client-side to a fresh empty conversation -----
function resetToNewChat() {
activeId = null;
document.getElementById('activeThread').value = '';
clearLogToEmpty('Stelle eine Frage zu deinen Bewerbungen.');
if (input) { input.value = ''; input.disabled = false; input.style.height = 'auto'; input.focus(); }
if (sendBtn) sendBtn.disabled = false;
// deselect sidebar rows
threadList && threadList.querySelectorAll('.bg-blue-50, .dark\\:bg-gray-700').forEach((el) => {
el.classList.remove('bg-blue-50', 'dark:bg-gray-700');
});
history.replaceState(null, '', '/chat');
}
async function createThread() {
const res = await fetch('/chat/api/threads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ titel: '' }),
});
if (!res.ok) throw new Error('Thread konnte nicht angelegt werden.');
const data = await res.json();
activeId = data.id;
document.getElementById('activeThread').value = activeId;
prependThread(activeId, data.titel);
history.replaceState(null, '', '/chat?thread=' + activeId);
return activeId;
}
// Parse the SSE response body stream; call onEvent with each parsed data object.
async function consumeSSE(res, onEvent) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep;
while ((sep = buffer.indexOf('\n\n')) >= 0) {
const block = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
for (const line of block.split('\n')) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
try { onEvent(JSON.parse(payload)); } catch (e) { /* ignore */ }
}
}
}
}
async function sendMessage(text) {
if (sending || !text.trim()) return;
sending = true;
sendBtn.disabled = true;
if (!activeId) {
try { await createThread(); }
catch (e) { sending = false; sendBtn.disabled = false; alert(e.message); return; }
}
addMessage('user', text);
input.value = '';
input.style.height = 'auto';
const typing = typingIndicator();
let assistantBubble = null;
let assistantText = '';
try {
const res = await fetch('/chat/api/threads/' + activeId + '/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text }),
});
if (!res.ok || !res.body) {
let msg = 'Senden fehlgeschlagen (' + res.status + ')';
try { const j = await res.json(); msg = j.error || msg; } catch (e) {}
throw new Error(msg);
}
await consumeSSE(res, (ev) => {
if (ev.type === 'tool') {
// Tool lookups run before the first text token; surface a muted hint
// inside the typing indicator so the user sees activity.
const typingEl = document.getElementById('typing');
if (typingEl && !assistantBubble) {
const label = ev.label || ev.name || 'nachschlagen…';
typingEl.innerHTML = '<div class="rounded-lg px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-400 text-sm">' +
'🔍 ' + escapeHtml(label) + '</div>';
}
scrollBottom();
} else if (ev.type === 'token') {
if (!assistantBubble) {
if (typing) typing.remove();
assistantBubble = addMessage('assistant', '');
}
assistantText += ev.content;
assistantBubble.innerHTML = renderMarkdown(assistantText);
scrollBottom();
} else if (ev.type === 'done') {
if (assistantBubble && ev.content) {
assistantBubble.innerHTML = renderMarkdown(ev.content);
} else if (!assistantBubble) {
if (typing) typing.remove();
addMessage('assistant', ev.content || '(keine Antwort)');
}
// Live-update the auto-generated thread title in the sidebar.
if (ev.titel && activeId) {
const li = threadList && threadList.querySelector('li[data-thread-li="' + activeId + '"]');
const titleEl = li && li.querySelector('.threadLink .font-medium');
if (titleEl && ev.titel && ev.titel !== titleEl.textContent) titleEl.textContent = ev.titel;
}
scrollBottom();
} else if (ev.type === 'error') {
if (typing) typing.remove();
if (!assistantBubble) addMessage('assistant', 'Fehler: ' + ev.message);
else assistantBubble.innerHTML = renderMarkdown(assistantText + '\n\n_Fehler: ' + ev.message + '_');
}
});
} catch (err) {
if (typing) typing.remove();
addMessage('assistant', 'Fehler: ' + (err.message || 'unbekannt'));
} finally {
if (typing && typing.parentNode) typing.remove();
sending = false;
sendBtn.disabled = false;
input.focus();
}
}
// ----- wiring -----
if (input) {
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 160) + 'px';
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(input.value);
}
});
}
if (form) {
form.addEventListener('submit', (e) => {
e.preventDefault();
sendMessage(input.value);
});
}
if (newThreadBtn) {
newThreadBtn.addEventListener('click', resetToNewChat);
}
// Rename a thread inline: replace the title with an input, save on
// Enter/blur (PATCH), cancel on Escape.
async function saveRename(input, link, id, original) {
const val = input.value.trim();
if (!val) { link.querySelector('.font-medium').textContent = original; return; }
try {
const res = await fetch('/chat/api/threads/' + id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ titel: val }),
});
if (!res.ok) throw new Error('Speichern fehlgeschlagen');
link.querySelector('.font-medium').textContent = val;
} catch (err) {
link.querySelector('.font-medium').textContent = original;
alert(err.message);
}
}
function startRename(li, id) {
const link = li.querySelector('.threadLink');
const titleEl = link.querySelector('.font-medium');
const original = titleEl.textContent;
const input = document.createElement('input');
input.type = 'text';
input.value = original;
input.className = 'font-medium w-full bg-transparent border border-blue-400 rounded px-1 py-0.5 text-sm text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500';
titleEl.replaceWith(input);
input.focus();
input.select();
let done = false;
const commit = () => {
if (done) return; done = true;
const title = document.createElement('div');
title.className = 'truncate font-medium';
title.textContent = input.value.trim() || original;
input.replaceWith(title);
if (input.value.trim() && input.value.trim() !== original) {
saveRename(input, link, id, original);
} else {
title.textContent = original;
}
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { e.preventDefault(); done = true; const title = document.createElement('div'); title.className = 'truncate font-medium'; title.textContent = original; input.replaceWith(title); }
});
input.addEventListener('blur', commit);
}
if (threadList) {
threadList.addEventListener('click', (e) => {
const renameBtn = e.target.closest('[data-rename-thread]');
if (renameBtn) {
e.preventDefault();
e.stopPropagation();
const id = Number(renameBtn.getAttribute('data-rename-thread'));
const li = threadList.querySelector('li[data-thread-li="' + id + '"]');
if (li) startRename(li, id);
}
});
}
// Delete a thread from the sidebar. Event delegation: works for server-rendered
// rows and ones added client-side (prependThread).
if (threadList) {
threadList.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-delete-thread]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const id = Number(btn.getAttribute('data-delete-thread'));
if (!id || !confirm('Diesen Chat-Verlauf wirklich löschen?')) return;
try {
const res = await fetch('/chat/api/threads/' + id, { method: 'DELETE' });
if (!res.ok) throw new Error('Löschen fehlgeschlagen (' + res.status + ')');
} catch (err) {
alert(err.message);
return;
}
const li = threadList.querySelector('li[data-thread-li="' + id + '"]');
if (li) li.remove();
// If the deleted thread was active, start a fresh chat.
if (activeId === id) resetToNewChat();
// If no threads remain, show the empty placeholder.
if (!threadList.querySelector('li[data-thread-li]')) {
const empty = document.createElement('li');
empty.className = 'px-3 py-4 text-sm text-gray-400 text-center';
empty.textContent = 'Noch keine Verläufe.';
threadList.appendChild(empty);
}
});
}
// Render any pre-loaded messages (existing thread) with the same renderer.
function renderInitial() {
const blob = document.getElementById('initialMessages');
if (!blob) return;
let msgs = [];
try { msgs = JSON.parse(blob.textContent); } catch (e) { return; }
if (!Array.isArray(msgs) || !msgs.length) return;
if (log.querySelector('.h-full')) log.innerHTML = '';
msgs.forEach((m) => addMessage(m.role, m.content));
scrollBottom();
}
renderInitial();
scrollBottom();
if (input && !input.disabled) input.focus();
})();