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)