mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-20 20:57:59 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69288699b3 | ||
|
|
826d15729b | ||
|
|
2ae40c59a3 | ||
|
|
119100bbce | ||
|
|
f92fab24b3 | ||
|
|
671abf3b07 | ||
|
|
d0a366258a | ||
|
|
58d7478f3a | ||
|
|
e8216b70a5 | ||
|
|
152d5f9b4f | ||
|
|
f1104c5e09 | ||
|
|
3ccb097705 | ||
|
|
b9416e66c3 | ||
|
|
9f576e8572 | ||
|
|
629613f75a | ||
|
|
abbb8c1650 | ||
|
|
d2ac4e26b0 | ||
|
|
bd97c0fb4b | ||
|
|
8bd54e6d61 | ||
|
|
e419c5561b | ||
|
|
24a73e92e1 | ||
|
|
832e054dbf | ||
|
|
4703ab04ab |
No files matched your search
@@ -3,7 +3,19 @@
|
||||
## News
|
||||
Things we are currently working on:
|
||||
|
||||
- Since September 2024: Work on the first personal assistant. The first personal assistant is a specialist in cognitive bias. It should be able to explain to you what biases exist. We do not want to rely on the training data of the LLM. Instead, AI Studio will bring a large catalog of bias descriptions in the future. This requires a lot of editorial work because we need to collect and revise the information. Related PR: [~~#166~~](https://github.com/MindWorkAI/AI-Studio/pull/166), [#173](https://github.com/MindWorkAI/AI-Studio/pull/173).
|
||||
- Since November 2024: Work on RAG (integration of your data and files) has begun. We will support the integration of local and external data sources. We need to implement the following runtime (Rust) and app (.NET) steps:
|
||||
|
||||
- [x] ~~Runtime: Restructuring the code into meaningful modules (PR [#192](https://github.com/MindWorkAI/AI-Studio/pull/192))~~
|
||||
- [x] ~~Define the [External Data API (EDI)](https://github.com/MindWorkAI/EDI) as a contract for integrating arbitrary external data (PR [#1](https://github.com/MindWorkAI/EDI/pull/1))~~
|
||||
- [ ] App: Metadata for providers (which provider offers embeddings?)
|
||||
- [ ] App: Management of data sources (local data)
|
||||
- [ ] Runtime: Extract data from txt / md / pdf / docx / xlsx files
|
||||
- [ ] App: Implement embedding providers
|
||||
- [ ] App: Implement the process to vectorize local data using embeddings
|
||||
- [ ] Runtime: Integration of the vector database [LanceDB](https://github.com/lancedb/lancedb)
|
||||
- [ ] App: Define an interface for the integration of RAG processes in chats
|
||||
- [ ] App: Integrate data sources in chats
|
||||
- [ ] App: Management of data sources (external data via [EDI](https://github.com/MindWorkAI/EDI))
|
||||
|
||||
|
||||
- Since September 2024: Experiments have been started on how we can work on long texts with AI Studio. Let's say you want to write a fantasy novel or create a complex project proposal and use LLM for support. The initial experiments were promising, but not yet satisfactory. We are testing further approaches until a satisfactory solution is found. Related PR: [#167](https://github.com/MindWorkAI/AI-Studio/pull/167).
|
||||
@@ -11,12 +23,21 @@ Things we are currently working on:
|
||||
|
||||
Other News:
|
||||
|
||||
- October 2024: We have our first financial supporter: Thank you @richard-stanton for financially supporting AI Studio. The supporter page in the app will be updated accordingly with the next update.
|
||||
|
||||
- October 2024: We've found the first two financial supporters. Huge thanks to `richard-stanton` and `peerschuett` for backing the project. Thanks for having the courage to be the first to support us.
|
||||
|
||||
- October 2024: The [German Aerospace Center (DLR)](https://en.wikipedia.org/wiki/German_Aerospace_Center) ([Website](https://www.dlr.de/en)) will use AI Studio at least within the scope of one project and will also contribute to its further development. This is great news.
|
||||
|
||||
|
||||
Features we have recently released:
|
||||
|
||||
- v0.9.17: Added the new Anthropic model `claude-3-5-sonnet-20241022`.
|
||||
- v0.9.16: Added workspace display options & improved the layout of the app window.
|
||||
- v0.9.15: Added the bias-of-the-day assistant. Tells you about a cognitive bias every day.
|
||||
- v0.9.13: You can use `ollama` providers secured with API keys.
|
||||
- v0.9.12: Added a job posting assistant to the business category and improved grammar & spelling check and rewrite assistants.
|
||||
- v0.9.11: Added enforcement of minimal confidence levels & dark mode.
|
||||
- v0.9.10: Added support for the OpenAI `o1` model.
|
||||
|
||||
## What is AI Studio?
|
||||
|
||||

|
||||
@@ -45,7 +66,7 @@ We offer various ways you can support the project:
|
||||
- **One-Time Contributions**: Make a one-time donation and have your name or company logo included in the app as a gesture of our gratitude.
|
||||
|
||||
For companies, sponsoring MindWork AI Studio is not only a way to support innovation but also a valuable opportunity for public relations and marketing. Your company's name and logo will be featured prominently, showcasing your commitment to using cutting-edge AI tools and enhancing your reputation as an innovative enterprise.
|
||||
|
||||
|
||||
To view all available tiers, please visit our [GitHub Sponsors page](https://github.com/sponsors/MindWorkAI).
|
||||
Your support, whether big or small, keeps the wheels turning and is deeply appreciated ❤️.
|
||||
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LLM/@EntryIndexedValue">LLM</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LM/@EntryIndexedValue">LM</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=MSG/@EntryIndexedValue">MSG</s:String>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=groq/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=ollama/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=tauri_0027s/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||
@@ -51,11 +51,8 @@
|
||||
@if (this.ShowSendTo)
|
||||
{
|
||||
<MudMenu StartIcon="@Icons.Material.Filled.Apps" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="Send to ..." Variant="Variant.Filled" Style="@this.GetSendToColor()" Class="rounded">
|
||||
@foreach (var assistant in Enum.GetValues<Components>().OrderBy(n => n.Name().Length))
|
||||
@foreach (var assistant in Enum.GetValues<Components>().Where(n => n.AllowSendTo()).OrderBy(n => n.Name().Length))
|
||||
{
|
||||
if (assistant is Components.NONE || this.Component == assistant)
|
||||
continue;
|
||||
|
||||
<MudMenuItem OnClick="() => this.SendToAssistant(assistant, new())">
|
||||
@assistant.Name()
|
||||
</MudMenuItem>
|
||||
@@ -84,11 +81,8 @@
|
||||
|
||||
case SendToButton sendToButton:
|
||||
<MudMenu StartIcon="@Icons.Material.Filled.Apps" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="Send to ..." Variant="Variant.Filled" Style="@this.GetSendToColor()" Class="rounded">
|
||||
@foreach (var assistant in Enum.GetValues<Components>().OrderBy(n => n.Name().Length))
|
||||
@foreach (var assistant in Enum.GetValues<Components>().Where(n => n.AllowSendTo()).OrderBy(n => n.Name().Length))
|
||||
{
|
||||
if(assistant is Components.NONE || sendToButton.Self == assistant)
|
||||
continue;
|
||||
|
||||
<MudMenuItem OnClick="() => this.SendToAssistant(assistant, sendToButton)">
|
||||
@assistant.Name()
|
||||
</MudMenuItem>
|
||||
|
||||
@@ -267,6 +267,9 @@ public abstract partial class AssistantBase : ComponentBase, IMessageBusReceiver
|
||||
|
||||
protected Task SendToAssistant(Tools.Components destination, SendToButton sendToButton)
|
||||
{
|
||||
if (!destination.AllowSendTo())
|
||||
return Task.CompletedTask;
|
||||
|
||||
var contentToSend = sendToButton == default ? string.Empty : sendToButton.UseResultingContentBlockData switch
|
||||
{
|
||||
false => sendToButton.GetText(),
|
||||
|
||||
@@ -13,6 +13,9 @@ public partial class Changelog
|
||||
|
||||
public static readonly Log[] LOGS =
|
||||
[
|
||||
new (193, "v0.9.18, build 193 (2024-11-09 21:10 UTC)", "v0.9.18.md"),
|
||||
new (192, "v0.9.17, build 192 (2024-11-03 11:11 UTC)", "v0.9.17.md"),
|
||||
new (191, "v0.9.16, build 191 (2024-11-02 22:04 UTC)", "v0.9.16.md"),
|
||||
new (190, "v0.9.15, build 190 (2024-10-28 15:04 UTC)", "v0.9.15.md"),
|
||||
new (189, "v0.9.14, build 189 (2024-10-18 08:48 UTC)", "v0.9.14.md"),
|
||||
new (188, "v0.9.13, build 188 (2024-10-07 11:18 UTC)", "v0.9.13.md"),
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<div class="d-flex flex-column" style="@this.Height">
|
||||
<div class="@this.Classes" style="@this.Styles">
|
||||
@if (this.HeaderContent is not null)
|
||||
{
|
||||
<div>
|
||||
@this.HeaderContent
|
||||
</div>
|
||||
}
|
||||
<div class="flex-auto overflow-auto">
|
||||
@this.ChildContent
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace AIStudio.Components;
|
||||
|
||||
public partial class InnerScrolling : MSGComponentBase
|
||||
{
|
||||
[Parameter]
|
||||
public bool FillEntireHorizontalSpace { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set the height of anything above the scrolling content; usually a header.
|
||||
/// What we do is calc(100vh - HeaderHeight). Means, you can use multiple measures like
|
||||
@@ -14,6 +17,9 @@ public partial class InnerScrolling : MSGComponentBase
|
||||
[Parameter]
|
||||
public string HeaderHeight { get; set; } = "3em";
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? HeaderContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
@@ -22,6 +28,9 @@ public partial class InnerScrolling : MSGComponentBase
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? FooterContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string Class { get; set; } = string.Empty;
|
||||
|
||||
[CascadingParameter]
|
||||
private MainLayout MainLayout { get; set; } = null!;
|
||||
@@ -62,7 +71,9 @@ public partial class InnerScrolling : MSGComponentBase
|
||||
|
||||
#endregion
|
||||
|
||||
private string Height => $"height: calc(100vh - {this.HeaderHeight} - {this.MainLayout.AdditionalHeight});";
|
||||
private string Styles => this.FillEntireHorizontalSpace ? $"height: calc(100vh - {this.HeaderHeight} - {this.MainLayout.AdditionalHeight}); overflow-x: auto; min-width: 0;" : $"height: calc(100vh - {this.HeaderHeight} - {this.MainLayout.AdditionalHeight}); flex-shrink: 0;";
|
||||
|
||||
private string Classes => this.FillEntireHorizontalSpace ? $"{this.Class} d-flex flex-column flex-grow-1" : $"{this.Class} d-flex flex-column";
|
||||
|
||||
public async Task ScrollToBottom()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<MudListItem T="string" Icon="@this.Icon">
|
||||
@if (string.IsNullOrWhiteSpace(this.URL))
|
||||
{
|
||||
<MudText Typo="Typo.body1" Inline="@true">
|
||||
@this.Name
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudLink Href="@this.URL" Target="_blank">
|
||||
@this.Name
|
||||
</MudLink>
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.body1" Class="ml-3" Inline="@true">
|
||||
@this.Acknowledgment
|
||||
</MudText>
|
||||
</MudListItem>
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class Supporter : ComponentBase
|
||||
{
|
||||
[Parameter]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string Acknowledgment { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string? URL { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public SupporterType Type { get; set; }
|
||||
|
||||
private string Icon => this.Type switch
|
||||
{
|
||||
SupporterType.INDIVIDUAL => Icons.Material.Filled.Person,
|
||||
SupporterType.ORGANIZATION => Icons.Material.Filled.Business,
|
||||
|
||||
_ => Icons.Material.Filled.Person4,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public enum SupporterType
|
||||
{
|
||||
UNKNOWN,
|
||||
INDIVIDUAL,
|
||||
ORGANIZATION
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<MudTreeView T="ITreeItem" Items="@this.treeItems" SelectionMode="SelectionMode.SingleSelection" Hover="@true" ExpandOnClick="@true">
|
||||
<MudTreeView T="ITreeItem" Items="@this.treeItems" SelectionMode="SelectionMode.SingleSelection" Hover="@true" ExpandOnClick="@true" Class="ma-3">
|
||||
<ItemTemplate Context="item">
|
||||
@switch (item.Value)
|
||||
{
|
||||
@@ -11,7 +11,7 @@
|
||||
case TreeItemData treeItem:
|
||||
@if (treeItem.Type is TreeItemType.CHAT)
|
||||
{
|
||||
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" CanExpand="@treeItem.Expandable" Items="@treeItem.Children" OnClick="() => this.LoadChat(treeItem.Path, true)">
|
||||
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" 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;">
|
||||
@@ -44,7 +44,7 @@
|
||||
}
|
||||
else if (treeItem.Type is TreeItemType.WORKSPACE)
|
||||
{
|
||||
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" CanExpand="@treeItem.Expandable" Items="@treeItem.Children">
|
||||
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" 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>
|
||||
@@ -63,7 +63,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" CanExpand="@treeItem.Expandable" Items="@treeItem.Children">
|
||||
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" 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>
|
||||
|
||||
@@ -35,6 +35,9 @@ public partial class Workspaces : ComponentBase
|
||||
[Parameter]
|
||||
public Func<Task> LoadedChatWasChanged { get; set; } = () => Task.CompletedTask;
|
||||
|
||||
[Parameter]
|
||||
public bool ExpandRootNodes { get; set; } = true;
|
||||
|
||||
private const Placement WORKSPACE_ITEM_TOOLTIP_PLACEMENT = Placement.Bottom;
|
||||
|
||||
public static readonly Guid WORKSPACE_ID_BIAS = Guid.Parse("82050a4e-ee92-43d7-8ee5-ab512f847e02");
|
||||
@@ -73,9 +76,9 @@ public partial class Workspaces : ComponentBase
|
||||
|
||||
private async Task LoadTreeItems()
|
||||
{
|
||||
this.treeItems.Clear();
|
||||
this.treeItems.Add(new TreeItemData<ITreeItem>
|
||||
var workspacesNode = new TreeItemData<ITreeItem>
|
||||
{
|
||||
Expanded = this.ExpandRootNodes,
|
||||
Expandable = true,
|
||||
Value = new TreeItemData
|
||||
{
|
||||
@@ -87,16 +90,11 @@ public partial class Workspaces : ComponentBase
|
||||
Path = "root",
|
||||
Children = await this.LoadWorkspaces(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
this.treeItems.Add(new TreeItemData<ITreeItem>
|
||||
{
|
||||
Expandable = false,
|
||||
Value = new TreeDivider(),
|
||||
});
|
||||
|
||||
this.treeItems.Add(new TreeItemData<ITreeItem>
|
||||
var tempChatNode = new TreeItemData<ITreeItem>
|
||||
{
|
||||
Expanded = this.ExpandRootNodes,
|
||||
Expandable = true,
|
||||
Value = new TreeItemData
|
||||
{
|
||||
@@ -108,7 +106,16 @@ public partial class Workspaces : ComponentBase
|
||||
Path = "temp",
|
||||
Children = await this.LoadTemporaryChats(),
|
||||
},
|
||||
};
|
||||
|
||||
this.treeItems.Clear();
|
||||
this.treeItems.Add(workspacesNode);
|
||||
this.treeItems.Add(new TreeItemData<ITreeItem>
|
||||
{
|
||||
Expandable = false,
|
||||
Value = new TreeDivider(),
|
||||
});
|
||||
this.treeItems.Add(tempChatNode);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<TreeItemData<ITreeItem>>> LoadTemporaryChats()
|
||||
|
||||
@@ -105,7 +105,7 @@ public partial class ProviderDialog : ComponentBase
|
||||
Id = this.DataId,
|
||||
InstanceName = this.DataInstanceName,
|
||||
UsedLLMProvider = this.DataLLMProvider,
|
||||
Model = this.DataLLMProvider is LLMProviders.FIREWORKS ? new Model(this.dataManuallyModel) : this.DataModel,
|
||||
Model = this.DataLLMProvider is LLMProviders.FIREWORKS ? new Model(this.dataManuallyModel, null) : this.DataModel,
|
||||
IsSelfHosted = this.DataLLMProvider is LLMProviders.SELF_HOSTED,
|
||||
Hostname = this.DataHostname.EndsWith('/') ? this.DataHostname[..^1] : this.DataHostname,
|
||||
Host = this.DataHost,
|
||||
@@ -357,7 +357,9 @@ public partial class ProviderDialog : ComponentBase
|
||||
LLMProviders.OPEN_AI => true,
|
||||
LLMProviders.MISTRAL => true,
|
||||
LLMProviders.ANTHROPIC => true,
|
||||
LLMProviders.GOOGLE => true,
|
||||
|
||||
LLMProviders.GROQ => true,
|
||||
LLMProviders.FIREWORKS => true,
|
||||
|
||||
_ => false,
|
||||
@@ -368,8 +370,11 @@ public partial class ProviderDialog : ComponentBase
|
||||
LLMProviders.OPEN_AI => true,
|
||||
LLMProviders.MISTRAL => true,
|
||||
LLMProviders.ANTHROPIC => true,
|
||||
LLMProviders.GOOGLE => true,
|
||||
|
||||
LLMProviders.GROQ => true,
|
||||
LLMProviders.FIREWORKS => true,
|
||||
|
||||
LLMProviders.SELF_HOSTED => this.DataHost is Host.OLLAMA,
|
||||
|
||||
_ => false,
|
||||
@@ -411,7 +416,9 @@ public partial class ProviderDialog : ComponentBase
|
||||
LLMProviders.OPEN_AI => "https://platform.openai.com/signup",
|
||||
LLMProviders.MISTRAL => "https://console.mistral.ai/",
|
||||
LLMProviders.ANTHROPIC => "https://console.anthropic.com/dashboard",
|
||||
|
||||
LLMProviders.GOOGLE => "https://console.cloud.google.com/",
|
||||
|
||||
LLMProviders.GROQ => "https://console.groq.com/",
|
||||
LLMProviders.FIREWORKS => "https://fireworks.ai/login",
|
||||
|
||||
_ => string.Empty,
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
}
|
||||
|
||||
<MudMainContent Class="mud-height-full pt-1" Style="@this.PaddingLeft">
|
||||
<MudContainer Fixed="@true" Class="mud-height-full" Style="margin-left: 5em; width: calc(100% - 5em);">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" 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">
|
||||
|
||||
@@ -16,82 +16,125 @@
|
||||
</MudText>
|
||||
|
||||
<ProviderSelection @bind-ProviderSettings="@this.providerSettings"/>
|
||||
<InnerScrolling @ref="@this.scrollingArea" HeaderHeight="12.3em">
|
||||
<ChildContent>
|
||||
@if (this.chatThread is not null)
|
||||
<MudStack Row="@true" Style="width: 100%; overflow: hidden;">
|
||||
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES
|
||||
&& this.SettingsManager.ConfigurationData.Workspace.DisplayBehavior is WorkspaceDisplayBehavior.TOGGLE_SIDEBAR
|
||||
&& !this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible)
|
||||
{
|
||||
<MudPaper Class="border border-solid rounded-lg">
|
||||
<MudTooltip Text="Show your workspaces" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Size="Size.Large" Icon="@this.WorkspaceSidebarToggleIcon" OnClick="() => this.ToggleWorkspaceSidebar()"/>
|
||||
</MudTooltip>
|
||||
</MudPaper>
|
||||
}
|
||||
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES)
|
||||
{
|
||||
@if ((this.SettingsManager.ConfigurationData.Workspace.DisplayBehavior is WorkspaceDisplayBehavior.TOGGLE_SIDEBAR && this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible) || this.SettingsManager.ConfigurationData.Workspace.DisplayBehavior is WorkspaceDisplayBehavior.SIDEBAR_ALWAYS_VISIBLE)
|
||||
{
|
||||
foreach (var block in this.chatThread.Blocks.OrderBy(n => n.Time))
|
||||
@if (this.SettingsManager.ConfigurationData.Workspace.DisplayBehavior is WorkspaceDisplayBehavior.TOGGLE_SIDEBAR && this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible)
|
||||
{
|
||||
@if (!block.HideFromUser)
|
||||
{
|
||||
<ContentBlockComponent Role="@block.Role" Type="@block.ContentType" Time="@block.Time" Content="@block.Content"/>
|
||||
}
|
||||
<InnerScrolling HeaderHeight="12.3em" Class="border border-solid rounded-lg">
|
||||
<HeaderContent>
|
||||
<MudTooltip Text="Hide your workspaces" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Size="Size.Large" Icon="@this.WorkspaceSidebarToggleIcon" OnClick="() => this.ToggleWorkspaceSidebar()"/>
|
||||
</MudTooltip>
|
||||
</HeaderContent>
|
||||
<ChildContent>
|
||||
<Workspaces @ref="this.workspaces" @bind-CurrentChatThread="@this.chatThread" LoadedChatWasChanged="this.LoadedChatChanged"/>
|
||||
</ChildContent>
|
||||
</InnerScrolling>
|
||||
}
|
||||
else
|
||||
{
|
||||
<InnerScrolling HeaderHeight="12.3em" Class="border border-solid rounded-lg">
|
||||
<ChildContent>
|
||||
<Workspaces @ref="this.workspaces" @bind-CurrentChatThread="@this.chatThread" LoadedChatWasChanged="this.LoadedChatChanged"/>
|
||||
</ChildContent>
|
||||
</InnerScrolling>
|
||||
}
|
||||
}
|
||||
</ChildContent>
|
||||
<FooterContent>
|
||||
<MudElement 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" Class="@this.UserInputClass" Style="@this.UserInputStyle"/>
|
||||
</MudElement>
|
||||
<MudToolBar WrapContent="true" Gutters="@false" Class="border border-solid rounded" Style="border-color: lightgrey;">
|
||||
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES)
|
||||
}
|
||||
<InnerScrolling FillEntireHorizontalSpace="@true" @ref="@this.scrollingArea" HeaderHeight="12.3em">
|
||||
<ChildContent>
|
||||
@if (this.chatThread is not null)
|
||||
{
|
||||
<MudTooltip Text="Your workspaces" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SnippetFolder" OnClick="() => this.ToggleWorkspaces()"/>
|
||||
foreach (var block in this.chatThread.Blocks.OrderBy(n => n.Time))
|
||||
{
|
||||
@if (!block.HideFromUser)
|
||||
{
|
||||
<ContentBlockComponent Role="@block.Role" Type="@block.ContentType" Time="@block.Time" Content="@block.Content"/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</ChildContent>
|
||||
<FooterContent>
|
||||
<MudElement 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" Class="@this.UserInputClass" Style="@this.UserInputStyle"/>
|
||||
</MudElement>
|
||||
<MudToolBar WrapContent="true" Gutters="@false" Class="border border-solid rounded" Style="border-color: lightgrey;">
|
||||
@if (
|
||||
this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES
|
||||
&& this.SettingsManager.ConfigurationData.Workspace.DisplayBehavior is WorkspaceDisplayBehavior.TOGGLE_OVERLAY)
|
||||
{
|
||||
<MudTooltip Text="Show your workspaces" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SnippetFolder" OnClick="() => this.ToggleWorkspaceOverlay()"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior 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 (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior 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>
|
||||
}
|
||||
@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>
|
||||
}
|
||||
|
||||
<MudTooltip Text="Start temporary chat" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.AddComment" OnClick="() => this.StartNewChat(useSameWorkspace: false)"/>
|
||||
</MudTooltip>
|
||||
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior 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 (!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.Workspace.StorageBehavior 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.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES)
|
||||
{
|
||||
<MudTooltip Text="Move the chat to a workspace, or to another if it is already in one." Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Disabled="@(!this.CanThreadBeSaved)" OnClick="() => this.MoveChatToWorkspace()"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES)
|
||||
{
|
||||
<MudTooltip Text="Move the chat to a workspace, or to another if it is already in one." Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Disabled="@(!this.CanThreadBeSaved)" OnClick="() => this.MoveChatToWorkspace()"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
@if (this.SettingsManager.ConfigurationData.LLMProviders.ShowProviderConfidence)
|
||||
{
|
||||
<ConfidenceInfo Mode="ConfidenceInfoMode.ICON" LLMProvider="@this.providerSettings.UsedLLMProvider"/>
|
||||
}
|
||||
|
||||
@if (this.SettingsManager.ConfigurationData.LLMProviders.ShowProviderConfidence)
|
||||
{
|
||||
<ConfidenceInfo Mode="ConfidenceInfoMode.ICON" LLMProvider="@this.providerSettings.UsedLLMProvider"/>
|
||||
}
|
||||
<ProfileSelection CurrentProfile="@this.currentProfile" CurrentProfileChanged="@this.ProfileWasChanged" />
|
||||
</MudToolBar>
|
||||
</FooterContent>
|
||||
</InnerScrolling>
|
||||
</MudStack>
|
||||
|
||||
<ProfileSelection CurrentProfile="@this.currentProfile" CurrentProfileChanged="@this.ProfileWasChanged" />
|
||||
</MudToolBar>
|
||||
</FooterContent>
|
||||
</InnerScrolling>
|
||||
|
||||
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior != WorkspaceStorageBehavior.DISABLE_WORKSPACES)
|
||||
@if (
|
||||
this.SettingsManager.ConfigurationData.Workspace.StorageBehavior != WorkspaceStorageBehavior.DISABLE_WORKSPACES
|
||||
&& this.SettingsManager.ConfigurationData.Workspace.DisplayBehavior is WorkspaceDisplayBehavior.TOGGLE_OVERLAY)
|
||||
{
|
||||
<MudDrawer @bind-Open="@this.workspacesVisible" Width="40em" Height="100%" Anchor="Anchor.Start" Variant="DrawerVariant.Temporary" Elevation="1">
|
||||
<MudDrawer @bind-Open="@this.workspaceOverlayVisible" 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()"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close" Variant="Variant.Filled" Color="Color.Default" Size="Size.Small" OnClick="() => this.ToggleWorkspaceOverlay()"/>
|
||||
</MudStack>
|
||||
</MudDrawerHeader>
|
||||
<MudDrawerContainer Class="ml-6">
|
||||
|
||||
@@ -42,7 +42,7 @@ public partial class Chat : MSGComponentBase, IAsyncDisposable
|
||||
private string userInput = string.Empty;
|
||||
private string currentWorkspaceName = string.Empty;
|
||||
private Guid currentWorkspaceId = Guid.Empty;
|
||||
private bool workspacesVisible;
|
||||
private bool workspaceOverlayVisible;
|
||||
private Workspaces? workspaces;
|
||||
private bool mustScrollToBottomAfterRender;
|
||||
private bool mustStoreChat;
|
||||
@@ -157,6 +157,8 @@ public partial class Chat : MSGComponentBase, IAsyncDisposable
|
||||
private string UserInputStyle => this.SettingsManager.ConfigurationData.LLMProviders.ShowProviderConfidence ? this.providerSettings.UsedLLMProvider.GetConfidence(this.SettingsManager).SetColorStyle(this.SettingsManager) : string.Empty;
|
||||
|
||||
private string UserInputClass => this.SettingsManager.ConfigurationData.LLMProviders.ShowProviderConfidence ? "confidence-border" : string.Empty;
|
||||
|
||||
private string WorkspaceSidebarToggleIcon => this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible ? Icons.Material.Filled.ArrowCircleLeft : Icons.Material.Filled.ArrowCircleRight;
|
||||
|
||||
private void ProfileWasChanged(Profile profile)
|
||||
{
|
||||
@@ -308,9 +310,15 @@ public partial class Chat : MSGComponentBase, IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleWorkspaces()
|
||||
private void ToggleWorkspaceOverlay()
|
||||
{
|
||||
this.workspacesVisible = !this.workspacesVisible;
|
||||
this.workspaceOverlayVisible = !this.workspaceOverlayVisible;
|
||||
}
|
||||
|
||||
private async Task ToggleWorkspaceSidebar()
|
||||
{
|
||||
this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible = !this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible;
|
||||
await this.SettingsManager.StoreSettings();
|
||||
}
|
||||
|
||||
private async Task SaveThread()
|
||||
|
||||
@@ -202,7 +202,12 @@
|
||||
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Work" HeaderText="Workspace Options">
|
||||
<ConfigurationSelect OptionDescription="Workspace behavior" SelectedValue="@(() => this.SettingsManager.ConfigurationData.Workspace.StorageBehavior)" Data="@ConfigurationSelectDataFactory.GetWorkspaceStorageBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.Workspace.StorageBehavior = selectedValue)" OptionHelp="Should we store your chats?"/>
|
||||
<ConfigurationSelect OptionDescription="Workspace maintenance" SelectedValue="@(() => this.SettingsManager.ConfigurationData.Workspace.StorageTemporaryMaintenancePolicy)" Data="@ConfigurationSelectDataFactory.GetWorkspaceStorageTemporaryMaintenancePolicyData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.Workspace.StorageTemporaryMaintenancePolicy = selectedValue)" OptionHelp="If and when should we delete your temporary chats?"/>
|
||||
|
||||
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES)
|
||||
{
|
||||
<ConfigurationSelect OptionDescription="Workspace maintenance" SelectedValue="@(() => this.SettingsManager.ConfigurationData.Workspace.StorageTemporaryMaintenancePolicy)" Data="@ConfigurationSelectDataFactory.GetWorkspaceStorageTemporaryMaintenancePolicyData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.Workspace.StorageTemporaryMaintenancePolicy = selectedValue)" OptionHelp="If and when should we delete your temporary chats?"/>
|
||||
<ConfigurationSelect OptionDescription="Workspace display behavior" SelectedValue="@(() => this.SettingsManager.ConfigurationData.Workspace.DisplayBehavior)" Data="@ConfigurationSelectDataFactory.GetWorkspaceDisplayBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.Workspace.DisplayBehavior = selectedValue)" OptionHelp="How should we display your workspaces?"/>
|
||||
}
|
||||
</ExpansionPanel>
|
||||
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.FindInPage" HeaderText="Assistant: Icon Finder Options">
|
||||
|
||||
@@ -131,7 +131,9 @@ public partial class Settings : ComponentBase, IMessageBusReceiver, IDisposable
|
||||
LLMProviders.OPEN_AI => true,
|
||||
LLMProviders.MISTRAL => true,
|
||||
LLMProviders.ANTHROPIC => true,
|
||||
LLMProviders.GROQ => true,
|
||||
LLMProviders.FIREWORKS => true,
|
||||
LLMProviders.GOOGLE => true,
|
||||
|
||||
_ => false,
|
||||
};
|
||||
@@ -141,6 +143,8 @@ public partial class Settings : ComponentBase, IMessageBusReceiver, IDisposable
|
||||
LLMProviders.OPEN_AI => "https://platform.openai.com/usage",
|
||||
LLMProviders.MISTRAL => "https://console.mistral.ai/usage/",
|
||||
LLMProviders.ANTHROPIC => "https://console.anthropic.com/settings/plans",
|
||||
LLMProviders.GROQ => "https://console.groq.com/settings/usage",
|
||||
LLMProviders.GOOGLE => "https://console.cloud.google.com/billing",
|
||||
LLMProviders.FIREWORKS => "https://fireworks.ai/account/billing",
|
||||
|
||||
_ => string.Empty,
|
||||
|
||||
@@ -33,14 +33,8 @@
|
||||
The first 10 supporters who make a monthly contribution:
|
||||
</MudText>
|
||||
<MudList T="string">
|
||||
<MudListItem T="string" Icon="@Icons.Material.Filled.Person">
|
||||
<MudLink Href="https://github.com/richard-stanton" Target="_blank">
|
||||
richard-stanton
|
||||
</MudLink>
|
||||
<MudText Typo="Typo.body1" Class="ml-3" Inline="@true">
|
||||
Thank you, Richard, for being the first.
|
||||
</MudText>
|
||||
</MudListItem>
|
||||
<Supporter Name="richard-stanton" Type="SupporterType.INDIVIDUAL" URL="https://github.com/richard-stanton" Acknowledgment="Thank you, Richard, for being the first."/>
|
||||
<Supporter Name="peerschuett" Type="SupporterType.INDIVIDUAL" URL="https://github.com/peerschuett" Acknowledgment="Thank you, Peer, for your courage in being the second person to support the project financially."/>
|
||||
</MudList>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
@@ -147,10 +147,18 @@ public sealed class ProviderAnthropic(ILogger logger) : BaseProvider("https://ap
|
||||
{
|
||||
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"),
|
||||
new Model("claude-3-5-sonnet-latest", "Claude 3.5 Sonnet (latest)"),
|
||||
new Model("claude-3-5-sonnet-20240620", "Claude 3.5 Sonnet (20. June 2024)"),
|
||||
new Model("claude-3-5-sonnet-20241022", "Claude 3.5 Sonnet (22. October 2024)"),
|
||||
|
||||
new Model("claude-3-5-haiku-latest", "Claude 3.5 Haiku (latest)"),
|
||||
new Model("claude-3-5-heiku-20241022", "Claude 3.5 Haiku (22. October 2024)"),
|
||||
|
||||
new Model("claude-3-opus-20240229", "Claude 3.0 Opus (29. February 2024)"),
|
||||
new Model("claude-3-opus-latest", "Claude 3.0 Opus (latest)"),
|
||||
|
||||
new Model("claude-3-sonnet-20240229", "Claude 3.0 Sonnet (29. February 2024)"),
|
||||
new Model("claude-3-haiku-20240307", "Claude 3.0 Haiku (7. March 2024)"),
|
||||
}.AsEnumerable());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using AIStudio.Provider.OpenAI;
|
||||
|
||||
namespace AIStudio.Provider.Google;
|
||||
|
||||
/// <summary>
|
||||
/// The Google 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>
|
||||
public readonly record struct ChatRequest(
|
||||
string Model,
|
||||
IList<Message> Messages,
|
||||
bool Stream
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Provider.Google;
|
||||
|
||||
public readonly record struct Model(string Name, string DisplayName);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace AIStudio.Provider.Google;
|
||||
|
||||
/// <summary>
|
||||
/// A data model for the response from the model endpoint.
|
||||
/// </summary>
|
||||
/// <param name="Models"></param>
|
||||
public readonly record struct ModelsResponse(IList<Model> Models);
|
||||
@@ -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;
|
||||
|
||||
namespace AIStudio.Provider.Google;
|
||||
|
||||
public class ProviderGoogle(ILogger logger) : BaseProvider("https://generativelanguage.googleapis.com/v1beta/", logger), IProvider
|
||||
{
|
||||
private static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
};
|
||||
|
||||
#region Implementation of IProvider
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => "Google";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string InstanceName { get; set; } = "Google Gemini";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<string> StreamChatCompletion(Provider.Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default)
|
||||
{
|
||||
// Get the API key:
|
||||
var requestedSecret = await RUST_SERVICE.GetAPIKey(this);
|
||||
if(!requestedSecret.Success)
|
||||
yield break;
|
||||
|
||||
// Prepare the system prompt:
|
||||
var systemPrompt = new Message
|
||||
{
|
||||
Role = "system",
|
||||
Content = chatThread.SystemPrompt,
|
||||
};
|
||||
|
||||
// Prepare the Google HTTP chat request:
|
||||
var geminiChatRequest = 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 Message
|
||||
{
|
||||
Role = n.Role switch
|
||||
{
|
||||
ChatRole.USER => "user",
|
||||
ChatRole.AI => "assistant",
|
||||
ChatRole.AGENT => "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,
|
||||
}, 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", await requestedSecret.Secret.Decrypt(ENCRYPTION));
|
||||
|
||||
// Set the content:
|
||||
request.Content = new StringContent(geminiChatRequest, 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 geminiStream = await response.Content.ReadAsStreamAsync(token);
|
||||
|
||||
// Add a stream reader to read the stream, line by line:
|
||||
var streamReader = new StreamReader(geminiStream);
|
||||
|
||||
// Read the stream, line by line:
|
||||
while(!streamReader.EndOfStream)
|
||||
{
|
||||
// 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 geminiResponse;
|
||||
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:
|
||||
geminiResponse = JsonSerializer.Deserialize<ResponseStreamLine>(jsonData, JSON_SERIALIZER_OPTIONS);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip invalid JSON data:
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty responses:
|
||||
if(geminiResponse == default || geminiResponse.Choices.Count == 0)
|
||||
continue;
|
||||
|
||||
// Yield the response:
|
||||
yield return geminiResponse.Choices[0].Delta.Content;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ImageURL> StreamImageCompletion(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 Task<IEnumerable<Provider.Model>> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
return this.LoadModels(token, apiKeyProvisional);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IEnumerable<Provider.Model>> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
return Task.FromResult(Enumerable.Empty<Provider.Model>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task<IEnumerable<Provider.Model>> LoadModels(CancellationToken token, string? apiKeyProvisional = null)
|
||||
{
|
||||
var secretKey = apiKeyProvisional switch
|
||||
{
|
||||
not null => apiKeyProvisional,
|
||||
_ => await RUST_SERVICE.GetAPIKey(this) switch
|
||||
{
|
||||
{ Success: true } result => await result.Secret.Decrypt(ENCRYPTION),
|
||||
_ => null,
|
||||
}
|
||||
};
|
||||
|
||||
if (secretKey is null)
|
||||
return [];
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"models?key={secretKey}");
|
||||
var response = await this.httpClient.SendAsync(request, token);
|
||||
|
||||
if(!response.IsSuccessStatusCode)
|
||||
return [];
|
||||
|
||||
var modelResponse = await response.Content.ReadFromJsonAsync<ModelsResponse>(token);
|
||||
return modelResponse.Models.Where(model =>
|
||||
model.Name.StartsWith("models/gemini-", StringComparison.InvariantCultureIgnoreCase))
|
||||
.Select(n => new Provider.Model(n.Name.Replace("models/", string.Empty), n.DisplayName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using AIStudio.Provider.OpenAI;
|
||||
|
||||
namespace AIStudio.Provider.Groq;
|
||||
|
||||
/// <summary>
|
||||
/// The Groq 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="Seed">The seed for the chat completion.</param>
|
||||
public readonly record struct ChatRequest(
|
||||
string Model,
|
||||
IList<Message> Messages,
|
||||
bool Stream,
|
||||
int Seed
|
||||
);
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Provider.OpenAI;
|
||||
|
||||
namespace AIStudio.Provider.Groq;
|
||||
|
||||
public class ProviderGroq(ILogger logger) : BaseProvider("https://api.groq.com/openai/v1/", logger), IProvider
|
||||
{
|
||||
private static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
};
|
||||
|
||||
#region Implementation of IProvider
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => "Groq";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string InstanceName { get; set; } = "Groq";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<string> StreamChatCompletion(Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default)
|
||||
{
|
||||
// Get the API key:
|
||||
var requestedSecret = await RUST_SERVICE.GetAPIKey(this);
|
||||
if(!requestedSecret.Success)
|
||||
yield break;
|
||||
|
||||
// Prepare the system prompt:
|
||||
var systemPrompt = new Message
|
||||
{
|
||||
Role = "system",
|
||||
Content = chatThread.SystemPrompt,
|
||||
};
|
||||
|
||||
// Prepare the OpenAI HTTP chat request:
|
||||
var groqChatRequest = 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 Message
|
||||
{
|
||||
Role = n.Role switch
|
||||
{
|
||||
ChatRole.USER => "user",
|
||||
ChatRole.AI => "assistant",
|
||||
ChatRole.AGENT => "assistant",
|
||||
ChatRole.SYSTEM => "system",
|
||||
|
||||
_ => "user",
|
||||
},
|
||||
|
||||
Content = n.Content switch
|
||||
{
|
||||
ContentText text => text.Text,
|
||||
_ => string.Empty,
|
||||
}
|
||||
}).ToList()],
|
||||
|
||||
Seed = chatThread.Seed,
|
||||
|
||||
// Right now, we only support streaming completions:
|
||||
Stream = true,
|
||||
}, 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", await requestedSecret.Secret.Decrypt(ENCRYPTION));
|
||||
|
||||
// Set the content:
|
||||
request.Content = new StringContent(groqChatRequest, 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 groqStream = await response.Content.ReadAsStreamAsync(token);
|
||||
|
||||
// Add a stream reader to read the stream, line by line:
|
||||
var streamReader = new StreamReader(groqStream);
|
||||
|
||||
// Read the stream, line by line:
|
||||
while(!streamReader.EndOfStream)
|
||||
{
|
||||
// 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 groqResponse;
|
||||
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:
|
||||
groqResponse = JsonSerializer.Deserialize<ResponseStreamLine>(jsonData, JSON_SERIALIZER_OPTIONS);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip invalid JSON data:
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty responses:
|
||||
if(groqResponse == default || groqResponse.Choices.Count == 0)
|
||||
continue;
|
||||
|
||||
// Yield the response:
|
||||
yield return groqResponse.Choices[0].Delta.Content;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ImageURL> StreamImageCompletion(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(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
return this.LoadModels(token, apiKeyProvisional);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IEnumerable<Model>> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<Model>>(Array.Empty<Model>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task<IEnumerable<Model>> LoadModels(CancellationToken token, string? apiKeyProvisional = null)
|
||||
{
|
||||
var secretKey = apiKeyProvisional switch
|
||||
{
|
||||
not null => apiKeyProvisional,
|
||||
_ => await RUST_SERVICE.GetAPIKey(this) switch
|
||||
{
|
||||
{ Success: true } result => await result.Secret.Decrypt(ENCRYPTION),
|
||||
_ => 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("whisper-", StringComparison.InvariantCultureIgnoreCase) &&
|
||||
!n.Id.StartsWith("distil-", StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ public enum LLMProviders
|
||||
OPEN_AI = 1,
|
||||
ANTHROPIC = 2,
|
||||
MISTRAL = 3,
|
||||
GOOGLE = 7,
|
||||
|
||||
FIREWORKS = 5,
|
||||
GROQ = 6,
|
||||
|
||||
SELF_HOSTED = 4,
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using AIStudio.Provider.Anthropic;
|
||||
using AIStudio.Provider.Fireworks;
|
||||
using AIStudio.Provider.Google;
|
||||
using AIStudio.Provider.Groq;
|
||||
using AIStudio.Provider.Mistral;
|
||||
using AIStudio.Provider.OpenAI;
|
||||
using AIStudio.Provider.SelfHosted;
|
||||
@@ -21,7 +23,9 @@ public static class LLMProvidersExtensions
|
||||
LLMProviders.OPEN_AI => "OpenAI",
|
||||
LLMProviders.ANTHROPIC => "Anthropic",
|
||||
LLMProviders.MISTRAL => "Mistral",
|
||||
LLMProviders.GOOGLE => "Google",
|
||||
|
||||
LLMProviders.GROQ => "Groq",
|
||||
LLMProviders.FIREWORKS => "Fireworks.ai",
|
||||
|
||||
LLMProviders.SELF_HOSTED => "Self-hosted",
|
||||
@@ -48,6 +52,10 @@ public static class LLMProvidersExtensions
|
||||
"https://openai.com/enterprise-privacy/"
|
||||
).WithLevel(settingsManager.GetConfiguredConfidenceLevel(llmProvider)),
|
||||
|
||||
LLMProviders.GOOGLE => Confidence.USA_NO_TRAINING.WithRegion("America, U.S.").WithSources("https://ai.google.dev/gemini-api/terms").WithLevel(settingsManager.GetConfiguredConfidenceLevel(llmProvider)),
|
||||
|
||||
LLMProviders.GROQ => Confidence.USA_NO_TRAINING.WithRegion("America, U.S.").WithSources("https://wow.groq.com/terms-of-use/").WithLevel(settingsManager.GetConfiguredConfidenceLevel(llmProvider)),
|
||||
|
||||
LLMProviders.ANTHROPIC => Confidence.USA_NO_TRAINING.WithRegion("America, U.S.").WithSources("https://www.anthropic.com/legal/commercial-terms").WithLevel(settingsManager.GetConfiguredConfidenceLevel(llmProvider)),
|
||||
|
||||
LLMProviders.MISTRAL => Confidence.GDPR_NO_TRAINING.WithRegion("Europe, France").WithSources("https://mistral.ai/terms/#terms-of-service-la-plateforme").WithLevel(settingsManager.GetConfiguredConfidenceLevel(llmProvider)),
|
||||
@@ -72,7 +80,9 @@ public static class LLMProvidersExtensions
|
||||
LLMProviders.OPEN_AI => new ProviderOpenAI(logger) { InstanceName = providerSettings.InstanceName },
|
||||
LLMProviders.ANTHROPIC => new ProviderAnthropic(logger) { InstanceName = providerSettings.InstanceName },
|
||||
LLMProviders.MISTRAL => new ProviderMistral(logger) { InstanceName = providerSettings.InstanceName },
|
||||
LLMProviders.GOOGLE => new ProviderGoogle(logger) { InstanceName = providerSettings.InstanceName },
|
||||
|
||||
LLMProviders.GROQ => new ProviderGroq(logger) { InstanceName = providerSettings.InstanceName },
|
||||
LLMProviders.FIREWORKS => new ProviderFireworks(logger) { InstanceName = providerSettings.InstanceName },
|
||||
|
||||
LLMProviders.SELF_HOSTED => new ProviderSelfHosted(logger, providerSettings) { InstanceName = providerSettings.InstanceName },
|
||||
|
||||
@@ -173,7 +173,7 @@ public sealed class ProviderMistral(ILogger logger) : BaseProvider("https://api.
|
||||
return modelResponse.Data.Where(n =>
|
||||
!n.Id.StartsWith("code", StringComparison.InvariantCulture) &&
|
||||
!n.Id.Contains("embed", StringComparison.InvariantCulture))
|
||||
.Select(n => new Provider.Model(n.Id));
|
||||
.Select(n => new Provider.Model(n.Id, null));
|
||||
}
|
||||
|
||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||
|
||||
@@ -4,11 +4,33 @@ namespace AIStudio.Provider;
|
||||
/// The data model for the model to use.
|
||||
/// </summary>
|
||||
/// <param name="Id">The model's ID.</param>
|
||||
public readonly record struct Model(string Id)
|
||||
/// <param name="DisplayName">The model's display name.</param>
|
||||
public readonly record struct Model(string Id, string? DisplayName)
|
||||
{
|
||||
#region Overrides of ValueType
|
||||
|
||||
public override string ToString() => string.IsNullOrWhiteSpace(this.Id) ? "no model selected" : this.Id;
|
||||
public override string ToString()
|
||||
{
|
||||
if(!string.IsNullOrWhiteSpace(this.DisplayName))
|
||||
return this.DisplayName;
|
||||
|
||||
if(!string.IsNullOrWhiteSpace(this.Id))
|
||||
return this.Id;
|
||||
|
||||
return "no model selected";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Implementation of IEquatable<Model?>
|
||||
|
||||
public bool Equals(Model? other)
|
||||
{
|
||||
if(other is null)
|
||||
return false;
|
||||
|
||||
return this.Id == other.Value.Id;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -164,7 +164,7 @@ public sealed class ProviderSelfHosted(ILogger logger, Settings.Provider provide
|
||||
case Host.LLAMACPP:
|
||||
// Right now, llama.cpp only supports one model.
|
||||
// There is no API to list the model(s).
|
||||
return [ new Provider.Model("as configured by llama.cpp") ];
|
||||
return [ new Provider.Model("as configured by llama.cpp", null) ];
|
||||
|
||||
case Host.LM_STUDIO:
|
||||
case Host.OLLAMA:
|
||||
@@ -188,7 +188,7 @@ public sealed class ProviderSelfHosted(ILogger logger, Settings.Provider provide
|
||||
return [];
|
||||
|
||||
var lmStudioModelResponse = await lmStudioResponse.Content.ReadFromJsonAsync<ModelsResponse>(token);
|
||||
return lmStudioModelResponse.Data.Select(n => new Provider.Model(n.Id));
|
||||
return lmStudioModelResponse.Data.Select(n => new Provider.Model(n.Id, null));
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -58,6 +58,13 @@ public static class ConfigurationSelectDataFactory
|
||||
yield return new("Delete temporary chats older than 1 year", WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_365_DAYS);
|
||||
}
|
||||
|
||||
public static IEnumerable<ConfigurationSelectData<WorkspaceDisplayBehavior>> GetWorkspaceDisplayBehaviorData()
|
||||
{
|
||||
yield return new("Toggle the overlay: the chat uses all the space, workspaces are temporarily shown", WorkspaceDisplayBehavior.TOGGLE_OVERLAY);
|
||||
yield return new("Toggle the sidebar: show the workspaces next to the chat when desired", WorkspaceDisplayBehavior.TOGGLE_SIDEBAR);
|
||||
yield return new("Sidebar is always visible: show the workspaces next to the chat all the time", WorkspaceDisplayBehavior.SIDEBAR_ALWAYS_VISIBLE);
|
||||
}
|
||||
|
||||
public static IEnumerable<ConfigurationSelectData<NavBehavior>> GetNavBehaviorData()
|
||||
{
|
||||
yield return new("Navigation expands on mouse hover", NavBehavior.EXPAND_ON_HOVER);
|
||||
|
||||
@@ -1196,7 +1196,7 @@ public static class BiasCatalog
|
||||
|
||||
McDaniel and Einstein argues that bizarreness intrinsically does not enhance memory in their paper
|
||||
from 1986. They claim that bizarre information becomes distinctive. It is the distinctiveness that
|
||||
according to them makes encoding easier.[3] Which makes common sense from an instinctual perspective
|
||||
according to them makes encoding easier. Which makes common sense from an instinctual perspective
|
||||
as the human brain will disregard ingesting information it already is familiar with and will be
|
||||
particularly attuned to taking in new information as an adaptation technique.
|
||||
""",
|
||||
@@ -4749,7 +4749,7 @@ public static class BiasCatalog
|
||||
effect. There are psychological mechanisms by which backfire effects could potentially occur, but the evidence on
|
||||
this topic is mixed, and backfire effects are very rare in practice. A 2020 review of the scientific literature on
|
||||
backfire effects found that there have been widespread failures to replicate their existence, even under conditions
|
||||
that would be theoretically favorable to observing them.[8] Due to the lack of reproducibility, as of 2020 most
|
||||
that would be theoretically favorable to observing them. Due to the lack of reproducibility, as of 2020 most
|
||||
researchers believe that backfire effects are either unlikely to occur on the broader population level, or they
|
||||
only occur in very specific circumstances, or they do not exist. For most people, corrections and fact-checking
|
||||
are very unlikely to have a negative impact, and there is no specific group of people in which backfire effects
|
||||
@@ -5715,13 +5715,13 @@ public static class BiasCatalog
|
||||
|
||||
Other topics that are sensitive to social-desirability bias include:
|
||||
|
||||
- Self-reported personality traits will correlate strongly with social desirability bias[2]
|
||||
- Self-reported personality traits will correlate strongly with social desirability bias
|
||||
- Personal income and earnings, often inflated when low and deflated when high
|
||||
- Feelings of low self-worth and/or powerlessness, often denied
|
||||
- Excretory functions, often approached uncomfortably, if discussed at all
|
||||
- Compliance with medicinal-dosing schedules, often inflated
|
||||
- Family planning, including use of contraceptives and abortion[3][4]
|
||||
- Religion, often either avoided or uncomfortably approached[5]
|
||||
- Family planning, including use of contraceptives and abortion
|
||||
- Religion, often either avoided or uncomfortably approached
|
||||
- Patriotism, either inflated or, if denied, done so with a fear of other party's judgment
|
||||
- Bigotry and intolerance, often denied, even if it exists within the responder
|
||||
- Intellectual achievements, often inflated
|
||||
|
||||
@@ -11,4 +11,14 @@ public sealed class DataWorkspace
|
||||
/// The chat storage maintenance behavior.
|
||||
/// </summary>
|
||||
public WorkspaceStorageTemporaryMaintenancePolicy StorageTemporaryMaintenancePolicy { get; set; } = WorkspaceStorageTemporaryMaintenancePolicy.DELETE_OLDER_THAN_90_DAYS;
|
||||
|
||||
/// <summary>
|
||||
/// The behavior used for displaying the workspace.
|
||||
/// </summary>
|
||||
public WorkspaceDisplayBehavior DisplayBehavior { get; set; } = WorkspaceDisplayBehavior.TOGGLE_SIDEBAR;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the sidebar is currently visible.
|
||||
/// </summary>
|
||||
public bool IsSidebarVisible { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Settings.DataModel;
|
||||
|
||||
public enum WorkspaceDisplayBehavior
|
||||
{
|
||||
TOGGLE_OVERLAY,
|
||||
|
||||
TOGGLE_SIDEBAR,
|
||||
SIDEBAR_ALWAYS_VISIBLE,
|
||||
}
|
||||
@@ -5,6 +5,14 @@ namespace AIStudio.Tools;
|
||||
|
||||
public static class ComponentsExtensions
|
||||
{
|
||||
public static bool AllowSendTo(this Components component) => component switch
|
||||
{
|
||||
Components.NONE => false,
|
||||
Components.BIAS_DAY_ASSISTANT => false,
|
||||
|
||||
_ => true,
|
||||
};
|
||||
|
||||
public static string Name(this Components component) => component switch
|
||||
{
|
||||
Components.GRAMMAR_SPELLING_ASSISTANT => "Grammar & Spelling Assistant",
|
||||
|
||||
@@ -60,6 +60,10 @@
|
||||
background-color: initial !important;
|
||||
}
|
||||
|
||||
.improvedDiff .wikEdDiffNoChange {
|
||||
background-color: initial !important;
|
||||
}
|
||||
|
||||
.improvedDiff .wikEdDiffDelete {
|
||||
text-decoration-line: line-through;
|
||||
text-decoration-style: double;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# v0.9.16, build 191 (2024-11-02 22:04 UTC)
|
||||
- Added our second financial contributor: Thanks `peerschuett` for supporting AI Studio financially.
|
||||
- Added multiple workspace display options: toggle the workspace overlay (this is the previous behavior), toggle the workspace sidebar, or permanently display the workspace sidebar.
|
||||
- Improved the layout of the app window, for example, in full-screen mode on large or high-resolution monitors.
|
||||
- Fixed the "send to" issue, where you could select the bias-of-the-day assistant as destination.
|
||||
- Fixed the appearance of the "no diff" message for text diffs when the dark mode is enabled.
|
||||
@@ -0,0 +1,2 @@
|
||||
# v0.9.17, build 192 (2024-11-03 11:11 UTC)
|
||||
- Added new Anthropic model `claude-3-5-sonnet-20241022` as well as the aliases `claude-3-5-sonnet-latest` and `claude-3-opus-latest`.
|
||||
@@ -0,0 +1,7 @@
|
||||
# v0.9.18, build 193 (2024-11-09 21:10 UTC)
|
||||
- Added new Anthropic model `claude-3-5-heiku-20241022` as well as the alias `claude-3-5-heiku-latest`.
|
||||
- Added [Groq](https://console.groq.com/) as a provider option.
|
||||
- Added Google Gemini as a provider option.
|
||||
- Improved model display while configuring a provider when the provider supports display names.
|
||||
- Improved the bias catalog by cleaning up some artifacts.
|
||||
- Refactored the runtime code as preparation for the integration of RAG (integrating your data and files in AI Studio).
|
||||
+1
-1
@@ -1 +1 @@
|
||||
.mud-markdown-body code:not(.hljs){background:var(--mud-palette-action-disabled-background);color:var(--mud-palette-text-primary)!important;padding:2.5px 7.5px;border-radius:5px}pre code.hljs{display:block!important;overflow-x:auto;padding:1em}.mud-markdown-body .snippet-clipboard-content{position:relative!important}.mud-markdown-body .snippet-clipboard-content:hover>.snippet-clipboard-copy-icon{display:block!important}.mud-markdown-body .snippet-clipboard-content .snippet-clipboard-copy-icon{position:absolute;display:none;top:0;right:0}.mud-markdown-body blockquote{border-left:.25em solid var(--mud-palette-text-disabled);color:var(--mud-palette-text-secondary);background-color:var(--mud-palette-table-hover);padding:.25em 1em;margin:.5em 0 1.25em}.mud-markdown-body blockquote p{margin-bottom:0!important}.mud-markdown-body table{margin:1.25em 0}.mud-markdown-body .mud-link:hover{cursor:pointer!important}.mud-markdown-body ul{list-style-type:disc}.mud-markdown-body ol,.mud-markdown-body ul{padding-left:2em;margin-bottom:1.25em!important}.mud-markdown-body ul ul{list-style-type:circle;margin-bottom:0!important}.mud-markdown-body ul ul ul{list-style-type:square;margin-bottom:0!important}.mud-markdown-body li{display:list-item!important;text-align:-webkit-match-parent}.mud-markdown-body h1,.mud-markdown-body h2{border-bottom:1px solid var(--mud-palette-text-disabled);padding-bottom:.125em;margin-bottom:.4em}.mud-markdown-body h1,.mud-markdown-body h2,.mud-markdown-body h3,.mud-markdown-body h4,.mud-markdown-body h5,.mud-markdown-body h6{scroll-margin-top:5rem;margin-top:.25em;word-wrap:break-word;margin-bottom:.3em!important}.mud-markdown-body .mud-divider{margin:.5em 0;height:.25em}.mud-markdown-body p{margin-bottom:1.25em!important}.mud-markdown-body .mud-expand-panel p,.mud-markdown-body li p{margin-bottom:0!important}.mud-markdown-body img{max-width:100%}
|
||||
.mud-markdown-body code:not(.hljs){background:var(--mud-palette-action-disabled-background);color:var(--mud-palette-text-primary)!important;padding:2.5px 7.5px;border-radius:5px}pre code.hljs{display:block!important;overflow-x:auto;padding:1em}.mud-markdown-body .snippet-clipboard-content{position:relative!important}.mud-markdown-body .snippet-clipboard-content:hover>.snippet-clipboard-copy-icon{display:block!important}.mud-markdown-body .snippet-clipboard-content .snippet-clipboard-copy-icon{position:absolute;display:none;top:0;right:0}.mud-markdown-body blockquote{border-left:.25em solid var(--mud-palette-text-disabled);color:var(--mud-palette-text-secondary);background-color:var(--mud-palette-table-hover);padding:.25em 1em;margin:.5em 0 1.25em}.mud-markdown-body blockquote p{margin-bottom:0!important}.mud-markdown-body table{margin:1.25em 0}.mud-markdown-body .mud-link:hover{cursor:pointer!important}.mud-markdown-body ul{list-style-type:disc}.mud-markdown-body ol,.mud-markdown-body ul{padding-left:2em;margin-bottom:1.25em!important}.mud-markdown-body ul ul{list-style-type:circle;margin-bottom:0!important}.mud-markdown-body ul ul ul{list-style-type:square;margin-bottom:0!important}.mud-markdown-body li{display:list-item!important;text-align:-webkit-match-parent}.mud-markdown-body h1,.mud-markdown-body h2{border-bottom:1px solid var(--mud-palette-text-disabled);padding-bottom:.125em;margin-bottom:.4em}.mud-markdown-body h1,.mud-markdown-body h2,.mud-markdown-body h3,.mud-markdown-body h4,.mud-markdown-body h5,.mud-markdown-body h6{scroll-margin-top:5rem;margin-top:.25em;word-wrap:break-word;margin-bottom:.3em!important}.mud-markdown-body .mud-divider{margin:.5em 0;height:.25em}.mud-markdown-body p{margin-bottom:1.25em!important}.mud-markdown-body .mud-expand-panel p,.mud-markdown-body li p{margin-bottom:0!important}.mud-markdown-body p .markdown-error{white-space:pre}.mud-markdown-body img{max-width:100%}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
(()=>{var e={2689:e=>{e.exports=function(e){var t="built_in",a={},n={begin:"}",className:t,endsParent:!0},i={begin:"{",end:"}",contains:[e.QUOTE_STRING_MODE,"self"]},r=e.COMMENT("@\\*","\\*@",{relevance:10}),o={begin:"@[A-Za-z0-9\\._:-]+",returnBegin:!0,end:"(\\r|\\n|<|\\s|\"|')",subLanguage:"csharp",contains:[{begin:"@",className:t},{begin:"\\[",end:"\\]",skip:!0},{begin:"\\(",end:"\\)",skip:!0}],returnEnd:!0},s={begin:"[@]{0,1}<text>",returnBegin:!0,end:"</text>",returnEnd:!0,subLanguage:"cshtml-razor",contains:[{begin:"[@]{0,1}<text>",className:t},{begin:"</text>",className:t,endsParent:!0}]},l={begin:"@\\(",end:"\\)",returnBegin:!0,returnEnd:!0,subLanguage:"csharp",contains:[{begin:"@\\(",className:t},{begin:"\\(",end:"\\)",subLanguage:"csharp",contains:[e.QUOTE_STRING_MODE,"self",s]},s,{begin:"\\)",className:t,endsParent:!0}]},c=function(e,t){var a={endsWithParent:!0,illegal:/</,relevance:0,contains:[{className:"attr",begin:"[A-Za-z0-9\\._:-]+",relevance:0},{begin:/=\s*/,relevance:0,contains:[{className:"string",variants:[{begin:/"/,end:/"/,contains:t},{begin:/'/,end:/'/,contains:t},{begin:/[^\s"'=<>`]+/}]}]}]};return[{className:"meta",begin:"<!DOCTYPE",end:">",relevance:10,contains:[{begin:"\\[",end:"\\]"}]},e.COMMENT("\x3c!--","--\x3e",{relevance:10}),{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10},{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",begin:"<style(?=\\s|>|$)",end:">",keywords:{name:"style"},contains:[a],starts:{end:"</style>",returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:"<script(?=\\s|>|$)",end:">",keywords:{name:"script"},contains:[a],starts:{end:"<\/script>",returnEnd:!0,subLanguage:["actionscript","javascript","handlebars","xml"]}},{className:"tag",begin:"</?",end:"/?>",contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0},a]}].concat(t)}(e,[o,l]),_="^\\s*@(page|model|using|inherits|inject|layout)",d={begin:_+"[^\\r\\n{\\(]*$",end:"$",returnBegin:!0,returnEnd:!0,contains:[{begin:_,className:t},{variants:[{begin:"\\r|\\n",endsParent:!0},{begin:"\\s[^\\r\\n]+",end:"$"},{begin:"$"}],className:"type",endsParent:!0}]},m={variants:[{begin:"@\\{",end:"}"},{begin:"@code\\s*\\{",end:"}"}],returnBegin:!0,returnEnd:!0,subLanguage:"csharp",contains:[{begin:"@(code\\s*)?\\{",className:t},a,{begin:"{",end:"}",contains:["self"],skip:!0},n]},p={begin:"^\\s*@helper[\\s]*[^{]+[\\s]*{",returnBegin:!0,returnEnd:!0,end:"}",subLanguage:"cshtml-razor",contains:[{begin:"@helper",className:t},{begin:"{",className:t},n]},u=[{begin:"@for[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@if[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@switch[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@while[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@using[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@lock[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@foreach[\\s]*\\([^{]+[\\s]*{",end:"}"}],g={variants:u,returnBegin:!0,returnEnd:!0,subLanguage:"csharp",contains:[{variants:u.map((function(e){return{begin:e.begin}})),returnBegin:!0,contains:[{begin:"@",className:t},{variants:u.map((function(e){return{begin:e.begin.substr(1,e.begin.length-2)}})),subLanguage:"csharp"},{begin:"{",className:t}]},a,{variants:[{begin:"}[\\s]*else\\sif[\\s]*\\([^{]+[\\s]*{"},{begin:"}[\\s]*else[\\s]*{"}],returnBegin:!0,contains:[{begin:"}",className:t},{variants:[{begin:"[\\s]*else\\sif[\\s]*\\([^{]+[\\s]*{"},{begin:"[\\s]*else[\\s]*"}],subLanguage:"csharp"},{begin:"{",className:t}]},i,n]},E={begin:"@try[\\s]*{",end:"}",returnBegin:!0,returnEnd:!0,subLanguage:"csharp",contains:[{begin:"@",className:t},{begin:"try[\\s]*{",subLanguage:"csharp"},{variants:[{begin:"}[\\s]*catch[\\s]*\\([^\\)]+\\)[\\s]*{"},{begin:"}[\\s]*finally[\\s]*{"}],returnBegin:!0,contains:[{begin:"}",className:t},{variants:[{begin:"[\\s]*catch[\\s]*\\([^\\)]+\\)[\\s]*"},{begin:"[\\s]*finally[\\s]*"}],subLanguage:"csharp"},{begin:"{",className:t}]},a,i,n]},S="@section[\\s]+[a-zA-Z0-9]+[\\s]*{",b=[d,p,m,g,{begin:S,returnBegin:!0,returnEnd:!0,end:"}",subLanguage:"cshtml-razor",contains:[{begin:S,className:t},i,n]},{begin:"@await ",returnBegin:!0,subLanguage:"csharp",end:"(\\r|\\n|<|\\s)",contains:[{begin:"@await ",className:t},{begin:"[<\\r\\n]",endsParent:!0}]},E,{variants:[{begin:"@@"},{begin:"[a-zA-Z]+@"}],skip:!0},s,r,l,{className:"meta",begin:"<!DOCTYPE",end:">",relevance:10,contains:[{begin:"\\[",end:"\\]"}]},{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10}].concat(c);return[m,g,E].forEach((function(e){var t=b.filter((function(t){return t!==e})),n=e.contains.indexOf(a);e.contains.splice.apply(e.contains,[n,1].concat(t))})),{aliases:["cshtml","razor","razor-cshtml","cshtml-razor"],contains:b}}},3390:e=>{function t(e){return e instanceof Map?e.clear=e.delete=e.set=function(){throw new Error("map is read-only")}:e instanceof Set&&(e.add=e.clear=e.delete=function(){throw new Error("set is read-only")}),Object.freeze(e),Object.getOwnPropertyNames(e).forEach((a=>{const n=e[a],i=typeof n;"object"!==i&&"function"!==i||Object.isFrozen(n)||t(n)})),e}class a{constructoLine truncated
|
||||
(()=>{var e={2224:e=>{e.exports=function(e){var t="built_in",a={},n={begin:"}",className:t,endsParent:!0},r={begin:"{",end:"}",contains:[e.QUOTE_STRING_MODE,"self"]},i=e.COMMENT("@\\*","\\*@",{relevance:10}),o={begin:"@[A-Za-z0-9\\._:-]+",returnBegin:!0,end:"(\\r|\\n|<|\\s|\"|')",subLanguage:"csharp",contains:[{begin:"@",className:t},{begin:"\\[",end:"\\]",skip:!0},{begin:"\\(",end:"\\)",skip:!0}],returnEnd:!0},s={begin:"[@]{0,1}<text>",returnBegin:!0,end:"</text>",returnEnd:!0,subLanguage:"cshtml-razor",contains:[{begin:"[@]{0,1}<text>",className:t},{begin:"</text>",className:t,endsParent:!0}]},l={begin:"@\\(",end:"\\)",returnBegin:!0,returnEnd:!0,subLanguage:"csharp",contains:[{begin:"@\\(",className:t},{begin:"\\(",end:"\\)",subLanguage:"csharp",contains:[e.QUOTE_STRING_MODE,"self",s]},s,{begin:"\\)",className:t,endsParent:!0}]},c=function(e,t){var a={endsWithParent:!0,illegal:/</,relevance:0,contains:[{className:"attr",begin:"[A-Za-z0-9\\._:-]+",relevance:0},{begin:/=\s*/,relevance:0,contains:[{className:"string",variants:[{begin:/"/,end:/"/,contains:t},{begin:/'/,end:/'/,contains:t},{begin:/[^\s"'=<>`]+/}]}]}]};return[{className:"meta",begin:"<!DOCTYPE",end:">",relevance:10,contains:[{begin:"\\[",end:"\\]"}]},e.COMMENT("\x3c!--","--\x3e",{relevance:10}),{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10},{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",begin:"<style(?=\\s|>|$)",end:">",keywords:{name:"style"},contains:[a],starts:{end:"</style>",returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:"<script(?=\\s|>|$)",end:">",keywords:{name:"script"},contains:[a],starts:{end:"<\/script>",returnEnd:!0,subLanguage:["actionscript","javascript","handlebars","xml"]}},{className:"tag",begin:"</?",end:"/?>",contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0},a]}].concat(t)}(e,[o,l]),_="^\\s*@(page|model|using|inherits|inject|layout)",d={begin:_+"[^\\r\\n{\\(]*$",end:"$",returnBegin:!0,returnEnd:!0,contains:[{begin:_,className:t},{variants:[{begin:"\\r|\\n",endsParent:!0},{begin:"\\s[^\\r\\n]+",end:"$"},{begin:"$"}],className:"type",endsParent:!0}]},m={variants:[{begin:"@\\{",end:"}"},{begin:"@code\\s*\\{",end:"}"}],returnBegin:!0,returnEnd:!0,subLanguage:"csharp",contains:[{begin:"@(code\\s*)?\\{",className:t},a,{begin:"{",end:"}",contains:["self"],skip:!0},n]},p={begin:"^\\s*@helper[\\s]*[^{]+[\\s]*{",returnBegin:!0,returnEnd:!0,end:"}",subLanguage:"cshtml-razor",contains:[{begin:"@helper",className:t},{begin:"{",className:t},n]},u=[{begin:"@for[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@if[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@switch[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@while[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@using[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@lock[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@foreach[\\s]*\\([^{]+[\\s]*{",end:"}"}],g={variants:u,returnBegin:!0,returnEnd:!0,subLanguage:"csharp",contains:[{variants:u.map((function(e){return{begin:e.begin}})),returnBegin:!0,contains:[{begin:"@",className:t},{variants:u.map((function(e){return{begin:e.begin.substr(1,e.begin.length-2)}})),subLanguage:"csharp"},{begin:"{",className:t}]},a,{variants:[{begin:"}[\\s]*else\\sif[\\s]*\\([^{]+[\\s]*{"},{begin:"}[\\s]*else[\\s]*{"}],returnBegin:!0,contains:[{begin:"}",className:t},{variants:[{begin:"[\\s]*else\\sif[\\s]*\\([^{]+[\\s]*{"},{begin:"[\\s]*else[\\s]*"}],subLanguage:"csharp"},{begin:"{",className:t}]},r,n]},E={begin:"@try[\\s]*{",end:"}",returnBegin:!0,returnEnd:!0,subLanguage:"csharp",contains:[{begin:"@",className:t},{begin:"try[\\s]*{",subLanguage:"csharp"},{variants:[{begin:"}[\\s]*catch[\\s]*\\([^\\)]+\\)[\\s]*{"},{begin:"}[\\s]*finally[\\s]*{"}],returnBegin:!0,contains:[{begin:"}",className:t},{variants:[{begin:"[\\s]*catch[\\s]*\\([^\\)]+\\)[\\s]*"},{begin:"[\\s]*finally[\\s]*"}],subLanguage:"csharp"},{begin:"{",className:t}]},a,r,n]},S="@section[\\s]+[a-zA-Z0-9]+[\\s]*{",b=[d,p,m,g,{begin:S,returnBegin:!0,returnEnd:!0,end:"}",subLanguage:"cshtml-razor",contains:[{begin:S,className:t},r,n]},{begin:"@await ",returnBegin:!0,subLanguage:"csharp",end:"(\\r|\\n|<|\\s)",contains:[{begin:"@await ",className:t},{begin:"[<\\r\\n]",endsParent:!0}]},E,{variants:[{begin:"@@"},{begin:"[a-zA-Z]+@"}],skip:!0},s,i,l,{className:"meta",begin:"<!DOCTYPE",end:">",relevance:10,contains:[{begin:"\\[",end:"\\]"}]},{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10}].concat(c);return[m,g,E].forEach((function(e){var t=b.filter((function(t){return t!==e})),n=e.contains.indexOf(a);e.contains.splice.apply(e.contains,[n,1].concat(t))})),{aliases:["cshtml","razor","razor-cshtml","cshtml-razor"],contains:b}}},8416:e=>{function t(e){return e instanceof Map?e.clear=e.delete=e.set=function(){throw new Error("map is read-only")}:e instanceof Set&&(e.add=e.clear=e.delete=function(){throw new Error("set is read-only")}),Object.freeze(e),Object.getOwnPropertyNames(e).forEach((a=>{const n=e[a],r=typeof n;"object"!==r&&"function"!==r||Object.isFrozen(n)||t(n)})),e}class a{constructoLine truncated
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.9 KiB |
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
+4
-4
@@ -1,9 +1,9 @@
|
||||
0.9.15
|
||||
2024-10-28 15:04:04 UTC
|
||||
190
|
||||
0.9.18
|
||||
2024-11-09 21:10:03 UTC
|
||||
193
|
||||
8.0.110 (commit 87a66bb3d1)
|
||||
8.0.10 (commit 81cabf2857)
|
||||
1.82.0 (commit f6e511eec)
|
||||
7.14.0
|
||||
1.8.1
|
||||
5d3ea3d57aa, release
|
||||
826d15729b5, release
|
||||
Generated
+1
-1
@@ -2174,7 +2174,7 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mindwork-ai-studio"
|
||||
version = "0.9.15"
|
||||
version = "0.9.18"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mindwork-ai-studio"
|
||||
version = "0.9.15"
|
||||
version = "0.9.18"
|
||||
edition = "2021"
|
||||
description = "MindWork AI Studio"
|
||||
authors = ["Thorsten Sommer"]
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
use log::info;
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::{RngCore, SeedableRng};
|
||||
use rocket::http::Status;
|
||||
use rocket::Request;
|
||||
use rocket::request::FromRequest;
|
||||
|
||||
/// The API token used to authenticate requests.
|
||||
pub static API_TOKEN: Lazy<APIToken> = Lazy::new(|| {
|
||||
let mut token = [0u8; 32];
|
||||
let mut rng = rand_chacha::ChaChaRng::from_entropy();
|
||||
rng.fill_bytes(&mut token);
|
||||
|
||||
let token = APIToken::from_bytes(token.to_vec());
|
||||
info!("API token was generated successfully.");
|
||||
|
||||
token
|
||||
});
|
||||
|
||||
/// The API token data structure used to authenticate requests.
|
||||
pub struct APIToken {
|
||||
hex_text: String,
|
||||
}
|
||||
|
||||
impl APIToken {
|
||||
/// Creates a new API token from a byte vector.
|
||||
fn from_bytes(bytes: Vec<u8>) -> Self {
|
||||
APIToken {
|
||||
hex_text: bytes.iter().fold(String::new(), |mut result, byte| {
|
||||
result.push_str(&format!("{:02x}", byte));
|
||||
result
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new API token from a hexadecimal text.
|
||||
fn from_hex_text(hex_text: &str) -> Self {
|
||||
APIToken {
|
||||
hex_text: hex_text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_hex_text(&self) -> &str {
|
||||
self.hex_text.as_str()
|
||||
}
|
||||
|
||||
/// Validates the received token against the valid token.
|
||||
fn validate(&self, received_token: &Self) -> bool {
|
||||
received_token.to_hex_text() == self.to_hex_text()
|
||||
}
|
||||
}
|
||||
|
||||
/// The request outcome type used to handle API token requests.
|
||||
type RequestOutcome<R, T> = rocket::request::Outcome<R, T>;
|
||||
|
||||
/// The request outcome implementation for the API token.
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for APIToken {
|
||||
type Error = APITokenError;
|
||||
|
||||
/// Handles the API token requests.
|
||||
async fn from_request(request: &'r Request<'_>) -> RequestOutcome<Self, Self::Error> {
|
||||
let token = request.headers().get_one("token");
|
||||
match token {
|
||||
Some(token) => {
|
||||
let received_token = APIToken::from_hex_text(token);
|
||||
if API_TOKEN.validate(&received_token) {
|
||||
RequestOutcome::Success(received_token)
|
||||
} else {
|
||||
RequestOutcome::Error((Status::Unauthorized, APITokenError::Invalid))
|
||||
}
|
||||
}
|
||||
|
||||
None => RequestOutcome::Error((Status::Unauthorized, APITokenError::Missing)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The API token error types.
|
||||
#[derive(Debug)]
|
||||
pub enum APITokenError {
|
||||
Missing,
|
||||
Invalid,
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use log::{error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use rocket::get;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::serde::Serialize;
|
||||
use tauri::updater::UpdateResponse;
|
||||
use tauri::{Manager, Window};
|
||||
use tokio::time;
|
||||
use crate::api_token::APIToken;
|
||||
use crate::dotnet::stop_dotnet_server;
|
||||
use crate::environment::{is_prod, CONFIG_DIRECTORY, DATA_DIRECTORY};
|
||||
use crate::log::switch_to_file_logging;
|
||||
|
||||
/// The Tauri main window.
|
||||
static MAIN_WINDOW: Lazy<Mutex<Option<Window>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
/// The update response coming from the Tauri updater.
|
||||
static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<UpdateResponse<tauri::Wry>>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
/// Starts the Tauri app.
|
||||
pub fn start_tauri() {
|
||||
info!("Starting Tauri app...");
|
||||
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);
|
||||
|
||||
info!(Source = "Bootloader Tauri"; "Setup is running.");
|
||||
let logger_path = app.path_resolver().app_local_data_dir().unwrap();
|
||||
let logger_path = logger_path.join("data");
|
||||
|
||||
DATA_DIRECTORY.set(logger_path.to_str().unwrap().to_string()).map_err(|_| error!("Was not abe to set the data directory.")).unwrap();
|
||||
CONFIG_DIRECTORY.set(app.path_resolver().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap();
|
||||
|
||||
info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {logger_path:?}");
|
||||
switch_to_file_logging(logger_path).map_err(|e| error!("Failed to switch logging to file: {e}")).unwrap();
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.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!(Source = "Tauri"; "Window '{label}': close was requested.");
|
||||
}
|
||||
|
||||
tauri::WindowEvent::Destroyed => {
|
||||
warn!(Source = "Tauri"; "Window '{label}': was destroyed.");
|
||||
}
|
||||
|
||||
tauri::WindowEvent::FileDrop(files) => {
|
||||
info!(Source = "Tauri"; "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!(Source = "Tauri"; "Updater: update available: body size={body_len} time={date:?} version={version}");
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::Pending => {
|
||||
info!(Source = "Tauri"; "Updater: update is pending!");
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::DownloadProgress { chunk_length, content_length } => {
|
||||
info!(Source = "Tauri"; "Updater: downloaded {} of {:?}", chunk_length, content_length);
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::Downloaded => {
|
||||
info!(Source = "Tauri"; "Updater: update has been downloaded!");
|
||||
warn!(Source = "Tauri"; "Try to stop the .NET server now...");
|
||||
stop_dotnet_server();
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::Updated => {
|
||||
info!(Source = "Tauri"; "Updater: app has been updated");
|
||||
warn!(Source = "Tauri"; "Try to restart the app now...");
|
||||
app_handle.restart();
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::AlreadyUpToDate => {
|
||||
info!(Source = "Tauri"; "Updater: app is already up to date");
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::Error(error) => {
|
||||
warn!(Source = "Tauri"; "Updater: failed to update: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
warn!(Source = "Tauri"; "Run event: exit was requested.");
|
||||
}
|
||||
|
||||
tauri::RunEvent::Ready => {
|
||||
info!(Source = "Tauri"; "Run event: Tauri app is ready.");
|
||||
}
|
||||
|
||||
_ => {}
|
||||
});
|
||||
|
||||
warn!(Source = "Tauri"; "Tauri app was stopped.");
|
||||
if is_prod() {
|
||||
warn!("Try to stop the .NET server as well...");
|
||||
stop_dotnet_server();
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes the location of the main window to the given URL.
|
||||
pub async fn change_location_to(url: &str) {
|
||||
// Try to get the main window. If it is not available yet, wait for it:
|
||||
let mut main_window_ready = false;
|
||||
let mut main_window_status_reported = false;
|
||||
let main_window_spawn_clone = &MAIN_WINDOW;
|
||||
while !main_window_ready
|
||||
{
|
||||
main_window_ready = {
|
||||
let main_window = main_window_spawn_clone.lock().unwrap();
|
||||
main_window.is_some()
|
||||
};
|
||||
|
||||
if !main_window_ready {
|
||||
if !main_window_status_reported {
|
||||
info!("Waiting for main window to be ready, because .NET was faster than Tauri.");
|
||||
main_window_status_reported = true;
|
||||
}
|
||||
|
||||
time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
let js_location_change = format!("window.location = '{url}';");
|
||||
let main_window = main_window_spawn_clone.lock().unwrap();
|
||||
let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str());
|
||||
match location_change_result {
|
||||
Ok(_) => info!("The app location was changed to {url}."),
|
||||
Err(e) => error!("Failed to change the app location to {url}: {e}."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks for updates.
|
||||
#[get("/updates/check")]
|
||||
pub async fn check_for_update(_token: APIToken) -> Json<CheckUpdateResponse> {
|
||||
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().unwrap().app_handle();
|
||||
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!(Source = "Updater"; "An update to version '{new_version}' is available.");
|
||||
let changelog = update_response.body();
|
||||
Json(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!(Source = "Updater"; "No updates are available.");
|
||||
Json(CheckUpdateResponse {
|
||||
update_is_available: false,
|
||||
error: false,
|
||||
new_version: String::from(""),
|
||||
changelog: String::from(""),
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!(Source = "Updater"; "Failed to check for updates: {e}.");
|
||||
Json(CheckUpdateResponse {
|
||||
update_is_available: false,
|
||||
error: true,
|
||||
new_version: String::from(""),
|
||||
changelog: String::from(""),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The response to the check for update request.
|
||||
#[derive(Serialize)]
|
||||
pub struct CheckUpdateResponse {
|
||||
update_is_available: bool,
|
||||
error: bool,
|
||||
new_version: String,
|
||||
changelog: String,
|
||||
}
|
||||
|
||||
/// Installs the update.
|
||||
#[get("/updates/install")]
|
||||
pub async fn install_update(_token: APIToken) {
|
||||
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!(Source = "Updater"; "No update available to install. Did you check for updates first?");
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use std::sync::OnceLock;
|
||||
use log::info;
|
||||
use rcgen::generate_simple_self_signed;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
/// The certificate used for the runtime API server.
|
||||
pub static CERTIFICATE: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
|
||||
/// The private key used for the certificate of the runtime API server.
|
||||
pub static CERTIFICATE_PRIVATE_KEY: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
|
||||
/// The fingerprint of the certificate used for the runtime API server.
|
||||
pub static CERTIFICATE_FINGERPRINT: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Generates a TLS certificate for the runtime API server.
|
||||
pub fn generate_certificate() {
|
||||
|
||||
info!("Try to generate a TLS certificate for the runtime API server...");
|
||||
|
||||
let subject_alt_names = vec!["localhost".to_string()];
|
||||
let certificate_data = generate_simple_self_signed(subject_alt_names).unwrap();
|
||||
let certificate_binary_data = certificate_data.cert.der().to_vec();
|
||||
|
||||
let certificate_fingerprint = Sha256::digest(certificate_binary_data).to_vec();
|
||||
let certificate_fingerprint = certificate_fingerprint.iter().fold(String::new(), |mut result, byte| {
|
||||
result.push_str(&format!("{:02x}", byte));
|
||||
result
|
||||
});
|
||||
|
||||
let certificate_fingerprint = certificate_fingerprint.to_uppercase();
|
||||
|
||||
CERTIFICATE_FINGERPRINT.set(certificate_fingerprint.clone()).expect("Could not set the certificate fingerprint.");
|
||||
CERTIFICATE.set(certificate_data.cert.pem().as_bytes().to_vec()).expect("Could not set the certificate.");
|
||||
CERTIFICATE_PRIVATE_KEY.set(certificate_data.key_pair.serialize_pem().as_bytes().to_vec()).expect("Could not set the private key.");
|
||||
|
||||
info!("Certificate fingerprint: '{certificate_fingerprint}'.");
|
||||
info!("Done generating certificate for the runtime API server.");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use arboard::Clipboard;
|
||||
use log::{debug, error};
|
||||
use rocket::post;
|
||||
use rocket::serde::json::Json;
|
||||
use serde::Serialize;
|
||||
use crate::api_token::APIToken;
|
||||
use crate::encryption::{EncryptedText, ENCRYPTION};
|
||||
|
||||
/// Sets the clipboard text to the provided encrypted text.
|
||||
#[post("/clipboard/set", data = "<encrypted_text>")]
|
||||
pub fn set_clipboard(_token: APIToken, encrypted_text: EncryptedText) -> Json<SetClipboardResponse> {
|
||||
|
||||
// Decrypt this text first:
|
||||
let decrypted_text = match ENCRYPTION.decrypt(&encrypted_text) {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
error!(Source = "Clipboard"; "Failed to decrypt the text: {e}.");
|
||||
return Json(SetClipboardResponse {
|
||||
success: false,
|
||||
issue: e,
|
||||
})
|
||||
},
|
||||
};
|
||||
|
||||
let clipboard_result = Clipboard::new();
|
||||
let mut clipboard = match clipboard_result {
|
||||
Ok(clipboard) => clipboard,
|
||||
Err(e) => {
|
||||
error!(Source = "Clipboard"; "Failed to get the clipboard instance: {e}.");
|
||||
return Json(SetClipboardResponse {
|
||||
success: false,
|
||||
issue: e.to_string(),
|
||||
})
|
||||
},
|
||||
};
|
||||
|
||||
let set_text_result = clipboard.set_text(decrypted_text);
|
||||
match set_text_result {
|
||||
Ok(_) => {
|
||||
debug!(Source = "Clipboard"; "Text was set to the clipboard successfully.");
|
||||
Json(SetClipboardResponse {
|
||||
success: true,
|
||||
issue: String::from(""),
|
||||
})
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
error!(Source = "Clipboard"; "Failed to set text to the clipboard: {e}.");
|
||||
Json(SetClipboardResponse {
|
||||
success: false,
|
||||
issue: e.to_string(),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The response for setting the clipboard text.
|
||||
#[derive(Serialize)]
|
||||
pub struct SetClipboardResponse {
|
||||
success: bool,
|
||||
issue: String,
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use log::{debug, error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use rocket::get;
|
||||
use tauri::api::process::{Command, CommandChild, CommandEvent};
|
||||
use tauri::Url;
|
||||
use crate::api_token::{APIToken, API_TOKEN};
|
||||
use crate::app_window::change_location_to;
|
||||
use crate::certificate::CERTIFICATE_FINGERPRINT;
|
||||
use crate::encryption::ENCRYPTION;
|
||||
use crate::environment::is_dev;
|
||||
use crate::network::get_available_port;
|
||||
use crate::runtime_api::API_SERVER_PORT;
|
||||
|
||||
// The .NET server is started in a separate process and communicates with this
|
||||
// runtime process via IPC. However, we do net start the .NET server in
|
||||
// the development environment.
|
||||
static DOTNET_SERVER: Lazy<Arc<Mutex<Option<CommandChild>>>> = Lazy::new(|| Arc::new(Mutex::new(None)));
|
||||
|
||||
// The .NET server port is relevant for the production environment only, sine we
|
||||
// do not start the server in the development environment.
|
||||
static DOTNET_SERVER_PORT: Lazy<u16> = Lazy::new(|| get_available_port().unwrap());
|
||||
|
||||
static DOTNET_INITIALIZED: Lazy<Mutex<bool>> = Lazy::new(|| Mutex::new(false));
|
||||
|
||||
/// Returns the desired port of the .NET server. Our .NET app calls this endpoint to get
|
||||
/// the port where the .NET server should listen to.
|
||||
#[get("/system/dotnet/port")]
|
||||
pub fn dotnet_port(_token: APIToken) -> String {
|
||||
let dotnet_server_port = *DOTNET_SERVER_PORT;
|
||||
format!("{dotnet_server_port}")
|
||||
}
|
||||
|
||||
/// Starts the .NET server in a separate process.
|
||||
pub fn start_dotnet_server() {
|
||||
|
||||
// Get the secret password & salt and convert it to a base64 string:
|
||||
let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password);
|
||||
let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt);
|
||||
|
||||
let dotnet_server_environment = HashMap::from_iter([
|
||||
(String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password),
|
||||
(String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt),
|
||||
(String::from("AI_STUDIO_CERTIFICATE_FINGERPRINT"), CERTIFICATE_FINGERPRINT.get().unwrap().to_string()),
|
||||
(String::from("AI_STUDIO_API_TOKEN"), API_TOKEN.to_hex_text().to_string()),
|
||||
]);
|
||||
|
||||
info!("Try to start the .NET server...");
|
||||
let server_spawn_clone = DOTNET_SERVER.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let api_port = *API_SERVER_PORT;
|
||||
let (mut rx, child) = match is_dev() {
|
||||
true => {
|
||||
// We are in the development environment, so we try to start a process
|
||||
// with `dotnet run` in the `../app/MindWork AI Studio` directory. But
|
||||
// we cannot issue a sidecar because we cannot use any command for the
|
||||
// sidecar (see Tauri configuration). Thus, we use a standard Rust process:
|
||||
warn!(Source = "Bootloader .NET"; "Development environment detected; start .NET server using 'dotnet run'.");
|
||||
Command::new("dotnet")
|
||||
|
||||
// Start the .NET server in the `../app/MindWork AI Studio` directory.
|
||||
// We provide the runtime API server port to the .NET server:
|
||||
.args(["run", "--project", "../app/MindWork AI Studio", "--", format!("{api_port}").as_str()])
|
||||
|
||||
.envs(dotnet_server_environment)
|
||||
.spawn()
|
||||
.expect("Failed to spawn .NET server process.")
|
||||
}
|
||||
|
||||
false => {
|
||||
Command::new_sidecar("mindworkAIStudioServer")
|
||||
.expect("Failed to create sidecar")
|
||||
|
||||
// Provide the runtime API server port to the .NET server:
|
||||
.args([format!("{api_port}").as_str()])
|
||||
|
||||
.envs(dotnet_server_environment)
|
||||
.spawn()
|
||||
.expect("Failed to spawn .NET server process.")
|
||||
}
|
||||
};
|
||||
|
||||
let server_pid = child.pid();
|
||||
info!(Source = "Bootloader .NET"; "The .NET server process started with PID={server_pid}.");
|
||||
|
||||
// Save the server process to stop it later:
|
||||
*server_spawn_clone.lock().unwrap() = Some(child);
|
||||
|
||||
// Log the output of the .NET server:
|
||||
while let Some(CommandEvent::Stdout(line)) = rx.recv().await {
|
||||
|
||||
// Remove newline characters from the end:
|
||||
let line = line.trim_end();
|
||||
|
||||
// Starts the line with '=>'?
|
||||
if line.starts_with("=>") {
|
||||
// Yes. This means that the line is a log message from the .NET server.
|
||||
// The format is: '<YYYY-MM-dd HH:mm:ss.fff> [<log level>] <source>: <message>'.
|
||||
// We try to parse this line and log it with the correct log level:
|
||||
let line = line.trim_start_matches("=>").trim();
|
||||
let parts = line.split_once(": ").unwrap();
|
||||
let left_part = parts.0.trim();
|
||||
let message = parts.1.trim();
|
||||
let parts = left_part.split_once("] ").unwrap();
|
||||
let level = parts.0.split_once("[").unwrap().1.trim();
|
||||
let source = parts.1.trim();
|
||||
match level {
|
||||
"Trace" => debug!(Source = ".NET Server", Comp = source; "{message}"),
|
||||
"Debug" => debug!(Source = ".NET Server", Comp = source; "{message}"),
|
||||
"Information" => info!(Source = ".NET Server", Comp = source; "{message}"),
|
||||
"Warning" => warn!(Source = ".NET Server", Comp = source; "{message}"),
|
||||
"Error" => error!(Source = ".NET Server", Comp = source; "{message}"),
|
||||
"Critical" => error!(Source = ".NET Server", Comp = source; "{message}"),
|
||||
|
||||
_ => error!(Source = ".NET Server", Comp = source; "{message} (unknown log level '{level}')"),
|
||||
}
|
||||
} else {
|
||||
let lower_line = line.to_lowercase();
|
||||
if lower_line.contains("error") {
|
||||
error!(Source = ".NET Server"; "{line}");
|
||||
} else if lower_line.contains("warning") {
|
||||
warn!(Source = ".NET Server"; "{line}");
|
||||
} else {
|
||||
info!(Source = ".NET Server"; "{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// This endpoint is called by the .NET server to signal that the server is ready.
|
||||
#[get("/system/dotnet/ready")]
|
||||
pub async fn dotnet_ready(_token: APIToken) {
|
||||
|
||||
// We create a manual scope for the lock to be released as soon as possible.
|
||||
// This is necessary because we cannot await any function while the lock is
|
||||
// held.
|
||||
{
|
||||
let mut initialized = DOTNET_INITIALIZED.lock().unwrap();
|
||||
if *initialized {
|
||||
error!("Anyone tried to initialize the runtime twice. This is not intended.");
|
||||
return;
|
||||
}
|
||||
|
||||
info!("The .NET server was booted successfully.");
|
||||
*initialized = true;
|
||||
}
|
||||
|
||||
let dotnet_server_port = *DOTNET_SERVER_PORT;
|
||||
let url = match Url::parse(format!("http://localhost:{dotnet_server_port}").as_str())
|
||||
{
|
||||
Ok(url) => url,
|
||||
Err(msg) => {
|
||||
error!("Error while parsing URL for navigating to the app: {msg}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
change_location_to(url.as_str()).await;
|
||||
}
|
||||
|
||||
/// Stops the .NET server process.
|
||||
pub fn stop_dotnet_server() {
|
||||
if let Some(server_process) = DOTNET_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 is already stopped.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use std::fmt;
|
||||
use std::time::Instant;
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
|
||||
use hmac::Hmac;
|
||||
use log::info;
|
||||
use once_cell::sync::Lazy;
|
||||
use pbkdf2::pbkdf2;
|
||||
use rand::{RngCore, SeedableRng};
|
||||
use rocket::{data, Data, Request};
|
||||
use rocket::data::ToByteUnit;
|
||||
use rocket::http::Status;
|
||||
use rocket::serde::{Deserialize, Serialize};
|
||||
use sha2::Sha512;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
|
||||
|
||||
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
|
||||
|
||||
type DataOutcome<'r, T> = data::Outcome<'r, T>;
|
||||
|
||||
/// The encryption instance used for the IPC channel.
|
||||
pub static ENCRYPTION: Lazy<Encryption> = Lazy::new(|| {
|
||||
//
|
||||
// Generate a secret key & salt for the AES encryption for the IPC channel:
|
||||
//
|
||||
let mut secret_key = [0u8; 512]; // 512 bytes = 4096 bits
|
||||
let mut secret_key_salt = [0u8; 16]; // 16 bytes = 128 bits
|
||||
|
||||
// We use a cryptographically secure pseudo-random number generator
|
||||
// to generate the secret password & salt. ChaCha20Rng is the algorithm
|
||||
// of our choice:
|
||||
let mut rng = rand_chacha::ChaChaRng::from_entropy();
|
||||
|
||||
// Fill the secret key & salt with random bytes:
|
||||
rng.fill_bytes(&mut secret_key);
|
||||
rng.fill_bytes(&mut secret_key_salt);
|
||||
|
||||
info!("Secret password for the IPC channel was generated successfully.");
|
||||
Encryption::new(&secret_key, &secret_key_salt).unwrap()
|
||||
});
|
||||
|
||||
/// The encryption struct used for the IPC channel.
|
||||
pub struct Encryption {
|
||||
key: [u8; 32],
|
||||
iv: [u8; 16],
|
||||
|
||||
pub secret_password: [u8; 512],
|
||||
pub secret_key_salt: [u8; 16],
|
||||
}
|
||||
|
||||
impl Encryption {
|
||||
// The number of iterations to derive the key and IV from the password. For a password
|
||||
// manager where the user has to enter their primary password, 100 iterations would be
|
||||
// too few and insecure. Here, the use case is different: We generate a 512-byte long
|
||||
// and cryptographically secure password at every start. This password already contains
|
||||
// enough entropy. In our case, we need key and IV primarily because AES, with the
|
||||
// algorithms we chose, requires a fixed key length, and our password is too long.
|
||||
const ITERATIONS: u32 = 100;
|
||||
|
||||
/// Initializes the encryption with the given secret password and salt.
|
||||
pub fn new(secret_password: &[u8], secret_key_salt: &[u8]) -> Result<Self, String> {
|
||||
if secret_password.len() != 512 {
|
||||
return Err("The secret password must be 512 bytes long.".to_string());
|
||||
}
|
||||
|
||||
if secret_key_salt.len() != 16 {
|
||||
return Err("The salt must be 16 bytes long.".to_string());
|
||||
}
|
||||
|
||||
info!(Source = "Encryption"; "Initializing encryption...");
|
||||
let mut encryption = Encryption {
|
||||
key: [0u8; 32],
|
||||
iv: [0u8; 16],
|
||||
|
||||
secret_password: [0u8; 512],
|
||||
secret_key_salt: [0u8; 16],
|
||||
};
|
||||
|
||||
encryption.secret_password.copy_from_slice(secret_password);
|
||||
encryption.secret_key_salt.copy_from_slice(secret_key_salt);
|
||||
|
||||
let start = Instant::now();
|
||||
let mut key_iv = [0u8; 48];
|
||||
pbkdf2::<Hmac<Sha512>>(secret_password, secret_key_salt, Self::ITERATIONS, &mut key_iv).map_err(|e| format!("Error while generating key and IV: {e}"))?;
|
||||
encryption.key.copy_from_slice(&key_iv[0..32]);
|
||||
encryption.iv.copy_from_slice(&key_iv[32..48]);
|
||||
|
||||
let duration = start.elapsed();
|
||||
let duration = duration.as_millis();
|
||||
info!(Source = "Encryption"; "Encryption initialized in {duration} milliseconds.", );
|
||||
|
||||
Ok(encryption)
|
||||
}
|
||||
|
||||
/// Encrypts the given data.
|
||||
pub fn encrypt(&self, data: &str) -> Result<EncryptedText, String> {
|
||||
let cipher = Aes256CbcEnc::new(&self.key.into(), &self.iv.into());
|
||||
let encrypted = cipher.encrypt_padded_vec_mut::<Pkcs7>(data.as_bytes());
|
||||
let mut result = BASE64_STANDARD.encode(self.secret_key_salt);
|
||||
result.push_str(&BASE64_STANDARD.encode(&encrypted));
|
||||
Ok(EncryptedText::new(result))
|
||||
}
|
||||
|
||||
/// Decrypts the given data.
|
||||
pub fn decrypt(&self, encrypted_data: &EncryptedText) -> Result<String, String> {
|
||||
let decoded = BASE64_STANDARD.decode(encrypted_data.get_encrypted()).map_err(|e| format!("Error decoding base64: {e}"))?;
|
||||
|
||||
if decoded.len() < 16 {
|
||||
return Err("Encrypted data is too short.".to_string());
|
||||
}
|
||||
|
||||
let (salt, encrypted) = decoded.split_at(16);
|
||||
if salt != self.secret_key_salt {
|
||||
return Err("The salt bytes do not match. The data is corrupted or tampered.".to_string());
|
||||
}
|
||||
|
||||
let cipher = Aes256CbcDec::new(&self.key.into(), &self.iv.into());
|
||||
let decrypted = cipher.decrypt_padded_vec_mut::<Pkcs7>(encrypted).map_err(|e| format!("Error decrypting data: {e}"))?;
|
||||
|
||||
String::from_utf8(decrypted).map_err(|e| format!("Error converting decrypted data to string: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents encrypted text.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct EncryptedText(String);
|
||||
|
||||
impl EncryptedText {
|
||||
|
||||
/// Creates a new encrypted text instance.
|
||||
pub fn new(encrypted_data: String) -> Self {
|
||||
EncryptedText(encrypted_data)
|
||||
}
|
||||
|
||||
/// Returns the encrypted data.
|
||||
pub fn get_encrypted(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for EncryptedText {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "EncryptedText(**********)")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EncryptedText {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "**********")
|
||||
}
|
||||
}
|
||||
|
||||
/// Use Case: When we receive encrypted text from the client as body (e.g., in a POST request).
|
||||
/// We must interpret the body as EncryptedText.
|
||||
#[rocket::async_trait]
|
||||
impl<'r> data::FromData<'r> for EncryptedText {
|
||||
type Error = String;
|
||||
|
||||
/// Parses the data as EncryptedText.
|
||||
async fn from_data(req: &'r Request<'_>, data: Data<'r>) -> DataOutcome<'r, Self> {
|
||||
let content_type = req.content_type();
|
||||
if content_type.map_or(true, |ct| !ct.is_text()) {
|
||||
return DataOutcome::Forward((data, Status::Ok));
|
||||
}
|
||||
|
||||
let mut stream = data.open(2.mebibytes());
|
||||
let mut body = String::new();
|
||||
if let Err(e) = stream.read_to_string(&mut body).await {
|
||||
return DataOutcome::Error((Status::InternalServerError, format!("Failed to read data: {}", e)));
|
||||
}
|
||||
|
||||
DataOutcome::Success(EncryptedText(body))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use std::sync::OnceLock;
|
||||
use rocket::get;
|
||||
use crate::api_token::APIToken;
|
||||
|
||||
/// The data directory where the application stores its data.
|
||||
pub static DATA_DIRECTORY: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// The config directory where the application stores its configuration.
|
||||
pub static CONFIG_DIRECTORY: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Returns the config directory.
|
||||
#[get("/system/directories/config")]
|
||||
pub fn get_config_directory(_token: APIToken) -> String {
|
||||
match CONFIG_DIRECTORY.get() {
|
||||
Some(config_directory) => config_directory.clone(),
|
||||
None => String::from(""),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the data directory.
|
||||
#[get("/system/directories/data")]
|
||||
pub fn get_data_directory(_token: APIToken) -> String {
|
||||
match DATA_DIRECTORY.get() {
|
||||
Some(data_directory) => data_directory.clone(),
|
||||
None => String::from(""),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the application is running in development mode.
|
||||
pub fn is_dev() -> bool {
|
||||
cfg!(debug_assertions)
|
||||
}
|
||||
|
||||
/// Returns true if the application is running in production mode.
|
||||
pub fn is_prod() -> bool {
|
||||
!is_dev()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod encryption;
|
||||
pub mod log;
|
||||
pub mod environment;
|
||||
pub mod dotnet;
|
||||
pub mod network;
|
||||
pub mod api_token;
|
||||
pub mod app_window;
|
||||
pub mod secret;
|
||||
pub mod clipboard;
|
||||
pub mod runtime_api;
|
||||
pub mod certificate;
|
||||
@@ -0,0 +1,163 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::error::Error;
|
||||
use std::fmt::Debug;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use flexi_logger::{DeferredNow, Duplicate, FileSpec, Logger, LoggerHandle};
|
||||
use flexi_logger::writers::FileLogWriter;
|
||||
use log::kv;
|
||||
use log::kv::{Key, Value, VisitSource};
|
||||
use crate::environment::is_dev;
|
||||
|
||||
static LOGGER: OnceLock<RuntimeLoggerHandle> = OnceLock::new();
|
||||
|
||||
/// Initialize the logging system.
|
||||
pub fn init_logging() {
|
||||
|
||||
//
|
||||
// Configure the LOGGER:
|
||||
//
|
||||
let mut log_config = String::new();
|
||||
|
||||
// Set the log level depending on the environment:
|
||||
match is_dev() {
|
||||
true => log_config.push_str("debug, "),
|
||||
false => log_config.push_str("info, "),
|
||||
};
|
||||
|
||||
// Set the log level for the Rocket library:
|
||||
log_config.push_str("rocket=info, ");
|
||||
|
||||
// Set the log level for the Rocket server:
|
||||
log_config.push_str("rocket::server=warn, ");
|
||||
|
||||
// Set the log level for the Reqwest library:
|
||||
log_config.push_str("reqwest::async_impl::client=info");
|
||||
|
||||
// Configure the initial filename. On Unix systems, the file should start
|
||||
// with a dot to be hidden.
|
||||
let log_basename = match cfg!(unix)
|
||||
{
|
||||
true => ".AI Studio Events",
|
||||
false => "AI Studio Events",
|
||||
};
|
||||
|
||||
let runtime_logger = Logger::try_with_str(log_config).expect("Cannot create logging")
|
||||
.log_to_file(FileSpec::default()
|
||||
.basename(log_basename)
|
||||
.suppress_timestamp()
|
||||
.suffix("log"))
|
||||
.duplicate_to_stdout(Duplicate::All)
|
||||
.use_utc()
|
||||
.format_for_files(file_logger_format)
|
||||
.format_for_stderr(terminal_colored_logger_format)
|
||||
.format_for_stdout(terminal_colored_logger_format)
|
||||
.start().expect("Cannot start logging");
|
||||
|
||||
let runtime_logger = RuntimeLoggerHandle{
|
||||
handle: runtime_logger
|
||||
};
|
||||
|
||||
LOGGER.set(runtime_logger).expect("Cannot set LOGGER");
|
||||
}
|
||||
|
||||
/// Switch the logging system to a file-based output.
|
||||
pub fn switch_to_file_logging(logger_path: PathBuf) -> Result<(), Box<dyn Error>>{
|
||||
LOGGER.get().expect("No LOGGER was set").handle.reset_flw(&FileLogWriter::builder(
|
||||
FileSpec::default()
|
||||
.directory(logger_path)
|
||||
.basename("events")
|
||||
.suppress_timestamp()
|
||||
.suffix("log")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct RuntimeLoggerHandle {
|
||||
handle: LoggerHandle
|
||||
}
|
||||
|
||||
impl Debug for RuntimeLoggerHandle {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "LoggerHandle")
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Data structure for iterating over key-value pairs of log messages.
|
||||
//
|
||||
struct LogKVCollect<'kvs>(BTreeMap<Key<'kvs>, Value<'kvs>>);
|
||||
|
||||
impl<'kvs> VisitSource<'kvs> for LogKVCollect<'kvs> {
|
||||
fn visit_pair(&mut self, key: Key<'kvs>, value: Value<'kvs>) -> Result<(), kv::Error> {
|
||||
self.0.insert(key, value);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn write_kv_pairs(w: &mut dyn std::io::Write, record: &log::Record) -> Result<(), std::io::Error> {
|
||||
if record.key_values().count() > 0 {
|
||||
let mut visitor = LogKVCollect(BTreeMap::new());
|
||||
record.key_values().visit(&mut visitor).unwrap();
|
||||
write!(w, "[")?;
|
||||
let mut index = 0;
|
||||
for (key, value) in visitor.0 {
|
||||
index += 1;
|
||||
if index > 1 {
|
||||
write!(w, ", ")?;
|
||||
}
|
||||
|
||||
write!(w, "{} = {}", key, value)?;
|
||||
}
|
||||
write!(w, "] ")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Custom LOGGER format for the terminal:
|
||||
fn terminal_colored_logger_format(
|
||||
w: &mut dyn std::io::Write,
|
||||
now: &mut DeferredNow,
|
||||
record: &log::Record,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let level = record.level();
|
||||
|
||||
// Write the timestamp, log level, and module path:
|
||||
write!(
|
||||
w,
|
||||
"[{}] {} [{}] ",
|
||||
flexi_logger::style(level).paint(now.format(flexi_logger::TS_DASHES_BLANK_COLONS_DOT_BLANK).to_string()),
|
||||
flexi_logger::style(level).paint(record.level().to_string()),
|
||||
record.module_path().unwrap_or("<unnamed>"),
|
||||
)?;
|
||||
|
||||
// Write all key-value pairs:
|
||||
write_kv_pairs(w, record)?;
|
||||
|
||||
// Write the log message:
|
||||
write!(w, "{}", flexi_logger::style(level).paint(record.args().to_string()))
|
||||
}
|
||||
|
||||
/// Custom LOGGER format for the log files:
|
||||
fn file_logger_format(
|
||||
w: &mut dyn std::io::Write,
|
||||
now: &mut DeferredNow,
|
||||
record: &log::Record,
|
||||
) -> Result<(), std::io::Error> {
|
||||
|
||||
// Write the timestamp, log level, and module path:
|
||||
write!(
|
||||
w,
|
||||
"[{}] {} [{}] ",
|
||||
now.format(flexi_logger::TS_DASHES_BLANK_COLONS_DOT_BLANK),
|
||||
record.level(),
|
||||
record.module_path().unwrap_or("<unnamed>"),
|
||||
)?;
|
||||
|
||||
// Write all key-value pairs:
|
||||
write_kv_pairs(w, record)?;
|
||||
|
||||
// Write the log message:
|
||||
write!(w, "{}", &record.args())
|
||||
}
|
||||
+13
-1050
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
use std::net::TcpListener;
|
||||
|
||||
/// Returns an available port on the local machine.
|
||||
pub fn get_available_port() -> Option<u16> {
|
||||
TcpListener::bind(("127.0.0.1", 0))
|
||||
.map(|listener| listener.local_addr().unwrap().port())
|
||||
.ok()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use std::collections::HashSet;
|
||||
use log::info;
|
||||
use once_cell::sync::Lazy;
|
||||
use rocket::config::Shutdown;
|
||||
use rocket::figment::Figment;
|
||||
use rocket::routes;
|
||||
use crate::certificate::{CERTIFICATE, CERTIFICATE_PRIVATE_KEY};
|
||||
use crate::environment::is_dev;
|
||||
use crate::network::get_available_port;
|
||||
|
||||
/// The port used for the runtime API server. In the development environment, we use a fixed
|
||||
/// port, in the production environment we use the next available port. This differentiation
|
||||
/// is necessary because we cannot communicate the port to the .NET server in the development
|
||||
/// environment.
|
||||
pub static API_SERVER_PORT: Lazy<u16> = Lazy::new(|| {
|
||||
if is_dev() {
|
||||
5000
|
||||
} else {
|
||||
get_available_port().unwrap()
|
||||
}
|
||||
});
|
||||
|
||||
/// Starts the runtime API server. The server is used to communicate with the .NET server and
|
||||
/// to provide additional functionality to the Tauri app.
|
||||
pub fn start_runtime_api() {
|
||||
let api_port = *API_SERVER_PORT;
|
||||
info!("Try to start the API server on 'http://localhost:{api_port}'...");
|
||||
|
||||
// The shutdown configuration for the runtime API server:
|
||||
let mut shutdown = Shutdown {
|
||||
// We do not want to use the Ctrl+C signal to stop the server:
|
||||
ctrlc: false,
|
||||
|
||||
// Everything else is set to default for now:
|
||||
..Shutdown::default()
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// We do not want to use the termination signal to stop the server.
|
||||
// This option, however, is only available on Unix systems:
|
||||
shutdown.signals = HashSet::new();
|
||||
}
|
||||
|
||||
// Configure the runtime API server:
|
||||
let figment = Figment::from(rocket::Config::release_default())
|
||||
|
||||
// We use the next available port which was determined before:
|
||||
.merge(("port", api_port))
|
||||
|
||||
// The runtime API server should be accessible only from the local machine:
|
||||
.merge(("address", "127.0.0.1"))
|
||||
|
||||
// We do not want to use the Ctrl+C signal to stop the server:
|
||||
.merge(("ctrlc", false))
|
||||
|
||||
// Set a name for the server:
|
||||
.merge(("ident", "AI Studio Runtime API"))
|
||||
|
||||
// Set the maximum number of workers and blocking threads:
|
||||
.merge(("workers", 3))
|
||||
.merge(("max_blocking", 12))
|
||||
|
||||
// No colors and emojis in the log output:
|
||||
.merge(("cli_colors", false))
|
||||
|
||||
// Read the TLS certificate and key from the generated certificate data in-memory:
|
||||
.merge(("tls.certs", CERTIFICATE.get().unwrap()))
|
||||
.merge(("tls.key", CERTIFICATE_PRIVATE_KEY.get().unwrap()))
|
||||
|
||||
// Set the shutdown configuration:
|
||||
.merge(("shutdown", shutdown));
|
||||
|
||||
//
|
||||
// Start the runtime API server in a separate thread. This is necessary
|
||||
// because the server is blocking, and we need to run the Tauri app in
|
||||
// parallel:
|
||||
//
|
||||
tauri::async_runtime::spawn(async move {
|
||||
rocket::custom(figment)
|
||||
.mount("/", routes![
|
||||
crate::dotnet::dotnet_port,
|
||||
crate::dotnet::dotnet_ready,
|
||||
crate::clipboard::set_clipboard,
|
||||
crate::app_window::check_for_update,
|
||||
crate::app_window::install_update,
|
||||
crate::secret::get_secret,
|
||||
crate::secret::store_secret,
|
||||
crate::secret::delete_secret,
|
||||
crate::environment::get_data_directory,
|
||||
crate::environment::get_config_directory,
|
||||
])
|
||||
.ignite().await.unwrap()
|
||||
.launch().await.unwrap();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use keyring::Entry;
|
||||
use log::{error, info, warn};
|
||||
use rocket::post;
|
||||
use rocket::serde::json::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use keyring::error::Error::NoEntry;
|
||||
use crate::api_token::APIToken;
|
||||
use crate::encryption::{EncryptedText, ENCRYPTION};
|
||||
|
||||
/// Stores a secret in the secret store using the operating system's keyring.
|
||||
#[post("/secrets/store", data = "<request>")]
|
||||
pub fn store_secret(_token: APIToken, request: Json<StoreSecret>) -> Json<StoreSecretResponse> {
|
||||
let user_name = request.user_name.as_str();
|
||||
let decrypted_text = match ENCRYPTION.decrypt(&request.secret) {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
error!(Source = "Secret Store"; "Failed to decrypt the text: {e}.");
|
||||
return Json(StoreSecretResponse {
|
||||
success: false,
|
||||
issue: format!("Failed to decrypt the text: {e}"),
|
||||
})
|
||||
},
|
||||
};
|
||||
|
||||
let service = format!("mindwork-ai-studio::{}", request.destination);
|
||||
let entry = Entry::new(service.as_str(), user_name).unwrap();
|
||||
let result = entry.set_password(decrypted_text.as_str());
|
||||
match result {
|
||||
Ok(_) => {
|
||||
info!(Source = "Secret Store"; "Secret for {service} and user {user_name} was stored successfully.");
|
||||
Json(StoreSecretResponse {
|
||||
success: true,
|
||||
issue: String::from(""),
|
||||
})
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
error!(Source = "Secret Store"; "Failed to store secret for {service} and user {user_name}: {e}.");
|
||||
Json(StoreSecretResponse {
|
||||
success: false,
|
||||
issue: e.to_string(),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The structure of the request to store a secret.
|
||||
#[derive(Deserialize)]
|
||||
pub struct StoreSecret {
|
||||
destination: String,
|
||||
user_name: String,
|
||||
secret: EncryptedText,
|
||||
}
|
||||
|
||||
/// The structure of the response to storing a secret.
|
||||
#[derive(Serialize)]
|
||||
pub struct StoreSecretResponse {
|
||||
success: bool,
|
||||
issue: String,
|
||||
}
|
||||
|
||||
/// Retrieves a secret from the secret store using the operating system's keyring.
|
||||
#[post("/secrets/get", data = "<request>")]
|
||||
pub fn get_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<RequestedSecret> {
|
||||
let user_name = request.user_name.as_str();
|
||||
let service = format!("mindwork-ai-studio::{}", request.destination);
|
||||
let entry = Entry::new(service.as_str(), user_name).unwrap();
|
||||
let secret = entry.get_password();
|
||||
match secret {
|
||||
Ok(s) => {
|
||||
info!(Source = "Secret Store"; "Secret for '{service}' and user '{user_name}' was retrieved successfully.");
|
||||
|
||||
// Encrypt the secret:
|
||||
let encrypted_secret = match ENCRYPTION.encrypt(s.as_str()) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
error!(Source = "Secret Store"; "Failed to encrypt the secret: {e}.");
|
||||
return Json(RequestedSecret {
|
||||
success: false,
|
||||
secret: EncryptedText::new(String::from("")),
|
||||
issue: format!("Failed to encrypt the secret: {e}"),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Json(RequestedSecret {
|
||||
success: true,
|
||||
secret: encrypted_secret,
|
||||
issue: String::from(""),
|
||||
})
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
if !request.is_trying {
|
||||
error!(Source = "Secret Store"; "Failed to retrieve secret for '{service}' and user '{user_name}': {e}.");
|
||||
}
|
||||
|
||||
Json(RequestedSecret {
|
||||
success: false,
|
||||
secret: EncryptedText::new(String::from("")),
|
||||
issue: format!("Failed to retrieve secret for '{service}' and user '{user_name}': {e}"),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The structure of the request to retrieve a secret.
|
||||
#[derive(Deserialize)]
|
||||
pub struct RequestSecret {
|
||||
destination: String,
|
||||
user_name: String,
|
||||
is_trying: bool,
|
||||
}
|
||||
|
||||
/// The structure of the response to retrieving a secret.
|
||||
#[derive(Serialize)]
|
||||
pub struct RequestedSecret {
|
||||
success: bool,
|
||||
secret: EncryptedText,
|
||||
issue: String,
|
||||
}
|
||||
|
||||
/// Deletes a secret from the secret store using the operating system's keyring.
|
||||
#[post("/secrets/delete", data = "<request>")]
|
||||
pub fn delete_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<DeleteSecretResponse> {
|
||||
let user_name = request.user_name.as_str();
|
||||
let service = format!("mindwork-ai-studio::{}", request.destination);
|
||||
let entry = Entry::new(service.as_str(), user_name).unwrap();
|
||||
let result = entry.delete_credential();
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
warn!(Source = "Secret Store"; "Secret for {service} and user {user_name} was deleted successfully.");
|
||||
Json(DeleteSecretResponse {
|
||||
success: true,
|
||||
was_entry_found: true,
|
||||
issue: String::from(""),
|
||||
})
|
||||
},
|
||||
|
||||
Err(NoEntry) => {
|
||||
warn!(Source = "Secret Store"; "No secret for {service} and user {user_name} was found.");
|
||||
Json(DeleteSecretResponse {
|
||||
success: true,
|
||||
was_entry_found: false,
|
||||
issue: String::from(""),
|
||||
})
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
error!(Source = "Secret Store"; "Failed to delete secret for {service} and user {user_name}: {e}.");
|
||||
Json(DeleteSecretResponse {
|
||||
success: false,
|
||||
was_entry_found: false,
|
||||
issue: e.to_string(),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The structure of the response to deleting a secret.
|
||||
#[derive(Serialize)]
|
||||
pub struct DeleteSecretResponse {
|
||||
success: bool,
|
||||
was_entry_found: bool,
|
||||
issue: String,
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "MindWork AI Studio",
|
||||
"version": "0.9.15"
|
||||
"version": "0.9.18"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
|
||||
Reference in new issue
Block a user