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
This commit is contained in:
Meleki committed 2026-03-07 11:30:55 +01:00
1 parent 72347ed2f5
commit 5f3e308975
10 files changed
+628 -140

No files matched your search

+203 -111
View File
@@ -27,6 +27,116 @@ function addChainMetaBubble(d, streamed) {
scrollDown();
}
// ── Stream Event Handler (shared between sendMessage + handleChoiceClick) ──
function handleStreamEvent(eventType, data) {
switch (eventType) {
case 'text_start':
if (!_streamDiv) startStreamBubble();
break;
case 'text_delta':
appendToStream(data.text);
break;
case 'text_end':
pauseStreamBubble();
break;
case 'tool_start':
endStreamBubble();
if (isToolVisible(data.name)) {
_currentToolBox = addToolBoxLoading(data.name);
} else {
_currentToolBox = null;
}
break;
case 'tool_end':
if (_currentToolBox) {
finishToolBox(_currentToolBox, data);
}
_currentToolBox = null;
break;
case 'web_search_start':
pauseStreamBubble();
_currentToolBox = addToolBoxLoading('web_search');
break;
case 'web_search_end':
if (_currentToolBox) {
finishToolBox(_currentToolBox, { name: 'web_search', result: 'Suche abgeschlossen', isError: false, sources: data.sources });
}
_currentToolBox = null;
break;
case 'user_choice':
endStreamBubble();
addChoiceBubble(data.toolCallId, data.question, data.choices);
break;
case 'review_start':
endStreamBubble();
addReviewBubble('start', data);
break;
case 'review_text':
appendToReviewText(data.text);
break;
case 'review_tool_use':
addReviewToolUse(data);
break;
case 'review_feedback':
finalizeReviewBubble('feedback', data);
break;
case 'review_approved':
finalizeReviewBubble('approved', data);
break;
case 'finalizer_start':
endStreamBubble();
addReviewBubble('start', data, 'finalizer');
break;
case 'finalizer_text':
appendToReviewText(data.text);
break;
case 'finalizer_tool_use':
addReviewToolUse(data);
break;
case 'finalizer_done':
finalizeReviewBubble('done', data, 'finalizer');
break;
case 'error':
endStreamBubble();
addErrorBubble(data.error, true);
break;
case 'done':
endStreamBubble();
if (data.stopReason === 'max_tokens') {
addWarningBubble('Antwort wurde abgeschnitten (max_tokens erreicht). Bitte frage nach dem Rest.', true);
}
_lastDoneChainData = null;
if (data.model) {
_lastDoneChainData = data;
} else if (data.chainId) {
apiGetChain(data.chainId).then(chain => {
if (chain) _lastDoneChainData = chain;
}).catch(() => {});
}
setMeta('Bereit');
break;
}
}
// ── Chat State ──
let isStreaming = false;
@@ -34,6 +144,7 @@ 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
let _abortController = null; // AbortController für laufenden Stream
@@ -138,7 +249,7 @@ async function sendMessage() {
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming...</span>');
let fullText = '';
let currentToolBox = null;
_currentToolBox = null;
_chatAbandoned = false;
@@ -164,116 +275,7 @@ async function sendMessage() {
let data;
try { data = JSON.parse(line.substring(6)); } catch { continue; }
switch (eventType) {
case 'text_start':
// Wenn bereits eine Bubble offen ist (z.B. nach Web-Search-Pause),
// einfach weiter reinschreiben – appendToStream kümmert sich darum.
// Nur wenn keine Bubble existiert, wird eine neue erstellt.
if (!_streamDiv) startStreamBubble();
break;
case 'text_delta':
appendToStream(data.text);
fullText += data.text;
break;
case 'text_end':
// Nur Cursor entfernen, Bubble offen lassen
// (nach Web-Search kommt evtl. noch ein text_start in dieselbe Bubble)
pauseStreamBubble();
break;
case 'tool_start':
endStreamBubble();
if (isToolVisible(data.name)) {
currentToolBox = addToolBoxLoading(data.name);
} else {
currentToolBox = null;
}
break;
case 'tool_end':
if (currentToolBox) {
finishToolBox(currentToolBox, data);
}
currentToolBox = null;
break;
case 'web_search_start':
// Bubble pausieren, nicht beenden – Text wird nach Search fortgesetzt
pauseStreamBubble();
currentToolBox = addToolBoxLoading('web_search');
break;
case 'web_search_end':
if (currentToolBox) {
finishToolBox(currentToolBox, { name: 'web_search', result: 'Suche abgeschlossen', isError: false, sources: data.sources });
}
currentToolBox = null;
break;
case 'review_start':
endStreamBubble();
addReviewBubble('start', data);
break;
case 'review_text':
appendToReviewText(data.text);
break;
case 'review_tool_use':
addReviewToolUse(data);
break;
case 'review_feedback':
finalizeReviewBubble('feedback', data);
break;
case 'review_approved':
finalizeReviewBubble('approved', data);
break;
case 'finalizer_start':
endStreamBubble();
addReviewBubble('start', data, 'finalizer');
break;
case 'finalizer_text':
appendToReviewText(data.text);
break;
case 'finalizer_tool_use':
addReviewToolUse(data);
break;
case 'finalizer_done':
finalizeReviewBubble('done', data, 'finalizer');
break;
case 'error':
endStreamBubble();
addErrorBubble(data.error, true);
break;
case 'done':
endStreamBubble();
if (data.stopReason === 'max_tokens') {
addWarningBubble('Antwort wurde abgeschnitten (max_tokens erreicht). Bitte frage nach dem Rest.', true);
}
// Chain-Meta nur merken, nicht sofort anzeigen –
// wird erst im finally nach pollHistory eingefügt
_lastDoneChainData = null;
if (data.model) {
_lastDoneChainData = data;
} else if (data.chainId) {
try {
const chain = await apiGetChain(data.chainId);
if (chain) _lastDoneChainData = chain;
} catch (_) {}
}
setMeta('Bereit');
break;
}
handleStreamEvent(eventType, data);
eventType = null;
}
}
@@ -323,6 +325,96 @@ async function sendMessage() {
}
}
// ── Choice Click Handler ──
async function handleChoiceClick(toolCallId, choice, bubbleDiv) {
// Buttons deaktivieren und gewählte Option markieren
const buttons = bubbleDiv.querySelectorAll('.choice-btn');
buttons.forEach(btn => {
btn.disabled = true;
if (btn.textContent === choice) {
btn.classList.add('choice-selected');
} else {
btn.classList.add('choice-unselected');
}
});
bubbleDiv.classList.add('choice-answered');
// Stream starten mit Choice-Response
const sid = getCurrentSessionId();
const sendBtn = $('#send');
const stopBtn = $('#stop');
sendBtn.disabled = true;
sendBtn.hidden = true;
stopBtn.hidden = false;
isStreaming = true;
_abortController = new AbortController();
_currentToolBox = null;
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming...</span>');
_chatAbandoned = false;
try {
const resp = await apiChatChoice(sid, toolCallId, choice, _abortController.signal);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let eventType = null;
while (true) {
const { done, value } = await reader.read();
if (done || _chatAbandoned) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.substring(7).trim();
} else if (line.startsWith('data: ') && eventType) {
let data;
try { data = JSON.parse(line.substring(6)); } catch { continue; }
handleStreamEvent(eventType, data);
eventType = null;
}
}
}
} catch (e) {
if (_chatAbandoned) return;
endStreamBubble();
if (e.name === 'AbortError') {
setMeta('Abgebrochen');
await new Promise(r => setTimeout(r, 500));
} else {
try {
const status = await apiIsStreaming(sid);
if (status.streaming) {
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming... (Verbindung wiederhergestellt)</span>');
startStreamingPoll(sid, sendBtn, status.chainId);
return;
}
} catch (_) {}
addErrorBubble('Netzwerk-Fehler: ' + e.message);
}
} finally {
if (_chatAbandoned) return;
if (!isStreaming || !_streamingPollInterval) {
sendBtn.disabled = false;
sendBtn.hidden = false;
stopBtn.hidden = true;
isStreaming = false;
_abortController = null;
}
endStreamBubble();
removeStreamedElements();
await pollHistory();
if (_lastDoneChainData) {
addChainMetaBubble(_lastDoneChainData, false);
_lastDoneChainData = null;
}
}
}
// ── File Handling ──
function handleFileSelect(file) {