using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Gott.Endpoints; using Gott.Models; using Microsoft.AspNetCore.Http; namespace Gott.Services; public partial class AnthropicService { private static string _cachedPythonCmd; private static bool _pythonCmdChecked; /// /// Streaming-Chat über die Claude CLI. /// Die CLI verwendet das lokale Token aus ~/.claude/.credentials.json. /// public async Task StreamChatViaCli( HttpResponse response, string modelId, List systemPrompt, List messages, string chainId, string workspace = null, bool allowUserChoice = false, bool planMode = false, bool webSearch = false, string sessionId = null, string agentId = null, RemoteConnectionManager remoteMgr = null, string unityMcpUrl = null, string unityMcpToken = null, bool useAutoContext = true, string effort = null, TokenUsage seedUsage = null, CancellationToken workCt = default, CancellationToken sseCt = default) { if (_cliService == null || !_cliService.IsAvailable) { await SseHelper.TryWriteSse(response, "error", new { error = "Claude CLI nicht verfügbar" }, sseCt); await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt); return; } var sw = System.Diagnostics.Stopwatch.StartNew(); var usage = new TokenUsage(); // Panel-Member-Tokens (Fusion/MoA) vorab in die Chain-Usage falten → erscheinen im // done-Event (Live-Badge) UND in FinalizeChain (DB, überlebt F5). Null/leer = No-op. if (seedUsage != null) usage.Add(seedUsage); var fullText = new StringBuilder(); var tempImagePaths = new List(); string mcpConfigPath = null; bool isUserChoice = false; CancellationTokenSource segCts = null; // aktuelles CLI-Segment (Kill-and-Restart-Steering) var chain = new ChainRecord { ChainId = chainId, SessionId = sessionId, Model = modelId }; try { // MCP-Config generieren (immer – für ask_user_choice, optional Memory-Tools + remote_execute) var remoteConn = remoteMgr != null && !string.IsNullOrEmpty(sessionId) ? remoteMgr.GetBySessionId(sessionId) : null; var hasRemote = remoteConn != null; var remoteOs = remoteConn?.OsVersion ?? ""; mcpConfigPath = GenerateMcpConfig(sessionId, agentId, hasRemote, unityMcpUrl, unityMcpToken, remoteOs, modelId, webSearch); // Python-Verfügbarkeit prüfen und ggf. warnen if (FindPythonCommand() == null) await SseHelper.TryWriteSse(response, "warning", new { message = "⚠ Python wurde nicht gefunden (python3/python/py). MCP-Tools (Remote-Execute, Memory, etc.) sind nicht verfügbar." }, sseCt); // System-Prompt als Text extrahieren var systemText = new StringBuilder(); if (systemPrompt != null) { foreach (var block in systemPrompt) { var json = JsonSerializer.Serialize(block); var el = JsonSerializer.Deserialize(json); if (el.TryGetProperty("text", out var t)) systemText.AppendLine(t.GetString()); } } // ── Automatische Kontext-Injektion via Embedding-Suche (wie in StreamChat) ── if (useAutoContext && messages.Count > 0) { try { var autoCtx = await BuildAutoContextAsync(_vectorSearch, messages, chainId, agentId); if (autoCtx != null) { systemText.AppendLine(autoCtx); await SseHelper.TryWriteSse(response, "auto_context", new { context = autoCtx }, sseCt); await _db.SaveMessage(new MessageRecord { ChainId = chainId, Role = "system", Type = "auto_context", Content = autoCtx }); } } catch (Exception ex) { Console.WriteLine($"[AutoContext/CLI] Error: {ex.Message}"); } } // Letzte User-Message als Prompt extrahieren var prompt = ExtractLastUserText(messages) ?? ""; // Bild-Attachments aus letzter User-Message als Temp-Dateien speichern // (CLI kann Bilder nicht inline empfangen, aber per Read-Tool lesen) var imageRefs = ExtractLastUserImageRefs(messages, tempImagePaths); if (!string.IsNullOrEmpty(imageRefs)) { prompt = imageRefs + "\n" + prompt; } // Konversations-History als Kontext zum Prompt hinzufügen var historyContext = BuildHistoryContext(messages); if (!string.IsNullOrEmpty(historyContext)) { prompt = $"\n{historyContext}\n\n\n\n{prompt}\n"; } string choiceToolCallId = null; // ── Kill-and-Restart-Steering ── // Der Agentic-Loop läuft INNERHALB von `claude -p` – gott sieht nur Events, // echtes Mid-Turn-Inject ist über die CLI nicht möglich (Einwürfe würden nur // gequeued). Stattdessen: kommt während eines Laufs ein Einwurf rein, killen wir // den Child am nächsten Tool-Boundary, hängen den bisherigen Verlauf + den Einwurf // an den Prompt und starten die CLI neu (Abbruch-Semantik – mehr geht über die CLI nicht). var basePrompt = prompt; var currentPrompt = prompt; int totalTurns = 0; double totalCost = 0; int steerRestartCount = 0; CliChatResult result = null; bool steerRestart; bool segSteerTriggered = false; // Event-Handler als lokale Funktion (damit der Restart-Loop flach bleibt) async Task HandleCliEvent(CliStreamEvent evt) { switch (evt.Type) { case CliEventType.TextStart: await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, sseCt); break; case CliEventType.TextDelta: fullText.Append(evt.Text); await SseHelper.TryWriteSse(response, "text_delta", new { text = evt.Text }, sseCt); break; case CliEventType.TextEnd: await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt); break; case CliEventType.ToolStart: { // Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB) if (fullText.Length > 0) { var textForEmbed = fullText.ToString(); var msgId = await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = 1, Role = "assistant", Type = "text", Content = textForEmbed }); _embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", textForEmbed); fullText.Clear(); } var mappedName = MapCliToolName(evt.ToolName); await SseHelper.TryWriteSse(response, "tool_start", new { name = mappedName, id = evt.ToolId }, sseCt); break; } case CliEventType.ToolEnd: { var mappedName = MapCliToolName(evt.ToolName); var mappedInput = MapCliToolInput(evt.ToolName, evt.ToolInput); // Tool-Use in DB speichern (damit Poll die Tools anzeigt) await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = 1, Role = "assistant", Type = "tool_use", ToolName = mappedName, ToolCallId = evt.ToolId, 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() ?? ""); // 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; 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: { // Tool-Result in DB speichern (damit es beim Neuladen sichtbar ist) var resultContent = SseHelper.TruncateToolResult(evt.ToolResultContent ?? ""); await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = 1, Role = "user", Type = "tool_result", ToolCallId = evt.ToolId, Content = resultContent, IsError = evt.ToolResultIsError }); // SSE Event an Client senden await SseHelper.TryWriteSse(response, "tool_result", new { id = evt.ToolId, result = resultContent, isError = evt.ToolResultIsError }, sseCt); // ask_user_choice wurde gerade beantwortet (Result des Choice-Tools kam zurück) // → Choice-Pause aufheben: die CLI nimmt die Arbeit in DERSELBEN Invocation // wieder auf, also muss Steering ab hier wieder greifen. Ohne diesen Reset // bliebe isUserChoice für den Rest des Laufs true und ein Einwurf käme erst // ganz am Chain-Ende (als Close()-Leftover) an statt am nächsten Boundary. if (isUserChoice && choiceToolCallId != null && evt.ToolId == choiceToolCallId) { isUserChoice = false; choiceToolCallId = null; } // Steering: Tool-Boundary erreicht – steht ein Einwurf an, Child killen // (löst OperationCanceledException aus → Restart unten). if (!segSteerTriggered && !isUserChoice && _steeringQueue != null && !string.IsNullOrEmpty(sessionId) && _steeringQueue.HasPending(sessionId)) { segSteerTriggered = true; try { segCts?.Cancel(); } catch { } } break; } } } do { steerRestart = false; segSteerTriggered = false; segCts?.Dispose(); segCts = CancellationTokenSource.CreateLinkedTokenSource(workCt); try { result = await _cliService.ChatStreamingAsync( currentPrompt, HandleCliEvent, modelId: modelId, systemPrompt: systemText.Length > 0 ? systemText.ToString() : null, workspace: workspace, hasWorkspace: !string.IsNullOrEmpty(workspace), planMode: planMode, webSearch: webSearch, mcpConfigPath: mcpConfigPath, hasUnityMcp: !string.IsNullOrEmpty(unityMcpUrl), effort: effort, cancellationToken: segCts.Token ); } catch (OperationCanceledException) when (segSteerTriggered && !workCt.IsCancellationRequested) { // Steering-Kill (kein echter Abbruch) → Restart unten vorbereiten result = null; } // Tokens des abgeschlossenen Segments akkumulieren (bei Kill: result == null) if (result != null) { usage.InputTokens += result.InputTokens; usage.OutputTokens += result.OutputTokens; usage.CacheReadTokens += result.CacheReadTokens; usage.CacheCreationTokens += result.CacheCreationTokens; totalCost += result.CostUsd; totalTurns += result.NumTurns > 0 ? result.NumTurns : 1; } // Restart nötig? (a) Kill am Tool-Boundary, (b) Einwurf kam erst am Turn-Ende rein bool steerPending = !isUserChoice && _steeringQueue != null && !string.IsNullOrEmpty(sessionId) && _steeringQueue.HasPending(sessionId); if (segSteerTriggered || steerPending) { steerRestart = true; Console.WriteLine($"[CLI-Steering] Restart #{++steerRestartCount} (chain {chainId})"); // 1. Bisher gepufferten Assistant-Text dieses Segments persistieren if (fullText.Length > 0) { var partial = fullText.ToString(); var pid = await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = 1, Role = "assistant", Type = "text", Content = partial }); _embeddingBg?.Enqueue(pid, chainId, sessionId, agentId, "assistant", partial); fullText.Clear(); } // 2. Verlauf dieses Durchlaufs aus der DB als Transkript (vor dem Persistieren der neuen Einwürfe) var progressLog = await BuildCliProgressLog(chainId); // 3. Einwürfe entnehmen, RAW persistieren (Type=steer), via SSE melden, gewrappt sammeln var steers = _steeringQueue.Drain(sessionId); var wrapped = new StringBuilder(); foreach (var steer in steers) { var sid = await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = 1, Role = "user", Type = "steer", Content = steer }); _embeddingBg?.Enqueue(sid, chainId, sessionId, agentId, "user", steer); await SseHelper.TryWriteSse(response, "steer_injected", new { text = steer }, sseCt); wrapped.AppendLine(WrapSteeringMessage(steer)); } // 4. Restart-Prompt: Basis-Prompt + bisheriger Verlauf + Einwürfe var sb = new StringBuilder(basePrompt); if (!string.IsNullOrEmpty(progressLog)) { sb.Append("\n\n\nDu arbeitest bereits an obiger Aufgabe. Bisheriger Verlauf in diesem Durchlauf (Text, Tool-Aufrufe, Ergebnisse):\n"); sb.Append(progressLog); sb.Append("\n\nWICHTIG: Setze die Aufgabe JETZT aktiv fort und bringe sie in diesem Durchlauf zu Ende. Bestätige den Einwurf NICHT nur und beende dann den Turn – arbeite die noch offenen Schritte selbst weiter ab. Falls du zuvor Hintergrund-/Async-Operationen gestartet hattest (z.B. Background-Bash-Tasks, lange Sleeps): diese wurden durch den Neustart beendet und existieren NICHT mehr – warte nicht passiv auf sie, sondern starte sie neu, falls für die Fertigstellung nötig.\n"); } sb.Append("\n\n"); sb.Append(wrapped.ToString().Trim()); currentPrompt = sb.ToString(); } } while (steerRestart && !workCt.IsCancellationRequested); // Bei CLI-Fehler → Fehlermeldung an Client senden und in DB speichern if (result != null && !result.Success && !isUserChoice) { var errorMsg = !string.IsNullOrEmpty(result.Error) ? result.Error : "CLI hat keine Antwort geliefert"; // Error in DB speichern (damit er beim Neuladen sichtbar ist) await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = 1, Role = "system", Type = "error", Content = $"CLI-Fehler: {errorMsg}" }); await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {errorMsg}" }, sseCt); } // Antwort in DB speichern (finaler Text des letzten Segments) if (fullText.Length > 0) { var finalText = fullText.ToString(); var msgId = await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = 1, Role = "assistant", Type = "text", Content = finalText }); _embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", finalText); } // Token-Usage wurde segmentweise in usage/totalTurns/totalCost akkumuliert (siehe Restart-Loop) var stopReason = isUserChoice ? "awaiting_user_choice" : (result != null && result.Success ? "end_turn" : "error"); await FinalizeChain(chain, usage, totalTurns > 0 ? totalTurns : 1, stopReason, sw); await SseHelper.TryWriteSse(response, "done", new { model = modelId, inputTokens = usage.InputTokens, outputTokens = usage.OutputTokens, cacheCreationTokens = usage.CacheCreationTokens, cacheReadTokens = usage.CacheReadTokens, costUsd = totalCost, iterations = totalTurns > 0 ? totalTurns : 1, chainId, durationMs = sw.ElapsedMilliseconds, stopReason, effort, viaCli = true }, sseCt); } catch (OperationCanceledException) when (workCt.IsCancellationRequested) { // Stop-Button gedrückt → CLI-Prozess wurde gestoppt // 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 = stoppedReason, effort, viaCli = true }, CancellationToken.None); } catch (OperationCanceledException) { // Client-Disconnect (sseCt gecancelled, aber NICHT workCt) // CLI-Prozess läuft weiter im Hintergrund – hier nur Chain finalisieren // (sollte nicht mehr passieren, da CLI jetzt workCt nutzt) await FinalizeChain(chain, usage, 1, "client_disconnected", sw); } catch (Exception ex) { var errorMsg = $"CLI-Fehler: {ex.Message}"; await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = 1, Role = "system", Type = "error", Content = errorMsg }); await SseHelper.TryWriteSse(response, "error", new { error = errorMsg }, sseCt); await FinalizeChain(chain, usage, 1, "error", sw); await SseHelper.TryWriteSse(response, "done", new { model = modelId, chainId, durationMs = sw.ElapsedMilliseconds, stopReason = "error", effort, viaCli = true }, sseCt); } finally { segCts?.Dispose(); // Temp-Bilddateien aufräumen CleanupTempImages(tempImagePaths); // MCP-Config Temp-Datei aufräumen if (mcpConfigPath != null) { try { File.Delete(mcpConfigPath); } catch { } } } } /// /// Baut aus den bereits gespeicherten Messages einer Chain ein kompaktes Verlauf-Transkript /// für den Kill-and-Restart-Steering-Prompt (was hat das Modell in diesem Durchlauf bereits getan). /// private async Task BuildCliProgressLog(string chainId) { var msgs = await _db.GetChainMessages(chainId); var sb = new StringBuilder(); foreach (var m in msgs) { switch (m.Type) { case "text": if (m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)) sb.AppendLine("[Du]: " + m.Content.Trim()); break; case "tool_use": sb.AppendLine($"[Du hast aufgerufen: {m.ToolName}] {TruncateForLog(m.Content, 600)}"); break; case "tool_result": sb.AppendLine($"[Ergebnis{(m.IsError ? " (Fehler)" : "")}]: {TruncateForLog(m.Content, 600)}"); break; case "steer": sb.AppendLine("[Vorheriger Live-Einwurf des Users]: " + (m.Content ?? "").Trim()); break; } } return sb.ToString().Trim(); } private static string TruncateForLog(string s, int max) { if (string.IsNullOrEmpty(s)) return ""; s = s.Replace("\r", " ").Replace("\n", " ").Trim(); return s.Length <= max ? s : s.Substring(0, max) + " …[gekürzt]"; } /// /// Non-Streaming Chat über die Claude CLI für Telegram (und andere Caller, die kein SSE brauchen). /// Sammelt den finalen Text und gibt ihn zurück. Persistiert Text, Tool-Use und Tool-Result in der DB, /// damit die History im Web-UI korrekt rekonstruiert werden kann. /// public async Task ChatNonStreamingViaCli( ChatContext ctx, List messages, Func onIteration = null, CancellationToken cancellationToken = default) { if (_cliService == null || !_cliService.IsAvailable) return ""; var sw = System.Diagnostics.Stopwatch.StartNew(); var usage = new TokenUsage(); var tempImagePaths = new List(); var chain = new ChainRecord { ChainId = ctx.ChainId, SessionId = ctx.SessionId, Model = ctx.ModelId }; try { // System-Prompt als Text var systemText = new StringBuilder(ctx.SystemPromptText ?? ""); // Auto-Context (analog Streaming-Pfad) if ((ctx.Agent?.UseAutoContext ?? true) && messages.Count > 0) { try { var autoCtx = await BuildAutoContextAsync(_vectorSearch, messages, ctx.ChainId, ctx.AgentId); if (!string.IsNullOrEmpty(autoCtx)) { systemText.AppendLine(autoCtx); await _db.SaveMessage(new MessageRecord { ChainId = ctx.ChainId, Role = "system", Type = "auto_context", Content = autoCtx }); } } catch { /* silent – Auto-Context optional */ } } // Prompt aus letzter User-Message + Bild-/PDF-Refs + History-Kontext var prompt = ExtractLastUserText(messages) ?? ""; var imageRefs = ExtractLastUserImageRefs(messages, tempImagePaths); if (!string.IsNullOrEmpty(imageRefs)) prompt = imageRefs + "\n" + prompt; var historyContext = BuildHistoryContext(messages); if (!string.IsNullOrEmpty(historyContext)) prompt = $"\n{historyContext}\n\n\n\n{prompt}\n"; var fullText = new StringBuilder(); if (onIteration != null) await onIteration(1); var result = await _cliService.ChatStreamingAsync( prompt, async evt => { switch (evt.Type) { case CliEventType.TextDelta: fullText.Append(evt.Text); break; case CliEventType.ToolStart: // Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB) if (fullText.Length > 0) { var textForEmbed = fullText.ToString(); var preMsgId = await _db.SaveMessage(new MessageRecord { ChainId = ctx.ChainId, Iteration = 1, Role = "assistant", Type = "text", Content = textForEmbed }); _embeddingBg?.Enqueue(preMsgId, ctx.ChainId, ctx.SessionId, ctx.AgentId, "assistant", textForEmbed); fullText.Clear(); } if (onIteration != null) await onIteration(1); break; case CliEventType.ToolEnd: { var mappedName = MapCliToolName(evt.ToolName); var mappedInput = MapCliToolInput(evt.ToolName, evt.ToolInput); await _db.SaveMessage(new MessageRecord { ChainId = ctx.ChainId, Iteration = 1, Role = "assistant", Type = "tool_use", ToolName = mappedName, ToolCallId = evt.ToolId, Content = mappedInput }); break; } case CliEventType.ToolResult: await _db.SaveMessage(new MessageRecord { ChainId = ctx.ChainId, Iteration = 1, Role = "user", Type = "tool_result", ToolCallId = evt.ToolId, Content = evt.ToolResultContent ?? "", IsError = evt.ToolResultIsError }); break; } }, modelId: ctx.ModelId, systemPrompt: systemText.ToString(), workspace: ctx.Workspace, hasWorkspace: !string.IsNullOrEmpty(ctx.Workspace), effort: ctx.Effort, cancellationToken: cancellationToken ); // Finalen Text persistieren var finalText = fullText.ToString(); if (finalText.Length > 0) { var msgId = await _db.SaveMessage(new MessageRecord { ChainId = ctx.ChainId, Iteration = 1, Role = "assistant", Type = "text", Content = finalText }); _embeddingBg?.Enqueue(msgId, ctx.ChainId, ctx.SessionId, ctx.AgentId, "assistant", finalText); } usage.InputTokens = result.InputTokens; usage.OutputTokens = result.OutputTokens; usage.CacheReadTokens = result.CacheReadTokens; usage.CacheCreationTokens = result.CacheCreationTokens; var stopReason = result.Success ? "end_turn" : "error"; await FinalizeChain(chain, usage, result.NumTurns > 0 ? result.NumTurns : 1, stopReason, sw); return finalText; } catch (Exception ex) { await _db.SaveMessage(new MessageRecord { ChainId = ctx.ChainId, Iteration = 1, Role = "system", Type = "error", Content = $"CLI-Fehler: {ex.Message}", IsError = true }); await FinalizeChain(chain, usage, 1, "error", sw); return ""; } finally { CleanupTempImages(tempImagePaths); } } /// /// Mappt CLI-Tool-Namen (Edit, Write, Bash, etc.) auf gott-Tool-Namen (edit_file, write_file, etc.). /// private static string MapCliToolName(string cliName) { return cliName switch { "Edit" => "edit_file", "Write" => "write_file", "Read" => "read_file", "Bash" => "execute", "Glob" => "list_files", "Grep" => "search_files", "WebSearch" => "web_search", "WebFetch" => "web_fetch", "NotebookEdit" => "notebook_edit", "TodoWrite" => "todo_write", _ => cliName?.StartsWith("mcp__") == true ? cliName.Split("__").LastOrDefault() ?? cliName : cliName }; } /// /// Mappt CLI-Tool-Input-Felder auf gott-Konventionen (z.B. file_path→path, old_string→old_text). /// private static string MapCliToolInput(string cliToolName, string inputJson) { if (string.IsNullOrEmpty(inputJson)) return inputJson; try { var input = JsonSerializer.Deserialize(inputJson); if (input.ValueKind != JsonValueKind.Object) return inputJson; switch (cliToolName) { case "Edit": { // file_path → path, old_string → old_text, new_string → new_text var mapped = new Dictionary(); foreach (var prop in input.EnumerateObject()) { var key = prop.Name switch { "file_path" => "path", "old_string" => "old_text", "new_string" => "new_text", _ => prop.Name }; mapped[key] = prop.Value; } return JsonSerializer.Serialize(mapped); } case "Write": { // file_path → path var mapped = new Dictionary(); foreach (var prop in input.EnumerateObject()) { var key = prop.Name == "file_path" ? "path" : prop.Name; mapped[key] = prop.Value; } return JsonSerializer.Serialize(mapped); } case "Read": { // file_path → path var mapped = new Dictionary(); foreach (var prop in input.EnumerateObject()) { var key = prop.Name == "file_path" ? "path" : prop.Name; mapped[key] = prop.Value; } return JsonSerializer.Serialize(mapped); } case "Bash": { // command bleibt gleich return inputJson; } default: return inputJson; } } catch { return inputJson; } } /// /// Baut einen Kontext-String aus der Konversations-History. /// private static string BuildHistoryContext(List messages) { var sb = new StringBuilder(); // Letzte Message überspringen wenn es ein Text-Prompt ist (wird separat als current_request gesendet). // Wenn die letzte Message ein tool_result ist (z.B. Choice-Antwort), NICHT überspringen. int takeCount = messages.Count; if (messages.Count > 0) { var lastJson = JsonSerializer.Serialize(messages[messages.Count - 1]); var lastEl = JsonSerializer.Deserialize(lastJson); bool lastIsToolResult = false; if (lastEl.TryGetProperty("content", out var lc) && lc.ValueKind == JsonValueKind.Array) { foreach (var b in lc.EnumerateArray()) { var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : ""; if (bType == "tool_result") { lastIsToolResult = true; break; } } } if (!lastIsToolResult) takeCount = messages.Count - 1; } foreach (var msg in messages.Take(takeCount)) { var json = JsonSerializer.Serialize(msg); var el = JsonSerializer.Deserialize(json); if (!el.TryGetProperty("role", out var role)) continue; var roleStr = role.GetString() ?? ""; if (el.TryGetProperty("content", out var content)) { if (content.ValueKind == JsonValueKind.String) { sb.AppendLine($"[{roleStr}]: {content.GetString()}"); } else if (content.ValueKind == JsonValueKind.Array) { foreach (var block in content.EnumerateArray()) { var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : ""; if (blockType == "text" && block.TryGetProperty("text", out var txt)) { sb.AppendLine($"[{roleStr}]: {txt.GetString()}"); } else if (blockType == "tool_use" && block.TryGetProperty("name", out var tn) && tn.GetString() == "ask_user_choice") { if (block.TryGetProperty("input", out var inp)) { var q = inp.TryGetProperty("question", out var qq) ? qq.GetString() ?? "" : ""; var opts = new List(); if (inp.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array) foreach (var o in cc.EnumerateArray()) opts.Add(o.GetString() ?? ""); sb.AppendLine($"[assistant]: Ich habe gefragt: \"{q}\" mit Optionen: {string.Join(", ", opts)}"); } } else if (blockType == "tool_result" && block.TryGetProperty("content", out var trContent)) { sb.AppendLine($"[user]: Du hast gewählt: \"{trContent.GetString()}\""); } } } } } return sb.ToString().Trim(); } /// /// Sucht einen verfügbaren Python-Befehl (python3, python, py). /// Gibt null zurück, wenn keiner gefunden wird. /// private static string FindPythonCommand() { if (_pythonCmdChecked) return _cachedPythonCmd; foreach (var cmd in new[] { "python3", "python", "py" }) { try { var psi = new System.Diagnostics.ProcessStartInfo { FileName = cmd, Arguments = "--version", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var proc = System.Diagnostics.Process.Start(psi); if (proc != null) { proc.WaitForExit(3000); if (proc.ExitCode == 0) { _cachedPythonCmd = cmd; _pythonCmdChecked = true; Console.WriteLine($"[MCP] Python gefunden: {cmd}"); return cmd; } } } catch { /* Command not found – try next */ } } _pythonCmdChecked = true; _cachedPythonCmd = null; return null; } /// /// 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, string agentId = null, bool hasRemote = false, string unityMcpUrl = null, string unityMcpToken = null, string remoteOs = null, string modelId = null, bool webSearch = false) { // 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)) { 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", ["GOTT_REMOTE_OS"] = remoteOs ?? "" }; var pythonCmd = FindPythonCommand(); var mcpServers = new Dictionary { ["gott-remote"] = new { command = pythonCmd ?? "python3", args = new[] { scriptPath }, env } }; if (pythonCmd == null) Console.WriteLine("[MCP] ⚠ Python nicht gefunden (python3/python/py) – MCP-Tools werden nicht funktionieren!"); // Unity MCP Server (pro Session konfigurierbar) // Claude CLI erfordert expliziten "type" – "http" für streamableHttp, "sse" für SSE if (!string.IsNullOrEmpty(unityMcpUrl)) { // URL-basierte Typ-Erkennung: /sse → SSE-Transport, sonst HTTP (streamableHttp) var mcpType = unityMcpUrl.TrimEnd('/').EndsWith("/sse", StringComparison.OrdinalIgnoreCase) ? "sse" : "http"; var unityConfig = new Dictionary { ["type"] = mcpType, ["url"] = unityMcpUrl }; if (!string.IsNullOrEmpty(unityMcpToken)) { unityConfig["headers"] = new Dictionary { ["Authorization"] = $"Bearer {unityMcpToken}" }; } mcpServers["unity"] = unityConfig; } // z.ai Web-Search-Prime-MCP: GLM-Modelle können Anthropics native WebSearch nicht nutzen. // Läuft ein GLM-Modell mit aktivierter Web-Suche und ist der z.ai-Key gesetzt, registrieren // wir z.ais HTTP-MCP-Suchserver (Bearer-Auth mit demselben Key). Tool wird in den // --allowed-tools der CLI (ClaudeCliService) als "mcp__web-search-prime__*" freigegeben. if (webSearch && ClaudeCliService.IsZaiModel(modelId)) { var zaiKey = _settings?.Get("ZaiApiKey"); if (!string.IsNullOrEmpty(zaiKey)) { mcpServers["web-search-prime"] = new Dictionary { ["type"] = "http", ["url"] = "https://api.z.ai/api/mcp/web_search_prime/mcp", ["headers"] = new Dictionary { ["Authorization"] = $"Bearer {zaiKey}" } }; } } var config = new { mcpServers }; 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; } }