- Auto-Context (Embedding-Suche) für OpenAI-Provider aktiviert: BuildAutoContextAsync auf internal static refactored und in StreamChat + ChatNonStreaming integriert - Tool-Results mit Role="tool" (OpenAI-Konvention) werden in BuildHistoryFromDb jetzt korrekt erkannt (vorher nur Role="user"/Anthropic) - Payload-Preview zeigt für OpenAI-Provider echtes API-Format (function-Wrapper, tool_choice:"auto", max_tokens, stream_options, extra_body) - Tool-Input (Argumente) wird im SSE tool_end-Event mitgesendet (fehlte im OpenAI-Pfad) - Leere Bubbles durch Whitespace-only Text nach </think>-Extraktion behoben (Trim in DB-Save, BuildHistoryFromDb und Frontend-Guard) - History-Toggle-Bug: var→const in for-Loop (Block-Scoping für Closures), tool_result-Matching per data-tool-call-id statt blindes letztes Element - Non-Streaming max_tokens für MiniMax auf 32768 + extra_body.reasoning_split Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
447 lines
19 KiB
C#
447 lines
19 KiB
C#
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
|
||
{
|
||
/// <summary>
|
||
/// Optionen für AddToolsToPayload – ersetzt die 12 bool-Parameter.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Prüft ob die Claude CLI verfügbar ist.
|
||
/// </summary>
|
||
public bool IsCliAvailable => _cliService?.IsAvailable ?? false;
|
||
|
||
/// <summary>
|
||
/// Wird nach der Konstruktion gesetzt um zirkuläre Abhängigkeit zu vermeiden
|
||
/// (ChatService → AnthropicService → ChatService).
|
||
/// </summary>
|
||
public void SetChatService(ChatService chatService)
|
||
{
|
||
_chatService = chatService;
|
||
}
|
||
|
||
public void SetTelegramService(TelegramService telegramService)
|
||
{
|
||
_telegramService = telegramService;
|
||
}
|
||
|
||
private OpenAiService _openAiService;
|
||
public void SetOpenAiService(OpenAiService openAiService)
|
||
{
|
||
_openAiService = openAiService;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Liest Usage-Header aus einer Anthropic-Response und aktualisiert die api_keys-Tabelle.
|
||
/// Fire-and-forget – Fehler werden stillschweigend ignoriert.
|
||
/// </summary>
|
||
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);
|
||
|
||
/// <summary>
|
||
/// Extrahiert den Text der letzten User-Message aus der Message-Liste.
|
||
/// </summary>
|
||
private static string ExtractLastUserText(List<object> messages)
|
||
{
|
||
for (int i = messages.Count - 1; i >= 0; i--)
|
||
{
|
||
var json = JsonSerializer.Serialize(messages[i]);
|
||
var el = JsonSerializer.Deserialize<JsonElement>(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;
|
||
}
|
||
|
||
/// <summary>Extrahiert die letzten N User-Texte für Query-Erweiterung.</summary>
|
||
internal static string ExtractRecentUserTexts(List<object> messages, int count = 3, int maxLength = 500)
|
||
{
|
||
var texts = new List<string>();
|
||
for (int i = messages.Count - 1; i >= 0 && texts.Count < count; i--)
|
||
{
|
||
var json = JsonSerializer.Serialize(messages[i]);
|
||
var el = JsonSerializer.Deserialize<JsonElement>(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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Baut den AutoContext-String aus Embedding-Suche.
|
||
/// Shared zwischen StreamChat, StreamChatViaCli und OpenAiService.
|
||
/// </summary>
|
||
internal static async Task<string> BuildAutoContextAsync(VectorSearchService vectorSearch, List<object> 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<recalled_context>");
|
||
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("</recalled_context>");
|
||
return ctxSb.ToString();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private static string ExtractLastUserImageRefs(List<object> messages, List<string> tempPaths)
|
||
{
|
||
for (int i = messages.Count - 1; i >= 0; i--)
|
||
{
|
||
var json = JsonSerializer.Serialize(messages[i]);
|
||
var el = JsonSerializer.Deserialize<JsonElement>(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<string>();
|
||
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) + "\n<hint>Lies die referenzierten Dateien mit dem Read-Tool um sie zu sehen.</hint>";
|
||
}
|
||
break;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Löscht temporäre Bilddateien die für den CLI-Aufruf erstellt wurden.
|
||
/// </summary>
|
||
private static void CleanupTempImages(List<string> 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);
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private static void ApplyCacheControlToLast(List<object> blocks, string ttl = "1h")
|
||
{
|
||
if (blocks == null || blocks.Count == 0) return;
|
||
var last = blocks[blocks.Count - 1];
|
||
Dictionary<string, object> dict;
|
||
if (last is Dictionary<string, object> existing)
|
||
{
|
||
dict = existing;
|
||
}
|
||
else
|
||
{
|
||
var json = JsonSerializer.Serialize(last);
|
||
var parsed = JsonSerializer.Deserialize<Dictionary<string, object>>(json);
|
||
dict = parsed ?? new Dictionary<string, object>();
|
||
}
|
||
dict["cache_control"] = new { type = "ephemeral", ttl };
|
||
blocks[blocks.Count - 1] = dict;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fügt Tool-Definitionen und WebSearch zum Payload hinzu.
|
||
/// </summary>
|
||
private void AddToolsToPayload(Dictionary<string, object> 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<object>();
|
||
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 };
|
||
}
|
||
|
||
/// <summary>
|
||
/// Entfernt Messages vom Anfang der Liste, ohne tool_use/tool_result-Paare zu trennen.
|
||
/// Gibt die tatsächliche Anzahl entfernter Messages zurück.
|
||
/// </summary>
|
||
private static int TrimMessagesCleanly(List<object> 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
|
||
}
|