Files
gott/wwwroot/js/history.js
T
Meleki 5f3e308975 feat: ask_user_choice Tool – interaktive Auswahlmöglichkeiten im Chat
Neues Tool 'ask_user_choice' analog zum AskUserQuestion aus dem Claude Agent SDK.
Claude kann dem User Multiple-Choice-Fragen stellen, wenn es mehrere sinnvolle
Wege gibt. Der User wählt per Button, Claude arbeitet mit der Antwort weiter.

Backend:
- ToolService: ask_user_choice Tool-Definition (question + choices[2-5])
- AnthropicService/ProcessStream: Tool wird NICHT ausgeführt, sondern als
  SSE-Event 'user_choice' an den Client gesendet. stopReason wird auf
  'awaiting_user_choice' gesetzt (mit Guard gegen Überschreibung durch
  message_delta)
- ChatEndpoint: Erkennung von Choice-Responses (choiceToolCallId + choiceResult),
  tool_result wird in die korrekte Chain gespeichert (wo der tool_use liegt),
  toolChoice wird auf 'auto' gesetzt damit Claude frei antworten kann
- ChatService: FindChainWithToolCall() zum Auffinden der richtigen Chain,
  BuildHistoryFromDb() erweitert um Choice-Chain-Erkennung (wenn letzte Chain
  leer/nur tool_result → vorletzte Chain vollständig rekonstruieren)
- ChatModels: ChatRequest um ChoiceToolCallId + ChoiceResult erweitert

Frontend:
- api.js: apiChatChoice() für Choice-Responses
- chat.js: handleStreamEvent() als shared Handler extrahiert (sendMessage +
  handleChoiceClick nutzen denselben Code), handleChoiceClick() startet neuen
  Stream nach Button-Klick
- ui.js: addChoiceBubble() (live/interaktiv) + addChoiceBubbleStatic() (History)
- history.js: Choice-Bubbles in _renderMessagesIntoFragment + renderMessages,
  Voraus-Suche nach zugehörigem tool_result, unbeantwortet = klickbare Buttons
- chat.css: Styling für Choice-Bubbles (Primary-Color-Outline-Buttons, Hover,
  Selected/Unselected States)

Robustheit:
- Backend-Fallback wenn Claude choices als String statt Array sendet
- Frontend-Guard: Array.isArray(choices) an allen 4 Render-Stellen
- Page-Refresh mit offener Choice → aktive Buttons werden korrekt gerendert
2026-03-07 11:30:55 +01:00

