diff --git a/server/api/claude.js b/server/api/claude.js index 1cd5b93..cdc6d95 100644 --- a/server/api/claude.js +++ b/server/api/claude.js @@ -1,8 +1,26 @@ import { Router } from "express"; +import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js"; const router = Router(); const ANTHROPIC_API_URL = "https://api.anthropic.com"; +/** + * Build entity context string for the system prompt + */ +function buildEntityContext(entities) { + if (!entities || entities.length === 0) { + return "\n\nThe diagram is currently empty."; + } + + const entityList = entities.map(e => { + const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown'; + const pos = e.position || { x: 0, y: 0, z: 0 }; + return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`; + }).join('\n'); + + return `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`; +} + // Express 5 uses named parameters for wildcards router.post("/*path", async (req, res) => { const apiKey = process.env.ANTHROPIC_API_KEY; @@ -16,6 +34,36 @@ router.post("/*path", async (req, res) => { const pathParam = req.params.path; const path = "/" + (Array.isArray(pathParam) ? pathParam.join("/") : pathParam || ""); + // Check for session-based request + const { sessionId, ...requestBody } = req.body; + let modifiedBody = requestBody; + + if (sessionId) { + const session = getSession(sessionId); + if (session) { + console.log(`[Claude API] Session ${sessionId}: ${session.entities.length} entities, ${session.conversationHistory.length} messages in history`); + + // Inject entity context into system prompt + if (modifiedBody.system) { + const entityContext = buildEntityContext(session.entities); + console.log(`[Claude API] Entity context:`, entityContext); + modifiedBody.system += entityContext; + } + + // Get conversation history and merge with current messages + const historyMessages = getConversationForAPI(sessionId); + if (historyMessages.length > 0 && modifiedBody.messages) { + // Filter out any duplicate messages (in case client sent history too) + const currentContent = modifiedBody.messages[modifiedBody.messages.length - 1]?.content; + const filteredHistory = historyMessages.filter(msg => msg.content !== currentContent); + modifiedBody.messages = [...filteredHistory, ...modifiedBody.messages]; + console.log(`[Claude API] Merged ${filteredHistory.length} history messages with ${modifiedBody.messages.length - filteredHistory.length} new messages`); + } + } else { + console.log(`[Claude API] Session ${sessionId} not found`); + } + } + try { const response = await fetch(`${ANTHROPIC_API_URL}${path}`, { method: "POST", @@ -24,10 +72,39 @@ router.post("/*path", async (req, res) => { "x-api-key": apiKey, "anthropic-version": "2023-06-01", }, - body: JSON.stringify(req.body), + body: JSON.stringify(modifiedBody), }); const data = await response.json(); + + // If session exists and response is successful, store messages + if (sessionId && response.ok && data.content) { + const session = getSession(sessionId); + if (session) { + // Store the user message if it was new (only if it's a string, not tool results) + const userMessage = requestBody.messages?.[requestBody.messages.length - 1]; + if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') { + addMessage(sessionId, { + role: 'user', + content: userMessage.content + }); + } + + // Store the assistant response (text only, not tool use blocks) + const assistantContent = data.content + .filter(c => c.type === 'text') + .map(c => c.text) + .join('\n'); + + if (assistantContent) { + addMessage(sessionId, { + role: 'assistant', + content: assistantContent + }); + } + } + } + res.status(response.status).json(data); } catch (error) { console.error("Claude API error:", error.message); diff --git a/server/api/index.js b/server/api/index.js index 70321ff..56faff4 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -1,8 +1,12 @@ import { Router } from "express"; import claudeRouter from "./claude.js"; +import sessionRouter from "./session.js"; const router = Router(); +// Session management +router.use("/session", sessionRouter); + // Claude API proxy router.use("/claude", claudeRouter); diff --git a/server/api/session.js b/server/api/session.js new file mode 100644 index 0000000..20e738a --- /dev/null +++ b/server/api/session.js @@ -0,0 +1,144 @@ +import { Router } from "express"; +import { + createSession, + getSession, + findSessionByDiagram, + syncEntities, + addMessage, + clearHistory, + deleteSession, + getStats +} from "../services/sessionStore.js"; + +const router = Router(); + +/** + * GET /api/session/debug/stats + * Get session statistics (for debugging) + * Query params: + * - details=true: Include full entity and conversation data + * NOTE: Must be before /:id routes to avoid matching "debug" as an id + */ +router.get("/debug/stats", (req, res) => { + const includeDetails = req.query.details === 'true'; + const stats = getStats(includeDetails); + console.log('[Session Debug] Stats requested:', JSON.stringify(stats, null, 2)); + res.json(stats); +}); + +/** + * POST /api/session/create + * Create a new session or return existing one for a diagram + */ +router.post("/create", (req, res) => { + const { diagramId } = req.body; + + if (!diagramId) { + return res.status(400).json({ error: "diagramId is required" }); + } + + // Check for existing session + let session = findSessionByDiagram(diagramId); + if (session) { + console.log(`[Session] Resuming existing session ${session.id} for diagram ${diagramId} (${session.conversationHistory.length} messages, ${session.entities.length} entities)`); + return res.json({ + session, + isNew: false + }); + } + + // Create new session + session = createSession(diagramId); + console.log(`[Session] Created new session ${session.id} for diagram ${diagramId}`); + res.json({ + session, + isNew: true + }); +}); + +/** + * GET /api/session/:id + * Get session details including history + */ +router.get("/:id", (req, res) => { + const session = getSession(req.params.id); + + if (!session) { + return res.status(404).json({ error: "Session not found" }); + } + + res.json({ session }); +}); + +/** + * PUT /api/session/:id/sync + * Sync entities from client to server + */ +router.put("/:id/sync", (req, res) => { + const { entities } = req.body; + + if (!entities || !Array.isArray(entities)) { + return res.status(400).json({ error: "entities array is required" }); + } + + const session = syncEntities(req.params.id, entities); + + if (!session) { + return res.status(404).json({ error: "Session not found" }); + } + + console.log(`[Session ${req.params.id}] Synced ${entities.length} entities:`, + entities.map(e => `${e.text || '(no label)'} (${e.template})`).join(', ') || 'none'); + + res.json({ success: true, entityCount: entities.length }); +}); + +/** + * POST /api/session/:id/message + * Add a message to history (used after successful Claude response) + */ +router.post("/:id/message", (req, res) => { + const { role, content, toolResults } = req.body; + + if (!role || !content) { + return res.status(400).json({ error: "role and content are required" }); + } + + const session = addMessage(req.params.id, { role, content, toolResults }); + + if (!session) { + return res.status(404).json({ error: "Session not found" }); + } + + res.json({ success: true, messageCount: session.conversationHistory.length }); +}); + +/** + * DELETE /api/session/:id/history + * Clear conversation history + */ +router.delete("/:id/history", (req, res) => { + const session = clearHistory(req.params.id); + + if (!session) { + return res.status(404).json({ error: "Session not found" }); + } + + res.json({ success: true }); +}); + +/** + * DELETE /api/session/:id + * Delete a session entirely + */ +router.delete("/:id", (req, res) => { + const deleted = deleteSession(req.params.id); + + if (!deleted) { + return res.status(404).json({ error: "Session not found" }); + } + + res.json({ success: true }); +}); + +export default router; diff --git a/server/services/sessionStore.js b/server/services/sessionStore.js new file mode 100644 index 0000000..c997efb --- /dev/null +++ b/server/services/sessionStore.js @@ -0,0 +1,158 @@ +/** + * In-memory session store for diagram chat sessions. + * Stores conversation history and entity snapshots. + */ + +import { v4 as uuidv4 } from 'uuid'; + +// Session structure: +// { +// id: string, +// diagramId: string, +// conversationHistory: Array<{role, content, toolResults?, timestamp}>, +// entities: Array<{id, template, text, color, position}>, +// createdAt: Date, +// lastAccess: Date +// } + +const sessions = new Map(); + +// Session timeout (1 hour of inactivity) +const SESSION_TIMEOUT_MS = 60 * 60 * 1000; + +/** + * Create a new session for a diagram + */ +export function createSession(diagramId) { + const id = uuidv4(); + const session = { + id, + diagramId, + conversationHistory: [], + entities: [], + createdAt: new Date(), + lastAccess: new Date() + }; + sessions.set(id, session); + return session; +} + +/** + * Get a session by ID + */ +export function getSession(sessionId) { + const session = sessions.get(sessionId); + if (session) { + session.lastAccess = new Date(); + } + return session || null; +} + +/** + * Find existing session for a diagram + */ +export function findSessionByDiagram(diagramId) { + for (const [, session] of sessions) { + if (session.diagramId === diagramId) { + session.lastAccess = new Date(); + return session; + } + } + return null; +} + +/** + * Update entities snapshot for a session + */ +export function syncEntities(sessionId, entities) { + const session = sessions.get(sessionId); + if (!session) return null; + + session.entities = entities; + session.lastAccess = new Date(); + return session; +} + +/** + * Add a message to conversation history + */ +export function addMessage(sessionId, message) { + const session = sessions.get(sessionId); + if (!session) return null; + + session.conversationHistory.push({ + ...message, + timestamp: new Date() + }); + session.lastAccess = new Date(); + return session; +} + +/** + * Get conversation history for API calls (formatted for Claude) + */ +export function getConversationForAPI(sessionId) { + const session = sessions.get(sessionId); + if (!session) return []; + + // Convert to Claude message format + return session.conversationHistory.map(msg => ({ + role: msg.role, + content: msg.content + })); +} + +/** + * Clear conversation history but keep session + */ +export function clearHistory(sessionId) { + const session = sessions.get(sessionId); + if (!session) return null; + + session.conversationHistory = []; + session.lastAccess = new Date(); + return session; +} + +/** + * Delete a session + */ +export function deleteSession(sessionId) { + return sessions.delete(sessionId); +} + +/** + * Clean up expired sessions + */ +export function cleanupExpiredSessions() { + const now = Date.now(); + for (const [id, session] of sessions) { + if (now - session.lastAccess.getTime() > SESSION_TIMEOUT_MS) { + sessions.delete(id); + } + } +} + +// Run cleanup every 15 minutes +setInterval(cleanupExpiredSessions, 15 * 60 * 1000); + +/** + * Get session stats (for debugging) + */ +export function getStats(includeDetails = false) { + return { + activeSessions: sessions.size, + sessions: Array.from(sessions.values()).map(s => ({ + id: s.id, + diagramId: s.diagramId, + messageCount: s.conversationHistory.length, + entityCount: s.entities.length, + lastAccess: s.lastAccess, + // Include full details if requested + ...(includeDetails && { + entities: s.entities, + conversationHistory: s.conversationHistory + }) + })) + }; +} diff --git a/src/diagram/diagramManager.ts b/src/diagram/diagramManager.ts index a2e10ea..a2f5f75 100644 --- a/src/diagram/diagramManager.ts +++ b/src/diagram/diagramManager.ts @@ -13,6 +13,7 @@ import {UserModelType} from "../users/userTypes"; import {vectoxys} from "./functions/vectorConversion"; import {controllerObservable} from "../controllers/controllers"; import {ControllerEvent} from "../controllers/types/controllerEvent"; +import {HEX_TO_COLOR_NAME, TEMPLATE_TO_SHAPE} from "../react/types/chatTypes"; export class DiagramManager { @@ -107,6 +108,13 @@ export class DiagramManager { document.addEventListener('chatCreateEntity', (event: CustomEvent) => { const {entity} = event.detail; this._logger.debug('chatCreateEntity', entity); + + // Generate a default label if none is provided + if (!entity.text) { + entity.text = this.generateDefaultLabel(entity); + this._logger.debug('Generated default label:', entity.text); + } + const object = new DiagramObject(this._scene, this.onDiagramEventObservable, { diagramEntity: entity, actionManager: this._diagramEntityActionManager @@ -161,6 +169,7 @@ export class DiagramManager { id: obj.diagramEntity.id, template: obj.diagramEntity.template, text: obj.diagramEntity.text || '', + color: obj.diagramEntity.color, position: obj.diagramEntity.position })); const responseEvent = new CustomEvent('chatListEntitiesResponse', { @@ -219,6 +228,42 @@ export class DiagramManager { return null; } + /** + * Generates a default label for an entity based on its color and shape. + * Format: "{color} {shape} {number}" e.g., "blue box 1", "red sphere 2" + * The number is determined by counting existing entities with the same prefix. + */ + private generateDefaultLabel(entity: DiagramEntity): string { + // Get color name from hex + const colorHex = entity.color?.toLowerCase() || '#0000ff'; + const colorName = HEX_TO_COLOR_NAME[colorHex] || 'blue'; + + // Get shape name from template + const shapeName = TEMPLATE_TO_SHAPE[entity.template] || 'box'; + + // Create the prefix (e.g., "blue box") + const prefix = `${colorName} ${shapeName}`; + + // Count existing entities with labels starting with this prefix + let maxNumber = 0; + for (const [, obj] of this._diagramObjects) { + const label = obj.diagramEntity.text?.toLowerCase() || ''; + if (label.startsWith(prefix)) { + // Extract the number from the end of the label + const match = label.match(new RegExp(`^${prefix}\\s*(\\d+)$`)); + if (match) { + const num = parseInt(match[1], 10); + if (num > maxNumber) { + maxNumber = num; + } + } + } + } + + // Return the next number in sequence + return `${prefix} ${maxNumber + 1}`; + } + private onDiagramEvent(event: DiagramEvent) { let diagramObject = this._diagramObjects.get(event?.entity?.id); switch (event.type) { diff --git a/src/react/components/ChatPanel.tsx b/src/react/components/ChatPanel.tsx index ed8d401..a10fd30 100644 --- a/src/react/components/ChatPanel.tsx +++ b/src/react/components/ChatPanel.tsx @@ -2,9 +2,19 @@ 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, ToolResult} from "../types/chatTypes"; -import {createAssistantMessage, createLoadingMessage, createUserMessage, sendMessage} from "../services/diagramAI"; +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'); @@ -13,16 +23,79 @@ interface ChatPanelProps { 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([ - createAssistantMessage("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?") - ]); + const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [isInitializing, setIsInitializing] = useState(true); const [error, setError] = useState(null); const scrollAreaRef = useRef(null); const textareaRef = useRef(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({ @@ -46,6 +119,13 @@ export default function ChatPanel({width = 400, onClose}: ChatPanelProps) { 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( @@ -141,22 +221,22 @@ export default function ChatPanel({width = 400, onClose}: ChatPanelProps) {