add configuration profiles with per-invocation --profile flag

- Add SystemPrompt field to HushConfig (empty = built-in default)
- Refactor ConfigManager: extract ApplyTomlFields, add LoadWithProfile(),
  ListProfiles(), GetProfilePath(), EnsureProfilesDirExists(); remove
  HUSH_PROFILE env-var logic (profiles are now resolved by the CLI)
- Extend socket protocol: action commands (START/STOP/TOGGLE/ABORT) now
  carry a [4-byte LE length][optional HushConfig JSON] payload so the CLI
  can pass a per-invocation config override without restarting the daemon
- Add GENERATE_PROFILE (cmd 7) socket command: CLI sends a description,
  daemon calls the LLM and returns a generated system prompt
- Orchestrator: StopAndProcessAsync accepts optional HushConfig override;
  ProcessWithLlmAsync uses proper system/user chat roles and respects
  config.SystemPrompt; add GenerateProfilePromptAsync
- Split CompleteTextAsync signature to (systemPrompt, userMessage, model)
  across ITextStreamingProvider, GroqProvider, FireworksProvider
- Add --profile/-p flag to hush toggle and hush stop
- Add hush profiles subcommand: list, get, new (manual or AI-generated), edit
This commit is contained in:
2026-03-23 00:38:29 +01:00
parent 70e784a1cc
commit eb0619dea2
14 changed files with 659 additions and 372 deletions
@@ -15,5 +15,6 @@ public interface IAudioToTextProvider
Task<string> TranscribeAsync(
Stream audioStream,
string modelName,
string? language = null,
CancellationToken cancellationToken = default);
}
@@ -1,31 +1,21 @@
namespace Hush.Providers.Interfaces;
/// <summary>
/// Interface for text generation with both synchronous and streaming capabilities.
/// Interface for text generation.
/// </summary>
public interface ITextStreamingProvider
{
/// <summary>
/// Generates text completion for a given prompt.
/// Generates a text completion using a system prompt and a user message.
/// </summary>
/// <param name="prompt">The input prompt</param>
/// <param name="systemPrompt">The system prompt that instructs the model how to behave</param>
/// <param name="userMessage">The user message to process</param>
/// <param name="modelName">The model name to use (e.g., llama-3.3-70b-versatile)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The generated text</returns>
Task<string> CompleteTextAsync(
string prompt,
string modelName,
CancellationToken cancellationToken = default);
/// <summary>
/// Streams text generation for a given prompt.
/// </summary>
/// <param name="prompt">The input prompt</param>
/// <param name="modelName">The model name to use (e.g., llama-3.3-70b-versatile)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Async enumerable of text chunks</returns>
IAsyncEnumerable<string> StreamTextAsync(
string prompt,
string systemPrompt,
string userMessage,
string modelName,
CancellationToken cancellationToken = default);
}
@@ -34,6 +34,7 @@ public class FireworksProvider : IAudioToTextProvider, ITextStreamingProvider
public async Task<string> TranscribeAsync(
Stream audioStream,
string modelName,
string? language = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(audioStream);
@@ -45,7 +46,7 @@ public class FireworksProvider : IAudioToTextProvider, ITextStreamingProvider
? TRANSCRIPTION_ENDPOINT_TURBO
: TRANSCRIPTION_ENDPOINT_PROD;
var request = new TranscriptionRequest { Model = modelName };
var request = new TranscriptionRequest { Model = modelName, Language = language };
using var content = new MultipartFormDataContent();
content.Add(new StreamContent(audioStream), "file", "audio.wav");
@@ -84,12 +85,13 @@ public class FireworksProvider : IAudioToTextProvider, ITextStreamingProvider
/// <inheritdoc />
public async Task<string> CompleteTextAsync(
string prompt,
string systemPrompt,
string userMessage,
string modelName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(prompt))
throw new ArgumentException("Prompt is required", nameof(prompt));
if (string.IsNullOrWhiteSpace(systemPrompt))
throw new ArgumentException("System prompt is required", nameof(systemPrompt));
if (string.IsNullOrWhiteSpace(modelName))
throw new ArgumentException("Model name is required", nameof(modelName));
@@ -97,7 +99,11 @@ public class FireworksProvider : IAudioToTextProvider, ITextStreamingProvider
var request = new ChatCompletionRequest
{
Model = modelName,
Messages = new List<Message> { new() { Role = "user", Content = prompt } }
Messages = new List<Message>
{
new() { Role = "system", Content = systemPrompt },
new() { Role = "user", Content = userMessage }
}
};
var jsonContent = new StringContent(
@@ -126,86 +132,5 @@ public class FireworksProvider : IAudioToTextProvider, ITextStreamingProvider
return result.Choices[0].Message.Content;
}
/// <inheritdoc />
public async IAsyncEnumerable<string> StreamTextAsync(
string prompt,
string modelName,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(prompt))
throw new ArgumentException("Prompt is required", nameof(prompt));
if (string.IsNullOrWhiteSpace(modelName))
throw new ArgumentException("Model name is required", nameof(modelName));
var request = new ChatCompletionRequest
{
Model = modelName,
Stream = true,
Messages = new List<Message> { new() { Role = "user", Content = prompt } }
};
var jsonContent = new StringContent(
JsonSerializer.Serialize(request, JsonSourceGeneration.Default.ChatCompletionRequest),
Encoding.UTF8,
"application/json");
var httpRequest = new HttpRequestMessage(HttpMethod.Post, CHAT_COMPLETION_ENDPOINT)
{
Content = jsonContent
};
httpRequest.Headers.TryAddWithoutValidation("Authorization", _apiKey);
using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) != null)
{
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: "))
continue;
var data = line.Substring(6).Trim(); // Remove "data: " prefix
if (data == "[DONE]")
break;
var text = ParseTextFromStreamData(data);
if (!string.IsNullOrEmpty(text))
yield return text;
}
}
private static string? ParseTextFromStreamData(string data)
{
try
{
using var jsonDoc = JsonDocument.Parse(data);
var choices = jsonDoc.RootElement.GetProperty("choices");
var choice = choices[0];
if (choice.TryGetProperty("delta", out var delta))
{
if (delta.TryGetProperty("content", out var content))
{
return content.GetString();
}
}
else if (choice.TryGetProperty("text", out var text))
{
return text.GetString();
}
}
catch (JsonException)
{
// Skip malformed JSON chunks
}
return null;
}
}
+12 -89
View File
@@ -33,6 +33,7 @@ public class GroqProvider : IAudioToTextProvider, ITextStreamingProvider
public async Task<string> TranscribeAsync(
Stream audioStream,
string modelName,
string? language = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(audioStream);
@@ -40,7 +41,7 @@ public class GroqProvider : IAudioToTextProvider, ITextStreamingProvider
if (string.IsNullOrWhiteSpace(modelName))
throw new ArgumentException("Model name is required", nameof(modelName));
var request = new TranscriptionRequest { Model = modelName };
var request = new TranscriptionRequest { Model = modelName, Language = language };
using var content = new MultipartFormDataContent();
content.Add(new StreamContent(audioStream), "file", "audio.wav");
@@ -79,20 +80,24 @@ public class GroqProvider : IAudioToTextProvider, ITextStreamingProvider
/// <inheritdoc />
public async Task<string> CompleteTextAsync(
string prompt,
string systemPrompt,
string userMessage,
string modelName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(prompt))
throw new ArgumentException("Prompt is required", nameof(prompt));
if (string.IsNullOrWhiteSpace(systemPrompt))
throw new ArgumentException("System prompt is required", nameof(systemPrompt));
if (string.IsNullOrWhiteSpace(modelName))
throw new ArgumentException("Model name is required", nameof(modelName));
var request = new ChatCompletionRequest
{
Model = modelName,
Messages = new List<Models.Request.Message> { new() { Role = "user", Content = prompt } }
Model = modelName,
Messages = new List<Models.Request.Message>
{
new() { Role = "system", Content = systemPrompt },
new() { Role = "user", Content = userMessage }
}
};
var jsonContent = new StringContent(
@@ -121,86 +126,4 @@ public class GroqProvider : IAudioToTextProvider, ITextStreamingProvider
return result.Choices[0].Message.Content;
}
/// <inheritdoc />
public async IAsyncEnumerable<string> StreamTextAsync(
string prompt,
string modelName,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(prompt))
throw new ArgumentException("Prompt is required", nameof(prompt));
if (string.IsNullOrWhiteSpace(modelName))
throw new ArgumentException("Model name is required", nameof(modelName));
var request = new ChatCompletionRequest
{
Model = modelName,
Stream = true,
Messages = new List<Models.Request.Message> { new() { Role = "user", Content = prompt } }
};
var jsonContent = new StringContent(
JsonSerializer.Serialize(request, JsonSourceGeneration.Default.ChatCompletionRequest),
Encoding.UTF8,
"application/json");
var httpRequest = new HttpRequestMessage(HttpMethod.Post, CHAT_COMPLETION_ENDPOINT)
{
Content = jsonContent
};
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) != null)
{
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: "))
continue;
var data = line.Substring(6).Trim(); // Remove "data: " prefix
if (data == "[DONE]")
break;
var text = ParseTextFromStreamData(data);
if (!string.IsNullOrEmpty(text))
yield return text;
}
}
private static string? ParseTextFromStreamData(string data)
{
try
{
using var jsonDoc = JsonDocument.Parse(data);
var choices = jsonDoc.RootElement.GetProperty("choices");
var choice = choices[0];
if (choice.TryGetProperty("delta", out var delta))
{
if (delta.TryGetProperty("content", out var content))
{
return content.GetString();
}
}
else if (choice.TryGetProperty("text", out var text))
{
return text.GetString();
}
}
catch (JsonException)
{
// Skip malformed JSON chunks
}
return null;
}
}