Files
gott/wwwroot/js/history.js
T
Meleki 3ec9ebba45 Chat: Usage-Badge-Overlay mit Key-Switch-Modal
- Usage-Badge oben zentriert im Chat (nur Admin)
- Zeigt Sitzung/Woche-Balken des aktiven API-Keys
- Blendet bei Scroll-Up aus, bei Scroll-Down ein
- Klick auf Badge öffnet Key-Switch-Modal (via _createModal)
- Ganze Row klickbar für Key-Wechsel
- Refresh-Button neben Badge für manuellen Key-Check
- Badge aktualisiert sich automatisch bei neuen Nachrichten (pollHistory)
- Cleanup in destroy()
2026-03-12 20:03:30 +01:00

493 lines
18 KiB
JavaScript

// ── Web Search Sources Extraction ──
// Unterstützt zwei Formate:
// - Alt: JSON-Array von {url, title} (nur Sources)
// - Neu: Raw web_search_tool_result JSON (content[].type === 'web_search_result')
function _extractWebSearchSources(content) {
try {
var parsed = JSON.parse(content || '[]');
// Altes Format: direktes Array von Sources
if (Array.isArray(parsed)) return parsed;
// Neues Format: Raw web_search_tool_result mit content-Array
if (parsed.content && Array.isArray(parsed.content)) {
var sources = [];
parsed.content.forEach(function(item) {
if (item.type === 'web_search_result' && item.url) {
sources.push({ url: item.url, title: item.title || '' });
}
});
return sources;
}
return [];
} catch(e) { return []; }
}
// ── Image Attachments Auto-Loading ──
async function _autoLoadImageAttachments(messageId) {
// Finde die User-Bubble mit dieser messageId
const bubble = document.querySelector('.msg.user[data-message-id="' + messageId + '"]');
if (!bubble) return;
const placeholders = bubble.querySelectorAll('.inline-image-placeholder');
if (placeholders.length === 0) return;
try {
const resp = await fetch('/api/attachments/' + messageId);
const attachments = await resp.json();
const imageAtts = attachments.filter(function(a) { return a.type === 'image'; });
placeholders.forEach(function(ph) {
const filename = ph.dataset.filename;
// Bild anhand Dateiname zuordnen, oder der Reihe nach
const att = imageAtts.find(function(a) { return a.fileName === filename; }) || imageAtts.shift();
if (att) {
const img = document.createElement('img');
img.className = 'inline-user-image';
img.src = 'data:' + att.mediaType + ';base64,' + att.data;
img.alt = filename;
img.title = filename;
img.addEventListener('click', function() { showKreaImageModal(filename, img.src); });
ph.replaceWith(img);
}
});
} catch (e) {
// Platzhalter bleiben stehen bei Fehler
}
}
// ── History State ──
let lastKnownMessageId = 0;
let _oldestLoadedSortKey = 0;
let _hasMoreOlderMessages = false;
let historyPollInterval = null;
// ── History Loading ──
async function loadHistory() {
try {
const sid = getCurrentSessionId();
// Auto-Scroll initialisieren (einmalig pro Session-Load)
initAutoScroll();
const data = await apiHistory(sid, null, isDeletedVisible());
const entries = data.entries || [];
// Älteste geladene Message-ID merken für Nachladen
_hasMoreOlderMessages = data.hasMore;
_oldestLoadedSortKey = _findOldestSortKey(entries);
if (data.hasMore) {
showOlderMessagesButton(data.remainingCount);
} else {
// Keine älteren Nachrichten mehr → Archiv-Button anzeigen falls vorhanden
const archives = await apiListArchives(sid);
if (archives.length > 0) {
showArchiveLoadButton();
}
}
for (const entry of entries) {
renderMessages(entry.messages, true);
if (entry.chain) addChainMetaBubble(entry.chain, false);
}
// Beim initialen Laden immer ganz nach unten scrollen
scrollDownForce();
setMeta('Bereit');
startHistoryPolling();
} catch (e) {
console.error('History laden fehlgeschlagen:', e);
}
}
async function loadOlderMessages() {
if (!_hasMoreOlderMessages || !_oldestLoadedSortKey) return;
const btn = chatEl().querySelector('.older-messages-btn');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-hourglass-half"></i> Wird geladen...';
}
try {
const sid = getCurrentSessionId();
const data = await apiHistory(sid, _oldestLoadedSortKey, isDeletedVisible());
const entries = data.entries || [];
_hasMoreOlderMessages = data.hasMore;
const newOldest = _findOldestSortKey(entries);
if (newOldest) _oldestLoadedSortKey = newOldest;
// Button entfernen
removeOlderMessagesButton();
// Scroll-Position merken
const chat = chatEl();
const oldScrollHeight = chat.scrollHeight;
// Nachrichten oben einfügen (vor dem ersten .msg Element)
const fragment = document.createDocumentFragment();
const imageMessageIds = [];
for (const entry of entries) {
_renderMessagesIntoFragment(fragment, _mergeConsecutiveAssistantTexts(entry.messages), imageMessageIds);
if (entry.chain && isChainMetaVisible(entry.chain)) {
fragment.appendChild(_createChainMetaDiv(entry.chain));
}
}
// Einfügen: vor dem ersten .msg oder .chain-meta Element
const firstMsg = chat.querySelector('.msg, .chain-meta, .tool-box');
if (firstMsg) {
chat.insertBefore(fragment, firstMsg);
} else {
chat.appendChild(fragment);
}
// Bild-Attachments automatisch laden
for (const mid of imageMessageIds) _autoLoadImageAttachments(mid);
// Neuen Button oder Archiv-Button anzeigen
if (data.hasMore) {
showOlderMessagesButton(data.remainingCount);
} else {
const archives = await apiListArchives(sid);
if (archives.length > 0) {
showArchiveLoadButton();
}
}
// Scroll-Position beibehalten
const newScrollHeight = chat.scrollHeight;
chat.scrollTop += (newScrollHeight - oldScrollHeight);
} catch (e) {
console.error('Ältere Nachrichten laden fehlgeschlagen:', e);
if (_hasMoreOlderMessages) {
showOlderMessagesButton(0);
}
}
}
// ── Helper Functions ──
function _findOldestSortKey(entries) {
for (const entry of entries) {
if (entry.messages && entry.messages.length > 0) {
return entry.messages[0].sortKey;
}
}
return 0;
}
function _mergeConsecutiveAssistantTexts(msgs) {
const result = [];
for (const msg of msgs) {
if (msg.type === 'text' && msg.role === 'assistant') {
const prev = result.length > 0 ? result[result.length - 1] : null;
if (prev && prev.type === 'text' && prev.role === 'assistant'
&& prev.iteration === msg.iteration
&& !msg.tag && !prev.tag
&& !_isReviewMessage(msg.content) && !_isReviewMessage(prev.content)) {
prev.content = (prev.content || '') + ' ' + (msg.content || '');
if (msg.id) prev.id = msg.id;
if (msg.sortKey && (!prev.sortKey || msg.sortKey > prev.sortKey)) prev.sortKey = msg.sortKey;
if (msg.createdAt) prev.createdAt = msg.createdAt;
if (msg.isMarked) prev.isMarked = true;
continue;
}
}
result.push(Object.assign({}, msg));
}
return result;
}
// ── Fragment Rendering (shared between loadOlderMessages + loadLatestArchive) ──
/**
* Renders a merged message list into a DocumentFragment.
* @param {DocumentFragment} fragment
* @param {Array} msgs - Already merged messages
* @param {Array} imageMessageIds - Collects message IDs with image attachments
* @param {object} opts - { archived: bool }
*/
function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
const archived = opts && opts.archived;
for (var _msgIdx = 0; _msgIdx < msgs.length; _msgIdx++) {
const msg = msgs[_msgIdx];
// ── Grouped Review/Preparer/Finalizer Bubble (tag-based) ──
if (msg.tag && (msg.tag === 'review' || msg.tag === 'preparer' || msg.tag === 'finalizer')) {
// Collect all consecutive messages with the same tag
var tagGroup = [msg];
while (_msgIdx + 1 < msgs.length && msgs[_msgIdx + 1].tag === msg.tag) {
_msgIdx++;
tagGroup.push(msgs[_msgIdx]);
}
fragment.appendChild(_renderReviewGroup(tagGroup, archived));
continue;
}
switch (msg.type) {
case 'text': {
const isReviewMsg = _isReviewMessage(msg.content);
if (msg.role === 'user' && !isReviewMsg) {
const div = _createMessageBubble({ role: 'user', timestamp: msg.createdAt, messageId: msg.id, isMarked: msg.isMarked, deletedOn: msg.deletedOn, archived });
if (msg.tag === 'openclaw-import') _addOpenClawBadge(div);
renderUserContent(div, msg.content);
_addBubbleActions(div, { timestamp: msg.createdAt, messageId: msg.id, deletedOn: msg.deletedOn, isMarked: msg.isMarked });
fragment.appendChild(div);
if (msg.attachmentCount > 0) {
if (/\[Bild: [^\]]+\]/.test(msg.content) && imageMessageIds) {
imageMessageIds.push(msg.id);
}
div.appendChild(createAttachmentIndicator(msg.id, msg.attachmentCount, msg.attachmentNames));
}
} else if (isReviewMsg) {
const div = _createMessageBubble({ role: msg.role, extraClass: 'review-bubble', timestamp: msg.createdAt, messageId: msg.id, isMarked: msg.isMarked, deletedOn: msg.deletedOn, archived });
div.innerHTML = renderMarkdown(msg.content);
parseFluentEmojis(div);
_addBubbleActions(div, { timestamp: msg.createdAt, messageId: msg.id, deletedOn: msg.deletedOn, isMarked: msg.isMarked });
fragment.appendChild(div);
} else if (msg.role === 'assistant') {
const div = _createMessageBubble({ role: 'assistant', timestamp: msg.createdAt, messageId: msg.id, isMarked: msg.isMarked, deletedOn: msg.deletedOn, archived });
div.innerHTML = renderMarkdown(msg.content);
parseFluentEmojis(div);
if (msg.tag === 'openclaw-import') _addOpenClawBadge(div);
_addBubbleActions(div, { timestamp: msg.createdAt, messageId: msg.id, deletedOn: msg.deletedOn, isMarked: msg.isMarked });
fragment.appendChild(div);
}
break;
}
case 'tool_use':
if (msg.toolName === 'ask_user_choice') {
_renderChoiceBubbleInFragment(fragment, msgs, _msgIdx);
continue;
}
if (!isToolVisible(msg.toolName)) continue;
var toolDiv = document.createElement('div');
toolDiv.className = 'tool-box' + (archived ? ' archived' : '');
toolDiv.dataset.toolName = msg.toolName;
renderToolBoxContent(toolDiv, msg.toolName, msg.content, null, false);
fragment.appendChild(toolDiv);
break;
case 'web_search_result': {
if (!isToolVisible('web_search')) continue;
var wsSources = _extractWebSearchSources(msg.content);
if (wsSources.length === 0) continue;
var wsDiv = document.createElement('div');
wsDiv.className = 'tool-box' + (archived ? ' archived' : '');
wsDiv.dataset.toolName = 'web_search';
renderToolBoxContent(wsDiv, 'web_search', null, null, false, wsSources);
fragment.appendChild(wsDiv);
break;
}
case 'tool_result': {
if (msg._choiceHandled) continue;
const prevBoxes = fragment.querySelectorAll('.tool-box');
const lastToolBox = prevBoxes[prevBoxes.length - 1];
if (lastToolBox && !lastToolBox.querySelector('.tool-result') && !lastToolBox.querySelector('.todo-list')) {
// todo_write: nachträglich die Todo-Liste rendern (sichtbar)
if (lastToolBox.dataset.toolName === 'todo_write') {
lastToolBox.appendChild(buildTodoList(msg.content || '', msg.isError));
} else {
const rd = document.createElement('div');
rd.className = 'tool-result' + (msg.isError ? ' tool-error' : '');
rd.textContent = truncate(msg.content || '', 300);
rd.hidden = true;
lastToolBox.appendChild(rd);
}
}
continue;
}
case 'error': {
var errDiv = document.createElement('div');
errDiv.className = 'msg system-info' + (archived ? ' archived' : '');
errDiv.style.color = 'var(--dangerous)';
errDiv.textContent = (msg.content || '').trim();
fragment.appendChild(errDiv);
break;
}
default:
continue;
}
}
}
/**
* Renders a group of consecutive messages with the same tag (review/preparer/finalizer)
* into a single Review-Bubble matching the streaming UI structure.
*/
function _renderReviewGroup(tagGroup, archived) {
var tag = tagGroup[0].tag;
var bubble = document.createElement('div');
bubble.className = 'msg review-bubble' + (archived ? ' archived' : '');
if (tag === 'finalizer') bubble.classList.add('finalizer-bubble');
if (tag === 'preparer') bubble.classList.add('preparer-bubble');
// Extract agent name from first text message: "[Review: AgentName] text..."
var agentName = null;
var firstText = tagGroup.find(function(m) { return m.type === 'text' && m.role === 'assistant'; });
if (firstText) {
var nameMatch = firstText.content.match(/^\[(Review|Preparer|Finalizer): ([^\]]+)\]/);
if (nameMatch) agentName = nameMatch[2];
}
bubble.appendChild(_createSubAgentHeader(tag, agentName));
// Content: collect text (strip prefix) and tool-use indicators
var textParts = [];
function flushText() {
if (textParts.length === 0) return;
var div = document.createElement('div');
div.className = 'review-content markdown';
div.innerHTML = renderMarkdown(textParts.join('\n\n'));
parseFluentEmojis(div);
bubble.appendChild(div);
textParts = [];
}
for (var i = 0; i < tagGroup.length; i++) {
var m = tagGroup[i];
if (m.type === 'text' && m.role === 'assistant') {
var text = m.content;
// Strip [Tag: AgentName] prefix
text = text.replace(/^\[(Review|Preparer|Finalizer): [^\]]+\]\s*/, '');
if (text) textParts.push(text);
} else if (m.type === 'tool_use') {
if (!isToolVisible(m.toolName)) continue;
flushText();
// Full tool-box (same as normal tool boxes)
var toolDiv = document.createElement('div');
toolDiv.className = 'tool-box review-tool-box' + (archived ? ' archived' : '');
// Look ahead for matching tool_result
var toolResult = null;
var toolIsError = false;
for (var j = i + 1; j < tagGroup.length; j++) {
if (tagGroup[j].type === 'tool_result' && tagGroup[j].toolCallId === m.toolCallId) {
toolResult = tagGroup[j].content;
toolIsError = tagGroup[j].isError;
break;
}
}
renderToolBoxContent(toolDiv, m.toolName, m.content, toolResult, toolIsError);
bubble.appendChild(toolDiv);
} else if (m.type === 'web_search_result') {
if (!isToolVisible('web_search')) continue;
flushText();
var wsSources = _extractWebSearchSources(m.content);
if (wsSources.length > 0) {
var wsDiv = document.createElement('div');
wsDiv.className = 'tool-box review-tool-box' + (archived ? ' archived' : '');
renderToolBoxContent(wsDiv, 'web_search', null, null, false, wsSources);
bubble.appendChild(wsDiv);
}
}
// tool_result is consumed by tool_use look-ahead above
}
flushText();
return bubble;
}
function _addOpenClawBadge(div) {
var badge = document.createElement('span');
badge.className = 'openclaw-badge';
badge.innerHTML = '<i class="fa fa-cloud-download"></i> OC';
badge.title = 'Importiert aus OpenClaw';
div.insertBefore(badge, div.firstChild);
}
// _isReviewMessage → moved to ui-helpers.js
function _createChainMetaDiv(chain, extraClass) {
const badges = buildMetaBadges(chain);
const div = document.createElement('div');
div.className = 'chain-meta' + (extraClass ? ' ' + extraClass : '');
div.innerHTML = badges.map(b => '<span class="chain-meta-badge">' + esc(b) + '</span>').join('');
return div;
}
function _renderChoiceBubbleInFragment(fragment, msgs, msgIdx) {
var msg = msgs[msgIdx];
try {
var choiceInput = typeof msg.content === 'string' ? JSON.parse(msg.content) : msg.content;
var choiceQuestion = choiceInput.question || '';
var choiceOptions = Array.isArray(choiceInput.choices) ? choiceInput.choices : [String(choiceInput.choices || '')];
var selectedChoice = null;
for (var ci = msgIdx + 1; ci < msgs.length; ci++) {
if (msgs[ci].type === 'tool_result' && msgs[ci].toolCallId === msg.toolCallId) {
selectedChoice = msgs[ci].content;
msgs[ci]._choiceHandled = true;
break;
}
}
var choiceDiv = _createChoiceBubble({
toolCallId: msg.toolCallId,
question: choiceQuestion,
choices: choiceOptions,
selectedChoice: selectedChoice,
timestamp: msg.createdAt,
messageId: msg.id
});
fragment.appendChild(choiceDiv);
} catch(e) {
console.error('Choice-Bubble Rendering fehlgeschlagen:', e);
}
}
// ── Render Messages (for pollHistory + loadHistory) ──
function renderMessages(msgs, addToConversation) {
msgs = _mergeConsecutiveAssistantTexts(msgs);
const fragment = document.createDocumentFragment();
const imageMessageIds = [];
_renderMessagesIntoFragment(fragment, msgs, imageMessageIds);
chatEl().appendChild(fragment);
// Track lastKnownMessageId
for (const msg of msgs) {
const sk = msg.sortKey || msg.id;
if (sk > lastKnownMessageId) lastKnownMessageId = sk;
}
// Auto-load image attachments
for (const mid of imageMessageIds) _autoLoadImageAttachments(mid);
scrollDown();
}
// ── Polling ──
let _pollLock = false;
function startHistoryPolling(intervalMs = 3000) {
if (historyPollInterval) return;
historyPollInterval = setInterval(pollHistory, intervalMs);
}
function stopHistoryPolling() {
if (historyPollInterval) {
clearInterval(historyPollInterval);
historyPollInterval = null;
}
}
async function pollHistory(force = false) {
if (isStreaming && !force) return;
if (_pollLock) return;
_pollLock = true;
try {
const newMessages = await apiHistoryPoll(getCurrentSessionId(), lastKnownMessageId, isDeletedVisible());
if (newMessages.length > 0) {
renderMessages(newMessages, true);
scrollDown();
if (typeof _refreshChatUsageBadge === 'function') _refreshChatUsageBadge();
}
} catch (e) {
console.error('History poll fehlgeschlagen:', e);
} finally {
_pollLock = false;
}
}