Compare commits

...

4 Commits

Author SHA1 Message Date
bf5ebbc454 added webGPU flag
All checks were successful
Build and Deploy / build (push) Successful in 1m55s
2026-03-03 08:24:51 -06:00
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
719c969f72 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>
2026-01-14 14:45:42 -06:00
4ca98cf980 Add LangChain model wrappers and enhance diagram AI tools
- Migrate to LangChain for model abstraction (@langchain/anthropic, @langchain/ollama)
- Add custom ChatCloudflare class for Cloudflare Workers AI
- Simplify API routes using unified LangChain interface
- Add session preferences API for storing user settings
- Add connection label preference (ask user once, remember for session)
- Add shape modification support (change entity shapes via AI)
- Add template setter to DiagramObject for shape changes
- Improve entity inference with fuzzy matching
- Map colors to 16 toolbox palette colors
- Limit conversation history to last 6 messages
- Fix model switching to accept display names

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:17:15 -06:00
22 changed files with 2753 additions and 1252 deletions

400
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "immersive",
"version": "0.0.8-47",
"version": "0.0.8-50",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immersive",
"version": "0.0.8-47",
"version": "0.0.8-50",
"license": "MIT",
"dependencies": {
"@auth0/auth0-react": "^2.2.4",
@ -20,6 +20,9 @@
"@emotion/react": "^11.13.0",
"@giphy/js-fetch-api": "^5.6.0",
"@giphy/react-components": "^9.6.0",
"@langchain/anthropic": "^1.3.8",
"@langchain/core": "^1.1.13",
"@langchain/ollama": "^1.2.0",
"@mantine/core": "^7.17.8",
"@mantine/form": "^7.17.8",
"@mantine/hooks": "^7.17.8",
@ -61,7 +64,9 @@
"uuid": "^9.0.1",
"vite-express": "^0.21.1",
"websocket": "^1.0.34",
"websocket-ts": "^2.1.5"
"websocket-ts": "^2.1.5",
"zod": "^4.3.5",
"zod-to-json-schema": "^3.25.1"
},
"devDependencies": {
"@types/dom-to-image": "^2.6.7",
@ -74,6 +79,26 @@
"node": ">=18.0.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.71.2",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz",
"integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@apm-js-collab/code-transformer": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz",
@ -333,6 +358,12 @@
"babylonjs-gltf2interface": "^8.0.0"
}
},
"node_modules/@cfworker/json-schema": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
"license": "MIT"
},
"node_modules/@colors/colors": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
@ -1089,6 +1120,97 @@
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@langchain/anthropic": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.8.tgz",
"integrity": "sha512-liCcRIkoA07s7bVbGpimCwGSIN8JfMdhV+7fji/7jyPI5P6T2TM1vTIL/D6cGMcEiq7DKQK8mTkmO98cv/DpPA==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.71.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@langchain/core": "1.1.13"
}
},
"node_modules/@langchain/core": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.13.tgz",
"integrity": "sha512-CmTES4DNfNs7PisGm/is4RxOf1NAWCkhi+RrBBHb/gB5nZVFd+dfmXSomKoiBQ1DOdCUz1k9RX4DzSUbwg1swg==",
"license": "MIT",
"dependencies": {
"@cfworker/json-schema": "^4.0.2",
"ansi-styles": "^5.0.0",
"camelcase": "6",
"decamelize": "1.2.0",
"js-tiktoken": "^1.0.12",
"langsmith": ">=0.4.0 <1.0.0",
"mustache": "^4.2.0",
"p-queue": "^6.6.2",
"uuid": "^10.0.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@langchain/core/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@langchain/core/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@langchain/ollama": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@langchain/ollama/-/ollama-1.2.0.tgz",
"integrity": "sha512-OinxIhssKXdDQKnQoBF4TQTMBuMMV5OcNPk4Zze8UjcaSOGngn3CAI1FVbBxl0bTG5ov61w4AoWWsUwOwiSJFw==",
"license": "MIT",
"dependencies": {
"ollama": "^0.6.3",
"uuid": "^10.0.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@langchain/core": "^1.0.0"
}
},
"node_modules/@langchain/ollama/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@mantine/core": {
"version": "7.17.8",
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.17.8.tgz",
@ -2209,6 +2331,12 @@
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@tyriar/fibonacci-heap": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/@tyriar/fibonacci-heap/-/fibonacci-heap-2.0.9.tgz",
@ -2785,6 +2913,18 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
@ -3088,6 +3228,15 @@
"integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==",
"dev": true
},
"node_modules/console-table-printer": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.15.0.tgz",
"integrity": "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==",
"license": "MIT",
"dependencies": {
"simple-wcswidth": "^1.1.2"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@ -3372,6 +3521,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-uri-component": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz",
@ -3830,6 +3988,12 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@ -5294,6 +5458,15 @@
"resolved": "https://registry.npmjs.org/js-crypto-env/-/js-crypto-env-1.0.5.tgz",
"integrity": "sha512-8/UNN3sG8J+yMzqwSNVaobaWhIz4MqZFoOg5OB0DFXqS8eFjj2YvdmLJqIWXPl57Yw10SvYx0DQOtkfsWIV9Aw=="
},
"node_modules/js-tiktoken": {
"version": "1.0.21",
"resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz",
"integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.5.1"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -5336,6 +5509,19 @@
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
@ -5420,6 +5606,123 @@
"node": ">= 8"
}
},
"node_modules/langsmith": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.4.6.tgz",
"integrity": "sha512-9aYop1fEwA8RgFuvv8XPeV9ieeSnKnVRn3bNemkFQCyINLAxfNHC547bVMW8i8MuS1F1pgKwopqhLNf80qS1bQ==",
"license": "MIT",
"dependencies": {
"@types/uuid": "^10.0.0",
"chalk": "^4.1.2",
"console-table-printer": "^2.12.1",
"p-queue": "^6.6.2",
"semver": "^7.6.3",
"uuid": "^10.0.0"
},
"peerDependencies": {
"@opentelemetry/api": "*",
"@opentelemetry/exporter-trace-otlp-proto": "*",
"@opentelemetry/sdk-trace-base": "*",
"openai": "*"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@opentelemetry/exporter-trace-otlp-proto": {
"optional": true
},
"@opentelemetry/sdk-trace-base": {
"optional": true
},
"openai": {
"optional": true
}
}
},
"node_modules/langsmith/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/langsmith/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/langsmith/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/langsmith/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/langsmith/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/langsmith/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/langsmith/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/level": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/level/-/level-6.0.1.tgz",
@ -6079,6 +6382,15 @@
"node": ">= 0.6"
}
},
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
}
},
"node_modules/nan": {
"version": "2.24.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz",
@ -6311,6 +6623,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ollama": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz",
"integrity": "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==",
"license": "MIT",
"dependencies": {
"whatwg-fetch": "^3.6.20"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -6356,6 +6677,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/p-limit": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
@ -6371,6 +6701,34 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"license": "MIT",
"dependencies": {
"p-finally": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -8292,6 +8650,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-wcswidth": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz",
"integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==",
"license": "MIT"
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@ -8829,6 +9193,12 @@
"utf8-byte-length": "^1.0.1"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-easing": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz",
@ -9386,6 +9756,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
"license": "MIT"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@ -9645,6 +10021,24 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25 || ^4"
}
}
}
}

View File

@ -1,7 +1,7 @@
{
"name": "immersive",
"private": true,
"version": "0.0.8-48",
"version": "0.0.8-50",
"type": "module",
"license": "MIT",
"engines": {
@ -29,6 +29,9 @@
"@emotion/react": "^11.13.0",
"@giphy/js-fetch-api": "^5.6.0",
"@giphy/react-components": "^9.6.0",
"@langchain/anthropic": "^1.3.8",
"@langchain/core": "^1.1.13",
"@langchain/ollama": "^1.2.0",
"@mantine/core": "^7.17.8",
"@mantine/form": "^7.17.8",
"@mantine/hooks": "^7.17.8",
@ -70,7 +73,9 @@
"uuid": "^9.0.1",
"vite-express": "^0.21.1",
"websocket": "^1.0.34",
"websocket-ts": "^2.1.5"
"websocket-ts": "^2.1.5",
"zod": "^4.3.5",
"zod-to-json-schema": "^3.25.1"
},
"devDependencies": {
"@types/dom-to-image": "^2.6.7",
@ -79,4 +84,4 @@
"vite-plugin-cp": "^1.0.0",
"vitest": "^1.4.0"
}
}
}

View File

