// ── DOM Helpers ── const $ = (sel) => document.querySelector(sel); const chatEl = () => $('#chat'); const metaEl = () => $('#meta'); // ── Markdown ── marked.setOptions({ breaks: true, gfm: true }); function renderMarkdown(text) { if (!text) return ''; var html = marked.parse(text.trim()); // [Krea: dateiname.png] → klickbarer Bild-Link html = html.replace(/\[Krea:\s*([^\]]+)\]/g, function(match, filename) { var fn = filename.trim(); var safeFn = esc(fn); return '' + ' ' + safeFn + ''; }); return html; } function scrollDown() { const chat = chatEl(); if (!chat) return; chat.scrollTop = chat.scrollHeight; // Nach Font-Swap kann sich die Höhe ändern → nochmal scrollen document.fonts.ready.then(() => { chat.scrollTop = chat.scrollHeight; }); } function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } function truncate(s, max) { return s && s.length > max ? s.substring(0, max) + '...' : (s || ''); } // ── Mark Button ── function _addMarkButton(div, messageId, isMarked) { if (!messageId) return; // kein DB-Eintrag → kein Button const btn = document.createElement('button'); btn.className = 'msg-mark-btn icon-btn' + (isMarked ? ' marked' : ''); btn.innerHTML = isMarked ? '' : ''; btn.title = 'Als wichtig markieren'; btn.addEventListener('click', async (e) => { e.stopPropagation(); try { const result = await apiToggleMessageMark(messageId); btn.classList.toggle('marked', result.isMarked); btn.innerHTML = result.isMarked ? '' : ''; div.classList.toggle('marked', result.isMarked); } catch (err) { console.error('Mark toggle failed:', err); } }); const replyBtn = div.querySelector('.msg-reply-btn'); if (replyBtn) { replyBtn.after(btn); } else { div.prepend(btn); } } // ── Reply Button ── function _addReplyButton(div) { const btn = document.createElement('button'); btn.className = 'msg-reply-btn icon-btn'; btn.innerHTML = ''; btn.title = 'Antworten'; btn.addEventListener('click', (e) => { e.stopPropagation(); // Nur den eigentlichen Nachrichten-Text holen (ohne Reply-Button-Text) const clone = div.cloneNode(true); clone.querySelectorAll('.msg-reply-btn, .attachment-indicator, .reply-quote').forEach(el => el.remove()); const text = clone.textContent.trim(); setReply({ role: div.dataset.role, timestamp: div.dataset.timestamp || '', text }); }); div.prepend(btn); } // ── Message Bubbles ── function renderUserContent(container, text) { const raw = (text || '').trim(); // Reply-Zitat erkennen und als visuellen Block rendern // Format: > [Zitat: HH:MM, Role]:\n> text...\n\nrest const quoteMatch = raw.match(/^> \[Zitat: ([^\]]+)\]:\n((?:> .*\n?)*)\n*([\s\S]*)$/); if (quoteMatch) { const quoteMeta = quoteMatch[1]; // z.B. "14:32, Assistant" const quoteBody = quoteMatch[2].replace(/^> /gm, '').trim(); const restText = quoteMatch[3].trim(); const quoteEl = document.createElement('div'); quoteEl.className = 'reply-quote'; const metaEl = document.createElement('div'); metaEl.className = 'reply-quote-meta'; metaEl.innerHTML = ' ' + esc(quoteMeta); quoteEl.appendChild(metaEl); const bodyEl = document.createElement('div'); bodyEl.className = 'reply-quote-text'; bodyEl.textContent = truncate(quoteBody, 200); quoteEl.appendChild(bodyEl); container.appendChild(quoteEl); // Rest als normalen Text weiterverarbeiten if (restText) { _renderUserTextContent(container, restText); } return; } _renderUserTextContent(container, raw); } function _renderUserTextContent(container, raw) { // [Datei: path] oder [Datei: path:1-10] erkennen const pattern = /\[Datei: ([^\]]+)\]/g; let last = 0; let match; const frag = document.createDocumentFragment(); while ((match = pattern.exec(raw)) !== null) { // Text vor dem Tag if (match.index > last) { frag.appendChild(document.createTextNode(raw.slice(last, match.index))); } const ref = match[1]; // z.B. "src/foo.cs:10-25" // Pfad ohne Zeilenangabe extrahieren const filePath = ref.replace(/:\d+(-\d+)?$/, ''); const chip = document.createElement('span'); chip.className = 'file-ref-chip'; // Schöne Darstellung: Icon + Kurzpfad const fileName = filePath.includes('/') ? filePath.substring(filePath.lastIndexOf('/') + 1) : filePath; const lineInfo = ref.replace(filePath, '').trim(); // z.B. ":10-25" chip.innerHTML = ' ' + esc(fileName + lineInfo); chip.title = ref; chip.style.cursor = 'pointer'; chip.addEventListener('click', () => { openFileExplorer({ sessionId: getCurrentSessionId(), initialFile: filePath }); }); frag.appendChild(chip); last = match.index + match[0].length; } // Resttext if (last < raw.length) { frag.appendChild(document.createTextNode(raw.slice(last))); } container.appendChild(frag); } function addUserBubble(text, streamed = false, timestamp = '', messageId = null, isMarked = false) { const div = document.createElement('div'); div.className = 'msg user' + (isMarked ? ' marked' : ''); div.dataset.role = 'user'; div.dataset.timestamp = timestamp || new Date().toISOString(); if (messageId) div.dataset.messageId = messageId; if (streamed) div.classList.add('streamed'); renderUserContent(div, text); _addReplyButton(div); _addMarkButton(div, messageId, isMarked); chatEl().appendChild(div); scrollDown(); } function addAssistantBubble(text, timestamp = '', messageId = null, isMarked = false) { const div = document.createElement('div'); div.className = 'msg assistant markdown' + (isMarked ? ' marked' : ''); div.dataset.role = 'assistant'; div.dataset.timestamp = timestamp || new Date().toISOString(); if (messageId) div.dataset.messageId = messageId; div.innerHTML = renderMarkdown(text); _addReplyButton(div); _addMarkButton(div, messageId, isMarked); chatEl().appendChild(div); scrollDown(); return div; } function addErrorBubble(text, streamed = false) { const div = document.createElement('div'); div.className = 'msg system-info'; if (streamed) div.classList.add('streamed'); div.style.color = 'var(--dangerous)'; div.innerHTML = ' ' + esc((text || '').trim()); chatEl().appendChild(div); scrollDown(); } function addWarningBubble(text, streamed = false) { const div = document.createElement('div'); div.className = 'msg system-info'; if (streamed) div.classList.add('streamed'); div.style.color = 'var(--warning)'; div.innerHTML = ' ' + esc((text || '').trim()); chatEl().appendChild(div); scrollDown(); } // ── Streaming Bubble ── let _streamDiv = null; let _streamText = ''; function startStreamBubble() { _streamDiv = document.createElement('div'); _streamDiv.className = 'msg assistant markdown streamed'; _streamDiv.innerHTML = '

