Files
gott/wwwroot/js/bubbles.js
T
MelekiandClaude Opus 4.8 f2fd493e5f Add live steering injection into running chains
Let the user inject a message into a running chain instead of only being
able to abort it. While a chain streams, typing + Enter sends the text to
a new /api/chat/steer endpoint; it is drained at the next iteration
boundary, wrapped with a hint so the model actively classifies it
("complements the task -> keep working" vs. "changes course -> adjust"),
and stored as a Type="steer" user message so it survives reload.

Shared infrastructure (provider-agnostic):
- SteeringQueue: out-of-band queue keyed by sessionId, mirrored from
  StreamingTracker (Enqueue/Drain/Clear).
- Program.cs registers it as a singleton and passes it to AnthropicService.
- ChatEndpoint: POST /api/chat/steer enqueues only while IsStreaming,
  Clear() in the finally.
- ChatService.BuildHistoryFromDb groups Type="steer" so no two
  consecutive user turns are produced on re-chat.
- Frontend api.js/chat.js/bubbles.js/history.js + chat.css render the
  optimistic steer bubble and the steer_injected SSE event; Enter during
  a stream sends a steer instead of being blocked.

Anthropic API path:
- Drain point B (tool boundary, merged into the tool_result user turn)
  and C (turn end -> inject + continue instead of finalize).

Claude CLI path (kill-and-restart):
- The agentic loop runs inside the claude child, so true mid-turn inject
  is impossible. On a pending steer the child is killed at the next tool
  boundary, progress is rebuilt from the DB as a <work_in_progress>
  transcript, and the CLI is restarted with the steer appended.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 06:49:58 +02:00

