diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 3eb23ec..f2f8308 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -161,16 +161,13 @@ public class AnthropicService } else if (blockType == "web_search_tool_result") { - var sources = ExtractWebSearchSources(el); - if (sources.Count > 0) + // Raw-JSON speichern für History-Rebuild (API braucht den vollständigen Block) + await _db.SaveMessage(new MessageRecord { - await _db.SaveMessage(new MessageRecord - { - ChainId = chainId, Iteration = iterations, - Role = "assistant", Type = "web_search_result", - Content = JsonSerializer.Serialize(sources) - }); - } + ChainId = chainId, Iteration = iterations, + Role = "assistant", Type = "web_search_result", + Content = el.GetRawText() + }); } else if (blockType.EndsWith("_tool_result")) { @@ -838,19 +835,16 @@ public class AnthropicService else if (blockType == "web_search_tool_result") { assistantContent.Add(JsonSerializer.Deserialize(block.GetRawText())); - var sources = ExtractWebSearchSources(block); - if (sources.Count > 0) + // Raw-JSON speichern für History-Rebuild (API braucht den vollständigen Block) + await _db.SaveMessage(new MessageRecord { - await _db.SaveMessage(new MessageRecord - { - ChainId = chainId, - Iteration = baseIterations + iterations, - Role = "assistant", - Type = "web_search_result", - Content = JsonSerializer.Serialize(sources), - Tag = agentType - }); - } + ChainId = chainId, + Iteration = baseIterations + iterations, + Role = "assistant", + Type = "web_search_result", + Content = block.GetRawText(), + Tag = agentType + }); } else if (blockType == "server_tool_use") { @@ -1019,16 +1013,13 @@ public class AnthropicService else if (blockType == "web_search_tool_result") { assistantContent.Add(JsonSerializer.Deserialize(block.GetRawText())); - var sources = ExtractWebSearchSources(block); - if (sources.Count > 0) + // Raw-JSON speichern für History-Rebuild (API braucht den vollständigen Block) + await _db.SaveMessage(new MessageRecord { - await _db.SaveMessage(new MessageRecord - { - ChainId = ctx.ChainId, Iteration = iterations, - Role = "assistant", Type = "web_search_result", - Content = JsonSerializer.Serialize(sources) - }); - } + ChainId = ctx.ChainId, Iteration = iterations, + Role = "assistant", Type = "web_search_result", + Content = block.GetRawText() + }); } else if (blockType == "server_tool_use") { diff --git a/Services/ChatService.cs b/Services/ChatService.cs index e741ed3..4a994df 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -419,6 +419,34 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? }); } } + else if (msg.Role == "assistant" && msg.Type == "web_search_result") + { + // web_search_tool_result – Raw-JSON in den Assistant-Content-Block einfügen + // (API erwartet web_search_tool_result nach server_tool_use vom Typ web_search) + JsonElement resultEl; + try { resultEl = JsonSerializer.Deserialize(msg.Content ?? "{}"); } + catch { continue; } + + // Rückwärtskompatibilität: Alte Einträge enthalten nur Sources-Array statt Raw-JSON + // Diese überspringen, da sie kein gültiger web_search_tool_result-Block sind + if (resultEl.ValueKind == JsonValueKind.Array) continue; + + var lastAssistant = history.Count > 0 ? history[history.Count - 1] : null; + if (lastAssistant is Dictionary dict && + dict.TryGetValue("role", out var role) && role.ToString() == "assistant" && + dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) + { + contentList.Add(resultEl); + } + else + { + history.Add(new Dictionary + { + ["role"] = "assistant", + ["content"] = new List { resultEl } + }); + } + } else if (msg.Role == "user" && msg.Type == "tool_result") { var lastUser = history.Count > 0 ? history[history.Count - 1] : null; @@ -562,9 +590,76 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? } } + // Post-Processing: Orphaned web_search server_tool_use entfernen + // Bei alten DB-Einträgen wurde web_search_tool_result nur als Sources-Array gespeichert, + // nicht als Raw-JSON → BuildHistoryFromDb überspringt sie (Array-Check) → + // server_tool_use mit name "web_search" hat kein zugehöriges web_search_tool_result → + // API 400 Fehler. Lösung: Solche orphaned Blöcke aus der History entfernen. + CleanOrphanedWebSearchBlocks(history); + return history; } + /// + /// Entfernt server_tool_use-Blöcke vom Typ "web_search" aus assistant-Messages, + /// wenn kein zugehöriger web_search_tool_result-Block in derselben Message existiert. + /// + private static void CleanOrphanedWebSearchBlocks(List history) + { + foreach (var msg in history) + { + if (msg is not Dictionary dict) continue; + if (!dict.TryGetValue("role", out var role) || role.ToString() != "assistant") continue; + if (!dict.TryGetValue("content", out var content) || content is not List contentList) continue; + + // Prüfe ob es web_search server_tool_use gibt + var webSearchToolUseIds = new HashSet(); + var webSearchToolResultIds = new HashSet(); + + foreach (var block in contentList) + { + var json = JsonSerializer.Serialize(block); + JsonElement el; + try { el = JsonSerializer.Deserialize(json); } + catch { continue; } + + var type = el.TryGetProperty("type", out var t) ? t.GetString() : ""; + if (type == "server_tool_use") + { + var name = el.TryGetProperty("name", out var n) ? n.GetString() : ""; + var id = el.TryGetProperty("id", out var idProp) ? idProp.GetString() : ""; + if (name == "web_search" && !string.IsNullOrEmpty(id)) + webSearchToolUseIds.Add(id); + } + else if (type == "web_search_tool_result") + { + var toolUseId = el.TryGetProperty("tool_use_id", out var tid) ? tid.GetString() : ""; + if (!string.IsNullOrEmpty(toolUseId)) + webSearchToolResultIds.Add(toolUseId); + } + } + + // Orphaned = server_tool_use IDs ohne zugehörige web_search_tool_result + var orphanedIds = webSearchToolUseIds.Except(webSearchToolResultIds).ToHashSet(); + if (orphanedIds.Count == 0) continue; + + // Entferne orphaned server_tool_use Blöcke + contentList.RemoveAll(block => + { + var json = JsonSerializer.Serialize(block); + JsonElement el; + try { el = JsonSerializer.Deserialize(json); } + catch { return false; } + + var type = el.TryGetProperty("type", out var t) ? t.GetString() : ""; + if (type != "server_tool_use") return false; + + var id = el.TryGetProperty("id", out var idProp) ? idProp.GetString() : ""; + return orphanedIds.Contains(id); + }); + } + } + /// /// Baut einen SubAgentContext für Preparer/Review/Finalizer. /// Gemeinsame Logik: Agent laden, Model auflösen, SystemPrompt bauen, ToolChoice bestimmen. diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index 327f470..c7dbf55 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -1,3 +1,26 @@ +// ── Web Search Sources Extraction ── +// Unterstützt zwei Formate: +// - Alt: JSON-Array von {url, title} (nur Sources) +// - Neu: Raw web_search_tool_result JSON (content[].type === 'web_search_result') +function _extractWebSearchSources(content) { + try { + var parsed = JSON.parse(content || '[]'); + // Altes Format: direktes Array von Sources + if (Array.isArray(parsed)) return parsed; + // Neues Format: Raw web_search_tool_result mit content-Array + if (parsed.content && Array.isArray(parsed.content)) { + var sources = []; + parsed.content.forEach(function(item) { + if (item.type === 'web_search_result' && item.url) { + sources.push({ url: item.url, title: item.title || '' }); + } + }); + return sources; + } + return []; + } catch(e) { return []; } +} + // ── Image Attachments Auto-Loading ── async function _autoLoadImageAttachments(messageId) { @@ -246,8 +269,7 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { break; case 'web_search_result': { if (!isToolVisible('web_search')) continue; - var wsSources = []; - try { wsSources = JSON.parse(msg.content || '[]'); } catch(e) {} + var wsSources = _extractWebSearchSources(msg.content); if (wsSources.length === 0) continue; var wsDiv = document.createElement('div'); wsDiv.className = 'tool-box' + (archived ? ' archived' : ''); @@ -350,8 +372,7 @@ function _renderReviewGroup(tagGroup, archived) { } else if (m.type === 'web_search_result') { if (!isToolVisible('web_search')) continue; flushText(); - var wsSources = []; - try { wsSources = JSON.parse(m.content || '[]'); } catch(e) {} + var wsSources = _extractWebSearchSources(m.content); if (wsSources.length > 0) { var wsDiv = document.createElement('div'); wsDiv.className = 'tool-box review-tool-box' + (archived ? ' archived' : ''); diff --git a/wwwroot/js/pages/shared-page.js b/wwwroot/js/pages/shared-page.js index 1f3f7ce..00f4927 100644 --- a/wwwroot/js/pages/shared-page.js +++ b/wwwroot/js/pages/shared-page.js @@ -29,8 +29,7 @@ registerPage('shared', { // Web-Search-Results als Tool-Box mit Sources rendern if (msg.type === 'web_search_result') { - var wsSources = []; - try { wsSources = JSON.parse(c || '[]'); } catch(e) {} + var wsSources = _extractWebSearchSources(c); if (wsSources.length === 0) continue; const div = document.createElement('div'); div.className = 'tool-box';