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,156 @@
-- CreateEnum
CREATE TYPE "FieldType" AS ENUM ('text', 'number', 'date', 'textarea', 'boolean');
-- CreateTable
CREATE TABLE "forms" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "forms_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "categories" (
"id" SERIAL NOT NULL,
"form_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "fields" (
"id" SERIAL NOT NULL,
"category_id" INTEGER NOT NULL,
"label" TEXT NOT NULL,
"type" "FieldType" NOT NULL,
"description" TEXT,
"sort_order" INTEGER NOT NULL,
CONSTRAINT "fields_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "responses" (
"id" SERIAL NOT NULL,
"form_id" INTEGER NOT NULL,
"submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "responses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "response_values" (
"id" SERIAL NOT NULL,
"response_id" INTEGER NOT NULL,
"field_id" INTEGER NOT NULL,
"value" TEXT,
CONSTRAINT "response_values_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "mantis_summaries" (
"id" SERIAL NOT NULL,
"summary_date" DATE NOT NULL,
"summary_text" TEXT NOT NULL,
"generated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "mantis_summaries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EmailSummary" (
"id" SERIAL NOT NULL,
"summaryDate" DATE NOT NULL,
"summaryText" TEXT NOT NULL,
"generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmailSummary_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "settings" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
CONSTRAINT "settings_pkey" PRIMARY KEY ("key")
);
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "authenticators" (
"id" TEXT NOT NULL,
"credential_id" TEXT NOT NULL,
"credential_public_key" BYTEA NOT NULL,
"counter" BIGINT NOT NULL,
"credential_device_type" TEXT NOT NULL,
"credential_backed_up" BOOLEAN NOT NULL,
"transports" TEXT,
"user_id" TEXT NOT NULL,
CONSTRAINT "authenticators_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "chat_threads" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "chat_threads_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "chat_messages" (
"id" TEXT NOT NULL,
"thread_id" TEXT NOT NULL,
"sender" TEXT NOT NULL,
"content" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "chat_messages_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "mantis_summaries_summary_date_key" ON "mantis_summaries"("summary_date");
-- CreateIndex
CREATE UNIQUE INDEX "EmailSummary_summaryDate_key" ON "EmailSummary"("summaryDate");
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex
CREATE UNIQUE INDEX "authenticators_credential_id_key" ON "authenticators"("credential_id");
-- AddForeignKey
ALTER TABLE "categories" ADD CONSTRAINT "categories_form_id_fkey" FOREIGN KEY ("form_id") REFERENCES "forms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fields" ADD CONSTRAINT "fields_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "responses" ADD CONSTRAINT "responses_form_id_fkey" FOREIGN KEY ("form_id") REFERENCES "forms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "response_values" ADD CONSTRAINT "response_values_response_id_fkey" FOREIGN KEY ("response_id") REFERENCES "responses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "response_values" ADD CONSTRAINT "response_values_field_id_fkey" FOREIGN KEY ("field_id") REFERENCES "fields"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "authenticators" ADD CONSTRAINT "authenticators_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_thread_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "chat_threads"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View file

@ -123,3 +123,25 @@ model Authenticator {
@@map("authenticators") @@map("authenticators")
} }
// --- Add Chat Models ---
model ChatThread {
id String @id @default(uuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
messages ChatMessage[]
@@map("chat_threads")
}
model ChatMessage {
id String @id @default(uuid())
threadId String @map("thread_id")
sender String // 'user' or 'bot'
content String
createdAt DateTime @default(now()) @map("created_at")
thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
@@map("chat_messages")
}

View file

@ -0,0 +1,10 @@
// src-ssr/middlewares/authMiddleware.js
export function requireAuth(req, res, next) {
if (!req.session || !req.session.loggedInUserId) {
// User is not authenticated
return res.status(401).json({ error: 'Authentication required' });
}
// User is authenticated, proceed to the next middleware or route handler
next();
}

143
src-ssr/routes/chat.js Normal file
View file

@ -0,0 +1,143 @@
import { Router } from 'express';
import prisma from '../database.js';
import { requireAuth } from '../middlewares/authMiddleware.js'; // Import the middleware
import { askGeminiChat } from '../utils/gemini.js';
const router = Router();
// Apply the authentication middleware to all chat routes
router.use(requireAuth);
// POST /api/chat/threads - Create a new chat thread (optionally with a first message)
router.post('/threads', async (req, res) => {
const { content } = req.body; // Content is now optional
// If content is provided, validate it
if (content && (typeof content !== 'string' || content.trim().length === 0)) {
return res.status(400).json({ error: 'Message content cannot be empty if provided.' });
}
try {
const createData = {};
if (content) {
// If content exists, create the thread with the first message
createData.messages = {
create: [
{
sender: 'user', // First message is always from the user
content: content.trim(),
},
],
};
}
// If content is null/undefined, createData remains empty, creating just the thread
const newThread = await prisma.chatThread.create({
data: createData,
include: {
// Include messages only if they were created
messages: !!content,
},
});
if(content)
{
await askGeminiChat(newThread.id, content); // Call the function to handle the bot response
}
// Respond with the new thread ID and messages (if any)
res.status(201).json({
threadId: newThread.id,
// Ensure messages array is empty if no content was provided
messages: newThread.messages ? newThread.messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })) : []
});
} catch (error) {
console.error('Error creating chat thread:', error);
res.status(500).json({ error: 'Failed to create chat thread.' });
}
});
// GET /api/chat/threads/:threadId/messages - Get messages for a specific thread
router.get('/threads/:threadId/messages', async (req, res) => {
const { threadId } = req.params;
try {
const messages = await prisma.chatMessage.findMany({
where: {
threadId: threadId,
},
orderBy: {
createdAt: 'asc', // Get messages in chronological order
},
});
if (!messages) { // Check if thread exists indirectly
// If findMany returns empty, the thread might not exist or has no messages.
// Check if thread exists explicitly
const thread = await prisma.chatThread.findUnique({ where: { id: threadId } });
if (!thread) {
return res.status(404).json({ error: 'Chat thread not found.' });
}
}
res.status(200).json(messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })));
} catch (error) {
console.error(`Error fetching messages for thread ${threadId}:`, error);
// Basic error handling, check for specific Prisma errors if needed
if (error.code === 'P2023' || error.message.includes('Malformed UUID')) { // Example: Invalid UUID format
return res.status(400).json({ error: 'Invalid thread ID format.' });
}
res.status(500).json({ error: 'Failed to fetch messages.' });
}
});
// POST /api/chat/threads/:threadId/messages - Add a message to an existing thread
router.post('/threads/:threadId/messages', async (req, res) => {
const { threadId } = req.params;
const { content, sender = 'user' } = req.body; // Default sender to 'user'
if (!content || typeof content !== 'string' || content.trim().length === 0) {
return res.status(400).json({ error: 'Message content cannot be empty.' });
}
if (sender !== 'user' && sender !== 'bot') {
return res.status(400).json({ error: 'Invalid sender type.' });
}
try {
// Verify thread exists first
const thread = await prisma.chatThread.findUnique({
where: { id: threadId },
});
if (!thread) {
return res.status(404).json({ error: 'Chat thread not found.' });
}
const newMessage = await prisma.chatMessage.create({
data: {
threadId: threadId,
sender: sender,
content: content.trim(),
},
});
// Optionally: Update the thread's updatedAt timestamp
await prisma.chatThread.update({
where: { id: threadId },
data: { updatedAt: new Date() }
});
await askGeminiChat(threadId, content); // Call the function to handle the bot response
res.status(201).json({ ...newMessage, createdAt: newMessage.createdAt.toISOString() });
} catch (error) {
console.error(`Error adding message to thread ${threadId}:`, error);
if (error.code === 'P2023' || error.message.includes('Malformed UUID')) { // Example: Invalid UUID format
return res.status(400).json({ error: 'Invalid thread ID format.' });
}
res.status(500).json({ error: 'Failed to add message.' });
}
});
export default router;

