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>
248 lines
9.0 KiB
TypeScript
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>
|
|
);
|
|
}
|