Add floating AI chat panel with page context awareness
Replace the standalone /chat page with a bottom-right floating widget available on every page. The panel sends page context with each message so the AI knows what the user is viewing. On the Transactions page, the full visible transaction list (with filters) is serialized into the context so the AI can answer questions about on-screen entries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
<template>
|
||||
<!-- FAB -->
|
||||
<v-btn
|
||||
icon
|
||||
color="primary"
|
||||
size="large"
|
||||
class="chat-fab"
|
||||
elevation="8"
|
||||
@click="togglePanel"
|
||||
>
|
||||
<v-icon>{{ panelOpen ? 'mdi-close' : 'mdi-robot-outline' }}</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<!-- Chat Panel -->
|
||||
<v-card v-show="panelOpen" class="chat-panel" elevation="12" rounded="lg">
|
||||
<!-- Header -->
|
||||
<v-toolbar density="compact" color="primary" class="flex-grow-0">
|
||||
<v-toolbar-title class="text-body-1 font-weight-medium">Purrse AI</v-toolbar-title>
|
||||
<template v-slot:append>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon size="small" v-bind="props">
|
||||
<v-icon size="20">mdi-history</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Conversations</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" min-width="240" max-height="320">
|
||||
<v-list-item v-if="conversations.length === 0">
|
||||
<v-list-item-title class="text-medium-emphasis text-center text-caption">No conversations</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
:active="conv.id === activeConversationId"
|
||||
@click="loadConversation(conv.id)"
|
||||
>
|
||||
<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>
|
||||
</v-menu>
|
||||
<v-btn icon size="small" @click="startNewChat">
|
||||
<v-icon size="20">mdi-plus</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">New Chat</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn icon size="small" @click="panelOpen = false">
|
||||
<v-icon size="20">mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-toolbar>
|
||||
|
||||
<!-- Messages -->
|
||||
<div ref="messagesContainer" class="chat-messages">
|
||||
<!-- Empty state -->
|
||||
<div v-if="messages.length === 0 && !loading" class="d-flex flex-column align-center justify-center" style="height: 100%;">
|
||||
<v-icon size="48" color="primary" class="mb-3">mdi-robot-outline</v-icon>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">Ask me about your finances</p>
|
||||
<div class="d-flex flex-wrap justify-center ga-2 px-4">
|
||||
<v-chip
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
@click="sendSuggestion(suggestion)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages list -->
|
||||
<div v-else class="pa-3">
|
||||
<div v-for="msg in messages" :key="msg.id" class="mb-3">
|
||||
<!-- User message -->
|
||||
<div v-if="msg.role === 'user'" class="d-flex justify-end">
|
||||
<v-card color="primary" variant="flat" class="pa-2 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-2 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-3">
|
||||
<v-card variant="tonal" class="pa-2 rounded-lg">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-progress-circular indeterminate size="14" width="2" />
|
||||
<span class="text-body-2 text-medium-emphasis">Thinking...</span>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="chat-input pa-2 border-t">
|
||||
<v-textarea
|
||||
v-model="inputMessage"
|
||||
placeholder="Ask about your finances..."
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
rows="1"
|
||||
max-rows="3"
|
||||
auto-grow
|
||||
hide-details
|
||||
:disabled="loading"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
>
|
||||
<template v-slot:append-inner>
|
||||
<v-btn
|
||||
icon="mdi-send"
|
||||
size="x-small"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
:disabled="!inputMessage.trim() || loading"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</template>
|
||||
</v-textarea>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { chatApi } from '@/services/chat'
|
||||
import { usePageContext } from '@/composables/usePageContext'
|
||||
import ChatToolResult from '@/views/chat/ChatToolResult.vue'
|
||||
import type { ChatConversation, ChatMessage } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const { pageContext } = usePageContext()
|
||||
|
||||
const panelOpen = ref(false)
|
||||
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 conversationsLoaded = ref(false)
|
||||
|
||||
let _idCounter = 0
|
||||
function tempId(): string {
|
||||
return `temp-${Date.now()}-${++_idCounter}`
|
||||
}
|
||||
|
||||
const suggestions = [
|
||||
'What are my account balances?',
|
||||
'Show my spending this month',
|
||||
'What did I spend on groceries?',
|
||||
'Show my dashboard summary',
|
||||
]
|
||||
|
||||
function buildPageContext(): string {
|
||||
// If the current page has pushed rich context, use that
|
||||
if (pageContext.value) return pageContext.value
|
||||
|
||||
// Fallback: describe page from route name
|
||||
const name = route.name as string | undefined
|
||||
const path = route.path
|
||||
|
||||
switch (name) {
|
||||
case 'dashboard':
|
||||
return 'Dashboard — net worth, recent transactions, spending summary'
|
||||
case 'accounts':
|
||||
return 'Accounts page — list of all financial accounts'
|
||||
case 'account-transactions':
|
||||
return `Transactions for account ${route.params.id}`
|
||||
case 'transactions':
|
||||
return 'All transactions page'
|
||||
case 'categories':
|
||||
return 'Categories management page'
|
||||
case 'payees':
|
||||
return 'Payees management page'
|
||||
case 'budgets':
|
||||
return 'Budgets page'
|
||||
case 'scheduled':
|
||||
return 'Scheduled transactions page'
|
||||
case 'reconciliation':
|
||||
return 'Account reconciliation page'
|
||||
case 'imports':
|
||||
return 'Transaction imports page'
|
||||
case 'loans':
|
||||
return 'Loans page'
|
||||
case 'reports':
|
||||
return 'Financial reports page'
|
||||
case 'investments':
|
||||
return 'Investments page'
|
||||
case 'settings':
|
||||
return 'Settings page'
|
||||
default:
|
||||
return `Page: ${path}`
|
||||
}
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
panelOpen.value = !panelOpen.value
|
||||
if (panelOpen.value && !conversationsLoaded.value) {
|
||||
loadConversations()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConversations() {
|
||||
try {
|
||||
const { data } = await chatApi.getConversations()
|
||||
conversations.value = data
|
||||
conversationsLoaded.value = true
|
||||
} 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
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: tempId(),
|
||||
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,
|
||||
pageContext: buildPageContext(),
|
||||
})
|
||||
|
||||
if (!activeConversationId.value) {
|
||||
activeConversationId.value = data.conversationId
|
||||
await loadConversations()
|
||||
}
|
||||
|
||||
messages.value.push(data.assistantMessage)
|
||||
} catch (err: any) {
|
||||
messages.value.push({
|
||||
id: tempId(),
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.chat-fab {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
position: fixed;
|
||||
bottom: 88px;
|
||||
right: 24px;
|
||||
width: 400px;
|
||||
height: 550px;
|
||||
z-index: 1099;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -23,6 +23,7 @@
|
||||
:title="item.title"
|
||||
:to="item.route"
|
||||
:value="item.route"
|
||||
:active="isNavActive(item.route)"
|
||||
rounded="xl"
|
||||
>
|
||||
<v-tooltip v-if="rail" activator="parent" location="end">{{ item.title }}</v-tooltip>
|
||||
@@ -116,6 +117,8 @@
|
||||
<slot />
|
||||
</v-container>
|
||||
</v-main>
|
||||
|
||||
<ChatPanel />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -124,6 +127,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useNotifications } from '@/composables/useNotifications'
|
||||
import ChatPanel from '@/components/chat/ChatPanel.vue'
|
||||
import type { AppNotification } from '@/types'
|
||||
|
||||
const drawer = ref(true)
|
||||
@@ -150,13 +154,18 @@ const navItems = [
|
||||
{ title: 'Loans', icon: 'mdi-home-city', route: '/loans' },
|
||||
{ title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' },
|
||||
{ title: 'Investments', icon: 'mdi-finance', route: '/investments' },
|
||||
{ title: 'AI Chat', icon: 'mdi-robot-outline', route: '/chat' },
|
||||
{ title: 'Plugins', icon: 'mdi-puzzle', route: '/plugins' },
|
||||
{ title: 'Settings', icon: 'mdi-cog', route: '/settings' },
|
||||
]
|
||||
|
||||
function isNavActive(navRoute: string) {
|
||||
if (route.path === navRoute) return true
|
||||
// Highlight "Transactions" when viewing account-scoped transactions
|
||||
if (navRoute === '/transactions' && route.name === 'account-transactions') return true
|
||||
return false
|
||||
}
|
||||
|
||||
const currentPageTitle = computed(() => {
|
||||
const item = navItems.find(n => n.route === route.path)
|
||||
const item = navItems.find(n => isNavActive(n.route))
|
||||
return item?.title || 'Purrse'
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const pageContext = ref('')
|
||||
|
||||
export function usePageContext() {
|
||||
function setPageContext(context: string) {
|
||||
pageContext.value = context
|
||||
}
|
||||
|
||||
function clearPageContext() {
|
||||
pageContext.value = ''
|
||||
}
|
||||
|
||||
return { pageContext, setPageContext, clearPageContext }
|
||||
}
|
||||
@@ -17,8 +17,6 @@ const routes = [
|
||||
{ path: '/loans/:id?', name: 'loans', component: () => import('@/views/loans/LoansView.vue'), meta: { auth: true }, props: true },
|
||||
{ path: '/reports', name: 'reports', component: () => import('@/views/reports/ReportsView.vue'), meta: { auth: true } },
|
||||
{ path: '/investments', name: 'investments', component: () => import('@/views/investments/InvestmentsView.vue'), meta: { auth: true } },
|
||||
{ path: '/chat', name: 'chat', component: () => import('@/views/chat/ChatView.vue'), meta: { auth: true } },
|
||||
{ path: '/plugins', name: 'plugins', component: () => import('@/views/plugins/PluginsView.vue'), meta: { auth: true } },
|
||||
{ path: '/settings', name: 'settings', component: () => import('@/views/settings/SettingsView.vue'), meta: { auth: true } },
|
||||
]
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import api from './api'
|
||||
import type { ChatConversation, ChatConversationDetail, SendChatMessageResponse } from '@/types'
|
||||
|
||||
export const chatApi = {
|
||||
sendMessage: (data: { conversationId?: string; message: string }) =>
|
||||
sendMessage: (data: { conversationId?: string; message: string; pageContext?: string }) =>
|
||||
api.post<SendChatMessageResponse>('/chat/send', data),
|
||||
|
||||
getConversations: () =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h1 class="text-h4">{{ accountName || 'Transactions' }}</h1>
|
||||
<h1 class="text-h4">Transactions</h1>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="info"
|
||||
@@ -20,16 +20,28 @@
|
||||
<v-card class="mb-4">
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="accountFilter"
|
||||
label="Account"
|
||||
:items="accountOptions"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
density="compact"
|
||||
clearable
|
||||
@update:model-value="onAccountFilterChange"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field v-model="search" label="Search" prepend-icon="mdi-magnify" clearable density="compact" @update:model-value="doSearch" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-col cols="12" md="2">
|
||||
<v-text-field v-model="startDate" label="Start Date" type="date" density="compact" @update:model-value="doSearch" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-col cols="12" md="2">
|
||||
<v-text-field v-model="endDate" label="End Date" type="date" density="compact" @update:model-value="doSearch" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-col cols="12" md="2">
|
||||
<v-select v-model="statusFilter" label="Status" :items="['All','Uncleared','Cleared','Reconciled']" density="compact" @update:model-value="doSearch" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -98,7 +110,7 @@
|
||||
<v-card>
|
||||
<v-card-title>{{ editingTxn ? 'Edit Transaction' : 'Add Transaction' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-select v-if="!props.id" v-model="txnForm.accountId" label="Account" :items="accountsStore.accounts" item-title="name" item-value="id" />
|
||||
<v-select v-if="!accountFilter" v-model="txnForm.accountId" label="Account" :items="accountsStore.accounts" item-title="name" item-value="id" />
|
||||
<v-text-field v-model="txnForm.date" label="Date" type="date" />
|
||||
<v-text-field v-model.number="txnForm.amount" label="Amount" type="number" prefix="$" />
|
||||
<v-select v-model="txnForm.type" label="Type" :items="['Debit','Credit']" />
|
||||
@@ -187,22 +199,26 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar v-model="classifySnackbar" :color="classifySnackbarColor" :timeout="4000">
|
||||
<v-snackbar v-model="classifySnackbar" :color="classifySnackbarColor" :timeout="5000">
|
||||
{{ classifySnackbarText }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { transactionsApi } from '@/services/transactions'
|
||||
import { categoriesApi } from '@/services/categories'
|
||||
import { aiCategorizationApi } from '@/services/aiCategorization'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { useNotifications } from '@/composables/useNotifications'
|
||||
import { usePageContext } from '@/composables/usePageContext'
|
||||
import { formatCurrency, formatDate } from '@/utils/formatters'
|
||||
import type { Transaction, Category, SplitFormItem } from '@/types'
|
||||
import type { Transaction, Category, SplitFormItem, AiClassificationNotification } from '@/types'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
const router = useRouter()
|
||||
const accountsStore = useAccountsStore()
|
||||
const transactions = ref<Transaction[]>([])
|
||||
const totalCount = ref(0)
|
||||
@@ -210,11 +226,37 @@ const page = ref(1)
|
||||
const itemsPerPage = ref(50)
|
||||
const categories = ref<Category[]>([])
|
||||
const loading = ref(false)
|
||||
const accountName = ref('')
|
||||
const accountFilter = ref<string | null>(props.id || null)
|
||||
const search = ref('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const statusFilter = ref('All')
|
||||
|
||||
const { setPageContext, clearPageContext } = usePageContext()
|
||||
|
||||
watchEffect(() => {
|
||||
const filters: string[] = []
|
||||
if (accountFilter.value) {
|
||||
const acct = accountsStore.accounts.find(a => a.id === accountFilter.value)
|
||||
filters.push(`account="${acct?.name || accountFilter.value}"`)
|
||||
}
|
||||
if (search.value) filters.push(`search="${search.value}"`)
|
||||
if (startDate.value) filters.push(`from ${startDate.value}`)
|
||||
if (endDate.value) filters.push(`to ${endDate.value}`)
|
||||
if (statusFilter.value !== 'All') filters.push(`status=${statusFilter.value}`)
|
||||
|
||||
const filterDesc = filters.length > 0 ? ` (${filters.join(', ')})` : ''
|
||||
const lines = [`Transactions page${filterDesc} — showing ${transactions.value.length} of ${totalCount.value} transactions:`]
|
||||
|
||||
for (const txn of transactions.value) {
|
||||
lines.push(`- ${txn.date.split('T')[0]} | ${txn.payeeName || '—'} | ${txn.categoryName || 'Uncategorized'} | ${txn.amount >= 0 ? '+' : ''}${txn.amount.toFixed(2)} | ${txn.status} | ID:${txn.id}`)
|
||||
}
|
||||
|
||||
setPageContext(lines.join('\n'))
|
||||
})
|
||||
|
||||
onUnmounted(() => clearPageContext())
|
||||
|
||||
const showAddDialog = ref(false)
|
||||
const showTransferDialog = ref(false)
|
||||
const editingTxn = ref(false)
|
||||
@@ -255,17 +297,26 @@ const splitRemaining = computed(() => {
|
||||
return Math.round((total - splitTotal) * 100) / 100
|
||||
})
|
||||
|
||||
const accountOptions = computed(() => accountsStore.accounts)
|
||||
|
||||
function onAccountFilterChange(value: string | null) {
|
||||
if (value) {
|
||||
router.replace({ name: 'account-transactions', params: { id: value } })
|
||||
} else {
|
||||
router.replace({ name: 'transactions' })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await accountsStore.fetchAccounts()
|
||||
loadCategories()
|
||||
if (props.id) {
|
||||
txnForm.value.accountId = props.id
|
||||
const acct = accountsStore.accounts.find(a => a.id === props.id)
|
||||
accountName.value = acct?.name || ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.id, () => {
|
||||
watch(() => props.id, (newId) => {
|
||||
accountFilter.value = newId || null
|
||||
page.value = 1
|
||||
loadTransactions()
|
||||
})
|
||||
@@ -280,13 +331,14 @@ async function loadCategories() {
|
||||
async function loadTransactions() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (props.id && !search.value && !startDate.value && !endDate.value && statusFilter.value === 'All') {
|
||||
const { data } = await transactionsApi.getByAccount(props.id, page.value, itemsPerPage.value)
|
||||
const acctId = accountFilter.value || undefined
|
||||
if (acctId && !search.value && !startDate.value && !endDate.value && statusFilter.value === 'All') {
|
||||
const { data } = await transactionsApi.getByAccount(acctId, page.value, itemsPerPage.value)
|
||||
transactions.value = data.items
|
||||
totalCount.value = data.totalCount
|
||||
} else {
|
||||
const { data } = await transactionsApi.search({
|
||||
accountId: props.id || undefined,
|
||||
accountId: acctId,
|
||||
searchText: search.value || undefined,
|
||||
startDate: startDate.value || undefined,
|
||||
endDate: endDate.value || undefined,
|
||||
@@ -320,7 +372,7 @@ function openAddDialog() {
|
||||
editingTxn.value = false
|
||||
editingTxnId.value = ''
|
||||
txnForm.value = {
|
||||
accountId: props.id || '',
|
||||
accountId: accountFilter.value || '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
amount: 0,
|
||||
type: 'Debit',
|
||||
@@ -388,7 +440,7 @@ async function saveTxn() {
|
||||
if (editingTxn.value) {
|
||||
await transactionsApi.update(editingTxnId.value, payload)
|
||||
} else {
|
||||
payload.accountId = txnForm.value.accountId || props.id
|
||||
payload.accountId = txnForm.value.accountId || accountFilter.value
|
||||
await transactionsApi.create(payload)
|
||||
}
|
||||
showAddDialog.value = false
|
||||
@@ -407,25 +459,42 @@ async function createTransfer() {
|
||||
await loadTransactions()
|
||||
}
|
||||
|
||||
const { notifications } = useNotifications()
|
||||
|
||||
watch(notifications, (items) => {
|
||||
const latest = items.find(n => n.type === 'AiClassificationComplete' && !n.read)
|
||||
if (!latest || !classifying.value) return
|
||||
const data = latest.data as AiClassificationNotification
|
||||
if (data.error) {
|
||||
classifySnackbarColor.value = 'error'
|
||||
classifySnackbarText.value = data.error
|
||||
} else {
|
||||
classifySnackbarColor.value = 'success'
|
||||
classifySnackbarText.value = `Classified ${data.classified} of ${data.totalUncategorized} uncategorized transactions`
|
||||
}
|
||||
classifySnackbar.value = true
|
||||
classifying.value = false
|
||||
latest.read = true
|
||||
loadTransactions()
|
||||
}, { deep: true })
|
||||
|
||||
async function classifyUncategorized() {
|
||||
classifying.value = true
|
||||
try {
|
||||
const { data } = await aiCategorizationApi.classifyUncategorized({
|
||||
accountId: props.id || undefined,
|
||||
await aiCategorizationApi.classifyUncategorized({
|
||||
accountId: accountFilter.value || undefined,
|
||||
startDate: startDate.value || undefined,
|
||||
endDate: endDate.value || undefined,
|
||||
searchText: search.value || undefined,
|
||||
status: statusFilter.value === 'All' ? undefined : statusFilter.value,
|
||||
})
|
||||
classifySnackbarColor.value = 'success'
|
||||
classifySnackbarText.value = `Classified ${data.classified} of ${data.totalUncategorized} uncategorized transactions`
|
||||
classifySnackbarColor.value = 'info'
|
||||
classifySnackbarText.value = 'Classification started. You will be notified when it completes.'
|
||||
classifySnackbar.value = true
|
||||
await doSearch()
|
||||
} catch (e: any) {
|
||||
classifySnackbarColor.value = 'error'
|
||||
classifySnackbarText.value = e?.response?.data?.message || 'Failed to classify transactions'
|
||||
classifySnackbarText.value = e?.response?.data?.message || 'Failed to start classification'
|
||||
classifySnackbar.value = true
|
||||
} finally {
|
||||
classifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,16 @@ public class ChatService : IChatService
|
||||
Content = string.Format(SystemPromptTemplate, DateTime.UtcNow.ToString("yyyy-MM-dd"))
|
||||
});
|
||||
|
||||
// Page context
|
||||
if (!string.IsNullOrEmpty(request.PageContext))
|
||||
{
|
||||
ollamaMessages.Add(new OllamaToolMessage
|
||||
{
|
||||
Role = "system",
|
||||
Content = $"The user is currently viewing: {request.PageContext}"
|
||||
});
|
||||
}
|
||||
|
||||
// Last 20 conversation messages
|
||||
var recentMessages = conversation.Messages
|
||||
.OrderBy(m => m.CreatedAt)
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Text.Json.Serialization;
|
||||
namespace Purrse.Core.DTOs;
|
||||
|
||||
// API DTOs
|
||||
public record SendChatMessageRequest(Guid? ConversationId, string Message);
|
||||
public record SendChatMessageRequest(Guid? ConversationId, string Message, string? PageContext = null);
|
||||
|
||||
public record SendChatMessageResponse(Guid ConversationId, ChatMessageResponse AssistantMessage);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user