- emoji.js: Emoji-Replacement-Engine mit umfassendem Unicode 15.0 Regex - String-basiert (replaceEmojisWithFluent) in renderMarkdown() - DOM-basiert (parseFluentEmojis) als Post-Processing via TreeWalker - CDN: @lobehub/fluent-emoji-3d@1.1.0 via jsdelivr (WebP/3D) - Intelligenter Fallback: fe0f-Varianten, dann Original-Unicode - Fail-Cache verhindert wiederholte Requests für fehlende Emojis - User-Nachrichten werden jetzt als Markdown gerendert (renderMarkdown) - [Datei: ...] Refs: Platzhalter-Mechanismus vor Markdown-Parsing - Reply-Quotes bleiben durch Wrapper-Div erhalten - parseFluentEmojis an allen Render-Stellen integriert: addAssistantBubble, endStreamBubble, _renderUserTextContent, _renderSystemPromptBlocks, history._renderMessagesIntoFragment, history.loadLatestArchive - CSS: .fluent-emoji Klasse (1.2em, inline, scale 1.25) - CSS: white-space:pre-wrap nur noch für .msg:not(.markdown)
875 lines
28 KiB
JavaScript
875 lines
28 KiB
JavaScript
// ── DOM Helpers ──
|
||
|
||
const $ = (sel) => document.querySelector(sel);
|
||
const chatEl = () => $('#chat');
|
||
const metaEl = () => $('#meta');
|
||
|
||
function prettyModelName(model) {
|
||
if (!model) return '?';
|
||
var m = model.match(/claude-(\w+)-(\d+)(?:-(\d+))?/);
|
||
if (m) {
|
||
var family = m[1].charAt(0).toUpperCase() + m[1].slice(1);
|
||
var major = m[2];
|
||
var minor = m[3];
|
||
if (minor && minor.length <= 2) return family + ' ' + major + '.' + minor;
|
||
return family + ' ' + major;
|
||
}
|
||
if (model == 'sonnet') return 'Sonnet 4.6'
|
||
if (model == 'opus') return 'Opus 4.6'
|
||
if (model == 'haiku') return 'Haiku 4.5'
|
||
return 'Unbekannt (' + model + ')';
|
||
}
|
||
|
||
// ── Markdown ──
|
||
|
||
marked.setOptions({ breaks: true, gfm: true });
|
||
|
||
function renderMarkdown(text) {
|
||
if (!text) return '';
|
||
var html = marked.parse(text.trim());
|
||
// Code-Blöcke (<pre>...<code>...</code>...</pre> und inline <code>...</code>) schützen
|
||
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) + '%%';
|
||
});
|
||
// [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>';
|
||
});
|
||
// Unicode-Emojis durch Fluent Emoji 3D ersetzen (außerhalb von Code-Blöcken)
|
||
html = replaceEmojisWithFluent(html);
|
||
// Code-Blöcke wiederherstellen
|
||
html = html.replace(/%%CODE(\d+)%%/g, function(m, idx) {
|
||
return codeBlocks[parseInt(idx)];
|
||
});
|
||
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 || '');
|
||
}
|
||
|
||
// ── 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);
|
||
}
|
||
});
|
||
const replyBtn = div.querySelector('.msg-reply-btn');
|
||
if (replyBtn) {
|
||
replyBtn.after(btn);
|
||
} else {
|
||
div.prepend(btn);
|
||
}
|
||
}
|
||
|
||
// ── Reply Button ──
|
||
|
||
function _addReplyButton(div) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'msg-reply-btn icon-btn';
|
||
btn.innerHTML = '<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) {
|
||
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);
|
||
}
|
||
return;
|
||
}
|
||
|
||
_renderUserTextContent(container, raw);
|
||
}
|
||
|
||
function _renderUserTextContent(container, raw) {
|
||
// [Datei: path] oder [Datei: path:1-10] durch Platzhalter ersetzen (vor Markdown-Parsing)
|
||
const pattern = /\[Datei: ([^\]]+)\]/g;
|
||
const fileRefs = [];
|
||
const mdText = raw.replace(pattern, function(m, ref) {
|
||
fileRefs.push(ref);
|
||
return '%%FILEREF' + (fileRefs.length - 1) + '%%';
|
||
});
|
||
|
||
// Markdown rendern
|
||
let html = renderMarkdown(mdText);
|
||
|
||
// 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 });
|
||
});
|
||
});
|
||
|
||
// Fluent Emoji DOM-Parsing
|
||
parseFluentEmojis(wrap);
|
||
}
|
||
|
||
function addUserBubble(text, streamed = false, timestamp = '', messageId = null, isMarked = false) {
|
||
const div = document.createElement('div');
|
||
div.className = 'msg user markdown' + (isMarked ? ' marked' : '');
|
||
div.dataset.role = 'user';
|
||
div.dataset.timestamp = timestamp || new Date().toISOString();
|
||
if (messageId) div.dataset.messageId = messageId;
|
||
if (streamed) div.classList.add('streamed');
|
||
renderUserContent(div, text);
|
||
_addReplyButton(div);
|
||
_addMarkButton(div, messageId, isMarked);
|
||
chatEl().appendChild(div);
|
||
scrollDown();
|
||
}
|
||
|
||
function addAssistantBubble(text, timestamp = '', messageId = null, isMarked = false) {
|
||
const div = document.createElement('div');
|
||
div.className = 'msg assistant markdown' + (isMarked ? ' marked' : '');
|
||
div.dataset.role = 'assistant';
|
||
div.dataset.timestamp = timestamp || new Date().toISOString();
|
||
if (messageId) div.dataset.messageId = messageId;
|
||
div.innerHTML = renderMarkdown(text);
|
||
parseFluentEmojis(div);
|
||
wrapKreaGalleries(div);
|
||
_addReplyButton(div);
|
||
_addMarkButton(div, messageId, isMarked);
|
||
chatEl().appendChild(div);
|
||
scrollDown();
|
||
return div;
|
||
}
|
||
|
||
function addErrorBubble(text, streamed = false) {
|
||
const div = document.createElement('div');
|
||
div.className = 'msg system-info';
|
||
if (streamed) div.classList.add('streamed');
|
||
div.style.color = 'var(--dangerous)';
|
||
div.innerHTML = '<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';
|
||
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 {
|
||
// Standard: path/command als input anzeigen
|
||
let inputLabel = '';
|
||
if (parsedInput) {
|
||
inputLabel = parsedInput.path || parsedInput.command || JSON.stringify(parsedInput).substring(0, 120);
|
||
} else {
|
||
inputLabel = truncate(inputJson, 120);
|
||
}
|
||
if (inputLabel) {
|
||
const inputSpan = document.createElement('span');
|
||
inputSpan.className = 'tool-input';
|
||
inputSpan.textContent = inputLabel;
|
||
header.appendChild(inputSpan);
|
||
}
|
||
|
||
const resultDiv = document.createElement('div');
|
||
resultDiv.className = 'tool-result' + (isError ? ' tool-error' : '');
|
||
resultDiv.textContent = result || '';
|
||
resultDiv.hidden = true;
|
||
div.appendChild(resultDiv);
|
||
foldTarget = resultDiv;
|
||
}
|
||
|
||
if (foldTarget) {
|
||
header.style.cursor = 'pointer';
|
||
header.addEventListener('click', () => {
|
||
foldTarget.hidden = !foldTarget.hidden;
|
||
});
|
||
}
|
||
}
|
||
|
||
function buildEditDiff(oldText, newText) {
|
||
const oldLines = oldText.split('\n');
|
||
const newLines = newText.split('\n');
|
||
|
||
const container = document.createElement('div');
|
||
container.className = 'tool-diff';
|
||
|
||
// Entfernte Zeilen (-)
|
||
for (const line of oldLines) {
|
||
const row = document.createElement('div');
|
||
row.className = 'diff-line diff-removed';
|
||
const marker = document.createElement('span');
|
||
marker.className = 'diff-marker';
|
||
marker.textContent = '-';
|
||
const content = document.createElement('span');
|
||
content.className = 'diff-content';
|
||
content.textContent = line;
|
||
row.appendChild(marker);
|
||
row.appendChild(content);
|
||
container.appendChild(row);
|
||
}
|
||
|
||
// Hinzugefügte Zeilen (+)
|
||
for (const line of newLines) {
|
||
const row = document.createElement('div');
|
||
row.className = 'diff-line diff-added';
|
||
const marker = document.createElement('span');
|
||
marker.className = 'diff-marker';
|
||
marker.textContent = '+';
|
||
const content = document.createElement('span');
|
||
content.className = 'diff-content';
|
||
content.textContent = line;
|
||
row.appendChild(marker);
|
||
row.appendChild(content);
|
||
container.appendChild(row);
|
||
}
|
||
|
||
return container;
|
||
}
|
||
|
||
function buildWritePreview(content) {
|
||
const lines = content.split('\n');
|
||
|
||
const container = document.createElement('div');
|
||
container.className = 'tool-diff';
|
||
|
||
for (const line of lines) {
|
||
const row = document.createElement('div');
|
||
row.className = 'diff-line diff-added';
|
||
const marker = document.createElement('span');
|
||
marker.className = 'diff-marker';
|
||
marker.textContent = '+';
|
||
const contentSpan = document.createElement('span');
|
||
contentSpan.className = 'diff-content';
|
||
contentSpan.textContent = line;
|
||
row.appendChild(marker);
|
||
row.appendChild(contentSpan);
|
||
container.appendChild(row);
|
||
}
|
||
|
||
return container;
|
||
}
|
||
|
||
function finishToolBox(div, data) {
|
||
if (!div) return;
|
||
renderToolBoxContent(div, data.name, data.input, data.result, data.isError);
|
||
scrollDown();
|
||
}
|
||
|
||
function addToolBoxStatic(name, input, result, isError) {
|
||
const div = document.createElement('div');
|
||
div.className = 'tool-box';
|
||
renderToolBoxContent(div, name, input, result, isError);
|
||
chatEl().appendChild(div);
|
||
scrollDown();
|
||
}
|
||
|
||
function appendToolResult(result, isError) {
|
||
// Letzten tool-box im Chat finden und Result in die bereits vorhandene .tool-result-Div schreiben
|
||
const boxes = chatEl().querySelectorAll('.tool-box');
|
||
const lastBox = boxes[boxes.length - 1];
|
||
if (!lastBox) return;
|
||
|
||
// Wenn Diff vorhanden (edit_file/write_file) → kein Result anhängen
|
||
if (lastBox.querySelector('.tool-diff')) return;
|
||
|
||
const existing = lastBox.querySelector('.tool-result');
|
||
if (existing) {
|
||
existing.textContent = result || '';
|
||
if (isError) existing.classList.add('tool-error');
|
||
} else {
|
||
const resultDiv = document.createElement('div');
|
||
resultDiv.className = 'tool-result' + (isError ? ' tool-error' : '');
|
||
resultDiv.textContent = result || '';
|
||
resultDiv.hidden = true;
|
||
lastBox.appendChild(resultDiv);
|
||
}
|
||
}
|
||
|
||
// ── Streamed Elements ──
|
||
|
||
function removeStreamedElements() {
|
||
chatEl().querySelectorAll('.streamed').forEach(el => el.remove());
|
||
}
|
||
|
||
// ── Attachment Indicator ──
|
||
|
||
function addAttachmentIndicator(messageId, count, namesJson) {
|
||
const chat = chatEl();
|
||
const lastMsg = chat.lastElementChild;
|
||
if (!lastMsg) return;
|
||
|
||
let names = [];
|
||
try { if (namesJson) names = JSON.parse(namesJson); } catch {}
|
||
|
||
const indicator = document.createElement('div');
|
||
indicator.className = 'attachment-indicator';
|
||
|
||
const nameList = names.filter(n => n).join(', ');
|
||
indicator.innerHTML = `<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);
|
||
}
|
||
});
|