Private
Public Access
1
0

Add adjustable context size, custom chatbot name, and cat icon for AI chat

Settings > AI tab now includes context size slider (2048–131072) with VRAM
estimate and a chatbot name field. ChatService reads both dynamically instead
of hardcoding num_ctx=16384 and "Purrse AI". Chat panel FAB and empty state
use mdi-cat icon to match the rest of the app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-13 14:38:07 -05:00
parent 0ad8e472f7
commit 688dd333dc
11 changed files with 1696 additions and 12 deletions
+17 -3
View File
@@ -8,14 +8,14 @@
elevation="8" elevation="8"
@click="togglePanel" @click="togglePanel"
> >
<v-icon>{{ panelOpen ? 'mdi-close' : 'mdi-robot-outline' }}</v-icon> <v-icon>{{ panelOpen ? 'mdi-close' : 'mdi-cat' }}</v-icon>
</v-btn> </v-btn>
<!-- Chat Panel --> <!-- Chat Panel -->
<v-card v-show="panelOpen" class="chat-panel" elevation="12" rounded="lg"> <v-card v-show="panelOpen" class="chat-panel" elevation="12" rounded="lg">
<!-- Header --> <!-- Header -->
<v-toolbar density="compact" color="primary" class="flex-grow-0"> <v-toolbar density="compact" color="primary" class="flex-grow-0">
<v-toolbar-title class="text-body-1 font-weight-medium">Purrse AI</v-toolbar-title> <v-toolbar-title class="text-body-1 font-weight-medium">{{ chatBotName }}</v-toolbar-title>
<template v-slot:append> <template v-slot:append>
<v-menu> <v-menu>
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
@@ -56,7 +56,7 @@
<div ref="messagesContainer" class="chat-messages"> <div ref="messagesContainer" class="chat-messages">
<!-- Empty state --> <!-- Empty state -->
<div v-if="messages.length === 0 && !loading" class="d-flex flex-column align-center justify-center" style="height: 100%;"> <div v-if="messages.length === 0 && !loading" class="d-flex flex-column align-center justify-center" style="height: 100%;">
<v-icon size="48" color="primary" class="mb-3">mdi-robot-outline</v-icon> <v-icon size="48" color="primary" class="mb-3">mdi-cat</v-icon>
<p class="text-body-2 text-medium-emphasis mb-4">Ask me about your finances</p> <p class="text-body-2 text-medium-emphasis mb-4">Ask me about your finances</p>
<div class="d-flex flex-wrap justify-center ga-2 px-4"> <div class="d-flex flex-wrap justify-center ga-2 px-4">
<v-chip <v-chip
@@ -140,6 +140,7 @@
import { ref, nextTick, onMounted, watch } from 'vue' import { ref, nextTick, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { chatApi } from '@/services/chat' import { chatApi } from '@/services/chat'
import { aiCategorizationApi } from '@/services/aiCategorization'
import { usePageContext } from '@/composables/usePageContext' import { usePageContext } from '@/composables/usePageContext'
import ChatToolResult from '@/views/chat/ChatToolResult.vue' import ChatToolResult from '@/views/chat/ChatToolResult.vue'
import type { ChatConversation, ChatMessage } from '@/types' import type { ChatConversation, ChatMessage } from '@/types'
@@ -155,6 +156,7 @@ const inputMessage = ref('')
const loading = ref(false) const loading = ref(false)
const messagesContainer = ref<HTMLElement | null>(null) const messagesContainer = ref<HTMLElement | null>(null)
const conversationsLoaded = ref(false) const conversationsLoaded = ref(false)
const chatBotName = ref('Purrse AI')
let _idCounter = 0 let _idCounter = 0
function tempId(): string { function tempId(): string {
@@ -214,6 +216,18 @@ function togglePanel() {
panelOpen.value = !panelOpen.value panelOpen.value = !panelOpen.value
if (panelOpen.value && !conversationsLoaded.value) { if (panelOpen.value && !conversationsLoaded.value) {
loadConversations() loadConversations()
loadChatBotName()
}
}
async function loadChatBotName() {
try {
const { data } = await aiCategorizationApi.getSettings()
if (data.chatBotName) {
chatBotName.value = data.chatBotName
}
} catch {
// Default name is fine
} }
} }
+2
View File
@@ -521,6 +521,8 @@ export interface AiCategorizationSettings {
updatePayeeDefaults: boolean updatePayeeDefaults: boolean
systemPrompt: string | null systemPrompt: string | null
userPromptTemplate: string | null userPromptTemplate: string | null
chatContextSize: number
chatBotName: string | null
} }
export interface OllamaConnectionTestResult { export interface OllamaConnectionTestResult {
+34 -1
View File
@@ -762,6 +762,37 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<v-card class="mt-4">
<v-card-title>AI Chat Settings</v-card-title>
<v-card-text>
<div class="text-body-2 mb-1">Context Size: {{ aiSettings.chatContextSize.toLocaleString() }} tokens</div>
<v-slider
v-model="aiSettings.chatContextSize"
:min="2048"
:max="131072"
:step="2048"
thumb-label
color="primary"
hide-details
class="mb-1"
/>
<div class="text-caption text-medium-emphasis mb-4">
Estimated VRAM: ~{{ Math.round((aiSettings.chatContextSize / 1024) * 75) }} MB Increase if the AI loses context in long chats. Decrease if Ollama runs out of memory.
</div>
<v-text-field
v-model="aiSettings.chatBotName"
label="Chatbot Name"
variant="outlined"
density="compact"
placeholder="Purrse AI"
hint="Custom name shown in the chat panel header and used by the AI"
persistent-hint
clearable
/>
</v-card-text>
</v-card>
<v-snackbar v-model="showAiSuccess" :timeout="4000" color="success"> <v-snackbar v-model="showAiSuccess" :timeout="4000" color="success">
AI categorization settings saved AI categorization settings saved
</v-snackbar> </v-snackbar>
@@ -1305,7 +1336,9 @@ const aiSettings = reactive<AiCategorizationSettings>({
timeoutSeconds: 30, timeoutSeconds: 30,
updatePayeeDefaults: true, updatePayeeDefaults: true,
systemPrompt: null, systemPrompt: null,
userPromptTemplate: null userPromptTemplate: null,
chatContextSize: 16384,
chatBotName: null
}) })
const aiSaving = ref(false) const aiSaving = ref(false)
const aiTesting = ref(false) const aiTesting = ref(false)
@@ -67,13 +67,14 @@ public class AiCategorizationService : IAiCategorizationService
if (settings == null) if (settings == null)
{ {
return new AiCategorizationSettingsResponse( return new AiCategorizationSettingsResponse(
false, "http://localhost:11434", "llama3.1:8b", 0.7, 30, true, null, null); false, "http://localhost:11434", "llama3.1:8b", 0.7, 30, true, null, null, 16384, null);
} }
return new AiCategorizationSettingsResponse( return new AiCategorizationSettingsResponse(
settings.IsEnabled, settings.OllamaUrl, settings.ModelName, settings.IsEnabled, settings.OllamaUrl, settings.ModelName,
settings.ConfidenceThreshold, settings.TimeoutSeconds, settings.UpdatePayeeDefaults, settings.ConfidenceThreshold, settings.TimeoutSeconds, settings.UpdatePayeeDefaults,
settings.SystemPrompt, settings.UserPromptTemplate); settings.SystemPrompt, settings.UserPromptTemplate,
settings.ChatContextSize, settings.ChatBotName);
} }
public async Task<AiCategorizationSettingsResponse> UpdateSettingsAsync(Guid userId, UpdateAiCategorizationSettingsRequest request) public async Task<AiCategorizationSettingsResponse> UpdateSettingsAsync(Guid userId, UpdateAiCategorizationSettingsRequest request)
@@ -98,6 +99,8 @@ public class AiCategorizationService : IAiCategorizationService
settings.UpdatePayeeDefaults = request.UpdatePayeeDefaults; settings.UpdatePayeeDefaults = request.UpdatePayeeDefaults;
settings.SystemPrompt = string.IsNullOrWhiteSpace(request.SystemPrompt) ? null : request.SystemPrompt; settings.SystemPrompt = string.IsNullOrWhiteSpace(request.SystemPrompt) ? null : request.SystemPrompt;
settings.UserPromptTemplate = string.IsNullOrWhiteSpace(request.UserPromptTemplate) ? null : request.UserPromptTemplate; settings.UserPromptTemplate = string.IsNullOrWhiteSpace(request.UserPromptTemplate) ? null : request.UserPromptTemplate;
settings.ChatContextSize = request.ChatContextSize;
settings.ChatBotName = string.IsNullOrWhiteSpace(request.ChatBotName) ? null : request.ChatBotName;
settings.UpdatedAt = DateTime.UtcNow; settings.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
@@ -105,7 +108,8 @@ public class AiCategorizationService : IAiCategorizationService
return new AiCategorizationSettingsResponse( return new AiCategorizationSettingsResponse(
settings.IsEnabled, settings.OllamaUrl, settings.ModelName, settings.IsEnabled, settings.OllamaUrl, settings.ModelName,
settings.ConfidenceThreshold, settings.TimeoutSeconds, settings.UpdatePayeeDefaults, settings.ConfidenceThreshold, settings.TimeoutSeconds, settings.UpdatePayeeDefaults,
settings.SystemPrompt, settings.UserPromptTemplate); settings.SystemPrompt, settings.UserPromptTemplate,
settings.ChatContextSize, settings.ChatBotName);
} }
public async Task<OllamaConnectionTestResponse> TestConnectionAsync(Guid userId) public async Task<OllamaConnectionTestResponse> TestConnectionAsync(Guid userId)
+10 -3
View File
@@ -113,6 +113,8 @@ public class ChatService : IChatService
var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId); var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId);
var ollamaUrl = settings?.OllamaUrl ?? "http://localhost:11434"; var ollamaUrl = settings?.OllamaUrl ?? "http://localhost:11434";
var modelName = settings?.ModelName ?? "llama3.1:8b"; var modelName = settings?.ModelName ?? "llama3.1:8b";
var chatContextSize = settings?.ChatContextSize ?? 16384;
var chatBotName = settings?.ChatBotName;
// Save user message // Save user message
var userMessage = new ChatMessage var userMessage = new ChatMessage
@@ -130,10 +132,15 @@ public class ChatService : IChatService
var ollamaMessages = new List<OllamaToolMessage>(); var ollamaMessages = new List<OllamaToolMessage>();
// System prompt // System prompt
var systemPrompt = string.Format(SystemPromptTemplate, DateTime.UtcNow.ToString("yyyy-MM-dd"));
if (!string.IsNullOrWhiteSpace(chatBotName))
{
systemPrompt += $"\nYour name is {chatBotName}.";
}
ollamaMessages.Add(new OllamaToolMessage ollamaMessages.Add(new OllamaToolMessage
{ {
Role = "system", Role = "system",
Content = string.Format(SystemPromptTemplate, DateTime.UtcNow.ToString("yyyy-MM-dd")) Content = systemPrompt
}); });
// Last 20 conversation messages // Last 20 conversation messages
@@ -199,7 +206,7 @@ public class ChatService : IChatService
Messages = ollamaMessages, Messages = ollamaMessages,
Stream = false, Stream = false,
Tools = tools, Tools = tools,
Options = new OllamaRequestOptions { NumCtx = 16384 } Options = new OllamaRequestOptions { NumCtx = chatContextSize }
}; };
var response = await CallOllamaAsync(ollamaUrl, ollamaRequest); var response = await CallOllamaAsync(ollamaUrl, ollamaRequest);
@@ -272,7 +279,7 @@ public class ChatService : IChatService
Messages = ollamaMessages, Messages = ollamaMessages,
Stream = false, Stream = false,
Tools = null, // drop tools so the model focuses on the page data Tools = null, // drop tools so the model focuses on the page data
Options = new OllamaRequestOptions { NumCtx = 16384 } Options = new OllamaRequestOptions { NumCtx = chatContextSize }
}; };
var retryResponse = await CallOllamaAsync(ollamaUrl, retryRequest); var retryResponse = await CallOllamaAsync(ollamaUrl, retryRequest);
rawContent = retryResponse?.Message?.Content; rawContent = retryResponse?.Message?.Content;
+6 -2
View File
@@ -11,7 +11,9 @@ public record AiCategorizationSettingsResponse(
int TimeoutSeconds, int TimeoutSeconds,
bool UpdatePayeeDefaults, bool UpdatePayeeDefaults,
string? SystemPrompt, string? SystemPrompt,
string? UserPromptTemplate); string? UserPromptTemplate,
int ChatContextSize,
string? ChatBotName);
public record UpdateAiCategorizationSettingsRequest( public record UpdateAiCategorizationSettingsRequest(
bool IsEnabled, bool IsEnabled,
@@ -21,7 +23,9 @@ public record UpdateAiCategorizationSettingsRequest(
int TimeoutSeconds, int TimeoutSeconds,
bool UpdatePayeeDefaults, bool UpdatePayeeDefaults,
string? SystemPrompt, string? SystemPrompt,
string? UserPromptTemplate); string? UserPromptTemplate,
int ChatContextSize,
string? ChatBotName);
// Connection test // Connection test
public record OllamaConnectionTestResponse( public record OllamaConnectionTestResponse(
@@ -12,6 +12,8 @@ public class AiCategorizationSettings
public bool UpdatePayeeDefaults { get; set; } = true; public bool UpdatePayeeDefaults { get; set; } = true;
public string? SystemPrompt { get; set; } public string? SystemPrompt { get; set; }
public string? UserPromptTemplate { get; set; } public string? UserPromptTemplate { get; set; }
public int ChatContextSize { get; set; } = 16384;
public string? ChatBotName { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!; public User User { get; set; } = null!;
@@ -14,6 +14,7 @@ public class AiCategorizationSettingsConfiguration : IEntityTypeConfiguration<Ai
builder.Property(s => s.ModelName).HasMaxLength(100); builder.Property(s => s.ModelName).HasMaxLength(100);
builder.Property(s => s.SystemPrompt).HasMaxLength(4000); builder.Property(s => s.SystemPrompt).HasMaxLength(4000);
builder.Property(s => s.UserPromptTemplate).HasMaxLength(4000); builder.Property(s => s.UserPromptTemplate).HasMaxLength(4000);
builder.Property(s => s.ChatBotName).HasMaxLength(100);
builder.HasOne(s => s.User) builder.HasOne(s => s.User)
.WithOne(u => u.AiCategorizationSettings) .WithOne(u => u.AiCategorizationSettings)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Purrse.Data.Migrations
{
/// <inheritdoc />
public partial class AddChatSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ChatBotName",
table: "ai_categorization_settings",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ChatContextSize",
table: "ai_categorization_settings",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ChatBotName",
table: "ai_categorization_settings");
migrationBuilder.DropColumn(
name: "ChatContextSize",
table: "ai_categorization_settings");
}
}
}
@@ -91,6 +91,13 @@ namespace Purrse.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("ChatBotName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("ChatContextSize")
.HasColumnType("integer");
b.Property<double>("ConfidenceThreshold") b.Property<double>("ConfidenceThreshold")
.HasColumnType("double precision"); .HasColumnType("double precision");