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,
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();
var fullText = new StringBuilder();
var tempImagePaths = new List();
string mcpConfigPath = null;
bool isUserChoice = false;
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 hasRemote = remoteMgr != null && !string.IsNullOrEmpty(sessionId) && remoteMgr.GetBySessionId(sessionId) != null;
mcpConfigPath = GenerateMcpConfig(sessionId, agentId, hasRemote, unityMcpUrl, unityMcpToken);
// 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(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;
var result = await _cliService.ChatStreamingAsync(
prompt,
async 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);
break;
}
}
},
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),
cancellationToken: workCt
);
// Bei CLI-Fehler → Fehlermeldung an Client senden und in DB speichern
if (!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
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 vom CLI-Result übernehmen
usage.InputTokens = result.InputTokens;
usage.OutputTokens = result.OutputTokens;
usage.CacheReadTokens = result.CacheReadTokens;
usage.CacheCreationTokens = result.CacheCreationTokens;
var stopReason = isUserChoice ? "awaiting_user_choice" : (result.Success ? "end_turn" : "error");
await FinalizeChain(chain, usage, result.NumTurns > 0 ? result.NumTurns : 1, stopReason, sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
inputTokens = result.InputTokens,
outputTokens = result.OutputTokens,
cacheCreationTokens = result.CacheCreationTokens,
cacheReadTokens = result.CacheReadTokens,
costUsd = result.CostUsd,
iterations = result.NumTurns > 0 ? result.NumTurns : 1,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason,
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,
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",
viaCli = true
}, sseCt);
}
finally
{
// Temp-Bilddateien aufräumen
CleanupTempImages(tempImagePaths);
// MCP-Config Temp-Datei aufräumen
if (mcpConfigPath != null)
{
try { File.Delete(mcpConfigPath); } catch { }
}
}
}
///
/// 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)
{
// 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"
};
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;
}
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;
}
}