refactor: ui.js, chat.js, history.js in 13 modulare Dateien aufteilen
Die monolithische ui.js (1534 Zeilen) zusammen mit chat.js (612) und history.js (626) in fokussierte Module aufgeteilt: Neue Dateien aus ui.js: - ui-helpers.js (126) - DOM-Utilities, $, esc, truncate, scrollDown, Toast - markdown.js (143) - renderMarkdown, wrapKreaGalleries, LaTeX/Code-Schutz - cards.js (90) - buildSessionCard, buildAgentCard - msg-actions.js (208) - Delete, Mark, Share, Reply-Button, Timestamp - tool-ui.js (332) - Tool-Boxes, Diffs, Todo-Liste, Visibility - modals.js (190) - System-Prompt, Command-Output, Krea-Image Modals - stream-ui.js (124) - Stream-Bubble, Review-Streaming - bubbles.js (312) - User/Assistant/Error/Warning/Choice/Review Bubbles Neue Dateien aus chat.js: - reply.js (54) - Reply/Quote-Handling mit pendingReply State - files.js (110) - File-Upload, Workspace-Refs mit pendingFiles/Refs State Neue Datei aus history.js: - archive.js (173) - Archiv-Loading mit loadedArchiveCount State Verbleibend: - chat.js (448) - Stream-Events, sendMessage, handleChoiceClick, Plan-Mode - history.js (464) - History laden, Polling, Message-Rendering Kein Code verändert, nur verschoben. Alle 94 Funktionen verifiziert. Globale Variablen in die Module verschoben, wo sie primär genutzt werden.
This commit is contained in:
1 parent
a82d8cfb05
commit
0dcc05ebf9
15 files changed
+1885
-1873
No files matched your search
+11
-1
@@ -555,12 +555,22 @@
|
||||
<script src="js/tooltip.js"></script>
|
||||
<script src="js/emoji.js"></script>
|
||||
<script src="js/emoji.userdata.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/ui-helpers.js"></script>
|
||||
<script src="js/markdown.js"></script>
|
||||
<script src="js/cards.js"></script>
|
||||
<script src="js/msg-actions.js"></script>
|
||||
<script src="js/tool-ui.js"></script>
|
||||
<script src="js/modals.js"></script>
|
||||
<script src="js/stream-ui.js"></script>
|
||||
<script src="js/bubbles.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/session.js"></script>
|
||||
<script src="js/reply.js"></script>
|
||||
<script src="js/files.js"></script>
|
||||
<script src="js/file-explorer.js"></script>
|
||||
<script src="js/chat.js"></script>
|
||||
<script src="js/history.js"></script>
|
||||
<script src="js/archive.js"></script>
|
||||
<script src="js/router.js"></script>
|
||||
<script src="js/commands-ui.js"></script>
|
||||
<script src="js/avatar-editor.js"></script>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// ── Archive Loading ──
|
||||
|
||||
let loadedArchiveCount = 0;
|
||||
|
||||
function getOrCreateArchiveContainer() {
|
||||
let container = chatEl().querySelector('.archive-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'archive-container';
|
||||
chatEl().prepend(container);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
function getOrCreateArchiveDivider() {
|
||||
let divider = chatEl().querySelector('.archive-divider');
|
||||
if (!divider) {
|
||||
divider = document.createElement('div');
|
||||
divider.className = 'msg system-info archive-divider';
|
||||
divider.textContent = '── Aktuelle Konversation ──';
|
||||
// Nach dem Container, vor den aktiven Messages
|
||||
const container = getOrCreateArchiveContainer();
|
||||
container.after(divider);
|
||||
}
|
||||
return divider;
|
||||
}
|
||||
|
||||
async function loadLatestArchive() {
|
||||
try {
|
||||
const archives = await apiListArchives(getCurrentSessionId());
|
||||
if (archives.length <= loadedArchiveCount) return;
|
||||
|
||||
const latest = archives[loadedArchiveCount];
|
||||
loadedArchiveCount++;
|
||||
|
||||
removeArchiveLoadButton();
|
||||
|
||||
const data = await apiGetArchive(latest.archiveId);
|
||||
const container = getOrCreateArchiveContainer();
|
||||
getOrCreateArchiveDivider();
|
||||
|
||||
// Archiv-Block zusammenbauen
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'msg system-info';
|
||||
sep.innerHTML = `<i class="fa fa-archive"></i> Archiv vom ${new Date(latest.createdAt).toLocaleString('de-AT')} (${latest.chainCount} Chains, ${latest.messageCount} Messages)`;
|
||||
fragment.appendChild(sep);
|
||||
|
||||
const archiveImageIds = [];
|
||||
for (const entry of data) {
|
||||
const mergedMsgs = _mergeConsecutiveAssistantTexts(entry.messages);
|
||||
for (const msg of mergedMsgs) {
|
||||
const div = document.createElement('div');
|
||||
switch (msg.type) {
|
||||
case 'text': {
|
||||
const isReviewMsg = msg.content && (msg.content.startsWith('[Review:') || msg.content.startsWith('[Review-Feedback') || msg.content.startsWith('[Finalizer:') || msg.content.startsWith('[Preparer:'));
|
||||
if (msg.role === 'user' && !isReviewMsg) {
|
||||
div.className = 'msg user markdown archived' + (msg.isMarked ? ' marked' : '');
|
||||
if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
|
||||
div.dataset.role = 'user';
|
||||
div.dataset.timestamp = msg.createdAt || '';
|
||||
if (msg.id) div.dataset.messageId = msg.id;
|
||||
renderUserContent(div, msg.content);
|
||||
_addTimestamp(div, msg.createdAt);
|
||||
_addReplyButton(div);
|
||||
_addDeleteButton(div, msg.id, msg.deletedOn);
|
||||
_addMarkButton(div, msg.id, msg.isMarked);
|
||||
_addShareButton(div, msg.id);
|
||||
} else if (isReviewMsg) {
|
||||
div.className = 'msg review-bubble markdown archived' + (msg.isMarked ? ' marked' : '');
|
||||
if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
|
||||
div.dataset.role = msg.role;
|
||||
div.dataset.timestamp = msg.createdAt || '';
|
||||
if (msg.id) div.dataset.messageId = msg.id;
|
||||
div.innerHTML = renderMarkdown(msg.content);
|
||||
parseFluentEmojis(div);
|
||||
_addTimestamp(div, msg.createdAt);
|
||||
_addReplyButton(div);
|
||||
_addDeleteButton(div, msg.id, msg.deletedOn);
|
||||
_addMarkButton(div, msg.id, msg.isMarked);
|
||||
_addShareButton(div, msg.id);
|
||||
} else if (msg.role === 'assistant') {
|
||||
div.className = 'msg assistant markdown archived' + (msg.isMarked ? ' marked' : '');
|
||||
if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
|
||||
div.dataset.role = 'assistant';
|
||||
div.dataset.timestamp = msg.createdAt || '';
|
||||
if (msg.id) div.dataset.messageId = msg.id;
|
||||
div.innerHTML = renderMarkdown(msg.content);
|
||||
parseFluentEmojis(div);
|
||||
_addTimestamp(div, msg.createdAt);
|
||||
_addReplyButton(div);
|
||||
_addDeleteButton(div, msg.id, msg.deletedOn);
|
||||
_addMarkButton(div, msg.id, msg.isMarked);
|
||||
_addShareButton(div, msg.id);
|
||||
}
|
||||
if (msg.role === 'user' && msg.attachmentCount > 0) {
|
||||
if (/\[Bild: [^\]]+\]/.test(msg.content)) archiveImageIds.push(msg.id);
|
||||
let names = [];
|
||||
try { if (msg.attachmentNames) names = JSON.parse(msg.attachmentNames); } catch {}
|
||||
const nameList = names.filter(n => n).join(', ');
|
||||
const ind = document.createElement('div');
|
||||
ind.className = 'attachment-indicator';
|
||||
ind.innerHTML = `<i class="fa fa-paperclip"></i> ${msg.attachmentCount} Datei${msg.attachmentCount > 1 ? 'en' : ''}` + (nameList ? `: ${esc(nameList)}` : '');
|
||||
ind.style.cursor = 'pointer';
|
||||
ind.title = 'Klicken zum Anzeigen';
|
||||
ind.addEventListener('click', () => loadAndShowAttachments(msg.id, ind));
|
||||
div.appendChild(ind);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_use':
|
||||
if (!isToolVisible(msg.toolName)) continue;
|
||||
div.className = 'tool-box archived';
|
||||
renderToolBoxContent(div, msg.toolName, msg.content, null, false);
|
||||
break;
|
||||
case 'web_search_result': {
|
||||
if (!isToolVisible('web_search')) continue;
|
||||
var archSrc = [];
|
||||
try { archSrc = JSON.parse(msg.content || '[]'); } catch(e) {}
|
||||
if (archSrc.length === 0) continue; // Leere Suchergebnisse überspringen
|
||||
div.className = 'tool-box archived';
|
||||
div.dataset.toolName = 'web_search';
|
||||
renderToolBoxContent(div, 'web_search', null, null, false, archSrc);
|
||||
break;
|
||||
}
|
||||
case 'tool_result': {
|
||||
// An vorherigen tool-box anhängen
|
||||
const prevBoxes = fragment.querySelectorAll ? fragment.querySelectorAll('.tool-box') : [];
|
||||
const lastToolBox = prevBoxes[prevBoxes.length - 1];
|
||||
if (lastToolBox && !lastToolBox.querySelector('.tool-result')) {
|
||||
const rd = document.createElement('div');
|
||||
rd.className = 'tool-result' + (msg.isError ? ' tool-error' : '');
|
||||
rd.textContent = truncate(msg.content || '', 300);
|
||||
lastToolBox.appendChild(rd);
|
||||
}
|
||||
continue; // kein separates div
|
||||
}
|
||||
case 'error':
|
||||
div.className = 'msg system-info archived';
|
||||
div.style.color = 'var(--dangerous)';
|
||||
div.textContent = (msg.content || '').trim();
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
fragment.appendChild(div);
|
||||
}
|
||||
// Chain-Meta-Badges nach jeder Chain einfügen
|
||||
if (entry.chain && isChainMetaVisible(entry.chain)) {
|
||||
const badges = buildMetaBadges(entry.chain);
|
||||
const metaDiv = document.createElement('div');
|
||||
metaDiv.className = 'chain-meta archived';
|
||||
metaDiv.innerHTML = badges.map(b => '<span class="chain-meta-badge">' + esc(b) + '</span>').join('');
|
||||
fragment.appendChild(metaDiv);
|
||||
}
|
||||
}
|
||||
|
||||
// Älteres Archiv vor dem bisherigen Inhalt einfügen
|
||||
container.prepend(fragment);
|
||||
|
||||
// Bild-Attachments automatisch laden
|
||||
for (const mid of archiveImageIds) _autoLoadImageAttachments(mid);
|
||||
|
||||
// Button ganz oben, falls es noch ältere Archive gibt
|
||||
if (archives.length > loadedArchiveCount) {
|
||||
showArchiveLoadButton();
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Archiv laden fehlgeschlagen:', e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// ── User Content Rendering ──
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Message Bubbles ──
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// ── Choice Bubbles (ask_user_choice) ──
|
||||
|
||||
// Wird live aus dem SSE-Stream aufgerufen
|
||||
function addChoiceBubble(toolCallId, question, choices, streamed = true) {
|
||||
// Guard: choices muss ein Array sein
|
||||
if (!Array.isArray(choices)) choices = [String(choices)];
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg choice-bubble';
|
||||
if (streamed) div.classList.add('streamed');
|
||||
div.dataset.toolCallId = toolCallId;
|
||||
|
||||
const questionDiv = document.createElement('div');
|
||||
questionDiv.className = 'choice-question';
|
||||
questionDiv.innerHTML = renderMarkdown(question);
|
||||
parseFluentEmojis(questionDiv);
|
||||
div.appendChild(questionDiv);
|
||||
|
||||
const buttonsDiv = document.createElement('div');
|
||||
buttonsDiv.className = 'choice-buttons';
|
||||
for (const choice of choices) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'choice-btn';
|
||||
btn.textContent = choice;
|
||||
btn.addEventListener('click', () => handleChoiceClick(toolCallId, choice, div));
|
||||
buttonsDiv.appendChild(btn);
|
||||
}
|
||||
div.appendChild(buttonsDiv);
|
||||
|
||||
chatEl().appendChild(div);
|
||||
scrollDown();
|
||||
return div;
|
||||
}
|
||||
|
||||
// Wird für History-Rendering aufgerufen (bereits beantwortete Choices)
|
||||
function addChoiceBubbleStatic(toolCallId, question, choices, selectedChoice, timestamp, messageId) {
|
||||
// Guard: choices muss ein Array sein
|
||||
if (!Array.isArray(choices)) choices = [String(choices)];
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg choice-bubble choice-answered';
|
||||
div.dataset.toolCallId = toolCallId;
|
||||
if (messageId) div.dataset.messageId = messageId;
|
||||
|
||||
const questionDiv = document.createElement('div');
|
||||
questionDiv.className = 'choice-question';
|
||||
questionDiv.innerHTML = renderMarkdown(question);
|
||||
parseFluentEmojis(questionDiv);
|
||||
div.appendChild(questionDiv);
|
||||
|
||||
const buttonsDiv = document.createElement('div');
|
||||
buttonsDiv.className = 'choice-buttons';
|
||||
for (const choice of choices) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'choice-btn';
|
||||
btn.disabled = true;
|
||||
btn.textContent = choice;
|
||||
if (choice === selectedChoice) {
|
||||
btn.classList.add('choice-selected');
|
||||
} else {
|
||||
btn.classList.add('choice-unselected');
|
||||
}
|
||||
buttonsDiv.appendChild(btn);
|
||||
}
|
||||
div.appendChild(buttonsDiv);
|
||||
|
||||
if (timestamp) _addTimestamp(div, timestamp);
|
||||
chatEl().appendChild(div);
|
||||
scrollDown();
|
||||
return div;
|
||||
}
|
||||
|
||||
// ── Review Bubbles (Static / History) ──
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── 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 = '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// ── Session Card Helper ──
|
||||
// Erzeugt ein .card-Element für eine Session (wiederverwendbar für Dashboard + Picker).
|
||||
|
||||
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).
|
||||
|
||||
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.allowUserChoice) badges.push('<span class="badge badge-feature" title="Rückfragen per Auswahl erlaubt"><i class="fa fa-question-circle"></i> Rückfragen</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;
|
||||
}
|
||||
@@ -175,9 +175,6 @@ function togglePlanMode() {
|
||||
// ── Chat State ──
|
||||
|
||||
let isStreaming = false;
|
||||
let pendingFiles = []; // [{ name, type, base64 }]
|
||||
let pendingRefs = []; // [{ path, from, to }] - Workspace-Dateireferenzen
|
||||
let pendingReply = null; // { index, role, text } - Zitierte Nachricht
|
||||
let _lastDoneChainData = null; // Chain-Daten vom letzten done-Event
|
||||
let _currentToolBox = null; // Aktuell laufende Tool-Box (für Stream-Events)
|
||||
let _chatAbandoned = false; // true wenn User weg-navigiert während Stream läuft
|
||||
@@ -449,164 +446,3 @@ async function handleChoiceClick(toolCallId, choice, bubbleDiv) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── File Handling ──
|
||||
|
||||
function handleFileSelect(file) {
|
||||
if (!file) return;
|
||||
|
||||
const maxSize = 20 * 1024 * 1024; // 20MB
|
||||
if (file.size > maxSize) {
|
||||
alert('Datei zu groß (max. 20MB)');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||
if (!allowed.includes(file.type)) {
|
||||
alert('Nicht unterstützter Dateityp. Erlaubt: JPEG, PNG, GIF, WebP, PDF');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = reader.result.split(',')[1];
|
||||
pendingFiles.push({ name: file.name, type: file.type, base64 });
|
||||
renderFilePreview();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function handleMultipleFiles(files) {
|
||||
for (const file of files) handleFileSelect(file);
|
||||
}
|
||||
|
||||
function renderFilePreview() {
|
||||
const preview = $('#filePreview');
|
||||
if (!preview || pendingFiles.length === 0) {
|
||||
if (preview) { preview.innerHTML = ''; preview.hidden = true; }
|
||||
return;
|
||||
}
|
||||
|
||||
preview.innerHTML = pendingFiles.map((f, i) => {
|
||||
const isImage = f.type.startsWith('image/');
|
||||
return `<div class="file-preview-inner">
|
||||
${isImage ? '<img src="data:' + f.type + ';base64,' + f.base64 + '" class="file-thumb">' : '<span class="file-icon"><i class="fa fa-file-text-o"></i></span>'}
|
||||
<span class="file-name">${esc(f.name)}</span>
|
||||
<button class="file-remove" onclick="removePendingFile(${i})"><i class="fa fa-times"></i></button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
preview.hidden = false;
|
||||
}
|
||||
|
||||
function removePendingFile(index) {
|
||||
pendingFiles.splice(index, 1);
|
||||
renderFilePreview();
|
||||
}
|
||||
|
||||
// ── Workspace Refs ──
|
||||
|
||||
function addPendingRef(ref) {
|
||||
// ref = "path", "path:line" oder "path:from-to"
|
||||
const rangeMatch = ref.match(/^(.+?):(\d+)-(\d+)$/);
|
||||
const lineMatch = !rangeMatch && ref.match(/^(.+?):(\d+)$/);
|
||||
if (rangeMatch) {
|
||||
pendingRefs.push({ path: rangeMatch[1], from: parseInt(rangeMatch[2]), to: parseInt(rangeMatch[3]) });
|
||||
} else if (lineMatch) {
|
||||
pendingRefs.push({ path: lineMatch[1], from: parseInt(lineMatch[2]), to: parseInt(lineMatch[2]) });
|
||||
} else {
|
||||
pendingRefs.push({ path: ref, from: null, to: null });
|
||||
}
|
||||
renderRefPreview();
|
||||
}
|
||||
|
||||
function removePendingRef(index) {
|
||||
pendingRefs.splice(index, 1);
|
||||
renderRefPreview();
|
||||
}
|
||||
|
||||
function clearPendingRefs() {
|
||||
pendingRefs.length = 0;
|
||||
renderRefPreview();
|
||||
}
|
||||
|
||||
function renderRefPreview() {
|
||||
const container = $('#refPreview');
|
||||
if (!container) return;
|
||||
|
||||
if (pendingRefs.length === 0) {
|
||||
container.innerHTML = '';
|
||||
container.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = pendingRefs.map((r, i) => {
|
||||
const label = r.from && r.to && r.from !== r.to ? `${r.path}:${r.from}-${r.to}` : r.from ? `${r.path}:${r.from}` : r.path;
|
||||
return `<div class="ref-preview-item">
|
||||
<span class="ref-icon"><i class="fa fa-folder-open"></i></span>
|
||||
<span class="ref-label">${esc(label)}</span>
|
||||
<button class="file-remove" onclick="removePendingRef(${i})"><i class="fa fa-times"></i></button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
container.hidden = false;
|
||||
}
|
||||
|
||||
function clearPendingFiles() {
|
||||
pendingFiles.length = 0;
|
||||
const preview = $('#filePreview');
|
||||
if (preview) { preview.innerHTML = ''; preview.hidden = true; }
|
||||
const fileInput = $('#fileInput');
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
|
||||
// ── Reply / Quote ──
|
||||
|
||||
function _formatReplyTimestamp(ts) {
|
||||
if (!ts) return 'Jetzt';
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const hh = d.getHours().toString().padStart(2, '0');
|
||||
const mm = d.getMinutes().toString().padStart(2, '0');
|
||||
// Gleiches Datum → nur Uhrzeit, sonst Datum + Uhrzeit
|
||||
if (d.toDateString() === now.toDateString()) {
|
||||
return hh + ':' + mm;
|
||||
}
|
||||
const dd = d.getDate().toString().padStart(2, '0');
|
||||
const mo = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
return dd + '.' + mo + '. ' + hh + ':' + mm;
|
||||
} catch { return 'Jetzt'; }
|
||||
}
|
||||
|
||||
function setReply(reply) {
|
||||
pendingReply = reply;
|
||||
renderReplyPreview();
|
||||
document.getElementById('input')?.focus();
|
||||
}
|
||||
|
||||
function clearReply() {
|
||||
pendingReply = null;
|
||||
renderReplyPreview();
|
||||
}
|
||||
|
||||
function renderReplyPreview() {
|
||||
const container = $('#replyPreview');
|
||||
if (!container) return;
|
||||
|
||||
if (!pendingReply) {
|
||||
container.innerHTML = '';
|
||||
container.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const roleLabel = pendingReply.role === 'user' ? 'User' : 'Assistant';
|
||||
const timeLabel = _formatReplyTimestamp(pendingReply.timestamp);
|
||||
const preview = truncate(pendingReply.text, 150);
|
||||
|
||||
container.innerHTML = `<div class="reply-preview-item">
|
||||
<span class="reply-icon"><i class="fa fa-reply"></i></span>
|
||||
<span class="reply-meta">${esc(timeLabel)}, ${esc(roleLabel)}</span>
|
||||
<span class="reply-text">${esc(preview)}</span>
|
||||
<button class="file-remove" onclick="clearReply()"><i class="fa fa-times"></i></button>
|
||||
</div>`;
|
||||
container.hidden = false;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// ── File Handling ──
|
||||
|
||||
let pendingFiles = []; // [{ name, type, base64 }]
|
||||
let pendingRefs = []; // [{ path, from, to }] - Workspace-Dateireferenzen
|
||||
|
||||
function handleFileSelect(file) {
|
||||
if (!file) return;
|
||||
|
||||
const maxSize = 20 * 1024 * 1024; // 20MB
|
||||
if (file.size > maxSize) {
|
||||
alert('Datei zu groß (max. 20MB)');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||
if (!allowed.includes(file.type)) {
|
||||
alert('Nicht unterstützter Dateityp. Erlaubt: JPEG, PNG, GIF, WebP, PDF');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = reader.result.split(',')[1];
|
||||
pendingFiles.push({ name: file.name, type: file.type, base64 });
|
||||
renderFilePreview();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function handleMultipleFiles(files) {
|
||||
for (const file of files) handleFileSelect(file);
|
||||
}
|
||||
|
||||
function renderFilePreview() {
|
||||
const preview = $('#filePreview');
|
||||
if (!preview || pendingFiles.length === 0) {
|
||||
if (preview) { preview.innerHTML = ''; preview.hidden = true; }
|
||||
return;
|
||||
}
|
||||
|
||||
preview.innerHTML = pendingFiles.map((f, i) => {
|
||||
const isImage = f.type.startsWith('image/');
|
||||
return `<div class="file-preview-inner">
|
||||
${isImage ? '<img src="data:' + f.type + ';base64,' + f.base64 + '" class="file-thumb">' : '<span class="file-icon"><i class="fa fa-file-text-o"></i></span>'}
|
||||
<span class="file-name">${esc(f.name)}</span>
|
||||
<button class="file-remove" onclick="removePendingFile(${i})"><i class="fa fa-times"></i></button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
preview.hidden = false;
|
||||
}
|
||||
|
||||
function removePendingFile(index) {
|
||||
pendingFiles.splice(index, 1);
|
||||
renderFilePreview();
|
||||
}
|
||||
|
||||
function clearPendingFiles() {
|
||||
pendingFiles.length = 0;
|
||||
const preview = $('#filePreview');
|
||||
if (preview) { preview.innerHTML = ''; preview.hidden = true; }
|
||||
const fileInput = $('#fileInput');
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
|
||||
// ── Workspace Refs ──
|
||||
|
||||
function addPendingRef(ref) {
|
||||
// ref = "path", "path:line" oder "path:from-to"
|
||||
const rangeMatch = ref.match(/^(.+?):(\d+)-(\d+)$/);
|
||||
const lineMatch = !rangeMatch && ref.match(/^(.+?):(\d+)$/);
|
||||
if (rangeMatch) {
|
||||
pendingRefs.push({ path: rangeMatch[1], from: parseInt(rangeMatch[2]), to: parseInt(rangeMatch[3]) });
|
||||
} else if (lineMatch) {
|
||||
pendingRefs.push({ path: lineMatch[1], from: parseInt(lineMatch[2]), to: parseInt(lineMatch[2]) });
|
||||
} else {
|
||||
pendingRefs.push({ path: ref, from: null, to: null });
|
||||
}
|
||||
renderRefPreview();
|
||||
}
|
||||
|
||||
function removePendingRef(index) {
|
||||
pendingRefs.splice(index, 1);
|
||||
renderRefPreview();
|
||||
}
|
||||
|
||||
function clearPendingRefs() {
|
||||
pendingRefs.length = 0;
|
||||
renderRefPreview();
|
||||
}
|
||||
|
||||
function renderRefPreview() {
|
||||
const container = $('#refPreview');
|
||||
if (!container) return;
|
||||
|
||||
if (pendingRefs.length === 0) {
|
||||
container.innerHTML = '';
|
||||
container.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = pendingRefs.map((r, i) => {
|
||||
const label = r.from && r.to && r.from !== r.to ? `${r.path}:${r.from}-${r.to}` : r.from ? `${r.path}:${r.from}` : r.path;
|
||||
return `<div class="ref-preview-item">
|
||||
<span class="ref-icon"><i class="fa fa-folder-open"></i></span>
|
||||
<span class="ref-label">${esc(label)}</span>
|
||||
<button class="file-remove" onclick="removePendingRef(${i})"><i class="fa fa-times"></i></button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
container.hidden = false;
|
||||
}
|
||||
+12
-174
@@ -1,4 +1,4 @@
|
||||
// ── History Loading ──
|
||||
// ── Image Attachments Auto-Loading ──
|
||||
|
||||
async function _autoLoadImageAttachments(messageId) {
|
||||
// Finde die User-Bubble mit dieser messageId
|
||||
@@ -31,11 +31,15 @@ async function _autoLoadImageAttachments(messageId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── History State ──
|
||||
|
||||
let lastKnownMessageId = 0;
|
||||
let _oldestLoadedMessageId = 0;
|
||||
let _hasMoreOlderMessages = false;
|
||||
let historyPollInterval = null;
|
||||
let loadedArchiveCount = 0;
|
||||
|
||||
|
||||
// ── History Loading ──
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
@@ -143,6 +147,8 @@ async function loadOlderMessages() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper Functions ──
|
||||
|
||||
function _findOldestMessageId(entries) {
|
||||
for (const entry of entries) {
|
||||
if (entry.messages && entry.messages.length > 0) {
|
||||
@@ -173,6 +179,8 @@ function _mergeConsecutiveAssistantTexts(msgs) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Fragment Rendering (for loadOlderMessages) ──
|
||||
|
||||
function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) {
|
||||
msgs = _mergeConsecutiveAssistantTexts(msgs);
|
||||
for (var _msgIdx = 0; _msgIdx < msgs.length; _msgIdx++) {
|
||||
@@ -340,6 +348,8 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render Messages (for pollHistory + loadHistory) ──
|
||||
|
||||
function renderMessages(msgs, addToConversation) {
|
||||
msgs = _mergeConsecutiveAssistantTexts(msgs);
|
||||
for (var _rmIdx = 0; _rmIdx < msgs.length; _rmIdx++) {
|
||||
@@ -419,178 +429,6 @@ function renderMessages(msgs, addToConversation) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Archive Loading ──
|
||||
|
||||
function getOrCreateArchiveContainer() {
|
||||
let container = chatEl().querySelector('.archive-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'archive-container';
|
||||
chatEl().prepend(container);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
function getOrCreateArchiveDivider() {
|
||||
let divider = chatEl().querySelector('.archive-divider');
|
||||
if (!divider) {
|
||||
divider = document.createElement('div');
|
||||
divider.className = 'msg system-info archive-divider';
|
||||
divider.textContent = '── Aktuelle Konversation ──';
|
||||
// Nach dem Container, vor den aktiven Messages
|
||||
const container = getOrCreateArchiveContainer();
|
||||
container.after(divider);
|
||||
}
|
||||
return divider;
|
||||
}
|
||||
|
||||
async function loadLatestArchive() {
|
||||
try {
|
||||
const archives = await apiListArchives(getCurrentSessionId());
|
||||
if (archives.length <= loadedArchiveCount) return;
|
||||
|
||||
const latest = archives[loadedArchiveCount];
|
||||
loadedArchiveCount++;
|
||||
|
||||
removeArchiveLoadButton();
|
||||
|
||||
const data = await apiGetArchive(latest.archiveId);
|
||||
const container = getOrCreateArchiveContainer();
|
||||
getOrCreateArchiveDivider();
|
||||
|
||||
// Archiv-Block zusammenbauen
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'msg system-info';
|
||||
sep.innerHTML = `<i class="fa fa-archive"></i> Archiv vom ${new Date(latest.createdAt).toLocaleString('de-AT')} (${latest.chainCount} Chains, ${latest.messageCount} Messages)`;
|
||||
fragment.appendChild(sep);
|
||||
|
||||
const archiveImageIds = [];
|
||||
for (const entry of data) {
|
||||
const mergedMsgs = _mergeConsecutiveAssistantTexts(entry.messages);
|
||||
for (const msg of mergedMsgs) {
|
||||
const div = document.createElement('div');
|
||||
switch (msg.type) {
|
||||
case 'text': {
|
||||
const isReviewMsg = msg.content && (msg.content.startsWith('[Review:') || msg.content.startsWith('[Review-Feedback') || msg.content.startsWith('[Finalizer:') || msg.content.startsWith('[Preparer:'));
|
||||
if (msg.role === 'user' && !isReviewMsg) {
|
||||
div.className = 'msg user markdown archived' + (msg.isMarked ? ' marked' : '');
|
||||
if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
|
||||
div.dataset.role = 'user';
|
||||
div.dataset.timestamp = msg.createdAt || '';
|
||||
if (msg.id) div.dataset.messageId = msg.id;
|
||||
renderUserContent(div, msg.content);
|
||||
_addTimestamp(div, msg.createdAt);
|
||||
_addReplyButton(div);
|
||||
_addDeleteButton(div, msg.id, msg.deletedOn);
|
||||
_addMarkButton(div, msg.id, msg.isMarked);
|
||||
_addShareButton(div, msg.id);
|
||||
} else if (isReviewMsg) {
|
||||
div.className = 'msg review-bubble markdown archived' + (msg.isMarked ? ' marked' : '');
|
||||
if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
|
||||
div.dataset.role = msg.role;
|
||||
div.dataset.timestamp = msg.createdAt || '';
|
||||
if (msg.id) div.dataset.messageId = msg.id;
|
||||
div.innerHTML = renderMarkdown(msg.content);
|
||||
parseFluentEmojis(div);
|
||||
_addTimestamp(div, msg.createdAt);
|
||||
_addReplyButton(div);
|
||||
_addDeleteButton(div, msg.id, msg.deletedOn);
|
||||
_addMarkButton(div, msg.id, msg.isMarked);
|
||||
_addShareButton(div, msg.id);
|
||||
} else if (msg.role === 'assistant') {
|
||||
div.className = 'msg assistant markdown archived' + (msg.isMarked ? ' marked' : '');
|
||||
if (msg.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
|
||||
div.dataset.role = 'assistant';
|
||||
div.dataset.timestamp = msg.createdAt || '';
|
||||
if (msg.id) div.dataset.messageId = msg.id;
|
||||
div.innerHTML = renderMarkdown(msg.content);
|
||||
parseFluentEmojis(div);
|
||||
_addTimestamp(div, msg.createdAt);
|
||||
_addReplyButton(div);
|
||||
_addDeleteButton(div, msg.id, msg.deletedOn);
|
||||
_addMarkButton(div, msg.id, msg.isMarked);
|
||||
_addShareButton(div, msg.id);
|
||||
}
|
||||
if (msg.role === 'user' && msg.attachmentCount > 0) {
|
||||
if (/\[Bild: [^\]]+\]/.test(msg.content)) archiveImageIds.push(msg.id);
|
||||
let names = [];
|
||||
try { if (msg.attachmentNames) names = JSON.parse(msg.attachmentNames); } catch {}
|
||||
const nameList = names.filter(n => n).join(', ');
|
||||
const ind = document.createElement('div');
|
||||
ind.className = 'attachment-indicator';
|
||||
ind.innerHTML = `<i class="fa fa-paperclip"></i> ${msg.attachmentCount} Datei${msg.attachmentCount > 1 ? 'en' : ''}` + (nameList ? `: ${esc(nameList)}` : '');
|
||||
ind.style.cursor = 'pointer';
|
||||
ind.title = 'Klicken zum Anzeigen';
|
||||
ind.addEventListener('click', () => loadAndShowAttachments(msg.id, ind));
|
||||
div.appendChild(ind);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_use':
|
||||
if (!isToolVisible(msg.toolName)) continue;
|
||||
div.className = 'tool-box archived';
|
||||
renderToolBoxContent(div, msg.toolName, msg.content, null, false);
|
||||
break;
|
||||
case 'web_search_result': {
|
||||
if (!isToolVisible('web_search')) continue;
|
||||
var archSrc = [];
|
||||
try { archSrc = JSON.parse(msg.content || '[]'); } catch(e) {}
|
||||
if (archSrc.length === 0) continue; // Leere Suchergebnisse überspringen
|
||||
div.className = 'tool-box archived';
|
||||
div.dataset.toolName = 'web_search';
|
||||
renderToolBoxContent(div, 'web_search', null, null, false, archSrc);
|
||||
break;
|
||||
}
|
||||
case 'tool_result': {
|
||||
// An vorherigen tool-box anhängen
|
||||
const prevBoxes = fragment.querySelectorAll ? fragment.querySelectorAll('.tool-box') : [];
|
||||
const lastToolBox = prevBoxes[prevBoxes.length - 1];
|
||||
if (lastToolBox && !lastToolBox.querySelector('.tool-result')) {
|
||||
const rd = document.createElement('div');
|
||||
rd.className = 'tool-result' + (msg.isError ? ' tool-error' : '');
|
||||
rd.textContent = truncate(msg.content || '', 300);
|
||||
lastToolBox.appendChild(rd);
|
||||
}
|
||||
continue; // kein separates div
|
||||
}
|
||||
case 'error':
|
||||
div.className = 'msg system-info archived';
|
||||
div.style.color = 'var(--dangerous)';
|
||||
div.textContent = (msg.content || '').trim();
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
fragment.appendChild(div);
|
||||
}
|
||||
// Chain-Meta-Badges nach jeder Chain einfügen
|
||||
if (entry.chain && isChainMetaVisible(entry.chain)) {
|
||||
const badges = buildMetaBadges(entry.chain);
|
||||
const metaDiv = document.createElement('div');
|
||||
metaDiv.className = 'chain-meta archived';
|
||||
metaDiv.innerHTML = badges.map(b => '<span class="chain-meta-badge">' + esc(b) + '</span>').join('');
|
||||
fragment.appendChild(metaDiv);
|
||||
}
|
||||
}
|
||||
|
||||
// Älteres Archiv vor dem bisherigen Inhalt einfügen
|
||||
container.prepend(fragment);
|
||||
|
||||
// Bild-Attachments automatisch laden
|
||||
for (const mid of archiveImageIds) _autoLoadImageAttachments(mid);
|
||||
|
||||
// Button ganz oben, falls es noch ältere Archive gibt
|
||||
if (archives.length > loadedArchiveCount) {
|
||||
showArchiveLoadButton();
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Archiv laden fehlgeschlagen:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Polling ──
|
||||
|
||||
let _pollLock = false;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// ── Markdown Rendering ──
|
||||
|
||||
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> ';
|
||||
});
|
||||
|
||||
// 4a3) [Preparer: AgentName] prefix → Preparer-Header
|
||||
html = html.replace(/\[Preparer:\s*([^\]]+)\]\s*/g, function(match, agentName) {
|
||||
return '<span class="review-inline-header preparer-inline-header"><i class="fa fa-filter"></i> <strong>' + esc(agentName.trim()) + '</strong></span> ';
|
||||
});
|
||||
|
||||
// 4a4) [Vorbereitet von AgentName]: → Preparer-Forwarded-Header
|
||||
html = html.replace(/\[Vorbereitet von\s+([^\]]+)\]:\s*/g, function(match, agentName) {
|
||||
return '<div class="preparer-forwarded-header"><i class="fa fa-forward"></i> <strong>Vorbereitet von ' + esc(agentName.trim()) + '</strong></div>';
|
||||
});
|
||||
|
||||
// 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], [FORWARD] und [QUESTION] ausblenden
|
||||
html = html.replace(/\[APPROVED\]/g, '');
|
||||
html = html.replace(/\[FORWARD\]\s*/g, '');
|
||||
html = html.replace(/\[QUESTION\]\s*/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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// ── 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, typeof _planModeActive !== 'undefined' && _planModeActive);
|
||||
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);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// ── Reply / Quote ──
|
||||
|
||||
let pendingReply = null; // { index, role, text } - Zitierte Nachricht
|
||||
|
||||
function _formatReplyTimestamp(ts) {
|
||||
if (!ts) return 'Jetzt';
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const hh = d.getHours().toString().padStart(2, '0');
|
||||
const mm = d.getMinutes().toString().padStart(2, '0');
|
||||
// Gleiches Datum → nur Uhrzeit, sonst Datum + Uhrzeit
|
||||
if (d.toDateString() === now.toDateString()) {
|
||||
return hh + ':' + mm;
|
||||
}
|
||||
const dd = d.getDate().toString().padStart(2, '0');
|
||||
const mo = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
return dd + '.' + mo + '. ' + hh + ':' + mm;
|
||||
} catch { return 'Jetzt'; }
|
||||
}
|
||||
|
||||
function setReply(reply) {
|
||||
pendingReply = reply;
|
||||
renderReplyPreview();
|
||||
document.getElementById('input')?.focus();
|
||||
}
|
||||
|
||||
function clearReply() {
|
||||
pendingReply = null;
|
||||
renderReplyPreview();
|
||||
}
|
||||
|
||||
function renderReplyPreview() {
|
||||
const container = $('#replyPreview');
|
||||
if (!container) return;
|
||||
|
||||
if (!pendingReply) {
|
||||
container.innerHTML = '';
|
||||
container.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const roleLabel = pendingReply.role === 'user' ? 'User' : 'Assistant';
|
||||
const timeLabel = _formatReplyTimestamp(pendingReply.timestamp);
|
||||
const preview = truncate(pendingReply.text, 150);
|
||||
|
||||
container.innerHTML = `<div class="reply-preview-item">
|
||||
<span class="reply-icon"><i class="fa fa-reply"></i></span>
|
||||
<span class="reply-meta">${esc(timeLabel)}, ${esc(roleLabel)}</span>
|
||||
<span class="reply-text">${esc(preview)}</span>
|
||||
<button class="file-remove" onclick="clearReply()"><i class="fa fa-times"></i></button>
|
||||
</div>`;
|
||||
container.hidden = false;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// ── 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 pauseStreamBubble() {
|
||||
// Cursor entfernen, aber Bubble + Text behalten (z.B. bei Web-Search-Pause)
|
||||
if (_streamDiv) {
|
||||
const cursor = _streamDiv.querySelector('.cursor');
|
||||
if (cursor) cursor.remove();
|
||||
}
|
||||
}
|
||||
|
||||
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 = '';
|
||||
}
|
||||
|
||||
// ── Review Bubbles (Streaming) ──
|
||||
|
||||
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');
|
||||
if (agentType === 'preparer') _reviewBubble.classList.add('preparer-bubble');
|
||||
const header = document.createElement('div');
|
||||
header.className = 'review-header';
|
||||
if (agentType === 'preparer') {
|
||||
header.innerHTML = '<i class="fa fa-filter"></i> <strong>' + esc(data.agentName || 'Vorbereiter-Agent') + '</strong>';
|
||||
} else 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 === 'preparer' && type === 'forwarded') {
|
||||
status.className += ' review-approved';
|
||||
status.innerHTML = '<i class="fa fa-arrow-right"></i> Weitergeleitet an Session-Agent';
|
||||
} else if (agentType === 'preparer' && type === 'query') {
|
||||
status.className += ' review-rejected';
|
||||
status.innerHTML = '<i class="fa fa-question-circle"></i> Rückfrage an User';
|
||||
} else 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();
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// ── DOM Helpers ──
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const chatEl = () => $('#chat');
|
||||
const metaEl = () => $('#meta');
|
||||
|
||||
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 || '');
|
||||
}
|
||||
|
||||
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 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 + ')';
|
||||
}
|
||||
|
||||
// ── 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(); });
|
||||
}
|
||||
|
||||
// ── 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());
|
||||
}
|
||||
|
||||
// ── Streamed Elements ──
|
||||
|
||||
function removeStreamedElements() {
|
||||
chatEl().querySelectorAll('.streamed').forEach(el => el.remove());
|
||||
}
|
||||
-1534
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user