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 { 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);
|
||||||
|
|||||||
@ -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
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 {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) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 })
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user