Compare commits

..
7 Commits
19 changed files with 698 additions and 234 deletions

No files matched your search

+41
View File
@@ -97,4 +97,45 @@ public sealed record ChatThread
logger.LogInformation(logMessage);
return systemPromptText;
}
/// <summary>
/// Removes a content block from this chat thread.
/// </summary>
/// <param name="content">The content block to remove.</param>
/// <param name="removeForRegenerate">Indicates whether the content block is removed for
/// regeneration purposes. True, when the content block is removed for regeneration purposes,
/// which will not remove the previous user block if it is hidden from the user.</param>
public void Remove(IContent content, bool removeForRegenerate = false)
{
var block = this.Blocks.FirstOrDefault(x => x.Content == content);
if(block is null)
return;
//
// Remove the previous user block if it is hidden from the user. Otherwise,
// the experience might be confusing for the user.
//
// Explanation, using the ERI assistant as an example:
// - The ERI assistant generates for every file a hidden user prompt.
// - In the UI, the user can only see the AI's responses, not the hidden user prompts.
// - Now, the user removes one AI response
// - The hidden user prompt is still there, but the user can't see it.
// - Since the user prompt is hidden, neither is it possible to remove nor edit it.
// - This method solves this issue by removing the hidden user prompt when the AI response is removed.
//
if (block.Role is ChatRole.AI && !removeForRegenerate)
{
var sortedBlocks = this.Blocks.OrderBy(x => x.Time).ToList();
var index = sortedBlocks.IndexOf(block);
if (index > 0)
{
var previousBlock = sortedBlocks[index - 1];
if (previousBlock.Role is ChatRole.USER && previousBlock.HideFromUser)
this.Blocks.Remove(previousBlock);
}
}
// Remove the block from the chat thread:
this.Blocks.Remove(block);
}
}
@@ -12,7 +12,33 @@
<MudText Typo="Typo.body1">@this.Role.ToName() (@this.Time)</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy" Color="Color.Default" OnClick="@this.CopyToClipboard" />
@if (this.IsSecondToLastBlock && this.Role is ChatRole.USER && this.EditLastUserBlockFunc is not null)
{
<MudTooltip Text="Edit" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Default" OnClick="@this.EditLastUserBlock"/>
</MudTooltip>
}
@if (this.IsLastContentBlock && this.Role is ChatRole.USER && this.EditLastBlockFunc is not null)
{
<MudTooltip Text="Edit" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Default" OnClick="@this.EditLastBlock"/>
</MudTooltip>
}
@if (this.IsLastContentBlock && this.Role is ChatRole.AI && this.RegenerateFunc is not null)
{
<MudTooltip Text="Regenerate" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Recycling" Color="Color.Default" OnClick="@this.RegenerateBlock"/>
</MudTooltip>
}
@if (this.RemoveBlockFunc is not null)
{
<MudTooltip Text="Removes this block" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@this.RemoveBlock"/>
</MudTooltip>
}
<MudTooltip Text="Copies the content to the clipboard" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy" Color="Color.Default" OnClick="@this.CopyToClipboard"/>
</MudTooltip>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent>
@@ -41,6 +41,27 @@ public partial class ContentBlockComponent : ComponentBase
[Parameter]
public string Class { get; set; } = string.Empty;
[Parameter]
public bool IsLastContentBlock { get; set; } = false;
[Parameter]
public bool IsSecondToLastBlock { get; set; } = false;
[Parameter]
public Func<IContent, Task>? RemoveBlockFunc { get; set; }
[Parameter]
public Func<IContent, Task>? RegenerateFunc { get; set; }
[Parameter]
public Func<IContent, Task>? EditLastBlockFunc { get; set; }
[Parameter]
public Func<IContent, Task>? EditLastUserBlockFunc { get; set; }
[Parameter]
public Func<bool> RegenerateEnabled { get; set; } = () => false;
[Inject]
private RustService RustService { get; init; } = null!;
@@ -49,6 +70,9 @@ public partial class ContentBlockComponent : ComponentBase
[Inject]
private SettingsManager SettingsManager { get; init; } = null!;
[Inject]
private IDialogService DialogService { get; init; } = null!;
private bool HideContent { get; set; }
@@ -122,4 +146,66 @@ public partial class ContentBlockComponent : ComponentBase
private string CardClasses => $"my-2 rounded-lg {this.Class}";
private CodeBlockTheme CodeColorPalette => this.SettingsManager.IsDarkMode ? CodeBlockTheme.Dark : CodeBlockTheme.Default;
private async Task RemoveBlock()
{
if (this.RemoveBlockFunc is null)
return;
var remove = await this.DialogService.ShowMessageBox(
"Remove Message",
"Do you really want to remove this message?",
"Yes, remove it",
"No, keep it");
if (remove.HasValue && remove.Value)
await this.RemoveBlockFunc(this.Content);
}
private async Task RegenerateBlock()
{
if (this.RegenerateFunc is null)
return;
if(this.Role is not ChatRole.AI)
return;
var regenerate = await this.DialogService.ShowMessageBox(
"Regenerate Message",
"Do you really want to regenerate this message?",
"Yes, regenerate it",
"No, keep it");
if (regenerate.HasValue && regenerate.Value)
await this.RegenerateFunc(this.Content);
}
private async Task EditLastBlock()
{
if (this.EditLastBlockFunc is null)
return;
if(this.Role is not ChatRole.USER)
return;
await this.EditLastBlockFunc(this.Content);
}
private async Task EditLastUserBlock()
{
if (this.EditLastUserBlockFunc is null)
return;
if(this.Role is not ChatRole.USER)
return;
var edit = await this.DialogService.ShowMessageBox(
"Edit Message",
"Do you really want to edit this message? In order to edit this message, the AI response will be deleted.",
"Yes, remove the AI response and edit it",
"No, keep it");
if (edit.HasValue && edit.Value)
await this.EditLastUserBlockFunc(this.Content);
}
}
@@ -13,6 +13,7 @@ public partial class Changelog
public static readonly Log[] LOGS =
[
new (199, "v0.9.24, build 199 (2025-01-04 11:40 UTC)", "v0.9.24.md"),
new (198, "v0.9.23, build 198 (2025-01-02 19:39 UTC)", "v0.9.23.md"),
new (197, "v0.9.22, build 197 (2024-12-04 10:58 UTC)", "v0.9.22.md"),
new (196, "v0.9.21, build 196 (2024-11-23 12:22 UTC)", "v0.9.21.md"),
@@ -7,11 +7,26 @@
<ChildContent>
@if (this.ChatThread is not null)
{
@foreach (var block in this.ChatThread.Blocks.OrderBy(n => n.Time))
var blocks = this.ChatThread.Blocks.OrderBy(n => n.Time).ToList();
for (var i = 0; i < blocks.Count; i++)
{
var block = blocks[i];
var isLastBlock = i == blocks.Count - 1;
var isSecondLastBlock = i == blocks.Count - 2;
@if (!block.HideFromUser)
{
<ContentBlockComponent Role="@block.Role" Type="@block.ContentType" Time="@block.Time" Content="@block.Content"/>
<ContentBlockComponent
Role="@block.Role"
Type="@block.ContentType"
Time="@block.Time"
Content="@block.Content"
RemoveBlockFunc="@this.RemoveBlock"
IsLastContentBlock="@isLastBlock"
IsSecondToLastBlock="@isSecondLastBlock"
RegenerateFunc="@this.RegenerateBlock"
RegenerateEnabled="@(() => this.IsProviderSelected)"
EditLastBlockFunc="@this.EditLastBlock"
EditLastUserBlockFunc="@this.EditLastUserBlock"/>
}
}
}
@@ -85,6 +100,13 @@
<ConfidenceInfo Mode="ConfidenceInfoMode.ICON" LLMProvider="@this.Provider.UsedLLMProvider"/>
}
@if (this.isStreaming && this.cancellationTokenSource is not null)
{
<MudTooltip Text="Stop generation" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="() => this.CancelStreaming()"/>
</MudTooltip>
}
<ProfileSelection CurrentProfile="@this.currentProfile" CurrentProfileChanged="@this.ProfileWasChanged"/>
</MudToolBar>
</FooterContent>
@@ -56,6 +56,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private bool autoSaveEnabled;
private string currentWorkspaceName = string.Empty;
private Guid currentWorkspaceId = Guid.Empty;
private CancellationTokenSource? cancellationTokenSource;
// Unfortunately, we need the input field reference to blur the focus away. Without
// this, we cannot clear the input field.
@@ -242,7 +243,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
}
}
private async Task SendMessage()
private async Task SendMessage(bool reuseLastUserPrompt = false)
{
if (!this.IsProviderSelected)
return;
@@ -252,8 +253,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
await this.inputField.BlurAsync();
// Create a new chat thread if necessary:
var threadName = this.ExtractThreadName(this.userInput);
if (this.ChatThread is null)
{
this.ChatThread = new()
@@ -263,7 +262,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
Name = threadName,
Name = this.ExtractThreadName(this.userInput),
Seed = this.RNG.Next(),
Blocks = [],
};
@@ -274,34 +273,37 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
{
// Set the thread name if it is empty:
if (string.IsNullOrWhiteSpace(this.ChatThread.Name))
this.ChatThread.Name = threadName;
this.ChatThread.Name = this.ExtractThreadName(this.userInput);
// Update provider and profile:
this.ChatThread.SelectedProvider = this.Provider.Id;
this.ChatThread.SelectedProfile = this.currentProfile.Id;
}
//
// Add the user message to the thread:
//
var time = DateTimeOffset.Now;
this.ChatThread?.Blocks.Add(new ContentBlock
{
Time = time,
ContentType = ContentType.TEXT,
Role = ChatRole.USER,
Content = new ContentText
{
Text = this.userInput,
},
});
// Save the chat:
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
var time = DateTimeOffset.Now;
if (!reuseLastUserPrompt)
{
await this.SaveThread();
this.hasUnsavedChanges = false;
this.StateHasChanged();
//
// Add the user message to the thread:
//
this.ChatThread?.Blocks.Add(new ContentBlock
{
Time = time,
ContentType = ContentType.TEXT,
Role = ChatRole.USER,
Content = new ContentText
{
Text = this.userInput,
},
});
// Save the chat:
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
{
await this.SaveThread();
this.hasUnsavedChanges = false;
this.StateHasChanged();
}
}
//
@@ -335,15 +337,20 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.scrollRenderCountdown = 2;
}
this.StateHasChanged();
this.Logger.LogDebug($"Start processing user input using provider '{this.Provider.InstanceName}' with model '{this.Provider.Model}'.");
// Use the selected provider to get the AI response.
// By awaiting this line, we wait for the entire
// content to be streamed.
await aiText.CreateFromProviderAsync(this.Provider.CreateProvider(this.Logger), this.SettingsManager, this.Provider.Model, this.ChatThread);
using (this.cancellationTokenSource = new())
{
this.StateHasChanged();
// Use the selected provider to get the AI response.
// By awaiting this line, we wait for the entire
// content to be streamed.
await aiText.CreateFromProviderAsync(this.Provider.CreateProvider(this.Logger), this.SettingsManager, this.Provider.Model, this.ChatThread, this.cancellationTokenSource.Token);
}
this.cancellationTokenSource = null;
// Save the chat:
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
{
@@ -356,6 +363,13 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.StateHasChanged();
}
private async Task CancelStreaming()
{
if (this.cancellationTokenSource is not null)
if(!this.cancellationTokenSource.IsCancellationRequested)
await this.cancellationTokenSource.CancelAsync();
}
private async Task SaveThread()
{
if(this.ChatThread is null)
@@ -571,6 +585,67 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
await MessageBus.INSTANCE.SendMessage<bool>(this, Event.WORKSPACE_TOGGLE_OVERLAY);
}
private async Task RemoveBlock(IContent block)
{
if(this.ChatThread is null)
return;
this.ChatThread.Remove(block);
this.hasUnsavedChanges = true;
await this.SaveThread();
this.StateHasChanged();
}
private async Task RegenerateBlock(IContent aiBlock)
{
if(this.ChatThread is null)
return;
this.ChatThread.Remove(aiBlock, removeForRegenerate: true);
this.hasUnsavedChanges = true;
this.StateHasChanged();
await this.SendMessage(reuseLastUserPrompt: true);
}
private Task EditLastUserBlock(IContent block)
{
if(this.ChatThread is null)
return Task.CompletedTask;
if (block is not ContentText textBlock)
return Task.CompletedTask;
var lastBlock = this.ChatThread.Blocks.Last();
var lastBlockContent = lastBlock.Content;
if(lastBlockContent is null)
return Task.CompletedTask;
this.userInput = textBlock.Text;
this.ChatThread.Remove(block);
this.ChatThread.Remove(lastBlockContent);
this.hasUnsavedChanges = true;
this.StateHasChanged();
return Task.CompletedTask;
}
private Task EditLastBlock(IContent block)
{
if(this.ChatThread is null)
return Task.CompletedTask;
if (block is not ContentText textBlock)
return Task.CompletedTask;
this.userInput = textBlock.Text;
this.ChatThread.Remove(block);
this.hasUnsavedChanges = true;
this.StateHasChanged();
return Task.CompletedTask;
}
#region Overrides of MSGComponentBase
public override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
@@ -617,6 +692,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
await this.SaveThread();
this.hasUnsavedChanges = false;
}
if (this.cancellationTokenSource is not null)
{
if(!this.cancellationTokenSource.IsCancellationRequested)
await this.cancellationTokenSource.CancelAsync();
this.cancellationTokenSource.Dispose();
}
}
#endregion
@@ -60,45 +60,76 @@ public sealed class ProviderAnthropic(ILogger logger) : BaseProvider("https://ap
Stream = true,
}, JSON_SERIALIZER_OPTIONS);
async Task<HttpRequestMessage> RequestBuilder()
StreamReader? streamReader = null;
try
{
// Build the HTTP post request:
var request = new HttpRequestMessage(HttpMethod.Post, "messages");
async Task<HttpRequestMessage> RequestBuilder()
{
// Build the HTTP post request:
var request = new HttpRequestMessage(HttpMethod.Post, "messages");
// Set the authorization header:
request.Headers.Add("x-api-key", await requestedSecret.Secret.Decrypt(ENCRYPTION));
// Set the authorization header:
request.Headers.Add("x-api-key", await requestedSecret.Secret.Decrypt(ENCRYPTION));
// Set the Anthropic version:
request.Headers.Add("anthropic-version", "2023-06-01");
// Set the Anthropic version:
request.Headers.Add("anthropic-version", "2023-06-01");
// Set the content:
request.Content = new StringContent(chatRequest, Encoding.UTF8, "application/json");
return request;
// Set the content:
request.Content = new StringContent(chatRequest, Encoding.UTF8, "application/json");
return request;
}
// Send the request using exponential backoff:
var responseData = await this.SendRequest(RequestBuilder, token);
if(responseData.IsFailedAfterAllRetries)
{
this.logger.LogError($"Anthropic chat completion failed: {responseData.ErrorMessage}");
yield break;
}
// Open the response stream:
var stream = await responseData.Response!.Content.ReadAsStreamAsync(token);
// Add a stream reader to read the stream, line by line:
streamReader = new StreamReader(stream);
}
// Send the request using exponential backoff:
using var responseData = await this.SendRequest(RequestBuilder, token);
if(responseData.IsFailedAfterAllRetries)
catch (Exception e)
{
this.logger.LogError($"Anthropic chat completion failed: {responseData.ErrorMessage}");
this.logger.LogError($"Failed to stream chat completion from Anthropic '{this.InstanceName}': {e.Message}");
}
if (streamReader is null)
yield break;
}
// Open the response stream:
var stream = await responseData.Response!.Content.ReadAsStreamAsync(token);
// Add a stream reader to read the stream, line by line:
var streamReader = new StreamReader(stream);
// Read the stream, line by line:
while(!streamReader.EndOfStream)
while(true)
{
try
{
if(streamReader.EndOfStream)
break;
}
catch (Exception e)
{
this.logger.LogWarning($"Failed to read the end-of-stream state from Anthropic '{this.InstanceName}': {e.Message}");
break;
}
// Check if the token is canceled:
if(token.IsCancellationRequested)
yield break;
// Read the next line:
var line = await streamReader.ReadLineAsync(token);
string? line;
try
{
line = await streamReader.ReadLineAsync(token);
}
catch (Exception e)
{
this.logger.LogError($"Failed to read the stream from Anthropic '{this.InstanceName}': {e.Message}");
break;
}
// Skip empty lines:
if(string.IsNullOrWhiteSpace(line))
@@ -69,42 +69,73 @@ public class ProviderFireworks(ILogger logger) : BaseProvider("https://api.firew
Stream = true,
}, JSON_SERIALIZER_OPTIONS);
async Task<HttpRequestMessage> RequestBuilder()
StreamReader? streamReader = null;
try
{
// Build the HTTP post request:
var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");
async Task<HttpRequestMessage> RequestBuilder()
{
// Build the HTTP post request:
var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");
// Set the authorization header:
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION));
// Set the authorization header:
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION));
// Set the content:
request.Content = new StringContent(fireworksChatRequest, Encoding.UTF8, "application/json");
return request;
// Set the content:
request.Content = new StringContent(fireworksChatRequest, Encoding.UTF8, "application/json");
return request;
}
// Send the request using exponential backoff:
var responseData = await this.SendRequest(RequestBuilder, token);
if(responseData.IsFailedAfterAllRetries)
{
this.logger.LogError($"Fireworks chat completion failed: {responseData.ErrorMessage}");
yield break;
}
// Open the response stream:
var fireworksStream = await responseData.Response!.Content.ReadAsStreamAsync(token);
// Add a stream reader to read the stream, line by line:
streamReader = new StreamReader(fireworksStream);
}
// Send the request using exponential backoff:
using var responseData = await this.SendRequest(RequestBuilder, token);
if(responseData.IsFailedAfterAllRetries)
catch (Exception e)
{
this.logger.LogError($"Fireworks chat completion failed: {responseData.ErrorMessage}");
this.logger.LogError($"Failed to stream chat completion from Fireworks '{this.InstanceName}': {e.Message}");
}
if (streamReader is null)
yield break;
}
// Open the response stream:
var fireworksStream = await responseData.Response!.Content.ReadAsStreamAsync(token);
// Add a stream reader to read the stream, line by line:
var streamReader = new StreamReader(fireworksStream);
// Read the stream, line by line:
while(!streamReader.EndOfStream)
while(true)
{
try
{
if(streamReader.EndOfStream)
break;
}
catch (Exception e)
{
this.logger.LogWarning($"Failed to read the end-of-stream state from Fireworks '{this.InstanceName}': {e.Message}");
break;
}
// Check if the token is canceled:
if(token.IsCancellationRequested)
yield break;
// Read the next line:
var line = await streamReader.ReadLineAsync(token);
string? line;
try
{
line = await streamReader.ReadLineAsync(token);
}
catch (Exception e)
{
this.logger.LogError($"Failed to read the stream from Fireworks '{this.InstanceName}': {e.Message}");
break;
}
// Skip empty lines:
if(string.IsNullOrWhiteSpace(line))
@@ -70,42 +70,73 @@ public class ProviderGoogle(ILogger logger) : BaseProvider("https://generativela
Stream = true,
}, JSON_SERIALIZER_OPTIONS);
async Task<HttpRequestMessage> RequestBuilder()
StreamReader? streamReader = null;
try
{
// Build the HTTP post request:
var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");
async Task<HttpRequestMessage> RequestBuilder()
{
// Build the HTTP post request:
var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");
// Set the authorization header:
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION));
// Set the authorization header:
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION));
// Set the content:
request.Content = new StringContent(geminiChatRequest, Encoding.UTF8, "application/json");
return request;
// Set the content:
request.Content = new StringContent(geminiChatRequest, Encoding.UTF8, "application/json");
return request;
}
// Send the request using exponential backoff:
var responseData = await this.SendRequest(RequestBuilder, token);
if(responseData.IsFailedAfterAllRetries)
{
this.logger.LogError($"Google chat completion failed: {responseData.ErrorMessage}");
yield break;
}
// Open the response stream:
var geminiStream = await responseData.Response!.Content.ReadAsStreamAsync(token);
// Add a stream reader to read the stream, line by line:
streamReader = new StreamReader(geminiStream);
}
// Send the request using exponential backoff:
using var responseData = await this.SendRequest(RequestBuilder, token);
if(responseData.IsFailedAfterAllRetries)
catch (Exception e)
{
this.logger.LogError($"Google chat completion failed: {responseData.ErrorMessage}");
this.logger.LogError($"Failed to stream chat completion from Google '{this.InstanceName}': {e.Message}");
}
if (streamReader is null)
yield break;
}
// Open the response stream:
var geminiStream = await responseData.Response!.Content.ReadAsStreamAsync(token);
// Add a stream reader to read the stream, line by line:
var streamReader = new StreamReader(geminiStream);
// Read the stream, line by line:
while(!streamReader.EndOfStream)
while(true)
{
try
{
if(streamReader.EndOfStream)
break;
}
catch (Exception e)
{
this.logger.LogWarning($"Failed to read the end-of-stream state from Google '{this.InstanceName}': {e.Message}");
break;
}
// Check if the token is canceled:
if(token.IsCancellationRequested)
yield break;
// Read the next line:
var line = await streamReader.ReadLineAsync(token);
string? line;
try
{
line = await streamReader.ReadLineAsync(token);
}
catch (Exception e)
{
this.logger.LogError($"Failed to read the stream from Google '{this.InstanceName}': {e.Message}");
break;
}
// Skip empty lines:
if(string.IsNullOrWhiteSpace(line))
@@ -72,42 +72,73 @@ public class ProviderGroq(ILogger logger) : BaseProvider("https://api.groq.com/o
Stream = true,
}, JSON_SERIALIZER_OPTIONS);
async Task<HttpRequestMessage> RequestBuilder()
StreamReader? streamReader = null;
try
{
// Build the HTTP post request:
var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");
async Task<HttpRequestMessage> RequestBuilder()
{
// Build the HTTP post request:
var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");
// Set the authorization header:
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION));
// Set the authorization header:
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION));
// Set the content:
request.Content = new StringContent(groqChatRequest, Encoding.UTF8, "application/json");
return request;
// Set the content:
request.Content = new StringContent(groqChatRequest, Encoding.UTF8, "application/json");
return request;
}
// Send the request using exponential backoff:
var responseData = await this.SendRequest(RequestBuilder, token);
if(responseData.IsFailedAfterAllRetries)
{
this.logger.LogError($"Groq chat completion failed: {responseData.ErrorMessage}");
yield break;
}
// Open the response stream:
var groqStream = await responseData.Response!.Content.ReadAsStreamAsync(token);
// Add a stream reader to read the stream, line by line:
streamReader = new StreamReader(groqStream);
}
// Send the request using exponential backoff:
using var responseData = await this.SendRequest(RequestBuilder, token);
if(responseData.IsFailedAfterAllRetries)
catch (Exception e)
{
this.logger.LogError($"Groq chat completion failed: {responseData.ErrorMessage}");
this.logger.LogError($"Failed to stream chat completion from Groq '{this.InstanceName}': {e.Message}");
}
if (streamReader is null)
yield break;
}
// Open the response stream:
var groqStream = await responseData.Response!.Content.ReadAsStreamAsync(token);
// Add a stream reader to read the stream, line by line:
var streamReader = new StreamReader(groqStream);
// Read the stream, line by line:
while(!streamReader.EndOfStream)
while(true)
{
try
{
if(streamReader.EndOfStream)
break;
}
catch (Exception e)
{
this.logger.LogWarning($"Failed to read the end-of-stream state from Groq '{this.InstanceName}': {e.Message}");
break;
}
// Check if the token is canceled:
if(token.IsCancellationRequested)
yield break;
// Read the next line:
var line = await streamReader.ReadLineAsync(token);
string? line;
try
{
line = await streamReader.ReadLineAsync(token);
}
catch (Exception e)
{
this.logger.LogError($"Failed to read the stream from Groq '{this.InstanceName}': {e.Message}");
break;
}
// Skip empty lines:
if(string.IsNullOrWhiteSpace(line))
@@ -71,42 +71,73 @@ public sealed class ProviderMistral(ILogger logger) : BaseProvider("https://api.
SafePrompt = false,
}, JSON_SERIALIZER_OPTIONS);
async Task<HttpRequestMessage> RequestBuilder()
StreamReader? streamReader = null;
try
{
// Build the HTTP post request:
var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");
async Task<HttpRequestMessage> RequestBuilder()
{
// Build the HTTP post request:
var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");
// Set the authorization header:
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION));
// Set the authorization header:
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION));
// Set the content:
request.Content = new StringContent(mistralChatRequest, Encoding.UTF8, "application/json");
return request;
// Set the content:
request.Content = new StringContent(mistralChatRequest, Encoding.UTF8, "application/json");
return request;
}
// Send the request using exponential backoff:
var responseData = await this.SendRequest(RequestBuilder, token);
if(responseData.IsFailedAfterAllRetries)
{
this.logger.LogError($"Mistral chat completion failed: {responseData.ErrorMessage}");
yield break;
}
// Open the response stream:
var mistralStream = await responseData.Response!.Content.ReadAsStreamAsync(token);
// Add a stream reader to read the stream, line by line:
streamReader = new StreamReader(mistralStream);
}
// Send the request using exponential backoff:
using var responseData = await this.SendRequest(RequestBuilder, token);
if(responseData.IsFailedAfterAllRetries)
catch (Exception e)
{
this.logger.LogError($"Mistral chat completion failed: {responseData.ErrorMessage}");
this.logger.LogError($"Failed to stream chat completion from Mistral '{this.InstanceName}': {e.Message}");
}
if (streamReader is null)
yield break;
}
// Open the response stream:
var mistralStream = await responseData.Response!.Content.ReadAsStreamAsync(token);
// Add a stream reader to read the stream, line by line:
var streamReader = new StreamReader(mistralStream);
// Read the stream, line by line:
while(!streamReader.EndOfStream)
while(true)
{
try
{
if(streamReader.EndOfStream)
break;
}
catch (Exception e)
{
this.logger.LogWarning($"Failed to read the end-of-stream state from Mistral '{this.InstanceName}': {e.Message}");
break;
}
// Check if the token is canceled:
if(token.IsCancellationRequested)
yield break;
// Read the next line:
var line = await streamReader.ReadLineAsync(token);
string? line;
try
{
line = await streamReader.ReadLineAsync(token);
}
catch (Exception e)
{
this.logger.LogError($"Failed to read the stream from Mistral '{this.InstanceName}': {e.Message}");
break;
}
// Skip empty lines:
if(string.IsNullOrWhiteSpace(line))
@@ -99,42 +99,73 @@ public sealed class ProviderOpenAI(ILogger logger) : BaseProvider("https://api.o
Stream = true,
}, JSON_SERIALIZER_OPTIONS);
async Task<HttpRequestMessage> RequestBuilder()
StreamReader? streamReader = null;
try
{
// Build the HTTP post request:
var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");
async Task<HttpRequestMessage> RequestBuilder()
{
// Build the HTTP post request:
var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");
// Set the authorization header:
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION));
// Set the authorization header:
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION));
// Set the content:
request.Content = new StringContent(openAIChatRequest, Encoding.UTF8, "application/json");
return request;
// Set the content:
request.Content = new StringContent(openAIChatRequest, Encoding.UTF8, "application/json");
return request;
}
// Send the request using exponential backoff:
var responseData = await this.SendRequest(RequestBuilder, token);
if(responseData.IsFailedAfterAllRetries)
{
this.logger.LogError($"OpenAI chat completion failed: {responseData.ErrorMessage}");
yield break;
}
// Open the response stream:
var openAIStream = await responseData.Response!.Content.ReadAsStreamAsync(token);
// Add a stream reader to read the stream, line by line:
streamReader = new StreamReader(openAIStream);
}
catch (Exception e)
{
this.logger.LogError($"Failed to stream chat completion from OpenAI '{this.InstanceName}': {e.Message}");
}
// Send the request using exponential backoff:
using var responseData = await this.SendRequest(RequestBuilder, token);
if(responseData.IsFailedAfterAllRetries)
{
this.logger.LogError($"OpenAI chat completion failed: {responseData.ErrorMessage}");
if (streamReader is null)
yield break;
}
// Open the response stream:
var openAIStream = await responseData.Response!.Content.ReadAsStreamAsync(token);
// Add a stream reader to read the stream, line by line:
var streamReader = new StreamReader(openAIStream);
// Read the stream, line by line:
while(!streamReader.EndOfStream)
while(true)
{
try
{
if(streamReader.EndOfStream)
break;
}
catch (Exception e)
{
this.logger.LogWarning($"Failed to read the end-of-stream state from OpenAI '{this.InstanceName}': {e.Message}");
break;
}
// Check if the token is canceled:
if(token.IsCancellationRequested)
yield break;
// Read the next line:
var line = await streamReader.ReadLineAsync(token);
string? line;
try
{
line = await streamReader.ReadLineAsync(token);
}
catch (Exception e)
{
this.logger.LogError($"Failed to read the stream from OpenAI '{this.InstanceName}': {e.Message}");
break;
}
// Skip empty lines:
if(string.IsNullOrWhiteSpace(line))
@@ -67,7 +67,7 @@ public sealed class ProviderSelfHosted(ILogger logger, Host host, string hostnam
MaxTokens = -1,
}, JSON_SERIALIZER_OPTIONS);
StreamReader? streamReader = default;
StreamReader? streamReader = null;
try
{
async Task<HttpRequestMessage> RequestBuilder()
@@ -85,13 +85,13 @@ public sealed class ProviderSelfHosted(ILogger logger, Host host, string hostnam
}
// Send the request using exponential backoff:
using var responseData = await this.SendRequest(RequestBuilder, token);
var responseData = await this.SendRequest(RequestBuilder, token);
if(responseData.IsFailedAfterAllRetries)
{
this.logger.LogError($"Self-hosted provider's chat completion failed: {responseData.ErrorMessage}");
yield break;
}
// Open the response stream:
var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(token);
@@ -103,55 +103,77 @@ public sealed class ProviderSelfHosted(ILogger logger, Host host, string hostnam
this.logger.LogError($"Failed to stream chat completion from self-hosted provider '{this.InstanceName}': {e.Message}");
}
if (streamReader is not null)
if (streamReader is null)
yield break;
// Read the stream, line by line:
while (true)
{
// Read the stream, line by line:
while (!streamReader.EndOfStream)
try
{
// Check if the token is canceled:
if (token.IsCancellationRequested)
yield break;
// Read the next line:
var line = await streamReader.ReadLineAsync(token);
// Skip empty lines:
if (string.IsNullOrWhiteSpace(line))
continue;
// Skip lines that do not start with "data: ". Regard
// to the specification, we only want to read the data lines:
if (!line.StartsWith("data: ", StringComparison.InvariantCulture))
continue;
// Check if the line is the end of the stream:
if (line.StartsWith("data: [DONE]", StringComparison.InvariantCulture))
yield break;
ResponseStreamLine providerResponse;
try
{
// We know that the line starts with "data: ". Hence, we can
// skip the first 6 characters to get the JSON data after that.
var jsonData = line[6..];
// Deserialize the JSON data:
providerResponse = JsonSerializer.Deserialize<ResponseStreamLine>(jsonData, JSON_SERIALIZER_OPTIONS);
}
catch
{
// Skip invalid JSON data:
continue;
}
// Skip empty responses:
if (providerResponse == default || providerResponse.Choices.Count == 0)
continue;
// Yield the response:
yield return providerResponse.Choices[0].Delta.Content;
if(streamReader.EndOfStream)
break;
}
catch (Exception e)
{
this.logger.LogWarning($"Failed to read the end-of-stream state from self-hosted provider '{this.InstanceName}': {e.Message}");
break;
}
// Check if the token is canceled:
if (token.IsCancellationRequested)
yield break;
// Read the next line:
string? line;
try
{
line = await streamReader.ReadLineAsync(token);
}
catch (Exception e)
{
this.logger.LogError($"Failed to read the stream from self-hosted provider '{this.InstanceName}': {e.Message}");
break;
}
// Skip empty lines:
if (string.IsNullOrWhiteSpace(line))
continue;
// Skip lines that do not start with "data: ". Regard
// to the specification, we only want to read the data lines:
if (!line.StartsWith("data: ", StringComparison.InvariantCulture))
continue;
// Check if the line is the end of the stream:
if (line.StartsWith("data: [DONE]", StringComparison.InvariantCulture))
yield break;
ResponseStreamLine providerResponse;
try
{
// We know that the line starts with "data: ". Hence, we can
// skip the first 6 characters to get the JSON data after that.
var jsonData = line[6..];
// Deserialize the JSON data:
providerResponse = JsonSerializer.Deserialize<ResponseStreamLine>(jsonData, JSON_SERIALIZER_OPTIONS);
}
catch
{
// Skip invalid JSON data:
continue;
}
// Skip empty responses:
if (providerResponse == default || providerResponse.Choices.Count == 0)
continue;
// Yield the response:
yield return providerResponse.Choices[0].Delta.Content;
}
streamReader.Dispose();
}
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
@@ -10,14 +10,4 @@ public readonly record struct HttpRateLimitedStreamResult(
bool IsSuccessful,
bool IsFailedAfterAllRetries,
string ErrorMessage,
HttpResponseMessage? Response) : IDisposable
{
#region IDisposable
public void Dispose()
{
this.Response?.Dispose();
}
#endregion
}
HttpResponseMessage? Response);
@@ -0,0 +1,7 @@
# v0.9.24, build 199 (2025-01-04 11:40 UTC)
- Added a button to remove a message from the chat thread.
- Added a button to regenerate the last AI response.
- Added a button to edit the last user message.
- Added a button to stop the AI from generating more tokens.
- Improved the stream handling for all providers. The stream handling is now very resilient and handles all kinds of network issues.
- Fixed a streaming bug that was particularly visible with self-hosted providers.
+4 -4
View File
@@ -1,9 +1,9 @@
0.9.23
2025-01-02 19:39:00 UTC
198
0.9.24
2025-01-04 11:40:00 UTC
199
8.0.111 (commit f3835a7fb4)
8.0.11 (commit 9cb3b725e3)
1.83.0 (commit 90b35a623)
7.15.0
1.8.1
dd03889d10b, release
d6521850e95, release
+1 -1
View File
@@ -2319,7 +2319,7 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mindwork-ai-studio"
version = "0.9.23"
version = "0.9.24"
dependencies = [
"aes",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mindwork-ai-studio"
version = "0.9.23"
version = "0.9.24"
edition = "2021"
description = "MindWork AI Studio"
authors = ["Thorsten Sommer"]
+1 -1
View File
@@ -6,7 +6,7 @@
},
"package": {
"productName": "MindWork AI Studio",
"version": "0.9.23"
"version": "0.9.24"
},
"tauri": {
"allowlist": {