Backend Refactoring: Models, Services, Endpoints aufgeteilt
This commit is contained in:
1 parent
fde6b51b1b
commit
5f210323fd
5 files changed
+609
-535
No files matched your search
@@ -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<ChatRequest>(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<object>();
|
||||
foreach (var msg in body.Messages)
|
||||
messages.Add(new { role = msg.Role, content = msg.Content });
|
||||
|
||||
var systemPrompt = new List<object>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<ChatMessage> Messages { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("systemPrompt")]
|
||||
public string SystemPrompt { get; set; } = "";
|
||||
}
|
||||
+7
-535
@@ -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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["path"] = new { type = "string", description = "Relativer Verzeichnispfad (leer = Root)" }
|
||||
},
|
||||
required = System.Array.Empty<string>()
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
name = "delete_file",
|
||||
description = "Löscht eine Datei oder ein leeres Verzeichnis.",
|
||||
input_schema = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new Dictionary<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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<string> 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<string>();
|
||||
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<ChatRequest>(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<object>();
|
||||
foreach (var msg in body.Messages)
|
||||
messages.Add(new { role = msg.Role, content = msg.Content });
|
||||
|
||||
var systemPrompt = new List<object>
|
||||
{
|
||||
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<string, object>
|
||||
{
|
||||
["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<object>();
|
||||
var currentToolId = "";
|
||||
var currentToolName = "";
|
||||
var currentToolInputJson = new StringBuilder();
|
||||
var currentTextBlock = new StringBuilder();
|
||||
var blockIndex = -1;
|
||||
|
||||
var blockTypes = new Dictionary<int, string>();
|
||||
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<JsonElement>(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<JsonElement>(currentToolInputJson.ToString()); }
|
||||
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
|
||||
|
||||
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<object>();
|
||||
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<ChatMessage> Messages { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("systemPrompt")]
|
||||
public string SystemPrompt { get; set; } = "";
|
||||
}
|
||||
@@ -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<object> systemPrompt,
|
||||
List<object> messages,
|
||||
int maxIterations = 25)
|
||||
{
|
||||
var usage = new TokenUsage();
|
||||
int iterations = 0;
|
||||
|
||||
while (iterations < maxIterations)
|
||||
{
|
||||
iterations++;
|
||||
|
||||
var payload = new Dictionary<string, object>
|
||||
{
|
||||
["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<object>();
|
||||
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<object> Content, List<ToolResult> 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<object>();
|
||||
var currentToolId = "";
|
||||
var currentToolName = "";
|
||||
var currentToolInputJson = new StringBuilder();
|
||||
var currentTextBlock = new StringBuilder();
|
||||
|
||||
var blockTypes = new Dictionary<int, string>();
|
||||
var executedTools = new List<ToolResult>();
|
||||
|
||||
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<JsonElement>(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<JsonElement>(currentToolInputJson.ToString()); }
|
||||
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
|
||||
|
||||
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; }
|
||||
}
|
||||
@@ -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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["path"] = new { type = "string", description = "Relativer Verzeichnispfad (leer = Root)" }
|
||||
},
|
||||
required = Array.Empty<string>()
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
name = "delete_file",
|
||||
description = "Löscht eine Datei oder ein leeres Verzeichnis.",
|
||||
input_schema = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new Dictionary<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["command"] = new { type = "string", description = "Shell-Befehl zum Ausführen" }
|
||||
},
|
||||
required = new[] { "command" }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<string> 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<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user