Add WebXR system keyboard support and improve entity positioning
- Add SystemKeyboardInput class for Meta Quest native keyboard in VR - Integrate system keyboard into DiagramMenuManager with auto-detection - Falls back to virtual keyboard when system keyboard not supported - Update AI system prompt with entity positioning guidelines: - Default position near (0, 1.5, 0) at eye level - Minimum 0.2m spacing between entities - Entities placed in front of camera with positive z values Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4ca98cf980
commit
719c969f72
@ -1,6 +1,7 @@
|
|||||||
import {DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
|
import {DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
|
||||||
import {AbstractMesh, ActionEvent, Observable, Scene, Vector3, WebXRDefaultExperience, WebXRInputSource} from "@babylonjs/core";
|
import {AbstractMesh, ActionEvent, Observable, Scene, Vector3, WebXRDefaultExperience, WebXRInputSource} from "@babylonjs/core";
|
||||||
import {InputTextView} from "../information/inputTextView";
|
import {InputTextView} from "../information/inputTextView";
|
||||||
|
import {SystemKeyboardInput} from "../information/systemKeyboardInput";
|
||||||
import {DefaultScene} from "../defaultScene";
|
import {DefaultScene} from "../defaultScene";
|
||||||
import log from "loglevel";
|
import log from "loglevel";
|
||||||
import {Toolbox} from "../toolbox/toolbox";
|
import {Toolbox} from "../toolbox/toolbox";
|
||||||
@ -19,6 +20,8 @@ export class DiagramMenuManager {
|
|||||||
public readonly toolbox: Toolbox;
|
public readonly toolbox: Toolbox;
|
||||||
private readonly _notifier: Observable<DiagramEvent>;
|
private readonly _notifier: Observable<DiagramEvent>;
|
||||||
private readonly _inputTextView: InputTextView;
|
private readonly _inputTextView: InputTextView;
|
||||||
|
private readonly _systemKeyboardInput: SystemKeyboardInput;
|
||||||
|
private _useSystemKeyboard: boolean = false;
|
||||||
private readonly _vrConfigPanel: VRConfigPanel;
|
private readonly _vrConfigPanel: VRConfigPanel;
|
||||||
private _groupMenu: GroupMenu;
|
private _groupMenu: GroupMenu;
|
||||||
private readonly _scene: Scene;
|
private readonly _scene: Scene;
|
||||||
@ -31,16 +34,22 @@ export class DiagramMenuManager {
|
|||||||
this._scene = DefaultScene.Scene;
|
this._scene = DefaultScene.Scene;
|
||||||
this._notifier = notifier;
|
this._notifier = notifier;
|
||||||
this._inputTextView = new InputTextView(controllerObservable);
|
this._inputTextView = new InputTextView(controllerObservable);
|
||||||
|
this._systemKeyboardInput = new SystemKeyboardInput();
|
||||||
this._vrConfigPanel = new VRConfigPanel(this._scene);
|
this._vrConfigPanel = new VRConfigPanel(this._scene);
|
||||||
//this.configMenu = new ConfigMenu(config);
|
//this.configMenu = new ConfigMenu(config);
|
||||||
|
|
||||||
this._inputTextView.onTextObservable.add((evt) => {
|
// Handler for text input changes (shared between both input methods)
|
||||||
|
const handleTextEvent = (evt) => {
|
||||||
const event = {
|
const event = {
|
||||||
type: DiagramEventType.MODIFY,
|
type: DiagramEventType.MODIFY,
|
||||||
entity: {id: evt.id, text: evt.text, type: DiagramEntityType.ENTITY}
|
entity: {id: evt.id, text: evt.text, type: DiagramEntityType.ENTITY}
|
||||||
}
|
}
|
||||||
this._notifier.notifyObservers(event, DiagramEventObserverMask.FROM_DB);
|
this._notifier.notifyObservers(event, DiagramEventObserverMask.FROM_DB);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
this._inputTextView.onTextObservable.add(handleTextEvent);
|
||||||
|
this._systemKeyboardInput.onTextObservable.add(handleTextEvent);
|
||||||
|
|
||||||
this.toolbox = new Toolbox(readyObservable);
|
this.toolbox = new Toolbox(readyObservable);
|
||||||
|
|
||||||
if (viewOnly()) {
|
if (viewOnly()) {
|
||||||
@ -87,8 +96,14 @@ export class DiagramMenuManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public editText(mesh: AbstractMesh) {
|
public editText(mesh: AbstractMesh) {
|
||||||
|
if (this._useSystemKeyboard) {
|
||||||
|
this._logger.debug('Using system keyboard for text input');
|
||||||
|
this._systemKeyboardInput.show(mesh);
|
||||||
|
} else {
|
||||||
|
this._logger.debug('Using virtual keyboard for text input');
|
||||||
this._inputTextView.show(mesh);
|
this._inputTextView.show(mesh);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public activateResizeGizmo(mesh: AbstractMesh) {
|
public activateResizeGizmo(mesh: AbstractMesh) {
|
||||||
// Dispose existing gizmo if any
|
// Dispose existing gizmo if any
|
||||||
@ -164,6 +179,21 @@ export class DiagramMenuManager {
|
|||||||
public setXR(xr: WebXRDefaultExperience): void {
|
public setXR(xr: WebXRDefaultExperience): void {
|
||||||
this._xr = xr;
|
this._xr = xr;
|
||||||
this.toolbox.setXR(xr, this);
|
this.toolbox.setXR(xr, this);
|
||||||
|
|
||||||
|
// Listen for XR session start to detect system keyboard support
|
||||||
|
xr.baseExperience.onStateChangedObservable.add((state) => {
|
||||||
|
if (state === 2) { // WebXRState.IN_XR
|
||||||
|
const session = xr.baseExperience.sessionManager.session;
|
||||||
|
this._useSystemKeyboard = SystemKeyboardInput.isSupported(session);
|
||||||
|
this._logger.debug('System keyboard supported:', this._useSystemKeyboard);
|
||||||
|
|
||||||
|
if (this._useSystemKeyboard) {
|
||||||
|
this._systemKeyboardInput.setSession(session);
|
||||||
|
}
|
||||||
|
} else if (state === 0) { // WebXRState.NOT_IN_XR
|
||||||
|
this._useSystemKeyboard = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleVRConfigPanel(): void {
|
public toggleVRConfigPanel(): void {
|
||||||
|
|||||||
158
src/information/systemKeyboardInput.ts
Normal file
158
src/information/systemKeyboardInput.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import {AbstractMesh, Observable} from "@babylonjs/core";
|
||||||
|
import log, {Logger} from "loglevel";
|
||||||
|
import {TextEvent} from "./inputTextView";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SystemKeyboardInput provides text input using the WebXR system keyboard (Meta Quest native keyboard).
|
||||||
|
* This is an alternative to the VirtualKeyboard-based InputTextView for devices that support
|
||||||
|
* the WebXR system keyboard API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* - Check isSupported() before using
|
||||||
|
* - Call show(mesh) to display the system keyboard
|
||||||
|
* - Listen to onTextObservable for text changes
|
||||||
|
* - Call dispose() when XR session ends
|
||||||
|
*/
|
||||||
|
export class SystemKeyboardInput {
|
||||||
|
private logger: Logger = log.getLogger('SystemKeyboardInput');
|
||||||
|
public readonly onTextObservable: Observable<TextEvent> = new Observable<TextEvent>();
|
||||||
|
|
||||||
|
private inputElement: HTMLInputElement | null = null;
|
||||||
|
private currentMeshId: string | null = null;
|
||||||
|
private xrSession: XRSession | null = null;
|
||||||
|
private visibilityHandler: ((ev: XRSessionEvent) => void) | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.logger.debug('SystemKeyboardInput created');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the system keyboard is supported for the given XR session.
|
||||||
|
*/
|
||||||
|
public static isSupported(session: XRSession | null): boolean {
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// TypeScript doesn't have this property in the type definitions yet
|
||||||
|
return (session as any).isSystemKeyboardSupported === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize with an XR session. Should be called when entering immersive mode.
|
||||||
|
*/
|
||||||
|
public setSession(session: XRSession): void {
|
||||||
|
this.xrSession = session;
|
||||||
|
|
||||||
|
// Set up visibility change listener for keyboard lifecycle
|
||||||
|
this.visibilityHandler = (ev: XRSessionEvent) => {
|
||||||
|
const visState = (ev.target as XRSession).visibilityState;
|
||||||
|
this.logger.debug('XR visibility changed:', visState);
|
||||||
|
|
||||||
|
if (visState === 'visible' && this.inputElement) {
|
||||||
|
// Keyboard was dismissed, finalize input
|
||||||
|
this.finalizeInput();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
session.addEventListener('visibilitychange', this.visibilityHandler);
|
||||||
|
|
||||||
|
// Clean up when session ends
|
||||||
|
session.addEventListener('end', () => {
|
||||||
|
this.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the system keyboard for editing the given mesh's text.
|
||||||
|
*/
|
||||||
|
public show(mesh: AbstractMesh): void {
|
||||||
|
if (!this.xrSession) {
|
||||||
|
this.logger.warn('Cannot show system keyboard: no XR session');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentMeshId = mesh.id;
|
||||||
|
|
||||||
|
// Create input element if it doesn't exist
|
||||||
|
if (!this.inputElement) {
|
||||||
|
this.inputElement = document.createElement('input');
|
||||||
|
this.inputElement.type = 'text';
|
||||||
|
this.inputElement.id = 'system-keyboard-input';
|
||||||
|
|
||||||
|
// Position off-screen to avoid visual interference
|
||||||
|
// but keep it in DOM for keyboard to work
|
||||||
|
this.inputElement.style.position = 'fixed';
|
||||||
|
this.inputElement.style.left = '-9999px';
|
||||||
|
this.inputElement.style.top = '0';
|
||||||
|
this.inputElement.style.opacity = '0';
|
||||||
|
this.inputElement.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
// Handle blur event (keyboard dismissed)
|
||||||
|
this.inputElement.onblur = () => {
|
||||||
|
this.logger.debug('Input blurred');
|
||||||
|
this.finalizeInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.appendChild(this.inputElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial value from mesh metadata
|
||||||
|
const initialText = mesh.metadata?.text || '';
|
||||||
|
this.inputElement.value = initialText;
|
||||||
|
|
||||||
|
this.logger.debug('Showing system keyboard for mesh:', mesh.id, 'initial text:', initialText);
|
||||||
|
|
||||||
|
// Focus to trigger the system keyboard
|
||||||
|
this.inputElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the keyboard and discard any changes.
|
||||||
|
*/
|
||||||
|
public hide(): void {
|
||||||
|
if (this.inputElement) {
|
||||||
|
this.inputElement.blur();
|
||||||
|
}
|
||||||
|
this.currentMeshId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize the input and notify observers.
|
||||||
|
*/
|
||||||
|
private finalizeInput(): void {
|
||||||
|
if (!this.currentMeshId || !this.inputElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = this.inputElement.value.trim();
|
||||||
|
this.logger.debug('Finalizing input:', text, 'for mesh:', this.currentMeshId);
|
||||||
|
|
||||||
|
// Notify observers with the final text (or null if empty)
|
||||||
|
this.onTextObservable.notifyObservers({
|
||||||
|
id: this.currentMeshId,
|
||||||
|
text: text.length > 0 ? text : null
|
||||||
|
});
|
||||||
|
|
||||||
|
this.currentMeshId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up resources. Should be called when XR session ends.
|
||||||
|
*/
|
||||||
|
public dispose(): void {
|
||||||
|
this.logger.debug('Disposing SystemKeyboardInput');
|
||||||
|
|
||||||
|
if (this.visibilityHandler && this.xrSession) {
|
||||||
|
this.xrSession.removeEventListener('visibilitychange', this.visibilityHandler);
|
||||||
|
this.visibilityHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.inputElement) {
|
||||||
|
this.inputElement.remove();
|
||||||
|
this.inputElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.xrSession = null;
|
||||||
|
this.currentMeshId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,84 +17,97 @@ export interface ModelInfo {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
provider: AIProvider;
|
provider: AIProvider;
|
||||||
|
contextLimit: number; // Maximum context window size in tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
const AVAILABLE_MODELS: ModelInfo[] = [
|
const AVAILABLE_MODELS: ModelInfo[] = [
|
||||||
// Claude models
|
// Claude models - 200K context
|
||||||
{
|
{
|
||||||
id: 'claude-sonnet-4-20250514',
|
id: 'claude-sonnet-4-20250514',
|
||||||
name: 'Claude Sonnet 4',
|
name: 'Claude Sonnet 4',
|
||||||
description: 'Balanced performance and speed (default)',
|
description: 'Balanced performance and speed (default)',
|
||||||
provider: 'claude'
|
provider: 'claude',
|
||||||
|
contextLimit: 200000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'claude-opus-4-20250514',
|
id: 'claude-opus-4-20250514',
|
||||||
name: 'Claude Opus 4',
|
name: 'Claude Opus 4',
|
||||||
description: 'Most capable, best for complex tasks',
|
description: 'Most capable, best for complex tasks',
|
||||||
provider: 'claude'
|
provider: 'claude',
|
||||||
|
contextLimit: 200000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'claude-haiku-3-5-20241022',
|
id: 'claude-haiku-3-5-20241022',
|
||||||
name: 'Claude Haiku 3.5',
|
name: 'Claude Haiku 3.5',
|
||||||
description: 'Fastest responses, good for simple tasks',
|
description: 'Fastest responses, good for simple tasks',
|
||||||
provider: 'claude'
|
provider: 'claude',
|
||||||
|
contextLimit: 200000
|
||||||
},
|
},
|
||||||
// Cloudflare Workers AI models - with tool support
|
// Cloudflare Workers AI models - with tool support
|
||||||
{
|
{
|
||||||
id: '@cf/mistralai/mistral-small-3.1-24b-instruct',
|
id: '@cf/mistralai/mistral-small-3.1-24b-instruct',
|
||||||
name: 'Mistral Small 3.1 (CF)',
|
name: 'Mistral Small 3.1 (CF)',
|
||||||
description: 'Best CF model - supports diagram tools',
|
description: 'Best CF model - supports diagram tools',
|
||||||
provider: 'cloudflare'
|
provider: 'cloudflare',
|
||||||
|
contextLimit: 32000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '@hf/nousresearch/hermes-2-pro-mistral-7b',
|
id: '@hf/nousresearch/hermes-2-pro-mistral-7b',
|
||||||
name: 'Hermes 2 Pro (CF)',
|
name: 'Hermes 2 Pro (CF)',
|
||||||
description: 'Lightweight - supports diagram tools',
|
description: 'Lightweight - supports diagram tools',
|
||||||
provider: 'cloudflare'
|
provider: 'cloudflare',
|
||||||
|
contextLimit: 8000
|
||||||
},
|
},
|
||||||
// Cloudflare models WITHOUT tool support - chat only
|
// Cloudflare models WITHOUT tool support - chat only
|
||||||
{
|
{
|
||||||
id: '@cf/meta/llama-3.3-70b-instruct-fp8-fast',
|
id: '@cf/meta/llama-3.3-70b-instruct-fp8-fast',
|
||||||
name: 'Llama 3.3 70B (CF)',
|
name: 'Llama 3.3 70B (CF)',
|
||||||
description: 'Powerful but NO tool support',
|
description: 'Powerful but NO tool support',
|
||||||
provider: 'cloudflare'
|
provider: 'cloudflare',
|
||||||
|
contextLimit: 128000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '@cf/meta/llama-3.1-8b-instruct',
|
id: '@cf/meta/llama-3.1-8b-instruct',
|
||||||
name: 'Llama 3.1 8B (CF)',
|
name: 'Llama 3.1 8B (CF)',
|
||||||
description: 'Fast/cheap but NO tool support',
|
description: 'Fast/cheap but NO tool support',
|
||||||
provider: 'cloudflare'
|
provider: 'cloudflare',
|
||||||
|
contextLimit: 128000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b',
|
id: '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b',
|
||||||
name: 'DeepSeek R1 (CF)',
|
name: 'DeepSeek R1 (CF)',
|
||||||
description: 'Reasoning but NO tool support',
|
description: 'Reasoning but NO tool support',
|
||||||
provider: 'cloudflare'
|
provider: 'cloudflare',
|
||||||
|
contextLimit: 32000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '@cf/qwen/qwen2.5-coder-32b-instruct',
|
id: '@cf/qwen/qwen2.5-coder-32b-instruct',
|
||||||
name: 'Qwen 2.5 Coder (CF)',
|
name: 'Qwen 2.5 Coder (CF)',
|
||||||
description: 'Code-focused but NO tool support',
|
description: 'Code-focused but NO tool support',
|
||||||
provider: 'cloudflare'
|
provider: 'cloudflare',
|
||||||
|
contextLimit: 32000
|
||||||
},
|
},
|
||||||
// Ollama models (local)
|
// Ollama models (local) - context depends on local config, using defaults
|
||||||
{
|
{
|
||||||
id: 'llama3.1',
|
id: 'llama3.1',
|
||||||
name: 'Llama 3.1',
|
name: 'Llama 3.1',
|
||||||
description: 'Local model with function calling support',
|
description: 'Local model with function calling support',
|
||||||
provider: 'ollama'
|
provider: 'ollama',
|
||||||
|
contextLimit: 128000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mistral',
|
id: 'mistral',
|
||||||
name: 'Mistral',
|
name: 'Mistral',
|
||||||
description: 'Fast local model with good tool support',
|
description: 'Fast local model with good tool support',
|
||||||
provider: 'ollama'
|
provider: 'ollama',
|
||||||
|
contextLimit: 32000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'qwen2.5',
|
id: 'qwen2.5',
|
||||||
name: 'Qwen 2.5',
|
name: 'Qwen 2.5',
|
||||||
description: 'Capable local model with function calling',
|
description: 'Capable local model with function calling',
|
||||||
provider: 'ollama'
|
provider: 'ollama',
|
||||||
|
contextLimit: 32000
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -413,9 +426,12 @@ Example positions:
|
|||||||
- (0, 3, 2) = directly in front, above eye level
|
- (0, 3, 2) = directly in front, above eye level
|
||||||
|
|
||||||
## Layout Guidelines
|
## Layout Guidelines
|
||||||
- Spread entities apart (at least 0.5 units)
|
- **Default position**: Place new entities in front of the camera, centered around (0, 1.5, 0) which is eye level
|
||||||
- Use y=1.5 for eye-level entities
|
- **Minimum spacing**: Entities must NEVER be closer than 0.2 meters (20cm) from each other
|
||||||
- For relative directions, call get_camera_position first
|
- **Spread layout**: When creating multiple entities, spread them horizontally (x-axis) and vertically (y-axis) with at least 0.5m between them
|
||||||
|
- Use y=1.5 for eye-level entities (comfortable viewing height)
|
||||||
|
- Use positive z values (e.g., z=1 to z=3) to place entities in front of the user
|
||||||
|
- For relative directions (left, right, forward), call get_camera_position first
|
||||||
|
|
||||||
Be concise. Call tools immediately when the user requests an action.`;
|
Be concise. Call tools immediately when the user requests an action.`;
|
||||||
|
|
||||||
@ -444,9 +460,9 @@ const TOOLS = [
|
|||||||
properties: {
|
properties: {
|
||||||
x: {type: "number", description: "Left (-) / Right (+) from camera view"},
|
x: {type: "number", description: "Left (-) / Right (+) from camera view"},
|
||||||
y: {type: "number", description: "Down (-) / Up (+), 0=floor, 1.5=eye level"},
|
y: {type: "number", description: "Down (-) / Up (+), 0=floor, 1.5=eye level"},
|
||||||
z: {type: "number", description: "Backward (-) / Forward (+) from camera view"}
|
z: {type: "number", description: "Backward (-) / Forward (+) from camera view, use positive values (1-3) to place in front"}
|
||||||
},
|
},
|
||||||
description: "3D position from camera perspective. Example: (0, 1.5, 2) = in front at eye level"
|
description: "3D position. Default around (0, 1.5, 0). Keep entities at least 0.2m apart. Example: (0, 1.5, 2) = in front at eye level"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
required: ["shape"]
|
required: ["shape"]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user