Ollama Integration: - Add providerConfig.js for managing AI provider settings - Add toolConverter.js to convert between Claude and Ollama formats - Add ollama.js API handler with function calling support - Update diagramAI.ts with Ollama models (llama3.1, mistral, qwen2.5) - Route requests to appropriate provider based on selected model - Use 127.0.0.1 to avoid IPv6 resolution issues New modify_connection Tool: - Add modify_connection tool to change connection labels and colors - Support finding connections by label or by from/to entities - Add chatModifyConnection event handler in diagramManager - Clarify in tool descriptions that empty string removes labels 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
import {DiagramEntity, DiagramEntityType, DiagramTemplates} from "../../diagram/types/diagramEntity";
|
|
import {
|
|
ClearDiagramParams,
|
|
COLOR_NAME_TO_HEX,
|
|
ConnectEntitiesParams,
|
|
CreateEntityParams,
|
|
ModifyConnectionParams,
|
|
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 async function modifyConnection(params: ModifyConnectionParams): Promise<ToolResult> {
|
|
logger.debug('[modifyConnection] Modifying connection:', params);
|
|
|
|
// Determine how to find the connection
|
|
let connectionTarget: string | null = null;
|
|
|
|
if (params.target) {
|
|
// Direct target specified (connection label)
|
|
connectionTarget = params.target;
|
|
} else if (params.from && params.to) {
|
|
// Find by from/to entities
|
|
const fromEntity = await resolveEntity(params.from);
|
|
const toEntity = await resolveEntity(params.to);
|
|
|
|
if (!fromEntity.id) {
|
|
return {
|
|
toolName: 'modify_connection',
|
|
success: false,
|
|
message: `Could not find source entity "${params.from}"`
|
|
};
|
|
}
|
|
|
|
if (!toEntity.id) {
|
|
return {
|
|
toolName: 'modify_connection',
|
|
success: false,
|
|
message: `Could not find destination entity "${params.to}"`
|
|
};
|
|
}
|
|
|
|
// Use a special format to identify connection by from/to
|
|
connectionTarget = `connection:${fromEntity.id}:${toEntity.id}`;
|
|
} else {
|
|
return {
|
|
toolName: 'modify_connection',
|
|
success: false,
|
|
message: 'Must specify either "target" (connection label) or both "from" and "to" entities'
|
|
};
|
|
}
|
|
|
|
const updates: Partial<DiagramEntity> = {};
|
|
|
|
if (params.color) {
|
|
updates.color = resolveColor(params.color);
|
|
}
|
|
if (params.label !== undefined) {
|
|
updates.text = params.label;
|
|
}
|
|
|
|
const event = new CustomEvent('chatModifyConnection', {
|
|
detail: {target: connectionTarget, updates},
|
|
bubbles: true
|
|
});
|
|
document.dispatchEvent(event);
|
|
|
|
const labelInfo = params.label ? ` with label "${params.label}"` : '';
|
|
const colorInfo = params.color ? ` with color "${params.color}"` : '';
|
|
|
|
return {
|
|
toolName: 'modify_connection',
|
|
success: true,
|
|
message: `Modified connection${labelInfo}${colorInfo}`
|
|
};
|
|
}
|
|
|
|
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
|
|
* Returns world-space coordinates with ground-projected directions for intuitive placement
|
|
*/
|
|
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, groundForward, groundRight} = e.detail;
|
|
logger.debug('[getCameraPosition] Got response:', e.detail);
|
|
|
|
// Compute example positions for each direction
|
|
const distance = 2;
|
|
const frontPos = {
|
|
x: position.x + groundForward.x * distance,
|
|
y: position.y,
|
|
z: position.z + groundForward.z * distance
|
|
};
|
|
const rightPos = {
|
|
x: position.x + groundRight.x * distance,
|
|
y: position.y,
|
|
z: position.z + groundRight.z * distance
|
|
};
|
|
const leftPos = {
|
|
x: position.x - groundRight.x * distance,
|
|
y: position.y,
|
|
z: position.z - groundRight.z * distance
|
|
};
|
|
const backPos = {
|
|
x: position.x - groundForward.x * distance,
|
|
y: position.y,
|
|
z: position.z - groundForward.z * distance
|
|
};
|
|
|
|
const message = `User's current view (world coordinates):
|
|
|
|
Position: (${position.x.toFixed(2)}, ${position.y.toFixed(2)}, ${position.z.toFixed(2)})
|
|
Looking direction: (${forward.x.toFixed(2)}, ${forward.y.toFixed(2)}, ${forward.z.toFixed(2)})
|
|
|
|
Ground-plane directions (use these for left/right/forward/back):
|
|
• Forward: (${groundForward.x.toFixed(2)}, 0, ${groundForward.z.toFixed(2)})
|
|
• Right: (${groundRight.x.toFixed(2)}, 0, ${groundRight.z.toFixed(2)})
|
|
|
|
To place entities relative to user (${distance}m away, at eye level y=${position.y.toFixed(1)}):
|
|
• FORWARD: (${frontPos.x.toFixed(2)}, ${frontPos.y.toFixed(2)}, ${frontPos.z.toFixed(2)})
|
|
• BACK: (${backPos.x.toFixed(2)}, ${backPos.y.toFixed(2)}, ${backPos.z.toFixed(2)})
|
|
• RIGHT: (${rightPos.x.toFixed(2)}, ${rightPos.y.toFixed(2)}, ${rightPos.z.toFixed(2)})
|
|
• LEFT: (${leftPos.x.toFixed(2)}, ${leftPos.y.toFixed(2)}, ${leftPos.z.toFixed(2)})
|
|
|
|
Formula: position + (groundForward * distance) for forward, position + (groundRight * distance) for right, etc.`;
|
|
|
|
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);
|
|
});
|
|
}
|