From 5f3e308975230f9dcc73113e2acc9809b1c99209 Mon Sep 17 00:00:00 2001 From: Meleki Date: Sat, 7 Mar 2026 11:30:55 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20ask=5Fuser=5Fchoice=20Tool=20=E2=80=93?= =?UTF-8?q?=20interaktive=20Auswahlm=C3=B6glichkeiten=20im=20Chat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Endpoints/ChatEndpoint.cs | 93 ++++++++--- Models/ChatModels.cs | 13 ++ Services/AnthropicService.cs | 36 +++- Services/ChatService.cs | 41 ++++- Services/ToolService.cs | 30 ++++ wwwroot/css/chat.css | 69 ++++++++ wwwroot/js/api.js | 9 + wwwroot/js/chat.js | 314 ++++++++++++++++++++++------------- wwwroot/js/history.js | 89 +++++++++- wwwroot/js/ui.js | 74 +++++++++ 10 files changed, 628 insertions(+), 140 deletions(-) diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 6c9255a..6fcefc1 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -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(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("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 { diff --git a/Models/ChatModels.cs b/Models/ChatModels.cs index 347c96f..a13526b 100644 --- a/Models/ChatModels.cs +++ b/Models/ChatModels.cs @@ -131,4 +131,17 @@ public class ChatRequest [JsonPropertyName("sessionId")] public string SessionId { get; set; } = ""; + + /// + /// Wenn gesetzt: Dies ist eine Antwort auf ein ask_user_choice Tool. + /// Enthält die tool_use_id des offenen ask_user_choice-Calls. + /// + [JsonPropertyName("choiceToolCallId")] + public string ChoiceToolCallId { get; set; } + + /// + /// Der gewählte Text aus den Auswahloptionen. + /// + [JsonPropertyName("choiceResult")] + public string ChoiceResult { get; set; } } diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 4c327e5..51beea6 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -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(); + 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") diff --git a/Services/ChatService.cs b/Services/ChatService.cs index c20f3e8..f4b7550 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -197,6 +197,25 @@ public class ChatService }; } + /// + /// 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. + /// + public async Task 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; + } + /// /// 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) { diff --git a/Services/ToolService.cs b/Services/ToolService.cs index 12ff7f0..4aa2571 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -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(); + // 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 + { + ["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[] diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index e07009e..71303ab 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -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; +} diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 8c17a7a..437b242 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -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() { diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 14001f5..e6a9764 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -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(' Streaming...'); 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(' Streaming...'); + _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(' Streaming... (Verbindung wiederhergestellt)'); + 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) { diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index e7c4649..5b7d032 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -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; diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index 9d41cff..0459ba8 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -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) {