mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-19 21:58:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88aefe1c4e | ||
|
|
12e31453e3 | ||
|
|
4f823b0fe4 | ||
|
|
59d0321625 | ||
|
|
29263660fc | ||
|
|
6cc1d37db8 | ||
|
|
b29e181ecd | ||
|
|
9b1649b48a | ||
|
|
15e8fbc6ca | ||
|
|
d6e80a4563 |
No files matched your search
@@ -5,14 +5,14 @@ MindWork AI Studio is a desktop application available for macOS, Windows, and Li
|
||||
|
||||
**Key advantages:**
|
||||
- **Free of charge**: The app is free to use, both for personal and commercial purposes.
|
||||
- **Independence**: Users are not tied to any single provider. The initial version supports OpenAI models (like GPT-4o, GPT-4, GPT-4 Turbo, etc.). Future versions will support other providers such as Mistral or Google Gemini.
|
||||
- **Independence**: Users are not tied to any single provider. Instead, they can choose the provider that best suits their needs. Right now, we support OpenAI (GPT4o etc.), Mistral, Anthropic (Claude), and self-hosted models using [llama.cpp](https://github.com/ggerganov/llama.cpp) or [LM Studio](https://lmstudio.ai/). Support for Google Gemini, [ollama](https://github.com/ollama/ollama), [Replicate](https://replicate.com/), and [Fireworks](https://fireworks.ai/) is planned.
|
||||
- **Unrestricted usage**: Unlike services like ChatGPT, which impose limits after intensive use, MindWork AI Studio offers unlimited usage through the providers API.
|
||||
- **Cost-effective**: You only pay for what you use, which can be cheaper than monthly subscription services like ChatGPT Plus, especially if used infrequently. But beware, here be dragons: For extremely intensive usage, the API costs can be significantly higher. Unfortunately, providers currently do not offer a way to display current costs in the app. Therefore, check your account with the respective provider to see how your costs are developing. When available, use prepaid and set a cost limit.
|
||||
- **Privacy**: The data entered into the app is not used for training by the providers since we are using the provider's API.
|
||||
- **Flexibility**: Choose the provider and model best suited for your current task.
|
||||
- **No bloatware**: The app requires minimal storage for installation and operates with low memory usage. Additionally, it has a minimal impact on system resources, which is beneficial for battery life.
|
||||
|
||||
**Ready to get started 🤩?** [Download the appropriate setup for your operating system here](documentation/Setup.md).
|
||||
## **Ready to get started 🤩?** [Download the appropriate setup for your operating system here](documentation/Setup.md).
|
||||
|
||||
## Support the Project
|
||||
Thank you for using MindWork AI Studio and considering supporting its development 😀. Your support helps keep the project alive and ensures continuous improvements and new features.
|
||||
@@ -30,9 +30,7 @@ Your support, whether big or small, keeps the wheels turning and is deeply appre
|
||||
|
||||
## Planned Features
|
||||
Here's an exciting look at some of the features we're planning to add to MindWork AI Studio in future releases:
|
||||
- **More providers**: We plan to add support for additional LLM providers, such as Mistral and Google Gemini, giving you more options to choose from.
|
||||
- **Persistent chats**: Your chats will be stored locally, allowing you to continue conversations at any time without starting from scratch.
|
||||
- **Local LLMs**: We aim to support local LLMs, enabling you to use options like LM Studio, `ollama`, or `llama.cpp` for a more private and self-contained experience.
|
||||
- **More providers**: We plan to add support for additional LLM providers, such as Google Gemini, giving you more options to choose from.
|
||||
- **System prompts**: Integration of a system prompt library will allow you to control the behavior of the LLM with predefined prompts, ensuring consistency and efficiency.
|
||||
- **Text replacement for better privacy**: Define keywords that will be replaced in your chats before sending content to the provider, enhancing your privacy.
|
||||
- **Advanced interactions**: We're full of ideas for advanced interactions tailored for specific use cases, whether in a business context or for writers and other professionals.
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=AI/@EntryIndexedValue">AI</s:String></wpf:ResourceDictionary>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=AI/@EntryIndexedValue">AI</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=MSG/@EntryIndexedValue">MSG</s:String>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=tauri_0027s/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||
@@ -3,29 +3,35 @@ namespace AIStudio.Chat;
|
||||
/// <summary>
|
||||
/// Data structure for a chat thread.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the chat thread.</param>
|
||||
/// <param name="seed">The seed for the chat thread. Some providers use this to generate deterministic results.</param>
|
||||
/// <param name="systemPrompt">The system prompt for the chat thread.</param>
|
||||
/// <param name="blocks">The content blocks of the chat thread.</param>
|
||||
public sealed class ChatThread(string name, int seed, string systemPrompt, IEnumerable<ContentBlock> blocks)
|
||||
public sealed class ChatThread
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique identifier of the chat thread.
|
||||
/// </summary>
|
||||
public Guid ChatId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The unique identifier of the workspace.
|
||||
/// </summary>
|
||||
public Guid WorkspaceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of the chat thread. Usually generated by an AI model or manually edited by the user.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = name;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The seed for the chat thread. Some providers use this to generate deterministic results.
|
||||
/// </summary>
|
||||
public int Seed { get; set; } = seed;
|
||||
public int Seed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The current system prompt for the chat thread.
|
||||
/// </summary>
|
||||
public string SystemPrompt { get; set; } = systemPrompt;
|
||||
|
||||
public string SystemPrompt { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The content blocks of the chat thread.
|
||||
/// </summary>
|
||||
public List<ContentBlock> Blocks { get; init; } = blocks.ToList();
|
||||
public List<ContentBlock> Blocks { get; init; } = [];
|
||||
}
|
||||
@@ -3,25 +3,22 @@ namespace AIStudio.Chat;
|
||||
/// <summary>
|
||||
/// A block of content in a chat thread. Might be any type of content, e.g., text, image, voice, etc.
|
||||
/// </summary>
|
||||
/// <param name="time">Time when the content block was created.</param>
|
||||
/// <param name="type">Type of the content block, e.g., text, image, voice, etc.</param>
|
||||
/// <param name="content">The content of the block.</param>
|
||||
public class ContentBlock(DateTimeOffset time, ContentType type, IContent content)
|
||||
public class ContentBlock
|
||||
{
|
||||
/// <summary>
|
||||
/// Time when the content block was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset Time => time;
|
||||
public DateTimeOffset Time { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of the content block, e.g., text, image, voice, etc.
|
||||
/// </summary>
|
||||
public ContentType ContentType => type;
|
||||
public ContentType ContentType { get; init; } = ContentType.NONE;
|
||||
|
||||
/// <summary>
|
||||
/// The content of the block.
|
||||
/// </summary>
|
||||
public IContent Content => content;
|
||||
public IContent? Content { get; init; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// The role of the content block in the chat thread, e.g., user, AI, etc.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
|
||||
@@ -11,15 +13,19 @@ public sealed class ContentImage : IContent
|
||||
#region Implementation of IContent
|
||||
|
||||
/// <inheritdoc />
|
||||
[JsonIgnore]
|
||||
public bool InitialRemoteWait { get; set; } = false;
|
||||
|
||||
/// <inheritdoc />
|
||||
[JsonIgnore]
|
||||
public bool IsStreaming { get; set; } = false;
|
||||
|
||||
/// <inheritdoc />
|
||||
[JsonIgnore]
|
||||
public Func<Task> StreamingDone { get; set; } = () => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
[JsonIgnore]
|
||||
public Func<Task> StreamingEvent { get; set; } = () => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
|
||||
@@ -17,14 +19,19 @@ public sealed class ContentText : IContent
|
||||
#region Implementation of IContent
|
||||
|
||||
/// <inheritdoc />
|
||||
[JsonIgnore]
|
||||
public bool InitialRemoteWait { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
// [JsonIgnore]
|
||||
public bool IsStreaming { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
[JsonIgnore]
|
||||
public Func<Task> StreamingDone { get; set; } = () => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
[JsonIgnore]
|
||||
public Func<Task> StreamingEvent { get; set; } = () => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
|
||||
@@ -6,6 +8,8 @@ namespace AIStudio.Chat;
|
||||
/// <summary>
|
||||
/// The interface for any content in the chat.
|
||||
/// </summary>
|
||||
[JsonDerivedType(typeof(ContentText), typeDiscriminator: "text")]
|
||||
[JsonDerivedType(typeof(ContentImage), typeDiscriminator: "image")]
|
||||
public interface IContent
|
||||
{
|
||||
/// <summary>
|
||||
@@ -13,22 +17,26 @@ public interface IContent
|
||||
/// Does not indicate that the stream is finished; it only indicates that we are
|
||||
/// waiting for the first response, i.e., wait for the remote to pick up the request.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool InitialRemoteWait { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the content is streaming right now. False, if the content is
|
||||
/// either static or the stream has finished.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsStreaming { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// An action that is called when the content was changed during streaming.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public Func<Task> StreamingEvent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// An action that is called when the streaming is done.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public Func<Task> StreamingDone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -13,8 +13,14 @@ public partial class Changelog
|
||||
|
||||
public static readonly Log[] LOGS =
|
||||
[
|
||||
new (161, "v0.7.1, build 161 (2024-07-13 12:44 UTC)", "v0.7.1.md"),
|
||||
new (160, "v0.7.0, build 160 (2024-07-13 08:21 UTC)", "v0.7.0.md"),
|
||||
new (159, "v0.6.3, build 159 (2024-07-03 18:26 UTC)", "v0.6.3.md"),
|
||||
new (158, "v0.6.2, build 158 (2024-07-01 18:03 UTC)", "v0.6.2.md"),
|
||||
new (157, "v0.6.1, build 157 (2024-06-30 19:00 UTC)", "v0.6.1.md"),
|
||||
new (156, "v0.6.0, build 156 (2024-06-30 12:49 UTC)", "v0.6.0.md"),
|
||||
new (155, "v0.5.2, build 155 (2024-06-25 18:07 UTC)", "v0.5.2.md"),
|
||||
new (154, "v0.5.1, build 154 (2024-06-25 15:35 UTC)", "v0.5.1.md"),
|
||||
new (154, "v0.5.2, build 154 (2024-06-25 15:35 UTC)", "v0.5.2.md"),
|
||||
new (149, "v0.5.0, build 149 (2024-06-02 18:51 UTC)", "v0.5.0.md"),
|
||||
new (138, "v0.4.0, build 138 (2024-05-26 13:26 UTC)", "v0.4.0.md"),
|
||||
new (120, "v0.3.0, build 120 (2024-05-18 21:57 UTC)", "v0.3.0.md"),
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Components.Blocks;
|
||||
|
||||
public interface ITreeItem;
|
||||
@@ -1,3 +1,5 @@
|
||||
@inherits AIStudio.Tools.MSGComponentBase
|
||||
|
||||
<div class="d-flex flex-column" style="@this.Height">
|
||||
<div class="flex-auto overflow-auto">
|
||||
@this.ChildContent
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using AIStudio.Components.Layout;
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components.Blocks;
|
||||
|
||||
public partial class InnerScrolling : ComponentBase
|
||||
public partial class InnerScrolling : MSGComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Set the height of anything above the scrolling content; usually a header.
|
||||
/// What we do is calc(100vh - THIS). Means, you can use multiple measures like
|
||||
/// What we do is calc(100vh - HeaderHeight). Means, you can use multiple measures like
|
||||
/// 230px - 3em. Default is 3em.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
@@ -21,5 +24,39 @@ public partial class InnerScrolling : ComponentBase
|
||||
[Parameter]
|
||||
public RenderFragment? FooterContent { get; set; }
|
||||
|
||||
private string Height => $"height: calc(100vh - {this.HeaderHeight});";
|
||||
[CascadingParameter]
|
||||
private MainLayout MainLayout { get; set; } = null!;
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.ApplyFilters([], [ Event.STATE_HAS_CHANGED ]);
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
public override Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
|
||||
{
|
||||
switch (triggeredEvent)
|
||||
{
|
||||
case Event.STATE_HAS_CHANGED:
|
||||
this.StateHasChanged();
|
||||
break;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) where TResult : default where TPayload : default
|
||||
{
|
||||
return Task.FromResult(default(TResult));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private string Height => $"height: calc(100vh - {this.HeaderHeight} - {this.MainLayout.AdditionalHeight});";
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Components.Blocks;
|
||||
|
||||
public readonly record struct TreeButton(WorkspaceBranch Branch, int Depth, string Text, string Icon, Func<Task> Action) : ITreeItem;
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Components.Blocks;
|
||||
|
||||
public readonly record struct TreeDivider : ITreeItem;
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace AIStudio.Components.Blocks;
|
||||
|
||||
public class TreeItemData : ITreeItem
|
||||
{
|
||||
public WorkspaceBranch Branch { get; init; } = WorkspaceBranch.NONE;
|
||||
|
||||
public int Depth { get; init; }
|
||||
|
||||
public string Text { get; init; } = string.Empty;
|
||||
|
||||
public string ShortenedText => Text.Length > 30 ? this.Text[..30] + "..." : this.Text;
|
||||
|
||||
public string Icon { get; init; } = string.Empty;
|
||||
|
||||
public TreeItemType Type { get; init; }
|
||||
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
public bool Expandable { get; init; } = true;
|
||||
|
||||
public HashSet<ITreeItem> Children { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Components.Blocks;
|
||||
|
||||
public enum TreeItemType
|
||||
{
|
||||
NONE,
|
||||
|
||||
CHAT,
|
||||
WORKSPACE,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Components.Blocks;
|
||||
|
||||
public enum WorkspaceBranch
|
||||
{
|
||||
NONE,
|
||||
|
||||
WORKSPACES,
|
||||
TEMPORARY_CHATS,
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<MudTreeView T="ITreeItem" Items="@this.treeItems" MultiSelection="@false" Hover="@true" ExpandOnClick="@true">
|
||||
<ItemTemplate Context="item">
|
||||
@switch (item)
|
||||
{
|
||||
case TreeDivider:
|
||||
<li style="min-height: 1em;">
|
||||
<MudDivider Style="margin-top: 1em; width: 90%; border-width: 3pt;"/>
|
||||
</li>
|
||||
break;
|
||||
|
||||
case TreeItemData treeItem:
|
||||
@if (treeItem.Type is TreeItemType.CHAT)
|
||||
{
|
||||
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item" CanExpand="@treeItem.Expandable" Items="@treeItem.Children" OnClick="() => this.LoadChat(treeItem.Path, true)">
|
||||
<BodyContent>
|
||||
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
|
||||
<MudText Style="justify-self: start;">
|
||||
@if (string.IsNullOrWhiteSpace(treeItem.Text))
|
||||
{
|
||||
@("Empty chat")
|
||||
}
|
||||
else
|
||||
{
|
||||
@treeItem.ShortenedText
|
||||
}
|
||||
</MudText>
|
||||
<div style="justify-self: end;">
|
||||
|
||||
<MudTooltip Text="Move to workspace" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Size="Size.Medium" Color="Color.Inherit" OnClick="() => this.MoveChat(treeItem.Path)"/>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Text="Rename" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="() => this.RenameChat(treeItem.Path)"/>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Text="Delete" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Inherit" OnClick="() => this.DeleteChat(treeItem.Path)"/>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</BodyContent>
|
||||
</MudTreeViewItem>
|
||||
}
|
||||
else if (treeItem.Type is TreeItemType.WORKSPACE)
|
||||
{
|
||||
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item" CanExpand="@treeItem.Expandable" Items="@treeItem.Children">
|
||||
<BodyContent>
|
||||
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
|
||||
<MudText Style="justify-self: start;">@treeItem.Text</MudText>
|
||||
<div style="justify-self: end;">
|
||||
<MudTooltip Text="Rename" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="() => this.RenameWorkspace(treeItem.Path)"/>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Text="Delete" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Inherit" OnClick="() => this.DeleteWorkspace(treeItem.Path)"/>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</BodyContent>
|
||||
</MudTreeViewItem>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item" CanExpand="@treeItem.Expandable" Items="@treeItem.Children">
|
||||
<BodyContent>
|
||||
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
|
||||
<MudText Style="justify-self: start;">@treeItem.Text</MudText>
|
||||
</div>
|
||||
</BodyContent>
|
||||
</MudTreeViewItem>
|
||||
}
|
||||
break;
|
||||
|
||||
case TreeButton treeButton:
|
||||
<li>
|
||||
<div class="mud-treeview-item-content" style="background-color: unset;">
|
||||
<div class="mud-treeview-item-arrow"></div>
|
||||
<MudButton StartIcon="@treeButton.Icon" Variant="Variant.Filled" OnClick="treeButton.Action">
|
||||
@treeButton.Text
|
||||
</MudButton>
|
||||
</div>
|
||||
</li>
|
||||
break;
|
||||
}
|
||||
</ItemTemplate>
|
||||
</MudTreeView>
|
||||
@@ -0,0 +1,499 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Components.CommonDialogs;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
using DialogOptions = AIStudio.Components.CommonDialogs.DialogOptions;
|
||||
|
||||
namespace AIStudio.Components.Blocks;
|
||||
|
||||
public partial class Workspaces : ComponentBase
|
||||
{
|
||||
[Inject]
|
||||
private SettingsManager SettingsManager { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
public Random RNG { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public ChatThread? CurrentChatThread { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ChatThread> CurrentChatThreadChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Func<Task> LoadedChatWasChanged { get; set; } = () => Task.CompletedTask;
|
||||
|
||||
private const Placement WORKSPACE_ITEM_TOOLTIP_PLACEMENT = Placement.Bottom;
|
||||
|
||||
private static readonly JsonSerializerOptions JSON_OPTIONS = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
AllowTrailingCommas = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper),
|
||||
}
|
||||
};
|
||||
|
||||
private readonly HashSet<ITreeItem> treeItems = new();
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
//
|
||||
// Notice: In order to get the server-based loading to work, we need to respect the following rules:
|
||||
// - We must have initial tree items
|
||||
// - Those initial tree items cannot have children
|
||||
// - When assigning the tree items to the MudTreeViewItem component, we must set the Value property to the value of the item
|
||||
//
|
||||
|
||||
await this.LoadTreeItems();
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task LoadTreeItems()
|
||||
{
|
||||
this.treeItems.Clear();
|
||||
this.treeItems.Add(new TreeItemData
|
||||
{
|
||||
Depth = 0,
|
||||
Branch = WorkspaceBranch.WORKSPACES,
|
||||
Text = "Workspaces",
|
||||
Icon = Icons.Material.Filled.Folder,
|
||||
Expandable = true,
|
||||
Path = "root",
|
||||
Children = await this.LoadWorkspaces(),
|
||||
});
|
||||
|
||||
this.treeItems.Add(new TreeDivider());
|
||||
this.treeItems.Add(new TreeItemData
|
||||
{
|
||||
Depth = 0,
|
||||
Branch = WorkspaceBranch.TEMPORARY_CHATS,
|
||||
Text = "Temporary chats",
|
||||
Icon = Icons.Material.Filled.Timer,
|
||||
Expandable = true,
|
||||
Path = "temp",
|
||||
Children = await this.LoadTemporaryChats(),
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<HashSet<ITreeItem>> LoadTemporaryChats()
|
||||
{
|
||||
var tempChildren = new HashSet<ITreeItem>();
|
||||
|
||||
//
|
||||
// Search for workspace folders in the data directory:
|
||||
//
|
||||
|
||||
// Get the workspace root directory:
|
||||
var temporaryDirectories = Path.Join(SettingsManager.DataDirectory, "tempChats");
|
||||
|
||||
// Ensure the directory exists:
|
||||
Directory.CreateDirectory(temporaryDirectories);
|
||||
|
||||
// Enumerate the workspace directories:
|
||||
foreach (var tempChatDirPath in Directory.EnumerateDirectories(temporaryDirectories))
|
||||
{
|
||||
// Read the `name` file:
|
||||
var chatNamePath = Path.Join(tempChatDirPath, "name");
|
||||
var chatName = await File.ReadAllTextAsync(chatNamePath, Encoding.UTF8);
|
||||
|
||||
tempChildren.Add(new TreeItemData
|
||||
{
|
||||
Type = TreeItemType.CHAT,
|
||||
Depth = 1,
|
||||
Branch = WorkspaceBranch.TEMPORARY_CHATS,
|
||||
Text = chatName,
|
||||
Icon = Icons.Material.Filled.Timer,
|
||||
Expandable = false,
|
||||
Path = tempChatDirPath,
|
||||
});
|
||||
}
|
||||
|
||||
return tempChildren;
|
||||
}
|
||||
|
||||
public async Task<string> LoadWorkspaceName(Guid workspaceId)
|
||||
{
|
||||
if(workspaceId == Guid.Empty)
|
||||
return string.Empty;
|
||||
|
||||
var workspacePath = Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString());
|
||||
var workspaceNamePath = Path.Join(workspacePath, "name");
|
||||
return await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8);
|
||||
}
|
||||
|
||||
private async Task<HashSet<ITreeItem>> LoadWorkspaces()
|
||||
{
|
||||
var workspaces = new HashSet<ITreeItem>();
|
||||
|
||||
//
|
||||
// Search for workspace folders in the data directory:
|
||||
//
|
||||
|
||||
// Get the workspace root directory:
|
||||
var workspaceDirectories = Path.Join(SettingsManager.DataDirectory, "workspaces");
|
||||
|
||||
// Ensure the directory exists:
|
||||
Directory.CreateDirectory(workspaceDirectories);
|
||||
|
||||
// Enumerate the workspace directories:
|
||||
foreach (var workspaceDirPath in Directory.EnumerateDirectories(workspaceDirectories))
|
||||
{
|
||||
// Read the `name` file:
|
||||
var workspaceNamePath = Path.Join(workspaceDirPath, "name");
|
||||
var workspaceName = await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8);
|
||||
|
||||
workspaces.Add(new TreeItemData
|
||||
{
|
||||
Type = TreeItemType.WORKSPACE,
|
||||
Depth = 1,
|
||||
Branch = WorkspaceBranch.WORKSPACES,
|
||||
Text = workspaceName,
|
||||
Icon = Icons.Material.Filled.Description,
|
||||
Expandable = true,
|
||||
Path = workspaceDirPath,
|
||||
Children = await this.LoadWorkspaceChats(workspaceDirPath),
|
||||
});
|
||||
}
|
||||
|
||||
workspaces.Add(new TreeButton(WorkspaceBranch.WORKSPACES, 1, "Add workspace",Icons.Material.Filled.LibraryAdd, this.AddWorkspace));
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
private async Task<HashSet<ITreeItem>> LoadWorkspaceChats(string workspacePath)
|
||||
{
|
||||
var workspaceChats = new HashSet<ITreeItem>();
|
||||
|
||||
// Enumerate the workspace directory:
|
||||
foreach (var chatPath in Directory.EnumerateDirectories(workspacePath))
|
||||
{
|
||||
// Read the `name` file:
|
||||
var chatNamePath = Path.Join(chatPath, "name");
|
||||
var chatName = await File.ReadAllTextAsync(chatNamePath, Encoding.UTF8);
|
||||
|
||||
workspaceChats.Add(new TreeItemData
|
||||
{
|
||||
Type = TreeItemType.CHAT,
|
||||
Depth = 2,
|
||||
Branch = WorkspaceBranch.WORKSPACES,
|
||||
Text = chatName,
|
||||
Icon = Icons.Material.Filled.Chat,
|
||||
Expandable = false,
|
||||
Path = chatPath,
|
||||
});
|
||||
}
|
||||
|
||||
workspaceChats.Add(new TreeButton(WorkspaceBranch.WORKSPACES, 2, "Add chat",Icons.Material.Filled.AddComment, () => this.AddChat(workspacePath)));
|
||||
return workspaceChats;
|
||||
}
|
||||
|
||||
public async Task StoreChat(ChatThread chat)
|
||||
{
|
||||
string chatDirectory;
|
||||
if (chat.WorkspaceId == Guid.Empty)
|
||||
chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString());
|
||||
else
|
||||
chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString());
|
||||
|
||||
// Ensure the directory exists:
|
||||
Directory.CreateDirectory(chatDirectory);
|
||||
|
||||
// Save the chat name:
|
||||
var chatNamePath = Path.Join(chatDirectory, "name");
|
||||
await File.WriteAllTextAsync(chatNamePath, chat.Name);
|
||||
|
||||
// Save the thread as thread.json:
|
||||
var chatPath = Path.Join(chatDirectory, "thread.json");
|
||||
await File.WriteAllTextAsync(chatPath, JsonSerializer.Serialize(chat, JSON_OPTIONS), Encoding.UTF8);
|
||||
|
||||
// Reload the tree items:
|
||||
await this.LoadTreeItems();
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task<ChatThread?> LoadChat(string? chatPath, bool switchToChat)
|
||||
{
|
||||
if(string.IsNullOrWhiteSpace(chatPath))
|
||||
return null;
|
||||
|
||||
if(!Directory.Exists(chatPath))
|
||||
return null;
|
||||
|
||||
// Check if the chat has unsaved changes:
|
||||
if (switchToChat && await MessageBus.INSTANCE.SendMessageUseFirstResult<bool, bool>(this, Event.HAS_CHAT_UNSAVED_CHANGES))
|
||||
{
|
||||
var dialogParameters = new DialogParameters
|
||||
{
|
||||
{ "Message", "Are you sure you want to load another chat? All unsaved changes will be lost." },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>("Load Chat", dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var chatData = await File.ReadAllTextAsync(Path.Join(chatPath, "thread.json"), Encoding.UTF8);
|
||||
var chat = JsonSerializer.Deserialize<ChatThread>(chatData, JSON_OPTIONS);
|
||||
if (switchToChat)
|
||||
{
|
||||
this.CurrentChatThread = chat;
|
||||
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
|
||||
await this.LoadedChatWasChanged();
|
||||
}
|
||||
|
||||
return chat;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task DeleteChat(string? chatPath, bool askForConfirmation = true, bool unloadChat = true)
|
||||
{
|
||||
var chat = await this.LoadChat(chatPath, false);
|
||||
if (chat is null)
|
||||
return;
|
||||
|
||||
if (askForConfirmation)
|
||||
{
|
||||
var workspaceName = await this.LoadWorkspaceName(chat.WorkspaceId);
|
||||
var dialogParameters = new DialogParameters
|
||||
{
|
||||
{
|
||||
"Message", (chat.WorkspaceId == Guid.Empty) switch
|
||||
{
|
||||
true => $"Are you sure you want to delete the temporary chat '{chat.Name}'?",
|
||||
false => $"Are you sure you want to delete the chat '{chat.Name}' in the workspace '{workspaceName}'?",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>("Delete Chat", dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
return;
|
||||
}
|
||||
|
||||
string chatDirectory;
|
||||
if (chat.WorkspaceId == Guid.Empty)
|
||||
chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString());
|
||||
else
|
||||
chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString());
|
||||
|
||||
Directory.Delete(chatDirectory, true);
|
||||
await this.LoadTreeItems();
|
||||
|
||||
if(unloadChat && this.CurrentChatThread?.ChatId == chat.ChatId)
|
||||
{
|
||||
this.CurrentChatThread = null;
|
||||
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
|
||||
await this.LoadedChatWasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RenameChat(string? chatPath)
|
||||
{
|
||||
var chat = await this.LoadChat(chatPath, false);
|
||||
if (chat is null)
|
||||
return;
|
||||
|
||||
var dialogParameters = new DialogParameters
|
||||
{
|
||||
{ "Message", $"Please enter a new or edit the name for your chat '{chat.Name}':" },
|
||||
{ "UserInput", chat.Name },
|
||||
{ "ConfirmText", "Rename" },
|
||||
{ "ConfirmColor", Color.Info },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<SingleInputDialog>("Rename Chat", dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
return;
|
||||
|
||||
chat.Name = (dialogResult.Data as string)!;
|
||||
await this.StoreChat(chat);
|
||||
await this.LoadTreeItems();
|
||||
}
|
||||
|
||||
private async Task RenameWorkspace(string? workspacePath)
|
||||
{
|
||||
if(workspacePath is null)
|
||||
return;
|
||||
|
||||
var workspaceId = Guid.Parse(Path.GetFileName(workspacePath));
|
||||
var workspaceName = await this.LoadWorkspaceName(workspaceId);
|
||||
var dialogParameters = new DialogParameters
|
||||
{
|
||||
{ "Message", $"Please enter a new or edit the name for your workspace '{workspaceName}':" },
|
||||
{ "UserInput", workspaceName },
|
||||
{ "ConfirmText", "Rename" },
|
||||
{ "ConfirmColor", Color.Info },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<SingleInputDialog>("Rename Workspace", dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
return;
|
||||
|
||||
var alteredWorkspaceName = (dialogResult.Data as string)!;
|
||||
var workspaceNamePath = Path.Join(workspacePath, "name");
|
||||
await File.WriteAllTextAsync(workspaceNamePath, alteredWorkspaceName, Encoding.UTF8);
|
||||
await this.LoadTreeItems();
|
||||
}
|
||||
|
||||
private async Task AddWorkspace()
|
||||
{
|
||||
var dialogParameters = new DialogParameters
|
||||
{
|
||||
{ "Message", "Please name your workspace:" },
|
||||
{ "UserInput", string.Empty },
|
||||
{ "ConfirmText", "Add workspace" },
|
||||
{ "ConfirmColor", Color.Info },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<SingleInputDialog>("Add Workspace", dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
return;
|
||||
|
||||
var workspaceId = Guid.NewGuid();
|
||||
var workspacePath = Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString());
|
||||
Directory.CreateDirectory(workspacePath);
|
||||
|
||||
var workspaceNamePath = Path.Join(workspacePath, "name");
|
||||
await File.WriteAllTextAsync(workspaceNamePath, (dialogResult.Data as string)!, Encoding.UTF8);
|
||||
|
||||
await this.LoadTreeItems();
|
||||
}
|
||||
|
||||
private async Task DeleteWorkspace(string? workspacePath)
|
||||
{
|
||||
if(workspacePath is null)
|
||||
return;
|
||||
|
||||
var workspaceId = Guid.Parse(Path.GetFileName(workspacePath));
|
||||
var workspaceName = await this.LoadWorkspaceName(workspaceId);
|
||||
|
||||
// Determine how many chats are in the workspace:
|
||||
var chatCount = Directory.EnumerateDirectories(workspacePath).Count();
|
||||
|
||||
var dialogParameters = new DialogParameters
|
||||
{
|
||||
{ "Message", $"Are you sure you want to delete the workspace '{workspaceName}'? This will also delete {chatCount} chat(s) in this workspace." },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>("Delete Workspace", dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
return;
|
||||
|
||||
Directory.Delete(workspacePath, true);
|
||||
await this.LoadTreeItems();
|
||||
}
|
||||
|
||||
private async Task MoveChat(string? chatPath)
|
||||
{
|
||||
var chat = await this.LoadChat(chatPath, false);
|
||||
if (chat is null)
|
||||
return;
|
||||
|
||||
var dialogParameters = new DialogParameters
|
||||
{
|
||||
{ "Message", "Please select the workspace where you want to move the chat to." },
|
||||
{ "SelectedWorkspace", chat.WorkspaceId },
|
||||
{ "ConfirmText", "Move chat" },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<WorkspaceSelectionDialog>("Move Chat to Workspace", dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
return;
|
||||
|
||||
var workspaceId = dialogResult.Data is Guid id ? id : default;
|
||||
if (workspaceId == Guid.Empty)
|
||||
return;
|
||||
|
||||
// Delete the chat from the current workspace or the temporary storage:
|
||||
if (chat.WorkspaceId == Guid.Empty)
|
||||
{
|
||||
// Case: The chat is stored in the temporary storage:
|
||||
await this.DeleteChat(Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()), askForConfirmation: false, unloadChat: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Case: The chat is stored in a workspace.
|
||||
await this.DeleteChat(Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()), askForConfirmation: false, unloadChat: false);
|
||||
}
|
||||
|
||||
// Update the chat's workspace:
|
||||
chat.WorkspaceId = workspaceId;
|
||||
|
||||
// Handle the case where the chat is the active chat:
|
||||
if (this.CurrentChatThread?.ChatId == chat.ChatId)
|
||||
{
|
||||
this.CurrentChatThread = chat;
|
||||
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
|
||||
await this.LoadedChatWasChanged();
|
||||
}
|
||||
|
||||
await this.StoreChat(chat);
|
||||
}
|
||||
|
||||
private async Task AddChat(string workspacePath)
|
||||
{
|
||||
// Check if the chat has unsaved changes:
|
||||
if (await MessageBus.INSTANCE.SendMessageUseFirstResult<bool, bool>(this, Event.HAS_CHAT_UNSAVED_CHANGES))
|
||||
{
|
||||
var dialogParameters = new DialogParameters
|
||||
{
|
||||
{ "Message", "Are you sure you want to create a another chat? All unsaved changes will be lost." },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>("Create Chat", dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
return;
|
||||
}
|
||||
|
||||
var workspaceId = Guid.Parse(Path.GetFileName(workspacePath));
|
||||
var chat = new ChatThread
|
||||
{
|
||||
WorkspaceId = workspaceId,
|
||||
ChatId = Guid.NewGuid(),
|
||||
Name = string.Empty,
|
||||
Seed = this.RNG.Next(),
|
||||
SystemPrompt = "You are a helpful assistant!",
|
||||
Blocks = [],
|
||||
};
|
||||
|
||||
var chatPath = Path.Join(workspacePath, chat.ChatId.ToString());
|
||||
|
||||
await this.StoreChat(chat);
|
||||
await this.LoadChat(chatPath, switchToChat: true);
|
||||
await this.LoadTreeItems();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace AIStudio.Components.CommonDialogs;
|
||||
|
||||
public static class DialogOptions
|
||||
{
|
||||
public static readonly MudBlazor.DialogOptions FULLSCREEN = new()
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
FullWidth = true, MaxWidth = MaxWidth.Medium,
|
||||
};
|
||||
|
||||
public static readonly MudBlazor.DialogOptions FULLSCREEN_NO_HEADER = new()
|
||||
{
|
||||
NoHeader = true,
|
||||
CloseOnEscapeKey = true,
|
||||
FullWidth = true, MaxWidth = MaxWidth.Medium,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.body1">@this.Message</MudText>
|
||||
<MudTextField T="string" @bind-Text="@this.UserInput" Variant="Variant.Outlined" AutoGrow="@false" Lines="1" Label="Chat name" AutoFocus="@true"/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">Cancel</MudButton>
|
||||
<MudButton OnClick="@this.Confirm" Variant="Variant.Filled" Color="@this.ConfirmColor">@this.ConfirmText</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components.CommonDialogs;
|
||||
|
||||
public partial class SingleInputDialog : ComponentBase
|
||||
{
|
||||
[CascadingParameter]
|
||||
private MudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string UserInput { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string ConfirmText { get; set; } = "OK";
|
||||
|
||||
[Parameter]
|
||||
public Color ConfirmColor { get; set; } = Color.Error;
|
||||
|
||||
private void Cancel() => this.MudDialog.Cancel();
|
||||
|
||||
private void Confirm() => this.MudDialog.Close(DialogResult.Ok(this.UserInput));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@using AIStudio.Tools
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.h4" Class="d-inline-flex align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Update" Size="Size.Large" Class="mr-3"/>
|
||||
Update from v@(META_DATA.Version) to v@(this.UpdateResponse.NewVersion)
|
||||
</MudText>
|
||||
<MudMarkdown Value="@this.UpdateResponse.Changelog" OverrideHeaderTypo="@Markdown.OverrideHeaderTypo"/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">Install later</MudButton>
|
||||
<MudButton OnClick="@this.Confirm" Variant="Variant.Filled" Color="Color.Info">Install now</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Reflection;
|
||||
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components.CommonDialogs;
|
||||
|
||||
/// <summary>
|
||||
/// The update dialog that is used to inform the user about an available update.
|
||||
/// </summary>
|
||||
public partial class UpdateDialog : ComponentBase
|
||||
{
|
||||
private static readonly Assembly ASSEMBLY = Assembly.GetExecutingAssembly();
|
||||
private static readonly MetaDataAttribute META_DATA = ASSEMBLY.GetCustomAttribute<MetaDataAttribute>()!;
|
||||
|
||||
[CascadingParameter]
|
||||
private MudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public UpdateResponse UpdateResponse { get; set; }
|
||||
|
||||
private void Cancel() => this.MudDialog.Cancel();
|
||||
|
||||
private void Confirm() => this.MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.body1">@this.Message</MudText>
|
||||
<MudList Clickable="@true" @bind-SelectedValue="@this.selectedWorkspace">
|
||||
@foreach (var (workspaceName, workspaceId) in this.workspaces)
|
||||
{
|
||||
<MudListItem Text="@workspaceName" Icon="@Icons.Material.Filled.Description" Value="@workspaceId" />
|
||||
}
|
||||
</MudList>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">Cancel</MudButton>
|
||||
<MudButton OnClick="@this.Confirm" Variant="Variant.Filled" Color="Color.Info">@this.ConfirmText</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text;
|
||||
|
||||
using AIStudio.Settings;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components.CommonDialogs;
|
||||
|
||||
public partial class WorkspaceSelectionDialog : ComponentBase
|
||||
{
|
||||
[CascadingParameter]
|
||||
private MudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public Guid SelectedWorkspace { get; set; } = Guid.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string ConfirmText { get; set; } = "OK";
|
||||
|
||||
private readonly Dictionary<string, Guid> workspaces = new();
|
||||
private object? selectedWorkspace;
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.selectedWorkspace = this.SelectedWorkspace;
|
||||
|
||||
// Get the workspace root directory:
|
||||
var workspaceDirectories = Path.Join(SettingsManager.DataDirectory, "workspaces");
|
||||
if(!Directory.Exists(workspaceDirectories))
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enumerate the workspace directories:
|
||||
foreach (var workspaceDirPath in Directory.EnumerateDirectories(workspaceDirectories))
|
||||
{
|
||||
// Read the `name` file:
|
||||
var workspaceNamePath = Path.Join(workspaceDirPath, "name");
|
||||
var workspaceName = await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8);
|
||||
|
||||
// Add the workspace to the list:
|
||||
this.workspaces.Add(workspaceName, Guid.Parse(Path.GetFileName(workspaceDirPath)));
|
||||
}
|
||||
|
||||
this.StateHasChanged();
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void Cancel() => this.MudDialog.Cancel();
|
||||
|
||||
private void Confirm() => this.MudDialog.Close(DialogResult.Ok(this.selectedWorkspace is Guid workspaceId ? workspaceId : default));
|
||||
}
|
||||
@@ -21,4 +21,30 @@ public static class ConfigurationSelectDataFactory
|
||||
yield return new("Modifier key + enter is sending the input", SendBehavior.MODIFER_ENTER_IS_SENDING);
|
||||
yield return new("Enter is sending the input", SendBehavior.ENTER_IS_SENDING);
|
||||
}
|
||||
|
||||
public static IEnumerable<ConfigurationSelectData<UpdateBehavior>> GetUpdateBehaviorData()
|
||||
{
|
||||
yield return new("No automatic update checks", UpdateBehavior.NO_CHECK);
|
||||
yield return new("Once at startup", UpdateBehavior.ONCE_STARTUP);
|
||||
yield return new("Check every hour", UpdateBehavior.HOURLY);
|
||||
yield return new("Check every day", UpdateBehavior.DAILY);
|
||||
yield return new ("Check every week", UpdateBehavior.WEEKLY);
|
||||
}
|
||||
|
||||
public static IEnumerable<ConfigurationSelectData<WorkspaceStorageBehavior>> GetWorkspaceStorageBehaviorData()
|
||||
{
|
||||
yield return new("Disable workspaces", WorkspaceStorageBehavior.DISABLE_WORKSPACES);
|
||||
yield return new("Store chats automatically", WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY);
|
||||
yield return new("Store chats manually", WorkspaceStorageBehavior.STORE_CHATS_MANUALLY);
|
||||
}
|
||||
|
||||
public static IEnumerable<ConfigurationSelectData<WorkspaceStorageTemporaryMaintenancePolicy>> GetWorkspaceStorageTemporaryMaintenancePolicyData()
|
||||
{
|
||||
yield return new("No automatic maintenance for temporary chats", WorkspaceStorageTemporaryMaintenancePolicy.NO_AUTOMATIC_MAINTENANCE);
|
||||
yield return new("Delete temporary chats older than 7 days", WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_7_DAYS);
|
||||
yield return new("Delete temporary chats older than 30 days", WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_30_DAYS);
|
||||
yield return new("Delete temporary chats older than 90 days", WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_90_DAYS);
|
||||
yield return new("Delete temporary chats older than 180 days", WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_180_DAYS);
|
||||
yield return new("Delete temporary chats older than 1 year", WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_365_DAYS);
|
||||
}
|
||||
}
|
||||
@@ -2,31 +2,57 @@
|
||||
|
||||
<MudPaper Height="calc(100vh);" Elevation="0">
|
||||
<MudLayout>
|
||||
<MudDrawerContainer Class="mud-height-full absolute">
|
||||
<MudDrawer Elevation="0" Variant="@DrawerVariant.Mini" OpenMiniOnHover="@true" Color="Color.Default">
|
||||
<MudNavMenu>
|
||||
<MudTooltip Text="Home" Placement="Placement.Right">
|
||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Home">Home</MudNavLink>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Chats" Placement="Placement.Right">
|
||||
<MudNavLink Href="/chat" Icon="@Icons.Material.Filled.Chat">Chats</MudNavLink>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Supporters" Placement="Placement.Right">
|
||||
<MudNavLink Href="/supporters" Icon="@Icons.Material.Filled.Favorite" IconColor="Color.Error">Supporters</MudNavLink>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="About" Placement="Placement.Right">
|
||||
<MudNavLink Href="/about" Icon="@Icons.Material.Filled.Info">About</MudNavLink>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Settings" Placement="Placement.Right">
|
||||
<MudNavLink Href="/settings" Icon="@Icons.Material.Filled.Settings">Settings</MudNavLink>
|
||||
</MudTooltip>
|
||||
</MudNavMenu>
|
||||
</MudDrawer>
|
||||
</MudDrawerContainer>
|
||||
@if (!this.performingUpdate)
|
||||
{
|
||||
<MudDrawerContainer Class="mud-height-full absolute">
|
||||
<MudDrawer Elevation="0" Variant="@DrawerVariant.Mini" OpenMiniOnHover="@true" Color="Color.Default">
|
||||
<MudNavMenu>
|
||||
<MudTooltip Text="Home" Placement="Placement.Right">
|
||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Home">Home</MudNavLink>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Chat" Placement="Placement.Right">
|
||||
<MudNavLink Href="/chat" Icon="@Icons.Material.Filled.Chat">Chat</MudNavLink>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Supporters" Placement="Placement.Right">
|
||||
<MudNavLink Href="/supporters" Icon="@Icons.Material.Filled.Favorite" IconColor="Color.Error">Supporters</MudNavLink>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="About" Placement="Placement.Right">
|
||||
<MudNavLink Href="/about" Icon="@Icons.Material.Filled.Info">About</MudNavLink>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Settings" Placement="Placement.Right">
|
||||
<MudNavLink Href="/settings" Icon="@Icons.Material.Filled.Settings">Settings</MudNavLink>
|
||||
</MudTooltip>
|
||||
</MudNavMenu>
|
||||
</MudDrawer>
|
||||
</MudDrawerContainer>
|
||||
}
|
||||
|
||||
<MudMainContent Class="mud-height-full pt-1">
|
||||
<MudContainer Fixed="@true" Class="mud-height-full" Style="margin-left: 5em; width: calc(100% - 5em);">
|
||||
@this.Body
|
||||
@if (!this.performingUpdate && this.IsUpdateAlertVisible)
|
||||
{
|
||||
<MudAlert NoIcon="@true" Severity="Severity.Info" Variant="Variant.Filled" ShowCloseIcon="@true" Dense="@true" CloseIconClicked="() => this.DismissUpdate()" Class="mt-2 mb-2">
|
||||
<div class="d-inline-flex align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Update" Size="Size.Medium" Class="mr-3"/>
|
||||
An update to version @this.updateToVersion is available.
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Dark" Size="Size.Small" Class="ml-3" OnClick="() => this.ShowUpdateDialog()">
|
||||
Show details
|
||||
</MudButton>
|
||||
</div>
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@if (!this.performingUpdate)
|
||||
{
|
||||
<CascadingValue Value="@this" IsFixed="true">
|
||||
@this.Body
|
||||
</CascadingValue>
|
||||
}
|
||||
|
||||
<MudOverlay Visible="@this.performingUpdate" DarkBackground="@true" LockScroll="@true">
|
||||
<MudText Typo="Typo.h3">Please wait for the update to complete...</MudText>
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="@true" Size="Size.Large" Rounded="@true"/>
|
||||
</MudOverlay>
|
||||
</MudContainer>
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
@@ -1,17 +1,51 @@
|
||||
using AIStudio.Components.CommonDialogs;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Routing;
|
||||
|
||||
using DialogOptions = AIStudio.Components.CommonDialogs.DialogOptions;
|
||||
|
||||
namespace AIStudio.Components.Layout;
|
||||
|
||||
public partial class MainLayout : LayoutComponentBase
|
||||
public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver
|
||||
{
|
||||
[Inject]
|
||||
private IJSRuntime JsRuntime { get; set; } = null!;
|
||||
private IJSRuntime JsRuntime { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private SettingsManager SettingsManager { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private MessageBus MessageBus { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private Rust Rust { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ISnackbar Snackbar { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private NavigationManager NavigationManager { get; init; } = null!;
|
||||
|
||||
public string AdditionalHeight { get; private set; } = "0em";
|
||||
|
||||
private bool isUpdateAvailable;
|
||||
private bool performingUpdate;
|
||||
private bool userDismissedUpdate;
|
||||
private string updateToVersion = string.Empty;
|
||||
private UpdateResponse? currentUpdateResponse;
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.NavigationManager.RegisterLocationChangingHandler(this.OnLocationChanging);
|
||||
|
||||
//
|
||||
// We use the Tauri API (Rust) to get the data and config directories
|
||||
// for this app.
|
||||
@@ -21,10 +55,129 @@ public partial class MainLayout : LayoutComponentBase
|
||||
|
||||
// Store the directories in the settings manager:
|
||||
SettingsManager.ConfigDirectory = configDir;
|
||||
SettingsManager.DataDirectory = dataDir;
|
||||
SettingsManager.DataDirectory = Path.Join(dataDir, "data");
|
||||
Directory.CreateDirectory(SettingsManager.DataDirectory);
|
||||
|
||||
// Ensure that all settings are loaded:
|
||||
await this.SettingsManager.LoadSettings();
|
||||
|
||||
// Register this component with the message bus:
|
||||
this.MessageBus.RegisterComponent(this);
|
||||
this.MessageBus.ApplyFilters(this, [], [ Event.UPDATE_AVAILABLE, Event.USER_SEARCH_FOR_UPDATE ]);
|
||||
|
||||
// Set the js runtime for the update service:
|
||||
UpdateService.SetBlazorDependencies(this.JsRuntime, this.Snackbar);
|
||||
TemporaryChatService.Initialize();
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of IMessageBusReceiver
|
||||
|
||||
public async Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data)
|
||||
{
|
||||
switch (triggeredEvent)
|
||||
{
|
||||
case Event.USER_SEARCH_FOR_UPDATE:
|
||||
this.userDismissedUpdate = false;
|
||||
break;
|
||||
|
||||
case Event.UPDATE_AVAILABLE:
|
||||
if (data is UpdateResponse updateResponse)
|
||||
{
|
||||
this.currentUpdateResponse = updateResponse;
|
||||
this.isUpdateAvailable = updateResponse.UpdateIsAvailable;
|
||||
this.updateToVersion = updateResponse.NewVersion;
|
||||
|
||||
await this.InvokeAsync(this.StateHasChanged);
|
||||
await this.SendMessage<bool>(Event.STATE_HAS_CHANGED);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data)
|
||||
{
|
||||
return Task.FromResult<TResult?>(default);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task DismissUpdate()
|
||||
{
|
||||
this.userDismissedUpdate = true;
|
||||
this.AdditionalHeight = "0em";
|
||||
|
||||
await this.SendMessage<bool>(Event.STATE_HAS_CHANGED);
|
||||
}
|
||||
|
||||
private bool IsUpdateAlertVisible
|
||||
{
|
||||
get
|
||||
{
|
||||
var state = this.isUpdateAvailable && !this.userDismissedUpdate;
|
||||
this.AdditionalHeight = state ? "3em" : "0em";
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowUpdateDialog()
|
||||
{
|
||||
if(this.currentUpdateResponse is null)
|
||||
return;
|
||||
|
||||
//
|
||||
// Replace the fir line with `# Changelog`:
|
||||
//
|
||||
var changelog = this.currentUpdateResponse.Value.Changelog;
|
||||
if (!string.IsNullOrWhiteSpace(changelog))
|
||||
{
|
||||
var lines = changelog.Split('\n');
|
||||
if (lines.Length > 0)
|
||||
lines[0] = "# Changelog";
|
||||
|
||||
changelog = string.Join('\n', lines);
|
||||
}
|
||||
|
||||
var updatedResponse = this.currentUpdateResponse.Value with { Changelog = changelog };
|
||||
var dialogParameters = new DialogParameters<UpdateDialog>
|
||||
{
|
||||
{ x => x.UpdateResponse, updatedResponse }
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<UpdateDialog>("Update", dialogParameters, DialogOptions.FULLSCREEN_NO_HEADER);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
return;
|
||||
|
||||
this.performingUpdate = true;
|
||||
this.StateHasChanged();
|
||||
await this.Rust.InstallUpdate(this.JsRuntime);
|
||||
}
|
||||
|
||||
private async ValueTask OnLocationChanging(LocationChangingContext context)
|
||||
{
|
||||
if (await MessageBus.INSTANCE.SendMessageUseFirstResult<bool, bool>(this, Event.HAS_CHAT_UNSAVED_CHANGES))
|
||||
{
|
||||
var dialogParameters = new DialogParameters
|
||||
{
|
||||
{ "Message", "Are you sure you want to leave the chat page? All unsaved changes will be lost." },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>("Leave Chat Page", dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
{
|
||||
context.PreventNavigation();
|
||||
return;
|
||||
}
|
||||
|
||||
// User accepted to leave the chat page, reset the chat state:
|
||||
await MessageBus.INSTANCE.SendMessage<bool>(this, Event.RESET_CHAT_STATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@
|
||||
<MudListItem Icon="@Icons.Material.Outlined.Widgets" Text="@MudBlazorVersion"/>
|
||||
<MudListItem Icon="@Icons.Material.Outlined.Memory" Text="@TauriVersion"/>
|
||||
</MudList>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Update" OnClick="() => this.CheckForUpdate()">
|
||||
Check for updates
|
||||
</MudButton>
|
||||
</ExpansionPanel>
|
||||
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.EventNote" HeaderText="Changelog">
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
using System.Reflection;
|
||||
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components.Pages;
|
||||
|
||||
public partial class About : ComponentBase
|
||||
{
|
||||
[Inject]
|
||||
private MessageBus MessageBus { get; init; } = null!;
|
||||
|
||||
private static readonly Assembly ASSEMBLY = Assembly.GetExecutingAssembly();
|
||||
private static readonly MetaDataAttribute META_DATA = ASSEMBLY.GetCustomAttribute<MetaDataAttribute>()!;
|
||||
|
||||
@@ -135,4 +140,9 @@ public partial class About : ComponentBase
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
""";
|
||||
|
||||
private async Task CheckForUpdate()
|
||||
{
|
||||
await this.MessageBus.SendMessage<bool>(this, Event.USER_SEARCH_FOR_UPDATE);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,19 @@
|
||||
@using AIStudio.Chat
|
||||
@using AIStudio.Settings
|
||||
|
||||
<MudText Typo="Typo.h3" Class="mb-2">Chats</MudText>
|
||||
@inherits AIStudio.Tools.MSGComponentBase
|
||||
|
||||
<MudText Typo="Typo.h3" Class="mb-2 mr-3">
|
||||
@if (this.chatThread is not null && this.chatThread.WorkspaceId != Guid.Empty)
|
||||
{
|
||||
@($"Chat in Workspace \"{this.currentWorkspaceName}\"")
|
||||
}
|
||||
else
|
||||
{
|
||||
@("Temporary Chat")
|
||||
}
|
||||
</MudText>
|
||||
|
||||
<MudSelect T="Provider" @bind-Value="@this.selectedProvider" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Apps" Margin="Margin.Dense" Label="Provider" Class="mb-2 rounded-lg" Variant="Variant.Outlined">
|
||||
@foreach (var provider in this.SettingsManager.ConfigurationData.Providers)
|
||||
{
|
||||
@@ -24,5 +36,64 @@
|
||||
<MudPaper Style="flex: 0 0 auto;">
|
||||
<MudTextField T="string" @ref="@this.inputField" @bind-Text="@this.userInput" Variant="Variant.Outlined" AutoGrow="@true" Lines="3" MaxLines="12" Label="@this.InputLabel" Placeholder="@this.ProviderPlaceholder" Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Send" OnAdornmentClick="() => this.SendMessage()" ReadOnly="!this.IsProviderSelected || this.isStreaming" Immediate="@true" OnKeyUp="this.InputKeyEvent" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
|
||||
</MudPaper>
|
||||
<MudPaper Class="mt-1" Outlined="@true">
|
||||
<MudToolBar WrapContent="true">
|
||||
@if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES)
|
||||
{
|
||||
<MudTooltip Text="Your workspaces" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SnippetFolder" OnClick="() => this.ToggleWorkspaces()"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
@if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY)
|
||||
{
|
||||
<MudTooltip Text="Save chat" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Save" OnClick="() => this.SaveThread()" Disabled="@(!this.CanThreadBeSaved)"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
<MudTooltip Text="Start temporary chat" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.AddComment" OnClick="() => this.StartNewChat(useSameWorkspace: false)"/>
|
||||
</MudTooltip>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(this.currentWorkspaceName))
|
||||
{
|
||||
<MudTooltip Text="@this.TooltipAddChatToWorkspace" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.CommentBank" OnClick="() => this.StartNewChat(useSameWorkspace: true)"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
@if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
|
||||
{
|
||||
<MudTooltip Text="Delete this chat & start a new one" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="() => this.StartNewChat(useSameWorkspace: true, deletePreviousChat: true)" Disabled="@(!this.CanThreadBeSaved)"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
@if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES)
|
||||
{
|
||||
<MudTooltip Text="Move chat to (another) workspace" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Disabled="@(!this.CanThreadBeSaved)" OnClick="() => this.MoveChatToWorkspace()"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudToolBar>
|
||||
</MudPaper>
|
||||
</FooterContent>
|
||||
</InnerScrolling>
|
||||
</InnerScrolling>
|
||||
|
||||
@if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior != WorkspaceStorageBehavior.DISABLE_WORKSPACES)
|
||||
{
|
||||
<MudDrawer @bind-Open="@this.workspacesVisible" Width="40em" Height="100%" Anchor="Anchor.Start" Variant="DrawerVariant.Temporary" Elevation="1">
|
||||
<MudDrawerHeader>
|
||||
<MudStack Row="@true" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h6" Class="mr-3">
|
||||
Your workspaces
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close" Variant="Variant.Filled" Color="Color.Default" Size="Size.Small" OnClick="() => this.ToggleWorkspaces()"/>
|
||||
</MudStack>
|
||||
</MudDrawerHeader>
|
||||
<MudDrawerContainer Class="ml-6">
|
||||
<Workspaces @ref="this.workspaces" @bind-CurrentChatThread="@this.chatThread" LoadedChatWasChanged="this.LoadedChatChanged"/>
|
||||
</MudDrawerContainer>
|
||||
</MudDrawer>
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Components.Blocks;
|
||||
using AIStudio.Components.CommonDialogs;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
|
||||
using DialogOptions = AIStudio.Components.CommonDialogs.DialogOptions;
|
||||
|
||||
namespace AIStudio.Components.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// The chat page.
|
||||
/// </summary>
|
||||
public partial class Chat : ComponentBase
|
||||
public partial class Chat : MSGComponentBase, IAsyncDisposable
|
||||
{
|
||||
[Inject]
|
||||
private SettingsManager SettingsManager { get; set; } = null!;
|
||||
@@ -20,13 +25,22 @@ public partial class Chat : ComponentBase
|
||||
|
||||
[Inject]
|
||||
public Random RNG { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
public IDialogService DialogService { get; set; } = null!;
|
||||
|
||||
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Bottom;
|
||||
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
|
||||
|
||||
private AIStudio.Settings.Provider selectedProvider;
|
||||
private ChatThread? chatThread;
|
||||
private bool hasUnsavedChanges;
|
||||
private bool isStreaming;
|
||||
private string userInput = string.Empty;
|
||||
private string currentWorkspaceName = string.Empty;
|
||||
private Guid currentWorkspaceId = Guid.Empty;
|
||||
private bool workspacesVisible;
|
||||
private Workspaces? workspaces;
|
||||
|
||||
// Unfortunately, we need the input field reference to clear it after sending a message.
|
||||
// This is necessary because we have to handle the key events ourselves. Otherwise,
|
||||
@@ -37,17 +51,11 @@ public partial class Chat : ComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Ensure that the settings are loaded:
|
||||
await this.SettingsManager.LoadSettings();
|
||||
this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE ]);
|
||||
|
||||
// Configure the spellchecking for the user input:
|
||||
this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES);
|
||||
|
||||
// For now, we just create a new chat thread.
|
||||
// Later we want the chats to be persisted
|
||||
// across page loads and organize them in
|
||||
// a chat history & workspaces.
|
||||
this.chatThread = new("Thread 1", this.RNG.Next(), "You are a helpful assistant!", []);
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
@@ -59,25 +67,60 @@ public partial class Chat : ComponentBase
|
||||
|
||||
private string InputLabel => this.IsProviderSelected ? $"Your Prompt (use selected instance '{this.selectedProvider.InstanceName}', provider '{this.selectedProvider.UsedProvider.ToName()}')" : "Select a provider first";
|
||||
|
||||
private bool CanThreadBeSaved => this.chatThread is not null && this.chatThread.Blocks.Count > 0;
|
||||
|
||||
private string TooltipAddChatToWorkspace => $"Start new chat in workspace \"{this.currentWorkspaceName}\"";
|
||||
|
||||
private async Task SendMessage()
|
||||
{
|
||||
if (!this.IsProviderSelected)
|
||||
return;
|
||||
|
||||
// Create a new chat thread if necessary:
|
||||
var threadName = this.ExtractThreadName(this.userInput);
|
||||
|
||||
if (this.chatThread is null)
|
||||
{
|
||||
this.chatThread = new()
|
||||
{
|
||||
WorkspaceId = this.currentWorkspaceId,
|
||||
ChatId = Guid.NewGuid(),
|
||||
Name = threadName,
|
||||
Seed = this.RNG.Next(),
|
||||
SystemPrompt = "You are a helpful assistant!",
|
||||
Blocks = [],
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Set the thread name if it is empty:
|
||||
if (string.IsNullOrWhiteSpace(this.chatThread.Name))
|
||||
this.chatThread.Name = threadName;
|
||||
}
|
||||
|
||||
//
|
||||
// Add the user message to the thread:
|
||||
//
|
||||
var time = DateTimeOffset.Now;
|
||||
this.chatThread?.Blocks.Add(new ContentBlock(time, ContentType.TEXT, new ContentText
|
||||
this.chatThread?.Blocks.Add(new ContentBlock
|
||||
{
|
||||
// Text content properties:
|
||||
Text = this.userInput,
|
||||
})
|
||||
{
|
||||
// Content block properties:
|
||||
Time = time,
|
||||
ContentType = ContentType.TEXT,
|
||||
Role = ChatRole.USER,
|
||||
Content = new ContentText
|
||||
{
|
||||
Text = this.userInput,
|
||||
},
|
||||
});
|
||||
|
||||
// Save the chat:
|
||||
if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
|
||||
{
|
||||
await this.SaveThread();
|
||||
this.hasUnsavedChanges = false;
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
//
|
||||
// Add the AI response to the thread:
|
||||
//
|
||||
@@ -88,9 +131,12 @@ public partial class Chat : ComponentBase
|
||||
InitialRemoteWait = true,
|
||||
};
|
||||
|
||||
this.chatThread?.Blocks.Add(new ContentBlock(time, ContentType.TEXT, aiText)
|
||||
this.chatThread?.Blocks.Add(new ContentBlock
|
||||
{
|
||||
Time = time,
|
||||
ContentType = ContentType.TEXT,
|
||||
Role = ChatRole.AI,
|
||||
Content = aiText,
|
||||
});
|
||||
|
||||
// Clear the input field:
|
||||
@@ -99,13 +145,21 @@ public partial class Chat : ComponentBase
|
||||
|
||||
// Enable the stream state for the chat component:
|
||||
this.isStreaming = true;
|
||||
this.hasUnsavedChanges = true;
|
||||
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.selectedProvider.UsedProvider.CreateProvider(this.selectedProvider.InstanceName), this.JsRuntime, this.SettingsManager, this.selectedProvider.Model, this.chatThread);
|
||||
await aiText.CreateFromProviderAsync(this.selectedProvider.UsedProvider.CreateProvider(this.selectedProvider.InstanceName, this.selectedProvider.Hostname), this.JsRuntime, this.SettingsManager, this.selectedProvider.Model, this.chatThread);
|
||||
|
||||
// Save the chat:
|
||||
if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
|
||||
{
|
||||
await this.SaveThread();
|
||||
this.hasUnsavedChanges = false;
|
||||
}
|
||||
|
||||
// Disable the stream state:
|
||||
this.isStreaming = false;
|
||||
this.StateHasChanged();
|
||||
@@ -113,6 +167,7 @@ public partial class Chat : ComponentBase
|
||||
|
||||
private async Task InputKeyEvent(KeyboardEventArgs keyEvent)
|
||||
{
|
||||
this.hasUnsavedChanges = true;
|
||||
var key = keyEvent.Code.ToLowerInvariant();
|
||||
|
||||
// Was the enter key (either enter or numpad enter) pressed?
|
||||
@@ -135,4 +190,216 @@ public partial class Chat : ComponentBase
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleWorkspaces()
|
||||
{
|
||||
this.workspacesVisible = !this.workspacesVisible;
|
||||
}
|
||||
|
||||
private async Task SaveThread()
|
||||
{
|
||||
if(this.workspaces is null)
|
||||
return;
|
||||
|
||||
if(this.chatThread is null)
|
||||
return;
|
||||
|
||||
if (!this.CanThreadBeSaved)
|
||||
return;
|
||||
|
||||
await this.workspaces.StoreChat(this.chatThread);
|
||||
this.hasUnsavedChanges = false;
|
||||
}
|
||||
|
||||
private string ExtractThreadName(string firstUserInput)
|
||||
{
|
||||
// We select the first 10 words of the user input:
|
||||
var words = firstUserInput.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var threadName = string.Join(' ', words.Take(10));
|
||||
|
||||
// If the thread name is empty, we use a default name:
|
||||
if (string.IsNullOrWhiteSpace(threadName))
|
||||
threadName = "Thread";
|
||||
|
||||
return threadName;
|
||||
}
|
||||
|
||||
private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false)
|
||||
{
|
||||
if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges)
|
||||
{
|
||||
var dialogParameters = new DialogParameters
|
||||
{
|
||||
{ "Message", "Are you sure you want to start a new chat? All unsaved changes will be lost." },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>("Delete Chat", dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.chatThread is not null && this.workspaces is not null && deletePreviousChat)
|
||||
{
|
||||
string chatPath;
|
||||
if (this.chatThread.WorkspaceId == Guid.Empty)
|
||||
{
|
||||
chatPath = Path.Join(SettingsManager.DataDirectory, "tempChats", this.chatThread.ChatId.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
chatPath = Path.Join(SettingsManager.DataDirectory, "workspaces", this.chatThread.WorkspaceId.ToString(), this.chatThread.ChatId.ToString());
|
||||
}
|
||||
|
||||
await this.workspaces.DeleteChat(chatPath, askForConfirmation: false, unloadChat: true);
|
||||
}
|
||||
|
||||
this.isStreaming = false;
|
||||
this.hasUnsavedChanges = false;
|
||||
this.userInput = string.Empty;
|
||||
|
||||
if (!useSameWorkspace)
|
||||
{
|
||||
this.chatThread = null;
|
||||
this.currentWorkspaceId = Guid.Empty;
|
||||
this.currentWorkspaceName = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.chatThread = new()
|
||||
{
|
||||
WorkspaceId = this.currentWorkspaceId,
|
||||
ChatId = Guid.NewGuid(),
|
||||
Name = string.Empty,
|
||||
Seed = this.RNG.Next(),
|
||||
SystemPrompt = "You are a helpful assistant!",
|
||||
Blocks = [],
|
||||
};
|
||||
}
|
||||
|
||||
await this.inputField.Clear();
|
||||
}
|
||||
|
||||
private async Task MoveChatToWorkspace()
|
||||
{
|
||||
if(this.chatThread is null)
|
||||
return;
|
||||
|
||||
if(this.workspaces is null)
|
||||
return;
|
||||
|
||||
if (this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges)
|
||||
{
|
||||
var confirmationDialogParameters = new DialogParameters
|
||||
{
|
||||
{ "Message", "Are you sure you want to move this chat? All unsaved changes will be lost." },
|
||||
};
|
||||
|
||||
var confirmationDialogReference = await this.DialogService.ShowAsync<ConfirmDialog>("Unsaved Changes", confirmationDialogParameters, DialogOptions.FULLSCREEN);
|
||||
var confirmationDialogResult = await confirmationDialogReference.Result;
|
||||
if (confirmationDialogResult.Canceled)
|
||||
return;
|
||||
}
|
||||
|
||||
var dialogParameters = new DialogParameters
|
||||
{
|
||||
{ "Message", "Please select the workspace where you want to move the chat to." },
|
||||
{ "SelectedWorkspace", this.chatThread?.WorkspaceId },
|
||||
{ "ConfirmText", "Move chat" },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<WorkspaceSelectionDialog>("Move Chat to Workspace", dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
return;
|
||||
|
||||
var workspaceId = dialogResult.Data is Guid id ? id : default;
|
||||
if (workspaceId == Guid.Empty)
|
||||
return;
|
||||
|
||||
// Delete the chat from the current workspace or the temporary storage:
|
||||
if (this.chatThread!.WorkspaceId == Guid.Empty)
|
||||
{
|
||||
// Case: The chat is stored in the temporary storage:
|
||||
await this.workspaces.DeleteChat(Path.Join(SettingsManager.DataDirectory, "tempChats", this.chatThread.ChatId.ToString()), askForConfirmation: false, unloadChat: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Case: The chat is stored in a workspace.
|
||||
await this.workspaces.DeleteChat(Path.Join(SettingsManager.DataDirectory, "workspaces", this.chatThread.WorkspaceId.ToString(), this.chatThread.ChatId.ToString()), askForConfirmation: false, unloadChat: false);
|
||||
}
|
||||
|
||||
this.chatThread!.WorkspaceId = workspaceId;
|
||||
await this.SaveThread();
|
||||
|
||||
this.currentWorkspaceId = this.chatThread.WorkspaceId;
|
||||
this.currentWorkspaceName = await this.workspaces.LoadWorkspaceName(this.chatThread.WorkspaceId);
|
||||
}
|
||||
|
||||
private async Task LoadedChatChanged()
|
||||
{
|
||||
if(this.workspaces is null)
|
||||
return;
|
||||
|
||||
this.isStreaming = false;
|
||||
this.hasUnsavedChanges = false;
|
||||
this.userInput = string.Empty;
|
||||
this.currentWorkspaceId = this.chatThread?.WorkspaceId ?? Guid.Empty;
|
||||
this.currentWorkspaceName = this.chatThread is null ? string.Empty : await this.workspaces.LoadWorkspaceName(this.chatThread.WorkspaceId);
|
||||
|
||||
await this.inputField.Clear();
|
||||
}
|
||||
|
||||
private void ResetState()
|
||||
{
|
||||
this.isStreaming = false;
|
||||
this.hasUnsavedChanges = false;
|
||||
this.userInput = string.Empty;
|
||||
this.currentWorkspaceId = Guid.Empty;
|
||||
this.currentWorkspaceName = string.Empty;
|
||||
this.chatThread = null;
|
||||
}
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
public override Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
|
||||
{
|
||||
switch (triggeredEvent)
|
||||
{
|
||||
case Event.RESET_CHAT_STATE:
|
||||
this.ResetState();
|
||||
break;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) where TResult : default where TPayload : default
|
||||
{
|
||||
switch (triggeredEvent)
|
||||
{
|
||||
case Event.HAS_CHAT_UNSAVED_CHANGES:
|
||||
if(this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
|
||||
return Task.FromResult((TResult?) (object) false);
|
||||
|
||||
return Task.FromResult((TResult?)(object)this.hasUnsavedChanges);
|
||||
}
|
||||
|
||||
return Task.FromResult(default(TResult));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of IAsyncDisposable
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if(this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
|
||||
{
|
||||
await this.SaveThread();
|
||||
this.hasUnsavedChanges = false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -31,7 +31,7 @@ public partial class Home : ComponentBase
|
||||
private static readonly TextItem[] ITEMS_ADVANTAGES =
|
||||
[
|
||||
new TextItem("Free of charge", "The app is free to use, both for personal and commercial purposes."),
|
||||
new TextItem("Independence", "Users are not tied to any single provider. The initial version supports OpenAI models (like GPT-4o, GPT-4, GPT-4 Turbo, etc.). Future versions will support other providers such as Mistral or Google Gemini."),
|
||||
new TextItem("Independence", "You are not tied to any single provider. Instead, you might choose the provider that best suits your needs. Right now, we support OpenAI (GPT4o etc.), Mistral, Anthropic (Claude), and self-hosted models using llama.cpp or LM Studio. Support for Google Gemini, ollama, Replicate, and Fireworks is planned."),
|
||||
new TextItem("Unrestricted usage", "Unlike services like ChatGPT, which impose limits after intensive use, MindWork AI Studio offers unlimited usage through the providers API."),
|
||||
new TextItem("Cost-effective", "You only pay for what you use, which can be cheaper than monthly subscription services like ChatGPT Plus, especially if used infrequently. But beware, here be dragons: For extremely intensive usage, the API costs can be significantly higher. Unfortunately, providers currently do not offer a way to display current costs in the app. Therefore, check your account with the respective provider to see how your costs are developing. When available, use prepaid and set a cost limit."),
|
||||
new TextItem("Privacy", "The data entered into the app is not used for training by the providers since we are using the provider's API."),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@page "/settings"
|
||||
@using AIStudio.Provider
|
||||
|
||||
<MudText Typo="Typo.h3" Class="mb-12">Settings</MudText>
|
||||
|
||||
@@ -11,7 +12,7 @@
|
||||
<col style="width: 12em;"/>
|
||||
<col style="width: 12em;"/>
|
||||
<col/>
|
||||
<col style="width: 20em;"/>
|
||||
<col style="width: 34em;"/>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>#</MudTh>
|
||||
@@ -24,12 +25,20 @@
|
||||
<MudTd>@context.Num</MudTd>
|
||||
<MudTd>@context.InstanceName</MudTd>
|
||||
<MudTd>@context.UsedProvider</MudTd>
|
||||
<MudTd>@context.Model</MudTd>
|
||||
<MudTd>
|
||||
@if(context.UsedProvider is not Providers.SELF_HOSTED)
|
||||
@context.Model
|
||||
else
|
||||
@("as selected by provider")
|
||||
</MudTd>
|
||||
<MudTd Style="text-align: left;">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Edit" Class="mr-2" OnClick="() => this.EditProvider(context)">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.OpenInBrowser" Class="ma-2" Href="@this.GetProviderDashboardURL(context.UsedProvider)" Target="_blank" Disabled="@(context.UsedProvider is Providers.NONE or Providers.SELF_HOSTED)">
|
||||
Open Dashboard
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Edit" Class="ma-2" OnClick="() => this.EditProvider(context)">
|
||||
Edit
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Error" StartIcon="@Icons.Material.Filled.Delete" Class="mr-2" OnClick="() => this.DeleteProvider(context)">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Error" StartIcon="@Icons.Material.Filled.Delete" Class="ma-2" OnClick="() => this.DeleteProvider(context)">
|
||||
Delete
|
||||
</MudButton>
|
||||
</MudTd>
|
||||
@@ -52,5 +61,8 @@
|
||||
<ConfigurationOption OptionDescription="Save energy?" LabelOn="Energy saving is enabled" LabelOff="Energy saving is disabled" State="@(() => this.SettingsManager.ConfigurationData.IsSavingEnergy)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.IsSavingEnergy = updatedState)" OptionHelp="When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available."/>
|
||||
<ConfigurationOption OptionDescription="Enable spellchecking?" LabelOn="Spellchecking is enabled" LabelOff="Spellchecking is disabled" State="@(() => this.SettingsManager.ConfigurationData.EnableSpellchecking)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.EnableSpellchecking = updatedState)" OptionHelp="When enabled, spellchecking will be active in all input fields. Depending on your operating system, errors may not be visually highlighted, but right-clicking may still offer possible corrections." />
|
||||
<ConfigurationSelect OptionDescription="Shortcut to send input" SelectedValue="@(() => this.SettingsManager.ConfigurationData.ShortcutSendBehavior)" Data="@ConfigurationSelectDataFactory.GetSendBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.ShortcutSendBehavior = selectedValue)" OptionHelp="Do you want to use any shortcut to send your input?"/>
|
||||
<ConfigurationSelect OptionDescription="Check for updates" SelectedValue="@(() => this.SettingsManager.ConfigurationData.UpdateBehavior)" Data="@ConfigurationSelectDataFactory.GetUpdateBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.UpdateBehavior = selectedValue)" OptionHelp="How often should we check for app updates?"/>
|
||||
<ConfigurationSelect OptionDescription="Workspace behavior" SelectedValue="@(() => this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior)" Data="@ConfigurationSelectDataFactory.GetWorkspaceStorageBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.WorkspaceStorageBehavior = selectedValue)" OptionHelp="Should we store your chats?"/>
|
||||
<ConfigurationSelect OptionDescription="Workspace maintenance" SelectedValue="@(() => this.SettingsManager.ConfigurationData.WorkspaceStorageTemporaryMaintenancePolicy)" Data="@ConfigurationSelectDataFactory.GetWorkspaceStorageTemporaryMaintenancePolicyData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.WorkspaceStorageTemporaryMaintenancePolicy = selectedValue)" OptionHelp="If and when should we delete your temporary chats?"/>
|
||||
</MudPaper>
|
||||
</InnerScrolling>
|
||||
@@ -3,6 +3,8 @@ using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
using DialogOptions = AIStudio.Components.CommonDialogs.DialogOptions;
|
||||
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
|
||||
namespace AIStudio.Components.Pages;
|
||||
@@ -18,22 +20,6 @@ public partial class Settings : ComponentBase
|
||||
[Inject]
|
||||
public IJSRuntime JsRuntime { get; init; } = null!;
|
||||
|
||||
private static readonly DialogOptions DIALOG_OPTIONS = new()
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
FullWidth = true, MaxWidth = MaxWidth.Medium,
|
||||
};
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await this.SettingsManager.LoadSettings();
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Provider related
|
||||
|
||||
private async Task AddProvider()
|
||||
@@ -43,7 +29,7 @@ public partial class Settings : ComponentBase
|
||||
{ x => x.IsEditing, false },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ProviderDialog>("Add Provider", dialogParameters, DIALOG_OPTIONS);
|
||||
var dialogReference = await this.DialogService.ShowAsync<ProviderDialog>("Add Provider", dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
return;
|
||||
@@ -64,10 +50,12 @@ public partial class Settings : ComponentBase
|
||||
{ x => x.DataInstanceName, provider.InstanceName },
|
||||
{ x => x.DataProvider, provider.UsedProvider },
|
||||
{ x => x.DataModel, provider.Model },
|
||||
{ x => x.DataHostname, provider.Hostname },
|
||||
{ x => x.IsSelfHosted, provider.IsSelfHosted },
|
||||
{ x => x.IsEditing, true },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ProviderDialog>("Edit Provider", dialogParameters, DIALOG_OPTIONS);
|
||||
var dialogReference = await this.DialogService.ShowAsync<ProviderDialog>("Edit Provider", dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
return;
|
||||
@@ -90,12 +78,12 @@ public partial class Settings : ComponentBase
|
||||
{ "Message", $"Are you sure you want to delete the provider '{provider.InstanceName}'?" },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>("Delete Provider", dialogParameters, DIALOG_OPTIONS);
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>("Delete Provider", dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult.Canceled)
|
||||
return;
|
||||
|
||||
var providerInstance = provider.UsedProvider.CreateProvider(provider.InstanceName);
|
||||
var providerInstance = provider.UsedProvider.CreateProvider(provider.InstanceName, provider.Hostname);
|
||||
var deleteSecretResponse = await this.SettingsManager.DeleteAPIKey(this.JsRuntime, providerInstance);
|
||||
if(deleteSecretResponse.Success)
|
||||
{
|
||||
@@ -103,6 +91,15 @@ public partial class Settings : ComponentBase
|
||||
await this.SettingsManager.StoreSettings();
|
||||
}
|
||||
}
|
||||
|
||||
private string GetProviderDashboardURL(Providers provider) => provider switch
|
||||
{
|
||||
Providers.OPEN_AI => "https://platform.openai.com/usage",
|
||||
Providers.MISTRAL => "https://console.mistral.ai/usage/",
|
||||
Providers.ANTHROPIC => "https://console.anthropic.com/settings/plans",
|
||||
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -25,10 +25,13 @@ builder.Services.AddMudServices(config =>
|
||||
});
|
||||
|
||||
builder.Services.AddMudMarkdownServices();
|
||||
builder.Services.AddSingleton(MessageBus.INSTANCE);
|
||||
builder.Services.AddSingleton<Rust>();
|
||||
builder.Services.AddMudMarkdownClipboardService<MarkdownClipboardService>();
|
||||
builder.Services.AddSingleton<SettingsManager>();
|
||||
builder.Services.AddSingleton<Random>();
|
||||
builder.Services.AddHostedService<UpdateService>();
|
||||
builder.Services.AddHostedService<TemporaryChatService>();
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents()
|
||||
.AddHubOptions(options =>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using AIStudio.Provider.OpenAI;
|
||||
|
||||
namespace AIStudio.Provider.Anthropic;
|
||||
|
||||
/// <summary>
|
||||
/// The Anthropic chat request model.
|
||||
/// </summary>
|
||||
/// <param name="Model">Which model to use for chat completion.</param>
|
||||
/// <param name="Messages">The chat messages.</param>
|
||||
/// <param name="MaxTokens">The maximum number of tokens to generate.</param>
|
||||
/// <param name="Stream">Whether to stream the chat completion.</param>
|
||||
/// <param name="System">The system prompt for the chat completion.</param>
|
||||
public readonly record struct ChatRequest(
|
||||
string Model,
|
||||
IList<Message> Messages,
|
||||
int MaxTokens,
|
||||
bool Stream,
|
||||
string System
|
||||
);
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Provider.OpenAI;
|
||||
using AIStudio.Settings;
|
||||
|
||||
namespace AIStudio.Provider.Anthropic;
|
||||
|
||||
public sealed class ProviderAnthropic() : BaseProvider("https://api.anthropic.com/v1/"), IProvider
|
||||
{
|
||||
private static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
};
|
||||
|
||||
#region Implementation of IProvider
|
||||
|
||||
public string Id => "Anthropic";
|
||||
|
||||
public string InstanceName { get; set; } = "Anthropic";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<string> StreamChatCompletion(IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default)
|
||||
{
|
||||
// Get the API key:
|
||||
var requestedSecret = await settings.GetAPIKey(jsRuntime, this);
|
||||
if(!requestedSecret.Success)
|
||||
yield break;
|
||||
|
||||
// Prepare the Anthropic HTTP chat request:
|
||||
var chatRequest = JsonSerializer.Serialize(new ChatRequest
|
||||
{
|
||||
Model = chatModel.Id,
|
||||
|
||||
// Build the messages:
|
||||
Messages = [..chatThread.Blocks.Where(n => n.ContentType is ContentType.TEXT && !string.IsNullOrWhiteSpace((n.Content as ContentText)?.Text)).Select(n => new Message
|
||||
{
|
||||
Role = n.Role switch
|
||||
{
|
||||
ChatRole.USER => "user",
|
||||
ChatRole.AI => "assistant",
|
||||
|
||||
_ => "user",
|
||||
},
|
||||
|
||||
Content = n.Content switch
|
||||
{
|
||||
ContentText text => text.Text,
|
||||
_ => string.Empty,
|
||||
}
|
||||
}).ToList()],
|
||||
|
||||
System = chatThread.SystemPrompt,
|
||||
MaxTokens = 4_096,
|
||||
|
||||
// Right now, we only support streaming completions:
|
||||
Stream = true,
|
||||
}, JSON_SERIALIZER_OPTIONS);
|
||||
|
||||
// Build the HTTP post request:
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "messages");
|
||||
|
||||
// Set the authorization header:
|
||||
request.Headers.Add("x-api-key", requestedSecret.Secret);
|
||||
|
||||
// Set the Anthropic version:
|
||||
request.Headers.Add("anthropic-version", "2023-06-01");
|
||||
|
||||
// Set the content:
|
||||
request.Content = new StringContent(chatRequest, Encoding.UTF8, "application/json");
|
||||
|
||||
// Send the request with the ResponseHeadersRead option.
|
||||
// This allows us to read the stream as soon as the headers are received.
|
||||
// This is important because we want to stream the responses.
|
||||
var response = await this.httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
|
||||
|
||||
// Open the response stream:
|
||||
var stream = await 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)
|
||||
{
|
||||
// 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;
|
||||
|
||||
// Check for the end of the stream:
|
||||
if(line.StartsWith("event: message_stop", StringComparison.InvariantCulture))
|
||||
yield break;
|
||||
|
||||
// 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;
|
||||
|
||||
// Ignore any type except "content_block_delta":
|
||||
if(!line.Contains("\"content_block_delta\"", StringComparison.InvariantCulture))
|
||||
continue;
|
||||
|
||||
ResponseStreamLine anthropicResponse;
|
||||
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:
|
||||
anthropicResponse = JsonSerializer.Deserialize<ResponseStreamLine>(jsonData, JSON_SERIALIZER_OPTIONS);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip invalid JSON data:
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty responses:
|
||||
if(anthropicResponse == default || string.IsNullOrWhiteSpace(anthropicResponse.Delta.Text))
|
||||
continue;
|
||||
|
||||
// Yield the response:
|
||||
yield return anthropicResponse.Delta.Text;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ImageURL> StreamImageCompletion(IJSRuntime jsRuntime, SettingsManager settings, Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IEnumerable<Model>> GetTextModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
return Task.FromResult(new[]
|
||||
{
|
||||
new Model("claude-3-5-sonnet-20240620"),
|
||||
new Model("claude-3-opus-20240229"),
|
||||
new Model("claude-3-sonnet-20240229"),
|
||||
new Model("claude-3-haiku-20240307"),
|
||||
}.AsEnumerable());
|
||||
}
|
||||
|
||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
/// <inheritdoc />
|
||||
public Task<IEnumerable<Model>> GetImageModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
return Task.FromResult(Enumerable.Empty<Model>());
|
||||
}
|
||||
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||
namespace AIStudio.Provider.Anthropic;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a response stream line.
|
||||
/// </summary>
|
||||
/// <param name="Type">The type of the response line.</param>
|
||||
/// <param name="Index">The index of the response line.</param>
|
||||
/// <param name="Delta">The delta of the response line.</param>
|
||||
public readonly record struct ResponseStreamLine(string Type, int Index, Delta Delta);
|
||||
|
||||
/// <summary>
|
||||
/// The delta object of a response line.
|
||||
/// </summary>
|
||||
/// <param name="Type">The type of the delta.</param>
|
||||
/// <param name="Text">The text of the delta.</param>
|
||||
public readonly record struct Delta(string Type, string Text);
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace AIStudio.Provider.Mistral;
|
||||
|
||||
/// <summary>
|
||||
/// The OpenAI chat request model.
|
||||
/// </summary>
|
||||
/// <param name="Model">Which model to use for chat completion.</param>
|
||||
/// <param name="Messages">The chat messages.</param>
|
||||
/// <param name="Stream">Whether to stream the chat completion.</param>
|
||||
/// <param name="RandomSeed">The seed for the chat completion.</param>
|
||||
/// <param name="SafePrompt">Whether to inject a safety prompt before all conversations.</param>
|
||||
public readonly record struct ChatRequest(
|
||||
string Model,
|
||||
IList<RegularMessage> Messages,
|
||||
bool Stream,
|
||||
int RandomSeed,
|
||||
bool SafePrompt = false
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace AIStudio.Provider.Mistral;
|
||||
|
||||
public readonly record struct ModelsResponse(string Object, Model[] Data);
|
||||
|
||||
public readonly record struct Model(string Id, string Object, int Created, string OwnedBy);
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Provider.OpenAI;
|
||||
using AIStudio.Settings;
|
||||
|
||||
namespace AIStudio.Provider.Mistral;
|
||||
|
||||
public sealed class ProviderMistral() : BaseProvider("https://api.mistral.ai/v1/"), IProvider
|
||||
{
|
||||
private static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
};
|
||||
|
||||
#region Implementation of IProvider
|
||||
|
||||
public string Id => "Mistral";
|
||||
|
||||
public string InstanceName { get; set; } = "Mistral";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<string> StreamChatCompletion(IJSRuntime jsRuntime, SettingsManager settings, Provider.Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default)
|
||||
{
|
||||
// Get the API key:
|
||||
var requestedSecret = await settings.GetAPIKey(jsRuntime, this);
|
||||
if(!requestedSecret.Success)
|
||||
yield break;
|
||||
|
||||
// Prepare the system prompt:
|
||||
var systemPrompt = new RegularMessage
|
||||
{
|
||||
Role = "system",
|
||||
Content = chatThread.SystemPrompt,
|
||||
};
|
||||
|
||||
// Prepare the Mistral HTTP chat request:
|
||||
var mistralChatRequest = JsonSerializer.Serialize(new ChatRequest
|
||||
{
|
||||
Model = chatModel.Id,
|
||||
|
||||
// Build the messages:
|
||||
// - First of all the system prompt
|
||||
// - Then none-empty user and AI messages
|
||||
Messages = [systemPrompt, ..chatThread.Blocks.Where(n => n.ContentType is ContentType.TEXT && !string.IsNullOrWhiteSpace((n.Content as ContentText)?.Text)).Select(n => new RegularMessage
|
||||
{
|
||||
Role = n.Role switch
|
||||
{
|
||||
ChatRole.USER => "user",
|
||||
ChatRole.AI => "assistant",
|
||||
ChatRole.SYSTEM => "system",
|
||||
|
||||
_ => "user",
|
||||
},
|
||||
|
||||
Content = n.Content switch
|
||||
{
|
||||
ContentText text => text.Text,
|
||||
_ => string.Empty,
|
||||
}
|
||||
}).ToList()],
|
||||
|
||||
RandomSeed = chatThread.Seed,
|
||||
|
||||
// Right now, we only support streaming completions:
|
||||
Stream = true,
|
||||
SafePrompt = false,
|
||||
}, JSON_SERIALIZER_OPTIONS);
|
||||
|
||||
// Build the HTTP post request:
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");
|
||||
|
||||
// Set the authorization header:
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", requestedSecret.Secret);
|
||||
|
||||
// Set the content:
|
||||
request.Content = new StringContent(mistralChatRequest, Encoding.UTF8, "application/json");
|
||||
|
||||
// Send the request with the ResponseHeadersRead option.
|
||||
// This allows us to read the stream as soon as the headers are received.
|
||||
// This is important because we want to stream the responses.
|
||||
var response = await this.httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
|
||||
|
||||
// Open the response stream:
|
||||
var mistralStream = await 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)
|
||||
{
|
||||
// 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 mistralResponse;
|
||||
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:
|
||||
mistralResponse = JsonSerializer.Deserialize<ResponseStreamLine>(jsonData, JSON_SERIALIZER_OPTIONS);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip invalid JSON data:
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty responses:
|
||||
if(mistralResponse == default || mistralResponse.Choices.Count == 0)
|
||||
continue;
|
||||
|
||||
// Yield the response:
|
||||
yield return mistralResponse.Choices[0].Delta.Content;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ImageURL> StreamImageCompletion(IJSRuntime jsRuntime, SettingsManager settings, Provider.Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<Provider.Model>> GetTextModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
var secretKey = apiKeyProvisional switch
|
||||
{
|
||||
not null => apiKeyProvisional,
|
||||
_ => await settings.GetAPIKey(jsRuntime, this) switch
|
||||
{
|
||||
{ Success: true } result => result.Secret,
|
||||
_ => null,
|
||||
}
|
||||
};
|
||||
|
||||
if (secretKey is null)
|
||||
return [];
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "models");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey);
|
||||
|
||||
var response = await this.httpClient.SendAsync(request, token);
|
||||
if(!response.IsSuccessStatusCode)
|
||||
return [];
|
||||
|
||||
var modelResponse = await response.Content.ReadFromJsonAsync<ModelsResponse>(token);
|
||||
return modelResponse.Data.Where(n =>
|
||||
!n.Id.StartsWith("code", StringComparison.InvariantCulture) &&
|
||||
!n.Id.Contains("embed", StringComparison.InvariantCulture))
|
||||
.Select(n => new Provider.Model(n.Id));
|
||||
}
|
||||
|
||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
/// <inheritdoc />
|
||||
public Task<IEnumerable<Provider.Model>> GetImageModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
return Task.FromResult(Enumerable.Empty<Provider.Model>());
|
||||
}
|
||||
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Provider.Mistral;
|
||||
|
||||
/// <summary>
|
||||
/// Regulat chat message model.
|
||||
/// </summary>
|
||||
/// <param name="Content">The text content of the message.</param>
|
||||
/// <param name="Role">The role of the message.</param>
|
||||
public readonly record struct RegularMessage(string Content, string Role);
|
||||
File renamed without changes.
@@ -1,4 +1,7 @@
|
||||
using AIStudio.Provider.Anthropic;
|
||||
using AIStudio.Provider.Mistral;
|
||||
using AIStudio.Provider.OpenAI;
|
||||
using AIStudio.Provider.SelfHosted;
|
||||
|
||||
namespace AIStudio.Provider;
|
||||
|
||||
@@ -8,7 +11,12 @@ namespace AIStudio.Provider;
|
||||
public enum Providers
|
||||
{
|
||||
NONE,
|
||||
|
||||
OPEN_AI,
|
||||
ANTHROPIC,
|
||||
MISTRAL,
|
||||
|
||||
SELF_HOSTED,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -23,9 +31,14 @@ public static class ExtensionsProvider
|
||||
/// <returns>The human-readable name of the provider.</returns>
|
||||
public static string ToName(this Providers provider) => provider switch
|
||||
{
|
||||
Providers.OPEN_AI => "OpenAI",
|
||||
|
||||
Providers.NONE => "No provider selected",
|
||||
|
||||
Providers.OPEN_AI => "OpenAI",
|
||||
Providers.ANTHROPIC => "Anthropic",
|
||||
Providers.MISTRAL => "Mistral",
|
||||
|
||||
Providers.SELF_HOSTED => "Self-hosted",
|
||||
|
||||
_ => "Unknown",
|
||||
};
|
||||
|
||||
@@ -34,10 +47,15 @@ public static class ExtensionsProvider
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider value.</param>
|
||||
/// <param name="instanceName">The used instance name.</param>
|
||||
/// <param name="hostname">The hostname of the provider.</param>
|
||||
/// <returns>The provider instance.</returns>
|
||||
public static IProvider CreateProvider(this Providers provider, string instanceName) => provider switch
|
||||
public static IProvider CreateProvider(this Providers provider, string instanceName, string hostname = "http://localhost:1234") => provider switch
|
||||
{
|
||||
Providers.OPEN_AI => new ProviderOpenAI { InstanceName = instanceName },
|
||||
Providers.ANTHROPIC => new ProviderAnthropic { InstanceName = instanceName },
|
||||
Providers.MISTRAL => new ProviderMistral { InstanceName = instanceName },
|
||||
|
||||
Providers.SELF_HOSTED => new ProviderSelfHosted(hostname) { InstanceName = instanceName },
|
||||
|
||||
_ => new NoProvider(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace AIStudio.Provider.SelfHosted;
|
||||
|
||||
/// <summary>
|
||||
/// The chat request model.
|
||||
/// </summary>
|
||||
/// <param name="Model">Which model to use for chat completion.</param>
|
||||
/// <param name="Messages">The chat messages.</param>
|
||||
/// <param name="Stream">Whether to stream the chat completion.</param>
|
||||
/// <param name="MaxTokens">The maximum number of tokens to generate.</param>
|
||||
public readonly record struct ChatRequest(
|
||||
string Model,
|
||||
IList<Message> Messages,
|
||||
bool Stream,
|
||||
|
||||
int MaxTokens
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Provider.SelfHosted;
|
||||
|
||||
/// <summary>
|
||||
/// Chat message model.
|
||||
/// </summary>
|
||||
/// <param name="Content">The text content of the message.</param>
|
||||
/// <param name="Role">The role of the message.</param>
|
||||
public readonly record struct Message(string Content, string Role);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace AIStudio.Provider.SelfHosted;
|
||||
|
||||
public readonly record struct ModelsResponse(string Object, Model[] Data);
|
||||
|
||||
public readonly record struct Model(string Id, string Object, string OwnedBy);
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Provider.OpenAI;
|
||||
using AIStudio.Settings;
|
||||
|
||||
namespace AIStudio.Provider.SelfHosted;
|
||||
|
||||
public sealed class ProviderSelfHosted(string hostname) : BaseProvider($"{hostname}/v1/"), IProvider
|
||||
{
|
||||
private static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
};
|
||||
|
||||
#region Implementation of IProvider
|
||||
|
||||
public string Id => "Self-hosted";
|
||||
|
||||
public string InstanceName { get; set; } = "Self-hosted";
|
||||
|
||||
public async IAsyncEnumerable<string> StreamChatCompletion(IJSRuntime jsRuntime, SettingsManager settings, Provider.Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default)
|
||||
{
|
||||
// Prepare the system prompt:
|
||||
var systemPrompt = new Message
|
||||
{
|
||||
Role = "system",
|
||||
Content = chatThread.SystemPrompt,
|
||||
};
|
||||
|
||||
// Prepare the OpenAI HTTP chat request:
|
||||
var providerChatRequest = JsonSerializer.Serialize(new ChatRequest
|
||||
{
|
||||
Model = (await this.GetTextModels(jsRuntime, settings, token: token)).First().Id,
|
||||
|
||||
// Build the messages:
|
||||
// - First of all the system prompt
|
||||
// - Then none-empty user and AI messages
|
||||
Messages = [systemPrompt, ..chatThread.Blocks.Where(n => n.ContentType is ContentType.TEXT && !string.IsNullOrWhiteSpace((n.Content as ContentText)?.Text)).Select(n => new Message
|
||||
{
|
||||
Role = n.Role switch
|
||||
{
|
||||
ChatRole.USER => "user",
|
||||
ChatRole.AI => "assistant",
|
||||
ChatRole.SYSTEM => "system",
|
||||
|
||||
_ => "user",
|
||||
},
|
||||
|
||||
Content = n.Content switch
|
||||
{
|
||||
ContentText text => text.Text,
|
||||
_ => string.Empty,
|
||||
}
|
||||
}).ToList()],
|
||||
|
||||
// Right now, we only support streaming completions:
|
||||
Stream = true,
|
||||
MaxTokens = -1,
|
||||
}, JSON_SERIALIZER_OPTIONS);
|
||||
|
||||
// Build the HTTP post request:
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");
|
||||
|
||||
// Set the content:
|
||||
request.Content = new StringContent(providerChatRequest, Encoding.UTF8, "application/json");
|
||||
|
||||
// Send the request with the ResponseHeadersRead option.
|
||||
// This allows us to read the stream as soon as the headers are received.
|
||||
// This is important because we want to stream the responses.
|
||||
var response = await this.httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
|
||||
|
||||
// Open the response stream:
|
||||
var providerStream = await response.Content.ReadAsStreamAsync(token);
|
||||
|
||||
// Add a stream reader to read the stream, line by line:
|
||||
var streamReader = new StreamReader(providerStream);
|
||||
|
||||
// Read the stream, line by line:
|
||||
while(!streamReader.EndOfStream)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ImageURL> StreamImageCompletion(IJSRuntime jsRuntime, SettingsManager settings, Provider.Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
|
||||
|
||||
public async Task<IEnumerable<Provider.Model>> GetTextModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "models");
|
||||
var response = await this.httpClient.SendAsync(request, token);
|
||||
if(!response.IsSuccessStatusCode)
|
||||
return [];
|
||||
|
||||
var modelResponse = await response.Content.ReadFromJsonAsync<ModelsResponse>(token);
|
||||
if (modelResponse.Data.Length > 1)
|
||||
Console.WriteLine("Warning: multiple models found; using the first one.");
|
||||
|
||||
var firstModel = modelResponse.Data.First();
|
||||
return [ new Provider.Model(firstModel.Id) ];
|
||||
}
|
||||
|
||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
/// <inheritdoc />
|
||||
public Task<IEnumerable<Provider.Model>> GetImageModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
return Task.FromResult(Enumerable.Empty<Provider.Model>());
|
||||
}
|
||||
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -6,10 +6,10 @@ namespace AIStudio.Settings;
|
||||
public sealed class Data
|
||||
{
|
||||
/// <summary>
|
||||
/// The version of the settings file. Allows us to upgrade the settings,
|
||||
/// The version of the settings file. Allows us to upgrade the settings
|
||||
/// when a new version is available.
|
||||
/// </summary>
|
||||
public Version Version { get; init; } = Version.V1;
|
||||
public Version Version { get; init; } = Version.V2;
|
||||
|
||||
/// <summary>
|
||||
/// List of configured providers.
|
||||
@@ -36,4 +36,19 @@ public sealed class Data
|
||||
/// Should we enable spellchecking for all input fields?
|
||||
/// </summary>
|
||||
public bool EnableSpellchecking { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If and when we should look for updates.
|
||||
/// </summary>
|
||||
public UpdateBehavior UpdateBehavior { get; set; } = UpdateBehavior.ONCE_STARTUP;
|
||||
|
||||
/// <summary>
|
||||
/// The chat storage behavior.
|
||||
/// </summary>
|
||||
public WorkspaceStorageBehavior WorkspaceStorageBehavior { get; set; } = WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY;
|
||||
|
||||
/// <summary>
|
||||
/// The chat storage maintenance behavior.
|
||||
/// </summary>
|
||||
public WorkspaceStorageTemporaryMaintenancePolicy WorkspaceStorageTemporaryMaintenancePolicy { get; set; } = WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_90_DAYS;
|
||||
}
|
||||
@@ -9,8 +9,10 @@ namespace AIStudio.Settings;
|
||||
/// <param name="Id">The provider's ID.</param>
|
||||
/// <param name="InstanceName">The provider's instance name. Useful for multiple instances of the same provider, e.g., to distinguish between different OpenAI API keys.</param>
|
||||
/// <param name="UsedProvider">The provider used.</param>
|
||||
/// <param name="IsSelfHosted">Whether the provider is self-hosted.</param>
|
||||
/// <param name="Hostname">The hostname of the provider. Useful for self-hosted providers.</param>
|
||||
/// <param name="Model">The LLM model to use for chat.</param>
|
||||
public readonly record struct Provider(uint Num, string Id, string InstanceName, Providers UsedProvider, Model Model)
|
||||
public readonly record struct Provider(uint Num, string Id, string InstanceName, Providers UsedProvider, Model Model, bool IsSelfHosted = false, string Hostname = "http://localhost:1234")
|
||||
{
|
||||
#region Overrides of ValueType
|
||||
|
||||
@@ -21,6 +23,9 @@ public readonly record struct Provider(uint Num, string Id, string InstanceName,
|
||||
/// <returns>A string that represents the current provider in a human-readable format.</returns>
|
||||
public override string ToString()
|
||||
{
|
||||
if(this.IsSelfHosted)
|
||||
return $"{this.InstanceName} ({this.UsedProvider.ToName()}, {this.Hostname}, {this.Model})";
|
||||
|
||||
return $"{this.InstanceName} ({this.UsedProvider.ToName()}, {this.Model})";
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,53 @@
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues">
|
||||
<MudStack Row="@true" AlignItems="AlignItems.Center">
|
||||
@* ReSharper disable once CSharpWarnings::CS8974 *@
|
||||
<MudSelect @bind-Value="@this.DataProvider" Label="Provider" Class="mb-3" OpenIcon="@Icons.Material.Filled.AccountBalance" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.ValidatingProvider">
|
||||
@foreach (Providers provider in Enum.GetValues(typeof(Providers)))
|
||||
{
|
||||
<MudSelectItem Value="@provider">@provider</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Disabled="@this.IsSelfHostedOrNone" Variant="Variant.Filled" Size="Size.Small" StartIcon="@Icons.Material.Filled.OpenInBrowser" Href="@this.GetProviderCreationURL()" Target="_blank">Create account</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@* ReSharper disable once CSharpWarnings::CS8974 *@
|
||||
<MudTextField
|
||||
T="string"
|
||||
@bind-Text="@this.dataAPIKey"
|
||||
Label="API Key"
|
||||
Disabled="@this.IsSelfHostedOrNone"
|
||||
Class="mb-3"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.VpnKey"
|
||||
AdornmentColor="Color.Info"
|
||||
InputType="InputType.Password"
|
||||
Validation="@this.ValidatingAPIKey"
|
||||
/>
|
||||
|
||||
<MudTextField
|
||||
T="string"
|
||||
@bind-Text="@this.DataHostname"
|
||||
Label="Hostname"
|
||||
Disabled="@this.IsCloudProvider"
|
||||
Class="mb-3"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Dns"
|
||||
AdornmentColor="Color.Info"
|
||||
Validation="@this.ValidatingHostname"
|
||||
/>
|
||||
|
||||
<MudStack Row="@true" AlignItems="AlignItems.Center">
|
||||
<MudButton Disabled="@(!this.CanLoadModels)" Variant="Variant.Filled" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="this.ReloadModels">Load</MudButton>
|
||||
<MudSelect Disabled="@this.IsSelfHostedOrNone" @bind-Value="@this.DataModel" Label="Model" Class="mb-3" OpenIcon="@Icons.Material.Filled.FaceRetouchingNatural" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.ValidatingModel">
|
||||
@foreach (var model in this.availableModels)
|
||||
{
|
||||
<MudSelectItem Value="@model">@model</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
|
||||
@* ReSharper disable once CSharpWarnings::CS8974 *@
|
||||
<MudTextField
|
||||
T="string"
|
||||
@@ -17,37 +64,6 @@
|
||||
UserAttributes="@INSTANCE_NAME_ATTRIBUTES"
|
||||
/>
|
||||
|
||||
@* ReSharper disable once CSharpWarnings::CS8974 *@
|
||||
<MudSelect @bind-Value="@this.DataProvider" Label="Provider" Class="mb-3" OpenIcon="@Icons.Material.Filled.AccountBalance" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.ValidatingProvider">
|
||||
@foreach (Providers provider in Enum.GetValues(typeof(Providers)))
|
||||
{
|
||||
<MudSelectItem Value="@provider">@provider</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
@* ReSharper disable once CSharpWarnings::CS8974 *@
|
||||
<MudTextField
|
||||
T="string"
|
||||
@bind-Text="@this.dataAPIKey"
|
||||
Label="API Key"
|
||||
Class="mb-3"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.VpnKey"
|
||||
AdornmentColor="Color.Info"
|
||||
InputType="InputType.Password"
|
||||
Validation="@this.ValidatingAPIKey"
|
||||
/>
|
||||
|
||||
<MudStack Row="@true" AlignItems="AlignItems.Center">
|
||||
<MudButton Disabled="@(!this.CanLoadModels)" Variant="Variant.Filled" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="this.ReloadModels">Reload</MudButton>
|
||||
<MudSelect @bind-Value="@this.DataModel" Label="Model" Class="mb-3" OpenIcon="@Icons.Material.Filled.FaceRetouchingNatural" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.ValidatingModel">
|
||||
@foreach (var model in this.availableModels)
|
||||
{
|
||||
<MudSelectItem Value="@model">@model</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
|
||||
</MudForm>
|
||||
|
||||
@if (this.dataIssues.Any())
|
||||
|
||||
@@ -32,6 +32,18 @@ public partial class ProviderDialog : ComponentBase
|
||||
[Parameter]
|
||||
public string DataInstanceName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The chosen hostname for self-hosted providers.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string DataHostname { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Is this provider self-hosted?
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool IsSelfHosted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The provider to use.
|
||||
/// </summary>
|
||||
@@ -99,7 +111,8 @@ public partial class ProviderDialog : ComponentBase
|
||||
this.dataAPIKey = requestedSecret.Secret;
|
||||
|
||||
// Now, we try to load the list of available models:
|
||||
await this.ReloadModels();
|
||||
if(this.DataProvider is not Providers.SELF_HOSTED)
|
||||
await this.ReloadModels();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -142,6 +155,8 @@ public partial class ProviderDialog : ComponentBase
|
||||
InstanceName = this.DataInstanceName,
|
||||
UsedProvider = this.DataProvider,
|
||||
Model = this.DataModel,
|
||||
IsSelfHosted = this.DataProvider is Providers.SELF_HOSTED,
|
||||
Hostname = this.DataHostname,
|
||||
};
|
||||
|
||||
// We need to instantiate the provider to store the API key:
|
||||
@@ -169,33 +184,49 @@ public partial class ProviderDialog : ComponentBase
|
||||
|
||||
private string? ValidatingModel(Model model)
|
||||
{
|
||||
if(this.DataProvider is Providers.SELF_HOSTED)
|
||||
return null;
|
||||
|
||||
if (model == default)
|
||||
return "Please select a model.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[GeneratedRegex("^[a-zA-Z0-9 ]+$")]
|
||||
[GeneratedRegex(@"^[a-zA-Z0-9\-_. ]+$")]
|
||||
private static partial Regex InstanceNameRegex();
|
||||
|
||||
private static readonly string[] RESERVED_NAMES = { "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" };
|
||||
|
||||
private string? ValidatingInstanceName(string instanceName)
|
||||
{
|
||||
if(string.IsNullOrWhiteSpace(instanceName))
|
||||
if (string.IsNullOrWhiteSpace(instanceName))
|
||||
return "Please enter an instance name.";
|
||||
|
||||
if(instanceName.StartsWith(' '))
|
||||
return "The instance name must not start with a space.";
|
||||
if (instanceName.StartsWith(' ') || instanceName.StartsWith('.'))
|
||||
return "The instance name must not start with a space or a dot.";
|
||||
|
||||
if(instanceName.EndsWith(' '))
|
||||
return "The instance name must not end with a space.";
|
||||
if (instanceName.EndsWith(' ') || instanceName.EndsWith('.'))
|
||||
return "The instance name must not end with a space or a dot.";
|
||||
|
||||
if (instanceName.StartsWith('-') || instanceName.StartsWith('_'))
|
||||
return "The instance name must not start with a hyphen or an underscore.";
|
||||
|
||||
if (instanceName.Length > 255)
|
||||
return "The instance name must not exceed 255 characters.";
|
||||
|
||||
// The instance name must only contain letters, numbers, and spaces:
|
||||
if (!InstanceNameRegex().IsMatch(instanceName))
|
||||
return "The instance name must only contain letters, numbers, and spaces.";
|
||||
return "The instance name must only contain letters, numbers, spaces, hyphens, underscores, and dots.";
|
||||
|
||||
if(instanceName.Contains(" "))
|
||||
if (instanceName.Contains(" "))
|
||||
return "The instance name must not contain consecutive spaces.";
|
||||
|
||||
if (RESERVED_NAMES.Contains(instanceName.ToUpperInvariant()))
|
||||
return "This name is reserved and cannot be used.";
|
||||
|
||||
if (instanceName.Any(c => Path.GetInvalidFileNameChars().Contains(c)))
|
||||
return "The instance name contains invalid characters.";
|
||||
|
||||
// The instance name must be unique:
|
||||
var lowerInstanceName = instanceName.ToLowerInvariant();
|
||||
if (lowerInstanceName != this.dataEditingPreviousInstanceName && this.UsedInstanceNames.Contains(lowerInstanceName))
|
||||
@@ -206,6 +237,9 @@ public partial class ProviderDialog : ComponentBase
|
||||
|
||||
private string? ValidatingAPIKey(string apiKey)
|
||||
{
|
||||
if(this.DataProvider is Providers.SELF_HOSTED)
|
||||
return null;
|
||||
|
||||
if(!string.IsNullOrWhiteSpace(this.dataAPIKeyStorageIssue))
|
||||
return this.dataAPIKeyStorageIssue;
|
||||
|
||||
@@ -215,13 +249,28 @@ public partial class ProviderDialog : ComponentBase
|
||||
return null;
|
||||
}
|
||||
|
||||
private void Cancel() => this.MudDialog.Cancel();
|
||||
private string? ValidatingHostname(string hostname)
|
||||
{
|
||||
if(this.DataProvider != Providers.SELF_HOSTED)
|
||||
return null;
|
||||
|
||||
if(string.IsNullOrWhiteSpace(hostname))
|
||||
return "Please enter a hostname, e.g., http://localhost:1234";
|
||||
|
||||
if(!hostname.StartsWith("http://", StringComparison.InvariantCultureIgnoreCase) && !hostname.StartsWith("https://", StringComparison.InvariantCultureIgnoreCase))
|
||||
return "The hostname must start with either http:// or https://";
|
||||
|
||||
private bool CanLoadModels => !string.IsNullOrWhiteSpace(this.dataAPIKey) && this.DataProvider != Providers.NONE && !string.IsNullOrWhiteSpace(this.DataInstanceName);
|
||||
if(!Uri.TryCreate(hostname, UriKind.Absolute, out _))
|
||||
return "The hostname is not a valid HTTP(S) URL.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void Cancel() => this.MudDialog.Cancel();
|
||||
|
||||
private async Task ReloadModels()
|
||||
{
|
||||
var provider = this.DataProvider.CreateProvider(this.DataInstanceName);
|
||||
var provider = this.DataProvider.CreateProvider("temp");
|
||||
if(provider is NoProvider)
|
||||
return;
|
||||
|
||||
@@ -233,4 +282,19 @@ public partial class ProviderDialog : ComponentBase
|
||||
this.availableModels.Clear();
|
||||
this.availableModels.AddRange(orderedModels);
|
||||
}
|
||||
|
||||
private bool CanLoadModels => !string.IsNullOrWhiteSpace(this.dataAPIKey) && this.DataProvider != Providers.NONE && this.DataProvider != Providers.SELF_HOSTED;
|
||||
|
||||
private bool IsCloudProvider => this.DataProvider is not Providers.SELF_HOSTED;
|
||||
|
||||
private bool IsSelfHostedOrNone => this.DataProvider is Providers.SELF_HOSTED or Providers.NONE;
|
||||
|
||||
private string GetProviderCreationURL() => this.DataProvider switch
|
||||
{
|
||||
Providers.OPEN_AI => "https://platform.openai.com/signup",
|
||||
Providers.MISTRAL => "https://console.mistral.ai/",
|
||||
Providers.ANTHROPIC => "https://console.anthropic.com/dashboard",
|
||||
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,7 @@ public enum SendBehavior
|
||||
|
||||
/// <summary>
|
||||
/// The user can send the input to the AI by pressing any modifier key
|
||||
/// together with the enter key. Alternatively, the user can click the send
|
||||
/// together with the enter key. Alternatively, the user can click the sent
|
||||
/// button. The enter key alone adds a new line.
|
||||
/// </summary>
|
||||
MODIFER_ENTER_IS_SENDING,
|
||||
|
||||
@@ -103,7 +103,7 @@ public sealed class SettingsManager
|
||||
if(loadedConfiguration is null)
|
||||
return;
|
||||
|
||||
this.ConfigurationData = loadedConfiguration;
|
||||
this.ConfigurationData = SettingsMigrations.Migrate(loadedConfiguration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
public static class SettingsMigrations
|
||||
{
|
||||
public static Data Migrate(Data previousData)
|
||||
{
|
||||
switch (previousData.Version)
|
||||
{
|
||||
case Version.V1:
|
||||
return MigrateFromV1(previousData);
|
||||
|
||||
default:
|
||||
Console.WriteLine("No migration needed.");
|
||||
return previousData;
|
||||
}
|
||||
}
|
||||
|
||||
private static Data MigrateFromV1(Data previousData)
|
||||
{
|
||||
//
|
||||
// Summary:
|
||||
// In v1 we had no self-hosted providers. Thus, we had no hostnames.
|
||||
//
|
||||
|
||||
Console.WriteLine("Migrating from v1 to v2...");
|
||||
return new()
|
||||
{
|
||||
Version = Version.V2,
|
||||
|
||||
Providers = previousData.Providers.Select(provider => provider with { IsSelfHosted = false, Hostname = "" }).ToList(),
|
||||
|
||||
EnableSpellchecking = previousData.EnableSpellchecking,
|
||||
IsSavingEnergy = previousData.IsSavingEnergy,
|
||||
NextProviderNum = previousData.NextProviderNum,
|
||||
ShortcutSendBehavior = previousData.ShortcutSendBehavior,
|
||||
UpdateBehavior = previousData.UpdateBehavior,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
public enum UpdateBehavior
|
||||
{
|
||||
NO_CHECK,
|
||||
ONCE_STARTUP,
|
||||
HOURLY,
|
||||
DAILY,
|
||||
WEEKLY,
|
||||
}
|
||||
@@ -7,5 +7,7 @@ namespace AIStudio.Settings;
|
||||
public enum Version
|
||||
{
|
||||
UNKNOWN,
|
||||
|
||||
V1,
|
||||
V2,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
public enum WorkspaceStorageBehavior
|
||||
{
|
||||
DISABLE_WORKSPACES,
|
||||
|
||||
STORE_CHATS_AUTOMATICALLY,
|
||||
STORE_CHATS_MANUALLY,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
public enum WorkspaceStorageTemporaryMaintenancePolicy
|
||||
{
|
||||
NO_AUTOMATIC_MAINTENANCE,
|
||||
|
||||
DELETE_OLDER_THAN_7_DAYS,
|
||||
DELETE_OLDER_THAN_30_DAYS,
|
||||
DELETE_OLDER_THAN_90_DAYS,
|
||||
DELETE_OLDER_THAN_180_DAYS,
|
||||
DELETE_OLDER_THAN_365_DAYS,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public enum Event
|
||||
{
|
||||
NONE,
|
||||
|
||||
// Common events:
|
||||
STATE_HAS_CHANGED,
|
||||
|
||||
// Update events:
|
||||
USER_SEARCH_FOR_UPDATE,
|
||||
UPDATE_AVAILABLE,
|
||||
|
||||
// Chat events:
|
||||
HAS_CHAT_UNSAVED_CHANGES,
|
||||
RESET_CHAT_STATE,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public interface IMessageBusReceiver
|
||||
{
|
||||
public Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data);
|
||||
|
||||
public Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBusReceiver
|
||||
{
|
||||
[Inject]
|
||||
protected MessageBus MessageBus { get; init; } = null!;
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
this.MessageBus.RegisterComponent(this);
|
||||
base.OnInitialized();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of IMessageBusReceiver
|
||||
|
||||
public abstract Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data);
|
||||
|
||||
public abstract Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this.MessageBus.Unregister(this);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
protected async Task SendMessage<T>(Event triggeredEvent, T? data = default)
|
||||
{
|
||||
await this.MessageBus.SendMessage(this, triggeredEvent, data);
|
||||
}
|
||||
|
||||
protected async Task<TResult?> SendMessageWithResult<TPayload, TResult>(Event triggeredEvent, TPayload? data)
|
||||
{
|
||||
return await this.MessageBus.SendMessageUseFirstResult<TPayload, TResult>(this, triggeredEvent, data);
|
||||
}
|
||||
|
||||
protected void ApplyFilters(ComponentBase[] components, Event[] events)
|
||||
{
|
||||
this.MessageBus.ApplyFilters(this, components, events);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
// ReSharper disable RedundantRecordClassKeyword
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public sealed class MessageBus
|
||||
{
|
||||
public static readonly MessageBus INSTANCE = new();
|
||||
|
||||
private readonly ConcurrentDictionary<IMessageBusReceiver, ComponentBase[]> componentFilters = new();
|
||||
private readonly ConcurrentDictionary<IMessageBusReceiver, Event[]> componentEvents = new();
|
||||
private readonly ConcurrentQueue<Message> messageQueue = new();
|
||||
private readonly SemaphoreSlim sendingSemaphore = new(1, 1);
|
||||
|
||||
private MessageBus()
|
||||
{
|
||||
}
|
||||
|
||||
public void ApplyFilters(IMessageBusReceiver receiver, ComponentBase[] components, Event[] events)
|
||||
{
|
||||
this.componentFilters[receiver] = components;
|
||||
this.componentEvents[receiver] = events;
|
||||
}
|
||||
|
||||
public void RegisterComponent(IMessageBusReceiver receiver)
|
||||
{
|
||||
this.componentFilters.TryAdd(receiver, []);
|
||||
this.componentEvents.TryAdd(receiver, []);
|
||||
}
|
||||
|
||||
public void Unregister(IMessageBusReceiver receiver)
|
||||
{
|
||||
this.componentFilters.TryRemove(receiver, out _);
|
||||
this.componentEvents.TryRemove(receiver, out _);
|
||||
}
|
||||
|
||||
private record class Message(ComponentBase? SendingComponent, Event TriggeredEvent, object? Data);
|
||||
|
||||
public async Task SendMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data = default)
|
||||
{
|
||||
this.messageQueue.Enqueue(new Message(sendingComponent, triggeredEvent, data));
|
||||
|
||||
try
|
||||
{
|
||||
await this.sendingSemaphore.WaitAsync();
|
||||
while (this.messageQueue.TryDequeue(out var message))
|
||||
{
|
||||
foreach (var (receiver, componentFilter) in this.componentFilters)
|
||||
{
|
||||
if (componentFilter.Length > 0 && sendingComponent is not null && !componentFilter.Contains(sendingComponent))
|
||||
continue;
|
||||
|
||||
var eventFilter = this.componentEvents[receiver];
|
||||
if (eventFilter.Length == 0 || eventFilter.Contains(triggeredEvent))
|
||||
// We don't await the task here because we don't want to block the message bus:
|
||||
_ = receiver.ProcessMessage(message.SendingComponent, message.TriggeredEvent, message.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.sendingSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TResult?> SendMessageUseFirstResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data = default)
|
||||
{
|
||||
foreach (var (receiver, componentFilter) in this.componentFilters)
|
||||
{
|
||||
if (componentFilter.Length > 0 && sendingComponent is not null && !componentFilter.Contains(sendingComponent))
|
||||
continue;
|
||||
|
||||
var eventFilter = this.componentEvents[receiver];
|
||||
if (eventFilter.Length == 0 || eventFilter.Contains(triggeredEvent))
|
||||
{
|
||||
var result = await receiver.ProcessMessageWithResult<TPayload, TResult>(sendingComponent, triggeredEvent, data);
|
||||
if (result is not null)
|
||||
return (TResult) result;
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public static class MessageBusExtensions
|
||||
{
|
||||
public static async Task SendMessage<T>(this ComponentBase component, Event triggeredEvent, T? data = default)
|
||||
{
|
||||
await MessageBus.INSTANCE.SendMessage(component, triggeredEvent, data);
|
||||
}
|
||||
|
||||
public static void ApplyFilters(this IMessageBusReceiver component, ComponentBase[] components, Event[] events)
|
||||
{
|
||||
MessageBus.INSTANCE.ApplyFilters(component, components, events);
|
||||
}
|
||||
}
|
||||
@@ -39,4 +39,16 @@ public sealed class Rust
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<UpdateResponse> CheckForUpdate(IJSRuntime jsRuntime)
|
||||
{
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(16));
|
||||
return await jsRuntime.InvokeAsync<UpdateResponse>("window.__TAURI__.invoke", cts.Token, "check_for_update");
|
||||
}
|
||||
|
||||
public async Task InstallUpdate(IJSRuntime jsRuntime)
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
await jsRuntime.InvokeVoidAsync("window.__TAURI__.invoke", cts.Token, "install_update");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,6 @@ namespace AIStudio.Tools;
|
||||
/// <summary>
|
||||
/// The response from the set clipboard operation.
|
||||
/// </summary>
|
||||
/// <param name="Success">True when the operation was successful.</param>
|
||||
/// <param name="Issue">The issue that occurred during the operation, empty when successful.</param>
|
||||
/// <param name="Success">True, when the operation was successful.</param>
|
||||
/// <param name="Issue">The issues, which occurred during the operation, empty when successful.</param>
|
||||
public readonly record struct SetClipboardResponse(bool Success, string Issue);
|
||||
@@ -0,0 +1,71 @@
|
||||
using AIStudio.Settings;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public class TemporaryChatService(SettingsManager settingsManager) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromDays(1);
|
||||
private static bool IS_INITIALIZED;
|
||||
|
||||
#region Overrides of BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested && !IS_INITIALIZED)
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
|
||||
|
||||
await settingsManager.LoadSettings();
|
||||
if(settingsManager.ConfigurationData.WorkspaceStorageTemporaryMaintenancePolicy is WorkspaceStorageTemporaryMaintenancePolicy.NO_AUTOMATIC_MAINTENANCE)
|
||||
{
|
||||
Console.WriteLine("Automatic maintenance of temporary chat storage is disabled. Exiting maintenance service.");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.StartMaintenance();
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(CHECK_INTERVAL, stoppingToken);
|
||||
await this.StartMaintenance();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private Task StartMaintenance()
|
||||
{
|
||||
var temporaryDirectories = Path.Join(SettingsManager.DataDirectory, "tempChats");
|
||||
if(!Directory.Exists(temporaryDirectories))
|
||||
return Task.CompletedTask;
|
||||
|
||||
foreach (var tempChatDirPath in Directory.EnumerateDirectories(temporaryDirectories))
|
||||
{
|
||||
var chatPath = Path.Join(tempChatDirPath, "thread.json");
|
||||
var chatMetadata = new FileInfo(chatPath);
|
||||
if (!chatMetadata.Exists)
|
||||
continue;
|
||||
|
||||
var lastWriteTime = chatMetadata.LastWriteTimeUtc;
|
||||
var deleteChat = settingsManager.ConfigurationData.WorkspaceStorageTemporaryMaintenancePolicy switch
|
||||
{
|
||||
WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_7_DAYS => DateTime.UtcNow - lastWriteTime > TimeSpan.FromDays(7),
|
||||
WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_30_DAYS => DateTime.UtcNow - lastWriteTime > TimeSpan.FromDays(30),
|
||||
WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_90_DAYS => DateTime.UtcNow - lastWriteTime > TimeSpan.FromDays(90),
|
||||
WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_180_DAYS => DateTime.UtcNow - lastWriteTime > TimeSpan.FromDays(180),
|
||||
WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_365_DAYS => DateTime.UtcNow - lastWriteTime > TimeSpan.FromDays(365),
|
||||
|
||||
WorkspaceStorageTemporaryMaintenancePolicy.NO_AUTOMATIC_MAINTENANCE => false,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if(deleteChat)
|
||||
Directory.Delete(tempChatDirPath, true);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
IS_INITIALIZED = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// The response of the update check.
|
||||
/// </summary>
|
||||
/// <param name="UpdateIsAvailable">True if an update is available.</param>
|
||||
/// <param name="NewVersion">The new version, when available.</param>
|
||||
/// <param name="Changelog">The changelog of the new version, when available.</param>
|
||||
public readonly record struct UpdateResponse(
|
||||
[property:JsonPropertyName("update_is_available")] bool UpdateIsAvailable,
|
||||
[property:JsonPropertyName("error")] bool Error,
|
||||
[property:JsonPropertyName("new_version")] string NewVersion,
|
||||
string Changelog
|
||||
);
|
||||
@@ -0,0 +1,123 @@
|
||||
using AIStudio.Settings;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public sealed class UpdateService : BackgroundService, IMessageBusReceiver
|
||||
{
|
||||
// We cannot inject IJSRuntime into our service. This is because
|
||||
// the service is not a Blazor component. We need to pass the IJSRuntime from
|
||||
// the MainLayout component to the service.
|
||||
private static IJSRuntime? JS_RUNTIME;
|
||||
private static bool IS_INITIALIZED;
|
||||
private static ISnackbar? SNACKBAR;
|
||||
|
||||
private readonly SettingsManager settingsManager;
|
||||
private readonly MessageBus messageBus;
|
||||
private readonly Rust rust;
|
||||
|
||||
private TimeSpan updateInterval;
|
||||
|
||||
public UpdateService(MessageBus messageBus, SettingsManager settingsManager, Rust rust)
|
||||
{
|
||||
this.settingsManager = settingsManager;
|
||||
this.messageBus = messageBus;
|
||||
this.rust = rust;
|
||||
|
||||
this.messageBus.RegisterComponent(this);
|
||||
this.ApplyFilters([], [ Event.USER_SEARCH_FOR_UPDATE ]);
|
||||
}
|
||||
|
||||
#region Overrides of BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested && !IS_INITIALIZED)
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
|
||||
|
||||
this.updateInterval = this.settingsManager.ConfigurationData.UpdateBehavior switch
|
||||
{
|
||||
UpdateBehavior.NO_CHECK => Timeout.InfiniteTimeSpan,
|
||||
UpdateBehavior.ONCE_STARTUP => Timeout.InfiniteTimeSpan,
|
||||
|
||||
UpdateBehavior.HOURLY => TimeSpan.FromHours(1),
|
||||
UpdateBehavior.DAILY => TimeSpan.FromDays(1),
|
||||
UpdateBehavior.WEEKLY => TimeSpan.FromDays(7),
|
||||
|
||||
_ => TimeSpan.FromHours(1)
|
||||
};
|
||||
|
||||
if(this.settingsManager.ConfigurationData.UpdateBehavior is UpdateBehavior.NO_CHECK)
|
||||
return;
|
||||
|
||||
await this.CheckForUpdate();
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(this.updateInterval, stoppingToken);
|
||||
await this.CheckForUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of IMessageBusReceiver
|
||||
|
||||
public async Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data)
|
||||
{
|
||||
switch (triggeredEvent)
|
||||
{
|
||||
case Event.USER_SEARCH_FOR_UPDATE:
|
||||
await this.CheckForUpdate(notifyUserWhenNoUpdate: true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data)
|
||||
{
|
||||
return Task.FromResult<TResult?>(default);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Overrides of BackgroundService
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
this.messageBus.Unregister(this);
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task CheckForUpdate(bool notifyUserWhenNoUpdate = false)
|
||||
{
|
||||
if(!IS_INITIALIZED)
|
||||
return;
|
||||
|
||||
var response = await this.rust.CheckForUpdate(JS_RUNTIME!);
|
||||
if (response.UpdateIsAvailable)
|
||||
{
|
||||
await this.messageBus.SendMessage(null, Event.UPDATE_AVAILABLE, response);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (notifyUserWhenNoUpdate)
|
||||
{
|
||||
SNACKBAR!.Add("No update found.", Severity.Normal, config =>
|
||||
{
|
||||
config.Icon = Icons.Material.Filled.Update;
|
||||
config.IconSize = Size.Large;
|
||||
config.IconColor = Color.Primary;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetBlazorDependencies(IJSRuntime jsRuntime, ISnackbar snackbar)
|
||||
{
|
||||
SNACKBAR = snackbar;
|
||||
JS_RUNTIME = jsRuntime;
|
||||
IS_INITIALIZED = true;
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[8.0.6, )",
|
||||
"resolved": "8.0.6",
|
||||
"contentHash": "E+lDylsTeP4ZiDmnEkiJ5wobnGaIJzFhOgZppznJCb69UZgbh6G3cfv1pnLDLLBx6JAgl0kAlnINDeT3uCuczQ=="
|
||||
"requested": "[8.0.7, )",
|
||||
"resolved": "8.0.7",
|
||||
"contentHash": "iI52ptEKby2ymQ6B7h4TWbFmm85T4VvLgc/HvS45Yr3lgi4IIFbQtjON3bQbX/Vc94jXNSLvrDOp5Kh7SJyFYQ=="
|
||||
},
|
||||
"MudBlazor": {
|
||||
"type": "Direct",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# v0.6.0, build 156 (2024-06-30 12:49 UTC)
|
||||
- Added a setting to determine whether and how often to check for updates
|
||||
- Added a bidirectional message bus for inter-component communication
|
||||
- Added an update dialog for the app
|
||||
- Added an update banner to show when a new version is available
|
||||
- Added an option to manually check for updates to the about page
|
||||
- Fixed an issue with previous automatic updates on Windows, where background processes were not terminated
|
||||
- Disabled Tauri's built-in update dialog
|
||||
@@ -0,0 +1,2 @@
|
||||
# v0.6.1, build 157 (2024-06-30 19:00 UTC)
|
||||
- Added support for the Anthropic provider
|
||||
@@ -0,0 +1,2 @@
|
||||
# v0.6.2, build 158 (2024-07-01 18:03 UTC)
|
||||
- Added support for the Mistral provider
|
||||
@@ -0,0 +1,8 @@
|
||||
# v0.6.3, build 159 (2024-07-03 18:26 UTC)
|
||||
- Added possibility to configure a self-hosted or local provider.
|
||||
- Added settings migration processor to handle settings migration from previous versions.
|
||||
- Added links to create an account for the selected provider in the provider dialog.
|
||||
- Added links to each provider's dashboard to the settings page.
|
||||
- Added self-hosted and local providers.
|
||||
- Improved instance name validation.
|
||||
- Optimized the provider dialog: changed the layout to improve usability.
|
||||
@@ -0,0 +1,16 @@
|
||||
# v0.7.0, build 160 (2024-07-13 08:21 UTC)
|
||||
- Added workspaces for organizing your chats
|
||||
- Added temporary chats for quick conversations
|
||||
- Added configurable chat maintenance settings for temporary chats
|
||||
- Added configuration to disable workspace and temporary chat features; no chat will be stored in this case
|
||||
- Added possibility to rename chats
|
||||
- Added possibility to delete chats
|
||||
- Added possibility to move chats between workspaces
|
||||
- Added possibility to delete entire workspaces
|
||||
- Added possibility to rename workspaces
|
||||
- Added possibility to create new workspaces
|
||||
- Added feature to delete the current chat and start a new one
|
||||
- Added feature to start a new chat within the current workspace
|
||||
- Added a confirmation dialog for when unsaved changes are about to be lost
|
||||
- Show the current workspace in the title bar
|
||||
- Fixed a bug where the periodic, automatic update check would not work
|
||||
@@ -0,0 +1,2 @@
|
||||
# v0.7.1, build 161 (2024-07-13 11:42 UTC)
|
||||
- Fixed the description on the start page, about the possible LLM providers.
|
||||
@@ -8,7 +8,7 @@ To get started, choose your operating system where you want to install the app:
|
||||
## Windows
|
||||
AI Studio is only available for modern 64-bit Windows systems. When you have an older 32-bit system, you won't be able to run the app. We require an updated Windows 10 or 11 version; the app won't properly work on Windows 7 or 8. Next, we have to figure out if you have an Intel/AMD or a modern ARM system on your Windows machine.
|
||||
|
||||
- **Copilot Plus PC:** Do you own one of the new Copilot Plus PCs? If so, you most likely have an ARM system. When your machine has a Qualcomm sticker on it, you have an ARM system for sure. [Download the ARM version](https://github.com/MindWorkAI/AI-Studio/releases/latest/download/MindWork.AI.Studio_arm64-setup.exe) of AI Studio.
|
||||
- **Copilot+ PC:** Do you own one of the new Copilot+ PCs? If so, you most likely have an ARM system. When your machine has a Qualcomm sticker on it, you have an ARM system for sure. [Download the ARM version](https://github.com/MindWorkAI/AI-Studio/releases/latest/download/MindWork.AI.Studio_arm64-setup.exe) of AI Studio.
|
||||
|
||||
- **Windows on macOS:** Do you run Windows using Parallels on an Apple Silicon system (M1, M2, M3, M4)? Then you have an ARM system as well. [Download the ARM version](https://github.com/MindWorkAI/AI-Studio/releases/latest/download/MindWork.AI.Studio_arm64-setup.exe) of AI Studio.
|
||||
|
||||
|
||||
+6
-6
@@ -1,9 +1,9 @@
|
||||
0.5.2
|
||||
2024-06-25 18:07:06 UTC
|
||||
155
|
||||
8.0.206 (commit bb12410699)
|
||||
8.0.6 (commit 3b8b000a0e)
|
||||
0.7.1
|
||||
2024-07-13 11:42:46 UTC
|
||||
161
|
||||
8.0.107 (commit 1bdaef7265)
|
||||
8.0.7 (commit 2aade6beb0)
|
||||
1.79.0 (commit 129f3b996)
|
||||
6.20.0
|
||||
1.6.1
|
||||
2818aa93411, release
|
||||
d781e48ae66, release
|
||||
Generated
+2
-1
@@ -2313,12 +2313,13 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mindwork-ai-studio"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"flexi_logger",
|
||||
"keyring",
|
||||
"log",
|
||||
"once_cell",
|
||||
"reqwest 0.12.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mindwork-ai-studio"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
edition = "2021"
|
||||
description = "MindWork AI Studio"
|
||||
authors = ["Thorsten Sommer"]
|
||||
@@ -18,6 +18,7 @@ arboard = "3.4.0"
|
||||
tokio = "1.37.0"
|
||||
flexi_logger = "0.28"
|
||||
log = "0.4"
|
||||
once_cell = "1.19.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# See issue https://github.com/tauri-apps/tauri/issues/4470
|
||||
|
||||
+175
-19
@@ -5,6 +5,7 @@ extern crate core;
|
||||
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use arboard::Clipboard;
|
||||
use keyring::Entry;
|
||||
@@ -15,6 +16,11 @@ use tauri::utils::config::AppUrl;
|
||||
use tokio::time;
|
||||
use flexi_logger::{AdaptiveFormat, Logger};
|
||||
use log::{debug, error, info, warn};
|
||||
use tauri::updater::UpdateResponse;
|
||||
|
||||
static SERVER: Lazy<Arc<Mutex<Option<CommandChild>>>> = Lazy::new(|| Arc::new(Mutex::new(None)));
|
||||
static MAIN_WINDOW: Lazy<Mutex<Option<Window>>> = Lazy::new(|| Mutex::new(None));
|
||||
static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<UpdateResponse<tauri::Wry>>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
fn main() {
|
||||
|
||||
@@ -30,7 +36,14 @@ fn main() {
|
||||
let tauri_version = metadata_lines.next().unwrap();
|
||||
let app_commit_hash = metadata_lines.next().unwrap();
|
||||
|
||||
Logger::try_with_str("debug").expect("Cannot create logging")
|
||||
// Set the log level according to the environment:
|
||||
// In debug mode, the log level is set to debug, in release mode to info.
|
||||
let log_level = match is_dev() {
|
||||
true => "debug",
|
||||
false => "info",
|
||||
};
|
||||
|
||||
Logger::try_with_str(log_level).expect("Cannot create logging")
|
||||
.log_to_stdout()
|
||||
.adaptive_format_for_stdout(AdaptiveFormat::Detailed)
|
||||
.start().expect("Cannot start logging");
|
||||
@@ -69,8 +82,7 @@ fn main() {
|
||||
info!("Try to start the .NET server on {app_url_log}...");
|
||||
|
||||
// Arc for the server process to stop it later:
|
||||
let server: Arc<Mutex<Option<CommandChild>>> = Arc::new(Mutex::new(None));
|
||||
let server_spawn_clone = server.clone();
|
||||
let server_spawn_clone = SERVER.clone();
|
||||
|
||||
// Channel to communicate with the server process:
|
||||
let (sender, mut receiver) = tauri::async_runtime::channel(100);
|
||||
@@ -114,9 +126,8 @@ fn main() {
|
||||
warn!("Running in development mode, no .NET server will be started.");
|
||||
}
|
||||
|
||||
let main_window: Arc<Mutex<Option<Window>>> = Arc::new(Mutex::new(None));
|
||||
let main_window_spawn_clone = main_window.clone();
|
||||
let server_receive_clone = server.clone();
|
||||
let main_window_spawn_clone = &MAIN_WINDOW;
|
||||
let server_receive_clone = SERVER.clone();
|
||||
|
||||
// Create a thread to handle server events:
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -181,29 +192,93 @@ fn main() {
|
||||
});
|
||||
|
||||
info!("Starting Tauri app...");
|
||||
tauri::Builder::default()
|
||||
let app = tauri::Builder::default()
|
||||
.setup(move |app| {
|
||||
let window = app.get_window("main").expect("Failed to get main window.");
|
||||
*main_window.lock().unwrap() = Some(window);
|
||||
*MAIN_WINDOW.lock().unwrap() = Some(window);
|
||||
Ok(())
|
||||
})
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.invoke_handler(tauri::generate_handler![store_secret, get_secret, delete_secret, set_clipboard])
|
||||
.run(tauri::generate_context!())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
store_secret, get_secret, delete_secret, set_clipboard,
|
||||
check_for_update, install_update
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("Error while running Tauri application");
|
||||
|
||||
app.run(|app_handle, event| match event {
|
||||
|
||||
tauri::RunEvent::WindowEvent { event, label, .. } => {
|
||||
match event {
|
||||
tauri::WindowEvent::CloseRequested { .. } => {
|
||||
warn!("Window '{label}': close was requested.");
|
||||
}
|
||||
|
||||
tauri::WindowEvent::Destroyed => {
|
||||
warn!("Window '{label}': was destroyed.");
|
||||
}
|
||||
|
||||
tauri::WindowEvent::FileDrop(files) => {
|
||||
info!("Window '{label}': files were dropped: {files:?}");
|
||||
}
|
||||
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
tauri::RunEvent::Updater(updater_event) => {
|
||||
match updater_event {
|
||||
|
||||
tauri::UpdaterEvent::UpdateAvailable { body, date, version } => {
|
||||
let body_len = body.len();
|
||||
info!("Updater: update available: body size={body_len} time={date:?} version={version}");
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::Pending => {
|
||||
info!("Updater: update is pending!");
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::DownloadProgress { chunk_length, content_length } => {
|
||||
info!("Updater: downloaded {} of {:?}", chunk_length, content_length);
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::Downloaded => {
|
||||
info!("Updater: update has been downloaded!");
|
||||
warn!("Try to stop the .NET server now...");
|
||||
stop_server();
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::Updated => {
|
||||
info!("Updater: app has been updated");
|
||||
warn!("Try to restart the app now...");
|
||||
app_handle.restart();
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::AlreadyUpToDate => {
|
||||
info!("Updater: app is already up to date");
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::Error(error) => {
|
||||
warn!("Updater: failed to update: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
warn!("Run event: exit was requested.");
|
||||
}
|
||||
|
||||
tauri::RunEvent::Ready => {
|
||||
info!("Run event: Tauri app is ready.");
|
||||
}
|
||||
|
||||
_ => {}
|
||||
});
|
||||
|
||||
info!("Tauri app was stopped.");
|
||||
if is_prod() {
|
||||
info!("Try to stop the .NET server as well...");
|
||||
if let Some(server_process) = server.lock().unwrap().take() {
|
||||
let server_kill_result = server_process.kill();
|
||||
match server_kill_result {
|
||||
Ok(_) => info!("The .NET server process was stopped."),
|
||||
Err(e) => error!("Failed to stop the .NET server process: {e}."),
|
||||
}
|
||||
} else {
|
||||
warn!("The .NET server process was not started or already stopped.");
|
||||
}
|
||||
stop_server();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +305,87 @@ fn get_available_port() -> Option<u16> {
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn stop_server() {
|
||||
if let Some(server_process) = SERVER.lock().unwrap().take() {
|
||||
let server_kill_result = server_process.kill();
|
||||
match server_kill_result {
|
||||
Ok(_) => info!("The .NET server process was stopped."),
|
||||
Err(e) => error!("Failed to stop the .NET server process: {e}."),
|
||||
}
|
||||
} else {
|
||||
warn!("The .NET server process was not started or already stopped.");
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_for_update() -> CheckUpdateResponse {
|
||||
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().unwrap().app_handle();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let response = app_handle.updater().check().await;
|
||||
match response {
|
||||
Ok(update_response) => match update_response.is_update_available() {
|
||||
true => {
|
||||
*CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update_response.clone());
|
||||
let new_version = update_response.latest_version();
|
||||
info!("Updater: update to version '{new_version}' is available.");
|
||||
let changelog = update_response.body();
|
||||
CheckUpdateResponse {
|
||||
update_is_available: true,
|
||||
error: false,
|
||||
new_version: new_version.to_string(),
|
||||
changelog: match changelog {
|
||||
Some(c) => c.to_string(),
|
||||
None => String::from(""),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
false => {
|
||||
info!("Updater: no updates available.");
|
||||
CheckUpdateResponse {
|
||||
update_is_available: false,
|
||||
error: false,
|
||||
new_version: String::from(""),
|
||||
changelog: String::from(""),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to check updater: {e}.");
|
||||
CheckUpdateResponse {
|
||||
update_is_available: false,
|
||||
error: true,
|
||||
new_version: String::from(""),
|
||||
changelog: String::from(""),
|
||||
}
|
||||
},
|
||||
}
|
||||
}).await.unwrap()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CheckUpdateResponse {
|
||||
update_is_available: bool,
|
||||
error: bool,
|
||||
new_version: String,
|
||||
changelog: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn install_update() {
|
||||
let cloned_response_option = CHECK_UPDATE_RESPONSE.lock().unwrap().clone();
|
||||
match cloned_response_option {
|
||||
Some(update_response) => {
|
||||
update_response.download_and_install().await.unwrap();
|
||||
},
|
||||
|
||||
None => {
|
||||
error!("Update installer: no update available to install. Did you check for updates first?");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn store_secret(destination: String, user_name: String, secret: String) -> StoreSecretResponse {
|
||||
let service = format!("mindwork-ai-studio::{}", destination);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "MindWork AI Studio",
|
||||
"version": "0.5.2"
|
||||
"version": "0.7.1"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
@@ -76,7 +76,7 @@
|
||||
"endpoints": [
|
||||
"https://github.com/MindWorkAI/AI-Studio/releases/latest/download/latest.json"
|
||||
],
|
||||
"dialog": true,
|
||||
"dialog": false,
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user