mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 13:07:21 +00:00
Added a way to read what the AI received from your sources (#1100)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
e0d8e18f34
commit
cbc4ce5413
229 files changed
+12739
-1269
No files matched your search
@@ -81,6 +81,16 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
[Parameter]
|
||||
public Func<string, Task<ChatThread?>> EnsureOwnerChatAsync { get; set; } = _ => Task.FromResult<ChatThread?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Keeps what the preview read of an attached document, so that sending it does not read the file again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the chat hands one over, cf. DocumentCheckDialog.KeepReadText. Elsewhere, e.g., in the
|
||||
/// assistants, the documents are read for every run anyway.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public Func<FileAttachment, string, FileVersion, Task>? KeepPreviewedText { get; set; }
|
||||
|
||||
[Inject]
|
||||
private ILogger<AttachDocuments> Logger { get; set; } = null!;
|
||||
|
||||
@@ -263,7 +273,7 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
return;
|
||||
|
||||
var previousAttachments = this.DocumentPaths.ToHashSet();
|
||||
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths, this.AttachDroppedPathsAsync, () => this.IsUnavailable);
|
||||
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths, this.AttachDroppedPathsAsync, () => this.IsUnavailable, this.KeepPreviewedText);
|
||||
var removedAttachments = previousAttachments.Except(this.DocumentPaths).ToList();
|
||||
foreach (var removedAttachment in removedAttachments)
|
||||
ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment);
|
||||
@@ -383,7 +393,7 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
// about whether it happened to be new to us.
|
||||
//
|
||||
var attachment = FileAttachment.FromPath(path);
|
||||
this.DocumentPaths.Add(attachment);
|
||||
this.DocumentPaths.AttachInPlaceOfSameFile(attachment);
|
||||
attached.Add(attachment);
|
||||
}
|
||||
|
||||
@@ -452,6 +462,7 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
{ x => x.Document, fileAttachment },
|
||||
{ x => x.AttachPaths, this.AttachDroppedPathsAsync },
|
||||
{ x => x.IsAttachingUnavailable, () => this.IsUnavailable },
|
||||
{ x => x.KeepReadText, this.KeepPreviewedText },
|
||||
};
|
||||
|
||||
await this.DialogService.ShowAsync<DocumentCheckDialog>(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
<AttachDocuments Name="File Attachments" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" ShowMediaStatus="false" Provider="@this.Provider" OwnerChat="@this.ChatThread" EnsureOwnerChatAsync="@this.EnsureMediaImportChatAsync" Disabled="@(!this.CanAttachFiles)"/>
|
||||
<AttachDocuments Name="File Attachments" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" ShowMediaStatus="false" Provider="@this.Provider" OwnerChat="@this.ChatThread" EnsureOwnerChatAsync="@this.EnsureMediaImportChatAsync" KeepPreviewedText="@this.KeepPreviewedTextAsync" Disabled="@(!this.CanAttachFiles)"/>
|
||||
|
||||
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ using AIStudio.Dialogs;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.Databases;
|
||||
using AIStudio.Tools.Databases.ProvenanceStore;
|
||||
using AIStudio.Tools.ToolCallingSystem;
|
||||
using AIStudio.Tools.AIJobs;
|
||||
using AIStudio.Tools.Media;
|
||||
@@ -84,6 +86,9 @@ public partial class ChatComponent : MSGComponentBase
|
||||
[Inject]
|
||||
private NavigationManager NavigationManager { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private DatabaseClientProvider DatabaseClientProvider { get; init; } = null!;
|
||||
|
||||
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top;
|
||||
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
|
||||
|
||||
@@ -1273,7 +1278,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
this.hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
private void ComposerAttachmentsChanged(HashSet<FileAttachment> attachments)
|
||||
private async Task ComposerAttachmentsChanged(HashSet<FileAttachment> attachments)
|
||||
{
|
||||
if (!ReferenceEquals(this.ComposerState.FileAttachments, attachments))
|
||||
this.ComposerState.ReplaceFileAttachments(attachments);
|
||||
@@ -1281,6 +1286,10 @@ public partial class ChatComponent : MSGComponentBase
|
||||
this.ComposerState.MarkUserDraft();
|
||||
this.KeepComposerInDraft();
|
||||
this.hasUnsavedChanges = true;
|
||||
|
||||
// A file the user took out goes without the text its preview read:
|
||||
if (this.ChatThread is { } chat)
|
||||
await this.ForgetPreviewedTextsAsync(chat.ChatId, chat.Draft.ForgetPreviewsOfDetachedFiles());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1428,7 +1437,117 @@ public partial class ChatComponent : MSGComponentBase
|
||||
}
|
||||
|
||||
/// <summary>Creates and stores a stable draft immediately after media import confirmation.</summary>
|
||||
private async Task<ChatThread?> EnsureMediaImportChatAsync(string firstMediaPath)
|
||||
private Task<ChatThread?> EnsureMediaImportChatAsync(string firstMediaPath) => this.EnsureChatAsync(string.Format(T("Transcription: {0}"), Path.GetFileName(firstMediaPath)));
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the text the preview read of an attached document, so that sending the message does not read the file again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The text belongs to a chat, and its draft notes it, see ChatDraft.PreviewedTexts. Only a chat
|
||||
/// which exists keeps it: looking at a file must not create a chat, which the user may never have
|
||||
/// meant to keep. In a new chat, sending reads the file, as it did before. Another preview of a
|
||||
/// file which did not change keeps nothing new; one of a file which changed replaces the text kept
|
||||
/// before. Nothing here may stop the preview: should the text not be kept, sending reads the file
|
||||
/// as before.
|
||||
/// </remarks>
|
||||
/// <param name="attachment">The attachment, as the composer holds it.</param>
|
||||
/// <param name="text">The text the preview read, after the prompt injection filter.</param>
|
||||
/// <param name="version">The state of the file before the preview read it.</param>
|
||||
private async Task KeepPreviewedTextAsync(FileAttachment attachment, string text, FileVersion version)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (this.ChatThread is not { } chat)
|
||||
return;
|
||||
|
||||
if (chat.Draft.GetPreviewedText(attachment.FilePath)?.Version == version)
|
||||
return;
|
||||
|
||||
var store = await this.DatabaseClientProvider.GetProvenanceStoreAsync();
|
||||
var provenanceId = await store.StoreAsync(chat.ChatId, ProvenanceKind.ATTACHMENT, text, null, CancellationToken.None);
|
||||
if (provenanceId is null)
|
||||
return;
|
||||
|
||||
// Stored with the chat like a change in the composer, so the text still counts after leaving the chat:
|
||||
var replacedId = chat.Draft.NotePreviewedText(new(attachment.FilePath, provenanceId.Value, version));
|
||||
this.KeepComposerInDraft();
|
||||
|
||||
if (replacedId is { } id)
|
||||
await this.ForgetPreviewedTextsAsync(chat.ChatId, [id]);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this.Logger.LogWarning(exception, "Failed to keep the previewed text of the attachment '{FilePath}'. Sending reads the file instead.", attachment.FilePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives an attachment of the message being sent the text its preview kept, as long as its file did not change since.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A file which changed, or which is gone, is read when the message is sent, as it would be
|
||||
/// without a preview. An attachment of a message sent before has its text already.
|
||||
/// </remarks>
|
||||
/// <param name="attachment">The attachment, normalized.</param>
|
||||
/// <param name="draft">The draft being sent, which notes the texts its previews read.</param>
|
||||
/// <returns>The attachment with the entry of its text, or the attachment as it was.</returns>
|
||||
private static FileAttachment WithPreviewedText(FileAttachment attachment, ChatDraft draft)
|
||||
{
|
||||
if (attachment.ProvenanceId is not null || draft.GetPreviewedText(attachment.FilePath) is not { } previewed)
|
||||
return attachment;
|
||||
|
||||
return FileVersion.Of(attachment.FilePath) == previewed.Version
|
||||
? attachment with { ProvenanceId = previewed.ProvenanceId, Version = previewed.Version }
|
||||
: attachment;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the texts the previews read for a message which was just sent from the provenance store, when the message went without them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The message went without the text of an attachment which the user removed, and without that
|
||||
/// of a file which changed since its preview or is gone.
|
||||
/// </remarks>
|
||||
/// <param name="chatId">The chat the message went to.</param>
|
||||
/// <param name="previewedTexts">The texts the draft of the message noted.</param>
|
||||
/// <param name="sentAttachments">The attachments of the message.</param>
|
||||
private Task ForgetUnsentPreviewsAsync(Guid chatId, IEnumerable<PreviewedText> previewedTexts, IEnumerable<FileAttachment> sentAttachments)
|
||||
{
|
||||
var sentIds = sentAttachments.Select(attachment => attachment.ProvenanceId).OfType<Guid>().ToHashSet();
|
||||
return this.ForgetPreviewedTextsAsync(chatId, previewedTexts.Select(text => text.ProvenanceId).Where(id => !sentIds.Contains(id)).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes texts the preview kept from the provenance store, since no message will be sent with them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Should that fail, the next start removes them, since the chat does not refer to them.
|
||||
/// </remarks>
|
||||
/// <param name="chatId">The chat the texts were kept for.</param>
|
||||
/// <param name="provenanceIds">The entries of the texts.</param>
|
||||
private async Task ForgetPreviewedTextsAsync(Guid chatId, IReadOnlyCollection<Guid> provenanceIds)
|
||||
{
|
||||
if (provenanceIds.Count is 0)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var store = await this.DatabaseClientProvider.GetProvenanceStoreAsync();
|
||||
await store.DeleteEntriesAsync(chatId, provenanceIds, CancellationToken.None);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this.Logger.LogWarning(exception, "Failed to remove {Count} previewed texts which no message of the chat '{ChatId}' was sent with. The next start removes them.", provenanceIds.Count, chatId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and stores the chat the composer belongs to, when there is none yet, e.g., for what an
|
||||
/// attachment brings along before the first message is sent.
|
||||
/// </summary>
|
||||
/// <param name="nameWithoutInput">The name of the chat while the user typed nothing yet.</param>
|
||||
/// <returns>The chat of the composer.</returns>
|
||||
private async Task<ChatThread?> EnsureChatAsync(string nameWithoutInput)
|
||||
{
|
||||
if (this.ChatThread is not null)
|
||||
return this.ChatThread;
|
||||
@@ -1436,7 +1555,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
this.RefreshCurrentProfileAndChatTemplate();
|
||||
var promptName = this.ExtractThreadName(this.ComposerState.UserInput);
|
||||
var threadName = string.IsNullOrWhiteSpace(this.ComposerState.UserInput)
|
||||
? string.Format(T("Transcription: {0}"), Path.GetFileName(firstMediaPath))
|
||||
? nameWithoutInput
|
||||
: promptName;
|
||||
|
||||
var chat = this.NewChatThread(threadName, await this.ResolveWorkspaceOfFirstThreadAsync());
|
||||
@@ -1516,9 +1635,11 @@ public partial class ChatComponent : MSGComponentBase
|
||||
IContent? lastUserPrompt;
|
||||
if (!reuseLastUserPrompt)
|
||||
{
|
||||
var draft = this.ChatThread.Draft;
|
||||
var normalizedAttachments = this.ComposerState.FileAttachments
|
||||
.Select(attachment => attachment.Normalize())
|
||||
.Where(attachment => attachment.IsValid)
|
||||
.Select(attachment => WithPreviewedText(attachment, draft))
|
||||
.ToList();
|
||||
|
||||
lastUserPrompt = new ContentText
|
||||
@@ -1526,7 +1647,10 @@ public partial class ChatComponent : MSGComponentBase
|
||||
Text = this.ComposerState.UserInput,
|
||||
FileAttachments = normalizedAttachments,
|
||||
};
|
||||
this.ChatThread.Draft.Clear();
|
||||
|
||||
var previewedTexts = draft.PreviewedTexts;
|
||||
draft.Clear();
|
||||
await this.ForgetUnsentPreviewsAsync(this.ChatThread.ChatId, previewedTexts, normalizedAttachments);
|
||||
|
||||
//
|
||||
// Add the user message to the thread:
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
@* One element around everything, so that a layout around the viewer can give it its place. *@
|
||||
<div class="content-viewer">
|
||||
@if (this.cutOffCharacters > 0 && this.CutOffNotice is not null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="my-2">
|
||||
@this.CutOffNotice(this.cutOffCharacters)
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudPaper Outlined="@true" Class="content-viewer-frame">
|
||||
<div class="content-viewer-toolbar">
|
||||
<div class="content-viewer-toolbar-start">
|
||||
@this.Toolbar
|
||||
</div>
|
||||
|
||||
@if (this.views.Count > 1)
|
||||
{
|
||||
<MudToggleGroup T="ContentView" @bind-Value="@this.view" SelectionMode="SelectionMode.SingleSelection" Size="Size.Small" Color="Color.Primary">
|
||||
@foreach (var offeredView in this.views)
|
||||
{
|
||||
<MudToggleItem Value="@offeredView" Text="@this.GetViewName(offeredView)"/>
|
||||
}
|
||||
</MudToggleGroup>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* MathJax leaves this alone, even when something else on the page has it typeset everything:
|
||||
it would replace elements Blazor renders here, and the next render of them would fail. *@
|
||||
<div class="content-viewer-body mathjax_ignore" style="@($"max-height: {this.MaxHeight};")">
|
||||
@switch (this.view)
|
||||
{
|
||||
case ContentView.MEDIA:
|
||||
<MudImage ObjectFit="ObjectFit.ScaleDown" Style="width: 100%;" Src="@this.MediaUrl"/>
|
||||
break;
|
||||
|
||||
case ContentView.FORMATTED:
|
||||
<MudMarkdown Value="@this.shownText" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.CONTENT_MARKDOWN_PIPELINE"/>
|
||||
break;
|
||||
|
||||
default:
|
||||
<pre class="content-viewer-plain">@this.shownText</pre>
|
||||
break;
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(this.HelperText))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mt-1 px-1 mud-text-secondary">
|
||||
@this.HelperText
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,185 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Shows a piece of content for the user to check: text formatted and as plain text, and an image
|
||||
/// as itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every place which lets the user see what AI Studio reads or hands to the AI shows it the same
|
||||
/// way, which is why this lives here instead of in each of them. A slim bar above the content says
|
||||
/// what it is, and switches between the ways to see it; the caller fills its start.<br/><br/>
|
||||
/// The content scrolls inside its frame, so a dialog around it does not have to scroll as well. In a
|
||||
/// dialog which fits the window, the frame gives way down to the room left over.<br/><br/>
|
||||
/// The text is shown in full unless the caller sets a limit. Whether the beginning of a text is
|
||||
/// enough depends on what the user wants to check: how a whole document was read, or exactly what
|
||||
/// the AI received. Only the caller knows which, and how to explain a cut.
|
||||
/// </remarks>
|
||||
public partial class ContentViewer : MSGComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The ways to see the content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Internal rather than private: Razor infers the type of the toggle items in a class of its own.
|
||||
/// </remarks>
|
||||
internal enum ContentView
|
||||
{
|
||||
MEDIA,
|
||||
FORMATTED,
|
||||
PLAIN,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What kind of content this is.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public ContentViewerKind Kind { get; set; } = ContentViewerKind.TEXT;
|
||||
|
||||
/// <summary>
|
||||
/// The text to show. For an image, the text which belongs to it, or nothing.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The address an image is loaded from. Not used for text.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string MediaUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// What the bar above the content shows at its start, e.g., what the content is, where it comes
|
||||
/// from, and a button to open it there.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? Toolbar { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The hint below the text, or nothing.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string HelperText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// How many characters of the text we show at most, or null to show all of it.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int? CharacterLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Explains that the text was cut, given the number of characters left out. Null shows no explanation.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Func<int, string>? CutOffNotice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The height which does not limit the content: it may take all the room its place leaves over,
|
||||
/// e.g., in a dialog which fits the window, see dialog-content-fit in app.css.
|
||||
/// </summary>
|
||||
public const string NO_HEIGHT_LIMIT = "none";
|
||||
|
||||
/// <summary>
|
||||
/// How high the content may grow before it scrolls, unless its place says otherwise.
|
||||
/// </summary>
|
||||
public const string DEFAULT_MAX_HEIGHT = "40vh";
|
||||
|
||||
/// <summary>
|
||||
/// How high the content may grow before it scrolls, as a CSS length.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string MaxHeight { get; set; } = DEFAULT_MAX_HEIGHT;
|
||||
|
||||
/// <summary>
|
||||
/// The ways to see this content, in the order they are offered; the first one is shown first.
|
||||
/// </summary>
|
||||
private readonly List<ContentView> views = [];
|
||||
|
||||
/// <summary>
|
||||
/// The way the content is shown right now.
|
||||
/// </summary>
|
||||
private ContentView view;
|
||||
|
||||
/// <summary>
|
||||
/// The text and the limit the current cut was made of, so that we cut once per text instead of once per render.
|
||||
/// </summary>
|
||||
private string? cutText;
|
||||
|
||||
private int? cutLimit;
|
||||
|
||||
/// <summary>
|
||||
/// What we show of the text: all of it, or its beginning.
|
||||
/// </summary>
|
||||
private string shownText = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// How many characters we left out. Zero when we show the entire text.
|
||||
/// </summary>
|
||||
private int cutOffCharacters;
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ReferenceEquals(this.cutText, this.Text) || this.cutLimit != this.CharacterLimit)
|
||||
{
|
||||
this.cutText = this.Text;
|
||||
this.cutLimit = this.CharacterLimit;
|
||||
this.shownText = CutAtLineBreak(this.Text, this.CharacterLimit ?? int.MaxValue, out this.cutOffCharacters);
|
||||
}
|
||||
|
||||
//
|
||||
// An image is seen as itself first. Text is seen formatted and as plain text, for text and
|
||||
// for an image which brings some along. The way the user chose stays as long as it is offered:
|
||||
//
|
||||
this.views.Clear();
|
||||
if (this.Kind is not ContentViewerKind.TEXT)
|
||||
this.views.Add(ContentView.MEDIA);
|
||||
|
||||
if (this.Kind is ContentViewerKind.TEXT || !string.IsNullOrWhiteSpace(this.Text))
|
||||
this.views.AddRange([ContentView.FORMATTED, ContentView.PLAIN]);
|
||||
|
||||
if (!this.views.Contains(this.view))
|
||||
this.view = this.views[0];
|
||||
|
||||
base.OnParametersSet();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private string GetViewName(ContentView contentView) => contentView switch
|
||||
{
|
||||
ContentView.MEDIA => T("Image"),
|
||||
ContentView.FORMATTED => T("Formatted"),
|
||||
_ => T("Plain text"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Takes the beginning of a text which is too long to show.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// We cut at the last line break before the limit. Otherwise, we might tear apart a Markdown
|
||||
/// construct like a table row or a code fence in the middle of a line. A text without any line
|
||||
/// break before the limit is cut at the limit itself.
|
||||
/// </remarks>
|
||||
/// <param name="text">The text.</param>
|
||||
/// <param name="characterLimit">How many characters to keep at most.</param>
|
||||
/// <param name="cutOffCharacters">How many characters were left out; zero when nothing was.</param>
|
||||
/// <returns>The text itself when it fits, otherwise its beginning.</returns>
|
||||
internal static string CutAtLineBreak(string text, int characterLimit, out int cutOffCharacters)
|
||||
{
|
||||
if (text.Length <= characterLimit)
|
||||
{
|
||||
cutOffCharacters = 0;
|
||||
return text;
|
||||
}
|
||||
|
||||
var cutIndex = text.LastIndexOf('\n', characterLimit - 1) + 1;
|
||||
if (cutIndex < 1)
|
||||
cutIndex = characterLimit;
|
||||
|
||||
cutOffCharacters = text.Length - cutIndex;
|
||||
return text[..cutIndex];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// What kind of content a ContentViewer shows.
|
||||
/// </summary>
|
||||
public enum ContentViewerKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Text, shown as rendered Markdown and as plain text.
|
||||
/// </summary>
|
||||
TEXT,
|
||||
|
||||
/// <summary>
|
||||
/// An image, shown from its address.
|
||||
/// </summary>
|
||||
IMAGE,
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
<MudText Typo="Typo.body2">
|
||||
@string.Format(T("Backed up {0} files into: {1}"), backupResult.FileCount, backupResult.ArchivePath)
|
||||
</MudText>
|
||||
<FileExportActions FilePath="@backupResult.ArchivePath" OffersOpening="@false"/>
|
||||
<FileActions Path="@backupResult.ArchivePath" ShowCopy="@false" ShowOpen="@false"/>
|
||||
</MudStack>
|
||||
@if (!backupResult.IncludesDataSourceIndexes)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
||||
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Tools.ChatArchive;
|
||||
using AIStudio.Tools.Databases;
|
||||
using AIStudio.Tools.DataBackup;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
@@ -43,6 +44,9 @@ public partial class DataBackupPanel : MSGComponentBase
|
||||
[Inject]
|
||||
private DataRestoreService DataRestoreService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private DatabaseClientProvider DatabaseClientProvider { get; init; } = null!;
|
||||
|
||||
private ChatArchiveCollisionBehavior collisionBehavior = ChatArchiveCollisionBehavior.SKIP;
|
||||
private CancellationTokenSource? importCancellation;
|
||||
private ChatArchiveProgress importProgress;
|
||||
@@ -253,7 +257,8 @@ public partial class DataBackupPanel : MSGComponentBase
|
||||
var archivePath = this.selectedArchivePath;
|
||||
var token = this.importCancellation.Token;
|
||||
var reporter = this.CreateImportProgressReporter();
|
||||
this.importResult = await Task.Run(() => ChatArchiveImporter.ImportAsync(archivePath, this.collisionBehavior, reporter, token), token);
|
||||
var provenanceStore = await this.DatabaseClientProvider.GetProvenanceStoreAsync(token);
|
||||
this.importResult = await Task.Run(() => ChatArchiveImporter.ImportAsync(archivePath, provenanceStore, this.collisionBehavior, reporter, token), token);
|
||||
|
||||
//
|
||||
// Drop the preview once every chat was added. Otherwise, another click on the button
|
||||
|
||||
@@ -78,6 +78,7 @@ public partial class DataFolders : MSGComponentBase
|
||||
DataBackupEntryKind.SETTINGS => T("Your settings."),
|
||||
DataBackupEntryKind.WORKSPACES => T("Your chats in workspaces, along with their transcripts."),
|
||||
DataBackupEntryKind.TEMPORARY_CHATS => T("Your temporary chats, along with their transcripts."),
|
||||
DataBackupEntryKind.CHAT_PROVENANCE => T("What the AI received in your chats besides your messages, such as the content of attached files, the results of tools, and passages of your data sources. It is part of your chats."),
|
||||
DataBackupEntryKind.PLUGINS => T("Your plugins, such as the assistants you built, apart from the folders listed below."),
|
||||
DataBackupEntryKind.TOKENIZERS => T("The tokenizers you chose for your providers."),
|
||||
DataBackupEntryKind.VISUAL_BRIEFINGS => T("Your projects in the Visual Briefing Assistant."),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
@*
|
||||
The tooltips of the buttons which open something show on hover only: the focus comes back to the
|
||||
button from the program, the browser, or the file manager it opened, and a tooltip shown on focus
|
||||
would then stay until the user clicks somewhere else.
|
||||
*@
|
||||
@switch (this.kind)
|
||||
{
|
||||
case FileActionsKind.WEB_PAGE:
|
||||
@if (this.ShowCopy)
|
||||
{
|
||||
<MudTooltip Text="@string.Format(T("Copy the address: {0}"), this.Path)" Placement="@this.TooltipPlacement">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ContentCopy" Size="Size.Small" Class="icon-button-compact" OnClick="@this.CopyPath"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
@if (this.ShowOpen)
|
||||
{
|
||||
<MudTooltip Text="@T("Open this web page in your browser")" Placement="@this.TooltipPlacement" ShowOnFocus="@false">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.OpenInNew" Size="Size.Small" Class="icon-button-compact" Href="@this.Path" Target="_blank"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
break;
|
||||
|
||||
case FileActionsKind.FILE:
|
||||
@if (this.ShowCopy)
|
||||
{
|
||||
<MudTooltip Text="@string.Format(T("Copy the path: {0}"), this.Path)" Placement="@this.TooltipPlacement">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ContentCopy" Size="Size.Small" Class="icon-button-compact" OnClick="@this.CopyPath"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
@if (this.isMissing)
|
||||
{
|
||||
@* Instead of buttons which would only fail, and only where there would have been one: *@
|
||||
@if (this.WarnIfMissing && (this.ShowFileManager || this.ShowOpen))
|
||||
{
|
||||
@* Not necessarily gone: the server of an ERI data source may name a file of its own, which was never on this computer. *@
|
||||
<MudTooltip Text="@string.Format(T("AI Studio cannot find the file {0} on this computer. It may have been moved, renamed, or deleted, or it is on another computer, such as the server of a data source."), this.Path)" Placement="@this.TooltipPlacement">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.WarningAmber" Color="Color.Warning" Size="Size.Small" Class="file-action-warning"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (this.ShowFileManager)
|
||||
{
|
||||
<MudTooltip Text="@T("Show this file in the file manager of your system")" Placement="@this.TooltipPlacement" ShowOnFocus="@false">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.FolderOpen" Size="Size.Small" Class="icon-button-compact" OnClick="@this.ShowInFileManager"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
@if (this.ShowOpen)
|
||||
{
|
||||
<MudTooltip Text="@GetOpenTooltip(this.PageNumber)" Placement="@this.TooltipPlacement" ShowOnFocus="@false">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.OpenInNew" Size="Size.Small" Class="icon-button-compact" OnClick="@this.Open"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// What the user can do with a file or a web page which a source, a passage, or an attachment
|
||||
/// names: copy its path, find it in the file manager, open it, at a page where there is one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every place which names a file offers the same buttons the same way, which is why they live
|
||||
/// here. Each place decides which of them it shows, e.g., a title copies and finds, while the bar
|
||||
/// above a text opens.<br/><br/>
|
||||
///
|
||||
/// A file which is no longer at its place gets a warning instead of the buttons which would only
|
||||
/// fail; copying its path stays, since it tells where the file was. Whether it is there is asked
|
||||
/// off the UI thread and once per path: on an unreachable network share, the answer takes until
|
||||
/// the timeout of the share, and a list of sources renders anew with every streamed chunk.<br/><br/>
|
||||
///
|
||||
/// A web page is copied and opened in the browser. Anything else, such as a mail, which only
|
||||
/// AI Studio can find by its address, or a relative path, gets no buttons at all.<br/><br/>
|
||||
///
|
||||
/// Every place which opens a file names it the same way, see GetOpenTooltip, also where a title
|
||||
/// opens it instead of a button.
|
||||
/// </remarks>
|
||||
public partial class FileActions : MSGComponentBase
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(FileActions).Namespace, nameof(FileActions));
|
||||
|
||||
/// <summary>
|
||||
/// What a path names.
|
||||
/// </summary>
|
||||
internal enum FileActionsKind
|
||||
{
|
||||
NONE,
|
||||
FILE,
|
||||
WEB_PAGE,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The path of the file, or the address of the web page.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The page to open a file at, or null to open it at its beginning.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int? PageNumber { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool ShowCopy { get; set; } = true;
|
||||
|
||||
[Parameter]
|
||||
public bool ShowFileManager { get; set; } = true;
|
||||
|
||||
[Parameter]
|
||||
public bool ShowOpen { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a file which is no longer at its place gets a warning. False where the caller says so
|
||||
/// itself, or where another place on the screen warns already.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool WarnIfMissing { get; set; } = true;
|
||||
|
||||
[Parameter]
|
||||
public Placement TooltipPlacement { get; set; } = Placement.Top;
|
||||
|
||||
/// <summary>
|
||||
/// Tells the caller whether the file is at its place, once that is known, e.g., so that a title
|
||||
/// which opens it stops being a link.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<bool> OnExistenceChecked { get; set; }
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
private FileActionsKind kind;
|
||||
|
||||
/// <summary>
|
||||
/// The path whose existence was asked last, so that a render with the same path does not ask again.
|
||||
/// </summary>
|
||||
private string? checkedPath;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the file is known to be gone. Until the answer arrives, the buttons show: most files are there.
|
||||
/// </summary>
|
||||
private bool isMissing;
|
||||
|
||||
/// <summary>
|
||||
/// The tooltip of opening a file, at its page where there is one.
|
||||
/// </summary>
|
||||
/// <param name="pageNumber">The page the file opens at, or null when it opens at its beginning.</param>
|
||||
/// <returns>The tooltip.</returns>
|
||||
internal static string GetOpenTooltip(int? pageNumber) => pageNumber is { } page
|
||||
? string.Format(TB("Open the file at page {0}"), page)
|
||||
: TB("Open the file in the program your system uses for it");
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
this.kind = DetermineKind(this.Path);
|
||||
if (this.kind is FileActionsKind.FILE && !string.Equals(this.checkedPath, this.Path, StringComparison.Ordinal))
|
||||
{
|
||||
var path = this.Path;
|
||||
this.checkedPath = path;
|
||||
this.isMissing = false;
|
||||
|
||||
var exists = await Task.Run(() => File.Exists(path));
|
||||
|
||||
// Another path took over meanwhile, which asks for itself:
|
||||
if (!string.Equals(this.checkedPath, path, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
this.isMissing = !exists;
|
||||
await this.OnExistenceChecked.InvokeAsync(exists);
|
||||
}
|
||||
|
||||
await base.OnParametersSetAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Tells a web page from a file on this computer by its path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only an absolute path names a file: a relative one would name another file depending on
|
||||
/// where it is opened from.
|
||||
/// </remarks>
|
||||
internal static FileActionsKind DetermineKind(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return FileActionsKind.NONE;
|
||||
|
||||
if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https")
|
||||
return FileActionsKind.WEB_PAGE;
|
||||
|
||||
return System.IO.Path.IsPathRooted(path) && (uri is null || uri.IsFile)
|
||||
? FileActionsKind.FILE
|
||||
: FileActionsKind.NONE;
|
||||
}
|
||||
|
||||
private async Task CopyPath() => await this.RustService.CopyText2Clipboard(this.Path);
|
||||
|
||||
private async Task ShowInFileManager() => await new SourceDocumentLocation(this.Path, null).ShowInFileManagerAsync(this.RustService);
|
||||
|
||||
private async Task Open() => await new SourceDocumentLocation(this.Path, this.PageNumber).OpenAsync(this.RustService);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
@* The name takes the free width, so the buttons stand right. *@
|
||||
<div class="file-dialog-title">
|
||||
<MudText Typo="Typo.h6" Class="flex-grow-1">
|
||||
@this.Title
|
||||
</MudText>
|
||||
|
||||
@* Opening a file belongs to the bar above its content; a web page has no such bar, so it opens from here: *@
|
||||
<FileActions Path="@this.Path" ShowOpen="@this.OpensWebPages" WarnIfMissing="@this.WarnIfMissing" TooltipPlacement="Placement.Bottom"/>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// The title of a dialog about one file or web page: its name, and at the side, ways to copy its
|
||||
/// path and to find it in the file manager, see FileActions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The path is what the copy button copies, so that is where it shows, in the tooltip of that
|
||||
/// button, instead of taking a line of its own. Every dialog about a file names it the same way,
|
||||
/// which is why this lives here instead of in each of them. A file which is no longer at its place
|
||||
/// gets a warning instead of the button of the file manager.
|
||||
/// </remarks>
|
||||
public partial class FileDialogTitle : MSGComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The title, usually the name of the file.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The path of the file or the address of the web page, or nothing when there is neither to name.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a file which is no longer at its place gets a warning, see FileActions.WarnIfMissing.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool WarnIfMissing { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the path is the address of a web page, which this title then opens in the browser.
|
||||
/// </summary>
|
||||
private bool OpensWebPages => FileActions.DetermineKind(this.Path) is FileActions.FileActionsKind.WEB_PAGE;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="0">
|
||||
<MudTooltip Text="@T("Show in the file manager")" Placement="Placement.Top">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.FolderOpen" Size="Size.Small" OnClick="@this.ShowInFileManager"/>
|
||||
</MudTooltip>
|
||||
@if (this.OffersOpening)
|
||||
{
|
||||
<MudTooltip Text="@T("Open with the default program")" Placement="Placement.Top">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.OpenInNew" Size="Size.Small" OnClick="@this.OpenWithDefaultProgram"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
@@ -1,73 +0,0 @@
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// The ways from an exported file to the file itself: the file manager, which shows it, and the default
|
||||
/// program, which opens it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Whoever chose where a file goes wants to get to it afterward, and a message which merely says that
|
||||
/// the export succeeded leaves them searching. Both ways go through the runtime, like those of the list
|
||||
/// of sources: under Linux, the file manager is reached through the desktop portal, and the runtime
|
||||
/// refuses to start programs and launchers.
|
||||
/// </remarks>
|
||||
public partial class FileExportActions : MSGComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The exported file.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the file may be opened with the default program. False for an archive, which that
|
||||
/// program would only unpack, at least on macOS.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool OffersOpening { get; set; }
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<FileExportActions> Logger { get; init; } = null!;
|
||||
|
||||
private async Task ShowInFileManager()
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await this.RustService.TryOpenPathInRuntimeFileManager(this.FilePath);
|
||||
if (response.Success)
|
||||
return;
|
||||
|
||||
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, string.Format(T("Could not open the file location: {0}"), issue)));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.Logger.LogWarning(e, "Could not show an exported file in the file manager.");
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, T("Could not open the file location.")));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenWithDefaultProgram()
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await this.RustService.TryOpenDocumentInSystemViewer(this.FilePath, null);
|
||||
if (response.Success)
|
||||
return;
|
||||
|
||||
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.OpenInNew, string.Format(T("Could not open the file: {0}"), issue)));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.Logger.LogWarning(e, "Could not open an exported file with the default program.");
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.OpenInNew, T("Could not open the file.")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,5 @@
|
||||
<div>
|
||||
@this.Message
|
||||
</div>
|
||||
<FileExportActions FilePath="@this.FilePath" OffersOpening="@this.OffersOpening"/>
|
||||
<FileActions Path="@this.FilePath" ShowCopy="@false" ShowOpen="@this.OffersOpening"/>
|
||||
</MudStack>
|
||||
@@ -23,6 +23,19 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IAsyncDispo
|
||||
|
||||
private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage;
|
||||
|
||||
/// <summary>
|
||||
/// How Markdown rendered by this component looks: its code blocks follow the color theme of the app.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every component which renders Markdown wants the same, so it is written down once here instead
|
||||
/// of in each of them. Read on every render: a change of the color theme renders every component
|
||||
/// again, cf. ProcessMessage, and the code blocks change along with it.
|
||||
/// </remarks>
|
||||
protected MudMarkdownStyling MarkdownStyling => new()
|
||||
{
|
||||
CodeBlock = { Theme = this.SettingsManager.IsDarkMode ? CodeBlockTheme.Dark : CodeBlockTheme.Default },
|
||||
};
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
@using AIStudio.Tools.RAG
|
||||
@inherits MSGComponentBase
|
||||
|
||||
@if (this.Passage.Category is RetrievalContentCategory.IMAGE)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
|
||||
@T("An image matched here. The AI was told about it and where to find it, but it did not receive the image itself.")
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (this.isStoreUnavailable)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="mb-2">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap">
|
||||
<MudText>@T("At the moment, AI Studio cannot read the text of this passage that the AI received, e.g., while AI Studio is still starting.")</MudText>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Warning" StartIcon="@Icons.Material.Filled.Refresh" OnClick="@this.ReadTextAgainAsync">
|
||||
@T("Try again")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudAlert>
|
||||
}
|
||||
else if (this.isTextMissing)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-2">
|
||||
@T("AI Studio no longer has the text of this passage that the AI received. Only where it was found is still known.")
|
||||
</MudAlert>
|
||||
}
|
||||
else if (this.isReadingText)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="@true" Color="Color.Primary" Size="Size.Small" Class="mb-2"/>
|
||||
}
|
||||
|
||||
<ContentViewer Text="@this.text" MaxHeight="@this.MaxHeight">
|
||||
<Toolbar>
|
||||
@* The heading takes the free width, so the button stands right, next to the views. The
|
||||
title of the dialog warns about a file which is no longer at its place. *@
|
||||
<MudText Typo="Typo.subtitle2" Class="flex-grow-1">
|
||||
@this.heading
|
||||
</MudText>
|
||||
|
||||
<FileActions Path="@this.openPath" PageNumber="@this.openPage" ShowCopy="@false" ShowFileManager="@false" WarnIfMissing="@false"/>
|
||||
</Toolbar>
|
||||
</ContentViewer>
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using AIStudio.Tools.Databases;
|
||||
using AIStudio.Tools.Databases.ProvenanceStore;
|
||||
using AIStudio.Tools.RAG;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Shows one passage the AI received from a data source, in full and the way the AI read it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The content is the filtered one, see SourcePassage. The bar above it says where in the document
|
||||
/// the passage stands, and whether it is the transcript of a recording, since the AI then read the
|
||||
/// words of the recording rather than the recording itself. An image the AI was only told about is
|
||||
/// said to be one, rather than shown as if the AI had seen it.<br/><br/>
|
||||
///
|
||||
/// The texts of a passage lie in the provenance store once its answer is complete, and are read
|
||||
/// from there. Should the store no longer hold them, the view says so instead of showing nothing.
|
||||
/// A store which cannot be read, e.g., while it is still starting, is no proof of that: the view
|
||||
/// then offers to read the texts again.
|
||||
/// </remarks>
|
||||
public partial class SourcePassageView : MSGComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// What stands between the parts of the text, as Markdown: a horizontal rule.
|
||||
/// </summary>
|
||||
private const string PART_SEPARATOR = "\n\n---\n\n";
|
||||
|
||||
/// <summary>
|
||||
/// The passage to show.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SourcePassage Passage { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// The document the passage is from, which can be opened at its page, or null when there is no
|
||||
/// document to open.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SourceDocumentLocation? Document { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How high the text may grow before it scrolls, as a CSS length.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string MaxHeight { get; set; } = ContentViewer.DEFAULT_MAX_HEIGHT;
|
||||
|
||||
[Inject]
|
||||
private DatabaseClientProvider DatabaseClientProvider { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<SourcePassageView> Logger { get; init; } = null!;
|
||||
|
||||
private string heading = string.Empty;
|
||||
|
||||
private string text = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// What the bar above the text opens: the document, or the web page of the passage; empty when there is neither.
|
||||
/// </summary>
|
||||
private string openPath = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The page of the passage, which a document opens at, or null.
|
||||
/// </summary>
|
||||
private int? openPage;
|
||||
|
||||
/// <summary>
|
||||
/// The passage whose texts are shown or being read, so that a render with the same passage does not read them again.
|
||||
/// </summary>
|
||||
private SourcePassage? shownPassage;
|
||||
|
||||
/// <summary>
|
||||
/// Numbers the reads, so that a read which another passage overtook leaves the texts alone.
|
||||
/// </summary>
|
||||
private int readNumber;
|
||||
|
||||
private bool isReadingText;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the provenance store no longer holds the texts of the passage.
|
||||
/// </summary>
|
||||
private bool isTextMissing;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the provenance store could not be read, so that it is unknown whether it holds the texts of the passage.
|
||||
/// </summary>
|
||||
private bool isStoreUnavailable;
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var parts = new[] { this.Passage.GetLocationLabel(), this.GetTranscriptLabel() }.Where(part => part.Length > 0).ToList();
|
||||
this.heading = parts.Count > 0 ? string.Join(" · ", parts) : T("Text the AI received");
|
||||
|
||||
//
|
||||
// A document opens at the page of the passage. One without pages, such as a CSV file, opens
|
||||
// at its beginning: no program can be sent to a part, but the user may still want to look.
|
||||
// Without a document, the passage of a web page opens its page; any other passage, such as
|
||||
// that of a mail, has nothing to open, see FileActions:
|
||||
//
|
||||
var page = this.Passage.PageNumber is > 0 ? this.Passage.PageNumber : null;
|
||||
this.openPath = this.Document?.Path ?? this.Passage.Path;
|
||||
this.openPage = this.Document is null ? null : page;
|
||||
|
||||
if (!ReferenceEquals(this.shownPassage, this.Passage))
|
||||
{
|
||||
this.shownPassage = this.Passage;
|
||||
await this.ReadTextAsync(this.Passage);
|
||||
}
|
||||
|
||||
await base.OnParametersSetAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Reads the texts of a passage: from the provenance store once they lie there, otherwise from the passage itself.
|
||||
/// </summary>
|
||||
/// <param name="passage">The passage.</param>
|
||||
private async Task ReadTextAsync(SourcePassage passage)
|
||||
{
|
||||
var currentRead = ++this.readNumber;
|
||||
this.isTextMissing = false;
|
||||
this.isStoreUnavailable = false;
|
||||
if (passage.ProvenanceId is not { } provenanceId)
|
||||
{
|
||||
this.isReadingText = false;
|
||||
this.text = this.ComposeText(passage.Text, passage.SurroundingContent);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isReadingText = true;
|
||||
this.text = string.Empty;
|
||||
|
||||
ProvenanceEntry? entry = null;
|
||||
bool storeCannotBeRead;
|
||||
try
|
||||
{
|
||||
// A store which is not available answers every read with nothing, which would read as a deleted text:
|
||||
var store = await this.DatabaseClientProvider.GetProvenanceStoreAsync();
|
||||
storeCannotBeRead = !store.IsAvailable;
|
||||
if (!storeCannotBeRead)
|
||||
entry = (await store.GetEntriesAsync([provenanceId], CancellationToken.None)).GetValueOrDefault(provenanceId);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this.Logger.LogError(exception, "Failed to read the passage '{ProvenanceId}' from the provenance store.", provenanceId);
|
||||
storeCannotBeRead = true;
|
||||
}
|
||||
|
||||
// The user went on to another passage meanwhile, which shows its own texts:
|
||||
if (currentRead != this.readNumber)
|
||||
return;
|
||||
|
||||
this.isReadingText = false;
|
||||
this.isStoreUnavailable = storeCannotBeRead;
|
||||
this.isTextMissing = !storeCannotBeRead && entry is null;
|
||||
this.text = entry is null ? string.Empty : this.ComposeText(entry.Content, entry.SurroundingContent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the texts of the shown passage once more, after the provenance store could not be read.
|
||||
/// </summary>
|
||||
private Task ReadTextAgainAsync() => this.ReadTextAsync(this.Passage);
|
||||
|
||||
/// <summary>
|
||||
/// Writes the texts of a passage as one text.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The surrounding content follows the matched text, the way the AI received both, so that all
|
||||
/// of it reads in one place.
|
||||
/// </remarks>
|
||||
private string ComposeText(string matchedText, IReadOnlyList<string> surroundingContent) => surroundingContent.Count is 0
|
||||
? matchedText
|
||||
: $"{matchedText}{PART_SEPARATOR}**{T("Surrounding content")}**{PART_SEPARATOR}{string.Join(PART_SEPARATOR, surroundingContent)}";
|
||||
|
||||
/// <summary>
|
||||
/// Says what the passage is a transcript of, or nothing when it is no transcript.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Asked by the extension of the path only: the file may be gone by now, and reading it would
|
||||
/// tell nothing more about what the AI received.
|
||||
/// </remarks>
|
||||
private string GetTranscriptLabel()
|
||||
{
|
||||
if (!this.Passage.IsTranscript())
|
||||
return string.Empty;
|
||||
|
||||
var extension = Path.GetExtension(this.Passage.Path);
|
||||
if (FileTypes.IsAllowedExtension(extension, FileTypes.VIDEO))
|
||||
return T("Transcript of a video file");
|
||||
|
||||
return FileTypes.IsAllowedExtension(extension, FileTypes.AUDIO)
|
||||
? T("Transcript of an audio file")
|
||||
: T("Transcript");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@using AIStudio.Tools.RAG
|
||||
@inherits MSGComponentBase
|
||||
|
||||
@* The class is what the Markdown renderer wraps its own output in, so the headings and the list
|
||||
@@ -13,32 +14,85 @@
|
||||
<ul>
|
||||
@foreach (var entry in group.Entries)
|
||||
{
|
||||
<li>
|
||||
@($"[{entry.Number}] ")
|
||||
@if (entry.Document is { } document)
|
||||
{
|
||||
<MudTooltip Text="@T("Opens this document in the program your system uses for it")" Placement="Placement.Top">
|
||||
<MudLink Typo="Typo.body1" OnClick="@(() => this.OpenDocument(document))">
|
||||
<li class="source-entry">
|
||||
<div class="source-entry-title">
|
||||
@($"[{entry.Number}] ")
|
||||
@if (this.IsMissing(entry))
|
||||
{
|
||||
@* A link would only fail. The warning next to the entry says why: *@
|
||||
<MudText Typo="Typo.body1" Inline="true">
|
||||
@entry.Title
|
||||
</MudText>
|
||||
}
|
||||
else if (entry.Document is { } pagedDocument && entry.Location is { } location)
|
||||
{
|
||||
@* Each page the AI read is a link of its own, which opens the document there. The
|
||||
name would only open it at one of them, so it is no link. *@
|
||||
<MudText Typo="Typo.body1" Inline="true">
|
||||
@($"{entry.Name} ({location.Prefix}")
|
||||
</MudText>
|
||||
@for (var rangeIndex = 0; rangeIndex < location.Ranges.Count; rangeIndex++)
|
||||
{
|
||||
var pageRange = location.Ranges[rangeIndex];
|
||||
if (rangeIndex > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Inline="true">
|
||||
@PassageLocation.RANGE_SEPARATOR
|
||||
</MudText>
|
||||
}
|
||||
|
||||
<MudTooltip Text="@FileActions.GetOpenTooltip(pageRange.First)" Placement="Placement.Top">
|
||||
<MudLink Typo="Typo.body1" OnClick="@(() => this.OpenDocument(pagedDocument with { PageNumber = pageRange.First }))">
|
||||
@pageRange
|
||||
</MudLink>
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudText Typo="Typo.body1" Inline="true">
|
||||
@($"{location.Suffix})")
|
||||
</MudText>
|
||||
}
|
||||
else if (entry.Document is { } document)
|
||||
{
|
||||
@* Inline rather than the usual inline block: a long title, e.g., one naming many
|
||||
parts, then wraps like text right after its number instead of moving below it. *@
|
||||
<MudTooltip Text="@FileActions.GetOpenTooltip(null)" Placement="Placement.Top" RootClass="d-inline">
|
||||
<MudLink Typo="Typo.body1" OnClick="@(() => this.OpenDocument(document))">
|
||||
@entry.Title
|
||||
</MudLink>
|
||||
</MudTooltip>
|
||||
}
|
||||
else if (entry.HasNoLink)
|
||||
{
|
||||
@* Nothing opens a mail by its address yet, nor what only a data source knows, so a link would lead nowhere. *@
|
||||
<MudText Typo="Typo.body1" Inline="true">
|
||||
@entry.Title
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudLink Href="@entry.Link" Target="_blank" Typo="Typo.body1">
|
||||
@entry.Title
|
||||
</MudLink>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@T("Show this file in the file manager of your system")" Placement="Placement.Top">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.FolderOpen" Size="Size.Small" OnClick="@(() => this.ShowInFileManager(document))"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
else if (entry.IsMail)
|
||||
{
|
||||
@* Nothing opens a mail by its address yet, so a link would lead nowhere. *@
|
||||
<MudText Typo="Typo.body1" Inline="true">
|
||||
@entry.Title
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudLink Href="@entry.Link" Target="_blank" Typo="Typo.body1">
|
||||
@entry.Title
|
||||
</MudLink>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@* The passages come last, so their button lines up at the right edge also where a
|
||||
source has no file to show. The tooltips open to the left, where there is room
|
||||
for them; above a button at the edge, they would be pushed around and cover the
|
||||
row above. They show on hover only: the focus comes back to the button when the
|
||||
dialog closes, or the file manager does, and a tooltip shown on focus would then
|
||||
stay until the user clicks somewhere else. The title opens the file or the page,
|
||||
so the actions only copy and find it; a mail gets none, see FileActions. *@
|
||||
<div class="source-entry-actions">
|
||||
<FileActions Path="@(entry.Document?.Path ?? entry.Link)" ShowOpen="@false" TooltipPlacement="Placement.Left" OnExistenceChecked="@(exists => this.NoteExistence(entry, exists))"/>
|
||||
|
||||
@if (entry.Passages.Count > 0)
|
||||
{
|
||||
<MudTooltip Text="@this.PassagesTooltip(entry)" Placement="Placement.Left" ShowOnFocus="@false">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Visibility" Size="Size.Small" Class="icon-button-compact" OnClick="@(() => this.ShowPassages(entry))"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Tools.RAG;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
@@ -29,14 +30,20 @@ public partial class SourcesList : MSGComponentBase
|
||||
[Parameter]
|
||||
public IList<Source> Sources { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The passages the AI received from the sources, which the user can read below each of them.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public IReadOnlyList<SourcePassage> Passages { get; set; } = [];
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JsRuntime { get; init; } = null!;
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<SourcesList> Logger { get; init; } = null!;
|
||||
private IJSRuntime JsRuntime { get; init; } = null!;
|
||||
|
||||
private readonly List<SourceEntryGroup> groups = [];
|
||||
|
||||
@@ -73,77 +80,57 @@ public partial class SourcesList : MSGComponentBase
|
||||
private void RebuildGroups()
|
||||
{
|
||||
this.groups.Clear();
|
||||
var passagesBySource = this.Passages.SortBySource();
|
||||
foreach (var group in this.Sources.GroupSources())
|
||||
{
|
||||
var entries = new List<SourceEntry>(group.Sources.Count);
|
||||
foreach (var numberedSource in group.Sources)
|
||||
{
|
||||
var document = numberedSource.Source.TryGetDocumentLocation(out var location) ? location : (SourceDocumentLocation?)null;
|
||||
entries.Add(new(numberedSource.Number, numberedSource.Source.Title, numberedSource.Source.URL, document, numberedSource.Source.IsMailSource()));
|
||||
var passages = passagesBySource.GetPassagesOf(numberedSource.Source);
|
||||
|
||||
//
|
||||
// Pages are links only into a document, and only under the name the title of the
|
||||
// source was made with, see SourcePassageExtensions.NamePassagesInTitles. Parts of a
|
||||
// document without pages are nothing a program can open, so their title stays as it is:
|
||||
//
|
||||
var name = passages.Count > 0 ? passages[0].DataSourceName : string.Empty;
|
||||
var pages = document is not null && !string.IsNullOrWhiteSpace(name) && passages.GetLocation() is { ArePages: true } pageLocation ? pageLocation : null;
|
||||
|
||||
entries.Add(new(numberedSource.Number, numberedSource.Source.Title, numberedSource.Source.URL, document, numberedSource.Source.HasNoLink(), passages, name, pages));
|
||||
}
|
||||
|
||||
this.groups.Add(new(group.Heading, entries));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a document in the program the system uses for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Whether the program can be sent to a page is the runtime's business, and it says afterwards
|
||||
/// whether it managed to. Nothing is shown about that here: the document is open, and the title
|
||||
/// of the source names the page anyway.
|
||||
/// </remarks>
|
||||
/// <param name="document">The document to open, and the page to show.</param>
|
||||
private async Task OpenDocument(SourceDocumentLocation document)
|
||||
{
|
||||
OpenDocumentResponse response;
|
||||
try
|
||||
{
|
||||
response = await this.RustService.TryOpenDocumentInSystemViewer(document.Path, document.PageNumber);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.Logger.LogWarning(e, "Could not open a source document.");
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, T("Could not open the document.")));
|
||||
return;
|
||||
}
|
||||
private string PassagesTooltip(SourceEntry entry) => entry.Passages.Count is 1
|
||||
? T("Show the passage the AI received from this source")
|
||||
: string.Format(T("Show the {0} passages the AI received from this source"), entry.Passages.Count.CompactCount());
|
||||
|
||||
if (response.Success)
|
||||
return;
|
||||
private async Task ShowPassages(SourceEntry entry) => await SourcePassagesDialog.OpenDialogAsync(this.DialogService, entry.Passages, entry.Document);
|
||||
|
||||
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, string.Format(T("Could not open the document: {0}"), issue)));
|
||||
}
|
||||
private async Task OpenDocument(SourceDocumentLocation document) => await document.OpenAsync(this.RustService);
|
||||
|
||||
/// <summary>
|
||||
/// Opens the file browser of the system and selects the document in it.
|
||||
/// The files of sources which are no longer at their place, by their paths, as FileActions found out.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The second way out of the list: a document which the system opens in the wrong program, or
|
||||
/// which the user wants to move or send on instead of read, is reached from here without being
|
||||
/// opened. This is the same way out the embeddings page offers for a file it could not read.
|
||||
/// </remarks>
|
||||
/// <param name="document">The document to show.</param>
|
||||
private async Task ShowInFileManager(SourceDocumentLocation document)
|
||||
private readonly HashSet<string> missingFiles = new(PathTools.COMPARER);
|
||||
|
||||
private bool IsMissing(SourceEntry entry) => entry.Document is { } document && this.missingFiles.Contains(document.Path);
|
||||
|
||||
/// <summary>
|
||||
/// Keeps whether the file of a source is at its place, so that its title stops being a link when it is not.
|
||||
/// </summary>
|
||||
private void NoteExistence(SourceEntry entry, bool exists)
|
||||
{
|
||||
OpenPathResponse response;
|
||||
try
|
||||
{
|
||||
response = await this.RustService.TryOpenPathInRuntimeFileManager(document.Path);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.Logger.LogWarning(e, "Could not show a source document in the file manager.");
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, T("Could not open the file location.")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.Success)
|
||||
if (entry.Document is not { } document)
|
||||
return;
|
||||
|
||||
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, string.Format(T("Could not open the file location: {0}"), issue)));
|
||||
if (exists)
|
||||
this.missingFiles.Remove(document.Path);
|
||||
else
|
||||
this.missingFiles.Add(document.Path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -160,6 +147,9 @@ public partial class SourcesList : MSGComponentBase
|
||||
/// <param name="Title">The title of the source.</param>
|
||||
/// <param name="Link">The address of the source, which a web source is opened by.</param>
|
||||
/// <param name="Document">The document the source names, or null when it names none.</param>
|
||||
/// <param name="IsMail">Whether the source names a mail, which is shown as text.</param>
|
||||
private readonly record struct SourceEntry(int Number, string Title, string Link, SourceDocumentLocation? Document, bool IsMail);
|
||||
/// <param name="HasNoLink">Whether nothing can open the source by its address, e.g., a mail, so that it is shown as text.</param>
|
||||
/// <param name="Passages">The passages the AI received from the source; empty when it received none, or the chat is older than these.</param>
|
||||
/// <param name="Name">The name of the source without its location, or empty when it has no passages.</param>
|
||||
/// <param name="Location">The pages of the passages in the document, each to be opened on its own, or null when there are none.</param>
|
||||
private readonly record struct SourceEntry(int Number, string Title, string Link, SourceDocumentLocation? Document, bool HasNoLink, IReadOnlyList<SourcePassage> Passages, string Name, PassageLocation? Location);
|
||||
}
|
||||
Reference in new issue
Block a user