Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save Cozy228/8d94231c54026a12344e85f6fb91b4ad to your computer and use it in GitHub Desktop.
// components/ChatHistorySidebar/ChatHistorySidebar.tsx
import React, { useState, useEffect, useCallback, ChangeEvent } from 'react'; // Added ChangeEvent
import {
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Button,
Box,
Tooltip,
Typography,
CircularProgress,
Divider,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField, // <-- Import TextField for Edit Dialog
} from '@material-ui/core';
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
import DeleteIcon from '@material-ui/icons/Delete';
import EditIcon from '@material-ui/icons/Edit';
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import RefreshIcon from '@material-ui/icons/Refresh';
import DeleteSweepIcon from '@material-ui/icons/DeleteSweep'; // <-- Icon for Delete All
import { useApi, fetchApiRef } from '@backstage/core-plugin-api';
import { makeStyles } from '@material-ui/core/styles';
import { Alert } from '@material-ui/lab';
// --- Types ---
type ConversationMeta = {
id: string;
title: string;
updated_at: string;
};
// --- Props ---
type ChatHistorySidebarProps = {
onSelectConversation: (id: string | null) => void;
currentConversationId: string | null;
isExpanded: boolean;
onToggleExpand: () => void;
refetchKey: number;
};
// --- Styles ---
const useStyles = makeStyles((theme) => ({
sidebarContainer: { /* ... as before ... */
display: 'flex', flexDirection: 'column', height: '100%', position: 'relative',
},
topBar: { /* ... as before ... */
display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: theme.spacing(1, 1, 1, 1.5), flexShrink: 0, borderBottom: `1px solid ${theme.palette.divider}`,
},
topBarActions: { // Group buttons on the right
display: 'flex',
alignItems: 'center',
},
newChatButton: { /* ... as before ... */
marginRight: theme.spacing(1),
},
deleteAllButton: { // Style for delete all button
marginRight: theme.spacing(1),
color: theme.palette.error.main, // Make it red
borderColor: theme.palette.error.light, // Lighter border
},
listContainer: { /* ... as before ... */
flexGrow: 1, overflowY: 'auto', paddingTop: theme.spacing(1),
},
listItem: { /* ... as before ... */
paddingLeft: theme.spacing(1.5), paddingRight: theme.spacing(8), '& .MuiListItemSecondaryAction-root': { right: theme.spacing(0.5), }, '&.Mui-selected .MuiListItemSecondaryAction-root': {},
},
listItemText: { /* ... as before ... */
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', margin: 0,
},
listItemActions: { /* ... as before ... */
display: 'flex', alignItems: 'center', '& > *': { marginLeft: theme.spacing(0.5), },
},
collapsedContent: { /* ... as before ... */
display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: theme.spacing(1), height: '100%',
},
errorAlert: { /* ... as before ... */
margin: theme.spacing(0, 1.5, 1, 1.5),
},
loadingBox: { /* ... as before ... */
padding: theme.spacing(2),
},
emptyStateBox: { /* ... as before ... */
padding: theme.spacing(2, 1.5), textAlign: 'center',
}
}));
export const ChatHistorySidebar = ({
onSelectConversation,
currentConversationId,
isExpanded,
onToggleExpand,
refetchKey,
}: ChatHistorySidebarProps) => {
const classes = useStyles();
const [conversations, setConversations] = useState<ConversationMeta[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fetchApi = useApi(fetchApiRef);
// --- Dialog States ---
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState<boolean>(false);
const [conversationToDeleteId, setConversationToDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [isDeleteAllDialogOpen, setIsDeleteAllDialogOpen] = useState<boolean>(false);
const [isDeletingAll, setIsDeletingAll] = useState<boolean>(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState<boolean>(false);
const [conversationToEdit, setConversationToEdit] = useState<ConversationMeta | null>(null);
const [newTitleInput, setNewTitleInput] = useState<string>('');
const [isEditingTitle, setIsEditingTitle] = useState<boolean>(false);
// --- End Dialog States ---
// Fetch History Logic (no changes)
const fetchHistory = useCallback(async (isManualRefresh: boolean = false) => { /* ... as before ... */
if (!isManualRefresh) { setIsLoading(true); } setErrorMessage(null); console.log(`Workspaceing history (key: ${refetchKey}, manual: ${isManualRefresh})`); try { const response = await fetchApi.fetch('/api/chat/history'); if (!response.ok) { const errorBody = await response.text(); throw new Error(`History Fetch Error: ${response.status} ${response.statusText} - ${errorBody}`); } const data = await response.json(); setConversations(Array.isArray(data?.conversations) ? data.conversations : []); } catch (e: any) { console.error("Fetch history failed:", e); setErrorMessage(e.message || 'Unknown error fetching history.'); setConversations([]); } finally { if (!isManualRefresh) { setIsLoading(false); } }
}, [fetchApi, refetchKey]);
useEffect(() => {
fetchHistory();
}, [fetchHistory, refetchKey]);
// --- Single Delete Logic (Uses Dialog) ---
const handleDeleteClick = (id: string, event: React.MouseEvent) => { /* ... as before ... */
event.stopPropagation(); setErrorMessage(null); setConversationToDeleteId(id); setIsDeleteDialogOpen(true);
};
const handleCloseDeleteDialog = () => { /* ... as before ... */
setIsDeleteDialogOpen(false); setConversationToDeleteId(null); setIsDeleting(false);
};
const handleConfirmDelete = async () => { /* ... as before ... */
if (!conversationToDeleteId) { setErrorMessage("Error: No conversation selected."); handleCloseDeleteDialog(); return; } setIsDeleting(true); setErrorMessage(null); try { const response = await fetchApi.fetch(`/api/chat/conversations/${conversationToDeleteId}`, { method: 'DELETE' }); if (!response.ok) { let errorMsg = `Delete Error: ${response.statusText}`; if (response.status === 404 || response.status === 403) { errorMsg = `Conversation not found or access denied.`; } else { try { const body = await response.json(); errorMsg += ` - ${body.error}`; } catch {} } throw new Error(errorMsg); } setConversations(prev => prev.filter(conv => conv.id !== conversationToDeleteId)); if (conversationToDeleteId === currentConversationId) { onSelectConversation(null); } handleCloseDeleteDialog(); } catch (e: any) { console.error("Deletion failed:", e); setErrorMessage(e.message || 'Unknown deletion error.'); handleCloseDeleteDialog(); }
};
// --- End Single Delete Logic ---
// --- Delete All Logic ---
const handleDeleteAllClick = () => {
setErrorMessage(null);
setIsDeleteAllDialogOpen(true);
};
const handleCloseDeleteAllDialog = () => {
setIsDeleteAllDialogOpen(false);
setIsDeletingAll(false);
};
const handleConfirmDeleteAll = async () => {
setIsDeletingAll(true);
setErrorMessage(null);
try {
// Call the new backend endpoint for deleting all
const response = await fetchApi.fetch(`/api/chat/conversations`, { method: 'DELETE' }); // No ID needed
if (!response.ok) {
let errorMsg = `Failed to delete all: ${response.statusText}`;
try { const body = await response.json(); errorMsg += ` - ${body.error}`; } catch {}
throw new Error(errorMsg);
}
// Clear local state and potentially reset view
setConversations([]);
if (currentConversationId !== null) {
onSelectConversation(null); // Switch to 'new chat' view
}
handleCloseDeleteAllDialog(); // Close dialog on success
} catch (e: any) {
console.error("Delete All failed:", e);
setErrorMessage(e.message || 'An unknown error occurred deleting all conversations.');
handleCloseDeleteAllDialog(); // Also close on error
}
};
// --- End Delete All Logic ---
// --- Edit Title Logic (Uses Dialog) ---
const handleEditTitleClick = (conv: ConversationMeta, event: React.MouseEvent) => {
event.stopPropagation();
setErrorMessage(null);
setConversationToEdit(conv); // Store the conversation object
setNewTitleInput(conv.title); // Pre-fill input with current title
setIsEditDialogOpen(true); // Open the dialog
};
const handleCloseEditDialog = () => {
setIsEditDialogOpen(false);
setConversationToEdit(null);
setNewTitleInput(''); // Reset input
setIsEditingTitle(false);
};
const handleEditInputChange = (event: ChangeEvent<HTMLInputElement>) => {
setNewTitleInput(event.target.value);
};
const handleConfirmEditTitle = async () => {
if (!conversationToEdit || !newTitleInput.trim() || newTitleInput.trim() === conversationToEdit.title) {
// Don't save if no change or empty title
if (!newTitleInput.trim()){
setErrorMessage("Title cannot be empty."); // Show error temporarily in dialog? Or outside?
}
// Maybe close dialog if title hasn't changed, or keep open? Closing is simpler.
handleCloseEditDialog();
return;
}
const trimmedTitle = newTitleInput.trim();
setIsEditingTitle(true);
setErrorMessage(null);
try {
const response = await fetchApi.fetch(`/api/chat/conversations/${conversationToEdit.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: trimmedTitle }),
});
if (!response.ok) {
let errorMsg = `Failed to update title: ${response.statusText}`;
if (response.status === 404 || response.status === 403) { errorMsg = `Conversation not found or access denied.`; }
else if (response.status === 400) { errorMsg = `Invalid title provided.`; }
else { try { const body = await response.json(); errorMsg += ` - ${body.error}`; } catch {} }
throw new Error(errorMsg);
}
// Optimistic UI update
setConversations(prev =>
prev.map(conv =>
conv.id === conversationToEdit.id ? { ...conv, title: trimmedTitle } : conv
)
);
handleCloseEditDialog(); // Close on success
} catch (e: any) {
console.error("Title update failed:", e);
setErrorMessage(e.message || 'An unknown error occurred updating title.');
handleCloseEditDialog(); // Close on error too
}
};
// Allow submitting edit dialog with Enter key
const handleEditDialogKeyPress = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
// Prevent default form submission if TextField is inside a form
event.preventDefault();
handleConfirmEditTitle();
}
};
// --- End Edit Title Logic ---
// New Chat Handler (no changes)
const handleNewChat = useCallback(() => {
onSelectConversation(null);
}, [onSelectConversation]);
// Manual Refresh Handler (no changes)
const handleManualRefresh = () => {
fetchHistory(true);
};
// --- Render Logic ---
return (
<Box className={classes.sidebarContainer}>
{/* --- DIALOGS --- */}
{/* Edit Title Dialog */}
<Dialog open={isEditDialogOpen} onClose={handleCloseEditDialog} aria-labelledby="edit-title-dialog-title">
<DialogTitle id="edit-title-dialog-title">Edit Conversation Title</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
id="name"
label="New Title"
type="text"
fullWidth
value={newTitleInput}
onChange={handleEditInputChange}
onKeyPress={handleEditDialogKeyPress} // Submit on Enter
disabled={isEditingTitle}
/>
{isEditingTitle && <Box textAlign="center" mt={2}><CircularProgress size={24} /></Box>}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseEditDialog} color="primary" disabled={isEditingTitle}>
Cancel
</Button>
<Button
onClick={handleConfirmEditTitle}
color="primary"
disabled={isEditingTitle || !newTitleInput.trim() || newTitleInput.trim() === conversationToEdit?.title} // Disable if no change or empty
>
Save
</Button>
</DialogActions>
</Dialog>
{/* Delete Single Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onClose={handleCloseDeleteDialog} aria-labelledby="delete-dialog-title" aria-describedby="delete-dialog-description">
<DialogTitle id="delete-dialog-title">Delete Conversation?</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
Are you sure you want to delete this conversation? This action cannot be undone.
</DialogContentText>
{isDeleting && <Box textAlign="center" mt={2}><CircularProgress size={24} /></Box>}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDeleteDialog} color="primary" disabled={isDeleting}> Cancel </Button>
<Button onClick={handleConfirmDelete} color="secondary" disabled={isDeleting} autoFocus> Delete </Button>
</DialogActions>
</Dialog>
{/* Delete All Confirmation Dialog */}
<Dialog open={isDeleteAllDialogOpen} onClose={handleCloseDeleteAllDialog} aria-labelledby="delete-all-dialog-title" aria-describedby="delete-all-dialog-description">
<DialogTitle id="delete-all-dialog-title" style={{ color: 'red' }}>Delete All Conversations?</DialogTitle>
<DialogContent>
<DialogContentText id="delete-all-dialog-description">
Are you absolutely sure you want to delete ALL of your conversations? This action is irreversible and will remove all chat history permanently.
</DialogContentText>
{isDeletingAll && <Box textAlign="center" mt={2}><CircularProgress size={24} /></Box>}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDeleteAllDialog} color="primary" disabled={isDeletingAll}> Cancel </Button>
<Button onClick={handleConfirmDeleteAll} className={classes.deleteAllButton} variant="contained" disabled={isDeletingAll} autoFocus> Delete All </Button>
</DialogActions>
</Dialog>
{/* --- END DIALOGS --- */}
{/* --- Main Sidebar Content --- */}
{isExpanded ? (
<>
{/* Top Bar */}
<Box className={classes.topBar}>
{/* Left Aligned Buttons */}
<Box>
<Button
variant="contained"
color="primary"
startIcon={<AddCircleOutlineIcon />}
onClick={handleNewChat}
className={classes.newChatButton}
size="small"
>
New Chat
</Button>
{/* Conditionally render Delete All only if there are conversations */}
{conversations.length > 0 && (
<Tooltip title="Delete all conversations">
<Button
variant="outlined"
// color="secondary" // Using custom class for red color
className={classes.deleteAllButton}
startIcon={<DeleteSweepIcon />}
onClick={handleDeleteAllClick}
size="small"
>
Delete All
</Button>
</Tooltip>
)}
</Box>
{/* Right Aligned Icons */}
<Box className={classes.topBarActions}>
<Tooltip title="Refresh History">
<IconButton onClick={handleManualRefresh} size="medium" edge="end"> <RefreshIcon /> </IconButton>
</Tooltip>
<Tooltip title="Collapse Sidebar">
<IconButton onClick={onToggleExpand} size="medium" edge="end"> <ChevronLeftIcon /> </IconButton>
</Tooltip>
</Box>
</Box>
{/* Loading and Error display */}
{isLoading && (<Box display="flex" justifyContent="center" className={classes.loadingBox}><CircularProgress size={24} /></Box>)}
{errorMessage && (<Alert severity="error" className={classes.errorAlert} onClose={() => setErrorMessage(null)}>{errorMessage}</Alert>)}
{/* Conversation List Area */}
{!isLoading && (
<Box className={classes.listContainer}>
{conversations.length > 0 ? (
<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}>
<ListItemText primary={conv.title || `Chat`} primaryTypographyProps={{ noWrap: true, className: classes.listItemText }} />
<ListItemSecondaryAction className={classes.listItemActions}>
<Tooltip title="Edit title">
<IconButton edge="end" aria-label="edit title" size="small" onClick={(e) => handleEditTitleClick(conv, e)} > <EditIcon fontSize="small"/> </IconButton>
</Tooltip>
<Tooltip title="Delete conversation">
<IconButton edge="end" aria-label="delete conversation" size="small" onClick={(e) => handleDeleteClick(conv.id, e)} > <DeleteIcon fontSize="small"/> </IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
) : ( !errorMessage && ( <Box className={classes.emptyStateBox}><Typography variant="body2" color="textSecondary">No conversations yet.</Typography></Box>) )}
</Box>
)}
</>
) : ( // Collapsed State
<Box className={classes.collapsedContent}>
<Tooltip title="Expand Sidebar"> <IconButton onClick={onToggleExpand} size="medium"> <ChevronRightIcon /> </IconButton> </Tooltip>
<Tooltip title="Refresh History"> <IconButton onClick={handleManualRefresh} size="medium"> <RefreshIcon /> </IconButton> </Tooltip>
<Tooltip title="New Chat"> <IconButton onClick={handleNewChat} size="medium"> <AddCircleOutlineIcon /> </IconButton> </Tooltip>
{/* Delete All doesn't make sense when collapsed, perhaps */}
</Box>
)}
</Box>
);
};
// 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>
);
};
// 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;
}
}
// 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