diff --git a/ai-assistant/package-lock.json b/ai-assistant/package-lock.json index cafe6e970..52483a30e 100644 --- a/ai-assistant/package-lock.json +++ b/ai-assistant/package-lock.json @@ -12,9 +12,12 @@ "@langchain/community": "^0.3.42", "@langchain/core": "^0.3.51", "@langchain/google-genai": "^0.2.5", + "@langchain/langgraph": "^0.4.9", + "@langchain/mcp-adapters": "^0.6.0", "@langchain/mistralai": "^0.2.0", "@langchain/ollama": "^0.2.0", "@langchain/openai": "^0.5.5", + "@modelcontextprotocol/sdk": "^1.17.5", "@monaco-editor/react": "^4.5.2", "@types/prismjs": "^1.26.5", "@types/react-syntax-highlighter": "^15.5.13", @@ -2706,6 +2709,106 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/@langchain/langgraph": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.4.9.tgz", + "integrity": "sha512-+rcdTGi4Ium4X/VtIX3Zw4RhxEkYWpwUyz806V6rffjHOAMamg6/WZDxpJbrP33RV/wJG1GH12Z29oX3Pqq3Aw==", + "license": "MIT", + "dependencies": { + "@langchain/langgraph-checkpoint": "^0.1.1", + "@langchain/langgraph-sdk": "~0.1.0", + "uuid": "^10.0.0", + "zod": "^3.25.32" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.3.58 < 0.4.0", + "zod-to-json-schema": "^3.x" + }, + "peerDependenciesMeta": { + "zod-to-json-schema": { + "optional": true + } + } + }, + "node_modules/@langchain/langgraph-checkpoint": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.1.1.tgz", + "integrity": "sha512-h2bP0RUikQZu0Um1ZUPErQLXyhzroJqKRbRcxYRTAh49oNlsfeq4A3K4YEDRbGGuyPZI/Jiqwhks1wZwY73AZw==", + "license": "MIT", + "dependencies": { + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.2.31 <0.4.0 || ^1.0.0-alpha" + } + }, + "node_modules/@langchain/langgraph-sdk": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.1.0.tgz", + "integrity": "sha512-1EKwzwJpgpNqLcRuGG+kLvvhAaPiFWZ9shl/obhL8qDKtYdbR67WCYE+2jUObZ8vKQuCoul16ewJ78g5VrZlKA==", + "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/mcp-adapters": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@langchain/mcp-adapters/-/mcp-adapters-0.6.0.tgz", + "integrity": "sha512-NHQNH9NciLhxlCnL/4HDebiYT3UQvpBfF5KPlIi/uSXn8te/bYjPV64gUyAloNNo+fjj4qDvKP1/nHj0r7fKFw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "debug": "^4.4.0", + "zod": "^3.24.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "extended-eventsource": "^1.x" + }, + "peerDependencies": { + "@langchain/core": "^0.3.66" + } + }, "node_modules/@langchain/mistralai": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@langchain/mistralai/-/mistralai-0.2.1.tgz", @@ -2836,6 +2939,51 @@ "zod": ">= 3" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.5.tgz", + "integrity": "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg==", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.6", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, "node_modules/@monaco-editor/loader": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz", @@ -5274,7 +5422,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/json5": { @@ -6106,6 +6253,40 @@ "integrity": "sha512-VtUwTNU8fpMwvWGn4xE93ywbogTYsuT+AUxAXOeelbXuQVIwNmC5YLeho9sH4vZ4ITW8414TTAOG1nW6uIVHCA==", "license": "MIT" }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -6745,6 +6926,26 @@ "dev": true, "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -7056,6 +7257,15 @@ "dev": true, "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -7102,7 +7312,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7644,6 +7853,27 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -7655,12 +7885,20 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -7668,6 +7906,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -7791,7 +8042,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -8319,6 +8569,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -8525,6 +8784,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.193", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.193.tgz", @@ -8569,6 +8834,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", @@ -8897,6 +9171,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -9521,6 +9801,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -9546,6 +9835,27 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -9604,12 +9914,97 @@ "integrity": "sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg==", "license": "MIT" }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extended-eventsource": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/extended-eventsource/-/extended-eventsource-1.7.0.tgz", + "integrity": "sha512-s8rtvZuYcKBpzytHb5g95cHbZ1J99WeMnV18oKc5wKoxkHzlzpPc/bNAm7Da2Db0BDw0CAu1z3LpH+7UsyzIpw==", + "license": "MIT", + "optional": true + }, "node_modules/fake-indexeddb": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.0.1.tgz", @@ -9624,7 +10019,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-equals": { @@ -9678,7 +10072,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -9818,6 +10211,23 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -9972,6 +10382,24 @@ "node": ">= 12.20" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "11.3.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", @@ -10817,6 +11245,31 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -10978,7 +11431,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -11081,7 +11533,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/inline-style-parser": { @@ -11126,6 +11577,15 @@ "node": ">= 0.10" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -11564,6 +12024,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -11779,7 +12245,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/isomorphic-timers-promises": { @@ -13221,6 +13686,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -13238,6 +13712,18 @@ "map-or-similar": "^1.5.0" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -14142,6 +14628,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nice-grpc": { "version": "2.1.12", "resolved": "https://registry.npmjs.org/nice-grpc/-/nice-grpc-2.1.12.tgz", @@ -14463,7 +14958,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14473,7 +14967,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -14608,11 +15101,22 @@ "whatwg-fetch": "^3.6.20" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -14988,6 +15492,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -15019,7 +15532,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15182,6 +15694,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", @@ -15435,6 +15956,19 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -15500,7 +16034,6 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -15641,6 +16174,46 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -16695,6 +17268,32 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rrweb-cssom": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", @@ -16839,7 +17438,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, "license": "MIT" }, "node_modules/sanitize-html": { @@ -16916,6 +17514,64 @@ "dev": true, "license": "MIT" }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -16972,6 +17628,12 @@ "dev": true, "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sha.js": { "version": "2.4.12", "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", @@ -16997,7 +17659,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -17010,7 +17671,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -17101,7 +17761,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -17121,7 +17780,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -17138,7 +17796,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -17157,7 +17814,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -17318,7 +17974,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -18201,6 +18856,15 @@ "node": ">=10.13.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/token-types": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", @@ -18361,6 +19025,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -18600,6 +19299,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unplugin": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", @@ -18649,7 +19357,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -18766,6 +19473,15 @@ "node": ">= 10.13.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -19473,7 +20189,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -19729,7 +20444,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { diff --git a/ai-assistant/package.json b/ai-assistant/package.json index 47f9d4849..f017c3e98 100644 --- a/ai-assistant/package.json +++ b/ai-assistant/package.json @@ -38,9 +38,12 @@ "@langchain/community": "^0.3.42", "@langchain/core": "^0.3.51", "@langchain/google-genai": "^0.2.5", + "@langchain/langgraph": "^0.4.9", + "@langchain/mcp-adapters": "^0.6.0", "@langchain/mistralai": "^0.2.0", "@langchain/ollama": "^0.2.0", "@langchain/openai": "^0.5.5", + "@modelcontextprotocol/sdk": "^1.17.5", "@monaco-editor/react": "^4.5.2", "@types/prismjs": "^1.26.5", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/ai-assistant/src/ContentRenderer.tsx b/ai-assistant/src/ContentRenderer.tsx index 92427e1fc..3dcba2b18 100644 --- a/ai-assistant/src/ContentRenderer.tsx +++ b/ai-assistant/src/ContentRenderer.tsx @@ -6,6 +6,7 @@ import { Link as RouterLink, useHistory } from 'react-router-dom'; import remarkGfm from 'remark-gfm'; import YAML from 'yaml'; import { LogsButton, YamlDisplay } from './components'; +import MCPFormattedMessage from './components/chat/MCPFormattedMessage'; import { getHeadlampLink } from './utils/promptLinkHelper'; import { parseKubernetesYAML } from './utils/SampleYamlLibrary'; @@ -87,6 +88,8 @@ const parseLogsButtonData = (content: string, logsButtonIndex: number): ParseRes interface ContentRendererProps { content: string; onYamlDetected?: (yaml: string, resourceType: string) => void; + promptWidth?: string; // Add width prop + onRetryTool?: (toolName: string, args: Record) => void; } // Table wrapper component with show more functionality - moved outside to preserve state @@ -276,7 +279,7 @@ markdownComponents.li.displayName = 'MarkdownLi'; markdownComponents.blockquote.displayName = 'MarkdownBlockquote'; const ContentRenderer: React.FC = React.memo( - ({ content, onYamlDetected }) => { + ({ content, onYamlDetected, onRetryTool }) => { const history = useHistory(); // Create code component that has access to onYamlDetected const CodeComponent = React.useMemo(() => { @@ -524,7 +527,18 @@ const ContentRenderer: React.FC = React.memo( const processedContent = useMemo(() => { if (!content) return null; - // First, check if content is a JSON response with error or success keys + // First, check if content is a formatted MCP output (pure JSON) + try { + const parsed = JSON.parse(content.trim()); + if (parsed.formatted && parsed.mcpOutput) { + // This is a formatted MCP output, use our specialized component + return ; + } + } catch (error) { + // Not JSON or not formatted MCP output, continue with normal processing + } + + // Second, check if content is a JSON response with error or success keys const jsonParseResult = parseJsonContent(content.trim()); if (jsonParseResult.success) { const parsedContent = jsonParseResult.data; @@ -556,8 +570,8 @@ const ContentRenderer: React.FC = React.memo( theme.palette.grey[100], - color: theme => theme.palette.grey[900], + backgroundColor: (theme: any) => theme.palette.grey[100], + color: (theme: any) => theme.palette.grey[900], padding: 2, borderRadius: 1, overflowX: 'auto', @@ -633,7 +647,7 @@ const ContentRenderer: React.FC = React.memo( {content} ); - }, [content, onYamlDetected, processUnformattedYaml]); + }, [content, onYamlDetected, onRetryTool, processUnformattedYaml]); return ( @@ -645,7 +659,8 @@ const ContentRenderer: React.FC = React.memo( // Only re-render if content or onYamlDetected actually changed return ( prevProps.content === nextProps.content && - prevProps.onYamlDetected === nextProps.onYamlDetected + prevProps.onYamlDetected === nextProps.onYamlDetected && + prevProps.onRetryTool === nextProps.onRetryTool ); } ); diff --git a/ai-assistant/src/ai/manager.ts b/ai-assistant/src/ai/manager.ts index 55429a3dd..c09795c30 100644 --- a/ai-assistant/src/ai/manager.ts +++ b/ai-assistant/src/ai/manager.ts @@ -1,3 +1,11 @@ +export type ToolCall = { + id: string; + name: string; + description?: string; + arguments: Record; + type: 'mcp' | 'regular'; +}; + export type Prompt = { role: string; content: string; @@ -9,6 +17,14 @@ export type Prompt = { contentFilterError?: boolean; alreadyDisplayed?: boolean; isDisplayOnly?: boolean; // Mark messages that shouldn't be sent to LLM + requestId?: string; // For tracking tool confirmation messages + // Add support for inline tool confirmations + toolConfirmation?: { + tools: ToolCall[]; + onApprove: (approvedToolIds: string[]) => void; + onDeny: () => void; + loading?: boolean; + }; }; export default abstract class AIManager { diff --git a/ai-assistant/src/ai/mcp/electron-client.ts b/ai-assistant/src/ai/mcp/electron-client.ts new file mode 100644 index 000000000..720d67753 --- /dev/null +++ b/ai-assistant/src/ai/mcp/electron-client.ts @@ -0,0 +1,294 @@ +// Frontend MCP client that communicates with Electron main process +// This replaces the direct MCP client import to avoid spawn issues in renderer process + +interface MCPTool { + name: string; + description?: string; + inputSchema?: any; + server?: string; // Add server information +} + +interface MCPResponse { + success: boolean; + tools?: MCPTool[]; + result?: any; + error?: string; + toolCallId?: string; +} + +interface ElectronMCPApi { + executeTool: ( + toolName: string, + args: Record, + toolCallId?: string + ) => Promise; + getStatus: () => Promise<{ isInitialized: boolean; hasClient: boolean }>; + resetClient: () => Promise; + getConfig: () => Promise<{ success: boolean; config?: any; error?: string }>; + updateConfig: (config: any) => Promise; + getToolsConfig: () => Promise<{ success: boolean; config?: any; error?: string }>; + updateToolsConfig: (config: any) => Promise; + setToolEnabled: (serverName: string, toolName: string, enabled: boolean) => Promise; + getToolStats: ( + serverName: string, + toolName: string + ) => Promise<{ success: boolean; stats?: any; error?: string }>; +} + +declare global { + interface Window { + desktopApi?: { + mcp: ElectronMCPApi; + }; + } +} + +class ElectronMCPClient { + private isElectron: boolean; + + constructor() { + this.isElectron = + typeof window !== 'undefined' && + typeof window.desktopApi !== 'undefined' && + typeof window.desktopApi.mcp !== 'undefined'; + } + + /** + * Check if running in Electron environment with MCP support + */ + isAvailable(): boolean { + return this.isElectron; + } + + /** + * Execute an MCP tool via Electron main process + */ + async executeTool( + toolName: string, + args: Record, + toolCallId?: string + ): Promise { + if (!this.isElectron) { + throw new Error('MCP client not available - not running in Electron environment'); + } + + try { + const response = await window.desktopApi!.mcp.executeTool(toolName, args, toolCallId); + + if (response.success) { + return response.result; + } else { + throw new Error(response.error || 'Unknown error executing MCP tool'); + } + } catch (error) { + console.error(`Error executing MCP tool ${toolName}:`, error); + throw error; + } + } + + /** + * Get MCP client status from Electron main process + */ + async getStatus(): Promise<{ isInitialized: boolean; hasClient: boolean }> { + if (!this.isElectron) { + return { isInitialized: false, hasClient: false }; + } + + try { + return await window.desktopApi!.mcp.getStatus(); + } catch (error) { + console.error('Error getting MCP status:', error); + return { isInitialized: false, hasClient: false }; + } + } + + /** + * Reset/restart MCP client in Electron main process + */ + async resetClient(): Promise { + if (!this.isElectron) { + return false; + } + + try { + const response = await window.desktopApi!.mcp.resetClient(); + return response.success; + } catch (error) { + console.error('Error resetting MCP client:', error); + return false; + } + } + + /** + * Get MCP configuration from Electron main process + */ + async getConfig(): Promise<{ success: boolean; config?: any; error?: string }> { + if (!this.isElectron) { + return { + success: false, + error: 'MCP client not available - not running in Electron environment', + }; + } + + try { + const response = await window.desktopApi!.mcp.getConfig(); + return response; + } catch (error) { + console.error('Error getting MCP config:', error); + return { success: false, error: String(error) }; + } + } + + /** + * Get MCP tools configuration from Electron main process + */ + async getToolsConfig(): Promise<{ success: boolean; config?: any; error?: string }> { + if (!this.isElectron) { + return { + success: false, + error: 'MCP client not available - not running in Electron environment', + }; + } + + try { + const response = await window.desktopApi!.mcp.getToolsConfig(); + return response; + } catch (error) { + console.error('Error getting MCP tools config:', error); + return { success: false, error: String(error) }; + } + } + + /** + * Update MCP tools configuration in Electron main process + */ + async updateToolsConfig(config: any): Promise { + if (!this.isElectron) { + return false; + } + + try { + const response = await window.desktopApi!.mcp.updateToolsConfig(config); + return response.success; + } catch (error) { + console.error('Error updating MCP tools config:', error); + return false; + } + } + + /** + * Enable or disable a specific MCP tool + */ + async setToolEnabled(serverName: string, toolName: string, enabled: boolean): Promise { + if (!this.isElectron) { + return false; + } + + try { + const response = await window.desktopApi!.mcp.setToolEnabled(serverName, toolName, enabled); + return response.success; + } catch (error) { + console.error('Error setting tool enabled state:', error); + return false; + } + } + + /** + * Get tool statistics for a specific MCP tool + */ + async getToolStats(serverName: string, toolName: string): Promise { + if (!this.isElectron) { + return null; + } + + try { + const response = await window.desktopApi!.mcp.getToolStats(serverName, toolName); + return response.success ? response.stats : null; + } catch (error) { + console.error('Error getting tool stats:', error); + return null; + } + } + + /** + * Parse MCP tool name to extract server and tool components + * Format: "serverName__toolName" + */ + parseToolName(fullToolName: string): { serverName: string; toolName: string } { + const parts = fullToolName.split('__'); + if (parts.length >= 2) { + return { + serverName: parts[0], + toolName: parts.slice(1).join('__'), + }; + } + return { + serverName: 'default', + toolName: fullToolName, + }; + } + + /** + * Check if a specific tool is enabled + */ + async isToolEnabled(fullToolName: string): Promise { + if (!this.isElectron) { + return true; // Default to enabled if not in Electron + } + + try { + const { serverName, toolName } = this.parseToolName(fullToolName); + const toolsConfig = await this.getToolsConfig(); + + if (!toolsConfig.success || !toolsConfig.config) { + return true; // Default to enabled if config not available + } + + const serverConfig = toolsConfig.config[serverName]; + if (!serverConfig || !serverConfig[toolName]) { + return true; // Default to enabled for new tools + } + + return serverConfig[toolName].enabled; + } catch (error) { + console.error('Error checking tool enabled state:', error); + return true; // Default to enabled on error + } + } + + /** + * Get all enabled MCP tools + */ + async getEnabledTools(): Promise { + if (!this.isElectron) { + return []; + } + + try { + const allTools = await this.getEnabledTools(); + const enabledTools: MCPTool[] = []; + + for (const tool of allTools) { + const isEnabled = await this.isToolEnabled(tool.name); + if (isEnabled) { + enabledTools.push(tool); + } + } + + return enabledTools; + } catch (error) { + console.error('Error getting enabled tools:', error); + return []; + } + } +} + +// Export a function that returns tools (compatible with existing interface) +const tools = async function (): Promise { + const client = new ElectronMCPClient(); + return (await client.getToolsConfig()).config; +}; + +// Export both the client class and the tools function for flexibility +export { ElectronMCPClient }; +export default tools; diff --git a/ai-assistant/src/components/assistant/AIChatContent.tsx b/ai-assistant/src/components/assistant/AIChatContent.tsx index 6e418bc85..f92732145 100644 --- a/ai-assistant/src/components/assistant/AIChatContent.tsx +++ b/ai-assistant/src/components/assistant/AIChatContent.tsx @@ -11,6 +11,7 @@ interface AIChatContentProps { onOperationSuccess: (response: any) => void; onOperationFailure: (error: any, operationType: string, resourceInfo?: any) => void; onYamlAction: (yaml: string, title: string, type: string, isDeleteOp: boolean) => void; + onRetryTool?: (toolName: string, args: Record) => void; } export default function AIChatContent({ @@ -20,12 +21,18 @@ export default function AIChatContent({ onOperationSuccess, onOperationFailure, onYamlAction, + onRetryTool, }: AIChatContentProps) { return ( {apiError && ( @@ -56,6 +63,7 @@ export default function AIChatContent({ onOperationSuccess={onOperationSuccess} onOperationFailure={onOperationFailure} onYamlAction={onYamlAction} + onRetryTool={onRetryTool} /> ); diff --git a/ai-assistant/src/components/assistant/AIInputSection.tsx b/ai-assistant/src/components/assistant/AIInputSection.tsx index 2743eb102..677508cc3 100644 --- a/ai-assistant/src/components/assistant/AIInputSection.tsx +++ b/ai-assistant/src/components/assistant/AIInputSection.tsx @@ -15,6 +15,7 @@ import { getProviderById } from '../../config/modelConfig'; import { getModelDisplayName, getProviderModelsForChat } from '../../utils/modalUtils'; import { StoredProviderConfig } from '../../utils/ProviderConfigManager'; import TestModeInput from './TestModeInput'; +import { ToolsDialog } from './ToolsDialog'; interface AIInputSectionProps { promptVal: string; @@ -24,11 +25,17 @@ interface AIInputSectionProps { activeConfig: StoredProviderConfig | null; availableConfigs: StoredProviderConfig[]; selectedModel: string; + enabledTools: string[]; onSend: (prompt: string) => void; onStop: () => void; onClearHistory: () => void; onConfigChange: (config: StoredProviderConfig, model: string) => void; - onTestModeResponse: (content: string, type: 'assistant' | 'user', hasError?: boolean) => void; + onTestModeResponse: ( + content: string | object, + type: 'assistant' | 'user', + hasError?: boolean + ) => void; + onToolsChange: (enabledTools: string[]) => void; } export const AIInputSection: React.FC = ({ @@ -39,12 +46,15 @@ export const AIInputSection: React.FC = ({ activeConfig, availableConfigs, selectedModel, + enabledTools, onSend, onStop, onClearHistory, onConfigChange, onTestModeResponse, + onToolsChange, }) => { + const [showToolsDialog, setShowToolsDialog] = React.useState(false); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -123,7 +133,16 @@ export const AIInputSection: React.FC = ({ }} /> - + .MuiGrid-item': { + maxWidth: '100% !important', + }, + }} + > @@ -188,6 +207,20 @@ export const AIInputSection: React.FC = ({ )} + + {/* Tools Button */} + {!isTestMode && ( + + setShowToolsDialog(true)} + icon="mdi:tools" + iconButtonProps={{ + size: 'small', + }} + /> + + )} @@ -214,6 +247,14 @@ export const AIInputSection: React.FC = ({ )} + + {/* Tools Dialog */} + setShowToolsDialog(false)} + enabledTools={enabledTools} + onToolsChange={onToolsChange} + /> ); }; diff --git a/ai-assistant/src/components/assistant/TestModeInput.tsx b/ai-assistant/src/components/assistant/TestModeInput.tsx index 67ced8930..43a1eb4b5 100644 --- a/ai-assistant/src/components/assistant/TestModeInput.tsx +++ b/ai-assistant/src/components/assistant/TestModeInput.tsx @@ -20,7 +20,11 @@ import { import React, { useState } from 'react'; interface TestModeInputProps { - onAddTestResponse: (content: string, type: 'assistant' | 'user', hasError?: boolean) => void; + onAddTestResponse: ( + content: string | object, + type: 'assistant' | 'user', + hasError?: boolean + ) => void; isTestMode: boolean; } @@ -31,7 +35,12 @@ const TestModeInput: React.FC = ({ onAddTestResponse, isTest const [hasError, setHasError] = useState(false); // Sample test responses for quick testing - const sampleResponses = [ + const sampleResponses: Array<{ + label: string; + content: string | object; + type: 'assistant' | 'user'; + hasError?: boolean; + }> = [ { label: 'Simple Markdown Text', content: `Here's how you can create a simple deployment: @@ -183,11 +192,154 @@ All deployments are currently active in your cluster.`, content: `How can I create a deployment with 3 replicas of nginx?`, type: 'user' as const, }, + { + label: 'Tool Confirmation - Kubernetes API', + content: { + role: 'assistant', + content: '', + toolConfirmation: { + tools: [ + { + id: 'call_O7EYtgCzt5RmchxdZDJihMEF', + name: 'kubernetes_api_request', + description: 'Executes Kubernetes API operations', + arguments: { + url: '/api/v1/namespaces/default/pods', + method: 'GET', + }, + type: 'regular', + }, + ], + loading: false, + requestId: 'tool-approval-1759265356521-0.1868110264399998', + userContext: { + timeContext: '2025-09-30T20:49:16.521Z', + userMessage: 'List me the pods here.', + conversationHistory: [ + { + role: 'user', + content: 'List me the pods here.', + }, + { + role: 'assistant', + content: '', + }, + ], + }, + }, + isDisplayOnly: true, + requestId: 'tool-approval-1759265356521-0.1868110264399998', + }, + type: 'assistant' as const, + }, + { + label: 'Tool Confirmation - MCP Tool', + content: { + role: 'assistant', + content: '', + toolConfirmation: { + tools: [ + { + id: 'call_MCP_example', + name: 'flux_get_resources', + description: 'Get Flux resources from the cluster', + arguments: { + namespace: 'flux-system', + resourceType: 'helmreleases', + name: '', + }, + type: 'mcp', + }, + ], + loading: false, + requestId: 'tool-approval-mcp-test', + userContext: { + timeContext: '2025-09-30T20:49:16.521Z', + userMessage: 'Show me the Flux Helm releases.', + conversationHistory: [ + { + role: 'user', + content: 'Show me the Flux Helm releases.', + }, + { + role: 'assistant', + content: '', + }, + ], + }, + }, + isDisplayOnly: true, + requestId: 'tool-approval-mcp-test', + }, + type: 'assistant' as const, + }, + { + label: 'Tool Confirmation - Multiple Tools', + content: { + role: 'assistant', + content: '', + toolConfirmation: { + tools: [ + { + id: 'call_k8s_get_pods', + name: 'kubernetes_api_request', + description: 'Get pods from Kubernetes API', + arguments: { + url: '/api/v1/namespaces/default/pods', + method: 'GET', + }, + type: 'regular', + }, + { + id: 'call_flux_check', + name: 'flux_get_helmreleases', + description: 'Check Flux Helm releases', + arguments: { + namespace: 'flux-system', + name: '', + output: 'json', + }, + type: 'mcp', + }, + ], + loading: false, + requestId: 'tool-approval-multi-test', + userContext: { + timeContext: '2025-09-30T20:49:16.521Z', + userMessage: 'Show me pods and Flux releases.', + conversationHistory: [ + { + role: 'user', + content: 'Show me pods and Flux releases.', + }, + { + role: 'assistant', + content: '', + }, + ], + }, + }, + isDisplayOnly: true, + requestId: 'tool-approval-multi-test', + }, + type: 'assistant' as const, + }, ]; const handleSubmit = () => { if (testContent.trim()) { - onAddTestResponse(testContent, responseType, hasError); + let content: string | object = testContent; + + // Try to parse as JSON if it looks like a tool confirmation object + if (testContent.trim().startsWith('{') && testContent.includes('toolConfirmation')) { + try { + content = JSON.parse(testContent); + } catch (error) { + console.warn('Failed to parse JSON content, using as string:', error); + } + } + + onAddTestResponse(content, responseType, hasError); setTestContent(''); setOpen(false); } @@ -266,13 +418,14 @@ All deployments are currently active in your cluster.`, fullWidth value={testContent} onChange={e => setTestContent(e.target.value)} - placeholder="Enter your test response here. You can use markdown, YAML code blocks, etc." + placeholder="Enter your test response here. You can use markdown, YAML code blocks, or JSON objects for tool confirmations." variant="outlined" /> - Tip: Use ```yaml code blocks to test YAML rendering, or include markdown for - formatting tests. + Tip: Use ```yaml code blocks to test YAML rendering, markdown for formatting tests, or + JSON objects starting with {'{toolConfirmation: ...}'} to test tool confirmation + dialogs. diff --git a/ai-assistant/src/components/assistant/ToolsDialog.tsx b/ai-assistant/src/components/assistant/ToolsDialog.tsx new file mode 100644 index 000000000..379377924 --- /dev/null +++ b/ai-assistant/src/components/assistant/ToolsDialog.tsx @@ -0,0 +1,552 @@ +import { Icon } from '@iconify/react'; +import { Dialog } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Button, + Chip, + CircularProgress, + DialogActions, + DialogContent, + DialogTitle, + Divider, + InputAdornment, + List, + ListItem, + ListItemSecondaryAction, + ListItemText, + Switch, + TextField, + Typography, +} from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import { ElectronMCPClient } from '../../ai/mcp/electron-client'; +import { AVAILABLE_TOOLS } from '../../langchain/tools/registry'; + +interface MCPTool { + name: string; + description?: string; + inputSchema?: any; + server?: string; +} + +interface ToolsDialogProps { + open: boolean; + onClose: () => void; + enabledTools: string[]; + onToolsChange: (enabledTools: string[]) => void; +} + +export const ToolsDialog: React.FC = ({ + open, + onClose, + enabledTools, + onToolsChange, +}) => { + const [localEnabledTools, setLocalEnabledTools] = useState(enabledTools); + const [allKnownMcpTools, setAllKnownMcpTools] = useState([]); // Track all tools ever seen + const [mcpToolsConfig, setMcpToolsConfig] = useState({}); + const [originalMcpConfig, setOriginalMcpConfig] = useState({}); + const [isLoadingMcp, setIsLoadingMcp] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [expandedServers, setExpandedServers] = useState>(new Set()); + const [, setMcpServers] = useState<{ [key: string]: any }>({}); + + // Load MCP tools when dialog opens + useEffect(() => { + if (open) { + loadMcpTools(); + } + }, [open]); + + // Sync local state when enabledTools prop changes + useEffect(() => { + setLocalEnabledTools(enabledTools); + }, [enabledTools]); + + // Parse MCP tool name to extract server and tool components + const parseMcpToolName = (fullToolName: string): { serverName: string; toolName: string } => { + const parts = fullToolName.split('__'); + if (parts.length >= 2) { + return { + serverName: parts[0], + toolName: parts.slice(1).join('__'), + }; + } + return { + serverName: 'default', + toolName: fullToolName, + }; + }; + + // Check if an MCP tool is enabled in the configuration + const isMcpToolEnabled = (toolName: string): boolean => { + const { serverName, toolName: actualToolName } = parseMcpToolName(toolName); + const serverConfig = mcpToolsConfig[serverName]; + if (!serverConfig || !serverConfig[actualToolName]) { + return true; // Default to enabled for new tools + } + return serverConfig[actualToolName].enabled !== false; + }; + + const loadMcpTools = async () => { + setIsLoadingMcp(true); + try { + const mcpClient = new ElectronMCPClient(); + + // Load server configuration and tools configuration - these are our source of truth + const [configResponse, toolsConfigResponse] = await Promise.all([ + mcpClient.getConfig(), + mcpClient.getToolsConfig(), + ]); + + console.log('config response is ', configResponse); + // Store MCP tools configuration + if (toolsConfigResponse.success && toolsConfigResponse.config) { + setMcpToolsConfig(toolsConfigResponse.config); + setOriginalMcpConfig(JSON.parse(JSON.stringify(toolsConfigResponse.config))); + } + + // Extract server names from config + let servers: { [key: string]: any } = {}; + if (configResponse.success && configResponse.config && configResponse.config.servers) { + servers = configResponse.config.servers.reduce( + (acc: { [key: string]: any }, server: any) => { + acc[server.name] = server; + return acc; + }, + {} + ); + setMcpServers(servers); + } + + // Create tools from configuration (this is our source of truth) + const toolsFromConfig: MCPTool[] = []; + if (toolsConfigResponse.success && toolsConfigResponse.config) { + Object.entries(toolsConfigResponse.config).forEach( + ([serverName, serverTools]: [string, any]) => { + Object.keys(serverTools).forEach((toolName: string) => { + const fullToolName = `${serverName}__${toolName}`; + toolsFromConfig.push({ + name: fullToolName, + description: `Tool: ${toolName}`, + server: serverName, + }); + }); + } + ); + } + + // Update allKnownMcpTools with tools from configuration + setAllKnownMcpTools(prevKnown => { + const knownToolNames = new Set(prevKnown.map(t => t.name)); + const newToolsFromConfig = toolsFromConfig.filter(t => !knownToolNames.has(t.name)); + return [...prevKnown, ...newToolsFromConfig]; + }); + + // Auto-expand servers that have tools in configuration + const serversWithTools = new Set(); + toolsFromConfig.forEach(tool => { + if (tool.server) { + serversWithTools.add(tool.server); + } + }); + setExpandedServers(serversWithTools); + } catch (error) { + setMcpServers({}); + } finally { + setIsLoadingMcp(false); + } + }; + + const handleToggleRegularTool = (toolName: string) => { + setLocalEnabledTools(prevTools => { + if (prevTools.includes(toolName)) { + return prevTools.filter(tool => tool !== toolName); + } else { + return [...prevTools, toolName]; + } + }); + }; + + const handleToggleMcpTool = (toolName: string) => { + const { serverName, toolName: actualToolName } = parseMcpToolName(toolName); + const currentlyEnabled = isMcpToolEnabled(toolName); + + setMcpToolsConfig((prevConfig: any) => { + const newConfig = { ...prevConfig }; + if (!newConfig[serverName]) { + newConfig[serverName] = {}; + } + if (!newConfig[serverName][actualToolName]) { + newConfig[serverName][actualToolName] = { + enabled: true, + usageCount: 0, + }; + } + newConfig[serverName][actualToolName].enabled = !currentlyEnabled; + return newConfig; + }); + }; + + const handleToggleServer = (serverName: string) => { + const serverTools = allKnownMcpTools.filter(tool => tool.server === serverName); + + // Check if all tools from this server are currently enabled + const allEnabled = serverTools.every(tool => isMcpToolEnabled(tool.name)); + + // Update MCP configuration for all tools in this server + setMcpToolsConfig((prevConfig: any) => { + const newConfig = { ...prevConfig }; + if (!newConfig[serverName]) { + newConfig[serverName] = {}; + } + + serverTools.forEach(tool => { + const { toolName: actualToolName } = parseMcpToolName(tool.name); + if (!newConfig[serverName][actualToolName]) { + newConfig[serverName][actualToolName] = { + enabled: true, + usageCount: 0, + }; + } + newConfig[serverName][actualToolName].enabled = !allEnabled; + }); + + return newConfig; + }); + }; + + const isServerEnabled = (serverName: string) => { + const serverTools = allKnownMcpTools.filter(tool => tool.server === serverName); + return serverTools.length > 0 && serverTools.every(tool => isMcpToolEnabled(tool.name)); + }; + + // Filter tools based on search query - use allKnownMcpTools to show all tools (including disabled ones) + const filteredMcpTools = allKnownMcpTools.filter( + tool => + tool.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (tool.description && tool.description.toLowerCase().includes(searchQuery.toLowerCase())) + ); + + // Group tools by server + const groupedToolsByServer = filteredMcpTools.reduce((acc, tool) => { + const serverName = tool.server || 'Unknown Server'; + if (!acc[serverName]) { + acc[serverName] = []; + } + acc[serverName].push(tool); + return acc; + }, {} as { [key: string]: MCPTool[] }); + + const handleToggleServerExpansion = (serverName: string) => { + const newExpanded = new Set(expandedServers); + if (newExpanded.has(serverName)) { + newExpanded.delete(serverName); + } else { + newExpanded.add(serverName); + } + setExpandedServers(newExpanded); + }; + + const handleSave = async () => { + try { + // Save regular tools configuration + onToolsChange(localEnabledTools); + + // Save MCP tools configuration if it has changed + const mcpConfigChanged = JSON.stringify(mcpToolsConfig) !== JSON.stringify(originalMcpConfig); + + if (mcpConfigChanged) { + const mcpClient = new ElectronMCPClient(); + if (mcpClient.isAvailable()) { + await mcpClient.updateToolsConfig(mcpToolsConfig); + } + } + + onClose(); + } catch (error) { + console.error('Error saving configuration:', error); + // Still close the dialog even if there was an error + onClose(); + } + }; + + const handleCancel = () => { + // Restore original state for both regular and MCP tools + setLocalEnabledTools(enabledTools); + setMcpToolsConfig(JSON.parse(JSON.stringify(originalMcpConfig))); + onClose(); + }; + + const getToolIcon = (toolName: string, toolType?: string) => { + if (toolType === 'mcp' || toolName.includes('mcp')) { + return 'mdi:connection'; + } + if (toolName.includes('kubernetes') || toolName.includes('k8s')) { + return 'mdi:kubernetes'; + } + return 'mdi:tool'; + }; + + const renderMcpToolList = () => ( + <> + + + MCP Tools + + + These are Model Context Protocol tools that provide additional capabilities to the + assistant. + + + {/* Search Bar */} + setSearchQuery(e.target.value)} + size="small" + sx={{ mt: 2 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + {isLoadingMcp ? ( + + + + Loading MCP tools... + + + ) : ( + <> + {Object.entries(groupedToolsByServer).map(([serverName, tools]) => ( + handleToggleServerExpansion(serverName)} + sx={{ mb: 1 }} + > + }> + + + + + {serverName} ({tools.length} tools) + + + + { + e.stopPropagation(); + handleToggleServer(serverName); + }} + onClick={e => e.stopPropagation()} + /> + + + + + + {tools.map((tool, index) => ( + + + + + + + + {tool.name} + + + } + secondary={tool.description} + /> + + + handleToggleMcpTool(tool.name)} + checked={isMcpToolEnabled(tool.name)} + /> + + + {index < tools.length - 1 && } + + ))} + + + + ))} + + {filteredMcpTools.length === 0 && allKnownMcpTools.length > 0 && ( + + No tools match your search query. + + )} + + {allKnownMcpTools.length === 0 && ( + + No MCP tools available. Connect to MCP servers to see available tools. + + )} + + )} + + ); // Get tool categories + const kubernetesTools = AVAILABLE_TOOLS.filter(ToolClass => { + const tempTool = new ToolClass(); + return tempTool.config.name.includes('kubernetes') || tempTool.config.name.includes('k8s'); + }); + + const otherTools = AVAILABLE_TOOLS.filter(ToolClass => { + const tempTool = new ToolClass(); + return !tempTool.config.name.includes('kubernetes') && !tempTool.config.name.includes('k8s'); + }); + + const renderToolList = (tools: any[], title: string, subtitle?: string) => ( + <> + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + + + {tools.map(ToolClass => { + const tempTool = new ToolClass(); + const toolName = tempTool.config.name; + const isEnabled = localEnabledTools.includes(toolName); + + return ( + + + + + + + {tempTool.config.displayName || toolName} + + {tempTool.config.category && ( + + )} + + } + secondary={tempTool.config.shortDescription || tempTool.config.description} + /> + + handleToggleRegularTool(toolName)} + checked={isEnabled} + color="primary" + /> + + + ); + })} + + + ); + + return ( + + + + + Manage Tools + + + + + + + Enable or disable tools that the AI can use. Changes will take effect immediately and will + be saved to your settings. + + + {/* Kubernetes Tools */} + {kubernetesTools.length > 0 && ( + <> + {renderToolList( + kubernetesTools, + 'Kubernetes Tools', + 'Tools for interacting with Kubernetes clusters' + )} + + + )} + + {/* Other Tools */} + {otherTools.length > 0 && + renderToolList( + otherTools, + 'System Tools', + 'General purpose tools for various operations' + )} + + {/* MCP Tools */} + {renderMcpToolList()} + + {(kubernetesTools.length > 0 || otherTools.length > 0) && } + + + + + + + + ); +}; + +export default ToolsDialog; diff --git a/ai-assistant/src/components/assistant/index.ts b/ai-assistant/src/components/assistant/index.ts index 6a238feba..1761ad3c8 100644 --- a/ai-assistant/src/components/assistant/index.ts +++ b/ai-assistant/src/components/assistant/index.ts @@ -3,3 +3,4 @@ export { default as AIChatContent } from './AIChatContent'; export { AIInputSection } from './AIInputSection'; export { PromptSuggestions } from './PromptSuggestions'; export { default as TestModeInput } from './TestModeInput'; +export { ToolsDialog } from './ToolsDialog'; diff --git a/ai-assistant/src/components/chat/MCPFormattedMessage.tsx b/ai-assistant/src/components/chat/MCPFormattedMessage.tsx new file mode 100644 index 000000000..b8d4b6e4e --- /dev/null +++ b/ai-assistant/src/components/chat/MCPFormattedMessage.tsx @@ -0,0 +1,256 @@ +import { Icon } from '@iconify/react'; +import { Alert, Box, Paper, Typography } from '@mui/material'; +import React, { useCallback } from 'react'; +import { FormattedMCPOutput } from '../../langchain/formatters/MCPOutputFormatter'; +import MCPOutputDisplay from '../mcpOutput/MCPOutputDisplay'; + +interface MCPFormattedMessageProps { + content: string; + isAssistant?: boolean; + onRetryTool?: (toolName: string, args: Record) => void; +} + +interface ParsedMCPContent { + formatted: boolean; + mcpOutput: FormattedMCPOutput; + raw: string; + isError?: boolean; +} + +const MCPFormattedMessage: React.FC = ({ + content, + isAssistant = true, + onRetryTool, +}) => { + // Try to parse the content as formatted MCP output + const parseContent = (): ParsedMCPContent | null => { + try { + const parsed = JSON.parse(content); + if (parsed.formatted && parsed.mcpOutput) { + return parsed as ParsedMCPContent; + } + } catch (error) { + // Not formatted MCP content + } + return null; + }; + + const mcpContent = parseContent(); + + // If not formatted MCP content, return null (let other components handle it) + if (!mcpContent) { + return null; + } + + const handleExport = (format: 'json' | 'csv' | 'txt') => { + const { mcpOutput } = mcpContent; + let exportData: string; + let filename: string; + let mimeType: string; + + switch (format) { + case 'json': + exportData = JSON.stringify(mcpOutput.data, null, 2); + filename = `${mcpOutput.metadata?.toolName || 'mcp-output'}.json`; + mimeType = 'application/json'; + break; + case 'csv': + if (mcpOutput.type === 'table' && mcpOutput.data.headers && mcpOutput.data.rows) { + const csvContent = [ + mcpOutput.data.headers.join(','), + ...mcpOutput.data.rows.map((row: any[]) => + row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',') + ), + ].join('\n'); + exportData = csvContent; + } else { + exportData = JSON.stringify(mcpOutput.data, null, 2); + } + filename = `${mcpOutput.metadata?.toolName || 'mcp-output'}.csv`; + mimeType = 'text/csv'; + break; + case 'txt': + exportData = mcpContent.raw; + filename = `${mcpOutput.metadata?.toolName || 'mcp-output'}.txt`; + mimeType = 'text/plain'; + break; + default: + return; + } + + // Create and download the file + const blob = new Blob([exportData], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); + }; + + const handleRetry = useCallback(() => { + if (!onRetryTool) { + return; + } + + try { + // Parse the content to extract originalArgs and tool information + const parsedContent = JSON.parse(content); + + // Look for originalArgs in the parsed content + const originalArgs = parsedContent.originalArgs; + + if (!originalArgs) { + console.error('No originalArgs found in content for retry'); + return; + } + + // Extract tool name from the formatted output or use a fallback + let toolName = ''; + if (parsedContent.mcpOutput?.metadata?.toolName) { + toolName = parsedContent.mcpOutput.metadata.toolName; + } else if (parsedContent.toolName) { + toolName = parsedContent.toolName; + } else { + console.error('No tool name found in content for retry'); + return; + } + + onRetryTool(toolName, originalArgs); + } catch (error) { + console.error('Failed to parse content for retry:', error); + } + }, [content, onRetryTool]); + + return ( + + {isAssistant && ( + + + + {mcpContent.isError || mcpContent.mcpOutput.type === 'error' + ? 'Tool Error - AI Analysis' + : 'AI-Formatted Tool Output'} + + {(mcpContent.isError || mcpContent.mcpOutput.type === 'error') && ( + + Tool Execution Failed + + )} + + )} + + + + {/* Show processing info if available and not an error */} + {mcpContent.mcpOutput.metadata && + !(mcpContent.isError || mcpContent.mcpOutput.type === 'error') && ( + + + + Processed by AI in {mcpContent.mcpOutput.metadata.processingTime}ms + {mcpContent.mcpOutput.insights && mcpContent.mcpOutput.insights.length > 0 && ( + <> • {mcpContent.mcpOutput.insights.length} insights generated + )} + {mcpContent.mcpOutput.actionable_items && + mcpContent.mcpOutput.actionable_items.length > 0 && ( + <> • {mcpContent.mcpOutput.actionable_items.length} action items + )} + + + )} + + {/* Show error-specific info */} + {(mcpContent.isError || mcpContent.mcpOutput.type === 'error') && + mcpContent.mcpOutput.metadata && ( + + + + Error analyzed by AI in {mcpContent.mcpOutput.metadata.processingTime}ms • Tool:{' '} + {mcpContent.mcpOutput.metadata.toolName} + + + )} + + ); +}; + +// Helper component to detect and render MCP content in existing messages +export const withMCPFormatting =

( + Component: React.ComponentType

+) => { + return (props: P & { content: string }) => { + const mcpFormatted = ; + + // If content is formatted MCP output, show formatted version + try { + const parsed = JSON.parse(props.content); + if (parsed.formatted && parsed.mcpOutput) { + return mcpFormatted; + } + } catch { + // Not JSON or not formatted MCP content, use original component + } + + return ; + }; +}; + +export default MCPFormattedMessage; diff --git a/ai-assistant/src/components/common/InlineToolConfirmation.tsx b/ai-assistant/src/components/common/InlineToolConfirmation.tsx new file mode 100644 index 000000000..09cfeb3fe --- /dev/null +++ b/ai-assistant/src/components/common/InlineToolConfirmation.tsx @@ -0,0 +1,672 @@ +import { Icon } from '@iconify/react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Collapse, + FormControl, + FormControlLabel, + FormHelperText, + IconButton, + InputLabel, + ListItem, + ListItemText, + MenuItem, + Select, + Switch, + TextField, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import { + MCPArgumentProcessor, + type ProcessedArguments, + type UserContext, +} from '../mcpOutput/MCPArgumentProcessor'; + +interface ToolCall { + id: string; + name: string; + description?: string; + arguments: Record; + type: 'mcp' | 'regular'; +} + +interface InlineToolConfirmationProps { + toolCalls: ToolCall[]; + onApprove: (approvedToolIds: string[]) => void; + onDeny: () => void; + loading?: boolean; + compact?: boolean; + userContext?: UserContext; +} + +const InlineToolConfirmation: React.FC = ({ + toolCalls, + onApprove, + onDeny, + loading = false, + userContext, +}) => { + const theme = useTheme(); + const [selectedToolIds] = useState(toolCalls.map(tool => tool.id)); + const [expandedTools, setExpandedTools] = useState>(new Set()); // Track which tools are expanded + + // State to track processed arguments for each tool + const [processedArguments, setProcessedArguments] = useState>( + {} + ); + const [editedArguments, setEditedArguments] = useState>>({}); + const [argumentsInitialized, setArgumentsInitialized] = useState(false); + + // State to track deny action + const [isDenying, setIsDenying] = useState(false); + const [isApproving, setIsApproving] = useState(false); + + // Initialize arguments with intelligent processing + useEffect(() => { + const initializeArguments = async () => { + const processed: Record = {}; + const edited: Record> = {}; + + for (const tool of toolCalls) { + if (tool.type === 'mcp') { + // Process MCP tool arguments with intelligent defaults + const processedArgs = await MCPArgumentProcessor.processArguments( + tool.name, + tool.arguments, + userContext + ); + processed[tool.id] = processedArgs; + edited[tool.id] = { ...processedArgs.processed }; + } else { + // For regular tools, use arguments as-is + edited[tool.id] = { ...tool.arguments }; + } + } + + setProcessedArguments(processed); + setEditedArguments(edited); + setArgumentsInitialized(true); + }; + + if (!argumentsInitialized) { + initializeArguments(); + } + }, [toolCalls, argumentsInitialized]); + + const handleApprove = async () => { + if (isApproving || isDenying) return; // Prevent double-clicks + + if (selectedToolIds.length === 0) { + handleDeny(); + return; + } + + setIsApproving(true); + + try { + // Update the original toolCalls with edited arguments + toolCalls.forEach(tool => { + if (selectedToolIds.includes(tool.id) && editedArguments[tool.id]) { + const edited = editedArguments[tool.id]; + + if (tool.type === 'mcp') { + // For MCP tools, clean up arguments before sending + const processedArgs = processedArguments[tool.id]; + if (processedArgs?.schema) { + // Use the argument processor to clean up the final arguments + const cleaned = MCPArgumentProcessor.cleanupArguments(edited, processedArgs.schema); + tool.arguments = cleaned; + } else { + tool.arguments = edited; + } + } else { + // For regular tools, use edited arguments as-is + tool.arguments = edited; + } + } + }); + + onApprove(selectedToolIds); + } catch (error) { + console.error('Error during tool approval:', error); + setIsApproving(false); + } + }; + + const handleDeny = async () => { + if (isDenying || isApproving) return; // Prevent double-clicks and conflicts + + setIsDenying(true); + + try { + onDeny(); + } catch (error) { + console.error('Error during tool denial:', error); + setIsDenying(false); + } + }; + + const getToolIcon = (toolName: string, toolType: 'mcp' | 'regular') => { + if (toolType === 'mcp') { + return 'mdi:connection'; // Use connection icon for MCP tools + } + + if (toolName.includes('kubernetes') || toolName.includes('k8s')) { + return 'mdi:kubernetes'; + } + return 'mdi:tool'; + }; + + const toggleToolExpansion = (toolId: string) => { + setExpandedTools(prev => { + const newSet = new Set(prev); + if (newSet.has(toolId)) { + newSet.delete(toolId); + } else { + newSet.add(toolId); + } + return newSet; + }); + }; + + const renderArgumentField = ( + toolId: string, + fieldName: string, + fieldValue: any, + fieldSchema: any, + _isRequired: boolean, + hasError: boolean + ) => { + const fieldType = fieldSchema?.type || 'string'; + const fieldDescription = fieldSchema?.description; + const enumValues = fieldSchema?.enum; + + const currentValue = editedArguments[toolId]?.[fieldName] ?? fieldValue; + + const handleFieldChange = (newValue: any) => { + setEditedArguments(prev => ({ + ...prev, + [toolId]: { + ...prev[toolId], + [fieldName]: newValue, + }, + })); + }; + + // Render different input types based on schema + if (fieldType === 'boolean') { + return ( + handleFieldChange(e.target.checked)} + size="small" + /> + } + label={fieldName} + sx={{ ml: 0 }} + /> + ); + } + + if (enumValues && Array.isArray(enumValues)) { + return ( + + {fieldName} + + {fieldDescription && {fieldDescription}} + + ); + } + + if (fieldType === 'number' || fieldType === 'integer') { + return ( + + handleFieldChange( + fieldType === 'integer' + ? parseInt(e.target.value) || 0 + : parseFloat(e.target.value) || 0 + ) + } + helperText={fieldDescription} + error={hasError} + sx={{ minWidth: 200 }} + inputProps={{ + min: fieldSchema?.minimum, + max: fieldSchema?.maximum, + step: fieldType === 'integer' ? 1 : 'any', + }} + /> + ); + } + + // Default to text field for strings and other types + const isMultiline = + fieldType === 'object' || + fieldType === 'array' || + (typeof currentValue === 'string' && currentValue.length > 50); + + return ( + { + let newValue = e.target.value; + + // Try to parse as JSON for object/array fields + if (fieldType === 'object' || fieldType === 'array') { + try { + newValue = JSON.parse(e.target.value); + } catch { + // Keep as string if not valid JSON + } + } + + handleFieldChange(newValue); + }} + multiline={isMultiline} + rows={isMultiline ? 3 : 1} + helperText={fieldDescription} + error={hasError} + sx={{ minWidth: 300 }} + placeholder={fieldSchema?.example || `Enter ${fieldName}...`} + /> + ); + }; + + const renderArgumentsForTool = (tool: ToolCall, toolId: string) => { + if (tool.type !== 'mcp' || !argumentsInitialized) { + // Render simple view for regular tools or while loading + return Object.entries(tool.arguments).map(([key, value]) => ( + + + {key}: + + } + secondary={ + + {typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)} + + } + /> + + )); + } + + const processedArgs = processedArguments[toolId]; + if (!processedArgs) return null; + + const { schema, errors } = processedArgs; + const properties = schema?.inputSchema?.properties || {}; + const required = schema?.inputSchema?.required || []; + const currentArgs = editedArguments[toolId] || {}; + + // Show argument fields based on schema + const fields = Object.entries(properties).map(([fieldName, fieldSchema]) => { + const isRequired = required.includes(fieldName); + const hasError = errors.some(error => error.includes(fieldName)); + const fieldValue = currentArgs[fieldName]; + + return ( + + + + {fieldName} + + {isRequired && } + {!isRequired && } + + {/* Show intelligent fill indicator */} + {processedArgs.intelligentFills[fieldName] && ( + + } + /> + + )} + + {fieldSchema.description && ( + + + + + + )} + + + {/* Show intelligent fill details */} + {processedArgs.intelligentFills[fieldName] && ( + } + > + + AI Analysis: {processedArgs.intelligentFills[fieldName].reason} + {processedArgs.intelligentFills[fieldName].confidence < 0.8 && ( + <> + {' '} + • Please verify this value + + )} + + + )} + + {renderArgumentField(toolId, fieldName, fieldValue, fieldSchema, isRequired, hasError)} + + ); + }); + + // Show validation errors + if (errors.length > 0) { + fields.push( + + + Validation Issues: + + {errors.map((error, index) => ( + + • {error} + + ))} + + ); + } + + return fields; + }; + + const mcpTools = toolCalls.filter(tool => tool.type === 'mcp'); + + // Check if any action is in progress + const isActionInProgress = loading || isApproving || isDenying; + + if (loading || isApproving) { + return ( + + + + + {isApproving ? 'Approving and executing tools...' : 'Executing approved tools...'} + + + + ); + } + + // Show denying state + if (isDenying) { + return ( + + + + + Denying tool execution... + + + + ); + } + + return ( + + + {/* Header */} + + + + Tool Execution Required + + 1 ? 's' : ''}`} + size="small" + variant="outlined" + color="primary" + /> + + + {/* Summary */} + + The assistant wants to execute {toolCalls.length} tool{toolCalls.length > 1 ? 's' : ''}: + + + {/* Collapsible tool list */} + + {toolCalls.map((tool, index) => ( + + {/* Tool header - always visible and clickable */} + toggleToolExpansion(tool.id)} + > + + + + + + {tool.name} + + {tool.type === 'mcp' && ( + + )} + {tool.description && ( + + {tool.description.length > 50 + ? `${tool.description.substring(0, 50)}...` + : tool.description} + + )} + + + {/* Tool details - collapsible */} + + + {/* Tool description */} + {tool.description && ( + + + Description + + + {tool.description} + + + )} + + {/* Tool arguments */} + {(Object.keys(tool.arguments).length > 0 || tool.type === 'mcp') && ( + + + Arguments {tool.type === 'mcp' ? '(editable)' : ''} + + {tool.type === 'mcp' ? ( + {renderArgumentsForTool(tool, tool.id)} + ) : ( + + {Object.entries(tool.arguments).map(([key, value]) => ( + + + {key}:{' '} + + + {typeof value === 'object' + ? JSON.stringify(value, null, 2) + : String(value)} + + + ))} + + )} + + )} + + + + ))} + + + {/* Contextual info */} + + + {mcpTools.length > 0 + ? `${ + mcpTools.length > 1 ? 'These MCP tools have' : 'This MCP tool has' + } been configured with AI-analyzed arguments from your request. Click on any tool above to view details and edit arguments.` + : 'These tools will access your Kubernetes cluster and other systems. Click on any tool above to view details.'} + + + + {/* Loading state for argument processing */} + {!argumentsInitialized && mcpTools.length > 0 && ( + + + + Processing intelligent argument suggestions... + + + )} + + {/* Action buttons */} + + + + + + + ); +}; + +export default InlineToolConfirmation; diff --git a/ai-assistant/src/components/common/ToolApprovalDialog.tsx b/ai-assistant/src/components/common/ToolApprovalDialog.tsx new file mode 100644 index 000000000..b1538bcf9 --- /dev/null +++ b/ai-assistant/src/components/common/ToolApprovalDialog.tsx @@ -0,0 +1,296 @@ +import { Icon } from '@iconify/react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, + Box, + Button, + Checkbox, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + FormGroup, + IconButton, + List, + ListItem, + ListItemText, + Typography, +} from '@mui/material'; +import React, { useState } from 'react'; + +interface ToolCall { + id: string; + name: string; + description?: string; + arguments: Record; + type: 'mcp' | 'regular'; +} + +interface ToolApprovalDialogProps { + open: boolean; + toolCalls: ToolCall[]; + onApprove: (approvedToolIds: string[]) => void; + onDeny: () => void; + onClose: () => void; + loading?: boolean; +} + +const ToolApprovalDialog: React.FC = ({ + open, + toolCalls, + onApprove, + onDeny, + onClose, + loading = false, +}) => { + const [selectedToolIds, setSelectedToolIds] = useState(toolCalls.map(tool => tool.id)); + const [rememberChoice, setRememberChoice] = useState(false); + + // Reset selection when toolCalls change + React.useEffect(() => { + if (open) { + setSelectedToolIds(toolCalls.map(tool => tool.id)); + setRememberChoice(false); + } + }, [toolCalls, open]); + + const handleSelectAll = () => { + setSelectedToolIds(toolCalls.map(tool => tool.id)); + }; + + const handleDeselectAll = () => { + setSelectedToolIds([]); + }; + + const handleToolToggle = (toolId: string) => { + setSelectedToolIds(prev => + prev.includes(toolId) ? prev.filter(id => id !== toolId) : [...prev, toolId] + ); + }; + + const handleApprove = () => { + onApprove(selectedToolIds); + }; + + const mcpTools = toolCalls.filter(tool => tool.type === 'mcp'); + const regularTools = toolCalls.filter(tool => tool.type === 'regular'); + + const getToolIcon = (toolName: string, toolType: 'mcp' | 'regular') => { + if (toolType === 'mcp') { + return 'mdi:docker'; // Inspektor Gadget runs in Docker + } + + // Regular Kubernetes tools + if (toolName.includes('kubernetes') || toolName.includes('k8s')) { + return 'mdi:kubernetes'; + } + return 'mdi:tool'; + }; + + const formatArguments = (args: Record) => { + return Object.entries(args) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => ( + + + + )); + }; + + const renderToolSection = (tools: ToolCall[], title: string, color: 'primary' | 'secondary') => { + if (tools.length === 0) return null; + + return ( + + + {title} + + + {tools.map(tool => ( + + } + sx={{ + '& .MuiAccordionSummary-content': { + alignItems: 'center', + }, + }} + > + handleToolToggle(tool.id)} + onClick={e => e.stopPropagation()} + /> + } + label="" + sx={{ mr: 1 }} + onClick={e => e.stopPropagation()} + /> + + + + {tool.name} + + {tool.description && ( + + {tool.description} + + )} + + {tool.type === 'mcp' && ( + + )} + + + + Arguments to be passed: + + {formatArguments(tool.arguments)} + + + ))} + + ); + }; + + return ( +

+ + + + Tool Execution Approval Required + + {!loading && ( + + + + )} + + + + + The AI Assistant wants to execute {toolCalls.length} tool{toolCalls.length > 1 ? 's' : ''} + to complete your request. Please review and approve the tools you want to allow. + + + {mcpTools.length > 0 && ( + + + MCP Tools (Inspektor Gadget) + + + These tools will execute debugging commands in your Kubernetes clusters through + Inspektor Gadget containers. They provide deep system-level insights but require + elevated permissions. + + + )} + + + + + + + {renderToolSection(regularTools, 'Kubernetes Tools', 'primary')} + {renderToolSection(mcpTools, 'MCP Tools (Inspektor Gadget)', 'secondary')} + + + setRememberChoice(e.target.checked)} + /> + } + label={ + + Remember my choice for this session (auto-approve similar tools) + + } + /> + + + + + + + {selectedToolIds.length} of {toolCalls.length} tools selected + + + + + + + + + ); +}; + +export default ToolApprovalDialog; diff --git a/ai-assistant/src/components/common/index.ts b/ai-assistant/src/components/common/index.ts index 166c50f08..f2cbcc0d2 100644 --- a/ai-assistant/src/components/common/index.ts +++ b/ai-assistant/src/components/common/index.ts @@ -1,4 +1,5 @@ export { default as ApiConfirmationDialog } from './ApiConfirmationDialog'; +export { default as InlineToolConfirmation } from './InlineToolConfirmation'; export { default as LogsButton } from './LogsButton'; export { default as LogsDialog } from './LogsDialog'; export { default as YamlDisplay } from './YamlDisplay'; diff --git a/ai-assistant/src/components/mcpOutput/MCPArgumentProcessor.ts b/ai-assistant/src/components/mcpOutput/MCPArgumentProcessor.ts new file mode 100644 index 000000000..168be4a5c --- /dev/null +++ b/ai-assistant/src/components/mcpOutput/MCPArgumentProcessor.ts @@ -0,0 +1,495 @@ +import { ElectronMCPClient } from '../../ai/mcp/electron-client'; + +export interface MCPToolSchema { + name: string; + description?: string; + inputSchema?: { + type: string; + properties?: Record; + required?: string[]; + }; +} + +export interface UserContext { + userMessage?: string; + conversationHistory?: Array<{ role: string; content: string }>; + kubernetesContext?: { + selectedClusters?: string[]; + namespace?: string; + currentResource?: any; + }; + lastToolResults?: Record; + timeContext?: Date; +} + +export interface ProcessedArguments { + original: Record; + processed: Record; + schema: MCPToolSchema | null; + suggestions: Record; + errors: string[]; + intelligentFills: Record; +} + +export class MCPArgumentProcessor { + private static toolSchemas: Map = new Map(); + private static schemasLoaded = false; + + /** + * Load MCP tool schemas + */ + static async loadSchemas(): Promise { + if (this.schemasLoaded) return; + + try { + const mcpClient = new ElectronMCPClient(); + const toolsConfigResponse = await mcpClient.getToolsConfig(); + + if (toolsConfigResponse && toolsConfigResponse.config) { + // Parse the new structure: { "serverName": { "toolName": { enabled, inputSchema, ... } } } + Object.entries(toolsConfigResponse.config).forEach( + ([serverName, serverTools]: [string, any]) => { + Object.entries(serverTools).forEach(([toolName, toolConfig]: [string, any]) => { + const fullToolName = `${serverName}__${toolName}`; + this.toolSchemas.set(fullToolName, { + name: fullToolName, + description: toolConfig.description, + inputSchema: toolConfig.inputSchema, + }); + }); + } + ); + this.schemasLoaded = true; + } + } catch (error) { + console.error('Failed to load MCP tool schemas:', error); + } + } + + /** + * Validate and process arguments for MCP tools (simplified version) + * Main argument generation is now handled by AI in LangChainManager + */ + static async processArguments( + toolName: string, + aiProcessedArgs: Record = {}, + userContext?: UserContext + ): Promise { + await this.loadSchemas(); + console.log('tools'); + const schema = this.toolSchemas.get(toolName); + const errors: string[] = []; + const processed = { ...aiProcessedArgs }; + const intelligentFills: Record = {}; + + // Check if arguments were enhanced by LLM + const llmEnhanced = aiProcessedArgs._llmEnhanced; + if (llmEnhanced) { + // Remove metadata before processing + delete processed._llmEnhanced; + + // Mark LLM-enhanced fields in intelligentFills + for (const fieldName of llmEnhanced.enhancedFields || []) { + if (fieldName in processed) { + intelligentFills[fieldName] = { + value: processed[fieldName], + reason: `AI-enhanced based on user request analysis`, + confidence: 0.9, // High confidence for LLM-enhanced fields + }; + } + } + } + + if (!schema) { + errors.push(`No schema found for tool: ${toolName}`); + return { + original: aiProcessedArgs, + processed, + schema: null, + suggestions: {}, + errors, + intelligentFills, + }; + } + + // Ensure required fields have appropriate values (even if empty objects/arrays) + if (schema.inputSchema?.required) { + for (const requiredField of schema.inputSchema.required) { + if (!(requiredField in processed) || processed[requiredField] === undefined) { + const fieldSchema = schema.inputSchema.properties?.[requiredField]; + if (fieldSchema) { + // Provide appropriate empty value based on type + processed[requiredField] = this.getEmptyValueForRequiredField(fieldSchema); + + // Only mark as intelligent fill if not already enhanced by LLM + if (!intelligentFills[requiredField]) { + intelligentFills[requiredField] = { + value: processed[requiredField], + reason: `Required field provided with empty ${fieldSchema.type}`, + confidence: 0.8, + }; + } + } + } + } + } + + // Generate suggestions for UI display + const suggestions = this.generateIntelligentSuggestions(schema, userContext); + + // Validate processed arguments + const validationErrors = this.validateArgumentsWithEmptyObjectSupport(processed, schema); + errors.push(...validationErrors); + + return { + original: aiProcessedArgs, + processed, + schema, + suggestions, + errors, + intelligentFills, + }; + } + + /** + * Get appropriate empty value for required field + */ + private static getEmptyValueForRequiredField(fieldSchema: any): any { + const type = fieldSchema.type; + + switch (type) { + case 'object': + return {}; + case 'array': + return []; + case 'string': + return fieldSchema.default || ''; + case 'number': + case 'integer': + return fieldSchema.default || fieldSchema.minimum || 0; + case 'boolean': + return fieldSchema.default !== undefined ? fieldSchema.default : false; + default: + return null; + } + } + + /** + * Generate intelligent suggestions based on tool schema and context + */ + private static generateIntelligentSuggestions( + schema: MCPToolSchema, + userContext?: UserContext + ): Record { + const suggestions: Record = {}; + + if (!schema.inputSchema?.properties) return suggestions; + + const properties = schema.inputSchema.properties; + + // Generate suggestions based on property types and names + for (const [key, propertySchema] of Object.entries(properties)) { + const suggestion = this.generatePropertySuggestion(key, propertySchema, userContext); + if (suggestion !== undefined) { + suggestions[key] = suggestion; + } + } + + return suggestions; + } + + /** + * Generate suggestion for a specific property + */ + private static generatePropertySuggestion( + propertyName: string, + propertySchema: any, + userContext?: UserContext + ): any { + const type = propertySchema.type; + const description = propertySchema.description?.toLowerCase() || ''; + + // Check context data for matching values + if (userContext) { + // Check kubernetes context + if (userContext.kubernetesContext) { + const k8sContext = userContext.kubernetesContext; + if (propertyName.toLowerCase().includes('namespace') && k8sContext.namespace) { + return k8sContext.namespace; + } + if (propertyName.toLowerCase().includes('cluster') && k8sContext.selectedClusters?.length) { + return k8sContext.selectedClusters[0]; + } + } + + // Check last tool results + if (userContext.lastToolResults) { + const lowerPropName = propertyName.toLowerCase(); + for (const [contextKey, contextValue] of Object.entries(userContext.lastToolResults)) { + if ( + contextKey.toLowerCase().includes(lowerPropName) || + lowerPropName.includes(contextKey.toLowerCase()) + ) { + return contextValue; + } + } + } + } + + // Generate suggestions based on property name and type + switch (type) { + case 'string': + return this.suggestStringValue(propertyName, description, propertySchema); + case 'number': + case 'integer': + return this.suggestNumberValue(propertyName, description, propertySchema); + case 'boolean': + return this.suggestBooleanValue(propertyName); + case 'array': + return this.suggestArrayValue(); + case 'object': + return this.suggestObjectValue(); + default: + return undefined; + } + } + + /** + * Suggest string values based on property name and context + */ + private static suggestStringValue( + propertyName: string, + description: string, + schema: any + ): string | undefined { + const lowerName = propertyName.toLowerCase(); + const lowerDesc = description.toLowerCase(); + + // Check for enum values + if (schema.enum && Array.isArray(schema.enum)) { + return schema.enum[0]; // Default to first enum value + } + + // Path-related suggestions + if ( + lowerName.includes('path') || + lowerName.includes('directory') || + lowerName.includes('dir') + ) { + if (lowerDesc.includes('current') || lowerDesc.includes('working')) { + return '.'; + } + if (lowerDesc.includes('home')) { + return '~'; + } + return '/Users/ashughildiyal/Desktop'; // Safe default path + } + + // File-related suggestions + if (lowerName.includes('file') || lowerName.includes('filename')) { + return ''; + } + + // Name suggestions + if (lowerName.includes('name') && !lowerName.includes('filename')) { + return ''; + } + + // Command suggestions + if (lowerName.includes('command') || lowerName.includes('cmd')) { + return ''; + } + + // Query suggestions + if (lowerName.includes('query') || lowerName.includes('search')) { + return ''; + } + + return undefined; + } + + /** + * Suggest number values + */ + private static suggestNumberValue( + propertyName: string, + description: string, + schema: any + ): number | undefined { + const lowerName = propertyName.toLowerCase(); + + // Check for default in schema + if (schema.default !== undefined) { + return schema.default; + } + + // Check for minimum value + if (schema.minimum !== undefined) { + return schema.minimum; + } + + // Common number patterns + if (lowerName.includes('port')) { + return 8080; + } + if (lowerName.includes('timeout')) { + return 30; + } + if (lowerName.includes('limit') || lowerName.includes('max')) { + return 100; + } + if (lowerName.includes('count')) { + return 10; + } + + return undefined; + } + + /** + * Suggest boolean values + */ + private static suggestBooleanValue(propertyName: string): boolean | undefined { + const lowerName = propertyName.toLowerCase(); + // Note: description analysis could be added here for more intelligent suggestions + + // Common boolean patterns + if (lowerName.includes('enable') || lowerName.includes('enabled')) { + return false; // Conservative default + } + if (lowerName.includes('disable') || lowerName.includes('disabled')) { + return false; + } + if (lowerName.includes('recursive') || lowerName.includes('recurse')) { + return false; + } + if (lowerName.includes('force')) { + return false; + } + if (lowerName.includes('verbose')) { + return false; + } + + return undefined; + } + + /** + * Suggest array values + */ + private static suggestArrayValue(): any[] | undefined { + // Return empty array for optional arrays + return []; + } + + /** + * Suggest object values + */ + private static suggestObjectValue(): Record | undefined { + // Return empty object for optional objects + return {}; + } + + /** + * Clean up arguments by removing empty non-required fields + */ + static cleanupArguments(args: Record, schema: MCPToolSchema): Record { + if (!schema.inputSchema) return args; + + const cleaned: Record = {}; + const required = schema.inputSchema.required || []; + const properties = schema.inputSchema.properties || {}; + + for (const [key, value] of Object.entries(args)) { + // Skip LLM metadata + if (key === '_llmEnhanced') continue; + + const isRequired = required.includes(key); + const propertySchema = properties[key]; + const hasDefault = propertySchema?.default !== undefined; + + // Include if: + // 1. Required field + // 2. Has a non-empty value + // 3. Has a default value defined in schema + if (isRequired || this.hasActualValue(value) || hasDefault) { + cleaned[key] = value; + } + } + + return cleaned; + } + + /** + * Check if a value is meaningful (not empty/null/undefined) + */ + private static hasActualValue(value: any): boolean { + if (value === null || value === undefined || value === '') { + return false; + } + + if (Array.isArray(value)) { + return value.length > 0; + } + + if (typeof value === 'object') { + return Object.keys(value).length > 0; + } + + return true; + } + + /** + * Validate arguments against schema with support for empty objects/arrays + */ + private static validateArgumentsWithEmptyObjectSupport( + args: Record, + schema: MCPToolSchema + ): string[] { + const errors: string[] = []; + + if (!schema.inputSchema) return errors; + + const required = schema.inputSchema.required || []; + const properties = schema.inputSchema.properties || {}; + + // Check required fields (allow empty objects/arrays for required fields) + for (const requiredField of required) { + if ( + !(requiredField in args) || + args[requiredField] === undefined || + args[requiredField] === null + ) { + errors.push(`Required field '${requiredField}' is missing`); + } + } + + // Check type validation + for (const [key, value] of Object.entries(args)) { + if (properties[key] && value !== undefined && value !== null) { + const expectedType = properties[key].type; + const actualType = Array.isArray(value) ? 'array' : typeof value; + + if (expectedType && actualType !== expectedType) { + errors.push(`Field '${key}' should be ${expectedType}, got ${actualType}`); + } + } + } + + return errors; + } + + /** + * Get tool schema + */ + static async getToolSchema(toolName: string): Promise { + await this.loadSchemas(); + return this.toolSchemas.get(toolName) || null; + } + + /** + * Get all available tool names + */ + static async getAvailableTools(): Promise { + await this.loadSchemas(); + return Array.from(this.toolSchemas.keys()); + } +} diff --git a/ai-assistant/src/components/mcpOutput/MCPOutputDisplay.tsx b/ai-assistant/src/components/mcpOutput/MCPOutputDisplay.tsx new file mode 100644 index 000000000..7ca55a686 --- /dev/null +++ b/ai-assistant/src/components/mcpOutput/MCPOutputDisplay.tsx @@ -0,0 +1,1398 @@ +import { Icon } from '@iconify/react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + CardHeader, + Chip, + Collapse, + Divider, + Grid, + IconButton, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import remarkGfm from 'remark-gfm'; +import { usePromptWidth } from '../../contexts/PromptWidthContext'; +import { FormattedMCPOutput } from '../../langchain/formatters/MCPOutputFormatter'; + +interface MCPOutputDisplayProps { + output: FormattedMCPOutput; + onRetry?: () => void; + onExport?: (format: 'json' | 'csv' | 'txt') => void; + compact?: boolean; +} + +function calculateWidth(width: string): string { + if (!width) return '800px'; // fallback + + // Handle viewport width (vw) units + if (width.includes('vw')) { + const vwValue = parseFloat(width.replace('vw', '')); + const pixelWidth = Math.floor(window.innerWidth * (vwValue / 100)); + const adjustedWidth = Math.max(300, pixelWidth - 30); // Subtract 40px, minimum 300px + return `${adjustedWidth}px`; + } + + // Handle pixel (px) units + if (width.includes('px')) { + const pixelValue = parseInt(width.replace('px', ''), 10); + const adjustedWidth = Math.max(300, pixelValue - 30); // Subtract 40px, minimum 300px + return `${adjustedWidth}px`; + } + + // Handle numeric values (assume pixels) + const numericValue = parseInt(width, 10); + if (!isNaN(numericValue)) { + const adjustedWidth = Math.max(300, numericValue - 30); // Subtract 40px, minimum 300px + return `${adjustedWidth}px`; + } + + // Fallback for any other format + return '780px'; // 800px - 40px +} + +// Function to detect if content is markdown +function isMarkdownContent(data: any): boolean { + // Check if language is explicitly markdown + if (data.language === 'markdown' || data.language === 'md') { + return true; + } + + // Check for markdown patterns in content + if (typeof data.content === 'string') { + const content = data.content; + + // Common markdown patterns + const markdownPatterns = [ + /^#{1,6}\s+/m, // Headers (# ## ### etc.) + /^\s*[-*+]\s+/m, // Lists (- * +) + /^\s*\d+\.\s+/m, // Numbered lists (1. 2. etc.) + /\*\*[^*]+\*\*/, // Bold text + /\*[^*]+\*/, // Italic text + /`[^`]+`/, // Inline code + /```[\s\S]*?```/, // Code blocks + /\[.*?\]\(.*?\)/, // Links [text](url) + /^\s*>\s+/m, // Blockquotes + /^\s*\|.*\|/m, // Tables + ]; + + // Count how many patterns match + const matchCount = markdownPatterns.filter(pattern => pattern.test(content)).length; + + // If we find 2 or more markdown patterns, consider it markdown + return matchCount >= 2; + } + + return false; +} + +// Markdown Renderer Component +const MarkdownRenderer: React.FC<{ data: any; width: string; syntaxTheme: any }> = ({ + data, + width, + syntaxTheme, +}) => { + const theme = useTheme(); + const [showFullContent, setShowFullContent] = useState(false); + + // Check if content appears to be truncated + const isTruncated = + data.content && + (data.content.includes('[Content truncated for display') || + data.content.includes('[Output truncated...]') || + data.content.endsWith('...')); + + const displayContent = showFullContent ? data.fullContent || data.content : data.content; + + return ( + + {data.highlights && data.highlights.length > 0 && ( + + {data.highlights.map((highlight: string, index: number) => ( + + ))} + + )} + + {/* Truncation notification */} + {isTruncated && !showFullContent && ( + setShowFullContent(true)} + startIcon={} + > + Show Full Content + + } + > + + Content has been truncated for display. Click "Show Full Content" to view the complete + documentation. + + + )} + + + + {String(children).replace(/\n$/, '')} + + ); + } + + return ( + + {children} + + ); + }, + }} + > + {displayContent} + + + + {/* Collapse button for expanded content */} + {showFullContent && isTruncated && ( + + + + )} + + ); +}; + +const MCPOutputDisplay: React.FC = ({ + output, + onRetry, + onExport, + compact = false, +}) => { + const theme = useTheme(); + const { promptWidth } = usePromptWidth(); + const [expanded, setExpanded] = useState(!compact); + const [showRawData, setShowRawData] = useState(false); + const [showExportMenu, setShowExportMenu] = useState(false); + const [width, setWidth] = useState(calculateWidth(promptWidth?.toString() || '800px')); // Default width if not provided + const isDarkMode = theme.palette.mode === 'dark'; + const syntaxTheme = isDarkMode ? oneDark : oneLight; + + useEffect(() => { + const calculatedWidth = calculateWidth(promptWidth?.toString() || '800px'); + setWidth(calculatedWidth); + }, [promptWidth]); + // Get status color based on type or warnings + const getStatusColor = () => { + if (output.type === 'error') return 'error'; + if (output.warnings && output.warnings.length > 0) return 'warning'; + return 'primary'; + }; + + // Get icon based on output type + const getTypeIcon = () => { + switch (output.type) { + case 'table': + return 'mdi:table'; + case 'metrics': + return 'mdi:chart-line'; + case 'list': + return 'mdi:format-list-bulleted'; + case 'graph': + return 'mdi:chart-bar'; + case 'text': + return 'mdi:text'; + case 'error': + return 'mdi:alert-circle'; + default: + return 'mdi:file-document'; + } + }; + + const renderContent = () => { + switch (output.type) { + case 'table': + return ; + case 'metrics': + return ; + case 'list': + return ; + case 'graph': + return ; + case 'text': + return ; + case 'error': + return ; + default: + return ; + } + }; + + return ( + + + + } + title={ + + + {output.title} + + + {output.metadata && ( + + + + )} + + } + subheader={output.summary} + action={ + + {onExport && ( + + setShowExportMenu(!showExportMenu)} + sx={{ mr: 2 }} + > + + + + )} + + setShowRawData(!showRawData)} + color={showRawData ? 'primary' : 'default'} + sx={{ mr: 2 }} + > + + + + {compact && ( + setExpanded(!expanded)}> + + + )} + + } + sx={{ pb: 1 }} + /> + + + *': { + width: '100%', + minWidth: 0, + overflowWrap: 'break-word', + wordWrap: 'break-word', + }, + }} + > + {/* Warnings */} + {output.warnings && output.warnings.length > 0 && ( + + + Warnings: + + {output.warnings.map((warning, index) => ( + + • {warning} + + ))} + + )} + + {/* Main Content */} + {renderContent()} + + {/* Insights */} + {output.insights && output.insights.length > 0 && ( + + + + Key Insights: + + {output.insights.map((insight, index) => ( + + • {insight} + + ))} + + )} + + {/* Actionable Items */} + {output.actionable_items && output.actionable_items.length > 0 && ( + + + + Recommended Actions: + + {output.actionable_items.map((item, index) => ( + + • {item} + + ))} + + )} + + {/* Raw Data Collapse */} + + + + Raw Data: + + + {JSON.stringify(output.data, null, 2)} + + + + {/* Metadata */} + {output.metadata && ( + + + + + + )} + + + + + ); +}; + +// Table Display Component +const TableDisplay: React.FC<{ data: any; width: string }> = ({ data, width }) => { + const theme = useTheme(); + const [sortBy, setSortBy] = useState(data.sortBy || null); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); + + const handleSort = (column: string) => { + if (sortBy === column) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortBy(column); + setSortOrder('asc'); + } + }; + + const sortedRows = React.useMemo(() => { + if (!sortBy || !data.rows) return data.rows; + + const columnIndex = data.headers.indexOf(sortBy); + if (columnIndex === -1) return data.rows; + + return [...data.rows].sort((a, b) => { + const aVal = a[columnIndex]; + const bVal = b[columnIndex]; + + const comparison = String(aVal).localeCompare(String(bVal), undefined, { numeric: true }); + return sortOrder === 'asc' ? comparison : -comparison; + }); + }, [data.rows, data.headers, sortBy, sortOrder]); + + return ( + + + + + {data.headers.map((header: string, index: number) => ( + handleSort(header)} + sx={{ + cursor: 'pointer', + fontWeight: 'bold', + minWidth: '120px', + maxWidth: '300px', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + paddingX: 1, + '&:first-of-type': { + paddingLeft: 2, + }, + '&:last-of-type': { + paddingRight: 2, + }, + }} + title={header} // Show full header on hover + > + + {header} + {sortBy === header && ( + + )} + + + ))} + + + + {sortedRows?.map((row: any[], rowIndex: number) => ( + + {row.map((cell: any, cellIndex: number) => ( + + {typeof cell === 'object' ? JSON.stringify(cell) : String(cell)} + + ))} + + ))} + +
+
+ ); +}; + +// Metrics Display Component +const MetricsDisplay: React.FC<{ data: any; width: string }> = ({ data, width }) => { + const theme = useTheme(); + + const getStatusColor = (status: string) => { + switch (status) { + case 'error': + return 'error'; + case 'warning': + return 'warning'; + case 'info': + return 'info'; + default: + return 'primary'; + } + }; + + return ( + + {/* Primary Metrics */} + {data.primary && ( + + {data.primary.map((metric: any, index: number) => ( + + + + {metric.value} + + + {metric.label} + + + + ))} + + )} + + {/* Secondary Metrics */} + {data.secondary && ( + + {data.secondary.map((metric: any, index: number) => ( + + + + {metric.value} + + + {metric.label} + + + + ))} + + )} + + {/* Trends */} + {data.trends && ( + + + Trends: + + + {data.trends.map((trend: any, index: number) => ( + + + + ))} + + + )} + + ); +}; + +// List Display Component +const ListDisplay: React.FC<{ data: any; width: string }> = ({ data, width }) => { + const theme = useTheme(); + + const getStatusColor = (status: string) => { + switch (status) { + case 'error': + return theme.palette.error.main; + case 'warning': + return theme.palette.warning.main; + case 'info': + return theme.palette.info.main; + default: + return theme.palette.text.primary; + } + }; + + return ( + + {data.items?.map((item: any, index: number) => ( + + + {item.text} + + {item.metadata && ( + + {item.metadata} + + )} + + ))} + + ); +}; + +// Graph Display Component (placeholder for now) +const GraphDisplay: React.FC<{ data: any; width: string }> = ({ data, width }) => { + return ( + + + + Graph Visualization + + + {data.description || 'Chart visualization would appear here'} + + + Chart Type: {data.chartType} • {data.datasets?.length || 0} datasets + + + ); +}; + +// Text Display Component +const TextDisplay: React.FC<{ data: any; theme: any; width: string }> = ({ + data, + theme, + width, +}) => { + // Check if the content should be rendered as markdown + if (isMarkdownContent(data)) { + return ; + } + + // Default to syntax highlighting for non-markdown content + return ( + + {data.highlights && data.highlights.length > 0 && ( + + {data.highlights.map((highlight: string, index: number) => ( + + ))} + + )} + + + {data.content} + + + + ); +}; + +// Error Display Component +const ErrorDisplay: React.FC<{ data: any; onRetry?: () => void; width: string }> = ({ + data, + onRetry, + width, +}) => { + // Extract concise error message for common error types + const getDisplayMessage = (errorData: any) => { + const message = errorData.message || 'Tool Execution Error'; + + // Handle file not found errors specifically + if (message.includes('ENOENT') || message.includes('no such file')) { + return 'File Not Found Error'; + } + + // Handle schema mismatch errors + if (message.includes('schema mismatch')) { + return 'Tool Configuration Error'; + } + + return message; + }; + + return ( + + + + + + + {getDisplayMessage(data)} + + {data.details && ( + + + Error Details: + + + {data.details} + + + )} + + + + + {data.suggestions && data.suggestions.length > 0 && ( + + + + Troubleshooting Suggestions: + + + {data.suggestions.map((suggestion: string, index: number) => ( + + {suggestion} + + ))} + + + )} + + + {onRetry && ( + + )} + + + + ); +}; + +// Raw Display Component +const RawDisplay: React.FC<{ data: any; theme: any; width: string }> = ({ data, theme, width }) => { + return ( + + + {JSON.stringify(data, null, 2)} + + + ); +}; + +export default MCPOutputDisplay; diff --git a/ai-assistant/src/components/mcpOutput/index.ts b/ai-assistant/src/components/mcpOutput/index.ts new file mode 100644 index 000000000..c05de17eb --- /dev/null +++ b/ai-assistant/src/components/mcpOutput/index.ts @@ -0,0 +1,2 @@ +export { default as MCPOutputDisplay } from './MCPOutputDisplay'; +export type { FormattedMCPOutput } from '../../langchain/formatters/MCPOutputFormatter'; diff --git a/ai-assistant/src/components/settings/MCPConfigEditorDialog.tsx b/ai-assistant/src/components/settings/MCPConfigEditorDialog.tsx new file mode 100644 index 000000000..6825b4393 --- /dev/null +++ b/ai-assistant/src/components/settings/MCPConfigEditorDialog.tsx @@ -0,0 +1,336 @@ +import { Icon } from '@iconify/react'; +import { Dialog } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import Editor from '@monaco-editor/react'; +import { + Alert, + Box, + Button, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Tab, + Tabs, + Typography, +} from '@mui/material'; +import React, { useEffect, useState } from 'react'; + +export interface MCPServer { + name: string; + command: string; + args: string[]; + env?: Record; + enabled: boolean; +} + +export interface MCPConfig { + enabled: boolean; + servers: MCPServer[]; +} + +interface MCPConfigEditorDialogProps { + open: boolean; + onClose: () => void; + config: MCPConfig; + onSave: (config: MCPConfig) => void; +} + +export default function MCPConfigEditorDialog({ + open, + onClose, + config, + onSave, +}: MCPConfigEditorDialogProps) { + const [content, setContent] = useState(''); + const [validationError, setValidationError] = useState(''); + const [tabValue, setTabValue] = useState(0); + const themeName = localStorage.getItem('headlampThemePreference'); + + useEffect(() => { + if (open) { + setContent(JSON.stringify(config, null, 2)); + setValidationError(''); + setTabValue(0); + } + }, [config, open]); + + const handleEditorChange = (value: string | undefined) => { + if (value !== undefined) { + setContent(value); + setValidationError(''); + } + }; + + const validateConfig = (configToValidate: any): string | null => { + if (typeof configToValidate.enabled !== 'boolean') { + return 'enabled field must be a boolean'; + } + + if (!Array.isArray(configToValidate.servers)) { + return 'servers field must be an array'; + } + + for (let i = 0; i < configToValidate.servers.length; i++) { + const server = configToValidate.servers[i]; + + if (typeof server.name !== 'string' || !server.name.trim()) { + return `Server ${i + 1}: name must be a non-empty string`; + } + + if (typeof server.command !== 'string' || !server.command.trim()) { + return `Server ${i + 1}: command must be a non-empty string`; + } + + if (!Array.isArray(server.args)) { + return `Server ${i + 1}: args must be an array`; + } + + if (server.env !== undefined) { + if (typeof server.env !== 'object' || server.env === null || Array.isArray(server.env)) { + return `Server ${i + 1}: env must be an object with string key-value pairs`; + } + + for (const [key, value] of Object.entries(server.env)) { + if (typeof key !== 'string' || typeof value !== 'string') { + return `Server ${i + 1}: env must contain only string key-value pairs`; + } + } + } + + if (typeof server.enabled !== 'boolean') { + return `Server ${i + 1}: enabled must be a boolean`; + } + } + + return null; + }; + + const handleSave = () => { + try { + const parsedConfig = JSON.parse(content); + + const error = validateConfig(parsedConfig); + if (error) { + setValidationError(error); + return; + } + + onSave(parsedConfig); + onClose(); + } catch (error) { + setValidationError(error instanceof Error ? error.message : 'Invalid JSON configuration'); + } + }; + + const handleLoadExample = () => { + const exampleConfig: MCPConfig = { + enabled: true, + servers: [ + { + name: 'inspektor-gadget', + command: 'docker', + args: ['mcp', 'gateway', 'run'], + enabled: true, + }, + { + name: 'flux-mcp', + command: 'flux-operator-mcp', + args: ['serve'], + env: { + KUBECONFIG: '/Users/ashughildiyal/.kube/config', + }, + enabled: true, + }, + ], + }; + + setContent(JSON.stringify(exampleConfig, null, 2)); + setValidationError(''); + }; + + const handleReset = () => { + setContent(JSON.stringify(config, null, 2)); + setValidationError(''); + }; + + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue); + }; + + const getSchemaDocumentation = () => { + return { + enabled: 'boolean - Enable/disable all MCP servers', + servers: [ + { + name: 'string - Unique server name', + command: 'string - Executable command or path', + args: ['array of strings - Command arguments'], + env: { + KEY: 'string value - Environment variables (optional)', + }, + enabled: 'boolean - Enable/disable this specific server', + }, + ], + }; + }; + + return ( + + + + Edit MCP Configuration + + + + + + + + + + + + + + {tabValue === 0 && ( + + {validationError && ( + + {validationError} + + )} + + + + + Edit the JSON configuration above. The editor will automatically format and validate + your configuration. + + + )} + + {tabValue === 1 && ( + + + + Configuration Schema + + + + + The MCP configuration defines how your AI assistant connects to external tools and + services. Each server represents a separate MCP server that provides specific + capabilities. + + + + +
+                  {JSON.stringify(getSchemaDocumentation(), null, 2)}
+                
+
+ + + + Field Descriptions: + + +
  • + + enabled: Master switch to enable/disable all MCP servers + +
  • +
  • + + servers: Array of MCP server configurations + +
  • +
  • + + name: Unique identifier for the server + +
  • +
  • + + command: The executable to run (e.g., "docker", "npx", + "python") + +
  • +
  • + + args: Command-line arguments passed to the executable + +
  • +
  • + + env: Optional environment variables for the server process + +
  • +
  • + + enabled: Toggle individual server on/off without removing + configuration + +
  • +
    +
    +
    +
    + )} +
    + + + + + +
    + ); +} diff --git a/ai-assistant/src/components/settings/MCPSettings.tsx b/ai-assistant/src/components/settings/MCPSettings.tsx new file mode 100644 index 000000000..a6565b0d4 --- /dev/null +++ b/ai-assistant/src/components/settings/MCPSettings.tsx @@ -0,0 +1,232 @@ +import { Icon } from '@iconify/react'; +import { Headlamp } from '@kinvolk/headlamp-plugin/lib'; +import { SectionBox } from '@kinvolk/headlamp-plugin/lib/components/common'; +import { Box, Button, FormControlLabel, Switch, Typography } from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import { pluginStore } from '../../utils'; +import MCPConfigEditorDialog from './MCPConfigEditorDialog'; + +export interface MCPServer { + name: string; + command: string; + args: string[]; + env?: Record; + enabled: boolean; +} + +export interface MCPConfig { + enabled: boolean; + servers: MCPServer[]; +} + +interface MCPSettingsProps { + config?: MCPConfig; + onConfigChange?: (config: MCPConfig) => void; +} + +export function MCPSettings({ config, onConfigChange }: MCPSettingsProps) { + const [mcpConfig, setMCPConfig] = useState( + config || { + enabled: false, + servers: [], + } + ); + + const [editorDialogOpen, setEditorDialogOpen] = useState(false); + + useEffect(() => { + // Load MCP config from Electron if available + if (Headlamp.isRunningAsApp()) { + loadMCPConfigFromElectron(); + } else { + // Fallback to plugin store for non-Electron environments + const savedConfig = pluginStore.get(); + if (savedConfig?.mcpConfig) { + setMCPConfig(savedConfig.mcpConfig); + } + } + }, []); + + const loadMCPConfigFromElectron = async () => { + if (!Headlamp.isRunningAsApp()) return; + + try { + const response = await window.desktopApi!.mcp.getConfig(); + if (response.success && response.config) { + setMCPConfig(response.config); + } + } catch (error) { + console.error('Error loading MCP config from Electron:', error); + // Fallback to plugin store + const savedConfig = pluginStore.get(); + if (savedConfig?.mcpConfig) { + setMCPConfig(savedConfig.mcpConfig); + } + } + }; + + const handleConfigChange = async (newConfig: MCPConfig) => { + setMCPConfig(newConfig); + + if (Headlamp.isRunningAsApp()) { + // Save to Electron settings and restart MCP client + try { + const response = await window.desktopApi!.mcp.updateConfig(newConfig); + if (!response.success) { + console.error('Error updating MCP config in Electron:', response.error); + // Still save to plugin store as fallback + const currentConfig = pluginStore.get() || {}; + pluginStore.update({ + ...currentConfig, + mcpConfig: newConfig, + }); + } + } catch (error) { + console.error('Error updating MCP config:', error); + // Fallback to plugin store + const currentConfig = pluginStore.get() || {}; + pluginStore.update({ + ...currentConfig, + mcpConfig: newConfig, + }); + } + } else { + // Save to plugin store for non-Electron environments + const currentConfig = pluginStore.get() || {}; + pluginStore.update({ + ...currentConfig, + mcpConfig: newConfig, + }); + } + + // Also notify parent if callback provided + if (onConfigChange) { + onConfigChange(newConfig); + } + }; + + const handleToggleEnabled = async () => { + const newConfig = { ...mcpConfig, enabled: !mcpConfig.enabled }; + + // If enabling MCP for the first time and no servers exist, add default servers + if (newConfig.enabled && mcpConfig.servers.length === 0) { + const defaultServers: MCPServer[] = [ + { + name: 'inspektor-gadget', + command: 'docker', + args: ['mcp', 'gateway', 'run'], + enabled: true, + }, + { + name: 'flux-mcp', + command: 'flux-operator-mcp', + args: ['serve'], + env: { + KUBECONFIG: '/Users/ashughildiyal/.kube/config', + }, + enabled: true, + }, + ]; + + newConfig.servers = defaultServers; + } + + await handleConfigChange(newConfig); + }; + + const handleOpenEditorDialog = () => { + setEditorDialogOpen(true); + }; + + const handleCloseEditorDialog = () => { + setEditorDialogOpen(false); + }; + + const handleSaveConfig = (newConfig: MCPConfig) => { + handleConfigChange(newConfig); + }; + + // Only show MCP settings in Electron + if (!Headlamp.isRunningAsApp()) { + return ( + + + MCP server configuration is only available in the desktop app. + + + ); + } + + return ( + + + + Model Context Protocol (MCP) allows AI assistants to connect to external tools and data + sources. Configure MCP servers here to extend the AI assistant's capabilities. + + + } + label="Enable MCP Servers" + /> + + + {mcpConfig.enabled && ( + <> + {/* Configuration Summary */} + + + Server Configuration + + + You have {mcpConfig.servers.length} server(s) configured. + {mcpConfig.servers.filter(s => s.enabled).length} server(s) are currently enabled. + + + + + + {/* Server List Summary */} + {mcpConfig.servers.length > 0 && ( + + + Configured Servers: + + + {mcpConfig.servers.map((server, index) => ( +
  • + + {server.name} ({server.command}) - + + {server.enabled ? 'Enabled' : 'Disabled'} + + {server.env && ( + + (with env variables) + + )} + +
  • + ))} +
    +
    + )} + + )} + + {/* Editor Dialog */} + +
    + ); +} diff --git a/ai-assistant/src/components/settings/MCPToolSettings.tsx b/ai-assistant/src/components/settings/MCPToolSettings.tsx new file mode 100644 index 000000000..14abc6c73 --- /dev/null +++ b/ai-assistant/src/components/settings/MCPToolSettings.tsx @@ -0,0 +1,475 @@ +import { Icon } from '@iconify/react'; +import { Headlamp } from '@kinvolk/headlamp-plugin/lib'; +import { SectionBox } from '@kinvolk/headlamp-plugin/lib/components/common'; +import { + Box, + Button, + Chip, + FormControlLabel, + IconButton, + Paper, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + useTheme, +} from '@mui/material'; +import React, { useCallback, useEffect, useState } from 'react'; +import { parseMCPToolName } from '../../utils/ToolConfigManager'; + +interface MCPToolState { + enabled: boolean; + lastUsed?: Date; + usageCount?: number; +} + +interface MCPServerToolState { + [toolName: string]: MCPToolState; +} + +interface MCPToolsConfig { + [serverName: string]: MCPServerToolState; +} + +interface MCPToolInfo { + name: string; + description?: string; + server: string; + actualToolName: string; + enabled: boolean; + stats: MCPToolState | null; +} + +interface MCPToolSettingsProps { + onConfigChange?: (hasChanges: boolean) => void; +} + +export function MCPToolSettings({ onConfigChange }: MCPToolSettingsProps) { + const theme = useTheme(); + const [loading, setLoading] = useState(true); + const [mcpTools, setMCPTools] = useState([]); + const [toolsConfig, setToolsConfig] = useState({}); + const [originalConfig, setOriginalConfig] = useState({}); + const [hasChanges, setHasChanges] = useState(false); + const [error, setError] = useState(null); + + // Load MCP tools and configuration + const loadMCPToolsAndConfig = useCallback(async () => { + if (!Headlamp.isRunningAsApp() || !window.desktopApi?.mcp) { + setError('MCP tool management is only available in the Headlamp desktop application.'); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + + // Get available MCP tools + const toolsResponse = await window.desktopApi.mcp.getToolsConfig(); + if (!toolsResponse.success) { + throw new Error(toolsResponse.error || 'Failed to get MCP tools'); + } + + // Get tools configuration + const configResponse = await window.desktopApi.mcp.getToolsConfig(); + if (!configResponse.success) { + throw new Error(configResponse.error || 'Failed to get MCP tools configuration'); + } + + const config = configResponse.config || {}; + setToolsConfig(config); + setOriginalConfig(JSON.parse(JSON.stringify(config))); // Deep copy for comparison + + // Process tools data + const toolsData: MCPToolInfo[] = []; + + for (const tool of toolsResponse.config || []) { + const { serverName, toolName: actualToolName } = parseMCPToolName(tool.name); + + // Get tool state from configuration + const serverConfig = config[serverName]; + const toolState = serverConfig?.[actualToolName]; + const enabled = toolState?.enabled !== false; // Default to true if not configured + + // Get tool statistics + let stats: MCPToolState | null = null; + try { + const statsResponse = await window.desktopApi.mcp.getToolStats( + serverName, + actualToolName + ); + if (statsResponse.success) { + stats = statsResponse.stats; + } + } catch (error) { + console.warn(`Failed to get stats for tool ${tool.name}:`, error); + } + + toolsData.push({ + name: tool.name, + description: tool.description, + server: serverName, + actualToolName, + enabled, + stats, + }); + } + + // Sort tools by server name and tool name + toolsData.sort((a, b) => { + if (a.server !== b.server) { + return a.server.localeCompare(b.server); + } + return a.actualToolName.localeCompare(b.actualToolName); + }); + + setMCPTools(toolsData); + } catch (error) { + console.error('Error loading MCP tools and configuration:', error); + setError(error instanceof Error ? error.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadMCPToolsAndConfig(); + }, [loadMCPToolsAndConfig]); + + // Handle tool enable/disable toggle (local state only) + const handleToolToggle = (toolInfo: MCPToolInfo, enabled: boolean) => { + // Update local tool state + setMCPTools(prevTools => + prevTools.map(tool => (tool.name === toolInfo.name ? { ...tool, enabled } : tool)) + ); + + // Update configuration state + setToolsConfig(prevConfig => { + const newConfig = { ...prevConfig }; + if (!newConfig[toolInfo.server]) { + newConfig[toolInfo.server] = {}; + } + if (!newConfig[toolInfo.server][toolInfo.actualToolName]) { + newConfig[toolInfo.server][toolInfo.actualToolName] = { + enabled: true, + usageCount: 0, + }; + } + newConfig[toolInfo.server][toolInfo.actualToolName].enabled = enabled; + return newConfig; + }); + + // Mark as having changes + setHasChanges(true); + onConfigChange?.(true); + }; + + // Refresh tools and configuration + const handleRefresh = () => { + setHasChanges(false); + onConfigChange?.(false); + loadMCPToolsAndConfig(); + }; + + // Save configuration changes + const handleSaveChanges = async () => { + if (!window.desktopApi?.mcp) { + return; + } + + try { + const response = await window.desktopApi.mcp.updateToolsConfig(toolsConfig); + if (response.success) { + setHasChanges(false); + onConfigChange?.(false); + setOriginalConfig(JSON.parse(JSON.stringify(toolsConfig))); // Update original config + } else { + throw new Error(response.error || 'Failed to save configuration'); + } + } catch (error) { + setError( + `Failed to save configuration: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }; + + // Discard changes and revert to original configuration + const handleDiscardChanges = () => { + setToolsConfig(JSON.parse(JSON.stringify(originalConfig))); // Restore original config + + // Update tools to reflect original configuration + setMCPTools(prevTools => + prevTools.map(tool => { + const serverConfig = originalConfig[tool.server]; + const toolState = serverConfig?.[tool.actualToolName]; + const enabled = toolState?.enabled !== false; // Default to true if not configured + return { ...tool, enabled }; + }) + ); + + setHasChanges(false); + onConfigChange?.(false); + }; + + // Enable all tools (local state only) + const handleEnableAll = () => { + // Update all tools to enabled in local state + setMCPTools(prevTools => prevTools.map(tool => ({ ...tool, enabled: true }))); + + // Update configuration state + setToolsConfig(prevConfig => { + const newConfig = { ...prevConfig }; + for (const tool of mcpTools) { + if (!newConfig[tool.server]) { + newConfig[tool.server] = {}; + } + if (!newConfig[tool.server][tool.actualToolName]) { + newConfig[tool.server][tool.actualToolName] = { + enabled: true, + usageCount: 0, + }; + } + newConfig[tool.server][tool.actualToolName].enabled = true; + } + return newConfig; + }); + + setHasChanges(true); + onConfigChange?.(true); + }; + + // Disable all tools (local state only) + const handleDisableAll = () => { + // Update all tools to disabled in local state + setMCPTools(prevTools => prevTools.map(tool => ({ ...tool, enabled: false }))); + + // Update configuration state + setToolsConfig(prevConfig => { + const newConfig = { ...prevConfig }; + for (const tool of mcpTools) { + if (!newConfig[tool.server]) { + newConfig[tool.server] = {}; + } + if (!newConfig[tool.server][tool.actualToolName]) { + newConfig[tool.server][tool.actualToolName] = { + enabled: true, + usageCount: 0, + }; + } + newConfig[tool.server][tool.actualToolName].enabled = false; + } + return newConfig; + }); + + setHasChanges(true); + onConfigChange?.(true); + }; + + // Format usage count for display + const formatUsageCount = (count?: number): string => { + if (count === undefined || count === 0) return 'Never used'; + return `Used ${count} time${count === 1 ? '' : 's'}`; + }; + + // Format last used date for display + const formatLastUsed = (lastUsed?: Date): string => { + if (!lastUsed) return 'Never'; + const date = new Date(lastUsed); + return date.toLocaleString(); + }; + + // Group tools by server + const groupedTools = mcpTools.reduce((groups, tool) => { + if (!groups[tool.server]) { + groups[tool.server] = []; + } + groups[tool.server].push(tool); + return groups; + }, {} as Record); + + const enabledCount = mcpTools.filter(tool => tool.enabled).length; + const totalCount = mcpTools.length; + + if (loading) { + return ( + + + Loading MCP tools... + + + ); + } + + if (error) { + return ( + + + + {error} + + + + + ); + } + + return ( + + + + Configure individual MCP (Model Context Protocol) tools. You can enable or disable + specific tools to control which capabilities are available to the AI assistant. + + + + + + {hasChanges && ( + } + /> + )} + + + + {hasChanges && ( + <> + + + + )} + + + + + + + + + + {totalCount === 0 ? ( + + + + No MCP Tools Available + + + No MCP servers are configured or running. Configure MCP servers in the MCP Settings + section to see available tools here. + + + ) : ( + Object.entries(groupedTools).map(([serverName, serverTools]) => ( + + + + {serverName} ({serverTools.length} tools) + + + + + + + Tool Name + Description + Usage Statistics + Enabled + + + + {serverTools.map(tool => ( + + + + {tool.actualToolName} + + + {tool.name} + + + + + {tool.description || 'No description available'} + + + + + + {formatUsageCount(tool.stats?.usageCount)} + + + Last used: {formatLastUsed(tool.stats?.lastUsed)} + + + + + handleToolToggle(tool, e.target.checked)} + size="small" + /> + } + label="" + /> + + + ))} + +
    +
    +
    + )) + )} +
    + ); +} diff --git a/ai-assistant/src/components/settings/index.ts b/ai-assistant/src/components/settings/index.ts index ba38a4cf8..e25059f67 100644 --- a/ai-assistant/src/components/settings/index.ts +++ b/ai-assistant/src/components/settings/index.ts @@ -1,2 +1,3 @@ export { default as ModelSelector } from './ModelSelector'; export { default as TermsDialog } from './TermsDialog'; +export { MCPSettings } from './MCPSettings'; diff --git a/ai-assistant/src/contexts/PromptWidthContext.tsx b/ai-assistant/src/contexts/PromptWidthContext.tsx new file mode 100644 index 000000000..eed0b10d9 --- /dev/null +++ b/ai-assistant/src/contexts/PromptWidthContext.tsx @@ -0,0 +1,34 @@ +import React, { createContext, ReactNode, useContext, useState } from 'react'; + +interface PromptWidthContextType { + promptWidth: string; + setPromptWidth: (width: string) => void; +} + +const PromptWidthContext = createContext(undefined); + +interface PromptWidthProviderProps { + children: ReactNode; + initialWidth?: string; +} + +export const PromptWidthProvider: React.FC = ({ + children, + initialWidth = '400px', +}) => { + const [promptWidth, setPromptWidth] = useState(initialWidth); + + return ( + + {children} + + ); +}; + +export const usePromptWidth = (): PromptWidthContextType => { + const context = useContext(PromptWidthContext); + if (context === undefined) { + throw new Error('usePromptWidth must be used within a PromptWidthProvider'); + } + return context; +}; diff --git a/ai-assistant/src/helper/apihelper.tsx b/ai-assistant/src/helper/apihelper.tsx index 0e06db34c..abe8c4c26 100644 --- a/ai-assistant/src/helper/apihelper.tsx +++ b/ai-assistant/src/helper/apihelper.tsx @@ -394,13 +394,14 @@ export const handleActualApiRequest = async ( isJSON: !isLogRequest(cleanedUrl), }); } catch (apiError) { - console.log('Error in clusterRequest:', apiError); - // Handle specific multi-container pod logs error if ( isLogRequest(cleanedUrl) && apiError.message && - apiError.message?.includes('a container name must be specified') + (apiError.message?.includes('a container name must be specified') || + apiError.message?.includes('container name must be specified') || + (apiError.message?.includes('Bad Request') && cleanedUrl.includes('/log')) || + apiError.message?.includes('choose one of')) ) { // Extract pod name and available containers from error message const podMatch = apiError.message.match(/for pod ([^,]+)/); @@ -425,12 +426,33 @@ export const handleActualApiRequest = async ( role: 'assistant', content: errorContent, }); + } else { + // If we can't parse the specific error but know it's a log request with Bad Request + // Extract pod name from URL and suggest getting pod details + const podNameFromUrl = cleanedUrl.match(/\/pods\/([^\/]+)\/log/); + if (podNameFromUrl) { + const podName = podNameFromUrl[1]; + + const errorContent = `Failed to get logs from pod "${podName}". This is likely because it has multiple containers.\n\nTo see the containers in this pod, I need to get the pod details first. Would you like me to check the pod details to see available containers?`; + + aiManager.history.push({ + error: false, + role: 'assistant', + content: errorContent, + }); + + return JSON.stringify({ + error: false, + role: 'assistant', + content: errorContent, + }); + } } } // Handle general API errors if (onFailure) { - onFailure(apiError, 'GET', { type: 'api_error' }); + // onFailure(apiError, 'GET', { type: 'api_error' }); } aiManager.history.push({ error: true, diff --git a/ai-assistant/src/hooks/useToolApproval.ts b/ai-assistant/src/hooks/useToolApproval.ts new file mode 100644 index 000000000..0b74337cc --- /dev/null +++ b/ai-assistant/src/hooks/useToolApproval.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useState } from 'react'; +import { toolApprovalManager, ToolApprovalRequest } from '../utils/ToolApprovalManager'; + +export interface UseToolApprovalResult { + showApprovalDialog: boolean; + pendingRequest: ToolApprovalRequest | null; + handleApprove: (approvedToolIds: string[], rememberChoice?: boolean) => void; + handleDeny: () => void; + handleClose: () => void; + isProcessing: boolean; +} + +export const useToolApproval = (): UseToolApprovalResult => { + const [showApprovalDialog, setShowApprovalDialog] = useState(false); + const [pendingRequest, setPendingRequest] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + + // Listen for approval requests from the manager + useEffect(() => { + const handleApprovalRequest = (request: ToolApprovalRequest) => { + setPendingRequest(request); + setShowApprovalDialog(true); + setIsProcessing(false); + }; + + toolApprovalManager.on('approval-requested', handleApprovalRequest); + + return () => { + toolApprovalManager.off('approval-requested', handleApprovalRequest); + }; + }, []); + + const handleApprove = useCallback( + (approvedToolIds: string[], rememberChoice = false) => { + if (!pendingRequest) return; + + setIsProcessing(true); + toolApprovalManager.approveTools(pendingRequest.requestId, approvedToolIds, rememberChoice); + + // Close dialog after a brief delay to show processing state + setTimeout(() => { + setShowApprovalDialog(false); + setPendingRequest(null); + setIsProcessing(false); + }, 500); + }, + [pendingRequest] + ); + + const handleDeny = useCallback(() => { + if (!pendingRequest) return; + + toolApprovalManager.denyTools(pendingRequest.requestId); + setShowApprovalDialog(false); + setPendingRequest(null); + setIsProcessing(false); + }, [pendingRequest]); + + const handleClose = useCallback(() => { + // Close is essentially a denial - user dismissed the dialog + handleDeny(); + }, [handleDeny]); + + return { + showApprovalDialog, + pendingRequest, + handleApprove, + handleDeny, + handleClose, + isProcessing, + }; +}; diff --git a/ai-assistant/src/index.tsx b/ai-assistant/src/index.tsx index c1d653042..2e69f196b 100644 --- a/ai-assistant/src/index.tsx +++ b/ai-assistant/src/index.tsx @@ -22,16 +22,26 @@ import { import React from 'react'; import { useHistory } from 'react-router-dom'; import { ModelSelector } from './components'; +import { MCPSettings } from './components/settings'; import { getDefaultConfig } from './config/modelConfig'; +import { PromptWidthProvider } from './contexts/PromptWidthContext'; import { isTestModeCheck } from './helper'; import AIPrompt from './modal'; -import { getSettingsURL, PLUGIN_NAME, pluginStore, useGlobalState, usePluginConfig } from './utils'; +import { + getAllAvailableTools, + getSettingsURL, + isToolEnabled, + PLUGIN_NAME, + pluginStore, + toggleTool, + useGlobalState, + usePluginConfig, +} from './utils'; import { getActiveConfig, getSavedConfigurations, SavedConfigurations, } from './utils/ProviderConfigManager'; -import { getAllAvailableTools, isToolEnabled, toggleTool } from './utils/ToolConfigManager'; // Memoized UI Panel component to prevent unnecessary re-renders const AIPanelComponent = React.memo(() => { @@ -107,11 +117,14 @@ const AIPanelComponent = React.memo(() => { zIndex: 10, }} /> - + + + ); }); @@ -460,6 +473,13 @@ function Settings() { ))} + + {/* MCP Servers Section */} + + + + {/* MCP Tool Configuration Section */} + ); } diff --git a/ai-assistant/src/langchain/LangChainManager.ts b/ai-assistant/src/langchain/LangChainManager.ts index ceddb1bb6..5fcfd2687 100644 --- a/ai-assistant/src/langchain/LangChainManager.ts +++ b/ai-assistant/src/langchain/LangChainManager.ts @@ -18,6 +18,10 @@ import { AzureChatOpenAI, ChatOpenAI } from '@langchain/openai'; import sanitizeHtml from 'sanitize-html'; import AIManager, { Prompt } from '../ai/manager'; import { basePrompt } from '../ai/prompts'; +import { MCPArgumentProcessor, UserContext } from '../components/mcpOutput/MCPArgumentProcessor'; +import { inlineToolApprovalManager } from '../utils/InlineToolApprovalManager'; +import { ToolCall } from '../utils/ToolApprovalManager'; +import { isBuiltInTool } from '../utils/ToolConfigManager'; import { apiErrorPromptTemplate, toolFailurePromptTemplate } from './PromptTemplates'; import { KubernetesToolContext, ToolManager } from './tools'; @@ -29,21 +33,34 @@ export default class LangChainManager extends AIManager { private currentAbortController: AbortController | null = null; private promptTemplate: ChatPromptTemplate; private outputParser: StringOutputParser; + private useDirectToolCalling: boolean = false; constructor(providerId: string, config: Record, enabledTools?: string[]) { super(); this.providerId = providerId; const enabledToolIds = enabledTools ?? []; - console.log( - 'AI Assistant: Initializing with enabled tools:', - enabledToolIds || 'all tools enabled' - ); - this.toolManager = new ToolManager(enabledToolIds); // Only enabled tools + this.toolManager = new ToolManager(undefined, enabledToolIds); // Only enabled tools this.model = this.createModel(providerId, config); // Initialize prompt template and output parser this.promptTemplate = this.createPromptTemplate(); this.outputParser = new StringOutputParser(); + + // Set up event listeners for inline tool confirmations + this.setupToolConfirmationListeners(); + } + + // Set up event listeners for tool confirmation events + private setupToolConfirmationListeners() { + inlineToolApprovalManager.on('request-confirmation', (data: any) => { + // Add the tool confirmation message to chat history + this.addToolConfirmationMessage('', data.toolConfirmation); + }); + + inlineToolApprovalManager.on('update-confirmation', (data: any) => { + // Update the specific tool confirmation message with new state (e.g., loading) + this.updateToolConfirmationMessage(data.requestId, data.toolConfirmation); + }); } // Helper method to extract text content from different response formats @@ -178,20 +195,143 @@ export default class LangChainManager extends AIManager { } } - configureTools(tools: any[], kubernetesContext: KubernetesToolContext): void { - console.log('🔧 Configuring tools for LangChain with context:', { - toolCount: tools.length, - selectedClusters: kubernetesContext.selectedClusters, - providerId: this.providerId, - }); + async configureTools(tools: any[], kubernetesContext: KubernetesToolContext): Promise { + await this.toolManager.waitForMCPToolsInitialization(); // Configure the Kubernetes context for the KubernetesTool this.toolManager.configureKubernetesContext(kubernetesContext); + // Get all tools (including MCP tools) + const allTools = this.toolManager.getLangChainTools(); + // Bind all tools to the model for compatible providers (OpenAI, Azure, etc.) - this.boundModel = this.toolManager.bindToModel(this.model, this.providerId); + // Use the async version to ensure MCP tools are properly included + this.boundModel = await this.toolManager.bindToModelAsync(this.model, this.providerId); + + // Enable direct tool calling for better performance + if (allTools.length > 0 && this.canUseDirectToolCalling()) { + this.useDirectToolCalling = true; + } + } + + /** + * Check if the current provider can use direct tool calling + */ + private canUseDirectToolCalling(): boolean { + // All major providers support direct tool calling + return ['openai', 'azure', 'anthropic', 'mistral', 'gemini'].includes(this.providerId); + } + + /** + * Build user context from current conversation and state + */ + private buildUserContext(): UserContext { + // Get the most recent user message + const recentUserMessages = this.history.filter(prompt => prompt.role === 'user').slice(-3); // Last 3 user messages for context + + const userMessage = + recentUserMessages.length > 0 + ? recentUserMessages[recentUserMessages.length - 1].content + : ''; + + // Build conversation history + const conversationHistory = this.history + .slice(-10) // Last 10 messages + .map(prompt => ({ + role: prompt.role, + content: prompt.content, + })); + + // Get recent tool results + const lastToolResults: Record = {}; + const recentToolResponses = this.history.filter(prompt => prompt.role === 'tool').slice(-5); // Last 5 tool responses + + recentToolResponses.forEach(response => { + if (response.name) { + try { + const parsed = JSON.parse(response.content); + lastToolResults[response.name] = parsed; + } catch { + lastToolResults[response.name] = response.content; + } + } + }); + + return { + userMessage, + conversationHistory, + lastToolResults, + timeContext: new Date(), + }; + } + + /** + * Get description for a tool (for approval dialog) + */ + private getToolDescription(toolName: string, isMCPTool: boolean): string { + if (isMCPTool) { + // MCP tool descriptions can be more specific based on tool name + if (toolName.includes('trace') || toolName.includes('profile')) { + return 'Traces system calls and processes for debugging'; + } else if (toolName.includes('network') || toolName.includes('socket')) { + return 'Monitors network connections and traffic'; + } else if (toolName.includes('top') || toolName.includes('process')) { + return 'Shows running processes and resource usage'; + } else if (toolName.includes('exec') || toolName.includes('run')) { + return 'Executes commands in containers'; + } else { + return `Inspektor Gadget debugging tool: ${toolName}`; + } + } else { + // Regular Kubernetes tools + if (toolName.includes('kubernetes')) { + return 'Executes Kubernetes API operations'; + } + return `Kubernetes management tool: ${toolName}`; + } + } - console.log('🔧 Tools bound to model successfully, boundModel exists:', !!this.boundModel); + /** + * Add a tool confirmation message to the history + */ + public addToolConfirmationMessage( + content: string, + toolConfirmation: any, + updateHistoryCallback?: () => void + ): void { + const confirmationPrompt: Prompt = { + role: 'assistant', + content: content, + toolConfirmation: toolConfirmation, + isDisplayOnly: true, // Don't send to LLM + requestId: toolConfirmation.requestId, // Add requestId for tracking + }; + this.history.push(confirmationPrompt); + + // Call the update callback if provided to trigger UI re-render + if (updateHistoryCallback) { + updateHistoryCallback(); + } + } + + public updateToolConfirmationMessage(requestId: string, updatedToolConfirmation: any): void { + // Find the message with matching requestId + const messageIndex = this.history.findIndex( + prompt => prompt.requestId === requestId && prompt.toolConfirmation + ); + + if (messageIndex !== -1) { + // Update the tool confirmation in the existing message + this.history[messageIndex] = { + ...this.history[messageIndex], + toolConfirmation: updatedToolConfirmation, + }; + + // Use the inline tool approval manager to emit update event + inlineToolApprovalManager.emit('message-updated', { requestId, updatedToolConfirmation }); + } else { + console.warn('⚠️ LangChainManager: Could not find tool confirmation message to update'); + } } // Helper method to prepare chat history for prompt template @@ -205,13 +345,142 @@ export default class LangChainManager extends AIManager { // Helper method to create system prompt with context private createSystemPrompt(): string { - let systemPromptContent = basePrompt; + const availableTools = this.toolManager.getToolNames(); + const hasKubernetesTool = availableTools.includes('kubernetes_api_request'); + + let systemPromptContent; + + if (!hasKubernetesTool) { + // Modified prompt when Kubernetes tools are disabled + systemPromptContent = `You are an AI assistant for the Headlamp Kubernetes UI. You help users understand and manage their Kubernetes resources through a web interface. + +IMPORTANT: Kubernetes API access tools are currently DISABLED in your settings. + +CRITICAL LIMITATIONS: +- You CANNOT access live cluster data (pods, deployments, services, etc.) +- You CANNOT fetch current resource information from the cluster +- You CANNOT retrieve logs, events, or real-time status information +- DO NOT promise to fetch, retrieve, or access any live cluster data + +WHAT YOU CAN DO: +- Provide general Kubernetes guidance and explanations +- Generate YAML examples for resource creation +- Explain Kubernetes concepts and best practices +- Help troubleshoot based on information the user provides +- Direct users to enable tools if they need live data access + +WHEN USERS ASK FOR LIVE DATA: +- Clearly explain that you cannot access live cluster information +- Inform them that Kubernetes API tools are disabled +- Provide instructions to enable tools in AI Assistant settings +- Offer to help with general guidance instead + +YAML FORMATTING: +When providing Kubernetes YAML examples, use this format: + +## [Resource Type] Example: + +Brief explanation of the resource. + +\`\`\`yaml +apiVersion: [version] +kind: [kind] +metadata: + name: [name] + namespace: default +spec: + # Configuration here +\`\`\` + +Note: The YAML you provide will be displayed in a preview editor with an "Edit" button that allows users to modify the configuration before applying it to their cluster. + +RESPONSES: +- Format responses in markdown +- Be honest about limitations +- Always suggest enabling tools for live data access +- Provide helpful general guidance when possible +- If asked non-Kubernetes questions, politely redirect and include a light Kubernetes joke`; + } else { + // Original prompt when tools are available + systemPromptContent = basePrompt; + } + + // Add MCP tool guidance if we have MCP tools available + const mcpTools = this.toolManager.getMCPTools(); + if (mcpTools.length > 0) { + systemPromptContent += ` + +MCP TOOL GUIDANCE: +You have access to advanced debugging and monitoring tools through MCP (Model Context Protocol). When users request system analysis, monitoring, or debugging: + +INTELLIGENT PARAMETER SETTING: +- When calling MCP tools, intelligently populate parameters based on the user's request and the current context +- For duration parameters: Use reasonable defaults (30 seconds for quick checks, 60-300 seconds for monitoring, 0 for continuous) +- For namespace parameters: Use the current namespace from context, or "default" if not specified +- For filtering parameters: Extract relevant filters from the user's request (pod names, labels, etc.) +- For params objects: Populate with relevant Kubernetes selectors based on context + +COMMON MCP TOOL PATTERNS: +- Gadget tools with "snapshot" are for one-time data collection +- Gadget tools with "trace" are for monitoring over time +- Duration 0 means continuous monitoring (use sparingly) +- Always populate the required "params" object, even if empty: {"params": {}} + +PARAMETER EXAMPLES: +- For namespace-specific requests: {"params": {"operator.KubeManager.namespace": "target-namespace"}} +- For pod-specific requests: {"params": {"operator.KubeManager.podname": "pod-name"}} +- For monitoring duration: {"duration": 30, "params": {"operator.KubeManager.namespace": "default"}} +- For continuous monitoring: {"duration": 0, "params": {...}} + +CONTEXT-AWARE PARAMETER EXTRACTION: +- Extract pod names, namespaces, and labels from the user's request +- Use current cluster context when available +- Default to "default" namespace if not specified +- Apply appropriate filters based on the user's intent + +RESULT INTERPRETATION AND PRESENTATION: +When MCP tools return data, you MUST: +1. **Analyze and summarize** - Don't just show raw JSON data +2. **Identify patterns** - Group similar items, highlight anomalies +3. **Format clearly** - Use tables, lists, or structured presentation +4. **Focus on insights** - Explain what the data means, not just what it contains +5. **Highlight issues** - Point out potential security or performance problems + +EXAMPLE result processing: +- Socket data → "Found X active connections, Y listening ports, Z external connections" +- Process data → "Identified N processes, M high CPU consumers" +- Network traces → "Detected traffic patterns: internal vs external, protocols used" +- Performance data → "Key metrics: CPU usage X%, memory Y%, network Z Mbps" + +NEVER just dump raw JSON - always interpret and present meaningfully.`; + } + if (this.currentContext) { systemPromptContent += `\n\nCURRENT CONTEXT:\n${this.currentContext}`; } return systemPromptContent; } + // Helper method to create system prompt specifically for tool response processing + private createToolResponseSystemPrompt(): string { + const baseSystemPrompt = this.createSystemPrompt(); + + // Add specific instructions for tool response processing + const toolResponseInstructions = ` + +IMPORTANT: You have just received tool execution results. Your task is to: + +1. ANALYZE the tool results and provide a clear, helpful response to the user +2. SUMMARIZE the information in a user-friendly way +3. DO NOT call additional tools unless the user explicitly requests more actions +4. FOCUS on explaining what the tools found or accomplished +5. If the tool results show data (like file listings, directories, etc.), present them in a clear, formatted way + +The user is waiting for you to explain what the tools discovered. Provide a direct, informative response based on the tool results.`; + + return baseSystemPrompt + toolResponseInstructions; + } + private convertPromptsToMessages(prompts: Prompt[]): BaseMessage[] { return prompts.map(prompt => { switch (prompt.role) { @@ -241,6 +510,11 @@ export default class LangChainManager extends AIManager { this.currentAbortController = new AbortController(); try { + // Use direct tool calling if enabled + if (this.useDirectToolCalling) { + return await this.handleDirectToolCallingRequest(message); + } + const modelToUse = this.boundModel || this.model; // For local models, use simplified approach @@ -255,6 +529,55 @@ export default class LangChainManager extends AIManager { } } + // Handle requests using direct tool calling (single LLM call) + private async handleDirectToolCallingRequest(message: string): Promise { + try { + const modelToUse = this.boundModel || this.model; + + // Prepare input for the model with tools + const chainInput = { + systemPrompt: this.createSystemPrompt(), + chatHistory: this.prepareChatHistory(), + input: message, + }; + + // Convert chain input to messages + const messages = [ + new SystemMessage(chainInput.systemPrompt), + ...chainInput.chatHistory, + new HumanMessage(chainInput.input), + ]; + + // Single LLM call with tool capabilities + const response = await modelToUse.invoke(messages, { + signal: this.currentAbortController?.signal, + }); + + this.currentAbortController = null; + + // Handle tool calls if present + if (response.tool_calls?.length) { + return await this.handleToolCalls(response); + } else { + // Handle regular response + const assistantPrompt: Prompt = { + role: 'assistant', + content: this.extractTextContent(response.content), + }; + this.history.push(assistantPrompt); + return assistantPrompt; + } + } catch (error) { + console.error('Error in direct tool calling request:', error); + + // If direct tool calling fails, fall back to regular approach + this.useDirectToolCalling = false; + + const modelToUse = this.boundModel || this.model; + return await this.handleChainBasedRequest(message, modelToUse); + } + } + // Handle requests for local models (simplified) private async handleLocalModelRequest(message: string, model: BaseChatModel): Promise { const systemMessage = new SystemMessage(this.createSystemPrompt()); @@ -316,12 +639,6 @@ export default class LangChainManager extends AIManager { // IMPORTANT: Use the boundModel (which has tools) instead of the original model const modelToUse = this.boundModel || model; - console.log('🔧 Using model for tool-enabled request:', { - usingBoundModel: !!this.boundModel, - modelHasBindTools: typeof modelToUse.bindTools === 'function', - toolsAvailable: this.toolManager.getToolNames(), - }); - const response = await modelToUse.invoke(messages, { signal: this.currentAbortController.signal, }); @@ -330,17 +647,7 @@ export default class LangChainManager extends AIManager { // Handle tool calls if present if (response.tool_calls?.length) { - console.log( - '🔧 Tool calls detected:', - response.tool_calls.length, - response.tool_calls.map(tc => ({ - name: tc.name, - args: tc.args, - })) - ); return await this.handleToolCalls(response); - } else { - console.log('💬 No tool calls detected in response, treating as regular message'); } // Handle regular response @@ -358,11 +665,6 @@ export default class LangChainManager extends AIManager { // If no tools are enabled but LLM is returning tool calls, this indicates a bug if (enabledToolIds.length === 0) { - console.warn('LLM returned tool calls but no tools are enabled. This should not happen.', { - toolCalls: response.tool_calls, - modelUsed: this.boundModel === this.model ? 'original' : 'bound', - }); - // Treat as regular response since no tools should be available const assistantPrompt: Prompt = { role: 'assistant', @@ -374,7 +676,8 @@ export default class LangChainManager extends AIManager { return assistantPrompt; } - const toolCalls = response.tool_calls.map(tc => ({ + // Filter out disabled tools from tool calls + const allToolCalls = response.tool_calls.map(tc => ({ type: 'function', id: tc.id, function: { @@ -383,6 +686,12 @@ export default class LangChainManager extends AIManager { }, })); + // Only keep tool calls for enabled tools + const toolCalls = allToolCalls.filter(tc => enabledToolIds.includes(tc.function.name)); + + // Log if any tools were filtered out + const filteredOutTools = allToolCalls.filter(tc => !enabledToolIds.includes(tc.function.name)); + const assistantPrompt: Prompt = { role: 'assistant', content: this.extractTextContent(response.content), @@ -390,8 +699,161 @@ export default class LangChainManager extends AIManager { }; this.history.push(assistantPrompt); - // Process tool calls - await this.processToolCalls(toolCalls, assistantPrompt); + // If all tool calls were filtered out (all requested tools are disabled), handle gracefully + if (toolCalls.length === 0) { + // Add informational message about disabled tools if any were filtered + if (filteredOutTools.length > 0) { + const disabledToolNames = filteredOutTools.map(tc => tc.function.name).join(', '); + + // Replace the AI's response with a clear explanation instead of calling tools again + const clarifiedResponse = `I understand you're asking for cluster data, but I cannot access live Kubernetes information because the required tools (${disabledToolNames}) are currently disabled in your settings. + +To get real-time cluster data, you'll need to: +1. Go to AI Assistant settings +2. Enable the "${disabledToolNames}" tool +3. Ask your question again + +Without access to the Kubernetes API, I cannot fetch current pod, deployment, service, or other resource information from your cluster.`; + + // Update the assistant prompt in history with the clarified response + const updatedPrompt: Prompt = { + role: 'assistant', + content: clarifiedResponse, + }; + + // Replace the last history entry with the updated prompt + this.history[this.history.length - 1] = updatedPrompt; + + return updatedPrompt; + } + + return assistantPrompt; + } + + // Prepare tool calls for approval with intelligent argument processing + const toolCallsForApproval: ToolCall[] = await Promise.all( + toolCalls.map(async tc => { + const toolName = tc.function.name; + const mcpTools = this.toolManager.getMCPTools(); + const isMCPTool = mcpTools.some(tool => tool.name === toolName); + let processedArguments = JSON.parse(tc.function.arguments); + + // Use AI to enhance arguments for MCP tools + if (isMCPTool) { + try { + const toolSchema = await MCPArgumentProcessor.getToolSchema(toolName); + if (toolSchema) { + // Build user context from current conversation + const userContext = this.buildUserContext(); + + // Store original arguments for comparison + const originalArguments = { ...processedArguments }; + + // Use AI to intelligently prepare arguments + processedArguments = await this.enhanceArgumentsWithAI( + toolName, + toolSchema, + userContext, + processedArguments + ); + + // Mark which fields were enhanced by LLM for UI display + processedArguments._llmEnhanced = { + enhanced: true, + originalArgs: originalArguments, + enhancedFields: this.identifyEnhancedFields(originalArguments, processedArguments), + }; + } + } catch (error) { + console.warn(`Failed to enhance arguments for ${toolName}:`, error); + // Fall back to original arguments + } + } + + return { + id: tc.id, + name: toolName, + description: this.getToolDescription(toolName, isMCPTool), + arguments: processedArguments, + type: isMCPTool ? 'mcp' : 'regular', + }; + }) + ); + + try { + // Separate built-in tools from MCP tools + const builtInTools = toolCallsForApproval.filter(tool => isBuiltInTool(tool.name)); + const mcpTools = toolCallsForApproval.filter(tool => !isBuiltInTool(tool.name)); + + const approvedToolIds: string[] = []; + + // Auto-approve all built-in tools (no user interaction needed) + const builtInToolIds = builtInTools.map(tool => tool.id); + approvedToolIds.push(...builtInToolIds); + + // Only request approval for MCP tools + if (mcpTools.length > 0) { + const approvedMCPToolIds = await inlineToolApprovalManager.requestApproval( + mcpTools, + this // Pass the AI manager instance + ); + approvedToolIds.push(...approvedMCPToolIds); + } + + // Filter tool calls to only execute approved ones and update with processed arguments + const approvedToolCalls = toolCalls + .filter(tc => approvedToolIds.includes(tc.id)) + .map(tc => { + // Find the processed arguments from the approval data + const approvalData = toolCallsForApproval.find(approval => approval.id === tc.id); + if (approvalData) { + return { + ...tc, + function: { + ...tc.function, + arguments: JSON.stringify(approvalData.arguments), + }, + }; + } + return tc; + }); + const deniedToolCalls = toolCalls.filter(tc => !approvedToolIds.includes(tc.id)); + + // Add denied tool responses to history + for (const deniedTool of deniedToolCalls) { + this.history.push({ + role: 'tool', + content: JSON.stringify({ + error: true, + message: 'Tool execution denied by user', + userFriendlyMessage: `The execution of ${deniedTool.function.name} was denied by the user.`, + }), + toolCallId: deniedTool.id, + name: deniedTool.function.name, + }); + } + + // Process approved tool calls + if (approvedToolCalls.length > 0) { + await this.processToolCalls(approvedToolCalls, assistantPrompt); + } + } catch (error) { + // Add denial responses for all tools + for (const toolCall of toolCalls) { + this.history.push({ + role: 'tool', + content: JSON.stringify({ + error: true, + message: error.message || 'Tool execution denied', + userFriendlyMessage: `Tool execution was denied: ${ + error.message || 'User chose not to proceed' + }`, + }), + toolCallId: toolCall.id, + name: toolCall.function.name, + }); + } + } // Check if we should process follow-up const toolResponses = this.history.filter( @@ -420,6 +882,12 @@ export default class LangChainManager extends AIManager { for (const toolCall of toolCalls) { const args = JSON.parse(toolCall.function.arguments); + console.log( + '🔧 LangChainManager: Executing tool', + toolCall.function.name, + 'with parsed args:', + args + ); try { // Execute the tool call using ToolManager @@ -691,7 +1159,7 @@ Format your response to make the errors prominent and actionable.`, // Process the response const response = await chain.invoke({ messages: messages.slice(1), // Exclude system message for the chain - systemPrompt: this.createSystemPrompt(), + systemPrompt: this.createToolResponseSystemPrompt(), // Use specialized prompt for tool responses }); return this.handleToolResponseResult(response); @@ -702,7 +1170,10 @@ Format your response to make the errors prominent and actionable.`, // Helper method to check if there are tool responses private hasToolResponses(): boolean { - return this.history.some(prompt => prompt.role === 'tool' && prompt.toolCallId); + const toolResponses = this.history.filter( + prompt => prompt.role === 'tool' && prompt.toolCallId + ); + return toolResponses.length > 0; } // Helper method to get the last assistant message @@ -761,24 +1232,7 @@ Format your response to make the errors prominent and actionable.`, }); // Add missing tool responses - this.addMissingToolResponses(expectedToolCallIds, actualToolResponses); - } - } - } - - // Add missing tool responses - private addMissingToolResponses(expectedIds: string[], actualResponses: any[]): void { - for (const expectedId of expectedIds) { - if (!actualResponses.find(r => r.toolCallId === expectedId)) { - this.history.push({ - role: 'tool', - content: JSON.stringify({ - error: true, - message: 'Tool execution failed - no response recorded', - }), - toolCallId: expectedId, - name: 'kubernetes_api_request', - }); + // this.addMissingToolResponses(expectedToolCallIds, actualToolResponses); } } } @@ -853,8 +1307,10 @@ Format your response to make the errors prominent and actionable.`, }); } - // Check response size - const responseSize = prompt.content.length; + const content = prompt.content; + + // Check response size after optimization handling + const responseSize = content.length; if (currentSize + responseSize > maxSize) { console.warn(`Tool response size exceeds limit (${currentSize + responseSize}/${maxSize})`); return ( @@ -864,7 +1320,7 @@ Format your response to make the errors prominent and actionable.`, } // Sanitize content - return this.sanitizeContent(prompt.content); + return this.sanitizeContent(content); } // Find the last assistant message with tool calls @@ -882,15 +1338,111 @@ Format your response to make the errors prominent and actionable.`, // Handle the result of tool response processing private async handleToolResponseResult(response: any): Promise { - // Track usage after tool processing - this.logUsageInfo(response); - // Analyze and potentially correct kubectl suggestions const correctedResponse = await this.analyzeAndCorrectResponse(response); + const extractedContent = this.extractTextContent(correctedResponse.content); + + // If the model returned empty content but has tool calls, it's trying to call more tools + // Instead of allowing this, we should provide a fallback response based on the tool results + if ( + (!extractedContent || extractedContent.trim().length === 0) && + response.tool_calls?.length > 0 + ) { + // Get the most recent tool responses from history + const recentToolResponses = this.history + .filter(prompt => prompt.role === 'tool' && prompt.toolCallId) + .slice(-3) // Get last 3 tool responses + .map(response => ({ + name: response.name, + content: response.content, + })); + + // Create a fallback response based on tool results + // For MCP tools with formatted output, return them directly without prefix + if (recentToolResponses.length === 1) { + const singleResponse = recentToolResponses[0]; + try { + const parsed = JSON.parse(singleResponse.content); + if (parsed.formatted && parsed.mcpOutput) { + // This is a formatted MCP output, return it directly + const assistantPrompt: Prompt = { + role: 'assistant', + content: singleResponse.content, + toolCalls: [], + }; + + // Clean up history to prevent message order issues + const lastAssistantWithToolsIndex = this.findLastAssistantWithTools(); + if (lastAssistantWithToolsIndex >= 0) { + this.history = this.history.slice(0, lastAssistantWithToolsIndex + 1); + } + + this.history.push(assistantPrompt); + return assistantPrompt; + } + } catch (e) { + // Not formatted MCP output, continue with fallback + } + } + + // Standard fallback for multiple tools or non-MCP tools + let fallbackContent = ''; + + recentToolResponses.forEach((toolResponse, index) => { + const toolName = toolResponse.name || 'tool'; + let content = toolResponse.content; + + // Try to parse and clean up the content + try { + const parsed = JSON.parse(content); + if (parsed.formatted && parsed.mcpOutput) { + // For formatted MCP outputs, return the JSON directly + content = toolResponse.content; + } else if (parsed.error) { + content = `Error: ${parsed.message || 'Tool execution failed'}`; + } else if (parsed.userFriendlyMessage) { + content = parsed.userFriendlyMessage; + } else if (typeof parsed === 'object') { + content = JSON.stringify(parsed, null, 2); + } + } catch (e) { + // Content is not JSON, use as-is but clean it up + content = content.toString().trim(); + } + + // For single formatted MCP output, return just the content + if (recentToolResponses.length === 1) { + console.log('🔧 Taking single tool response path'); + fallbackContent = content; + } else { + console.log('🔧 Taking multiple tool responses path'); + // For multiple tools, use the tool name format + fallbackContent += `${toolName}: ${content}${ + index < recentToolResponses.length - 1 ? '\n\n' : '' + }`; + } + }); + + const assistantPrompt: Prompt = { + role: 'assistant', + content: fallbackContent.trim(), + toolCalls: [], // Don't include additional tool calls + }; + + // Clean up history to prevent message order issues + const lastAssistantWithToolsIndex = this.findLastAssistantWithTools(); + if (lastAssistantWithToolsIndex >= 0) { + this.history = this.history.slice(0, lastAssistantWithToolsIndex + 1); + } + + this.history.push(assistantPrompt); + return assistantPrompt; + } + const assistantPrompt: Prompt = { role: 'assistant', - content: this.extractTextContent(correctedResponse.content), + content: extractedContent, toolCalls: correctedResponse.tool_calls?.map(tc => ({ id: tc.id, @@ -902,8 +1454,6 @@ Format your response to make the errors prominent and actionable.`, })) || [], }; - console.log('Assistant prompt created from response'); - // Clean up history to prevent message order issues const lastAssistantWithToolsIndex = this.findLastAssistantWithTools(); if (lastAssistantWithToolsIndex >= 0) { @@ -914,33 +1464,6 @@ Format your response to make the errors prominent and actionable.`, return assistantPrompt; } - // Log usage information - private logUsageInfo(response: any): void { - let providerName = 'AI Service'; - let estimatedTokens = 0; - - // Estimate tokens - const outputLength = this.extractTextContent(response.content).length || 0; - estimatedTokens = Math.ceil(outputLength / 4); - - switch (this.providerId) { - case 'openai': - providerName = 'OpenAI'; - break; - case 'azure': - providerName = 'Azure OpenAI'; - break; - case 'anthropic': - providerName = 'Anthropic'; - break; - case 'local': - providerName = 'Local Model'; - break; - } - - console.log(`${providerName} - Estimated tokens: ${estimatedTokens}`); - } - // Analyze response and correct kubectl suggestions private async analyzeAndCorrectResponse(response: any): Promise { const responseContent = this.extractTextContent(response.content); @@ -1032,4 +1555,280 @@ Format your response to make the errors prominent and actionable.`, : JSON.stringify({ error: true, message: 'Content could not be sanitized' }); } } + + /** + * Enhance arguments using AI-like intelligence + */ + private async enhanceArgumentsWithAI( + toolName: string, + toolSchema: any, + userContext: UserContext, + originalArgs: Record + ): Promise> { + const enhanced = { ...originalArgs }; + + if (!toolSchema.inputSchema?.properties) { + return enhanced; + } + + try { + // Use LLM to intelligently prepare arguments based on user context and tool schema + const llmEnhancedArgs = await this.prepareLLMArguments( + toolName, + toolSchema, + userContext, + originalArgs + ); + + // Merge LLM suggestions with original arguments, preferring LLM suggestions + Object.assign(enhanced, llmEnhancedArgs); + } catch (error) { + console.warn(`Failed to get LLM enhancement for ${toolName}:`, error); + // Fall back to basic enhancement + const properties = toolSchema.inputSchema.properties; + const required = toolSchema.inputSchema.required || []; + + // Fill in required fields that are missing or empty + for (const [fieldName, fieldSchema] of Object.entries(properties)) { + const isRequired = required.includes(fieldName); + const currentValue = enhanced[fieldName]; + + if ( + isRequired && + (currentValue === undefined || currentValue === null || currentValue === '') + ) { + // Provide intelligent defaults based on field type and context + enhanced[fieldName] = this.getIntelligentDefault(fieldName, fieldSchema, userContext); + } + } + } + + return enhanced; + } + + /** + * Use LLM to prepare intelligent arguments based on user request and tool schema + */ + private async prepareLLMArguments( + toolName: string, + toolSchema: any, + userContext: UserContext, + originalArgs: Record + ): Promise> { + // Build prompt for argument preparation + const argumentPreparationPrompt = this.createArgumentPreparationPrompt( + toolName, + toolSchema, + userContext, + originalArgs + ); + + try { + // Use the existing model instance but without tools to avoid recursive tool calls + const response = await this.model.invoke([ + { role: 'system', content: argumentPreparationPrompt.system }, + { role: 'user', content: argumentPreparationPrompt.user }, + ]); + + // Parse the LLM response to extract arguments + const responseText = this.extractTextContent(response.content); + const parsedArgs = this.parseArgumentsFromLLMResponse(responseText); + + return parsedArgs; + } catch (error) { + console.warn('Failed to prepare arguments with LLM:', error); + return {}; + } + } + + /** + * Create a prompt for the LLM to prepare tool arguments + */ + private createArgumentPreparationPrompt( + toolName: string, + toolSchema: any, + userContext: UserContext, + originalArgs: Record + ): { system: string; user: string } { + const properties = toolSchema.inputSchema?.properties || {}; + const required = toolSchema.inputSchema?.required || []; + + // Create a description of the tool schema + const schemaDescription = Object.entries(properties) + .map(([fieldName, fieldSchema]: [string, any]) => { + const isReq = required.includes(fieldName) ? ' (REQUIRED)' : ' (optional)'; + const type = fieldSchema.type || 'any'; + const desc = fieldSchema.description || 'No description'; + + // Handle nested properties for complex objects + let nestedProps = ''; + if (fieldSchema.properties) { + nestedProps = + '\n Nested properties:\n' + + Object.entries(fieldSchema.properties) + .map( + ([nestedName, nestedSchema]: [string, any]) => + ` - ${nestedName} (${nestedSchema.type || 'any'}): ${ + nestedSchema.description || 'No description' + }` + ) + .join('\n'); + } + + return `- ${fieldName}${isReq} (${type}): ${desc}${nestedProps}`; + }) + .join('\n'); + + const system = `You are an expert at preparing tool arguments based on user requests. Your task is to analyze the user's request and generate appropriate arguments for the "${toolName}" tool. + +TOOL SCHEMA: +${schemaDescription} + +INSTRUCTIONS: +1. Analyze the user's request to understand their intent +2. Map their natural language request to the appropriate tool arguments +3. For complex objects (like params), fill in the nested properties based on the user's requirements +4. Use the conversation context to infer missing details +5. Return ONLY a valid JSON object with the tool arguments +6. If a required field cannot be determined from the user's request, provide a sensible default + +RESPONSE FORMAT: +Return only valid JSON with the tool arguments. No explanations, no markdown, just the JSON.`; + + const conversationContext = + userContext.conversationHistory + ?.slice(-5) + .map(msg => `${msg.role}: ${msg.content}`) + .join('\n') || ''; + + const user = `USER REQUEST: "${userContext.userMessage}" + +CONVERSATION CONTEXT: +${conversationContext} + +CURRENT ARGUMENTS: ${JSON.stringify(originalArgs, null, 2)} + +Based on the user's request and the tool schema above, generate the appropriate arguments for the "${toolName}" tool. Focus on mapping the user's intent to the correct parameter values. + +For example, if the user says "get me info only from gadget namespace", the params object should include: +{"operator.KubeManager.namespace": "gadget"} + +Return the complete arguments object:`; + + return { system, user }; + } + + /** + * Parse arguments from LLM response + */ + private parseArgumentsFromLLMResponse(response: string): Record { + try { + // Try to extract JSON from the response + const jsonMatch = response.match(/\{[\s\S]*\}/); + if (jsonMatch) { + return JSON.parse(jsonMatch[0]); + } + + // If no JSON found, try to parse the entire response + return JSON.parse(response.trim()); + } catch (error) { + console.warn('Failed to parse LLM response for arguments:', error, response); + return {}; + } + } + + /** + * Identify which fields were enhanced by comparing original and enhanced arguments + */ + private identifyEnhancedFields( + original: Record, + enhanced: Record + ): string[] { + const enhancedFields: string[] = []; + + // Compare each field to see what was added or modified + for (const [key, enhancedValue] of Object.entries(enhanced)) { + if (key === '_llmEnhanced') continue; // Skip metadata + + const originalValue = original[key]; + + // Field is enhanced if: + // 1. It didn't exist in original + // 2. It was null/undefined/empty in original but has value now + // 3. The value is different + if ( + !(key in original) || + originalValue === null || + originalValue === undefined || + originalValue === '' || + JSON.stringify(originalValue) !== JSON.stringify(enhancedValue) + ) { + enhancedFields.push(key); + } + } + + return enhancedFields; + } + + /** + * Get intelligent default value for a field based on context + */ + private getIntelligentDefault( + fieldName: string, + fieldSchema: any, + userContext: UserContext + ): any { + const fieldType = fieldSchema.type; + const fieldNameLower = fieldName.toLowerCase(); + + // Try to extract from user context first + if (userContext.userMessage) { + const userMessage = userContext.userMessage.toLowerCase(); + + // Extract namespace + if (fieldNameLower.includes('namespace')) { + const namespaceMatch = userMessage.match(/namespace[\s:]+([a-zA-Z0-9-_.]+)/i); + if (namespaceMatch) { + return namespaceMatch[1]; + } + return 'default'; // Default Kubernetes namespace + } + + // Extract container/pod names + if (fieldNameLower.includes('container') || fieldNameLower.includes('pod')) { + const containerMatch = userMessage.match(/(?:container|pod)[\s:]+([a-zA-Z0-9-_.]+)/i); + if (containerMatch) { + return containerMatch[1]; + } + } + + // Extract commands + if (fieldNameLower.includes('command') || fieldNameLower.includes('cmd')) { + const commandMatch = userMessage.match(/(?:run|execute|command)[\s:]+["']([^"']+)["']/i); + if (commandMatch) { + return commandMatch[1]; + } + } + } + + // Fallback to type-based defaults + switch (fieldType) { + case 'object': + return {}; + case 'array': + return []; + case 'string': + if (fieldSchema.enum) { + return fieldSchema.enum[0]; + } + return fieldSchema.default || ''; + case 'number': + case 'integer': + return fieldSchema.default || fieldSchema.minimum || 0; + case 'boolean': + return fieldSchema.default !== undefined ? fieldSchema.default : false; + default: + return null; + } + } } diff --git a/ai-assistant/src/langchain/formatters/MCPOutputFormatter.ts b/ai-assistant/src/langchain/formatters/MCPOutputFormatter.ts new file mode 100644 index 000000000..fa7871cc9 --- /dev/null +++ b/ai-assistant/src/langchain/formatters/MCPOutputFormatter.ts @@ -0,0 +1,509 @@ +import { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import { HumanMessage, SystemMessage } from '@langchain/core/messages'; + +export interface FormattedMCPOutput { + type: 'table' | 'metrics' | 'list' | 'graph' | 'text' | 'error' | 'raw'; + title: string; + summary: string; + data: any; + insights?: string[]; + warnings?: string[]; + actionable_items?: string[]; + metadata?: { + toolName: string; + responseSize: number; + processingTime: number; + dataPoints?: number; + }; +} + +export interface MCPFormatterOptions { + maxTokens?: number; + includeInsights?: boolean; + includeActionableItems?: boolean; + formatStyle?: 'detailed' | 'compact' | 'minimal'; +} + +export class MCPOutputFormatter { + private model: BaseChatModel; + private readonly SYSTEM_PROMPT = `You are an expert data analyst specializing in Kubernetes debugging and system monitoring data. Your task is to analyze raw tool outputs and format them in a user-friendly way. + +CRITICAL INSTRUCTIONS: +1. ALWAYS respond with valid JSON in the exact schema provided +2. Analyze the raw data to identify patterns, anomalies, and key insights +3. Format data appropriately based on its type (tables for structured data, metrics for numbers, etc.) +4. Provide actionable insights and recommendations +5. Highlight any security issues, performance problems, or anomalies +6. Keep summaries concise but informative +7. If data contains sensitive information, sanitize it appropriately + +RESPONSE SCHEMA: +{ + "type": "table" | "metrics" | "list" | "graph" | "text" | "error" | "raw", + "title": "Clear, descriptive title", + "summary": "Brief summary of what the data shows", + "data": "Formatted data structure (varies by type)", + "insights": ["Key insights from the data"], + "warnings": ["Any security or performance warnings"], + "actionable_items": ["Specific actions the user should consider"], + "metadata": { + "toolName": "Name of the tool that generated this data", + "responseSize": "Size in bytes", + "processingTime": "Time taken to process", + "dataPoints": "Number of data points if applicable" + } +} + +DATA TYPE FORMATTING GUIDELINES: + +TABLE: For structured data like process lists, network connections, resource usage +{ + "type": "table", + "data": { + "headers": ["Column1", "Column2", ...], + "rows": [["value1", "value2", ...], ...], + "sortBy": "column_name", + "highlightRows": [row_indices_with_issues] + } +} + +ERROR: For tool failures, schema mismatches, or execution errors +{ + "type": "error", + "data": { + "message": "Clear, user-friendly error description", + "details": "Technical error details or original error message", + "suggestions": [ + "Check the tool configuration and parameters", + "Verify input data format matches expected schema", + "Review tool documentation for correct usage" + ] + } +} + +METRICS: For numerical data, statistics, and KPIs +{ + "type": "metrics", + "data": { + "primary": [{"label": "CPU Usage", "value": "85%", "status": "warning"}], + "secondary": [{"label": "Memory", "value": "4.2GB", "status": "normal"}], + "trends": [{"label": "Network I/O", "value": "↑ 15%", "status": "info"}] + } +} + +LIST: For simple lists, logs, or sequential data +{ + "type": "list", + "data": { + "items": [ + {"text": "Item description", "status": "normal|warning|error", "metadata": "additional info"} + ], + "grouped": false + } +} + +GRAPH: For time-series or relationship data +{ + "type": "graph", + "data": { + "chartType": "line|bar|pie|scatter", + "datasets": [{"label": "Dataset name", "data": [...]}], + "labels": [...], + "description": "What the graph represents" + } +} + +TEXT: For unstructured text, logs, or narrative explanations +{ + "type": "text", + "data": { + "content": "Formatted text content", + "language": "json|yaml|shell|text", + "highlights": ["Important phrases to highlight"] + } +} + +ERROR: For error responses or failed tool executions +{ + "type": "error", + "data": { + "message": "User-friendly error message", + "details": "Technical details if available", + "suggestions": ["Possible solutions or next steps"] + } +} + +Remember: Focus on making complex data accessible and actionable for Kubernetes operators and developers.`; + + constructor(model: BaseChatModel) { + this.model = model; + } + + /** + * Format MCP tool output using AI analysis + */ + async formatMCPOutput( + rawOutput: string, + toolName: string, + options: MCPFormatterOptions = {} + ): Promise { + const startTime = Date.now(); + + try { + // Prepare options with defaults + const opts = { + maxTokens: 4000, + includeInsights: true, + includeActionableItems: true, + formatStyle: 'detailed', + ...options, + } as Required; + + // Prepare the analysis prompt + const analysisPrompt = this.buildAnalysisPrompt(rawOutput, toolName, opts); + + // Send to AI for analysis and formatting + const messages = [new SystemMessage(this.SYSTEM_PROMPT), new HumanMessage(analysisPrompt)]; + + const response = await this.model.invoke(messages, { + max_tokens: opts.maxTokens, + }); + + const processingTime = Date.now() - startTime; + + // Parse the AI response + const formattedOutput = this.parseAIResponse(response.content as string, { + toolName, + responseSize: rawOutput.length, + processingTime, + }); + + return formattedOutput; + } catch (error) { + console.error('Error formatting MCP output:', error); + + // Fallback to basic formatting + return this.createFallbackFormat(rawOutput, toolName, Date.now() - startTime); + } + } + + /** + * Build the analysis prompt for the AI + */ + private buildAnalysisPrompt( + rawOutput: string, + toolName: string, + options: Required + ): string { + // Detect if this is documentation content + const isDocumentation = this.isDocumentationContent(rawOutput, toolName); + + // Use higher limits for documentation content + const maxLength = isDocumentation ? 25000 : 10000; + const truncatedOutput = this.truncateIfNeeded(rawOutput, maxLength); + + // Detect if this is likely an error + const isError = this.detectError(rawOutput); + const errorHint = isError + ? '\n\nIMPORTANT: This appears to be an error response. Use "error" type and provide helpful troubleshooting guidance.' + : ''; + + const docHint = isDocumentation + ? '\n\nIMPORTANT: This appears to be documentation content. Use "text" type with language="markdown" to enable proper markdown rendering.' + : ''; + + return `Analyze and format this ${toolName} tool output: + +TOOL: ${toolName} +RAW OUTPUT: +${truncatedOutput} + +FORMAT STYLE: ${options.formatStyle} +INCLUDE INSIGHTS: ${options.includeInsights} +INCLUDE ACTIONABLE ITEMS: ${options.includeActionableItems} + +Please analyze this data and respond with properly formatted JSON following the schema. +Pay special attention to: +1. Identifying the most appropriate visualization type (or "error" if this is an error) +2. For errors: Provide clear, actionable troubleshooting steps +3. For data: Extract key metrics and patterns +4. For documentation: Use markdown formatting and preserve structure +5. Highlighting any security or performance issues +6. Providing actionable recommendations + +${errorHint}${docHint} + +${ + truncatedOutput.length < rawOutput.length + ? `\n[Note: Output was truncated from ${rawOutput.length} to ${truncatedOutput.length} characters for analysis. Original content size: ${rawOutput.length} characters]` + : '' +}`; + } + + /** + * Detect if raw output indicates an error + */ + private detectError(rawOutput: string): boolean { + try { + const parsed = JSON.parse(rawOutput); + return ( + parsed.success === false || + parsed.error === true || + (typeof parsed.error === 'string' && parsed.error.length > 0) || + rawOutput.toLowerCase().includes('schema mismatch') + ); + } catch { + const lower = rawOutput.toLowerCase(); + return ( + lower.includes('error') || + lower.includes('failed') || + lower.includes('exception') || + lower.includes('schema mismatch') + ); + } + } + + /** + * Detect if content is documentation + */ + private isDocumentationContent(rawOutput: string, toolName: string): boolean { + // Check tool name patterns + const docToolPatterns = [ + 'documentation', + 'docs', + 'fetch', + 'microsoft', + 'azure', + 'guide', + 'tutorial', + 'manual', + 'readme', + ]; + + const toolNameLower = toolName.toLowerCase(); + const isDocTool = docToolPatterns.some(pattern => toolNameLower.includes(pattern)); + + // Check content patterns + const docContentPatterns = [ + /^#{1,6}\s+/m, // Markdown headers + /```[\s\S]*?```/, // Code blocks + /\[.*?\]\(.*?\)/, // Markdown links + /^\s*[-*+]\s+/m, // Lists + /^\s*\d+\.\s+/m, // Numbered lists + /^\s*>\s+/m, // Blockquotes + /\*\*[^*]+\*\*/, // Bold text + /\*[^*]+\*/, // Italic text + /`[^`]+`/, // Inline code + ]; + + const contentMatches = docContentPatterns.filter(pattern => pattern.test(rawOutput)).length; + + // Check for common documentation keywords + const docKeywords = [ + 'prerequisites', + 'installation', + 'configuration', + 'getting started', + 'tutorial', + 'example', + 'usage', + 'overview', + 'introduction', + 'documentation', + 'azure', + 'microsoft', + 'learn.microsoft.com', + ]; + + const contentLower = rawOutput.toLowerCase(); + const keywordMatches = docKeywords.filter(keyword => contentLower.includes(keyword)).length; + + // Consider it documentation if: + // 1. Tool name suggests documentation OR + // 2. Multiple markdown patterns + documentation keywords OR + // 3. Very large content with some doc patterns (likely fetched docs) + return ( + isDocTool || + (contentMatches >= 3 && keywordMatches >= 2) || + (rawOutput.length > 20000 && contentMatches >= 2) + ); + } + + /** + * Parse AI response and validate structure + */ + private parseAIResponse( + aiResponse: string, + metadata: { toolName: string; responseSize: number; processingTime: number } + ): FormattedMCPOutput { + try { + // Extract JSON from response (handle potential markdown wrapping) + const jsonMatch = + aiResponse.match(/```json\n?([\s\S]*?)\n?```/) || aiResponse.match(/\{[\s\S]*\}/); + + if (!jsonMatch) { + throw new Error('No JSON found in AI response'); + } + + const parsed = JSON.parse(jsonMatch[1] || jsonMatch[0]); + + // Validate required fields and add metadata + const formatted: FormattedMCPOutput = { + type: parsed.type || 'text', + title: parsed.title || `${metadata.toolName} Output`, + summary: parsed.summary || 'Analysis completed', + data: parsed.data || { content: aiResponse }, + insights: parsed.insights || [], + warnings: parsed.warnings || [], + actionable_items: parsed.actionable_items || [], + metadata: { + ...metadata, + dataPoints: this.estimateDataPoints(parsed.data), + }, + }; + + return formatted; + } catch (error) { + console.error('Error parsing AI response:', error); + throw error; + } + } + + /** + * Create fallback formatting when AI processing fails + */ + private createFallbackFormat( + rawOutput: string, + toolName: string, + processingTime: number + ): FormattedMCPOutput { + // Try to detect if it's JSON + let data: any; + let type: FormattedMCPOutput['type'] = 'text'; + const warnings: string[] = ['AI formatting failed - showing raw output']; + + try { + const parsed = JSON.parse(rawOutput); + + if (Array.isArray(parsed)) { + type = 'list'; + data = { + items: parsed.slice(0, 100).map((item, index) => ({ + text: typeof item === 'string' ? item : JSON.stringify(item), + status: 'normal', + metadata: `Item ${index + 1}`, + })), + }; + } else if (typeof parsed === 'object') { + type = 'text'; + data = { + content: JSON.stringify(parsed, null, 2), + language: 'json', + }; + } else { + type = 'text'; + data = { content: String(parsed) }; + } + } catch { + // Not JSON, treat as text + type = 'text'; + + // Check if this is documentation content + const isDocumentation = this.isDocumentationContent(rawOutput, toolName); + + // Use higher limits for documentation + const maxLength = isDocumentation ? 15000 : 5000; + + if (rawOutput.length > maxLength) { + const truncatedContent = rawOutput.substring(0, maxLength); + warnings.push(`Content truncated from ${rawOutput.length} to ${maxLength} characters`); + + data = { + content: + truncatedContent + + '\n\n[Content truncated for display. Original size: ' + + rawOutput.length + + ' characters]', + language: isDocumentation ? 'markdown' : 'text', + }; + } else { + data = { + content: rawOutput, + language: isDocumentation ? 'markdown' : 'text', + }; + } + } + + return { + type, + title: `${toolName} Output`, + summary: `Raw output from ${toolName}. AI formatting was not available.`, + data, + insights: [], + warnings, + actionable_items: ['Consider checking the AI service connection'], + metadata: { + toolName, + responseSize: rawOutput.length, + processingTime, + dataPoints: this.estimateDataPoints(data), + }, + }; + } + + /** + * Truncate output if too large for AI processing + */ + private truncateIfNeeded(output: string, maxLength: number): string { + if (output.length <= maxLength) { + return output; + } + + // Try to truncate at a reasonable boundary + const truncated = output.substring(0, maxLength); + const lastNewline = truncated.lastIndexOf('\n'); + const lastBrace = truncated.lastIndexOf('}'); + const lastBracket = truncated.lastIndexOf(']'); + + // Use the best boundary we can find + const cutPoint = Math.max(lastNewline, lastBrace, lastBracket); + + return cutPoint > maxLength * 0.8 ? output.substring(0, cutPoint) : truncated; + } + + /** + * Estimate number of data points in the formatted data + */ + private estimateDataPoints(data: any): number { + if (!data) return 0; + + if (Array.isArray(data)) { + return data.length; + } + + if (data.rows && Array.isArray(data.rows)) { + return data.rows.length; + } + + if (data.items && Array.isArray(data.items)) { + return data.items.length; + } + + if (data.primary && Array.isArray(data.primary)) { + return data.primary.length + (data.secondary?.length || 0); + } + + if (typeof data === 'object') { + return Object.keys(data).length; + } + + return 1; + } + + /** + * Quick format for simple cases without AI processing + */ + formatSimple(rawOutput: string, toolName: string): FormattedMCPOutput { + return this.createFallbackFormat(rawOutput, toolName, 0); + } +} diff --git a/ai-assistant/src/langchain/tools/ToolBase.ts b/ai-assistant/src/langchain/tools/ToolBase.ts index 4bce65702..9cd56e2de 100644 --- a/ai-assistant/src/langchain/tools/ToolBase.ts +++ b/ai-assistant/src/langchain/tools/ToolBase.ts @@ -4,6 +4,7 @@ import { Prompt } from '../../ai/manager'; export interface ToolConfig { name: string; + shortDescription: string; description: string; schema: z.ZodSchema; } diff --git a/ai-assistant/src/langchain/tools/ToolManager.ts b/ai-assistant/src/langchain/tools/ToolManager.ts index d13b41132..50a643255 100644 --- a/ai-assistant/src/langchain/tools/ToolManager.ts +++ b/ai-assistant/src/langchain/tools/ToolManager.ts @@ -1,5 +1,8 @@ import { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import { DynamicTool } from '@langchain/core/tools'; import { Prompt } from '../../ai/manager'; +import { ElectronMCPClient } from '../../ai/mcp/electron-client'; +import { MCPOutputFormatter } from '../formatters/MCPOutputFormatter'; import { KubernetesTool, KubernetesToolContext } from './kubernetes'; import { AVAILABLE_TOOLS, getToolByName } from './registry'; import { ToolBase, ToolResponse } from './ToolBase'; @@ -7,19 +10,28 @@ import { ToolBase, ToolResponse } from './ToolBase'; export class ToolManager { private tools: ToolBase[] = []; private toolHandlers: Map = new Map(); + private mcpTools: any[] = []; + private mcpToolsInitialized: boolean = false; + private mcpInitializationPromise: Promise | null = null; + private boundModel: BaseChatModel | null = null; + private providerId: string | null = null; + private mcpFormatter: MCPOutputFormatter | null = null; + private mcpClient: ElectronMCPClient; - constructor(enabledToolIds?: string[]) { + constructor(private kubernetesContext?: KubernetesToolContext, enabledToolIds?: string[]) { + this.mcpClient = new ElectronMCPClient(); this.initializeTools(enabledToolIds); + this.mcpInitializationPromise = this.initializeMCPTools(); } /** * Initialize only enabled tools from the registry */ private initializeTools(enabledToolIds?: string[]): void { + // Initialize regular tools first for (const ToolClass of AVAILABLE_TOOLS) { const tempTool = new ToolClass(); if (enabledToolIds && !enabledToolIds.includes(tempTool.config.name)) { - console.log('AI Assistant: Skipping tool (disabled)', tempTool.config.name); continue; // Skip tools not enabled } try { @@ -29,7 +41,351 @@ export class ToolManager { console.error(`Failed to load tool ${ToolClass.name}:`, error); } } + + // Initialize MCP tools asynchronously but start immediately + this.initializeMCPTools(); + } + + /** + * Initialize MCP tools from Electron main process + */ + private async initializeMCPTools(): Promise { + try { + if (!this.mcpClient.isAvailable()) { + this.mcpToolsInitialized = true; + return; + } + + // Get tools configuration (source of truth) + const toolsConfigResponse = await this.mcpClient.getToolsConfig(); + + if (!toolsConfigResponse.success || !toolsConfigResponse.config) { + this.mcpToolsInitialized = true; + return; + } + + // Create tools from configuration + const mcpToolsData: any[] = []; + Object.entries(toolsConfigResponse.config).forEach( + ([serverName, serverTools]: [string, any]) => { + Object.entries(serverTools).forEach(([toolName, toolConfig]: [string, any]) => { + const fullToolName = `${serverName}__${toolName}`; + mcpToolsData.push({ + name: fullToolName, + description: toolConfig.description || `Tool: ${toolName} from ${serverName} server`, + inputSchema: toolConfig.inputSchema || {}, // Use the actual schema from MCP config + server: serverName, + enabled: toolConfig.enabled !== false, + }); + }); + } + ); + + // Filter MCP tools using the MCP configuration (not legacy enabledToolIds) + const filteredMcpTools: typeof mcpToolsData = []; + + for (const toolData of mcpToolsData) { + // MCP tools are controlled by their own configuration system, not legacy enabledToolIds + // Check if tool is enabled in MCP configuration + if (!toolData.enabled) { + continue; + } + + filteredMcpTools.push(toolData); + } + + if (filteredMcpTools.length > 0) { + // Convert MCP tools to LangChain DynamicTool format + this.mcpTools = filteredMcpTools.map( + toolData => + new DynamicTool({ + name: toolData.name, + description: toolData.description || `MCP tool: ${toolData.name}`, + schema: toolData.inputSchema, + func: async (args: any) => { + try { + // Handle argument mapping for MCP tools + // LangChain may wrap args in different formats, need to handle properly + const mappedArgs = this.mapMCPToolArguments(args, toolData.inputSchema); + + // Execute MCP tool through Electron API + const result = await window.desktopApi?.mcp.executeTool( + toolData.name, + mappedArgs + ); + // Extract actual result from MCP response + const actualResult = result?.result || result; + + // Ensure we return a string response + const response = + typeof actualResult === 'string' ? actualResult : JSON.stringify(actualResult); + return response; + } catch (error) { + console.error(`Error executing MCP tool ${toolData.name}:`, error); + throw error; + } + }, + }) + ); + + this.mcpToolsInitialized = true; + + // If we have a bound model, rebind with the new MCP tools + if (this.boundModel && this.providerId) { + this.boundModel = this.bindToModel(this.boundModel, this.providerId); + } + } else { + this.mcpToolsInitialized = true; + } + } catch (error) { + this.mcpToolsInitialized = true; + // Continue without MCP tools - this is not a fatal error + } + } + + /** + * Map LangChain tool arguments to MCP tool expected format + * Handles common argument wrapping patterns and schema mismatches + */ + private mapMCPToolArguments(args: any, inputSchema?: any): any { + if (!inputSchema) { + // If no schema, return args as-is + return args; + } + + const schemaProps = inputSchema?.properties; + + // Handle tools that expect no parameters + if (!schemaProps || Object.keys(schemaProps).length === 0) { + // Return empty object for tools that expect no parameters + return {}; + } + + // Handle empty/null/undefined args + if ( + !args || + args === '' || + args === '""' || + (typeof args === 'object' && Object.keys(args).length === 0) + ) { + // Check if the tool has required parameters + const requiredProps = inputSchema?.required || []; + if (requiredProps.length === 0) { + // Tool has no required parameters, safe to return empty object + return {}; + } else { + // Tool has required parameters but got empty args - create default structure + return this.createDefaultParameterStructure(inputSchema); + } + } + + // First, check if args is properly structured for the schema + if (args && typeof args === 'object') { + // Remove any non-schema fields like 'input' that shouldn't be there + const schemaPropertyNames = Object.keys(schemaProps); + const cleanArgs: any = {}; + + // Copy only fields that exist in the schema + for (const [key, value] of Object.entries(args)) { + if (schemaPropertyNames.includes(key)) { + cleanArgs[key] = value; + } + } + + // If we found valid schema fields, use the cleaned args + if (Object.keys(cleanArgs).length > 0) { + return this.filterMCPArguments(cleanArgs, inputSchema); + } + + // If no valid schema fields found, check for 'input' wrapper + if ('input' in args && !schemaProps.input) { + const inputValue = args.input; + + // Handle empty input + if (!inputValue || inputValue === '' || inputValue === '""') { + const requiredProps = inputSchema?.required || []; + if (requiredProps.length === 0) { + return {}; + } + } + + // If the input is a primitive value and the schema has only one property + if ( + schemaPropertyNames.length === 1 && + (typeof inputValue === 'string' || + typeof inputValue === 'number' || + typeof inputValue === 'boolean') + ) { + // Map the primitive value to the single expected property + return { [schemaPropertyNames[0]]: inputValue }; + } + + // If the input is an object, try to unwrap it + if (typeof inputValue === 'object' && inputValue !== null) { + return this.filterMCPArguments(inputValue, inputSchema); + return this.filterMCPArguments(inputValue, inputSchema); + } + + // For primitive values with multiple schema properties, try common mappings + if (typeof inputValue === 'string') { + // Try common parameter names + if (schemaProps.query) { + return { query: inputValue }; + } + if (schemaProps.path) { + return { path: inputValue }; + } + if (schemaProps.directory) { + return { directory: inputValue }; + } + if (schemaProps.file) { + return { file: inputValue }; + } + if (schemaProps.name) { + return { name: inputValue }; + } + + // If no common mapping found, map to first required property, then first property + const requiredProps = inputSchema?.required || []; + const targetProp = requiredProps.length > 0 ? requiredProps[0] : schemaPropertyNames[0]; + if (targetProp) { + return { [targetProp]: inputValue }; + } + } + + // Return the unwrapped input and let the tool handle validation + return inputValue; + return inputValue; + } + } + + // If args structure matches or is already properly formatted, filter and return + return this.filterMCPArguments(args, inputSchema); } + + /** + * Filter MCP arguments to only include required fields and fields with actual values + */ + private filterMCPArguments(args: any, inputSchema?: any): any { + if (!inputSchema || !args || typeof args !== 'object') { + return args; + } + + const schemaProps = inputSchema?.properties; + const requiredProps = inputSchema?.required || []; + + if (!schemaProps) { + return args; + } + + const filteredArgs: any = {}; + + // Always include all required properties, even if they have empty/default values + for (const requiredProp of requiredProps) { + if (requiredProp in args) { + filteredArgs[requiredProp] = args[requiredProp]; + } else { + // If required property is missing, add a default value based on schema + const propSchema = schemaProps[requiredProp]; + if (propSchema) { + if (propSchema.type === 'string') { + filteredArgs[requiredProp] = propSchema.default || ''; + } else if (propSchema.type === 'number' || propSchema.type === 'integer') { + filteredArgs[requiredProp] = propSchema.default || 0; + } else if (propSchema.type === 'boolean') { + filteredArgs[requiredProp] = propSchema.default || false; + } else if (propSchema.type === 'array') { + filteredArgs[requiredProp] = propSchema.default || []; + } else if (propSchema.type === 'object') { + filteredArgs[requiredProp] = propSchema.default || {}; + } else { + filteredArgs[requiredProp] = propSchema.default || {}; + } + } + } + } + + // Include optional properties only if they have actual values + for (const [key, value] of Object.entries(args)) { + // Skip if already included as required + if (requiredProps.includes(key)) { + continue; + } + + // Skip if property is not in schema + if (!(key in schemaProps)) { + continue; + } + + // Include only if value is meaningful (not empty string, null, undefined, or empty object/array) + if (this.hasActualValue(value)) { + filteredArgs[key] = value; + } + } + return filteredArgs; + } + + /** + * Check if a value is meaningful (not empty/null/undefined) + */ + private hasActualValue(value: any): boolean { + // Null, undefined, or empty string are not actual values + if (value === null || value === undefined || value === '') { + return false; + } + + // Empty arrays are not actual values + if (Array.isArray(value)) { + return value.length > 0; + } + + // Empty objects are not actual values + if (typeof value === 'object') { + return Object.keys(value).length > 0; + } + + // Numbers (including 0), booleans, and non-empty strings are actual values + return true; + } + + /** + * Create default parameter structure based on schema + * For required parameters that are missing, provide appropriate defaults + */ + private createDefaultParameterStructure(inputSchema: any): any { + if (!inputSchema || !inputSchema.properties) { + return {}; + } + + const defaultParams: any = {}; + const requiredProps = inputSchema.required || []; + const schemaProps = inputSchema.properties; + + for (const requiredProp of requiredProps) { + if (requiredProp in schemaProps) { + const propSchema = schemaProps[requiredProp]; + + // Create appropriate default values based on type + if (propSchema.type === 'string') { + defaultParams[requiredProp] = propSchema.default || ''; + } else if (propSchema.type === 'number' || propSchema.type === 'integer') { + defaultParams[requiredProp] = propSchema.default || 0; + } else if (propSchema.type === 'boolean') { + defaultParams[requiredProp] = propSchema.default || false; + } else if (propSchema.type === 'array') { + defaultParams[requiredProp] = propSchema.default || []; + } else if (propSchema.type === 'object') { + defaultParams[requiredProp] = propSchema.default || {}; + } else { + // For unknown types, try to use the default or provide an empty object + defaultParams[requiredProp] = propSchema.default || {}; + } + } + } + + return defaultParams; + } + /** * Configure external dependencies for tools that need them */ @@ -67,10 +423,31 @@ export class ToolManager { } /** - * Get all configured tools as LangChain tools + * Get all configured tools as LangChain tools (including MCP tools) */ getLangChainTools() { - return this.tools.map(tool => tool.createLangChainTool()); + const regularTools = this.tools.map(tool => tool.createLangChainTool()); + return [...regularTools, ...this.mcpTools]; + } + + /** + * Get all MCP tools as LangChain tools + */ + getMCPTools() { + return this.mcpTools; + } + + /** + * Check if a specific tool is configured (including MCP tools) + */ + hasTool(toolName: string): boolean { + // Check regular tools first + if (this.toolHandlers.has(toolName)) { + return true; + } + + // Check MCP tools + return this.mcpTools.some(tool => tool.name === toolName); } /** @@ -93,12 +470,131 @@ export class ToolManager { metadata: { error: 'tool_disabled', toolName }, }; } - const tool = this.toolHandlers.get(toolName); - if (!tool) { - throw new Error(`Tool ${toolName} not found`); + + // Check if it's a regular tool first + const regularTool = this.toolHandlers.get(toolName); + if (regularTool) { + return await regularTool.handler(args, toolCallId, pendingPrompt); } - return await tool.handler(args, toolCallId, pendingPrompt); + // Check if it's an MCP tool + const mcpTool = this.mcpTools.find(tool => tool.name === toolName); + if (mcpTool) { + try { + const rawResult = await mcpTool.func(args); + + // Check if the raw result indicates an error + const isError = this.detectMCPError(rawResult); + + // Format the MCP output using AI if formatter is available + let formattedContent = rawResult; + if (this.mcpFormatter) { + try { + const formatted = await this.mcpFormatter.formatMCPOutput(rawResult, toolName); + formattedContent = JSON.stringify({ + formatted: true, + mcpOutput: formatted, + raw: rawResult, + isError: isError || formatted.type === 'error', + originalArgs: args, // Include original arguments in the formatted content for retry functionality + }); + } catch (formatError) { + console.warn(`Failed to format MCP output for ${toolName}:`, formatError); + // Fall back to simple formatting + const simpleFormatted = this.mcpFormatter.formatSimple(rawResult, toolName); + formattedContent = JSON.stringify({ + formatted: true, + mcpOutput: simpleFormatted, + raw: rawResult, + isError: isError || simpleFormatted.type === 'error', + originalArgs: args, // Include original arguments in the fallback formatting too + }); + } + } else { + // No formatter available, but still detect errors + if (isError) { + formattedContent = JSON.stringify({ + error: true, + message: this.extractErrorMessage(rawResult), + toolName, + raw: rawResult, + originalArgs: args, // Include original arguments for error cases too + }); + } + } + + return { + content: formattedContent, + shouldAddToHistory: true, + shouldProcessFollowUp: false, + metadata: { + toolName, + source: 'mcp', + formatted: !!this.mcpFormatter, + isError: + isError || + (this.mcpFormatter && JSON.parse(formattedContent).mcpOutput?.type === 'error'), + originalArgs: args, // Store original arguments for retry functionality + }, + }; + } catch (error) { + console.error(`Error executing MCP tool ${toolName}:`, error); + + // Format execution errors properly if formatter is available + let errorContent; + if (this.mcpFormatter) { + const errorFormatted = { + type: 'error' as const, + title: `Tool Execution Failed: ${toolName}`, + summary: 'The MCP tool failed to execute due to an internal error.', + data: { + message: 'Tool execution failed', + details: error instanceof Error ? error.message : 'Unknown execution error', + suggestions: [ + 'Check if the tool is properly configured and accessible', + 'Verify the input parameters match the tool requirements', + 'Try again in a few moments as this may be a temporary issue', + ], + }, + insights: ['Tool execution errors may indicate configuration or connectivity issues'], + warnings: ['This tool is currently unavailable'], + actionable_items: ['Review tool configuration and try again'], + metadata: { + toolName, + responseSize: 0, + processingTime: 0, + dataPoints: 0, + }, + }; + + errorContent = JSON.stringify({ + formatted: true, + mcpOutput: errorFormatted, + raw: error instanceof Error ? error.message : 'Unknown error', + isError: true, + originalArgs: args, // Include original arguments in error formatting too + }); + } else { + errorContent = JSON.stringify({ + error: true, + message: `Error executing MCP tool: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + toolName, + originalArgs: args, // Include original arguments in simple error format too + }); + } + + return { + content: errorContent, + shouldAddToHistory: true, + shouldProcessFollowUp: false, + metadata: { error: 'mcp_execution_error', toolName, isError: true }, + }; + } + } + + throw new Error(`Tool ${toolName} not found`); } /** @@ -106,30 +602,217 @@ export class ToolManager { */ bindToModel(model: BaseChatModel, providerId: string): BaseChatModel { try { + // Store for potential rebinding when MCP tools are loaded + this.providerId = providerId; + + // Initialize MCP formatter with the model + if (!this.mcpFormatter) { + this.mcpFormatter = new MCPOutputFormatter(model); + } + const langChainTools = this.getLangChainTools(); if (langChainTools.length === 0) { console.warn('No tools configured for binding'); + this.boundModel = model; return model; } - - return model.bindTools(langChainTools); + this.boundModel = model.bindTools(langChainTools); + return this.boundModel; } catch (error) { console.error(`Error binding tools to ${providerId} model:`, error); + this.boundModel = model; return model; } } /** - * Get list of all configured tool names + * Bind all tools to a LangChain model, waiting for MCP tools to initialize first + */ + async bindToModelAsync(model: BaseChatModel, providerId: string): Promise { + // Wait for MCP tools to initialize before binding + await this.waitForMCPToolsInitialization(); + + return this.bindToModel(model, providerId); + } + + /** + * Wait for MCP tools to be initialized + */ + async waitForMCPToolsInitialization(): Promise { + if (this.mcpInitializationPromise) { + await this.mcpInitializationPromise; + } + } + + /** + * Check if MCP tools are initialized + */ + areMCPToolsInitialized(): boolean { + return this.mcpToolsInitialized; + } + + /** + * Get list of all configured tool names (including MCP tools) */ getToolNames(): string[] { - return this.tools.map(tool => tool.config.name); + const regularToolNames = this.tools.map(tool => tool.config.name); + const mcpToolNames = this.mcpTools.map(tool => tool.name); + return [...regularToolNames, ...mcpToolNames]; } /** - * Check if a specific tool is configured + * Get MCP client instance for configuration management */ - hasTool(toolName: string): boolean { - return this.toolHandlers.has(toolName); + getMCPClient(): ElectronMCPClient { + return this.mcpClient; + } + + /** + * Enable or disable an MCP tool + */ + async setMCPToolEnabled(toolName: string, enabled: boolean): Promise { + if (!this.mcpClient.isAvailable()) { + return false; + } + + const { serverName, toolName: actualToolName } = this.mcpClient.parseToolName(toolName); + const result = await this.mcpClient.setToolEnabled(serverName, actualToolName, enabled); + + if (result) { + // Reinitialize MCP tools to reflect the change + this.mcpToolsInitialized = false; + this.mcpInitializationPromise = this.initializeMCPTools(); + await this.mcpInitializationPromise; + + // If we have a bound model, rebind with the updated tools + if (this.boundModel && this.providerId) { + this.boundModel = this.bindToModel(this.boundModel, this.providerId); + } + } + + return result; + } + + /** + * Check if an MCP tool is enabled + */ + async isMCPToolEnabled(toolName: string): Promise { + if (!this.mcpClient.isAvailable()) { + return true; + } + return await this.mcpClient.isToolEnabled(toolName); + } + + /** + * Get MCP tool statistics + */ + async getMCPToolStats(toolName: string): Promise { + if (!this.mcpClient.isAvailable()) { + return null; + } + + const { serverName, toolName: actualToolName } = this.mcpClient.parseToolName(toolName); + return await this.mcpClient.getToolStats(serverName, actualToolName); + } + + /** + * Get MCP tools configuration + */ + async getMCPToolsConfig(): Promise<{ success: boolean; config?: any; error?: string }> { + if (!this.mcpClient.isAvailable()) { + return { + success: false, + error: 'MCP client not available - not running in Electron environment', + }; + } + return await this.mcpClient.getToolsConfig(); + } + + /** + * Update MCP tools configuration + */ + async updateMCPToolsConfig(config: any): Promise { + if (!this.mcpClient.isAvailable()) { + return false; + } + + const result = await this.mcpClient.updateToolsConfig(config); + + if (result) { + // Reinitialize MCP tools to reflect the changes + this.mcpToolsInitialized = false; + this.mcpInitializationPromise = this.initializeMCPTools(); + await this.mcpInitializationPromise; + + // If we have a bound model, rebind with the updated tools + if (this.boundModel && this.providerId) { + this.boundModel = this.bindToModel(this.boundModel, this.providerId); + } + } + + return result; + } + + /** + * Detect if an MCP tool result indicates an error + */ + private detectMCPError(result: string): boolean { + try { + const parsed = JSON.parse(result); + + // Check for explicit error indicators + if (parsed.success === false || parsed.error === true) { + return true; + } + + // Check for error messages + if (parsed.error || parsed.message?.toLowerCase().includes('error')) { + return true; + } + + // Check for schema mismatch or other common error patterns + if (typeof parsed.error === 'string' && parsed.error.length > 0) { + return true; + } + + return false; + } catch { + // If not JSON, check for common error patterns in the string + const lowerResult = result.toLowerCase(); + return ( + lowerResult.includes('error') || + lowerResult.includes('failed') || + lowerResult.includes('exception') || + lowerResult.includes('invalid') || + lowerResult.includes('schema mismatch') + ); + } + } + + /** + * Extract error message from MCP tool result + */ + private extractErrorMessage(result: string): string { + try { + const parsed = JSON.parse(result); + + // Try various fields that might contain the error message + if (parsed.error && typeof parsed.error === 'string') { + return parsed.error; + } + + if (parsed.message) { + return parsed.message; + } + + if (parsed.details) { + return parsed.details; + } + + return 'Tool execution failed with an unspecified error'; + } catch { + // Not JSON, return the raw result or a cleaned version + return result.length > 200 ? result.substring(0, 200) + '...' : result; + } } } diff --git a/ai-assistant/src/langchain/tools/kubernetes/KubernetesTool.ts b/ai-assistant/src/langchain/tools/kubernetes/KubernetesTool.ts index 0f5695d0b..ad190de8f 100644 --- a/ai-assistant/src/langchain/tools/kubernetes/KubernetesTool.ts +++ b/ai-assistant/src/langchain/tools/kubernetes/KubernetesTool.ts @@ -5,6 +5,7 @@ import { KubernetesToolContext } from './types'; export class KubernetesTool extends ToolBase { readonly config: ToolConfig = { name: 'kubernetes_api_request', + shortDescription: 'Make requests to the Kubernetes API', description: `Make requests to the Kubernetes API server to fetch, create, update or delete resources. RESOURCE UPDATE GUIDELINES: @@ -94,16 +95,9 @@ LOG HANDLING FOR MULTI-CONTAINER PODS: pendingPrompt ): Promise => { if (!this.context) { - console.error('Kubernetes tool context not configured'); throw new Error('Kubernetes tool context not configured'); } - console.log(`Processing kubernetes_api_request tool: ${method} ${url}`, { - hasContext: !!this.context, - toolCallId, - selectedClusters: this.context?.selectedClusters, - }); - // For GET requests, we can execute them immediately using the API helper if (method.toUpperCase() === 'GET') { try { diff --git a/ai-assistant/src/modal.tsx b/ai-assistant/src/modal.tsx index 1882f2081..ca9eebb75 100644 --- a/ai-assistant/src/modal.tsx +++ b/ai-assistant/src/modal.tsx @@ -2,7 +2,6 @@ import { Icon } from '@iconify/react'; import { useClustersConf, useSelectedClusters } from '@kinvolk/headlamp-plugin/lib/k8s'; import { getCluster, getClusterGroup } from '@kinvolk/headlamp-plugin/lib/Utils'; import { Box, Button, Grid, Typography } from '@mui/material'; -import { isEqual } from 'lodash'; import React, { useEffect, useMemo, useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import AIManager, { Prompt } from './ai/manager'; @@ -21,6 +20,7 @@ import { useKubernetesToolUI } from './hooks/useKubernetesToolUI'; import LangChainManager from './langchain/LangChainManager'; import { getSettingsURL, useGlobalState } from './utils'; import { generateContextDescription } from './utils/contextGenerator'; +import { inlineToolApprovalManager } from './utils/InlineToolApprovalManager'; import { getProviderModels, parseSuggestionsFromResponse } from './utils/modalUtils'; import { useDynamicPrompts } from './utils/promptGenerator'; @@ -31,19 +31,20 @@ const OPERATION_TYPES = { DELETION: 'deletion', GENERIC: 'operation', } as const; +import { usePromptWidth } from './contexts/PromptWidthContext'; import { getActiveConfig, getSavedConfigurations, StoredProviderConfig, } from './utils/ProviderConfigManager'; -import { getEnabledToolIds } from './utils/ToolConfigManager'; export default function AIPrompt(props: { openPopup: boolean; setOpenPopup: (...args) => void; pluginSettings: any; + width: string; }) { - const { openPopup, setOpenPopup, pluginSettings } = props; + const { openPopup, setOpenPopup, pluginSettings, width } = props; const history = useHistory(); const location = useLocation(); const rootRef = React.useRef(null); @@ -52,11 +53,17 @@ export default function AIPrompt(props: { const [apiError, setApiError] = React.useState(null); const [aiManager, setAiManager] = React.useState(null); const _pluginSetting = useGlobalState(); + const { enabledTools, setEnabledTools } = _pluginSetting; const [promptHistory, setPromptHistory] = React.useState([]); const [suggestions, setSuggestions] = React.useState([]); const selectedClusters = useSelectedClusters(); const clusters = useClustersConf() || {}; const dynamicPrompts = useDynamicPrompts(); + const prompWidthContext = usePromptWidth(); + + useEffect(() => { + prompWidthContext.setPromptWidth(width); + }, [width]); // Get cluster names for warning lookup - use selected clusters or current cluster only const clusterNames = useMemo(() => { const currentCluster = getCluster(); @@ -80,10 +87,6 @@ export default function AIPrompt(props: { const [activeConfig, setActiveConfig] = useState(null); const [availableConfigs, setAvailableConfigs] = useState([]); - const [enabledTools, setEnabledTools] = React.useState( - getEnabledToolIds(pluginSettings) - ); - // Test mode detection const isTestMode = isTestModeCheck(); @@ -290,23 +293,11 @@ export default function AIPrompt(props: { } }, [enabledTools, activeConfig, selectedModel]); - React.useEffect(() => { - // Only set if different - setEnabledTools(currentlyEnabledTools => { - const newEnabledTools = getEnabledToolIds(pluginSettings); - if (isEqual(currentlyEnabledTools, newEnabledTools)) { - return currentlyEnabledTools; - } - return newEnabledTools; - }); - }, [pluginSettings]); - const updateHistory = React.useCallback(() => { if (!aiManager?.history) { setPromptHistory([]); return; } - // Process the history to extract suggestions and clean content const processedHistory = aiManager.history.map((prompt, index) => { if (prompt.role === 'assistant' && prompt.content && !prompt.error) { @@ -333,34 +324,37 @@ export default function AIPrompt(props: { const { state: kubernetesUI, callbacks: kubernetesCallbacks } = useKubernetesToolUI(updateHistory); - const handleOperationSuccess = React.useCallback( - (response: any) => { - // Add the response to the conversation - const operationType = response.metadata?.deletionTimestamp ? 'deletion' : 'application'; + // Set up event listeners for tool confirmation events + React.useEffect(() => { + const handleRequestConfirmation = () => { + // Clear loading state when tool approval is requested + setLoading(false); + // Force an immediate update of the history from the AI manager + updateHistory(); + // Also force a re-render by updating the state + setPromptHistory(prev => [...prev]); + }; - const toolPrompt: Prompt = { - role: 'tool', - content: `Resource ${operationType} completed successfully: ${JSON.stringify( - { - kind: response.kind, - name: response.metadata.name, - namespace: response.metadata.namespace, - status: 'Success', - }, - null, - 2 - )}`, - name: 'kubernetes_api_request', - toolCallId: `${operationType}-${Date.now()}`, - }; + const handleUpdateConfirmation = () => { + updateHistory(); + setPromptHistory(prev => [...prev]); + }; - if (aiManager) { - aiManager.history.push(toolPrompt); - updateHistory(); - } - }, - [aiManager, updateHistory] - ); + const handleMessageUpdated = () => { + updateHistory(); + setPromptHistory(prev => [...prev]); + }; + + inlineToolApprovalManager.on('request-confirmation', handleRequestConfirmation); + inlineToolApprovalManager.on('update-confirmation', handleUpdateConfirmation); + inlineToolApprovalManager.on('message-updated', handleMessageUpdated); + + return () => { + inlineToolApprovalManager.removeListener('request-confirmation', handleRequestConfirmation); + inlineToolApprovalManager.removeListener('update-confirmation', handleUpdateConfirmation); + inlineToolApprovalManager.removeListener('message-updated', handleMessageUpdated); + }; + }, [updateHistory]); const handleOperationFailure = React.useCallback( (error: any, operationType: string, resourceInfo?: any) => { @@ -424,16 +418,32 @@ export default function AIPrompt(props: { // Function to handle test mode responses const handleTestModeResponse = ( - content: string, + content: string | object, type: 'assistant' | 'user', hasError?: boolean ) => { - const newPrompt: Prompt = { - role: type, - content, - error: hasError || false, - ...(hasError && { contentFilterError: true }), - }; + let newPrompt: Prompt; + + // Handle tool confirmation objects + if (typeof content === 'object' && content && 'toolConfirmation' in content) { + newPrompt = { + role: type, + content: (content as any).content || '', + error: hasError || false, + toolConfirmation: (content as any).toolConfirmation, + isDisplayOnly: (content as any).isDisplayOnly, + requestId: (content as any).requestId, + ...(hasError && { contentFilterError: true }), + }; + } else { + // Handle regular string content + newPrompt = { + role: type, + content: typeof content === 'string' ? content : JSON.stringify(content), + error: hasError || false, + ...(hasError && { contentFilterError: true }), + }; + } setPromptHistory(prev => [...prev, newPrompt]); setOpenPopup(true); @@ -494,6 +504,66 @@ export default function AIPrompt(props: { } } + // Function to handle tool retry + const handleRetryTool = React.useCallback( + async (toolName: string, args: Record) => { + if (!aiManager) { + console.error('Cannot retry tool: aiManager not available'); + return; + } + + try { + // Get the tool manager from the LangChain manager + const toolManager = (aiManager as any).toolManager; + if (!toolManager) { + console.error('Cannot retry tool: toolManager not available'); + return; + } + + // Execute the tool directly + const toolResponse = await toolManager.executeTool(toolName, args); + + // Add the retry result to the conversation history + const retryPrompt: Prompt = { + role: 'tool', + content: toolResponse.content, + toolCallId: `retry-${Date.now()}`, + name: toolName, + }; + + aiManager.history.push(retryPrompt); + updateHistory(); + + // If the tool should process follow-up, trigger that + if (toolResponse.shouldProcessFollowUp) { + await aiManager.processToolResponses(); + updateHistory(); + } + } catch (error) { + console.error(`Error retrying tool ${toolName}:`, error); + + // Add error to conversation + const errorPrompt: Prompt = { + role: 'tool', + content: JSON.stringify({ + error: true, + message: `Failed to retry tool: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + toolName, + }), + toolCallId: `retry-error-${Date.now()}`, + name: toolName, + error: true, + }; + + aiManager.history.push(errorPrompt); + updateHistory(); + } + }, + [aiManager, updateHistory] + ); + // Function to stop the current request const handleStopRequest = () => { if (aiManager && loading) { @@ -705,6 +775,13 @@ export default function AIPrompt(props: { height: '100vh', display: 'flex', flexDirection: 'column', + overflow: 'hidden', // Prevent horizontal overflow + maxWidth: '100%', + minWidth: 0, + // Global override for all MUI Grid items to prevent max-width: none + '& .MuiGrid-root > .MuiGrid-item': { + maxWidth: '100% !important', + }, }} > .MuiGrid-item': { + maxWidth: '100% !important', + }, }} > {}} onOperationFailure={handleOperationFailure} onYamlAction={handleYamlAction} + onRetryTool={handleRetryTool} /> { AnalyzeResourceBasedOnPrompt(prompt).catch(error => { setApiError(error.message); @@ -779,6 +871,8 @@ export default function AIPrompt(props: { aiManager?.reset(); updateHistory(); } + // Clear tool approval session when history is cleared + inlineToolApprovalManager.clearSession(); }} onConfigChange={(config, model) => { setActiveConfig(config); @@ -786,6 +880,11 @@ export default function AIPrompt(props: { handleChangeConfig(config, model); }} onTestModeResponse={handleTestModeResponse} + onToolsChange={newEnabledTools => { + setEnabledTools(newEnabledTools); + // Recreate AI manager with new tools + handleChangeConfig(activeConfig, selectedModel); + }} /> @@ -799,7 +898,7 @@ export default function AIPrompt(props: { yamlContent={editorContent} title={editorTitle} resourceType={resourceType} - onSuccess={handleOperationSuccess} + onSuccess={() => {}} onFailure={handleOperationFailure} /> )} diff --git a/ai-assistant/src/textstream.tsx b/ai-assistant/src/textstream.tsx index c32ec957c..9c176a004 100644 --- a/ai-assistant/src/textstream.tsx +++ b/ai-assistant/src/textstream.tsx @@ -4,6 +4,7 @@ import { useTheme } from '@mui/material'; import { alpha } from '@mui/material/styles'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Prompt } from './ai/manager'; +import { InlineToolConfirmation } from './components'; import ContentRenderer from './ContentRenderer'; import EditorDialog from './editordialog'; @@ -14,6 +15,7 @@ const TextStreamContainer = React.memo(function TextStreamContainer({ onOperationSuccess, onOperationFailure, onYamlAction, + onRetryTool, }: { history: Prompt[]; isLoading: boolean; @@ -21,6 +23,8 @@ const TextStreamContainer = React.memo(function TextStreamContainer({ onOperationSuccess?: (response: any) => void; onOperationFailure?: (error: any, operationType: string, resourceInfo?: any) => void; onYamlAction?: (yaml: string, title: string, resourceType: string, isDelete: boolean) => void; + onRetryTool?: (toolName: string, args: Record) => void; + promptWidth?: string; }) { const [showEditor, setShowEditor] = useState(false); const [editorContent, setEditorContent] = useState(''); @@ -236,7 +240,8 @@ const TextStreamContainer = React.memo(function TextStreamContainer({ const isJsonSuccess = prompt.success; if (prompt.content === '' && prompt.role === 'user') return null; - if (prompt.content === '' && prompt.role === 'assistant') return null; + if (prompt.content === '' && prompt.role === 'assistant' && !prompt.toolConfirmation) + return null; return ( {prompt.role === 'user' ? 'You' : 'AI Assistant'} - + {prompt.role === 'user' ? ( prompt.content ) : ( <> {isContentFilterError || hasError ? ( - + {prompt.content} {isContentFilterError && ( @@ -285,11 +319,25 @@ const TextStreamContainer = React.memo(function TextStreamContainer({ ) : ( <> - {/* Use ContentRenderer for all assistant content */} - + {/* Check if this is a tool confirmation message */} + {prompt.toolConfirmation ? ( + + ) : ( + /* Use ContentRenderer for all assistant content */ + + )} )} @@ -302,16 +350,27 @@ const TextStreamContainer = React.memo(function TextStreamContainer({ ); return ( - + {/* Content filter guidance when errors are detected */} diff --git a/ai-assistant/src/types/electron.d.ts b/ai-assistant/src/types/electron.d.ts new file mode 100644 index 000000000..1ab5ad8e7 --- /dev/null +++ b/ai-assistant/src/types/electron.d.ts @@ -0,0 +1,41 @@ +// Type definitions for the Electron desktop API exposed to the renderer process + +interface MCPTool { + name: string; + description?: string; + inputSchema?: any; +} + +interface MCPResponse { + success: boolean; + tools?: MCPTool[]; + result?: any; + error?: string; + toolCallId?: string; +} + +interface ElectronMCPApi { + getTools: () => Promise; + executeTool: ( + toolName: string, + args: Record, + toolCallId?: string + ) => Promise; + getStatus: () => Promise<{ isInitialized: boolean; hasClient: boolean }>; + resetClient: () => Promise; +} + +interface DesktopApi { + send: (channel: string, data: unknown) => void; + receive: (channel: string, func: (...args: unknown[]) => void) => void; + removeListener: (channel: string, func: (...args: unknown[]) => void) => void; + mcp: ElectronMCPApi; +} + +declare global { + interface Window { + desktopApi?: DesktopApi; + } +} + +export { MCPTool, MCPResponse, ElectronMCPApi, DesktopApi }; diff --git a/ai-assistant/src/utils.tsx b/ai-assistant/src/utils.tsx index bf5e242d5..f10e4c3a7 100644 --- a/ai-assistant/src/utils.tsx +++ b/ai-assistant/src/utils.tsx @@ -2,6 +2,7 @@ import { ConfigStore } from '@kinvolk/headlamp-plugin/lib'; import React from 'react'; import { useBetween } from 'use-between'; import { StoredProviderConfig } from './utils/ProviderConfigManager'; +import { initializeToolsState } from './utils/ToolConfigManager'; export const PLUGIN_NAME = '@headlamp-k8s/ai-assistant'; export const getSettingsURL = () => `/settings/plugins/${encodeURIComponent(PLUGIN_NAME)}`; @@ -21,6 +22,36 @@ function usePluginSettings() { // Add state to control UI panel visibility - initialize from stored settings const [isUIPanelOpen, setIsUIPanelOpenState] = React.useState(conf?.isUIPanelOpen ?? false); + // Add state for enabled tools - will be initialized properly using initializeToolsState + const [enabledTools, setEnabledToolsState] = React.useState([]); + const [toolsInitialized, setToolsInitialized] = React.useState(false); + + // Initialize tools state properly on first load + React.useEffect(() => { + if (!toolsInitialized) { + initializeToolsState(conf) + .then(initializedTools => { + setEnabledToolsState(initializedTools); + setToolsInitialized(true); + + // If this is the first time and we have tools to save, save them + if (!conf?.enabledTools && initializedTools.length > 0) { + const currentConf = pluginStore.get() || {}; + pluginStore.update({ + ...currentConf, + enabledTools: initializedTools, + }); + } + }) + .catch(error => { + console.error('Failed to initialize tools state:', error); + // Fallback to existing behavior + setEnabledToolsState(conf?.enabledTools ?? []); + setToolsInitialized(true); + }); + } + }, [conf, toolsInitialized]); + // Wrap setIsUIPanelOpen to also update the stored configuration const setIsUIPanelOpen = (isOpen: boolean) => { setIsUIPanelOpenState(isOpen); @@ -32,6 +63,17 @@ function usePluginSettings() { }); }; + // Wrap setEnabledTools to also update the stored configuration + const setEnabledTools = (tools: string[]) => { + setEnabledToolsState(tools); + // Save the tools configuration + const currentConf = pluginStore.get() || {}; + pluginStore.update({ + ...currentConf, + enabledTools: tools, + }); + }; + return { event, setEvent, @@ -41,7 +83,23 @@ function usePluginSettings() { setActiveProvider, isUIPanelOpen, setIsUIPanelOpen, + enabledTools, + setEnabledTools, + toolsInitialized, }; } export const useGlobalState = () => useBetween(usePluginSettings); + +// Export tool configuration utilities +export { + getAllAvailableTools, + isToolEnabled, + toggleTool, + getAllAvailableToolsIncludingMCP, + getEnabledToolIdsIncludingMCP, + isMCPTool, + parseMCPToolName, + isBuiltInTool, + initializeToolsState, +} from './utils/ToolConfigManager'; diff --git a/ai-assistant/src/utils/InlineToolApprovalManager.ts b/ai-assistant/src/utils/InlineToolApprovalManager.ts new file mode 100644 index 000000000..01a5c5ed1 --- /dev/null +++ b/ai-assistant/src/utils/InlineToolApprovalManager.ts @@ -0,0 +1,272 @@ +import { EventEmitter } from 'events'; +import { ToolCall } from '../ai/manager'; +import { UserContext } from '../components/mcpOutput/MCPArgumentProcessor'; + +export interface InlineToolApprovalRequest { + requestId: string; + toolCalls: ToolCall[]; + resolve: (approvedToolIds: string[]) => void; + reject: (error: Error) => void; + // Reference to the AI manager for adding messages to history + aiManager?: any; + // Callback to update the specific message with loading state + updateMessage?: (loading: boolean) => void; +} + +export class InlineToolApprovalManager extends EventEmitter { + private static instance: InlineToolApprovalManager | null = null; + private pendingRequest: InlineToolApprovalRequest | null = null; + private autoApproveSettings: Map = new Map(); + private sessionAutoApproval: boolean = false; + + private constructor() { + super(); + } + + public static getInstance(): InlineToolApprovalManager { + if (!InlineToolApprovalManager.instance) { + InlineToolApprovalManager.instance = new InlineToolApprovalManager(); + } + return InlineToolApprovalManager.instance; + } + + /** + * Extract user context from AI manager + */ + private extractUserContext(aiManager: any): UserContext { + const userContext: UserContext = { + timeContext: new Date(), + }; + + try { + // Extract user message from history + const history = aiManager.history || []; + const lastUserMessage = history.filter((msg: any) => msg.role === 'user').pop(); + + if (lastUserMessage) { + userContext.userMessage = lastUserMessage.content; + } + + // Extract conversation history (last 5 messages for context) + userContext.conversationHistory = history.slice(-5).map((msg: any) => ({ + role: msg.role, + content: msg.content, + })); + + // Extract kubernetes context if available + if (aiManager.toolManager?.kubernetesContext) { + userContext.kubernetesContext = { + selectedClusters: aiManager.toolManager.kubernetesContext.selectedClusters, + namespace: aiManager.toolManager.kubernetesContext.namespace, + currentResource: aiManager.toolManager.kubernetesContext.currentResource, + }; + } + + // Extract last tool results + const lastToolResults = history.filter((msg: any) => msg.role === 'tool').slice(-3); + + if (lastToolResults.length > 0) { + userContext.lastToolResults = {}; + lastToolResults.forEach((toolMsg: any, index: number) => { + try { + const parsed = JSON.parse(toolMsg.content); + userContext.lastToolResults![`tool_${index}`] = parsed; + } catch { + userContext.lastToolResults![`tool_${index}`] = toolMsg.content; + } + }); + } + } catch (error) { + console.warn('Failed to extract user context:', error); + } + + return userContext; + } + + /** + * Request approval for tool execution via inline chat message + */ + async requestApproval(toolCalls: any[], aiManager: any): Promise { + // Check if session auto-approval is enabled + if (this.sessionAutoApproval) { + return toolCalls.map(tool => tool.id); + } + + // Check for individual tool auto-approvals + const autoApprovedTools: string[] = []; + const needsApprovalTools: ToolCall[] = []; + + for (const tool of toolCalls) { + if (this.autoApproveSettings.get(tool.name)) { + autoApprovedTools.push(tool.id); + } else { + needsApprovalTools.push(tool); + } + } + + // If all tools are auto-approved, return them + if (needsApprovalTools.length === 0) { + return autoApprovedTools; + } + + // If there's already a pending request, reject the previous one + if (this.pendingRequest) { + this.pendingRequest.reject(new Error('Request superseded by new tool approval request')); + } + + // Extract user context for intelligent argument processing + const userContext = this.extractUserContext(aiManager); + + return new Promise((resolve, reject) => { + const requestId = `tool-approval-${Date.now()}-${Math.random()}`; + + const handleApprove = (approvedToolIds: string[]) => { + // Combine auto-approved and manually approved tools + const allApprovedIds = [...autoApprovedTools, ...approvedToolIds]; + + // Update the message to show loading state + if (this.pendingRequest?.updateMessage) { + this.pendingRequest.updateMessage(true); + } + + this.pendingRequest = null; + resolve(allApprovedIds); + }; + + const handleDeny = () => { + this.pendingRequest = null; + reject(new Error('User denied tool execution')); + }; + + const updateMessage = (loading: boolean) => { + // Emit an event to update the UI + if (this.pendingRequest) { + this.emit('update-confirmation', { + requestId: this.pendingRequest.requestId, + toolConfirmation: { + tools: this.pendingRequest.toolCalls, + onApprove: handleApprove, + onDeny: handleDeny, + loading: loading, + requestId: this.pendingRequest.requestId, // Include requestId + userContext: userContext, // Include user context + }, + }); + } + }; + + this.pendingRequest = { + requestId, + toolCalls: needsApprovalTools, + resolve: handleApprove, + reject: handleDeny, + aiManager, + updateMessage, + }; + + // Emit event to add the tool confirmation message to chat history + this.emit('request-confirmation', { + requestId, + toolConfirmation: { + tools: needsApprovalTools, + onApprove: handleApprove, + onDeny: handleDeny, + loading: false, + requestId: requestId, // Include requestId in the tool confirmation + userContext: userContext, // Pass user context for intelligent argument processing + }, + aiManager, + }); + }); + } + + /** + * Approve tools (called from inline confirmation component) + */ + public approveTools(requestId: string, approvedToolIds: string[], rememberChoice = false): void { + if (!this.pendingRequest || this.pendingRequest.requestId !== requestId) { + console.warn('No matching pending request for approval:', requestId); + return; + } + + // Handle remember choice + if (rememberChoice) { + // If all tools were approved, enable session auto-approval + const allToolIds = this.pendingRequest.toolCalls.map(tool => tool.id); + if (approvedToolIds.length === allToolIds.length) { + this.sessionAutoApproval = true; + } else { + // Remember individual tool approvals + for (const toolCall of this.pendingRequest.toolCalls) { + if (approvedToolIds.includes(toolCall.id)) { + this.autoApproveSettings.set(toolCall.name, true); + } + } + } + } + + this.pendingRequest.resolve(approvedToolIds); + } + + /** + * Deny all tools (called from inline confirmation component) + */ + public denyTools(requestId: string): void { + if (!this.pendingRequest || this.pendingRequest.requestId !== requestId) { + console.warn('No matching pending request for denial:', requestId); + return; + } + + this.pendingRequest.reject(new Error('User denied tool execution')); + } + + /** + * Get current pending request + */ + public getPendingRequest(): InlineToolApprovalRequest | null { + return this.pendingRequest; + } + + /** + * Clear session settings (called when user explicitly clears or starts new session) + */ + public clearSession(): void { + this.sessionAutoApproval = false; + this.autoApproveSettings.clear(); + } + + /** + * Set session auto-approval + */ + public setSessionAutoApproval(enabled: boolean): void { + this.sessionAutoApproval = enabled; + } + + /** + * Get session auto-approval status + */ + public isSessionAutoApprovalEnabled(): boolean { + return this.sessionAutoApproval; + } + + /** + * Set auto-approval for a specific tool + */ + public setToolAutoApproval(toolName: string, enabled: boolean): void { + if (enabled) { + this.autoApproveSettings.set(toolName, true); + } else { + this.autoApproveSettings.delete(toolName); + } + } + + /** + * Check if a tool has auto-approval enabled + */ + public isToolAutoApprovalEnabled(toolName: string): boolean { + return this.autoApproveSettings.get(toolName) === true; + } +} + +// Export singleton instance +export const inlineToolApprovalManager = InlineToolApprovalManager.getInstance(); diff --git a/ai-assistant/src/utils/ToolApprovalManager.ts b/ai-assistant/src/utils/ToolApprovalManager.ts new file mode 100644 index 000000000..843e82ddc --- /dev/null +++ b/ai-assistant/src/utils/ToolApprovalManager.ts @@ -0,0 +1,178 @@ +import { EventEmitter } from 'events'; + +export interface ToolCall { + id: string; + name: string; + description?: string; + arguments: Record; + type: 'mcp' | 'regular'; +} + +export interface ToolApprovalRequest { + requestId: string; + toolCalls: ToolCall[]; + resolve: (approvedToolIds: string[]) => void; + reject: (error: Error) => void; +} + +export class ToolApprovalManager extends EventEmitter { + private static instance: ToolApprovalManager | null = null; + private pendingRequest: ToolApprovalRequest | null = null; + private autoApproveSettings: Map = new Map(); + private sessionAutoApproval: boolean = false; + + private constructor() { + super(); + } + + public static getInstance(): ToolApprovalManager { + if (!ToolApprovalManager.instance) { + ToolApprovalManager.instance = new ToolApprovalManager(); + } + return ToolApprovalManager.instance; + } + + /** + * Request approval for tool execution + */ + public async requestApproval(toolCalls: ToolCall[]): Promise { + // Check if session auto-approval is enabled + if (this.sessionAutoApproval) { + return toolCalls.map(tool => tool.id); + } + + // Check for individual tool auto-approvals + const autoApprovedTools: string[] = []; + const needsApprovalTools: ToolCall[] = []; + + for (const tool of toolCalls) { + if (this.autoApproveSettings.get(tool.name)) { + autoApprovedTools.push(tool.id); + } else { + needsApprovalTools.push(tool); + } + } + + // If all tools are auto-approved, return them + if (needsApprovalTools.length === 0) { + return autoApprovedTools; + } + + // If there's already a pending request, reject the previous one + if (this.pendingRequest) { + this.pendingRequest.reject(new Error('Request superseded by new tool approval request')); + } + + return new Promise((resolve, reject) => { + const requestId = `tool-approval-${Date.now()}-${Math.random()}`; + + this.pendingRequest = { + requestId, + toolCalls: needsApprovalTools, + resolve: (approvedToolIds: string[]) => { + // Combine auto-approved and manually approved tools + const allApprovedIds = [...autoApprovedTools, ...approvedToolIds]; + this.pendingRequest = null; + resolve(allApprovedIds); + }, + reject: (error: Error) => { + this.pendingRequest = null; + reject(error); + }, + }; + + // Emit event for UI components to listen to + this.emit('approval-requested', this.pendingRequest); + }); + } + + /** + * Approve tools from the UI + */ + public approveTools(requestId: string, approvedToolIds: string[], rememberChoice = false): void { + if (!this.pendingRequest || this.pendingRequest.requestId !== requestId) { + console.warn('No matching pending request for approval:', requestId); + return; + } + + // Handle remember choice + if (rememberChoice) { + // If all tools were approved, enable session auto-approval + const allToolIds = this.pendingRequest.toolCalls.map(tool => tool.id); + if (approvedToolIds.length === allToolIds.length) { + this.sessionAutoApproval = true; + } else { + // Remember individual tool approvals + for (const toolCall of this.pendingRequest.toolCalls) { + if (approvedToolIds.includes(toolCall.id)) { + this.autoApproveSettings.set(toolCall.name, true); + } + } + } + } + + this.pendingRequest.resolve(approvedToolIds); + } + + /** + * Deny all tools from the UI + */ + public denyTools(requestId: string): void { + if (!this.pendingRequest || this.pendingRequest.requestId !== requestId) { + console.warn('No matching pending request for denial:', requestId); + return; + } + + this.pendingRequest.reject(new Error('User denied tool execution')); + } + + /** + * Get current pending request + */ + public getPendingRequest(): ToolApprovalRequest | null { + return this.pendingRequest; + } + + /** + * Clear session settings (called when user explicitly clears or starts new session) + */ + public clearSession(): void { + this.sessionAutoApproval = false; + this.autoApproveSettings.clear(); + } + + /** + * Set session auto-approval + */ + public setSessionAutoApproval(enabled: boolean): void { + this.sessionAutoApproval = enabled; + } + + /** + * Check if session auto-approval is enabled + */ + public isSessionAutoApprovalEnabled(): boolean { + return this.sessionAutoApproval; + } + + /** + * Get auto-approval settings for debugging + */ + public getAutoApprovalSettings(): { + sessionAutoApproval: boolean; + toolSettings: Array<{ toolName: string; autoApprove: boolean }>; + } { + return { + sessionAutoApproval: this.sessionAutoApproval, + toolSettings: Array.from(this.autoApproveSettings.entries()).map( + ([toolName, autoApprove]) => ({ + toolName, + autoApprove, + }) + ), + }; + } +} + +// Export singleton instance +export const toolApprovalManager = ToolApprovalManager.getInstance(); diff --git a/ai-assistant/src/utils/ToolConfigManager.ts b/ai-assistant/src/utils/ToolConfigManager.ts index c5c189d2b..501a5f1ba 100644 --- a/ai-assistant/src/utils/ToolConfigManager.ts +++ b/ai-assistant/src/utils/ToolConfigManager.ts @@ -5,6 +5,7 @@ export type ToolInfo = { id: string; name: string; description: string; + source: 'built-in' | 'mcp'; }; // List of all available tools (add more here as needed) @@ -14,6 +15,7 @@ const AVAILABLE_TOOLS: ToolInfo[] = [ name: 'Kubernetes API Request', description: 'Make requests to the Kubernetes API server to fetch, create, update or delete resources.', + source: 'built-in', }, // Add more tools here as needed ]; @@ -52,3 +54,101 @@ export function getEnabledToolIds(pluginSettings: any): string[] { const allTools = getAllAvailableTools(); return allTools.map(tool => tool.id).filter(toolId => isToolEnabled(pluginSettings, toolId)); } + +// Sets the enabled tools list in plugin settings +export function setEnabledTools(pluginSettings: any, enabledToolIds: string[]): any { + const enabledTools: Record = {}; + + // Get all available tools and set their enabled state + const allTools = getAllAvailableTools(); + allTools.forEach(tool => { + enabledTools[tool.id] = enabledToolIds.includes(tool.id); + }); + + return { + ...pluginSettings, + enabledTools, + }; +} + +// Check if a tool is a built-in tool (from AVAILABLE_TOOLS registry) +export function isBuiltInTool(toolName: string): boolean { + return AVAILABLE_TOOLS.some(tool => tool.id === toolName); +} + +// Check if a tool is an MCP tool by consulting the tool registry +// This is async because we need to fetch MCP tools to check +export async function isMCPTool(toolName: string): Promise { + const allTools = await getAllAvailableToolsIncludingMCP(); + const tool = allTools.find(t => t.id === toolName); + return tool?.source === 'mcp'; +} + +// Get the source of a tool (built-in or MCP) +export async function getToolSource(toolName: string): Promise<'built-in' | 'mcp' | 'unknown'> { + const allTools = await getAllAvailableToolsIncludingMCP(); + const tool = allTools.find(t => t.id === toolName); + return tool?.source || 'unknown'; +} + +// Parse MCP tool name to extract server and tool components +export function parseMCPToolName(fullToolName: string): { serverName: string; toolName: string } { + const parts = fullToolName.split('__'); + if (parts.length >= 2) { + return { + serverName: parts[0], + toolName: parts.slice(1).join('__'), + }; + } + return { + serverName: 'default', + toolName: fullToolName, + }; +} + +// Get all available tools (both built-in and MCP tools) +// This function needs to be async to fetch MCP tools +export async function getAllAvailableToolsIncludingMCP(): Promise { + const builtInTools = getAllAvailableTools(); + + // Try to get MCP tools if running in Electron environment + try { + if (typeof window !== 'undefined' && window.desktopApi?.mcp) { + const mcpResponse = await window.desktopApi.mcp.getTools(); + if (mcpResponse.success && mcpResponse.tools) { + const mcpTools: ToolInfo[] = mcpResponse.tools.map(tool => ({ + id: tool.name, + name: tool.name, + description: tool.description || `MCP tool: ${tool.name}`, + source: 'mcp' as const, + })); + return [...builtInTools, ...mcpTools]; + } + } + } catch (error) { + console.warn('Failed to fetch MCP tools for tool config management:', error); + } + + return builtInTools; +} + +// Get enabled tool IDs including MCP tools +export async function getEnabledToolIdsIncludingMCP(pluginSettings: any): Promise { + const allTools = await getAllAvailableToolsIncludingMCP(); + return allTools.map(tool => tool.id).filter(toolId => isToolEnabled(pluginSettings, toolId)); +} + +// Initialize tools state properly on app load +// This ensures that on first load, all tools are enabled (default behavior) +// but respects any saved configuration if it exists +export async function initializeToolsState(pluginSettings: any): Promise { + const allTools = await getAllAvailableToolsIncludingMCP(); + + // If we have no enabledTools config at all, enable all tools by default + if (!pluginSettings || !pluginSettings.enabledTools) { + return allTools.map(tool => tool.id); + } + + // If we have partial config, use the isToolEnabled logic which defaults to true + return allTools.map(tool => tool.id).filter(toolId => isToolEnabled(pluginSettings, toolId)); +}