Private
Public Access
1
0

Restructure AI settings into 3 cards and add AI Chat enable/disable toggle

Splits the AI tab into Configuration, Categorization, and Chat sections
with independent feature toggles. Both toggles are disabled when Ollama
URL or model is not configured. ChatPanel FAB is gated on isChatEnabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-15 23:54:51 -05:00
parent 7f15cd11e3
commit beb0ef521b
9 changed files with 1709 additions and 60 deletions
+14 -2
View File
@@ -122,15 +122,16 @@
</v-container> </v-container>
</v-main> </v-main>
<ChatPanel /> <ChatPanel v-if="isChatEnabled" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useTheme } from 'vuetify' import { useTheme } from 'vuetify'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useNotifications } from '@/composables/useNotifications' import { useNotifications } from '@/composables/useNotifications'
import { aiCategorizationApi } from '@/services/aiCategorization'
import ChatPanel from '@/components/chat/ChatPanel.vue' import ChatPanel from '@/components/chat/ChatPanel.vue'
import type { AppNotification } from '@/types' import type { AppNotification } from '@/types'
@@ -143,6 +144,17 @@ const authStore = useAuthStore()
const { notifications, unreadCount, markAllAsRead, markAsRead } = useNotifications() const { notifications, unreadCount, markAllAsRead, markAsRead } = useNotifications()
const isChatEnabled = ref(false)
onMounted(async () => {
try {
const { data } = await aiCategorizationApi.getSettings()
isChatEnabled.value = data.isChatEnabled
} catch {
// Settings not configured — chat stays disabled
}
})
const isDark = computed(() => theme.global.current.value.dark) const isDark = computed(() => theme.global.current.value.dark)
const navGroups = [ const navGroups = [
+1
View File
@@ -541,6 +541,7 @@ export interface AiCategorizationSettings {
confidenceThreshold: number confidenceThreshold: number
timeoutSeconds: number timeoutSeconds: number
updatePayeeDefaults: boolean updatePayeeDefaults: boolean
isChatEnabled: boolean
systemPrompt: string | null systemPrompt: string | null
userPromptTemplate: string | null userPromptTemplate: string | null
chatContextSize: number chatContextSize: number
+82 -55
View File
@@ -4,7 +4,7 @@
<v-tab value="profile">Profile</v-tab> <v-tab value="profile">Profile</v-tab>
<v-tab value="connections">Bank Connections</v-tab> <v-tab value="connections">Bank Connections</v-tab>
<v-tab value="history">Sync History</v-tab> <v-tab value="history">Sync History</v-tab>
<v-tab value="ai">AI Categorization</v-tab> <v-tab value="ai">AI</v-tab>
</v-tabs> </v-tabs>
<v-tabs-window v-model="activeTab" class="mt-4"> <v-tabs-window v-model="activeTab" class="mt-4">
@@ -616,24 +616,16 @@
</v-card> </v-card>
</v-tabs-window-item> </v-tabs-window-item>
<!-- Tab 4: AI Categorization --> <!-- Tab 4: AI -->
<v-tabs-window-item value="ai"> <v-tabs-window-item value="ai">
<v-alert type="info" variant="tonal" class="mb-4"> <v-alert type="info" variant="tonal" class="mb-4">
AI Categorization uses a local Ollama LLM to automatically categorize transactions when payee matching doesn't produce a category. AI features use a local Ollama LLM. Ollama runs locally on your machine — no data is sent to external services.
Ollama runs locally on your machine — no data is sent to external services.
</v-alert> </v-alert>
<v-card> <!-- Card 1: AI Configuration -->
<v-card-title>AI Categorization Settings</v-card-title> <v-card class="mb-4">
<v-card-title>AI Configuration</v-card-title>
<v-card-text> <v-card-text>
<v-switch
v-model="aiSettings.isEnabled"
label="Enable AI Categorization"
color="primary"
hide-details
class="mb-4"
/>
<v-row dense> <v-row dense>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-text-field <v-text-field
@@ -656,6 +648,50 @@
</v-col> </v-col>
</v-row> </v-row>
<v-btn
variant="tonal"
color="secondary"
:loading="aiTesting"
@click="testAiConnection"
>
Test Connection
</v-btn>
<v-alert
v-if="aiTestResult"
:type="aiTestResult.success ? 'success' : 'error'"
variant="tonal"
class="mt-4"
closable
@click:close="aiTestResult = null"
>
<template v-if="aiTestResult.success">
Connected to Ollama. Available models: {{ aiTestResult.availableModels?.join(', ') || 'none' }}
</template>
<template v-else>
Connection failed: {{ aiTestResult.errorMessage }}
</template>
</v-alert>
</v-card-text>
</v-card>
<!-- Card 2: AI Categorization -->
<v-card class="mb-4">
<v-card-title>AI Categorization</v-card-title>
<v-card-text>
<v-switch
v-model="aiSettings.isEnabled"
label="Enable AI Categorization"
color="primary"
hide-details
class="mb-4"
:disabled="!isOllamaConfigured"
/>
<v-alert v-if="!isOllamaConfigured" type="warning" variant="tonal" class="mb-4">
Configure Ollama connection above to enable AI features
</v-alert>
<v-row dense> <v-row dense>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<div class="text-body-2 mb-1">Confidence Threshold: {{ aiSettings.confidenceThreshold.toFixed(2) }}</div> <div class="text-body-2 mb-1">Confidence Threshold: {{ aiSettings.confidenceThreshold.toFixed(2) }}</div>
@@ -690,45 +726,9 @@
class="mb-4" class="mb-4"
/> />
<div class="d-flex ga-2"> <v-divider class="mb-4" />
<v-btn
color="primary"
:loading="aiSaving"
@click="saveAiSettings"
>
Save
</v-btn>
<v-btn
variant="tonal"
color="secondary"
:loading="aiTesting"
@click="testAiConnection"
>
Test Connection
</v-btn>
</div>
<v-alert <div class="text-subtitle-2 mb-2">Prompt Templates</div>
v-if="aiTestResult"
:type="aiTestResult.success ? 'success' : 'error'"
variant="tonal"
class="mt-4"
closable
@click:close="aiTestResult = null"
>
<template v-if="aiTestResult.success">
Connected to Ollama. Available models: {{ aiTestResult.availableModels?.join(', ') || 'none' }}
</template>
<template v-else>
Connection failed: {{ aiTestResult.errorMessage }}
</template>
</v-alert>
</v-card-text>
</v-card>
<v-card class="mt-4">
<v-card-title>Prompt Templates</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-4"> <p class="text-body-2 text-medium-emphasis mb-4">
Customize the prompts sent to the AI model. Use placeholders to control where dynamic data is inserted: Customize the prompts sent to the AI model. Use placeholders to control where dynamic data is inserted:
<strong>{categories}</strong> = category list, <strong>{categories}</strong> = category list,
@@ -762,9 +762,23 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<v-card class="mt-4"> <!-- Card 3: AI Chat -->
<v-card-title>AI Chat Settings</v-card-title> <v-card class="mb-4">
<v-card-title>AI Chat</v-card-title>
<v-card-text> <v-card-text>
<v-switch
v-model="aiSettings.isChatEnabled"
label="Enable AI Chat"
color="primary"
hide-details
class="mb-4"
:disabled="!isOllamaConfigured"
/>
<v-alert v-if="!isOllamaConfigured" type="warning" variant="tonal" class="mb-4">
Configure Ollama connection above to enable AI features
</v-alert>
<div class="text-body-2 mb-1">Context Size: {{ aiSettings.chatContextSize.toLocaleString() }} tokens</div> <div class="text-body-2 mb-1">Context Size: {{ aiSettings.chatContextSize.toLocaleString() }} tokens</div>
<v-slider <v-slider
v-model="aiSettings.chatContextSize" v-model="aiSettings.chatContextSize"
@@ -793,8 +807,16 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<v-btn
color="primary"
:loading="aiSaving"
@click="saveAiSettings"
>
Save AI Settings
</v-btn>
<v-snackbar v-model="showAiSuccess" :timeout="4000" color="success"> <v-snackbar v-model="showAiSuccess" :timeout="4000" color="success">
AI categorization settings saved AI settings saved successfully
</v-snackbar> </v-snackbar>
<v-snackbar v-model="showAiError" :timeout="6000" color="error"> <v-snackbar v-model="showAiError" :timeout="6000" color="error">
{{ aiErrorMessage }} {{ aiErrorMessage }}
@@ -1335,11 +1357,16 @@ const aiSettings = reactive<AiCategorizationSettings>({
confidenceThreshold: 0.7, confidenceThreshold: 0.7,
timeoutSeconds: 30, timeoutSeconds: 30,
updatePayeeDefaults: true, updatePayeeDefaults: true,
isChatEnabled: false,
systemPrompt: null, systemPrompt: null,
userPromptTemplate: null, userPromptTemplate: null,
chatContextSize: 16384, chatContextSize: 16384,
chatBotName: null chatBotName: null
}) })
const isOllamaConfigured = computed(() =>
!!aiSettings.ollamaUrl?.trim() && !!aiSettings.modelName?.trim()
)
const aiSaving = ref(false) const aiSaving = ref(false)
const aiTesting = ref(false) const aiTesting = ref(false)
const aiTestResult = ref<OllamaConnectionTestResult | null>(null) const aiTestResult = ref<OllamaConnectionTestResult | null>(null)
@@ -67,13 +67,13 @@ 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, 16384, null); false, "http://localhost:11434", "llama3.1:8b", 0.7, 30, true, false, 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.IsChatEnabled, settings.SystemPrompt, settings.UserPromptTemplate,
settings.ChatContextSize, settings.ChatBotName); settings.ChatContextSize, settings.ChatBotName);
} }
@@ -97,6 +97,7 @@ public class AiCategorizationService : IAiCategorizationService
settings.ConfidenceThreshold = request.ConfidenceThreshold; settings.ConfidenceThreshold = request.ConfidenceThreshold;
settings.TimeoutSeconds = request.TimeoutSeconds; settings.TimeoutSeconds = request.TimeoutSeconds;
settings.UpdatePayeeDefaults = request.UpdatePayeeDefaults; settings.UpdatePayeeDefaults = request.UpdatePayeeDefaults;
settings.IsChatEnabled = request.IsChatEnabled;
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.ChatContextSize = request.ChatContextSize;
@@ -108,7 +109,7 @@ 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.IsChatEnabled, settings.SystemPrompt, settings.UserPromptTemplate,
settings.ChatContextSize, settings.ChatBotName); settings.ChatContextSize, settings.ChatBotName);
} }
@@ -10,6 +10,7 @@ public record AiCategorizationSettingsResponse(
double ConfidenceThreshold, double ConfidenceThreshold,
int TimeoutSeconds, int TimeoutSeconds,
bool UpdatePayeeDefaults, bool UpdatePayeeDefaults,
bool IsChatEnabled,
string? SystemPrompt, string? SystemPrompt,
string? UserPromptTemplate, string? UserPromptTemplate,
int ChatContextSize, int ChatContextSize,
@@ -22,6 +23,7 @@ public record UpdateAiCategorizationSettingsRequest(
double ConfidenceThreshold, double ConfidenceThreshold,
int TimeoutSeconds, int TimeoutSeconds,
bool UpdatePayeeDefaults, bool UpdatePayeeDefaults,
bool IsChatEnabled,
string? SystemPrompt, string? SystemPrompt,
string? UserPromptTemplate, string? UserPromptTemplate,
int ChatContextSize, int ChatContextSize,
@@ -10,6 +10,7 @@ public class AiCategorizationSettings
public double ConfidenceThreshold { get; set; } = 0.7; public double ConfidenceThreshold { get; set; } = 0.7;
public int TimeoutSeconds { get; set; } = 30; public int TimeoutSeconds { get; set; } = 30;
public bool UpdatePayeeDefaults { get; set; } = true; public bool UpdatePayeeDefaults { get; set; } = true;
public bool IsChatEnabled { get; set; }
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 int ChatContextSize { get; set; } = 16384;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Purrse.Data.Migrations
{
/// <inheritdoc />
public partial class AddIsChatEnabled : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsChatEnabled",
table: "ai_categorization_settings",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsChatEnabled",
table: "ai_categorization_settings");
}
}
}
@@ -101,6 +101,9 @@ namespace Purrse.Data.Migrations
b.Property<double>("ConfidenceThreshold") b.Property<double>("ConfidenceThreshold")
.HasColumnType("double precision"); .HasColumnType("double precision");
b.Property<bool>("IsChatEnabled")
.HasColumnType("boolean");
b.Property<bool>("IsEnabled") b.Property<bool>("IsEnabled")
.HasColumnType("boolean"); .HasColumnType("boolean");