Der Assistent kannte bisher nur die Datenbank. Fragen wie "Was muss ich ueber die Firma wissen?" konnte er damit nicht beantworten - Groesse, Produkte, News, Kultur stehen nirgends in der Bewerbung. Zwei neue Werkzeuge (web_suche, web_seite_lesen) ueber die Web-Search-API von ollama.com. Sie authentifizieren sich mit demselben OLLAMA_API_KEY, den der Benutzer fuer Chat und Dokumente ohnehin hinterlegt hat: keine zusaetzliche Konfiguration. Anders als /api/chat haengt die Websuche nicht an OLLAMA_HOST - wer rein lokal chattet, hat schlicht keine Websuche, der Rest laeuft unveraendert weiter. - Tool-Runden 4 -> 8: Bewerbung holen, suchen, Seiten lesen, antworten lief vorher ins Limit, statt zu antworten. - Antworten der Websuche hart gedeckelt, sie wandern sonst in jeder weiteren Runde erneut ins Kontextfenster. - Status-Label zeigt, wonach gesucht wird - sonst sieht der Nutzer nicht, ob die richtige Firma erwischt wurde. - Nackte URLs im Chat sind klickbar: Belege nennt das Modell meist als reine URL, nicht als Markdown-Link. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
457 lines
18 KiB
JavaScript
457 lines
18 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. Link
|
|
// targets are restricted to http(s)/mailto/relative so a `javascript:` URL
|
|
// from the model cannot become a clickable XSS vector, and the URL is stripped
|
|
// of characters that could break out of the href attribute.
|
|
function link(url, label) {
|
|
const safe = String(url).trim().replace(/["'<>`\\]/g, '');
|
|
return `<a href="${safe}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
|
}
|
|
|
|
function inlineFmt(t) {
|
|
// Fertige <a>-Tags werden geparkt, damit die Autolink-Regel unten nicht in
|
|
// eine bereits gebaute href hineinläuft und den Tag zerlegt.
|
|
const parked = [];
|
|
const park = (html) => `\u0000${parked.push(html) - 1}\u0000`;
|
|
|
|
let s = t
|
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
|
|
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
|
|
const u = String(url).trim();
|
|
if (u && !/^(https?:|mailto:|\/|#)/i.test(u)) return m;
|
|
return park(link(u, label));
|
|
});
|
|
|
|
// Nackte Quellen-URLs klickbar machen — bei Web-Recherchen nennt der
|
|
// Assistent Belege oft als reine URL, nicht als Markdown-Link. Nur http(s),
|
|
// und Satzzeichen am Ende gehören zum Satz, nicht zur Adresse.
|
|
s = s.replace(/https?:\/\/[^\s<>"'`\u0000]+/g, (u) => {
|
|
const m = u.match(/[.,;:!?)\]]+$/);
|
|
const rein = m ? u.slice(0, -m[0].length) : u;
|
|
return park(link(rein, rein)) + (m ? m[0] : '');
|
|
});
|
|
|
|
return s.replace(/\u0000(\d+)\u0000/g, (m, i) => parked[Number(i)]);
|
|
}
|
|
|
|
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();
|
|
})(); |