Welcome back to the series! π
In Part 2, we added chat history support, allowing our app to maintain conversation context like a real assistant.
Today, weβre taking another big step forward β enabling our chatbot to read and discuss uploaded PDF files!
Imagine uploading a research paper, a business report, or a contract β and asking the LLM to summarize, explain, or extract information from it.
Let's make it happen!
π‘ What weβre building?
By the end of this part, youβll be able to:
- π Upload multiple PDF files
- π See a list of uploaded documents
- ποΈ Remove individual files or clear all uploads
- π¬ Chat with the LLM about the content inside the PDFs
- π Persist uploaded files between page reloads
All files will be stored safely and reliably, with a clean user interface and correct file handling behind the scenes.
π¦ One more thing before we begin: Embeddings model.
Before we move on, we need to add one more model to support document understanding β an embeddings model.
This model is used behind the scenes to analyze and chunk your PDF content in a way that the LLM can actually understand and reference during conversation.
π οΈ If you were using the MaIN.NET CLI, this step would happen automatically.
Iβll cover the CLI setup in a separate tutorial, but for now letβs do it manually.
π Download the model from Hugging Face:
https://huggingface.co/Inza124/Nomic
Once downloaded, place the .gguf
file in the same folder where you stored your Gemma model β for example /Documents/MainModels/.
Weβll reference this embeddings model automatically later when chatting with PDFs. Thatβs all for setup β now letβs build!
π Key features weβre adding.
πΌ File upload UI.
We will enhance the message input area to not only send text messages but also easily attach PDF files.
Find this part of code and remove it:
<div class="d-flex gap-2">
<input type="text" class="form-control"
@bind="messageToLLM"
@bind:event="oninput"
@onkeydown="HandleKeyDown"
placeholder="Type your message..." />
<button class="btn btn-primary" @onclick="SendMessage">Send</button>
</div>
Weβll slightly reorganize the layout:
- Keep the familiar text input field for typing your messages.
- Add a new file attachment button (with a paperclip icon) to upload PDF files.
- Keep the Send button aligned next to it for a smooth user experience.
At this point, the entire input and button section should look like this:
<div class="border rounded p-3">
<div class="d-flex gap-2 mb-2">
<input type="text" class="form-control"
@bind="messageToLLM"
@bind:event="oninput"
@onkeydown="HandleKeyDown"
placeholder="Type your message..." />
<div class="d-flex gap-2">
<label class="btn btn-outline-secondary px-2 d-flex align-items-center" style="cursor: pointer;">
<i class="bi bi-paperclip"></i>
<InputFile OnChange="@LoadFiles" multiple accept=".pdf" class="d-none" />
</label>
<button class="btn btn-primary px-4" @onclick="SendMessage">Send</button>
</div>
</div>
</div>
This layout keeps everything compact and intuitive:
you can type, attach PDFs, and send messages β all from the same place!
After the files are uploaded, we wand to display:
A clear files button to remove all uploaded documents
A list of uploaded files with nice PDF icons and the original filenames
A remove button next to each file for individual deletion
To achieve that, we need to add the code below:
@if (uploadedFiles.Any())
{
<div class="border-top pt-2">
<div class="d-flex justify-content-between align-items-center mb-2">
<small>Attached files:</small>
<button class="btn btn-link btn-sm text-danger p-0" @onclick="ClearFiles">
<small>Clear all</small>
</button>
</div>
<div class="d-flex flex-wrap gap-2">
@foreach (var file in uploadedFiles)
{
<div class="bg-light rounded p-2 d-flex align-items-center" style="font-size: 0.875rem;">
<i class="bi bi-file-pdf text-danger me-2"></i>
<span class="text-truncate" style="max-width: 200px;">@file.Name</span>
<button class="btn btn-link text-danger p-0 ms-2" style="font-size: 0.875rem;" @onclick="() => RemoveFile(file)">
<i class="bi bi-x"></i>
</button>
</div>
}
</div>
</div>
}
IMPORTANT Don't forget to create the "uploads" directory in the wwwroot. You can do it directly from Terminal inside the project by:
mkdir -p wwwroot/uploads
IMPORTANT-2π Don't forget to link the bootstrap icons as it's not a part of Blazor app boilerplate. In the App.razor file insert the line in the head section:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
π§° File handling logic behind the scenes.
Our backend logic ensures that files are properly managed:
-
LoadFiles
handles file uploads, saves them to thewwwroot/uploads
directory
private async Task LoadFiles(InputFileChangeEventArgs e)
{
foreach (var file in e.GetMultipleFiles())
{
if (Path.GetExtension(file.Name).ToLower() == ".pdf")
{
var fileName = Path.GetRandomFileName() + "_" + file.Name;
var filePath = Path.Combine(uploadsPath, fileName);
await using (var stream = File.Create(filePath))
{
await file.OpenReadStream().CopyToAsync(stream);
}
uploadedFiles.Add(new UploadedFile
{
Name = file.Name,
Path = filePath
});
}
}
}
-
RemoveFile
lets users delete specific uploaded files
private void RemoveFile(UploadedFile file)
{
try
{
if (File.Exists(file.Path))
{
File.Delete(file.Path);
}
uploadedFiles.Remove(file);
}
catch (Exception ex)
{
Console.WriteLine($"Error deleting file: {ex.Message}");
}
}
-
ClearFiles
removes all uploaded files at once
private void ClearFiles()
{
foreach (var file in uploadedFiles.ToList())
{
RemoveFile(file);
}
}
- Automatic file cleanup happens when the component is disposed to keep the storage clean
public void Dispose()
{
ClearFiles();
}
π Handling web root and persistence.
We made sure the file system logic is environment-agnostic and persistent:
- Injected
IWebHostEnvironment
to dynamically get the correctwwwroot
path. Add this two lines below using declarations.
@using Microsoft.AspNetCore.Hosting
@inject IWebHostEnvironment Environment
- Created a computed
uploadsPath
property that points to the uploads directory and a list of uploaded files:
private List<UploadedFile> uploadedFiles = new();
private string uploadsPath => Path.Combine(Environment.WebRootPath, "uploads");
- In
OnInitialized
, we:- Ensure the uploads folder exists
- Load any pre-existing files if they were uploaded before
protected override void OnInitialized()
{
// Ensure uploads directory exists
Directory.CreateDirectory(uploadsPath);
// Load existing files
LoadExistingFiles();
}
private void LoadExistingFiles()
{
uploadedFiles.Clear();
var files = Directory.GetFiles(uploadsPath);
foreach (var filePath in files)
{
var originalName = Path.GetFileName(filePath).Split('_', 2).Last();
uploadedFiles.Add(new UploadedFile
{
Name = originalName,
Path = filePath
});
}
}
This way, uploaded PDFs persist across page reloads, and you always see the correct files on app startup.
π¬ Updated chat functionality.
We updated the SendMessage
method so that:
- It sends not only your chat message but also the paths to uploaded PDFs to the LLM
- The LLM now has full access to the contents of the uploaded documents when responding
Below you can find updated SendMessage method code:
private async Task SendMessage()
{
if (string.IsNullOrWhiteSpace(messageToLLM))
return;
// Add user message to history
chatHistory.Add(new ChatMessage { Content = messageToLLM, IsUser = true });
var userMessage = messageToLLM;
messageToLLM = "";
try
{
if (chatInstance == null)
{
chatInstance = AIHub.Chat()
.WithModel("gemma3:4b");
}
var result = await chatInstance
.WithMessage(userMessage)
.WithFiles(uploadedFiles.Select(f => f.Path).ToList())
.CompleteAsync();
// Add chat response to history
chatHistory.Add(new ChatMessage { Content = result.Message.Content, IsUser = false });
}
catch (Exception ex)
{
chatHistory.Add(new ChatMessage { Content = $"Error: {ex.Message}", IsUser = false });
}
}
We also introduced a new UploadedFile
class to manage uploaded file states. You can create it below the ChatMessage class:
private class UploadedFile
{
public string Name { get; set; } = "";
public string Path { get; set; } = "";
}
π― How it works now?
β
Upload one or more PDFs
β
See uploaded files with original names and icons
β
Remove individual files or clear all uploads
β
Persist uploaded files across page reloads
β
Chat about the content inside PDFs with the LLM!
All operations are smooth, safe, and ready for real-world use cases.
π Whatβs next?
In Part 4, weβll focus on making our app look and feel even better!
Weβll refine the chat UI, improve the layout, and make it more incredible.
Expect:
- Design tweaks
- Chat bubble animations
- Typing indicators... and maybe even dark mode π
<< To be continued >>
π¬ If you enjoyed this part, leave a comment, share the tutorial, and donβt forget to β the MAIN.NET GitHub repo!
Top comments (0)