- 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
376 lines
13 KiB
C#
376 lines
13 KiB
C#
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<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>()
|
|
}
|
|
}
|
|
};
|
|
|
|
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<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);
|
|
}
|
|
default:
|
|
return $"Fehler: Unbekanntes Tool: {name}";
|
|
}
|
|
}
|
|
|
|
app.MapPost("/api/chat", async (HttpRequest req, IHttpClientFactory httpFactory, IConfiguration config) =>
|
|
{
|
|
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(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<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 steps = new List<StepInfo>();
|
|
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
|
|
};
|
|
|
|
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<JsonElement>(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<object>();
|
|
var textParts = new List<string>();
|
|
|
|
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<object>();
|
|
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<ChatMessage> 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; }
|
|
}
|