diff --git a/package-lock.json b/package-lock.json index 879a1ef..e28b043 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "immersive", - "version": "0.0.8-47", + "version": "0.0.8-50", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immersive", - "version": "0.0.8-47", + "version": "0.0.8-50", "license": "MIT", "dependencies": { "@auth0/auth0-react": "^2.2.4", @@ -20,6 +20,9 @@ "@emotion/react": "^11.13.0", "@giphy/js-fetch-api": "^5.6.0", "@giphy/react-components": "^9.6.0", + "@langchain/anthropic": "^1.3.8", + "@langchain/core": "^1.1.13", + "@langchain/ollama": "^1.2.0", "@mantine/core": "^7.17.8", "@mantine/form": "^7.17.8", "@mantine/hooks": "^7.17.8", @@ -61,7 +64,9 @@ "uuid": "^9.0.1", "vite-express": "^0.21.1", "websocket": "^1.0.34", - "websocket-ts": "^2.1.5" + "websocket-ts": "^2.1.5", + "zod": "^4.3.5", + "zod-to-json-schema": "^3.25.1" }, "devDependencies": { "@types/dom-to-image": "^2.6.7", @@ -74,6 +79,26 @@ "node": ">=18.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.71.2", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz", + "integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@apm-js-collab/code-transformer": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz", @@ -333,6 +358,12 @@ "babylonjs-gltf2interface": "^8.0.0" } }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -1089,6 +1120,97 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@langchain/anthropic": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.8.tgz", + "integrity": "sha512-liCcRIkoA07s7bVbGpimCwGSIN8JfMdhV+7fji/7jyPI5P6T2TM1vTIL/D6cGMcEiq7DKQK8mTkmO98cv/DpPA==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.71.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "1.1.13" + } + }, + "node_modules/@langchain/core": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.13.tgz", + "integrity": "sha512-CmTES4DNfNs7PisGm/is4RxOf1NAWCkhi+RrBBHb/gB5nZVFd+dfmXSomKoiBQ1DOdCUz1k9RX4DzSUbwg1swg==", + "license": "MIT", + "dependencies": { + "@cfworker/json-schema": "^4.0.2", + "ansi-styles": "^5.0.0", + "camelcase": "6", + "decamelize": "1.2.0", + "js-tiktoken": "^1.0.12", + "langsmith": ">=0.4.0 <1.0.0", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "uuid": "^10.0.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@langchain/core/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@langchain/core/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@langchain/ollama": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@langchain/ollama/-/ollama-1.2.0.tgz", + "integrity": "sha512-OinxIhssKXdDQKnQoBF4TQTMBuMMV5OcNPk4Zze8UjcaSOGngn3CAI1FVbBxl0bTG5ov61w4AoWWsUwOwiSJFw==", + "license": "MIT", + "dependencies": { + "ollama": "^0.6.3", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.0.0" + } + }, + "node_modules/@langchain/ollama/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@mantine/core": { "version": "7.17.8", "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.17.8.tgz", @@ -2209,6 +2331,12 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@tyriar/fibonacci-heap": { "version": "2.0.9", "resolved": "https://registry.npmjs.org/@tyriar/fibonacci-heap/-/fibonacci-heap-2.0.9.tgz", @@ -2785,6 +2913,18 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/camelize": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", @@ -3088,6 +3228,15 @@ "integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==", "dev": true }, + "node_modules/console-table-printer": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.15.0.tgz", + "integrity": "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==", + "license": "MIT", + "dependencies": { + "simple-wcswidth": "^1.1.2" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -3372,6 +3521,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-uri-component": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz", @@ -3830,6 +3988,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -5294,6 +5458,15 @@ "resolved": "https://registry.npmjs.org/js-crypto-env/-/js-crypto-env-1.0.5.tgz", "integrity": "sha512-8/UNN3sG8J+yMzqwSNVaobaWhIz4MqZFoOg5OB0DFXqS8eFjj2YvdmLJqIWXPl57Yw10SvYx0DQOtkfsWIV9Aw==" }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5336,6 +5509,19 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -5420,6 +5606,123 @@ "node": ">= 8" } }, + "node_modules/langsmith": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.4.6.tgz", + "integrity": "sha512-9aYop1fEwA8RgFuvv8XPeV9ieeSnKnVRn3bNemkFQCyINLAxfNHC547bVMW8i8MuS1F1pgKwopqhLNf80qS1bQ==", + "license": "MIT", + "dependencies": { + "@types/uuid": "^10.0.0", + "chalk": "^4.1.2", + "console-table-printer": "^2.12.1", + "p-queue": "^6.6.2", + "semver": "^7.6.3", + "uuid": "^10.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": "*", + "@opentelemetry/exporter-trace-otlp-proto": "*", + "@opentelemetry/sdk-trace-base": "*", + "openai": "*" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "openai": { + "optional": true + } + } + }, + "node_modules/langsmith/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/langsmith/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/langsmith/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/langsmith/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/langsmith/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/langsmith/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/langsmith/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/level": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/level/-/level-6.0.1.tgz", @@ -6079,6 +6382,15 @@ "node": ">= 0.6" } }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/nan": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", @@ -6311,6 +6623,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ollama": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz", + "integrity": "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==", + "license": "MIT", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6356,6 +6677,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", @@ -6371,6 +6701,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8292,6 +8650,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-wcswidth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz", + "integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==", + "license": "MIT" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -8829,6 +9193,12 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-easing": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz", @@ -9386,6 +9756,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -9645,6 +10021,24 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/package.json b/package.json index 556af2e..1ab240f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "immersive", "private": true, - "version": "0.0.8-48", + "version": "0.0.8-50", "type": "module", "license": "MIT", "engines": { @@ -29,6 +29,9 @@ "@emotion/react": "^11.13.0", "@giphy/js-fetch-api": "^5.6.0", "@giphy/react-components": "^9.6.0", + "@langchain/anthropic": "^1.3.8", + "@langchain/core": "^1.1.13", + "@langchain/ollama": "^1.2.0", "@mantine/core": "^7.17.8", "@mantine/form": "^7.17.8", "@mantine/hooks": "^7.17.8", @@ -70,7 +73,9 @@ "uuid": "^9.0.1", "vite-express": "^0.21.1", "websocket": "^1.0.34", - "websocket-ts": "^2.1.5" + "websocket-ts": "^2.1.5", + "zod": "^4.3.5", + "zod-to-json-schema": "^3.25.1" }, "devDependencies": { "@types/dom-to-image": "^2.6.7", @@ -79,4 +84,4 @@ "vite-plugin-cp": "^1.0.0", "vitest": "^1.4.0" } -} \ No newline at end of file +} diff --git a/server/api/claude.js b/server/api/claude.js index 5862ee1..0139e8f 100644 --- a/server/api/claude.js +++ b/server/api/claude.js @@ -1,178 +1,124 @@ import { Router } from "express"; -import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js"; +import { getSession, addMessage } from "../services/sessionStore.js"; import { trackUsage, getUsageSummary, formatCost, getSessionUsage } from "../services/usageTracker.js"; +import { + getClaudeModel, + buildLangChainMessages, + aiMessageToClaudeResponse +} from "../services/langchainModels.js"; const router = Router(); -const ANTHROPIC_API_URL = "https://api.anthropic.com"; -/** - * Build entity context string for the system prompt - */ -function buildEntityContext(entities) { - if (!entities || entities.length === 0) { - return "\n\nThe diagram is currently empty."; - } - - const entityList = entities.map(e => { - const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown'; - const pos = e.position || { x: 0, y: 0, z: 0 }; - return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`; - }).join('\n'); - - return `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`; -} - -// Express 5 uses named parameters for wildcards router.post("/*path", async (req, res) => { - const requestStart = Date.now(); - console.log(`[Claude API] ========== REQUEST START ==========`); + const requestStart = Date.now(); + console.log(`[Claude API] ========== REQUEST START ==========`); - const apiKey = process.env.ANTHROPIC_API_KEY; + const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) { - console.error(`[Claude API] ERROR: API key not configured`); - return res.status(500).json({ error: "API key not configured" }); - } - - // Get the path after /api/claude (e.g., /v1/messages) - // Express 5 returns path segments as an array - const pathParam = req.params.path; - const path = "/" + (Array.isArray(pathParam) ? pathParam.join("/") : pathParam || ""); - console.log(`[Claude API] Path: ${path}`); - - // Check for session-based request - const { sessionId, ...requestBody } = req.body; - let modifiedBody = requestBody; - console.log(`[Claude API] Session ID: ${sessionId || 'none'}`); - console.log(`[Claude API] Model: ${requestBody.model}`); - console.log(`[Claude API] Messages count: ${requestBody.messages?.length || 0}`); - - if (sessionId) { - const session = getSession(sessionId); - if (session) { - console.log(`[Claude API] Session found: ${session.entities.length} entities, ${session.conversationHistory.length} messages in history`); - - // Inject entity context into system prompt - if (modifiedBody.system) { - const entityContext = buildEntityContext(session.entities); - console.log(`[Claude API] Entity context added (${entityContext.length} chars)`); - modifiedBody.system += entityContext; - } - - // Get conversation history and merge with current messages - const historyMessages = getConversationForAPI(sessionId); - if (historyMessages.length > 0 && modifiedBody.messages) { - // Filter out any duplicate messages (in case client sent history too) - const currentContent = modifiedBody.messages[modifiedBody.messages.length - 1]?.content; - const filteredHistory = historyMessages.filter(msg => msg.content !== currentContent); - modifiedBody.messages = [...filteredHistory, ...modifiedBody.messages]; - console.log(`[Claude API] Merged ${filteredHistory.length} history + ${modifiedBody.messages.length - filteredHistory.length} new = ${modifiedBody.messages.length} total messages`); - } - } else { - console.log(`[Claude API] WARNING: Session ${sessionId} not found`); - } - } - - try { - console.log(`[Claude API] Sending request to Anthropic API...`); - const fetchStart = Date.now(); - - const response = await fetch(`${ANTHROPIC_API_URL}${path}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - }, - body: JSON.stringify(modifiedBody), - }); - - const fetchDuration = Date.now() - fetchStart; - console.log(`[Claude API] Response received in ${fetchDuration}ms, status: ${response.status}`); - - console.log(`[Claude API] Parsing response JSON...`); - const data = await response.json(); - console.log(`[Claude API] Response parsed. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`); - - // Track and log token usage - if (data.usage) { - // Extract content for detailed tracking - const userMessage = requestBody.messages?.[requestBody.messages.length - 1]; - const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null; - - const outputText = data.content - ?.filter(c => c.type === 'text') - .map(c => c.text) - .join('\n') || null; - - const toolCalls = data.content - ?.filter(c => c.type === 'tool_use') - .map(c => ({ name: c.name, input: c.input })) || []; - - const usageRecord = trackUsage(sessionId, modifiedBody.model, data.usage, { - inputText, - outputText, - toolCalls - }); - console.log(`[Claude API] REQUEST USAGE: ${getUsageSummary(usageRecord)}`); - - // Log cumulative session usage if session exists - if (sessionId) { - const sessionStats = getSessionUsage(sessionId); - if (sessionStats) { - console.log(`[Claude API] SESSION TOTALS (${sessionStats.requestCount} requests):`); - console.log(`[Claude API] Total input: ${sessionStats.totalInputTokens} tokens`); - console.log(`[Claude API] Total output: ${sessionStats.totalOutputTokens} tokens`); - console.log(`[Claude API] Total cost: ${formatCost(sessionStats.totalCost)}`); - } - } + if (!apiKey) { + console.error(`[Claude API] ERROR: API key not configured`); + return res.status(500).json({ error: "API key not configured" }); } - if (data.error) { - console.error(`[Claude API] API returned error:`, data.error); - } + const { sessionId, model: modelId, system: systemPrompt, ...requestBody } = req.body; + console.log(`[Claude API] Session ID: ${sessionId || 'none'}`); + console.log(`[Claude API] Model: ${modelId}`); + console.log(`[Claude API] Messages count: ${requestBody.messages?.length || 0}`); - // If session exists and response is successful, store messages - if (sessionId && response.ok && data.content) { - const session = getSession(sessionId); - if (session) { - // Store the user message if it was new (only if it's a string, not tool results) - const userMessage = requestBody.messages?.[requestBody.messages.length - 1]; - if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') { - addMessage(sessionId, { - role: 'user', - content: userMessage.content - }); - console.log(`[Claude API] Stored user message to session`); + try { + // Get LangChain model with tools bound + const model = getClaudeModel(modelId); + + // Build messages with entity context and history + const messages = buildLangChainMessages( + sessionId, + requestBody.messages, + systemPrompt + ); + + console.log(`[Claude API] Sending request via LangChain...`); + const fetchStart = Date.now(); + + // Invoke model + const response = await model.invoke(messages); + + const fetchDuration = Date.now() - fetchStart; + console.log(`[Claude API] Response received in ${fetchDuration}ms`); + + // Convert to Claude API format for client compatibility + const data = aiMessageToClaudeResponse(response, modelId); + console.log(`[Claude API] Response converted. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`); + + // Track and log token usage + if (data.usage) { + const userMessage = requestBody.messages?.[requestBody.messages.length - 1]; + const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null; + + const outputText = data.content + ?.filter(c => c.type === 'text') + .map(c => c.text) + .join('\n') || null; + + const toolCalls = data.content + ?.filter(c => c.type === 'tool_use') + .map(c => ({ name: c.name, input: c.input })) || []; + + const usageRecord = trackUsage(sessionId, modelId, data.usage, { + inputText, + outputText, + toolCalls + }); + 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) - const assistantContent = data.content - .filter(c => c.type === 'text') - .map(c => c.text) - .join('\n'); + // Store messages to session + if (sessionId && data.content) { + const session = getSession(sessionId); + if (session) { + const userMessage = requestBody.messages?.[requestBody.messages.length - 1]; + if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') { + addMessage(sessionId, { + role: 'user', + content: userMessage.content + }); + console.log(`[Claude API] Stored user message to session`); + } - if (assistantContent) { - addMessage(sessionId, { - role: 'assistant', - content: assistantContent - }); - console.log(`[Claude API] Stored assistant response to session (${assistantContent.length} chars)`); + const assistantContent = data.content + .filter(c => c.type === 'text') + .map(c => c.text) + .join('\n'); + + if (assistantContent) { + addMessage(sessionId, { + role: 'assistant', + content: assistantContent + }); + console.log(`[Claude API] Stored assistant response to session (${assistantContent.length} chars)`); + } + } } - } - } - const totalDuration = Date.now() - requestStart; - console.log(`[Claude API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`); - res.status(response.status).json(data); - } catch (error) { - const totalDuration = Date.now() - requestStart; - console.error(`[Claude API] ========== REQUEST FAILED (${totalDuration}ms) ==========`); - console.error(`[Claude API] Error:`, error); - console.error(`[Claude API] Error message:`, error.message); - console.error(`[Claude API] Error stack:`, error.stack); - res.status(500).json({ error: "Failed to proxy request to Claude API", details: error.message }); - } + const totalDuration = Date.now() - requestStart; + console.log(`[Claude API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`); + res.json(data); + } catch (error) { + const totalDuration = Date.now() - requestStart; + console.error(`[Claude API] ========== REQUEST FAILED (${totalDuration}ms) ==========`); + console.error(`[Claude API] Error:`, error); + console.error(`[Claude API] Error message:`, error.message); + res.status(500).json({ error: "Failed to call Claude API", details: error.message }); + } }); export default router; diff --git a/server/api/cloudflare.js b/server/api/cloudflare.js index ebd6acd..1f1ed01 100644 --- a/server/api/cloudflare.js +++ b/server/api/cloudflare.js @@ -1,213 +1,129 @@ import { Router } from "express"; -import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js"; +import { getSession, addMessage } from "../services/sessionStore.js"; import { trackUsage, getUsageSummary, formatCost, getSessionUsage } from "../services/usageTracker.js"; import { getCloudflareAccountId, getCloudflareApiToken } from "../services/providerConfig.js"; -import { - claudeToolsToCloudflare, - claudeMessagesToCloudflare, - cloudflareResponseToClaude -} from "../services/toolConverter.js"; +import { getCloudflareModel } from "../services/ChatCloudflare.js"; +import { buildLangChainMessages, aiMessageToClaudeResponse } from "../services/langchainModels.js"; const router = Router(); -/** - * Build entity context string for the system prompt - */ -function buildEntityContext(entities) { - if (!entities || entities.length === 0) { - return "\n\nThe diagram is currently empty."; - } - - const entityList = entities.map(e => { - const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown'; - const pos = e.position || { x: 0, y: 0, z: 0 }; - return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`; - }).join('\n'); - - return `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`; -} - -// Express 5 uses named parameters for wildcards router.post("/*path", async (req, res) => { - const requestStart = Date.now(); - console.log(`[Cloudflare API] ========== REQUEST START ==========`); + const requestStart = Date.now(); + console.log(`[Cloudflare API] ========== REQUEST START ==========`); - const accountId = getCloudflareAccountId(); - const apiToken = getCloudflareApiToken(); + const accountId = getCloudflareAccountId(); + const apiToken = getCloudflareApiToken(); - if (!accountId) { - console.error(`[Cloudflare API] ERROR: Account ID not configured`); - return res.status(500).json({ error: "Cloudflare account ID not configured" }); - } - - if (!apiToken) { - console.error(`[Cloudflare API] ERROR: API token not configured`); - return res.status(500).json({ error: "Cloudflare API token not configured" }); - } - - // Check for session-based request - const { sessionId, ...requestBody } = req.body; - let modifiedBody = { ...requestBody }; - const model = requestBody.model; - - console.log(`[Cloudflare API] Session ID: ${sessionId || 'none'}`); - console.log(`[Cloudflare API] Model: ${model}`); - console.log(`[Cloudflare API] Messages count: ${requestBody.messages?.length || 0}`); - - // Build system prompt with entity context - let systemPrompt = modifiedBody.system || ''; - - if (sessionId) { - const session = getSession(sessionId); - if (session) { - console.log(`[Cloudflare API] Session found: ${session.entities.length} entities, ${session.conversationHistory.length} messages in history`); - - // Inject entity context into system prompt - const entityContext = buildEntityContext(session.entities); - console.log(`[Cloudflare API] Entity context added (${entityContext.length} chars)`); - systemPrompt += entityContext; - - // Get conversation history and merge with current messages - const historyMessages = getConversationForAPI(sessionId); - if (historyMessages.length > 0 && modifiedBody.messages) { - const currentContent = modifiedBody.messages[modifiedBody.messages.length - 1]?.content; - const filteredHistory = historyMessages.filter(msg => msg.content !== currentContent); - modifiedBody.messages = [...filteredHistory, ...modifiedBody.messages]; - console.log(`[Cloudflare API] Merged ${filteredHistory.length} history + ${modifiedBody.messages.length - filteredHistory.length} new = ${modifiedBody.messages.length} total messages`); - } - } else { - console.log(`[Cloudflare API] WARNING: Session ${sessionId} not found`); - } - } - - try { - // Convert to Cloudflare format - const cfMessages = claudeMessagesToCloudflare(modifiedBody.messages || [], systemPrompt); - const cfTools = modifiedBody.tools ? claudeToolsToCloudflare(modifiedBody.tools) : undefined; - - // Build Cloudflare request body - const cfRequestBody = { - messages: cfMessages, - max_tokens: modifiedBody.max_tokens || 1024 - }; - - // Only include tools if the model supports them - if (cfTools && cfTools.length > 0) { - cfRequestBody.tools = cfTools; + if (!accountId) { + console.error(`[Cloudflare API] ERROR: Account ID not configured`); + return res.status(500).json({ error: "Cloudflare account ID not configured" }); } - // Cloudflare endpoint: https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model} - const endpoint = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${model}`; - - console.log(`[Cloudflare API] Sending request to: ${endpoint}`); - console.log(`[Cloudflare API] Request body messages: ${cfMessages.length}, tools: ${cfTools?.length || 0}`); - const requestBodyJson = JSON.stringify(cfRequestBody); - console.log(`[Cloudflare API] Full request body (${requestBodyJson.length} bytes):`); - console.log(requestBodyJson); - const fetchStart = Date.now(); - - const response = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${apiToken}`, - }, - body: JSON.stringify(cfRequestBody), - }); - - const fetchDuration = Date.now() - fetchStart; - console.log(`[Cloudflare API] Response received in ${fetchDuration}ms, status: ${response.status}`); - - console.log(`[Cloudflare API] Parsing response JSON...`); - const cfData = await response.json(); - - if (!cfData.success) { - console.error(`[Cloudflare API] API returned error:`, cfData.errors); - return res.status(response.status).json({ - error: cfData.errors?.[0]?.message || "Cloudflare API error", - details: cfData.errors - }); + if (!apiToken) { + console.error(`[Cloudflare API] ERROR: API token not configured`); + return res.status(500).json({ error: "Cloudflare API token not configured" }); } - // Convert Cloudflare response to Claude format - const data = cloudflareResponseToClaude(cfData, model); - console.log(`[Cloudflare API] Response converted. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`); + const { sessionId, model: modelId, system: systemPrompt, messages } = req.body; - // Track and log token usage - if (data.usage) { - // Extract content for detailed tracking - const userMessage = requestBody.messages?.[requestBody.messages.length - 1]; - const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null; + console.log(`[Cloudflare API] Session ID: ${sessionId || 'none'}`); + console.log(`[Cloudflare API] Model: ${modelId}`); + console.log(`[Cloudflare API] Messages count: ${messages?.length || 0}`); - const outputText = data.content - ?.filter(c => c.type === 'text') - .map(c => c.text) - .join('\n') || null; + try { + // Get LangChain-compatible Cloudflare model with tools bound + const model = getCloudflareModel(modelId); - const toolCalls = data.content - ?.filter(c => c.type === 'tool_use') - .map(c => ({ name: c.name, input: c.input })) || []; + // Build messages with entity context and history + const langChainMessages = buildLangChainMessages( + sessionId, + messages, + systemPrompt + ); - const usageRecord = trackUsage(sessionId, model, data.usage, { - inputText, - outputText, - toolCalls - }); - console.log(`[Cloudflare API] REQUEST USAGE: ${getUsageSummary(usageRecord)}`); + console.log(`[Cloudflare API] Sending request via LangChain ChatCloudflare...`); + const fetchStart = Date.now(); - // Log cumulative session usage if session exists - if (sessionId) { - const sessionStats = getSessionUsage(sessionId); - if (sessionStats) { - console.log(`[Cloudflare API] SESSION TOTALS (${sessionStats.requestCount} requests):`); - console.log(`[Cloudflare API] Total input: ${sessionStats.totalInputTokens} tokens`); - console.log(`[Cloudflare API] Total output: ${sessionStats.totalOutputTokens} tokens`); - console.log(`[Cloudflare API] Total cost: ${formatCost(sessionStats.totalCost)}`); - } - } - } + // Invoke model + const response = await model.invoke(langChainMessages); - // If session exists and response is successful, store messages - if (sessionId && response.ok && data.content) { - const session = getSession(sessionId); - if (session) { - // Store the user message if it was new (only if it's a string, not tool results) - const userMessage = requestBody.messages?.[requestBody.messages.length - 1]; - if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') { - addMessage(sessionId, { - role: 'user', - content: userMessage.content - }); - console.log(`[Cloudflare API] Stored user message to session`); + const fetchDuration = Date.now() - fetchStart; + console.log(`[Cloudflare API] Response received in ${fetchDuration}ms`); + + // Convert to Claude API format for client compatibility + const data = aiMessageToClaudeResponse(response, modelId); + console.log(`[Cloudflare API] Response converted. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`); + + // Track and log token usage + if (data.usage) { + const userMessage = messages?.[messages.length - 1]; + const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null; + + const outputText = data.content + ?.filter(c => c.type === 'text') + .map(c => c.text) + .join('\n') || null; + + const toolCalls = data.content + ?.filter(c => c.type === 'tool_use') + .map(c => ({ name: c.name, input: c.input })) || []; + + const 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) - const assistantContent = data.content - .filter(c => c.type === 'text') - .map(c => c.text) - .join('\n'); + // Store messages to session + if (sessionId && data.content) { + const session = getSession(sessionId); + if (session) { + const userMessage = messages?.[messages.length - 1]; + if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') { + addMessage(sessionId, { + role: 'user', + content: userMessage.content + }); + console.log(`[Cloudflare API] Stored user message to session`); + } - if (assistantContent) { - addMessage(sessionId, { - role: 'assistant', - content: assistantContent - }); - console.log(`[Cloudflare API] Stored assistant response to session (${assistantContent.length} chars)`); + const assistantContent = data.content + .filter(c => c.type === 'text') + .map(c => c.text) + .join('\n'); + + if (assistantContent) { + addMessage(sessionId, { + role: 'assistant', + content: assistantContent + }); + console.log(`[Cloudflare API] Stored assistant response to session (${assistantContent.length} chars)`); + } + } } - } - } - const totalDuration = Date.now() - requestStart; - console.log(`[Cloudflare API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`); - res.status(response.status).json(data); - } catch (error) { - const totalDuration = Date.now() - requestStart; - console.error(`[Cloudflare API] ========== REQUEST FAILED (${totalDuration}ms) ==========`); - console.error(`[Cloudflare API] Error:`, error); - console.error(`[Cloudflare API] Error message:`, error.message); - console.error(`[Cloudflare API] Error stack:`, error.stack); - res.status(500).json({ error: "Failed to proxy request to Cloudflare API", details: error.message }); - } + const totalDuration = Date.now() - requestStart; + console.log(`[Cloudflare API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`); + res.json(data); + } catch (error) { + const totalDuration = Date.now() - requestStart; + console.error(`[Cloudflare API] ========== REQUEST FAILED (${totalDuration}ms) ==========`); + console.error(`[Cloudflare API] Error:`, error); + console.error(`[Cloudflare API] Error message:`, error.message); + res.status(500).json({ error: "Failed to call Cloudflare API", details: error.message }); + } }); export default router; diff --git a/server/api/ollama.js b/server/api/ollama.js index 1d3f8de..2b32f8c 100644 --- a/server/api/ollama.js +++ b/server/api/ollama.js @@ -1,131 +1,51 @@ import { Router } from "express"; -import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js"; -import { getOllamaUrl } from "../services/providerConfig.js"; +import { getSession, addMessage } from "../services/sessionStore.js"; import { - claudeToolsToOllama, - claudeMessagesToOllama, - ollamaResponseToClaude -} from "../services/toolConverter.js"; + getOllamaModel, + buildLangChainMessages, + aiMessageToClaudeResponse +} from "../services/langchainModels.js"; const router = Router(); -/** - * Build entity context string for the system prompt - */ -function buildEntityContext(entities) { - if (!entities || entities.length === 0) { - return "\n\nThe diagram is currently empty."; - } - - const entityList = entities.map(e => { - const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown'; - const pos = e.position || { x: 0, y: 0, z: 0 }; - return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`; - }).join('\n'); - - return `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`; -} - -/** - * Handle Ollama chat requests - * Accepts Claude-format requests and converts them to Ollama format - */ router.post("/*path", async (req, res) => { const requestStart = Date.now(); console.log(`[Ollama API] ========== REQUEST START ==========`); - const ollamaUrl = getOllamaUrl(); - console.log(`[Ollama API] Using Ollama at: ${ollamaUrl}`); - - // Extract request body (Claude format) - const { sessionId, model, max_tokens, system, tools, messages } = req.body; + const { sessionId, model: modelId, max_tokens, system: systemPrompt, messages } = req.body; console.log(`[Ollama API] Session ID: ${sessionId || 'none'}`); - console.log(`[Ollama API] Model: ${model}`); + console.log(`[Ollama API] Model: ${modelId}`); console.log(`[Ollama API] Messages count: ${messages?.length || 0}`); - // Build system prompt with entity context - let systemPrompt = system || ''; - - if (sessionId) { - const session = getSession(sessionId); - if (session) { - console.log(`[Ollama API] Session found: ${session.entities.length} entities, ${session.conversationHistory.length} messages in history`); - - // Inject entity context into system prompt - const entityContext = buildEntityContext(session.entities); - console.log(`[Ollama API] Entity context added (${entityContext.length} chars)`); - systemPrompt += entityContext; - - // Get conversation history and merge with current messages - const historyMessages = getConversationForAPI(sessionId); - if (historyMessages.length > 0 && messages) { - const currentContent = messages[messages.length - 1]?.content; - const filteredHistory = historyMessages.filter(msg => msg.content !== currentContent); - messages.unshift(...filteredHistory); - console.log(`[Ollama API] Merged ${filteredHistory.length} history messages`); - } - } else { - console.log(`[Ollama API] WARNING: Session ${sessionId} not found`); - } - } - - // Convert to Ollama format - const ollamaMessages = claudeMessagesToOllama(messages || [], systemPrompt); - const ollamaTools = claudeToolsToOllama(tools); - - const ollamaRequest = { - model: model, - messages: ollamaMessages, - stream: false, - options: { - num_predict: max_tokens || 1024 - } - }; - - // Only add tools if there are any - if (ollamaTools.length > 0) { - ollamaRequest.tools = ollamaTools; - } - - console.log(`[Ollama API] Converted to Ollama format: ${ollamaMessages.length} messages, ${ollamaTools.length} tools`); - try { - console.log(`[Ollama API] Sending request to Ollama...`); + // Get LangChain model with tools bound + const model = getOllamaModel(modelId); + + // Build messages with entity context and history + const langChainMessages = buildLangChainMessages( + sessionId, + messages, + systemPrompt + ); + + console.log(`[Ollama API] Sending request via LangChain...`); const fetchStart = Date.now(); - const response = await fetch(`${ollamaUrl}/api/chat`, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(ollamaRequest) - }); + // Invoke model + const response = await model.invoke(langChainMessages); const fetchDuration = Date.now() - fetchStart; - console.log(`[Ollama API] Response received in ${fetchDuration}ms, status: ${response.status}`); + console.log(`[Ollama API] Response received in ${fetchDuration}ms`); - if (!response.ok) { - const errorText = await response.text(); - console.error(`[Ollama API] Error response:`, errorText); - return res.status(response.status).json({ - error: `Ollama API error: ${response.status}`, - details: errorText - }); - } - - const ollamaData = await response.json(); - console.log(`[Ollama API] Response parsed. Done: ${ollamaData.done}, model: ${ollamaData.model}`); - - // Convert response back to Claude format - const claudeResponse = ollamaResponseToClaude(ollamaData); - console.log(`[Ollama API] Converted to Claude format. Stop reason: ${claudeResponse.stop_reason}, content blocks: ${claudeResponse.content.length}`); + // Convert to Claude API format for client compatibility + const claudeResponse = aiMessageToClaudeResponse(response, modelId); + console.log(`[Ollama API] Response converted. Stop reason: ${claudeResponse.stop_reason}, content blocks: ${claudeResponse.content.length}`); // Store messages to session if applicable if (sessionId && claudeResponse.content) { const session = getSession(sessionId); if (session) { - // Store the user message if it was new const userMessage = messages?.[messages.length - 1]; if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') { addMessage(sessionId, { @@ -135,7 +55,6 @@ router.post("/*path", async (req, res) => { console.log(`[Ollama API] Stored user message to session`); } - // Store the assistant response (text only) const assistantContent = claudeResponse.content .filter(c => c.type === 'text') .map(c => c.text) @@ -161,15 +80,15 @@ router.post("/*path", async (req, res) => { console.error(`[Ollama API] Error:`, error); // Check if it's a connection error - if (error.cause?.code === 'ECONNREFUSED') { + if (error.cause?.code === 'ECONNREFUSED' || error.message?.includes('ECONNREFUSED')) { return res.status(503).json({ error: "Ollama is not running", - details: `Could not connect to Ollama at ${ollamaUrl}. Make sure Ollama is installed and running.` + details: `Could not connect to Ollama. Make sure Ollama is installed and running.` }); } res.status(500).json({ - error: "Failed to proxy request to Ollama", + error: "Failed to call Ollama", details: error.message }); } diff --git a/server/api/session.js b/server/api/session.js index 88a3f66..234b6ab 100644 --- a/server/api/session.js +++ b/server/api/session.js @@ -4,10 +4,13 @@ import { getSession, findSessionByDiagram, syncEntities, + syncCameraPosition, addMessage, clearHistory, deleteSession, - getStats + getStats, + getPreferences, + setPreference } from "../services/sessionStore.js"; import { getSessionUsage, getGlobalUsage, formatCost } from "../services/usageTracker.js"; @@ -125,6 +128,26 @@ router.put("/:id/sync", (req, res) => { res.json({ success: true, entityCount: entities.length }); }); +/** + * PUT /api/session/:id/camera + * Sync camera position from client to server + */ +router.put("/:id/camera", (req, res) => { + const { cameraPosition } = req.body; + + if (!cameraPosition) { + return res.status(400).json({ error: "cameraPosition is required" }); + } + + const session = syncCameraPosition(req.params.id, cameraPosition); + + if (!session) { + return res.status(404).json({ error: "Session not found" }); + } + + res.json({ success: true }); +}); + /** * POST /api/session/:id/message * Add a message to history (used after successful Claude response) @@ -173,4 +196,39 @@ router.delete("/:id", (req, res) => { res.json({ success: true }); }); +/** + * GET /api/session/:id/preferences + * Get session preferences + */ +router.get("/:id/preferences", (req, res) => { + const preferences = getPreferences(req.params.id); + + if (preferences === null) { + return res.status(404).json({ error: "Session not found" }); + } + + res.json({ preferences }); +}); + +/** + * PUT /api/session/:id/preferences + * Set a session preference + */ +router.put("/:id/preferences", (req, res) => { + const { key, value } = req.body; + + if (!key) { + return res.status(400).json({ error: "key is required" }); + } + + const preferences = setPreference(req.params.id, key, value); + + if (preferences === null) { + return res.status(404).json({ error: "Session not found" }); + } + + console.log(`[Session ${req.params.id}] Set preference: ${key} = ${value}`); + res.json({ success: true, preferences }); +}); + export default router; diff --git a/server/services/ChatCloudflare.js b/server/services/ChatCloudflare.js new file mode 100644 index 0000000..b99a62f --- /dev/null +++ b/server/services/ChatCloudflare.js @@ -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; diff --git a/server/services/langchainModels.js b/server/services/langchainModels.js new file mode 100644 index 0000000..a58557c --- /dev/null +++ b/server/services/langchainModels.js @@ -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 +}; diff --git a/server/services/langchainTools.js b/server/services/langchainTools.js new file mode 100644 index 0000000..1826d47 --- /dev/null +++ b/server/services/langchainTools.js @@ -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 +}; diff --git a/server/services/sessionStore.js b/server/services/sessionStore.js index c997efb..aebde88 100644 --- a/server/services/sessionStore.js +++ b/server/services/sessionStore.js @@ -11,6 +11,8 @@ import { v4 as uuidv4 } from 'uuid'; // diagramId: string, // conversationHistory: Array<{role, content, toolResults?, timestamp}>, // entities: Array<{id, template, text, color, position}>, +// cameraPosition: { position: {x,y,z}, forward: {x,y,z}, groundForward: {x,y,z}, groundRight: {x,y,z} }, +// preferences: { useDefaultConnectionLabels?: boolean }, // createdAt: Date, // lastAccess: Date // } @@ -30,6 +32,8 @@ export function createSession(diagramId) { diagramId, conversationHistory: [], entities: [], + cameraPosition: null, + preferences: {}, createdAt: new Date(), lastAccess: new Date() }; @@ -73,6 +77,18 @@ export function syncEntities(sessionId, entities) { return session; } +/** + * Update camera position for a session + */ +export function syncCameraPosition(sessionId, cameraPosition) { + const session = sessions.get(sessionId); + if (!session) return null; + + session.cameraPosition = cameraPosition; + session.lastAccess = new Date(); + return session; +} + /** * Add a message to conversation history */ @@ -156,3 +172,28 @@ export function getStats(includeDetails = false) { })) }; } + +/** + * Get session preferences + */ +export function getPreferences(sessionId) { + const session = sessions.get(sessionId); + if (!session) return null; + session.lastAccess = new Date(); + return session.preferences || {}; +} + +/** + * Set a session preference + */ +export function setPreference(sessionId, key, value) { + const session = sessions.get(sessionId); + if (!session) return null; + + if (!session.preferences) { + session.preferences = {}; + } + session.preferences[key] = value; + session.lastAccess = new Date(); + return session.preferences; +} diff --git a/server/services/toolConverter.js b/server/services/toolConverter.js index 5d3af02..fd42109 100644 --- a/server/services/toolConverter.js +++ b/server/services/toolConverter.js @@ -1,663 +1,17 @@ /** - * Tool Format Converter - * Converts between Claude and Ollama tool/function formats + * Tool Format Converter (DEPRECATED) + * + * This file has been superseded by LangChain integration: + * - Tool definitions: server/services/langchainTools.js + * - Model wrappers: server/services/langchainModels.js + * - Cloudflare-specific: server/services/ChatCloudflare.js + * + * LangChain handles message format conversion automatically via: + * - ChatAnthropic (Claude) + * - ChatOllama (Ollama) + * - ChatCloudflare (custom, see ChatCloudflare.js) + * + * This file is kept for reference only and can be safely deleted. */ -/** - * Convert Claude tool definition to Ollama function format - * - * Claude format: - * { name: "...", description: "...", input_schema: { type: "object", properties: {...} } } - * - * Ollama format: - * { type: "function", function: { name: "...", description: "...", parameters: {...} } } - * - * @param {object} claudeTool - Tool in Claude format - * @returns {object} Tool in Ollama format - */ -export function claudeToolToOllama(claudeTool) { - return { - type: "function", - function: { - name: claudeTool.name, - description: claudeTool.description, - parameters: claudeTool.input_schema - } - }; -} - -/** - * Convert array of Claude tools to Ollama format - * @param {Array} claudeTools - Array of Claude tool definitions - * @returns {Array} Array of Ollama function definitions - */ -export function claudeToolsToOllama(claudeTools) { - if (!claudeTools || !Array.isArray(claudeTools)) { - return []; - } - return claudeTools.map(claudeToolToOllama); -} - -/** - * Convert Ollama tool call to Claude format - * - * Ollama format (in message): - * { tool_calls: [{ function: { name: "...", arguments: {...} } }] } - * - * Claude format: - * { type: "tool_use", id: "...", name: "...", input: {...} } - * - * @param {object} ollamaToolCall - Tool call from Ollama response - * @param {number} index - Index for generating unique ID - * @returns {object} Tool call in Claude format - */ -export function ollamaToolCallToClaude(ollamaToolCall, index = 0) { - const func = ollamaToolCall.function; - - // Parse arguments if it's a string - let input = func.arguments; - if (typeof input === 'string') { - try { - input = JSON.parse(input); - } catch (e) { - console.warn('[ToolConverter] Failed to parse tool arguments:', e); - input = {}; - } - } - - return { - type: "tool_use", - id: `toolu_ollama_${Date.now()}_${index}`, - name: func.name, - input: input || {} - }; -} - -/** - * Convert Claude tool result to Ollama format - * - * Claude format (in messages): - * { role: "user", content: [{ type: "tool_result", tool_use_id: "...", content: "..." }] } - * - * Ollama format: - * { role: "tool", content: "...", name: "..." } - * - * @param {object} claudeToolResult - Tool result in Claude format - * @param {string} toolName - Name of the tool (from previous tool_use) - * @returns {object} Tool result in Ollama message format - */ -export function claudeToolResultToOllama(claudeToolResult, toolName) { - let content = claudeToolResult.content; - - // Stringify if it's an object - if (typeof content === 'object') { - content = JSON.stringify(content); - } - - return { - role: "tool", - content: content, - name: toolName - }; -} - -/** - * Convert Claude messages array to Ollama format - * Handles regular messages and tool result messages - * - * @param {Array} claudeMessages - Messages in Claude format - * @param {string} systemPrompt - System prompt to prepend - * @returns {Array} Messages in Ollama format - */ -export function claudeMessagesToOllama(claudeMessages, systemPrompt) { - const ollamaMessages = []; - - // Add system message if provided - if (systemPrompt) { - ollamaMessages.push({ - role: "system", - content: systemPrompt - }); - } - - // Track tool names for tool results - const toolNameMap = new Map(); - - for (const msg of claudeMessages) { - if (msg.role === 'user') { - // Check if it's a tool result message - if (Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === 'tool_result') { - const toolName = toolNameMap.get(block.tool_use_id) || 'unknown'; - ollamaMessages.push(claudeToolResultToOllama(block, toolName)); - } else if (block.type === 'text') { - ollamaMessages.push({ - role: "user", - content: block.text - }); - } - } - } else { - ollamaMessages.push({ - role: "user", - content: msg.content - }); - } - } else if (msg.role === 'assistant') { - // Handle assistant messages with potential tool calls - if (Array.isArray(msg.content)) { - let textContent = ''; - const toolCalls = []; - - for (const block of msg.content) { - if (block.type === 'text') { - textContent += block.text; - } else if (block.type === 'tool_use') { - // Track tool name for later tool results - toolNameMap.set(block.id, block.name); - toolCalls.push({ - function: { - name: block.name, - // Ollama expects arguments as object, not string - arguments: block.input || {} - } - }); - } - } - - const assistantMsg = { - role: "assistant", - content: textContent || "" - }; - - if (toolCalls.length > 0) { - assistantMsg.tool_calls = toolCalls; - } - - ollamaMessages.push(assistantMsg); - } else { - ollamaMessages.push({ - role: "assistant", - content: msg.content - }); - } - } - } - - return ollamaMessages; -} - -/** - * Convert Ollama response to Claude format - * - * @param {object} ollamaResponse - Response from Ollama API - * @returns {object} Response in Claude format - */ -export function ollamaResponseToClaude(ollamaResponse) { - const content = []; - const message = ollamaResponse.message; - - // Add text content if present - if (message.content) { - content.push({ - type: "text", - text: message.content - }); - } - - // Add tool calls if present - if (message.tool_calls && message.tool_calls.length > 0) { - for (let i = 0; i < message.tool_calls.length; i++) { - content.push(ollamaToolCallToClaude(message.tool_calls[i], i)); - } - } - - // Determine stop reason - let stopReason = "end_turn"; - if (message.tool_calls && message.tool_calls.length > 0) { - stopReason = "tool_use"; - } else if (ollamaResponse.done_reason === "length") { - stopReason = "max_tokens"; - } - - return { - id: `msg_ollama_${Date.now()}`, - type: "message", - role: "assistant", - content: content, - model: ollamaResponse.model, - stop_reason: stopReason, - usage: { - input_tokens: ollamaResponse.prompt_eval_count || 0, - output_tokens: ollamaResponse.eval_count || 0 - } - }; -} - -// ============================================ -// Cloudflare Workers AI Converters -// ============================================ - -/** - * Convert Claude tool definition to Cloudflare format - * Cloudflare uses OpenAI-compatible format - * - * Claude format: - * { name: "...", description: "...", input_schema: { type: "object", properties: {...} } } - * - * Cloudflare format: - * { type: "function", function: { name: "...", description: "...", parameters: {...} } } - * - * @param {object} claudeTool - Tool in Claude format - * @returns {object} Tool in Cloudflare format - */ -export function claudeToolToCloudflare(claudeTool) { - return { - type: "function", - function: { - name: claudeTool.name, - description: claudeTool.description, - parameters: claudeTool.input_schema - } - }; -} - -/** - * Convert array of Claude tools to Cloudflare format - * @param {Array} claudeTools - Array of Claude tool definitions - * @returns {Array} Array of Cloudflare function definitions - */ -export function claudeToolsToCloudflare(claudeTools) { - if (!claudeTools || !Array.isArray(claudeTools)) { - return []; - } - return claudeTools.map(claudeToolToCloudflare); -} - -/** - * Convert Cloudflare tool call to Claude format - * - * Cloudflare format: - * { name: "...", arguments: {...} } - * - * Claude format: - * { type: "tool_use", id: "...", name: "...", input: {...} } - * - * @param {object} cfToolCall - Tool call from Cloudflare response - * @param {number} index - Index for generating unique ID - * @returns {object} Tool call in Claude format - */ -export function cloudflareToolCallToClaude(cfToolCall, index = 0) { - // Parse arguments if it's a string - let input = cfToolCall.arguments; - if (typeof input === 'string') { - try { - input = JSON.parse(input); - } catch (e) { - console.warn('[ToolConverter] Failed to parse Cloudflare tool arguments:', e); - input = {}; - } - } - - return { - type: "tool_use", - id: `toolu_cf_${Date.now()}_${index}`, - name: cfToolCall.name, - input: input || {} - }; -} - -/** - * Convert Claude messages array to Cloudflare format - * Cloudflare uses OpenAI-compatible message format - * - * IMPORTANT: Cloudflare Workers AI does NOT support multi-turn tool conversations. - * It crashes with error 3043 when conversation history contains tool_calls or tool results. - * We must strip tool call history and only keep text content from past messages. - * - * @param {Array} claudeMessages - Messages in Claude format - * @param {string} systemPrompt - System prompt to prepend - * @returns {Array} Messages in Cloudflare format - */ -export function claudeMessagesToCloudflare(claudeMessages, systemPrompt) { - const cfMessages = []; - - // Add system message if provided - if (systemPrompt) { - cfMessages.push({ - role: "system", - content: systemPrompt - }); - } - - // Cloudflare doesn't support tool call history in native format - convert to text - // so the model knows what tools were called and their results - for (const msg of claudeMessages) { - if (msg.role === 'user') { - if (Array.isArray(msg.content)) { - // Convert tool_result blocks to text summaries - const textParts = []; - for (const block of msg.content) { - if (block.type === 'text') { - textParts.push(block.text); - } else if (block.type === 'tool_result') { - // Convert tool result to readable text so model knows it was executed - textParts.push(`[Tool Result: ${block.content}]`); - } - } - if (textParts.length > 0) { - cfMessages.push({ - role: "user", - content: textParts.join('\n') - }); - } - } else { - cfMessages.push({ - role: "user", - content: msg.content - }); - } - } else if (msg.role === 'assistant') { - // For assistant messages, convert tool_use to text descriptions - const textParts = []; - - if (Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === 'text') { - textParts.push(block.text); - } else if (block.type === 'tool_use') { - // Convert tool call to readable text so model knows it called this - const argsStr = JSON.stringify(block.input || {}); - textParts.push(`[Called tool: ${block.name}(${argsStr})]`); - } - } - } else { - textParts.push(msg.content || ''); - } - - // Also handle pre-converted messages that might have tool_calls property - if (msg.tool_calls && Array.isArray(msg.tool_calls)) { - for (const tc of msg.tool_calls) { - const name = tc.function?.name || tc.name || 'unknown'; - const args = tc.function?.arguments || tc.arguments || '{}'; - textParts.push(`[Called tool: ${name}(${typeof args === 'string' ? args : JSON.stringify(args)})]`); - } - } - - const textContent = textParts.filter(t => t).join('\n'); - if (textContent) { - cfMessages.push({ - role: "assistant", - content: textContent - }); - } - } else if (msg.role === 'tool') { - // Convert tool messages to user messages with result text - cfMessages.push({ - role: "user", - content: `[Tool Result (${msg.name || 'unknown'}): ${msg.content}]` - }); - } - } - - return cfMessages; -} - -/** - * Try to repair and parse a potentially truncated JSON object - * @param {string} jsonStr - Potentially incomplete JSON string - * @returns {object|null} - Parsed object or null if unparseable - */ -function tryRepairAndParse(jsonStr) { - // First try as-is - try { - return JSON.parse(jsonStr); - } catch (e) { - // Try adding closing brackets - const repairs = [ - jsonStr + '}', - jsonStr + '"}', - jsonStr + '}}', - jsonStr + '"}}', - jsonStr + ': null}}', - jsonStr + '": null}}' - ]; - - for (const attempt of repairs) { - try { - const parsed = JSON.parse(attempt); - if (parsed.name) { // Must have a name to be valid - return parsed; - } - } catch (e2) { - // Continue trying - } - } - return null; - } -} - -/** - * Parse tool calls from text response - * Handles multiple formats: - * 1. Mistral native: [TOOL_CALLS][{"name": "...", "arguments": {...}}, ...] - * 2. History format: [Called tool: name({args})] - * - * This parser is resilient to truncation - it will extract as many valid tool calls - * as possible even if the JSON is incomplete. - * - * @param {string} text - Text response that may contain embedded tool calls - * @returns {object} - { cleanText: string, toolCalls: array } - */ -function parseTextToolCalls(text) { - if (!text) return { cleanText: '', toolCalls: [] }; - - const toolCalls = []; - let cleanText = text; - - // Format 1: [TOOL_CALLS][...] (Mistral native format) - const toolCallMatch = text.match(/\[TOOL_CALLS\]\s*(\[[\s\S]*)/); - - if (toolCallMatch) { - const toolCallsJson = toolCallMatch[1]; - - // First try normal JSON.parse (for complete responses) - try { - const parsedCalls = JSON.parse(toolCallsJson); - if (Array.isArray(parsedCalls)) { - const validCalls = parsedCalls - .filter(call => call && call.name) - .map(call => ({ - name: call.name, - arguments: call.arguments || {} - })); - - console.log(`[ToolConverter] Parsed ${validCalls.length} tool calls from [TOOL_CALLS] JSON`); - cleanText = text.replace(/\[TOOL_CALLS\]\s*\[[\s\S]*/, '').trim(); - return { cleanText, toolCalls: validCalls }; - } - } catch (e) { - console.log('[ToolConverter] [TOOL_CALLS] JSON incomplete, attempting to extract individual tool calls...'); - } - - // JSON is truncated - extract individual tool calls using regex - const toolCallStarts = []; - const startPattern = /\{"name"\s*:\s*"/g; - let match; - - while ((match = startPattern.exec(toolCallsJson)) !== null) { - toolCallStarts.push(match.index); - } - - console.log(`[ToolConverter] Found ${toolCallStarts.length} potential tool call starts in [TOOL_CALLS]`); - - for (let i = 0; i < toolCallStarts.length; i++) { - const start = toolCallStarts[i]; - const end = toolCallStarts[i + 1] || toolCallsJson.length; - let segment = toolCallsJson.substring(start, end).replace(/,\s*$/, ''); - const parsed = tryRepairAndParse(segment); - - if (parsed && parsed.name) { - toolCalls.push({ - name: parsed.name, - arguments: parsed.arguments || {} - }); - console.log(`[ToolConverter] Extracted tool call from [TOOL_CALLS]: ${parsed.name}`); - } - } - - cleanText = text.replace(/\[TOOL_CALLS\]\s*\[[\s\S]*/, '').trim(); - if (toolCalls.length > 0) { - console.log(`[ToolConverter] Extracted ${toolCalls.length} tool calls from [TOOL_CALLS] format`); - return { cleanText, toolCalls }; - } - } - - // Format 2: [Called tool: name({args})] (history format the model might mimic) - // Match patterns like: [Called tool: create_entity({"shape": "box", ...})] - const calledToolPattern = /\[Called tool:\s*(\w+)\((\{[\s\S]*?\})\)\]/g; - let calledMatch; - const calledToolMatches = []; - - while ((calledMatch = calledToolPattern.exec(text)) !== null) { - calledToolMatches.push({ - fullMatch: calledMatch[0], - name: calledMatch[1], - argsStr: calledMatch[2] - }); - } - - if (calledToolMatches.length > 0) { - console.log(`[ToolConverter] Found ${calledToolMatches.length} [Called tool:] format tool calls`); - - for (const match of calledToolMatches) { - try { - const args = JSON.parse(match.argsStr); - toolCalls.push({ - name: match.name, - arguments: args - }); - console.log(`[ToolConverter] Extracted tool call from [Called tool:]: ${match.name}`); - // Remove this match from clean text - cleanText = cleanText.replace(match.fullMatch, ''); - } catch (e) { - console.warn(`[ToolConverter] Failed to parse [Called tool:] args for ${match.name}:`, e.message); - // Try to repair the JSON - const repaired = tryRepairAndParse(match.argsStr); - if (repaired) { - toolCalls.push({ - name: match.name, - arguments: repaired - }); - console.log(`[ToolConverter] Repaired and extracted tool call: ${match.name}`); - cleanText = cleanText.replace(match.fullMatch, ''); - } - } - } - - cleanText = cleanText.trim(); - if (toolCalls.length > 0) { - console.log(`[ToolConverter] Extracted ${toolCalls.length} tool calls from [Called tool:] format`); - return { cleanText, toolCalls }; - } - } - - // No tool calls found - return { cleanText: text, toolCalls: [] }; -} - -/** - * Convert Cloudflare response to Claude format - * - * Cloudflare response format: - * { - * result: { - * response: "text output", - * tool_calls: [{ name: "...", arguments: {...} }] - * }, - * success: true - * } - * - * Note: Some models (like Mistral) output tool calls as text in format: - * [TOOL_CALLS][{...}] - * - * @param {object} cfResponse - Response from Cloudflare Workers AI API - * @param {string} model - Model name used - * @returns {object} Response in Claude format - */ -export function cloudflareResponseToClaude(cfResponse, model) { - const content = []; - const result = cfResponse.result || cfResponse; - - // Get tool calls from proper field or parse from text - let toolCalls = result.tool_calls || []; - let textResponse = result.response || ''; - - // Log raw response for debugging - console.log(`[ToolConverter] Raw response (first 500 chars): ${textResponse.substring(0, 500)}`); - console.log(`[ToolConverter] Native tool_calls present: ${toolCalls.length}`); - - // Check if tool calls are embedded in text response (Mistral format or history format) - if (toolCalls.length === 0 && textResponse) { - console.log(`[ToolConverter] No native tool_calls, parsing text response...`); - const parsed = parseTextToolCalls(textResponse); - console.log(`[ToolConverter] Parsed ${parsed.toolCalls.length} tool calls from text`); - if (parsed.toolCalls.length > 0) { - toolCalls = parsed.toolCalls; - textResponse = parsed.cleanText; - } - } - - // Add text content if present (after removing tool calls) - if (textResponse) { - content.push({ - type: "text", - text: textResponse - }); - } - - // Add tool calls if present - if (toolCalls.length > 0) { - for (let i = 0; i < toolCalls.length; i++) { - content.push(cloudflareToolCallToClaude(toolCalls[i], i)); - } - } - - // Determine stop reason - let stopReason = "end_turn"; - if (toolCalls.length > 0) { - stopReason = "tool_use"; - } - - // Extract usage if available - const usage = { - input_tokens: result.usage?.prompt_tokens || result.usage?.input_tokens || 0, - output_tokens: result.usage?.completion_tokens || result.usage?.output_tokens || 0 - }; - - return { - id: `msg_cf_${Date.now()}`, - type: "message", - role: "assistant", - content: content, - model: model, - stop_reason: stopReason, - usage: usage - }; -} - -export default { - claudeToolToOllama, - claudeToolsToOllama, - ollamaToolCallToClaude, - claudeToolResultToOllama, - claudeMessagesToOllama, - ollamaResponseToClaude, - // Cloudflare converters - claudeToolToCloudflare, - claudeToolsToCloudflare, - cloudflareToolCallToClaude, - claudeMessagesToCloudflare, - cloudflareResponseToClaude -}; +export default {}; diff --git a/src/diagram/diagramManager.ts b/src/diagram/diagramManager.ts index 6fe9dd8..43e3065 100644 --- a/src/diagram/diagramManager.ts +++ b/src/diagram/diagramManager.ts @@ -157,6 +157,9 @@ export class DiagramManager { if (updates.color !== undefined) { diagramObject.color = updates.color; } + if (updates.template !== undefined) { + diagramObject.template = updates.template; + } if (updates.position !== undefined) { diagramObject.position = updates.position; } @@ -214,17 +217,22 @@ export class DiagramManager { } }); - document.addEventListener('chatListEntities', () => { - this._logger.debug('chatListEntities'); + document.addEventListener('chatListEntities', (event: CustomEvent) => { + const requestId = event.detail?.requestId; + this._logger.debug('chatListEntities', requestId ? `(request: ${requestId})` : ''); const entities = Array.from(this._diagramObjects.values()).map(obj => ({ id: obj.diagramEntity.id, + label: obj.diagramEntity.text || '', template: obj.diagramEntity.template, text: obj.diagramEntity.text || '', color: obj.diagramEntity.color, - position: obj.diagramEntity.position + position: obj.diagramEntity.position, + // Include from/to for connections + from: obj.diagramEntity.from, + to: obj.diagramEntity.to })); const responseEvent = new CustomEvent('chatListEntitiesResponse', { - detail: {entities}, + detail: { entities, requestId }, bubbles: true }); document.dispatchEvent(responseEvent); diff --git a/src/diagram/diagramObject.ts b/src/diagram/diagramObject.ts index 3f6c789..0f0ec63 100644 --- a/src/diagram/diagramObject.ts +++ b/src/diagram/diagramObject.ts @@ -220,6 +220,49 @@ export class DiagramObject { }, DiagramEventObserverMask.TO_DB); } + public set template(value: string) { + if (!this._diagramEntity || this._diagramEntity.template === value) { + return; + } + + // Don't allow changing connections to shapes or vice versa + if (this._diagramEntity.template === '#connection-template' || value === '#connection-template') { + this._logger.warn('Cannot change between connection and shape templates'); + return; + } + + this._logger.debug('Changing template from', this._diagramEntity.template, 'to', value); + + // Update the entity template + this._diagramEntity.template = value; + + // Rebuild mesh with new template + // Must dispose old mesh FIRST, otherwise buildMeshFromDiagramEntity + // finds it by ID and returns the same mesh (which we then dispose!) + if (this._mesh) { + const actionManager = this._mesh.actionManager; + this._mesh.dispose(); + this._mesh = null; + + this._mesh = buildMeshFromDiagramEntity(this._diagramEntity, this._scene); + if (this._mesh) { + this._mesh.setParent(this._baseTransform); + this._mesh.position = Vector3.Zero(); + this._mesh.rotation = Vector3.Zero(); + if (actionManager) { + this._mesh.actionManager = actionManager; + } + } else { + this._logger.error('Failed to rebuild mesh with new template'); + } + } + + this._eventObservable.notifyObservers({ + type: DiagramEventType.MODIFY, + entity: this._diagramEntity + }, DiagramEventObserverMask.TO_DB); + } + public set text(value: string) { if (this._label) { this._label.dispose(); diff --git a/src/react/services/diagramAI.ts b/src/react/services/diagramAI.ts index ce90d22..6273fd0 100644 --- a/src/react/services/diagramAI.ts +++ b/src/react/services/diagramAI.ts @@ -1,5 +1,5 @@ import {ChatMessage, CreateSessionResponse, DiagramSession, DiagramToolCall, SessionEntity, SessionUsage, SyncEntitiesResponse, ToolResult} from "../types/chatTypes"; -import {clearDiagram, connectEntities, createEntity, getCameraPosition, listEntities, modifyConnection, modifyEntity, removeEntity} from "./entityBridge"; +import {clearDiagram, connectEntities, createEntity, getCameraPosition, getCameraForSync, listEntities, modifyConnection, modifyEntity, removeEntity} from "./entityBridge"; import {v4 as uuidv4} from 'uuid'; import log from 'loglevel'; @@ -131,14 +131,26 @@ export function getCurrentModel(): ModelInfo { /** * Set current model */ -export function setCurrentModel(modelId: string): boolean { - const model = AVAILABLE_MODELS.find(m => m.id === modelId); +export function setCurrentModel(modelIdOrName: string): boolean { + // Try to find by exact ID first + let model = AVAILABLE_MODELS.find(m => m.id === modelIdOrName); + + // If not found, try to match by display name (case-insensitive) + if (!model) { + const searchLower = modelIdOrName.toLowerCase(); + model = AVAILABLE_MODELS.find(m => + m.name.toLowerCase() === searchLower || + m.name.toLowerCase().includes(searchLower) || + m.id.toLowerCase().includes(searchLower) + ); + } + if (model) { - currentModelId = modelId; - logger.info('Model changed to:', model.name); + currentModelId = model.id; + logger.info('Model changed to:', model.name, `(${model.id})`); return true; } - logger.warn('Invalid model ID:', modelId); + logger.warn('Invalid model ID or name:', modelIdOrName); return false; } @@ -188,6 +200,31 @@ export async function syncEntitiesToSession(entities: SessionEntity[]): Promise< } } +/** + * Sync camera position to the current session + */ +export async function syncCameraToSession(): Promise { + if (!currentSessionId) { + return; + } + + const cameraPosition = await getCameraForSync(); + if (!cameraPosition) { + console.warn('Failed to get camera position for sync'); + return; + } + + const response = await fetch(`/api/session/${currentSessionId}/camera`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cameraPosition }) + }); + + if (!response.ok) { + console.error('Failed to sync camera position:', response.status); + } +} + /** * Get session with conversation history */ @@ -218,6 +255,48 @@ export async function clearSessionHistory(): Promise { }); } +/** + * Get a session preference + */ +export async function getSessionPreference(key: string): Promise { + 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 { + if (!currentSessionId) { + return false; + } + + try { + const response = await fetch(`/api/session/${currentSessionId}/preferences`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value }) + }); + return response.ok; + } catch (err) { + logger.error('Failed to set session preference:', err); + return false; + } +} + /** * Get token usage for current session */ @@ -238,18 +317,51 @@ export async function getSessionUsage(): Promise { } } -const SYSTEM_PROMPT = `You are a 3D diagram assistant. You MUST use tools to perform actions - never just describe what you would do. +const SYSTEM_PROMPT = `You are a 3D diagram assistant that EXECUTES actions using tools. You cannot modify the diagram by describing changes - you MUST call tools. -## CRITICAL RULES -1. When the user asks to create, add, or make something → CALL create_entity tool -2. When the user asks to connect things → CALL connect_entities tool -3. When the user asks to change, modify, move, resize, or rotate → CALL modify_entity tool -4. When the user asks to remove or delete → CALL remove_entity tool -5. When the user asks what exists or to list → CALL list_entities tool -6. When the user uses directions (left, right, forward, in front of me) → CALL get_camera_position FIRST -7. When diagramming unfamiliar topics → CALL search_wikipedia FIRST to research the concept +## CRITICAL: YOU MUST CALL TOOLS +**NEVER say "I have modified..." or "The entity has been..." without ACTUALLY calling a tool.** +**If you don't call a tool, NOTHING happens. Your words alone cannot change the diagram.** -DO NOT just describe actions. DO NOT say "I will create..." without calling a tool. ALWAYS call the appropriate tool. +When the user requests ANY action: +1. CREATE/ADD/MAKE → CALL create_entity +2. CONNECT/LINK → CALL connect_entities +3. CHANGE/MODIFY/MOVE/RESIZE/SCALE/ROTATE/RENAME → CALL modify_entity +4. CHANGE CONNECTION → CALL modify_connection +5. REMOVE/DELETE → CALL remove_entity +6. LIST/SHOW/WHAT EXISTS → CALL list_entities +7. DIRECTIONS (left, right, forward) → CALL get_camera_position FIRST +8. UNFAMILIAR TOPIC → CALL search_wikipedia FIRST + +## WRONG (does nothing): +"I've resized the CDN entity to 0.1m high." + +## CORRECT (actually works): +Call modify_entity with: target="CDN", scale={x: 1, y: 0.1, z: 1} + +## Scale Parameter +- scale is in METERS (1 = 1 meter, 0.1 = 10cm, 0.5 = 50cm) +- Use {x: width, y: height, z: depth} for non-uniform scaling +- Example: 1m wide × 0.1m tall × 1m deep = scale: {x: 1, y: 0.1, z: 1} + +## Connection Labels +When creating connections for the FIRST TIME in a session: +1. Call get_connection_label_preference to check if user has set a preference +2. If no preference is set, ASK the user: "Would you like default labels on connections (e.g., 'Server to Database')?" +3. Save their response with set_connection_label_preference +4. Then proceed with connect_entities + +The preference persists for the session - you don't need to ask again. + +## Bulk Operations +When the user asks to modify ALL items (e.g., "remove labels from all connections"): +1. First call list_entities to see what exists +2. Then call the modify tool for EACH item that needs to change +3. Use modify_connection with from/to to identify each connection + +Example: To remove all connection labels: +- Call list_entities to see connections +- For each connection, call modify_connection with from="EntityA", to="EntityB", label="" ## Research First When asked to diagram a technical concept, architecture, or system you're not fully familiar with, use search_wikipedia to research it first. This ensures accurate and comprehensive diagrams. @@ -257,13 +369,48 @@ When asked to diagram a technical concept, architecture, or system you're not fu ## Available Shapes box, sphere, cylinder, cone, plane, person -## Available Colors -red, blue, green, yellow, orange, purple, cyan, pink, white, black, brown, gray (or hex like #ff5500) +## Available Colors (Toolbox Palette) +ONLY use these exact hex codes. Map any requested color to the closest match: -## Position Coordinates -- x: left/right -- y: up/down (1.5 = eye level, 0 = floor) -- z: forward/backward +| Color Name | Hex Code | +|-----------------|-----------| +| Black/Dark Gray | #222222 | +| Brown | #8b4513 | +| Dark Green | #006400 | +| Slate Gray | #778899 | +| Purple/Indigo | #4b0082 | +| Red | #ff0000 | +| Orange | #ffa500 | +| Yellow | #ffff00 | +| Green | #00ff00 | +| Cyan | #00ffff | +| Blue | #0000ff | +| Magenta | #ff00ff | +| Dodger Blue | #1e90ff | +| Pale Green | #98fb98 | +| Moccasin/Beige | #ffe4b5 | +| Pink | #ff69b4 | + +Examples of color mapping: +- "navy" → #0000ff (Blue) +- "lime" → #00ff00 (Green) +- "teal" → #00ffff (Cyan) +- "maroon" → #ff0000 (Red) +- "gold" → #ffff00 (Yellow) +- "salmon" → #ff69b4 (Pink) +- "tan" → #ffe4b5 (Moccasin) +- "forest green" → #006400 (Dark Green) + +## Coordinate System (from camera/user perspective) +- X axis: LEFT (-) and RIGHT (+) - negative values are to the left, positive to the right +- Y axis: DOWN (-) and UP (+) - 0 is the floor, 1.5 is eye level, higher values go up +- Z axis: BACKWARD (-) and FORWARD (+) - positive values are in front of the camera, negative behind + +Example positions: +- (0, 1.5, 2) = directly in front at eye level +- (-1, 1.5, 2) = to the left, at eye level, in front +- (1, 0.5, 2) = to the right, below eye level, in front +- (0, 3, 2) = directly in front, above eye level ## Layout Guidelines - Spread entities apart (at least 0.5 units) @@ -295,11 +442,11 @@ const TOOLS = [ position: { type: "object", properties: { - x: {type: "number", description: "Left/right position"}, - y: {type: "number", description: "Up/down position (1.5 = eye level)"}, - z: {type: "number", description: "Forward/backward position"} + x: {type: "number", description: "Left (-) / Right (+) from camera view"}, + y: {type: "number", description: "Down (-) / Up (+), 0=floor, 1.5=eye level"}, + z: {type: "number", description: "Backward (-) / Forward (+) from camera view"} }, - 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"] @@ -307,7 +454,7 @@ const TOOLS = [ }, { name: "connect_entities", - description: "Draw a connection line between two entities. Use entity IDs or labels to identify them.", + description: "Draw a connection line between two entities. Check useDefaultLabels preference first - if not set, ask user if they want default labels on connections.", input_schema: { type: "object", properties: { @@ -319,6 +466,10 @@ const TOOLS = [ type: "string", description: "ID or label of the target entity" }, + label: { + type: "string", + description: "Optional label for the connection. If omitted, default label 'X to Y' is used based on useDefaultLabels preference." + }, color: { type: "string", description: "Color of the connection line" @@ -327,6 +478,28 @@ const TOOLS = [ required: ["from", "to"] } }, + { + name: "set_connection_label_preference", + description: "Set whether connections should have default labels. Call this after asking the user their preference.", + input_schema: { + type: "object", + properties: { + use_default_labels: { + type: "boolean", + description: "true = create labels like 'Server to Database', false = no labels on connections" + } + }, + required: ["use_default_labels"] + } + }, + { + name: "get_connection_label_preference", + description: "Check if the user has set a preference for connection labels. Returns the preference or null if not set.", + input_schema: { + type: "object", + properties: {} + } + }, { name: "list_entities", description: "List all entities currently in the diagram. Use this to see what exists before connecting or modifying.", @@ -351,44 +524,50 @@ const TOOLS = [ }, { name: "modify_entity", - description: "Modify an existing entity's properties like color, label, position, scale, or rotation.", + description: "CALL THIS TOOL to modify an entity. You MUST call this tool - describing changes does nothing. Use for: resize, move, rename, recolor, rotate, change shape.", input_schema: { type: "object", properties: { target: { type: "string", - description: "ID or label of the entity to modify" + description: "Label or ID of the entity to modify (e.g., 'CDN', 'Server', 'Database')" }, color: { type: "string", - description: "New color for the entity" + description: "New color hex code from the toolbox palette" }, label: { type: "string", description: "New label text. Use empty string \"\" to remove the label." }, + shape: { + type: "string", + enum: ["box", "sphere", "cylinder", "cone", "plane", "person"], + description: "New shape for the entity" + }, position: { type: "object", properties: { - x: {type: "number"}, - y: {type: "number"}, - z: {type: "number"} - } + x: {type: "number", description: "Left(-)/Right(+) in meters"}, + y: {type: "number", description: "Down(-)/Up(+) in meters"}, + z: {type: "number", description: "Back(-)/Forward(+) in meters"} + }, + description: "New position in meters" }, scale: { oneOf: [ { type: "number", - description: "Uniform scale factor (e.g., 0.2 = double size, 0.05 = half size). Default is 0.1." + description: "Uniform size in meters (e.g., 1 = 1 meter cube)" }, { type: "object", properties: { - x: {type: "number"}, - y: {type: "number"}, - z: {type: "number"} + x: {type: "number", description: "Width in meters"}, + y: {type: "number", description: "Height in meters"}, + z: {type: "number", description: "Depth in meters"} }, - description: "Non-uniform scale as {x, y, z}. Default is {x: 0.1, y: 0.1, z: 0.1}." + description: "Size as {x: width, y: height, z: depth} in meters. Example: {x: 1, y: 0.1, z: 1} = 1m wide, 10cm tall, 1m deep" } ], description: "Scale/size of the entity. Use a number for uniform scaling or {x, y, z} for non-uniform." @@ -484,13 +663,13 @@ const TOOLS = [ }, { name: "set_model", - description: "Change the AI model used for this conversation. Use list_models first to see available options.", + description: "Change the AI model. Use the model name from list_models.", input_schema: { type: "object", properties: { model_id: { type: "string", - description: "The model ID to switch to. Claude models: 'claude-sonnet-4-20250514', 'claude-opus-4-20250514', 'claude-haiku-3-5-20241022'. Ollama models (local): 'llama3.1', 'mistral', 'qwen2.5'" + description: "Model name like 'Claude Opus 4', 'Hermes 2 Pro (CF)', 'Mistral Small 3.1 (CF)', etc." } }, required: ["model_id"] @@ -548,14 +727,32 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise { case 'create_entity': result = createEntity(toolCall.input); break; - case 'connect_entities': - result = await connectEntities(toolCall.input); + case 'connect_entities': { + // Check if label is explicitly provided (including empty string) + const hasExplicitLabel = toolCall.input.label !== undefined; + let label = toolCall.input.label; + + // If no explicit label, check preference + if (!hasExplicitLabel) { + const useDefaultLabels = await getSessionPreference('useDefaultConnectionLabels'); + if (useDefaultLabels === false) { + // User prefers no labels - pass empty string + label = ''; + } + // If true or undefined, let connectEntities create default label (pass undefined) + } + + result = await connectEntities({ + ...toolCall.input, + label + }); break; + } case 'remove_entity': - result = removeEntity(toolCall.input); + result = await removeEntity(toolCall.input); break; case 'modify_entity': - result = modifyEntity(toolCall.input); + result = await modifyEntity(toolCall.input); break; case 'modify_connection': result = await modifyConnection(toolCall.input); @@ -579,12 +776,12 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise { const models = getAvailableModels(); const current = getCurrentModel(); const modelList = models.map(m => - `• ${m.name} (${m.id})${m.id === current.id ? ' [CURRENT]' : ''}\n ${m.description}` + `• ${m.name}${m.id === current.id ? ' [CURRENT]' : ''}\n ${m.description}` ).join('\n\n'); result = { toolName: 'list_models', success: true, - message: `Available models:\n\n${modelList}` + message: `Available models:\n\n${modelList}\n\nTo switch, use set_model with the model name (e.g., "Hermes 2 Pro (CF)")` }; break; } @@ -598,7 +795,9 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise { break; } case 'set_model': { - const success = setCurrentModel(toolCall.input.model_id); + // Normalize parameter name - some models use model_name instead of model_id + const modelId = toolCall.input.model_id || toolCall.input.model_name || toolCall.input.modelId || toolCall.input.model; + const success = setCurrentModel(modelId); if (success) { const model = getCurrentModel(); result = { @@ -611,7 +810,7 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise { result = { toolName: 'set_model', success: false, - message: `Invalid model ID: "${toolCall.input.model_id}"\n\nAvailable models: ${models.map(m => m.id).join(', ')}` + message: `Invalid model ID: "${modelId}"\n\nAvailable models: ${models.map(m => m.id).join(', ')}` }; } break; @@ -636,6 +835,35 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise { }; break; } + case 'set_connection_label_preference': { + const useDefaultLabels = toolCall.input.use_default_labels; + const success = await setSessionPreference('useDefaultConnectionLabels', useDefaultLabels); + result = { + toolName: 'set_connection_label_preference', + success, + message: success + ? `Connection label preference set: ${useDefaultLabels ? 'Default labels will be created (e.g., "Server to Database")' : 'No labels will be created on connections'}` + : 'Failed to save preference (no active session)' + }; + break; + } + case 'get_connection_label_preference': { + const pref = await getSessionPreference('useDefaultConnectionLabels'); + if (pref === undefined) { + result = { + toolName: 'get_connection_label_preference', + success: true, + message: 'No preference set yet. Ask the user if they want default labels on connections.' + }; + } else { + result = { + toolName: 'get_connection_label_preference', + success: true, + message: `Connection label preference: ${pref ? 'Default labels enabled' : 'No labels on connections'}` + }; + } + break; + } default: result = { toolName: 'unknown', @@ -750,6 +978,9 @@ export async function sendMessage( logger.debug('[sendMessage] Starting with message:', userMessage); logger.debug('[sendMessage] Session ID:', currentSessionId); + // Sync camera position before sending message so AI knows user's view + await syncCameraToSession(); + // When using sessions, we don't need to send full history - server manages it // Just send the new message const messages: ClaudeMessage[] = currentSessionId @@ -838,6 +1069,7 @@ export async function sendMessage( let modelSwitched = false; for (const toolBlock of toolBlocks) { + console.log('[sendMessage] Full tool block:', JSON.stringify(toolBlock, null, 2)); logger.debug('[sendMessage] Tool call:', toolBlock.name, JSON.stringify(toolBlock.input)); const toolCall: DiagramToolCall = { name: toolBlock.name as DiagramToolCall['name'], diff --git a/src/react/services/entityBridge.ts b/src/react/services/entityBridge.ts index 71ef47c..e29e3ed 100644 --- a/src/react/services/entityBridge.ts +++ b/src/react/services/entityBridge.ts @@ -16,15 +16,87 @@ import log from 'loglevel'; const logger = log.getLogger('entityBridge'); logger.setLevel('debug'); +// The 16 predefined toolbox colors +const TOOLBOX_COLORS = [ + "#222222", "#8b4513", "#006400", "#778899", // Dark gray, Brown, Dark green, Slate gray + "#4b0082", "#ff0000", "#ffa500", "#ffff00", // Indigo, Red, Orange, Yellow + "#00ff00", "#00ffff", "#0000ff", "#ff00ff", // Green, Cyan, Blue, Magenta + "#1e90ff", "#98fb98", "#ffe4b5", "#ff69b4" // Dodger blue, Pale green, Moccasin, Pink +]; + +/** + * Parse a hex color string to RGB values + */ +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : null; +} + +/** + * Calculate color distance using simple Euclidean distance in RGB space + */ +function colorDistance(c1: { r: number; g: number; b: number }, c2: { r: number; g: number; b: number }): number { + return Math.sqrt( + Math.pow(c1.r - c2.r, 2) + + Math.pow(c1.g - c2.g, 2) + + Math.pow(c1.b - c2.b, 2) + ); +} + +/** + * Find the closest toolbox color to the given hex color + */ +function findClosestToolboxColor(hex: string): string { + const inputRgb = hexToRgb(hex); + if (!inputRgb) return '#0000ff'; // Default to blue if parsing fails + + let closestColor = TOOLBOX_COLORS[0]; + let minDistance = Infinity; + + for (const toolboxHex of TOOLBOX_COLORS) { + const toolboxRgb = hexToRgb(toolboxHex); + if (toolboxRgb) { + const distance = colorDistance(inputRgb, toolboxRgb); + if (distance < minDistance) { + minDistance = distance; + closestColor = toolboxHex; + } + } + } + + if (closestColor !== hex.toLowerCase()) { + logger.debug(`[resolveColor] Mapped ${hex} to closest toolbox color: ${closestColor}`); + } + + return closestColor; +} + function resolveColor(color?: string): string { if (!color) return '#0000ff'; - const lower = color.toLowerCase(); + const lower = color.toLowerCase().trim(); + + // Check if it's a named color if (COLOR_NAME_TO_HEX[lower]) { - return COLOR_NAME_TO_HEX[lower]; + const namedHex = COLOR_NAME_TO_HEX[lower]; + // Map named color to closest toolbox color + return findClosestToolboxColor(namedHex); } + + // Check if it's a hex color if (color.startsWith('#')) { - return color; + // If it's already a toolbox color, use it directly + if (TOOLBOX_COLORS.includes(lower)) { + return lower; + } + // Otherwise, find the closest toolbox color + return findClosestToolboxColor(color); } + + // Default to blue return '#0000ff'; } @@ -33,6 +105,113 @@ interface ResolvedEntity { label: string | null; } +interface EntityInfo { + id: string; + label: string; + template: string; + color?: string; +} + +/** + * Get all entities currently in the diagram + */ +function getAllEntities(): Promise { + 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 { + const entities = await getAllEntities(); + + if (entities.length === 0) { + logger.debug('[inferEntity] No entities in diagram'); + return null; + } + + // If only one entity, use it + if (entities.length === 1) { + logger.debug('[inferEntity] Only one entity, using:', entities[0].label); + return entities[0]; + } + + // If we have a hint, try to find the best match + if (hint) { + let bestMatch: EntityInfo | null = null; + let bestScore = 0; + + for (const entity of entities) { + const labelScore = stringSimilarity(hint, entity.label); + const idScore = stringSimilarity(hint, entity.id); + const score = Math.max(labelScore, idScore); + + if (score > bestScore && score > 0.3) { // Minimum threshold + bestScore = score; + bestMatch = entity; + } + } + + if (bestMatch) { + logger.debug(`[inferEntity] Best match for "${hint}": ${bestMatch.label} (score: ${bestScore.toFixed(2)})`); + return bestMatch; + } + } + + logger.debug('[inferEntity] Could not infer entity'); + return null; +} + /** * Resolve an entity label or ID to actual entity ID and label */ @@ -69,17 +248,55 @@ function resolveEntity(target: string): Promise { export function createEntity(params: CreateEntityParams): ToolResult { logger.debug('[createEntity] Creating entity:', params); + + // Normalize params - some models use different parameter names + const normalizedParams = { ...params } as CreateEntityParams & { + x?: number; + y?: number; + z?: number; + text?: string; + name?: string; + }; + + // Handle position as array [x, y, z] instead of object {x, y, z} + if (Array.isArray(normalizedParams.position)) { + const posArray = normalizedParams.position as unknown as number[]; + normalizedParams.position = { + x: posArray[0] ?? 0, + y: posArray[1] ?? 1.5, + z: posArray[2] ?? 2 + }; + logger.debug('[createEntity] Converted position array to object:', normalizedParams.position); + } + + // Handle x, y, z directly instead of position object + if (!normalizedParams.position && (normalizedParams.x !== undefined || normalizedParams.y !== undefined || normalizedParams.z !== undefined)) { + normalizedParams.position = { + x: normalizedParams.x ?? 0, + y: normalizedParams.y ?? 1.5, + z: normalizedParams.z ?? 2 + }; + } + + // Handle 'text' or 'name' as alias for 'label' + if (normalizedParams.label === undefined && normalizedParams.text !== undefined) { + normalizedParams.label = normalizedParams.text; + } + if (normalizedParams.label === undefined && normalizedParams.name !== undefined) { + normalizedParams.label = normalizedParams.name; + } + const id = 'id' + uuidv4(); - const template = SHAPE_TO_TEMPLATE[params.shape]; - const color = resolveColor(params.color); - const position = params.position || {x: 0, y: 1.5, z: 2}; + const template = SHAPE_TO_TEMPLATE[normalizedParams.shape]; + const color = resolveColor(normalizedParams.color); + const position = normalizedParams.position || {x: 0, y: 1.5, z: 2}; const entity: DiagramEntity = { id, template, type: DiagramEntityType.ENTITY, color, - text: params.label || '', + text: normalizedParams.label || '', position, rotation: {x: 0, y: Math.PI, z: 0}, scale: {x: 0.1, y: 0.1, z: 0.1}, @@ -95,7 +312,7 @@ export function createEntity(params: CreateEntityParams): ToolResult { const result = { toolName: 'create_entity', success: true, - message: `Created ${params.shape}${params.label ? ` labeled "${params.label}"` : ''} at position (${position.x.toFixed(1)}, ${position.y.toFixed(1)}, ${position.z.toFixed(1)})`, + message: `Created ${normalizedParams.shape}${normalizedParams.label ? ` labeled "${normalizedParams.label}"` : ''} at position (${position.x.toFixed(1)}, ${position.y.toFixed(1)}, ${position.z.toFixed(1)})`, entityId: id }; logger.debug('[createEntity] Done:', result); @@ -128,10 +345,11 @@ export async function connectEntities(params: ConnectEntitiesParams): Promise { + // Normalize params - some models use different parameter names + const normalizedParams = { ...params } as RemoveEntityParams & { + entity?: string; + name?: string; + }; + + // Handle 'entity' or 'name' as alias for 'target' + let target = normalizedParams.target || normalizedParams.entity || normalizedParams.name; + + // If still no target, try to infer it + if (!target) { + const hint = normalizedParams.entity || normalizedParams.name; + const inferredEntity = await inferEntity(hint); + if (inferredEntity) { + logger.info(`[removeEntity] Inferred target: "${inferredEntity.label}" (id: ${inferredEntity.id})`); + target = inferredEntity.label || inferredEntity.id; + } + } + + if (!target) { + return { + toolName: 'remove_entity', + success: false, + message: `Error: No target entity specified. Please provide the entity name or ID to remove.` + }; + } + const event = new CustomEvent('chatRemoveEntity', { - detail: {target: params.target}, + detail: { target }, bubbles: true }); document.dispatchEvent(event); @@ -167,7 +413,7 @@ export function removeEntity(params: RemoveEntityParams): ToolResult { return { toolName: 'remove_entity', success: true, - message: `Removed entity "${params.target}"` + message: `Removed entity "${target}"` }; } @@ -176,70 +422,188 @@ function degreesToRadians(degrees: number): number { return degrees * (Math.PI / 180); } -export function modifyEntity(params: ModifyEntityParams): ToolResult { - const updates: Partial = {}; +export async function modifyEntity(params: ModifyEntityParams): Promise { + // Normalize params - some models use different parameter names + const normalizedParams = { ...params } as ModifyEntityParams & { + entity?: string; + name?: string; + x?: number; + y?: number; + z?: number; + text?: string; + shape?: string; // Some models include shape for context + }; - if (params.color) { - updates.color = resolveColor(params.color); + // Handle 'entity' or 'name' as alias for 'target' + if (!normalizedParams.target && normalizedParams.entity) { + normalizedParams.target = normalizedParams.entity; } - if (params.label !== undefined) { - updates.text = params.label; + if (!normalizedParams.target && normalizedParams.name) { + normalizedParams.target = normalizedParams.name; } - if (params.position) { - updates.position = params.position; - } - if (params.scale !== undefined) { - // Accept either uniform scale (number) or 3D scale object - if (typeof params.scale === 'number') { - updates.scale = {x: params.scale, y: params.scale, z: params.scale}; - } else { - updates.scale = params.scale; + + // If still no target, try to infer it using color and shape hints + if (!normalizedParams.target) { + const entities = await getAllEntities(); + logger.debug('[modifyEntity] Trying to infer target from', entities.length, 'entities'); + + // Try to find entity by color and/or shape + if (normalizedParams.color || normalizedParams.shape) { + const colorHint = normalizedParams.color?.toLowerCase(); + const shapeHint = normalizedParams.shape?.toLowerCase(); + + for (const entity of entities) { + const entityShape = entity.template?.replace('#', '').replace('-template', '').toLowerCase(); + const entityColor = entity.color?.toLowerCase(); + + const matchesShape = !shapeHint || entityShape === shapeHint; + + // Match by actual entity color or by color name in label + let matchesColor = false; + if (colorHint) { + // Direct color match + if (entityColor === colorHint || entityColor === findClosestToolboxColor(colorHint)) { + matchesColor = true; + } + // Check if label contains color name + else if ( + (colorHint === '#ff0000' && entity.label?.toLowerCase().includes('red')) || + (colorHint === '#00ff00' && entity.label?.toLowerCase().includes('green')) || + (colorHint === '#0000ff' && entity.label?.toLowerCase().includes('blue')) || + (colorHint === '#ffff00' && entity.label?.toLowerCase().includes('yellow')) || + (colorHint === '#ffa500' && entity.label?.toLowerCase().includes('orange')) || + (colorHint === '#ff69b4' && entity.label?.toLowerCase().includes('pink')) || + (colorHint === '#4b0082' && entity.label?.toLowerCase().includes('purple')) + ) { + matchesColor = true; + } + } + + if (matchesShape && (matchesColor || (!colorHint && entities.length === 1))) { + logger.info(`[modifyEntity] Inferred target by shape/color: "${entity.label || entity.id}" (${entityShape}, ${entityColor})`); + normalizedParams.target = entity.label || entity.id; + break; + } + } + } + + // Fall back to general inference + if (!normalizedParams.target) { + const hint = normalizedParams.entity || normalizedParams.name || undefined; + const inferredEntity = await inferEntity(hint); + if (inferredEntity) { + logger.info(`[modifyEntity] Inferred target: "${inferredEntity.label}" (id: ${inferredEntity.id})`); + normalizedParams.target = inferredEntity.label || inferredEntity.id; + } } } - if (params.rotation !== undefined) { + + // Handle position as array [x, y, z] instead of object {x, y, z} + if (Array.isArray(normalizedParams.position)) { + const posArray = normalizedParams.position as unknown as number[]; + normalizedParams.position = { + x: posArray[0] ?? 0, + y: posArray[1] ?? 1.5, + z: posArray[2] ?? 0 + }; + logger.debug('[modifyEntity] Converted position array to object:', normalizedParams.position); + } + + // Handle x, y, z directly instead of position object + if (!normalizedParams.position && (normalizedParams.x !== undefined || normalizedParams.y !== undefined || normalizedParams.z !== undefined)) { + normalizedParams.position = { + x: normalizedParams.x ?? 0, + y: normalizedParams.y ?? 1.5, + z: normalizedParams.z ?? 0 + }; + } + + // Handle 'text' as alias for 'label' + if (normalizedParams.label === undefined && normalizedParams.text !== undefined) { + normalizedParams.label = normalizedParams.text; + } + + // Validate target is provided + if (!normalizedParams.target) { + logger.error('[modifyEntity] Called without target! Params:', params); + return { + toolName: 'modify_entity', + success: false, + message: `Error: No target entity specified. Please provide the entity name or ID to modify.` + }; + } + + const updates: Partial = {}; + + if (normalizedParams.color) { + updates.color = resolveColor(normalizedParams.color); + } + if (normalizedParams.label !== undefined) { + updates.text = normalizedParams.label; + } + if (normalizedParams.shape) { + const template = SHAPE_TO_TEMPLATE[normalizedParams.shape as keyof typeof SHAPE_TO_TEMPLATE]; + if (template) { + updates.template = template; + logger.debug('[modifyEntity] Changing shape to:', normalizedParams.shape, '→', template); + } + } + if (normalizedParams.position) { + updates.position = normalizedParams.position; + } + if (normalizedParams.scale !== undefined) { + // Accept either uniform scale (number) or 3D scale object + if (typeof normalizedParams.scale === 'number') { + updates.scale = {x: normalizedParams.scale, y: normalizedParams.scale, z: normalizedParams.scale}; + } else { + updates.scale = normalizedParams.scale; + } + } + if (normalizedParams.rotation !== undefined) { // Accept degrees from AI, convert to radians for internal use // Can be uniform (single number for Y rotation) or full 3D rotation - if (typeof params.rotation === 'number') { + if (typeof normalizedParams.rotation === 'number') { // Single number = Y-axis rotation (most common for "turn 90 degrees") - updates.rotation = {x: 0, y: degreesToRadians(params.rotation), z: 0}; + updates.rotation = {x: 0, y: degreesToRadians(normalizedParams.rotation), z: 0}; } else { updates.rotation = { - x: degreesToRadians(params.rotation.x), - y: degreesToRadians(params.rotation.y), - z: degreesToRadians(params.rotation.z) + x: degreesToRadians(normalizedParams.rotation.x), + y: degreesToRadians(normalizedParams.rotation.y), + z: degreesToRadians(normalizedParams.rotation.z) }; } } const event = new CustomEvent('chatModifyEntity', { - detail: {target: params.target, updates}, + detail: {target: normalizedParams.target, updates}, bubbles: true }); document.dispatchEvent(event); const changes: string[] = []; - if (params.color) changes.push(`color to ${params.color}`); - if (params.label !== undefined) changes.push(`label to "${params.label}"`); - if (params.position) changes.push(`position`); - if (params.scale !== undefined) { - if (typeof params.scale === 'number') { - changes.push(`scale to ${params.scale}`); + if (normalizedParams.color) changes.push(`color to ${normalizedParams.color}`); + if (normalizedParams.label !== undefined) changes.push(`label to "${normalizedParams.label}"`); + if (normalizedParams.shape) changes.push(`shape to ${normalizedParams.shape}`); + if (normalizedParams.position) changes.push(`position`); + if (normalizedParams.scale !== undefined) { + if (typeof normalizedParams.scale === 'number') { + changes.push(`scale to ${normalizedParams.scale}`); } else { - changes.push(`scale to (${params.scale.x}, ${params.scale.y}, ${params.scale.z})`); + changes.push(`scale to (${normalizedParams.scale.x}, ${normalizedParams.scale.y}, ${normalizedParams.scale.z})`); } } - if (params.rotation !== undefined) { - if (typeof params.rotation === 'number') { - changes.push(`rotation to ${params.rotation}°`); + if (normalizedParams.rotation !== undefined) { + if (typeof normalizedParams.rotation === 'number') { + changes.push(`rotation to ${normalizedParams.rotation}°`); } else { - changes.push(`rotation to (${params.rotation.x}°, ${params.rotation.y}°, ${params.rotation.z}°)`); + changes.push(`rotation to (${normalizedParams.rotation.x}°, ${normalizedParams.rotation.y}°, ${normalizedParams.rotation.z}°)`); } } return { toolName: 'modify_entity', success: true, - message: `Modified entity "${params.target}"${changes.length > 0 ? ': ' + changes.join(', ') : ''}` + message: `Modified entity "${normalizedParams.target}"${changes.length > 0 ? ': ' + changes.join(', ') : ''}` }; } @@ -313,15 +677,18 @@ export function listEntities(): Promise { return new Promise((resolve) => { const responseHandler = (e: CustomEvent) => { document.removeEventListener('chatListEntitiesResponse', responseHandler as EventListener); - const entities = e.detail.entities as Array<{ + const allItems = e.detail.entities as Array<{ id: string; template: string; text: string; - position: { x: number; y: number; z: number } + color?: string; + position: { x: number; y: number; z: number }; + from?: string; + to?: string; }>; - logger.debug('[listEntities] Got response, entities:', entities.length); + logger.debug('[listEntities] Got response, items:', allItems.length); - if (entities.length === 0) { + if (allItems.length === 0) { resolve({ toolName: 'list_entities', success: true, @@ -330,15 +697,38 @@ export function listEntities(): Promise { return; } - const list = entities.map(e => { + // Separate entities from connections + const entities = allItems.filter(e => !e.template.includes('connection')); + const connections = allItems.filter(e => e.template.includes('connection')); + + // Build entity list + const entityList = entities.map(e => { const shape = e.template.replace('#', '').replace('-template', ''); return `- ${e.text || '(no label)'} (${shape}) at (${e.position?.x?.toFixed(1) || 0}, ${e.position?.y?.toFixed(1) || 0}, ${e.position?.z?.toFixed(1) || 0}) [id: ${e.id}]`; }).join('\n'); + // Build connection list with from/to info + const connectionList = connections.map(c => { + const fromEntity = entities.find(e => e.id === c.from); + const toEntity = entities.find(e => e.id === c.to); + const fromLabel = fromEntity?.text || c.from || '?'; + const toLabel = toEntity?.text || c.to || '?'; + return `- "${c.text || '(no label)'}" connects ${fromLabel} → ${toLabel} [id: ${c.id}]`; + }).join('\n'); + + let message = ''; + if (entities.length > 0) { + message += `**Entities (${entities.length}):**\n${entityList}`; + } + if (connections.length > 0) { + message += `\n\n**Connections (${connections.length}):**\n${connectionList}`; + message += `\n\nTo modify a connection, use modify_connection with from/to entity names or the connection label.`; + } + resolve({ toolName: 'list_entities', success: true, - message: `Current entities in the diagram:\n${list}` + message: message || 'The diagram is empty.' }); }; @@ -423,6 +813,33 @@ export async function clearDiagram(params: ClearDiagramParams): Promise { + return new Promise((resolve) => { + const responseHandler = (e: CustomEvent) => { + document.removeEventListener('chatGetCameraResponse', responseHandler as EventListener); + resolve(e.detail); + }; + + document.addEventListener('chatGetCameraResponse', responseHandler as EventListener); + + const event = new CustomEvent('chatGetCamera', {bubbles: true}); + document.dispatchEvent(event); + + setTimeout(() => { + document.removeEventListener('chatGetCameraResponse', responseHandler as EventListener); + resolve(null); + }, 2000); + }); +} + /** * Get the current camera position and orientation * Returns world-space coordinates with ground-projected directions for intuitive placement diff --git a/src/react/types/chatTypes.ts b/src/react/types/chatTypes.ts index fe88148..8f324e4 100644 --- a/src/react/types/chatTypes.ts +++ b/src/react/types/chatTypes.ts @@ -29,6 +29,7 @@ export interface ConnectEntitiesParams { from: string; to: string; color?: string; + label?: string; } export interface RemoveEntityParams { @@ -39,6 +40,7 @@ export interface ModifyEntityParams { target: string; color?: string; label?: string; + shape?: 'box' | 'sphere' | 'cylinder' | 'cone' | 'plane' | 'person'; position?: { x: number; y: number; z: number }; scale?: { x: number; y: number; z: number } | number; rotation?: { x: number; y: number; z: number } | number; // in degrees @@ -64,6 +66,10 @@ export interface WikipediaSearchParams { topic: string; } +export interface SetConnectionLabelPreferenceParams { + use_default_labels: boolean; +} + export type DiagramToolCall = | { name: 'create_entity'; input: CreateEntityParams } | { name: 'connect_entities'; input: ConnectEntitiesParams } @@ -77,7 +83,9 @@ export type DiagramToolCall = | { name: 'get_current_model'; input: Record } | { name: 'set_model'; input: SetModelParams } | { name: 'clear_conversation'; input: Record } - | { name: 'search_wikipedia'; input: WikipediaSearchParams }; + | { name: 'search_wikipedia'; input: WikipediaSearchParams } + | { name: 'set_connection_label_preference'; input: SetConnectionLabelPreferenceParams } + | { name: 'get_connection_label_preference'; input: Record }; export const SHAPE_TO_TEMPLATE: Record = { box: DiagramTemplates.BOX, diff --git a/tsconfig.json b/tsconfig.json index 2511dea..16311eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,8 +33,10 @@ // raises an error for unused parameters "noImplicitReturns": true, // raises an error for functions that return nothing - "skipLibCheck": true + "skipLibCheck": true, // skip type-checking of .d.ts files (it speeds up transpiling) + "noEmit": true + // don't emit .js files - Vite handles transpilation, tsc is for type checking only }, "include": [ "src"