mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-20 18:18:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59d0321625 | ||
|
|
29263660fc | ||
|
|
6cc1d37db8 | ||
|
|
b29e181ecd | ||
|
|
9b1649b48a | ||
|
|
15e8fbc6ca | ||
|
|
d6e80a4563 | ||
|
|
50a03e6c9d | ||
|
|
6170e0ce9a |
No files matched your search
@@ -662,18 +662,6 @@ jobs:
|
||||
# Here, we create the JSON object:
|
||||
platforms_json=$(jq -n '{}')
|
||||
|
||||
# Define the urlencode function:
|
||||
urlencode() {
|
||||
local length="${#1}"
|
||||
for (( i = 0; i < length; i++ )); do
|
||||
local c="${1:i:1}"
|
||||
case $c in
|
||||
[a-zA-Z0-9.~_-]) printf "$c" ;;
|
||||
*) printf '%%%02X' "'$c" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Display the structure of the release directory:
|
||||
ls -Rlhat $GITHUB_WORKSPACE/release/assets
|
||||
|
||||
@@ -713,7 +701,11 @@ jobs:
|
||||
|
||||
# Extract the artifact name and create the URL:
|
||||
artifact_name=$(basename "$sig_file" .sig)
|
||||
encoded_artifact_name=$(urlencode "$artifact_name")
|
||||
|
||||
# Replaces spaces by '.':
|
||||
encoded_artifact_name=$(echo "$artifact_name" | sed 's/ /./g')
|
||||
|
||||
# Create the URL:
|
||||
url="https://github.com/MindWorkAI/AI-Studio/releases/download/$FORMATTED_VERSION/$encoded_artifact_name"
|
||||
|
||||
# Build the JSON object:
|
||||
|
||||
@@ -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,6 +13,12 @@ public partial class Changelog
|
||||
|
||||
public static readonly Log[] LOGS =
|
||||
[
|
||||
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 (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"),
|
||||
|
||||
@@ -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,14 +2,16 @@
|
||||
|
||||
<MudPaper Height="calc(100vh);" Elevation="0">
|
||||
<MudLayout>
|
||||
@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="Chats" Placement="Placement.Right">
|
||||
<MudNavLink Href="/chat" Icon="@Icons.Material.Filled.Chat">Chats</MudNavLink>
|
||||
<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>
|
||||
@@ -23,10 +25,34 @@
|
||||
</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);">
|
||||
@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>
|
||||
|
||||
@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!;
|
||||
@@ -21,12 +26,21 @@ 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,12 +145,20 @@ 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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
<MudImage Src="svg/banner.svg" />
|
||||
<MudText Typo="Typo.h3" Class="mt-2 mb-2">Let's get started</MudText>
|
||||
|
||||
<InnerScrolling HeaderHeight="288px - 3em">
|
||||
<InnerScrolling HeaderHeight="288px - 8em">
|
||||
<MudExpansionPanels Class="mb-3" MultiExpansion="@false">
|
||||
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.MenuBook" HeaderText="Introduction" IsExpanded="@true">
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
@@ -104,5 +92,14 @@ public partial class Settings : ComponentBase
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +111,7 @@ public partial class ProviderDialog : ComponentBase
|
||||
this.dataAPIKey = requestedSecret.Secret;
|
||||
|
||||
// Now, we try to load the list of available models:
|
||||
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;
|
||||
|
||||
private bool CanLoadModels => !string.IsNullOrWhiteSpace(this.dataAPIKey) && this.DataProvider != Providers.NONE && !string.IsNullOrWhiteSpace(this.DataInstanceName);
|
||||
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://";
|
||||
|
||||
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",
|
||||
@@ -163,6 +163,6 @@
|
||||
"contentHash": "FHNOatmUq0sqJOkTx+UF/9YK1f180cnW5FVqnQMvYUN0elp6wFzbtPSiqbo1/ru8ICp43JM1i7kKkk6GsNGHlA=="
|
||||
}
|
||||
},
|
||||
"net8.0/osx-arm64": {}
|
||||
"net8.0/osx-x64": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# v0.5.2, build 155 (2024-06-25 18:07 UTC)
|
||||
- Fixed home page layout for 1080p screens.
|
||||
@@ -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
|
||||
@@ -52,7 +52,7 @@ When you are confident in the app's safety, follow these steps:
|
||||
3. Find the MindWork AI Studio app.
|
||||
4. Type this command: `xattr -r -d com.apple.quarantine ` (with a space at the end).
|
||||
5. Drag the MindWork AI Studio app from the Finder into the Terminal window. The path to the app will be added to the command automatically.
|
||||
6. The final command should be: `xattr -r -d com.apple.quarantine /Applications/MindWork\ AI\ Studio.app`. Press Enter.
|
||||
6. The final command should be: `xattr -r -d com.apple.quarantine "/Applications/MindWork AI Studio.app"`. Press Enter.
|
||||
7. Now, you might close the Terminal app and the Finder.
|
||||
|
||||
The AI Studio app should now open without any issues. Once the app is installed, it will check for updates automatically. If a new version is available, you will be prompted to install it.
|
||||
|
||||
+6
-6
@@ -1,9 +1,9 @@
|
||||
0.5.1
|
||||
2024-06-25 15:35:45 UTC
|
||||
154
|
||||
8.0.206 (commit bb12410699)
|
||||
8.0.6 (commit 3b8b000a0e)
|
||||
0.7.0
|
||||
2024-07-13 08:21:49 UTC
|
||||
160
|
||||
8.0.107 (commit 1bdaef7265)
|
||||
8.0.7 (commit 2aade6beb0)
|
||||
1.79.0 (commit 129f3b996)
|
||||
6.20.0
|
||||
1.6.1
|
||||
e1cda54fcd1, release
|
||||
09e1f8715f8, release
|
||||
Generated
+2
-1
@@ -2313,12 +2313,13 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mindwork-ai-studio"
|
||||
version = "0.5.0"
|
||||
version = "0.7.0"
|
||||
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.1"
|
||||
version = "0.7.0"
|
||||
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.1"
|
||||
"version": "0.7.0"
|
||||
},
|
||||
"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