feat: parallel async processing and compact output mode

Major performance improvements:
- Parallel search execution across all queries
- Parallel article fetching with 10 concurrent limit
- Parallel embeddings with rate limiting (4 concurrent)
- Polly integration for retry resilience

New features:
- Add -v/--verbose flag for detailed output
- Compact single-line status mode with braille spinner
- StatusReporter service for unified output handling
- Query generation and errors hidden in compact mode
- ANSI escape codes for clean line updates

New files:
- Services/RateLimiter.cs - Semaphore-based concurrency control
- Services/StatusReporter.cs - Verbose/compact output handler
- Models/ParallelOptions.cs - Parallel processing configuration

All changes maintain Native AOT compatibility.
This commit is contained in:
2026-03-18 22:16:28 +01:00
parent 9d4bec7a17
commit b28d8998f7
9 changed files with 579 additions and 109 deletions

View File

@@ -12,7 +12,6 @@ public class OpenQueryApp
private readonly OpenRouterClient _client;
private readonly SearchTool _searchTool;
private readonly string _model;
private static readonly char[] Function = ['|', '/', '-', '\\'];
public OpenQueryApp(
OpenRouterClient client,
@@ -26,12 +25,22 @@ public class OpenQueryApp
public async Task RunAsync(OpenQueryOptions options)
{
using var reporter = new StatusReporter(options.Verbose);
reporter.StartSpinner();
var queries = new List<string> { options.Question };
if (options.Queries > 1)
{
Console.WriteLine($"[Generating {options.Queries} search queries based on your question...]");
if (options.Verbose)
{
reporter.WriteLine($"[Generating {options.Queries} search queries based on your question...]");
}
else
{
reporter.UpdateStatus("Generating search queries...");
}
var queryGenMessages = new List<Message>
{
new Message("system", """
@@ -63,23 +72,67 @@ public class OpenQueryApp
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)}]");
if (options.Verbose)
{
reporter.WriteLine($"[Generated queries: {string.Join(", ", queries)}]");
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[Failed to generate queries, falling back to original question. Error: {ex.Message}]");
if (options.Verbose)
{
reporter.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();
reporter.UpdateStatus("Searching web...");
var searchResult = await _searchTool.ExecuteAsync(
options.Question,
queries,
options.Results,
options.Chunks,
(progress) => {
if (options.Verbose)
{
reporter.WriteLine(progress);
}
else
{
// Parse progress messages for compact mode
if (progress.StartsWith("[Fetching article") && progress.Contains("/"))
{
// Extract "X/Y" from "[Fetching article X/Y: domain]"
var match = Regex.Match(progress, @"\[(\d+)/(\d+)");
if (match.Success)
{
reporter.UpdateStatus($"Fetching articles {match.Groups[1].Value}/{match.Groups[2].Value}...");
}
}
else if (progress.Contains("embeddings"))
{
reporter.UpdateStatus("Processing embeddings...");
}
}
},
options.Verbose);
if (!options.Verbose)
{
reporter.UpdateStatus("Asking AI...");
}
else
{
reporter.ClearStatus();
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)
@@ -94,46 +147,27 @@ public class OpenQueryApp
};
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))
using var streamCts = new CancellationTokenSource();
await foreach (var chunk in _client.StreamAsync(requestStream, streamCts.Token))
{
if (chunk.TextDelta == null) continue;
if (isFirstChunk)
{
await cts.CancelAsync();
await spinnerTask;
Console.WriteLine();
Console.Write("Assistant: ");
reporter.StopSpinner();
if (!options.Verbose)
{
reporter.ClearStatus();
}
else
{
Console.Write("Assistant: ");
}
isFirstChunk = false;
}
Console.Write(chunk.TextDelta);
@@ -142,12 +176,9 @@ public class OpenQueryApp
}
finally
{
if (!cts.IsCancellationRequested)
{
await cts.CancelAsync();
}
reporter.StopSpinner();
}
Console.WriteLine();
}
}
}