using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; 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 { /// /// Optionen für AddToolsToPayload – ersetzt die 12 bool-Parameter. /// public record ToolPayloadOptions { public string Workspace { get; init; } public string AgentId { get; init; } public bool UseMemory { get; init; } public string WebSearchVersion { get; init; } public string ToolChoice { get; init; } public int Iteration { get; init; } public bool AllowUserChoice { get; init; } public bool PlanMode { get; init; } public bool IncludeRemote { get; init; } public bool IncludeGitHubSearch { get; init; } public bool IncludeSerperSearch { get; init; } } private readonly IHttpClientFactory _httpFactory; private readonly ToolService _toolService; private readonly DatabaseService _db; private readonly MemoryService _memoryService; private readonly ServerSettingsService _settings; private readonly VectorSearchService _vectorSearch; private readonly EmbeddingBackgroundService _embeddingBg; private ChatService _chatService; private TelegramService _telegramService; private ClaudeCliService _cliService; public AnthropicService(IHttpClientFactory httpFactory, ToolService toolService, DatabaseService db, MemoryService memoryService = null, ServerSettingsService settings = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null) { _httpFactory = httpFactory; _toolService = toolService; _db = db; _memoryService = memoryService; _settings = settings; _vectorSearch = vectorSearch; _embeddingBg = embeddingBg; } public void SetCliService(ClaudeCliService cliService) { _cliService = cliService; } /// /// Prüft ob die Claude CLI verfügbar ist. /// public bool IsCliAvailable => _cliService?.IsAvailable ?? false; /// /// Wird nach der Konstruktion gesetzt um zirkuläre Abhängigkeit zu vermeiden /// (ChatService → AnthropicService → ChatService). /// public void SetChatService(ChatService chatService) { _chatService = chatService; } public void SetTelegramService(TelegramService telegramService) { _telegramService = telegramService; } private OpenAiService _openAiService; public void SetOpenAiService(OpenAiService openAiService) { _openAiService = openAiService; } /// /// Liest Usage-Header aus einer Anthropic-Response und aktualisiert die api_keys-Tabelle. /// Fire-and-forget – Fehler werden stillschweigend ignoriert. /// private void TryUpdateKeyMeta(HttpResponseMessage httpResp, string apiToken) { try { var meta = AnthropicKeyChecker.ExtractMetaFromHeaders(httpResp.Headers); if (meta == null || string.IsNullOrEmpty(apiToken)) return; _ = _db.UpdateApiKeyMetaByToken(apiToken, meta.Status, meta.Plan, meta.Usage7d, meta.Usage5h); } catch { /* silent */ } } // ── Shared Helpers ── // TruncateToolResult → SseHelper.cs internal static string TruncateToolResult(string result) => SseHelper.TruncateToolResult(result); /// /// Extrahiert den Text der letzten User-Message aus der Message-Liste. /// private static string ExtractLastUserText(List messages) { for (int i = messages.Count - 1; i >= 0; i--) { var json = JsonSerializer.Serialize(messages[i]); var el = JsonSerializer.Deserialize(json); if (!el.TryGetProperty("role", out var role) || role.GetString() != "user") continue; if (!el.TryGetProperty("content", out var content)) continue; if (content.ValueKind == JsonValueKind.String) return content.GetString(); if (content.ValueKind == JsonValueKind.Array) { foreach (var block in content.EnumerateArray()) { var type = block.TryGetProperty("type", out var t) ? t.GetString() : ""; if (type == "text" && block.TryGetProperty("text", out var txt)) return txt.GetString(); } } break; } return null; } /// Extrahiert die letzten N User-Texte für Query-Erweiterung. private static string ExtractRecentUserTexts(List messages, int count = 3, int maxLength = 500) { var texts = new List(); for (int i = messages.Count - 1; i >= 0 && texts.Count < count; i--) { var json = JsonSerializer.Serialize(messages[i]); var el = JsonSerializer.Deserialize(json); if (!el.TryGetProperty("role", out var role) || role.GetString() != "user") continue; if (!el.TryGetProperty("content", out var content)) continue; string text = null; if (content.ValueKind == JsonValueKind.String) text = content.GetString(); else if (content.ValueKind == JsonValueKind.Array) { foreach (var block in content.EnumerateArray()) { var type = block.TryGetProperty("type", out var t) ? t.GetString() : ""; if (type == "text" && block.TryGetProperty("text", out var txt)) { text = txt.GetString(); break; } } } if (!string.IsNullOrWhiteSpace(text)) texts.Add(text); } texts.Reverse(); // chronological order var combined = string.Join(" ", texts); return combined.Length > maxLength ? combined.Substring(0, maxLength) : combined; } /// /// Baut den AutoContext-String aus Embedding-Suche. /// Shared zwischen StreamChat und StreamChatViaCli. /// private async Task BuildAutoContextAsync(List messages, string chainId, string agentId) { if (_vectorSearch == null || messages.Count == 0) return null; // Query-Erweiterung: letzte 2-3 User-Messages statt nur die letzte var queryText = ExtractRecentUserTexts(messages, count: 3, maxLength: 500); if (string.IsNullOrWhiteSpace(queryText)) return null; var contextResults = await _vectorSearch.SearchAsync(queryText, agentId, topN: 5, minScore: 0.35f); // Note: threshold lowered from 0.45 to 0.35 because hybrid search is now more precise contextResults.RemoveAll(r => r.ChainId == chainId || (r.TextPreview?.Length ?? 0) < 20); var memoryResults = await _vectorSearch.SearchMemoryFilesAsync(queryText, agentId, topN: 3, minScore: 0.35f); if (contextResults.Count == 0 && memoryResults.Count == 0) return null; var ctxSb = new StringBuilder(); ctxSb.AppendLine("\n"); ctxSb.AppendLine("Die folgende Information wurde automatisch abgerufen und könnte relevant sein:"); foreach (var r in contextResults) { var date = DateTime.TryParse(r.CreatedAt, out var dt) ? dt.ToString("yyyy-MM-dd HH:mm") : r.CreatedAt; // Use FullText (up to 800 chars) if available, otherwise TextPreview var displayText = r.FullText ?? r.TextPreview ?? ""; if (displayText.Length > 800) displayText = displayText.Substring(0, 800); ctxSb.AppendLine($"- [Gespräch, {r.Role}, {date}]: {displayText}"); } foreach (var r in memoryResults) { var displayText = r.FullText ?? r.TextPreview ?? ""; if (displayText.Length > 800) displayText = displayText.Substring(0, 800); ctxSb.AppendLine($"- [Memory, {r.FilePath}]: {displayText}"); } ctxSb.AppendLine(""); return ctxSb.ToString(); } /// /// Extrahiert Bild-Blöcke aus der letzten User-Message, speichert sie als Temp-Dateien /// und gibt Datei-Referenz-Hinweise zurück, die der CLI-Prompt vorangestellt werden. /// private static string ExtractLastUserImageRefs(List messages, List tempPaths) { for (int i = messages.Count - 1; i >= 0; i--) { var json = JsonSerializer.Serialize(messages[i]); var el = JsonSerializer.Deserialize(json); if (!el.TryGetProperty("role", out var role) || role.GetString() != "user") continue; if (!el.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) continue; var refs = new List(); var tempDir = Path.Combine(Path.GetTempPath(), "gott-cli-images"); Directory.CreateDirectory(tempDir); foreach (var block in content.EnumerateArray()) { var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : ""; if (blockType != "image" && blockType != "file") continue; var source = block.TryGetProperty("source", out var src) ? src : default; if (source.ValueKind == JsonValueKind.Undefined) continue; var sourceType = source.TryGetProperty("type", out var st) ? st.GetString() ?? "" : ""; if (sourceType != "base64") continue; var data = source.TryGetProperty("data", out var d) ? d.GetString() ?? "" : ""; var mediaType = source.TryGetProperty("media_type", out var mt) ? mt.GetString() ?? "" : ""; var ext = mediaType switch { "image/png" => ".png", "image/jpeg" => ".jpg", "image/gif" => ".gif", "image/webp" => ".webp", "application/pdf" => ".pdf", _ => ".bin" }; var fileName = $"{Guid.NewGuid():N}{ext}"; var filePath = Path.Combine(tempDir, fileName); try { File.WriteAllBytes(filePath, Convert.FromBase64String(data)); tempPaths.Add(filePath); var label = blockType == "image" ? "Screenshot" : "Datei"; refs.Add($"[{label}: {filePath}]"); } catch { // Base64-Decode fehlgeschlagen → überspringen } } if (refs.Count > 0) { return string.Join(" ", refs) + "\nLies die referenzierten Dateien mit dem Read-Tool um sie zu sehen."; } break; } return null; } /// /// Löscht temporäre Bilddateien die für den CLI-Aufruf erstellt wurden. /// private static void CleanupTempImages(List paths) { if (paths == null || paths.Count == 0) return; foreach (var path in paths) { try { if (File.Exists(path)) File.Delete(path); } catch { } } } private void LogPayload(string json, string sessionId, string chainId, int iteration) { if (_settings == null || !_settings.GetBool("PayloadLogging", false)) return; try { var logsDir = Path.Combine(AppContext.BaseDirectory, "data", "logs"); Directory.CreateDirectory(logsDir); var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var baseName = $"{timestamp}-{sessionId}-{chainId}-iter{iteration}-payload"; if (_settings?.GetBool("PayloadLoggingGzip", false) ?? false) { var filePath = Path.Combine(logsDir, baseName + ".json.gz"); using var fs = File.Create(filePath); using var gz = new GZipStream(fs, CompressionLevel.Fastest); using var writer = new StreamWriter(gz, Encoding.UTF8); writer.Write(json); } else { File.WriteAllText(Path.Combine(logsDir, baseName + ".json"), json); } } catch { /* Logging darf nie die App crashen */ } } private Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw, string streamErrorInfo = null) => SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw, streamErrorInfo); /// /// Fügt Tool-Definitionen und WebSearch zum Payload hinzu. /// private void AddToolsToPayload(Dictionary payload, ToolPayloadOptions opts) { var hasWorkspace = !string.IsNullOrEmpty(opts.Workspace); var includeMemory = opts.UseMemory && _memoryService != null && !string.IsNullOrEmpty(opts.AgentId); var includeMemorySearch = _vectorSearch != null && _vectorSearch.GetEmbeddingCount() > 0; var includeSendToSession = _db != null && _chatService != null; var hasTools = hasWorkspace || includeMemory || opts.IncludeRemote || includeMemorySearch || includeSendToSession || opts.IncludeGitHubSearch || opts.IncludeSerperSearch; var hasWebSearch = !string.IsNullOrEmpty(opts.WebSearchVersion); if (!hasTools && !hasWebSearch) return; var tools = new List(); if (hasTools) { var allTools = _toolService.GetToolDefinitions(new ToolDefinitionOptions { IncludeFileTools = hasWorkspace, IncludeMemory = includeMemory, IncludeUserChoice = opts.AllowUserChoice, IncludeRemote = opts.IncludeRemote, IncludeMemorySearch = includeMemorySearch, IncludeSendToSession = includeSendToSession, IncludeGitHubSearch = opts.IncludeGitHubSearch, IncludeSerperSearch = opts.IncludeSerperSearch }); if (opts.PlanMode) allTools = _toolService.FilterReadOnlyTools(allTools); tools.AddRange(allTools); } if (hasWebSearch) { tools.Add(new { type = opts.WebSearchVersion, name = "web_search", max_uses = 5 }); // web_search_20260209: Code Execution wird von der API automatisch injected – NICHT manuell hinzufügen! } payload["tools"] = tools; var effectiveChoice = (!opts.PlanMode && opts.ToolChoice == "any" && opts.Iteration == 1) ? "any" : "auto"; payload["tool_choice"] = new { type = effectiveChoice }; } /// /// Entfernt Messages vom Anfang der Liste, ohne tool_use/tool_result-Paare zu trennen. /// Gibt die tatsächliche Anzahl entfernter Messages zurück. /// private static int TrimMessagesCleanly(List messages, int targetRemoveCount) { if (targetRemoveCount <= 0 || targetRemoveCount >= messages.Count) return 0; int removeCount = targetRemoveCount; // Sicherstellen, dass wir nicht mitten in einem tool_use/tool_result-Paar abschneiden while (removeCount < messages.Count - 1) { var firstRemaining = messages[removeCount]; var firstRemainingJson = JsonSerializer.Serialize(firstRemaining); // Wenn die erste verbleibende Message ein tool_result ist → vorherige auch behalten if (firstRemainingJson.Contains("\"tool_result\"")) { removeCount--; if (removeCount <= 0) return 0; continue; } // Wenn die letzte entfernte Message ein assistant mit tool_use ist → tool_result auch entfernen var lastRemovedJson = JsonSerializer.Serialize(messages[removeCount - 1]); if (lastRemovedJson.Contains("\"tool_use\"") && lastRemovedJson.Contains("\"assistant\"")) { removeCount++; continue; } break; } if (removeCount > 0 && removeCount < messages.Count) { messages.RemoveRange(0, removeCount); return removeCount; } return 0; } private HttpClient CreateClient(string apiToken) { var client = _httpFactory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken); client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); client.DefaultRequestHeaders.Add("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,code-execution-2025-08-25"); 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; } // WriteSseEvent, TryWriteSse, TokenUsage, ToolResult → SseHelper.cs }