Adds in AI chatting system.

This commit is contained in:
Cameron Redmore 2025-04-24 23:20:20 +01:00
parent 28c054de22
commit 8655eae39c
11 changed files with 1198 additions and 119 deletions

View 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>

View file

@ -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>

View 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
View 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,
};
});