597 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── History 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
}
}
let lastKnownMessageId = 0;
let _oldestLoadedMessageId = 0;
let _hasMoreOlderMessages = false;
let historyPollInterval = null;
let loadedArchiveCount = 0;
async function loadHistory() {
try {
const sid = getCurrentSessionId();
const data = await apiHistory(sid, null, isDeletedVisible());
const entries = data.entries || [];
// Älteste geladene Message-ID merken für Nachladen
_hasMoreOlderMessages = data.hasMore;
_oldestLoadedMessageId = _findOldestMessageId(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);
}
scrollDown();
setMeta('Bereit');
startHistoryPolling();
} catch (e) {
console.error('History laden fehlgeschlagen:', e);
}
}
async function loadOlderMessages() {
if (!_hasMoreOlderMessages || !_oldestLoadedMessageId) 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, _oldestLoadedMessageId, isDeletedVisible());
const entries = data.entries || [];
_hasMoreOlderMessages = data.hasMore;
const newOldest = _findOldestMessageId(entries);
if (newOldest > 0) _oldestLoadedMessageId = 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, entry.messages, imageMessageIds);
if (entry.chain && isChainMetaVisible(entry.chain)) {
const badges = buildMetaBadges(entry.chain);
const metaDiv = document.createElement('div');
metaDiv.className = 'chain-meta';
metaDiv.innerHTML = badges.map(b => '<span class="chain-meta-badge">' + esc(b) + '</span>').join('');
fragment.appendChild(metaDiv);
}
}
// 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);
}
}
}
function _findOldestMessageId(entries) {
for (const entry of entries) {
if (entry.messages && entry.messages.length > 0) {
return entry.messages[0].id;
}
}
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;
const isReview = msg.content && (msg.content.startsWith('[Review:') || msg.content.startsWith('[Finalizer:'));
const prevIsReview = prev && prev.content && (prev.content.startsWith('[Review:') || prev.content.startsWith('[Finalizer:'));
if (prev && prev.type === 'text' && prev.role === 'assistant'
&& prev.iteration === msg.iteration && !isReview && !prevIsReview) {
prev.content = (prev.content || '') + ' ' + (msg.content || '');
if (msg.id) prev.id = msg.id;
if (msg.createdAt) prev.createdAt = msg.createdAt;
if (msg.isMarked) prev.isMarked = true;
continue;
}
}
result.push(Object.assign({}, msg));
}
return result;
}
function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) {
msgs = _mergeConsecutiveAssistantTexts(msgs);
for (var _msgIdx = 0; _msgIdx < msgs.length; _msgIdx++) {
const msg = msgs[_msgIdx];
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:'));
if (msg.role === 'user' && !isReviewMsg) {
div.className = 'msg user markdown' + (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);
fragment.appendChild(div);
if (msg.attachmentCount > 0) {
// Prüfe ob Bilder dabei sind (Content enthält [Bild: ...])
if (/\[Bild: [^\]]+\]/.test(msg.content) && imageMessageIds) {
imageMessageIds.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);
}
} else if (isReviewMsg) {
div.className = 'msg review-bubble markdown' + (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);
fragment.appendChild(div);
} else if (msg.role === 'assistant') {
div.className = 'msg assistant markdown' + (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);
fragment.appendChild(div);
}
break;
}
case 'tool_use':
if (msg.toolName === 'ask_user_choice') {
// ask_user_choice: als Choice-Bubble rendern
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 || '')];
// Voraus-Suche nach zugehörigem tool_result
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; // Markieren damit es nicht nochmal gerendert wird
break;
}
}
var choiceDiv = document.createElement('div');
choiceDiv.className = 'msg choice-bubble' + (selectedChoice ? ' choice-answered' : '');
choiceDiv.dataset.toolCallId = msg.toolCallId || '';
if (msg.id) choiceDiv.dataset.messageId = msg.id;
var qDiv = document.createElement('div');
qDiv.className = 'choice-question';
qDiv.innerHTML = renderMarkdown(choiceQuestion);
parseFluentEmojis(qDiv);
choiceDiv.appendChild(qDiv);
var btnsDiv = document.createElement('div');
btnsDiv.className = 'choice-buttons';
for (var co of choiceOptions) {
var cbtn = document.createElement('button');
cbtn.className = 'choice-btn';
cbtn.textContent = co;
if (selectedChoice) {
cbtn.disabled = true;
if (co === selectedChoice) {
cbtn.classList.add('choice-selected');
} else {
cbtn.classList.add('choice-unselected');
}
} else {
// Noch nicht beantwortet – klickbar machen
(function(toolCallId, choice, bubble) {
cbtn.addEventListener('click', function() { handleChoiceClick(toolCallId, choice, bubble); });
})(msg.toolCallId, co, choiceDiv);
}
btnsDiv.appendChild(cbtn);
}
choiceDiv.appendChild(btnsDiv);
if (msg.createdAt) _addTimestamp(choiceDiv, msg.createdAt);
fragment.appendChild(choiceDiv);
} catch(e) {
console.error('Choice-Bubble Rendering fehlgeschlagen:', e);
}
continue;
}
if (!isToolVisible(msg.toolName)) continue;
div.className = 'tool-box';
renderToolBoxContent(div, msg.toolName, msg.content, null, false);
fragment.appendChild(div);
break;
case 'web_search_result': {
if (!isToolVisible('web_search')) continue;
var wsSources = [];
try { wsSources = JSON.parse(msg.content || '[]'); } catch(e) {}
if (wsSources.length === 0) continue; // Leere Suchergebnisse überspringen
div.className = 'tool-box';
div.dataset.toolName = 'web_search';
renderToolBoxContent(div, 'web_search', null, null, false, wsSources);
fragment.appendChild(div);
break;
}
case 'tool_result': {
// Überspringe tool_results die bereits von einer Choice-Bubble verarbeitet wurden
if (msg._choiceHandled) continue;
const prevBoxes = 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);
rd.hidden = true;
lastToolBox.appendChild(rd);
}
continue;
}
case 'error':
div.className = 'msg system-info';
div.style.color = 'var(--dangerous)';
div.textContent = (msg.content || '').trim();
fragment.appendChild(div);
break;
default:
continue;
}
}
}
function renderMessages(msgs, addToConversation) {
msgs = _mergeConsecutiveAssistantTexts(msgs);
for (var _rmIdx = 0; _rmIdx < msgs.length; _rmIdx++) {
const msg = msgs[_rmIdx];
if (msg.id > lastKnownMessageId) lastKnownMessageId = msg.id;
switch (msg.type) {
case 'text':
if (msg.role === 'user') {
if (msg.content && msg.content.startsWith('[Review-Feedback')) {
addReviewBubbleStatic(msg.content, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn);
} else {
addUserBubble(msg.content, false, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn);
if (msg.attachmentCount > 0) {
_autoLoadImageAttachments(msg.id);
addAttachmentIndicator(msg.id, msg.attachmentCount, msg.attachmentNames);
}
}
} else if (msg.role === 'assistant') {
if (msg.content && (msg.content.startsWith('[Review:') || msg.content.startsWith('[Finalizer:'))) {
addReviewBubbleStatic(msg.content, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn);
} else {
addAssistantBubble(msg.content, msg.createdAt, msg.id, msg.isMarked, msg.deletedOn);
}
}
break;
case 'tool_use':
if (msg.toolName === 'ask_user_choice') {
try {
var choiceInput2 = typeof msg.content === 'string' ? JSON.parse(msg.content) : msg.content;
var choiceQ2 = choiceInput2.question || '';
var choiceOpts2 = Array.isArray(choiceInput2.choices) ? choiceInput2.choices : [String(choiceInput2.choices || '')];
// Voraus-Suche nach zugehörigem tool_result
var selectedChoice2 = null;
for (var ci2 = _rmIdx + 1; ci2 < msgs.length; ci2++) {
if (msgs[ci2].type === 'tool_result' && msgs[ci2].toolCallId === msg.toolCallId) {
selectedChoice2 = msgs[ci2].content;
msgs[ci2]._choiceHandled = true;
break;
}
}
if (selectedChoice2) {
addChoiceBubbleStatic(msg.toolCallId, choiceQ2, choiceOpts2, selectedChoice2, msg.createdAt, msg.id);
} else {
addChoiceBubble(msg.toolCallId, choiceQ2, choiceOpts2, false);
}
} catch(e) {
console.error('Choice-Bubble Rendering fehlgeschlagen:', e);
}
break;
}
if (isToolVisible(msg.toolName)) {
addToolBoxStatic(msg.toolName, msg.content, null, false);
}
break;
case 'web_search_result': {
if (isToolVisible('web_search')) {
var wsSrc = [];
try { wsSrc = JSON.parse(msg.content || '[]'); } catch(e) {}
if (wsSrc.length > 0) addToolBoxStatic('web_search', null, null, false, wsSrc);
}
break;
}
case 'tool_result':
if (msg._choiceHandled) break;
// tool_result wird dem vorherigen tool_use-Box angehängt wenn möglich
appendToolResult(msg.content, msg.isError);
break;
case 'error':
addErrorBubble(msg.content);
break;
}
}
}
// ── 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':
if (msg.role === 'user') {
div.className = 'msg user markdown archived';
if (msg.id) div.dataset.messageId = msg.id;
renderUserContent(div, msg.content);
_addTimestamp(div, msg.createdAt);
} else {
div.className = 'msg assistant markdown archived';
div.innerHTML = renderMarkdown(msg.content);
parseFluentEmojis(div);
_addTimestamp(div, msg.createdAt);
}
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;
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();
}
} catch (e) {
console.error('History poll fehlgeschlagen:', e);
} finally {
_pollLock = false;
}
}