DEV Community

Pawel Janda
Pawel Janda

Posted on

Build a Local ChatGPT-like App with Blazor and MAIN.NET – Part 3: Chatting with Your PDF Files.

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
    }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

🧰 File handling logic behind the scenes.

Our backend logic ensures that files are properly managed:

  • LoadFiles handles file uploads, saves them to the wwwroot/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
                });
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode
  • 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}");
        }
    }
Enter fullscreen mode Exit fullscreen mode
  • ClearFiles removes all uploaded files at once
private void ClearFiles()
    {
        foreach (var file in uploadedFiles.ToList())
        {
            RemoveFile(file);
        }
    }
Enter fullscreen mode Exit fullscreen mode
  • Automatic file cleanup happens when the component is disposed to keep the storage clean
public void Dispose()
    {
        ClearFiles();
    }
Enter fullscreen mode Exit fullscreen mode

🌐 Handling web root and persistence.

We made sure the file system logic is environment-agnostic and persistent:

  • Injected IWebHostEnvironment to dynamically get the correct wwwroot path. Add this two lines below using declarations.
@using Microsoft.AspNetCore.Hosting
@inject IWebHostEnvironment Environment
Enter fullscreen mode Exit fullscreen mode
  • 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");
Enter fullscreen mode Exit fullscreen mode
  • 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
            });
        }
    }
Enter fullscreen mode Exit fullscreen mode

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 });
        }
    }
Enter fullscreen mode Exit fullscreen mode

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; } = "";
    }
Enter fullscreen mode Exit fullscreen mode

🎯 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.

Image description


πŸš€ 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)