Backend: - web_search_tool_result Sources extrahieren (url, title) - Als 'web_search_result' Message-Typ in DB persistieren - Sources im web_search_end SSE-Event mitsenden - Alle 3 Chat-Pfade: StreamChat, RunAgentChat, ChatNonStreaming Frontend: - renderToolBoxContent: Sources-Sektion mit klickbaren Domain-Links - finishToolBox/addToolBoxStatic: sources-Parameter durchgereicht - History: web_search_result Message-Typ in allen 3 Render-Pfaden - web_search immer sichtbar (auch bei 'modifications' Filter) CSS: - .web-search-sources: Flex-Wrap Container - .web-search-source: Domain-Pill mit Hover-Effekt, Title-Ellipsis
1449 lines
50 KiB
JavaScript
1449 lines
50 KiB
JavaScript
// ── 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 ? `<img src="${session.image}" alt=""${transformStyle}>` : '';
|
||
|
||
// Badges
|
||
const badges = [];
|
||
if (agent) badges.push(`<span class="badge">${esc(agent.name)}</span>`);
|
||
if (session.reviewAgentId) badges.push(`<span class="badge badge-feature" title="Review-Agent"><i class="fa fa-check-circle-o"></i></span>`);
|
||
if (session.finalizerAgentId) badges.push(`<span class="badge badge-feature" title="Finalizer-Agent"><i class="fa fa-flag-checkered"></i></span>`);
|
||
if (session.context) badges.push(`<span class="badge badge-feature" title="${esc(session.context)}"><i class="fa fa-file-text-o"></i></span>`);
|
||
if (session.telegramConfigured) badges.push(`<span class="badge ${session.telegramLinked ? 'badge-feature' : 'badge-off'}" title="Telegram ${session.telegramLinked ? 'verbunden' : 'konfiguriert'}"><i class="fa fa-paper-plane"></i></span>`);
|
||
|
||
// Meta
|
||
const metaParts = [];
|
||
if (session.messageCount) metaParts.push(`<span class="card-meta-item"><i class="fa fa-comment"></i> ${esc(session.messageCount + ' Nachrichten')}</span>`);
|
||
if (session.archiveMessageCount) metaParts.push(`<span class="card-meta-item"><i class="fa fa-archive"></i> ${esc(session.archiveMessageCount + ' Nachrichten archiviert')}</span>`);
|
||
if (session.workspace) metaParts.push(`<span class="card-meta-item card-workspace" title="${esc(session.workspace)}"><i class="fa fa-folder"></i> ${esc(session.workspace)}</span>`);
|
||
|
||
card.innerHTML = `
|
||
<div class="card-image">${imageHtml}</div>
|
||
<div class="card-body">
|
||
<div class="card-title-row">
|
||
<span class="card-title">${esc(session.name || 'Unbenannt')}</span>
|
||
${badges.join('')}
|
||
</div>
|
||
${metaParts.length ? `<div class="card-meta">${metaParts.join('')}</div>` : ''}
|
||
</div>
|
||
`;
|
||
|
||
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 ? `<img src="${agent.avatar}" alt=""${agentTransformStyle}>` : '';
|
||
|
||
// 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) + '%%';
|
||
});
|
||
|
||
// 4a) [Review: AgentName] prefix → Review-Header
|
||
html = html.replace(/\[Review:\s*([^\]]+)\]\s*/g, function(match, agentName) {
|
||
return '<span class="review-inline-header"><i class="fa fa-check-circle-o"></i> <strong>' + esc(agentName.trim()) + '</strong></span> ';
|
||
});
|
||
|
||
// 4a2) [Finalizer: AgentName] prefix → Finalizer-Header
|
||
html = html.replace(/\[Finalizer:\s*([^\]]+)\]\s*/g, function(match, agentName) {
|
||
return '<span class="review-inline-header finalizer-inline-header"><i class="fa fa-flag-checkered"></i> <strong>' + esc(agentName.trim()) + '</strong></span> ';
|
||
});
|
||
|
||
// 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 '<div class="review-inline-feedback"><i class="fa fa-repeat"></i> <strong>Feedback von ' + esc(agentName.trim()) + '</strong> <span class="review-round">Runde ' + esc(round) + '/' + esc(maxRounds) + '</span></div>';
|
||
});
|
||
|
||
// 4c) [APPROVED] ausblenden
|
||
html = html.replace(/\[APPROVED\]/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 '<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();
|
||
}
|
||
|
||
// ── 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');
|
||
const header = document.createElement('div');
|
||
header.className = 'review-header';
|
||
if (agentType === 'finalizer') {
|
||
header.innerHTML = '<i class="fa fa-flag-checkered"></i> <strong>' + esc(data.agentName || 'Finalizer-Agent') + '</strong>';
|
||
} else {
|
||
header.innerHTML = '<i class="fa fa-check-circle-o"></i> <strong>' + esc(data.agentName || 'Review-Agent') + '</strong> <span class="review-round">Runde ' + data.round + '/' + data.maxRounds + '</span>';
|
||
}
|
||
_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 = '<i class="fa fa-wrench"></i> <span class="tool-name">' + esc(data.toolName) + '</span>';
|
||
_reviewBubble.appendChild(toolDiv);
|
||
scrollDown();
|
||
}
|
||
|
||
function finalizeReviewBubble(type, data, agentType) {
|
||
if (!_reviewBubble) return;
|
||
const status = document.createElement('div');
|
||
status.className = 'review-status';
|
||
if (agentType === 'finalizer' || type === 'done') {
|
||
status.className += ' review-approved';
|
||
status.innerHTML = '<i class="fa fa-flag-checkered"></i> Abgeschlossen';
|
||
} else if (type === 'approved') {
|
||
status.className += ' review-approved';
|
||
status.innerHTML = '<i class="fa fa-check"></i> Approved';
|
||
} else {
|
||
status.className += ' review-rejected';
|
||
status.innerHTML = '<i class="fa fa-repeat"></i> Feedback – Worker korrigiert...';
|
||
}
|
||
_reviewBubble.appendChild(status);
|
||
_reviewBubble = null;
|
||
_reviewTextEl = null;
|
||
_reviewText = '';
|
||
scrollDown();
|
||
}
|
||
|
||
// ── Streaming Bubble ──
|
||
|
||
let _streamDiv = null;
|
||
let _streamText = '';
|
||
let _lastStreamDiv = null;
|
||
let _lastStreamText = '';
|
||
|
||
function startStreamBubble() {
|
||
_streamDiv = document.createElement('div');
|
||
_streamDiv.className = 'msg assistant markdown streamed';
|
||
_streamDiv.innerHTML = '<p><span class="cursor"></span></p>';
|
||
_streamText = '';
|
||
_lastStreamDiv = null;
|
||
_lastStreamText = '';
|
||
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);
|
||
}
|
||
}
|
||
_lastStreamDiv = _streamDiv;
|
||
_lastStreamText = _streamText;
|
||
_streamDiv = null;
|
||
_streamText = '';
|
||
}
|
||
|
||
function resumeStreamBubble() {
|
||
// Letzte finalisierte Bubble wiederverwenden (z.B. nach Web-Search)
|
||
if (_lastStreamDiv && _lastStreamDiv.parentNode) {
|
||
_streamDiv = _lastStreamDiv;
|
||
_streamText = _lastStreamText;
|
||
} else {
|
||
startStreamBubble();
|
||
}
|
||
_lastStreamDiv = null;
|
||
_lastStreamText = '';
|
||
}
|
||
|
||
// ── 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 = '<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, 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 = '<i class="fa fa-external-link"></i> <span class="source-domain">' + esc(domain) + '</span>' +
|
||
(s.title ? '<span class="source-title">' + esc(s.title) + '</span>' : '');
|
||
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 = `<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);
|
||
}
|
||
});
|