548 lines
17 KiB
JavaScript
548 lines
17 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 || '');
|
|
}
|
|
|
|
// ── Message Bubbles ──
|
|
|
|
function renderUserContent(container, text) {
|
|
const raw = (text || '').trim();
|
|
// [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) {
|
|
const div = document.createElement('div');
|
|
div.className = 'msg user';
|
|
if (streamed) div.classList.add('streamed');
|
|
renderUserContent(div, text);
|
|
chatEl().appendChild(div);
|
|
scrollDown();
|
|
}
|
|
|
|
function addAssistantBubble(text) {
|
|
const div = document.createElement('div');
|
|
div.className = 'msg assistant markdown';
|
|
div.innerHTML = renderMarkdown(text);
|
|
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 = '<span class="cursor"></span>';
|
|
_streamText = '';
|
|
chatEl().appendChild(_streamDiv);
|
|
scrollDown();
|
|
}
|
|
|
|
function appendToStream(text) {
|
|
if (!_streamDiv) startStreamBubble();
|
|
_streamText += text;
|
|
_streamDiv.innerHTML = renderMarkdown(_streamText);
|
|
const cursor = document.createElement('span');
|
|
cursor.className = 'cursor';
|
|
_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 ? truncate(result, 300) : '';
|
|
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 = truncate(result, 300);
|
|
if (isError) existing.classList.add('tool-error');
|
|
} else {
|
|
const resultDiv = document.createElement('div');
|
|
resultDiv.className = 'tool-result' + (isError ? ' tool-error' : '');
|
|
resultDiv.textContent = truncate(result, 300);
|
|
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();
|
|
}
|