02c1f49db5
Replace with a simple counter-based temp ID generator for optimistic UI messages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
273 lines
8.5 KiB
Vue
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>
|