Private
Public Access
1
0

Add AI chat with Ollama tool calling for financial queries

Conversational AI chat that uses Ollama's native tool-calling API to query
transactions, spending reports, account balances, dashboard summaries, and
update transaction categories through natural language. Includes persistent
conversation history, a full-page chat UI with sidebar, and rich inline
rendering of tool results (tables, alerts, cards).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-09 23:34:16 -05:00
parent 1ceeca0fb6
commit 846edad2a4
20 changed files with 3237 additions and 0 deletions
@@ -150,6 +150,7 @@ const navItems = [
{ title: 'Loans', icon: 'mdi-home-city', route: '/loans' },
{ title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' },
{ title: 'Investments', icon: 'mdi-finance', route: '/investments' },
{ title: 'AI Chat', icon: 'mdi-robot-outline', route: '/chat' },
{ title: 'Plugins', icon: 'mdi-puzzle', route: '/plugins' },
{ title: 'Settings', icon: 'mdi-cog', route: '/settings' },
]
+1
View File
@@ -17,6 +17,7 @@ const routes = [
{ path: '/loans/:id?', name: 'loans', component: () => import('@/views/loans/LoansView.vue'), meta: { auth: true }, props: true },
{ path: '/reports', name: 'reports', component: () => import('@/views/reports/ReportsView.vue'), meta: { auth: true } },
{ path: '/investments', name: 'investments', component: () => import('@/views/investments/InvestmentsView.vue'), meta: { auth: true } },
{ path: '/chat', name: 'chat', component: () => import('@/views/chat/ChatView.vue'), meta: { auth: true } },
{ path: '/plugins', name: 'plugins', component: () => import('@/views/plugins/PluginsView.vue'), meta: { auth: true } },
{ path: '/settings', name: 'settings', component: () => import('@/views/settings/SettingsView.vue'), meta: { auth: true } },
]
+16
View File
@@ -0,0 +1,16 @@
import api from './api'
import type { ChatConversation, ChatConversationDetail, SendChatMessageResponse } from '@/types'
export const chatApi = {
sendMessage: (data: { conversationId?: string; message: string }) =>
api.post<SendChatMessageResponse>('/chat/send', data),
getConversations: () =>
api.get<ChatConversation[]>('/chat/conversations'),
getConversation: (id: string) =>
api.get<ChatConversationDetail>(`/chat/conversations/${id}`),
deleteConversation: (id: string) =>
api.delete(`/chat/conversations/${id}`),
}
+30
View File
@@ -528,6 +528,36 @@ export interface ClassifyUncategorizedResult {
skipped: number
}
// AI Chat
export interface ChatConversation {
id: string
title: string | null
createdAt: string
updatedAt: string
}
export interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'tool'
content: string
toolResultType: string | null
toolResultJson: string | null
createdAt: string
}
export interface ChatConversationDetail {
id: string
title: string | null
messages: ChatMessage[]
createdAt: string
updatedAt: string
}
export interface SendChatMessageResponse {
conversationId: string
assistantMessage: ChatMessage
}
// Split form helper
export interface SplitFormItem {
categoryId: string | null
@@ -0,0 +1,31 @@
<template>
<div v-if="parsed" class="mb-2">
<!-- Multiple results -->
<template v-if="Array.isArray(parsed)">
<div v-for="(item, idx) in parsed" :key="idx">
<ChatToolResultItem :type="item.type" :data="item.data" />
</div>
</template>
<!-- Single result -->
<template v-else>
<ChatToolResultItem :type="parsed.type" :data="parsed.data" />
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ChatToolResultItem from './ChatToolResultItem.vue'
const props = defineProps<{
toolResultJson: string
}>()
const parsed = computed(() => {
try {
return JSON.parse(props.toolResultJson)
} catch {
return null
}
})
</script>
@@ -0,0 +1,139 @@
<template>
<!-- Transactions -->
<div v-if="type === 'transactions' && data?.items">
<v-table density="compact" class="text-caption rounded-lg mb-2">
<thead>
<tr>
<th>Date</th>
<th>Payee</th>
<th class="text-right">Amount</th>
<th>Category</th>
</tr>
</thead>
<tbody>
<tr v-for="txn in data.items.slice(0, 15)" :key="txn.id">
<td class="text-no-wrap">{{ formatDate(txn.date) }}</td>
<td>{{ txn.payeeName || 'Unknown' }}</td>
<td class="text-right text-no-wrap" :class="txn.amount >= 0 ? 'text-success' : 'text-error'">
{{ formatCurrency(txn.amount) }}
</td>
<td>{{ txn.categoryName || 'Uncategorized' }}</td>
</tr>
</tbody>
</v-table>
<div v-if="data.totalCount > 15" class="text-caption text-medium-emphasis ml-2">
Showing 15 of {{ data.totalCount }} transactions
</div>
</div>
<!-- Spending Report -->
<div v-else-if="type === 'spending_report'">
<div class="text-subtitle-2 mb-1">
Total Spending: {{ formatCurrency(data.totalSpending) }}
</div>
<v-table density="compact" class="text-caption rounded-lg mb-2">
<thead>
<tr>
<th>Category</th>
<th class="text-right">Amount</th>
<th class="text-right">%</th>
</tr>
</thead>
<tbody>
<tr v-for="cat in data.categories?.slice(0, 15)" :key="cat.categoryName">
<td>{{ cat.categoryName }}</td>
<td class="text-right">{{ formatCurrency(cat.amount) }}</td>
<td class="text-right">{{ cat.percentage.toFixed(1) }}%</td>
</tr>
</tbody>
</v-table>
</div>
<!-- Income vs Expense -->
<div v-else-if="type === 'income_expense'">
<v-table density="compact" class="text-caption rounded-lg mb-2">
<thead>
<tr>
<th>Month</th>
<th class="text-right">Income</th>
<th class="text-right">Expenses</th>
<th class="text-right">Net</th>
</tr>
</thead>
<tbody>
<tr v-for="month in data.months" :key="`${month.year}-${month.month}`">
<td>{{ month.year }}-{{ String(month.month).padStart(2, '0') }}</td>
<td class="text-right text-success">{{ formatCurrency(month.income) }}</td>
<td class="text-right text-error">{{ formatCurrency(month.expenses) }}</td>
<td class="text-right" :class="month.net >= 0 ? 'text-success' : 'text-error'">
{{ formatCurrency(month.net) }}
</td>
</tr>
</tbody>
</v-table>
</div>
<!-- Accounts -->
<div v-else-if="type === 'accounts' && Array.isArray(data)">
<v-table density="compact" class="text-caption rounded-lg mb-2">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th class="text-right">Balance</th>
</tr>
</thead>
<tbody>
<tr v-for="acct in data" :key="acct.id">
<td>{{ acct.name }}</td>
<td>{{ acct.type }}</td>
<td class="text-right" :class="acct.balance >= 0 ? 'text-success' : 'text-error'">
{{ formatCurrency(acct.balance) }}
</td>
</tr>
</tbody>
</v-table>
</div>
<!-- Dashboard -->
<div v-else-if="type === 'dashboard'">
<v-card variant="tonal" density="compact" class="mb-2 pa-3">
<div class="d-flex justify-space-between mb-1">
<span class="text-caption text-medium-emphasis">Net Worth</span>
<span class="text-subtitle-2 font-weight-bold">{{ formatCurrency(data.netWorth) }}</span>
</div>
<div class="d-flex justify-space-between mb-1">
<span class="text-caption text-medium-emphasis">Assets</span>
<span class="text-caption text-success">{{ formatCurrency(data.totalAssets) }}</span>
</div>
<div class="d-flex justify-space-between">
<span class="text-caption text-medium-emphasis">Liabilities</span>
<span class="text-caption text-error">{{ formatCurrency(data.totalLiabilities) }}</span>
</div>
</v-card>
</div>
<!-- Category Update -->
<div v-else-if="type === 'category_update'">
<v-alert type="success" variant="tonal" density="compact" class="mb-2 text-caption">
Updated "{{ data.payeeName || 'Transaction' }}" ({{ formatDate(data.date) }}, {{ formatCurrency(data.amount) }})
from "{{ data.oldCategory }}" to "{{ data.newCategory }}"
</v-alert>
</div>
</template>
<script setup lang="ts">
defineProps<{
type: string
data: any
}>()
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value)
}
function formatDate(value: string): string {
if (!value) return ''
return new Date(value).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
</script>
+267
View File
@@ -0,0 +1,267 @@
<template>
<div class="d-flex" style="height: calc(100vh - 64px); margin: -24px;">
<!-- Conversation Sidebar -->
<v-navigation-drawer permanent :width="280" location="left" class="border-e">
<div class="pa-3">
<v-btn block color="primary" prepend-icon="mdi-plus" @click="startNewChat">
New Chat
</v-btn>
</div>
<v-divider />
<v-list density="compact" nav class="pa-2">
<v-list-item
v-for="conv in conversations"
:key="conv.id"
:active="conv.id === activeConversationId"
rounded="lg"
@click="loadConversation(conv.id)"
class="mb-1"
>
<v-list-item-title class="text-body-2 text-truncate">
{{ conv.title || 'Untitled' }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption">
{{ formatRelativeDate(conv.updatedAt) }}
</v-list-item-subtitle>
<template v-slot:append>
<v-btn
icon="mdi-delete-outline"
size="x-small"
variant="text"
@click.stop="deleteConversation(conv.id)"
/>
</template>
</v-list-item>
<v-list-item v-if="conversations.length === 0" class="text-medium-emphasis text-center">
<v-list-item-title class="text-caption">No conversations yet</v-list-item-title>
</v-list-item>
</v-list>
</v-navigation-drawer>
<!-- Chat Area -->
<div class="d-flex flex-column flex-grow-1" style="min-width: 0;">
<!-- Messages -->
<div ref="messagesContainer" class="flex-grow-1 overflow-y-auto pa-4" style="min-height: 0;">
<!-- Empty state -->
<div v-if="messages.length === 0 && !loading" class="d-flex flex-column align-center justify-center" style="height: 100%;">
<v-icon size="64" color="primary" class="mb-4">mdi-robot-outline</v-icon>
<h2 class="text-h5 mb-2">Purrse AI Chat</h2>
<p class="text-body-2 text-medium-emphasis mb-6">Ask me about your finances</p>
<div class="d-flex flex-wrap justify-center ga-2" style="max-width: 600px;">
<v-chip
v-for="suggestion in suggestions"
:key="suggestion"
variant="tonal"
@click="sendSuggestion(suggestion)"
class="cursor-pointer"
>
{{ suggestion }}
</v-chip>
</div>
</div>
<!-- Messages list -->
<div v-else class="mx-auto" style="max-width: 800px;">
<div v-for="msg in messages" :key="msg.id" class="mb-4">
<!-- User message -->
<div v-if="msg.role === 'user'" class="d-flex justify-end">
<v-card color="primary" variant="flat" class="pa-3 rounded-lg" max-width="85%">
<div class="text-body-2" style="white-space: pre-wrap;">{{ msg.content }}</div>
</v-card>
</div>
<!-- Assistant message -->
<div v-else-if="msg.role === 'assistant'" class="d-flex justify-start">
<div style="max-width: 85%;">
<ChatToolResult
v-if="msg.toolResultJson"
:tool-result-json="msg.toolResultJson"
/>
<v-card variant="tonal" class="pa-3 rounded-lg">
<div class="text-body-2" style="white-space: pre-wrap;">{{ msg.content }}</div>
</v-card>
</div>
</div>
</div>
<!-- Typing indicator -->
<div v-if="loading" class="d-flex justify-start mb-4">
<v-card variant="tonal" class="pa-3 rounded-lg">
<div class="d-flex align-center ga-2">
<v-progress-circular indeterminate size="16" width="2" />
<span class="text-body-2 text-medium-emphasis">Thinking...</span>
</div>
</v-card>
</div>
</div>
</div>
<!-- Input Area -->
<div class="pa-4 border-t">
<div class="mx-auto" style="max-width: 800px;">
<v-textarea
v-model="inputMessage"
placeholder="Ask about your finances..."
variant="outlined"
density="comfortable"
rows="1"
max-rows="4"
auto-grow
hide-details
:disabled="loading"
@keydown.enter.exact.prevent="sendMessage"
>
<template v-slot:append-inner>
<v-btn
icon="mdi-send"
size="small"
variant="flat"
color="primary"
:disabled="!inputMessage.trim() || loading"
@click="sendMessage"
/>
</template>
</v-textarea>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { chatApi } from '@/services/chat'
import ChatToolResult from './ChatToolResult.vue'
import type { ChatConversation, ChatMessage } from '@/types'
const conversations = ref<ChatConversation[]>([])
const messages = ref<ChatMessage[]>([])
const activeConversationId = ref<string | null>(null)
const inputMessage = ref('')
const loading = ref(false)
const messagesContainer = ref<HTMLElement | null>(null)
const suggestions = [
'What are my account balances?',
'Show my spending this month',
'What did I spend on groceries?',
'Income vs expenses last 6 months',
'Show my dashboard summary',
]
onMounted(async () => {
await loadConversations()
})
async function loadConversations() {
try {
const { data } = await chatApi.getConversations()
conversations.value = data
} catch (err) {
console.error('Failed to load conversations', err)
}
}
async function loadConversation(id: string) {
try {
activeConversationId.value = id
const { data } = await chatApi.getConversation(id)
messages.value = data.messages
await scrollToBottom()
} catch (err) {
console.error('Failed to load conversation', err)
}
}
function startNewChat() {
activeConversationId.value = null
messages.value = []
inputMessage.value = ''
}
async function sendSuggestion(text: string) {
inputMessage.value = text
await sendMessage()
}
async function sendMessage() {
const text = inputMessage.value.trim()
if (!text || loading.value) return
// Add user message optimistically
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: text,
toolResultType: null,
toolResultJson: null,
createdAt: new Date().toISOString(),
}
messages.value.push(userMsg)
inputMessage.value = ''
loading.value = true
await scrollToBottom()
try {
const { data } = await chatApi.sendMessage({
conversationId: activeConversationId.value ?? undefined,
message: text,
})
// Update conversation ID for new conversations
if (!activeConversationId.value) {
activeConversationId.value = data.conversationId
await loadConversations()
}
// Add assistant message
messages.value.push(data.assistantMessage)
} catch (err: any) {
messages.value.push({
id: crypto.randomUUID(),
role: 'assistant',
content: err.response?.data?.message || 'Sorry, something went wrong. Please check that Ollama is running and try again.',
toolResultType: null,
toolResultJson: null,
createdAt: new Date().toISOString(),
})
} finally {
loading.value = false
await scrollToBottom()
}
}
async function deleteConversation(id: string) {
try {
await chatApi.deleteConversation(id)
conversations.value = conversations.value.filter(c => c.id !== id)
if (activeConversationId.value === id) {
startNewChat()
}
} catch (err) {
console.error('Failed to delete conversation', err)
}
}
async function scrollToBottom() {
await nextTick()
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
</script>