From 6ccd8cdb062a68783c1d5062af7ba3f31bf275d6 Mon Sep 17 00:00:00 2001 From: Meleki Date: Thu, 26 Feb 2026 14:22:55 +0100 Subject: [PATCH] SSE Streaming, Tool-Loop Refactor, Hilfsfunktionen extrahiert --- Program.cs | 339 +++++++++++++++++++++++++++++---------------- wwwroot/index.html | 240 +++++++++++++++++++++----------- 2 files changed, 374 insertions(+), 205 deletions(-) diff --git a/Program.cs b/Program.cs index aeb2af6..213763c 100644 --- a/Program.cs +++ b/Program.cs @@ -7,6 +7,7 @@ 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 Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -20,11 +21,12 @@ var app = builder.Build(); app.UseDefaultFiles(); app.UseStaticFiles(); -// Workspace-Root für Dateioperationen var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); var workspaceRoot = Path.Combine(dataDir, "workspace"); Directory.CreateDirectory(workspaceRoot); +// ── Tool Definitionen ── + var toolDefs = new[] { new @@ -88,11 +90,13 @@ var toolDefs = new[] } }; +// ── Hilfsfunktionen ── + string ResolvePath(string relativePath) { var full = Path.GetFullPath(Path.Combine(workspaceRoot, relativePath ?? "")); if (!full.StartsWith(workspaceRoot)) - throw new System.Exception("Zugriff außerhalb des Workspace nicht erlaubt"); + throw new Exception("Zugriff außerhalb des Workspace nicht erlaubt"); return full; } @@ -147,15 +151,45 @@ string ExecuteTool(string name, JsonElement input) } } -app.MapPost("/api/chat", async (HttpRequest req, IHttpClientFactory httpFactory, IConfiguration config) => +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) - return Results.BadRequest(new { error = "messages required" }); + { + res.StatusCode = 400; + await res.WriteAsJsonAsync(new { error = "messages required" }); + return; + } var token = config["AnthropicToken"]; if (string.IsNullOrEmpty(token)) - return Results.Problem("AnthropicToken not configured"); + { + res.StatusCode = 500; + await res.WriteAsJsonAsync(new { error = "AnthropicToken not configured" }); + return; + } var modelId = body.Model switch { @@ -164,23 +198,22 @@ app.MapPost("/api/chat", async (HttpRequest req, IHttpClientFactory httpFactory, _ => "claude-sonnet-4-6" }; - // Messages in API-Format konvertieren + // 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 }); - } - var steps = new List(); long totalInputTokens = 0; long totalOutputTokens = 0; int iterations = 0; @@ -196,138 +229,219 @@ app.MapPost("/api/chat", async (HttpRequest req, IHttpClientFactory httpFactory, ["max_tokens"] = 4096, ["system"] = systemPrompt, ["messages"] = messages, - ["tools"] = toolDefs + ["tools"] = toolDefs, + ["stream"] = true }; - 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"); - + var client = CreateAnthropicClient(httpFactory, token); var json = JsonSerializer.Serialize(payload); var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); - var resp = await client.PostAsync("https://api.anthropic.com/v1/messages", httpContent); - var respBody = await resp.Content.ReadAsStringAsync(); + var httpResp = await client.SendAsync( + new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent }, + HttpCompletionOption.ResponseHeadersRead + ); - if (!resp.IsSuccessStatusCode) - return Results.Problem($"Anthropic API error: {(int)resp.StatusCode} - {respBody}"); - - var result = JsonSerializer.Deserialize(respBody); - - // Usage tracken - if (result.TryGetProperty("usage", out var usage)) + if (!httpResp.IsSuccessStatusCode) { - if (usage.TryGetProperty("input_tokens", out var it)) totalInputTokens += it.GetInt64(); - if (usage.TryGetProperty("output_tokens", out var ot)) totalOutputTokens += ot.GetInt64(); + var errBody = await httpResp.Content.ReadAsStringAsync(); + await WriteSseEvent(res, "error", new { error = $"API {(int)httpResp.StatusCode}: {errBody}" }); + await WriteSseEvent(res, "done", new { }); + return; } - var stopReason = result.GetProperty("stop_reason").GetString(); - var contentBlocks = result.GetProperty("content"); + // Anthropic SSE Stream parsen + var stream = await httpResp.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(stream); - // Assistant-Antwort zur History hinzufügen + var stopReason = ""; var assistantContent = new List(); - var textParts = new List(); + var currentToolId = ""; + var currentToolName = ""; + var currentToolInputJson = new StringBuilder(); + var currentTextBlock = new StringBuilder(); + var blockIndex = -1; - foreach (var block in contentBlocks.EnumerateArray()) + var blockTypes = new Dictionary(); + var executedTools = new List<(string Id, string Result, bool IsError)>(); + + string line; + while ((line = await reader.ReadLineAsync()) != null) { - var blockType = block.GetProperty("type").GetString(); - if (blockType == "text") - { - var text = block.GetProperty("text").GetString() ?? ""; - textParts.Add(text); - assistantContent.Add(new { type = "text", text }); - } - else if (blockType == "tool_use") - { - var toolId = block.GetProperty("id").GetString() ?? ""; - var toolName = block.GetProperty("name").GetString() ?? ""; - var toolInput = block.GetProperty("input"); + if (!line.StartsWith("data: ")) continue; - assistantContent.Add(new + 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": { - type = "tool_use", - id = toolId, - name = toolName, - input = toolInput - }); + 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 = 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; + } } } - messages.Add(new { role = "assistant", content = assistantContent }); + // Restlichen Text abschließen + if (currentTextBlock.Length > 0) + assistantContent.Add(new { type = "text", text = currentTextBlock.ToString() }); - // Zwischen-Text als Step erfassen - if (textParts.Count > 0) - { - steps.Add(new StepInfo - { - Type = "text", - Text = string.Join("\n", textParts) - }); - } + // Assistant-Antwort zur History + messages.Add(new { role = "assistant", content = assistantContent }); // Kein Tool Use? Fertig! if (stopReason != "tool_use") { - return Results.Ok(new + await WriteSseEvent(res, "done", new { - text = string.Join("\n", textParts), model = modelId, inputTokens = totalInputTokens, outputTokens = totalOutputTokens, - iterations, - steps + iterations }); + return; } - // Tool Calls ausführen + // Tool Results zusammenbauen (wurden beim Streaming bereits ausgeführt) var toolResults = new List(); - foreach (var block in contentBlocks.EnumerateArray()) + foreach (var tr in executedTools) { - if (block.GetProperty("type").GetString() != "tool_use") continue; - - var toolId = block.GetProperty("id").GetString() ?? ""; - var toolName = block.GetProperty("name").GetString() ?? ""; - var toolInput = block.GetProperty("input"); - - string toolResult; - bool isError = false; - try - { - toolResult = ExecuteTool(toolName, toolInput); - } - catch (System.Exception ex) - { - toolResult = $"Fehler: {ex.Message}"; - isError = true; - } - - steps.Add(new StepInfo - { - Type = "tool", - ToolName = toolName, - ToolInput = toolInput.ToString(), - ToolResult = toolResult.Length > 500 ? toolResult[..500] + "..." : toolResult, - IsError = isError - }); - toolResults.Add(new { type = "tool_result", - tool_use_id = toolId, - content = toolResult, - is_error = isError + tool_use_id = tr.Id, + content = tr.Result, + is_error = tr.IsError }); } messages.Add(new { role = "user", content = toolResults }); } - return Results.Problem("Max iterations erreicht"); + await WriteSseEvent(res, "error", new { error = "Max iterations erreicht" }); + await WriteSseEvent(res, "done", new { }); }); app.Run("http://0.0.0.0:19000"); @@ -352,24 +466,3 @@ class ChatRequest [JsonPropertyName("systemPrompt")] public string SystemPrompt { get; set; } = ""; } - -class StepInfo -{ - [JsonPropertyName("type")] - public string Type { get; set; } = ""; - - [JsonPropertyName("text")] - public string Text { get; set; } = ""; - - [JsonPropertyName("toolName")] - public string ToolName { get; set; } = ""; - - [JsonPropertyName("toolInput")] - public string ToolInput { get; set; } = ""; - - [JsonPropertyName("toolResult")] - public string ToolResult { get; set; } = ""; - - [JsonPropertyName("isError")] - public bool IsError { get; set; } -} diff --git a/wwwroot/index.html b/wwwroot/index.html index 7b9bb2a..f6a95ba 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -16,23 +16,24 @@ select { font-family: monospace; padding: 6px 10px; border: 1px solid #333; bord .msg.user { align-self: flex-end; background: #16213e; border: 1px solid #333; } .msg.assistant { align-self: flex-start; background: #0f3460; border: 1px solid #1a4a7a; } .msg.system-info { align-self: center; background: #1a1a2e; border: 1px solid #333; color: #888; font-size: 0.85em; text-align: center; max-width: 95%; } -.tool-calls { margin-top: 8px; padding-top: 8px; border-top: 1px solid #1a4a7a; } -.tool-call { margin: 4px 0; padding: 6px 8px; background: #0a1e3d; border-radius: 4px; font-size: 0.85em; } +.msg .cursor { display: inline-block; width: 2px; height: 1em; background: #7c83ff; animation: blink 0.8s infinite; vertical-align: text-bottom; margin-left: 1px; } +@keyframes blink { 0%,50% { opacity: 1; } 51%,100% { opacity: 0; } } +.tool-box { align-self: center; max-width: 95%; padding: 8px 12px; border-radius: 6px; background: #0a1e3d; border: 1px solid #1a3a5c; font-size: 0.85em; } .tool-name { color: #7c83ff; font-weight: bold; } +.tool-input { color: #888; margin-left: 6px; font-size: 0.85em; } +.tool-result { color: #6bff8a; font-size: 0.8em; margin-top: 4px; max-height: 100px; overflow-y: auto; } .tool-error { color: #ff6b6b; } -.tool-input { color: #888; margin-left: 8px; } -.tool-result { color: #6bff8a; font-size: 0.8em; margin-top: 2px; max-height: 100px; overflow-y: auto; } +.tool-spinner { color: #7c83ff; } .input-area { padding: 12px 20px; border-top: 1px solid #333; display: flex; gap: 8px; flex-shrink: 0; } .input-area textarea { flex: 1; font-family: monospace; padding: 10px; border: 1px solid #333; border-radius: 4px; background: #0f3460; color: #e0e0e0; resize: none; font-size: 14px; min-height: 60px; max-height: 150px; } .input-area button { padding: 10px 20px; background: #7c83ff; color: #fff; border: none; border-radius: 4px; font-family: monospace; font-weight: bold; cursor: pointer; align-self: flex-end; } .input-area button:hover { background: #5a63e0; } .input-area button:disabled { opacity: 0.5; cursor: not-allowed; } -.spinner { color: #7c83ff; } .clear-btn { padding: 6px 10px; background: #333; color: #aaa; border: none; border-radius: 4px; cursor: pointer; font-family: monospace; font-size: 0.85em; } .clear-btn:hover { background: #444; } .system-toggle { display: flex; align-items: center; gap: 6px; margin-left: auto; } .system-toggle label { font-size: 0.85em; color: #888; cursor: pointer; } -#systemPrompt { display: none; width: 100%; font-family: monospace; padding: 8px; border: 1px solid #333; border-radius: 4px; background: #0a0a1a; color: #e0e0e0; resize: vertical; font-size: 12px; min-height: 40px; margin: 0 20px; } +#systemPrompt { display: none; width: calc(100% - 40px); font-family: monospace; padding: 8px; border: 1px solid #333; border-radius: 4px; background: #0a0a1a; color: #e0e0e0; resize: vertical; font-size: 12px; min-height: 40px; margin: 8px 20px 0; } #systemPrompt.visible { display: block; } @@ -56,12 +57,14 @@ select { font-family: monospace; padding: 6px 10px; border: 1px solid #333; bord
- +