Skip to content

Instantly share code, notes, and snippets.

@Cozy228
Last active April 23, 2025 09:45
Show Gist options
  • Select an option

  • Save Cozy228/b7272aeac4751b099a855e01172bba71 to your computer and use it in GitHub Desktop.

Select an option

Save Cozy228/b7272aeac4751b099a855e01172bba71 to your computer and use it in GitHub Desktop.
// components/ChatPage/ChatPage.tsx (or appropriate path)
import React, { useState, useCallback } from 'react';
import { Grid, Paper, makeStyles } from '@material-ui/core';
import { ChatHistorySidebar } from '../ChatHistorySidebar'; // Adjust path as needed
import { ChatWindow } from '../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
height: 'calc(100vh - 64px)',
padding: theme.spacing(1), // Add some padding around the grid
},
paper: {
height: '100%',
display: 'flex',
flexDirection: 'column', // Ensure content inside Paper behaves correctly
overflow: 'hidden', // Prevent Paper itself from scrolling, children handle scroll
},
sidebarPaper: {
height: '100%',
overflow: 'hidden', // Sidebar content handles its own scroll if needed
},
chatWindowPaper: {
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden', // ChatWindow content handles its own scroll
},
}));
export const ChatPage = () => {
const classes = useStyles();
// --- State Management ---
// 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
// --- Callback Functions ---
// 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); // Uncomment if you want this behavior
}
// If selecting "New Chat", keep current expanded state or decide based on UX
}, [isSidebarExpanded]); // Add isSidebarExpanded if uncommenting the auto-expand line
// Called by Sidebar's toggle button
const handleToggleSidebar = useCallback(() => {
setIsSidebarExpanded(prev => !prev);
}, []);
// Called by ChatWindow when a new conversation is successfully initiated via API
const handleConversationStarted = useCallback((newId: string) => {
setCurrentConversationId(newId);
// Optional UX: Automatically expand sidebar when a new chat starts showing messages
// if (!isSidebarExpanded) {
// setIsSidebarExpanded(true);
// }
// We might not need to force expand here, user might want to keep it collapsed
// But we *must* update the currentConversationId so the sidebar can highlight it if visible
}, []);
// --- Render Logic ---
return (
<Grid container spacing={1} className={classes.container}> {/* Use spacing 1 for tighter layout */}
{/* Sidebar Grid Item - Width adjusts based on state */}
{/* Note: xs={1} might need visual tweaking in ChatHistorySidebar to look good when collapsed */}
<Grid item xs={isSidebarExpanded ? 3 : 1}>
{/* Wrap Sidebar in Paper for background/elevation */}
<Paper className={classes.sidebarPaper} elevation={1}>
<ChatHistorySidebar
onSelectConversation={handleSelectConversation}
currentConversationId={currentConversationId}
isExpanded={isSidebarExpanded} // Pass down expanded state
onToggleExpand={handleToggleSidebar} // Pass down toggle callback
/>
</Paper>
</Grid>
{/* Chat Window Grid Item - Width adjusts based on state */}
<Grid item xs={isSidebarExpanded ? 9 : 11}>
{/* Wrap ChatWindow in Paper */}
<Paper className={classes.chatWindowPaper} elevation={1}>
<ChatWindow
conversationId={currentConversationId} // Pass down selected conversation ID
onConversationStarted={handleConversationStarted} // Pass down callback for new convos
/>
</Paper>
</Grid>
</Grid>
);
};
import React, { useState, useEffect, useCallback, useRef, ChangeEvent, KeyboardEvent } from 'react';
import {
Box,
TextField,
IconButton,
CircularProgress,
Typography,
Paper,
List,
ListItem,
Alert,
} from '@material-ui/core';
import SendIcon from '@material-ui/icons/Send';
import { useApi, fetchApiRef } from '@backstage/core-plugin-api';
// Assume ChatMessage component exists and handles rendering + feedback
// import { ChatMessage } from './ChatMessage';
// Define message structure (adjust based on your actual data)
type Message = {
id: string;
sender_type: 'user' | 'ai';
content: string;
timestamp: string;
metadata?: any; // For AI message context/sources
userFeedback?: 'up' | 'down' | null; // For AI messages, current user's feedback
// Add other relevant fields
};
// Define API response structure for sending a message
type SendApiResponse = {
aiResponse: Message; // The AI's reply message object
newConversationId?: string; // Returned ONLY when a new conversation was created
};
// Define API response structure for getting conversation history
type HistoryApiResponse = {
messages: Message[];
};
type ChatWindowProps = {
// conversationId from parent (e.g., ChatPage state based on sidebar selection)
// null indicates a new chat or initial state
conversationId: string | null;
// Optional callback to notify parent when a new conversation is successfully started
onConversationStarted?: (newId: string) => void;
};
export const ChatWindow = ({ conversationId: conversationIdProp, onConversationStarted }: ChatWindowProps) => {
const fetchApi = useApi(fetchApiRef);
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false); // For loading history or waiting for AI
const [error, setError] = useState<string | null>(null);
// Internal state to track the *active* conversation ID for sending messages.
// This handles the case where a new chat starts (prop is null) but gets an ID after the first message.
const [activeConversationId, setActiveConversationId] = useState<string | null>(conversationIdProp);
const messagesEndRef = useRef<HTMLDivElement>(null); // For scrolling
// --- Effect to Load Messages When conversationIdProp Changes ---
useEffect(() => {
// Sync internal active ID with the prop when it changes
setActiveConversationId(conversationIdProp);
if (conversationIdProp) {
// Scenario 3: Existing Conversation
const loadMessages = async () => {
setIsLoading(true);
setError(null);
setMessages([]); // Clear previous messages
try {
const response = await fetchApi.fetch(`/api/rag-chat/conversations/${conversationIdProp}`); // Adjust API path
if (!response.ok) {
throw new Error(`Failed to fetch messages: ${response.statusText}`);
}
const data: HistoryApiResponse = await response.json();
setMessages(data.messages || []);
} catch (e: any) {
setError(`Failed to load conversation: ${e.message}`);
setMessages([]);
} finally {
setIsLoading(false);
}
};
loadMessages();
} else {
// Scenario 1 & 2 (Initial / New Chat Intent): No history to load
setMessages([]);
setError(null);
setIsLoading(false);
}
}, [conversationIdProp, fetchApi]);
// --- Scroll to Bottom ---
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// --- Handle Sending a Message ---
const handleSendMessage = useCallback(async () => {
const trimmedInput = inputValue.trim();
if (!trimmedInput || isLoading) {
return; // Don't send empty messages or while loading
}
// Optimistic update: Add user message immediately
const userMessage: Message = {
id: `temp-user-${Date.now()}`, // Temporary ID
sender_type: 'user',
content: trimmedInput,
timestamp: new Date().toISOString(),
};
setMessages(prev => [...prev, userMessage]);
setInputValue('');
setIsLoading(true); // Start loading indicator for AI response
setError(null);
try {
// API call includes the current *active* conversation ID (could be null)
const response = await fetchApi.fetch('/api/rag-chat/send', { // Adjust API path
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: trimmedInput,
conversationId: activeConversationId, // Send null if starting a new chat
}),
});
if (!response.ok) {
throw new Error(`API Error: ${response.statusText}`);
}
const result: SendApiResponse = await response.json();
// Add AI response
setMessages(prev => [...prev.filter(m => m.id !== userMessage.id), userMessage, result.aiResponse]); // Replace temp user message if needed, add AI response
// Scenario 2 Handling: If a new conversation was created, update active ID
if (result.newConversationId) {
setActiveConversationId(result.newConversationId);
// Notify parent component that a new conversation has started
if (onConversationStarted) {
onConversationStarted(result.newConversationId);
}
}
} catch (e: any) {
setError(`Failed to get response: ${e.message}`);
// Optionally mark the optimistic user message as failed
setMessages(prev => prev.map(m => m.id === userMessage.id ? { ...m, metadata: { ...m.metadata, error: true } } : m));
} finally {
setIsLoading(false); // Stop loading indicator
}
}, [inputValue, isLoading, activeConversationId, fetchApi, onConversationStarted]);
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
};
const handleKeyPress = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); // Prevent newline in TextField
handleSendMessage();
}
};
// --- Render Logic ---
return (
<Box display="flex" flexDirection="column" height="100%" component={Paper} elevation={2}>
{/* Message Display Area */}
<Box flexGrow={1} p={2} style={{ overflowY: 'auto' }}>
<List>
{messages.map((msg) => (
// Replace with your actual ChatMessage component
<ListItem key={msg.id} style={{ display: 'flex', flexDirection: 'column', alignItems: msg.sender_type === 'user' ? 'flex-end' : 'flex-start' }}>
<Paper elevation={1} style={{ padding: '10px', maxWidth: '70%', background: msg.sender_type === 'user' ? '#e3f2fd' : '#fce4ec' }}>
<Typography variant="body2" style={{ whiteSpace: 'pre-wrap' }}>{msg.content}</Typography>
<Typography variant="caption" color="textSecondary">{new Date(msg.timestamp).toLocaleTimeString()}</Typography>
{/* Basic error indicator for optimistic message failure */}
{msg.metadata?.error && <Typography variant="caption" color="error"> - Failed to send</Typography>}
</Paper>
{/* Placeholder for FeedbackButtons if it's an AI message */}
{/* {msg.sender_type === 'ai' && <FeedbackButtons messageId={msg.id} initialFeedback={msg.userFeedback} onFeedbackSubmit={...} />} */}
</ListItem>
// <ChatMessage key={msg.id} message={msg} onFeedbackSubmit={handleFeedbackSubmit} />
))}
{/* Display loading indicator while fetching history or waiting for AI */}
{isLoading && messages.length === 0 && <CircularProgress style={{ display: 'block', margin: '20px auto' }} />}
{/* End ref for scrolling */}
<div ref={messagesEndRef} />
</List>
{/* Initial State Prompt */}
{!isLoading && messages.length === 0 && !conversationIdProp && (
<Typography variant="body1" color="textSecondary" align="center" style={{ marginTop: '20px' }}>
Type a message below to start a new conversation.
</Typography>
)}
{/* Error Display */}
{error && <Alert severity="error" style={{ margin: '10px 0' }}>{error}</Alert>}
</Box>
{/* Input Area */}
<Box p={2} display="flex" alignItems="center" borderTop="1px solid #e0e0e0">
<TextField
fullWidth
variant="outlined"
size="small"
placeholder="Type your message..."
value={inputValue}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
disabled={isLoading} // Disable input while waiting for AI
multiline
maxRows={5}
/>
<IconButton
color="primary"
onClick={handleSendMessage}
disabled={!inputValue.trim() || isLoading}
style={{ marginLeft: '10px' }}
>
{isLoading ? <CircularProgress size={24} /> : <SendIcon />}
</IconButton>
</Box>
</Box>
);
};
// 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';
// 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.
*/
async createConversation(userRef: string, title?: string): Promise<{ id: string }> {
this.logger.info(`Creating new conversation for user ${userRef}`);
const newConversationId = uuid();
await this.db<Conversation>(T_CONVERSATIONS).insert({
id: newConversationId,
user_ref: userRef,
title: title || 'New Chat', // Default title
// created_at and updated_at will use DB defaults if set up
});
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.
*/
async updateConversationTimestamp(id: string): Promise<void> {
await this.db(T_CONVERSATIONS)
.where({ id })
.update({ updated_at: this.db.fn.now() });
}
/**
* Deletes a conversation and its associated messages (due to CASCADE constraint) and feedback.
* Ensures the conversation belongs to the user requesting deletion.
*/
async deleteConversation(id: string, userRef: string): Promise<boolean> {
const result = await this.db(T_CONVERSATIONS)
.where({ id, user_ref: userRef }) // Ensure ownership
.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.
*/
async saveMessage(
messageData: Omit<Message, 'id' | 'timestamp' | 'userFeedback'> // Exclude fields generated by DB or added later
): Promise<Message> {
const newMessageId = uuid();
const [savedMessage] = await this.db<Message>(T_MESSAGES)
.insert({
id: newMessageId,
...messageData,
// timestamp will use DB default if set up
})
.returning('*'); // Return the full row including defaults
this.logger.info(`Saved message ${newMessageId} for conversation ${messageData.conversation_id}`);
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();
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 []; // Or throw an error
}
return this.db<Message>(T_MESSAGES)
.where({ conversation_id: conversationId })
.orderBy('timestamp', 'asc');
}
/**
* Saves or updates feedback for a message. Handles removing feedback if type is null.
* Uses ON CONFLICT for UPSERT behavior based on the unique constraint (message_id, user_ref).
*/
async saveFeedback(feedback: FeedbackInput): Promise<void> {
this.logger.info(`Saving feedback type '${feedback.feedback_type}' for message ${feedback.message_id} from user ${feedback.user_ref}`);
if (feedback.feedback_type === null) {
// User wants to remove their feedback
await this.db(T_FEEDBACK)
.where({
message_id: feedback.message_id,
user_ref: feedback.user_ref,
})
.delete();
this.logger.info(`Removed feedback for message ${feedback.message_id} from user ${feedback.user_ref}`);
} else {
// Insert or Update feedback
const feedbackToSave = {
message_id: feedback.message_id,
user_ref: feedback.user_ref,
feedback_type: feedback.feedback_type,
feedback_comment: feedback.feedback_comment,
rated_message_content: feedback.rated_message_content,
rag_context: feedback.rag_context ? JSON.stringify(feedback.rag_context) : null, // Ensure JSONB is handled correctly
// Let updated_at be handled by DB trigger or Knex update
};
// Use Knex's onConflict().merge() for UPSERT
// Assumes a UNIQUE constraint exists on (message_id, user_ref)
await this.db(T_FEEDBACK)
.insert({ ...feedbackToSave, id: uuid() }) // Provide ID for insert
.onConflict(['message_id', 'user_ref']) // Specify unique columns
.merge({ // Fields to update on conflict
feedback_type: feedback.feedback_type,
feedback_comment: feedback.feedback_comment,
rated_message_content: feedback.rated_message_content, // Potentially re-copy if needed
rag_context: feedback.rag_context ? JSON.stringify(feedback.rag_context) : null,
updated_at: this.db.fn.now(),
});
this.logger.info(`Upserted feedback for message ${feedback.message_id} from user ${feedback.user_ref}`);
}
}
/**
* Retrieves the feedback type ('up' or 'down') given by a specific user
* for a list of message IDs. Returns a Map for efficient lookup.
*/
async getFeedbackForMessagesByUser(
messageIds: string[],
userRef: string
): Promise<Map<string, 'up' | 'down'>> {
if (messageIds.length === 0) {
return new Map();
}
const feedbackRecords = await this.db(T_FEEDBACK)
.select('message_id', 'feedback_type')
.where({ user_ref: userRef })
.whereIn('message_id', messageIds);
const feedbackMap = new Map<string, 'up' | 'down'>();
feedbackRecords.forEach(record => {
// Ensure feedback_type is only 'up' or 'down' before setting
if(record.feedback_type === 'up' || record.feedback_type === 'down') {
feedbackMap.set(record.message_id, record.feedback_type);
}
});
return feedbackMap;
}
}
// src/types.ts - Already defined in previous turn, assuming it's correct
// export interface SourceDocument { ... }
// export interface Metadata { ... }
// export interface Message { ... }
// export type MessageToSave = Omit<Message, 'id' | 'timestamp' | 'userFeedback' | 'metadata'>; // This input type will change
// Define the input type for the refactored saveMessage
// It now includes metadata, but excludes id and timestamp
export type MessageDataForDb = Omit<Message, 'id' | 'timestamp'>;
// src/schemas.ts - Already defined, assuming correct
// import { z } from 'zod';
// import { SourceDocument, Metadata } from './types';
// export const SourceDocumentSchema = z.object({ ... });
// export const MetadataSchema = z.object({ ... });
// src/db.ts - Already defined, assuming correct
// import knex, { Knex } from 'knex';
// const db: Knex = ...;
// export default db;
// src/services/externalApiService.ts - Already defined, assuming correct logic
// export async function fetchRawMetadataString(resourceId: string): Promise<string | null | undefined> { ... }
// src/services/metadataProcessor.ts - NEW FILE
import { z } from 'zod'; // For ZodError
import { Metadata } from '../types'; // Import the Metadata type
import { MetadataSchema } from '../schemas'; // Import the Metadata Zod schema
/**
* Parses and validates a raw JSON string into a Metadata object or returns null.
* Logs warnings if parsing or validation fails.
*
* @param rawMetadataString - The raw JSON string received from an external source.
* @param logger - The logger instance.
* @param contextId - An ID for logging context (e.g., conversation ID, request ID).
* @returns The parsed and validated Metadata object, or null if processing failed or input was empty.
*/
export function processRawMetadata(
rawMetadataString: string | null | undefined,
logger: { info: (msg: string, ...args: any[]) => void; warn: (msg: string, ...args: any[]) => void; error: (msg: string, ...args: any[]) => void }, // Basic logger interface
contextId?: string // Optional ID for better logging context
): Metadata | null {
let parsedMetadata: Metadata | null = null; // This will hold the parsed object or null
let processingStatus: 'succeeded' | 'nulled_empty' | 'failed_parse' | 'failed_validation' = 'succeeded'; // Track processing outcome
if (rawMetadataString === null || rawMetadataString === undefined || rawMetadataString === '') {
parsedMetadata = null; // Treat as null
processingStatus = 'nulled_empty';
logger.info(`Raw metadata string is null, undefined, or empty for context ${contextId || 'N/A'}. Result: NULL.`);
} else {
try {
// Attempt to parse the string
const rawParsedObject = JSON.parse(rawMetadataString);
// Attempt to validate the parsed object
const validationResult = MetadataSchema.safeParse(rawParsedObject);
if (validationResult.success) {
// Parsing and validation successful
parsedMetadata = validationResult.data;
processingStatus = 'succeeded';
logger.info(`Successfully parsed and validated metadata for context ${contextId || 'N/A'}.`);
} else {
// Validation failed - JSON valid, but structure mismatch
processingStatus = 'failed_validation';
const validationErrors = validationResult.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
logger.warn(`Metadata validation failed for context ${contextId || 'N/A'}. Error: ${validationErrors}`, {
contextId: contextId,
metadata_error_details: validationResult.error.errors,
// rawMetadataString: rawMetadataString.substring(0, 200) + '...' // Optional: Log snippet
});
parsedMetadata = null; // Result is null on validation failure
}
} catch (error: any) { // Catch JSON.parse errors
// JSON.parse failed - the string is not valid JSON syntax
processingStatus = 'failed_parse';
logger.warn(`Failed to parse raw metadata string as JSON for context ${contextId || 'N/A'}. Error: ${error.message}`, {
contextId: contextId,
parse_error_message: error.message,
// rawMetadataString: rawMetadataString.substring(0, 200) + '...' // Optional: Log snippet
});
parsedMetadata = null; // Result is null on parsing failure
}
}
// Optional: Log overall processing status if not succeeded
// if (processingStatus !== 'succeeded' && processingStatus !== 'nulled_empty') {
// logger.warn(`Metadata processing resulted in status: ${processingStatus} for context ${contextId || 'N/A'}. Saved as NULL.`);
// }
return parsedMetadata; // Return the Metadata object or null
}
// src/services/messageService.ts - MODIFIED FILE
// Removed metadata fetching, parsing, validation logic
import { v4 as uuid } from 'uuid';
import type { Knex } from 'knex';
import { Message, MessageDataForDb } from '../types'; // Import types
// Schemas and MetadataProcessor are NOT needed in this simplified file
// import { MetadataSchema } from '../schemas';
// import { processRawMetadata } from './metadataProcessor';
// externalApiService is NOT needed here either
// import { fetchRawMetadataString } from './externalApiService';
// Assuming T_MESSAGES is defined
const T_MESSAGES = 'messages';
/**
* Saves a complete message object (including already processed metadata) to the database.
* Assumes metadata parsing and validation has already happened before calling this function.
*
* @param db - The Knex database instance.
* @param logger - The logger instance.
* @param messageData - The message data object, *including* the processed metadata (or null),
* but excluding DB-generated/later-added fields like id and timestamp.
* @returns A Promise resolving to the saved Message object from the DB.
* @throws Throws errors for database failures.
*/
export async function saveMessage(
db: Knex,
logger: { info: (msg: string, ...args: any[]) => void; error: (msg: string, ...args: any[]) => void }, // Basic logger interface
messageData: MessageDataForDb // Use the input type that includes processed metadata
): Promise<Message> {
const newMessageId = uuid(); // Generate ID before inserting
// The messageData object already contains the metadata field, which is either
// a Metadata object or null, as determined by the caller (metadataProcessor).
// Knex will handle serializing the Metadata object or inserting NULL for the jsonb column.
const objectToInsert = {
id: newMessageId, // Use generated ID
...messageData, // This spread includes conversation_id, sender_type, content, and metadata
// timestamp will use DB default if set up via Knex migration/DB schema
// userFeedback is excluded by MessageDataForDb type
};
try {
const [savedMessage] = await db<Message>(T_MESSAGES) // Type assertion for return
.insert(objectToInsert)
.returning('*'); // Return the full row including DB-generated fields
logger.info(`Saved message ${newMessageId} for conversation ${messageData.conversation_id}.`);
return savedMessage; // Return the saved message object
} catch (dbError: any) {
// Catch and log actual database errors
logger.error(`Database error saving message ${newMessageId} for conversation ${messageData.conversation_id}:`, {
dbError: dbError.message,
dbErrorStack: dbError.stack,
// Log other relevant messageData if not sensitive
// partialData: messageData,
});
// Re-throw the database error
throw dbError;
}
}
// src/index.ts or src/app.ts - Orchestration Layer (Entry Point Example)
// This file ties everything together
import db from './db'; // Import the database instance
// Assuming a logger instance is initialized somewhere
const appLogger = console; // Replace with your actual logger instance
import { fetchRawMetadataString } from './services/externalApiService';
import { processRawMetadata } from './services/metadataProcessor';
import { saveMessage } from './services/messageService';
import { MessageDataForDb } from './types'; // Import the input type for saveMessage
async function handleIncomingRequest(requestData: any) {
// Assume requestData contains basic message info and info to get metadata
const { conversationId, senderType, content, externalResourceId } = requestData;
// --- Step 1: Get the raw metadata string ---
const rawMetadataString = await fetchRawMetadataString(externalResourceId);
// --- Step 2: Process the raw metadata string (Parse and Validate) ---
// This function handles warnings internally and returns the processed object or null
const processedMetadata = processRawMetadata(rawMetadataString, appLogger, conversationId);
// --- Step 3: Prepare the message data object with processed metadata ---
// This object now conforms to MessageDataForDb type
const messageDataForDb: MessageDataForDb = {
conversation_id: conversationId,
sender_type: senderType,
content: content,
metadata: processedMetadata, // Assign the processed/validated/null result
};
// --- Step 4: Save the message using the simplified service function ---
try {
const savedMessage = await saveMessage(
db, // Pass the db instance
appLogger, // Pass the logger instance
messageDataForDb // Pass the prepared message data object
);
console.log("Message processing complete. Saved message ID:", savedMessage.id);
// Send successful response to user, including savedMessage data
} catch (error) {
// This catch block handles ONLY database errors thrown by saveMessage
console.error("Message processing failed due to a database error:", error);
// Send error response to user
}
}
// Example call from your application's entry point or request handler
// handleIncomingRequest({
// conversationId: 'conv-abc-123',
// senderType: 'user',
// content: 'This is a test message.',
// externalResourceId: 'resource-123' // Change this to test different metadata cases
// });
// plugins/chat-backend/src/plugin.ts
import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { createRouter } from './service/router';
import cookieParser from 'cookie-parser';
import { loggerToWinstonLogger } from '@backstage/backend-common';
export const chatBackendPlugin = createBackendPlugin({
pluginId: 'chat', // Match the frontend plugin ID if applicable
register(env) {
env.registerInit({
deps: {
logger: coreServices.logger,
config: coreServices.config,
database: coreServices.database,
http: coreServices.httpRouter,
identity: coreServices.identity,
// Add httpAuth dependency if you need more fine-grained auth checks
// httpAuth: coreServices.httpAuth,
},
async init({ logger, config, database, http, identity }) {
const winstonLogger = loggerToWinstonLogger(logger); // For compatibility if needed, else use logger directly
winstonLogger.info('Initializing chat backend plugin...');
// 1. Use cookie parser - might be needed for Backstage auth session cookie
http.use(cookieParser());
// 2. Create and use the router
const router = await createRouter({
logger,
identity,
database,
// Pass other dependencies like config if needed by router/store
});
// Mount router at the specific API path used by the frontend
http.use('/api/chat', router); // Adjust base path as needed ('/api/chat' is an example)
winstonLogger.info('Chat backend plugin router registered successfully at /api/chat');
},
});
},
});
erDiagram
    conversations {
        UUID id PK "Unique conversation ID"
        VARCHAR user_ref "Backstage user entity ref (e.g., user:default/guest)"
        VARCHAR title "Optional conversation title"
        TIMESTAMPTZ created_at "Timestamp when created"
        TIMESTAMPTZ updated_at "Timestamp of last message or update"
    }

    messages {
        UUID id PK "Unique message ID"
        UUID conversation_id FK "Links to conversations.id"
        VARCHAR(10) sender_type "Either 'user' or 'ai'"
        TEXT content "The actual text content of the message"
        JSONB metadata "Optional: Stores RAG context, sources, etc. for AI messages"
        TIMESTAMPTZ timestamp "Timestamp when message was created"
    }

    message_feedback {
        UUID id PK "Unique feedback record ID"
        UUID message_id FK "Links to messages.id (the message being rated)"
        VARCHAR user_ref "Backstage user entity ref giving feedback"
        VARCHAR(10) feedback_type "Either 'up' or 'down'"
        TEXT feedback_comment "Optional textual comment with feedback"
        TEXT rated_message_content "Denormalized: Copy of the message content when feedback was given"
        JSONB rag_context "Denormalized: Copy of the RAG context used for the message when feedback was given"
        TIMESTAMPTZ created_at "Timestamp when feedback was first given"
        TIMESTAMPTZ updated_at "Timestamp when feedback was last updated"
    }

    conversations ||--o{ messages : "has"
    messages ||--o{ message_feedback : "receives feedback on"
    
    %% Note: UNIQUE constraint on (message_id, user_ref) should be applied in the actual DB schema
Loading

这个设计的核心目标是:

  1. 持久化存储:将用户的对话(包括用户输入和 AI 回复)保存下来。
  2. 可视化列表:在聊天界面旁边或合适的位置展示历史对话列表。
  3. 加载与切换:用户可以点击列表中的某一项,加载该对话的完整内容到聊天窗口。
  4. 新建对话:用户可以随时发起新的对话,新对话会自动出现在历史列表中。
  5. (可选)管理:提供删除历史对话的功能。
  6. (可选)命名:允许用户或系统自动为对话命名。

设计方案

我们将从前端、后端和 API 三个层面来设计。

1. 核心组件

  • 前端 (Frontend - React + TypeScript):
    • ChatHistorySidebar: 一个侧边栏组件,用于展示对话历史列表,处理新建对话、选择对话、删除对话等操作。
    • ConversationListItem: ChatHistorySidebar 中的列表项,显示对话的摘要信息(如标题或首句、时间戳)并响应点击事件。
    • ChatWindow: 现有的聊天窗口组件,需要改造以支持加载不同 conversationId 的消息记录,并能在发送新消息时关联到当前 conversationId
    • 状态管理 (State Management): 需要管理 conversationListcurrentConversationIdmessages 等状态。可以使用 React Context API、Zustand 或 Redux Toolkit。
  • 后端 (Backend - Backstage Backend Plugin + TypeScript):
    • 存储层 (Storage): 需要一个地方存储对话数据。推荐使用 Backstage 数据库 (@backstage/plugin-db-manager),你可以定义自己的表结构来存储对话和消息。备选方案是外部数据库或简单的文件存储(不推荐用于生产)。
    • 服务层 (Service): 处理来自前端的请求,如获取历史列表、获取特定对话消息、保存新消息、删除对话等。
  • API (Backstage API):
    • 定义清晰的 RESTful API 接口供前端调用,用于历史记录的增删查改。

2. 数据模型 (Database Schema Example)

如果你使用关系型数据库(如 PostgreSQL,通常与 Backstage 搭配),可以设计如下表结构:

  • conversations: 存储每个独立的对话会话。
    -- Example for PostgreSQL
    CREATE TABLE conversations (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Unique ID for the conversation
      user_ref VARCHAR(255) NOT NULL, -- Reference to the Backstage user (e.g., user entity ref)
      title VARCHAR(255) DEFAULT 'New Chat', -- Optional title for the conversation
      created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
      updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
    );
    -- Index for faster lookup by user
    CREATE INDEX idx_conversations_user_ref ON conversations(user_ref);
    CREATE INDEX idx_conversations_updated_at ON conversations(updated_at);
  • messages: 存储每次对话中的具体消息。
    -- Example for PostgreSQL
    CREATE TABLE messages (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Unique ID for the message
      conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, -- Link to the conversation
      sender_type VARCHAR(10) NOT NULL CHECK (sender_type IN ('user', 'ai')), -- Who sent the message
      content TEXT NOT NULL, -- The actual message text
      metadata JSONB, -- Optional: Store RAG context, sources, etc.
      timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
    );
    -- Index for faster lookup by conversation and ordering
    CREATE INDEX idx_messages_conversation_id_timestamp ON messages(conversation_id, timestamp);

3. 前端设计 (React Components - TypeScript)

  • ChatHistorySidebar.tsx

    • 使用 @backstage/core-plugin-apiWorkspaceApi 调用后端 API 获取对话列表 (/api/rag-chat/history)。
    • 使用 useState 或状态管理库存储 conversations 列表。
    • 渲染 ConversationListItem 列表。
    • 包含一个 "New Chat" / "新建对话" 按钮,点击时将 currentConversationId 设置为 null 或一个特殊值,并清空 ChatWindow 的消息。
    • 提供删除按钮,调用后端 API 删除对话 (DELETE /api/rag-chat/conversations/{id}) 并更新列表。
    // Example Snippet for ChatHistorySidebar.tsx
    import React, { useState, useEffect, useCallback } from 'react';
    import { List, ListItem, ListItemText, IconButton, Button } from '@material-ui/core';
    import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
    import DeleteIcon from '@material-ui/icons/Delete';
    import { useApi, fetchApiRef } from '@backstage/core-plugin-api';
    
    type ConversationMeta = {
      id: string;
      title: string;
      updated_at: string;
    };
    
    type ChatHistorySidebarProps = {
      onSelectConversation: (id: string | null) => void; // Callback when a conversation is selected or new chat is clicked
      currentConversationId: string | null;
    };
    
    export const ChatHistorySidebar = ({ onSelectConversation, currentConversationId }: ChatHistorySidebarProps) => {
      const [conversations, setConversations] = useState<ConversationMeta[]>([]);
      const [isLoading, setIsLoading] = useState(false);
      const [error, setError] = useState<Error | null>(null);
      const fetchApi = useApi(fetchApiRef);
    
      const fetchHistory = useCallback(async () => {
        setIsLoading(true);
        setError(null);
        try {
          const response = await fetchApi.fetch('/api/rag-chat/history'); // Adjust API path
          if (!response.ok) {
            throw new Error(`Failed to fetch history: ${response.statusText}`);
          }
          const data = await response.json();
          setConversations(data.conversations || []); // Assuming API returns { conversations: [...] }
        } catch (e: any) {
          setError(e);
        } finally {
          setIsLoading(false);
        }
      }, [fetchApi]);
    
      useEffect(() => {
        fetchHistory();
      }, [fetchHistory]);
    
      const handleDelete = async (id: string, event: React.MouseEvent) => {
        event.stopPropagation(); // Prevent selection when clicking delete
        if (window.confirm('Are you sure you want to delete this conversation?')) {
          try {
            const response = await fetchApi.fetch(`/api/rag-chat/conversations/${id}`, { method: 'DELETE' }); // Adjust API path
            if (!response.ok) {
              throw new Error(`Failed to delete conversation: ${response.statusText}`);
            }
            // Refresh list or remove locally
            setConversations(prev => prev.filter(conv => conv.id !== id));
            // If deleting the current conversation, select "New Chat"
            if (id === currentConversationId) {
              onSelectConversation(null);
            }
          } catch (e: any) {
            console.error("Deletion failed:", e);
            // Show error to user
          }
        }
      };
    
      const handleNewChat = () => {
        onSelectConversation(null);
      };
    
      if (isLoading) return <div>Loading history...</div>;
      if (error) return <div>Error loading history: {error.message}</div>;
    
      return (
        <div>
          <Button
            variant="contained"
            color="primary"
            startIcon={<AddCircleOutlineIcon />}
            onClick={handleNewChat}
            fullWidth
            style={{ marginBottom: '10px' }}
          >
            New Chat
          </Button>
          <List component="nav" aria-label="conversation history">
            {conversations.map((conv) => (
              <ListItem
                button
                key={conv.id}
                selected={conv.id === currentConversationId}
                onClick={() => onSelectConversation(conv.id)}
              >
                <ListItemText
                  primary={conv.title || `Chat from ${new Date(conv.updated_at).toLocaleString()}`}
                  // secondary={`Last updated: ${new Date(conv.updated_at).toLocaleString()}`}
                />
                <IconButton edge="end" aria-label="delete" onClick={(e) => handleDelete(conv.id, e)}>
                  <DeleteIcon />
                </IconButton>
              </ListItem>
            ))}
          </List>
        </div>
      );
    };
  • ChatWindow.tsx

    • 接收 currentConversationId: string | null 作为 prop。
    • currentConversationId 改变时:
      • 如果 currentConversationIdnull,清空消息列表。
      • 如果 currentConversationId 是一个有效的 ID,调用 API (GET /api/rag-chat/conversations/{id}) 获取该对话的所有消息并显示。
    • 当用户发送消息时:
      • 如果 currentConversationIdnull,表示这是一个新对话的第一条消息。后端在处理这条消息时,应先创建一个新的 conversation 记录,并将 conversation_id 返回给前端,或者前端在发送第一条消息前先调用创建接口。之后的消息使用这个新的 conversationId
      • 如果 currentConversationId 已存在,将消息和 conversationId 一起发送到后端处理接口(可能是你现有的 RAG 处理接口,但需要增加 conversationId 参数)。
      • 后端保存用户消息和 AI 回复时,都需要关联到对应的 conversation_id
  • Container Component (e.g., ChatPage.tsx)

    • 管理 currentConversationId 状态。
    • 渲染 ChatHistorySidebarChatWindow
    • currentConversationId 传递给两个子组件。
    • 提供 handleSelectConversation 回调给 ChatHistorySidebar 来更新 currentConversationId
    // Example Snippet for ChatPage.tsx (Container)
    import React, { useState } from 'react';
    import { Grid, Paper } from '@material-ui/core';
    import { ChatHistorySidebar } from './ChatHistorySidebar';
    import { ChatWindow } from './ChatWindow'; // Assuming ChatWindow exists
    
    export const ChatPage = () => {
      const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
    
      const handleSelectConversation = (id: string | null) => {
        setCurrentConversationId(id);
      };
    
      return (
        <Grid container spacing={2} style={{ height: 'calc(100vh - 64px)', padding: '10px' }}> {/* Adjust height based on header */}
          <Grid item xs={3} style={{ height: '100%', overflowY: 'auto' }}>
              <Paper style={{ height: '100%', padding: '10px' }}>
                  <ChatHistorySidebar
                    onSelectConversation={handleSelectConversation}
                    currentConversationId={currentConversationId}
                  />
              </Paper>
          </Grid>
          <Grid item xs={9} style={{ height: '100%' }}>
              <Paper style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
                 {/* Ensure ChatWindow takes conversationId and handles message loading/sending */}
                 <ChatWindow conversationId={currentConversationId} />
              </Paper>
          </Grid>
        </Grid>
      );
    };

4. 后端设计 (Backstage Backend Plugin - TypeScript)

  • 创建一个新的 Backend Plugin 或在你现有的 RAG 相关插件中添加功能。
  • Router Setup: 使用 Express Router 定义 API 路由。
    // Example Backend Router Snippet (in createRouter function of your plugin)
    import { LoggerService, DatabaseService } from '@backstage/backend-plugin-api';
    import { IdentityApi } from '@backstage/plugin-auth-node';
    import express from 'express';
    import Router from 'express-promise-router';
    // Import your database interaction logic (e.g., DbConversationStore)
    
    export interface RouterOptions {
      logger: LoggerService;
      database: DatabaseService;
      identity: IdentityApi; // To get current user
      // Add other dependencies like your RAG service client
    }
    
    export async function createRouter(
      options: RouterOptions,
    ): Promise<express.Router> {
      const { logger, database, identity } = options;
      // const dbStore = new DbConversationStore(await database.getClient()); // Your data access layer
    
      const router = Router();
      router.use(express.json());
    
      // Middleware to get user identity (optional but recommended)
      router.use(async (req, res, next) => {
        const user = await identity.getIdentity({ request: req });
        // Attach user to request or handle unauthorized access
        if (!user) {
           res.status(401).json({ error: 'Unauthorized' });
           return;
        }
        (req as any).user = user; // Or store userRef appropriately
        next();
      });
    
      // GET /history - List conversations for the current user
      router.get('/history', async (req, res) => {
        const userRef = (req as any).user.identity.userEntityRef;
        // const conversations = await dbStore.listConversations(userRef);
        // Mock data for example:
        const conversations = [
             { id: 'uuid-1', title: 'Kube Deployment Issue', updated_at: new Date().toISOString() },
             { id: 'uuid-2', title: 'React Best Practices', updated_at: new Date(Date.now() - 3600000).toISOString() },
        ];
        res.json({ conversations });
      });
    
      // GET /conversations/:id - Get messages for a specific conversation
      router.get('/conversations/:id', async (req, res) => {
        const { id } = req.params;
        const userRef = (req as any).user.identity.userEntityRef;
        // Add logic to verify user owns this conversation before fetching
        // const messages = await dbStore.getMessages(id, userRef);
        // Mock data for example:
        const messages = [
            { id: 'msg-1', sender_type: 'user', content: 'Help me with k8s', timestamp: new Date().toISOString() },
            { id: 'msg-2', sender_type: 'ai', content: 'Sure, what is the issue?', timestamp: new Date().toISOString() },
        ];
        res.json({ messages });
      });
    
      // POST /conversations/:id/messages - Add a message (likely modifies your existing RAG endpoint)
      // Your existing RAG endpoint needs modification:
      // 1. Accept `conversationId` (optional, if null, create a new one).
      // 2. Save user message to DB associated with `conversationId`.
      // 3. Call RAG logic.
      // 4. Save AI response to DB associated with `conversationId`.
      // 5. Update conversation's `updated_at`.
      // 6. Return AI response (and maybe the `conversationId` if it was newly created).
    
      // DELETE /conversations/:id - Delete a conversation
      router.delete('/conversations/:id', async (req, res) => {
        const { id } = req.params;
        const userRef = (req as any).user.identity.userEntityRef;
        // Add logic to verify user owns this conversation
        // await dbStore.deleteConversation(id, userRef);
        logger.info(`User ${userRef} deleted conversation ${id}`);
        res.status(204).send(); // No Content
      });
    
      // Error Handling Middleware (add at the end)
      router.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
         logger.error("API Error:", err);
         res.status(500).json({ error: 'Internal Server Error' });
      });
    
    
      return router;
    }

