Files
gott/wwwroot/js/ui.js
T

614 lines
19 KiB
JavaScript

// ── DOM Helpers ──
const $ = (sel) => document.querySelector(sel);
const chatEl = () => $('#chat');
const metaEl = () => $('#meta');
// ── Markdown ──
marked.setOptions({ breaks: true, gfm: true });
function renderMarkdown(text) {
if (!text) return '';
return marked.parse(text.trim());
}
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 || '');
}
// ── 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] erkennen
const pattern = /\[Datei: ([^\]]+)\]/g;
let last = 0;
let match;
const frag = document.createDocumentFragment();
while ((match = pattern.exec(raw)) !== null) {
// Text vor dem Tag
if (match.index > last) {
frag.appendChild(document.createTextNode(raw.slice(last, match.index)));
}
const ref = match[1]; // z.B. "src/foo.cs:10-25"
// Pfad ohne Zeilenangabe extrahieren
const filePath = ref.replace(/:\d+(-\d+)?$/, '');
const chip = document.createElement('span');
chip.className = 'file-ref-chip';
// Schöne Darstellung: Icon + Kurzpfad
const fileName = filePath.includes('/') ? filePath.substring(filePath.lastIndexOf('/') + 1) : filePath;
const lineInfo = ref.replace(filePath, '').trim(); // z.B. ":10-25"
chip.innerHTML = '<i class="fa fa-file-text-o"></i> ' + esc(fileName + lineInfo);
chip.title = ref;
chip.style.cursor = 'pointer';
chip.addEventListener('click', () => {
openFileExplorer({ sessionId: getCurrentSessionId(), initialFile: filePath });
});
frag.appendChild(chip);
last = match.index + match[0].length;
}
// Resttext
if (last < raw.length) {
frag.appendChild(document.createTextNode(raw.slice(last)));
}
container.appendChild(frag);
}
function addUserBubble(text, streamed = false, timestamp = '') {
const div = document.createElement('div');
div.className = 'msg user';
div.dataset.role = 'user';
div.dataset.timestamp = timestamp || new Date().toISOString();
if (streamed) div.classList.add('streamed');
renderUserContent(div, text);
_addReplyButton(div);
chatEl().appendChild(div);
scrollDown();
}
function addAssistantBubble(text, timestamp = '') {
const div = document.createElement('div');
div.className = 'msg assistant markdown';
div.dataset.role = 'assistant';
div.dataset.timestamp = timestamp || new Date().toISOString();
div.innerHTML = renderMarkdown(text);
_addReplyButton(div);
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);
}
_streamDiv = null;
_streamText = '';
}
// ── Tool Boxes ──
function addToolBoxLoading(name) {
const div = document.createElement('div');
div.className = 'tool-box streamed';
div.innerHTML = '<span class="tool-name">' + esc(name) + '</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 = 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 ──
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 prompt = await apiGetSystemPrompt(sessionId);
body.innerHTML = renderMarkdown(prompt);
} 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;
}
// ── Archive Button ──
function showArchiveLoadButton() {
const existing = chatEl().querySelector('.archive-load-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
chatEl().prepend(btn);
}
function removeArchiveLoadButton() {
const btn = chatEl().querySelector('.archive-load-btn');
if (btn) btn.remove();
}