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:
1 parent
72347ed2f5
commit
5f3e308975
10 files changed
+628
-140
No files matched your search
+203
-111
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user