Private
Public Access
1
0

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:
Catherine Renelle
2026-02-10 23:51:52 -05:00
parent 482df9d94d
commit c2a4272585
8 changed files with 493 additions and 31 deletions
+361
View File
@@ -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>
+12 -3
View File
@@ -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 }
}
-2
View File
@@ -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 } },
]
+1 -1
View File
@@ -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
}
}
+10
View File
@@ -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)
+1 -1
View File
@@ -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);