Adds in AI chatting system.
This commit is contained in:
parent
28c054de22
commit
8655eae39c
11 changed files with 1198 additions and 119 deletions
117
src/components/ChatInterface.vue
Normal file
117
src/components/ChatInterface.vue
Normal file
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<div class="q-pa-md column full-height">
|
||||
<q-scroll-area
|
||||
ref="scrollAreaRef"
|
||||
class="col"
|
||||
style="flex-grow: 1; overflow-x: visible; overflow-y: auto;"
|
||||
>
|
||||
<div v-for="(message, index) in messages" :key="index" class="q-mb-sm q-mx-md">
|
||||
<q-chat-message
|
||||
:name="message.sender.toUpperCase()"
|
||||
:sent="message.sender === 'user'"
|
||||
:bg-color="message.sender === 'user' ? 'primary' : 'grey-4'"
|
||||
:text-color="message.sender === 'user' ? 'white' : 'black'"
|
||||
>
|
||||
<!-- Use v-html to render parsed markdown -->
|
||||
<div v-if="!message.loading" v-html="parseMarkdown(message.content)" class="message-content"></div>
|
||||
<!-- Optional: Add a spinner for a better loading visual -->
|
||||
<template v-if="message.loading" v-slot:default>
|
||||
<q-spinner-dots size="2em" />
|
||||
</template>
|
||||
</q-chat-message>
|
||||
</div>
|
||||
</q-scroll-area>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<div class="q-pa-sm row items-center">
|
||||
<q-input
|
||||
v-model="newMessage"
|
||||
outlined
|
||||
dense
|
||||
placeholder="Type a message..."
|
||||
class="col"
|
||||
@keyup.enter="sendMessage"
|
||||
autogrow
|
||||
/>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
icon="send"
|
||||
color="primary"
|
||||
class="q-ml-sm"
|
||||
@click="sendMessage"
|
||||
:disable="!newMessage.trim()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import { QScrollArea, QChatMessage, QSpinnerDots } from 'quasar'; // Import QSpinnerDots
|
||||
import { marked } from 'marked'; // Import marked
|
||||
|
||||
const props = defineProps({
|
||||
messages: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
// Example message structure:
|
||||
// { sender: 'Bot', content: 'Hello!', loading: false }
|
||||
// { sender: 'You', content: 'Thinking...', loading: true }
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['send-message']);
|
||||
|
||||
const newMessage = ref('');
|
||||
const scrollAreaRef = ref(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollAreaRef.value) {
|
||||
const scrollTarget = scrollAreaRef.value.getScrollTarget();
|
||||
const duration = 300; // Optional: animation duration
|
||||
// Use getScrollTarget().scrollHeight for accurate height
|
||||
scrollAreaRef.value.setScrollPosition('vertical', scrollTarget.scrollHeight, duration);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
const trimmedMessage = newMessage.value.trim();
|
||||
if (trimmedMessage) {
|
||||
emit('send-message', trimmedMessage);
|
||||
newMessage.value = '';
|
||||
// Ensure the scroll happens after the message is potentially added to the list
|
||||
nextTick(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const parseMarkdown = (content) => {
|
||||
// Basic check to prevent errors if content is not a string
|
||||
if (typeof content !== 'string') {
|
||||
return '';
|
||||
}
|
||||
// Configure marked options if needed (e.g., sanitization)
|
||||
// marked.setOptions({ sanitize: true }); // Example: Enable sanitization
|
||||
return marked(content);
|
||||
};
|
||||
|
||||
// Scroll to bottom when messages change or component mounts
|
||||
watch(() => props.messages, () => {
|
||||
nextTick(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
}, { deep: true, immediate: true });
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.message-content p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
|
@ -1,119 +1,193 @@
|
|||
<template>
|
||||
<q-layout view="hHh Lpr lFf">
|
||||
<q-drawer
|
||||
:mini="!leftDrawerOpen"
|
||||
bordered
|
||||
persistent
|
||||
:model-value="true"
|
||||
>
|
||||
<q-list>
|
||||
<q-item clickable v-ripple @click="toggleLeftDrawer">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="menu"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-h6">StylePoint</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<!-- Dynamic Navigation Items -->
|
||||
<q-item
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
clickable
|
||||
v-ripple
|
||||
:to="{ name: item.name }"
|
||||
exact
|
||||
>
|
||||
<q-tooltip anchor="center right" self="center left" >
|
||||
<span>{{ item.meta.title }}</span>
|
||||
</q-tooltip>
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="item.meta.icon" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ item.meta.title }}</q-item-label>
|
||||
<q-item-label caption>{{ item.meta.caption }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<!-- Logout Button (Conditional) -->
|
||||
<q-item
|
||||
v-if="authStore.isAuthenticated"
|
||||
clickable
|
||||
v-ripple
|
||||
@click="logout"
|
||||
>
|
||||
<q-tooltip anchor="center right" self="center left" >
|
||||
<span>Logout</span>
|
||||
</q-tooltip>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="logout" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Logout</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
</q-list>
|
||||
</q-drawer>
|
||||
|
||||
<q-page-container>
|
||||
<router-view />
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import axios from 'axios'
|
||||
import { ref, computed } from 'vue' // Import computed
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useAuthStore } from 'stores/auth'; // Import the auth store
|
||||
import routes from '../router/routes'; // Import routes
|
||||
|
||||
const $q = useQuasar()
|
||||
const leftDrawerOpen = ref(false)
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore(); // Use the auth store
|
||||
|
||||
// Get the child routes of the main layout
|
||||
const mainLayoutRoutes = routes.find(r => r.path === '/')?.children || [];
|
||||
|
||||
// Compute navigation items based on auth state and route meta
|
||||
const navItems = computed(() => {
|
||||
const isAuthenticated = authStore.isAuthenticated;
|
||||
return mainLayoutRoutes.filter(route => {
|
||||
const navGroup = route.meta?.navGroup;
|
||||
if (!navGroup) return false; // Only include routes with navGroup defined
|
||||
|
||||
if (navGroup === 'always') return true;
|
||||
if (navGroup === 'auth' && isAuthenticated) return true;
|
||||
if (navGroup === 'noAuth' && !isAuthenticated) return true;
|
||||
|
||||
return false; // Exclude otherwise
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
function toggleLeftDrawer () {
|
||||
leftDrawerOpen.value = !leftDrawerOpen.value
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await axios.post('/auth/logout');
|
||||
authStore.logout(); // Use the store action to update state
|
||||
// No need to manually push, router guard should redirect
|
||||
// router.push({ name: 'login' });
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: 'Logout failed. Please try again.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<q-layout view="hHh Lpr lFf">
|
||||
<q-drawer
|
||||
:mini="!leftDrawerOpen"
|
||||
bordered
|
||||
persistent
|
||||
:model-value="true"
|
||||
>
|
||||
<q-list>
|
||||
<q-item clickable v-ripple @click="toggleLeftDrawer">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="menu"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-h6">StylePoint</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<!-- Dynamic Navigation Items -->
|
||||
<q-item
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
clickable
|
||||
v-ripple
|
||||
:to="{ name: item.name }"
|
||||
exact
|
||||
>
|
||||
<q-tooltip anchor="center right" self="center left" >
|
||||
<span>{{ item.meta.title }}</span>
|
||||
</q-tooltip>
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="item.meta.icon" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ item.meta.title }}</q-item-label>
|
||||
<q-item-label caption>{{ item.meta.caption }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<!-- Logout Button (Conditional) -->
|
||||
<q-item
|
||||
v-if="authStore.isAuthenticated"
|
||||
clickable
|
||||
v-ripple
|
||||
@click="logout"
|
||||
>
|
||||
<q-tooltip anchor="center right" self="center left" >
|
||||
<span>Logout</span>
|
||||
</q-tooltip>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="logout" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Logout</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
</q-list>
|
||||
</q-drawer>
|
||||
|
||||
<q-page-container>
|
||||
<router-view />
|
||||
</q-page-container>
|
||||
|
||||
<!-- Chat FAB -->
|
||||
<q-page-sticky v-if="isAuthenticated" position="bottom-right" :offset="[18, 18]">
|
||||
<q-fab
|
||||
v-model="fabOpen"
|
||||
icon="chat"
|
||||
color="accent"
|
||||
direction="up"
|
||||
padding="sm"
|
||||
@click="toggleChat"
|
||||
/>
|
||||
</q-page-sticky>
|
||||
|
||||
<!-- Chat Window Dialog -->
|
||||
<q-dialog v-model="isChatVisible" :maximized="$q.screen.lt.sm" fixed persistent style="width: max(400px, 25%);">
|
||||
<q-card style="width: max(400px, 25%); height: 600px; max-height: 80vh;">
|
||||
<q-bar class="bg-primary text-white">
|
||||
<div>Chat</div>
|
||||
<q-space />
|
||||
<q-btn dense flat icon="close" @click="toggleChat" />
|
||||
</q-bar>
|
||||
|
||||
<q-card-section class="q-pa-none" style="height: calc(100% - 50px);"> <!-- Adjust height based on q-bar -->
|
||||
<ChatInterface
|
||||
:messages="chatMessages"
|
||||
@send-message="handleSendMessage"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-inner-loading :showing="isLoading">
|
||||
<q-spinner-gears size="50px" color="primary" />
|
||||
</q-inner-loading>
|
||||
<q-banner v-if="chatError" inline-actions class="text-white bg-red">
|
||||
{{ chatError }}
|
||||
<template v-slot:action>
|
||||
<q-btn flat color="white" label="Dismiss" @click="clearError" />
|
||||
</template>
|
||||
</q-banner>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import axios from 'axios'
|
||||
import { ref, computed } from 'vue' // Import computed
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useAuthStore } from 'stores/auth'; // Import the auth store
|
||||
import { useChatStore } from 'stores/chat' // Adjust path as needed
|
||||
import ChatInterface from 'components/ChatInterface.vue' // Adjust path as needed
|
||||
import routes from '../router/routes'; // Import routes
|
||||
|
||||
const $q = useQuasar()
|
||||
const leftDrawerOpen = ref(false)
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore(); // Use the auth store
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const fabOpen = ref(false) // Local state for FAB animation, not chat visibility
|
||||
|
||||
// Computed properties to get state from the store
|
||||
const isChatVisible = computed(() => chatStore.isChatVisible)
|
||||
const chatMessages = computed(() => chatStore.chatMessages)
|
||||
const isLoading = computed(() => chatStore.isLoading)
|
||||
const chatError = computed(() => chatStore.error)
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated) // Get auth state
|
||||
|
||||
// Get the child routes of the main layout
|
||||
const mainLayoutRoutes = routes.find(r => r.path === '/')?.children || [];
|
||||
|
||||
// Compute navigation items based on auth state and route meta
|
||||
const navItems = computed(() => {
|
||||
return mainLayoutRoutes.filter(route => {
|
||||
const navGroup = route.meta?.navGroup;
|
||||
if (!navGroup) return false; // Only include routes with navGroup defined
|
||||
|
||||
if (navGroup === 'always') return true;
|
||||
if (navGroup === 'auth' && isAuthenticated.value) return true;
|
||||
if (navGroup === 'noAuth' && !isAuthenticated.value) return true;
|
||||
|
||||
return false; // Exclude otherwise
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Method to toggle chat visibility via the store action
|
||||
const toggleChat = () => {
|
||||
// Optional: Add an extra check here if needed, though hiding the button is primary
|
||||
if (isAuthenticated.value) {
|
||||
chatStore.toggleChat()
|
||||
}
|
||||
}
|
||||
|
||||
// Method to send a message via the store action
|
||||
const handleSendMessage = (messageContent) => {
|
||||
chatStore.sendMessage(messageContent)
|
||||
}
|
||||
|
||||
// Method to clear errors in the store (optional)
|
||||
const clearError = () => {
|
||||
chatStore.error = null; // Directly setting ref or add an action in store
|
||||
}
|
||||
function toggleLeftDrawer () {
|
||||
leftDrawerOpen.value = !leftDrawerOpen.value
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await axios.post('/auth/logout');
|
||||
authStore.logout(); // Use the store action to update state
|
||||
// No need to manually push, router guard should redirect
|
||||
// router.push({ name: 'login' });
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: 'Logout failed. Please try again.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any specific styles for the layout or chat window here */
|
||||
.q-dialog .q-card {
|
||||
overflow: hidden; /* Prevent scrollbars on the card itself */
|
||||
}
|
||||
</style>
|
181
src/layouts/MainLayoutBusted.vue
Normal file
181
src/layouts/MainLayoutBusted.vue
Normal file
|
@ -0,0 +1,181 @@
|
|||
<template>
|
||||
<q-layout view="hHh Lpr lFf">
|
||||
<q-drawer
|
||||
:mini="!leftDrawerOpen"
|
||||
bordered
|
||||
persistent
|
||||
:model-value="true"
|
||||
>
|
||||
<q-list>
|
||||
<q-item clickable v-ripple @click="toggleLeftDrawer">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="menu"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-h6">StylePoint</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple :to="{ name: 'formList' }" exact>
|
||||
<q-tooltip anchor="center right" self="center left" >
|
||||
<span>Forms</span>
|
||||
</q-tooltip>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="list_alt" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Forms</q-item-label>
|
||||
<q-item-label caption>View existing forms</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item
|
||||
clickable
|
||||
v-ripple
|
||||
:to="{ name: 'mantisSummaries' }"
|
||||
exact
|
||||
>
|
||||
<q-tooltip anchor="center right" self="center left" >
|
||||
<span>Mantis Summaries</span>
|
||||
</q-tooltip>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="summarize" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Mantis Summaries</q-item-label>
|
||||
<q-item-label caption>View daily summaries</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item
|
||||
clickable
|
||||
v-ripple
|
||||
:to="{ name: 'emailSummaries' }"
|
||||
exact
|
||||
>
|
||||
<q-tooltip anchor="center right" self="center left" >
|
||||
<span>Email Summaries</span>
|
||||
</q-tooltip>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="email" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Email Summaries</q-item-label>
|
||||
<q-item-label caption>View email summaries</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item
|
||||
clickable
|
||||
to="/settings" exact
|
||||
>
|
||||
<q-tooltip anchor="center right" self="center left" >
|
||||
<span>Settings</span>
|
||||
</q-tooltip>
|
||||
<q-item-section
|
||||
avatar
|
||||
>
|
||||
<q-icon name="settings" />
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label>Settings</q-item-label>
|
||||
<q-item-label caption>Manage application settings</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
</q-list>
|
||||
</q-drawer>
|
||||
|
||||
<q-page-container>
|
||||
<router-view />
|
||||
</q-page-container>
|
||||
|
||||
<!-- Chat FAB -->
|
||||
<q-page-sticky v-if="isAuthenticated" position="bottom-right" :offset="[18, 18]">
|
||||
<q-fab
|
||||
v-model="fabOpen"
|
||||
icon="chat"
|
||||
color="accent"
|
||||
direction="up"
|
||||
padding="sm"
|
||||
@click="toggleChat"
|
||||
/>
|
||||
</q-page-sticky>
|
||||
|
||||
<!-- Chat Window Dialog -->
|
||||
<q-dialog v-model="isChatVisible" :maximized="$q.screen.lt.sm" persistent>
|
||||
<q-card style="width: 400px; height: 600px; max-height: 80vh;">
|
||||
<q-bar class="bg-primary text-white">
|
||||
<div>Chat</div>
|
||||
<q-space />
|
||||
<q-btn dense flat icon="close" @click="toggleChat" />
|
||||
</q-bar>
|
||||
|
||||
<q-card-section class="q-pa-none" style="height: calc(100% - 50px);"> <!-- Adjust height based on q-bar -->
|
||||
<ChatInterface
|
||||
:messages="chatMessages"
|
||||
@send-message="handleSendMessage"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-inner-loading :showing="isLoading">
|
||||
<q-spinner-gears size="50px" color="primary" />
|
||||
</q-inner-loading>
|
||||
<q-banner v-if="chatError" inline-actions class="text-white bg-red">
|
||||
{{ chatError }}
|
||||
<template v-slot:action>
|
||||
<q-btn flat color="white" label="Dismiss" @click="clearError" />
|
||||
</template>
|
||||
</q-banner>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useChatStore } from 'stores/chat' // Adjust path as needed
|
||||
import { useAuthStore } from 'stores/auth' // Import the auth store
|
||||
import ChatInterface from 'components/ChatInterface.vue' // Adjust path as needed
|
||||
|
||||
const $q = useQuasar()
|
||||
const chatStore = useChatStore()
|
||||
const authStore = useAuthStore() // Use the auth store
|
||||
|
||||
const fabOpen = ref(false) // Local state for FAB animation, not chat visibility
|
||||
|
||||
// Computed properties to get state from the store
|
||||
const isChatVisible = computed(() => chatStore.isChatVisible)
|
||||
const chatMessages = computed(() => chatStore.chatMessages)
|
||||
const isLoading = computed(() => chatStore.isLoading)
|
||||
const chatError = computed(() => chatStore.error)
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated) // Get auth state
|
||||
|
||||
// Method to toggle chat visibility via the store action
|
||||
const toggleChat = () => {
|
||||
// Optional: Add an extra check here if needed, though hiding the button is primary
|
||||
if (isAuthenticated.value) {
|
||||
chatStore.toggleChat()
|
||||
}
|
||||
}
|
||||
|
||||
// Method to send a message via the store action
|
||||
const handleSendMessage = (messageContent) => {
|
||||
chatStore.sendMessage(messageContent)
|
||||
}
|
||||
|
||||
// Method to clear errors in the store (optional)
|
||||
const clearError = () => {
|
||||
chatStore.error = null; // Directly setting ref or add an action in store
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any specific styles for the layout or chat window here */
|
||||
.q-dialog .q-card {
|
||||
overflow: hidden; /* Prevent scrollbars on the card itself */
|
||||
}
|
||||
</style>
|
222
src/stores/chat.js
Normal file
222
src/stores/chat.js
Normal file
|
@ -0,0 +1,222 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import { ref, computed, watch } from 'vue'; // Import watch
|
||||
import axios from 'axios';
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const isVisible = ref(false);
|
||||
const currentThreadId = ref(null);
|
||||
const messages = ref([]); // Array of { sender: 'user' | 'bot', content: string, createdAt?: Date, loading?: boolean }
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
const pollingIntervalId = ref(null); // To store the interval ID
|
||||
|
||||
// --- Getters ---
|
||||
const chatMessages = computed(() => messages.value);
|
||||
const isChatVisible = computed(() => isVisible.value);
|
||||
const activeThreadId = computed(() => currentThreadId.value);
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
// New action to create a thread if it doesn't exist
|
||||
async function createThreadIfNotExists() {
|
||||
if (currentThreadId.value) return; // Already have a thread
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
// Call the endpoint without content to just create the thread
|
||||
const response = await axios.post('/api/chat/threads', {});
|
||||
currentThreadId.value = response.data.threadId;
|
||||
messages.value = []; // Start with an empty message list for the new thread
|
||||
console.log('Created new chat thread:', currentThreadId.value);
|
||||
// Start polling now that we have a thread ID
|
||||
startPolling();
|
||||
} catch (err) {
|
||||
console.error('Error creating chat thread:', err);
|
||||
error.value = 'Failed to start chat.';
|
||||
// Don't set isVisible to false, let the user see the error
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleChat() {
|
||||
isVisible.value = !isVisible.value;
|
||||
|
||||
if (isVisible.value) {
|
||||
if (!currentThreadId.value) {
|
||||
// If opening and no thread exists, create one
|
||||
createThreadIfNotExists();
|
||||
} else {
|
||||
// If opening and thread exists, fetch messages if empty and start polling
|
||||
if (messages.value.length === 0) {
|
||||
fetchMessages();
|
||||
}
|
||||
startPolling();
|
||||
}
|
||||
} else {
|
||||
// If closing, stop polling
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMessages() {
|
||||
if (!currentThreadId.value) {
|
||||
console.log('No active thread to fetch messages for.');
|
||||
// Don't try to fetch if no thread ID yet. createThreadIfNotExists handles the initial state.
|
||||
return;
|
||||
}
|
||||
// Avoid setting isLoading if polling, maybe use a different flag? For now, keep it simple.
|
||||
// isLoading.value = true; // Might cause flickering during polling
|
||||
error.value = null; // Clear previous errors on fetch attempt
|
||||
try {
|
||||
const response = await axios.get(`/api/chat/threads/${currentThreadId.value}/messages`);
|
||||
const newMessages = response.data.map(msg => ({
|
||||
sender: msg.sender,
|
||||
content: msg.content,
|
||||
createdAt: new Date(msg.createdAt),
|
||||
loading: msg.content === 'Loading...'
|
||||
})).sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
// Only update if messages have actually changed to prevent unnecessary re-renders
|
||||
if (JSON.stringify(messages.value) !== JSON.stringify(newMessages)) {
|
||||
messages.value = newMessages;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error fetching messages:', err);
|
||||
error.value = 'Failed to load messages.';
|
||||
// Don't clear messages on polling error, keep the last known state
|
||||
// messages.value = [];
|
||||
stopPolling(); // Stop polling if there's an error fetching
|
||||
} finally {
|
||||
// isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to start polling
|
||||
function startPolling() {
|
||||
if (pollingIntervalId.value) return; // Already polling
|
||||
if (!currentThreadId.value) return; // No thread to poll for
|
||||
|
||||
console.log('Starting chat polling for thread:', currentThreadId.value);
|
||||
pollingIntervalId.value = setInterval(fetchMessages, 5000); // Poll every 5 seconds
|
||||
}
|
||||
|
||||
// Function to stop polling
|
||||
function stopPolling() {
|
||||
if (pollingIntervalId.value) {
|
||||
console.log('Stopping chat polling.');
|
||||
clearInterval(pollingIntervalId.value);
|
||||
pollingIntervalId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function sendMessage(content) {
|
||||
if (!content.trim()) return;
|
||||
if (!currentThreadId.value) {
|
||||
error.value = "Cannot send message: No active chat thread.";
|
||||
console.error("Attempted to send message without a thread ID.");
|
||||
return; // Should not happen if UI waits for thread creation
|
||||
}
|
||||
|
||||
const userMessage = {
|
||||
sender: 'user',
|
||||
content: content.trim(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
messages.value.push(userMessage);
|
||||
|
||||
const loadingMessage = { sender: 'bot', content: '...', loading: true, createdAt: new Date(Date.now() + 1) }; // Ensure unique key/time
|
||||
messages.value.push(loadingMessage);
|
||||
|
||||
// Stop polling temporarily while sending a message to avoid conflicts
|
||||
stopPolling();
|
||||
|
||||
isLoading.value = true; // Indicate activity
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const payload = { content: userMessage.content };
|
||||
// Always post to the existing thread once it's created
|
||||
const response = await axios.post(`/api/chat/threads/${currentThreadId.value}/messages`, payload);
|
||||
|
||||
// Remove loading indicator
|
||||
messages.value = messages.value.filter(m => !m.loading);
|
||||
|
||||
// The POST might return the new message, but we'll rely on the next fetchMessages call
|
||||
// triggered by startPolling to get the latest state including any potential bot response.
|
||||
|
||||
// Immediately fetch messages after sending to get the updated list
|
||||
await fetchMessages();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error sending message:', err);
|
||||
error.value = 'Failed to send message.';
|
||||
// Remove loading indicator on error
|
||||
messages.value = messages.value.filter(m => !m.loading);
|
||||
// Optionally add an error message to the chat
|
||||
// Ensure the object is correctly formatted
|
||||
messages.value.push({ sender: 'bot', content: "Sorry, I couldn't send that message.", createdAt: new Date() });
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
// Restart polling after sending attempt is complete
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
// Call this when the user logs out or the app closes if you want to clear state
|
||||
function resetChat() {
|
||||
stopPolling(); // Ensure polling stops on reset
|
||||
isVisible.value = false;
|
||||
currentThreadId.value = null;
|
||||
messages.value = [];
|
||||
isLoading.value = false;
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
// Watch for visibility changes to manage polling (alternative to putting logic in toggleChat)
|
||||
// watch(isVisible, (newValue) => {
|
||||
// if (newValue && currentThreadId.value) {
|
||||
// startPolling();
|
||||
// } else {
|
||||
// stopPolling();
|
||||
// }
|
||||
// });
|
||||
|
||||
// Watch for thread ID changes (e.g., after creation)
|
||||
// watch(currentThreadId, (newId) => {
|
||||
// if (newId && isVisible.value) {
|
||||
// messages.value = []; // Clear old messages if any
|
||||
// fetchMessages(); // Fetch messages for the new thread
|
||||
// startPolling(); // Start polling for the new thread
|
||||
// } else {
|
||||
// stopPolling(); // Stop polling if thread ID becomes null
|
||||
// }
|
||||
// });
|
||||
|
||||
|
||||
return {
|
||||
// State refs
|
||||
isVisible,
|
||||
currentThreadId,
|
||||
messages,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Computed getters
|
||||
chatMessages,
|
||||
isChatVisible,
|
||||
activeThreadId,
|
||||
|
||||
// Actions
|
||||
toggleChat,
|
||||
sendMessage,
|
||||
fetchMessages, // Expose if needed externally
|
||||
resetChat,
|
||||
// Expose polling control if needed externally, though typically managed internally
|
||||
// startPolling,
|
||||
// stopPolling,
|
||||
};
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue