From 192f41e91ea3063c13512bb5d7ac8bb1ba7bbc23 Mon Sep 17 00:00:00 2001 From: Meleki Date: Fri, 13 Mar 2026 09:41:56 +0100 Subject: [PATCH] feat: Serper Web Search als Agent-Tool - ToolService: serper_web_search Tool-Definition + ExecuteSerperWebSearch() Google-Suche via Serper.dev API mit Titel, Link, Snippet Knowledge Graph + Answer Box + organische Ergebnisse - AgentRecord: UseSerperSearch Property (Default: false) - DB-Migration: use_serper_search Spalte - AnthropicService + ChatService: useSerperSearch Parameter durchreichen - ChatEndpoint + AgentEndpoint: useSerperSearch in API - Frontend: Checkbox, Badge (Serper Search), laden/speichern - Aktivierung: Agent-Checkbox + SerperApiKey in Server-Einstellungen --- Endpoints/AgentEndpoint.cs | 3 + Endpoints/ChatEndpoint.cs | 2 +- Models/AgentRecord.cs | 1 + Services/AnthropicService.cs | 9 +- Services/ChatService.cs | 5 +- Services/DatabaseService.Agents.cs | 6 +- Services/DatabaseService.cs | 1 + Services/ToolService.cs | 127 ++++++++++++++++++++++++++++- wwwroot/index.html | 4 + wwwroot/js/cards.js | 1 + wwwroot/js/pages/agent-page.js | 3 + 11 files changed, 150 insertions(+), 12 deletions(-) diff --git a/Endpoints/AgentEndpoint.cs b/Endpoints/AgentEndpoint.cs index 0dca9e7..c8f52eb 100644 --- a/Endpoints/AgentEndpoint.cs +++ b/Endpoints/AgentEndpoint.cs @@ -66,6 +66,7 @@ public static class AgentEndpoint a.UseMemory, a.UseAutoContext, a.UseGitHubSearch, + a.UseSerperSearch, a.UseWebSearch, a.WebSearchVersion, a.AllowUserChoice, @@ -123,6 +124,7 @@ public static class AgentEndpoint UseMemory = !body.TryGetProperty("useMemory", out var um) || um.GetBoolean(), UseAutoContext = !body.TryGetProperty("useAutoContext", out var uac) || uac.GetBoolean(), UseGitHubSearch = body.TryGetProperty("useGitHubSearch", out var ughs) && ughs.GetBoolean(), + UseSerperSearch = body.TryGetProperty("useSerperSearch", out var usps) && usps.GetBoolean(), UseWebSearch = body.TryGetProperty("webSearchVersion", out var wsv) && !string.IsNullOrEmpty(wsv.GetString()), WebSearchVersion = body.TryGetProperty("webSearchVersion", out var wsv2) ? wsv2.GetString() ?? "" : "", AllowUserChoice = !body.TryGetProperty("allowUserChoice", out var auc) || auc.GetBoolean(), @@ -166,6 +168,7 @@ public static class AgentEndpoint if (body.TryGetProperty("useMemory", out var um)) agent.UseMemory = um.GetBoolean(); if (body.TryGetProperty("useAutoContext", out var uac)) agent.UseAutoContext = uac.GetBoolean(); if (body.TryGetProperty("useGitHubSearch", out var ughs)) agent.UseGitHubSearch = ughs.GetBoolean(); + if (body.TryGetProperty("useSerperSearch", out var usps)) agent.UseSerperSearch = usps.GetBoolean(); if (body.TryGetProperty("webSearchVersion", out var wsv)) { agent.WebSearchVersion = wsv.GetString() ?? ""; diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 2a9fcfc..e361010 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -216,7 +216,7 @@ public static class ChatEndpoint else { // Standard: Anthropic - await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, userId: userId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, useAutoContext: ctx.Agent?.UseAutoContext ?? true, useGitHubSearch: ctx.Agent?.UseGitHubSearch ?? false, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr); + await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, userId: userId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, useAutoContext: ctx.Agent?.UseAutoContext ?? true, useGitHubSearch: ctx.Agent?.UseGitHubSearch ?? false, useSerperSearch: ctx.Agent?.UseSerperSearch ?? false, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr); } } finally diff --git a/Models/AgentRecord.cs b/Models/AgentRecord.cs index b4980c1..6ea5b18 100644 --- a/Models/AgentRecord.cs +++ b/Models/AgentRecord.cs @@ -18,6 +18,7 @@ public class AgentRecord public bool UseMemory { get; set; } = true; public bool UseAutoContext { get; set; } = true; public bool UseGitHubSearch { get; set; } + public bool UseSerperSearch { get; set; } public bool UseWebSearch { get; set; } public string WebSearchVersion { get; set; } = ""; public bool AllowUserChoice { get; set; } = true; diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index fd397d6..b6d5a83 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -97,6 +97,7 @@ public class AnthropicService bool planMode = false, bool useAutoContext = true, bool useGitHubSearch = false, + bool useSerperSearch = false, CancellationToken workCt = default, CancellationToken hardCt = default, CancellationToken sseCt = default, @@ -178,7 +179,7 @@ public class AnthropicService payload["system"] = systemPrompt; var hasRemote = remoteMgr?.GetBySessionId(sessionId) != null; - AddToolsToPayload(payload, workspace, agentId, useMemory, webSearchVersion, toolChoice, iterations, allowUserChoice, planMode, includeRemote: hasRemote, includeGitHubSearch: useGitHubSearch && _toolService.HasGitHubToken); + AddToolsToPayload(payload, workspace, agentId, useMemory, webSearchVersion, toolChoice, iterations, allowUserChoice, planMode, includeRemote: hasRemote, includeGitHubSearch: useGitHubSearch && _toolService.HasGitHubToken, includeSerperSearch: useSerperSearch && _toolService.HasSerperKey); var client = CreateClient(); var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); @@ -1620,13 +1621,13 @@ public class AnthropicService /// /// Fügt Tool-Definitionen und WebSearch zum Payload hinzu. /// - private void AddToolsToPayload(Dictionary payload, string workspace, string agentId, bool useMemory, string webSearchVersion, string toolChoice, int iteration, bool allowUserChoice = false, bool planMode = false, bool includeRemote = false, bool includeGitHubSearch = false) + private void AddToolsToPayload(Dictionary payload, string workspace, string agentId, bool useMemory, string webSearchVersion, string toolChoice, int iteration, bool allowUserChoice = false, bool planMode = false, bool includeRemote = false, bool includeGitHubSearch = false, bool includeSerperSearch = false) { var hasWorkspace = !string.IsNullOrEmpty(workspace); var includeMemory = useMemory && _memoryService != null && !string.IsNullOrEmpty(agentId); var includeMemorySearch = _vectorSearch != null && _vectorSearch.GetEmbeddingCount() > 0; var includeSendToSession = _db != null && _chatService != null; - var hasTools = hasWorkspace || includeMemory || includeRemote || includeMemorySearch || includeSendToSession || includeGitHubSearch; + var hasTools = hasWorkspace || includeMemory || includeRemote || includeMemorySearch || includeSendToSession || includeGitHubSearch || includeSerperSearch; var hasWebSearch = !string.IsNullOrEmpty(webSearchVersion); if (!hasTools && !hasWebSearch) return; @@ -1634,7 +1635,7 @@ public class AnthropicService var tools = new List(); if (hasTools) { - var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice, includeRemote: includeRemote, includeMemorySearch: includeMemorySearch, includeSendToSession: includeSendToSession, includeGitHubSearch: includeGitHubSearch); + var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice, includeRemote: includeRemote, includeMemorySearch: includeMemorySearch, includeSendToSession: includeSendToSession, includeGitHubSearch: includeGitHubSearch, includeSerperSearch: includeSerperSearch); if (planMode) allTools = _toolService.FilterReadOnlyTools(allTools); tools.AddRange(allTools); diff --git a/Services/ChatService.cs b/Services/ChatService.cs index 544c2da..80bf8d7 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -80,11 +80,12 @@ public class ChatService var hasWorkspace = !string.IsNullOrEmpty(workspace); var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId); var includeGitHubSearch = agent.UseGitHubSearch && _toolService.HasGitHubToken; - var hasTools = hasWorkspace || includeMemory || includeGitHubSearch; + var includeSerperSearch = agent.UseSerperSearch && _toolService.HasSerperKey; + var hasTools = hasWorkspace || includeMemory || includeGitHubSearch || includeSerperSearch; var toolChoice = hasTools ? "any" : null; var toolDefs = hasTools - ? _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: agent.AllowUserChoice, includeRemote: hasRemoteConnection, includeGitHubSearch: includeGitHubSearch) + ? _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: agent.AllowUserChoice, includeRemote: hasRemoteConnection, includeGitHubSearch: includeGitHubSearch, includeSerperSearch: includeSerperSearch) : Array.Empty(); var toolNames = toolDefs.Length > 0 ? JsonSerializer.Serialize(toolDefs.Select(td => diff --git a/Services/DatabaseService.Agents.cs b/Services/DatabaseService.Agents.cs index d7af6c5..d206ad5 100644 --- a/Services/DatabaseService.Agents.cs +++ b/Services/DatabaseService.Agents.cs @@ -19,8 +19,8 @@ public partial class DatabaseService agent.CreatedAt = now; agent.UpdatedAt = now; await conn.ExecuteAsync(@" - INSERT INTO agents (agent_id, user_id, name, avatar, avatar_small, description, model, tool_choice, custom_commands, auto_commit, use_soul, use_identity, use_user_info, use_memory, use_auto_context, use_github_search, use_web_search, web_search_version, allow_user_choice, task_instruction, avatar_scale, avatar_offset_x, avatar_offset_y, is_deleted, created_at, updated_at) - VALUES (@AgentId, @UserId, @Name, @Avatar, @AvatarSmall, @Description, @Model, @ToolChoice, @CustomCommands, @AutoCommit, @UseSoul, @UseIdentity, @UseUserInfo, @UseMemory, @UseAutoContext, @UseGitHubSearch, @UseWebSearch, @WebSearchVersion, @AllowUserChoice, @TaskInstruction, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, 0, @CreatedAt, @UpdatedAt) + INSERT INTO agents (agent_id, user_id, name, avatar, avatar_small, description, model, tool_choice, custom_commands, auto_commit, use_soul, use_identity, use_user_info, use_memory, use_auto_context, use_github_search, use_serper_search, use_web_search, web_search_version, allow_user_choice, task_instruction, avatar_scale, avatar_offset_x, avatar_offset_y, is_deleted, created_at, updated_at) + VALUES (@AgentId, @UserId, @Name, @Avatar, @AvatarSmall, @Description, @Model, @ToolChoice, @CustomCommands, @AutoCommit, @UseSoul, @UseIdentity, @UseUserInfo, @UseMemory, @UseAutoContext, @UseGitHubSearch, @UseSerperSearch, @UseWebSearch, @WebSearchVersion, @AllowUserChoice, @TaskInstruction, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, 0, @CreatedAt, @UpdatedAt) ", agent); return agent; } @@ -54,7 +54,7 @@ public partial class DatabaseService UPDATE agents SET name = @Name, avatar = @Avatar, avatar_small = @AvatarSmall, description = @Description, model = @Model, tool_choice = @ToolChoice, custom_commands = @CustomCommands, auto_commit = @AutoCommit, - use_soul = @UseSoul, use_identity = @UseIdentity, use_user_info = @UseUserInfo, use_memory = @UseMemory, use_auto_context = @UseAutoContext, use_github_search = @UseGitHubSearch, use_web_search = @UseWebSearch, web_search_version = @WebSearchVersion, + use_soul = @UseSoul, use_identity = @UseIdentity, use_user_info = @UseUserInfo, use_memory = @UseMemory, use_auto_context = @UseAutoContext, use_github_search = @UseGitHubSearch, use_serper_search = @UseSerperSearch, use_web_search = @UseWebSearch, web_search_version = @WebSearchVersion, allow_user_choice = @AllowUserChoice, task_instruction = @TaskInstruction, avatar_scale = @AvatarScale, avatar_offset_x = @AvatarOffsetX, avatar_offset_y = @AvatarOffsetY, is_public = @IsPublic, updated_at = @UpdatedAt diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index ef039ba..b5faf0e 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -152,6 +152,7 @@ public partial class DatabaseService TryAddColumn(conn, "agents", "use_memory", "INTEGER NOT NULL DEFAULT 1"); TryAddColumn(conn, "agents", "use_auto_context", "INTEGER NOT NULL DEFAULT 1"); TryAddColumn(conn, "agents", "use_github_search", "INTEGER NOT NULL DEFAULT 0"); + TryAddColumn(conn, "agents", "use_serper_search", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "messages", "deleted_on", "TEXT"); TryAddColumn(conn, "agents", "use_web_search", "INTEGER NOT NULL DEFAULT 0"); diff --git a/Services/ToolService.cs b/Services/ToolService.cs index a7a414a..ba51ff0 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -33,6 +33,7 @@ public class ToolService private int ExecuteTimeoutSeconds => _config.GetValue("ExecuteTimeout", 180); public bool HasGitHubToken => !string.IsNullOrWhiteSpace(_config["GitHubToken"]); + public bool HasSerperKey => !string.IsNullOrWhiteSpace(_config["SerperApiKey"]); public const string AskUserChoiceToolName = "ask_user_choice"; /// @@ -43,7 +44,7 @@ public class ToolService { "read_file", "list_files", "glob", "grep", "execute", "todo_write", "memory_read", "memory_write", "memory_list", "memory_search", - "remote_execute", "github_code_search", AskUserChoiceToolName + "remote_execute", "github_code_search", "serper_web_search", AskUserChoiceToolName }; public object[] FilterReadOnlyTools(object[] tools) @@ -63,7 +64,7 @@ public class ToolService return filtered.ToArray(); } - public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false, bool includeUserChoice = true, bool includeRemote = false, bool includeMemorySearch = false, bool includeSendToSession = false, bool includeGitHubSearch = false) + public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false, bool includeUserChoice = true, bool includeRemote = false, bool includeMemorySearch = false, bool includeSendToSession = false, bool includeGitHubSearch = false, bool includeSerperSearch = false) { var allTools = new List(); if (includeUserChoice) @@ -80,6 +81,8 @@ public class ToolService allTools.Add(GetSendToSessionTool()); if (includeGitHubSearch) allTools.Add(GetGitHubCodeSearchTool()); + if (includeSerperSearch) + allTools.Add(GetSerperWebSearchTool()); return allTools.ToArray(); } @@ -814,6 +817,10 @@ public class ToolService { return await ExecuteGitHubCodeSearch(input, cancellationToken); } + case "serper_web_search": + { + return await ExecuteSerperWebSearch(input, cancellationToken); + } default: return $"Fehler: Unbekanntes Tool: {name}"; } @@ -929,4 +936,120 @@ public class ToolService return $"GitHub-Suche fehlgeschlagen: {ex.Message}"; } } + + // ── Serper Web Search ── + + private object GetSerperWebSearchTool() + { + return new + { + name = "serper_web_search", + description = "Durchsucht das Internet via Google (Serper.dev API). Gibt strukturierte Suchergebnisse zurück mit Titel, Link und Snippet. Unterstützt alle Google-Suchoperatoren (z.B. exakte Phrasen mit Anführungszeichen, site:, filetype:). Nutze dies für Webrecherche, aktuelle Informationen oder das Finden spezifischer Inhalte im Internet.", + input_schema = new + { + type = "object", + properties = new Dictionary + { + ["query"] = new { type = "string", description = "Suchbegriff (unterstützt Google-Suchsyntax, z.B. '\"exakte Phrase\"', 'site:github.com', 'filetype:pdf')" }, + ["num"] = new { type = "integer", description = "Anzahl Ergebnisse (1-100, Default: 10)" } + }, + required = new[] { "query" } + } + }; + } + + private async Task ExecuteSerperWebSearch(JsonElement input, CancellationToken ct) + { + var apiKey = _config["SerperApiKey"] ?? ""; + if (string.IsNullOrWhiteSpace(apiKey)) + return "Fehler: Kein SerperApiKey konfiguriert. Bitte in den Server-Einstellungen hinterlegen."; + + var query = input.GetProperty("query").GetString() ?? ""; + if (string.IsNullOrWhiteSpace(query)) + return "Fehler: Suchbegriff (query) darf nicht leer sein."; + + var num = input.TryGetProperty("num", out var numProp) ? Math.Clamp(numProp.GetInt32(), 1, 100) : 10; + + try + { + var client = _httpFactory.CreateClient(); + client.DefaultRequestHeaders.Add("X-API-KEY", apiKey); + + var requestBody = JsonSerializer.Serialize(new { q = query, num = num }); + var content = new StringContent(requestBody, Encoding.UTF8, "application/json"); + + var response = await client.PostAsync("https://google.serper.dev/search", content, ct); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(ct); + return $"Serper API Fehler {(int)response.StatusCode}: {errorBody}"; + } + + var json = await response.Content.ReadAsStringAsync(ct); + var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var sb = new StringBuilder(); + + // Knowledge Graph (wenn vorhanden) + if (root.TryGetProperty("knowledgeGraph", out var kg)) + { + if (kg.TryGetProperty("title", out var kgTitle)) + sb.AppendLine($"## {kgTitle.GetString()}"); + if (kg.TryGetProperty("description", out var kgDesc)) + sb.AppendLine(kgDesc.GetString()); + sb.AppendLine(); + } + + // Answer Box (wenn vorhanden) + if (root.TryGetProperty("answerBox", out var ab)) + { + sb.AppendLine("### Direkte Antwort"); + if (ab.TryGetProperty("title", out var abTitle)) + sb.AppendLine($"**{abTitle.GetString()}**"); + if (ab.TryGetProperty("answer", out var abAnswer)) + sb.AppendLine(abAnswer.GetString()); + else if (ab.TryGetProperty("snippet", out var abSnippet)) + sb.AppendLine(abSnippet.GetString()); + sb.AppendLine(); + } + + // Organische Ergebnisse + if (root.TryGetProperty("organic", out var organic)) + { + var results = organic.EnumerateArray().ToArray(); + sb.AppendLine($"**{results.Length} Ergebnisse:**"); + sb.AppendLine(); + + var index = 0; + foreach (var item in results) + { + index++; + var title = item.TryGetProperty("title", out var t) ? t.GetString() : ""; + var link = item.TryGetProperty("link", out var l) ? l.GetString() : ""; + var snippet = item.TryGetProperty("snippet", out var s) ? s.GetString() : ""; + + sb.AppendLine($"### {index}. {title}"); + sb.AppendLine($"🔗 {link}"); + if (!string.IsNullOrEmpty(snippet)) + sb.AppendLine(snippet); + sb.AppendLine(); + } + } + + if (sb.Length == 0) + sb.AppendLine("Keine Ergebnisse gefunden."); + + return sb.ToString(); + } + catch (TaskCanceledException) + { + return "Serper-Suche abgebrochen (Timeout)."; + } + catch (Exception ex) + { + return $"Serper-Suche fehlgeschlagen: {ex.Message}"; + } + } } diff --git a/wwwroot/index.html b/wwwroot/index.html index 1396bc0..43de231 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -245,6 +245,10 @@ GitHub Code Search (Öffentlichen Code auf GitHub durchsuchen) +