diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 383f1f4..d913c68 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, 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, remoteMgr: remoteMgr, workCt: workCt, sseCt: sseCt); } else { diff --git a/Endpoints/InternalEndpoint.cs b/Endpoints/InternalEndpoint.cs new file mode 100644 index 0000000..4a73005 --- /dev/null +++ b/Endpoints/InternalEndpoint.cs @@ -0,0 +1,73 @@ +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Gott.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Gott.Endpoints; + +/// +/// Interne API-Endpoints für MCP-Server und andere lokale Integrationen. +/// Gesichert über einen internen Token (da Reverse-Proxy den Localhost-Check umgeht). +/// +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 void MapInternalEndpoints(this IEndpointRouteBuilder app) + { + app.MapPost("/api/internal/remote-execute", HandleRemoteExecute); + } + + private static async Task HandleRemoteExecute(HttpContext ctx, RemoteConnectionManager mgr) + { + try + { + // Token-Check: Header "X-Internal-Token" muss den internen Token enthalten + var token = ctx.Request.Headers["X-Internal-Token"].FirstOrDefault() ?? ""; + if (token != InternalToken) + return Results.StatusCode(403); + + 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 command = json.TryGetProperty("command", out var cmd) ? cmd.GetString() ?? "" : ""; + var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() ?? "" : ""; + + if (string.IsNullOrEmpty(command)) + return Results.BadRequest(new { error = "command erforderlich" }); + + if (string.IsNullOrEmpty(sessionId)) + return Results.BadRequest(new { error = "sessionId erforderlich" }); + + var conn = mgr.GetBySessionId(sessionId); + if (conn == null) + return Results.NotFound(new { error = "Keine Remote-Verbindung für diese Session" }); + + using var cts = new CancellationTokenSource(System.TimeSpan.FromSeconds(180)); + var result = await mgr.ExecuteCommand(conn.Id, command, 180, cts.Token); + + return Results.Ok(new + { + exitCode = result.ExitCode, + stdout = result.Output ?? "", + stderr = result.Error ?? "" + }); + } + catch (System.Exception ex) + { + return Results.Json(new { error = ex.Message, stackTrace = ex.StackTrace }, statusCode: 500); + } + } +} diff --git a/Middleware/AuthMiddleware.cs b/Middleware/AuthMiddleware.cs index a0f5c23..4a17702 100644 --- a/Middleware/AuthMiddleware.cs +++ b/Middleware/AuthMiddleware.cs @@ -26,7 +26,8 @@ public class AuthMiddleware var isImageFile = path.StartsWith("/api/images/file/") && ctx.Request.Method == "GET"; var isRemoteWs = path == "/api/remote"; var isRemoteScript = path == "/remote.ps1"; - if (!path.StartsWith("/api/") || path == "/api/auth/login" || isTelegramWebhook || isSharedGet || isAutologin || isImageFile || isRemoteWs || isRemoteScript) + var isInternal = path.StartsWith("/api/internal/"); + if (!path.StartsWith("/api/") || path == "/api/auth/login" || isTelegramWebhook || isSharedGet || isAutologin || isImageFile || isRemoteWs || isRemoteScript || isInternal) { await _next(ctx); return; diff --git a/Plans/MCP_REMOTE_EXECUTE.md b/Plans/MCP_REMOTE_EXECUTE.md new file mode 100644 index 0000000..7b2f85f --- /dev/null +++ b/Plans/MCP_REMOTE_EXECUTE.md @@ -0,0 +1,126 @@ +# Plan: MCP-Server für `remote_execute` in der CLI + +## Übersicht + +Ein eigener MCP-Server (Model Context Protocol, stdio-basiert) der `remote_execute` als Tool für die Claude Code CLI exposed. Die CLI erkennt MCP-Tools nativ und kann sie wie Built-in-Tools verwenden. + +## Architektur + +``` +Claude CLI ←→ MCP-Server (stdio) ←→ gott HTTP-API ←→ RemoteEndpoint → SSH/Exec +``` + +## Komponenten + +### 1. MCP-Server: `scripts/mcp-server` (oder `Services/McpServer.cs`) + +**Variante A: Standalone Script (Python/Node/Bash+jq)** +- Leichtgewichtig, unabhängig von .NET Runtime +- Kommuniziert über stdin/stdout (JSON-RPC 2.0) +- Ruft gott's interne HTTP-API auf + +**Variante B: .NET Console App** +- Kann ToolService/RemoteService direkt referenzieren +- Gleiche Codebase, keine API-Zwischenschicht nötig +- Aber: Eigener Prozess, muss separat gebaut werden + +**Empfehlung: Variante A** – ein einfaches Script das die gott-API aufruft. Minimaler Overhead, keine Build-Abhängigkeit. + +### 2. Interner API-Endpoint + +``` +POST /api/internal/remote-execute +Authorization: (interner Token oder localhost-only) +Content-Type: application/json + +{ + "command": "docker ps", + "sessionId": "abc123", // für Zuordnung zur Chat-Session + "serverId": "optional" // falls mehrere Server +} +``` + +**Response:** +```json +{ + "exitCode": 0, + "stdout": "CONTAINER ID IMAGE ...", + "stderr": "", + "durationMs": 234 +} +``` + +### 3. MCP-Server Konfiguration + +In `.claude/settings.json` oder per `--mcp-config`: +```json +{ + "mcpServers": { + "gott-remote": { + "command": "/app/gott/scripts/mcp-remote-execute", + "type": "stdio", + "env": { + "GOTT_API_URL": "http://localhost:5000", + "GOTT_SESSION_ID": "${SESSION_ID}" + } + } + } +} +``` + +### 4. MCP-Protokoll (stdio, JSON-RPC 2.0) + +**Tool-Definition (tools/list Response):** +```json +{ + "name": "remote_execute", + "description": "Führt einen Befehl auf dem Remote-Server aus (via SSH oder lokal)", + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Der auszuführende Befehl" + } + }, + "required": ["command"] + } +} +``` + +**Tool-Call (tools/call):** +- MCP-Server empfängt JSON-RPC Request +- Ruft `POST /api/internal/remote-execute` auf +- Gibt stdout/stderr als Text-Content zurück + +### 5. Integration in ClaudeCliService + +- MCP-Server-Config wird beim CLI-Start übergeben +- `--mcp-config /tmp/gott-mcp-{sessionId}.json` oder globale Config +- SessionId/ServerId werden als Environment-Variablen an den MCP-Server übergeben + +### 6. AnthropicService: CLI-Aufruf anpassen + +In `StreamChatViaCli` den MCP-Config-Pfad generieren und an `ChatStreamingAsync` übergeben: +```csharp +var mcpConfig = GenerateMcpConfig(sessionId, serverId); +var mcpConfigPath = WriteTempMcpConfig(mcpConfig); +// CLI-Aufruf mit --mcp-config mcpConfigPath +``` + +## Dateien + +| Datei | Aktion | Beschreibung | +|-------|--------|-------------| +| `scripts/mcp-remote-execute` | **NEU** | MCP-Server Script (stdio) | +| `Endpoints/InternalEndpoint.cs` | **NEU** | Interner API-Endpoint für remote-execute | +| `Services/ClaudeCliService.cs` | **EDIT** | MCP-Config übergeben | +| `Services/AnthropicService.cs` | **EDIT** | MCP-Config generieren | +| `.claude/settings.json` | **EDIT** | MCP-Server registrieren (oder dynamisch per --mcp-config) | + +## Offene Fragen + +1. **Script-Sprache**: Python (weit verbreitet), Node (npm-Ökosystem), oder Shell+curl+jq (keine Runtime-Abhängigkeit)? +2. **Auth für internen Endpoint**: Nur localhost erlauben? Oder interner Token? +3. **Server-Auswahl**: Wenn mehrere Remote-Server konfiguriert sind – soll der MCP-Server alle als separate Tools exposen oder einen Default verwenden? +4. **Tool-Ergebnis-Mapping**: Wie sollen remote_execute-Results in der gott-UI angezeigt werden? Als normales Tool-Result oder speziell formatiert? diff --git a/Program.cs b/Program.cs index 03a18da..d5a4b2c 100644 --- a/Program.cs +++ b/Program.cs @@ -179,6 +179,7 @@ app.MapApiKeysEndpoints(); app.MapOpenClawEndpoints(); app.MapRemoteEndpoints(); app.MapDatabaseExplorerEndpoints(); +app.MapInternalEndpoints(); var port = app.Configuration.GetValue("Port", 19000); app.Run($"http://0.0.0.0:{port}"); diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 8a27f26..2d7f80f 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -1733,6 +1733,49 @@ 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) + { + // Port aus Konfiguration oder Default + var port = 19000; + var scriptPath = Path.Combine(AppContext.BaseDirectory, "scripts", "mcp-remote-execute.py"); + + // 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 config = new + { + mcpServers = new Dictionary + { + ["gott-remote"] = new + { + command = "python3", + args = new[] { scriptPath }, + env = new Dictionary + { + ["GOTT_API_URL"] = $"http://localhost:{port}", + ["GOTT_SESSION_ID"] = sessionId, + ["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken + } + } + } + }; + + var json = System.Text.Json.JsonSerializer.Serialize(config, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + var tempPath = Path.Combine(Path.GetTempPath(), $"gott-mcp-{sessionId}.json"); + File.WriteAllText(tempPath, json); + return tempPath; + } + /// /// Extrahiert Web-Search-Sources aus einem web_search_tool_result-Block. /// @@ -1886,6 +1929,7 @@ public class AnthropicService bool planMode = false, bool webSearch = false, string sessionId = null, + RemoteConnectionManager remoteMgr = null, CancellationToken workCt = default, CancellationToken sseCt = default) { @@ -1900,6 +1944,7 @@ public class AnthropicService var usage = new TokenUsage(); var fullText = new StringBuilder(); var tempImagePaths = new List(); + string mcpConfigPath = null; var chain = new ChainRecord { @@ -1910,6 +1955,12 @@ 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); + } + // System-Prompt als Text extrahieren var systemText = new StringBuilder(); if (systemPrompt != null) @@ -2079,6 +2130,7 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En hasWorkspace: !string.IsNullOrEmpty(workspace), planMode: planMode, webSearch: webSearch, + mcpConfigPath: mcpConfigPath, cancellationToken: workCt ); @@ -2181,6 +2233,12 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En { // Temp-Bilddateien aufräumen CleanupTempImages(tempImagePaths); + + // MCP-Config Temp-Datei aufräumen + if (mcpConfigPath != null) + { + try { File.Delete(mcpConfigPath); } catch { } + } } } @@ -2200,7 +2258,8 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En "WebSearch" => "web_search", "WebFetch" => "web_fetch", "NotebookEdit" => "notebook_edit", - _ => cliName + "mcp__gott-remote__remote_execute" => "remote_execute", + _ => cliName?.StartsWith("mcp__") == true ? cliName.Split("__").LastOrDefault() ?? cliName : cliName }; } diff --git a/Services/ClaudeCliService.cs b/Services/ClaudeCliService.cs index 4e1ad51..5e15290 100644 --- a/Services/ClaudeCliService.cs +++ b/Services/ClaudeCliService.cs @@ -82,6 +82,7 @@ public class ClaudeCliService bool hasWorkspace = false, bool planMode = false, bool webSearch = false, + string mcpConfigPath = null, CancellationToken cancellationToken = default) { if (!_isAvailable) @@ -111,6 +112,8 @@ public class ClaudeCliService tools.AddRange(new[] { "Bash(*)", "Read", "Write", "Edit", "Glob", "Grep" }); } 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"); } args.Add("--allowed-tools"); args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\""); @@ -132,6 +135,12 @@ public class ClaudeCliService args.Add(systemPrompt); } + if (!string.IsNullOrEmpty(mcpConfigPath)) + { + args.Add("--mcp-config"); + args.Add(mcpConfigPath); + } + return await RunCliStreamingAsync(args, prompt, onEvent, cancellationToken); } diff --git a/gott.csproj b/gott.csproj index 36bdd25..563b027 100755 --- a/gott.csproj +++ b/gott.csproj @@ -47,6 +47,10 @@ + + + + diff --git a/scripts/mcp-remote-execute.py b/scripts/mcp-remote-execute.py new file mode 100644 index 0000000..da0b274 --- /dev/null +++ b/scripts/mcp-remote-execute.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +MCP-Server (Model Context Protocol) für remote_execute. +Kommuniziert über stdin/stdout (JSON-RPC 2.0) mit der Claude CLI. +Ruft die gott-interne HTTP-API auf um Befehle auf dem Remote-PC auszuführen. + +Umgebungsvariablen: + GOTT_API_URL - URL der gott-API (z.B. http://localhost:19000) + GOTT_SESSION_ID - Session-ID für die Remote-Verbindungszuordnung + GOTT_AUTH_TOKEN - Auth-Token für die gott-API (optional, für nicht-localhost) +""" + +import json +import sys +import urllib.request +import urllib.error +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", "") + +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." +) + + +def send_response(response): + """Sendet eine JSON-RPC Response über stdout.""" + data = json.dumps(response) + sys.stdout.write(data + "\n") + sys.stdout.flush() + + +def handle_initialize(req_id, params): + """MCP initialize handshake.""" + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "gott-remote", + "version": "1.0.0" + } + } + } + + +def handle_tools_list(req_id): + """Gibt die verfügbaren Tools zurück.""" + return { + "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"] + } + } + ] + } + } + + +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 + } + } + + 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 + } + } + + # 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 + } + } + + 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 + } + } + except Exception as e: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [{"type": "text", "text": f"Fehler: {str(e)}"}], + "isError": True + } + } + + +def handle_notifications_initialized(params): + """Handle notifications/initialized - no response needed.""" + pass + + +def main(): + """Hauptloop: Liest JSON-RPC Requests von stdin, verarbeitet sie.""" + for line in sys.stdin: + line = line.strip() + if not line: + continue + + try: + request = json.loads(line) + except json.JSONDecodeError: + continue + + method = request.get("method", "") + req_id = request.get("id") + params = request.get("params", {}) + + # Notifications (kein id) brauchen keine Response + if req_id is None: + if method == "notifications/initialized": + handle_notifications_initialized(params) + continue + + if method == "initialize": + response = handle_initialize(req_id, params) + elif method == "tools/list": + response = handle_tools_list(req_id) + elif method == "tools/call": + response = handle_tools_call(req_id, params) + else: + response = { + "jsonrpc": "2.0", + "id": req_id, + "error": { + "code": -32601, + "message": f"Method not found: {method}" + } + } + + send_response(response) + + +if __name__ == "__main__": + main() diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index 238d229..4dbe68d 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -1280,6 +1280,14 @@ color: var(--muted); } +.inline-choice-block { + margin: var(--gap) 0; + padding: var(--gap); + border-left: 3px solid var(--primary); + background: var(--f1-bg); + border-radius: var(--border-radius); +} + /* ── OpenClaw Import Badge ── */ .openclaw-badge { diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index a978c82..71839a8 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -402,6 +402,14 @@ async function sendMessage() { await _runStream((signal) => apiChat(content, getCurrentSessionId(), signal, _planModeActive)); } +// ── Direct Message (für Inline-Choice-Buttons) ── +async function sendMessageDirect(text) { + if (isStreaming || !text) return; + addUserBubble(text, true); + scrollDownForce(); + await _runStream((signal) => apiChat(text, getCurrentSessionId(), signal, _planModeActive)); +} + // ── Choice Click Handler ── async function handleChoiceClick(toolCallId, choice, bubbleDiv) { @@ -419,3 +427,23 @@ async function handleChoiceClick(toolCallId, choice, bubbleDiv) { await _runStream((signal) => apiChatChoice(getCurrentSessionId(), toolCallId, choice, signal, _planModeActive)); } + +// ── Inline Choice Blocks (Fallback aus Markdown-Rendering) ── +// Event-Delegation für .inline-choice-btn Buttons die von renderMarkdown() erzeugt wurden +document.addEventListener('click', function(e) { + var btn = e.target.closest('.inline-choice-btn'); + if (!btn) return; + var block = btn.closest('.inline-choice-block'); + if (!block || block.classList.contains('choice-answered')) return; + + var choice = btn.dataset.choice; + // Buttons deaktivieren + markieren + block.querySelectorAll('.inline-choice-btn').forEach(function(b) { + b.disabled = true; + b.classList.add(b === btn ? 'choice-selected' : 'choice-unselected'); + }); + block.classList.add('choice-answered'); + + // Inline-Choice als normale User-Nachricht senden (kein tool_use in DB vorhanden) + sendMessageDirect(choice); +}); diff --git a/wwwroot/js/markdown.js b/wwwroot/js/markdown.js index f52fded..0b5f9c4 100644 --- a/wwwroot/js/markdown.js +++ b/wwwroot/js/markdown.js @@ -6,6 +6,18 @@ 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 = []; @@ -95,6 +107,22 @@ 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 138fc3e..481eb5c 100644 --- a/wwwroot/js/stream-ui.js +++ b/wwwroot/js/stream-ui.js @@ -15,7 +15,16 @@ function startStreamBubble() { function appendToStream(text) { if (!_streamDiv) startStreamBubble(); _streamText += text; - _streamDiv.innerHTML = renderMarkdown(_streamText); + + // 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); // Cursor ins letzte Inline/Text-Element einfügen, damit er nicht auf eine neue Zeile rutscht const cursor = document.createElement('span'); cursor.className = 'cursor'; diff --git a/wwwroot/js/tool-ui.js b/wwwroot/js/tool-ui.js index 9131bde..d60e33c 100644 --- a/wwwroot/js/tool-ui.js +++ b/wwwroot/js/tool-ui.js @@ -10,12 +10,19 @@ const CLI_TOOL_NAME_MAP = { 'WebSearch': 'web_search', 'WebFetch': 'web_fetch', 'NotebookEdit': 'notebook_edit', - 'ToolSearch': 'tool_search' + 'ToolSearch': 'tool_search', + 'mcp__gott-remote__remote_execute': 'remote_execute' }; /** Normalize CLI tool name to gott convention (Edit → edit_file, etc.) */ function normalizeToolName(name) { - return CLI_TOOL_NAME_MAP[name] || name; + if (CLI_TOOL_NAME_MAP[name]) return CLI_TOOL_NAME_MAP[name]; + // Generisch: mcp__server__tool → tool + if (name && name.startsWith('mcp__')) { + var parts = name.split('__'); + return parts[parts.length - 1] || name; + } + return name; } /** Normalize CLI tool input fields (file_path → path, old_string → old_text, etc.) */