Adds in AI chatting system.
This commit is contained in:
parent
28c054de22
commit
8655eae39c
11 changed files with 1198 additions and 119 deletions
156
prisma/migrations/20250424205631_add_chat_models/migration.sql
Normal file
156
prisma/migrations/20250424205631_add_chat_models/migration.sql
Normal 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;
|
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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"
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
10
src-ssr/middlewares/authMiddleware.js
Normal file
10
src-ssr/middlewares/authMiddleware.js
Normal 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
143
src-ssr/routes/chat.js
Normal 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;
|
|
@ -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
149
src-ssr/utils/gemini.js
Normal 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;
|
||||||
|
}
|
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>
|
|
@ -61,6 +61,45 @@
|
||||||
<q-page-container>
|
<q-page-container>
|
||||||
<router-view />
|
<router-view />
|
||||||
</q-page-container>
|
</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>
|
</q-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -70,32 +109,60 @@ 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
|
||||||
}
|
}
|
||||||
|
@ -117,3 +184,10 @@ async function logout() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</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>
|
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