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

+70 -23
View File
@@ -24,10 +24,10 @@ public static class ChatEndpoint
private static async Task HandleChat(HttpRequest req, HttpResponse res, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, CancellationToken cancellationToken)
{
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
if (body == null || body.Content.ValueKind == JsonValueKind.Undefined)
if (body == null)
{
res.StatusCode = 400;
await res.WriteAsJsonAsync(new { error = "content required" });
await res.WriteAsJsonAsync(new { error = "body required" });
return;
}
@@ -39,6 +39,16 @@ public static class ChatEndpoint
return;
}
// Prüfe ob dies eine Choice-Antwort ist (ask_user_choice response)
var isChoiceResponse = !string.IsNullOrEmpty(body.ChoiceToolCallId) && !string.IsNullOrEmpty(body.ChoiceResult);
if (!isChoiceResponse && body.Content.ValueKind == JsonValueKind.Undefined)
{
res.StatusCode = 400;
await res.WriteAsJsonAsync(new { error = "content required" });
return;
}
var userId = req.HttpContext.GetUserId();
var maxIterations = config.GetValue<int>("MaxIterations", 100);
@@ -50,42 +60,79 @@ public static class ChatEndpoint
return;
}
// Neue User-Message aus Request-Content aufbereiten und speichern
var userMessage = new ChatMessage { Role = "user", Content = body.Content };
var textContent = userMessage.GetTextContent();
var attachmentBlocks = userMessage.GetAttachmentBlocks();
var attachmentRecords = attachmentBlocks.Select(b => new AttachmentRecord
if (isChoiceResponse)
{
Type = b.Type,
MediaType = b.MediaType,
FileName = b.FileName,
Data = b.Data
}).ToList();
await db.SaveMessageWithAttachments(new MessageRecord
// Choice-Antwort: tool_result in die VORHERIGE Chain speichern (wo der tool_use liegt)
// Damit BuildHistoryFromDb die tool_use + tool_result korrekt zusammenbauen kann.
var previousChainId = await chatService.FindChainWithToolCall(sessionId, body.ChoiceToolCallId);
if (previousChainId != null)
{
await db.SaveMessage(new MessageRecord
{
ChainId = previousChainId,
Iteration = 999, // Hohe Iteration damit es nach dem tool_use kommt
Role = "user",
Type = "tool_result",
ToolCallId = body.ChoiceToolCallId,
Content = body.ChoiceResult
});
}
else
{
// Fallback: In aktuelle Chain speichern
await db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 0,
Role = "user",
Type = "tool_result",
ToolCallId = body.ChoiceToolCallId,
Content = body.ChoiceResult
});
}
}
else
{
ChainId = ctx.ChainId,
Iteration = 0,
Role = "user",
Type = "text",
Content = textContent
}, attachmentRecords);
// Normale User-Message aufbereiten und speichern
var userMessage = new ChatMessage { Role = "user", Content = body.Content };
var textContent = userMessage.GetTextContent();
var attachmentBlocks = userMessage.GetAttachmentBlocks();
// History komplett aus DB aufbauen (inkl. gerade gespeicherter Message)
var attachmentRecords = attachmentBlocks.Select(b => new AttachmentRecord
{
Type = b.Type,
MediaType = b.MediaType,
FileName = b.FileName,
Data = b.Data
}).ToList();
await db.SaveMessageWithAttachments(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 0,
Role = "user",
Type = "text",
Content = textContent
}, attachmentRecords);
}
// History komplett aus DB aufbauen (inkl. gerade gespeicherter Message/tool_result)
var messages = await chatService.BuildHistoryFromDb(sessionId);
res.Headers["Content-Type"] = "text/event-stream";
res.Headers["Cache-Control"] = "no-cache";
res.Headers["Connection"] = "keep-alive";
// Bei Choice-Response: toolChoice auf "auto" setzen, damit Claude frei antworten kann
var effectiveToolChoice = isChoiceResponse ? "auto" : ctx.ToolChoice;
// CancellationToken: Verknüpft Stop-Button (über StreamingTracker) + Client-Disconnect
var stopCts = streamingTracker.Start(sessionId, ctx.ChainId);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, stopCts.Token);
try
{
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: ctx.ToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, cancellationToken: linkedCts.Token);
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, cancellationToken: linkedCts.Token);
}
finally
{
+13
View File
@@ -131,4 +131,17 @@ public class ChatRequest
[JsonPropertyName("sessionId")]
public string SessionId { get; set; } = "";
/// <summary>
/// Wenn gesetzt: Dies ist eine Antwort auf ein ask_user_choice Tool.
/// Enthält die tool_use_id des offenen ask_user_choice-Calls.
/// </summary>
[JsonPropertyName("choiceToolCallId")]
public string ChoiceToolCallId { get; set; }
/// <summary>
/// Der gewählte Text aus den Auswahloptionen.
/// </summary>
[JsonPropertyName("choiceResult")]
public string ChoiceResult { get; set; }
}
+35 -1
View File
@@ -1141,6 +1141,38 @@ public class AnthropicService
input = inputEl
});
// ask_user_choice: NICHT ausführen, sondern als SSE-Event an Client senden
if (currentToolName == ToolService.AskUserChoiceToolName)
{
var question = inputEl.TryGetProperty("question", out var q) ? q.GetString() ?? "" : "";
var choices = new List<string>();
if (inputEl.TryGetProperty("choices", out var c))
{
if (c.ValueKind == JsonValueKind.Array)
{
foreach (var ch in c.EnumerateArray())
choices.Add(ch.GetString() ?? "");
}
else if (c.ValueKind == JsonValueKind.String)
{
// Fallback: Claude hat einen einzelnen String statt Array gesendet
choices.Add(c.GetString() ?? "");
}
}
await WriteSseEvent(response, "user_choice", new
{
toolCallId = currentToolId,
question,
choices
}, cancellationToken);
// Stream restlich konsumieren ohne weitere Verarbeitung
// (Claude sendet nach dem tool_use noch message_delta mit stop_reason)
stopReason = "awaiting_user_choice";
continue;
}
// Cancellation prüfen bevor Tool ausgeführt wird
cancellationToken.ThrowIfCancellationRequested();
@@ -1193,7 +1225,9 @@ public class AnthropicService
{
if (d.TryGetProperty("stop_reason", out var sr))
{
stopReason = sr.GetString() ?? "";
// Nicht überschreiben wenn bereits auf awaiting_user_choice gesetzt
if (stopReason != "awaiting_user_choice")
stopReason = sr.GetString() ?? "";
// Bei problematischen stop_reasons Debug-Infos sammeln
if (stopReason is "unexpected_end" or "max_tokens")
+38 -3
View File
@@ -197,6 +197,25 @@ public class ChatService
};
}
/// <summary>
/// Findet die Chain-ID die einen bestimmten tool_use-Call enthält.
/// Wird genutzt um bei ask_user_choice das tool_result in die richtige Chain zu speichern.
/// </summary>
public async Task<string> FindChainWithToolCall(string sessionId, string toolCallId)
{
var chains = (await _db.GetActiveChains(sessionId)).ToList();
foreach (var chain in chains)
{
var messages = await _db.GetChainMessages(chain.ChainId);
foreach (var msg in messages)
{
if (msg.Role == "assistant" && msg.Type == "tool_use" && msg.ToolCallId == toolCallId)
return chain.ChainId;
}
}
return null;
}
/// <summary>
/// Baut die Message-History aus der DB (aktive Chains).
/// Optimiert: Nur Text-Messages (user/assistant) für ältere Chains,
@@ -212,6 +231,21 @@ public class ChatService
var isLastChain = false;
long? lastUserMsgId = null;
// Bestimme welche Chain als "vollständige letzte Chain" gilt.
// Wenn die letzte Chain leer oder nur tool_result enthält (z.B. nach ask_user_choice),
// dann muss die vorletzte Chain vollständig rekonstruiert werden.
int fullChainStartIndex = chainsList.Count - 1;
if (chainsList.Count >= 2)
{
var lastChainMsgs = (await _db.GetChainMessages(chainsList[chainsList.Count - 1].ChainId)).ToList();
if (lastChainMsgs.Count == 0 ||
(lastChainMsgs.All(m => m.Type == "tool_result") && lastChainMsgs.Count <= 2))
{
// Vorletzte Chain hatte einen ask_user_choice → beide Chains vollständig laden
fullChainStartIndex = chainsList.Count - 2;
}
}
for (int i = 0; i < chainsList.Count; i++)
{
var chain = chainsList[i];
@@ -220,14 +254,15 @@ public class ChatService
var msgs = (await _db.GetChainMessages(chain.ChainId)).ToList();
// Letzte User-Message-ID der letzten Chain merken (für Attachments)
if (isLastChain)
if (isLastChain || i == fullChainStartIndex)
{
var lastUserMsg = msgs.LastOrDefault(m => m.Role == "user" && m.Type == "text");
lastUserMsgId = lastUserMsg?.Id;
if (lastUserMsg != null)
lastUserMsgId = lastUserMsg.Id;
}
// Für ältere Chains: nur Text-Messages (wie Frontend es vorher gemacht hat)
if (!isLastChain)
if (i < fullChainStartIndex)
{
foreach (var msg in msgs)
{
+30
View File
@@ -15,9 +15,13 @@ namespace Gott.Services;
public class ToolService
{
public const string AskUserChoiceToolName = "ask_user_choice";
public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false)
{
var allTools = new List<object>();
// ask_user_choice ist immer verfügbar wenn Tools aktiv sind
allTools.Add(GetAskUserChoiceTool());
if (includeFileTools)
allTools.AddRange(GetFileTools());
if (includeMemory)
@@ -25,6 +29,32 @@ public class ToolService
return allTools.ToArray();
}
private object GetAskUserChoiceTool()
{
return new
{
name = AskUserChoiceToolName,
description = "Stellt dem User eine Frage mit Auswahlmöglichkeiten, wenn du unsicher bist oder es mehrere sinnvolle Wege gibt. Der User wählt eine Option und du arbeitest mit seiner Antwort weiter. Nutze dies sparsam – nur wenn die Entscheidung wirklich vom User getroffen werden sollte.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["question"] = new { type = "string", description = "Die Frage an den User" },
["choices"] = new
{
type = "array",
description = "Liste der Auswahlmöglichkeiten (2-5 Optionen). MUSS ein JSON-Array mit Strings sein, z.B. [\"Option A\", \"Option B\", \"Option C\"]. Niemals einen einzelnen String verwenden.",
items = new { type = "string", description = "Eine einzelne Auswahlmöglichkeit als kurzer, prägnanter Text" },
minItems = 2,
maxItems = 5
}
},
required = new[] { "question", "choices" }
}
};
}
private object[] GetFileTools()
{
return new object[]
+69
View File
@@ -1134,3 +1134,72 @@
.review-iterations-row input[type="number"] {
width: 5em;
}
/* ── Choice Bubbles (ask_user_choice) ── */
.choice-bubble {
align-self: flex-start;
background: var(--f1-bg);
border: var(--border-width) solid var(--f1-border);
border-left: 3px solid var(--primary);
max-width: 80%;
display: flex;
flex-direction: column;
gap: var(--box-spacing);
}
.choice-question {
font-size: 0.95em;
}
.choice-question p {
margin: 0;
}
.choice-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--badge-spacing);
}
.choice-btn {
padding: 0.45em 1em;
border: var(--border-width) solid var(--primary);
border-radius: var(--border-radius);
background: transparent;
color: var(--primary);
font-size: 0.9em;
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
}
.choice-btn:hover:not(:disabled) {
background: var(--primary);
color: var(--primary-text);
}
.choice-btn:active:not(:disabled) {
transform: scale(0.97);
}
.choice-btn:disabled {
cursor: default;
}
.choice-btn.choice-selected {
background: var(--primary);
color: var(--primary-text);
border-color: var(--primary);
font-weight: 600;
}
.choice-btn.choice-unselected {
opacity: 0.4;
border-color: var(--f1-border);
color: var(--muted);
}
.choice-answered .choice-question {
opacity: 0.7;
}
+9
View File
@@ -50,6 +50,15 @@ async function apiChat(content, sessionId, signal) {
});
}
async function apiChatChoice(sessionId, choiceToolCallId, choiceResult, signal) {
return fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, choiceToolCallId, choiceResult }),
signal
});
}
// ── Agent API ──
async function apiGetAgents() {
+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) {
+87 -2
View File
@@ -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;
+74
View File
@@ -663,6 +663,80 @@ function addWarningBubble(text, streamed = false) {
scrollDown();
}
// ── Choice Bubbles (ask_user_choice) ──
// Wird live aus dem SSE-Stream aufgerufen
function addChoiceBubble(toolCallId, question, choices, streamed = true) {
// Guard: choices muss ein Array sein
if (!Array.isArray(choices)) choices = [String(choices)];
const div = document.createElement('div');
div.className = 'msg choice-bubble';
if (streamed) div.classList.add('streamed');
div.dataset.toolCallId = toolCallId;
const questionDiv = document.createElement('div');
questionDiv.className = 'choice-question';
questionDiv.innerHTML = renderMarkdown(question);
parseFluentEmojis(questionDiv);
div.appendChild(questionDiv);
const buttonsDiv = document.createElement('div');
buttonsDiv.className = 'choice-buttons';
for (const choice of choices) {
const btn = document.createElement('button');
btn.className = 'choice-btn';
btn.textContent = choice;
btn.addEventListener('click', () => handleChoiceClick(toolCallId, choice, div));
buttonsDiv.appendChild(btn);
}
div.appendChild(buttonsDiv);
chatEl().appendChild(div);
scrollDown();
return div;
}
// Wird für History-Rendering aufgerufen (bereits beantwortete Choices)
function addChoiceBubbleStatic(toolCallId, question, choices, selectedChoice, timestamp, messageId) {
// Guard: choices muss ein Array sein
if (!Array.isArray(choices)) choices = [String(choices)];
const div = document.createElement('div');
div.className = 'msg choice-bubble choice-answered';
div.dataset.toolCallId = toolCallId;
if (messageId) div.dataset.messageId = messageId;
const questionDiv = document.createElement('div');
questionDiv.className = 'choice-question';
questionDiv.innerHTML = renderMarkdown(question);
parseFluentEmojis(questionDiv);
div.appendChild(questionDiv);
const buttonsDiv = document.createElement('div');
buttonsDiv.className = 'choice-buttons';
for (const choice of choices) {
const btn = document.createElement('button');
btn.className = 'choice-btn';
btn.disabled = true;
btn.textContent = choice;
if (choice === selectedChoice) {
btn.classList.add('choice-selected');
} else {
btn.classList.add('choice-unselected');
}
buttonsDiv.appendChild(btn);
}
div.appendChild(buttonsDiv);
if (timestamp) _addTimestamp(div, timestamp);
chatEl().appendChild(div);
scrollDown();
return div;
}
// handleChoiceClick ist in chat.js definiert (benötigt Stream-Infrastruktur)
// ── Review Bubbles ──
function addReviewBubbleStatic(text, timestamp, messageId, isMarked, deletedOn) {