@ -1,178 +1,136 @@
import { Router } from "express";
import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js";
import { getSession, addMessage } from "../services/sessionStore.js";
import { trackUsage, getUsageSummary, formatCost, getSessionUsage } from "../services/usageTracker.js";
import {
getClaudeModel,
buildLangChainMessages,
aiMessageToClaudeResponse
} from "../services/langchainModels.js";
const router = Router();
const ANTHROPIC_API_URL = "https://api.anthropic.com";
/**
* Build entity context string for the system prompt
*/
function buildEntityContext(entities) {
if (!entities || entities.length === 0) {
return "\n\nThe diagram is currently empty.";
}
// Context limits for Claude models (all have 200K)
const CLAUDE_CONTEXT_LIMIT = 200000;
const entityList = entities.map(e => {
const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown';
const pos = e.position || { x: 0, y: 0, z: 0 };
return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`;
}).join('\n');
return `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`;
}
// Express 5 uses named parameters for wildcards
router.post("/*path", async (req, res) => {
const requestStart = Date.now();
console.log(`[Claude API] ========== REQUEST START ==========`);
const requestStart = Date.now();
console.log(`[Claude API] ========== REQUEST START ==========`);
const apiKey = process.env.ANTHROPIC_API_KEY;
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
console.error(`[Claude API] ERROR: API key not configured`);
return res.status(500).json({ error: "API key not configured" });
}
// Get the path after /api/claude (e.g., /v1/messages)
// Express 5 returns path segments as an array
const pathParam = req.params.path;
const path = "/" + (Array.isArray(pathParam) ? pathParam.join("/") : pathParam || "");
console.log(`[Claude API] Path: ${path}`);
// Check for session-based request
const { sessionId, ...requestBody } = req.body;
let modifiedBody = requestBody;
console.log(`[Claude API] Session ID: ${sessionId || 'none'}`);
console.log(`[Claude API] Model: ${requestBody.model}`);
console.log(`[Claude API] Messages count: ${requestBody.messages?.length || 0}`);
if (sessionId) {
const session = getSession(sessionId);
if (session) {
console.log(`[Claude API] Session found: ${session.entities.length} entities, ${session.conversationHistory.length} messages in history`);
// Inject entity context into system prompt
if (modifiedBody.system) {
const entityContext = buildEntityContext(session.entities);
console.log(`[Claude API] Entity context added (${entityContext.length} chars)`);
modifiedBody.system += entityContext;
}
// Get conversation history and merge with current messages
const historyMessages = getConversationForAPI(sessionId);
if (historyMessages.length > 0 && modifiedBody.messages) {
// Filter out any duplicate messages (in case client sent history too)
const currentContent = modifiedBody.messages[modifiedBody.messages.length - 1]?.content;
const filteredHistory = historyMessages.filter(msg => msg.content !== currentContent);
modifiedBody.messages = [...filteredHistory, ...modifiedBody.messages];
console.log(`[Claude API] Merged ${filteredHistory.length} history + ${modifiedBody.messages.length - filteredHistory.length} new = ${modifiedBody.messages.length} total messages`);
}
} else {
console.log(`[Claude API] WARNING: Session ${sessionId} not found`);
}
}
try {
console.log(`[Claude API] Sending request to Anthropic API...`);
const fetchStart = Date.now();
const response = await fetch(`${ANTHROPIC_API_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify(modifiedBody),
});
const fetchDuration = Date.now() - fetchStart;
console.log(`[Claude API] Response received in ${fetchDuration}ms, status: ${response.status}`);
console.log(`[Claude API] Parsing response JSON...`);
const data = await response.json();
console.log(`[Claude API] Response parsed. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`);
// Track and log token usage
if (data.usage) {
// Extract content for detailed tracking
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null;
const outputText = data.content
?.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n') || null;
const toolCalls = data.content
?.filter(c => c.type === 'tool_use')
.map(c => ({ name: c.name, input: c.input })) || [];
const usageRecord = trackUsage(sessionId, modifiedBody.model, data.usage, {
inputText,
outputText,
toolCalls
});
console.log(`[Claude API] REQUEST USAGE: ${getUsageSummary(usageRecord)}`);
// Log cumulative session usage if session exists
if (sessionId) {
const sessionStats = getSessionUsage(sessionId);
if (sessionStats) {
console.log(`[Claude API] SESSION TOTALS (${sessionStats.requestCount} requests):`);
console.log(`[Claude API] Total input: ${sessionStats.totalInputTokens} tokens`);
console.log(`[Claude API] Total output: ${sessionStats.totalOutputTokens} tokens`);
console.log(`[Claude API] Total cost: ${formatCost(sessionStats.totalCost)}`);
}
}
if (!apiKey) {
console.error(`[Claude API] ERROR: API key not configured`);
return res.status(500).json({ error: "API key not configured" });
}
if (data.error) {
console.error(`[Claude API] API returned error:`, data.error);
}
const { sessionId, model: modelId, system: systemPrompt, ...requestBody } = req.body;
console.log(`[Claude API] Session ID: ${sessionId || 'none'}`);
console.log(`[Claude API] Model: ${modelId}`);
console.log(`[Claude API] Messages count: ${requestBody.messages?.length || 0}`);
// If session exists and response is successful, store messages
if (sessionId && response.ok && data.content) {
const session = getSession(sessionId);
if (session) {
// Store the user message if it was new (only if it's a string, not tool results)
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
addMessage(sessionId, {
role: 'user',
content: userMessage.content
});
console.log(`[Claude API] Stored user message to session`);
try {
// Get LangChain model with tools bound
const model = getClaudeModel(modelId);
// Build messages with entity context and history
const messages = buildLangChainMessages(
sessionId,
requestBody.messages,
systemPrompt
);
console.log(`[Claude API] Sending request via LangChain...`);
const fetchStart = Date.now();
// Invoke model
const response = await model.invoke(messages);
const fetchDuration = Date.now() - fetchStart;
console.log(`[Claude API] Response received in ${fetchDuration}ms`);
// Convert to Claude API format for client compatibility
const data = aiMessageToClaudeResponse(response, modelId);
console.log(`[Claude API] Response converted. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`);
// Track and log token usage
if (data.usage) {
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null;
const outputText = data.content
?.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n') || null;
const toolCalls = data.content
?.filter(c => c.type === 'tool_use')
.map(c => ({ name: c.name, input: c.input })) || [];
const usageRecord = trackUsage(sessionId, modelId, data.usage, {
inputText,
outputText,
toolCalls,
contextLimit: CLAUDE_CONTEXT_LIMIT
});
console.log(`[Claude API] REQUEST USAGE: ${getUsageSummary(usageRecord)}`);
if (sessionId) {
const sessionStats = getSessionUsage(sessionId);
if (sessionStats) {
console.log(`[Claude API] SESSION TOTALS (${sessionStats.requestCount} requests):`);
console.log(`[Claude API] Total input: ${sessionStats.totalInputTokens} tokens`);
console.log(`[Claude API] Total output: ${sessionStats.totalOutputTokens} tokens`);
console.log(`[Claude API] Total cost: ${formatCost(sessionStats.totalCost)}`);
console.log(`[Claude API] Context: ${sessionStats.contextUsed}/${sessionStats.contextLimit} (${sessionStats.contextPercent.toFixed(1)}%)`);
// Context warnings
if (sessionStats.contextPercent >= 95) {
console.error(`[Claude API] ⚠️ CONTEXT CRITICAL: ${sessionStats.contextPercent.toFixed(0)}% used! Consider clearing conversation.`);
} else if (sessionStats.contextPercent >= 80) {
console.warn(`[Claude API] ⚠️ CONTEXT WARNING: ${sessionStats.contextPercent.toFixed(0)}% of context window used`);
}
}
}
}
// Store the assistant response (text only, not tool use blocks)
const assistantContent = data.content
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
// Store messages to session
if (sessionId && data.content) {
const session = getSession(sessionId);
if (session) {
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
addMessage(sessionId, {
role: 'user',
content: userMessage.content
});
console.log(`[Claude API] Stored user message to session`);
}
if (assistantContent) {
addMessage(sessionId, {
role: 'assistant',
content: assistantContent
});
console.log(`[Claude API] Stored assistant response to session (${assistantContent.length} chars)`);
const assistantContent = data.content
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
if (assistantContent) {
addMessage(sessionId, {
role: 'assistant',
content: assistantContent
});
console.log(`[Claude API] Stored assistant response to session (${assistantContent.length} chars)`);
}
}
}
}
}
const totalDuration = Date.now() - requestStart;
console.log(`[Claude API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
res.status(response.status).json(data);
} catch (error) {
const totalDuration = Date.now() - requestStart;
console.error(`[Claude API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
console.error(`[Claude API] Error:`, error);
console.error(`[Claude API] Error message:`, error.message);
console.error(`[Claude API] Error stack:`, error.stack);
res.status(500).json({ error: "Failed to proxy request to Claude API", details: error.message });
}
const totalDuration = Date.now() - requestStart;
console.log(`[Claude API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
res.json(data);
} catch (error) {
const totalDuration = Date.now() - requestStart;
console.error(`[Claude API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
console.error(`[Claude API] Error:`, error);
console.error(`[Claude API] Error message:`, error.message);
res.status(500).json({ error: "Failed to call Claude API", details: error.message });
}
});
export default router;

View File

@ -1,213 +1,149 @@
import { Router } from "express";
import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js";
import { getSession, addMessage } from "../services/sessionStore.js";
import { trackUsage, getUsageSummary, formatCost, getSessionUsage } from "../services/usageTracker.js";
import { getCloudflareAccountId, getCloudflareApiToken } from "../services/providerConfig.js";
import {
claudeToolsToCloudflare,
claudeMessagesToCloudflare,
cloudflareResponseToClaude
} from "../services/toolConverter.js";
import { getCloudflareModel } from "../services/ChatCloudflare.js";
import { buildLangChainMessages, aiMessageToClaudeResponse } from "../services/langchainModels.js";
const router = Router();
/**
* Build entity context string for the system prompt
*/
function buildEntityContext(entities) {
if (!entities || entities.length === 0) {
return "\n\nThe diagram is currently empty.";
}
// Context limits for Cloudflare models
const CLOUDFLARE_CONTEXT_LIMITS = {
'@cf/mistralai/mistral-small-3.1-24b-instruct': 32000,
'@hf/nousresearch/hermes-2-pro-mistral-7b': 8000,
'@cf/meta/llama-3.3-70b-instruct-fp8-fast': 128000,
'@cf/meta/llama-3.1-8b-instruct': 128000,
'@cf/deepseek-ai/deepseek-r1-distill-qwen-32b': 32000,
'@cf/qwen/qwen2.5-coder-32b-instruct': 32000
};
const entityList = entities.map(e => {
const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown';
const pos = e.position || { x: 0, y: 0, z: 0 };
return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`;
}).join('\n');
return `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`;
}
// Express 5 uses named parameters for wildcards
router.post("/*path", async (req, res) => {
const requestStart = Date.now();
console.log(`[Cloudflare API] ========== REQUEST START ==========`);
const requestStart = Date.now();
console.log(`[Cloudflare API] ========== REQUEST START ==========`);
const accountId = getCloudflareAccountId();
const apiToken = getCloudflareApiToken();
const accountId = getCloudflareAccountId();
const apiToken = getCloudflareApiToken();
if (!accountId) {
console.error(`[Cloudflare API] ERROR: Account ID not configured`);
return res.status(500).json({ error: "Cloudflare account ID not configured" });
}
if (!apiToken) {
console.error(`[Cloudflare API] ERROR: API token not configured`);
return res.status(500).json({ error: "Cloudflare API token not configured" });
}
// Check for session-based request
const { sessionId, ...requestBody } = req.body;
let modifiedBody = { ...requestBody };
const model = requestBody.model;
console.log(`[Cloudflare API] Session ID: ${sessionId || 'none'}`);
console.log(`[Cloudflare API] Model: ${model}`);
console.log(`[Cloudflare API] Messages count: ${requestBody.messages?.length || 0}`);
// Build system prompt with entity context
let systemPrompt = modifiedBody.system || '';
if (sessionId) {
const session = getSession(sessionId);
if (session) {
console.log(`[Cloudflare API] Session found: ${session.entities.length} entities, ${session.conversationHistory.length} messages in history`);
// Inject entity context into system prompt
const entityContext = buildEntityContext(session.entities);
console.log(`[Cloudflare API] Entity context added (${entityContext.length} chars)`);
systemPrompt += entityContext;
// Get conversation history and merge with current messages
const historyMessages = getConversationForAPI(sessionId);
if (historyMessages.length > 0 && modifiedBody.messages) {
const currentContent = modifiedBody.messages[modifiedBody.messages.length - 1]?.content;
const filteredHistory = historyMessages.filter(msg => msg.content !== currentContent);
modifiedBody.messages = [...filteredHistory, ...modifiedBody.messages];
console.log(`[Cloudflare API] Merged ${filteredHistory.length} history + ${modifiedBody.messages.length - filteredHistory.length} new = ${modifiedBody.messages.length} total messages`);
}
} else {
console.log(`[Cloudflare API] WARNING: Session ${sessionId} not found`);
}
}
try {
// Convert to Cloudflare format
const cfMessages = claudeMessagesToCloudflare(modifiedBody.messages || [], systemPrompt);
const cfTools = modifiedBody.tools ? claudeToolsToCloudflare(modifiedBody.tools) : undefined;
// Build Cloudflare request body
const cfRequestBody = {
messages: cfMessages,
max_tokens: modifiedBody.max_tokens || 1024
};
// Only include tools if the model supports them
if (cfTools && cfTools.length > 0) {
cfRequestBody.tools = cfTools;
if (!accountId) {
console.error(`[Cloudflare API] ERROR: Account ID not configured`);
return res.status(500).json({ error: "Cloudflare account ID not configured" });
}
// Cloudflare endpoint: https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model}
const endpoint = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${model}`;
console.log(`[Cloudflare API] Sending request to: ${endpoint}`);
console.log(`[Cloudflare API] Request body messages: ${cfMessages.length}, tools: ${cfTools?.length || 0}`);
const requestBodyJson = JSON.stringify(cfRequestBody);
console.log(`[Cloudflare API] Full request body (${requestBodyJson.length} bytes):`);
console.log(requestBodyJson);
const fetchStart = Date.now();
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiToken}`,
},
body: JSON.stringify(cfRequestBody),
});
const fetchDuration = Date.now() - fetchStart;
console.log(`[Cloudflare API] Response received in ${fetchDuration}ms, status: ${response.status}`);
console.log(`[Cloudflare API] Parsing response JSON...`);
const cfData = await response.json();
if (!cfData.success) {
console.error(`[Cloudflare API] API returned error:`, cfData.errors);
return res.status(response.status).json({
error: cfData.errors?.[0]?.message || "Cloudflare API error",
details: cfData.errors
});
if (!apiToken) {
console.error(`[Cloudflare API] ERROR: API token not configured`);
return res.status(500).json({ error: "Cloudflare API token not configured" });
}
// Convert Cloudflare response to Claude format
const data = cloudflareResponseToClaude(cfData, model);
console.log(`[Cloudflare API] Response converted. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`);
const { sessionId, model: modelId, system: systemPrompt, messages } = req.body;
// Track and log token usage
if (data.usage) {
// Extract content for detailed tracking
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null;
console.log(`[Cloudflare API] Session ID: ${sessionId || 'none'}`);
console.log(`[Cloudflare API] Model: ${modelId}`);
console.log(`[Cloudflare API] Messages count: ${messages?.length || 0}`);
const outputText = data.content
?.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n') || null;
try {
// Get LangChain-compatible Cloudflare model with tools bound
const model = getCloudflareModel(modelId);
const toolCalls = data.content
?.filter(c => c.type === 'tool_use')
.map(c => ({ name: c.name, input: c.input })) || [];
// Build messages with entity context and history
const langChainMessages = buildLangChainMessages(
sessionId,
messages,
systemPrompt
);
const usageRecord = trackUsage(sessionId, model, data.usage, {
inputText,
outputText,
toolCalls
});
console.log(`[Cloudflare API] REQUEST USAGE: ${getUsageSummary(usageRecord)}`);
console.log(`[Cloudflare API] Sending request via LangChain ChatCloudflare...`);
const fetchStart = Date.now();
// Log cumulative session usage if session exists
if (sessionId) {
const sessionStats = getSessionUsage(sessionId);
if (sessionStats) {
console.log(`[Cloudflare API] SESSION TOTALS (${sessionStats.requestCount} requests):`);
console.log(`[Cloudflare API] Total input: ${sessionStats.totalInputTokens} tokens`);
console.log(`[Cloudflare API] Total output: ${sessionStats.totalOutputTokens} tokens`);
console.log(`[Cloudflare API] Total cost: ${formatCost(sessionStats.totalCost)}`);
}
}
}
// Invoke model
const response = await model.invoke(langChainMessages);
// If session exists and response is successful, store messages
if (sessionId && response.ok && data.content) {
const session = getSession(sessionId);
if (session) {
// Store the user message if it was new (only if it's a string, not tool results)
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
addMessage(sessionId, {
role: 'user',
content: userMessage.content
});
console.log(`[Cloudflare API] Stored user message to session`);
const fetchDuration = Date.now() - fetchStart;
console.log(`[Cloudflare API] Response received in ${fetchDuration}ms`);
// Convert to Claude API format for client compatibility
const data = aiMessageToClaudeResponse(response, modelId);
console.log(`[Cloudflare API] Response converted. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`);
// Track and log token usage
if (data.usage) {
const userMessage = messages?.[messages.length - 1];
const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null;
const outputText = data.content
?.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n') || null;
const toolCalls = data.content
?.filter(c => c.type === 'tool_use')
.map(c => ({ name: c.name, input: c.input })) || [];
const contextLimit = CLOUDFLARE_CONTEXT_LIMITS[modelId] || 32000;
const usageRecord = trackUsage(sessionId, modelId, data.usage, {
inputText,
outputText,
toolCalls,
contextLimit
});
console.log(`[Cloudflare API] REQUEST USAGE: ${getUsageSummary(usageRecord)}`);
if (sessionId) {
const sessionStats = getSessionUsage(sessionId);
if (sessionStats) {
console.log(`[Cloudflare API] SESSION TOTALS (${sessionStats.requestCount} requests):`);
console.log(`[Cloudflare API] Total input: ${sessionStats.totalInputTokens} tokens`);
console.log(`[Cloudflare API] Total output: ${sessionStats.totalOutputTokens} tokens`);
console.log(`[Cloudflare API] Total cost: ${formatCost(sessionStats.totalCost)}`);
console.log(`[Cloudflare API] Context: ${sessionStats.contextUsed}/${sessionStats.contextLimit} (${sessionStats.contextPercent.toFixed(1)}%)`);
// Context warnings
if (sessionStats.contextPercent >= 95) {
console.error(`[Cloudflare API] ⚠️ CONTEXT CRITICAL: ${sessionStats.contextPercent.toFixed(0)}% used! Consider clearing conversation.`);
} else if (sessionStats.contextPercent >= 80) {
console.warn(`[Cloudflare API] ⚠️ CONTEXT WARNING: ${sessionStats.contextPercent.toFixed(0)}% of context window used`);
}
}
}
}
// Store the assistant response (text only, not tool use blocks)
const assistantContent = data.content
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
// Store messages to session
if (sessionId && data.content) {
const session = getSession(sessionId);
if (session) {
const userMessage = messages?.[messages.length - 1];
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
addMessage(sessionId, {
role: 'user',
content: userMessage.content
});
console.log(`[Cloudflare API] Stored user message to session`);
}
if (assistantContent) {
addMessage(sessionId, {
role: 'assistant',
content: assistantContent
});
console.log(`[Cloudflare API] Stored assistant response to session (${assistantContent.length} chars)`);
const assistantContent = data.content
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
if (assistantContent) {
addMessage(sessionId, {
role: 'assistant',
content: assistantContent
});
console.log(`[Cloudflare API] Stored assistant response to session (${assistantContent.length} chars)`);
}
}
}
}
}
const totalDuration = Date.now() - requestStart;
console.log(`[Cloudflare API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
res.status(response.status).json(data);
} catch (error) {
const totalDuration = Date.now() - requestStart;
console.error(`[Cloudflare API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
console.error(`[Cloudflare API] Error:`, error);
console.error(`[Cloudflare API] Error message:`, error.message);
console.error(`[Cloudflare API] Error stack:`, error.stack);
res.status(500).json({ error: "Failed to proxy request to Cloudflare API", details: error.message });
}
const totalDuration = Date.now() - requestStart;
console.log(`[Cloudflare API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
res.json(data);
} catch (error) {
const totalDuration = Date.now() - requestStart;
console.error(`[Cloudflare API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
console.error(`[Cloudflare API] Error:`, error);
console.error(`[Cloudflare API] Error message:`, error.message);
res.status(500).json({ error: "Failed to call Cloudflare API", details: error.message });
}
});
export default router;

View File

@ -1,131 +1,51 @@
import { Router } from "express";
import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js";
import { getOllamaUrl } from "../services/providerConfig.js";
import { getSession, addMessage } from "../services/sessionStore.js";
import {
claudeToolsToOllama,
claudeMessagesToOllama,
ollamaResponseToClaude
} from "../services/toolConverter.js";
getOllamaModel,
buildLangChainMessages,
aiMessageToClaudeResponse
} from "../services/langchainModels.js";
const router = Router();
/**
* Build entity context string for the system prompt
*/
function buildEntityContext(entities) {
if (!entities || entities.length === 0) {
return "\n\nThe diagram is currently empty.";
}
const entityList = entities.map(e => {
const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown';
const pos = e.position || { x: 0, y: 0, z: 0 };
return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`;
}).join('\n');
return `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`;
}
/**
* Handle Ollama chat requests
* Accepts Claude-format requests and converts them to Ollama format
*/
router.post("/*path", async (req, res) => {
const requestStart = Date.now();
console.log(`[Ollama API] ========== REQUEST START ==========`);
const ollamaUrl = getOllamaUrl();
console.log(`[Ollama API] Using Ollama at: ${ollamaUrl}`);
// Extract request body (Claude format)
const { sessionId, model, max_tokens, system, tools, messages } = req.body;
const { sessionId, model: modelId, max_tokens, system: systemPrompt, messages } = req.body;
console.log(`[Ollama API] Session ID: ${sessionId || 'none'}`);
console.log(`[Ollama API] Model: ${model}`);
console.log(`[Ollama API] Model: ${modelId}`);
console.log(`[Ollama API] Messages count: ${messages?.length || 0}`);
// Build system prompt with entity context
let systemPrompt = system || '';
if (sessionId) {
const session = getSession(sessionId);
if (session) {
console.log(`[Ollama API] Session found: ${session.entities.length} entities, ${session.conversationHistory.length} messages in history`);
// Inject entity context into system prompt
const entityContext = buildEntityContext(session.entities);
console.log(`[Ollama API] Entity context added (${entityContext.length} chars)`);
systemPrompt += entityContext;
// Get conversation history and merge with current messages
const historyMessages = getConversationForAPI(sessionId);
if (historyMessages.length > 0 && messages) {
const currentContent = messages[messages.length - 1]?.content;
const filteredHistory = historyMessages.filter(msg => msg.content !== currentContent);
messages.unshift(...filteredHistory);
console.log(`[Ollama API] Merged ${filteredHistory.length} history messages`);
}
} else {
console.log(`[Ollama API] WARNING: Session ${sessionId} not found`);
}
}
// Convert to Ollama format
const ollamaMessages = claudeMessagesToOllama(messages || [], systemPrompt);
const ollamaTools = claudeToolsToOllama(tools);
const ollamaRequest = {
model: model,
messages: ollamaMessages,
stream: false,
options: {
num_predict: max_tokens || 1024
}
};
// Only add tools if there are any
if (ollamaTools.length > 0) {
ollamaRequest.tools = ollamaTools;
}
console.log(`[Ollama API] Converted to Ollama format: ${ollamaMessages.length} messages, ${ollamaTools.length} tools`);
try {
console.log(`[Ollama API] Sending request to Ollama...`);
// Get LangChain model with tools bound
const model = getOllamaModel(modelId);
// Build messages with entity context and history
const langChainMessages = buildLangChainMessages(
sessionId,
messages,
systemPrompt
);
console.log(`[Ollama API] Sending request via LangChain...`);
const fetchStart = Date.now();
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(ollamaRequest)
});
// Invoke model
const response = await model.invoke(langChainMessages);
const fetchDuration = Date.now() - fetchStart;
console.log(`[Ollama API] Response received in ${fetchDuration}ms, status: ${response.status}`);
console.log(`[Ollama API] Response received in ${fetchDuration}ms`);
if (!response.ok) {
const errorText = await response.text();
console.error(`[Ollama API] Error response:`, errorText);
return res.status(response.status).json({
error: `Ollama API error: ${response.status}`,
details: errorText
});
}
const ollamaData = await response.json();
console.log(`[Ollama API] Response parsed. Done: ${ollamaData.done}, model: ${ollamaData.model}`);
// Convert response back to Claude format
const claudeResponse = ollamaResponseToClaude(ollamaData);
console.log(`[Ollama API] Converted to Claude format. Stop reason: ${claudeResponse.stop_reason}, content blocks: ${claudeResponse.content.length}`);
// Convert to Claude API format for client compatibility
const claudeResponse = aiMessageToClaudeResponse(response, modelId);
console.log(`[Ollama API] Response converted. Stop reason: ${claudeResponse.stop_reason}, content blocks: ${claudeResponse.content.length}`);
// Store messages to session if applicable
if (sessionId && claudeResponse.content) {
const session = getSession(sessionId);
if (session) {
// Store the user message if it was new
const userMessage = messages?.[messages.length - 1];
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
addMessage(sessionId, {
@ -135,7 +55,6 @@ router.post("/*path", async (req, res) => {
console.log(`[Ollama API] Stored user message to session`);
}
// Store the assistant response (text only)
const assistantContent = claudeResponse.content
.filter(c => c.type === 'text')
.map(c => c.text)
@ -161,15 +80,15 @@ router.post("/*path", async (req, res) => {
console.error(`[Ollama API] Error:`, error);
// Check if it's a connection error
if (error.cause?.code === 'ECONNREFUSED') {
if (error.cause?.code === 'ECONNREFUSED' || error.message?.includes('ECONNREFUSED')) {
return res.status(503).json({
error: "Ollama is not running",
details: `Could not connect to Ollama at ${ollamaUrl}. Make sure Ollama is installed and running.`
details: `Could not connect to Ollama. Make sure Ollama is installed and running.`
});
}
res.status(500).json({
error: "Failed to proxy request to Ollama",
error: "Failed to call Ollama",
details: error.message
});
}

View File

@ -4,10 +4,13 @@ import {
getSession,
findSessionByDiagram,
syncEntities,
syncCameraPosition,
addMessage,
clearHistory,
deleteSession,
getStats
getStats,
getPreferences,
setPreference
} from "../services/sessionStore.js";
import { getSessionUsage, getGlobalUsage, formatCost } from "../services/usageTracker.js";
@ -125,6 +128,26 @@ router.put("/:id/sync", (req, res) => {
res.json({ success: true, entityCount: entities.length });
});
/**
* PUT /api/session/:id/camera
* Sync camera position from client to server
*/
router.put("/:id/camera", (req, res) => {
const { cameraPosition } = req.body;
if (!cameraPosition) {
return res.status(400).json({ error: "cameraPosition is required" });
}
const session = syncCameraPosition(req.params.id, cameraPosition);
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
res.json({ success: true });
});
/**
* POST /api/session/:id/message
* Add a message to history (used after successful Claude response)
@ -173,4 +196,39 @@ router.delete("/:id", (req, res) => {
res.json({ success: true });
});
/**
* GET /api/session/:id/preferences
* Get session preferences
*/
router.get("/:id/preferences", (req, res) => {
const preferences = getPreferences(req.params.id);
if (preferences === null) {
return res.status(404).json({ error: "Session not found" });
}
res.json({ preferences });
});
/**
* PUT /api/session/:id/preferences
* Set a session preference
*/
router.put("/:id/preferences", (req, res) => {
const { key, value } = req.body;
if (!key) {
return res.status(400).json({ error: "key is required" });
}
const preferences = setPreference(req.params.id, key, value);
if (preferences === null) {
return res.status(404).json({ error: "Session not found" });
}
console.log(`[Session ${req.params.id}] Set preference: ${key} = ${value}`);
res.json({ success: true, preferences });
});
export default router;

View File

@ -0,0 +1,367 @@
/**
* Custom LangChain ChatModel for Cloudflare Workers AI
*
* Cloudflare Workers AI has limitations:
* - Does NOT support multi-turn tool conversations (error 3043)
* - Some models (Mistral) output tools as text: [TOOL_CALLS][{...}]
*
* This class handles these quirks to provide a LangChain-compatible interface.
*/
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import { AIMessage } from "@langchain/core/messages";
import { getCloudflareAccountId, getCloudflareApiToken } from "./providerConfig.js";
import { toolSchemas } from "./langchainTools.js";
import { zodToJsonSchema } from "zod-to-json-schema";
/**
* Try to repair and parse a potentially truncated JSON object
*/
function tryRepairAndParse(jsonStr) {
try {
return JSON.parse(jsonStr);
} catch (e) {
const repairs = [
jsonStr + '}',
jsonStr + '"}',
jsonStr + '}}',
jsonStr + '"}}',
jsonStr + ': null}}',
jsonStr + '": null}}'
];
for (const attempt of repairs) {
try {
const parsed = JSON.parse(attempt);
if (parsed.name) {
return parsed;
}
} catch (e2) {
// Continue trying
}
}
return null;
}
}
/**
* Parse tool calls from text response
* Handles: [TOOL_CALLS][{...}] and [Called tool: name({args})]
*/
function parseTextToolCalls(text) {
if (!text) return { cleanText: '', toolCalls: [] };
const toolCalls = [];
let cleanText = text;
// Format 1: [TOOL_CALLS][...] (Mistral native format)
const toolCallMatch = text.match(/\[TOOL_CALLS\]\s*(\[[\s\S]*)/);
if (toolCallMatch) {
const toolCallsJson = toolCallMatch[1];
// Try normal JSON.parse first
try {
const parsedCalls = JSON.parse(toolCallsJson);
if (Array.isArray(parsedCalls)) {
const validCalls = parsedCalls
.filter(call => call && call.name)
.map(call => ({
id: `toolu_cf_${Date.now()}_${toolCalls.length}`,
name: call.name,
args: call.arguments || {}
}));
cleanText = text.replace(/\[TOOL_CALLS\]\s*\[[\s\S]*/, '').trim();
return { cleanText, toolCalls: validCalls };
}
} catch (e) {
// Try multi-line format
}
// Try parsing as multiple single-element arrays on separate lines
const lines = toolCallsJson.split('\n');
const lineMatches = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed) && parsed.length > 0 && parsed[0].name) {
lineMatches.push({
id: `toolu_cf_${Date.now()}_${lineMatches.length}`,
name: parsed[0].name,
args: parsed[0].arguments || {}
});
}
} catch (e) {
const objMatch = trimmed.match(/\[\s*(\{[\s\S]*)/);
if (objMatch) {
const repaired = tryRepairAndParse(objMatch[1].replace(/\]\s*$/, ''));
if (repaired && repaired.name) {
lineMatches.push({
id: `toolu_cf_${Date.now()}_${lineMatches.length}`,
name: repaired.name,
args: repaired.arguments || {}
});
}
}
}
}
}
if (lineMatches.length > 0) {
cleanText = text.replace(/\[TOOL_CALLS\][\s\S]*/, '').trim();
return { cleanText, toolCalls: lineMatches };
}
// Extract individual tool calls using regex for truncated JSON
const startPattern = /\{"name"\s*:\s*"/g;
let match;
const toolCallStarts = [];
while ((match = startPattern.exec(toolCallsJson)) !== null) {
toolCallStarts.push(match.index);
}
for (let i = 0; i < toolCallStarts.length; i++) {
const start = toolCallStarts[i];
const end = toolCallStarts[i + 1] || toolCallsJson.length;
let segment = toolCallsJson.substring(start, end).replace(/,\s*$/, '');
const parsed = tryRepairAndParse(segment);
if (parsed && parsed.name) {
toolCalls.push({
id: `toolu_cf_${Date.now()}_${i}`,
name: parsed.name,
args: parsed.arguments || {}
});
}
}
cleanText = text.replace(/\[TOOL_CALLS\]\s*\[[\s\S]*/, '').trim();
if (toolCalls.length > 0) {
return { cleanText, toolCalls };
}
}
// Format 2: [Called tool: name({args})]
const calledToolPattern = /\[Called tool:\s*(\w+)\((\{[\s\S]*?\})\)\]/g;
let calledMatch;
while ((calledMatch = calledToolPattern.exec(text)) !== null) {
try {
const args = JSON.parse(calledMatch[2]);
toolCalls.push({
id: `toolu_cf_${Date.now()}_${toolCalls.length}`,
name: calledMatch[1],
args: args
});
cleanText = cleanText.replace(calledMatch[0], '');
} catch (e) {
const repaired = tryRepairAndParse(calledMatch[2]);
if (repaired) {
toolCalls.push({
id: `toolu_cf_${Date.now()}_${toolCalls.length}`,
name: calledMatch[1],
args: repaired
});
cleanText = cleanText.replace(calledMatch[0], '');
}
}
}
return { cleanText: cleanText.trim(), toolCalls };
}
/**
* Convert LangChain messages to Cloudflare format
* IMPORTANT: Converts tool history to text (Cloudflare limitation)
*/
function messagesToCloudflare(messages) {
const cfMessages = [];
for (const msg of messages) {
const msgType = msg.constructor.name;
if (msgType === 'SystemMessage') {
cfMessages.push({
role: "system",
content: msg.content
});
} else if (msgType === 'HumanMessage') {
cfMessages.push({
role: "user",
content: msg.content
});
} else if (msgType === 'AIMessage') {
// Convert tool calls to text for Cloudflare
let content = msg.content || '';
if (msg.tool_calls && msg.tool_calls.length > 0) {
const toolText = msg.tool_calls.map(tc =>
`[Called tool: ${tc.name}(${JSON.stringify(tc.args)})]`
).join('\n');
content = content ? `${content}\n${toolText}` : toolText;
}
if (content) {
cfMessages.push({
role: "assistant",
content: content
});
}
} else if (msgType === 'ToolMessage') {
// Convert tool results to user messages
cfMessages.push({
role: "user",
content: `[Tool Result (${msg.name}): ${msg.content}]`
});
}
}
return cfMessages;
}
/**
* ChatCloudflare - LangChain ChatModel for Cloudflare Workers AI
*/
export class ChatCloudflare extends BaseChatModel {
static lc_name() {
return "ChatCloudflare";
}
constructor(fields = {}) {
super(fields);
this.model = fields.model || "@cf/mistral/mistral-7b-instruct-v0.2";
this.accountId = fields.accountId || getCloudflareAccountId();
this.apiToken = fields.apiToken || getCloudflareApiToken();
this.maxTokens = fields.maxTokens || 1024;
this._tools = [];
}
_llmType() {
return "cloudflare";
}
/**
* Bind tools to this model instance
*/
bindTools(tools) {
const bound = new ChatCloudflare({
model: this.model,
accountId: this.accountId,
apiToken: this.apiToken,
maxTokens: this.maxTokens
});
// Convert tools to OpenAI format
bound._tools = tools.map(tool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: zodToJsonSchema(tool.schema, { target: "openApi3" })
}
}));
return bound;
}
async _generate(messages, options, runManager) {
const cfMessages = messagesToCloudflare(messages);
const requestBody = {
messages: cfMessages,
max_tokens: this.maxTokens
};
// Add tools if bound
if (this._tools.length > 0) {
requestBody.tools = this._tools;
}
const endpoint = `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/ai/run/${this.model}`;
console.log(`[ChatCloudflare] Sending request to: ${endpoint}`);
console.log(`[ChatCloudflare] Messages: ${cfMessages.length}, Tools: ${this._tools.length}`);
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.apiToken}`,
},
body: JSON.stringify(requestBody),
});
const cfData = await response.json();
if (!cfData.success) {
throw new Error(cfData.errors?.[0]?.message || "Cloudflare API error");
}
const result = cfData.result || cfData;
// Get tool calls from proper field or parse from text
let toolCalls = result.tool_calls || [];
let textResponse = result.response || '';
// Parse tool calls from text if not present natively
if (toolCalls.length === 0 && textResponse) {
const parsed = parseTextToolCalls(textResponse);
if (parsed.toolCalls.length > 0) {
toolCalls = parsed.toolCalls;
textResponse = parsed.cleanText;
}
} else {
// Convert native tool calls to LangChain format
toolCalls = toolCalls.map((tc, i) => ({
id: `toolu_cf_${Date.now()}_${i}`,
name: tc.name,
args: typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : (tc.arguments || {})
}));
}
// Create AIMessage with tool calls
const aiMessage = new AIMessage({
content: textResponse,
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
usage_metadata: {
input_tokens: result.usage?.prompt_tokens || 0,
output_tokens: result.usage?.completion_tokens || 0
}
});
return {
generations: [{
text: textResponse,
message: aiMessage,
}],
llmOutput: {
tokenUsage: {
promptTokens: result.usage?.prompt_tokens || 0,
completionTokens: result.usage?.completion_tokens || 0,
}
}
};
}
}
/**
* Get a Cloudflare model instance with tools bound
* @param {string} modelId - The Cloudflare model name
* @returns {ChatCloudflare} ChatCloudflare instance with tools
*/
export function getCloudflareModel(modelId) {
const model = new ChatCloudflare({ model: modelId });
const langchainTools = Object.values(toolSchemas).map(tool => ({
name: tool.name,
description: tool.description,
schema: tool.schema
}));
return model.bindTools(langchainTools);
}
export default ChatCloudflare;

View File

@ -0,0 +1,288 @@
/**
* LangChain Model Wrappers
*
* Provides unified model interfaces using LangChain.
* Handles tool binding and response format conversion.
*/
import { ChatAnthropic } from "@langchain/anthropic";
import { ChatOllama } from "@langchain/ollama";
import { HumanMessage, SystemMessage, AIMessage, ToolMessage } from "@langchain/core/messages";
import { toolSchemas } from "./langchainTools.js";
import { getSession, getConversationForAPI } from "./sessionStore.js";
import { getOllamaUrl } from "./providerConfig.js";
/**
* Convert tool schemas to LangChain tool format for bindTools()
* LangChain expects: { name, description, schema (Zod) }
*/
const langchainTools = Object.values(toolSchemas).map(tool => ({
name: tool.name,
description: tool.description,
schema: tool.schema
}));
/**
* Get a Claude model instance with tools bound
* @param {string} modelId - The Claude model ID
* @returns {object} ChatAnthropic instance with tools
*/
export function getClaudeModel(modelId) {
const model = new ChatAnthropic({
modelName: modelId,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
});
return model.bindTools(langchainTools);
}
/**
* Get an Ollama model instance with tools bound
* @param {string} modelId - The Ollama model name
* @returns {object} ChatOllama instance with tools
*/
export function getOllamaModel(modelId) {
const model = new ChatOllama({
model: modelId,
baseUrl: getOllamaUrl(),
});
return model.bindTools(langchainTools);
}
/**
* Build camera context string
* @param {object} cameraPosition - Camera position and orientation data
* @returns {string} Camera context string
*/
export function buildCameraContext(cameraPosition) {
if (!cameraPosition) {
return "";
}
const { position, forward, groundForward, groundRight } = cameraPosition;
if (!position) return "";
const p = position;
const gf = groundForward || { x: 0, z: 1 };
const gr = groundRight || { x: 1, z: 0 };
return `\n\n## User's Current View (World Coordinates)
Position: (${p.x?.toFixed(2)}, ${p.y?.toFixed(2)}, ${p.z?.toFixed(2)})
Looking: (${forward?.x?.toFixed(2) || 0}, ${forward?.y?.toFixed(2) || 0}, ${forward?.z?.toFixed(2) || 0})
Ground Forward: (${gf.x?.toFixed(2)}, 0, ${gf.z?.toFixed(2)})
Ground Right: (${gr.x?.toFixed(2)}, 0, ${gr.z?.toFixed(2)})
To place entities relative to user:
- FORWARD: add groundForward * distance to position
- RIGHT: add groundRight * distance to position
- LEFT: subtract groundRight * distance from position
- BACK: subtract groundForward * distance from position`;
}
/**
* Build entity context string for the system prompt
* @param {Array} entities - Array of diagram entities
* @param {object} cameraPosition - Optional camera position data
* @returns {string} Entity context string
*/
export function buildEntityContext(entities, cameraPosition = null) {
let context = "";
// Add camera context if available
context += buildCameraContext(cameraPosition);
// Add entity context
if (!entities || entities.length === 0) {
context += "\n\nThe diagram is currently empty.";
return context;
}
const entityList = entities.map(e => {
const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown';
const pos = e.position || { x: 0, y: 0, z: 0 };
return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`;
}).join('\n');
context += `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`;
return context;
}
/**
* Convert Claude-format messages to LangChain message objects
* @param {Array} messages - Messages in Claude format
* @returns {Array} Array of LangChain message objects
*/
export function claudeMessagesToLangChain(messages) {
const result = [];
// Track tool use IDs for tool results
const toolCallMap = new Map();
for (const msg of messages) {
if (msg.role === 'user') {
if (Array.isArray(msg.content)) {
// Handle tool results
for (const block of msg.content) {
if (block.type === 'text') {
result.push(new HumanMessage(block.text));
} else if (block.type === 'tool_result') {
// Get tool name from previous tool_use
const toolName = toolCallMap.get(block.tool_use_id) || 'unknown';
result.push(new ToolMessage({
content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content),
tool_call_id: block.tool_use_id,
name: toolName
}));
}
}
} else {
result.push(new HumanMessage(msg.content));
}
} else if (msg.role === 'assistant') {
if (Array.isArray(msg.content)) {
let textContent = '';
const toolCalls = [];
for (const block of msg.content) {
if (block.type === 'text') {
textContent += block.text;
} else if (block.type === 'tool_use') {
toolCallMap.set(block.id, block.name);
toolCalls.push({
id: block.id,
name: block.name,
args: block.input
});
}
}
const aiMessage = new AIMessage({
content: textContent,
tool_calls: toolCalls.length > 0 ? toolCalls : undefined
});
result.push(aiMessage);
} else {
result.push(new AIMessage(msg.content));
}
}
}
return result;
}
/**
* Build LangChain messages from session and request
* @param {string} sessionId - Session ID
* @param {Array} requestMessages - Messages from the request
* @param {string} systemPrompt - Base system prompt
* @returns {Array} Array of LangChain messages
*/
// Maximum number of history messages to include (to limit token usage)
const MAX_HISTORY_MESSAGES = 6; // 3 exchanges (user + assistant pairs)
export function buildLangChainMessages(sessionId, requestMessages, systemPrompt) {
const messages = [];
let entityContext = '';
if (sessionId) {
const session = getSession(sessionId);
if (session) {
entityContext = buildEntityContext(session.entities, session.cameraPosition);
// Get conversation history (limited to last few messages)
const historyMessages = getConversationForAPI(sessionId);
if (historyMessages.length > 0) {
// Filter out duplicates
const currentContent = requestMessages?.[requestMessages.length - 1]?.content;
let filteredHistory = historyMessages.filter(msg => msg.content !== currentContent);
// Limit to last N messages to reduce token usage
if (filteredHistory.length > MAX_HISTORY_MESSAGES) {
console.log(`[LangChain] Trimming history from ${filteredHistory.length} to ${MAX_HISTORY_MESSAGES} messages`);
filteredHistory = filteredHistory.slice(-MAX_HISTORY_MESSAGES);
}
// Convert history to LangChain format
const langChainHistory = claudeMessagesToLangChain(filteredHistory);
messages.push(...langChainHistory);
}
}
}
// Add system message at the beginning
if (systemPrompt || entityContext) {
messages.unshift(new SystemMessage((systemPrompt || '') + entityContext));
}
// Add current request messages
if (requestMessages && requestMessages.length > 0) {
const currentMessages = claudeMessagesToLangChain(requestMessages);
messages.push(...currentMessages);
}
return messages;
}
/**
* Convert LangChain AIMessage to Claude API response format
* @param {AIMessage} aiMessage - LangChain AIMessage
* @param {string} model - Model name
* @returns {object} Response in Claude API format
*/
export function aiMessageToClaudeResponse(aiMessage, model) {
const content = [];
// Add text content if present
if (aiMessage.content) {
content.push({
type: "text",
text: typeof aiMessage.content === 'string' ? aiMessage.content : aiMessage.content.toString()
});
}
// Add tool calls if present
if (aiMessage.tool_calls && aiMessage.tool_calls.length > 0) {
console.log('[LangChain] Tool calls in AIMessage:', JSON.stringify(aiMessage.tool_calls, null, 2));
for (let i = 0; i < aiMessage.tool_calls.length; i++) {
const tc = aiMessage.tool_calls[i];
console.log(`[LangChain] Tool call ${i}: name=${tc.name}, args=${JSON.stringify(tc.args)}`);
content.push({
type: "tool_use",
id: tc.id || `toolu_${Date.now()}_${i}`,
name: tc.name,
input: tc.args || {}
});
}
}
// Extract usage from response metadata
const usage = aiMessage.usage_metadata || aiMessage.response_metadata?.usage || {
input_tokens: 0,
output_tokens: 0
};
return {
id: `msg_${Date.now()}`,
type: "message",
role: "assistant",
content: content,
model: model,
stop_reason: aiMessage.tool_calls?.length > 0 ? "tool_use" : "end_turn",
usage: {
input_tokens: usage.input_tokens || usage.prompt_tokens || 0,
output_tokens: usage.output_tokens || usage.completion_tokens || 0,
cache_creation_input_tokens: usage.cache_creation_input_tokens || 0,
cache_read_input_tokens: usage.cache_read_input_tokens || 0
}
};
}
export default {
getClaudeModel,
getOllamaModel,
buildEntityContext,
claudeMessagesToLangChain,
buildLangChainMessages,
aiMessageToClaudeResponse,
langchainTools
};

View File

@ -0,0 +1,223 @@
/**
* LangChain Tool Definitions with Zod Schemas
*
* Single source of truth for all diagram AI tools.
* Uses Zod for type-safe schema definitions.
* Can be exported to Claude or OpenAI format.
*/
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
// Position schema (reusable) - from camera/user perspective
const positionSchema = z.object({
x: z.number().describe("Left (-) / Right (+) position from camera view"),
y: z.number().describe("Down (-) / Up (+) position (0 = floor, 1.5 = eye level)"),
z: z.number().describe("Backward (-) / Forward (+) position from camera view")
}).optional().describe("3D position from camera perspective. Example: (0, 1.5, 2) = directly in front at eye level");
// Scale schema - can be number or object, values are in METERS
const scaleSchema = z.union([
z.number().describe("Uniform size in meters (e.g., 1 = 1 meter cube, 0.5 = 50cm cube)"),
z.object({
x: z.number().describe("Width in meters"),
y: z.number().describe("Height in meters"),
z: z.number().describe("Depth in meters")
}).describe("Size as {x: width, y: height, z: depth} in meters. Example: {x: 1, y: 0.1, z: 1} = 1m wide, 10cm tall, 1m deep")
]).optional().describe("Size in METERS. Use {x, y, z} for different width/height/depth.");
// Rotation schema - can be number or object
const rotationSchema = z.union([
z.number().describe("Y-axis rotation in degrees (e.g., 90 = turn right 90°, -90 = turn left 90°)"),
z.object({
x: z.number().describe("Pitch in degrees"),
y: z.number().describe("Yaw in degrees"),
z: z.number().describe("Roll in degrees")
}).describe("Full 3D rotation in degrees as {x, y, z}")
]).optional().describe("Rotation in degrees. Use a number for Y-axis rotation or {x, y, z} for full 3D rotation.");
// Tool definitions with Zod schemas
export const toolSchemas = {
create_entity: {
name: "create_entity",
description: "Create a 3D shape in the diagram. Use this to add new elements like boxes, spheres, cylinders, etc.",
schema: z.object({
shape: z.enum(["box", "sphere", "cylinder", "cone", "plane", "person"])
.describe("The type of 3D shape to create"),
color: z.string().optional()
.describe("Color name (red, blue, green, etc.) or hex code (#ff0000)"),
text: z.string().optional()
.describe("Text label to display on or near the entity"),
position: positionSchema
})
},
connect_entities: {
name: "connect_entities",
description: "Draw a connection line between two entities. Check useDefaultLabels preference first - if not set, ask user if they want default labels on connections.",
schema: z.object({
from: z.string().describe("ID or label of the source entity"),
to: z.string().describe("ID or label of the target entity"),
label: z.string().optional().describe("Optional label for the connection. If omitted, default label 'X to Y' is used based on useDefaultLabels preference."),
color: z.string().optional().describe("Color of the connection line")
})
},
list_entities: {
name: "list_entities",
description: "List all entities currently in the diagram. Use this to see what exists before connecting or modifying.",
schema: z.object({})
},
remove_entity: {
name: "remove_entity",
description: "Remove an entity from the diagram by its ID or label.",
schema: z.object({
target: z.string().describe("ID or label of the entity to remove")
})
},
modify_entity: {
name: "modify_entity",
description: "CALL THIS TOOL to modify an entity. You MUST call this - describing changes does nothing. Use for: resize, move, rename, recolor, rotate, change shape.",
schema: z.object({
target: z.string().describe("Label or ID of entity to modify (e.g., 'CDN', 'Server')"),
color: z.string().optional().describe("New color hex code from toolbox palette"),
text: z.string().optional()
.describe("New label text. Use empty string \"\" to remove the label."),
shape: z.enum(["box", "sphere", "cylinder", "cone", "plane", "person"]).optional()
.describe("New shape for the entity"),
position: positionSchema,
scale: scaleSchema,
rotation: rotationSchema
})
},
modify_connection: {
name: "modify_connection",
description: "Modify a connection's label or color. Connections can be identified by their label or by specifying the from/to entities.",
schema: z.object({
target: z.string().optional()
.describe("Label of the connection to modify, or use from/to to identify it"),
from: z.string().optional()
.describe("ID or label of the source entity (alternative to target)"),
to: z.string().optional()
.describe("ID or label of the destination entity (alternative to target)"),
text: z.string().optional()
.describe("New label text for the connection. Use empty string \"\" to remove the label."),
color: z.string().optional()
.describe("New color for the connection")
})
},
clear_diagram: {
name: "clear_diagram",
description: "DESTRUCTIVE: Permanently delete ALL entities from the diagram and clear the session. This cannot be undone. IMPORTANT: Before calling this tool, you MUST first ask the user to confirm. Only call this tool with confirmed=true AFTER the user explicitly confirms.",
schema: z.object({
confirmed: z.boolean()
.describe("Must be true to execute. Only set to true after user has explicitly confirmed the deletion.")
})
},
get_camera_position: {
name: "get_camera_position",
description: "Get the current camera/viewer position and orientation in the 3D scene. Use this to understand where the user is looking and to position new entities relative to their view.",
schema: z.object({})
},
list_models: {
name: "list_models",
description: "List all available AI models that can be used for this conversation.",
schema: z.object({})
},
get_current_model: {
name: "get_current_model",
description: "Get information about the currently active AI model.",
schema: z.object({})
},
set_model: {
name: "set_model",
description: "Change the AI model. Use the model name from list_models.",
schema: z.object({
model_id: z.string()
.describe("Model name like 'Claude Opus 4', 'Hermes 2 Pro (CF)', 'Mistral Small 3.1 (CF)', etc.")
})
},
clear_conversation: {
name: "clear_conversation",
description: "Clear the conversation history to start fresh. This preserves the diagram entities but clears chat history.",
schema: z.object({})
},
search_wikipedia: {
name: "search_wikipedia",
description: "Search Wikipedia for information about a topic. Use this to research concepts, architectures, technologies, or anything else that would help create more accurate and detailed diagrams.",
schema: z.object({
query: z.string()
.describe("The topic or concept to search for (e.g., 'microservices architecture', 'neural network', 'kubernetes')")
})
},
set_connection_label_preference: {
name: "set_connection_label_preference",
description: "Set whether connections should have default labels. Call this after asking the user their preference.",
schema: z.object({
use_default_labels: z.boolean()
.describe("true = create labels like 'Server to Database', false = no labels on connections")
})
},
get_connection_label_preference: {
name: "get_connection_label_preference",
description: "Check if the user has set a preference for connection labels. Returns the preference or null if not set.",
schema: z.object({})
}
};
/**
* Convert tool schema to Claude/Anthropic format
* @param {object} toolDef - Tool definition with name, description, and Zod schema
* @returns {object} Tool in Claude format
*/
function toClaudeFormat(toolDef) {
return {
name: toolDef.name,
description: toolDef.description,
input_schema: zodToJsonSchema(toolDef.schema, { target: "openApi3" })
};
}
/**
* Convert tool schema to OpenAI/Ollama/Cloudflare format
* @param {object} toolDef - Tool definition with name, description, and Zod schema
* @returns {object} Tool in OpenAI function format
*/
function toOpenAIFormat(toolDef) {
return {
type: "function",
function: {
name: toolDef.name,
description: toolDef.description,
parameters: zodToJsonSchema(toolDef.schema, { target: "openApi3" })
}
};
}
// Export tools in different formats
export const claudeTools = Object.values(toolSchemas).map(toClaudeFormat);
export const openAITools = Object.values(toolSchemas).map(toOpenAIFormat);
// For backwards compatibility - alias
export const ollamaTools = openAITools;
export const cloudflareTools = openAITools;
export default {
toolSchemas,
claudeTools,
openAITools,
ollamaTools,
cloudflareTools
};

View File

@ -11,6 +11,8 @@ import { v4 as uuidv4 } from 'uuid';
// diagramId: string,
// conversationHistory: Array<{role, content, toolResults?, timestamp}>,
// entities: Array<{id, template, text, color, position}>,
// cameraPosition: { position: {x,y,z}, forward: {x,y,z}, groundForward: {x,y,z}, groundRight: {x,y,z} },
// preferences: { useDefaultConnectionLabels?: boolean },
// createdAt: Date,
// lastAccess: Date
// }
@ -30,6 +32,8 @@ export function createSession(diagramId) {
diagramId,
conversationHistory: [],
entities: [],
cameraPosition: null,
preferences: {},
createdAt: new Date(),
lastAccess: new Date()
};
@ -73,6 +77,18 @@ export function syncEntities(sessionId, entities) {
return session;
}
/**
* Update camera position for a session
*/
export function syncCameraPosition(sessionId, cameraPosition) {
const session = sessions.get(sessionId);
if (!session) return null;
session.cameraPosition = cameraPosition;
session.lastAccess = new Date();
return session;
}
/**
* Add a message to conversation history
*/
@ -156,3 +172,28 @@ export function getStats(includeDetails = false) {
}))
};
}
/**
* Get session preferences
*/
export function getPreferences(sessionId) {
const session = sessions.get(sessionId);
if (!session) return null;
session.lastAccess = new Date();
return session.preferences || {};
}
/**
* Set a session preference
*/
export function setPreference(sessionId, key, value) {
const session = sessions.get(sessionId);
if (!session) return null;
if (!session.preferences) {
session.preferences = {};
}
session.preferences[key] = value;
session.lastAccess = new Date();
return session.preferences;
}

View File

@ -1,663 +1,17 @@
/**
* Tool Format Converter
* Converts between Claude and Ollama tool/function formats
* Tool Format Converter (DEPRECATED)
*
* This file has been superseded by LangChain integration:
* - Tool definitions: server/services/langchainTools.js
* - Model wrappers: server/services/langchainModels.js
* - Cloudflare-specific: server/services/ChatCloudflare.js
*
* LangChain handles message format conversion automatically via:
* - ChatAnthropic (Claude)
* - ChatOllama (Ollama)
* - ChatCloudflare (custom, see ChatCloudflare.js)
*
* This file is kept for reference only and can be safely deleted.
*/
/**
* Convert Claude tool definition to Ollama function format
*
* Claude format:
* { name: "...", description: "...", input_schema: { type: "object", properties: {...} } }
*
* Ollama format:
* { type: "function", function: { name: "...", description: "...", parameters: {...} } }
*
* @param {object} claudeTool - Tool in Claude format
* @returns {object} Tool in Ollama format
*/
export function claudeToolToOllama(claudeTool) {
return {
type: "function",
function: {
name: claudeTool.name,
description: claudeTool.description,
parameters: claudeTool.input_schema
}
};
}
/**
* Convert array of Claude tools to Ollama format
* @param {Array} claudeTools - Array of Claude tool definitions
* @returns {Array} Array of Ollama function definitions
*/
export function claudeToolsToOllama(claudeTools) {
if (!claudeTools || !Array.isArray(claudeTools)) {
return [];
}
return claudeTools.map(claudeToolToOllama);
}
/**
* Convert Ollama tool call to Claude format
*
* Ollama format (in message):
* { tool_calls: [{ function: { name: "...", arguments: {...} } }] }
*
* Claude format:
* { type: "tool_use", id: "...", name: "...", input: {...} }
*
* @param {object} ollamaToolCall - Tool call from Ollama response
* @param {number} index - Index for generating unique ID
* @returns {object} Tool call in Claude format
*/
export function ollamaToolCallToClaude(ollamaToolCall, index = 0) {
const func = ollamaToolCall.function;
// Parse arguments if it's a string
let input = func.arguments;
if (typeof input === 'string') {
try {
input = JSON.parse(input);
} catch (e) {
console.warn('[ToolConverter] Failed to parse tool arguments:', e);
input = {};
}
}
return {
type: "tool_use",
id: `toolu_ollama_${Date.now()}_${index}`,
name: func.name,
input: input || {}
};
}
/**
* Convert Claude tool result to Ollama format
*
* Claude format (in messages):
* { role: "user", content: [{ type: "tool_result", tool_use_id: "...", content: "..." }] }
*
* Ollama format:
* { role: "tool", content: "...", name: "..." }
*
* @param {object} claudeToolResult - Tool result in Claude format
* @param {string} toolName - Name of the tool (from previous tool_use)
* @returns {object} Tool result in Ollama message format
*/
export function claudeToolResultToOllama(claudeToolResult, toolName) {
let content = claudeToolResult.content;
// Stringify if it's an object
if (typeof content === 'object') {
content = JSON.stringify(content);
}
return {
role: "tool",
content: content,
name: toolName
};
}
/**
* Convert Claude messages array to Ollama format
* Handles regular messages and tool result messages
*
* @param {Array} claudeMessages - Messages in Claude format
* @param {string} systemPrompt - System prompt to prepend
* @returns {Array} Messages in Ollama format
*/
export function claudeMessagesToOllama(claudeMessages, systemPrompt) {
const ollamaMessages = [];
// Add system message if provided
if (systemPrompt) {
ollamaMessages.push({
role: "system",
content: systemPrompt
});
}
// Track tool names for tool results
const toolNameMap = new Map();
for (const msg of claudeMessages) {
if (msg.role === 'user') {
// Check if it's a tool result message
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === 'tool_result') {
const toolName = toolNameMap.get(block.tool_use_id) || 'unknown';
ollamaMessages.push(claudeToolResultToOllama(block, toolName));
} else if (block.type === 'text') {
ollamaMessages.push({
role: "user",
content: block.text
});
}
}
} else {
ollamaMessages.push({
role: "user",
content: msg.content
});
}
} else if (msg.role === 'assistant') {
// Handle assistant messages with potential tool calls
if (Array.isArray(msg.content)) {
let textContent = '';
const toolCalls = [];
for (const block of msg.content) {
if (block.type === 'text') {
textContent += block.text;
} else if (block.type === 'tool_use') {
// Track tool name for later tool results
toolNameMap.set(block.id, block.name);
toolCalls.push({
function: {
name: block.name,
// Ollama expects arguments as object, not string
arguments: block.input || {}
}
});
}
}
const assistantMsg = {
role: "assistant",
content: textContent || ""
};
if (toolCalls.length > 0) {
assistantMsg.tool_calls = toolCalls;
}
ollamaMessages.push(assistantMsg);
} else {
ollamaMessages.push({
role: "assistant",
content: msg.content
});
}
}
}
return ollamaMessages;
}
/**
* Convert Ollama response to Claude format
*
* @param {object} ollamaResponse - Response from Ollama API
* @returns {object} Response in Claude format
*/
export function ollamaResponseToClaude(ollamaResponse) {
const content = [];
const message = ollamaResponse.message;
// Add text content if present
if (message.content) {
content.push({
type: "text",
text: message.content
});
}
// Add tool calls if present
if (message.tool_calls && message.tool_calls.length > 0) {
for (let i = 0; i < message.tool_calls.length; i++) {
content.push(ollamaToolCallToClaude(message.tool_calls[i], i));
}
}
// Determine stop reason
let stopReason = "end_turn";
if (message.tool_calls && message.tool_calls.length > 0) {
stopReason = "tool_use";
} else if (ollamaResponse.done_reason === "length") {
stopReason = "max_tokens";
}
return {
id: `msg_ollama_${Date.now()}`,
type: "message",
role: "assistant",
content: content,
model: ollamaResponse.model,
stop_reason: stopReason,
usage: {
input_tokens: ollamaResponse.prompt_eval_count || 0,
output_tokens: ollamaResponse.eval_count || 0
}
};
}
// ============================================
// Cloudflare Workers AI Converters
// ============================================
/**
* Convert Claude tool definition to Cloudflare format
* Cloudflare uses OpenAI-compatible format
*
* Claude format:
* { name: "...", description: "...", input_schema: { type: "object", properties: {...} } }
*
* Cloudflare format:
* { type: "function", function: { name: "...", description: "...", parameters: {...} } }
*
* @param {object} claudeTool - Tool in Claude format
* @returns {object} Tool in Cloudflare format
*/
export function claudeToolToCloudflare(claudeTool) {
return {
type: "function",
function: {
name: claudeTool.name,
description: claudeTool.description,
parameters: claudeTool.input_schema
}
};
}
/**
* Convert array of Claude tools to Cloudflare format
* @param {Array} claudeTools - Array of Claude tool definitions
* @returns {Array} Array of Cloudflare function definitions
*/
export function claudeToolsToCloudflare(claudeTools) {
if (!claudeTools || !Array.isArray(claudeTools)) {
return [];
}
return claudeTools.map(claudeToolToCloudflare);
}
/**
* Convert Cloudflare tool call to Claude format
*
* Cloudflare format:
* { name: "...", arguments: {...} }
*
* Claude format:
* { type: "tool_use", id: "...", name: "...", input: {...} }
*
* @param {object} cfToolCall - Tool call from Cloudflare response
* @param {number} index - Index for generating unique ID
* @returns {object} Tool call in Claude format
*/
export function cloudflareToolCallToClaude(cfToolCall, index = 0) {
// Parse arguments if it's a string
let input = cfToolCall.arguments;
if (typeof input === 'string') {
try {
input = JSON.parse(input);
} catch (e) {
console.warn('[ToolConverter] Failed to parse Cloudflare tool arguments:', e);
input = {};
}
}
return {
type: "tool_use",
id: `toolu_cf_${Date.now()}_${index}`,
name: cfToolCall.name,
input: input || {}
};
}
/**
* Convert Claude messages array to Cloudflare format
* Cloudflare uses OpenAI-compatible message format
*
* IMPORTANT: Cloudflare Workers AI does NOT support multi-turn tool conversations.
* It crashes with error 3043 when conversation history contains tool_calls or tool results.
* We must strip tool call history and only keep text content from past messages.
*
* @param {Array} claudeMessages - Messages in Claude format
* @param {string} systemPrompt - System prompt to prepend
* @returns {Array} Messages in Cloudflare format
*/
export function claudeMessagesToCloudflare(claudeMessages, systemPrompt) {
const cfMessages = [];
// Add system message if provided
if (systemPrompt) {
cfMessages.push({
role: "system",
content: systemPrompt
});
}
// Cloudflare doesn't support tool call history in native format - convert to text
// so the model knows what tools were called and their results
for (const msg of claudeMessages) {
if (msg.role === 'user') {
if (Array.isArray(msg.content)) {
// Convert tool_result blocks to text summaries
const textParts = [];
for (const block of msg.content) {
if (block.type === 'text') {
textParts.push(block.text);
} else if (block.type === 'tool_result') {
// Convert tool result to readable text so model knows it was executed
textParts.push(`[Tool Result: ${block.content}]`);
}
}
if (textParts.length > 0) {
cfMessages.push({
role: "user",
content: textParts.join('\n')
});
}
} else {
cfMessages.push({
role: "user",
content: msg.content
});
}
} else if (msg.role === 'assistant') {
// For assistant messages, convert tool_use to text descriptions
const textParts = [];
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === 'text') {
textParts.push(block.text);
} else if (block.type === 'tool_use') {
// Convert tool call to readable text so model knows it called this
const argsStr = JSON.stringify(block.input || {});
textParts.push(`[Called tool: ${block.name}(${argsStr})]`);
}
}
} else {
textParts.push(msg.content || '');
}
// Also handle pre-converted messages that might have tool_calls property
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls) {
const name = tc.function?.name || tc.name || 'unknown';
const args = tc.function?.arguments || tc.arguments || '{}';
textParts.push(`[Called tool: ${name}(${typeof args === 'string' ? args : JSON.stringify(args)})]`);
}
}
const textContent = textParts.filter(t => t).join('\n');
if (textContent) {
cfMessages.push({
role: "assistant",
content: textContent
});
}
} else if (msg.role === 'tool') {
// Convert tool messages to user messages with result text
cfMessages.push({
role: "user",
content: `[Tool Result (${msg.name || 'unknown'}): ${msg.content}]`
});
}
}
return cfMessages;
}
/**
* Try to repair and parse a potentially truncated JSON object
* @param {string} jsonStr - Potentially incomplete JSON string
* @returns {object|null} - Parsed object or null if unparseable
*/
function tryRepairAndParse(jsonStr) {
// First try as-is
try {
return JSON.parse(jsonStr);
} catch (e) {
// Try adding closing brackets
const repairs = [
jsonStr + '}',
jsonStr + '"}',
jsonStr + '}}',
jsonStr + '"}}',
jsonStr + ': null}}',
jsonStr + '": null}}'
];
for (const attempt of repairs) {
try {
const parsed = JSON.parse(attempt);
if (parsed.name) { // Must have a name to be valid
return parsed;
}
} catch (e2) {
// Continue trying
}
}
return null;
}
}
/**
* Parse tool calls from text response
* Handles multiple formats:
* 1. Mistral native: [TOOL_CALLS][{"name": "...", "arguments": {...}}, ...]
* 2. History format: [Called tool: name({args})]
*
* This parser is resilient to truncation - it will extract as many valid tool calls
* as possible even if the JSON is incomplete.
*
* @param {string} text - Text response that may contain embedded tool calls
* @returns {object} - { cleanText: string, toolCalls: array }
*/
function parseTextToolCalls(text) {
if (!text) return { cleanText: '', toolCalls: [] };
const toolCalls = [];
let cleanText = text;
// Format 1: [TOOL_CALLS][...] (Mistral native format)
const toolCallMatch = text.match(/\[TOOL_CALLS\]\s*(\[[\s\S]*)/);
if (toolCallMatch) {
const toolCallsJson = toolCallMatch[1];
// First try normal JSON.parse (for complete responses)
try {
const parsedCalls = JSON.parse(toolCallsJson);
if (Array.isArray(parsedCalls)) {
const validCalls = parsedCalls
.filter(call => call && call.name)
.map(call => ({
name: call.name,
arguments: call.arguments || {}
}));
console.log(`[ToolConverter] Parsed ${validCalls.length} tool calls from [TOOL_CALLS] JSON`);
cleanText = text.replace(/\[TOOL_CALLS\]\s*\[[\s\S]*/, '').trim();
return { cleanText, toolCalls: validCalls };
}
} catch (e) {
console.log('[ToolConverter] [TOOL_CALLS] JSON incomplete, attempting to extract individual tool calls...');
}
// JSON is truncated - extract individual tool calls using regex
const toolCallStarts = [];
const startPattern = /\{"name"\s*:\s*"/g;
let match;
while ((match = startPattern.exec(toolCallsJson)) !== null) {
toolCallStarts.push(match.index);
}
console.log(`[ToolConverter] Found ${toolCallStarts.length} potential tool call starts in [TOOL_CALLS]`);
for (let i = 0; i < toolCallStarts.length; i++) {
const start = toolCallStarts[i];
const end = toolCallStarts[i + 1] || toolCallsJson.length;
let segment = toolCallsJson.substring(start, end).replace(/,\s*$/, '');
const parsed = tryRepairAndParse(segment);
if (parsed && parsed.name) {
toolCalls.push({
name: parsed.name,
arguments: parsed.arguments || {}
});
console.log(`[ToolConverter] Extracted tool call from [TOOL_CALLS]: ${parsed.name}`);
}
}
cleanText = text.replace(/\[TOOL_CALLS\]\s*\[[\s\S]*/, '').trim();
if (toolCalls.length > 0) {
console.log(`[ToolConverter] Extracted ${toolCalls.length} tool calls from [TOOL_CALLS] format`);
return { cleanText, toolCalls };
}
}
// Format 2: [Called tool: name({args})] (history format the model might mimic)
// Match patterns like: [Called tool: create_entity({"shape": "box", ...})]
const calledToolPattern = /\[Called tool:\s*(\w+)\((\{[\s\S]*?\})\)\]/g;
let calledMatch;
const calledToolMatches = [];
while ((calledMatch = calledToolPattern.exec(text)) !== null) {
calledToolMatches.push({
fullMatch: calledMatch[0],
name: calledMatch[1],
argsStr: calledMatch[2]
});
}
if (calledToolMatches.length > 0) {
console.log(`[ToolConverter] Found ${calledToolMatches.length} [Called tool:] format tool calls`);
for (const match of calledToolMatches) {
try {
const args = JSON.parse(match.argsStr);
toolCalls.push({
name: match.name,
arguments: args
});
console.log(`[ToolConverter] Extracted tool call from [Called tool:]: ${match.name}`);
// Remove this match from clean text
cleanText = cleanText.replace(match.fullMatch, '');
} catch (e) {
console.warn(`[ToolConverter] Failed to parse [Called tool:] args for ${match.name}:`, e.message);
// Try to repair the JSON
const repaired = tryRepairAndParse(match.argsStr);
if (repaired) {
toolCalls.push({
name: match.name,
arguments: repaired
});
console.log(`[ToolConverter] Repaired and extracted tool call: ${match.name}`);
cleanText = cleanText.replace(match.fullMatch, '');
}
}
}
cleanText = cleanText.trim();
if (toolCalls.length > 0) {
console.log(`[ToolConverter] Extracted ${toolCalls.length} tool calls from [Called tool:] format`);
return { cleanText, toolCalls };
}
}
// No tool calls found
return { cleanText: text, toolCalls: [] };
}
/**
* Convert Cloudflare response to Claude format
*
* Cloudflare response format:
* {
* result: {
* response: "text output",
* tool_calls: [{ name: "...", arguments: {...} }]
* },
* success: true
* }
*
* Note: Some models (like Mistral) output tool calls as text in format:
* [TOOL_CALLS][{...}]
*
* @param {object} cfResponse - Response from Cloudflare Workers AI API
* @param {string} model - Model name used
* @returns {object} Response in Claude format
*/
export function cloudflareResponseToClaude(cfResponse, model) {
const content = [];
const result = cfResponse.result || cfResponse;
// Get tool calls from proper field or parse from text
let toolCalls = result.tool_calls || [];
let textResponse = result.response || '';
// Log raw response for debugging
console.log(`[ToolConverter] Raw response (first 500 chars): ${textResponse.substring(0, 500)}`);
console.log(`[ToolConverter] Native tool_calls present: ${toolCalls.length}`);
// Check if tool calls are embedded in text response (Mistral format or history format)
if (toolCalls.length === 0 && textResponse) {
console.log(`[ToolConverter] No native tool_calls, parsing text response...`);
const parsed = parseTextToolCalls(textResponse);
console.log(`[ToolConverter] Parsed ${parsed.toolCalls.length} tool calls from text`);
if (parsed.toolCalls.length > 0) {
toolCalls = parsed.toolCalls;
textResponse = parsed.cleanText;
}
}
// Add text content if present (after removing tool calls)
if (textResponse) {
content.push({
type: "text",
text: textResponse
});
}
// Add tool calls if present
if (toolCalls.length > 0) {
for (let i = 0; i < toolCalls.length; i++) {
content.push(cloudflareToolCallToClaude(toolCalls[i], i));
}
}
// Determine stop reason
let stopReason = "end_turn";
if (toolCalls.length > 0) {
stopReason = "tool_use";
}
// Extract usage if available
const usage = {
input_tokens: result.usage?.prompt_tokens || result.usage?.input_tokens || 0,
output_tokens: result.usage?.completion_tokens || result.usage?.output_tokens || 0
};
return {
id: `msg_cf_${Date.now()}`,
type: "message",
role: "assistant",
content: content,
model: model,
stop_reason: stopReason,
usage: usage
};
}
export default {
claudeToolToOllama,
claudeToolsToOllama,
ollamaToolCallToClaude,
claudeToolResultToOllama,
claudeMessagesToOllama,
ollamaResponseToClaude,
// Cloudflare converters
claudeToolToCloudflare,
claudeToolsToCloudflare,
cloudflareToolCallToClaude,
claudeMessagesToCloudflare,
cloudflareResponseToClaude
};
export default {};

View File

@ -114,6 +114,7 @@ function calculateCost(model, usage) {
* @param {string} content.inputText - User input text
* @param {string} content.outputText - Assistant output text
* @param {array} content.toolCalls - Tool calls made
* @param {number} content.contextLimit - Model's context window limit
*/
export function trackUsage(sessionId, model, usage, content = {}) {
if (!usage) return null;
@ -150,7 +151,10 @@ export function trackUsage(sessionId, model, usage, content = {}) {
totalCost: 0,
requestCount: 0,
requests: [],
startTime: Date.now()
startTime: Date.now(),
// Context tracking
contextUsed: 0,
contextLimit: content.contextLimit || 32000
});
}
@ -163,6 +167,12 @@ export function trackUsage(sessionId, model, usage, content = {}) {
session.requestCount += 1;
session.requests.push(usageRecord);
// Update context tracking - input tokens represent current context size
session.contextUsed = usageRecord.inputTokens;
if (content.contextLimit) {
session.contextLimit = content.contextLimit;
}
// Keep only last 100 requests per session to limit memory
if (session.requests.length > 100) {
session.requests.shift();
@ -198,7 +208,18 @@ export function trackUsage(sessionId, model, usage, content = {}) {
* Get usage for a session
*/
export function getSessionUsage(sessionId) {
return sessionUsage.get(sessionId) || null;
const session = sessionUsage.get(sessionId);
if (!session) return null;
// Calculate context percentage
const contextPercent = session.contextLimit > 0
? (session.contextUsed / session.contextLimit) * 100
: 0;
return {
...session,
contextPercent
};
}
/**

View File

@ -110,7 +110,9 @@ export class DiagramManager {
this._logger.debug('chatCreateEntity', entity);
// Generate a default label if none is provided
if (!entity.text) {
// 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);
}
@ -157,6 +159,9 @@ export class DiagramManager {
if (updates.color !== undefined) {
diagramObject.color = updates.color;
}
if (updates.template !== undefined) {
diagramObject.template = updates.template;
}
if (updates.position !== undefined) {
diagramObject.position = updates.position;
}
@ -214,17 +219,22 @@ export class DiagramManager {
}
});
document.addEventListener('chatListEntities', () => {
this._logger.debug('chatListEntities');
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
position: obj.diagramEntity.position,
// Include from/to for connections
from: obj.diagramEntity.from,
to: obj.diagramEntity.to
}));
const responseEvent = new CustomEvent('chatListEntitiesResponse', {
detail: {entities},
detail: { entities, requestId },
bubbles: true
});
document.dispatchEvent(responseEvent);

View File

@ -1,6 +1,7 @@
import {DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
import {AbstractMesh, ActionEvent, Observable, Scene, Vector3, WebXRDefaultExperience, WebXRInputSource} from "@babylonjs/core";
import {InputTextView} from "../information/inputTextView";
import {SystemKeyboardInput} from "../information/systemKeyboardInput";
import {DefaultScene} from "../defaultScene";
import log from "loglevel";
import {Toolbox} from "../toolbox/toolbox";
@ -19,6 +20,8 @@ export class DiagramMenuManager {
public readonly toolbox: Toolbox;
private readonly _notifier: Observable<DiagramEvent>;
private readonly _inputTextView: InputTextView;
private readonly _systemKeyboardInput: SystemKeyboardInput;
private _useSystemKeyboard: boolean = false;
private readonly _vrConfigPanel: VRConfigPanel;
private _groupMenu: GroupMenu;
private readonly _scene: Scene;
@ -31,16 +34,22 @@ export class DiagramMenuManager {
this._scene = DefaultScene.Scene;
this._notifier = notifier;
this._inputTextView = new InputTextView(controllerObservable);
this._systemKeyboardInput = new SystemKeyboardInput();
this._vrConfigPanel = new VRConfigPanel(this._scene);
//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 = {
type: DiagramEventType.MODIFY,
entity: {id: evt.id, text: evt.text, type: DiagramEntityType.ENTITY}
}
this._notifier.notifyObservers(event, DiagramEventObserverMask.FROM_DB);
});
};
this._inputTextView.onTextObservable.add(handleTextEvent);
this._systemKeyboardInput.onTextObservable.add(handleTextEvent);
this.toolbox = new Toolbox(readyObservable);
if (viewOnly()) {
@ -87,7 +96,13 @@ export class DiagramMenuManager {
}
public editText(mesh: AbstractMesh) {
this._inputTextView.show(mesh);
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);
}
}
public activateResizeGizmo(mesh: AbstractMesh) {
@ -164,6 +179,21 @@ export class DiagramMenuManager {
public setXR(xr: WebXRDefaultExperience): void {
this._xr = xr;
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 {

View File

@ -220,6 +220,49 @@ export class DiagramObject {
}, DiagramEventObserverMask.TO_DB);
}
public set template(value: string) {
if (!this._diagramEntity || this._diagramEntity.template === value) {
return;
}
// Don't allow changing connections to shapes or vice versa
if (this._diagramEntity.template === '#connection-template' || value === '#connection-template') {
this._logger.warn('Cannot change between connection and shape templates');
return;
}
this._logger.debug('Changing template from', this._diagramEntity.template, 'to', value);
// Update the entity template
this._diagramEntity.template = value;
// Rebuild mesh with new template
// Must dispose old mesh FIRST, otherwise buildMeshFromDiagramEntity
// finds it by ID and returns the same mesh (which we then dispose!)
if (this._mesh) {
const actionManager = this._mesh.actionManager;
this._mesh.dispose();
this._mesh = null;
this._mesh = buildMeshFromDiagramEntity(this._diagramEntity, this._scene);
if (this._mesh) {
this._mesh.setParent(this._baseTransform);
this._mesh.position = Vector3.Zero();
this._mesh.rotation = Vector3.Zero();
if (actionManager) {
this._mesh.actionManager = actionManager;
}
} else {
this._logger.error('Failed to rebuild mesh with new template');
}
}
this._eventObservable.notifyObservers({
type: DiagramEventType.MODIFY,
entity: this._diagramEntity
}, DiagramEventObserverMask.TO_DB);
}
public set text(value: string) {
if (this._label) {
this._label.dispose();

View 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;
}
}

View File

@ -1,5 +1,5 @@
import React, {useEffect, useRef, useState} from "react";
import {ActionIcon, Alert, Box, CloseButton, Group, Paper, ScrollArea, Text, Textarea, Tooltip, UnstyledButton} from "@mantine/core";
import {ActionIcon, Alert, Badge, Box, CloseButton, Group, Paper, ScrollArea, Text, Textarea, Tooltip, UnstyledButton} from "@mantine/core";
import {IconAlertCircle, IconCoins, IconRobot, IconSend, IconTrash} from "@tabler/icons-react";
import ChatMessage from "./ChatMessage";
import UsageDetailModal from "./UsageDetailModal";
@ -177,6 +177,8 @@ export default function ChatPanel({width = 400, onClose}: ChatPanelProps) {
setError(err instanceof Error ? err.message : 'Failed to send message');
} finally {
setIsLoading(false);
// Refocus the input after message completes
textareaRef.current?.focus();
}
};
@ -226,6 +228,20 @@ export default function ChatPanel({width = 400, onClose}: ChatPanelProps) {
</UnstyledButton>
</Tooltip>
)}
{usage && usage.contextPercent !== undefined && (
<Tooltip
label={`${(usage.contextUsed || 0).toLocaleString()} / ${(usage.contextLimit || 0).toLocaleString()} tokens`}
position="bottom"
>
<Badge
size="sm"
variant="light"
color={usage.contextPercent > 80 ? 'red' : usage.contextPercent > 50 ? 'yellow' : 'green'}
>
{usage.contextPercent.toFixed(0)}%
</Badge>
</Tooltip>
)}
<Tooltip label="Clear conversation" position="bottom">
<ActionIcon
variant="subtle"

View File

@ -1,5 +1,5 @@
import {ChatMessage, CreateSessionResponse, DiagramSession, DiagramToolCall, SessionEntity, SessionUsage, SyncEntitiesResponse, ToolResult} from "../types/chatTypes";
import {clearDiagram, connectEntities, createEntity, getCameraPosition, listEntities, modifyConnection, modifyEntity, removeEntity} from "./entityBridge";
import {clearDiagram, connectEntities, createEntity, getCameraPosition, getCameraForSync, listEntities, modifyConnection, modifyEntity, removeEntity} from "./entityBridge";
import {v4 as uuidv4} from 'uuid';
import log from 'loglevel';
@ -17,84 +17,97 @@ export interface ModelInfo {
name: string;
description: string;
provider: AIProvider;
contextLimit: number; // Maximum context window size in tokens
}
const AVAILABLE_MODELS: ModelInfo[] = [
// Claude models
// Claude models - 200K context
{
id: 'claude-sonnet-4-20250514',
name: 'Claude Sonnet 4',
description: 'Balanced performance and speed (default)',
provider: 'claude'
provider: 'claude',
contextLimit: 200000
},
{
id: 'claude-opus-4-20250514',
name: 'Claude Opus 4',
description: 'Most capable, best for complex tasks',
provider: 'claude'
provider: 'claude',
contextLimit: 200000
},
{
id: 'claude-haiku-3-5-20241022',
name: 'Claude Haiku 3.5',
description: 'Fastest responses, good for simple tasks',
provider: 'claude'
provider: 'claude',
contextLimit: 200000
},
// Cloudflare Workers AI models - with tool support
{
id: '@cf/mistralai/mistral-small-3.1-24b-instruct',
name: 'Mistral Small 3.1 (CF)',
description: 'Best CF model - supports diagram tools',
provider: 'cloudflare'
provider: 'cloudflare',
contextLimit: 32000
},
{
id: '@hf/nousresearch/hermes-2-pro-mistral-7b',
name: 'Hermes 2 Pro (CF)',
description: 'Lightweight - supports diagram tools',
provider: 'cloudflare'
provider: 'cloudflare',
contextLimit: 8000
},
// Cloudflare models WITHOUT tool support - chat only
{
id: '@cf/meta/llama-3.3-70b-instruct-fp8-fast',
name: 'Llama 3.3 70B (CF)',
description: 'Powerful but NO tool support',
provider: 'cloudflare'
provider: 'cloudflare',
contextLimit: 128000
},
{
id: '@cf/meta/llama-3.1-8b-instruct',
name: 'Llama 3.1 8B (CF)',
description: 'Fast/cheap but NO tool support',
provider: 'cloudflare'
provider: 'cloudflare',
contextLimit: 128000
},
{
id: '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b',
name: 'DeepSeek R1 (CF)',
description: 'Reasoning but NO tool support',
provider: 'cloudflare'
provider: 'cloudflare',
contextLimit: 32000
},
{
id: '@cf/qwen/qwen2.5-coder-32b-instruct',
name: 'Qwen 2.5 Coder (CF)',
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',
name: 'Llama 3.1',
description: 'Local model with function calling support',
provider: 'ollama'
provider: 'ollama',
contextLimit: 128000
},
{
id: 'mistral',
name: 'Mistral',
description: 'Fast local model with good tool support',
provider: 'ollama'
provider: 'ollama',
contextLimit: 32000
},
{
id: 'qwen2.5',
name: 'Qwen 2.5',
description: 'Capable local model with function calling',
provider: 'ollama'
provider: 'ollama',
contextLimit: 32000
}
];
@ -131,14 +144,26 @@ export function getCurrentModel(): ModelInfo {
/**
* Set current model
*/
export function setCurrentModel(modelId: string): boolean {
const model = AVAILABLE_MODELS.find(m => m.id === modelId);
export function setCurrentModel(modelIdOrName: string): boolean {
// Try to find by exact ID first
let model = AVAILABLE_MODELS.find(m => m.id === modelIdOrName);
// If not found, try to match by display name (case-insensitive)
if (!model) {
const searchLower = modelIdOrName.toLowerCase();
model = AVAILABLE_MODELS.find(m =>
m.name.toLowerCase() === searchLower ||
m.name.toLowerCase().includes(searchLower) ||
m.id.toLowerCase().includes(searchLower)
);
}
if (model) {
currentModelId = modelId;
logger.info('Model changed to:', model.name);
currentModelId = model.id;
logger.info('Model changed to:', model.name, `(${model.id})`);
return true;
}
logger.warn('Invalid model ID:', modelId);
logger.warn('Invalid model ID or name:', modelIdOrName);
return false;
}
@ -188,6 +213,31 @@ export async function syncEntitiesToSession(entities: SessionEntity[]): Promise<
}
}
/**
* Sync camera position to the current session
*/
export async function syncCameraToSession(): Promise<void> {
if (!currentSessionId) {
return;
}
const cameraPosition = await getCameraForSync();
if (!cameraPosition) {
console.warn('Failed to get camera position for sync');
return;
}
const response = await fetch(`/api/session/${currentSessionId}/camera`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cameraPosition })
});
if (!response.ok) {
console.error('Failed to sync camera position:', response.status);
}
}
/**
* Get session with conversation history
*/
@ -218,6 +268,48 @@ export async function clearSessionHistory(): Promise<void> {
});
}
/**
* Get a session preference
*/
export async function getSessionPreference(key: string): Promise<unknown> {
if (!currentSessionId) {
return undefined;
}
try {
const response = await fetch(`/api/session/${currentSessionId}/preferences`);
if (!response.ok) {
return undefined;
}
const data = await response.json();
return data.preferences?.[key];
} catch (err) {
logger.error('Failed to get session preference:', err);
return undefined;
}
}
/**
* Set a session preference
*/
export async function setSessionPreference(key: string, value: unknown): Promise<boolean> {
if (!currentSessionId) {
return false;
}
try {
const response = await fetch(`/api/session/${currentSessionId}/preferences`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value })
});
return response.ok;
} catch (err) {
logger.error('Failed to set session preference:', err);
return false;
}
}
/**
* Get token usage for current session
*/
@ -238,18 +330,51 @@ export async function getSessionUsage(): Promise<SessionUsage | null> {
}
}
const SYSTEM_PROMPT = `You are a 3D diagram assistant. You MUST use tools to perform actions - never just describe what you would do.
const SYSTEM_PROMPT = `You are a 3D diagram assistant that EXECUTES actions using tools. You cannot modify the diagram by describing changes - you MUST call tools.
## CRITICAL RULES
1. When the user asks to create, add, or make something CALL create_entity tool
2. When the user asks to connect things CALL connect_entities tool
3. When the user asks to change, modify, move, resize, or rotate CALL modify_entity tool
4. When the user asks to remove or delete CALL remove_entity tool
5. When the user asks what exists or to list CALL list_entities tool
6. When the user uses directions (left, right, forward, in front of me) CALL get_camera_position FIRST
7. When diagramming unfamiliar topics CALL search_wikipedia FIRST to research the concept
## CRITICAL: YOU MUST CALL TOOLS
**NEVER say "I have modified..." or "The entity has been..." without ACTUALLY calling a tool.**
**If you don't call a tool, NOTHING happens. Your words alone cannot change the diagram.**
DO NOT just describe actions. DO NOT say "I will create..." without calling a tool. ALWAYS call the appropriate tool.
When the user requests ANY action:
1. CREATE/ADD/MAKE CALL create_entity
2. CONNECT/LINK CALL connect_entities
3. CHANGE/MODIFY/MOVE/RESIZE/SCALE/ROTATE/RENAME CALL modify_entity
4. CHANGE CONNECTION CALL modify_connection
5. REMOVE/DELETE CALL remove_entity
6. LIST/SHOW/WHAT EXISTS CALL list_entities
7. DIRECTIONS (left, right, forward) CALL get_camera_position FIRST
8. UNFAMILIAR TOPIC CALL search_wikipedia FIRST
## WRONG (does nothing):
"I've resized the CDN entity to 0.1m high."
## CORRECT (actually works):
Call modify_entity with: target="CDN", scale={x: 1, y: 0.1, z: 1}
## Scale Parameter
- scale is in METERS (1 = 1 meter, 0.1 = 10cm, 0.5 = 50cm)
- Use {x: width, y: height, z: depth} for non-uniform scaling
- Example: 1m wide × 0.1m tall × 1m deep = scale: {x: 1, y: 0.1, z: 1}
## Connection Labels
When creating connections for the FIRST TIME in a session:
1. Call get_connection_label_preference to check if user has set a preference
2. If no preference is set, ASK the user: "Would you like default labels on connections (e.g., 'Server to Database')?"
3. Save their response with set_connection_label_preference
4. Then proceed with connect_entities
The preference persists for the session - you don't need to ask again.
## Bulk Operations
When the user asks to modify ALL items (e.g., "remove labels from all connections"):
1. First call list_entities to see what exists
2. Then call the modify tool for EACH item that needs to change
3. Use modify_connection with from/to to identify each connection
Example: To remove all connection labels:
- Call list_entities to see connections
- For each connection, call modify_connection with from="EntityA", to="EntityB", label=""
## Research First
When asked to diagram a technical concept, architecture, or system you're not fully familiar with, use search_wikipedia to research it first. This ensures accurate and comprehensive diagrams.
@ -257,18 +382,56 @@ When asked to diagram a technical concept, architecture, or system you're not fu
## Available Shapes
box, sphere, cylinder, cone, plane, person
## Available Colors
red, blue, green, yellow, orange, purple, cyan, pink, white, black, brown, gray (or hex like #ff5500)
## Available Colors (Toolbox Palette)
ONLY use these exact hex codes. Map any requested color to the closest match:
## Position Coordinates
- x: left/right
- y: up/down (1.5 = eye level, 0 = floor)
- z: forward/backward
| Color Name | Hex Code |
|-----------------|-----------|
| Black/Dark Gray | #222222 |
| Brown | #8b4513 |
| Dark Green | #006400 |
| Slate Gray | #778899 |
| Purple/Indigo | #4b0082 |
| Red | #ff0000 |
| Orange | #ffa500 |
| Yellow | #ffff00 |
| Green | #00ff00 |
| Cyan | #00ffff |
| Blue | #0000ff |
| Magenta | #ff00ff |
| Dodger Blue | #1e90ff |
| Pale Green | #98fb98 |
| Moccasin/Beige | #ffe4b5 |
| Pink | #ff69b4 |
Examples of color mapping:
- "navy" #0000ff (Blue)
- "lime" #00ff00 (Green)
- "teal" #00ffff (Cyan)
- "maroon" #ff0000 (Red)
- "gold" #ffff00 (Yellow)
- "salmon" #ff69b4 (Pink)
- "tan" #ffe4b5 (Moccasin)
- "forest green" #006400 (Dark Green)
## Coordinate System (from camera/user perspective)
- X axis: LEFT (-) and RIGHT (+) - negative values are to the left, positive to the right
- Y axis: DOWN (-) and UP (+) - 0 is the floor, 1.5 is eye level, higher values go up
- Z axis: BACKWARD (-) and FORWARD (+) - positive values are in front of the camera, negative behind
Example positions:
- (0, 1.5, 2) = directly in front at eye level
- (-1, 1.5, 2) = to the left, at eye level, in front
- (1, 0.5, 2) = to the right, below eye level, in front
- (0, 3, 2) = directly in front, above eye level
## Layout Guidelines
- Spread entities apart (at least 0.5 units)
- Use y=1.5 for eye-level entities
- For relative directions, call get_camera_position first
- **Default position**: Place new entities in front of the camera, centered around (0, 1.5, 0) which is eye level
- **Minimum spacing**: Entities must NEVER be closer than 0.2 meters (20cm) from each other
- **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.`;
@ -295,11 +458,11 @@ const TOOLS = [
position: {
type: "object",
properties: {
x: {type: "number", description: "Left/right position"},
y: {type: "number", description: "Up/down position (1.5 = eye level)"},
z: {type: "number", description: "Forward/backward position"}
x: {type: "number", description: "Left (-) / Right (+) from camera view"},
y: {type: "number", description: "Down (-) / Up (+), 0=floor, 1.5=eye level"},
z: {type: "number", description: "Backward (-) / Forward (+) from camera view, use positive values (1-3) to place in front"}
},
description: "3D position. If not specified, defaults to (0, 1.5, 2)"
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"]
@ -307,7 +470,7 @@ const TOOLS = [
},
{
name: "connect_entities",
description: "Draw a connection line between two entities. Use entity IDs or labels to identify them.",
description: "Draw a connection line between two entities. Check useDefaultLabels preference first - if not set, ask user if they want default labels on connections.",
input_schema: {
type: "object",
properties: {
@ -319,6 +482,10 @@ const TOOLS = [
type: "string",
description: "ID or label of the target entity"
},
label: {
type: "string",
description: "Optional label for the connection. If omitted, default label 'X to Y' is used based on useDefaultLabels preference."
},
color: {
type: "string",
description: "Color of the connection line"
@ -327,6 +494,28 @@ const TOOLS = [
required: ["from", "to"]
}
},
{
name: "set_connection_label_preference",
description: "Set whether connections should have default labels. Call this after asking the user their preference.",
input_schema: {
type: "object",
properties: {
use_default_labels: {
type: "boolean",
description: "true = create labels like 'Server to Database', false = no labels on connections"
}
},
required: ["use_default_labels"]
}
},
{
name: "get_connection_label_preference",
description: "Check if the user has set a preference for connection labels. Returns the preference or null if not set.",
input_schema: {
type: "object",
properties: {}
}
},
{
name: "list_entities",
description: "List all entities currently in the diagram. Use this to see what exists before connecting or modifying.",
@ -351,44 +540,50 @@ const TOOLS = [
},
{
name: "modify_entity",
description: "Modify an existing entity's properties like color, label, position, scale, or rotation.",
description: "CALL THIS TOOL to modify an entity. You MUST call this tool - describing changes does nothing. Use for: resize, move, rename, recolor, rotate, change shape.",
input_schema: {
type: "object",
properties: {
target: {
type: "string",
description: "ID or label of the entity to modify"
description: "Label or ID of the entity to modify (e.g., 'CDN', 'Server', 'Database')"
},
color: {
type: "string",
description: "New color for the entity"
description: "New color hex code from the toolbox palette"
},
label: {
type: "string",
description: "New label text. Use empty string \"\" to remove the label."
},
shape: {
type: "string",
enum: ["box", "sphere", "cylinder", "cone", "plane", "person"],
description: "New shape for the entity"
},
position: {
type: "object",
properties: {
x: {type: "number"},
y: {type: "number"},
z: {type: "number"}
}
x: {type: "number", description: "Left(-)/Right(+) in meters"},
y: {type: "number", description: "Down(-)/Up(+) in meters"},
z: {type: "number", description: "Back(-)/Forward(+) in meters"}
},
description: "New position in meters"
},
scale: {
oneOf: [
{
type: "number",
description: "Uniform scale factor (e.g., 0.2 = double size, 0.05 = half size). Default is 0.1."
description: "Uniform size in meters (e.g., 1 = 1 meter cube)"
},
{
type: "object",
properties: {
x: {type: "number"},
y: {type: "number"},
z: {type: "number"}
x: {type: "number", description: "Width in meters"},
y: {type: "number", description: "Height in meters"},
z: {type: "number", description: "Depth in meters"}
},
description: "Non-uniform scale as {x, y, z}. Default is {x: 0.1, y: 0.1, z: 0.1}."
description: "Size as {x: width, y: height, z: depth} in meters. Example: {x: 1, y: 0.1, z: 1} = 1m wide, 10cm tall, 1m deep"
}
],
description: "Scale/size of the entity. Use a number for uniform scaling or {x, y, z} for non-uniform."
@ -484,13 +679,13 @@ const TOOLS = [
},
{
name: "set_model",
description: "Change the AI model used for this conversation. Use list_models first to see available options.",
description: "Change the AI model. Use the model name from list_models.",
input_schema: {
type: "object",
properties: {
model_id: {
type: "string",
description: "The model ID to switch to. Claude models: 'claude-sonnet-4-20250514', 'claude-opus-4-20250514', 'claude-haiku-3-5-20241022'. Ollama models (local): 'llama3.1', 'mistral', 'qwen2.5'"
description: "Model name like 'Claude Opus 4', 'Hermes 2 Pro (CF)', 'Mistral Small 3.1 (CF)', etc."
}
},
required: ["model_id"]
@ -548,14 +743,32 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise<ToolResult> {
case 'create_entity':
result = createEntity(toolCall.input);
break;
case 'connect_entities':
result = await connectEntities(toolCall.input);
case 'connect_entities': {
// Check if label is explicitly provided (including empty string)
const hasExplicitLabel = toolCall.input.label !== undefined;
let label = toolCall.input.label;
// If no explicit label, check preference
if (!hasExplicitLabel) {
const useDefaultLabels = await getSessionPreference('useDefaultConnectionLabels');
if (useDefaultLabels === false) {
// User prefers no labels - pass empty string
label = '';
}
// If true or undefined, let connectEntities create default label (pass undefined)
}
result = await connectEntities({
...toolCall.input,
label
});
break;
}
case 'remove_entity':
result = removeEntity(toolCall.input);
result = await removeEntity(toolCall.input);
break;
case 'modify_entity':
result = modifyEntity(toolCall.input);
result = await modifyEntity(toolCall.input);
break;
case 'modify_connection':
result = await modifyConnection(toolCall.input);
@ -579,12 +792,12 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise<ToolResult> {
const models = getAvailableModels();
const current = getCurrentModel();
const modelList = models.map(m =>
`${m.name} (${m.id})${m.id === current.id ? ' [CURRENT]' : ''}\n ${m.description}`
`${m.name}${m.id === current.id ? ' [CURRENT]' : ''}\n ${m.description}`
).join('\n\n');
result = {
toolName: 'list_models',
success: true,
message: `Available models:\n\n${modelList}`
message: `Available models:\n\n${modelList}\n\nTo switch, use set_model with the model name (e.g., "Hermes 2 Pro (CF)")`
};
break;
}
@ -598,7 +811,9 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise<ToolResult> {
break;
}
case 'set_model': {
const success = setCurrentModel(toolCall.input.model_id);
// Normalize parameter name - some models use model_name instead of model_id
const modelId = toolCall.input.model_id || toolCall.input.model_name || toolCall.input.modelId || toolCall.input.model;
const success = setCurrentModel(modelId);
if (success) {
const model = getCurrentModel();
result = {
@ -611,7 +826,7 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise<ToolResult> {
result = {
toolName: 'set_model',
success: false,
message: `Invalid model ID: "${toolCall.input.model_id}"\n\nAvailable models: ${models.map(m => m.id).join(', ')}`
message: `Invalid model ID: "${modelId}"\n\nAvailable models: ${models.map(m => m.id).join(', ')}`
};
}
break;
@ -636,6 +851,35 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise<ToolResult> {
};
break;
}
case 'set_connection_label_preference': {
const useDefaultLabels = toolCall.input.use_default_labels;
const success = await setSessionPreference('useDefaultConnectionLabels', useDefaultLabels);
result = {
toolName: 'set_connection_label_preference',
success,
message: success
? `Connection label preference set: ${useDefaultLabels ? 'Default labels will be created (e.g., "Server to Database")' : 'No labels will be created on connections'}`
: 'Failed to save preference (no active session)'
};
break;
}
case 'get_connection_label_preference': {
const pref = await getSessionPreference('useDefaultConnectionLabels');
if (pref === undefined) {
result = {
toolName: 'get_connection_label_preference',
success: true,
message: 'No preference set yet. Ask the user if they want default labels on connections.'
};
} else {
result = {
toolName: 'get_connection_label_preference',
success: true,
message: `Connection label preference: ${pref ? 'Default labels enabled' : 'No labels on connections'}`
};
}
break;
}
default:
result = {
toolName: 'unknown',
@ -750,6 +994,9 @@ export async function sendMessage(
logger.debug('[sendMessage] Starting with message:', userMessage);
logger.debug('[sendMessage] Session ID:', currentSessionId);
// Sync camera position before sending message so AI knows user's view
await syncCameraToSession();
// When using sessions, we don't need to send full history - server manages it
// Just send the new message
const messages: ClaudeMessage[] = currentSessionId
@ -838,6 +1085,7 @@ export async function sendMessage(
let modelSwitched = false;
for (const toolBlock of toolBlocks) {
console.log('[sendMessage] Full tool block:', JSON.stringify(toolBlock, null, 2));
logger.debug('[sendMessage] Tool call:', toolBlock.name, JSON.stringify(toolBlock.input));
const toolCall: DiagramToolCall = {
name: toolBlock.name as DiagramToolCall['name'],

View File

@ -16,15 +16,87 @@ import log from 'loglevel';
const logger = log.getLogger('entityBridge');
logger.setLevel('debug');
// The 16 predefined toolbox colors
const TOOLBOX_COLORS = [
"#222222", "#8b4513", "#006400", "#778899", // Dark gray, Brown, Dark green, Slate gray
"#4b0082", "#ff0000", "#ffa500", "#ffff00", // Indigo, Red, Orange, Yellow
"#00ff00", "#00ffff", "#0000ff", "#ff00ff", // Green, Cyan, Blue, Magenta
"#1e90ff", "#98fb98", "#ffe4b5", "#ff69b4" // Dodger blue, Pale green, Moccasin, Pink
];
/**
* Parse a hex color string to RGB values
*/
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
/**
* Calculate color distance using simple Euclidean distance in RGB space
*/
function colorDistance(c1: { r: number; g: number; b: number }, c2: { r: number; g: number; b: number }): number {
return Math.sqrt(
Math.pow(c1.r - c2.r, 2) +
Math.pow(c1.g - c2.g, 2) +
Math.pow(c1.b - c2.b, 2)
);
}
/**
* Find the closest toolbox color to the given hex color
*/
function findClosestToolboxColor(hex: string): string {
const inputRgb = hexToRgb(hex);
if (!inputRgb) return '#0000ff'; // Default to blue if parsing fails
let closestColor = TOOLBOX_COLORS[0];
let minDistance = Infinity;
for (const toolboxHex of TOOLBOX_COLORS) {
const toolboxRgb = hexToRgb(toolboxHex);
if (toolboxRgb) {
const distance = colorDistance(inputRgb, toolboxRgb);
if (distance < minDistance) {
minDistance = distance;
closestColor = toolboxHex;
}
}
}
if (closestColor !== hex.toLowerCase()) {
logger.debug(`[resolveColor] Mapped ${hex} to closest toolbox color: ${closestColor}`);
}
return closestColor;
}
function resolveColor(color?: string): string {
if (!color) return '#0000ff';
const lower = color.toLowerCase();
const lower = color.toLowerCase().trim();
// Check if it's a named color
if (COLOR_NAME_TO_HEX[lower]) {
return COLOR_NAME_TO_HEX[lower];
const namedHex = COLOR_NAME_TO_HEX[lower];
// Map named color to closest toolbox color
return findClosestToolboxColor(namedHex);
}
// Check if it's a hex color
if (color.startsWith('#')) {
return color;
// If it's already a toolbox color, use it directly
if (TOOLBOX_COLORS.includes(lower)) {
return lower;
}
// Otherwise, find the closest toolbox color
return findClosestToolboxColor(color);
}
// Default to blue
return '#0000ff';
}
@ -33,6 +105,113 @@ interface ResolvedEntity {
label: string | null;
}
interface EntityInfo {
id: string;
label: string;
template: string;
color?: string;
}
/**
* Get all entities currently in the diagram
*/
function getAllEntities(): Promise<EntityInfo[]> {
return new Promise((resolve) => {
const requestId = 'req-' + Date.now() + '-' + Math.random();
const responseHandler = (e: CustomEvent) => {
if (e.detail.requestId !== requestId) return;
logger.debug('[getAllEntities] Got response:', e.detail.entities?.length, 'entities');
document.removeEventListener('chatListEntitiesResponse', responseHandler as EventListener);
resolve(e.detail.entities || []);
};
document.addEventListener('chatListEntitiesResponse', responseHandler as EventListener);
const event = new CustomEvent('chatListEntities', {
detail: { requestId },
bubbles: true
});
document.dispatchEvent(event);
setTimeout(() => {
logger.warn('[getAllEntities] Timeout getting entities');
document.removeEventListener('chatListEntitiesResponse', responseHandler as EventListener);
resolve([]);
}, 2000);
});
}
/**
* Calculate similarity between two strings (case-insensitive)
* Returns a score between 0 and 1
*/
function stringSimilarity(str1: string, str2: string): number {
const s1 = str1.toLowerCase().trim();
const s2 = str2.toLowerCase().trim();
if (s1 === s2) return 1;
if (s1.includes(s2) || s2.includes(s1)) return 0.8;
// Simple Levenshtein-based similarity
const longer = s1.length > s2.length ? s1 : s2;
const shorter = s1.length > s2.length ? s2 : s1;
if (longer.length === 0) return 1;
// Count matching characters
let matches = 0;
for (let i = 0; i < shorter.length; i++) {
if (longer.includes(shorter[i])) matches++;
}
return matches / longer.length;
}
/**
* Infer the best matching entity from a partial or missing target
* Uses fuzzy matching and context clues
*/
async function inferEntity(hint?: string): Promise<EntityInfo | null> {
const entities = await getAllEntities();
if (entities.length === 0) {
logger.debug('[inferEntity] No entities in diagram');
return null;
}
// If only one entity, use it
if (entities.length === 1) {
logger.debug('[inferEntity] Only one entity, using:', entities[0].label);
return entities[0];
}
// If we have a hint, try to find the best match
if (hint) {
let bestMatch: EntityInfo | null = null;
let bestScore = 0;
for (const entity of entities) {
const labelScore = stringSimilarity(hint, entity.label);
const idScore = stringSimilarity(hint, entity.id);
const score = Math.max(labelScore, idScore);
if (score > bestScore && score > 0.3) { // Minimum threshold
bestScore = score;
bestMatch = entity;
}
}
if (bestMatch) {
logger.debug(`[inferEntity] Best match for "${hint}": ${bestMatch.label} (score: ${bestScore.toFixed(2)})`);
return bestMatch;
}
}
logger.debug('[inferEntity] Could not infer entity');
return null;
}
/**
* Resolve an entity label or ID to actual entity ID and label
*/
@ -69,17 +248,55 @@ function resolveEntity(target: string): Promise<ResolvedEntity> {
export function createEntity(params: CreateEntityParams): ToolResult {
logger.debug('[createEntity] Creating entity:', params);
// Normalize params - some models use different parameter names
const normalizedParams = { ...params } as CreateEntityParams & {
x?: number;
y?: number;
z?: number;
text?: string;
name?: string;
};
// Handle position as array [x, y, z] instead of object {x, y, z}
if (Array.isArray(normalizedParams.position)) {
const posArray = normalizedParams.position as unknown as number[];
normalizedParams.position = {
x: posArray[0] ?? 0,
y: posArray[1] ?? 1.5,
z: posArray[2] ?? 2
};
logger.debug('[createEntity] Converted position array to object:', normalizedParams.position);
}
// Handle x, y, z directly instead of position object
if (!normalizedParams.position && (normalizedParams.x !== undefined || normalizedParams.y !== undefined || normalizedParams.z !== undefined)) {
normalizedParams.position = {
x: normalizedParams.x ?? 0,
y: normalizedParams.y ?? 1.5,
z: normalizedParams.z ?? 2
};
}
// Handle 'text' or 'name' as alias for 'label'
if (normalizedParams.label === undefined && normalizedParams.text !== undefined) {
normalizedParams.label = normalizedParams.text;
}
if (normalizedParams.label === undefined && normalizedParams.name !== undefined) {
normalizedParams.label = normalizedParams.name;
}
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 template = SHAPE_TO_TEMPLATE[normalizedParams.shape];
const color = resolveColor(normalizedParams.color);
const position = normalizedParams.position || {x: 0, y: 1.5, z: 2};
const entity: DiagramEntity = {
id,
template,
type: DiagramEntityType.ENTITY,
color,
text: params.label || '',
text: normalizedParams.label || '',
position,
rotation: {x: 0, y: Math.PI, z: 0},
scale: {x: 0.1, y: 0.1, z: 0.1},
@ -95,7 +312,7 @@ export function createEntity(params: CreateEntityParams): ToolResult {
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)})`,
message: `Created ${normalizedParams.shape}${normalizedParams.label ? ` labeled "${normalizedParams.label}"` : ''} at position (${position.x.toFixed(1)}, ${position.y.toFixed(1)}, ${position.z.toFixed(1)})`,
entityId: id
};
logger.debug('[createEntity] Done:', result);
@ -128,10 +345,11 @@ export async function connectEntities(params: ConnectEntitiesParams): Promise<To
const id = 'id' + uuidv4();
const color = resolveColor(params.color);
// Generate default label from entity labels: "{from label} to {to label}"
// Use explicit label if provided, otherwise generate default
const fromLabel = fromEntity.label || params.from;
const toLabel = toEntity.label || params.to;
const connectionLabel = `${fromLabel} to ${toLabel}`;
// If label is explicitly set (including empty string), use it; otherwise generate default
const connectionLabel = params.label !== undefined ? params.label : `${fromLabel} to ${toLabel}`;
const entity: DiagramEntity = {
id,
@ -149,17 +367,45 @@ export async function connectEntities(params: ConnectEntitiesParams): Promise<To
});
document.dispatchEvent(event);
const labelInfo = connectionLabel ? ` with label "${connectionLabel}"` : ' (no label)';
return {
toolName: 'connect_entities',
success: true,
message: `Connected "${fromLabel}" to "${toLabel}"`,
message: `Connected "${fromLabel}" to "${toLabel}"${labelInfo}`,
entityId: id
};
}
export function removeEntity(params: RemoveEntityParams): ToolResult {
export async function removeEntity(params: RemoveEntityParams): Promise<ToolResult> {
// Normalize params - some models use different parameter names
const normalizedParams = { ...params } as RemoveEntityParams & {
entity?: string;
name?: string;
};
// Handle 'entity' or 'name' as alias for 'target'
let target = normalizedParams.target || normalizedParams.entity || normalizedParams.name;
// If still no target, try to infer it
if (!target) {
const hint = normalizedParams.entity || normalizedParams.name;
const inferredEntity = await inferEntity(hint);
if (inferredEntity) {
logger.info(`[removeEntity] Inferred target: "${inferredEntity.label}" (id: ${inferredEntity.id})`);
target = inferredEntity.label || inferredEntity.id;
}
}
if (!target) {
return {
toolName: 'remove_entity',
success: false,
message: `Error: No target entity specified. Please provide the entity name or ID to remove.`
};
}
const event = new CustomEvent('chatRemoveEntity', {
detail: {target: params.target},
detail: { target },
bubbles: true
});
document.dispatchEvent(event);
@ -167,7 +413,7 @@ export function removeEntity(params: RemoveEntityParams): ToolResult {
return {
toolName: 'remove_entity',
success: true,
message: `Removed entity "${params.target}"`
message: `Removed entity "${target}"`
};
}
@ -176,70 +422,188 @@ function degreesToRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}
export function modifyEntity(params: ModifyEntityParams): ToolResult {
const updates: Partial<DiagramEntity> = {};
export async function modifyEntity(params: ModifyEntityParams): Promise<ToolResult> {
// Normalize params - some models use different parameter names
const normalizedParams = { ...params } as ModifyEntityParams & {
entity?: string;
name?: string;
x?: number;
y?: number;
z?: number;
text?: string;
shape?: string; // Some models include shape for context
};
if (params.color) {
updates.color = resolveColor(params.color);
// Handle 'entity' or 'name' as alias for 'target'
if (!normalizedParams.target && normalizedParams.entity) {
normalizedParams.target = normalizedParams.entity;
}
if (params.label !== undefined) {
updates.text = params.label;
if (!normalizedParams.target && normalizedParams.name) {
normalizedParams.target = normalizedParams.name;
}
if (params.position) {
updates.position = params.position;
}
if (params.scale !== undefined) {
// Accept either uniform scale (number) or 3D scale object
if (typeof params.scale === 'number') {
updates.scale = {x: params.scale, y: params.scale, z: params.scale};
} else {
updates.scale = params.scale;
// If still no target, try to infer it using color and shape hints
if (!normalizedParams.target) {
const entities = await getAllEntities();
logger.debug('[modifyEntity] Trying to infer target from', entities.length, 'entities');
// Try to find entity by color and/or shape
if (normalizedParams.color || normalizedParams.shape) {
const colorHint = normalizedParams.color?.toLowerCase();
const shapeHint = normalizedParams.shape?.toLowerCase();
for (const entity of entities) {
const entityShape = entity.template?.replace('#', '').replace('-template', '').toLowerCase();
const entityColor = entity.color?.toLowerCase();
const matchesShape = !shapeHint || entityShape === shapeHint;
// Match by actual entity color or by color name in label
let matchesColor = false;
if (colorHint) {
// Direct color match
if (entityColor === colorHint || entityColor === findClosestToolboxColor(colorHint)) {
matchesColor = true;
}
// Check if label contains color name
else if (
(colorHint === '#ff0000' && entity.label?.toLowerCase().includes('red')) ||
(colorHint === '#00ff00' && entity.label?.toLowerCase().includes('green')) ||
(colorHint === '#0000ff' && entity.label?.toLowerCase().includes('blue')) ||
(colorHint === '#ffff00' && entity.label?.toLowerCase().includes('yellow')) ||
(colorHint === '#ffa500' && entity.label?.toLowerCase().includes('orange')) ||
(colorHint === '#ff69b4' && entity.label?.toLowerCase().includes('pink')) ||
(colorHint === '#4b0082' && entity.label?.toLowerCase().includes('purple'))
) {
matchesColor = true;
}
}
if (matchesShape && (matchesColor || (!colorHint && entities.length === 1))) {
logger.info(`[modifyEntity] Inferred target by shape/color: "${entity.label || entity.id}" (${entityShape}, ${entityColor})`);
normalizedParams.target = entity.label || entity.id;
break;
}
}
}
// Fall back to general inference
if (!normalizedParams.target) {
const hint = normalizedParams.entity || normalizedParams.name || undefined;
const inferredEntity = await inferEntity(hint);
if (inferredEntity) {
logger.info(`[modifyEntity] Inferred target: "${inferredEntity.label}" (id: ${inferredEntity.id})`);
normalizedParams.target = inferredEntity.label || inferredEntity.id;
}
}
}
if (params.rotation !== undefined) {
// Handle position as array [x, y, z] instead of object {x, y, z}
if (Array.isArray(normalizedParams.position)) {
const posArray = normalizedParams.position as unknown as number[];
normalizedParams.position = {
x: posArray[0] ?? 0,
y: posArray[1] ?? 1.5,
z: posArray[2] ?? 0
};
logger.debug('[modifyEntity] Converted position array to object:', normalizedParams.position);
}
// Handle x, y, z directly instead of position object
if (!normalizedParams.position && (normalizedParams.x !== undefined || normalizedParams.y !== undefined || normalizedParams.z !== undefined)) {
normalizedParams.position = {
x: normalizedParams.x ?? 0,
y: normalizedParams.y ?? 1.5,
z: normalizedParams.z ?? 0
};
}
// Handle 'text' as alias for 'label'
if (normalizedParams.label === undefined && normalizedParams.text !== undefined) {
normalizedParams.label = normalizedParams.text;
}
// Validate target is provided
if (!normalizedParams.target) {
logger.error('[modifyEntity] Called without target! Params:', params);
return {
toolName: 'modify_entity',
success: false,
message: `Error: No target entity specified. Please provide the entity name or ID to modify.`
};
}
const updates: Partial<DiagramEntity> = {};
if (normalizedParams.color) {
updates.color = resolveColor(normalizedParams.color);
}
if (normalizedParams.label !== undefined) {
updates.text = normalizedParams.label;
}
if (normalizedParams.shape) {
const template = SHAPE_TO_TEMPLATE[normalizedParams.shape as keyof typeof SHAPE_TO_TEMPLATE];
if (template) {
updates.template = template;
logger.debug('[modifyEntity] Changing shape to:', normalizedParams.shape, '→', template);
}
}
if (normalizedParams.position) {
updates.position = normalizedParams.position;
}
if (normalizedParams.scale !== undefined) {
// Accept either uniform scale (number) or 3D scale object
if (typeof normalizedParams.scale === 'number') {
updates.scale = {x: normalizedParams.scale, y: normalizedParams.scale, z: normalizedParams.scale};
} else {
updates.scale = normalizedParams.scale;
}
}
if (normalizedParams.rotation !== undefined) {
// Accept degrees from AI, convert to radians for internal use
// Can be uniform (single number for Y rotation) or full 3D rotation
if (typeof params.rotation === 'number') {
if (typeof normalizedParams.rotation === 'number') {
// Single number = Y-axis rotation (most common for "turn 90 degrees")
updates.rotation = {x: 0, y: degreesToRadians(params.rotation), z: 0};
updates.rotation = {x: 0, y: degreesToRadians(normalizedParams.rotation), z: 0};
} else {
updates.rotation = {
x: degreesToRadians(params.rotation.x),
y: degreesToRadians(params.rotation.y),
z: degreesToRadians(params.rotation.z)
x: degreesToRadians(normalizedParams.rotation.x),
y: degreesToRadians(normalizedParams.rotation.y),
z: degreesToRadians(normalizedParams.rotation.z)
};
}
}
const event = new CustomEvent('chatModifyEntity', {
detail: {target: params.target, updates},
detail: {target: normalizedParams.target, updates},
bubbles: true
});
document.dispatchEvent(event);
const changes: string[] = [];
if (params.color) changes.push(`color to ${params.color}`);
if (params.label !== undefined) changes.push(`label to "${params.label}"`);
if (params.position) changes.push(`position`);
if (params.scale !== undefined) {
if (typeof params.scale === 'number') {
changes.push(`scale to ${params.scale}`);
if (normalizedParams.color) changes.push(`color to ${normalizedParams.color}`);
if (normalizedParams.label !== undefined) changes.push(`label to "${normalizedParams.label}"`);
if (normalizedParams.shape) changes.push(`shape to ${normalizedParams.shape}`);
if (normalizedParams.position) changes.push(`position`);
if (normalizedParams.scale !== undefined) {
if (typeof normalizedParams.scale === 'number') {
changes.push(`scale to ${normalizedParams.scale}`);
} else {
changes.push(`scale to (${params.scale.x}, ${params.scale.y}, ${params.scale.z})`);
changes.push(`scale to (${normalizedParams.scale.x}, ${normalizedParams.scale.y}, ${normalizedParams.scale.z})`);
}
}
if (params.rotation !== undefined) {
if (typeof params.rotation === 'number') {
changes.push(`rotation to ${params.rotation}°`);
if (normalizedParams.rotation !== undefined) {
if (typeof normalizedParams.rotation === 'number') {
changes.push(`rotation to ${normalizedParams.rotation}°`);
} else {
changes.push(`rotation to (${params.rotation.x}°, ${params.rotation.y}°, ${params.rotation.z}°)`);
changes.push(`rotation to (${normalizedParams.rotation.x}°, ${normalizedParams.rotation.y}°, ${normalizedParams.rotation.z}°)`);
}
}
return {
toolName: 'modify_entity',
success: true,
message: `Modified entity "${params.target}"${changes.length > 0 ? ': ' + changes.join(', ') : ''}`
message: `Modified entity "${normalizedParams.target}"${changes.length > 0 ? ': ' + changes.join(', ') : ''}`
};
}
@ -313,15 +677,18 @@ export function listEntities(): Promise<ToolResult> {
return new Promise((resolve) => {
const responseHandler = (e: CustomEvent) => {
document.removeEventListener('chatListEntitiesResponse', responseHandler as EventListener);
const entities = e.detail.entities as Array<{
const allItems = e.detail.entities as Array<{
id: string;
template: string;
text: string;
position: { x: number; y: number; z: number }
color?: string;
position: { x: number; y: number; z: number };
from?: string;
to?: string;
}>;
logger.debug('[listEntities] Got response, entities:', entities.length);
logger.debug('[listEntities] Got response, items:', allItems.length);
if (entities.length === 0) {
if (allItems.length === 0) {
resolve({
toolName: 'list_entities',
success: true,
@ -330,15 +697,38 @@ export function listEntities(): Promise<ToolResult> {
return;
}
const list = entities.map(e => {
// Separate entities from connections
const entities = allItems.filter(e => !e.template.includes('connection'));
const connections = allItems.filter(e => e.template.includes('connection'));
// Build entity list
const entityList = 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');
// Build connection list with from/to info
const connectionList = connections.map(c => {
const fromEntity = entities.find(e => e.id === c.from);
const toEntity = entities.find(e => e.id === c.to);
const fromLabel = fromEntity?.text || c.from || '?';
const toLabel = toEntity?.text || c.to || '?';
return `- "${c.text || '(no label)'}" connects ${fromLabel}${toLabel} [id: ${c.id}]`;
}).join('\n');
let message = '';
if (entities.length > 0) {
message += `**Entities (${entities.length}):**\n${entityList}`;
}
if (connections.length > 0) {
message += `\n\n**Connections (${connections.length}):**\n${connectionList}`;
message += `\n\nTo modify a connection, use modify_connection with from/to entity names or the connection label.`;
}
resolve({
toolName: 'list_entities',
success: true,
message: `Current entities in the diagram:\n${list}`
message: message || 'The diagram is empty.'
});
};
@ -423,6 +813,33 @@ export async function clearDiagram(params: ClearDiagramParams): Promise<ToolResu
};
}
/**
* Get camera data for session sync (returns raw camera data)
*/
export function getCameraForSync(): Promise<{
position: { x: number; y: number; z: number };
forward: { x: number; y: number; z: number };
groundForward: { x: number; z: number };
groundRight: { x: number; z: number };
} | null> {
return new Promise((resolve) => {
const responseHandler = (e: CustomEvent) => {
document.removeEventListener('chatGetCameraResponse', responseHandler as EventListener);
resolve(e.detail);
};
document.addEventListener('chatGetCameraResponse', responseHandler as EventListener);
const event = new CustomEvent('chatGetCamera', {bubbles: true});
document.dispatchEvent(event);
setTimeout(() => {
document.removeEventListener('chatGetCameraResponse', responseHandler as EventListener);
resolve(null);
}, 2000);
});
}
/**
* Get the current camera position and orientation
* Returns world-space coordinates with ground-projected directions for intuitive placement

View File

@ -29,6 +29,7 @@ export interface ConnectEntitiesParams {
from: string;
to: string;
color?: string;
label?: string;
}
export interface RemoveEntityParams {
@ -39,6 +40,7 @@ export interface ModifyEntityParams {
target: string;
color?: string;
label?: string;
shape?: 'box' | 'sphere' | 'cylinder' | 'cone' | 'plane' | 'person';
position?: { x: number; y: number; z: number };
scale?: { x: number; y: number; z: number } | number;
rotation?: { x: number; y: number; z: number } | number; // in degrees
@ -64,6 +66,10 @@ export interface WikipediaSearchParams {
topic: string;
}
export interface SetConnectionLabelPreferenceParams {
use_default_labels: boolean;
}
export type DiagramToolCall =
| { name: 'create_entity'; input: CreateEntityParams }
| { name: 'connect_entities'; input: ConnectEntitiesParams }
@ -77,7 +83,9 @@ export type DiagramToolCall =
| { name: 'get_current_model'; input: Record<string, never> }
| { name: 'set_model'; input: SetModelParams }
| { name: 'clear_conversation'; input: Record<string, never> }
| { name: 'search_wikipedia'; input: WikipediaSearchParams };
| { name: 'search_wikipedia'; input: WikipediaSearchParams }
| { name: 'set_connection_label_preference'; input: SetConnectionLabelPreferenceParams }
| { name: 'get_connection_label_preference'; input: Record<string, never> };
export const SHAPE_TO_TEMPLATE: Record<CreateEntityParams['shape'], DiagramTemplates> = {
box: DiagramTemplates.BOX,
@ -180,4 +188,8 @@ export interface SessionUsage {
requestCount: number;
startTime: number;
requests?: UsageRequestDetail[];
// Context tracking
contextUsed?: number;
contextLimit?: number;
contextPercent?: number;
}

View File

@ -20,7 +20,8 @@ import {DefaultScene} from "./defaultScene";
import {Introduction} from "./tutorial/introduction";
import {PouchData} from "./integration/database/pouchData";
const webGpu = false;
const webGpu = new URLSearchParams(window.location.search).has('webGPU');
console.log(`Rendering backend: ${webGpu ? 'WebGPU' : 'WebGL'}`);
log.setLevel('debug', false);
export default class VrApp {

View File

@ -33,8 +33,10 @@
// raises an error for unused parameters
"noImplicitReturns": true,
// raises an error for functions that return nothing
"skipLibCheck": true
"skipLibCheck": true,
// skip type-checking of .d.ts files (it speeds up transpiling)
"noEmit": true
// don't emit .js files - Vite handles transpilation, tsc is for type checking only
},
"include": [
"src"