Files
gott/wwwroot/js/ui.js
T
Meleki 92c20402eb refactor: Agent-Auswahl in Session-Form als Card + Picker-Modal
- buildAgentCard() Helper in ui.js erstellt (wiederverwendbar für Dashboard + Picker)
- Session-Form: select-Dropdown durch klickbare Agent-Card ersetzt
- Agent-Picker-Modal zeigt alle Agents mit identischen Cards wie am Dashboard
- Dashboard refactored: nutzt buildAgentCard() statt dupliziertem Inline-HTML
- Kein duplizierter Code mehr für Agent-Card-Rendering (Badges, Skills, Avatar)
2026-03-06 14:01:27 +01:00

1264 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── 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 scaleStyle = agent.avatarScale && agent.avatarScale !== 1 ? ` style="transform: scale(${agent.avatarScale})"` : '';
const imageHtml = agent.avatar ? `<img src="${agent.avatar}" alt=""${scaleStyle}>` : '';
// Badges
const badges = [];
if (agent.toolChoice === 'any') badges.push('<span class="badge badge-forced-feature" title="Tool-Nutzung erzwingen"><i class="fa fa-wrench"></i> Erzwungen</span>');
if (agent.autoCommit === 'mention') badges.push('<span class="badge badge-feature" title="Git-Commits nur auf Anweisung"><i class="fa fa-code-fork"></i> Anmerken</span>');
if (agent.autoCommit === 'enforce') badges.push('<span class="badge badge-forced-feature" title="Git-Commits nach jeder Änderung erzwungen"><i class="fa fa-code-fork"></i> Erzwungen</span>');
if (agent.autoCommit === 'forbid') badges.push('<span class="badge badge-forced-feature" title="Git-Commits strikt verboten"><i class="fa fa-code-fork"></i> Verboten</span>');
if (agent.useSoul) badges.push('<span class="badge badge-feature" title="Hat eine Seele (SOUL.md)"><i class="fa fa-heart"></i> Seele</span>');
if (agent.useIdentity) badges.push('<span class="badge badge-feature" title="Hat eine Identität (IDENTITY.md)"><i class="fa fa-id-badge"></i> Ich</span>');
if (agent.useUserInfo) badges.push('<span class="badge badge-feature" title="Kennt sein Gegenüber (Benutzer-Persona)"><i class="fa fa-user"></i> Du</span>');
if (agent.useMemory) badges.push('<span class="badge badge-feature" title="Zugriff auf Erinnerungen (Memory-Tools)"><i class="fa fa-database"></i> Erinnerungen</span>');
if (agent.webSearchVersion) {
const wsYear = agent.webSearchVersion.includes('2025') ? 'Version 2025' : 'v2026';
badges.push(`<span class="badge badge-feature" title="Web-Suche - ${wsYear}"><i class="fa fa-search"></i> Web-Suche</span>`);
}
if (skills) {
for (const skill of skills) {
badges.push(`<span class="badge badge-skill" title="${esc(skill.description || skill.slug)}"><i class="fa fa-puzzle-piece"></i> ${esc(skill.name)}</span>`);
}
}
card.innerHTML = `
<div class="card-image">${imageHtml}</div>
<div class="card-body">
<div class="card-title-row">
<span class="card-title">${esc(agent.name)}</span>
<span class="badge">${prettyModelName(agent.model)}</span>
</div>
${badges.length ? `<div class="card-badges">${badges.join('')}</div>` : ''}
</div>
`;
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 = '<i class="fa ' + icon + '"></i> ' + (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(/(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)/g, function(m, latex) {
mathBlocks.push({ latex: latex, displayMode: false });
return '%%MATH' + (mathBlocks.length - 1) + '%%';
});
// 2) Markdown parsen
var html = marked.parse(src);
// 3) Code-Blöcke schützen (damit Emojis/Krea dort nicht greifen)
var codeBlocks = [];
html = html.replace(/<pre[\s>][\s\S]*?<\/pre>|<code[\s>][\s\S]*?<\/code>/gi, function(m) {
codeBlocks.push(m);
return '%%CODE' + (codeBlocks.length - 1) + '%%';
});
// 4) [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/skills/krea-api/output/' + encodeURIComponent(fn) + '/thumb';
var fullUrl = '/api/skills/krea-api/output/' + encodeURIComponent(fn);
return '<span class="krea-ref" data-filename="' + safeFn + '" data-fullurl="' + fullUrl + '" title="Klicken für Vollbild">'
+ '<img class="krea-thumb" src="' + thumbUrl + '" alt="' + safeFn + '" loading="lazy">'
+ '</span>';
});
// 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 '<code class="katex-error">' + esc(block.latex) + '</code>';
}
});
return html;
}
function wrapKreaGalleries(container) {
// Findet <p>-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 <p> nur krea-refs enthält (ggf. mit Whitespace)
if (el.tagName === 'P' && el.querySelector('.krea-ref') && el.textContent.trim() === '') {
// Sammle aufeinanderfolgende krea-only <p>-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 = '<i class="fa fa-trash-o"></i>';
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 ? '<i class="fa fa-star"></i>' : '<i class="fa fa-star-o"></i>';
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 ? '<i class="fa fa-star"></i>' : '<i class="fa fa-star-o"></i>';
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 = '<i class="fa fa-share-alt"></i>';
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 = `
<button class="share-menu-item" data-mode="single">
<i class="fa fa-comment-o"></i> Einzelne Nachricht
</button>
<button class="share-menu-item" data-mode="context">
<i class="fa fa-comments-o"></i> Mit Kontext (Thread)
</button>
`;
// 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 = '<i class="fa fa-reply"></i>';
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 = '<i class="fa fa-reply"></i> ' + 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 '<img class="inline-user-image" src="' + esc(img.dataUrl) + '" alt="' + esc(name) + '" title="' + esc(name) + '">';
}
// Sonst: Platzhalter mit data-Attribut für späteres Laden
return '<span class="inline-image-placeholder" data-filename="' + esc(name) + '">'
+ '<i class="fa fa-image"></i> ' + esc(name) + '</span>';
});
// 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 '<span class="file-ref-chip" data-filepath="' + esc(filePath) + '" title="' + esc(ref) + '" style="cursor:pointer">'
+ '<i class="fa fa-file-text-o"></i> ' + esc(fileName + lineInfo) + '</span>';
});
// 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 = '<i class="fa fa-times-circle"></i> ' + 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 = '<i class="fa fa-exclamation-triangle"></i> ' + esc((text || '').trim());
chatEl().appendChild(div);
scrollDown();
}
// ── Streaming Bubble ──
let _streamDiv = null;
let _streamText = '';
function startStreamBubble() {
_streamDiv = document.createElement('div');
_streamDiv.className = 'msg assistant markdown streamed';
_streamDiv.innerHTML = '<p><span class="cursor"></span></p>';
_streamText = '';
chatEl().appendChild(_streamDiv);
scrollDown();
}
function appendToStream(text) {
if (!_streamDiv) startStreamBubble();
_streamText += text;
_streamDiv.innerHTML = renderMarkdown(_streamText);
// Cursor ins letzte Inline/Text-Element einfügen, damit er nicht auf eine neue Zeile rutscht
const cursor = document.createElement('span');
cursor.className = 'cursor';
const lastBlock = _streamDiv.lastElementChild;
if (lastBlock) {
lastBlock.appendChild(cursor);
} else {
_streamDiv.appendChild(cursor);
}
scrollDown();
}
function endStreamBubble() {
if (_streamDiv) {
const cursor = _streamDiv.querySelector('.cursor');
if (cursor) cursor.remove();
if (!_streamText.trim()) _streamDiv.remove();
else {
_streamDiv.innerHTML = renderMarkdown(_streamText);
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 anzeigen
return MODIFICATION_TOOLS.includes(toolName);
}
// ── Chain Meta Visibility ──
function isChainMetaVisible(chainData) {
const mode = loadSettings().chainMetaDisplay;
if (mode === 'always') return true;
if (mode === 'never') return false;
// 'sometimes' – nur bei auffälligen Chains anzeigen
if (!chainData) return false;
const inOut = (chainData.inputTokens || 0) + (chainData.outputTokens || 0);
if (inOut >= 10000) return true;
if ((chainData.iterations || 0) >= 25) return true;
if ((chainData.durationMs || 0) >= 90000) return true;
return false;
}
// ── Tool Boxes ──
function addToolBoxLoading(name) {
const div = document.createElement('div');
div.className = 'tool-box streamed';
div.dataset.toolName = name;
const displayName = TOOL_DISPLAY_NAMES[name] || name;
div.innerHTML = '<span class="tool-name">' + esc(displayName) + '</span> <span class="tool-spinner"><i class="fa fa-hourglass-half"></i></span>';
chatEl().appendChild(div);
scrollDown();
return div;
}
function renderToolBoxContent(div, name, inputJson, result, isError) {
let parsedInput = null;
try { parsedInput = JSON.parse(inputJson); } catch {}
const header = document.createElement('div');
header.className = 'tool-box-header';
const nameSpan = document.createElement('span');
nameSpan.className = 'tool-name';
nameSpan.textContent = 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;
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);
scrollDown();
}
function addToolBoxStatic(name, input, result, isError) {
const div = document.createElement('div');
div.className = 'tool-box';
div.dataset.toolName = name;
renderToolBoxContent(div, name, input, result, isError);
chatEl().appendChild(div);
scrollDown();
}
function appendToolResult(result, isError) {
// Letzten tool-box im Chat finden und Result in die bereits vorhandene .tool-result-Div schreiben
const boxes = chatEl().querySelectorAll('.tool-box');
const lastBox = boxes[boxes.length - 1];
if (!lastBox) return;
// Wenn Diff vorhanden (edit_file/write_file) → kein Result anhängen
if (lastBox.querySelector('.tool-diff')) return;
const 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 = `<i class="fa fa-paperclip"></i> ${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 = '<i class="fa fa-paperclip"></i> 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 = `<i class="fa fa-file-text-o"></i> ${esc(att.fileName || 'Dokument')}`;
container.appendChild(link);
}
}
indicator.innerHTML = `<i class="fa fa-paperclip"></i> ${attachments.length} Datei${attachments.length > 1 ? 'en' : ''}`;
indicator.parentElement.appendChild(container);
} catch (e) {
indicator.innerHTML = '<i class="fa fa-paperclip"></i> 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 = '<i class="fa ' + esc(block.icon || 'fa-cog') + '"></i> ' + 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 = '<i class="fa fa-times"></i>';
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 = '<div class="sp-loading">Laden...</div>';
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);
body.innerHTML = '';
_renderSystemPromptBlocks(body, blocks);
} catch (e) {
body.innerHTML = '<div class="sp-loading" style="color:var(--dangerous)">Fehler beim Laden des System-Prompts</div>';
}
}
// ── 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 = '<i class="fa fa-times"></i>';
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 = `<code>${esc(command)}</code> <span class="cmd-exit-code${isError ? ' cmd-exit-error' : ''}">${esc(statusText)}</span>`;
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 = '<i class="fa fa-arrow-up"></i> ' + 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 = '<i class="fa fa-archive"></i> 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 = '<i class="fa fa-times"></i>';
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 = '<div class="sp-loading" style="color:var(--dangerous)">Bild konnte nicht geladen werden</div>';
});
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);
}
});