329 lines
12 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.
// ── 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 clickable" data-filepath="' + esc(filePath) + '" title="' + esc(ref) + '">'
+ '<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 Bubble Helpers ──
/**
* Creates a message bubble div with common setup (className, dataset, deletedOn, action buttons).
* @param {object} opts - { role, extraClass, timestamp, messageId, isMarked, deletedOn, streamed, archived }
*/
function _createMessageBubble(opts) {
const div = document.createElement('div');
const classes = ['msg', opts.extraClass || opts.role, 'markdown'];
if (opts.isMarked) classes.push('marked');
if (opts.archived) classes.push('archived');
if (opts.streamed) classes.push('streamed');
div.className = classes.join(' ');
if (opts.deletedOn) div.classList.add(isDeletedVisible() ? 'msg-deleted' : 'msg-deleted-hidden');
div.dataset.role = opts.role || '';
div.dataset.timestamp = opts.timestamp || new Date().toISOString();
if (opts.messageId) div.dataset.messageId = opts.messageId;
return div;
}
/**
* Adds standard action buttons (timestamp, reply, delete, mark, share) to a bubble.
*/
function _addBubbleActions(div, opts) {
_addTimestamp(div, opts.timestamp);
_addReplyButton(div);
_addDeleteButton(div, opts.messageId, opts.deletedOn);
_addMarkButton(div, opts.messageId, opts.isMarked);
_addShareButton(div, opts.messageId);
}
/**
* Creates an attachment indicator element for a message.
*/
function createAttachmentIndicator(messageId, count, namesJson) {
let names = [];
try { if (namesJson) names = JSON.parse(namesJson); } 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> ${count} Datei${count > 1 ? 'en' : ''}` + (nameList ? `: ${esc(nameList)}` : '');
ind.classList.add('clickable');
ind.title = 'Klicken zum Anzeigen';
ind.addEventListener('click', () => loadAndShowAttachments(messageId, ind));
return ind;
}
// ── Message Bubbles ──
function addUserBubble(text, streamed = false, timestamp = '', messageId = null, isMarked = false, deletedOn = null, inlineImages = null) {
const div = _createMessageBubble({ role: 'user', timestamp, messageId, isMarked, deletedOn, streamed });
renderUserContent(div, text, inlineImages);
_addBubbleActions(div, { timestamp, messageId, deletedOn, isMarked });
chatEl().appendChild(div);
scrollDown();
}
// ── Steering Bubble (Live-Einwurf während eine Chain läuft) ──
/** Baut eine Steering-Bubble (User-Stil + Einwurf-Label). Returns den div – für Live + History. */
function _buildSteerBubble(opts) {
const div = _createMessageBubble({ role: 'user', extraClass: 'user steer', timestamp: opts.timestamp, messageId: opts.messageId, isMarked: opts.isMarked, deletedOn: opts.deletedOn, archived: opts.archived, streamed: opts.streamed });
const label = document.createElement('div');
label.className = 'steer-label';
label.innerHTML = '<i class="fa fa-bolt"></i> Einwurf';
div.appendChild(label);
renderUserContent(div, opts.text);
_addBubbleActions(div, { timestamp: opts.timestamp, messageId: opts.messageId, deletedOn: opts.deletedOn, isMarked: opts.isMarked });
return div;
}
function addSteerBubble(text, streamed = false) {
const div = _buildSteerBubble({ text, streamed });
chatEl().appendChild(div);
scrollDown();
return div;
}
function _addSystemBubble(text, icon, color, streamed) {
const div = document.createElement('div');
div.className = 'msg system-info';
if (streamed) div.classList.add('streamed');
div.style.color = color;
div.innerHTML = '<i class="fa ' + icon + '"></i> ' + esc((text || '').trim());
chatEl().appendChild(div);
scrollDown();
return div;
}
function addErrorBubble(text, streamed = false) {
return _addSystemBubble(text, 'fa-times-circle', 'var(--dangerous)', streamed);
}
function addWarningBubble(text, streamed = false) {
return _addSystemBubble(text, 'fa-exclamation-triangle', 'var(--warning)', streamed);
}
// ── Choice Bubbles (ask_user_choice) ──
/**
* Creates a choice bubble element (for both live stream and history rendering).
* @param {object} opts - { toolCallId, question, choices, selectedChoice, timestamp, messageId, streamed, viaCli }
*/
function _createChoiceBubble(opts) {
if (!Array.isArray(opts.choices)) opts.choices = [String(opts.choices)];
const answered = !!opts.selectedChoice;
const div = document.createElement('div');
div.className = 'msg choice-bubble' + (answered ? ' choice-answered' : '');
if (opts.streamed) div.classList.add('streamed');
if (opts.viaCli) div.dataset.viaCli = 'true';
div.dataset.toolCallId = opts.toolCallId || '';
if (opts.messageId) div.dataset.messageId = opts.messageId;
const questionDiv = document.createElement('div');
questionDiv.className = 'choice-question';
questionDiv.innerHTML = renderMarkdown(opts.question);
parseFluentEmojis(questionDiv);
div.appendChild(questionDiv);
const buttonsDiv = document.createElement('div');
buttonsDiv.className = 'choice-buttons';
for (const choice of opts.choices) {
const btn = document.createElement('button');
btn.className = 'choice-btn';
btn.textContent = choice;
if (answered) {
btn.disabled = true;
btn.classList.add(choice === opts.selectedChoice ? 'choice-selected' : 'choice-unselected');
} else {
btn.addEventListener('click', () => handleChoiceClick(opts.toolCallId, choice, div));
}
buttonsDiv.appendChild(btn);
}
div.appendChild(buttonsDiv);
// Freitext-Option (nur wenn noch nicht beantwortet)
if (!answered) {
const customDiv = document.createElement('div');
customDiv.className = 'choice-custom';
const toggleLink = document.createElement('a');
toggleLink.className = 'choice-custom-toggle';
toggleLink.href = '#';
toggleLink.textContent = 'Eigene Antwort…';
toggleLink.addEventListener('click', function(e) {
e.preventDefault();
customDiv.classList.add('choice-custom-open');
customDiv.querySelector('.choice-custom-input').focus();
});
customDiv.appendChild(toggleLink);
const inputRow = document.createElement('div');
inputRow.className = 'choice-custom-row';
const input = document.createElement('input');
input.type = 'text';
input.className = 'choice-custom-input';
input.placeholder = 'Eigene Antwort eingeben…';
var sendCustom = function() {
var text = input.value.trim();
if (!text) return;
handleChoiceClick(opts.toolCallId, text, div);
};
input.addEventListener('keydown', function(e) { if (e.key === 'Enter') sendCustom(); });
var sendBtn = document.createElement('button');
sendBtn.className = 'choice-custom-send';
sendBtn.textContent = '↵';
sendBtn.addEventListener('click', sendCustom);
inputRow.appendChild(input);
inputRow.appendChild(sendBtn);
customDiv.appendChild(inputRow);
div.appendChild(customDiv);
}
if (opts.timestamp) _addTimestamp(div, opts.timestamp);
return div;
}
// Wird live aus dem SSE-Stream aufgerufen
function addChoiceBubble(toolCallId, question, choices, streamed = true, viaCli = false) {
const div = _createChoiceBubble({ toolCallId, question, choices, streamed, viaCli });
chatEl().appendChild(div);
scrollDown();
return div;
}
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 = '';
}
}