5. API 设计 (Endpoints Summary)

  • GET /api/rag-chat/history: 获取当前用户的对话历史列表 (返回 [{id, title, updated_at}, ...])。
  • GET /api/rag-chat/conversations/:id: 获取指定对话的所有消息 (返回 [{id, sender_type, content, timestamp, metadata}, ...])。
  • POST /api/rag-chat/conversations/:id/messages (或你的 RAG 交互端点): 发送消息到指定对话。如果 :id 是特殊值(如 'new')或 body 中 conversationId 为空,则创建新对话。
  • DELETE /api/rag-chat/conversations/:id: 删除指定对话。

6. Backstage 集成

  • Frontend Plugin:
    • 在你的 RAG 聊天插件中添加 ChatHistorySidebar 和修改 ChatWindow
    • 确保使用 @backstage/core-plugin-apiuseApi(fetchApiRef) 来进行 API 调用,这样可以利用 Backstage 的认证和代理。
    • 将新的组件添加到插件的导出和 App.tsx 或相关路由中。
  • Backend Plugin:
    • 创建或修改后端插件以包含上述 createRouter 逻辑。
    • packages/backend/src/index.ts (或你的 plugins.ts) 中设置和挂载你的插件路由,并传入所需的依赖(logger, database, identity)。
    • 确保你的插件依赖 @backstage/plugin-auth-node (获取用户信息) 和 @backstage/plugin-db-manager (如果使用 Backstage 数据库)。

