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
+87
-2
@@ -175,7 +175,8 @@ function _mergeConsecutiveAssistantTexts(msgs) {
|
||||
|
||||
function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) {
|
||||
msgs = _mergeConsecutiveAssistantTexts(msgs);
|
||||
for (const msg of msgs) {
|
||||
for (var _msgIdx = 0; _msgIdx < msgs.length; _msgIdx++) {
|
||||
const msg = msgs[_msgIdx];
|
||||
const div = document.createElement('div');
|
||||
switch (msg.type) {
|
||||
case 'text': {
|
||||
@@ -241,6 +242,62 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) {
|
||||
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);
|
||||
@@ -258,6 +315,8 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) {
|
||||
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')) {
|
||||
@@ -283,7 +342,8 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) {
|
||||
|
||||
function renderMessages(msgs, addToConversation) {
|
||||
msgs = _mergeConsecutiveAssistantTexts(msgs);
|
||||
for (const msg of msgs) {
|
||||
for (var _rmIdx = 0; _rmIdx < msgs.length; _rmIdx++) {
|
||||
const msg = msgs[_rmIdx];
|
||||
if (msg.id > lastKnownMessageId) lastKnownMessageId = msg.id;
|
||||
|
||||
switch (msg.type) {
|
||||
@@ -308,6 +368,30 @@ function renderMessages(msgs, addToConversation) {
|
||||
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);
|
||||
}
|
||||
@@ -323,6 +407,7 @@ function renderMessages(msgs, addToConversation) {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user