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:
parent
1152ab0d0c
commit
7769910027
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
144
server/api/session.js
Normal file
144
server/api/session.js
Normal 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;
|
||||
158
server/services/sessionStore.js
Normal file
158
server/services/sessionStore.js
Normal 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
|
||||
})
|
||||
}))
|
||||
};
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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<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 [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({
|
||||
@ -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) {
|
||||
<Group gap="xs" align="flex-end">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
placeholder="Describe what you want to create..."
|
||||
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}
|
||||
disabled={isLoading || isInitializing}
|
||||
style={{flex: 1}}
|
||||
/>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="filled"
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() || isLoading}
|
||||
loading={isLoading}
|
||||
disabled={!inputValue.trim() || isLoading || isInitializing}
|
||||
loading={isLoading || isInitializing}
|
||||
>
|
||||
<IconSend size={18}/>
|
||||
</ActionIcon>
|
||||
|
||||
@ -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 {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.
|
||||
|
||||
Available entity shapes: box, sphere, cylinder, cone, plane, person
|
||||
@ -174,14 +253,20 @@ export async function sendMessage(
|
||||
conversationHistory: ChatMessage[],
|
||||
onToolResult?: (result: ToolResult) => void
|
||||
): Promise<{ response: string; toolResults: ToolResult[] }> {
|
||||
const messages: ClaudeMessage[] = conversationHistory
|
||||
.filter(m => !m.isLoading)
|
||||
.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}));
|
||||
// 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)
|
||||
.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}));
|
||||
|
||||
messages.push({role: 'user', content: userMessage});
|
||||
if (!currentSessionId) {
|
||||
messages.push({role: 'user', content: userMessage});
|
||||
}
|
||||
|
||||
const allToolResults: ToolResult[] = [];
|
||||
let finalResponse = '';
|
||||
@ -198,7 +283,9 @@ export async function sendMessage(
|
||||
max_tokens: 1024,
|
||||
system: SYSTEM_PROMPT,
|
||||
tools: TOOLS,
|
||||
messages
|
||||
messages,
|
||||
// Include sessionId if we have an active session
|
||||
...(currentSessionId && { sessionId: currentSessionId })
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@ -167,3 +167,31 @@ export function listEntities(): Promise<ToolResult> {
|
||||
}, 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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -73,3 +73,55 @@ export const COLOR_NAME_TO_HEX: Record<string, string> = {
|
||||
gray: '#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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user