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: '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' },
] ]
+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: '/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 } },
] ]
+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 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>
+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>
@@ -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();
}
}
+1
View File
@@ -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
+756
View File
@@ -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;
}
}
+143
View File
@@ -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>();
}
+13
View File
@@ -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");
+2
View File
@@ -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)
{ {