From 9d4bec7a17df0563cc08ca75cf372c30419b4c71 Mon Sep 17 00:00:00 2001 From: TomiEckert Date: Wed, 18 Mar 2026 09:28:14 +0100 Subject: [PATCH] initial release --- .gitignore | 7 ++ ConfigManager.cs | 63 ++++++++++++ Models/Chunk.cs | 11 ++ Models/JsonContexts.cs | 15 +++ Models/OpenQueryOptions.cs | 10 ++ Models/OpenRouter.cs | 75 ++++++++++++++ Models/Searxng.cs | 13 +++ OpenQuery.cs | 153 ++++++++++++++++++++++++++++ OpenQuery.csproj | 18 ++++ Program.cs | 188 +++++++++++++++++++++++++++++++++++ Services/ArticleService.cs | 12 +++ Services/ChunkingService.cs | 32 ++++++ Services/EmbeddingService.cs | 38 +++++++ Services/OpenRouterClient.cs | 123 +++++++++++++++++++++++ Services/SearxngClient.cs | 30 ++++++ Tools/SearchTool.cs | 89 +++++++++++++++++ install.sh | 21 ++++ uninstall.sh | 16 +++ 18 files changed, 914 insertions(+) create mode 100644 .gitignore create mode 100644 ConfigManager.cs create mode 100644 Models/Chunk.cs create mode 100644 Models/JsonContexts.cs create mode 100644 Models/OpenQueryOptions.cs create mode 100644 Models/OpenRouter.cs create mode 100644 Models/Searxng.cs create mode 100644 OpenQuery.cs create mode 100644 OpenQuery.csproj create mode 100644 Program.cs create mode 100644 Services/ArticleService.cs create mode 100644 Services/ChunkingService.cs create mode 100644 Services/EmbeddingService.cs create mode 100644 Services/OpenRouterClient.cs create mode 100644 Services/SearxngClient.cs create mode 100644 Tools/SearchTool.cs create mode 100755 install.sh create mode 100755 uninstall.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14f9907 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin +obj +.vs +.vscode +.anchor +.crush +.idea diff --git a/ConfigManager.cs b/ConfigManager.cs new file mode 100644 index 0000000..79713cd --- /dev/null +++ b/ConfigManager.cs @@ -0,0 +1,63 @@ +namespace OpenQuery; + +public class AppConfig +{ + public string ApiKey { get; set; } = ""; + public string Model { get; set; } = "qwen/qwen3.5-flash-02-23"; + public int DefaultQueries { get; set; } = 3; + public int DefaultChunks { get; set; } = 3; + public int DefaultResults { get; set; } = 5; +} + +public static class ConfigManager +{ + private static string GetConfigPath() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var configDir = Path.Combine(home, ".config", "openquery"); + if (!Directory.Exists(configDir)) + { + Directory.CreateDirectory(configDir); + } + return Path.Combine(configDir, "config"); + } + + public static AppConfig Load() + { + var config = new AppConfig(); + var path = GetConfigPath(); + if (File.Exists(path)) + { + var lines = File.ReadAllLines(path); + foreach (var line in lines) + { + var parts = line.Split('=', 2); + if (parts.Length == 2) + { + var key = parts[0].Trim(); + var val = parts[1].Trim(); + if (key == "ApiKey") config.ApiKey = val; + if (key == "Model") config.Model = val; + if (key == "DefaultQueries" && int.TryParse(val, out var q)) config.DefaultQueries = q; + if (key == "DefaultChunks" && int.TryParse(val, out var c)) config.DefaultChunks = c; + if (key == "DefaultResults" && int.TryParse(val, out var r)) config.DefaultResults = r; + } + } + } + return config; + } + + public static void Save(AppConfig config) + { + var path = GetConfigPath(); + var lines = new List + { + $"ApiKey={config.ApiKey}", + $"Model={config.Model}", + $"DefaultQueries={config.DefaultQueries}", + $"DefaultChunks={config.DefaultChunks}", + $"DefaultResults={config.DefaultResults}" + }; + File.WriteAllLines(path, lines); + } +} \ No newline at end of file diff --git a/Models/Chunk.cs b/Models/Chunk.cs new file mode 100644 index 0000000..756644d --- /dev/null +++ b/Models/Chunk.cs @@ -0,0 +1,11 @@ +namespace OpenQuery.Models; + +public record Chunk( + string Content, + string SourceUrl, + string? Title = null +) +{ + public float[]? Embedding { get; set; } + public float Score { get; set; } +} \ No newline at end of file diff --git a/Models/JsonContexts.cs b/Models/JsonContexts.cs new file mode 100644 index 0000000..8326e12 --- /dev/null +++ b/Models/JsonContexts.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using OpenQuery.Services; + +namespace OpenQuery.Models; + +[JsonSerializable(typeof(ChatCompletionRequest))] +[JsonSerializable(typeof(ChatCompletionResponse))] +[JsonSerializable(typeof(ChatCompletionChunk))] +[JsonSerializable(typeof(EmbeddingRequest))] +[JsonSerializable(typeof(EmbeddingResponse))] +[JsonSerializable(typeof(SearxngRoot))] +[JsonSerializable(typeof(List))] +internal partial class AppJsonContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/Models/OpenQueryOptions.cs b/Models/OpenQueryOptions.cs new file mode 100644 index 0000000..5195b03 --- /dev/null +++ b/Models/OpenQueryOptions.cs @@ -0,0 +1,10 @@ +namespace OpenQuery.Models; + +public record OpenQueryOptions( + int Chunks, + int Results, + int Queries, + bool Short, + bool Long, + string Question +); \ No newline at end of file diff --git a/Models/OpenRouter.cs b/Models/OpenRouter.cs new file mode 100644 index 0000000..89b5261 --- /dev/null +++ b/Models/OpenRouter.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenQuery.Models; + +public record ChatCompletionRequest( + [property: JsonPropertyName("model")] string Model, + [property: JsonPropertyName("messages")] List Messages, + [property: JsonPropertyName("tools")] List? Tools = null, + [property: JsonPropertyName("stream")] bool Stream = false +); + +public record Message( + [property: JsonPropertyName("role")] string Role, + [property: JsonPropertyName("content")] string? Content = null, + [property: JsonPropertyName("tool_calls")] List? ToolCalls = null, + [property: JsonPropertyName("tool_call_id")] string? ToolCallId = null +) +{ + public static Message FromTool(string content, string toolCallId) => + new Message("tool", content, null, toolCallId); +} + +public record ToolDefinition( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("function")] ToolFunction Function +); + +public record ToolFunction( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("parameters")] JsonElement Parameters +); + +public record ToolCall( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("function")] FunctionCall Function +); + +public record FunctionCall( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("arguments")] string Arguments +); + +public record ChatCompletionResponse( + [property: JsonPropertyName("choices")] List Choices, + [property: JsonPropertyName("usage")] Usage? Usage = null +); + +public record Choice( + [property: JsonPropertyName("message")] Message Message, + [property: JsonPropertyName("finish_reason")] string? FinishReason = null +); + +public record Usage( + [property: JsonPropertyName("prompt_tokens")] int PromptTokens, + [property: JsonPropertyName("completion_tokens")] int CompletionTokens, + [property: JsonPropertyName("total_tokens")] int TotalTokens +); + +public record EmbeddingRequest( + [property: JsonPropertyName("model")] string Model, + [property: JsonPropertyName("input")] List Input +); + +public record EmbeddingResponse( + [property: JsonPropertyName("data")] List Data, + [property: JsonPropertyName("usage")] Usage Usage +); + +public record EmbeddingData( + [property: JsonPropertyName("embedding")] float[] Embedding, + [property: JsonPropertyName("index")] int Index +); \ No newline at end of file diff --git a/Models/Searxng.cs b/Models/Searxng.cs new file mode 100644 index 0000000..5fc566c --- /dev/null +++ b/Models/Searxng.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace OpenQuery.Models; + +public record SearxngRoot( + [property: JsonPropertyName("results")] List Results +); + +public record SearxngResult( + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("content")] string Content +); \ No newline at end of file diff --git a/OpenQuery.cs b/OpenQuery.cs new file mode 100644 index 0000000..93404a9 --- /dev/null +++ b/OpenQuery.cs @@ -0,0 +1,153 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using OpenQuery.Models; +using OpenQuery.Services; +using OpenQuery.Tools; + +namespace OpenQuery; + +public class OpenQueryApp +{ + private readonly OpenRouterClient _client; + private readonly SearchTool _searchTool; + private readonly string _model; + private static readonly char[] Function = ['|', '/', '-', '\\']; + + public OpenQueryApp( + OpenRouterClient client, + SearchTool searchTool, + string model) + { + _client = client; + _searchTool = searchTool; + _model = model; + } + + public async Task RunAsync(OpenQueryOptions options) + { + var queries = new List { options.Question }; + + if (options.Queries > 1) + { + Console.WriteLine($"[Generating {options.Queries} search queries based on your question...]"); + + var queryGenMessages = new List + { + new Message("system", """ + You are an expert researcher. The user will ask a question. Your task is to generate optimal search queries to gather comprehensive information to answer this question. + + Instructions: + 1. Break down complex questions into diverse search queries. + 2. Use synonyms and alternative phrasing to capture different sources. + 3. Target different aspects of the question (e.g., specific entities, mechanisms, pros/cons, historical context). + + Examples: + User: "What are the environmental impacts of electric cars compared to gas cars?" + Output: ["environmental impact of electric cars", "gas vs electric car carbon footprint", "EV battery production environmental cost", "lifecycle emissions electric vs gas vehicles"] + + User: "How does the mRNA vaccine technology work?" + Output: ["how mRNA vaccines work", "mechanism of mRNA vaccination", "mRNA vaccine technology explained", "history of mRNA vaccines"] + + CRITICAL: Your output MUST strictly be a valid JSON array of strings. Do not include any markdown formatting (like ```json), explanations, preambles, or other text. Just the raw JSON array. + """), + new Message("user", $"Generate {options.Queries} distinct search queries for this question:\n{options.Question}") + }; + + try + { + var request = new ChatCompletionRequest(_model, queryGenMessages); + var response = await _client.CompleteAsync(request); + var content = response.Choices.FirstOrDefault()?.Message.Content; + + if (!string.IsNullOrEmpty(content)) + { + content = Regex.Replace(content, @"```json\s*|\s*```", "").Trim(); + + var generatedQueries = JsonSerializer.Deserialize(content, AppJsonContext.Default.ListString); + if (generatedQueries != null && generatedQueries.Count > 0) + { + queries = generatedQueries; + Console.WriteLine($"[Generated queries: {string.Join(", ", queries)}]"); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[Failed to generate queries, falling back to original question. Error: {ex.Message}]"); + } + } + + var searchResult = await _searchTool.ExecuteAsync(options.Question, queries, options.Results, options.Chunks, msg => Console.WriteLine(msg)); + Console.WriteLine(); + + var systemPrompt = "You are a helpful AI assistant. Answer the user's question in depth, based on the provided context. Be precise and accurate. You can mention sources or citations."; + if (options.Short) + systemPrompt += " Give a very short concise answer."; + if (options.Long) + systemPrompt += " Give a long elaborate detailed answer."; + + var messages = new List + { + new Message("system", systemPrompt), + new Message("user", $"Context:\n{searchResult}\n\nQuestion: {options.Question}") + }; + + var requestStream = new ChatCompletionRequest(_model, messages); + + var assistantResponse = new StringBuilder(); + var isFirstChunk = true; + + Console.Write("[Sending request to AI model...] "); + + using var cts = new CancellationTokenSource(); + var spinnerTask = Task.Run(async () => + { + var spinner = Function; + var index = 0; + while (cts is { Token.IsCancellationRequested: false }) + { + if (Console.CursorLeft > 0) + { + Console.Write(spinner[index++ % spinner.Length]); + Console.SetCursorPosition(Console.CursorLeft - 1, Console.CursorTop); + } + try + { + await Task.Delay(100, cts.Token); + } + catch (TaskCanceledException) + { + break; + } + } + }, cts.Token); + + try + { + await foreach (var chunk in _client.StreamAsync(requestStream, cts.Token)) + { + if (chunk.TextDelta == null) continue; + if (isFirstChunk) + { + await cts.CancelAsync(); + await spinnerTask; + Console.WriteLine(); + Console.Write("Assistant: "); + isFirstChunk = false; + } + Console.Write(chunk.TextDelta); + assistantResponse.Append(chunk.TextDelta); + } + } + finally + { + if (!cts.IsCancellationRequested) + { + await cts.CancelAsync(); + } + } + + Console.WriteLine(); + } +} \ No newline at end of file diff --git a/OpenQuery.csproj b/OpenQuery.csproj new file mode 100644 index 0000000..2f2406f --- /dev/null +++ b/OpenQuery.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + true + true + + + + + + + + + diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..54b93e9 --- /dev/null +++ b/Program.cs @@ -0,0 +1,188 @@ +using System.CommandLine; +using OpenQuery; +using OpenQuery.Models; +using OpenQuery.Services; +using OpenQuery.Tools; + +var config = ConfigManager.Load(); + +var chunksOption = new Option( + aliases: ["-c", "--chunks"], + getDefaultValue: () => config.DefaultChunks, + description: "Amount of top chunks to pass to the LLM overall" +); + +var resultsOption = new Option( + aliases: ["-r", "--results"], + getDefaultValue: () => config.DefaultResults, + description: "Amount of search results to choose from per query" +); + +var queriesOption = new Option( + aliases: ["-q", "--queries"], + getDefaultValue: () => config.DefaultQueries, + description: "Amount of search queries the LLM should generate before starting the searches" +); + +var shortOption = new Option( + aliases: ["-s", "--short"], + description: "Give a very short concise answer" +); + +var longOption = new Option( + aliases: ["-l", "--long"], + description: "Give a long elaborate detailed answer" +); + +var questionArgument = new Argument( + name: "question", + description: "The question to ask" +) +{ + Arity = ArgumentArity.ZeroOrMore // Changed to ZeroOrMore so 'configure' works without error +}; + +var configureCommand = new Command("configure", "Configure OpenQuery settings"); +var interactiveOption = new Option(["-i", "--interactive"], "Interactive configuration"); +var keyOption = new Option("--key", "Set API key"); +var modelOption = new Option("--model", "Set default model"); +var defQueriesOption = new Option("--queries", "Set default queries"); +var defChunksOption = new Option("--chunks", "Set default chunks"); +var defResultsOption = new Option("--results", "Set default results"); + +configureCommand.AddOption(interactiveOption); +configureCommand.AddOption(keyOption); +configureCommand.AddOption(modelOption); +configureCommand.AddOption(defQueriesOption); +configureCommand.AddOption(defChunksOption); +configureCommand.AddOption(defResultsOption); + +configureCommand.SetHandler((isInteractive, key, model, queries, chunks, results) => +{ + var cfg = ConfigManager.Load(); + if (isInteractive) + { + Console.Write($"API Key [{cfg.ApiKey}]: "); + var k = Console.ReadLine(); + if (!string.IsNullOrWhiteSpace(k)) cfg.ApiKey = k; + + Console.WriteLine("Available models:"); + Console.WriteLine("1. qwen/qwen3.5-flash-02-23"); + Console.WriteLine("2. qwen/qwen3.5-122b-a10b"); + Console.WriteLine("3. minimax/minimax-m2.5"); + Console.WriteLine("4. google/gemini-3-flash-preview"); + Console.WriteLine("5. deepseek/deepseek-v3.2"); + Console.WriteLine("6. moonshotai/kimi-k2.5"); + Console.Write($"Model [{cfg.Model}]: "); + var m = Console.ReadLine(); + if (!string.IsNullOrWhiteSpace(m)) + { + var models = new[] { + "qwen/qwen3.5-flash-02-23", + "qwen/qwen3.5-122b-a10b", + "minimax/minimax-m2.5", + "google/gemini-3-flash-preview", + "deepseek/deepseek-v3.2", + "moonshotai/kimi-k2.5" + }; + if (int.TryParse(m, out var idx) && idx >= 1 && idx <= 6) + { + cfg.Model = models[idx - 1]; + } + else + { + cfg.Model = m; + } + } + + Console.Write($"Default Queries [{cfg.DefaultQueries}]: "); + var q = Console.ReadLine(); + if (int.TryParse(q, out var qi)) cfg.DefaultQueries = qi; + + Console.Write($"Default Chunks [{cfg.DefaultChunks}]: "); + var c = Console.ReadLine(); + if (int.TryParse(c, out var ci)) cfg.DefaultChunks = ci; + + Console.Write($"Default Results [{cfg.DefaultResults}]: "); + var r = Console.ReadLine(); + if (int.TryParse(r, out var ri)) cfg.DefaultResults = ri; + } + else + { + cfg.ApiKey = key; + cfg.Model = model; + if (queries.HasValue) cfg.DefaultQueries = queries.Value; + if (chunks.HasValue) cfg.DefaultChunks = chunks.Value; + if (results.HasValue) cfg.DefaultResults = results.Value; + } + ConfigManager.Save(cfg); + Console.WriteLine("Configuration saved to " + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/.config/openquery/config"); +}, interactiveOption, keyOption, modelOption, defQueriesOption, defChunksOption, defResultsOption); + + +var rootCommand = new RootCommand("OpenQuery - AI powered search and answer") +{ + chunksOption, + resultsOption, + queriesOption, + shortOption, + longOption, + questionArgument, + configureCommand +}; + +rootCommand.SetHandler(async (chunks, results, queries, isShort, isLong, questionArgs) => +{ + var question = string.Join(" ", questionArgs); + if (string.IsNullOrWhiteSpace(question)) + { + rootCommand.Invoke("--help"); + return; + } + + var options = new OpenQueryOptions(chunks, results, queries, isShort, isLong, question); + + var apiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY"); + + if (string.IsNullOrEmpty(apiKey)) + { + apiKey = config.ApiKey; + } + + if (string.IsNullOrEmpty(apiKey)) + { + Console.Error.WriteLine("Error: API Key is missing. Set OPENROUTER_API_KEY environment variable or run 'configure -i' to set it up."); + Environment.Exit(1); + } + + var model = Environment.GetEnvironmentVariable("OPENROUTER_MODEL"); + if (string.IsNullOrEmpty(model)) + { + model = config.Model; + } + + var searxngUrl = Environment.GetEnvironmentVariable("SEARXNG_URL") ?? "http://localhost:8002"; + + var client = new OpenRouterClient(apiKey); + var searxngClient = new SearxngClient(searxngUrl); + var embeddingService = new EmbeddingService(client); + var searchTool = new SearchTool(searxngClient, embeddingService); + + try + { + var openQuery = new OpenQueryApp(client, searchTool, model); + await openQuery.RunAsync(options); + } + catch (HttpRequestException ex) + { + Console.Error.WriteLine($"\n[Error] Network request failed. Details: {ex.Message}"); + Environment.Exit(1); + } + catch (Exception ex) + { + Console.Error.WriteLine($"\n[Error] An unexpected error occurred: {ex.Message}"); + Environment.Exit(1); + } +}, chunksOption, resultsOption, queriesOption, shortOption, longOption, questionArgument); + +return await rootCommand.InvokeAsync(args); \ No newline at end of file diff --git a/Services/ArticleService.cs b/Services/ArticleService.cs new file mode 100644 index 0000000..5713b5c --- /dev/null +++ b/Services/ArticleService.cs @@ -0,0 +1,12 @@ +using SmartReader; + +namespace OpenQuery.Services; + +public class ArticleService +{ + public static async Task
FetchArticleAsync(string url) + { + var article = await Reader.ParseArticleAsync(url); + return article; + } +} \ No newline at end of file diff --git a/Services/ChunkingService.cs b/Services/ChunkingService.cs new file mode 100644 index 0000000..18b8040 --- /dev/null +++ b/Services/ChunkingService.cs @@ -0,0 +1,32 @@ +namespace OpenQuery.Services; + +public static class ChunkingService +{ + private const int MAX_CHUNK_SIZE = 500; + + public static List ChunkText(string text) + { + var chunks = new List(); + var start = 0; + + while (start < text.Length) + { + var length = Math.Min(MAX_CHUNK_SIZE, text.Length - start); + + if (start + length < text.Length) + { + var lastSpace = text.LastIndexOfAny([' ', '\n', '\r', '.', '!'], start + length, length); + if (lastSpace > start) + length = lastSpace - start + 1; + } + + var chunk = text.Substring(start, length).Trim(); + if (!string.IsNullOrEmpty(chunk)) + chunks.Add(chunk); + + start += length; + } + + return chunks; + } +} \ No newline at end of file diff --git a/Services/EmbeddingService.cs b/Services/EmbeddingService.cs new file mode 100644 index 0000000..e88b1d9 --- /dev/null +++ b/Services/EmbeddingService.cs @@ -0,0 +1,38 @@ +using System.Numerics.Tensors; + +namespace OpenQuery.Services; + +public class EmbeddingService +{ + private readonly OpenRouterClient _client; + private readonly string _embeddingModel; + + public EmbeddingService(OpenRouterClient client, string embeddingModel = "openai/text-embedding-3-small") + { + _client = client; + _embeddingModel = embeddingModel; + } + + public async Task GetEmbeddingsAsync(List texts) + { + var results = new List(); + const int batchSize = 300; + + for (var i = 0; i < texts.Count; i += batchSize) + { + if (texts.Count > batchSize) + Console.WriteLine( + $"[Generating {Math.Ceiling(i / (double)batchSize)}/{Math.Ceiling(texts.Count / (double)batchSize)} batch of embeddings]"); + var batch = texts.Skip(i).Take(batchSize).ToList(); + var batchResults = await _client.EmbedAsync(_embeddingModel, batch); + results.AddRange(batchResults); + } + + return results.ToArray(); + } + + public static float CosineSimilarity(float[] vector1, float[] vector2) + { + return TensorPrimitives.CosineSimilarity(vector1, vector2); + } +} \ No newline at end of file diff --git a/Services/OpenRouterClient.cs b/Services/OpenRouterClient.cs new file mode 100644 index 0000000..65cd301 --- /dev/null +++ b/Services/OpenRouterClient.cs @@ -0,0 +1,123 @@ +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenQuery.Models; + +namespace OpenQuery.Services; + +public class OpenRouterClient +{ + private readonly HttpClient _httpClient; + private readonly string _apiKey; + private readonly string _baseUrl = "https://openrouter.ai/api/v1"; + + public OpenRouterClient(string apiKey) + { + _apiKey = apiKey; + _httpClient = new HttpClient(); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + public async IAsyncEnumerable StreamAsync(ChatCompletionRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + request = request with { Stream = true }; + + var json = JsonSerializer.Serialize(request, AppJsonContext.Default.ChatCompletionRequest); + var content = new StringContent(json, Encoding.UTF8, new MediaTypeHeaderValue("application/json")); + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/chat/completions") + { + Content = content + }; + + using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var reader = new StreamReader(stream); + + while (await reader.ReadLineAsync(cancellationToken) is { } line) + { + if (string.IsNullOrEmpty(line) || line.StartsWith($":")) + continue; + + if (!line.StartsWith("data: ")) continue; + var data = line[6..]; + if (data == "[DONE]") + yield break; + + var chunk = JsonSerializer.Deserialize(data, AppJsonContext.Default.ChatCompletionChunk); + if (!(chunk?.Choices?.Count > 0)) continue; + var delta = chunk.Choices[0].Delta; + if (!string.IsNullOrEmpty(delta?.Content)) + yield return new StreamChunk(delta.Content); + + if (delta?.ToolCalls is not { Count: > 0 }) continue; + var toolCall = delta.ToolCalls[0]; + yield return new StreamChunk(null, new ClientToolCall( + toolCall.Id, + toolCall.Function.Name, + toolCall.Function.Arguments + )); + } + } + + public async Task CompleteAsync(ChatCompletionRequest request) + { + request = request with { Stream = false }; + + var json = JsonSerializer.Serialize(request, AppJsonContext.Default.ChatCompletionRequest); + var content = new StringContent(json, Encoding.UTF8, new MediaTypeHeaderValue("application/json")); + + var response = await _httpClient.PostAsync($"{_baseUrl}/chat/completions", content); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseJson, AppJsonContext.Default.ChatCompletionResponse)!; + } + + public async Task EmbedAsync(string model, List inputs) + { + var request = new EmbeddingRequest(model, inputs); + var json = JsonSerializer.Serialize(request, AppJsonContext.Default.EmbeddingRequest); + var content = new StringContent(json, Encoding.UTF8, new MediaTypeHeaderValue("application/json")); + + var response = await _httpClient.PostAsync($"{_baseUrl}/embeddings", content); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(); + var embeddingResponse = JsonSerializer.Deserialize(responseJson, AppJsonContext.Default.EmbeddingResponse)!; + + return embeddingResponse.Data + .OrderBy(d => d.Index) + .Select(d => d.Embedding) + .ToArray(); + } +} + +public record StreamChunk( + string? TextDelta = null, + ClientToolCall? Tool = null +); + +public record ClientToolCall( + string ToolId, + string ToolName, + string Arguments +); + +public record ChatCompletionChunk( + [property: JsonPropertyName("choices")] List Choices +); + +public record ChunkChoice( + [property: JsonPropertyName("delta")] ChunkDelta Delta +); + +public record ChunkDelta( + [property: JsonPropertyName("content")] string? Content = null, + [property: JsonPropertyName("tool_calls")] List? ToolCalls = null +); \ No newline at end of file diff --git a/Services/SearxngClient.cs b/Services/SearxngClient.cs new file mode 100644 index 0000000..2ea8e1d --- /dev/null +++ b/Services/SearxngClient.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using OpenQuery.Models; + +namespace OpenQuery.Services; + +public class SearxngClient +{ + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + + public SearxngClient(string baseUrl) + { + _baseUrl = baseUrl.TrimEnd('/'); + _httpClient = new HttpClient(); + } + + public async Task> SearchAsync(string query, int limit = 10) + { + var encodedQuery = Uri.EscapeDataString(query); + var url = $"{_baseUrl}/search?q={encodedQuery}&format=json"; + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var results = JsonSerializer.Deserialize(json, AppJsonContext.Default.SearxngRoot); + + return results?.Results?.Take(limit).ToList() ?? []; + } +} \ No newline at end of file diff --git a/Tools/SearchTool.cs b/Tools/SearchTool.cs new file mode 100644 index 0000000..6d81803 --- /dev/null +++ b/Tools/SearchTool.cs @@ -0,0 +1,89 @@ +using OpenQuery.Models; +using OpenQuery.Services; + +namespace OpenQuery.Tools; + +public class SearchTool +{ + private readonly SearxngClient _searxngClient; + private readonly EmbeddingService _embeddingService; + + public static string Name => "search"; + public static string Description => "Search the web for information on a topic"; + + public SearchTool( + SearxngClient searxngClient, + EmbeddingService embeddingService) + { + _searxngClient = searxngClient; + _embeddingService = embeddingService; + } + + public async Task ExecuteAsync(string originalQuery, List generatedQueries, int maxResults, int topChunksLimit, Action? onProgress = null) + { + var allResults = new List(); + + foreach (var query in generatedQueries) + { + onProgress?.Invoke($"[Searching web for '{query}'...]"); + var results = await _searxngClient.SearchAsync(query, maxResults); + allResults.AddRange(results); + } + + var uniqueResults = allResults.DistinctBy(r => r.Url).ToList(); + + if (uniqueResults.Count == 0) + return "No search results found."; + + onProgress?.Invoke($"[Found {uniqueResults.Count} unique results across all queries. Fetching and reading articles...]"); + var chunks = new List(); + + foreach (var result in uniqueResults) + { + try + { + var article = await ArticleService.FetchArticleAsync(result.Url); + if (!article.IsReadable || string.IsNullOrEmpty(article.TextContent)) continue; + var textChunks = ChunkingService.ChunkText(article.TextContent); + + chunks.AddRange(textChunks.Select(chunkText => new Chunk(chunkText, result.Url, article.Title))); + } + catch + { + // ignored + } + } + + if (chunks.Count == 0) + return "Found search results but could not extract readable content."; + + onProgress?.Invoke($"[Extracted {chunks.Count} text chunks. Generating embeddings for semantic search...]"); + var chunkTexts = chunks.Select(c => c.Content).ToList(); + var embeddings = await _embeddingService.GetEmbeddingsAsync(chunkTexts); + + for (var i = 0; i < chunks.Count; i++) + { + chunks[i] = chunks[i] with { Embedding = embeddings[i] }; + } + + var queryEmbedding = (await _embeddingService.GetEmbeddingsAsync([originalQuery]))[0]; + + foreach (var chunk in chunks) + { + chunk.Score = EmbeddingService.CosineSimilarity(queryEmbedding, chunk.Embedding!); + } + + var topChunks = chunks.OrderByDescending(c => c.Score).Take(topChunksLimit).ToList(); + + onProgress?.Invoke($"[Found top {topChunks.Count} most relevant chunks overall. Generating answer...]"); + var context = string.Join("\n\n", topChunks.Select((c, i) => + $"[Source {i + 1}: {c.Title ?? "Unknown"}]({c.SourceUrl})\n{c.Content}")); + + return context; + } + + public static string Execute(string argumentsJson) + { + throw new InvalidOperationException("Use ExecuteAsync instead"); + } +} \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..7bf045d --- /dev/null +++ b/install.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Exit on error +set -e + +echo "Building OpenQuery with Native AOT..." +dotnet publish -c Release + +BINARY_PATH="bin/Release/net10.0/linux-x64/publish/OpenQuery" + +if [ ! -f "$BINARY_PATH" ]; then + echo "Error: Published binary not found at $BINARY_PATH" + echo "Please ensure the project builds successfully." + exit 1 +fi + +echo "Installing OpenQuery to /usr/bin/openquery..." +sudo cp "$BINARY_PATH" /usr/bin/openquery +sudo chmod +x /usr/bin/openquery + +echo "OpenQuery installed successfully! You can now run it using the 'openquery' command." diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..5eda2cd --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Exit on error +set -e + +INSTALL_PATH="/usr/bin/openquery" + +if [ ! -f "$INSTALL_PATH" ]; then + echo "OpenQuery is not installed at $INSTALL_PATH" + exit 0 +fi + +echo "Removing OpenQuery from $INSTALL_PATH..." +sudo rm "$INSTALL_PATH" + +echo "OpenQuery uninstalled successfully."