mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-09 14:29:40 +00:00
Added local RAG (#756)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
d043fbc8f0
commit
c7b42bee96
179 files changed
+276977
-1421
No files matched your search
@@ -35,7 +35,7 @@
|
||||
<FooterContent>
|
||||
<MediaTranscriptionStatus Owner="@this.CurrentMediaImportOwner"/>
|
||||
<MudElement Style="flex: 0 0 auto;">
|
||||
<MudTextField
|
||||
<UserPromptComponent
|
||||
T="string"
|
||||
@ref="@this.inputField"
|
||||
@bind-Text="@this.UserInput"
|
||||
@@ -51,8 +51,11 @@
|
||||
Disabled="@this.IsInputForbidden()"
|
||||
Immediate="@true"
|
||||
OnKeyUp="@this.InputKeyEvent"
|
||||
WhenTextChangedAsync="@(_ =>this.CalculateTokenCount())"
|
||||
UserAttributes="@USER_INPUT_ATTRIBUTES"
|
||||
Class="@this.UserInputClass"
|
||||
DebounceTime="TimeSpan.FromSeconds(1)"
|
||||
HelperText="@this.TokenCountMessage"
|
||||
Style="@this.UserInputStyle"/>
|
||||
</MudElement>
|
||||
<MudToolBar WrapContent="true" Gutters="@false" Class="border border-solid rounded" Style="border-color: lightgrey; gap: 2px;">
|
||||
@@ -132,7 +135,7 @@
|
||||
|
||||
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
|
||||
{
|
||||
<DataSourceSelection @ref="@this.dataSourceSelectionComponent" PopoverTriggerMode="PopoverTriggerMode.BUTTON" LLMProvider="@this.Provider" DataSourceOptions="@this.GetCurrentDataSourceOptions()" DataSourceOptionsChanged="@(async options => await this.SetCurrentDataSourceOptions(options))" DataSourcesAISelected="@this.GetAgentSelectedDataSources()"/>
|
||||
<DataSourceSelection @ref="@this.dataSourceSelectionComponent" PopoverTriggerMode="PopoverTriggerMode.ICON" LLMProvider="@this.Provider" DataSourceOptions="@this.GetCurrentDataSourceOptions()" DataSourceOptionsChanged="@(async options => await this.SetCurrentDataSourceOptions(options))" DataSourcesAISelected="@this.GetAgentSelectedDataSources()"/>
|
||||
}
|
||||
|
||||
@if (this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence)
|
||||
@@ -149,7 +152,7 @@
|
||||
|
||||
@if (!this.ChatThread.IsLLMProviderAllowed(this.Provider))
|
||||
{
|
||||
<MudTooltip Text="@T("The selected provider is not allowed in this chat due to data security reasons.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudTooltip Text="@T("The selected provider is not allowed in this chat due to data security or confidence-level requirements.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Error" Color="Color.Error"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ public partial class ChatComponent : MSGComponentBase
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
[Inject]
|
||||
private IJSRuntime JsRuntime { get; init; } = null!;
|
||||
|
||||
@@ -91,12 +93,17 @@ public partial class ChatComponent : MSGComponentBase
|
||||
private Guid loadedParameterWorkspaceId = Guid.Empty;
|
||||
private Guid foregroundChatId = Guid.Empty;
|
||||
private int workspaceHeaderSyncVersion;
|
||||
private string tokenCount = "0";
|
||||
private bool HasCustomTokenizer => !string.IsNullOrWhiteSpace(this.Provider.TokenizerPath);
|
||||
private string TokenCountMessage => this.HasCustomTokenizer
|
||||
? $"{this.T("Estimated amount of tokens:")} {this.tokenCount}"
|
||||
: string.Empty;
|
||||
|
||||
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForChat(this.ChatThread?.ChatId ?? this.draftMediaOwnerId);
|
||||
|
||||
// Unfortunately, we need the input field reference to blur the focus away. Without
|
||||
// this, we cannot clear the input field.
|
||||
private MudTextField<string> inputField = null!;
|
||||
private UserPromptComponent<string> inputField = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the user's input in the chat interface.
|
||||
@@ -373,15 +380,22 @@ public partial class ChatComponent : MSGComponentBase
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var incomingChatId = this.ChatThread?.ChatId ?? Guid.Empty;
|
||||
var providerChanged = this.Provider != this.lastSeenProvider;
|
||||
if (incomingChatId != this.lastSeenChatId || this.Provider != this.lastSeenProvider)
|
||||
{
|
||||
this.lastSeenChatId = incomingChatId;
|
||||
this.lastSeenProvider = this.Provider;
|
||||
if (providerChanged)
|
||||
this.tokenCount = "0";
|
||||
|
||||
this.previousInputForbidden = true;
|
||||
}
|
||||
|
||||
await this.ApplyLoadedChatParameterAsync();
|
||||
await this.SyncForegroundChatAsync();
|
||||
if (providerChanged && this.HasCustomTokenizer)
|
||||
await this.CalculateTokenCount();
|
||||
|
||||
await this.ConsumeMediaOutcomeAsync();
|
||||
await base.OnParametersSetAsync();
|
||||
}
|
||||
@@ -713,6 +727,9 @@ public partial class ChatComponent : MSGComponentBase
|
||||
// Was a modifier key pressed as well?
|
||||
var isModifier = keyEvent.AltKey || keyEvent.CtrlKey || keyEvent.MetaKey || keyEvent.ShiftKey;
|
||||
|
||||
if (isEnter)
|
||||
await this.CalculateTokenCount();
|
||||
|
||||
// Depending on the user's settings, might react to shortcuts:
|
||||
switch (this.SettingsManager.ConfigurationData.Chat.ShortcutSendBehavior)
|
||||
{
|
||||
@@ -899,6 +916,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
this.ComposerState.Clear();
|
||||
|
||||
await this.inputField.BlurAsync();
|
||||
this.tokenCount = "0";
|
||||
|
||||
// Enable the stream state for the chat component:
|
||||
this.hasUnsavedChanges = true;
|
||||
@@ -1291,6 +1309,43 @@ public partial class ChatComponent : MSGComponentBase
|
||||
this.ComposerState.RestoreFromTextBlock(textBlock);
|
||||
}
|
||||
|
||||
private async Task CalculateTokenCount()
|
||||
{
|
||||
if (!this.HasCustomTokenizer)
|
||||
{
|
||||
if (this.tokenCount != "0")
|
||||
{
|
||||
this.tokenCount = "0";
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// Read the text from the bound property rather than from the input field: the field is a
|
||||
// component reference, which is only set once the component has rendered. Counting is also
|
||||
// triggered while parameters are set, which happens before that.
|
||||
//
|
||||
var currentInput = this.UserInput;
|
||||
if (string.IsNullOrEmpty(currentInput))
|
||||
{
|
||||
this.tokenCount = "0";
|
||||
return;
|
||||
}
|
||||
|
||||
var response = await this.RustService.GetTokenCount(this.Provider, currentInput);
|
||||
if (response is null)
|
||||
return;
|
||||
if (!response.Value.Success)
|
||||
{
|
||||
this.Logger.LogWarning("Failed to calculate token count: reason='{Reason}'", response.Value.Message);
|
||||
return;
|
||||
}
|
||||
this.tokenCount = response.Value.TokenCount.ToString();
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="@true" Class="mb-3">
|
||||
<MudText Typo="Typo.body2">
|
||||
@this.WarningText
|
||||
</MudText>
|
||||
</MudAlert>
|
||||
|
||||
<MudTextSwitch Value="@this.UserAcknowledged" ValueChanged="@this.UserAcknowledgedChanged" Label="@T("I confirm that I have read and understood the above")" LabelOn="@T("Yes, please send my data to the external embedding provider")" LabelOff="@T("No, I will choose another embedding")" Validation="@this.Validation"/>
|
||||
@@ -0,0 +1,52 @@
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class DataSourceCloudEmbeddingWarning : MSGComponentBase
|
||||
{
|
||||
[Parameter]
|
||||
public DataSourceType DataSourceType { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string SourcePath { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public bool UserAcknowledged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<bool> UserAcknowledgedChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Func<bool, string?> Validation { get; set; } = _ => null;
|
||||
|
||||
private string WarningText
|
||||
{
|
||||
get
|
||||
{
|
||||
var subject = this.GetSubjectText();
|
||||
return string.Format(
|
||||
T("Warning: The selected embedding provider is not self-hosted. Creating embeddings can cost money and may need to run multiple times, for example after errors or file changes. {0} will be sent to an external third party. MindWork AI Studio has no control over what that third party does with the data after it is sent."),
|
||||
subject);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetSubjectText()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(this.SourcePath))
|
||||
return this.DataSourceType switch
|
||||
{
|
||||
DataSourceType.LOCAL_DIRECTORY => T("All files in this folder and its subfolders"),
|
||||
DataSourceType.LOCAL_FILE => T("The selected file"),
|
||||
_ => T("The selected data")
|
||||
};
|
||||
|
||||
return this.DataSourceType switch
|
||||
{
|
||||
DataSourceType.LOCAL_DIRECTORY => string.Format(T("All files in the folder '{0}' and its subfolders"), this.SourcePath),
|
||||
DataSourceType.LOCAL_FILE => string.Format(T("The file '{0}'"), this.SourcePath),
|
||||
_ => string.Format(T("The data source '{0}'"), this.SourcePath)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
@using AIStudio.Settings
|
||||
@using AIStudio.Settings.DataModel
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
@T("You might configure different data sources. A data source can include one file, all files in a directory, or data from your company. Later, you can incorporate these data sources as needed when the AI requires this data to complete a certain task.")
|
||||
</MudJustifiedText>
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3" Wrap="Wrap.Wrap">
|
||||
<MudTextSwitch Label="@T("Automatic local data source refresh")" Value="@this.SettingsManager.ConfigurationData.App.DataSourceIndexing.AutomaticRefresh" LabelOn="@T("Local data sources refresh when files change.")" LabelOff="@T("Local data sources refresh only when triggered manually.")" ValueChanged="@this.AutomaticRefreshChanged"/>
|
||||
<MudTooltip Text="@T("Refresh all")">
|
||||
<MudIconButton Color="Color.Primary" Icon="@Icons.Material.Filled.Sync" Disabled="@(!this.HasRefreshableDataSources())" OnClick="@this.RefreshAllDataSources"/>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
|
||||
@{ var embeddingStatuses = this.DataSourceEmbeddingService.GetStatuses().ToDictionary(status => status.DataSourceId, StringComparer.OrdinalIgnoreCase); }
|
||||
<MudTable Items="@this.SettingsManager.ConfigurationData.DataSources" Hover="@true" Class="border-dashed border rounded-lg">
|
||||
<ColGroup>
|
||||
<col style="width: 3em;"/>
|
||||
<col/>
|
||||
<col style="width: 9em;"/>
|
||||
<col style="width: 10em;"/>
|
||||
<col style="width: 8em;"/>
|
||||
<col style="width: 16em;"/>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>#</MudTh>
|
||||
<MudTh>@T("Name")</MudTh>
|
||||
<MudTh>@T("Type")</MudTh>
|
||||
<MudTh>@T("Embedding")</MudTh>
|
||||
<MudTh>@T("Indexed files")</MudTh>
|
||||
<MudTh>@T("Actions")</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
@{ var embeddingStatus = embeddingStatuses.GetValueOrDefault(context.Id); }
|
||||
<MudTd>@context.Num</MudTd>
|
||||
<MudTd Style="white-space: normal; overflow-wrap: anywhere;">@context.Name</MudTd>
|
||||
<MudTd Style="white-space: nowrap;">@context.Type.GetDisplayName()</MudTd>
|
||||
<MudTd Style="white-space: nowrap;">@this.GetEmbeddingName(context)</MudTd>
|
||||
<MudTd>
|
||||
@if (context is IInternalDataSource)
|
||||
{
|
||||
<MudTooltip Text="@this.GetIndexingStatusTooltip(embeddingStatus)">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Circle" Color="@GetIndexingStatusColor(embeddingStatus)" Size="Size.Small" Style="width: 0.75rem; height: 0.75rem; flex-shrink: 0;"/>
|
||||
<MudText Typo="Typo.body2">@(embeddingStatus is null ? T("Not available") : string.Format(T("{0} of {1}"), embeddingStatus.IndexedFiles, embeddingStatus.TotalFiles))</MudText>
|
||||
</MudStack>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Deliberately muted instead of colored: there is nothing to index and nothing to fix here. *@
|
||||
<MudText Typo="Typo.body2" Class="mud-text-secondary">@T("Not applicable")</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
|
||||
<MudTd>
|
||||
<MudStack Row="true" Class="mb-2 mt-2" Spacing="1" Wrap="Wrap.NoWrap">
|
||||
<MudTooltip Text="@T("Information")">
|
||||
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Outlined.Info" OnClick="@(() => this.ShowInformation(context))"/>
|
||||
</MudTooltip>
|
||||
@if (context.IsEnterpriseConfiguration)
|
||||
{
|
||||
<MudTooltip Text="@T("This data source is managed by your organization.")">
|
||||
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Business" Disabled="true"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="@T("Edit")">
|
||||
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="@(() => this.EditDataSource(context))"/>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@T("Refresh")">
|
||||
<MudIconButton Color="Color.Primary" Icon="@Icons.Material.Filled.Sync" Disabled="@(!this.CanRefreshDataSource(context))" OnClick="@(() => this.RefreshDataSource(context))"/>
|
||||
</MudTooltip>
|
||||
@if (context is DataSourceERI_V1)
|
||||
{
|
||||
<AdminExportButton OnClick="@(() => this.ExportDataSource(context))" />
|
||||
}
|
||||
<MudTooltip Text="@T("Delete")">
|
||||
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="@(() => this.DeleteDataSource(context))"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
|
||||
@if (this.SettingsManager.ConfigurationData.DataSources.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.h6" Class="mt-3">
|
||||
@T("No data sources configured yet.")
|
||||
</MudText>
|
||||
}
|
||||
|
||||
<MudMenu EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="@T("Add Data Source")" Color="Color.Primary" Variant="Variant.Filled" AnchorOrigin="Origin.TopLeft" TransformOrigin="Origin.BottomLeft" Class="mt-3 mb-6">
|
||||
<MudMenuItem OnClick="@(() => this.AddDataSource(DataSourceType.ERI_V1))">
|
||||
@T("External Data (ERI-Server v1)")
|
||||
</MudMenuItem>
|
||||
<MudMenuItem OnClick="@(() => this.AddDataSource(DataSourceType.LOCAL_DIRECTORY))">
|
||||
@T("Local Directory")
|
||||
</MudMenuItem>
|
||||
<MudMenuItem OnClick="@(() => this.AddDataSource(DataSourceType.LOCAL_FILE))">
|
||||
@T("Local File")
|
||||
</MudMenuItem>
|
||||
</MudMenu>
|
||||
@@ -0,0 +1,458 @@
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.ERIClient.DataModel;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the configured data sources. Used by the data source settings dialog, which the chat
|
||||
/// opens, and by the data source panel in the app settings.
|
||||
/// </summary>
|
||||
public partial class DataSourceManagement : MSGComponentBase
|
||||
{
|
||||
[Inject]
|
||||
private DataSourceEmbeddingService DataSourceEmbeddingService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
private readonly List<ConfigurationSelectData<string>> availableEmbeddingProviders = new();
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED, Event.RAG_EMBEDDING_STATUS_CHANGED ]);
|
||||
this.UpdateEmbeddingProviders();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
protected override Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
|
||||
{
|
||||
switch (triggeredEvent)
|
||||
{
|
||||
case Event.CONFIGURATION_CHANGED:
|
||||
case Event.PLUGINS_RELOADED:
|
||||
this.UpdateEmbeddingProviders();
|
||||
this.StateHasChanged();
|
||||
break;
|
||||
|
||||
case Event.RAG_EMBEDDING_STATUS_CHANGED:
|
||||
this.StateHasChanged();
|
||||
break;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void UpdateEmbeddingProviders()
|
||||
{
|
||||
this.availableEmbeddingProviders.Clear();
|
||||
foreach (var provider in this.SettingsManager.GetAllEmbeddingProviders())
|
||||
this.availableEmbeddingProviders.Add(new (provider.Name, provider.Id));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Files which were skipped for good are none of the failed ones, so a data source made of
|
||||
/// scanned documents stays green: there is nothing here for the user to fix.
|
||||
/// </remarks>
|
||||
private static Color GetIndexingStatusColor(DataSourceEmbeddingStatus? status)
|
||||
{
|
||||
if (status is null || status.State is DataSourceEmbeddingState.IDLE or DataSourceEmbeddingState.QUEUED or DataSourceEmbeddingState.RUNNING)
|
||||
return Color.Warning;
|
||||
|
||||
return status.State is DataSourceEmbeddingState.FAILED || status.FailedFiles > 0
|
||||
? Color.Error
|
||||
: Color.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explains the indexing dot, including the files which stay out of the index.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The column shows the indexed files against the total, which reads as unfinished for a data
|
||||
/// source whose remaining files were skipped for good. The tooltip is where that gap gets its
|
||||
/// explanation.
|
||||
/// </remarks>
|
||||
private string GetIndexingStatusTooltip(DataSourceEmbeddingStatus? status)
|
||||
{
|
||||
if (status is null)
|
||||
return T("Waiting for indexing status");
|
||||
|
||||
if (status.PermanentlySkippedFiles == 0)
|
||||
return status.StateLabel;
|
||||
|
||||
return $"{status.StateLabel} — {string.Format(T("{0} files were skipped because they contain no readable text. AI Studio reads them again once they change."), status.PermanentlySkippedFiles)}";
|
||||
}
|
||||
|
||||
private string GetEmbeddingName(IDataSource dataSource)
|
||||
{
|
||||
if(dataSource is IInternalDataSource internalDataSource)
|
||||
{
|
||||
var matchedEmbedding = this.SettingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id == internalDataSource.EmbeddingId);
|
||||
if(matchedEmbedding == default)
|
||||
return T("No valid embedding");
|
||||
|
||||
return matchedEmbedding.Name;
|
||||
}
|
||||
|
||||
if(dataSource is IExternalDataSource)
|
||||
return T("External (ERI)");
|
||||
|
||||
return T("Unknown");
|
||||
}
|
||||
|
||||
private bool CanRefreshDataSource(IDataSource dataSource)
|
||||
{
|
||||
return this.DataSourceEmbeddingService.CanRefreshDataSource(dataSource);
|
||||
}
|
||||
|
||||
private bool HasRefreshableDataSources()
|
||||
{
|
||||
return this.SettingsManager.ConfigurationData.DataSources.Any(this.CanRefreshDataSource);
|
||||
}
|
||||
|
||||
private async Task AutomaticRefreshChanged(bool enabled)
|
||||
{
|
||||
this.SettingsManager.ConfigurationData.App.DataSourceIndexing.AutomaticRefresh = enabled;
|
||||
await this.SettingsManager.StoreSettings();
|
||||
this.DataSourceEmbeddingService.RefreshAutomaticWatchers();
|
||||
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
|
||||
}
|
||||
|
||||
private async Task RefreshAllDataSources()
|
||||
{
|
||||
await this.DataSourceEmbeddingService.QueueAllInternalDataSourcesAsync();
|
||||
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
|
||||
}
|
||||
|
||||
private async Task RefreshDataSource(IDataSource dataSource)
|
||||
{
|
||||
if (!this.CanRefreshDataSource(dataSource))
|
||||
return;
|
||||
|
||||
await this.DataSourceEmbeddingService.QueueDataSourceAsync(dataSource);
|
||||
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
|
||||
}
|
||||
|
||||
private async Task AddDataSource(DataSourceType type)
|
||||
{
|
||||
IDataSource? addedDataSource = null;
|
||||
switch (type)
|
||||
{
|
||||
case DataSourceType.LOCAL_FILE:
|
||||
var localFileDialogParameters = new DialogParameters<DataSourceLocalFileDialog>
|
||||
{
|
||||
{ x => x.IsEditing, false },
|
||||
{ x => x.AvailableEmbeddings, this.availableEmbeddingProviders }
|
||||
};
|
||||
|
||||
var localFileDialogReference = await this.DialogService.ShowAsync<DataSourceLocalFileDialog>(T("Add Local File as Data Source"), localFileDialogParameters, DialogOptions.FULLSCREEN);
|
||||
var localFileDialogResult = await localFileDialogReference.Result;
|
||||
if (localFileDialogResult is null || localFileDialogResult.Canceled)
|
||||
return;
|
||||
|
||||
var localFile = (DataSourceLocalFile)localFileDialogResult.Data!;
|
||||
localFile = localFile with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ };
|
||||
addedDataSource = localFile;
|
||||
break;
|
||||
|
||||
case DataSourceType.LOCAL_DIRECTORY:
|
||||
var localDirectoryDialogParameters = new DialogParameters<DataSourceLocalDirectoryDialog>
|
||||
{
|
||||
{ x => x.IsEditing, false },
|
||||
{ x => x.AvailableEmbeddings, this.availableEmbeddingProviders }
|
||||
};
|
||||
|
||||
var localDirectoryDialogReference = await this.DialogService.ShowAsync<DataSourceLocalDirectoryDialog>(T("Add Local Directory as Data Source"), localDirectoryDialogParameters, DialogOptions.FULLSCREEN);
|
||||
var localDirectoryDialogResult = await localDirectoryDialogReference.Result;
|
||||
if (localDirectoryDialogResult is null || localDirectoryDialogResult.Canceled)
|
||||
return;
|
||||
|
||||
var localDirectory = (DataSourceLocalDirectory)localDirectoryDialogResult.Data!;
|
||||
localDirectory = localDirectory with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ };
|
||||
addedDataSource = localDirectory;
|
||||
break;
|
||||
|
||||
case DataSourceType.ERI_V1:
|
||||
var eriDialogParameters = new DialogParameters<DataSourceERI_V1Dialog>
|
||||
{
|
||||
{ x => x.IsEditing, false },
|
||||
};
|
||||
|
||||
var eriDialogReference = await this.DialogService.ShowAsync<DataSourceERI_V1Dialog>(T("Add ERI v1 Data Source"), eriDialogParameters, DialogOptions.FULLSCREEN);
|
||||
var eriDialogResult = await eriDialogReference.Result;
|
||||
if (eriDialogResult is null || eriDialogResult.Canceled)
|
||||
return;
|
||||
|
||||
var eriDataSource = (DataSourceERI_V1)eriDialogResult.Data!;
|
||||
eriDataSource = eriDataSource with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ };
|
||||
addedDataSource = eriDataSource;
|
||||
break;
|
||||
}
|
||||
|
||||
if(addedDataSource is null)
|
||||
return;
|
||||
|
||||
this.SettingsManager.ConfigurationData.DataSources.Add(addedDataSource);
|
||||
await this.SettingsManager.StoreSettings();
|
||||
await this.DataSourceEmbeddingService.QueueDataSourceAsync(addedDataSource);
|
||||
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
|
||||
}
|
||||
|
||||
private async Task ExportDataSource(IDataSource dataSource)
|
||||
{
|
||||
if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
|
||||
return;
|
||||
|
||||
if (dataSource is not DataSourceERI_V1 eriDataSource)
|
||||
return;
|
||||
|
||||
if (eriDataSource.AuthMethod is AuthMethod.KERBEROS)
|
||||
{
|
||||
await this.DialogService.ShowMessageBox(
|
||||
T("Export ERI Data Source"),
|
||||
T("Kerberos/SSO ERI data sources cannot be exported yet. Please configure them manually in the configuration plugin."),
|
||||
T("Close"));
|
||||
return;
|
||||
}
|
||||
|
||||
var needsSecret = eriDataSource.AuthMethod is AuthMethod.TOKEN or AuthMethod.USERNAME_PASSWORD;
|
||||
if (!needsSecret)
|
||||
{
|
||||
var publicLuaCode = eriDataSource.ExportAsConfigurationSection();
|
||||
if (!string.IsNullOrWhiteSpace(publicLuaCode))
|
||||
await this.RustService.CopyText2Clipboard(publicLuaCode);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var secretResponse = await this.RustService.GetSecret(eriDataSource, SecretStoreType.DATA_SOURCE, isTrying: true);
|
||||
if (!secretResponse.Success)
|
||||
{
|
||||
await this.DialogService.ShowMessageBox(
|
||||
T("Export ERI Data Source"),
|
||||
string.Format(T("Cannot export this ERI data source because no authentication secret is configured. The issue was: {0}"), secretResponse.Issue),
|
||||
T("Close"));
|
||||
return;
|
||||
}
|
||||
|
||||
var encryption = PluginFactory.EnterpriseEncryption;
|
||||
if (encryption?.IsAvailable != true)
|
||||
{
|
||||
await this.DialogService.ShowMessageBox(
|
||||
T("Export ERI Data Source"),
|
||||
T("Cannot export this ERI data source because no enterprise encryption secret is configured."),
|
||||
T("Close"));
|
||||
return;
|
||||
}
|
||||
|
||||
var usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED;
|
||||
if (eriDataSource.AuthMethod is AuthMethod.TOKEN)
|
||||
{
|
||||
var dialogParameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{ x => x.Message, T("This ERI data source has an access token configured. Do you want to include the encrypted access token in the export? Note: The recipient will need the same encryption secret to use the access token.") },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Export Access Token?"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return;
|
||||
}
|
||||
else if (eriDataSource.AuthMethod is AuthMethod.USERNAME_PASSWORD)
|
||||
{
|
||||
var dialogParameters = new DialogParameters<DataSourceERIV1UsernamePasswordExportDialog>
|
||||
{
|
||||
{ x => x.DataSource, eriDataSource },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<DataSourceERIV1UsernamePasswordExportDialog>(T("Export ERI Data Source"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is not DataSourceERIV1UsernamePasswordExportDialogResult exportResult)
|
||||
return;
|
||||
|
||||
usernamePasswordMode = exportResult.UsernamePasswordMode;
|
||||
}
|
||||
|
||||
var decryptedSecret = await secretResponse.Secret.Decrypt(Program.ENCRYPTION);
|
||||
if (!encryption.TryEncrypt(decryptedSecret, out var encryptedSecret))
|
||||
{
|
||||
await this.DialogService.ShowMessageBox(
|
||||
T("Export ERI Data Source"),
|
||||
T("Cannot export this ERI data source because the authentication secret could not be encrypted."),
|
||||
T("Close"));
|
||||
return;
|
||||
}
|
||||
|
||||
var luaCode = eriDataSource.ExportAsConfigurationSection(
|
||||
encryptedSecret,
|
||||
usernamePasswordMode);
|
||||
if (string.IsNullOrWhiteSpace(luaCode))
|
||||
return;
|
||||
|
||||
await this.RustService.CopyText2Clipboard(luaCode);
|
||||
}
|
||||
|
||||
private async Task EditDataSource(IDataSource dataSource)
|
||||
{
|
||||
if (dataSource.IsEnterpriseConfiguration)
|
||||
return;
|
||||
|
||||
IDataSource? editedDataSource = null;
|
||||
var lockDataSourceIdentity = dataSource is IInternalDataSource
|
||||
&& await this.DataSourceEmbeddingService.ShouldLockDataSourceIdentityAsync(dataSource.Id);
|
||||
switch (dataSource)
|
||||
{
|
||||
case DataSourceLocalFile localFile:
|
||||
var localFileDialogParameters = new DialogParameters<DataSourceLocalFileDialog>
|
||||
{
|
||||
{ x => x.IsEditing, true },
|
||||
{ x => x.DataSource, localFile },
|
||||
{ x => x.LockSourceAndEmbedding, lockDataSourceIdentity },
|
||||
{ x => x.AvailableEmbeddings, this.availableEmbeddingProviders }
|
||||
};
|
||||
|
||||
var localFileDialogReference = await this.DialogService.ShowAsync<DataSourceLocalFileDialog>(T("Edit Local File Data Source"), localFileDialogParameters, DialogOptions.FULLSCREEN);
|
||||
var localFileDialogResult = await localFileDialogReference.Result;
|
||||
if (localFileDialogResult is null || localFileDialogResult.Canceled)
|
||||
return;
|
||||
|
||||
editedDataSource = (DataSourceLocalFile)localFileDialogResult.Data!;
|
||||
break;
|
||||
|
||||
case DataSourceLocalDirectory localDirectory:
|
||||
var localDirectoryDialogParameters = new DialogParameters<DataSourceLocalDirectoryDialog>
|
||||
{
|
||||
{ x => x.IsEditing, true },
|
||||
{ x => x.DataSource, localDirectory },
|
||||
{ x => x.LockSourceAndEmbedding, lockDataSourceIdentity },
|
||||
{ x => x.AvailableEmbeddings, this.availableEmbeddingProviders }
|
||||
};
|
||||
|
||||
var localDirectoryDialogReference = await this.DialogService.ShowAsync<DataSourceLocalDirectoryDialog>(T("Edit Local Directory Data Source"), localDirectoryDialogParameters, DialogOptions.FULLSCREEN);
|
||||
var localDirectoryDialogResult = await localDirectoryDialogReference.Result;
|
||||
if (localDirectoryDialogResult is null || localDirectoryDialogResult.Canceled)
|
||||
return;
|
||||
|
||||
editedDataSource = (DataSourceLocalDirectory)localDirectoryDialogResult.Data!;
|
||||
break;
|
||||
|
||||
case DataSourceERI_V1 eriDataSource:
|
||||
var eriDialogParameters = new DialogParameters<DataSourceERI_V1Dialog>
|
||||
{
|
||||
{ x => x.IsEditing, true },
|
||||
{ x => x.DataSource, eriDataSource },
|
||||
};
|
||||
|
||||
var eriDialogReference = await this.DialogService.ShowAsync<DataSourceERI_V1Dialog>(T("Edit ERI v1 Data Source"), eriDialogParameters, DialogOptions.FULLSCREEN);
|
||||
var eriDialogResult = await eriDialogReference.Result;
|
||||
if (eriDialogResult is null || eriDialogResult.Canceled)
|
||||
return;
|
||||
|
||||
editedDataSource = (DataSourceERI_V1)eriDialogResult.Data!;
|
||||
break;
|
||||
}
|
||||
|
||||
if(editedDataSource is null)
|
||||
return;
|
||||
|
||||
this.SettingsManager.ConfigurationData.DataSources[this.SettingsManager.ConfigurationData.DataSources.IndexOf(dataSource)] = editedDataSource;
|
||||
|
||||
await this.SettingsManager.StoreSettings();
|
||||
await this.DataSourceEmbeddingService.QueueDataSourceAsync(editedDataSource);
|
||||
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
|
||||
}
|
||||
|
||||
private async Task DeleteDataSource(IDataSource dataSource)
|
||||
{
|
||||
if (dataSource.IsEnterpriseConfiguration)
|
||||
return;
|
||||
|
||||
var dialogParameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{ x => x.Message, string.Format(T("Are you sure you want to delete the data source '{0}' of type '{1}'?"), dataSource.Name, dataSource.Type.GetDisplayName()) },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Delete Data Source"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return;
|
||||
|
||||
var applyChanges = dataSource is IInternalDataSource;
|
||||
|
||||
// External data sources may need a secret for authentication:
|
||||
if (dataSource is IExternalDataSource externalDataSource)
|
||||
{
|
||||
// When the auth method is NONE or KERBEROS, we don't need to delete a secret.
|
||||
// In the case of KERBEROS, we don't store the Kerberos ticket in the secret store.
|
||||
if(dataSource is IERIDataSource { AuthMethod: AuthMethod.NONE or AuthMethod.KERBEROS })
|
||||
applyChanges = true;
|
||||
|
||||
// All other auth methods require a secret, which we need to delete now:
|
||||
else
|
||||
{
|
||||
var deleteSecretResponse = await this.RustService.DeleteSecret(externalDataSource, SecretStoreType.DATA_SOURCE);
|
||||
if (deleteSecretResponse.Success)
|
||||
applyChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if(applyChanges)
|
||||
{
|
||||
this.SettingsManager.ConfigurationData.DataSources.Remove(dataSource);
|
||||
await this.SettingsManager.StoreSettings();
|
||||
await this.DataSourceEmbeddingService.RemoveDataSourceAsync(dataSource);
|
||||
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowInformation(IDataSource dataSource)
|
||||
{
|
||||
switch (dataSource)
|
||||
{
|
||||
case DataSourceLocalFile localFile:
|
||||
var localFileDialogParameters = new DialogParameters<DataSourceLocalFileInfoDialog>
|
||||
{
|
||||
{ x => x.DataSource, localFile },
|
||||
};
|
||||
|
||||
await this.DialogService.ShowAsync<DataSourceLocalFileInfoDialog>(T("Local File Data Source Information"), localFileDialogParameters, DialogOptions.FULLSCREEN);
|
||||
break;
|
||||
|
||||
case DataSourceLocalDirectory localDirectory:
|
||||
var localDirectoryDialogParameters = new DialogParameters<DataSourceLocalDirectoryInfoDialog>
|
||||
{
|
||||
{ x => x.DataSource, localDirectory },
|
||||
};
|
||||
|
||||
await this.DialogService.ShowAsync<DataSourceLocalDirectoryInfoDialog>(T("Local Directory Data Source Information"), localDirectoryDialogParameters, DialogOptions.FULLSCREEN);
|
||||
break;
|
||||
|
||||
case DataSourceERI_V1 eriV1DataSource:
|
||||
var eriV1DialogParameters = new DialogParameters<DataSourceERI_V1InfoDialog>
|
||||
{
|
||||
{ x => x.DataSource, eriV1DataSource },
|
||||
};
|
||||
|
||||
await this.DialogService.ShowAsync<DataSourceERI_V1InfoDialog>(T("ERI v1 Data Source Information"), eriV1DialogParameters, DialogOptions.FULLSCREEN);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@using AIStudio.Settings
|
||||
@using AIStudio.Provider
|
||||
@inherits MSGComponentBase
|
||||
@if (this.SelectionMode is DataSourceSelectionMode.SELECTION_MODE)
|
||||
{
|
||||
@@ -6,11 +7,11 @@
|
||||
<MudTooltip Text="@T("Select the data you want to use here.")" Placement="Placement.Top">
|
||||
@if (this.PopoverTriggerMode is PopoverTriggerMode.ICON)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Source" Class="@this.PopoverButtonClasses" OnClick="@(() => this.ToggleDataSourceSelection())"/>
|
||||
<MudIconButton Icon="@AppIcons.DATABASE" Class="@this.PopoverButtonClasses" OnClick="@(() => this.ToggleDataSourceSelection())"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" StartIcon="@Icons.Material.Filled.Source" Class="@this.PopoverButtonClasses" OnClick="@(() => this.ToggleDataSourceSelection())">
|
||||
<MudButton Variant="Variant.Filled" StartIcon="@AppIcons.DATABASE" Class="@this.PopoverButtonClasses" OnClick="@(() => this.ToggleDataSourceSelection())">
|
||||
@T("Select data")
|
||||
</MudButton>
|
||||
}
|
||||
@@ -20,7 +21,7 @@
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<PreviewPrototype/>
|
||||
<PreviewBeta/>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5">
|
||||
@T("Data Source Selection")
|
||||
@@ -70,7 +71,7 @@
|
||||
{
|
||||
case true when this.availableDataSources.Count == 0:
|
||||
<MudText Typo="Typo.body1" Class="mb-3">
|
||||
@T("Your data sources cannot be used with the LLM provider you selected due to data privacy, or they are currently unavailable.")
|
||||
@T("Your data sources cannot be used with the selected providers due to data privacy or confidence-level requirements, or they are currently unavailable.")
|
||||
</MudText>
|
||||
break;
|
||||
|
||||
@@ -82,7 +83,7 @@
|
||||
|
||||
case false when this.availableDataSources.Count == 0:
|
||||
<MudText Typo="Typo.body1" Class="mb-3">
|
||||
@T("Your data sources cannot be used with the LLM provider you selected due to data privacy, or they are currently unavailable.")
|
||||
@T("Your data sources cannot be used with the selected providers due to data privacy or confidence-level requirements, or they are currently unavailable.")
|
||||
</MudText>
|
||||
break;
|
||||
|
||||
@@ -92,7 +93,18 @@
|
||||
@foreach (var source in this.availableDataSources)
|
||||
{
|
||||
<MudListItem Value="@source">
|
||||
@source.Name
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
|
||||
<MudText Typo="Typo.body1" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
|
||||
@source.Name
|
||||
</MudText>
|
||||
@if (source is IInternalDataSource internalSource)
|
||||
{
|
||||
<MudSpacer/>
|
||||
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
@@ -106,7 +118,18 @@
|
||||
@foreach (var source in this.availableDataSources)
|
||||
{
|
||||
<MudListItem Value="@source">
|
||||
@source.Name
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
|
||||
<MudText Typo="Typo.body1" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
|
||||
@source.Name
|
||||
</MudText>
|
||||
@if (source is IInternalDataSource internalSource)
|
||||
{
|
||||
<MudSpacer/>
|
||||
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
@@ -117,9 +140,18 @@
|
||||
{
|
||||
<MudListItem Value="@source">
|
||||
<ChildContent>
|
||||
<MudText Typo="Typo.body1">
|
||||
@source.DataSource.Name
|
||||
</MudText>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
|
||||
<MudText Typo="Typo.body1" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
|
||||
@source.DataSource.Name
|
||||
</MudText>
|
||||
@if (source.DataSource is IInternalDataSource internalSource)
|
||||
{
|
||||
<MudSpacer/>
|
||||
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
<MudProgressLinear Color="Color.Info" Min="0" Max="1" Value="@source.AIDecision.Confidence"/>
|
||||
<MudJustifiedText Typo="Typo.body2">
|
||||
@@ -148,7 +180,7 @@
|
||||
else if (this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE)
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-8 mt-3 border-dashed border rounded-lg">
|
||||
<PreviewPrototype/>
|
||||
<PreviewBeta/>
|
||||
<MudText Typo="Typo.h5">
|
||||
@T("Data Source Selection")
|
||||
</MudText>
|
||||
@@ -170,11 +202,22 @@ else if (this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE)
|
||||
@foreach (var source in this.availableDataSources)
|
||||
{
|
||||
<MudListItem Value="@source">
|
||||
@source.Name
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
|
||||
<MudText Typo="Typo.body1" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
|
||||
@source.Name
|
||||
</MudText>
|
||||
@if (source is IInternalDataSource internalSource)
|
||||
{
|
||||
<MudSpacer/>
|
||||
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
</MudField>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using AIStudio.Dialogs.Settings;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.Services;
|
||||
@@ -141,6 +142,8 @@ public partial class DataSourceSelection : MSGComponentBase
|
||||
private IReadOnlyCollection<DataSourceAgentSelected> GetSelectedDataSourcesWithAI() => this.DataSourcesAISelected.Where(n => n.Selected).ToList();
|
||||
|
||||
private string GetAIReasoning(DataSourceAgentSelected source) => $"AI reasoning (confidence {source.AIDecision.Confidence:P0}): {source.AIDecision.Reason}";
|
||||
|
||||
private string GetConfidenceIconStyle(IInternalDataSource source) => $"{source.ConfidenceLevel.SetColorStyle(this.SettingsManager)} flex-shrink: 0;";
|
||||
|
||||
public void ChangeOptionWithoutSaving(DataSourceOptions options, IReadOnlyList<DataSourceAgentSelected>? aiSelectedDataSources = null)
|
||||
{
|
||||
@@ -198,7 +201,7 @@ public partial class DataSourceSelection : MSGComponentBase
|
||||
this.StateHasChanged();
|
||||
|
||||
// Load the data sources:
|
||||
var sources = await this.DataSourceService.GetDataSources(this.LLMProvider, this.selectedDataSources);
|
||||
var sources = await this.DataSourceService.GetDataSources(this.LLMProvider, this.DataSourceOptions, this.selectedDataSources);
|
||||
if (generation != this.loadAndApplyFiltersGeneration)
|
||||
return;
|
||||
|
||||
@@ -222,7 +225,8 @@ public partial class DataSourceSelection : MSGComponentBase
|
||||
{
|
||||
this.aiBasedSourceSelection = state;
|
||||
this.DataSourceOptions.AutomaticDataSourceSelection = this.aiBasedSourceSelection;
|
||||
|
||||
|
||||
await this.LoadAndApplyFilters();
|
||||
await this.OptionsChanged();
|
||||
}
|
||||
|
||||
@@ -230,7 +234,8 @@ public partial class DataSourceSelection : MSGComponentBase
|
||||
{
|
||||
this.aiBasedValidation = state;
|
||||
this.DataSourceOptions.AutomaticValidation = this.aiBasedValidation;
|
||||
|
||||
|
||||
await this.LoadAndApplyFilters();
|
||||
await this.OptionsChanged();
|
||||
}
|
||||
|
||||
@@ -308,4 +313,4 @@ public partial class DataSourceSelection : MSGComponentBase
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@
|
||||
<MudSelectItem T="string" Value="@chatTemplate.Id">@chatTemplate.GetSafeName()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="string" Label="@T("Data sources (Optional)")" MultiSelection="@true" SelectedValues="@this.DataSourceIds" SelectedValuesChanged="@this.SetDataSourceIds" MultiSelectionTextFunc="@this.GetSelectedDataSourceText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Source">
|
||||
<MudSelect T="string" Label="@T("Data sources (Optional)")" MultiSelection="@true" SelectedValues="@this.DataSourceIds" SelectedValuesChanged="@this.SetDataSourceIds" MultiSelectionTextFunc="@this.GetSelectedDataSourceText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@AppIcons.DATABASE">
|
||||
@foreach (var dataSource in this.SettingsManager.ConfigurationData.DataSources)
|
||||
{
|
||||
<MudSelectItem T="string" Value="@dataSource.Id">@dataSource.Name</MudSelectItem>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<MudExpansionPanel Class="border-solid border rounded-lg" Expanded="@this.IsExpanded" MaxHeight="@this.MaxHeight" ExpandedChanged="async s => await this.ExpandedChanged(s)">
|
||||
<MudExpansionPanel Class="border-solid border rounded-lg" HeaderClass="@this.HeaderClass" Expanded="@this.IsExpanded" MaxHeight="@this.MaxHeight" ExpandedChanged="async s => await this.ExpandedChanged(s)">
|
||||
<TitleContent>
|
||||
<div class="d-flex align-center">
|
||||
<MudIcon Icon="@this.HeaderIcon" Size="@this.IconSize" Color="@this.IconColor" class="mr-3"/>
|
||||
<MudText Typo="Typo.h6">
|
||||
<MudText Typo="@this.HeaderTypo">
|
||||
@this.HeaderText
|
||||
</MudText>
|
||||
@if (this.ShowEndButton)
|
||||
|
||||
@@ -15,7 +15,27 @@ public partial class ExpansionPanel : ComponentBase
|
||||
|
||||
[Parameter]
|
||||
public string HeaderText { get; set; } = "n/a";
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The typography of the header text.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Worth lowering for a panel which sits inside another one, together with the compact header
|
||||
/// class below: two headers of the same size give no clue about which one contains the other.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public Typo HeaderTypo { get; set; } = Typo.h6;
|
||||
|
||||
/// <summary>
|
||||
/// Additional class names for the header, separated by space.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The one this exists for is expansion-panel-header-compact, which takes the height of the
|
||||
/// header down for a nested panel.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public string HeaderClass { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public int? MaxHeight { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<div @onmouseenter="@this.OnMouseEnter" @onmouseleave="@this.OnMouseLeave">
|
||||
<MudPaper Outlined="@true" Class="@this.dragClass">
|
||||
@this.ChildContent
|
||||
</MudPaper>
|
||||
</div>
|
||||
@@ -0,0 +1,178 @@
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// A drop zone which reports the paths of whatever was dropped on it, and nothing else.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Dropping is a native matter in AI Studio: the Tauri runtime reports real paths, which is why
|
||||
/// this zone can hand out folders just as well as files. What those paths mean is the consumer's
|
||||
/// business — this component reads no content and does not care whether a path leads to a file or
|
||||
/// to a folder.
|
||||
/// </remarks>
|
||||
public partial class PathDropZone : MSGComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The content shown inside the zone.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reports the dropped paths, in the order the runtime delivered them.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<List<string>> OnPathsDropped { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// On which layer to register the drop area. Higher layers have priority over lower layers.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int Layer { get; set; } = DropLayers.ROOT;
|
||||
|
||||
/// <summary>
|
||||
/// Catch all documents that are hovered over the AI Studio window and not only over the drop zone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Practically every zone needs this today. Hovering is detected through mouse events, and no
|
||||
/// webview delivers those while a native drag is in progress, so a zone without this flag
|
||||
/// hardly ever catches anything. The consequence is that two zones of the same layer cannot be
|
||||
/// told apart: the one carrying this flag takes every drop, including the ones meant for the
|
||||
/// other. A page may therefore hold only one zone per layer. Lifting that limit needs the
|
||||
/// cursor position, which the runtime receives from Tauri and currently discards in
|
||||
/// app_window.rs.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public bool CatchAllDocuments { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the zone ignores drops and is not highlighted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The drop area stays registered nevertheless. Releasing it during the lifetime of the
|
||||
/// component would lower the count of every zone below this one, and those zones would then
|
||||
/// catch files while this one is still on screen.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
[Inject]
|
||||
private ILogger<PathDropZone> Logger { get; init; } = null!;
|
||||
|
||||
private const string DEFAULT_DRAG_CLASS = "relative rounded-lg border-2 border-dashed pa-3 mb-3 mud-width-full";
|
||||
|
||||
private string dragClass = DEFAULT_DRAG_CLASS;
|
||||
private uint numDropAreasAboveThis;
|
||||
private bool isComponentHovered;
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.ApplyFilters([], [ Event.TAURI_EVENT_RECEIVED, Event.REGISTER_FILE_DROP_AREA, Event.UNREGISTER_FILE_DROP_AREA ]);
|
||||
await this.MessageBus.SendMessage(this, Event.REGISTER_FILE_DROP_AREA, this.Layer);
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the drop area.
|
||||
/// </summary>
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
// Without this, drop areas below this one would count this component forever and would
|
||||
// stop catching dropped files:
|
||||
this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer).Observe($"{nameof(PathDropZone)}: releasing the drop area");
|
||||
|
||||
base.DisposeResources();
|
||||
}
|
||||
|
||||
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
|
||||
{
|
||||
// A disabled zone takes no files. It keeps track of the zones above it, though, because
|
||||
// those come and go while this one is disabled:
|
||||
if (this.Disabled && triggeredEvent == Event.TAURI_EVENT_RECEIVED)
|
||||
return;
|
||||
|
||||
switch (triggeredEvent)
|
||||
{
|
||||
case Event.REGISTER_FILE_DROP_AREA when sendingComponent != this:
|
||||
{
|
||||
if(data is int layer && layer > this.Layer)
|
||||
{
|
||||
this.numDropAreasAboveThis++;
|
||||
this.ClearDragClass();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Event.UNREGISTER_FILE_DROP_AREA when sendingComponent != this:
|
||||
{
|
||||
if(data is int layer && layer > this.Layer && this.numDropAreasAboveThis > 0)
|
||||
this.numDropAreasAboveThis--;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_HOVERED }:
|
||||
if(!this.CanCatchDroppedPath())
|
||||
return;
|
||||
|
||||
this.SetDragClass();
|
||||
this.StateHasChanged();
|
||||
break;
|
||||
|
||||
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_CANCELED }:
|
||||
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.WINDOW_NOT_FOCUSED }:
|
||||
this.isComponentHovered = false;
|
||||
this.ClearDragClass();
|
||||
this.StateHasChanged();
|
||||
break;
|
||||
|
||||
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_DROPPED, Payload: var paths }:
|
||||
if(!this.CanCatchDroppedPath())
|
||||
return;
|
||||
|
||||
this.Logger.LogDebug("The path drop zone on layer {Layer} caught {Count} path(s).", this.Layer, paths.Count);
|
||||
await this.OnPathsDropped.InvokeAsync(paths);
|
||||
this.ClearDragClass();
|
||||
this.StateHasChanged();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private bool CanCatchDroppedPath() => this.numDropAreasAboveThis is 0 && (this.isComponentHovered || this.CatchAllDocuments);
|
||||
|
||||
private void SetDragClass() => this.dragClass = $"{DEFAULT_DRAG_CLASS} mud-border-primary border-2";
|
||||
|
||||
private void ClearDragClass() => this.dragClass = DEFAULT_DRAG_CLASS;
|
||||
|
||||
private void OnMouseEnter(EventArgs _)
|
||||
{
|
||||
if(this.Disabled || this.numDropAreasAboveThis > 0)
|
||||
return;
|
||||
|
||||
// A native drag delivers no DOM events at all, mouse events included. This fires before a
|
||||
// drag begins, while the pointer still moves freely, which makes it a hint about where the
|
||||
// user is aiming rather than a reliable signal. See the remarks on CatchAllDocuments:
|
||||
this.isComponentHovered = true;
|
||||
this.SetDragClass();
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnMouseLeave(EventArgs _)
|
||||
{
|
||||
if(this.Disabled)
|
||||
return;
|
||||
|
||||
this.isComponentHovered = false;
|
||||
this.ClearDragClass();
|
||||
this.StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,37 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<MudStack Row="@true" Spacing="3" Class="mb-3" StretchItems="StretchItems.None" AlignItems="AlignItems.Center">
|
||||
<MudTextField
|
||||
T="string"
|
||||
Text="@this.Directory"
|
||||
Label="@this.Label"
|
||||
ReadOnly="@true"
|
||||
Validation="@this.Validation"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Folder"
|
||||
UserAttributes="@SPELLCHECK_ATTRIBUTES"
|
||||
Variant="Variant.Outlined"
|
||||
/>
|
||||
|
||||
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen" Variant="Variant.Outlined" Color="Color.Primary" Disabled="@(this.Disabled || this.isDirectoryDialogOpen)" OnClick="@this.OpenDirectoryDialog">
|
||||
@T("Choose Directory")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
@if (this.EnableDragDrop)
|
||||
{
|
||||
<PathDropZone Layer="@this.Layer" CatchAllDocuments="@this.CatchAllDocuments" Disabled="@this.Disabled" OnPathsDropped="@this.PathsDropped">
|
||||
@this.Picker
|
||||
<MudText Typo="Typo.body2">
|
||||
@T("You can also drag & drop the folder here.")
|
||||
</MudText>
|
||||
</PathDropZone>
|
||||
}
|
||||
else
|
||||
{
|
||||
@this.Picker
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
private RenderFragment Picker =>
|
||||
@<MudStack Row="@true" Spacing="3" Class="mb-3" StretchItems="StretchItems.None" AlignItems="AlignItems.Center">
|
||||
<MudTextField
|
||||
T="string"
|
||||
Text="@this.Directory"
|
||||
Label="@this.Label"
|
||||
ReadOnly="@true"
|
||||
Validation="@this.Validation"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Folder"
|
||||
UserAttributes="@SPELLCHECK_ATTRIBUTES"
|
||||
Variant="Variant.Outlined"
|
||||
/>
|
||||
|
||||
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen" Variant="Variant.Outlined" Color="Color.Primary" Disabled="@(this.Disabled || this.isDirectoryDialogOpen)" OnClick="@this.OpenDirectoryDialog">
|
||||
@T("Choose Directory")
|
||||
</MudButton>
|
||||
</MudStack>;
|
||||
}
|
||||
@@ -2,6 +2,9 @@ using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
// This component has a parameter called Directory, which would shadow the file system's Directory class:
|
||||
using IODirectory = System.IO.Directory;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class SelectDirectory : MSGComponentBase
|
||||
@@ -24,6 +27,24 @@ public partial class SelectDirectory : MSGComponentBase
|
||||
[Parameter]
|
||||
public Func<string, string?> Validation { get; set; } = _ => null;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the folder can also be chosen by dropping it onto this component.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool EnableDragDrop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// On which layer to register the drop area. Higher layers have priority over lower layers.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int Layer { get; set; } = DropLayers.ROOT;
|
||||
|
||||
/// <summary>
|
||||
/// Catch all documents that are hovered over the AI Studio window and not only over the drop zone.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool CatchAllDocuments { get; set; }
|
||||
|
||||
[Inject]
|
||||
public RustService RustService { get; set; } = null!;
|
||||
|
||||
@@ -69,4 +90,39 @@ public partial class SelectDirectory : MSGComponentBase
|
||||
this.isDirectoryDialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the first dropped path which leads to a folder.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A dropped file is rejected instead of being taken as its parent folder. Everything a folder
|
||||
/// contains is processed, so guessing the parent of a mistakenly dropped file could pull in far
|
||||
/// more data than the user meant to hand over.
|
||||
/// </remarks>
|
||||
/// <param name="paths">The dropped paths.</param>
|
||||
private async Task PathsDropped(List<string> paths)
|
||||
{
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (!IODirectory.Exists(path))
|
||||
continue;
|
||||
|
||||
this.Logger.LogInformation("The user dropped the directory '{DroppedDirectory}'.", path);
|
||||
this.InternalDirectoryChanged(path);
|
||||
return;
|
||||
}
|
||||
|
||||
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a directory.", paths.Count);
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, this.GetDropWarning(paths)));
|
||||
}
|
||||
|
||||
private string GetDropWarning(List<string> paths)
|
||||
{
|
||||
// Naming the actual mistake beats a generic "that did not work". Dropping a file onto a
|
||||
// folder picker is the likeliest of them:
|
||||
if (paths.Any(File.Exists))
|
||||
return T("Please drop a folder, not a file.");
|
||||
|
||||
return T("The dropped folder could not be accessed. Please choose it with the folder chooser instead.");
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,37 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<MudStack Row="@true" Spacing="3" Class="mb-3" StretchItems="StretchItems.None" AlignItems="AlignItems.Center">
|
||||
<MudTextField
|
||||
T="string"
|
||||
Text="@this.File"
|
||||
Label="@this.Label"
|
||||
ReadOnly="@true"
|
||||
Validation="@this.Validation"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.AttachFile"
|
||||
UserAttributes="@SPELLCHECK_ATTRIBUTES"
|
||||
Variant="Variant.Outlined"
|
||||
/>
|
||||
|
||||
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen" Variant="Variant.Outlined" Color="Color.Primary" Disabled="@(this.Disabled || this.isFileDialogOpen)" OnClick="@this.OpenFileDialog">
|
||||
@T("Choose File")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
@if (this.EnableDragDrop)
|
||||
{
|
||||
<PathDropZone Layer="@this.Layer" CatchAllDocuments="@this.CatchAllDocuments" Disabled="@this.Disabled" OnPathsDropped="@this.PathsDropped">
|
||||
@this.Picker
|
||||
<MudText Typo="Typo.body2">
|
||||
@T("You can also drag & drop the file here.")
|
||||
</MudText>
|
||||
</PathDropZone>
|
||||
}
|
||||
else
|
||||
{
|
||||
@this.Picker
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
private RenderFragment Picker =>
|
||||
@<MudStack Row="@true" Spacing="3" Class="mb-3" StretchItems="StretchItems.None" AlignItems="AlignItems.Center">
|
||||
<MudTextField
|
||||
T="string"
|
||||
Text="@this.File"
|
||||
Label="@this.Label"
|
||||
ReadOnly="@true"
|
||||
Validation="@this.Validation"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.AttachFile"
|
||||
UserAttributes="@SPELLCHECK_ATTRIBUTES"
|
||||
Variant="Variant.Outlined"
|
||||
/>
|
||||
|
||||
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen" Variant="Variant.Outlined" Color="Color.Primary" Disabled="@(this.Disabled || this.isFileDialogOpen)" OnClick="@this.OpenFileDialog">
|
||||
@T("Choose File")
|
||||
</MudButton>
|
||||
</MudStack>;
|
||||
}
|
||||
@@ -3,6 +3,9 @@ using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
// This component has a parameter called File, which would shadow the file system's File class:
|
||||
using IOFile = System.IO.File;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class SelectFile : MSGComponentBase
|
||||
@@ -28,6 +31,24 @@ public partial class SelectFile : MSGComponentBase
|
||||
[Parameter]
|
||||
public Func<string, string?> Validation { get; set; } = _ => null;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the file can also be chosen by dropping it onto this component.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool EnableDragDrop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// On which layer to register the drop area. Higher layers have priority over lower layers.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int Layer { get; set; } = DropLayers.ROOT;
|
||||
|
||||
/// <summary>
|
||||
/// Catch all documents that are hovered over the AI Studio window and not only over the drop zone.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool CatchAllDocuments { get; set; }
|
||||
|
||||
[Inject]
|
||||
public RustService RustService { get; set; } = null!;
|
||||
|
||||
@@ -73,4 +94,46 @@ public partial class SelectFile : MSGComponentBase
|
||||
this.isFileDialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the first dropped path which leads to a usable file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This component carries exactly one file, so a multi-selection cannot be honored as a whole.
|
||||
/// A dropped folder is rejected instead of being read as "the first file inside it": the user
|
||||
/// was asked for a file, and picking one for them would be a surprise.
|
||||
/// </remarks>
|
||||
/// <param name="paths">The dropped paths.</param>
|
||||
private async Task PathsDropped(List<string> paths)
|
||||
{
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (!IOFile.Exists(path))
|
||||
continue;
|
||||
|
||||
if (this.Filter is { Length: > 0 } && !FileTypes.IsAllowedPath(path, this.Filter))
|
||||
continue;
|
||||
|
||||
this.Logger.LogInformation("The user dropped the file '{DroppedFilePath}'.", path);
|
||||
this.InternalFileChanged(path);
|
||||
return;
|
||||
}
|
||||
|
||||
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a file.", paths.Count);
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, this.GetDropWarning(paths)));
|
||||
}
|
||||
|
||||
private string GetDropWarning(List<string> paths)
|
||||
{
|
||||
// Naming the actual mistake beats a generic "that did not work". Dropping a folder onto a
|
||||
// file picker is the likeliest of them:
|
||||
if (paths.Any(Directory.Exists))
|
||||
return T("Please drop a file, not a folder.");
|
||||
|
||||
// The file exists, so the file type filter is what turned it down:
|
||||
if (paths.Any(IOFile.Exists))
|
||||
return T("Please drop a file with a supported file type.");
|
||||
|
||||
return T("The dropped file could not be accessed. Please choose it with the file chooser instead.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@using AIStudio.Settings.DataModel
|
||||
@inherits SettingsPanelBase
|
||||
|
||||
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
|
||||
{
|
||||
<ExpansionPanel HeaderIcon="@AppIcons.DATABASE" HeaderText="@T("Configure Data Sources")">
|
||||
<PreviewBeta ApplyInnerScrollingFix="true"/>
|
||||
<DataSourceManagement/>
|
||||
</ExpansionPanel>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Components.Settings;
|
||||
|
||||
public partial class SettingsPanelDataSources : SettingsPanelBase;
|
||||
@@ -6,7 +6,7 @@
|
||||
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
|
||||
{
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.IntegrationInstructions" HeaderText="@T("Configure Embedding Providers")">
|
||||
<PreviewPrototype ApplyInnerScrollingFix="true"/>
|
||||
<PreviewBeta ApplyInnerScrollingFix="true"/>
|
||||
<MudText Typo="Typo.h4" Class="mb-3">
|
||||
@T("Configured Embedding Providers")
|
||||
</MudText>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -11,6 +12,9 @@ namespace AIStudio.Components.Settings;
|
||||
|
||||
public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
|
||||
{
|
||||
[Inject]
|
||||
private DataSourceEmbeddingService DataSourceEmbeddingService { get; init; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Groups the table by the used LLM provider. The embedding provider list is already sorted by
|
||||
/// that provider, so all instances of one LLM provider form a single, coherent group.
|
||||
@@ -68,6 +72,7 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
|
||||
await this.UpdateEmbeddingProviders();
|
||||
|
||||
await this.SettingsManager.StoreSettings();
|
||||
await this.DataSourceEmbeddingService.QueueAllInternalDataSourcesAsync();
|
||||
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
|
||||
}
|
||||
|
||||
@@ -88,6 +93,9 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
|
||||
{ x => x.IsSelfHosted, embeddingProvider.IsSelfHosted },
|
||||
{ x => x.IsEditing, true },
|
||||
{ x => x.DataHost, embeddingProvider.Host },
|
||||
{ x => x.DataTokenizerPath, embeddingProvider.TokenizerPath },
|
||||
{ x => x.DataTokenLimit, embeddingProvider.EffectiveTokenLimit },
|
||||
{ x => x.DataEmbeddingBatchSize, embeddingProvider.EffectiveEmbeddingBatchSize },
|
||||
{ x => x.HFInferenceProviderId, embeddingProvider.HFInferenceProvider },
|
||||
{ x => x.IsEnterpriseConfiguration, embeddingProvider.IsEnterpriseConfiguration },
|
||||
};
|
||||
@@ -118,6 +126,7 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
|
||||
await this.UpdateEmbeddingProviders();
|
||||
|
||||
await this.SettingsManager.StoreSettings();
|
||||
await this.DataSourceEmbeddingService.QueueAllInternalDataSourcesAsync();
|
||||
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
|
||||
}
|
||||
|
||||
@@ -134,13 +143,36 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
|
||||
return;
|
||||
|
||||
var deleteSecretResponse = await this.RustService.DeleteAPIKey(provider, SecretStoreType.EMBEDDING_PROVIDER);
|
||||
|
||||
//
|
||||
// Removing the tokenizer is best effort: it leaves an unused file behind when it fails,
|
||||
// which is not worth bothering the user about while they are deleting the provider. The
|
||||
// API key is different, though, because a leftover secret is a secret we promised to remove.
|
||||
//
|
||||
_ = await this.RustService.DeleteTokenizer(TokenizerModelId.ForEmbeddingProvider(provider));
|
||||
if(deleteSecretResponse.Success)
|
||||
{
|
||||
this.SettingsManager.ConfigurationData.EmbeddingProviders.Remove(provider);
|
||||
await this.SettingsManager.StoreSettings();
|
||||
}
|
||||
else
|
||||
{
|
||||
var issueDialogParameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{ x => x.Message, string.Format(T("Couldn't delete the embedding provider '{0}'. The issue: {1}. We can ignore this issue and delete the embedding provider anyway. Do you want to ignore it and delete this embedding provider?"), provider.Name, deleteSecretResponse.Issue) },
|
||||
};
|
||||
|
||||
var issueDialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Delete Embedding Provider"), issueDialogParameters, DialogOptions.FULLSCREEN);
|
||||
var issueDialogResult = await issueDialogReference.Result;
|
||||
if (issueDialogResult is null || issueDialogResult.Canceled)
|
||||
return;
|
||||
|
||||
this.SettingsManager.ConfigurationData.EmbeddingProviders.Remove(provider);
|
||||
await this.SettingsManager.StoreSettings();
|
||||
}
|
||||
|
||||
await this.UpdateEmbeddingProviders();
|
||||
await this.DataSourceEmbeddingService.QueueAllInternalDataSourcesAsync();
|
||||
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
|
||||
}
|
||||
|
||||
@@ -183,7 +215,21 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
|
||||
return;
|
||||
|
||||
var embeddingProvider = provider.CreateProvider();
|
||||
var embeddings = await embeddingProvider.EmbedTextAsync(provider.Model, this.SettingsManager, default, new List<string> { inputText });
|
||||
IReadOnlyList<IReadOnlyList<float>> embeddings;
|
||||
try
|
||||
{
|
||||
embeddings = await embeddingProvider.EmbedTextAsync(provider.Model, this.SettingsManager, CancellationToken.None, inputText);
|
||||
}
|
||||
catch (ProviderRequestException exception)
|
||||
{
|
||||
//
|
||||
// The provider named what went wrong and what to do about it. Showing that beats the
|
||||
// sentence below, which used to be the same one for a missing API key, an unreachable
|
||||
// provider and a provider which cannot embed anything at all:
|
||||
//
|
||||
await this.DialogService.ShowMessageBox(T("Embedding Result"), exception.UserMessage, T("Close"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (embeddings.Count == 0)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -84,6 +85,7 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase
|
||||
{ x => x.DataHost, provider.Host },
|
||||
{ x => x.HFInferenceProviderId, provider.HFInferenceProvider },
|
||||
{ x => x.AdditionalJsonApiParameters, provider.AdditionalJsonApiParameters },
|
||||
{ x => x.DataTokenizerPath, provider.TokenizerPath },
|
||||
{ x => x.DataCapabilityOverrides, provider.CapabilityOverrides },
|
||||
{ x => x.IsEnterpriseConfiguration, provider.IsEnterpriseConfiguration },
|
||||
};
|
||||
@@ -131,6 +133,13 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase
|
||||
return;
|
||||
|
||||
var deleteSecretResponse = await this.RustService.DeleteAPIKey(provider, SecretStoreType.LLM_PROVIDER);
|
||||
|
||||
//
|
||||
// Removing the tokenizer is best effort: it leaves an unused file behind when it fails,
|
||||
// which is not worth bothering the user about while they are deleting the provider. The
|
||||
// API key is different, though, because a leftover secret is a secret we promised to remove.
|
||||
//
|
||||
_ = await this.RustService.DeleteTokenizer(TokenizerModelId.ForProvider(provider));
|
||||
if(deleteSecretResponse.Success)
|
||||
{
|
||||
this.SettingsManager.ConfigurationData.Providers.Remove(provider);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Timer = System.Timers.Timer;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Debounced multi-line text input built on <see cref="MudTextField{T}"/>.
|
||||
/// Keeps the base API while adding a debounce timer.
|
||||
/// Callers can override any property as usual.
|
||||
/// </summary>
|
||||
public class UserPromptComponent<T> : MudTextField<T>, IDisposable
|
||||
{
|
||||
[Parameter]
|
||||
public TimeSpan DebounceTime { get; set; } = TimeSpan.FromMilliseconds(800);
|
||||
|
||||
[Parameter]
|
||||
public Func<string, Task> WhenTextChangedAsync { get; set; } = _ => Task.CompletedTask;
|
||||
|
||||
private readonly Timer debounceTimer = new();
|
||||
private string text = string.Empty;
|
||||
private string lastParameterText = string.Empty;
|
||||
private string lastNotifiedText = string.Empty;
|
||||
private bool isInitialized;
|
||||
private bool isDisposed;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.text = this.Text ?? string.Empty;
|
||||
this.lastParameterText = this.text;
|
||||
this.lastNotifiedText = this.text;
|
||||
this.debounceTimer.AutoReset = false;
|
||||
this.debounceTimer.Interval = this.DebounceTime.TotalMilliseconds;
|
||||
this.debounceTimer.Elapsed += this.WhenDebounceElapsed;
|
||||
|
||||
this.isInitialized = true;
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// Ensure the timer uses the latest debouncing interval:
|
||||
if (!this.isInitialized || this.isDisposed)
|
||||
{
|
||||
await base.OnParametersSetAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if(Math.Abs(this.debounceTimer.Interval - this.DebounceTime.TotalMilliseconds) > 1)
|
||||
this.debounceTimer.Interval = this.DebounceTime.TotalMilliseconds;
|
||||
|
||||
// Only sync when the parent's parameter actually changed since the last change:
|
||||
if (this.Text != this.lastParameterText)
|
||||
{
|
||||
this.text = this.Text ?? string.Empty;
|
||||
this.lastParameterText = this.text;
|
||||
}
|
||||
|
||||
this.debounceTimer.Stop();
|
||||
this.debounceTimer.Start();
|
||||
|
||||
await base.OnParametersSetAsync();
|
||||
}
|
||||
|
||||
private void WhenDebounceElapsed(object? sender, System.Timers.ElapsedEventArgs args)
|
||||
{
|
||||
this.debounceTimer.Stop();
|
||||
|
||||
//
|
||||
// The timer runs on its own thread and may still fire while this component is being torn
|
||||
// down. Notifying a renderer which is already gone would throw on that thread, where no
|
||||
// caller is left to handle it.
|
||||
//
|
||||
if (this.isDisposed || this.text == this.lastNotifiedText)
|
||||
return;
|
||||
|
||||
this.lastNotifiedText = this.text;
|
||||
this.InvokeAsync(async () => await this.TextChanged.InvokeAsync(this.text)).Observe($"{nameof(UserPromptComponent<T>)}: notifying about changed text");
|
||||
this.InvokeAsync(async () => await this.WhenTextChangedAsync(this.text)).Observe($"{nameof(UserPromptComponent<T>)}: handling changed text asynchronously");
|
||||
}
|
||||
|
||||
#region IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (this.isDisposed)
|
||||
return;
|
||||
|
||||
//
|
||||
// Set before stopping the timer: the handler might be running on the timer thread right
|
||||
// now, and this is what tells it to leave the gone renderer alone.
|
||||
//
|
||||
this.isDisposed = true;
|
||||
try
|
||||
{
|
||||
this.debounceTimer.Elapsed -= this.WhenDebounceElapsed;
|
||||
this.debounceTimer.Stop();
|
||||
this.debounceTimer.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in new issue
Block a user