diff --git a/.eslintignore b/.eslintignore index 0299f2c1..1ddb0330 100644 --- a/.eslintignore +++ b/.eslintignore @@ -11,6 +11,7 @@ **/extensions/markdown-language-features/notebook-out/** **/extensions/markdown-math/notebook-out/** **/extensions/notebook-renderers/renderer-out/index.js +**/extensions/open-remote-ssh/out/extension.js **/extensions/simple-browser/media/index.js **/extensions/typescript-language-features/test-workspace/** **/extensions/typescript-language-features/extension.webpack.config.js diff --git a/build/filters.js b/build/filters.js index 4e5049f2..a065d2ed 100644 --- a/build/filters.js +++ b/build/filters.js @@ -134,6 +134,7 @@ module.exports.indentationFilter = [ '!extensions/markdown-math/notebook-out/*.js', '!extensions/ipynb/notebook-out/**', '!extensions/notebook-renderers/renderer-out/*.js', + '!extensions/open-remote-ssh/out/*.js', '!extensions/simple-browser/media/*.js', ]; diff --git a/build/gulpfile.extensions.js b/build/gulpfile.extensions.js index 4f745aeb..65ea5daf 100644 --- a/build/gulpfile.extensions.js +++ b/build/gulpfile.extensions.js @@ -55,6 +55,7 @@ const compilations = [ 'extensions/microsoft-authentication/tsconfig.json', 'extensions/notebook-renderers/tsconfig.json', 'extensions/npm/tsconfig.json', + 'extensions/open-remote-ssh/tsconfig.json', 'extensions/php-language-features/tsconfig.json', 'extensions/references-view/tsconfig.json', 'extensions/search-result/tsconfig.json', diff --git a/build/npm/dirs.js b/build/npm/dirs.js index 7b11bde9..d06a1ec1 100644 --- a/build/npm/dirs.js +++ b/build/npm/dirs.js @@ -36,6 +36,7 @@ const dirs = [ 'extensions/microsoft-authentication', 'extensions/notebook-renderers', 'extensions/npm', + 'extensions/open-remote-ssh', 'extensions/php-language-features', 'extensions/references-view', 'extensions/search-result', diff --git a/extensions/open-remote-ssh/CHANGELOG.md b/extensions/open-remote-ssh/CHANGELOG.md new file mode 100644 index 00000000..71a79fd2 --- /dev/null +++ b/extensions/open-remote-ssh/CHANGELOG.md @@ -0,0 +1,74 @@ +## 0.0.48 +- Support `%n` in ProxyCommand +- fix: add missing direct @types/ssh2-stream dependency (#177) +- fix Win32 internal error (#178) + +## 0.0.47 +- Add support for loong64 (#175) +- Add s390x support (#174) +- Support vscodium alpine reh (#142) + +## 0.0.46 +- Add riscv64 support (#147) + +## 0.0.45 +- Use windows-x64 server on windows-arm64 + +## 0.0.44 +- Update ssh2 lib +- Properly set extensionHost env variables + +## 0.0.43 +- Fix parsing multiple include directives + +## 0.0.42 +- Fix remote label to show port when connecting to a port other than 22 + +## 0.0.41 +- Take into account parsed port from ssh destination. Fixes (#110) + +## 0.0.40 +- Update ssh-config package + +## 0.0.39 + +- output error messages when downloading vscode server (#39) +- Add PreferredAuthentications support (#97) + +## 0.0.38 + +- Enable remote support for ppc64le (#93) + +## 0.0.37 + +- Default to Current OS User in Connection String if No User Provided (#91) +- Add support for (unofficial) DragonFly reh (#86) + +## 0.0.36 + +- Make wget support continue download (#85) + +## 0.0.35 + +- Fixes hardcoded agentsock for windows breaks pageant compatibility (#81) + +## 0.0.34 + +- Add remote.SSH.connectTimeout setting +- adding %r username replacement to proxycommand (#77) + +## 0.0.33 + +- feat: support %r user substitution in proxycommand + +## 0.0.32 + +- feat: use serverDownloadUrlTemplate from product.json (#59) + +## 0.0.31 + +- feat: support glob patterns in SSH include directives + +## 0.0.30 + +- feat: support file patterns in SSH include directives diff --git a/extensions/open-remote-ssh/README.md b/extensions/open-remote-ssh/README.md new file mode 100644 index 00000000..2a208f68 --- /dev/null +++ b/extensions/open-remote-ssh/README.md @@ -0,0 +1,48 @@ +# Open Remote - SSH + +## SSH Host Requirements + +You can connect to a running SSH server on the following platforms. + +**Supported**: + +- x86_64 Debian 8+, Ubuntu 16.04+, CentOS / RHEL 7+ Linux. +- ARMv7l (AArch32) Raspbian Stretch/9+ (32-bit). +- ARMv8l (AArch64) Ubuntu 18.04+ (64-bit). +- macOS 10.14+ (Mojave) +- Windows 10+ +- FreeBSD 13 (Requires manual remote-extension-host installation) +- DragonFlyBSD (Requires manual remote-extension-host installation) + +## Requirements + +**Activation** + +Enable the extension in your `argv.json` + +```json +{ + ... + "enable-proposed-api": [ + ..., + "jeanp413.open-remote-ssh", + ] + ... +} +``` + +which you can open by running the `Preferences: Configure Runtime Arguments` command. +The file is located in `~/.vscode-oss/argv.json`. + +**Alpine linux** + +When running on alpine linux, the packages `libstdc++` and `bash` are necessary and can be installed via +running + +```bash +sudo apk add bash libstdc++ +``` + +## SSH configuration file + +[OpenSSH](https://www.openssh.com/) supports using a [configuration file](https://linuxize.com/post/using-the-ssh-config-file/) to store all your different SSH connections. To use an SSH config file, run the `Remote-SSH: Open SSH Configuration File...` command. diff --git a/extensions/open-remote-ssh/extension-browser.webpack.config.js b/extensions/open-remote-ssh/extension-browser.webpack.config.js new file mode 100644 index 00000000..7fcc53a7 --- /dev/null +++ b/extensions/open-remote-ssh/extension-browser.webpack.config.js @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const withBrowserDefaults = require('../shared.webpack.config').browser; + +module.exports = withBrowserDefaults({ + context: __dirname, + entry: { + extension: './src/extension.ts' + } +}); diff --git a/extensions/open-remote-ssh/extension.webpack.config.js b/extensions/open-remote-ssh/extension.webpack.config.js new file mode 100644 index 00000000..e124b7f4 --- /dev/null +++ b/extensions/open-remote-ssh/extension.webpack.config.js @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const withDefaults = require('../shared.webpack.config'); +const { IgnorePlugin } = require('webpack'); + +module.exports = withDefaults({ + context: __dirname, + resolve: { + mainFields: ['module', 'main'] + }, + entry: { + extension: './src/extension.ts', + }, + externals: { + vscode: "commonjs vscode", + bufferutil: "commonjs bufferutil", + "utf-8-validate": "commonjs utf-8-validate", + }, + plugins: [ + new IgnorePlugin({ + resourceRegExp: /crypto\/build\/Release\/sshcrypto\.node$/, + }), + new IgnorePlugin({ + resourceRegExp: /cpu-features/, + }) + ] +}); diff --git a/extensions/open-remote-ssh/package-lock.json b/extensions/open-remote-ssh/package-lock.json new file mode 100644 index 00000000..8b9b7d82 --- /dev/null +++ b/extensions/open-remote-ssh/package-lock.json @@ -0,0 +1,370 @@ +{ + "name": "open-remote-ssh", + "version": "0.0.48", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "open-remote-ssh", + "version": "0.0.48", + "dependencies": { + "@jeanp413/ssh-config": "^4.3.1", + "glob": "^9.3.1", + "simple-socks": "git+https://github.com/jeanp413/simple-socks#main", + "socks": "^2.5.0", + "ssh2": "git+https://github.com/jeanp413/ssh2#master" + }, + "devDependencies": { + "@types/ssh2": "^0.5.52", + "@types/ssh2-streams": "0.1.12" + }, + "engines": { + "vscode": "^1.70.2" + } + }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.26.0.tgz", + "integrity": "sha512-YXHu5lN8kJCb1LOb9PgV6pvak43X2h4HvRApcN5SdWeaItQOzfn1hgP6jasD6KWQyJDBxrVmA9o9OivlnNJK/w==", + "license": "MIT", + "dependencies": { + "core-js-pure": "^3.30.2", + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jeanp413/ssh-config": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@jeanp413/ssh-config/-/ssh-config-4.3.1.tgz", + "integrity": "sha512-x0EaWRdjs5sPDNmYr11wVB1GdwWQgRekc7SbueuO5FK7YZUav98qZKtZZU5iSDKyxJkooCs3rgVizB1wIWrF7g==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.10.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", + "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/ssh2": { + "version": "0.5.52", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz", + "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2-streams": "*" + } + }, + "node_modules/@types/ssh2-streams": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.12.tgz", + "integrity": "sha512-Sy8tpEmCce4Tq0oSOYdfqaBpA3hDM8SoxoFh5vzFsu2oL+znzGz8oVWW7xb4K920yYMUY+PIG31qZnFMfPWNCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/core-js-pure": { + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.40.0.tgz", + "integrity": "sha512-AtDzVIgRrmRKQai62yuSIN5vNiQjcJakJb4fbhVw3ehxx7Lohphvw9SGNWKhLFqSxC4ilD0g/L1huAYFQU3Q6A==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/glob": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", + "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/nan": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", + "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", + "license": "MIT", + "optional": true + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/simple-socks": { + "version": "2.2.2", + "resolved": "git+ssh://git@github.com/jeanp413/simple-socks.git#2ac739301a82d6baff04804ed494436a026acb60", + "license": "MIT", + "dependencies": { + "@babel/runtime-corejs3": "^7.16.8", + "binary": "^0.3.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/ssh2": { + "version": "1.14.0", + "resolved": "git+ssh://git@github.com/jeanp413/ssh2.git#a169f627213aa663e0aa2fd2f0ef5c8931890c26", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.9", + "nan": "^2.17.0" + } + }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/extensions/open-remote-ssh/package.json b/extensions/open-remote-ssh/package.json new file mode 100644 index 00000000..f6fc75a8 --- /dev/null +++ b/extensions/open-remote-ssh/package.json @@ -0,0 +1,351 @@ +{ + "name": "open-remote-ssh", + "displayName": "Open Remote - SSH", + "publisher": "voideditor", + "description": "Use any remote machine with a SSH server as your development environment.", + "version": "0.0.48", + "icon": "resources/icon.png", + "engines": { + "vscode": "^1.70.2" + }, + "extensionKind": [ + "ui" + ], + "enabledApiProposals": [ + "resolvers", + "contribViewsRemote" + ], + "keywords": [ + "remote development", + "remote", + "ssh" + ], + "api": "none", + "activationEvents": [ + "onCommand:openremotessh.openEmptyWindow", + "onCommand:openremotessh.openEmptyWindowInCurrentWindow", + "onCommand:openremotessh.openConfigFile", + "onCommand:openremotessh.showLog", + "onResolveRemoteAuthority:ssh-remote", + "onView:sshHosts" + ], + "main": "./out/extension.js", + "contributes": { + "configuration": { + "title": "Remote - SSH", + "properties": { + "remote.SSH.configFile": { + "type": "string", + "description": "The absolute file path to a custom SSH config file.", + "default": "", + "scope": "application" + }, + "remote.SSH.connectTimeout": { + "type": "number", + "description": "Specifies the timeout in seconds used for the SSH command that connects to the remote.", + "default": 60, + "scope": "application", + "minimum": 1 + }, + "remote.SSH.defaultExtensions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of extensions that should be installed automatically on all SSH hosts.", + "scope": "application" + }, + "remote.SSH.enableDynamicForwarding": { + "type": "boolean", + "description": "Whether to use SSH dynamic forwarding to allow setting up new port tunnels over an existing SSH connection.", + "scope": "application", + "default": true + }, + "remote.SSH.enableAgentForwarding": { + "type": "boolean", + "markdownDescription": "Enable fixing the remote environment so that the SSH config option `ForwardAgent` will take effect as expected from VS Code's remote extension host.", + "scope": "application", + "default": true + }, + "remote.SSH.serverDownloadUrlTemplate": { + "type": "string", + "description": "The URL from where the vscode server will be downloaded. You can use the following variables and they will be replaced dynamically:\n- ${quality}: vscode server quality, e.g. stable or insiders\n- ${version}: vscode server version, e.g. 1.69.0\n- ${commit}: vscode server release commit\n- ${arch}: vscode server arch, e.g. x64, armhf, arm64\n- ${release}: release number", + "scope": "application", + "default": "https://github.com/voideditor/${NAME_OF_REPO}/releases/download/${version}.${release}/void-server-${os}-${arch}-${version}.${release}.tar.gz" + }, + "remote.SSH.remotePlatform": { + "type": "object", + "description": "A map of the remote hostname to the platform for that remote. Valid values: linux, macos, windows.", + "scope": "application", + "default": {}, + "additionalProperties": { + "type": "string", + "enum": [ + "linux", + "macos", + "windows" + ] + } + }, + "remote.SSH.remoteServerListenOnSocket": { + "type": "boolean", + "description": "When true, the remote vscode server will listen on a socket path instead of opening a port. Only valid for Linux and macOS remotes. Requires `AllowStreamLocalForwarding` to be enabled for the SSH server.", + "default": false + }, + "remote.SSH.experimental.serverBinaryName": { + "type": "string", + "description": "**Experimental:** The name of the server binary, use this **only if** you are using a client without a corresponding server release", + "scope": "application", + "default": "" + } + } + }, + "views": { + "remote": [ + { + "id": "sshHosts", + "name": "SSH Targets", + "group": "targets@1", + "remoteName": "ssh-remote" + } + ] + }, + "commands": [ + { + "command": "openremotessh.openEmptyWindow", + "title": "Connect to Host...", + "category": "Remote-SSH" + }, + { + "command": "openremotessh.openEmptyWindowInCurrentWindow", + "title": "Connect Current Window to Host...", + "category": "Remote-SSH" + }, + { + "command": "openremotessh.openConfigFile", + "title": "Open SSH Configuration File...", + "category": "Remote-SSH" + }, + { + "command": "openremotessh.showLog", + "title": "Show Log", + "category": "Remote-SSH" + }, + { + "command": "openremotessh.explorer.emptyWindowInNewWindow", + "title": "Connect to Host in New Window", + "icon": "$(empty-window)" + }, + { + "command": "openremotessh.explorer.emptyWindowInCurrentWindow", + "title": "Connect to Host in Current Window" + }, + { + "command": "openremotessh.explorer.reopenFolderInCurrentWindow", + "title": "Open on SSH Host in Current Window" + }, + { + "command": "openremotessh.explorer.reopenFolderInNewWindow", + "title": "Open on SSH Host in New Window", + "icon": "$(folder-opened)" + }, + { + "command": "openremotessh.explorer.deleteFolderHistoryItem", + "title": "Remove From Recent List", + "icon": "$(x)" + }, + { + "command": "openremotessh.explorer.refresh", + "title": "Refresh", + "icon": "$(refresh)" + }, + { + "command": "openremotessh.explorer.configure", + "title": "Configure", + "icon": "$(gear)" + }, + { + "command": "openremotessh.explorer.add", + "title": "Add New", + "icon": "$(plus)" + } + ], + "resourceLabelFormatters": [ + { + "scheme": "vscode-remote", + "authority": "ssh-remote+*", + "formatting": { + "label": "${path}", + "separator": "/", + "tildify": true, + "workspaceSuffix": "SSH" + } + } + ], + "menus": { + "statusBar/remoteIndicator": [ + { + "command": "openremotessh.openEmptyWindow", + "when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected", + "group": "remote_20_ssh_1general@1" + }, + { + "command": "openremotessh.openEmptyWindowInCurrentWindow", + "when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected", + "group": "remote_20_ssh_1general@2" + }, + { + "command": "openremotessh.openConfigFile", + "when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected", + "group": "remote_20_ssh_1general@3" + }, + { + "command": "openremotessh.showLog", + "when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected", + "group": "remote_20_ssh_1general@4" + }, + { + "command": "openremotessh.openEmptyWindow", + "when": "remoteConnectionState == disconnected", + "group": "remote_20_ssh_3local@1" + }, + { + "command": "openremotessh.openEmptyWindowInCurrentWindow", + "when": "remoteConnectionState == disconnected", + "group": "remote_20_ssh_3local@2" + }, + { + "command": "openremotessh.openConfigFile", + "when": "remoteConnectionState == disconnected", + "group": "remote_20_ssh_3local@3" + }, + { + "command": "openremotessh.openEmptyWindow", + "when": "!remoteName && !virtualWorkspace", + "group": "remote_20_ssh_3local@5" + }, + { + "command": "openremotessh.openEmptyWindowInCurrentWindow", + "when": "!remoteName && !virtualWorkspace", + "group": "remote_20_ssh_3local@6" + }, + { + "command": "openremotessh.openConfigFile", + "when": "!remoteName && !virtualWorkspace", + "group": "remote_20_ssh_3local@7" + } + ], + "commandPalette": [ + { + "command": "openremotessh.explorer.refresh", + "when": "false" + }, + { + "command": "openremotessh.explorer.configure", + "when": "false" + }, + { + "command": "openremotessh.explorer.add", + "when": "false" + }, + { + "command": "openremotessh.explorer.emptyWindowInNewWindow", + "when": "false" + }, + { + "command": "openremotessh.explorer.emptyWindowInCurrentWindow", + "when": "false" + }, + { + "command": "openremotessh.explorer.reopenFolderInCurrentWindow", + "when": "false" + }, + { + "command": "openremotessh.explorer.reopenFolderInNewWindow", + "when": "false" + }, + { + "command": "openremotessh.explorer.deleteFolderHistoryItem", + "when": "false" + } + ], + "view/title": [ + { + "command": "openremotessh.explorer.add", + "when": "view == sshHosts", + "group": "navigation" + }, + { + "command": "openremotessh.explorer.configure", + "when": "view == sshHosts", + "group": "navigation" + }, + { + "command": "openremotessh.explorer.refresh", + "when": "view == sshHosts", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "openremotessh.explorer.emptyWindowInNewWindow", + "when": "viewItem =~ /^openremotessh.explorer.host$/", + "group": "inline@1" + }, + { + "command": "openremotessh.explorer.emptyWindowInNewWindow", + "when": "viewItem =~ /^openremotessh.explorer.host$/", + "group": "navigation@2" + }, + { + "command": "openremotessh.explorer.emptyWindowInCurrentWindow", + "when": "viewItem =~ /^openremotessh.explorer.host$/", + "group": "navigation@1" + }, + { + "command": "openremotessh.explorer.reopenFolderInNewWindow", + "when": "viewItem == openremotessh.explorer.folder", + "group": "inline@1" + }, + { + "command": "openremotessh.explorer.reopenFolderInNewWindow", + "when": "viewItem == openremotessh.explorer.folder", + "group": "navigation@2" + }, + { + "command": "openremotessh.explorer.reopenFolderInCurrentWindow", + "when": "viewItem == openremotessh.explorer.folder", + "group": "navigation@1" + }, + { + "command": "openremotessh.explorer.deleteFolderHistoryItem", + "when": "viewItem =~ /^openremotessh.explorer.folder/", + "group": "navigation@3" + }, + { + "command": "openremotessh.explorer.deleteFolderHistoryItem", + "when": "viewItem =~ /^openremotessh.explorer.folder/", + "group": "inline@2" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "gulp compile-extension:open-remote-ssh", + "compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none", + "watch": "gulp watch-extension:open-remote-ssh", + "watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch --info-verbosity verbose" + }, + "devDependencies": { + "@types/ssh2": "^0.5.52", + "@types/ssh2-streams": "0.1.12" + }, + "dependencies": { + "glob": "^9.3.1", + "simple-socks": "git+https://github.com/jeanp413/simple-socks#main", + "socks": "^2.5.0", + "@jeanp413/ssh-config": "^4.3.1", + "ssh2": "git+https://github.com/jeanp413/ssh2#master" + } +} diff --git a/extensions/open-remote-ssh/resources/icon.png b/extensions/open-remote-ssh/resources/icon.png new file mode 100644 index 00000000..aa5a7b9f Binary files /dev/null and b/extensions/open-remote-ssh/resources/icon.png differ diff --git a/extensions/open-remote-ssh/src/authResolver.ts b/extensions/open-remote-ssh/src/authResolver.ts new file mode 100644 index 00000000..16db657f --- /dev/null +++ b/extensions/open-remote-ssh/src/authResolver.ts @@ -0,0 +1,464 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as net from 'net'; +import * as stream from 'stream'; +import { SocksClient, SocksClientOptions } from 'socks'; +import * as vscode from 'vscode'; +import * as ssh2 from 'ssh2'; +import type { ParsedKey } from 'ssh2-streams'; +import Log from './common/logger'; +import SSHDestination from './ssh/sshDestination'; +import SSHConnection, { SSHTunnelConfig } from './ssh/sshConnection'; +import SSHConfiguration from './ssh/sshConfig'; +import { gatherIdentityFiles } from './ssh/identityFiles'; +import { untildify, exists as fileExists } from './common/files'; +import { findRandomPort } from './common/ports'; +import { disposeAll } from './common/disposable'; +import { installCodeServer, ServerInstallError } from './serverSetup'; +import { isWindows } from './common/platform'; +import * as os from 'os'; + +const PASSWORD_RETRY_COUNT = 3; +const PASSPHRASE_RETRY_COUNT = 3; + +export const REMOTE_SSH_AUTHORITY = 'ssh-remote'; + +export function getRemoteAuthority(host: string) { + return `${REMOTE_SSH_AUTHORITY}+${host}`; +} + +class TunnelInfo implements vscode.Disposable { + constructor( + readonly localPort: number, + readonly remotePortOrSocketPath: number | string, + private disposables: vscode.Disposable[] + ) { + } + + dispose() { + disposeAll(this.disposables); + } +} + +interface SSHKey { + filename: string; + parsedKey: ParsedKey; + fingerprint: string; + agentSupport?: boolean; + isPrivate?: boolean; +} + +export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode.Disposable { + + private proxyConnections: SSHConnection[] = []; + private sshConnection: SSHConnection | undefined; + private sshAgentSock: string | undefined; + private proxyCommandProcess: cp.ChildProcessWithoutNullStreams | undefined; + + private socksTunnel: SSHTunnelConfig | undefined; + private tunnels: TunnelInfo[] = []; + + private labelFormatterDisposable: vscode.Disposable | undefined; + + constructor( + readonly context: vscode.ExtensionContext, + readonly logger: Log + ) { + } + + resolve(authority: string, context: vscode.RemoteAuthorityResolverContext): Thenable { + const [type, dest] = authority.split('+'); + if (type !== REMOTE_SSH_AUTHORITY) { + throw new Error(`Invalid authority type for SSH resolver: ${type}`); + } + + this.logger.info(`Resolving ssh remote authority '${authority}' (attemp #${context.resolveAttempt})`); + + const sshDest = SSHDestination.parseEncoded(dest); + + // It looks like default values are not loaded yet when resolving a remote, + // so let's hardcode the default values here + const remoteSSHconfig = vscode.workspace.getConfiguration('remote.SSH'); + const enableDynamicForwarding = remoteSSHconfig.get('enableDynamicForwarding', true)!; + const enableAgentForwarding = remoteSSHconfig.get('enableAgentForwarding', true)!; + const serverDownloadUrlTemplate = remoteSSHconfig.get('serverDownloadUrlTemplate'); + const defaultExtensions = remoteSSHconfig.get('defaultExtensions', []); + const remotePlatformMap = remoteSSHconfig.get>('remotePlatform', {}); + const remoteServerListenOnSocket = remoteSSHconfig.get('remoteServerListenOnSocket', false)!; + const connectTimeout = remoteSSHconfig.get('connectTimeout', 60)!; + + return vscode.window.withProgress({ + title: `Setting up SSH Host ${sshDest.hostname}`, + location: vscode.ProgressLocation.Notification, + cancellable: false + }, async () => { + try { + const sshconfig = await SSHConfiguration.loadFromFS(); + const sshHostConfig = sshconfig.getHostConfiguration(sshDest.hostname); + const sshHostName = sshHostConfig['HostName'] ? sshHostConfig['HostName'].replace('%h', sshDest.hostname) : sshDest.hostname; + const sshUser = sshHostConfig['User'] || sshDest.user || os.userInfo().username || ''; // https://github.com/openssh/openssh-portable/blob/5ec5504f1d328d5bfa64280cd617c3efec4f78f3/sshconnect.c#L1561-L1562 + const sshPort = sshHostConfig['Port'] ? parseInt(sshHostConfig['Port'], 10) : (sshDest.port || 22); + + this.sshAgentSock = sshHostConfig['IdentityAgent'] || process.env['SSH_AUTH_SOCK'] || (isWindows ? '\\\\.\\pipe\\openssh-ssh-agent' : undefined); + this.sshAgentSock = this.sshAgentSock ? untildify(this.sshAgentSock) : undefined; + const agentForward = enableAgentForwarding && (sshHostConfig['ForwardAgent'] || 'no').toLowerCase() === 'yes'; + const agent = agentForward && this.sshAgentSock ? new ssh2.OpenSSHAgent(this.sshAgentSock) : undefined; + + const preferredAuthentications = sshHostConfig['PreferredAuthentications'] ? sshHostConfig['PreferredAuthentications'].split(',').map(s => s.trim()) : ['publickey', 'password', 'keyboard-interactive']; + + const identityFiles: string[] = (sshHostConfig['IdentityFile'] as unknown as string[]) || []; + const identitiesOnly = (sshHostConfig['IdentitiesOnly'] || 'no').toLowerCase() === 'yes'; + const identityKeys = await gatherIdentityFiles(identityFiles, this.sshAgentSock, identitiesOnly, this.logger); + + // Create proxy jump connections if any + let proxyStream: ssh2.ClientChannel | stream.Duplex | undefined; + if (sshHostConfig['ProxyJump']) { + const proxyJumps = sshHostConfig['ProxyJump'].split(',').filter(i => !!i.trim()) + .map(i => { + const proxy = SSHDestination.parse(i); + const proxyHostConfig = sshconfig.getHostConfiguration(proxy.hostname); + return [proxy, proxyHostConfig] as [SSHDestination, Record]; + }); + for (let i = 0; i < proxyJumps.length; i++) { + const [proxy, proxyHostConfig] = proxyJumps[i]; + const proxyHostName = proxyHostConfig['HostName'] || proxy.hostname; + const proxyUser = proxyHostConfig['User'] || proxy.user || sshUser; + const proxyPort = proxyHostConfig['Port'] ? parseInt(proxyHostConfig['Port'], 10) : (proxy.port || sshPort); + + const proxyAgentForward = enableAgentForwarding && (proxyHostConfig['ForwardAgent'] || 'no').toLowerCase() === 'yes'; + const proxyAgent = proxyAgentForward && this.sshAgentSock ? new ssh2.OpenSSHAgent(this.sshAgentSock) : undefined; + + const proxyIdentityFiles: string[] = (proxyHostConfig['IdentityFile'] as unknown as string[]) || []; + const proxyIdentitiesOnly = (proxyHostConfig['IdentitiesOnly'] || 'no').toLowerCase() === 'yes'; + const proxyIdentityKeys = await gatherIdentityFiles(proxyIdentityFiles, this.sshAgentSock, proxyIdentitiesOnly, this.logger); + + const proxyAuthHandler = this.getSSHAuthHandler(proxyUser, proxyHostName, proxyIdentityKeys, preferredAuthentications); + const proxyConnection = new SSHConnection({ + host: !proxyStream ? proxyHostName : undefined, + port: !proxyStream ? proxyPort : undefined, + sock: proxyStream, + username: proxyUser, + readyTimeout: connectTimeout * 1000, + strictVendor: false, + agentForward: proxyAgentForward, + agent: proxyAgent, + authHandler: (arg0, arg1, arg2) => (proxyAuthHandler(arg0, arg1, arg2), undefined) + }); + this.proxyConnections.push(proxyConnection); + + const nextProxyJump = i < proxyJumps.length - 1 ? proxyJumps[i + 1] : undefined; + const destIP = nextProxyJump ? (nextProxyJump[1]['HostName'] || nextProxyJump[0].hostname) : sshHostName; + const destPort = nextProxyJump ? ((nextProxyJump[1]['Port'] && parseInt(nextProxyJump[1]['Port'], 10)) || nextProxyJump[0].port || 22) : sshPort; + proxyStream = await proxyConnection.forwardOut('127.0.0.1', 0, destIP, destPort); + } + } else if (sshHostConfig['ProxyCommand']) { + let proxyArgs = (sshHostConfig['ProxyCommand'] as unknown as string[]) + .map((arg) => arg.replace('%h', sshHostName).replace('%n', sshDest.hostname).replace('%p', sshPort.toString()).replace('%r', sshUser)); + let proxyCommand = proxyArgs.shift()!; + + let options = {}; + if (isWindows && /\.(bat|cmd)$/.test(proxyCommand)) { + proxyCommand = `"${proxyCommand}"`; + proxyArgs = proxyArgs.map((arg) => arg.includes(' ') ? `"${arg}"` : arg); + options = { shell: true, windowsHide: true, windowsVerbatimArguments: true }; + } + + this.logger.trace(`Spawning ProxyCommand: ${proxyCommand} ${proxyArgs.join(' ')}`); + + const child = cp.spawn(proxyCommand, proxyArgs, options); + proxyStream = stream.Duplex.from({ readable: child.stdout, writable: child.stdin }); + this.proxyCommandProcess = child; + } + + // Create final shh connection + const sshAuthHandler = this.getSSHAuthHandler(sshUser, sshHostName, identityKeys, preferredAuthentications); + + this.sshConnection = new SSHConnection({ + host: !proxyStream ? sshHostName : undefined, + port: !proxyStream ? sshPort : undefined, + sock: proxyStream, + username: sshUser, + readyTimeout: connectTimeout * 1000, + strictVendor: false, + agentForward, + agent, + authHandler: (arg0, arg1, arg2) => (sshAuthHandler(arg0, arg1, arg2), undefined), + }); + await this.sshConnection.connect(); + + const envVariables: Record = {}; + if (agentForward) { + envVariables['SSH_AUTH_SOCK'] = null; + } + + const installResult = await installCodeServer(this.sshConnection, serverDownloadUrlTemplate, defaultExtensions, Object.keys(envVariables), remotePlatformMap[sshDest.hostname], remoteServerListenOnSocket, this.logger); + + for (const key of Object.keys(envVariables)) { + if (installResult[key] !== undefined) { + envVariables[key] = installResult[key]; + } + } + + // Update terminal env variables + this.context.environmentVariableCollection.persistent = false; + for (const [key, value] of Object.entries(envVariables)) { + if (value) { + this.context.environmentVariableCollection.replace(key, value); + } + } + + if (enableDynamicForwarding) { + const socksPort = await findRandomPort(); + this.socksTunnel = await this.sshConnection!.addTunnel({ + name: `ssh_tunnel_socks_${socksPort}`, + localPort: socksPort, + socks: true + }); + } + + const tunnelConfig = await this.openTunnel(0, installResult.listeningOn); + this.tunnels.push(tunnelConfig); + + // Enable ports view + vscode.commands.executeCommand('setContext', 'forwardedPortsViewEnabled', true); + + this.labelFormatterDisposable?.dispose(); + this.labelFormatterDisposable = vscode.workspace.registerResourceLabelFormatter({ + scheme: 'vscode-remote', + authority: `${REMOTE_SSH_AUTHORITY}+*`, + formatting: { + label: '${path}', + separator: '/', + tildify: true, + workspaceSuffix: `SSH: ${sshDest.hostname}` + (sshDest.port && sshDest.port !== 22 ? `:${sshDest.port}` : '') + } + }); + + const resolvedResult: vscode.ResolverResult = new vscode.ResolvedAuthority('127.0.0.1', tunnelConfig.localPort, installResult.connectionToken); + resolvedResult.extensionHostEnv = envVariables; + return resolvedResult; + } catch (e: unknown) { + this.logger.error(`Error resolving authority`, e); + + // Initial connection + if (context.resolveAttempt === 1) { + this.logger.show(); + + const closeRemote = 'Close Remote'; + const retry = 'Retry'; + const result = await vscode.window.showErrorMessage(`Could not establish connection to "${sshDest.hostname}"`, { modal: true }, closeRemote, retry); + if (result === closeRemote) { + await vscode.commands.executeCommand('workbench.action.remote.close'); + } else if (result === retry) { + await vscode.commands.executeCommand('workbench.action.reloadWindow'); + } + } + + if (e instanceof ServerInstallError || !(e instanceof Error)) { + throw vscode.RemoteAuthorityResolverError.NotAvailable(e instanceof Error ? e.message : String(e)); + } else { + throw vscode.RemoteAuthorityResolverError.TemporarilyNotAvailable(e.message); + } + } + }); + } + + private async openTunnel(localPort: number, remotePortOrSocketPath: number | string) { + localPort = localPort > 0 ? localPort : await findRandomPort(); + + const disposables: vscode.Disposable[] = []; + const remotePort = typeof remotePortOrSocketPath === 'number' ? remotePortOrSocketPath : undefined; + const remoteSocketPath = typeof remotePortOrSocketPath === 'string' ? remotePortOrSocketPath : undefined; + if (this.socksTunnel && remotePort) { + const forwardingServer = await new Promise((resolve, reject) => { + this.logger.trace(`Creating forwarding server ${localPort}(local) => ${this.socksTunnel!.localPort!}(socks) => ${remotePort}(remote)`); + const socksOptions: SocksClientOptions = { + proxy: { + host: '127.0.0.1', + port: this.socksTunnel!.localPort!, + type: 5 + }, + command: 'connect', + destination: { + host: '127.0.0.1', + port: remotePort + } + }; + const server: net.Server = net.createServer() + .on('error', reject) + .on('connection', async (socket: net.Socket) => { + try { + const socksConn = await SocksClient.createConnection(socksOptions); + socket.pipe(socksConn.socket); + socksConn.socket.pipe(socket); + } catch (error) { + this.logger.error(`Error while creating SOCKS connection`, error); + } + }) + .on('listening', () => resolve(server)) + .listen(localPort); + }); + disposables.push({ + dispose: () => forwardingServer.close(() => { + this.logger.trace(`SOCKS forwading server closed`); + }), + }); + } else { + this.logger.trace(`Opening tunnel ${localPort}(local) => ${remotePortOrSocketPath}(remote)`); + const tunnelConfig = await this.sshConnection!.addTunnel({ + name: `ssh_tunnel_${localPort}_${remotePortOrSocketPath}`, + remoteAddr: '127.0.0.1', + remotePort, + remoteSocketPath, + localPort + }); + disposables.push({ + dispose: () => { + this.sshConnection?.closeTunnel(tunnelConfig.name); + this.logger.trace(`Tunnel ${tunnelConfig.name} closed`); + } + }); + } + + return new TunnelInfo(localPort, remotePortOrSocketPath, disposables); + } + + private getSSHAuthHandler(sshUser: string, sshHostName: string, identityKeys: SSHKey[], preferredAuthentications: string[]) { + let passwordRetryCount = PASSWORD_RETRY_COUNT; + let keyboardRetryCount = PASSWORD_RETRY_COUNT; + identityKeys = identityKeys.slice(); + return async (methodsLeft: string[] | null, _partialSuccess: boolean | null, callback: (nextAuth: ssh2.AuthHandlerResult) => void) => { + if (methodsLeft === null) { + this.logger.info(`Trying no-auth authentication`); + + return callback({ + type: 'none', + username: sshUser, + }); + } + if (methodsLeft.includes('publickey') && identityKeys.length && preferredAuthentications.includes('publickey')) { + const identityKey = identityKeys.shift()!; + + this.logger.info(`Trying publickey authentication: ${identityKey.filename} ${identityKey.parsedKey.type} SHA256:${identityKey.fingerprint}`); + + if (identityKey.agentSupport) { + return callback({ + type: 'agent', + username: sshUser, + agent: new class extends ssh2.OpenSSHAgent { + // Only return the current key + override getIdentities(callback: (err: Error | undefined, publicKeys?: ParsedKey[]) => void): void { + callback(undefined, [identityKey.parsedKey]); + } + }(this.sshAgentSock!) + }); + } + if (identityKey.isPrivate) { + return callback({ + type: 'publickey', + username: sshUser, + key: identityKey.parsedKey + }); + } + if (!await fileExists(identityKey.filename)) { + // Try next identity file + return callback(null as any); + } + + const keyBuffer = await fs.promises.readFile(identityKey.filename); + let result = ssh2.utils.parseKey(keyBuffer); // First try without passphrase + if (result instanceof Error && result.message === 'Encrypted private OpenSSH key detected, but no passphrase given') { + let passphraseRetryCount = PASSPHRASE_RETRY_COUNT; + while (result instanceof Error && passphraseRetryCount > 0) { + const passphrase = await vscode.window.showInputBox({ + title: `Enter passphrase for ${identityKey.filename}`, + password: true, + ignoreFocusOut: true + }); + if (!passphrase) { + break; + } + result = ssh2.utils.parseKey(keyBuffer, passphrase); + passphraseRetryCount--; + } + } + if (!result || result instanceof Error) { + // Try next identity file + return callback(null as any); + } + + const key = Array.isArray(result) ? result[0] : result; + return callback({ + type: 'publickey', + username: sshUser, + key + }); + } + if (methodsLeft.includes('password') && passwordRetryCount > 0 && preferredAuthentications.includes('password')) { + if (passwordRetryCount === PASSWORD_RETRY_COUNT) { + this.logger.info(`Trying password authentication`); + } + + const password = await vscode.window.showInputBox({ + title: `Enter password for ${sshUser}@${sshHostName}`, + password: true, + ignoreFocusOut: true + }); + passwordRetryCount--; + + return callback(password + ? { + type: 'password', + username: sshUser, + password + } + : false); + } + if (methodsLeft.includes('keyboard-interactive') && keyboardRetryCount > 0 && preferredAuthentications.includes('keyboard-interactive')) { + if (keyboardRetryCount === PASSWORD_RETRY_COUNT) { + this.logger.info(`Trying keyboard-interactive authentication`); + } + + return callback({ + type: 'keyboard-interactive', + username: sshUser, + prompt: async (_name, _instructions, _instructionsLang, prompts, finish) => { + const responses: string[] = []; + for (const prompt of prompts) { + const response = await vscode.window.showInputBox({ + title: `(${sshUser}@${sshHostName}) ${prompt.prompt}`, + password: !prompt.echo, + ignoreFocusOut: true + }); + if (response === undefined) { + keyboardRetryCount = 0; + break; + } + responses.push(response); + } + keyboardRetryCount--; + finish(responses); + } + }); + } + + callback(false); + }; + } + + dispose() { + disposeAll(this.tunnels); + // If there's proxy connections then just close the parent connection + if (this.proxyConnections.length) { + this.proxyConnections[0].close(); + } else { + this.sshConnection?.close(); + } + this.proxyCommandProcess?.kill(); + this.labelFormatterDisposable?.dispose(); + } +} diff --git a/extensions/open-remote-ssh/src/commands.ts b/extensions/open-remote-ssh/src/commands.ts new file mode 100644 index 00000000..ccc9c7ff --- /dev/null +++ b/extensions/open-remote-ssh/src/commands.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import { getRemoteAuthority } from './authResolver'; +import { getSSHConfigPath } from './ssh/sshConfig'; +import { exists as fileExists } from './common/files'; +import SSHDestination from './ssh/sshDestination'; + +export async function promptOpenRemoteSSHWindow(reuseWindow: boolean) { + const host = await vscode.window.showInputBox({ + title: 'Enter [user@]hostname[:port]' + }); + + if (!host) { + return; + } + + const sshDest = new SSHDestination(host); + openRemoteSSHWindow(sshDest.toEncodedString(), reuseWindow); +} + +export function openRemoteSSHWindow(host: string, reuseWindow: boolean) { + vscode.commands.executeCommand('vscode.newWindow', { remoteAuthority: getRemoteAuthority(host), reuseWindow }); +} + +export function openRemoteSSHLocationWindow(host: string, path: string, reuseWindow: boolean) { + vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.from({ scheme: 'vscode-remote', authority: getRemoteAuthority(host), path }), { forceNewWindow: !reuseWindow }); +} + +export async function addNewHost() { + const sshConfigPath = getSSHConfigPath(); + if (!await fileExists(sshConfigPath)) { + await fs.promises.appendFile(sshConfigPath, ''); + } + + await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(sshConfigPath), { preview: false }); + + const textEditor = vscode.window.activeTextEditor; + if (textEditor?.document.uri.fsPath !== sshConfigPath) { + return; + } + + const textDocument = textEditor.document; + const lastLine = textDocument.lineAt(textDocument.lineCount - 1); + + if (!lastLine.isEmptyOrWhitespace) { + await textEditor.edit((editBuilder: vscode.TextEditorEdit) => { + editBuilder.insert(lastLine.range.end, '\n'); + }); + } + + const snippet = '\nHost ${1:dev}\n\tHostName ${2:dev.example.com}\n\tUser ${3:john}'; + await textEditor.insertSnippet( + new vscode.SnippetString(snippet), + new vscode.Position(textDocument.lineCount, 0) + ); +} + +export async function openSSHConfigFile() { + const sshConfigPath = getSSHConfigPath(); + if (!await fileExists(sshConfigPath)) { + await fs.promises.appendFile(sshConfigPath, ''); + } + vscode.commands.executeCommand('vscode.open', vscode.Uri.file(sshConfigPath)); +} diff --git a/extensions/open-remote-ssh/src/common/disposable.ts b/extensions/open-remote-ssh/src/common/disposable.ts new file mode 100644 index 00000000..c81eb6cd --- /dev/null +++ b/extensions/open-remote-ssh/src/common/disposable.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; + +export function disposeAll(disposables: vscode.Disposable[]): void { + while (disposables.length) { + const item = disposables.pop(); + if (item) { + item.dispose(); + } + } +} + +export abstract class Disposable { + private _isDisposed = false; + + protected _disposables: vscode.Disposable[] = []; + + public dispose(): any { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + disposeAll(this._disposables); + } + + protected _register(value: T): T { + if (this._isDisposed) { + value.dispose(); + } else { + this._disposables.push(value); + } + return value; + } + + protected get isDisposed(): boolean { + return this._isDisposed; + } +} diff --git a/extensions/open-remote-ssh/src/common/files.ts b/extensions/open-remote-ssh/src/common/files.ts new file mode 100644 index 00000000..6d9ff46f --- /dev/null +++ b/extensions/open-remote-ssh/src/common/files.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'fs'; +import * as os from 'os'; + +const homeDir = os.homedir(); + +export async function exists(path: string) { + try { + await fs.promises.access(path); + return true; + } catch { + return false; + } +} + +export function untildify(path: string) { + return path.replace(/^~(?=$|\/|\\)/, homeDir); +} + +export function normalizeToSlash(path: string) { + return path.replace(/\\/g, '/'); +} diff --git a/extensions/open-remote-ssh/src/common/logger.ts b/extensions/open-remote-ssh/src/common/logger.ts new file mode 100644 index 00000000..6489d0c0 --- /dev/null +++ b/extensions/open-remote-ssh/src/common/logger.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; + +type LogLevel = 'Trace' | 'Info' | 'Error'; + +export default class Log { + private output: vscode.OutputChannel; + + constructor(name: string) { + this.output = vscode.window.createOutputChannel(name); + } + + private data2String(data: any): string { + if (data instanceof Error) { + return data.stack || data.message; + } + if (data.success === false && data.message) { + return data.message; + } + return data.toString(); + } + + public trace(message: string, data?: any): void { + this.logLevel('Trace', message, data); + } + + public info(message: string, data?: any): void { + this.logLevel('Info', message, data); + } + + public error(message: string, data?: any): void { + this.logLevel('Error', message, data); + } + + public logLevel(level: LogLevel, message: string, data?: any): void { + this.output.appendLine(`[${level} - ${this.now()}] ${message}`); + if (data) { + this.output.appendLine(this.data2String(data)); + } + } + + private now(): string { + const now = new Date(); + return padLeft(now.getUTCHours() + '', 2, '0') + + ':' + padLeft(now.getMinutes() + '', 2, '0') + + ':' + padLeft(now.getUTCSeconds() + '', 2, '0') + '.' + now.getMilliseconds(); + } + + public show() { + this.output.show(); + } + + public dispose() { + this.output.dispose(); + } +} + +function padLeft(s: string, n: number, pad = ' ') { + return pad.repeat(Math.max(0, n - s.length)) + s; +} diff --git a/extensions/open-remote-ssh/src/common/platform.ts b/extensions/open-remote-ssh/src/common/platform.ts new file mode 100644 index 00000000..73c65ca9 --- /dev/null +++ b/extensions/open-remote-ssh/src/common/platform.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +export const isWindows = process.platform === 'win32'; +export const isMacintosh = process.platform === 'darwin'; +export const isLinux = process.platform === 'linux'; diff --git a/extensions/open-remote-ssh/src/common/ports.ts b/extensions/open-remote-ssh/src/common/ports.ts new file mode 100644 index 00000000..e135441b --- /dev/null +++ b/extensions/open-remote-ssh/src/common/ports.ts @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as net from 'net'; + +/** + * Finds a random unused port assigned by the operating system. Will reject in case no free port can be found. + */ +export function findRandomPort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer({ pauseOnConnect: true }); + server.on('error', reject); + server.on('listening', () => { + const port = (server.address() as net.AddressInfo).port; + server.close(() => resolve(port)); + }); + server.listen(0, '127.0.0.1'); + }); +} + +/** + * Given a start point and a max number of retries, will find a port that + * is openable. Will return 0 in case no free port can be found. + */ +export function findFreePort(startPort: number, giveUpAfter: number, timeout: number, stride = 1): Promise { + let done = false; + + return new Promise(resolve => { + const timeoutHandle = setTimeout(() => { + if (!done) { + done = true; + return resolve(0); + } + }, timeout); + + doFindFreePort(startPort, giveUpAfter, stride, (port) => { + if (!done) { + done = true; + clearTimeout(timeoutHandle); + return resolve(port); + } + }); + }); +} + +function doFindFreePort(startPort: number, giveUpAfter: number, stride: number, clb: (port: number) => void): void { + if (giveUpAfter === 0) { + return clb(0); + } + + const client = new net.Socket(); + + // If we can connect to the port it means the port is already taken so we continue searching + client.once('connect', () => { + dispose(client); + + return doFindFreePort(startPort + stride, giveUpAfter - 1, stride, clb); + }); + + client.once('data', () => { + // this listener is required since node.js 8.x + }); + + client.once('error', (err: Error & { code?: string }) => { + dispose(client); + + // If we receive any non ECONNREFUSED error, it means the port is used but we cannot connect + if (err.code !== 'ECONNREFUSED') { + return doFindFreePort(startPort + stride, giveUpAfter - 1, stride, clb); + } + + // Otherwise it means the port is free to use! + return clb(startPort); + }); + + client.connect(startPort, '127.0.0.1'); +} + +/** + * Uses listen instead of connect. Is faster, but if there is another listener on 0.0.0.0 then this will take 127.0.0.1 from that listener. + */ +export function findFreePortFaster(startPort: number, giveUpAfter: number, timeout: number): Promise { + let resolved = false; + let timeoutHandle: NodeJS.Timeout | undefined = undefined; + let countTried = 1; + const server = net.createServer({ pauseOnConnect: true }); + function doResolve(port: number, resolve: (port: number) => void) { + if (!resolved) { + resolved = true; + server.removeAllListeners(); + server.close(); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + resolve(port); + } + } + return new Promise(resolve => { + timeoutHandle = setTimeout(() => { + doResolve(0, resolve); + }, timeout); + + server.on('listening', () => { + doResolve(startPort, resolve); + }); + server.on('error', err => { + if (err && ((err).code === 'EADDRINUSE' || (err).code === 'EACCES') && (countTried < giveUpAfter)) { + startPort++; + countTried++; + server.listen(startPort, '127.0.0.1'); + } else { + doResolve(0, resolve); + } + }); + server.on('close', () => { + doResolve(0, resolve); + }); + server.listen(startPort, '127.0.0.1'); + }); +} + +function dispose(socket: net.Socket): void { + try { + socket.removeAllListeners('connect'); + socket.removeAllListeners('error'); + socket.end(); + socket.destroy(); + socket.unref(); + } catch (error) { + console.error(error); // otherwise this error would get lost in the callback chain + } +} diff --git a/extensions/open-remote-ssh/src/extension.ts b/extensions/open-remote-ssh/src/extension.ts new file mode 100644 index 00000000..e950f545 --- /dev/null +++ b/extensions/open-remote-ssh/src/extension.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import Log from './common/logger'; +import { RemoteSSHResolver, REMOTE_SSH_AUTHORITY } from './authResolver'; +import { openSSHConfigFile, promptOpenRemoteSSHWindow } from './commands'; +import { HostTreeDataProvider } from './hostTreeView'; +import { getRemoteWorkspaceLocationData, RemoteLocationHistory } from './remoteLocationHistory'; + +export async function activate(context: vscode.ExtensionContext) { + const logger = new Log('Remote - SSH'); + context.subscriptions.push(logger); + + const remoteSSHResolver = new RemoteSSHResolver(context, logger); + context.subscriptions.push(vscode.workspace.registerRemoteAuthorityResolver(REMOTE_SSH_AUTHORITY, remoteSSHResolver)); + context.subscriptions.push(remoteSSHResolver); + + const locationHistory = new RemoteLocationHistory(context); + const locationData = getRemoteWorkspaceLocationData(); + if (locationData) { + await locationHistory.addLocation(locationData[0], locationData[1]); + } + + const hostTreeDataProvider = new HostTreeDataProvider(locationHistory); + context.subscriptions.push(vscode.window.createTreeView('sshHosts', { treeDataProvider: hostTreeDataProvider })); + context.subscriptions.push(hostTreeDataProvider); + + context.subscriptions.push(vscode.commands.registerCommand('openremotessh.openEmptyWindow', () => promptOpenRemoteSSHWindow(false))); + context.subscriptions.push(vscode.commands.registerCommand('openremotessh.openEmptyWindowInCurrentWindow', () => promptOpenRemoteSSHWindow(true))); + context.subscriptions.push(vscode.commands.registerCommand('openremotessh.openConfigFile', () => openSSHConfigFile())); + context.subscriptions.push(vscode.commands.registerCommand('openremotessh.showLog', () => logger.show())); +} + +export function deactivate() { +} diff --git a/extensions/open-remote-ssh/src/hostTreeView.ts b/extensions/open-remote-ssh/src/hostTreeView.ts new file mode 100644 index 00000000..5874c53e --- /dev/null +++ b/extensions/open-remote-ssh/src/hostTreeView.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import * as path from 'path'; +import SSHConfiguration, { getSSHConfigPath } from './ssh/sshConfig'; +import { RemoteLocationHistory } from './remoteLocationHistory'; +import { Disposable } from './common/disposable'; +import { addNewHost, openRemoteSSHLocationWindow, openRemoteSSHWindow, openSSHConfigFile } from './commands'; +import SSHDestination from './ssh/sshDestination'; + +class HostItem { + constructor( + public hostname: string, + public locations: string[] + ) { + } +} + +class HostLocationItem { + constructor( + public path: string, + public hostname: string + ) { + } +} + +type DataTreeItem = HostItem | HostLocationItem; + +export class HostTreeDataProvider extends Disposable implements vscode.TreeDataProvider { + + private readonly _onDidChangeTreeData = this._register(new vscode.EventEmitter()); + public readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + constructor( + private locationHistory: RemoteLocationHistory + ) { + super(); + + this._register(vscode.commands.registerCommand('openremotessh.explorer.add', () => addNewHost())); + this._register(vscode.commands.registerCommand('openremotessh.explorer.configure', () => openSSHConfigFile())); + this._register(vscode.commands.registerCommand('openremotessh.explorer.refresh', () => this.refresh())); + this._register(vscode.commands.registerCommand('openremotessh.explorer.emptyWindowInNewWindow', e => this.openRemoteSSHWindow(e, false))); + this._register(vscode.commands.registerCommand('openremotessh.explorer.emptyWindowInCurrentWindow', e => this.openRemoteSSHWindow(e, true))); + this._register(vscode.commands.registerCommand('openremotessh.explorer.reopenFolderInNewWindow', e => this.openRemoteSSHLocationWindow(e, false))); + this._register(vscode.commands.registerCommand('openremotessh.explorer.reopenFolderInCurrentWindow', e => this.openRemoteSSHLocationWindow(e, true))); + this._register(vscode.commands.registerCommand('openremotessh.explorer.deleteFolderHistoryItem', e => this.deleteHostLocation(e))); + + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('remote.SSH.configFile')) { + this.refresh(); + } + })); + this._register(vscode.workspace.onDidSaveTextDocument(e => { + if (e.uri.fsPath === getSSHConfigPath()) { + this.refresh(); + } + })); + } + + getTreeItem(element: DataTreeItem): vscode.TreeItem { + if (element instanceof HostLocationItem) { + const label = path.posix.basename(element.path).replace(/\.code-workspace$/, ' (Workspace)'); + const treeItem = new vscode.TreeItem(label); + treeItem.description = path.posix.dirname(element.path); + treeItem.iconPath = new vscode.ThemeIcon('folder'); + treeItem.contextValue = 'openremotessh.explorer.folder'; + return treeItem; + } + + const treeItem = new vscode.TreeItem(element.hostname); + treeItem.collapsibleState = element.locations.length ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; + treeItem.iconPath = new vscode.ThemeIcon('vm'); + treeItem.contextValue = 'openremotessh.explorer.host'; + return treeItem; + } + + async getChildren(element?: HostItem): Promise { + if (!element) { + const sshConfigFile = await SSHConfiguration.loadFromFS(); + const hosts = sshConfigFile.getAllConfiguredHosts(); + return hosts.map(hostname => new HostItem(hostname, this.locationHistory.getHistory(hostname))); + } + if (element instanceof HostItem) { + return element.locations.map(location => new HostLocationItem(location, element.hostname)); + } + return []; + } + + private refresh() { + this._onDidChangeTreeData.fire(); + } + + private async deleteHostLocation(element: HostLocationItem) { + await this.locationHistory.removeLocation(element.hostname, element.path); + this.refresh(); + } + + private async openRemoteSSHWindow(element: HostItem, reuseWindow: boolean) { + const sshDest = new SSHDestination(element.hostname); + openRemoteSSHWindow(sshDest.toEncodedString(), reuseWindow); + } + + private async openRemoteSSHLocationWindow(element: HostLocationItem, reuseWindow: boolean) { + const sshDest = new SSHDestination(element.hostname); + openRemoteSSHLocationWindow(sshDest.toEncodedString(), element.path, reuseWindow); + } +} diff --git a/extensions/open-remote-ssh/src/remoteLocationHistory.ts b/extensions/open-remote-ssh/src/remoteLocationHistory.ts new file mode 100644 index 00000000..6e6b7777 --- /dev/null +++ b/extensions/open-remote-ssh/src/remoteLocationHistory.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import { REMOTE_SSH_AUTHORITY } from './authResolver'; +import SSHDestination from './ssh/sshDestination'; + +export class RemoteLocationHistory { + private static STORAGE_KEY = 'remoteLocationHistory_v0'; + + private remoteLocationHistory: Record = {}; + + constructor(private context: vscode.ExtensionContext) { + // context.globalState.update(RemoteLocationHistory.STORAGE_KEY, undefined); + this.remoteLocationHistory = context.globalState.get(RemoteLocationHistory.STORAGE_KEY) || {}; + } + + getHistory(host: string): string[] { + return this.remoteLocationHistory[host] || []; + } + + async addLocation(host: string, path: string) { + const hostLocations = this.remoteLocationHistory[host] || []; + if (!hostLocations.includes(path)) { + hostLocations.unshift(path); + this.remoteLocationHistory[host] = hostLocations; + + await this.context.globalState.update(RemoteLocationHistory.STORAGE_KEY, this.remoteLocationHistory); + } + } + + async removeLocation(host: string, path: string) { + let hostLocations = this.remoteLocationHistory[host] || []; + hostLocations = hostLocations.filter(l => l !== path); + this.remoteLocationHistory[host] = hostLocations; + + await this.context.globalState.update(RemoteLocationHistory.STORAGE_KEY, this.remoteLocationHistory); + } +} + +export function getRemoteWorkspaceLocationData(): [string, string] | undefined { + let location = vscode.workspace.workspaceFile; + if (location && location.scheme === 'vscode-remote' && location.authority.startsWith(REMOTE_SSH_AUTHORITY) && location.path.endsWith('.code-workspace')) { + const [, host] = location.authority.split('+'); + const sshDest = SSHDestination.parseEncoded(host); + return [sshDest.hostname, location.path]; + } + + location = vscode.workspace.workspaceFolders?.[0].uri; + if (location && location.scheme === 'vscode-remote' && location.authority.startsWith(REMOTE_SSH_AUTHORITY)) { + const [, host] = location.authority.split('+'); + const sshDest = SSHDestination.parseEncoded(host); + return [sshDest.hostname, location.path]; + } + + return undefined; +} diff --git a/extensions/open-remote-ssh/src/serverConfig.ts b/extensions/open-remote-ssh/src/serverConfig.ts new file mode 100644 index 00000000..3f2758fa --- /dev/null +++ b/extensions/open-remote-ssh/src/serverConfig.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; + +let vscodeProductJson: any; +async function getVSCodeProductJson() { + if (!vscodeProductJson) { + const productJsonStr = await fs.promises.readFile(path.join(vscode.env.appRoot, 'product.json'), 'utf8'); + vscodeProductJson = JSON.parse(productJsonStr); + } + + return vscodeProductJson; +} + +export interface IServerConfig { + version: string; + commit: string; + quality: string; + release?: string; // void-like specific + serverApplicationName: string; + serverDataFolderName: string; + serverDownloadUrlTemplate?: string; // void-like specific +} + +export async function getVSCodeServerConfig(): Promise { + const productJson = await getVSCodeProductJson(); + + const customServerBinaryName = vscode.workspace.getConfiguration('remote.SSH.experimental').get('serverBinaryName', ''); + + return { + version: vscode.version.replace('-insider', ''), + commit: productJson.commit, + quality: productJson.quality, + release: productJson.release, + serverApplicationName: customServerBinaryName || productJson.serverApplicationName, + serverDataFolderName: productJson.serverDataFolderName, + serverDownloadUrlTemplate: productJson.serverDownloadUrlTemplate + }; +} diff --git a/extensions/open-remote-ssh/src/serverSetup.ts b/extensions/open-remote-ssh/src/serverSetup.ts new file mode 100644 index 00000000..d74ff8e9 --- /dev/null +++ b/extensions/open-remote-ssh/src/serverSetup.ts @@ -0,0 +1,626 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as crypto from 'crypto'; +import Log from './common/logger'; +import { getVSCodeServerConfig } from './serverConfig'; +import SSHConnection from './ssh/sshConnection'; + +export interface ServerInstallOptions { + id: string; + quality: string; + commit: string; + version: string; + release?: string; // void specific + extensionIds: string[]; + envVariables: string[]; + useSocketPath: boolean; + serverApplicationName: string; + serverDataFolderName: string; + serverDownloadUrlTemplate: string; +} + +export interface ServerInstallResult { + exitCode: number; + listeningOn: number | string; + connectionToken: string; + logFile: string; + osReleaseId: string; + arch: string; + platform: string; + tmpDir: string; + [key: string]: any; +} + +export class ServerInstallError extends Error { + constructor(message: string) { + super(message); + } +} + +const DEFAULT_DOWNLOAD_URL_TEMPLATE = 'https://github.com/voideditor/void-updates-server/releases/download/test/void-server-${os}-${arch}.tar.gz'; + +export async function installCodeServer(conn: SSHConnection, serverDownloadUrlTemplate: string | undefined, extensionIds: string[], envVariables: string[], platform: string | undefined, useSocketPath: boolean, logger: Log): Promise { + let shell = 'powershell'; + + // detect platform and shell for windows + if (!platform || platform === 'windows') { + const result = await conn.exec('uname -s'); + + if (result.stdout) { + if (result.stdout.includes('windows32')) { + platform = 'windows'; + } else if (result.stdout.includes('MINGW64')) { + platform = 'windows'; + shell = 'bash'; + } + } else if (result.stderr) { + if (result.stderr.includes('FullyQualifiedErrorId : CommandNotFoundException')) { + platform = 'windows'; + } + + if (result.stderr.includes('is not recognized as an internal or external command')) { + platform = 'windows'; + shell = 'cmd'; + } + } + + if (platform) { + logger.trace(`Detected platform: ${platform}, ${shell}`); + } + } + + const scriptId = crypto.randomBytes(12).toString('hex'); + + const vscodeServerConfig = await getVSCodeServerConfig(); + const installOptions: ServerInstallOptions = { + id: scriptId, + version: vscodeServerConfig.version, + commit: vscodeServerConfig.commit, + quality: vscodeServerConfig.quality, + release: vscodeServerConfig.release, + extensionIds, + envVariables, + useSocketPath, + serverApplicationName: vscodeServerConfig.serverApplicationName, + serverDataFolderName: vscodeServerConfig.serverDataFolderName, + serverDownloadUrlTemplate: serverDownloadUrlTemplate ?? vscodeServerConfig.serverDownloadUrlTemplate ?? DEFAULT_DOWNLOAD_URL_TEMPLATE, + }; + + let commandOutput: { stdout: string; stderr: string }; + if (platform === 'windows') { + const installServerScript = generatePowerShellInstallScript(installOptions); + + logger.trace('Server install command:', installServerScript); + + const installDir = `$HOME\\${vscodeServerConfig.serverDataFolderName}\\install`; + const installScript = `${installDir}\\${vscodeServerConfig.commit}.ps1`; + const endRegex = new RegExp(`${scriptId}: end`); + // investigate if it's possible to use `-EncodedCommand` flag + // https://devblogs.microsoft.com/powershell/invoking-powershell-with-complex-expressions-using-scriptblocks/ + let command = ''; + if (shell === 'powershell') { + command = `md -Force ${installDir}; echo @'\n${installServerScript}\n'@ | Set-Content ${installScript}; powershell -ExecutionPolicy ByPass -File "${installScript}"`; + } else if (shell === 'bash') { + command = `mkdir -p ${installDir.replace(/\\/g, '/')} && echo '\n${installServerScript.replace(/'/g, '\'"\'"\'')}\n' > ${installScript.replace(/\\/g, '/')} && powershell -ExecutionPolicy ByPass -File "${installScript}"`; + } else if (shell === 'cmd') { + const script = installServerScript.trim() + // remove comments + .replace(/^#.*$/gm, '') + // remove empty lines + .replace(/\n{2,}/gm, '\n') + // remove leading spaces + .replace(/^\s*/gm, '') + // escape double quotes (from powershell/cmd) + .replace(/"/g, '"""') + // escape single quotes (from cmd) + .replace(/'/g, `''`) + // escape redirect (from cmd) + .replace(/>/g, `^>`) + // escape new lines (from powershell/cmd) + .replace(/\n/g, '\'`n\''); + + command = `powershell "md -Force ${installDir}" && powershell "echo '${script}'" > ${installScript.replace('$HOME', '%USERPROFILE%')} && powershell -ExecutionPolicy ByPass -File "${installScript.replace('$HOME', '%USERPROFILE%')}"`; + + logger.trace('Command length (8191 max):', command.length); + + if (command.length > 8191) { + throw new ServerInstallError(`Command line too long`); + } + } else { + throw new ServerInstallError(`Not supported shell: ${shell}`); + } + + commandOutput = await conn.execPartial(command, (stdout: string) => endRegex.test(stdout)); + } else { + const installServerScript = generateBashInstallScript(installOptions); + + logger.trace('Server install command:', installServerScript); + // Fish shell does not support heredoc so let's workaround it using -c option, + // also replace single quotes (') within the script with ('\'') as there's no quoting within single quotes, see https://unix.stackexchange.com/a/24676 + commandOutput = await conn.exec(`bash -c '${installServerScript.replace(/'/g, `'\\''`)}'`); + } + + if (commandOutput.stderr) { + logger.trace('Server install command stderr:', commandOutput.stderr); + } + logger.trace('Server install command stdout:', commandOutput.stdout); + + const resultMap = parseServerInstallOutput(commandOutput.stdout, scriptId); + if (!resultMap) { + throw new ServerInstallError(`Failed parsing install script output`); + } + + const exitCode = parseInt(resultMap.exitCode, 10); + if (exitCode !== 0) { + throw new ServerInstallError(`Couldn't install vscode server on remote server, install script returned non-zero exit status`); + } + + const listeningOn = resultMap.listeningOn.match(/^\d+$/) + ? parseInt(resultMap.listeningOn, 10) + : resultMap.listeningOn; + + const remoteEnvVars = Object.fromEntries(Object.entries(resultMap).filter(([key,]) => envVariables.includes(key))); + + return { + exitCode, + listeningOn, + connectionToken: resultMap.connectionToken, + logFile: resultMap.logFile, + osReleaseId: resultMap.osReleaseId, + arch: resultMap.arch, + platform: resultMap.platform, + tmpDir: resultMap.tmpDir, + ...remoteEnvVars + }; +} + +function parseServerInstallOutput(str: string, scriptId: string): { [k: string]: string } | undefined { + const startResultStr = `${scriptId}: start`; + const endResultStr = `${scriptId}: end`; + + const startResultIdx = str.indexOf(startResultStr); + if (startResultIdx < 0) { + return undefined; + } + + const endResultIdx = str.indexOf(endResultStr, startResultIdx + startResultStr.length); + if (endResultIdx < 0) { + return undefined; + } + + const installResult = str.substring(startResultIdx + startResultStr.length, endResultIdx); + + const resultMap: { [k: string]: string } = {}; + const resultArr = installResult.split(/\r?\n/); + for (const line of resultArr) { + const [key, value] = line.split('=='); + resultMap[key] = value; + } + + return resultMap; +} + +function generateBashInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate }: ServerInstallOptions) { + const extensions = extensionIds.map(id => '--install-extension ' + id).join(' '); + return ` +# Server installation script + +TMP_DIR="\${XDG_RUNTIME_DIR:-"/tmp"}" + +DISTRO_VERSION="${version}" +DISTRO_COMMIT="${commit}" +DISTRO_QUALITY="${quality}" +DISTRO_VOID_RELEASE="${release ?? ''}" + +SERVER_APP_NAME="${serverApplicationName}" +SERVER_INITIAL_EXTENSIONS="${extensions}" +SERVER_LISTEN_FLAG="${useSocketPath ? `--socket-path="$TMP_DIR/vscode-server-sock-${crypto.randomUUID()}"` : '--port=0'}" +SERVER_DATA_DIR="$HOME/${serverDataFolderName}" +SERVER_DIR="$SERVER_DATA_DIR/bin/$DISTRO_COMMIT" +SERVER_SCRIPT="$SERVER_DIR/bin/$SERVER_APP_NAME" +SERVER_LOGFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.log" +SERVER_PIDFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.pid" +SERVER_TOKENFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.token" +SERVER_ARCH= +SERVER_CONNECTION_TOKEN= +SERVER_DOWNLOAD_URL= + +LISTENING_ON= +OS_RELEASE_ID= +ARCH= +PLATFORM= + +# Mimic output from logs of remote-ssh extension +print_install_results_and_exit() { + echo "${id}: start" + echo "exitCode==$1==" + echo "listeningOn==$LISTENING_ON==" + echo "connectionToken==$SERVER_CONNECTION_TOKEN==" + echo "logFile==$SERVER_LOGFILE==" + echo "osReleaseId==$OS_RELEASE_ID==" + echo "arch==$ARCH==" + echo "platform==$PLATFORM==" + echo "tmpDir==$TMP_DIR==" + ${envVariables.map(envVar => `echo "${envVar}==$${envVar}=="`).join('\n')} + echo "${id}: end" + exit 0 +} + +# Check if platform is supported +KERNEL="$(uname -s)" +case $KERNEL in + Darwin) + PLATFORM="darwin" + ;; + Linux) + PLATFORM="linux" + ;; + FreeBSD) + PLATFORM="freebsd" + ;; + DragonFly) + PLATFORM="dragonfly" + ;; + *) + echo "Error platform not supported: $KERNEL" + print_install_results_and_exit 1 + ;; +esac + +# Check machine architecture +ARCH="$(uname -m)" +case $ARCH in + x86_64 | amd64) + SERVER_ARCH="x64" + ;; + armv7l | armv8l) + SERVER_ARCH="armhf" + ;; + arm64 | aarch64) + SERVER_ARCH="arm64" + ;; + ppc64le) + SERVER_ARCH="ppc64le" + ;; + riscv64) + SERVER_ARCH="riscv64" + ;; + loongarch64) + SERVER_ARCH="loong64" + ;; + s390x) + SERVER_ARCH="s390x" + ;; + *) + echo "Error architecture not supported: $ARCH" + print_install_results_and_exit 1 + ;; +esac + +# https://www.freedesktop.org/software/systemd/man/os-release.html +OS_RELEASE_ID="$(grep -i '^ID=' /etc/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')" +if [[ -z $OS_RELEASE_ID ]]; then + OS_RELEASE_ID="$(grep -i '^ID=' /usr/lib/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')" + if [[ -z $OS_RELEASE_ID ]]; then + OS_RELEASE_ID="unknown" + fi +fi + +# Create installation folder +if [[ ! -d $SERVER_DIR ]]; then + mkdir -p $SERVER_DIR + if (( $? > 0 )); then + echo "Error creating server install directory" + print_install_results_and_exit 1 + fi +fi + +# adjust platform for void download, if needed +if [[ $OS_RELEASE_ID = alpine ]]; then + PLATFORM=$OS_RELEASE_ID +fi + +SERVER_DOWNLOAD_URL="$(echo "${serverDownloadUrlTemplate.replace(/\$\{/g, '\\${')}" | sed "s/\\\${quality}/$DISTRO_QUALITY/g" | sed "s/\\\${version}/$DISTRO_VERSION/g" | sed "s/\\\${commit}/$DISTRO_COMMIT/g" | sed "s/\\\${os}/$PLATFORM/g" | sed "s/\\\${arch}/$SERVER_ARCH/g" | sed "s/\\\${release}/$DISTRO_VOID_RELEASE/g")" + +# Check if server script is already installed +if [[ ! -f $SERVER_SCRIPT ]]; then + case "$PLATFORM" in + darwin | linux | alpine ) + ;; + *) + echo "Error '$PLATFORM' needs manual installation of remote extension host" + print_install_results_and_exit 1 + ;; + esac + + pushd $SERVER_DIR > /dev/null + + if [[ ! -z $(which wget) ]]; then + wget --tries=3 --timeout=10 --continue --no-verbose -O vscode-server.tar.gz $SERVER_DOWNLOAD_URL + elif [[ ! -z $(which curl) ]]; then + curl --retry 3 --connect-timeout 10 --location --show-error --silent --output vscode-server.tar.gz $SERVER_DOWNLOAD_URL + else + echo "Error no tool to download server binary" + print_install_results_and_exit 1 + fi + + if (( $? > 0 )); then + echo "Error downloading server from $SERVER_DOWNLOAD_URL" + print_install_results_and_exit 1 + fi + + tar -xf vscode-server.tar.gz --strip-components 1 + if (( $? > 0 )); then + echo "Error while extracting server contents" + print_install_results_and_exit 1 + fi + + if [[ ! -f $SERVER_SCRIPT ]]; then + echo "Error server contents are corrupted" + print_install_results_and_exit 1 + fi + + rm -f vscode-server.tar.gz + + popd > /dev/null +else + echo "Server script already installed in $SERVER_SCRIPT" +fi + +# Try to find if server is already running +if [[ -f $SERVER_PIDFILE ]]; then + SERVER_PID="$(cat $SERVER_PIDFILE)" + SERVER_RUNNING_PROCESS="$(ps -o pid,args -p $SERVER_PID | grep $SERVER_SCRIPT)" +else + SERVER_RUNNING_PROCESS="$(ps -o pid,args -A | grep $SERVER_SCRIPT | grep -v grep)" +fi + +if [[ -z $SERVER_RUNNING_PROCESS ]]; then + if [[ -f $SERVER_LOGFILE ]]; then + rm $SERVER_LOGFILE + fi + if [[ -f $SERVER_TOKENFILE ]]; then + rm $SERVER_TOKENFILE + fi + + touch $SERVER_TOKENFILE + chmod 600 $SERVER_TOKENFILE + SERVER_CONNECTION_TOKEN="${crypto.randomUUID()}" + echo $SERVER_CONNECTION_TOKEN > $SERVER_TOKENFILE + + $SERVER_SCRIPT --start-server --host=127.0.0.1 $SERVER_LISTEN_FLAG $SERVER_INITIAL_EXTENSIONS --connection-token-file $SERVER_TOKENFILE --telemetry-level off --enable-remote-auto-shutdown --accept-server-license-terms &> $SERVER_LOGFILE & + echo $! > $SERVER_PIDFILE +else + echo "Server script is already running $SERVER_SCRIPT" +fi + +if [[ -f $SERVER_TOKENFILE ]]; then + SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)" +else + echo "Error server token file not found $SERVER_TOKENFILE" + print_install_results_and_exit 1 +fi + +if [[ -f $SERVER_LOGFILE ]]; then + for i in {1..5}; do + LISTENING_ON="$(cat $SERVER_LOGFILE | grep -E 'Extension host agent listening on .+' | sed 's/Extension host agent listening on //')" + if [[ -n $LISTENING_ON ]]; then + break + fi + sleep 0.5 + done + + if [[ -z $LISTENING_ON ]]; then + echo "Error server did not start successfully" + print_install_results_and_exit 1 + fi +else + echo "Error server log file not found $SERVER_LOGFILE" + print_install_results_and_exit 1 +fi + +# Finish server setup +print_install_results_and_exit 0 +`; +} + +function generatePowerShellInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate }: ServerInstallOptions) { + const extensions = extensionIds.map(id => '--install-extension ' + id).join(' '); + const downloadUrl = serverDownloadUrlTemplate + .replace(/\$\{quality\}/g, quality) + .replace(/\$\{version\}/g, version) + .replace(/\$\{commit\}/g, commit) + .replace(/\$\{os\}/g, 'win32') + .replace(/\$\{arch\}/g, 'x64') + .replace(/\$\{release\}/g, release ?? ''); + + return ` +# Server installation script + +$TMP_DIR="$env:TEMP\\$([System.IO.Path]::GetRandomFileName())" +$ProgressPreference = "SilentlyContinue" + +$DISTRO_VERSION="${version}" +$DISTRO_COMMIT="${commit}" +$DISTRO_QUALITY="${quality}" +$DISTRO_VOID_RELEASE="${release ?? ''}" + +$SERVER_APP_NAME="${serverApplicationName}" +$SERVER_INITIAL_EXTENSIONS="${extensions}" +$SERVER_LISTEN_FLAG="${useSocketPath ? `--socket-path="$TMP_DIR/vscode-server-sock-${crypto.randomUUID()}"` : '--port=0'}" +$SERVER_DATA_DIR="$(Resolve-Path ~)\\${serverDataFolderName}" +$SERVER_DIR="$SERVER_DATA_DIR\\bin\\$DISTRO_COMMIT" +$SERVER_SCRIPT="$SERVER_DIR\\bin\\$SERVER_APP_NAME.cmd" +$SERVER_LOGFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.log" +$SERVER_PIDFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.pid" +$SERVER_TOKENFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.token" +$SERVER_ARCH= +$SERVER_CONNECTION_TOKEN= +$SERVER_DOWNLOAD_URL= + +$LISTENING_ON= +$OS_RELEASE_ID= +$ARCH= +$PLATFORM="win32" + +function printInstallResults($code) { + "${id}: start" + "exitCode==$code==" + "listeningOn==$LISTENING_ON==" + "connectionToken==$SERVER_CONNECTION_TOKEN==" + "logFile==$SERVER_LOGFILE==" + "osReleaseId==$OS_RELEASE_ID==" + "arch==$ARCH==" + "platform==$PLATFORM==" + "tmpDir==$TMP_DIR==" + ${envVariables.map(envVar => `"${envVar}==$${envVar}=="`).join('\n')} + "${id}: end" +} + +# Check machine architecture +$ARCH=$env:PROCESSOR_ARCHITECTURE +# Use x64 version for ARM64, as it's not yet available. +if(($ARCH -eq "AMD64") -or ($ARCH -eq "IA64") -or ($ARCH -eq "ARM64")) { + $SERVER_ARCH="x64" +} +else { + "Error architecture not supported: $ARCH" + printInstallResults 1 + exit 0 +} + +# Create installation folder +if(!(Test-Path $SERVER_DIR)) { + try { + ni -it d $SERVER_DIR -f -ea si + } catch { + "Error creating server install directory - $($_.ToString())" + exit 1 + } + + if(!(Test-Path $SERVER_DIR)) { + "Error creating server install directory" + exit 1 + } +} + +cd $SERVER_DIR + +# Check if server script is already installed +if(!(Test-Path $SERVER_SCRIPT)) { + del vscode-server.tar.gz + + $REQUEST_ARGUMENTS = @{ + Uri="${downloadUrl}" + TimeoutSec=20 + OutFile="vscode-server.tar.gz" + UseBasicParsing=$True + } + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + Invoke-RestMethod @REQUEST_ARGUMENTS + + if(Test-Path "vscode-server.tar.gz") { + tar -xf vscode-server.tar.gz --strip-components 1 + + del vscode-server.tar.gz + } + + if(!(Test-Path $SERVER_SCRIPT)) { + "Error while installing the server binary" + exit 1 + } +} +else { + "Server script already installed in $SERVER_SCRIPT" +} + +# Try to find if server is already running +if(Get-Process node -ErrorAction SilentlyContinue | Where-Object Path -Like "$SERVER_DIR\\*") { + echo "Server script is already running $SERVER_SCRIPT" +} +else { + if(Test-Path $SERVER_LOGFILE) { + del $SERVER_LOGFILE + } + if(Test-Path $SERVER_PIDFILE) { + del $SERVER_PIDFILE + } + if(Test-Path $SERVER_TOKENFILE) { + del $SERVER_TOKENFILE + } + + $SERVER_CONNECTION_TOKEN="${crypto.randomUUID()}" + [System.IO.File]::WriteAllLines($SERVER_TOKENFILE, $SERVER_CONNECTION_TOKEN) + + $SCRIPT_ARGUMENTS="--start-server --host=127.0.0.1 $SERVER_LISTEN_FLAG $SERVER_INITIAL_EXTENSIONS --connection-token-file $SERVER_TOKENFILE --telemetry-level off --enable-remote-auto-shutdown --accept-server-license-terms *> '$SERVER_LOGFILE'" + + $START_ARGUMENTS = @{ + FilePath = "powershell.exe" + WindowStyle = "hidden" + ArgumentList = @( + "-ExecutionPolicy", "Unrestricted", "-NoLogo", "-NoProfile", "-NonInteractive", "-c", "$SERVER_SCRIPT $SCRIPT_ARGUMENTS" + ) + PassThru = $True + } + + $SERVER_ID = (start @START_ARGUMENTS).ID + + if($SERVER_ID) { + [System.IO.File]::WriteAllLines($SERVER_PIDFILE, $SERVER_ID) + } +} + +if(Test-Path $SERVER_TOKENFILE) { + $SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)" +} +else { + "Error server token file not found $SERVER_TOKENFILE" + printInstallResults 1 + exit 0 +} + +sleep -Milliseconds 500 + +$SELECT_ARGUMENTS = @{ + Path = $SERVER_LOGFILE + Pattern = "Extension host agent listening on (\\d+)" +} + +for($I = 1; $I -le 5; $I++) { + if(Test-Path $SERVER_LOGFILE) { + $GROUPS = (Select-String @SELECT_ARGUMENTS).Matches.Groups + + if($GROUPS) { + $LISTENING_ON = $GROUPS[1].Value + break + } + } + + sleep -Milliseconds 500 +} + +if(!(Test-Path $SERVER_LOGFILE)) { + "Error server log file not found $SERVER_LOGFILE" + printInstallResults 1 + exit 0 +} + +# Finish server setup +printInstallResults 0 + +if($SERVER_ID) { + while($True) { + if(!(gps -Id $SERVER_ID)) { + "server died, exit" + exit 0 + } + + sleep 30 + } +} +`; +} diff --git a/extensions/open-remote-ssh/src/ssh/hostfile.ts b/extensions/open-remote-ssh/src/ssh/hostfile.ts new file mode 100644 index 00000000..51d34a9e --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/hostfile.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import { exists as folderExists } from '../common/files'; + +const PATH_SSH_USER_DIR = path.join(os.homedir(), '.ssh'); +const KNOW_HOST_FILE = path.join(PATH_SSH_USER_DIR, 'known_hosts'); +const HASH_MAGIC = '|1|'; +const HASH_DELIM = '|'; + +export async function checkNewHostInHostkeys(host: string): Promise { + const fileContent = await fs.promises.readFile(KNOW_HOST_FILE, { encoding: 'utf8' }); + const lines = fileContent.split(/\r?\n/); + for (let line of lines) { + line = line.trim(); + if (!line.startsWith(HASH_MAGIC)) { + continue; + } + + const [hostEncripted_] = line.split(' '); + const [salt_, hostHash_] = hostEncripted_.substring(HASH_MAGIC.length).split(HASH_DELIM); + const hostHash = crypto.createHmac('sha1', Buffer.from(salt_, 'base64')).update(host).digest(); + if (hostHash.toString('base64') === hostHash_) { + return false; + } + } + + return true; +} + +export async function addHostToHostFile(host: string, hostKey: Buffer, type: string): Promise { + if (!folderExists(PATH_SSH_USER_DIR)) { + await fs.promises.mkdir(PATH_SSH_USER_DIR, 0o700); + } + + const salt = crypto.randomBytes(20); + const hostHash = crypto.createHmac('sha1', salt).update(host).digest(); + + const entry = `${HASH_MAGIC}${salt.toString('base64')}${HASH_DELIM}${hostHash.toString('base64')} ${type} ${hostKey.toString('base64')}\n`; + await fs.promises.appendFile(KNOW_HOST_FILE, entry); +} diff --git a/extensions/open-remote-ssh/src/ssh/identityFiles.ts b/extensions/open-remote-ssh/src/ssh/identityFiles.ts new file mode 100644 index 00000000..ffb33161 --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/identityFiles.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import type { ParsedKey } from 'ssh2-streams'; +import * as ssh2 from 'ssh2'; +import { untildify, exists as fileExists } from '../common/files'; +import Log from '../common/logger'; + +const homeDir = os.homedir(); +const PATH_SSH_CLIENT_ID_DSA = path.join(homeDir, '.ssh', '/id_dsa'); +const PATH_SSH_CLIENT_ID_ECDSA = path.join(homeDir, '.ssh', '/id_ecdsa'); +const PATH_SSH_CLIENT_ID_RSA = path.join(homeDir, '.ssh', '/id_rsa'); +const PATH_SSH_CLIENT_ID_ED25519 = path.join(homeDir, '.ssh', '/id_ed25519'); +const PATH_SSH_CLIENT_ID_XMSS = path.join(homeDir, '.ssh', '/id_xmss'); +const PATH_SSH_CLIENT_ID_ECDSA_SK = path.join(homeDir, '.ssh', '/id_ecdsa_sk'); +const PATH_SSH_CLIENT_ID_ED25519_SK = path.join(homeDir, '.ssh', '/id_ed25519_sk'); + +const DEFAULT_IDENTITY_FILES: string[] = [ + PATH_SSH_CLIENT_ID_RSA, + PATH_SSH_CLIENT_ID_ECDSA, + PATH_SSH_CLIENT_ID_ECDSA_SK, + PATH_SSH_CLIENT_ID_ED25519, + PATH_SSH_CLIENT_ID_ED25519_SK, + PATH_SSH_CLIENT_ID_XMSS, + PATH_SSH_CLIENT_ID_DSA, +]; + +export interface SSHKey { + filename: string; + parsedKey: ParsedKey; + fingerprint: string; + agentSupport?: boolean; + isPrivate?: boolean; +} + +// From https://github.com/openssh/openssh-portable/blob/acb2059febaddd71ee06c2ebf63dcf211d9ab9f2/sshconnect2.c#L1689-L1690 +export async function gatherIdentityFiles(identityFiles: string[], sshAgentSock: string | undefined, identitiesOnly: boolean, logger: Log) { + identityFiles = identityFiles.map(untildify).map(i => i.replace(/\.pub$/, '')); + if (identityFiles.length === 0) { + identityFiles.push(...DEFAULT_IDENTITY_FILES); + } + + const identityFileContentsResult = await Promise.allSettled(identityFiles.map(async keyPath => { + keyPath = await fileExists(keyPath + '.pub') ? keyPath + '.pub' : keyPath; + return fs.promises.readFile(keyPath); + })); + const fileKeys: SSHKey[] = identityFileContentsResult.map((result, i) => { + if (result.status === 'rejected') { + return undefined; + } + + const parsedResult = ssh2.utils.parseKey(result.value); + if (parsedResult instanceof Error || !parsedResult) { + logger.error(`Error while parsing SSH public key ${identityFiles[i]}:`, parsedResult); + return undefined; + } + + const parsedKey = Array.isArray(parsedResult) ? parsedResult[0] : parsedResult; + const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64'); + + return { + filename: identityFiles[i], + parsedKey, + fingerprint + }; + }).filter((v: T | undefined): v is T => !!v); + + let sshAgentParsedKeys: ParsedKey[] = []; + try { + if (!sshAgentSock) { + throw new Error(`SSH_AUTH_SOCK environment variable not defined`); + } + + sshAgentParsedKeys = await new Promise((resolve, reject) => { + const sshAgent = new ssh2.OpenSSHAgent(sshAgentSock); + sshAgent.getIdentities((err, publicKeys) => { + if (err) { + reject(err); + } else { + resolve(publicKeys || []); + } + }); + }); + } catch (e) { + logger.error(`Couldn't get identities from OpenSSH agent`, e); + } + + const sshAgentKeys: SSHKey[] = sshAgentParsedKeys.map(parsedKey => { + const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64'); + return { + filename: parsedKey.comment, + parsedKey, + fingerprint, + agentSupport: true + }; + }); + + const agentKeys: SSHKey[] = []; + const preferredIdentityKeys: SSHKey[] = []; + for (const agentKey of sshAgentKeys) { + const foundIdx = fileKeys.findIndex(k => agentKey.parsedKey.type === k.parsedKey.type && agentKey.fingerprint === k.fingerprint); + if (foundIdx >= 0) { + preferredIdentityKeys.push({ ...fileKeys[foundIdx], agentSupport: true }); + fileKeys.splice(foundIdx, 1); + } else if (!identitiesOnly) { + agentKeys.push(agentKey); + } + } + preferredIdentityKeys.push(...agentKeys); + preferredIdentityKeys.push(...fileKeys); + + logger.trace(`Identity keys:`, preferredIdentityKeys.length ? preferredIdentityKeys.map(k => `${k.filename} ${k.parsedKey.type} SHA256:${k.fingerprint}`).join('\n') : 'None'); + + return preferredIdentityKeys; +} diff --git a/extensions/open-remote-ssh/src/ssh/sshConfig.ts b/extensions/open-remote-ssh/src/ssh/sshConfig.ts new file mode 100644 index 00000000..6ce6ed32 --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/sshConfig.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import SSHConfig, { Directive, Line, Section } from '@jeanp413/ssh-config'; +import * as vscode from 'vscode'; +import { exists as fileExists, normalizeToSlash, untildify } from '../common/files'; +import { isWindows } from '../common/platform'; +import { glob } from 'glob'; + +const systemSSHConfig = isWindows ? path.resolve(process.env.ALLUSERSPROFILE || 'C:\\ProgramData', 'ssh\\ssh_config') : '/etc/ssh/ssh_config'; +const defaultSSHConfigPath = path.resolve(os.homedir(), '.ssh/config'); + +export function getSSHConfigPath() { + const sshConfigPath = vscode.workspace.getConfiguration('remote.SSH').get('configFile'); + return sshConfigPath ? untildify(sshConfigPath) : defaultSSHConfigPath; +} + +function isDirective(line: Line): line is Directive { + return line.type === SSHConfig.DIRECTIVE; +} + +function isHostSection(line: Line): line is Section { + return isDirective(line) && line.param === 'Host' && !!line.value && !!(line as Section).config; +} + +function isIncludeDirective(line: Line): line is Section { + return isDirective(line) && line.param === 'Include' && !!line.value; +} + +const SSH_CONFIG_PROPERTIES: Record = { + 'host': 'Host', + 'hostname': 'HostName', + 'user': 'User', + 'port': 'Port', + 'identityagent': 'IdentityAgent', + 'identitiesonly': 'IdentitiesOnly', + 'identityfile': 'IdentityFile', + 'forwardagent': 'ForwardAgent', + 'preferredauthentications': 'PreferredAuthentications', + 'proxyjump': 'ProxyJump', + 'proxycommand': 'ProxyCommand', + 'include': 'Include', +}; + +function normalizeProp(prop: Directive) { + prop.param = SSH_CONFIG_PROPERTIES[prop.param.toLowerCase()] || prop.param; +} + +function normalizeSSHConfig(config: SSHConfig) { + for (const line of config) { + if (isDirective(line)) { + normalizeProp(line); + } + if (isHostSection(line)) { + normalizeSSHConfig(line.config); + } + } + return config; +} + +async function parseSSHConfigFromFile(filePath: string, userConfig: boolean) { + let content = ''; + if (await fileExists(filePath)) { + content = (await fs.promises.readFile(filePath, 'utf8')).trim(); + } + const config = normalizeSSHConfig(SSHConfig.parse(content)); + + const includedConfigs: [number, SSHConfig[]][] = []; + for (let i = 0; i < config.length; i++) { + const line = config[i]; + if (isIncludeDirective(line)) { + const values = (line.value as string).split(',').map(s => s.trim()); + const configs: SSHConfig[] = []; + for (const value of values) { + const includePaths = await glob(normalizeToSlash(untildify(value)), { + absolute: true, + cwd: normalizeToSlash(path.dirname(userConfig ? defaultSSHConfigPath : systemSSHConfig)) + }); + for (const p of includePaths) { + configs.push(await parseSSHConfigFromFile(p, userConfig)); + } + } + includedConfigs.push([i, configs]); + } + } + for (const [idx, includeConfigs] of includedConfigs.reverse()) { + config.splice(idx, 1, ...includeConfigs.flat()); + } + + return config; +} + +export default class SSHConfiguration { + + static async loadFromFS(): Promise { + const config = await parseSSHConfigFromFile(getSSHConfigPath(), true); + config.push(...await parseSSHConfigFromFile(systemSSHConfig, false)); + + return new SSHConfiguration(config); + } + + constructor(private sshConfig: SSHConfig) { + } + + getAllConfiguredHosts(): string[] { + const hosts = new Set(); + for (const line of this.sshConfig) { + if (isHostSection(line)) { + const value = Array.isArray(line.value) ? line.value[0] : line.value; + const isPattern = /^!/.test(value) || /[?*]/.test(value); + if (!isPattern) { + hosts.add(value); + } + } + } + + return [...hosts.keys()]; + } + + getHostConfiguration(host: string): Record { + // Only a few directives return an array + // https://github.com/jeanp413/ssh-config/blob/8d187bb8f1d83a51ff2b1d127e6b6269d24092b5/src/ssh-config.ts#L9C1-L9C118 + return this.sshConfig.compute(host) as Record; + } +} diff --git a/extensions/open-remote-ssh/src/ssh/sshConnection.ts b/extensions/open-remote-ssh/src/ssh/sshConnection.ts new file mode 100644 index 00000000..abaf1471 --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/sshConnection.ts @@ -0,0 +1,367 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EventEmitter } from 'events'; +import * as net from 'net'; +import * as fs from 'fs'; +import * as stream from 'stream'; +import { Client, ClientChannel, ClientErrorExtensions, ExecOptions, ShellOptions, ConnectConfig } from 'ssh2'; +import { Server } from 'net'; +import { SocksConnectionInfo, createServer as createSocksServer } from 'simple-socks'; + +export interface SSHConnectConfig extends ConnectConfig { + /** Optional Unique ID attached to ssh connection. */ + uniqueId?: string; + /** Automatic retry to connect, after disconnect. Default true */ + reconnect?: boolean; + /** Number of reconnect retry, after disconnect. Default 10 */ + reconnectTries?: number; + /** Delay after which reconnect should be done. Default 5000ms */ + reconnectDelay?: number; + /** Path to private key */ + identity?: string | Buffer; +} + +export interface SSHTunnelConfig { + /** Remote Address to connect */ + remoteAddr?: string; + /** Local port to bind to. By default, it will bind to a random port, if not passed */ + localPort?: number; + /** Remote Port to connect */ + remotePort?: number; + /** Remote socket path to connect */ + remoteSocketPath?: string; + socks?: boolean; + /** Unique name */ + name?: string; +} + +const defaultOptions: Partial = { + reconnect: false, + port: 22, + reconnectTries: 3, + reconnectDelay: 5000 +}; + +const SSHConstants = { + 'CHANNEL': { + SSH: 'ssh', + TUNNEL: 'tunnel', + X11: 'x11' + }, + 'STATUS': { + BEFORECONNECT: 'beforeconnect', + CONNECT: 'connect', + BEFOREDISCONNECT: 'beforedisconnect', + DISCONNECT: 'disconnect' + } +}; + +export default class SSHConnection extends EventEmitter { + public config: SSHConnectConfig; + + private activeTunnels: { [index: string]: SSHTunnelConfig & { server: Server } } = {}; + private __$connectPromise: Promise | null = null; + private __retries: number = 0; + private __err: Error & ClientErrorExtensions & { code?: string } | null = null; + private sshConnection: Client | null = null; + + constructor(options: SSHConnectConfig) { + super(); + this.config = Object.assign({}, defaultOptions, options); + this.config.uniqueId = this.config.uniqueId || `${this.config.username}@${this.config.host}`; + } + + /** + * Emit message on this channel + */ + override emit(channel: string, status: string, payload?: any): boolean { + super.emit(channel, status, this, payload); + return super.emit(`${channel}:${status}`, this, payload); + } + + /** + * Get shell socket + */ + shell(options: ShellOptions = {}): Promise { + return this.connect().then(() => { + return new Promise((resolve, reject) => { + this.sshConnection!.shell(options, (err, stream) => err ? reject(err) : resolve(stream)); + }); + }); + } + + /** + * Exec a command + */ + exec(cmd: string, params?: Array, options: ExecOptions = {}): Promise<{ stdout: string; stderr: string }> { + cmd += (Array.isArray(params) ? (' ' + params.join(' ')) : ''); + return this.connect().then(() => { + return new Promise((resolve, reject) => { + this.sshConnection!.exec(cmd, options, (err, stream) => { + if (err) { + return reject(err); + } + let stdout = ''; + let stderr = ''; + stream.on('close', function () { + return resolve({ stdout, stderr }); + }).on('data', function (data: Buffer | string) { + stdout += data.toString(); + }).stderr.on('data', function (data: Buffer | string) { + stderr += data.toString(); + }); + }); + }); + }); + } + + /** + * Exec a command + */ + execPartial(cmd: string, tester: (stdout: string, stderr: string) => boolean, params?: Array, options: ExecOptions = {}): Promise<{ stdout: string; stderr: string }> { + cmd += (Array.isArray(params) ? (' ' + params.join(' ')) : ''); + return this.connect().then(() => { + return new Promise((resolve, reject) => { + this.sshConnection!.exec(cmd, options, (err, stream) => { + if (err) { + return reject(err); + } + let stdout = ''; + let stderr = ''; + let resolved = false; + stream.on('close', function () { + if (!resolved) { + return resolve({ stdout, stderr }); + } + }).on('data', function (data: Buffer | string) { + stdout += data.toString(); + + if (tester(stdout, stderr)) { + resolved = true; + + return resolve({ stdout, stderr }); + } + }).stderr.on('data', function (data: Buffer | string) { + stderr += data.toString(); + + if (tester(stdout, stderr)) { + resolved = true; + + return resolve({ stdout, stderr }); + } + }); + }); + }); + }); + } + + /** + * Forward out + */ + forwardOut(srcIP: string, srcPort: number, destIP: string, destPort: number): Promise { + return this.connect().then(() => { + return new Promise((resolve, reject) => { + this.sshConnection!.forwardOut(srcIP, srcPort, destIP, destPort, (err, stream) => { + if (err) { + return reject(err); + } + resolve(stream); + }); + }); + }); + } + + /** + * Get a Socks Port + */ + getSocksPort(localPort: number): Promise { + return this.addTunnel({ name: '__socksServer', socks: true, localPort: localPort }).then((tunnel) => { + return tunnel.localPort!; + }); + } + + /** + * Close SSH Connection + */ + close(): Promise { + this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.BEFOREDISCONNECT); + return this.closeTunnel().then(() => { + if (this.sshConnection) { + this.sshConnection.end(); + this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT); + } + }); + } + + /** + * Connect the SSH Connection + */ + connect(c?: SSHConnectConfig): Promise { + this.config = Object.assign(this.config, c); + ++this.__retries; + + if (this.__$connectPromise) { + return this.__$connectPromise; + } + + this.__$connectPromise = new Promise((resolve, reject) => { + this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.BEFORECONNECT); + if (!this.config || typeof this.config === 'function' || !(this.config.host || this.config.sock) || !this.config.username) { + reject(`Invalid SSH connection configuration host/username can't be empty`); + this.__$connectPromise = null; + return; + } + + if (this.config.identity) { + if (fs.existsSync(this.config.identity)) { + this.config.privateKey = fs.readFileSync(this.config.identity); + } + delete this.config.identity; + } + + //Start ssh server connection + this.sshConnection = new Client(); + this.sshConnection.on('ready', (err: Error & ClientErrorExtensions) => { + if (err) { + this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: err }); + this.__$connectPromise = null; + return reject(err); + } + this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.CONNECT); + this.__retries = 0; + this.__err = null; + resolve(this); + }).on('error', (err) => { + this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: err }); + this.__err = err; + }).on('close', () => { + this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: this.__err }); + if (this.config.reconnect && this.__retries <= this.config.reconnectTries! && this.__err && this.__err.level !== 'client-authentication' && this.__err.code !== 'ENOTFOUND') { + setTimeout(() => { + this.__$connectPromise = null; + resolve(this.connect()); + }, this.config.reconnectDelay); + } else { + reject(this.__err); + } + }).connect(this.config); + }); + return this.__$connectPromise; + } + + /** + * Get existing tunnel by name + */ + getTunnel(name: string) { + return this.activeTunnels[name]; + } + + /** + * Add new tunnel if not exist + */ + addTunnel(SSHTunnelConfig: SSHTunnelConfig): Promise { + SSHTunnelConfig.name = SSHTunnelConfig.name || `${SSHTunnelConfig.remoteAddr}@${SSHTunnelConfig.remotePort || SSHTunnelConfig.remoteSocketPath}`; + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.BEFORECONNECT, { SSHTunnelConfig: SSHTunnelConfig }); + if (this.getTunnel(SSHTunnelConfig.name)) { + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.CONNECT, { SSHTunnelConfig: SSHTunnelConfig }); + return Promise.resolve(this.getTunnel(SSHTunnelConfig.name)); + } else { + return new Promise((resolve, reject) => { + let server: net.Server; + if (SSHTunnelConfig.socks) { + server = createSocksServer({ + connectionFilter: (destination: SocksConnectionInfo, origin: SocksConnectionInfo, callback: (err?: any, dest?: stream.Duplex) => void) => { + this.connect().then(() => { + this.sshConnection!.forwardOut( + origin.address, + origin.port, + destination.address, + destination.port, + (err, stream) => { + if (err) { + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err }); + return callback(err); + } + return callback(null, stream); + }); + }); + } + }).on('proxyError', (err: any) => { + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err }); + }); + } else { + server = net.createServer() + .on('connection', (socket) => { + this.connect().then(() => { + if (SSHTunnelConfig.remotePort) { + this.sshConnection!.forwardOut('127.0.0.1', 0, SSHTunnelConfig.remoteAddr!, SSHTunnelConfig.remotePort!, (err, stream) => { + if (err) { + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err }); + return; + } + stream.pipe(socket); + socket.pipe(stream); + }); + } else { + this.sshConnection!.openssh_forwardOutStreamLocal(SSHTunnelConfig.remoteSocketPath!, (err, stream) => { + if (err) { + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err }); + return; + } + stream.pipe(socket); + socket.pipe(stream); + }); + } + }); + }); + } + + SSHTunnelConfig.localPort = SSHTunnelConfig.localPort || 0; + server.on('listening', () => { + SSHTunnelConfig.localPort = (server.address() as net.AddressInfo).port; + this.activeTunnels[SSHTunnelConfig.name!] = Object.assign({}, { server }, SSHTunnelConfig); + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.CONNECT, { SSHTunnelConfig: SSHTunnelConfig }); + resolve(this.activeTunnels[SSHTunnelConfig.name!]); + }).on('error', (err: any) => { + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err }); + server.close(); + reject(err); + delete this.activeTunnels[SSHTunnelConfig.name!]; + }).listen(SSHTunnelConfig.localPort); + }); + } + } + + /** + * Close the tunnel + */ + closeTunnel(name?: string): Promise { + if (name && this.activeTunnels[name]) { + return new Promise((resolve) => { + const tunnel = this.activeTunnels[name]; + this.emit( + SSHConstants.CHANNEL.TUNNEL, + SSHConstants.STATUS.BEFOREDISCONNECT, + { SSHTunnelConfig: tunnel } + ); + tunnel.server.close(() => { + this.emit( + SSHConstants.CHANNEL.TUNNEL, + SSHConstants.STATUS.DISCONNECT, + { SSHTunnelConfig: this.activeTunnels[name] } + ); + delete this.activeTunnels[name]; + resolve(); + }); + }); + } else if (!name) { + const tunnels = Object.keys(this.activeTunnels).map((key) => this.closeTunnel(key)); + return Promise.all(tunnels).then(() => { }); + } + + return Promise.resolve(); + } +} diff --git a/extensions/open-remote-ssh/src/ssh/sshDestination.ts b/extensions/open-remote-ssh/src/ssh/sshDestination.ts new file mode 100644 index 00000000..db0b4406 --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/sshDestination.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +export default class SSHDestination { + constructor( + public readonly hostname: string, + public readonly user?: string, + public readonly port?: number + ) { + } + + static parse(dest: string): SSHDestination { + let user: string | undefined; + const atPos = dest.lastIndexOf('@'); + if (atPos !== -1) { + user = dest.substring(0, atPos); + } + + let port: number | undefined; + const colonPos = dest.lastIndexOf(':'); + if (colonPos !== -1) { + port = parseInt(dest.substring(colonPos + 1), 10); + } + + const start = atPos !== -1 ? atPos + 1 : 0; + const end = colonPos !== -1 ? colonPos : dest.length; + const hostname = dest.substring(start, end); + + return new SSHDestination(hostname, user, port); + } + + toString(): string { + let result = this.hostname; + if (this.user) { + result = `${this.user}@` + result; + } + if (this.port) { + result = result + `:${this.port}`; + } + return result; + } + + // vscode.uri implementation lowercases the authority, so when reopen or restore + // a remote session from the recently openend list the connection fails + static parseEncoded(dest: string): SSHDestination { + try { + const data = JSON.parse(Buffer.from(dest, 'hex').toString()); + return new SSHDestination(data.hostName, data.user, data.port); + } catch { + } + return SSHDestination.parse(dest.replace(/\\x([0-9a-f]{2})/g, (_, charCode) => String.fromCharCode(parseInt(charCode, 16)))); + } + + toEncodedString(): string { + return this.toString().replace(/[A-Z]/g, (ch) => `\\x${ch.charCodeAt(0).toString(16).toLowerCase()}`); + } +} diff --git a/extensions/open-remote-ssh/tsconfig.json b/extensions/open-remote-ssh/tsconfig.json new file mode 100644 index 00000000..2e08e0d2 --- /dev/null +++ b/extensions/open-remote-ssh/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out", + }, + "include": [ + "src/**/*", + "../../src/vscode-dts/vscode.d.ts", + "../../src/vscode-dts/vscode.proposed.resolvers.d.ts", + "../../src/vscode-dts/vscode.proposed.contribViewsRemote.d.ts", + ] +} diff --git a/package-lock.json b/package-lock.json index bc045891..420ade10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "@xterm/headless": "^5.6.0-beta.64", "@xterm/xterm": "^5.6.0-beta.64", "ajv": "^8.17.1", + "cross-spawn": "^7.0.6", "diff": "^7.0.0", "groq-sdk": "^0.9.0", "http-proxy-agent": "^7.0.0", @@ -56,7 +57,7 @@ "node-pty": "1.1.0-beta21", "ollama": "^0.5.11", "open": "^8.4.2", - "openai": "^4.76.1", + "openai": "^4.85.4", "posthog-node": "^4.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -7116,7 +7117,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -14249,8 +14250,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/isobject": { "version": "3.0.1", @@ -17079,9 +17079,10 @@ } }, "node_modules/openai": { - "version": "4.77.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.77.0.tgz", - "integrity": "sha512-WWacavtns/7pCUkOWvQIjyOfcdr9X+9n9Vvb0zFeKVDAqwCMDHB+iSr24SVaBAhplvSG6JrRXFpcNM9gWhOGIw==", + "version": "4.85.4", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.85.4.tgz", + "integrity": "sha512-Nki51PBSu+Aryo7WKbdXvfm0X/iKkQS2fq3O0Uqb/O3b4exOZFid2te1BZ52bbO5UwxQZ5eeHJDCTqtrJLPw0w==", + "license": "Apache-2.0", "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", @@ -17095,9 +17096,13 @@ "openai": "bin/cli" }, "peerDependencies": { + "ws": "^8.18.0", "zod": "^3.23.8" }, "peerDependenciesMeta": { + "ws": { + "optional": true + }, "zod": { "optional": true } @@ -17536,7 +17541,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -20241,7 +20245,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -20253,7 +20256,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -23733,7 +23735,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, diff --git a/package.json b/package.json index a4ee38bb..9c6a4e88 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "@xterm/headless": "^5.6.0-beta.64", "@xterm/xterm": "^5.6.0-beta.64", "ajv": "^8.17.1", + "cross-spawn": "^7.0.6", "diff": "^7.0.0", "groq-sdk": "^0.9.0", "http-proxy-agent": "^7.0.0", @@ -124,7 +125,7 @@ "node-pty": "1.1.0-beta21", "ollama": "^0.5.11", "open": "^8.4.2", - "openai": "^4.76.1", + "openai": "^4.85.4", "posthog-node": "^4.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts b/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts index 42f4a7a7..4dce7e6c 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts @@ -270,7 +270,7 @@ export class EditorGroupWatermark extends Disposable { const keys3 = this.keybindingService.lookupKeybinding('workbench.action.openGlobalKeybindings'); const button3 = append(recentsBox, $('button')); - button3.textContent = 'Void Settings' + button3.textContent = `Void Settings` button3.style.display = 'block' button3.style.marginLeft = 'auto' button3.style.marginRight = 'auto' diff --git a/src/vs/workbench/contrib/void/browser/MarkerCheckService.ts b/src/vs/workbench/contrib/void/browser/MarkerCheckService.ts new file mode 100644 index 00000000..4ea36c4c --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/MarkerCheckService.ts @@ -0,0 +1,135 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IMarkerService, MarkerSeverity } from '../../../../platform/markers/common/markers.js'; +import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { CodeActionContext, CodeActionTriggerType } from '../../../../editor/common/languages.js'; +import { URI } from '../../../../base/common/uri.js'; + +export interface IMarkerCheckService { + readonly _serviceBrand: undefined; +} + +export const IMarkerCheckService = createDecorator('markerCheckService'); + +class MarkerCheckService extends Disposable implements IMarkerCheckService { + _serviceBrand: undefined; + + constructor( + @IMarkerService private readonly _markerService: IMarkerService, + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @ITextModelService private readonly _textModelService: ITextModelService, + ) { + super(); + + setInterval(async () => { + const allMarkers = this._markerService.read(); + const errors = allMarkers.filter(marker => marker.severity === MarkerSeverity.Error); + + if (errors.length > 0) { + for (const error of errors) { + + console.log(`----------------------------------------------`); + + console.log(`${error.resource.toString()}: ${error.startLineNumber} ${error.message} ${error.severity}`); // ! all errors in the file + + try { + // Get the text model for the file + const modelReference = await this._textModelService.createModelReference(error.resource); + const model = modelReference.object.textEditorModel; + + // Create a range from the marker + const range = new Range( + error.startLineNumber, + error.startColumn, + error.endLineNumber, + error.endColumn + ); + + // Get code action providers for this model + const codeActionProvider = this._languageFeaturesService.codeActionProvider; + const providers = codeActionProvider.ordered(model); + + if (providers.length > 0) { + // Request code actions from each provider + for (const provider of providers) { + const context: CodeActionContext = { + trigger: CodeActionTriggerType.Invoke, // keeping 'trigger' since it works + only: 'quickfix' // adding this to filter for quick fixes + }; + + const actions = await provider.provideCodeActions( + model, + range, + context, + CancellationToken.None + ); + + if (actions?.actions?.length) { + + const quickFixes = actions.actions.filter(action => action.isPreferred); // ! all quickFixes for the error + const quickFixesForImports = actions.actions.filter(action => action.isPreferred && action.title.includes('import')); // ! all possible imports + quickFixesForImports + + if (quickFixes.length > 0) { + console.log('Available Quick Fixes:'); + quickFixes.forEach(action => { + console.log(`- ${action.title}`); + }); + } + } + } + } + + // Dispose the model reference + modelReference.dispose(); + } catch (e) { + console.error('Error getting quick fixes:', e); + } + } + } + }, 5000); + } + + + + + fixErrorsInFiles(uris: URI[], contextSoFar: []) { + // const allMarkers = this._markerService.read(); + + + // check errors in files + + + // give LLM errors in files + + + + } + + // private _onMarkersChanged = (changedResources: readonly URI[]): void => { + // for (const resource of changedResources) { + // const markers = this._markerService.read({ resource }); + + // if (markers.length === 0) { + // console.log(`${resource.toString()}: No diagnostics`); + // continue; + // } + + // console.log(`Diagnostics for ${resource.toString()}:`); + // markers.forEach(marker => this._logMarker(marker)); + // } + // }; + + +} + +registerSingleton(IMarkerCheckService, MarkerCheckService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/browser/aiRegexService.ts b/src/vs/workbench/contrib/void/browser/aiRegexService.ts new file mode 100644 index 00000000..f38236a9 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/aiRegexService.ts @@ -0,0 +1,187 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +// import { URI } from '../../../../base/common/uri.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +// import { IToolService, ToolService } from '../common/toolsService.js'; + + + +export type ChatMessageLocation = { + threadId: string; + messageIdx: number; +} + + +export type SearchAndReplaceBlock = { + search: string; + replace: string; +} + +// service that manages state +export type ApplyState = { + [applyBoxId: string]: { + searchAndReplaceBlocks: SearchAndReplaceBlock; + } +} + +// the purpose of this service is to generate search and replace blocks for a given codeblock `codeblockId` and on a file `fileName` and version `fileVersion` + +export interface IFastApplyService { + readonly _serviceBrand: undefined; + + // readonly state: ApplyState; // readonly to the user + // setState(newState: Partial): void; + // onDidChangeState: Event; +} + +export const IVoidFastApplyService = createDecorator('voidFastApplyService'); +class VoidFastApplyService extends Disposable implements IFastApplyService { + _serviceBrand: undefined; + + // static readonly ID = 'voidFastApplyService'; + + private readonly _onDidChangeState = new Emitter(); + readonly onDidChangeState: Event = this._onDidChangeState.event; + + + // state + // state: ApplyState + + constructor( + // @IToolService private readonly toolService: ToolService + ) { + super() + + // initial state + // this.state = { currentUri: undefined } + } + + setState(newState: Partial) { + + // this.state = { ...this.state, ...newState } + this._onDidChangeState.fire() + } + + aiSearch(searchStr: string) { + + } + + aiReplace(searchStr: string, replaceStr: string) { + + } + + + // 1. search(ai) + // - tool use to find all possible changes + // - if search only: is this file related to the search? + // - if search + replace: should I modify this file? + // 2. replace(ai) + // - what changes to make? + // 3. postprocess errors + // -fastapply changes simultaneously + // -iterate on syntax errors (all files can be changed from a syntax error, not just the one with the error) + + + // private async _searchUsingAI({ searchClause }: { searchClause: string }) { + + // // const relevantURIs: URI[] = [] + // // const gatherPrompt = `\ + // // asdasdas + // // ` + // // const filterPrompt = `\ + // // Is this file relevant? + // // ` + + + // // // optimizations (DO THESE LATER!!!!!!) + // // // if tool includes a uri in uriSet, skip it obviously + // // let uriSet = new Set() + // // // gather + // // let messages = [] + // // while (true) { + // // const result = await new Promise((res, rej) => { + // // sendLLMMessage({ + // // messages, + // // tools: ['search'], + // // onFinalMessage: ({ result: r, }) => { + // // res(r) + // // }, + // // onError: (error) => { + // // rej(error) + // // } + // // }) + // // }) + + // // messages.push({ role: 'tool', content: turnToString(result) }) + + // // sendLLMMessage({ + // // messages: { 'Output ': result }, + // // onFinalMessage: (r) => { + // // // output is file1\nfile2\nfile3\n... + // // } + // // }) + + // // uriSet.add(...) + // // } + + // // // writes + // // if (!replaceClause) return + + // // for (const uri of uriSet) { + // // // in future, batch these + // // applyWorkflow({ uri, applyStr: replaceClause }) + // // } + + + + + + + // // while (true) { + // // const result = new Promise((res, rej) => { + // // sendLLMMessage({ + // // messages, + // // tools: ['search'], + // // onResult: (r) => { + // // res(r) + // // } + // // }) + // // }) + + // // messages.push(result) + + // // } + + + // } + + + // private async _replaceUsingAI({ searchClause, replaceClause, relevantURIs }: { searchClause: string, replaceClause: string, relevantURIs: URI[] }) { + + // for (const uri of relevantURIs) { + + // uri + + // } + + + + // // should I change this file? + // // if so what changes to make? + + + + // // fast apply the changes + // } + + + +} + +registerSingleton(IVoidFastApplyService, VoidFastApplyService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/browser/autocompleteService.ts b/src/vs/workbench/contrib/void/browser/autocompleteService.ts index aa8902f3..f9cbec7b 100644 --- a/src/vs/workbench/contrib/void/browser/autocompleteService.ts +++ b/src/vs/workbench/contrib/void/browser/autocompleteService.ts @@ -16,9 +16,9 @@ import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { EditorResourceAccessor } from '../../../common/editor.js'; import { IModelService } from '../../../../editor/common/services/model.js'; import { extractCodeFromRegular } from './helpers/extractCodeFromResult.js'; -import { isWindows } from '../../../../base/common/platform.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { ILLMMessageService } from '../common/llmMessageService.js'; +import { _ln, allLinebreakSymbols } from '../common/voidFileService.js'; // import { IContextGatheringService } from './contextGatheringService.js'; // The extension this was called from is here - https://github.com/voideditor/void/blob/autocomplete/extensions/void/src/extension/extension.ts @@ -415,9 +415,6 @@ const toInlineCompletions = ({ autocompletionMatchup, autocompletion, prefixAndS // } -const allLinebreakSymbols = ['\r\n', '\n'] -const _ln = isWindows ? allLinebreakSymbols[0] : allLinebreakSymbols[1] - type PrefixAndSuffixInfo = { prefix: string, suffix: string, prefixLines: string[], suffixLines: string[], prefixToTheLeftOfCursor: string, suffixToTheRightOfCursor: string } const getPrefixAndSuffixInfo = (model: ITextModel, position: Position): PrefixAndSuffixInfo => { @@ -798,26 +795,27 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ }, useProviderFor: 'Autocomplete', logging: { loggingName: 'Autocomplete' }, - onText: async ({ fullText, newText }) => { + onText: () => { }, // unused in FIMMessage + // onText: async ({ fullText, newText }) => { - newAutocompletion.insertText = fullText + // newAutocompletion.insertText = fullText - // count newlines in newText - const numNewlines = newText.match(/\n|\r\n/g)?.length || 0 - newAutocompletion._newlineCount += numNewlines + // // count newlines in newText + // const numNewlines = newText.match(/\n|\r\n/g)?.length || 0 + // newAutocompletion._newlineCount += numNewlines - // if too many newlines, resolve up to last newline - if (newAutocompletion._newlineCount > 10) { - const lastNewlinePos = fullText.lastIndexOf('\n') - newAutocompletion.insertText = fullText.substring(0, lastNewlinePos) - resolve(newAutocompletion.insertText) - return - } + // // if too many newlines, resolve up to last newline + // if (newAutocompletion._newlineCount > 10) { + // const lastNewlinePos = fullText.lastIndexOf('\n') + // newAutocompletion.insertText = fullText.substring(0, lastNewlinePos) + // resolve(newAutocompletion.insertText) + // return + // } - // if (!getAutocompletionMatchup({ prefix: this._lastPrefix, autocompletion: newAutocompletion })) { - // reject('LLM response did not match user\'s text.') - // } - }, + // // if (!getAutocompletionMatchup({ prefix: this._lastPrefix, autocompletion: newAutocompletion })) { + // // reject('LLM response did not match user\'s text.') + // // } + // }, onFinalMessage: ({ fullText }) => { // console.log('____res: ', JSON.stringify(newAutocompletion.insertText)) diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index a3452eb2..d92fb772 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -12,8 +12,23 @@ import { URI } from '../../../../base/common/uri.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IRange } from '../../../../editor/common/core/range.js'; import { ILLMMessageService } from '../common/llmMessageService.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; -import { chat_userMessage, chat_systemMessage } from './prompt/prompts.js'; +import { chat_userMessageContent, chat_systemMessage, chat_userMessageContentWithAllFilesToo as chat_userMessageContentWithAllFiles, chat_selectionsString } from './prompt/prompts.js'; +import { InternalToolInfo, IToolsService, ToolCallReturnType, ToolFns, ToolName, voidTools } from '../common/toolsService.js'; +import { toLLMChatMessage } from '../common/llmMessageTypes.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IVoidFileService } from '../common/voidFileService.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; + + +const findLastIndex = (arr: T[], condition: (t: T) => boolean): number => { + for (let i = arr.length - 1; i >= 0; i--) { + if (condition(arr[i])) { + return i; + } + } + return -1; +} + // one of the square items that indicates a selection in a chat bubble (NOT a file, a Selection of text) export type CodeSelection = { @@ -33,33 +48,45 @@ export type FileSelection = { export type StagingSelectionItem = CodeSelection | FileSelection -export type StagingInfo = { - isBeingEdited: boolean; - selections: StagingSelectionItem[] | null; // staging selections in edit mode +export type ToolMessage = { + role: 'tool'; + name: T; // internal use + params: string; // internal use + id: string; // apis require this tool use id + content: string; // result + result: ToolCallReturnType[T]; // text message of result } -const defaultStaging: StagingInfo = { isBeingEdited: false, selections: [] } - // WARNING: changing this format is a big deal!!!!!! need to migrate old format to new format on users' computers so people don't get errors. export type ChatMessage = | { + role: 'system'; + content: string; + displayContent?: undefined; + } | { role: 'user'; - content: string | null; // content sent to the llm - allowed to be '', will be replaced with (empty) + content: string | null; // content displayed to the LLM on future calls - allowed to be '', will be replaced with (empty) displayContent: string | null; // content displayed to user - allowed to be '', will be ignored selections: StagingSelectionItem[] | null; // the user's selection - staging: StagingInfo | null - } - | { + state: { + stagingSelections: StagingSelectionItem[]; + isBeingEdited: boolean; + } + } | { role: 'assistant'; content: string | null; // content received from LLM - allowed to be '', will be replaced with (empty) displayContent: string | null; // content displayed to user (this is the same as content for now) - allowed to be '', will be ignored } - | { - role: 'system'; - content: string; - displayContent?: undefined; - } + | ToolMessage + +type UserMessageType = ChatMessage & { role: 'user' } +type UserMessageState = UserMessageType['state'] + +export const defaultMessageState: UserMessageState = { + stagingSelections: [], + isBeingEdited: false, +} // a 'thread' means a chat message history export type ChatThreads = { @@ -68,11 +95,18 @@ export type ChatThreads = { createdAt: string; // ISO string lastModified: string; // ISO string messages: ChatMessage[]; - staging: StagingInfo | null; - focusedMessageIdx?: number | undefined; // index of the message that is being edited (undefined if none) + state: { + stagingSelections: StagingSelectionItem[]; + focusedMessageIdx: number | undefined; // index of the message that is being edited (undefined if none) + isCheckedOfSelectionId: { [selectionId: string]: boolean }; + } }; } +type ThreadType = ChatThreads[string] + +const defaultThreadState: ThreadType['state'] = { stagingSelections: [], focusedMessageIdx: undefined, isCheckedOfSelectionId: {} } + export type ThreadsState = { allThreads: ChatThreads; currentThreadId: string; // intended for internal use only @@ -90,23 +124,26 @@ export type ThreadStreamState = { const newThreadObject = () => { const now = new Date().toISOString() return { - id: new Date().getTime().toString(), + id: generateUuid(), createdAt: now, lastModified: now, messages: [], - focusedMessageIdx: undefined, - staging: { - isBeingEdited: true, - selections: [], - } + state: { + stagingSelections: [], + focusedMessageIdx: undefined, + isCheckedOfSelectionId: {} + }, + } satisfies ChatThreads[string] } const THREAD_VERSION_KEY = 'void.chatThreadVersion' -const THREAD_VERSION = 'v2' +const LATEST_THREAD_VERSION = 'v2' const THREAD_STORAGE_KEY = 'void.chatThreadStorage' + +type ChatMode = 'agent' | 'chat' export interface IChatThreadService { readonly _serviceBrand: undefined; @@ -120,14 +157,25 @@ export interface IChatThreadService { openNewThread(): void; switchToThread(threadId: string): void; + // you can edit multiple messages + // the one you're currently editing is "focused", and we add items to that one when you press cmd+L. getFocusedMessageIdx(): number | undefined; isFocusingMessage(): boolean; setFocusedMessageIdx(messageIdx: number | undefined): void; - _useFocusedStagingState(messageIdx?: number | undefined): readonly [StagingInfo, (stagingInfo: StagingInfo) => void]; + // exposed getters/setters + getCurrentMessageState: (messageIdx: number) => UserMessageState + setCurrentMessageState: (messageIdx: number, newState: Partial) => void + getCurrentThreadStagingSelections: () => StagingSelectionItem[] + setCurrentThreadStagingSelections: (stagingSelections: StagingSelectionItem[]) => void + + + // call to edit a message + editUserMessageAndStreamResponse({ userMessage, chatMode, messageIdx }: { userMessage: string, chatMode: ChatMode, messageIdx: number }): Promise; + + // call to add a message + addUserMessageAndStreamResponse({ userMessage, chatMode }: { userMessage: string, chatMode: ChatMode }): Promise; - editUserMessageAndStreamResponse(userMessage: string, messageIdx: number): Promise; - addUserMessageAndStreamResponse(userMessage: string): Promise; cancelStreaming(threadId: string): void; dismissStreamError(threadId: string): void; @@ -149,85 +197,83 @@ class ChatThreadService extends Disposable implements IChatThreadService { constructor( @IStorageService private readonly _storageService: IStorageService, - @IModelService private readonly _modelService: IModelService, + @IVoidFileService private readonly _voidFileService: IVoidFileService, @ILLMMessageService private readonly _llmMessageService: ILLMMessageService, + @IToolsService private readonly _toolsService: IToolsService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, ) { super() + const oldVersionNum = this._storageService.get(THREAD_VERSION_KEY, StorageScope.APPLICATION) + + + const readThreads = this._readAllThreads() + const updatedThreads = this._updatedThreadsToVersion(readThreads, oldVersionNum) + + if (updatedThreads !== null) { + this._storeAllThreads(updatedThreads) + } + + const allThreads = updatedThreads ?? readThreads this.state = { - allThreads: this._readAllThreads(), + allThreads: allThreads, currentThreadId: null as unknown as string, // gets set in startNewThread() } // always be in a thread this.openNewThread() - // for now just write the version, anticipating bigger changes in the future where we'll want to access this - this._storageService.store(THREAD_VERSION_KEY, THREAD_VERSION, StorageScope.APPLICATION, StorageTarget.USER) + this._storageService.store(THREAD_VERSION_KEY, LATEST_THREAD_VERSION, StorageScope.APPLICATION, StorageTarget.USER) } private _readAllThreads(): ChatThreads { - // PUT ANY VERSION CHANGE FORMAT CONVERSION CODE HERE - // CAN ADD "v0" TAG IN STORAGE AND CONVERT - - const threadsStr = this._storageService.get(THREAD_STORAGE_KEY, StorageScope.APPLICATION) - const threads: ChatThreads = threadsStr ? JSON.parse(threadsStr) : {} - this._updateThreadsToVersion(threads, THREAD_VERSION) - return threads } - private _updateThreadsToVersion(oldThreadsObject: any, toVersion: string) { + // returns if should update + private _updatedThreadsToVersion(oldThreadsObject: any, oldVersion: string | undefined): ChatThreads | null { - if (toVersion === 'v2') { + if (!oldVersion) { - const threads: ChatThreads = oldThreadsObject - - /** v1 -> v2 - - threadsState.currentStagingSelections: CodeStagingSelection[] | null; - + thread.staging: StagingInfo - + thread.focusedMessageIdx?: number | undefined; + // unknown, just reset chat? + return null + } + /** v1 -> v2 + - threads.state.currentStagingSelections: CodeStagingSelection[] | null; + + thread[threadIdx].state + + message.state + chatMessage.staging: StagingInfo | null - */ - - // check if we need to update - let shouldUpdate = false - for (const thread of Object.values(threads)) { - if (!thread.staging) { - shouldUpdate = true - } - for (const chatMessage of Object.values(thread.messages)) { - if (chatMessage.role === 'user' && !chatMessage.staging) { - shouldUpdate = true - } - } - } - - if (!shouldUpdate) return; - + */ + else if (oldVersion === 'v1') { + const threads = oldThreadsObject as Omit // update the threads for (const thread of Object.values(threads)) { - if (!thread.staging) { - thread.staging = defaultStaging - thread.focusedMessageIdx = undefined + if (!thread.state) { + thread.state = defaultThreadState } for (const chatMessage of Object.values(thread.messages)) { - if (chatMessage.role === 'user' && !chatMessage.staging) { - chatMessage.staging = defaultStaging + if (chatMessage.role === 'user' && !chatMessage.state) { + chatMessage.state = defaultMessageState } } } // push the update - this._storeAllThreads(threads) + return threads } + else if (oldVersion === 'v2') { + return null + } + + // up to date + return null } @@ -245,6 +291,17 @@ class ChatThreadService extends Disposable implements IChatThreadService { this._onDidChangeCurrentThread.fire() } + private _getAllSelections() { + const thread = this.getCurrentThread() + return thread.messages.flatMap(m => m.role === 'user' && m.selections || []) + } + + private _getSelectionsUpToMessageIdx(messageIdx: number) { + const thread = this.getCurrentThread() + const prevMessages = thread.messages.slice(0, messageIdx) + return prevMessages.flatMap(m => m.role === 'user' && m.selections || []) + } + private _setStreamState(threadId: string, state: Partial>) { this.streamState[threadId] = { ...this.streamState[threadId], @@ -256,24 +313,27 @@ class ChatThreadService extends Disposable implements IChatThreadService { // ---------- streaming ---------- - finishStreaming = (threadId: string, content: string, error?: { message: string, fullError: Error | null }) => { + private _finishStreamingTextMessage = (threadId: string, content: string, error?: { message: string, fullError: Error | null }) => { // add assistant's message to chat history, and clear selection - const assistantHistoryElt: ChatMessage = { role: 'assistant', content, displayContent: content || null } - this._addMessageToThread(threadId, assistantHistoryElt) + this._addMessageToThread(threadId, { role: 'assistant', content, displayContent: content || null }) this._setStreamState(threadId, { messageSoFar: undefined, streamingToken: undefined, error }) } - async editUserMessageAndStreamResponse(userMessage: string, messageIdx: number) { + + + async editUserMessageAndStreamResponse({ userMessage, chatMode, messageIdx }: { userMessage: string, chatMode: ChatMode, messageIdx: number }) { const thread = this.getCurrentThread() - const messageToReplace = thread.messages[messageIdx] - if (messageToReplace?.role !== 'user') { - console.log(`Error: tried to edit non-user message. messageIdx=${messageIdx}, numMessages=${thread.messages.length}`) - return + if (thread.messages?.[messageIdx]?.role !== 'user') { + throw new Error("Error: editing a message with role !=='user'") } + // get prev and curr selections before clearing the message + const prevSelns = this._getSelectionsUpToMessageIdx(messageIdx) + const currSelns = thread.messages[messageIdx].selections || [] + // clear messages up to the index const slicedMessages = thread.messages.slice(0, messageIdx) this._setState({ @@ -286,58 +346,137 @@ class ChatThreadService extends Disposable implements IChatThreadService { } }, true) - // stream the edit - this.addUserMessageAndStreamResponse(userMessage, messageToReplace.staging) + // re-add the message and stream it + this.addUserMessageAndStreamResponse({ userMessage, chatMode, chatSelections: { prevSelns, currSelns } }) } - async addUserMessageAndStreamResponse(userMessage: string, stagingOverride?: StagingInfo | null) { + async addUserMessageAndStreamResponse({ userMessage, chatMode, chatSelections }: { userMessage: string, chatMode: ChatMode, chatSelections?: { prevSelns?: StagingSelectionItem[], currSelns?: StagingSelectionItem[] } }) { + const thread = this.getCurrentThread() const threadId = thread.id - let threadStaging = thread.staging - - const currStaging = stagingOverride ?? threadStaging ?? defaultStaging // don't use _useFocusedStagingState to avoid race conditions with focusing - const { selections: currSelns, } = currStaging + // selections in all past chats, then in current chat (can have many duplicates here) + const prevSelns: StagingSelectionItem[] = chatSelections?.prevSelns ?? this._getAllSelections() + const currSelns: StagingSelectionItem[] = chatSelections?.currSelns ?? thread.state.stagingSelections // add user's message to chat history const instructions = userMessage - const content = await chat_userMessage(instructions, currSelns, this._modelService) - const userHistoryElt: ChatMessage = { role: 'user', content: content, displayContent: instructions, selections: currSelns, staging: null, } + const userMessageContent = await chat_userMessageContent(instructions, currSelns) + const selectionsStr = await chat_selectionsString(prevSelns, currSelns, this._voidFileService) + const userMessageFullContent = chat_userMessageContentWithAllFiles(userMessageContent, selectionsStr) + + const userHistoryElt: ChatMessage = { role: 'user', content: userMessageContent, displayContent: instructions, selections: currSelns, state: defaultMessageState } this._addMessageToThread(threadId, userHistoryElt) this._setStreamState(threadId, { error: undefined }) - const llmCancelToken = this._llmMessageService.sendLLMMessage({ - messagesType: 'chatMessages', - logging: { loggingName: 'Chat' }, - useProviderFor: 'Ctrl+L', - messages: [ - { role: 'system', content: chat_systemMessage }, - ...this.getCurrentThread().messages.map(m => ({ role: m.role, content: m.content || '(empty model output)' })), - ], - onText: ({ newText, fullText }) => { - this._setStreamState(threadId, { messageSoFar: fullText }) - }, - onFinalMessage: ({ fullText: content }) => { - this.finishStreaming(threadId, content) - }, - onError: (error) => { - this.finishStreaming(threadId, this.streamState[threadId]?.messageSoFar ?? '', error) - }, - }) - if (llmCancelToken === null) return - this._setStreamState(threadId, { streamingToken: llmCancelToken }) + const tools: InternalToolInfo[] | undefined = ( + chatMode === 'chat' ? undefined + : chatMode === 'agent' ? Object.keys(voidTools).map(toolName => voidTools[toolName as ToolName]) + : undefined) + + // agent loop + const agentLoop = async () => { + + let shouldSendAnotherMessage = true + let nMessagesSent = 0 + + while (shouldSendAnotherMessage) { + shouldSendAnotherMessage = false + nMessagesSent += 1 + + let res_: () => void + const awaitable = new Promise((res, rej) => { res_ = res }) + + // replace last userMessage with userMessageFullContent (which contains all the files too) + const messages_ = this.getCurrentThread().messages.map(m => (toLLMChatMessage(m))) + const lastUserMsgIdx = findLastIndex(messages_, m => m.role === 'user') + let messages = messages_ + if (lastUserMsgIdx !== -1) { // should never be -1 + messages = [ + ...messages.slice(0, lastUserMsgIdx), + { role: 'user', content: userMessageFullContent }, + ...messages.slice(lastUserMsgIdx + 1, Infinity)] + } + + const llmCancelToken = this._llmMessageService.sendLLMMessage({ + messagesType: 'chatMessages', + useProviderFor: 'Ctrl+L', + logging: { loggingName: `Agent` }, + messages: [ + { role: 'system', content: chat_systemMessage(this._workspaceContextService.getWorkspace().folders.map(f => f.uri.fsPath)) }, + ...messages, + ], + + tools: tools, + + onText: ({ fullText }) => { + this._setStreamState(threadId, { messageSoFar: fullText }) + }, + onFinalMessage: async ({ fullText, toolCalls }) => { + + if ((toolCalls?.length ?? 0) === 0) { + this._finishStreamingTextMessage(threadId, fullText) + } + else { + this._addMessageToThread(threadId, { role: 'assistant', content: fullText, displayContent: fullText }) + this._setStreamState(threadId, { messageSoFar: undefined }) // clear streaming message + for (const tool of toolCalls ?? []) { + const toolName = tool.name as ToolName + + // 1. + let toolResult: Awaited> + let toolResultVal: ToolCallReturnType[ToolName] + try { + toolResult = await this._toolsService.toolFns[toolName](tool.params) + toolResultVal = toolResult + } catch (error) { + this._setStreamState(threadId, { error }) + shouldSendAnotherMessage = false + break + } + + // 2. + let toolResultStr: string + try { + toolResultStr = this._toolsService.toolResultToString[toolName](toolResult as any) // typescript is so bad it doesn't even couple the type of ToolResult with the type of the function being called here + } catch (error) { + this._setStreamState(threadId, { error }) + shouldSendAnotherMessage = false + break + } + + this._addMessageToThread(threadId, { role: 'tool', name: toolName, params: tool.params, id: tool.id, content: toolResultStr, result: toolResultVal, }) + shouldSendAnotherMessage = true + } + + } + res_() + }, + onError: (error) => { + this._finishStreamingTextMessage(threadId, this.streamState[threadId]?.messageSoFar ?? '', error) + res_() + }, + }) + if (llmCancelToken === null) break + this._setStreamState(threadId, { streamingToken: llmCancelToken }) + + await awaitable + } + } + + agentLoop() // DO NOT AWAIT THIS, this fn should resolve when ready to clear inputs } cancelStreaming(threadId: string) { const llmCancelToken = this.streamState[threadId]?.streamingToken if (llmCancelToken !== undefined) this._llmMessageService.abort(llmCancelToken) - this.finishStreaming(threadId, this.streamState[threadId]?.messageSoFar ?? '') + this._finishStreamingTextMessage(threadId, this.streamState[threadId]?.messageSoFar ?? '') } dismissStreamError(threadId: string): void { @@ -357,13 +496,13 @@ class ChatThreadService extends Disposable implements IChatThreadService { const thread = this.getCurrentThread() // get the focusedMessageIdx - const focusedMessageIdx = thread.focusedMessageIdx + const focusedMessageIdx = thread.state.focusedMessageIdx if (focusedMessageIdx === undefined) return; // check that the message is actually being edited const focusedMessage = thread.messages[focusedMessageIdx] if (focusedMessage.role !== 'user') return; - if (!focusedMessage.staging?.isBeingEdited) return; + if (!focusedMessage.state) return; return focusedMessageIdx } @@ -429,28 +568,34 @@ class ChatThreadService extends Disposable implements IChatThreadService { ...this.state.allThreads, [threadId]: { ...thread, - focusedMessageIdx: messageIdx + state: { + ...thread.state, + focusedMessageIdx: messageIdx, + } } } }, true) } - // set thread.messages[messageIdx].stagingSelections - private setEditMessageStaging(staging: StagingInfo, messageIdx: number): void { + // set message.state + private _setCurrentMessageState(state: Partial, messageIdx: number): void { - const thread = this.getCurrentThread() - const message = thread.messages[messageIdx] - if (message.role !== 'user') return; + const threadId = this.state.currentThreadId + const thread = this.state.allThreads[threadId] + if (!thread) return this._setState({ allThreads: { ...this.state.allThreads, - [thread.id]: { + [threadId]: { ...thread, messages: thread.messages.map((m, i) => - i === messageIdx ? { + i === messageIdx && m.role === 'user' ? { ...m, - staging, + state: { + ...m.state, + ...state + }, } : m ) } @@ -459,50 +604,50 @@ class ChatThreadService extends Disposable implements IChatThreadService { } - // set thread.stagingSelections - private setDefaultStaging(staging: StagingInfo): void { + // set thread.state + private _setCurrentThreadState(state: Partial): void { - const thread = this.getCurrentThread() + const threadId = this.state.currentThreadId + const thread = this.state.allThreads[threadId] + if (!thread) return this._setState({ allThreads: { ...this.state.allThreads, [thread.id]: { ...thread, - staging, + state: { + ...thread.state, + ...state + } } } }, true) } - // gets `staging` and `setStaging` of the currently focused element, given the index of the currently selected message (or undefined if no message is selected) - _useFocusedStagingState(messageIdx?: number | undefined) { - - const defaultStaging = { isBeingEdited: false, selections: [], text: '' } - - let staging: StagingInfo = defaultStaging - let setStaging: (selections: StagingInfo) => void = () => { } - - const thread = this.getCurrentThread() - const isFocusingMessage = messageIdx !== undefined - if (isFocusingMessage) { // is editing message - - const message = thread.messages[messageIdx!] - if (message.role === 'user') { - staging = message.staging || defaultStaging - setStaging = (s) => this.setEditMessageStaging(s, messageIdx) - } - - } - else { // is editing the default input box - staging = thread.staging || defaultStaging - setStaging = this.setDefaultStaging.bind(this) - } - - return [staging, setStaging] as const + getCurrentThreadStagingSelections = () => { + return this.getCurrentThread().state.stagingSelections } + setCurrentThreadStagingSelections = (stagingSelections: StagingSelectionItem[]) => { + this._setCurrentThreadState({ stagingSelections }) + } + + // gets `staging` and `setStaging` of the currently focused element, given the index of the currently selected message (or undefined if no message is selected) + + getCurrentMessageState(messageIdx: number): UserMessageState { + const currMessage = this.getCurrentThread()?.messages?.[messageIdx] + if (!currMessage || currMessage.role !== 'user') return defaultMessageState + return currMessage.state + } + setCurrentMessageState(messageIdx: number, newState: Partial) { + const currMessage = this.getCurrentThread()?.messages?.[messageIdx] + if (!currMessage || currMessage.role !== 'user') return + this._setCurrentMessageState(newState, messageIdx) + } + + } diff --git a/src/vs/workbench/contrib/void/browser/inlineDiffsService.ts b/src/vs/workbench/contrib/void/browser/editCodeService.ts similarity index 72% rename from src/vs/workbench/contrib/void/browser/inlineDiffsService.ts rename to src/vs/workbench/contrib/void/browser/editCodeService.ts index ed435a26..0f92d4ef 100644 --- a/src/vs/workbench/contrib/void/browser/inlineDiffsService.ts +++ b/src/vs/workbench/contrib/void/browser/editCodeService.ts @@ -25,23 +25,23 @@ import * as dom from '../../../../base/browser/dom.js'; import { Widget } from '../../../../base/browser/ui/widget.js'; import { URI } from '../../../../base/common/uri.js'; import { IConsistentEditorItemService, IConsistentItemService } from './helperServices/consistentItemService.js'; -import { voidPrefixAndSuffix, ctrlKStream_userMessage, ctrlKStream_systemMessage, fastApply_rewritewholething_userMessage, fastApply_rewritewholething_systemMessage, defaultQuickEditFimTags, tripleTick } from './prompt/prompts.js'; +import { voidPrefixAndSuffix, ctrlKStream_userMessage, ctrlKStream_systemMessage, defaultQuickEditFimTags, rewriteCode_systemMessage, rewriteCode_userMessage, searchReplace_systemMessage, searchReplace_userMessage, } from './prompt/prompts.js'; -import { mountCtrlK } from '../browser/react/out/quick-edit-tsx/index.js' +import { mountCtrlK } from './react/out/quick-edit-tsx/index.js' import { QuickEditPropsType } from './quickEditActions.js'; import { IModelContentChangedEvent } from '../../../../editor/common/textModelEvents.js'; -import { extractCodeFromFIM, extractCodeFromRegular } from './helpers/extractCodeFromResult.js'; +import { extractCodeFromFIM, extractCodeFromRegular, ExtractedSearchReplaceBlock, extractSearchReplaceBlocks } from './helpers/extractCodeFromResult.js'; import { filenameToVscodeLanguage } from './helpers/detectLanguage.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; -import { Emitter } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { VOID_OPEN_SETTINGS_ACTION_ID } from './voidSettingsPane.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ILLMMessageService } from '../common/llmMessageService.js'; -import { LLMChatMessage, _InternalLLMChatMessage, errorDetails } from '../common/llmMessageTypes.js'; +import { LLMChatMessage, OnError, errorDetails } from '../common/llmMessageTypes.js'; import { IMetricsService } from '../common/metricsService.js'; -import { VSReadFile } from './helpers/readFile.js'; +import { IVoidFileService } from '../common/voidFileService.js'; const configOfBG = (color: Color) => { return { dark: color, light: color, hcDark: color, hcLight: color, } @@ -103,6 +103,27 @@ const getLeadingWhitespacePx = (editor: ICodeEditor, startLine: number): number +// finds block.orig in fileContents and return its range in file +// startingAtLine is 1-indexed and inclusive +const findTextInCode = (text: string, fileContents: string, startingAtLine?: number) => { + const idx = fileContents.indexOf(text, + startingAtLine !== undefined ? + fileContents.split('\n').slice(0, startingAtLine).join('\n').length // num characters in all lines before startingAtLine + : 0 + ) + if (idx === -1) return 'Not found' as const + const lastIdx = fileContents.lastIndexOf(text) + if (lastIdx !== idx) return 'Not unique' as const + const startLine = fileContents.substring(0, idx).split('\n').length + const numLines = text.split('\n').length + const endLine = startLine + numLines - 1 + return [startLine, endLine] as const +} + + +export type URIStreamState = 'idle' | 'acceptRejectAll' | 'streaming' + + export type StartApplyingOpts = { from: 'QuickEdit'; type: 'rewrite'; @@ -114,6 +135,7 @@ export type StartApplyingOpts = { } + export type AddCtrlKOpts = { startLine: number, endLine: number, @@ -121,7 +143,7 @@ export type AddCtrlKOpts = { } // // TODO diffArea should be removed if we just discovered it has no more diffs in it -// for (const diffareaid of this.diffAreasOfURI[uri.fsPath]) { +// for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { // const diffArea = this.diffAreaOfId[diffareaid] // if (Object.keys(diffArea._diffOfId).length === 0 && !diffArea._sweepState.isStreaming) { // const { onFinishEdit } = this._addToHistory(uri) @@ -138,12 +160,6 @@ export type Diff = { -type ExtractedCodeBlock = { - state: 'writingOriginal' | 'writingFinal' | 'done', - orig: string, - final: string, -} - // _ means anything we don't include if we clone it // DiffArea.originalStartLine is the line in originalCode (not the file) @@ -154,7 +170,6 @@ type CommonZoneProps = { _URI: URI; // typically we get the URI from model - _removeStylesFns: Set; // these don't remove diffs or this diffArea, only their styles } type CtrlKZone = { @@ -170,6 +185,7 @@ type CtrlKZone = { } _linkedStreamingDiffZone: number | null; // diffareaid of the diffZone currently streaming here + _removeStylesFns: Set // these don't remove diffs or this diffArea, only their styles } & CommonZoneProps @@ -189,12 +205,22 @@ type DiffZone = { }; editorId?: undefined; linkedStreamingDiffZone?: undefined; + _removeStylesFns: Set // these don't remove diffs or this diffArea, only their styles } & CommonZoneProps +type TrackingZone = { + type: 'TrackingZone'; + metadata: T; + originalCode?: undefined; + editorId?: undefined; + _removeStylesFns?: undefined; +} & CommonZoneProps + + // called DiffArea for historical purposes, we can rename to something like TextRegion if we want -type DiffArea = CtrlKZone | DiffZone +type DiffArea = CtrlKZone | DiffZone | TrackingZone const diffAreaSnapshotKeys = [ 'type', @@ -217,18 +243,34 @@ type HistorySnapshot = { -export interface IInlineDiffsService { +// line/col is the location, originalCodeStartLine is the start line of the original code being displayed +type StreamLocationMutable = { line: number, col: number, addedSplitYet: boolean, originalCodeStartLine: number } + + +export interface IEditCodeService { readonly _serviceBrand: undefined; - startApplying(opts: StartApplyingOpts): number | undefined; - interruptStreaming(diffareaid: number): void; + startApplying(opts: StartApplyingOpts): URI | null; + addCtrlKZone(opts: AddCtrlKOpts): number | undefined; removeCtrlKZone(opts: { diffareaid: number }): void; + removeDiffAreas(opts: { uri: URI, removeCtrlKs: boolean, behavior: 'reject' | 'accept' }): void; + + // CtrlKZone streaming state + isCtrlKZoneStreaming(opts: { diffareaid: number }): boolean; + interruptCtrlKStreaming(opts: { diffareaid: number }): void; + onDidChangeCtrlKZoneStreaming: Event<{ uri: URI; diffareaid: number }>; + + // // DiffZone codeBoxId streaming state + getURIStreamState(opts: { uri: URI | null }): URIStreamState; + interruptURIStreaming(opts: { uri: URI }): void; + onDidChangeURIStreamState: Event<{ uri: URI; state: URIStreamState }>; + // testDiffs(): void; } -export const IInlineDiffsService = createDecorator('inlineDiffAreasService'); +export const IEditCodeService = createDecorator('editCodeService'); -class InlineDiffsService extends Disposable implements IInlineDiffsService { +class EditCodeService extends Disposable implements IEditCodeService { _serviceBrand: undefined; @@ -241,9 +283,17 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { // only applies to diffZones // streamingDiffZones: Set = new Set() - private readonly _onDidChangeStreaming = new Emitter<{ uri: URI; diffareaid: number }>(); + private readonly _onDidChangeDiffZoneStreaming = new Emitter<{ uri: URI; diffareaid: number }>(); private readonly _onDidAddOrDeleteDiffZones = new Emitter<{ uri: URI }>(); + private readonly _onDidChangeCtrlKZoneStreaming = new Emitter<{ uri: URI; diffareaid: number }>(); + onDidChangeCtrlKZoneStreaming = this._onDidChangeCtrlKZoneStreaming.event + + private readonly _onDidChangeURIStreamState = new Emitter<{ uri: URI; state: URIStreamState }>(); + onDidChangeURIStreamState = this._onDidChangeURIStreamState.event + + + constructor( // @IHistoryService private readonly _historyService: IHistoryService, // history service is the history of pressing alt left/right @@ -257,6 +307,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { @IMetricsService private readonly _metricsService: IMetricsService, @INotificationService private readonly _notificationService: INotificationService, @ICommandService private readonly _commandService: ICommandService, + @IVoidFileService private readonly _voidFileService: IVoidFileService, ) { super(); @@ -277,23 +328,32 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { }) ) - // when a stream starts or ends - let removeAcceptRejectAllUI: (() => void) | null = null - const onChangeUriState = () => { - const uri = model.uri - const diffZones = [...this.diffAreasOfURI[uri.fsPath].values()] - .map(diffareaid => this.diffAreaOfId[diffareaid]) - .filter(diffArea => !!diffArea && diffArea.type === 'DiffZone') - const isStreaming = diffZones.find(diffZone => !!diffZone._streamState.isStreaming) - if (diffZones.length !== 0 && !isStreaming && !removeAcceptRejectAllUI) { - removeAcceptRejectAllUI = this._addAcceptRejectAllUI(uri) ?? null - } else { - removeAcceptRejectAllUI?.() - removeAcceptRejectAllUI = null - } + // when a stream starts or ends, fire the event for onDidChangeURIStreamState + let prevStreamState = this.getURIStreamState({ uri: model.uri }) + const updateAcceptRejectAllUI = () => { + const state = this.getURIStreamState({ uri: model.uri }) + let prevStateActual = prevStreamState + prevStreamState = state + if (state === prevStateActual) return + this._onDidChangeURIStreamState.fire({ uri: model.uri, state }) } - this._register(this._onDidAddOrDeleteDiffZones.event(({ uri: uri_ }) => { if (uri_.fsPath === model.uri.fsPath) onChangeUriState() })) - this._register(this._onDidChangeStreaming.event(({ uri: uri_ }) => { if (uri_.fsPath === model.uri.fsPath) onChangeUriState() })) + + + let _removeAcceptRejectAllUI: (() => void) | null = null + this._register(this._onDidChangeURIStreamState.event(({ uri, state }) => { + if (uri.fsPath !== model.uri.fsPath) return + if (state === 'acceptRejectAll') { + if (!_removeAcceptRejectAllUI) + _removeAcceptRejectAllUI = this._addAcceptRejectAllUI(model.uri) ?? null + } else { + _removeAcceptRejectAllUI?.() + _removeAcceptRejectAllUI = null + } + })) + this._register(this._onDidChangeDiffZoneStreaming.event(({ uri: uri_ }) => { if (uri_.fsPath === model.uri.fsPath) updateAcceptRejectAllUI() })) + this._register(this._onDidAddOrDeleteDiffZones.event(({ uri: uri_ }) => { if (uri_.fsPath === model.uri.fsPath) updateAcceptRejectAllUI() })) + + } // initialize all existing models + initialize when a new model mounts for (let model of this._modelService.getModels()) { initializeModel(model) } @@ -330,6 +390,29 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { } + + + private _notifyError = (e: Parameters[0]) => { + const details = errorDetails(e.fullError) + this._notificationService.notify({ + severity: Severity.Warning, + message: `Void Error: ${e.message}`, + actions: { + secondary: [{ + id: 'void.onerror.opensettings', + enabled: true, + label: `Open Void's settings`, + tooltip: '', + class: undefined, + run: () => { this._commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) } + }] + }, + source: details ? `(Hold ${isMacintosh ? 'Option' : 'Alt'} to hover) - ${details}\n\nIf this persists, feel free to [report](https://github.com/voideditor/void/issues/new) it.` : undefined + }) + } + + + // highlight the region private _addLineDecoration = (model: ITextModel | null, startLine: number, endLine: number, className: string, options?: Partial) => { if (model === null) return @@ -351,7 +434,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { private _addDiffAreaStylesToURI = (uri: URI) => { const model = this._getModel(uri) - for (const diffareaid of this.diffAreasOfURI[uri.fsPath]) { + for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] if (diffArea.type === 'DiffZone') { @@ -380,7 +463,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { private _computeDiffsAndAddStylesToURI = (uri: URI) => { const fullFileText = this._readURI(uri) ?? '' - for (const diffareaid of this.diffAreasOfURI[uri.fsPath]) { + for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] if (diffArea.type !== 'DiffZone') continue @@ -404,7 +487,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { // find all diffzones that aren't streaming const diffZones: DiffZone[] = [] - for (let diffareaid of this.diffAreasOfURI[uri.fsPath]) { + for (let diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] if (diffArea.type !== 'DiffZone') continue if (diffArea._streamState.isStreaming) continue @@ -473,7 +556,6 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { mountCtrlK(domNode, accessor, { diffareaid: ctrlKZone.diffareaid, - initStreamingDiffZoneId: ctrlKZone._linkedStreamingDiffZone, textAreaRef: (r) => { textAreaRef.current = r @@ -523,7 +605,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { private _refreshCtrlKInputs = async (uri: URI) => { - for (const diffareaid of this.diffAreasOfURI[uri.fsPath]) { + for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] if (diffArea.type !== 'CtrlKZone') continue if (!diffArea._mountInfo) { @@ -702,7 +784,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { } weAreWriting = false - private async _writeText(uri: URI, text: string, range: IRange, { shouldRealignDiffAreas }: { shouldRealignDiffAreas: boolean }) { + private _writeText(uri: URI, text: string, range: IRange, { shouldRealignDiffAreas }: { shouldRealignDiffAreas: boolean }) { const model = this._getModel(uri) if (!model) return const uriStr = this._readURI(uri, range) @@ -785,7 +867,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { this.diffAreaOfId[diffareaid] = { ...snapshottedDiffArea as DiffAreaSnapshot, _URI: uri, - _removeStylesFns: new Set(), + _removeStylesFns: new Set(), _mountInfo: null, _linkedStreamingDiffZone: null, // when restoring, we will never be streaming } @@ -812,7 +894,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { type: UndoRedoElementType.Resource, resource: uri, label: 'Void Changes', - code: 'undoredo.inlineDiffs', + code: 'undoredo.editCode', undo: () => { restoreDiffAreas(beforeSnapshot) }, redo: () => { if (afterSnapshot) restoreDiffAreas(afterSnapshot) } } @@ -843,14 +925,14 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { if (diffArea.type === 'DiffZone') this._deleteDiffs(diffArea) - diffArea._removeStylesFns.forEach(removeStyles => removeStyles()) - diffArea._removeStylesFns.clear() + diffArea._removeStylesFns?.forEach(removeStyles => removeStyles()) + diffArea._removeStylesFns?.clear() } // clears all Diffs (and their styles) and all styles of DiffAreas, etc private _clearAllEffects(uri: URI) { - for (let diffareaid of this.diffAreasOfURI[uri.fsPath]) { + for (let diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] this._clearAllDiffAreaEffects(diffArea) } @@ -865,6 +947,11 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { this._onDidAddOrDeleteDiffZones.fire({ uri: diffZone._URI }) } + private _deleteTrackingZone(trackingZone: TrackingZone) { + delete this.diffAreaOfId[trackingZone.diffareaid] + this.diffAreasOfURI[trackingZone._URI.fsPath].delete(trackingZone.diffareaid.toString()) + } + private _deleteCtrlKZone(ctrlKZone: CtrlKZone) { this._clearAllEffects(ctrlKZone._URI) ctrlKZone._mountInfo?.dispose() @@ -1002,44 +1089,37 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { // @throttle(100) - private _writeStreamedDiffZoneLLMText(diffZone: DiffZone, llmText: string, deltaText: string, latestMutable: { line: number, col: number, addedSplitYet: boolean, originalCodeStartLine: number }) { + private _writeStreamedDiffZoneLLMText(uri: URI, originalCode: string, llmTextSoFar: string, deltaText: string, latestMutable: StreamLocationMutable) { + + let numNewLines = 0 // ----------- 1. Write the new code to the document ----------- // figure out where to highlight based on where the AI is in the stream right now, use the last diff to figure that out - const uri = diffZone._URI - const computedDiffs = findDiffs(diffZone.originalCode, llmText) - - // should always be in streaming state here - if (!diffZone._streamState.isStreaming) { - console.error('DiffZone was not in streaming state on _writeDiffZoneLLMText') - return - } + const computedDiffs = findDiffs(originalCode, llmTextSoFar) // if streaming, use diffs to figure out where to write new code // these are two different coordinate systems - new and old line number - let newCodeEndLine: number // get file[diffArea.startLine...newFileEndLine] with line=newFileEndLine highlighted - let originalCodeStartLine: number // get original[oldStartingPoint...] (line in the original code, so starts at 1) + let endLineInLlmTextSoFar: number // get file[diffArea.startLine...newFileEndLine] with line=newFileEndLine highlighted + let startLineInOriginalCode: number // get original[oldStartingPoint...] (line in the original code, so starts at 1) const lastDiff = computedDiffs.pop() if (!lastDiff) { // console.log('!lastDiff') // if the writing is identical so far, display no changes - originalCodeStartLine = 1 - newCodeEndLine = 1 + startLineInOriginalCode = 1 + endLineInLlmTextSoFar = 1 } else { - originalCodeStartLine = lastDiff.originalStartLine + startLineInOriginalCode = lastDiff.originalStartLine if (lastDiff.type === 'insertion' || lastDiff.type === 'edit') - newCodeEndLine = lastDiff.endLine + endLineInLlmTextSoFar = lastDiff.endLine else if (lastDiff.type === 'deletion') - newCodeEndLine = lastDiff.startLine + endLineInLlmTextSoFar = lastDiff.startLine else throw new Error(`Void: diff.type not recognized on: ${lastDiff}`) } - - // at the start, add a newline between the stream and originalCode to make reasoning easier if (!latestMutable.addedSplitYet) { this._writeText(uri, '\n', @@ -1047,6 +1127,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { { shouldRealignDiffAreas: true } ) latestMutable.addedSplitYet = true + numNewLines += 1 } // insert deltaText at latest line and col @@ -1054,32 +1135,33 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { { startLineNumber: latestMutable.line, startColumn: latestMutable.col, endLineNumber: latestMutable.line, endColumn: latestMutable.col }, { shouldRealignDiffAreas: true } ) - latestMutable.line += deltaText.split('\n').length - 1 + const deltaNumNewLines = deltaText.split('\n').length - 1 + latestMutable.line += deltaNumNewLines const lastNewlineIdx = deltaText.lastIndexOf('\n') latestMutable.col = lastNewlineIdx === -1 ? latestMutable.col + deltaText.length : deltaText.length - lastNewlineIdx + numNewLines += deltaNumNewLines // delete or insert to get original up to speed - if (latestMutable.originalCodeStartLine < originalCodeStartLine) { + if (latestMutable.originalCodeStartLine < startLineInOriginalCode) { // moved up, delete - const numLinesDeleted = originalCodeStartLine - latestMutable.originalCodeStartLine + const numLinesDeleted = startLineInOriginalCode - latestMutable.originalCodeStartLine this._writeText(uri, '', { startLineNumber: latestMutable.line, startColumn: latestMutable.col, endLineNumber: latestMutable.line + numLinesDeleted, endColumn: Number.MAX_SAFE_INTEGER, }, { shouldRealignDiffAreas: true } ) + numNewLines -= numLinesDeleted } - else if (latestMutable.originalCodeStartLine > originalCodeStartLine) { - this._writeText(uri, '\n' + diffZone.originalCode.split('\n').slice((originalCodeStartLine - 1), (latestMutable.originalCodeStartLine - 1) - 1 + 1).join('\n'), + else if (latestMutable.originalCodeStartLine > startLineInOriginalCode) { + const newText = '\n' + originalCode.split('\n').slice((startLineInOriginalCode - 1), (latestMutable.originalCodeStartLine - 1) - 1 + 1).join('\n') + this._writeText(uri, newText, { startLineNumber: latestMutable.line, startColumn: latestMutable.col, endLineNumber: latestMutable.line, endColumn: latestMutable.col }, { shouldRealignDiffAreas: true } ) + numNewLines += newText.split('\n').length - 1 } - latestMutable.originalCodeStartLine = originalCodeStartLine - - // add diffZone.startLine to convert to right coordinate system (line in file, not in diffarea) - diffZone._streamState.line = (diffZone.startLine - 1) + newCodeEndLine - - return computedDiffs + latestMutable.originalCodeStartLine = startLineInOriginalCode + return { endLineInLlmTextSoFar, numNewLines } // numNewLines here might not be correct.... } @@ -1140,20 +1222,20 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { + + + + public startApplying(opts: StartApplyingOpts) { - if (opts.type === 'rewrite') { - const addedDiffZone = this._initializeRewriteStream(opts) - return addedDiffZone?.diffareaid + const addedDiffArea = this._initializeWriteoverStream(opts) + return addedDiffArea?._URI ?? null } - else if (opts.type === 'searchReplace') { - this._initializeSearchAndReplaceStream(opts) - return undefined + const addedDiffArea = this._initializeSearchAndReplaceStream(opts) + return addedDiffArea?._URI ?? null } - - else return undefined - + return null } @@ -1161,7 +1243,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { private _findOverlappingDiffArea({ startLine, endLine, uri, filter }: { startLine: number, endLine: number, uri: URI, filter?: (diffArea: DiffArea) => boolean }): DiffArea | null { // check if there's overlap with any other diffAreas and return early if there is - for (const diffareaid of this.diffAreasOfURI[uri.fsPath]) { + for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] if (!diffArea) continue if (!filter?.(diffArea)) continue @@ -1174,243 +1256,11 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { } - private async _initializeSearchAndReplaceStream({ applyStr }: { applyStr: string }) { - const ORIGINAL = `<<<<<<< ORIGINAL` - const DIVIDER = `=======` - const FINAL = `>>>>>>> UPDATED` - - const searchReplaceSysMessage = `\ -You are a coding assistant that generates SEARCH/REPLACE code blocks that will be used to edit a file. - -A SEARCH/REPLACE block describes the code before and after a change. Here is the format: -${ORIGINAL} -// ... original code goes here -${DIVIDER} -// ... final code goes here -${FINAL} - -You will be given the original file \`ORIGINAL_FILE\` and a description of a change \`CHANGE\` to make. -Output SEARCH/REPLACE blocks to edit the file according to the desired change. You may output multiple SEARCH/REPLACE blocks. - -Directions: -1. Your OUTPUT should consist ONLY of SEARCH/REPLACE blocks. Do NOT output any text or explanations before or after this. -2. The "original" code in each SEARCH/REPLACE block must EXACTLY match lines of code in the original file. -3. The "original" code in each SEARCH/REPLACE block should include enough text to uniquely identify the change in the file. -4. The SEARCH/REPLACE blocks you generate will be applied immediately, and so they **MUST** produce a file that the user can run IMMEDIATELY. - - Make sure you add all necessary imports. - - Make sure the "final" code is complete and will not result in syntax/lint errors. -5. Follow coding convention (spaces, semilcolons, comments, etc). - -## EXAMPLE 1 -ORIGINAL_FILE -${tripleTick[0]} -let w = 5 -let x = 6 -let y = 7 -let z = 8 -${tripleTick[1]} - -CHANGE -Make x equal to 6.5, not 6. -${tripleTick[0]} -// ... existing code -let x = 6.5 -// ... existing code -${tripleTick[1]} - - -## ACCEPTED OUTPUT -${tripleTick[0]} -${ORIGINAL} -let x = 6 -${DIVIDER} -let x = 6.5 -${FINAL} -${tripleTick[1]} -` - - const uri_ = this._getActiveEditorURI() - if (!uri_) return - const uri = uri_ - - // generate search/replace block text - const fileContents = await VSReadFile(this._modelService, uri) - if (fileContents === null) return - - - const searchReplaceUserMessage = ({ originalCode, applyStr }: { originalCode: string, applyStr: string }) => `\ -ORIGINAL_FILE -${originalCode} - -CHANGE -${applyStr} - -INSTRUCTIONS -Please output SEARCH/REPLACE blocks to make the change. Return ONLY your suggested SEARCH/REPLACE blocks, without any explanation. -` - - const endsWithAnyPrefixOf = (str: string, anyPrefix: string) => { - // for each prefix - for (let i = anyPrefix.length; i >= 0; i--) { - const prefix = anyPrefix.slice(0, i) - if (str.endsWith(prefix)) return prefix - } - return null - } - - const extractBlocks = (str: string) => { - - const ORIGINAL_ = ORIGINAL + `\n` - const DIVIDER_ = '\n' + DIVIDER + `\n` - const FINAL_ = '\n' + FINAL - - - const blocks: ExtractedCodeBlock[] = [] - - let i = 0 // search i and beyond (this is done by plain index, not by line number. much simpler this way) - while (true) { - let origStart = str.indexOf(ORIGINAL_, i) - if (origStart === -1) { return blocks } - origStart += ORIGINAL_.length - i = origStart - // wrote <<<< ORIGINAL - - let dividerStart = str.indexOf(DIVIDER_, i) - if (dividerStart === -1) { // if didnt find DIVIDER_, either writing originalStr or DIVIDER_ right now - const isWritingDIVIDER = endsWithAnyPrefixOf(str, DIVIDER_) - blocks.push({ - orig: str.substring(origStart, str.length - (isWritingDIVIDER?.length ?? 0)), - final: '', - state: 'writingOriginal' - }) - return blocks - } - const origStrDone = str.substring(origStart, dividerStart) - dividerStart += DIVIDER_.length - i = dividerStart - // wrote ===== - - let finalStart = str.indexOf(FINAL_, i) - if (finalStart === -1) { // if didnt find FINAL_, either writing finalStr or FINAL_ right now - const isWritingFINAL = endsWithAnyPrefixOf(str, FINAL_) - blocks.push({ - orig: origStrDone, - final: str.substring(origStart, str.length - (isWritingFINAL?.length ?? 0)), - state: 'writingFinal' - }) - return blocks - } - const finalStrDone = str.substring(dividerStart, finalStart) - finalStart += FINAL_.length - i = finalStart - // wrote >>>>> FINAL - - blocks.push({ - orig: origStrDone, - final: finalStrDone, - state: 'done' - }) - } - } - - - // reject all diffZones on this URI, adding to history (there can't possibly be overlap after this) - this.removeDiffAreas({ uri, behavior: 'reject', removeCtrlKs: true }) - - const userMessageContent = searchReplaceUserMessage({ originalCode: fileContents, applyStr: applyStr }) - const messages: LLMChatMessage[] = [ - { role: 'system', content: searchReplaceSysMessage }, - { role: 'user', content: userMessageContent } - ] - let streamRequestIdRef: { current: string | null } = { current: null } - - const diffareaidOfBlockNum: number[] = [] - - const onText = ({ newText, fullText }: { newText: string, fullText: string }) => { - const blocks = extractBlocks(fullText) - - // find block.orig in fileContents and return its range in file - const findTextInCode = (text: string, fileContents: string) => { - const idx = fileContents.indexOf(text) - if (idx === -1) return 'Not found' as const - const lastIdx = fileContents.lastIndexOf(text) - if (lastIdx !== idx) return 'Not unique' as const - const startLine = fileContents.substring(0, idx).split('\n').length - const numLines = text.split('\n').length - const endLine = startLine + numLines - 1 - return [startLine, endLine] - } - - let latestStreamInfoMutable: any = {} - - for (let blockNum = 0; blockNum < blocks.length; blockNum += 1) { - const block = blocks[blockNum] - if (block.state === 'writingOriginal') continue - - const foundInCode = findTextInCode(block.orig, fileContents) - if (typeof foundInCode === 'string') { - console.log('ERROR!!!!', foundInCode) - continue - } - - const [startLine, endLine] = foundInCode - - // if should add new diffarea - if (blockNum > diffareaidOfBlockNum.length) { - const adding: Omit = { - type: 'DiffZone', - originalCode: block.orig, - startLine, - endLine, - _URI: uri, - _streamState: { - isStreaming: true, - streamRequestIdRef, - line: startLine, - }, - _diffOfId: {}, // added later - _removeStylesFns: new Set(), - } - const diffZone = this._addDiffArea(adding) - this._onDidChangeStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) - this._onDidAddOrDeleteDiffZones.fire({ uri }) - - diffareaidOfBlockNum.push(diffZone.diffareaid) - - latestStreamInfoMutable = { line: diffZone.startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 } - } - - const diffareaid = diffareaidOfBlockNum[blockNum] - const diffZone = this.diffAreaOfId[diffareaid] - if (diffZone.type !== 'DiffZone') continue - - this._writeStreamedDiffZoneLLMText(diffZone, fullText, newText, latestStreamInfoMutable) - this._refreshStylesAndDiffsInURI(uri) - } - - } - // TODO turn this into a service and provide it - streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({ - messagesType: 'chatMessages', - useProviderFor: 'FastApply', - logging: { loggingName: `generateSearchAndReplace` }, - messages, - onText: ({ newText, fullText }) => { onText({ newText, fullText }) }, - onFinalMessage: ({ fullText }) => { }, - onError: (e) => { console.log('ERROR', e) }, - - }) - - } - - - - - private _initializeRewriteStream(opts: StartApplyingOpts): DiffZone | undefined { + private _initializeWriteoverStream(opts: StartApplyingOpts): DiffZone | undefined { const { from } = opts @@ -1477,7 +1327,7 @@ Please output SEARCH/REPLACE blocks to make the change. Return ONLY your suggest _removeStylesFns: new Set(), } const diffZone = this._addDiffArea(adding) - this._onDidChangeStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) this._onDidAddOrDeleteDiffZones.fire({ uri }) if (from === 'QuickEdit') { @@ -1486,15 +1336,16 @@ Please output SEARCH/REPLACE blocks to make the change. Return ONLY your suggest if (ctrlKZone.type !== 'CtrlKZone') return ctrlKZone._linkedStreamingDiffZone = diffZone.diffareaid + this._onDidChangeCtrlKZoneStreaming.fire({ uri, diffareaid: ctrlKZone.diffareaid }) } // now handle messages let messages: LLMChatMessage[] if (from === 'ClickApply') { - const userContent = fastApply_rewritewholething_userMessage({ originalCode, applyStr: opts.applyStr, uri }) + const userContent = rewriteCode_userMessage({ originalCode, applyStr: opts.applyStr, uri }) messages = [ - { role: 'system', content: fastApply_rewritewholething_systemMessage, }, + { role: 'system', content: rewriteCode_systemMessage, }, { role: 'user', content: userContent, } ] } @@ -1517,23 +1368,19 @@ Please output SEARCH/REPLACE blocks to make the change. Return ONLY your suggest else { throw new Error(`featureName ${from} is invalid`) } - const onDone = (hadError: boolean) => { + const onDone = () => { diffZone._streamState = { isStreaming: false, } - this._onDidChangeStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) if (from === 'QuickEdit') { const ctrlKZone = this.diffAreaOfId[opts.diffareaid] as CtrlKZone ctrlKZone._linkedStreamingDiffZone = null + this._onDidChangeCtrlKZoneStreaming.fire({ uri, diffareaid: ctrlKZone.diffareaid }) this._deleteCtrlKZone(ctrlKZone) } this._refreshStylesAndDiffsInURI(uri) onFinishEdit() - - // if had error, revert! - if (hadError) { - this._undoHistory(diffZone._URI) - } } // refresh now in case onText takes a while to get 1st message @@ -1550,7 +1397,7 @@ Please output SEARCH/REPLACE blocks to make the change. Return ONLY your suggest throw 1 } - const latestStreamInfoMutable = { line: diffZone.startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 } + const latestStreamInfoMutable: StreamLocationMutable = { line: diffZone.startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 } // state used in onText: let fullText = '' @@ -1558,48 +1405,36 @@ Please output SEARCH/REPLACE blocks to make the change. Return ONLY your suggest streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({ messagesType: 'chatMessages', - useProviderFor: opts.from === 'ClickApply' ? 'FastApply' : 'Ctrl+K', + useProviderFor: opts.from === 'ClickApply' ? 'Apply' : 'Ctrl+K', logging: { loggingName: `startApplying - ${from}` }, messages, onText: ({ newText: newText_ }) => { const newText = prevIgnoredSuffix + newText_ // add the previously ignored suffix because it's no longer the suffix! - fullText += prevIgnoredSuffix + newText + fullText += prevIgnoredSuffix + newText // full text, including ```, etc + + const [croppedText, deltaCroppedText, croppedSuffix] = extractText(fullText, newText.length) + const { endLineInLlmTextSoFar } = this._writeStreamedDiffZoneLLMText(uri, originalCode, croppedText, deltaCroppedText, latestStreamInfoMutable) + diffZone._streamState.line = (diffZone.startLine - 1) + endLineInLlmTextSoFar // change coordinate systems from originalCode to full file - const [text, deltaText, ignoredSuffix] = extractText(fullText, newText.length) - this._writeStreamedDiffZoneLLMText(diffZone, text, deltaText, latestStreamInfoMutable) this._refreshStylesAndDiffsInURI(uri) - prevIgnoredSuffix = ignoredSuffix + prevIgnoredSuffix = croppedSuffix }, onFinalMessage: ({ fullText }) => { // console.log('DONE! FULL TEXT\n', extractText(fullText), diffZone.startLine, diffZone.endLine) // at the end, re-write whole thing to make sure no sync errors - const [text, _] = extractText(fullText, 0) - this._writeText(uri, text, + const [croppedText, _1, _2] = extractText(fullText, 0) + this._writeText(uri, croppedText, { startLineNumber: diffZone.startLine, startColumn: 1, endLineNumber: diffZone.endLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed { shouldRealignDiffAreas: true } ) - onDone(false) + onDone() }, onError: (e) => { - const details = errorDetails(e.fullError) - this._notificationService.notify({ - severity: Severity.Warning, - message: `Void Error: ${e.message}`, - actions: { - secondary: [{ - id: 'void.onerror.opensettings', - enabled: true, - label: 'Open Void settings', - tooltip: '', - class: undefined, - run: () => { this._commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) } - }] - }, - source: details ? `(Hold ${isMacintosh ? 'Option' : 'Alt'} to hover) - ${details}` : undefined - }) - onDone(true) + this._notifyError(e) + onDone() + this._undoHistory(uri) }, }) @@ -1611,6 +1446,281 @@ Please output SEARCH/REPLACE blocks to make the change. Return ONLY your suggest + private _initializeSearchAndReplaceStream(opts: StartApplyingOpts & { from: 'ClickApply' }) { + const { applyStr } = opts + + const uri_ = this._getActiveEditorURI() + if (!uri_) return + const uri = uri_ + + // generate search/replace block text + const originalFileCode = this._voidFileService.readModel(uri) + if (originalFileCode === null) return + + const numLines = this._getNumLines(uri) + if (numLines === null) return + + // reject all diffZones on this URI, adding to history (there can't possibly be overlap after this) + this.removeDiffAreas({ uri, behavior: 'reject', removeCtrlKs: true }) + + const startLine = 1 + const endLine = numLines + + const userMessageContent = searchReplace_userMessage({ originalCode: originalFileCode, applyStr: applyStr }) + const messages: LLMChatMessage[] = [ + { role: 'system', content: searchReplace_systemMessage }, + { role: 'user', content: userMessageContent }, + ] + + // can use this as a proxy to set the diffArea's stream state requestId + let streamRequestIdRef: { current: string | null } = { current: null } + + let { onFinishEdit } = this._addToHistory(uri) + + // TODO replace these with whatever block we're on initially if already started + + type SearchReplaceDiffAreaMetadata = { + originalBounds: [number, number], // 1-indexed + originalCode: string, + } + + const addedTrackingZoneOfBlockNum: TrackingZone[] = [] + + const adding: Omit = { + type: 'DiffZone', + originalCode: originalFileCode, + startLine, + endLine, + _URI: uri, + _streamState: { + isStreaming: true, + streamRequestIdRef, + line: startLine, + }, + _diffOfId: {}, // added later + _removeStylesFns: new Set(), + } + const diffZone = this._addDiffArea(adding) + this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + this._onDidAddOrDeleteDiffZones.fire({ uri }) + + + const revertAndContinueHistory = () => { + this._undoHistory(uri) + const { onFinishEdit: onFinishEdit_ } = this._addToHistory(uri) + onFinishEdit = onFinishEdit_ + } + + + const convertOriginalRangeToFinalRange = (originalRange: readonly [number, number]): [number, number] => { + // adjust based on the changes by computing line offset + const [originalStart, originalEnd] = originalRange + let lineOffset = 0 + for (const blockDiffArea of addedTrackingZoneOfBlockNum) { + const { + startLine, endLine, + metadata: { originalBounds: [originalStart2, originalEnd2], }, + } = blockDiffArea + if (originalStart2 >= originalEnd) continue + const numNewLines = endLine - startLine + 1 + const numOldLines = originalEnd2 - originalStart2 + 1 + lineOffset += numNewLines - numOldLines + } + return [originalStart + lineOffset, originalEnd + lineOffset] + } + + + const errMsgOfInvalidStr = (str: string & ReturnType) => { + return str === 'Not found' ? + 'I interrupted you because the latest ORIGINAL code could not be found in the file. Please output all SEARCH/REPLACE blocks again, making sure the code in ORIGINAL is identical to a code snippet in the file.' + : str === 'Not unique' ? + 'I interrupted you because the latest ORIGINAL code shows up multiple times in the file. Please output all SEARCH/REPLACE blocks again, making sure the code in each ORIGINAL section is unique in the file.' + : '' + } + + + const onDone = () => { + diffZone._streamState = { isStreaming: false, } + this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + this._refreshStylesAndDiffsInURI(uri) + + // delete the tracking zones + for (const trackingZone of addedTrackingZoneOfBlockNum) + this._deleteTrackingZone(trackingZone) + + onFinishEdit() + } + + // refresh now in case onText takes a while to get 1st message + this._refreshStylesAndDiffsInURI(uri) + + // stream style related + let latestStreamLocationMutable: StreamLocationMutable | null = null + let shouldUpdateOrigStreamStyle = true + + let oldBlocks: ExtractedSearchReplaceBlock[] = [] + + // this generates >>>>>>> ORIGINAL <<<<<<< REPLACE blocks and and simultaneously applies it + let shouldSendAnotherMessage = true + let nMessagesSent = 0 + let currStreamingBlockNum = 0 + while (shouldSendAnotherMessage) { + shouldSendAnotherMessage = false + nMessagesSent += 1 + + streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({ + messagesType: 'chatMessages', + useProviderFor: 'Apply', + logging: { loggingName: `generateSearchAndReplace` }, + messages, + onText: ({ fullText }) => { + // blocks are [done done done ... {writingFinal|writingOriginal}] + // ^ + // currStreamingBlockNum + + const blocks = extractSearchReplaceBlocks(fullText) + + for (let blockNum = currStreamingBlockNum; blockNum < blocks.length; blockNum += 1) { + const block = blocks[blockNum] + + if (block.state === 'writingOriginal') { + // update stream state to the first line of original if some portion of original has been written + if (shouldUpdateOrigStreamStyle && block.orig.trim().length >= 20) { + const startingAtLine = diffZone._streamState.line ?? 1 // dont go backwards if already have a stream line + const originalRange = findTextInCode(block.orig, originalFileCode, startingAtLine) + if (typeof originalRange !== 'string') { + const [startLine, _] = convertOriginalRangeToFinalRange(originalRange) + diffZone._streamState.line = startLine + shouldUpdateOrigStreamStyle = false + } + } + // must be done writing original to move on to writing streamed content + continue + } + shouldUpdateOrigStreamStyle = true + + + // if this is the first time we're seeing this block, add it as a diffarea so we can start streaming + if (!(blockNum in addedTrackingZoneOfBlockNum)) { + const originalBounds = findTextInCode(block.orig, originalFileCode) + + // if error + if (typeof originalBounds === 'string') { + messages.push( + { role: 'assistant', content: fullText }, // latest output + { role: 'user', content: errMsgOfInvalidStr(originalBounds) } // user explanation of what's wrong + ) + if (streamRequestIdRef.current) this._llmMessageService.abort(streamRequestIdRef.current) + shouldSendAnotherMessage = true + revertAndContinueHistory() + continue + } + + const [startLine, endLine] = convertOriginalRangeToFinalRange(originalBounds) + + // otherwise if no error, add the position as a diffarea + const adding: Omit, 'diffareaid'> = { + type: 'TrackingZone', + startLine: startLine, + endLine: endLine, + _URI: uri, + metadata: { + originalBounds: [...originalBounds], + originalCode: block.orig, + }, + } + const trackingZone = this._addDiffArea(adding) + addedTrackingZoneOfBlockNum.push(trackingZone) + latestStreamLocationMutable = { line: startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 } + } // <-- done adding diffarea + + + // should always be in streaming state here + if (!diffZone._streamState.isStreaming) { + console.error('DiffZone was not in streaming state in _initializeSearchAndReplaceStream') + continue + } + if (!latestStreamLocationMutable) continue + + // if a block is done, finish it by writing all + if (block.state === 'done') { + const { startLine: finalStartLine, endLine: finalEndLine } = addedTrackingZoneOfBlockNum[blockNum] + this._writeText(uri, block.final, + { startLineNumber: finalStartLine, startColumn: 1, endLineNumber: finalEndLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed + { shouldRealignDiffAreas: true } + ) + diffZone._streamState.line = finalEndLine + 1 + currStreamingBlockNum = blockNum + 1 + continue + } + + // write the added text to the file + const deltaFinalText = block.final.substring((oldBlocks[blockNum]?.final ?? '').length, Infinity) + this._writeStreamedDiffZoneLLMText(uri, block.orig, block.final, deltaFinalText, latestStreamLocationMutable) + oldBlocks = blocks // oldblocks is only used if writingFinal + + // const { endLine: currentEndLine } = addedTrackingZoneOfBlockNum[blockNum] // would be bad to do this because a lot of the bottom lines might be the same. more accurate to go with latestStreamLocationMutable + // diffZone._streamState.line = currentEndLine + diffZone._streamState.line = latestStreamLocationMutable.line + + + + } // end for + + this._refreshStylesAndDiffsInURI(uri) + }, + onFinalMessage: async ({ fullText }) => { + console.log('final message!!', fullText) + + // 1. wait 500ms and fix lint errors - call lint error workflow + // (update react state to say "Fixing errors") + const blocks = extractSearchReplaceBlocks(fullText) + + if (blocks.length === 0) { + this._notificationService.info(`Void: We ran Apply, but the LLM didn't output any changes.`) + } + + // writeover the whole file + let newCode = originalFileCode + for (let blockNum = addedTrackingZoneOfBlockNum.length - 1; blockNum >= 0; blockNum -= 1) { + const { originalBounds } = addedTrackingZoneOfBlockNum[blockNum].metadata + const finalCode = blocks[blockNum].final + + if (finalCode === null) continue + + const [originalStart, originalEnd] = originalBounds + const lines = newCode.split('\n') + newCode = [ + ...lines.slice(0, (originalStart - 1)), + ...finalCode.split('\n'), + ...lines.slice((originalEnd - 1) + 1, Infinity) + ].join('\n') + } + const numLines = this._getNumLines(uri) + if (numLines !== null) { + this._writeText(uri, newCode, + { startLineNumber: 1, startColumn: 1, endLineNumber: numLines, endColumn: Number.MAX_SAFE_INTEGER }, + { shouldRealignDiffAreas: true } + ) + } + + onDone() + }, + onError: (e) => { + this._notifyError(e) + onDone() + this._undoHistory(uri) + }, + + }) + } + + + return diffZone + } + + + private _stopIfStreaming(diffZone: DiffZone) { const uri = diffZone._URI @@ -1620,29 +1730,73 @@ Please output SEARCH/REPLACE blocks to make the change. Return ONLY your suggest this._llmMessageService.abort(streamRequestId) diffZone._streamState = { isStreaming: false, } - this._onDidChangeStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + this._onDidChangeDiffZoneStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) } _undoHistory(uri: URI) { this._undoRedoService.undo(uri) } - // call this outside undo/redo (it calls undo). this is only for aborting a diffzone stream - interruptStreaming(diffareaid: number) { - const diffArea = this.diffAreaOfId[diffareaid] - if (!diffArea) return - if (diffArea.type !== 'DiffZone') return - if (!diffArea._streamState.isStreaming) return - this._stopIfStreaming(diffArea) - this._undoHistory(diffArea._URI) + + + _interruptSingleDiffZoneStreaming({ diffareaid }: { diffareaid: number }) { + const diffZone = this.diffAreaOfId[diffareaid] + if (diffZone?.type !== 'DiffZone') return + if (!diffZone._streamState.isStreaming) return + + this._stopIfStreaming(diffZone) + this._undoHistory(diffZone._URI) + } + + + isCtrlKZoneStreaming({ diffareaid }: { diffareaid: number }) { + const ctrlKZone = this.diffAreaOfId[diffareaid] + if (!ctrlKZone) return false + if (ctrlKZone.type !== 'CtrlKZone') return false + return !!ctrlKZone._linkedStreamingDiffZone + } + + + // diffareaid of the ctrlKZone (even though the stream state is dictated by the linked diffZone) + interruptCtrlKStreaming({ diffareaid }: { diffareaid: number }) { + const ctrlKZone = this.diffAreaOfId[diffareaid] + if (ctrlKZone?.type !== 'CtrlKZone') return + if (!ctrlKZone._linkedStreamingDiffZone) return + + const linkedStreamingDiffZone = this.diffAreaOfId[ctrlKZone._linkedStreamingDiffZone] + if (!linkedStreamingDiffZone) return + if (linkedStreamingDiffZone.type !== 'DiffZone') return + + this._interruptSingleDiffZoneStreaming({ diffareaid: linkedStreamingDiffZone.diffareaid }) } + getURIStreamState = ({ uri }: { uri: URI | null }) => { + if (uri === null) return 'idle' + const diffZones = [...this.diffAreasOfURI[uri.fsPath].values()] + .map(diffareaid => this.diffAreaOfId[diffareaid]) + .filter(diffArea => !!diffArea && diffArea.type === 'DiffZone') + const isStreaming = diffZones.find(diffZone => !!diffZone._streamState.isStreaming) + + const state: URIStreamState = isStreaming ? 'streaming' : (diffZones.length === 0 ? 'idle' : 'acceptRejectAll') + return state + } + + interruptURIStreaming({ uri }: { uri: URI }) { + // brute force for now is OK + for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { + const diffArea = this.diffAreaOfId[diffareaid] + if (diffArea?.type !== 'DiffZone') continue + if (!diffArea._streamState.isStreaming) continue + this._stopIfStreaming(diffArea) + } + this._undoHistory(uri) + } // public removeDiffZone(diffZone: DiffZone, behavior: 'reject' | 'accept') { @@ -1894,7 +2048,7 @@ Please output SEARCH/REPLACE blocks to make the change. Return ONLY your suggest } -registerSingleton(IInlineDiffsService, InlineDiffsService, InstantiationType.Eager); +registerSingleton(IEditCodeService, EditCodeService, InstantiationType.Eager); const acceptBg = '#1a7431' const acceptAllBg = '#1e8538' @@ -2098,17 +2252,3 @@ class AcceptAllRejectAllWidget extends Widget implements IOverlayWidget { -// registerAction2(class extends Action2 { -// constructor() { -// super({ -// id: 'void.testDiff', -// title: localize2('voidTestDiff', 'Void Test Diff'), -// f1: true, -// }); -// } -// async run(accessor: ServicesAccessor): Promise { -// const inlineDiffsService = accessor.get(IInlineDiffsService) -// // inlineDiffsService.testDiffs() - -// } -// }) diff --git a/src/vs/workbench/contrib/void/browser/helpers/detectLanguage.ts b/src/vs/workbench/contrib/void/browser/helpers/detectLanguage.ts index b4b9d513..9bc3c9bd 100644 --- a/src/vs/workbench/contrib/void/browser/helpers/detectLanguage.ts +++ b/src/vs/workbench/contrib/void/browser/helpers/detectLanguage.ts @@ -1,3 +1,7 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ // eg "bash" -> "shell" export const nameToVscodeLanguage: { [key: string]: string } = { diff --git a/src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts b/src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts index 1cb53e5c..00eb2ef1 100644 --- a/src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts +++ b/src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts @@ -3,6 +3,9 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ +import { OnText } from '../../common/llmMessageTypes.js' +import { DIVIDER, FINAL, ORIGINAL } from '../prompt/prompts.js' + class SurroundingsRemover { readonly originalS: string i: number @@ -57,7 +60,7 @@ class SurroundingsRemover { // return offset === suffix.length // } - removeFromStartUntil = (until: string, alsoRemoveUntilStr: boolean) => { + removeFromStartUntilFullMatch = (until: string, alsoRemoveUntilStr: boolean) => { const index = this.originalS.indexOf(until, this.i) if (index === -1) { @@ -84,7 +87,7 @@ class SurroundingsRemover { const foundCodeBlock = pm.removePrefix('```') if (!foundCodeBlock) return false - pm.removeFromStartUntil('\n', true) // language + pm.removeFromStartUntilFullMatch('\n', true) // language const j = pm.j let foundCodeBlockEnd = pm.removeSuffix('```') @@ -157,21 +160,177 @@ export const extractCodeFromFIM = ({ text, recentlyAddedTextLen, midTag, }: { te const [delta, ignoredSuffix] = pm.deltaInfo(recentlyAddedTextLen) return [s, delta, ignoredSuffix] - - - // // const regex = /[\s\S]*?(?:`{1,3}\s*([a-zA-Z_]+[\w]*)?[\s\S]*?)?([\s\S]*?)(?:<\/MID>|`{1,3}|$)/; - // const regex = new RegExp( - // `[\\s\\S]*?(?:\`{1,3}\\s*([a-zA-Z_]+[\\w]*)?[\\s\\S]*?)?<${midTag}>([\\s\\S]*?)(?:|\`{1,3}|$)`, - // '' - // ); - // const match = text.match(regex); - // if (match) { - // const [_, languageName, codeBetweenMidTags] = match; - // return [languageName, codeBetweenMidTags] as const - - // } else { - // return [undefined, extractCodeFromRegular(text)] as const - // } - } + + +export type ExtractedSearchReplaceBlock = { + state: 'writingOriginal' | 'writingFinal' | 'done', + orig: string, + final: string, +} + + +const endsWithAnyPrefixOf = (str: string, anyPrefix: string) => { + // for each prefix + for (let i = anyPrefix.length; i >= 0; i--) { + const prefix = anyPrefix.slice(0, i) + if (str.endsWith(prefix)) return prefix + } + return null +} + +// guarantees if you keep adding text, array length will strictly grow and state will progress without going back +export const extractSearchReplaceBlocks = (str: string) => { + + const ORIGINAL_ = ORIGINAL + `\n` + const DIVIDER_ = '\n' + DIVIDER + `\n` + // logic for FINAL_ is slightly more complicated - should be '\n' + FINAL, but that ignores if the final output is empty + + + const blocks: ExtractedSearchReplaceBlock[] = [] + + let i = 0 // search i and beyond (this is done by plain index, not by line number. much simpler this way) + while (true) { + let origStart = str.indexOf(ORIGINAL_, i) + if (origStart === -1) { return blocks } + origStart += ORIGINAL_.length + i = origStart + // wrote <<<< ORIGINAL + + let dividerStart = str.indexOf(DIVIDER_, i) + if (dividerStart === -1) { // if didnt find DIVIDER_, either writing originalStr or DIVIDER_ right now + const isWritingDIVIDER = endsWithAnyPrefixOf(str, DIVIDER_) + blocks.push({ + orig: str.substring(origStart, str.length - (isWritingDIVIDER?.length ?? 0)), + final: '', + state: 'writingOriginal' + }) + return blocks + } + const origStrDone = str.substring(origStart, dividerStart) + dividerStart += DIVIDER_.length + i = dividerStart + // wrote ===== + + + + const finalStartA = str.indexOf(FINAL, i) + const finalStartB = str.indexOf('\n' + FINAL, i) // go with B if possible, else fallback to A, it's more permissive + const FINAL_ = finalStartB !== -1 ? '\n' + FINAL : FINAL + let finalStart = finalStartB !== -1 ? finalStartB : finalStartA + + if (finalStart === -1) { // if didnt find FINAL_, either writing finalStr or FINAL_ right now + const isWritingFINAL = endsWithAnyPrefixOf(str, FINAL_) + blocks.push({ + orig: origStrDone, + final: str.substring(dividerStart, str.length - (isWritingFINAL?.length ?? 0)), + state: 'writingFinal' + }) + return blocks + } + const finalStrDone = str.substring(dividerStart, finalStart) + finalStart += FINAL_.length + i = finalStart + // wrote >>>>> FINAL + + blocks.push({ + orig: origStrDone, + final: finalStrDone, + state: 'done' + }) + } +} + + + + + + + + + +// could simplify this - this assumes we can never add a tag without committing it to the user's screen, but that's not true +export const extractReasoningFromText = ( + onText_: OnText, + thinkTags: [string, string], +): OnText => { + + let latestAddIdx = 0 // exclusive + let foundTag1 = false + let foundTag2 = false + + let fullText = '' + let fullReasoning = '' + + const onText: OnText = ({ newText: newText_, fullText: fullText_ }) => { + // abcdefghi + // | + // until found the first think tag, keep adding to fullText + if (!foundTag1) { + const endsWithTag1 = endsWithAnyPrefixOf(fullText_, thinkTags[0]) + if (endsWithTag1) { + // wait until we get the full tag or know more + return + } + // if found the first tag + const tag1Index = fullText_.lastIndexOf(thinkTags[0]) + if (tag1Index !== -1) { + foundTag1 = true + const newText = fullText.substring(latestAddIdx, tag1Index) + const newReasoning = fullText.substring(tag1Index + thinkTags[0].length, Infinity) + + fullText += newText + fullReasoning += newReasoning + latestAddIdx += newText.length + newReasoning.length + onText_({ newText, fullText, newReasoning: newReasoning, fullReasoning }) + return + } + + // add the text to fullText + const newText = fullText.substring(latestAddIdx, Infinity) + fullText += newText + latestAddIdx += newText.length + onText_({ newText, fullText, newReasoning: '', fullReasoning }) + return + } + // at this point, we found + + // until found the second think tag, keep adding to fullReasoning + if (!foundTag2) { + const endsWithTag2 = endsWithAnyPrefixOf(fullText_, thinkTags[1]) + if (endsWithTag2) { + // wait until we get the full tag or know more + return + } + // if found the second tag + const tag2Index = fullText_.lastIndexOf(thinkTags[1]) + if (tag2Index !== -1) { + foundTag2 = true + const newReasoning = fullText.substring(latestAddIdx, tag2Index) + const newText = fullText.substring(tag2Index + thinkTags[1].length, Infinity) + + fullText += newText + fullReasoning += newReasoning + latestAddIdx += newText.length + newReasoning.length + onText_({ newText, fullText, newReasoning: newReasoning, fullReasoning }) + return + } + + // add the text to fullReasoning + const newReasoning = fullText.substring(latestAddIdx, Infinity) + fullReasoning += newReasoning + latestAddIdx += newReasoning.length + onText_({ newText: '', fullText, newReasoning, fullReasoning }) + return + } + // at this point, we found + + fullText += newText_ + const newText = fullText.substring(latestAddIdx, Infinity) + latestAddIdx += newText.length + onText_({ newText, fullText, newReasoning: '', fullReasoning }) + } + + return onText +} diff --git a/src/vs/workbench/contrib/void/browser/helpers/readFile.ts b/src/vs/workbench/contrib/void/browser/helpers/readFile.ts index b0f154d1..2b134b7f 100644 --- a/src/vs/workbench/contrib/void/browser/helpers/readFile.ts +++ b/src/vs/workbench/contrib/void/browser/helpers/readFile.ts @@ -1,17 +1,52 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + import { URI } from '../../../../../base/common/uri' import { EndOfLinePreference } from '../../../../../editor/common/model' import { IModelService } from '../../../../../editor/common/services/model.js' import { IFileService } from '../../../../../platform/files/common/files' -// read files from VSCode. preferred (but appears to only work if the model of this URI already exists. If it doesn't use the other function.) -export const VSReadFile = async (modelService: IModelService, uri: URI): Promise => { - const model = modelService.getModel(uri) - if (!model) return null - return model.getValue(EndOfLinePreference.LF) + +// attempts to read URI of currently opened model, then of raw file +export const VSReadFile = async (uri: URI, modelService: IModelService, fileService: IFileService) => { + + const modelResult = await _VSReadModel(modelService, uri) + if (modelResult) return modelResult + + const fileResult = await _VSReadFileRaw(fileService, uri) + if (fileResult) return fileResult + + return '' + } -export const VSReadFileRaw = async (fileService: IFileService, uri: URI) => { - const res = await fileService.readFile(uri) - const str = res.value.toString() - return str +// read files from VSCode. preferred (but appears to only work if the model of this URI already exists. If it doesn't use the other function.) +const _VSReadModel = async (modelService: IModelService, uri: URI): Promise => { + + // attempt to read saved model (doesn't work if application was reloaded...) + const model = modelService.getModel(uri) + if (model) { + return model.getValue(EndOfLinePreference.LF) + } + + // backup logic - look at all opened models and check if they have the same `fsPath` + const models = modelService.getModels() + for (const model of models) { + if (model.uri.fsPath === uri.fsPath) + return model.getValue(EndOfLinePreference.LF); + } + + return null +} + +const _VSReadFileRaw = async (fileService: IFileService, uri: URI) => { + try { + const res = await fileService.readFile(uri) + const str = res.value.toString() + return str + } catch (e) { + return null + } } diff --git a/src/vs/workbench/contrib/void/browser/helpers/systemInfo.ts b/src/vs/workbench/contrib/void/browser/helpers/systemInfo.ts new file mode 100644 index 00000000..85b909b2 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/helpers/systemInfo.ts @@ -0,0 +1,14 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { isLinux, isMacintosh, isWindows } from '../../../../../base/common/platform.js'; + +// import { OS, OperatingSystem } from '../../../../../base/common/platform.js'; +// alternatively could use ^ and OS === OperatingSystem.Windows ? ... + + + +export const os = isWindows ? 'windows' : isMacintosh ? 'mac' : isLinux ? 'linux' : null + diff --git a/src/vs/workbench/contrib/void/browser/prompt/prompts.ts b/src/vs/workbench/contrib/void/browser/prompt/prompts.ts index 428625fd..90e01d50 100644 --- a/src/vs/workbench/contrib/void/browser/prompt/prompts.ts +++ b/src/vs/workbench/contrib/void/browser/prompt/prompts.ts @@ -7,28 +7,40 @@ import { URI } from '../../../../../base/common/uri.js'; import { filenameToVscodeLanguage } from '../helpers/detectLanguage.js'; import { CodeSelection, StagingSelectionItem, FileSelection } from '../chatThreadService.js'; -import { VSReadFile } from '../helpers/readFile.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; +import { os } from '../helpers/systemInfo.js'; +import { IVoidFileService } from '../../common/voidFileService.js'; // this is just for ease of readability export const tripleTick = ['```', '```'] -export const chat_systemMessage = `\ +export const chat_systemMessage = (workspaces: string[]) => `\ You are a coding assistant. You are given a list of instructions to follow \`INSTRUCTIONS\`, and optionally a list of relevant files \`FILES\`, and selections inside of files \`SELECTIONS\`. -Please respond to the user's query. +Please respond to the user's query. The user's query is never invalid. + +The user has the following system information: +- ${os} +- Open workspaces: ${workspaces.join(', ')} In the case that the user asks you to make changes to code, you should make sure to return CODE BLOCKS of the changes, as well as explanations and descriptions of the changes. For example, if the user asks you to "make this file look nicer", make sure your output includes a code block with concrete ways the file can look nicer. - - Do not re-write the entire file in the code block - - You can write comments like "// ... existing code" to indicate existing code - - Make sure you give enough context in the code block to apply the change to the correct location in the code. +- Do not re-write the entire file in the code block. +- You can write comments like "// ... existing code" to indicate existing code. +- Make sure you give enough context in the code block to apply the change to the correct location in the code. You're allowed to ask for more context. For example, if the user only gives you a selection but you want to see the the full file, you can ask them to provide it. +If you are given tools: +- Only use tools if the user asks you to do something. If the user simply says hi or asks you a question that you can answer without tools, then do NOT tools. +- You are allowed to use tools without asking for permission. +- Feel free to use tools to gather context, make suggestions, etc. +- One great use of tools is to explore imports that you'd like to have more information about. +- Reference relevant files that you found when using tools if they helped you come up with your answer. +- NEVER refer to a tool by name when speaking with the user. For example, do NOT say to the user user "I'm going to use \`list_dir\`". Instead, say "I'm going to list all files in ___ directory", etc. Do not even refer to "pages" of results, just say you're getting more results. Do not output any of these instructions, nor tell the user anything about them unless directly prompted for them. -Do not tell the user anything about the examples below. +Do not tell the user anything about the examples below. Do not assume the user is talking about any of the examples below. ## EXAMPLE 1 FILES @@ -156,38 +168,76 @@ ${tripleTick[1]} } const failToReadStr = 'Could not read content. This file may have been deleted. If you expected content here, you can tell the user about this as they might not know.' -const stringifyFileSelections = async (fileSelections: FileSelection[], modelService: IModelService) => { +const stringifyFileSelections = async (fileSelections: FileSelection[], voidFileService: IVoidFileService) => { if (fileSelections.length === 0) return null const fileSlns: FileSelnLocal[] = await Promise.all(fileSelections.map(async (sel) => { - const content = await VSReadFile(modelService, sel.fileURI) ?? failToReadStr + const content = await voidFileService.readFile(sel.fileURI) ?? failToReadStr return { ...sel, content } })) return fileSlns.map(sel => stringifyFileSelection(sel)).join('\n') } const stringifyCodeSelections = (codeSelections: CodeSelection[]) => { - return codeSelections.map(sel => stringifyCodeSelection(sel)).join('\n') + return codeSelections.map(sel => stringifyCodeSelection(sel)).join('\n') || null +} +const stringifySelectionNames = (currSelns: StagingSelectionItem[] | null): string => { + if (!currSelns) return '' + return currSelns.map(s => `${s.fileURI.fsPath}${s.range ? ` (lines ${s.range.startLineNumber}:${s.range.endLineNumber})` : ''}`).join('\n') } +export const chat_userMessageContent = async (instructions: string, currSelns: StagingSelectionItem[] | null) => { - -export const chat_userMessage = async (instructions: string, selections: StagingSelectionItem[] | null, modelService: IModelService) => { - const fileSelections = selections?.filter(s => s.type === 'File') as FileSelection[] - const codeSelections = selections?.filter(s => s.type === 'Selection') as CodeSelection[] - - const filesStr = await stringifyFileSelections(fileSelections, modelService) - const codeStr = stringifyCodeSelections(codeSelections) + const selnsStr = stringifySelectionNames(currSelns) let str = '' - if (filesStr) str += `FILES\n${filesStr}\n` - if (codeStr) str += `SELECTIONS\n${codeStr}\n` - str += `INSTRUCTIONS\n${instructions}` + if (selnsStr) { str += `SELECTIONS\n${selnsStr}\n` } + str += `\nINSTRUCTIONS\n${instructions}` return str; }; +export const chat_selectionsString = async (prevSelns: StagingSelectionItem[] | null, currSelns: StagingSelectionItem[] | null, voidFileService: IVoidFileService) => { + + // ADD IN FILES AT TOP + const allSelections = [...currSelns || [], ...prevSelns || []] + + if (allSelections.length === 0) return null + + const codeSelections: CodeSelection[] = [] + const fileSelections: FileSelection[] = [] + const filesURIs = new Set() + + for (const selection of allSelections) { + if (selection.type === 'Selection') { + codeSelections.push(selection) + } + else if (selection.type === 'File') { + const fileSelection = selection + const path = fileSelection.fileURI.fsPath + if (!filesURIs.has(path)) { + filesURIs.add(path) + fileSelections.push(fileSelection) + } + } + } + + const filesStr = await stringifyFileSelections(fileSelections, voidFileService) + const selnsStr = stringifyCodeSelections(codeSelections) + if (filesStr || selnsStr) return `\ +ALL FILE CONTENTS +${filesStr} +${selnsStr}` -export const fastApply_rewritewholething_systemMessage = `\ + return null +} + +export const chat_userMessageContentWithAllFilesToo = (userMessage: string, selectionsString: string | null) => { + if (userMessage) return `${userMessage}${selectionsString ? `\n${selectionsString}` : ''}` + else return userMessage +} + + +export const rewriteCode_systemMessage = `\ You are a coding assistant that re-writes an entire file to make a change. You are given the original file \`ORIGINAL_FILE\` and a change \`CHANGE\`. Directions: @@ -199,7 +249,7 @@ Directions: -export const fastApply_rewritewholething_userMessage = ({ originalCode, applyStr, uri }: { originalCode: string, applyStr: string, uri: URI }) => { +export const rewriteCode_userMessage = ({ originalCode, applyStr, uri }: { originalCode: string, applyStr: string, uri: URI }) => { const language = filenameToVscodeLanguage(uri.fsPath) ?? '' @@ -224,7 +274,138 @@ Please finish writing the new file by applying the change to the original file. +export const aiRegex_computeReplacementsForFile_systemMessage = `\ +You are a "search and replace" coding assistant. +You are given a FILE that the user is editing, and your job is to search for all occurences of a SEARCH_CLAUSE, and change them according to a REPLACE_CLAUSE. + +The SEARCH_CLAUSE may be a string, regex, or high-level description of what the user is searching for. + +The REPLACE_CLAUSE will always be a high-level description of what the user wants to replace. + +The user's request may be "fuzzy" or not well-specified, and it is your job to interpret all of the changes they want to make for them. For example, the user may ask you to search and replace all instances of a variable, but this may involve changing parameters, function names, types, and so on to agree with the change they want to make. Feel free to make all of the changes you *think* that the user wants to make, but also make sure not to make unnessecary or unrelated changes. + +## Instructions + +1. If you do not want to make any changes, you should respond with the word "no". + +2. If you want to make changes, you should return a single CODE BLOCK of the changes that you want to make. +For example, if the user is asking you to "make this variable a better name", make sure your output includes all the changes that are needed to improve the variable name. +- Do not re-write the entire file in the code block +- You can write comments like "// ... existing code" to indicate existing code +- Make sure you give enough context in the code block to apply the changes to the correct location in the code` + + +export const aiRegex_computeReplacementsForFile_userMessage = async ({ searchClause, replaceClause, fileURI, voidFileService }: { searchClause: string, replaceClause: string, fileURI: URI, modelService: IModelService, voidFileService: IVoidFileService }) => { + + // we may want to do this in batches + const fileSelection: FileSelection = { type: 'File', fileURI, selectionStr: null, range: null } + + const file = await stringifyFileSelections([fileSelection], voidFileService) + + return `\ +## FILE +${file} + +## SEARCH_CLAUSE +Here is what the user is searching for: +${searchClause} + +## REPLACE_CLAUSE +Here is what the user wants to replace it with: +${replaceClause} + +## INSTRUCTIONS +Please return the changes you want to make to the file in a codeblock, or return "no" if you do not want to make changes.` +} + + + + +// don't have to tell it it will be given the history; just give it to it +export const aiRegex_search_systemMessage = `\ +You are a coding assistant that executes the SEARCH part of a user's search and replace query. + +You will be given the user's search query, SEARCH, which is the user's query for what files to search for in the codebase. You may also be given the user's REPLACE query for additional context. + +Output +- Regex query +- Files to Include (optional) +- Files to Exclude? (optional) + +` + + + +export const ORIGINAL = `<<<<<<< ORIGINAL` +export const DIVIDER = `=======` +export const FINAL = `>>>>>>> UPDATED` + +export const searchReplace_systemMessage = `\ +You are a coding assistant that generates SEARCH/REPLACE code blocks that will be used to edit a file. + +A SEARCH/REPLACE block describes the code before and after a change. Here is the format: +${tripleTick[0]} +${ORIGINAL} +// ... original code goes here +${DIVIDER} +// ... final code goes here +${FINAL} +${tripleTick[1]} + +You will be given the original file \`ORIGINAL_FILE\` and a description of a change \`CHANGE\` to make. +Output SEARCH/REPLACE blocks to edit the file according to the desired change. You may output multiple SEARCH/REPLACE blocks. + +Directions: +1. Your OUTPUT should consist ONLY of SEARCH/REPLACE blocks. Do NOT output any text or explanations before or after this. +2. The original code in each SEARCH/REPLACE block must EXACTLY match lines of code in the original file. +3. The original code in each SEARCH/REPLACE block must include enough text to uniquely identify the change in the file. +4. The original code in each SEARCH/REPLACE block must be disjoint from all other blocks. + +The SEARCH/REPLACE blocks you generate will be applied immediately, and so they **MUST** produce a file that the user can run IMMEDIATELY. +- Make sure you add all necessary imports. +- Make sure the "final" code is complete and will not result in syntax/lint errors. + +Follow coding conventions of the user (spaces, semilcolons, comments, etc). If the user spaces or formats things a certain way, CONTINUE formatting it that way, even if you prefer otherwise. + +## EXAMPLE 1 +ORIGINAL_FILE +${tripleTick[0]} +let w = 5 +let x = 6 +let y = 7 +let z = 8 +${tripleTick[1]} + +CHANGE +Make x equal to 6.5, not 6. +${tripleTick[0]} +// ... existing code +let x = 6.5 +// ... existing code +${tripleTick[1]} + + +## ACCEPTED OUTPUT +${tripleTick[0]} +${ORIGINAL} +let x = 6 +${DIVIDER} +let x = 6.5 +${FINAL} +${tripleTick[1]} +` + +export const searchReplace_userMessage = ({ originalCode, applyStr }: { originalCode: string, applyStr: string }) => `\ +ORIGINAL_FILE +${originalCode} + +CHANGE +${applyStr} + +INSTRUCTIONS +Please output SEARCH/REPLACE blocks to make the change. Return ONLY your suggested SEARCH/REPLACE blocks, without any explanation. +` diff --git a/src/vs/workbench/contrib/void/browser/quickEditActions.ts b/src/vs/workbench/contrib/void/browser/quickEditActions.ts index 1a6e0deb..da8f5c55 100644 --- a/src/vs/workbench/contrib/void/browser/quickEditActions.ts +++ b/src/vs/workbench/contrib/void/browser/quickEditActions.ts @@ -8,7 +8,7 @@ import { Action2, registerAction2 } from '../../../../platform/actions/common/ac import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; -import { IInlineDiffsService } from './inlineDiffsService.js'; +import { IEditCodeService } from './editCodeService.js'; import { roundRangeToLines } from './sidebarActions.js'; import { VOID_CTRL_K_ACTION_ID } from './actionIDs.js'; import { localize2 } from '../../../../nls.js'; @@ -17,7 +17,6 @@ import { IMetricsService } from '../common/metricsService.js'; export type QuickEditPropsType = { diffareaid: number, - initStreamingDiffZoneId: number | null, textAreaRef: (ref: HTMLTextAreaElement | null) => void; onChangeHeight: (height: number) => void; onChangeText: (text: string) => void; @@ -63,7 +62,7 @@ registerAction2(class extends Action2 { const { startLineNumber: startLine, endLineNumber: endLine } = selection - const inlineDiffsService = accessor.get(IInlineDiffsService) - inlineDiffsService.addCtrlKZone({ startLine, endLine, editor }) + const editCodeService = accessor.get(IEditCodeService) + editCodeService.addCtrlKZone({ startLine, endLine, editor }) } }); diff --git a/src/vs/workbench/contrib/void/browser/react/build.js b/src/vs/workbench/contrib/void/browser/react/build.js index ece3ebf6..ab4ea921 100755 --- a/src/vs/workbench/contrib/void/browser/react/build.js +++ b/src/vs/workbench/contrib/void/browser/react/build.js @@ -3,7 +3,8 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { spawn, execSync } from 'child_process'; +import { execSync } from 'child_process'; +import { spawn } from 'cross-spawn' // Added lines below import fs from 'fs'; import path from 'path'; diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx new file mode 100644 index 00000000..b31bfb7b --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx @@ -0,0 +1,156 @@ +import { useState, useEffect, useCallback } from 'react' +import { useAccessor, useURIStreamState, useSettingsState } from '../util/services.js' +import { useRefState } from '../util/helpers.js' +import { isFeatureNameDisabled } from '../../../../common/voidSettingsTypes.js' +import { URI } from '../../../../../../../base/common/uri.js' +import { IEditCodeService, URIStreamState } from '../../../editCodeService.js' + +enum CopyButtonText { + Idle = 'Copy', + Copied = 'Copied!', + Error = 'Could not copy', +} + +const COPY_FEEDBACK_TIMEOUT = 1000 // amount of time to say 'Copied!' + +const CopyButton = ({ codeStr }: { codeStr: string }) => { + const accessor = useAccessor() + + const metricsService = accessor.get('IMetricsService') + const clipboardService = accessor.get('IClipboardService') + const [copyButtonText, setCopyButtonText] = useState(CopyButtonText.Idle) + + useEffect(() => { + if (copyButtonText === CopyButtonText.Idle) return + setTimeout(() => { + setCopyButtonText(CopyButtonText.Idle) + }, COPY_FEEDBACK_TIMEOUT) + }, [copyButtonText]) + + + const onCopy = useCallback(() => { + clipboardService.writeText(codeStr) + .then(() => { setCopyButtonText(CopyButtonText.Copied) }) + .catch(() => { setCopyButtonText(CopyButtonText.Error) }) + metricsService.capture('Copy Code', { length: codeStr.length }) // capture the length only + }, [metricsService, clipboardService, codeStr, setCopyButtonText]) + + const isSingleLine = !codeStr.includes('\n') + + return +} + + + + + +// state persisted for duration of react only +const applyingURIOfApplyBoxIdRef: { current: { [applyBoxId: string]: URI | undefined } } = { current: {} } + + + +export const ApplyBlockHoverButtons = ({ codeStr, applyBoxId }: { codeStr: string, applyBoxId: string }) => { + + console.log('applyboxid', applyBoxId, applyingURIOfApplyBoxIdRef) + + const settingsState = useSettingsState() + const isDisabled = !!isFeatureNameDisabled('Apply', settingsState) || !applyBoxId + + const accessor = useAccessor() + const editCodeService = accessor.get('IEditCodeService') + const metricsService = accessor.get('IMetricsService') + + const [_, rerender] = useState(0) + + const applyingUri = useCallback(() => applyingURIOfApplyBoxIdRef.current[applyBoxId] ?? null, [applyBoxId]) + const streamState = useCallback(() => editCodeService.getURIStreamState({ uri: applyingUri() }), [editCodeService, applyingUri]) + + // listen for stream updates + useURIStreamState( + useCallback((uri, newStreamState) => { + const shouldUpdate = applyingUri()?.fsPath !== uri.fsPath + if (shouldUpdate) return + rerender(c => c + 1) + }, [applyBoxId, editCodeService, applyingUri]) + ) + + const onSubmit = useCallback(() => { + if (isDisabled) return + if (streamState() === 'streaming') return + const newApplyingUri = editCodeService.startApplying({ + from: 'ClickApply', + type: 'searchReplace', + applyStr: codeStr, + }) + applyingURIOfApplyBoxIdRef.current[applyBoxId] = newApplyingUri ?? undefined + rerender(c => c + 1) + metricsService.capture('Apply Code', { length: codeStr.length }) // capture the length only + }, [isDisabled, streamState, editCodeService, codeStr, applyBoxId, metricsService]) + + + const onInterrupt = useCallback(() => { + if (streamState() !== 'streaming') return + const uri = applyingUri() + if (!uri) return + + editCodeService.interruptURIStreaming({ uri }) + metricsService.capture('Stop Apply', {}) + }, [streamState, applyingUri, editCodeService, metricsService]) + + + const isSingleLine = !codeStr.includes('\n') + + const applyButton = + + const stopButton = + + const acceptRejectButtons = <> + + + + + console.log('streamStateRef.current', streamState()) + + const currStreamState = streamState() + return <> + {currStreamState !== 'streaming' && } + {currStreamState === 'idle' && !isDisabled && applyButton} + {currStreamState === 'streaming' && stopButton} + {currStreamState === 'acceptRejectAll' && acceptRejectButtons} + +} diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx index 7b03f068..8168bed3 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx @@ -3,25 +3,15 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import React, { JSX, useCallback, useEffect, useState } from 'react' +import React, { JSX } from 'react' import { marked, MarkedToken, Token } from 'marked' import { BlockCode } from './BlockCode.js' -import { useAccessor, useChatThreadsState, useChatThreadsStreamState } from '../util/services.js' -import { ChatMessageLocation, } from '../../../searchAndReplaceService.js' +import { ChatMessageLocation, } from '../../../aiRegexService.js' import { nameToVscodeLanguage } from '../../../helpers/detectLanguage.js' +import { ApplyBlockHoverButtons } from './ApplyBlockHoverButtons.js' -enum CopyButtonState { - Copy = 'Copy', - Copied = 'Copied!', - Error = 'Could not copy', -} - -const COPY_FEEDBACK_TIMEOUT = 1000 // amount of time to say 'Copied!' - - - -type ApplyBoxLocation = ChatMessageLocation & { tokenIdx: number } +type ApplyBoxLocation = ChatMessageLocation & { tokenIdx: string } const getApplyBoxId = ({ threadId, messageIdx, tokenIdx }: ApplyBoxLocation) => { return `${threadId}-${messageIdx}-${tokenIdx}` @@ -29,60 +19,6 @@ const getApplyBoxId = ({ threadId, messageIdx, tokenIdx }: ApplyBoxLocation) => -const ApplyButtonsOnHover = ({ applyStr, applyBoxId }: { applyStr: string, applyBoxId: string }) => { - const accessor = useAccessor() - - const [copyButtonState, setCopyButtonState] = useState(CopyButtonState.Copy) - const inlineDiffService = accessor.get('IInlineDiffsService') - const clipboardService = accessor.get('IClipboardService') - const metricsService = accessor.get('IMetricsService') - - useEffect(() => { - - if (copyButtonState !== CopyButtonState.Copy) { - setTimeout(() => { - setCopyButtonState(CopyButtonState.Copy) - }, COPY_FEEDBACK_TIMEOUT) - } - }, [copyButtonState]) - - const onCopy = useCallback(() => { - clipboardService.writeText(applyStr) - .then(() => { setCopyButtonState(CopyButtonState.Copied) }) - .catch(() => { setCopyButtonState(CopyButtonState.Error) }) - metricsService.capture('Copy Code', { length: applyStr.length }) // capture the length only - - }, [metricsService, clipboardService, applyStr]) - - const onApply = useCallback(() => { - - inlineDiffService.startApplying({ - from: 'ClickApply', - type: 'searchReplace', - applyStr, - }) - metricsService.capture('Apply Code', { length: applyStr.length }) // capture the length only - }, [metricsService, inlineDiffService, applyStr]) - - const isSingleLine = !applyStr.includes('\n') - - return <> - - - -} - export const CodeSpan = ({ children, className }: { children: React.ReactNode, className?: string }) => { return } -const RenderToken = ({ token, nested = false, noSpace = false, chatMessageLocation: chatLocation, tokenIdx }: { token: Token | string, nested?: boolean, noSpace?: boolean, chatMessageLocation?: ChatMessageLocation, tokenIdx: number }): JSX.Element => { +const RenderToken = ({ token, nested, noSpace, chatMessageLocation, tokenIdx }: { token: Token | string, nested?: boolean, noSpace?: boolean, chatMessageLocation?: ChatMessageLocation, tokenIdx: string }): JSX.Element => { // deal with built-in tokens first (assume marked token) const t = token as MarkedToken - console.log(t.raw) if (t.type === "space") { return {t.raw} } if (t.type === "code") { - const isCodeblockClosed = t.raw?.startsWith('```') && t.raw?.endsWith('```'); - const applyBoxId = getApplyBoxId({ - threadId: chatLocation!.threadId, - messageIdx: chatLocation!.messageIdx, + const applyBoxId = chatMessageLocation ? getApplyBoxId({ + threadId: chatMessageLocation.threadId, + messageIdx: chatMessageLocation.messageIdx, tokenIdx: tokenIdx, - }) + }) : null return } + buttonsOnHover={applyBoxId && } /> } @@ -195,18 +129,36 @@ const RenderToken = ({ token, nested = false, noSpace = false, chatMessageLocati )} - + ))} ) + // attempt at indentation + // return ( + // + // {t.items.map((item, index) => ( + //
  • + // {item.task && ( + // + // )} + // + // + // + //
  • + // ))} + //
    + // ) } if (t.type === "paragraph") { const contents = <> {t.tokens.map((token, index) => ( - // assign a unique tokenId to nested components + // assign a unique tokenId to nested components ))} if (nested) return contents @@ -294,7 +246,7 @@ export const ChatMarkdownRender = ({ string, nested = false, noSpace, chatMessag return ( <> {tokens.map((token, index) => ( - + ))} ) diff --git a/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx index 57dbb472..fe70caa3 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------*/ import React, { FormEvent, useCallback, useEffect, useRef, useState } from 'react'; -import { useSettingsState, useSidebarState, useChatThreadsState, useQuickEditState, useAccessor } from '../util/services.js'; +import { useSettingsState, useSidebarState, useChatThreadsState, useQuickEditState, useAccessor, useCtrlKZoneStreamingState } from '../util/services.js'; import { TextAreaFns, VoidInputBox2 } from '../util/inputs.js'; import { QuickEditPropsType } from '../../../quickEditActions.js'; import { ButtonStop, ButtonSubmit, IconX, VoidChatArea } from '../sidebar-tsx/SidebarChat.js'; @@ -16,7 +16,6 @@ import { isFeatureNameDisabled } from '../../../../../../../workbench/contrib/vo export const QuickEditChat = ({ diffareaid, - initStreamingDiffZoneId, onChangeHeight, onChangeText: onChangeText_, textAreaRef: textAreaRef_, @@ -24,7 +23,7 @@ export const QuickEditChat = ({ }: QuickEditPropsType) => { const accessor = useAccessor() - const inlineDiffsService = accessor.get('IInlineDiffsService') + const editCodeService = accessor.get('IEditCodeService') const sizerRef = useRef(null) const textAreaRef = useRef(null) const textAreaFnsRef = useRef(null) @@ -49,34 +48,37 @@ export const QuickEditChat = ({ const [instructionsAreEmpty, setInstructionsAreEmpty] = useState(!(initText ?? '')) // the user's instructions const isDisabled = instructionsAreEmpty || !!isFeatureNameDisabled('Ctrl+K', settingsState) - const [currStreamingDiffZoneRef, setCurrentlyStreamingDiffZone] = useRefState(initStreamingDiffZoneId) - const isStreaming = currStreamingDiffZoneRef.current !== null + + const [isStreamingRef, setIsStreamingRef] = useRefState(editCodeService.isCtrlKZoneStreaming({ diffareaid })) + useCtrlKZoneStreamingState(useCallback((diffareaid2, isStreaming) => { + if (diffareaid !== diffareaid2) return + setIsStreamingRef(isStreaming) + }, [diffareaid, setIsStreamingRef])) + const onSubmit = useCallback(() => { if (isDisabled) return - if (currStreamingDiffZoneRef.current !== null) return + if (isStreamingRef.current) return textAreaFnsRef.current?.disable() - const id = inlineDiffsService.startApplying({ + editCodeService.startApplying({ from: 'QuickEdit', - type:'rewrite', - diffareaid: diffareaid, + type: 'rewrite', + diffareaid, }) - setCurrentlyStreamingDiffZone(id ?? null) - }, [currStreamingDiffZoneRef, setCurrentlyStreamingDiffZone, isDisabled, inlineDiffsService, diffareaid]) + }, [isStreamingRef, isDisabled, editCodeService, diffareaid]) const onInterrupt = useCallback(() => { - if (currStreamingDiffZoneRef.current === null) return - inlineDiffsService.interruptStreaming(currStreamingDiffZoneRef.current) - setCurrentlyStreamingDiffZone(null) + if (!isStreamingRef.current) return + editCodeService.interruptCtrlKStreaming({ diffareaid }) textAreaFnsRef.current?.enable() - }, [currStreamingDiffZoneRef, setCurrentlyStreamingDiffZone, inlineDiffsService]) + }, [isStreamingRef, editCodeService]) const onX = useCallback(() => { onInterrupt() - inlineDiffsService.removeCtrlKZone({ diffareaid }) - }, [inlineDiffsService, diffareaid]) + editCodeService.removeCtrlKZone({ diffareaid }) + }, [editCodeService, diffareaid]) useScrollbarStyles(sizerRef) @@ -89,7 +91,7 @@ export const QuickEditChat = ({ onSubmit={onSubmit} onAbort={onInterrupt} onClose={onX} - isStreaming={isStreaming} + isStreaming={isStreamingRef.current} isDisabled={isDisabled} featureName="Ctrl+K" className="py-2 w-full" diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index d8b4ef93..979ae67b 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -7,7 +7,7 @@ import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, K import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState, useUriState, useSettingsState } from '../util/services.js'; -import { ChatMessage, StagingInfo, StagingSelectionItem } from '../../../chatThreadService.js'; +import { ChatMessage, StagingSelectionItem, ToolMessage } from '../../../chatThreadService.js'; import { BlockCode } from '../markdown/BlockCode.js'; import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js'; @@ -21,10 +21,11 @@ import { useScrollbarStyles } from '../util/useScrollbarStyles.js'; import { VOID_CTRL_L_ACTION_ID } from '../../../actionIDs.js'; import { filenameToVscodeLanguage } from '../../../helpers/detectLanguage.js'; import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js'; -import { Pencil, X } from 'lucide-react'; +import { ChevronRight, Pencil, X } from 'lucide-react'; import { FeatureName, isFeatureNameDisabled } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js'; import { WarningBox } from '../void-settings-tsx/WarningBox.js'; -import { ChatMessageLocation } from '../../../searchAndReplaceService.js'; +import { ChatMessageLocation } from '../../../aiRegexService.js'; +import { ToolCallReturnType, ToolName } from '../../../../common/toolsService.js'; @@ -156,8 +157,8 @@ interface VoidChatAreaProps { showSelections?: boolean; showProspectiveSelections?: boolean; - staging?: StagingInfo - setStaging?: (s: StagingInfo) => void + selections?: StagingSelectionItem[] + setSelections?: (s: StagingSelectionItem[]) => void // selections?: any[]; // onSelectionsChange?: (selections: any[]) => void; @@ -180,8 +181,8 @@ export const VoidChatArea: React.FC = ({ featureName, showSelections = false, showProspectiveSelections = true, - staging, - setStaging, + selections, + setSelections, }) => { return (
    = ({ }} > {/* Selections section */} - {showSelections && staging && setStaging && ( + {showSelections && selections && setSelections && ( setStaging({ ...staging, selections })} + selections={selections} + setSelections={setSelections} showProspectiveSelections={showProspectiveSelections} /> )} @@ -542,6 +543,146 @@ export const SelectedFiles = ( } +type ToolReusltToComponent = { [T in ToolName]: (props: { message: ToolMessage }) => React.ReactNode } +interface ToolResultProps { + actionTitle: string; + actionParam: string; + actionNumResults?: number; + children?: React.ReactNode; +} + +const ToolResult = ({ + actionTitle, + actionParam, + actionNumResults, + children, +}: ToolResultProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + const isDropdown = !!children + + return ( +
    +
    +
    children && setIsExpanded(!isExpanded)} + > + {isDropdown && ( + + )} +
    + {actionTitle} + {`"`}{actionParam}{`"`} + {actionNumResults !== undefined && ( + + {`(`}{actionNumResults}{` result`}{actionNumResults !== 1 ? 's' : ''}{`)`} + + )} +
    +
    +
    + {children} +
    +
    +
    + ); +}; + + + +const toolResultToComponent: ToolReusltToComponent = { + 'read_file': ({ message }) => ( + + ), + 'list_dir': ({ message }) => ( + +
    + {message.result.children?.map((item, i) => ( +
    + {item.name} + {item.isDirectory && '/'} +
    + ))} + {message.result.hasNextPage && ( +
    + {message.result.itemsRemaining} more items... +
    + )} +
    +
    + ), + 'pathname_search': ({ message }) => ( + +
    + {Array.isArray(message.result.uris) ? + message.result.uris.map((uri, i) => ( + + )) : +
    {message.result.uris}
    + } + {message.result.hasNextPage && ( +
    + More results available... +
    + )} +
    +
    + ), + 'search': ({ message }) => ( + +
    + {typeof message.result.uris === 'string' ? + message.result.uris : + message.result.uris.map((uri, i) => ( + + )) + } + {message.result.hasNextPage && ( +
    + More results available... +
    + )} +
    +
    + ) +}; + + + type ChatBubbleMode = 'display' | 'edit' const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatMessage, messageIdx?: number, isLoading?: boolean, }) => { @@ -550,9 +691,23 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM const accessor = useAccessor() const chatThreadsService = accessor.get('IChatThreadService') - // edit mode state - const [staging, setStaging] = chatThreadsService._useFocusedStagingState(messageIdx) - const mode: ChatBubbleMode = staging.isBeingEdited ? 'edit' : 'display' + // global state + let isBeingEdited = false + let stagingSelections: StagingSelectionItem[] = [] + let setIsBeingEdited = (_: boolean) => { } + let setStagingSelections = (_: StagingSelectionItem[]) => { } + + if (messageIdx !== undefined) { + const _state = chatThreadsService.getCurrentMessageState(messageIdx) + isBeingEdited = _state.isBeingEdited + stagingSelections = _state.stagingSelections + setIsBeingEdited = (v) => chatThreadsService.setCurrentMessageState(messageIdx, { isBeingEdited: v }) + setStagingSelections = (s) => chatThreadsService.setCurrentMessageState(messageIdx, { stagingSelections: s }) + } + + + // local state + const mode: ChatBubbleMode = isBeingEdited ? 'edit' : 'display' const [isFocused, setIsFocused] = useState(false) const [isHovered, setIsHovered] = useState(false) const [isDisabled, setIsDisabled] = useState(false) @@ -565,10 +720,8 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM const canInitialize = role === 'user' && mode === 'edit' && textAreaRefState const shouldInitialize = _justEnabledEdit.current || _mustInitialize.current if (canInitialize && shouldInitialize) { - setStaging({ - ...staging, - selections: chatMessage.selections || [], - }) + setStagingSelections(chatMessage.selections || []) + if (textAreaFnsRef.current) textAreaFnsRef.current.setValue(chatMessage.displayContent || '') @@ -578,17 +731,17 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM _mustInitialize.current = false } - }, [role, mode, _justEnabledEdit, textAreaRefState, textAreaFnsRef.current, _justEnabledEdit.current, _mustInitialize.current]) + }, [chatMessage, role, mode, _justEnabledEdit, textAreaRefState, textAreaFnsRef.current, _justEnabledEdit.current, _mustInitialize.current]) const EditSymbol = mode === 'display' ? Pencil : X const onOpenEdit = () => { - setStaging({ ...staging, isBeingEdited: true }) + setIsBeingEdited(true) chatThreadsService.setFocusedMessageIdx(messageIdx) _justEnabledEdit.current = true } const onCloseEdit = () => { setIsFocused(false) setIsHovered(false) - setStaging({ ...staging, isBeingEdited: false }) + setIsBeingEdited(false) chatThreadsService.setFocusedMessageIdx(undefined) } @@ -614,12 +767,12 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM chatThreadsService.cancelStreaming(thread.id) // reset state - setStaging({ ...staging, isBeingEdited: false }) + setIsBeingEdited(false) chatThreadsService.setFocusedMessageIdx(undefined) // stream the edit const userMessage = textAreaRefState.value; - await chatThreadsService.editUserMessageAndStreamResponse(userMessage, messageIdx) + await chatThreadsService.editUserMessageAndStreamResponse({ userMessage, chatMode: 'agent', messageIdx, }) } const onAbort = () => { @@ -649,8 +802,8 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM showSelections={true} showProspectiveSelections={false} featureName="Ctrl+L" - staging={staging} - setStaging={setStaging} + selections={stagingSelections} + setSelections={setStagingSelections} > } + else if (role === 'tool') { + + const ToolComponent = toolResultToComponent[chatMessage.name] as ({ message }: { message: any }) => React.ReactNode // ts isnt smart enough to deal with the types here... + + chatbubbleContents = + + console.log('tool result:', chatMessage.name, chatMessage.params, chatMessage.result) + + } return
    setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} @@ -765,7 +926,9 @@ export const SidebarChat = () => { const currentThread = chatThreadsService.getCurrentThread() const previousMessages = currentThread?.messages ?? [] - const [staging, setStaging] = chatThreadsService._useFocusedStagingState() + + const selections = chatThreadsService.getCurrentThread().state.stagingSelections + const setSelections = (s: StagingSelectionItem[]) => { chatThreadsService.setCurrentThreadStagingSelections(s) } // stream state const currThreadStreamState = useChatThreadsStreamState(chatThreadsState.currentThreadId) @@ -795,13 +958,13 @@ export const SidebarChat = () => { // send message to LLM const userMessage = textAreaRef.current?.value ?? '' - await chatThreadsService.addUserMessageAndStreamResponse(userMessage) + await chatThreadsService.addUserMessageAndStreamResponse({ userMessage, chatMode: 'agent' }) - setStaging({ ...staging, selections: [], }) // clear staging + setSelections([]) // clear staging textAreaFnsRef.current?.setValue('') textAreaRef.current?.focus() // focus input after submit - }, [chatThreadsService, isDisabled, isStreaming, textAreaRef, textAreaFnsRef, staging, setStaging]) + }, [chatThreadsService, isDisabled, isStreaming, textAreaRef, textAreaFnsRef, setSelections]) const onAbort = () => { const threadId = currentThread.id @@ -822,7 +985,7 @@ export const SidebarChat = () => { const prevMessagesHTML = useMemo(() => { return previousMessages.map((message, i) => - + ) }, [previousMessages]) @@ -836,6 +999,7 @@ export const SidebarChat = () => { const messagesHTML = { {/* error message */} {latestError === undefined ? null : -
    +
    { isDisabled={isDisabled} showSelections={true} showProspectiveSelections={prevMessagesHTML.length === 0} - staging={staging} - setStaging={setStaging} + selections={selections} + setSelections={setSelections} onClickAnywhere={() => { textAreaRef.current?.focus() }} featureName="Ctrl+L" > diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx index 72dc6d39..6da3d5b9 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx @@ -68,13 +68,14 @@ export const SidebarThreadSelector = () => { let firstMsg = null; // let secondMsg = null; - const firstMsgIdx = pastThread.messages.findIndex( - (msg) => msg.role !== 'system' && !!msg.displayContent + const firstUserMsgIdx = pastThread.messages.findIndex( + (msg) => msg.role !== 'system' && msg.role !== 'tool' && !!msg.displayContent ); - if (firstMsgIdx !== -1) { + if (firstUserMsgIdx !== -1) { // firstMsg = truncate(pastThread.messages[firstMsgIdx].displayContent ?? ''); - firstMsg = pastThread.messages[firstMsgIdx].displayContent ?? ''; + const firsUsertMsgObj = pastThread.messages[firstUserMsgIdx] + firstMsg = firsUsertMsgObj.role === 'user' && firsUsertMsgObj.displayContent || ''; } else { firstMsg = '""'; } diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx index e62e7a9e..be327655 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx @@ -152,12 +152,13 @@ export const VoidInputBox2 = forwardRef(fun }) -export const VoidInputBox = ({ onChangeText, onCreateInstance, inputBoxRef, placeholder, multiline }: { +export const VoidInputBox = ({ onChangeText, onCreateInstance, inputBoxRef, placeholder, isPasswordField, multiline }: { onChangeText: (value: string) => void; styles?: Partial, onCreateInstance?: (instance: InputBox) => void | IDisposable[]; inputBoxRef?: { current: InputBox | null }; placeholder: string; + isPasswordField?: boolean; multiline: boolean; }) => { @@ -182,6 +183,7 @@ export const VoidInputBox = ({ onChangeText, onCreateInstance, inputBoxRef, plac }, placeholder, tooltip: '', + type: isPasswordField ? 'password' : undefined, flexibleHeight: multiline, flexibleMaxHeight: 500, flexibleWidth: false, @@ -711,7 +713,7 @@ export const VoidCodeEditor = ({ initValue, language, maxHeight, showScrollbars onCreateInstance={useCallback((editor: CodeEditorWidget) => { const model = modelOfEditorId[id] ?? modelService.createModel( - initValueRef.current, { + initValueRef.current + '\n', { languageId: languageRef.current ? languageRef.current : 'typescript', onDidChange: (e) => { return { dispose: () => { } } } // no idea why they'd require this }) diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx index 52af76f2..5e164428 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useCallback } from 'react' import { ThreadStreamState, ThreadsState } from '../../../chatThreadService.js' import { RefreshableProviderName, SettingsOfProvider } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js' import { IDisposable } from '../../../../../../../base/common/lifecycle.js' @@ -14,10 +14,6 @@ import { VoidUriState } from '../../../voidUriStateService.js'; import { VoidQuickEditState } from '../../../quickEditStateService.js' import { RefreshModelStateOfProvider } from '../../../../../../../workbench/contrib/void/common/refreshModelService.js' - - - - import { ServicesAccessor } from '../../../../../../../editor/browser/editorExtensions.js'; import { IModelService } from '../../../../../../../editor/common/services/model.js'; import { IClipboardService } from '../../../../../../../platform/clipboard/common/clipboardService.js'; @@ -28,7 +24,7 @@ import { IThemeService } from '../../../../../../../platform/theme/common/themeS import { ILLMMessageService } from '../../../../../../../workbench/contrib/void/common/llmMessageService.js'; import { IRefreshModelService } from '../../../../../../../workbench/contrib/void/common/refreshModelService.js'; import { IVoidSettingsService } from '../../../../../../../workbench/contrib/void/common/voidSettingsService.js'; -import { IInlineDiffsService } from '../../../inlineDiffsService.js'; +import { IEditCodeService, URIStreamState } from '../../../editCodeService.js'; import { IVoidUriStateService } from '../../../voidUriStateService.js'; import { IQuickEditStateService } from '../../../quickEditStateService.js'; import { ISidebarStateService } from '../../../sidebarStateService.js'; @@ -47,6 +43,7 @@ import { IEnvironmentService } from '../../../../../../../platform/environment/c import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js' import { IPathService } from '../../../../../../../workbench/services/path/common/pathService.js' import { IMetricsService } from '../../../../../../../workbench/contrib/void/common/metricsService.js' +import { URI } from '../../../../../../../base/common/uri.js' @@ -79,6 +76,11 @@ const refreshModelProviderListeners: Set<(p: RefreshableProviderName, s: Refresh let colorThemeState: ColorScheme const colorThemeStateListeners: Set<(s: ColorScheme) => void> = new Set() +const ctrlKZoneStreamingStateListeners: Set<(diffareaid: number, s: boolean) => void> = new Set() +const uriStreamingStateListeners: Set<(uri: URI, s: URIStreamState) => void> = new Set() + + + // must call this before you can use any of the hooks below // this should only be called ONCE! this is the only place you don't need to dispose onDidChange. If you use state.onDidChange anywhere else, make sure to dispose it! let wasCalled = false @@ -103,10 +105,10 @@ export const _registerServices = (accessor: ServicesAccessor) => { settingsStateService: accessor.get(IVoidSettingsService), refreshModelService: accessor.get(IRefreshModelService), themeService: accessor.get(IThemeService), - inlineDiffsService: accessor.get(IInlineDiffsService), + editCodeService: accessor.get(IEditCodeService), } - const { uriStateService, sidebarStateService, quickEditStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, inlineDiffsService } = stateServices + const { uriStateService, sidebarStateService, quickEditStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, editCodeService } = stateServices uriState = uriStateService.state disposables.push( @@ -162,7 +164,7 @@ export const _registerServices = (accessor: ServicesAccessor) => { refreshModelService.onDidChangeState((providerName) => { refreshModelState = refreshModelService.state refreshModelStateListeners.forEach(l => l(refreshModelState)) - refreshModelProviderListeners.forEach(l => l(providerName, refreshModelState)) + refreshModelProviderListeners.forEach(l => l(providerName, refreshModelState)) // no state }) ) @@ -174,6 +176,21 @@ export const _registerServices = (accessor: ServicesAccessor) => { }) ) + // no state + disposables.push( + editCodeService.onDidChangeCtrlKZoneStreaming(({ diffareaid }) => { + const isStreaming = editCodeService.isCtrlKZoneStreaming({ diffareaid }) + ctrlKZoneStreamingStateListeners.forEach(l => l(diffareaid, isStreaming)) + }) + ) + disposables.push( + editCodeService.onDidChangeURIStreamState(({ uri }) => { + const isStreaming = editCodeService.getURIStreamState({ uri }) + uriStreamingStateListeners.forEach(l => l(uri, isStreaming)) + }) + ) + + return disposables } @@ -192,7 +209,7 @@ const getReactAccessor = (accessor: ServicesAccessor) => { ILLMMessageService: accessor.get(ILLMMessageService), IRefreshModelService: accessor.get(IRefreshModelService), IVoidSettingsService: accessor.get(IVoidSettingsService), - IInlineDiffsService: accessor.get(IInlineDiffsService), + IEditCodeService: accessor.get(IEditCodeService), IVoidUriStateService: accessor.get(IVoidUriStateService), IQuickEditStateService: accessor.get(IQuickEditStateService), ISidebarStateService: accessor.get(ISidebarStateService), @@ -336,7 +353,21 @@ export const useRefreshModelListener = (listener: (providerName: RefreshableProv useEffect(() => { refreshModelProviderListeners.add(listener) return () => { refreshModelProviderListeners.delete(listener) } - }, [listener]) + }, [listener, refreshModelProviderListeners]) +} + +export const useCtrlKZoneStreamingState = (listener: (diffareaid: number, s: boolean) => void) => { + useEffect(() => { + ctrlKZoneStreamingStateListeners.add(listener) + return () => { ctrlKZoneStreamingStateListeners.delete(listener) } + }, [listener, ctrlKZoneStreamingStateListeners]) +} + +export const useURIStreamState = (listener: (uri: URI, s: URIStreamState) => void) => { + useEffect(() => { + uriStreamingStateListeners.add(listener) + return () => { uriStreamingStateListeners.delete(listener) } + }, [listener, uriStreamingStateListeners]) } @@ -353,3 +384,4 @@ export const useIsDark = () => { return isDark } + diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/useScrollbarStyles.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/useScrollbarStyles.tsx index 7f8ceb34..1ba69c24 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/useScrollbarStyles.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/useScrollbarStyles.tsx @@ -1,7 +1,6 @@ import { useEffect } from 'react'; export const useScrollbarStyles = (containerRef: React.MutableRefObject) => { - useEffect(() => { if (!containerRef.current) return; @@ -12,90 +11,118 @@ export const useScrollbarStyles = (containerRef: React.MutableRefObject { + // Get all matching elements within the container, including the container itself + const scrollElements = [ + ...(containerRef.current?.matches(overflowSelector) ? [containerRef.current] : []), + ...Array.from(containerRef.current?.querySelectorAll(overflowSelector) || []) + ]; - // Apply styles and listeners to each scroll element - scrollElements.forEach(element => { - // Add the scrollable class directly to the overflow element - element.classList.add('void-scrollable-element'); - - let fadeTimeout: NodeJS.Timeout | null = null; - let fadeInterval: NodeJS.Timeout | null = null; - - const fadeIn = () => { - if (fadeInterval) clearInterval(fadeInterval); - - let step = 0; - fadeInterval = setInterval(() => { - if (step <= 10) { - element.classList.remove(`show-scrollbar-${step - 1}`); - element.classList.add(`show-scrollbar-${step}`); - step++; - } else { - clearInterval(fadeInterval!); - } - }, 10); - }; - - const fadeOut = () => { - if (fadeInterval) clearInterval(fadeInterval); - - let step = 10; - fadeInterval = setInterval(() => { - if (step >= 0) { - element.classList.remove(`show-scrollbar-${step + 1}`); - element.classList.add(`show-scrollbar-${step}`); - step--; - } else { - clearInterval(fadeInterval!); - } - }, 60); - }; - - const onMouseEnter = () => { - if (fadeTimeout) clearTimeout(fadeTimeout); - if (fadeInterval) clearInterval(fadeInterval); - fadeIn(); - }; - - const onMouseLeave = () => { - if (fadeTimeout) clearTimeout(fadeTimeout); - fadeTimeout = setTimeout(() => { - fadeOut(); - }, 10); - }; - - element.addEventListener('mouseenter', onMouseEnter); - element.addEventListener('mouseleave', onMouseLeave); - - // Store cleanup function - const cleanup = () => { - element.removeEventListener('mouseenter', onMouseEnter); - element.removeEventListener('mouseleave', onMouseLeave); - if (fadeTimeout) clearTimeout(fadeTimeout); - if (fadeInterval) clearInterval(fadeInterval); - element.classList.remove('void-scrollable-element'); - // Remove any remaining show-scrollbar classes - for (let i = 0; i <= 10; i++) { - element.classList.remove(`show-scrollbar-${i}`); - } - }; - - // Store the cleanup function on the element for later use - (element as any).__scrollbarCleanup = cleanup; - }); - - return () => { - // Clean up all scroll elements + // Apply basic styling to all elements scrollElements.forEach(element => { - if ((element as any).__scrollbarCleanup) { - (element as any).__scrollbarCleanup(); + element.classList.add('void-scrollable-element'); + }); + + // Only initialize fade effects for elements that haven't been initialized yet + scrollElements.forEach(element => { + if (!(element as any).__scrollbarCleanup) { + let fadeTimeout: NodeJS.Timeout | null = null; + let fadeInterval: NodeJS.Timeout | null = null; + + const fadeIn = () => { + if (fadeInterval) clearInterval(fadeInterval); + + let step = 0; + fadeInterval = setInterval(() => { + if (step <= 10) { + element.classList.remove(`show-scrollbar-${step - 1}`); + element.classList.add(`show-scrollbar-${step}`); + step++; + } else { + clearInterval(fadeInterval!); + } + }, 10); + }; + + const fadeOut = () => { + if (fadeInterval) clearInterval(fadeInterval); + + let step = 10; + fadeInterval = setInterval(() => { + if (step >= 0) { + element.classList.remove(`show-scrollbar-${step + 1}`); + element.classList.add(`show-scrollbar-${step}`); + step--; + } else { + clearInterval(fadeInterval!); + } + }, 60); + }; + + const onMouseEnter = () => { + if (fadeTimeout) clearTimeout(fadeTimeout); + if (fadeInterval) clearInterval(fadeInterval); + fadeIn(); + }; + + const onMouseLeave = () => { + if (fadeTimeout) clearTimeout(fadeTimeout); + fadeTimeout = setTimeout(() => { + fadeOut(); + }, 10); + }; + + element.addEventListener('mouseenter', onMouseEnter); + element.addEventListener('mouseleave', onMouseLeave); + + // Store cleanup function + const cleanup = () => { + element.removeEventListener('mouseenter', onMouseEnter); + element.removeEventListener('mouseleave', onMouseLeave); + if (fadeTimeout) clearTimeout(fadeTimeout); + if (fadeInterval) clearInterval(fadeInterval); + element.classList.remove('void-scrollable-element'); + // Remove any remaining show-scrollbar classes + for (let i = 0; i <= 10; i++) { + element.classList.remove(`show-scrollbar-${i}`); + } + }; + + // Store the cleanup function on the element for later use + (element as any).__scrollbarCleanup = cleanup; } }); }; + + // Initialize for the first time + initializeScrollbarStyles(); + + // Set up mutation observer to do the same + const observer = new MutationObserver(() => { + initializeScrollbarStyles(); + }); + + // Start observing the container for child changes + observer.observe(containerRef.current, { + childList: true, + subtree: true + }); + + return () => { + observer.disconnect(); + // Your existing cleanup code... + if (containerRef.current) { + const scrollElements = [ + ...(containerRef.current.matches(overflowSelector) ? [containerRef.current] : []), + ...Array.from(containerRef.current.querySelectorAll(overflowSelector)) + ]; + scrollElements.forEach(element => { + if ((element as any).__scrollbarCleanup) { + (element as any).__scrollbarCleanup(); + } + }); + } + }; }, [containerRef]); }; diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx index 62511563..7f28467f 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx @@ -5,7 +5,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { InputBox } from '../../../../../../../base/browser/ui/inputbox/inputBox.js' -import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidModelInfo, globalSettingNames, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, defaultProviderSettings, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName, isProviderNameDisabled } from '../../../../common/voidSettingsTypes.js' +import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidModelInfo, globalSettingNames, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, defaultProviderSettings, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName, isProviderNameDisabled, FeatureName } from '../../../../common/voidSettingsTypes.js' import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js' import { VoidButton, VoidCheckBox, VoidCustomDropdownBox, VoidInputBox, VoidInputBox2, VoidSwitch } from '../util/inputs.js' import { useAccessor, useIsDark, useRefreshModelListener, useRefreshModelState, useSettingsState } from '../util/services.js' @@ -17,10 +17,11 @@ import { env } from '../../../../../../../base/common/process.js' import { ModelDropdown } from './ModelDropdown.js' import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js' import { WarningBox } from './WarningBox.js' +import { os } from '../../../helpers/systemInfo.js' const SubtleButton = ({ onClick, text, icon, disabled }: { onClick: () => void, text: string, icon: React.ReactNode, disabled: boolean }) => { - return
    + return
    @@ -81,9 +82,7 @@ const RefreshableModels = () => { const buttons = refreshableProviderNames.map(providerName => { if (!settingsState.settingsOfProvider[providerName]._didFillInProviderSettings) return null - return
    - -
    + return }) return <> @@ -256,7 +255,7 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider // const { title: providerTitle, } = displayInfoOfProviderName(providerName) - const { title: settingTitle, placeholder, subTextMd } = displayInfoOfSettingName(providerName, settingName) + const { title: settingTitle, placeholder, isPasswordField, subTextMd } = displayInfoOfSettingName(providerName, settingName) const accessor = useAccessor() const voidSettingsService = accessor.get('IVoidSettingsService') @@ -268,6 +267,7 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider { if (weChangedTextRef) return voidSettingsService.setSettingOfProvider(providerName, settingName, newVal) @@ -290,6 +290,7 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider return [disposable] }, [voidSettingsService, providerName, settingName])} multiline={false} + isPasswordField={isPasswordField} /> {subTextMd === undefined ? null :
    @@ -338,7 +339,7 @@ const SettingsForProvider = ({ providerName }: { providerName: ProviderName }) = {needsModel ? providerName === 'ollama' ? - : + : : null}
    @@ -376,6 +377,7 @@ export const AutoRefreshToggle = () => { icon={enabled ? : } disabled={false} /> + } export const AIInstructionsBox = () => { @@ -385,7 +387,7 @@ export const AIInstructionsBox = () => { return { voidSettingsService.setGlobalSetting('aiInstructions', newText) @@ -395,7 +397,17 @@ export const AIInstructionsBox = () => { export const FeaturesTab = () => { return <> -

    Local Providers

    +

    Models

    + + + +
    + + + + + +

    Local Providers

    {/*

    {`Keep your data private by hosting AI locally on your computer.`}

    */} {/*

    {`Instructions:`}

    */} {/*

    {`Void can access any model that you host locally. We automatically detect your local models by default.`}

    */} @@ -403,7 +415,7 @@ export const FeaturesTab = () => {
    - + {/* TODO we should create UI for downloading models without user going into terminal */} @@ -420,13 +432,21 @@ export const FeaturesTab = () => { -

    Models

    + + +

    Feature Options

    - - - - + {featureNames.map(featureName => + (['Ctrl+L', 'Ctrl+K'] as FeatureName[]).includes(featureName) ? null : +
    +

    {displayInfoOfFeatureName(featureName)}

    + +
    + )}
    + } @@ -489,7 +509,7 @@ const transferTheseFilesOfOS = (os: 'mac' | 'windows' | 'linux' | null): Transfe throw new Error(`os '${os}' not recognized`) } -const os = isWindows ? 'windows' : isMacintosh ? 'mac' : isLinux ? 'linux' : null + let transferTheseFiles: TransferFilesInfo = [] let transferError: string | null = null @@ -588,17 +608,6 @@ const GeneralTab = () => {
    -
    -

    Model Selection

    - {featureNames.map(featureName => -
    -

    {displayInfoOfFeatureName(featureName)}

    - -
    - )} -
    } @@ -618,7 +627,7 @@ export const Settings = () => {
    -

    Void Settings

    +

    {`Void's Settings`}

    {/* separator */}
    diff --git a/src/vs/workbench/contrib/void/browser/react/tailwind.config.js b/src/vs/workbench/contrib/void/browser/react/tailwind.config.js index c4dc1980..bc57116b 100644 --- a/src/vs/workbench/contrib/void/browser/react/tailwind.config.js +++ b/src/vs/workbench/contrib/void/browser/react/tailwind.config.js @@ -28,17 +28,25 @@ module.exports = { colors: { "void-bg-1": "var(--vscode-input-background)", + "void-bg-1-alt": "var(--vscode-badge-background)", "void-bg-2": "var(--vscode-sideBar-background)", + "void-bg-2-alt": "color-mix(in srgb, var(--vscode-sideBar-background) 30%, var(--vscode-editor-background) 70%)", "void-bg-3": "var(--vscode-editor-background)", + "void-fg-1": "var(--vscode-editor-foreground)", "void-fg-2": "var(--vscode-input-foreground)", "void-fg-3": "var(--vscode-input-placeholderForeground)", + // "void-fg-4": "var(--vscode-tab-inactiveForeground)", + "void-fg-4": "var(--vscode-list-deemphasizedForeground)", + + "void-warning": "var(--vscode-charts-yellow)", "void-border-1": "var(--vscode-commandCenter-activeBorder)", "void-border-2": "var(--vscode-commandCenter-border)", "void-border-3": "var(--vscode-commandCenter-inactiveBorder)", + "void-border-3": "var(--vscode-settings-sashBorder)", vscode: { diff --git a/src/vs/workbench/contrib/void/browser/searchAndReplaceService.ts b/src/vs/workbench/contrib/void/browser/searchAndReplaceService.ts deleted file mode 100644 index c668ae26..00000000 --- a/src/vs/workbench/contrib/void/browser/searchAndReplaceService.ts +++ /dev/null @@ -1,71 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; - - - -export type ChatMessageLocation = { - threadId: string; - messageIdx: number; -} - - -export type SearchAndReplaceBlock = { - search: string; - replace: string; -} - -// service that manages state -export type ApplyState = { - [applyBoxId: string]: { - searchAndReplaceBlocks: SearchAndReplaceBlock; - } -} - -// the purpose of this service is to generate search and replace blocks for a given codeblock `codeblockId` and on a file `fileName` and version `fileVersion` - -export interface IFastApplyService { - readonly _serviceBrand: undefined; - - // readonly state: ApplyState; // readonly to the user - // setState(newState: Partial): void; - // onDidChangeState: Event; -} - -export const IVoidFastApplyService = createDecorator('voidFastApplyService'); -class VoidFastApplyService extends Disposable implements IFastApplyService { - _serviceBrand: undefined; - - static readonly ID = 'voidFastApplyService'; - - private readonly _onDidChangeState = new Emitter(); - readonly onDidChangeState: Event = this._onDidChangeState.event; - - - // state - // state: ApplyState - - constructor( - ) { - super() - - // initial state - // this.state = { currentUri: undefined } - } - - setState(newState: Partial) { - - // this.state = { ...this.state, ...newState } - this._onDidChangeState.fire() - } - - -} - -registerSingleton(IVoidFastApplyService, VoidFastApplyService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/browser/searchReplaceCacheService.ts b/src/vs/workbench/contrib/void/browser/searchReplaceCacheService.ts new file mode 100644 index 00000000..e7a9448e --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/searchReplaceCacheService.ts @@ -0,0 +1,46 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILLMMessageService } from '../common/llmMessageService.js'; +import { ServiceSendLLMMessageParams } from '../common/llmMessageTypes.js'; + + + +export interface ISearchReplaceService { + readonly _serviceBrand: undefined; +} + +export const ISearchReplaceService = createDecorator('SearchReplaceCacheService'); +class SearchReplaceService extends Disposable implements ISearchReplaceService { + _serviceBrand: undefined; + + private readonly _onDidChangeState = new Emitter(); + readonly onDidChangeState: Event = this._onDidChangeState.event; + + constructor( + @ILLMMessageService private readonly llmMessageService: ILLMMessageService, + ) { + super() + } + + send(params: Omit & { onText: (p: { newText: string, fullText: string }) => { retry: boolean } }) { + this.llmMessageService.sendLLMMessage({ + ...params as ServiceSendLLMMessageParams, + onText: (p) => { + const { retry } = params.onText(p) + if (retry) { + + } + } + }) + } + +} + +registerSingleton(ISearchReplaceService, SearchReplaceService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/browser/sidebarActions.ts b/src/vs/workbench/contrib/void/browser/sidebarActions.ts index d65c51a7..722eaa57 100644 --- a/src/vs/workbench/contrib/void/browser/sidebarActions.ts +++ b/src/vs/workbench/contrib/void/browser/sidebarActions.ts @@ -135,9 +135,18 @@ registerAction2(class extends Action2 { const chatThreadService = accessor.get(IChatThreadService) const focusedMessageIdx = chatThreadService.getFocusedMessageIdx() - const [staging, setStaging] = chatThreadService._useFocusedStagingState(focusedMessageIdx) - const selections = staging.selections || [] - const setSelections = (s: StagingSelectionItem[]) => setStaging({ ...staging, selections: s }) + + // set the selections to the proper value + let selections: StagingSelectionItem[] = [] + let setSelections = (s: StagingSelectionItem[]) => { } + + if (focusedMessageIdx === undefined) { + selections = chatThreadService.getCurrentThreadStagingSelections() + setSelections = (s: StagingSelectionItem[]) => chatThreadService.setCurrentThreadStagingSelections(s) + } else { + selections = chatThreadService.getCurrentMessageState(focusedMessageIdx).stagingSelections + setSelections = (s) => chatThreadService.setCurrentMessageState(focusedMessageIdx, { stagingSelections: s }) + } // if matches with existing selection, overwrite (since text may change) const matchingStagingEltIdx = findMatchingStagingIndex(selections, selection) @@ -230,7 +239,7 @@ registerAction2(class extends Action2 { constructor() { super({ id: 'void.settingsAction', - title: 'Void Settings', + title: `Void's Settings`, icon: { id: 'settings-gear' }, menu: [{ id: MenuId.ViewTitle, group: 'navigation', when: ContextKeyExpr.equals('view', VOID_VIEW_ID), }] }); diff --git a/src/vs/workbench/contrib/void/browser/void.contribution.ts b/src/vs/workbench/contrib/void/browser/void.contribution.ts index 19d20201..b179e603 100644 --- a/src/vs/workbench/contrib/void/browser/void.contribution.ts +++ b/src/vs/workbench/contrib/void/browser/void.contribution.ts @@ -5,7 +5,7 @@ // register inline diffs -import './inlineDiffsService.js' +import './editCodeService.js' // register Sidebar pane, state, actions (keybinds, menus) (Ctrl+L) import './sidebarActions.js' @@ -22,7 +22,7 @@ import './chatThreadService.js' import './autocompleteService.js' // register Context services -import './contextGatheringService.js' +// import './contextGatheringService.js' // import './contextUserChangesService.js' // settings pane diff --git a/src/vs/workbench/contrib/void/browser/voidSettingsPane.ts b/src/vs/workbench/contrib/void/browser/voidSettingsPane.ts index 0fd8ce2e..d5e99d57 100644 --- a/src/vs/workbench/contrib/void/browser/voidSettingsPane.ts +++ b/src/vs/workbench/contrib/void/browser/voidSettingsPane.ts @@ -49,7 +49,7 @@ class VoidSettingsInput extends EditorInput { } override getName(): string { - return nls.localize('voidSettingsInputsName', 'Void Settings'); + return nls.localize('voidSettingsInputsName', 'Void\'s Settings'); } override getIcon() { @@ -112,7 +112,7 @@ class VoidSettingsPane extends EditorPane { // register Settings pane Registry.as(EditorExtensions.EditorPane).registerEditorPane( - EditorPaneDescriptor.create(VoidSettingsPane, VoidSettingsPane.ID, nls.localize('VoidSettingsPane', "Void Settings Pane")), + EditorPaneDescriptor.create(VoidSettingsPane, VoidSettingsPane.ID, nls.localize('VoidSettingsPane', "Void\'s Settings Pane")), [new SyncDescriptor(VoidSettingsInput)] ); @@ -202,7 +202,7 @@ MenuRegistry.appendMenuItem(MenuId.GlobalActivity, { group: '0_command', command: { id: VOID_TOGGLE_SETTINGS_ACTION_ID, - title: nls.localize('voidSettings', "Void Settings") + title: nls.localize('voidSettings', "Void\'s Settings") }, order: 1 }); diff --git a/src/vs/workbench/contrib/void/common/llmMessageService.ts b/src/vs/workbench/contrib/void/common/llmMessageService.ts index 314031d4..b2266213 100644 --- a/src/vs/workbench/contrib/void/common/llmMessageService.ts +++ b/src/vs/workbench/contrib/void/common/llmMessageService.ts @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, ServiceSendLLMMessageParams, MainSendLLMMessageParams, MainLLMMessageAbortParams, ServiceModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, MainModelListParams, OllamaModelResponse, OpenaiCompatibleModelResponse, } from './llmMessageTypes.js'; +import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, ServiceSendLLMMessageParams, MainSendLLMMessageParams, MainLLMMessageAbortParams, ServiceModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, MainModelListParams, OllamaModelResponse, VLLMModelResponse, } from './llmMessageTypes.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; @@ -24,27 +24,39 @@ export interface ILLMMessageService { sendLLMMessage: (params: ServiceSendLLMMessageParams) => string | null; abort: (requestId: string) => void; ollamaList: (params: ServiceModelListParams) => void; - openAICompatibleList: (params: ServiceModelListParams) => void; + vLLMList: (params: ServiceModelListParams) => void; } + +// open this file side by side with llmMessageChannel export class LLMMessageService extends Disposable implements ILLMMessageService { readonly _serviceBrand: undefined; private readonly channel: IChannel // LLMMessageChannel - // llmMessage - private readonly onTextHooks_llm: { [eventId: string]: ((params: EventLLMMessageOnTextParams) => void) } = {} - private readonly onFinalMessageHooks_llm: { [eventId: string]: ((params: EventLLMMessageOnFinalMessageParams) => void) } = {} - private readonly onErrorHooks_llm: { [eventId: string]: ((params: EventLLMMessageOnErrorParams) => void) } = {} + // sendLLMMessage + private readonly llmMessageHooks = { + onText: {} as { [eventId: string]: ((params: EventLLMMessageOnTextParams) => void) }, + onFinalMessage: {} as { [eventId: string]: ((params: EventLLMMessageOnFinalMessageParams) => void) }, + onError: {} as { [eventId: string]: ((params: EventLLMMessageOnErrorParams) => void) }, + } - - // ollamaList - private readonly onSuccess_ollama: { [eventId: string]: ((params: EventModelListOnSuccessParams) => void) } = {} - private readonly onError_ollama: { [eventId: string]: ((params: EventModelListOnErrorParams) => void) } = {} - - // openAICompatibleList - private readonly onSuccess_openAICompatible: { [eventId: string]: ((params: EventModelListOnSuccessParams) => void) } = {} - private readonly onError_openAICompatible: { [eventId: string]: ((params: EventModelListOnErrorParams) => void) } = {} + // list hooks + private readonly listHooks = { + ollama: { + success: {} as { [eventId: string]: ((params: EventModelListOnSuccessParams) => void) }, + error: {} as { [eventId: string]: ((params: EventModelListOnErrorParams) => void) }, + }, + vLLM: { + success: {} as { [eventId: string]: ((params: EventModelListOnSuccessParams) => void) }, + error: {} as { [eventId: string]: ((params: EventModelListOnErrorParams) => void) }, + } + } satisfies { + [providerName: string]: { + success: { [eventId: string]: ((params: EventModelListOnSuccessParams) => void) }, + error: { [eventId: string]: ((params: EventModelListOnErrorParams) => void) }, + } + } constructor( @IMainProcessService private readonly mainProcessService: IMainProcessService, // used as a renderer (only usable on client side) @@ -59,32 +71,14 @@ export class LLMMessageService extends Disposable implements ILLMMessageService // .listen sets up an IPC channel and takes a few ms, so we set up listeners immediately and add hooks to them instead // llm - this._register((this.channel.listen('onText_llm') satisfies Event)(e => { - this.onTextHooks_llm[e.requestId]?.(e) - })) - this._register((this.channel.listen('onFinalMessage_llm') satisfies Event)(e => { - this.onFinalMessageHooks_llm[e.requestId]?.(e) - this._onRequestIdDone(e.requestId) - })) - this._register((this.channel.listen('onError_llm') satisfies Event)(e => { - console.error('Error in LLMMessageService:', JSON.stringify(e)) - this.onErrorHooks_llm[e.requestId]?.(e) - this._onRequestIdDone(e.requestId) - })) + this._register((this.channel.listen('onText_sendLLMMessage') satisfies Event)(e => { this.llmMessageHooks.onText[e.requestId]?.(e) })) + this._register((this.channel.listen('onFinalMessage_sendLLMMessage') satisfies Event)(e => { this.llmMessageHooks.onFinalMessage[e.requestId]?.(e); this._onRequestIdDone(e.requestId) })) + this._register((this.channel.listen('onError_sendLLMMessage') satisfies Event)(e => { this.llmMessageHooks.onError[e.requestId]?.(e); this._onRequestIdDone(e.requestId); console.error('Error in LLMMessageService:', JSON.stringify(e)) })) // ollama .list() - this._register((this.channel.listen('onSuccess_ollama') satisfies Event>)(e => { - this.onSuccess_ollama[e.requestId]?.(e) - })) - this._register((this.channel.listen('onError_ollama') satisfies Event>)(e => { - this.onError_ollama[e.requestId]?.(e) - })) - // openaiCompatible .list() - this._register((this.channel.listen('onSuccess_openAICompatible') satisfies Event>)(e => { - this.onSuccess_openAICompatible[e.requestId]?.(e) - })) - this._register((this.channel.listen('onError_openAICompatible') satisfies Event>)(e => { - this.onError_openAICompatible[e.requestId]?.(e) - })) + this._register((this.channel.listen('onSuccess_list_ollama') satisfies Event>)(e => { this.listHooks.ollama.success[e.requestId]?.(e) })) + this._register((this.channel.listen('onError_list_ollama') satisfies Event>)(e => { this.listHooks.ollama.error[e.requestId]?.(e) })) + this._register((this.channel.listen('onSuccess_list_vLLM') satisfies Event>)(e => { this.listHooks.vLLM.success[e.requestId]?.(e) })) + this._register((this.channel.listen('onError_list_vLLM') satisfies Event>)(e => { this.listHooks.vLLM.error[e.requestId]?.(e) })) } @@ -99,15 +93,15 @@ export class LLMMessageService extends Disposable implements ILLMMessageService let message: string if (isDisabled === 'addProvider' || isDisabled === 'providerNotAutoDetected') - message = `Please add a provider in Void Settings.` + message = `Please add a provider in Void's Settings.` else if (isDisabled === 'addModel') message = `Please add a model.` else if (isDisabled === 'needToEnableModel') message = `Please enable a model.` else if (isDisabled === 'notFilledIn') - message = `Please fill in Void Settings${modelSelection !== null ? ` for ${displayInfoOfProviderName(modelSelection.providerName).title}` : ''}.` + message = `Please fill in Void's Settings${modelSelection !== null ? ` for ${displayInfoOfProviderName(modelSelection.providerName).title}` : ''}.` else - message = 'Please add a provider in Void Settings.' + message = `Please add a provider in Void's Settings.` onError({ message, fullError: null }) return null @@ -117,9 +111,9 @@ export class LLMMessageService extends Disposable implements ILLMMessageService // add state for request id const requestId = generateUuid(); - this.onTextHooks_llm[requestId] = onText - this.onFinalMessageHooks_llm[requestId] = onFinalMessage - this.onErrorHooks_llm[requestId] = onError + this.llmMessageHooks.onText[requestId] = onText + this.llmMessageHooks.onFinalMessage[requestId] = onFinalMessage + this.llmMessageHooks.onError[requestId] = onError const { aiInstructions } = this.voidSettingsService.state.globalSettings const { settingsOfProvider } = this.voidSettingsService.state @@ -151,43 +145,46 @@ export class LLMMessageService extends Disposable implements ILLMMessageService // add state for request id const requestId_ = generateUuid(); - this.onSuccess_ollama[requestId_] = onSuccess - this.onError_ollama[requestId_] = onError + this.listHooks.ollama.success[requestId_] = onSuccess + this.listHooks.ollama.error[requestId_] = onError this.channel.call('ollamaList', { ...proxyParams, settingsOfProvider, + providerName: 'ollama', requestId: requestId_, } satisfies MainModelListParams) } - openAICompatibleList = (params: ServiceModelListParams) => { + vLLMList = (params: ServiceModelListParams) => { const { onSuccess, onError, ...proxyParams } = params const { settingsOfProvider } = this.voidSettingsService.state // add state for request id const requestId_ = generateUuid(); - this.onSuccess_openAICompatible[requestId_] = onSuccess - this.onError_openAICompatible[requestId_] = onError + this.listHooks.vLLM.success[requestId_] = onSuccess + this.listHooks.vLLM.error[requestId_] = onError - this.channel.call('openAICompatibleList', { + this.channel.call('vLLMList', { ...proxyParams, settingsOfProvider, + providerName: 'vLLM', requestId: requestId_, - } satisfies MainModelListParams) + } satisfies MainModelListParams) } - - _onRequestIdDone(requestId: string) { - delete this.onTextHooks_llm[requestId] - delete this.onFinalMessageHooks_llm[requestId] - delete this.onErrorHooks_llm[requestId] + delete this.llmMessageHooks.onText[requestId] + delete this.llmMessageHooks.onFinalMessage[requestId] + delete this.llmMessageHooks.onError[requestId] - delete this.onSuccess_ollama[requestId] - delete this.onError_ollama[requestId] + delete this.listHooks.ollama.success[requestId] + delete this.listHooks.ollama.error[requestId] + + delete this.listHooks.vLLM.success[requestId] + delete this.listHooks.vLLM.error[requestId] } } diff --git a/src/vs/workbench/contrib/void/common/llmMessageTypes.ts b/src/vs/workbench/contrib/void/common/llmMessageTypes.ts index fb6a94fc..abe88970 100644 --- a/src/vs/workbench/contrib/void/common/llmMessageTypes.ts +++ b/src/vs/workbench/contrib/void/common/llmMessageTypes.ts @@ -3,6 +3,8 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ +import { ChatMessage } from '../browser/chatThreadService.js' +import { InternalToolInfo, ToolName } from './toolsService.js' import { FeatureName, ProviderName, SettingsOfProvider } from './voidSettingsTypes.js' @@ -20,22 +22,50 @@ export const errorDetails = (fullError: Error | null): string | null => { return null } -export type OnText = (p: { newText: string, fullText: string }) => void -export type OnFinalMessage = (p: { fullText: string }) => void + +export type LLMChatMessage = { + role: 'system' | 'user'; + content: string; +} | { + role: 'assistant', + content: string; +} | { + role: 'tool'; + content: string; // result + name: string; + params: string; + id: string; +} + + +export type ToolCallType = { + name: ToolName; + params: string; + id: string; +} + + +export type OnText = (p: { newText: string, fullText: string; newReasoning: string; fullReasoning: string }) => void +export type OnFinalMessage = (p: { fullText: string, toolCalls?: ToolCallType[] }) => void // id is tool_use_id export type OnError = (p: { message: string, fullError: Error | null }) => void export type AbortRef = { current: (() => void) | null } -export type LLMChatMessage = { - role: 'system' | 'user' | 'assistant'; - content: string; + +export const toLLMChatMessage = (c: ChatMessage): LLMChatMessage => { + if (c.role === 'system' || c.role === 'user') { + return { role: c.role, content: c.content || '(empty message)' } + } + else if (c.role === 'assistant') + return { role: c.role, content: c.content || '(empty message)' } + else if (c.role === 'tool') + return { role: c.role, id: c.id, name: c.name, params: c.params, content: c.content || '(empty output)' } + else { + throw 1 + } } -export type _InternalLLMChatMessage = { - role: 'user' | 'assistant'; - content: string; -} -type _InternalSendFIMMessage = { +export type LLMFIMMessage = { prefix: string; suffix: string; stopTokens: string[]; @@ -44,9 +74,11 @@ type _InternalSendFIMMessage = { type SendLLMType = { messagesType: 'chatMessages'; messages: LLMChatMessage[]; + tools?: InternalToolInfo[]; } | { messagesType: 'FIMMessage'; - messages: _InternalSendFIMMessage; + messages: LLMFIMMessage; + tools?: undefined; } // service types @@ -86,34 +118,6 @@ export type EventLLMMessageOnFinalMessageParams = Parameters[0] export type EventLLMMessageOnErrorParams = Parameters[0] & { requestId: string } -export type _InternalSendLLMChatMessageFnType = ( - params: { - onText: OnText; - onFinalMessage: OnFinalMessage; - onError: OnError; - providerName: ProviderName; - settingsOfProvider: SettingsOfProvider; - modelName: string; - _setAborter: (aborter: () => void) => void; - - messages: _InternalLLMChatMessage[]; - } -) => void - -export type _InternalSendLLMFIMMessageFnType = ( - params: { - onText: OnText; - onFinalMessage: OnFinalMessage; - onError: OnError; - providerName: ProviderName; - settingsOfProvider: SettingsOfProvider; - modelName: string; - _setAborter: (aborter: () => void) => void; - - messages: _InternalSendFIMMessage; - } -) => void - // service -> main -> internal -> event (back to main) // (browser) @@ -145,18 +149,22 @@ export type OllamaModelResponse = { size_vram: number; } -export type OpenaiCompatibleModelResponse = { +type OpenaiCompatibleModelResponse = { id: string; created: number; object: 'model'; owned_by: string; } +export type VLLMModelResponse = OpenaiCompatibleModelResponse + + // params to the true list fn -export type ModelListParams = { +export type ModelListParams = { + providerName: ProviderName; settingsOfProvider: SettingsOfProvider; - onSuccess: (param: { models: modelResponse[] }) => void; + onSuccess: (param: { models: ModelResponse[] }) => void; onError: (param: { error: string }) => void; } @@ -175,4 +183,3 @@ export type EventModelListOnErrorParams = Parameters = (params: ModelListParams) => void diff --git a/src/vs/workbench/contrib/void/common/refreshModelService.ts b/src/vs/workbench/contrib/void/common/refreshModelService.ts index ff61e8a8..1d68b304 100644 --- a/src/vs/workbench/contrib/void/common/refreshModelService.ts +++ b/src/vs/workbench/contrib/void/common/refreshModelService.ts @@ -8,7 +8,7 @@ import { ILLMMessageService } from './llmMessageService.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { RefreshableProviderName, refreshableProviderNames, SettingsOfProvider } from './voidSettingsTypes.js'; -import { OllamaModelResponse, OpenaiCompatibleModelResponse } from './llmMessageTypes.js'; +import { OllamaModelResponse, VLLMModelResponse } from './llmMessageTypes.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; @@ -45,6 +45,7 @@ export type RefreshModelStateOfProvider = Record { this._clearProviderTimeout(providerName) @@ -158,7 +160,7 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ } } const listFn = providerName === 'ollama' ? this.llmMessageService.ollamaList - : providerName === 'openAICompatible' ? this.llmMessageService.openAICompatibleList + : providerName === 'vLLM' ? this.llmMessageService.vLLMList : () => { } listFn({ @@ -169,7 +171,7 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ providerName, models.map(model => { if (providerName === 'ollama') return (model as OllamaModelResponse).name; - else if (providerName === 'openAICompatible') return (model as OpenaiCompatibleModelResponse).id; + else if (providerName === 'vLLM') return (model as VLLMModelResponse).id; else throw new Error('refreshMode fn: unknown provider', providerName); }), { enableProviderOnSuccess: options.enableProviderOnSuccess, hideRefresh: options.doNotFire } diff --git a/src/vs/workbench/contrib/void/common/toolsService.ts b/src/vs/workbench/contrib/void/common/toolsService.ts index 8ffd6b9b..f27739c0 100644 --- a/src/vs/workbench/contrib/void/common/toolsService.ts +++ b/src/vs/workbench/contrib/void/common/toolsService.ts @@ -1,12 +1,12 @@ import { CancellationToken } from '../../../../base/common/cancellation.js' import { URI } from '../../../../base/common/uri.js' -import { IFileService, IFileStat } from '../../../../platform/files/common/files.js' +import { IFileService } from '../../../../platform/files/common/files.js' import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js' import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js' import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js' -import { VSReadFileRaw } from '../../../../workbench/contrib/void/browser/helpers/readFile.js' import { QueryBuilder } from '../../../../workbench/services/search/common/queryBuilder.js' import { ISearchService } from '../../../../workbench/services/search/common/search.js' +import { IVoidFileService } from './voidFileService.js' // tool use for AI @@ -15,6 +15,7 @@ import { ISearchService } from '../../../../workbench/services/search/common/sea // we do this using Anthropic's style and convert to OpenAI style later export type InternalToolInfo = { + name: string, description: string, params: { [paramName: string]: { type: string, description: string | undefined } // name -> type @@ -22,15 +23,15 @@ export type InternalToolInfo = { required: string[], // required paramNames } -// helper -const pagination = { +const paginationHelper = { desc: `Very large results may be paginated (indicated in the result). Pagination fails gracefully if out of bounds or invalid page number.`, param: { pageNumber: { type: 'number', description: 'The page number (optional, default is 1).' }, } } as const -export const contextTools = { +export const voidTools = { read_file: { - description: 'Returns file contents of a given URI.', + name: 'read_file', + description: `Returns file contents of a given URI. ${paginationHelper.desc}`, params: { uri: { type: 'string', description: undefined }, }, @@ -38,145 +39,326 @@ export const contextTools = { }, list_dir: { - description: `Returns all file names and folder names in a given URI. ${pagination.desc}`, + name: 'list_dir', + description: `Returns all file names and folder names in a given URI. ${paginationHelper.desc}`, params: { uri: { type: 'string', description: undefined }, - ...pagination.param + ...paginationHelper.param }, required: ['uri'], }, pathname_search: { - description: `Returns all pathnames that match a given grep query. You should use this when looking for a file with a specific name or path. This does NOT search file content. ${pagination.desc}`, + name: 'pathname_search', + description: `Returns all pathnames that match a given grep query. You should use this when looking for a file with a specific name or path. This does NOT search file content. ${paginationHelper.desc}`, params: { query: { type: 'string', description: undefined }, - ...pagination.param, - }, - required: ['query'] - }, - - search: { - description: `Returns all code excerpts containing the given string or grep query. This does NOT search pathname. As a follow-up, you may want to use read_file to view the full file contents of the results. ${pagination.desc}`, - params: { - query: { type: 'string', description: undefined }, - ...pagination.param, + ...paginationHelper.param, }, required: ['query'], }, + search: { + name: 'search', + description: `Returns all code excerpts containing the given string or grep query. This does NOT search pathname. As a follow-up, you may want to use read_file to view the full file contents of the results. ${paginationHelper.desc}`, + params: { + query: { type: 'string', description: undefined }, + ...paginationHelper.param, + }, + required: ['query'], + }, + + // go_to_definition: + + // go_to_usages: + + // create_file: { + // name: 'create_file', + // description: `Creates a file at the given path. Fails gracefully if the file already exists by doing nothing.` + // params: { + // uri: { type: 'string', description: undefined }, + // } + // } + // semantic_search: { // description: 'Searches files semantically for the given string query.', // // RAG // }, +} satisfies { [name: string]: InternalToolInfo } -} as const satisfies { [name: string]: InternalToolInfo } +export type ToolName = keyof typeof voidTools +export const toolNames = Object.keys(voidTools) as ToolName[] -export type ContextToolName = keyof typeof contextTools -type ContextToolParamNames = keyof typeof contextTools[T]['params'] -type ContextToolParams = { [paramName in ContextToolParamNames]: unknown } - -type AllContextToolCallFns = { - [ToolName in ContextToolName]: ((p: (ContextToolParams)) => Promise) +const toolNamesSet = new Set(toolNames) +export const isAToolName = (toolName: string): toolName is ToolName => { + const isAToolName = toolNamesSet.has(toolName) + return isAToolName } +export type ToolParamNames = keyof typeof voidTools[T]['params'] +export type ToolParamsObj = { [paramName in ToolParamNames]: unknown } + +export type ToolCallReturnType = { + 'read_file': { uri: URI, fileContents: string, hasNextPage: boolean }, + 'list_dir': { rootURI: URI, children: DirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number }, + 'pathname_search': { queryStr: string, uris: URI[] | string, hasNextPage: boolean }, + 'search': { queryStr: string, uris: URI[] | string, hasNextPage: boolean } + 'create_file': {} +} + +type DirectoryItem = { + name: string; + isDirectory: boolean; + isSymbolicLink: boolean; +} + +export type ToolFns = { [T in ToolName]: (p: string) => Promise } +export type ToolResultToString = { [T in ToolName]: (result: ToolCallReturnType[T]) => string } + + +// pagination info +const MAX_FILE_CHARS_PAGE = 50_000 +const MAX_CHILDREN_URIs_PAGE = 500 - - -// TODO check to make sure in workspace -// TODO check to make sure is not gitignored - - -async function generateDirectoryTreeMd(fileService: IFileService, rootURI: URI): Promise { - let output = '' - function traverseChildren(children: IFileStat[], depth: number) { - const indentation = ' '.repeat(depth); - for (const child of children) { - output += `${indentation}- ${child.name}\n`; - traverseChildren(child.children ?? [], depth + 1); - } - } +const computeDirectoryResult = async ( + fileService: IFileService, + rootURI: URI, + pageNumber: number = 1 +): Promise => { const stat = await fileService.resolve(rootURI, { resolveMetadata: false }); + if (!stat.isDirectory) { + return { rootURI, children: null, hasNextPage: false, hasPrevPage: false, itemsRemaining: 0 }; + } - // kickstart recursion - output += `${stat.name}\n`; - traverseChildren(stat.children ?? [], 1); + const originalChildrenLength = stat.children?.length ?? 0; + const fromChildIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1); + const toChildIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1; // INCLUSIVE + const listChildren = stat.children?.slice(fromChildIdx, toChildIdx + 1) ?? []; + + const children: DirectoryItem[] = listChildren.map(child => ({ + name: child.name, + isDirectory: child.isDirectory, + isSymbolicLink: child.isSymbolicLink || false + })); + + const hasNextPage = (originalChildrenLength - 1) > toChildIdx; + const hasPrevPage = pageNumber > 1; + const itemsRemaining = Math.max(0, originalChildrenLength - (toChildIdx + 1)); + + return { + rootURI, + children, + hasNextPage, + hasPrevPage, + itemsRemaining + }; +}; + +const directoryResultToString = (result: ToolCallReturnType['list_dir']): string => { + if (!result.children) { + return `Error: ${result.rootURI} is not a directory`; + } + + let output = ''; + const entries = result.children; + + if (!result.hasPrevPage) { + output += `${result.rootURI}\n`; + } + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const isLast = i === entries.length - 1 && !result.hasNextPage; + const prefix = isLast ? '└── ' : '├── '; + + output += `${prefix}${entry.name}${entry.isDirectory ? '/' : ''}${entry.isSymbolicLink ? ' (symbolic link)' : ''}\n`; + } + + if (result.hasNextPage) { + output += `└── (${result.itemsRemaining} results remaining...)\n`; + } return output; +}; + + + + + +const validateJSON = (s: string): { [s: string]: unknown } => { + try { + const o = JSON.parse(s) + return o + } + catch (e) { + throw new Error(`Tool parameter was not a valid JSON: "${s}".`) + } } + +const validateQueryStr = (queryStr: unknown) => { + if (typeof queryStr !== 'string') throw new Error('Error calling tool: provided query must be a string.') + return queryStr +} + + +// TODO!!!! check to make sure in workspace const validateURI = (uriStr: unknown) => { - if (typeof uriStr !== 'string') throw new Error('(uri was not a string)') + if (typeof uriStr !== 'string') throw new Error('Error calling tool: provided uri must be a string.') + const uri = URI.file(uriStr) return uri } -export interface IToolService { +const validatePageNum = (pageNumberUnknown: unknown) => { + const proposedPageNum = Number.parseInt(pageNumberUnknown + '') + const num = Number.isInteger(proposedPageNum) ? proposedPageNum : 1 + const pageNumber = num < 1 ? 1 : num + return pageNumber +} +export interface IToolsService { readonly _serviceBrand: undefined; - callContextTool: (toolName: T, params: ContextToolParams) => Promise + toolFns: ToolFns; + toolResultToString: ToolResultToString; } -export const IToolService = createDecorator('ToolService'); +export const IToolsService = createDecorator('ToolsService'); -export class ToolService implements IToolService { +export class ToolsService implements IToolsService { readonly _serviceBrand: undefined; - contextToolCallFns: AllContextToolCallFns + public toolFns: ToolFns + public toolResultToString: ToolResultToString + constructor( @IFileService fileService: IFileService, @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, @ISearchService searchService: ISearchService, @IInstantiationService instantiationService: IInstantiationService, + @IVoidFileService voidFileService: IVoidFileService, ) { - const queryBuilder = instantiationService.createInstance(QueryBuilder); - this.contextToolCallFns = { - read_file: async ({ uri: uriStr }) => { + this.toolFns = { + read_file: async (s: string) => { + console.log('read_file') + + const o = validateJSON(s) + const { uri: uriStr, pageNumber: pageNumberUnknown } = o + const uri = validateURI(uriStr) - const fileContents = await VSReadFileRaw(fileService, uri) - return fileContents ?? '(could not read file)' + const pageNumber = validatePageNum(pageNumberUnknown) + + const readFileContents = await voidFileService.readFile(uri) + + const fromIdx = MAX_FILE_CHARS_PAGE * (pageNumber - 1) + const toIdx = MAX_FILE_CHARS_PAGE * pageNumber - 1 + const fileContents = readFileContents.slice(fromIdx, toIdx + 1) || '(empty)' // paginate + const hasNextPage = (readFileContents.length - 1) - toIdx >= 1 + + + console.log('read_file result:', fileContents) + + + return { uri, fileContents, hasNextPage } }, - list_dir: async ({ uri: uriStr }) => { + list_dir: async (s: string) => { + console.log('list_dir') + const o = validateJSON(s) + const { uri: uriStr, pageNumber: pageNumberUnknown } = o + const uri = validateURI(uriStr) - const treeStr = await generateDirectoryTreeMd(fileService, uri) - return treeStr - }, - pathname_search: async ({ query: queryStr }) => { - if (typeof queryStr !== 'string') return '(Error: query was not a string)' - const query = queryBuilder.file(workspaceContextService.getWorkspace().folders.map(f => f.uri), { filePattern: queryStr, }); + const pageNumber = validatePageNum(pageNumberUnknown) - const data = await searchService.fileSearch(query, CancellationToken.None); - const str = data.results.map(({ resource, results }) => resource.fsPath).join('\n') - return str - }, - search: async ({ query: queryStr }) => { - if (typeof queryStr !== 'string') return '(Error: query was not a string)' - const query = queryBuilder.text({ pattern: queryStr, }, workspaceContextService.getWorkspace().folders.map(f => f.uri)); + const dirResult = await computeDirectoryResult(fileService, uri, pageNumber) + console.log('list_dir result:', dirResult) - const data = await searchService.textSearch(query, CancellationToken.None); - const str = data.results.map(({ resource, results }) => resource.fsPath).join('\n') - return str + return dirResult + }, + pathname_search: async (s: string) => { + console.log('pathname_search') + const o = validateJSON(s) + const { query: queryUnknown, pageNumber: pageNumberUnknown } = o + + const queryStr = validateQueryStr(queryUnknown) + const pageNumber = validatePageNum(pageNumberUnknown) + + const query = queryBuilder.file(workspaceContextService.getWorkspace().folders.map(f => f.uri), { filePattern: queryStr, }) + const data = await searchService.fileSearch(query, CancellationToken.None) + + const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1) + const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1 + const uris = data.results + .slice(fromIdx, toIdx + 1) // paginate + .map(({ resource, results }) => resource) + + const hasNextPage = (data.results.length - 1) - toIdx >= 1 + console.log('pathname_search result:', uris) + + return { queryStr, uris, hasNextPage } + }, + search: async (s: string) => { + + + console.log('search') + + const o = validateJSON(s) + const { query: queryUnknown, pageNumber: pageNumberUnknown } = o + + const queryStr = validateQueryStr(queryUnknown) + const pageNumber = validatePageNum(pageNumberUnknown) + + const query = queryBuilder.text({ pattern: queryStr, }, workspaceContextService.getWorkspace().folders.map(f => f.uri)) + const data = await searchService.textSearch(query, CancellationToken.None) + + const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1) + const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1 + const uris = data.results + .slice(fromIdx, toIdx + 1) // paginate + .map(({ resource, results }) => resource) + + const hasNextPage = (data.results.length - 1) - toIdx >= 1 + + console.log('search result:', uris) + + return { queryStr, uris, hasNextPage } }, + + } + + const nextPageStr = (hasNextPage: boolean) => hasNextPage ? '\n\n(more on next page...)' : '' + + this.toolResultToString = { + read_file: (result) => { + return nextPageStr(result.hasNextPage) + }, + list_dir: (result) => { + const dirTreeStr = directoryResultToString(result) + return dirTreeStr + nextPageStr(result.hasNextPage) + }, + pathname_search: (result) => { + if (typeof result.uris === 'string') return result.uris + return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage) + }, + search: (result) => { + if (typeof result.uris === 'string') return result.uris + return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage) + }, } } - callContextTool: IToolService['callContextTool'] = (toolName, params) => { - return this.contextToolCallFns[toolName](params) - } - } -registerSingleton(IToolService, ToolService, InstantiationType.Eager); - +registerSingleton(IToolsService, ToolsService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/common/voidFileService.ts b/src/vs/workbench/contrib/void/common/voidFileService.ts new file mode 100644 index 00000000..a7c25631 --- /dev/null +++ b/src/vs/workbench/contrib/void/common/voidFileService.ts @@ -0,0 +1,109 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { isWindows } from '../../../../base/common/platform.js'; +import { URI } from '../../../../base/common/uri.js'; +import { EndOfLinePreference } from '../../../../editor/common/model.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + + +// linebreak symbols +export const allLinebreakSymbols = ['\r\n', '\n'] +export const _ln = isWindows ? allLinebreakSymbols[0] : allLinebreakSymbols[1] + +export interface IVoidFileService { + readonly _serviceBrand: undefined; + + readFile(uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise; + readModel(uri: URI, range?: { startLineNumber: number, endLineNumber: number }): string | null; +} + +export const IVoidFileService = createDecorator('VoidFileService'); + +// implemented by calling channel +export class VoidFileService implements IVoidFileService { + readonly _serviceBrand: undefined; + + constructor( + @IModelService private readonly modelService: IModelService, + @IFileService private readonly fileService: IFileService, + ) { + + } + + readFile = async (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise => { + + // attempt to read the model + const modelResult = this.readModel(uri, range); + if (modelResult) return modelResult; + + // if no model, read the raw file + const fileResult = await this._readFileRaw(uri, range); + if (fileResult) return fileResult; + + return ''; + } + + _readFileRaw = async (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise => { + + try { // this throws an error if no file exists (eg it was deleted) + + const res = await this.fileService.readFile(uri); + + if (range) { + return res.value.toString() + .split(_ln) + .slice(range.startLineNumber - 1, range.endLineNumber) + .join(_ln) + } + + return res.value.toString(); + + + } catch (e) { + return null; + } + } + + + readModel = (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): string | null => { + + // read saved model (sometimes null if the user reloads application) + let model = this.modelService.getModel(uri); + + // check all opened models for the same `fsPath` + if (!model) { + const models = this.modelService.getModels(); + for (const m of models) { + if (m.uri.fsPath === uri.fsPath) { + model = m + break; + } + } + } + + // if still not found, return + if (!model) { return null } + + // if range, read it + if (range) { + return model.getValueInRange({ + startLineNumber: range.startLineNumber, + endLineNumber: range.endLineNumber, + startColumn: 1, + endColumn: Number.MAX_VALUE + }, EndOfLinePreference.LF); + } else { + return model.getValue(EndOfLinePreference.LF) + } + + } + +} + +registerSingleton(IVoidFileService, VoidFileService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/common/voidSettingsService.ts b/src/vs/workbench/contrib/void/common/voidSettingsService.ts index 4322eaf4..72095c83 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsService.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsService.ts @@ -11,7 +11,7 @@ import { registerSingleton, InstantiationType } from '../../../../platform/insta import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IMetricsService } from './metricsService.js'; -import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, modelInfoOfDefaultModelNames, VoidModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, displayInfoOfProviderName, defaultProviderSettings } from './voidSettingsTypes.js'; +import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, defaultProviderSettings } from './voidSettingsTypes.js'; const STORAGE_KEY = 'void.settingsServiceStorage' @@ -32,8 +32,6 @@ type SetGlobalSettingFn = (settingName: T, newVal export type ModelOption = { name: string, selection: ModelSelection } - - export type VoidSettingsState = { readonly settingsOfProvider: SettingsOfProvider; // optionsOfProvider readonly modelSelectionOfFeature: ModelSelectionOfFeature; // stateOfFeature @@ -65,7 +63,30 @@ export interface IVoidSettingsService { -const _updatedValidatedState = (state: Omit) => { + +const _updatedModelsAfterDefaultModelsChange = (defaultModelNames: string[], options: { existingModels: VoidModelInfo[] }) => { + const { existingModels } = options + + const existingModelsMap: Record = {} + for (const existingModel of existingModels) { + existingModelsMap[existingModel.modelName] = existingModel + } + + const newDefaultModels = defaultModelNames.map((modelName, i) => ({ + modelName, + isDefault: true, + isAutodetected: true, + isHidden: !!existingModelsMap[modelName]?.isHidden, + })) + + return [ + ...newDefaultModels, // swap out all the default models for the new default models + ...existingModels.filter(m => !m.isDefault), // keep any non-default (custom) models + ] +} + + +const _validatedState = (state: Omit) => { let newSettingsOfProvider = state.settingsOfProvider @@ -89,7 +110,7 @@ const _updatedValidatedState = (state: Omit) // update model options let newModelOptions: ModelOption[] = [] for (const providerName of providerNames) { - const providerTitle = displayInfoOfProviderName(providerName).title.toLowerCase() // looks better lowercase, best practice to not use raw providerName + const providerTitle = providerName // displayInfoOfProviderName(providerName).title.toLowerCase() // looks better lowercase, best practice to not use raw providerName if (!newSettingsOfProvider[providerName]._didFillInProviderSettings) continue // if disabled, don't display model options for (const { modelName, isHidden } of newSettingsOfProvider[providerName].models) { if (isHidden) continue @@ -131,7 +152,7 @@ const _updatedValidatedState = (state: Omit) const defaultState = () => { const d: VoidSettingsState = { settingsOfProvider: deepClone(defaultSettingsOfProvider), - modelSelectionOfFeature: { 'Ctrl+L': null, 'Ctrl+K': null, 'Autocomplete': null, 'FastApply': null }, + modelSelectionOfFeature: { 'Ctrl+L': null, 'Ctrl+K': null, 'Autocomplete': null, 'Apply': null }, globalSettings: deepClone(defaultGlobalSettings), _modelOptions: [], // computed later } @@ -172,8 +193,12 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { // A HACK BECAUSE WE ADDED DEEPSEEK (did not exist before, comes before readS) ...{ deepseek: defaultSettingsOfProvider.deepseek }, - // A HACK BECAUSE WE ADDED MISTRAL (did not exist before, comes before readS) - ...{ mistral: defaultSettingsOfProvider.mistral }, + // A HACK BECAUSE WE ADDED XAI (did not exist before, comes before readS) + ...{ xAI: defaultSettingsOfProvider.xAI }, + + // A HACK BECAUSE WE ADDED VLLM (did not exist before, comes before readS) + ...{ vLLM: defaultSettingsOfProvider.vLLM }, + ...readS.settingsOfProvider, @@ -189,7 +214,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { const newModelSelectionOfFeature = { // A HACK BECAUSE WE ADDED FastApply - ...{ 'FastApply': null }, + ...{ 'Apply': null }, ...readS.modelSelectionOfFeature, } @@ -199,7 +224,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { modelSelectionOfFeature: newModelSelectionOfFeature, } - this.state = _updatedValidatedState(readS) + this.state = _validatedState(readS) resolver() this._onDidChangeState.fire() @@ -246,7 +271,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { globalSettings: newGlobalSettings, } - this.state = _updatedValidatedState(newState) + this.state = _validatedState(newState) await this._storeState() this._onDidChangeState.fire() @@ -289,27 +314,21 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { - setAutodetectedModels(providerName: ProviderName, newDefaultModelNames: string[], logging: object) { + + setAutodetectedModels(providerName: ProviderName, autodetectedModelNames: string[], logging: object) { const { models } = this.state.settingsOfProvider[providerName] - const oldModelNames = models.map(m => m.modelName) - const newDefaultModelInfo = modelInfoOfDefaultModelNames(newDefaultModelNames, { isAutodetected: true, existingModels: models }) - const newModelInfo = [ - ...newDefaultModelInfo, // swap out all the default models for the new default models - ...models.filter(m => !m.isDefault), // keep any non-defaul (custom) models - ] - - - this.setSettingOfProvider(providerName, 'models', newModelInfo) + const newModels = _updatedModelsAfterDefaultModelsChange(autodetectedModelNames, { existingModels: models }) + this.setSettingOfProvider(providerName, 'models', newModels) // if the models changed, log it - const new_names = newModelInfo.map(m => m.modelName) + const new_names = newModels.map(m => m.modelName) if (!(oldModelNames.length === new_names.length && oldModelNames.every((_, i) => oldModelNames[i] === new_names[i])) ) { - this._metricsService.capture('Autodetect Models', { providerName, newModels: newModelInfo, ...logging }) + this._metricsService.capture('Autodetect Models', { providerName, newModels: newModels, ...logging }) } } toggleModelHidden(providerName: ProviderName, modelName: string) { diff --git a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts index 0a5bdc64..379a4817 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts @@ -7,141 +7,9 @@ import { VoidSettingsState } from './voidSettingsService.js' -export type VoidModelInfo = { - modelName: string, - isDefault: boolean, // whether or not it's a default for its provider - isHidden: boolean, // whether or not the user is hiding it (switched off) - isAutodetected?: boolean, // whether the model was autodetected by polling -} - -// creates `modelInfo` from `modelNames` -export const modelInfoOfDefaultModelNames = (defaultModelNames: string[], options?: { isAutodetected: true, existingModels: VoidModelInfo[] }): VoidModelInfo[] => { - - const { isAutodetected, existingModels } = options ?? {} - - if (!existingModels) { // default settings - - return defaultModelNames.map((modelName, i) => ({ - modelName, - isDefault: true, - isAutodetected: isAutodetected, - isHidden: defaultModelNames.length >= 10 // hide all models if there are a ton of them, and make user enable them individually - })) - - } else { // settings if there are existing models (keep existing `isHidden` property) - - const existingModelsMap: Record = {} - for (const existingModel of existingModels) { - existingModelsMap[existingModel.modelName] = existingModel - } - - return defaultModelNames.map((modelName, i) => ({ - modelName, - isDefault: true, - isAutodetected: isAutodetected, - isHidden: !!existingModelsMap[modelName]?.isHidden, - })) - - } - -} - -// https://docs.anthropic.com/en/docs/about-claude/models -export const defaultAnthropicModels = modelInfoOfDefaultModelNames([ - 'claude-3-5-sonnet-20241022', - 'claude-3-5-haiku-20241022', - 'claude-3-opus-20240229', - 'claude-3-sonnet-20240229', - // 'claude-3-haiku-20240307', -]) - - -// https://platform.openai.com/docs/models/gp -export const defaultOpenAIModels = modelInfoOfDefaultModelNames([ - 'o1', - 'o1-mini', - 'o3-mini', - 'gpt-4o', - 'gpt-4o-mini', - // 'gpt-4o-2024-05-13', - // 'gpt-4o-2024-08-06', - // 'gpt-4o-mini-2024-07-18', - // 'gpt-4-turbo', - // 'gpt-4-turbo-2024-04-09', - // 'gpt-4-turbo-preview', - // 'gpt-4-0125-preview', - // 'gpt-4-1106-preview', - // 'gpt-4', - // 'gpt-4-0613', - // 'gpt-3.5-turbo-0125', - // 'gpt-3.5-turbo', - // 'gpt-3.5-turbo-1106', -]) - -// https://platform.openai.com/docs/models/gp -export const defaultDeepseekModels = modelInfoOfDefaultModelNames([ - 'deepseek-chat', - 'deepseek-reasoner', -]) - - -// https://console.groq.com/docs/models -export const defaultGroqModels = modelInfoOfDefaultModelNames([ - "llama3-70b-8192", - "llama-3.3-70b-versatile", - "llama-3.1-8b-instant", - "gemma2-9b-it", - "mixtral-8x7b-32768" -]) - - -export const defaultGeminiModels = modelInfoOfDefaultModelNames([ - 'gemini-1.5-flash', - 'gemini-1.5-pro', - 'gemini-1.5-flash-8b', - 'gemini-2.0-flash-exp', - 'gemini-2.0-flash-thinking-exp-1219', - 'learnlm-1.5-pro-experimental' -]) - -export const defaultMistralModels = modelInfoOfDefaultModelNames([ - "codestral-latest", - "open-codestral-mamba", - "open-mistral-nemo", - "mistral-large-latest", - "pixtral-large-latest", - "ministral-3b-latest", - "ministral-8b-latest", - "mistral-small-latest", -]) - -// export const parseMaxTokensStr = (maxTokensStr: string) => { -// // parse the string but only if the full string is a valid number, eg parseInt('100abc') should return NaN -// const int = isNaN(Number(maxTokensStr)) ? undefined : parseInt(maxTokensStr) -// if (Number.isNaN(int)) -// return undefined -// return int -// } - - - - -export const anthropicMaxPossibleTokens = (modelName: string) => { - if (modelName === 'claude-3-5-sonnet-20241022' - || modelName === 'claude-3-5-haiku-20241022') - return 8192 - if (modelName === 'claude-3-opus-20240229' - || modelName === 'claude-3-sonnet-20240229' - || modelName === 'claude-3-haiku-20240307') - return 4096 - return 1024 // return a reasonably small number if they're using a different model -} - - type UnionOfKeys = T extends T ? keyof T : never; - export const defaultProviderSettings = { anthropic: { apiKey: '', @@ -155,6 +23,9 @@ export const defaultProviderSettings = { ollama: { endpoint: 'http://127.0.0.1:11434', }, + vLLM: { + endpoint: 'http://localhost:8000', + }, openRouter: { apiKey: '', }, @@ -168,15 +39,74 @@ export const defaultProviderSettings = { groq: { apiKey: '', }, - mistral: { + xAI: { apiKey: '' - } + }, } as const + + + +export const defaultModelsOfProvider = { + openAI: [ // https://platform.openai.com/docs/models/gp + 'o1', + 'o3-mini', + 'o1-mini', + 'gpt-4o', + 'gpt-4o-mini', + ], + anthropic: [ // https://docs.anthropic.com/en/docs/about-claude/models + 'claude-3-5-sonnet-latest', + 'claude-3-5-haiku-latest', + 'claude-3-opus-latest', + ], + xAI: [ // https://docs.x.ai/docs/models?cluster=us-east-1 + 'grok-2-latest', + 'grok-3-latest', + ], + gemini: [ // https://ai.google.dev/gemini-api/docs/models/gemini + 'gemini-2.0-flash', + 'gemini-1.5-flash', + 'gemini-1.5-pro', + 'gemini-1.5-flash-8b', + 'gemini-2.0-flash-thinking-exp', + ], + deepseek: [ // https://api-docs.deepseek.com/quick_start/pricing + 'deepseek-chat', + 'deepseek-reasoner', + ], + ollama: [ // autodetected + ], + vLLM: [ // autodetected + ], + openRouter: [ // https://openrouter.ai/models + 'anthropic/claude-3.5-sonnet', + 'deepseek/deepseek-r1', + 'mistralai/codestral-2501', + 'qwen/qwen-2.5-coder-32b-instruct', + ], + groq: [ // https://console.groq.com/docs/models + 'llama-3.3-70b-versatile', + 'llama-3.1-8b-instant', + 'qwen-2.5-coder-32b', // preview mode (experimental) + ], + // not supporting mistral right now- it's last on Void usage, and a huge pain to set up since it's nonstandard (it supports codestral FIM but it's on v1/fim/completions, etc) + // mistral: [ // https://docs.mistral.ai/getting-started/models/models_overview/ + // 'codestral-latest', + // 'mistral-large-latest', + // 'ministral-3b-latest', + // 'ministral-8b-latest', + // ], + openAICompatible: [], // fallback +} as const satisfies Record + + + + export type ProviderName = keyof typeof defaultProviderSettings export const providerNames = Object.keys(defaultProviderSettings) as ProviderName[] -export const localProviderNames = ['ollama'] satisfies ProviderName[] // all local names +export const localProviderNames = ['ollama', 'vLLM'] satisfies ProviderName[] // all local names export const nonlocalProviderNames = providerNames.filter((name) => !(localProviderNames as string[]).includes(name)) // all non-local names type CustomSettingName = UnionOfKeys @@ -189,6 +119,14 @@ export const customSettingNamesOfProvider = (providerName: ProviderName) => { +export type VoidModelInfo = { // <-- STATEFUL + modelName: string, + isDefault: boolean, // whether or not it's a default for its provider + isHidden: boolean, // whether or not the user is hiding it (switched off) + isAutodetected?: boolean, // whether the model was autodetected by polling +} // TODO!!! eventually we'd want to let the user change supportsFIM, etc on the model themselves + + type CommonProviderSettings = { _didFillInProviderSettings: boolean | undefined, // undefined initially, computed when user types in all fields @@ -205,10 +143,6 @@ export type SettingsOfProvider = { export type SettingName = keyof SettingsAtProvider - - - - type DisplayInfoForProviderName = { title: string, desc?: string, @@ -238,7 +172,11 @@ export const displayInfoOfProviderName = (providerName: ProviderName): DisplayIn else if (providerName === 'ollama') { return { title: 'Ollama', - + } + } + else if (providerName === 'vLLM') { + return { + title: 'vLLM', } } else if (providerName === 'openAICompatible') { @@ -256,19 +194,21 @@ export const displayInfoOfProviderName = (providerName: ProviderName): DisplayIn title: 'Groq.com API', } } - else if (providerName === 'mistral') { + else if (providerName === 'xAI') { return { - title: 'Mistral API', + title: 'xAI API', } } + throw new Error(`descOfProviderName: Unknown provider name: "${providerName}"`) } type DisplayInfo = { - title: string, - placeholder: string, - subTextMd?: string, + title: string; + placeholder: string; + subTextMd?: string; + isPasswordField?: boolean; } export const displayInfoOfSettingName = (providerName: ProviderName, settingName: SettingName): DisplayInfo => { if (settingName === 'apiKey') { @@ -283,8 +223,8 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName providerName === 'openRouter' ? 'sk-or-key...' : // sk-or-v1-key providerName === 'gemini' ? 'key...' : providerName === 'groq' ? 'gsk_key...' : - providerName === 'mistral' ? 'key...' : - providerName === 'openAICompatible' ? 'sk-key...' : + providerName === 'openAICompatible' ? 'sk-key...' : + providerName === 'xAI' ? 'xai-key...' : '', subTextMd: providerName === 'anthropic' ? 'Get your [API Key here](https://console.anthropic.com/settings/keys).' : @@ -293,23 +233,27 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName providerName === 'openRouter' ? 'Get your [API Key here](https://openrouter.ai/settings/keys).' : providerName === 'gemini' ? 'Get your [API Key here](https://aistudio.google.com/apikey).' : providerName === 'groq' ? 'Get your [API Key here](https://console.groq.com/keys).' : - providerName === 'mistral' ? 'Get your [API Key here](https://console.mistral.ai/api-keys/).' : + providerName === 'xAI' ? 'Get your [API Key here](https://console.x.ai).' : providerName === 'openAICompatible' ? undefined : '', + isPasswordField: true, } } else if (settingName === 'endpoint') { return { title: providerName === 'ollama' ? 'Endpoint' : - providerName === 'openAICompatible' ? 'baseURL' // (do not include /chat/completions) - : '(never)', + providerName === 'vLLM' ? 'Endpoint' : + providerName === 'openAICompatible' ? 'baseURL' : // (do not include /chat/completions) + '(never)', placeholder: providerName === 'ollama' ? defaultProviderSettings.ollama.endpoint - : providerName === 'openAICompatible' ? 'https://my-website.com/v1' - : '(never)', + : providerName === 'vLLM' ? defaultProviderSettings.vLLM.endpoint + : providerName === 'openAICompatible' ? 'https://my-website.com/v1' + : '(never)', subTextMd: providerName === 'ollama' ? 'If you would like to change this endpoint, please read more about [Endpoints here](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-can-i-expose-ollama-on-my-network).' : - undefined, + providerName === 'vLLM' ? 'If you would like to change this endpoint, please read more about [Endpoints here](https://docs.vllm.ai/en/latest/getting_started/quickstart.html#openai-compatible-server).' : + undefined, } } else if (settingName === '_didFillInProviderSettings') { @@ -338,92 +282,77 @@ const defaultCustomSettings: Record = { } - -export const voidInitModelOptions = { - anthropic: { - models: defaultAnthropicModels, - }, - openAI: { - models: defaultOpenAIModels, - }, - deepseek: { - models: defaultDeepseekModels, - }, - ollama: { - models: [], - }, - openRouter: { - models: [], // any string - }, - openAICompatible: { - models: [], - }, - gemini: { - models: defaultGeminiModels, - }, - groq: { - models: defaultGroqModels, - }, - mistral: { - models: defaultMistralModels, +const modelInfoOfDefaultModelNames = (defaultModelNames: string[]): { models: VoidModelInfo[] } => { + return { + models: defaultModelNames.map((modelName, i) => ({ + modelName, + isDefault: true, + isAutodetected: false, + isHidden: defaultModelNames.length >= 10, // hide all models if there are a ton of them, and make user enable them individually + })) } } - // used when waiting and for a type reference export const defaultSettingsOfProvider: SettingsOfProvider = { anthropic: { ...defaultCustomSettings, ...defaultProviderSettings.anthropic, - ...voidInitModelOptions.anthropic, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.anthropic), _didFillInProviderSettings: undefined, }, openAI: { ...defaultCustomSettings, ...defaultProviderSettings.openAI, - ...voidInitModelOptions.openAI, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.openAI), _didFillInProviderSettings: undefined, }, deepseek: { ...defaultCustomSettings, ...defaultProviderSettings.deepseek, - ...voidInitModelOptions.deepseek, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.deepseek), _didFillInProviderSettings: undefined, }, gemini: { ...defaultCustomSettings, ...defaultProviderSettings.gemini, - ...voidInitModelOptions.gemini, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.gemini), _didFillInProviderSettings: undefined, }, - mistral: { + xAI: { ...defaultCustomSettings, - ...defaultProviderSettings.mistral, - ...voidInitModelOptions.mistral, + ...defaultProviderSettings.xAI, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.xAI), _didFillInProviderSettings: undefined, }, groq: { // aggregator ...defaultCustomSettings, ...defaultProviderSettings.groq, - ...voidInitModelOptions.groq, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.groq), _didFillInProviderSettings: undefined, }, openRouter: { // aggregator ...defaultCustomSettings, ...defaultProviderSettings.openRouter, - ...voidInitModelOptions.openRouter, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.openRouter), _didFillInProviderSettings: undefined, }, openAICompatible: { // aggregator ...defaultCustomSettings, ...defaultProviderSettings.openAICompatible, - ...voidInitModelOptions.openAICompatible, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.openAICompatible), _didFillInProviderSettings: undefined, }, ollama: { // aggregator ...defaultCustomSettings, ...defaultProviderSettings.ollama, - ...voidInitModelOptions.ollama, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.ollama), + _didFillInProviderSettings: undefined, + }, + vLLM: { // aggregator + ...defaultCustomSettings, + ...defaultProviderSettings.vLLM, + ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.vLLM), _didFillInProviderSettings: undefined, }, } @@ -436,18 +365,20 @@ export const modelSelectionsEqual = (m1: ModelSelection, m2: ModelSelection) => } // this is a state -export const featureNames = ['Ctrl+L', 'Ctrl+K', 'Autocomplete', 'FastApply'] as const +export const featureNames = ['Ctrl+L', 'Ctrl+K', 'Autocomplete', 'Apply'] as const export type ModelSelectionOfFeature = Record<(typeof featureNames)[number], ModelSelection | null> export type FeatureName = keyof ModelSelectionOfFeature export const displayInfoOfFeatureName = (featureName: FeatureName) => { + // editor: if (featureName === 'Autocomplete') return 'Autocomplete' else if (featureName === 'Ctrl+K') - return 'Quick-Edit' + return 'Quick Edit' + // sidebar: else if (featureName === 'Ctrl+L') return 'Chat' - else if (featureName === 'FastApply') + else if (featureName === 'Apply') return 'Apply' else throw new Error(`Feature Name ${featureName} not allowed`) @@ -528,77 +459,3 @@ export const globalSettingNames = Object.keys(defaultGlobalSettings) as GlobalSe - - - -export const recognizedModels = [ - - // chat - 'OpenAI 4o', - 'Anthropic Claude', - 'Llama 3.x', - 'Deepseek Chat', // deepseek coder v2 is now merged into chat (V3) https://api-docs.deepseek.com/updates#deepseek-coder--deepseek-chat-upgraded-to-deepseek-v25-model - // 'xAI Grok', - // 'Google Gemini, Gemma', - // 'Microsoft Phi4', - - - // coding (autocomplete) - 'Alibaba Qwen2.5 Coder Instruct', // we recommend this over Qwen2.5 - 'Mistral Codestral', - - // thinking - 'OpenAI o1, o3', - 'Deepseek R1', - - // general - '' - // 'Mixtral 8x7b' - // 'Qwen2.5', - -] as const - - - - -type RecognizedModel = (typeof recognizedModels)[number] - - -// const modelCapabilities: { [recognizedModel in RecognizedModel]: ({ }) => string } = { -// 'OpenAI 4o': { -// template: ({ prefix, suffix, }: { prefix: string; suffix: string; }) => `\ -// ` -// } -// } - -export function getRecognizedModel(modelName: string): RecognizedModel { - const lower = modelName.toLowerCase(); - - if (lower.includes('gpt-4o')) { - return 'OpenAI 4o'; - } - if (lower.includes('claude')) { - return 'Anthropic Claude'; - } - if (lower.includes('llama')) { - return 'Llama 3.x'; - } - if (lower.includes('qwen2.5-coder')) { - return 'Alibaba Qwen2.5 Coder Instruct'; - } - if (lower.includes('mistral')) { - return 'Mistral Codestral'; - } - // Check for "o1" or "o3" - if (/\bo1\b/.test(lower) || /\bo3\b/.test(lower)) { - return 'OpenAI o1, o3'; - } - if (lower.includes('deepseek-r1') || lower.includes('deepseek-reasoner')) { - return 'Deepseek R1'; - } - - - - // Fallback: - return ''; -} diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/MODELS.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/MODELS.ts new file mode 100644 index 00000000..a4ad5487 --- /dev/null +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/MODELS.ts @@ -0,0 +1,1000 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import OpenAI, { ClientOptions } from 'openai'; +import Anthropic from '@anthropic-ai/sdk'; +import { Ollama } from 'ollama'; + +import { Model as OpenAIModel } from 'openai/resources/models.js'; +import { OllamaModelResponse, OnText, OnFinalMessage, OnError, LLMChatMessage, LLMFIMMessage, ModelListParams } from '../../common/llmMessageTypes.js'; +import { InternalToolInfo, isAToolName } from '../../common/toolsService.js'; +import { defaultProviderSettings, displayInfoOfProviderName, ProviderName, SettingsOfProvider } from '../../common/voidSettingsTypes.js'; +import { prepareFIMMessage, prepareMessages } from './preprocessLLMMessages.js'; +import { extractReasoningFromText } from '../../browser/helpers/extractCodeFromResult.js'; + + + +type ModelOptions = { + contextWindow: number; // input tokens + maxOutputTokens: number | null; // output tokens + cost: { + input: number; + output: number; + cache_read?: number; + cache_write?: number; + } + supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated'; + supportsTools: false | 'anthropic-style' | 'openai-style'; + supportsFIM: boolean; + + supportsReasoningOutput: false | { + // you are allowed to not include openSourceThinkTags if it's not open source (no such cases as of writing) + // if it's open source, put the think tags here so we parse them out in e.g. ollama + openSourceThinkTags?: [string, string] + }; +} + +type ProviderReasoningOptions = { + // include this in payload to get reasoning + input?: { includeInPayload?: { [key: string]: any }, }; + // nameOfFieldInDelta: reasoning output is in response.choices[0].delta[deltaReasoningField] + // needsManualParse: whether we must manually parse out the tags + output?: + | { nameOfFieldInDelta?: string, needsManualParse?: undefined, } + | { nameOfFieldInDelta?: undefined, needsManualParse?: true, }; +} + +type ProviderSettings = { + ifSupportsReasoningOutput?: ProviderReasoningOptions; + modelOptions: { [key: string]: ModelOptions }; + modelOptionsFallback: (modelName: string) => (ModelOptions & { modelName: string }) | null; +} + + +type ModelSettingsOfProvider = { + [providerName in ProviderName]: ProviderSettings +} + + + +// type DefaultModels = typeof defaultModelsOfProvider[T][number] +// type AssertModelsIncluded< +// T extends ProviderName, +// Options extends Record +// > = Exclude, keyof Options> extends never +// ? true +// : ["Missing models for", T, Exclude, keyof Options>]; +// const assertOpenAI: AssertModelsIncluded<'openAI', typeof openAIModelOptions> = true; + + +const modelOptionDefaults: ModelOptions = { + contextWindow: 32_000, + maxOutputTokens: null, + cost: { input: 0, output: 0 }, + supportsSystemMessage: false, + supportsTools: false, + supportsFIM: false, + supportsReasoningOutput: false, +} + +const invalidApiKeyMessage = (providerName: ProviderName) => `Invalid ${displayInfoOfProviderName(providerName).title} API key.` + + +// ---------------- OPENAI ---------------- +const openAIModelOptions = { // https://platform.openai.com/docs/pricing + 'o1': { + contextWindow: 128_000, + maxOutputTokens: 100_000, + cost: { input: 15.00, cache_read: 7.50, output: 60.00, }, + supportsFIM: false, + supportsTools: false, + supportsSystemMessage: 'developer-role', + supportsReasoningOutput: false, + }, + 'o3-mini': { + contextWindow: 200_000, + maxOutputTokens: 100_000, + cost: { input: 1.10, cache_read: 0.55, output: 4.40, }, + supportsFIM: false, + supportsTools: false, + supportsSystemMessage: 'developer-role', + supportsReasoningOutput: false, + }, + 'gpt-4o': { + contextWindow: 128_000, + maxOutputTokens: 16_384, + cost: { input: 2.50, cache_read: 1.25, output: 10.00, }, + supportsFIM: false, + supportsTools: 'openai-style', + supportsSystemMessage: 'system-role', + supportsReasoningOutput: false, + }, + 'o1-mini': { + contextWindow: 128_000, + maxOutputTokens: 65_536, + cost: { input: 1.10, cache_read: 0.55, output: 4.40, }, + supportsFIM: false, + supportsTools: false, + supportsSystemMessage: false, // does not support any system + supportsReasoningOutput: false, + }, + 'gpt-4o-mini': { + contextWindow: 128_000, + maxOutputTokens: 16_384, + cost: { input: 0.15, cache_read: 0.075, output: 0.60, }, + supportsFIM: false, + supportsTools: 'openai-style', + supportsSystemMessage: 'system-role', // ?? + supportsReasoningOutput: false, + }, +} as const satisfies { [s: string]: ModelOptions } + + +const openAISettings: ProviderSettings = { + modelOptions: openAIModelOptions, + modelOptionsFallback: (modelName) => { + let fallbackName: keyof typeof openAIModelOptions | null = null + if (modelName.includes('o1')) { fallbackName = 'o1' } + if (modelName.includes('o3-mini')) { fallbackName = 'o3-mini' } + if (modelName.includes('gpt-4o')) { fallbackName = 'gpt-4o' } + if (fallbackName) return { modelName: fallbackName, ...openAIModelOptions[fallbackName] } + return null + } +} + +// ---------------- ANTHROPIC ---------------- +const anthropicModelOptions = { + 'claude-3-5-sonnet-20241022': { + contextWindow: 200_000, + maxOutputTokens: 8_192, + cost: { input: 3.00, cache_read: 0.30, cache_write: 3.75, output: 15.00 }, + supportsFIM: false, + supportsSystemMessage: 'separated', + supportsTools: 'anthropic-style', + supportsReasoningOutput: false, + }, + 'claude-3-5-haiku-20241022': { + contextWindow: 200_000, + maxOutputTokens: 8_192, + cost: { input: 0.80, cache_read: 0.08, cache_write: 1.00, output: 4.00 }, + supportsFIM: false, + supportsSystemMessage: 'separated', + supportsTools: 'anthropic-style', + supportsReasoningOutput: false, + }, + 'claude-3-opus-20240229': { + contextWindow: 200_000, + maxOutputTokens: 4_096, + cost: { input: 15.00, cache_read: 1.50, cache_write: 18.75, output: 75.00 }, + supportsFIM: false, + supportsSystemMessage: 'separated', + supportsTools: 'anthropic-style', + supportsReasoningOutput: false, + }, + 'claude-3-sonnet-20240229': { // no point of using this, but including this for people who put it in + contextWindow: 200_000, cost: { input: 3.00, output: 15.00 }, + maxOutputTokens: 4_096, + supportsFIM: false, + supportsSystemMessage: 'separated', + supportsTools: 'anthropic-style', + supportsReasoningOutput: false, + } +} as const satisfies { [s: string]: ModelOptions } + +const anthropicSettings: ProviderSettings = { + modelOptions: anthropicModelOptions, + modelOptionsFallback: (modelName) => { + let fallbackName: keyof typeof anthropicModelOptions | null = null + if (modelName.includes('claude-3-5-sonnet')) fallbackName = 'claude-3-5-sonnet-20241022' + if (modelName.includes('claude-3-5-haiku')) fallbackName = 'claude-3-5-haiku-20241022' + if (modelName.includes('claude-3-opus')) fallbackName = 'claude-3-opus-20240229' + if (modelName.includes('claude-3-sonnet')) fallbackName = 'claude-3-sonnet-20240229' + if (fallbackName) return { modelName: fallbackName, ...anthropicModelOptions[fallbackName] } + return { modelName, ...modelOptionDefaults, maxOutputTokens: 4_096 } + } +} + + +// ---------------- XAI ---------------- +const xAIModelOptions = { + 'grok-2-latest': { + contextWindow: 131_072, + maxOutputTokens: null, // 131_072, + cost: { input: 2.00, output: 10.00 }, + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, +} as const satisfies { [s: string]: ModelOptions } + +const xAISettings: ProviderSettings = { + modelOptions: xAIModelOptions, + modelOptionsFallback: (modelName) => { + let fallbackName: keyof typeof xAIModelOptions | null = null + if (modelName.includes('grok-2')) fallbackName = 'grok-2-latest' + if (fallbackName) return { modelName: fallbackName, ...xAIModelOptions[fallbackName] } + return null + } +} + + +// ---------------- GEMINI ---------------- +const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing + 'gemini-2.0-flash': { + contextWindow: 1_048_576, + maxOutputTokens: null, // 8_192, + cost: { input: 0.10, output: 0.40 }, + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', // we are assuming OpenAI SDK when calling gemini + supportsReasoningOutput: false, + }, + 'gemini-2.0-flash-lite-preview-02-05': { + contextWindow: 1_048_576, + maxOutputTokens: null, // 8_192, + cost: { input: 0.075, output: 0.30 }, + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'gemini-1.5-flash': { + contextWindow: 1_048_576, + maxOutputTokens: null, // 8_192, + cost: { input: 0.075, output: 0.30 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'gemini-1.5-pro': { + contextWindow: 2_097_152, + maxOutputTokens: null, // 8_192, + cost: { input: 1.25, output: 5.00 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'gemini-1.5-flash-8b': { + contextWindow: 1_048_576, + maxOutputTokens: null, // 8_192, + cost: { input: 0.0375, output: 0.15 }, // TODO!!! price doubles after 128K tokens, we are NOT encoding that info right now + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, +} as const satisfies { [s: string]: ModelOptions } + +const geminiSettings: ProviderSettings = { + modelOptions: geminiModelOptions, + modelOptionsFallback: (modelName) => { + return null + } +} + + +// ---------------- OPEN SOURCE MODELS ---------------- + +const openSourceModelDefaultOptionsAssumingOAICompat = { + 'deepseekR1': { + supportsFIM: false, + supportsSystemMessage: false, + supportsTools: false, + supportsReasoningOutput: { openSourceThinkTags: ['', ''] }, + }, + 'deepseekCoderV2': { + supportsFIM: false, + supportsSystemMessage: false, // unstable + supportsTools: false, + supportsReasoningOutput: false, + }, + 'codestral': { + supportsFIM: true, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + // llama + 'llama3': { + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'llama3.1': { + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'llama3.2': { + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'llama3.3': { + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'qwen2.5coder': { + supportsFIM: true, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + // FIM only + 'starcoder2': { + supportsFIM: true, + supportsSystemMessage: false, + supportsTools: false, + supportsReasoningOutput: false, + }, + 'codegemma:2b': { + supportsFIM: true, + supportsSystemMessage: false, + supportsTools: false, + supportsReasoningOutput: false, + }, +} as const satisfies { [s: string]: Partial } + + + +// ---------------- DEEPSEEK API ---------------- +const deepseekModelOptions = { + 'deepseek-chat': { + ...openSourceModelDefaultOptionsAssumingOAICompat.deepseekR1, + contextWindow: 64_000, // https://api-docs.deepseek.com/quick_start/pricing + maxOutputTokens: null, // 8_000, + cost: { cache_read: .07, input: .27, output: 1.10, }, + }, + 'deepseek-reasoner': { + ...openSourceModelDefaultOptionsAssumingOAICompat.deepseekCoderV2, + contextWindow: 64_000, + maxOutputTokens: null, // 8_000, + cost: { cache_read: .14, input: .55, output: 2.19, }, + }, +} as const satisfies { [s: string]: ModelOptions } + + +const deepseekSettings: ProviderSettings = { + modelOptions: deepseekModelOptions, + ifSupportsReasoningOutput: { + // reasoning: OAICompat + response.choices[0].delta.reasoning_content // https://api-docs.deepseek.com/guides/reasoning_model + output: { nameOfFieldInDelta: 'reasoning_content' }, + }, + modelOptionsFallback: (modelName) => { + return null + } +} + +// ---------------- GROQ ---------------- +const groqModelOptions = { + 'llama-3.3-70b-versatile': { + contextWindow: 128_000, + maxOutputTokens: null, // 32_768, + cost: { input: 0.59, output: 0.79 }, + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'llama-3.1-8b-instant': { + contextWindow: 128_000, + maxOutputTokens: null, // 8_192, + cost: { input: 0.05, output: 0.08 }, + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'qwen-2.5-coder-32b': { + contextWindow: 128_000, + maxOutputTokens: null, // not specified? + cost: { input: 0.79, output: 0.79 }, + supportsFIM: false, // unfortunately looks like no FIM support on groq + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, +} as const satisfies { [s: string]: ModelOptions } +const groqSettings: ProviderSettings = { + modelOptions: groqModelOptions, + modelOptionsFallback: (modelName) => { return null } +} + + +// ---------------- anything self-hosted/local: VLLM, OLLAMA, OPENAICOMPAT ---------------- + +// fallback to any model (anything openai-compatible) +const extensiveModelFallback: ProviderSettings['modelOptionsFallback'] = (modelName) => { + const toFallback = (opts: Omit): ModelOptions & { modelName: string } => { + return { + modelName, + ...opts, + supportsSystemMessage: opts.supportsSystemMessage ? 'system-role' : false, + cost: { input: 0, output: 0 }, + } + } + if (modelName.includes('gpt-4o')) return toFallback(openAIModelOptions['gpt-4o']) + if (modelName.includes('claude')) return toFallback(anthropicModelOptions['claude-3-5-sonnet-20241022']) + if (modelName.includes('grok')) return toFallback(xAIModelOptions['grok-2-latest']) + if (modelName.includes('deepseek-r1') || modelName.includes('deepseek-reasoner')) return toFallback({ ...openSourceModelDefaultOptionsAssumingOAICompat.deepseekR1, contextWindow: 32_000, maxOutputTokens: 4_096, }) + if (modelName.includes('deepseek')) return toFallback({ ...openSourceModelDefaultOptionsAssumingOAICompat.deepseekCoderV2, contextWindow: 32_000, maxOutputTokens: 4_096, }) + if (modelName.includes('llama3')) return toFallback({ ...openSourceModelDefaultOptionsAssumingOAICompat.llama3, contextWindow: 32_000, maxOutputTokens: 4_096, }) + if (modelName.includes('qwen') && modelName.includes('2.5') && modelName.includes('coder')) return toFallback({ ...openSourceModelDefaultOptionsAssumingOAICompat['qwen2.5coder'], contextWindow: 32_000, maxOutputTokens: 4_096, }) + if (modelName.includes('codestral')) return toFallback({ ...openSourceModelDefaultOptionsAssumingOAICompat.codestral, contextWindow: 32_000, maxOutputTokens: 4_096, }) + if (/\bo1\b/.test(modelName) || /\bo3\b/.test(modelName)) return toFallback(openAIModelOptions['o1']) + return toFallback(modelOptionDefaults) +} + + +const vLLMSettings: ProviderSettings = { + // reasoning: OAICompat + response.choices[0].delta.reasoning_content // https://docs.vllm.ai/en/stable/features/reasoning_outputs.html#streaming-chat-completions + ifSupportsReasoningOutput: { output: { nameOfFieldInDelta: 'reasoning_content' }, }, + modelOptionsFallback: (modelName) => extensiveModelFallback(modelName), + modelOptions: {}, +} + +const ollamaSettings: ProviderSettings = { + // reasoning: we need to filter out reasoning tags manually + ifSupportsReasoningOutput: { output: { needsManualParse: true }, }, + modelOptionsFallback: (modelName) => extensiveModelFallback(modelName), + modelOptions: {}, +} + +const openaiCompatible: ProviderSettings = { + // reasoning: we have no idea what endpoint they used, so we can't consistently parse out reasoning + modelOptionsFallback: (modelName) => extensiveModelFallback(modelName), + modelOptions: {}, +} + + +// ---------------- OPENROUTER ---------------- +const openRouterModelOptions = { + 'deepseek/deepseek-r1': { + ...openSourceModelDefaultOptionsAssumingOAICompat.deepseekR1, + contextWindow: 128_000, + maxOutputTokens: null, + cost: { input: 0.8, output: 2.4 }, + }, + 'anthropic/claude-3.5-sonnet': { + contextWindow: 200_000, + maxOutputTokens: null, + cost: { input: 3.00, output: 15.00 }, + supportsFIM: false, + supportsSystemMessage: 'system-role', + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'mistralai/codestral-2501': { + ...openSourceModelDefaultOptionsAssumingOAICompat.codestral, + contextWindow: 256_000, + maxOutputTokens: null, + cost: { input: 0.3, output: 0.9 }, + supportsTools: 'openai-style', + supportsReasoningOutput: false, + }, + 'qwen/qwen-2.5-coder-32b-instruct': { + ...openSourceModelDefaultOptionsAssumingOAICompat['qwen2.5coder'], + contextWindow: 33_000, + maxOutputTokens: null, + supportsTools: false, // openrouter qwen doesn't seem to support tools...? + cost: { input: 0.07, output: 0.16 }, + } + + +} as const satisfies { [s: string]: ModelOptions } + +const openRouterSettings: ProviderSettings = { + // reasoning: OAICompat + response.choices[0].delta.reasoning : payload should have {include_reasoning: true} https://openrouter.ai/announcements/reasoning-tokens-for-thinking-models + ifSupportsReasoningOutput: { + input: { includeInPayload: { include_reasoning: true } }, + output: { nameOfFieldInDelta: 'reasoning' }, + }, + modelOptions: openRouterModelOptions, + // TODO!!! send a query to openrouter to get the price, isFIM, etc. + modelOptionsFallback: (modelName) => extensiveModelFallback(modelName), +} + +// ---------------- model settings of everything above ---------------- + +const modelSettingsOfProvider: ModelSettingsOfProvider = { + openAI: openAISettings, + anthropic: anthropicSettings, + xAI: xAISettings, + gemini: geminiSettings, + + // open source models + deepseek: deepseekSettings, + groq: groqSettings, + + // open source models + providers (mixture of everything) + openRouter: openRouterSettings, + vLLM: vLLMSettings, + ollama: ollamaSettings, + openAICompatible: openaiCompatible, + + // googleVertex: {}, + // microsoftAzure: {}, +} as const satisfies ModelSettingsOfProvider + + + + +export const getModelCapabilities = (providerName: ProviderName, modelName: string): ModelOptions & { modelName: string } => { + const { modelOptions, modelOptionsFallback } = modelSettingsOfProvider[providerName] + if (modelName in modelOptions) return { modelName, ...modelOptions[modelName] } + const result = modelOptionsFallback(modelName) + if (!result) return { modelName, ...modelOptionDefaults } + return result +} + + + +type InternalCommonMessageParams = { + aiInstructions: string; + onText: OnText; + onFinalMessage: OnFinalMessage; + onError: OnError; + providerName: ProviderName; + settingsOfProvider: SettingsOfProvider; + modelName: string; + _setAborter: (aborter: () => void) => void; +} + +type SendChatParams_Internal = InternalCommonMessageParams & { messages: LLMChatMessage[]; tools?: InternalToolInfo[] } +type SendFIMParams_Internal = InternalCommonMessageParams & { messages: LLMFIMMessage; } +export type ListParams_Internal = ModelListParams + + +// ------------ OPENAI-COMPATIBLE (HELPERS) ------------ +const toOpenAICompatibleTool = (toolInfo: InternalToolInfo) => { + const { name, description, params, required } = toolInfo + return { + type: 'function', + function: { + name: name, + description: description, + parameters: { + type: 'object', + properties: params, + required: required, + } + } + } satisfies OpenAI.Chat.Completions.ChatCompletionTool +} + +type ToolCallOfIndex = { [index: string]: { name: string, params: string, id: string } } + +const toolCallsFrom_OpenAICompat = (toolCallOfIndex: ToolCallOfIndex) => { + return Object.keys(toolCallOfIndex).map(index => { + const tool = toolCallOfIndex[index] + return isAToolName(tool.name) ? { name: tool.name, id: tool.id, params: tool.params } : null + }).filter(t => !!t) +} + + +const newOpenAICompatibleSDK = ({ settingsOfProvider, providerName, includeInPayload }: { settingsOfProvider: SettingsOfProvider, providerName: ProviderName, includeInPayload?: { [s: string]: any } }) => { + const commonPayloadOpts: ClientOptions = { + dangerouslyAllowBrowser: true, + ...includeInPayload, + } + if (providerName === 'openAI') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ apiKey: thisConfig.apiKey, ...commonPayloadOpts }) + } + else if (providerName === 'ollama') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ baseURL: `${thisConfig.endpoint}/v1`, apiKey: 'noop', ...commonPayloadOpts }) + } + else if (providerName === 'vLLM') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ baseURL: `${thisConfig.endpoint}/v1`, apiKey: 'noop', ...commonPayloadOpts }) + } + else if (providerName === 'openRouter') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ + baseURL: 'https://openrouter.ai/api/v1', + apiKey: thisConfig.apiKey, + defaultHeaders: { + 'HTTP-Referer': 'https://voideditor.com', // Optional, for including your app on openrouter.ai rankings. + 'X-Title': 'Void', // Optional. Shows in rankings on openrouter.ai. + }, + ...commonPayloadOpts, + }) + } + else if (providerName === 'gemini') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai', apiKey: thisConfig.apiKey, ...commonPayloadOpts }) + } + else if (providerName === 'deepseek') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ baseURL: 'https://api.deepseek.com/v1', apiKey: thisConfig.apiKey, ...commonPayloadOpts }) + } + else if (providerName === 'openAICompatible') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ baseURL: thisConfig.endpoint, apiKey: thisConfig.apiKey, ...commonPayloadOpts }) + } + else if (providerName === 'groq') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ baseURL: 'https://api.groq.com/openai/v1', apiKey: thisConfig.apiKey, ...commonPayloadOpts }) + } + else if (providerName === 'xAI') { + const thisConfig = settingsOfProvider[providerName] + return new OpenAI({ baseURL: 'https://api.x.ai/v1', apiKey: thisConfig.apiKey, ...commonPayloadOpts }) + } + + else throw new Error(`Void providerName was invalid: ${providerName}.`) +} + + + +const _sendOpenAICompatibleFIM = ({ messages: messages_, onFinalMessage, onError, settingsOfProvider, modelName: modelName_, _setAborter, providerName, aiInstructions, }: SendFIMParams_Internal) => { + const { modelName, supportsFIM } = getModelCapabilities(providerName, modelName_) + if (!supportsFIM) { + if (modelName === modelName_) + onFinalMessage({ fullText: `Model ${modelName} does not support FIM.` }) + else + onFinalMessage({ fullText: `Model ${modelName_} (${modelName}) does not support FIM.` }) + return + } + + const messages = prepareFIMMessage({ messages: messages_, aiInstructions, }) + + const openai = newOpenAICompatibleSDK({ providerName, settingsOfProvider }) + openai.completions + .create({ + model: modelName, + prompt: messages.prefix, + suffix: messages.suffix, + stop: messages.stopTokens, + max_tokens: messages.maxTokens, + }) + .then(async response => { + const fullText = response.choices[0]?.text + onFinalMessage({ fullText, }); + }) + .catch(error => { + if (error instanceof OpenAI.APIError && error.status === 401) { onError({ message: invalidApiKeyMessage(providerName), fullError: error }); } + else { onError({ message: error + '', fullError: error }); } + }) +} + + + + +const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName: modelName_, _setAborter, providerName, aiInstructions, tools: tools_ }: SendChatParams_Internal) => { + const { + modelName, + supportsReasoningOutput, + supportsSystemMessage, + supportsTools, + maxOutputTokens, + } = getModelCapabilities(providerName, modelName_) + + const { messages } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, }) + const tools = (supportsTools && ((tools_?.length ?? 0) !== 0)) ? tools_?.map(tool => toOpenAICompatibleTool(tool)) : undefined + + const includeInPayload = supportsReasoningOutput ? modelSettingsOfProvider[providerName].ifSupportsReasoningOutput?.input?.includeInPayload || {} : {} + + const toolsObj = tools ? { tools: tools, tool_choice: 'auto', parallel_tool_calls: false, } as const : {} + const maxTokensObj = maxOutputTokens ? { max_tokens: maxOutputTokens } : {} + const openai: OpenAI = newOpenAICompatibleSDK({ providerName, settingsOfProvider, includeInPayload }) + const options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: modelName, messages: messages, stream: true, ...toolsObj, ...maxTokensObj } + + const { nameOfFieldInDelta: nameOfReasoningFieldInDelta, needsManualParse: needsManualReasoningParse } = modelSettingsOfProvider[providerName].ifSupportsReasoningOutput?.output ?? {} + if (needsManualReasoningParse && supportsReasoningOutput && supportsReasoningOutput.openSourceThinkTags) + onText = extractReasoningFromText(onText, supportsReasoningOutput.openSourceThinkTags) + + let fullReasoning = '' + let fullText = '' + const toolCallOfIndex: ToolCallOfIndex = {} + openai.chat.completions + .create(options) + .then(async response => { + _setAborter(() => response.controller.abort()) + // when receive text + for await (const chunk of response) { + // tool call + for (const tool of chunk.choices[0]?.delta?.tool_calls ?? []) { + const index = tool.index + if (!toolCallOfIndex[index]) toolCallOfIndex[index] = { name: '', params: '', id: '' } + toolCallOfIndex[index].name += tool.function?.name ?? '' + toolCallOfIndex[index].params += tool.function?.arguments ?? ''; + toolCallOfIndex[index].id = tool.id ?? '' + } + // message + const newText = chunk.choices[0]?.delta?.content ?? '' + fullText += newText + + // reasoning + let newReasoning = '' + if (nameOfReasoningFieldInDelta) { + // @ts-ignore + newReasoning = (chunk.choices[0]?.delta?.[nameOfReasoningFieldInDelta] || '') + '' + fullReasoning += newReasoning + } + + onText({ newText, fullText, newReasoning, fullReasoning }) + } + onFinalMessage({ fullText, toolCalls: toolCallsFrom_OpenAICompat(toolCallOfIndex) }); + }) + // when error/fail - this catches errors of both .create() and .then(for await) + .catch(error => { + if (error instanceof OpenAI.APIError && error.status === 401) { onError({ message: invalidApiKeyMessage(providerName), fullError: error }); } + else { onError({ message: error + '', fullError: error }); } + }) +} + + +const _openaiCompatibleList = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider, providerName }: ListParams_Internal) => { + const onSuccess = ({ models }: { models: OpenAIModel[] }) => { + onSuccess_({ models }) + } + const onError = ({ error }: { error: string }) => { + onError_({ error }) + } + try { + const openai = newOpenAICompatibleSDK({ providerName, settingsOfProvider }) + openai.models.list() + .then(async (response) => { + const models: OpenAIModel[] = [] + models.push(...response.data) + while (response.hasNextPage()) { + models.push(...(await response.getNextPage()).data) + } + onSuccess({ models }) + }) + .catch((error) => { + onError({ error: error + '' }) + }) + } + catch (error) { + onError({ error: error + '' }) + } +} + + + + +// ------------ ANTHROPIC ------------ +const toAnthropicTool = (toolInfo: InternalToolInfo) => { + const { name, description, params, required } = toolInfo + return { + name: name, + description: description, + input_schema: { + type: 'object', + properties: params, + required: required, + } + } satisfies Anthropic.Messages.Tool +} + +const toolCallsFromAnthropicContent = (content: Anthropic.Messages.ContentBlock[]) => { + return content.map(c => { + if (c.type !== 'tool_use') return null + if (!isAToolName(c.name)) return null + return c.type === 'tool_use' ? { name: c.name, params: JSON.stringify(c.input), id: c.id } : null + }).filter(t => !!t) +} + +const sendAnthropicChat = ({ messages: messages_, onText, providerName, onFinalMessage, onError, settingsOfProvider, modelName: modelName_, _setAborter, aiInstructions, tools: tools_ }: SendChatParams_Internal) => { + const { + // supportsReasoning: modelSupportsReasoning, + modelName, + supportsSystemMessage, + supportsTools, + maxOutputTokens, + } = getModelCapabilities(providerName, modelName_) + + const { messages, separateSystemMessageStr } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, }) + + const thisConfig = settingsOfProvider.anthropic + const anthropic = new Anthropic({ apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true }); + const tools = ((tools_?.length ?? 0) !== 0) ? tools_?.map(tool => toAnthropicTool(tool)) : undefined + + const stream = anthropic.messages.stream({ + system: separateSystemMessageStr, + messages: messages, + model: modelName, + max_tokens: maxOutputTokens ?? 4_096, // anthropic requires this + tools: tools, + tool_choice: tools ? { type: 'auto', disable_parallel_tool_use: true } : undefined // one tool use at a time + }) + // when receive text + stream.on('text', (newText, fullText) => { + onText({ newText, fullText, newReasoning: '', fullReasoning: '' }) + }) + // when we get the final message on this stream (or when error/fail) + stream.on('finalMessage', (response) => { + const content = response.content.map(c => c.type === 'text' ? c.text : '').join('\n\n') + const toolCalls = toolCallsFromAnthropicContent(response.content) + onFinalMessage({ fullText: content, toolCalls }) + }) + // on error + stream.on('error', (error) => { + if (error instanceof Anthropic.APIError && error.status === 401) { onError({ message: invalidApiKeyMessage(providerName), fullError: error }) } + else { onError({ message: error + '', fullError: error }) } + }) + _setAborter(() => stream.controller.abort()) +} + +// // in future, can do tool_use streaming in anthropic, but it's pretty fast even without streaming... +// const toolCallOfIndex: { [index: string]: { name: string, args: string } } = {} +// stream.on('streamEvent', e => { +// if (e.type === 'content_block_start') { +// if (e.content_block.type !== 'tool_use') return +// const index = e.index +// if (!toolCallOfIndex[index]) toolCallOfIndex[index] = { name: '', args: '' } +// toolCallOfIndex[index].name += e.content_block.name ?? '' +// toolCallOfIndex[index].args += e.content_block.input ?? '' +// } +// else if (e.type === 'content_block_delta') { +// if (e.delta.type !== 'input_json_delta') return +// toolCallOfIndex[e.index].args += e.delta.partial_json +// } +// }) + + +// ------------ OLLAMA ------------ +const newOllamaSDK = ({ endpoint }: { endpoint: string }) => { + // if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in + if (!endpoint) throw new Error(`Ollama Endpoint was empty (please enter ${defaultProviderSettings.ollama.endpoint} in Void if you want the default url).`) + const ollama = new Ollama({ host: endpoint }) + return ollama +} + +const ollamaList = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider }: ListParams_Internal) => { + const onSuccess = ({ models }: { models: OllamaModelResponse[] }) => { + onSuccess_({ models }) + } + const onError = ({ error }: { error: string }) => { + onError_({ error }) + } + try { + const thisConfig = settingsOfProvider.ollama + const ollama = newOllamaSDK({ endpoint: thisConfig.endpoint }) + ollama.list() + .then((response) => { + const { models } = response + onSuccess({ models }) + }) + .catch((error) => { + onError({ error: error + '' }) + }) + } + catch (error) { + onError({ error: error + '' }) + } +} + +const sendOllamaFIM = ({ messages: messages_, onFinalMessage, onError, settingsOfProvider, modelName, aiInstructions, _setAborter }: SendFIMParams_Internal) => { + const thisConfig = settingsOfProvider.ollama + const ollama = newOllamaSDK({ endpoint: thisConfig.endpoint }) + + const messages = prepareFIMMessage({ messages: messages_, aiInstructions, }) + + let fullText = '' + ollama.generate({ + model: modelName, + prompt: messages.prefix, + suffix: messages.suffix, + options: { + stop: messages.stopTokens, + num_predict: messages.maxTokens, // max tokens + // repeat_penalty: 1, + }, + raw: true, + stream: true, // stream is not necessary but lets us expose the + }) + .then(async stream => { + _setAborter(() => stream.abort()) + for await (const chunk of stream) { + const newText = chunk.response + fullText += newText + } + onFinalMessage({ fullText }) + }) + // when error/fail + .catch((error) => { + onError({ message: error + '', fullError: error }) + }) +} + + + +type CallFnOfProvider = { + [providerName in ProviderName]: { + sendChat: (params: SendChatParams_Internal) => void; + sendFIM: ((params: SendFIMParams_Internal) => void) | null; + list: ((params: ListParams_Internal) => void) | null; + } +} + +export const sendLLMMessageToProviderImplementation = { + anthropic: { + sendChat: sendAnthropicChat, + sendFIM: null, + list: null, + }, + openAI: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: null, + list: null, + }, + xAI: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: null, + list: null, + }, + gemini: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: null, + list: null, + }, + ollama: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: sendOllamaFIM, + list: ollamaList, + }, + openAICompatible: { + sendChat: (params) => _sendOpenAICompatibleChat(params), // using openai's SDK is not ideal (your implementation might not do tools, reasoning, FIM etc correctly), talk to us for a custom integration + sendFIM: (params) => _sendOpenAICompatibleFIM(params), + list: null, + }, + openRouter: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: (params) => _sendOpenAICompatibleFIM(params), + list: null, + }, + vLLM: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: (params) => _sendOpenAICompatibleFIM(params), + list: (params) => _openaiCompatibleList(params), + }, + deepseek: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: null, + list: null, + }, + groq: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: null, + list: null, + }, +} satisfies CallFnOfProvider + + + + +/* +FIM info (this may be useful in the future with vLLM, but in most cases the only way to use FIM is if the provider explicitly supports it): + +qwen2.5-coder https://ollama.com/library/qwen2.5-coder/blobs/e94a8ecb9327 +<|fim_prefix|>{{ .Prompt }}<|fim_suffix|>{{ .Suffix }}<|fim_middle|> + +codestral https://ollama.com/library/codestral/blobs/51707752a87c +[SUFFIX]{{ .Suffix }}[PREFIX] {{ .Prompt }} + +deepseek-coder-v2 https://ollama.com/library/deepseek-coder-v2/blobs/22091531faf0 +<|fim▁begin|>{{ .Prompt }}<|fim▁hole|>{{ .Suffix }}<|fim▁end|> + +starcoder2 https://ollama.com/library/starcoder2/blobs/3b190e68fefe + + +{{ .Prompt }}{{ .Suffix }} +<|end_of_text|> + +codegemma https://ollama.com/library/codegemma:2b/blobs/48d9a8140749 +<|fim_prefix|>{{ .Prompt }}<|fim_suffix|>{{ .Suffix }}<|fim_middle|> + +*/ diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/anthropic.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/anthropic.ts deleted file mode 100644 index 91461b16..00000000 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/anthropic.ts +++ /dev/null @@ -1,76 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import Anthropic from '@anthropic-ai/sdk'; -import { _InternalSendLLMChatMessageFnType } from '../../common/llmMessageTypes.js'; -import { anthropicMaxPossibleTokens } from '../../common/voidSettingsTypes.js'; -import { InternalToolInfo } from '../../common/toolsService.js'; - - - - -export const toAnthropicTool = (toolName: string, toolInfo: InternalToolInfo) => { - const { description, params, required } = toolInfo - return { - name: toolName, - description: description, - input_schema: { - type: 'object', - properties: params, - required: required, - } - } satisfies Anthropic.Messages.Tool -} - - - - - -export const sendAnthropicChat: _InternalSendLLMChatMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => { - - const thisConfig = settingsOfProvider.anthropic - - const maxTokens = anthropicMaxPossibleTokens(modelName) - if (maxTokens === undefined) { - onError({ message: `Please set a value for Max Tokens.`, fullError: null }) - return - } - - const anthropic = new Anthropic({ apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true }); - - const stream = anthropic.messages.stream({ - // system: systemMessage, - messages: messages, - model: modelName, - max_tokens: maxTokens, - }); - - - // when receive text - stream.on('text', (newText, fullText) => { - onText({ newText, fullText }) - }) - - // when we get the final message on this stream (or when error/fail) - stream.on('finalMessage', (claude_response) => { - // stringify the response's content - const content = claude_response.content.map(c => c.type === 'text' ? c.text : c.type).join('\n'); - onFinalMessage({ fullText: content }) - }) - - stream.on('error', (error) => { - // the most common error will be invalid API key (401), so we handle this with a nice message - if (error instanceof Anthropic.APIError && error.status === 401) { - onError({ message: 'Invalid API key.', fullError: error }) - } - else { - onError({ message: error + '', fullError: error }) // anthropic errors can be stringified nicely like this - } - }) - - // TODO need to test this to make sure it works, it might throw an error - _setAborter(() => stream.controller.abort()) - -}; diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/gemini.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/gemini.ts deleted file mode 100644 index eef8cc3a..00000000 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/gemini.ts +++ /dev/null @@ -1,43 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import { Content, GoogleGenerativeAI } from '@google/generative-ai'; -import { _InternalSendLLMChatMessageFnType } from '../../common/llmMessageTypes.js'; - -// Gemini -export const sendGeminiChat: _InternalSendLLMChatMessageFnType = async ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => { - - let fullText = '' - - const thisConfig = settingsOfProvider.gemini - - const genAI = new GoogleGenerativeAI(thisConfig.apiKey); - const model = genAI.getGenerativeModel({ model: modelName }); - - // Convert messages to Gemini format - const geminiMessages: Content[] = messages - .map((msg, i) => ({ - parts: [{ text: msg.content }], - role: msg.role === 'assistant' ? 'model' : 'user' - })) - - model.generateContentStream({ - // systemInstruction: systemMessage, - contents: geminiMessages, - }) - .then(async response => { - _setAborter(() => response.stream.return(fullText)) - - for await (const chunk of response.stream) { - const newText = chunk.text(); - fullText += newText; - onText({ newText, fullText }); - } - onFinalMessage({ fullText }); - }) - .catch((error) => { - onError({ message: error + '', fullError: error }) - }) -} diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/groq.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/groq.ts deleted file mode 100644 index 8f7efd14..00000000 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/groq.ts +++ /dev/null @@ -1,42 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import Groq from 'groq-sdk'; -import { _InternalSendLLMChatMessageFnType } from '../../common/llmMessageTypes.js'; - -// Groq -export const sendGroqChat: _InternalSendLLMChatMessageFnType = async ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => { - let fullText = ''; - - const thisConfig = settingsOfProvider.groq - - const groq = new Groq({ - apiKey: thisConfig.apiKey, - dangerouslyAllowBrowser: true - }); - - await groq.chat.completions - .create({ - messages: messages, - model: modelName, - stream: true, - }) - .then(async response => { - _setAborter(() => response.controller.abort()) - // when receive text - for await (const chunk of response) { - const newText = chunk.choices[0]?.delta?.content || ''; - fullText += newText; - onText({ newText, fullText }); - } - - onFinalMessage({ fullText }); - }) - .catch(error => { - onError({ message: error + '', fullError: error }); - }) - - -}; diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/mistral.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/mistral.ts deleted file mode 100644 index cfddc2a5..00000000 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/mistral.ts +++ /dev/null @@ -1,44 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import { Mistral } from '@mistralai/mistralai'; -import { _InternalSendLLMChatMessageFnType } from '../../common/llmMessageTypes.js'; - -// Mistral -export const sendMistralChat: _InternalSendLLMChatMessageFnType = async ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => { - let fullText = ''; - - const thisConfig = settingsOfProvider.mistral; - - const mistral = new Mistral({ - apiKey: thisConfig.apiKey, - }) - - await mistral.chat - .stream({ - messages: messages, - model: modelName, - stream: true, - }) - .then(async response => { - // Mistral has a really nonstandard API - no interrupt and weird stream types - _setAborter(() => { console.log('Mistral does not support interrupts! Further messages will just be ignored.') }); - // when receive text - for await (const chunk of response) { - const c = chunk.data.choices[0].delta.content || '' - const newText = ( - typeof c === 'string' ? c - : c?.map(c => c.type === 'text' ? c.text : c.type).join('\n') - ) - fullText += newText; - onText({ newText, fullText }); - } - - onFinalMessage({ fullText }); - }) - .catch(error => { - onError({ message: error + '', fullError: error }); - }) -} diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/ollama.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/ollama.ts deleted file mode 100644 index 43c817a3..00000000 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/ollama.ts +++ /dev/null @@ -1,117 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import { Ollama } from 'ollama'; -import { _InternalModelListFnType, _InternalSendLLMFIMMessageFnType, _InternalSendLLMChatMessageFnType, OllamaModelResponse } from '../../common/llmMessageTypes.js'; -import { defaultProviderSettings } from '../../common/voidSettingsTypes.js'; - -export const ollamaList: _InternalModelListFnType = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider }) => { - - const onSuccess = ({ models }: { models: OllamaModelResponse[] }) => { - onSuccess_({ models }) - } - - const onError = ({ error }: { error: string }) => { - onError_({ error }) - } - - try { - const thisConfig = settingsOfProvider.ollama - // if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in - if (!thisConfig.endpoint) throw new Error(`Ollama Endpoint was empty (please enter ${defaultProviderSettings.ollama.endpoint} in Void if you want the default url).`) - - const ollama = new Ollama({ host: thisConfig.endpoint }) - ollama.list() - .then((response) => { - const { models } = response - onSuccess({ models }) - }) - .catch((error) => { - onError({ error: error + '' }) - }) - } - catch (error) { - onError({ error: error + '' }) - } -} - - -export const sendOllamaFIM: _InternalSendLLMFIMMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => { - - const thisConfig = settingsOfProvider.ollama - // if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in - if (!thisConfig.endpoint) throw new Error(`Ollama Endpoint was empty (please enter ${defaultProviderSettings.ollama.endpoint} if you want the default).`) - - let fullText = '' - - const ollama = new Ollama({ host: thisConfig.endpoint }) - - ollama.generate({ - model: modelName, - prompt: messages.prefix, - suffix: messages.suffix, - options: { - stop: messages.stopTokens, - num_predict: 300, // max tokens - // repeat_penalty: 1, - }, - raw: true, - stream: true, - }) - .then(async stream => { - _setAborter(() => stream.abort()) - // iterate through the stream - for await (const chunk of stream) { - const newText = chunk.response; - fullText += newText; - onText({ newText, fullText }); - } - onFinalMessage({ fullText }); - }) - // when error/fail - .catch((error) => { - onError({ message: error + '', fullError: error }) - }) -}; - - -// Ollama -export const sendOllamaChat: _InternalSendLLMChatMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter }) => { - - const thisConfig = settingsOfProvider.ollama - // if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in - if (!thisConfig.endpoint) throw new Error(`Ollama Endpoint was empty (please enter ${defaultProviderSettings.ollama.endpoint} if you want the default).`) - - let fullText = '' - - const ollama = new Ollama({ host: thisConfig.endpoint }) - - ollama.chat({ - model: modelName, - messages: messages, - stream: true, - // options: { num_predict: parseMaxTokensStr(thisConfig.maxTokens) } // this is max_tokens - }) - .then(async stream => { - _setAborter(() => stream.abort()) - // iterate through the stream - for await (const chunk of stream) { - const newText = chunk.message.content; - fullText += newText; - onText({ newText, fullText }); - } - onFinalMessage({ fullText }); - - }) - // when error/fail - .catch((error) => { - onError({ message: error + '', fullError: error }) - }) - -}; - - - -// ['codestral', 'qwen2.5-coder', 'qwen2.5-coder:0.5b', 'qwen2.5-coder:1.5b', 'qwen2.5-coder:3b', 'qwen2.5-coder:7b', 'qwen2.5-coder:14b', 'qwen2.5-coder:32b', 'codegemma', 'codegemma:2b', 'codegemma:7b', 'codellama', 'codellama:7b', 'codellama:13b', 'codellama:34b', 'codellama:70b', 'codellama:code', 'codellama:python', 'command-r', 'command-r:35b', 'command-r-plus', 'command-r-plus:104b', 'deepseek-coder-v2', 'deepseek-coder-v2:16b', 'deepseek-coder-v2:236b', 'falcon2', 'falcon2:11b', 'firefunction-v2', 'firefunction-v2:70b', 'gemma', 'gemma:2b', 'gemma:7b', 'gemma2', 'gemma2:2b', 'gemma2:9b', 'gemma2:27b', 'llama2', 'llama2:7b', 'llama2:13b', 'llama2:70b', 'llama3', 'llama3:8b', 'llama3:70b', 'llama3-chatqa', 'llama3-chatqa:8b', 'llama3-chatqa:70b', 'llama3-gradient', 'llama3-gradient:8b', 'llama3-gradient:70b', 'llama3.1', 'llama3.1:8b', 'llama3.1:70b', 'llama3.1:405b', 'llava', 'llava:7b', 'llava:13b', 'llava:34b', 'llava-llama3', 'llava-llama3:8b', 'llava-phi3', 'llava-phi3:3.8b', 'mistral', 'mistral:7b', 'mistral-large', 'mistral-large:123b', 'mistral-nemo', 'mistral-nemo:12b', 'mixtral', 'mixtral:8x7b', 'mixtral:8x22b', 'moondream', 'moondream:1.8b', 'openhermes', 'openhermes:v2.5', 'phi3', 'phi3:3.8b', 'phi3:14b', 'phi3.5', 'phi3.5:3.8b', 'qwen', 'qwen:7b', 'qwen:14b', 'qwen:32b', 'qwen:72b', 'qwen:110b', 'qwen2', 'qwen2:0.5b', 'qwen2:1.5b', 'qwen2:7b', 'qwen2:72b', 'smollm', 'smollm:135m', 'smollm:360m', 'smollm:1.7b',] diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/openai.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/openai.ts deleted file mode 100644 index df4d2322..00000000 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/openai.ts +++ /dev/null @@ -1,159 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import OpenAI from 'openai'; -import { _InternalModelListFnType, _InternalSendLLMFIMMessageFnType, _InternalSendLLMChatMessageFnType } from '../../common/llmMessageTypes.js'; -import { Model } from 'openai/resources/models.js'; -import { InternalToolInfo } from '../../common/toolsService.js'; -// import { parseMaxTokensStr } from './util.js'; - - -// developer command - https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command -// prompting - https://platform.openai.com/docs/guides/reasoning#advice-on-prompting - - -export const toOpenAITool = (toolName: string, toolInfo: InternalToolInfo) => { - const { description, params, required } = toolInfo - return { - type: 'function', - function: { - name: toolName, - description: description, - parameters: { - type: 'object', - properties: params, - required: required, - } - } - } satisfies OpenAI.Chat.Completions.ChatCompletionTool -} - - - - - -type NewParams = Pick[0] & Parameters<_InternalSendLLMFIMMessageFnType>[0], 'settingsOfProvider' | 'providerName'> -const newOpenAI = ({ settingsOfProvider, providerName }: NewParams) => { - - if (providerName === 'openAI') { - const thisConfig = settingsOfProvider.openAI - return new OpenAI({ apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true }); - } - else if (providerName === 'openRouter') { - const thisConfig = settingsOfProvider.openRouter - return new OpenAI({ - baseURL: 'https://openrouter.ai/api/v1', apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true, - defaultHeaders: { - 'HTTP-Referer': 'https://voideditor.com', // Optional, for including your app on openrouter.ai rankings. - 'X-Title': 'Void Editor', // Optional. Shows in rankings on openrouter.ai. - }, - }) - } - else if (providerName === 'deepseek') { - const thisConfig = settingsOfProvider.deepseek - return new OpenAI({ - baseURL: 'https://api.deepseek.com/v1', apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true, - }) - - } - else if (providerName === 'openAICompatible') { - const thisConfig = settingsOfProvider.openAICompatible - return new OpenAI({ - baseURL: thisConfig.endpoint, apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true - }) - } - else { - console.error(`sendOpenAIMsg: invalid providerName: ${providerName}`) - throw new Error(`providerName was invalid: ${providerName}`) - } -} - - - -// might not currently be used in the code -export const openaiCompatibleList: _InternalModelListFnType = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider }) => { - const onSuccess = ({ models }: { models: Model[] }) => { - onSuccess_({ models }) - } - - const onError = ({ error }: { error: string }) => { - onError_({ error }) - } - - try { - const openai = newOpenAI({ providerName: 'openAICompatible', settingsOfProvider }) - - openai.models.list() - .then(async (response) => { - const models: Model[] = [] - models.push(...response.data) - while (response.hasNextPage()) { - models.push(...(await response.getNextPage()).data) - } - onSuccess({ models }) - }) - .catch((error) => { - onError({ error: error + '' }) - }) - } - catch (error) { - onError({ error: error + '' }) - } -} - - - - -export const sendOpenAIFIM: _InternalSendLLMFIMMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }) => { - - - // openai.completions has a FIM parameter called `suffix`, but it's deprecated and only works for ~GPT 3 era models - - onFinalMessage({ fullText: 'TODO' }) - -} - - - -// OpenAI, OpenRouter, OpenAICompatible -export const sendOpenAIChat: _InternalSendLLMChatMessageFnType = ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }) => { - - let fullText = '' - - const openai: OpenAI = newOpenAI({ providerName, settingsOfProvider }) - const options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model: modelName, - messages: messages, - stream: true, - // tools: Object.keys(contextTools).map(name => toOpenAITool(name, contextTools[name as ContextToolName])), - } - - openai.chat.completions - .create(options) - .then(async response => { - _setAborter(() => response.controller.abort()) - // when receive text - for await (const chunk of response) { - - let newText = '' - newText += chunk.choices[0]?.delta?.tool_calls?.[0]?.function?.name ?? '' - newText += chunk.choices[0]?.delta?.tool_calls?.[0]?.function?.arguments ?? '' - newText += chunk.choices[0]?.delta?.content ?? '' - fullText += newText; - onText({ newText, fullText }); - } - onFinalMessage({ fullText }); - }) - // when error/fail - this catches errors of both .create() and .then(for await) - .catch(error => { - if (error instanceof OpenAI.APIError && error.status === 401) { - onError({ message: 'Invalid API key.', fullError: error }); - } - else { - onError({ message: error + '', fullError: error }); - } - }) - -}; diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts new file mode 100644 index 00000000..32b91d07 --- /dev/null +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts @@ -0,0 +1,350 @@ + + +import { LLMChatMessage, LLMFIMMessage } from '../../common/llmMessageTypes.js'; +import { deepClone } from '../../../../../base/common/objects.js'; + + +export const parseObject = (args: unknown) => { + if (typeof args === 'object') + return args + if (typeof args === 'string') + try { return JSON.parse(args) } + catch (e) { return { args } } + return {} +} + + +const prepareMessages_cloneAndTrim = ({ messages: messages_ }: { messages: LLMChatMessage[] }) => { + const messages = deepClone(messages_).map(m => ({ ...m, content: m.content.trim(), })) + return { messages } +} + +// no matter whether the model supports a system message or not (or what format it supports), add it in some way +const prepareMessages_systemMessage = ({ + messages, + aiInstructions, + supportsSystemMessage, +}: { + messages: LLMChatMessage[], + aiInstructions: string, + supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated', +}) + : { separateSystemMessageStr?: string, messages: any[] } => { + + // find system messages and concatenate them + let systemMessageStr = messages + .filter(msg => msg.role === 'system') + .map(msg => msg.content) + .join('\n') || undefined; + + if (aiInstructions) + systemMessageStr = `${(systemMessageStr ? `${systemMessageStr}\n\n` : '')}GUIDELINES\n${aiInstructions}` + + let separateSystemMessageStr: string | undefined = undefined + + // remove all system messages + const newMessages: (LLMChatMessage | { role: 'developer', content: string })[] = messages.filter(msg => msg.role !== 'system') + + + // if (!supportsTools) { + // if (!systemMessageStr) systemMessageStr = '' + // systemMessageStr += '' // TODO!!! add tool use system message here + // } + + + if (systemMessageStr) { + // if supports system message + if (supportsSystemMessage) { + if (supportsSystemMessage === 'separated') + separateSystemMessageStr = systemMessageStr + else if (supportsSystemMessage === 'system-role') + newMessages.unshift({ role: 'system', content: systemMessageStr }) // add new first message + else if (supportsSystemMessage === 'developer-role') + newMessages.unshift({ role: 'developer', content: systemMessageStr }) // add new first message + } + // if does not support system message + else { + if (supportsSystemMessage) { + if (newMessages.length === 0) + newMessages.push({ role: 'user', content: systemMessageStr }) + // add system mesasges to first message (should be a user message) + else { + const newFirstMessage = { + role: 'user', + content: ('' + + '\n' + + systemMessageStr + + '\n' + + '\n' + + newMessages[0].content + ) + } as const + newMessages.splice(0, 1) // delete first message + newMessages.unshift(newFirstMessage) // add new first message + } + } + } + } + + return { messages: newMessages, separateSystemMessageStr } +} + + + + + +// convert messages as if about to send to openai +/* +reference - https://platform.openai.com/docs/guides/function-calling#function-calling-steps +openai MESSAGE (role=assistant): +"tool_calls":[{ + "type": "function", + "id": "call_12345xyz", + "function": { + "name": "get_weather", + "arguments": "{\"latitude\":48.8566,\"longitude\":2.3522}" +}] + +openai RESPONSE (role=user): +{ "role": "tool", + "tool_call_id": tool_call.id, + "content": str(result) } + +also see +openai on prompting - https://platform.openai.com/docs/guides/reasoning#advice-on-prompting +openai on developer system message - https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command +*/ + +const prepareMessages_tools_openai = ({ messages }: { messages: LLMChatMessage[], }) => { + + const newMessages: ( + Exclude | { + role: 'assistant', + content: string; + tool_calls?: { + type: 'function'; + id: string; + function: { + name: string; + arguments: string; + } + }[] + } | { + role: 'tool', + id: string; // old val + tool_call_id: string; // new val + content: string; + } + )[] = []; + + for (let i = 0; i < messages.length; i += 1) { + const currMsg = messages[i] + + if (currMsg.role !== 'tool') { + newMessages.push(currMsg) + continue + } + + // edit previous assistant message to have called the tool + const prevMsg = 0 <= i - 1 && i - 1 <= newMessages.length ? newMessages[i - 1] : undefined + if (prevMsg?.role === 'assistant') { + prevMsg.tool_calls = [{ + type: 'function', + id: currMsg.id, + function: { + name: currMsg.name, + arguments: JSON.stringify(currMsg.params) + } + }] + } + + // add the tool + newMessages.push({ + role: 'tool', + id: currMsg.id, + content: currMsg.content, + tool_call_id: currMsg.id, + }) + } + return { messages: newMessages } + +} + + +// convert messages as if about to send to anthropic +/* +https://docs.anthropic.com/en/docs/build-with-claude/tool-use#tool-use-examples +anthropic MESSAGE (role=assistant): +"content": [{ + "type": "text", + "text": "I need to call the get_weather function, and the user wants SF, which is likely San Francisco, CA." +}, { + "type": "tool_use", + "id": "toolu_01A09q90qw90lq917835lq9", + "name": "get_weather", + "input": { "location": "San Francisco, CA", "unit": "celsius" } +}] +anthropic RESPONSE (role=user): +"content": [{ + "type": "tool_result", + "tool_use_id": "toolu_01A09q90qw90lq917835lq9", + "content": "15 degrees" +}] +*/ + +const prepareMessages_tools_anthropic = ({ messages }: { messages: LLMChatMessage[], }) => { + const newMessages: ( + Exclude | { + role: 'assistant', + content: string | ({ + type: 'text'; + text: string; + } | { + type: 'tool_use'; + name: string; + input: Record; + id: string; + })[] + } | { + role: 'user', + content: string | ({ + type: 'text'; + text: string; + } | { + type: 'tool_result'; + tool_use_id: string; + content: string; + })[] + } + )[] = messages; + + + for (let i = 0; i < newMessages.length; i += 1) { + const currMsg = newMessages[i] + + if (currMsg.role !== 'tool') continue + + const prevMsg = 0 <= i - 1 && i - 1 <= newMessages.length ? newMessages[i - 1] : undefined + + if (prevMsg?.role === 'assistant') { + if (typeof prevMsg.content === 'string') prevMsg.content = [{ type: 'text', text: prevMsg.content }] + prevMsg.content.push({ type: 'tool_use', id: currMsg.id, name: currMsg.name, input: parseObject(currMsg.params) }) + } + + // turn each tool into a user message with tool results at the end + newMessages[i] = { + role: 'user', + content: [ + ...[{ type: 'tool_result', tool_use_id: currMsg.id, content: currMsg.content }] as const, + ...currMsg.content ? [{ type: 'text', text: currMsg.content }] as const : [], + ] + } + } + return { messages: newMessages } +} + + + + + +const prepareMessages_tools = ({ messages, supportsTools }: { messages: LLMChatMessage[], supportsTools: false | 'anthropic-style' | 'openai-style' }) => { + if (!supportsTools) { + return { messages: messages } + } + else if (supportsTools === 'anthropic-style') { + return prepareMessages_tools_anthropic({ messages }) + } + else if (supportsTools === 'openai-style') { + return prepareMessages_tools_openai({ messages }) + } + else { + throw 1 + } +} + + + + + +/* +Gemini has this, but they're openai-compat so we don't need to implement this +gemini request: +{ "role": "assistant", + "content": null, + "function_call": { + "name": "get_weather", + "arguments": { + "latitude": 48.8566, + "longitude": 2.3522 + } + } +} + +gemini response: +{ "role": "assistant", + "function_response": { + "name": "get_weather", + "response": { + "temperature": "15°C", + "condition": "Cloudy" + } + } +} +*/ + + + + + + + + + + + +export const prepareMessages = ({ + messages, + aiInstructions, + supportsSystemMessage, + supportsTools, +}: { + messages: LLMChatMessage[], + aiInstructions: string, + supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated', + supportsTools: false | 'anthropic-style' | 'openai-style', +}) => { + const { messages: messages1 } = prepareMessages_cloneAndTrim({ messages }) + const { messages: messages2, separateSystemMessageStr } = prepareMessages_systemMessage({ messages: messages1, aiInstructions, supportsSystemMessage }) + const { messages: messages3 } = prepareMessages_tools({ messages: messages2, supportsTools }) + return { + messages: messages3 as any, + separateSystemMessageStr + } as const +} + + + + + +export const prepareFIMMessage = ({ + messages, + aiInstructions, +}: { + messages: LLMFIMMessage, + aiInstructions: string, +}) => { + + let prefix = `\ +${!aiInstructions ? '' : `\ +// Instructions: +// Do not output an explanation. Try to avoid outputting comments. Only output the middle code. +${aiInstructions.split('\n').map(line => `//${line}`).join('\n')}`} + +${messages.prefix}` + + const suffix = messages.suffix + const stopTokens = messages.stopTokens + const ret = { prefix, suffix, stopTokens, maxTokens: 300 } as const + console.log('ret', ret) + return ret +} diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts index dc70c36c..90deffe2 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts @@ -3,50 +3,10 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { SendLLMMessageParams, OnText, OnFinalMessage, OnError, LLMChatMessage, _InternalLLMChatMessage } from '../../common/llmMessageTypes.js'; +import { SendLLMMessageParams, OnText, OnFinalMessage, OnError } from '../../common/llmMessageTypes.js'; import { IMetricsService } from '../../common/metricsService.js'; - -import { sendAnthropicChat } from './anthropic.js'; -import { sendOllamaFIM, sendOllamaChat } from './ollama.js'; -import { sendOpenAIChat, sendOpenAIFIM } from './openai.js'; -import { sendGeminiChat } from './gemini.js'; -import { sendGroqChat } from './groq.js'; -import { sendMistralChat } from './mistral.js'; import { displayInfoOfProviderName } from '../../common/voidSettingsTypes.js'; - - -const cleanChatMessages = (messages: LLMChatMessage[]): _InternalLLMChatMessage[] => { - // trim message content (Anthropic and other providers give an error if there is trailing whitespace) - messages = messages.map(m => ({ ...m, content: m.content.trim() })) - - // find system messages and concatenate them - const systemMessage = messages - .filter(msg => msg.role === 'system') - .map(msg => msg.content) - .join('\n') || undefined; - - // remove all system messages - const noSystemMessages = messages - .filter(msg => msg.role !== 'system') as _InternalLLMChatMessage[] - - // add system mesasges to first message (should be a user message) - if (systemMessage && (noSystemMessages.length !== 0)) { - const newFirstMessage = { - role: noSystemMessages[0].role, - content: ('' - + '\n' - + systemMessage - + '\n' - + '\n' - + noSystemMessages[0].content - ) - } - noSystemMessages.splice(0, 1) // delete first message - noSystemMessages.unshift(newFirstMessage) // add new first message - } - - return noSystemMessages -} +import { sendLLMMessageToProviderImplementation } from './MODELS.js'; export const sendLLMMessage = ({ @@ -61,27 +21,23 @@ export const sendLLMMessage = ({ settingsOfProvider, providerName, modelName, + tools, }: SendLLMMessageParams, metricsService: IMetricsService ) => { - let messagesArr: _InternalLLMChatMessage[] = [] - if (messagesType === 'chatMessages') { - messagesArr = cleanChatMessages([ - { role: 'system', content: aiInstructions }, - ...messages_ - ]) - } // only captures number of messages and message "shape", no actual code, instructions, prompts, etc const captureLLMEvent = (eventId: string, extras?: object) => { metricsService.capture(eventId, { providerName, modelName, + customEndpointURL: settingsOfProvider[providerName]?.endpoint, + numModelsAtEndpoint: settingsOfProvider[providerName]?.models?.length, ...messagesType === 'chatMessages' ? { - numMessages: messagesArr?.length, - messagesShape: messagesArr?.map(msg => ({ role: msg.role, length: msg.content.length })), + numMessages: messages_?.length, + messagesShape: messages_?.map(msg => ({ role: msg.role, length: msg.content.length })), origNumMessages: messages_?.length, origMessagesShape: messages_?.map(msg => ({ role: msg.role, length: msg.content.length })), @@ -100,16 +56,17 @@ export const sendLLMMessage = ({ let _setAborter = (fn: () => void) => { _aborter = fn } let _didAbort = false - const onText: OnText = ({ newText, fullText }) => { + const onText: OnText = (params) => { + const { fullText } = params if (_didAbort) return - onText_({ newText, fullText }) + onText_(params) _fullTextSoFar = fullText } - const onFinalMessage: OnFinalMessage = ({ fullText }) => { + const onFinalMessage: OnFinalMessage = ({ fullText, toolCalls }) => { if (_didAbort) return captureLLMEvent(`${loggingName} - Received Full Message`, { messageLength: fullText.length, duration: new Date().getMilliseconds() - submit_time.getMilliseconds() }) - onFinalMessage_({ fullText }) + onFinalMessage_({ fullText, toolCalls }) } const onError: OnError = ({ message: error, fullError }) => { @@ -118,7 +75,7 @@ export const sendLLMMessage = ({ // handle failed to fetch errors, which give 0 information by design if (error === 'TypeError: fetch failed') - error = `Failed to fetch from ${displayInfoOfProviderName(providerName).title}. This likely means you specified the wrong endpoint in Void Settings, or your local model provider like Ollama is powered off.` + error = `Failed to fetch from ${displayInfoOfProviderName(providerName).title}. This likely means you specified the wrong endpoint in Void's Settings, or your local model provider like Ollama is powered off.` captureLLMEvent(`${loggingName} - Error`, { error }) onError_({ message: error, fullError }) @@ -132,41 +89,32 @@ export const sendLLMMessage = ({ } abortRef_.current = onAbort - captureLLMEvent(`${loggingName} - Sending Message`, { messageLength: messagesArr[messagesArr.length - 1]?.content.length }) + if (messagesType === 'chatMessages') + captureLLMEvent(`${loggingName} - Sending Message`, { messageLength: messages_[messages_.length - 1]?.content.length }) + else if (messagesType === 'FIMMessage') + captureLLMEvent(`${loggingName} - Sending FIM`, {}) // TODO!!! add more metrics + try { - switch (providerName) { - case 'openAI': - case 'openRouter': - case 'deepseek': - case 'openAICompatible': - if (messagesType === 'FIMMessage') sendOpenAIFIM({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); - else /* */ sendOpenAIChat({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); - break; - case 'ollama': - if (messagesType === 'FIMMessage') sendOllamaFIM({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }) - else /* */ sendOllamaChat({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }) - break; - case 'anthropic': - if (messagesType === 'FIMMessage') onFinalMessage({ fullText: 'TODO - Anthropic FIM' }) - else /* */ sendAnthropicChat({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); - break; - case 'gemini': - if (messagesType === 'FIMMessage') onFinalMessage({ fullText: 'TODO - Gemini FIM' }) - else /* */ sendGeminiChat({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); - break; - case 'groq': - if (messagesType === 'FIMMessage') onFinalMessage({ fullText: 'TODO - Groq FIM' }) - else /* */ sendGroqChat({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); - break; - case 'mistral': - if (messagesType === 'FIMMessage') onFinalMessage({ fullText: 'TODO - Mistral FIM' }) - else /* */ sendMistralChat({ messages: messagesArr, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }); - break; - default: - onError({ message: `Error: Void provider was "${providerName}", which is not recognized.`, fullError: null }) - break; + const implementation = sendLLMMessageToProviderImplementation[providerName] + if (!implementation) { + onError({ message: `Error: Provider "${providerName}" not recognized.`, fullError: null }) + return } + const { sendFIM, sendChat } = implementation + if (messagesType === 'chatMessages') { + sendChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName, aiInstructions, tools }) + return + } + if (messagesType === 'FIMMessage') { + if (sendFIM) { + sendFIM({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName, aiInstructions }) + return + } + onError({ message: `Error: This provider does not support Autocomplete yet.`, fullError: null }) + return + } + onError({ message: `Error: Message type "${messagesType}" not recognized.`, fullError: null }) } catch (error) { diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessageChannel.ts b/src/vs/workbench/contrib/void/electron-main/llmMessageChannel.ts index 98725631..d2bceb4c 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessageChannel.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessageChannel.ts @@ -8,30 +8,42 @@ import { IServerChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, MainSendLLMMessageParams, AbortRef, SendLLMMessageParams, MainLLMMessageAbortParams, MainModelListParams, ModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, OllamaModelResponse, OpenaiCompatibleModelResponse, } from '../common/llmMessageTypes.js'; +import { EventLLMMessageOnTextParams, EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, MainSendLLMMessageParams, AbortRef, SendLLMMessageParams, MainLLMMessageAbortParams, ModelListParams, EventModelListOnSuccessParams, EventModelListOnErrorParams, OllamaModelResponse, VLLMModelResponse, MainModelListParams, } from '../common/llmMessageTypes.js'; import { sendLLMMessage } from './llmMessage/sendLLMMessage.js' import { IMetricsService } from '../common/metricsService.js'; -import { ollamaList } from './llmMessage/ollama.js'; -import { openaiCompatibleList } from './llmMessage/openai.js'; +import { sendLLMMessageToProviderImplementation } from './llmMessage/MODELS.js'; // NODE IMPLEMENTATION - calls actual sendLLMMessage() and returns listeners to it export class LLMMessageChannel implements IServerChannel { + // sendLLMMessage - private readonly _onText_llm = new Emitter(); - private readonly _onFinalMessage_llm = new Emitter(); - private readonly _onError_llm = new Emitter(); + private readonly llmMessageEmitters = { + onText: new Emitter(), + onFinalMessage: new Emitter(), + onError: new Emitter(), + } - // abort - private readonly _abortRefOfRequestId_llm: Record = {} + // aborters for above + private readonly abortRefOfRequestId: Record = {} - // ollamaList - private readonly _onSuccess_ollama = new Emitter>(); - private readonly _onError_ollama = new Emitter>(); - // openaiCompatibleList - private readonly _onSuccess_openAICompatible = new Emitter>(); - private readonly _onError_openAICompatible = new Emitter>(); + // list + private readonly listEmitters = { + ollama: { + success: new Emitter>(), + error: new Emitter>(), + }, + vLLM: { + success: new Emitter>(), + error: new Emitter>(), + } + } satisfies { + [providerName: string]: { + success: Emitter>, + error: Emitter>, + } + } // stupidly, channels can't take in @IService constructor( @@ -40,30 +52,17 @@ export class LLMMessageChannel implements IServerChannel { // browser uses this to listen for changes listen(_: unknown, event: string): Event { - if (event === 'onText_llm') { - return this._onText_llm.event; - } - else if (event === 'onFinalMessage_llm') { - return this._onFinalMessage_llm.event; - } - else if (event === 'onError_llm') { - return this._onError_llm.event; - } - else if (event === 'onSuccess_ollama') { - return this._onSuccess_ollama.event; - } - else if (event === 'onError_ollama') { - return this._onError_ollama.event; - } - else if (event === 'onSuccess_openAICompatible') { - return this._onSuccess_openAICompatible.event; - } - else if (event === 'onError_openAICompatible') { - return this._onError_openAICompatible.event; - } - else { - throw new Error(`Event not found: ${event}`); - } + // text + if (event === 'onText_sendLLMMessage') return this.llmMessageEmitters.onText.event; + else if (event === 'onFinalMessage_sendLLMMessage') return this.llmMessageEmitters.onFinalMessage.event; + else if (event === 'onError_sendLLMMessage') return this.llmMessageEmitters.onError.event; + // list + else if (event === 'onSuccess_list_ollama') return this.listEmitters.ollama.success.event; + else if (event === 'onError_list_ollama') return this.listEmitters.ollama.error.event; + else if (event === 'onSuccess_list_vLLM') return this.listEmitters.vLLM.success.event; + else if (event === 'onError_list_vLLM') return this.listEmitters.vLLM.error.event; + + else throw new Error(`Event not found: ${event}`); } // browser uses this to call (see this.channel.call() in llmMessageService.ts for all usages) @@ -78,8 +77,8 @@ export class LLMMessageChannel implements IServerChannel { else if (command === 'ollamaList') { this._callOllamaList(params) } - else if (command === 'openAICompatibleList') { - this._callOpenAICompatibleList(params) + else if (command === 'vLLMList') { + this._callVLLMList(params) } else { throw new Error(`Void sendLLM: command "${command}" not recognized.`) @@ -94,47 +93,50 @@ export class LLMMessageChannel implements IServerChannel { private async _callSendLLMMessage(params: MainSendLLMMessageParams) { const { requestId } = params; - if (!(requestId in this._abortRefOfRequestId_llm)) - this._abortRefOfRequestId_llm[requestId] = { current: null } + if (!(requestId in this.abortRefOfRequestId)) + this.abortRefOfRequestId[requestId] = { current: null } const mainThreadParams: SendLLMMessageParams = { ...params, - onText: ({ newText, fullText }) => { this._onText_llm.fire({ requestId, newText, fullText }); }, - onFinalMessage: ({ fullText }) => { this._onFinalMessage_llm.fire({ requestId, fullText }); }, - onError: ({ message: error, fullError }) => { console.log('sendLLM: firing err'); this._onError_llm.fire({ requestId, message: error, fullError }); }, - abortRef: this._abortRefOfRequestId_llm[requestId], + onText: (p) => { this.llmMessageEmitters.onText.fire({ requestId, ...p }); }, + onFinalMessage: (p) => { this.llmMessageEmitters.onFinalMessage.fire({ requestId, ...p }); }, + onError: (p) => { console.log('sendLLM: firing err'); this.llmMessageEmitters.onError.fire({ requestId, ...p }); }, + abortRef: this.abortRefOfRequestId[requestId], } sendLLMMessage(mainThreadParams, this.metricsService); } - private _callAbort(params: MainLLMMessageAbortParams) { - const { requestId } = params; - if (!(requestId in this._abortRefOfRequestId_llm)) return - this._abortRefOfRequestId_llm[requestId].current?.() - delete this._abortRefOfRequestId_llm[requestId] - } - - private _callOllamaList(params: MainModelListParams) { - const { requestId } = params; - + _callOllamaList = (params: MainModelListParams) => { + const { requestId } = params + const emitters = this.listEmitters.ollama const mainThreadParams: ModelListParams = { ...params, - onSuccess: ({ models }) => { this._onSuccess_ollama.fire({ requestId, models }); }, - onError: ({ error }) => { this._onError_ollama.fire({ requestId, error }); }, + onSuccess: (p) => { emitters.success.fire({ requestId, ...p }); }, + onError: (p) => { emitters.error.fire({ requestId, ...p }); }, } - ollamaList(mainThreadParams) + sendLLMMessageToProviderImplementation.ollama.list(mainThreadParams) } - private _callOpenAICompatibleList(params: MainModelListParams) { - const { requestId } = params; - - const mainThreadParams: ModelListParams = { + _callVLLMList = (params: MainModelListParams) => { + const { requestId } = params + const emitters = this.listEmitters.vLLM + const mainThreadParams: ModelListParams = { ...params, - onSuccess: ({ models }) => { this._onSuccess_openAICompatible.fire({ requestId, models }); }, - onError: ({ error }) => { this._onError_openAICompatible.fire({ requestId, error }); }, + onSuccess: (p) => { emitters.success.fire({ requestId, ...p }); }, + onError: (p) => { emitters.error.fire({ requestId, ...p }); }, } - openaiCompatibleList(mainThreadParams) + sendLLMMessageToProviderImplementation.vLLM.list(mainThreadParams) } + + + + private _callAbort(params: MainLLMMessageAbortParams) { + const { requestId } = params; + if (!(requestId in this.abortRefOfRequestId)) return + this.abortRefOfRequestId[requestId].current?.() + delete this.abortRefOfRequestId[requestId] + } + } diff --git a/src/vs/workbench/contrib/void/electron-main/templates/templates.ts b/src/vs/workbench/contrib/void/electron-main/templates/templates.ts deleted file mode 100644 index 138f7be3..00000000 --- a/src/vs/workbench/contrib/void/electron-main/templates/templates.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - -modelName -> { - system_message_type: 'system' | 'developer' (openai) | null // if null, we will just do a string of system message - supports_tools: boolean // we will just do a string of tool use if it doesn't support - supports_autocomplete_FIM (suffix) // we will just do a description of FIM if it doens't support <|fim_hole|> - - supports_streaming: boolean // (o1 does NOT) we will just dump the final result if doesn't support it - max_tokens: number // required, DEFAULT is Infinity - -} - -*/