immersive2/src/react/services/entityBridge.ts
Michael Mainguy c9dc61b918 Add camera position tool and fix entity modification bugs
- Add get_camera_position tool for positioning entities relative to user view
- Fix color change causing entities to disappear (dispose mesh before rebuild)
- Fix connections being lost when modifying entities (defer disposal, let
  scene observer re-find meshes after they're recreated with same ID)
- Add position and color setters to DiagramObject for real-time updates
- Add debug logging to diagramAI and claude.js for troubleshooting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 15:59:30 -06:00

355 lines
12 KiB
TypeScript

import {DiagramEntity, DiagramEntityType, DiagramTemplates} from "../../diagram/types/diagramEntity";
import {
ClearDiagramParams,
COLOR_NAME_TO_HEX,
ConnectEntitiesParams,
CreateEntityParams,
ModifyEntityParams,
RemoveEntityParams,
SHAPE_TO_TEMPLATE,
ToolResult
} from "../types/chatTypes";
import {v4 as uuidv4} from 'uuid';
import log from 'loglevel';
const logger = log.getLogger('entityBridge');
logger.setLevel('debug');
function resolveColor(color?: string): string {
if (!color) return '#0000ff';
const lower = color.toLowerCase();
if (COLOR_NAME_TO_HEX[lower]) {
return COLOR_NAME_TO_HEX[lower];
}
if (color.startsWith('#')) {
return color;
}
return '#0000ff';
}
interface ResolvedEntity {
id: string | null;
label: string | null;
}
/**
* Resolve an entity label or ID to actual entity ID and label
*/
function resolveEntity(target: string): Promise<ResolvedEntity> {
logger.debug('[resolveEntity] Resolving:', target);
return new Promise((resolve) => {
const requestId = 'req-' + Date.now() + '-' + Math.random();
const responseHandler = (e: CustomEvent) => {
if (e.detail.requestId !== requestId) return;
logger.debug('[resolveEntity] Got response:', e.detail);
document.removeEventListener('chatResolveEntityResponse', responseHandler as EventListener);
resolve({
id: e.detail.entityId,
label: e.detail.entityLabel
});
};
document.addEventListener('chatResolveEntityResponse', responseHandler as EventListener);
const event = new CustomEvent('chatResolveEntity', {
detail: {target, requestId},
bubbles: true
});
document.dispatchEvent(event);
setTimeout(() => {
logger.warn('[resolveEntity] Timeout resolving:', target);
document.removeEventListener('chatResolveEntityResponse', responseHandler as EventListener);
resolve({id: null, label: null});
}, 5000);
});
}
export function createEntity(params: CreateEntityParams): ToolResult {
logger.debug('[createEntity] Creating entity:', params);
const id = 'id' + uuidv4();
const template = SHAPE_TO_TEMPLATE[params.shape];
const color = resolveColor(params.color);
const position = params.position || {x: 0, y: 1.5, z: 2};
const entity: DiagramEntity = {
id,
template,
type: DiagramEntityType.ENTITY,
color,
text: params.label || '',
position,
rotation: {x: 0, y: Math.PI, z: 0},
scale: {x: 0.1, y: 0.1, z: 0.1},
};
logger.debug('[createEntity] Dispatching chatCreateEntity event:', entity);
const event = new CustomEvent('chatCreateEntity', {
detail: {entity},
bubbles: true
});
document.dispatchEvent(event);
const result = {
toolName: 'create_entity',
success: true,
message: `Created ${params.shape}${params.label ? ` labeled "${params.label}"` : ''} at position (${position.x.toFixed(1)}, ${position.y.toFixed(1)}, ${position.z.toFixed(1)})`,
entityId: id
};
logger.debug('[createEntity] Done:', result);
return result;
}
export async function connectEntities(params: ConnectEntitiesParams): Promise<ToolResult> {
logger.debug('[connectEntities] Connecting:', params);
// Resolve labels to actual entity IDs and get their labels
const fromEntity = await resolveEntity(params.from);
const toEntity = await resolveEntity(params.to);
logger.debug('[connectEntities] Resolved from:', fromEntity, 'to:', toEntity);
if (!fromEntity.id) {
return {
toolName: 'connect_entities',
success: false,
message: `Could not find entity "${params.from}"`
};
}
if (!toEntity.id) {
return {
toolName: 'connect_entities',
success: false,
message: `Could not find entity "${params.to}"`
};
}
const id = 'id' + uuidv4();
const color = resolveColor(params.color);
// Generate default label from entity labels: "{from label} to {to label}"
const fromLabel = fromEntity.label || params.from;
const toLabel = toEntity.label || params.to;
const connectionLabel = `${fromLabel} to ${toLabel}`;
const entity: DiagramEntity = {
id,
template: DiagramTemplates.CONNECTION,
type: DiagramEntityType.ENTITY,
color,
text: connectionLabel,
from: fromEntity.id,
to: toEntity.id,
};
const event = new CustomEvent('chatCreateEntity', {
detail: {entity},
bubbles: true
});
document.dispatchEvent(event);
return {
toolName: 'connect_entities',
success: true,
message: `Connected "${fromLabel}" to "${toLabel}"`,
entityId: id
};
}
export function removeEntity(params: RemoveEntityParams): ToolResult {
const event = new CustomEvent('chatRemoveEntity', {
detail: {target: params.target},
bubbles: true
});
document.dispatchEvent(event);
return {
toolName: 'remove_entity',
success: true,
message: `Removed entity "${params.target}"`
};
}
export function modifyEntity(params: ModifyEntityParams): ToolResult {
const updates: Partial<DiagramEntity> = {};
if (params.color) {
updates.color = resolveColor(params.color);
}
if (params.label !== undefined) {
updates.text = params.label;
}
if (params.position) {
updates.position = params.position;
}
const event = new CustomEvent('chatModifyEntity', {
detail: {target: params.target, updates},
bubbles: true
});
document.dispatchEvent(event);
return {
toolName: 'modify_entity',
success: true,
message: `Modified entity "${params.target}"`
};
}
export function listEntities(): Promise<ToolResult> {
logger.debug('[listEntities] Listing entities...');
return new Promise((resolve) => {
const responseHandler = (e: CustomEvent) => {
document.removeEventListener('chatListEntitiesResponse', responseHandler as EventListener);
const entities = e.detail.entities as Array<{
id: string;
template: string;
text: string;
position: { x: number; y: number; z: number }
}>;
logger.debug('[listEntities] Got response, entities:', entities.length);
if (entities.length === 0) {
resolve({
toolName: 'list_entities',
success: true,
message: 'The diagram is empty.'
});
return;
}
const list = entities.map(e => {
const shape = e.template.replace('#', '').replace('-template', '');
return `- ${e.text || '(no label)'} (${shape}) at (${e.position?.x?.toFixed(1) || 0}, ${e.position?.y?.toFixed(1) || 0}, ${e.position?.z?.toFixed(1) || 0}) [id: ${e.id}]`;
}).join('\n');
resolve({
toolName: 'list_entities',
success: true,
message: `Current entities in the diagram:\n${list}`
});
};
document.addEventListener('chatListEntitiesResponse', responseHandler as EventListener);
const event = new CustomEvent('chatListEntities', {bubbles: true});
document.dispatchEvent(event);
setTimeout(() => {
logger.warn('[listEntities] Timeout waiting for response');
document.removeEventListener('chatListEntitiesResponse', responseHandler as EventListener);
resolve({
toolName: 'list_entities',
success: false,
message: 'Failed to list entities (timeout)'
});
}, 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);
});
}
/**
* Clear all entities from the diagram
*/
export async function clearDiagram(params: ClearDiagramParams): Promise<ToolResult> {
if (!params.confirmed) {
return {
toolName: 'clear_diagram',
success: false,
message: 'Clearing the diagram requires confirmation. Please ask the user to confirm before proceeding.'
};
}
// Get all entities first
const entities = await getEntitiesForSync();
if (entities.length === 0) {
return {
toolName: 'clear_diagram',
success: true,
message: 'The diagram is already empty.'
};
}
// Dispatch clear event to remove all entities at once
const event = new CustomEvent('chatClearDiagram', {
bubbles: true
});
document.dispatchEvent(event);
return {
toolName: 'clear_diagram',
success: true,
message: `Cleared ${entities.length} entities from the diagram.`
};
}
/**
* Get the current camera position and orientation
*/
export function getCameraPosition(): Promise<ToolResult> {
logger.debug('[getCameraPosition] Getting camera position...');
return new Promise((resolve) => {
const responseHandler = (e: CustomEvent) => {
document.removeEventListener('chatGetCameraResponse', responseHandler as EventListener);
const {position, forward, up} = e.detail;
logger.debug('[getCameraPosition] Got response:', e.detail);
const message = `Camera position: (${position.x.toFixed(2)}, ${position.y.toFixed(2)}, ${position.z.toFixed(2)})
Forward direction: (${forward.x.toFixed(2)}, ${forward.y.toFixed(2)}, ${forward.z.toFixed(2)})
Up direction: (${up.x.toFixed(2)}, ${up.y.toFixed(2)}, ${up.z.toFixed(2)})
To place an entity in front of the camera, add the forward direction scaled by desired distance to the camera position.
Example: For an entity 2 units in front: position = (${(position.x + forward.x * 2).toFixed(2)}, ${(position.y + forward.y * 2).toFixed(2)}, ${(position.z + forward.z * 2).toFixed(2)})`;
resolve({
toolName: 'get_camera_position',
success: true,
message
});
};
document.addEventListener('chatGetCameraResponse', responseHandler as EventListener);
const event = new CustomEvent('chatGetCamera', {bubbles: true});
document.dispatchEvent(event);
setTimeout(() => {
logger.warn('[getCameraPosition] Timeout waiting for response');
document.removeEventListener('chatGetCameraResponse', responseHandler as EventListener);
resolve({
toolName: 'get_camera_position',
success: false,
message: 'Failed to get camera position (timeout)'
});
}, 5000);
});
}