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:
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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 } },
|
||||
]
|
||||
|
||||
@@ -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}`),
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user