diff --git a/Endpoints/InternalEndpoint.cs b/Endpoints/InternalEndpoint.cs index 81e6d62..6b164b8 100644 --- a/Endpoints/InternalEndpoint.cs +++ b/Endpoints/InternalEndpoint.cs @@ -24,16 +24,19 @@ public static class InternalEndpoint public static string InternalToken { get; } = Guid.NewGuid().ToString("N"); /// - /// Pending user choices – Key ist sessionId, Value wird completed wenn der User klickt. + /// Beantwortete User-Choices – Key ist sessionId, Value ist die gewählte Option. + /// MCP-Server pollt dieses Dictionary (Short-Poll statt Long-Poll). /// - public static readonly ConcurrentDictionary> PendingChoices = new(); + public static readonly ConcurrentDictionary ChoiceAnswers = new(); public static void MapInternalEndpoints(this IEndpointRouteBuilder app) { app.MapPost("/api/internal/remote-execute", HandleRemoteExecute); - app.MapPost("/api/internal/ask-user-choice", HandleAskUserChoice); + // WICHTIG: Expliziter Rückgabetyp Task nötig! Ohne ihn interpretiert ASP.NET den Lambda als + // RequestDelegate (Func) → IResult-Rückgabe wird ignoriert → 200 empty body. + app.MapPost("/api/internal/ask-user-choice", async Task (HttpContext ctx) => await HandleAskUserChoice(ctx)); // choice-respond ist NICHT unter /api/internal/ weil es vom Frontend aufgerufen wird (normale Auth) - app.MapPost("/api/chat/choice-respond", HandleChoiceRespond); + app.MapPost("/api/chat/choice-respond", async Task (HttpContext ctx) => await HandleChoiceRespond(ctx)); app.MapPost("/api/internal/memory-read", HandleMemoryRead); app.MapPost("/api/internal/memory-write", HandleMemoryWrite); app.MapPost("/api/internal/memory-list", HandleMemoryList); @@ -85,8 +88,8 @@ public static class InternalEndpoint } /// - /// MCP-Server ruft diesen Endpoint auf und blockiert bis der User eine Auswahl trifft. - /// Der TCS wird vorher von AnthropicService erstellt (beim tool_use Event). + /// MCP-Server pollt diesen Endpoint bis der User eine Auswahl getroffen hat. + /// Gibt 200 mit { choice } zurück wenn beantwortet, 404 wenn noch ausstehend. /// private static async Task HandleAskUserChoice(HttpContext ctx) { @@ -105,34 +108,12 @@ public static class InternalEndpoint if (string.IsNullOrEmpty(sessionId)) return Results.BadRequest(new { error = "sessionId erforderlich" }); - // Warte auf den TCS der von AnthropicService erstellt wurde - if (!PendingChoices.TryGetValue(sessionId, out var tcs)) - { - // TCS noch nicht erstellt (Race Condition) – kurz warten und nochmal probieren - for (int i = 0; i < 10; i++) - { - await Task.Delay(500, ctx.RequestAborted); - if (PendingChoices.TryGetValue(sessionId, out tcs)) break; - } - if (tcs == null) - return Results.Json(new { error = "Keine ausstehende Auswahl für diese Session" }, statusCode: 404); - } - - // Blockieren bis User antwortet (max 5 Minuten) - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted); - cts.CancelAfter(TimeSpan.FromMinutes(5)); - - try - { - var choice = await tcs.Task.WaitAsync(cts.Token); - PendingChoices.TryRemove(sessionId, out _); + // Prüfen ob bereits eine Antwort vorliegt (Short-Poll) + if (ChoiceAnswers.TryRemove(sessionId, out var choice)) return Results.Ok(new { choice }); - } - catch (OperationCanceledException) - { - PendingChoices.TryRemove(sessionId, out _); - return Results.Json(new { error = "Timeout – keine Auswahl getroffen" }, statusCode: 408); - } + + // Noch keine Antwort – MCP-Server soll erneut pollen + return Results.Json(new { status = "pending" }, statusCode: 202); } catch (Exception ex) { @@ -142,6 +123,7 @@ public static class InternalEndpoint /// /// Frontend ruft diesen Endpoint auf wenn der User einen Choice-Button klickt (CLI-Modus). + /// Speichert die Antwort in ChoiceAnswers – MCP-Server holt sie beim nächsten Poll ab. /// private static async Task HandleChoiceRespond(HttpContext ctx) { @@ -162,13 +144,9 @@ public static class InternalEndpoint if (string.IsNullOrEmpty(choice)) return Results.BadRequest(new { error = "choice erforderlich" }); - if (PendingChoices.TryGetValue(sessionId, out var tcs)) - { - tcs.TrySetResult(choice); - return Results.Ok(new { success = true }); - } - - return Results.NotFound(new { error = "Keine ausstehende Auswahl für diese Session" }); + // Antwort speichern – MCP-Server holt sie beim nächsten Poll ab + ChoiceAnswers[sessionId] = choice; + return Results.Ok(new { success = true }); } catch (Exception ex) { diff --git a/Services/AnthropicKeyChecker.cs b/Services/AnthropicKeyChecker.cs index 6d6e652..12c8802 100644 --- a/Services/AnthropicKeyChecker.cs +++ b/Services/AnthropicKeyChecker.cs @@ -25,7 +25,7 @@ namespace Gott.Services public static class AnthropicKeyChecker { - private const string CheckModel = "claude-sonnet-4-20250514"; + private const string CheckModel = "claude-haiku-4-5"; //"claude-sonnet-4-20250514"; public static async Task CheckKeyAsync(IHttpClientFactory httpFactory, string apiKey) { diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index b503b6c..a56ec8c 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -1950,6 +1950,7 @@ public class AnthropicService var fullText = new StringBuilder(); var tempImagePaths = new List(); string mcpConfigPath = null; + bool isUserChoice = false; var chain = new ChainRecord { @@ -1996,7 +1997,6 @@ public class AnthropicService prompt = $"\n{historyContext}\n\n\n\n{prompt}\n"; } - bool isUserChoice = false; string choiceToolCallId = null; var result = await _cliService.ChatStreamingAsync( @@ -2063,8 +2063,8 @@ public class AnthropicService foreach (var cOpt in cc.EnumerateArray()) cChoices.Add(cOpt.GetString() ?? ""); - // TCS erstellen bevor der MCP-Server den Endpoint aufruft - InternalEndpoint.PendingChoices[sessionId] = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + // Alte Antwort entfernen (falls vorhanden) + InternalEndpoint.ChoiceAnswers.TryRemove(sessionId, out _); // SSE Event mit viaCli-Flag → Frontend zeigt Buttons + nutzt choice-respond Endpoint choiceToolCallId = evt.ToolId; @@ -2133,8 +2133,8 @@ public class AnthropicService await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {errorMsg}" }, sseCt); } - // Antwort in DB speichern (nur wenn nicht schon im UserChoice-Handler gespeichert) - if (!isUserChoice && fullText.Length > 0) + // Antwort in DB speichern + if (fullText.Length > 0) { await _db.SaveMessage(new MessageRecord { @@ -2173,13 +2173,16 @@ public class AnthropicService catch (OperationCanceledException) when (workCt.IsCancellationRequested) { // Stop-Button gedrückt → CLI-Prozess wurde gestoppt - await FinalizeChain(chain, usage, 1, "stopped", sw); + // Wenn ask_user_choice aktiv war: als awaiting_user_choice markieren, + // damit die nächste Chain den Kontext (Frage + Optionen) behält. + var stoppedReason = isUserChoice ? "awaiting_user_choice" : "stopped"; + await FinalizeChain(chain, usage, 1, stoppedReason, sw); await SseHelper.TryWriteSse(response, "done", new { model = modelId, chainId, durationMs = sw.ElapsedMilliseconds, - stopReason = "stopped", + stopReason = stoppedReason, viaCli = true }, CancellationToken.None); } diff --git a/Services/SystemPromptBuilder.cs b/Services/SystemPromptBuilder.cs index f30e360..72522b4 100644 --- a/Services/SystemPromptBuilder.cs +++ b/Services/SystemPromptBuilder.cs @@ -89,7 +89,7 @@ Alle Dateipfade in Tool-Aufrufen müssen relativ zu diesem Workspace sein oder a { Icon = "fa-question-circle", Label = "Agent · Rückfragen (AllowUserChoice)", - Content = "## Rückfragen\nWenn du dir bei einer Aufgabe unsicher bist oder es mehrere sinnvolle Wege gibt, nutze das `ask_user_choice`-Tool um dem User eine Auswahlliste zu präsentieren. Nutze es immer, wenn eine Entscheidung vom User getroffen werden soll." + Content = "## Rückfragen\nWenn du dem User Optionen, Auswahlmöglichkeiten oder Fragen mit mehreren Antwortmöglichkeiten präsentierst, nutze IMMER das `ask_user_choice`-Tool (bzw. `mcp__gott-remote__ask_user_choice`). Das gilt für:\n- Rückfragen bei Unsicherheit oder mehreren sinnvollen Wegen\n- Quiz-Fragen, Multiple-Choice-Fragen\n- Jede Situation in der der User zwischen Optionen wählen soll\n\nNutze NIEMALS A/B/C/D-Aufzählungen oder nummerierte Listen als Ersatz – IMMER das Tool verwenden." }); } diff --git a/scripts/mcp-remote-execute.py b/scripts/mcp-remote-execute.py index 727c902..9b74b5d 100644 --- a/scripts/mcp-remote-execute.py +++ b/scripts/mcp-remote-execute.py @@ -217,7 +217,8 @@ def handle_memory_list(req_id, arguments): def handle_ask_user_choice(req_id, arguments): - """Stellt dem User eine Frage und wartet auf Antwort (long-poll).""" + """Stellt dem User eine Frage und wartet auf Antwort (short-poll).""" + import time question = arguments.get("question", "") choices = arguments.get("choices", []) if not question: @@ -225,23 +226,29 @@ def handle_ask_user_choice(req_id, arguments): if not choices or len(choices) < 2: return make_result(req_id, "Fehler: Mindestens 2 choices erforderlich", True) - # Long-Poll: Blockiert bis der User antwortet (max 5 Min) - # Der Backend-Endpoint /api/internal/ask-user-choice blockiert selbst - result = api_call_long("ask-user-choice", {"sessionId": SESSION_ID}) - choice = result.get("choice", "") - return make_result(req_id, f"User hat gewählt: {choice}") + # Short-Poll: Jede Sekunde beim Backend nachfragen ob der User geantwortet hat + for i in range(300): # max 5 Minuten + try: + result = api_call("ask-user-choice", {"sessionId": SESSION_ID}) + # 200 = Antwort vorhanden + if "choice" in result: + return make_result(req_id, f"User hat gewählt: {result['choice']}") + except urllib.error.HTTPError as e: + if e.code == 202: + # 202 = noch ausstehend, weiter pollen + pass + else: + body = "" + try: + body = e.read().decode("utf-8") + except Exception: + pass + return make_result(req_id, f"API-Fehler {e.code}: {body}", True) + except Exception as e: + return make_result(req_id, f"Fehler: {str(e)}", True) + time.sleep(1) - -def api_call_long(endpoint, payload): - """API-Call mit langem Timeout (für Long-Poll Endpoints).""" - url = f"{API_URL}/api/internal/{endpoint}" - data = json.dumps(payload).encode("utf-8") - req = urllib.request.Request(url, data=data, method="POST") - req.add_header("Content-Type", "application/json") - if AUTH_TOKEN: - req.add_header("X-Internal-Token", AUTH_TOKEN) - with urllib.request.urlopen(req, timeout=330) as resp: - return json.loads(resp.read().decode("utf-8")) + return make_result(req_id, "Timeout – keine Auswahl getroffen (5 Minuten)", True) TOOL_HANDLERS = { diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index 4dbe68d..38d2548 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -1280,6 +1280,80 @@ color: var(--muted); } +/* Freitext-Option */ +.choice-custom { + margin-top: var(--badge-spacing); +} + +.choice-custom-toggle { + font-size: 0.85em; + color: var(--muted); + text-decoration: none; +} + +.choice-custom-toggle:hover { + color: var(--primary); +} + +.choice-custom-row { + display: none; + gap: var(--badge-spacing); + align-items: center; + margin-top: var(--badge-spacing); +} + +.choice-custom-open .choice-custom-toggle { + display: none; +} + +.choice-custom-open .choice-custom-row { + display: flex; +} + +.choice-custom-input { + flex: 1; + border: var(--border-width) solid var(--f1-border); + border-radius: var(--border-radius); + background: var(--f1-bg); + color: var(--f1-text); + padding: 0.3em 0.6em; + font-size: 0.9em; +} + +.choice-custom-input:focus { + border-color: var(--primary); + outline: none; +} + +.choice-custom-send { + border: var(--border-width) solid var(--primary); + border-radius: var(--border-radius); + background: transparent; + color: var(--primary); + cursor: pointer; + padding: 0.3em 0.6em; + font-size: 0.9em; +} + +.choice-custom-send:hover { + background: var(--primary); + color: var(--f1-bg); +} + +.choice-answered .choice-custom { + display: none; +} + +.choice-answered .choice-custom.has-answer { + display: block; +} + +.choice-custom-answer { + font-size: 0.85em; + color: var(--primary); + font-style: italic; +} + .inline-choice-block { margin: var(--gap) 0; padding: var(--gap); diff --git a/wwwroot/js/bubbles.js b/wwwroot/js/bubbles.js index c003cbf..deb0383 100644 --- a/wwwroot/js/bubbles.js +++ b/wwwroot/js/bubbles.js @@ -215,6 +215,48 @@ function _createChoiceBubble(opts) { } div.appendChild(buttonsDiv); + // Freitext-Option (nur wenn noch nicht beantwortet) + if (!answered) { + const customDiv = document.createElement('div'); + customDiv.className = 'choice-custom'; + + const toggleLink = document.createElement('a'); + toggleLink.className = 'choice-custom-toggle'; + toggleLink.href = '#'; + toggleLink.textContent = 'Eigene Antwort…'; + toggleLink.addEventListener('click', function(e) { + e.preventDefault(); + customDiv.classList.add('choice-custom-open'); + customDiv.querySelector('.choice-custom-input').focus(); + }); + customDiv.appendChild(toggleLink); + + const inputRow = document.createElement('div'); + inputRow.className = 'choice-custom-row'; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'choice-custom-input'; + input.placeholder = 'Eigene Antwort eingeben…'; + + var sendCustom = function() { + var text = input.value.trim(); + if (!text) return; + handleChoiceClick(opts.toolCallId, text, div); + }; + input.addEventListener('keydown', function(e) { if (e.key === 'Enter') sendCustom(); }); + + var sendBtn = document.createElement('button'); + sendBtn.className = 'choice-custom-send'; + sendBtn.textContent = '↵'; + sendBtn.addEventListener('click', sendCustom); + + inputRow.appendChild(input); + inputRow.appendChild(sendBtn); + customDiv.appendChild(inputRow); + div.appendChild(customDiv); + } + if (opts.timestamp) _addTimestamp(div, opts.timestamp); return div; } diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index efba604..c1e2f26 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -415,14 +415,28 @@ async function sendMessageDirect(text) { async function handleChoiceClick(toolCallId, choice, bubbleDiv) { // Buttons deaktivieren und gewählte Option markieren const buttons = bubbleDiv.querySelectorAll('.choice-btn'); + var matchedButton = false; buttons.forEach(btn => { btn.disabled = true; if (btn.textContent === choice) { btn.classList.add('choice-selected'); + matchedButton = true; } else { btn.classList.add('choice-unselected'); } }); + // Freitext-Antwort: Keine der Buttons matched → Antwort als Text anzeigen + if (!matchedButton) { + var customDiv = bubbleDiv.querySelector('.choice-custom'); + if (customDiv) { + customDiv.innerHTML = ''; + customDiv.classList.add('has-answer'); + var answerSpan = document.createElement('span'); + answerSpan.className = 'choice-custom-answer'; + answerSpan.textContent = '↳ ' + choice; + customDiv.appendChild(answerSpan); + } + } bubbleDiv.classList.add('choice-answered'); // CLI-Modus: Antwort über internen Endpoint an MCP-Server weiterleiten (CLI läuft weiter)