From bca5e5145ac709aed0c7c081cc0f93a3b7ab63b5 Mon Sep 17 00:00:00 2001 From: Meleki Date: Fri, 13 Mar 2026 08:12:55 +0100 Subject: [PATCH] Add github_code_search tool + scanned key type field - New tool: github_code_search (GitHub Code Search API with text-match fragments) - Tool available when GitHubToken is configured in server settings - Registered in AnthropicService + ChatService tool pipelines - ToolService: SetHttpClientFactory() pattern for lazy DI resolution - ScannedKeyRecord: type field (anthropic_oauth, anthropic_api, anthropic_unknown, unknown) - DetectKeyType() based on key prefix - DB migration: type column with default 'anthropic_oauth' - Frontend: type label in scanned keys list --- Endpoints/ApiKeysEndpoint.cs | 1 + Models/ScannedKeyRecord.cs | 1 + Program.cs | 1 + Services/AnthropicService.cs | 5 +- Services/ChatService.cs | 5 +- Services/DatabaseService.Integrations.cs | 4 +- Services/DatabaseService.cs | 1 + Services/KeyScannerService.cs | 9 ++ Services/ToolService.cs | 122 ++++++++++++++++++++++- wwwroot/index.html | 4 +- wwwroot/js/pages/api-keys-page.js | 2 + 11 files changed, 145 insertions(+), 10 deletions(-) diff --git a/Endpoints/ApiKeysEndpoint.cs b/Endpoints/ApiKeysEndpoint.cs index 7503b03..66ffbbc 100644 --- a/Endpoints/ApiKeysEndpoint.cs +++ b/Endpoints/ApiKeysEndpoint.cs @@ -263,6 +263,7 @@ public static class ApiKeysEndpoint { k.ScannedKeyId, k.Source, + k.Type, k.SourceUrl, k.Status, k.Plan, diff --git a/Models/ScannedKeyRecord.cs b/Models/ScannedKeyRecord.cs index 9f679b2..38eaafd 100644 --- a/Models/ScannedKeyRecord.cs +++ b/Models/ScannedKeyRecord.cs @@ -6,6 +6,7 @@ public class ScannedKeyRecord public string ScannedKeyId { get; set; } public string ApiKey { get; set; } public string Source { get; set; } // Herkunft: github, serper, gist, pastebin + public string Type { get; set; } // Key-Typ: anthropic_oauth, etc. public string SourceUrl { get; set; } // URL wo der Key gefunden wurde public string Status { get; set; } // valid, invalid, auth_error, rate_limited, quota_exceeded, error public string Plan { get; set; } // Pro, Max, Pro/Max, API, Unknown diff --git a/Program.cs b/Program.cs index c6bdabe..51a3381 100644 --- a/Program.cs +++ b/Program.cs @@ -101,6 +101,7 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); var app = builder.Build(); +toolService.SetHttpClientFactory(app.Services.GetRequiredService()); app.Services.GetRequiredService().SetTelegramService(app.Services.GetRequiredService()); await app.Services.GetRequiredService().LoadModelsAsync(); app.UseWebSockets(); diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index fee555b..add3061 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -1625,7 +1625,8 @@ public class AnthropicService 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; + var includeGitHubSearch = _toolService.HasGitHubToken; + var hasTools = hasWorkspace || includeMemory || includeRemote || includeMemorySearch || includeSendToSession || includeGitHubSearch; var hasWebSearch = !string.IsNullOrEmpty(webSearchVersion); if (!hasTools && !hasWebSearch) return; @@ -1633,7 +1634,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); + var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice, includeRemote: includeRemote, includeMemorySearch: includeMemorySearch, includeSendToSession: includeSendToSession, includeGitHubSearch: includeGitHubSearch); if (planMode) allTools = _toolService.FilterReadOnlyTools(allTools); tools.AddRange(allTools); diff --git a/Services/ChatService.cs b/Services/ChatService.cs index ad703c4..52ecf39 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -79,11 +79,12 @@ public class ChatService var hasWorkspace = !string.IsNullOrEmpty(workspace); var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId); - var hasTools = hasWorkspace || includeMemory; + var includeGitHubSearch = _toolService.HasGitHubToken; + var hasTools = hasWorkspace || includeMemory || includeGitHubSearch; var toolChoice = hasTools ? "any" : null; var toolDefs = hasTools - ? _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: agent.AllowUserChoice, includeRemote: hasRemoteConnection) + ? _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: agent.AllowUserChoice, includeRemote: hasRemoteConnection, includeGitHubSearch: includeGitHubSearch) : Array.Empty(); var toolNames = toolDefs.Length > 0 ? JsonSerializer.Serialize(toolDefs.Select(td => diff --git a/Services/DatabaseService.Integrations.cs b/Services/DatabaseService.Integrations.cs index fe4e9dd..a12860f 100644 --- a/Services/DatabaseService.Integrations.cs +++ b/Services/DatabaseService.Integrations.cs @@ -460,8 +460,8 @@ public partial class DatabaseService { using var conn = CreateConnection(); await conn.ExecuteAsync(@" - INSERT OR IGNORE INTO scanned_keys (scanned_key_id, api_key, source, source_url, status, plan, usage_7d, usage_5h, scanned_at, checked_at) - VALUES (@ScannedKeyId, @ApiKey, @Source, @SourceUrl, @Status, @Plan, @Usage7d, @Usage5h, @ScannedAt, @CheckedAt)", + INSERT OR IGNORE INTO scanned_keys (scanned_key_id, api_key, source, source_url, type, status, plan, usage_7d, usage_5h, scanned_at, checked_at) + VALUES (@ScannedKeyId, @ApiKey, @Source, @SourceUrl, @Type, @Status, @Plan, @Usage7d, @Usage5h, @ScannedAt, @CheckedAt)", record); } diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 9625033..aac55e2 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -330,6 +330,7 @@ public partial class DatabaseService TryAddColumn(conn, "scanned_keys", "deleted", "INTEGER NOT NULL DEFAULT 0"); TryAddColumn(conn, "scanned_keys", "adopted_api_key_id", "INTEGER"); TryAddColumn(conn, "scanned_keys", "source", "TEXT"); + TryAddColumn(conn, "scanned_keys", "type", "TEXT NOT NULL DEFAULT 'anthropic_oauth'"); // api_keys Metadaten-Spalten TryAddColumn(conn, "api_keys", "status", "TEXT"); diff --git a/Services/KeyScannerService.cs b/Services/KeyScannerService.cs index db7e4a1..a196807 100644 --- a/Services/KeyScannerService.cs +++ b/Services/KeyScannerService.cs @@ -185,6 +185,7 @@ public class KeyScannerService : BackgroundService ApiKey = key, Source = source, SourceUrl = sourceUrl, + Type = DetectKeyType(key), Status = "unchecked", Plan = "Unknown", ScannedAt = DateTime.UtcNow.ToString("o") @@ -471,4 +472,12 @@ public class KeyScannerService : BackgroundService await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, "error", "Unknown", null, null); } } + + private static string DetectKeyType(string key) + { + if (key.StartsWith("sk-ant-oat01-", StringComparison.Ordinal)) return "anthropic_oauth"; + if (key.StartsWith("sk-ant-api03-", StringComparison.Ordinal)) return "anthropic_api"; + if (key.StartsWith("sk-ant-", StringComparison.Ordinal)) return "anthropic_unknown"; + return "unknown"; + } } diff --git a/Services/ToolService.cs b/Services/ToolService.cs index 1e35a35..a7a414a 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; @@ -17,13 +19,20 @@ namespace Gott.Services; public class ToolService { private readonly IConfiguration _config; + private IHttpClientFactory _httpFactory; public ToolService(IConfiguration config) { _config = config; } + public void SetHttpClientFactory(IHttpClientFactory httpFactory) + { + _httpFactory = httpFactory; + } + private int ExecuteTimeoutSeconds => _config.GetValue("ExecuteTimeout", 180); + public bool HasGitHubToken => !string.IsNullOrWhiteSpace(_config["GitHubToken"]); public const string AskUserChoiceToolName = "ask_user_choice"; /// @@ -34,7 +43,7 @@ public class ToolService { "read_file", "list_files", "glob", "grep", "execute", "todo_write", "memory_read", "memory_write", "memory_list", "memory_search", - "remote_execute", AskUserChoiceToolName + "remote_execute", "github_code_search", AskUserChoiceToolName }; public object[] FilterReadOnlyTools(object[] tools) @@ -54,7 +63,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) + public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false, bool includeUserChoice = true, bool includeRemote = false, bool includeMemorySearch = false, bool includeSendToSession = false, bool includeGitHubSearch = false) { var allTools = new List(); if (includeUserChoice) @@ -69,6 +78,8 @@ public class ToolService allTools.Add(GetRemoteExecuteTool()); if (includeSendToSession) allTools.Add(GetSendToSessionTool()); + if (includeGitHubSearch) + allTools.Add(GetGitHubCodeSearchTool()); return allTools.ToArray(); } @@ -799,6 +810,10 @@ public class ToolService return $"Nachricht wurde erfolgreich an Session \"{targetSession.Name}\" gesendet. Der Agent dort hat geantwortet."; } + case "github_code_search": + { + return await ExecuteGitHubCodeSearch(input, cancellationToken); + } default: return $"Fehler: Unbekanntes Tool: {name}"; } @@ -811,4 +826,107 @@ public class ToolService throw new Exception("Zugriff außerhalb des Workspace nicht erlaubt"); return full; } + + // ── GitHub Code Search ── + + private object GetGitHubCodeSearchTool() + { + return new + { + name = "github_code_search", + description = "Durchsucht öffentliche GitHub-Repositories nach Code. Nutzt die GitHub Code Search API. Gibt Dateinamen, Repositories und Code-Ausschnitte zurück. Unterstützt GitHub-Suchsyntax (z.B. language:python, path:*.js, org:microsoft).", + input_schema = new + { + type = "object", + properties = new Dictionary + { + ["query"] = new { type = "string", description = "Suchbegriff mit optionaler GitHub-Suchsyntax (z.B. 'useState language:typescript', 'OPENAI_API_KEY path:.env')" }, + ["per_page"] = new { type = "integer", description = "Anzahl Ergebnisse (1-50, Default: 10)" } + }, + required = new[] { "query" } + } + }; + } + + private async Task ExecuteGitHubCodeSearch(JsonElement input, CancellationToken ct) + { + var token = _config["GitHubToken"] ?? ""; + if (string.IsNullOrWhiteSpace(token)) + return "Fehler: Kein GitHubToken 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 perPage = input.TryGetProperty("per_page", out var ppProp) ? Math.Clamp(ppProp.GetInt32(), 1, 50) : 10; + + try + { + var client = _httpFactory.CreateClient(); + client.DefaultRequestHeaders.UserAgent.ParseAdd("GottApp/1.0"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github.text-match+json")); + + var url = $"https://api.github.com/search/code?q={Uri.EscapeDataString(query)}&per_page={perPage}"; + var response = await client.GetAsync(url, ct); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(ct); + return $"GitHub API Fehler {(int)response.StatusCode}: {errorBody}"; + } + + var json = await response.Content.ReadAsStringAsync(ct); + var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var totalCount = root.GetProperty("total_count").GetInt32(); + var items = root.GetProperty("items"); + var sb = new StringBuilder(); + sb.AppendLine($"**{totalCount} Treffer gefunden** (zeige {Math.Min(perPage, items.GetArrayLength())})"); + sb.AppendLine(); + + var index = 0; + foreach (var item in items.EnumerateArray()) + { + index++; + var name = item.GetProperty("name").GetString() ?? ""; + var path = item.GetProperty("path").GetString() ?? ""; + var repo = item.GetProperty("repository").GetProperty("full_name").GetString() ?? ""; + var htmlUrl = item.GetProperty("html_url").GetString() ?? ""; + + sb.AppendLine($"### {index}. {repo}"); + sb.AppendLine($"📄 `{path}`"); + sb.AppendLine($"🔗 {htmlUrl}"); + + // Text-Match-Fragmente (wenn vorhanden) + if (item.TryGetProperty("text_matches", out var textMatches)) + { + foreach (var match in textMatches.EnumerateArray()) + { + if (match.TryGetProperty("fragment", out var fragment)) + { + var frag = fragment.GetString() ?? ""; + if (frag.Length > 500) frag = frag.Substring(0, 500) + "..."; + sb.AppendLine("```"); + sb.AppendLine(frag); + sb.AppendLine("```"); + } + } + } + sb.AppendLine(); + } + + return sb.ToString(); + } + catch (TaskCanceledException) + { + return "GitHub-Suche abgebrochen (Timeout)."; + } + catch (Exception ex) + { + return $"GitHub-Suche fehlgeschlagen: {ex.Message}"; + } + } } diff --git a/wwwroot/index.html b/wwwroot/index.html index 1967bc1..a7b5bea 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -580,8 +580,8 @@
- diff --git a/wwwroot/js/pages/api-keys-page.js b/wwwroot/js/pages/api-keys-page.js index 024b5d2..ffd1610 100644 --- a/wwwroot/js/pages/api-keys-page.js +++ b/wwwroot/js/pages/api-keys-page.js @@ -362,6 +362,8 @@ registerPage('api-keys', { var metaParts = []; var sourceIcons = { github: '🐙 GitHub', serper: '🔍 Google', gist: '📋 Gist', pastebin: '📄 Pastebin' }; metaParts.push(sourceIcons[sk.source] || sk.source || 'github'); + var typeLabels = { anthropic_oauth: 'OAuth', anthropic_api: 'API', anthropic_unknown: 'Anthropic', unknown: '?' }; + metaParts.push(typeLabels[sk.type] || sk.type || '?'); metaParts.push(formatKeyStatus(sk.status)); if (sk.plan && sk.plan !== 'Unknown') metaParts.push(sk.plan); if (sk.checkedAt) metaParts.push(new Date(sk.checkedAt).toLocaleString());