From 3e0a69e7a403fb2536af06068fd9cd53df9033a1 Mon Sep 17 00:00:00 2001 From: Meleki Date: Wed, 18 Mar 2026 09:54:30 +0100 Subject: [PATCH] feat: MCP-Tools ask_user_choice + memory_read/write/list, Text-Marker entfernt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP-Server erweitert um ask_user_choice (Long-Poll), memory_read, memory_write und memory_list Tools. MCP-Config wird jetzt immer generiert (nicht nur bei Remote-Verbindung). InternalEndpoint um PendingChoices + choice-respond Endpoint ergänzt. Frontend differenziert CLI-Modus (choice-respond API) vs. API-Modus. Gesamter :::ASK_USER_CHOICE::: Text-Marker-Mechanismus aus ClaudeCliService, AnthropicService, markdown.js und stream-ui.js entfernt. Co-Authored-By: Claude Opus 4.6 --- Endpoints/ChatEndpoint.cs | 2 +- Endpoints/InternalEndpoint.cs | 205 ++++++++++++++++++++++- Services/AnthropicService.cs | 109 ++++++------- Services/ClaudeCliService.cs | 136 ++-------------- scripts/mcp-remote-execute.py | 298 +++++++++++++++++++++++----------- wwwroot/js/api.js | 4 + wwwroot/js/bubbles.js | 7 +- wwwroot/js/chat.js | 13 +- wwwroot/js/markdown.js | 28 ---- wwwroot/js/stream-ui.js | 10 +- 10 files changed, 493 insertions(+), 319 deletions(-) diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index d913c68..ab89b79 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -245,7 +245,7 @@ public static class ChatEndpoint { if (anthropic.IsCliAvailable) { - await anthropic.StreamChatViaCli(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, workspace: ctx.Workspace, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: body.PlanMode, webSearch: !string.IsNullOrEmpty(ctx.WebSearchVersion), sessionId: sessionId, remoteMgr: remoteMgr, workCt: workCt, sseCt: sseCt); + await anthropic.StreamChatViaCli(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, workspace: ctx.Workspace, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: body.PlanMode, webSearch: !string.IsNullOrEmpty(ctx.WebSearchVersion), sessionId: sessionId, agentId: ctx.AgentId, remoteMgr: remoteMgr, workCt: workCt, sseCt: sseCt); } else { diff --git a/Endpoints/InternalEndpoint.cs b/Endpoints/InternalEndpoint.cs index 4a73005..81e6d62 100644 --- a/Endpoints/InternalEndpoint.cs +++ b/Endpoints/InternalEndpoint.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Concurrent; using System.IO; using System.Linq; using System.Text.Json; @@ -19,11 +21,22 @@ public static class InternalEndpoint /// /// Interner Auth-Token – wird beim Start generiert und an MCP-Server übergeben. /// - public static string InternalToken { get; } = System.Guid.NewGuid().ToString("N"); + public static string InternalToken { get; } = Guid.NewGuid().ToString("N"); + + /// + /// Pending user choices – Key ist sessionId, Value wird completed wenn der User klickt. + /// + public static readonly ConcurrentDictionary> PendingChoices = new(); public static void MapInternalEndpoints(this IEndpointRouteBuilder app) { app.MapPost("/api/internal/remote-execute", HandleRemoteExecute); + app.MapPost("/api/internal/ask-user-choice", HandleAskUserChoice); + // 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/internal/memory-read", HandleMemoryRead); + app.MapPost("/api/internal/memory-write", HandleMemoryWrite); + app.MapPost("/api/internal/memory-list", HandleMemoryList); } private static async Task HandleRemoteExecute(HttpContext ctx, RemoteConnectionManager mgr) @@ -70,4 +83,194 @@ public static class InternalEndpoint return Results.Json(new { error = ex.Message, stackTrace = ex.StackTrace }, statusCode: 500); } } + + /// + /// 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). + /// + private static async Task HandleAskUserChoice(HttpContext ctx) + { + try + { + var denied = CheckToken(ctx); + if (denied != null) return denied; + + using var reader = new StreamReader(ctx.Request.Body); + var body = await reader.ReadToEndAsync(); + JsonElement json; + try { json = JsonSerializer.Deserialize(body); } + catch { return Results.BadRequest(new { error = "Invalid JSON" }); } + + var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() ?? "" : ""; + 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 _); + return Results.Ok(new { choice }); + } + catch (OperationCanceledException) + { + PendingChoices.TryRemove(sessionId, out _); + return Results.Json(new { error = "Timeout – keine Auswahl getroffen" }, statusCode: 408); + } + } + catch (Exception ex) + { + return Results.Json(new { error = ex.Message }, statusCode: 500); + } + } + + /// + /// Frontend ruft diesen Endpoint auf wenn der User einen Choice-Button klickt (CLI-Modus). + /// + private static async Task HandleChoiceRespond(HttpContext ctx) + { + try + { + // Kein Token-Check – wird über normale Auth-Middleware geschützt (Cookie-basiert) + using var reader = new StreamReader(ctx.Request.Body); + var body = await reader.ReadToEndAsync(); + JsonElement json; + try { json = JsonSerializer.Deserialize(body); } + catch { return Results.BadRequest(new { error = "Invalid JSON" }); } + + var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() ?? "" : ""; + var choice = json.TryGetProperty("choice", out var c) ? c.GetString() ?? "" : ""; + + if (string.IsNullOrEmpty(sessionId)) + return Results.BadRequest(new { error = "sessionId erforderlich" }); + 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" }); + } + catch (Exception ex) + { + return Results.Json(new { error = ex.Message }, statusCode: 500); + } + } + + private static IResult CheckToken(HttpContext ctx) + { + var token = ctx.Request.Headers["X-Internal-Token"].FirstOrDefault() ?? ""; + return token != InternalToken ? Results.StatusCode(403) : null; + } + + private static async Task HandleMemoryRead(HttpContext ctx, MemoryService memoryService) + { + try + { + var denied = CheckToken(ctx); + if (denied != null) return denied; + + using var reader = new StreamReader(ctx.Request.Body); + var body = await reader.ReadToEndAsync(); + JsonElement json; + try { json = JsonSerializer.Deserialize(body); } + catch { return Results.BadRequest(new { error = "Invalid JSON" }); } + + var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : ""; + var path = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""; + + if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" }); + if (string.IsNullOrEmpty(path)) return Results.BadRequest(new { error = "path erforderlich" }); + + var content = memoryService.ReadMemoryFile(agentId, path); + if (content == null) + return Results.NotFound(new { error = $"Datei nicht gefunden: {path}" }); + + return Results.Ok(new { content }); + } + catch (System.Exception ex) + { + return Results.Json(new { error = ex.Message }, statusCode: 500); + } + } + + private static async Task HandleMemoryWrite(HttpContext ctx, MemoryService memoryService) + { + try + { + var denied = CheckToken(ctx); + if (denied != null) return denied; + + using var reader = new StreamReader(ctx.Request.Body); + var body = await reader.ReadToEndAsync(); + JsonElement json; + try { json = JsonSerializer.Deserialize(body); } + catch { return Results.BadRequest(new { error = "Invalid JSON" }); } + + var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : ""; + var path = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""; + var content = json.TryGetProperty("content", out var c) ? c.GetString() ?? "" : ""; + var append = json.TryGetProperty("append", out var ap) && ap.GetBoolean(); + + if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" }); + if (string.IsNullOrEmpty(path)) return Results.BadRequest(new { error = "path erforderlich" }); + + if (append) + memoryService.AppendMemoryFile(agentId, path, content); + else + memoryService.WriteMemoryFile(agentId, path, content); + + return Results.Ok(new { success = true, message = $"Memory-Datei {(append ? "erweitert" : "geschrieben")}: {path} ({content.Length} Zeichen)" }); + } + catch (System.Exception ex) + { + return Results.Json(new { error = ex.Message }, statusCode: 500); + } + } + + private static async Task HandleMemoryList(HttpContext ctx, MemoryService memoryService) + { + try + { + var denied = CheckToken(ctx); + if (denied != null) return denied; + + using var reader = new StreamReader(ctx.Request.Body); + var body = await reader.ReadToEndAsync(); + JsonElement json; + try { json = JsonSerializer.Deserialize(body); } + catch { return Results.BadRequest(new { error = "Invalid JSON" }); } + + var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : ""; + var subPath = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""; + + if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" }); + + var files = memoryService.ListMemoryFiles(agentId, subPath); + return Results.Ok(new { files }); + } + catch (System.Exception ex) + { + return Results.Json(new { error = ex.Message }, statusCode: 500); + } + } } diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 2d7f80f..b503b6c 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -9,6 +9,7 @@ using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Gott.Endpoints; using Gott.Models; using Microsoft.AspNetCore.Http; @@ -1737,7 +1738,7 @@ public class AnthropicService /// Generiert eine temporäre MCP-Config-Datei für die Claude CLI. /// Enthält den MCP-Server für remote_execute. /// - private string GenerateMcpConfig(string sessionId) + private string GenerateMcpConfig(string sessionId, string agentId = null, bool hasRemote = false) { // Port aus Konfiguration oder Default var port = 19000; @@ -1746,12 +1747,20 @@ public class AnthropicService // Fallback: Quell-Verzeichnis prüfen (für Dev-Modus) if (!File.Exists(scriptPath)) { - // wwwroot ist typischerweise /app/gott/wwwroot → eine Ebene hoch = Projekt-Root var projectRoot = Path.GetDirectoryName(AppPaths.WwwRoot) ?? AppContext.BaseDirectory; var srcPath = Path.Combine(projectRoot, "scripts", "mcp-remote-execute.py"); if (File.Exists(srcPath)) scriptPath = srcPath; } + var env = new Dictionary + { + ["GOTT_API_URL"] = $"http://localhost:{port}", + ["GOTT_SESSION_ID"] = sessionId ?? "", + ["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken, + ["GOTT_AGENT_ID"] = agentId ?? "", + ["GOTT_HAS_REMOTE"] = hasRemote ? "true" : "false" + }; + var config = new { mcpServers = new Dictionary @@ -1760,12 +1769,7 @@ public class AnthropicService { command = "python3", args = new[] { scriptPath }, - env = new Dictionary - { - ["GOTT_API_URL"] = $"http://localhost:{port}", - ["GOTT_SESSION_ID"] = sessionId, - ["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken - } + env } } }; @@ -1929,6 +1933,7 @@ public class AnthropicService bool planMode = false, bool webSearch = false, string sessionId = null, + string agentId = null, RemoteConnectionManager remoteMgr = null, CancellationToken workCt = default, CancellationToken sseCt = default) @@ -1955,11 +1960,9 @@ public class AnthropicService try { - // MCP-Config für remote_execute generieren (wenn Remote-Verbindung vorhanden) - if (remoteMgr != null && !string.IsNullOrEmpty(sessionId) && remoteMgr.GetBySessionId(sessionId) != null) - { - mcpConfigPath = GenerateMcpConfig(sessionId); - } + // MCP-Config generieren (immer – für ask_user_choice, optional Memory-Tools + remote_execute) + var hasRemote = remoteMgr != null && !string.IsNullOrEmpty(sessionId) && remoteMgr.GetBySessionId(sessionId) != null; + mcpConfigPath = GenerateMcpConfig(sessionId, agentId, hasRemote); // System-Prompt als Text extrahieren var systemText = new StringBuilder(); @@ -1974,19 +1977,6 @@ public class AnthropicService } } - // ask_user_choice Instruktion anhängen - if (allowUserChoice) - { - systemText.AppendLine(); - systemText.AppendLine(@"## Rückfragen -Wenn du dem User eine Frage mit Auswahlmöglichkeiten stellen möchtest (2-5 Optionen), gib EXAKT diesen Block aus, auf eigenen Zeilen, ohne weiteren Text drumherum: - -:::ASK_USER_CHOICE::: -{""question"": ""Deine Frage"", ""choices"": [""Option A"", ""Option B""]} -:::END_ASK_USER_CHOICE::: - -Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die Entscheidung wirklich vom User getroffen werden muss."); - } // Letzte User-Message als Prompt extrahieren var prompt = ExtractLastUserText(messages) ?? ""; @@ -2060,6 +2050,36 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En Content = mappedInput }); await SseHelper.TryWriteSse(response, "tool_end", new { name = mappedName, id = evt.ToolId, input = mappedInput }, sseCt); + + // ask_user_choice via MCP: TCS erstellen + SSE user_choice Event + if (mappedName == "ask_user_choice") + { + try + { + var choiceInput = JsonSerializer.Deserialize(mappedInput); + var cQuestion = choiceInput.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : ""; + var cChoices = new List(); + if (choiceInput.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array) + 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); + + // SSE Event mit viaCli-Flag → Frontend zeigt Buttons + nutzt choice-respond Endpoint + choiceToolCallId = evt.ToolId; + await SseHelper.TryWriteSse(response, "user_choice", new + { + toolCallId = evt.ToolId, + question = cQuestion, + choices = cChoices, + viaCli = true + }, sseCt); + + isUserChoice = true; + } + catch (JsonException) { /* Input nicht parsbar – MCP-Server gibt Fehler */ } + } break; } case CliEventType.ToolResult: @@ -2085,43 +2105,6 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En }, sseCt); break; } - case CliEventType.UserChoice: - // Text bisher in DB speichern - if (fullText.Length > 0) - { - await _db.SaveMessage(new MessageRecord - { - ChainId = chainId, - Iteration = 1, - Role = "assistant", - Type = "text", - Content = fullText.ToString() - }); - } - - // Synthetische tool_use speichern (für History-Rebuild) - choiceToolCallId = $"toolu_cli_{Guid.NewGuid():N}"; - await _db.SaveMessage(new MessageRecord - { - ChainId = chainId, - Iteration = 1, - Role = "assistant", - Type = "tool_use", - ToolName = "ask_user_choice", - ToolCallId = choiceToolCallId, - Content = JsonSerializer.Serialize(new { question = evt.ChoiceQuestion, choices = evt.ChoiceOptions }) - }); - - // SSE user_choice Event (Frontend zeigt Buttons) - await SseHelper.TryWriteSse(response, "user_choice", new - { - toolCallId = choiceToolCallId, - question = evt.ChoiceQuestion, - choices = evt.ChoiceOptions - }, sseCt); - - isUserChoice = true; - break; } }, modelId: modelId, diff --git a/Services/ClaudeCliService.cs b/Services/ClaudeCliService.cs index 5e15290..5ddd62b 100644 --- a/Services/ClaudeCliService.cs +++ b/Services/ClaudeCliService.cs @@ -113,7 +113,14 @@ public class ClaudeCliService } if (webSearch) { tools.Add("WebFetch"); tools.Add("WebSearch"); } // MCP-Tools erlauben (wenn MCP-Config vorhanden) - if (!string.IsNullOrEmpty(mcpConfigPath)) { tools.Add("mcp__gott-remote__remote_execute"); } + if (!string.IsNullOrEmpty(mcpConfigPath)) + { + tools.Add("mcp__gott-remote__remote_execute"); + tools.Add("mcp__gott-remote__ask_user_choice"); + tools.Add("mcp__gott-remote__memory_read"); + tools.Add("mcp__gott-remote__memory_write"); + tools.Add("mcp__gott-remote__memory_list"); + } args.Add("--allowed-tools"); args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\""); @@ -161,11 +168,7 @@ public class ClaudeCliService var currentToolInput = new StringBuilder(); bool inTextBlock = false; - // Tracking für ask_user_choice Marker-Erkennung var lineBuffer = new StringBuilder(); - bool inChoiceCapture = false; - var choiceJsonBuffer = new StringBuilder(); - bool userChoiceDetected = false; try { @@ -286,9 +289,8 @@ public class ClaudeCliService if (deltaType == "text_delta" && delta.TryGetProperty("text", out var txt)) { var text = txt.GetString(); - if (!string.IsNullOrEmpty(text) && !userChoiceDetected) + if (!string.IsNullOrEmpty(text)) { - // Zeilenbasierte Marker-Erkennung für :::ASK_USER_CHOICE::: for (int ci = 0; ci < text.Length; ci++) { var ch = text[ci]; @@ -297,52 +299,8 @@ public class ClaudeCliService var completedLine = lineBuffer.ToString().Trim(); lineBuffer.Clear(); - if (inChoiceCapture) + if (completedLine.Length > 0 || fullOutput.Length > 0) { - if (completedLine == ":::END_ASK_USER_CHOICE:::") - { - // JSON parsen und UserChoice-Event emittieren - try - { - var choiceJson = JsonSerializer.Deserialize(choiceJsonBuffer.ToString()); - var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : ""; - var choices = new List(); - if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array) - foreach (var cOpt in cc.EnumerateArray()) - choices.Add(cOpt.GetString() ?? ""); - - userChoiceDetected = true; - await onEvent(new CliStreamEvent - { - Type = CliEventType.UserChoice, - ChoiceQuestion = question, - ChoiceOptions = choices - }); - } - catch (JsonException) - { - // Fehlerhaftes JSON → als normalen Text weiterleiten - var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n:::END_ASK_USER_CHOICE:::\n"; - fullOutput.Append(fallback); - await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback }); - } - inChoiceCapture = false; - choiceJsonBuffer.Clear(); - } - else - { - choiceJsonBuffer.AppendLine(completedLine); - } - } - else if (completedLine == ":::ASK_USER_CHOICE:::") - { - inChoiceCapture = true; - choiceJsonBuffer.Clear(); - } - else if (completedLine.Length > 0 || fullOutput.Length > 0) - { - // Normale Zeile → Text weiterleiten (inkl. Newline) - // Leere Zeilen am Anfang überspringen (vor dem eigentlichen Text) var lineText = completedLine + "\n"; fullOutput.Append(lineText); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = lineText }); @@ -401,41 +359,12 @@ public class ClaudeCliService else if (type == "result") { // Restlichen lineBuffer flushen (nicht-abgeschlossene Zeile) - if (lineBuffer.Length > 0 && !userChoiceDetected) + if (lineBuffer.Length > 0) { var remaining = lineBuffer.ToString().Trim(); lineBuffer.Clear(); - if (inChoiceCapture && remaining == ":::END_ASK_USER_CHOICE:::") - { - // End-Marker ohne trailing \n → Capture abschließen - try - { - var choiceJson = JsonSerializer.Deserialize(choiceJsonBuffer.ToString()); - var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : ""; - var choices = new List(); - if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array) - foreach (var cOpt in cc.EnumerateArray()) - choices.Add(cOpt.GetString() ?? ""); - - userChoiceDetected = true; - await onEvent(new CliStreamEvent - { - Type = CliEventType.UserChoice, - ChoiceQuestion = question, - ChoiceOptions = choices - }); - } - catch (JsonException) - { - var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n{remaining}\n"; - fullOutput.Append(fallback); - await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback }); - } - inChoiceCapture = false; - choiceJsonBuffer.Clear(); - } - else if (!inChoiceCapture) + if (remaining.Length > 0) { fullOutput.Append(remaining); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining }); @@ -519,40 +448,12 @@ public class ClaudeCliService } // Restlichen lineBuffer flushen - if (lineBuffer.Length > 0 && !userChoiceDetected) + if (lineBuffer.Length > 0) { var remaining = lineBuffer.ToString().Trim(); lineBuffer.Clear(); - if (inChoiceCapture && remaining == ":::END_ASK_USER_CHOICE:::") - { - try - { - var choiceJson = JsonSerializer.Deserialize(choiceJsonBuffer.ToString()); - var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : ""; - var choices = new List(); - if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array) - foreach (var cOpt in cc.EnumerateArray()) - choices.Add(cOpt.GetString() ?? ""); - - userChoiceDetected = true; - await onEvent(new CliStreamEvent - { - Type = CliEventType.UserChoice, - ChoiceQuestion = question, - ChoiceOptions = choices - }); - } - catch (JsonException) - { - var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n{remaining}\n"; - fullOutput.Append(fallback); - await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback }); - } - inChoiceCapture = false; - choiceJsonBuffer.Clear(); - } - else if (!inChoiceCapture) + if (remaining.Length > 0) { fullOutput.Append(remaining); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining }); @@ -578,8 +479,7 @@ public class ClaudeCliService result.ExitCode = process.ExitCode; result.Output = fullOutput.ToString().Trim(); - result.Success = process.ExitCode == 0 && (fullOutput.Length > 0 || userChoiceDetected); - result.UserChoiceDetected = userChoiceDetected; + result.Success = process.ExitCode == 0 && fullOutput.Length > 0; if (!result.Success && string.IsNullOrEmpty(result.Error)) result.Error = error.Length > 0 ? error.ToString().Trim() : $"CLI exit code: {process.ExitCode}"; @@ -696,8 +596,7 @@ public enum CliEventType TextEnd, ToolStart, ToolEnd, - ToolResult, - UserChoice + ToolResult } public class CliStreamEvent @@ -709,8 +608,6 @@ public class CliStreamEvent public string ToolInput { get; set; } public string ToolResultContent { get; set; } public bool ToolResultIsError { get; set; } - public string ChoiceQuestion { get; set; } - public List ChoiceOptions { get; set; } } public class CliChatResult @@ -727,7 +624,6 @@ public class CliChatResult public long DurationApiMs { get; set; } public string SessionId { get; set; } public int NumTurns { get; set; } - public bool UserChoiceDetected { get; set; } } public class CliStatusResult diff --git a/scripts/mcp-remote-execute.py b/scripts/mcp-remote-execute.py index da0b274..727c902 100644 --- a/scripts/mcp-remote-execute.py +++ b/scripts/mcp-remote-execute.py @@ -19,13 +19,90 @@ import os API_URL = os.environ.get("GOTT_API_URL", "http://localhost:19000") SESSION_ID = os.environ.get("GOTT_SESSION_ID", "") AUTH_TOKEN = os.environ.get("GOTT_AUTH_TOKEN", "") +AGENT_ID = os.environ.get("GOTT_AGENT_ID", "") +HAS_REMOTE = os.environ.get("GOTT_HAS_REMOTE", "false") == "true" -TOOL_NAME = "remote_execute" -TOOL_DESCRIPTION = ( - "Führt einen PowerShell-Befehl auf der verbundenen Remote-Windows-Workstation aus. " - "Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs " - "(z.B. Druckerprobleme, Netzwerk, Services, Registry). Timeout: 180 Sekunden." -) +TOOLS = [] + +# Remote-Execute Tool (nur wenn Remote-Verbindung vorhanden) +if HAS_REMOTE: + TOOLS.append({ + "name": "remote_execute", + "description": ( + "Führt einen PowerShell-Befehl auf der verbundenen Remote-Windows-Workstation aus. " + "Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs " + "(z.B. Druckerprobleme, Netzwerk, Services, Registry). Timeout: 180 Sekunden." + ), + "inputSchema": { + "type": "object", + "properties": { + "command": {"type": "string", "description": "PowerShell-Befehl zum Ausführen auf dem Remote-PC"} + }, + "required": ["command"] + } + }) + +# ask_user_choice Tool (immer verfügbar) +TOOLS.append({ + "name": "ask_user_choice", + "description": ( + "Stellt dem User eine Frage mit Auswahlmöglichkeiten (2-5 Optionen). " + "Der User wählt eine Option und du arbeitest mit seiner Antwort weiter. " + "Nutze dies wenn du unsicher bist oder es mehrere sinnvolle Wege gibt." + ), + "inputSchema": { + "type": "object", + "properties": { + "question": {"type": "string", "description": "Die Frage an den User"}, + "choices": { + "type": "array", + "items": {"type": "string"}, + "description": "Liste der Auswahlmöglichkeiten (2-5 Optionen)", + "minItems": 2, + "maxItems": 5 + } + }, + "required": ["question", "choices"] + } +}) + +# Memory-Tools (immer verfügbar wenn agentId gesetzt) +if AGENT_ID: + TOOLS.append({ + "name": "memory_read", + "description": "Liest eine Memory-Datei des Agents. Nutze dies um gespeicherte Erinnerungen, Notizen oder Kontextinformationen abzurufen.", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Relativer Pfad zur Memory-Datei (z.B. 'MEMORY.md' oder 'memory/YYYY-MM-DD.md')"} + }, + "required": ["path"] + } + }) + TOOLS.append({ + "name": "memory_write", + "description": "Schreibt oder überschreibt eine Memory-Datei. Nutze dies um wichtige Informationen, Entscheidungen oder Präferenzen dauerhaft zu speichern.", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Relativer Pfad zur Memory-Datei (z.B. 'MEMORY.md' oder 'memory/YYYY-MM-DD.md')"}, + "content": {"type": "string", "description": "Inhalt der Datei"}, + "append": {"type": "boolean", "description": "Wenn true, wird der Inhalt angehängt statt überschrieben (Default: false)"} + }, + "required": ["path", "content"] + } + }) + TOOLS.append({ + "name": "memory_list", + "description": "Listet alle Memory-Dateien des Agents auf.", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Optionaler Unterpfad zum Auflisten (leer = Root)"} + }, + "required": [] + } + }) def send_response(response): @@ -59,112 +136,147 @@ def handle_tools_list(req_id): "jsonrpc": "2.0", "id": req_id, "result": { - "tools": [ - { - "name": TOOL_NAME, - "description": TOOL_DESCRIPTION, - "inputSchema": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "PowerShell-Befehl zum Ausführen auf dem Remote-PC" - } - }, - "required": ["command"] - } - } - ] + "tools": TOOLS } } +def api_call(endpoint, payload): + """Macht einen API-Call an gott und gibt das Ergebnis zurück.""" + 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=200) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def make_result(req_id, text, is_error=False): + """Erstellt eine MCP Tool-Result Response.""" + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [{"type": "text", "text": text}], + "isError": is_error + } + } + + +def handle_remote_execute(req_id, arguments): + """Führt remote_execute aus.""" + command = arguments.get("command", "") + if not command: + return make_result(req_id, "Fehler: Kein Befehl angegeben", True) + + result = api_call("remote-execute", {"command": command, "sessionId": SESSION_ID}) + + output_parts = [] + if result.get("stdout"): + output_parts.append(result["stdout"]) + if result.get("stderr"): + output_parts.append(f"[stderr] {result['stderr']}") + if result.get("exitCode", 0) != 0: + output_parts.append(f"[Exit Code: {result['exitCode']}]") + + text = "\n".join(output_parts) if output_parts else "(keine Ausgabe)" + return make_result(req_id, text, result.get("exitCode", 0) != 0) + + +def handle_memory_read(req_id, arguments): + """Liest eine Memory-Datei.""" + path = arguments.get("path", "") + if not path: + return make_result(req_id, "Fehler: path erforderlich", True) + + result = api_call("memory-read", {"agentId": AGENT_ID, "path": path}) + return make_result(req_id, result.get("content", "")) + + +def handle_memory_write(req_id, arguments): + """Schreibt eine Memory-Datei.""" + path = arguments.get("path", "") + content = arguments.get("content", "") + append = arguments.get("append", False) + if not path: + return make_result(req_id, "Fehler: path erforderlich", True) + + result = api_call("memory-write", {"agentId": AGENT_ID, "path": path, "content": content, "append": append}) + return make_result(req_id, result.get("message", "OK")) + + +def handle_memory_list(req_id, arguments): + """Listet Memory-Dateien auf.""" + path = arguments.get("path", "") + + result = api_call("memory-list", {"agentId": AGENT_ID, "path": path}) + files = result.get("files", []) + return make_result(req_id, "\n".join(files) if files else "(keine Memory-Dateien vorhanden)") + + +def handle_ask_user_choice(req_id, arguments): + """Stellt dem User eine Frage und wartet auf Antwort (long-poll).""" + question = arguments.get("question", "") + choices = arguments.get("choices", []) + if not question: + return make_result(req_id, "Fehler: question erforderlich", True) + 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}") + + +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")) + + +TOOL_HANDLERS = { + "remote_execute": handle_remote_execute, + "ask_user_choice": handle_ask_user_choice, + "memory_read": handle_memory_read, + "memory_write": handle_memory_write, + "memory_list": handle_memory_list, +} + + def handle_tools_call(req_id, params): """Führt ein Tool aus.""" tool_name = params.get("name", "") arguments = params.get("arguments", {}) - if tool_name != TOOL_NAME: - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "content": [{"type": "text", "text": f"Unbekanntes Tool: {tool_name}"}], - "isError": True - } - } + handler = TOOL_HANDLERS.get(tool_name) + if not handler: + return make_result(req_id, f"Unbekanntes Tool: {tool_name}", True) - command = arguments.get("command", "") - if not command: - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "content": [{"type": "text", "text": "Fehler: Kein Befehl angegeben"}], - "isError": True - } - } + # Prüfen ob das Tool auch in TOOLS registriert ist + if not any(t["name"] == tool_name for t in TOOLS): + return make_result(req_id, f"Tool nicht verfügbar: {tool_name}", True) - # API-Call an gott try: - url = f"{API_URL}/api/internal/remote-execute" - payload = json.dumps({ - "command": command, - "sessionId": SESSION_ID - }).encode("utf-8") - - req = urllib.request.Request(url, data=payload, 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=200) as resp: - result = json.loads(resp.read().decode("utf-8")) - - # Ergebnis formatieren - output_parts = [] - if result.get("stdout"): - output_parts.append(result["stdout"]) - if result.get("stderr"): - output_parts.append(f"[stderr] {result['stderr']}") - if result.get("exitCode", 0) != 0: - output_parts.append(f"[Exit Code: {result['exitCode']}]") - - text = "\n".join(output_parts) if output_parts else "(keine Ausgabe)" - is_error = result.get("exitCode", 0) != 0 - - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "content": [{"type": "text", "text": text}], - "isError": is_error - } - } - + return handler(req_id, arguments) except urllib.error.HTTPError as e: body = "" try: body = e.read().decode("utf-8") except Exception: pass - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "content": [{"type": "text", "text": f"API-Fehler {e.code}: {body}"}], - "isError": True - } - } + return make_result(req_id, f"API-Fehler {e.code}: {body}", True) except Exception as e: - return { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "content": [{"type": "text", "text": f"Fehler: {str(e)}"}], - "isError": True - } - } + return make_result(req_id, f"Fehler: {str(e)}", True) def handle_notifications_initialized(params): diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 1d6e88c..467de8c 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -72,6 +72,10 @@ function apiChatChoice(sessionId, choiceToolCallId, choiceResult, signal, planMo return fetch('/api/chat', { method: 'POST', headers: _jsonHeaders, body: JSON.stringify(payload), signal: signal }); } +function apiChoiceRespond(sessionId, choice) { + return _post('/api/chat/choice-respond', { sessionId: sessionId, choice: choice }); +} + function apiStopChat(sessionId) { return _post('/api/chat/stop', { sessionId: sessionId }); } // ── Agent API ── diff --git a/wwwroot/js/bubbles.js b/wwwroot/js/bubbles.js index b013160..c003cbf 100644 --- a/wwwroot/js/bubbles.js +++ b/wwwroot/js/bubbles.js @@ -180,7 +180,7 @@ function addWarningBubble(text, streamed = false) { /** * Creates a choice bubble element (for both live stream and history rendering). - * @param {object} opts - { toolCallId, question, choices, selectedChoice, timestamp, messageId, streamed } + * @param {object} opts - { toolCallId, question, choices, selectedChoice, timestamp, messageId, streamed, viaCli } */ function _createChoiceBubble(opts) { if (!Array.isArray(opts.choices)) opts.choices = [String(opts.choices)]; @@ -189,6 +189,7 @@ function _createChoiceBubble(opts) { const div = document.createElement('div'); div.className = 'msg choice-bubble' + (answered ? ' choice-answered' : ''); if (opts.streamed) div.classList.add('streamed'); + if (opts.viaCli) div.dataset.viaCli = 'true'; div.dataset.toolCallId = opts.toolCallId || ''; if (opts.messageId) div.dataset.messageId = opts.messageId; @@ -219,8 +220,8 @@ function _createChoiceBubble(opts) { } // Wird live aus dem SSE-Stream aufgerufen -function addChoiceBubble(toolCallId, question, choices, streamed = true) { - const div = _createChoiceBubble({ toolCallId, question, choices, streamed }); +function addChoiceBubble(toolCallId, question, choices, streamed = true, viaCli = false) { + const div = _createChoiceBubble({ toolCallId, question, choices, streamed, viaCli }); chatEl().appendChild(div); scrollDown(); return div; diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 71839a8..efba604 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -78,7 +78,7 @@ function handleStreamEvent(eventType, data) { case 'user_choice': endStreamBubble(); - addChoiceBubble(data.toolCallId, data.question, data.choices); + addChoiceBubble(data.toolCallId, data.question, data.choices, true, data.viaCli); break; // Preparer / Review / Finalizer events (shared pattern) @@ -425,6 +425,17 @@ async function handleChoiceClick(toolCallId, choice, bubbleDiv) { }); bubbleDiv.classList.add('choice-answered'); + // CLI-Modus: Antwort über internen Endpoint an MCP-Server weiterleiten (CLI läuft weiter) + if (bubbleDiv.dataset.viaCli === 'true') { + try { + await apiChoiceRespond(getCurrentSessionId(), choice); + } catch (e) { + console.error('Choice respond failed:', e); + } + return; + } + + // API-Modus: Normaler Chat-Flow (neue Chain) await _runStream((signal) => apiChatChoice(getCurrentSessionId(), toolCallId, choice, signal, _planModeActive)); } diff --git a/wwwroot/js/markdown.js b/wwwroot/js/markdown.js index 0b5f9c4..f52fded 100644 --- a/wwwroot/js/markdown.js +++ b/wwwroot/js/markdown.js @@ -6,18 +6,6 @@ function renderMarkdown(text) { if (!text) return ''; var src = text.trim(); - // 0) :::ASK_USER_CHOICE::: Blöcke erkennen und als Platzhalter speichern (vor Markdown-Parsing) - var choiceBlocks = []; - src = src.replace(/:::ASK_USER_CHOICE:::\s*\n?([\s\S]*?)\n?\s*:::END_ASK_USER_CHOICE:::/g, function(match, jsonStr) { - try { - var data = JSON.parse(jsonStr.trim()); - choiceBlocks.push(data); - return '%%CHOICE' + (choiceBlocks.length - 1) + '%%'; - } catch(e) { - return match; // JSON-Fehler → als Text belassen - } - }); - // 1) LaTeX-Ausdrücke VOR dem Markdown-Parsing schützen // Reihenfolge: zuerst Block ($$...$$), dann Inline ($...$) var mathBlocks = []; @@ -107,22 +95,6 @@ function renderMarkdown(text) { } }); - // 8) Choice-Platzhalter durch interaktive Buttons ersetzen - html = html.replace(/%%CHOICE(\d+)%%/g, function(m, idx) { - var data = choiceBlocks[parseInt(idx)]; - var q = data.question || ''; - var choices = data.choices || []; - var jsonAttr = esc(JSON.stringify(data)); - var out = '
'; - out += '
' + esc(q) + '
'; - out += '
'; - for (var i = 0; i < choices.length; i++) { - out += ''; - } - out += '
'; - return out; - }); - return html; } diff --git a/wwwroot/js/stream-ui.js b/wwwroot/js/stream-ui.js index 481eb5c..97307c1 100644 --- a/wwwroot/js/stream-ui.js +++ b/wwwroot/js/stream-ui.js @@ -16,15 +16,7 @@ function appendToStream(text) { if (!_streamDiv) startStreamBubble(); _streamText += text; - // Wenn ein :::ASK_USER_CHOICE::: Block begonnen aber noch nicht abgeschlossen wurde, - // nur den Text VOR dem Marker rendern (Marker-Block verstecken bis er komplett ist) - var displayText = _streamText; - var choiceStart = displayText.indexOf(':::ASK_USER_CHOICE:::'); - if (choiceStart >= 0 && displayText.indexOf(':::END_ASK_USER_CHOICE:::') < 0) { - displayText = displayText.substring(0, choiceStart); - } - - _streamDiv.innerHTML = renderMarkdown(displayText); + _streamDiv.innerHTML = renderMarkdown(_streamText); // Cursor ins letzte Inline/Text-Element einfügen, damit er nicht auf eine neue Zeile rutscht const cursor = document.createElement('span'); cursor.className = 'cursor';