feat: Web-Search Sources anzeigen (Streaming + History + Archive + Shared)
Backend: - web_search_tool_result Sources extrahieren (url, title) - Als 'web_search_result' Message-Typ in DB persistieren - Sources im web_search_end SSE-Event mitsenden - Alle 3 Chat-Pfade: StreamChat, RunAgentChat, ChatNonStreaming Frontend: - renderToolBoxContent: Sources-Sektion mit klickbaren Domain-Links - finishToolBox/addToolBoxStatic: sources-Parameter durchgereicht - History: web_search_result Message-Typ in allen 3 Render-Pfaden - web_search immer sichtbar (auch bei 'modifications' Filter) CSS: - .web-search-sources: Flex-Wrap Container - .web-search-source: Domain-Pill mit Hover-Effekt, Title-Ellipsis
This commit is contained in:
1 parent
116f21105c
commit
f64052caf4
5 files changed
+221
-7
No files matched your search
@@ -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<object>();
|
||||
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<JsonElement>(block.GetRawText()));
|
||||
|
||||
var sources = new List<object>();
|
||||
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<JsonElement>("{}"); }
|
||||
assistantContent.Add(new { type = "server_tool_use", id = toolId, name = toolName, input = inputEl });
|
||||
}
|
||||
}
|
||||
|
||||
messages.Add(new Dictionary<string, object> { ["role"] = "assistant", ["content"] = assistantContent });
|
||||
@@ -808,6 +871,40 @@ public class AnthropicService
|
||||
IsError = isError
|
||||
});
|
||||
}
|
||||
else if (blockType == "web_search_tool_result")
|
||||
{
|
||||
assistantContent.Add(JsonSerializer.Deserialize<JsonElement>(block.GetRawText()));
|
||||
var sources = new List<object>();
|
||||
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<JsonElement>("{}"); }
|
||||
assistantContent.Add(new { type = "server_tool_use", id = toolId, name = toolName, input = inputEl });
|
||||
}
|
||||
}
|
||||
|
||||
messages.Add(new Dictionary<string, object> { ["role"] = "assistant", ["content"] = assistantContent });
|
||||
@@ -890,6 +987,7 @@ public class AnthropicService
|
||||
|
||||
var blockTypes = new Dictionary<int, string>();
|
||||
var webSearchResultBlocks = new Dictionary<int, object>();
|
||||
var webSearchSources = new Dictionary<int, List<object>>();
|
||||
var executedTools = new List<ToolResult>();
|
||||
var streamErrors = new List<object>();
|
||||
|
||||
@@ -964,6 +1062,24 @@ public class AnthropicService
|
||||
// Gesamten Block als JSON speichern für die Conversation-History
|
||||
var rawBlock = JsonSerializer.Deserialize<JsonElement>(cb.GetRawText());
|
||||
webSearchResultBlocks[idx] = rawBlock;
|
||||
|
||||
// Sources extrahieren für Frontend
|
||||
var sources = new List<object>();
|
||||
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<object>();
|
||||
await WriteSseEvent(response, "web_search_end", new { id = currentToolId, sources }, cancellationToken);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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') : [];
|
||||
|
||||
+25
-5
@@ -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 = '<i class="fa fa-external-link"></i> <span class="source-domain">' + esc(domain) + '</span>' +
|
||||
(s.title ? '<span class="source-title">' + esc(s.title) + '</span>' : '');
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user