Private
Public Access
1
0
Files
Purrse/frontend/src/views/chat/ChatView.vue
T
Catherine Renelle 02c1f49db5 Fix crypto.randomUUID not available in all environments
Replace with a simple counter-based temp ID generator for optimistic
UI messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 23:37:24 -05:00

273 lines
8.5 KiB
Vue

<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'
let _idCounter = 0
function tempId(): string {
return `temp-${Date.now()}-${++_idCounter}`
}
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: 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,
})
// 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: 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>