Files
SqlDatabaseVectorSearch/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
T
Marco Minerva 7d413aa0b3 Disable upload form during upload; update citation template
The file upload form in Documents.razor is now wrapped in a <fieldset> that disables all controls while uploading, using a new isUploading flag to manage state and UI updates. The upload button's loading state is also handled. In Program.cs, the citation template was revised to require supporting excerpts of about 20-30 words instead of 15-20 words.
2026-07-28 15:23:19 +02:00

300 lines
12 KiB
Plaintext

@page "/documents"
@using MimeMapping
@inject IServiceScopeFactory ServiceScopeFactory
@inject IJSRuntime JSRuntime
<ConfirmDialog @ref="dialog" />
<PageTitle>Documents</PageTitle>
<h4 class="mb-1">
<Icon Name="IconName.CloudArrowUpFill" Color="IconColor.Primary" class="me-2" />
Upload a new document
</h4>
<p class="text-body-secondary small mb-4">The document is split into chunks, and an embedding is generated and stored for each chunk.</p>
<EditForm Model="Model" Enhance OnValidSubmit="UploadFile">
<DataAnnotationsValidator />
<fieldset disabled="@isUploading">
<div class="row">
<div class="col-md-5 col-sm-4 col-5">
<div class="input-group">
<span class="input-group-text">
<Tooltip Title="PDF, DOCX, TXT and MD files are supported, up to 20 MB" Color="TooltipColor.Primary" Placement="TooltipPlacement.Bottom">
<Icon Class="d-flex" Color="IconColor.Info" Name="IconName.InfoCircle"></Icon>
</Tooltip>
</span>
<InputFile class="form-control" OnChange="@((e) => Model.File = e.File)" accept=".pdf,.docx,.txt,.md" id="fileInput" />
</div>
</div>
<div class="col-md-5 col-sm-5 col-5">
<div class="input-group">
<span class="input-group-text">
<Tooltip Title="The unique identifier (GUID) of the document. If not provided, a new one will be generated. If you specify an existing Document ID, the corresponding document will be overwritten." Color="TooltipColor.Primary" Placement="TooltipPlacement.Bottom">
<Icon Class="d-flex me-2" Color="IconColor.Warning" Name="IconName.KeyFill"></Icon>
</Tooltip>
Document ID
</span>
<TextInput Placeholder="Enter a valid GUID or leave empty for auto-generation" @bind-Value="@Model.DocumentId" />
</div>
<ValidationMessage For="@(() => Model.DocumentId)" />
</div>
<div class="col-md-2 col-sm-3 col-2">
<div class="d-grid gap-2">
<Button @ref="uploadButton" Type="ButtonType.Submit" Color="ButtonColor.Primary" To="#" Disabled="@(Model.File is null)" Class="w-100 py-2 fw-semibold shadow-sm"><Icon Name="IconName.Upload" /><span class="d-none d-lg-inline ps-3">Upload</span></Button>
</div>
</div>
</div>
</fieldset>
</EditForm>
@if (isLoading && documents.Count == 0)
{
<div class="text-center">
<Spinner Type="SpinnerType.Dots" Class="me-3 mt-4" Color="SpinnerColor.Primary" />
</div>
}
else
{
<h4 class="mt-4 mb-4">
<Icon Name="IconName.FileEarmarkTextFill" Color="IconColor.Info" class="me-2" />
Available documents
<span class="badge bg-secondary-subtle text-secondary-emphasis ms-2 align-middle">@documents.Count</span>
</h4>
@if (documents.Count == 0)
{
<div class="text-center border rounded py-5 text-body-secondary">
<Icon Name="IconName.Inbox" Color="IconColor.Secondary" Size="IconSize.x3" />
<p class="mt-3 mb-0">No documents have been indexed yet. Upload one to get started.</p>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 border rounded overflow-hidden">
<thead class="table-light sticky-top">
<tr>
<th style="width:40px;"></th>
<th class="text-secondary">ID</th>
<th class="text-secondary">Name</th>
<th class="text-secondary">Content type</th>
<th class="text-secondary text-center">Chunks</th>
<th class="text-secondary">Created</th>
</tr>
</thead>
<tbody>
@foreach (var document in documents)
{
<tr class="@((document.IsSelected ? "table-primary" : null))">
<td>
<div class="d-flex justify-content-center align-items-center">
<CheckboxInput @bind-Value="document.IsSelected" />
</div>
</td>
<td class="text-break small">@document.Id</td>
<td class="fw-medium">@document.Name</td>
<td>
<span class="badge content-type-badge px-2 py-1 rounded-pill small">
@document.ContentType
</span>
</td>
<td class="text-center">@document.ChunkCount</td>
<td class="small text-secondary">@document.LocalCreationDateString</td>
</tr>
}
</tbody>
</table>
</div>
<div class="my-4"></div>
<div class="row">
<div class="col-md-2 col-sm-3 col-2">
<div class="d-grid gap-2">
<Button @ref="deleteButton" Color="ButtonColor.Danger" Disabled="@(!documents.Any(d => d.IsSelected))" @onclick="DeleteSelectedDocuments" Class="w-100 py-2 fw-semibold shadow-sm">
<Icon Name="IconName.Trash" /><span class="d-none d-lg-inline ps-3">Delete</span>
</Button>
</div>
</div>
</div>
}
}
@code {
private ConfirmDialog dialog = default!;
private Button uploadButton = default!;
private Button deleteButton = default!;
private bool isLoading = true;
private bool isUploading;
private IList<SelectableDocument> documents = [];
private UploadDocument Model { get; set; } = new();
[Inject]
protected ToastService ToastService { get; set; } = default!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
await using var scope = ServiceScopeFactory.CreateAsyncScope();
await LoadDocumentsAsync(scope.ServiceProvider);
await InvokeAsync(StateHasChanged);
}
private async Task LoadDocumentsAsync(IServiceProvider services)
{
isLoading = true;
try
{
var documentService = services.GetRequiredService<DocumentService>();
var dbDocuments = await documentService.GetAsync();
documents.Clear();
foreach (var dbDocument in dbDocuments)
{
documents.Add(new SelectableDocument(dbDocument.Id, dbDocument.Name, dbDocument.CreationDate, dbDocument.ChunkCount)
{
LocalCreationDateString = await GetLocalDateTimeStringAsync(dbDocument.CreationDate)
});
}
}
finally
{
isLoading = false;
}
}
private async Task UploadFile()
{
if (Model.File is null)
{
return;
}
uploadButton.ShowLoading();
isUploading = true;
await InvokeAsync(StateHasChanged);
var fileName = Model.File.Name;
try
{
await using var inputStream = Model.File.OpenReadStream(20 * 1024 * 1024); // 20 MB
await using var stream = await inputStream.GetMemoryStreamAsync();
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var vectorSearchService = scope.ServiceProvider.GetRequiredService<VectorSearchService>();
var documentId = string.IsNullOrWhiteSpace(Model.DocumentId) ? null : (Guid?)Guid.Parse(Model.DocumentId);
await vectorSearchService.ImportAsync(EmbeddingRequest.Create(stream, fileName, documentId));
ToastService.Notify(await CreateToastMessageAsync(ToastType.Success, "Upload document", $"The document '{fileName}' has been successfully uploaded and indexed."));
Model = new UploadDocument();
await JSRuntime.InvokeVoidAsync("resetFileInput", "fileInput");
await LoadDocumentsAsync(scope.ServiceProvider);
}
catch (Exception ex)
{
ToastService.Notify(await CreateToastMessageAsync(ToastType.Danger, "Upload error", $"There was an error while uploading the document '{fileName}': {ex.Message}"));
}
finally
{
uploadButton.HideLoading();
isUploading = false;
await InvokeAsync(StateHasChanged);
}
}
private async Task DeleteSelectedDocuments()
{
var selectedDocumentIds = documents?.Where(d => d.IsSelected).Select(d => d.Id) ?? [];
var options = new ConfirmDialogOptions
{
YesButtonText = "Yes",
YesButtonColor = ButtonColor.Danger,
NoButtonText = "No",
NoButtonColor = ButtonColor.Secondary
};
var confirmation = await dialog.ShowAsync(
title: "Delete the selected documents?",
message1: "This will delete the documents and all the corresponding embeddings. The operation cannot be undone.",
message2: "Do you want to proceed?",
confirmDialogOptions: options);
if (!confirmation)
{
return;
}
try
{
deleteButton.ShowLoading();
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var documentService = scope.ServiceProvider.GetRequiredService<DocumentService>();
await documentService.DeleteAsync(selectedDocumentIds);
await LoadDocumentsAsync(scope.ServiceProvider);
ToastService.Notify(await CreateToastMessageAsync(ToastType.Info, "Delete documents", "The selected documents have been successfully deleted."));
}
catch (Exception ex)
{
ToastService.Notify(await CreateToastMessageAsync(ToastType.Danger, "Delete error", $"There was an error while deleting the documents: {ex.Message}"));
}
finally
{
deleteButton.HideLoading();
}
}
private async Task<ToastMessage> CreateToastMessageAsync(ToastType toastType, string title, string message)
{
var toastMessage = new ToastMessage
{
Type = toastType,
Title = title,
HelpText = await GetLocalDateTimeStringAsync(DateTimeOffset.UtcNow),
Message = message
};
return toastMessage;
}
private async Task<string> GetLocalDateTimeStringAsync(DateTimeOffset dateTime)
{
return await JSRuntime.InvokeAsync<string>("getLocalTime", dateTime);
}
private record class SelectableDocument(Guid Id, string Name, DateTimeOffset CreationDate, int ChunkCount) : Document(Id, Name, CreationDate, ChunkCount)
{
public bool IsSelected { get; set; }
public string ContentType => MimeUtility.GetMimeMapping(Name);
public string LocalCreationDateString { get; set; } = string.Empty;
}
public class UploadDocument
{
public IBrowserFile? File { get; set; }
[RegularExpression(@"^(\{|\()?[0-9a-fA-F]{8}(-?)[0-9a-fA-F]{4}(-?)[0-9a-fA-F]{4}(-?)[0-9a-fA-F]{4}(-?)[0-9a-fA-F]{12}(\}|\))?$", ErrorMessage = "Invalid GUID format.")]
public string? DocumentId { get; set; }
}
}