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 string RemoteOs { 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. internal 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, StreamChatViaCli und OpenAiService. /// internal static async Task BuildAutoContextAsync(VectorSearchService vectorSearch, 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("WICHTIG: Der folgende Kontext wurde automatisch per Vektor-Suche aus früheren Gesprächen und Memory abgerufen. Prüfe diesen Kontext ZUERST, bevor du antwortest. Wenn er zur aktuellen Frage passt, beziehe dich aktiv darauf und nutze die Informationen in deiner Antwort. Ignoriere ihn nur, wenn er eindeutig irrelevant ist."); 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() ?? "" : ""; // "document" = Frontend-Format für PDFs (chat.js sendet type:'document' für non-image files). // "file" = Legacy-Alias für Datei-Anhänge. if (blockType != "image" && blockType != "file" && blockType != "document") 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); /// /// Versieht das letzte Element einer Block-Liste (Tools, System-Prompt-Blöcke, etc.) mit /// einer cache_control-Annotation. Konvertiert das Element über JSON-Roundtrip in ein /// Dictionary, damit cache_control auch bei anonymous types gesetzt werden kann. /// Idempotent: Mehrfaches Aufrufen überschreibt nur das cache_control-Feld. /// private static void ApplyCacheControlToLast(List blocks, string ttl = "1h") { if (blocks == null || blocks.Count == 0) return; var last = blocks[blocks.Count - 1]; Dictionary dict; if (last is Dictionary existing) { dict = existing; } else { var json = JsonSerializer.Serialize(last); var parsed = JsonSerializer.Deserialize>(json); dict = parsed ?? new Dictionary(); } dict["cache_control"] = new { type = "ephemeral", ttl }; blocks[blocks.Count - 1] = dict; } /// /// 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, RemoteOs = opts.RemoteOs, 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! } // Cache-Breakpoint auf das letzte Tool → cached den gesamten Tool-Block (1h TTL). // Da Tools pro Iteration neu generiert werden, ist das idempotent. ApplyCacheControlToLast(tools); 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,extended-cache-ttl-2025-04-11"); 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 }