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>
This commit is contained in:
parent
add1ece149
commit
4ca98cf980
400
package-lock.json
generated
400
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immersive",
|
"name": "immersive",
|
||||||
"version": "0.0.8-47",
|
"version": "0.0.8-50",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immersive",
|
"name": "immersive",
|
||||||
"version": "0.0.8-47",
|
"version": "0.0.8-50",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth0/auth0-react": "^2.2.4",
|
"@auth0/auth0-react": "^2.2.4",
|
||||||
@ -20,6 +20,9 @@
|
|||||||
"@emotion/react": "^11.13.0",
|
"@emotion/react": "^11.13.0",
|
||||||
"@giphy/js-fetch-api": "^5.6.0",
|
"@giphy/js-fetch-api": "^5.6.0",
|
||||||
"@giphy/react-components": "^9.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/core": "^7.17.8",
|
||||||
"@mantine/form": "^7.17.8",
|
"@mantine/form": "^7.17.8",
|
||||||
"@mantine/hooks": "^7.17.8",
|
"@mantine/hooks": "^7.17.8",
|
||||||
@ -61,7 +64,9 @@
|
|||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"vite-express": "^0.21.1",
|
"vite-express": "^0.21.1",
|
||||||
"websocket": "^1.0.34",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/dom-to-image": "^2.6.7",
|
"@types/dom-to-image": "^2.6.7",
|
||||||
@ -74,6 +79,26 @@
|
|||||||
"node": ">=18.0.0"
|
"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": {
|
"node_modules/@apm-js-collab/code-transformer": {
|
||||||
"version": "0.8.2",
|
"version": "0.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz",
|
"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"
|
"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": {
|
"node_modules/@colors/colors": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
|
||||||
@ -1089,6 +1120,97 @@
|
|||||||
"url": "https://opencollective.com/js-sdsl"
|
"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": {
|
"node_modules/@mantine/core": {
|
||||||
"version": "7.17.8",
|
"version": "7.17.8",
|
||||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.17.8.tgz",
|
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.17.8.tgz",
|
||||||
@ -2209,6 +2331,12 @@
|
|||||||
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
|
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@tyriar/fibonacci-heap": {
|
||||||
"version": "2.0.9",
|
"version": "2.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@tyriar/fibonacci-heap/-/fibonacci-heap-2.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@tyriar/fibonacci-heap/-/fibonacci-heap-2.0.9.tgz",
|
||||||
@ -2785,6 +2913,18 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/camelize": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
||||||
@ -3088,6 +3228,15 @@
|
|||||||
"integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==",
|
"integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
"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": {
|
"node_modules/decode-uri-component": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz",
|
||||||
@ -3830,6 +3988,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/js-crypto-env/-/js-crypto-env-1.0.5.tgz",
|
||||||
"integrity": "sha512-8/UNN3sG8J+yMzqwSNVaobaWhIz4MqZFoOg5OB0DFXqS8eFjj2YvdmLJqIWXPl57Yw10SvYx0DQOtkfsWIV9Aw=="
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"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",
|
"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=="
|
"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": {
|
"node_modules/json-stringify-safe": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||||
@ -5420,6 +5606,123 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/level": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/level/-/level-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/level/-/level-6.0.1.tgz",
|
||||||
@ -6079,6 +6382,15 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/nan": {
|
||||||
"version": "2.24.0",
|
"version": "2.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz",
|
||||||
@ -6311,6 +6623,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@ -6356,6 +6677,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/p-limit": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
|
||||||
@ -6371,6 +6701,34 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@ -8292,6 +8650,12 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/slash": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||||
@ -8829,6 +9193,12 @@
|
|||||||
"utf8-byte-length": "^1.0.1"
|
"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": {
|
"node_modules/ts-easing": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
"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": {
|
"node_modules/whatwg-url": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
@ -9645,6 +10021,24 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
package.json
11
package.json
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "immersive",
|
"name": "immersive",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.8-48",
|
"version": "0.0.8-50",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -29,6 +29,9 @@
|
|||||||
"@emotion/react": "^11.13.0",
|
"@emotion/react": "^11.13.0",
|
||||||
"@giphy/js-fetch-api": "^5.6.0",
|
"@giphy/js-fetch-api": "^5.6.0",
|
||||||
"@giphy/react-components": "^9.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/core": "^7.17.8",
|
||||||
"@mantine/form": "^7.17.8",
|
"@mantine/form": "^7.17.8",
|
||||||
"@mantine/hooks": "^7.17.8",
|
"@mantine/hooks": "^7.17.8",
|
||||||
@ -70,7 +73,9 @@
|
|||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"vite-express": "^0.21.1",
|
"vite-express": "^0.21.1",
|
||||||
"websocket": "^1.0.34",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/dom-to-image": "^2.6.7",
|
"@types/dom-to-image": "^2.6.7",
|
||||||
@ -79,4 +84,4 @@
|
|||||||
"vite-plugin-cp": "^1.0.0",
|
"vite-plugin-cp": "^1.0.0",
|
||||||
"vitest": "^1.4.0"
|
"vitest": "^1.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,178 +1,124 @@
|
|||||||
import { Router } from "express";
|
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 { trackUsage, getUsageSummary, formatCost, getSessionUsage } from "../services/usageTracker.js";
|
||||||
|
import {
|
||||||
|
getClaudeModel,
|
||||||
|
buildLangChainMessages,
|
||||||
|
aiMessageToClaudeResponse
|
||||||
|
} from "../services/langchainModels.js";
|
||||||
|
|
||||||
const router = Router();
|
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.";
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
router.post("/*path", async (req, res) => {
|
||||||
const requestStart = Date.now();
|
const requestStart = Date.now();
|
||||||
console.log(`[Claude API] ========== REQUEST START ==========`);
|
console.log(`[Claude API] ========== REQUEST START ==========`);
|
||||||
|
|
||||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
console.error(`[Claude API] ERROR: API key not configured`);
|
console.error(`[Claude API] ERROR: API key not configured`);
|
||||||
return res.status(500).json({ 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 (data.error) {
|
const { sessionId, model: modelId, system: systemPrompt, ...requestBody } = req.body;
|
||||||
console.error(`[Claude API] API returned error:`, data.error);
|
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
|
try {
|
||||||
if (sessionId && response.ok && data.content) {
|
// Get LangChain model with tools bound
|
||||||
const session = getSession(sessionId);
|
const model = getClaudeModel(modelId);
|
||||||
if (session) {
|
|
||||||
// Store the user message if it was new (only if it's a string, not tool results)
|
// Build messages with entity context and history
|
||||||
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
|
const messages = buildLangChainMessages(
|
||||||
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
|
sessionId,
|
||||||
addMessage(sessionId, {
|
requestBody.messages,
|
||||||
role: 'user',
|
systemPrompt
|
||||||
content: userMessage.content
|
);
|
||||||
});
|
|
||||||
console.log(`[Claude API] Stored user message to session`);
|
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
|
||||||
|
});
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the assistant response (text only, not tool use blocks)
|
// Store messages to session
|
||||||
const assistantContent = data.content
|
if (sessionId && data.content) {
|
||||||
.filter(c => c.type === 'text')
|
const session = getSession(sessionId);
|
||||||
.map(c => c.text)
|
if (session) {
|
||||||
.join('\n');
|
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) {
|
const assistantContent = data.content
|
||||||
addMessage(sessionId, {
|
.filter(c => c.type === 'text')
|
||||||
role: 'assistant',
|
.map(c => c.text)
|
||||||
content: assistantContent
|
.join('\n');
|
||||||
});
|
|
||||||
console.log(`[Claude API] Stored assistant response to session (${assistantContent.length} chars)`);
|
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;
|
const totalDuration = Date.now() - requestStart;
|
||||||
console.log(`[Claude API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
|
console.log(`[Claude API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
|
||||||
res.status(response.status).json(data);
|
res.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const totalDuration = Date.now() - requestStart;
|
const totalDuration = Date.now() - requestStart;
|
||||||
console.error(`[Claude API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
|
console.error(`[Claude API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
|
||||||
console.error(`[Claude API] Error:`, error);
|
console.error(`[Claude API] Error:`, error);
|
||||||
console.error(`[Claude API] Error message:`, error.message);
|
console.error(`[Claude API] Error message:`, error.message);
|
||||||
console.error(`[Claude API] Error stack:`, error.stack);
|
res.status(500).json({ error: "Failed to call Claude API", details: error.message });
|
||||||
res.status(500).json({ error: "Failed to proxy request to Claude API", details: error.message });
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -1,213 +1,129 @@
|
|||||||
import { Router } from "express";
|
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 { trackUsage, getUsageSummary, formatCost, getSessionUsage } from "../services/usageTracker.js";
|
||||||
import { getCloudflareAccountId, getCloudflareApiToken } from "../services/providerConfig.js";
|
import { getCloudflareAccountId, getCloudflareApiToken } from "../services/providerConfig.js";
|
||||||
import {
|
import { getCloudflareModel } from "../services/ChatCloudflare.js";
|
||||||
claudeToolsToCloudflare,
|
import { buildLangChainMessages, aiMessageToClaudeResponse } from "../services/langchainModels.js";
|
||||||
claudeMessagesToCloudflare,
|
|
||||||
cloudflareResponseToClaude
|
|
||||||
} from "../services/toolConverter.js";
|
|
||||||
|
|
||||||
const router = Router();
|
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Express 5 uses named parameters for wildcards
|
|
||||||
router.post("/*path", async (req, res) => {
|
router.post("/*path", async (req, res) => {
|
||||||
const requestStart = Date.now();
|
const requestStart = Date.now();
|
||||||
console.log(`[Cloudflare API] ========== REQUEST START ==========`);
|
console.log(`[Cloudflare API] ========== REQUEST START ==========`);
|
||||||
|
|
||||||
const accountId = getCloudflareAccountId();
|
const accountId = getCloudflareAccountId();
|
||||||
const apiToken = getCloudflareApiToken();
|
const apiToken = getCloudflareApiToken();
|
||||||
|
|
||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
console.error(`[Cloudflare API] ERROR: Account ID not configured`);
|
console.error(`[Cloudflare API] ERROR: Account ID not configured`);
|
||||||
return res.status(500).json({ error: "Cloudflare 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cloudflare endpoint: https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model}
|
if (!apiToken) {
|
||||||
const endpoint = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${model}`;
|
console.error(`[Cloudflare API] ERROR: API token not configured`);
|
||||||
|
return res.status(500).json({ error: "Cloudflare API token not configured" });
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert Cloudflare response to Claude format
|
const { sessionId, model: modelId, system: systemPrompt, messages } = req.body;
|
||||||
const data = cloudflareResponseToClaude(cfData, model);
|
|
||||||
console.log(`[Cloudflare API] Response converted. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`);
|
|
||||||
|
|
||||||
// Track and log token usage
|
console.log(`[Cloudflare API] Session ID: ${sessionId || 'none'}`);
|
||||||
if (data.usage) {
|
console.log(`[Cloudflare API] Model: ${modelId}`);
|
||||||
// Extract content for detailed tracking
|
console.log(`[Cloudflare API] Messages count: ${messages?.length || 0}`);
|
||||||
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
|
|
||||||
const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null;
|
|
||||||
|
|
||||||
const outputText = data.content
|
try {
|
||||||
?.filter(c => c.type === 'text')
|
// Get LangChain-compatible Cloudflare model with tools bound
|
||||||
.map(c => c.text)
|
const model = getCloudflareModel(modelId);
|
||||||
.join('\n') || null;
|
|
||||||
|
|
||||||
const toolCalls = data.content
|
// Build messages with entity context and history
|
||||||
?.filter(c => c.type === 'tool_use')
|
const langChainMessages = buildLangChainMessages(
|
||||||
.map(c => ({ name: c.name, input: c.input })) || [];
|
sessionId,
|
||||||
|
messages,
|
||||||
|
systemPrompt
|
||||||
|
);
|
||||||
|
|
||||||
const usageRecord = trackUsage(sessionId, model, data.usage, {
|
console.log(`[Cloudflare API] Sending request via LangChain ChatCloudflare...`);
|
||||||
inputText,
|
const fetchStart = Date.now();
|
||||||
outputText,
|
|
||||||
toolCalls
|
|
||||||
});
|
|
||||||
console.log(`[Cloudflare API] REQUEST USAGE: ${getUsageSummary(usageRecord)}`);
|
|
||||||
|
|
||||||
// Log cumulative session usage if session exists
|
// Invoke model
|
||||||
if (sessionId) {
|
const response = await model.invoke(langChainMessages);
|
||||||
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)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If session exists and response is successful, store messages
|
const fetchDuration = Date.now() - fetchStart;
|
||||||
if (sessionId && response.ok && data.content) {
|
console.log(`[Cloudflare API] Response received in ${fetchDuration}ms`);
|
||||||
const session = getSession(sessionId);
|
|
||||||
if (session) {
|
// Convert to Claude API format for client compatibility
|
||||||
// Store the user message if it was new (only if it's a string, not tool results)
|
const data = aiMessageToClaudeResponse(response, modelId);
|
||||||
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
|
console.log(`[Cloudflare API] Response converted. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`);
|
||||||
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
|
|
||||||
addMessage(sessionId, {
|
// Track and log token usage
|
||||||
role: 'user',
|
if (data.usage) {
|
||||||
content: userMessage.content
|
const userMessage = messages?.[messages.length - 1];
|
||||||
});
|
const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null;
|
||||||
console.log(`[Cloudflare API] Stored user message to session`);
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the assistant response (text only, not tool use blocks)
|
// Store messages to session
|
||||||
const assistantContent = data.content
|
if (sessionId && data.content) {
|
||||||
.filter(c => c.type === 'text')
|
const session = getSession(sessionId);
|
||||||
.map(c => c.text)
|
if (session) {
|
||||||
.join('\n');
|
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) {
|
const assistantContent = data.content
|
||||||
addMessage(sessionId, {
|
.filter(c => c.type === 'text')
|
||||||
role: 'assistant',
|
.map(c => c.text)
|
||||||
content: assistantContent
|
.join('\n');
|
||||||
});
|
|
||||||
console.log(`[Cloudflare API] Stored assistant response to session (${assistantContent.length} chars)`);
|
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;
|
const totalDuration = Date.now() - requestStart;
|
||||||
console.log(`[Cloudflare API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
|
console.log(`[Cloudflare API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
|
||||||
res.status(response.status).json(data);
|
res.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const totalDuration = Date.now() - requestStart;
|
const totalDuration = Date.now() - requestStart;
|
||||||
console.error(`[Cloudflare API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
|
console.error(`[Cloudflare API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
|
||||||
console.error(`[Cloudflare API] Error:`, error);
|
console.error(`[Cloudflare API] Error:`, error);
|
||||||
console.error(`[Cloudflare API] Error message:`, error.message);
|
console.error(`[Cloudflare API] Error message:`, error.message);
|
||||||
console.error(`[Cloudflare API] Error stack:`, error.stack);
|
res.status(500).json({ error: "Failed to call Cloudflare API", details: error.message });
|
||||||
res.status(500).json({ error: "Failed to proxy request to Cloudflare API", details: error.message });
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -1,131 +1,51 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js";
|
import { getSession, addMessage } from "../services/sessionStore.js";
|
||||||
import { getOllamaUrl } from "../services/providerConfig.js";
|
|
||||||
import {
|
import {
|
||||||
claudeToolsToOllama,
|
getOllamaModel,
|
||||||
claudeMessagesToOllama,
|
buildLangChainMessages,
|
||||||
ollamaResponseToClaude
|
aiMessageToClaudeResponse
|
||||||
} from "../services/toolConverter.js";
|
} from "../services/langchainModels.js";
|
||||||
|
|
||||||
const router = Router();
|
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) => {
|
router.post("/*path", async (req, res) => {
|
||||||
const requestStart = Date.now();
|
const requestStart = Date.now();
|
||||||
console.log(`[Ollama API] ========== REQUEST START ==========`);
|
console.log(`[Ollama API] ========== REQUEST START ==========`);
|
||||||
|
|
||||||
const ollamaUrl = getOllamaUrl();
|
const { sessionId, model: modelId, max_tokens, system: systemPrompt, messages } = req.body;
|
||||||
console.log(`[Ollama API] Using Ollama at: ${ollamaUrl}`);
|
|
||||||
|
|
||||||
// Extract request body (Claude format)
|
|
||||||
const { sessionId, model, max_tokens, system, tools, messages } = req.body;
|
|
||||||
|
|
||||||
console.log(`[Ollama API] Session ID: ${sessionId || 'none'}`);
|
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}`);
|
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 {
|
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 fetchStart = Date.now();
|
||||||
|
|
||||||
const response = await fetch(`${ollamaUrl}/api/chat`, {
|
// Invoke model
|
||||||
method: "POST",
|
const response = await model.invoke(langChainMessages);
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify(ollamaRequest)
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchDuration = Date.now() - fetchStart;
|
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) {
|
// Convert to Claude API format for client compatibility
|
||||||
const errorText = await response.text();
|
const claudeResponse = aiMessageToClaudeResponse(response, modelId);
|
||||||
console.error(`[Ollama API] Error response:`, errorText);
|
console.log(`[Ollama API] Response converted. Stop reason: ${claudeResponse.stop_reason}, content blocks: ${claudeResponse.content.length}`);
|
||||||
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}`);
|
|
||||||
|
|
||||||
// Store messages to session if applicable
|
// Store messages to session if applicable
|
||||||
if (sessionId && claudeResponse.content) {
|
if (sessionId && claudeResponse.content) {
|
||||||
const session = getSession(sessionId);
|
const session = getSession(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
// Store the user message if it was new
|
|
||||||
const userMessage = messages?.[messages.length - 1];
|
const userMessage = messages?.[messages.length - 1];
|
||||||
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
|
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
|
||||||
addMessage(sessionId, {
|
addMessage(sessionId, {
|
||||||
@ -135,7 +55,6 @@ router.post("/*path", async (req, res) => {
|
|||||||
console.log(`[Ollama API] Stored user message to session`);
|
console.log(`[Ollama API] Stored user message to session`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the assistant response (text only)
|
|
||||||
const assistantContent = claudeResponse.content
|
const assistantContent = claudeResponse.content
|
||||||
.filter(c => c.type === 'text')
|
.filter(c => c.type === 'text')
|
||||||
.map(c => c.text)
|
.map(c => c.text)
|
||||||
@ -161,15 +80,15 @@ router.post("/*path", async (req, res) => {
|
|||||||
console.error(`[Ollama API] Error:`, error);
|
console.error(`[Ollama API] Error:`, error);
|
||||||
|
|
||||||
// Check if it's a connection 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({
|
return res.status(503).json({
|
||||||
error: "Ollama is not running",
|
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({
|
res.status(500).json({
|
||||||
error: "Failed to proxy request to Ollama",
|
error: "Failed to call Ollama",
|
||||||
details: error.message
|
details: error.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,10 +4,13 @@ import {
|
|||||||
getSession,
|
getSession,
|
||||||
findSessionByDiagram,
|
findSessionByDiagram,
|
||||||
syncEntities,
|
syncEntities,
|
||||||
|
syncCameraPosition,
|
||||||
addMessage,
|
addMessage,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
getStats
|
getStats,
|
||||||
|
getPreferences,
|
||||||
|
setPreference
|
||||||
} from "../services/sessionStore.js";
|
} from "../services/sessionStore.js";
|
||||||
import { getSessionUsage, getGlobalUsage, formatCost } from "../services/usageTracker.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 });
|
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
|
* POST /api/session/:id/message
|
||||||
* Add a message to history (used after successful Claude response)
|
* Add a message to history (used after successful Claude response)
|
||||||
@ -173,4 +196,39 @@ router.delete("/:id", (req, res) => {
|
|||||||
res.json({ success: true });
|
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;
|
export default router;
|
||||||
|
|||||||
367
server/services/ChatCloudflare.js
Normal file
367
server/services/ChatCloudflare.js
Normal 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;
|
||||||
288
server/services/langchainModels.js
Normal file
288
server/services/langchainModels.js
Normal 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
|
||||||
|
};
|
||||||
223
server/services/langchainTools.js
Normal file
223
server/services/langchainTools.js
Normal 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
|
||||||
|
};
|
||||||
@ -11,6 +11,8 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
// diagramId: string,
|
// diagramId: string,
|
||||||
// conversationHistory: Array<{role, content, toolResults?, timestamp}>,
|
// conversationHistory: Array<{role, content, toolResults?, timestamp}>,
|
||||||
// entities: Array<{id, template, text, color, position}>,
|
// 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,
|
// createdAt: Date,
|
||||||
// lastAccess: Date
|
// lastAccess: Date
|
||||||
// }
|
// }
|
||||||
@ -30,6 +32,8 @@ export function createSession(diagramId) {
|
|||||||
diagramId,
|
diagramId,
|
||||||
conversationHistory: [],
|
conversationHistory: [],
|
||||||
entities: [],
|
entities: [],
|
||||||
|
cameraPosition: null,
|
||||||
|
preferences: {},
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
lastAccess: new Date()
|
lastAccess: new Date()
|
||||||
};
|
};
|
||||||
@ -73,6 +77,18 @@ export function syncEntities(sessionId, entities) {
|
|||||||
return session;
|
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
|
* 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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,663 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Tool Format Converter
|
* Tool Format Converter (DEPRECATED)
|
||||||
* Converts between Claude and Ollama tool/function formats
|
*
|
||||||
|
* 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
export default {};
|
||||||
* 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
|
|
||||||
};
|
|
||||||
|
|||||||
@ -157,6 +157,9 @@ export class DiagramManager {
|
|||||||
if (updates.color !== undefined) {
|
if (updates.color !== undefined) {
|
||||||
diagramObject.color = updates.color;
|
diagramObject.color = updates.color;
|
||||||
}
|
}
|
||||||
|
if (updates.template !== undefined) {
|
||||||
|
diagramObject.template = updates.template;
|
||||||
|
}
|
||||||
if (updates.position !== undefined) {
|
if (updates.position !== undefined) {
|
||||||
diagramObject.position = updates.position;
|
diagramObject.position = updates.position;
|
||||||
}
|
}
|
||||||
@ -214,17 +217,22 @@ export class DiagramManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('chatListEntities', () => {
|
document.addEventListener('chatListEntities', (event: CustomEvent) => {
|
||||||
this._logger.debug('chatListEntities');
|
const requestId = event.detail?.requestId;
|
||||||
|
this._logger.debug('chatListEntities', requestId ? `(request: ${requestId})` : '');
|
||||||
const entities = Array.from(this._diagramObjects.values()).map(obj => ({
|
const entities = Array.from(this._diagramObjects.values()).map(obj => ({
|
||||||
id: obj.diagramEntity.id,
|
id: obj.diagramEntity.id,
|
||||||
|
label: obj.diagramEntity.text || '',
|
||||||
template: obj.diagramEntity.template,
|
template: obj.diagramEntity.template,
|
||||||
text: obj.diagramEntity.text || '',
|
text: obj.diagramEntity.text || '',
|
||||||
color: obj.diagramEntity.color,
|
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', {
|
const responseEvent = new CustomEvent('chatListEntitiesResponse', {
|
||||||
detail: {entities},
|
detail: { entities, requestId },
|
||||||
bubbles: true
|
bubbles: true
|
||||||
});
|
});
|
||||||
document.dispatchEvent(responseEvent);
|
document.dispatchEvent(responseEvent);
|
||||||
|
|||||||
@ -220,6 +220,49 @@ export class DiagramObject {
|
|||||||
}, DiagramEventObserverMask.TO_DB);
|
}, 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) {
|
public set text(value: string) {
|
||||||
if (this._label) {
|
if (this._label) {
|
||||||
this._label.dispose();
|
this._label.dispose();
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import {ChatMessage, CreateSessionResponse, DiagramSession, DiagramToolCall, SessionEntity, SessionUsage, SyncEntitiesResponse, ToolResult} from "../types/chatTypes";
|
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 {v4 as uuidv4} from 'uuid';
|
||||||
import log from 'loglevel';
|
import log from 'loglevel';
|
||||||
|
|
||||||
@ -131,14 +131,26 @@ export function getCurrentModel(): ModelInfo {
|
|||||||
/**
|
/**
|
||||||
* Set current model
|
* Set current model
|
||||||
*/
|
*/
|
||||||
export function setCurrentModel(modelId: string): boolean {
|
export function setCurrentModel(modelIdOrName: string): boolean {
|
||||||
const model = AVAILABLE_MODELS.find(m => m.id === modelId);
|
// 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) {
|
if (model) {
|
||||||
currentModelId = modelId;
|
currentModelId = model.id;
|
||||||
logger.info('Model changed to:', model.name);
|
logger.info('Model changed to:', model.name, `(${model.id})`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
logger.warn('Invalid model ID:', modelId);
|
logger.warn('Invalid model ID or name:', modelIdOrName);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,6 +200,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
|
* Get session with conversation history
|
||||||
*/
|
*/
|
||||||
@ -218,6 +255,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
|
* Get token usage for current session
|
||||||
*/
|
*/
|
||||||
@ -238,18 +317,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
|
## CRITICAL: YOU MUST CALL TOOLS
|
||||||
1. When the user asks to create, add, or make something → CALL create_entity tool
|
**NEVER say "I have modified..." or "The entity has been..." without ACTUALLY calling a tool.**
|
||||||
2. When the user asks to connect things → CALL connect_entities tool
|
**If you don't call a tool, NOTHING happens. Your words alone cannot change the diagram.**
|
||||||
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
|
|
||||||
|
|
||||||
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
|
## 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.
|
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,13 +369,48 @@ When asked to diagram a technical concept, architecture, or system you're not fu
|
|||||||
## Available Shapes
|
## Available Shapes
|
||||||
box, sphere, cylinder, cone, plane, person
|
box, sphere, cylinder, cone, plane, person
|
||||||
|
|
||||||
## Available Colors
|
## Available Colors (Toolbox Palette)
|
||||||
red, blue, green, yellow, orange, purple, cyan, pink, white, black, brown, gray (or hex like #ff5500)
|
ONLY use these exact hex codes. Map any requested color to the closest match:
|
||||||
|
|
||||||
## Position Coordinates
|
| Color Name | Hex Code |
|
||||||
- x: left/right
|
|-----------------|-----------|
|
||||||
- y: up/down (1.5 = eye level, 0 = floor)
|
| Black/Dark Gray | #222222 |
|
||||||
- z: forward/backward
|
| 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
|
## Layout Guidelines
|
||||||
- Spread entities apart (at least 0.5 units)
|
- Spread entities apart (at least 0.5 units)
|
||||||
@ -295,11 +442,11 @@ const TOOLS = [
|
|||||||
position: {
|
position: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
x: {type: "number", description: "Left/right position"},
|
x: {type: "number", description: "Left (-) / Right (+) from camera view"},
|
||||||
y: {type: "number", description: "Up/down position (1.5 = eye level)"},
|
y: {type: "number", description: "Down (-) / Up (+), 0=floor, 1.5=eye level"},
|
||||||
z: {type: "number", description: "Forward/backward position"}
|
z: {type: "number", description: "Backward (-) / Forward (+) from camera view"}
|
||||||
},
|
},
|
||||||
description: "3D position. If not specified, defaults to (0, 1.5, 2)"
|
description: "3D position from camera perspective. Example: (0, 1.5, 2) = in front at eye level"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
required: ["shape"]
|
required: ["shape"]
|
||||||
@ -307,7 +454,7 @@ const TOOLS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "connect_entities",
|
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: {
|
input_schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@ -319,6 +466,10 @@ const TOOLS = [
|
|||||||
type: "string",
|
type: "string",
|
||||||
description: "ID or label of the target entity"
|
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: {
|
color: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Color of the connection line"
|
description: "Color of the connection line"
|
||||||
@ -327,6 +478,28 @@ const TOOLS = [
|
|||||||
required: ["from", "to"]
|
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",
|
name: "list_entities",
|
||||||
description: "List all entities currently in the diagram. Use this to see what exists before connecting or modifying.",
|
description: "List all entities currently in the diagram. Use this to see what exists before connecting or modifying.",
|
||||||
@ -351,44 +524,50 @@ const TOOLS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "modify_entity",
|
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: {
|
input_schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
target: {
|
target: {
|
||||||
type: "string",
|
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: {
|
color: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "New color for the entity"
|
description: "New color hex code from the toolbox palette"
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "New label text. Use empty string \"\" to remove the label."
|
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: {
|
position: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
x: {type: "number"},
|
x: {type: "number", description: "Left(-)/Right(+) in meters"},
|
||||||
y: {type: "number"},
|
y: {type: "number", description: "Down(-)/Up(+) in meters"},
|
||||||
z: {type: "number"}
|
z: {type: "number", description: "Back(-)/Forward(+) in meters"}
|
||||||
}
|
},
|
||||||
|
description: "New position in meters"
|
||||||
},
|
},
|
||||||
scale: {
|
scale: {
|
||||||
oneOf: [
|
oneOf: [
|
||||||
{
|
{
|
||||||
type: "number",
|
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",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
x: {type: "number"},
|
x: {type: "number", description: "Width in meters"},
|
||||||
y: {type: "number"},
|
y: {type: "number", description: "Height in meters"},
|
||||||
z: {type: "number"}
|
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."
|
description: "Scale/size of the entity. Use a number for uniform scaling or {x, y, z} for non-uniform."
|
||||||
@ -484,13 +663,13 @@ const TOOLS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "set_model",
|
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: {
|
input_schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
model_id: {
|
model_id: {
|
||||||
type: "string",
|
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"]
|
required: ["model_id"]
|
||||||
@ -548,14 +727,32 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise<ToolResult> {
|
|||||||
case 'create_entity':
|
case 'create_entity':
|
||||||
result = createEntity(toolCall.input);
|
result = createEntity(toolCall.input);
|
||||||
break;
|
break;
|
||||||
case 'connect_entities':
|
case 'connect_entities': {
|
||||||
result = await connectEntities(toolCall.input);
|
// 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;
|
break;
|
||||||
|
}
|
||||||
case 'remove_entity':
|
case 'remove_entity':
|
||||||
result = removeEntity(toolCall.input);
|
result = await removeEntity(toolCall.input);
|
||||||
break;
|
break;
|
||||||
case 'modify_entity':
|
case 'modify_entity':
|
||||||
result = modifyEntity(toolCall.input);
|
result = await modifyEntity(toolCall.input);
|
||||||
break;
|
break;
|
||||||
case 'modify_connection':
|
case 'modify_connection':
|
||||||
result = await modifyConnection(toolCall.input);
|
result = await modifyConnection(toolCall.input);
|
||||||
@ -579,12 +776,12 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise<ToolResult> {
|
|||||||
const models = getAvailableModels();
|
const models = getAvailableModels();
|
||||||
const current = getCurrentModel();
|
const current = getCurrentModel();
|
||||||
const modelList = models.map(m =>
|
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');
|
).join('\n\n');
|
||||||
result = {
|
result = {
|
||||||
toolName: 'list_models',
|
toolName: 'list_models',
|
||||||
success: true,
|
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;
|
break;
|
||||||
}
|
}
|
||||||
@ -598,7 +795,9 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise<ToolResult> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'set_model': {
|
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) {
|
if (success) {
|
||||||
const model = getCurrentModel();
|
const model = getCurrentModel();
|
||||||
result = {
|
result = {
|
||||||
@ -611,7 +810,7 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise<ToolResult> {
|
|||||||
result = {
|
result = {
|
||||||
toolName: 'set_model',
|
toolName: 'set_model',
|
||||||
success: false,
|
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;
|
break;
|
||||||
@ -636,6 +835,35 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise<ToolResult> {
|
|||||||
};
|
};
|
||||||
break;
|
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:
|
default:
|
||||||
result = {
|
result = {
|
||||||
toolName: 'unknown',
|
toolName: 'unknown',
|
||||||
@ -750,6 +978,9 @@ export async function sendMessage(
|
|||||||
logger.debug('[sendMessage] Starting with message:', userMessage);
|
logger.debug('[sendMessage] Starting with message:', userMessage);
|
||||||
logger.debug('[sendMessage] Session ID:', currentSessionId);
|
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
|
// When using sessions, we don't need to send full history - server manages it
|
||||||
// Just send the new message
|
// Just send the new message
|
||||||
const messages: ClaudeMessage[] = currentSessionId
|
const messages: ClaudeMessage[] = currentSessionId
|
||||||
@ -838,6 +1069,7 @@ export async function sendMessage(
|
|||||||
let modelSwitched = false;
|
let modelSwitched = false;
|
||||||
|
|
||||||
for (const toolBlock of toolBlocks) {
|
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));
|
logger.debug('[sendMessage] Tool call:', toolBlock.name, JSON.stringify(toolBlock.input));
|
||||||
const toolCall: DiagramToolCall = {
|
const toolCall: DiagramToolCall = {
|
||||||
name: toolBlock.name as DiagramToolCall['name'],
|
name: toolBlock.name as DiagramToolCall['name'],
|
||||||
|
|||||||
@ -16,15 +16,87 @@ import log from 'loglevel';
|
|||||||
const logger = log.getLogger('entityBridge');
|
const logger = log.getLogger('entityBridge');
|
||||||
logger.setLevel('debug');
|
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 {
|
function resolveColor(color?: string): string {
|
||||||
if (!color) return '#0000ff';
|
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]) {
|
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('#')) {
|
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';
|
return '#0000ff';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,6 +105,113 @@ interface ResolvedEntity {
|
|||||||
label: string | null;
|
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
|
* 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 {
|
export function createEntity(params: CreateEntityParams): ToolResult {
|
||||||
logger.debug('[createEntity] Creating entity:', params);
|
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 id = 'id' + uuidv4();
|
||||||
const template = SHAPE_TO_TEMPLATE[params.shape];
|
const template = SHAPE_TO_TEMPLATE[normalizedParams.shape];
|
||||||
const color = resolveColor(params.color);
|
const color = resolveColor(normalizedParams.color);
|
||||||
const position = params.position || {x: 0, y: 1.5, z: 2};
|
const position = normalizedParams.position || {x: 0, y: 1.5, z: 2};
|
||||||
|
|
||||||
const entity: DiagramEntity = {
|
const entity: DiagramEntity = {
|
||||||
id,
|
id,
|
||||||
template,
|
template,
|
||||||
type: DiagramEntityType.ENTITY,
|
type: DiagramEntityType.ENTITY,
|
||||||
color,
|
color,
|
||||||
text: params.label || '',
|
text: normalizedParams.label || '',
|
||||||
position,
|
position,
|
||||||
rotation: {x: 0, y: Math.PI, z: 0},
|
rotation: {x: 0, y: Math.PI, z: 0},
|
||||||
scale: {x: 0.1, y: 0.1, z: 0.1},
|
scale: {x: 0.1, y: 0.1, z: 0.1},
|
||||||
@ -95,7 +312,7 @@ export function createEntity(params: CreateEntityParams): ToolResult {
|
|||||||
const result = {
|
const result = {
|
||||||
toolName: 'create_entity',
|
toolName: 'create_entity',
|
||||||
success: true,
|
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
|
entityId: id
|
||||||
};
|
};
|
||||||
logger.debug('[createEntity] Done:', result);
|
logger.debug('[createEntity] Done:', result);
|
||||||
@ -128,10 +345,11 @@ export async function connectEntities(params: ConnectEntitiesParams): Promise<To
|
|||||||
const id = 'id' + uuidv4();
|
const id = 'id' + uuidv4();
|
||||||
const color = resolveColor(params.color);
|
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 fromLabel = fromEntity.label || params.from;
|
||||||
const toLabel = toEntity.label || params.to;
|
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 = {
|
const entity: DiagramEntity = {
|
||||||
id,
|
id,
|
||||||
@ -149,17 +367,45 @@ export async function connectEntities(params: ConnectEntitiesParams): Promise<To
|
|||||||
});
|
});
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
|
const labelInfo = connectionLabel ? ` with label "${connectionLabel}"` : ' (no label)';
|
||||||
return {
|
return {
|
||||||
toolName: 'connect_entities',
|
toolName: 'connect_entities',
|
||||||
success: true,
|
success: true,
|
||||||
message: `Connected "${fromLabel}" to "${toLabel}"`,
|
message: `Connected "${fromLabel}" to "${toLabel}"${labelInfo}`,
|
||||||
entityId: id
|
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', {
|
const event = new CustomEvent('chatRemoveEntity', {
|
||||||
detail: {target: params.target},
|
detail: { target },
|
||||||
bubbles: true
|
bubbles: true
|
||||||
});
|
});
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
@ -167,7 +413,7 @@ export function removeEntity(params: RemoveEntityParams): ToolResult {
|
|||||||
return {
|
return {
|
||||||
toolName: 'remove_entity',
|
toolName: 'remove_entity',
|
||||||
success: true,
|
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);
|
return degrees * (Math.PI / 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function modifyEntity(params: ModifyEntityParams): ToolResult {
|
export async function modifyEntity(params: ModifyEntityParams): Promise<ToolResult> {
|
||||||
const updates: Partial<DiagramEntity> = {};
|
// 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) {
|
// Handle 'entity' or 'name' as alias for 'target'
|
||||||
updates.color = resolveColor(params.color);
|
if (!normalizedParams.target && normalizedParams.entity) {
|
||||||
|
normalizedParams.target = normalizedParams.entity;
|
||||||
}
|
}
|
||||||
if (params.label !== undefined) {
|
if (!normalizedParams.target && normalizedParams.name) {
|
||||||
updates.text = params.label;
|
normalizedParams.target = normalizedParams.name;
|
||||||
}
|
}
|
||||||
if (params.position) {
|
|
||||||
updates.position = params.position;
|
// If still no target, try to infer it using color and shape hints
|
||||||
}
|
if (!normalizedParams.target) {
|
||||||
if (params.scale !== undefined) {
|
const entities = await getAllEntities();
|
||||||
// Accept either uniform scale (number) or 3D scale object
|
logger.debug('[modifyEntity] Trying to infer target from', entities.length, 'entities');
|
||||||
if (typeof params.scale === 'number') {
|
|
||||||
updates.scale = {x: params.scale, y: params.scale, z: params.scale};
|
// Try to find entity by color and/or shape
|
||||||
} else {
|
if (normalizedParams.color || normalizedParams.shape) {
|
||||||
updates.scale = params.scale;
|
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
|
// Accept degrees from AI, convert to radians for internal use
|
||||||
// Can be uniform (single number for Y rotation) or full 3D rotation
|
// 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")
|
// 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 {
|
} else {
|
||||||
updates.rotation = {
|
updates.rotation = {
|
||||||
x: degreesToRadians(params.rotation.x),
|
x: degreesToRadians(normalizedParams.rotation.x),
|
||||||
y: degreesToRadians(params.rotation.y),
|
y: degreesToRadians(normalizedParams.rotation.y),
|
||||||
z: degreesToRadians(params.rotation.z)
|
z: degreesToRadians(normalizedParams.rotation.z)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = new CustomEvent('chatModifyEntity', {
|
const event = new CustomEvent('chatModifyEntity', {
|
||||||
detail: {target: params.target, updates},
|
detail: {target: normalizedParams.target, updates},
|
||||||
bubbles: true
|
bubbles: true
|
||||||
});
|
});
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
const changes: string[] = [];
|
const changes: string[] = [];
|
||||||
if (params.color) changes.push(`color to ${params.color}`);
|
if (normalizedParams.color) changes.push(`color to ${normalizedParams.color}`);
|
||||||
if (params.label !== undefined) changes.push(`label to "${params.label}"`);
|
if (normalizedParams.label !== undefined) changes.push(`label to "${normalizedParams.label}"`);
|
||||||
if (params.position) changes.push(`position`);
|
if (normalizedParams.shape) changes.push(`shape to ${normalizedParams.shape}`);
|
||||||
if (params.scale !== undefined) {
|
if (normalizedParams.position) changes.push(`position`);
|
||||||
if (typeof params.scale === 'number') {
|
if (normalizedParams.scale !== undefined) {
|
||||||
changes.push(`scale to ${params.scale}`);
|
if (typeof normalizedParams.scale === 'number') {
|
||||||
|
changes.push(`scale to ${normalizedParams.scale}`);
|
||||||
} else {
|
} 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 (normalizedParams.rotation !== undefined) {
|
||||||
if (typeof params.rotation === 'number') {
|
if (typeof normalizedParams.rotation === 'number') {
|
||||||
changes.push(`rotation to ${params.rotation}°`);
|
changes.push(`rotation to ${normalizedParams.rotation}°`);
|
||||||
} else {
|
} 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 {
|
return {
|
||||||
toolName: 'modify_entity',
|
toolName: 'modify_entity',
|
||||||
success: true,
|
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) => {
|
return new Promise((resolve) => {
|
||||||
const responseHandler = (e: CustomEvent) => {
|
const responseHandler = (e: CustomEvent) => {
|
||||||
document.removeEventListener('chatListEntitiesResponse', responseHandler as EventListener);
|
document.removeEventListener('chatListEntitiesResponse', responseHandler as EventListener);
|
||||||
const entities = e.detail.entities as Array<{
|
const allItems = e.detail.entities as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
template: string;
|
template: string;
|
||||||
text: 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({
|
resolve({
|
||||||
toolName: 'list_entities',
|
toolName: 'list_entities',
|
||||||
success: true,
|
success: true,
|
||||||
@ -330,15 +697,38 @@ export function listEntities(): Promise<ToolResult> {
|
|||||||
return;
|
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', '');
|
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}]`;
|
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');
|
}).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({
|
resolve({
|
||||||
toolName: 'list_entities',
|
toolName: 'list_entities',
|
||||||
success: true,
|
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
|
* Get the current camera position and orientation
|
||||||
* Returns world-space coordinates with ground-projected directions for intuitive placement
|
* Returns world-space coordinates with ground-projected directions for intuitive placement
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export interface ConnectEntitiesParams {
|
|||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoveEntityParams {
|
export interface RemoveEntityParams {
|
||||||
@ -39,6 +40,7 @@ export interface ModifyEntityParams {
|
|||||||
target: string;
|
target: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
shape?: 'box' | 'sphere' | 'cylinder' | 'cone' | 'plane' | 'person';
|
||||||
position?: { x: number; y: number; z: number };
|
position?: { x: number; y: number; z: number };
|
||||||
scale?: { x: number; y: number; z: number } | number;
|
scale?: { x: number; y: number; z: number } | number;
|
||||||
rotation?: { x: number; y: number; z: number } | number; // in degrees
|
rotation?: { x: number; y: number; z: number } | number; // in degrees
|
||||||
@ -64,6 +66,10 @@ export interface WikipediaSearchParams {
|
|||||||
topic: string;
|
topic: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SetConnectionLabelPreferenceParams {
|
||||||
|
use_default_labels: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type DiagramToolCall =
|
export type DiagramToolCall =
|
||||||
| { name: 'create_entity'; input: CreateEntityParams }
|
| { name: 'create_entity'; input: CreateEntityParams }
|
||||||
| { name: 'connect_entities'; input: ConnectEntitiesParams }
|
| { name: 'connect_entities'; input: ConnectEntitiesParams }
|
||||||
@ -77,7 +83,9 @@ export type DiagramToolCall =
|
|||||||
| { name: 'get_current_model'; input: Record<string, never> }
|
| { name: 'get_current_model'; input: Record<string, never> }
|
||||||
| { name: 'set_model'; input: SetModelParams }
|
| { name: 'set_model'; input: SetModelParams }
|
||||||
| { name: 'clear_conversation'; input: Record<string, never> }
|
| { 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> = {
|
export const SHAPE_TO_TEMPLATE: Record<CreateEntityParams['shape'], DiagramTemplates> = {
|
||||||
box: DiagramTemplates.BOX,
|
box: DiagramTemplates.BOX,
|
||||||
|
|||||||
@ -33,8 +33,10 @@
|
|||||||
// raises an error for unused parameters
|
// raises an error for unused parameters
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
// raises an error for functions that return nothing
|
// raises an error for functions that return nothing
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true,
|
||||||
// skip type-checking of .d.ts files (it speeds up transpiling)
|
// 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": [
|
"include": [
|
||||||
"src"
|
"src"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user