initial release
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
bin
|
||||||
|
obj
|
||||||
|
.vs
|
||||||
|
.vscode
|
||||||
|
.anchor
|
||||||
|
.crush
|
||||||
|
.idea
|
||||||
63
ConfigManager.cs
Normal file
63
ConfigManager.cs
Normal file
@@ -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<string>
|
||||||
|
{
|
||||||
|
$"ApiKey={config.ApiKey}",
|
||||||
|
$"Model={config.Model}",
|
||||||
|
$"DefaultQueries={config.DefaultQueries}",
|
||||||
|
$"DefaultChunks={config.DefaultChunks}",
|
||||||
|
$"DefaultResults={config.DefaultResults}"
|
||||||
|
};
|
||||||
|
File.WriteAllLines(path, lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Models/Chunk.cs
Normal file
11
Models/Chunk.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
15
Models/JsonContexts.cs
Normal file
15
Models/JsonContexts.cs
Normal file
@@ -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<string>))]
|
||||||
|
internal partial class AppJsonContext : JsonSerializerContext
|
||||||
|
{
|
||||||
|
}
|
||||||
10
Models/OpenQueryOptions.cs
Normal file
10
Models/OpenQueryOptions.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace OpenQuery.Models;
|
||||||
|
|
||||||
|
public record OpenQueryOptions(
|
||||||
|
int Chunks,
|
||||||
|
int Results,
|
||||||
|
int Queries,
|
||||||
|
bool Short,
|
||||||
|
bool Long,
|
||||||
|
string Question
|
||||||
|
);
|
||||||
75
Models/OpenRouter.cs
Normal file
75
Models/OpenRouter.cs
Normal file
@@ -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<Message> Messages,
|
||||||
|
[property: JsonPropertyName("tools")] List<ToolDefinition>? 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<ToolCall>? 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<Choice> 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<string> Input
|
||||||
|
);
|
||||||
|
|
||||||
|
public record EmbeddingResponse(
|
||||||
|
[property: JsonPropertyName("data")] List<EmbeddingData> Data,
|
||||||
|
[property: JsonPropertyName("usage")] Usage Usage
|
||||||
|
);
|
||||||
|
|
||||||
|
public record EmbeddingData(
|
||||||
|
[property: JsonPropertyName("embedding")] float[] Embedding,
|
||||||
|
[property: JsonPropertyName("index")] int Index
|
||||||
|
);
|
||||||
13
Models/Searxng.cs
Normal file
13
Models/Searxng.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace OpenQuery.Models;
|
||||||
|
|
||||||
|
public record SearxngRoot(
|
||||||
|
[property: JsonPropertyName("results")] List<SearxngResult> Results
|
||||||
|
);
|
||||||
|
|
||||||
|
public record SearxngResult(
|
||||||
|
[property: JsonPropertyName("title")] string Title,
|
||||||
|
[property: JsonPropertyName("url")] string Url,
|
||||||
|
[property: JsonPropertyName("content")] string Content
|
||||||
|
);
|
||||||
153
OpenQuery.cs
Normal file
153
OpenQuery.cs
Normal file
@@ -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<string> { options.Question };
|
||||||
|
|
||||||
|
if (options.Queries > 1)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Generating {options.Queries} search queries based on your question...]");
|
||||||
|
|
||||||
|
var queryGenMessages = new List<Message>
|
||||||
|
{
|
||||||
|
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<Message>
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
OpenQuery.csproj
Normal file
18
OpenQuery.csproj
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<PublishAot>true</PublishAot>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="SmartReader" Version="0.11.0" />
|
||||||
|
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||||
|
<PackageReference Include="System.Numerics.Tensors" Version="9.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
188
Program.cs
Normal file
188
Program.cs
Normal file
@@ -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<int>(
|
||||||
|
aliases: ["-c", "--chunks"],
|
||||||
|
getDefaultValue: () => config.DefaultChunks,
|
||||||
|
description: "Amount of top chunks to pass to the LLM overall"
|
||||||
|
);
|
||||||
|
|
||||||
|
var resultsOption = new Option<int>(
|
||||||
|
aliases: ["-r", "--results"],
|
||||||
|
getDefaultValue: () => config.DefaultResults,
|
||||||
|
description: "Amount of search results to choose from per query"
|
||||||
|
);
|
||||||
|
|
||||||
|
var queriesOption = new Option<int>(
|
||||||
|
aliases: ["-q", "--queries"],
|
||||||
|
getDefaultValue: () => config.DefaultQueries,
|
||||||
|
description: "Amount of search queries the LLM should generate before starting the searches"
|
||||||
|
);
|
||||||
|
|
||||||
|
var shortOption = new Option<bool>(
|
||||||
|
aliases: ["-s", "--short"],
|
||||||
|
description: "Give a very short concise answer"
|
||||||
|
);
|
||||||
|
|
||||||
|
var longOption = new Option<bool>(
|
||||||
|
aliases: ["-l", "--long"],
|
||||||
|
description: "Give a long elaborate detailed answer"
|
||||||
|
);
|
||||||
|
|
||||||
|
var questionArgument = new Argument<string[]>(
|
||||||
|
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<bool>(["-i", "--interactive"], "Interactive configuration");
|
||||||
|
var keyOption = new Option<string>("--key", "Set API key");
|
||||||
|
var modelOption = new Option<string>("--model", "Set default model");
|
||||||
|
var defQueriesOption = new Option<int?>("--queries", "Set default queries");
|
||||||
|
var defChunksOption = new Option<int?>("--chunks", "Set default chunks");
|
||||||
|
var defResultsOption = new Option<int?>("--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);
|
||||||
12
Services/ArticleService.cs
Normal file
12
Services/ArticleService.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using SmartReader;
|
||||||
|
|
||||||
|
namespace OpenQuery.Services;
|
||||||
|
|
||||||
|
public class ArticleService
|
||||||
|
{
|
||||||
|
public static async Task<Article> FetchArticleAsync(string url)
|
||||||
|
{
|
||||||
|
var article = await Reader.ParseArticleAsync(url);
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Services/ChunkingService.cs
Normal file
32
Services/ChunkingService.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
namespace OpenQuery.Services;
|
||||||
|
|
||||||
|
public static class ChunkingService
|
||||||
|
{
|
||||||
|
private const int MAX_CHUNK_SIZE = 500;
|
||||||
|
|
||||||
|
public static List<string> ChunkText(string text)
|
||||||
|
{
|
||||||
|
var chunks = new List<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Services/EmbeddingService.cs
Normal file
38
Services/EmbeddingService.cs
Normal file
@@ -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<float[][]> GetEmbeddingsAsync(List<string> texts)
|
||||||
|
{
|
||||||
|
var results = new List<float[]>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
Services/OpenRouterClient.cs
Normal file
123
Services/OpenRouterClient.cs
Normal file
@@ -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<StreamChunk> 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<ChatCompletionChunk>(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<ChatCompletionResponse> 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<ChatCompletionResponse>(responseJson, AppJsonContext.Default.ChatCompletionResponse)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<float[][]> EmbedAsync(string model, List<string> 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<EmbeddingResponse>(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<ChunkChoice> Choices
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ChunkChoice(
|
||||||
|
[property: JsonPropertyName("delta")] ChunkDelta Delta
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ChunkDelta(
|
||||||
|
[property: JsonPropertyName("content")] string? Content = null,
|
||||||
|
[property: JsonPropertyName("tool_calls")] List<ToolCall>? ToolCalls = null
|
||||||
|
);
|
||||||
30
Services/SearxngClient.cs
Normal file
30
Services/SearxngClient.cs
Normal file
@@ -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<List<SearxngResult>> 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<SearxngRoot>(json, AppJsonContext.Default.SearxngRoot);
|
||||||
|
|
||||||
|
return results?.Results?.Take(limit).ToList() ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
89
Tools/SearchTool.cs
Normal file
89
Tools/SearchTool.cs
Normal file
@@ -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<string> ExecuteAsync(string originalQuery, List<string> generatedQueries, int maxResults, int topChunksLimit, Action<string>? onProgress = null)
|
||||||
|
{
|
||||||
|
var allResults = new List<SearxngResult>();
|
||||||
|
|
||||||
|
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<Chunk>();
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
21
install.sh
Executable file
21
install.sh
Executable file
@@ -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."
|
||||||
16
uninstall.sh
Executable file
16
uninstall.sh
Executable file
@@ -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."
|
||||||
Reference in New Issue
Block a user