From 4531a19b39be4f97ae433b2c3b56035ecf08149f Mon Sep 17 00:00:00 2001 From: Meleki Date: Thu, 26 Feb 2026 14:13:25 +0100 Subject: [PATCH] Initial: Debug Console mit Tool Use Loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Anthropic Messages API mit OAuth Token Auth - Chat Interface mit Message History - Tools: read_file, write_file, edit_file, list_files - Multi-Turn Tool Loop (max 25 Iterationen) - Steps-basiertes Response Format (Text + Tool interleaved) - Sandboxed Workspace für Dateioperationen --- .gitignore | 4 + DEVY.md | 23 +++ Program.cs | 375 +++++++++++++++++++++++++++++++++++++++++++++ appsettings.json | 9 ++ deploy.sh | 16 ++ git-status.sh | 14 ++ gott.csproj | 26 ++++ wwwroot/index.html | 198 ++++++++++++++++++++++++ 8 files changed, 665 insertions(+) create mode 100644 .gitignore create mode 100644 DEVY.md create mode 100644 Program.cs create mode 100755 appsettings.json create mode 100755 deploy.sh create mode 100755 git-status.sh create mode 100755 gott.csproj create mode 100644 wwwroot/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57dd4ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +publish/ +appsettings.Production.json diff --git a/DEVY.md b/DEVY.md new file mode 100644 index 0000000..0fc6888 --- /dev/null +++ b/DEVY.md @@ -0,0 +1,23 @@ +## Info + +- **.NET 10** without Nullable and Implicit Usings + +## Deployment + +- **Workspace:** `/home/node/.openclaw/workspace/projects/gott/` +- **Build & Deploy:** `bash deploy.sh` +- **WICHTIG:** Bei reinen Frontend-Änderungen (CSS/JS/HTML) IMMER auch `publish/wwwroot/` aktualisieren! + +## Git + +- **Push:** `git push` + +## Notizen + +**Commits:** NICHT nach jeder Nachricht committen - erst wenn Aufgabe abgeschlossen und User zufrieden ist. + +## REGELN (NIEMALS BRECHEN) + +- **NUR `bash deploy.sh` für Deployment.** NIEMALS `dotnet publish` direkt ausführen. Wenn deploy.sh nicht funktioniert → NACHFRAGEN! +- **NIEMALS Dateien in `publish/data/` manuell überschreiben oder manipulieren.** Das sind Live-Daten (Sessions, Users, History). Ein falscher Schreibvorgang = Datenverlust. +- **Keine Experimente am Live-System.** Wenn was nicht klappt, nicht "kreativ werden" sondern FRAGEN! \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..aeb2af6 --- /dev/null +++ b/Program.cs @@ -0,0 +1,375 @@ +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.Tasks; +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(); + +// Workspace-Root für Dateioperationen +var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); +var workspaceRoot = Path.Combine(dataDir, "workspace"); +Directory.CreateDirectory(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() + } + } +}; + +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"); + return full; +} + +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(); + 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); + } + default: + return $"Fehler: Unbekanntes Tool: {name}"; + } +} + +app.MapPost("/api/chat", async (HttpRequest req, 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" }); + + var token = config["AnthropicToken"]; + if (string.IsNullOrEmpty(token)) + return Results.Problem("AnthropicToken not configured"); + + var modelId = body.Model switch + { + "opus" => "claude-opus-4-6", + "sonnet" => "claude-sonnet-4-6", + _ => "claude-sonnet-4-6" + }; + + // Messages in API-Format konvertieren + 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; + int maxIterations = 25; + + while (iterations < maxIterations) + { + iterations++; + + var payload = new Dictionary + { + ["model"] = modelId, + ["max_tokens"] = 4096, + ["system"] = systemPrompt, + ["messages"] = messages, + ["tools"] = toolDefs + }; + + 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 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(); + + 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 (usage.TryGetProperty("input_tokens", out var it)) totalInputTokens += it.GetInt64(); + if (usage.TryGetProperty("output_tokens", out var ot)) totalOutputTokens += ot.GetInt64(); + } + + var stopReason = result.GetProperty("stop_reason").GetString(); + var contentBlocks = result.GetProperty("content"); + + // Assistant-Antwort zur History hinzufügen + var assistantContent = new List(); + var textParts = new List(); + + foreach (var block in contentBlocks.EnumerateArray()) + { + 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"); + + assistantContent.Add(new + { + type = "tool_use", + id = toolId, + name = toolName, + input = toolInput + }); + } + } + + messages.Add(new { role = "assistant", content = assistantContent }); + + // Zwischen-Text als Step erfassen + if (textParts.Count > 0) + { + steps.Add(new StepInfo + { + Type = "text", + Text = string.Join("\n", textParts) + }); + } + + // Kein Tool Use? Fertig! + if (stopReason != "tool_use") + { + return Results.Ok(new + { + text = string.Join("\n", textParts), + model = modelId, + inputTokens = totalInputTokens, + outputTokens = totalOutputTokens, + iterations, + steps + }); + } + + // Tool Calls ausführen + var toolResults = new List(); + foreach (var block in contentBlocks.EnumerateArray()) + { + 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 + }); + } + + messages.Add(new { role = "user", content = toolResults }); + } + + return Results.Problem("Max iterations erreicht"); +}); + +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; } = ""; +} + +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/appsettings.json b/appsettings.json new file mode 100755 index 0000000..10f68b8 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..d45c23c --- /dev/null +++ b/deploy.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +if [ "$(hostname)" = "stein2" ]; then + echo "stein2 is not allowed to do this" + exit +fi + +export PATH="$HOME/.dotnet:$PATH" +cd ~/.openclaw/workspace/projects/gott + +dotnet publish -c Release -o publish +cp wwwroot/index.html publish/wwwroot/index.html + +ssh -o ConnectTimeout=5 meleki@192.168.100.200 "nohup sudo systemctl restart gott > /dev/null 2>&1 &" +echo "Service restarted" diff --git a/git-status.sh b/git-status.sh new file mode 100755 index 0000000..378dec2 --- /dev/null +++ b/git-status.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +hostname +echo "" + +GIT="git -c safe.directory=*" + +echo "Uncommited Files:" +$GIT status --porcelain +echo "" + +echo -n "Outgoing Changes: " +$GIT rev-list --count origin/$($GIT rev-parse --abbrev-ref HEAD)..HEAD +echo "" diff --git a/gott.csproj b/gott.csproj new file mode 100755 index 0000000..1781201 --- /dev/null +++ b/gott.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + ASP0015;IDE0017;IDE0028;IDE0130;IDE0305;IDE1006;SYSLIB1045 + + + + + + + + + + + + + + + + + + + + + diff --git a/wwwroot/index.html b/wwwroot/index.html new file mode 100644 index 0000000..7b9bb2a --- /dev/null +++ b/wwwroot/index.html @@ -0,0 +1,198 @@ + + + + + +Gott - Debug Console + + + + +
+

// gott

+ + +
+ + +
+
+ +
+ +
+ +
+ + +
+ + + +