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: 'Loans', icon: 'mdi-home-city', route: '/loans' },
|
||||||
{ title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' },
|
{ title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' },
|
||||||
{ title: 'Investments', icon: 'mdi-finance', route: '/investments' },
|
{ title: 'Investments', icon: 'mdi-finance', route: '/investments' },
|
||||||
|
{ title: 'AI Chat', icon: 'mdi-robot-outline', route: '/chat' },
|
||||||
{ title: 'Plugins', icon: 'mdi-puzzle', route: '/plugins' },
|
{ title: 'Plugins', icon: 'mdi-puzzle', route: '/plugins' },
|
||||||
{ title: 'Settings', icon: 'mdi-cog', route: '/settings' },
|
{ 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: '/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: '/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: '/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: '/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 } },
|
{ 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
|
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
|
// Split form helper
|
||||||
export interface SplitFormItem {
|
export interface SplitFormItem {
|
||||||
categoryId: string | null
|
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>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class ChatController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IChatService _chatService;
|
||||||
|
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
|
public ChatController(IChatService chatService) => _chatService = chatService;
|
||||||
|
|
||||||
|
[HttpPost("send")]
|
||||||
|
public async Task<ActionResult<SendChatMessageResponse>> SendMessage(SendChatMessageRequest request)
|
||||||
|
=> Ok(await _chatService.SendMessageAsync(UserId, request));
|
||||||
|
|
||||||
|
[HttpGet("conversations")]
|
||||||
|
public async Task<ActionResult<List<ChatConversationResponse>>> GetConversations()
|
||||||
|
=> Ok(await _chatService.GetConversationsAsync(UserId));
|
||||||
|
|
||||||
|
[HttpGet("conversations/{id}")]
|
||||||
|
public async Task<ActionResult<ChatConversationDetailResponse>> GetConversation(Guid id)
|
||||||
|
{
|
||||||
|
var conversation = await _chatService.GetConversationAsync(UserId, id);
|
||||||
|
return conversation == null ? NotFound() : Ok(conversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("conversations/{id}")]
|
||||||
|
public async Task<ActionResult> DeleteConversation(Guid id)
|
||||||
|
{
|
||||||
|
await _chatService.DeleteConversationAsync(UserId, id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,7 @@ builder.Services.AddScoped<IDuplicateDetectionService, DuplicateDetectionService
|
|||||||
builder.Services.AddScoped<IEncryptionService, EncryptionService>();
|
builder.Services.AddScoped<IEncryptionService, EncryptionService>();
|
||||||
builder.Services.AddScoped<IAiCategorizationService, AiCategorizationService>();
|
builder.Services.AddScoped<IAiCategorizationService, AiCategorizationService>();
|
||||||
builder.Services.AddScoped<IBankSyncService, BankSyncService>();
|
builder.Services.AddScoped<IBankSyncService, BankSyncService>();
|
||||||
|
builder.Services.AddScoped<IChatService, ChatService>();
|
||||||
builder.Services.AddHttpClient("Ollama");
|
builder.Services.AddHttpClient("Ollama");
|
||||||
|
|
||||||
// Built-in file parsers
|
// Built-in file parsers
|
||||||
|
|||||||
@@ -0,0 +1,756 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Purrse.Core.DTOs;
|
||||||
|
using Purrse.Core.Interfaces.Services;
|
||||||
|
using Purrse.Core.Models;
|
||||||
|
using Purrse.Data;
|
||||||
|
|
||||||
|
namespace Purrse.Api.Services;
|
||||||
|
|
||||||
|
public class ChatService : IChatService
|
||||||
|
{
|
||||||
|
private readonly PurrseDbContext _db;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly ITransactionService _transactionService;
|
||||||
|
private readonly ICategoryService _categoryService;
|
||||||
|
private readonly IAccountService _accountService;
|
||||||
|
private readonly IReportService _reportService;
|
||||||
|
private readonly IDashboardService _dashboardService;
|
||||||
|
private readonly ILogger<ChatService> _logger;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions OllamaJsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions CamelCaseOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string SystemPromptTemplate =
|
||||||
|
"""
|
||||||
|
You are a helpful personal finance assistant for Purrse.
|
||||||
|
You can search transactions, view spending reports, check account balances, and update transaction categories.
|
||||||
|
When the user asks about their finances, use the available tools to look up real data.
|
||||||
|
Always present financial amounts formatted as currency.
|
||||||
|
When showing transactions, include the date, payee, amount, and category.
|
||||||
|
Today's date is {0}.
|
||||||
|
""";
|
||||||
|
|
||||||
|
public ChatService(
|
||||||
|
PurrseDbContext db,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ITransactionService transactionService,
|
||||||
|
ICategoryService categoryService,
|
||||||
|
IAccountService accountService,
|
||||||
|
IReportService reportService,
|
||||||
|
IDashboardService dashboardService,
|
||||||
|
ILogger<ChatService> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_transactionService = transactionService;
|
||||||
|
_categoryService = categoryService;
|
||||||
|
_accountService = accountService;
|
||||||
|
_reportService = reportService;
|
||||||
|
_dashboardService = dashboardService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SendChatMessageResponse> SendMessageAsync(Guid userId, SendChatMessageRequest request)
|
||||||
|
{
|
||||||
|
// Get or create conversation
|
||||||
|
ChatConversation conversation;
|
||||||
|
if (request.ConversationId.HasValue)
|
||||||
|
{
|
||||||
|
conversation = await _db.ChatConversations
|
||||||
|
.Include(c => c.Messages.OrderBy(m => m.CreatedAt))
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == request.ConversationId.Value && c.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Conversation not found");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
conversation = new ChatConversation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
Title = request.Message.Length > 50 ? request.Message[..50] + "..." : request.Message,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
_db.ChatConversations.Add(conversation);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Reload with empty messages collection
|
||||||
|
conversation.Messages = new List<ChatMessage>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load AI settings
|
||||||
|
var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId);
|
||||||
|
var ollamaUrl = settings?.OllamaUrl ?? "http://localhost:11434";
|
||||||
|
var modelName = settings?.ModelName ?? "llama3.1:8b";
|
||||||
|
|
||||||
|
// Save user message
|
||||||
|
var userMessage = new ChatMessage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ConversationId = conversation.Id,
|
||||||
|
Role = "user",
|
||||||
|
Content = request.Message,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
_db.ChatMessages.Add(userMessage);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Build Ollama message list
|
||||||
|
var ollamaMessages = new List<OllamaToolMessage>();
|
||||||
|
|
||||||
|
// System prompt
|
||||||
|
ollamaMessages.Add(new OllamaToolMessage
|
||||||
|
{
|
||||||
|
Role = "system",
|
||||||
|
Content = string.Format(SystemPromptTemplate, DateTime.UtcNow.ToString("yyyy-MM-dd"))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Last 20 conversation messages
|
||||||
|
var recentMessages = conversation.Messages
|
||||||
|
.OrderBy(m => m.CreatedAt)
|
||||||
|
.TakeLast(20)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var msg in recentMessages)
|
||||||
|
{
|
||||||
|
var ollamaMsg = new OllamaToolMessage { Role = msg.Role, Content = msg.Content };
|
||||||
|
if (msg.ToolCallsJson != null)
|
||||||
|
{
|
||||||
|
ollamaMsg.ToolCalls = JsonSerializer.Deserialize<List<OllamaToolCall>>(msg.ToolCallsJson, OllamaJsonOptions);
|
||||||
|
}
|
||||||
|
ollamaMessages.Add(ollamaMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current user message
|
||||||
|
ollamaMessages.Add(new OllamaToolMessage { Role = "user", Content = request.Message });
|
||||||
|
|
||||||
|
// Build tool definitions
|
||||||
|
var tools = BuildToolDefinitions();
|
||||||
|
|
||||||
|
// Collect tool results from this turn
|
||||||
|
var toolResults = new List<(string type, string json)>();
|
||||||
|
|
||||||
|
// Tool-call loop (max 5 iterations)
|
||||||
|
ChatMessage? assistantDbMessage = null;
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
var ollamaRequest = new OllamaToolChatRequest
|
||||||
|
{
|
||||||
|
Model = modelName,
|
||||||
|
Messages = ollamaMessages,
|
||||||
|
Stream = false,
|
||||||
|
Tools = tools
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await CallOllamaAsync(ollamaUrl, ollamaRequest);
|
||||||
|
|
||||||
|
if (response?.Message?.ToolCalls != null && response.Message.ToolCalls.Count > 0)
|
||||||
|
{
|
||||||
|
// Save assistant message with tool calls
|
||||||
|
var assistantToolMsg = new ChatMessage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ConversationId = conversation.Id,
|
||||||
|
Role = "assistant",
|
||||||
|
Content = response.Message.Content ?? string.Empty,
|
||||||
|
ToolCallsJson = JsonSerializer.Serialize(response.Message.ToolCalls, OllamaJsonOptions),
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
_db.ChatMessages.Add(assistantToolMsg);
|
||||||
|
|
||||||
|
// Add assistant message to Ollama context
|
||||||
|
ollamaMessages.Add(new OllamaToolMessage
|
||||||
|
{
|
||||||
|
Role = "assistant",
|
||||||
|
Content = response.Message.Content ?? string.Empty,
|
||||||
|
ToolCalls = response.Message.ToolCalls
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute each tool call
|
||||||
|
foreach (var toolCall in response.Message.ToolCalls)
|
||||||
|
{
|
||||||
|
var (toolResultType, data, summaryForLlm) = await ExecuteToolAsync(userId, toolCall);
|
||||||
|
|
||||||
|
var toolResultJsonObj = JsonSerializer.Serialize(new { type = toolResultType, data }, CamelCaseOptions);
|
||||||
|
toolResults.Add((toolResultType, toolResultJsonObj));
|
||||||
|
|
||||||
|
// Save tool message
|
||||||
|
var toolMsg = new ChatMessage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ConversationId = conversation.Id,
|
||||||
|
Role = "tool",
|
||||||
|
Content = summaryForLlm,
|
||||||
|
ToolResultJson = toolResultJsonObj,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
_db.ChatMessages.Add(toolMsg);
|
||||||
|
|
||||||
|
// Add tool result to Ollama context
|
||||||
|
ollamaMessages.Add(new OllamaToolMessage
|
||||||
|
{
|
||||||
|
Role = "tool",
|
||||||
|
Content = summaryForLlm
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No tool calls - save final assistant message
|
||||||
|
var content = response?.Message?.Content ?? "I'm sorry, I couldn't process that request.";
|
||||||
|
assistantDbMessage = new ChatMessage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ConversationId = conversation.Id,
|
||||||
|
Role = "assistant",
|
||||||
|
Content = content,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aggregate tool results onto the final assistant message
|
||||||
|
if (toolResults.Count > 0)
|
||||||
|
{
|
||||||
|
assistantDbMessage.ToolResultJson = toolResults.Count == 1
|
||||||
|
? toolResults[0].json
|
||||||
|
: JsonSerializer.Serialize(toolResults.Select(r => JsonSerializer.Deserialize<object>(r.json)).ToList(), CamelCaseOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.ChatMessages.Add(assistantDbMessage);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we exhausted the loop without a final message, create one
|
||||||
|
if (assistantDbMessage == null)
|
||||||
|
{
|
||||||
|
assistantDbMessage = new ChatMessage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ConversationId = conversation.Id,
|
||||||
|
Role = "assistant",
|
||||||
|
Content = "I've gathered the information. Let me know if you need anything else.",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
if (toolResults.Count > 0)
|
||||||
|
{
|
||||||
|
assistantDbMessage.ToolResultJson = toolResults.Count == 1
|
||||||
|
? toolResults[0].json
|
||||||
|
: JsonSerializer.Serialize(toolResults.Select(r => JsonSerializer.Deserialize<object>(r.json)).ToList(), CamelCaseOptions);
|
||||||
|
}
|
||||||
|
_db.ChatMessages.Add(assistantDbMessage);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update conversation timestamp
|
||||||
|
conversation.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Determine tool result type for the response
|
||||||
|
string? resultType = null;
|
||||||
|
string? resultJson = null;
|
||||||
|
if (toolResults.Count > 0)
|
||||||
|
{
|
||||||
|
resultType = toolResults.Count == 1 ? toolResults[0].type : "multiple";
|
||||||
|
resultJson = assistantDbMessage.ToolResultJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SendChatMessageResponse(
|
||||||
|
conversation.Id,
|
||||||
|
new ChatMessageResponse(
|
||||||
|
assistantDbMessage.Id,
|
||||||
|
assistantDbMessage.Role,
|
||||||
|
assistantDbMessage.Content,
|
||||||
|
resultType,
|
||||||
|
resultJson,
|
||||||
|
assistantDbMessage.CreatedAt
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ChatConversationResponse>> GetConversationsAsync(Guid userId)
|
||||||
|
{
|
||||||
|
return await _db.ChatConversations
|
||||||
|
.Where(c => c.UserId == userId)
|
||||||
|
.OrderByDescending(c => c.UpdatedAt)
|
||||||
|
.Select(c => new ChatConversationResponse(c.Id, c.Title, c.CreatedAt, c.UpdatedAt))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChatConversationDetailResponse?> GetConversationAsync(Guid userId, Guid conversationId)
|
||||||
|
{
|
||||||
|
var conversation = await _db.ChatConversations
|
||||||
|
.Include(c => c.Messages.OrderBy(m => m.CreatedAt))
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == conversationId && c.UserId == userId);
|
||||||
|
|
||||||
|
if (conversation == null) return null;
|
||||||
|
|
||||||
|
// Return only user and assistant messages
|
||||||
|
// Aggregate tool results from preceding tool messages onto assistant messages
|
||||||
|
var messages = new List<ChatMessageResponse>();
|
||||||
|
var pendingToolResults = new List<(string type, string json)>();
|
||||||
|
|
||||||
|
foreach (var msg in conversation.Messages.OrderBy(m => m.CreatedAt))
|
||||||
|
{
|
||||||
|
if (msg.Role == "tool")
|
||||||
|
{
|
||||||
|
if (msg.ToolResultJson != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsed = JsonSerializer.Deserialize<JsonElement>(msg.ToolResultJson);
|
||||||
|
var type = parsed.TryGetProperty("type", out var t) ? t.GetString() ?? "unknown" : "unknown";
|
||||||
|
pendingToolResults.Add((type, msg.ToolResultJson));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
pendingToolResults.Add(("unknown", msg.ToolResultJson));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.Role == "assistant" && msg.ToolCallsJson != null && msg.Content == string.Empty)
|
||||||
|
{
|
||||||
|
// Skip intermediate assistant messages that only contain tool calls
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? toolResultType = null;
|
||||||
|
string? toolResultJson = null;
|
||||||
|
|
||||||
|
if (msg.Role == "assistant" && pendingToolResults.Count > 0)
|
||||||
|
{
|
||||||
|
toolResultType = pendingToolResults.Count == 1 ? pendingToolResults[0].type : "multiple";
|
||||||
|
toolResultJson = pendingToolResults.Count == 1
|
||||||
|
? pendingToolResults[0].json
|
||||||
|
: JsonSerializer.Serialize(pendingToolResults.Select(r => JsonSerializer.Deserialize<object>(r.json)).ToList(), CamelCaseOptions);
|
||||||
|
pendingToolResults.Clear();
|
||||||
|
}
|
||||||
|
else if (msg.Role == "assistant" && msg.ToolResultJson != null)
|
||||||
|
{
|
||||||
|
// Use stored tool result if present
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsed = JsonSerializer.Deserialize<JsonElement>(msg.ToolResultJson);
|
||||||
|
if (parsed.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
toolResultType = "multiple";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
toolResultType = parsed.TryGetProperty("type", out var t) ? t.GetString() : "unknown";
|
||||||
|
}
|
||||||
|
toolResultJson = msg.ToolResultJson;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
toolResultJson = msg.ToolResultJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.Add(new ChatMessageResponse(
|
||||||
|
msg.Id,
|
||||||
|
msg.Role,
|
||||||
|
msg.Content,
|
||||||
|
toolResultType,
|
||||||
|
toolResultJson,
|
||||||
|
msg.CreatedAt
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ChatConversationDetailResponse(
|
||||||
|
conversation.Id,
|
||||||
|
conversation.Title,
|
||||||
|
messages,
|
||||||
|
conversation.CreatedAt,
|
||||||
|
conversation.UpdatedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteConversationAsync(Guid userId, Guid conversationId)
|
||||||
|
{
|
||||||
|
var conversation = await _db.ChatConversations
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == conversationId && c.UserId == userId)
|
||||||
|
?? throw new KeyNotFoundException("Conversation not found");
|
||||||
|
|
||||||
|
_db.ChatConversations.Remove(conversation);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OllamaToolChatResponse?> CallOllamaAsync(string ollamaUrl, OllamaToolChatRequest request)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("Ollama");
|
||||||
|
client.Timeout = TimeSpan.FromMinutes(3);
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(request, OllamaJsonOptions);
|
||||||
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
_logger.LogDebug("Sending chat request to Ollama: {Url}", $"{ollamaUrl}/api/chat");
|
||||||
|
|
||||||
|
var response = await client.PostAsync($"{ollamaUrl}/api/chat", content);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
return JsonSerializer.Deserialize<OllamaToolChatResponse>(responseBody, OllamaJsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string toolResultType, object data, string summaryForLlm)> ExecuteToolAsync(Guid userId, OllamaToolCall toolCall)
|
||||||
|
{
|
||||||
|
var name = toolCall.Function.Name;
|
||||||
|
var args = toolCall.Function.Arguments;
|
||||||
|
|
||||||
|
_logger.LogInformation("Executing tool: {ToolName} with args: {Args}", name, JsonSerializer.Serialize(args));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return name switch
|
||||||
|
{
|
||||||
|
"search_transactions" => await ExecuteSearchTransactionsAsync(userId, args),
|
||||||
|
"get_spending_by_category" => await ExecuteGetSpendingByCategoryAsync(userId, args),
|
||||||
|
"get_income_vs_expense" => await ExecuteGetIncomeVsExpenseAsync(userId, args),
|
||||||
|
"get_account_balances" => await ExecuteGetAccountBalancesAsync(userId, args),
|
||||||
|
"get_dashboard_summary" => await ExecuteGetDashboardSummaryAsync(userId),
|
||||||
|
"update_transaction_category" => await ExecuteUpdateTransactionCategoryAsync(userId, args),
|
||||||
|
_ => ("error", new { error = $"Unknown tool: {name}" }, $"Error: Unknown tool '{name}'")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Tool execution failed: {ToolName}", name);
|
||||||
|
return ("error", new { error = ex.Message }, $"Error executing {name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string, object, string)> ExecuteSearchTransactionsAsync(Guid userId, Dictionary<string, object> args)
|
||||||
|
{
|
||||||
|
Guid? categoryId = null;
|
||||||
|
var categoryName = ParseStringArg(args, "category_name");
|
||||||
|
if (categoryName != null)
|
||||||
|
{
|
||||||
|
var categories = await _categoryService.GetAllAsync(userId);
|
||||||
|
var match = categories.FirstOrDefault(c => c.Name.Equals(categoryName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (match != null) categoryId = match.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchRequest = new TransactionSearchRequest(
|
||||||
|
AccountId: null,
|
||||||
|
StartDate: ParseDateArg(args, "start_date"),
|
||||||
|
EndDate: ParseDateArg(args, "end_date"),
|
||||||
|
MinAmount: ParseDecimalArg(args, "min_amount"),
|
||||||
|
MaxAmount: ParseDecimalArg(args, "max_amount"),
|
||||||
|
PayeeName: ParseStringArg(args, "payee_name"),
|
||||||
|
CategoryId: categoryId,
|
||||||
|
Status: null,
|
||||||
|
SearchText: ParseStringArg(args, "search_text"),
|
||||||
|
Page: 1,
|
||||||
|
PageSize: Math.Min(ParseIntArg(args, "page_size") ?? 25, 25)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await _transactionService.SearchAsync(userId, searchRequest);
|
||||||
|
|
||||||
|
var summary = new StringBuilder();
|
||||||
|
summary.AppendLine($"Found {result.TotalCount} transaction(s).");
|
||||||
|
foreach (var txn in result.Items.Take(25))
|
||||||
|
{
|
||||||
|
summary.AppendLine($"- {txn.Date:yyyy-MM-dd} | {txn.PayeeName ?? "Unknown"} | {txn.Amount:C} | {txn.CategoryName ?? "Uncategorized"} (ID: {txn.Id})");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ("transactions", result, summary.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string, object, string)> ExecuteGetSpendingByCategoryAsync(Guid userId, Dictionary<string, object> args)
|
||||||
|
{
|
||||||
|
var startDate = ParseDateArg(args, "start_date") ?? new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1);
|
||||||
|
var endDate = ParseDateArg(args, "end_date") ?? DateTime.UtcNow;
|
||||||
|
|
||||||
|
var report = await _reportService.GetSpendingByCategoryAsync(userId, startDate, endDate);
|
||||||
|
|
||||||
|
var summary = new StringBuilder();
|
||||||
|
summary.AppendLine($"Spending from {startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}: Total {report.TotalSpending:C}");
|
||||||
|
foreach (var cat in report.Categories.Take(15))
|
||||||
|
{
|
||||||
|
summary.AppendLine($"- {cat.CategoryName}: {cat.Amount:C} ({cat.Percentage:F1}%)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ("spending_report", report, summary.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string, object, string)> ExecuteGetIncomeVsExpenseAsync(Guid userId, Dictionary<string, object> args)
|
||||||
|
{
|
||||||
|
var startDate = ParseDateArg(args, "start_date") ?? DateTime.UtcNow.AddMonths(-6);
|
||||||
|
var endDate = ParseDateArg(args, "end_date") ?? DateTime.UtcNow;
|
||||||
|
|
||||||
|
var report = await _reportService.GetIncomeVsExpenseAsync(userId, startDate, endDate);
|
||||||
|
|
||||||
|
var summary = new StringBuilder();
|
||||||
|
summary.AppendLine($"Income vs Expenses from {startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}:");
|
||||||
|
foreach (var month in report.Months)
|
||||||
|
{
|
||||||
|
summary.AppendLine($"- {month.Year}-{month.Month:D2}: Income {month.Income:C}, Expenses {month.Expenses:C}, Net {month.Net:C}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ("income_expense", report, summary.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string, object, string)> ExecuteGetAccountBalancesAsync(Guid userId, Dictionary<string, object> args)
|
||||||
|
{
|
||||||
|
var accounts = await _accountService.GetAllAsync(userId);
|
||||||
|
|
||||||
|
var summary = new StringBuilder();
|
||||||
|
summary.AppendLine($"You have {accounts.Count} account(s):");
|
||||||
|
foreach (var acct in accounts)
|
||||||
|
{
|
||||||
|
summary.AppendLine($"- {acct.Name} ({acct.Type}): {acct.Balance:C}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ("accounts", accounts, summary.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string, object, string)> ExecuteGetDashboardSummaryAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var dashboard = await _dashboardService.GetDashboardAsync(userId);
|
||||||
|
|
||||||
|
var summary = new StringBuilder();
|
||||||
|
summary.AppendLine($"Net Worth: {dashboard.NetWorth:C} (Assets: {dashboard.TotalAssets:C}, Liabilities: {dashboard.TotalLiabilities:C})");
|
||||||
|
summary.AppendLine($"Spending this month: {dashboard.SpendingSummary.ThisMonth:C} (last month: {dashboard.SpendingSummary.LastMonth:C})");
|
||||||
|
if (dashboard.UpcomingBills.Count > 0)
|
||||||
|
{
|
||||||
|
summary.AppendLine("Upcoming bills:");
|
||||||
|
foreach (var bill in dashboard.UpcomingBills.Take(5))
|
||||||
|
{
|
||||||
|
summary.AppendLine($"- {bill.PayeeName}: {bill.Amount:C} due {bill.DueDate:yyyy-MM-dd}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ("dashboard", dashboard, summary.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string, object, string)> ExecuteUpdateTransactionCategoryAsync(Guid userId, Dictionary<string, object> args)
|
||||||
|
{
|
||||||
|
var transactionId = ParseGuidArg(args, "transaction_id")
|
||||||
|
?? throw new ArgumentException("transaction_id is required");
|
||||||
|
|
||||||
|
var categoryName = ParseStringArg(args, "category_name")
|
||||||
|
?? throw new ArgumentException("category_name is required");
|
||||||
|
|
||||||
|
// Resolve category by name
|
||||||
|
var categories = await _categoryService.GetAllAsync(userId);
|
||||||
|
var category = categories.FirstOrDefault(c => c.Name.Equals(categoryName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? throw new ArgumentException($"Category '{categoryName}' not found");
|
||||||
|
|
||||||
|
// Get existing transaction
|
||||||
|
var existing = await _transactionService.GetByIdAsync(userId, transactionId)
|
||||||
|
?? throw new KeyNotFoundException($"Transaction not found");
|
||||||
|
|
||||||
|
// Build update request preserving all fields
|
||||||
|
var updateRequest = new UpdateTransactionRequest(
|
||||||
|
Date: existing.Date,
|
||||||
|
Amount: existing.Amount,
|
||||||
|
PayeeName: existing.PayeeName,
|
||||||
|
PayeeId: existing.PayeeId,
|
||||||
|
CategoryId: category.Id,
|
||||||
|
Memo: existing.Memo,
|
||||||
|
ReferenceNumber: existing.ReferenceNumber,
|
||||||
|
CheckNumber: existing.CheckNumber,
|
||||||
|
Type: existing.Type,
|
||||||
|
Status: existing.Status,
|
||||||
|
Splits: null
|
||||||
|
);
|
||||||
|
|
||||||
|
var updated = await _transactionService.UpdateAsync(userId, transactionId, updateRequest);
|
||||||
|
|
||||||
|
var result = new
|
||||||
|
{
|
||||||
|
transactionId = updated.Id,
|
||||||
|
payeeName = updated.PayeeName,
|
||||||
|
oldCategory = existing.CategoryName ?? "Uncategorized",
|
||||||
|
newCategory = category.Name,
|
||||||
|
amount = updated.Amount,
|
||||||
|
date = updated.Date
|
||||||
|
};
|
||||||
|
|
||||||
|
return ("category_update", result,
|
||||||
|
$"Updated transaction '{updated.PayeeName ?? "Unknown"}' ({updated.Date:yyyy-MM-dd}, {updated.Amount:C}) category from '{existing.CategoryName ?? "Uncategorized"}' to '{category.Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<OllamaTool> BuildToolDefinitions()
|
||||||
|
{
|
||||||
|
return new List<OllamaTool>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Type = "function",
|
||||||
|
Function = new OllamaFunction
|
||||||
|
{
|
||||||
|
Name = "search_transactions",
|
||||||
|
Description = "Search for transactions by date range, payee, category, amount, or text. Returns matching transactions.",
|
||||||
|
Parameters = new OllamaFunctionParameters
|
||||||
|
{
|
||||||
|
Type = "object",
|
||||||
|
Properties = new Dictionary<string, OllamaPropertySchema>
|
||||||
|
{
|
||||||
|
["start_date"] = new() { Type = "string", Description = "Start date in YYYY-MM-DD format" },
|
||||||
|
["end_date"] = new() { Type = "string", Description = "End date in YYYY-MM-DD format" },
|
||||||
|
["payee_name"] = new() { Type = "string", Description = "Payee name to filter by (partial match)" },
|
||||||
|
["category_name"] = new() { Type = "string", Description = "Category name to filter by (exact match)" },
|
||||||
|
["min_amount"] = new() { Type = "number", Description = "Minimum transaction amount" },
|
||||||
|
["max_amount"] = new() { Type = "number", Description = "Maximum transaction amount" },
|
||||||
|
["search_text"] = new() { Type = "string", Description = "General text search across payee, memo, etc." },
|
||||||
|
["page_size"] = new() { Type = "integer", Description = "Number of results to return (max 25, default 25)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Type = "function",
|
||||||
|
Function = new OllamaFunction
|
||||||
|
{
|
||||||
|
Name = "get_spending_by_category",
|
||||||
|
Description = "Get a spending breakdown by category for a date range. Shows how much was spent in each category.",
|
||||||
|
Parameters = new OllamaFunctionParameters
|
||||||
|
{
|
||||||
|
Type = "object",
|
||||||
|
Properties = new Dictionary<string, OllamaPropertySchema>
|
||||||
|
{
|
||||||
|
["start_date"] = new() { Type = "string", Description = "Start date in YYYY-MM-DD format" },
|
||||||
|
["end_date"] = new() { Type = "string", Description = "End date in YYYY-MM-DD format" }
|
||||||
|
},
|
||||||
|
Required = new List<string> { "start_date", "end_date" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Type = "function",
|
||||||
|
Function = new OllamaFunction
|
||||||
|
{
|
||||||
|
Name = "get_income_vs_expense",
|
||||||
|
Description = "Get monthly income vs expense comparison for a date range. Shows income, expenses, and net for each month.",
|
||||||
|
Parameters = new OllamaFunctionParameters
|
||||||
|
{
|
||||||
|
Type = "object",
|
||||||
|
Properties = new Dictionary<string, OllamaPropertySchema>
|
||||||
|
{
|
||||||
|
["start_date"] = new() { Type = "string", Description = "Start date in YYYY-MM-DD format" },
|
||||||
|
["end_date"] = new() { Type = "string", Description = "End date in YYYY-MM-DD format" }
|
||||||
|
},
|
||||||
|
Required = new List<string> { "start_date", "end_date" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Type = "function",
|
||||||
|
Function = new OllamaFunction
|
||||||
|
{
|
||||||
|
Name = "get_account_balances",
|
||||||
|
Description = "Get all accounts with their current balances, types, and status.",
|
||||||
|
Parameters = new OllamaFunctionParameters
|
||||||
|
{
|
||||||
|
Type = "object",
|
||||||
|
Properties = new Dictionary<string, OllamaPropertySchema>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Type = "function",
|
||||||
|
Function = new OllamaFunction
|
||||||
|
{
|
||||||
|
Name = "get_dashboard_summary",
|
||||||
|
Description = "Get a dashboard overview including net worth, assets, liabilities, recent transactions, top spending categories, and upcoming bills.",
|
||||||
|
Parameters = new OllamaFunctionParameters
|
||||||
|
{
|
||||||
|
Type = "object",
|
||||||
|
Properties = new Dictionary<string, OllamaPropertySchema>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Type = "function",
|
||||||
|
Function = new OllamaFunction
|
||||||
|
{
|
||||||
|
Name = "update_transaction_category",
|
||||||
|
Description = "Update the category of a specific transaction. Use the transaction ID from search results.",
|
||||||
|
Parameters = new OllamaFunctionParameters
|
||||||
|
{
|
||||||
|
Type = "object",
|
||||||
|
Properties = new Dictionary<string, OllamaPropertySchema>
|
||||||
|
{
|
||||||
|
["transaction_id"] = new() { Type = "string", Description = "The transaction ID (GUID) to update" },
|
||||||
|
["category_name"] = new() { Type = "string", Description = "The new category name to assign" }
|
||||||
|
},
|
||||||
|
Required = new List<string> { "transaction_id", "category_name" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Argument parsing helpers
|
||||||
|
private static string? ParseStringArg(Dictionary<string, object> args, string key)
|
||||||
|
{
|
||||||
|
if (!args.TryGetValue(key, out var value)) return null;
|
||||||
|
if (value is JsonElement el) return el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString();
|
||||||
|
return value?.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime? ParseDateArg(Dictionary<string, object> args, string key)
|
||||||
|
{
|
||||||
|
var str = ParseStringArg(args, key);
|
||||||
|
if (str == null) return null;
|
||||||
|
return DateTime.TryParse(str, out var dt) ? dt : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid? ParseGuidArg(Dictionary<string, object> args, string key)
|
||||||
|
{
|
||||||
|
var str = ParseStringArg(args, key);
|
||||||
|
if (str == null) return null;
|
||||||
|
return Guid.TryParse(str, out var guid) ? guid : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal? ParseDecimalArg(Dictionary<string, object> args, string key)
|
||||||
|
{
|
||||||
|
if (!args.TryGetValue(key, out var value)) return null;
|
||||||
|
if (value is JsonElement el)
|
||||||
|
{
|
||||||
|
return el.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Number => el.GetDecimal(),
|
||||||
|
JsonValueKind.String => decimal.TryParse(el.GetString(), out var d) ? d : null,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return decimal.TryParse(value?.ToString(), out var dec) ? dec : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? ParseIntArg(Dictionary<string, object> args, string key)
|
||||||
|
{
|
||||||
|
if (!args.TryGetValue(key, out var value)) return null;
|
||||||
|
if (value is JsonElement el)
|
||||||
|
{
|
||||||
|
return el.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Number => el.GetInt32(),
|
||||||
|
JsonValueKind.String => int.TryParse(el.GetString(), out var i) ? i : null,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return int.TryParse(value?.ToString(), out var result) ? result : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
// API DTOs
|
||||||
|
public record SendChatMessageRequest(Guid? ConversationId, string Message);
|
||||||
|
|
||||||
|
public record SendChatMessageResponse(Guid ConversationId, ChatMessageResponse AssistantMessage);
|
||||||
|
|
||||||
|
public record ChatMessageResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Role,
|
||||||
|
string Content,
|
||||||
|
string? ToolResultType,
|
||||||
|
string? ToolResultJson,
|
||||||
|
DateTime CreatedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ChatConversationResponse(
|
||||||
|
Guid Id,
|
||||||
|
string? Title,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime UpdatedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ChatConversationDetailResponse(
|
||||||
|
Guid Id,
|
||||||
|
string? Title,
|
||||||
|
List<ChatMessageResponse> Messages,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime UpdatedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ollama tool-calling DTOs (separate from existing OllamaChatRequest to avoid breaking categorization)
|
||||||
|
public class OllamaToolChatRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("model")]
|
||||||
|
public string Model { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("messages")]
|
||||||
|
public List<OllamaToolMessage> Messages { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("stream")]
|
||||||
|
public bool Stream { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("tools")]
|
||||||
|
public List<OllamaTool>? Tools { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OllamaToolMessage
|
||||||
|
{
|
||||||
|
[JsonPropertyName("role")]
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("content")]
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("tool_calls")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public List<OllamaToolCall>? ToolCalls { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OllamaTool
|
||||||
|
{
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public string Type { get; set; } = "function";
|
||||||
|
|
||||||
|
[JsonPropertyName("function")]
|
||||||
|
public OllamaFunction Function { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OllamaFunction
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("description")]
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("parameters")]
|
||||||
|
public OllamaFunctionParameters Parameters { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OllamaFunctionParameters
|
||||||
|
{
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public string Type { get; set; } = "object";
|
||||||
|
|
||||||
|
[JsonPropertyName("properties")]
|
||||||
|
public Dictionary<string, OllamaPropertySchema> Properties { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("required")]
|
||||||
|
public List<string>? Required { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OllamaPropertySchema
|
||||||
|
{
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public string Type { get; set; } = "string";
|
||||||
|
|
||||||
|
[JsonPropertyName("description")]
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("enum")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public List<string>? Enum { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OllamaToolCall
|
||||||
|
{
|
||||||
|
[JsonPropertyName("function")]
|
||||||
|
public OllamaToolCallFunction Function { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OllamaToolCallFunction
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("arguments")]
|
||||||
|
public Dictionary<string, object> Arguments { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OllamaToolChatResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("message")]
|
||||||
|
public OllamaToolResponseMessage? Message { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("done")]
|
||||||
|
public bool Done { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OllamaToolResponseMessage
|
||||||
|
{
|
||||||
|
[JsonPropertyName("role")]
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("content")]
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("tool_calls")]
|
||||||
|
public List<OllamaToolCall>? ToolCalls { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Purrse.Core.DTOs;
|
||||||
|
|
||||||
|
namespace Purrse.Core.Interfaces.Services;
|
||||||
|
|
||||||
|
public interface IChatService
|
||||||
|
{
|
||||||
|
Task<SendChatMessageResponse> SendMessageAsync(Guid userId, SendChatMessageRequest request);
|
||||||
|
Task<List<ChatConversationResponse>> GetConversationsAsync(Guid userId);
|
||||||
|
Task<ChatConversationDetailResponse?> GetConversationAsync(Guid userId, Guid conversationId);
|
||||||
|
Task DeleteConversationAsync(Guid userId, Guid conversationId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Purrse.Core.Models;
|
||||||
|
|
||||||
|
public class ChatConversation
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
public ICollection<ChatMessage> Messages { get; set; } = new List<ChatMessage>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Purrse.Core.Models;
|
||||||
|
|
||||||
|
public class ChatMessage
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid ConversationId { get; set; }
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
public string? ToolCallsJson { get; set; }
|
||||||
|
public string? ToolResultJson { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public ChatConversation Conversation { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Purrse.Core.Models;
|
||||||
|
|
||||||
|
namespace Purrse.Data.Configurations;
|
||||||
|
|
||||||
|
public class ChatConversationConfiguration : IEntityTypeConfiguration<ChatConversation>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ChatConversation> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("chat_conversations");
|
||||||
|
builder.HasKey(c => c.Id);
|
||||||
|
builder.Property(c => c.Title).HasMaxLength(200);
|
||||||
|
|
||||||
|
builder.HasOne(c => c.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasIndex(c => c.UserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Purrse.Core.Models;
|
||||||
|
|
||||||
|
namespace Purrse.Data.Configurations;
|
||||||
|
|
||||||
|
public class ChatMessageConfiguration : IEntityTypeConfiguration<ChatMessage>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ChatMessage> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("chat_messages");
|
||||||
|
builder.HasKey(m => m.Id);
|
||||||
|
builder.Property(m => m.Role).HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(m => m.Content).IsRequired();
|
||||||
|
|
||||||
|
builder.HasOne(m => m.Conversation)
|
||||||
|
.WithMany(c => c.Messages)
|
||||||
|
.HasForeignKey(m => m.ConversationId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasIndex(m => m.ConversationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Purrse.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAiChat : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "chat_conversations",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_chat_conversations", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_chat_conversations_users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "chat_messages",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ConversationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Role = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Content = table.Column<string>(type: "text", nullable: false),
|
||||||
|
ToolCallsJson = table.Column<string>(type: "text", nullable: true),
|
||||||
|
ToolResultJson = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_chat_messages", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_chat_messages_chat_conversations_ConversationId",
|
||||||
|
column: x => x.ConversationId,
|
||||||
|
principalTable: "chat_conversations",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_chat_conversations_UserId",
|
||||||
|
table: "chat_conversations",
|
||||||
|
column: "UserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_chat_messages_ConversationId",
|
||||||
|
table: "chat_messages",
|
||||||
|
column: "ConversationId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "chat_messages");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "chat_conversations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -302,6 +302,66 @@ namespace Purrse.Data.Migrations
|
|||||||
b.ToTable("categories", (string)null);
|
b.ToTable("categories", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Purrse.Core.Models.ChatConversation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("chat_conversations", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Purrse.Core.Models.ChatMessage", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConversationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("ToolCallsJson")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ToolResultJson")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ConversationId");
|
||||||
|
|
||||||
|
b.ToTable("chat_messages", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b =>
|
modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1129,6 +1189,28 @@ namespace Purrse.Data.Migrations
|
|||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Purrse.Core.Models.ChatConversation", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Purrse.Core.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Purrse.Core.Models.ChatMessage", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Purrse.Core.Models.ChatConversation", "Conversation")
|
||||||
|
.WithMany("Messages")
|
||||||
|
.HasForeignKey("ConversationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Conversation");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b =>
|
modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Purrse.Core.Models.Account", "Account")
|
b.HasOne("Purrse.Core.Models.Account", "Account")
|
||||||
@@ -1411,6 +1493,11 @@ namespace Purrse.Data.Migrations
|
|||||||
b.Navigation("Transactions");
|
b.Navigation("Transactions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Purrse.Core.Models.ChatConversation", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b =>
|
modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Transactions");
|
b.Navigation("Transactions");
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ public class PurrseDbContext : DbContext
|
|||||||
public DbSet<SyncLog> SyncLogs => Set<SyncLog>();
|
public DbSet<SyncLog> SyncLogs => Set<SyncLog>();
|
||||||
public DbSet<BankSyncSettings> BankSyncSettings => Set<BankSyncSettings>();
|
public DbSet<BankSyncSettings> BankSyncSettings => Set<BankSyncSettings>();
|
||||||
public DbSet<AiCategorizationSettings> AiCategorizationSettings => Set<AiCategorizationSettings>();
|
public DbSet<AiCategorizationSettings> AiCategorizationSettings => Set<AiCategorizationSettings>();
|
||||||
|
public DbSet<ChatConversation> ChatConversations => Set<ChatConversation>();
|
||||||
|
public DbSet<ChatMessage> ChatMessages => Set<ChatMessage>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user