diff --git a/package-lock.json b/package-lock.json index 5cdccdc15..ea72b7cd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,10 @@ "dependencies": { "@huggingface/transformers": "^3.0.0", "@isomorphic-git/lightning-fs": "^4.6.2", + "@langchain/core": "^0.3.0", + "@langchain/google-genai": "^0.1.0", + "@langchain/langgraph": "^0.2.74", + "@langchain/openai": "^0.3.0", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.1.18", "axios": "^1.13.2", @@ -23,6 +27,7 @@ "isomorphic-git": "^1.36.1", "jszip": "^3.10.1", "kuzu-wasm": "^0.11.1", + "langchain": "^0.3.37", "lru-cache": "^11.2.4", "lucide-react": "^0.562.0", "react": "^18.3.1", @@ -35,7 +40,7 @@ "vite-plugin-top-level-await": "^1.6.0", "vite-plugin-wasm": "^3.5.0", "web-tree-sitter": "^0.20.8", - "zod": "^4.1.13" + "zod": "^3.25.76" }, "devDependencies": { "@babel/types": "^7.28.5", @@ -352,6 +357,12 @@ "node": ">=6.9.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/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -817,6 +828,15 @@ "node": ">=14" } }, + "node_modules/@google/generative-ai": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", + "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@huggingface/jinja": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.3.tgz", @@ -1404,6 +1424,242 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@langchain/core": { + "version": "0.3.80", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.80.tgz", + "integrity": "sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA==", + "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.3.67", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "p-retry": "4", + "uuid": "^10.0.0", + "zod": "^3.25.32", + "zod-to-json-schema": "^3.22.3" + }, + "engines": { + "node": ">=18" + } + }, + "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/google-genai": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-0.1.12.tgz", + "integrity": "sha512-0Ea0E2g63ejCuormVxbuoyJQ5BYN53i2/fb6WP8bMKzyh+y43R13V8JqOtr3e/GmgNyv3ou/VeaZjx7KAvu/0g==", + "license": "MIT", + "dependencies": { + "@google/generative-ai": "^0.24.0", + "zod-to-json-schema": "^3.22.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.3.17 <0.4.0" + } + }, + "node_modules/@langchain/langgraph": { + "version": "0.2.74", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.2.74.tgz", + "integrity": "sha512-oHpEi5sTZTPaeZX1UnzfM2OAJ21QGQrwReTV6+QnX7h8nDCBzhtipAw1cK616S+X8zpcVOjgOtJuaJhXa4mN8w==", + "license": "MIT", + "dependencies": { + "@langchain/langgraph-checkpoint": "~0.0.17", + "@langchain/langgraph-sdk": "~0.0.32", + "uuid": "^10.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.2.36 <0.3.0 || >=0.3.40 < 0.4.0", + "zod-to-json-schema": "^3.x" + }, + "peerDependenciesMeta": { + "zod-to-json-schema": { + "optional": true + } + } + }, + "node_modules/@langchain/langgraph-checkpoint": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.18.tgz", + "integrity": "sha512-IS7zJj36VgY+4pf8ZjsVuUWef7oTwt1y9ylvwu0aLuOn1d0fg05Om9DLm3v2GZ2Df6bhLV1kfWAM0IAl9O5rQQ==", + "license": "MIT", + "dependencies": { + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.2.31 <0.4.0" + } + }, + "node_modules/@langchain/langgraph-checkpoint/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/langgraph-sdk": { + "version": "0.0.112", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.112.tgz", + "integrity": "sha512-/9W5HSWCqYgwma6EoOspL4BGYxGxeJP6lIquPSF4FA0JlKopaUv58ucZC3vAgdJyCgg6sorCIV/qg7SGpEcCLw==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "p-queue": "^6.6.2", + "p-retry": "4", + "uuid": "^9.0.0" + }, + "peerDependencies": { + "@langchain/core": ">=0.2.31 <0.4.0", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@langchain/core": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@langchain/langgraph-sdk/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@langchain/langgraph/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/openai": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-0.3.17.tgz", + "integrity": "sha512-uw4po32OKptVjq+CYHrumgbfh4NuD7LqyE+ZgqY9I/LrLc6bHLMc+sisHmI17vgek0K/yqtarI0alPJbzrwyag==", + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12", + "openai": "^4.77.0", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.3.29 <0.4.0" + } + }, + "node_modules/@langchain/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@langchain/openai/node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@langchain/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/@langchain/textsplitters": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@langchain/textsplitters/-/textsplitters-0.1.0.tgz", + "integrity": "sha512-djI4uw9rlkAb5iMhtLED+xJebDdAG935AdP4eRTB02R7OB/act55Bj9wsskhZsvuyQRpO4O1wQOp85s6T6GWmw==", + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.2.21 <0.4.0" + } + }, "node_modules/@mapbox/node-pre-gyp": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz", @@ -2519,7 +2775,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/jszip": { @@ -2556,6 +2811,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/prismjs": { "version": "1.26.5", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", @@ -2598,12 +2863,24 @@ "@types/react": "*" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "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/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -2854,6 +3131,18 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "8.6.3", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.3.tgz", @@ -2871,6 +3160,18 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "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/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -2892,6 +3193,12 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/async-listen": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-3.0.0.tgz", @@ -3162,6 +3469,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/caniuse-lite": { "version": "1.0.30001762", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", @@ -3193,6 +3512,37 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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/chalk/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/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -3287,6 +3637,24 @@ "dev": true, "license": "MIT" }, + "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/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/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3341,6 +3709,15 @@ "node": "^14.18.0 || >=16.10.0" } }, + "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/convert-hrtime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-3.0.0.tgz", @@ -3807,6 +4184,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-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -4518,6 +4904,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", @@ -4663,6 +5055,12 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -4671,6 +5069,19 @@ "node": ">=0.4.x" } }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4905,6 +5316,15 @@ "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", "license": "ISC" }, + "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/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -5065,6 +5485,15 @@ "node": ">= 14" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -5368,12 +5797,33 @@ "jiti": "lib/jiti-cli.mjs" } }, + "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", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5424,6 +5874,15 @@ "node": ">=6" } }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -5473,6 +5932,173 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/langchain": { + "version": "0.3.37", + "resolved": "https://registry.npmjs.org/langchain/-/langchain-0.3.37.tgz", + "integrity": "sha512-1jPsZ6xsxkcQPUvqRjvfuOLwZLLyt49hzcOK7OYAJovIkkOxd5gzK4Yw6giPUQ8g4XHyvULNlWBz+subdkcokw==", + "license": "MIT", + "dependencies": { + "@langchain/openai": ">=0.1.0 <0.7.0", + "@langchain/textsplitters": ">=0.0.0 <0.2.0", + "js-tiktoken": "^1.0.12", + "js-yaml": "^4.1.0", + "jsonpointer": "^5.0.1", + "langsmith": "^0.3.67", + "openapi-types": "^12.1.3", + "p-retry": "4", + "uuid": "^10.0.0", + "yaml": "^2.2.1", + "zod": "^3.25.32" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/anthropic": "*", + "@langchain/aws": "*", + "@langchain/cerebras": "*", + "@langchain/cohere": "*", + "@langchain/core": ">=0.3.58 <0.4.0", + "@langchain/deepseek": "*", + "@langchain/google-genai": "*", + "@langchain/google-vertexai": "*", + "@langchain/google-vertexai-web": "*", + "@langchain/groq": "*", + "@langchain/mistralai": "*", + "@langchain/ollama": "*", + "@langchain/xai": "*", + "axios": "*", + "cheerio": "*", + "handlebars": "^4.7.8", + "peggy": "^3.0.2", + "typeorm": "*" + }, + "peerDependenciesMeta": { + "@langchain/anthropic": { + "optional": true + }, + "@langchain/aws": { + "optional": true + }, + "@langchain/cerebras": { + "optional": true + }, + "@langchain/cohere": { + "optional": true + }, + "@langchain/deepseek": { + "optional": true + }, + "@langchain/google-genai": { + "optional": true + }, + "@langchain/google-vertexai": { + "optional": true + }, + "@langchain/google-vertexai-web": { + "optional": true + }, + "@langchain/groq": { + "optional": true + }, + "@langchain/mistralai": { + "optional": true + }, + "@langchain/ollama": { + "optional": true + }, + "@langchain/xai": { + "optional": true + }, + "axios": { + "optional": true + }, + "cheerio": { + "optional": true + }, + "handlebars": { + "optional": true + }, + "peggy": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, + "node_modules/langchain/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/langsmith": { + "version": "0.3.87", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.87.tgz", + "integrity": "sha512-XXR1+9INH8YX96FKWc5tie0QixWz6tOqAsAKfcJyPkE0xPep+NDz0IQLR32q4bn10QK3LqD2HN6T3n6z1YLW7Q==", + "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/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -6564,6 +7190,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "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/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -6582,11 +7217,30 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", - "dev": true, "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" @@ -6715,6 +7369,21 @@ "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", "license": "MIT" }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT" + }, + "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-map": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", @@ -6728,6 +7397,47 @@ "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-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "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/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -7169,6 +7879,15 @@ "node": ">=8" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -7513,6 +8232,12 @@ "simple-concat": "^1.0.0" } }, + "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/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -7579,6 +8304,18 @@ "inline-style-parser": "0.2.7" } }, + "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/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", @@ -7771,7 +8508,6 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, "license": "MIT" }, "node_modules/trim-lines": { @@ -8234,6 +8970,15 @@ "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/web-tree-sitter": { "version": "0.20.8", "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.20.8.tgz", @@ -8244,14 +8989,12 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, "license": "MIT", "dependencies": { "tr46": "~0.0.3", @@ -8292,6 +9035,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -8303,14 +9061,23 @@ } }, "node_modules/zod": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", - "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "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" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 79910530e..389cb37c2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,10 @@ "dependencies": { "@huggingface/transformers": "^3.0.0", "@isomorphic-git/lightning-fs": "^4.6.2", + "@langchain/core": "^0.3.0", + "@langchain/google-genai": "^0.1.0", + "@langchain/langgraph": "^0.2.74", + "@langchain/openai": "^0.3.0", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.1.18", "axios": "^1.13.2", @@ -24,6 +28,7 @@ "isomorphic-git": "^1.36.1", "jszip": "^3.10.1", "kuzu-wasm": "^0.11.1", + "langchain": "^0.3.37", "lru-cache": "^11.2.4", "lucide-react": "^0.562.0", "react": "^18.3.1", @@ -36,7 +41,7 @@ "vite-plugin-top-level-await": "^1.6.0", "vite-plugin-wasm": "^3.5.0", "web-tree-sitter": "^0.20.8", - "zod": "^4.1.13" + "zod": "^3.25.76" }, "devDependencies": { "@babel/types": "^7.28.5", diff --git a/src/App.tsx b/src/App.tsx index 7e75d40f4..61cb2f352 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { LoadingOverlay } from './components/LoadingOverlay'; import { Header } from './components/Header'; import { GraphCanvas, GraphCanvasHandle } from './components/GraphCanvas'; import { RightPanel } from './components/RightPanel'; +import { SettingsPanel } from './components/SettingsPanel'; import { StatusBar } from './components/StatusBar'; import { FileTreePanel } from './components/FileTreePanel'; import { FileEntry } from './services/zip'; @@ -21,6 +22,12 @@ const AppContent = () => { isRightPanelOpen, runPipeline, runPipelineFromFiles, + isSettingsPanelOpen, + setSettingsPanelOpen, + refreshLLMSettings, + initializeAgent, + startEmbeddings, + embeddingStatus, } = useAppState(); const graphCanvasRef = useRef(null); @@ -38,6 +45,17 @@ const AppContent = () => { setGraph(result.graph); setFileContents(result.fileContents); setViewMode('exploring'); + + // Auto-start embeddings pipeline in background + // Uses WebGPU if available, falls back to WASM + startEmbeddings().catch((err) => { + // WebGPU not available - try WASM fallback silently + if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { + startEmbeddings('wasm').catch(console.warn); + } else { + console.warn('Embeddings auto-start failed:', err); + } + }); } catch (error) { console.error('Pipeline error:', error); setProgress({ @@ -51,7 +69,7 @@ const AppContent = () => { setProgress(null); }, 3000); } - }, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipeline]); + }, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipeline, startEmbeddings]); const handleGitClone = useCallback(async (files: FileEntry[]) => { // Extract project name from first file path (e.g., "owner-repo-123/src/..." -> "owner-repo") @@ -69,6 +87,17 @@ const AppContent = () => { setGraph(result.graph); setFileContents(result.fileContents); setViewMode('exploring'); + + // Auto-start embeddings pipeline in background + // Uses WebGPU if available, falls back to WASM + startEmbeddings().catch((err) => { + // WebGPU not available - try WASM fallback silently + if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { + startEmbeddings('wasm').catch(console.warn); + } else { + console.warn('Embeddings auto-start failed:', err); + } + }); } catch (error) { console.error('Pipeline error:', error); setProgress({ @@ -82,12 +111,19 @@ const AppContent = () => { setProgress(null); }, 3000); } - }, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles]); + }, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings]); const handleFocusNode = useCallback((nodeId: string) => { graphCanvasRef.current?.focusNode(nodeId); }, []); + // Handle settings saved - refresh and reinitialize agent + // NOTE: Must be defined BEFORE any conditional returns (React hooks rule) + const handleSettingsSaved = useCallback(() => { + refreshLLMSettings(); + initializeAgent(); + }, [refreshLLMSettings, initializeAgent]); + // Render based on view mode if (viewMode === 'onboarding') { return ; @@ -116,6 +152,13 @@ const AppContent = () => { + + {/* Settings Panel (modal) */} + setSettingsPanelOpen(false)} + onSettingsSaved={handleSettingsSaved} + /> ); }; diff --git a/src/components/DropZone.tsx b/src/components/DropZone.tsx index b89dbdfbe..c2141f61a 100644 --- a/src/components/DropZone.tsx +++ b/src/components/DropZone.tsx @@ -209,9 +209,6 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => { .zip - - up to 50MB - )} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 89ab6267f..12ab765c3 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,4 +1,4 @@ -import { Search, Settings, HelpCircle, Sparkles } from 'lucide-react'; +import { Search, Settings, HelpCircle, Sparkles, Github, Star } from 'lucide-react'; import { useAppState } from '../hooks/useAppState'; import { useState, useMemo, useRef, useEffect } from 'react'; import { GraphNode } from '../core/graph/types'; @@ -183,6 +183,19 @@ export const Header = ({ onFocusNode }: HeaderProps) => { {/* Right section */}
+ {/* GitHub Star Button */} + + + Star if cool + + ✨ + + {/* Stats */} {graph && (
diff --git a/src/components/RightPanel.tsx b/src/components/RightPanel.tsx index 61539dd9d..e516fd916 100644 --- a/src/components/RightPanel.tsx +++ b/src/components/RightPanel.tsx @@ -1,9 +1,14 @@ import { useState, useMemo } from 'react'; -import { X, Send, Sparkles, User, FileCode, Hash, GitBranch, Code, MessageSquare, PanelRightClose } from 'lucide-react'; +import { + X, Send, Sparkles, User, FileCode, Hash, GitBranch, Code, MessageSquare, + PanelRightClose, Loader2, Settings, AlertTriangle +} from 'lucide-react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import ReactMarkdown from 'react-markdown'; import { useAppState } from '../hooks/useAppState'; import { NODE_COLORS } from '../lib/constants'; +import { ToolCallCard } from './ToolCallCard'; // Custom syntax theme const customTheme = { @@ -23,13 +28,6 @@ const customTheme = { }, }; -// Chat message interface -interface Message { - id: string; - role: 'user' | 'assistant'; - content: string; -} - export const RightPanel = () => { const { selectedNode, @@ -40,10 +38,18 @@ export const RightPanel = () => { setRightPanelOpen, rightPanelTab, setRightPanelTab, + // LLM / chat state + chatMessages, + isChatLoading, + currentToolCalls, + agentError, + isAgentReady, + isAgentInitializing, + setSettingsPanelOpen, + sendChatMessage, + clearChat, } = useAppState(); - // Chat state - const [messages, setMessages] = useState([]); const [chatInput, setChatInput] = useState(''); // Get source code for selected node @@ -90,27 +96,11 @@ export const RightPanel = () => { }, [selectedNode, graph]); // Chat handlers - const handleSendMessage = () => { + const handleSendMessage = async () => { if (!chatInput.trim()) return; - - const userMessage: Message = { - id: Date.now().toString(), - role: 'user', - content: chatInput.trim(), - }; - - setMessages(prev => [...prev, userMessage]); + const text = chatInput.trim(); setChatInput(''); - - // Simulate AI response - setTimeout(() => { - const aiMessage: Message = { - id: (Date.now() + 1).toString(), - role: 'assistant', - content: 'This is a placeholder response. AI integration coming soon! I will be able to help you understand the codebase, find specific functions, and explain how different parts connect.', - }; - setMessages(prev => [...prev, aiMessage]); - }, 500); + await sendChatMessage(text); }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -289,11 +279,51 @@ export const RightPanel = () => { Nexus AI • Ask about the codebase +
+ {!isAgentReady && ( + + Configure AI + + )} + {isAgentInitializing && ( + + Connecting + + )} + +
+ {/* Status / errors */} + {agentError && ( +
+ + {agentError} +
+ )} + + {/* Active tool calls - shown at top during execution */} + {currentToolCalls.length > 0 && ( +
+
+ + Working... +
+ {currentToolCalls.map(tc => ( + + ))} +
+ )} + {/* Messages */}
- {messages.length === 0 ? ( + {chatMessages.length === 0 ? (
🧠 @@ -318,7 +348,7 @@ export const RightPanel = () => {
) : (
- {messages.map((message) => ( + {chatMessages.map((message) => (
{
- {message.content} + {message.role === 'assistant' ? ( + { + const isInline = !className; + return isInline ? ( + + {children} + + ) : ( + + {children} + + ); + }, + pre: ({ children }) => <>{children}, + }} + > + {message.content} + + ) : ( + message.content + )} + {/* Tool calls shown as expandable cards */} + {message.toolCalls && message.toolCalls.length > 0 && ( +
+ {message.toolCalls.map(tc => ( + + ))} +
+ )}
))} @@ -363,13 +423,26 @@ export const RightPanel = () => { className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted resize-none max-h-24" /> +
+ {!isAgentReady && !isAgentInitializing && ( +
+ + Configure an LLM provider to enable chat. +
+ )}
)} diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx new file mode 100644 index 000000000..632a321bf --- /dev/null +++ b/src/components/SettingsPanel.tsx @@ -0,0 +1,490 @@ +import { useState, useEffect, useCallback } from 'react'; +import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw, Loader2 } from 'lucide-react'; +import { + loadSettings, + saveSettings, + getProviderDisplayName, + getAvailableModels, +} from '../core/llm/settings-service'; +import type { LLMSettings, LLMProvider } from '../core/llm/types'; + +interface SettingsPanelProps { + isOpen: boolean; + onClose: () => void; + onSettingsSaved?: () => void; +} + +/** + * Fetch available Gemini models from the API + */ +const fetchGeminiModels = async (apiKey: string): Promise => { + try { + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}` + ); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const data = await response.json(); + + // Filter for chat-capable models and extract model names + const models = (data.models || []) + .filter((model: any) => { + const methods = model.supportedGenerationMethods || []; + return methods.includes('generateContent'); + }) + .map((model: any) => { + const name = model.name || ''; + return name.replace('models/', ''); + }) + .filter((name: string) => name.length > 0) + .sort((a: string, b: string) => { + const score = (n: string) => { + if (n.includes('gemini-2')) return 0; + if (n.includes('gemini-1.5')) return 1; + if (n.includes('gemini-1')) return 2; + return 3; + }; + return score(a) - score(b) || a.localeCompare(b); + }); + + return models; + } catch (error) { + console.warn('Failed to fetch Gemini models:', error); + return []; + } +}; + +export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPanelProps) => { + const [settings, setSettings] = useState(loadSettings); + const [showApiKey, setShowApiKey] = useState>({}); + const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle'); + + // Gemini model fetching state + const [geminiModels, setGeminiModels] = useState([]); + const [isLoadingModels, setIsLoadingModels] = useState(false); + const [modelFetchError, setModelFetchError] = useState(null); + const [useCustomModel, setUseCustomModel] = useState(false); + + // Load settings when panel opens + useEffect(() => { + if (isOpen) { + setSettings(loadSettings()); + setSaveStatus('idle'); + setGeminiModels([]); + setModelFetchError(null); + setUseCustomModel(false); + } + }, [isOpen]); + + // Auto-fetch models when Gemini API key changes + const fetchModels = useCallback(async (apiKey: string) => { + if (!apiKey || apiKey.length < 10) { + setGeminiModels([]); + return; + } + + setIsLoadingModels(true); + setModelFetchError(null); + + const models = await fetchGeminiModels(apiKey); + + setIsLoadingModels(false); + + if (models.length > 0) { + setGeminiModels(models); + setModelFetchError(null); + } else { + setGeminiModels([]); + setModelFetchError('Could not fetch models. Check your API key or enter model manually.'); + } + }, []); + + // Fetch models when API key is entered (debounced) + useEffect(() => { + if (settings.activeProvider === 'gemini' && settings.gemini?.apiKey) { + const apiKey = settings.gemini.apiKey ?? ''; + const timer = setTimeout(() => { + fetchModels(apiKey); + }, 500); + return () => clearTimeout(timer); + } + }, [settings.gemini?.apiKey, settings.activeProvider, fetchModels]); + + const handleProviderChange = (provider: LLMProvider) => { + setSettings(prev => ({ ...prev, activeProvider: provider })); + }; + + const handleSave = () => { + try { + saveSettings(settings); + setSaveStatus('saved'); + onSettingsSaved?.(); + setTimeout(() => setSaveStatus('idle'), 2000); + } catch { + setSaveStatus('error'); + } + }; + + const toggleApiKeyVisibility = (key: string) => { + setShowApiKey(prev => ({ ...prev, [key]: !prev[key] })); + }; + + if (!isOpen) return null; + + const providers: LLMProvider[] = ['gemini', 'azure-openai']; + + const availableGeminiModels = geminiModels.length > 0 + ? geminiModels + : getAvailableModels('gemini'); + const currentGeminiModel = settings.gemini?.model ?? 'gemini-2.0-flash'; + const isCustomModelSelected = !availableGeminiModels.includes(currentGeminiModel); + + return ( +
+ {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+
+ +
+
+

AI Settings

+

Configure your LLM provider

+
+
+ +
+ + {/* Content */} +
+ {/* Provider Selection */} +
+ +
+ {providers.map(provider => ( + + ))} +
+
+ + {/* Gemini Settings */} + {settings.activeProvider === 'gemini' && ( +
+
+ +
+ setSettings(prev => ({ + ...prev, + gemini: { ...prev.gemini!, apiKey: e.target.value } + }))} + placeholder="Enter your Google AI API key" + className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" + /> + +
+

+ Get your API key from{' '} + + Google AI Studio + +

+
+ +
+
+ +
+ {isLoadingModels && ( + + + Fetching models... + + )} + {geminiModels.length > 0 && ( + + {geminiModels.length} models available + + )} + {settings.gemini?.apiKey && !isLoadingModels && ( + + )} +
+
+ + {/* Model selector or manual input */} + {(useCustomModel || isCustomModelSelected) ? ( +
+ setSettings(prev => ({ + ...prev, + gemini: { ...prev.gemini!, model: e.target.value } + }))} + placeholder="e.g., gemini-2.0-flash-exp" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm" + /> + +
+ ) : ( +
+ + +
+ )} + + {modelFetchError && !useCustomModel && ( +

+ + {modelFetchError} +

+ )} +
+
+ )} + + {/* Azure OpenAI Settings */} + {settings.activeProvider === 'azure-openai' && ( +
+
+ +
+ setSettings(prev => ({ + ...prev, + azureOpenAI: { ...prev.azureOpenAI!, apiKey: e.target.value } + }))} + placeholder="Enter your Azure OpenAI API key" + className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" + /> + +
+
+ +
+ + setSettings(prev => ({ + ...prev, + azureOpenAI: { ...prev.azureOpenAI!, endpoint: e.target.value } + }))} + placeholder="https://your-resource.openai.azure.com" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" + /> +
+ +
+ + setSettings(prev => ({ + ...prev, + azureOpenAI: { ...prev.azureOpenAI!, deploymentName: e.target.value } + }))} + placeholder="e.g., gpt-4o-deployment" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" + /> +
+ +
+
+ + setSettings(prev => ({ + ...prev, + azureOpenAI: { ...prev.azureOpenAI!, model: e.target.value } + }))} + placeholder="gpt-4o" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" + /> +
+ +
+ + setSettings(prev => ({ + ...prev, + azureOpenAI: { ...prev.azureOpenAI!, apiVersion: e.target.value } + }))} + placeholder="2024-08-01-preview" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" + /> +
+
+ +

+ Configure your Azure OpenAI service in the{' '} + + Azure Portal + +

+
+ )} + + {/* Privacy Note */} +
+
+
+ 🔒 +
+
+ Privacy: Your API keys are stored only in your browser's local storage. + They're sent directly to the LLM provider when you chat. Your code never leaves your machine. +
+
+
+
+ + {/* Footer */} +
+
+ {saveStatus === 'saved' && ( + + + Settings saved + + )} + {saveStatus === 'error' && ( + + + Failed to save + + )} +
+
+ + +
+
+
+
+ ); +}; + diff --git a/src/components/ToolCallCard.tsx b/src/components/ToolCallCard.tsx new file mode 100644 index 000000000..ca98ec1bc --- /dev/null +++ b/src/components/ToolCallCard.tsx @@ -0,0 +1,169 @@ +/** + * ToolCallCard Component + * + * Displays a tool call with expand/collapse functionality. + * Shows the tool name, status, and when expanded, the query/args and result. + */ + +import { useState } from 'react'; +import { ChevronDown, ChevronRight, Sparkles, Check, Loader2, AlertCircle } from 'lucide-react'; +import type { ToolCallInfo } from '../core/llm/types'; + +interface ToolCallCardProps { + toolCall: ToolCallInfo; + /** Start expanded (useful for in-progress calls) */ + defaultExpanded?: boolean; +} + +/** + * Format tool arguments for display + */ +const formatArgs = (args: Record): string => { + if (!args || Object.keys(args).length === 0) { + return ''; + } + + // Special handling for Cypher queries + if ('query' in args && typeof args.query === 'string') { + return args.query; + } + if ('cypher' in args && typeof args.cypher === 'string') { + // For execute_vector_cypher, show both the natural language query and cypher + let result = ''; + if ('query' in args) { + result += `Search: "${args.query}"\n\n`; + } + result += args.cypher; + return result; + } + + // For other tools, show as formatted JSON + return JSON.stringify(args, null, 2); +}; + +/** + * Get status icon and color + */ +const getStatusDisplay = (status: ToolCallInfo['status']) => { + switch (status) { + case 'running': + return { + icon: , + color: 'text-amber-400', + bgColor: 'bg-amber-500/10', + borderColor: 'border-amber-500/30', + }; + case 'completed': + return { + icon: , + color: 'text-emerald-400', + bgColor: 'bg-emerald-500/10', + borderColor: 'border-emerald-500/30', + }; + case 'error': + return { + icon: , + color: 'text-rose-400', + bgColor: 'bg-rose-500/10', + borderColor: 'border-rose-500/30', + }; + default: + return { + icon: , + color: 'text-text-muted', + bgColor: 'bg-surface', + borderColor: 'border-border-subtle', + }; + } +}; + +/** + * Get a friendly display name for the tool + */ +const getToolDisplayName = (name: string): string => { + const names: Record = { + 'execute_cypher': '🔍 Cypher Query', + 'execute_vector_cypher': '🧠 Semantic + Graph Query', + 'semantic_search': '🔎 Semantic Search', + 'semantic_search_with_context': '🔎 Semantic Search + Context', + 'get_code_content': '📄 Read Code', + 'get_codebase_stats': '📊 Get Stats', + 'get_graph_schema': '📋 Get Schema', + }; + return names[name] || name; +}; + +export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCardProps) => { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const status = getStatusDisplay(toolCall.status); + const formattedArgs = formatArgs(toolCall.args); + + return ( +
+ {/* Header - always visible */} + + + {/* Expanded content */} + {isExpanded && ( +
+ {/* Arguments/Query */} + {formattedArgs && ( +
+
+ {toolCall.name.includes('cypher') ? 'Query' : 'Input'} +
+
+                {formattedArgs}
+              
+
+ )} + + {/* Result */} + {toolCall.result && ( +
+
+ Result +
+
+                {toolCall.result.length > 2000 
+                  ? toolCall.result.slice(0, 2000) + '\n\n... (truncated)'
+                  : toolCall.result
+                }
+              
+
+ )} + + {/* Loading state for in-progress */} + {toolCall.status === 'running' && !toolCall.result && ( +
+ + Executing... +
+ )} +
+ )} +
+ ); +}; + +export default ToolCallCard; + diff --git a/src/core/embeddings/embedding-pipeline.ts b/src/core/embeddings/embedding-pipeline.ts index 6a0d8619a..154f70c61 100644 --- a/src/core/embeddings/embedding-pipeline.ts +++ b/src/core/embeddings/embedding-pipeline.ts @@ -275,10 +275,12 @@ export const semanticSearch = async ( const queryVecStr = `[${queryVec.join(',')}]`; // Query the vector index on CodeEmbedding, then JOIN with CodeNode for metadata + // Note: KuzuDB requires WITH after YIELD before using WHERE const cypher = ` CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', CAST(${queryVecStr} AS FLOAT[384]), ${k}) YIELD node AS emb, distance + WITH emb, distance WHERE distance < ${maxDistance} MATCH (n:CodeNode {id: emb.nodeId}) RETURN n.id AS nodeId, n.name AS name, n.label AS label, @@ -301,22 +303,24 @@ export const semanticSearch = async ( }; /** - * Semantic search with graph expansion - * Finds similar nodes AND their connections + * Semantic search with graph expansion (flattened results) + * Finds similar nodes AND their direct connections with relationship types * - * Uses separate CodeEmbedding table and JOINs with CodeNode + * Uses separate CodeEmbedding table and JOINs with CodeNode. + * Returns flattened results: one row per (match, connected) pair. + * This format works with KuzuDB and preserves relationship type information. * * @param executeQuery - Function to execute Cypher queries * @param query - Search query text - * @param k - Number of initial results - * @param hops - Number of hops to expand (default: 2) - * @returns Search results with connected nodes + * @param k - Number of initial semantic matches (default: 5) + * @param _hops - Unused (kept for API compatibility). Use execute_vector_cypher for multi-hop. + * @returns Flattened results: each row is a (match → connected) pair with relationship type */ export const semanticSearchWithContext = async ( executeQuery: (cypher: string) => Promise, query: string, k: number = 5, - hops: number = 2 + _hops: number = 1 // Currently only single-hop supported; multi-hop via execute_vector_cypher ): Promise => { if (!isEmbedderReady()) { throw new Error('Embedding model not initialized. Run embedding pipeline first.'); @@ -327,23 +331,22 @@ export const semanticSearchWithContext = async ( const queryVec = embeddingToArray(queryEmbedding); const queryVecStr = `[${queryVec.join(',')}]`; - // Query embedding table, JOIN with CodeNode, then expand graph + // Query embedding table, JOIN with CodeNode, then expand to direct connections + // Using single-hop so we can access r.type (variable-length paths don't support this in KuzuDB) + // Note: KuzuDB requires WITH after YIELD before using WHERE const cypher = ` CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', CAST(${queryVecStr} AS FLOAT[384]), ${k}) YIELD node AS emb, distance + WITH emb, distance WHERE distance < 0.5 MATCH (match:CodeNode {id: emb.nodeId}) - MATCH (match)-[r:CodeRelation*1..${hops}]-(connected:CodeNode) + MATCH (match)-[r:CodeRelation]-(connected:CodeNode) RETURN match.id AS matchId, match.name AS matchName, match.label AS matchLabel, match.filePath AS matchPath, distance, - collect(DISTINCT { - id: connected.id, - name: connected.name, - label: connected.label, - relationType: [rel IN r | rel.type] - }) AS connections - ORDER BY distance + connected.id AS connectedId, connected.name AS connectedName, + connected.label AS connectedLabel, r.type AS relationType + ORDER BY distance, matchId `; return executeQuery(cypher); diff --git a/src/core/llm/agent.ts b/src/core/llm/agent.ts new file mode 100644 index 000000000..73ca5ec70 --- /dev/null +++ b/src/core/llm/agent.ts @@ -0,0 +1,300 @@ +/** + * Graph RAG Agent Factory + * + * Creates a LangChain agent configured for code graph analysis. + * Supports Azure OpenAI and Google Gemini providers. + */ + +import { createReactAgent } from '@langchain/langgraph/prebuilt'; +import { SystemMessage } from '@langchain/core/messages'; +import { AzureChatOpenAI } from '@langchain/openai'; +import { ChatGoogleGenerativeAI } from '@langchain/google-genai'; +import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import { createGraphRAGTools } from './tools'; +import type { + ProviderConfig, + AzureOpenAIConfig, + GeminiConfig, + AgentStreamChunk, +} from './types'; + +/** + * System prompt for the Graph RAG agent + */ +const SYSTEM_PROMPT = `You are Nexus AI, an intelligent code analysis assistant. You help developers understand codebases by querying a knowledge graph (KuzuDB) that contains code structure, relationships, and semantic embeddings. + +CAPABILITIES: +- Execute Cypher queries to explore code structure (functions, classes, files, imports, call graphs) +- Perform semantic search to find code by meaning (when embeddings are available) +- Combine semantic search + graph traversal in a SINGLE Cypher query via the vector index (when embeddings are available) +- Search for exact text patterns using grep (regex) across all files +- Read full file contents directly +- Trace dependencies and relationships between code elements +- Explain code architecture and patterns + +APPROACH: +1. Start by understanding what the user wants to know +2. Choose the right tool(s) for the task: + - Use 'get_codebase_stats' first if you need an overview + - Use 'grep_code' to find EXACT text patterns (strings, error messages, TODOs, variable names) + - Use 'read_file' to see the FULL content of any file + - Use 'semantic_search' for CONCEPT-based lookup (find code by meaning, not exact text) + - Use 'semantic_search_with_context' for semantic + neighborhood expansion + - Use 'execute_vector_cypher' when you need semantic search + CUSTOM traversal in ONE query + - Use 'execute_cypher' for pure structural queries (relationships, call graphs) + - Use 'get_code_content' to show source code for a specific node ID +3. Interpret results and explain them clearly +4. Suggest follow-up explorations when relevant + +TOOL SELECTION GUIDE: +| Task | Best Tool | +|------|-----------| +| Find exact string "API_KEY" | grep_code | +| Find all TODO comments | grep_code | +| Find code related to "authentication" | semantic_search | +| What functions call X? | execute_cypher | +| Show me file utils.ts | read_file | +| Show code for a found node | get_code_content | +| Find auth code AND its callers | semantic_search_with_context | + +IMPORTANT NOTES ABOUT THE DATABASE: +- Nodes are stored in CodeNode(id, label, name, filePath, startLine, endLine, content) +- Edges are stored in CodeRelation(FROM CodeNode TO CodeNode, type) where type ∈ {CALLS, IMPORTS, CONTAINS, DEFINES} +- Embeddings are stored separately in CodeEmbedding(nodeId, embedding) for memory efficiency +- Vector index: code_embedding_idx on CodeEmbedding.embedding (cosine distance; smaller distance = more similar) +- Full file contents are available via grep_code and read_file (not truncated) + +UNIFIED VECTOR + GRAPH QUERY PATTERN (ONE QUERY): +1) Vector search to get closest embeddings +2) JOIN to CodeNode +3) Traverse relationships / collect context + +Example skeleton (note: WITH after YIELD is required in KuzuDB before WHERE): +CALL QUERY_VECTOR_INDEX('CodeEmbedding','code_embedding_idx', {{QUERY_VECTOR}}, 10) +YIELD node AS emb, distance +WITH emb, distance +WHERE distance < 0.5 +MATCH (match:CodeNode {id: emb.nodeId}) +MATCH (match)-[r:CodeRelation*1..2]-(ctx:CodeNode) +RETURN match.name, match.label, match.filePath, distance, collect(DISTINCT ctx.name) AS context +ORDER BY distance + +STYLE: +- Be concise but thorough +- Use code formatting when showing results +- Explain technical concepts when helpful +- If a query fails, explain why and suggest alternatives + +LIMITATIONS: +- Semantic search requires embeddings to be generated first (but grep_code always works) +- Large codebases may require more specific queries + +When showing code or query results, format them nicely using markdown.`; + +/** + * Create a chat model instance from provider configuration + */ +export const createChatModel = (config: ProviderConfig): BaseChatModel => { + switch (config.provider) { + case 'azure-openai': { + const azureConfig = config as AzureOpenAIConfig; + return new AzureChatOpenAI({ + azureOpenAIApiKey: azureConfig.apiKey, + azureOpenAIApiInstanceName: extractInstanceName(azureConfig.endpoint), + azureOpenAIApiDeploymentName: azureConfig.deploymentName, + azureOpenAIApiVersion: azureConfig.apiVersion ?? '2024-08-01-preview', + temperature: azureConfig.temperature ?? 0.1, + maxTokens: azureConfig.maxTokens, + streaming: true, + }); + } + + case 'gemini': { + const geminiConfig = config as GeminiConfig; + return new ChatGoogleGenerativeAI({ + apiKey: geminiConfig.apiKey, + model: geminiConfig.model, + temperature: geminiConfig.temperature ?? 0.1, + maxOutputTokens: geminiConfig.maxTokens, + streaming: true, + }); + } + + default: + throw new Error(`Unsupported provider: ${(config as any).provider}`); + } +}; + +/** + * Extract instance name from Azure endpoint URL + * e.g., "https://my-resource.openai.azure.com" -> "my-resource" + */ +const extractInstanceName = (endpoint: string): string => { + try { + const url = new URL(endpoint); + const hostname = url.hostname; + // Extract the first part before .openai.azure.com + const match = hostname.match(/^([^.]+)\.openai\.azure\.com/); + if (match) { + return match[1]; + } + // Fallback: just use the first part of hostname + return hostname.split('.')[0]; + } catch { + return endpoint; + } +}; + +/** + * Create a Graph RAG agent + */ +export const createGraphRAGAgent = ( + config: ProviderConfig, + executeQuery: (cypher: string) => Promise, + semanticSearch: (query: string, k?: number, maxDistance?: number) => Promise, + semanticSearchWithContext: (query: string, k?: number, hops?: number) => Promise, + isEmbeddingReady: () => boolean, + fileContents: Map +) => { + const model = createChatModel(config); + const tools = createGraphRAGTools( + executeQuery, + semanticSearch, + semanticSearchWithContext, + isEmbeddingReady, + fileContents + ); + + const agent = createReactAgent({ + llm: model as any, + tools: tools as any, + messageModifier: new SystemMessage(SYSTEM_PROMPT) as any, + }); + + return agent; +}; + +/** + * Message type for agent conversation + */ +export interface AgentMessage { + role: 'user' | 'assistant'; + content: string; +} + +/** + * Stream a response from the agent + * Uses streamMode: "values" to get step-by-step updates including reasoning + * + * Each step shows: + * - AI reasoning/thinking (content before tool calls) + * - Tool calls with arguments + * - Tool results + * - Final answer + */ +export async function* streamAgentResponse( + agent: ReturnType, + messages: AgentMessage[] +): AsyncGenerator { + try { + const formattedMessages = messages.map(m => ({ + role: m.role, + content: m.content, + })); + + // Use stream with "values" mode to get each step as a complete state + // This lets us see reasoning, tool calls, and results separately + const stream = await agent.stream( + { messages: formattedMessages }, + { streamMode: 'values' } + ); + + let lastMessageCount = formattedMessages.length; + + for await (const step of stream) { + const stepMessages = step.messages || []; + + // Process only new messages since last step + for (let i = lastMessageCount; i < stepMessages.length; i++) { + const msg = stepMessages[i]; + const msgType = msg._getType?.() || msg.type || 'unknown'; + + // AI message with content (reasoning or final answer) + if (msgType === 'ai' || msgType === 'AIMessage') { + const content = msg.content; + const toolCalls = msg.tool_calls || msg.additional_kwargs?.tool_calls || []; + + // If has content, yield it (reasoning or answer) + if (content && typeof content === 'string' && content.trim()) { + yield { + type: toolCalls.length > 0 ? 'reasoning' : 'content', + reasoning: toolCalls.length > 0 ? content : undefined, + content: toolCalls.length === 0 ? content : undefined, + }; + } + + // If has tool calls, yield each one + for (const tc of toolCalls) { + yield { + type: 'tool_call', + toolCall: { + id: tc.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2)}`, + name: tc.name || tc.function?.name || 'unknown', + args: tc.args || (tc.function?.arguments ? JSON.parse(tc.function.arguments) : {}), + status: 'running', + }, + }; + } + } + + // Tool message (result from a tool) + if (msgType === 'tool' || msgType === 'ToolMessage') { + const toolCallId = msg.tool_call_id || msg.additional_kwargs?.tool_call_id || ''; + const result = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); + + yield { + type: 'tool_result', + toolCall: { + id: toolCallId, + name: msg.name || 'tool', + args: {}, + result: result, + status: 'completed', + }, + }; + } + } + + lastMessageCount = stepMessages.length; + } + + yield { type: 'done' }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + yield { + type: 'error', + error: message, + }; + } +} + +/** + * Get a non-streaming response from the agent + * Simpler for cases where streaming isn't needed + */ +export const invokeAgent = async ( + agent: ReturnType, + messages: AgentMessage[] +): Promise => { + const formattedMessages = messages.map(m => ({ + role: m.role, + content: m.content, + })); + + const result = await agent.invoke({ messages: formattedMessages }); + + // result.messages is the full conversation state + const lastMessage = result.messages[result.messages.length - 1]; + return lastMessage?.content?.toString() ?? 'No response generated.'; +}; + diff --git a/src/core/llm/index.ts b/src/core/llm/index.ts new file mode 100644 index 000000000..ed1de4b87 --- /dev/null +++ b/src/core/llm/index.ts @@ -0,0 +1,34 @@ +/** + * LLM Module Exports + * + * Provides Graph RAG agent capabilities for code analysis. + */ + +// Types +export * from './types'; + +// Settings management +export { + loadSettings, + saveSettings, + updateProviderSettings, + setActiveProvider, + getActiveProviderConfig, + isProviderConfigured, + clearSettings, + getProviderDisplayName, + getAvailableModels, +} from './settings-service'; + +// Tools +export { createGraphRAGTools } from './tools'; + +// Agent +export { + createChatModel, + createGraphRAGAgent, + streamAgentResponse, + invokeAgent, + type AgentMessage, +} from './agent'; + diff --git a/src/core/llm/settings-service.ts b/src/core/llm/settings-service.ts new file mode 100644 index 000000000..4612b2872 --- /dev/null +++ b/src/core/llm/settings-service.ts @@ -0,0 +1,219 @@ +/** + * Settings Service + * + * Handles localStorage persistence for LLM provider settings. + * All API keys are stored locally - never sent to any server except the LLM provider. + */ + +import { + LLMSettings, + DEFAULT_LLM_SETTINGS, + LLMProvider, + AzureOpenAIConfig, + GeminiConfig, + OllamaConfig, + ProviderConfig, +} from './types'; + +const STORAGE_KEY = 'gitnexus-llm-settings'; + +/** + * Load settings from localStorage + */ +export const loadSettings = (): LLMSettings => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) { + return DEFAULT_LLM_SETTINGS; + } + + const parsed = JSON.parse(stored) as Partial; + + // Merge with defaults to handle new fields + return { + ...DEFAULT_LLM_SETTINGS, + ...parsed, + azureOpenAI: { + ...DEFAULT_LLM_SETTINGS.azureOpenAI, + ...parsed.azureOpenAI, + }, + gemini: { + ...DEFAULT_LLM_SETTINGS.gemini, + ...parsed.gemini, + }, + ollama: { + ...DEFAULT_LLM_SETTINGS.ollama, + ...parsed.ollama, + }, + }; + } catch (error) { + console.warn('Failed to load LLM settings:', error); + return DEFAULT_LLM_SETTINGS; + } +}; + +/** + * Save settings to localStorage + */ +export const saveSettings = (settings: LLMSettings): void => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch (error) { + console.error('Failed to save LLM settings:', error); + } +}; + +/** + * Update a specific provider's settings + */ +export const updateProviderSettings = ( + provider: T, + updates: Partial< + T extends 'azure-openai' ? Partial> : + T extends 'gemini' ? Partial> : + T extends 'ollama' ? Partial> : + never + > +): LLMSettings => { + const current = loadSettings(); + + // Avoid spreading unions like LLMSettings[keyof LLMSettings] (can be string/undefined) + switch (provider) { + case 'azure-openai': { + const updated: LLMSettings = { + ...current, + azureOpenAI: { + ...(current.azureOpenAI ?? {}), + ...(updates as Partial>), + }, + }; + saveSettings(updated); + return updated; + } + case 'gemini': { + const updated: LLMSettings = { + ...current, + gemini: { + ...(current.gemini ?? {}), + ...(updates as Partial>), + }, + }; + saveSettings(updated); + return updated; + } + case 'ollama': { + const updated: LLMSettings = { + ...current, + ollama: { + ...(current.ollama ?? {}), + ...(updates as Partial>), + }, + }; + saveSettings(updated); + return updated; + } + default: { + // Should be unreachable due to T extends LLMProvider, but keep a safe fallback + const updated: LLMSettings = { ...current }; + saveSettings(updated); + return updated; + } + } +}; + +/** + * Set the active provider + */ +export const setActiveProvider = (provider: LLMProvider): LLMSettings => { + const current = loadSettings(); + const updated: LLMSettings = { + ...current, + activeProvider: provider, + }; + saveSettings(updated); + return updated; +}; + +/** + * Get the current provider configuration + */ +export const getActiveProviderConfig = (): ProviderConfig | null => { + const settings = loadSettings(); + + switch (settings.activeProvider) { + case 'azure-openai': + if (!settings.azureOpenAI?.apiKey || !settings.azureOpenAI?.endpoint) { + return null; + } + return { + provider: 'azure-openai', + ...settings.azureOpenAI, + } as AzureOpenAIConfig; + + case 'gemini': + if (!settings.gemini?.apiKey) { + return null; + } + return { + provider: 'gemini', + ...settings.gemini, + } as GeminiConfig; + + case 'ollama': + return { + provider: 'ollama', + ...settings.ollama, + } as OllamaConfig; + + default: + return null; + } +}; + +/** + * Check if the active provider is properly configured + */ +export const isProviderConfigured = (): boolean => { + return getActiveProviderConfig() !== null; +}; + +/** + * Clear all settings (reset to defaults) + */ +export const clearSettings = (): void => { + localStorage.removeItem(STORAGE_KEY); +}; + +/** + * Get display name for a provider + */ +export const getProviderDisplayName = (provider: LLMProvider): string => { + switch (provider) { + case 'azure-openai': + return 'Azure OpenAI'; + case 'gemini': + return 'Google Gemini'; + case 'ollama': + return 'Ollama (Local)'; + default: + return provider; + } +}; + +/** + * Get available models for a provider + */ +export const getAvailableModels = (provider: LLMProvider): string[] => { + switch (provider) { + case 'azure-openai': + // Azure models depend on deployment, so we show common ones + return ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-35-turbo']; + case 'gemini': + return ['gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash', 'gemini-1.0-pro']; + case 'ollama': + return ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'deepseek-coder']; + default: + return []; + } +}; + diff --git a/src/core/llm/tools.ts b/src/core/llm/tools.ts new file mode 100644 index 000000000..c94fc76ce --- /dev/null +++ b/src/core/llm/tools.ts @@ -0,0 +1,594 @@ +/** + * Graph RAG Tools for LangChain Agent + * + * Custom tools that allow the agent to interact with the KuzuDB graph database + * for code analysis, semantic search, and graph traversal. + */ + +import { tool } from '@langchain/core/tools'; +import { z } from 'zod'; +import { GRAPH_SCHEMA_DESCRIPTION } from './types'; +import { WebGPUNotAvailableError, embedText, embeddingToArray, initEmbedder, isEmbedderReady } from '../embeddings/embedder'; + +/** + * Tool factory - creates tools bound to the KuzuDB query functions + * This is needed because the tools run in the worker and need access to the adapter + */ +export const createGraphRAGTools = ( + executeQuery: (cypher: string) => Promise, + semanticSearch: (query: string, k?: number, maxDistance?: number) => Promise, + semanticSearchWithContext: (query: string, k?: number, hops?: number) => Promise, + isEmbeddingReady: () => boolean, + fileContents: Map +) => { + /** + * Tool: Execute Cypher Query + * Allows the agent to run arbitrary Cypher queries against the graph + */ + const executeCypherTool = tool( + async ({ query }: { query: string }) => { + try { + const results = await executeQuery(query); + + if (results.length === 0) { + return 'Query returned no results.'; + } + + // Format results nicely for the LLM + const formatted = results.slice(0, 50).map((row, i) => { + // Handle both object and array results + if (Array.isArray(row)) { + return `[${i + 1}] ${row.join(', ')}`; + } + return `[${i + 1}] ${JSON.stringify(row)}`; + }); + + const resultText = formatted.join('\n'); + const truncated = results.length > 50 ? `\n... (${results.length - 50} more results truncated)` : ''; + + return `Query returned ${results.length} results:\n${resultText}${truncated}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Cypher query error: ${message}\n\nPlease check your query syntax and try again.`; + } + }, + { + name: 'execute_cypher', + description: 'Execute a Cypher query against the code knowledge graph. Use this for structural queries like finding functions, tracing call graphs, or analyzing imports. Call get_graph_schema first if you need to see the database schema.', + schema: z.object({ + query: z.string().describe('The Cypher query to execute. Must be valid KuzuDB Cypher syntax.'), + }), + } + ); + + /** + * Tool: Execute Vector Cypher Query (Unified Vector + Graph in ONE query) + * + * Lets the LLM write a Cypher query that includes a vector index call, + * while this tool handles embedding the natural-language query and injecting + * the vector into the Cypher safely. + * + * IMPORTANT: + * - The provided Cypher MUST include the placeholder {{QUERY_VECTOR}} + * - The placeholder will be replaced with: CAST([..384 floats..] AS FLOAT[384]) + * - KuzuDB requires WITH after YIELD before using WHERE + * + * Example: + * CALL QUERY_VECTOR_INDEX('CodeEmbedding','code_embedding_idx', {{QUERY_VECTOR}}, 10) + * YIELD node AS emb, distance + * WITH emb, distance + * WHERE distance < 0.5 + * MATCH (match:CodeNode {id: emb.nodeId}) ... + */ + const executeVectorCypherTool = tool( + async ({ query, cypher }: { query: string; cypher: string }) => { + if (!isEmbeddingReady()) { + return 'Vector Cypher is not available. Embeddings have not been generated yet.'; + } + + if (!cypher.includes('{{QUERY_VECTOR}}')) { + return "Invalid input: your Cypher must include the placeholder '{{QUERY_VECTOR}}' where a FLOAT[384] vector should go."; + } + + try { + // Ensure embedder is loaded. If WebGPU isn't available, fall back to WASM. + if (!isEmbedderReady()) { + try { + await initEmbedder(); + } catch (err) { + if (err instanceof WebGPUNotAvailableError) { + await initEmbedder(undefined, {}, 'wasm'); + } else { + throw err; + } + } + } + + // Embed the natural language query and inject into Cypher + const queryEmbedding = await embedText(query); + const queryVec = embeddingToArray(queryEmbedding); + const queryVecStr = `CAST([${queryVec.join(',')}] AS FLOAT[384])`; + + const finalCypher = cypher.replace(/\{\{\s*QUERY_VECTOR\s*\}\}/g, queryVecStr); + const results = await executeQuery(finalCypher); + + if (results.length === 0) { + return 'Query returned no results.'; + } + + const formatted = results.slice(0, 50).map((row, i) => { + if (Array.isArray(row)) { + return `[${i + 1}] ${row.join(', ')}`; + } + return `[${i + 1}] ${JSON.stringify(row)}`; + }); + + const resultText = formatted.join('\n'); + const truncated = results.length > 50 ? `\n... (${results.length - 50} more results truncated)` : ''; + + return `Query returned ${results.length} results:\n${resultText}${truncated}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Vector Cypher error: ${message}\n\nTip: Ensure you're querying the vector index on CodeEmbedding and JOINing back to CodeNode via emb.nodeId.`; + } + }, + { + name: 'execute_vector_cypher', + description: + "Execute a single Cypher query that combines vector similarity search and graph traversal. Provide a natural-language 'query' to embed, and a 'cypher' string containing the placeholder {{QUERY_VECTOR}}. Use this to do semantic search + traversal in ONE Cypher query. Remember: KuzuDB requires 'WITH emb, distance' after 'YIELD node AS emb, distance' before you can use WHERE.", + schema: z.object({ + query: z.string().describe('Natural language query to embed (used to produce a FLOAT[384] vector)'), + cypher: z + .string() + .describe( + "Cypher query to execute. MUST contain {{QUERY_VECTOR}}. Pattern: CALL QUERY_VECTOR_INDEX('CodeEmbedding','code_embedding_idx', {{QUERY_VECTOR}}, 10) YIELD node AS emb, distance WITH emb, distance WHERE distance < 0.5 MATCH (n:CodeNode {id: emb.nodeId}) ..." + ), + }), + } + ); + + /** + * Tool: Semantic Code Search + * Find code by meaning using vector embeddings + */ + const semanticSearchTool = tool( + async ({ query, limit }: { query: string; limit?: number }) => { + if (!isEmbeddingReady()) { + return 'Semantic search is not available. Embeddings have not been generated yet. Please use execute_cypher tool for structured queries instead.'; + } + + try { + const results = await semanticSearch(query, limit ?? 10, 0.5); + + if (results.length === 0) { + return `No code found matching "${query}". Try a different search term or use execute_cypher for structured queries.`; + } + + const formatted = results.map((r, i) => { + const location = r.startLine ? ` (lines ${r.startLine}-${r.endLine})` : ''; + return `[${i + 1}] ${r.label}: ${r.name}\n ID: ${r.nodeId}\n File: ${r.filePath}${location}\n Relevance: ${(1 - r.distance).toFixed(2)}`; + }); + + return `Found ${results.length} semantically similar code elements (use ID with get_code_content to see source):\n\n${formatted.join('\n\n')}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Semantic search error: ${message}`; + } + }, + { + name: 'semantic_search', + description: 'Search for code by meaning using semantic similarity. Good for finding code related to a concept even if exact terms are not used.', + schema: z.object({ + query: z.string().describe('Natural language description of what you are looking for'), + limit: z.number().optional().nullable().describe('Maximum number of results to return (default: 10)'), + }), + } + ); + + /** + * Tool: Semantic Search with Graph Context + * Find similar code AND expand to connected nodes (flattened format with relationship types) + */ + const semanticSearchWithContextTool = tool( + async ({ query, limit }: { query: string; limit?: number }) => { + if (!isEmbeddingReady()) { + return 'Semantic search is not available. Embeddings have not been generated yet. Please use execute_cypher tool for structured queries instead.'; + } + + try { + const results = await semanticSearchWithContext(query, limit ?? 5); + + if (results.length === 0) { + return `No code found matching "${query}". Try a different search term.`; + } + + // Results are flattened: one row per (match → connected) pair + // Group by match for cleaner output + const grouped = new Map; + }>(); + + for (const r of results) { + const matchId = r.matchId ?? r[0]; + const matchName = r.matchName ?? r[1]; + const matchLabel = r.matchLabel ?? r[2]; + const matchPath = r.matchPath ?? r[3]; + const distance = r.distance ?? r[4]; + const connectedName = r.connectedName ?? r[6]; + const connectedLabel = r.connectedLabel ?? r[7]; + const relationType = r.relationType ?? r[8]; + + if (!grouped.has(matchId)) { + grouped.set(matchId, { + matchId, + matchName, + matchLabel, + matchPath, + distance, + connections: [], + }); + } + + grouped.get(matchId)!.connections.push({ + name: connectedName, + label: connectedLabel, + relType: relationType, + }); + } + + // Format grouped results + const formatted = Array.from(grouped.values()).map((g, i) => { + const connectionsList = g.connections + .slice(0, 15) + .map(c => `${c.name} (${c.label}) via ${c.relType}`) + .join('\n '); + const more = g.connections.length > 15 ? `\n ... and ${g.connections.length - 15} more` : ''; + + return `[${i + 1}] ${g.matchLabel}: ${g.matchName}\n ID: ${g.matchId}\n File: ${g.matchPath}\n Relevance: ${(1 - g.distance).toFixed(2)}\n Connections:\n ${connectionsList}${more}`; + }); + + return `Found ${grouped.size} code elements with ${results.length} total connections (use ID with get_code_content to see source):\n\n${formatted.join('\n\n')}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Search with context error: ${message}`; + } + }, + { + name: 'semantic_search_with_context', + description: 'Search for code semantically AND show directly connected code elements with relationship types (CALLS, IMPORTS, DEFINES, CONTAINS). Shows what each match is connected to and how.', + schema: z.object({ + query: z.string().describe('Natural language description of what you are looking for'), + limit: z.number().optional().nullable().describe('Number of semantic matches to find (default: 5)'), + }), + } + ); + + /** + * Tool: Get Graph Schema + * Returns the schema for reference - LLM should call this before writing Cypher queries + */ + const getSchemaTool = tool( + async ({ includeExamples }: { includeExamples?: boolean }) => { + return GRAPH_SCHEMA_DESCRIPTION; + }, + { + name: 'get_graph_schema', + description: 'Get the graph database schema including node types, relationships, and Cypher query patterns. Call this before writing Cypher queries.', + schema: z.object({ + includeExamples: z.boolean().optional().nullable().describe('Whether to include query examples (default: true)'), + }), + } + ); + + /** + * Tool: Get Code Content + * Retrieve the source code for a specific node + * Uses fileContents Map for full content (not truncated DB content) + */ + const getCodeContentTool = tool( + async ({ nodeId }: { nodeId: string }) => { + try { + // Query graph for node metadata (fast, small data) + const results = await executeQuery( + `MATCH (n:CodeNode {id: '${nodeId.replace(/'/g, "''")}'}) + RETURN n.name AS name, n.label AS label, n.filePath AS filePath, + n.startLine AS startLine, n.endLine AS endLine` + ); + + if (results.length === 0) { + return `No node found with ID: ${nodeId}`; + } + + const node = results[0]; + const name = node.name ?? node[0]; + const label = node.label ?? node[1]; + const filePath = node.filePath ?? node[2]; + const startLine = node.startLine ?? node[3]; + const endLine = node.endLine ?? node[4]; + + // Get FULL content from fileContents Map (not truncated DB) + const fileContent = fileContents.get(filePath); + + if (!fileContent) { + return `${label}: ${name}\nFile: ${filePath}\n(File content not available in memory)`; + } + + // For File nodes, return full content (limited for very large files) + if (label === 'File' || label === 'Folder') { + const MAX_FILE_CONTENT = 30000; + if (fileContent.length > MAX_FILE_CONTENT) { + return `${label}: ${name}\nFile: ${filePath}\nTotal size: ${fileContent.length} characters\n\n\`\`\`\n${fileContent.slice(0, MAX_FILE_CONTENT)}\n\`\`\`\n\n... [truncated, use read_file for full content]`; + } + return `${label}: ${name}\nFile: ${filePath}\n\n\`\`\`\n${fileContent}\n\`\`\``; + } + + // For Function/Class/Method nodes, extract specific lines with context + const lines = fileContent.split('\n'); + const contextBefore = 3; + const contextAfter = 20; // Show more after to capture full function body + + const start = Math.max(0, (startLine ?? 0) - contextBefore); + const end = Math.min(lines.length - 1, (endLine ?? startLine ?? 0) + contextAfter); + + const snippet = lines.slice(start, end + 1).join('\n'); + + return `${label}: ${name}\nFile: ${filePath}\nLines: ${startLine + 1}-${endLine + 1}\n\n\`\`\`\n${snippet}\n\`\`\``; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error retrieving code: ${message}`; + } + }, + { + name: 'get_code_content', + description: 'Retrieve the source code content for a specific node by its ID. Use this after finding relevant nodes to see the actual implementation.', + schema: z.object({ + nodeId: z.string().describe('The ID of the node to retrieve code for'), + }), + } + ); + + /** + * Tool: Get Codebase Statistics + * Quick overview of what's in the graph + */ + const getStatsTool = tool( + async ({ verbose }: { verbose?: boolean }) => { + try { + const labelCounts = await executeQuery(` + MATCH (n:CodeNode) + RETURN n.label AS label, count(*) AS count + ORDER BY count DESC + `); + + const relCounts = await executeQuery(` + MATCH ()-[r:CodeRelation]->() + RETURN r.type AS type, count(*) AS count + ORDER BY count DESC + `); + + const nodeStats = labelCounts.map(r => { + const label = r.label ?? r[0]; + const count = r.count ?? r[1]; + return ` ${label}: ${count}`; + }).join('\n'); + + const relStats = relCounts.map(r => { + const type = r.type ?? r[0]; + const count = r.count ?? r[1]; + return ` ${type}: ${count}`; + }).join('\n'); + + const embeddingStatus = isEmbeddingReady() + ? 'Ready (semantic search available)' + : 'Not generated (use execute_cypher for queries)'; + + return `Codebase Statistics:\n\nNodes by type:\n${nodeStats}\n\nRelationships by type:\n${relStats}\n\nEmbeddings: ${embeddingStatus}\n\nFiles in memory: ${fileContents.size}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error getting stats: ${message}`; + } + }, + { + name: 'get_codebase_stats', + description: 'Get an overview of the codebase including counts of different element types (files, functions, classes) and relationship types.', + schema: z.object({ + verbose: z.boolean().optional().nullable().describe('Include detailed breakdown (default: false)'), + }), + } + ); + + /** + * Tool: Grep Code + * Search for patterns across all file contents using regex + */ + const grepCodeTool = tool( + async ({ pattern, filePattern, caseSensitive, maxResults }: { + pattern: string; + filePattern?: string; + caseSensitive?: boolean; + maxResults?: number; + }) => { + try { + const flags = caseSensitive ? 'g' : 'gi'; + let regex: RegExp; + try { + regex = new RegExp(pattern, flags); + } catch (e) { + return `Invalid regex pattern: ${pattern}. Error: ${e instanceof Error ? e.message : String(e)}`; + } + + const results: Array<{ + file: string; + line: number; + content: string; + }> = []; + + const limit = maxResults ?? 100; + + for (const [filePath, content] of fileContents.entries()) { + // Optional file pattern filter + if (filePattern && !filePath.toLowerCase().includes(filePattern.toLowerCase())) { + continue; + } + + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + results.push({ + file: filePath, + line: i + 1, + content: lines[i].trim().slice(0, 200), // Limit line length + }); + + if (results.length >= limit) break; + } + // Reset regex lastIndex for global flag + regex.lastIndex = 0; + } + + if (results.length >= limit) break; + } + + if (results.length === 0) { + return `No matches found for pattern: "${pattern}"${filePattern ? ` in files matching "${filePattern}"` : ''}`; + } + + const formatted = results.map(r => + `${r.file}:${r.line}: ${r.content}` + ).join('\n'); + + const truncatedMsg = results.length >= limit + ? `\n\n(Showing first ${limit} results. Use filePattern to narrow search.)` + : ''; + + return `Found ${results.length} matches for "${pattern}":\n\n${formatted}${truncatedMsg}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Grep error: ${message}`; + } + }, + { + name: 'grep_code', + description: 'Search for text patterns across all files in the codebase using regex. Use this to find exact strings, error messages, TODOs, specific variable names, or any text pattern. Returns file paths and line numbers of matches.', + schema: z.object({ + pattern: z.string().describe('Regex pattern to search for (e.g., "TODO", "console\\.log", "API_KEY")'), + filePattern: z.string().optional().nullable().describe('Optional filter - only search files whose path contains this string (e.g., ".ts", "src/api")'), + caseSensitive: z.boolean().optional().nullable().describe('Whether search is case-sensitive (default: false)'), + maxResults: z.number().optional().nullable().describe('Maximum number of results to return (default: 100)'), + }), + } + ); + + /** + * Tool: Read File + * Read the full content of a file by its path + */ + const readFileTool = tool( + async ({ filePath }: { filePath: string }) => { + // Normalize the requested path (handle Windows-style paths) + const normalizedRequest = filePath.replace(/\\/g, '/').toLowerCase(); + + // Try exact match first + let content = fileContents.get(filePath); + let actualPath = filePath; + + // If not found, try smarter matching + if (!content) { + // Score each file path by how well it matches the request + const candidates: Array<{ path: string; score: number }> = []; + + for (const [path] of fileContents.entries()) { + const normalizedPath = path.toLowerCase(); + + // Exact match (case-insensitive) + if (normalizedPath === normalizedRequest) { + candidates.push({ path, score: 1000 }); + continue; + } + + // Ends with the requested path (e.g., "README.md" matches "src/agent_service/README.md") + if (normalizedPath.endsWith(normalizedRequest)) { + // Score higher for shorter paths (more specific match) + candidates.push({ path, score: 100 + (200 - path.length) }); + continue; + } + + // Path contains all segments of the request in order + const requestSegments = normalizedRequest.split('/').filter(Boolean); + const pathSegments = normalizedPath.split('/'); + let matchScore = 0; + let lastMatchIdx = -1; + + for (const seg of requestSegments) { + const idx = pathSegments.findIndex((s, i) => i > lastMatchIdx && s.includes(seg)); + if (idx > lastMatchIdx) { + matchScore += 10; + lastMatchIdx = idx; + } + } + + // Only include if we matched more than half the segments + if (matchScore >= requestSegments.length * 5) { + candidates.push({ path, score: matchScore }); + } + } + + // Sort by score descending, pick best match + candidates.sort((a, b) => b.score - a.score); + + if (candidates.length > 0) { + actualPath = candidates[0].path; + content = fileContents.get(actualPath); + } + } + + if (!content) { + // List similar files to help the user + const fileName = filePath.split('/').pop()?.toLowerCase() || ''; + const similarFiles = Array.from(fileContents.keys()) + .filter(p => p.toLowerCase().includes(fileName)) + .slice(0, 5); + + if (similarFiles.length > 0) { + return `File not found: "${filePath}"\n\nDid you mean one of these?\n${similarFiles.map(f => ` - ${f}`).join('\n')}`; + } + return `File not found: "${filePath}"`; + } + + // For very large files, truncate with a warning + const MAX_CONTENT = 50000; // ~50KB + if (content.length > MAX_CONTENT) { + const truncated = content.slice(0, MAX_CONTENT); + const lines = content.split('\n').length; + return `File: ${actualPath}\nTotal lines: ${lines}\n\n(Showing first ${MAX_CONTENT} characters, file is ${content.length} characters total)\n\n${truncated}\n\n... [truncated]`; + } + + const lines = content.split('\n').length; + return `File: ${actualPath}\nLines: ${lines}\n\n${content}`; + }, + { + name: 'read_file', + description: 'Read the full content of a file by its path. Use this to see the complete source code of any file in the codebase.', + schema: z.object({ + filePath: z.string().describe('The file path to read (can be partial path like "src/utils.ts")'), + }), + } + ); + + return [ + executeCypherTool, + executeVectorCypherTool, + semanticSearchTool, + semanticSearchWithContextTool, + getSchemaTool, + getCodeContentTool, + getStatsTool, + grepCodeTool, + readFileTool, + ]; +}; diff --git a/src/core/llm/types.ts b/src/core/llm/types.ts new file mode 100644 index 000000000..282cab508 --- /dev/null +++ b/src/core/llm/types.ts @@ -0,0 +1,230 @@ +/** + * LLM Provider Types + * + * Type definitions for multi-provider LLM support. + * Supports Azure OpenAI and Google Gemini (with extensibility for others). + */ + +/** + * Supported LLM providers + */ +export type LLMProvider = 'azure-openai' | 'gemini' | 'ollama'; + +/** + * Base configuration shared by all providers + */ +export interface BaseProviderConfig { + provider: LLMProvider; + model: string; + temperature?: number; + maxTokens?: number; +} + +/** + * Azure OpenAI specific configuration + */ +export interface AzureOpenAIConfig extends BaseProviderConfig { + provider: 'azure-openai'; + apiKey: string; + endpoint: string; // e.g., https://your-resource.openai.azure.com + deploymentName: string; + apiVersion?: string; // defaults to '2024-08-01-preview' +} + +/** + * Google Gemini specific configuration + */ +export interface GeminiConfig extends BaseProviderConfig { + provider: 'gemini'; + apiKey: string; + model: string; // e.g., 'gemini-2.0-flash', 'gemini-1.5-pro' +} + +/** + * Ollama configuration (for future use) + */ +export interface OllamaConfig extends BaseProviderConfig { + provider: 'ollama'; + baseUrl?: string; // defaults to http://localhost:11434 + model: string; +} + +/** + * Union type for all provider configurations + */ +export type ProviderConfig = AzureOpenAIConfig | GeminiConfig | OllamaConfig; + +/** + * Stored settings (what goes to localStorage) + */ +export interface LLMSettings { + activeProvider: LLMProvider; + /** + * Provider settings are persisted to localStorage and may be partially configured. + * We validate required fields at runtime before creating a ProviderConfig. + */ + azureOpenAI?: Partial>; + gemini?: Partial>; + ollama?: Partial>; +} + +/** + * Default LLM settings + */ +export const DEFAULT_LLM_SETTINGS: LLMSettings = { + activeProvider: 'gemini', + gemini: { + apiKey: '', + model: 'gemini-2.0-flash', + temperature: 0.1, + }, + azureOpenAI: { + apiKey: '', + endpoint: '', + deploymentName: '', + model: 'gpt-4o', + apiVersion: '2024-08-01-preview', + temperature: 0.1, + }, + ollama: { + baseUrl: 'http://localhost:11434', + model: 'llama3.2', + temperature: 0.1, + }, +}; + +/** + * Chat message for agent interaction + */ +export interface ChatMessage { + id: string; + role: 'user' | 'assistant' | 'tool'; + content: string; + toolCalls?: ToolCallInfo[]; + toolCallId?: string; + timestamp: number; +} + +/** + * Tool call information for UI display + */ +export interface ToolCallInfo { + id: string; + name: string; + args: Record; + result?: string; + status: 'pending' | 'running' | 'completed' | 'error'; +} + +/** + * Streaming chunk from agent + * Now supports step-based streaming where each step is a distinct message + */ +export interface AgentStreamChunk { + type: 'reasoning' | 'tool_call' | 'tool_result' | 'content' | 'error' | 'done'; + /** LLM's reasoning/thinking text (shown as a step) */ + reasoning?: string; + /** Final answer content (streamed token by token) */ + content?: string; + /** Tool call information */ + toolCall?: ToolCallInfo; + /** Error message */ + error?: string; +} + +/** + * A single step in the agent's execution + * Used for displaying the agent's thought process + */ +export interface AgentStep { + id: string; + type: 'reasoning' | 'tool_call' | 'answer'; + /** For reasoning steps */ + content?: string; + /** For tool_call steps */ + toolCall?: ToolCallInfo; + /** Timestamp */ + timestamp: number; +} + +/** + * Graph schema information for LLM context + */ +export const GRAPH_SCHEMA_DESCRIPTION = ` +KUZU GRAPH DATABASE SCHEMA: + +NODE TABLES: +1. CodeNode - All code elements (polymorphic) + - id: STRING (primary key) + - label: STRING (one of: File, Folder, Function, Class, Method, Interface) + - name: STRING (element name) + - filePath: STRING (path in project) + - startLine: INT64 (line number where element starts) + - endLine: INT64 (line number where element ends) + - content: STRING (source code snippet) + +2. CodeEmbedding - Vector embeddings (SEPARATE TABLE for memory efficiency) + - nodeId: STRING (primary key, references CodeNode.id) + - embedding: FLOAT[384] (semantic vector) + +RELATIONSHIP TABLE: +- CodeRelation (FROM CodeNode TO CodeNode) + - type: STRING (one of: CALLS, IMPORTS, CONTAINS, DEFINES) + +IMPORTANT QUERY PATTERNS: + +1. Basic node queries: + MATCH (n:CodeNode {label: 'Function'}) RETURN n.name, n.filePath LIMIT 10 + +2. Relationship traversal: + MATCH (f:CodeNode {label: 'File'})-[r:CodeRelation {type: 'DEFINES'}]->(fn:CodeNode {label: 'Function'}) + RETURN f.name AS file, fn.name AS function + +3. SEMANTIC SEARCH (embeddings in separate table - MUST JOIN): + CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', $queryVector, 10) + YIELD node AS emb, distance + WITH emb, distance -- KuzuDB requires WITH after YIELD before WHERE + WHERE distance < 0.4 + MATCH (n:CodeNode {id: emb.nodeId}) -- JOIN required! + RETURN n.name, n.label, n.filePath, distance + ORDER BY distance + +4. Semantic search + graph expansion: + CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', $queryVector, 5) + YIELD node AS emb, distance + WITH emb, distance + WHERE distance < 0.5 + MATCH (match:CodeNode {id: emb.nodeId}) + MATCH (match)-[r:CodeRelation*1..2]-(connected:CodeNode) + RETURN match.name, distance, collect(DISTINCT connected.name) AS related + +5. Find callers of a function: + MATCH (caller:CodeNode)-[r:CodeRelation {type: 'CALLS'}]->(fn:CodeNode {name: $functionName}) + RETURN caller.name, caller.label, caller.filePath + +6. Import chain analysis: + MATCH (f:CodeNode {name: $fileName})-[r:CodeRelation {type: 'IMPORTS'}]->(imported:CodeNode) + RETURN imported.name AS imports + +7. Unified vector + graph traversal in ONE query (recommended pattern): + CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', $queryVector, 10) + YIELD node AS emb, distance + WITH emb, distance + WHERE distance < 0.5 + MATCH (match:CodeNode {id: emb.nodeId}) + MATCH (match)-[r:CodeRelation*1..2]-(ctx:CodeNode) + RETURN match.name AS found, match.label AS label, match.filePath AS path, + distance, collect(DISTINCT ctx.name) AS context + ORDER BY distance + +TOOLING NOTE (for execute_vector_cypher): +- When using the execute_vector_cypher tool, write Cypher containing {{QUERY_VECTOR}} where the vector should go. +- The tool will replace {{QUERY_VECTOR}} with a CAST([..] AS FLOAT[384]) literal. + +NOTES: +- Always use WHERE clauses to filter by label when possible for performance +- Use LIMIT to avoid returning too many results +- For semantic search, the vector index is on CodeEmbedding table, not CodeNode +- distance in vector search is cosine distance (0 = identical, 1 = orthogonal) +`; + diff --git a/src/hooks/useAppState.tsx b/src/hooks/useAppState.tsx index 6336e7a47..88c63cb6e 100644 --- a/src/hooks/useAppState.tsx +++ b/src/hooks/useAppState.tsx @@ -7,6 +7,9 @@ import { DEFAULT_VISIBLE_LABELS } from '../lib/constants'; import type { IngestionWorkerApi } from '../workers/ingestion.worker'; import type { FileEntry } from '../services/zip'; import type { EmbeddingProgress, SemanticSearchResult } from '../core/embeddings/types'; +import type { LLMSettings, ProviderConfig, AgentStreamChunk, ChatMessage, ToolCallInfo } from '../core/llm/types'; +import { loadSettings, getActiveProviderConfig } from '../core/llm/settings-service'; +import type { AgentMessage } from '../core/llm/agent'; export type ViewMode = 'onboarding' | 'loading' | 'exploring'; export type RightPanelTab = 'code' | 'chat'; @@ -82,6 +85,25 @@ interface AppState { // Debug/test methods testArrayParams: () => Promise<{ success: boolean; error?: string }>; + + // LLM/Agent state + llmSettings: LLMSettings; + isSettingsPanelOpen: boolean; + setSettingsPanelOpen: (open: boolean) => void; + isAgentReady: boolean; + isAgentInitializing: boolean; + agentError: string | null; + + // Chat state + chatMessages: ChatMessage[]; + isChatLoading: boolean; + currentToolCalls: ToolCallInfo[]; + + // LLM methods + refreshLLMSettings: () => void; + initializeAgent: () => Promise; + sendChatMessage: (message: string) => Promise; + clearChat: () => void; } const AppStateContext = createContext(null); @@ -135,6 +157,18 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { // Embedding state const [embeddingStatus, setEmbeddingStatus] = useState('idle'); const [embeddingProgress, setEmbeddingProgress] = useState(null); + + // LLM/Agent state + const [llmSettings, setLLMSettings] = useState(loadSettings); + const [isSettingsPanelOpen, setSettingsPanelOpen] = useState(false); + const [isAgentReady, setIsAgentReady] = useState(false); + const [isAgentInitializing, setIsAgentInitializing] = useState(false); + const [agentError, setAgentError] = useState(null); + + // Chat state + const [chatMessages, setChatMessages] = useState([]); + const [isChatLoading, setIsChatLoading] = useState(false); + const [currentToolCalls, setCurrentToolCalls] = useState([]); // Worker (single instance shared across app) const workerRef = useRef(null); @@ -266,6 +300,276 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { return api.testArrayParams(); }, []); + // LLM methods + const refreshLLMSettings = useCallback(() => { + setLLMSettings(loadSettings()); + }, []); + + const initializeAgent = useCallback(async (): Promise => { + const api = apiRef.current; + if (!api) { + setAgentError('Worker not initialized'); + return; + } + + const config = getActiveProviderConfig(); + if (!config) { + setAgentError('Please configure an LLM provider in settings'); + return; + } + + setIsAgentInitializing(true); + setAgentError(null); + + try { + const result = await api.initializeAgent(config); + if (result.success) { + setIsAgentReady(true); + setAgentError(null); + if (import.meta.env.DEV) { + console.log('✅ Agent initialized successfully'); + } + } else { + setAgentError(result.error ?? 'Failed to initialize agent'); + setIsAgentReady(false); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setAgentError(message); + setIsAgentReady(false); + } finally { + setIsAgentInitializing(false); + } + }, []); + + const sendChatMessage = useCallback(async (message: string): Promise => { + const api = apiRef.current; + if (!api) { + setAgentError('Worker not initialized'); + return; + } + + if (!isAgentReady) { + // Try to initialize first + await initializeAgent(); + if (!apiRef.current) return; + } + + // Add user message + const userMessage: ChatMessage = { + id: `user-${Date.now()}`, + role: 'user', + content: message, + timestamp: Date.now(), + }; + setChatMessages(prev => [...prev, userMessage]); + setIsChatLoading(true); + setCurrentToolCalls([]); + + // Prepare message history for agent (convert our format to AgentMessage format) + const history: AgentMessage[] = [...chatMessages, userMessage].map(m => ({ + role: m.role === 'tool' ? 'assistant' : m.role, + content: m.content, + })); + + // Create placeholder for assistant response + const assistantMessageId = `assistant-${Date.now()}`; + let assistantContent = ''; + const toolCallsForMessage: ToolCallInfo[] = []; + let reasoningSteps: string[] = []; // Collect reasoning steps + + try { + const onChunk = Comlink.proxy((chunk: AgentStreamChunk) => { + switch (chunk.type) { + case 'reasoning': + // LLM's thinking/reasoning before tool calls + if (chunk.reasoning) { + reasoningSteps.push(chunk.reasoning); + // Update message to show reasoning + setChatMessages(prev => { + const existing = prev.find(m => m.id === assistantMessageId); + // Build content with reasoning steps + const reasoningText = reasoningSteps.join('\n\n'); + if (existing) { + return prev.map(m => + m.id === assistantMessageId + ? { ...m, content: reasoningText, toolCalls: [...toolCallsForMessage] } + : m + ); + } else { + return [...prev, { + id: assistantMessageId, + role: 'assistant' as const, + content: reasoningText, + timestamp: Date.now(), + toolCalls: [...toolCallsForMessage], + }]; + } + }); + } + break; + + case 'content': + // Final answer content + if (chunk.content) { + assistantContent = chunk.content; // Replace, don't append (it's a full message) + // Update the assistant message + setChatMessages(prev => { + const existing = prev.find(m => m.id === assistantMessageId); + // Combine reasoning + final answer + const fullContent = reasoningSteps.length > 0 + ? reasoningSteps.join('\n\n') + '\n\n---\n\n' + assistantContent + : assistantContent; + if (existing) { + return prev.map(m => + m.id === assistantMessageId + ? { ...m, content: fullContent, toolCalls: [...toolCallsForMessage] } + : m + ); + } else { + return [...prev, { + id: assistantMessageId, + role: 'assistant' as const, + content: fullContent, + timestamp: Date.now(), + toolCalls: [...toolCallsForMessage], + }]; + } + }); + } + break; + + case 'tool_call': + if (chunk.toolCall) { + const tc = chunk.toolCall; + toolCallsForMessage.push(tc); + setCurrentToolCalls(prev => [...prev, tc]); + // Update message to include this tool call + setChatMessages(prev => { + const existing = prev.find(m => m.id === assistantMessageId); + const currentContent = reasoningSteps.length > 0 + ? reasoningSteps.join('\n\n') + : assistantContent || ''; + if (existing) { + return prev.map(m => + m.id === assistantMessageId + ? { ...m, content: currentContent, toolCalls: [...toolCallsForMessage] } + : m + ); + } else { + return [...prev, { + id: assistantMessageId, + role: 'assistant' as const, + content: currentContent, + timestamp: Date.now(), + toolCalls: [...toolCallsForMessage], + }]; + } + }); + } + break; + + case 'tool_result': + if (chunk.toolCall) { + const tc = chunk.toolCall; + // Update the tool call status - try ID first, then find first running tool with same name + let idx = toolCallsForMessage.findIndex(t => t.id === tc.id); + if (idx < 0) { + // ID didn't match - find the first RUNNING tool with same name (not already completed) + idx = toolCallsForMessage.findIndex(t => t.name === tc.name && t.status === 'running'); + } + if (idx < 0) { + // Still no match - find any tool with same name + idx = toolCallsForMessage.findIndex(t => t.name === tc.name && !t.result); + } + if (idx >= 0) { + toolCallsForMessage[idx] = { + ...toolCallsForMessage[idx], + result: tc.result, + status: 'completed' + }; + } + // Same logic for currentToolCalls + setCurrentToolCalls(prev => { + // Find by ID first + let targetIdx = prev.findIndex(t => t.id === tc.id); + if (targetIdx < 0) { + // Find first running tool with same name + targetIdx = prev.findIndex(t => t.name === tc.name && t.status === 'running'); + } + if (targetIdx < 0) { + // Find any incomplete tool with same name + targetIdx = prev.findIndex(t => t.name === tc.name && !t.result); + } + if (targetIdx >= 0) { + return prev.map((t, i) => i === targetIdx + ? { ...t, result: tc.result, status: 'completed' } + : t + ); + } + return prev; + }); + // Update message with completed tool call + setChatMessages(prev => { + const existing = prev.find(m => m.id === assistantMessageId); + if (existing) { + return prev.map(m => + m.id === assistantMessageId + ? { ...m, toolCalls: [...toolCallsForMessage] } + : m + ); + } + return prev; + }); + } + break; + + case 'error': + setAgentError(chunk.error ?? 'Unknown error'); + break; + + case 'done': + // Finalize the assistant message + setChatMessages(prev => { + const existing = prev.find(m => m.id === assistantMessageId); + const finalContent = assistantContent || (reasoningSteps.length > 0 ? reasoningSteps.join('\n\n') : ''); + if (existing) { + return prev.map(m => + m.id === assistantMessageId + ? { ...m, content: finalContent, toolCalls: [...toolCallsForMessage] } + : m + ); + } else if (finalContent || toolCallsForMessage.length > 0) { + return [...prev, { + id: assistantMessageId, + role: 'assistant' as const, + content: finalContent, + timestamp: Date.now(), + toolCalls: [...toolCallsForMessage], + }]; + } + return prev; + }); + break; + } + }); + + await api.chatStream(history, onChunk); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setAgentError(message); + } finally { + setIsChatLoading(false); + setCurrentToolCalls([]); + } + }, [chatMessages, isAgentReady, initializeAgent]); + + const clearChat = useCallback(() => { + setChatMessages([]); + setCurrentToolCalls([]); + setAgentError(null); + }, []); + const toggleLabelVisibility = useCallback((label: NodeLabel) => { setVisibleLabels(prev => { if (prev.includes(label)) { @@ -317,6 +621,22 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { isEmbeddingReady: embeddingStatus === 'ready', // Debug testArrayParams, + // LLM/Agent state + llmSettings, + isSettingsPanelOpen, + setSettingsPanelOpen, + isAgentReady, + isAgentInitializing, + agentError, + // Chat state + chatMessages, + isChatLoading, + currentToolCalls, + // LLM methods + refreshLLMSettings, + initializeAgent, + sendChatMessage, + clearChat, }; return ( diff --git a/src/workers/ingestion.worker.ts b/src/workers/ingestion.worker.ts index 1c6ccb86c..f70a7b2e4 100644 --- a/src/workers/ingestion.worker.ts +++ b/src/workers/ingestion.worker.ts @@ -10,6 +10,8 @@ import { } from '../core/embeddings/embedding-pipeline'; import { isEmbedderReady, disposeEmbedder } from '../core/embeddings/embedder'; import type { EmbeddingProgress, SemanticSearchResult } from '../core/embeddings/types'; +import type { ProviderConfig, AgentStreamChunk } from '../core/llm/types'; +import { createGraphRAGAgent, streamAgentResponse, type AgentMessage } from '../core/llm/agent'; // Lazy import for Kuzu to avoid breaking worker if SharedArrayBuffer unavailable let kuzuAdapter: typeof import('../core/kuzu/kuzu-adapter') | null = null; @@ -24,6 +26,13 @@ const getKuzuAdapter = async () => { let embeddingProgress: EmbeddingProgress | null = null; let isEmbeddingComplete = false; +// File contents state - stores full file contents for grep/read tools +let storedFileContents: Map = new Map(); + +// Agent state +let currentAgent: ReturnType | null = null; +let currentProviderConfig: ProviderConfig | null = null; + /** * Worker API exposed via Comlink * @@ -44,6 +53,9 @@ const workerApi = { // Run the actual pipeline const result = await runIngestionPipeline(file, onProgress); + // Store file contents for grep/read tools (full content, not truncated) + storedFileContents = result.fileContents; + // Load graph into KuzuDB for querying (optional - gracefully degrades) try { onProgress({ @@ -61,8 +73,9 @@ const workerApi = { await kuzu.loadGraphToKuzu(result.graph, result.fileContents); if (import.meta.env.DEV) { - const stats = await kuzu.getKuzuStats(); - console.log('KuzuDB loaded:', stats); + const stats = await kuzu.getKuzuStats(); + console.log('KuzuDB loaded:', stats); + console.log('📁 Stored', storedFileContents.size, 'files for grep/read tools'); } } catch { // KuzuDB is optional - silently continue without it @@ -130,6 +143,9 @@ const workerApi = { // Run the pipeline const result = await runPipelineFromFiles(files, onProgress); + // Store file contents for grep/read tools (full content, not truncated) + storedFileContents = result.fileContents; + // Load graph into KuzuDB for querying (optional - gracefully degrades) try { onProgress({ @@ -149,6 +165,7 @@ const workerApi = { if (import.meta.env.DEV) { const stats = await kuzu.getKuzuStats(); console.log('KuzuDB loaded:', stats); + console.log('📁 Stored', storedFileContents.size, 'files for grep/read tools'); } } catch { // KuzuDB is optional - silently continue without it @@ -285,6 +302,112 @@ const workerApi = { } return kuzu.testArrayParams(); }, + + // ============================================================ + // Graph RAG Agent Methods + // ============================================================ + + /** + * Initialize the Graph RAG agent with a provider configuration + * Must be called before using chat methods + * @param config - Provider configuration (Azure OpenAI or Gemini) + */ + async initializeAgent(config: ProviderConfig): Promise<{ success: boolean; error?: string }> { + try { + const kuzu = await getKuzuAdapter(); + if (!kuzu.isKuzuReady()) { + return { success: false, error: 'Database not ready. Please load a repository first.' }; + } + + // Create semantic search wrappers that handle embedding state + const semanticSearchWrapper = async (query: string, k?: number, maxDistance?: number) => { + if (!isEmbeddingComplete) { + throw new Error('Embeddings not ready'); + } + return doSemanticSearch(kuzu.executeQuery, query, k, maxDistance); + }; + + const semanticSearchWithContextWrapper = async (query: string, k?: number, hops?: number) => { + if (!isEmbeddingComplete) { + throw new Error('Embeddings not ready'); + } + return doSemanticSearchWithContext(kuzu.executeQuery, query, k, hops); + }; + + currentAgent = createGraphRAGAgent( + config, + kuzu.executeQuery, + semanticSearchWrapper, + semanticSearchWithContextWrapper, + () => isEmbeddingComplete, + storedFileContents + ); + currentProviderConfig = config; + + if (import.meta.env.DEV) { + console.log('🤖 Graph RAG Agent initialized with provider:', config.provider); + } + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (import.meta.env.DEV) { + console.error('❌ Agent initialization failed:', error); + } + return { success: false, error: message }; + } + }, + + /** + * Check if the agent is initialized + */ + isAgentReady(): boolean { + return currentAgent !== null; + }, + + /** + * Get current provider info + */ + getAgentProvider(): { provider: string; model: string } | null { + if (!currentProviderConfig) return null; + return { + provider: currentProviderConfig.provider, + model: currentProviderConfig.model, + }; + }, + + /** + * Chat with the Graph RAG agent (streaming) + * Sends response chunks via the onChunk callback + * @param messages - Conversation history + * @param onChunk - Proxied callback for streaming chunks (runs on main thread) + */ + async chatStream( + messages: AgentMessage[], + onChunk: (chunk: AgentStreamChunk) => void + ): Promise { + if (!currentAgent) { + onChunk({ type: 'error', error: 'Agent not initialized. Please configure an LLM provider first.' }); + return; + } + + try { + for await (const chunk of streamAgentResponse(currentAgent, messages)) { + onChunk(chunk); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + onChunk({ type: 'error', error: message }); + } + }, + + /** + * Dispose of the current agent + */ + disposeAgent(): void { + currentAgent = null; + currentProviderConfig = null; + }, }; // Expose the worker API to the main thread