diff --git a/bun.lock b/bun.lock index 06bd502..d6318b5 100644 --- a/bun.lock +++ b/bun.lock @@ -134,7 +134,7 @@ "jsdom": "^26.1.0", "puppeteer-ghost": "^0.0.15", "rollup-plugin-visualizer": "^6.0.3", - "typescript": "^5.7.2", + "typescript": "^5.9.0-beta", "vite": "^6.1.0", "vitest": "^3.0.5", "web-vitals": "^4.2.4", @@ -150,7 +150,7 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@antfu/utils": ["@antfu/utils@8.1.1", "", {}, "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ=="], + "@antfu/utils": ["@antfu/utils@9.2.0", "", {}, "sha512-Oq1d9BGZakE/FyoEtcNeSwM7MpDO2vUBi11RWBZXf75zPsbUVWmUs03EqkRFrcgbXyKTas0BdZWC1wcuSoqSAw=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.27.3", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-IjLt0gd3L4jlOfilxVXTifn42FnVffMgDC04RJK1KDZpmkBWLv0XC92MVVmkxrFZNS/7l3xWgP/I3nqtX1sQHw=="], @@ -348,7 +348,7 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.9", "", { "os": "win32", "cpu": "x64" }, "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.8.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-MJQFqrZgcW0UNYLGOuQpey/oTN59vyWwplvCGZztn1cKz9agZPPYpJB7h2OMmuu7VLqkvEjN8feFZJmxNF9D+Q=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], @@ -388,7 +388,7 @@ "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], - "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], @@ -398,7 +398,7 @@ "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], - "@iconify/utils": ["@iconify/utils@2.3.0", "", { "dependencies": { "@antfu/install-pkg": "^1.0.0", "@antfu/utils": "^8.1.0", "@iconify/types": "^2.0.0", "debug": "^4.4.0", "globals": "^15.14.0", "kolorist": "^1.8.0", "local-pkg": "^1.0.0", "mlly": "^1.7.4" } }, "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA=="], + "@iconify/utils": ["@iconify/utils@3.0.1", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@antfu/utils": "^9.2.0", "@iconify/types": "^2.0.0", "debug": "^4.4.1", "globals": "^15.15.0", "kolorist": "^1.8.0", "local-pkg": "^1.1.1", "mlly": "^1.7.4" } }, "sha512-A78CUEnFGX8I/WlILxJCuIJXloL0j/OJ9PSchPAfCargEIKmUBWvvEMmKWB5oONwiUqlNt+5eRufdkLxeHIWYw=="], "@inquirer/checkbox": ["@inquirer/checkbox@4.2.2", "", { "dependencies": { "@inquirer/core": "^10.2.0", "@inquirer/figures": "^1.0.13", "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-E+KExNurKcUJJdxmjglTl141EwxWyAHplvsYJQgSwXf8qiNWkTxTuCCqmhFEmbIXd4zLaGMfQFJ6WrZ7fSeV3g=="], @@ -478,13 +478,13 @@ "@lezer/html": ["@lezer/html@1.3.10", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w=="], - "@lezer/javascript": ["@lezer/javascript@1.5.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw=="], + "@lezer/javascript": ["@lezer/javascript@1.5.2", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-oJDMyptbtS/zhSi/uOszsqCm7/0l6QpbnvjoXBgNiFlk4NHrqoP/+psiVxYKYe9GHRr6K7jBSxwmIW61TrtZOQ=="], "@lezer/lr": ["@lezer/lr@1.4.2", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA=="], "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], - "@mendable/firecrawl-js": ["@mendable/firecrawl-js@4.3.1", "", { "dependencies": { "axios": "^1.11.0", "typescript-event-target": "^1.1.1", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.0" } }, "sha512-wDPpWjnU3SStnhTtCVtTEiEOelJHaUqKq8Oak6+GRcOUkJk2VgueT8GvsOW9FZtMYjo5dbUa8KfPpdN0mAyfIA=="], + "@mendable/firecrawl-js": ["@mendable/firecrawl-js@4.3.3", "", { "dependencies": { "axios": "^1.11.0", "typescript-event-target": "^1.1.1", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.0" } }, "sha512-TdIMI597L18ZM6QqAM65NnjTm9n3EfOtg8Yo58Im2yVPWHHKd0OhqRvM183b9/JIlq9iF6RfR8TDuxh7dpcT5A=="], "@mermaid-js/parser": ["@mermaid-js/parser@0.6.2", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ=="], @@ -570,7 +570,7 @@ "@playwright/test": ["@playwright/test@1.55.0", "", { "dependencies": { "playwright": "1.55.0" }, "bin": { "playwright": "cli.js" } }, "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ=="], - "@polar-sh/sdk": ["@polar-sh/sdk@0.34.14", "", { "dependencies": { "standardwebhooks": "^1.0.0", "zod": "^3.25.76" }, "peerDependencies": { "@modelcontextprotocol/sdk": ">=1.5.0 <1.10.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"], "bin": { "mcp": "bin/mcp-server.js" } }, "sha512-Hm25NDxjoc4MwXtmeDZVgHmdo7ESkUtaKyvr9CWcs2+/4GEOeP1PlgbMzRAM2kixbZk2gXongOlpoPQiFw7J9g=="], + "@polar-sh/sdk": ["@polar-sh/sdk@0.34.15", "", { "dependencies": { "standardwebhooks": "^1.0.0", "zod": "^3.25.76" }, "peerDependencies": { "@modelcontextprotocol/sdk": ">=1.5.0 <1.10.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"], "bin": { "mcp": "bin/mcp-server.js" } }, "sha512-6j5Ju8lzKzQVWwS2NNmenQYYUEdz1t2IpdWCJkHiANVl3/wU3yqqFZRCQj+x4nBzWb0lD+rDoaXMSK4Gpfw+zw=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], @@ -820,33 +820,33 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.12", "", { "dependencies": { "@tailwindcss/node": "4.1.12", "@tailwindcss/oxide": "4.1.12", "tailwindcss": "4.1.12" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ=="], - "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.83.1", "", { "dependencies": { "@typescript-eslint/utils": "^8.37.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-tdkpPFfzkTksN9BIlT/qjixSAtKrsW6PUVRwdKWaOcag7DrD1vpki3UzzdfMQGDRGeg1Ue1Dg+rcl5FJGembNg=="], + "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.86.0", "", { "dependencies": { "@typescript-eslint/utils": "^8.37.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-tmXdnx/fF3yY5G5jpzrJQbASY3PNzsKF0gq9IsZVqz3LJ4sExgdUFGQ305nao0wTMBOclyrSC13v/VQ3yOXu/Q=="], "@tanstack/history": ["@tanstack/history@1.131.2", "", {}, "sha512-cs1WKawpXIe+vSTeiZUuSBy8JFjEuDgdMKZFRLKwQysKo8y2q6Q1HvS74Yw+m5IhOW1nTZooa6rlgdfXcgFAaw=="], - "@tanstack/query-core": ["@tanstack/query-core@5.85.9", "", {}, "sha512-5fxb9vwyftYE6KFLhhhDyLr8NO75+Wpu7pmTo+TkwKmMX2oxZDoLwcqGP8ItKSpUMwk3urWgQDZfyWr5Jm9LsQ=="], + "@tanstack/query-core": ["@tanstack/query-core@5.86.0", "", {}, "sha512-Y6ibQm6BXbw6w1p3a5LrPn8Ae64M0dx7hGmnhrm9P+XAkCCKXOwZN0J5Z1wK/0RdNHtR9o+sWHDXd4veNI60tQ=="], - "@tanstack/query-devtools": ["@tanstack/query-devtools@5.84.0", "", {}, "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ=="], + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.86.0", "", {}, "sha512-/JDw9BP80eambEK/EsDMGAcsL2VFT+8F5KCOwierjPU7QP8Wt1GT32yJpn3qOinBM8/zS3Jy36+F0GiyJp411A=="], - "@tanstack/react-query": ["@tanstack/react-query@5.85.9", "", { "dependencies": { "@tanstack/query-core": "5.85.9" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-2T5zgSpcOZXGkH/UObIbIkGmUPQqZqn7esVQFXLOze622h4spgWf5jmvrqAo9dnI13/hyMcNsF1jsoDcb59nJQ=="], + "@tanstack/react-query": ["@tanstack/react-query@5.86.0", "", { "dependencies": { "@tanstack/query-core": "5.86.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-jgS/v0oSJkGHucv9zxOS8rL7mjATh1XO3K4eqAV4WMpAly8okcBrGi1YxRZN5S4B59F54x9JFjWrK5vMAvJYqA=="], - "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.85.9", "", { "dependencies": { "@tanstack/query-devtools": "5.84.0" }, "peerDependencies": { "@tanstack/react-query": "^5.85.9", "react": "^18 || ^19" } }, "sha512-BAdhgwpzxkC1vdyCfiPbbC7FU/t/x6q2d9ZyhON/WykVUdznD69nlppuWpSIlIGipdRG7sF6tRZ6x3GtSq0EUQ=="], + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.86.0", "", { "dependencies": { "@tanstack/query-devtools": "5.86.0" }, "peerDependencies": { "@tanstack/react-query": "^5.86.0", "react": "^18 || ^19" } }, "sha512-+50IcXI+54qHx3IDccbTala4tkToKxa0WKqP4XWlTnP1mQNfHO3dJj8wwnzpG50os69kpSbnU8C98Q/i8b6lyA=="], - "@tanstack/react-router": ["@tanstack/react-router@1.131.32", "", { "dependencies": { "@tanstack/history": "1.131.2", "@tanstack/react-store": "^0.7.0", "@tanstack/router-core": "1.131.32", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-QvT7vV8ic7jOh3U8c2FkzdnHZ7eejPaneNQacJm4Op28aSpkFRwIU3S15pPYqyiLr8DnG5L75ADfSqlJ18ZGpw=="], + "@tanstack/react-router": ["@tanstack/react-router@1.131.35", "", { "dependencies": { "@tanstack/history": "1.131.2", "@tanstack/react-store": "^0.7.0", "@tanstack/router-core": "1.131.35", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-2mwHgwoSs4wih67jfl2TjcF4enYpLpY0TljE+Sl1njZ01CWLrrQgjQ6tEuVA24Pm5re4V01A3abKvDtN1miQ9Q=="], - "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.131.32", "", { "dependencies": { "@tanstack/router-devtools-core": "1.131.32" }, "peerDependencies": { "@tanstack/react-router": "^1.131.32", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-7Pf25kSEJfAUqYlxuQb+Nquy7YUO1rOsrbYeeU1rv0WYSiyB0dOAOZmcRmQ0zl2TCtEkZnepcL5Hbmb5CamMBA=="], + "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.131.35", "", { "dependencies": { "@tanstack/router-devtools-core": "1.131.35" }, "peerDependencies": { "@tanstack/react-router": "^1.131.35", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-F93UQDpOJPXvyu4PUiRyr7al6B4sJIgwIkyr7O8QQNY2r2NLqyEWeJMWfOJD/FtrF6r+4G8UR/PE3sXF+kNO5Q=="], "@tanstack/react-store": ["@tanstack/react-store@0.7.4", "", { "dependencies": { "@tanstack/store": "0.7.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-DyG1e5Qz/c1cNLt/NdFbCA7K1QGuFXQYT6EfUltYMJoQ4LzBOGnOl5IjuxepNcRtmIKkGpmdMzdFZEkevgU9bQ=="], "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.12", "", { "dependencies": { "@tanstack/virtual-core": "3.13.12" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA=="], - "@tanstack/router-core": ["@tanstack/router-core@1.131.32", "", { "dependencies": { "@tanstack/history": "1.131.2", "@tanstack/store": "^0.7.0", "cookie-es": "^1.2.2", "seroval": "^1.3.2", "seroval-plugins": "^1.3.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-6pIX4ZS6tyxVTQKAKsGE6xl6S7OekyPPRqEbOTmIxUJ5vBL0iAu3ir7YTk5a7PEM6lDe/nUI2oVV5zkarEEiPQ=="], + "@tanstack/router-core": ["@tanstack/router-core@1.131.35", "", { "dependencies": { "@tanstack/history": "1.131.2", "@tanstack/store": "^0.7.0", "cookie-es": "^1.2.2", "seroval": "^1.3.2", "seroval-plugins": "^1.3.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-wS+Tcczo3+63LbrRKQGrpUSa9yws0V/fg32KK/tOi0BDlloVM3KTED3UP2hMVyqaMgts6jK7n1b/cEGWOlLdAA=="], - "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.131.32", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.5" }, "peerDependencies": { "@tanstack/router-core": "^1.131.32", "csstype": "^3.0.10", "tiny-invariant": "^1.3.3" }, "optionalPeers": ["csstype"] }, "sha512-VQPAA/B9L68hl16KyIonUEooJOfL75rJJjfashcVGDRsssGCCfCjvHS8Cw0mSYeVJ0iawgKPdIUyCZAqGycYAg=="], + "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.131.35", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.5" }, "peerDependencies": { "@tanstack/router-core": "^1.131.35", "csstype": "^3.0.10", "tiny-invariant": "^1.3.3" }, "optionalPeers": ["csstype"] }, "sha512-a3FgyedrjreUK3bypxvtUgumjsGsOQn6V/ksNVyC3yTIdpmRL1b7zrzkM0vT18H0x5iaX33Ix3Xt/renqh3qwg=="], - "@tanstack/router-generator": ["@tanstack/router-generator@1.131.32", "", { "dependencies": { "@tanstack/router-core": "1.131.32", "@tanstack/router-utils": "1.131.2", "@tanstack/virtual-file-routes": "1.131.2", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-HQVnytwHBFr4frvsqwl4hLNYtvXp73ttoQOZyLwhxqUdMSoQ4F26sJ1RYKPGGhRaLfBqnGbTX6tLq5hm/ePBVQ=="], + "@tanstack/router-generator": ["@tanstack/router-generator@1.131.35", "", { "dependencies": { "@tanstack/router-core": "1.131.35", "@tanstack/router-utils": "1.131.2", "@tanstack/virtual-file-routes": "1.131.2", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-fOxjiactUQXGfOAv4dC/hFjFUSUV0XmwnQwhNHswWNol6Te/gIBBj14umAaG/PqTaQZoN/b6v+wvkVvpQt3Dbw=="], - "@tanstack/router-plugin": ["@tanstack/router-plugin@1.131.32", "", { "dependencies": { "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-core": "1.131.32", "@tanstack/router-generator": "1.131.32", "@tanstack/router-utils": "1.131.2", "@tanstack/virtual-file-routes": "1.131.2", "babel-dead-code-elimination": "^1.0.10", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.131.32", "vite": ">=5.0.0 || >=6.0.0", "vite-plugin-solid": "^2.11.2", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-HKrBXnlRkhvXGWYPaRl1ASj3Y++7Y0nGKeTFgHSc2nIzlcuPTHr5DROr3nC2DsfPxAkvUzPVpUNpMb0cbMcqHw=="], + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.131.35", "", { "dependencies": { "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-core": "1.131.35", "@tanstack/router-generator": "1.131.35", "@tanstack/router-utils": "1.131.2", "@tanstack/virtual-file-routes": "1.131.2", "babel-dead-code-elimination": "^1.0.10", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.131.35", "vite": ">=5.0.0 || >=6.0.0", "vite-plugin-solid": "^2.11.2", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-/J7ipJsNK1oSJv6M8gsS/0eCo34eg5DTwwX5Cv+JFoZ+svcuaJrqN2QxyMt88Kq10IKRUo8xzaLbRxwyjY8hbA=="], "@tanstack/router-utils": ["@tanstack/router-utils@1.131.2", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2" } }, "sha512-sr3x0d2sx9YIJoVth0QnfEcAcl+39sQYaNQxThtHmRpyeFYNyM2TTH+Ud3TNEnI3bbzmLYEUD+7YqB987GzhDA=="], @@ -974,7 +974,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@22.18.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ=="], + "@types/node": ["@types/node@22.18.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw=="], "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], @@ -1084,7 +1084,7 @@ "atmn": ["atmn@0.0.19", "", { "dependencies": { "@inquirer/prompts": "^7.6.0", "@types/prettier": "^3.0.0", "axios": "^1.10.0", "chalk": "^5.2.0", "commander": "^14.0.0", "dotenv": "^17.2.0", "inquirer": "^12.7.0", "jiti": "^2.4.2", "open": "^10.1.2", "prettier": "^3.6.2", "yocto-spinner": "^1.0.0", "zod": "^4.0.0" }, "bin": { "atmn": "dist/cli.js" } }, "sha512-PXCpuFAd2v3+u7sUDZ4FOl24zPrf8xu6plTQaKIZY2w76fBIDEAO9S2foVFi9T2lMssw2mzLsNNRFwMzuLzEvw=="], - "autumn-js": ["autumn-js@0.1.28", "", { "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", "swr": "^2.3.3", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "^1.3.4", "better-call": "^1.0.12", "convex": "^1.25.4" }, "optionalPeers": ["better-auth", "better-call"] }, "sha512-f40FLvmZtY/3t41HZtUZmi5/191DhEE7IATI+8388yGPBSa4Pk46J5zeExfhBJEiphYirFoDxh4jxwpw5cJQdw=="], + "autumn-js": ["autumn-js@0.1.29", "", { "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", "swr": "^2.3.3", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "^1.3.4", "better-call": "^1.0.12", "convex": "^1.25.4" }, "optionalPeers": ["better-auth", "better-call"] }, "sha512-voGmoFMn8QcUShm34T7ekuHxKBx4IRKfX/OYoOqYmQURQee2lVH1VKI+ucAwYRStmA8Z3vZp/5K0OjMbt4U/Lg=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], @@ -1100,7 +1100,7 @@ "bare-events": ["bare-events@2.6.1", "", {}, "sha512-AuTJkq9XmE6Vk0FJVNq5QxETrSA/vKHarWVBG5l/JbdCL1prJemiyJqUS0jrlXO0MftuPq4m3YVYhoNc5+aE/g=="], - "bare-fs": ["bare-fs@4.2.2", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-5vn+bdnlCYMwETIm1FqQXDP6TYPbxr2uJd88ve40kr4oPbiTZJVrTNzqA3/4sfWZeWKuQR/RkboBt7qEEDtfMA=="], + "bare-fs": ["bare-fs@4.2.3", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-1aGs5pRVLToMQ79elP+7cc0u0s/wXAzfBv/7hDloT7WFggLqECCas5qqPky7WHCFdsBH5WDq6sD4fAoz5sJbtA=="], "bare-os": ["bare-os@3.6.2", "", {}, "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A=="], @@ -1416,7 +1416,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.212", "", {}, "sha512-gE7ErIzSW+d8jALWMcOIgf+IB6lpfsg6NwOhPVwKzDtN2qcBix47vlin4yzSregYDxTCXOUqAZjVY/Z3naS7ww=="], + "electron-to-chromium": ["electron-to-chromium@1.5.214", "", {}, "sha512-TpvUNdha+X3ybfU78NoQatKvQEm1oq3lf2QbnmCEdw+Bd9RuIAY+hJTvq1avzHM0f7EJfnH3vbCnbzKzisc/9Q=="], "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], @@ -1718,7 +1718,7 @@ "install": ["install@0.13.0", "", {}, "sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA=="], - "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "intersection-observer": ["intersection-observer@0.10.0", "", {}, "sha512-fn4bQ0Xq8FTej09YC/jqKZwtijpvARlRp6wxL5WTA6yPe2YWSJ5RJh7Nm79rK2qB0wr6iDQzH60XGq5V/7u8YQ=="], @@ -1846,7 +1846,7 @@ "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], - "langsmith": ["langsmith@0.3.66", "", { "dependencies": { "@types/uuid": "^10.0.0", "chalk": "^4.1.2", "console-table-printer": "^2.12.1", "p-queue": "^6.6.2", "p-retry": "4", "semver": "^7.6.3", "uuid": "^10.0.0" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai"] }, "sha512-d50FJ25HPAT2e/6u7oPAYFYH7uvVhxf7vThAOE5tP6YFIUHwLMBmJj8R4Z7APp5jF4/m8upvbK4J4jP5UIN+Eg=="], + "langsmith": ["langsmith@0.3.67", "", { "dependencies": { "@types/uuid": "^10.0.0", "chalk": "^4.1.2", "console-table-printer": "^2.12.1", "p-queue": "^6.6.2", "p-retry": "4", "semver": "^7.6.3", "uuid": "^10.0.0" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai"] }, "sha512-l4y3RmJ9yWF5a29fLg3eWZQxn6Q6dxTOgLGgQHzPGZHF3NUynn+A+airYIe/Yt4rwjGbuVrABAPsXBkVu/Hi7g=="], "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], @@ -1980,7 +1980,7 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "mermaid": ["mermaid@11.10.1", "", { "dependencies": { "@braintree/sanitize-url": "^7.0.4", "@iconify/utils": "^2.1.33", "@mermaid-js/parser": "^0.6.2", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.11", "dayjs": "^1.11.13", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^16.0.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-0PdeADVWURz7VMAX0+MiMcgfxFKY4aweSGsjgFihe3XlMKNqmai/cugMrqTd3WNHM93V+K+AZL6Wu6tB5HmxRw=="], + "mermaid": ["mermaid@11.11.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.0.4", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^0.6.2", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.11", "dayjs": "^1.11.13", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^15.0.7", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-9lb/VNkZqWTRjVgCV+l1N+t4kyi94y+l5xrmBmbbxZYkfRl5hEDaTPMOcaWKCl1McG8nBEaMlWwkcAEEgjhBgg=="], "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], @@ -2132,7 +2132,7 @@ "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], - "openai": ["openai@5.18.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-iXSOfLlOL+jgnFr5CGrB2SEZw5C92o1nrFW2SasoAXj4QxGhfeJPgg8zkX+vaCfX80cT6CWjgaGnq7z9XzbyRw=="], + "openai": ["openai@5.12.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], @@ -2286,7 +2286,7 @@ "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - "raw-body": ["raw-body@3.0.0", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "unpipe": "1.0.0" } }, "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g=="], + "raw-body": ["raw-body@3.0.1", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.7.0", "unpipe": "1.0.0" } }, "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="], "react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="], @@ -2752,11 +2752,11 @@ "@antfu/install-pkg/tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], - "@anthropic-ai/sdk/@types/node": ["@types/node@18.19.123", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg=="], + "@anthropic-ai/sdk/@types/node": ["@types/node@18.19.124", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hY4YWZFLs3ku6D2Gqo3RchTd9VRCcrjqp/I0mmohYeUVA5Y8eCXKJEasHxLAJVZRJuQogfd1GiJ9lgogBgKeuQ=="], "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "@browserbasehq/sdk/@types/node": ["@types/node@18.19.123", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg=="], + "@browserbasehq/sdk/@types/node": ["@types/node@18.19.124", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hY4YWZFLs3ku6D2Gqo3RchTd9VRCcrjqp/I0mmohYeUVA5Y8eCXKJEasHxLAJVZRJuQogfd1GiJ9lgogBgKeuQ=="], "@browserbasehq/stagehand/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], @@ -2770,9 +2770,7 @@ "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], - - "@ibm-cloud/watsonx-ai/@types/node": ["@types/node@18.19.123", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg=="], + "@ibm-cloud/watsonx-ai/@types/node": ["@types/node@18.19.124", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hY4YWZFLs3ku6D2Gqo3RchTd9VRCcrjqp/I0mmohYeUVA5Y8eCXKJEasHxLAJVZRJuQogfd1GiJ9lgogBgKeuQ=="], "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], @@ -2784,8 +2782,6 @@ "@langchain/mcp-adapters/extended-eventsource": ["extended-eventsource@1.7.0", "", {}, "sha512-s8rtvZuYcKBpzytHb5g95cHbZ1J99WeMnV18oKc5wKoxkHzlzpPc/bNAm7Da2Db0BDw0CAu1z3LpH+7UsyzIpw=="], - "@langchain/openai/openai": ["openai@5.12.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ=="], - "@puppeteer/browsers/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="], @@ -2800,7 +2796,7 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@types/ssh2/@types/node": ["@types/node@18.19.123", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg=="], + "@types/ssh2/@types/node": ["@types/node@18.19.124", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hY4YWZFLs3ku6D2Gqo3RchTd9VRCcrjqp/I0mmohYeUVA5Y8eCXKJEasHxLAJVZRJuQogfd1GiJ9lgogBgKeuQ=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -2864,7 +2860,7 @@ "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], - "ibm-cloud-sdk-core/@types/node": ["@types/node@18.19.123", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg=="], + "ibm-cloud-sdk-core/@types/node": ["@types/node@18.19.124", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hY4YWZFLs3ku6D2Gqo3RchTd9VRCcrjqp/I0mmohYeUVA5Y8eCXKJEasHxLAJVZRJuQogfd1GiJ9lgogBgKeuQ=="], "ibm-cloud-sdk-core/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], @@ -2892,8 +2888,6 @@ "mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], - "mermaid/marked": ["marked@16.2.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-r3UrXED9lMlHF97jJByry90cwrZBBvZmjG1L68oYfuPMW+uDTnuMbyJDymCWwbTE+f+3LhpNDKfpR3a3saFyjA=="], - "mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -2918,6 +2912,8 @@ "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "raw-body/iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -2962,24 +2958,6 @@ "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "@langchain/community/@langchain/openai/openai": ["openai@5.12.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], - - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -3038,6 +3016,8 @@ "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], "eslint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -3050,8 +3030,6 @@ "ibm-cloud-sdk-core/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "langchain/@langchain/openai/openai": ["openai@5.12.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ=="], - "langsmith/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "mdast-util-mdx-jsx/parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -3072,18 +3050,6 @@ "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], } } diff --git a/convex/chatMessages/helpers.ts b/convex/chatMessages/helpers.ts index d257ca1..89956ff 100644 --- a/convex/chatMessages/helpers.ts +++ b/convex/chatMessages/helpers.ts @@ -1,40 +1,40 @@ import type { Doc, Id } from "../_generated/dataModel"; import { - mapStoredMessageToChatMessage, - type BaseMessage, - type StoredMessage, - AIMessage, - ToolMessage, - ToolMessage as LangChainToolMessage, + mapStoredMessageToChatMessage, + type BaseMessage, + type StoredMessage, + AIMessage, + ToolMessage, + ToolMessage as LangChainToolMessage, } from "@langchain/core/messages"; import type { AIChunkGroup, ToolChunkGroup } from "../langchain/state"; export type Message = Omit, "message"> & { - message: BaseMessage; + message: BaseMessage; }; export interface MessageWithBranchInfo { - message: Omit, "message"> & { - message: BaseMessage; - }; - branchIndex: number; // 1-indexed for display - totalBranches: number; - depth: number; + message: Omit, "message"> & { + message: BaseMessage; + }; + branchIndex: number; // 1-indexed for display + totalBranches: number; + depth: number; } export type MessageGroup = { - input: MessageWithBranchInfo; - response: MessageWithBranchInfo[]; + input: MessageWithBranchInfo; + response: MessageWithBranchInfo[]; }; /** * Helpers */ const clamp = (n: number, min: number, max: number) => - Math.max(min, Math.min(n, max)); + Math.max(min, Math.min(n, max)); const byCreatedAsc = (a: Doc<"chatMessages">, b: Doc<"chatMessages">) => - a._creationTime - b._creationTime; + a._creationTime - b._creationTime; // Identify message kinds const getType = (m: MessageWithBranchInfo) => m.message.message.getType(); @@ -45,296 +45,296 @@ const isTool = (m: MessageWithBranchInfo) => getType(m) === "tool"; const parsedMessageCache = new Map(); const toBaseMessage = (doc: Doc<"chatMessages">): BaseMessage => { - const cached = parsedMessageCache.get(doc.message); - if (cached) return cached; - const parsed = mapStoredMessageToChatMessage( - JSON.parse(doc.message) as StoredMessage, - ); - parsedMessageCache.set(doc.message, parsed); - return parsed; + const cached = parsedMessageCache.get(doc.message); + if (cached) return cached; + const parsed = mapStoredMessageToChatMessage( + JSON.parse(doc.message) as StoredMessage, + ); + parsedMessageCache.set(doc.message, parsed); + return parsed; }; const wrapDoc = ( - doc: Doc<"chatMessages">, + doc: Doc<"chatMessages">, ): Omit, "message"> & { message: BaseMessage } => ({ - ...doc, - message: toBaseMessage(doc), + ...doc, + message: toBaseMessage(doc), }); type Index = { - byId: Map, Doc<"chatMessages">>; - children: Map, Doc<"chatMessages">[]>; - roots: Doc<"chatMessages">[]; - indexInParent: Map, number>; - rootIndex: Map, number>; + byId: Map, Doc<"chatMessages">>; + children: Map, Doc<"chatMessages">[]>; + roots: Doc<"chatMessages">[]; + indexInParent: Map, number>; + rootIndex: Map, number>; }; const indexChatMessages = (messages: Doc<"chatMessages">[]): Index => { - const byId = new Map, Doc<"chatMessages">>(); - for (const m of messages) byId.set(m._id, m); - - const children = new Map, Doc<"chatMessages">[]>(); - const roots: Doc<"chatMessages">[] = []; - const indexInParent = new Map, number>(); - const rootIndex = new Map, number>(); - - // Build adjacency and roots - for (const m of messages) { - const p = m.parentId; - if (p && byId.has(p)) { - const arr = children.get(p); - if (arr) arr.push(m); - else children.set(p, [m]); - } else { - roots.push(m); - } - } - - // Sort siblings by creation time (natural flow) - roots.sort(byCreatedAsc); - for (const [, arr] of children) arr.sort(byCreatedAsc); - - // Fill constant-time index maps - for (const [, arr] of children) { - for (let i = 0; i < arr.length; i += 1) { - indexInParent.set(arr[i]._id, i); - } - } - for (let i = 0; i < roots.length; i += 1) { - rootIndex.set(roots[i]._id, i); - } - - return { byId, children, roots, indexInParent, rootIndex }; + const byId = new Map, Doc<"chatMessages">>(); + for (const m of messages) byId.set(m._id, m); + + const children = new Map, Doc<"chatMessages">[]>(); + const roots: Doc<"chatMessages">[] = []; + const indexInParent = new Map, number>(); + const rootIndex = new Map, number>(); + + // Build adjacency and roots + for (const m of messages) { + const p = m.parentId; + if (p && byId.has(p)) { + const arr = children.get(p); + if (arr) arr.push(m); + else children.set(p, [m]); + } else { + roots.push(m); + } + } + + // Sort siblings by creation time (natural flow) + roots.sort(byCreatedAsc); + for (const [, arr] of children) arr.sort(byCreatedAsc); + + // Fill constant-time index maps + for (const [, arr] of children) { + for (let i = 0; i < arr.length; i += 1) { + indexInParent.set(arr[i]._id, i); + } + } + for (let i = 0; i < roots.length; i += 1) { + rootIndex.set(roots[i]._id, i); + } + + return { byId, children, roots, indexInParent, rootIndex }; }; export const buildThreadFromMessages = ( - messages: Doc<"chatMessages">[], - path: number[], - idxOverride?: Index, + messages: Doc<"chatMessages">[], + path: number[], + idxOverride?: Index, ): MessageWithBranchInfo[] => { - if (messages.length === 0) return []; + if (messages.length === 0) return []; - const idx = idxOverride ?? indexChatMessages(messages); - const result: MessageWithBranchInfo[] = []; + const idx = idxOverride ?? indexChatMessages(messages); + const result: MessageWithBranchInfo[] = []; - let current = idx.roots; - let depth = 0; + let current = idx.roots; + let depth = 0; - while (current.length > 0) { - const desired = path?.[depth] ?? current.length - 1; // default to newest created - const i = clamp(desired, 0, current.length - 1); - const selectedNode = current[i]; + while (current.length > 0) { + const desired = path?.[depth] ?? current.length - 1; // default to newest created + const i = clamp(desired, 0, current.length - 1); + const selectedNode = current[i]; - result.push({ - message: wrapDoc(selectedNode), - branchIndex: i + 1, - totalBranches: current.length, - depth, - }); + result.push({ + message: wrapDoc(selectedNode), + branchIndex: i + 1, + totalBranches: current.length, + depth, + }); - // Get children of the selected node - current = idx.children.get(selectedNode._id) ?? []; - depth += 1; - } + // Get children of the selected node + current = idx.children.get(selectedNode._id) ?? []; + depth += 1; + } - return result; + return result; }; export const groupMessages = ( - currentThread: MessageWithBranchInfo[], + currentThread: MessageWithBranchInfo[], ): MessageGroup[] => { - const groups: MessageGroup[] = []; - let group: MessageGroup | null = null; - // Track last AI for O(1) tool association - let lastAI: MessageWithBranchInfo | null = null; - - for (const item of currentThread) { - if (isHuman(item)) { - if (group) groups.push(group); - group = { input: item, response: [] }; - lastAI = null; - continue; - } - - if (!group) { - // Ignore leading AI/tool/other messages without a human input - continue; - } - - if (isAI(item)) { - group.response.push(item); - lastAI = item; - continue; - } - - if (isTool(item) && lastAI) { - const ai = lastAI.message.message as AIMessage; - const tool = item.message.message as ToolMessage; - const tc = ai.tool_calls?.find((t) => t.id === tool.tool_call_id); - if (tc) { - item.message.message.additional_kwargs = { - ...item.message.message.additional_kwargs, - input: tc.args, - }; - } - } - - // Non-AI responses (tool/system/other) are appended as part of response - group.response.push(item); - } - - if (group) groups.push(group); - return groups; + const groups: MessageGroup[] = []; + let group: MessageGroup | null = null; + // Track last AI for O(1) tool association + let lastAI: MessageWithBranchInfo | null = null; + + for (const item of currentThread) { + if (isHuman(item)) { + if (group) groups.push(group); + group = { input: item, response: [] }; + lastAI = null; + continue; + } + + if (!group) { + // Ignore leading AI/tool/other messages without a human input + continue; + } + + if (isAI(item)) { + group.response.push(item); + lastAI = item; + continue; + } + + if (isTool(item) && lastAI) { + const ai = lastAI.message.message as AIMessage; + const tool = item.message.message as ToolMessage; + const tc = ai.tool_calls?.find((t) => t.id === tool.tool_call_id); + if (tc) { + item.message.message.additional_kwargs = { + ...item.message.message.additional_kwargs, + input: tc.args, + }; + } + } + + // Non-AI responses (tool/system/other) are appended as part of response + group.response.push(item); + } + + if (group) groups.push(group); + return groups; }; // O(depth) latest path computation using precomputed indices const computeLatestPath = ( - idx: Index, - latestMessage: Doc<"chatMessages">, + idx: Index, + latestMessage: Doc<"chatMessages">, ): number[] => { - const latestPath: number[] = []; - const visited = new Set>(); - let current: Doc<"chatMessages"> | undefined = latestMessage; - - while (current && !visited.has(current._id)) { - visited.add(current._id); - const p = current.parentId; - if (p && idx.byId.has(p)) { - // index among parent's children - latestPath.push(idx.indexInParent.get(current._id) ?? 0); - current = idx.byId.get(p); - } else { - // current is a root (parent missing or not loaded) - latestPath.push(idx.rootIndex.get(current._id) ?? 0); - current = undefined; - } - } - latestPath.reverse(); - return latestPath; + const latestPath: number[] = []; + const visited = new Set>(); + let current: Doc<"chatMessages"> | undefined = latestMessage; + + while (current && !visited.has(current._id)) { + visited.add(current._id); + const p = current.parentId; + if (p && idx.byId.has(p)) { + // index among parent's children + latestPath.push(idx.indexInParent.get(current._id) ?? 0); + current = idx.byId.get(p); + } else { + // current is a root (parent missing or not loaded) + latestPath.push(idx.rootIndex.get(current._id) ?? 0); + current = undefined; + } + } + latestPath.reverse(); + return latestPath; }; export const buildThreadAndGroups = ( - messages: Doc<"chatMessages">[], - path: number[], + messages: Doc<"chatMessages">[], + path: number[], ): { groups: MessageGroup[]; latestPath: number[] } => { - if (messages.length === 0) { - return { groups: [], latestPath: [] }; - } + if (messages.length === 0) { + return { groups: [], latestPath: [] }; + } - const idx = indexChatMessages(messages); - const thread = buildThreadFromMessages(messages, path, idx); - const groups = groupMessages(thread); + const idx = indexChatMessages(messages); + const thread = buildThreadFromMessages(messages, path, idx); + const groups = groupMessages(thread); - // Compute latest path using O(depth) lookups - const latestMessage = messages[messages.length - 1]; - const latestPath = computeLatestPath(idx, latestMessage); + // Compute latest path using O(depth) lookups + const latestMessage = messages[messages.length - 1]; + const latestPath = computeLatestPath(idx, latestMessage); - return { groups, latestPath }; + return { groups, latestPath }; }; export function getThreadFromMessage( - leafMessage: Doc<"chatMessages">, - messages: Doc<"chatMessages">[], + leafMessage: Doc<"chatMessages">, + messages: Doc<"chatMessages">[], ): Message[] { - const byId = new Map, Doc<"chatMessages">>(); - for (const m of messages) byId.set(m._id, m); + const byId = new Map, Doc<"chatMessages">>(); + for (const m of messages) byId.set(m._id, m); - const chain: Doc<"chatMessages">[] = []; - let current: Doc<"chatMessages"> | undefined = leafMessage; + const chain: Doc<"chatMessages">[] = []; + let current: Doc<"chatMessages"> | undefined = leafMessage; - while (current) { - chain.push(current); - current = current.parentId ? byId.get(current.parentId) : undefined; - } + while (current) { + chain.push(current); + current = current.parentId ? byId.get(current.parentId) : undefined; + } - return chain.reverse().map((m) => ({ ...m, message: toBaseMessage(m) })); + return chain.reverse().map((m) => ({ ...m, message: toBaseMessage(m) })); } export function groupStreamChunks( - chunks: (AIChunkGroup | ToolChunkGroup)[], + chunks: (AIChunkGroup | ToolChunkGroup)[], ): (AIChunkGroup | ToolChunkGroup)[] { - const groups: (AIChunkGroup | ToolChunkGroup)[] = []; - let currentGroup: (AIChunkGroup | ToolChunkGroup) | null = null; - - for (const chunk of chunks) { - if (chunk.type === "ai") { - if (currentGroup?.type === "ai") { - currentGroup.content += chunk.content; - if (chunk.reasoning) { - currentGroup.reasoning = - (currentGroup.reasoning ?? "") + chunk.reasoning; - } - } else { - currentGroup = { ...chunk }; - groups.push(currentGroup); - } - } else if (chunk.type === "tool") { - currentGroup = chunk; - groups.push(currentGroup); - } - } - - return groups; + const groups: (AIChunkGroup | ToolChunkGroup)[] = []; + let currentGroup: (AIChunkGroup | ToolChunkGroup) | null = null; + + for (const chunk of chunks) { + if (chunk.type === "ai") { + if (currentGroup?.type === "ai") { + currentGroup.content += chunk.content; + if (chunk.reasoning) { + currentGroup.reasoning = + (currentGroup.reasoning ?? "") + chunk.reasoning; + } + } else { + currentGroup = { ...chunk }; + groups.push(currentGroup); + } + } else if (chunk.type === "tool") { + currentGroup = chunk; + groups.push(currentGroup); + } + } + + return groups; } export function convertChunksToLangChainMessages( - groups: (AIChunkGroup | ToolChunkGroup)[], + groups: (AIChunkGroup | ToolChunkGroup)[], ): (AIMessage | LangChainToolMessage)[] { - const completedIds = new Set( - groups - .filter((c) => c.type === "tool" && c.isComplete) - .map((c) => (c as ToolChunkGroup).toolCallId), - ); - - return groups - .map((chunk) => { - if (chunk.type === "ai") { - return new AIMessage({ - content: chunk.content, - additional_kwargs: chunk.reasoning - ? { reasoning_content: chunk.reasoning } - : {}, - }); - } - - if (chunk.type === "tool") { - if (chunk.isComplete) { - return new LangChainToolMessage({ - content: chunk.output as string, - name: chunk.toolName, - tool_call_id: chunk.toolCallId, - additional_kwargs: { - input: JSON.parse(JSON.stringify(chunk.input)), - is_complete: true, - }, - }); - } - - if (!completedIds.has(chunk.toolCallId)) { - return new LangChainToolMessage({ - name: chunk.toolName, - tool_call_id: chunk.toolCallId, - content: "", - additional_kwargs: { - input: JSON.parse(JSON.stringify(chunk.input)), - is_complete: false, - }, - }); - } - } - - return undefined; - }) - .filter(Boolean) as (AIMessage | LangChainToolMessage)[]; + const completedIds = new Set( + groups + .filter((c) => c.type === "tool" && c.isComplete) + .map((c) => (c as ToolChunkGroup).toolCallId), + ); + + return groups + .map((chunk) => { + if (chunk.type === "ai") { + return new AIMessage({ + content: chunk.content, + additional_kwargs: chunk.reasoning + ? { reasoning_content: chunk.reasoning } + : {}, + }); + } + + if (chunk.type === "tool") { + if (chunk.isComplete) { + return new LangChainToolMessage({ + content: chunk.output as string, + name: chunk.toolName, + tool_call_id: chunk.toolCallId, + additional_kwargs: { + input: JSON.parse(JSON.stringify(chunk.input)), + is_complete: true, + }, + }); + } + + if (!completedIds.has(chunk.toolCallId)) { + return new LangChainToolMessage({ + name: chunk.toolName, + tool_call_id: chunk.toolCallId, + content: "", + additional_kwargs: { + input: JSON.parse(JSON.stringify(chunk.input)), + is_complete: false, + }, + }); + } + } + + return undefined; + }) + .filter(Boolean) as (AIMessage | LangChainToolMessage)[]; } export function processBufferToMessages( - accumulatedBuffer: string[], + accumulatedBuffer: string[], ): (AIMessage | LangChainToolMessage)[] { - if (accumulatedBuffer.length === 0) return []; - const chunks = accumulatedBuffer.map( - (chunkStr) => JSON.parse(chunkStr) as AIChunkGroup | ToolChunkGroup, - ); - const groups = groupStreamChunks(chunks); - return convertChunksToLangChainMessages(groups); + if (accumulatedBuffer.length === 0) return []; + const chunks = accumulatedBuffer.map( + (chunkStr) => JSON.parse(chunkStr) as AIChunkGroup | ToolChunkGroup, + ); + const groups = groupStreamChunks(chunks); + return convertChunksToLangChainMessages(groups); } diff --git a/convex/langchain/agent.ts b/convex/langchain/agent.ts index 71d698b..f229d4e 100644 --- a/convex/langchain/agent.ts +++ b/convex/langchain/agent.ts @@ -2,338 +2,348 @@ import type { RunnableConfig } from "@langchain/core/runnables"; import { - type ExtendedRunnableConfig, - createSimpleAgent, - createAgentWithTools, - getAvailableToolsDescription, + type ExtendedRunnableConfig, + createSimpleAgent, + createAgentWithTools, + getAvailableToolsDescription, } from "./helpers"; -import { type CompletedStep, GraphState, planSchema, planArray } from "./state"; +import { GraphState, planSchema, planArray, type CompletedStep } from "./state"; import { modelSupportsTools, formatMessages, getModel, models } from "./models"; import { - BaseMessage, - AIMessage, - HumanMessage, - ToolMessage, - mapChatMessagesToStoredMessages, + type BaseMessage, + AIMessage, + HumanMessage, + ToolMessage, + mapChatMessagesToStoredMessages, } from "@langchain/core/messages"; import { - createPlannerPrompt, - createReplannerPrompt, - replannerOutputSchema, + createPlannerPrompt, + createReplannerPrompt, + replannerOutputSchema, } from "./prompts"; -import { z } from "zod"; +import type { z } from "zod"; import { END, START, StateGraph } from "@langchain/langgraph/web"; async function shouldPlanOrAgentOrSimple( - _state: typeof GraphState.State, - config: RunnableConfig, + _state: typeof GraphState.State, + config: RunnableConfig, ) { - const formattedConfig = config.configurable as ExtendedRunnableConfig; - if (!modelSupportsTools(formattedConfig.chat.model!)) { - return "simple"; - } + const formattedConfig = config.configurable as ExtendedRunnableConfig; + if (!modelSupportsTools(formattedConfig.chat.model)) { + return "simple"; + } - if (formattedConfig.chat.orchestratorMode) { - return "planner"; - } + if (formattedConfig.chat.orchestratorMode) { + return "planner"; + } - return "baseAgent"; + return "baseAgent"; } function filterMessagesForReplanning(messages: BaseMessage[]): BaseMessage[] { - const recentMessages = messages.slice(-20); + const recentMessages = messages.slice(-20); - const filteredMessages: BaseMessage[] = []; - let skipNextAIMessage = false; + const filteredMessages: BaseMessage[] = []; + let skipNextAIMessage = false; - for (let i = recentMessages.length - 1; i >= 0; i--) { - const message = recentMessages[i]; + for (let i = recentMessages.length - 1; i >= 0; i--) { + const message = recentMessages[i]; - if (message instanceof ToolMessage) { - skipNextAIMessage = true; - continue; - } + if (message instanceof ToolMessage) { + skipNextAIMessage = true; + continue; + } - if (message instanceof AIMessage && skipNextAIMessage) { - if (message.tool_calls && message.tool_calls.length > 0) { - skipNextAIMessage = false; - continue; - } - skipNextAIMessage = false; - } + if (message instanceof AIMessage && skipNextAIMessage) { + if (message.tool_calls && message.tool_calls.length > 0) { + skipNextAIMessage = false; + continue; + } + skipNextAIMessage = false; + } - filteredMessages.unshift(message); - } + filteredMessages.unshift(message); + } - return filteredMessages; + return filteredMessages; } async function simple(state: typeof GraphState.State, config: RunnableConfig) { - const formattedConfig = config.configurable as ExtendedRunnableConfig; - - const chain = await createSimpleAgent(state, formattedConfig); - const formattedMessages = await formatMessages( - formattedConfig.ctx, - state.messages, - formattedConfig.chat.model!, - ); - - const response = await chain.invoke( - { - messages: formattedMessages, - }, - config, - ); - - return { - messages: [response], - }; + const formattedConfig = config.configurable as ExtendedRunnableConfig; + + const chain = await createSimpleAgent(state, formattedConfig); + const formattedMessages = await formatMessages( + formattedConfig.ctx, + state.messages, + formattedConfig.chat.model, + ); + + const response = await chain.invoke( + { + messages: formattedMessages, + }, + config, + ); + + return { + messages: [response], + }; } async function baseAgent( - state: typeof GraphState.State, - config: RunnableConfig, + state: typeof GraphState.State, + config: RunnableConfig, ) { - const formattedConfig = config.configurable as ExtendedRunnableConfig; - - const chain = await createAgentWithTools(state, formattedConfig); - const formattedMessages = await formatMessages( - formattedConfig.ctx, - state.messages, - formattedConfig.chat.model!, - ); - - const response = await chain.invoke( - { - messages: formattedMessages, - }, - config, - ); - - let newMessages = response.messages.slice( - formattedMessages.length, - ) as BaseMessage[]; - - return { - messages: newMessages, - }; + const formattedConfig = config.configurable as ExtendedRunnableConfig; + + const chain = await createAgentWithTools(state, formattedConfig); + const formattedMessages = await formatMessages( + formattedConfig.ctx, + state.messages, + formattedConfig.chat.model, + ); + + const response = await chain.invoke( + { + messages: formattedMessages, + }, + config, + ); + + const newMessages = response.messages.slice( + formattedMessages.length, + ) as BaseMessage[]; + + return { + messages: newMessages, + }; } async function planner(state: typeof GraphState.State, config: RunnableConfig) { - const formattedConfig = config.configurable as ExtendedRunnableConfig; - const availableToolsDescription = await getAvailableToolsDescription( - state, - formattedConfig, - ); - const promptTemplate = createPlannerPrompt(availableToolsDescription); - - const model = await getModel( - formattedConfig.ctx, - formattedConfig.chat.model!, - formattedConfig.chat.reasoningEffort, - ); - - // Get model config to check if it's anthropic - const modelConfig = models.find( - (m) => m.model_name === formattedConfig.chat.model!, - ); - const isFunctionCallingParser = modelConfig?.parser === "functionCalling"; - - const modelWithOutputParser = promptTemplate.pipe( - isFunctionCallingParser - ? model.withStructuredOutput(planSchema, { method: "functionCalling" }) - : model.withStructuredOutput(planSchema), - ); - - const formattedMessages = await formatMessages( - formattedConfig.ctx, - state.messages, - formattedConfig.chat.model!, - ); - - const response = (await modelWithOutputParser.invoke( - { - messages: formattedMessages, - }, - config, - )) as z.infer; - - return { - plan: response.plan, - }; + const formattedConfig = config.configurable as ExtendedRunnableConfig; + const availableToolsDescription = await getAvailableToolsDescription( + state, + formattedConfig, + ); + const promptTemplate = createPlannerPrompt(availableToolsDescription); + + const model = await getModel( + formattedConfig.ctx, + formattedConfig.chat.model, + formattedConfig.chat.reasoningEffort, + ); + + // Get model config to check if it's anthropic + const modelConfig = models.find( + (m) => m.model_name === formattedConfig.chat.model, + ); + const isFunctionCallingParser = modelConfig?.parser === "functionCalling"; + + const modelWithOutputParser = promptTemplate.pipe( + isFunctionCallingParser + ? model.withStructuredOutput(planSchema, { method: "functionCalling" }) + : model.withStructuredOutput(planSchema), + ); + + const formattedMessages = await formatMessages( + formattedConfig.ctx, + state.messages, + formattedConfig.chat.model, + ); + + const response = (await modelWithOutputParser.invoke( + { + messages: formattedMessages, + }, + config, + )) as z.infer; + + return { + plan: response.plan, + }; } async function plannerAgent( - state: typeof GraphState.State, - config: RunnableConfig, + state: typeof GraphState.State, + config: RunnableConfig, ) { - const formattedConfig = config.configurable as ExtendedRunnableConfig; - - if (!state.plan || state.plan.length === 0) { - return {}; - } - - const currentPlanItem = state.plan[0]; - const remainingPlan = state.plan.slice(1); - const pastSteps = state.pastSteps || []; - - const plannerAgentChain = await createAgentWithTools( - state, - formattedConfig, - true, - ); - - const invoke = async ({ planItem }: { planItem: typeof currentPlanItem }) => { - if (planItem.type === "single") { - const response = await plannerAgentChain.invoke( - { - messages: [ - new HumanMessage( - `Task: ${planItem.data.step}\nContext: ${planItem.data.context}`, - ), - ], - }, - config, - ); - - const newMessages = response.messages.slice(1, response.messages.length); - const completedStep: CompletedStep = [planItem.data.step, newMessages]; - - return completedStep; - } - }; - - if (currentPlanItem.type === "single") { - const response = await invoke({ - planItem: currentPlanItem, - }); - - return { - plan: remainingPlan, - pastSteps: [...pastSteps, response], - }; - } else if (currentPlanItem.type === "parallel") { - const responses = await Promise.all( - currentPlanItem.data.map(async (planStep) => { - return await invoke({ - planItem: { type: "single" as const, data: planStep }, - }); - }), - ); - - return { - plan: remainingPlan, - pastSteps: [...pastSteps, ...responses], - }; - } + const formattedConfig = config.configurable as ExtendedRunnableConfig; + + if (!state.plan || state.plan.length === 0) { + return {}; + } + + const currentPlanItem = state.plan[0]; + const remainingPlan = state.plan.slice(1); + const pastSteps = state.pastSteps || []; + + const plannerAgentChain = await createAgentWithTools( + state, + formattedConfig, + true, + ); + + const invoke = async ({ planItem }: { planItem: typeof currentPlanItem }) => { + if (planItem.type === "single") { + const response = await plannerAgentChain.invoke( + { + messages: [ + new HumanMessage( + `Task: ${planItem.data.step}\nContext: ${planItem.data.context}`, + ), + ], + }, + config, + ); + + const newMessages = response.messages.slice(1, response.messages.length); + const completedStep: CompletedStep = [planItem.data.step, newMessages]; + + return completedStep; + } + }; + + if (currentPlanItem.type === "single") { + const response = await invoke({ + planItem: currentPlanItem, + }); + + return { + plan: remainingPlan, + pastSteps: [...pastSteps, response], + }; + } else if (currentPlanItem.type === "parallel") { + const responses = await Promise.all( + currentPlanItem.data.map(async (planStep) => { + return await invoke({ + planItem: { type: "single" as const, data: planStep }, + }); + }), + ); + + return { + plan: remainingPlan, + pastSteps: [...pastSteps, ...responses], + }; + } } async function replanner( - state: typeof GraphState.State, - config: RunnableConfig, + state: typeof GraphState.State, + config: RunnableConfig, ) { - const formattedConfig = config.configurable as ExtendedRunnableConfig; - const availableToolsDescription = await getAvailableToolsDescription( - state, - formattedConfig, - ); - const promptTemplate = createReplannerPrompt(availableToolsDescription); - - const model = await getModel( - formattedConfig.ctx, - formattedConfig.chat.model!, - formattedConfig.chat.reasoningEffort, - ); - - // Get model config to check if it's anthropic - const outputSchema = replannerOutputSchema(formattedConfig.chat.artifacts); - const modelConfig = models.find( - (m) => m.model_name === formattedConfig.chat.model!, - ); - const isFunctionCallingParser = modelConfig?.parser === "functionCalling"; - const modelWithOutputParser = promptTemplate.pipe( - isFunctionCallingParser - ? model.withStructuredOutput(outputSchema, { method: "functionCalling" }) - : model.withStructuredOutput(outputSchema), - ); - - // Filter messages for replanning: last 20 steps, no tool calls - const filteredMessages = filterMessagesForReplanning(state.messages); - - const formattedMessages = await formatMessages( - formattedConfig.ctx, - filteredMessages, - formattedConfig.chat.model!, - ); - - const response = (await modelWithOutputParser.invoke( - { - messages: formattedMessages, - plan: state.plan, - pastSteps: state.pastSteps - .slice(-10) // Also limit pastSteps to last 10 for replanning - .map((pastStep) => { - const [step, messages] = pastStep; - // Filter out tool messages from past steps as well - const filteredStepMessages = filterMessagesForReplanning(messages); - const stepMessage = new HumanMessage(step as string); - return [stepMessage, ...filteredStepMessages]; - }) - .flat(), - }, - config, - )) as z.infer; - - if (response.type === "respond_to_user") { - const responseMessages = [ - new AIMessage({ - content: response.data as string, - additional_kwargs: { - pastSteps: state.pastSteps.map((pastStep) => { - const [step, messages] = pastStep; - const storedMessages = mapChatMessagesToStoredMessages(messages); - return [step, storedMessages]; - }), - }, - }), - ]; - return { - messages: responseMessages, - plan: [], - pastSteps: [], - }; - } else if (response.type === "continue_planning") { - return { - plan: response.data as z.infer, - }; - } else { - throw new Error("Invalid response from replanner"); - } + const formattedConfig = config.configurable as ExtendedRunnableConfig; + const availableToolsDescription = await getAvailableToolsDescription( + state, + formattedConfig, + ); + const promptTemplate = createReplannerPrompt(availableToolsDescription); + + const model = await getModel( + formattedConfig.ctx, + formattedConfig.chat.model, + formattedConfig.chat.reasoningEffort, + ); + + // Get model config to check if it's anthropic + const outputSchema = replannerOutputSchema(formattedConfig.chat.artifacts); + const modelConfig = models.find( + (m) => m.model_name === formattedConfig.chat.model, + ); + const isFunctionCallingParser = modelConfig?.parser === "functionCalling"; + const modelWithOutputParser = promptTemplate.pipe( + isFunctionCallingParser + ? model.withStructuredOutput(outputSchema, { method: "functionCalling" }) + : model.withStructuredOutput(outputSchema), + ); + + // Filter messages for replanning: last 20 steps, no tool calls + const filteredMessages = filterMessagesForReplanning(state.messages); + + const formattedMessages = await formatMessages( + formattedConfig.ctx, + filteredMessages, + formattedConfig.chat.model, + ); + + const response = (await modelWithOutputParser.invoke( + { + messages: formattedMessages, + plan: state.plan, + pastSteps: state.pastSteps + .slice(-10) // Also limit pastSteps to last 10 for replanning + .flatMap((pastStep) => { + const [step, messages] = pastStep; + // Filter out tool messages from past steps as well + const filteredStepMessages = filterMessagesForReplanning(messages); + const stepMessage = new HumanMessage(step as string); + return [stepMessage, ...filteredStepMessages]; + }), + }, + config, + )) as z.infer; + + if (response.type === "respond_to_user") { + // Normalize various shapes into a single string for the final answer. + const raw = response.data; + let finalText: string; + if (typeof raw === "string") { + finalText = raw; + } else if (Array.isArray(raw)) { + const first = raw[0] as any; + finalText = + (first?.data?.context as string | undefined) ?? + (first?.data?.step as string | undefined) ?? + JSON.stringify(first ?? raw); + } else if (raw && typeof raw === "object") { + finalText = raw.context ?? raw.step ?? JSON.stringify(raw); + } else { + finalText = String(raw); + } + + const responseMessages = [ + new AIMessage({ + content: finalText, + additional_kwargs: { + pastSteps: state.pastSteps.map((pastStep) => { + const [step, messages] = pastStep; + const storedMessages = mapChatMessagesToStoredMessages(messages); + return [step, storedMessages]; + }), + }, + }), + ]; + return { messages: responseMessages, plan: [], pastSteps: [] }; + } else if (response.type === "continue_planning") { + return { plan: response.data as z.infer }; + } else { + throw new Error("Invalid response from replanner"); + } } async function shouldEndPlanner(state: typeof GraphState.State) { - if (!state.plan || state.plan.length === 0) { - return "true"; - } + if (!state.plan || state.plan.length === 0) { + return "true"; + } - return "false"; + return "false"; } export const agentGraph = new StateGraph(GraphState) - .addNode("simple", simple) - .addNode("baseAgent", baseAgent) - .addNode("planner", planner) - .addNode("plannerAgent", plannerAgent) - .addNode("replanner", replanner) - .addConditionalEdges(START, shouldPlanOrAgentOrSimple, { - planner: "planner", - baseAgent: "baseAgent", - simple: "simple", - }) - .addEdge("baseAgent", END) - .addEdge("planner", "plannerAgent") - .addEdge("plannerAgent", "replanner") - .addConditionalEdges("replanner", shouldEndPlanner, { - true: END, - false: "plannerAgent", - }); + .addNode("simple", simple) + .addNode("baseAgent", baseAgent) + .addNode("planner", planner) + .addNode("plannerAgent", plannerAgent) + .addNode("replanner", replanner) + .addConditionalEdges(START, shouldPlanOrAgentOrSimple, { + planner: "planner", + baseAgent: "baseAgent", + simple: "simple", + }) + .addEdge("baseAgent", END) + .addEdge("planner", "plannerAgent") + .addEdge("plannerAgent", "replanner") + .addConditionalEdges("replanner", shouldEndPlanner, { + true: END, + false: "plannerAgent", + }); diff --git a/convex/langchain/helpers.ts b/convex/langchain/helpers.ts index 9936953..483c962 100644 --- a/convex/langchain/helpers.ts +++ b/convex/langchain/helpers.ts @@ -33,7 +33,7 @@ export type ExtendedRunnableConfig = RunnableConfig & { export async function createSimpleAgent( _state: typeof GraphState.State, - config: ExtendedRunnableConfig, + config: ExtendedRunnableConfig ) { const chat = config.chat; const model = await getModel(config.ctx, chat.model, chat.reasoningEffort); @@ -43,7 +43,7 @@ export async function createSimpleAgent( undefined, config.customPrompt, false, - chat.artifacts, + chat.artifacts ), new MessagesPlaceholder("messages"), ]); @@ -53,7 +53,7 @@ export async function createSimpleAgent( export async function createAgentWithTools( state: typeof GraphState.State, config: ExtendedRunnableConfig, - plannerMode: boolean = false, + plannerMode: boolean = false ) { const chat = config.chat; const allTools = await getAvailableTools(state, config); @@ -66,7 +66,7 @@ export async function createAgentWithTools( plannerMode, plannerMode ? undefined : config.customPrompt, true, - plannerMode ? false : chat.artifacts, + plannerMode ? false : chat.artifacts ), new MessagesPlaceholder("messages"), ]); @@ -85,7 +85,7 @@ export async function createAgentWithTools( const supervisorLlm = await getModel( config.ctx, chat.model!, - chat.reasoningEffort, + chat.reasoningEffort ); const agents = toolkits.map((toolkit: Toolkit) => createReactAgent({ @@ -93,7 +93,7 @@ export async function createAgentWithTools( tools: toolkit.tools, name: toolkit.name, prompt: `You are a ${toolkit.name} assistant`, - }), + }) ); return createSupervisor({ agents: [...agents], @@ -103,7 +103,7 @@ export async function createAgentWithTools( plannerMode, plannerMode ? undefined : config.customPrompt, true, - plannerMode ? false : chat.artifacts, + plannerMode ? false : chat.artifacts ), }).compile(); } @@ -112,7 +112,7 @@ export async function createAgentWithTools( export function getPlannerAgentResponse(messages: BaseMessage[]): BaseMessage { // filter and concat all ai messages const aiResponses = messages.filter( - (message) => typeof message === typeof AIMessage, + (message) => message instanceof AIMessage ); const storedAIResponses = mapChatMessagesToStoredMessages(aiResponses); return mapStoredMessagesToChatMessages([ @@ -130,7 +130,7 @@ export function getPlannerAgentResponse(messages: BaseMessage[]): BaseMessage { export function getLastMessage( messages: BaseMessage[], - type: "ai" | "human", + type: "ai" | "human" ): { message: BaseMessage; index: number } | null { for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; @@ -152,36 +152,54 @@ export type Toolkit = { // Overloads to provide precise return types based on `groupTools` export async function getAvailableTools( state: typeof GraphState.State, - config: ExtendedRunnableConfig, + config: ExtendedRunnableConfig ): Promise[]>; export async function getAvailableTools( state: typeof GraphState.State, config: ExtendedRunnableConfig, - groupTools: false, + groupTools: false ): Promise[]>; export async function getAvailableTools( state: typeof GraphState.State, config: ExtendedRunnableConfig, - groupTools: true, + groupTools: true ): Promise; export async function getAvailableTools( state: typeof GraphState.State, config: ExtendedRunnableConfig, - groupTools: boolean = false, + groupTools: boolean = false ): Promise[]> { const chat = config.chat; - const [mcpTools, retrievalTools] = await Promise.all([ + const [mcpResult, retrievalResult] = await Promise.allSettled([ getMCPTools(config.ctx, state, config), getRetrievalTools(state, config, true), ]); + const mcpData = + mcpResult.status === "fulfilled" + ? mcpResult.value + : { + tools: [], + groupedTools: {} as Record< + string, + StructuredToolInterface[] + >, + }; + + const retrievalData = + retrievalResult.status === "fulfilled" ? retrievalResult.value : null; + if (!groupTools) { - return [ - ...mcpTools, - ...(chat.projectId ? [retrievalTools.vectorSearch] : []), - ...(chat.webSearch ? [retrievalTools.webSearch] : []), + const pickedRetrievalTools: StructuredToolInterface[] = [ + ...(chat.projectId && retrievalData?.vectorSearch + ? [retrievalData.vectorSearch] + : []), + ...(chat.webSearch && retrievalData?.webSearch + ? [retrievalData.webSearch] + : []), ]; + return [...mcpData.tools, ...pickedRetrievalTools]; } // Group MCP tools by server name (tool name format: "mcp____") @@ -189,7 +207,7 @@ export async function getAvailableTools( string, StructuredToolInterface[] >(); - for (const tool of mcpTools) { + for (const tool of mcpData.tools) { const parts = tool.name.split("__"); const groupName = parts.length >= 2 ? parts[1] : "MCP"; if (!mcpGrouped.has(groupName)) mcpGrouped.set(groupName, []); @@ -201,20 +219,25 @@ export async function getAvailableTools( name, tools, })), - ...(chat.webSearch - ? [{ name: "WebSearch", tools: [retrievalTools.webSearch] }] - : []), - ...(chat.projectId - ? [{ name: "VectorSearch", tools: [retrievalTools.vectorSearch] }] - : []), ]; + if (chat.webSearch && retrievalData?.webSearch) { + toolkits.push({ name: "WebSearch", tools: [retrievalData.webSearch] }); + } + + if (chat.projectId && retrievalData?.vectorSearch) { + toolkits.push({ + name: "VectorSearch", + tools: [retrievalData.vectorSearch], + }); + } + return toolkits; } export async function getAvailableToolsDescription( state: typeof GraphState.State, - config: ExtendedRunnableConfig, + config: ExtendedRunnableConfig ): Promise { const toolsInfo: StructuredToolInterface[] = await getAvailableTools(state, config); @@ -229,7 +252,7 @@ export async function getAvailableToolsDescription( } export function extractFileIdsFromMessage( - messageContent: any, + messageContent: any ): Id<"documents">[] { const fileIds: Id<"documents">[] = []; diff --git a/convex/langchain/index.ts b/convex/langchain/index.ts index f53015a..6a071ca 100644 --- a/convex/langchain/index.ts +++ b/convex/langchain/index.ts @@ -6,488 +6,660 @@ import type { Doc, Id } from "../_generated/dataModel"; import { agentGraph } from "./agent"; import { api, internal } from "../_generated/api"; import { - mapChatMessagesToStoredMessages, - mapStoredMessageToChatMessage, - type StoredMessage, - SystemMessage, - ToolMessage, - HumanMessage, - BaseMessage, + mapChatMessagesToStoredMessages, + mapStoredMessageToChatMessage, + type StoredMessage, + SystemMessage, + ToolMessage, + HumanMessage, + BaseMessage, } from "@langchain/core/messages"; import { MemorySaver } from "@langchain/langgraph"; import type { GraphState, AIChunkGroup, ToolChunkGroup } from "./state"; import { v } from "convex/values"; import { - getThreadFromMessage, - processBufferToMessages, + getThreadFromMessage, + processBufferToMessages, } from "../chatMessages/helpers"; import { formatMessages, getModel } from "./models"; import { ChatMessages, Chats } from "../schema"; import { checkInternal, trackInternal } from "../autumn"; import { models } from "./models"; +// Helper: Append a ToolChunkGroup JSON string to the provided buffer +function appendToolChunk( + buffer: string[], + options: { + toolName: string; + isComplete: boolean; + toolCallId: string; + input?: unknown; + output?: unknown; + }, +): void { + buffer.push( + JSON.stringify({ + type: "tool", + toolName: options.toolName, + input: options.input, + output: options.output, + isComplete: options.isComplete, + toolCallId: options.toolCallId, + } as ToolChunkGroup), + ); +} + +// Helper: Extract completed step names from a LangGraph checkpoint +function extractCompletedStepsFromCheckpoint( + checkpoint: typeof GraphState.State | null | undefined, +): string[] { + const completedSteps: string[] = []; + + const pastSteps = (checkpoint as any)?.pastSteps as + | Array<[string, unknown[]]> + | undefined; + if (pastSteps && pastSteps.length > 0) { + completedSteps.push(...pastSteps.map((ps) => ps[0])); + } + + const plan = (checkpoint as any)?.plan as + | Array< + | { + type: "parallel"; + data: Array<{ step: string; context: string }>; + } + | { type: "single"; data: { step: string; context: string } } + > + | undefined; + if (plan && plan.length > 0) { + const first = plan[0]; + if (first.type === "parallel") { + completedSteps.push(...first.data.map((s) => s.step)); + } else { + completedSteps.push(first.data.step); + } + } + + return completedSteps; +} export const generateTitle = internalAction({ - args: v.object({ - chat: Chats.doc, - message: ChatMessages.doc, - }), - handler: async (ctx, args) => { - const firstMessage = await formatMessages( - ctx, - [ - mapStoredMessageToChatMessage( - JSON.parse(args.message.message) as StoredMessage, - ), - ], - args.chat.model, - ); - const model = await getModel(ctx, "worker", undefined, args.chat.userId); - const titleSchema = z.object({ - title: z - .string() - .describe("A short title for the chat. Keep it under 6 words."), - }); - const structuredModel = model.withStructuredOutput(titleSchema); - const title = (await structuredModel.invoke([ - new SystemMessage( - "You are a title generator that generates a short title for the following user message.", - ), - ...firstMessage, - ])) as z.infer; - await ctx.runMutation(internal.chats.crud.update, { - id: args.chat._id, - patch: { - name: title.title, - updatedAt: Date.now(), - }, - }); - }, + args: v.object({ + chat: Chats.doc, + message: ChatMessages.doc, + }), + handler: async (ctx, args) => { + const firstMessage = await formatMessages( + ctx, + [ + mapStoredMessageToChatMessage( + JSON.parse(args.message.message) as StoredMessage, + ), + ], + args.chat.model, + ); + const model = await getModel(ctx, "worker", undefined, args.chat.userId); + const titleSchema = z.object({ + title: z + .string() + .describe("A short title for the chat. Keep it under 6 words."), + }); + const structuredModel = model.withStructuredOutput(titleSchema); + const title = (await structuredModel.invoke([ + new SystemMessage( + "You are a title generator that generates a short title for the following user message.", + ), + ...firstMessage, + ])) as z.infer; + await ctx.runMutation(internal.chats.crud.update, { + id: args.chat._id, + patch: { + name: title.title, + updatedAt: Date.now(), + }, + }); + }, }); export const chat = action({ - args: v.object({ - chatId: v.id("chats"), - model: v.optional(v.string()), - }), - handler: async (ctx, args) => { - const { chatId } = args; - const prep = await ctx.runMutation(internal.langchain.utils.prepareChat, { - chatId, - model: args.model, - }); - const { chat, message, messages, customPrompt } = prep!; - const thread = getThreadFromMessage(message, messages); - - const modelConfig = models.find((m) => m.model_name === chat.model); - const multiplier = modelConfig?.usageRateMultiplier ?? 1.0; - - // Check usage limits before processing chat, accounting for multiplier - const usageCheck = await checkInternal( - chat.userId!, - "messages", - multiplier, - ); - if (!usageCheck.allowed) { - throw new Error( - `Message limit exceeded. ${usageCheck.message || "Please upgrade your plan to send more messages."}`, - ); - } - - const checkpointer = new MemorySaver(); - const agent = agentGraph.compile({ checkpointer }); - - const abort = new AbortController(); - const stream = agent.streamEvents( - { messages: thread.map((m) => m.message) }, - { - version: "v2", - configurable: { ctx, chat, customPrompt, thread_id: chatId }, - recursionLimit: 30, - signal: abort.signal, - }, - ); - - let streamDoc: Doc<"streams"> | null = null; - let buffer: string[] = []; - let accumulatedBuffer: string[] = []; - let checkpoint: typeof GraphState.State | null = null; - let finalMessages: BaseMessage[] | null = null; - let finished = false; - let hadError = false; - - const flushAndStream = async (): Promise< - typeof GraphState.State | null - > => { - let localCheckpoint: typeof GraphState.State | null = null; - - const flusher = async () => { - while (!finished) { - if (buffer.length > 0) { - const chunks = buffer; - buffer = []; - accumulatedBuffer = [...accumulatedBuffer, ...chunks]; - streamDoc = await ctx.runMutation( - internal.streams.mutations.flush, - { - chatId, - chunks, - completedSteps: [ - ...(localCheckpoint?.pastSteps?.map( - (pastStep) => pastStep[0], - ) ?? []), - ...(localCheckpoint?.plan && localCheckpoint.plan.length > 0 - ? [ - ...(localCheckpoint.plan[0].type === "parallel" - ? localCheckpoint.plan[0].data.map( - (step) => step.step, - ) - : [localCheckpoint.plan[0].data.step]), - ] - : []), - ], - }, - ); - } - if (streamDoc?.status === "cancelled") { - abort.abort(); - return null; - } - await new Promise((resolve) => setTimeout(resolve, 300)); - } - }; - - const streamer = async () => { - try { - for await (const evt of stream) { - if (abort.signal.aborted) { - return; - } - localCheckpoint = ( - await agent.getState({ - configurable: { thread_id: chatId }, - }) - ).values as typeof GraphState.State; - - const allowedNodes = ["baseAgent", "simple", "plannerAgent"]; - if ( - allowedNodes.some((node) => - evt.metadata?.checkpoint_ns?.startsWith(node), - ) - ) { - if (evt.event === "on_chat_model_stream") { - buffer.push( - JSON.stringify({ - type: "ai", - content: evt.data?.chunk?.content ?? "", - reasoning: - evt.data?.chunk?.additional_kwargs?.reasoning_content, - } as AIChunkGroup), - ); - } else if (evt.event === "on_tool_start") { - buffer.push( - JSON.stringify({ - type: "tool", - toolName: evt.name, - input: evt.data?.input, - isComplete: false, - toolCallId: evt.run_id, - } as ToolChunkGroup), - ); - } else if (evt.event === "on_tool_end") { - let output = evt.data?.output.content; - - if (Array.isArray(output)) { - output = await Promise.all( - output.map(async (item: any) => { - if ( - item.type === "image_url" && - item.image_url && - item.image_url.url - ) { - return { - type: "image_url", - image_url: { - url: "https://t3.chat/images/noise.png", - }, - }; - } - return item; - }), - ); - } - - buffer.push( - JSON.stringify({ - type: "tool", - toolName: evt.name, - input: evt.data?.input, - output, - isComplete: true, - toolCallId: evt.run_id, - } as ToolChunkGroup), - ); - } - } - } - } finally { - finished = true; - } - }; - - await Promise.all([flusher(), streamer()]); - return localCheckpoint; - }; - - try { - checkpoint = await flushAndStream(); - finalMessages = checkpoint?.messages!; - } catch (e) { - hadError = true; - // Create messages from accumulated buffer and combine with existing thread - const bufferMessages = processBufferToMessages(accumulatedBuffer); - finalMessages = [...thread.map((m) => m.message), ...bufferMessages]; - - if (abort.signal.aborted) { - // Continue processing the buffer messages even when aborted - } else { - // Update status to error but continue processing - await ctx.runMutation(internal.streams.mutations.update, { - chatId, - updates: { - completedSteps: [], - status: "error", - }, - }); - } - } - - const newMessages = finalMessages?.slice(thread.length); - if (newMessages?.length) { - const parent: Id<"chatMessages"> | null = thread.length - ? thread[thread.length - 1]._id - : null; - - // Process all messages and prepare them for batch creation - const messagesToCreate: Array<{ - message: string; - parentId?: Id<"chatMessages">; - }> = []; - - for (const m of newMessages) { - let stored = mapChatMessagesToStoredMessages([m])[0]; - - if (m instanceof ToolMessage && Array.isArray(m.content)) { - const patched = await Promise.all( - m.content.map(async (item) => { - if ( - item.type === "image_url" && - item.image_url?.url?.startsWith("data:") - ) { - const [, mime, base64] = - item.image_url.url.match(/^data:(.+);base64,(.+)$/) ?? []; - const blob = await ( - await fetch(`data:${mime};base64,${base64}`) - ).blob(); - const key = await ctx.storage.store(blob); - const docId = await ctx.runMutation( - api.documents.mutations.create, - { - name: "Image Upload - " + new Date().toISOString(), - type: "file", - key, - size: blob.size, - }, - ); - return { type: "file", file: { file_id: docId } }; - } - return item; - }), - ); - stored = { - ...stored, - data: { ...stored.data, content: JSON.stringify(patched) }, - }; - } - - messagesToCreate.push({ - message: JSON.stringify(stored), - parentId: parent ?? undefined, - }); - } - - // Create all messages in a single batch operation - if (messagesToCreate.length > 0) { - await ctx.runMutation(internal.chats.mutations.createRaw, { - chatId, - messages: messagesToCreate, - }); - } - } - - // Only update to "done" if there was no error and we weren't aborted - if (!hadError && !abort.signal.aborted) { - await ctx.runMutation(internal.streams.mutations.update, { - chatId, - updates: { status: "done", completedSteps: [] }, - }); - } - - // Track message usage - count the number of new messages created - if (newMessages?.length) { - // Apply multiplier and round to nearest integer - const usageValue = Math.round(newMessages.length * multiplier); - - await trackInternal(chat.userId!, "messages", usageValue); - } - }, + args: v.object({ + chatId: v.id("chats"), + model: v.optional(v.string()), + }), + handler: async (ctx, args) => { + const { chatId } = args; + const prep = await ctx.runMutation(internal.langchain.utils.prepareChat, { + chatId, + model: args.model, + }); + const { chat, message, messages, customPrompt } = prep!; + const thread = getThreadFromMessage(message, messages); + + const modelConfig = models.find((m) => m.model_name === chat.model); + const multiplier = modelConfig?.usageRateMultiplier ?? 1.0; + + // Check usage limits before processing chat, accounting for multiplier + const usageCheck = await checkInternal( + chat.userId!, + "messages", + multiplier, + ); + if (!usageCheck.allowed) { + throw new Error( + `Message limit exceeded. ${usageCheck.message || "Please upgrade your plan to send more messages."}`, + ); + } + + const checkpointer = new MemorySaver(); + const agent = agentGraph.compile({ checkpointer }); + + const abort = new AbortController(); + const stream = agent.streamEvents( + { messages: thread.map((m) => m.message) }, + { + version: "v2", + configurable: { ctx, chat, customPrompt, thread_id: chatId }, + recursionLimit: 30, + signal: abort.signal, + }, + ); + + let streamDoc: Doc<"streams"> | null = null; + let buffer: string[] = []; + let accumulatedBuffer: string[] = []; + let checkpoint: typeof GraphState.State | null = null; + let finalMessages: BaseMessage[] | null = null; + let finished = false; + let hadError = false; + let pendingCheckpointRefresh = false; + + const flushAndStream = async (): Promise< + typeof GraphState.State | null + > => { + let localCheckpoint: typeof GraphState.State | null = null; + + const flusher = async () => { + while (!finished) { + if (buffer.length > 0) { + if (pendingCheckpointRefresh) { + // Fetch checkpoint lazily only when we are about to flush + localCheckpoint = ( + await agent.getState({ configurable: { thread_id: chatId } }) + ).values as typeof GraphState.State; + pendingCheckpointRefresh = false; + } + const chunks = buffer; + buffer = []; + accumulatedBuffer = [...accumulatedBuffer, ...chunks]; + streamDoc = await ctx.runMutation( + internal.streams.mutations.flush, + { + chatId, + chunks, + completedSteps: [ + ...(localCheckpoint?.pastSteps?.map( + (pastStep) => pastStep[0], + ) ?? []), + ...(localCheckpoint?.plan && localCheckpoint.plan.length > 0 + ? [ + ...(localCheckpoint.plan[0].type === "parallel" + ? localCheckpoint.plan[0].data.map( + (step) => step.step, + ) + : [localCheckpoint.plan[0].data.step]), + ] + : []), + ], + }, + ); + } + if (streamDoc?.status === "cancelled") { + abort.abort(); + return null; + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + }; + + const streamer = async () => { + try { + for await (const evt of stream) { + if (abort.signal.aborted) { + return; + } + + const allowedNodes = [ + "baseAgent", + "simple", + "plannerAgent", + "replanner", + ]; + if ( + allowedNodes.some((node) => + evt.metadata?.checkpoint_ns?.startsWith(node), + ) + ) { + if (evt.event === "on_chat_model_stream") { + buffer.push( + JSON.stringify({ + type: "ai", + content: evt.data?.chunk?.content ?? "", + reasoning: + evt.data?.chunk?.additional_kwargs?.reasoning_content, + } as AIChunkGroup), + ); + } else if (evt.event === "on_tool_start") { + appendToolChunk(buffer, { + toolName: evt.name, + input: evt.data?.input, + isComplete: false, + toolCallId: evt.run_id, + }); + } else if (evt.event === "on_tool_stream") { + appendToolChunk(buffer, { + toolName: evt.name, + output: evt.data?.chunk, + isComplete: false, + toolCallId: evt.run_id, + }); + } else if (evt.event === "on_tool_end") { + let output = evt.data?.output?.content ?? evt.data?.output; + + if (Array.isArray(output)) { + output = await Promise.all( + output.map(async (item: any) => { + if ( + item.type === "image_url" && + item.image_url && + item.image_url.url + ) { + return { + type: "image_url", + image_url: { + url: "https://t3.chat/images/noise.png", + }, + }; + } + return item; + }), + ); + } + appendToolChunk(buffer, { + toolName: evt.name, + input: evt.data?.input, + output, + isComplete: true, + toolCallId: evt.run_id, + }); + // Mark that the checkpoint should be refreshed soon + pendingCheckpointRefresh = true; + } else if (evt.event === "on_tool_error") { + // Gracefully surface tool errors to the client and mark the tool call as complete + const errorOutput = + (evt.data as any)?.error?.message ?? + (evt.data as any)?.error ?? + "Tool execution failed"; + appendToolChunk(buffer, { + toolName: evt.name, + input: (evt.data as any)?.input, + output: `Error: ${errorOutput}`, + isComplete: true, + toolCallId: evt.run_id, + }); + // Force checkpoint refresh so calling step is marked done/errored + pendingCheckpointRefresh = true; + } else if ( + evt.event === "on_chat_model_end" || + evt.event === "on_chain_end" + ) { + // Model or chain finished a unit of work; refresh on next flush + pendingCheckpointRefresh = true; + } else if (evt.event === "on_custom_event") { + const raw: any = evt; + const data = raw?.data; + if (!data || typeof data !== "object") { + console.warn( + "[stream] Ignoring custom event without object data", + { + event: raw?.event, + name: raw?.name, + }, + ); + return; + } + + // Prefer the framework-provided event name + const eventName = + typeof raw?.name === "string" + ? raw.name + : typeof (data as any).event === "string" + ? (data as any).event + : typeof (data as any).name === "string" + ? (data as any).name + : undefined; + + if (!eventName) { + console.warn( + "[stream] Ignoring custom event with missing name", + { + name: raw?.name, + }, + ); + return; + } + + // For dispatchCustomEvent, the payload is in evt.data + const payload = data; + const chunk = + typeof (payload as any).chunk === "string" + ? (payload as any).chunk + : undefined; + // Ignore `complete` flag on tool_progress to prevent duplicate completion chunks; let on_tool_end handle completion. + const isComplete = + eventName === "tool_progress" + ? false + : payload.complete === true; + + if ( + eventName === "tool_stream" || + eventName === "tool_progress" + ) { + if (!chunk) { + console.warn( + "[stream] tool_progress custom event missing chunk; skipping", + ); + return; + } + appendToolChunk(buffer, { + toolName: + typeof raw?.name === "string" ? raw.name : "custom_event", + output: chunk, + isComplete, + toolCallId: raw.run_id, + }); + if (isComplete) { + // Custom tool stream finished; refresh on next flush + pendingCheckpointRefresh = true; + } + } + } + } + } + } finally { + finished = true; + } + }; + + await Promise.all([flusher(), streamer()]); + // Final flush in case there are any buffered chunks left + if (buffer.length > 0) { + // Always refresh checkpoint before final flush to ensure accuracy + localCheckpoint = ( + await agent.getState({ configurable: { thread_id: chatId } }) + ).values as typeof GraphState.State; + pendingCheckpointRefresh = false; + const chunks = buffer; + buffer = []; + const completedSteps = + extractCompletedStepsFromCheckpoint(localCheckpoint); + await ctx.runMutation(internal.streams.mutations.flush, { + chatId, + chunks, + completedSteps, + }); + } + return localCheckpoint; + }; + + try { + checkpoint = await flushAndStream(); + finalMessages = checkpoint?.messages!; + } catch (e) { + hadError = true; + // Create messages from accumulated buffer and combine with existing thread + const bufferMessages = processBufferToMessages(accumulatedBuffer); + finalMessages = [...thread.map((m) => m.message), ...bufferMessages]; + + if (abort.signal.aborted) { + // Continue processing the buffer messages even when aborted + } else { + // Update status to error but continue processing + await ctx.runMutation(internal.streams.mutations.update, { + chatId, + updates: { + completedSteps: [], + status: "error", + }, + }); + } + } + + const newMessages = finalMessages?.slice(thread.length); + if (newMessages?.length) { + const parent: Id<"chatMessages"> | null = thread.length + ? thread[thread.length - 1]._id + : null; + + // Process all messages and prepare them for batch creation + const messagesToCreate: Array<{ + message: string; + parentId?: Id<"chatMessages">; + }> = []; + + for (const m of newMessages) { + let stored = mapChatMessagesToStoredMessages([m])[0]; + + if (m instanceof ToolMessage && Array.isArray(m.content)) { + const patched = await Promise.all( + m.content.map(async (item) => { + if ( + item.type === "image_url" && + item.image_url?.url?.startsWith("data:") + ) { + const [, mime, base64] = + item.image_url.url.match(/^data:(.+);base64,(.+)$/) ?? []; + const blob = await ( + await fetch(`data:${mime};base64,${base64}`) + ).blob(); + const key = await ctx.storage.store(blob); + const docId = await ctx.runMutation( + api.documents.mutations.create, + { + name: "Image Upload - " + new Date().toISOString(), + type: "file", + key, + size: blob.size, + }, + ); + return { type: "file", file: { file_id: docId } }; + } + return item; + }), + ); + stored = { + ...stored, + data: { ...stored.data, content: JSON.stringify(patched) }, + }; + } + + messagesToCreate.push({ + message: JSON.stringify(stored), + parentId: parent ?? undefined, + }); + } + + // Create all messages in a single batch operation + if (messagesToCreate.length > 0) { + await ctx.runMutation(internal.chats.mutations.createRaw, { + chatId, + messages: messagesToCreate, + }); + } + } + + // Only update to "done" if there was no error and we weren't aborted + if (!hadError && !abort.signal.aborted) { + await ctx.runMutation(internal.streams.mutations.update, { + chatId, + updates: { status: "done", completedSteps: [] }, + }); + } + + // Track message usage - count the number of new messages created + if (newMessages?.length) { + // Apply multiplier and round to nearest integer + const usageValue = Math.round(newMessages.length * multiplier); + + await trackInternal(chat.userId!, "messages", usageValue); + } + }, }); export const regenerate = action({ - args: v.object({ - messageId: v.id("chatMessages"), - }), - handler: async (ctx, args) => { - const message = await ctx.runQuery(internal.chatMessages.crud.read, { - id: args.messageId, - }); - if (!message) { - throw new Error("Message not found"); - } - await ctx.runMutation(internal.chatMessages.mutations.regenerate, { - messageId: args.messageId, - }); - // Intentionally do not forward model here; chat will use the saved chat.model. - // If a model change is desired, update the chat first and then call regenerate. - await ctx.runAction(api.langchain.index.chat, { - chatId: message.chatId, - }); - }, + args: v.object({ + messageId: v.id("chatMessages"), + }), + handler: async (ctx, args) => { + const message = await ctx.runQuery(internal.chatMessages.crud.read, { + id: args.messageId, + }); + if (!message) { + throw new Error("Message not found"); + } + await ctx.runMutation(internal.chatMessages.mutations.regenerate, { + messageId: args.messageId, + }); + // Intentionally do not forward model here; chat will use the saved chat.model. + // If a model change is desired, update the chat first and then call regenerate. + await ctx.runAction(api.langchain.index.chat, { + chatId: message.chatId, + }); + }, }); export const branchChat = action({ - args: v.object({ - chatId: v.id("chats"), - branchFrom: v.id("chatMessages"), - model: v.optional(v.string()), - editedContent: v.optional( - v.object({ - text: v.optional(v.string()), - documents: v.optional(v.array(v.id("documents"))), - }), - ), - }), - handler: async (ctx, args): Promise<{ newChatId: Id<"chats"> }> => { - const chatDoc = await ctx.runQuery(api.chats.queries.get, { - chatId: args.chatId, - }); - - const newChatId = await ctx.runMutation(api.chats.mutations.create, { - name: `Branched: ${chatDoc.name}`, - model: args.model ?? chatDoc.model, - reasoningEffort: chatDoc.reasoningEffort, - projectId: chatDoc.projectId, - conductorMode: chatDoc.conductorMode, - orchestratorMode: chatDoc.orchestratorMode, - webSearch: chatDoc.webSearch, - artifacts: chatDoc.artifacts, - }); - - const allMessages = await ctx.runQuery(api.chatMessages.queries.get, { - chatId: args.chatId, - }); - - const branchFromMessage = allMessages.find( - (m) => m._id === args.branchFrom, - ); - if (!branchFromMessage) { - throw new Error("Branch message not found"); - } - - const thread = getThreadFromMessage(branchFromMessage, allMessages); - - const lastMessage = thread.at(-1); - if (lastMessage) { - const storedMessage = mapChatMessagesToStoredMessages([ - lastMessage.message, - ])[0]; - if (storedMessage.type === "ai") { - thread.pop(); - } - } - - // If edited content is provided, replace the last message with edited content - if (args.editedContent) { - const { text, documents } = args.editedContent; - - // Only proceed if there's actual content - if (text || (documents && documents.length > 0)) { - // Create the edited message - const editedMessage = JSON.stringify( - mapChatMessagesToStoredMessages([ - new HumanMessage({ - content: [ - ...(text ? [{ type: "text", text }] : []), - ...(documents && documents.length > 0 - ? documents.map((documentId) => ({ - type: "file", - file: { - file_id: documentId, - }, - })) - : []), - ], - }), - ])[0], - ); - - // Replace the last message in the thread with the edited content - if (thread.length > 0) { - // We'll handle this replacement during the mapping phase - thread[thread.length - 1] = { - ...thread[thread.length - 1], - // Mark this message for replacement - _replacementMessage: editedMessage, - } as any; - } else { - // If no thread, create a new message - const editedHumanMessage = new HumanMessage({ - content: [ - ...(text ? [{ type: "text", text }] : []), - ...(documents && documents.length > 0 - ? documents.map((documentId) => ({ - type: "file", - file: { - file_id: documentId, - }, - })) - : []), - ], - }); - - thread.push({ - ...branchFromMessage, - message: editedHumanMessage, - }); - } - } - } - - if (thread.length > 0) { - await ctx.runMutation(internal.chats.mutations.createRaw, { - chatId: newChatId, - messages: thread.map((m) => ({ - message: - (m as any)._replacementMessage || - (typeof m.message === "string" - ? m.message - : JSON.stringify( - mapChatMessagesToStoredMessages([m.message])[0], - )), - })), - }); - } - - return { newChatId }; - }, + args: v.object({ + chatId: v.id("chats"), + branchFrom: v.id("chatMessages"), + model: v.optional(v.string()), + editedContent: v.optional( + v.object({ + text: v.optional(v.string()), + documents: v.optional(v.array(v.id("documents"))), + }), + ), + }), + handler: async (ctx, args): Promise<{ newChatId: Id<"chats"> }> => { + const chatDoc = await ctx.runQuery(api.chats.queries.get, { + chatId: args.chatId, + }); + + const newChatId = await ctx.runMutation(api.chats.mutations.create, { + name: `Branched: ${chatDoc.name}`, + model: args.model ?? chatDoc.model, + reasoningEffort: chatDoc.reasoningEffort, + projectId: chatDoc.projectId, + conductorMode: chatDoc.conductorMode, + orchestratorMode: chatDoc.orchestratorMode, + webSearch: chatDoc.webSearch, + artifacts: chatDoc.artifacts, + }); + + const allMessages = await ctx.runQuery(api.chatMessages.queries.get, { + chatId: args.chatId, + }); + + const branchFromMessage = allMessages.find( + (m) => m._id === args.branchFrom, + ); + if (!branchFromMessage) { + throw new Error("Branch message not found"); + } + + const thread = getThreadFromMessage(branchFromMessage, allMessages); + + const lastMessage = thread.at(-1); + if (lastMessage) { + const storedMessage = mapChatMessagesToStoredMessages([ + lastMessage.message, + ])[0]; + if (storedMessage.type === "ai") { + thread.pop(); + } + } + + // If edited content is provided, replace the last message with edited content + if (args.editedContent) { + const { text, documents } = args.editedContent; + + // Only proceed if there's actual content + if (text || (documents && documents.length > 0)) { + // Create the edited message + const editedMessage = JSON.stringify( + mapChatMessagesToStoredMessages([ + new HumanMessage({ + content: [ + ...(text ? [{ type: "text", text }] : []), + ...(documents && documents.length > 0 + ? documents.map((documentId) => ({ + type: "file", + file: { + file_id: documentId, + }, + })) + : []), + ], + }), + ])[0], + ); + + // Replace the last message in the thread with the edited content + if (thread.length > 0) { + // We'll handle this replacement during the mapping phase + thread[thread.length - 1] = { + ...thread[thread.length - 1], + // Mark this message for replacement + _replacementMessage: editedMessage, + } as any; + } else { + // If no thread, create a new message + const editedHumanMessage = new HumanMessage({ + content: [ + ...(text ? [{ type: "text", text }] : []), + ...(documents && documents.length > 0 + ? documents.map((documentId) => ({ + type: "file", + file: { + file_id: documentId, + }, + })) + : []), + ], + }); + + thread.push({ + ...branchFromMessage, + message: editedHumanMessage, + }); + } + } + } + + if (thread.length > 0) { + await ctx.runMutation(internal.chats.mutations.createRaw, { + chatId: newChatId, + messages: thread.map((m) => ({ + message: + (m as any)._replacementMessage || + (typeof m.message === "string" + ? m.message + : JSON.stringify( + mapChatMessagesToStoredMessages([m.message])[0], + )), + })), + }); + } + + return { newChatId }; + }, }); diff --git a/convex/langchain/prompts.ts b/convex/langchain/prompts.ts index accfbd1..21b64c6 100644 --- a/convex/langchain/prompts.ts +++ b/convex/langchain/prompts.ts @@ -136,7 +136,7 @@ export function createAgentSystemMessage( plannerMode: boolean = false, customPrompt?: string, baseAgentType: boolean = true, - artifacts: boolean = true, + artifacts: boolean = true ): SystemMessage { const baseIdentity = `You are 0bs Chat, an AI assistant powered by the ${model} model.`; @@ -178,56 +178,103 @@ export function createAgentSystemMessage( `- If documents are provided, they are made avilable to in /mnt/data directory.\n`; return new SystemMessage( - `${baseIdentity} ${roleDescription}${communicationGuidelines}${formattingGuidelines}${baseAgentType ? baseAgentGuidelines : ""}${artifacts ? artifactsGuidelines : ""}${customPrompt ? customPrompt : ""}`, + `${baseIdentity} ${roleDescription}${communicationGuidelines}${formattingGuidelines}${baseAgentType ? baseAgentGuidelines : ""}${artifacts ? artifactsGuidelines : ""}${customPrompt ? customPrompt : ""}` ); } // Prompt template for planner export function createPlannerPrompt(availableToolsDescription: string) { - const toolsSection = `\n**Available Tools:**\n${availableToolsDescription}\n\nWhen planning steps, consider which tools are available and how they can be used to accomplish the objective efficiently.`; + const toolsSection = + `\n**Available Tools**\n${availableToolsDescription}\n` + + `When planning, think about which tool each step will need (if any).\n`; + // --- NEW PROMPT --------------------------------------------------------- return ChatPromptTemplate.fromMessages([ - [ - "system", - `For the given objective, create a step-by-step plan using the planStep and planArray schema conventions.\n\n` + - `**CRITICAL INSTRUCTIONS:**\n` + - `- You are ONLY responsible for creating a plan, NOT executing it\n` + - `- DO NOT call any tools or execute any function calls\n` + - `- DO NOT attempt to carry out the plan yourself\n` + - `- ONLY output valid JSON that conforms to the planArray schema\n` + - `- Your response must be parseable JSON with no additional text, explanations, or markdown formatting\n\n` + - `**Planning Guidelines:**\n` + - `- Each step should be actionable, unambiguous, and provide all information needed for a subagent to execute independently\n` + - `- Use the discriminated union format with nested arrays for parallel execution\n` + - `- Scale the number of steps and parallelism to the complexity of the query\n` + - `- Do not add superfluous steps. The result of the final step should be the final answer\n` + - `- Make the plan technical and specific to the topic\n` + - `- Each planStep object must include both "step" (short instruction) and "context" (detailed explanation) properties\n` + - `- Use the discriminated union format: {{ type: "single", data: planStep }} for single steps or {{ type: "parallel", data: planStep[] }} for parallel execution\n` + - `${toolsSection}\n\n` + - `**Output Format:**\n` + - `Your response must be a valid JSON array following the planArray schema. Do not include any other text, explanations, or formatting.\n`, - ], + new SystemMessage( + String.raw` +You are a task-planner. Your ONLY job is to output a valid JSON object that +matches **exactly** the TypeScript schema shown below. Do NOT execute the plan. + +--------------- REQUIRED SCHEMA ----------------- + +type planStep = { step: string; context: string }; + +type PlanItem = + | { type: "single"; data: planStep } + | { type: "parallel"; data: planStep[] }; + +export type Plan = { plan: PlanItem[] }; + +CRITICAL: For "single" items, data must be an OBJECT with step and context fields: +✅ CORRECT: { "type": "single", "data": { "step": "Search docs", "context": "Search for relevant information" } } +❌ WRONG: { "type": "single", "data": ["Search for relevant information"] } + +For "parallel" items, data must be an ARRAY of planStep objects: +✅ CORRECT: { "type": "parallel", "data": [{ "step": "Task A", "context": "Do A" }, { "step": "Task B", "context": "Do B" }] } +❌ WRONG: { "type": "parallel", "data": ["Task A", "Task B"] } + +--------------------------------------------------- + +Important constraints: +1. The top-level key must be "plan". +2. In a "parallel" item, **data is an array of planStep**, NOT wrapped + in {type:"single"} objects. ❌ WRONG: + { "type":"parallel","data":[{ "type":"single", data:{…} }]} + ✅ RIGHT: + { "type":"parallel","data":[ { "step":"...", "context":"..." }, … ] } +3. Every planStep must have both fields: "step" (≤6 words) and "context" + (1-3 sentences with enough detail for an agent to act). +4. No markdown, no extra keys, no comments. +5. Your JSON must pass a strict JSON.parse in JavaScript. + +${toolsSection} + +Respond with the JSON ONLY.` + ), new MessagesPlaceholder("messages"), ]); } // Prompt template for replanner export function createReplannerPrompt(availableToolsDescription: string) { + // Explicit schema block to reduce model output errors by clearly + // specifying the exact JSON structure that must be returned. + const schemaSection = String.raw` ++--------------- REQUIRED SCHEMA ----------------- +type planStep = { step: string; context: string }; + +type PlanItem = + | { type: "single"; data: planStep } + | { type: "parallel"; data: planStep[] }; + +// Replanner response must match one of the following shapes exactly +type ReplanResponse = + | { type: "continue_planning"; data: PlanItem[] } + | { type: "respond_to_user"; data: string }; + +CRITICAL: +- For "single", data MUST be an OBJECT: { step, context } (not an array). +- For "parallel", data MUST be an ARRAY of planStep objects (not strings). +- For "respond_to_user", data MUST be a STRING. Do NOT return arrays or objects. ++---------------------------------------------------`; + const toolsSection = `\n**Available Tools:**\n${availableToolsDescription}\n\nWhen planning remaining steps, consider which tools are available and how they can be used to accomplish the remaining objectives efficiently.`; return ChatPromptTemplate.fromMessages([ - [ - "system", - `## Your Task: Reflect and Re-plan\n\n` + - `For the given objective, update the step-by-step plan using the planStep and planArray schema conventions.\n` + - `- Only include the remaining steps needed to fill the gaps identified in your analysis.\n` + - `- Use the discriminated union format with nested arrays for parallel execution.\n` + - `- Ensure steps are non-overlapping, unambiguous, and context-rich.\n` + - `- The result of the final step should be the final answer.\n` + - `${toolsSection}\n\n` + - `**Message History:**\n`, - ], + new SystemMessage( + String.raw`## Your Task: Reflect and Re-plan + +${schemaSection} + +For the given objective, update the step-by-step plan using the planStep and planArray schema conventions. +- Only include the remaining steps needed to fill the gaps identified in your analysis. +- Use the discriminated union format with nested arrays for parallel execution. +- Ensure steps are non-overlapping, unambiguous, and context-rich. +- The result of the final step should be the final answer. +${toolsSection} + +**Message History:**` + ), new MessagesPlaceholder("messages"), [ "system", @@ -235,21 +282,39 @@ export function createReplannerPrompt(availableToolsDescription: string) { `**Completed steps so far:**\n`, ], new MessagesPlaceholder("pastSteps"), - [ - "system", - `\n\n**MANDATORY ANALYSIS & REPLANNING:**\n\n` + - `1. **Re-evaluate the Original Objective:** Look at the user's first message. What were the core components of their request?\n\n` + - `2. **Conduct a Gap Analysis:** Compare the completed steps' results against the original objective. Have all components been fully addressed? State explicitly what is **still missing**.\n\n` + - `3. **Assess Readiness:** Based on your analysis, decide if you have all the necessary information to synthesize a final, complete answer that satisfies the entire original objective.\n\n` + - `4. **Update Your Plan:**\n` + - ` - **If ready to respond:** Set type to "respond_to_user" and data as the response. Formulate the complete, synthesized response. This is not a draft; it is the complete, polished answer.\n` + - ` - **If not ready:** Set type to "continue_planning" and provide a new plan containing **only the remaining steps needed** to fill ` + - `the gaps you identified.\n\n` + - `**ALWAYS** output valid JSON, do not include any extraneous text or explanations, make sure you understand unions in the output format and respond correctly`, - ], + new SystemMessage( + String.raw` + +**MANDATORY ANALYSIS & REPLANNING:** + +1. **Re-evaluate the Original Objective:** Look at the user's first message. What were the core components of their request? + +2. **Conduct a Gap Analysis:** Compare the completed steps' results against the original objective. Have all components been fully addressed? State explicitly what is **still missing**. + +3. **Assess Readiness:** Based on your analysis, decide if you have all the necessary information to synthesize a final, complete answer that satisfies the entire original objective. + +4. **Update Your Plan:** + - **If ready to respond:** Set type to "respond_to_user" and data as the response. Formulate the complete, synthesized response. This is not a draft; it is the complete, polished answer. + - **If not ready:** Set type to "continue_planning" and provide a new plan containing **only the remaining steps needed** to fill the gaps you identified. + +**ALWAYS** output valid JSON, do not include any extraneous text or explanations, make sure you understand unions in the output format and respond correctly` + ), ]); } +// Helper schemas to accept alternative respond_to_user shapes for robustness. +const respondPayloadObject = z.object({ + step: z.string(), + context: z.string(), +}); + +const nestedRespondArray = z.array( + z.object({ + type: z.literal("respond_to_user"), + data: respondPayloadObject, + }) +); + export const replannerOutputSchema = (artifacts: boolean) => z.object({ type: z @@ -263,11 +328,15 @@ export const replannerOutputSchema = (artifacts: boolean) => .describe( "The final, complete, and user-facing response, to be used ONLY when 'type' is 'respond_to_user'. " + "This string MUST synthesize all gathered information and results from the previous steps into a single, " + - "coherent, and well-formatted answer. This is the ONLY output the end-user will see. It must fully and directly " + - "address the user's original query, leaving no questions unanswered. Do not include any conversational filler, " + - "apologies, or meta-commentary about the process; provide only the definitive answer." + - `${artifacts ? ` Adhere to the following additional guidelines and format your response accordingly:\n${artifactsGuidelines}` : ""}`, + "coherent, and well-formatted answer. Do not include any conversational filler." + + `${artifacts ? ` Adhere to the following additional guidelines and format your response accordingly:\n${artifactsGuidelines}` : ""}` ), + respondPayloadObject.describe( + "Alternate object shape: { step, context }. 'context' treated as final answer." + ), + nestedRespondArray.describe( + "Alternate nested array shape: [{ type: 'respond_to_user', data: { step, context } }]. 'context' taken from first entry." + ), ]) .describe("The response data - either a plan array or a string response"), }); diff --git a/convex/langchain/state.ts b/convex/langchain/state.ts index 53a4a1a..ea23260 100644 --- a/convex/langchain/state.ts +++ b/convex/langchain/state.ts @@ -8,7 +8,7 @@ export const planStep = z.object({ .describe( "A short, specific instruction (ideally < 6 words) describing the subtask to be performed " + "by an agent. Should be actionable, unambiguous, and clearly distinct from other steps to ensure effective division " + - "of labor and prevent overlap.", + "of labor and prevent overlap." ), context: z .string() @@ -16,7 +16,7 @@ export const planStep = z.object({ "A concise explanation of the background, objective, and constraints for this step," + "written to help a subagent understand exactly what is needed, what tools or sources to use, and any boundaries or" + " heuristics to follow. Should clarify the subtask's purpose, avoid ambiguity, and prevent duplication or" + - " misinterpretation by other agents.", + " misinterpretation by other agents." ), }); @@ -36,7 +36,7 @@ export const planArray = z "A step-by-step plan for decomposing a complex research objective into clear, non-overlapping subtasks. " + "Each step should be concise, actionable, and include enough context for a subagent to execute independently. " + "The plan should scale in complexity with the query, allocate effort efficiently, and ensure that all necessary aspects of the research are covered without redundancy. " + - "If multiple tasks should be executed in parallel, group them together in a nested list (i.e., use an array of plan steps within the main array) to indicate parallel execution.", + "If multiple tasks should be executed in parallel, group them together in a nested list (i.e., use an array of plan steps within the main array) to indicate parallel execution." ) .min(1) .max(9); diff --git a/convex/langchain/tools/googleTools.ts b/convex/langchain/tools/googleTools.ts index 3efdc53..5501a7a 100644 --- a/convex/langchain/tools/googleTools.ts +++ b/convex/langchain/tools/googleTools.ts @@ -1,4 +1,7 @@ -import { DynamicStructuredTool } from "@langchain/core/tools"; +"use node"; + +import { tool } from "@langchain/core/tools"; +import { dispatchCustomEvent } from "@langchain/core/callbacks/dispatch"; import { z } from "zod"; import type { ExtendedRunnableConfig } from "../helpers"; import { internal } from "../../_generated/api"; @@ -76,13 +79,19 @@ export const getGoogleTools = async (config: ExtendedRunnableConfig) => { } // Google Calendar Tools - const listCalendarseTool = new DynamicStructuredTool({ - name: "listGoogleCalendars", - description: - "List all Google Calendars accessible to the user. Use this to see available calendars before working with events.", - schema: z.object({}), - func: async () => { + const listCalendarsTool = tool( + async (_args: {}, toolConfig: any) => { try { + await dispatchCustomEvent( + "tool_progress", + { chunk: "Checking Google authentication…" }, + toolConfig, + ); + await dispatchCustomEvent( + "tool_progress", + { chunk: "Fetching your Google calendars…" }, + toolConfig, + ); const result = await makeGoogleAPIRequest( "/users/me/calendarList", accessToken, @@ -97,53 +106,61 @@ export const getGoogleTools = async (config: ExtendedRunnableConfig) => { accessRole: calendar.accessRole, })) || []; + await dispatchCustomEvent( + "tool_progress", + { chunk: `Found ${calendars.length} calendars. Formatting results…` }, + toolConfig, + ); + return JSON.stringify(calendars, null, 2); } catch (error) { - return `Failed to list calendars: ${error instanceof Error ? error.message : "Unknown error"}`; + const message = + error instanceof Error ? error.message : "Unknown error"; + await dispatchCustomEvent( + "tool_progress", + { chunk: `Failed to list calendars: ${message}` }, + toolConfig, + ); + return `Failed to list calendars: ${message}`; } }, - }); - - const listCalendarEventsTool = new DynamicStructuredTool({ - name: "listGoogleCalendarEvents", - description: - "List events from a specific Google Calendar. Use this to see upcoming or past events.", - schema: z.object({ - calendarId: z - .string() - .describe("The calendar ID (use 'primary' for default calendar)") - .default("primary"), - timeMin: z - .string() - .optional() - .describe( - "Lower bound (inclusive) for an event's end time (RFC3339 timestamp)", - ), - timeMax: z - .string() - .optional() - .describe( - "Upper bound (exclusive) for an event's start time (RFC3339 timestamp)", - ), - maxResults: z - .number() - .min(1) - .max(2500) - .default(10) - .describe("Maximum number of events to return"), - q: z - .string() - .optional() - .describe("Free text search terms to find events"), - }), - func: async ({ - calendarId = "primary", - timeMin, - timeMax, - maxResults = 10, - q, - }) => { + { + name: "listGoogleCalendars", + description: + "List all Google Calendars accessible to the user. Use this to see available calendars before working with events.", + schema: z.object({}), + }, + ); + + const listCalendarEventsTool = tool( + async ( + { + calendarId = "primary", + timeMin, + timeMax, + maxResults = 10, + q, + }: { + calendarId: string; + timeMin?: string; + timeMax?: string; + maxResults?: number; + q?: string; + }, + toolConfig: any, + ) => { try { + await dispatchCustomEvent( + "tool_progress", + { + chunk: `Fetching events from calendar '${calendarId}'${ + timeMin || timeMax + ? ` within ${timeMin ?? "-∞"} → ${timeMax ?? "+∞"}` + : "" + }…`, + }, + toolConfig, + ); const params = new URLSearchParams({ maxResults: maxResults.toString(), singleEvents: "true", @@ -171,54 +188,90 @@ export const getGoogleTools = async (config: ExtendedRunnableConfig) => { creator: event.creator, organizer: event.organizer, })) || []; + await dispatchCustomEvent( + "tool_progress", + { chunk: `Found ${events.length} events. Formatting results…` }, + toolConfig, + ); return JSON.stringify(events, null, 2); } catch (error) { - return `Failed to list calendar events: ${error instanceof Error ? error.message : "Unknown error"}`; + const message = + error instanceof Error ? error.message : "Unknown error"; + await dispatchCustomEvent( + "tool_progress", + { + chunk: `Failed to list calendar events: ${message}`, + }, + toolConfig, + ); + return `Failed to list calendar events: ${message}`; } }, - }); - - const createCalendarEventTool = new DynamicStructuredTool({ - name: "createGoogleCalendarEvent", - description: - "Create a new event in a Google Calendar. Use this to schedule meetings, appointments, or reminders.", - schema: z.object({ - calendarId: z - .string() - .describe("The calendar ID (use 'primary' for default calendar)") - .default("primary"), - summary: z.string().describe("The title/summary of the event"), - description: z - .string() - .optional() - .describe("The description of the event"), - startDateTime: z - .string() - .describe( - "Start date and time (RFC3339 format, e.g., '2024-01-15T09:00:00-07:00')", - ), - endDateTime: z - .string() - .describe( - "End date and time (RFC3339 format, e.g., '2024-01-15T10:00:00-07:00')", - ), - location: z.string().optional().describe("The location of the event"), - attendees: z - .array(z.string()) - .optional() - .describe("List of email addresses of attendees"), - }), - func: async ({ - calendarId = "primary", - summary, - description, - startDateTime, - endDateTime, - location, - attendees, - }) => { + { + name: "listGoogleCalendarEvents", + description: + "List events from a specific Google Calendar. Use this to see upcoming or past events.", + schema: z.object({ + calendarId: z + .string() + .describe("The calendar ID (use 'primary' for default calendar)") + .default("primary"), + timeMin: z + .string() + .optional() + .describe( + "Lower bound (inclusive) for an event's end time (RFC3339 timestamp)", + ), + timeMax: z + .string() + .optional() + .describe( + "Upper bound (exclusive) for an event's start time (RFC3339 timestamp)", + ), + maxResults: z + .number() + .min(1) + .max(2500) + .default(10) + .describe("Maximum number of events to return"), + q: z + .string() + .optional() + .describe("Free text search terms to find events"), + }), + }, + ); + + const createCalendarEventTool = tool( + async ( + { + calendarId = "primary", + summary, + description, + startDateTime, + endDateTime, + location, + attendees, + }: { + calendarId: string; + summary: string; + description?: string; + startDateTime: string; + endDateTime: string; + location?: string; + attendees?: string[]; + }, + toolConfig: any, + ) => { try { + await dispatchCustomEvent( + "tool_progress", + { + chunk: `Creating event '${summary}' on '${calendarId}' from ${startDateTime} to ${endDateTime}…`, + }, + toolConfig, + ); const event = { summary, description, @@ -249,58 +302,98 @@ export const getGoogleTools = async (config: ExtendedRunnableConfig) => { htmlLink: result.htmlLink, }; + await dispatchCustomEvent( + "tool_progress", + { chunk: "Event created successfully. Preparing output…" }, + toolConfig, + ); + return JSON.stringify(createdEvent, null, 2); } catch (error) { - return `Failed to create calendar event: ${error instanceof Error ? error.message : "Unknown error"}`; + const message = + error instanceof Error ? error.message : "Unknown error"; + await dispatchCustomEvent( + "tool_progress", + { + chunk: `Failed to create calendar event: ${message}`, + }, + toolConfig, + ); + return `Failed to create calendar event: ${message}`; } }, - }); - - const updateCalendarEventTool = new DynamicStructuredTool({ - name: "updateGoogleCalendarEvent", - description: - "Update an existing event in a Google Calendar. Use this to modify event details.", - schema: z.object({ - calendarId: z - .string() - .describe("The calendar ID (use 'primary' for default calendar)") - .default("primary"), - eventId: z.string().describe("The ID of the event to update"), - summary: z - .string() - .optional() - .describe("The new title/summary of the event"), - description: z - .string() - .optional() - .describe("The new description of the event"), - startDateTime: z - .string() - .optional() - .describe("New start date and time (RFC3339 format)"), - endDateTime: z - .string() - .optional() - .describe("New end date and time (RFC3339 format)"), - location: z.string().optional().describe("The new location of the event"), - }), - func: async ({ - calendarId = "primary", - eventId, - summary, - description, - startDateTime, - endDateTime, - location, - }) => { + { + name: "createGoogleCalendarEvent", + description: + "Create a new event in a Google Calendar. Use this to schedule meetings, appointments, or reminders.", + schema: z.object({ + calendarId: z + .string() + .describe("The calendar ID (use 'primary' for default calendar)") + .default("primary"), + summary: z.string().describe("The title/summary of the event"), + description: z + .string() + .optional() + .describe("The description of the event"), + startDateTime: z + .string() + .describe( + "Start date and time (RFC3339 format, e.g., '2024-01-15T09:00:00-07:00')", + ), + endDateTime: z + .string() + .describe( + "End date and time (RFC3339 format, e.g., '2024-01-15T10:00:00-07:00')", + ), + location: z.string().optional().describe("The location of the event"), + attendees: z + .array(z.string()) + .optional() + .describe("List of email addresses of attendees"), + }), + }, + ); + + const updateCalendarEventTool = tool( + async ( + { + calendarId = "primary", + eventId, + summary, + description, + startDateTime, + endDateTime, + location, + }: { + calendarId: string; + eventId: string; + summary?: string; + description?: string; + startDateTime?: string; + endDateTime?: string; + location?: string; + }, + toolConfig: any, + ) => { try { - // First get the existing event + await dispatchCustomEvent( + "tool_progress", + { + chunk: `Loading existing event '${eventId}' from '${calendarId}'…`, + }, + toolConfig, + ); const existingEvent = await makeGoogleAPIRequest( `/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, accessToken, ); - // Update only the provided fields + await dispatchCustomEvent( + "tool_progress", + { chunk: "Applying updates to the event…" }, + toolConfig, + ); const updatedEvent = { ...existingEvent, ...(summary && { summary }), @@ -327,26 +420,74 @@ export const getGoogleTools = async (config: ExtendedRunnableConfig) => { htmlLink: result.htmlLink, }; + await dispatchCustomEvent( + "tool_progress", + { chunk: "Event updated successfully. Preparing output…" }, + toolConfig, + ); + return JSON.stringify(event, null, 2); } catch (error) { - return `Failed to update calendar event: ${error instanceof Error ? error.message : "Unknown error"}`; + const message = + error instanceof Error ? error.message : "Unknown error"; + await dispatchCustomEvent( + "tool_progress", + { + chunk: `Failed to update calendar event: ${message}`, + }, + toolConfig, + ); + return `Failed to update calendar event: ${message}`; } }, - }); - - const deleteCalendarEventTool = new DynamicStructuredTool({ - name: "deleteGoogleCalendarEvent", - description: - "Delete an event from a Google Calendar. Use this to cancel or remove events.", - schema: z.object({ - calendarId: z - .string() - .describe("The calendar ID (use 'primary' for default calendar)") - .default("primary"), - eventId: z.string().describe("The ID of the event to delete"), - }), - func: async ({ calendarId = "primary", eventId }) => { + { + name: "updateGoogleCalendarEvent", + description: + "Update an existing event in a Google Calendar. Use this to modify event details.", + schema: z.object({ + calendarId: z + .string() + .describe("The calendar ID (use 'primary' for default calendar)") + .default("primary"), + eventId: z.string().describe("The ID of the event to update"), + summary: z + .string() + .optional() + .describe("The new title/summary of the event"), + description: z + .string() + .optional() + .describe("The new description of the event"), + startDateTime: z + .string() + .optional() + .describe("New start date and time (RFC3339 format)"), + endDateTime: z + .string() + .optional() + .describe("New end date and time (RFC3339 format)"), + location: z + .string() + .optional() + .describe("The new location of the event"), + }), + }, + ); + + const deleteCalendarEventTool = tool( + async ( + { + calendarId = "primary", + eventId, + }: { calendarId: string; eventId: string }, + toolConfig: any, + ) => { try { + await dispatchCustomEvent( + "tool_progress", + { chunk: `Deleting event '${eventId}' from '${calendarId}'…` }, + toolConfig, + ); await makeGoogleAPIRequest( `/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, accessToken, @@ -357,39 +498,56 @@ export const getGoogleTools = async (config: ExtendedRunnableConfig) => { success: true, message: `Event ${eventId} deleted successfully`, }; + await dispatchCustomEvent( + "tool_progress", + { chunk: "Event deleted successfully." }, + toolConfig, + ); return JSON.stringify(result, null, 2); } catch (error) { - return `Failed to delete calendar event: ${error instanceof Error ? error.message : "Unknown error"}`; + const message = + error instanceof Error ? error.message : "Unknown error"; + await dispatchCustomEvent( + "tool_progress", + { + chunk: `Failed to delete calendar event: ${message}`, + }, + toolConfig, + ); + return `Failed to delete calendar event: ${message}`; } }, - }); + { + name: "deleteGoogleCalendarEvent", + description: + "Delete an event from a Google Calendar. Use this to cancel or remove events.", + schema: z.object({ + calendarId: z + .string() + .describe("The calendar ID (use 'primary' for default calendar)") + .default("primary"), + eventId: z.string().describe("The ID of the event to delete"), + }), + }, + ); // Gmail Tools - const listGmailMessagesTool = new DynamicStructuredTool({ - name: "listGmailMessages", - description: - "List Gmail messages. Use this to search and retrieve email messages.", - schema: z.object({ - q: z - .string() - .optional() - .describe( - "Gmail search query (e.g., 'from:example@gmail.com', 'subject:meeting', 'is:unread')", - ), - maxResults: z - .number() - .min(1) - .max(500) - .default(10) - .describe("Maximum number of messages to return"), - labelIds: z - .array(z.string()) - .optional() - .describe("Only return messages with these label IDs"), - }), - func: async ({ q, maxResults = 10, labelIds }) => { + const listGmailMessagesTool = tool( + async ( + { + q, + maxResults = 10, + labelIds, + }: { q?: string; maxResults?: number; labelIds?: string[] }, + toolConfig: any, + ) => { try { + await dispatchCustomEvent( + "tool_progress", + { chunk: "Fetching Gmail messages…" }, + toolConfig, + ); const params = new URLSearchParams({ maxResults: maxResults.toString(), }); @@ -404,7 +562,6 @@ export const getGoogleTools = async (config: ExtendedRunnableConfig) => { accessToken, ); - // Get full message details for each message const messages = await Promise.all( (result.messages || []).map(async (message: any) => { const fullMessage = await makeGmailAPIRequest( @@ -431,26 +588,65 @@ export const getGoogleTools = async (config: ExtendedRunnableConfig) => { }), ); + await dispatchCustomEvent( + "tool_progress", + { chunk: `Found ${messages.length} messages. Formatting results…` }, + toolConfig, + ); + return JSON.stringify(messages, null, 2); } catch (error) { - return `Failed to list Gmail messages: ${error instanceof Error ? error.message : "Unknown error"}`; + const message = + error instanceof Error ? error.message : "Unknown error"; + await dispatchCustomEvent( + "tool_progress", + { + chunk: `Failed to list Gmail messages: ${message}`, + }, + toolConfig, + ); + return `Failed to list Gmail messages: ${message}`; } }, - }); - - const getGmailMessageTool = new DynamicStructuredTool({ - name: "getGmailMessage", - description: - "Get the full content of a specific Gmail message. Use this to read the complete email content.", - schema: z.object({ - messageId: z.string().describe("The ID of the message to retrieve"), - format: z - .enum(["full", "metadata", "minimal"]) - .default("full") - .describe("The format to return the message in"), - }), - func: async ({ messageId, format = "full" }) => { + { + name: "listGmailMessages", + description: + "List Gmail messages. Use this to search and retrieve email messages.", + schema: z.object({ + q: z + .string() + .optional() + .describe( + "Gmail search query (e.g., 'from:example@gmail.com', 'subject:meeting', 'is:unread')", + ), + maxResults: z + .number() + .min(1) + .max(500) + .default(10) + .describe("Maximum number of messages to return"), + labelIds: z + .array(z.string()) + .optional() + .describe("Only return messages with these label IDs"), + }), + }, + ); + + const getGmailMessageTool = tool( + async ( + { + messageId, + format = "full", + }: { messageId: string; format?: "full" | "metadata" | "minimal" }, + toolConfig: any, + ) => { try { + await dispatchCustomEvent( + "tool_progress", + { chunk: `Fetching Gmail message '${messageId}'…` }, + toolConfig, + ); const result = await makeGmailAPIRequest( `/users/me/messages/${messageId}?format=${format}`, accessToken, @@ -461,12 +657,10 @@ export const getGoogleTools = async (config: ExtendedRunnableConfig) => { headers.find((h: any) => h.name.toLowerCase() === name.toLowerCase()) ?.value; - // Extract body content let body = ""; if (result.payload?.body?.data) { body = Buffer.from(result.payload.body.data, "base64").toString(); } else if (result.payload?.parts) { - // Multi-part message const textPart = result.payload.parts.find( (part: any) => part.mimeType === "text/plain", ); @@ -487,40 +681,78 @@ export const getGoogleTools = async (config: ExtendedRunnableConfig) => { labelIds: result.labelIds, }; + await dispatchCustomEvent( + "tool_progress", + { chunk: "Message loaded. Preparing output…" }, + toolConfig, + ); + return JSON.stringify(message, null, 2); } catch (error) { - return `Failed to get Gmail message: ${error instanceof Error ? error.message : "Unknown error"}`; + const message = + error instanceof Error ? error.message : "Unknown error"; + await dispatchCustomEvent( + "tool_progress", + { chunk: `Failed to get Gmail message: ${message}` }, + toolConfig, + ); + return `Failed to get Gmail message: ${message}`; } }, - }); - - const sendGmailMessageTool = new DynamicStructuredTool({ - name: "sendGmailMessage", - description: - "Send a new Gmail message. Use this to compose and send emails.", - schema: z.object({ - to: z.string().describe("Recipient email address"), - subject: z.string().describe("Email subject line"), - body: z.string().describe("Email body content"), - cc: z.string().optional().describe("CC email address"), - bcc: z.string().optional().describe("BCC email address"), - }), - func: async ({ to, subject, body, cc, bcc }) => { + { + name: "getGmailMessage", + description: + "Get the full content of a specific Gmail message. Use this to read the complete email content.", + schema: z.object({ + messageId: z.string().describe("The ID of the message to retrieve"), + format: z + .enum(["full", "metadata", "minimal"]) + .default("full") + .describe("The format to return the message in"), + }), + }, + ); + + const sendGmailMessageTool = tool( + async ( + { + to, + subject, + body, + cc, + bcc, + }: { + to: string; + subject: string; + body: string; + cc?: string; + bcc?: string; + }, + toolConfig: any, + ) => { try { - // Create email in RFC 2822 format + await dispatchCustomEvent( + "tool_progress", + { chunk: `Composing email to ${to} with subject '${subject}'…` }, + toolConfig, + ); let email = `To: ${to}\r\n`; if (cc) email += `Cc: ${cc}\r\n`; if (bcc) email += `Bcc: ${bcc}\r\n`; email += `Subject: ${subject}\r\n`; email += `\r\n${body}`; - // Encode email in base64url format const encodedEmail = Buffer.from(email) .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); + await dispatchCustomEvent( + "tool_progress", + { chunk: "Sending email via Gmail API…" }, + toolConfig, + ); const result = await makeGmailAPIRequest( `/users/me/messages/send`, accessToken, @@ -535,32 +767,49 @@ export const getGoogleTools = async (config: ExtendedRunnableConfig) => { message: "Email sent successfully", }; + await dispatchCustomEvent( + "tool_progress", + { chunk: "Email sent successfully. Preparing output…" }, + toolConfig, + ); + return JSON.stringify(sentMessage, null, 2); } catch (error) { - return `Failed to send Gmail message: ${error instanceof Error ? error.message : "Unknown error"}`; + const message = + error instanceof Error ? error.message : "Unknown error"; + await dispatchCustomEvent( + "tool_progress", + { chunk: `Failed to send Gmail message: ${message}` }, + toolConfig, + ); + return `Failed to send Gmail message: ${message}`; } }, - }); - - const searchGmailTool = new DynamicStructuredTool({ - name: "searchGmail", - description: - "Search Gmail messages with advanced query options. Use this for complex email searches.", - schema: z.object({ - query: z - .string() - .describe( - "Gmail search query (supports Gmail search operators like 'from:', 'subject:', 'has:attachment', etc.)", - ), - maxResults: z - .number() - .min(1) - .max(100) - .default(10) - .describe("Maximum number of results to return"), - }), - func: async ({ query, maxResults = 10 }) => { + { + name: "sendGmailMessage", + description: + "Send a new Gmail message. Use this to compose and send emails.", + schema: z.object({ + to: z.string().describe("Recipient email address"), + subject: z.string().describe("Email subject line"), + body: z.string().describe("Email body content"), + cc: z.string().optional().describe("CC email address"), + bcc: z.string().optional().describe("BCC email address"), + }), + }, + ); + + const searchGmailTool = tool( + async ( + { query, maxResults = 10 }: { query: string; maxResults?: number }, + toolConfig: any, + ) => { try { + await dispatchCustomEvent( + "tool_progress", + { chunk: `Searching Gmail for: ${query}…` }, + toolConfig, + ); const params = new URLSearchParams({ q: query, maxResults: maxResults.toString(), @@ -571,7 +820,6 @@ export const getGoogleTools = async (config: ExtendedRunnableConfig) => { accessToken, ); - // Get basic info for each message const messages = await Promise.all( (result.messages || []) .slice(0, maxResults) @@ -599,15 +847,46 @@ export const getGoogleTools = async (config: ExtendedRunnableConfig) => { }), ); + await dispatchCustomEvent( + "tool_progress", + { chunk: `Found ${messages.length} matching messages. Formatting…` }, + toolConfig, + ); + return JSON.stringify(messages, null, 2); } catch (error) { - return `Failed to search Gmail: ${error instanceof Error ? error.message : "Unknown error"}`; + const message = + error instanceof Error ? error.message : "Unknown error"; + await dispatchCustomEvent( + "tool_progress", + { chunk: `Failed to search Gmail: ${message}` }, + toolConfig, + ); + return `Failed to search Gmail: ${message}`; } }, - }); + { + name: "searchGmail", + description: + "Search Gmail messages with advanced query options. Use this for complex email searches.", + schema: z.object({ + query: z + .string() + .describe( + "Gmail search query (supports Gmail search operators like 'from:', 'subject:', 'has:attachment', etc.)", + ), + maxResults: z + .number() + .min(1) + .max(100) + .default(10) + .describe("Maximum number of results to return"), + }), + }, + ); return [ - listCalendarseTool, + listCalendarsTool, listCalendarEventsTool, createCalendarEventTool, updateCalendarEventTool, diff --git a/convex/langchain/tools/mcpTools.ts b/convex/langchain/tools/mcpTools.ts index 34aa7d5..a80d4a3 100644 --- a/convex/langchain/tools/mcpTools.ts +++ b/convex/langchain/tools/mcpTools.ts @@ -7,226 +7,243 @@ import type { Doc, Id } from "../../_generated/dataModel"; import { fly } from "../../utils/flyio"; import { getDocumentUrl } from "../../utils/helpers"; import { - extractFileIdsFromMessage, - type ExtendedRunnableConfig, + extractFileIdsFromMessage, + type ExtendedRunnableConfig, } from "../helpers"; import type { GraphState } from "../state"; import { getTemplatePromptTool } from "../../mcps/templateHelpers"; import { - resolveConfigurableEnvs, - buildMcpConnectionHeaders, + resolveConfigurableEnvs, + buildMcpConnectionHeaders, } from "../../mcps/utils"; import { DynamicStructuredTool } from "@langchain/core/tools"; export const getMCPTools = async ( - ctx: ActionCtx, - state: typeof GraphState.State, - config?: ExtendedRunnableConfig, + ctx: ActionCtx, + state: typeof GraphState.State, + config?: ExtendedRunnableConfig, ) => { - const mcps = await ctx.runQuery(api.mcps.queries.getAll, { - filters: { - enabled: true, - }, - includeApps: true, - }); - - if (mcps.length === 0) { - return []; - } - - // Process all MCPs in one comprehensive loop - setup, connection, client creation, and tool fetching - const clientsAndTools = await Promise.all( - mcps.map(async (mcp) => { - try { - const mcpConfigurableEnvs = await resolveConfigurableEnvs(ctx, mcp, true); - - let appDoc: Doc<"mcpApps"> | null; - - // Handle per-chat MCPs - assign from pool or create on demand - if (mcp.perChat && ["stdio", "docker"].includes(mcp.type) && config) { - try { - appDoc = await ctx.runMutation( - internal.mcps.mutations.assignAppToChat, - { - mcpId: mcp._id, - chatId: config.chat._id, - }, - ); - } catch (error) { - console.error( - `Failed to assign per-chat MCP machine for ${mcp.name}:`, - error, - ); - return null; - } - } else { - appDoc = mcp.apps?.[0]!; - } - - if (!appDoc) { - return null; - } - - const machine = await fly.getMachineByName(appDoc?._id, "machine"); - - try { - await fly.startMachine(appDoc?._id, machine?.id!); - } catch (error) {} - await fly.waitTillHealthy(appDoc?._id, machine?.id!, { - timeout: 120000, - interval: 1000, - }); - - // Filter out MCPs that don't have a URL or aren't ready - if (!appDoc?.url || appDoc.status === "creating") { - console.error(`MCP ${mcp._id} is not ready`); - return null; - } - - // Build connection headers using shared utility - const headers = await buildMcpConnectionHeaders( - mcp, - mcpConfigurableEnvs, - ); - - const connection = { - transport: "http" as const, - url: appDoc.url, - headers, - useNodeEventSource: true, - reconnect: { - enabled: true, - maxAttempts: 60, - delayMs: 1000, - }, - }; - - // Create client for this MCP server - const client = new MultiServerMCPClient({ - prefixToolNameWithServerName: true, - additionalToolNamePrefix: "mcp", - throwOnLoadError: true, - mcpServers: { - [mcp.name]: connection, - }, - }); - - // Fetch tools with retry logic - let tools: DynamicStructuredTool[] = []; - for (let attempt = 0; attempt <= 10 && tools.length === 0; attempt++) { - try { - if (attempt >= 5) { - try { - await fly.startMachine(appDoc._id, machine?.id!); - } catch (error) {} - await fly.waitTillHealthy(appDoc._id, machine?.id!, { - timeout: 120000, - interval: 1000, - }); - } - tools = await client.getTools(); - if (tools.length === 0) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } catch (error) { - if (attempt === 10) { - throw new Error( - `Failed to fetch tools after 10 attempts: ${error}`, - ); - } - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } - - // Handle prompt tool if template exists - if (mcp.template) { - const promptTool = getTemplatePromptTool(mcp.template); - if (promptTool) { - try { - const mcpClient = await client.getClient(mcp.name); - if (mcpClient) { - const prompt = await mcpClient.getPrompt({ - name: promptTool, - }); - if (prompt && prompt.messages && prompt.messages.length > 0) { - const mcpTools = tools.filter((t) => - t.name.includes(mcp.name), - ); - if (mcpTools.length > 0) { - mcpTools[0].description = `${prompt.messages[0].content.text}\n\n${mcpTools[0].description}`; - } - } - } - } catch (error) {} - } - } - - return { client, tools, mcp, appDoc, machine }; - } catch (error) { - console.error(`Failed to process MCP ${mcp.name}:`, error); - return null; - } - }), - ); - - // Filter out failed MCPs and concatenate all tools - const validClientsAndTools = clientsAndTools.filter( - (result): result is NonNullable => result !== null, - ); - if (validClientsAndTools.length === 0) { - return []; - } - - const tools = validClientsAndTools.flatMap(({ tools }) => tools); - - if (state) { - if (state.messages && state.messages.length > 0) { - const lastMessage = state.messages[state.messages.length - 1]; - const messageContent = lastMessage.content; - - // Extract file IDs from the message content using helper function - const fileIds = extractFileIdsFromMessage(messageContent); - - const files: { name: string; url: string }[] = ( - await Promise.all( - fileIds.map(async (documentId, index) => { - try { - const documentDoc = await ctx.runQuery( - internal.documents.crud.read, - { - id: documentId as Id<"documents">, - }, - ); - if (!documentDoc) { - return null; - } - // Include various document types for upload (file, image, github, text) - const url = await getDocumentUrl(ctx, documentDoc.key); - return { - name: `${index}_${documentDoc.name}`, - url, - }; - } catch (error) { - return null; - } - }), - ) - ).filter((file): file is { name: string; url: string } => file !== null); - - // Upload files to each MCP's specific machine - await Promise.all( - validClientsAndTools.map(async ({ mcp, appDoc, machine }) => { - if ( - ["stdio", "docker"].includes(mcp.type) && - files.length > 0 && - machine?.id - ) { - await fly.uploadFileToMachine(appDoc._id, machine.id, files); - } - }), - ); - } - } - - return tools; + const mcps = await ctx.runQuery(api.mcps.queries.getAll, { + filters: { + enabled: true, + }, + includeApps: true, + }); + + if (mcps.length === 0) { + return { + tools: [], + groupedTools: {} as Record, + }; + } + + // Process all MCPs in one comprehensive loop - setup, connection, client creation, and tool fetching + const clientsAndTools = await Promise.all( + mcps.map(async (mcp) => { + try { + const mcpConfigurableEnvs = await resolveConfigurableEnvs( + ctx, + mcp, + true, + ); + + let appDoc: Doc<"mcpApps"> | null; + + // Handle per-chat MCPs - assign from pool or create on demand + if (mcp.perChat && ["stdio", "docker"].includes(mcp.type) && config) { + try { + appDoc = await ctx.runMutation( + internal.mcps.mutations.assignAppToChat, + { + mcpId: mcp._id, + chatId: config.chat._id, + }, + ); + } catch (error) { + console.error( + `Failed to assign per-chat MCP machine for ${mcp.name}:`, + error, + ); + return null; + } + } else { + appDoc = mcp.apps?.[0] ?? null; + } + + if (!appDoc) { + return null; + } + + const machine = await fly.getMachineByName(appDoc?._id, "machine"); + + try { + await fly.startMachine(appDoc?._id, machine?.id!); + } catch (error) {} + await fly.waitTillHealthy(appDoc?._id, machine?.id!, { + timeout: 120000, + interval: 1000, + }); + + // Filter out MCPs that don't have a URL or aren't ready + if (!appDoc?.url || appDoc.status === "creating") { + console.error(`MCP ${mcp._id} is not ready`); + return null; + } + + // Build connection headers using shared utility + const headers = await buildMcpConnectionHeaders( + mcp, + mcpConfigurableEnvs, + ); + + const connection = { + transport: "http" as const, + url: appDoc.url, + headers, + useNodeEventSource: true, + reconnect: { + enabled: true, + maxAttempts: 60, + delayMs: 1000, + }, + }; + + // Create client for this MCP server + const client = new MultiServerMCPClient({ + prefixToolNameWithServerName: true, + additionalToolNamePrefix: "mcp", + throwOnLoadError: true, + mcpServers: { + [mcp.name]: connection, + }, + }); + + // Fetch tools with retry logic + let tools: DynamicStructuredTool[] = []; + for (let attempt = 0; attempt <= 10 && tools.length === 0; attempt++) { + try { + if (attempt >= 5) { + try { + await fly.startMachine(appDoc._id, machine?.id!); + } catch (error) {} + await fly.waitTillHealthy(appDoc._id, machine?.id!, { + timeout: 120000, + interval: 1000, + }); + } + tools = await client.getTools(); + if (tools.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } catch (error) { + if (attempt === 10) { + throw new Error( + `Failed to fetch tools after 10 attempts: ${error}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + + // Handle prompt tool if template exists + if (mcp.template) { + const promptTool = getTemplatePromptTool(mcp.template); + if (promptTool) { + try { + const mcpClient = await client.getClient(mcp.name); + if (mcpClient) { + const prompt = await mcpClient.getPrompt({ + name: promptTool, + }); + if (prompt && prompt.messages && prompt.messages.length > 0) { + const mcpTools = tools.filter((t) => + t.name.includes(mcp.name), + ); + if (mcpTools.length > 0) { + mcpTools[0].description = `${prompt.messages[0].content.text}\n\n${mcpTools[0].description}`; + } + } + } + } catch (error) {} + } + } + + return { client, tools, mcp, appDoc, machine }; + } catch (error) { + console.error(`Failed to process MCP ${mcp.name}:`, error); + return null; + } + }), + ); + + // Filter out failed MCPs and concatenate all tools + const validClientsAndTools = clientsAndTools.filter( + (result): result is NonNullable => result !== null, + ); + if (validClientsAndTools.length === 0) { + return { + tools: [], + groupedTools: {} as Record, + }; + } + + const allTools = validClientsAndTools.flatMap(({ tools }) => tools); + + // File upload logic for MCPs if state/messages present + if (state && state.messages && state.messages.length > 0) { + const lastMessage = state.messages[state.messages.length - 1]; + const messageContent = lastMessage.content; + // Extract file IDs from the message content using helper function + const fileIds = extractFileIdsFromMessage(messageContent); + + const files: { name: string; url: string }[] = ( + await Promise.all( + fileIds.map(async (documentId, index) => { + try { + const documentDoc = await ctx.runQuery( + internal.documents.crud.read, + { + id: documentId as Id<"documents">, + }, + ); + if (!documentDoc) { + return null; + } + // Include various document types for upload (file, image, github, text) + const url = await getDocumentUrl(ctx, documentDoc.key); + return { + name: `${index}_${documentDoc.name}`, + url, + }; + } catch (error) { + return null; + } + }), + ) + ).filter((file): file is { name: string; url: string } => file !== null); + + // Upload files to each MCP's specific machine + await Promise.all( + validClientsAndTools.map(async ({ mcp, appDoc, machine }) => { + if ( + ["stdio", "docker"].includes(mcp.type) && + files.length > 0 && + machine?.id + ) { + await fly.uploadFileToMachine(appDoc._id, machine.id, files); + } + }), + ); + } + + // Group tools by MCP name for groupedTools + const groupedTools = new Map(); + for (const { mcp, tools } of validClientsAndTools) { + groupedTools.set(mcp.name, tools); + } + + return { + tools: allTools, + groupedTools: Object.fromEntries(groupedTools), + }; }; diff --git a/convex/langchain/tools/retrievalTools.ts b/convex/langchain/tools/retrievalTools.ts index 501ab31..f9c6f5c 100644 --- a/convex/langchain/tools/retrievalTools.ts +++ b/convex/langchain/tools/retrievalTools.ts @@ -1,6 +1,6 @@ "use node"; -import { DynamicStructuredTool } from "@langchain/core/tools"; +import { dispatchCustomEvent } from "@langchain/core/callbacks/dispatch"; import { ConvexVectorStore } from "@langchain/community/vectorstores/convex"; import { Document } from "@langchain/core/documents"; import { z } from "zod"; @@ -11,205 +11,207 @@ import { getEmbeddingModel } from "../models"; import type { GraphState } from "../state"; import type { ExtendedRunnableConfig } from "../helpers"; import { getDocumentUrl } from "../../utils/helpers"; +import { DynamicStructuredTool } from "@langchain/core/tools"; export const getRetrievalTools = async ( - _state: typeof GraphState.State, - config: ExtendedRunnableConfig, - returnString: boolean = false, + _state: typeof GraphState.State, + config: ExtendedRunnableConfig, + returnString: boolean = false, ) => { - const vectorSearchTool = new DynamicStructuredTool({ - name: "searchProjectDocuments", - description: - "Search through project documents using semantic similarity with multiple queries (1-5). Finds relevant information from uploaded project documents based on meaning rather than exact matches.", - schema: z.object({ - queries: z - .array(z.string()) - .min(1) - .max(5) - .describe( - "List of search queries to find relevant documents (1-5 queries)", - ), - }), - func: async ({ queries }: { queries: string[] }) => { - // Initialize ConvexVectorStore with the embedding model - const embeddingModel = await getEmbeddingModel(config.ctx, "embeddings"); - const vectorStore = new ConvexVectorStore(embeddingModel, { - ctx: config.ctx, - table: "documentVectors", - }); - - // Get selected project documents to filter vector search results - const includedProjectDocuments = await config.ctx.runQuery( - internal.projectDocuments.queries.getSelected, - { - projectId: config.chat.projectId!, - selected: true, - }, - ); - - if (includedProjectDocuments.length === 0) { - return "No project documents available for retrieval."; - } - - // Perform similarity search for each query in parallel, filtering by selected documents - const searchPromises = queries.map(async (query) => { - const results = await vectorStore.similaritySearch(query, 10, { - filter: (q) => - q.or( - // Assuming documentId is stored in the `source` field of metadata - ...includedProjectDocuments.map((document) => - q.eq("metadata", { - source: document.documentId, - }), - ), - ), - }); - - // Add query metadata to results - return results.map((doc) => ({ - ...doc, - metadata: { ...doc.metadata, query }, - })); - }); - - const allResultsArrays = await Promise.all(searchPromises); - const allResults = allResultsArrays.flat(); - - const documentsMap = new Map, Doc<"documents">>(); - includedProjectDocuments.forEach((projectDocument) => - documentsMap.set(projectDocument.documentId, projectDocument.document!), - ); - - const documents = await Promise.all( - allResults.map(async (doc) => { - const projectDocument = documentsMap.get( - (doc.metadata as any).source, - ); - if (!projectDocument) { - return null; - } - const url = await getDocumentUrl(config.ctx, projectDocument.key); - - return new Document({ - pageContent: doc.pageContent, - metadata: { - document: projectDocument, - source: url, - type: "document", - query: doc.metadata.query, - }, - }); - }), - ); - - if (returnString) { - return JSON.stringify(documents, null, 0); - } - - return documents; - }, - }); - - const webSearchTool = new DynamicStructuredTool({ - name: "searchWeb", - description: - "Search the web for current information using multiple queries (3-5). Access real-time web content including news, research papers, company information, and technical documentation.", - schema: z.object({ - queries: z - .array(z.string()) - .min(3) - .max(5) - .describe( - "List of search queries to find relevant web information (minimum 3 queries)", - ), - topic: z - .union([ - z.literal("company"), - z.literal("research paper"), - z.literal("news"), - z.literal("pdf"), - z.literal("github"), - z.literal("personal site"), - z.literal("linkedin profile"), - z.literal("financial report"), - ]) - .describe( - "The topic of the search query to optimize results. By default, performs a general web search. Choose the most relevant topic for better results.", - ) - .nullable() - .optional(), - }), - func: async ({ - queries, - topic, - }: { - queries: string[]; - topic?: string | null; - }) => { - const EXA_API_KEY = - ( - await config.ctx.runQuery(internal.apiKeys.queries.getFromKey, { - key: "EXA_API_KEY", - }) - )?.value ?? - process.env.EXA_API_KEY ?? - ""; - - try { - const exa = new Exa(EXA_API_KEY, undefined); - - // Perform web search for all queries in parallel - const searchPromises = queries.map(async (query) => { - const searchResponse = (console.log(query), - await exa.searchAndContents(query, { - numResults: 10, - type: "auto", - useAutoprompt: false, - topic: topic, - text: true, - })).results; - console.log(searchResponse.length); - - // Create LangChain Document objects from Exa search results - return searchResponse.map((result) => { - return new Document({ - pageContent: `${result.text}`, - metadata: { - type: "search", - title: result.title, - source: result.url, - publishedDate: result.publishedDate, - author: result.author, - image: result.image, - favicon: result.favicon, - query: query, - }, - }); - }); - }); - - const allDocumentsArrays = await Promise.all(searchPromises); - const allDocuments = allDocumentsArrays.flat(); - - if (allDocuments.length === 0) { - return "No results found."; - } - - if (returnString) { - return JSON.stringify(allDocuments, null, 0); - } - - return allDocuments; - } catch (error) { - return `Web search failed: ${ - error instanceof Error ? error.message : "Unknown error" - }`; - } - }, - }); - - return { - vectorSearch: vectorSearchTool, - webSearch: webSearchTool, - }; + const vectorSearchTool = new DynamicStructuredTool({ + name: "searchProjectDocuments", + description: + "Search through project documents using semantic similarity with multiple queries (1-5). Finds relevant information from uploaded project documents based on meaning rather than exact matches.", + schema: z.object({ + queries: z + .array(z.string()) + .min(1) + .max(5) + .describe( + "List of search queries to find relevant documents (1-5 queries)", + ), + }), + func: async ({ queries }: { queries: string[] }) => { + // Initialize ConvexVectorStore with the embedding model + const embeddingModel = await getEmbeddingModel(config.ctx, "embeddings"); + const vectorStore = new ConvexVectorStore(embeddingModel, { + ctx: config.ctx, + table: "documentVectors", + }); + + await dispatchCustomEvent("tool_progress", { + chunk: "Loading selected project documents...", + }); + const includedProjectDocuments = await config.ctx.runQuery( + internal.projectDocuments.queries.getSelected, + { + projectId: config.chat.projectId!, + selected: true, + }, + ); + + if (includedProjectDocuments.length === 0) { + return "No project documents available for retrieval."; + } + + // Perform similarity search for each query in parallel, filtering by selected documents + const searchPromises = queries.map(async (query) => { + const results = await vectorStore.similaritySearch(query, 10, { + filter: (q) => + q.or( + // Assuming documentId is stored in the `source` field of metadata + ...includedProjectDocuments.map((document) => + q.eq("metadata", { + source: document.documentId, + }), + ), + ), + }); + + // Add query metadata to results + return results.map((doc) => ({ + ...doc, + metadata: { ...doc.metadata, query }, + })); + }); + + const allResultsArrays = await Promise.all(searchPromises); + const allResults = allResultsArrays.flat(); + + const documentsMap = new Map, Doc<"documents">>(); + includedProjectDocuments.forEach((projectDocument) => { + documentsMap.set(projectDocument.documentId, projectDocument.document!); + }); + + const documents = await Promise.all( + allResults.map(async (doc) => { + const projectDocument = documentsMap.get( + (doc.metadata as any).source, + ); + if (!projectDocument) { + return null; + } + const url = await getDocumentUrl(config.ctx, projectDocument.key); + + return new Document({ + pageContent: doc.pageContent, + metadata: { + document: projectDocument, + source: url, + type: "document", + query: doc.metadata.query, + }, + }); + }), + ); + + await dispatchCustomEvent("tool_progress", { + chunk: "Formatting final output...", + }); + return returnString ? JSON.stringify(documents, null, 0) : documents; + }, + }); + + const webSearchTool = new DynamicStructuredTool({ + name: "searchWeb", + description: + "Search the web for current information using multiple queries (3-5). Access real-time web content including news, research papers, company information, and technical documentation.", + schema: z.object({ + queries: z + .array(z.string()) + .min(3) + .max(5) + .describe( + "List of search queries to find relevant web information (minimum 3 queries)", + ), + topic: z + .union([ + z.literal("company"), + z.literal("research paper"), + z.literal("news"), + z.literal("pdf"), + z.literal("github"), + z.literal("personal site"), + z.literal("linkedin profile"), + z.literal("financial report"), + ]) + .describe( + "The topic of the search query to optimize results. By default, performs a general web search. Choose the most relevant topic for better results.", + ) + .nullable() + .optional(), + }), + func: async ({ + queries, + topic, + }: { + queries: string[]; + topic?: string | null; + }) => { + const EXA_API_KEY = + ( + await config.ctx.runQuery(internal.apiKeys.queries.getFromKey, { + key: "EXA_API_KEY", + }) + )?.value ?? + process.env.EXA_API_KEY ?? + ""; + + try { + const exa = new Exa(EXA_API_KEY, undefined); + + // Perform web search for all queries in parallel + const searchPromises = queries.map(async (query) => { + const searchResponse = (console.log(query), + await exa.searchAndContents(query, { + numResults: 10, + type: "auto", + useAutoprompt: false, + topic: topic, + text: true, + })).results; + console.log(searchResponse.length); + + // Create LangChain Document objects from Exa search results + return searchResponse.map((result) => { + return new Document({ + pageContent: `${result.text}`, + metadata: { + type: "search", + title: result.title, + source: result.url, + publishedDate: result.publishedDate, + author: result.author, + image: result.image, + favicon: result.favicon, + query: query, + }, + }); + }); + }); + + const allResultsArrays = await Promise.all(searchPromises); + const allDocuments = allResultsArrays.flat(); + + if (allDocuments.length === 0) { + return "No results found."; + } + + if (returnString) { + return JSON.stringify(allDocuments, null, 0); + } + + return allDocuments; + } catch (error) { + return `Web search failed: ${ + error instanceof Error ? error.message : "Unknown error" + }`; + } + }, + }); + + return { + vectorSearch: vectorSearchTool, + webSearch: webSearchTool, + }; }; diff --git a/package.json b/package.json index 526d0bd..bc2d1ca 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "scripts": { "b": "npx convex dev", "dev": "vite --port 3000 & bun b", - "odev": "vite --port 3000", "setup": "bun setup.mjs", "start": "vite --port 3000", "build": "vite build && tsc --noEmit", @@ -144,7 +143,7 @@ "jsdom": "^26.1.0", "puppeteer-ghost": "^0.0.15", "rollup-plugin-visualizer": "^6.0.3", - "typescript": "^5.7.2", + "typescript": "^5.9.0-beta", "vite": "^6.1.0", "vitest": "^3.0.5", "web-vitals": "^4.2.4", diff --git a/src/components/chat/messages/ai-message/ai-message.tsx b/src/components/chat/messages/ai-message/ai-message.tsx index b69e0ec..2131daa 100644 --- a/src/components/chat/messages/ai-message/ai-message.tsx +++ b/src/components/chat/messages/ai-message/ai-message.tsx @@ -27,14 +27,18 @@ export const AiMessageContent = memo( if (type !== "ai") { return []; } - const parsed = parseContent(content as string); + if (Array.isArray(content)) { + return []; + } + // Ensure content is a string before parsing + const contentString = + typeof content === "string" ? content : String(content); + const parsed = parseContent(contentString); return parsed; }, [content, type]); // Memoize the content rendering to avoid unnecessary re-renders const renderedContent = useMemo(() => { - if (type !== "ai") return content as string; - return parsedContent.map((part: ContentPart, index: number) => { if (part.type === "text") { // Only render non-empty text content diff --git a/src/components/chat/messages/ai-message/reasoning.tsx b/src/components/chat/messages/ai-message/reasoning.tsx index afb697e..f36ff81 100644 --- a/src/components/chat/messages/ai-message/reasoning.tsx +++ b/src/components/chat/messages/ai-message/reasoning.tsx @@ -1,58 +1,58 @@ import { memo, useState } from "react"; import { - Accordion, - AccordionItem, - AccordionTrigger, - AccordionContent, + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, } from "@/components/ui/accordion"; import { Markdown } from "@/components/ui/markdown"; import { BrainIcon, ChevronDownIcon, Loader2 } from "lucide-react"; interface ReasoningProps { - reasoning?: string; - messageId: string; - isStreaming?: boolean; + reasoning?: string; + messageId: string; + isStreaming?: boolean; } export const Reasoning = memo( - ({ reasoning, messageId, isStreaming }: ReasoningProps) => { - if (!reasoning) return null; - const [isOpen, setIsOpen] = useState(false); + ({ reasoning, messageId, isStreaming }: ReasoningProps) => { + const [isOpen, setIsOpen] = useState(false); + if (!reasoning) return null; - return ( - setIsOpen(value.includes("reasoning"))} - > - - -
- -
Reasoning
- {isStreaming && ( - - )} -
- -
- - - -
-
- ); - }, + return ( + setIsOpen(value.includes("reasoning"))} + > + + +
+ +
Reasoning
+ {isStreaming && ( + + )} +
+ +
+ + + +
+
+ ); + }, ); Reasoning.displayName = "Reasoning"; diff --git a/src/components/chat/messages/ai-message/tool-message/index.tsx b/src/components/chat/messages/ai-message/tool-message/index.tsx index 2ff328b..7a1b91e 100644 --- a/src/components/chat/messages/ai-message/tool-message/index.tsx +++ b/src/components/chat/messages/ai-message/tool-message/index.tsx @@ -6,139 +6,139 @@ import { FileDisplay } from "./file-result"; import ToolAccordion from "@/components/ui/tool-accoordion"; export const ToolMessage = memo(({ message }: { message: BaseMessage }) => { - const parsedContent = useMemo(() => { - if (!message) return null; + const parsedContent = useMemo(() => { + if (!message) return null; - // 1) Known “searchWeb” → SearchResult[] - if (message.name === "searchWeb") { - try { - return { - type: "searchWeb" as const, - results: JSON.parse(message.content as string) as SearchResult[], - }; - } catch { - return { type: "generic" as const, content: message.content }; - } - } + // 1) Known “searchWeb” → SearchResult[] + if (message.name === "searchWeb") { + try { + return { + type: "searchWeb" as const, + results: JSON.parse(message.content as string) as SearchResult[], + }; + } catch { + return { type: "generic" as const, content: message.content }; + } + } - // 2) Known “searchProjectDocuments” → DocumentResult[] - if (message.name === "searchProjectDocuments") { - try { - return { - type: "document" as const, - results: JSON.parse(message.content as string) as DocumentResult[], - }; - } catch { - return { type: "generic" as const, content: message.content }; - } - } + // 2) Known “searchProjectDocuments” → DocumentResult[] + if (message.name === "searchProjectDocuments") { + try { + return { + type: "document" as const, + results: JSON.parse(message.content as string) as DocumentResult[], + }; + } catch { + return { type: "generic" as const, content: message.content }; + } + } - // 3) Mixed [ { type: "file", file: { file_id } } | { type: "text", text } ] - try { - const maybeArr = JSON.parse(message.content as string); - if (Array.isArray(maybeArr)) { - const isMixed = maybeArr.some( - (i) => - (i.type === "file" && i.file?.file_id) || - (i.type === "text" && i.text), - ); - if (isMixed) { - return { type: "mixed" as const, content: maybeArr }; - } - } - } catch { - // not JSON or not the format we expected - } + // 3) Mixed [ { type: "file", file: { file_id } } | { type: "text", text } ] + try { + const maybeArr = JSON.parse(message.content as string); + if (Array.isArray(maybeArr)) { + const isMixed = maybeArr.some( + (i) => + (i.type === "file" && i.file?.file_id) || + (i.type === "text" && i.text), + ); + if (isMixed) { + return { type: "mixed" as const, content: maybeArr }; + } + } + } catch { + // not JSON or not the format we expected + } - // 4) fallback generic - return { type: "generic" as const, content: message.content }; - }, [message]); + // 4) fallback generic + return { type: "generic" as const, content: message.content }; + }, [message]); - const input = (message.additional_kwargs as any)?.input as - | Record - | undefined; + const input = (message.additional_kwargs as any)?.input as + | Record + | undefined; - if (!parsedContent) return null; + if (!parsedContent) return null; - // Search/Web calls render in their own specialized component - if (parsedContent.type === "searchWeb") { - return ( - - ); - } - if (parsedContent.type === "document") { - return ( - - ); - } + // Search/Web calls render in their own specialized component + if (parsedContent.type === "searchWeb") { + return ( + + ); + } + if (parsedContent.type === "document") { + return ( + + ); + } - // MIXED: files + text blocks - if (parsedContent.type === "mixed") { - const images = parsedContent.content.filter( - (item) => item.type === "file" && item.file?.file_id, - ); - const nonImageContent = parsedContent.content.filter( - (item) => !(item.type === "file" && item.file?.file_id), - ); + // MIXED: files + text blocks + if (parsedContent.type === "mixed") { + const images = parsedContent.content.filter( + (item) => item.type === "file" && item.file?.file_id, + ); + const nonImageContent = parsedContent.content.filter( + (item) => !(item.type === "file" && item.file?.file_id), + ); - return ( -
- - {nonImageContent.length > 0 ? ( -
- {nonImageContent.map((item, idx) => { - if (item.type === "text" && item.text) { - return ( -
-                      {item.text}
-                    
- ); - } - return null; - })} -
- ) : ( -
- {images.length > 0 ? "Images displayed below" : "No output"} -
- )} -
+ return ( +
+ + {nonImageContent.length > 0 ? ( +
+ {nonImageContent.map((item, idx) => { + if (item.type === "text" && item.text) { + return ( +
+											{item.text}
+										
+ ); + } + return null; + })} +
+ ) : ( +
+ {images.length > 0 ? "Images displayed below" : "No output"} +
+ )} +
- {images.length > 0 && ( -
- {images.map((item, idx) => ( - - ))} -
- )} -
- ); - } + {images.length > 0 && ( +
+ {images.map((item, idx) => ( + + ))} +
+ )} +
+ ); + } - // GENERIC: just dump the string or JSON - return ( - - {typeof parsedContent.content === "string" ? ( -
-          {parsedContent.content}
-        
- ) : ( -
-          {JSON.stringify(parsedContent.content, null, 2)}
-        
- )} -
- ); + // GENERIC: just dump the string or JSON + return ( + + {typeof parsedContent.content === "string" ? ( +
+					{parsedContent.content}
+				
+ ) : ( +
+					{JSON.stringify(parsedContent.content, null, 2)}
+				
+ )} +
+ ); }); ToolMessage.displayName = "ToolMessage"; diff --git a/src/components/chat/messages/ai-message/tool-message/search-results.tsx b/src/components/chat/messages/ai-message/tool-message/search-results.tsx index 1f5d7c3..f03e081 100644 --- a/src/components/chat/messages/ai-message/tool-message/search-results.tsx +++ b/src/components/chat/messages/ai-message/tool-message/search-results.tsx @@ -1,200 +1,200 @@ import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, } from "@/components/ui/accordion"; import { Favicon } from "@/components/ui/favicon"; import { Markdown } from "@/components/ui/markdown"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, } from "@/components/ui/tooltip"; import { extractDomain } from "@/lib/utils"; -import { ChevronDownIcon, ExternalLinkIcon, GlobeIcon } from "lucide-react"; +import { ChevronDown, ExternalLinkIcon, GlobeIcon } from "lucide-react"; // Type definition for search results output export type SearchResultMetadata = { - type: string; // e.g., "search" - title: string; // Page title - source: string; // URL of the source - publishedDate: string; // ISO date string - author: string; // Author name (can be empty) - image?: string; // Optional image URL - favicon?: string; // Optional favicon URL + type: string; // e.g., "search" + title: string; // Page title + source: string; // URL of the source + publishedDate: string; // ISO date string + author: string; // Author name (can be empty) + image?: string; // Optional image URL + favicon?: string; // Optional favicon URL }; export type SearchResult = { - pageContent: string; // Full text content of the page - metadata: SearchResultMetadata; // Metadata about the source + pageContent: string; // Full text content of the page + metadata: SearchResultMetadata; // Metadata about the source }; interface SearchResultDisplayProps { - input: Record | undefined; - results: SearchResult[]; + input: Record | undefined; + results: SearchResult[]; } export const SearchResultDisplay = ({ - results, - input, + results, + input, }: SearchResultDisplayProps) => { - if (!results || results.length === 0) { - return ( -
- No search results found -
- ); - } + if (!results || results.length === 0) { + return ( +
+ No search results found +
+ ); + } - return ( - - - -
-
- - - Web Search Results ({results.length}) - -
- - - -
- {input?.queries - ? input.queries.length === 1 - ? input.queries[0] - : `${input.queries.length} queries` - : (input?.query as string)} - {input?.topic && ( - • {input.topic} - )} - -
-
- -
- {input?.queries ? ( - <> -
- Search Queries: -
-
    - {input.queries.map((query: string, index: number) => ( -
  • - - {index + 1}. - - {query} -
  • - ))} -
- {input?.topic && ( -
- - Topic:{" "} - - {input.topic} - - -
- )} - - ) : ( -
-
Search Query:
-
- {input?.query as string} -
-
- )} -
-
-
-
-
-
- -
- {results.map((result) => ( - - ))} -
-
-
-
- ); + /> + +
+ + {extractDomain(result.metadata.source)} + + +
+ + + ))} + + + + + ); }; diff --git a/src/components/chat/messages/index.tsx b/src/components/chat/messages/index.tsx index 4eba478..7f19113 100644 --- a/src/components/chat/messages/index.tsx +++ b/src/components/chat/messages/index.tsx @@ -13,7 +13,6 @@ export const ChatMessages = ({ chatId }: { chatId: Id<"chats"> | "new" }) => { const { isLoading, isEmpty } = useMessages({ chatId }); const streamStatus = useAtomValue(streamStatusAtom); - const mainContent = useMemo(() => { if (chatId === "new") { // Get user name from loadable state @@ -23,13 +22,13 @@ export const ChatMessages = ({ chatId }: { chatId: Id<"chats"> | "new" }) => { return (
how can i help you, - {userName} ? + {userName} ?
); diff --git a/src/components/chat/messages/streaming-message.tsx b/src/components/chat/messages/streaming-message.tsx index 4e7fb03..32c637f 100644 --- a/src/components/chat/messages/streaming-message.tsx +++ b/src/components/chat/messages/streaming-message.tsx @@ -8,67 +8,67 @@ import { useStreamAtom } from "@/store/chatStore"; import { streamingVariants, springTransition } from "@/lib/motion"; export const StreamingMessage = memo(() => { - const streamData = useAtomValue(useStreamAtom); - const messageId = "streaming-message"; + const streamData = useAtomValue(useStreamAtom); + const messageId = "streaming-message"; - const planningSteps = useMemo(() => { - if (!streamData?.planningStepsMessage) return null; + const planningSteps = useMemo(() => { + if (!streamData?.planningStepsMessage) return null; - return ( - - ); - }, [streamData?.planningStepsMessage, streamData?.status, messageId]); + return ( + + ); + }, [streamData?.planningStepsMessage, streamData?.status]); - const regularContent = useMemo(() => { - if (!streamData?.langchainMessages) return []; + const regularContent = useMemo(() => { + if (!streamData?.langchainMessages) return []; - return streamData.langchainMessages.map((message, index) => { - const isLastAiMessage = - index === streamData.langchainMessages!.length - 1 && - message?.getType() === "ai"; + return streamData.langchainMessages.map((message, index) => { + const isLastAiMessage = + index === streamData.langchainMessages.length - 1 && + message?.getType() === "ai"; - return ( - - {message?.getType() === "ai" ? ( - - ) : ( - - )} - - ); - }); - }, [streamData?.langchainMessages, messageId]); + return ( + + {message?.getType() === "ai" ? ( + + ) : ( + + )} + + ); + }); + }, [streamData?.langchainMessages]); - if ( - !streamData?.langchainMessages || - streamData.langchainMessages.length === 0 - ) - return null; + if ( + !streamData?.langchainMessages || + streamData.langchainMessages.length === 0 + ) + return null; - return ( - - {planningSteps || regularContent} - - ); + return ( + + {planningSteps || regularContent} + + ); }); StreamingMessage.displayName = "StreamingMessage"; diff --git a/src/components/chat/messages/utils-bar/index.ts b/src/components/chat/messages/utils-bar/index.ts index 1268c41..31c991a 100644 --- a/src/components/chat/messages/utils-bar/index.ts +++ b/src/components/chat/messages/utils-bar/index.ts @@ -11,66 +11,66 @@ export { AiUtilsBar } from "./ai-utils-bar"; // Helper function for navigation logic const navigateToChat = ( - navigate: ReturnType, - chatId: Id<"chats">, + navigate: ReturnType, + chatId: Id<"chats">, ) => { - navigate({ - to: "/chat/$chatId", - params: { chatId }, - }); + navigate({ + to: "/chat/$chatId", + params: { chatId }, + }); }; export function useMessageActions() { - const regenerate = useAction(api.langchain.index.regenerate); - const branchChat = useAction(api.langchain.index.branchChat); - const chat = useAction(api.langchain.index.chat); - const navigate = useNavigate(); - const navigateBranch = useNavigateBranch(); - const { mutateAsync: updateChatMutation } = useMutation({ - mutationFn: useConvexMutation(api.chats.mutations.update), - }); + const regenerate = useAction(api.langchain.index.regenerate); + const branchChat = useAction(api.langchain.index.branchChat); + const chat = useAction(api.langchain.index.chat); + const navigate = useNavigate(); + const navigateBranch = useNavigateBranch(); + const { mutateAsync: updateChatMutation } = useMutation({ + mutationFn: useConvexMutation(api.chats.mutations.update), + }); - const handleBranch = async ( - input: MessageWithBranchInfo, - model?: string, - editedContent?: { text?: string; documents?: Id<"documents">[] }, - ) => { - const result = await branchChat({ - chatId: input.message.chatId!, - branchFrom: input.message._id, - ...(model && { model }), - ...(editedContent && { editedContent }), - }); - if (result?.newChatId) { - // Model is already persisted by branchChat; start chat without forwarding model - chat({ - chatId: result.newChatId, - }); - navigateToChat(navigate, result.newChatId); - } - }; + const handleBranch = async ( + input: MessageWithBranchInfo, + model?: string, + editedContent?: { text?: string; documents?: Id<"documents">[] }, + ) => { + const result = await branchChat({ + chatId: input.message.chatId, + branchFrom: input.message._id, + ...(model && { model }), + ...(editedContent && { editedContent }), + }); + if (result?.newChatId) { + // Model is already persisted by branchChat; start chat without forwarding model + chat({ + chatId: result.newChatId, + }); + navigateToChat(navigate, result.newChatId); + } + }; - const handleRegenerate = async ( - input: MessageWithBranchInfo, - model?: string, - ) => { - navigateBranch?.(input.depth, input.totalBranches, input.totalBranches + 1); - // If the model is provided, update the chat with the new model - if (model) { - await updateChatMutation({ - chatId: input.message.chatId!, - updates: { model }, - }); - } - await regenerate({ - messageId: input.message._id, - }); - }; + const handleRegenerate = async ( + input: MessageWithBranchInfo, + model?: string, + ) => { + navigateBranch?.(input.depth, input.totalBranches, input.totalBranches + 1); + // If the model is provided, update the chat with the new model + if (model) { + await updateChatMutation({ + chatId: input.message.chatId, + updates: { model }, + }); + } + await regenerate({ + messageId: input.message._id, + }); + }; - return { - handleBranch, - handleRegenerate, - navigate, - navigateBranch, - }; + return { + handleBranch, + handleRegenerate, + navigate, + navigateBranch, + }; } diff --git a/src/components/chat/messages/utils-bar/user-utils-bar.tsx b/src/components/chat/messages/utils-bar/user-utils-bar.tsx index f59f359..4b5da8c 100644 --- a/src/components/chat/messages/utils-bar/user-utils-bar.tsx +++ b/src/components/chat/messages/utils-bar/user-utils-bar.tsx @@ -3,13 +3,13 @@ import type { Dispatch, SetStateAction } from "react"; import { BranchNavigation } from "./branch-navigation"; import { Button } from "@/components/ui/button"; import { - Check, - CheckCheck, - GitBranch, - Pencil, - RefreshCcw, - X, - PaperclipIcon, + Check, + CheckCheck, + GitBranch, + Pencil, + RefreshCcw, + X, + PaperclipIcon, } from "lucide-react"; import { ActionDropdown } from "./action-dropdown"; import { useMutation, useAction } from "convex/react"; @@ -20,262 +20,296 @@ import { TooltipButton } from "@/components/ui/tooltip-button"; import { CopyButton } from "./copy-button"; import { useMessageActions } from "./index"; import { useUploadDocuments } from "@/hooks/chats/use-documents"; +import { toast } from "sonner"; interface MessageContent { - type: string; - text?: string; + type: string; + text?: string; + file?: { + file_id: Id<"documents">; + }; } interface UserUtilsBarProps { - input: MessageWithBranchInfo; - isEditing?: boolean; - setEditing?: Dispatch>; - editedText?: string; - editedDocuments?: Id<"documents">[]; - onDone?: () => void; - onDocumentsChange?: (documents: Id<"documents">[]) => void; + input: MessageWithBranchInfo; + isEditing?: boolean; + setEditing?: Dispatch>; + editedText?: string; + editedDocuments?: Id<"documents">[]; + onDone?: () => void; + onDocumentsChange?: (documents: Id<"documents">[]) => void; } export const UserUtilsBar = memo( - ({ - input, - isEditing, - setEditing, - editedText, - editedDocuments, - onDone, - onDocumentsChange, - }: UserUtilsBarProps) => { - const { handleBranch, handleRegenerate, navigateBranch } = - useMessageActions(); - const updateMessage = useMutation(api.chatMessages.mutations.updateInput); - const chat = useAction(api.langchain.index.chat); - const fileInputRef = useRef(null); - const [isDragActive, setIsDragActive] = useState(false); - const handleFileUpload = useUploadDocuments({ type: "file" }); + ({ + input, + isEditing, + setEditing, + editedText, + editedDocuments, + onDone, + onDocumentsChange, + }: UserUtilsBarProps) => { + const { handleBranch, handleRegenerate, navigateBranch } = + useMessageActions(); + const updateMessage = useMutation(api.chatMessages.mutations.updateInput); + const chat = useAction(api.langchain.index.chat); + const fileInputRef = useRef(null); + const [isDragActive, setIsDragActive] = useState(false); + const handleFileUpload = useUploadDocuments({ type: "file" }); - // File upload handlers for editing - const handleFileInputChange = useCallback( - async (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - const uploadedIds = await handleFileUpload(e.target.files); - if (uploadedIds && onDocumentsChange) { - onDocumentsChange([...(editedDocuments || []), ...uploadedIds]); - } - } - }, - [handleFileUpload, editedDocuments, onDocumentsChange], - ); + // File upload handlers for editing + const handleFileInputChange = useCallback( + async (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + const uploadedIds = await handleFileUpload(e.target.files); + if (uploadedIds && onDocumentsChange) { + onDocumentsChange([...(editedDocuments || []), ...uploadedIds]); + } + } + }, + [handleFileUpload, editedDocuments, onDocumentsChange], + ); - const handleDrop = useCallback( - async (e: React.DragEvent) => { - e.preventDefault(); - setIsDragActive(false); - if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { - const uploadedIds = await handleFileUpload(e.dataTransfer.files); - if (uploadedIds && onDocumentsChange) { - onDocumentsChange([...(editedDocuments || []), ...uploadedIds]); - } - } - }, - [handleFileUpload, editedDocuments, onDocumentsChange], - ); + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + setIsDragActive(false); + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const uploadedIds = await handleFileUpload(e.dataTransfer.files); + if (uploadedIds && onDocumentsChange) { + onDocumentsChange([...(editedDocuments || []), ...uploadedIds]); + } + } + }, + [handleFileUpload, editedDocuments, onDocumentsChange], + ); - const handleDragOver = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - if (!isDragActive) setIsDragActive(true); - }, - [isDragActive], - ); + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + if (!isDragActive) setIsDragActive(true); + }, + [isDragActive], + ); - const handleDragLeave = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - if (e.currentTarget.contains(e.relatedTarget as Node)) return; - setIsDragActive(false); - }, - [], - ); + const handleDragLeave = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + if (e.currentTarget.contains(e.relatedTarget as Node)) return; + setIsDragActive(false); + }, + [], + ); - const copyText = (() => { - const content = input?.message.message.content; - if (!content) return ""; + const copyText = (() => { + const content = input?.message.message.content; + if (!content) return ""; - if (Array.isArray(content)) { - const textContent = (content as MessageContent[]).find( - (entry) => entry.type === "text", - ); - return textContent?.text || ""; - } - return typeof content === "string" ? content : ""; - })(); + if (Array.isArray(content)) { + const textContent = (content as MessageContent[]).find( + (entry) => entry.type === "text", + ); + return textContent?.text || ""; + } + return typeof content === "string" ? content : ""; + })(); - const handleSubmit = (applySame: boolean, model?: string) => { - if (!editedText) { - return; - } - if (applySame === false) { - navigateBranch?.( - input.depth, - input.totalBranches, - input.totalBranches + 1, - ); - } - updateMessage({ - id: input.message._id as Id<"chatMessages">, - updates: { text: editedText, documents: editedDocuments || [] }, - applySame: applySame, - }).then(() => { - if (applySame === false) { - chat({ chatId: input.message.chatId!, model }); - } - }); - onDone?.(); - }; + const handleSubmit = useCallback( + (applySame: boolean, model?: string) => { + if (!editedText?.trim()) { + toast.error("Please enter a message"); + return; + } - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (!isEditing) return; + if (applySame === false) { + navigateBranch?.( + input.depth, + input.totalBranches, + input.totalBranches + 1, + ); + } - if (e.key === "Enter" && !e.shiftKey) { - // Enter: Submit and regenerate - e.preventDefault(); - handleSubmit(false); - } - }, - [isEditing, handleSubmit], - ); + const finalDocuments = + editedDocuments ?? + (Array.isArray(input.message.message.content) + ? input.message.message.content + .filter( + ( + c, + ): c is MessageContent & { + type: "file"; + file: { file_id: Id<"documents"> }; + } => c.type === "file" && c.file?.file_id !== undefined, + ) + .map((c) => c.file.file_id) + : []); - useEffect(() => { - if (isEditing) { - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("keydown", handleKeyDown); - }; - } - }, [isEditing, handleKeyDown]); + try { + updateMessage({ + id: input.message._id as Id<"chatMessages">, + updates: { text: editedText, documents: finalDocuments }, + applySame: applySame, + }).then(() => { + if (applySame === false) { + chat({ chatId: input.message.chatId, model }); + } + }); + onDone?.(); + } catch { + toast.error("Failed to submit message edit"); + } + }, + [ + input.message._id, + editedText, + editedDocuments, + onDone, + navigateBranch, + chat, + input.depth, + input.totalBranches, + updateMessage, + input.message.chatId, + input.message.message.content, + ], + ); - if (isEditing) { - return ( -
- - setEditing?.(null)} - icon={} - tooltip="Cancel" - /> - handleSubmit(true)} - icon={} - tooltip="Submit" - ariaLabel="Submit" - /> - - - - } - actionLabel={ - <> - - Submit and Regenerate - - } - onAction={() => handleSubmit(false)} - onActionWithModel={(model) => handleSubmit(false, model)} - /> - - - - } - actionLabel={ - <> - - Branch from edited - - } - onAction={() => - handleBranch(input, undefined, { - text: editedText, - documents: editedDocuments, - }) - } - onActionWithModel={(model) => - handleBranch(input, model, { - text: editedText, - documents: editedDocuments, - }) - } - /> - fileInputRef.current?.click()} - icon={} - tooltip="Attach files" - /> -
- ); - } + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isEditing) return; - return ( -
- - {setEditing && ( - setEditing(input.message._id)} - icon={} - tooltip="Edit" - ariaLabel="Edit" - /> - )} - - - - } - actionLabel={ - <> - - Branch - - } - onAction={() => handleBranch(input)} - onActionWithModel={(model) => handleBranch(input, model)} - /> - - - - } - actionLabel={ - <> - - Regenerate - - } - onAction={() => handleRegenerate(input)} - onActionWithModel={(model) => handleRegenerate(input, model)} - /> - {copyText && } -
- ); - }, + if (e.key === "Enter" && !e.shiftKey) { + // Enter: Submit and regenerate + e.preventDefault(); + handleSubmit(false); + } + }, + [isEditing, handleSubmit], + ); + + useEffect(() => { + if (isEditing) { + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + } + }, [isEditing, handleKeyDown]); + + if (isEditing) { + return ( +
+ + setEditing?.(null)} + icon={} + tooltip="Cancel" + /> + handleSubmit(true)} + icon={} + tooltip="Submit" + ariaLabel="Submit" + disabled={!editedText?.trim() && editedDocuments?.length === 0} + /> + handleSubmit(false)} + icon={} + tooltip="Submit and Regenerate" + ariaLabel="Submit and Regenerate" + disabled={!editedText?.trim() && editedDocuments?.length === 0} + /> + + + + } + actionLabel={ + <> + + Branch from edited + + } + onAction={() => + handleBranch(input, undefined, { + text: editedText, + documents: editedDocuments, + }) + } + onActionWithModel={(model) => + handleBranch(input, model, { + text: editedText, + documents: editedDocuments, + }) + } + /> + fileInputRef.current?.click()} + icon={} + tooltip="Attach files" + /> +
+ ); + } + + return ( +
+ + {setEditing && ( + setEditing(input.message._id)} + icon={} + tooltip="Edit" + ariaLabel="Edit" + /> + )} + + + + } + actionLabel={ + <> + + Branch + + } + onAction={() => handleBranch(input)} + onActionWithModel={(model) => handleBranch(input, model)} + /> + + + + } + actionLabel={ + <> + + Regenerate + + } + onAction={() => handleRegenerate(input)} + onActionWithModel={(model) => handleRegenerate(input, model)} + /> + {copyText && } +
+ ); + }, ); UserUtilsBar.displayName = "UserUtilsBar"; diff --git a/src/components/document-dialog.tsx b/src/components/document-dialog.tsx index 83a97e1..3254de8 100644 --- a/src/components/document-dialog.tsx +++ b/src/components/document-dialog.tsx @@ -1,8 +1,8 @@ import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; import { documentDialogOpenAtom } from "@/store/chatStore"; import { api } from "../../convex/_generated/api"; @@ -14,207 +14,259 @@ import { formatBytes } from "@/lib/utils"; import { useState, useEffect } from "react"; import { useAtomValue, useSetAtom } from "jotai"; +const isSafeHttpUrl = (raw: string): boolean => { + try { + const url = new URL(raw); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +}; + export const DocumentDialog = () => { - const documentDialogOpen = useAtomValue(documentDialogOpenAtom); - const setDocumentDialogOpen = useSetAtom(documentDialogOpenAtom); - const [previewUrl, setPreviewUrl] = useState(null); + const documentDialogOpen = useAtomValue(documentDialogOpenAtom); + const setDocumentDialogOpen = useSetAtom(documentDialogOpenAtom); + const [previewUrl, setPreviewUrl] = useState(null); + const [previewError, setPreviewError] = useState(null); + + const { data: document } = useQuery({ + ...convexQuery( + api.documents.queries.get, + documentDialogOpen ? { documentId: documentDialogOpen } : "skip", + ), + enabled: !!documentDialogOpen, + }); - const { data: document } = useQuery({ - ...convexQuery( - api.documents.queries.get, - documentDialogOpen ? { documentId: documentDialogOpen } : "skip", - ), - enabled: !!documentDialogOpen, - }); + const { mutateAsync: generateDownloadUrl } = useMutation({ + mutationFn: useConvexMutation(api.documents.mutations.generateDownloadUrl), + }); - const { mutateAsync: generateDownloadUrl } = useMutation({ - mutationFn: useConvexMutation(api.documents.mutations.generateDownloadUrl), - }); + const documentName = document?.name ?? ""; + const { + icon: Icon, + className: IconClassName, + tag, + } = document + ? getDocTagInfo(document) + : { icon: () => null, className: "", tag: "" }; - const documentName = document?.name ?? ""; - const { - icon: Icon, - className: IconClassName, - tag, - } = document - ? getDocTagInfo(document) - : { icon: () => null, className: "", tag: "" }; + useEffect(() => { + setPreviewUrl(null); + setPreviewError(null); - useEffect(() => { - setPreviewUrl(null); - const loadPreviewUrl = async () => { - if (!document) return; - switch (tag) { - case "image": - case "pdf": - case "file": { - // Only files need download URL - const url = await generateDownloadUrl({ - documentId: document._id!, - }); - setPreviewUrl(url); - break; - } - case "url": - case "site": { - setPreviewUrl(document.key as string); - break; - } - case "youtube": { - setPreviewUrl(`https://www.youtube.com/embed/${document.key}`); - break; - } - default: - if (["file", "text", "github"].includes(document.type)) { - const url = await generateDownloadUrl({ - documentId: document._id!, - }); - setPreviewUrl(url); - break; - } else { - setPreviewUrl(document.key as string); - } - break; - } - }; - loadPreviewUrl(); - }, [document, tag, generateDownloadUrl]); + const loadPreviewUrl = async () => { + if (!document) return; + try { + switch (tag) { + case "image": + case "pdf": + case "file": { + // Only files need download URL + const url = await generateDownloadUrl({ + documentId: document._id, + }); + setPreviewUrl(url); + break; + } + case "url": + case "site": { + const raw = String(document.key ?? ""); + if (!isSafeHttpUrl(raw)) { + setPreviewError("Invalid URL. Only http(s) URLs are allowed."); + } else { + setPreviewUrl(raw); + } + break; + } + case "youtube": { + setPreviewUrl(`https://www.youtube.com/embed/${document.key}`); + break; + } + default: + if (["file", "text", "github"].includes(document.type)) { + const url = await generateDownloadUrl({ + documentId: document._id, + }); + setPreviewUrl(url); + break; + } else { + setPreviewUrl(document.key as string); + } + break; + } + } catch (error) { + setPreviewError( + `Failed to load preview: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }; + loadPreviewUrl(); + }, [document, tag, generateDownloadUrl]); - // Early return if dialog is not open - if (!documentDialogOpen) { - return null; - } + // Early return if dialog is not open + if (!documentDialogOpen) { + return null; + } - const handleDownload = async () => { - if (!document || tag !== "file") return; - const url = await generateDownloadUrl({ - documentId: document._id!, - }); - if (url) { - window.open(url, "_blank"); - } - }; + const handleDownload = async () => { + if (!document || !["file", "image", "pdf", "audio", "video"].includes(tag)) + return; + try { + const url = await generateDownloadUrl({ + documentId: document._id, + }); + if (url) { + const w = window.open(url, "_blank", "noopener,noreferrer"); + if (w) w.opener = null; + } + } catch (err) { + setPreviewError( + `Failed to download: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } + }; - const handleOpen = () => { - if (!document) return; - if (tag === "url" || tag === "site") { - window.open(document.key as string, "_blank"); - } else if (tag === "youtube") { - window.open(`https://youtube.com/watch?v=${document.key}`, "_blank"); - } - }; + const handleOpen = () => { + if (!document) return; + if (tag === "url" || tag === "site") { + if (!isSafeHttpUrl(document.key as string)) { + return; + } + const w = window.open( + document.key as string, + "_blank", + "noopener,noreferrer", + ); + if (w) w.opener = null; + } else if (tag === "youtube") { + const w = window.open( + `https://youtube.com/watch?v=${document.key}`, + "_blank", + "noopener,noreferrer", + ); + if (w) w.opener = null; + } + }; - return ( - setDocumentDialogOpen(undefined)} - > - - - - - {documentName} - - + return ( + setDocumentDialogOpen(undefined)} + > + + + + + {documentName} + + -
-
-
- Type:{" "} - {document?.type && - document.type.charAt(0).toUpperCase() + document.type.slice(1)} -
- {document?.size && ( -
- Size: {formatBytes(document.size)} -
- )} -
+
+
+
+ Type:{" "} + {document?.type && + document.type.charAt(0).toUpperCase() + document.type.slice(1)} +
+ {document?.size && ( +
+ Size: {formatBytes(document.size)} +
+ )} +
- {previewUrl && ( -
- {(() => { - if (tag === "image") { - return ( - {documentName} - ); - } else if (tag === "pdf") { - return ( - -
- PDF preview not supported in your browser. Please - download the file to view it. -
-
- ); - } else if (tag === "youtube") { - return ( -