immersive2/src/react/components/ChatPanel.tsx
Michael Mainguy 7769910027 Add server-side session persistence for chat
Implement session management to maintain conversation history and entity
context across page refreshes. Sessions are stored in-memory and include:
- Conversation history (stored server-side, restored on reconnect)
- Entity snapshots synced before each message for LLM context
- Auto-injection of diagram state into Claude's system prompt

Key changes:
- Add session store service with create/resume/sync/clear operations
- Add session API endpoints (/api/session/*)
- Update Claude API to inject entity context and manage history
- Update ChatPanel to initialize sessions and sync entities
- Add debug endpoint for inspecting session state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 13:42:01 -06:00

248 lines
9.0 KiB
TypeScript

import React, {useEffect, useRef, useState} from "react";
import {ActionIcon, Alert, Box, CloseButton, Group, Paper, ScrollArea, Text, Textarea} from "@mantine/core";
import {IconAlertCircle, IconRobot, IconSend} from "@tabler/icons-react";
import ChatMessage from "./ChatMessage";
import {ChatMessage as ChatMessageType, SessionMessage, ToolResult} from "../types/chatTypes";
import {
createAssistantMessage,
createLoadingMessage,
createOrResumeSession,
createUserMessage,
sendMessage,
syncEntitiesToSession
} from "../services/diagramAI";
import {getEntitiesForSync} from "../services/entityBridge";
import {getPath} from "../../util/functions/getPath";
import log from "loglevel";
import {v4 as uuidv4} from 'uuid';
const logger = log.getLogger('ChatPanel');
interface ChatPanelProps {
width?: number;
onClose?: () => void;
}
const WELCOME_MESSAGE = "Hello! I can help you create and modify 3D diagrams. Try saying things like:\n\n• \"Add a blue box labeled 'Server'\"\n• \"Create a red sphere called 'Database'\"\n• \"Connect Server to Database\"\n• \"What's in my diagram?\"\n\nHow can I help you today?";
/**
* Convert session messages to ChatMessage format
*/
function sessionMessageToChatMessage(msg: SessionMessage): ChatMessageType | null {
// Skip messages with non-string content (e.g., tool result objects)
if (typeof msg.content !== 'string') {
return null;
}
return {
id: uuidv4(),
role: msg.role,
content: msg.content,
timestamp: new Date(msg.timestamp),
toolResults: msg.toolResults
};
}
export default function ChatPanel({width = 400, onClose}: ChatPanelProps) {
const [messages, setMessages] = useState<ChatMessageType[]>([]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isInitializing, setIsInitializing] = useState(true);
const [error, setError] = useState<string | null>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Initialize or resume session on mount
useEffect(() => {
const initSession = async () => {
const diagramId = getPath() || 'default';
logger.info('Initializing session for diagram:', diagramId);
try {
// Create or resume session
const {session, isNew} = await createOrResumeSession(diagramId);
logger.info(`Session ${isNew ? 'created' : 'resumed'}:`, session.id);
// Sync current entities to server
const entities = await getEntitiesForSync();
if (entities.length > 0) {
await syncEntitiesToSession(entities);
logger.info('Synced', entities.length, 'entities to session');
}
// Restore conversation history or show welcome message
if (!isNew && session.conversationHistory && session.conversationHistory.length > 0) {
const restoredMessages = session.conversationHistory
.map(sessionMessageToChatMessage)
.filter((msg): msg is ChatMessageType => msg !== null);
if (restoredMessages.length > 0) {
setMessages(restoredMessages);
logger.info('Restored', restoredMessages.length, 'messages from session');
} else {
setMessages([createAssistantMessage(WELCOME_MESSAGE)]);
}
} else {
setMessages([createAssistantMessage(WELCOME_MESSAGE)]);
}
} catch (err) {
logger.error('Failed to initialize session:', err);
// Fall back to welcome message
setMessages([createAssistantMessage(WELCOME_MESSAGE)]);
} finally {
setIsInitializing(false);
}
};
initSession();
}, []);
// Auto-scroll when messages change
useEffect(() => {
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTo({
top: scrollAreaRef.current.scrollHeight,
behavior: 'smooth'
});
}
}, [messages]);
const handleSend = async () => {
const trimmedInput = inputValue.trim();
if (!trimmedInput || isLoading) return;
setError(null);
const userMessage = createUserMessage(trimmedInput);
setMessages(prev => [...prev, userMessage]);
setInputValue('');
setIsLoading(true);
const loadingMessage = createLoadingMessage();
setMessages(prev => [...prev, loadingMessage]);
try {
// Sync current entities before sending message so Claude has latest state
const entities = await getEntitiesForSync();
if (entities.length > 0) {
await syncEntitiesToSession(entities);
logger.debug('Synced', entities.length, 'entities before message');
}
const allToolResults: ToolResult[] = [];
const {response, toolResults} = await sendMessage(
trimmedInput,
messages,
(result) => {
allToolResults.push(result);
logger.debug('Tool executed:', result);
}
);
setMessages(prev => {
const filtered = prev.filter(m => m.id !== loadingMessage.id);
return [...filtered, createAssistantMessage(response, toolResults)];
});
} catch (err) {
logger.error('Chat error:', err);
setMessages(prev => prev.filter(m => m.id !== loadingMessage.id));
setError(err instanceof Error ? err.message : 'Failed to send message');
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<Paper
shadow="xl"
style={{
width,
height: '100vh',
display: 'flex',
flexDirection: 'column',
borderLeft: '1px solid var(--mantine-color-dark-4)',
backgroundColor: 'var(--mantine-color-dark-7)',
}}
>
{/* Header */}
<Box
style={{
padding: '1rem',
borderBottom: '1px solid var(--mantine-color-dark-4)',
flexShrink: 0,
}}
>
<Group justify="space-between">
<Group gap="xs">
<IconRobot size={24}/>
<Text fw={600}>Diagram Assistant</Text>
</Group>
{onClose && <CloseButton onClick={onClose}/>}
</Group>
</Box>
{/* Messages */}
<ScrollArea
style={{flex: 1}}
viewportRef={scrollAreaRef}
p="md"
>
{messages.map((message) => (
<ChatMessage key={message.id} message={message}/>
))}
</ScrollArea>
{/* Error Alert */}
{error && (
<Alert
color="red"
icon={<IconAlertCircle size={16}/>}
withCloseButton
onClose={() => setError(null)}
style={{margin: '0 1rem'}}
>
{error}
</Alert>
)}
{/* Input */}
<Box
style={{
padding: '1rem',
borderTop: '1px solid var(--mantine-color-dark-4)',
flexShrink: 0,
}}
>
<Group gap="xs" align="flex-end">
<Textarea
ref={textareaRef}
placeholder={isInitializing ? "Connecting..." : "Describe what you want to create..."}
value={inputValue}
onChange={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={handleKeyDown}
autosize
minRows={1}
maxRows={4}
disabled={isLoading || isInitializing}
style={{flex: 1}}
/>
<ActionIcon
size="lg"
variant="filled"
onClick={handleSend}
disabled={!inputValue.trim() || isLoading || isInitializing}
loading={isLoading || isInitializing}
>
<IconSend size={18}/>
</ActionIcon>
</Group>
</Box>
</Paper>
);
}