'; _streamText = ''; chatEl().appendChild(_streamDiv); scrollDown(); } function appendToStream(text) { if (!_streamDiv) startStreamBubble(); _streamText += text; _streamDiv.innerHTML = renderMarkdown(_streamText); // Cursor ins letzte Inline/Text-Element einfügen, damit er nicht auf eine neue Zeile rutscht const cursor = document.createElement('span'); cursor.className = 'cursor'; const lastBlock = _streamDiv.lastElementChild; if (lastBlock) { lastBlock.appendChild(cursor); } else { _streamDiv.appendChild(cursor); } scrollDown(); } function endStreamBubble() { if (_streamDiv) { const cursor = _streamDiv.querySelector('.cursor'); if (cursor) cursor.remove(); if (!_streamText.trim()) _streamDiv.remove(); else _streamDiv.innerHTML = renderMarkdown(_streamText); } _streamDiv = null; _streamText = ''; } // ── Tool Call Visibility ── const MODIFICATION_TOOLS = ['write_file', 'edit_file', 'delete_file', 'memory_write']; function isToolVisible(toolName) { const mode = loadSettings().toolCallDisplay; if (mode === 'all') return true; if (mode === 'hidden') return false; // 'modifications' – nur modifizierende Tools anzeigen return MODIFICATION_TOOLS.includes(toolName); } // ── Chain Meta Visibility ── function isChainMetaVisible(chainData) { const mode = loadSettings().chainMetaDisplay; if (mode === 'always') return true; if (mode === 'never') return false; // 'sometimes' – nur bei auffälligen Chains anzeigen if (!chainData) return false; const inOut = (chainData.inputTokens || 0) + (chainData.outputTokens || 0); if (inOut >= 10000) return true; if ((chainData.iterations || 0) >= 25) return true; if ((chainData.durationMs || 0) >= 90000) return true; return false; } // ── Tool Boxes ── function addToolBoxLoading(name) { const div = document.createElement('div'); div.className = 'tool-box streamed'; div.innerHTML = '' + esc(name) + ' '; chatEl().appendChild(div); scrollDown(); return div; } function renderToolBoxContent(div, name, inputJson, result, isError) { let parsedInput = null; try { parsedInput = JSON.parse(inputJson); } catch {} const header = document.createElement('div'); header.className = 'tool-box-header'; const nameSpan = document.createElement('span'); nameSpan.className = 'tool-name'; nameSpan.textContent = name; header.appendChild(nameSpan); if (isError) { const errSpan = document.createElement('span'); errSpan.className = 'tool-error-badge'; errSpan.textContent = 'error'; header.appendChild(errSpan); } div.innerHTML = ''; div.appendChild(header); let foldTarget = null; if (name === 'edit_file' && parsedInput && parsedInput.path) { const pathSpan = document.createElement('span'); pathSpan.className = 'tool-input'; pathSpan.textContent = parsedInput.path; header.appendChild(pathSpan); const diff = buildEditDiff(parsedInput.old_text || '', parsedInput.new_text || ''); diff.hidden = true; div.appendChild(diff); foldTarget = diff; } else if (name === 'write_file' && parsedInput && parsedInput.path) { const pathSpan = document.createElement('span'); pathSpan.className = 'tool-input'; pathSpan.textContent = parsedInput.path; header.appendChild(pathSpan); const content = parsedInput.content || ''; const lines = content.split('\n'); const lineCount = lines.length; const charCount = content.length; const meta = document.createElement('span'); meta.className = 'tool-write-meta'; meta.textContent = lineCount + ' Zeilen, ' + charCount + ' Zeichen'; header.appendChild(meta); const preview = buildWritePreview(content); preview.hidden = true; div.appendChild(preview); foldTarget = preview; } else { // Standard: path/command als input anzeigen let inputLabel = ''; if (parsedInput) { inputLabel = parsedInput.path || parsedInput.command || JSON.stringify(parsedInput).substring(0, 120); } else { inputLabel = truncate(inputJson, 120); } if (inputLabel) { const inputSpan = document.createElement('span'); inputSpan.className = 'tool-input'; inputSpan.textContent = inputLabel; header.appendChild(inputSpan); } const resultDiv = document.createElement('div'); resultDiv.className = 'tool-result' + (isError ? ' tool-error' : ''); resultDiv.textContent = result || ''; resultDiv.hidden = true; div.appendChild(resultDiv); foldTarget = resultDiv; } if (foldTarget) { header.style.cursor = 'pointer'; header.addEventListener('click', () => { foldTarget.hidden = !foldTarget.hidden; }); } } function buildEditDiff(oldText, newText) { const oldLines = oldText.split('\n'); const newLines = newText.split('\n'); const container = document.createElement('div'); container.className = 'tool-diff'; // Entfernte Zeilen (-) for (const line of oldLines) { const row = document.createElement('div'); row.className = 'diff-line diff-removed'; const marker = document.createElement('span'); marker.className = 'diff-marker'; marker.textContent = '-'; const content = document.createElement('span'); content.className = 'diff-content'; content.textContent = line; row.appendChild(marker); row.appendChild(content); container.appendChild(row); } // Hinzugefügte Zeilen (+) for (const line of newLines) { const row = document.createElement('div'); row.className = 'diff-line diff-added'; const marker = document.createElement('span'); marker.className = 'diff-marker'; marker.textContent = '+'; const content = document.createElement('span'); content.className = 'diff-content'; content.textContent = line; row.appendChild(marker); row.appendChild(content); container.appendChild(row); } return container; } function buildWritePreview(content) { const lines = content.split('\n'); const container = document.createElement('div'); container.className = 'tool-diff'; for (const line of lines) { const row = document.createElement('div'); row.className = 'diff-line diff-added'; const marker = document.createElement('span'); marker.className = 'diff-marker'; marker.textContent = '+'; const contentSpan = document.createElement('span'); contentSpan.className = 'diff-content'; contentSpan.textContent = line; row.appendChild(marker); row.appendChild(contentSpan); container.appendChild(row); } return container; } function finishToolBox(div, data) { if (!div) return; renderToolBoxContent(div, data.name, data.input, data.result, data.isError); scrollDown(); } function addToolBoxStatic(name, input, result, isError) { const div = document.createElement('div'); div.className = 'tool-box'; renderToolBoxContent(div, name, input, result, isError); chatEl().appendChild(div); scrollDown(); } function appendToolResult(result, isError) { // Letzten tool-box im Chat finden und Result in die bereits vorhandene .tool-result-Div schreiben const boxes = chatEl().querySelectorAll('.tool-box'); const lastBox = boxes[boxes.length - 1]; if (!lastBox) return; // Wenn Diff vorhanden (edit_file/write_file) → kein Result anhängen if (lastBox.querySelector('.tool-diff')) return; const existing = lastBox.querySelector('.tool-result'); if (existing) { existing.textContent = result || ''; if (isError) existing.classList.add('tool-error'); } else { const resultDiv = document.createElement('div'); resultDiv.className = 'tool-result' + (isError ? ' tool-error' : ''); resultDiv.textContent = result || ''; resultDiv.hidden = true; lastBox.appendChild(resultDiv); } } // ── Streamed Elements ── function removeStreamedElements() { chatEl().querySelectorAll('.streamed').forEach(el => el.remove()); } // ── Attachment Indicator ── function addAttachmentIndicator(messageId, count, namesJson) { const chat = chatEl(); const lastMsg = chat.lastElementChild; if (!lastMsg) return; let names = []; try { if (namesJson) names = JSON.parse(namesJson); } catch {} const indicator = document.createElement('div'); indicator.className = 'attachment-indicator'; const nameList = names.filter(n => n).join(', '); indicator.innerHTML = ` ${count} Datei${count > 1 ? 'en' : ''}` + (nameList ? `: ${esc(nameList)}` : ''); indicator.style.cursor = 'pointer'; indicator.title = 'Klicken zum Anzeigen'; indicator.addEventListener('click', () => loadAndShowAttachments(messageId, indicator)); lastMsg.appendChild(indicator); } async function loadAndShowAttachments(messageId, indicator) { if (indicator.dataset.loaded) return; indicator.dataset.loaded = '1'; indicator.innerHTML = ' Laden...'; try { const resp = await fetch(`/api/attachments/${messageId}`); const attachments = await resp.json(); const container = document.createElement('div'); container.className = 'attachment-container'; for (const att of attachments) { if (att.type === 'image') { const img = document.createElement('img'); img.src = `data:${att.mediaType};base64,${att.data}`; img.className = 'attachment-image'; img.title = att.fileName || 'Bild'; container.appendChild(img); } else { const link = document.createElement('a'); link.href = `data:${att.mediaType};base64,${att.data}`; link.download = att.fileName || 'datei'; link.className = 'attachment-file'; link.innerHTML = ` ${esc(att.fileName || 'Dokument')}`; container.appendChild(link); } } indicator.innerHTML = ` ${attachments.length} Datei${attachments.length > 1 ? 'en' : ''}`; indicator.parentElement.appendChild(container); } catch (e) { indicator.innerHTML = ' Fehler beim Laden'; indicator.dataset.loaded = ''; } } // ── System-Prompt Modal ── async function showSystemPromptModal(sessionId) { // Overlay const overlay = document.createElement('div'); overlay.className = 'sp-overlay'; // Modal const modal = document.createElement('div'); modal.className = 'sp-modal'; // Header const header = document.createElement('div'); header.className = 'sp-header'; const title = document.createElement('span'); title.textContent = 'System-Prompt'; const closeBtn = document.createElement('button'); closeBtn.innerHTML = ''; closeBtn.className = 'sp-close icon-btn'; header.appendChild(title); header.appendChild(closeBtn); // Body const body = document.createElement('div'); body.className = 'sp-body markdown'; body.innerHTML = '
Laden...
'; modal.appendChild(header); modal.appendChild(body); overlay.appendChild(modal); document.body.appendChild(overlay); // Schließen const close = () => overlay.remove(); closeBtn.addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); const onKey = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } }; document.addEventListener('keydown', onKey); // Laden try { const prompt = await apiGetSystemPrompt(sessionId); body.innerHTML = renderMarkdown(prompt); } catch (e) { body.innerHTML = '
Fehler beim Laden des System-Prompts
'; } } // ── Command Output Modal ── function showCommandOutputModal(name, command, result) { const overlay = document.createElement('div'); overlay.className = 'sp-overlay'; const modal = document.createElement('div'); modal.className = 'sp-modal cmd-output-modal'; // Header const header = document.createElement('div'); header.className = 'sp-header'; const title = document.createElement('span'); title.textContent = name; const closeBtn = document.createElement('button'); closeBtn.innerHTML = ''; closeBtn.className = 'sp-close icon-btn'; header.appendChild(title); header.appendChild(closeBtn); // Body const body = document.createElement('div'); body.className = 'sp-body'; const exitCode = result.exitCode != null ? result.exitCode : '?'; const isError = exitCode !== 0; const statusText = result.status === 'timeout' ? 'Timeout' : (isError ? `Exit ${exitCode}` : `Exit ${exitCode}`); const meta = document.createElement('div'); meta.className = 'cmd-output-meta'; meta.innerHTML = `${esc(command)} ${esc(statusText)}`; body.appendChild(meta); const output = document.createElement('pre'); output.className = 'cmd-output-content'; output.textContent = result.output || '(kein Output)'; body.appendChild(output); modal.appendChild(header); modal.appendChild(body); overlay.appendChild(modal); document.body.appendChild(overlay); // Schließen const close = () => overlay.remove(); closeBtn.addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); const onKey = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } }; document.addEventListener('keydown', onKey); } // ── Meta Bar ── function setMeta(text) { metaEl().textContent = text; } function setMetaHtml(html) { metaEl().innerHTML = html; } // ── Older Messages Button ── function showOlderMessagesButton(remainingCount) { removeOlderMessagesButton(); const btn = document.createElement('button'); btn.className = 'archive-load-btn older-messages-btn'; btn.innerHTML = ' ' + remainingCount + ' ältere Nachrichten laden'; btn.addEventListener('click', () => loadOlderMessages()); chatEl().prepend(btn); } function removeOlderMessagesButton() { const btn = chatEl().querySelector('.older-messages-btn'); if (btn) btn.remove(); } // ── Archive Button ── function showArchiveLoadButton() { const existing = chatEl().querySelector('.archive-load-btn:not(.older-messages-btn)'); if (existing) existing.remove(); const btn = document.createElement('button'); btn.className = 'archive-load-btn'; btn.innerHTML = ' Archivierte Nachrichten laden'; btn.addEventListener('click', loadLatestArchive); // Immer ganz oben im Chat (aber nach dem older-messages-btn falls vorhanden) const olderBtn = chatEl().querySelector('.older-messages-btn'); if (olderBtn) { olderBtn.after(btn); } else { chatEl().prepend(btn); } } function removeArchiveLoadButton() { const btns = chatEl().querySelectorAll('.archive-load-btn:not(.older-messages-btn)'); btns.forEach(b => b.remove()); } // ── Krea Image Preview Modal ── function showKreaImageModal(filename) { var url = '/api/skills/krea-api/output/' + encodeURIComponent(filename); var overlay = document.createElement('div'); overlay.className = 'sp-overlay krea-overlay'; var modal = document.createElement('div'); modal.className = 'sp-modal krea-modal'; // Header var header = document.createElement('div'); header.className = 'sp-header'; var title = document.createElement('span'); title.textContent = filename; var closeBtn = document.createElement('button'); closeBtn.innerHTML = ''; closeBtn.className = 'sp-close icon-btn'; header.appendChild(title); header.appendChild(closeBtn); // Body var body = document.createElement('div'); body.className = 'sp-body krea-modal-body'; var img = document.createElement('img'); img.src = url; img.className = 'krea-modal-image'; img.alt = filename; img.addEventListener('error', function() { body.innerHTML = '
Bild konnte nicht geladen werden
'; }); body.appendChild(img); modal.appendChild(header); modal.appendChild(body); overlay.appendChild(modal); document.body.appendChild(overlay); // Schließen var close = function() { overlay.remove(); }; closeBtn.addEventListener('click', close); overlay.addEventListener('click', function(e) { if (e.target === overlay) close(); }); var onKey = function(e) { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } }; document.addEventListener('keydown', onKey); } // Event-Delegation für Krea-Chips im Chat document.addEventListener('click', function(e) { var chip = e.target.closest('.krea-ref-chip'); if (chip) { var filename = chip.dataset.filename; if (filename) showKreaImageModal(filename); } });