diff --git a/wwwroot/index.html b/wwwroot/index.html index 37d1993..3c72e1c 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -555,12 +555,22 @@ - + + + + + + + + + + + diff --git a/wwwroot/js/archive.js b/wwwroot/js/archive.js new file mode 100644 index 0000000..efcb09a --- /dev/null +++ b/wwwroot/js/archive.js @@ -0,0 +1,173 @@ +// ── Archive Loading ── + +let loadedArchiveCount = 0; + +function getOrCreateArchiveContainer() { + let container = chatEl().querySelector('.archive-container'); + if (!container) { + container = document.createElement('div'); + container.className = 'archive-container'; + chatEl().prepend(container); + } + return container; +} + +function getOrCreateArchiveDivider() { + let divider = chatEl().querySelector('.archive-divider'); + if (!divider) { + divider = document.createElement('div'); + divider.className = 'msg system-info archive-divider'; + divider.textContent = '── Aktuelle Konversation ──'; + // Nach dem Container, vor den aktiven Messages + const container = getOrCreateArchiveContainer(); + container.after(divider); + } + return divider; +} + +async function loadLatestArchive() { + try { + const archives = await apiListArchives(getCurrentSessionId()); + if (archives.length <= loadedArchiveCount) return; + + const latest = archives[loadedArchiveCount]; + loadedArchiveCount++; + + removeArchiveLoadButton(); + + const data = await apiGetArchive(latest.archiveId); + const container = getOrCreateArchiveContainer(); + getOrCreateArchiveDivider(); + + // Archiv-Block zusammenbauen + const fragment = document.createDocumentFragment(); + + const sep = document.createElement('div'); + sep.className = 'msg system-info'; + sep.innerHTML = ` Archiv vom ${new Date(latest.createdAt).toLocaleString('de-AT')} (${latest.chainCount} Chains, ${latest.messageCount} Messages)`; + fragment.appendChild(sep); + + const archiveImageIds = []; + for (const entry of data) { + const mergedMsgs = _mergeConsecutiveAssistantTexts(entry.messages); + for (const msg of mergedMsgs) { + const div = document.createElement('div'); + switch (msg.type) { + case 'text': { + const isReviewMsg = msg.content && (msg.content.startsWith('[Review:') || msg.content.startsWith('[Review-Feedback') || msg.content.startsWith('[Finalizer:') || msg.content.startsWith('[Preparer:')); + if (msg.role === 'user' && !isReviewMsg) { + div.className = 'msg user markdown archived' + (msg.isMarked ? ' marked' : ''); + if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); + div.dataset.role = 'user'; + div.dataset.timestamp = msg.createdAt || ''; + if (msg.id) div.dataset.messageId = msg.id; + renderUserContent(div, msg.content); + _addTimestamp(div, msg.createdAt); + _addReplyButton(div); + _addDeleteButton(div, msg.id, msg.deletedOn); + _addMarkButton(div, msg.id, msg.isMarked); + _addShareButton(div, msg.id); + } else if (isReviewMsg) { + div.className = 'msg review-bubble markdown archived' + (msg.isMarked ? ' marked' : ''); + if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); + div.dataset.role = msg.role; + div.dataset.timestamp = msg.createdAt || ''; + if (msg.id) div.dataset.messageId = msg.id; + div.innerHTML = renderMarkdown(msg.content); + parseFluentEmojis(div); + _addTimestamp(div, msg.createdAt); + _addReplyButton(div); + _addDeleteButton(div, msg.id, msg.deletedOn); + _addMarkButton(div, msg.id, msg.isMarked); + _addShareButton(div, msg.id); + } else if (msg.role === 'assistant') { + div.className = 'msg assistant markdown archived' + (msg.isMarked ? ' marked' : ''); + if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); + div.dataset.role = 'assistant'; + div.dataset.timestamp = msg.createdAt || ''; + if (msg.id) div.dataset.messageId = msg.id; + div.innerHTML = renderMarkdown(msg.content); + parseFluentEmojis(div); + _addTimestamp(div, msg.createdAt); + _addReplyButton(div); + _addDeleteButton(div, msg.id, msg.deletedOn); + _addMarkButton(div, msg.id, msg.isMarked); + _addShareButton(div, msg.id); + } + if (msg.role === 'user' && msg.attachmentCount > 0) { + if (/\[Bild: [^\]]+\]/.test(msg.content)) archiveImageIds.push(msg.id); + let names = []; + try { if (msg.attachmentNames) names = JSON.parse(msg.attachmentNames); } catch {} + const nameList = names.filter(n => n).join(', '); + const ind = document.createElement('div'); + ind.className = 'attachment-indicator'; + ind.innerHTML = ` ${msg.attachmentCount} Datei${msg.attachmentCount > 1 ? 'en' : ''}` + (nameList ? `: ${esc(nameList)}` : ''); + ind.style.cursor = 'pointer'; + ind.title = 'Klicken zum Anzeigen'; + ind.addEventListener('click', () => loadAndShowAttachments(msg.id, ind)); + div.appendChild(ind); + } + break; + } + case 'tool_use': + if (!isToolVisible(msg.toolName)) continue; + div.className = 'tool-box archived'; + renderToolBoxContent(div, msg.toolName, msg.content, null, false); + break; + case 'web_search_result': { + if (!isToolVisible('web_search')) continue; + var archSrc = []; + try { archSrc = JSON.parse(msg.content || '[]'); } catch(e) {} + if (archSrc.length === 0) continue; // Leere Suchergebnisse überspringen + div.className = 'tool-box archived'; + div.dataset.toolName = 'web_search'; + renderToolBoxContent(div, 'web_search', null, null, false, archSrc); + break; + } + case 'tool_result': { + // An vorherigen tool-box anhängen + const prevBoxes = fragment.querySelectorAll ? fragment.querySelectorAll('.tool-box') : []; + const lastToolBox = prevBoxes[prevBoxes.length - 1]; + if (lastToolBox && !lastToolBox.querySelector('.tool-result')) { + const rd = document.createElement('div'); + rd.className = 'tool-result' + (msg.isError ? ' tool-error' : ''); + rd.textContent = truncate(msg.content || '', 300); + lastToolBox.appendChild(rd); + } + continue; // kein separates div + } + case 'error': + div.className = 'msg system-info archived'; + div.style.color = 'var(--dangerous)'; + div.textContent = (msg.content || '').trim(); + break; + default: + continue; + } + fragment.appendChild(div); + } + // Chain-Meta-Badges nach jeder Chain einfügen + if (entry.chain && isChainMetaVisible(entry.chain)) { + const badges = buildMetaBadges(entry.chain); + const metaDiv = document.createElement('div'); + metaDiv.className = 'chain-meta archived'; + metaDiv.innerHTML = badges.map(b => '' + esc(b) + '').join(''); + fragment.appendChild(metaDiv); + } + } + + // Älteres Archiv vor dem bisherigen Inhalt einfügen + container.prepend(fragment); + + // Bild-Attachments automatisch laden + for (const mid of archiveImageIds) _autoLoadImageAttachments(mid); + + // Button ganz oben, falls es noch ältere Archive gibt + if (archives.length > loadedArchiveCount) { + showArchiveLoadButton(); + } + + } catch (e) { + console.error('Archiv laden fehlgeschlagen:', e); + } +} diff --git a/wwwroot/js/bubbles.js b/wwwroot/js/bubbles.js new file mode 100644 index 0000000..c7ab002 --- /dev/null +++ b/wwwroot/js/bubbles.js @@ -0,0 +1,312 @@ +// ── User Content Rendering ── + +function renderUserContent(container, text, inlineImages) { + 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, inlineImages); + } + return; + } + + _renderUserTextContent(container, raw, inlineImages); +} + +function _renderUserTextContent(container, raw, inlineImages) { + // [Bild: name] durch Platzhalter ersetzen + const imagePattern = /\[Bild: ([^\]]+)\]/g; + const imageRefs = []; + let mdText = raw.replace(imagePattern, function(m, name) { + imageRefs.push(name); + return '%%IMGREF' + (imageRefs.length - 1) + '%%'; + }); + + // [Datei: path] oder [Datei: path:1-10] durch Platzhalter ersetzen (vor Markdown-Parsing) + const pattern = /\[Datei: ([^\]]+)\]/g; + const fileRefs = []; + mdText = mdText.replace(pattern, function(m, ref) { + fileRefs.push(ref); + return '%%FILEREF' + (fileRefs.length - 1) + '%%'; + }); + + // Markdown rendern + let html = renderMarkdown(mdText); + + // Bild-Platzhalter durch Inline-Bilder ersetzen + html = html.replace(/%%IMGREF(\d+)%%/g, function(m, idx) { + const name = imageRefs[parseInt(idx)]; + // Wenn Inline-Bild-Daten vorhanden (beim Senden), direkt anzeigen + const img = inlineImages && inlineImages.find(function(i) { return i.name === name; }); + if (img) { + return '' + esc(name) + ''; + } + // Sonst: Platzhalter mit data-Attribut für späteres Laden + return '' + + ' ' + esc(name) + ''; + }); + + // Datei-Platzhalter durch File-Ref-Chips ersetzen (als HTML) + html = html.replace(/%%FILEREF(\d+)%%/g, function(m, idx) { + const ref = fileRefs[parseInt(idx)]; + const filePath = ref.replace(/:\d+(-\d+)?$/, ''); + const fileName = filePath.includes('/') ? filePath.substring(filePath.lastIndexOf('/') + 1) : filePath; + const lineInfo = ref.replace(filePath, '').trim(); + return '' + + ' ' + esc(fileName + lineInfo) + ''; + }); + + // In ein Wrapper-Div rendern (damit vorherige Elemente wie Reply-Quotes erhalten bleiben) + const wrap = document.createElement('div'); + wrap.className = 'user-text-content'; + wrap.innerHTML = html; + container.appendChild(wrap); + + // Click-Handler für File-Ref-Chips nachträglich binden + wrap.querySelectorAll('.file-ref-chip[data-filepath]').forEach(function(chip) { + chip.addEventListener('click', function() { + openFileExplorer({ sessionId: getCurrentSessionId(), initialFile: chip.dataset.filepath }); + }); + }); + + // Click-Handler für Inline-Bilder (Vollbild) + wrap.querySelectorAll('.inline-user-image').forEach(function(img) { + img.addEventListener('click', function() { + showKreaImageModal(img.alt || 'Bild', img.src); + }); + }); + + // Fluent Emoji DOM-Parsing + parseFluentEmojis(wrap); +} + +// ── Message Bubbles ── + +function addUserBubble(text, streamed = false, timestamp = '', messageId = null, isMarked = false, deletedOn = null, inlineImages = null) { + const div = document.createElement('div'); + div.className = 'msg user markdown' + (isMarked ? ' marked' : ''); + if (deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); + 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, inlineImages); + _addTimestamp(div, timestamp); + _addReplyButton(div); + _addDeleteButton(div, messageId, deletedOn); + _addMarkButton(div, messageId, isMarked); + _addShareButton(div, messageId); + chatEl().appendChild(div); + scrollDown(); +} + +function addAssistantBubble(text, timestamp = '', messageId = null, isMarked = false, deletedOn = null) { + const div = document.createElement('div'); + div.className = 'msg assistant markdown' + (isMarked ? ' marked' : ''); + if (deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); + div.dataset.role = 'assistant'; + div.dataset.timestamp = timestamp || new Date().toISOString(); + if (messageId) div.dataset.messageId = messageId; + div.innerHTML = renderMarkdown(text); + parseFluentEmojis(div); + wrapKreaGalleries(div); + _addTimestamp(div, timestamp); + _addReplyButton(div); + _addDeleteButton(div, messageId, deletedOn); + _addMarkButton(div, messageId, isMarked); + _addShareButton(div, messageId); + 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(); +} + +// ── Choice Bubbles (ask_user_choice) ── + +// Wird live aus dem SSE-Stream aufgerufen +function addChoiceBubble(toolCallId, question, choices, streamed = true) { + // Guard: choices muss ein Array sein + if (!Array.isArray(choices)) choices = [String(choices)]; + + const div = document.createElement('div'); + div.className = 'msg choice-bubble'; + if (streamed) div.classList.add('streamed'); + div.dataset.toolCallId = toolCallId; + + const questionDiv = document.createElement('div'); + questionDiv.className = 'choice-question'; + questionDiv.innerHTML = renderMarkdown(question); + parseFluentEmojis(questionDiv); + div.appendChild(questionDiv); + + const buttonsDiv = document.createElement('div'); + buttonsDiv.className = 'choice-buttons'; + for (const choice of choices) { + const btn = document.createElement('button'); + btn.className = 'choice-btn'; + btn.textContent = choice; + btn.addEventListener('click', () => handleChoiceClick(toolCallId, choice, div)); + buttonsDiv.appendChild(btn); + } + div.appendChild(buttonsDiv); + + chatEl().appendChild(div); + scrollDown(); + return div; +} + +// Wird für History-Rendering aufgerufen (bereits beantwortete Choices) +function addChoiceBubbleStatic(toolCallId, question, choices, selectedChoice, timestamp, messageId) { + // Guard: choices muss ein Array sein + if (!Array.isArray(choices)) choices = [String(choices)]; + + const div = document.createElement('div'); + div.className = 'msg choice-bubble choice-answered'; + div.dataset.toolCallId = toolCallId; + if (messageId) div.dataset.messageId = messageId; + + const questionDiv = document.createElement('div'); + questionDiv.className = 'choice-question'; + questionDiv.innerHTML = renderMarkdown(question); + parseFluentEmojis(questionDiv); + div.appendChild(questionDiv); + + const buttonsDiv = document.createElement('div'); + buttonsDiv.className = 'choice-buttons'; + for (const choice of choices) { + const btn = document.createElement('button'); + btn.className = 'choice-btn'; + btn.disabled = true; + btn.textContent = choice; + if (choice === selectedChoice) { + btn.classList.add('choice-selected'); + } else { + btn.classList.add('choice-unselected'); + } + buttonsDiv.appendChild(btn); + } + div.appendChild(buttonsDiv); + + if (timestamp) _addTimestamp(div, timestamp); + chatEl().appendChild(div); + scrollDown(); + return div; +} + +// ── Review Bubbles (Static / History) ── + +function addReviewBubbleStatic(text, timestamp, messageId, isMarked, deletedOn) { + const div = document.createElement('div'); + div.className = 'msg review-bubble markdown' + (isMarked ? ' marked' : ''); + if (deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); + div.dataset.role = 'assistant'; + div.dataset.timestamp = timestamp || ''; + if (messageId) div.dataset.messageId = messageId; + div.innerHTML = renderMarkdown(text); + parseFluentEmojis(div); + _addTimestamp(div, timestamp); + _addReplyButton(div); + _addDeleteButton(div, messageId, deletedOn); + _addMarkButton(div, messageId, isMarked); + _addShareButton(div, messageId); + chatEl().appendChild(div); + scrollDown(); + return div; +} + +// ── 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 = ''; + } +} diff --git a/wwwroot/js/cards.js b/wwwroot/js/cards.js new file mode 100644 index 0000000..b1ae60e --- /dev/null +++ b/wwwroot/js/cards.js @@ -0,0 +1,90 @@ +// ── Session Card Helper ── +// Erzeugt ein .card-Element für eine Session (wiederverwendbar für Dashboard + Picker). + +function buildSessionCard(session, agent) { + const card = document.createElement('div'); + card.className = 'card'; + + const transformParts = []; + if (session.avatarScale && session.avatarScale !== 1) transformParts.push(`scale(${session.avatarScale})`); + if (session.avatarOffsetX || session.avatarOffsetY) transformParts.push(`translate(${session.avatarOffsetX || 0}%, ${session.avatarOffsetY || 0}%)`); + const transformStyle = transformParts.length ? ` style="transform: ${transformParts.join(' ')}"` : ''; + const imageHtml = session.image ? `` : ''; + + // Badges + const badges = []; + if (agent) badges.push(`${esc(agent.name)}`); + if (session.reviewAgentId) badges.push(``); + if (session.finalizerAgentId) badges.push(``); + if (session.context) badges.push(``); + if (session.telegramConfigured) badges.push(``); + + // Meta + const metaParts = []; + if (session.messageCount) metaParts.push(` ${esc(session.messageCount + ' Nachrichten')}`); + if (session.archiveMessageCount) metaParts.push(` ${esc(session.archiveMessageCount + ' Nachrichten archiviert')}`); + if (session.workspace) metaParts.push(` ${esc(session.workspace)}`); + + card.innerHTML = ` +
${imageHtml}
+
+
+ ${esc(session.name || 'Unbenannt')} + ${badges.join('')} +
+ ${metaParts.length ? `
${metaParts.join('')}
` : ''} +
+ `; + + return card; +} + + +// ── Agent Card Helper ── +// Erzeugt ein .card-Element für einen Agent (wiederverwendbar für Dashboard + Picker). + +function buildAgentCard(agent, skills) { + const card = document.createElement('div'); + card.className = 'card'; + + const agentTransformParts = []; + if (agent.avatarScale && agent.avatarScale !== 1) agentTransformParts.push(`scale(${agent.avatarScale})`); + if (agent.avatarOffsetX || agent.avatarOffsetY) agentTransformParts.push(`translate(${agent.avatarOffsetX || 0}%, ${agent.avatarOffsetY || 0}%)`); + const agentTransformStyle = agentTransformParts.length ? ` style="transform: ${agentTransformParts.join(' ')}"` : ''; + const imageHtml = agent.avatar ? `` : ''; + + // Badges + const badges = []; + if (agent.toolChoice === 'any') badges.push(' Erzwungen'); + if (agent.allowUserChoice) badges.push(' Rückfragen'); + if (agent.autoCommit === 'mention') badges.push(' Anmerken'); + if (agent.autoCommit === 'enforce') badges.push(' Erzwungen'); + if (agent.autoCommit === 'forbid') badges.push(' Verboten'); + if (agent.useSoul) badges.push(' Seele'); + if (agent.useIdentity) badges.push(' Ich'); + if (agent.useUserInfo) badges.push(' Du'); + if (agent.useMemory) badges.push(' Erinnerungen'); + if (agent.webSearchVersion) { + const wsYear = agent.webSearchVersion.includes('2025') ? 'Version 2025' : 'v2026'; + badges.push(` Web-Suche`); + } + + if (skills) { + for (const skill of skills) { + badges.push(` ${esc(skill.name)}`); + } + } + + card.innerHTML = ` +
${imageHtml}
+
+
+ ${esc(agent.name)} + ${prettyModelName(agent.model)} +
+ ${badges.length ? `
${badges.join('')}
` : ''} +
+ `; + + return card; +} diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 6444dca..48aff1d 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -175,9 +175,6 @@ function togglePlanMode() { // ── Chat State ── let isStreaming = false; -let pendingFiles = []; // [{ name, type, base64 }] -let pendingRefs = []; // [{ path, from, to }] - Workspace-Dateireferenzen -let pendingReply = null; // { index, role, text } - Zitierte Nachricht let _lastDoneChainData = null; // Chain-Daten vom letzten done-Event let _currentToolBox = null; // Aktuell laufende Tool-Box (für Stream-Events) let _chatAbandoned = false; // true wenn User weg-navigiert während Stream läuft @@ -449,164 +446,3 @@ async function handleChoiceClick(toolCallId, choice, bubbleDiv) { } } } - -// ── File Handling ── - -function handleFileSelect(file) { - if (!file) return; - - const maxSize = 20 * 1024 * 1024; // 20MB - if (file.size > maxSize) { - alert('Datei zu groß (max. 20MB)'); - return; - } - - const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; - if (!allowed.includes(file.type)) { - alert('Nicht unterstützter Dateityp. Erlaubt: JPEG, PNG, GIF, WebP, PDF'); - return; - } - - const reader = new FileReader(); - reader.onload = () => { - const base64 = reader.result.split(',')[1]; - pendingFiles.push({ name: file.name, type: file.type, base64 }); - renderFilePreview(); - }; - reader.readAsDataURL(file); -} - -function handleMultipleFiles(files) { - for (const file of files) handleFileSelect(file); -} - -function renderFilePreview() { - const preview = $('#filePreview'); - if (!preview || pendingFiles.length === 0) { - if (preview) { preview.innerHTML = ''; preview.hidden = true; } - return; - } - - preview.innerHTML = pendingFiles.map((f, i) => { - const isImage = f.type.startsWith('image/'); - return `
- ${isImage ? '' : ''} - ${esc(f.name)} - -
`; - }).join(''); - preview.hidden = false; -} - -function removePendingFile(index) { - pendingFiles.splice(index, 1); - renderFilePreview(); -} - -// ── Workspace Refs ── - -function addPendingRef(ref) { - // ref = "path", "path:line" oder "path:from-to" - const rangeMatch = ref.match(/^(.+?):(\d+)-(\d+)$/); - const lineMatch = !rangeMatch && ref.match(/^(.+?):(\d+)$/); - if (rangeMatch) { - pendingRefs.push({ path: rangeMatch[1], from: parseInt(rangeMatch[2]), to: parseInt(rangeMatch[3]) }); - } else if (lineMatch) { - pendingRefs.push({ path: lineMatch[1], from: parseInt(lineMatch[2]), to: parseInt(lineMatch[2]) }); - } else { - pendingRefs.push({ path: ref, from: null, to: null }); - } - renderRefPreview(); -} - -function removePendingRef(index) { - pendingRefs.splice(index, 1); - renderRefPreview(); -} - -function clearPendingRefs() { - pendingRefs.length = 0; - renderRefPreview(); -} - -function renderRefPreview() { - const container = $('#refPreview'); - if (!container) return; - - if (pendingRefs.length === 0) { - container.innerHTML = ''; - container.hidden = true; - return; - } - - container.innerHTML = pendingRefs.map((r, i) => { - const label = r.from && r.to && r.from !== r.to ? `${r.path}:${r.from}-${r.to}` : r.from ? `${r.path}:${r.from}` : r.path; - return `
- - ${esc(label)} - -
`; - }).join(''); - container.hidden = false; -} - -function clearPendingFiles() { - pendingFiles.length = 0; - const preview = $('#filePreview'); - if (preview) { preview.innerHTML = ''; preview.hidden = true; } - const fileInput = $('#fileInput'); - if (fileInput) fileInput.value = ''; -} - -// ── Reply / Quote ── - -function _formatReplyTimestamp(ts) { - if (!ts) return 'Jetzt'; - try { - const d = new Date(ts); - const now = new Date(); - const hh = d.getHours().toString().padStart(2, '0'); - const mm = d.getMinutes().toString().padStart(2, '0'); - // Gleiches Datum → nur Uhrzeit, sonst Datum + Uhrzeit - if (d.toDateString() === now.toDateString()) { - return hh + ':' + mm; - } - const dd = d.getDate().toString().padStart(2, '0'); - const mo = (d.getMonth() + 1).toString().padStart(2, '0'); - return dd + '.' + mo + '. ' + hh + ':' + mm; - } catch { return 'Jetzt'; } -} - -function setReply(reply) { - pendingReply = reply; - renderReplyPreview(); - document.getElementById('input')?.focus(); -} - -function clearReply() { - pendingReply = null; - renderReplyPreview(); -} - -function renderReplyPreview() { - const container = $('#replyPreview'); - if (!container) return; - - if (!pendingReply) { - container.innerHTML = ''; - container.hidden = true; - return; - } - - const roleLabel = pendingReply.role === 'user' ? 'User' : 'Assistant'; - const timeLabel = _formatReplyTimestamp(pendingReply.timestamp); - const preview = truncate(pendingReply.text, 150); - - container.innerHTML = `
- - ${esc(timeLabel)}, ${esc(roleLabel)} - ${esc(preview)} - -
`; - container.hidden = false; -} diff --git a/wwwroot/js/files.js b/wwwroot/js/files.js new file mode 100644 index 0000000..c933452 --- /dev/null +++ b/wwwroot/js/files.js @@ -0,0 +1,110 @@ +// ── File Handling ── + +let pendingFiles = []; // [{ name, type, base64 }] +let pendingRefs = []; // [{ path, from, to }] - Workspace-Dateireferenzen + +function handleFileSelect(file) { + if (!file) return; + + const maxSize = 20 * 1024 * 1024; // 20MB + if (file.size > maxSize) { + alert('Datei zu groß (max. 20MB)'); + return; + } + + const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; + if (!allowed.includes(file.type)) { + alert('Nicht unterstützter Dateityp. Erlaubt: JPEG, PNG, GIF, WebP, PDF'); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + const base64 = reader.result.split(',')[1]; + pendingFiles.push({ name: file.name, type: file.type, base64 }); + renderFilePreview(); + }; + reader.readAsDataURL(file); +} + +function handleMultipleFiles(files) { + for (const file of files) handleFileSelect(file); +} + +function renderFilePreview() { + const preview = $('#filePreview'); + if (!preview || pendingFiles.length === 0) { + if (preview) { preview.innerHTML = ''; preview.hidden = true; } + return; + } + + preview.innerHTML = pendingFiles.map((f, i) => { + const isImage = f.type.startsWith('image/'); + return `
+ ${isImage ? '' : ''} + ${esc(f.name)} + +
`; + }).join(''); + preview.hidden = false; +} + +function removePendingFile(index) { + pendingFiles.splice(index, 1); + renderFilePreview(); +} + +function clearPendingFiles() { + pendingFiles.length = 0; + const preview = $('#filePreview'); + if (preview) { preview.innerHTML = ''; preview.hidden = true; } + const fileInput = $('#fileInput'); + if (fileInput) fileInput.value = ''; +} + +// ── Workspace Refs ── + +function addPendingRef(ref) { + // ref = "path", "path:line" oder "path:from-to" + const rangeMatch = ref.match(/^(.+?):(\d+)-(\d+)$/); + const lineMatch = !rangeMatch && ref.match(/^(.+?):(\d+)$/); + if (rangeMatch) { + pendingRefs.push({ path: rangeMatch[1], from: parseInt(rangeMatch[2]), to: parseInt(rangeMatch[3]) }); + } else if (lineMatch) { + pendingRefs.push({ path: lineMatch[1], from: parseInt(lineMatch[2]), to: parseInt(lineMatch[2]) }); + } else { + pendingRefs.push({ path: ref, from: null, to: null }); + } + renderRefPreview(); +} + +function removePendingRef(index) { + pendingRefs.splice(index, 1); + renderRefPreview(); +} + +function clearPendingRefs() { + pendingRefs.length = 0; + renderRefPreview(); +} + +function renderRefPreview() { + const container = $('#refPreview'); + if (!container) return; + + if (pendingRefs.length === 0) { + container.innerHTML = ''; + container.hidden = true; + return; + } + + container.innerHTML = pendingRefs.map((r, i) => { + const label = r.from && r.to && r.from !== r.to ? `${r.path}:${r.from}-${r.to}` : r.from ? `${r.path}:${r.from}` : r.path; + return `
+ + ${esc(label)} + +
`; + }).join(''); + container.hidden = false; +} diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index 9b88e2d..9a5bed4 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -1,4 +1,4 @@ -// ── History Loading ── +// ── Image Attachments Auto-Loading ── async function _autoLoadImageAttachments(messageId) { // Finde die User-Bubble mit dieser messageId @@ -31,11 +31,15 @@ async function _autoLoadImageAttachments(messageId) { } } +// ── History State ── + let lastKnownMessageId = 0; let _oldestLoadedMessageId = 0; let _hasMoreOlderMessages = false; let historyPollInterval = null; -let loadedArchiveCount = 0; + + +// ── History Loading ── async function loadHistory() { try { @@ -143,6 +147,8 @@ async function loadOlderMessages() { } } +// ── Helper Functions ── + function _findOldestMessageId(entries) { for (const entry of entries) { if (entry.messages && entry.messages.length > 0) { @@ -173,6 +179,8 @@ function _mergeConsecutiveAssistantTexts(msgs) { return result; } +// ── Fragment Rendering (for loadOlderMessages) ── + function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) { msgs = _mergeConsecutiveAssistantTexts(msgs); for (var _msgIdx = 0; _msgIdx < msgs.length; _msgIdx++) { @@ -340,6 +348,8 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) { } } +// ── Render Messages (for pollHistory + loadHistory) ── + function renderMessages(msgs, addToConversation) { msgs = _mergeConsecutiveAssistantTexts(msgs); for (var _rmIdx = 0; _rmIdx < msgs.length; _rmIdx++) { @@ -419,178 +429,6 @@ function renderMessages(msgs, addToConversation) { } } -// ── Archive Loading ── - -function getOrCreateArchiveContainer() { - let container = chatEl().querySelector('.archive-container'); - if (!container) { - container = document.createElement('div'); - container.className = 'archive-container'; - chatEl().prepend(container); - } - return container; -} - -function getOrCreateArchiveDivider() { - let divider = chatEl().querySelector('.archive-divider'); - if (!divider) { - divider = document.createElement('div'); - divider.className = 'msg system-info archive-divider'; - divider.textContent = '── Aktuelle Konversation ──'; - // Nach dem Container, vor den aktiven Messages - const container = getOrCreateArchiveContainer(); - container.after(divider); - } - return divider; -} - -async function loadLatestArchive() { - try { - const archives = await apiListArchives(getCurrentSessionId()); - if (archives.length <= loadedArchiveCount) return; - - const latest = archives[loadedArchiveCount]; - loadedArchiveCount++; - - removeArchiveLoadButton(); - - const data = await apiGetArchive(latest.archiveId); - const container = getOrCreateArchiveContainer(); - getOrCreateArchiveDivider(); - - // Archiv-Block zusammenbauen - const fragment = document.createDocumentFragment(); - - const sep = document.createElement('div'); - sep.className = 'msg system-info'; - sep.innerHTML = ` Archiv vom ${new Date(latest.createdAt).toLocaleString('de-AT')} (${latest.chainCount} Chains, ${latest.messageCount} Messages)`; - fragment.appendChild(sep); - - const archiveImageIds = []; - for (const entry of data) { - const mergedMsgs = _mergeConsecutiveAssistantTexts(entry.messages); - for (const msg of mergedMsgs) { - const div = document.createElement('div'); - switch (msg.type) { - case 'text': { - const isReviewMsg = msg.content && (msg.content.startsWith('[Review:') || msg.content.startsWith('[Review-Feedback') || msg.content.startsWith('[Finalizer:') || msg.content.startsWith('[Preparer:')); - if (msg.role === 'user' && !isReviewMsg) { - div.className = 'msg user markdown archived' + (msg.isMarked ? ' marked' : ''); - if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); - div.dataset.role = 'user'; - div.dataset.timestamp = msg.createdAt || ''; - if (msg.id) div.dataset.messageId = msg.id; - renderUserContent(div, msg.content); - _addTimestamp(div, msg.createdAt); - _addReplyButton(div); - _addDeleteButton(div, msg.id, msg.deletedOn); - _addMarkButton(div, msg.id, msg.isMarked); - _addShareButton(div, msg.id); - } else if (isReviewMsg) { - div.className = 'msg review-bubble markdown archived' + (msg.isMarked ? ' marked' : ''); - if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); - div.dataset.role = msg.role; - div.dataset.timestamp = msg.createdAt || ''; - if (msg.id) div.dataset.messageId = msg.id; - div.innerHTML = renderMarkdown(msg.content); - parseFluentEmojis(div); - _addTimestamp(div, msg.createdAt); - _addReplyButton(div); - _addDeleteButton(div, msg.id, msg.deletedOn); - _addMarkButton(div, msg.id, msg.isMarked); - _addShareButton(div, msg.id); - } else if (msg.role === 'assistant') { - div.className = 'msg assistant markdown archived' + (msg.isMarked ? ' marked' : ''); - if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); - div.dataset.role = 'assistant'; - div.dataset.timestamp = msg.createdAt || ''; - if (msg.id) div.dataset.messageId = msg.id; - div.innerHTML = renderMarkdown(msg.content); - parseFluentEmojis(div); - _addTimestamp(div, msg.createdAt); - _addReplyButton(div); - _addDeleteButton(div, msg.id, msg.deletedOn); - _addMarkButton(div, msg.id, msg.isMarked); - _addShareButton(div, msg.id); - } - if (msg.role === 'user' && msg.attachmentCount > 0) { - if (/\[Bild: [^\]]+\]/.test(msg.content)) archiveImageIds.push(msg.id); - let names = []; - try { if (msg.attachmentNames) names = JSON.parse(msg.attachmentNames); } catch {} - const nameList = names.filter(n => n).join(', '); - const ind = document.createElement('div'); - ind.className = 'attachment-indicator'; - ind.innerHTML = ` ${msg.attachmentCount} Datei${msg.attachmentCount > 1 ? 'en' : ''}` + (nameList ? `: ${esc(nameList)}` : ''); - ind.style.cursor = 'pointer'; - ind.title = 'Klicken zum Anzeigen'; - ind.addEventListener('click', () => loadAndShowAttachments(msg.id, ind)); - div.appendChild(ind); - } - break; - } - case 'tool_use': - if (!isToolVisible(msg.toolName)) continue; - div.className = 'tool-box archived'; - renderToolBoxContent(div, msg.toolName, msg.content, null, false); - break; - case 'web_search_result': { - if (!isToolVisible('web_search')) continue; - var archSrc = []; - try { archSrc = JSON.parse(msg.content || '[]'); } catch(e) {} - if (archSrc.length === 0) continue; // Leere Suchergebnisse überspringen - div.className = 'tool-box archived'; - div.dataset.toolName = 'web_search'; - renderToolBoxContent(div, 'web_search', null, null, false, archSrc); - break; - } - case 'tool_result': { - // An vorherigen tool-box anhängen - const prevBoxes = fragment.querySelectorAll ? fragment.querySelectorAll('.tool-box') : []; - const lastToolBox = prevBoxes[prevBoxes.length - 1]; - if (lastToolBox && !lastToolBox.querySelector('.tool-result')) { - const rd = document.createElement('div'); - rd.className = 'tool-result' + (msg.isError ? ' tool-error' : ''); - rd.textContent = truncate(msg.content || '', 300); - lastToolBox.appendChild(rd); - } - continue; // kein separates div - } - case 'error': - div.className = 'msg system-info archived'; - div.style.color = 'var(--dangerous)'; - div.textContent = (msg.content || '').trim(); - break; - default: - continue; - } - fragment.appendChild(div); - } - // Chain-Meta-Badges nach jeder Chain einfügen - if (entry.chain && isChainMetaVisible(entry.chain)) { - const badges = buildMetaBadges(entry.chain); - const metaDiv = document.createElement('div'); - metaDiv.className = 'chain-meta archived'; - metaDiv.innerHTML = badges.map(b => '' + esc(b) + '').join(''); - fragment.appendChild(metaDiv); - } - } - - // Älteres Archiv vor dem bisherigen Inhalt einfügen - container.prepend(fragment); - - // Bild-Attachments automatisch laden - for (const mid of archiveImageIds) _autoLoadImageAttachments(mid); - - // Button ganz oben, falls es noch ältere Archive gibt - if (archives.length > loadedArchiveCount) { - showArchiveLoadButton(); - } - - } catch (e) { - console.error('Archiv laden fehlgeschlagen:', e); - } -} - // ── Polling ── let _pollLock = false; diff --git a/wwwroot/js/markdown.js b/wwwroot/js/markdown.js new file mode 100644 index 0000000..a3fe769 --- /dev/null +++ b/wwwroot/js/markdown.js @@ -0,0 +1,143 @@ +// ── Markdown Rendering ── + +marked.setOptions({ breaks: true, gfm: true }); + +function renderMarkdown(text) { + if (!text) return ''; + var src = text.trim(); + + // 1) LaTeX-Ausdrücke VOR dem Markdown-Parsing schützen + // Reihenfolge: zuerst Block ($$...$$), dann Inline ($...$) + var mathBlocks = []; + // Block-Math: $$...$$ (auch mehrzeilig) + src = src.replace(/\$\$([\s\S]+?)\$\$/g, function(m, latex) { + mathBlocks.push({ latex: latex, displayMode: true }); + return '%%MATH' + (mathBlocks.length - 1) + '%%'; + }); + // Inline-Math: $...$ (nicht-gierig, kein $ am Anfang/Ende) + src = src.replace(/(?][\s\S]*?<\/pre>|][\s\S]*?<\/code>/gi, function(m) { + codeBlocks.push(m); + return '%%CODE' + (codeBlocks.length - 1) + '%%'; + }); + + // 4a) [Review: AgentName] prefix → Review-Header + html = html.replace(/\[Review:\s*([^\]]+)\]\s*/g, function(match, agentName) { + return ' ' + esc(agentName.trim()) + ' '; + }); + + // 4a2) [Finalizer: AgentName] prefix → Finalizer-Header + html = html.replace(/\[Finalizer:\s*([^\]]+)\]\s*/g, function(match, agentName) { + return ' ' + esc(agentName.trim()) + ' '; + }); + + // 4a3) [Preparer: AgentName] prefix → Preparer-Header + html = html.replace(/\[Preparer:\s*([^\]]+)\]\s*/g, function(match, agentName) { + return ' ' + esc(agentName.trim()) + ' '; + }); + + // 4a4) [Vorbereitet von AgentName]: → Preparer-Forwarded-Header + html = html.replace(/\[Vorbereitet von\s+([^\]]+)\]:\s*/g, function(match, agentName) { + return '
Vorbereitet von ' + esc(agentName.trim()) + '
'; + }); + + // 4b) [Review-Feedback von AgentName (Runde X/Y)]: → Review-Feedback-Header + html = html.replace(/\[Review-Feedback von\s+([^\(]+)\(Runde\s+(\d+)\/(\d+)\)\]:\s*/g, function(match, agentName, round, maxRounds) { + return '
Feedback von ' + esc(agentName.trim()) + ' Runde ' + esc(round) + '/' + esc(maxRounds) + '
'; + }); + + // 4c) [APPROVED], [FORWARD] und [QUESTION] ausblenden + html = html.replace(/\[APPROVED\]/g, ''); + html = html.replace(/\[FORWARD\]\s*/g, ''); + html = html.replace(/\[QUESTION\]\s*/g, ''); + + // 4d) [Krea: dateiname.png] → Inline-Thumbnail mit Klick für Vollbild + html = html.replace(/\[Krea:\s*([^\]]+)\]/g, function(match, filename) { + var fn = filename.trim(); + var safeFn = esc(fn); + var thumbUrl = '/api/images/file/' + encodeURIComponent(fn) + '/thumb'; + var fullUrl = '/api/images/file/' + encodeURIComponent(fn); + return '' + + '' + safeFn + '' + + ''; + }); + + // 5) Unicode-Emojis durch Fluent Emoji 3D ersetzen (außerhalb von Code-Blöcken) + html = replaceEmojisWithFluent(html); + + // 6) Code-Blöcke wiederherstellen + html = html.replace(/%%CODE(\d+)%%/g, function(m, idx) { + return codeBlocks[parseInt(idx)]; + }); + + // 7) LaTeX-Platzhalter durch KaTeX-gerenderten HTML ersetzen + html = html.replace(/%%MATH(\d+)%%/g, function(m, idx) { + var block = mathBlocks[parseInt(idx)]; + try { + return katex.renderToString(block.latex.trim(), { + displayMode: block.displayMode, + throwOnError: false, + output: 'html' + }); + } catch (e) { + // Fallback: Originaltext anzeigen + return '' + esc(block.latex) + ''; + } + }); + + return html; +} + +function wrapKreaGalleries(container) { + // Findet

-Tags die NUR krea-ref(s) enthalten und gruppiert aufeinanderfolgende zu einer Gallery + var children = Array.from(container.children); + var i = 0; + while (i < children.length) { + var el = children[i]; + // Prüfe ob

nur krea-refs enthält (ggf. mit Whitespace) + if (el.tagName === 'P' && el.querySelector('.krea-ref') && el.textContent.trim() === '') { + // Sammle aufeinanderfolgende krea-only

-Tags + var group = [el]; + var j = i + 1; + while (j < children.length) { + var next = children[j]; + if (next.tagName === 'P' && next.querySelector('.krea-ref') && next.textContent.trim() === '') { + group.push(next); + j++; + } else { + break; + } + } + // Gallery erstellen wenn mindestens 1 Bild + if (group.length >= 1) { + var gallery = document.createElement('div'); + gallery.className = 'krea-gallery'; + el.parentNode.insertBefore(gallery, group[0]); + for (var k = 0; k < group.length; k++) { + var refs = group[k].querySelectorAll('.krea-ref'); + for (var r = 0; r < refs.length; r++) { + gallery.appendChild(refs[r]); + } + group[k].remove(); + } + // children-Array aktualisieren + children = Array.from(container.children); + // i bleibt gleich, da gallery jetzt an der Position steht + i++; + } else { + i = j; + } + } else { + i++; + } + } +} diff --git a/wwwroot/js/modals.js b/wwwroot/js/modals.js new file mode 100644 index 0000000..569f83a --- /dev/null +++ b/wwwroot/js/modals.js @@ -0,0 +1,190 @@ +// ── System-Prompt Modal ── + +function _renderSystemPromptBlocks(container, blocks) { + for (var i = 0; i < blocks.length; i++) { + var block = blocks[i]; + if (!block.content) continue; + + var wrapper = document.createElement('div'); + wrapper.className = 'sp-block'; + + var label = document.createElement('div'); + label.className = 'sp-block-label'; + label.innerHTML = ' ' + esc(block.label || 'Block'); + wrapper.appendChild(label); + + var content = document.createElement('div'); + content.className = 'sp-block-content markdown'; + content.innerHTML = renderMarkdown(block.content); + parseFluentEmojis(content); + wrapper.appendChild(content); + + container.appendChild(wrapper); + } +} + +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 blocks = await apiGetSystemPromptBlocks(sessionId, typeof _planModeActive !== 'undefined' && _planModeActive); + body.innerHTML = ''; + _renderSystemPromptBlocks(body, blocks); + } 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); +} + +// ── Krea Image Preview Modal ── + +function showKreaImageModal(title, fullUrl) { + 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 titleEl = document.createElement('span'); + titleEl.textContent = title; + var closeBtn = document.createElement('button'); + closeBtn.innerHTML = ''; + closeBtn.className = 'sp-close icon-btn'; + header.appendChild(titleEl); + header.appendChild(closeBtn); + + // Body + var body = document.createElement('div'); + body.className = 'sp-body krea-modal-body'; + + var img = document.createElement('img'); + img.src = fullUrl; + img.className = 'krea-modal-image'; + img.alt = title; + 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-Thumbnails im Chat +document.addEventListener('click', function(e) { + var ref = e.target.closest('.krea-ref'); + if (ref) { + var fullUrl = ref.dataset.fullurl; + var filename = ref.dataset.filename; + if (fullUrl) showKreaImageModal(filename || 'Bild', fullUrl); + } +}); diff --git a/wwwroot/js/msg-actions.js b/wwwroot/js/msg-actions.js new file mode 100644 index 0000000..8bd825c --- /dev/null +++ b/wwwroot/js/msg-actions.js @@ -0,0 +1,208 @@ +// ── Timestamp ── + +function _formatTimestamp(isoString) { + if (!isoString) return ''; + var d = new Date(isoString); + if (isNaN(d.getTime())) return ''; + var dd = String(d.getDate()).padStart(2, '0'); + var mm = String(d.getMonth() + 1).padStart(2, '0'); + var yyyy = d.getFullYear(); + var hh = String(d.getHours()).padStart(2, '0'); + var min = String(d.getMinutes()).padStart(2, '0'); + var ss = String(d.getSeconds()).padStart(2, '0'); + return dd + '.' + mm + '.' + yyyy + ' ' + hh + ':' + min + ':' + ss; +} + +function _addTimestamp(div, timestamp) { + if (!timestamp) return; + var formatted = _formatTimestamp(timestamp); + if (!formatted) return; + var span = document.createElement('span'); + span.className = 'msg-timestamp'; + span.textContent = formatted; + div.prepend(span); +} + +// ── Delete Button ── + +function _addDeleteButton(div, messageId, deletedOn) { + if (!messageId) return; + const isDeleted = !!deletedOn; + const btn = document.createElement('button'); + btn.className = 'msg-delete-btn icon-btn' + (isDeleted ? ' deleted' : ''); + btn.innerHTML = ''; + btn.title = isDeleted ? 'Löschung aufheben' : 'Nachricht löschen'; + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + const result = await apiToggleMessageDelete(messageId); + const nowDeleted = !!result.deletedOn; + btn.classList.toggle('deleted', nowDeleted); + btn.title = nowDeleted ? 'Löschung aufheben' : 'Nachricht löschen'; + if (nowDeleted && !isDeletedVisible()) { + // Nachricht ausblenden + div.classList.add('msg-deleted-hidden'); + } else { + div.classList.remove('msg-deleted-hidden'); + div.classList.toggle('msg-deleted', nowDeleted); + } + } catch (err) { + console.error('Delete toggle failed:', err); + } + }); + // Nach dem Reply-Button einfügen + const replyBtn = div.querySelector('.msg-reply-btn'); + if (replyBtn) { + replyBtn.after(btn); + } else { + div.prepend(btn); + } +} + +function isDeletedVisible() { + return loadSettings().showDeletedMessages === true; +} + +// ── 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); + } + }); + // Nach dem Delete-Button einfügen (oder nach Reply-Button, oder am Anfang) + const deleteBtn = div.querySelector('.msg-delete-btn'); + if (deleteBtn) { + deleteBtn.after(btn); + } else { + const replyBtn = div.querySelector('.msg-reply-btn'); + if (replyBtn) { + replyBtn.after(btn); + } else { + div.prepend(btn); + } + } +} + +// ── Share Button ── + +function _addShareButton(div, messageId) { + if (!messageId) return; + const btn = document.createElement('button'); + btn.className = 'msg-share-btn icon-btn'; + btn.innerHTML = ''; + btn.title = 'Nachricht teilen'; + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + // Kontextmenü: Einzelnachricht oder mit Kontext? + const mode = await _showShareMenu(btn); + if (!mode) return; // abgebrochen + + const result = await apiShareMessage(messageId, mode === 'context'); + const url = location.origin + '/#shared/' + result.token; + await navigator.clipboard.writeText(url); + showToast('Link kopiert!', 'success'); + } catch (err) { + showToast('Fehler: ' + err.message, 'error'); + } + }); + // Nach dem Mark-Button einfügen + const markBtn = div.querySelector('.msg-mark-btn'); + if (markBtn) { + markBtn.after(btn); + } else { + const deleteBtn = div.querySelector('.msg-delete-btn'); + if (deleteBtn) { + deleteBtn.after(btn); + } else { + div.prepend(btn); + } + } +} + +function _showShareMenu(anchorBtn) { + return new Promise((resolve) => { + // Wenn schon ein Menü offen, schließen + const existing = document.querySelector('.share-menu'); + if (existing) { existing.remove(); resolve(null); return; } + + const menu = document.createElement('div'); + menu.className = 'share-menu'; + menu.innerHTML = ` + + + `; + + // Positionierung + const rect = anchorBtn.getBoundingClientRect(); + menu.style.position = 'fixed'; + menu.style.top = (rect.bottom + 4) + 'px'; + menu.style.left = rect.left + 'px'; + menu.style.zIndex = '10000'; + + document.body.appendChild(menu); + + function cleanup() { + menu.remove(); + document.removeEventListener('click', outsideClick); + } + + function outsideClick(e) { + if (!menu.contains(e.target)) { + cleanup(); + resolve(null); + } + } + + menu.querySelectorAll('.share-menu-item').forEach(item => { + item.addEventListener('click', (e) => { + e.stopPropagation(); + const mode = item.dataset.mode; + cleanup(); + resolve(mode); + }); + }); + + // Beim nächsten Klick außerhalb schließen + setTimeout(() => document.addEventListener('click', outsideClick), 10); + }); +} + +// ── 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); +} diff --git a/wwwroot/js/reply.js b/wwwroot/js/reply.js new file mode 100644 index 0000000..b6d44dc --- /dev/null +++ b/wwwroot/js/reply.js @@ -0,0 +1,54 @@ +// ── Reply / Quote ── + +let pendingReply = null; // { index, role, text } - Zitierte Nachricht + +function _formatReplyTimestamp(ts) { + if (!ts) return 'Jetzt'; + try { + const d = new Date(ts); + const now = new Date(); + const hh = d.getHours().toString().padStart(2, '0'); + const mm = d.getMinutes().toString().padStart(2, '0'); + // Gleiches Datum → nur Uhrzeit, sonst Datum + Uhrzeit + if (d.toDateString() === now.toDateString()) { + return hh + ':' + mm; + } + const dd = d.getDate().toString().padStart(2, '0'); + const mo = (d.getMonth() + 1).toString().padStart(2, '0'); + return dd + '.' + mo + '. ' + hh + ':' + mm; + } catch { return 'Jetzt'; } +} + +function setReply(reply) { + pendingReply = reply; + renderReplyPreview(); + document.getElementById('input')?.focus(); +} + +function clearReply() { + pendingReply = null; + renderReplyPreview(); +} + +function renderReplyPreview() { + const container = $('#replyPreview'); + if (!container) return; + + if (!pendingReply) { + container.innerHTML = ''; + container.hidden = true; + return; + } + + const roleLabel = pendingReply.role === 'user' ? 'User' : 'Assistant'; + const timeLabel = _formatReplyTimestamp(pendingReply.timestamp); + const preview = truncate(pendingReply.text, 150); + + container.innerHTML = `
+ + ${esc(timeLabel)}, ${esc(roleLabel)} + ${esc(preview)} + +
`; + container.hidden = false; +} diff --git a/wwwroot/js/stream-ui.js b/wwwroot/js/stream-ui.js new file mode 100644 index 0000000..c8ba5e0 --- /dev/null +++ b/wwwroot/js/stream-ui.js @@ -0,0 +1,124 @@ +// ── 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 pauseStreamBubble() { + // Cursor entfernen, aber Bubble + Text behalten (z.B. bei Web-Search-Pause) + if (_streamDiv) { + const cursor = _streamDiv.querySelector('.cursor'); + if (cursor) cursor.remove(); + } +} + +function endStreamBubble() { + if (_streamDiv) { + const cursor = _streamDiv.querySelector('.cursor'); + if (cursor) cursor.remove(); + if (!_streamText.trim()) _streamDiv.remove(); + else { + _streamDiv.innerHTML = renderMarkdown(_streamText); + parseFluentEmojis(_streamDiv); + wrapKreaGalleries(_streamDiv); + } + } + _streamDiv = null; + _streamText = ''; +} + +// ── Review Bubbles (Streaming) ── + +let _reviewBubble = null; +let _reviewTextEl = null; +let _reviewText = ''; + +function addReviewBubble(type, data, agentType) { + _reviewBubble = document.createElement('div'); + _reviewBubble.className = 'msg review-bubble streamed'; + if (agentType === 'finalizer') _reviewBubble.classList.add('finalizer-bubble'); + if (agentType === 'preparer') _reviewBubble.classList.add('preparer-bubble'); + const header = document.createElement('div'); + header.className = 'review-header'; + if (agentType === 'preparer') { + header.innerHTML = ' ' + esc(data.agentName || 'Vorbereiter-Agent') + ''; + } else if (agentType === 'finalizer') { + header.innerHTML = ' ' + esc(data.agentName || 'Finalizer-Agent') + ''; + } else { + header.innerHTML = ' ' + esc(data.agentName || 'Review-Agent') + ' Runde ' + data.round + '/' + data.maxRounds + ''; + } + _reviewBubble.appendChild(header); + _reviewTextEl = document.createElement('div'); + _reviewTextEl.className = 'review-content markdown'; + _reviewBubble.appendChild(_reviewTextEl); + _reviewText = ''; + chatEl().appendChild(_reviewBubble); + scrollDown(); +} + +function appendToReviewText(text) { + if (!_reviewBubble) return; + _reviewText += text; + _reviewTextEl.innerHTML = renderMarkdown(_reviewText); + scrollDown(); +} + +function addReviewToolUse(data) { + if (!_reviewBubble) return; + const toolDiv = document.createElement('div'); + toolDiv.className = 'review-tool-use'; + toolDiv.innerHTML = ' ' + esc(data.toolName) + ''; + _reviewBubble.appendChild(toolDiv); + scrollDown(); +} + +function finalizeReviewBubble(type, data, agentType) { + if (!_reviewBubble) return; + const status = document.createElement('div'); + status.className = 'review-status'; + if (agentType === 'preparer' && type === 'forwarded') { + status.className += ' review-approved'; + status.innerHTML = ' Weitergeleitet an Session-Agent'; + } else if (agentType === 'preparer' && type === 'query') { + status.className += ' review-rejected'; + status.innerHTML = ' Rückfrage an User'; + } else if (agentType === 'finalizer' || type === 'done') { + status.className += ' review-approved'; + status.innerHTML = ' Abgeschlossen'; + } else if (type === 'approved') { + status.className += ' review-approved'; + status.innerHTML = ' Approved'; + } else { + status.className += ' review-rejected'; + status.innerHTML = ' Feedback – Worker korrigiert...'; + } + _reviewBubble.appendChild(status); + _reviewBubble = null; + _reviewTextEl = null; + _reviewText = ''; + scrollDown(); +} diff --git a/wwwroot/js/tool-ui.js b/wwwroot/js/tool-ui.js new file mode 100644 index 0000000..5e0283b --- /dev/null +++ b/wwwroot/js/tool-ui.js @@ -0,0 +1,332 @@ +// ── Tool Call Visibility ── + +const MODIFICATION_TOOLS = ['write_file', 'edit_file', 'delete_file', 'memory_write']; +const TOOL_DISPLAY_NAMES = { + write_file: 'Datei schreiben', + edit_file: 'Datei bearbeiten', + delete_file: 'Datei löschen', + memory_write: 'Erinnerungen festhalten', + web_search: 'Web-Suche' +}; + +function isToolVisible(toolName) { + const mode = loadSettings().toolCallDisplay; + if (mode === 'all') return true; + if (mode === 'hidden') return false; + // 'modifications' – nur modifizierende Tools + web_search anzeigen + if (toolName === 'web_search') return true; + 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.dataset.toolName = name; + const displayName = TOOL_DISPLAY_NAMES[name] || name; + div.innerHTML = '' + esc(displayName) + ' '; + chatEl().appendChild(div); + scrollDown(); + return div; +} + +function renderToolBoxContent(div, name, inputJson, result, isError, sources) { + 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 = TOOL_DISPLAY_NAMES[name] || 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; + + // Web-Search Sources – immer sichtbar, kein Toggle + if (name === 'web_search' && sources && sources.length > 0) { + const sourcesList = document.createElement('div'); + sourcesList.className = 'web-search-sources'; + sources.forEach(function(s) { + const link = document.createElement('a'); + link.href = s.url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.className = 'web-search-source'; + var domain = ''; + try { domain = new URL(s.url).hostname.replace('www.', ''); } catch(e) { domain = s.url; } + link.innerHTML = ' ' + esc(domain) + '' + + (s.title ? '' + esc(s.title) + '' : ''); + sourcesList.appendChild(link); + }); + div.appendChild(sourcesList); + } + + 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 if (name === 'todo_write') { + // Todo-Liste: kein tool-input, standardmäßig sichtbar, custom parser + if (result) { + const todoList = buildTodoList(result, isError); + div.appendChild(todoList); + foldTarget = todoList; + } + + } 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 && name !== 'todo_write') { + 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 buildTodoList(result, isError) { + const container = document.createElement('div'); + container.className = 'todo-list' + (isError ? ' tool-error' : ''); + + const lines = result.split('\n').filter(l => l.trim()); + for (const line of lines) { + // Format: ICON [id] [priority] content + const m = line.match(/^(\S+)\s+\[([^\]]+)\]\s+(?:\[([^\]]+)\]\s+)?(.+)$/); + const row = document.createElement('div'); + row.className = 'todo-item'; + + if (m) { + const icon = m[1]; // ✅ / 🔄 / ⬜ + const id = m[2]; + const prio = m[3] || null; + const text = m[4]; + + const statusClass = + icon === '✅' ? 'todo-done' : + icon === '🔄' ? 'todo-progress' : + 'todo-pending'; + row.classList.add(statusClass); + + const iconSpan = document.createElement('span'); + iconSpan.className = 'todo-icon'; + iconSpan.textContent = icon; + + const idSpan = document.createElement('span'); + idSpan.className = 'todo-id'; + idSpan.textContent = id + '.'; + + const textSpan = document.createElement('span'); + textSpan.className = 'todo-text'; + textSpan.textContent = text; + + row.appendChild(iconSpan); + row.appendChild(idSpan); + row.appendChild(textSpan); + + if (prio) { + const prioSpan = document.createElement('span'); + prioSpan.className = 'todo-prio todo-prio-' + prio.toLowerCase(); + prioSpan.textContent = prio; + row.appendChild(prioSpan); + } + } else { + row.textContent = line; + } + + container.appendChild(row); + } + + parseFluentEmojis(container); + + return container; +} + +function finishToolBox(div, data) { + if (!div) return; + renderToolBoxContent(div, data.name, data.input, data.result, data.isError, data.sources); + scrollDown(); +} + +function addToolBoxStatic(name, input, result, isError, sources) { + const div = document.createElement('div'); + div.className = 'tool-box'; + div.dataset.toolName = name; + renderToolBoxContent(div, name, input, result, isError, sources); + 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 toolName = lastBox.dataset.toolName || ''; + const isTodoWrite = toolName === 'todo_write'; + + if (isTodoWrite) { + // Vorhandene todo-list ersetzen oder neu erstellen + const existing = lastBox.querySelector('.todo-list'); + if (existing) existing.remove(); + if (result && !isError) { + const todoList = buildTodoList(result, isError); + lastBox.appendChild(todoList); + } + 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); + } +} diff --git a/wwwroot/js/ui-helpers.js b/wwwroot/js/ui-helpers.js new file mode 100644 index 0000000..ae8c6b4 --- /dev/null +++ b/wwwroot/js/ui-helpers.js @@ -0,0 +1,126 @@ +// ── DOM Helpers ── + +const $ = (sel) => document.querySelector(sel); +const chatEl = () => $('#chat'); +const metaEl = () => $('#meta'); + +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 || ''); +} + +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 prettyModelName(model) { + if (!model) return '?'; + model = model.split('claude-').join(''); + if (model == 'sonnet') return 'Sonnet'; + if (model == 'sonnet-4-5') return 'Sonnet 4.5'; + if (model == 'sonnet-4-6') return 'Sonnet 4.6'; + if (model == 'opus') return 'Opus'; + if (model == 'opus-4-5') return 'Opus 4.5'; + if (model == 'opus-4-6') return 'Opus 4.6'; + if (model == 'haiku') return 'Haiku'; + if (model == 'haiku-4-5') return 'Haiku 4.5'; + return 'Unbekannt (' + model + ')'; +} + +// ── Toast Notifications ── + +function showToast(message, type) { + type = type || 'info'; + var container = document.getElementById('toastContainer'); + if (!container) { + container = document.createElement('div'); + container.id = 'toastContainer'; + document.body.appendChild(container); + } + var toast = document.createElement('div'); + toast.className = 'toast toast-' + type; + var icon = type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-times-circle' : 'fa-info-circle'; + toast.innerHTML = ' ' + (message || ''); + container.appendChild(toast); + // Einblenden + requestAnimationFrame(function() { toast.classList.add('toast-visible'); }); + // Automatisch ausblenden + var timeout = setTimeout(function() { removeToast(toast); }, 4000); + toast.addEventListener('click', function() { + clearTimeout(timeout); + removeToast(toast); + }); +} + +function removeToast(toast) { + toast.classList.remove('toast-visible'); + toast.addEventListener('transitionend', function() { toast.remove(); }); +} + +// ── 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()); +} + +// ── Streamed Elements ── + +function removeStreamedElements() { + chatEl().querySelectorAll('.streamed').forEach(el => el.remove()); +} diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js deleted file mode 100644 index 2599ebc..0000000 --- a/wwwroot/js/ui.js +++ /dev/null @@ -1,1534 +0,0 @@ -// ── Session Card Helper ── -// Erzeugt ein .card-Element für eine Session (wiederverwendbar für Dashboard + Picker). -// session: Session-Objekt, agent: zugehöriges Agent-Objekt (oder null) -// Gibt ein DOM-Element zurück (.card mit .card-image + .card-body inkl. Badges + Meta). - -function buildSessionCard(session, agent) { - const card = document.createElement('div'); - card.className = 'card'; - - const transformParts = []; - if (session.avatarScale && session.avatarScale !== 1) transformParts.push(`scale(${session.avatarScale})`); - if (session.avatarOffsetX || session.avatarOffsetY) transformParts.push(`translate(${session.avatarOffsetX || 0}%, ${session.avatarOffsetY || 0}%)`); - const transformStyle = transformParts.length ? ` style="transform: ${transformParts.join(' ')}"` : ''; - const imageHtml = session.image ? `` : ''; - - // Badges - const badges = []; - if (agent) badges.push(`${esc(agent.name)}`); - if (session.reviewAgentId) badges.push(``); - if (session.finalizerAgentId) badges.push(``); - if (session.context) badges.push(``); - if (session.telegramConfigured) badges.push(``); - - // Meta - const metaParts = []; - if (session.messageCount) metaParts.push(` ${esc(session.messageCount + ' Nachrichten')}`); - if (session.archiveMessageCount) metaParts.push(` ${esc(session.archiveMessageCount + ' Nachrichten archiviert')}`); - if (session.workspace) metaParts.push(` ${esc(session.workspace)}`); - - card.innerHTML = ` -
${imageHtml}
-
-
- ${esc(session.name || 'Unbenannt')} - ${badges.join('')} -
- ${metaParts.length ? `
${metaParts.join('')}
` : ''} -
- `; - - return card; -} - - -// ── Agent Card Helper ── -// Erzeugt ein .card-Element für einen Agent (wiederverwendbar für Dashboard + Picker). -// agent: Agent-Objekt, skills: Array von Skills (optional) -// Gibt ein DOM-Element zurück (.card mit .card-image + .card-body inkl. Badges). - -function buildAgentCard(agent, skills) { - const card = document.createElement('div'); - card.className = 'card'; - - const agentTransformParts = []; - if (agent.avatarScale && agent.avatarScale !== 1) agentTransformParts.push(`scale(${agent.avatarScale})`); - if (agent.avatarOffsetX || agent.avatarOffsetY) agentTransformParts.push(`translate(${agent.avatarOffsetX || 0}%, ${agent.avatarOffsetY || 0}%)`); - const agentTransformStyle = agentTransformParts.length ? ` style="transform: ${agentTransformParts.join(' ')}"` : ''; - const imageHtml = agent.avatar ? `` : ''; - - // Badges - const badges = []; - if (agent.toolChoice === 'any') badges.push(' Erzwungen'); - if (agent.allowUserChoice) badges.push(' Rückfragen'); - if (agent.autoCommit === 'mention') badges.push(' Anmerken'); - if (agent.autoCommit === 'enforce') badges.push(' Erzwungen'); - if (agent.autoCommit === 'forbid') badges.push(' Verboten'); - if (agent.useSoul) badges.push(' Seele'); - if (agent.useIdentity) badges.push(' Ich'); - if (agent.useUserInfo) badges.push(' Du'); - if (agent.useMemory) badges.push(' Erinnerungen'); - if (agent.webSearchVersion) { - const wsYear = agent.webSearchVersion.includes('2025') ? 'Version 2025' : 'v2026'; - badges.push(` Web-Suche`); - } - - if (skills) { - for (const skill of skills) { - badges.push(` ${esc(skill.name)}`); - } - } - - card.innerHTML = ` -
${imageHtml}
-
-
- ${esc(agent.name)} - ${prettyModelName(agent.model)} -
- ${badges.length ? `
${badges.join('')}
` : ''} -
- `; - - return card; -} - -// ── Toast Notifications ── - -function showToast(message, type) { - type = type || 'info'; - var container = document.getElementById('toastContainer'); - if (!container) { - container = document.createElement('div'); - container.id = 'toastContainer'; - document.body.appendChild(container); - } - var toast = document.createElement('div'); - toast.className = 'toast toast-' + type; - var icon = type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-times-circle' : 'fa-info-circle'; - toast.innerHTML = ' ' + (message || ''); - container.appendChild(toast); - // Einblenden - requestAnimationFrame(function() { toast.classList.add('toast-visible'); }); - // Automatisch ausblenden - var timeout = setTimeout(function() { removeToast(toast); }, 4000); - toast.addEventListener('click', function() { - clearTimeout(timeout); - removeToast(toast); - }); -} - -function removeToast(toast) { - toast.classList.remove('toast-visible'); - toast.addEventListener('transitionend', function() { toast.remove(); }); -} - -// ── DOM Helpers ── - -const $ = (sel) => document.querySelector(sel); -const chatEl = () => $('#chat'); -const metaEl = () => $('#meta'); - -function prettyModelName(model) { - if (!model) return '?'; - model = model.split('claude-').join(''); - if (model == 'sonnet') return 'Sonnet'; - if (model == 'sonnet-4-5') return 'Sonnet 4.5'; - if (model == 'sonnet-4-6') return 'Sonnet 4.6'; - if (model == 'opus') return 'Opus'; - if (model == 'opus-4-5') return 'Opus 4.5'; - if (model == 'opus-4-6') return 'Opus 4.6'; - if (model == 'haiku') return 'Haiku'; - if (model == 'haiku-4-5') return 'Haiku 4.5'; - return 'Unbekannt (' + model + ')'; -} - -// ── Markdown ── - -marked.setOptions({ breaks: true, gfm: true }); - -function renderMarkdown(text) { - if (!text) return ''; - var src = text.trim(); - - // 1) LaTeX-Ausdrücke VOR dem Markdown-Parsing schützen - // Reihenfolge: zuerst Block ($$...$$), dann Inline ($...$) - var mathBlocks = []; - // Block-Math: $$...$$ (auch mehrzeilig) - src = src.replace(/\$\$([\s\S]+?)\$\$/g, function(m, latex) { - mathBlocks.push({ latex: latex, displayMode: true }); - return '%%MATH' + (mathBlocks.length - 1) + '%%'; - }); - // Inline-Math: $...$ (nicht-gierig, kein $ am Anfang/Ende) - src = src.replace(/(?][\s\S]*?<\/pre>|][\s\S]*?<\/code>/gi, function(m) { - codeBlocks.push(m); - return '%%CODE' + (codeBlocks.length - 1) + '%%'; - }); - - // 4a) [Review: AgentName] prefix → Review-Header - html = html.replace(/\[Review:\s*([^\]]+)\]\s*/g, function(match, agentName) { - return ' ' + esc(agentName.trim()) + ' '; - }); - - // 4a2) [Finalizer: AgentName] prefix → Finalizer-Header - html = html.replace(/\[Finalizer:\s*([^\]]+)\]\s*/g, function(match, agentName) { - return ' ' + esc(agentName.trim()) + ' '; - }); - - // 4a3) [Preparer: AgentName] prefix → Preparer-Header - html = html.replace(/\[Preparer:\s*([^\]]+)\]\s*/g, function(match, agentName) { - return ' ' + esc(agentName.trim()) + ' '; - }); - - // 4a4) [Vorbereitet von AgentName]: → Preparer-Forwarded-Header - html = html.replace(/\[Vorbereitet von\s+([^\]]+)\]:\s*/g, function(match, agentName) { - return '
Vorbereitet von ' + esc(agentName.trim()) + '
'; - }); - - // 4b) [Review-Feedback von AgentName (Runde X/Y)]: → Review-Feedback-Header - html = html.replace(/\[Review-Feedback von\s+([^\(]+)\(Runde\s+(\d+)\/(\d+)\)\]:\s*/g, function(match, agentName, round, maxRounds) { - return '
Feedback von ' + esc(agentName.trim()) + ' Runde ' + esc(round) + '/' + esc(maxRounds) + '
'; - }); - - // 4c) [APPROVED], [FORWARD] und [QUESTION] ausblenden - html = html.replace(/\[APPROVED\]/g, ''); - html = html.replace(/\[FORWARD\]\s*/g, ''); - html = html.replace(/\[QUESTION\]\s*/g, ''); - - // 4d) [Krea: dateiname.png] → Inline-Thumbnail mit Klick für Vollbild - html = html.replace(/\[Krea:\s*([^\]]+)\]/g, function(match, filename) { - var fn = filename.trim(); - var safeFn = esc(fn); - var thumbUrl = '/api/images/file/' + encodeURIComponent(fn) + '/thumb'; - var fullUrl = '/api/images/file/' + encodeURIComponent(fn); - return '' - + '' + safeFn + '' - + ''; - }); - - // 5) Unicode-Emojis durch Fluent Emoji 3D ersetzen (außerhalb von Code-Blöcken) - html = replaceEmojisWithFluent(html); - - // 6) Code-Blöcke wiederherstellen - html = html.replace(/%%CODE(\d+)%%/g, function(m, idx) { - return codeBlocks[parseInt(idx)]; - }); - - // 7) LaTeX-Platzhalter durch KaTeX-gerenderten HTML ersetzen - html = html.replace(/%%MATH(\d+)%%/g, function(m, idx) { - var block = mathBlocks[parseInt(idx)]; - try { - return katex.renderToString(block.latex.trim(), { - displayMode: block.displayMode, - throwOnError: false, - output: 'html' - }); - } catch (e) { - // Fallback: Originaltext anzeigen - return '' + esc(block.latex) + ''; - } - }); - - return html; -} - -function wrapKreaGalleries(container) { - // Findet

-Tags die NUR krea-ref(s) enthalten und gruppiert aufeinanderfolgende zu einer Gallery - var children = Array.from(container.children); - var i = 0; - while (i < children.length) { - var el = children[i]; - // Prüfe ob

nur krea-refs enthält (ggf. mit Whitespace) - if (el.tagName === 'P' && el.querySelector('.krea-ref') && el.textContent.trim() === '') { - // Sammle aufeinanderfolgende krea-only

-Tags - var group = [el]; - var j = i + 1; - while (j < children.length) { - var next = children[j]; - if (next.tagName === 'P' && next.querySelector('.krea-ref') && next.textContent.trim() === '') { - group.push(next); - j++; - } else { - break; - } - } - // Gallery erstellen wenn mindestens 1 Bild - if (group.length >= 1) { - var gallery = document.createElement('div'); - gallery.className = 'krea-gallery'; - el.parentNode.insertBefore(gallery, group[0]); - for (var k = 0; k < group.length; k++) { - var refs = group[k].querySelectorAll('.krea-ref'); - for (var r = 0; r < refs.length; r++) { - gallery.appendChild(refs[r]); - } - group[k].remove(); - } - // children-Array aktualisieren - children = Array.from(container.children); - // i bleibt gleich, da gallery jetzt an der Position steht - i++; - } else { - i = j; - } - } else { - i++; - } - } -} - -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 || ''); -} - -// ── Delete Button ── - -function _addDeleteButton(div, messageId, deletedOn) { - if (!messageId) return; - const isDeleted = !!deletedOn; - const btn = document.createElement('button'); - btn.className = 'msg-delete-btn icon-btn' + (isDeleted ? ' deleted' : ''); - btn.innerHTML = ''; - btn.title = isDeleted ? 'Löschung aufheben' : 'Nachricht löschen'; - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - try { - const result = await apiToggleMessageDelete(messageId); - const nowDeleted = !!result.deletedOn; - btn.classList.toggle('deleted', nowDeleted); - btn.title = nowDeleted ? 'Löschung aufheben' : 'Nachricht löschen'; - if (nowDeleted && !isDeletedVisible()) { - // Nachricht ausblenden - div.classList.add('msg-deleted-hidden'); - } else { - div.classList.remove('msg-deleted-hidden'); - div.classList.toggle('msg-deleted', nowDeleted); - } - } catch (err) { - console.error('Delete toggle failed:', err); - } - }); - // Nach dem Reply-Button einfügen - const replyBtn = div.querySelector('.msg-reply-btn'); - if (replyBtn) { - replyBtn.after(btn); - } else { - div.prepend(btn); - } -} - -function isDeletedVisible() { - return loadSettings().showDeletedMessages === true; -} - -// ── 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); - } - }); - // Nach dem Delete-Button einfügen (oder nach Reply-Button, oder am Anfang) - const deleteBtn = div.querySelector('.msg-delete-btn'); - if (deleteBtn) { - deleteBtn.after(btn); - } else { - const replyBtn = div.querySelector('.msg-reply-btn'); - if (replyBtn) { - replyBtn.after(btn); - } else { - div.prepend(btn); - } - } -} - -// ── Share Button ── - -function _addShareButton(div, messageId) { - if (!messageId) return; - const btn = document.createElement('button'); - btn.className = 'msg-share-btn icon-btn'; - btn.innerHTML = ''; - btn.title = 'Nachricht teilen'; - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - try { - // Kontextmenü: Einzelnachricht oder mit Kontext? - const mode = await _showShareMenu(btn); - if (!mode) return; // abgebrochen - - const result = await apiShareMessage(messageId, mode === 'context'); - const url = location.origin + '/#shared/' + result.token; - await navigator.clipboard.writeText(url); - showToast('Link kopiert!', 'success'); - } catch (err) { - showToast('Fehler: ' + err.message, 'error'); - } - }); - // Nach dem Mark-Button einfügen - const markBtn = div.querySelector('.msg-mark-btn'); - if (markBtn) { - markBtn.after(btn); - } else { - const deleteBtn = div.querySelector('.msg-delete-btn'); - if (deleteBtn) { - deleteBtn.after(btn); - } else { - div.prepend(btn); - } - } -} - -function _showShareMenu(anchorBtn) { - return new Promise((resolve) => { - // Wenn schon ein Menü offen, schließen - const existing = document.querySelector('.share-menu'); - if (existing) { existing.remove(); resolve(null); return; } - - const menu = document.createElement('div'); - menu.className = 'share-menu'; - menu.innerHTML = ` - - - `; - - // Positionierung - const rect = anchorBtn.getBoundingClientRect(); - menu.style.position = 'fixed'; - menu.style.top = (rect.bottom + 4) + 'px'; - menu.style.left = rect.left + 'px'; - menu.style.zIndex = '10000'; - - document.body.appendChild(menu); - - function cleanup() { - menu.remove(); - document.removeEventListener('click', outsideClick); - } - - function outsideClick(e) { - if (!menu.contains(e.target)) { - cleanup(); - resolve(null); - } - } - - menu.querySelectorAll('.share-menu-item').forEach(item => { - item.addEventListener('click', (e) => { - e.stopPropagation(); - const mode = item.dataset.mode; - cleanup(); - resolve(mode); - }); - }); - - // Beim nächsten Klick außerhalb schließen - setTimeout(() => document.addEventListener('click', outsideClick), 10); - }); -} - -// ── Timestamp ── - -function _formatTimestamp(isoString) { - if (!isoString) return ''; - var d = new Date(isoString); - if (isNaN(d.getTime())) return ''; - var dd = String(d.getDate()).padStart(2, '0'); - var mm = String(d.getMonth() + 1).padStart(2, '0'); - var yyyy = d.getFullYear(); - var hh = String(d.getHours()).padStart(2, '0'); - var min = String(d.getMinutes()).padStart(2, '0'); - var ss = String(d.getSeconds()).padStart(2, '0'); - return dd + '.' + mm + '.' + yyyy + ' ' + hh + ':' + min + ':' + ss; -} - -function _addTimestamp(div, timestamp) { - if (!timestamp) return; - var formatted = _formatTimestamp(timestamp); - if (!formatted) return; - var span = document.createElement('span'); - span.className = 'msg-timestamp'; - span.textContent = formatted; - div.prepend(span); -} - -// ── 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, inlineImages) { - 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, inlineImages); - } - return; - } - - _renderUserTextContent(container, raw, inlineImages); -} - -function _renderUserTextContent(container, raw, inlineImages) { - // [Bild: name] durch Platzhalter ersetzen - const imagePattern = /\[Bild: ([^\]]+)\]/g; - const imageRefs = []; - let mdText = raw.replace(imagePattern, function(m, name) { - imageRefs.push(name); - return '%%IMGREF' + (imageRefs.length - 1) + '%%'; - }); - - // [Datei: path] oder [Datei: path:1-10] durch Platzhalter ersetzen (vor Markdown-Parsing) - const pattern = /\[Datei: ([^\]]+)\]/g; - const fileRefs = []; - mdText = mdText.replace(pattern, function(m, ref) { - fileRefs.push(ref); - return '%%FILEREF' + (fileRefs.length - 1) + '%%'; - }); - - // Markdown rendern - let html = renderMarkdown(mdText); - - // Bild-Platzhalter durch Inline-Bilder ersetzen - html = html.replace(/%%IMGREF(\d+)%%/g, function(m, idx) { - const name = imageRefs[parseInt(idx)]; - // Wenn Inline-Bild-Daten vorhanden (beim Senden), direkt anzeigen - const img = inlineImages && inlineImages.find(function(i) { return i.name === name; }); - if (img) { - return '' + esc(name) + ''; - } - // Sonst: Platzhalter mit data-Attribut für späteres Laden - return '' - + ' ' + esc(name) + ''; - }); - - // Datei-Platzhalter durch File-Ref-Chips ersetzen (als HTML) - html = html.replace(/%%FILEREF(\d+)%%/g, function(m, idx) { - const ref = fileRefs[parseInt(idx)]; - const filePath = ref.replace(/:\d+(-\d+)?$/, ''); - const fileName = filePath.includes('/') ? filePath.substring(filePath.lastIndexOf('/') + 1) : filePath; - const lineInfo = ref.replace(filePath, '').trim(); - return '' - + ' ' + esc(fileName + lineInfo) + ''; - }); - - // In ein Wrapper-Div rendern (damit vorherige Elemente wie Reply-Quotes erhalten bleiben) - const wrap = document.createElement('div'); - wrap.className = 'user-text-content'; - wrap.innerHTML = html; - container.appendChild(wrap); - - // Click-Handler für File-Ref-Chips nachträglich binden - wrap.querySelectorAll('.file-ref-chip[data-filepath]').forEach(function(chip) { - chip.addEventListener('click', function() { - openFileExplorer({ sessionId: getCurrentSessionId(), initialFile: chip.dataset.filepath }); - }); - }); - - // Click-Handler für Inline-Bilder (Vollbild) - wrap.querySelectorAll('.inline-user-image').forEach(function(img) { - img.addEventListener('click', function() { - showKreaImageModal(img.alt || 'Bild', img.src); - }); - }); - - // Fluent Emoji DOM-Parsing - parseFluentEmojis(wrap); -} - -function addUserBubble(text, streamed = false, timestamp = '', messageId = null, isMarked = false, deletedOn = null, inlineImages = null) { - const div = document.createElement('div'); - div.className = 'msg user markdown' + (isMarked ? ' marked' : ''); - if (deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); - 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, inlineImages); - _addTimestamp(div, timestamp); - _addReplyButton(div); - _addDeleteButton(div, messageId, deletedOn); - _addMarkButton(div, messageId, isMarked); - _addShareButton(div, messageId); - chatEl().appendChild(div); - scrollDown(); -} - -function addAssistantBubble(text, timestamp = '', messageId = null, isMarked = false, deletedOn = null) { - const div = document.createElement('div'); - div.className = 'msg assistant markdown' + (isMarked ? ' marked' : ''); - if (deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); - div.dataset.role = 'assistant'; - div.dataset.timestamp = timestamp || new Date().toISOString(); - if (messageId) div.dataset.messageId = messageId; - div.innerHTML = renderMarkdown(text); - parseFluentEmojis(div); - wrapKreaGalleries(div); - _addTimestamp(div, timestamp); - _addReplyButton(div); - _addDeleteButton(div, messageId, deletedOn); - _addMarkButton(div, messageId, isMarked); - _addShareButton(div, messageId); - 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(); -} - -// ── Choice Bubbles (ask_user_choice) ── - -// Wird live aus dem SSE-Stream aufgerufen -function addChoiceBubble(toolCallId, question, choices, streamed = true) { - // Guard: choices muss ein Array sein - if (!Array.isArray(choices)) choices = [String(choices)]; - - const div = document.createElement('div'); - div.className = 'msg choice-bubble'; - if (streamed) div.classList.add('streamed'); - div.dataset.toolCallId = toolCallId; - - const questionDiv = document.createElement('div'); - questionDiv.className = 'choice-question'; - questionDiv.innerHTML = renderMarkdown(question); - parseFluentEmojis(questionDiv); - div.appendChild(questionDiv); - - const buttonsDiv = document.createElement('div'); - buttonsDiv.className = 'choice-buttons'; - for (const choice of choices) { - const btn = document.createElement('button'); - btn.className = 'choice-btn'; - btn.textContent = choice; - btn.addEventListener('click', () => handleChoiceClick(toolCallId, choice, div)); - buttonsDiv.appendChild(btn); - } - div.appendChild(buttonsDiv); - - chatEl().appendChild(div); - scrollDown(); - return div; -} - -// Wird für History-Rendering aufgerufen (bereits beantwortete Choices) -function addChoiceBubbleStatic(toolCallId, question, choices, selectedChoice, timestamp, messageId) { - // Guard: choices muss ein Array sein - if (!Array.isArray(choices)) choices = [String(choices)]; - - const div = document.createElement('div'); - div.className = 'msg choice-bubble choice-answered'; - div.dataset.toolCallId = toolCallId; - if (messageId) div.dataset.messageId = messageId; - - const questionDiv = document.createElement('div'); - questionDiv.className = 'choice-question'; - questionDiv.innerHTML = renderMarkdown(question); - parseFluentEmojis(questionDiv); - div.appendChild(questionDiv); - - const buttonsDiv = document.createElement('div'); - buttonsDiv.className = 'choice-buttons'; - for (const choice of choices) { - const btn = document.createElement('button'); - btn.className = 'choice-btn'; - btn.disabled = true; - btn.textContent = choice; - if (choice === selectedChoice) { - btn.classList.add('choice-selected'); - } else { - btn.classList.add('choice-unselected'); - } - buttonsDiv.appendChild(btn); - } - div.appendChild(buttonsDiv); - - if (timestamp) _addTimestamp(div, timestamp); - chatEl().appendChild(div); - scrollDown(); - return div; -} - -// handleChoiceClick ist in chat.js definiert (benötigt Stream-Infrastruktur) - -// ── Review Bubbles ── - -function addReviewBubbleStatic(text, timestamp, messageId, isMarked, deletedOn) { - const div = document.createElement('div'); - div.className = 'msg review-bubble markdown' + (isMarked ? ' marked' : ''); - if (deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden'); - div.dataset.role = 'assistant'; - div.dataset.timestamp = timestamp || ''; - if (messageId) div.dataset.messageId = messageId; - div.innerHTML = renderMarkdown(text); - parseFluentEmojis(div); - _addTimestamp(div, timestamp); - _addReplyButton(div); - _addDeleteButton(div, messageId, deletedOn); - _addMarkButton(div, messageId, isMarked); - _addShareButton(div, messageId); - chatEl().appendChild(div); - scrollDown(); - return div; -} - -let _reviewBubble = null; -let _reviewTextEl = null; -let _reviewText = ''; - -function addReviewBubble(type, data, agentType) { - _reviewBubble = document.createElement('div'); - _reviewBubble.className = 'msg review-bubble streamed'; - if (agentType === 'finalizer') _reviewBubble.classList.add('finalizer-bubble'); - if (agentType === 'preparer') _reviewBubble.classList.add('preparer-bubble'); - const header = document.createElement('div'); - header.className = 'review-header'; - if (agentType === 'preparer') { - header.innerHTML = ' ' + esc(data.agentName || 'Vorbereiter-Agent') + ''; - } else if (agentType === 'finalizer') { - header.innerHTML = ' ' + esc(data.agentName || 'Finalizer-Agent') + ''; - } else { - header.innerHTML = ' ' + esc(data.agentName || 'Review-Agent') + ' Runde ' + data.round + '/' + data.maxRounds + ''; - } - _reviewBubble.appendChild(header); - _reviewTextEl = document.createElement('div'); - _reviewTextEl.className = 'review-content markdown'; - _reviewBubble.appendChild(_reviewTextEl); - _reviewText = ''; - chatEl().appendChild(_reviewBubble); - scrollDown(); -} - -function appendToReviewText(text) { - if (!_reviewBubble) return; - _reviewText += text; - _reviewTextEl.innerHTML = renderMarkdown(_reviewText); - scrollDown(); -} - -function addReviewToolUse(data) { - if (!_reviewBubble) return; - const toolDiv = document.createElement('div'); - toolDiv.className = 'review-tool-use'; - toolDiv.innerHTML = ' ' + esc(data.toolName) + ''; - _reviewBubble.appendChild(toolDiv); - scrollDown(); -} - -function finalizeReviewBubble(type, data, agentType) { - if (!_reviewBubble) return; - const status = document.createElement('div'); - status.className = 'review-status'; - if (agentType === 'preparer' && type === 'forwarded') { - status.className += ' review-approved'; - status.innerHTML = ' Weitergeleitet an Session-Agent'; - } else if (agentType === 'preparer' && type === 'query') { - status.className += ' review-rejected'; - status.innerHTML = ' Rückfrage an User'; - } else if (agentType === 'finalizer' || type === 'done') { - status.className += ' review-approved'; - status.innerHTML = ' Abgeschlossen'; - } else if (type === 'approved') { - status.className += ' review-approved'; - status.innerHTML = ' Approved'; - } else { - status.className += ' review-rejected'; - status.innerHTML = ' Feedback – Worker korrigiert...'; - } - _reviewBubble.appendChild(status); - _reviewBubble = null; - _reviewTextEl = null; - _reviewText = ''; - 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 pauseStreamBubble() { - // Cursor entfernen, aber Bubble + Text behalten (z.B. bei Web-Search-Pause) - if (_streamDiv) { - const cursor = _streamDiv.querySelector('.cursor'); - if (cursor) cursor.remove(); - } -} - -function endStreamBubble() { - if (_streamDiv) { - const cursor = _streamDiv.querySelector('.cursor'); - if (cursor) cursor.remove(); - if (!_streamText.trim()) _streamDiv.remove(); - else { - _streamDiv.innerHTML = renderMarkdown(_streamText); - parseFluentEmojis(_streamDiv); - wrapKreaGalleries(_streamDiv); - } - } - _streamDiv = null; - _streamText = ''; -} - -// ── Tool Call Visibility ── - -const MODIFICATION_TOOLS = ['write_file', 'edit_file', 'delete_file', 'memory_write']; -const TOOL_DISPLAY_NAMES = { - write_file: 'Datei schreiben', - edit_file: 'Datei bearbeiten', - delete_file: 'Datei löschen', - memory_write: 'Erinnerungen festhalten', - web_search: 'Web-Suche' -}; - -function isToolVisible(toolName) { - const mode = loadSettings().toolCallDisplay; - if (mode === 'all') return true; - if (mode === 'hidden') return false; - // 'modifications' – nur modifizierende Tools + web_search anzeigen - if (toolName === 'web_search') return true; - 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.dataset.toolName = name; - const displayName = TOOL_DISPLAY_NAMES[name] || name; - div.innerHTML = '' + esc(displayName) + ' '; - chatEl().appendChild(div); - scrollDown(); - return div; -} - -function renderToolBoxContent(div, name, inputJson, result, isError, sources) { - 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 = TOOL_DISPLAY_NAMES[name] || 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; - - // Web-Search Sources – immer sichtbar, kein Toggle - if (name === 'web_search' && sources && sources.length > 0) { - const sourcesList = document.createElement('div'); - sourcesList.className = 'web-search-sources'; - sources.forEach(function(s) { - const link = document.createElement('a'); - link.href = s.url; - link.target = '_blank'; - link.rel = 'noopener noreferrer'; - link.className = 'web-search-source'; - var domain = ''; - try { domain = new URL(s.url).hostname.replace('www.', ''); } catch(e) { domain = s.url; } - link.innerHTML = ' ' + esc(domain) + '' + - (s.title ? '' + esc(s.title) + '' : ''); - sourcesList.appendChild(link); - }); - div.appendChild(sourcesList); - } - - 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 if (name === 'todo_write') { - // Todo-Liste: kein tool-input, standardmäßig sichtbar, custom parser - if (result) { - const todoList = buildTodoList(result, isError); - div.appendChild(todoList); - foldTarget = todoList; - } - - } 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 && name !== 'todo_write') { - 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 buildTodoList(result, isError) { - const container = document.createElement('div'); - container.className = 'todo-list' + (isError ? ' tool-error' : ''); - - const lines = result.split('\n').filter(l => l.trim()); - for (const line of lines) { - // Format: ICON [id] [priority] content - const m = line.match(/^(\S+)\s+\[([^\]]+)\]\s+(?:\[([^\]]+)\]\s+)?(.+)$/); - const row = document.createElement('div'); - row.className = 'todo-item'; - - if (m) { - const icon = m[1]; // ✅ / 🔄 / ⬜ - const id = m[2]; - const prio = m[3] || null; - const text = m[4]; - - const statusClass = - icon === '✅' ? 'todo-done' : - icon === '🔄' ? 'todo-progress' : - 'todo-pending'; - row.classList.add(statusClass); - - const iconSpan = document.createElement('span'); - iconSpan.className = 'todo-icon'; - iconSpan.textContent = icon; - - const idSpan = document.createElement('span'); - idSpan.className = 'todo-id'; - idSpan.textContent = id + '.'; - - const textSpan = document.createElement('span'); - textSpan.className = 'todo-text'; - textSpan.textContent = text; - - row.appendChild(iconSpan); - row.appendChild(idSpan); - row.appendChild(textSpan); - - if (prio) { - const prioSpan = document.createElement('span'); - prioSpan.className = 'todo-prio todo-prio-' + prio.toLowerCase(); - prioSpan.textContent = prio; - row.appendChild(prioSpan); - } - } else { - row.textContent = line; - } - - container.appendChild(row); - } - - parseFluentEmojis(container); - - return container; -} - -function finishToolBox(div, data) { - if (!div) return; - renderToolBoxContent(div, data.name, data.input, data.result, data.isError, data.sources); - scrollDown(); -} - -function addToolBoxStatic(name, input, result, isError, sources) { - const div = document.createElement('div'); - div.className = 'tool-box'; - div.dataset.toolName = name; - renderToolBoxContent(div, name, input, result, isError, sources); - 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 toolName = lastBox.dataset.toolName || ''; - const isTodoWrite = toolName === 'todo_write'; - - if (isTodoWrite) { - // Vorhandene todo-list ersetzen oder neu erstellen - const existing = lastBox.querySelector('.todo-list'); - if (existing) existing.remove(); - if (result && !isError) { - const todoList = buildTodoList(result, isError); - lastBox.appendChild(todoList); - } - 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 ── - -function _renderSystemPromptBlocks(container, blocks) { - for (var i = 0; i < blocks.length; i++) { - var block = blocks[i]; - if (!block.content) continue; - - var wrapper = document.createElement('div'); - wrapper.className = 'sp-block'; - - var label = document.createElement('div'); - label.className = 'sp-block-label'; - label.innerHTML = ' ' + esc(block.label || 'Block'); - wrapper.appendChild(label); - - var content = document.createElement('div'); - content.className = 'sp-block-content markdown'; - content.innerHTML = renderMarkdown(block.content); - parseFluentEmojis(content); - wrapper.appendChild(content); - - container.appendChild(wrapper); - } -} - -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 blocks = await apiGetSystemPromptBlocks(sessionId, typeof _planModeActive !== 'undefined' && _planModeActive); - body.innerHTML = ''; - _renderSystemPromptBlocks(body, blocks); - } 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(title, fullUrl) { - 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 titleEl = document.createElement('span'); - titleEl.textContent = title; - var closeBtn = document.createElement('button'); - closeBtn.innerHTML = ''; - closeBtn.className = 'sp-close icon-btn'; - header.appendChild(titleEl); - header.appendChild(closeBtn); - - // Body - var body = document.createElement('div'); - body.className = 'sp-body krea-modal-body'; - - var img = document.createElement('img'); - img.src = fullUrl; - img.className = 'krea-modal-image'; - img.alt = title; - 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-Thumbnails im Chat -document.addEventListener('click', function(e) { - var ref = e.target.closest('.krea-ref'); - if (ref) { - var fullUrl = ref.dataset.fullurl; - var filename = ref.dataset.filename; - if (fullUrl) showKreaImageModal(filename || 'Bild', fullUrl); - } -});