From 5f210323fd9d0e15420fa872ab7368af797951f0 Mon Sep 17 00:00:00 2001 From: Meleki Date: Thu, 26 Feb 2026 14:47:10 +0100 Subject: [PATCH] Backend Refactoring: Models, Services, Endpoints aufgeteilt --- Endpoints/ChatEndpoint.cs | 65 +++++ Models/ChatModels.cs | 25 ++ Program.cs | 542 +---------------------------------- Services/AnthropicService.cs | 284 ++++++++++++++++++ Services/ToolService.cs | 228 +++++++++++++++ 5 files changed, 609 insertions(+), 535 deletions(-) create mode 100644 Endpoints/ChatEndpoint.cs create mode 100644 Models/ChatModels.cs create mode 100644 Services/AnthropicService.cs create mode 100644 Services/ToolService.cs diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs new file mode 100644 index 0000000..0945aa1 --- /dev/null +++ b/Endpoints/ChatEndpoint.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Gott.Models; +using Gott.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Gott.Endpoints; + +public static class ChatEndpoint +{ + public static void MapChatEndpoint(this IEndpointRouteBuilder app) + { + app.MapPost("/api/chat", HandleChat); + } + + private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ToolService toolService) + { + var body = await JsonSerializer.DeserializeAsync(req.Body); + if (body == null || body.Messages == null || body.Messages.Count == 0) + { + res.StatusCode = 400; + await res.WriteAsJsonAsync(new { error = "messages required" }); + return; + } + + var token = config["AnthropicToken"]; + if (string.IsNullOrEmpty(token)) + { + res.StatusCode = 500; + await res.WriteAsJsonAsync(new { error = "AnthropicToken not configured" }); + return; + } + + var modelId = body.Model switch + { + "opus" => "claude-opus-4-6", + "sonnet" => "claude-sonnet-4-6", + _ => "claude-sonnet-4-6" + }; + + res.Headers["Content-Type"] = "text/event-stream"; + res.Headers["Cache-Control"] = "no-cache"; + res.Headers["Connection"] = "keep-alive"; + + var messages = new List(); + foreach (var msg in body.Messages) + messages.Add(new { role = msg.Role, content = msg.Content }); + + var systemPrompt = new List + { + new { type = "text", text = "You are Claude Code, Anthropic's official CLI for Claude." } + }; + if (!string.IsNullOrWhiteSpace(body.SystemPrompt)) + systemPrompt.Add(new { type = "text", text = body.SystemPrompt }); + + var anthropic = new AnthropicService(httpFactory, token, toolService); + await anthropic.StreamChat(res, modelId, systemPrompt, messages); + } +} diff --git a/Models/ChatModels.cs b/Models/ChatModels.cs new file mode 100644 index 0000000..1319178 --- /dev/null +++ b/Models/ChatModels.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Gott.Models; + +public class ChatMessage +{ + [JsonPropertyName("role")] + public string Role { get; set; } = ""; + + [JsonPropertyName("content")] + public string Content { get; set; } = ""; +} + +public class ChatRequest +{ + [JsonPropertyName("model")] + public string Model { get; set; } = "sonnet"; + + [JsonPropertyName("messages")] + public List Messages { get; set; } = new(); + + [JsonPropertyName("systemPrompt")] + public string SystemPrompt { get; set; } = ""; +} diff --git a/Program.cs b/Program.cs index 76777ff..624f2a0 100644 --- a/Program.cs +++ b/Program.cs @@ -1,551 +1,23 @@ using System; -using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; +using Gott.Endpoints; +using Gott.Services; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; var builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient(); -var app = builder.Build(); -app.UseDefaultFiles(); -app.UseStaticFiles(); - var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); var workspaceRoot = Path.Combine(dataDir, "workspace"); Directory.CreateDirectory(workspaceRoot); -// ── Tool Definitionen ── +builder.Services.AddSingleton(new ToolService(workspaceRoot)); -var toolDefs = new[] -{ - new - { - name = "read_file", - description = "Liest den Inhalt einer Datei. Gibt den Textinhalt zurück.", - input_schema = new - { - type = "object", - properties = new Dictionary - { - ["path"] = new { type = "string", description = "Relativer Dateipfad" } - }, - required = new[] { "path" } - } - }, - new - { - name = "write_file", - description = "Erstellt oder überschreibt eine Datei mit dem angegebenen Inhalt.", - input_schema = new - { - type = "object", - properties = new Dictionary - { - ["path"] = new { type = "string", description = "Relativer Dateipfad" }, - ["content"] = new { type = "string", description = "Dateiinhalt" } - }, - required = new[] { "path", "content" } - } - }, - new - { - name = "edit_file", - description = "Ersetzt einen exakten Textabschnitt in einer Datei. oldText muss exakt matchen.", - input_schema = new - { - type = "object", - properties = new Dictionary - { - ["path"] = new { type = "string", description = "Relativer Dateipfad" }, - ["old_text"] = new { type = "string", description = "Exakter Text der ersetzt werden soll" }, - ["new_text"] = new { type = "string", description = "Neuer Text" } - }, - required = new[] { "path", "old_text", "new_text" } - } - }, - new - { - name = "list_files", - description = "Listet Dateien und Ordner in einem Verzeichnis auf.", - input_schema = new - { - type = "object", - properties = new Dictionary - { - ["path"] = new { type = "string", description = "Relativer Verzeichnispfad (leer = Root)" } - }, - required = System.Array.Empty() - } - }, - new - { - name = "delete_file", - description = "Löscht eine Datei oder ein leeres Verzeichnis.", - input_schema = new - { - type = "object", - properties = new Dictionary - { - ["path"] = new { type = "string", description = "Relativer Pfad zur Datei oder zum Verzeichnis" } - }, - required = new[] { "path" } - } - }, - new - { - name = "execute", - description = "Führt einen Shell-Befehl im Workspace-Verzeichnis aus. Gibt stdout und stderr zurück. Timeout: 30 Sekunden.", - input_schema = new - { - type = "object", - properties = new Dictionary - { - ["command"] = new { type = "string", description = "Shell-Befehl zum Ausführen" } - }, - required = new[] { "command" } - } - } -}; +var app = builder.Build(); +app.UseDefaultFiles(); +app.UseStaticFiles(); -// ── Hilfsfunktionen ── - -string ResolvePath(string relativePath) -{ - var full = Path.GetFullPath(Path.Combine(workspaceRoot, relativePath ?? "")); - if (!full.StartsWith(workspaceRoot)) - throw new Exception("Zugriff außerhalb des Workspace nicht erlaubt"); - return full; -} - -async Task ExecuteTool(string name, JsonElement input) -{ - switch (name) - { - case "read_file": - { - var path = ResolvePath(input.GetProperty("path").GetString() ?? ""); - if (!File.Exists(path)) - return $"Fehler: Datei nicht gefunden: {Path.GetRelativePath(workspaceRoot, path)}"; - return File.ReadAllText(path); - } - case "write_file": - { - var path = ResolvePath(input.GetProperty("path").GetString() ?? ""); - var dir = Path.GetDirectoryName(path); - if (dir != null) Directory.CreateDirectory(dir); - var content = input.GetProperty("content").GetString() ?? ""; - File.WriteAllText(path, content); - return $"Datei geschrieben: {Path.GetRelativePath(workspaceRoot, path)} ({content.Length} Zeichen)"; - } - case "edit_file": - { - var path = ResolvePath(input.GetProperty("path").GetString() ?? ""); - if (!File.Exists(path)) - return $"Fehler: Datei nicht gefunden: {Path.GetRelativePath(workspaceRoot, path)}"; - var fileContent = File.ReadAllText(path); - var oldText = input.GetProperty("old_text").GetString() ?? ""; - var newText = input.GetProperty("new_text").GetString() ?? ""; - if (!fileContent.Contains(oldText)) - return "Fehler: old_text nicht in der Datei gefunden. Exakter Match nötig."; - fileContent = fileContent.Replace(oldText, newText); - File.WriteAllText(path, fileContent); - return $"Datei bearbeitet: {Path.GetRelativePath(workspaceRoot, path)}"; - } - case "list_files": - { - var path = ResolvePath(input.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""); - if (!Directory.Exists(path)) - return $"Fehler: Verzeichnis nicht gefunden: {Path.GetRelativePath(workspaceRoot, path)}"; - var entries = new List(); - foreach (var dir in Directory.GetDirectories(path)) - entries.Add(Path.GetRelativePath(workspaceRoot, dir) + "/"); - foreach (var file in Directory.GetFiles(path)) - entries.Add(Path.GetRelativePath(workspaceRoot, file)); - return entries.Count == 0 ? "(leer)" : string.Join("\n", entries); - } - case "delete_file": - { - var path = ResolvePath(input.GetProperty("path").GetString() ?? ""); - if (File.Exists(path)) - { - File.Delete(path); - return $"Datei gelöscht: {Path.GetRelativePath(workspaceRoot, path)}"; - } - if (Directory.Exists(path)) - { - Directory.Delete(path, recursive: false); - return $"Verzeichnis gelöscht: {Path.GetRelativePath(workspaceRoot, path)}"; - } - return $"Fehler: Nicht gefunden: {Path.GetRelativePath(workspaceRoot, path)}"; - } - case "execute": - { - var command = input.GetProperty("command").GetString() ?? ""; - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = "/bin/bash", - WorkingDirectory = workspaceRoot, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - using var proc = System.Diagnostics.Process.Start(psi); - if (proc == null) return "Fehler: Prozess konnte nicht gestartet werden"; - await proc.StandardInput.WriteAsync(command); - proc.StandardInput.Close(); - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - try - { - await proc.WaitForExitAsync(cts.Token); - } - catch (OperationCanceledException) - { - proc.Kill(entireProcessTree: true); - return "Fehler: Timeout nach 30 Sekunden - Prozess abgebrochen"; - } - var stdout = await proc.StandardOutput.ReadToEndAsync(); - var stderr = await proc.StandardError.ReadToEndAsync(); - var result = new StringBuilder(); - if (!string.IsNullOrEmpty(stdout)) result.Append(stdout); - if (!string.IsNullOrEmpty(stderr)) - { - if (result.Length > 0) result.Append("\n--- stderr ---\n"); - result.Append(stderr); - } - if (result.Length == 0) result.Append($"(exit code {proc.ExitCode})"); - else if (proc.ExitCode != 0) result.Append($"\n(exit code {proc.ExitCode})"); - return result.ToString(); - } - default: - return $"Fehler: Unbekanntes Tool: {name}"; - } -} - -async Task WriteSseEvent(HttpResponse response, string eventType, object data) -{ - var json = JsonSerializer.Serialize(data); - await response.WriteAsync($"event: {eventType}\ndata: {json}\n\n"); - await response.Body.FlushAsync(); -} - -HttpClient CreateAnthropicClient(IHttpClientFactory factory, string token) -{ - var client = factory.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); - client.DefaultRequestHeaders.Add("anthropic-beta", "claude-code-20250219,oauth-2025-04-20"); - client.DefaultRequestHeaders.Add("User-Agent", "claude-cli/2.1.2 (external, cli)"); - client.DefaultRequestHeaders.Add("x-app", "cli"); - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - client.DefaultRequestHeaders.Add("anthropic-dangerous-direct-browser-access", "true"); - return client; -} - -// ── SSE Streaming Endpoint ── - -app.MapPost("/api/chat", async (HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config) => -{ - var body = await JsonSerializer.DeserializeAsync(req.Body); - if (body == null || body.Messages == null || body.Messages.Count == 0) - { - res.StatusCode = 400; - await res.WriteAsJsonAsync(new { error = "messages required" }); - return; - } - - var token = config["AnthropicToken"]; - if (string.IsNullOrEmpty(token)) - { - res.StatusCode = 500; - await res.WriteAsJsonAsync(new { error = "AnthropicToken not configured" }); - return; - } - - var modelId = body.Model switch - { - "opus" => "claude-opus-4-6", - "sonnet" => "claude-sonnet-4-6", - _ => "claude-sonnet-4-6" - }; - - // SSE Response starten - res.Headers["Content-Type"] = "text/event-stream"; - res.Headers["Cache-Control"] = "no-cache"; - res.Headers["Connection"] = "keep-alive"; - - var messages = new List(); - foreach (var msg in body.Messages) - messages.Add(new { role = msg.Role, content = msg.Content }); - - var systemPrompt = new List - { - new { type = "text", text = "You are Claude Code, Anthropic's official CLI for Claude." } - }; - if (!string.IsNullOrWhiteSpace(body.SystemPrompt)) - systemPrompt.Add(new { type = "text", text = body.SystemPrompt }); - - long totalInputTokens = 0; - long totalOutputTokens = 0; - int iterations = 0; - int maxIterations = 25; - - while (iterations < maxIterations) - { - iterations++; - - var payload = new Dictionary - { - ["model"] = modelId, - ["max_tokens"] = 4096, - ["system"] = systemPrompt, - ["messages"] = messages, - ["tools"] = toolDefs, - ["stream"] = true - }; - - var client = CreateAnthropicClient(httpFactory, token); - var json = JsonSerializer.Serialize(payload); - var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); - - var httpResp = await client.SendAsync( - new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent }, - HttpCompletionOption.ResponseHeadersRead - ); - - if (!httpResp.IsSuccessStatusCode) - { - var errBody = await httpResp.Content.ReadAsStringAsync(); - await WriteSseEvent(res, "error", new { error = $"API {(int)httpResp.StatusCode}: {errBody}" }); - await WriteSseEvent(res, "done", new { }); - return; - } - - // Anthropic SSE Stream parsen - var stream = await httpResp.Content.ReadAsStreamAsync(); - using var reader = new StreamReader(stream); - - var stopReason = ""; - var assistantContent = new List(); - var currentToolId = ""; - var currentToolName = ""; - var currentToolInputJson = new StringBuilder(); - var currentTextBlock = new StringBuilder(); - var blockIndex = -1; - - var blockTypes = new Dictionary(); - var executedTools = new List<(string Id, string Result, bool IsError)>(); - - string line; - while ((line = await reader.ReadLineAsync()) != null) - { - if (!line.StartsWith("data: ")) continue; - - var data = line["data: ".Length..]; - if (string.IsNullOrWhiteSpace(data)) continue; - - JsonElement evt; - try { evt = JsonSerializer.Deserialize(data); } - catch { continue; } - - var evtType = evt.TryGetProperty("type", out var t) ? t.GetString() ?? "" : ""; - - switch (evtType) - { - case "message_start": - { - if (evt.TryGetProperty("message", out var msg) && - msg.TryGetProperty("usage", out var u)) - { - if (u.TryGetProperty("input_tokens", out var it)) totalInputTokens += it.GetInt64(); - } - break; - } - - case "content_block_start": - { - var idx = evt.GetProperty("index").GetInt32(); - var cb = evt.GetProperty("content_block"); - var cbType = cb.GetProperty("type").GetString() ?? ""; - blockTypes[idx] = cbType; - - if (cbType == "text") - { - blockIndex = idx; - currentTextBlock.Clear(); - await WriteSseEvent(res, "text_start", new { index = idx }); - } - else if (cbType == "tool_use") - { - // Vorherigen Text-Block abschließen - if (currentTextBlock.Length > 0) - { - assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() }); - currentTextBlock.Clear(); - } - - blockIndex = idx; - currentToolId = cb.GetProperty("id").GetString() ?? ""; - currentToolName = cb.GetProperty("name").GetString() ?? ""; - currentToolInputJson.Clear(); - await WriteSseEvent(res, "tool_start", new { name = currentToolName, id = currentToolId }); - } - break; - } - - case "content_block_delta": - { - var idx = evt.GetProperty("index").GetInt32(); - var delta = evt.GetProperty("delta"); - var deltaType = delta.GetProperty("type").GetString() ?? ""; - - if (deltaType == "text_delta") - { - var text = delta.GetProperty("text").GetString() ?? ""; - currentTextBlock.Append(text); - await WriteSseEvent(res, "text_delta", new { text }); - } - else if (deltaType == "input_json_delta") - { - var partial = delta.GetProperty("partial_json").GetString() ?? ""; - currentToolInputJson.Append(partial); - } - break; - } - - case "content_block_stop": - { - var idx = evt.GetProperty("index").GetInt32(); - if (blockTypes.TryGetValue(idx, out var bt)) - { - if (bt == "text") - { - var text = currentTextBlock.ToString(); - if (text.Length > 0) - { - assistantContent.Add(new { type = "text", text }); - await WriteSseEvent(res, "text_end", new { }); - } - currentTextBlock.Clear(); - } - else if (bt == "tool_use") - { - JsonElement inputEl; - try { inputEl = JsonSerializer.Deserialize(currentToolInputJson.ToString()); } - catch { inputEl = JsonSerializer.Deserialize("{}"); } - - assistantContent.Add(new - { - type = "tool_use", - id = currentToolId, - name = currentToolName, - input = inputEl - }); - - // Tool sofort ausführen und streamen - string toolResult; - bool isError = false; - try { toolResult = await ExecuteTool(currentToolName, inputEl); } - catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } - - executedTools.Add((currentToolId, toolResult, isError)); - - await WriteSseEvent(res, "tool_end", new - { - name = currentToolName, - input = currentToolInputJson.ToString(), - result = toolResult.Length > 500 ? toolResult[..500] + "..." : toolResult, - isError - }); - } - } - break; - } - - case "message_delta": - { - if (evt.TryGetProperty("delta", out var d) && - d.TryGetProperty("stop_reason", out var sr)) - stopReason = sr.GetString() ?? ""; - if (evt.TryGetProperty("usage", out var u2) && - u2.TryGetProperty("output_tokens", out var ot)) - totalOutputTokens += ot.GetInt64(); - break; - } - } - } - - // Restlichen Text abschließen - if (currentTextBlock.Length > 0) - assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() }); - - // Assistant-Antwort zur History - messages.Add(new { role = "assistant", content = assistantContent }); - - // Kein Tool Use? Fertig! - if (stopReason != "tool_use") - { - await WriteSseEvent(res, "done", new - { - model = modelId, - inputTokens = totalInputTokens, - outputTokens = totalOutputTokens, - iterations - }); - return; - } - - // Tool Results zusammenbauen (wurden beim Streaming bereits ausgeführt) - var toolResults = new List(); - foreach (var tr in executedTools) - { - toolResults.Add(new - { - type = "tool_result", - tool_use_id = tr.Id, - content = tr.Result, - is_error = tr.IsError - }); - } - - messages.Add(new { role = "user", content = toolResults }); - } - - await WriteSseEvent(res, "error", new { error = "Max iterations erreicht" }); - await WriteSseEvent(res, "done", new { }); -}); +app.MapChatEndpoint(); app.Run("http://0.0.0.0:19000"); - -class ChatMessage -{ - [JsonPropertyName("role")] - public string Role { get; set; } = ""; - - [JsonPropertyName("content")] - public string Content { get; set; } = ""; -} - -class ChatRequest -{ - [JsonPropertyName("model")] - public string Model { get; set; } = "sonnet"; - - [JsonPropertyName("messages")] - public List Messages { get; set; } = new(); - - [JsonPropertyName("systemPrompt")] - public string SystemPrompt { get; set; } = ""; -} diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs new file mode 100644 index 0000000..8e310b8 --- /dev/null +++ b/Services/AnthropicService.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Gott.Services; + +public class AnthropicService +{ + private readonly IHttpClientFactory _httpFactory; + private readonly string _token; + private readonly ToolService _toolService; + + public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService) + { + _httpFactory = httpFactory; + _token = token; + _toolService = toolService; + } + + public async Task StreamChat( + HttpResponse response, + string modelId, + List systemPrompt, + List messages, + int maxIterations = 25) + { + var usage = new TokenUsage(); + int iterations = 0; + + while (iterations < maxIterations) + { + iterations++; + + var payload = new Dictionary + { + ["model"] = modelId, + ["max_tokens"] = 4096, + ["system"] = systemPrompt, + ["messages"] = messages, + ["tools"] = _toolService.GetToolDefinitions(), + ["stream"] = true + }; + + var client = CreateClient(); + var json = JsonSerializer.Serialize(payload); + var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); + + var httpResp = await client.SendAsync( + new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent }, + HttpCompletionOption.ResponseHeadersRead + ); + + if (!httpResp.IsSuccessStatusCode) + { + var errBody = await httpResp.Content.ReadAsStringAsync(); + await WriteSseEvent(response, "error", new { error = $"API {(int)httpResp.StatusCode}: {errBody}" }); + await WriteSseEvent(response, "done", new { }); + return; + } + + var (stopReason, assistantContent, executedTools) = await ProcessStream(response, httpResp, usage); + + messages.Add(new { role = "assistant", content = assistantContent }); + + if (stopReason != "tool_use") + { + await WriteSseEvent(response, "done", new + { + model = modelId, + inputTokens = usage.InputTokens, + outputTokens = usage.OutputTokens, + iterations + }); + return; + } + + var toolResults = new List(); + foreach (var tr in executedTools) + { + toolResults.Add(new + { + type = "tool_result", + tool_use_id = tr.Id, + content = tr.Result, + is_error = tr.IsError + }); + } + + messages.Add(new { role = "user", content = toolResults }); + } + + await WriteSseEvent(response, "error", new { error = "Max iterations erreicht" }); + await WriteSseEvent(response, "done", new { }); + } + + private async Task<(string StopReason, List Content, List Tools)> ProcessStream( + HttpResponse response, + HttpResponseMessage httpResp, + TokenUsage usage) + { + var stream = await httpResp.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(stream); + + var stopReason = ""; + var assistantContent = new List(); + var currentToolId = ""; + var currentToolName = ""; + var currentToolInputJson = new StringBuilder(); + var currentTextBlock = new StringBuilder(); + + var blockTypes = new Dictionary(); + var executedTools = new List(); + + string line; + while ((line = await reader.ReadLineAsync()) != null) + { + if (!line.StartsWith("data: ")) continue; + var data = line["data: ".Length..]; + if (string.IsNullOrWhiteSpace(data)) continue; + + JsonElement evt; + try { evt = JsonSerializer.Deserialize(data); } + catch { continue; } + + var evtType = evt.TryGetProperty("type", out var t) ? t.GetString() ?? "" : ""; + + switch (evtType) + { + case "message_start": + { + if (evt.TryGetProperty("message", out var msg) && + msg.TryGetProperty("usage", out var u) && + u.TryGetProperty("input_tokens", out var it)) + usage.InputTokens += it.GetInt64(); + break; + } + + case "content_block_start": + { + var idx = evt.GetProperty("index").GetInt32(); + var cb = evt.GetProperty("content_block"); + var cbType = cb.GetProperty("type").GetString() ?? ""; + blockTypes[idx] = cbType; + + if (cbType == "text") + { + currentTextBlock.Clear(); + await WriteSseEvent(response, "text_start", new { index = idx }); + } + else if (cbType == "tool_use") + { + if (currentTextBlock.Length > 0) + { + assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() }); + currentTextBlock.Clear(); + } + currentToolId = cb.GetProperty("id").GetString() ?? ""; + currentToolName = cb.GetProperty("name").GetString() ?? ""; + currentToolInputJson.Clear(); + await WriteSseEvent(response, "tool_start", new { name = currentToolName, id = currentToolId }); + } + break; + } + + case "content_block_delta": + { + var delta = evt.GetProperty("delta"); + var deltaType = delta.GetProperty("type").GetString() ?? ""; + + if (deltaType == "text_delta") + { + var text = delta.GetProperty("text").GetString() ?? ""; + currentTextBlock.Append(text); + await WriteSseEvent(response, "text_delta", new { text }); + } + else if (deltaType == "input_json_delta") + { + var partial = delta.GetProperty("partial_json").GetString() ?? ""; + currentToolInputJson.Append(partial); + } + break; + } + + case "content_block_stop": + { + var idx = evt.GetProperty("index").GetInt32(); + if (blockTypes.TryGetValue(idx, out var bt)) + { + if (bt == "text") + { + var text = currentTextBlock.ToString(); + if (text.Length > 0) + { + assistantContent.Add(new { type = "text", text }); + await WriteSseEvent(response, "text_end", new { }); + } + currentTextBlock.Clear(); + } + else if (bt == "tool_use") + { + JsonElement inputEl; + try { inputEl = JsonSerializer.Deserialize(currentToolInputJson.ToString()); } + catch { inputEl = JsonSerializer.Deserialize("{}"); } + + assistantContent.Add(new + { + type = "tool_use", + id = currentToolId, + name = currentToolName, + input = inputEl + }); + + string toolResult; + bool isError = false; + try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl); } + catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } + + executedTools.Add(new ToolResult(currentToolId, toolResult, isError)); + + await WriteSseEvent(response, "tool_end", new + { + name = currentToolName, + input = currentToolInputJson.ToString(), + result = toolResult.Length > 500 ? toolResult[..500] + "..." : toolResult, + isError + }); + } + } + break; + } + + case "message_delta": + { + if (evt.TryGetProperty("delta", out var d) && + d.TryGetProperty("stop_reason", out var sr)) + stopReason = sr.GetString() ?? ""; + if (evt.TryGetProperty("usage", out var u2) && + u2.TryGetProperty("output_tokens", out var ot)) + usage.OutputTokens += ot.GetInt64(); + break; + } + } + } + + if (currentTextBlock.Length > 0) + assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() }); + + return (stopReason, assistantContent, executedTools); + } + + private HttpClient CreateClient() + { + var client = _httpFactory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _token); + client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); + client.DefaultRequestHeaders.Add("anthropic-beta", "claude-code-20250219,oauth-2025-04-20"); + client.DefaultRequestHeaders.Add("User-Agent", "claude-cli/2.1.2 (external, cli)"); + client.DefaultRequestHeaders.Add("x-app", "cli"); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + client.DefaultRequestHeaders.Add("anthropic-dangerous-direct-browser-access", "true"); + return client; + } + + public static async Task WriteSseEvent(HttpResponse response, string eventType, object data) + { + var json = JsonSerializer.Serialize(data); + await response.WriteAsync($"event: {eventType}\ndata: {json}\n\n"); + await response.Body.FlushAsync(); + } +} + +public record ToolResult(string Id, string Result, bool IsError); + +public class TokenUsage +{ + public long InputTokens { get; set; } + public long OutputTokens { get; set; } +} diff --git a/Services/ToolService.cs b/Services/ToolService.cs new file mode 100644 index 0000000..3f4f5d4 --- /dev/null +++ b/Services/ToolService.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Gott.Services; + +public class ToolService +{ + private readonly string _workspaceRoot; + + public ToolService(string workspaceRoot) + { + _workspaceRoot = workspaceRoot; + } + + public object[] GetToolDefinitions() + { + return new object[] + { + new + { + name = "read_file", + description = "Liest den Inhalt einer Datei. Gibt den Textinhalt zurück.", + input_schema = new + { + type = "object", + properties = new Dictionary + { + ["path"] = new { type = "string", description = "Relativer Dateipfad" } + }, + required = new[] { "path" } + } + }, + new + { + name = "write_file", + description = "Erstellt oder überschreibt eine Datei mit dem angegebenen Inhalt.", + input_schema = new + { + type = "object", + properties = new Dictionary + { + ["path"] = new { type = "string", description = "Relativer Dateipfad" }, + ["content"] = new { type = "string", description = "Dateiinhalt" } + }, + required = new[] { "path", "content" } + } + }, + new + { + name = "edit_file", + description = "Ersetzt einen exakten Textabschnitt in einer Datei. oldText muss exakt matchen.", + input_schema = new + { + type = "object", + properties = new Dictionary + { + ["path"] = new { type = "string", description = "Relativer Dateipfad" }, + ["old_text"] = new { type = "string", description = "Exakter Text der ersetzt werden soll" }, + ["new_text"] = new { type = "string", description = "Neuer Text" } + }, + required = new[] { "path", "old_text", "new_text" } + } + }, + new + { + name = "list_files", + description = "Listet Dateien und Ordner in einem Verzeichnis auf.", + input_schema = new + { + type = "object", + properties = new Dictionary + { + ["path"] = new { type = "string", description = "Relativer Verzeichnispfad (leer = Root)" } + }, + required = Array.Empty() + } + }, + new + { + name = "delete_file", + description = "Löscht eine Datei oder ein leeres Verzeichnis.", + input_schema = new + { + type = "object", + properties = new Dictionary + { + ["path"] = new { type = "string", description = "Relativer Pfad zur Datei oder zum Verzeichnis" } + }, + required = new[] { "path" } + } + }, + new + { + name = "execute", + description = "Führt einen Shell-Befehl im Workspace-Verzeichnis aus. Gibt stdout und stderr zurück. Timeout: 30 Sekunden.", + input_schema = new + { + type = "object", + properties = new Dictionary + { + ["command"] = new { type = "string", description = "Shell-Befehl zum Ausführen" } + }, + required = new[] { "command" } + } + } + }; + } + + public async Task ExecuteTool(string name, JsonElement input) + { + switch (name) + { + case "read_file": + { + var path = ResolvePath(input.GetProperty("path").GetString() ?? ""); + if (!File.Exists(path)) + return $"Fehler: Datei nicht gefunden: {Path.GetRelativePath(_workspaceRoot, path)}"; + return File.ReadAllText(path); + } + case "write_file": + { + var path = ResolvePath(input.GetProperty("path").GetString() ?? ""); + var dir = Path.GetDirectoryName(path); + if (dir != null) Directory.CreateDirectory(dir); + var content = input.GetProperty("content").GetString() ?? ""; + File.WriteAllText(path, content); + return $"Datei geschrieben: {Path.GetRelativePath(_workspaceRoot, path)} ({content.Length} Zeichen)"; + } + case "edit_file": + { + var path = ResolvePath(input.GetProperty("path").GetString() ?? ""); + if (!File.Exists(path)) + return $"Fehler: Datei nicht gefunden: {Path.GetRelativePath(_workspaceRoot, path)}"; + var fileContent = File.ReadAllText(path); + var oldText = input.GetProperty("old_text").GetString() ?? ""; + var newText = input.GetProperty("new_text").GetString() ?? ""; + if (!fileContent.Contains(oldText)) + return "Fehler: old_text nicht in der Datei gefunden. Exakter Match nötig."; + fileContent = fileContent.Replace(oldText, newText); + File.WriteAllText(path, fileContent); + return $"Datei bearbeitet: {Path.GetRelativePath(_workspaceRoot, path)}"; + } + case "list_files": + { + var path = ResolvePath(input.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""); + if (!Directory.Exists(path)) + return $"Fehler: Verzeichnis nicht gefunden: {Path.GetRelativePath(_workspaceRoot, path)}"; + var entries = new List(); + foreach (var dir in Directory.GetDirectories(path)) + entries.Add(Path.GetRelativePath(_workspaceRoot, dir) + "/"); + foreach (var file in Directory.GetFiles(path)) + entries.Add(Path.GetRelativePath(_workspaceRoot, file)); + return entries.Count == 0 ? "(leer)" : string.Join("\n", entries); + } + case "delete_file": + { + var path = ResolvePath(input.GetProperty("path").GetString() ?? ""); + if (File.Exists(path)) + { + File.Delete(path); + return $"Datei gelöscht: {Path.GetRelativePath(_workspaceRoot, path)}"; + } + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: false); + return $"Verzeichnis gelöscht: {Path.GetRelativePath(_workspaceRoot, path)}"; + } + return $"Fehler: Nicht gefunden: {Path.GetRelativePath(_workspaceRoot, path)}"; + } + case "execute": + { + var command = input.GetProperty("command").GetString() ?? ""; + var psi = new ProcessStartInfo + { + FileName = "/bin/bash", + WorkingDirectory = _workspaceRoot, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var proc = Process.Start(psi); + if (proc == null) return "Fehler: Prozess konnte nicht gestartet werden"; + await proc.StandardInput.WriteAsync(command); + proc.StandardInput.Close(); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + try + { + await proc.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + proc.Kill(entireProcessTree: true); + return "Fehler: Timeout nach 30 Sekunden - Prozess abgebrochen"; + } + var stdout = await proc.StandardOutput.ReadToEndAsync(); + var stderr = await proc.StandardError.ReadToEndAsync(); + var result = new StringBuilder(); + if (!string.IsNullOrEmpty(stdout)) result.Append(stdout); + if (!string.IsNullOrEmpty(stderr)) + { + if (result.Length > 0) result.Append("\n--- stderr ---\n"); + result.Append(stderr); + } + if (result.Length == 0) result.Append($"(exit code {proc.ExitCode})"); + else if (proc.ExitCode != 0) result.Append($"\n(exit code {proc.ExitCode})"); + return result.ToString(); + } + default: + return $"Fehler: Unbekanntes Tool: {name}"; + } + } + + private string ResolvePath(string relativePath) + { + var full = Path.GetFullPath(Path.Combine(_workspaceRoot, relativePath ?? "")); + if (!full.StartsWith(_workspaceRoot)) + throw new Exception("Zugriff außerhalb des Workspace nicht erlaubt"); + return full; + } +}