From 70ad2c5eb18397b1deb72d7e53d9a289c28e4ec6 Mon Sep 17 00:00:00 2001 From: cte Date: Mon, 24 Feb 2025 09:23:23 -0800 Subject: [PATCH 1/3] Deep research --- package-lock.json | 289 +++++- package.json | 30 +- src/__mocks__/p-limit.js | 3 + src/activate/registerCommands.ts | 3 + .../__tests__/edit-strategies.test.ts | 6 +- src/core/webview/ClineProvider.ts | 119 ++- .../deep-research/DeepResearchService.ts | 936 ++++++++++++++++++ src/services/deep-research/TextSplitter.ts | 139 +++ .../__tests__/TextSplitter.test.ts | 59 ++ src/services/deep-research/types.ts | 134 +++ src/shared/ExtensionMessage.ts | 9 + src/shared/WebviewMessage.ts | 53 +- src/shared/api.ts | 1 + src/test/suite/extension.test.ts | 2 + webview-ui/package-lock.json | 32 +- webview-ui/package.json | 3 +- webview-ui/src/App.tsx | 5 +- webview-ui/src/__tests__/App.test.tsx | 12 +- webview-ui/src/components/ui/button.tsx | 2 +- .../src/components/ui/chat/ChatInput.tsx | 6 +- .../src/components/ui/chat/ChatMessage.tsx | 24 +- .../src/components/ui/chat/ChatMessages.tsx | 52 +- webview-ui/src/components/ui/chat/types.ts | 51 +- webview-ui/src/components/ui/index.ts | 1 + .../features/deep-research/DeepResearch.tsx | 50 + .../deep-research/DeepResearchProvider.tsx | 181 ++++ .../src/features/deep-research/GetStarted.tsx | 261 +++++ .../src/features/deep-research/History.tsx | 110 ++ .../deep-research/HistoryProvider.tsx | 79 ++ .../src/features/deep-research/Models.tsx | 108 ++ .../src/features/deep-research/Providers.tsx | 68 ++ .../deep-research/ResearchSession.tsx | 143 +++ .../features/deep-research/ResearchTask.tsx | 52 + .../src/features/deep-research/format.ts | 20 + .../src/features/deep-research/types.ts | 250 +++++ .../features/deep-research/useDeepResearch.ts | 13 + .../src/features/deep-research/useHistory.ts | 13 + .../src/features/deep-research/useProvider.ts | 75 ++ .../deep-research/useResearchSession.ts | 24 + webview-ui/src/index.css | 4 +- webview-ui/src/stories/Chat.stories.tsx | 4 +- 41 files changed, 3334 insertions(+), 92 deletions(-) create mode 100644 src/__mocks__/p-limit.js create mode 100644 src/services/deep-research/DeepResearchService.ts create mode 100644 src/services/deep-research/TextSplitter.ts create mode 100644 src/services/deep-research/__tests__/TextSplitter.test.ts create mode 100644 src/services/deep-research/types.ts create mode 100644 webview-ui/src/features/deep-research/DeepResearch.tsx create mode 100644 webview-ui/src/features/deep-research/DeepResearchProvider.tsx create mode 100644 webview-ui/src/features/deep-research/GetStarted.tsx create mode 100644 webview-ui/src/features/deep-research/History.tsx create mode 100644 webview-ui/src/features/deep-research/HistoryProvider.tsx create mode 100644 webview-ui/src/features/deep-research/Models.tsx create mode 100644 webview-ui/src/features/deep-research/Providers.tsx create mode 100644 webview-ui/src/features/deep-research/ResearchSession.tsx create mode 100644 webview-ui/src/features/deep-research/ResearchTask.tsx create mode 100644 webview-ui/src/features/deep-research/format.ts create mode 100644 webview-ui/src/features/deep-research/types.ts create mode 100644 webview-ui/src/features/deep-research/useDeepResearch.ts create mode 100644 webview-ui/src/features/deep-research/useHistory.ts create mode 100644 webview-ui/src/features/deep-research/useProvider.ts create mode 100644 webview-ui/src/features/deep-research/useResearchSession.ts diff --git a/package-lock.json b/package-lock.json index 9822548678d..e23f85acbbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@anthropic-ai/vertex-sdk": "^0.4.1", "@aws-sdk/client-bedrock-runtime": "^3.706.0", "@google/generative-ai": "^0.18.0", + "@hookform/resolvers": "^4.0.0", + "@mendable/firecrawl-js": "^1.16.0", "@mistralai/mistralai": "^1.3.6", "@modelcontextprotocol/sdk": "^1.0.1", "@types/clone-deep": "^4.0.4", @@ -34,15 +36,18 @@ "get-folder-size": "^5.0.0", "globby": "^14.0.2", "isbinaryfile": "^5.0.2", + "js-tiktoken": "^1.0.18", "mammoth": "^1.8.0", "monaco-vscode-textmate-theme-converter": "^0.1.7", "openai": "^4.78.1", "os-name": "^6.0.0", + "p-limit": "^6.2.0", "p-wait-for": "^5.0.2", "pdf-parse": "^1.1.1", "pretty-bytes": "^6.1.1", "puppeteer-chromium-resolver": "^23.0.0", "puppeteer-core": "^23.4.0", + "react-hook-form": "^7.54.2", "serialize-error": "^11.0.3", "simple-git": "^3.27.0", "sound-play": "^1.1.0", @@ -51,8 +56,9 @@ "tmp": "^0.2.3", "tree-sitter-wasms": "^0.1.11", "turndown": "^7.2.0", + "use-callback-ref": "^1.3.3", "web-tree-sitter": "^0.22.6", - "zod": "^3.23.8" + "zod": "^3.24.2" }, "devDependencies": { "@changesets/cli": "^2.27.10", @@ -3248,6 +3254,18 @@ "node": ">=18.0.0" } }, + "node_modules/@hookform/resolvers": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.0.tgz", + "integrity": "sha512-fX/uHKb+OOCpACLc6enuTQsf0ZpRrKbeBBPETg5PCPLCIYV6osP2Bw6ezuclM61lH+wBF9eXcuC0+BFh9XOEnQ==", + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001698" + }, + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -4055,6 +4073,19 @@ "node": ">=8" } }, + "node_modules/@mendable/firecrawl-js": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@mendable/firecrawl-js/-/firecrawl-js-1.17.0.tgz", + "integrity": "sha512-W8NEbFLtgedSI4CwxDFJ2iwcmwL7F3Gkv8usagYQ764AxnNdmcylVWMuYoQmzS6iYCtmSLFQUNEvHW9NbWigPQ==", + "license": "MIT", + "dependencies": { + "axios": "^1.6.8", + "isows": "^1.0.4", + "typescript-event-target": "^1.1.1", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.0" + } + }, "node_modules/@mistralai/mistralai": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.3.6.tgz", @@ -7435,10 +7466,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001687", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001687.tgz", - "integrity": "sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ==", - "dev": true, + "version": "1.0.30001699", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz", + "integrity": "sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w==", "funding": [ { "type": "opencollective", @@ -7452,7 +7482,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "4.1.2", @@ -7564,6 +7595,15 @@ "devtools-protocol": "*" } }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -10614,6 +10654,21 @@ "node": ">=0.10.0" } }, + "node_modules/isows": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.6.tgz", + "integrity": "sha512-lPHCayd40oW98/I0uvgaHKWCSvkzY27LjWLbtzOm64yQ+G3Q5npjjbdppU65iZXkK1Zt+kH9pfegli0AYfwYYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -10788,6 +10843,35 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-changed-files/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-changed-files/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jest-circus": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", @@ -10819,6 +10903,22 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-circus/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jest-circus/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -10828,6 +10928,19 @@ "node": ">=8" } }, + "node_modules/jest-circus/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jest-cli": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", @@ -11223,6 +11336,35 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-runner/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-runner/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jest-runtime": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", @@ -11450,6 +11592,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tiktoken": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.19.tgz", + "integrity": "sha512-XC63YQeEcS47Y53gg950xiZ4IWmkfMe4p2V9OSaBt26q+p47WHn18izuXzSclCI73B7yGqtfRsT6jcZQI0y08g==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -13265,15 +13416,15 @@ } }, "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", + "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", + "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "yocto-queue": "^1.1.1" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -13294,6 +13445,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", @@ -13917,6 +14097,32 @@ "node": ">= 0.8" } }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-hook-form": { + "version": "7.54.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", + "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -15439,6 +15645,12 @@ "node": ">=14.17" } }, + "node_modules/typescript-event-target": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/typescript-event-target/-/typescript-event-target-1.1.1.tgz", + "integrity": "sha512-dFSOFBKV6uwaloBCCUhxlD3Pr/P1a/tJdcmPrTXCHlEFD3faj0mztjcGn6VBAhQ0/Bdy8K3VWrrqwbt/ffsYsg==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -15575,6 +15787,33 @@ "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==" }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-callback-ref/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -16243,25 +16482,35 @@ } }, "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", + "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.2.tgz", + "integrity": "sha512-pNUqrcSxuuB3/+jBbU8qKUbTbDqYUaG1vf5cXFjbhGgoUuA1amO/y4Q8lzfOhHU8HNPK6VFJ18lBDKj3OHyDsg==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "node_modules/zod-validation-error": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.4.0.tgz", diff --git a/package.json b/package.json index c4c38e78571..2383871de55 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,11 @@ "title": "New Task", "icon": "$(add)" }, + { + "command": "roo-cline.researchButtonClicked", + "title": "Deep Research (β)", + "icon": "$(telescope)" + }, { "command": "roo-cline.mcpButtonClicked", "title": "MCP Servers", @@ -207,34 +212,39 @@ "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.promptsButtonClicked", + "command": "roo-cline.researchButtonClicked", "group": "navigation@2", "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.mcpButtonClicked", + "command": "roo-cline.promptsButtonClicked", "group": "navigation@3", "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.historyButtonClicked", + "command": "roo-cline.mcpButtonClicked", "group": "navigation@4", "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.popoutButtonClicked", + "command": "roo-cline.historyButtonClicked", "group": "navigation@5", "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.settingsButtonClicked", + "command": "roo-cline.popoutButtonClicked", "group": "navigation@6", "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.helpButtonClicked", + "command": "roo-cline.settingsButtonClicked", "group": "navigation@7", "when": "view == roo-cline.SidebarProvider" + }, + { + "command": "roo-cline.helpButtonClicked", + "group": "navigation@8", + "when": "view == roo-cline.SidebarProvider" } ] }, @@ -308,6 +318,8 @@ "@anthropic-ai/vertex-sdk": "^0.4.1", "@aws-sdk/client-bedrock-runtime": "^3.706.0", "@google/generative-ai": "^0.18.0", + "@hookform/resolvers": "^4.0.0", + "@mendable/firecrawl-js": "^1.16.0", "@mistralai/mistralai": "^1.3.6", "@modelcontextprotocol/sdk": "^1.0.1", "@types/clone-deep": "^4.0.4", @@ -329,15 +341,18 @@ "get-folder-size": "^5.0.0", "globby": "^14.0.2", "isbinaryfile": "^5.0.2", + "js-tiktoken": "^1.0.18", "mammoth": "^1.8.0", "monaco-vscode-textmate-theme-converter": "^0.1.7", "openai": "^4.78.1", "os-name": "^6.0.0", + "p-limit": "^6.2.0", "p-wait-for": "^5.0.2", "pdf-parse": "^1.1.1", "pretty-bytes": "^6.1.1", "puppeteer-chromium-resolver": "^23.0.0", "puppeteer-core": "^23.4.0", + "react-hook-form": "^7.54.2", "serialize-error": "^11.0.3", "simple-git": "^3.27.0", "sound-play": "^1.1.0", @@ -346,8 +361,9 @@ "tmp": "^0.2.3", "tree-sitter-wasms": "^0.1.11", "turndown": "^7.2.0", + "use-callback-ref": "^1.3.3", "web-tree-sitter": "^0.22.6", - "zod": "^3.23.8" + "zod": "^3.24.2" }, "devDependencies": { "@changesets/cli": "^2.27.10", diff --git a/src/__mocks__/p-limit.js b/src/__mocks__/p-limit.js new file mode 100644 index 00000000000..5071aa2287a --- /dev/null +++ b/src/__mocks__/p-limit.js @@ -0,0 +1,3 @@ +const pLimit = (concurrency) => async (fn) => fn() + +module.exports = pLimit diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 69e257e7a51..80bd833091a 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -24,6 +24,9 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt await provider.postStateToWebview() await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) }, + "roo-cline.researchButtonClicked": () => { + provider.postMessageToWebview({ type: "action", action: "researchButtonClicked" }) + }, "roo-cline.mcpButtonClicked": () => { provider.postMessageToWebview({ type: "action", action: "mcpButtonClicked" }) }, diff --git a/src/core/diff/strategies/new-unified/__tests__/edit-strategies.test.ts b/src/core/diff/strategies/new-unified/__tests__/edit-strategies.test.ts index 2bc35540baf..f8251e3d6e6 100644 --- a/src/core/diff/strategies/new-unified/__tests__/edit-strategies.test.ts +++ b/src/core/diff/strategies/new-unified/__tests__/edit-strategies.test.ts @@ -1,3 +1,5 @@ +/// + import { applyContextMatching, applyDMP, applyGitFallback } from "../edit-strategies" import { Hunk } from "../types" @@ -275,7 +277,7 @@ describe("applyGitFallback", () => { expect(result.result.join("\n")).toEqual("line1\nnew line2\nline3") expect(result.confidence).toBe(1) expect(result.strategy).toBe("git-fallback") - }) + }, 10_000) it("should return original content with 0 confidence when changes cannot be applied", async () => { const hunk = { @@ -291,5 +293,5 @@ describe("applyGitFallback", () => { expect(result.result).toEqual(content) expect(result.confidence).toBe(0) expect(result.strategy).toBe("git-fallback") - }) + }, 10_000) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6790224ecae..91b41cd8f29 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -20,11 +20,18 @@ import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api" import { findLast } from "../../shared/array" import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage" import { HistoryItem } from "../../shared/HistoryItem" -import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage" +import { + checkoutDiffPayloadSchema, + checkoutRestorePayloadSchema, + researchTaskPayloadSchema, + researchInputPayloadSchema, + WebviewMessage, +} from "../../shared/WebviewMessage" import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug } from "../../shared/modes" import { SYSTEM_PROMPT } from "../prompts/system" import { fileExistsAtPath } from "../../utils/fs" import { Cline } from "../Cline" +import { DeepResearchService } from "../../services/deep-research/DeepResearchService" import { openMention } from "../mentions" import { getNonce } from "./getNonce" import { getUri } from "./getUri" @@ -60,6 +67,8 @@ type SecretKey = | "mistralApiKey" | "unboundApiKey" | "requestyApiKey" + | "firecrawlApiKey" + type GlobalStateKey = | "apiProvider" | "apiModelId" @@ -148,6 +157,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { private view?: vscode.WebviewView | vscode.WebviewPanel private isViewLaunched = false private cline?: Cline + private deepResearchService?: DeepResearchService private workspaceTracker?: WorkspaceTracker protected mcpHub?: McpHub // Change from private to protected private latestAnnouncementId = "jan-21-2025-custom-modes" // update to some unique identifier when we add a new announcement @@ -1526,6 +1536,107 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("mode", defaultModeSlug) await this.postStateToWebview() } + break + case "research.task": { + const result = researchTaskPayloadSchema.safeParse(message.payload) + + if (!result.success) { + console.warn( + `[ClineProvider#research.task] Invalid payload: ${JSON.stringify(message.payload)}`, + ) + break + } + + if (result.success && !this.deepResearchService) { + const { session } = result.data + this.deepResearchService = new DeepResearchService(session, this) + await this.deepResearchService.input(session.query) + } + + break + } + case "research.input": { + const result = researchInputPayloadSchema.safeParse(message.payload) + + if (!result.success) { + console.warn( + `[ClineProvider#research.input] Invalid payload: ${JSON.stringify(message.payload)}`, + ) + break + } + + if (result.success && this.deepResearchService) { + const { content } = result.data.message + this.deepResearchService.input(content) + } + + break + } + case "research.viewReport": + await this.deepResearchService?.viewReport() + break + case "research.createTask": + await this.deepResearchService?.createTask() + break + case "research.getTasks": + const tasks = await DeepResearchService.getTasks(this.context.globalStorageUri.fsPath) + + await this.postMessageToWebview({ + type: "research.history", + text: JSON.stringify( + tasks + .sort((a, b) => b.createdAt - a.createdAt) + .map((task) => ({ + taskId: task.taskId, + query: task.inquiry.initialQuery, + createdAt: task.createdAt, + })), + ), + }) + + break + case "research.getTask": + if (message.text) { + const task = await DeepResearchService.getTask( + this.context.globalStorageUri.fsPath, + message.text, + ) + + await this.postMessageToWebview({ type: "research.task", text: JSON.stringify(task) }) + } + + break + case "research.deleteTask": + if (message.text) { + try { + await DeepResearchService.deleteTask(this.context.globalStorageUri.fsPath, message.text) + const tasks = await DeepResearchService.getTasks(this.context.globalStorageUri.fsPath) + + await this.postMessageToWebview({ + type: "research.history", + text: JSON.stringify( + tasks + .sort((a, b) => b.createdAt - a.createdAt) + .map((task) => ({ + taskId: task.taskId, + query: task.inquiry.initialQuery, + createdAt: task.createdAt, + })), + ), + }) + } catch (error) { + vscode.window.showErrorMessage("Failed to delete research task.") + } + } + + break + case "research.abort": + await this.deepResearchService?.abort() + break + case "research.reset": + await this.deepResearchService?.abort() + this.deepResearchService = undefined + break } }, null, @@ -1673,6 +1784,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestyModelId, requestyModelInfo, modelTemperature, + firecrawlApiKey, } = apiConfiguration await Promise.all([ this.updateGlobalState("apiProvider", apiProvider), @@ -1720,6 +1832,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.updateGlobalState("requestyModelId", requestyModelId), this.updateGlobalState("requestyModelInfo", requestyModelInfo), this.updateGlobalState("modelTemperature", modelTemperature), + this.storeSecret("firecrawlApiKey", firecrawlApiKey), ]) if (this.cline) { this.cline.api = buildApiHandler(apiConfiguration) @@ -2603,6 +2716,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestyModelInfo, modelTemperature, maxOpenTabsContext, + firecrawlApiKey, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -2685,6 +2799,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("requestyModelInfo") as Promise, this.getGlobalState("modelTemperature") as Promise, this.getGlobalState("maxOpenTabsContext") as Promise, + this.getSecret("firecrawlApiKey") as Promise, ]) let apiProvider: ApiProvider @@ -2748,6 +2863,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestyModelId, requestyModelInfo, modelTemperature, + firecrawlApiKey, }, lastShownAnnouncementId, customInstructions, @@ -2905,6 +3021,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { "mistralApiKey", "unboundApiKey", "requestyApiKey", + "firecrawlApiKey", ] for (const key of secretKeys) { await this.storeSecret(key, undefined) diff --git a/src/services/deep-research/DeepResearchService.ts b/src/services/deep-research/DeepResearchService.ts new file mode 100644 index 00000000000..94f43d6351a --- /dev/null +++ b/src/services/deep-research/DeepResearchService.ts @@ -0,0 +1,936 @@ +import path from "path" +import os from "os" +import fs from "fs/promises" + +import OpenAI from "openai" +import { ChatCompletionMessageParam } from "openai/resources/chat/completions" +import { zodResponseFormat } from "openai/helpers/zod" +import FirecrawlApp, { SearchResponse } from "@mendable/firecrawl-js" +import { z } from "zod" +import pLimit from "p-limit" +import * as vscode from "vscode" + +import { ExtensionMessage } from "../../shared/ExtensionMessage" +import { ResearchTaskPayload } from "../../shared/WebviewMessage" +import { ClineProvider } from "../../core/webview/ClineProvider" + +import { + ResearchInquiry, + ResearchStep, + ResearchProgress, + ResearchResult, + ResearchLearnings, + ResearchQuery, + ResearchTokenUsage, + ResearchTask, + ResearchRole, + ResearchOutput, + ResearchAnnotationType, + researchLearningsSchema, + researchQuerySchema, + researchTaskSchema, +} from "./types" +import { RecursiveCharacterTextSplitter, encoder } from "./TextSplitter" + +type DeepResearchServiceStatus = "idle" | "followUp" | "research" | "done" | "aborted" + +export class DeepResearchService { + public readonly taskId: string + public readonly providerId: string + public readonly providerApiKey: string + public readonly firecrawlApiKey: string + public readonly modelId: string + public readonly breadth: number + public readonly depth: number + public readonly concurrency: number + + private providerRef: WeakRef + private firecrawl: FirecrawlApp + private openai: OpenAI + private _status: DeepResearchServiceStatus = "idle" + + private inquiry: ResearchInquiry = { followUps: [], responses: [] } + private progress: ResearchProgress = { expectedQueries: 0, completedQueries: 0, progressPercentage: 0 } + + private tokenUsage: ResearchTokenUsage = { inTokens: 0, outTokens: 0, totalTokens: 0 } + private output: ResearchOutput[] = [] + private messages: ChatCompletionMessageParam[] = [] + + constructor( + { + providerId, + providerApiKey, + firecrawlApiKey, + modelId, + breadth, + depth, + concurrency, + }: ResearchTaskPayload["session"], + clineProvider: ClineProvider, + ) { + this.taskId = crypto.randomUUID() + this.providerId = providerId + this.providerApiKey = providerApiKey + this.firecrawlApiKey = firecrawlApiKey + this.modelId = modelId + this.breadth = breadth + this.depth = depth + this.concurrency = concurrency + + this.providerRef = new WeakRef(clineProvider) + + this.firecrawl = new FirecrawlApp({ apiKey: firecrawlApiKey }) + + this.openai = new OpenAI({ + baseURL: providerId === "openrouter" ? "https://openrouter.ai/api/v1" : undefined, + apiKey: providerApiKey, + }) + } + + /** + * Prompts + */ + + private researchSystemPrompt() { + const now = new Date().toISOString() + + return this.trimPrompt(` + You are an expert researcher. Today is ${now}. Follow these instructions when responding: + - You may be asked to research subjects that is after your knowledge cutoff, assume the user is right when presented with news. + - The user is a highly experienced analyst, no need to simplify it, be as detailed as possible and make sure your response is correct. + - Be highly organized. + - Suggest solutions that the user didn't think about. + - Be proactive and anticipate the user's needs. + - Treat the user as an expert in all subject matter. + - Mistakes erode the user's trust, so be accurate and thorough. + - Provide detailed explanations, the user is comfortable with lots of detail. + - Value good arguments over authorities, the source is irrelevant. + - Consider new technologies and contrarian ideas, not just the conventional wisdom. + - You may use high levels of speculation or prediction, just flag it for the user. + `) + } + + private chatSystemPrompt() { + const now = new Date().toISOString() + + return this.trimPrompt(` + You are an expert research assistant helping to explain and clarify research findings. Today is ${now}. Follow these guidelines: + + - You always answer with markdown formatting. You will be penalized if you do not answer with markdown when it would be possible. + - The markdown formatting you support: headings, bold, italic, links, tables, lists, code blocks, and blockquotes. + - You do not support images and never include images. You will be penalized if you render images. + - You also support Mermaid formatting. You will be penalized if you do not render Mermaid diagrams when it would be possible. + - The Mermaid diagrams you support: sequenceDiagram, flowChart, classDiagram, stateDiagram, erDiagram, gantt, journey, gitGraph, pie. + - Reference specific findings from the research when answering. + - Be precise and detailed in explanations. + - If asked about something outside the research scope, acknowledge this and stick to what was actually researched. + - Feel free to make connections between different parts of the research. + - When speculating or making inferences beyond the direct research, clearly label these as such. + - If asked about sources, refer to the URLs provided in the research. + - Maintain a professional, analytical tone. + - Never include images in responses. + `) + } + + /** + * LLM + */ + + public async generateFollowUps({ query, count = 3 }: { query: string; count?: number }) { + console.log(`[generateFollowUps] generating up to ${count} follow-up questions`) + + const prompt = this.trimPrompt(` + Given the following query from the user, ask some follow up questions to clarify the research direction. + Return a maximum of ${count} questions, but return less if the original query is clear. + Make sure each question is unique and not similar to each other. + Don't overly burden the user with questions; use your best judgement to determine only the most important questions. + ${query} + `) + + const schema = z.object({ + questions: z + .array(z.string()) + .describe(`Follow up questions to clarify the research direction, max of ${count}`), + }) + + try { + const completion = await this.withLoading( + this.openai.beta.chat.completions.parse({ + model: this.modelId, + messages: [ + { role: "system", content: this.researchSystemPrompt() }, + { role: "user", content: prompt }, + ], + response_format: zodResponseFormat(schema, "schema"), + }), + "Clarifying...", + ) + + const questions = completion.choices[0].message.parsed?.questions ?? [] + console.log(`[generateFollowUps] generated ${questions.length} follow-up questions`, questions) + return questions + } catch (error) { + await this.publishMessage({ + type: "research.error", + text: error instanceof Error ? error.message : "Unknown error.", + }) + + await this.abort() + return [] + } + } + + private async generateQueries({ + query, + breadth, + learnings, + }: { + query: string + breadth: number + learnings?: string[] + }): Promise { + console.log(`[generateQueries] generating up to ${breadth} queries`) + + const prompt = this.trimPrompt(` + Given the following prompt from the user, generate a list of SERP queries to research the topic. + Return a maximum of ${breadth} queries, but feel free to return less if the original prompt is clear. + Make sure each query is unique and not similar to each other: ${query} + + ${learnings ? `Here are some learnings from previous research, use them to generate more specific queries: ${learnings.join("\n")}` : ""} + `) + + const schema = z + .object({ + queries: z.array( + researchQuerySchema.extend({ + query: researchQuerySchema.shape.query.describe("The SERP query"), + researchGoal: researchQuerySchema.shape.query.describe( + "First talk about the goal of the research that this query is meant to accomplish, then go deeper into how to advance the research once the results are found, mention additional research directions. Be as specific as possible, especially for additional research directions.", + ), + }), + ), + }) + .describe(`List of SERP queries, max of ${breadth}`) + + try { + const completion = await this.openai.beta.chat.completions.parse({ + model: this.modelId, + messages: [ + { role: "system", content: this.researchSystemPrompt() }, + { role: "user", content: prompt }, + ], + response_format: zodResponseFormat(schema, "schema"), + }) + + await this.updateTokenUsage(completion.usage) + const queries = completion.choices[0].message.parsed?.queries ?? [] + console.log(`[generateQueries] generated ${queries.length} (out of ${breadth}) queries`, queries) + return queries + } catch (error) { + await this.publishMessage({ + type: "research.error", + text: error instanceof Error ? error.message : "Unknown error.", + }) + + await this.abort() + return [] + } + } + + private async generateLearnings({ + query, + result, + breadth, + learningsCount = 3, + }: { + query: string + result: SearchResponse + breadth: number + learningsCount?: number + }): Promise { + const contents = result.data + .map((item) => item.markdown) + .filter((content) => content !== undefined) + .map((content) => this.truncatePrompt(content, 25_000)) + + console.log(`[generateLearnings] extracting learnings from "${query}"`) + + const prompt = this.trimPrompt(` + Given the following contents from a SERP search for the query ${query}, generate a list of learnings from the contents. + Return a maximum of ${learningsCount} learnings, but feel free to return less if the contents are clear. + Make sure each learning is unique and not similar to each other. + The learnings should be concise and to the point, as detailed and information dense as possible. + Make sure to include any entities like people, places, companies, products, things, etc in the learnings, as well as any exact metrics, numbers, or dates. + The learnings will be used to research the topic further. + + ${contents.map((content) => `\n${content}\n`).join("\n")} + `) + + const schema = researchLearningsSchema.extend({ + learnings: researchLearningsSchema.shape.learnings.describe( + `List of learnings from the contents, max of ${learningsCount}`, + ), + followUpQuestions: researchLearningsSchema.shape.followUpQuestions.describe( + `List of follow-up questions to research the topic further, max of ${breadth}`, + ), + }) + + try { + const completion = await this.openai.beta.chat.completions.parse({ + model: this.modelId, + messages: [ + { role: "system", content: this.researchSystemPrompt() }, + { role: "user", content: prompt }, + ], + response_format: zodResponseFormat(schema, "schema"), + }) + + await this.updateTokenUsage(completion.usage) + const parsed = completion.choices[0].message.parsed + const learnings = parsed?.learnings ?? [] + const followUpQuestions = parsed?.followUpQuestions ?? [] + console.log(`[generateLearnings] extracted ${learnings.length} learnings`, learnings) + return { learnings, followUpQuestions } + } catch (error) { + await this.publishMessage({ + type: "research.error", + text: error instanceof Error ? error.message : "Unknown error.", + }) + + await this.abort() + return { learnings: [], followUpQuestions: [] } + } + } + + private async generateReport({ learnings, visitedUrls }: { learnings: string[]; visitedUrls: string[] }) { + const learningsString = this.truncatePrompt( + learnings.map((learning) => `\n${learning}\n`).join("\n"), + 150_000, + ) + + const prompt = this.trimPrompt(` + Given the following prompt from the user, write a final report on the topic using the learnings from research. + Make it as detailed as possible, aim for 3 or more pages, include ALL the learnings from research: + + ${this.inquiry!.query} + + Here are all the learnings from previous research: + + + ${learningsString} + + `) + + const schema = z.object({ + reportMarkdown: z.string().describe("Final report on the topic in Markdown"), + }) + + const completion = await this.openai.beta.chat.completions.parse({ + model: this.modelId, + messages: [ + { role: "system", content: this.researchSystemPrompt() }, + { role: "user", content: prompt }, + ], + response_format: zodResponseFormat(schema, "schema"), + }) + + await this.updateTokenUsage(completion.usage) + const parsed = completion.choices[0].message.parsed + const reportMarkdown = parsed?.reportMarkdown ?? "" + return reportMarkdown + `\n\n## Sources\n\n${visitedUrls.map((url) => `- ${url}`).join("\n")}` + } + + private async generateChatCompletion() { + try { + const completion = await this.openai.beta.chat.completions.stream({ + model: this.modelId, + messages: this.messages, + }) + + let buffer = "" + let usage: OpenAI.CompletionUsage | undefined + + for await (const chunk of completion) { + buffer += chunk.choices[0]?.delta?.content || "" + + if (chunk.usage) { + usage = chunk.usage + } + } + + await this.updateTokenUsage(usage) + return buffer + } catch (error) { + const text = error instanceof Error ? error.message : "Unknown error." + console.log(`[generateChatCompletion] error = ${text}`) + await this.publishMessage({ type: "research.error", text }) + return undefined + } + } + + /** + * Deep Research + */ + + private async runDeepResearch() { + this.status = "research" + + const query = this.trimPrompt(` + Initial Query: ${this.inquiry.initialQuery} + + Follow-up Questions and Answers: + ${this.inquiry.followUps.map((followUp, index) => `Q: ${followUp}\nA: ${this.inquiry.responses[index]}`).join("\n\n")} + `) + + this.inquiry.query = query + + const onProgressUpdated = () => { + const { expectedQueries, completedQueries } = this.progress + this.progress.progressPercentage = Math.round((completedQueries / expectedQueries) * 100) + this.publishMessage({ type: "research.progress", text: JSON.stringify(this.progress) }) + } + + const onGeneratedQueries = (queries: ResearchQuery[]) => + this.publishOutput({ + role: ResearchRole.Assistant, + content: `Generated ${queries.length} topics to research.\n\n${queries.map(({ query }) => `- ${query}`).join("\n")}`, + annotations: [ + { + type: ResearchAnnotationType.Badge, + data: { label: "Idea", variant: "outline" }, + }, + ], + }) + + const onExtractedLearnings = (learnings: ResearchLearnings & { urls: string[] }) => + this.publishOutput({ + role: ResearchRole.Assistant, + content: `Extracted ${learnings.learnings.length} learnings from ${learnings.urls.length} sources.\n\n${learnings.urls.map((url) => `- ${url}`).join("\n")}`, + annotations: [ + { + type: ResearchAnnotationType.Badge, + data: { label: "Learning", variant: "outline" }, + }, + ], + }) + + this.progress.expectedQueries = this.getTreeSize({ breadth: this.breadth, depth: this.depth }) + onProgressUpdated() + + console.log(`[transitionToResearch] query = ${query}`) + console.log(`[transitionToResearch] breadth = ${this.breadth}`) + console.log(`[transitionToResearch] depth = ${this.depth}`) + console.log(`[transitionToResearch] expectedQueries = ${this.progress.expectedQueries}`) + + const { learnings, visitedUrls } = await this.withLoading( + this.deepResearch({ + query, + breadth: this.breadth, + depth: this.depth, + learnings: [], + visitedUrls: [], + onProgressUpdated, + onGeneratedQueries, + onExtractedLearnings, + }), + "Researching...", + ) + + if (this.isAborted()) { + return + } + + this.inquiry.learnings = learnings + this.inquiry.urls = visitedUrls + + const report = await this.withLoading(this.generateReport({ learnings, visitedUrls }), "Summarizing...") + this.inquiry.report = report + + await this.viewReport() + + await this.publishOutput({ + role: ResearchRole.Assistant, + content: report, + annotations: [{ type: ResearchAnnotationType.Badge, data: { label: "Completed", variant: "default" } }], + }) + + this.messages.push({ + role: "system", + content: this.trimPrompt(` + ${this.chatSystemPrompt()} + + Here is the complete research context: + ${this.inquiry.query} + + Research Process: + - Depth: ${this.depth} + - Breadth: ${this.breadth} + + Intermediate Research Learnings: + ${this.inquiry.learnings?.map((learning) => `- ${learning}`).join("\n")} + + URLs Visited: + ${this.inquiry.urls?.map((url) => `- ${url}`).join("\n")} + + Final Research Report: + ${this.inquiry.report} + `), + }) + + this.status = "done" + + setTimeout(async () => { + const content = "I'm available to answer any questions you might have about the detailed report above." + this.messages.push({ role: "assistant", content }) + + await this.publishOutput({ + role: ResearchRole.Assistant, + content, + annotations: [{ type: ResearchAnnotationType.Badge, data: { label: "👋", variant: "outline" } }], + }) + }, 1000) + } + + private async deepResearch({ + query, + breadth, + depth, + learnings = [], + visitedUrls = [], + onProgressUpdated, + onGeneratedQueries, + onExtractedLearnings, + }: ResearchStep): Promise { + if (this.isAborted()) { + return { learnings, visitedUrls } + } + + const queries = await this.generateQueries({ query, learnings, breadth }) + onGeneratedQueries(queries) + + if (queries.length < breadth) { + const delta = breadth - queries.length + this.progress.expectedQueries = this.progress.expectedQueries - delta + console.log(`[deepResearch] expectedQueries reduced by ${delta} to ${this.progress.expectedQueries}`) + onProgressUpdated() + } + + const limit = pLimit(this.concurrency) + + const results = await Promise.all( + queries.map(({ query, researchGoal }) => + limit(async () => { + if (this.isAborted()) { + return { learnings, visitedUrls } + } + + let result: SearchResponse + + try { + result = await this.firecrawl.search(query, { + timeout: 15000, + limit: 5, + scrapeOptions: { formats: ["markdown"] }, + }) + } catch (e) { + const text = e instanceof Error ? e.message : "Unknown error" + console.log(`[deepResearch] error = ${text}`) + + await this.publishMessage({ + type: "research.error", + text: `Encountered an error while crawling "${query}": ${text}`, + }) + + return { learnings, visitedUrls } + } + + const newUrls = result.data.map(({ url }) => url).filter((url): url is string => url !== undefined) + + const newBreadth = Math.ceil(breadth / 2) + const newDepth = depth - 1 + let newLearnings: ResearchLearnings + + try { + newLearnings = await this.generateLearnings({ query, result, breadth: newBreadth }) + } catch (e) { + const text = e instanceof Error ? e.message : "Unknown error" + console.log(`[deepResearch] error = ${text}`) + + await this.publishMessage({ + type: "research.error", + text: `Encountered an error while extracting learnings from "${query}": ${text}`, + }) + + return { learnings, visitedUrls } + } + + const allLearnings = [...learnings, ...newLearnings.learnings] + const allUrls = [...visitedUrls, ...newUrls] + onExtractedLearnings({ ...newLearnings, urls: newUrls }) + + this.progress.completedQueries = this.progress.completedQueries + 1 + onProgressUpdated() + + if (newDepth <= 0) { + return { learnings: allLearnings, visitedUrls: allUrls } + } + + console.log(`[deepResearch] researching deeper, breadth: ${newBreadth}, depth: ${newDepth}`) + + const nextQuery = this.trimPrompt(` + Previous research goal: ${researchGoal} + Follow-up research directions: ${newLearnings.followUpQuestions.map((q) => `\n${q}`).join("")} + `) + + return this.deepResearch({ + query: nextQuery, + breadth: newBreadth, + depth: newDepth, + learnings: allLearnings, + visitedUrls: allUrls, + onProgressUpdated, + onGeneratedQueries, + onExtractedLearnings, + }) + }), + ), + ) + + return { + learnings: [...new Set(results.flatMap((r) => r.learnings))], + visitedUrls: [...new Set(results.flatMap((r) => r.visitedUrls))], + } + } + + /** + * Events + * + * idle -> feedback -> research -> done -> aborted + */ + + public async input(content: string) { + if (this.isAborted()) { + this.publishMessage({ type: "research.error", text: "Deep research task has ended." }) + return + } + + this.output.push({ role: "user", content }) + + const stateHandlers = { + idle: () => this.handleIdle(content), + followUp: () => this.handleFollowUp(content), + research: () => console.log("NOOP", content), + done: () => this.handleDone(content), + aborted: () => console.log("NOOP", content), + } as const + + console.log(`[DeepResearchService#append] executing ${this.status} handler with content = "${content}"`) + await stateHandlers[this.status]() + } + + private async handleIdle(query: string) { + this.status = "followUp" + this.inquiry = { initialQuery: query, followUps: [], responses: [] } + this.inquiry.followUps = await this.generateFollowUps({ query }) + await this.handleFollowUp(null) + } + + private async handleFollowUp(content: string | null) { + if (content) { + this.inquiry.responses.push(content) + } + + this.inquiry.responses.length >= this.inquiry.followUps.length + ? await this.runDeepResearch() + : await this.publishOutput({ + role: ResearchRole.Assistant, + content: this.inquiry.followUps[this.inquiry.responses.length], + annotations: [ + { type: ResearchAnnotationType.Badge, data: { label: "Follow Up", variant: "outline" } }, + ], + }) + } + + private async handleDone(content: string) { + this.messages.push({ role: "user", content }) + const response = await this.withLoading(this.generateChatCompletion()) + + if (response) { + await this.publishOutput({ role: ResearchRole.Assistant, content: response }) + } + } + + /** + * Statuses + */ + + public isAborted() { + return this.status === "aborted" + } + + private get status() { + return this._status + } + + private set status(value: DeepResearchServiceStatus) { + if (this.isAborted()) { + return + } + + console.log(`[setStatus] ${this.status} -> ${value}`) + this.publishMessage({ type: "research.status", text: JSON.stringify({ status: value }) }) + this._status = value + } + + /** + * Actions + */ + + public async abort() { + this.status = "aborted" + await this.saveResearchTask() + } + + public async viewReport() { + const document = await this.upsertReport() + await vscode.window.showTextDocument(document, { preview: false }) + } + + public async createTask() { + const provider = this.providerRef.deref() + + if (!provider) { + return + } + + const document = await this.upsertReport() + + if (provider) { + await provider.postStateToWebview() + await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + + const mentionPath = vscode.workspace.workspaceFolders?.[0] + ? `/${vscode.workspace.asRelativePath(document.uri)}` + : document.uri.fsPath + + await provider.postMessageToWebview({ + type: "invoke", + invoke: "setChatBoxMessage", + text: `@${mentionPath}`, + }) + } + } + + public static async getTasks(globalStoragePath: string) { + try { + const rootDir = path.join(globalStoragePath, "research") + const taskDirs = await fs.readdir(rootDir) + + const tasks = await Promise.all( + taskDirs.map(async (taskDir) => { + try { + const contents = await fs.readFile(path.join(rootDir, taskDir, "task.json"), "utf-8") + const result = researchTaskSchema.safeParse(JSON.parse(contents)) + return result.success ? result.data : null + } catch (e) { + console.error(`[getTasks] failed to load ${taskDir}:`, e) + return null + } + }), + ) + + return tasks.filter((task): task is ResearchTask => task !== null) + } catch (e) { + console.error("[getTasks] unexpected error", e) + return [] + } + } + + public static async getTask(globalStoragePath: string, taskId: string) { + try { + const filepath = path.join(globalStoragePath, "research", taskId, "task.json") + const contents = await fs.readFile(filepath, "utf-8") + const result = researchTaskSchema.safeParse(JSON.parse(contents)) + return result.success ? result.data : undefined + } catch (e) { + console.error("[getTask] unexpected error", e) + return [] + } + } + + public static async deleteTask(globalStoragePath: string, taskId: string) { + const filepath = path.join(globalStoragePath, "research", taskId, "task.json") + await fs.unlink(filepath) + await fs.rmdir(path.join(globalStoragePath, "research", taskId), { recursive: true }) + } + + /** + * Helpers + */ + + private async withLoading(promise: Promise, message?: string): Promise { + await this.publishMessage({ type: "research.loading", text: JSON.stringify({ message, isLoading: true }) }) + + try { + return await promise + } finally { + await this.publishMessage({ type: "research.loading", text: JSON.stringify({ message, isLoading: false }) }) + } + } + + private async publishOutput(output: ResearchOutput) { + const isPublished = await this.publishMessage({ type: "research.output", text: JSON.stringify(output) }) + + if (isPublished) { + this.output.push(output) + await this.saveResearchTask() + } + } + + private async publishMessage(message: ExtensionMessage) { + if (this.isAborted() && message.type === "research.output") { + return false + } + + await this.providerRef.deref()?.postMessageToWebview(message) + return true + } + + private async upsertReport() { + let document: vscode.TextDocument | undefined = undefined + + if (this.inquiry.fileUri) { + try { + return await vscode.workspace.openTextDocument(this.inquiry.fileUri) + } catch (error) { + console.log(`[saveReport] unable to open ${this.inquiry.fileUri}`) + } + } + + const fileName = `Deep-Research-${Date.now()}.md` + const workspaceFolders = vscode.workspace.workspaceFolders + const folderUri = workspaceFolders?.[0]?.uri || vscode.Uri.file(path.join(os.tmpdir(), fileName)) + const fileUri = vscode.Uri.joinPath(folderUri, fileName) + + console.log(`[upsertReport] saving to ${fileUri.fsPath}`) + + try { + await vscode.workspace.fs.writeFile(fileUri, Buffer.from(this.inquiry.report ?? "")) + document = await vscode.workspace.openTextDocument(fileUri) + this.inquiry.fileUri = fileUri.fsPath + } catch (error) { + console.log(`[upsertReport] unable to save to ${fileUri.fsPath}, falling back to buffer`) + document = await vscode.workspace.openTextDocument({ content: this.inquiry.report, language: "markdown" }) + } + + return document + } + + private async updateTokenUsage(usage: OpenAI.CompletionUsage | null | undefined) { + if (!usage) { + return + } + + this.tokenUsage = { + inTokens: this.tokenUsage.inTokens + (usage.prompt_tokens ?? 0), + outTokens: this.tokenUsage.outTokens + (usage.completion_tokens ?? 0), + totalTokens: this.tokenUsage.totalTokens + (usage.total_tokens ?? 0), + } + + await this.publishMessage({ type: "research.tokenUsage", text: JSON.stringify(this.tokenUsage) }) + } + + private trimPrompt(prompt: string) { + return prompt + .split("\n") + .map((line) => line.trim()) + .join("\n") + } + + private truncatePrompt(prompt: string, contextSize = 128_000, minChunkSize = 140): string { + if (!prompt) { + return "" + } + + const length = encoder.encode(prompt).length + + if (length <= contextSize) { + return prompt + } + + const overflowTokens = length - contextSize + + // On average it's 3 characters per token, so multiply by 3 to get a rough + // estimate of the number of characters. + const chunkSize = prompt.length - overflowTokens * 3 + + if (chunkSize < minChunkSize) { + return prompt.slice(0, minChunkSize) + } + + const splitter = new RecursiveCharacterTextSplitter({ + chunkSize, + chunkOverlap: 0, + }) + + const truncated = splitter.splitText(prompt)[0] ?? "" + + // Last catch, there's a chance that the trimmed prompt is same length as + // the original prompt, due to how tokens are split & innerworkings of the + // splitter, handle this case by just doing a hard cut. + if (truncated.length === prompt.length) { + return this.truncatePrompt(prompt.slice(0, chunkSize), contextSize, minChunkSize) + } + + // Recursively trim until the prompt is within the context size. + return this.truncatePrompt(truncated, contextSize, minChunkSize) + } + + // Calculate total expected queries across all depth levels. + // At each level, the breadth is halved, so level 1 has full breadth, + // level 2 has breadth/2, level 3 has breadth/4, etc. + // For breadth = 4, depth = 2, the expected queries are: + // D2: 2^2 * 1 = 4 + // D1: 2^1 * 2 = 4 + // D0: 2^0 * 4 = 4 + // Total: 12 + private getTreeSize = ({ breadth, depth }: { breadth: number; depth: number }) => { + let value = 0 + + for (let i = depth; i >= 0; i--) { + value = value + Math.pow(2, i) * Math.ceil(breadth / Math.pow(2, i)) + } + + return value + } + + private async ensureResearchTasksDirectoryExists() { + const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath + + if (!globalStoragePath) { + throw new Error("Global storage path is invalid.") + } + + const dir = path.join(globalStoragePath, "research", this.taskId) + await fs.mkdir(dir, { recursive: true }) + return dir + } + + private async saveResearchTask() { + const task: ResearchTask = { + taskId: this.taskId, + providerId: this.providerId, + modelId: this.modelId, + breadth: this.breadth, + depth: this.depth, + concurrency: this.concurrency, + inquiry: this.inquiry, + output: this.output, + messages: this.messages, + createdAt: Date.now(), + } + + const dir = await this.ensureResearchTasksDirectoryExists() + await fs.writeFile(path.join(dir, "task.json"), JSON.stringify(task)) + } +} diff --git a/src/services/deep-research/TextSplitter.ts b/src/services/deep-research/TextSplitter.ts new file mode 100644 index 00000000000..4ce6b6f22c0 --- /dev/null +++ b/src/services/deep-research/TextSplitter.ts @@ -0,0 +1,139 @@ +import { getEncoding } from "js-tiktoken" + +interface TextSplitterParams { + chunkSize: number + chunkOverlap: number +} + +abstract class TextSplitter implements TextSplitterParams { + chunkSize = 1000 + chunkOverlap = 200 + + constructor(fields?: Partial) { + this.chunkSize = fields?.chunkSize ?? this.chunkSize + this.chunkOverlap = fields?.chunkOverlap ?? this.chunkOverlap + if (this.chunkOverlap >= this.chunkSize) { + throw new Error("Cannot have chunkOverlap >= chunkSize") + } + } + + abstract splitText(text: string): string[] + + createDocuments(texts: string[]): string[] { + const documents: string[] = [] + for (let i = 0; i < texts.length; i += 1) { + const text = texts[i] + for (const chunk of this.splitText(text!)) { + documents.push(chunk) + } + } + return documents + } + + splitDocuments(documents: string[]): string[] { + return this.createDocuments(documents) + } + + private joinDocs(docs: string[], separator: string): string | null { + const text = docs.join(separator).trim() + return text === "" ? null : text + } + + mergeSplits(splits: string[], separator: string): string[] { + const docs: string[] = [] + const currentDoc: string[] = [] + let total = 0 + for (const d of splits) { + const _len = d.length + if (total + _len >= this.chunkSize) { + if (total > this.chunkSize) { + console.warn( + `Created a chunk of size ${total}, which is longer than the specified ${this.chunkSize}`, + ) + } + if (currentDoc.length > 0) { + const doc = this.joinDocs(currentDoc, separator) + if (doc !== null) { + docs.push(doc) + } + // Keep on popping if: + // - we have a larger chunk than in the chunk overlap + // - or if we still have any chunks and the length is long + while (total > this.chunkOverlap || (total + _len > this.chunkSize && total > 0)) { + total -= currentDoc[0]!.length + currentDoc.shift() + } + } + } + currentDoc.push(d) + total += _len + } + const doc = this.joinDocs(currentDoc, separator) + if (doc !== null) { + docs.push(doc) + } + return docs + } +} + +export interface RecursiveCharacterTextSplitterParams extends TextSplitterParams { + separators: string[] +} + +export class RecursiveCharacterTextSplitter extends TextSplitter implements RecursiveCharacterTextSplitterParams { + separators: string[] = ["\n\n", "\n", ".", ",", ">", "<", " ", ""] + + constructor(fields?: Partial) { + super(fields) + this.separators = fields?.separators ?? this.separators + } + + splitText(text: string): string[] { + const finalChunks: string[] = [] + + // Get appropriate separator to use. + let separator: string = this.separators[this.separators.length - 1]! + + for (const s of this.separators) { + if (s === "") { + separator = s + break + } + + if (text.includes(s)) { + separator = s + break + } + } + + // Now that we have the separator, split the text + const splits = separator ? text.split(separator) : text.split("") + + // Now go merging things, recursively splitting longer texts. + let goodSplits: string[] = [] + + for (const s of splits) { + if (s.length < this.chunkSize) { + goodSplits.push(s) + } else { + if (goodSplits.length) { + const mergedText = this.mergeSplits(goodSplits, separator) + finalChunks.push(...mergedText) + goodSplits = [] + } + + const otherInfo = this.splitText(s) + finalChunks.push(...otherInfo) + } + } + + if (goodSplits.length) { + const mergedText = this.mergeSplits(goodSplits, separator) + finalChunks.push(...mergedText) + } + + return finalChunks + } +} + +export const encoder = getEncoding("o200k_base") diff --git a/src/services/deep-research/__tests__/TextSplitter.test.ts b/src/services/deep-research/__tests__/TextSplitter.test.ts new file mode 100644 index 00000000000..66f8ea3f3b6 --- /dev/null +++ b/src/services/deep-research/__tests__/TextSplitter.test.ts @@ -0,0 +1,59 @@ +// npx jest src/services/deep-research/__tests__/text-splitter.test.ts + +import { RecursiveCharacterTextSplitter } from "../TextSplitter" + +describe("RecursiveCharacterTextSplitter", () => { + let splitter: RecursiveCharacterTextSplitter + + beforeEach(() => { + splitter = new RecursiveCharacterTextSplitter({ + chunkSize: 50, + chunkOverlap: 10, + }) + }) + + it("Should correctly split text by separators", () => { + const text = "Hello world, this is a test of the recursive text splitter." + + // Test with initial chunkSize + expect(splitter.splitText(text)).toEqual(["Hello world", "this is a test of the recursive text splitter"]) + + // Test with updated chunkSize + splitter.chunkSize = 100 + expect( + splitter.splitText( + "Hello world, this is a test of the recursive text splitter. If I have a period, it should split along the period.", + ), + ).toEqual([ + "Hello world, this is a test of the recursive text splitter", + "If I have a period, it should split along the period.", + ]) + + // Test with another updated chunkSize + splitter.chunkSize = 110 + expect( + splitter.splitText( + "Hello world, this is a test of the recursive text splitter. If I have a period, it should split along the period.\nOr, if there is a new line, it should prioritize splitting on new lines instead.", + ), + ).toEqual([ + "Hello world, this is a test of the recursive text splitter", + "If I have a period, it should split along the period.", + "Or, if there is a new line, it should prioritize splitting on new lines instead.", + ]) + }) + + it("Should handle empty string", () => { + expect(splitter.splitText("")).toEqual([]) + }) + + it.skip("Should handle large text", () => { + const largeText = "A".repeat(1000) + splitter.chunkSize = 200 + expect(splitter.splitText(largeText)).toEqual(Array(5).fill("A".repeat(200))) + }) + + it.skip("Should handle special characters", () => { + const specialCharText = "Hello!@# world$%^ &*( this) is+ a-test" + expect(splitter.splitText(specialCharText)).toEqual(["Hello!@#", "world$%^", "&*( this)", "is+", "a-test"]) + }) +}) diff --git a/src/services/deep-research/types.ts b/src/services/deep-research/types.ts new file mode 100644 index 00000000000..414f5c97066 --- /dev/null +++ b/src/services/deep-research/types.ts @@ -0,0 +1,134 @@ +import { z } from "zod" + +/** + * ResearchInquiry + */ + +const researchInquirySchema = z.object({ + initialQuery: z.string().optional(), + followUps: z.array(z.string()), + responses: z.array(z.string()), + query: z.string().optional(), + learnings: z.array(z.string()).optional(), + urls: z.array(z.string()).optional(), + report: z.string().optional(), + fileUri: z.string().optional(), +}) + +export type ResearchInquiry = z.infer + +/** + * ResearchStep + */ + +export type ResearchStep = { + query: string + breadth: number + depth: number + learnings?: string[] + visitedUrls?: string[] + onProgressUpdated: () => void + onGeneratedQueries: (queries: ResearchQuery[]) => void + onExtractedLearnings: (learnings: ResearchLearnings & { urls: string[] }) => void +} + +/** + * ResearchProgress + */ + +export type ResearchProgress = { + expectedQueries: number + completedQueries: number + progressPercentage: number +} + +/** + * ResearchResult + */ + +export type ResearchResult = { + learnings: string[] + visitedUrls: string[] +} + +/** + * ResearchQuery + */ + +export const researchQuerySchema = z.object({ + query: z.string(), + researchGoal: z.string(), +}) + +export type ResearchQuery = z.infer + +/** + * ResearchLearnings + */ + +export const researchLearningsSchema = z.object({ + learnings: z.array(z.string()), + followUpQuestions: z.array(z.string()), +}) + +export type ResearchLearnings = z.infer + +/** + * ResearchTokenUsage + */ + +export type ResearchTokenUsage = { + inTokens: number + outTokens: number + totalTokens: number +} + +/** + * ResearchOutput + */ + +export enum ResearchRole { + User = "user", + Assistant = "assistant", +} + +export enum ResearchAnnotationType { + Badge = "badge", +} + +const researchAnnotationSchema = z.object({ + type: z.nativeEnum(ResearchAnnotationType), + data: z.object({ + label: z.string(), + variant: z.enum(["default", "secondary", "destructive", "outline"]).optional(), + }), +}) + +export type ResearchAnnotation = z.infer + +const researchOutputSchema = z.object({ + role: z.enum(["user", "assistant"]), + content: z.string(), + annotations: z.array(researchAnnotationSchema).optional(), +}) + +export type ResearchOutput = z.infer + +/** + * ResearchTask + */ + +export const researchTaskSchema = z.object({ + taskId: z.string(), + providerId: z.string(), + modelId: z.string(), + breadth: z.number(), + depth: z.number(), + concurrency: z.number(), + inquiry: researchInquirySchema, + output: z.array(researchOutputSchema), + messages: z.array(z.any()), + createdAt: z.number(), +}) + +export type ResearchTask = z.infer diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 5d0e16e39cd..7408930e71b 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -46,9 +46,18 @@ export interface ExtensionMessage { | "unboundModels" | "refreshUnboundModels" | "currentCheckpointUpdated" + | "research.loading" + | "research.output" + | "research.progress" + | "research.status" + | "research.tokenUsage" + | "research.error" + | "research.history" + | "research.task" text?: string action?: | "chatButtonClicked" + | "researchButtonClicked" | "mcpButtonClicked" | "settingsButtonClicked" | "historyButtonClicked" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 106e6d243b9..157ad198327 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -95,6 +95,15 @@ export interface WebviewMessage { | "checkpointRestore" | "deleteMcpServer" | "maxOpenTabsContext" + | "research.task" + | "research.input" + | "research.viewReport" + | "research.createTask" + | "research.getTasks" + | "research.getTask" + | "research.deleteTask" + | "research.abort" + | "research.reset" text?: string disabled?: boolean askResponse?: ClineAskResponse @@ -120,6 +129,10 @@ export interface WebviewMessage { source?: "global" | "project" } +/** + * Checkpoints + */ + export const checkoutDiffPayloadSchema = z.object({ ts: z.number(), commitHash: z.string(), @@ -136,4 +149,42 @@ export const checkoutRestorePayloadSchema = z.object({ export type CheckpointRestorePayload = z.infer -export type WebViewMessagePayload = CheckpointDiffPayload | CheckpointRestorePayload +/** + * Deep Research + */ + +export const researchTaskPayloadSchema = z.object({ + session: z.object({ + providerId: z.string(), + providerApiKey: z.string(), + firecrawlApiKey: z.string(), + modelId: z.string(), + breadth: z.number(), + depth: z.number(), + query: z.string(), + concurrency: z.number(), + }), +}) + +export type ResearchTaskPayload = z.infer + +export const researchInputPayloadSchema = z.object({ + message: z.object({ + role: z.enum(["system", "user", "assistant", "data"]), + content: z.string(), + annotations: z.any().optional(), + }), + chatRequestOptions: z.object({ data: z.any() }).optional(), +}) + +export type ResearchInputPayload = z.infer + +/** + * Payload Union + */ + +export type WebViewMessagePayload = + | CheckpointDiffPayload + | CheckpointRestorePayload + | ResearchTaskPayload + | ResearchInputPayload diff --git a/src/shared/api.ts b/src/shared/api.ts index 9ecb12c1403..41272f501f9 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -67,6 +67,7 @@ export interface ApiHandlerOptions { requestyModelId?: string requestyModelInfo?: ModelInfo modelTemperature?: number + firecrawlApiKey?: string } export type ApiConfiguration = ApiHandlerOptions & { diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index 969087ff02d..c9213bb0a61 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -15,6 +15,8 @@ suite("Roo Code Extension", () => { const expectedCommands = [ "roo-cline.plusButtonClicked", + "roo-cline.researchButtonClicked", + "roo-cline.promptsButtonClicked", "roo-cline.mcpButtonClicked", "roo-cline.historyButtonClicked", "roo-cline.popoutButtonClicked", diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index 1d64f934dc2..55f8324ba9e 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -42,7 +42,8 @@ "tailwind-merge": "^2.6.0", "tailwindcss": "^4.0.0", "tailwindcss-animate": "^1.0.7", - "vscrui": "^0.2.2" + "vscrui": "^0.2.2", + "zustand": "^5.0.3" }, "devDependencies": { "@storybook/addon-essentials": "^8.5.6", @@ -20775,6 +20776,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zustand": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz", + "integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/webview-ui/package.json b/webview-ui/package.json index 2206fb35c94..0a384dbbb72 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -49,7 +49,8 @@ "tailwind-merge": "^2.6.0", "tailwindcss": "^4.0.0", "tailwindcss-animate": "^1.0.7", - "vscrui": "^0.2.2" + "vscrui": "^0.2.2", + "zustand": "^5.0.3" }, "devDependencies": { "@storybook/addon-essentials": "^8.5.6", diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 3ae441cd52f..7b20d77f5a3 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -11,11 +11,13 @@ import SettingsView, { SettingsViewRef } from "./components/settings/SettingsVie import WelcomeView from "./components/welcome/WelcomeView" import McpView from "./components/mcp/McpView" import PromptsView from "./components/prompts/PromptsView" +import { DeepResearch } from "./features/deep-research/DeepResearch" -type Tab = "settings" | "history" | "mcp" | "prompts" | "chat" +type Tab = "settings" | "history" | "mcp" | "prompts" | "chat" | "research" const tabsByMessageAction: Partial, Tab>> = { chatButtonClicked: "chat", + researchButtonClicked: "research", settingsButtonClicked: "settings", promptsButtonClicked: "prompts", mcpButtonClicked: "mcp", @@ -74,6 +76,7 @@ const App = () => { {tab === "history" && switchTab("chat")} />} {tab === "mcp" && switchTab("chat")} />} {tab === "prompts" && switchTab("chat")} />} + setTab("chat")} /> ({ ) }, })) - jest.mock("../components/prompts/PromptsView", () => ({ __esModule: true, default: function PromptsView({ onDone }: { onDone: () => void }) { @@ -67,6 +66,17 @@ jest.mock("../components/prompts/PromptsView", () => ({ }, })) +jest.mock("../features/deep-research/DeepResearch", () => ({ + __esModule: true, + DeepResearch: function DeepResearch({ isHidden, onDone }: { isHidden: boolean; onDone: () => void }) { + return ( +
+ Research View +
+ ) + }, +})) + jest.mock("../context/ExtensionStateContext", () => ({ useExtensionState: () => ({ didHydrateState: true, diff --git a/webview-ui/src/components/ui/button.tsx b/webview-ui/src/components/ui/button.tsx index 9f60dfedeec..41bbebcd0d1 100644 --- a/webview-ui/src/components/ui/button.tsx +++ b/webview-ui/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xs text-base font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer active:opacity-80", { variants: { variant: { diff --git a/webview-ui/src/components/ui/chat/ChatInput.tsx b/webview-ui/src/components/ui/chat/ChatInput.tsx index 8bec35b8426..782a642df67 100644 --- a/webview-ui/src/components/ui/chat/ChatInput.tsx +++ b/webview-ui/src/components/ui/chat/ChatInput.tsx @@ -2,6 +2,7 @@ import { PaperPlaneIcon, StopIcon } from "@radix-ui/react-icons" import { Button, AutosizeTextarea } from "@/components/ui" +import { MessageRole } from "./types" import { ChatInputProvider } from "./ChatInputProvider" import { useChatUI } from "./useChatUI" import { useChatInput } from "./useChatInput" @@ -16,7 +17,7 @@ export function ChatInput() { } setInput("") - await append({ role: "user", content: input }) + await append({ role: MessageRole.User, content: input }) } const handleSubmit = async (e: React.FormEvent) => { @@ -60,7 +61,7 @@ interface ChatInputFieldProps { } function ChatInputField({ placeholder = "Chat" }: ChatInputFieldProps) { - const { input, setInput } = useChatUI() + const { isDisabled, input, setInput } = useChatUI() const { handleKeyDown } = useChatInput() return ( @@ -72,6 +73,7 @@ function ChatInputField({ placeholder = "Chat" }: ChatInputFieldProps) { value={input} onChange={({ target: { value } }) => setInput(value)} onKeyDown={handleKeyDown} + disabled={!!isDisabled} className="resize-none px-3 pt-3 pb-[50px]" /> ) diff --git a/webview-ui/src/components/ui/chat/ChatMessage.tsx b/webview-ui/src/components/ui/chat/ChatMessage.tsx index 2a13c9f6027..b185c5144f9 100644 --- a/webview-ui/src/components/ui/chat/ChatMessage.tsx +++ b/webview-ui/src/components/ui/chat/ChatMessage.tsx @@ -1,13 +1,13 @@ import { useMemo } from "react" import { CopyIcon, CheckIcon } from "@radix-ui/react-icons" -import { BrainCircuit, CircleUserRound } from "lucide-react" +import { BrainCircuit, CircleUserRound, Loader2 } from "lucide-react" import { cn } from "@/lib/utils" import { useClipboard } from "@/components/ui/hooks" import { Badge } from "@/components/ui" import { Markdown } from "@/components/ui/markdown" -import { BadgeData, ChatHandler, Message, MessageAnnotationType } from "./types" +import { Message, MessageAnnotationType, MessageAnnotation } from "./types" import { ChatMessageProvider } from "./ChatMessageProvider" import { useChatUI } from "./useChatUI" import { useChatMessage } from "./useChatMessage" @@ -16,16 +16,11 @@ interface ChatMessageProps { message: Message isLast: boolean isHeaderVisible: boolean - isLoading?: boolean - append?: ChatHandler["append"] } -export function ChatMessage({ message, isLast, isHeaderVisible, isLoading, append }: ChatMessageProps) { +export function ChatMessage({ message, isLast, isHeaderVisible }: ChatMessageProps) { const badges = useMemo( - () => - message.annotations - ?.filter(({ type }) => type === MessageAnnotationType.BADGE) - .map(({ data }) => data as BadgeData), + () => message.annotations?.filter(({ type }) => type === MessageAnnotationType.Badge).map(({ data }) => data), [message.annotations], ) @@ -44,7 +39,7 @@ export function ChatMessage({ message, isLast, isHeaderVisible, isLoading, appen } interface ChatMessageHeaderProps { - badges?: BadgeData[] + badges?: MessageAnnotation["data"][] } function ChatMessageHeader({ badges }: ChatMessageHeaderProps) { @@ -103,3 +98,12 @@ function ChatMessageActions() { ) } + +export function ChatMessageLoading({ message = "Loading..." }: { message?: string }) { + return ( +
+ + {message &&
{message}
} +
+ ) +} diff --git a/webview-ui/src/components/ui/chat/ChatMessages.tsx b/webview-ui/src/components/ui/chat/ChatMessages.tsx index bcaffd23979..a3ecfbf8aa2 100644 --- a/webview-ui/src/components/ui/chat/ChatMessages.tsx +++ b/webview-ui/src/components/ui/chat/ChatMessages.tsx @@ -1,41 +1,43 @@ -import { useEffect, useRef } from "react" +import { useCallback, useEffect, useMemo, useRef } from "react" import { Virtuoso, VirtuosoHandle } from "react-virtuoso" import { useChatUI } from "./useChatUI" -import { ChatMessage } from "./ChatMessage" +import { ChatMessage, ChatMessageLoading } from "./ChatMessage" export function ChatMessages() { - const { messages, isLoading, append } = useChatUI() - const messageCount = messages.length + const { messages, isLoading, loadingMessage } = useChatUI() const virtuoso = useRef(null) + const totalCount = useMemo(() => messages.length + (isLoading ? 1 : 0), [messages, isLoading]) + useEffect(() => { if (!virtuoso.current) { return } requestAnimationFrame(() => - virtuoso.current?.scrollToIndex({ index: messageCount - 1, align: "end", behavior: "smooth" }), + virtuoso.current?.scrollToIndex({ index: totalCount - 1, align: "end", behavior: "smooth" }), ) - }, [messageCount]) - - return ( - ( - - )} - /> + }, [totalCount]) + + const itemContent = useCallback( + (index: number) => { + const isFirst = index === 0 + const isLast = index === totalCount - 1 + + if (isLoading && isLast) { + return + } + + const message = messages[index] + + const isHeaderVisible = + !!message.annotations?.length || isFirst || messages[index - 1].role !== message.role + + return + }, + [messages, isLoading, loadingMessage, totalCount], ) + + return } diff --git a/webview-ui/src/components/ui/chat/types.ts b/webview-ui/src/components/ui/chat/types.ts index 889645ccf9a..bcc97ab37a6 100644 --- a/webview-ui/src/components/ui/chat/types.ts +++ b/webview-ui/src/components/ui/chat/types.ts @@ -1,12 +1,11 @@ -export interface Message { - role: "system" | "user" | "assistant" | "data" - content: string - annotations?: MessageAnnotation[] -} +import { z } from "zod" export type ChatHandler = { + isDisabled?: boolean + setIsDisabled?: (isDisabled: boolean) => void + isLoading: boolean - setIsLoading: (isLoading: boolean, message?: string) => void + setIsLoading?: (isLoading: boolean) => void loadingMessage?: string setLoadingMessage?: (message: string) => void @@ -15,25 +14,45 @@ export type ChatHandler = { setInput: (input: string) => void messages: Message[] + append: (message: Message, options?: { data?: any }) => Promise reload?: (options?: { data?: any }) => void stop?: () => void - append: (message: Message, options?: { data?: any }) => Promise reset?: () => void } +/** + * Message Annotation + */ + export enum MessageAnnotationType { - BADGE = "badge", + Badge = "badge", } -export type BadgeData = { - label: string - variant?: "default" | "secondary" | "destructive" | "outline" -} +export const messageAnnotationSchema = z.object({ + type: z.nativeEnum(MessageAnnotationType), + data: z.object({ + label: z.string(), + variant: z.enum(["default", "secondary", "destructive", "outline"]).optional(), + }), +}) + +export type MessageAnnotation = z.infer -export type AnnotationData = BadgeData +/** + * Message + */ -export type MessageAnnotation = { - type: MessageAnnotationType - data: AnnotationData +export enum MessageRole { + System = "system", + User = "user", + Assistant = "assistant", } + +export const messageSchema = z.object({ + role: z.nativeEnum(MessageRole), + content: z.string(), + annotations: z.array(messageAnnotationSchema).optional(), +}) + +export type Message = z.infer diff --git a/webview-ui/src/components/ui/index.ts b/webview-ui/src/components/ui/index.ts index bf00aa64425..6eb8dd25ba9 100644 --- a/webview-ui/src/components/ui/index.ts +++ b/webview-ui/src/components/ui/index.ts @@ -1,3 +1,4 @@ +export * from "./alert-dialog" export * from "./autosize-textarea" export * from "./badge" export * from "./button" diff --git a/webview-ui/src/features/deep-research/DeepResearch.tsx b/webview-ui/src/features/deep-research/DeepResearch.tsx new file mode 100644 index 00000000000..1ce10b3249b --- /dev/null +++ b/webview-ui/src/features/deep-research/DeepResearch.tsx @@ -0,0 +1,50 @@ +import { cn } from "@/lib/utils" + +import { useResearchSession } from "./useResearchSession" +import { DeepResearchProvider } from "./DeepResearchProvider" +import { HistoryProvider } from "./HistoryProvider" +import { ResearchSession } from "./ResearchSession" +import { ResearchTask } from "./ResearchTask" +import { GetStarted } from "./GetStarted" +import { History } from "./History" + +type DeepResearchProps = { + isHidden: boolean + onDone: () => void +} + +export const DeepResearch = ({ isHidden }: DeepResearchProps) => { + const { session, task } = useResearchSession() + + if (session) { + return ( +
+ + + +
+ ) + } + + if (task) { + return ( +
+ +
+ ) + } + + return ( +
+
+ + + + +
+
+ ) +} diff --git a/webview-ui/src/features/deep-research/DeepResearchProvider.tsx b/webview-ui/src/features/deep-research/DeepResearchProvider.tsx new file mode 100644 index 00000000000..4d51a3c7ea7 --- /dev/null +++ b/webview-ui/src/features/deep-research/DeepResearchProvider.tsx @@ -0,0 +1,181 @@ +import { createContext, useCallback, useState, ReactNode } from "react" +import { useEvent } from "react-use" + +import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" + +import { vscode } from "@/utils/vscode" +import { ChatHandler, MessageRole, MessageAnnotationType, Message, messageSchema } from "@/components/ui/chat" + +import { + ResearchSession, + loadingSchema, + ResearchStatus, + researchStatusSchema, + ResearchProgress, + researchProgressSchema, + ResearchTokenUsage, + researchTokenUsageSchema, +} from "./types" + +type DeepResearchContextType = ChatHandler & { + status: ResearchStatus["status"] | undefined + progress: ResearchProgress | undefined + tokenUsage: ResearchTokenUsage | undefined + start: (session: ResearchSession) => void + viewReport: () => void + createTask: () => void +} + +export const DeepResearchContext = createContext(undefined) + +export function DeepResearchProvider({ children }: { children: ReactNode }) { + const [isLoading, setIsLoading] = useState(false) + const [loadingMessage, setLoadingMessage] = useState(undefined) + const [input, setInput] = useState("") + const [messages, setMessages] = useState([]) + const [progress, setProgress] = useState() + const [status, setStatus] = useState() + const [tokenUsage, setTokenUsage] = useState() + + const stop = useCallback(() => { + vscode.postMessage({ type: "research.abort" }) + }, []) + + const append = useCallback(async (message: Message, options?: { data?: any }) => { + if (message.role === "user") { + vscode.postMessage({ type: "research.input", payload: { message, chatRequestOptions: options } }) + } + + setMessages((prev) => [...prev, message]) + return Promise.resolve(null) + }, []) + + const reset = useCallback(() => { + setIsLoading(false) + setInput("") + setMessages([]) + vscode.postMessage({ type: "research.reset" }) + }, []) + + const start = useCallback((session: ResearchSession) => { + vscode.postMessage({ type: "research.task", payload: { session } }) + const message: Message = { role: MessageRole.User, content: session.query } + setMessages((prev) => [...prev, message]) + }, []) + + const viewReport = useCallback(() => { + vscode.postMessage({ type: "research.viewReport" }) + }, []) + + const createTask = useCallback(() => { + vscode.postMessage({ type: "research.createTask" }) + }, []) + + const onMessage = useCallback( + ({ data: { type, text } }: MessageEvent) => { + console.log(`[DeepResearch#onMessage] ${type} -> ${text}`) + + switch (type) { + case "research.loading": + const result = loadingSchema.safeParse(JSON.parse(text ?? "{}")) + + if (result.success) { + const { isLoading, message } = result.data + setIsLoading(isLoading) + setLoadingMessage(message ?? "") + } else { + console.warn(`[DeepResearch#onMessage] Invalid ${type}: ${text}: ${result.error}`) + } + + break + case "research.output": { + const result = messageSchema.safeParse(JSON.parse(text ?? "{}")) + + if (result.success) { + append(result.data) + } else { + console.warn(`[DeepResearch#onMessage] Invalid ${type}: ${text}: ${result.error}`) + } + + break + } + case "research.progress": { + const result = researchProgressSchema.safeParse(JSON.parse(text ?? "{}")) + + if (result.success) { + setProgress(result.data) + } else { + console.warn(`[DeepResearch#onMessage] Invalid ${type}: ${text}: ${result.error}`) + } + + break + } + case "research.status": { + const result = researchStatusSchema.safeParse(JSON.parse(text ?? "{}")) + + if (result.success) { + const { status } = result.data + setStatus(status) + } else { + console.warn(`[DeepResearch#onMessage] Invalid ${type}: ${text}: ${result.error}`) + } + + break + } + case "research.tokenUsage": { + const result = researchTokenUsageSchema.safeParse(JSON.parse(text ?? "{}")) + + if (result.success) { + setTokenUsage(result.data) + } else { + console.warn(`[DeepResearch#onMessage] Invalid ${type}: ${text}: ${result.error}`) + } + + break + } + case "research.error": + if (text) { + append({ + role: MessageRole.Assistant, + content: text, + annotations: [ + { + type: MessageAnnotationType.Badge, + data: { + label: "Error", + variant: "destructive", + }, + }, + ], + }) + } + + break + } + }, + [setIsLoading, setLoadingMessage, append], + ) + + useEvent("message", onMessage) + + const value = { + isLoading, + setIsLoading, + loadingMessage, + setLoadingMessage, + input, + setInput, + messages, + stop, + append, + reset, + progress, + status, + tokenUsage, + start, + viewReport, + createTask, + } + + return {children} +} diff --git a/webview-ui/src/features/deep-research/GetStarted.tsx b/webview-ui/src/features/deep-research/GetStarted.tsx new file mode 100644 index 00000000000..5a1079513c9 --- /dev/null +++ b/webview-ui/src/features/deep-research/GetStarted.tsx @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useState } from "react" +import { useForm, FormProvider, Controller } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { CircleBackslashIcon } from "@radix-ui/react-icons" +import { ChevronsUpDown, ChevronsDownUp, BrainCircuit } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Button, + Slider, + AutosizeTextarea, + Input, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui" + +import { deepResearchModels, ProviderId, ResearchSession, researchSessionSchema } from "./types" +import { useResearchSession } from "./useResearchSession" +import { useProvider } from "./useProvider" +import { Providers } from "./Providers" +import { Models } from "./Models" + +export const GetStarted = () => { + const [isProvidersOpen, setIsProvidersOpen] = useState(false) + const { setSession } = useResearchSession() + const { provider, providers, setProviderValue } = useProvider() + + const form = useForm({ + resolver: zodResolver(researchSessionSchema), + defaultValues: { + providerId: provider?.providerId, + providerApiKey: provider?.providerApiKey, + firecrawlApiKey: provider?.firecrawlApiKey, + modelId: deepResearchModels[0].modelIds[provider?.providerId ?? ProviderId.OpenRouter], + breadth: 4, + depth: 2, + query: "", + concurrency: 1, + }, + }) + + const { + handleSubmit, + control, + setValue, + formState: { errors }, + } = form + + const onSubmit = useCallback((data: ResearchSession) => setSession(data), [setSession]) + + useEffect(() => { + setValue("providerId", provider?.providerId ?? ProviderId.OpenRouter) + setValue("providerApiKey", provider?.providerApiKey ?? "") + setValue("firecrawlApiKey", provider?.firecrawlApiKey ?? "") + }, [provider, setValue]) + + useEffect(() => { + if (errors.providerId || errors.providerApiKey || errors.firecrawlApiKey) { + setIsProvidersOpen(true) + } + }, [errors.providerId, errors.providerApiKey, errors.firecrawlApiKey]) + + return ( +
+ + +
+ {providers?.length ? ( + + +
+
+ {provider && !isProvidersOpen + ? `${provider.providerName} (${provider.profileName})` + : "API Configuration"} +
+ + + +
+ +
+
Profile
+ +
+ Deep Research leverages structured LLM outputs and therefore only OpenRouter + and OpenAI providers are currently supported. +
+
+ ( +
+
{provider?.providerName} API Key
+ +
+ )} + /> + ( +
+
Firecrawl API Key
+ setProviderValue("firecrawlApiKey", field.value)} + /> +
+
+ Firecrawl turns websites into LLM-ready data. +
+ +
+
+ )} + /> +
+
+
+ ) : ( + + +
+ Deep research requires usage of an OpenAI model. Please create an OpenAI or OpenRouter + profile to get started. +
+
+ )} + + ( +
+
+ Breadth ({value}) +
+ onChange(values[0])} + /> +
+ )} + /> + ( +
+
+ Depth ({value}) +
+ onChange(values[0])} + /> +
+ )} + /> + ( +
+
+ Concurrency ({value}) +
+ onChange(values[0])} + /> +
+ )} + /> + {provider && } + ( + + )} + /> +
+ +
+ {Object.entries(errors).map(([field, error]) => ( +
+ {error?.message} +
+ ))} +
+
+
+
+ ) +} + +const Hero = () => ( +
+
+
+ +

Deep Research (β)

+
+

The ultimate planner.

+
+ +
Get detailed insights on any topic by synthesizing large amounts of online information.
+
+ Complete multi-step research tasks that can be fed into a Roo Code task to super-charge its problem + solving abilities. +
+
+
+) + +type CardProps = React.HTMLAttributes & { + title?: string +} + +const Card = ({ title, className, children, ...props }: CardProps) => ( +
+ {title &&
{title}
} + {children} +
+) diff --git a/webview-ui/src/features/deep-research/History.tsx b/webview-ui/src/features/deep-research/History.tsx new file mode 100644 index 00000000000..d6e1591779a --- /dev/null +++ b/webview-ui/src/features/deep-research/History.tsx @@ -0,0 +1,110 @@ +import { useState, useMemo } from "react" +import { ChevronsUpDown, ChevronsDownUp } from "lucide-react" +import { TrashIcon } from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" +import { + Button, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui" + +import { ResearchHistoryTask } from "./types" +import { useHistory } from "./useHistory" + +export const History = () => { + const [isOpen, setIsOpen] = useState(false) + const { tasks } = useHistory() + const [visibleTasks, hiddenTasks] = useMemo(() => [tasks.slice(0, 5), tasks.slice(5)], [tasks]) + + if (tasks.length === 0) { + return null + } + + return ( +
+
+ Research History +
+ + {visibleTasks.map((task) => ( + + ))} + {hiddenTasks.length > 0 && ( + <> + + {hiddenTasks.map((task) => ( + + ))} + + + + + + )} + +
+ ) +} + +type TaskProps = Omit, "onClick" | "children"> & { + task: ResearchHistoryTask +} + +const Task = ({ task, className, ...props }: TaskProps) => { + const { selectTask, deleteTask } = useHistory() + + return ( +
+
selectTask(task.taskId)}> +
+ {task.createdAt.toLocaleString("en-US", { + month: "long", + day: "numeric", + hour: "numeric", + minute: "numeric", + })} +
+
{task.query}
+
+ + + + + + + Are you sure? + This action cannot be undone. + + + Cancel + deleteTask(task.taskId)}>Continue + + + +
+ ) +} diff --git a/webview-ui/src/features/deep-research/HistoryProvider.tsx b/webview-ui/src/features/deep-research/HistoryProvider.tsx new file mode 100644 index 00000000000..3d6707082cf --- /dev/null +++ b/webview-ui/src/features/deep-research/HistoryProvider.tsx @@ -0,0 +1,79 @@ +import { createContext, useCallback, useState, ReactNode } from "react" +import { useEvent, useMount } from "react-use" +import { z } from "zod" + +import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" + +import { vscode } from "@/utils/vscode" + +import { ResearchHistoryTask, researchHistoryTaskSchema, researchTaskSchema } from "./types" +import { useResearchSession } from "./useResearchSession" + +type HistoryContextType = { + tasks: ResearchHistoryTask[] + selectTask: (taskId: string) => void + deleteTask: (taskId: string) => void +} + +export const HistoryContext = createContext(undefined) + +export function HistoryProvider({ children }: { children: ReactNode }) { + const [tasks, setTasks] = useState([]) + const { setTask } = useResearchSession() + + const selectTask = useCallback( + (taskId: string) => vscode.postMessage({ type: "research.getTask", text: taskId }), + [], + ) + + const deleteTask = useCallback( + (taskId: string) => vscode.postMessage({ type: "research.deleteTask", text: taskId }), + [], + ) + + const onMessage = useCallback( + ({ data: { type, text } }: MessageEvent) => { + switch (type) { + case "research.history": { + try { + const result = z.array(researchHistoryTaskSchema).safeParse(JSON.parse(text ?? "{}")) + + if (result.success) { + setTasks(result.data) + } else { + console.warn(`[HistoryProvider#onMessage] invalid ${type}: ${text}: ${result.error}`) + } + } catch (e) { + console.error(`[HistoryProvider#onMessage] unexpected error`, e) + } + + break + } + case "research.task": { + try { + const result = researchTaskSchema.safeParse(JSON.parse(text ?? "{}")) + + if (result.success) { + setTask(result.data) + } else { + console.warn(`[HistoryProvider#onMessage] invalid ${type}: ${text}: ${result.error}`) + } + } catch (e) { + console.error(`[HistoryProvider#onMessage] unexpected error`, e) + } + + break + } + } + }, + [setTask], + ) + + useEvent("message", onMessage) + + useMount(() => vscode.postMessage({ type: "research.getTasks" })) + + const value = { tasks, selectTask, deleteTask } + + return {children} +} diff --git a/webview-ui/src/features/deep-research/Models.tsx b/webview-ui/src/features/deep-research/Models.tsx new file mode 100644 index 00000000000..2894b411c9b --- /dev/null +++ b/webview-ui/src/features/deep-research/Models.tsx @@ -0,0 +1,108 @@ +import { useEffect, useMemo, useState } from "react" +import { useFormContext, Controller } from "react-hook-form" +import { Check, ChevronsUpDown } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui" + +import { DeepResearchModel, ProviderId, ResearchSession, deepResearchModels } from "./types" +import { useProvider } from "./useProvider" +import { formatTokenCount, formatCurrency, toSentenceCase } from "./format" + +type ButtonProps = React.HTMLAttributes + +export function Models({ className, ...props }: ButtonProps) { + const [open, setOpen] = useState(false) + const { control, setValue, watch } = useFormContext() + const { provider } = useProvider() + const [model, setModel] = useState() + + const models = useMemo( + () => (provider ? deepResearchModels.map((model) => model.modelIds[provider.providerId]) : undefined), + [provider], + ) + + useEffect(() => { + setValue("modelId", deepResearchModels[0].modelIds[provider?.providerId || ProviderId.OpenRouter]) + }, [provider, setValue]) + + const modelId = watch("modelId") + + useEffect(() => { + setModel( + modelId ? deepResearchModels.find(({ modelIds }) => Object.values(modelIds).includes(modelId)) : undefined, + ) + }, [modelId, setValue]) + + return models ? ( + ( + <> + + + + + + + + + No model found. + + {models.map((model) => ( + { + onChange(currentValue) + setOpen(false) + }}> + {model} + + + ))} + + + + + + {model && ( +
+
Context Window: {formatTokenCount(model.contextWindow)}
+
Input Price: {formatCurrency(model.inputPrice)} / 1M Tokens
+
Output Price: {formatCurrency(model.outputPrice)} / 1M Tokens
+ {model.reasoningEffort && ( +
Reasoning Effort: {toSentenceCase(model.reasoningEffort)}
+ )} +
+ )} + + )} + /> + ) : null +} diff --git a/webview-ui/src/features/deep-research/Providers.tsx b/webview-ui/src/features/deep-research/Providers.tsx new file mode 100644 index 00000000000..61134ae1008 --- /dev/null +++ b/webview-ui/src/features/deep-research/Providers.tsx @@ -0,0 +1,68 @@ +import { useCallback, useState } from "react" +import { Check, ChevronsUpDown } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui" + +import { useProvider } from "./useProvider" + +export function Providers() { + const [open, setOpen] = useState(false) + const { provider, providers, setProvider } = useProvider() + + const onSelect = useCallback( + (value: string) => { + const provider = providers.find(({ profileId }) => profileId === value) + + if (provider) { + setProvider(provider) + setOpen(false) + } + }, + [providers, setProvider, setOpen], + ) + + return ( + + + + + + + + + No matches. + + {providers?.map(({ profileId, profileName, providerName }) => ( + + {profileName} ({providerName}) + + + ))} + + + + + + ) +} diff --git a/webview-ui/src/features/deep-research/ResearchSession.tsx b/webview-ui/src/features/deep-research/ResearchSession.tsx new file mode 100644 index 00000000000..be31ae84b26 --- /dev/null +++ b/webview-ui/src/features/deep-research/ResearchSession.tsx @@ -0,0 +1,143 @@ +import { useMemo, useRef } from "react" +import { useMount } from "react-use" +import { Cross2Icon, ReaderIcon, RocketIcon, TriangleDownIcon, TriangleUpIcon } from "@radix-ui/react-icons" + +import { Button, Progress } from "@/components/ui" +import { Chat } from "@/components/ui/chat" + +import { deepResearchModels, ResearchProgress, ResearchStatus, ResearchTokenUsage } from "./types" +import { useDeepResearch } from "./useDeepResearch" +import { useResearchSession } from "./useResearchSession" +import { formatCost, formatTokenCount } from "./format" + +export const ResearchSession = () => { + const initialized = useRef(false) + const { session } = useResearchSession() + const { status, progress, tokenUsage, start, ...handler } = useDeepResearch() + + useMount(() => { + if (session && !initialized.current) { + start(session) + initialized.current = true + } + }) + + if (!session) { + return null + } + + return ( + <> + + {status === "aborted" && } + {(status === "research" || status === "done") && ( + + )} + {status === "done" && } + +
+ + ) +} + +function Header() { + const { session, setSession } = useResearchSession() + const { reset } = useDeepResearch() + + return ( +
+
{session?.query}
+ +
+ ) +} + +function Aborted() { + const { setSession } = useResearchSession() + const { reset } = useDeepResearch() + + return ( +
+
Deep research task canceled.
+ +
+ ) +} + +function ProgressBar({ + status, + progress, + tokenUsage, +}: { + status: ResearchStatus["status"] + progress?: ResearchProgress + tokenUsage?: ResearchTokenUsage +}) { + const { session } = useResearchSession() + + const model = useMemo( + () => + session + ? deepResearchModels.find(({ modelIds }) => Object.values(modelIds).includes(session.modelId)) + : undefined, + [session], + ) + + if (!progress && !tokenUsage) { + return null + } + + const isProgressing = status !== "done" && progress + + return ( +
+ {isProgressing && } + {tokenUsage && ( +
+
+ + {formatTokenCount(tokenUsage.inTokens)} +
+
+ + {formatTokenCount(tokenUsage.outTokens)} +
+ {model &&
{formatCost(tokenUsage.inTokens, tokenUsage.outTokens, model)}
} +
+ )} +
+ ) +} + +function Done() { + const { viewReport, createTask } = useDeepResearch() + + return ( +
+ + +
+ ) +} diff --git a/webview-ui/src/features/deep-research/ResearchTask.tsx b/webview-ui/src/features/deep-research/ResearchTask.tsx new file mode 100644 index 00000000000..9f3128fcb0c --- /dev/null +++ b/webview-ui/src/features/deep-research/ResearchTask.tsx @@ -0,0 +1,52 @@ +import { useState, useEffect } from "react" +import { Cross2Icon } from "@radix-ui/react-icons" + +import { Button } from "@/components/ui" +import { Chat, ChatHandler, Message } from "@/components/ui/chat" + +import { ResearchTask as Task } from "./types" +import { useResearchSession } from "./useResearchSession" + +const useChatHandler = (task?: Task): ChatHandler => { + const [input, setInput] = useState("") + const [messages, setMessages] = useState([]) + + useEffect(() => { + if (task) { + setMessages(task.output) + } + }, [task]) + + const append = async (message: Message, options?: { data?: any }) => Promise.resolve(null) + + return { isDisabled: true, isLoading: false, input, setInput, messages, append } +} + +export const ResearchTask = () => { + const { task } = useResearchSession() + const handler = useChatHandler(task) + + if (!task) { + return null + } + + return ( + <> + +
+ + ) +} + +function Header() { + const { task, setTask } = useResearchSession() + + return ( +
+
{task?.inquiry.initialQuery}
+ +
+ ) +} diff --git a/webview-ui/src/features/deep-research/format.ts b/webview-ui/src/features/deep-research/format.ts new file mode 100644 index 00000000000..5ae2348b9de --- /dev/null +++ b/webview-ui/src/features/deep-research/format.ts @@ -0,0 +1,20 @@ +import { DeepResearchModel } from "./types" + +export const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }) + +export function formatCurrency(value: number) { + return currencyFormatter.format(value) +} + +export function formatCost(inTokens: number, outTokens: number, model: DeepResearchModel) { + const costIn = (inTokens / 1_000_000) * model.inputPrice + const costOut = (outTokens / 1_000_000) * model.outputPrice + return formatCurrency(costIn + costOut) +} + +export function formatTokenCount(tokens: number) { + return (tokens < 100_000 ? (tokens / 1000).toFixed(1) : Math.round(tokens / 1000)) + "K" +} + +export const toSentenceCase = (value?: string) => + value ? value.charAt(0).toUpperCase() + value.slice(1).toLowerCase() : undefined diff --git a/webview-ui/src/features/deep-research/types.ts b/webview-ui/src/features/deep-research/types.ts new file mode 100644 index 00000000000..e9e4c89d0fe --- /dev/null +++ b/webview-ui/src/features/deep-research/types.ts @@ -0,0 +1,250 @@ +import { z } from "zod" + +import { messageSchema } from "@/components/ui/chat" + +/** + * DeepResearchModel + */ + +export const deepResearchProviders = ["openai-native", "openrouter"] as const + +export type DeepResearchProviderId = (typeof deepResearchProviders)[number] + +export const deepResearchModelKeys = [ + "o3-mini-high", + "o3-mini", + "o1", + "o1-preview", + "o1-mini", + "gpt-4o", + "gpt-4o-mini", +] as const + +export type DeepResearchModelKey = (typeof deepResearchModelKeys)[number] + +export type DeepResearchModelId = DeepResearchModelKey | `openai/${DeepResearchModelKey}` + +export type DeepResearchModel = { + key: DeepResearchModelKey + modelIds: Record + maxTokens: number + contextWindow: number + inputPrice: number + outputPrice: number + reasoningEffort?: "low" | "medium" | "high" | "none" +} + +export const deepResearchModels: DeepResearchModel[] = [ + { + key: "o3-mini-high", + modelIds: { + "openai-native": "o3-mini-high", + openrouter: "openai/o3-mini-high", + }, + maxTokens: 100_000, + contextWindow: 200_000, + inputPrice: 1.1, + outputPrice: 4.4, + reasoningEffort: "high", + }, + { + key: "o3-mini", + modelIds: { + "openai-native": "o3-mini", + openrouter: "openai/o3-mini", + }, + maxTokens: 100_000, + contextWindow: 200_000, + inputPrice: 1.1, + outputPrice: 4.4, + reasoningEffort: "medium", + }, + { + key: "o1", + modelIds: { + "openai-native": "o1", + openrouter: "openai/o1", + }, + maxTokens: 100_000, + contextWindow: 200_000, + inputPrice: 15, + outputPrice: 60, + }, + { + key: "o1-preview", + modelIds: { + "openai-native": "o1-preview", + openrouter: "openai/o1-preview", + }, + maxTokens: 32_768, + contextWindow: 128_000, + inputPrice: 15, + outputPrice: 60, + }, + { + key: "o1-mini", + modelIds: { + "openai-native": "o1-mini", + openrouter: "openai/o1-mini", + }, + maxTokens: 65_536, + contextWindow: 128_000, + inputPrice: 1.1, + outputPrice: 4.4, + }, + { + key: "gpt-4o", + modelIds: { + "openai-native": "gpt-4o", + openrouter: "openai/gpt-4o", + }, + maxTokens: 4_096, + contextWindow: 128_000, + inputPrice: 5, + outputPrice: 15, + }, + { + key: "gpt-4o-mini", + modelIds: { + "openai-native": "gpt-4o-mini", + openrouter: "openai/gpt-4o-mini", + }, + maxTokens: 16_384, + contextWindow: 128_000, + inputPrice: 0.15, + outputPrice: 0.6, + }, +] + +/** + * Provider + */ + +export const isProvider = (provider: string): provider is ProviderId => + deepResearchProviders.includes(provider as ProviderId) + +export enum ProviderId { + OpenRouter = "openrouter", + OpenAI = "openai-native", +} + +export type ProviderMetadata = { + profileId: string + profileName: string + providerId: ProviderId + providerName: string +} + +export type Provider = ProviderMetadata & { + providerApiKey?: string + firecrawlApiKey?: string +} + +/** + * Research Session + */ + +export const researchSessionSchema = z.object({ + providerId: z.nativeEnum(ProviderId), + providerApiKey: z.string().min(1, { message: "Provider API key is required." }), + firecrawlApiKey: z.string().min(1, { message: "Firecrawl API key is required." }), + modelId: z + .string() + .refine( + (value): value is DeepResearchModelId => + deepResearchModelKeys.some((key) => value === key || value === `openai/${key}`), + { message: "Invalid model ID format" }, + ), + breadth: z.number().min(1).max(10, { message: "Breadth must be between 1 and 10." }), + depth: z.number().min(0).max(9, { message: "Depth must be between 0 and 9." }), + concurrency: z.number().min(1).max(5, { message: "Concurrency must be between 1 and 5." }), + query: z.string().min(1, { message: "Research topic is required." }), +}) + +export type ResearchSession = z.infer + +/** + * Loading + */ + +export const loadingSchema = z.object({ + isLoading: z.boolean(), + message: z.string().optional(), +}) + +export type Loading = z.infer + +/** + * Research Progress + */ + +export const researchProgressSchema = z.object({ + completedQueries: z.number().min(0), + expectedQueries: z.number().min(0), + progressPercentage: z.number().min(0).max(100), +}) + +export type ResearchProgress = z.infer + +/** + * Research Status + */ + +export const researchStatusSchema = z.object({ + status: z.enum(["idle", "followUp", "research", "done", "aborted"]), +}) + +export type ResearchStatus = z.infer + +/** + * Research Token Usage + */ + +export const researchTokenUsageSchema = z.object({ + inTokens: z.number().min(0), + outTokens: z.number().min(0), + totalTokens: z.number().min(0), +}) + +export type ResearchTokenUsage = z.infer + +/** + * Research History Task + */ + +export const researchHistoryTaskSchema = z.object({ + taskId: z.string(), + query: z.string(), + createdAt: z.number().transform((timestamp) => new Date(timestamp)), +}) + +export type ResearchHistoryTask = z.infer + +/** + * Research Task + */ + +const researchInquirySchema = z.object({ + initialQuery: z.string().optional(), + followUps: z.array(z.string()), + responses: z.array(z.string()), + query: z.string().optional(), + learnings: z.array(z.string()).optional(), + urls: z.array(z.string()).optional(), + report: z.string().optional(), + fileUri: z.string().optional(), +}) + +export const researchTaskSchema = z.object({ + taskId: z.string(), + providerId: z.string(), + modelId: z.string(), + breadth: z.number(), + depth: z.number(), + concurrency: z.number(), + inquiry: researchInquirySchema, + output: z.array(messageSchema), + createdAt: z.number().transform((timestamp) => new Date(timestamp)), +}) + +export type ResearchTask = z.infer diff --git a/webview-ui/src/features/deep-research/useDeepResearch.ts b/webview-ui/src/features/deep-research/useDeepResearch.ts new file mode 100644 index 00000000000..695cef4b49e --- /dev/null +++ b/webview-ui/src/features/deep-research/useDeepResearch.ts @@ -0,0 +1,13 @@ +import { useContext } from "react" + +import { DeepResearchContext } from "./DeepResearchProvider" + +export function useDeepResearch() { + const context = useContext(DeepResearchContext) + + if (context === undefined) { + throw new Error("useDeepResearch must be used within a DeepResearchProvider") + } + + return context +} diff --git a/webview-ui/src/features/deep-research/useHistory.ts b/webview-ui/src/features/deep-research/useHistory.ts new file mode 100644 index 00000000000..d48118bf591 --- /dev/null +++ b/webview-ui/src/features/deep-research/useHistory.ts @@ -0,0 +1,13 @@ +import { useContext } from "react" + +import { HistoryContext } from "./HistoryProvider" + +export function useHistory() { + const context = useContext(HistoryContext) + + if (context === undefined) { + throw new Error("useHistory must be used within a HistoryProvider") + } + + return context +} diff --git a/webview-ui/src/features/deep-research/useProvider.ts b/webview-ui/src/features/deep-research/useProvider.ts new file mode 100644 index 00000000000..722e1a3b4ba --- /dev/null +++ b/webview-ui/src/features/deep-research/useProvider.ts @@ -0,0 +1,75 @@ +import { useCallback, useMemo } from "react" + +import { ApiConfiguration } from "../../../../src/shared/api" + +import { useExtensionState } from "@/context/ExtensionStateContext" +import { vscode } from "@/utils/vscode" + +import { Provider, ProviderMetadata, isProvider, ProviderId } from "./types" + +type UseProvider = { + provider?: Provider + providers: ProviderMetadata[] + setProvider: (provider: Provider) => void + setProviderValue: (key: keyof ApiConfiguration, value: string) => void +} + +export const useProvider = (): UseProvider => { + const { apiConfiguration, currentApiConfigName, listApiConfigMeta, onUpdateApiConfig } = useExtensionState() + + const providers = useMemo( + () => + listApiConfigMeta + ?.filter((config) => isProvider(config.apiProvider ?? "")) + .map((p) => ({ + profileId: p.id, + profileName: p.name, + providerId: p.apiProvider as ProviderId, + providerName: p.apiProvider === ProviderId.OpenRouter ? "OpenRouter" : "OpenAI", + })) ?? [], + [listApiConfigMeta], + ) + + const provider = useMemo(() => { + if ( + !apiConfiguration?.apiProvider || + !isProvider(apiConfiguration?.apiProvider) || + !currentApiConfigName || + !listApiConfigMeta + ) { + return undefined + } + + const matchedProvider = providers.find( + ({ profileName, providerId }) => + profileName === currentApiConfigName && providerId === apiConfiguration.apiProvider, + ) + + if (!matchedProvider) { + return undefined + } + + const { openRouterApiKey, openAiNativeApiKey, firecrawlApiKey } = apiConfiguration + + return { + ...matchedProvider, + providerApiKey: + matchedProvider.providerId === ProviderId.OpenRouter ? openRouterApiKey : openAiNativeApiKey, + firecrawlApiKey, + } + }, [apiConfiguration, currentApiConfigName, listApiConfigMeta, providers]) + + const setProvider = useCallback( + ({ profileName }: ProviderMetadata) => vscode.postMessage({ type: "loadApiConfiguration", text: profileName }), + [], + ) + + const setProviderValue = useCallback( + (key: keyof ApiConfiguration, value: string) => { + onUpdateApiConfig({ ...apiConfiguration, [key]: value }) + }, + [apiConfiguration, onUpdateApiConfig], + ) + + return { provider, providers, setProvider, setProviderValue } +} diff --git a/webview-ui/src/features/deep-research/useResearchSession.ts b/webview-ui/src/features/deep-research/useResearchSession.ts new file mode 100644 index 00000000000..8586f53eabb --- /dev/null +++ b/webview-ui/src/features/deep-research/useResearchSession.ts @@ -0,0 +1,24 @@ +import { create } from "zustand" + +import { ResearchSession, ResearchTask } from "./types" + +interface ResearchSessionState { + session?: ResearchSession + task?: ResearchTask +} + +interface ResearchSessionActions { + setSession: (session: ResearchSession | undefined) => void + setTask: (task: ResearchTask | undefined) => void +} + +const defaultState: ResearchSessionState = { + session: undefined, + task: undefined, +} + +export const useResearchSession = create()((set) => ({ + ...defaultState, + setSession: (session: ResearchSession | undefined) => set({ session }), + setTask: (task: ResearchTask | undefined) => set({ task }), +})) diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index 53025be01a6..ef225bb430c 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -23,9 +23,11 @@ @theme { --font-display: var(--vscode-font-family); - --text-sm: calc(var(--vscode-font-size) * 0.9); + --text-xs: calc(var(--vscode-font-size) * 0.9); + --text-sm: calc(var(--vscode-font-size) * 0.95); --text-base: var(--vscode-font-size); --text-lg: calc(var(--vscode-font-size) * 1.1); + --text-xl: calc(var(--vscode-font-size) * 1.2); --color-background: var(--background); --color-foreground: var(--foreground); diff --git a/webview-ui/src/stories/Chat.stories.tsx b/webview-ui/src/stories/Chat.stories.tsx index d9227bc437e..3fb0b86a9a8 100644 --- a/webview-ui/src/stories/Chat.stories.tsx +++ b/webview-ui/src/stories/Chat.stories.tsx @@ -1,7 +1,7 @@ import { useState } from "react" import type { Meta, StoryObj } from "@storybook/react" -import { Chat, ChatHandler, Message } from "@/components/ui/chat" +import { Chat, ChatHandler, Message, MessageRole } from "@/components/ui/chat" const meta = { title: "ui/Chat", @@ -40,7 +40,7 @@ const useStorybookChat = (): ChatHandler => { const append = async (message: Message, options?: { data?: any }) => { const echo: Message = { ...message, - role: "assistant", + role: MessageRole.Assistant, content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", } setMessages((prev) => [...prev, message, echo]) From 7e76e2513a466aeeeab896f16971bb5c122d9a3e Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Wed, 26 Feb 2025 10:37:04 -0800 Subject: [PATCH 2/3] Add missing type --- src/shared/globalState.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 7b6b4f8274b..143ac531b9f 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -12,6 +12,7 @@ export type SecretKey = | "mistralApiKey" | "unboundApiKey" | "requestyApiKey" + | "firecrawlApiKey" export type GlobalStateKey = | "apiProvider" From a1c263653c7416f291b41201d14b15218dc87020 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Wed, 26 Feb 2025 12:24:56 -0800 Subject: [PATCH 3/3] Fix useProvider --- .../src/features/deep-research/useProvider.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/webview-ui/src/features/deep-research/useProvider.ts b/webview-ui/src/features/deep-research/useProvider.ts index 722e1a3b4ba..f09088b59eb 100644 --- a/webview-ui/src/features/deep-research/useProvider.ts +++ b/webview-ui/src/features/deep-research/useProvider.ts @@ -7,15 +7,18 @@ import { vscode } from "@/utils/vscode" import { Provider, ProviderMetadata, isProvider, ProviderId } from "./types" +type ConfigurationKey = keyof ApiConfiguration +type ConfigurationValue = ApiConfiguration[K] + type UseProvider = { provider?: Provider providers: ProviderMetadata[] setProvider: (provider: Provider) => void - setProviderValue: (key: keyof ApiConfiguration, value: string) => void + setProviderValue: (key: K, value: ConfigurationValue) => void } export const useProvider = (): UseProvider => { - const { apiConfiguration, currentApiConfigName, listApiConfigMeta, onUpdateApiConfig } = useExtensionState() + const { currentApiConfigName, apiConfiguration, listApiConfigMeta } = useExtensionState() const providers = useMemo( () => @@ -65,10 +68,14 @@ export const useProvider = (): UseProvider => { ) const setProviderValue = useCallback( - (key: keyof ApiConfiguration, value: string) => { - onUpdateApiConfig({ ...apiConfiguration, [key]: value }) + (key: K, value: ApiConfiguration[K]) => { + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration: { ...apiConfiguration, [key]: value }, + }) }, - [apiConfiguration, onUpdateApiConfig], + [currentApiConfigName, apiConfiguration], ) return { provider, providers, setProvider, setProviderValue }