diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 11ab319..b69ff85 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -161,6 +161,31 @@ public class AnthropicService Content = el.GetProperty("input").ToString() }); } + else if (blockType == "web_search_tool_result") + { + // Sources aus dem Ergebnis-Block extrahieren und speichern + var sources = new List(); + if (el.TryGetProperty("content", out var searchContent) && searchContent.ValueKind == JsonValueKind.Array) + { + foreach (var item in searchContent.EnumerateArray()) + { + var itemType = item.TryGetProperty("type", out var it) ? it.GetString() : ""; + if (itemType == "web_search_result") + { + var url = item.TryGetProperty("url", out var u) ? u.GetString() ?? "" : ""; + var title = item.TryGetProperty("title", out var tt) ? tt.GetString() ?? "" : ""; + if (!string.IsNullOrEmpty(url)) + sources.Add(new { url, title }); + } + } + } + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iterations, + Role = "assistant", Type = "web_search_result", + Content = JsonSerializer.Serialize(sources) + }); + } } // Tool Results persistieren @@ -638,6 +663,44 @@ public class AnthropicService IsError = isError }); } + else if (blockType == "web_search_tool_result") + { + assistantContent.Add(JsonSerializer.Deserialize(block.GetRawText())); + + var sources = new List(); + if (block.TryGetProperty("content", out var searchContent) && searchContent.ValueKind == JsonValueKind.Array) + { + foreach (var item in searchContent.EnumerateArray()) + { + var itemType = item.TryGetProperty("type", out var it) ? it.GetString() : ""; + if (itemType == "web_search_result") + { + var url = item.TryGetProperty("url", out var u2) ? u2.GetString() ?? "" : ""; + var title = item.TryGetProperty("title", out var tt2) ? tt2.GetString() ?? "" : ""; + if (!string.IsNullOrEmpty(url)) + sources.Add(new { url, title }); + } + } + } + + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, + Iteration = baseIterations + iterations, + Role = "assistant", + Type = "web_search_result", + Content = JsonSerializer.Serialize(sources) + }); + } + else if (blockType == "server_tool_use") + { + var toolId = block.GetProperty("id").GetString() ?? ""; + var toolName = block.GetProperty("name").GetString() ?? ""; + JsonElement inputEl; + try { inputEl = block.GetProperty("input"); } + catch { inputEl = JsonSerializer.Deserialize("{}"); } + assistantContent.Add(new { type = "server_tool_use", id = toolId, name = toolName, input = inputEl }); + } } messages.Add(new Dictionary { ["role"] = "assistant", ["content"] = assistantContent }); @@ -808,6 +871,40 @@ public class AnthropicService IsError = isError }); } + else if (blockType == "web_search_tool_result") + { + assistantContent.Add(JsonSerializer.Deserialize(block.GetRawText())); + var sources = new List(); + if (block.TryGetProperty("content", out var searchContent) && searchContent.ValueKind == JsonValueKind.Array) + { + foreach (var item in searchContent.EnumerateArray()) + { + var itemType = item.TryGetProperty("type", out var it) ? it.GetString() : ""; + if (itemType == "web_search_result") + { + var url = item.TryGetProperty("url", out var u2) ? u2.GetString() ?? "" : ""; + var title = item.TryGetProperty("title", out var tt2) ? tt2.GetString() ?? "" : ""; + if (!string.IsNullOrEmpty(url)) + sources.Add(new { url, title }); + } + } + } + await _db.SaveMessage(new MessageRecord + { + ChainId = ctx.ChainId, Iteration = iterations, + Role = "assistant", Type = "web_search_result", + Content = JsonSerializer.Serialize(sources) + }); + } + else if (blockType == "server_tool_use") + { + var toolId = block.GetProperty("id").GetString() ?? ""; + var toolName = block.GetProperty("name").GetString() ?? ""; + JsonElement inputEl; + try { inputEl = block.GetProperty("input"); } + catch { inputEl = JsonSerializer.Deserialize("{}"); } + assistantContent.Add(new { type = "server_tool_use", id = toolId, name = toolName, input = inputEl }); + } } messages.Add(new Dictionary { ["role"] = "assistant", ["content"] = assistantContent }); @@ -890,6 +987,7 @@ public class AnthropicService var blockTypes = new Dictionary(); var webSearchResultBlocks = new Dictionary(); + var webSearchSources = new Dictionary>(); var executedTools = new List(); var streamErrors = new List(); @@ -964,6 +1062,24 @@ public class AnthropicService // Gesamten Block als JSON speichern für die Conversation-History var rawBlock = JsonSerializer.Deserialize(cb.GetRawText()); webSearchResultBlocks[idx] = rawBlock; + + // Sources extrahieren für Frontend + var sources = new List(); + if (cb.TryGetProperty("content", out var searchContent) && searchContent.ValueKind == JsonValueKind.Array) + { + foreach (var item in searchContent.EnumerateArray()) + { + var itemType = item.TryGetProperty("type", out var it) ? it.GetString() : ""; + if (itemType == "web_search_result") + { + var url = item.TryGetProperty("url", out var u) ? u.GetString() ?? "" : ""; + var title = item.TryGetProperty("title", out var tt) ? tt.GetString() ?? "" : ""; + if (!string.IsNullOrEmpty(url)) + sources.Add(new { url, title }); + } + } + } + webSearchSources[idx] = sources; } break; } @@ -1055,7 +1171,8 @@ public class AnthropicService { // Web-Search-Ergebnis von Anthropic – als Raw-JSON in assistantContent aufnehmen assistantContent.Add(webSearchResultBlocks[idx]); - await WriteSseEvent(response, "web_search_end", new { id = currentToolId }, cancellationToken); + var sources = webSearchSources.TryGetValue(idx, out var s) ? s : new List(); + await WriteSseEvent(response, "web_search_end", new { id = currentToolId, sources }, cancellationToken); } } break; diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index 93d6534..64c5e4e 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -170,6 +170,55 @@ padding: var(--badge-padding); } +/* Web Search Sources */ + +.web-search-sources { + display: flex; + flex-wrap: wrap; + gap: 0.4em; + padding: var(--box-padding); + padding-top: 0; +} + +.web-search-source { + display: inline-flex; + align-items: center; + gap: 0.3em; + padding: 0.2em 0.6em; + background: var(--f2-bg); + border: var(--border-width) solid var(--f2-border); + border-radius: var(--border-radius); + color: var(--text) !important; + text-decoration: none !important; + font-size: 0.85em; + transition: background var(--transition-speed), border-color var(--transition-speed); + max-width: 20em; + overflow: hidden; +} + +.web-search-source:hover { + background: var(--primary); + border-color: var(--primary); + color: var(--primary-text) !important; +} + +.web-search-source .fa { + font-size: 0.75em; + opacity: 0.6; +} + +.source-domain { + font-weight: 600; + white-space: nowrap; +} + +.source-title { + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* Diff View */ .tool-diff { diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 547e272..9c2f90e 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -210,7 +210,7 @@ async function sendMessage() { case 'web_search_end': if (currentToolBox) { - finishToolBox(currentToolBox, { name: 'web_search', result: 'Suche abgeschlossen', isError: false }); + finishToolBox(currentToolBox, { name: 'web_search', result: 'Suche abgeschlossen', isError: false, sources: data.sources }); } currentToolBox = null; _afterWebSearch = true; diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index f518b04..6303333 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -248,6 +248,16 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) { renderToolBoxContent(div, msg.toolName, msg.content, null, false); fragment.appendChild(div); break; + case 'web_search_result': { + if (!isToolVisible('web_search')) continue; + div.className = 'tool-box'; + div.dataset.toolName = 'web_search'; + var wsSources = []; + try { wsSources = JSON.parse(msg.content || '[]'); } catch(e) {} + renderToolBoxContent(div, 'web_search', null, null, false, wsSources); + fragment.appendChild(div); + break; + } case 'tool_result': { const prevBoxes = fragment.querySelectorAll('.tool-box'); const lastToolBox = prevBoxes[prevBoxes.length - 1]; @@ -304,6 +314,15 @@ function renderMessages(msgs, addToConversation) { } break; + case 'web_search_result': { + if (isToolVisible('web_search')) { + var wsSrc = []; + try { wsSrc = JSON.parse(msg.content || '[]'); } catch(e) {} + addToolBoxStatic('web_search', null, null, false, wsSrc); + } + break; + } + case 'tool_result': // tool_result wird dem vorherigen tool_use-Box angehängt wenn möglich appendToolResult(msg.content, msg.isError); @@ -400,6 +419,15 @@ async function loadLatestArchive() { div.className = 'tool-box archived'; renderToolBoxContent(div, msg.toolName, msg.content, null, false); break; + case 'web_search_result': { + if (!isToolVisible('web_search')) continue; + div.className = 'tool-box archived'; + div.dataset.toolName = 'web_search'; + var archSrc = []; + try { archSrc = JSON.parse(msg.content || '[]'); } catch(e) {} + renderToolBoxContent(div, 'web_search', null, null, false, archSrc); + break; + } case 'tool_result': { // An vorherigen tool-box anhängen const prevBoxes = fragment.querySelectorAll ? fragment.querySelectorAll('.tool-box') : []; diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index f72b972..9d9a0aa 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -823,7 +823,8 @@ function isToolVisible(toolName) { const mode = loadSettings().toolCallDisplay; if (mode === 'all') return true; if (mode === 'hidden') return false; - // 'modifications' – nur modifizierende Tools anzeigen + // 'modifications' – nur modifizierende Tools + web_search anzeigen + if (toolName === 'web_search') return true; return MODIFICATION_TOOLS.includes(toolName); } @@ -855,7 +856,7 @@ function addToolBoxLoading(name) { return div; } -function renderToolBoxContent(div, name, inputJson, result, isError) { +function renderToolBoxContent(div, name, inputJson, result, isError, sources) { let parsedInput = null; try { parsedInput = JSON.parse(inputJson); } catch {} @@ -879,6 +880,25 @@ function renderToolBoxContent(div, name, inputJson, result, isError) { let foldTarget = null; + // Web-Search Sources – immer sichtbar, kein Toggle + if (name === 'web_search' && sources && sources.length > 0) { + const sourcesList = document.createElement('div'); + sourcesList.className = 'web-search-sources'; + sources.forEach(function(s) { + const link = document.createElement('a'); + link.href = s.url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.className = 'web-search-source'; + var domain = ''; + try { domain = new URL(s.url).hostname.replace('www.', ''); } catch(e) { domain = s.url; } + link.innerHTML = ' ' + esc(domain) + '' + + (s.title ? '' + esc(s.title) + '' : ''); + sourcesList.appendChild(link); + }); + div.appendChild(sourcesList); + } + if (name === 'edit_file' && parsedInput && parsedInput.path) { const pathSpan = document.createElement('span'); pathSpan.className = 'tool-input'; @@ -1072,15 +1092,15 @@ function buildTodoList(result, isError) { function finishToolBox(div, data) { if (!div) return; - renderToolBoxContent(div, data.name, data.input, data.result, data.isError); + renderToolBoxContent(div, data.name, data.input, data.result, data.isError, data.sources); scrollDown(); } -function addToolBoxStatic(name, input, result, isError) { +function addToolBoxStatic(name, input, result, isError, sources) { const div = document.createElement('div'); div.className = 'tool-box'; div.dataset.toolName = name; - renderToolBoxContent(div, name, input, result, isError); + renderToolBoxContent(div, name, input, result, isError, sources); chatEl().appendChild(div); scrollDown(); }