immersive2/src/diagram/diagramManager.ts
Michael Mainguy eef2dcd5a5 Add context window tracking and usage display
- Track context window usage per AI model in usage tracker
- Add context limits for Claude (200K) and Cloudflare models
- Display context percentage badge in ChatPanel header
- Add context warnings at 80% and 95% usage levels
- Fix entity label generation to allow explicit empty labels
- Auto-refocus input after message completion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:46:56 -06:00

457 lines
19 KiB
TypeScript

import {AbstractActionManager, AbstractMesh, ActionManager, Observable, Scene, Vector3, WebXRDefaultExperience} from "@babylonjs/core";
import {DiagramEntity, DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
import log from "loglevel";
import {appConfigInstance} from "../util/appConfig";
import {buildEntityActionManager} from "./functions/buildEntityActionManager";
import {DefaultScene} from "../defaultScene";
import {DiagramMenuManager} from "./diagramMenuManager";
import {DiagramEventObserverMask} from "./types/diagramEventObserverMask";
import {DiagramObject} from "./diagramObject";
import {getMe} from "../util/me";
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 {
private readonly _logger = log.getLogger('DiagramManager');
private readonly _controllerObservable: Observable<ControllerEvent>;
private readonly _diagramEntityActionManager: ActionManager;
public readonly onDiagramEventObservable: Observable<DiagramEvent> = new Observable();
public readonly onUserEventObservable: Observable<UserModelType> = new Observable();
private readonly _diagramMenuManager: DiagramMenuManager;
private readonly _scene: Scene;
private readonly _diagramObjects: Map<string, DiagramObject> = new Map<string, DiagramObject>();
private readonly _me: string;
private _moving: number = 10;
private _i: number = 0;
public get diagramMenuManager(): DiagramMenuManager {
return this._diagramMenuManager;
}
public setXR(xr: WebXRDefaultExperience): void {
this._diagramMenuManager.setXR(xr);
}
constructor(readyObservable: Observable<boolean>) {
this._me = getMe();
this._scene = DefaultScene.Scene;
this._diagramMenuManager = new DiagramMenuManager(this.onDiagramEventObservable, controllerObservable, readyObservable);
this._diagramEntityActionManager = buildEntityActionManager(controllerObservable);
this.onDiagramEventObservable.add(this.onDiagramEvent, DiagramEventObserverMask.FROM_DB, true, this);
this.onUserEventObservable.add((user) => {
if (user.id != this._me) {
this._logger.debug('user event', user);
}
});
window.setInterval(() => {
this._i++;
const platform = this._scene.getMeshByName('platform');
if (!platform || !platform.physicsBody) {
return;
}
if (platform.physicsBody) {
if ((this._i % this._moving) == 0) {
this.onUserEventObservable.notifyObservers(
{
id: this._me,
name: 'me',
type: 'user',
base: {
position: vectoxys(platform.absolutePosition),
rotation: vectoxys(platform.absoluteRotationQuaternion.toEulerAngles()),
velocity: vectoxys(platform.physicsBody.getLinearVelocity())
},
}
);
}
if (platform.physicsBody.getLinearVelocity().length() > 0.01) {
this._moving = 1;
} else {
this._moving = 10;
}
}
}, 100);
document.addEventListener('uploadImage', (event: CustomEvent) => {
const diagramEntity: DiagramEntity = {
template: '#image-template',
image: event.detail.data,
text: event.detail.name,
type: DiagramEntityType.ENTITY,
position: {x: 0, y: 1.6, z: 0},
rotation: {x: 0, y: Math.PI, z: 0},
scale: {x: 1, y: 1, z: 1},
}
const object = new DiagramObject(this._scene, this.onDiagramEventObservable, {diagramEntity: diagramEntity});
this._diagramObjects.set(diagramEntity.id, object);
//const newMesh = buildMeshFromDiagramEntity(diagramEntity, this._scene);
if (this.onDiagramEventObservable) {
this.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.ADD,
entity: diagramEntity
}, DiagramEventObserverMask.TO_DB);
}
});
// Chat event listeners for AI-powered diagram creation
document.addEventListener('chatCreateEntity', (event: CustomEvent) => {
const {entity} = event.detail;
this._logger.debug('chatCreateEntity', entity);
// Generate a default label if none is provided
// Use strict check to allow empty string "" (explicit no label) while still
// generating labels for undefined/null (user didn't specify)
if (entity.text === undefined || entity.text === null) {
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
});
this._diagramObjects.set(entity.id, object);
this.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.ADD,
entity: entity
}, DiagramEventObserverMask.TO_DB);
});
document.addEventListener('chatRemoveEntity', (event: CustomEvent) => {
const {target} = event.detail;
this._logger.debug('chatRemoveEntity', target);
const entity = this.findEntityByIdOrLabel(target);
if (entity) {
const diagramObject = this._diagramObjects.get(entity.id);
if (diagramObject) {
diagramObject.dispose();
this._diagramObjects.delete(entity.id);
this.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.REMOVE,
entity: entity
}, DiagramEventObserverMask.TO_DB);
}
}
});
document.addEventListener('chatModifyEntity', (event: CustomEvent) => {
const {target, updates} = event.detail;
this._logger.debug('chatModifyEntity', target, updates);
const entity = this.findEntityByIdOrLabel(target);
if (entity) {
const diagramObject = this._diagramObjects.get(entity.id);
if (diagramObject) {
// Apply updates using setters (each setter handles its own DB notification)
if (updates.text !== undefined) {
diagramObject.text = updates.text;
}
if (updates.color !== undefined) {
diagramObject.color = updates.color;
}
if (updates.template !== undefined) {
diagramObject.template = updates.template;
}
if (updates.position !== undefined) {
diagramObject.position = updates.position;
}
if (updates.scale !== undefined) {
diagramObject.scale = updates.scale;
}
if (updates.rotation !== undefined) {
diagramObject.rotation = updates.rotation;
}
}
} else {
this._logger.warn('chatModifyEntity: entity not found:', target);
}
});
document.addEventListener('chatModifyConnection', (event: CustomEvent) => {
const {target, updates} = event.detail;
this._logger.debug('chatModifyConnection', target, updates);
let connection: DiagramEntity | undefined;
// Check if target is a connection:fromId:toId format
if (target.startsWith('connection:')) {
const parts = target.split(':');
if (parts.length === 3) {
const fromId = parts[1];
const toId = parts[2];
// Find connection by from/to
connection = Array.from(this._diagramObjects.values())
.map(obj => obj.diagramEntity)
.find(e => e.template === '#connection-template' && e.from === fromId && e.to === toId);
}
} else {
// Find by label (text)
connection = this.findEntityByIdOrLabel(target);
// Verify it's a connection
if (connection && connection.template !== '#connection-template') {
this._logger.warn('chatModifyConnection: found entity is not a connection:', target);
connection = undefined;
}
}
if (connection) {
const diagramObject = this._diagramObjects.get(connection.id);
if (diagramObject) {
if (updates.text !== undefined) {
diagramObject.text = updates.text;
}
if (updates.color !== undefined) {
diagramObject.color = updates.color;
}
}
} else {
this._logger.warn('chatModifyConnection: connection not found:', target);
}
});
document.addEventListener('chatListEntities', (event: CustomEvent) => {
const requestId = event.detail?.requestId;
this._logger.debug('chatListEntities', requestId ? `(request: ${requestId})` : '');
const entities = Array.from(this._diagramObjects.values()).map(obj => ({
id: obj.diagramEntity.id,
label: obj.diagramEntity.text || '',
template: obj.diagramEntity.template,
text: obj.diagramEntity.text || '',
color: obj.diagramEntity.color,
position: obj.diagramEntity.position,
// Include from/to for connections
from: obj.diagramEntity.from,
to: obj.diagramEntity.to
}));
const responseEvent = new CustomEvent('chatListEntitiesResponse', {
detail: { entities, requestId },
bubbles: true
});
document.dispatchEvent(responseEvent);
});
// Resolve entity label/ID to actual entity ID and label
document.addEventListener('chatResolveEntity', (event: CustomEvent) => {
const {target, requestId} = event.detail;
this._logger.debug('chatResolveEntity', target);
const entity = this.findEntityByIdOrLabel(target);
const responseEvent = new CustomEvent('chatResolveEntityResponse', {
detail: {
requestId,
target,
entityId: entity?.id || null,
entityLabel: entity?.text || null,
found: !!entity
},
bubbles: true
});
document.dispatchEvent(responseEvent);
});
// Clear all entities from the diagram
document.addEventListener('chatClearDiagram', () => {
this._logger.debug('chatClearDiagram - removing all entities');
const entitiesToRemove = Array.from(this._diagramObjects.keys());
for (const id of entitiesToRemove) {
const diagramObject = this._diagramObjects.get(id);
if (diagramObject) {
const entity = diagramObject.diagramEntity;
diagramObject.dispose();
this._diagramObjects.delete(id);
this.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.REMOVE,
entity: entity
}, DiagramEventObserverMask.TO_DB);
}
}
this._logger.debug(`Cleared ${entitiesToRemove.length} entities`);
});
// Get current camera position and orientation
// Camera may be parented to a platform, so we use world-space coordinates
document.addEventListener('chatGetCamera', () => {
this._logger.debug('chatGetCamera');
const camera = this._scene.activeCamera;
if (!camera) {
this._logger.warn('No active camera found');
return;
}
// World-space position (accounts for parent transforms)
const position = camera.globalPosition;
// World-space forward direction (where camera is looking)
const forward = camera.getForwardRay(1).direction;
// World up vector
const worldUp = new Vector3(0, 1, 0);
// Compute ground-projected forward (for intuitive forward/back movement)
// This ignores pitch so looking up/down doesn't affect horizontal movement
const groundForward = new Vector3(forward.x, 0, forward.z);
const groundForwardLength = groundForward.length();
if (groundForwardLength > 0.001) {
groundForward.scaleInPlace(1 / groundForwardLength);
} else {
// Looking straight up/down - use a fallback forward
groundForward.set(0, 0, -1);
}
// Compute right vector (perpendicular to groundForward in XZ plane)
// Right = Cross(groundForward, worldUp) gives left, so we negate or swap
const groundRight = Vector3.Cross(worldUp, groundForward).normalize();
const responseEvent = new CustomEvent('chatGetCameraResponse', {
detail: {
position: {x: position.x, y: position.y, z: position.z},
forward: {x: forward.x, y: forward.y, z: forward.z},
groundForward: {x: groundForward.x, y: groundForward.y, z: groundForward.z},
groundRight: {x: groundRight.x, y: groundRight.y, z: groundRight.z}
},
bubbles: true
});
document.dispatchEvent(responseEvent);
});
this._logger.debug("DiagramManager constructed");
}
public get actionManager(): AbstractActionManager {
return this._diagramEntityActionManager;
}
public getDiagramObject(id: string) {
return this._diagramObjects.get(id);
}
public isDiagramObject(mesh: AbstractMesh) {
return this._diagramObjects.has(mesh?.id)
}
public createCopy(id: string): DiagramObject {
const diagramObject = this._diagramObjects.get(id);
if (!diagramObject) {
this._logger.warn('createCopy called with invalid diagram object', id);
return null;
}
const obj = diagramObject.clone();
return obj;
}
public addObject(diagramObject: DiagramObject) {
this._diagramObjects.set(diagramObject.diagramEntity.id, diagramObject);
}
public get config() {
return appConfigInstance;
}
private findEntityByIdOrLabel(target: string): DiagramEntity | null {
// First try direct ID match
const byId = this._diagramObjects.get(target);
if (byId) {
return byId.diagramEntity;
}
// Then try label match (case-insensitive)
const targetLower = target.toLowerCase();
for (const [, obj] of this._diagramObjects) {
if (obj.diagramEntity.text?.toLowerCase() === targetLower) {
return obj.diagramEntity;
}
}
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) {
case DiagramEventType.CLEAR:
this._diagramObjects.forEach((value) => {
value.dispose();
});
this._diagramObjects.clear();
break;
case DiagramEventType.ADD:
if (diagramObject) {
diagramObject.fromDiagramEntity(event.entity);
} else {
diagramObject = DiagramObject.CreateObject(this._scene,
this.onDiagramEventObservable,
{diagramEntity: event.entity, actionManager: this._diagramEntityActionManager});
}
if (diagramObject) {
this._diagramObjects.set(event.entity.id, diagramObject);
} else {
this._logger.error('failed to create diagram object for ', event.entity);
}
break;
case DiagramEventType.REMOVE:
if (diagramObject) {
diagramObject.dispose();
}
this._diagramObjects.delete(event?.entity?.id);
break;
case DiagramEventType.MODIFY:
this._logger.debug(event);
//diagramObject = this._diagramObjects.get(event.entity.id);
if (diagramObject && event.entity.text && event.entity.text != diagramObject.text) {
diagramObject.text = event.entity.text;
} else {
this._logger.warn('Skipping text update for', event);
}
if (event.entity.position) {
//diagramObject.position = event.entity.position;
}
break;
}
}
}