Last active
April 30, 2025 09:01
-
-
Save Cozy228/8d94231c54026a12344e85f6fb91b4ad to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // components/ChatPage/ChatPage.tsx | |
| import React, { useState, useCallback } from 'react'; | |
| import { Grid, Paper, makeStyles } from '@material-ui/core'; | |
| import { ChatHistorySidebar } from '../ChatHistorySidebar/ChatHistorySidebar'; // Adjust path as needed | |
| import { ChatWindow } from '../ChatWindow/ChatWindow'; // Adjust path as needed | |
| // Optional: Custom styles for consistent height and layout | |
| const useStyles = makeStyles((theme) => ({ | |
| container: { | |
| // Example: Subtract App Bar height if necessary, adjust as needed | |
| // Use vh for viewport height or ensure parent container provides height | |
| // Ensure no negative heights if calculation is wrong | |
| height: 'max(calc(100vh - 64px - 2 * 8px), 400px)', // Example: 100vh - AppBar(64px) - Padding(2*8px), min height 400px | |
| padding: theme.spacing(1), // Add some padding around the grid | |
| overflow: 'hidden', // Prevent the main container from scrolling | |
| }, | |
| paper: { // General paper style for direct children of Grid items | |
| height: '100%', | |
| display: 'flex', | |
| flexDirection: 'column', // Ensure content inside Paper behaves correctly | |
| overflow: 'hidden', // Prevent Paper itself from scrolling, children handle scroll | |
| }, | |
| // No need for sidebarPaper/chatWindowPaper if the general paper class above suffices | |
| })); | |
| export const ChatPage = () => { | |
| const classes = useStyles(); | |
| // State for the currently selected/active conversation ID | |
| const [currentConversationId, setCurrentConversationId] = useState<string | null>(null); | |
| // State for managing sidebar visibility | |
| const [isSidebarExpanded, setIsSidebarExpanded] = useState(true); // Default to expanded | |
| // State to trigger refetch in the sidebar, incremented when needed | |
| const [sidebarRefetchKey, setSidebarRefetchKey] = useState<number>(0); | |
| // Callback: Called by Sidebar when a conversation is clicked or "New Chat" | |
| const handleSelectConversation = useCallback((id: string | null) => { | |
| setCurrentConversationId(id); | |
| // Optional UX: Automatically expand sidebar when a conversation is selected? | |
| // if (id !== null && !isSidebarExpanded) { | |
| // setIsSidebarExpanded(true); | |
| // } | |
| }, []); // No dependencies needed if only using setter | |
| // Callback: Called by Sidebar's toggle button | |
| const handleToggleSidebar = useCallback(() => { | |
| setIsSidebarExpanded(prev => !prev); | |
| }, []); // No dependencies needed | |
| // Callback: Called by ChatWindow when a new conversation is successfully initiated via API | |
| const handleConversationStarted = useCallback((newId: string) => { | |
| // Set the new conversation as the current one | |
| setCurrentConversationId(newId); | |
| // Increment key to trigger sidebar refetch | |
| // This helps the sidebar show the new chat (with its initial title) sooner, | |
| // and potentially pick up the AI-generated title later if the user stays on the page. | |
| setSidebarRefetchKey(prev => prev + 1); | |
| // Optional UX: Force expand sidebar when new chat starts? | |
| // if (!isSidebarExpanded) { | |
| // setIsSidebarExpanded(true); | |
| // } | |
| }, []); // No dependencies needed if only using setters | |
| // --- Render Logic --- | |
| return ( | |
| <Grid container spacing={1} className={classes.container}> | |
| {/* Sidebar Grid Item - Width adjusts based on state */} | |
| {/* Adjust xs values as needed for visual balance */} | |
| <Grid item xs={isSidebarExpanded ? 4 : 1} sm={isSidebarExpanded ? 3 : 1}> | |
| {/* Wrap Sidebar in Paper for background/elevation */} | |
| <Paper className={classes.paper} elevation={1} square> | |
| <ChatHistorySidebar | |
| onSelectConversation={handleSelectConversation} | |
| currentConversationId={currentConversationId} | |
| isExpanded={isSidebarExpanded} | |
| onToggleExpand={handleToggleSidebar} | |
| refetchKey={sidebarRefetchKey} // Pass down the key to trigger refetch | |
| /> | |
| </Paper> | |
| </Grid> | |
| {/* Chat Window Grid Item - Width adjusts based on state */} | |
| <Grid item xs={isSidebarExpanded ? 8 : 11} sm={isSidebarExpanded ? 9 : 11}> | |
| {/* Wrap ChatWindow in Paper */} | |
| <Paper className={classes.paper} elevation={1} square> | |
| <ChatWindow | |
| conversationId={currentConversationId} // Pass down selected conversation ID | |
| onConversationStarted={handleConversationStarted} // Pass down callback for new convos | |
| /> | |
| </Paper> | |
| </Grid> | |
| </Grid> | |
| ); | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // plugins/chat-backend/src/database/DbConversationStore.ts | |
| import { Knex } from 'knex'; | |
| import { LoggerService } from '@backstage/backend-plugin-api'; | |
| import { v4 as uuid } from 'uuid'; | |
| import { Conversation, ConversationMeta, FeedbackInput, Message } from '../types'; // Assume types are defined in ../types.ts | |
| // Define table names constants for easier maintenance | |
| const T_CONVERSATIONS = 'conversations'; | |
| const T_MESSAGES = 'messages'; | |
| const T_FEEDBACK = 'message_feedback'; | |
| export class DbConversationStore { | |
| constructor(private readonly db: Knex, private readonly logger: LoggerService) {} | |
| /** | |
| * Creates a new conversation record for a user with a specified initial title. | |
| */ | |
| async createConversation(userRef: string, initialTitle: string): Promise<{ id: string }> { | |
| // Basic validation/truncation for initial title if it's too long | |
| let titleToSave = initialTitle.trim().substring(0, 100); // Example: Truncate long messages used as titles | |
| if (!titleToSave) { | |
| // Fallback if the initial message was empty or just whitespace | |
| titleToSave = 'New Chat'; | |
| } | |
| this.logger.info(`Creating new conversation for user ${userRef} with initial title "${titleToSave}"`); | |
| const newConversationId = uuid(); | |
| await this.db<Conversation>(T_CONVERSATIONS).insert({ | |
| id: newConversationId, | |
| user_ref: userRef, | |
| title: titleToSave, // Use the provided initial title | |
| // created_at and updated_at will use DB defaults if schema is set up (e.g., DEFAULT CURRENT_TIMESTAMP) | |
| }); | |
| return { id: newConversationId }; | |
| } | |
| /** | |
| * Retrieves a conversation by ID, ensuring it belongs to the specified user. | |
| */ | |
| async getConversation(id: string, userRef: string): Promise<Conversation | null> { | |
| const conversation = await this.db<Conversation>(T_CONVERSATIONS) | |
| .where({ id, user_ref: userRef }) | |
| .first(); | |
| return conversation || null; | |
| } | |
| /** | |
| * Lists conversation metadata (id, title, updated_at) for a given user, ordered by most recently updated. | |
| */ | |
| async listConversations(userRef: string): Promise<ConversationMeta[]> { | |
| return this.db<Conversation>(T_CONVERSATIONS) | |
| .select('id', 'title', 'updated_at') | |
| .where({ user_ref: userRef }) | |
| .orderBy('updated_at', 'desc'); | |
| } | |
| /** | |
| * Updates the 'updated_at' timestamp for a conversation. | |
| * Can be called internally after verifying user actions. | |
| */ | |
| async updateConversationTimestamp(id: string): Promise<void> { | |
| await this.db(T_CONVERSATIONS) | |
| .where({ id }) | |
| .update({ updated_at: this.db.fn.now() }); // Use database's NOW() function | |
| } | |
| /** | |
| * Updates the title and timestamp of a conversation, ensuring it belongs to the specified user. | |
| * @returns boolean indicating if the update was successful (row found and updated). | |
| */ | |
| async updateConversationTitle(id: string, userRef: string, newTitle: string): Promise<boolean> { | |
| const trimmedTitle = newTitle.trim().substring(0, 100); // Also truncate here | |
| if (!trimmedTitle) { | |
| this.logger.warn(`Attempt to set empty title for conversation ${id} by user ${userRef}`); | |
| // Prevent updating to an empty title at the DB level as well | |
| return false; // Indicate failure | |
| } | |
| const result = await this.db(T_CONVERSATIONS) | |
| .where({ id, user_ref: userRef }) // Crucial: Ensure ownership | |
| .update({ | |
| title: trimmedTitle, // Store trimmed title | |
| updated_at: this.db.fn.now(), // Update timestamp as it's a modification | |
| }); | |
| this.logger.info(`Updated title for conversation ${id} by user ${userRef}. Rows affected: ${result}`); | |
| return result > 0; // Returns true if 1 row was affected, false otherwise | |
| } | |
| /** | |
| * Deletes a conversation and its associated messages/feedback (relies on DB CASCADE or requires manual deletion). | |
| * Ensures the conversation belongs to the user requesting deletion. | |
| */ | |
| async deleteConversation(id: string, userRef: string): Promise<boolean> { | |
| // Ensure messages and feedback related to this conversation are deleted. | |
| // Best practice: Use ON DELETE CASCADE foreign key constraints in your DB schema. | |
| // If not using CASCADE, you'd need to delete from T_FEEDBACK and T_MESSAGES here first. | |
| // Example (if no CASCADE): | |
| // await this.db(T_FEEDBACK).whereIn('message_id', function() { | |
| // this.select('id').from(T_MESSAGES).where('conversation_id', id); | |
| // }).delete(); | |
| // await this.db(T_MESSAGES).where('conversation_id', id).delete(); | |
| const result = await this.db(T_CONVERSATIONS) | |
| .where({ id, user_ref: userRef }) // Ensure ownership before deleting conversation | |
| .delete(); | |
| this.logger.info(`Deleted ${result} conversation(s) with id ${id} for user ${userRef}`); | |
| return result > 0; // Return true if a row was deleted | |
| } | |
| /** | |
| * Saves a message to the database and updates the conversation's timestamp. | |
| */ | |
| async saveMessage( | |
| messageData: Omit<Message, 'id' | 'timestamp' | 'userFeedback'> // Exclude fields generated by DB or added later | |
| ): Promise<Message> { | |
| const newMessageId = uuid(); | |
| // Use transaction to ensure message is saved AND timestamp is updated, or neither | |
| const savedMessage = await this.db.transaction(async (trx) => { | |
| const [insertedMessage] = await trx<Message>(T_MESSAGES) | |
| .insert({ | |
| id: newMessageId, | |
| ...messageData, | |
| metadata: messageData.metadata ? JSON.stringify(messageData.metadata) : null, // Ensure metadata is stored as JSON string if DB type is TEXT/VARCHAR | |
| // timestamp will use DB default if schema is set up | |
| }) | |
| .returning('*'); // Return the full row including defaults | |
| // Update conversation timestamp within the same transaction | |
| await trx(T_CONVERSATIONS) | |
| .where({ id: messageData.conversation_id }) | |
| .update({ updated_at: this.db.fn.now() }); | |
| return insertedMessage; | |
| }); | |
| this.logger.info(`Saved message ${newMessageId} for conversation ${messageData.conversation_id}`); | |
| // Parse metadata back to JSON object after retrieving, if necessary (depends on Message type) | |
| if (savedMessage.metadata && typeof savedMessage.metadata === 'string') { | |
| try { | |
| savedMessage.metadata = JSON.parse(savedMessage.metadata); | |
| } catch (e) { | |
| this.logger.warn(`Failed to parse metadata for message ${savedMessage.id}`); | |
| savedMessage.metadata = { error: 'Failed to parse stored metadata' }; | |
| } | |
| } | |
| // Add null feedback property for frontend consistency before returning if needed by Message type definition | |
| // (Alternatively, handle this transformation in the router where feedback is fetched) | |
| // return { ...savedMessage, userFeedback: null }; | |
| return savedMessage; | |
| } | |
| /** | |
| * Retrieves a single message by its ID. | |
| */ | |
| async getMessageById(id: string): Promise<Message | null> { | |
| const message = await this.db<Message>(T_MESSAGES).where({ id }).first(); | |
| if (message && message.metadata && typeof message.metadata === 'string') { | |
| try { | |
| message.metadata = JSON.parse(message.metadata); | |
| } catch (e) { | |
| this.logger.warn(`Failed to parse metadata for message ${message.id} in getMessageById`); | |
| message.metadata = { error: 'Failed to parse stored metadata' }; | |
| } | |
| } | |
| return message || null; | |
| } | |
| /** | |
| * Retrieves all messages for a given conversation ID, ensuring it belongs to the user. | |
| * Ordered by timestamp ascendingly. | |
| */ | |
| async getMessages(conversationId: string, userRef: string): Promise<Message[]> { | |
| // First, verify the user owns the conversation | |
| const conversation = await this.getConversation(conversationId, userRef); | |
| if (!conversation) { | |
| this.logger.warn(`User ${userRef} attempted to access unauthorized or non-existent conversation ${conversationId}`); | |
| // Return empty array; the router performs an additional check to return 404 if needed. | |
| return []; | |
| } | |
| const messages = await this.db<Message>(T_MESSAGES) | |
| .where({ conversation_id: conversationId }) | |
| .orderBy('timestamp', 'asc'); | |
| // Parse metadata for all messages | |
| messages.forEach(message => { | |
| if (message.metadata && typeof message.metadata === 'string') { | |
| try { | |
| message.metadata = JSON.parse(message.metadata); | |
| } catch (e) { | |
| this.logger.warn(`Failed to parse metadata for message ${message.id} in getMessages`); | |
| message.metadata = { error: 'Failed to parse stored metadata' }; | |
| } | |
| } | |
| }); | |
| return messages; | |
| } | |
| /** | |
| * Saves or updates user feedback for a specific message. | |
| */ | |
| async saveFeedback(feedbackInput: FeedbackInput): Promise<void> { | |
| // Ensure complex objects are stringified if the DB column type requires it | |
| const dataToSave = { | |
| ...feedbackInput, | |
| rated_message_content: feedbackInput.rated_message_content?.substring(0, 500), // Truncate denormalized content | |
| rag_context: feedbackInput.rag_context ? JSON.stringify(feedbackInput.rag_context) : null, | |
| }; | |
| await this.db(T_FEEDBACK) | |
| .insert(dataToSave) | |
| .onConflict(['message_id', 'user_ref']) // Assumes primary key or unique constraint on these columns | |
| .merge(); // Update if exists based on conflict target, insert if not | |
| this.logger.info(`Saved feedback for message ${feedbackInput.message_id} from user ${feedbackInput.user_ref}`); | |
| } | |
| /** | |
| * Retrieves feedback status for multiple messages by a specific user. | |
| * @returns A Map where key is message_id and value is feedback_type ('up' | 'down' | null). | |
| */ | |
| async getFeedbackForMessagesByUser(messageIds: string[], userRef: string): Promise<Map<string, 'up' | 'down' | null>> { | |
| const feedbackMap = new Map<string, 'up' | 'down' | null>(); | |
| if (messageIds.length === 0) { | |
| return feedbackMap; | |
| } | |
| const feedbackRecords = await this.db(T_FEEDBACK) | |
| .select('message_id', 'feedback_type') | |
| .whereIn('message_id', messageIds) | |
| .andWhere({ user_ref: userRef }); | |
| feedbackRecords.forEach(record => { | |
| // Ensure feedback_type is one of the expected values or null | |
| if (record.feedback_type === 'up' || record.feedback_type === 'down') { | |
| feedbackMap.set(record.message_id, record.feedback_type); | |
| } else { | |
| // Store null if the value is unexpected or explicitly null in DB | |
| feedbackMap.set(record.message_id, null); | |
| } | |
| }); | |
| return feedbackMap; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // plugins/chat-backend/src/service/router.ts | |
| import { | |
| DatabaseService, | |
| LoggerService, | |
| HttpRouterService, | |
| coreServices, // Import coreServices for plugin deps | |
| } from '@backstage/backend-plugin-api'; | |
| import { IdentityApi } from '@backstage/plugin-auth-node'; | |
| import express from 'express'; | |
| import Router from 'express-promise-router'; | |
| import { DbConversationStore } from '../database/DbConversationStore'; | |
| // Assume types are defined in ../types.ts relative to this file | |
| import { FeedbackInput, HistoryApiResponse, Message, SendApiResponse } from '../types'; | |
| // --- Interface for RAG Service (Example) --- | |
| // You'll need to define and implement this based on your actual RAG/LLM setup | |
| interface RagService { | |
| generateResponse(params: { | |
| userQuery: string; | |
| conversationHistory: Message[]; // Assuming Message type from types.ts | |
| conversationId: string; // Pass ID for context/logging | |
| userRef: string; // Pass user for context/logging | |
| }): Promise<{ responseContent: string; responseMetadata?: any }>; | |
| generateTitle(params: { | |
| firstUserMessageContent: string; | |
| conversationId: string; | |
| userRef: string; | |
| }): Promise<{ generatedTitle: string }>; | |
| } | |
| // --- Mock RAG Service (Replace with actual implementation) --- | |
| // This serves as a placeholder and should be replaced by your actual RAG service client. | |
| const createMockRagService = (logger: LoggerService): RagService => ({ | |
| generateResponse: async (params) => { | |
| logger.info(`[Mock RAG] Generating response for query "${params.userQuery}" in conv ${params.conversationId} for user ${params.userRef}`); | |
| // Simulate network delay and basic response generation | |
| await new Promise(resolve => setTimeout(resolve, 500 + Math.random() * 500)); // Simulate 0.5-1s delay | |
| return { | |
| responseContent: `(Mock AI response) This is a simulated answer to: "${params.userQuery}". History length: ${params.conversationHistory.length}.`, | |
| responseMetadata: { source: 'mock_rag_service', timestamp: new Date().toISOString(), history_length_used: params.conversationHistory.length }, | |
| }; | |
| }, | |
| generateTitle: async (params) => { | |
| logger.info(`[Mock RAG] Generating title for conv ${params.conversationId} based on: "${params.firstUserMessageContent.substring(0, 50)}..." for user ${params.userRef}`); | |
| // Simulate network delay and title generation | |
| await new Promise(resolve => setTimeout(resolve, 800 + Math.random() * 700)); // Simulate 0.8-1.5s delay | |
| const words = params.firstUserMessageContent.split(' '); | |
| // Generate a slightly more varied title | |
| let title = words.slice(0, 3 + Math.floor(Math.random() * 3)).join(' '); // Take 3-5 words | |
| title = title || 'Generated Title'; // Fallback | |
| if (words.length > 5) { | |
| title += '...'; | |
| } | |
| logger.info(`[Mock RAG] Generated title: "${title}" for conv ${params.conversationId}`); | |
| return { | |
| // Ensure title is not excessively long | |
| generatedTitle: title.substring(0, 80), | |
| }; | |
| }, | |
| }); | |
| // --- End Mock RAG Service --- | |
| export interface RouterOptions { | |
| logger: LoggerService; | |
| identity: IdentityApi; | |
| database: DatabaseService; | |
| ragService: RagService; // Add RAG service dependency | |
| } | |
| // Helper middleware to handle user authentication | |
| const createIdentityMiddleware = (identity: IdentityApi, logger: LoggerService) => { | |
| return async ( | |
| req: express.Request & { userRef?: string }, // Augment Request type | |
| res: express.Response, | |
| next: express.NextFunction | |
| ) => { | |
| try { | |
| const userIdentity = await identity.getIdentity({ request: req }); | |
| if (!userIdentity) { | |
| logger.warn('User identity not found in request.'); | |
| // Use 401 for authentication issues | |
| res.status(401).json({ error: 'Unauthorized: User identity not available.' }); | |
| return; | |
| } | |
| // Ensure userEntityRef exists, handle potential undefined if needed based on IdentityApi version/config | |
| if (!userIdentity.identity.userEntityRef) { | |
| logger.error('User entity reference (userRef) not found in resolved identity.'); | |
| res.status(500).json({ error: 'Internal Server Error: Failed to resolve user entity reference.' }); | |
| return; | |
| } | |
| req.userRef = userIdentity.identity.userEntityRef; | |
| next(); | |
| } catch (error: any) { | |
| logger.error('Failed to get user identity during middleware check:', error); | |
| res.status(500).json({ error: 'Internal Server Error: Failed to resolve user identity.' }); | |
| } | |
| }; | |
| }; | |
| export async function createRouter( | |
| options: RouterOptions, | |
| ): Promise<express.Router> { | |
| const { logger, identity, database, ragService } = options; // Destructure ragService | |
| // Input validation for options | |
| if (!logger || !identity || !database || !ragService) { | |
| throw new Error('Missing required services (logger, identity, database, ragService) in RouterOptions.'); | |
| } | |
| const dbStore = new DbConversationStore(await database.getClient(), logger); | |
| const identityMiddleware = createIdentityMiddleware(identity, logger); | |
| const router = Router(); | |
| router.use(express.json()); // Middleware to parse JSON bodies | |
| router.use(identityMiddleware); // Apply identity middleware to all chat routes defined below | |
| // --- Function for Background Title Generation --- | |
| const generateAndSaveTitle = async ( | |
| convId: string, | |
| userRef: string, | |
| firstMessageContent: string | |
| ) => { | |
| logger.info(`Starting background title generation for conversation ${convId}`); | |
| try { | |
| // Call the RAG service to get a title suggestion | |
| const { generatedTitle } = await ragService.generateTitle({ | |
| firstUserMessageContent: firstMessageContent, | |
| conversationId: convId, | |
| userRef: userRef, | |
| }); | |
| // Validate the generated title before saving | |
| if (generatedTitle && generatedTitle.trim()) { | |
| const trimmedTitle = generatedTitle.trim(); | |
| logger.info(`AI generated title "${trimmedTitle}" for conversation ${convId}. Attempting update.`); | |
| // Update the title in the database | |
| const updated = await dbStore.updateConversationTitle(convId, userRef, trimmedTitle); | |
| if (updated) { | |
| logger.info(`Successfully updated title for conversation ${convId} with AI generated title.`); | |
| } else { | |
| // This could happen if the conversation was deleted between creation and title update, or permissions changed. | |
| logger.warn(`Failed to update title for conversation ${convId} after AI generation (maybe deleted or permission issue?).`); | |
| } | |
| } else { | |
| logger.warn(`AI service returned empty or invalid title for conversation ${convId}. Title not updated.`); | |
| } | |
| } catch (error: any) { | |
| // Log errors during the background task | |
| logger.error(`Error during background title generation or update for conversation ${convId}:`, error); | |
| // Consider adding retry logic or dead-letter queue for critical tasks | |
| } | |
| }; | |
| // --- End Background Function --- | |
| // --- API Endpoints --- | |
| // POST /send - Send a message to a conversation (or start a new one) | |
| router.post('/send', async (req: express.Request & { userRef: string }, res) => { | |
| // Request body type assertion | |
| const { content, conversationId } = req.body as { content?: string, conversationId?: string | null }; | |
| const userRef = req.userRef; // Guaranteed by middleware | |
| // Input validation | |
| if (!content || typeof content !== 'string' || content.trim().length === 0) { | |
| return res.status(400).json({ error: 'Message content cannot be empty' }); | |
| } | |
| const trimmedContent = content.trim(); // Use trimmed content consistently | |
| let currentConversationId = conversationId || null; // Ensure it's null if empty/undefined | |
| let newConvIdResponse: string | undefined = undefined; | |
| let isNewConversation = false; // Flag to trigger background task | |
| try { | |
| // Handle conversation creation or validation | |
| if (!currentConversationId) { | |
| isNewConversation = true; | |
| // Use user's message as initial title | |
| const { id: newId } = await dbStore.createConversation(userRef, trimmedContent); | |
| currentConversationId = newId; // Assign the new ID | |
| newConvIdResponse = newId; // Store for the response | |
| logger.info(`Started new conversation ${currentConversationId} for user ${userRef}`); | |
| } else { | |
| // Validate that the user has access to the existing conversation | |
| const conversation = await dbStore.getConversation(currentConversationId, userRef); | |
| if (!conversation) { | |
| logger.warn(`User ${userRef} tried to send message to unauthorized/non-existent conversation ${currentConversationId}`); | |
| // Use 403 Forbidden for access denied or 404 Not Found if appropriate | |
| return res.status(403).json({ error: 'Conversation not found or access denied' }); | |
| } | |
| } | |
| // Save user message to the database | |
| // Ensure currentConversationId is definitely a string here | |
| if (!currentConversationId) { | |
| throw new Error('Internal logic error: currentConversationId is not set after creation/validation.'); | |
| } | |
| await dbStore.saveMessage({ | |
| conversation_id: currentConversationId, | |
| sender_type: 'user', | |
| content: trimmedContent, | |
| }); | |
| // --- Call RAG/LLM for Response --- | |
| // Fetch history *including* the message just saved | |
| const history = await dbStore.getMessages(currentConversationId, userRef); | |
| // Call the actual RAG service for the chat response | |
| const { responseContent: aiResponseContent, responseMetadata: aiResponseMetadata } = | |
| await ragService.generateResponse({ | |
| userQuery: trimmedContent, | |
| conversationHistory: history, | |
| conversationId: currentConversationId, | |
| userRef: userRef, | |
| }); | |
| // --- End RAG/LLM Call --- | |
| // Save AI message to the database | |
| const savedAiMessage = await dbStore.saveMessage({ | |
| conversation_id: currentConversationId, | |
| sender_type: 'ai', | |
| content: aiResponseContent, | |
| metadata: aiResponseMetadata, // Store metadata from RAG (e.g., sources) | |
| }); | |
| // Respond to frontend *immediately* (don't wait for background title generation) | |
| const responsePayload: SendApiResponse = { | |
| aiResponse: savedAiMessage, | |
| newConversationId: newConvIdResponse, // Will be undefined if not a new conversation | |
| }; | |
| res.status(200).json(responsePayload); | |
| // --- Trigger Background Title Generation ONLY for new conversations --- | |
| // Ensure currentConversationId is valid before starting background task | |
| if (isNewConversation && currentConversationId) { | |
| // Don't await this - let it run in the background | |
| generateAndSaveTitle(currentConversationId, userRef, trimmedContent) | |
| .catch(err => { | |
| // Catch any unexpected errors from the async function itself or its promise chain | |
| logger.error(`Unhandled error caught from background title generation trigger for ${currentConversationId}:`, err); | |
| }); | |
| } | |
| // --- End Background Trigger --- | |
| } catch (error: any) { | |
| logger.error(`Error processing message for conv ${currentConversationId || 'new'} for user ${userRef}:`, error); | |
| // Ensure response isn't sent twice if error happens after res.status(200) | |
| if (!res.headersSent) { | |
| res.status(500).json({ error: 'Internal Server Error: Failed to process message.' }); | |
| } | |
| } | |
| }); | |
| // GET /history - List conversations for the current user | |
| router.get('/history', async (req: express.Request & { userRef: string }, res) => { | |
| const userRef = req.userRef; | |
| try { | |
| const conversations = await dbStore.listConversations(userRef); | |
| // Ensure conversations array is returned even if empty | |
| res.json({ conversations: conversations || [] }); | |
| } catch (error: any) { | |
| logger.error(`Failed to fetch history for user ${userRef}:`, error); | |
| res.status(500).json({ error: 'Internal Server Error: Failed to fetch conversation history.' }); | |
| } | |
| }); | |
| // GET /conversations/:id - Get messages for a specific conversation | |
| router.get('/conversations/:id', async (req: express.Request & { userRef: string }, res) => { | |
| const { id: conversationId } = req.params; | |
| const userRef = req.userRef; | |
| try { | |
| // Fetch messages (includes ownership check implicitly via dbStore.getMessages) | |
| const messages = await dbStore.getMessages(conversationId, userRef); | |
| // Check if the conversation actually exists if messages array is empty | |
| // This distinguishes between an empty conversation and one that doesn't exist/isn't owned. | |
| if (messages.length === 0) { | |
| const convExists = await dbStore.getConversation(conversationId, userRef); | |
| if(!convExists) { | |
| logger.warn(`Conversation ${conversationId} not found or access denied for user ${userRef} during GET /conversations/:id`); | |
| return res.status(404).json({ error: 'Conversation not found or access denied' }); | |
| } | |
| // If convExists is true but messages is empty, it's just an empty chat, proceed normally. | |
| } | |
| // Fetch feedback for AI messages | |
| const messageIds = messages.filter(m => m.sender_type === 'ai').map(m => m.id); | |
| const feedbackMap = await dbStore.getFeedbackForMessagesByUser(messageIds, userRef); | |
| // Combine messages with their feedback status | |
| const messagesWithFeedback = messages.map(msg => ({ | |
| ...msg, | |
| // Add userFeedback field only for AI messages, default to null | |
| userFeedback: msg.sender_type === 'ai' ? feedbackMap.get(msg.id) || null : undefined, // Use undefined or null based on Message type | |
| })); | |
| const responsePayload: HistoryApiResponse = { messages: messagesWithFeedback }; | |
| res.json(responsePayload); | |
| } catch (error: any) { | |
| logger.error(`Failed to fetch messages for conv ${conversationId} for user ${userRef}:`, error); | |
| res.status(500).json({ error: 'Internal Server Error: Failed to fetch messages.' }); | |
| } | |
| }); | |
| // PATCH /conversations/:id - Update conversation title | |
| router.patch('/conversations/:id', async (req: express.Request & { userRef: string }, res) => { | |
| const { id: conversationId } = req.params; | |
| const userRef = req.userRef; | |
| const { title: newTitle } = req.body as { title?: string }; // Expect { "title": "New Title" } | |
| // Validate input title | |
| if (!newTitle || typeof newTitle !== 'string' || newTitle.trim().length === 0) { | |
| logger.warn(`Invalid title provided for PATCH /conversations/${conversationId} by user ${userRef}`); | |
| return res.status(400).json({ error: 'Invalid or empty title provided' }); | |
| } | |
| try { | |
| // Attempt to update the title in the database (includes ownership check) | |
| const updated = await dbStore.updateConversationTitle(conversationId, userRef, newTitle); | |
| if (updated) { | |
| logger.info(`Successfully updated title for conversation ${conversationId} by user ${userRef}`); | |
| res.status(204).send(); // Success, No Content is appropriate for PATCH/PUT with no body response | |
| } else { | |
| // This means the conversation wasn't found OR didn't belong to the user | |
| logger.warn(`Conversation ${conversationId} not found or access denied for title update by user ${userRef}`); | |
| res.status(404).json({ error: 'Conversation not found or access denied' }); | |
| } | |
| } catch (error: any) { | |
| logger.error(`Failed to update title for conv ${conversationId} for user ${userRef}:`, error); | |
| res.status(500).json({ error: 'Internal Server Error: Failed to update conversation title.' }); | |
| } | |
| }); | |
| // DELETE /conversations/:id - Delete a conversation | |
| router.delete('/conversations/:id', async (req: express.Request & { userRef: string }, res) => { | |
| const { id: conversationId } = req.params; | |
| const userRef = req.userRef; | |
| try { | |
| // Attempt to delete (includes ownership check) | |
| const deleted = await dbStore.deleteConversation(conversationId, userRef); | |
| if (deleted) { | |
| logger.info(`Successfully deleted conversation ${conversationId} by user ${userRef}`); | |
| res.status(204).send(); // Success, No Content | |
| } else { | |
| // Not found or access denied | |
| logger.warn(`Conversation ${conversationId} not found or access denied for deletion by user ${userRef}`); | |
| res.status(404).json({ error: 'Conversation not found or access denied for deletion' }); | |
| } | |
| } catch (error: any) { | |
| logger.error(`Failed to delete conv ${conversationId} for user ${userRef}:`, error); | |
| res.status(500).json({ error: 'Internal Server Error: Failed to delete conversation.' }); | |
| } | |
| }); | |
| // POST /feedback - Submit feedback for a message | |
| router.post('/feedback', async (req: express.Request & { userRef: string }, res) => { | |
| const { messageId, feedbackType, comment } = req.body as { | |
| messageId?: string; | |
| feedbackType?: 'up' | 'down' | null; // Allow null for deselecting/clearing feedback | |
| comment?: string; | |
| }; | |
| const userRef = req.userRef; | |
| // Validate required fields | |
| if (!messageId || typeof messageId !== 'string') { | |
| return res.status(400).json({ error: 'Invalid input: messageId is required.' }); | |
| } | |
| // Validate feedbackType (must be 'up', 'down', or null) | |
| if (feedbackType !== null && feedbackType !== 'up' && feedbackType !== 'down') { | |
| return res.status(400).json({ error: 'Invalid input: feedbackType must be "up", "down", or null.' }); | |
| } | |
| // Validate comment type if provided | |
| if (comment !== undefined && typeof comment !== 'string') { | |
| return res.status(400).json({ error: 'Invalid input: comment must be a string if provided.' }); | |
| } | |
| try { | |
| // Fetch the original message to verify it exists and potentially check sender_type | |
| const message = await dbStore.getMessageById(messageId); | |
| if (!message) { | |
| return res.status(404).json({ error: 'Message to provide feedback on not found' }); | |
| } | |
| // Optional check: Only allow feedback on AI messages | |
| if (message.sender_type !== 'ai') { | |
| logger.warn(`User ${userRef} attempted to give feedback on non-AI message ${messageId}`); | |
| return res.status(400).json({ error: 'Feedback can only be submitted for AI messages.' }); | |
| } | |
| // Prepare feedback data for storage | |
| const feedbackInput: FeedbackInput = { | |
| message_id: messageId, | |
| user_ref: userRef, | |
| feedback_type: feedbackType, // Can be 'up', 'down', or null | |
| feedback_comment: comment || null, // Store null if comment is empty or undefined | |
| rated_message_content: message.content, // Denormalize for easier analysis later | |
| rag_context: message.metadata // Denormalize RAG context/metadata | |
| }; | |
| // Save the feedback (insert or update) | |
| await dbStore.saveFeedback(feedbackInput); | |
| res.status(200).json({ message: 'Feedback submitted successfully' }); | |
| } catch (error: any) { | |
| logger.error(`Failed to save feedback for msg ${messageId} from user ${userRef}:`, error); | |
| res.status(500).json({ error: 'Internal Server Error: Failed to save feedback.' }); | |
| } | |
| }); | |
| // Generic error handler for the router - catches unhandled errors from routes | |
| router.use((err: Error, _req: express.Request, res: express.Response, next: express.NextFunction) => { | |
| // If headers are already sent, delegate to the default Express error handler | |
| if (res.headersSent) { | |
| return next(err); | |
| } | |
| // Log the error | |
| logger.error('API Router Error (unhandled):', err); | |
| // Send a generic 500 response | |
| res.status(500).json({ error: 'Internal Server Error' }); | |
| }); | |
| return router; | |
| } | |
| // --- Reminder --- | |
| // The actual `RagService` implementation needs to be created and | |
| // provided when this router is initialized in your Backstage backend plugin setup. | |
| // See example comment block in the previous response or your plugin's entry point. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment