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>
This commit is contained in:
Michael Mainguy 2025-12-20 13:42:01 -06:00
parent 1152ab0d0c
commit 7769910027
9 changed files with 694 additions and 19 deletions

View File

@ -1,8 +1,26 @@
import { Router } from "express"; import { Router } from "express";
import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js";
const router = Router(); const router = Router();
const ANTHROPIC_API_URL = "https://api.anthropic.com"; 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 // Express 5 uses named parameters for wildcards
router.post("/*path", async (req, res) => { router.post("/*path", async (req, res) => {
const apiKey = process.env.ANTHROPIC_API_KEY; const apiKey = process.env.ANTHROPIC_API_KEY;
@ -16,6 +34,36 @@ router.post("/*path", async (req, res) => {
const pathParam = req.params.path; const pathParam = req.params.path;
const path = "/" + (Array.isArray(pathParam) ? pathParam.join("/") : pathParam || ""); 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 { try {
const response = await fetch(`${ANTHROPIC_API_URL}${path}`, { const response = await fetch(`${ANTHROPIC_API_URL}${path}`, {
method: "POST", method: "POST",
@ -24,10 +72,39 @@ router.post("/*path", async (req, res) => {
"x-api-key": apiKey, "x-api-key": apiKey,
"anthropic-version": "2023-06-01", "anthropic-version": "2023-06-01",
}, },
body: JSON.stringify(req.body), body: JSON.stringify(modifiedBody),
}); });
const data = await response.json(); 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); res.status(response.status).json(data);
} catch (error) { } catch (error) {
console.error("Claude API error:", error.message); console.error("Claude API error:", error.message);

View File

@ -1,8 +1,12 @@
import { Router } from "express"; import { Router } from "express";
import claudeRouter from "./claude.js"; import claudeRouter from "./claude.js";
import sessionRouter from "./session.js";
const router = Router(); const router = Router();
// Session management
router.use("/session", sessionRouter);
// Claude API proxy // Claude API proxy
router.use("/claude", claudeRouter); router.use("/claude", claudeRouter);

144
server/api/session.js Normal file
View File

@ -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;

View File

@ -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
})
}))
};
}

View File

@ -13,6 +13,7 @@ import {UserModelType} from "../users/userTypes";
import {vectoxys} from "./functions/vectorConversion"; import {vectoxys} from "./functions/vectorConversion";
import {controllerObservable} from "../controllers/controllers"; import {controllerObservable} from "../controllers/controllers";
import {ControllerEvent} from "../controllers/types/controllerEvent"; import {ControllerEvent} from "../controllers/types/controllerEvent";
import {HEX_TO_COLOR_NAME, TEMPLATE_TO_SHAPE} from "../react/types/chatTypes";
export class DiagramManager { export class DiagramManager {
@ -107,6 +108,13 @@ export class DiagramManager {
document.addEventListener('chatCreateEntity', (event: CustomEvent) => { document.addEventListener('chatCreateEntity', (event: CustomEvent) => {
const {entity} = event.detail; const {entity} = event.detail;
this._logger.debug('chatCreateEntity', entity); 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, { const object = new DiagramObject(this._scene, this.onDiagramEventObservable, {
diagramEntity: entity, diagramEntity: entity,
actionManager: this._diagramEntityActionManager actionManager: this._diagramEntityActionManager
@ -161,6 +169,7 @@ export class DiagramManager {
id: obj.diagramEntity.id, id: obj.diagramEntity.id,
template: obj.diagramEntity.template, template: obj.diagramEntity.template,
text: obj.diagramEntity.text || '', text: obj.diagramEntity.text || '',
color: obj.diagramEntity.color,
position: obj.diagramEntity.position position: obj.diagramEntity.position
})); }));
const responseEvent = new CustomEvent('chatListEntitiesResponse', { const responseEvent = new CustomEvent('chatListEntitiesResponse', {
@ -219,6 +228,42 @@ export class DiagramManager {
return null; 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) { private onDiagramEvent(event: DiagramEvent) {
let diagramObject = this._diagramObjects.get(event?.entity?.id); let diagramObject = this._diagramObjects.get(event?.entity?.id);
switch (event.type) { switch (event.type) {

View File

@ -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 {ActionIcon, Alert, Box, CloseButton, Group, Paper, ScrollArea, Text, Textarea} from "@mantine/core";
import {IconAlertCircle, IconRobot, IconSend} from "@tabler/icons-react"; import {IconAlertCircle, IconRobot, IconSend} from "@tabler/icons-react";
import ChatMessage from "./ChatMessage"; import ChatMessage from "./ChatMessage";
import {ChatMessage as ChatMessageType, ToolResult} from "../types/chatTypes"; import {ChatMessage as ChatMessageType, SessionMessage, ToolResult} from "../types/chatTypes";
import {createAssistantMessage, createLoadingMessage, createUserMessage, sendMessage} from "../services/diagramAI"; 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 log from "loglevel";
import {v4 as uuidv4} from 'uuid';
const logger = log.getLogger('ChatPanel'); const logger = log.getLogger('ChatPanel');
@ -13,16 +23,79 @@ interface ChatPanelProps {
onClose?: () => void; 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) { export default function ChatPanel({width = 400, onClose}: ChatPanelProps) {
const [messages, setMessages] = useState<ChatMessageType[]>([ const [messages, setMessages] = useState<ChatMessageType[]>([]);
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 [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isInitializing, setIsInitializing] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null); const scrollAreaRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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(() => { useEffect(() => {
if (scrollAreaRef.current) { if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTo({ scrollAreaRef.current.scrollTo({
@ -46,6 +119,13 @@ export default function ChatPanel({width = 400, onClose}: ChatPanelProps) {
setMessages(prev => [...prev, loadingMessage]); setMessages(prev => [...prev, loadingMessage]);
try { 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 allToolResults: ToolResult[] = [];
const {response, toolResults} = await sendMessage( const {response, toolResults} = await sendMessage(
@ -141,22 +221,22 @@ export default function ChatPanel({width = 400, onClose}: ChatPanelProps) {
<Group gap="xs" align="flex-end"> <Group gap="xs" align="flex-end">
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
placeholder="Describe what you want to create..." placeholder={isInitializing ? "Connecting..." : "Describe what you want to create..."}
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.currentTarget.value)} onChange={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
autosize autosize
minRows={1} minRows={1}
maxRows={4} maxRows={4}
disabled={isLoading} disabled={isLoading || isInitializing}
style={{flex: 1}} style={{flex: 1}}
/> />
<ActionIcon <ActionIcon
size="lg" size="lg"
variant="filled" variant="filled"
onClick={handleSend} onClick={handleSend}
disabled={!inputValue.trim() || isLoading} disabled={!inputValue.trim() || isLoading || isInitializing}
loading={isLoading} loading={isLoading || isInitializing}
> >
<IconSend size={18}/> <IconSend size={18}/>
</ActionIcon> </ActionIcon>

View File

@ -1,7 +1,86 @@
import {ChatMessage, DiagramToolCall, ToolResult} from "../types/chatTypes"; import {ChatMessage, CreateSessionResponse, DiagramSession, DiagramToolCall, SessionEntity, SyncEntitiesResponse, ToolResult} from "../types/chatTypes";
import {connectEntities, createEntity, listEntities, modifyEntity, removeEntity} from "./entityBridge"; import {connectEntities, createEntity, listEntities, modifyEntity, removeEntity} from "./entityBridge";
import {v4 as uuidv4} from 'uuid'; import {v4 as uuidv4} from 'uuid';
// Session management
let currentSessionId: string | null = null;
/**
* Create a new session or resume existing one for a diagram
*/
export async function createOrResumeSession(diagramId: string): Promise<{ session: DiagramSession; isNew: boolean }> {
const response = await fetch('/api/session/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ diagramId })
});
if (!response.ok) {
throw new Error(`Failed to create session: ${response.status}`);
}
const data: CreateSessionResponse = await response.json();
currentSessionId = data.session.id;
return data;
}
/**
* Get current session ID
*/
export function getCurrentSessionId(): string | null {
return currentSessionId;
}
/**
* Sync entities to the current session
*/
export async function syncEntitiesToSession(entities: SessionEntity[]): Promise<void> {
if (!currentSessionId) {
console.warn('No active session to sync entities to');
return;
}
const response = await fetch(`/api/session/${currentSessionId}/sync`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entities })
});
if (!response.ok) {
console.error('Failed to sync entities:', response.status);
}
}
/**
* Get session with conversation history
*/
export async function getSessionHistory(): Promise<DiagramSession | null> {
if (!currentSessionId) {
return null;
}
const response = await fetch(`/api/session/${currentSessionId}`);
if (!response.ok) {
return null;
}
const data = await response.json();
return data.session;
}
/**
* Clear session history
*/
export async function clearSessionHistory(): Promise<void> {
if (!currentSessionId) {
return;
}
await fetch(`/api/session/${currentSessionId}/history`, {
method: 'DELETE'
});
}
const SYSTEM_PROMPT = `You are a 3D diagram assistant helping users create and modify diagrams in a virtual reality environment. const SYSTEM_PROMPT = `You are a 3D diagram assistant helping users create and modify diagrams in a virtual reality environment.
Available entity shapes: box, sphere, cylinder, cone, plane, person Available entity shapes: box, sphere, cylinder, cone, plane, person
@ -174,14 +253,20 @@ export async function sendMessage(
conversationHistory: ChatMessage[], conversationHistory: ChatMessage[],
onToolResult?: (result: ToolResult) => void onToolResult?: (result: ToolResult) => void
): Promise<{ response: string; toolResults: ToolResult[] }> { ): Promise<{ response: string; toolResults: ToolResult[] }> {
const messages: ClaudeMessage[] = conversationHistory // When using sessions, we don't need to send full history - server manages it
// Just send the new message
const messages: ClaudeMessage[] = currentSessionId
? [{ role: 'user', content: userMessage }]
: conversationHistory
.filter(m => !m.isLoading) .filter(m => !m.isLoading)
.map(m => ({ .map(m => ({
role: m.role, role: m.role,
content: m.content content: m.content
})); }));
if (!currentSessionId) {
messages.push({role: 'user', content: userMessage}); messages.push({role: 'user', content: userMessage});
}
const allToolResults: ToolResult[] = []; const allToolResults: ToolResult[] = [];
let finalResponse = ''; let finalResponse = '';
@ -198,7 +283,9 @@ export async function sendMessage(
max_tokens: 1024, max_tokens: 1024,
system: SYSTEM_PROMPT, system: SYSTEM_PROMPT,
tools: TOOLS, tools: TOOLS,
messages messages,
// Include sessionId if we have an active session
...(currentSessionId && { sessionId: currentSessionId })
}) })
}); });

View File

@ -167,3 +167,31 @@ export function listEntities(): Promise<ToolResult> {
}, 5000); }, 5000);
}); });
} }
/**
* Get all entities for session syncing (returns raw entity data)
*/
export function getEntitiesForSync(): Promise<Array<{
id: string;
template: string;
text?: string;
color?: string;
position?: { x: number; y: number; z: number };
}>> {
return new Promise((resolve) => {
const responseHandler = (e: CustomEvent) => {
document.removeEventListener('chatListEntitiesResponse', responseHandler as EventListener);
resolve(e.detail.entities || []);
};
document.addEventListener('chatListEntitiesResponse', responseHandler as EventListener);
const event = new CustomEvent('chatListEntities', {bubbles: true});
document.dispatchEvent(event);
setTimeout(() => {
document.removeEventListener('chatListEntitiesResponse', responseHandler as EventListener);
resolve([]);
}, 5000);
});
}

View File

@ -73,3 +73,55 @@ export const COLOR_NAME_TO_HEX: Record<string, string> = {
gray: '#778899', gray: '#778899',
grey: '#778899', grey: '#778899',
}; };
// Reverse mapping from hex to color name
export const HEX_TO_COLOR_NAME: Record<string, string> = Object.entries(COLOR_NAME_TO_HEX)
.reduce((acc, [name, hex]) => {
acc[hex.toLowerCase()] = name;
return acc;
}, {} as Record<string, string>);
// Template to shape name mapping
export const TEMPLATE_TO_SHAPE: Record<string, string> = {
'#box-template': 'box',
'#sphere-template': 'sphere',
'#cylinder-template': 'cylinder',
'#cone-template': 'cone',
'#plane-template': 'plane',
'#person-template': 'person',
};
// Session types
export interface SessionEntity {
id: string;
template: string;
text?: string;
color?: string;
position?: { x: number; y: number; z: number };
}
export interface SessionMessage {
role: ChatRole;
content: string;
toolResults?: ToolResult[];
timestamp: Date;
}
export interface DiagramSession {
id: string;
diagramId: string;
conversationHistory: SessionMessage[];
entities: SessionEntity[];
createdAt: Date;
lastAccess: Date;
}
export interface CreateSessionResponse {
session: DiagramSession;
isNew: boolean;
}
export interface SyncEntitiesResponse {
success: boolean;
entityCount: number;
}