Files
gott/Services/AnthropicService.Cli.cs
T
MelekiandClaude Opus 4.6 1c622abb37 refactor: AnthropicService partial-class Aufteilung + ToolPayloadOptions + Non-Streaming Deduplizierung
- AnthropicService.cs (337 Zeilen): Felder, Konstruktor, Setters, Utilities
- AnthropicService.Streaming.cs (750 Zeilen): StreamChat, ProcessStream, SSE-Parsing
- AnthropicService.Agents.cs (680 Zeilen): RunAgentChat, ChatNonStreaming, Review/Preparer/Finalizer
- AnthropicService.Cli.cs (530 Zeilen): StreamChatViaCli, MCP-Config, CLI-Helpers
- AddToolsToPayload: 12 Bool-Parameter → ToolPayloadOptions Record
- RunAgentChat + ChatNonStreaming → gemeinsame RunNonStreamingToolLoop-Methode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 14:54:39 +01:00

531 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Gott.Endpoints;
using Gott.Models;
using Microsoft.AspNetCore.Http;
namespace Gott.Services;
public partial class AnthropicService
{
/// <summary>
/// Streaming-Chat über die Claude CLI.
/// Die CLI verwendet das lokale Token aus ~/.claude/.credentials.json.
/// </summary>
public async Task StreamChatViaCli(
HttpResponse response,
string modelId,
List<object> systemPrompt,
List<object> messages,
string chainId,
string workspace = null,
bool allowUserChoice = false,
bool planMode = false,
bool webSearch = false,
string sessionId = null,
string agentId = null,
RemoteConnectionManager remoteMgr = null,
CancellationToken workCt = default,
CancellationToken sseCt = default)
{
if (_cliService == null || !_cliService.IsAvailable)
{
await SseHelper.TryWriteSse(response, "error", new { error = "Claude CLI nicht verfügbar" }, sseCt);
await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt);
return;
}
var sw = System.Diagnostics.Stopwatch.StartNew();
var usage = new TokenUsage();
var fullText = new StringBuilder();
var tempImagePaths = new List<string>();
string mcpConfigPath = null;
bool isUserChoice = false;
var chain = new ChainRecord
{
ChainId = chainId,
SessionId = sessionId,
Model = modelId
};
try
{
// MCP-Config generieren (immer – für ask_user_choice, optional Memory-Tools + remote_execute)
var hasRemote = remoteMgr != null && !string.IsNullOrEmpty(sessionId) && remoteMgr.GetBySessionId(sessionId) != null;
mcpConfigPath = GenerateMcpConfig(sessionId, agentId, hasRemote);
// System-Prompt als Text extrahieren
var systemText = new StringBuilder();
if (systemPrompt != null)
{
foreach (var block in systemPrompt)
{
var json = JsonSerializer.Serialize(block);
var el = JsonSerializer.Deserialize<JsonElement>(json);
if (el.TryGetProperty("text", out var t))
systemText.AppendLine(t.GetString());
}
}
// Letzte User-Message als Prompt extrahieren
var prompt = ExtractLastUserText(messages) ?? "";
// Bild-Attachments aus letzter User-Message als Temp-Dateien speichern
// (CLI kann Bilder nicht inline empfangen, aber per Read-Tool lesen)
var imageRefs = ExtractLastUserImageRefs(messages, tempImagePaths);
if (!string.IsNullOrEmpty(imageRefs))
{
prompt = imageRefs + "\n" + prompt;
}
// Konversations-History als Kontext zum Prompt hinzufügen
var historyContext = BuildHistoryContext(messages);
if (!string.IsNullOrEmpty(historyContext))
{
prompt = $"<conversation_history>\n{historyContext}\n</conversation_history>\n\n<current_request>\n{prompt}\n</current_request>";
}
string choiceToolCallId = null;
var result = await _cliService.ChatStreamingAsync(
prompt,
async evt =>
{
switch (evt.Type)
{
case CliEventType.TextStart:
await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, sseCt);
break;
case CliEventType.TextDelta:
fullText.Append(evt.Text);
await SseHelper.TryWriteSse(response, "text_delta", new { text = evt.Text }, sseCt);
break;
case CliEventType.TextEnd:
await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt);
break;
case CliEventType.ToolStart:
{
// Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB)
if (fullText.Length > 0)
{
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "assistant",
Type = "text",
Content = fullText.ToString()
});
fullText.Clear();
}
var mappedName = MapCliToolName(evt.ToolName);
await SseHelper.TryWriteSse(response, "tool_start", new { name = mappedName, id = evt.ToolId }, sseCt);
break;
}
case CliEventType.ToolEnd:
{
var mappedName = MapCliToolName(evt.ToolName);
var mappedInput = MapCliToolInput(evt.ToolName, evt.ToolInput);
// Tool-Use in DB speichern (damit Poll die Tools anzeigt)
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "assistant",
Type = "tool_use",
ToolName = mappedName,
ToolCallId = evt.ToolId,
Content = mappedInput
});
await SseHelper.TryWriteSse(response, "tool_end", new { name = mappedName, id = evt.ToolId, input = mappedInput }, sseCt);
// ask_user_choice via MCP: TCS erstellen + SSE user_choice Event
if (mappedName == "ask_user_choice")
{
try
{
var choiceInput = JsonSerializer.Deserialize<JsonElement>(mappedInput);
var cQuestion = choiceInput.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
var cChoices = new List<string>();
if (choiceInput.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
foreach (var cOpt in cc.EnumerateArray())
cChoices.Add(cOpt.GetString() ?? "");
// Alte Antwort entfernen (falls vorhanden)
InternalEndpoint.ChoiceAnswers.TryRemove(sessionId, out _);
// SSE Event mit viaCli-Flag → Frontend zeigt Buttons + nutzt choice-respond Endpoint
choiceToolCallId = evt.ToolId;
await SseHelper.TryWriteSse(response, "user_choice", new
{
toolCallId = evt.ToolId,
question = cQuestion,
choices = cChoices,
viaCli = true
}, sseCt);
isUserChoice = true;
}
catch (JsonException) { /* Input nicht parsbar – MCP-Server gibt Fehler */ }
}
break;
}
case CliEventType.ToolResult:
{
// Tool-Result in DB speichern (damit es beim Neuladen sichtbar ist)
var resultContent = SseHelper.TruncateToolResult(evt.ToolResultContent ?? "");
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "user",
Type = "tool_result",
ToolCallId = evt.ToolId,
Content = resultContent,
IsError = evt.ToolResultIsError
});
// SSE Event an Client senden
await SseHelper.TryWriteSse(response, "tool_result", new
{
id = evt.ToolId,
result = resultContent,
isError = evt.ToolResultIsError
}, sseCt);
break;
}
}
},
modelId: modelId,
systemPrompt: systemText.Length > 0 ? systemText.ToString() : null,
workspace: workspace,
hasWorkspace: !string.IsNullOrEmpty(workspace),
planMode: planMode,
webSearch: webSearch,
mcpConfigPath: mcpConfigPath,
cancellationToken: workCt
);
// Bei CLI-Fehler → Fehlermeldung an Client senden und in DB speichern
if (!result.Success && !isUserChoice)
{
var errorMsg = !string.IsNullOrEmpty(result.Error) ? result.Error : "CLI hat keine Antwort geliefert";
// Error in DB speichern (damit er beim Neuladen sichtbar ist)
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "system",
Type = "error",
Content = $"CLI-Fehler: {errorMsg}"
});
await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {errorMsg}" }, sseCt);
}
// Antwort in DB speichern
if (fullText.Length > 0)
{
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "assistant",
Type = "text",
Content = fullText.ToString()
});
}
// Token-Usage vom CLI-Result übernehmen
usage.InputTokens = result.InputTokens;
usage.OutputTokens = result.OutputTokens;
usage.CacheReadTokens = result.CacheReadTokens;
usage.CacheCreationTokens = result.CacheCreationTokens;
var stopReason = isUserChoice ? "awaiting_user_choice" : (result.Success ? "end_turn" : "error");
await FinalizeChain(chain, usage, result.NumTurns > 0 ? result.NumTurns : 1, stopReason, sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
inputTokens = result.InputTokens,
outputTokens = result.OutputTokens,
cacheCreationTokens = result.CacheCreationTokens,
cacheReadTokens = result.CacheReadTokens,
costUsd = result.CostUsd,
iterations = result.NumTurns > 0 ? result.NumTurns : 1,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason,
viaCli = true
}, sseCt);
}
catch (OperationCanceledException) when (workCt.IsCancellationRequested)
{
// Stop-Button gedrückt → CLI-Prozess wurde gestoppt
// Wenn ask_user_choice aktiv war: als awaiting_user_choice markieren,
// damit die nächste Chain den Kontext (Frage + Optionen) behält.
var stoppedReason = isUserChoice ? "awaiting_user_choice" : "stopped";
await FinalizeChain(chain, usage, 1, stoppedReason, sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = stoppedReason,
viaCli = true
}, CancellationToken.None);
}
catch (OperationCanceledException)
{
// Client-Disconnect (sseCt gecancelled, aber NICHT workCt)
// CLI-Prozess läuft weiter im Hintergrund – hier nur Chain finalisieren
// (sollte nicht mehr passieren, da CLI jetzt workCt nutzt)
await FinalizeChain(chain, usage, 1, "client_disconnected", sw);
}
catch (Exception ex)
{
var errorMsg = $"CLI-Fehler: {ex.Message}";
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "system",
Type = "error",
Content = errorMsg
});
await SseHelper.TryWriteSse(response, "error", new { error = errorMsg }, sseCt);
await FinalizeChain(chain, usage, 1, "error", sw);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = "error",
viaCli = true
}, sseCt);
}
finally
{
// Temp-Bilddateien aufräumen
CleanupTempImages(tempImagePaths);
// MCP-Config Temp-Datei aufräumen
if (mcpConfigPath != null)
{
try { File.Delete(mcpConfigPath); } catch { }
}
}
}
/// <summary>
/// Mappt CLI-Tool-Namen (Edit, Write, Bash, etc.) auf gott-Tool-Namen (edit_file, write_file, etc.).
/// </summary>
private static string MapCliToolName(string cliName)
{
return cliName switch
{
"Edit" => "edit_file",
"Write" => "write_file",
"Read" => "read_file",
"Bash" => "execute",
"Glob" => "list_files",
"Grep" => "search_files",
"WebSearch" => "web_search",
"WebFetch" => "web_fetch",
"NotebookEdit" => "notebook_edit",
"mcp__gott-remote__remote_execute" => "remote_execute",
_ => cliName?.StartsWith("mcp__") == true ? cliName.Split("__").LastOrDefault() ?? cliName : cliName
};
}
/// <summary>
/// Mappt CLI-Tool-Input-Felder auf gott-Konventionen (z.B. file_path→path, old_string→old_text).
/// </summary>
private static string MapCliToolInput(string cliToolName, string inputJson)
{
if (string.IsNullOrEmpty(inputJson)) return inputJson;
try
{
var input = JsonSerializer.Deserialize<JsonElement>(inputJson);
if (input.ValueKind != JsonValueKind.Object) return inputJson;
switch (cliToolName)
{
case "Edit":
{
// file_path → path, old_string → old_text, new_string → new_text
var mapped = new Dictionary<string, object>();
foreach (var prop in input.EnumerateObject())
{
var key = prop.Name switch
{
"file_path" => "path",
"old_string" => "old_text",
"new_string" => "new_text",
_ => prop.Name
};
mapped[key] = prop.Value;
}
return JsonSerializer.Serialize(mapped);
}
case "Write":
{
// file_path → path
var mapped = new Dictionary<string, object>();
foreach (var prop in input.EnumerateObject())
{
var key = prop.Name == "file_path" ? "path" : prop.Name;
mapped[key] = prop.Value;
}
return JsonSerializer.Serialize(mapped);
}
case "Read":
{
// file_path → path
var mapped = new Dictionary<string, object>();
foreach (var prop in input.EnumerateObject())
{
var key = prop.Name == "file_path" ? "path" : prop.Name;
mapped[key] = prop.Value;
}
return JsonSerializer.Serialize(mapped);
}
case "Bash":
{
// command bleibt gleich
return inputJson;
}
default:
return inputJson;
}
}
catch
{
return inputJson;
}
}
/// <summary>
/// Baut einen Kontext-String aus der Konversations-History.
/// </summary>
private static string BuildHistoryContext(List<object> messages)
{
var sb = new StringBuilder();
// Letzte Message überspringen wenn es ein Text-Prompt ist (wird separat als current_request gesendet).
// Wenn die letzte Message ein tool_result ist (z.B. Choice-Antwort), NICHT überspringen.
int takeCount = messages.Count;
if (messages.Count > 0)
{
var lastJson = JsonSerializer.Serialize(messages[messages.Count - 1]);
var lastEl = JsonSerializer.Deserialize<JsonElement>(lastJson);
bool lastIsToolResult = false;
if (lastEl.TryGetProperty("content", out var lc) && lc.ValueKind == JsonValueKind.Array)
{
foreach (var b in lc.EnumerateArray())
{
var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
if (bType == "tool_result") { lastIsToolResult = true; break; }
}
}
if (!lastIsToolResult)
takeCount = messages.Count - 1;
}
foreach (var msg in messages.Take(takeCount))
{
var json = JsonSerializer.Serialize(msg);
var el = JsonSerializer.Deserialize<JsonElement>(json);
if (!el.TryGetProperty("role", out var role)) continue;
var roleStr = role.GetString() ?? "";
if (el.TryGetProperty("content", out var content))
{
if (content.ValueKind == JsonValueKind.String)
{
sb.AppendLine($"[{roleStr}]: {content.GetString()}");
}
else if (content.ValueKind == JsonValueKind.Array)
{
foreach (var block in content.EnumerateArray())
{
var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
if (blockType == "text" && block.TryGetProperty("text", out var txt))
{
sb.AppendLine($"[{roleStr}]: {txt.GetString()}");
}
else if (blockType == "tool_use" && block.TryGetProperty("name", out var tn) && tn.GetString() == "ask_user_choice")
{
if (block.TryGetProperty("input", out var inp))
{
var q = inp.TryGetProperty("question", out var qq) ? qq.GetString() ?? "" : "";
var opts = new List<string>();
if (inp.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
foreach (var o in cc.EnumerateArray()) opts.Add(o.GetString() ?? "");
sb.AppendLine($"[assistant]: Ich habe gefragt: \"{q}\" mit Optionen: {string.Join(", ", opts)}");
}
}
else if (blockType == "tool_result" && block.TryGetProperty("content", out var trContent))
{
sb.AppendLine($"[user]: Du hast gewählt: \"{trContent.GetString()}\"");
}
}
}
}
}
return sb.ToString().Trim();
}
/// <summary>
/// Generiert eine temporäre MCP-Config-Datei für die Claude CLI.
/// Enthält den MCP-Server für remote_execute.
/// </summary>
private string GenerateMcpConfig(string sessionId, string agentId = null, bool hasRemote = false)
{
// Port aus Konfiguration oder Default
var port = 19000;
var scriptPath = Path.Combine(AppContext.BaseDirectory, "scripts", "mcp-remote-execute.py");
// Fallback: Quell-Verzeichnis prüfen (für Dev-Modus)
if (!File.Exists(scriptPath))
{
var projectRoot = Path.GetDirectoryName(AppPaths.WwwRoot) ?? AppContext.BaseDirectory;
var srcPath = Path.Combine(projectRoot, "scripts", "mcp-remote-execute.py");
if (File.Exists(srcPath)) scriptPath = srcPath;
}
var env = new Dictionary<string, string>
{
["GOTT_API_URL"] = $"http://localhost:{port}",
["GOTT_SESSION_ID"] = sessionId ?? "",
["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken,
["GOTT_AGENT_ID"] = agentId ?? "",
["GOTT_HAS_REMOTE"] = hasRemote ? "true" : "false"
};
var config = new
{
mcpServers = new Dictionary<string, object>
{
["gott-remote"] = new
{
command = "python3",
args = new[] { scriptPath },
env
}
}
};
var json = System.Text.Json.JsonSerializer.Serialize(config, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
var tempPath = Path.Combine(Path.GetTempPath(), $"gott-mcp-{sessionId}.json");
File.WriteAllText(tempPath, json);
return tempPath;
}
}