7. 技术栈确认

  • 语言: TypeScript (符合要求)
  • 包管理器: pnpm (符合要求)
    • 使用 pnpm add <package-name> 在相应的前端/后端插件包中添加依赖。
    • 使用 pnpm install 在根目录安装所有依赖。
    • 使用 pnpm --filter <your-plugin-package-name> build 来构建特定插件。

8. 实现步骤建议

  1. 后端:
    • 设计并实现数据库表结构(如果使用数据库)。
    • 在后端插件中创建数据访问层 (DAL) 来封装数据库操作。
    • 实现 API 路由 (createRouter),处理 CRUD 操作。特别注意修改现有的消息处理逻辑以包含 conversationId
    • 集成 IdentityApi 获取用户信息。
  2. 前端:
    • 创建 ChatHistorySidebar 组件。
    • 创建 ConversationListItem 组件。
    • 修改 ChatWindow 组件以支持加载和发送带有 conversationId 的消息。
    • 创建容器组件 (ChatPage) 来协调状态和布局。
    • 实现 API 调用逻辑 (使用 WorkspaceApi)。
    • 集成状态管理。
  3. 集成与测试:
    • 在 Backstage App 中正确挂载前端和后端插件。
    • 进行端到端测试:新建对话、发送多条消息、切换到旧对话、加载历史、删除对话。

