- 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>
457 lines
19 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|