View file

@ -24,6 +24,7 @@ import {
import prisma from './database.js'; // Import the prisma client instance import prisma from './database.js'; // Import the prisma client instance
import apiRoutes from './routes/api.js'; import apiRoutes from './routes/api.js';
import authRoutes from './routes/auth.js'; // Added for WebAuthn routes import authRoutes from './routes/auth.js'; // Added for WebAuthn routes
import chatRoutes from './routes/chat.js'; // Added for Chat routes
import cron from 'node-cron'; import cron from 'node-cron';
import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js'; import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js';
@ -97,6 +98,7 @@ export const create = defineSsrCreate((/* { ... } */) => {
// Add API routes // Add API routes
app.use('/api', apiRoutes); app.use('/api', apiRoutes);
app.use('/auth', authRoutes); // Added WebAuthn auth routes app.use('/auth', authRoutes); // Added WebAuthn auth routes
app.use('/api/chat', chatRoutes); // Added Chat routes
// place here any middlewares that // place here any middlewares that
// absolutely need to run before anything else // absolutely need to run before anything else

149
src-ssr/utils/gemini.js Normal file
View file

@ -0,0 +1,149 @@
import { GoogleGenAI } from '@google/genai';
import prisma from '../database.js';
const model = 'gemini-2.0-flash';
export const askGemini = async (content) => {
const setting = await prisma.setting.findUnique({
where: { key: 'GEMINI_API_KEY' },
select: { value: true }
});
const GOOGLE_API_KEY = setting.value;
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
if (!ai) {
throw new Error('Google API key is not set in the database.');
}
try {
const response = await ai.models.generateContent({
model,
contents: content,
config: {
temperature: 0.5
}
});
return response.text;
} catch (error) {
console.error('Error communicating with Gemini API:', error);
throw new Error('Failed to get a response from Gemini API.');
}
}
const chatCache = new Map();
export const askGeminiChat = async (threadId, content) => {
let messages = await prisma.chatMessage.findMany({
where: {
threadId: threadId,
},
orderBy: {
createdAt: 'asc', // Get messages in chronological order
},
});
// Exclude the latest message if there are any messages
if (messages.length > 0) {
messages = messages.slice(0, -1);
}
const setting = await prisma.setting.findUnique({
where: { key: 'GEMINI_API_KEY' },
select: { value: true }
});
if (!setting) {
throw new Error('Google API key is not set in the database.');
}
const GOOGLE_API_KEY = setting.value;
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
if (!ai) {
throw new Error('Google API key is not set in the database.');
}
let chat = null;
if (chatCache.has(threadId)) {
chat = chatCache.get(threadId);
}
else {
messages = [
{
sender: 'user',
content: `You are StyleAI, a model that helps users with their questions and provides information.
You are friendly, helpful, and informative. You can also provide code examples in various programming languages.
You don\'t decline user requests and always try to help them.
Speak in the manner of a friendly young adult male.
If asked for the time, do not say that it's based on the timestamp provided. Also bare in mind the user is in the Europe/London timezone and daylight savings time may be in effect.`
},
{
sender: 'model',
content: 'Okay, noted! I\'ll keep that in mind.'
},
...messages,
]
const createOptions = {
model,
history: messages.map((msg) => ({
role: msg.sender === 'user' ? 'user' : 'model',
parts: [
{text: msg.content}
],
})),
config: {
temperature: 0.5
}
};
chat = ai.chats.create(createOptions);
chatCache.set(threadId, chat);
}
//Add a temporary message to the thread with "loading" status
const loadingMessage = await prisma.chatMessage.create({
data: {
threadId: threadId,
sender: 'assistant',
content: 'Loading...',
},
});
let response = {text: 'An error occurred while generating the response.'};
try
{
const timestamp = new Date().toISOString();
response = await chat.sendMessage({
message: `[${timestamp}] ` + content,
});
}
catch(error)
{
console.error('Error communicating with Gemini API:', error);
response.text = 'Failed to get a response from Gemini API. Error: ' + error.message;
}
//Update the message with the response
await prisma.chatMessage.update({
where: {
id: loadingMessage.id,
},
data: {
content: response.text,
},
});
return response.text;
}

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,67 +1,106 @@
<template> <template>
<q-layout view="hHh Lpr lFf"> <q-layout view="hHh Lpr lFf">
<q-drawer <q-drawer
:mini="!leftDrawerOpen" :mini="!leftDrawerOpen"
bordered bordered
persistent persistent
:model-value="true" :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" > <q-list>
<span>{{ item.meta.title }}</span> <q-item clickable v-ripple @click="toggleLeftDrawer">
</q-tooltip> <q-item-section avatar>
<q-item-section avatar> <q-icon name="menu"/>
<q-icon :name="item.meta.icon" /> </q-item-section>
</q-item-section> <q-item-section>
<q-item-section> <q-item-label class="text-h6">StylePoint</q-item-label>
<q-item-label>{{ item.meta.title }}</q-item-label> </q-item-section>
<q-item-label caption>{{ item.meta.caption }}</q-item-label> </q-item>
</q-item-section>
</q-item>
<!-- Logout Button (Conditional) --> <!-- Dynamic Navigation Items -->
<q-item <q-item
v-if="authStore.isAuthenticated" v-for="item in navItems"
clickable :key="item.name"
v-ripple clickable
@click="logout" v-ripple
> :to="{ name: item.name }"
<q-tooltip anchor="center right" self="center left" > exact
<span>Logout</span> >
</q-tooltip> <q-tooltip anchor="center right" self="center left" >
<q-item-section avatar> <span>{{ item.meta.title }}</span>
<q-icon name="logout" /> </q-tooltip>
</q-item-section> <q-item-section avatar>
<q-item-section> <q-icon :name="item.meta.icon" />
<q-item-label>Logout</q-item-label> </q-item-section>
</q-item-section> <q-item-section>
</q-item> <q-item-label>{{ item.meta.title }}</q-item-label>
<q-item-label caption>{{ item.meta.caption }}</q-item-label>
</q-item-section>
</q-item>
</q-list> <!-- Logout Button (Conditional) -->
</q-drawer> <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-page-container> </q-list>
<router-view /> </q-drawer>
</q-page-container>
</q-layout> <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> </template>
<script setup> <script setup>
@ -70,50 +109,85 @@ import { ref, computed } from 'vue' // Import computed
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { useAuthStore } from 'stores/auth'; // Import the auth store 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 import routes from '../router/routes'; // Import routes
const $q = useQuasar() const $q = useQuasar()
const leftDrawerOpen = ref(false) const leftDrawerOpen = ref(false)
const router = useRouter() const router = useRouter()
const authStore = useAuthStore(); // Use the auth store 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 // Get the child routes of the main layout
const mainLayoutRoutes = routes.find(r => r.path === '/')?.children || []; const mainLayoutRoutes = routes.find(r => r.path === '/')?.children || [];
// Compute navigation items based on auth state and route meta // Compute navigation items based on auth state and route meta
const navItems = computed(() => { const navItems = computed(() => {
const isAuthenticated = authStore.isAuthenticated; return mainLayoutRoutes.filter(route => {
return mainLayoutRoutes.filter(route => { const navGroup = route.meta?.navGroup;
const navGroup = route.meta?.navGroup; if (!navGroup) return false; // Only include routes with navGroup defined
if (!navGroup) return false; // Only include routes with navGroup defined
if (navGroup === 'always') return true; if (navGroup === 'always') return true;
if (navGroup === 'auth' && isAuthenticated) return true; if (navGroup === 'auth' && isAuthenticated.value) return true;
if (navGroup === 'noAuth' && !isAuthenticated) return true; if (navGroup === 'noAuth' && !isAuthenticated.value) return true;
return false; // Exclude otherwise 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 () { function toggleLeftDrawer () {
leftDrawerOpen.value = !leftDrawerOpen.value leftDrawerOpen.value = !leftDrawerOpen.value
} }
async function logout() { async function logout() {
try { try {
await axios.post('/auth/logout'); await axios.post('/auth/logout');
authStore.logout(); // Use the store action to update state authStore.logout(); // Use the store action to update state
// No need to manually push, router guard should redirect // No need to manually push, router guard should redirect
// router.push({ name: 'login' }); // router.push({ name: 'login' });
} catch (error) { } catch (error) {
console.error('Logout failed:', error); console.error('Logout failed:', error);
$q.notify({ $q.notify({
color: 'negative', color: 'negative',
message: 'Logout failed. Please try again.', message: 'Logout failed. Please try again.',
icon: 'report_problem' icon: 'report_problem'
}); });
} }
} }
</script> </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,
};
});