9. 注意事项

  • 用户身份: 确保后端能正确识别当前用户 (通过 IdentityApi),以便只显示和操作该用户的对话历史。
  • 性能: 如果对话历史非常多,考虑在 GET /history API 中加入分页。
  • 错误处理: 在前端和后端都添加健壮的错误处理和用户反馈。
  • 标题生成: 可以考虑自动生成对话标题(例如,使用第一条用户消息的前 N 个词),或者允许用户稍后编辑标题。
  • RAG Context: 当加载旧对话并继续提问时,是否需要将之前的对话内容作为上下文提供给 RAG 模型?这需要在后端处理消息时考虑。可能需要将历史消息(或其摘要)传给 RAG 后端。
  • UI/UX: 考虑侧边栏的响应式设计,以及加载状态、空状态(没有历史记录时)的显示。
// plugins/chat-backend/src/service/router.ts
import {
DatabaseService,
LoggerService,
HttpRouterService,
} 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';
import { FeedbackInput, HistoryApiResponse, SendApiResponse } from '../types';
export interface RouterOptions {
logger: LoggerService;
identity: IdentityApi;
database: DatabaseService;
// Add other dependencies like RAG service client if needed
}
// 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.');
res.status(401).json({ error: 'Unauthorized' });
return;
}
req.userRef = userIdentity.identity.userEntityRef;
next();
} catch (error: any) {
logger.error('Failed to get user identity:', error);
res.status(500).json({ error: 'Failed to resolve user identity' });
}
};
};
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger, identity, database } = options;
const dbStore = new DbConversationStore(await database.getClient(), logger);
const identityMiddleware = createIdentityMiddleware(identity, logger);
const router = Router();
router.use(express.json());
// Apply identity middleware to all chat routes
router.use(identityMiddleware);
// --- 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) => {
const { content, conversationId } = req.body as { content: string, conversationId: string | null };
const userRef = req.userRef; // Guaranteed by middleware
if (!content) {
return res.status(400).json({ error: 'Message content cannot be empty' });
}
let currentConversationId = conversationId;
let newConvIdResponse: string | undefined = undefined;
try {
// Handle conversation creation or validation
if (!currentConversationId) {
const { id: newId } = await dbStore.createConversation(userRef);
currentConversationId = newId;
newConvIdResponse = newId; // Mark that a new ID was created for the response
logger.info(`Started new conversation ${currentConversationId} for user ${userRef}`);
} else {
const conversation = await dbStore.getConversation(currentConversationId, userRef);
if (!conversation) {
logger.warn(`User ${userRef} tried to send message to unauthorized/non-existent conversation ${currentConversationId}`);
return res.status(403).json({ error: 'Conversation not found or access denied' });
}
}
// Save user message
await dbStore.saveMessage({
conversation_id: currentConversationId,
sender_type: 'user',
content: content,
// No metadata needed for user message usually
});
// --- !!! Placeholder for your RAG/LLM Logic !!! ---
// This is where you would call your actual RAG service/LLM
// You might fetch context: const history = await dbStore.getMessages(currentConversationId, userRef);
// const { responseContent, responseMetadata } = await ragService.generateResponse(content, history);
const aiResponseContent = `AI response to: "${content}" in conversation ${currentConversationId}`; // Replace with actual AI response
const aiResponseMetadata = { context: { source: 'placeholder_rag_logic' } }; // Replace with actual RAG context/metadata
// --- !!! End Placeholder !!! ---
// Save AI message
const savedAiMessage = await dbStore.saveMessage({
conversation_id: currentConversationId,
sender_type: 'ai',
content: aiResponseContent,
metadata: aiResponseMetadata,
});
// Update conversation timestamp (fire and forget is ok here)
dbStore.updateConversationTimestamp(currentConversationId).catch(err => logger.error(`Failed to update timestamp for conv ${currentConversationId}`, err));
// Respond to frontend
const responsePayload: SendApiResponse = {
aiResponse: savedAiMessage,
newConversationId: newConvIdResponse,
};
res.status(200).json(responsePayload);
} catch (error: any) {
logger.error(`Error processing message for conv ${currentConversationId || 'new'} for user ${userRef}:`, error);
res.status(500).json({ 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);
res.json({ conversations });
} catch (error: any) {
logger.error(`Failed to fetch history for user ${userRef}:`, error);
res.status(500).json({ 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 {
const messages = await dbStore.getMessages(conversationId, userRef);
if (messages.length === 0) {
// Check if conversation actually exists or if it's just empty
const convExists = await dbStore.getConversation(conversationId, userRef);
if(!convExists) {
return res.status(404).json({ error: 'Conversation not found or access denied' });
}
}
const messageIds = messages.filter(m => m.sender_type === 'ai').map(m => m.id);
const feedbackMap = await dbStore.getFeedbackForMessagesByUser(messageIds, userRef);
const messagesWithFeedback = messages.map(msg => ({
...msg,
userFeedback: msg.sender_type === 'ai' ? feedbackMap.get(msg.id) || null : null,
}));
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: 'Failed to fetch messages' });
}
});
// 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 {
const deleted = await dbStore.deleteConversation(conversationId, userRef);
if (deleted) {
res.status(204).send(); // No Content
} else {
// Could be not found OR not owned by user
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: '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
comment?: string;
};
const userRef = req.userRef;
if (!messageId || (feedbackType !== null && !['up', 'down'].includes(feedbackType))) {
return res.status(400).json({ error: 'Invalid input: messageId and valid feedbackType (up/down/null) are required.' });
}
try {
// Fetch the original message to get content and context for denormalization
const message = await dbStore.getMessageById(messageId);
if (!message) {
return res.status(404).json({ error: 'Message to provide feedback on not found' });
}
// Optional: Check if message.sender_type is 'ai' before allowing feedback?
const feedbackInput: FeedbackInput = {
message_id: messageId,
user_ref: userRef,
feedback_type: feedbackType,
feedback_comment: comment,
rated_message_content: message.content, // Denormalize
rag_context: message.metadata // Denormalize (assumes context is in metadata)
};
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: 'Failed to save feedback' });
}
});
// Generic error handler
router.use((err: Error, _req: express.Request, res: express.Response, next: express.NextFunction) => {
if (res.headersSent) {
return next(err);
}
logger.error('API Router Error:', err);
res.status(500).json({ error: 'Internal Server Error' });
});
return router;
}
// plugins/chat-backend/src/service/router.ts
// ... (import statements and other code remain the same)
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger, identity, database /*, Add your RAG service dependency here, e.g., ragService */ } = options;
const dbStore = new DbConversationStore(await database.getClient(), logger);
const identityMiddleware = createIdentityMiddleware(identity, logger);
const router = Router();
router.use(express.json());
// Apply identity middleware to all chat routes
router.use(identityMiddleware);
// --- 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) => {
const { content, conversationId } = req.body as { content: string, conversationId: string | null };
const userRef = req.userRef; // Guaranteed by middleware
if (!content) {
return res.status(400).json({ error: 'Message content cannot be empty' });
}
let currentConversationId = conversationId;
let newConvIdResponse: string | undefined = undefined;
try {
// Handle conversation creation or validation
if (!currentConversationId) {
const { id: newId } = await dbStore.createConversation(userRef);
currentConversationId = newId;
newConvIdResponse = newId; // Mark that a new ID was created for the response
logger.info(`Started new conversation ${currentConversationId} for user ${userRef}`);
} else {
const conversation = await dbStore.getConversation(currentConversationId, userRef);
if (!conversation) {
logger.warn(`User ${userRef} tried to send message to unauthorized/non-existent conversation ${currentConversationId}`);
return res.status(403).json({ error: 'Conversation not found or access denied' });
}
}
// Save user message FIRST, so it's part of the history for the next AI response
// Note: Some RAG designs might prefer sending the current user message *with* history
// before saving the user message, depending on how the RAG service handles turns.
// Saving first ensures the history fetched below includes the user's latest message.
const userMessage = await dbStore.saveMessage({
conversation_id: currentConversationId,
sender_type: 'user',
content: content,
// No metadata needed for user message usually
});
// --- !!! Fetch Conversation History !!! ---
// Get all messages *including* the one just sent by the user
const history = await dbStore.getMessages(currentConversationId, userRef);
// history will be an array of Message objects, ordered by timestamp ascending
// --- !!! Call your RAG/LLM Logic Using History !!! ---
// This is where you would replace the placeholder with your actual call.
// You need to implement or integrate a service that takes the history
// (or a relevant subset of it) and the current user message content
// and generates a response.
// Example hypothetical call:
// const { responseContent: aiResponseContent, responseMetadata: aiResponseMetadata } =
// await ragService.generateResponse({ userQuery: content, conversationHistory: history });
// Placeholder for actual RAG/LLM call - REPLACE THIS
logger.info(`Using placeholder RAG logic for conversation ${currentConversationId}`);
const aiResponseContent = `(Context-aware response placeholder) AI response to: "${content}" based on ${history.length} messages in conversation ${currentConversationId}`; // Simulate using history
const aiResponseMetadata = {
context: {
source: 'placeholder_rag_logic_with_history',
// In a real scenario, populate this with actual RAG sources, relevant history window, etc.
historyLength: history.length,
lastUserMessage: content,
}
};
// --- !!! End RAG/LLM Call !!! ---
// Save AI message
const savedAiMessage = await dbStore.saveMessage({
conversation_id: currentConversationId,
sender_type: 'ai',
content: aiResponseContent,
metadata: aiResponseMetadata, // Store metadata from RAG (e.g., sources)
});
// Update conversation timestamp (fire and forget is ok here)
dbStore.updateConversationTimestamp(currentConversationId).catch(err => logger.error(`Failed to update timestamp for conv ${currentConversationId}`, err));
// Respond to frontend
const responsePayload: SendApiResponse = {
aiResponse: savedAiMessage,
newConversationId: newConvIdResponse,
};
res.status(200).json(responsePayload);
} catch (error: any) {
logger.error(`Error processing message for conv ${currentConversationId || 'new'} for user ${userRef}:`, error);
res.status(500).json({ error: 'Failed to process message' });
}
});
// ... (other endpoints like /history, /conversations/:id, /feedback, etc. remain the same)
// Generic error handler
router.use((err: Error, _req: express.Request, res: express.Response, next: express.NextFunction) => {
if (res.headersSent) {
return next(err);
}
logger.error('API Router Error:', err);
res.status(500).json({ error: 'Internal Server Error' });
});
return router;
}
// components/ChatHistorySidebar/ChatHistorySidebar.tsx (Updated Styles)
import React, { useState, useEffect, useCallback } from 'react';
import {
List,
ListItem,
ListItemIcon, // Import for potential icon alignment if needed later
ListItemText,
IconButton,
Button,
Box,
Tooltip,
Typography,
CircularProgress,
} from '@material-ui/core';
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
import DeleteIcon from '@material-ui/icons/Delete';
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import { useApi, fetchApiRef } from '@backstage/core-plugin-api';
import { makeStyles } from '@material-ui/core/styles';
// --- Types (ConversationMeta) remain the same ---
type ConversationMeta = {
id: string;
title: string;
updated_at: string;
};
// --- Props remain the same ---
type ChatHistorySidebarProps = {
onSelectConversation: (id: string | null) => void;
currentConversationId: string | null;
isExpanded: boolean;
onToggleExpand: () => void;
};
// --- Styles Refined ---
const useStyles = makeStyles((theme) => ({
sidebarContainer: {
display: 'flex',
flexDirection: 'column',
height: '100%',
position: 'relative', // Keep for potential absolute positioning needs
},
topBar: {
display: 'flex',
alignItems: 'center', // Vertically aligns items in the top bar (Addresses Req 1 partially)
justifyContent: 'space-between',
paddingLeft: theme.spacing(1.5), // Consistent left padding for topBar and list items
paddingRight: theme.spacing(1),
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
flexShrink: 0,
},
newChatButton: {
marginRight: theme.spacing(1), // Space between New Chat and Toggle Button
// Request 2: Removed size="small" to make it default medium size
// Request 1: Default Button behavior usually handles vertical centering well.
// If needed, add specific styles here for button's internal content alignment.
// e.g., '& .MuiButton-startIcon': { marginBottom: '-2px' } // Example adjustment if icon is off
},
listContainer: {
flexGrow: 1,
overflowY: 'auto',
// No horizontal padding here, let ListItem handle it for alignment
},
listItem: {
// Request 3: Apply consistent left padding to match topBar's content start
paddingLeft: theme.spacing(1.5),
paddingRight: theme.spacing(1), // Match topBar right padding
},
listItemText: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
// Ensure text aligns left properly within its container
margin: 0, // Reset default margins if needed
},
// Request 4: Style for collapsed state - icon at top-center
collapsedContent: {
display: 'flex',
justifyContent: 'center', // Center icon horizontally
paddingTop: theme.spacing(1), // Position icon near the top
height: '100%', // Ensure it takes full height for alignment context
},
}));
export const ChatHistorySidebar = ({
onSelectConversation,
currentConversationId,
isExpanded,
onToggleExpand,
}: ChatHistorySidebarProps) => {
const classes = useStyles();
const [conversations, setConversations] = useState<ConversationMeta[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchApi = useApi(fetchApiRef);
// --- Fetch History Logic (remains the same) ---
const fetchHistory = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetchApi.fetch('/api/chat/history'); // <<< ADJUST API PATH
if (!response.ok) {
throw new Error(`Failed to fetch history: ${response.statusText}`);
}
const data = await response.json();
setConversations(data.conversations || []);
} catch (e: any) {
setError(e);
} finally {
setIsLoading(false);
}
}, [fetchApi]);
useEffect(() => {
fetchHistory();
}, [fetchHistory]);
// --- Delete Logic (remains the same) ---
const handleDelete = useCallback(async (id: string, event: React.MouseEvent) => {
event.stopPropagation();
if (window.confirm('Are you sure you want to delete this conversation?')) {
try {
const response = await fetchApi.fetch(`/api/chat/conversations/${id}`, { method: 'DELETE' }); // <<< ADJUST API PATH
if (!response.ok) {
throw new Error(`Failed to delete conversation: ${response.statusText}`);
}
setConversations(prev => prev.filter(conv => conv.id !== id));
if (id === currentConversationId) {
onSelectConversation(null);
}
} catch (e: any) {
console.error("Deletion failed:", e);
// Show error to user
}
}
}, [fetchApi, currentConversationId, onSelectConversation]);
// --- New Chat Handler (remains the same) ---
const handleNewChat = useCallback(() => {
onSelectConversation(null);
}, [onSelectConversation]);
return (
<Box className={classes.sidebarContainer}>
{/* Conditionally render content based on expanded state */}
{isExpanded ? (
<>
{/* --- Top Bar: New Chat + Toggle Button --- */}
<Box className={classes.topBar}>
<Button
variant="contained"
color="primary"
// Removed size="small" to make button larger (Request 2)
startIcon={<AddCircleOutlineIcon />} // Request 1: Vertical alignment usually handled by Button
onClick={handleNewChat}
className={classes.newChatButton}
>
New Chat
</Button>
<Tooltip title="Collapse Sidebar">
{/* Use default size IconButton */}
<IconButton onClick={onToggleExpand} size="medium">
<ChevronLeftIcon />
</IconButton>
</Tooltip>
</Box>
{/* Loading and Error display */}
{isLoading && <Box display="flex" justifyContent="center" p={1}><CircularProgress size={24} /></Box>}
{error && <Box p={1} pl={1.5}><Typography variant="caption" color="error">Error: {error.message}</Typography></Box>}
{/* Conversation List */}
{!isLoading && !error && (
<Box className={classes.listContainer}>
{/* Apply dense to List for tighter spacing if desired */}
<List component="nav" aria-label="conversation history" dense>
{conversations.map((conv) => (
<ListItem
button
key={conv.id}
selected={conv.id === currentConversationId}
onClick={() => onSelectConversation(conv.id)}
className={classes.listItem} // Apply padding for alignment (Request 3)
>
<ListItemText
primary={conv.title || `Chat from ${new Date(conv.updated_at).toLocaleTimeString()}`}
// Apply class for text overflow and alignment (Request 3)
primaryTypographyProps={{ noWrap: true, className: classes.listItemText }}
/>
{/* Ensure delete icon aligns well, edge="end" helps */}
<IconButton edge="end" aria-label="delete" size="small" onClick={(e) => handleDelete(conv.id, e)}>
<DeleteIcon fontSize="small"/>
</IconButton>
</ListItem>
))}
</List>
</Box>
)}
</>
) : (
// --- Collapsed State (Request 4) ---
<Box className={classes.collapsedContent}>
<Tooltip title="Expand Sidebar">
{/* Use default size IconButton */}
<IconButton onClick={onToggleExpand} size="medium">
<ChevronRightIcon />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
);
};
// plugins/chat-backend/src/types.ts
/**
* Represents a conversation thread in the database.
*/
export interface Conversation {
id: string; // UUID
user_ref: string;
title?: string;
created_at: string; // ISO String from DB
updated_at: string; // ISO String from DB
}
/**
* Represents metadata for listing conversations.
*/
export interface ConversationMeta {
id: string;
title?: string;
updated_at: string;
}
/**
* Represents a single message within a conversation.
*/
export interface Message {
id: string; // UUID
conversation_id: string; // UUID, FK to conversations
sender_type: 'user' | 'ai';
content: string;
metadata?: Record<string, any>; // Stores RAG context, etc. for AI messages
timestamp: string; // ISO String from DB
// Optional field added when fetching messages for the frontend
userFeedback?: 'up' | 'down' | null;
}
/**
* Represents feedback given by a user on a specific message.
*/
export interface Feedback {
id: string; // UUID
message_id: string; // UUID, FK to messages
user_ref: string;
feedback_type: 'up' | 'down';
feedback_comment?: string;
rated_message_content: string;
rag_context?: Record<string, any>;
created_at: string; // ISO String from DB
updated_at: string; // ISO String from DB
}
/**
* Structure for saving feedback (some fields are auto-generated or derived).
*/
export interface FeedbackInput {
message_id: string;
user_ref: string;
feedback_type: 'up' | 'down' | null; // null indicates removal of feedback
feedback_comment?: string;
rated_message_content: string; // Must be provided when saving
rag_context?: Record<string, any>; // Must be provided when saving
}
/**
* API Response structure when sending a message.
*/
export interface SendApiResponse {
aiResponse: Message; // The AI's reply message object saved in DB
newConversationId?: string; // Returned ONLY when a new conversation was created
}
/**
* API Response structure for getting conversation history.
*/
export interface HistoryApiResponse {
messages: Message[];
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment