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
+70
-23
@@ -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
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user